Camera options (#3282)

This PR implements a camera options API.

- [x] Initial PR
- [x] Updated unit tests
- [x] Feedback / review
- [x] New unit tests
- [x] Update use-case examples
- [x] Ship?

## Public API

A user can provide camera options to the `Tldraw` component via the
`cameraOptions` prop. The prop is also available on the `TldrawEditor`
component and the constructor parameters of the `Editor` class.

```tsx
export default function CameraOptionsExample() {
	return (
		<div className="tldraw__editor">
			<Tldraw cameraOptions={CAMERA_OPTIONS} />
		</div>
	)
}
```

At runtime, a user can:
-  get the current camera options with `Editor.getCameraOptions`
-  update the camera options with `Editor.setCameraOptions`

Setting the camera options automatically applies them to the current
camera.

```ts
editor.setCameraOptions({...editor.getCameraOptions(), isLocked: true })
```

A user can get the "camera fit zoom" via `editor.getCameraFitZoom()`.

# Interface

The camera options themselves can look a few different ways depending on
the `type` provided.


```tsx
export type TLCameraOptions = {
	/** Whether the camera is locked. */
	isLocked: boolean
	/** The speed of a scroll wheel / trackpad pan. Default is 1. */
	panSpeed: number
	/** The speed of a scroll wheel / trackpad zoom. Default is 1. */
	zoomSpeed: number
	/** The steps that a user can zoom between with zoom in / zoom out. The first and last value will determine the min and max zoom. */
	zoomSteps: number[]
	/** Controls whether the wheel pans or zooms.
	 *
	 * - `zoom`: The wheel will zoom in and out.
	 * - `pan`: The wheel will pan the camera.
	 * - `none`: The wheel will do nothing.
	 */
	wheelBehavior: 'zoom' | 'pan' | 'none'
	/** 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.
		 *
		 * - `default`: Sets the initial zoom to 100%.
		 * - `fit-x`: The x axis will completely fill the viewport bounds.
		 * - `fit-y`: The y axis will completely fill the viewport bounds.
		 * - `fit-min`: The smaller axis will completely fill the viewport bounds.
		 * - `fit-max`: The larger axis will completely fill the viewport bounds.
		 * - `fit-x-100`: The x axis will completely fill the viewport bounds, or 100% zoom, whichever is smaller.
		 * - `fit-y-100`: The y axis will completely fill the viewport bounds, or 100% zoom, whichever is smaller.
		 * - `fit-min-100`: The smaller axis will completely fill the viewport bounds, or 100% zoom, whichever is smaller.
		 * - `fit-max-100`: The larger axis will completely fill the viewport bounds, or 100% zoom, whichever is smaller.
		 */
		initialZoom:
			| 'fit-min'
			| 'fit-max'
			| 'fit-x'
			| 'fit-y'
			| 'fit-min-100'
			| 'fit-max-100'
			| 'fit-x-100'
			| 'fit-y-100'
			| 'default'
		/** The camera's base for its zoom steps.
		 *
		 * - `default`: Sets the initial zoom to 100%.
		 * - `fit-x`: The x axis will completely fill the viewport bounds.
		 * - `fit-y`: The y axis will completely fill the viewport bounds.
		 * - `fit-min`: The smaller axis will completely fill the viewport bounds.
		 * - `fit-max`: The larger axis will completely fill the viewport bounds.
		 * - `fit-x-100`: The x axis will completely fill the viewport bounds, or 100% zoom, whichever is smaller.
		 * - `fit-y-100`: The y axis will completely fill the viewport bounds, or 100% zoom, whichever is smaller.
		 * - `fit-min-100`: The smaller axis will completely fill the viewport bounds, or 100% zoom, whichever is smaller.
		 * - `fit-max-100`: The larger axis will completely fill the viewport bounds, or 100% zoom, whichever is smaller.
		 */
		baseZoom:
			| 'fit-min'
			| 'fit-max'
			| 'fit-x'
			| 'fit-y'
			| 'fit-min-100'
			| 'fit-max-100'
			| 'fit-x-100'
			| 'fit-y-100'
			| 'default'
		/** The behavior for the constraints for both axes or each axis individually.
		 *
		 * - `free`: The bounds are ignored when moving the camera.
		 * - 'fixed': The bounds will be positioned within the viewport based on the origin
		 * - `contain`: The 'fixed' behavior will be used when the zoom is below the zoom level at which the bounds would fill the viewport; and when above this zoom, the bounds will use the 'inside' behavior.
		 * - `inside`: The bounds will stay completely within the viewport.
		 * - `outside`: The bounds will stay touching the viewport.
		 */
		behavior:
			| 'free'
			| 'fixed'
			| 'inside'
			| 'outside'
			| 'contain'
			| {
					x: 'free' | 'fixed' | 'inside' | 'outside' | 'contain'
					y: 'free' | 'fixed' | 'inside' | 'outside' | 'contain'
			  }
	}
}
```

### Change Type

- [x] `sdk` — Changes the tldraw SDK
- [x] `feature` — New feature

### Test Plan

These features combine in different ways, so we'll want to write some
more tests to find surprises.

1. Add a step-by-step description of how to test your PR here.
2.

- [ ] Unit Tests

### Release Notes

- SDK: Adds camera options.

---------

Co-authored-by: Mitja Bezenšek <mitja.bezensek@gmail.com>
This commit is contained in:
Steve Ruiz 2024-05-04 18:39:04 +01:00 committed by GitHub
parent db7c3f59bf
commit fabba66c0f
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
66 changed files with 3564 additions and 1855 deletions

View file

@ -201,7 +201,156 @@ 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 can 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 `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` | Sets the initial zoom to 100%. |
| `fit-x` | The x axis will completely fill the viewport bounds. |
| `fit-y` | The y axis will completely fill the viewport bounds. |
| `fit-min` | The smaller axis will completely fill the viewport bounds. |
| `fit-max` | The larger axis will completely fill the viewport bounds. |
| `fit-x-100` | The x axis will completely fill the viewport bounds, or 100% zoom, whichever is smaller. |
| `fit-y-100` | The y axis will completely fill the viewport bounds, or 100% zoom, whichever is smaller. |
| `fit-min-100` | The smaller axis will completely fill the viewport bounds, or 100% zoom, whichever is smaller. |
| `fit-max-100` | The larger axis will completely fill the viewport bounds, or 100% zoom, whichever is smaller. |
### `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 +450,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

View file

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

View file

@ -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
@ -70,14 +70,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)
}

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -0,0 +1,495 @@
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="wheelBehavior">Wheel behavior</label>
<select
name="wheelBehavior"
value={cameraOptions.wheelBehavior}
onChange={(e) => {
const value = e.target.value
updateOptions({
...CAMERA_OPTIONS,
wheelBehavior: value as 'zoom' | 'pan',
})
}}
>
<option>zoom</option>
<option>pan</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) => {
try {
const val = e.target.value.split(', ').map((v) => Number(v))
if (val.every((v) => typeof v === 'number' && Number.isFinite(v))) {
updateOptions({ zoomSteps: val })
}
} catch (e) {
// ignore
}
}}
/>
<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>fit-min-100</option>
<option>fit-max-100</option>
<option>fit-x-100</option>
<option>fit-y-100</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>fit-min-100</option>
<option>fit-max-100</option>
<option>fit-x-100</option>
<option>fit-y-100</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>free</option>
<option>contain</option>
<option>inside</option>
<option>outside</option>
<option>fixed</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>free</option>
<option>contain</option>
<option>inside</option>
<option>outside</option>
<option>fixed</option>
</select>
</>
) : null}
</div>
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
<button
onClick={() => {
editor.setCamera(editor.getCamera(), { reset: true })
// eslint-disable-next-line no-console
console.log(editor.getCameraOptions())
}}
>
Reset Camera
</button>
<button
onClick={() => {
updateOptions(CAMERA_OPTIONS)
}}
>
Reset Camera Options
</button>
</div>
</div>
)
})

View file

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

View file

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

View file

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

View file

@ -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,88 @@ 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 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.
*/
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: 'contain',
},
zoomSteps: [1, 2, 4, 8],
zoomSpeed: 1,
panSpeed: 1,
isLocked: false,
},
{ reset: true }
)
}, [editor, imageShapeId, image])
return (
<Tldraw
@ -91,11 +169,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>
/>
)
}
@ -172,165 +246,3 @@ function DoneButton({
</button>
)
}
/**
* We want to keep our locked image at the bottom of the current page - people shouldn't be able to
* place other shapes beneath it. This component adds side effects for when shapes are created or
* updated to make sure that this shape is always kept at the bottom.
*/
function KeepShapeAtBottomOfCurrentPage({ shapeId }: { shapeId: TLShapeId }) {
const editor = useEditor()
useEffect(() => {
function makeSureShapeIsAtBottom() {
let shape = editor.getShape(shapeId)
if (!shape) return
const pageId = editor.getCurrentPageId()
if (shape.parentId !== pageId) {
editor.moveShapesToPage([shape], pageId)
shape = editor.getShape(shapeId)!
}
const siblings = editor.getSortedChildIdsForParent(pageId)
const currentBottomShape = editor.getShape(siblings[0])!
if (currentBottomShape.id === shapeId) return
editor.updateShape({
id: shape.id,
type: shape.type,
isLocked: shape.isLocked,
index: getIndexBelow(currentBottomShape.index),
})
}
makeSureShapeIsAtBottom()
const removeOnCreate = editor.sideEffects.registerAfterCreateHandler(
'shape',
makeSureShapeIsAtBottom
)
const removeOnChange = editor.sideEffects.registerAfterChangeHandler(
'shape',
makeSureShapeIsAtBottom
)
return () => {
removeOnCreate()
removeOnChange()
}
}, [editor, shapeId])
return null
}
function KeepShapeLocked({ shapeId }: { shapeId: TLShapeId }) {
const editor = useEditor()
useEffect(() => {
const shape = editor.getShape(shapeId)
if (!shape) return
editor.updateShape({
id: shape.id,
type: shape.type,
isLocked: true,
})
const removeOnChange = editor.sideEffects.registerBeforeChangeHandler('shape', (prev, next) => {
if (next.id !== shapeId) return next
if (next.isLocked) return next
return { ...prev, isLocked: true }
})
return () => {
removeOnChange()
}
}, [editor, shapeId])
return null
}
/**
* We don't want the user to be able to scroll away from the image, or zoom it all the way out. This
* component hooks into camera updates to keep the camera constrained - try uploading a very long,
* thin image and seeing how the camera behaves.
*/
function ConstrainCamera({ shapeId }: { shapeId: TLShapeId }) {
const editor = useEditor()
const breakpoint = useBreakpoint()
const isMobile = breakpoint < PORTRAIT_BREAKPOINT.TABLET_SM
useEffect(() => {
const marginTop = 44
const marginSide = isMobile ? 16 : 164
const marginBottom = 60
function constrainCamera(camera: { x: number; y: number; z: number }): {
x: number
y: number
z: number
} {
const viewportBounds = editor.getViewportScreenBounds()
const targetBounds = editor.getShapePageBounds(shapeId)!
const usableViewport = new Box(
marginSide,
marginTop,
viewportBounds.w - marginSide * 2,
viewportBounds.h - marginTop - marginBottom
)
const minZoom = Math.min(
usableViewport.w / targetBounds.w,
usableViewport.h / targetBounds.h,
1
)
const zoom = Math.max(minZoom, camera.z)
const centerX = targetBounds.x - targetBounds.w / 2 + usableViewport.midX / zoom
const centerY = targetBounds.y - targetBounds.h / 2 + usableViewport.midY / zoom
const availableXMovement = Math.max(0, targetBounds.w - usableViewport.w / zoom)
const availableYMovement = Math.max(0, targetBounds.h - usableViewport.h / zoom)
return {
x: clamp(camera.x, centerX - availableXMovement / 2, centerX + availableXMovement / 2),
y: clamp(camera.y, centerY - availableYMovement / 2, centerY + availableYMovement / 2),
z: zoom,
}
}
const removeOnChange = editor.sideEffects.registerBeforeChangeHandler(
'camera',
(_prev, next) => {
const constrained = constrainCamera(next)
if (constrained.x === next.x && constrained.y === next.y && constrained.z === next.z)
return next
return { ...next, ...constrained }
}
)
const removeReaction = react('update camera when viewport/shape changes', () => {
const original = editor.getCamera()
const constrained = constrainCamera(original)
if (
original.x === constrained.x &&
original.y === constrained.y &&
original.z === constrained.z
) {
return
}
// this needs to be in a microtask for some reason, but idk why
queueMicrotask(() => editor.setCamera(constrained))
})
return () => {
removeOnChange()
removeReaction()
}
}, [editor, isMobile, shapeId])
return null
}

