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',
|
title: 'Examples',
|
||||||
description: 'Code recipes for bending tldraw to your will.',
|
description: 'Code recipes for bending tldraw to your will.',
|
||||||
categories: [
|
categories: [
|
||||||
{ id: 'basic', title: 'Getting Started', description: '', groups: [], hero: null },
|
{ id: 'basic', title: 'Getting started', description: '', groups: [], hero: null },
|
||||||
{ id: 'ui', title: 'UI & Theming', description: '', groups: [], hero: null },
|
{ id: 'ui', title: 'UI & theming', description: '', groups: [], hero: null },
|
||||||
{ id: 'shapes/tools', title: 'Shapes & Tools', 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: 'data/assets', title: 'Data & assets', description: '', groups: [], hero: null },
|
||||||
{ id: 'editor-api', title: 'Editor API', description: '', groups: [], hero: null },
|
{ id: 'editor-api', title: 'Editor API', description: '', groups: [], hero: null },
|
||||||
{ id: 'collaboration', title: 'Collaboration', description: '', groups: [], hero: null },
|
{ id: 'collaboration', title: 'Collaboration', description: '', groups: [], hero: null },
|
||||||
|
{ id: 'use-cases', title: 'Use cases', description: '', groups: [], hero: null },
|
||||||
],
|
],
|
||||||
hero: null,
|
hero: null,
|
||||||
sidebar_behavior: 'show-links',
|
sidebar_behavior: 'show-links',
|
||||||
|
|
|
@ -4,7 +4,14 @@ import path from 'path'
|
||||||
|
|
||||||
// get all routes from examples/src/examples folder
|
// get all routes from examples/src/examples folder
|
||||||
const examplesFolderList = fs.readdirSync(path.join(__dirname, '../../src/examples'))
|
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))
|
const exampelsToTest = examplesFolderList.filter((route) => !examplesWithoutCanvas.includes(route))
|
||||||
|
|
||||||
test.describe('Routes', () => {
|
test.describe('Routes', () => {
|
||||||
|
|
|
@ -13,7 +13,14 @@ export type Example = {
|
||||||
loadComponent: () => Promise<ComponentType>
|
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) =>
|
const getExamplesForCategory = (category: Category) =>
|
||||||
(Object.values(import.meta.glob('./examples/*/README.md', { eager: true })) as Example[])
|
(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> = {
|
const categories: Record<Category, string> = {
|
||||||
basic: 'Getting Started',
|
basic: 'Getting started',
|
||||||
ui: 'UI/Theming',
|
ui: 'UI & theming',
|
||||||
'shapes/tools': 'Shapes & Tools',
|
'shapes/tools': 'Shapes & tools',
|
||||||
'data/assets': 'Data & Assets',
|
'data/assets': 'Data & assets',
|
||||||
'editor-api': 'Editor API',
|
'editor-api': 'Editor API',
|
||||||
collaboration: 'Collaboration',
|
collaboration: 'Collaboration',
|
||||||
|
'use-cases': 'Use cases',
|
||||||
}
|
}
|
||||||
|
|
||||||
export const examples = Object.entries(categories).map(([category, title]) => ({
|
export const examples = Object.entries(categories).map(([category, title]) => ({
|
||||||
|
|
|
@ -6,7 +6,6 @@ import {
|
||||||
TLEditorComponents,
|
TLEditorComponents,
|
||||||
track,
|
track,
|
||||||
useEditor,
|
useEditor,
|
||||||
Vec,
|
|
||||||
} from 'tldraw'
|
} from 'tldraw'
|
||||||
import 'tldraw/tldraw.css'
|
import 'tldraw/tldraw.css'
|
||||||
|
|
||||||
|
@ -33,10 +32,7 @@ const ContextToolbarComponent = track(() => {
|
||||||
if (!size) return null
|
if (!size) return null
|
||||||
const currentSize = size.type === 'shared' ? size.value : undefined
|
const currentSize = size.type === 'shared' ? size.value : undefined
|
||||||
|
|
||||||
const pageCoordinates = Vec.Sub(
|
const pageCoordinates = editor.pageToViewport(selectionRotatedPageBounds.point)
|
||||||
editor.pageToScreen(selectionRotatedPageBounds.point),
|
|
||||||
editor.getViewportScreenBounds()
|
|
||||||
)
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<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,
|
TLUiOverrides,
|
||||||
Tldraw,
|
Tldraw,
|
||||||
TldrawUiMenuItem,
|
TldrawUiMenuItem,
|
||||||
Vec,
|
|
||||||
useEditor,
|
useEditor,
|
||||||
useIsToolSelected,
|
useIsToolSelected,
|
||||||
useTools,
|
useTools,
|
||||||
|
@ -79,10 +78,7 @@ function ScreenshotBox() {
|
||||||
// "page space", i.e. uneffected by scale, and relative to the tldraw
|
// "page space", i.e. uneffected by scale, and relative to the tldraw
|
||||||
// page's top left corner.
|
// page's top left corner.
|
||||||
const zoomLevel = editor.getZoomLevel()
|
const zoomLevel = editor.getZoomLevel()
|
||||||
const { x, y } = Vec.Sub(
|
const { x, y } = editor.pageToViewport({ x: box.x, y: box.y })
|
||||||
editor.pageToScreen({ x: box.x, y: box.y }),
|
|
||||||
editor.getViewportScreenBounds()
|
|
||||||
)
|
|
||||||
return new Box(x, y, box.w * zoomLevel, box.h * zoomLevel)
|
return new Box(x, y, box.w * zoomLevel, box.h * zoomLevel)
|
||||||
},
|
},
|
||||||
[editor]
|
[editor]
|
||||||
|
|
|
@ -1,5 +1,5 @@
|
||||||
import { useState } from 'react'
|
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'
|
import 'tldraw/tldraw.css'
|
||||||
|
|
||||||
// There's a guide at the bottom of this file!
|
// There's a guide at the bottom of this file!
|
||||||
|
@ -60,10 +60,7 @@ const MyComponentInFront = track(() => {
|
||||||
const selectionRotatedPageBounds = editor.getSelectionRotatedPageBounds()
|
const selectionRotatedPageBounds = editor.getSelectionRotatedPageBounds()
|
||||||
if (!selectionRotatedPageBounds) return null
|
if (!selectionRotatedPageBounds) return null
|
||||||
|
|
||||||
const pageCoordinates = Vec.Sub(
|
const pageCoordinates = editor.pageToViewport(selectionRotatedPageBounds.point)
|
||||||
editor.pageToScreen(selectionRotatedPageBounds.point),
|
|
||||||
editor.getViewportScreenBounds()
|
|
||||||
)
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
|
|
|
@ -19,7 +19,7 @@ import EndToEnd from './misc/end-to-end'
|
||||||
const assetUrls = getAssetUrlsByMetaUrl()
|
const assetUrls = getAssetUrlsByMetaUrl()
|
||||||
setDefaultEditorAssetUrls(assetUrls)
|
setDefaultEditorAssetUrls(assetUrls)
|
||||||
setDefaultUiAssetUrls(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')
|
if (!gettingStartedExamples) throw new Error('Could not find getting started exmaples')
|
||||||
const basicExample = gettingStartedExamples.value.find((e) => e.title === 'Persistence key')
|
const basicExample = gettingStartedExamples.value.find((e) => e.title === 'Persistence key')
|
||||||
if (!basicExample) throw new Error('Could not find initial example')
|
if (!basicExample) throw new Error('Could not find initial example')
|
||||||
|
|
|
@ -17,6 +17,8 @@ body {
|
||||||
/* mobile viewport bug fix */
|
/* mobile viewport bug fix */
|
||||||
min-height: -webkit-fill-available;
|
min-height: -webkit-fill-available;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
|
/* prevent two-finger swipe to go back */
|
||||||
|
overscroll-behavior-x: none;
|
||||||
}
|
}
|
||||||
@media screen and (max-width: 600px) {
|
@media screen and (max-width: 600px) {
|
||||||
html,
|
html,
|
||||||
|
|
|
@ -819,6 +819,11 @@ export class Editor extends EventEmitter<TLEventMap> {
|
||||||
y: number;
|
y: number;
|
||||||
z: number;
|
z: number;
|
||||||
};
|
};
|
||||||
|
pageToViewport(point: VecLike): {
|
||||||
|
x: number;
|
||||||
|
y: number;
|
||||||
|
z: number;
|
||||||
|
};
|
||||||
pan(offset: VecLike, animation?: TLAnimationOptions): this;
|
pan(offset: VecLike, animation?: TLAnimationOptions): this;
|
||||||
panZoomIntoView(ids: TLShapeId[], animation?: TLAnimationOptions): this;
|
panZoomIntoView(ids: TLShapeId[], animation?: TLAnimationOptions): this;
|
||||||
popFocusedGroupId(): this;
|
popFocusedGroupId(): this;
|
||||||
|
|
|
@ -15423,7 +15423,7 @@
|
||||||
{
|
{
|
||||||
"kind": "Method",
|
"kind": "Method",
|
||||||
"canonicalReference": "@tldraw/editor!Editor#pageToScreen:member(1)",
|
"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": [
|
"excerptTokens": [
|
||||||
{
|
{
|
||||||
"kind": "Content",
|
"kind": "Content",
|
||||||
|
@ -15469,6 +15469,55 @@
|
||||||
"isAbstract": false,
|
"isAbstract": false,
|
||||||
"name": "pageToScreen"
|
"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",
|
"kind": "Method",
|
||||||
"canonicalReference": "@tldraw/editor!Editor#pan:member(1)",
|
"canonicalReference": "@tldraw/editor!Editor#pan:member(1)",
|
||||||
|
|
|
@ -2865,7 +2865,7 @@ export class Editor extends EventEmitter<TLEventMap> {
|
||||||
* editor.pageToScreen({ x: 100, y: 100 })
|
* editor.pageToScreen({ x: 100, y: 100 })
|
||||||
* ```
|
* ```
|
||||||
*
|
*
|
||||||
* @param point - The point in screen space.
|
* @param point - The point in page space.
|
||||||
*
|
*
|
||||||
* @public
|
* @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
|
// Following
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
|
@ -3,7 +3,7 @@ import { createUseGesture, pinchAction, wheelAction } from '@use-gesture/react'
|
||||||
import * as React from 'react'
|
import * as React from 'react'
|
||||||
import { TLWheelEventInfo } from '../editor/types/event-types'
|
import { TLWheelEventInfo } from '../editor/types/event-types'
|
||||||
import { Vec } from '../primitives/Vec'
|
import { Vec } from '../primitives/Vec'
|
||||||
import { preventDefault } from '../utils/dom'
|
import { preventDefault, stopEventPropagation } from '../utils/dom'
|
||||||
import { normalizeWheel } from '../utils/normalizeWheel'
|
import { normalizeWheel } from '../utils/normalizeWheel'
|
||||||
import { useEditor } from './useEditor'
|
import { useEditor } from './useEditor'
|
||||||
|
|
||||||
|
@ -112,6 +112,7 @@ export function useGestureEvents(ref: React.RefObject<HTMLDivElement>) {
|
||||||
}
|
}
|
||||||
|
|
||||||
preventDefault(event)
|
preventDefault(event)
|
||||||
|
stopEventPropagation(event)
|
||||||
const delta = normalizeWheel(event)
|
const delta = normalizeWheel(event)
|
||||||
|
|
||||||
if (delta.x === 0 && delta.y === 0) return
|
if (delta.x === 0 && delta.y === 0) return
|
||||||
|
|
|
@ -11,13 +11,13 @@ import {
|
||||||
TLStoreWithStatus,
|
TLStoreWithStatus,
|
||||||
TldrawEditor,
|
TldrawEditor,
|
||||||
TldrawEditorBaseProps,
|
TldrawEditorBaseProps,
|
||||||
assert,
|
|
||||||
useEditor,
|
useEditor,
|
||||||
useEditorComponents,
|
useEditorComponents,
|
||||||
|
useEvent,
|
||||||
useShallowArrayIdentity,
|
useShallowArrayIdentity,
|
||||||
useShallowObjectIdentity,
|
useShallowObjectIdentity,
|
||||||
} from '@tldraw/editor'
|
} from '@tldraw/editor'
|
||||||
import { useCallback, useDebugValue, useLayoutEffect, useMemo, useRef } from 'react'
|
import { useLayoutEffect, useMemo } from 'react'
|
||||||
import { TldrawHandles } from './canvas/TldrawHandles'
|
import { TldrawHandles } from './canvas/TldrawHandles'
|
||||||
import { TldrawHoveredShapeIndicator } from './canvas/TldrawHoveredShapeIndicator'
|
import { TldrawHoveredShapeIndicator } from './canvas/TldrawHoveredShapeIndicator'
|
||||||
import { TldrawScribble } from './canvas/TldrawScribble'
|
import { TldrawScribble } from './canvas/TldrawScribble'
|
||||||
|
@ -209,22 +209,3 @@ function InsideOfEditorAndUiContext({
|
||||||
|
|
||||||
return null
|
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