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:
alex 2024-03-19 11:41:25 +00:00 committed by GitHub
parent d7b80baa31
commit 3a736007e5
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
23 changed files with 736 additions and 50 deletions

View file

@ -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',

View file

@ -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', () => {

View file

@ -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]) => ({

View file

@ -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

View file

@ -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
}

View file

@ -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>
)
}
}

View 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>
)
}

View 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>
)
}

View file

@ -0,0 +1,8 @@
---
title: Image annotator
component: ./ImageAnnotator.tsx
category: use-cases
priority: 1
---
An image annotator built with tldraw

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

View 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;
}

View file

@ -0,0 +1,6 @@
export interface AnnotatorImage {
src: string
width: number
height: number
type: string
}

View file

@ -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]

View file

@ -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

View file

@ -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')

View file

@ -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,

View file

@ -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;

View file

@ -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)",

View file

@ -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
/** /**

View file

@ -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

View file

@ -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)
}, [])
}