View file

@ -1,19 +1,17 @@
import { useCallback, useEffect, useMemo } from 'react'
import { useMemo } from 'react'
import {
Box,
PORTRAIT_BREAKPOINT,
DEFAULT_CAMERA_OPTIONS,
SVGContainer,
TLComponents,
TLImageShape,
TLShapeId,
TLShapePartial,
Tldraw,
clamp,
compact,
getIndicesBetween,
react,
sortByIndex,
track,
useBreakpoint,
useEditor,
} from 'tldraw'
import { ExportPdfButton } from './ExportPdfButton'
@ -25,13 +23,19 @@ import { Pdf } from './PdfPicker'
// - inertial scrolling for constrained camera
// - render pages on-demand instead of all at once.
export function PdfEditor({ pdf }: { pdf: Pdf }) {
const pdfShapeIds = useMemo(() => pdf.pages.map((page) => page.shapeId), [pdf.pages])
const components = useMemo<TLComponents>(
() => ({
PageMenu: null,
InFrontOfTheCanvas: () => <PageOverlayScreen pdf={pdf} />,
SharePanel: () => <ExportPdfButton pdf={pdf} />,
}),
[pdf]
)
return (
<Tldraw
onMount={(editor) => {
editor.updateInstanceState({ isDebugMode: false })
editor.setCamera({ x: 1000, y: 1000, z: 1 })
editor.createAssets(
pdf.pages.map((page) => ({
id: page.assetId,
@ -48,7 +52,6 @@ export function PdfEditor({ pdf }: { pdf: Pdf }) {
},
}))
)
editor.createShapes(
pdf.pages.map(
(page): TLShapePartial<TLImageShape> => ({
@ -56,6 +59,7 @@ export function PdfEditor({ pdf }: { pdf: Pdf }) {
type: 'image',
x: page.bounds.x,
y: page.bounds.y,
isLocked: true,
props: {
assetId: page.assetId,
w: page.bounds.w,
@ -64,21 +68,84 @@ export function PdfEditor({ pdf }: { pdf: Pdf }) {
})
)
)
const shapeIds = pdf.pages.map((page) => page.shapeId)
const shapeIdSet = new Set(shapeIds)
// Don't let the user unlock the pages
editor.sideEffects.registerBeforeChangeHandler('shape', (prev, next) => {
if (!shapeIdSet.has(next.id)) return next
if (next.isLocked) return next
return { ...prev, isLocked: true }
})
// Make sure the shapes are below any of the other shapes
function makeSureShapesAreAtBottom() {
const shapes = shapeIds.map((id) => editor.getShape(id)!).sort(sortByIndex)
const pageId = editor.getCurrentPageId()
const siblings = editor.getSortedChildIdsForParent(pageId)
const currentBottomShapes = siblings
.slice(0, shapes.length)
.map((id) => editor.getShape(id)!)
if (currentBottomShapes.every((shape, i) => shape.id === shapes[i].id)) return
const otherSiblings = siblings.filter((id) => !shapeIdSet.has(id))
const bottomSibling = otherSiblings[0]
const lowestIndex = editor.getShape(bottomSibling)!.index
const indexes = getIndicesBetween(undefined, lowestIndex, shapes.length)
editor.updateShapes(
shapes.map((shape, i) => ({
id: shape.id,
type: shape.type,
isLocked: shape.isLocked,
index: indexes[i],
}))
)
}
makeSureShapesAreAtBottom()
editor.sideEffects.registerAfterCreateHandler('shape', makeSureShapesAreAtBottom)
editor.sideEffects.registerAfterChangeHandler('shape', makeSureShapesAreAtBottom)
// Constrain the camera to the bounds of the pages
const targetBounds = pdf.pages.reduce(
(acc, page) => acc.union(page.bounds),
pdf.pages[0].bounds.clone()
)
function updateCameraBounds(isMobile: boolean) {
editor.setCameraOptions(
{
...DEFAULT_CAMERA_OPTIONS,
constraints: {
bounds: targetBounds,
padding: { x: isMobile ? 16 : 164, y: 64 },
origin: { x: 0.5, y: 0 },
initialZoom: 'fit-x-100',
baseZoom: 'default',
behavior: 'contain',
},
},
{ reset: true }
)
}
let isMobile = editor.getViewportScreenBounds().width < 840
react('update camera', () => {
const isMobileNow = editor.getViewportScreenBounds().width < 840
if (isMobileNow === isMobile) return
isMobile = isMobileNow
updateCameraBounds(isMobile)
})
updateCameraBounds(isMobile)
}}
components={{
PageMenu: null,
InFrontOfTheCanvas: useCallback(() => {
return <PageOverlayScreen pdf={pdf} />
}, [pdf]),
SharePanel: useCallback(() => {
return <ExportPdfButton pdf={pdf} />
}, [pdf]),
}}
>
<ConstrainCamera pdf={pdf} />
<KeepShapesLocked shapeIds={pdfShapeIds} />
<KeepShapesAtBottomOfCurrentPage shapeIds={pdfShapeIds} />
</Tldraw>
components={components}
/>
)
}
@ -125,165 +192,3 @@ const PageOverlayScreen = track(function PageOverlayScreen({ pdf }: { pdf: Pdf }
</>
)
})
function ConstrainCamera({ pdf }: { pdf: Pdf }) {
const editor = useEditor()
const breakpoint = useBreakpoint()
const isMobile = breakpoint < PORTRAIT_BREAKPOINT.TABLET_SM
useEffect(() => {
const marginTop = 64
const marginSide = isMobile ? 16 : 164
const marginBottom = 80
const targetBounds = pdf.pages.reduce(
(acc, page) => acc.union(page.bounds),
pdf.pages[0].bounds.clone()
)
function constrainCamera(camera: { x: number; y: number; z: number }): {
x: number
y: number
z: number
} {
const viewportBounds = editor.getViewportScreenBounds()
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, pdf.pages])
return null
}
function KeepShapesLocked({ shapeIds }: { shapeIds: TLShapeId[] }) {
const editor = useEditor()
useEffect(() => {
const shapeIdSet = new Set(shapeIds)
for (const shapeId of shapeIdSet) {
const shape = editor.getShape(shapeId)!
editor.updateShape({
id: shape.id,
type: shape.type,
isLocked: true,
})
}
const removeOnChange = editor.sideEffects.registerBeforeChangeHandler('shape', (prev, next) => {
if (!shapeIdSet.has(next.id)) return next
if (next.isLocked) return next
return { ...prev, isLocked: true }
})
return () => {
removeOnChange()
}
}, [editor, shapeIds])
return null
}
function KeepShapesAtBottomOfCurrentPage({ shapeIds }: { shapeIds: TLShapeId[] }) {
const editor = useEditor()
useEffect(() => {
const shapeIdSet = new Set(shapeIds)
function makeSureShapesAreAtBottom() {
const shapes = shapeIds.map((id) => editor.getShape(id)!).sort(sortByIndex)
const pageId = editor.getCurrentPageId()
const siblings = editor.getSortedChildIdsForParent(pageId)
const currentBottomShapes = siblings.slice(0, shapes.length).map((id) => editor.getShape(id)!)
if (currentBottomShapes.every((shape, i) => shape.id === shapes[i].id)) return
const otherSiblings = siblings.filter((id) => !shapeIdSet.has(id))
const bottomSibling = otherSiblings[0]
const lowestIndex = editor.getShape(bottomSibling)!.index
const indexes = getIndicesBetween(undefined, lowestIndex, shapes.length)
editor.updateShapes(
shapes.map((shape, i) => ({
id: shape.id,
type: shape.type,
isLocked: shape.isLocked,
index: indexes[i],
}))
)
}
makeSureShapesAreAtBottom()
const removeOnCreate = editor.sideEffects.registerAfterCreateHandler(
'shape',
makeSureShapesAreAtBottom
)
const removeOnChange = editor.sideEffects.registerAfterChangeHandler(
'shape',
makeSureShapesAreAtBottom
)
return () => {
removeOnCreate()
removeOnChange()
}
}, [editor, shapeIds])
return null
}

View file

@ -17,7 +17,7 @@ export default function PopupShapeExample() {
y: Math.floor(i / 3) * 220,
})
}
editor.zoomToContent({ duration: 0 })
editor.zoomToBounds(editor.getCurrentPageBounds()!, { animation: { duration: 0 } })
}}
/>
</div>

View file

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

View file

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

View file

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

View file

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

View file

