Add image annotator example (#3147)
This diff mostly adds an image annotator example, but also has a couple of drive-by changes: - Added a 'use-cases' category to the examples app for this style of mini-app - Add `editor.pageToViewport`, which is like `editor.pageToScreen` but works with viewport coordinates (better for `InFrontOfTheCanvas` stuff) - Prevent the chrome side-swipe-to-go-back thing in the examples app Some cool features of the image annotator: - The image cannot be unlocked, and cannot have shapes places behind it - I still need to work out a way of removing the context menu though - Anything you place outside the bounds of the image (and therefore outside the bounds of the export) will be greyed out - You can't change pages - unless you find the "move to page" action... need to fix that - The camera is constrained! It'll keep the image roughly centered on the screen. If you pick a very long thin image, you can only scroll vertically. If you pick a very big one, it'll default it to a reasonable size. ### Change Type <!-- ❗ Please select a 'Scope' label ❗️ --> - [x] `sdk` — Changes the tldraw SDK - [x] `docs` — Changes to the documentation, examples, or templates. <!-- ❗ Please select a 'Type' label ❗️ --> - [x] `feature` — New feature
This commit is contained in:
parent
d7b80baa31
commit
3a736007e5
23 changed files with 736 additions and 50 deletions
|
@ -8,12 +8,13 @@ const section: InputSection = {
|
|||
title: 'Examples',
|
||||
description: 'Code recipes for bending tldraw to your will.',
|
||||
categories: [
|
||||
{ id: 'basic', title: 'Getting Started', description: '', groups: [], hero: null },
|
||||
{ id: 'ui', title: 'UI & Theming', description: '', groups: [], hero: null },
|
||||
{ id: 'shapes/tools', title: 'Shapes & Tools', description: '', groups: [], hero: null },
|
||||
{ id: 'data/assets', title: 'Data & Assets', description: '', groups: [], hero: null },
|
||||
{ id: 'basic', title: 'Getting started', description: '', groups: [], hero: null },
|
||||
{ id: 'ui', title: 'UI & theming', description: '', groups: [], hero: null },
|
||||
{ id: 'shapes/tools', title: 'Shapes & tools', description: '', groups: [], hero: null },
|
||||
{ id: 'data/assets', title: 'Data & assets', description: '', groups: [], hero: null },
|
||||
{ id: 'editor-api', title: 'Editor API', description: '', groups: [], hero: null },
|
||||
{ id: 'collaboration', title: 'Collaboration', description: '', groups: [], hero: null },
|
||||
{ id: 'use-cases', title: 'Use cases', description: '', groups: [], hero: null },
|
||||
],
|
||||
hero: null,
|
||||
sidebar_behavior: 'show-links',
|
||||
|
|
|
@ -4,7 +4,14 @@ import path from 'path'
|
|||
|
||||
// get all routes from examples/src/examples folder
|
||||
const examplesFolderList = fs.readdirSync(path.join(__dirname, '../../src/examples'))
|
||||
const examplesWithoutCanvas = ['image-component', 'yjs']
|
||||
const examplesWithoutCanvas = [
|
||||
// only shows an image, not the canvas
|
||||
'image-component',
|
||||
// links out to a different repo
|
||||
'yjs',
|
||||
// starts by asking the user to select an image
|
||||
'image-annotator',
|
||||
]
|
||||
const exampelsToTest = examplesFolderList.filter((route) => !examplesWithoutCanvas.includes(route))
|
||||
|
||||
test.describe('Routes', () => {
|
||||
|
|
|
@ -13,7 +13,14 @@ export type Example = {
|
|||
loadComponent: () => Promise<ComponentType>
|
||||
}
|
||||
|
||||
type Category = 'basic' | 'editor-api' | 'ui' | 'collaboration' | 'data/assets' | 'shapes/tools'
|
||||
type Category =
|
||||
| 'basic'
|
||||
| 'editor-api'
|
||||
| 'ui'
|
||||
| 'collaboration'
|
||||
| 'data/assets'
|
||||
| 'shapes/tools'
|
||||
| 'use-cases'
|
||||
|
||||
const getExamplesForCategory = (category: Category) =>
|
||||
(Object.values(import.meta.glob('./examples/*/README.md', { eager: true })) as Example[])
|
||||
|
@ -24,12 +31,13 @@ const getExamplesForCategory = (category: Category) =>
|
|||
})
|
||||
|
||||
const categories: Record<Category, string> = {
|
||||
basic: 'Getting Started',
|
||||
ui: 'UI/Theming',
|
||||
'shapes/tools': 'Shapes & Tools',
|
||||
'data/assets': 'Data & Assets',
|
||||
basic: 'Getting started',
|
||||
ui: 'UI & theming',
|
||||
'shapes/tools': 'Shapes & tools',
|
||||
'data/assets': 'Data & assets',
|
||||
'editor-api': 'Editor API',
|
||||
collaboration: 'Collaboration',
|
||||
'use-cases': 'Use cases',
|
||||
}
|
||||
|
||||
export const examples = Object.entries(categories).map(([category, title]) => ({
|
||||
|
|
|
@ -6,7 +6,6 @@ import {
|
|||
TLEditorComponents,
|
||||
track,
|
||||
useEditor,
|
||||
Vec,
|
||||
} from 'tldraw'
|
||||
import 'tldraw/tldraw.css'
|
||||
|
||||
|
@ -33,10 +32,7 @@ const ContextToolbarComponent = track(() => {
|
|||
if (!size) return null
|
||||
const currentSize = size.type === 'shared' ? size.value : undefined
|
||||
|
||||
const pageCoordinates = Vec.Sub(
|
||||
editor.pageToScreen(selectionRotatedPageBounds.point),
|
||||
editor.getViewportScreenBounds()
|
||||
)
|
||||
const pageCoordinates = editor.pageToViewport(selectionRotatedPageBounds.point)
|
||||
|
||||
return (
|
||||
<div
|
||||
|
|
|
@ -0,0 +1,335 @@
|
|||
import { useCallback, useEffect, useState } from 'react'
|
||||
import {
|
||||
AssetRecordType,
|
||||
Box,
|
||||
Editor,
|
||||
SVGContainer,
|
||||
TLImageShape,
|
||||
TLShapeId,
|
||||
Tldraw,
|
||||
clamp,
|
||||
createShapeId,
|
||||
exportToBlob,
|
||||
getIndexBelow,
|
||||
react,
|
||||
track,
|
||||
useBreakpoint,
|
||||
useEditor,
|
||||
} from 'tldraw'
|
||||
import { PORTRAIT_BREAKPOINT } from 'tldraw/src/lib/ui/constants'
|
||||
import { AnnotatorImage } from './types'
|
||||
|
||||
// TODO:
|
||||
// - prevent changing pages (create page, change page, move shapes to new page)
|
||||
// - prevent locked shape context menu
|
||||
export function ImageAnnotationEditor({
|
||||
image,
|
||||
onDone,
|
||||
}: {
|
||||
image: AnnotatorImage
|
||||
onDone: (result: Blob) => void
|
||||
}) {
|
||||
const [imageShapeId, setImageShapeId] = useState<TLShapeId | null>(null)
|
||||
function onMount(editor: Editor) {
|
||||
editor.updateInstanceState({ isDebugMode: false })
|
||||
|
||||
const assetId = AssetRecordType.createId()
|
||||
editor.createAssets([
|
||||
{
|
||||
id: assetId,
|
||||
typeName: 'asset',
|
||||
type: 'image',
|
||||
meta: {},
|
||||
props: {
|
||||
w: image.width,
|
||||
h: image.height,
|
||||
mimeType: image.type,
|
||||
src: image.src,
|
||||
name: 'image',
|
||||
isAnimated: false,
|
||||
},
|
||||
},
|
||||
])
|
||||
|
||||
const imageId = createShapeId()
|
||||
editor.createShape<TLImageShape>({
|
||||
id: imageId,
|
||||
type: 'image',
|
||||
x: 0,
|
||||
y: 0,
|
||||
isLocked: true,
|
||||
props: {
|
||||
w: image.width,
|
||||
h: image.height,
|
||||
assetId,
|
||||
},
|
||||
})
|
||||
|
||||
editor.history.clear()
|
||||
setImageShapeId(imageId)
|
||||
|
||||
// zoom aaaaallll the way out. our camera constraints will make sure we end up nicely
|
||||
// centered on the image
|
||||
editor.setCamera({ x: 0, y: 0, z: 0.0001 })
|
||||
}
|
||||
|
||||
return (
|
||||
<Tldraw
|
||||
onMount={onMount}
|
||||
components={{
|
||||
// we don't need pages for this use-case
|
||||
PageMenu: null,
|
||||
// grey-out the area outside of the image
|
||||
InFrontOfTheCanvas: useCallback(() => {
|
||||
if (!imageShapeId) return null
|
||||
return <ImageBoundsOverlay imageShapeId={imageShapeId} />
|
||||
}, [imageShapeId]),
|
||||
// add a "done" button in the top right for when the user is ready to export
|
||||
SharePanel: useCallback(() => {
|
||||
if (!imageShapeId) return null
|
||||
return <DoneButton imageShapeId={imageShapeId} onClick={onDone} />
|
||||
}, [imageShapeId, onDone]),
|
||||
}}
|
||||
>
|
||||
{imageShapeId && <KeepShapeAtBottomOfCurrentPage shapeId={imageShapeId} />}
|
||||
{imageShapeId && <KeepShapeLocked shapeId={imageShapeId} />}
|
||||
{imageShapeId && <ConstrainCamera shapeId={imageShapeId} />}
|
||||
</Tldraw>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* When we export, we'll only include the bounds of the image itself, so show an overlay on top of
|
||||
* the canvas to make it clear what will/won't be included. Check `image-annotator.css` for more on
|
||||
* how this works.
|
||||
*/
|
||||
const ImageBoundsOverlay = track(function ImageBoundsOverlay({
|
||||
imageShapeId,
|
||||
}: {
|
||||
imageShapeId: TLShapeId
|
||||
}) {
|
||||
const editor = useEditor()
|
||||
const image = editor.getShape(imageShapeId) as TLImageShape
|
||||
if (!image) return null
|
||||
|
||||
const imagePageBounds = editor.getShapePageBounds(imageShapeId)!
|
||||
const viewport = editor.getViewportScreenBounds()
|
||||
const topLeft = editor.pageToViewport(imagePageBounds)
|
||||
const bottomRight = editor.pageToViewport({ x: imagePageBounds.maxX, y: imagePageBounds.maxY })
|
||||
|
||||
const path = [
|
||||
// start by tracing around the viewport itself:
|
||||
`M ${-10} ${-10}`,
|
||||
`L ${viewport.maxX + 10} ${-10}`,
|
||||
`L ${viewport.maxX + 10} ${viewport.maxY + 10}`,
|
||||
`L ${-10} ${viewport.maxY + 10}`,
|
||||
`Z`,
|
||||
|
||||
// then cut out a hole for the image:
|
||||
`M ${topLeft.x} ${topLeft.y}`,
|
||||
`L ${bottomRight.x} ${topLeft.y}`,
|
||||
`L ${bottomRight.x} ${bottomRight.y}`,
|
||||
`L ${topLeft.x} ${bottomRight.y}`,
|
||||
`Z`,
|
||||
].join(' ')
|
||||
|
||||
return (
|
||||
<SVGContainer className="ImageOverlayScreen">
|
||||
<path d={path} fillRule="evenodd" />
|
||||
</SVGContainer>
|
||||
)
|
||||
})
|
||||
|
||||
function DoneButton({
|
||||
imageShapeId,
|
||||
onClick,
|
||||
}: {
|
||||
imageShapeId: TLShapeId
|
||||
onClick: (result: Blob) => void
|
||||
}) {
|
||||
const editor = useEditor()
|
||||
return (
|
||||
<button
|
||||
className="DoneButton"
|
||||
onClick={async () => {
|
||||
const blob = await exportToBlob({
|
||||
editor,
|
||||
ids: Array.from(editor.getCurrentPageShapeIds()),
|
||||
format: 'png',
|
||||
opts: {
|
||||
background: true,
|
||||
bounds: editor.getShapePageBounds(imageShapeId)!,
|
||||
padding: 0,
|
||||
scale: 1,
|
||||
},
|
||||
})
|
||||
|
||||
onClick(blob)
|
||||
}}
|
||||
>
|
||||
Done
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* We want to keep our locked image at the bottom of the current page - people shouldn't be able to
|
||||
* place other shapes beneath it. This component adds side effects for when shapes are created or
|
||||
* updated to make sure that this shape is always kept at the bottom.
|
||||
*/
|
||||
function KeepShapeAtBottomOfCurrentPage({ shapeId }: { shapeId: TLShapeId }) {
|
||||
const editor = useEditor()
|
||||
|
||||
useEffect(() => {
|
||||
function makeSureShapeIsAtBottom() {
|
||||
let shape = editor.getShape(shapeId)
|
||||
if (!shape) return
|
||||
const pageId = editor.getCurrentPageId()
|
||||
|
||||
if (shape.parentId !== pageId) {
|
||||
editor.moveShapesToPage([shape], pageId)
|
||||
shape = editor.getShape(shapeId)!
|
||||
}
|
||||
|
||||
const siblings = editor.getSortedChildIdsForParent(pageId)
|
||||
const currentBottomShape = editor.getShape(siblings[0])!
|
||||
if (currentBottomShape.id === shapeId) return
|
||||
|
||||
editor.updateShape({
|
||||
id: shape.id,
|
||||
type: shape.type,
|
||||
isLocked: shape.isLocked,
|
||||
index: getIndexBelow(currentBottomShape.index),
|
||||
})
|
||||
}
|
||||
|
||||
makeSureShapeIsAtBottom()
|
||||
|
||||
const removeOnCreate = editor.sideEffects.registerAfterCreateHandler(
|
||||
'shape',
|
||||
makeSureShapeIsAtBottom
|
||||
)
|
||||
const removeOnChange = editor.sideEffects.registerAfterChangeHandler(
|
||||
'shape',
|
||||
makeSureShapeIsAtBottom
|
||||
)
|
||||
|
||||
return () => {
|
||||
removeOnCreate()
|
||||
removeOnChange()
|
||||
}
|
||||
}, [editor, shapeId])
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
function KeepShapeLocked({ shapeId }: { shapeId: TLShapeId }) {
|
||||
const editor = useEditor()
|
||||
|
||||
useEffect(() => {
|
||||
const shape = editor.getShape(shapeId)
|
||||
if (!shape) return
|
||||
|
||||
editor.updateShape({
|
||||
id: shape.id,
|
||||
type: shape.type,
|
||||
isLocked: true,
|
||||
})
|
||||
|
||||
const removeOnChange = editor.sideEffects.registerBeforeChangeHandler('shape', (prev, next) => {
|
||||
if (next.id !== shapeId) return next
|
||||
if (next.isLocked) return next
|
||||
return { ...prev, isLocked: true }
|
||||
})
|
||||
|
||||
return () => {
|
||||
removeOnChange()
|
||||
}
|
||||
}, [editor, shapeId])
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
/**
|
||||
* We don't want the user to be able to scroll away from the image, or zoom it all the way out. This
|
||||
* component hooks into camera updates to keep the camera constrained - try uploading a very long,
|
||||
* thin image and seeing how the camera behaves.
|
||||
*/
|
||||
function ConstrainCamera({ shapeId }: { shapeId: TLShapeId }) {
|
||||
const editor = useEditor()
|
||||
const breakpoint = useBreakpoint()
|
||||
const isMobile = breakpoint < PORTRAIT_BREAKPOINT.TABLET_SM
|
||||
|
||||
useEffect(() => {
|
||||
const marginTop = 44
|
||||
const marginSide = isMobile ? 16 : 164
|
||||
const marginBottom = 60
|
||||
|
||||
function constrainCamera(camera: { x: number; y: number; z: number }): {
|
||||
x: number
|
||||
y: number
|
||||
z: number
|
||||
} {
|
||||
const viewportBounds = editor.getViewportScreenBounds()
|
||||
const targetBounds = editor.getShapePageBounds(shapeId)!
|
||||
|
||||
const usableViewport = new Box(
|
||||
marginSide,
|
||||
marginTop,
|
||||
viewportBounds.w - marginSide * 2,
|
||||
viewportBounds.h - marginTop - marginBottom
|
||||
)
|
||||
|
||||
const minZoom = Math.min(
|
||||
usableViewport.w / targetBounds.w,
|
||||
usableViewport.h / targetBounds.h,
|
||||
1
|
||||
)
|
||||
const zoom = Math.max(minZoom, camera.z)
|
||||
|
||||
const centerX = targetBounds.x - targetBounds.w / 2 + usableViewport.midX / zoom
|
||||
const centerY = targetBounds.y - targetBounds.h / 2 + usableViewport.midY / zoom
|
||||
|
||||
const availableXMovement = Math.max(0, targetBounds.w - usableViewport.w / zoom)
|
||||
const availableYMovement = Math.max(0, targetBounds.h - usableViewport.h / zoom)
|
||||
|
||||
return {
|
||||
x: clamp(camera.x, centerX - availableXMovement / 2, centerX + availableXMovement / 2),
|
||||
y: clamp(camera.y, centerY - availableYMovement / 2, centerY + availableYMovement / 2),
|
||||
z: zoom,
|
||||
}
|
||||
}
|
||||
|
||||
const removeOnChange = editor.sideEffects.registerBeforeChangeHandler(
|
||||
'camera',
|
||||
(_prev, next) => {
|
||||
const constrained = constrainCamera(next)
|
||||
if (constrained.x === next.x && constrained.y === next.y && constrained.z === next.z)
|
||||
return next
|
||||
return { ...next, ...constrained }
|
||||
}
|
||||
)
|
||||
|
||||
const removeReaction = react('update camera when viewport/shape changes', () => {
|
||||
const original = editor.getCamera()
|
||||
const constrained = constrainCamera(original)
|
||||
if (
|
||||
original.x === constrained.x &&
|
||||
original.y === constrained.y &&
|
||||
original.z === constrained.z
|
||||
) {
|
||||
return
|
||||
}
|
||||
|
||||
// this needs to be in a microtask for some reason, but idk why
|
||||
queueMicrotask(() => editor.setCamera(constrained))
|
||||
})
|
||||
|
||||
return () => {
|
||||
removeOnChange()
|
||||
removeReaction()
|
||||
}
|
||||
}, [editor, isMobile, shapeId])
|
||||
|
||||
return null
|
||||
}
|
|
@ -0,0 +1,54 @@
|
|||
import { useState } from 'react'
|
||||
import { ImageAnnotationEditor } from './ImageAnnotationEditor'
|
||||
import { ImageExport } from './ImageExport'
|
||||
import { ImagePicker } from './ImagePicker'
|
||||
import './image-annotator.css'
|
||||
import { AnnotatorImage } from './types'
|
||||
|
||||
type State =
|
||||
| {
|
||||
phase: 'pick'
|
||||
}
|
||||
| {
|
||||
phase: 'annotate'
|
||||
id: string
|
||||
image: AnnotatorImage
|
||||
}
|
||||
| {
|
||||
phase: 'export'
|
||||
result: Blob
|
||||
}
|
||||
|
||||
export default function ImageAnnotatorWrapper() {
|
||||
const [state, setState] = useState<State>({ phase: 'pick' })
|
||||
|
||||
switch (state.phase) {
|
||||
case 'pick':
|
||||
return (
|
||||
<div className="ImageAnnotator">
|
||||
<ImagePicker
|
||||
onChooseImage={(image) =>
|
||||
setState({ phase: 'annotate', image, id: Math.random().toString(36) })
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
case 'annotate':
|
||||
return (
|
||||
<div className="ImageAnnotator">
|
||||
<ImageAnnotationEditor
|
||||
// remount tldraw if the image/id changes:
|
||||
key={state.id}
|
||||
image={state.image}
|
||||
onDone={(result) => setState({ phase: 'export', result })}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
case 'export':
|
||||
return (
|
||||
<div className="ImageAnnotator">
|
||||
<ImageExport result={state.result} onStartAgain={() => setState({ phase: 'pick' })} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
}
|
41
apps/examples/src/examples/image-annotator/ImageExport.tsx
Normal file
41
apps/examples/src/examples/image-annotator/ImageExport.tsx
Normal file
|
@ -0,0 +1,41 @@
|
|||
import { useEffect, useLayoutEffect, useState } from 'react'
|
||||
|
||||
export function ImageExport({ result, onStartAgain }: { result: Blob; onStartAgain: () => void }) {
|
||||
const [src, setSrc] = useState<string | null>(null)
|
||||
useLayoutEffect(() => {
|
||||
const url = URL.createObjectURL(result)
|
||||
setSrc(url)
|
||||
return () => URL.revokeObjectURL(url)
|
||||
}, [result])
|
||||
|
||||
function onDownload() {
|
||||
if (!src) return
|
||||
|
||||
const a = document.createElement('a')
|
||||
a.href = src
|
||||
a.download = 'annotated-image.png'
|
||||
a.click()
|
||||
}
|
||||
|
||||
const [didCopy, setDidCopy] = useState(false)
|
||||
function onCopy() {
|
||||
navigator.clipboard.write([new ClipboardItem({ [result.type]: result })])
|
||||
setDidCopy(true)
|
||||
}
|
||||
useEffect(() => {
|
||||
if (!didCopy) return
|
||||
const t = setTimeout(() => setDidCopy(false), 2000)
|
||||
return () => clearTimeout(t)
|
||||
}, [didCopy])
|
||||
|
||||
return (
|
||||
<div className="ImageExport">
|
||||
{src && <img src={src} />}
|
||||
<div className="ImageExport-buttons">
|
||||
<button onClick={onCopy}>{didCopy ? 'Copied!' : 'Copy'}</button>
|
||||
<button onClick={onDownload}>Download</button>
|
||||
</div>
|
||||
<button onClick={onStartAgain}>Start Again</button>
|
||||
</div>
|
||||
)
|
||||
}
|
69
apps/examples/src/examples/image-annotator/ImagePicker.tsx
Normal file
69
apps/examples/src/examples/image-annotator/ImagePicker.tsx
Normal file
|
@ -0,0 +1,69 @@
|
|||
import { useState } from 'react'
|
||||
import { FileHelpers, MediaHelpers } from 'tldraw'
|
||||
import anakin from './assets/anakin.jpeg'
|
||||
import distractedBf from './assets/distracted-bf.jpeg'
|
||||
import expandingBrain from './assets/expanding-brain.png'
|
||||
|
||||
export function ImagePicker({
|
||||
onChooseImage,
|
||||
}: {
|
||||
onChooseImage: (image: { src: string; width: number; height: number; type: string }) => void
|
||||
}) {
|
||||
const [isLoading, setIsLoading] = useState(false)
|
||||
function onClickChooseImage() {
|
||||
const input = window.document.createElement('input')
|
||||
input.type = 'file'
|
||||
input.accept = 'image/jpeg,image/png,image/gif,image/svg+xml,video/mp4,video/quicktime'
|
||||
input.addEventListener('change', async (e) => {
|
||||
const fileList = (e.target as HTMLInputElement).files
|
||||
if (!fileList || fileList.length === 0) return
|
||||
const file = fileList[0]
|
||||
|
||||
setIsLoading(true)
|
||||
try {
|
||||
const dataUrl = await FileHelpers.blobToDataUrl(file)
|
||||
const { w, h } = await MediaHelpers.getImageSize(file)
|
||||
onChooseImage({ src: dataUrl, width: w, height: h, type: file.type })
|
||||
} finally {
|
||||
setIsLoading(false)
|
||||
}
|
||||
})
|
||||
input.click()
|
||||
}
|
||||
|
||||
async function onChooseExample(src: string) {
|
||||
setIsLoading(true)
|
||||
try {
|
||||
const image = await fetch(src)
|
||||
const blob = await image.blob()
|
||||
const { w, h } = await MediaHelpers.getImageSize(blob)
|
||||
onChooseImage({ src, width: w, height: h, type: blob.type })
|
||||
} finally {
|
||||
setIsLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
if (isLoading) {
|
||||
return <div className="ImagePicker">Loading...</div>
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="ImagePicker">
|
||||
<button onClick={onClickChooseImage}>Choose an image</button>
|
||||
<div className="ImagePicker-exampleLabel">or use an example:</div>
|
||||
<div className="ImagePicker-examples">
|
||||
<img src={anakin} alt="anakin" onClick={() => onChooseExample(anakin)} />
|
||||
<img
|
||||
src={distractedBf}
|
||||
alt="distracted boyfriend"
|
||||
onClick={() => onChooseExample(distractedBf)}
|
||||
/>
|
||||
<img
|
||||
src={expandingBrain}
|
||||
alt="expanding brain"
|
||||
onClick={() => onChooseExample(expandingBrain)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
8
apps/examples/src/examples/image-annotator/README.md
Normal file
8
apps/examples/src/examples/image-annotator/README.md
Normal file
|
@ -0,0 +1,8 @@
|
|||
---
|
||||
title: Image annotator
|
||||
component: ./ImageAnnotator.tsx
|
||||
category: use-cases
|
||||
priority: 1
|
||||
---
|
||||
|
||||
An image annotator built with tldraw
|
BIN
apps/examples/src/examples/image-annotator/assets/anakin.jpeg
Normal file
BIN
apps/examples/src/examples/image-annotator/assets/anakin.jpeg
Normal file
Binary file not shown.
After Width: | Height: | Size: 190 KiB |
Binary file not shown.
After Width: | Height: | Size: 48 KiB |
Binary file not shown.
After Width: | Height: | Size: 78 KiB |
108
apps/examples/src/examples/image-annotator/image-annotator.css
Normal file
108
apps/examples/src/examples/image-annotator/image-annotator.css
Normal file
|
@ -0,0 +1,108 @@
|
|||
.ImageAnnotator {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
}
|
||||
|
||||
.ImageAnnotator .ImagePicker {
|
||||
position: absolute;
|
||||
inset: 1rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
text-align: center;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
}
|
||||
.ImageAnnotator .ImagePicker button {
|
||||
padding: 0.5rem 1rem;
|
||||
border: none;
|
||||
background: #eee;
|
||||
cursor: pointer;
|
||||
font: inherit;
|
||||
}
|
||||
.ImageAnnotator .ImagePicker button:hover {
|
||||
opacity: 0.9;
|
||||
}
|
||||
.ImageAnnotator .ImagePicker-exampleLabel {
|
||||
padding-top: 1rem;
|
||||
opacity: 0.7;
|
||||
font-size: 14px;
|
||||
}
|
||||
.ImageAnnotator .ImagePicker-examples {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
width: 100%;
|
||||
max-width: 780px;
|
||||
gap: 1rem;
|
||||
}
|
||||
.ImageAnnotator .ImagePicker-examples img {
|
||||
width: 100%;
|
||||
height: auto;
|
||||
object-fit: contain;
|
||||
aspect-ratio: 1;
|
||||
cursor: pointer;
|
||||
}
|
||||
.ImageAnnotator .ImagePicker-examples img:hover {
|
||||
opacity: 0.9;
|
||||
}
|
||||
|
||||
.ImageAnnotator .ImageOverlayScreen {
|
||||
pointer-events: none;
|
||||
z-index: -1;
|
||||
fill: var(--color-background);
|
||||
fill-opacity: 0.8;
|
||||
stroke: none;
|
||||
}
|
||||
|
||||
.ImageAnnotator .DoneButton {
|
||||
font: inherit;
|
||||
background: var(--color-primary);
|
||||
border: none;
|
||||
color: var(--color-selected-contrast);
|
||||
font-size: 1rem;
|
||||
padding: 0.5rem 1rem;
|
||||
border-radius: 6px;
|
||||
margin: 6px;
|
||||
pointer-events: all;
|
||||
z-index: var(--layer-panels);
|
||||
border: 2px solid var(--color-background);
|
||||
cursor: pointer;
|
||||
}
|
||||
.ImageAnnotator .DoneButton:hover {
|
||||
filter: brightness(1.1);
|
||||
}
|
||||
|
||||
.ImageAnnotator .ImageExport {
|
||||
padding: 1rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
align-items: center;
|
||||
height: 100%;
|
||||
}
|
||||
.ImageAnnotator .ImageExport img {
|
||||
width: 100%;
|
||||
max-height: 50vh;
|
||||
object-fit: contain;
|
||||
}
|
||||
.ImageAnnotator .ImageExport button {
|
||||
padding: 0.5rem 1rem;
|
||||
border: none;
|
||||
background: #eee;
|
||||
cursor: pointer;
|
||||
font: inherit;
|
||||
}
|
||||
.ImageAnnotator .ImageExport button:hover {
|
||||
opacity: 0.9;
|
||||
}
|
||||
.ImageAnnotator .ImageExport-buttons {
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
margin-bottom: auto;
|
||||
}
|
||||
.ImageAnnotator .ImageExport-buttons button {
|
||||
background-color: hsl(214, 84%, 56%);
|
||||
color: white;
|
||||
}
|
6
apps/examples/src/examples/image-annotator/types.tsx
Normal file
6
apps/examples/src/examples/image-annotator/types.tsx
Normal file
|
@ -0,0 +1,6 @@
|
|||
export interface AnnotatorImage {
|
||||
src: string
|
||||
width: number
|
||||
height: number
|
||||
type: string
|
||||
}
|
|
@ -7,7 +7,6 @@ import {
|
|||
TLUiOverrides,
|
||||
Tldraw,
|
||||
TldrawUiMenuItem,
|
||||
Vec,
|
||||
useEditor,
|
||||
useIsToolSelected,
|
||||
useTools,
|
||||
|
@ -79,10 +78,7 @@ function ScreenshotBox() {
|
|||
// "page space", i.e. uneffected by scale, and relative to the tldraw
|
||||
// page's top left corner.
|
||||
const zoomLevel = editor.getZoomLevel()
|
||||
const { x, y } = Vec.Sub(
|
||||
editor.pageToScreen({ x: box.x, y: box.y }),
|
||||
editor.getViewportScreenBounds()
|
||||
)
|
||||
const { x, y } = editor.pageToViewport({ x: box.x, y: box.y })
|
||||
return new Box(x, y, box.w * zoomLevel, box.h * zoomLevel)
|
||||
},
|
||||
[editor]
|
||||
|
|
|
@ -1,5 +1,5 @@
|
|||
import { useState } from 'react'
|
||||
import { stopEventPropagation, Tldraw, TLEditorComponents, track, useEditor, Vec } from 'tldraw'
|
||||
import { stopEventPropagation, Tldraw, TLEditorComponents, track, useEditor } from 'tldraw'
|
||||
import 'tldraw/tldraw.css'
|
||||
|
||||
// There's a guide at the bottom of this file!
|
||||
|
@ -60,10 +60,7 @@ const MyComponentInFront = track(() => {
|
|||
const selectionRotatedPageBounds = editor.getSelectionRotatedPageBounds()
|
||||
if (!selectionRotatedPageBounds) return null
|
||||
|
||||
const pageCoordinates = Vec.Sub(
|
||||
editor.pageToScreen(selectionRotatedPageBounds.point),
|
||||
editor.getViewportScreenBounds()
|
||||
)
|
||||
const pageCoordinates = editor.pageToViewport(selectionRotatedPageBounds.point)
|
||||
|
||||
return (
|
||||
<div
|
||||
|
|
|
@ -19,7 +19,7 @@ import EndToEnd from './misc/end-to-end'
|
|||
const assetUrls = getAssetUrlsByMetaUrl()
|
||||
setDefaultEditorAssetUrls(assetUrls)
|
||||
setDefaultUiAssetUrls(assetUrls)
|
||||
const gettingStartedExamples = examples.find((e) => e.id === 'Getting Started')
|
||||
const gettingStartedExamples = examples.find((e) => e.id === 'Getting started')
|
||||
if (!gettingStartedExamples) throw new Error('Could not find getting started exmaples')
|
||||
const basicExample = gettingStartedExamples.value.find((e) => e.title === 'Persistence key')
|
||||
if (!basicExample) throw new Error('Could not find initial example')
|
||||
|
|
|
@ -17,6 +17,8 @@ body {
|
|||
/* mobile viewport bug fix */
|
||||
min-height: -webkit-fill-available;
|
||||
height: 100%;
|
||||
/* prevent two-finger swipe to go back */
|
||||
overscroll-behavior-x: none;
|
||||
}
|
||||
@media screen and (max-width: 600px) {
|
||||
html,
|
||||
|
|
|
@ -819,6 +819,11 @@ export class Editor extends EventEmitter<TLEventMap> {
|
|||
y: number;
|
||||
z: number;
|
||||
};
|
||||
pageToViewport(point: VecLike): {
|
||||
x: number;
|
||||
y: number;
|
||||
z: number;
|
||||
};
|
||||
pan(offset: VecLike, animation?: TLAnimationOptions): this;
|
||||
panZoomIntoView(ids: TLShapeId[], animation?: TLAnimationOptions): this;
|
||||
popFocusedGroupId(): this;
|
||||
|
|
|
@ -15423,7 +15423,7 @@
|
|||
{
|
||||
"kind": "Method",
|
||||
"canonicalReference": "@tldraw/editor!Editor#pageToScreen:member(1)",
|
||||
"docComment": "/**\n * Convert a point in the current page space to a point in current screen space.\n *\n * @param point - The point in screen space.\n *\n * @example\n * ```ts\n * editor.pageToScreen({ x: 100, y: 100 })\n * ```\n *\n * @public\n */\n",
|
||||
"docComment": "/**\n * Convert a point in the current page space to a point in current screen space.\n *\n * @param point - The point in page space.\n *\n * @example\n * ```ts\n * editor.pageToScreen({ x: 100, y: 100 })\n * ```\n *\n * @public\n */\n",
|
||||
"excerptTokens": [
|
||||
{
|
||||
"kind": "Content",
|
||||
|
@ -15469,6 +15469,55 @@
|
|||
"isAbstract": false,
|
||||
"name": "pageToScreen"
|
||||
},
|
||||
{
|
||||
"kind": "Method",
|
||||
"canonicalReference": "@tldraw/editor!Editor#pageToViewport:member(1)",
|
||||
"docComment": "/**\n * Convert a point in the current page space to a point in current viewport space.\n *\n * @param point - The point in page space.\n *\n * @example\n * ```ts\n * editor.pageToViewport({ x: 100, y: 100 })\n * ```\n *\n * @public\n */\n",
|
||||
"excerptTokens": [
|
||||
{
|
||||
"kind": "Content",
|
||||
"text": "pageToViewport(point: "
|
||||
},
|
||||
{
|
||||
"kind": "Reference",
|
||||
"text": "VecLike",
|
||||
"canonicalReference": "@tldraw/editor!VecLike:type"
|
||||
},
|
||||
{
|
||||
"kind": "Content",
|
||||
"text": "): "
|
||||
},
|
||||
{
|
||||
"kind": "Content",
|
||||
"text": "{\n x: number;\n y: number;\n z: number;\n }"
|
||||
},
|
||||
{
|
||||
"kind": "Content",
|
||||
"text": ";"
|
||||
}
|
||||
],
|
||||
"isStatic": false,
|
||||
"returnTypeTokenRange": {
|
||||
"startIndex": 3,
|
||||
"endIndex": 4
|
||||
},
|
||||
"releaseTag": "Public",
|
||||
"isProtected": false,
|
||||
"overloadIndex": 1,
|
||||
"parameters": [
|
||||
{
|
||||
"parameterName": "point",
|
||||
"parameterTypeTokenRange": {
|
||||
"startIndex": 1,
|
||||
"endIndex": 2
|
||||
},
|
||||
"isOptional": false
|
||||
}
|
||||
],
|
||||
"isOptional": false,
|
||||
"isAbstract": false,
|
||||
"name": "pageToViewport"
|
||||
},
|
||||
{
|
||||
"kind": "Method",
|
||||
"canonicalReference": "@tldraw/editor!Editor#pan:member(1)",
|
||||
|
|
|
@ -2865,7 +2865,7 @@ export class Editor extends EventEmitter<TLEventMap> {
|
|||
* editor.pageToScreen({ x: 100, y: 100 })
|
||||
* ```
|
||||
*
|
||||
* @param point - The point in screen space.
|
||||
* @param point - The point in page space.
|
||||
*
|
||||
* @public
|
||||
*/
|
||||
|
@ -2880,6 +2880,28 @@ export class Editor extends EventEmitter<TLEventMap> {
|
|||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert a point in the current page space to a point in current viewport space.
|
||||
*
|
||||
* @example
|
||||
* ```ts
|
||||
* editor.pageToViewport({ x: 100, y: 100 })
|
||||
* ```
|
||||
*
|
||||
* @param point - The point in page space.
|
||||
*
|
||||
* @public
|
||||
*/
|
||||
pageToViewport(point: VecLike) {
|
||||
const { x: cx, y: cy, z: cz = 1 } = this.getCamera()
|
||||
|
||||
return {
|
||||
x: (point.x + cx) * cz,
|
||||
y: (point.y + cy) * cz,
|
||||
z: point.z ?? 0.5,
|
||||
}
|
||||
}
|
||||
|
||||
// Following
|
||||
|
||||
/**
|
||||
|
|
|
@ -3,7 +3,7 @@ import { createUseGesture, pinchAction, wheelAction } from '@use-gesture/react'
|
|||
import * as React from 'react'
|
||||
import { TLWheelEventInfo } from '../editor/types/event-types'
|
||||
import { Vec } from '../primitives/Vec'
|
||||
import { preventDefault } from '../utils/dom'
|
||||
import { preventDefault, stopEventPropagation } from '../utils/dom'
|
||||
import { normalizeWheel } from '../utils/normalizeWheel'
|
||||
import { useEditor } from './useEditor'
|
||||
|
||||
|
@ -112,6 +112,7 @@ export function useGestureEvents(ref: React.RefObject<HTMLDivElement>) {
|
|||
}
|
||||
|
||||
preventDefault(event)
|
||||
stopEventPropagation(event)
|
||||
const delta = normalizeWheel(event)
|
||||
|
||||
if (delta.x === 0 && delta.y === 0) return
|
||||
|
|
|
@ -11,13 +11,13 @@ import {
|
|||
TLStoreWithStatus,
|
||||
TldrawEditor,
|
||||
TldrawEditorBaseProps,
|
||||
assert,
|
||||
useEditor,
|
||||
useEditorComponents,
|
||||
useEvent,
|
||||
useShallowArrayIdentity,
|
||||
useShallowObjectIdentity,
|
||||
} from '@tldraw/editor'
|
||||
import { useCallback, useDebugValue, useLayoutEffect, useMemo, useRef } from 'react'
|
||||
import { useLayoutEffect, useMemo } from 'react'
|
||||
import { TldrawHandles } from './canvas/TldrawHandles'
|
||||
import { TldrawHoveredShapeIndicator } from './canvas/TldrawHoveredShapeIndicator'
|
||||
import { TldrawScribble } from './canvas/TldrawScribble'
|
||||
|
@ -209,22 +209,3 @@ function InsideOfEditorAndUiContext({
|
|||
|
||||
return null
|
||||
}
|
||||
|
||||
// duped from tldraw editor
|
||||
function useEvent<Args extends Array<unknown>, Result>(
|
||||
handler: (...args: Args) => Result
|
||||
): (...args: Args) => Result {
|
||||
const handlerRef = useRef<(...args: Args) => Result>()
|
||||
|
||||
useLayoutEffect(() => {
|
||||
handlerRef.current = handler
|
||||
})
|
||||
|
||||
useDebugValue(handler)
|
||||
|
||||
return useCallback((...args: Args) => {
|
||||
const fn = handlerRef.current
|
||||
assert(fn, 'fn does not exist')
|
||||
return fn(...args)
|
||||
}, [])
|
||||
}
|
||||
|
|
Loading…
Reference in a new issue