Steve Ruiz 2024-04-25 14:34:18 +00:00 zatwierdzone przez GitHub
commit 0820ca1683
Nie znaleziono w bazie danych klucza dla tego podpisu
ID klucza GPG: B5690EEEBB952194
61 zmienionych plików z 2777 dodań i 1277 usunięć

Wyświetl plik

@ -201,7 +201,152 @@ The [Editor#getInstanceState](?) method returns settings that relate to each ind
The editor's user preferences are shared between all instances. See the [TLUserPreferences](?) docs for more about the user preferences.
## Common things to do with the editor
# Camera and coordinates
The editor offers many methods and properties relating to the part of the infinite canvas that is displayed in the component. This section includes key concepts and methods that you can use to change or control which parts of the canvas are visible.
## Viewport
The viewport is the rectangular area contained by the editor.
| Method | Description |
| --------------------------------- | ------------------------------------------------------------------------------------------------------------------ |
| [Editor#getViewportScreenBounds] | A [Box](?) that describes the size and position of the component's canvas in actual screen pixels. |
| [Editor#getViewportPageBounds](?) | A [Box](?) that describes the size and position of the part of the current page that is displayed in the viewport. |
## Screen vs. page coordinates
In tldraw, coordinates are either be in page or screen space.
A "screen point" refers to the point's distance from the top left corner of the component.
A "page point" refers to the point's distance from the "zero point" of the canvas.
When the camera is at `{x: 0, y: 0, z: 0}`, the screen point and page point will be identical. As the camera moves, however, the viewport will display a different part of the page; and so a screen point will correspond to a different page point.
| Method | Description |
| ------------------------ | ---------------------------------------------- |
| [Editor#screenToPage](?) | Convert a point in screen space to page space. |
| [Editor#pageToScreen](?) | Convert a point in page space to screen space. |
You can get the user's pointer position in both screen and page space.
```ts
const {
// The user's most recent page / screen points
currentPagePoint,
currentScreenPoint,
// The user's previous page / screen points
previousPagePoint,
previousScreenPoint,
// The last place where the most recent pointer down occurred
originPagePoint,
originScreenPoint,
} = editor.inputs
```
## Camera options
You can use the editor's camera options to configure the behavior of the editor's camera. There are many options available.
### `wheelBehavior`
When set to `'pan'`, scrolling the mousewheel will pan the camera. When set to `'zoom'`, scrolling the mousewheel will zoom the camera. When set to `none`, it will have no effect.
### `panSpeed`
The speed at which the camera pans. A pan can occur when the user holds the spacebar and drags, holds the middle mouse button and drags, drags while using the hand tool, or scrolls the mousewheel. The default value is `1`. A value of `0.5` would be twice as slow as default. A value of `2` would be twice as fast. When set to `0`, the camera will not pan.
### `zoomSpeed`
The speed at which the camera zooms. A zoom can occur when the user pinches or scrolls the mouse wheel. The default value is `1`. A value of `0.5` would be twice as slow as default. A value of `2` would be twice as fast. When set to `0`, the camera will not zoom.
### `zoomSteps`
The camera's "zoom steps" are an array of discrete zoom levels that the camera will move between when using the "zoom in" or "zoom out" controls.
The first number in the `zoomSteps` array defines the camera's minimum zoom level. The last number in the `zoomSteps` array defines the camera's maximum zoom level.
If the `constraints` are provided, then the actual value for the camera's zoom will be be calculated by multiplying the value from the `zoomSteps` array with the value from the `baseZoom`. See the `baseZoom` property for more information.
### `isLocked`
Whether the camera is locked. When the camera is locked, the camera will not move.
### `constraints`
By default the camera is free to move anywhere on the infinite canvas. However, you may provide the camera with a `constraints` object that constrains the camera based on a relationship between a `bounds` (in page space) and the viewport (in screen space).
### `constraints.bounds`
A box model describing the bounds in page space.
### `constraints.padding`
An object with padding to apply to the `x` and `y` dimensions of the viewport. The padding is in screen space.
### `constraints.origin`
An object with an origin for the `x` and `y` dimensions. Depending on the `behavior`, the origin may be used to position the bounds within the viewport.
For example, when the `behavior` is `fixed` and the `origin.x` is `0`, the bounds will be placed with its left side touching the left side of the viewport. When `origin.x` is `1` the bounds will be placed with its right side touching the right side of the viewport. By default the origin for each dimension is .5. This places the bounds in the center of the viewport.
### `constraints.initialZoom`
The `initialZoom` option defines the camera's initial zoom level and what the zoom should be when when the camera is reset. The zoom it produces is based on the value provided:
| Value | Description |
| --------- | ----------------------------------------------------------------------------------------------------------------------- |
| 'default' | 100%. |
| 'fit-x' | The zoom at which the constraint's `bounds.width` exactly fits within the viewport. |
| 'fit-y' | The zoom at which the constraint's `bounds.height` exactly fits within the viewport. |
| 'fit-min' | The zoom at which the _smaller_ of the constraint's `bounds.width` or `bounds.height` exactly fits within the viewport. |
| 'fit-max' | The zoom at which the _larger_ of the constraint's `bounds.width` or `bounds.height` exactly fits within the viewport. |
### `constraints.baseZoom`
The `baseZoom` property defines the base property for the camera's zoom steps. It accepts the same values as `initialZoom`.
When `constraints` are provided, then the actual value for the camera's zoom will be be calculated by multiplying the value from the `zoomSteps` array with the value from the `baseZoom`.
For example, if the `baseZoom` is set to `default`, then a zoom step of 2 will be 200%. However, if the `baseZoom` is set to `fit-x`, then a zoom step value of 2 will be twice the zoom level at which the bounds width exactly fits within the viewport.
### `constraints.behavior`
The `behavior` property defines which logic should be used when calculating the bounds position.
| Value | Description |
| --------- | ------------------------------------------------------------------------------------------------------------------------------------------- |
| 'free' | The bounds may be placed anywhere relative to the viewport. This is the default "infinite canvas" experience. |
| 'inside' | The bounds must stay entirely within the viewport. |
| 'outside' | The bounds may partially leave the viewport but must never leave it completely. |
| 'fixed' | The bounds are placed in the viewport at a fixed location according to the `'origin'`. |
| 'contain' | When the zoom is below the "fit zoom" for an axis, the bounds use the `'fixed'` behavior; when above, the bounds use the `inside` behavior. |
## Controlling the camera
There are several `Editor` methods available for controlling the camera.
| Method | Description |
| ------------------------------- | --------------------------------------------------------------------------------------------------- |
| [Editor#setCamera](?) | Moves the camera to the provided coordinates. |
| [Editor#zoomIn](?) | Zooms the camera in to the nearest zoom step. See the `constraints.zoomSteps` for more information. |
| [Editor#zoomOut](?) | Zooms the camera in to the nearest zoom step. See the `constraints.zoomSteps` for more information. |
| [Editor#zoomToFit](?) | Zooms the camera in to the nearest zoom step. See the `constraints.zoomSteps` for more information. |
| [Editor#zoomToBounds](?) | Moves the camera to fit the given bounding box. |
| [Editor#zoomToSelection](?) | Moves the camera to fit the current selection. |
| [Editor#zoomToUser](?) | Moves the camera to center on a user's cursor. |
| [Editor#resetZoom](?) | Resets the zoom to 100% or to the `initialZoom` zoom level. |
| [Editor#centerOnPoint](?) | Centers the camera on the given point. |
| [Editor#stopCameraAnimation](?) | Stops any camera animation. |
## Camera state
The camera may be in two states, `idle` or `moving`.
You can get the current camera state with [Editor#getCameraState](?).
# Common things to do with the editor
### Create a shape id
@ -301,10 +446,10 @@ editor.setCamera(0, 0, 1)
### Freeze the camera
You can prevent the user from changing the camera using the [Editor#updateInstanceState](?) method.
You can prevent the user from changing the camera using the `Editor.setCameraOptions` method.
```ts
editor.updateInstanceState({ canMoveCamera: false })
editor.setCameraOptions({ isLocked: true })
```
### Turn on dark mode

Wyświetl plik

@ -38,7 +38,7 @@ export const PeopleMenuItem = track(function PeopleMenuItem({ userId }: { userId
<TldrawUiButton
type="menu"
className="tlui-people-menu__item__button"
onClick={() => editor.animateToUser(userId)}
onClick={() => editor.zoomToUser(userId)}
onDoubleClick={handleFollowClick}
>
<TldrawUiIcon icon="color" color={presence.color} />

Wyświetl plik

@ -1,5 +1,5 @@
import { default as React, useEffect } from 'react'
import { Editor, MAX_ZOOM, MIN_ZOOM, TLPageId, debounce, react, useEditor } from 'tldraw'
import { Editor, TLPageId, Vec, clamp, debounce, react, useEditor } from 'tldraw'
const PARAMS = {
// deprecated
@ -68,14 +68,15 @@ export function useUrlState(onChangeUrl: (params: UrlStateParams) => void) {
const viewport = viewportFromString(newViewportRaw)
const { x, y, w, h } = viewport
const { w: sw, h: sh } = editor.getViewportScreenBounds()
const zoom = Math.min(Math.max(Math.min(sw / w, sh / h), MIN_ZOOM), MAX_ZOOM)
editor.setCamera({
x: -x + (sw - w * zoom) / 2 / zoom,
y: -y + (sh - h * zoom) / 2 / zoom,
z: zoom,
})
const initialZoom = editor.getInitialZoom()
const { zoomSteps } = editor.getCameraOptions()
const zoomMin = zoomSteps[0]
const zoomMax = zoomSteps[zoomSteps.length - 1]
const zoom = clamp(Math.min(sw / w, sh / h), zoomMin * initialZoom, zoomMax * initialZoom)
editor.setCamera(
new Vec(-x + (sw - w * zoom) / 2 / zoom, -y + (sh - h * zoom) / 2 / zoom, zoom),
{ immediate: true }
)
} catch (err) {
console.error(err)
}

Wyświetl plik

@ -86,7 +86,6 @@ export function useFileSystem({ isMultiplayer }: { isMultiplayer: boolean }): TL
editor.history.clear()
// Put the old bounds back in place
editor.updateViewportScreenBounds(bounds)
editor.updateRenderingBounds()
editor.updateInstanceState({ isFocused })
})
},

Wyświetl plik

@ -68,5 +68,5 @@ function createDemoShapes(editor: Editor) {
},
}))
)
.zoomToContent({ duration: 0 })
.zoomToFit({ animation: { duration: 0 } })
}

Wyświetl plik

@ -58,5 +58,5 @@ function createDemoShapes(editor: Editor) {
})),
])
editor.zoomToContent({ duration: 0 })
editor.zoomToFit({ animation: { duration: 0 } })
}

Wyświetl plik

@ -44,7 +44,7 @@ export default function BeforeCreateUpdateShapeExample() {
editor.zoomToBounds(new Box(-500, -500, 1000, 1000))
// lock the camera on that area
editor.updateInstanceState({ canMoveCamera: false })
editor.setCameraOptions({ isLocked: true })
}}
components={{
// to make it a little clearer what's going on in this example, we'll draw a

Wyświetl plik

@ -44,5 +44,5 @@ function createDemoShapes(editor: Editor) {
},
},
])
.zoomToContent({ duration: 0 })
.zoomToFit({ animation: { duration: 0 } })
}

Wyświetl plik

@ -0,0 +1,463 @@
import { useEffect } from 'react'
import {
BoxModel,
TLCameraOptions,
Tldraw,
Vec,
clamp,
track,
useEditor,
useLocalStorageState,
} from 'tldraw'
import 'tldraw/tldraw.css'
const CAMERA_OPTIONS: TLCameraOptions = {
isLocked: false,
wheelBehavior: 'pan',
panSpeed: 1,
zoomSpeed: 1,
zoomSteps: [0.1, 0.25, 0.5, 1, 2, 4, 8],
constraints: {
initialZoom: 'fit-max',
baseZoom: 'fit-max',
bounds: {
x: 0,
y: 0,
w: 1600,
h: 900,
},
behavior: { x: 'contain', y: 'contain' },
padding: { x: 100, y: 100 },
origin: { x: 0.5, y: 0.5 },
},
}
const BOUNDS_SIZES: Record<string, BoxModel> = {
a4: { x: 0, y: 0, w: 1050, h: 1485 },
landscape: { x: 0, y: 0, w: 1600, h: 900 },
portrait: { x: 0, y: 0, w: 900, h: 1600 },
square: { x: 0, y: 0, w: 900, h: 900 },
}
export default function CameraOptionsExample() {
return (
<div className="tldraw__editor">
<Tldraw
// persistenceKey="camera-options"
components={components}
>
<CameraOptionsControlPanel />
</Tldraw>
</div>
)
}
const PaddingDisplay = track(() => {
const editor = useEditor()
const cameraOptions = editor.getCameraOptions()
if (!cameraOptions.constraints) return null
const {
constraints: {
padding: { x: px, y: py },
},
} = cameraOptions
return (
<div
style={{
position: 'absolute',
top: py,
left: px,
width: `calc(100% - ${px * 2}px)`,
height: `calc(100% - ${py * 2}px)`,
border: '1px dotted var(--color-text)',
}}
/>
)
})
const BoundsDisplay = track(() => {
const editor = useEditor()
const cameraOptions = editor.getCameraOptions()
if (!cameraOptions.constraints) return null
const {
constraints: {
bounds: { x, y, w, h },
},
} = cameraOptions
const d = Vec.ToAngle({ x: w, y: h }) * (180 / Math.PI)
const colB = '#00000002'
const colA = '#0000001F'
return (
<>
<div
style={{
position: 'absolute',
top: y,
left: x,
width: w,
height: h,
// grey and white stripes
border: '1px dashed var(--color-text)',
backgroundImage: `
`,
backgroundSize: '200px 200px',
backgroundPosition: '0 0, 0 100px, 100px -100px, -100px 0px',
}}
>
<div
style={{
position: 'absolute',
top: 0,
left: 0,
width: '100%',
height: '100%',
backgroundImage: `
linear-gradient(0deg, ${colB} 0%, ${colA} 50%, ${colB} 50%, ${colA} 100%),
linear-gradient(90deg, ${colB} 0%, ${colA} 50%, ${colB} 50%, ${colA} 100%),
linear-gradient(${d}deg, ${colB} 0%, ${colA} 50%, ${colB} 50%, ${colA} 100%),
linear-gradient(-${d}deg, ${colB} 0%, ${colA} 50%, ${colB} 50%, ${colA} 100%)`,
}}
></div>
</div>
</>
)
})
const components = {
// These components are just included for debugging / visualization!
OnTheCanvas: BoundsDisplay,
InFrontOfTheCanvas: PaddingDisplay,
}
const CameraOptionsControlPanel = track(() => {
const editor = useEditor()
const [cameraOptions, setCameraOptions] = useLocalStorageState('camera ex1', CAMERA_OPTIONS)
useEffect(() => {
if (!editor) return
editor.batch(() => {
editor.setCameraOptions(cameraOptions, { immediate: true })
editor.setCamera(editor.getCamera(), {
immediate: true,
})
})
}, [editor, cameraOptions])
const { constraints } = cameraOptions
const updateOptions = (
options: Partial<
Omit<TLCameraOptions, 'constraints'> & {
constraints: Partial<TLCameraOptions['constraints']>
}
>
) => {
const { constraints } = options
const cameraOptions = editor.getCameraOptions()
setCameraOptions({
...cameraOptions,
...options,
constraints:
constraints === undefined
? cameraOptions.constraints
: {
...(cameraOptions.constraints! ?? CAMERA_OPTIONS.constraints),
...constraints,
},
})
}
return (
<div
style={{
pointerEvents: 'all',
position: 'absolute',
top: 50,
left: 0,
padding: 4,
background: 'white',
zIndex: 1000000,
}}
>
<div
style={{
display: 'grid',
gridTemplateColumns: 'auto 1fr',
columnGap: 12,
rowGap: 4,
marginBottom: 12,
alignItems: 'center',
justifyContent: 'center',
}}
>
<label htmlFor="lock">Lock</label>
<select
name="lock"
value={cameraOptions.isLocked ? 'true' : 'false'}
onChange={(e) => {
const value = e.target.value
updateOptions({
...CAMERA_OPTIONS,
isLocked: value === 'true',
})
}}
>
<option value="true">true</option>
<option value="false">false</option>
</select>
<label htmlFor="panspeed">Pan Speed</label>
<input
name="panspeed"
type="number"
step={0.1}
value={cameraOptions.panSpeed}
onChange={(e) => {
const val = clamp(Number(e.target.value), 0, 2)
updateOptions({ panSpeed: val })
}}
/>
<label htmlFor="zoomspeed">Zoom Speed</label>
<input
name="zoomspeed"
type="number"
step={0.1}
value={cameraOptions.zoomSpeed}
onChange={(e) => {
const val = clamp(Number(e.target.value), 0, 2)
updateOptions({ zoomSpeed: val })
}}
/>
<label htmlFor="zoomsteps">Zoom Steps</label>
<input
name="zoomsteps"
type="text"
defaultValue={cameraOptions.zoomSteps.join(', ')}
onChange={(e) => {
const val = e.target.value.split(', ').map((v) => Number(v))
if (val.every((v) => typeof v === 'number' && Number.isFinite(v))) {
updateOptions({ zoomSteps: val })
}
}}
/>
<label htmlFor="bounds">Bounds</label>
<select
name="bounds"
value={
Object.entries(BOUNDS_SIZES).find(([_, b]) => b.w === constraints?.bounds.w)?.[0] ??
'none'
}
onChange={(e) => {
const currentConstraints = constraints ?? CAMERA_OPTIONS.constraints
const value = e.target.value
if (value === 'none') {
updateOptions({
...CAMERA_OPTIONS,
constraints: undefined,
})
return
}
updateOptions({
...CAMERA_OPTIONS,
constraints: {
...currentConstraints,
bounds: BOUNDS_SIZES[value] ?? BOUNDS_SIZES.a4,
},
})
}}
>
<option value="none">none</option>
<option value="a4">A4 Page</option>
<option value="portrait">Portait</option>
<option value="landscape">Landscape</option>
<option value="square">Square</option>
</select>
{constraints ? (
<>
<label htmlFor="initialZoom">Initial Zoom</label>
<select
name="initialZoom"
value={constraints.initialZoom}
onChange={(e) => {
updateOptions({
constraints: {
...constraints,
initialZoom: e.target.value as any,
},
})
}}
>
<option>fit-min</option>
<option>fit-max</option>
<option>fit-x</option>
<option>fit-y</option>
<option>default</option>
</select>
<label htmlFor="zoomBehavior">Base Zoom</label>
<select
name="zoomBehavior"
value={constraints.baseZoom}
onChange={(e) => {
updateOptions({
constraints: {
...constraints,
baseZoom: e.target.value as any,
},
})
}}
>
<option>fit-min</option>
<option>fit-max</option>
<option>fit-x</option>
<option>fit-y</option>
<option>default</option>
</select>
<label htmlFor="originX">Origin X</label>
<input
name="originX"
type="number"
step={0.1}
value={constraints.origin.x}
onChange={(e) => {
const val = clamp(Number(e.target.value), 0, 1)
updateOptions({
constraints: {
origin: {
...constraints.origin,
x: val,
},
},
})
}}
/>
<label htmlFor="originY">Origin Y</label>
<input
name="originY"
type="number"
step={0.1}
value={constraints.origin.y}
onChange={(e) => {
const val = clamp(Number(e.target.value), 0, 1)
updateOptions({
constraints: {
...constraints,
origin: {
...constraints.origin,
y: val,
},
},
})
}}
/>
<label htmlFor="paddingX">Padding X</label>
<input
name="paddingX"
type="number"
step={10}
value={constraints.padding.x}
onChange={(e) => {
const val = clamp(Number(e.target.value), 0)
updateOptions({
constraints: {
...constraints,
padding: {
...constraints.padding,
x: val,
},
},
})
}}
/>
<label htmlFor="paddingY">Padding Y</label>
<input
name="paddingY"
type="number"
step={10}
value={constraints.padding.y}
onChange={(e) => {
const val = clamp(Number(e.target.value), 0)
updateOptions({
constraints: {
padding: {
...constraints.padding,
y: val,
},
},
})
}}
/>
<label htmlFor="behaviorX">Behavior X</label>
<select
name="behaviorX"
value={(constraints.behavior as { x: any; y: any }).x}
onChange={(e) => {
setCameraOptions({
...cameraOptions,
constraints: {
...constraints,
behavior: {
...(constraints.behavior as { x: any; y: any }),
x: e.target.value as any,
},
},
})
}}
>
<option>contain</option>
<option>inside</option>
<option>outside</option>
<option>lock</option>
</select>
<label htmlFor="behaviorY">Behavior Y</label>
<select
name="behaviorY"
value={(constraints.behavior as { x: any; y: any }).y}
onChange={(e) => {
setCameraOptions({
...cameraOptions,
constraints: {
...constraints,
behavior: {
...(constraints.behavior as { x: any; y: any }),
y: e.target.value as any,
},
},
})
}}
>
<option>contain</option>
<option>inside</option>
<option>outside</option>
<option>lock</option>
</select>
</>
) : null}
</div>
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
<button
onClick={() => {
editor.setCamera(editor.getCamera(), { reset: true })
}}
>
Reset Camera
</button>
<button
onClick={() => {
updateOptions(CAMERA_OPTIONS)
}}
>
Reset Camera Options
</button>
</div>
</div>
)
})

Wyświetl plik

@ -0,0 +1,11 @@
---
title: Camera options
component: ./CameraOptionsExample.tsx
category: basic
---
You can set the camera's options and constraints.
---
The `Tldraw` component provides a prop, `cameraOptions`, that can be used to set the camera's constraints, zoom behavior, and other options.

Wyświetl plik

@ -90,7 +90,7 @@ export class EditableShapeUtil extends BaseBoxShapeUtil<IMyEditableShape> {
override onEditEnd: TLOnEditEndHandler<IMyEditableShape> = (shape) => {
this.editor.animateShape(
{ ...shape, rotation: shape.rotation + Math.PI * 2 },
{ duration: 250 }
{ animation: { duration: 250 } }
)
}
}

Wyświetl plik

@ -51,7 +51,7 @@ export default function ExternalContentSourcesExample() {
const htmlSource = sources?.find((s) => s.type === 'text' && s.subtype === 'html')
if (htmlSource) {
const center = point ?? editor.getViewportPageCenter()
const center = point ?? editor.getViewportPageBounds().center
editor.createShape({
type: 'html',

Wyświetl plik

@ -1,20 +1,14 @@
import { useCallback, useEffect, useState } from 'react'
import {
AssetRecordType,
Box,
Editor,
PORTRAIT_BREAKPOINT,
SVGContainer,
TLImageShape,
TLShapeId,
Tldraw,
clamp,
createShapeId,
exportToBlob,
getIndexBelow,
react,
track,
useBreakpoint,
useEditor,
} from 'tldraw'
import { AnnotatorImage } from './types'
@ -31,9 +25,19 @@ export function ImageAnnotationEditor({
onDone: (result: Blob) => void
}) {
const [imageShapeId, setImageShapeId] = useState<TLShapeId | null>(null)
const [editor, setEditor] = useState(null as Editor | null)
function onMount(editor: Editor) {
setEditor(editor)
}
useEffect(() => {
if (!editor) return
// Turn off debug mode
editor.updateInstanceState({ isDebugMode: false })
// Create the asset and image shape
const assetId = AssetRecordType.createId()
editor.createAssets([
{
@ -51,10 +55,9 @@ export function ImageAnnotationEditor({
},
},
])
const imageId = createShapeId()
const shapeId = createShapeId()
editor.createShape<TLImageShape>({
id: imageId,
id: shapeId,
type: 'image',
x: 0,
y: 0,
@ -66,13 +69,87 @@ export function ImageAnnotationEditor({
},
})
editor.history.clear()
setImageShapeId(imageId)
// Make sure the shape is at the bottom of the page
function makeSureShapeIsAtBottom() {
if (!editor) return
// 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 })
}
const shape = editor.getShape(shapeId)
if (!shape) return
const pageId = editor.getCurrentPageId()
// The shape should always be the child of the current page
if (shape.parentId !== pageId) {
editor.moveShapesToPage([shape], pageId)
}
// The shape should always be at the bottom of the page's children
const siblings = editor.getSortedChildIdsForParent(pageId)
const currentBottomShape = editor.getShape(siblings[0])!
if (currentBottomShape.id !== shapeId) {
editor.sendToBack([shape])
}
}
makeSureShapeIsAtBottom()
const removeOnCreate = editor.sideEffects.registerAfterCreateHandler(
'shape',
makeSureShapeIsAtBottom
)
const removeOnChange = editor.sideEffects.registerAfterChangeHandler(
'shape',
makeSureShapeIsAtBottom
)
// The shape should always be locked
const cleanupKeepShapeLocked = editor.sideEffects.registerBeforeChangeHandler(
'shape',
(prev, next) => {
if (next.id !== shapeId) return next
if (next.isLocked) return next
return { ...prev, isLocked: true }
}
)
// Reset the history
editor.history.clear()
setImageShapeId(shapeId)
return () => {
removeOnChange()
removeOnCreate()
cleanupKeepShapeLocked()
}
}, [image, editor])
useEffect(() => {
if (!editor) return
if (!imageShapeId) return
// We want to set the default zoom only on first mount
let isInitial = true
editor.setCameraOptions(
{
constraints: {
initialZoom: 'fit-max',
baseZoom: 'default',
bounds: { w: image.width, h: image.height, x: 0, y: 0 },
padding: { x: 32, y: 64 },
origin: { x: 0.5, y: 0.5 },
behavior: 'inside',
},
zoomSteps: [1, 2, 4, 8],
zoomSpeed: 1,
panSpeed: 1,
isLocked: false,
},
{ reset: isInitial }
)
isInitial = false
}, [editor, imageShapeId, image])
return (
<Tldraw
@ -91,11 +168,7 @@ export function ImageAnnotationEditor({
return <DoneButton imageShapeId={imageShapeId} onClick={onDone} />
}, [imageShapeId, onDone]),
}}
>
{imageShapeId && <KeepShapeAtBottomOfCurrentPage shapeId={imageShapeId} />}
{imageShapeId && <KeepShapeLocked shapeId={imageShapeId} />}
{imageShapeId && <ConstrainCamera shapeId={imageShapeId} />}
</Tldraw>
/>
)
}
@ -173,164 +246,8 @@ function DoneButton({
)
}
/**
* 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
}

Wyświetl plik

@ -1,9 +0,0 @@
---
title: Rendering shapes change
component: ./RenderingShapesChangeExample.tsx
category: basic
---
---
Do something when the rendering shapes change.

Wyświetl plik

@ -1,27 +0,0 @@
import { useCallback } from 'react'
import { TLShape, Tldraw } from 'tldraw'
import 'tldraw/tldraw.css'
import { useChangedShapesReactor } from './useRenderingShapesChange'
const components = {
InFrontOfTheCanvas: () => {
const onShapesChanged = useCallback((info: { culled: TLShape[]; restored: TLShape[] }) => {
// eslint-disable-next-line no-console
for (const shape of info.culled) console.log('culled: ' + shape.id)
// eslint-disable-next-line no-console
for (const shape of info.restored) console.log('restored: ' + shape.id)
}, [])
useChangedShapesReactor(onShapesChanged)
return null
},
}
export default function RenderingShapesChangeExample() {
return (
<div className="tldraw__editor">
<Tldraw persistenceKey="example" components={components} />
</div>
)
}

Wyświetl plik

@ -1,50 +0,0 @@
import { useEffect, useRef } from 'react'
import { TLShape, react, useEditor } from 'tldraw'
export function useChangedShapesReactor(
cb: (info: { culled: TLShape[]; restored: TLShape[] }) => void
) {
const editor = useEditor()
const rPrevShapes = useRef({
renderingShapes: editor.getRenderingShapes(),
culledShapes: editor.getCulledShapes(),
})
useEffect(() => {
return react('when rendering shapes change', () => {
const after = {
culledShapes: editor.getCulledShapes(),
renderingShapes: editor.getRenderingShapes(),
}
const before = rPrevShapes.current
const culled: TLShape[] = []
const restored: TLShape[] = []
const beforeToVisit = new Set(before.renderingShapes)
for (const afterInfo of after.renderingShapes) {
const beforeInfo = before.renderingShapes.find((s) => s.id === afterInfo.id)
if (!beforeInfo) {
continue
} else {
const isAfterCulled = after.culledShapes.has(afterInfo.id)
const isBeforeCulled = before.culledShapes.has(beforeInfo.id)
if (isAfterCulled && !isBeforeCulled) {
culled.push(afterInfo.shape)
} else if (!isAfterCulled && isBeforeCulled) {
restored.push(afterInfo.shape)
}
beforeToVisit.delete(beforeInfo)
}
}
rPrevShapes.current = after
cb({
culled,
restored,
})
})
}, [cb, editor])
}

Wyświetl plik

@ -8,7 +8,10 @@ export function moveToSlide(editor: Editor, slide: SlideShape) {
if (!bounds) return
$currentSlide.set(slide)
editor.selectNone()
editor.zoomToBounds(bounds, { duration: 500, easing: EASINGS.easeInOutCubic, inset: 0 })
editor.zoomToBounds(bounds, {
inset: 0,
animation: { duration: 500, easing: EASINGS.easeInOutCubic },
})
}
export function useSlides() {

Wyświetl plik

@ -50,7 +50,6 @@ const ZOOM_EVENT = {
'reset-zoom': 'resetZoom',
'zoom-to-fit': 'zoomToFit',
'zoom-to-selection': 'zoomToSelection',
'zoom-to-content': 'zoomToContent',
}
export function getCodeSnippet(name: string, data: any) {
@ -136,15 +135,11 @@ if (updates.length > 0) {
} else if (name === 'fit-frame-to-content') {
codeSnippet = `fitFrameToContent(editor, editor.getOnlySelectedShape().id)`
} else if (name.startsWith('zoom-') || name === 'reset-zoom') {
if (name === 'zoom-to-content') {
codeSnippet = 'editor.zoomToContent()'
} else {
codeSnippet = `editor.${ZOOM_EVENT[name as keyof typeof ZOOM_EVENT]}(${
name !== 'zoom-to-fit' && name !== 'zoom-to-selection'
? 'editor.getViewportScreenCenter(), '
: ''
}{ duration: 320 })`
}
codeSnippet = `editor.${ZOOM_EVENT[name as keyof typeof ZOOM_EVENT]}(${
name !== 'zoom-to-fit' && name !== 'zoom-to-selection'
? 'editor.getViewportScreenCenter(), '
: ''
}{ duration: 320 })`
} else if (name.startsWith('toggle-')) {
if (name === 'toggle-lock') {
codeSnippet = `editor.toggleLock(editor.getSelectedShapeIds())`

Wyświetl plik

@ -461,6 +461,9 @@ export const DEFAULT_ANIMATION_OPTIONS: {
easing: (t: number) => number;
};
// @internal (undocumented)
export const DEFAULT_CAMERA_OPTIONS: TLCameraOptions;
// @public (undocumented)
export function DefaultBackground(): JSX_2.Element;
@ -585,16 +588,27 @@ export class Edge2d extends Geometry2d {
// @public (undocumented)
export class Editor extends EventEmitter<TLEventMap> {
constructor({ store, user, shapeUtils, tools, getContainer, initialState, inferDarkMode, }: TLEditorOptions);
constructor({ store, user, shapeUtils, tools, getContainer, cameraOptions, initialState, inferDarkMode, }: TLEditorOptions);
addOpenMenu(id: string): this;
alignShapes(shapes: TLShape[] | TLShapeId[], operation: 'bottom' | 'center-horizontal' | 'center-vertical' | 'left' | 'right' | 'top'): this;
animateShape(partial: null | TLShapePartial | undefined, animationOptions?: TLAnimationOptions): this;
animateShapes(partials: (null | TLShapePartial | undefined)[], animationOptions?: Partial<{
duration: number;
easing: (t: number) => number;
animateShape(partial: null | TLShapePartial | undefined, opts?: Partial<{
animation: Partial<{
duration: number;
easing: (t: number) => number;
}>;
force: boolean;
immediate: boolean;
reset: boolean;
}>): this;
animateShapes(partials: (null | TLShapePartial | undefined)[], opts?: Partial<{
animation: Partial<{
duration: number;
easing: (t: number) => number;
}>;
force: boolean;
immediate: boolean;
reset: boolean;
}>): this;
animateToShape(shapeId: TLShapeId, opts?: TLAnimationOptions): this;
animateToUser(userId: string): this;
// @internal (undocumented)
annotateError(error: unknown, { origin, willCrashApp, tags, extras, }: {
extras?: Record<string, unknown>;
@ -611,7 +625,7 @@ export class Editor extends EventEmitter<TLEventMap> {
cancelDoubleClick(): void;
// @internal (undocumented)
capturedPointerId: null | number;
centerOnPoint(point: VecLike, animation?: TLAnimationOptions): this;
centerOnPoint(point: VecLike, opts?: TLCameraMoveOptions): this;
clearOpenMenus(): this;
// @internal
protected _clickManager: ClickManager;
@ -680,7 +694,9 @@ export class Editor extends EventEmitter<TLEventMap> {
getAsset(asset: TLAsset | TLAssetId): TLAsset | undefined;
getAssetForExternalContent(info: TLExternalAssetContent): Promise<TLAsset | undefined>;
getAssets(): (TLBookmarkAsset | TLImageAsset | TLVideoAsset)[];
getBaseZoom(): number;
getCamera(): TLCamera;
getCameraOptions(): TLCameraOptions;
getCameraState(): "idle" | "moving";
getCanRedo(): boolean;
getCanUndo(): boolean;
@ -718,6 +734,7 @@ export class Editor extends EventEmitter<TLEventMap> {
getHoveredShape(): TLShape | undefined;
getHoveredShapeId(): null | TLShapeId;
getInitialMetaForShape(_shape: TLShape): JsonObject;
getInitialZoom(): number;
getInstanceState(): TLInstance;
getIsMenuOpen(): boolean;
getOnlySelectedShape(): null | TLShape;
@ -731,7 +748,6 @@ export class Editor extends EventEmitter<TLEventMap> {
getPath(): string;
getPointInParentSpace(shape: TLShape | TLShapeId, point: VecLike): Vec;
getPointInShapeSpace(shape: TLShape | TLShapeId, point: VecLike): Vec;
getRenderingBounds(): Box;
getRenderingShapes(): {
backgroundIndex: number;
id: TLShapeId;
@ -806,7 +822,6 @@ export class Editor extends EventEmitter<TLEventMap> {
util: ShapeUtil;
}[];
getViewportPageBounds(): Box;
getViewportPageCenter(): Vec;
getViewportScreenBounds(): Box;
getViewportScreenCenter(): Vec;
getZoomLevel(): number;
@ -852,18 +867,8 @@ export class Editor extends EventEmitter<TLEventMap> {
moveShapesToPage(shapes: TLShape[] | TLShapeId[], pageId: TLPageId): this;
nudgeShapes(shapes: TLShape[] | TLShapeId[], offset: VecLike): this;
packShapes(shapes: TLShape[] | TLShapeId[], gap: number): this;
pageToScreen(point: VecLike): {
x: number;
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;
pageToScreen(point: VecLike): Vec;
pageToViewport(point: VecLike): Vec;
popFocusedGroupId(): this;
putContentOntoCurrentPage(content: TLContent, options?: {
point?: VecLike;
@ -880,24 +885,20 @@ export class Editor extends EventEmitter<TLEventMap> {
type: T;
} : TLExternalContent) => void) | null): this;
renamePage(page: TLPage | TLPageId, name: string): this;
renderingBoundsMargin: number;
reparentShapes(shapes: TLShape[] | TLShapeId[], parentId: TLParentId, insertIndex?: IndexKey): this;
resetZoom(point?: Vec, animation?: TLAnimationOptions): this;
resetZoom(point?: Vec, opts?: TLCameraMoveOptions): this;
resizeShape(shape: TLShape | TLShapeId, scale: VecLike, options?: TLResizeShapeOptions): this;
readonly root: RootState;
rotateShapesBy(shapes: TLShape[] | TLShapeId[], delta: number): this;
screenToPage(point: VecLike): {
x: number;
y: number;
z: number;
};
screenToPage(point: VecLike): Vec;
readonly scribbles: ScribbleManager;
select(...shapes: TLShape[] | TLShapeId[]): this;
selectAll(): this;
selectNone(): this;
sendBackward(shapes: TLShape[] | TLShapeId[]): this;
sendToBack(shapes: TLShape[] | TLShapeId[]): this;
setCamera(point: VecLike, animation?: TLAnimationOptions): this;
setCamera(point: VecLike, opts?: TLCameraMoveOptions): this;
setCameraOptions(options: Partial<TLCameraOptions>, opts?: TLCameraMoveOptions): this;
setCroppingShape(shape: null | TLShape | TLShapeId): this;
setCurrentPage(page: TLPage | TLPageId): this;
setCurrentTool(id: string, info?: {}): this;
@ -946,22 +947,20 @@ export class Editor extends EventEmitter<TLEventMap> {
updateDocumentSettings(settings: Partial<TLDocument>): this;
updateInstanceState(partial: Partial<Omit<TLInstance, 'currentPageId'>>, historyOptions?: TLHistoryBatchOptions): this;
updatePage(partial: RequiredKeys<TLPage, 'id'>): this;
// @internal
updateRenderingBounds(): this;
updateShape<T extends TLUnknownShape>(partial: null | TLShapePartial<T> | undefined): this;
updateShapes<T extends TLUnknownShape>(partials: (null | TLShapePartial<T> | undefined)[]): this;
updateViewportScreenBounds(screenBounds: Box, center?: boolean): this;
readonly user: UserPreferencesManager;
visitDescendants(parent: TLPage | TLParentId | TLShape, visitor: (id: TLShapeId) => false | void): this;
zoomIn(point?: Vec, animation?: TLAnimationOptions): this;
zoomOut(point?: Vec, animation?: TLAnimationOptions): this;
zoomToBounds(bounds: Box, opts?: {
zoomIn(point?: Vec, opts?: TLCameraMoveOptions): this;
zoomOut(point?: Vec, opts?: TLCameraMoveOptions): this;
zoomToBounds(bounds: BoxLike, opts?: {
inset?: number;
targetZoom?: number;
} & TLAnimationOptions): this;
zoomToContent(opts?: TLAnimationOptions): this;
zoomToFit(animation?: TLAnimationOptions): this;
zoomToSelection(animation?: TLAnimationOptions): this;
} & TLCameraMoveOptions): this;
zoomToFit(opts?: TLCameraMoveOptions): this;
zoomToSelection(opts?: TLCameraMoveOptions): this;
zoomToUser(userId: string, opts?: TLCameraMoveOptions): this;
}
// @internal (undocumented)
@ -1210,9 +1209,6 @@ export function hardReset({ shouldReload }?: {
// @public (undocumented)
export function hardResetEditor(): void;
// @internal (undocumented)
export const HASH_PATTERN_ZOOM_NAMES: Record<string, string>;
// @public (undocumented)
export class HistoryManager<R extends UnknownRecord> {
constructor(opts: {
@ -1448,12 +1444,6 @@ export const MAX_PAGES = 40;
// @internal (undocumented)
export const MAX_SHAPES_PER_PAGE = 2000;
// @internal (undocumented)
export const MAX_ZOOM = 8;
// @internal (undocumented)
export const MIN_ZOOM = 0.1;
// @public
export function moveCameraWhenCloseToEdge(editor: Editor): void;
@ -1979,12 +1969,6 @@ export type TLAfterCreateHandler<R extends TLRecord> = (record: R, source: 'remo
// @public (undocumented)
export type TLAfterDeleteHandler<R extends TLRecord> = (record: R, source: 'remote' | 'user') => void;
// @public (undocumented)
export type TLAnimationOptions = Partial<{
duration: number;
easing: (t: number) => number;
}>;
// @public (undocumented)
export type TLAnyShapeUtilConstructor = TLShapeUtilConstructor<any>;
@ -2067,6 +2051,37 @@ export type TLBrushProps = {
opacity?: number;
};
// @public (undocumented)
export type TLCameraMoveOptions = Partial<{
animation: Partial<{
easing: (t: number) => number;
duration: number;
}>;
force: boolean;
immediate: boolean;
reset: boolean;
}>;
// @public (undocumented)
export type TLCameraOptions = {
wheelBehavior: 'none' | 'pan' | 'zoom';
constraints?: {
behavior: 'contain' | 'fixed' | 'free' | 'inside' | 'outside' | {
x: 'contain' | 'fixed' | 'free' | 'inside' | 'outside';
y: 'contain' | 'fixed' | 'free' | 'inside' | 'outside';
};
bounds: BoxModel;
baseZoom: 'default' | 'fit-max' | 'fit-min' | 'fit-x' | 'fit-y';
initialZoom: 'default' | 'fit-max' | 'fit-min' | 'fit-x' | 'fit-y';
origin: VecLike;
padding: VecLike;
};
panSpeed: number;
zoomSpeed: number;
zoomSteps: number[];
isLocked: boolean;
};
// @public (undocumented)
export type TLCancelEvent = (info: TLCancelEventInfo) => void;
@ -2139,6 +2154,7 @@ export const TldrawEditor: React_2.NamedExoticComponent<TldrawEditorProps>;
// @public
export interface TldrawEditorBaseProps {
autoFocus?: boolean;
cameraOptions?: Partial<TLCameraOptions>;
children?: ReactNode;
className?: string;
components?: TLEditorComponents;
@ -2170,6 +2186,7 @@ export type TLEditorComponents = Partial<{
// @public (undocumented)
export interface TLEditorOptions {
cameraOptions?: Partial<TLCameraOptions>;
getContainer: () => HTMLElement;
inferDarkMode?: boolean;
initialState?: string;
@ -3067,9 +3084,6 @@ export class WeakMapCache<T extends object, K> {
export { whyAmIRunning }
// @internal (undocumented)
export const ZOOMS: number[];
export * from "@tldraw/store";
export * from "@tldraw/tlschema";

Wyświetl plik

@ -110,26 +110,18 @@ export {
ANIMATION_SHORT_MS,
CAMERA_SLIDE_FRICTION,
DEFAULT_ANIMATION_OPTIONS,
DEFAULT_CAMERA_OPTIONS,
DOUBLE_CLICK_DURATION,
DRAG_DISTANCE,
GRID_STEPS,
HASH_PATTERN_ZOOM_NAMES,
HIT_TEST_MARGIN,
MAX_PAGES,
MAX_SHAPES_PER_PAGE,
MAX_ZOOM,
MIN_ZOOM,
MULTI_CLICK_DURATION,
SIDES,
SVG_PADDING,
ZOOMS,
} from './lib/constants'
export {
Editor,
type TLAnimationOptions,
type TLEditorOptions,
type TLResizeShapeOptions,
} from './lib/editor/Editor'
export { Editor, type TLEditorOptions, type TLResizeShapeOptions } from './lib/editor/Editor'
export { HistoryManager } from './lib/editor/managers/HistoryManager'
export type {
SideEffectManager,
@ -235,7 +227,12 @@ export {
type TLExternalContent,
type TLExternalContentSource,
} from './lib/editor/types/external-content'
export { type RequiredKeys, type TLSvgOptions } from './lib/editor/types/misc-types'
export {
type RequiredKeys,
type TLCameraMoveOptions,
type TLCameraOptions,
type TLSvgOptions,
} from './lib/editor/types/misc-types'
export { type TLResizeHandle, type TLSelectionHandle } from './lib/editor/types/selection-types'
export { ContainerProvider, useContainer } from './lib/hooks/useContainer'
export { getCursor } from './lib/hooks/useCursor'

Wyświetl plik

@ -18,6 +18,7 @@ import { TLUser, createTLUser } from './config/createTLUser'
import { TLAnyShapeUtilConstructor } from './config/defaultShapes'
import { Editor } from './editor/Editor'
import { TLStateNodeConstructor } from './editor/tools/StateNode'
import { TLCameraOptions } from './editor/types/misc-types'
import { ContainerProvider, useContainer } from './hooks/useContainer'
import { useCursor } from './hooks/useCursor'
import { useDarkMode } from './hooks/useDarkMode'
@ -114,6 +115,11 @@ export interface TldrawEditorBaseProps {
* Whether to infer dark mode from the user's OS. Defaults to false.
*/
inferDarkMode?: boolean
/**
* Camera options for the editor.
*/
cameraOptions?: Partial<TLCameraOptions>
}
/**
@ -266,6 +272,7 @@ function TldrawEditorWithReadyStore({
initialState,
autoFocus = true,
inferDarkMode,
cameraOptions,
}: Required<
TldrawEditorProps & {
store: TLStore
@ -286,13 +293,14 @@ function TldrawEditorWithReadyStore({
user,
initialState,
inferDarkMode,
cameraOptions,
})
setEditor(editor)
return () => {
editor.dispose()
}
}, [container, shapeUtils, tools, store, user, initialState, inferDarkMode])
}, [container, shapeUtils, tools, store, user, initialState, inferDarkMode, cameraOptions])
const crashingError = useSyncExternalStore(
useCallback(

Wyświetl plik

@ -1,3 +1,4 @@
import { TLCameraOptions } from './editor/types/misc-types'
import { EASINGS } from './primitives/easings'
/** @internal */
@ -11,13 +12,14 @@ export const ANIMATION_SHORT_MS = 80
export const ANIMATION_MEDIUM_MS = 320
/** @internal */
export const ZOOMS = [0.1, 0.25, 0.5, 1, 2, 4, 8]
/** @internal */
export const MIN_ZOOM = 0.1
/** @internal */
export const MAX_ZOOM = 8
export const DEFAULT_CAMERA_OPTIONS: TLCameraOptions = {
isLocked: false,
wheelBehavior: 'pan',
panSpeed: 1,
zoomSpeed: 1,
zoomSteps: [0.1, 0.25, 0.5, 1, 2, 4, 8],
}
/** @internal */
export const FOLLOW_CHASE_PROPORTION = 0.5
/** @internal */
export const FOLLOW_CHASE_PAN_SNAP = 0.1
@ -42,14 +44,6 @@ export const DRAG_DISTANCE = 16 // 4 squared
/** @internal */
export const SVG_PADDING = 32
/** @internal */
export const HASH_PATTERN_ZOOM_NAMES: Record<string, string> = {}
for (let zoom = 1; zoom <= Math.ceil(MAX_ZOOM); zoom++) {
HASH_PATTERN_ZOOM_NAMES[zoom + '_dark'] = `hash_pattern_zoom_${zoom}_dark`
HASH_PATTERN_ZOOM_NAMES[zoom + '_light'] = `hash_pattern_zoom_${zoom}_light`
}
/** @internal */
export const DEFAULT_ANIMATION_OPTIONS = {
duration: 0,
@ -113,3 +107,8 @@ export const LONG_PRESS_DURATION = 500
/** @internal */
export const TEXT_SHADOW_LOD = 0.35
export const LEFT_MOUSE_BUTTON = 0
export const RIGHT_MOUSE_BUTTON = 2
export const MIDDLE_MOUSE_BUTTON = 1
export const STYLUS_ERASER_BUTTON = 5

Wyświetl plik

@ -20,8 +20,6 @@ function isShapeNotVisible(editor: Editor, id: TLShapeId, viewportPageBounds: Bo
* @returns Incremental derivation of non visible shapes.
*/
export const notVisibleShapes = (editor: Editor) => {
const isCullingOffScreenShapes = Number.isFinite(editor.renderingBoundsMargin)
function fromScratch(editor: Editor): Set<TLShapeId> {
const shapes = editor.getCurrentPageShapeIds()
const viewportPageBounds = editor.getViewportPageBounds()
@ -34,8 +32,6 @@ export const notVisibleShapes = (editor: Editor) => {
return notVisibleShapes
}
return computed<Set<TLShapeId>>('getCulledShapes', (prevValue) => {
if (!isCullingOffScreenShapes) return new Set<TLShapeId>()
if (isUninitialized(prevValue)) {
return fromScratch(editor)
}

Wyświetl plik

@ -95,116 +95,118 @@ export class ClickManager {
lastPointerInfo = {} as TLPointerEventInfo
/**
* Start the double click timeout.
*
* @param info - The event info.
*/
transformPointerDownEvent = (info: TLPointerEventInfo): TLPointerEventInfo | TLClickEventInfo => {
if (!this._clickState) return info
handlePointerEvent = (info: TLPointerEventInfo): TLPointerEventInfo | TLClickEventInfo => {
switch (info.name) {
case 'pointer_down': {
if (!this._clickState) return info
this._clickScreenPoint = Vec.From(info.point)
this._clickScreenPoint = Vec.From(info.point)
if (
this._previousScreenPoint &&
this._previousScreenPoint.dist(this._clickScreenPoint) > MAX_CLICK_DISTANCE
) {
this._clickState = 'idle'
}
this._previousScreenPoint = this._clickScreenPoint
this.lastPointerInfo = info
switch (this._clickState) {
case 'idle': {
this._clickState = 'pendingDouble'
this._clickTimeout = this._getClickTimeout(this._clickState)
return info // returns the pointer event
}
case 'pendingDouble': {
this._clickState = 'pendingTriple'
this._clickTimeout = this._getClickTimeout(this._clickState)
return {
...info,
type: 'click',
name: 'double_click',
phase: 'down',
if (
this._previousScreenPoint &&
Vec.Dist2(this._previousScreenPoint, this._clickScreenPoint) > MAX_CLICK_DISTANCE ** 2
) {
this._clickState = 'idle'
}
}
case 'pendingTriple': {
this._clickState = 'pendingQuadruple'
this._clickTimeout = this._getClickTimeout(this._clickState)
return {
...info,
type: 'click',
name: 'triple_click',
phase: 'down',
this._previousScreenPoint = this._clickScreenPoint
this.lastPointerInfo = info
switch (this._clickState) {
case 'pendingDouble': {
this._clickState = 'pendingTriple'
this._clickTimeout = this._getClickTimeout(this._clickState)
return {
...info,
type: 'click',
name: 'double_click',
phase: 'down',
}
}
case 'pendingTriple': {
this._clickState = 'pendingQuadruple'
this._clickTimeout = this._getClickTimeout(this._clickState)
return {
...info,
type: 'click',
name: 'triple_click',
phase: 'down',
}
}
case 'pendingQuadruple': {
this._clickState = 'pendingOverflow'
this._clickTimeout = this._getClickTimeout(this._clickState)
return {
...info,
type: 'click',
name: 'quadruple_click',
phase: 'down',
}
}
case 'idle': {
this._clickState = 'pendingDouble'
break
}
case 'pendingOverflow': {
this._clickState = 'overflow'
break
}
default: {
// overflow
}
}
}
case 'pendingQuadruple': {
this._clickState = 'pendingOverflow'
this._clickTimeout = this._getClickTimeout(this._clickState)
return {
...info,
type: 'click',
name: 'quadruple_click',
phase: 'down',
}
}
case 'pendingOverflow': {
this._clickState = 'overflow'
this._clickTimeout = this._getClickTimeout(this._clickState)
return info
}
default: {
// overflow
this._clickTimeout = this._getClickTimeout(this._clickState)
return info
}
}
}
/**
* Emit click_up events on pointer up.
*
* @param info - The event info.
*/
transformPointerUpEvent = (info: TLPointerEventInfo): TLPointerEventInfo | TLClickEventInfo => {
if (!this._clickState) return info
this._clickScreenPoint = Vec.From(info.point)
switch (this._clickState) {
case 'pendingTriple': {
return {
...this.lastPointerInfo,
type: 'click',
name: 'double_click',
phase: 'up',
}
}
case 'pendingQuadruple': {
return {
...this.lastPointerInfo,
type: 'click',
name: 'triple_click',
phase: 'up',
}
}
case 'pendingOverflow': {
return {
...this.lastPointerInfo,
type: 'click',
name: 'quadruple_click',
phase: 'up',
}
}
default: {
// idle, pendingDouble, overflow
case 'pointer_up': {
if (!this._clickState) return info
this._clickScreenPoint = Vec.From(info.point)
switch (this._clickState) {
case 'pendingTriple': {
return {
...this.lastPointerInfo,
type: 'click',
name: 'double_click',
phase: 'up',
}
}
case 'pendingQuadruple': {
return {
...this.lastPointerInfo,
type: 'click',
name: 'triple_click',
phase: 'up',
}
}
case 'pendingOverflow': {
return {
...this.lastPointerInfo,
type: 'click',
name: 'quadruple_click',
phase: 'up',
}
}
default: {
// idle, pendingDouble, overflow
}
}
return info
}
case 'pointer_move': {
if (
this._clickState !== 'idle' &&
this._clickScreenPoint &&
Vec.Dist2(this._clickScreenPoint, this.editor.inputs.currentScreenPoint) >
(this.editor.getInstanceState().isCoarsePointer ? COARSE_DRAG_DISTANCE : DRAG_DISTANCE)
) {
this.cancelDoubleClickTimeout()
}
return info
}
}
return info
}
/**
@ -216,21 +218,4 @@ export class ClickManager {
this._clickTimeout = clearTimeout(this._clickTimeout)
this._clickState = 'idle'
}
/**
* Handle a move event, possibly cancelling the click timeout.
*
* @internal
*/
handleMove = () => {
// Cancel a double click event if the user has started dragging.
if (
this._clickState !== 'idle' &&
this._clickScreenPoint &&
Vec.Dist2(this._clickScreenPoint, this.editor.inputs.currentScreenPoint) >
(this.editor.getInstanceState().isCoarsePointer ? COARSE_DRAG_DISTANCE : DRAG_DISTANCE)
) {
this.cancelDoubleClickTimeout()
}
}
}

Wyświetl plik

@ -64,7 +64,7 @@ export class SnapManager {
// TODO: make this an incremental derivation
@computed getSnappableShapes(): Set<TLShapeId> {
const { editor } = this
const renderingBounds = editor.getRenderingBounds()
const renderingBounds = editor.getViewportPageBounds()
const selectedShapeIds = editor.getSelectedShapeIds()
const snappableShapes: Set<TLShapeId> = new Set()

Wyświetl plik

@ -1,4 +1,6 @@
import { BoxModel } from '@tldraw/tlschema'
import { Box } from '../../primitives/Box'
import { VecLike } from '../../primitives/Vec'
/** @public */
export type RequiredKeys<T, K extends keyof T> = Partial<Omit<T, K>> & Pick<T, K>
@ -14,3 +16,58 @@ export type TLSvgOptions = {
darkMode?: boolean
preserveAspectRatio: React.SVGAttributes<SVGSVGElement>['preserveAspectRatio']
}
/** @public */
export type TLCameraMoveOptions = Partial<{
/** Whether to move the camera immediately, rather than on the next tick. */
immediate: boolean
/** Whether to force the camera to move, even if the user's camera options have locked the camera. */
force: boolean
/** Whether to reset the camera to its default position and zoom. */
reset: boolean
/** An (optional) animation to use. */
animation: Partial<{
/** The time the animation should take to arrive at the specified camera coordinates. */
duration: number
/** An easing function to apply to the animation's progress from start to end. */
easing: (t: number) => number
}>
}>
/** @public */
export type TLCameraOptions = {
/** Controls whether the wheel pans or zooms. */
wheelBehavior: 'zoom' | 'pan' | 'none'
/** The speed of a scroll wheel / trackpad pan */
panSpeed: number
/** The speed of a scroll wheel / trackpad zoom */
zoomSpeed: number
/** The steps that a user can zoom between with zoom in / zoom out (zoom factors) */
zoomSteps: number[]
/** Whether the camera is locked */
isLocked: boolean
/** The camera constraints */
constraints?: {
/** The bounds (in page space) of the constrained space */
bounds: BoxModel
/** The padding inside of the viewport (in screen space) */
padding: VecLike
/** The origin for placement. Used to position the bounds within the viewport when an axis is fixed or contained and zoom is below the axis fit. */
origin: VecLike
/** The camera's initial zoom, used also when the camera is reset. */
initialZoom: 'fit-min' | 'fit-max' | 'fit-x' | 'fit-y' | 'default'
/** The camera's base for its zoom steps. */
baseZoom: 'fit-min' | 'fit-max' | 'fit-x' | 'fit-y' | 'default'
/** The behavior for the constraints on the x axis. */
behavior:
| 'free'
| 'contain'
| 'inside'
| 'outside'
| 'fixed'
| {
x: 'contain' | 'inside' | 'outside' | 'fixed' | 'free'
y: 'contain' | 'inside' | 'outside' | 'fixed' | 'free'
}
}
}

Wyświetl plik

@ -1,4 +1,5 @@
import React, { useMemo } from 'react'
import { RIGHT_MOUSE_BUTTON } from '../constants'
import {
preventDefault,
releasePointerCapture,
@ -19,7 +20,7 @@ export function useCanvasEvents() {
function onPointerDown(e: React.PointerEvent) {
if ((e as any).isKilled) return
if (e.button === 2) {
if (e.button === RIGHT_MOUSE_BUTTON) {
editor.dispatch({
type: 'pointer',
target: 'canvas',

Wyświetl plik

@ -1,4 +1,5 @@
import { useMemo } from 'react'
import { RIGHT_MOUSE_BUTTON } from '../constants'
import { TLSelectionHandle } from '../editor/types/selection-types'
import {
loopToHtmlElement,
@ -18,7 +19,7 @@ export function useSelectionEvents(handle: TLSelectionHandle) {
const onPointerDown: React.PointerEventHandler = (e) => {
if ((e as any).isKilled) return
if (e.button === 2) {
if (e.button === RIGHT_MOUSE_BUTTON) {
editor.dispatch({
type: 'pointer',
target: 'selection',

Wyświetl plik

@ -1,5 +1,6 @@
import { COARSE_POINTER_WIDTH, EDGE_SCROLL_DISTANCE, EDGE_SCROLL_SPEED } from '../constants'
import { Editor } from '../editor/Editor'
import { Vec } from '../primitives/Vec'
/**
* Helper function to get the scroll proximity factor for a given position.
@ -33,11 +34,7 @@ function getEdgeProximityFactor(
* @public
*/
export function moveCameraWhenCloseToEdge(editor: Editor) {
if (
!editor.inputs.isDragging ||
editor.inputs.isPanning ||
!editor.getInstanceState().canMoveCamera
)
if (!editor.inputs.isDragging || editor.inputs.isPanning || editor.getCameraOptions().isLocked)
return
const {
@ -68,8 +65,5 @@ export function moveCameraWhenCloseToEdge(editor: Editor) {
const camera = editor.getCamera()
editor.setCamera({
x: camera.x + scrollDeltaX,
y: camera.y + scrollDeltaY,
})
editor.setCamera(new Vec(camera.x + scrollDeltaX, camera.y + scrollDeltaY, camera.z))
}

Wyświetl plik

@ -3358,6 +3358,30 @@
"parameters": [],
"name": "DefaultDebugMenuContent"
},
{
"kind": "Variable",
"canonicalReference": "tldraw!defaultEditorAssetUrls:var",
"docComment": "/**\n * @public\n */\n",
"excerptTokens": [
{
"kind": "Content",
"text": "defaultEditorAssetUrls: "
},
{
"kind": "Reference",
"text": "TLEditorAssetUrls",
"canonicalReference": "tldraw!~TLEditorAssetUrls:type"
}
],
"fileUrlPath": "packages/tldraw/src/lib/utils/static-assets/assetUrls.ts",
"isReadonly": false,
"releaseTag": "Public",
"name": "defaultEditorAssetUrls",
"variableTypeTokenRange": {
"startIndex": 1,
"endIndex": 2
}
},
{
"kind": "Function",
"canonicalReference": "tldraw!DefaultHelperButtons:function(1)",
@ -6450,7 +6474,7 @@
{
"kind": "Reference",
"text": "TLExportType",
"canonicalReference": "tldraw!~TLExportType:type"
"canonicalReference": "tldraw!TLExportType:type"
},
{
"kind": "Content",
@ -9202,6 +9226,97 @@
],
"name": "getOccludedChildren"
},
{
"kind": "Function",
"canonicalReference": "tldraw!getPerfectDashProps:function(1)",
"docComment": "/**\n * @public\n */\n",
"excerptTokens": [
{
"kind": "Content",
"text": "export declare function getPerfectDashProps(totalLength: "
},
{
"kind": "Content",
"text": "number"
},
{
"kind": "Content",
"text": ", strokeWidth: "
},
{
"kind": "Content",
"text": "number"
},
{
"kind": "Content",
"text": ", opts?: "
},
{
"kind": "Reference",
"text": "Partial",
"canonicalReference": "!Partial:type"
},
{
"kind": "Content",
"text": "<{\n closed: boolean;\n end: 'none' | 'outset' | 'skip';\n lengthRatio: number;\n snap: number;\n start: 'none' | 'outset' | 'skip';\n style: "
},
{
"kind": "Reference",
"text": "TLDefaultDashStyle",
"canonicalReference": "@tldraw/tlschema!TLDefaultDashStyle:type"
},
{
"kind": "Content",
"text": ";\n}>"
},
{
"kind": "Content",
"text": "): "
},
{
"kind": "Content",
"text": "{\n strokeDasharray: string;\n strokeDashoffset: string;\n}"
},
{
"kind": "Content",
"text": ";"
}
],
"fileUrlPath": "packages/tldraw/src/lib/shapes/shared/getPerfectDashProps.ts",
"returnTypeTokenRange": {
"startIndex": 10,
"endIndex": 11
},
"releaseTag": "Public",
"overloadIndex": 1,
"parameters": [
{
"parameterName": "totalLength",
"parameterTypeTokenRange": {
"startIndex": 1,
"endIndex": 2
},
"isOptional": false
},
{
"parameterName": "strokeWidth",
"parameterTypeTokenRange": {
"startIndex": 3,
"endIndex": 4
},
"isOptional": false
},
{
"parameterName": "opts",
"parameterTypeTokenRange": {
"startIndex": 5,
"endIndex": 9
},
"isOptional": true
}
],
"name": "getPerfectDashProps"
},
{
"kind": "Function",
"canonicalReference": "tldraw!getSvgAsImage:function(1)",
@ -13950,6 +14065,191 @@
"parameters": [],
"name": "PasteMenuItem"
},
{
"kind": "Enum",
"canonicalReference": "tldraw!PORTRAIT_BREAKPOINT:enum",
"docComment": "/**\n * @public\n */\n",
"excerptTokens": [
{
"kind": "Content",
"text": "export declare enum PORTRAIT_BREAKPOINT "
}
],
"fileUrlPath": "packages/tldraw/src/lib/ui/constants.ts",
"releaseTag": "Public",
"name": "PORTRAIT_BREAKPOINT",
"preserveMemberOrder": false,
"members": [
{
"kind": "EnumMember",
"canonicalReference": "tldraw!PORTRAIT_BREAKPOINT.DESKTOP:member",
"docComment": "",
"excerptTokens": [
{
"kind": "Content",
"text": "DESKTOP = "
},
{
"kind": "Content",
"text": "7"
}
],
"initializerTokenRange": {
"startIndex": 1,
"endIndex": 2
},
"releaseTag": "Public",
"name": "DESKTOP"
},
{
"kind": "EnumMember",
"canonicalReference": "tldraw!PORTRAIT_BREAKPOINT.MOBILE:member",
"docComment": "",
"excerptTokens": [
{
"kind": "Content",
"text": "MOBILE = "
},
{
"kind": "Content",
"text": "4"
}
],
"initializerTokenRange": {
"startIndex": 1,
"endIndex": 2
},
"releaseTag": "Public",
"name": "MOBILE"
},
{
"kind": "EnumMember",
"canonicalReference": "tldraw!PORTRAIT_BREAKPOINT.MOBILE_SM:member",
"docComment": "",
"excerptTokens": [
{
"kind": "Content",
"text": "MOBILE_SM = "
},
{
"kind": "Content",
"text": "3"
}
],
"initializerTokenRange": {
"startIndex": 1,
"endIndex": 2
},
"releaseTag": "Public",
"name": "MOBILE_SM"
},
{
"kind": "EnumMember",
"canonicalReference": "tldraw!PORTRAIT_BREAKPOINT.MOBILE_XS:member",
"docComment": "",
"excerptTokens": [
{
"kind": "Content",
"text": "MOBILE_XS = "
},
{
"kind": "Content",
"text": "2"
}
],
"initializerTokenRange": {
"startIndex": 1,
"endIndex": 2
},
"releaseTag": "Public",
"name": "MOBILE_XS"
},
{
"kind": "EnumMember",
"canonicalReference": "tldraw!PORTRAIT_BREAKPOINT.MOBILE_XXS:member",
"docComment": "",
"excerptTokens": [
{
"kind": "Content",
"text": "MOBILE_XXS = "
},
{
"kind": "Content",
"text": "1"
}
],
"initializerTokenRange": {
"startIndex": 1,
"endIndex": 2
},
"releaseTag": "Public",
"name": "MOBILE_XXS"
},
{
"kind": "EnumMember",
"canonicalReference": "tldraw!PORTRAIT_BREAKPOINT.TABLET:member",
"docComment": "",
"excerptTokens": [
{
"kind": "Content",
"text": "TABLET = "
},
{
"kind": "Content",
"text": "6"
}
],
"initializerTokenRange": {
"startIndex": 1,
"endIndex": 2
},
"releaseTag": "Public",
"name": "TABLET"
},
{
"kind": "EnumMember",
"canonicalReference": "tldraw!PORTRAIT_BREAKPOINT.TABLET_SM:member",
"docComment": "",
"excerptTokens": [
{
"kind": "Content",
"text": "TABLET_SM = "
},
{
"kind": "Content",
"text": "5"
}
],
"initializerTokenRange": {
"startIndex": 1,
"endIndex": 2
},
"releaseTag": "Public",
"name": "TABLET_SM"
},
{
"kind": "EnumMember",
"canonicalReference": "tldraw!PORTRAIT_BREAKPOINT.ZERO:member",
"docComment": "",
"excerptTokens": [
{
"kind": "Content",
"text": "ZERO = "
},
{
"kind": "Content",
"text": "0"
}
],
"initializerTokenRange": {
"startIndex": 1,
"endIndex": 2
},
"releaseTag": "Public",
"name": "ZERO"
}
]
},
{
"kind": "Function",
"canonicalReference": "tldraw!PreferencesGroup:function(1)",
@ -19960,6 +20260,32 @@
"endIndex": 7
}
},
{
"kind": "TypeAlias",
"canonicalReference": "tldraw!TLExportType:type",
"docComment": "/**\n * @public\n */\n",
"excerptTokens": [
{
"kind": "Content",
"text": "export type TLExportType = "
},
{
"kind": "Content",
"text": "'jpeg' | 'json' | 'png' | 'svg' | 'webp'"
},
{
"kind": "Content",
"text": ";"
}
],
"fileUrlPath": "packages/tldraw/src/lib/utils/export/exportAs.ts",
"releaseTag": "Public",
"name": "TLExportType",
"typeTokenRange": {
"startIndex": 1,
"endIndex": 2
}
},
{
"kind": "Interface",
"canonicalReference": "tldraw!TLUiActionItem:interface",
@ -20531,7 +20857,7 @@
},
{
"kind": "Content",
"text": "<T>, value: T, squashing: boolean) => void"
"text": "<T>, value: T) => void"
},
{
"kind": "Content",
@ -24472,6 +24798,33 @@
"endIndex": 2
}
},
{
"kind": "PropertySignature",
"canonicalReference": "tldraw!TLUiInputProps#onFocus:member",
"docComment": "",
"excerptTokens": [
{
"kind": "Content",
"text": "onFocus?: "
},
{
"kind": "Content",
"text": "() => void"
},
{
"kind": "Content",
"text": ";"
}
],
"isReadonly": false,
"isOptional": true,
"releaseTag": "Public",
"name": "onFocus",
"propertyTypeTokenRange": {
"startIndex": 1,
"endIndex": 2
}
},
{
"kind": "PropertySignature",
"canonicalReference": "tldraw!TLUiInputProps#onValueChange:member",
@ -27339,6 +27692,151 @@
"parameters": [],
"name": "useCurrentTranslation"
},
{
"kind": "Function",
"canonicalReference": "tldraw!useDefaultColorTheme:function(1)",
"docComment": "/**\n * @public\n */\n",
"excerptTokens": [
{
"kind": "Content",
"text": "export declare function useDefaultColorTheme(): "
},
{
"kind": "Content",
"text": "{\n \"light-blue\": import(\"@tldraw/editor\")."
},
{
"kind": "Reference",
"text": "TLDefaultColorThemeColor",
"canonicalReference": "@tldraw/tlschema!TLDefaultColorThemeColor:type"
},
{
"kind": "Content",
"text": ";\n \"light-green\": import(\"@tldraw/editor\")."
},
{
"kind": "Reference",
"text": "TLDefaultColorThemeColor",
"canonicalReference": "@tldraw/tlschema!TLDefaultColorThemeColor:type"
},
{
"kind": "Content",
"text": ";\n \"light-red\": import(\"@tldraw/editor\")."
},
{
"kind": "Reference",
"text": "TLDefaultColorThemeColor",
"canonicalReference": "@tldraw/tlschema!TLDefaultColorThemeColor:type"
},
{
"kind": "Content",
"text": ";\n \"light-violet\": import(\"@tldraw/editor\")."
},
{
"kind": "Reference",
"text": "TLDefaultColorThemeColor",
"canonicalReference": "@tldraw/tlschema!TLDefaultColorThemeColor:type"
},
{
"kind": "Content",
"text": ";\n background: string;\n black: import(\"@tldraw/editor\")."
},
{
"kind": "Reference",
"text": "TLDefaultColorThemeColor",
"canonicalReference": "@tldraw/tlschema!TLDefaultColorThemeColor:type"
},
{
"kind": "Content",
"text": ";\n blue: import(\"@tldraw/editor\")."
},
{
"kind": "Reference",
"text": "TLDefaultColorThemeColor",
"canonicalReference": "@tldraw/tlschema!TLDefaultColorThemeColor:type"
},
{
"kind": "Content",
"text": ";\n green: import(\"@tldraw/editor\")."
},
{
"kind": "Reference",
"text": "TLDefaultColorThemeColor",
"canonicalReference": "@tldraw/tlschema!TLDefaultColorThemeColor:type"
},
{
"kind": "Content",
"text": ";\n grey: import(\"@tldraw/editor\")."
},
{
"kind": "Reference",
"text": "TLDefaultColorThemeColor",
"canonicalReference": "@tldraw/tlschema!TLDefaultColorThemeColor:type"
},
{
"kind": "Content",
"text": ";\n id: \"dark\" | \"light\";\n orange: import(\"@tldraw/editor\")."
},
{
"kind": "Reference",
"text": "TLDefaultColorThemeColor",
"canonicalReference": "@tldraw/tlschema!TLDefaultColorThemeColor:type"
},
{
"kind": "Content",
"text": ";\n red: import(\"@tldraw/editor\")."
},
{
"kind": "Reference",
"text": "TLDefaultColorThemeColor",
"canonicalReference": "@tldraw/tlschema!TLDefaultColorThemeColor:type"
},
{
"kind": "Content",
"text": ";\n solid: string;\n text: string;\n violet: import(\"@tldraw/editor\")."
},
{
"kind": "Reference",
"text": "TLDefaultColorThemeColor",
"canonicalReference": "@tldraw/tlschema!TLDefaultColorThemeColor:type"
},
{
"kind": "Content",
"text": ";\n white: import(\"@tldraw/editor\")."
},
{
"kind": "Reference",
"text": "TLDefaultColorThemeColor",
"canonicalReference": "@tldraw/tlschema!TLDefaultColorThemeColor:type"
},
{
"kind": "Content",
"text": ";\n yellow: import(\"@tldraw/editor\")."
},
{
"kind": "Reference",
"text": "TLDefaultColorThemeColor",
"canonicalReference": "@tldraw/tlschema!TLDefaultColorThemeColor:type"
},
{
"kind": "Content",
"text": ";\n}"
},
{
"kind": "Content",
"text": ";"
}
],
"fileUrlPath": "packages/tldraw/src/lib/shapes/shared/ShapeFill.tsx",
"returnTypeTokenRange": {
"startIndex": 1,
"endIndex": 28
},
"releaseTag": "Public",
"overloadIndex": 1,
"parameters": [],
"name": "useDefaultColorTheme"
},
{
"kind": "Function",
"canonicalReference": "tldraw!useDefaultHelpers:function(1)",
@ -27625,7 +28123,7 @@
{
"kind": "Reference",
"text": "TLExportType",
"canonicalReference": "tldraw!~TLExportType:type"
"canonicalReference": "tldraw!TLExportType:type"
},
{
"kind": "Content",

Wyświetl plik

@ -152,7 +152,9 @@ export function registerDefaultExternalContentHandlers(
editor.registerExternalContentHandler('svg-text', async ({ point, text }) => {
const position =
point ??
(editor.inputs.shiftKey ? editor.inputs.currentPagePoint : editor.getViewportPageCenter())
(editor.inputs.shiftKey
? editor.inputs.currentPagePoint
: editor.getViewportPageBounds().center)
const svg = new DOMParser().parseFromString(text, 'image/svg+xml').querySelector('svg')
if (!svg) {
@ -185,7 +187,9 @@ export function registerDefaultExternalContentHandlers(
editor.registerExternalContentHandler('embed', ({ point, url, embed }) => {
const position =
point ??
(editor.inputs.shiftKey ? editor.inputs.currentPagePoint : editor.getViewportPageCenter())
(editor.inputs.shiftKey
? editor.inputs.currentPagePoint
: editor.getViewportPageBounds().center)
const { width, height } = embed
@ -210,7 +214,9 @@ export function registerDefaultExternalContentHandlers(
editor.registerExternalContentHandler('files', async ({ point, files }) => {
const position =
point ??
(editor.inputs.shiftKey ? editor.inputs.currentPagePoint : editor.getViewportPageCenter())
(editor.inputs.shiftKey
? editor.inputs.currentPagePoint
: editor.getViewportPageBounds().center)
const pagePoint = new Vec(position.x, position.y)
@ -266,7 +272,9 @@ export function registerDefaultExternalContentHandlers(
editor.registerExternalContentHandler('text', async ({ point, text }) => {
const p =
point ??
(editor.inputs.shiftKey ? editor.inputs.currentPagePoint : editor.getViewportPageCenter())
(editor.inputs.shiftKey
? editor.inputs.currentPagePoint
: editor.getViewportPageBounds().center)
const defaultProps = editor.getShapeUtil<TLTextShape>('text').getDefaultProps()
@ -370,7 +378,9 @@ export function registerDefaultExternalContentHandlers(
const position =
point ??
(editor.inputs.shiftKey ? editor.inputs.currentPagePoint : editor.getViewportPageCenter())
(editor.inputs.shiftKey
? editor.inputs.currentPagePoint
: editor.getViewportPageBounds().center)
const assetId: TLAssetId = AssetRecordType.createId(getHashForString(url))
const shape = createEmptyBookmarkShape(editor, url, position)

Wyświetl plik

@ -1,5 +1,4 @@
import {
HASH_PATTERN_ZOOM_NAMES,
TLDefaultColorStyle,
TLDefaultColorTheme,
TLDefaultFillStyle,
@ -10,6 +9,7 @@ import {
useValue,
} from '@tldraw/editor'
import React from 'react'
import { HASH_PATTERN_ZOOM_NAMES } from './defaultStyleDefs'
export interface ShapeFillProps {
d: string
@ -40,7 +40,7 @@ export const ShapeFill = React.memo(function ShapeFill({ theme, d, color, fill }
}
})
const PatternFill = function PatternFill({ d, color, theme }: ShapeFillProps) {
export function PatternFill({ d, color, theme }: ShapeFillProps) {
const editor = useEditor()
const svgExport = useSvgExportContext()
const zoomLevel = useValue('zoomLevel', () => editor.getZoomLevel(), [editor])

Wyświetl plik

@ -3,8 +3,6 @@ import {
DefaultFontFamilies,
DefaultFontStyle,
FileHelpers,
HASH_PATTERN_ZOOM_NAMES,
MAX_ZOOM,
SvgExportDef,
TLDefaultFillStyle,
TLDefaultFontStyle,
@ -15,6 +13,16 @@ import {
import { useEffect, useMemo, useRef, useState } from 'react'
import { useDefaultColorTheme } from './ShapeFill'
/** @internal */
export const HASH_PATTERN_ZOOM_NAMES: Record<string, string> = {}
const HASH_PATTERN_COUNT = 6
for (let zoom = 1; zoom <= HASH_PATTERN_COUNT; zoom++) {
HASH_PATTERN_ZOOM_NAMES[zoom + '_dark'] = `hash_pattern_zoom_${zoom}_dark`
HASH_PATTERN_ZOOM_NAMES[zoom + '_light'] = `hash_pattern_zoom_${zoom}_light`
}
/** @public */
export function getFontDefForExport(fontStyle: TLDefaultFontStyle): SvgExportDef {
return {
@ -148,7 +156,7 @@ type PatternDef = { zoom: number; url: string; darkMode: boolean }
const getDefaultPatterns = () => {
const defaultPatterns: PatternDef[] = []
for (let i = 1; i <= Math.ceil(MAX_ZOOM); i++) {
for (let i = 1; i <= HASH_PATTERN_COUNT; i++) {
const whitePixelBlob = canvasBlob([1, 1], (ctx) => {
ctx.fillStyle = DefaultColorThemePalette.lightMode.black.semi
ctx.fillRect(0, 0, 1, 1)
@ -186,7 +194,7 @@ function usePattern() {
const promises: Promise<{ zoom: number; url: string; darkMode: boolean }>[] = []
for (let i = 1; i <= Math.ceil(MAX_ZOOM); i++) {
for (let i = 1; i <= HASH_PATTERN_COUNT; i++) {
promises.push(
generateImage(dpr, i, false).then((blob) => ({
zoom: i,

Wyświetl plik

@ -12,14 +12,18 @@ export class HandTool extends StateNode {
override onDoubleClick: TLClickEvent = (info) => {
if (info.phase === 'settle') {
const { currentScreenPoint } = this.editor.inputs
this.editor.zoomIn(currentScreenPoint, { duration: 220, easing: EASINGS.easeOutQuint })
this.editor.zoomIn(currentScreenPoint, {
animation: { duration: 220, easing: EASINGS.easeOutQuint },
})
}
}
override onTripleClick: TLClickEvent = (info) => {
if (info.phase === 'settle') {
const { currentScreenPoint } = this.editor.inputs
this.editor.zoomOut(currentScreenPoint, { duration: 320, easing: EASINGS.easeOutQuint })
this.editor.zoomOut(currentScreenPoint, {
animation: { duration: 320, easing: EASINGS.easeOutQuint },
})
}
}
@ -31,9 +35,11 @@ export class HandTool extends StateNode {
} = this.editor
if (zoomLevel === 1) {
this.editor.zoomToFit({ duration: 400, easing: EASINGS.easeOutQuint })
this.editor.zoomToFit({ animation: { duration: 400, easing: EASINGS.easeOutQuint } })
} else {
this.editor.resetZoom(currentScreenPoint, { duration: 320, easing: EASINGS.easeOutQuint })
this.editor.resetZoom(currentScreenPoint, {
animation: { duration: 320, easing: EASINGS.easeOutQuint },
})
}
}
}

Wyświetl plik

@ -148,7 +148,9 @@ export function zoomToShapeIfOffscreen(editor: Editor) {
y: (eb.center.y - viewportPageBounds.center.y) * 2,
})
editor.zoomToBounds(nextBounds, {
duration: ANIMATION_MEDIUM_MS,
animation: {
duration: ANIMATION_MEDIUM_MS,
},
inset: 0,
})
}

Wyświetl plik

@ -26,9 +26,9 @@ export class Pointing extends StateNode {
private complete() {
const { currentScreenPoint } = this.editor.inputs
if (this.editor.inputs.altKey) {
this.editor.zoomOut(currentScreenPoint, { duration: 220 })
this.editor.zoomOut(currentScreenPoint, { animation: { duration: 220 } })
} else {
this.editor.zoomIn(currentScreenPoint, { duration: 220 })
this.editor.zoomIn(currentScreenPoint, { animation: { duration: 220 } })
}
this.parent.transition('idle', this.info)
}

Wyświetl plik

@ -48,13 +48,13 @@ export class ZoomBrushing extends StateNode {
if (zoomBrush.width < threshold && zoomBrush.height < threshold) {
const point = this.editor.inputs.currentScreenPoint
if (this.editor.inputs.altKey) {
this.editor.zoomOut(point, { duration: 220 })
this.editor.zoomOut(point, { animation: { duration: 220 } })
} else {
this.editor.zoomIn(point, { duration: 220 })
this.editor.zoomIn(point, { animation: { duration: 220 } })
}
} else {
const targetZoom = this.editor.inputs.altKey ? this.editor.getZoomLevel() / 2 : undefined
this.editor.zoomToBounds(zoomBrush, { targetZoom, duration: 220 })
this.editor.zoomToBounds(zoomBrush, { targetZoom, animation: { duration: 220 } })
}
this.parent.transition('idle', this.info)

Wyświetl plik

@ -117,7 +117,7 @@ export const EmbedDialog = track(function EmbedDialog({ onClose }: TLUiDialogPro
editor.putExternalContent({
type: 'embed',
url,
point: editor.getViewportPageCenter(),
point: editor.getViewportPageBounds().center,
embed: embedInfoForUrl.definition,
})

Wyświetl plik

@ -49,7 +49,7 @@ export function DefaultMinimap() {
minimapRef.current.originPagePoint.setTo(clampedPoint)
minimapRef.current.originPageCenter.setTo(editor.getViewportPageBounds().center)
editor.centerOnPoint(point, { duration: ANIMATION_MEDIUM_MS })
editor.centerOnPoint(point, { animation: { duration: ANIMATION_MEDIUM_MS } })
},
[editor]
)
@ -91,7 +91,7 @@ export function DefaultMinimap() {
const pagePoint = Vec.Add(point, delta)
minimapRef.current.originPagePoint.setTo(pagePoint)
minimapRef.current.originPageCenter.setTo(point)
editor.centerOnPoint(point, { duration: ANIMATION_MEDIUM_MS })
editor.centerOnPoint(point, { animation: { duration: ANIMATION_MEDIUM_MS } })
}
function release(e: PointerEvent) {

Wyświetl plik

@ -55,7 +55,9 @@ const ZoomTriggerButton = forwardRef<HTMLButtonElement, any>(
const msg = useTranslation()
const handleDoubleClick = useCallback(() => {
editor.resetZoom(editor.getViewportScreenCenter(), { duration: ANIMATION_MEDIUM_MS })
editor.resetZoom(editor.getViewportScreenCenter(), {
animation: { duration: ANIMATION_MEDIUM_MS },
})
}, [editor])
return (

Wyświetl plik

@ -20,6 +20,7 @@ import {
useEditor,
} from '@tldraw/editor'
import * as React from 'react'
import { ADJACENT_NOTE_MARGIN } from '../../shapes/note/noteHelpers'
import { kickoutOccludedShapes } from '../../tools/SelectTool/selectHelpers'
import { getEmbedInfo } from '../../utils/embeds/embeds'
import { fitFrameToContent, removeFrame } from '../../utils/frames/frames'
@ -502,7 +503,7 @@ export function ActionsProvider({ overrides, children }: ActionsProviderProps) {
} else {
ids = editor.getSelectedShapeIds()
const commonBounds = Box.Common(compact(ids.map((id) => editor.getShapePageBounds(id))))
offset = instanceState.canMoveCamera
offset = !editor.getCameraOptions().isLocked
? {
x: commonBounds.width + 20,
y: 0,
@ -818,7 +819,7 @@ export function ActionsProvider({ overrides, children }: ActionsProviderProps) {
trackEvent('pack-shapes', { source })
editor.mark('pack')
const selectedShapeIds = editor.getSelectedShapeIds()
editor.packShapes(selectedShapeIds, 16)
editor.packShapes(selectedShapeIds, ADJACENT_NOTE_MARGIN)
kickoutOccludedShapes(editor, selectedShapeIds)
},
},
@ -1036,7 +1037,9 @@ export function ActionsProvider({ overrides, children }: ActionsProviderProps) {
readonlyOk: true,
onSelect(source) {
trackEvent('zoom-in', { source })
editor.zoomIn(editor.getViewportScreenCenter(), { duration: ANIMATION_MEDIUM_MS })
editor.zoomIn(undefined, {
animation: { duration: ANIMATION_MEDIUM_MS },
})
},
},
{
@ -1046,7 +1049,9 @@ export function ActionsProvider({ overrides, children }: ActionsProviderProps) {
readonlyOk: true,
onSelect(source) {
trackEvent('zoom-out', { source })
editor.zoomOut(editor.getViewportScreenCenter(), { duration: ANIMATION_MEDIUM_MS })
editor.zoomOut(undefined, {
animation: { duration: ANIMATION_MEDIUM_MS },
})
},
},
{
@ -1057,7 +1062,9 @@ export function ActionsProvider({ overrides, children }: ActionsProviderProps) {
readonlyOk: true,
onSelect(source) {
trackEvent('reset-zoom', { source })
editor.resetZoom(editor.getViewportScreenCenter(), { duration: ANIMATION_MEDIUM_MS })
editor.resetZoom(undefined, {
animation: { duration: ANIMATION_MEDIUM_MS },
})
},
},
{
@ -1067,7 +1074,7 @@ export function ActionsProvider({ overrides, children }: ActionsProviderProps) {
readonlyOk: true,
onSelect(source) {
trackEvent('zoom-to-fit', { source })
editor.zoomToFit({ duration: ANIMATION_MEDIUM_MS })
editor.zoomToFit({ animation: { duration: ANIMATION_MEDIUM_MS } })
},
},
{
@ -1080,7 +1087,7 @@ export function ActionsProvider({ overrides, children }: ActionsProviderProps) {
if (mustGoBackToSelectToolFirst()) return
trackEvent('zoom-to-selection', { source })
editor.zoomToSelection({ duration: ANIMATION_MEDIUM_MS })
editor.zoomToSelection({ animation: { duration: ANIMATION_MEDIUM_MS } })
},
},
{
@ -1287,7 +1294,12 @@ export function ActionsProvider({ overrides, children }: ActionsProviderProps) {
readonlyOk: true,
onSelect(source) {
trackEvent('zoom-to-content', { source })
editor.zoomToContent()
const bounds = editor.getSelectionPageBounds() ?? editor.getCurrentPageBounds()
if (!bounds) return
editor.zoomToBounds(bounds, {
targetZoom: Math.min(1, editor.getZoomLevel()),
animation: { duration: 220 },
})
},
},
{

Wyświetl plik

@ -648,6 +648,7 @@ export function useNativeClipboardEvents() {
let disablingMiddleClickPaste = false
const pointerUpHandler = (e: PointerEvent) => {
if (e.button === 1) {
// middle mouse button
disablingMiddleClickPaste = true
requestAnimationFrame(() => {
disablingMiddleClickPaste = false

Wyświetl plik

@ -305,7 +305,6 @@ export async function parseAndLoadDocument(
editor.history.clear()
// Put the old bounds back in place
editor.updateViewportScreenBounds(initialBounds)
editor.updateRenderingBounds()
const bounds = editor.getCurrentPageBounds()
if (bounds) {

Wyświetl plik

@ -22,6 +22,7 @@ import {
TLWheelEventInfo,
Vec,
VecLike,
computed,
createShapeId,
createTLStore,
rotateSelectionHandle,
@ -143,6 +144,15 @@ export class TestEditor extends Editor {
elm: HTMLDivElement
bounds = { x: 0, y: 0, top: 0, left: 0, width: 1080, height: 720, bottom: 720, right: 1080 }
/**
* The center of the viewport in the current page space.
*
* @public
*/
@computed getViewportPageCenter() {
return this.getViewportPageBounds().center
}
setScreenBounds(bounds: BoxModel, center = false) {
this.bounds.x = bounds.x
this.bounds.y = bounds.y
@ -154,7 +164,6 @@ export class TestEditor extends Editor {
this.bounds.bottom = bounds.y + bounds.h
this.updateViewportScreenBounds(Box.From(bounds), center)
this.updateRenderingBounds()
return this
}
@ -200,12 +209,12 @@ export class TestEditor extends Editor {
* _transformPointerDownSpy.mockRestore())
*/
_transformPointerDownSpy = jest
.spyOn(this._clickManager, 'transformPointerDownEvent')
.spyOn(this._clickManager, 'handlePointerEvent')
.mockImplementation((info) => {
return info
})
_transformPointerUpSpy = jest
.spyOn(this._clickManager, 'transformPointerDownEvent')
.spyOn(this._clickManager, 'handlePointerEvent')
.mockImplementation((info) => {
return info
})
@ -455,6 +464,16 @@ export class TestEditor extends Editor {
return this
}
pan(offset: VecLike): this {
const { isLocked, panSpeed } = this.getCameraOptions()
if (isLocked) return this
const { x: cx, y: cy, z: cz } = this.getCamera()
this.setCamera(new Vec(cx + (offset.x * panSpeed) / cz, cy + (offset.y * panSpeed) / cz, cz), {
immediate: true,
})
return this
}
pinchStart = (
x = this.inputs.currentScreenPoint.x,
y = this.inputs.currentScreenPoint.y,

Wyświetl plik

@ -11,7 +11,7 @@ jest.useFakeTimers()
it('zooms in gradually when duration is present and animtion speed is default', () => {
expect(editor.getZoomLevel()).toBe(1)
editor.user.updateUserPreferences({ animationSpeed: 1 }) // default
editor.zoomIn(undefined, { duration: 100 })
editor.zoomIn(undefined, { animation: { duration: 100 } })
editor.emit('tick', 25) // <-- quarter way
expect(editor.getZoomLevel()).not.toBe(2)
editor.emit('tick', 25) // 50 <-- half way
@ -23,14 +23,14 @@ it('zooms in gradually when duration is present and animtion speed is default',
it('zooms in gradually when duration is present and animtion speed is off', () => {
expect(editor.getZoomLevel()).toBe(1)
editor.user.updateUserPreferences({ animationSpeed: 0 }) // none
editor.zoomIn(undefined, { duration: 100 })
editor.zoomIn(undefined, { animation: { duration: 100 } })
expect(editor.getZoomLevel()).toBe(2) // <-- Should skip!
})
it('zooms in gradually when duration is present and animtion speed is double', () => {
expect(editor.getZoomLevel()).toBe(1)
editor.user.updateUserPreferences({ animationSpeed: 2 }) // default
editor.zoomIn(undefined, { duration: 100 })
editor.zoomIn(undefined, { animation: { duration: 100 } })
editor.emit('tick', 25) // <-- half way
expect(editor.getZoomLevel()).not.toBe(2)
editor.emit('tick', 25) // 50 <-- should finish

Wyświetl plik

@ -12,7 +12,7 @@ it('centers on the point', () => {
})
it('centers on the point with animation', () => {
editor.centerOnPoint({ x: 400, y: 400 }, { duration: 200 })
editor.centerOnPoint({ x: 400, y: 400 }, { animation: { duration: 200 } })
expect(editor.getViewportPageCenter()).not.toMatchObject({ x: 400, y: 400 })
jest.advanceTimersByTime(100)
expect(editor.getViewportPageCenter()).not.toMatchObject({ x: 400, y: 400 })

Wyświetl plik

@ -0,0 +1,89 @@
import { TLCameraOptions } from '@tldraw/editor'
import { TestEditor } from '../TestEditor'
let editor: TestEditor
beforeEach(() => {
editor = new TestEditor()
})
describe('getBaseZoom', () => {
it('gets initial zoom with default options', () => {
expect(editor.getBaseZoom()).toBe(1)
})
it('gets initial zoom based on constraints', () => {
const vsb = editor.getViewportScreenBounds()
let cameraOptions: TLCameraOptions
cameraOptions = editor.getCameraOptions()
editor.setCameraOptions({
...cameraOptions,
constraints: {
bounds: { x: 0, y: 0, w: vsb.w * 2, h: vsb.h * 4 },
padding: { x: 0, y: 0 },
origin: { x: 0.5, y: 0.5 },
initialZoom: 'default',
baseZoom: 'default',
behavior: 'free',
},
})
expect(editor.getBaseZoom()).toBe(1)
cameraOptions = editor.getCameraOptions()
editor.setCameraOptions({
...cameraOptions,
constraints: {
...(cameraOptions.constraints as any),
baseZoom: 'fit-x',
},
})
expect(editor.getBaseZoom()).toBe(0.5)
cameraOptions = editor.getCameraOptions()
editor.setCameraOptions({
...cameraOptions,
constraints: {
...(cameraOptions.constraints as any),
baseZoom: 'fit-y',
},
})
expect(editor.getBaseZoom()).toBe(0.25)
cameraOptions = editor.getCameraOptions()
editor.setCameraOptions({
...cameraOptions,
constraints: {
...(cameraOptions.constraints as any),
baseZoom: 'fit-min',
},
})
expect(editor.getBaseZoom()).toBe(0.5)
cameraOptions = editor.getCameraOptions()
editor.setCameraOptions({
...cameraOptions,
constraints: {
...(cameraOptions.constraints as any),
baseZoom: 'fit-max',
},
})
expect(editor.getBaseZoom()).toBe(0.25)
cameraOptions = editor.getCameraOptions()
editor.setCameraOptions({
...cameraOptions,
constraints: {
...(cameraOptions.constraints as any),
baseZoom: 'default',
},
})
expect(editor.getBaseZoom()).toBe(1)
})
})

Wyświetl plik

@ -0,0 +1,89 @@
import { TLCameraOptions } from '@tldraw/editor'
import { TestEditor } from '../TestEditor'
let editor: TestEditor
beforeEach(() => {
editor = new TestEditor()
})
describe('getInitialZoom', () => {
it('gets initial zoom with default options', () => {
expect(editor.getInitialZoom()).toBe(1)
})
it('gets initial zoom based on constraints', () => {
const vsb = editor.getViewportScreenBounds()
let cameraOptions: TLCameraOptions
cameraOptions = editor.getCameraOptions()
editor.setCameraOptions({
...cameraOptions,
constraints: {
bounds: { x: 0, y: 0, w: vsb.w * 2, h: vsb.h * 4 },
padding: { x: 0, y: 0 },
origin: { x: 0.5, y: 0.5 },
initialZoom: 'default',
baseZoom: 'default',
behavior: 'free',
},
})
expect(editor.getInitialZoom()).toBe(1)
cameraOptions = editor.getCameraOptions()
editor.setCameraOptions({
...cameraOptions,
constraints: {
...(cameraOptions.constraints as any),
initialZoom: 'fit-x',
},
})
expect(editor.getInitialZoom()).toBe(0.5)
cameraOptions = editor.getCameraOptions()
editor.setCameraOptions({
...cameraOptions,
constraints: {
...(cameraOptions.constraints as any),
initialZoom: 'fit-y',
},
})
expect(editor.getInitialZoom()).toBe(0.25)
cameraOptions = editor.getCameraOptions()
editor.setCameraOptions({
...cameraOptions,
constraints: {
...(cameraOptions.constraints as any),
initialZoom: 'fit-min',
},
})
expect(editor.getInitialZoom()).toBe(0.5)
cameraOptions = editor.getCameraOptions()
editor.setCameraOptions({
...cameraOptions,
constraints: {
...(cameraOptions.constraints as any),
initialZoom: 'fit-max',
},
})
expect(editor.getInitialZoom()).toBe(0.25)
cameraOptions = editor.getCameraOptions()
editor.setCameraOptions({
...cameraOptions,
constraints: {
...(cameraOptions.constraints as any),
initialZoom: 'default',
},
})
expect(editor.getInitialZoom()).toBe(1)
})
})

Wyświetl plik

@ -14,6 +14,18 @@ describe('When panning', () => {
editor.expectCameraToBe(200, 200, 1)
})
it('Updates the camera with panSpeed at 2', () => {
editor.setCameraOptions({ panSpeed: 2 })
editor.pan({ x: 200, y: 200 })
editor.expectCameraToBe(400, 400, 1)
})
it('Updates the camera with panSpeed', () => {
editor.setCameraOptions({ panSpeed: 0.5 })
editor.pan({ x: 200, y: 200 })
editor.expectCameraToBe(100, 100, 1)
})
it('Updates the pageBounds', () => {
const screenBounds = editor.getViewportScreenBounds()
const beforeScreenBounds = new Box(

Wyświetl plik

@ -1,4 +1,4 @@
import { ZOOMS } from '@tldraw/editor'
import { DEFAULT_CAMERA_OPTIONS } from '@tldraw/editor'
import { TestEditor } from '../TestEditor'
let editor: TestEditor
@ -8,21 +8,19 @@ beforeEach(() => {
})
it('zooms by increments', () => {
// Starts at 1
expect(editor.getZoomLevel()).toBe(1)
expect(editor.getZoomLevel()).toBe(ZOOMS[3])
// zooms in
expect(editor.getZoomLevel()).toBe(ZOOMS[3])
editor.zoomIn()
expect(editor.getZoomLevel()).toBe(ZOOMS[4])
editor.zoomIn()
expect(editor.getZoomLevel()).toBe(ZOOMS[5])
editor.zoomIn()
expect(editor.getZoomLevel()).toBe(ZOOMS[6])
const cameraOptions = DEFAULT_CAMERA_OPTIONS
// does not zoom in past max
// Starts at 1
expect(editor.getZoomLevel()).toBe(cameraOptions.zoomSteps[3])
editor.zoomIn()
expect(editor.getZoomLevel()).toBe(ZOOMS[6])
expect(editor.getZoomLevel()).toBe(cameraOptions.zoomSteps[4])
editor.zoomIn()
expect(editor.getZoomLevel()).toBe(cameraOptions.zoomSteps[5])
editor.zoomIn()
expect(editor.getZoomLevel()).toBe(cameraOptions.zoomSteps[6])
// does not zoom out past min
editor.zoomIn()
expect(editor.getZoomLevel()).toBe(cameraOptions.zoomSteps[6])
})
it('preserves the screen center', () => {
@ -48,18 +46,24 @@ it('preserves the screen center when offset', () => {
})
it('zooms to from B to D when B >= (C - A)/2, else zooms from B to C', () => {
editor.setCamera({ x: 0, y: 0, z: (ZOOMS[2] + ZOOMS[3]) / 2 })
const cameraOptions = DEFAULT_CAMERA_OPTIONS
editor.setCamera({ x: 0, y: 0, z: (cameraOptions.zoomSteps[2] + cameraOptions.zoomSteps[3]) / 2 })
editor.zoomIn()
expect(editor.getZoomLevel()).toBe(ZOOMS[4])
editor.setCamera({ x: 0, y: 0, z: (ZOOMS[2] + ZOOMS[3]) / 2 - 0.1 })
expect(editor.getZoomLevel()).toBe(cameraOptions.zoomSteps[4])
editor.setCamera({
x: 0,
y: 0,
z: (cameraOptions.zoomSteps[2] + cameraOptions.zoomSteps[3]) / 2 - 0.1,
})
editor.zoomIn()
expect(editor.getZoomLevel()).toBe(ZOOMS[3])
expect(editor.getZoomLevel()).toBe(cameraOptions.zoomSteps[3])
})
it('does not zoom when camera is frozen', () => {
editor.setCamera({ x: 0, y: 0, z: 1 })
expect(editor.getCamera()).toMatchObject({ x: 0, y: 0, z: 1 })
editor.updateInstanceState({ canMoveCamera: false })
editor.setCameraOptions({ isLocked: true })
editor.zoomIn()
expect(editor.getCamera()).toMatchObject({ x: 0, y: 0, z: 1 })
})

Wyświetl plik

@ -1,4 +1,3 @@
import { ZOOMS } from '@tldraw/editor'
import { TestEditor } from '../TestEditor'
let editor: TestEditor
@ -7,25 +6,110 @@ beforeEach(() => {
editor = new TestEditor()
})
it('zooms by increments', () => {
it('zooms out and in by increments', () => {
const cameraOptions = editor.getCameraOptions()
// Starts at 1
expect(editor.getZoomLevel()).toBe(1)
expect(editor.getZoomLevel()).toBe(ZOOMS[3])
expect(editor.getZoomLevel()).toBe(cameraOptions.zoomSteps[3])
editor.zoomOut()
expect(editor.getZoomLevel()).toBe(ZOOMS[2])
expect(editor.getZoomLevel()).toBe(cameraOptions.zoomSteps[2])
editor.zoomOut()
expect(editor.getZoomLevel()).toBe(ZOOMS[1])
expect(editor.getZoomLevel()).toBe(cameraOptions.zoomSteps[1])
editor.zoomOut()
expect(editor.getZoomLevel()).toBe(ZOOMS[0])
expect(editor.getZoomLevel()).toBe(cameraOptions.zoomSteps[0])
// does not zoom out past min
editor.zoomOut()
expect(editor.getZoomLevel()).toBe(ZOOMS[0])
expect(editor.getZoomLevel()).toBe(cameraOptions.zoomSteps[0])
})
it('does not zoom out when camera is frozen', () => {
editor.setCamera({ x: 0, y: 0, z: 1 })
expect(editor.getCamera()).toMatchObject({ x: 0, y: 0, z: 1 })
editor.updateInstanceState({ canMoveCamera: false })
editor.setCameraOptions({ isLocked: true })
editor.zoomOut()
expect(editor.getCamera()).toMatchObject({ x: 0, y: 0, z: 1 })
})
it('zooms out and in by increments when the camera options have constraints but no base zoom', () => {
const cameraOptions = editor.getCameraOptions()
editor.setCameraOptions({
...cameraOptions,
constraints: {
bounds: { x: 0, y: 0, w: 1600, h: 900 },
padding: { x: 0, y: 0 },
origin: { x: 0.5, y: 0.5 },
initialZoom: 'default',
baseZoom: 'default',
behavior: 'free',
},
})
// Starts at 1
expect(editor.getZoomLevel()).toBe(cameraOptions.zoomSteps[3])
editor.zoomOut()
expect(editor.getZoomLevel()).toBe(cameraOptions.zoomSteps[2])
editor.zoomOut()
expect(editor.getZoomLevel()).toBe(cameraOptions.zoomSteps[1])
editor.zoomOut()
expect(editor.getZoomLevel()).toBe(cameraOptions.zoomSteps[0])
// does not zoom out past min
editor.zoomOut()
expect(editor.getZoomLevel()).toBe(cameraOptions.zoomSteps[0])
})
it('zooms out and in by increments when the camera options have constraints and a base zoom', () => {
const cameraOptions = editor.getCameraOptions()
const vsb = editor.getViewportScreenBounds()
editor.setCameraOptions({
...cameraOptions,
constraints: {
bounds: { x: 0, y: 0, w: vsb.w * 2, h: vsb.h * 4 },
padding: { x: 0, y: 0 },
origin: { x: 0.5, y: 0.5 },
initialZoom: 'fit-x',
baseZoom: 'fit-x',
behavior: 'free',
},
})
// And reset the zoom to its initial value
editor.resetZoom()
expect(editor.getInitialZoom()).toBe(0.5) // fitting the x axis
// Starts at 1
expect(editor.getZoomLevel()).toBe(cameraOptions.zoomSteps[3] * 0.5)
editor.zoomOut()
expect(editor.getZoomLevel()).toBe(cameraOptions.zoomSteps[2] * 0.5)
editor.zoomOut()
expect(editor.getZoomLevel()).toBe(cameraOptions.zoomSteps[1] * 0.5)
editor.zoomOut()
expect(editor.getZoomLevel()).toBe(cameraOptions.zoomSteps[0] * 0.5)
// does not zoom out past min
editor.zoomOut()
expect(editor.getZoomLevel()).toBe(cameraOptions.zoomSteps[0] * 0.5)
editor.setCameraOptions({
...cameraOptions,
constraints: {
bounds: { x: 0, y: 0, w: vsb.w * 2, h: vsb.h * 4 },
padding: { x: 0, y: 0 },
origin: { x: 0.5, y: 0.5 },
initialZoom: 'fit-y',
baseZoom: 'fit-y',
behavior: 'free',
},
})
// And reset the zoom to its initial value
editor.resetZoom()
expect(editor.getInitialZoom()).toBe(0.25) // fitting the y axis
// Starts at 1
expect(editor.getZoomLevel()).toBe(cameraOptions.zoomSteps[3] * 0.25)
editor.zoomOut()
expect(editor.getZoomLevel()).toBe(cameraOptions.zoomSteps[2] * 0.25)
editor.zoomOut()
expect(editor.getZoomLevel()).toBe(cameraOptions.zoomSteps[1] * 0.25)
editor.zoomOut()
expect(editor.getZoomLevel()).toBe(cameraOptions.zoomSteps[0] * 0.25)
// does not zoom out past min
editor.zoomOut()
expect(editor.getZoomLevel()).toBe(cameraOptions.zoomSteps[0] * 0.25)
})

Wyświetl plik

@ -44,7 +44,7 @@ it('does not zoom past min', () => {
it('does not zoom to bounds when camera is frozen', () => {
editor.setScreenBounds({ x: 0, y: 0, w: 1000, h: 1000 })
expect(editor.getViewportPageCenter().toJson()).toCloselyMatchObject({ x: 500, y: 500 })
editor.updateInstanceState({ canMoveCamera: false })
editor.setCameraOptions({ isLocked: true })
editor.zoomToBounds(new Box(200, 300, 300, 300))
expect(editor.getViewportPageCenter().toJson()).toCloselyMatchObject({ x: 500, y: 500 })
})

Wyświetl plik

@ -14,7 +14,7 @@ it('converts correctly', () => {
it('does not zoom to bounds when camera is frozen', () => {
const cameraBefore = { ...editor.getCamera() }
editor.updateInstanceState({ canMoveCamera: false })
editor.setCameraOptions({ isLocked: true })
editor.zoomToFit()
expect(editor.getCamera()).toMatchObject(cameraBefore)
})

Wyświetl plik

@ -35,7 +35,7 @@ it('does not zoom past min', () => {
it('does not zoom to selection when camera is frozen', () => {
const cameraBefore = { ...editor.getCamera() }
editor.updateInstanceState({ canMoveCamera: false })
editor.setCameraOptions({ isLocked: true })
editor.setSelectedShapes([ids.box1, ids.box2])
editor.zoomToSelection()
expect(editor.getCamera()).toMatchObject(cameraBefore)

Wyświetl plik

@ -7,7 +7,6 @@ let editor: TestEditor
beforeEach(() => {
editor = new TestEditor()
editor.setScreenBounds({ x: 0, y: 0, w: 1800, h: 900 })
editor.renderingBoundsMargin = 100
})
function createShapes() {

Wyświetl plik

@ -419,7 +419,6 @@ describe('When pasting into frames...', () => {
.bringToFront(editor.getSelectedShapeIds())
editor.setCamera({ x: -2000, y: -2000, z: 1 })
editor.updateRenderingBounds()
// Copy box 1 (should be out of viewport)
editor.select(ids.box1).copy()

Wyświetl plik

@ -34,7 +34,6 @@ function normalizeIndexes(
beforeEach(() => {
editor = new TestEditor()
editor.setScreenBounds({ x: 0, y: 0, w: 1800, h: 900 })
editor.renderingBoundsMargin = 100
})
function createShapes() {
@ -48,18 +47,6 @@ function createShapes() {
])
}
it('updates the rendering viewport when the camera stops moving', () => {
const ids = createShapes()
editor.updateRenderingBounds = jest.fn(editor.updateRenderingBounds)
editor.pan({ x: -201, y: -201 })
jest.advanceTimersByTime(500)
expect(editor.updateRenderingBounds).toHaveBeenCalledTimes(1)
expect(editor.getRenderingBounds()).toMatchObject({ x: 201, y: 201, w: 1800, h: 900 })
expect(editor.getShapePageBounds(ids.A)).toMatchObject({ x: 100, y: 100, w: 100, h: 100 })
})
it('lists shapes in viewport sorted by id with correct indexes & background indexes', () => {
const ids = createShapes()
// Expect the results to be sorted correctly by id

Wyświetl plik

@ -1026,8 +1026,6 @@ export interface TLInstance extends BaseRecord<'instance', TLInstanceId> {
// (undocumented)
brush: BoxModel | null;
// (undocumented)
canMoveCamera: boolean;
// (undocumented)
chatMessage: string;
// (undocumented)
currentPageId: TLPageId;

Wyświetl plik

@ -1549,6 +1549,18 @@ describe('Add font size adjustment to notes', () => {
})
})
describe('removes can move camera', () => {
const { up, down } = getTestMigration(instanceVersions.RemoveCanMoveCamera)
test('up works as expected', () => {
expect(up({ canMoveCamera: true })).toStrictEqual({})
})
test('down works as expected', () => {
expect(down({})).toStrictEqual({ canMoveCamera: true })
})
})
/* --- PUT YOUR MIGRATIONS TESTS ABOVE HERE --- */
// check that all migrator fns were called at least once

Wyświetl plik

@ -43,7 +43,6 @@ export interface TLInstance extends BaseRecord<'instance', TLInstanceId> {
isChatting: boolean
isPenMode: boolean
isGridMode: boolean
canMoveCamera: boolean
isFocused: boolean
devicePixelRatio: number
/**
@ -106,7 +105,6 @@ export function createInstanceRecordType(stylesById: Map<string, StyleProp<unkno
chatMessage: T.string,
isChatting: T.boolean,
highlightedUserIds: T.arrayOf(T.string),
canMoveCamera: T.boolean,
isFocused: T.boolean,
devicePixelRatio: T.number,
isCoarsePointer: T.boolean,
@ -150,7 +148,6 @@ export function createInstanceRecordType(stylesById: Map<string, StyleProp<unkno
chatMessage: true,
isChatting: true,
highlightedUserIds: true,
canMoveCamera: true,
isFocused: true,
devicePixelRatio: true,
isCoarsePointer: true,
@ -183,7 +180,6 @@ export function createInstanceRecordType(stylesById: Map<string, StyleProp<unkno
chatMessage: '',
isChatting: false,
highlightedUserIds: [],
canMoveCamera: true,
isFocused: false,
devicePixelRatio: typeof window === 'undefined' ? 1 : window.devicePixelRatio,
isCoarsePointer: false,
@ -223,6 +219,7 @@ export const instanceVersions = createMigrationIds('com.tldraw.instance', {
AddScribbles: 22,
AddInset: 23,
AddDuplicateProps: 24,
RemoveCanMoveCamera: 25,
} as const)
// TODO: rewrite these to use mutation
@ -465,6 +462,17 @@ export const instanceMigrations = createRecordMigrationSequence({
}
},
},
{
id: instanceVersions.RemoveCanMoveCamera,
up: ({ canMoveCamera: _, ...record }: any) => {
return {
...record,
}
},
down: (instance) => {
return { ...instance, canMoveCamera: true }
},
},
],
})