@ -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())`

View file

@ -1,373 +1,373 @@
{
"action.align-bottom": "Poravnaj dno",
"action.align-center-horizontal": "Poravnaj vodoravno",
"action.align-center-horizontal.short": "Poravnaj vodoravno",
"action.align-center-vertical": "Poravnaj navpično",
"action.align-center-vertical.short": "Poravnaj navpično",
"action.align-left": "Poravnaj levo",
"action.align-right": "Poravnaj desno",
"action.align-top": "Poravnaj vrh",
"action.back-to-content": "Nazaj na vsebino",
"action.bring-forward": "Premakni naprej",
"action.bring-to-front": "Premakni v ospredje",
"action.convert-to-bookmark": "Pretvori v zaznamek",
"action.convert-to-embed": "Pretvori v vdelavo",
"action.copy": "Kopiraj",
"action.copy-as-json": "Kopiraj kot JSON",
"action.copy-as-json.short": "JSON",
"action.copy-as-png": "Kopiraj kot PNG",
"action.copy-as-png.short": "PNG",
"action.copy-as-svg": "Kopiraj kot SVG",
"action.copy-as-svg.short": "SVG",
"action.cut": "Izreži",
"action.delete": "Izbriši",
"action.distribute-horizontal": "Porazdeli vodoravno",
"action.distribute-horizontal.short": "Porazdeli vodoravno",
"action.distribute-vertical": "Porazdeli navpično",
"action.distribute-vertical.short": "Porazdeli navpično",
"action.duplicate": "Podvoji",
"action.edit-link": "Uredi povezavo",
"action.exit-pen-mode": "Zapustite način peresa",
"action.export-all-as-json": "Izvozi vse kot JSON",
"action.export-all-as-json.short": "JSON",
"action.export-all-as-png": "Izvozi vse kot PNG",
"action.export-all-as-png.short": "PNG",
"action.export-all-as-svg": "Izvozi vse kot SVG",
"action.export-all-as-svg.short": "SVG",
"action.export-as-json": "Izvozi kot JSON",
"action.export-as-json.short": "JSON",
"action.export-as-png": "Izvozi kot PNG",
"action.export-as-png.short": "PNG",
"action.export-as-svg": "Izvozi kot SVG",
"action.export-as-svg.short": "SVG",
"action.fit-frame-to-content": "Prilagodi vsebini",
"action.flip-horizontal": "Zrcali vodoravno",
"action.flip-horizontal.short": "Zrcali horizontalno",
"action.flip-vertical": "Zrcali navpično",
"action.flip-vertical.short": "Zrcali vertikalno",
"action.fork-project": "Naredi kopijo projekta",
"action.fork-project-on-tldraw": "Naredi kopijo na tldraw",
"action.group": "Združi",
"action.insert-embed": "Vstavi vdelavo",
"action.insert-media": "Naloži predstavnost",
"action.leave-shared-project": "Zapusti skupni projekt",
"action.new-project": "Nov projekt",
"action.new-shared-project": "Nov skupni projekt",
"action.open-cursor-chat": "Klepet s kazalcem",
"action.open-embed-link": "Odpri povezavo",
"action.open-file": "Odpri datoteko",
"action.pack": "Spakiraj",
"action.paste": "Prilepi",
"action.print": "Natisni",
"action.redo": "Uveljavi",
"action.remove-frame": "Odstrani okvir",
"action.rename": "Preimenuj",
"action.rotate-ccw": "Zavrti v nasprotni smeri urinega kazalca",
"action.rotate-cw": "Zavrti v smeri urinega kazalca",
"action.save-copy": "Shrani kopijo",
"action.select-all": "Izberi vse",
"action.select-none": "Počisti izbiro",
"action.send-backward": "Pošlji nazaj",
"action.send-to-back": "Pošlji v ozadje",
"action.share-project": "Deli ta projekt",
"action.stack-horizontal": "Naloži vodoravno",
"action.stack-horizontal.short": "Naloži vodoravno",
"action.stack-vertical": "Naloži navpično",
"action.stack-vertical.short": "Naloži navpično",
"action.stop-following": "Prenehaj slediti",
"action.stretch-horizontal": "Raztegnite vodoravno",
"action.stretch-horizontal.short": "Raztezanje vodoravno",
"action.stretch-vertical": "Raztegni navpično",
"action.stretch-vertical.short": "Raztezanje navpično",
"action.toggle-auto-size": "Preklopi samodejno velikost",
"action.toggle-dark-mode": "Preklopi temni način",
"action.toggle-dark-mode.menu": "Temni način",
"action.toggle-debug-mode": "Preklopi način odpravljanja napak",
"action.toggle-debug-mode.menu": "Način odpravljanja napak",
"action.toggle-edge-scrolling": "Preklopi pomikanje ob robovih",
"action.toggle-edge-scrolling.menu": "Pomikanje ob robovih",
"action.toggle-focus-mode": "Preklopi na osredotočen način",
"action.toggle-focus-mode.menu": "Osredotočen način",
"action.toggle-grid": "Preklopi mrežo",
"action.toggle-grid.menu": "Prikaži mrežo",
"action.toggle-lock": "Zakleni \/ odkleni",
"action.toggle-reduce-motion": "Preklop zmanjšanja gibanja",
"action.toggle-reduce-motion.menu": "Zmanjšaj gibanje",
"action.toggle-snap-mode": "Preklopi pripenjanje",
"action.toggle-snap-mode.menu": "Vedno pripni",
"action.toggle-tool-lock": "Preklopi zaklepanje orodja",
"action.toggle-tool-lock.menu": "Zaklepanje orodja",
"action.toggle-transparent": "Preklopi prosojno ozadje",
"action.toggle-transparent.context-menu": "Prozorno",
"action.toggle-transparent.menu": "Prozorno",
"action.toggle-wrap-mode": "Preklopi Izberi ob zajetju",
"action.toggle-wrap-mode.menu": "Izberi ob zajetju",
"action.undo": "Razveljavi",
"action.ungroup": "Razdruži",
"action.unlock-all": "Odkleni vse",
"action.zoom-in": "Povečaj",
"action.zoom-out": "Pomanjšaj",
"action.zoom-to-100": "Povečaj na 100 %",
"action.zoom-to-fit": "Povečaj do prileganja",
"action.zoom-to-selection": "Pomakni na izbiro",
"actions-menu.title": "Akcije",
"align-style.end": "Konec",
"align-style.justify": "Poravnaj",
"align-style.middle": "Sredina",
"align-style.start": "Začetek",
"arrowheadEnd-style.arrow": "Puščica",
"arrowheadEnd-style.bar": "Črta",
"arrowheadEnd-style.diamond": "Diamant",
"arrowheadEnd-style.dot": "Pika",
"arrowheadEnd-style.inverted": "Obrnjeno",
"arrowheadEnd-style.none": "Brez",
"arrowheadEnd-style.pipe": "Cev",
"arrowheadEnd-style.square": "Kvadrat",
"arrowheadEnd-style.triangle": "Trikotnik",
"arrowheadStart-style.arrow": "Puščica",
"arrowheadStart-style.bar": "Črta",
"arrowheadStart-style.diamond": "Diamant",
"arrowheadStart-style.dot": "Pika",
"arrowheadStart-style.inverted": "Obrnjeno",
"arrowheadStart-style.none": "Brez",
"arrowheadStart-style.pipe": "Cev",
"arrowheadStart-style.square": "Kvadrat",
"arrowheadStart-style.triangle": "Trikotnik",
"assets.files.upload-failed": "Nalaganje ni uspelo",
"assets.url.failed": "Ni bilo mogoče naložiti predogleda URL",
"color-style.black": "Črna",
"color-style.blue": "Modra",
"color-style.green": "Zelena",
"color-style.grey": "Siva",
"color-style.light-blue": "Svetlo modra",
"color-style.light-green": "Svetlo zelena",
"color-style.light-red": "Svetlo rdeča",
"color-style.light-violet": "Svetlo vijolična",
"color-style.orange": "Oranžna",
"color-style.red": "Rdeča",
"color-style.violet": "Vijolična",
"color-style.white": "Bela",
"color-style.yellow": "Rumena",
"context-menu.arrange": "Preuredi",
"context-menu.copy-as": "Kopiraj kot",
"context-menu.export-all-as": "Izvozi vse kot",
"context-menu.export-as": "Izvozi kot",
"context-menu.move-to-page": "Premakni na stran",
"context-menu.reorder": "Preuredite",
"context.pages.new-page": "Nova stran",
"cursor-chat.type-to-chat": "Vnesite za klepet ...",
"dash-style.dashed": "Črtkano",
"dash-style.dotted": "Pikčasto",
"dash-style.draw": "Narisano",
"dash-style.solid": "Polno",
"debug-panel.more": "Več",
"document.default-name": "Neimenovana",
"edit-link-dialog.cancel": "Prekliči",
"edit-link-dialog.clear": "Počisti",
"edit-link-dialog.detail": "Povezave se bodo odprle v novem zavihku.",
"edit-link-dialog.invalid-url": "Povezava mora biti veljavna",
"edit-link-dialog.save": "Nadaljuj",
"edit-link-dialog.title": "Uredi povezavo",
"edit-link-dialog.url": "URL",
"edit-pages-dialog.move-down": "Premakni navzdol",
"edit-pages-dialog.move-up": "Premakni navzgor",
"embed-dialog.back": "Nazaj",
"embed-dialog.cancel": "Prekliči",
"embed-dialog.create": "Ustvari",
"embed-dialog.instruction": "Prilepite URL spletnega mesta, da ustvarite vdelavo.",
"embed-dialog.invalid-url": "Iz tega URL-ja nismo mogli ustvariti vdelave.",
"embed-dialog.title": "Ustvari vdelavo",
"embed-dialog.url": "URL",
"file-system.confirm-clear.cancel": "Prekliči",
"file-system.confirm-clear.continue": "Nadaljuj",
"file-system.confirm-clear.description": "Če ustvarite nov projekt, boste izbrisali trenutni projekt in vse neshranjene spremembe bodo izgubljene. Ste prepričani, da želite nadaljevati?",
"file-system.confirm-clear.dont-show-again": "Ne sprašuj znova",
"file-system.confirm-clear.title": "Počisti trenutni projekt?",
"file-system.confirm-open.cancel": "Prekliči",
"file-system.confirm-open.description": "Če ustvarite nov projekt, boste izbrisali trenutni projekt in vse neshranjene spremembe bodo izgubljene. Ste prepričani, da želite nadaljevati?",
"file-system.confirm-open.dont-show-again": "Ne sprašuj znova",
"file-system.confirm-open.open": "Odpri datoteko",
"file-system.confirm-open.title": "Prepiši trenutni projekt?",
"file-system.file-open-error.file-format-version-too-new": "Datoteka, ki ste jo poskušali odpreti, je iz novejše različice tldraw. Ponovno naložite stran in poskusite znova.",
"file-system.file-open-error.generic-corrupted-file": "Datoteka, ki ste jo poskušali odpreti, je poškodovana.",
"file-system.file-open-error.not-a-tldraw-file": "Datoteka, ki ste jo poskušali odpreti, ni videti kot datoteka tldraw.",
"file-system.file-open-error.title": "Datoteke ni bilo mogoče odpreti",
"file-system.shared-document-file-open-error.description": "Odpiranje datotek v skupnih projektih ni podprto.",
"file-system.shared-document-file-open-error.title": "Datoteke ni bilo mogoče odpreti",
"fill-style.none": "Brez",
"fill-style.pattern": "Vzorec",
"fill-style.semi": "Polovično",
"fill-style.solid": "Polno",
"focus-mode.toggle-focus-mode": "Preklopi na osredotočen način",
"font-style.draw": "Draw",
"font-style.mono": "Mono",
"font-style.sans": "Sans",
"font-style.serif": "Serif",
"geo-style.arrow-down": "Puščica navzdol",
"geo-style.arrow-left": "Puščica levo",
"geo-style.arrow-right": "Puščica desno",
"geo-style.arrow-up": "Puščica navzgor",
"geo-style.check-box": "Potrditveno polje",
"geo-style.cloud": "Oblak",
"geo-style.diamond": "Diamant",
"geo-style.ellipse": "Elipsa",
"geo-style.hexagon": "Šesterokotnik",
"geo-style.octagon": "Osmerokotnik",
"geo-style.oval": "Oval",
"geo-style.pentagon": "Peterokotnik",
"geo-style.rectangle": "Pravokotnik",
"geo-style.rhombus": "Romb",
"geo-style.rhombus-2": "Romb 2",
"geo-style.star": "Zvezda",
"geo-style.trapezoid": "Trapez",
"geo-style.triangle": "Trikotnik",
"geo-style.x-box": "X polje",
"help-menu.about": "O nas",
"help-menu.discord": "Discord",
"help-menu.github": "GitHub",
"help-menu.keyboard-shortcuts": "Bližnjice na tipkovnici",
"help-menu.title": "Pomoč in viri",
"help-menu.twitter": "Twitter",
"home-project-dialog.description": "To je vaš lokalni projekt. Namenjen je samo vam!",
"home-project-dialog.ok": "V redu",
"home-project-dialog.title": "Lokalni projekt",
"menu.copy-as": "Kopiraj kot",
"menu.edit": "Uredi",
"menu.export-as": "Izvozi kot",
"menu.file": "Datoteka",
"menu.language": "Jezik",
"menu.preferences": "Nastavitve",
"menu.title": "Meni",
"menu.view": "Pogled",
"navigation-zone.toggle-minimap": "Preklopi mini zemljevid",
"navigation-zone.zoom": "Povečava",
"opacity-style.0.1": "10 %",
"opacity-style.0.25": "25 %",
"opacity-style.0.5": "50 %",
"opacity-style.0.75": "75 %",
"opacity-style.1": "100 %",
"page-menu.create-new-page": "Ustvari novo stran",
"page-menu.edit-done": "Zaključi",
"page-menu.edit-start": "Uredi",
"page-menu.go-to-page": "Pojdi na stran",
"page-menu.max-page-count-reached": "Doseženo največje število strani",
"page-menu.new-page-initial-name": "Stran 1",
"page-menu.submenu.delete": "Izbriši",
"page-menu.submenu.duplicate-page": "Podvoji",
"page-menu.submenu.move-down": "Premakni navzdol",
"page-menu.submenu.move-up": "Premakni navzgor",
"page-menu.submenu.rename": "Preimenuj",
"page-menu.submenu.title": "Meni",
"page-menu.title": "Strani",
"people-menu.change-color": "Spremeni barvo",
"people-menu.change-name": "Spremeni ime",
"people-menu.follow": "Sledi",
"people-menu.following": "Sledim",
"people-menu.invite": "Povabi ostale",
"people-menu.leading": "Sledi vam",
"people-menu.title": "Ljudje",
"people-menu.user": "(Ti)",
"rename-project-dialog.cancel": "Prekliči",
"rename-project-dialog.rename": "Preimenuj",
"rename-project-dialog.title": "Preimenuj projekt",
"share-menu.copy-link": "Kopiraj povezavo",
"share-menu.copy-link-note": "Vsakdo s povezavo si bo lahko ogledal in urejal ta projekt.",
"share-menu.copy-readonly-link": "Kopiraj povezavo samo za branje",
"share-menu.copy-readonly-link-note": "Vsakdo s povezavo si bo lahko ogledal (vendar ne urejal) ta projekt.",
"share-menu.create-snapshot-link": "Ustvari povezavo do posnetka",
"share-menu.default-project-name": "Skupni projekt",
"share-menu.fork-note": "Na podlagi tega posnetka ustvarite nov skupni projekt.",
"share-menu.offline-note": "Skupna raba tega projekta bo ustvarila živo kopijo na novem URL-ju. URL lahko delite z do tridesetimi drugimi osebami, s katerimi lahko skupaj gledate in urejate vsebino.",
"share-menu.project-too-large": "Žal tega projekta ni mogoče deliti, ker je prevelik. Delamo na tem!",
"share-menu.readonly-link": "Samo za branje",
"share-menu.save-note": "Ta projekt prenesite na svoj računalnik kot datoteko .tldr.",
"share-menu.share-project": "Deli ta projekt",
"share-menu.snapshot-link-note": "Zajemite in delite ta projekt kot povezavo do posnetka samo za branje.",
"share-menu.title": "Deli",
"share-menu.upload-failed": "Oprostite, trenutno nismo mogli naložiti vašega projekta. Poskusite znova ali nam sporočite, če se težava ponovi.",
"sharing.confirm-leave.cancel": "Prekliči",
"sharing.confirm-leave.description": "Ali ste prepričani, da želite zapustiti ta skupni projekt? Nanj se lahko vrnete tako, da se ponovno vrnete na njegov URL.",
"sharing.confirm-leave.dont-show-again": "Ne sprašuj znova",
"sharing.confirm-leave.leave": "Zapusti",
"sharing.confirm-leave.title": "Zapusti trenutni projekt?",
"shortcuts-dialog.collaboration": "Sodelovanje",
"shortcuts-dialog.edit": "Uredi",
"shortcuts-dialog.file": "Datoteka",
"shortcuts-dialog.preferences": "Nastavitve",
"shortcuts-dialog.title": "Bližnjice na tipkovnici",
"shortcuts-dialog.tools": "Orodja",
"shortcuts-dialog.transform": "Preoblikuj",
"shortcuts-dialog.view": "Pogled",
"size-style.l": "Veliko",
"size-style.m": "Srednje",
"size-style.s": "Malo",
"size-style.xl": "Zelo veliko",
"spline-style.cubic": "Kubično",
"spline-style.line": "Črta",
"status.offline": "Brez povezave",
"status.online": "Povezan",
"style-panel.align": "Poravnava",
"style-panel.arrowhead-end": "Konec",
"style-panel.arrowhead-start": "Začetek",
"style-panel.arrowheads": "Puščice",
"style-panel.color": "Barva",
"style-panel.dash": "Črtasto",
"style-panel.fill": "Polnilo",
"style-panel.font": "Pisava",
"style-panel.geo": "Oblika",
"style-panel.mixed": "Mešano",
"style-panel.opacity": "Motnost",
"style-panel.position": "Položaj",
"style-panel.size": "Velikost",
"style-panel.spline": "Krivulja",
"style-panel.title": "Stili",
"style-panel.vertical-align": "Navpična poravnava",
"toast.close": "Zapri",
"toast.error.copy-fail.desc": "Kopiranje slike ni uspelo",
"toast.error.copy-fail.title": "Kopiranje ni uspelo",
"toast.error.export-fail.desc": "Izvoz slike ni uspel",
"toast.error.export-fail.title": "Izvoz ni uspel",
"tool-panel.drawing": "Risanje",
"tool-panel.more": "Več",
"tool-panel.shapes": "Oblike",
"tool.arrow": "Puščica",
"tool.arrow-down": "Puščica navzdol",
"tool.arrow-left": "Puščica levo",
"tool.arrow-right": "Puščica desno",
"tool.arrow-up": "Puščica navzgor",
"tool.asset": "Sredstvo",
"tool.check-box": "Potrditveno polje",
"tool.cloud": "Oblak",
"tool.diamond": "Diamant",
"tool.draw": "Risanje",
"tool.ellipse": "Elipsa",
"tool.embed": "Vdelava",
"tool.eraser": "Radirka",
"tool.frame": "Okvir",
"tool.hand": "Roka",
"tool.hexagon": "Šesterokotnik",
"tool.highlight": "Marker",
"tool.laser": "Laser",
"tool.line": "Črta",
"tool.note": "Opomba",
"tool.octagon": "Osmerokotnik",
"tool.oval": "Oval",
"tool.pentagon": "Peterokotnik",
"tool.rectangle": "Pravokotnik",
"tool.rhombus": "Romb",
"tool.select": "Izbor",
"tool.star": "Zvezda",
"tool.text": "Besedilo",
"tool.trapezoid": "Trapez",
"tool.triangle": "Trikotnik",
"tool.x-box": "X polje",
"verticalAlign-style.end": "Dno",
"verticalAlign-style.middle": "Sredina",
"verticalAlign-style.start": "Vrh",
"vscode.file-open.backup": "Varnostna kopija",
"vscode.file-open.backup-failed": "Varnostno kopiranje ni uspelo: to ni datoteka .tldr.",
"vscode.file-open.backup-saved": "Varnostna kopija shranjena",
"vscode.file-open.desc": "Ta datoteka je bila ustvarjena s starejšo različico tldraw. Ali jo želite posodobiti, da bo deloval z novo različico?",
"vscode.file-open.dont-show-again": "Ne sprašuj znova",
"vscode.file-open.open": "Nadaljuj"
"action.align-bottom": "Poravnaj dno",
"action.align-center-horizontal": "Poravnaj vodoravno",
"action.align-center-horizontal.short": "Poravnaj vodoravno",
"action.align-center-vertical": "Poravnaj navpično",
"action.align-center-vertical.short": "Poravnaj navpično",
"action.align-left": "Poravnaj levo",
"action.align-right": "Poravnaj desno",
"action.align-top": "Poravnaj vrh",
"action.back-to-content": "Nazaj na vsebino",
"action.bring-forward": "Premakni naprej",
"action.bring-to-front": "Premakni v ospredje",
"action.convert-to-bookmark": "Pretvori v zaznamek",
"action.convert-to-embed": "Pretvori v vdelavo",
"action.copy": "Kopiraj",
"action.copy-as-json": "Kopiraj kot JSON",
"action.copy-as-json.short": "JSON",
"action.copy-as-png": "Kopiraj kot PNG",
"action.copy-as-png.short": "PNG",
"action.copy-as-svg": "Kopiraj kot SVG",
"action.copy-as-svg.short": "SVG",
"action.cut": "Izreži",
"action.delete": "Izbriši",
"action.distribute-horizontal": "Porazdeli vodoravno",
"action.distribute-horizontal.short": "Porazdeli vodoravno",
"action.distribute-vertical": "Porazdeli navpično",
"action.distribute-vertical.short": "Porazdeli navpično",
"action.duplicate": "Podvoji",
"action.edit-link": "Uredi povezavo",
"action.exit-pen-mode": "Zapustite način peresa",
"action.export-all-as-json": "Izvozi vse kot JSON",
"action.export-all-as-json.short": "JSON",
"action.export-all-as-png": "Izvozi vse kot PNG",
"action.export-all-as-png.short": "PNG",
"action.export-all-as-svg": "Izvozi vse kot SVG",
"action.export-all-as-svg.short": "SVG",
"action.export-as-json": "Izvozi kot JSON",
"action.export-as-json.short": "JSON",
"action.export-as-png": "Izvozi kot PNG",
"action.export-as-png.short": "PNG",
"action.export-as-svg": "Izvozi kot SVG",
"action.export-as-svg.short": "SVG",
"action.fit-frame-to-content": "Prilagodi vsebini",
"action.flip-horizontal": "Zrcali vodoravno",
"action.flip-horizontal.short": "Zrcali horizontalno",
"action.flip-vertical": "Zrcali navpično",
"action.flip-vertical.short": "Zrcali vertikalno",
"action.fork-project": "Naredi kopijo projekta",
"action.fork-project-on-tldraw": "Naredi kopijo na tldraw",
"action.group": "Združi",
"action.insert-embed": "Vstavi vdelavo",
"action.insert-media": "Naloži predstavnost",
"action.leave-shared-project": "Zapusti skupni projekt",
"action.new-project": "Nov projekt",
"action.new-shared-project": "Nov skupni projekt",
"action.open-cursor-chat": "Klepet s kazalcem",
"action.open-embed-link": "Odpri povezavo",
"action.open-file": "Odpri datoteko",
"action.pack": "Spakiraj",
"action.paste": "Prilepi",
"action.print": "Natisni",
"action.redo": "Uveljavi",
"action.remove-frame": "Odstrani okvir",
"action.rename": "Preimenuj",
"action.rotate-ccw": "Zavrti v nasprotni smeri urinega kazalca",
"action.rotate-cw": "Zavrti v smeri urinega kazalca",
"action.save-copy": "Shrani kopijo",
"action.select-all": "Izberi vse",
"action.select-none": "Počisti izbiro",
"action.send-backward": "Pošlji nazaj",
"action.send-to-back": "Pošlji v ozadje",
"action.share-project": "Deli ta projekt",
"action.stack-horizontal": "Naloži vodoravno",
"action.stack-horizontal.short": "Naloži vodoravno",
"action.stack-vertical": "Naloži navpično",
"action.stack-vertical.short": "Naloži navpično",
"action.stop-following": "Prenehaj slediti",
"action.stretch-horizontal": "Raztegnite vodoravno",
"action.stretch-horizontal.short": "Raztezanje vodoravno",
"action.stretch-vertical": "Raztegni navpično",
"action.stretch-vertical.short": "Raztezanje navpično",
"action.toggle-auto-size": "Preklopi samodejno velikost",
"action.toggle-dark-mode": "Preklopi temni način",
"action.toggle-dark-mode.menu": "Temni način",
"action.toggle-debug-mode": "Preklopi način odpravljanja napak",
"action.toggle-debug-mode.menu": "Način odpravljanja napak",
"action.toggle-edge-scrolling": "Preklopi pomikanje ob robovih",
"action.toggle-edge-scrolling.menu": "Pomikanje ob robovih",
"action.toggle-focus-mode": "Preklopi na osredotočen način",
"action.toggle-focus-mode.menu": "Osredotočen način",
"action.toggle-grid": "Preklopi mrežo",
"action.toggle-grid.menu": "Prikaži mrežo",
"action.toggle-lock": "Zakleni / odkleni",
"action.toggle-reduce-motion": "Preklop zmanjšanja gibanja",
"action.toggle-reduce-motion.menu": "Zmanjšaj gibanje",
"action.toggle-snap-mode": "Preklopi pripenjanje",
"action.toggle-snap-mode.menu": "Vedno pripni",
"action.toggle-tool-lock": "Preklopi zaklepanje orodja",
"action.toggle-tool-lock.menu": "Zaklepanje orodja",
"action.toggle-transparent": "Preklopi prosojno ozadje",
"action.toggle-transparent.context-menu": "Prozorno",
"action.toggle-transparent.menu": "Prozorno",
"action.toggle-wrap-mode": "Preklopi Izberi ob zajetju",
"action.toggle-wrap-mode.menu": "Izberi ob zajetju",
"action.undo": "Razveljavi",
"action.ungroup": "Razdruži",
"action.unlock-all": "Odkleni vse",
"action.zoom-in": "Povečaj",
"action.zoom-out": "Pomanjšaj",
"action.zoom-to-100": "Povečaj na 100 %",
"action.zoom-to-fit": "Povečaj do prileganja",
"action.zoom-to-selection": "Pomakni na izbiro",
"actions-menu.title": "Akcije",
"align-style.end": "Konec",
"align-style.justify": "Poravnaj",
"align-style.middle": "Sredina",
"align-style.start": "Začetek",
"arrowheadEnd-style.arrow": "Puščica",
"arrowheadEnd-style.bar": "Črta",
"arrowheadEnd-style.diamond": "Diamant",
"arrowheadEnd-style.dot": "Pika",
"arrowheadEnd-style.inverted": "Obrnjeno",
"arrowheadEnd-style.none": "Brez",
"arrowheadEnd-style.pipe": "Cev",
"arrowheadEnd-style.square": "Kvadrat",
"arrowheadEnd-style.triangle": "Trikotnik",
"arrowheadStart-style.arrow": "Puščica",
"arrowheadStart-style.bar": "Črta",
"arrowheadStart-style.diamond": "Diamant",
"arrowheadStart-style.dot": "Pika",
"arrowheadStart-style.inverted": "Obrnjeno",
"arrowheadStart-style.none": "Brez",
"arrowheadStart-style.pipe": "Cev",
"arrowheadStart-style.square": "Kvadrat",
"arrowheadStart-style.triangle": "Trikotnik",
"assets.files.upload-failed": "Nalaganje ni uspelo",
"assets.url.failed": "Ni bilo mogoče naložiti predogleda URL",
"color-style.black": "Črna",
"color-style.blue": "Modra",
"color-style.green": "Zelena",
"color-style.grey": "Siva",
"color-style.light-blue": "Svetlo modra",
"color-style.light-green": "Svetlo zelena",
"color-style.light-red": "Svetlo rdeča",
"color-style.light-violet": "Svetlo vijolična",
"color-style.orange": "Oranžna",
"color-style.red": "Rdeča",
"color-style.violet": "Vijolična",
"color-style.white": "Bela",
"color-style.yellow": "Rumena",
"context-menu.arrange": "Preuredi",
"context-menu.copy-as": "Kopiraj kot",
"context-menu.export-all-as": "Izvozi vse kot",
"context-menu.export-as": "Izvozi kot",
"context-menu.move-to-page": "Premakni na stran",
"context-menu.reorder": "Preuredite",
"context.pages.new-page": "Nova stran",
"cursor-chat.type-to-chat": "Vnesite za klepet ...",
"dash-style.dashed": "Črtkano",
"dash-style.dotted": "Pikčasto",
"dash-style.draw": "Narisano",
"dash-style.solid": "Polno",
"debug-panel.more": "Več",
"document.default-name": "Neimenovana",
"edit-link-dialog.cancel": "Prekliči",
"edit-link-dialog.clear": "Počisti",
"edit-link-dialog.detail": "Povezave se bodo odprle v novem zavihku.",
"edit-link-dialog.invalid-url": "Povezava mora biti veljavna",
"edit-link-dialog.save": "Nadaljuj",
"edit-link-dialog.title": "Uredi povezavo",
"edit-link-dialog.url": "URL",
"edit-pages-dialog.move-down": "Premakni navzdol",
"edit-pages-dialog.move-up": "Premakni navzgor",
"embed-dialog.back": "Nazaj",
"embed-dialog.cancel": "Prekliči",
"embed-dialog.create": "Ustvari",
"embed-dialog.instruction": "Prilepite URL spletnega mesta, da ustvarite vdelavo.",
"embed-dialog.invalid-url": "Iz tega URL-ja nismo mogli ustvariti vdelave.",
"embed-dialog.title": "Ustvari vdelavo",
"embed-dialog.url": "URL",
"file-system.confirm-clear.cancel": "Prekliči",
"file-system.confirm-clear.continue": "Nadaljuj",
"file-system.confirm-clear.description": "Če ustvarite nov projekt, boste izbrisali trenutni projekt in vse neshranjene spremembe bodo izgubljene. Ste prepričani, da želite nadaljevati?",
"file-system.confirm-clear.dont-show-again": "Ne sprašuj znova",
"file-system.confirm-clear.title": "Počisti trenutni projekt?",
"file-system.confirm-open.cancel": "Prekliči",
"file-system.confirm-open.description": "Če ustvarite nov projekt, boste izbrisali trenutni projekt in vse neshranjene spremembe bodo izgubljene. Ste prepričani, da želite nadaljevati?",
"file-system.confirm-open.dont-show-again": "Ne sprašuj znova",
"file-system.confirm-open.open": "Odpri datoteko",
"file-system.confirm-open.title": "Prepiši trenutni projekt?",
"file-system.file-open-error.file-format-version-too-new": "Datoteka, ki ste jo poskušali odpreti, je iz novejše različice tldraw. Ponovno naložite stran in poskusite znova.",
"file-system.file-open-error.generic-corrupted-file": "Datoteka, ki ste jo poskušali odpreti, je poškodovana.",
"file-system.file-open-error.not-a-tldraw-file": "Datoteka, ki ste jo poskušali odpreti, ni videti kot datoteka tldraw.",
"file-system.file-open-error.title": "Datoteke ni bilo mogoče odpreti",
"file-system.shared-document-file-open-error.description": "Odpiranje datotek v skupnih projektih ni podprto.",
"file-system.shared-document-file-open-error.title": "Datoteke ni bilo mogoče odpreti",
"fill-style.none": "Brez",
"fill-style.pattern": "Vzorec",
"fill-style.semi": "Polovično",
"fill-style.solid": "Polno",
"focus-mode.toggle-focus-mode": "Preklopi na osredotočen način",
"font-style.draw": "Draw",
"font-style.mono": "Mono",
"font-style.sans": "Sans",
"font-style.serif": "Serif",
"geo-style.arrow-down": "Puščica navzdol",
"geo-style.arrow-left": "Puščica levo",
"geo-style.arrow-right": "Puščica desno",
"geo-style.arrow-up": "Puščica navzgor",
"geo-style.check-box": "Potrditveno polje",
"geo-style.cloud": "Oblak",
"geo-style.diamond": "Diamant",
"geo-style.ellipse": "Elipsa",
"geo-style.hexagon": "Šesterokotnik",
"geo-style.octagon": "Osmerokotnik",
"geo-style.oval": "Oval",
"geo-style.pentagon": "Peterokotnik",
"geo-style.rectangle": "Pravokotnik",
"geo-style.rhombus": "Romb",
"geo-style.rhombus-2": "Romb 2",
"geo-style.star": "Zvezda",
"geo-style.trapezoid": "Trapez",
"geo-style.triangle": "Trikotnik",
"geo-style.x-box": "X polje",
"help-menu.about": "O nas",
"help-menu.discord": "Discord",
"help-menu.github": "GitHub",
"help-menu.keyboard-shortcuts": "Bližnjice na tipkovnici",
"help-menu.title": "Pomoč in viri",
"help-menu.twitter": "Twitter",
"home-project-dialog.description": "To je vaš lokalni projekt. Namenjen je samo vam!",
"home-project-dialog.ok": "V redu",
"home-project-dialog.title": "Lokalni projekt",
"menu.copy-as": "Kopiraj kot",
"menu.edit": "Uredi",
"menu.export-as": "Izvozi kot",
"menu.file": "Datoteka",
"menu.language": "Jezik",
"menu.preferences": "Nastavitve",
"menu.title": "Meni",
"menu.view": "Pogled",
"navigation-zone.toggle-minimap": "Preklopi mini zemljevid",
"navigation-zone.zoom": "Povečava",
"opacity-style.0.1": "10 %",
"opacity-style.0.25": "25 %",
"opacity-style.0.5": "50 %",
"opacity-style.0.75": "75 %",
"opacity-style.1": "100 %",
"page-menu.create-new-page": "Ustvari novo stran",
"page-menu.edit-done": "Zaključi",
"page-menu.edit-start": "Uredi",
"page-menu.go-to-page": "Pojdi na stran",
"page-menu.max-page-count-reached": "Doseženo največje število strani",
"page-menu.new-page-initial-name": "Stran 1",
"page-menu.submenu.delete": "Izbriši",
"page-menu.submenu.duplicate-page": "Podvoji",
"page-menu.submenu.move-down": "Premakni navzdol",
"page-menu.submenu.move-up": "Premakni navzgor",
"page-menu.submenu.rename": "Preimenuj",
"page-menu.submenu.title": "Meni",
"page-menu.title": "Strani",
"people-menu.change-color": "Spremeni barvo",
"people-menu.change-name": "Spremeni ime",
"people-menu.follow": "Sledi",
"people-menu.following": "Sledim",
"people-menu.invite": "Povabi ostale",
"people-menu.leading": "Sledi vam",
"people-menu.title": "Ljudje",
"people-menu.user": "(Ti)",
"rename-project-dialog.cancel": "Prekliči",
"rename-project-dialog.rename": "Preimenuj",
"rename-project-dialog.title": "Preimenuj projekt",
"share-menu.copy-link": "Kopiraj povezavo",
"share-menu.copy-link-note": "Vsakdo s povezavo si bo lahko ogledal in urejal ta projekt.",
"share-menu.copy-readonly-link": "Kopiraj povezavo samo za branje",
"share-menu.copy-readonly-link-note": "Vsakdo s povezavo si bo lahko ogledal (vendar ne urejal) ta projekt.",
"share-menu.create-snapshot-link": "Ustvari povezavo do posnetka",
"share-menu.default-project-name": "Skupni projekt",
"share-menu.fork-note": "Na podlagi tega posnetka ustvarite nov skupni projekt.",
"share-menu.offline-note": "Skupna raba tega projekta bo ustvarila živo kopijo na novem URL-ju. URL lahko delite z do tridesetimi drugimi osebami, s katerimi lahko skupaj gledate in urejate vsebino.",
"share-menu.project-too-large": "Žal tega projekta ni mogoče deliti, ker je prevelik. Delamo na tem!",
"share-menu.readonly-link": "Samo za branje",
"share-menu.save-note": "Ta projekt prenesite na svoj računalnik kot datoteko .tldr.",
"share-menu.share-project": "Deli ta projekt",
"share-menu.snapshot-link-note": "Zajemite in delite ta projekt kot povezavo do posnetka samo za branje.",
"share-menu.title": "Deli",
"share-menu.upload-failed": "Oprostite, trenutno nismo mogli naložiti vašega projekta. Poskusite znova ali nam sporočite, če se težava ponovi.",
"sharing.confirm-leave.cancel": "Prekliči",
"sharing.confirm-leave.description": "Ali ste prepričani, da želite zapustiti ta skupni projekt? Nanj se lahko vrnete tako, da se ponovno vrnete na njegov URL.",
"sharing.confirm-leave.dont-show-again": "Ne sprašuj znova",
"sharing.confirm-leave.leave": "Zapusti",
"sharing.confirm-leave.title": "Zapusti trenutni projekt?",
"shortcuts-dialog.collaboration": "Sodelovanje",
"shortcuts-dialog.edit": "Uredi",
"shortcuts-dialog.file": "Datoteka",
"shortcuts-dialog.preferences": "Nastavitve",
"shortcuts-dialog.title": "Bližnjice na tipkovnici",
"shortcuts-dialog.tools": "Orodja",
"shortcuts-dialog.transform": "Preoblikuj",
"shortcuts-dialog.view": "Pogled",
"size-style.l": "Veliko",
"size-style.m": "Srednje",
"size-style.s": "Malo",
"size-style.xl": "Zelo veliko",
"spline-style.cubic": "Kubično",
"spline-style.line": "Črta",
"status.offline": "Brez povezave",
"status.online": "Povezan",
"style-panel.align": "Poravnava",
"style-panel.arrowhead-end": "Konec",
"style-panel.arrowhead-start": "Začetek",
"style-panel.arrowheads": "Puščice",
"style-panel.color": "Barva",
"style-panel.dash": "Črtasto",
"style-panel.fill": "Polnilo",
"style-panel.font": "Pisava",
"style-panel.geo": "Oblika",
"style-panel.mixed": "Mešano",
"style-panel.opacity": "Motnost",
"style-panel.position": "Položaj",
"style-panel.size": "Velikost",
"style-panel.spline": "Krivulja",
"style-panel.title": "Stili",
"style-panel.vertical-align": "Navpična poravnava",
"toast.close": "Zapri",
"toast.error.copy-fail.desc": "Kopiranje slike ni uspelo",
"toast.error.copy-fail.title": "Kopiranje ni uspelo",
"toast.error.export-fail.desc": "Izvoz slike ni uspel",
"toast.error.export-fail.title": "Izvoz ni uspel",
"tool-panel.drawing": "Risanje",
"tool-panel.more": "Več",
"tool-panel.shapes": "Oblike",
"tool.arrow": "Puščica",
"tool.arrow-down": "Puščica navzdol",
"tool.arrow-left": "Puščica levo",
"tool.arrow-right": "Puščica desno",
"tool.arrow-up": "Puščica navzgor",
"tool.asset": "Sredstvo",
"tool.check-box": "Potrditveno polje",
"tool.cloud": "Oblak",
"tool.diamond": "Diamant",
"tool.draw": "Risanje",
"tool.ellipse": "Elipsa",
"tool.embed": "Vdelava",
"tool.eraser": "Radirka",
"tool.frame": "Okvir",
"tool.hand": "Roka",
"tool.hexagon": "Šesterokotnik",
"tool.highlight": "Marker",
"tool.laser": "Laser",
"tool.line": "Črta",
"tool.note": "Opomba",
"tool.octagon": "Osmerokotnik",
"tool.oval": "Oval",
"tool.pentagon": "Peterokotnik",
"tool.rectangle": "Pravokotnik",
"tool.rhombus": "Romb",
"tool.select": "Izbor",
"tool.star": "Zvezda",
"tool.text": "Besedilo",
"tool.trapezoid": "Trapez",
"tool.triangle": "Trikotnik",
"tool.x-box": "X polje",
"verticalAlign-style.end": "Dno",
"verticalAlign-style.middle": "Sredina",
"verticalAlign-style.start": "Vrh",
"vscode.file-open.backup": "Varnostna kopija",
"vscode.file-open.backup-failed": "Varnostno kopiranje ni uspelo: to ni datoteka .tldr.",
"vscode.file-open.backup-saved": "Varnostna kopija shranjena",
"vscode.file-open.desc": "Ta datoteka je bila ustvarjena s starejšo različico tldraw. Ali jo želite posodobiti, da bo deloval z novo različico?",
"vscode.file-open.dont-show-again": "Ne sprašuj znova",
"vscode.file-open.open": "Nadaljuj"
}

View file

@ -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;
@ -807,7 +823,6 @@ export class Editor extends EventEmitter<TLEventMap> {
util: ShapeUtil;
}[];
getViewportPageBounds(): Box;
getViewportPageCenter(): Vec;
getViewportScreenBounds(): Box;
getViewportScreenCenter(): Vec;
getZoomLevel(): number;
@ -853,18 +868,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;
@ -881,24 +886,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;
@ -947,22 +948,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)
@ -1211,9 +1210,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: {
@ -1449,12 +1445,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;
@ -1980,12 +1970,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>;
@ -2068,6 +2052,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-100' | 'fit-max' | 'fit-min-100' | 'fit-min' | 'fit-x-100' | 'fit-x' | 'fit-y-100' | 'fit-y';
initialZoom: 'default' | 'fit-max-100' | 'fit-max' | 'fit-min-100' | 'fit-min' | 'fit-x-100' | 'fit-x' | 'fit-y-100' | 'fit-y';
origin: VecLike;
padding: VecLike;
};
panSpeed: number;
zoomSpeed: number;
zoomSteps: number[];
isLocked: boolean;
};
// @public (undocumented)
export type TLCancelEvent = (info: TLCancelEventInfo) => void;
@ -2140,6 +2155,7 @@ export const TldrawEditor: React_2.NamedExoticComponent<TldrawEditorProps>;
// @public
export interface TldrawEditorBaseProps {
autoFocus?: boolean;
cameraOptions?: Partial<TLCameraOptions>;
children?: ReactNode;
className?: string;
components?: TLEditorComponents;
@ -2171,6 +2187,7 @@ export type TLEditorComponents = Partial<{
// @public (undocumented)
export interface TLEditorOptions {
cameraOptions?: Partial<TLCameraOptions>;
getContainer: () => HTMLElement;
inferDarkMode?: boolean;
initialState?: string;
@ -3069,9 +3086,6 @@ export class WeakMapCache<T extends object, K> {
export { whyAmIRunning }
// @internal (undocumented)
export const ZOOMS: number[];
export * from "@tldraw/store";
export * from "@tldraw/tlschema";

View file

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

View file

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

View file

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

File diff suppressed because it is too large Load diff

View file

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

View file

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

View file

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

View file

@ -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,110 @@ 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 = {
/** Whether the camera is locked. */
isLocked: boolean
/** The speed of a scroll wheel / trackpad pan. Default is 1. */
panSpeed: number
/** The speed of a scroll wheel / trackpad zoom. Default is 1. */
zoomSpeed: number
/** The steps that a user can zoom between with zoom in / zoom out. The first and last value will determine the min and max zoom. */
zoomSteps: number[]
/** Controls whether the wheel pans or zooms.
*
* - `zoom`: The wheel will zoom in and out.
* - `pan`: The wheel will pan the camera.
* - `none`: The wheel will do nothing.
*/
wheelBehavior: 'zoom' | 'pan' | 'none'
/** 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.
*
* - `default`: Sets the initial zoom to 100%.
* - `fit-x`: The x axis will completely fill the viewport bounds.
* - `fit-y`: The y axis will completely fill the viewport bounds.
* - `fit-min`: The smaller axis will completely fill the viewport bounds.
* - `fit-max`: The larger axis will completely fill the viewport bounds.
* - `fit-x-100`: The x axis will completely fill the viewport bounds, or 100% zoom, whichever is smaller.
* - `fit-y-100`: The y axis will completely fill the viewport bounds, or 100% zoom, whichever is smaller.
* - `fit-min-100`: The smaller axis will completely fill the viewport bounds, or 100% zoom, whichever is smaller.
* - `fit-max-100`: The larger axis will completely fill the viewport bounds, or 100% zoom, whichever is smaller.
*/
initialZoom:
| 'fit-min'
| 'fit-max'
| 'fit-x'
| 'fit-y'
| 'fit-min-100'
| 'fit-max-100'
| 'fit-x-100'
| 'fit-y-100'
| 'default'
/** The camera's base for its zoom steps.
*
* - `default`: Sets the initial zoom to 100%.
* - `fit-x`: The x axis will completely fill the viewport bounds.
* - `fit-y`: The y axis will completely fill the viewport bounds.
* - `fit-min`: The smaller axis will completely fill the viewport bounds.
* - `fit-max`: The larger axis will completely fill the viewport bounds.
* - `fit-x-100`: The x axis will completely fill the viewport bounds, or 100% zoom, whichever is smaller.
* - `fit-y-100`: The y axis will completely fill the viewport bounds, or 100% zoom, whichever is smaller.
* - `fit-min-100`: The smaller axis will completely fill the viewport bounds, or 100% zoom, whichever is smaller.
* - `fit-max-100`: The larger axis will completely fill the viewport bounds, or 100% zoom, whichever is smaller.
*/
baseZoom:
| 'fit-min'
| 'fit-max'
| 'fit-x'
| 'fit-y'
| 'fit-min-100'
| 'fit-max-100'
| 'fit-x-100'
| 'fit-y-100'
| 'default'
/** The behavior for the constraints for both axes or each axis individually.
*
* - `free`: The bounds are ignored when moving the camera.
* - 'fixed': The bounds will be positioned within the viewport based on the origin
* - `contain`: The 'fixed' behavior will be used when the zoom is below the zoom level at which the bounds would fill the viewport; and when above this zoom, the bounds will use the 'inside' behavior.
* - `inside`: The bounds will stay completely within the viewport.
* - `outside`: The bounds will stay touching the viewport.
*/
behavior:
| 'free'
| 'fixed'
| 'inside'
| 'outside'
| 'contain'
| {
x: 'free' | 'fixed' | 'inside' | 'outside' | 'contain'
y: 'free' | 'fixed' | 'inside' | 'outside' | 'contain'
}
}
}

View file

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

View file

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

View file

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

View file

@ -11,17 +11,9 @@ export function normalizeWheel(event: WheelEvent | React.WheelEvent<HTMLElement>
let { deltaY, deltaX } = event
let deltaZ = 0
// wheeling
if (event.ctrlKey || event.altKey || event.metaKey) {
const signY = Math.sign(event.deltaY)
const absDeltaY = Math.abs(event.deltaY)
let dy = deltaY
if (absDeltaY > MAX_ZOOM_STEP) {
dy = MAX_ZOOM_STEP * signY
}
deltaZ = dy / 100
deltaZ = (Math.abs(deltaY) > MAX_ZOOM_STEP ? MAX_ZOOM_STEP * Math.sign(deltaY) : deltaY) / 100
} else {
if (event.shiftKey && !IS_DARWIN) {
deltaX = deltaY

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -62,7 +62,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]
)
@ -101,7 +101,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 } })
} else {
const clampedPoint = minimapRef.current.minimapScreenPointToPagePoint(
e.clientX,

View file

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

View file

@ -503,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,
@ -1037,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 },
})
},
},
{
@ -1047,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 },
})
},
},
{
@ -1058,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 },
})
},
},
{
@ -1068,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 } })
},
},
{
@ -1081,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 } })
},
},
{
@ -1288,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 },
})
},
},
{

View file

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

View file

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

View file

@ -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
})
@ -474,6 +483,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,

View file

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

View file

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

View file

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

View file

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

View file

@ -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('Is not undoable', () => {
editor.mark()
editor.pan({ x: 200, y: 200 })

View file

@ -1,9 +0,0 @@
// import { TestEditor } from '../TestEditor'
// let editor: TestEditor
// beforeEach(() => {
// editor =new TestEditor()
// })
it.todo('sets the app state')

View file

@ -0,0 +1,691 @@
import { Box, DEFAULT_CAMERA_OPTIONS, Vec } from '@tldraw/editor'
import { TestEditor } from '../TestEditor'
let editor: TestEditor
beforeEach(() => {
editor = new TestEditor()
editor.updateViewportScreenBounds(new Box(0, 0, 1600, 900))
})
const wheelEvent = {
type: 'wheel',
name: 'wheel',
delta: new Vec(0, 0, 0),
point: new Vec(0, 0),
shiftKey: false,
altKey: false,
ctrlKey: false,
} as const
describe('With default options', () => {
beforeEach(() => {
editor.setCameraOptions({ ...DEFAULT_CAMERA_OPTIONS })
})
it.todo('pans')
it.todo('zooms in')
it.todo('zooms out')
it.todo('resets zoom')
it.todo('pans with wheel')
})
it('Sets the camera options', () => {
const optionsA = { ...DEFAULT_CAMERA_OPTIONS, panSpeed: 2 }
editor.setCameraOptions(optionsA)
expect(editor.getCameraOptions()).toMatchObject(optionsA)
})
describe('CameraOptions.wheelBehavior', () => {
it('Pans when wheel behavior is pan', () => {
editor
.setCameraOptions({ ...DEFAULT_CAMERA_OPTIONS, wheelBehavior: 'pan' })
.dispatch({
...wheelEvent,
delta: new Vec(5, 10),
})
.forceTick()
expect(editor.getCamera()).toMatchObject({ x: 5, y: 10, z: 1 })
})
it('Zooms when wheel behavior is zoom', () => {
editor
.setCameraOptions({ ...DEFAULT_CAMERA_OPTIONS, wheelBehavior: 'zoom' })
.dispatch({
...wheelEvent,
delta: new Vec(0, 0, 0),
})
.forceTick()
expect(editor.getCamera()).toMatchObject({ z: 1 })
editor
.dispatch({
...wheelEvent,
delta: new Vec(0, 1, 0),
})
.forceTick()
expect(editor.getCamera()).toMatchObject({ z: 1.01 })
editor
.dispatch({
...wheelEvent,
delta: new Vec(0, -1, 0),
})
.forceTick()
expect(editor.getCamera()).toMatchObject({ z: 0.9999 }) // zooming is non-linear
})
it('When wheelBehavior is pan, ctrl key zooms', () => {
editor
.setCameraOptions({ ...DEFAULT_CAMERA_OPTIONS, wheelBehavior: 'pan' })
.dispatch({
...wheelEvent,
delta: new Vec(0, 5, 0.01),
ctrlKey: true, // zooms
})
.forceTick()
expect(editor.getCamera()).toMatchObject({ z: 1.01 })
})
it('When wheelBehavior is zoom, ctrl key pans', () => {
editor
.setCameraOptions({ ...DEFAULT_CAMERA_OPTIONS, wheelBehavior: 'zoom' })
.dispatch({
...wheelEvent,
delta: new Vec(0, 5, 0.01),
ctrlKey: true, // zooms
})
.forceTick()
expect(editor.getCamera()).toMatchObject({ x: 0, y: 5, z: 1 })
})
})
describe('CameraOptions.panSpeed', () => {
it('Effects wheel panning (2x)', () => {
editor
.setCameraOptions({ ...DEFAULT_CAMERA_OPTIONS, panSpeed: 2, wheelBehavior: 'pan' })
.dispatch({
...wheelEvent,
delta: new Vec(5, 10),
})
.forceTick()
expect(editor.getCamera()).toMatchObject({ x: 10, y: 20, z: 1 })
})
it('Effects wheel panning (.5x)', () => {
editor
.setCameraOptions({ ...DEFAULT_CAMERA_OPTIONS, panSpeed: 0.5, wheelBehavior: 'pan' })
.dispatch({
...wheelEvent,
delta: new Vec(5, 10),
})
.forceTick()
expect(editor.getCamera()).toMatchObject({ x: 2.5, y: 5, z: 1 })
})
it('Does not effect zoom mouse wheeling', () => {
editor
.setCameraOptions({ ...DEFAULT_CAMERA_OPTIONS, panSpeed: 2, wheelBehavior: 'zoom' })
.dispatch({
...wheelEvent,
delta: new Vec(0, 1, 0),
})
.forceTick()
expect(editor.getCamera()).toMatchObject({ x: 0, y: 0, z: 1.01 }) // 1 + 1
})
it.todo('hand tool panning')
it.todo('spacebar panning')
it.todo('edge scroll panning')
})
describe('CameraOptions.zoomSpeed', () => {
it('Effects wheel zooming (2x)', () => {
editor
.setCameraOptions({ ...DEFAULT_CAMERA_OPTIONS, zoomSpeed: 2, wheelBehavior: 'zoom' })
.dispatch({
...wheelEvent,
delta: new Vec(0, 1, 0),
})
.forceTick()
expect(editor.getCamera()).toMatchObject({ x: 0, y: 0, z: 1.02 }) // 1 + (.01 * 2)
})
it('Effects wheel zooming (.5x)', () => {
editor
.setCameraOptions({ ...DEFAULT_CAMERA_OPTIONS, zoomSpeed: 0.5, wheelBehavior: 'zoom' })
.dispatch({
...wheelEvent,
delta: new Vec(0, 1, 0),
})
.forceTick()
expect(editor.getCamera()).toMatchObject({ x: 0, y: 0, z: 1.005 }) // 1 + (.01 * .5)
})
it('Does not effect mouse wheel panning', () => {
editor
.setCameraOptions({ ...DEFAULT_CAMERA_OPTIONS, zoomSpeed: 0.5, wheelBehavior: 'pan' })
.dispatch({
...wheelEvent,
delta: new Vec(5, 10),
})
.forceTick()
expect(editor.getCamera()).toMatchObject({ x: 5, y: 10, z: 1 })
})
it.todo('zoom method')
it.todo('zoom tool zooming')
it.todo('pinch zooming')
})
describe('CameraOptions.isLocked', () => {
it('Pans when unlocked', () => {
editor
.setCameraOptions({ ...DEFAULT_CAMERA_OPTIONS, isLocked: false })
.dispatch({
...wheelEvent,
delta: new Vec(5, 10),
})
.forceTick()
expect(editor.getCamera()).toMatchObject({ x: 5, y: 10, z: 1 })
editor.pan(new Vec(10, 10))
expect(editor.getCamera()).toMatchObject({ x: 15, y: 20, z: 1 })
})
it('Does not pan when locked', () => {
editor
.setCameraOptions({ ...DEFAULT_CAMERA_OPTIONS, isLocked: true })
.dispatch({
...wheelEvent,
delta: new Vec(5, 10),
})
.forceTick()
expect(editor.getCamera()).toMatchObject({ x: 0, y: 0, z: 1 })
editor.pan(new Vec(10, 10))
expect(editor.getCamera()).toMatchObject({ x: 0, y: 0, z: 1 })
})
it('Zooms when unlocked', () => {
editor
.setCameraOptions({ ...DEFAULT_CAMERA_OPTIONS, isLocked: false })
.dispatch({
...wheelEvent,
delta: new Vec(0, 0, 0.01),
ctrlKey: true,
})
.forceTick()
expect(editor.getCamera()).toMatchObject({ z: 1.01 })
editor.zoomIn(undefined, { immediate: true })
expect(editor.getCamera()).toMatchObject({ z: 2 })
})
it('Does not zoom when locked', () => {
editor
.setCameraOptions({ ...DEFAULT_CAMERA_OPTIONS, isLocked: true })
.dispatch({
...wheelEvent,
delta: new Vec(0, 0, 0.01),
ctrlKey: true,
})
.forceTick()
expect(editor.getCamera()).toMatchObject({ z: 1 })
editor.zoomIn(undefined, { immediate: true })
expect(editor.getCamera()).toMatchObject({ z: 1 })
})
})
// zoom steps are tested in zoom in / zoom out method
describe('CameraOptions.zoomSteps', () => {
it('Does not zoom past max zoom step', () => {
editor
.setCameraOptions({ ...DEFAULT_CAMERA_OPTIONS, zoomSteps: [0.5, 1, 2] })
.setCamera(new Vec(0, 0, 100), { immediate: true })
expect(editor.getZoomLevel()).toBe(2)
editor.zoomIn(undefined, { immediate: true })
expect(editor.getZoomLevel()).toBe(2)
})
it('Does not zoom below min zoom step', () => {
editor
.setCameraOptions({ ...DEFAULT_CAMERA_OPTIONS, zoomSteps: [0.5, 1, 2] })
.setCamera(new Vec(0, 0, 0), { immediate: true })
expect(editor.getZoomLevel()).toBe(0.5)
editor.zoomOut(undefined, { immediate: true })
expect(editor.getZoomLevel()).toBe(0.5)
})
it('Zooms between zoom steps', () => {
editor.setCameraOptions({ ...DEFAULT_CAMERA_OPTIONS, zoomSteps: [0.5, 1, 2] })
expect(editor.getZoomLevel()).toBe(1)
editor.zoomIn(undefined, { immediate: true })
expect(editor.getZoomLevel()).toBe(2)
editor.zoomOut(undefined, { immediate: true })
expect(editor.getZoomLevel()).toBe(1)
editor.zoomOut(undefined, { immediate: true })
expect(editor.getZoomLevel()).toBe(0.5)
editor.zoomIn(undefined, { immediate: true })
expect(editor.getZoomLevel()).toBe(1)
})
})
// 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'
// }
// }
const DEFAULT_CONSTRAINTS = {
bounds: { x: 0, y: 0, w: 1200, h: 800 },
padding: { x: 0, y: 0 },
origin: { x: 0.5, y: 0.5 },
initialZoom: 'default',
baseZoom: 'default',
behavior: 'free',
} as const
describe('When constraints are free', () => {
beforeEach(() => {
editor.updateViewportScreenBounds(new Box(0, 0, 1600, 900))
editor.setCameraOptions({
...DEFAULT_CAMERA_OPTIONS,
constraints: {
...DEFAULT_CONSTRAINTS,
behavior: 'free',
},
})
})
it('starts at 1 zoom', () => {
expect(editor.getZoomLevel()).toBe(1)
})
it('pans freely', () => {
editor.pan(new Vec(100, 100))
expect(editor.getCamera()).toMatchObject({ x: 100, y: 100, z: 1 })
editor.pan(new Vec(5000, 5000))
expect(editor.getCamera()).toMatchObject({ x: 5100, y: 5100, z: 1 })
})
it('zooms onto mouse position', () => {
editor.pointerMove(100, 100)
expect(editor.inputs.currentPagePoint).toMatchObject({ x: 100, y: 100 })
editor.zoomIn(editor.inputs.currentScreenPoint, { immediate: true })
expect(editor.inputs.currentPagePoint).toMatchObject({ x: 100, y: 100 })
editor.zoomOut(editor.inputs.currentScreenPoint, { immediate: true })
expect(editor.inputs.currentPagePoint).toMatchObject({ x: 100, y: 100 })
})
})
describe('When constraints are contain', () => {
beforeEach(() => {
editor.setCameraOptions({
...DEFAULT_CAMERA_OPTIONS,
constraints: {
...DEFAULT_CONSTRAINTS,
behavior: 'contain',
},
})
})
it('resets zoom to 1', () => {
editor.zoomIn(undefined, { immediate: true })
editor.zoomIn(undefined, { immediate: true })
editor.resetZoom()
expect(editor.getZoomLevel()).toBe(1)
})
it('does not pan when below the fit zoom', () => {
editor.pan(new Vec(100, 100))
expect(editor.getCamera()).toMatchObject({ x: 200, y: 50, z: 1 })
editor.pan(new Vec(5000, 5000))
expect(editor.getCamera()).toMatchObject({ x: 200, y: 50, z: 1 })
})
})
describe('Zoom reset positions based on origin', () => {
it('Default .5, .5 origin', () => {
editor.setCameraOptions(
{
...DEFAULT_CAMERA_OPTIONS,
constraints: {
...DEFAULT_CONSTRAINTS,
behavior: 'contain',
origin: { x: 0.5, y: 0.5 },
initialZoom: 'default',
},
},
{ reset: true }
)
expect(editor.getCamera()).toMatchObject({ x: 200, y: 50, z: 1 })
editor.zoomIn().resetZoom().forceTick()
expect(editor.getCamera()).toMatchObject({ x: 200, y: 50, z: 1 })
})
it('0 0 origin', () => {
editor.setCameraOptions(
{
...DEFAULT_CAMERA_OPTIONS,
constraints: {
...DEFAULT_CONSTRAINTS,
behavior: 'contain',
origin: { x: 0, y: 0 },
initialZoom: 'default',
},
},
{ reset: true }
)
expect(editor.getCamera()).toMatchObject({ x: 0, y: 0, z: 1 })
editor.zoomIn().resetZoom().forceTick()
expect(editor.getCamera()).toMatchObject({ x: 0, y: 0, z: 1 })
})
it('1 1 origin', () => {
editor.setCameraOptions(
{
...DEFAULT_CAMERA_OPTIONS,
constraints: {
...DEFAULT_CONSTRAINTS,
behavior: 'contain',
origin: { x: 1, y: 1 },
initialZoom: 'default',
},
},
{ reset: true }
)
expect(editor.getCamera()).toMatchObject({ x: 400, y: 100, z: 1 })
editor.zoomIn().resetZoom().forceTick()
expect(editor.getCamera()).toMatchObject({ x: 400, y: 100, z: 1 })
})
})
describe('CameraOptions.constraints.initialZoom + behavior', () => {
it('When fit is default', () => {
editor.setCameraOptions(
{
...DEFAULT_CAMERA_OPTIONS,
constraints: {
...DEFAULT_CONSTRAINTS,
behavior: 'contain',
origin: { x: 0.5, y: 0.5 },
initialZoom: 'default',
},
},
{ reset: true }
)
expect(editor.getCamera()).toMatchObject({ x: 200, y: 50, z: 1 })
editor.zoomIn().resetZoom().forceTick()
expect(editor.getCamera()).toMatchObject({ x: 200, y: 50, z: 1 })
})
it('When fit is fit-max', () => {
editor.setCameraOptions(
{
...DEFAULT_CAMERA_OPTIONS,
constraints: {
...DEFAULT_CONSTRAINTS,
behavior: 'contain',
origin: { x: 0.5, y: 0.5 },
initialZoom: 'fit-max',
},
},
{ reset: true }
)
// y should be 0 because the viewport width is bigger than the height
expect(editor.getCamera()).toCloselyMatchObject({ x: 111.11, y: 0, z: 1.125 }, 2)
editor.zoomIn().resetZoom().forceTick()
expect(editor.getCamera()).toCloselyMatchObject({ x: 111.11, y: 0, z: 1.125 }, 2)
editor.setCameraOptions(
{
...DEFAULT_CAMERA_OPTIONS,
constraints: {
...DEFAULT_CONSTRAINTS,
bounds: { x: 0, y: 0, w: 800, h: 1200 },
behavior: 'contain',
origin: { x: 0.5, y: 0.5 },
initialZoom: 'fit-max',
},
},
{ reset: true }
)
// y should be 0 because the viewport width is bigger than the height
expect(editor.getCamera()).toCloselyMatchObject({ x: 666.66, y: 0, z: 0.75 }, 2)
editor.zoomIn().resetZoom().forceTick()
expect(editor.getCamera()).toCloselyMatchObject({ x: 666.66, y: 0, z: 0.75 }, 2)
// The proportions of the bounds don't matter, it's the proportion of the viewport that decides which axis gets fit
editor.updateViewportScreenBounds(new Box(0, 0, 900, 1600))
editor.setCameraOptions(
{
...DEFAULT_CAMERA_OPTIONS,
constraints: {
...DEFAULT_CONSTRAINTS,
behavior: 'contain',
origin: { x: 0.5, y: 0.5 },
initialZoom: 'fit-max',
},
},
{ reset: true }
)
expect(editor.getCamera()).toCloselyMatchObject({ x: 0, y: 666.66, z: 0.75 }, 2)
editor.zoomIn().resetZoom().forceTick()
expect(editor.getCamera()).toCloselyMatchObject({ x: 0, y: 666.66, z: 0.75 }, 2)
})
it('When fit is fit-min', () => {
editor.setCameraOptions(
{
...DEFAULT_CAMERA_OPTIONS,
constraints: {
...DEFAULT_CONSTRAINTS,
behavior: 'contain',
origin: { x: 0.5, y: 0.5 },
initialZoom: 'fit-min',
},
},
{ reset: true }
)
// x should be 0 because the viewport width is bigger than the height
expect(editor.getCamera()).toCloselyMatchObject({ x: 0, y: -62.5, z: 1.333 }, 2)
editor.zoomIn().resetZoom().forceTick()
expect(editor.getCamera()).toCloselyMatchObject({ x: 0, y: -62.5, z: 1.333 }, 2)
editor.setCameraOptions(
{
...DEFAULT_CAMERA_OPTIONS,
constraints: {
...DEFAULT_CONSTRAINTS,
bounds: { x: 0, y: 0, w: 800, h: 1200 },
behavior: 'contain',
origin: { x: 0.5, y: 0.5 },
initialZoom: 'fit-min',
},
},
{ reset: true }
)
// x should be 0 because the viewport width is bigger than the height
expect(editor.getCamera()).toCloselyMatchObject({ x: 0, y: -375, z: 2 }, 2)
editor.zoomIn().resetZoom().forceTick()
expect(editor.getCamera()).toCloselyMatchObject({ x: 0, y: -375, z: 2 }, 2)
// The proportions of the bounds don't matter, it's the proportion of the viewport that decides which axis gets fit
editor.updateViewportScreenBounds(new Box(0, 0, 900, 1600))
editor.setCameraOptions(
{
...DEFAULT_CAMERA_OPTIONS,
constraints: {
...DEFAULT_CONSTRAINTS,
behavior: 'contain',
origin: { x: 0.5, y: 0.5 },
initialZoom: 'fit-min',
},
},
{ reset: true }
)
expect(editor.getCamera()).toCloselyMatchObject({ x: -375, y: 0, z: 2 }, 2)
editor.zoomIn().resetZoom().forceTick()
expect(editor.getCamera()).toCloselyMatchObject({ x: -375, y: 0, z: 2 }, 2)
})
it('When fit is fit-min-100', () => {
editor.updateViewportScreenBounds(new Box(0, 0, 1600, 900))
editor.setCameraOptions(
{
...DEFAULT_CAMERA_OPTIONS,
constraints: {
...DEFAULT_CONSTRAINTS,
behavior: 'contain',
origin: { x: 0.5, y: 0.5 },
initialZoom: 'fit-min-100',
},
},
{ reset: true }
)
// Max 1 on initial / reset
expect(editor.getCamera()).toCloselyMatchObject({ x: 200, y: 50, z: 1 }, 2)
// Min is regular
editor.updateViewportScreenBounds(new Box(0, 0, 800, 450))
editor.setCameraOptions(
{
...DEFAULT_CAMERA_OPTIONS,
constraints: {
...DEFAULT_CONSTRAINTS,
behavior: 'contain',
origin: { x: 0.5, y: 0.5 },
initialZoom: 'fit-min-100',
},
},
{ reset: true }
)
expect(editor.getCamera()).toCloselyMatchObject({ x: 0, y: -62.5, z: 0.66 }, 2)
})
})
describe('Padding', () => {
it('sets when padding is zero', () => {
editor.setCameraOptions(
{
...DEFAULT_CAMERA_OPTIONS,
constraints: {
...DEFAULT_CONSTRAINTS,
behavior: 'contain',
origin: { x: 0.5, y: 0.5 },
padding: { x: 0, y: 0 },
initialZoom: 'fit-max',
},
},
{ reset: true }
)
// y should be 0 because the viewport width is bigger than the height
expect(editor.getCamera()).toCloselyMatchObject({ x: 111.11, y: 0, z: 1.125 }, 2)
editor.zoomIn().resetZoom().forceTick()
expect(editor.getCamera()).toCloselyMatchObject({ x: 111.11, y: 0, z: 1.125 }, 2)
})
it('sets when padding is 100, 0', () => {
editor.setCameraOptions(
{
...DEFAULT_CAMERA_OPTIONS,
constraints: {
...DEFAULT_CONSTRAINTS,
behavior: 'contain',
origin: { x: 0.5, y: 0.5 },
padding: { x: 100, y: 0 },
initialZoom: 'fit-max',
},
},
{ reset: true }
)
// no change because the horizontal axis has extra space available
expect(editor.getCamera()).toCloselyMatchObject({ x: 111.11, y: 0, z: 1.125 }, 2)
editor.zoomIn().resetZoom().forceTick()
expect(editor.getCamera()).toCloselyMatchObject({ x: 111.11, y: 0, z: 1.125 }, 2)
editor.setCameraOptions(
{
...DEFAULT_CAMERA_OPTIONS,
constraints: {
...DEFAULT_CONSTRAINTS,
behavior: 'contain',
origin: { x: 0.5, y: 0.5 },
padding: { x: 200, y: 0 },
initialZoom: 'fit-max',
},
},
{ reset: true }
)
// now we're pinching
expect(editor.getCamera()).toCloselyMatchObject({ x: 200, y: 50, z: 1 }, 2)
editor.zoomIn().resetZoom().forceTick()
expect(editor.getCamera()).toCloselyMatchObject({ x: 200, y: 50, z: 1 }, 2)
})
it('sets when padding is 0 x 100', () => {
editor.setCameraOptions(
{
...DEFAULT_CAMERA_OPTIONS,
constraints: {
...DEFAULT_CONSTRAINTS,
behavior: 'contain',
origin: { x: 0.5, y: 0.5 },
padding: { x: 0, y: 100 },
initialZoom: 'fit-max',
},
},
{ reset: true }
)
// y should be 0 because the viewport width is bigger than the height
expect(editor.getCamera()).toCloselyMatchObject({ x: 314.28, y: 114.28, z: 0.875 }, 2)
editor.zoomIn().resetZoom().forceTick()
expect(editor.getCamera()).toCloselyMatchObject({ x: 314.28, y: 114.28, z: 0.875 }, 2)
})
})
describe('Contain behavior', () => {
it.todo(
'Locks axis until the bounds are bigger than the padded viewport, then allows "inside" panning'
)
})
describe('Inside behavior', () => {
it.todo('Allows panning that keeps the bounds inside of the padded viewport')
})
describe('Outside behavior', () => {
it.todo('Allows panning that keeps the bounds adjacent to the padded viewport')
})
describe('Allows mixed values for x and y', () => {
it.todo('Allows different values to be set for x and y axes')
})

View file

@ -1,4 +1,4 @@
import { ZOOMS } from '@tldraw/editor'
import { DEFAULT_CAMERA_OPTIONS } from '@tldraw/editor'
import { TestEditor } from '../TestEditor'
let editor: TestEditor
@ -8,28 +8,28 @@ 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('is ignored by undo/redo', () => {
const cameraOptions = editor.getCameraOptions()
editor.mark()
editor.zoomIn()
editor.undo()
expect(editor.getZoomLevel()).toBe(ZOOMS[4])
expect(editor.getZoomLevel()).toBe(cameraOptions.zoomSteps[4])
})
it('preserves the screen center', () => {
@ -55,18 +55,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 })
})

View file

@ -1,4 +1,3 @@
import { ZOOMS } from '@tldraw/editor'
import { TestEditor } from '../TestEditor'
let editor: TestEditor
@ -7,32 +6,119 @@ 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('is ignored by undo/redo', () => {
const cameraOptions = editor.getCameraOptions()
editor.mark()
editor.zoomOut()
editor.undo()
expect(editor.getZoomLevel()).toBe(ZOOMS[2])
expect(editor.getZoomLevel()).toBe(cameraOptions.zoomSteps[2])
})
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)
})

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -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 })
})
})
describe('Add text align to text shapes', () => {
const { up, down } = getTestMigration(textShapeVersions.AddTextAlign)

View file

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