diff --git a/apps/docs/scripts/functions/generateExamplesContent.ts b/apps/docs/scripts/functions/generateExamplesContent.ts index ca17c1b92..3af794ecd 100644 --- a/apps/docs/scripts/functions/generateExamplesContent.ts +++ b/apps/docs/scripts/functions/generateExamplesContent.ts @@ -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', diff --git a/apps/examples/e2e/tests/test-routes.spec.ts b/apps/examples/e2e/tests/test-routes.spec.ts index 6c3ce6594..567bf3a70 100644 --- a/apps/examples/e2e/tests/test-routes.spec.ts +++ b/apps/examples/e2e/tests/test-routes.spec.ts @@ -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', () => { diff --git a/apps/examples/src/examples.tsx b/apps/examples/src/examples.tsx index 0fcb9011a..11a7f3179 100644 --- a/apps/examples/src/examples.tsx +++ b/apps/examples/src/examples.tsx @@ -13,7 +13,14 @@ export type Example = { loadComponent: () => Promise } -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 = { - 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]) => ({ diff --git a/apps/examples/src/examples/context-toolbar/ContextToolbar.tsx b/apps/examples/src/examples/context-toolbar/ContextToolbar.tsx index 393cbce06..4a92f88ba 100644 --- a/apps/examples/src/examples/context-toolbar/ContextToolbar.tsx +++ b/apps/examples/src/examples/context-toolbar/ContextToolbar.tsx @@ -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 (
void +}) { + const [imageShapeId, setImageShapeId] = useState(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({ + 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 ( + { + if (!imageShapeId) return null + return + }, [imageShapeId]), + // add a "done" button in the top right for when the user is ready to export + SharePanel: useCallback(() => { + if (!imageShapeId) return null + return + }, [imageShapeId, onDone]), + }} + > + {imageShapeId && } + {imageShapeId && } + {imageShapeId && } + + ) +} + +/** + * 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 ( + + + + ) +}) + +function DoneButton({ + imageShapeId, + onClick, +}: { + imageShapeId: TLShapeId + onClick: (result: Blob) => void +}) { + const editor = useEditor() + return ( + + ) +} + +/** + * 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 +} diff --git a/apps/examples/src/examples/image-annotator/ImageAnnotator.tsx b/apps/examples/src/examples/image-annotator/ImageAnnotator.tsx new file mode 100644 index 000000000..2ef069ace --- /dev/null +++ b/apps/examples/src/examples/image-annotator/ImageAnnotator.tsx @@ -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({ phase: 'pick' }) + + switch (state.phase) { + case 'pick': + return ( +
+ + setState({ phase: 'annotate', image, id: Math.random().toString(36) }) + } + /> +
+ ) + case 'annotate': + return ( +
+ setState({ phase: 'export', result })} + /> +
+ ) + case 'export': + return ( +
+ setState({ phase: 'pick' })} /> +
+ ) + } +} diff --git a/apps/examples/src/examples/image-annotator/ImageExport.tsx b/apps/examples/src/examples/image-annotator/ImageExport.tsx new file mode 100644 index 000000000..850f0b1c3 --- /dev/null +++ b/apps/examples/src/examples/image-annotator/ImageExport.tsx @@ -0,0 +1,41 @@ +import { useEffect, useLayoutEffect, useState } from 'react' + +export function ImageExport({ result, onStartAgain }: { result: Blob; onStartAgain: () => void }) { + const [src, setSrc] = useState(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 ( +
+ {src && } +
+ + +
+ +
+ ) +} diff --git a/apps/examples/src/examples/image-annotator/ImagePicker.tsx b/apps/examples/src/examples/image-annotator/ImagePicker.tsx new file mode 100644 index 000000000..7769ca6b3 --- /dev/null +++ b/apps/examples/src/examples/image-annotator/ImagePicker.tsx @@ -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
Loading...
+ } + + return ( +
+ +
or use an example:
+
+ anakin onChooseExample(anakin)} /> + distracted boyfriend onChooseExample(distractedBf)} + /> + expanding brain onChooseExample(expandingBrain)} + /> +
+
+ ) +} diff --git a/apps/examples/src/examples/image-annotator/README.md b/apps/examples/src/examples/image-annotator/README.md new file mode 100644 index 000000000..5deb2865a --- /dev/null +++ b/apps/examples/src/examples/image-annotator/README.md @@ -0,0 +1,8 @@ +--- +title: Image annotator +component: ./ImageAnnotator.tsx +category: use-cases +priority: 1 +--- + +An image annotator built with tldraw diff --git a/apps/examples/src/examples/image-annotator/assets/anakin.jpeg b/apps/examples/src/examples/image-annotator/assets/anakin.jpeg new file mode 100644 index 000000000..f78d52f1d Binary files /dev/null and b/apps/examples/src/examples/image-annotator/assets/anakin.jpeg differ diff --git a/apps/examples/src/examples/image-annotator/assets/distracted-bf.jpeg b/apps/examples/src/examples/image-annotator/assets/distracted-bf.jpeg new file mode 100644 index 000000000..f8d3ac010 Binary files /dev/null and b/apps/examples/src/examples/image-annotator/assets/distracted-bf.jpeg differ diff --git a/apps/examples/src/examples/image-annotator/assets/expanding-brain.png b/apps/examples/src/examples/image-annotator/assets/expanding-brain.png new file mode 100644 index 000000000..0426f2267 Binary files /dev/null and b/apps/examples/src/examples/image-annotator/assets/expanding-brain.png differ diff --git a/apps/examples/src/examples/image-annotator/image-annotator.css b/apps/examples/src/examples/image-annotator/image-annotator.css new file mode 100644 index 000000000..721596322 --- /dev/null +++ b/apps/examples/src/examples/image-annotator/image-annotator.css @@ -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; +} diff --git a/apps/examples/src/examples/image-annotator/types.tsx b/apps/examples/src/examples/image-annotator/types.tsx new file mode 100644 index 000000000..f737aadaf --- /dev/null +++ b/apps/examples/src/examples/image-annotator/types.tsx @@ -0,0 +1,6 @@ +export interface AnnotatorImage { + src: string + width: number + height: number + type: string +} diff --git a/apps/examples/src/examples/screenshot-tool/ScreenshotToolExample.tsx b/apps/examples/src/examples/screenshot-tool/ScreenshotToolExample.tsx index dbd7c13c4..f15ef7ab8 100644 --- a/apps/examples/src/examples/screenshot-tool/ScreenshotToolExample.tsx +++ b/apps/examples/src/examples/screenshot-tool/ScreenshotToolExample.tsx @@ -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] diff --git a/apps/examples/src/examples/things-on-the-canvas/OnTheCanvas.tsx b/apps/examples/src/examples/things-on-the-canvas/OnTheCanvas.tsx index 122778512..0bcbe6579 100644 --- a/apps/examples/src/examples/things-on-the-canvas/OnTheCanvas.tsx +++ b/apps/examples/src/examples/things-on-the-canvas/OnTheCanvas.tsx @@ -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 (
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') diff --git a/apps/examples/src/styles.css b/apps/examples/src/styles.css index 7f65fbbb9..34519b1a7 100644 --- a/apps/examples/src/styles.css +++ b/apps/examples/src/styles.css @@ -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, diff --git a/packages/editor/api-report.md b/packages/editor/api-report.md index d2df6ea09..4317f6d42 100644 --- a/packages/editor/api-report.md +++ b/packages/editor/api-report.md @@ -819,6 +819,11 @@ export class Editor extends EventEmitter { 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; diff --git a/packages/editor/api/api.json b/packages/editor/api/api.json index 6f5d87ed9..bda751816 100644 --- a/packages/editor/api/api.json +++ b/packages/editor/api/api.json @@ -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)", diff --git a/packages/editor/src/lib/editor/Editor.ts b/packages/editor/src/lib/editor/Editor.ts index 867a5c8f6..6b2be38b6 100644 --- a/packages/editor/src/lib/editor/Editor.ts +++ b/packages/editor/src/lib/editor/Editor.ts @@ -2865,7 +2865,7 @@ export class Editor extends EventEmitter { * 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 { } } + /** + * 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 /** diff --git a/packages/editor/src/lib/hooks/useGestureEvents.ts b/packages/editor/src/lib/hooks/useGestureEvents.ts index 7a534d42c..8f195abfc 100644 --- a/packages/editor/src/lib/hooks/useGestureEvents.ts +++ b/packages/editor/src/lib/hooks/useGestureEvents.ts @@ -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) { } preventDefault(event) + stopEventPropagation(event) const delta = normalizeWheel(event) if (delta.x === 0 && delta.y === 0) return diff --git a/packages/tldraw/src/lib/Tldraw.tsx b/packages/tldraw/src/lib/Tldraw.tsx index b89010fd2..9f7afcde8 100644 --- a/packages/tldraw/src/lib/Tldraw.tsx +++ b/packages/tldraw/src/lib/Tldraw.tsx @@ -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, 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) - }, []) -}