From fabba66c0f4b6c42ece30f409e70eb01e588f8e1 Mon Sep 17 00:00:00 2001 From: Steve Ruiz Date: Sat, 4 May 2024 18:39:04 +0100 Subject: [PATCH] Camera options (#3282) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 (
) } ``` 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 --- apps/docs/content/docs/editor.mdx | 155 +- .../components/PeopleMenu/PeopleMenuItem.tsx | 2 +- apps/dotcom/src/hooks/useUrlState.ts | 19 +- apps/dotcom/src/utils/useFileSystem.tsx | 1 - .../AfterCreateUpdateShapeExample.tsx | 2 +- .../AfterDeleteShapeExample.tsx | 2 +- .../BeforeCreateUpdateShapeExample.tsx | 2 +- .../BeforeDeleteShapeExample.tsx | 2 +- .../camera-options/CameraOptionsExample.tsx | 495 ++++++ .../src/examples/camera-options/README.md | 11 + .../editable-shape/EditableShapeUtil.tsx | 2 +- .../ExternalContentSourcesExample.tsx | 2 +- .../image-annotator/ImageAnnotationEditor.tsx | 276 +-- .../src/examples/pdf-editor/PdfEditor.tsx | 275 +-- .../popup-shape/PopupShapeExample.tsx | 2 +- .../rendering-shape-changes/README.md | 9 - .../RenderingShapesChangeExample.tsx | 27 - .../useRenderingShapesChange.ts | 50 - .../src/examples/slides/useSlides.tsx | 5 +- .../src/examples/ui-events/codeSnippets.ts | 15 +- assets/translations/sl.json | 744 ++++---- packages/editor/api-report.md | 128 +- packages/editor/src/index.ts | 19 +- packages/editor/src/lib/TldrawEditor.tsx | 10 +- packages/editor/src/lib/constants.ts | 27 +- packages/editor/src/lib/editor/Editor.ts | 1540 ++++++++++------- .../editor/derivations/notVisibleShapes.ts | 4 - .../src/lib/editor/managers/ClickManager.ts | 221 ++- .../managers/SnapManager/SnapManager.ts | 2 +- .../editor/src/lib/editor/types/misc-types.ts | 109 ++ .../editor/src/lib/hooks/useCanvasEvents.ts | 3 +- .../src/lib/hooks/useSelectionEvents.ts | 3 +- .../editor/src/lib/utils/edgeScrolling.ts | 12 +- .../editor/src/lib/utils/normalizeWheel.ts | 12 +- .../src/lib/defaultExternalContentHandlers.ts | 20 +- .../src/lib/shapes/shared/ShapeFill.tsx | 4 +- .../lib/shapes/shared/defaultStyleDefs.tsx | 16 +- .../tldraw/src/lib/tools/HandTool/HandTool.ts | 14 +- .../src/lib/tools/SelectTool/selectHelpers.ts | 4 +- .../tools/ZoomTool/childStates/Pointing.ts | 4 +- .../ZoomTool/childStates/ZoomBrushing.ts | 6 +- .../src/lib/ui/components/EmbedDialog.tsx | 2 +- .../ui/components/Minimap/DefaultMinimap.tsx | 4 +- .../components/ZoomMenu/DefaultZoomMenu.tsx | 4 +- .../tldraw/src/lib/ui/context/actions.tsx | 25 +- .../src/lib/ui/hooks/useClipboardEvents.ts | 1 + packages/tldraw/src/lib/utils/tldr/file.ts | 1 - packages/tldraw/src/test/TestEditor.ts | 25 +- .../src/test/commands/animationSpeed.test.ts | 6 +- .../src/test/commands/centerOnPoint.test.ts | 2 +- .../src/test/commands/getBaseZoom.test.ts | 89 + .../src/test/commands/getInitialZoom.test.ts | 89 + packages/tldraw/src/test/commands/pan.test.ts | 12 + .../src/test/commands/setAppState.test.ts | 9 - .../src/test/commands/setCamera.test.ts | 691 ++++++++ .../tldraw/src/test/commands/zoomIn.test.ts | 46 +- .../tldraw/src/test/commands/zoomOut.test.ts | 106 +- .../src/test/commands/zoomToBounds.test.ts | 2 +- .../src/test/commands/zoomToFit.test.ts | 2 +- .../src/test/commands/zoomToSelection.test.ts | 2 +- .../tldraw/src/test/getCulledShapes.test.tsx | 1 - packages/tldraw/src/test/paste.test.ts | 1 - .../tldraw/src/test/renderingShapes.test.tsx | 13 - packages/tlschema/api-report.md | 2 - packages/tlschema/src/migrations.test.ts | 12 + packages/tlschema/src/records/TLInstance.ts | 16 +- 66 files changed, 3564 insertions(+), 1855 deletions(-) create mode 100644 apps/examples/src/examples/camera-options/CameraOptionsExample.tsx create mode 100644 apps/examples/src/examples/camera-options/README.md delete mode 100644 apps/examples/src/examples/rendering-shape-changes/README.md delete mode 100644 apps/examples/src/examples/rendering-shape-changes/RenderingShapesChangeExample.tsx delete mode 100644 apps/examples/src/examples/rendering-shape-changes/useRenderingShapesChange.ts create mode 100644 packages/tldraw/src/test/commands/getBaseZoom.test.ts create mode 100644 packages/tldraw/src/test/commands/getInitialZoom.test.ts delete mode 100644 packages/tldraw/src/test/commands/setAppState.test.ts create mode 100644 packages/tldraw/src/test/commands/setCamera.test.ts diff --git a/apps/docs/content/docs/editor.mdx b/apps/docs/content/docs/editor.mdx index 3ff0e268f..c42b3e76f 100644 --- a/apps/docs/content/docs/editor.mdx +++ b/apps/docs/content/docs/editor.mdx @@ -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 diff --git a/apps/dotcom/src/components/PeopleMenu/PeopleMenuItem.tsx b/apps/dotcom/src/components/PeopleMenu/PeopleMenuItem.tsx index 202a78657..c2242eb75 100644 --- a/apps/dotcom/src/components/PeopleMenu/PeopleMenuItem.tsx +++ b/apps/dotcom/src/components/PeopleMenu/PeopleMenuItem.tsx @@ -38,7 +38,7 @@ export const PeopleMenuItem = track(function PeopleMenuItem({ userId }: { userId editor.animateToUser(userId)} + onClick={() => editor.zoomToUser(userId)} onDoubleClick={handleFollowClick} > diff --git a/apps/dotcom/src/hooks/useUrlState.ts b/apps/dotcom/src/hooks/useUrlState.ts index ed27fa12d..4d807a635 100644 --- a/apps/dotcom/src/hooks/useUrlState.ts +++ b/apps/dotcom/src/hooks/useUrlState.ts @@ -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) } diff --git a/apps/dotcom/src/utils/useFileSystem.tsx b/apps/dotcom/src/utils/useFileSystem.tsx index c328bb138..7caf2bbac 100644 --- a/apps/dotcom/src/utils/useFileSystem.tsx +++ b/apps/dotcom/src/utils/useFileSystem.tsx @@ -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 }) }) }, diff --git a/apps/examples/src/examples/after-create-update-shape/AfterCreateUpdateShapeExample.tsx b/apps/examples/src/examples/after-create-update-shape/AfterCreateUpdateShapeExample.tsx index f5414a755..d292b6428 100644 --- a/apps/examples/src/examples/after-create-update-shape/AfterCreateUpdateShapeExample.tsx +++ b/apps/examples/src/examples/after-create-update-shape/AfterCreateUpdateShapeExample.tsx @@ -68,5 +68,5 @@ function createDemoShapes(editor: Editor) { }, })) ) - .zoomToContent({ duration: 0 }) + .zoomToFit({ animation: { duration: 0 } }) } diff --git a/apps/examples/src/examples/after-delete-shape/AfterDeleteShapeExample.tsx b/apps/examples/src/examples/after-delete-shape/AfterDeleteShapeExample.tsx index 267c7b116..8a0293ea3 100644 --- a/apps/examples/src/examples/after-delete-shape/AfterDeleteShapeExample.tsx +++ b/apps/examples/src/examples/after-delete-shape/AfterDeleteShapeExample.tsx @@ -58,5 +58,5 @@ function createDemoShapes(editor: Editor) { })), ]) - editor.zoomToContent({ duration: 0 }) + editor.zoomToFit({ animation: { duration: 0 } }) } diff --git a/apps/examples/src/examples/before-create-update-shape/BeforeCreateUpdateShapeExample.tsx b/apps/examples/src/examples/before-create-update-shape/BeforeCreateUpdateShapeExample.tsx index 7f4b15370..11c8b019e 100644 --- a/apps/examples/src/examples/before-create-update-shape/BeforeCreateUpdateShapeExample.tsx +++ b/apps/examples/src/examples/before-create-update-shape/BeforeCreateUpdateShapeExample.tsx @@ -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 diff --git a/apps/examples/src/examples/before-delete-shape/BeforeDeleteShapeExample.tsx b/apps/examples/src/examples/before-delete-shape/BeforeDeleteShapeExample.tsx index a4ab4db37..520efb0c9 100644 --- a/apps/examples/src/examples/before-delete-shape/BeforeDeleteShapeExample.tsx +++ b/apps/examples/src/examples/before-delete-shape/BeforeDeleteShapeExample.tsx @@ -44,5 +44,5 @@ function createDemoShapes(editor: Editor) { }, }, ]) - .zoomToContent({ duration: 0 }) + .zoomToFit({ animation: { duration: 0 } }) } diff --git a/apps/examples/src/examples/camera-options/CameraOptionsExample.tsx b/apps/examples/src/examples/camera-options/CameraOptionsExample.tsx new file mode 100644 index 000000000..91ab37139 --- /dev/null +++ b/apps/examples/src/examples/camera-options/CameraOptionsExample.tsx @@ -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 = { + 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 ( +
+ + + +
+ ) +} + +const PaddingDisplay = track(() => { + const editor = useEditor() + const cameraOptions = editor.getCameraOptions() + + if (!cameraOptions.constraints) return null + + const { + constraints: { + padding: { x: px, y: py }, + }, + } = cameraOptions + + return ( +
+ ) +}) + +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 ( + <> +
+
+
+ + ) +}) + +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 & { + constraints: Partial + } + > + ) => { + const { constraints } = options + const cameraOptions = editor.getCameraOptions() + setCameraOptions({ + ...cameraOptions, + ...options, + constraints: + constraints === undefined + ? cameraOptions.constraints + : { + ...(cameraOptions.constraints! ?? CAMERA_OPTIONS.constraints), + ...constraints, + }, + }) + } + + return ( +
+
+ + + + + + { + const val = clamp(Number(e.target.value), 0, 2) + updateOptions({ panSpeed: val }) + }} + /> + + { + const val = clamp(Number(e.target.value), 0, 2) + updateOptions({ zoomSpeed: val }) + }} + /> + + { + 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 + } + }} + /> + + + {constraints ? ( + <> + + + + + + { + const val = clamp(Number(e.target.value), 0, 1) + updateOptions({ + constraints: { + origin: { + ...constraints.origin, + x: val, + }, + }, + }) + }} + /> + + { + const val = clamp(Number(e.target.value), 0, 1) + updateOptions({ + constraints: { + ...constraints, + origin: { + ...constraints.origin, + y: val, + }, + }, + }) + }} + /> + + { + const val = clamp(Number(e.target.value), 0) + updateOptions({ + constraints: { + ...constraints, + padding: { + ...constraints.padding, + x: val, + }, + }, + }) + }} + /> + + { + const val = clamp(Number(e.target.value), 0) + updateOptions({ + constraints: { + padding: { + ...constraints.padding, + y: val, + }, + }, + }) + }} + /> + + + + + + ) : null} +
+
+ + +
+
+ ) +}) diff --git a/apps/examples/src/examples/camera-options/README.md b/apps/examples/src/examples/camera-options/README.md new file mode 100644 index 000000000..08c7b5364 --- /dev/null +++ b/apps/examples/src/examples/camera-options/README.md @@ -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. diff --git a/apps/examples/src/examples/editable-shape/EditableShapeUtil.tsx b/apps/examples/src/examples/editable-shape/EditableShapeUtil.tsx index 8e1f85652..a0e13d7f1 100644 --- a/apps/examples/src/examples/editable-shape/EditableShapeUtil.tsx +++ b/apps/examples/src/examples/editable-shape/EditableShapeUtil.tsx @@ -90,7 +90,7 @@ export class EditableShapeUtil extends BaseBoxShapeUtil { override onEditEnd: TLOnEditEndHandler = (shape) => { this.editor.animateShape( { ...shape, rotation: shape.rotation + Math.PI * 2 }, - { duration: 250 } + { animation: { duration: 250 } } ) } } diff --git a/apps/examples/src/examples/external-content-sources/ExternalContentSourcesExample.tsx b/apps/examples/src/examples/external-content-sources/ExternalContentSourcesExample.tsx index 5efc16160..619b79cd7 100644 --- a/apps/examples/src/examples/external-content-sources/ExternalContentSourcesExample.tsx +++ b/apps/examples/src/examples/external-content-sources/ExternalContentSourcesExample.tsx @@ -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', diff --git a/apps/examples/src/examples/image-annotator/ImageAnnotationEditor.tsx b/apps/examples/src/examples/image-annotator/ImageAnnotationEditor.tsx index 5c2ebb941..774e63474 100644 --- a/apps/examples/src/examples/image-annotator/ImageAnnotationEditor.tsx +++ b/apps/examples/src/examples/image-annotator/ImageAnnotationEditor.tsx @@ -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(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({ - 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 ( }, [imageShapeId, onDone]), }} - > - {imageShapeId && } - {imageShapeId && } - {imageShapeId && } - + /> ) } @@ -172,165 +246,3 @@ function DoneButton({ ) } - -/** - * We want to keep our locked image at the bottom of the current page - people shouldn't be able to - * place other shapes beneath it. This component adds side effects for when shapes are created or - * updated to make sure that this shape is always kept at the bottom. - */ -function KeepShapeAtBottomOfCurrentPage({ shapeId }: { shapeId: TLShapeId }) { - const editor = useEditor() - - useEffect(() => { - function makeSureShapeIsAtBottom() { - let shape = editor.getShape(shapeId) - if (!shape) return - const pageId = editor.getCurrentPageId() - - if (shape.parentId !== pageId) { - editor.moveShapesToPage([shape], pageId) - shape = editor.getShape(shapeId)! - } - - const siblings = editor.getSortedChildIdsForParent(pageId) - const currentBottomShape = editor.getShape(siblings[0])! - if (currentBottomShape.id === shapeId) return - - editor.updateShape({ - id: shape.id, - type: shape.type, - isLocked: shape.isLocked, - index: getIndexBelow(currentBottomShape.index), - }) - } - - makeSureShapeIsAtBottom() - - const removeOnCreate = editor.sideEffects.registerAfterCreateHandler( - 'shape', - makeSureShapeIsAtBottom - ) - const removeOnChange = editor.sideEffects.registerAfterChangeHandler( - 'shape', - makeSureShapeIsAtBottom - ) - - return () => { - removeOnCreate() - removeOnChange() - } - }, [editor, shapeId]) - - return null -} - -function KeepShapeLocked({ shapeId }: { shapeId: TLShapeId }) { - const editor = useEditor() - - useEffect(() => { - const shape = editor.getShape(shapeId) - if (!shape) return - - editor.updateShape({ - id: shape.id, - type: shape.type, - isLocked: true, - }) - - const removeOnChange = editor.sideEffects.registerBeforeChangeHandler('shape', (prev, next) => { - if (next.id !== shapeId) return next - if (next.isLocked) return next - return { ...prev, isLocked: true } - }) - - return () => { - removeOnChange() - } - }, [editor, shapeId]) - - return null -} - -/** - * We don't want the user to be able to scroll away from the image, or zoom it all the way out. This - * component hooks into camera updates to keep the camera constrained - try uploading a very long, - * thin image and seeing how the camera behaves. - */ -function ConstrainCamera({ shapeId }: { shapeId: TLShapeId }) { - const editor = useEditor() - const breakpoint = useBreakpoint() - const isMobile = breakpoint < PORTRAIT_BREAKPOINT.TABLET_SM - - useEffect(() => { - const marginTop = 44 - const marginSide = isMobile ? 16 : 164 - const marginBottom = 60 - - function constrainCamera(camera: { x: number; y: number; z: number }): { - x: number - y: number - z: number - } { - const viewportBounds = editor.getViewportScreenBounds() - const targetBounds = editor.getShapePageBounds(shapeId)! - - const usableViewport = new Box( - marginSide, - marginTop, - viewportBounds.w - marginSide * 2, - viewportBounds.h - marginTop - marginBottom - ) - - const minZoom = Math.min( - usableViewport.w / targetBounds.w, - usableViewport.h / targetBounds.h, - 1 - ) - const zoom = Math.max(minZoom, camera.z) - - const centerX = targetBounds.x - targetBounds.w / 2 + usableViewport.midX / zoom - const centerY = targetBounds.y - targetBounds.h / 2 + usableViewport.midY / zoom - - const availableXMovement = Math.max(0, targetBounds.w - usableViewport.w / zoom) - const availableYMovement = Math.max(0, targetBounds.h - usableViewport.h / zoom) - - return { - x: clamp(camera.x, centerX - availableXMovement / 2, centerX + availableXMovement / 2), - y: clamp(camera.y, centerY - availableYMovement / 2, centerY + availableYMovement / 2), - z: zoom, - } - } - - const removeOnChange = editor.sideEffects.registerBeforeChangeHandler( - 'camera', - (_prev, next) => { - const constrained = constrainCamera(next) - if (constrained.x === next.x && constrained.y === next.y && constrained.z === next.z) - return next - return { ...next, ...constrained } - } - ) - - const removeReaction = react('update camera when viewport/shape changes', () => { - const original = editor.getCamera() - const constrained = constrainCamera(original) - if ( - original.x === constrained.x && - original.y === constrained.y && - original.z === constrained.z - ) { - return - } - - // this needs to be in a microtask for some reason, but idk why - queueMicrotask(() => editor.setCamera(constrained)) - }) - - return () => { - removeOnChange() - removeReaction() - } - }, [editor, isMobile, shapeId]) - - return null -} diff --git a/apps/examples/src/examples/pdf-editor/PdfEditor.tsx b/apps/examples/src/examples/pdf-editor/PdfEditor.tsx index 99086838e..c30a465c6 100644 --- a/apps/examples/src/examples/pdf-editor/PdfEditor.tsx +++ b/apps/examples/src/examples/pdf-editor/PdfEditor.tsx @@ -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( + () => ({ + PageMenu: null, + InFrontOfTheCanvas: () => , + SharePanel: () => , + }), + [pdf] + ) + return ( { 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 => ({ @@ -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 - }, [pdf]), - SharePanel: useCallback(() => { - return - }, [pdf]), - }} - > - - - - + 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 -} diff --git a/apps/examples/src/examples/popup-shape/PopupShapeExample.tsx b/apps/examples/src/examples/popup-shape/PopupShapeExample.tsx index b05fd8615..fb3dfa218 100644 --- a/apps/examples/src/examples/popup-shape/PopupShapeExample.tsx +++ b/apps/examples/src/examples/popup-shape/PopupShapeExample.tsx @@ -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 } }) }} />
diff --git a/apps/examples/src/examples/rendering-shape-changes/README.md b/apps/examples/src/examples/rendering-shape-changes/README.md deleted file mode 100644 index a6f95b86d..000000000 --- a/apps/examples/src/examples/rendering-shape-changes/README.md +++ /dev/null @@ -1,9 +0,0 @@ ---- -title: Rendering shapes change -component: ./RenderingShapesChangeExample.tsx -category: basic ---- - ---- - -Do something when the rendering shapes change. diff --git a/apps/examples/src/examples/rendering-shape-changes/RenderingShapesChangeExample.tsx b/apps/examples/src/examples/rendering-shape-changes/RenderingShapesChangeExample.tsx deleted file mode 100644 index 25160d531..000000000 --- a/apps/examples/src/examples/rendering-shape-changes/RenderingShapesChangeExample.tsx +++ /dev/null @@ -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 ( -
- -
- ) -} diff --git a/apps/examples/src/examples/rendering-shape-changes/useRenderingShapesChange.ts b/apps/examples/src/examples/rendering-shape-changes/useRenderingShapesChange.ts deleted file mode 100644 index 4b4ac137f..000000000 --- a/apps/examples/src/examples/rendering-shape-changes/useRenderingShapesChange.ts +++ /dev/null @@ -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]) -} diff --git a/apps/examples/src/examples/slides/useSlides.tsx b/apps/examples/src/examples/slides/useSlides.tsx index 9f135c2ea..4f3d79562 100644 --- a/apps/examples/src/examples/slides/useSlides.tsx +++ b/apps/examples/src/examples/slides/useSlides.tsx @@ -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() { diff --git a/apps/examples/src/examples/ui-events/codeSnippets.ts b/apps/examples/src/examples/ui-events/codeSnippets.ts index 340b7722b..cb01b42a8 100644 --- a/apps/examples/src/examples/ui-events/codeSnippets.ts +++ b/apps/examples/src/examples/ui-events/codeSnippets.ts @@ -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())` diff --git a/assets/translations/sl.json b/assets/translations/sl.json index b3319b0af..c52c21e76 100644 --- a/assets/translations/sl.json +++ b/assets/translations/sl.json @@ -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" -} \ No newline at end of file + "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" +} diff --git a/packages/editor/api-report.md b/packages/editor/api-report.md index a11a681c1..58e6d8ce6 100644 --- a/packages/editor/api-report.md +++ b/packages/editor/api-report.md @@ -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 { - 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; @@ -611,7 +625,7 @@ export class Editor extends EventEmitter { 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 { getAsset(asset: TLAsset | TLAssetId): TLAsset | undefined; getAssetForExternalContent(info: TLExternalAssetContent): Promise; 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 { 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 { 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 { util: ShapeUtil; }[]; getViewportPageBounds(): Box; - getViewportPageCenter(): Vec; getViewportScreenBounds(): Box; getViewportScreenCenter(): Vec; getZoomLevel(): number; @@ -853,18 +868,8 @@ export class Editor extends EventEmitter { 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 { 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, 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 { updateDocumentSettings(settings: Partial): this; updateInstanceState(partial: Partial>, historyOptions?: TLHistoryBatchOptions): this; updatePage(partial: RequiredKeys): this; - // @internal - updateRenderingBounds(): this; updateShape(partial: null | TLShapePartial | undefined): this; updateShapes(partials: (null | TLShapePartial | 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; - // @public (undocumented) export class HistoryManager { 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 = (record: R, source: 'remo // @public (undocumented) export type TLAfterDeleteHandler = (record: R, source: 'remote' | 'user') => void; -// @public (undocumented) -export type TLAnimationOptions = Partial<{ - duration: number; - easing: (t: number) => number; -}>; - // @public (undocumented) export type TLAnyShapeUtilConstructor = TLShapeUtilConstructor; @@ -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; // @public export interface TldrawEditorBaseProps { autoFocus?: boolean; + cameraOptions?: Partial; children?: ReactNode; className?: string; components?: TLEditorComponents; @@ -2171,6 +2187,7 @@ export type TLEditorComponents = Partial<{ // @public (undocumented) export interface TLEditorOptions { + cameraOptions?: Partial; getContainer: () => HTMLElement; inferDarkMode?: boolean; initialState?: string; @@ -3069,9 +3086,6 @@ export class WeakMapCache { export { whyAmIRunning } -// @internal (undocumented) -export const ZOOMS: number[]; - export * from "@tldraw/store"; export * from "@tldraw/tlschema"; diff --git a/packages/editor/src/index.ts b/packages/editor/src/index.ts index d89708f3e..e6090e951 100644 --- a/packages/editor/src/index.ts +++ b/packages/editor/src/index.ts @@ -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' diff --git a/packages/editor/src/lib/TldrawEditor.tsx b/packages/editor/src/lib/TldrawEditor.tsx index 87ea9b1fe..94a3d587a 100644 --- a/packages/editor/src/lib/TldrawEditor.tsx +++ b/packages/editor/src/lib/TldrawEditor.tsx @@ -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 } /** @@ -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( diff --git a/packages/editor/src/lib/constants.ts b/packages/editor/src/lib/constants.ts index d27456340..7b68334dd 100644 --- a/packages/editor/src/lib/constants.ts +++ b/packages/editor/src/lib/constants.ts @@ -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 = {} - -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 diff --git a/packages/editor/src/lib/editor/Editor.ts b/packages/editor/src/lib/editor/Editor.ts index 340732734..5f7271c2f 100644 --- a/packages/editor/src/lib/editor/Editor.ts +++ b/packages/editor/src/lib/editor/Editor.ts @@ -45,6 +45,7 @@ import { assert, compact, dedupe, + exhaustiveSwitchError, getIndexAbove, getIndexBetween, getIndices, @@ -52,6 +53,7 @@ import { getIndicesBetween, getOwnProperty, hasOwnProperty, + last, sortById, sortByIndex, structuredClone, @@ -68,6 +70,7 @@ import { COARSE_DRAG_DISTANCE, COLLABORATOR_IDLE_TIMEOUT, DEFAULT_ANIMATION_OPTIONS, + DEFAULT_CAMERA_OPTIONS, DRAG_DISTANCE, FOLLOW_CHASE_PAN_SNAP, FOLLOW_CHASE_PAN_UNSNAP, @@ -76,14 +79,15 @@ import { FOLLOW_CHASE_ZOOM_UNSNAP, HIT_TEST_MARGIN, INTERNAL_POINTER_IDS, + LEFT_MOUSE_BUTTON, LONG_PRESS_DURATION, MAX_PAGES, MAX_SHAPES_PER_PAGE, - MAX_ZOOM, - MIN_ZOOM, - ZOOMS, + MIDDLE_MOUSE_BUTTON, + RIGHT_MOUSE_BUTTON, + STYLUS_ERASER_BUTTON, } from '../constants' -import { Box } from '../primitives/Box' +import { Box, BoxLike } from '../primitives/Box' import { Mat, MatLike, MatModel } from '../primitives/Mat' import { Vec, VecLike } from '../primitives/Vec' import { EASINGS } from '../primitives/easings' @@ -129,15 +133,15 @@ import { } from './types/event-types' import { TLExternalAssetContent, TLExternalContent } from './types/external-content' import { TLHistoryBatchOptions } from './types/history-types' -import { OptionalKeys, RequiredKeys, TLSvgOptions } from './types/misc-types' +import { + OptionalKeys, + RequiredKeys, + TLCameraMoveOptions, + TLCameraOptions, + TLSvgOptions, +} from './types/misc-types' import { TLResizeHandle } from './types/selection-types' -/** @public */ -export type TLAnimationOptions = Partial<{ - duration: number - easing: (t: number) => number -}> - /** @public */ export type TLResizeShapeOptions = Partial<{ initialBounds: Box @@ -182,6 +186,10 @@ export interface TLEditorOptions { * Whether to infer dark mode from the user's system preferences. Defaults to false. */ inferDarkMode?: boolean + /** + * Options for the editor's camera. + */ + cameraOptions?: Partial } /** @public */ @@ -192,6 +200,7 @@ export class Editor extends EventEmitter { shapeUtils, tools, getContainer, + cameraOptions, initialState, inferDarkMode, }: TLEditorOptions) { @@ -208,6 +217,8 @@ export class Editor extends EventEmitter { this.snaps = new SnapManager(this) + this._cameraOptions.set({ ...DEFAULT_CAMERA_OPTIONS, ...cameraOptions }) + this.user = new UserPreferencesManager(user ?? createTLUser(), inferDarkMode ?? false) this.getContainer = getContainer ?? (() => document.body) @@ -660,8 +671,6 @@ export class Editor extends EventEmitter { this.stopFollowingUser() } - this.updateRenderingBounds() - this.on('tick', this._flushEventsForTick) requestAnimationFrame(() => { @@ -728,6 +737,13 @@ export class Editor extends EventEmitter { */ readonly scribbles: ScribbleManager + /** + * A manager for side effects and correct state enforcement. See {@link SideEffectManager} for details. + * + * @public + */ + readonly sideEffects: SideEffectManager + /** * The current HTML element containing the editor. * @@ -740,13 +756,6 @@ export class Editor extends EventEmitter { */ getContainer: () => HTMLElement - /** - * A manager for side effects and correct state enforcement. See {@link SideEffectManager} for details. - * - * @public - */ - readonly sideEffects: SideEffectManager - /** * Dispose the editor. * @@ -2023,16 +2032,308 @@ export class Editor extends EventEmitter { return this.getCamera().z } + /** + * Get the camera's initial or reset zoom level. + * + * @example + * ```ts + * editor.getInitialZoom() + * ``` + * + * @public */ + getInitialZoom() { + const cameraOptions = this.getCameraOptions() + // If no camera constraints are provided, the default zoom is 100% + if (!cameraOptions.constraints) return 1 + + // When defaultZoom is default, the default zoom is 100% + if (cameraOptions.constraints.initialZoom === 'default') return 1 + + const { zx, zy } = getCameraFitXFitY(this, cameraOptions) + + switch (cameraOptions.constraints.initialZoom) { + case 'fit-min': { + return Math.max(zx, zy) + } + case 'fit-max': { + return Math.min(zx, zy) + } + case 'fit-x': { + return zx + } + case 'fit-y': { + return zy + } + case 'fit-min-100': { + return Math.min(1, Math.max(zx, zy)) + } + case 'fit-max-100': { + return Math.min(1, Math.min(zx, zy)) + } + case 'fit-x-100': { + return Math.min(1, zx) + } + case 'fit-y-100': { + return Math.min(1, zy) + } + default: { + throw exhaustiveSwitchError(cameraOptions.constraints.initialZoom) + } + } + } + + /** + * Get the camera's base level for calculating actual zoom levels based on the zoom steps. + * + * @example + * ```ts + * editor.getBaseZoom() + * ``` + * + * @public */ + getBaseZoom() { + const cameraOptions = this.getCameraOptions() + // If no camera constraints are provided, the default zoom is 100% + if (!cameraOptions.constraints) return 1 + + // When defaultZoom is default, the default zoom is 100% + if (cameraOptions.constraints.baseZoom === 'default') return 1 + + const { zx, zy } = getCameraFitXFitY(this, cameraOptions) + + switch (cameraOptions.constraints.baseZoom) { + case 'fit-min': { + return Math.max(zx, zy) + } + case 'fit-max': { + return Math.min(zx, zy) + } + case 'fit-x': { + return zx + } + case 'fit-y': { + return zy + } + case 'fit-min-100': { + return Math.min(1, Math.max(zx, zy)) + } + case 'fit-max-100': { + return Math.min(1, Math.min(zx, zy)) + } + case 'fit-x-100': { + return Math.min(1, zx) + } + case 'fit-y-100': { + return Math.min(1, zy) + } + default: { + throw exhaustiveSwitchError(cameraOptions.constraints.baseZoom) + } + } + } + + private _cameraOptions = atom('camera options', DEFAULT_CAMERA_OPTIONS) + + /** + * Get the current camera options. + * + * @example + * ```ts + * editor.getCameraOptions() + * ``` + * + * @public */ + getCameraOptions() { + return this._cameraOptions.get() + } + + /** + * Set the camera options. + * + * @example + * ```ts + * editor.setCameraOptions(myCameraOptions) + * editor.setCameraOptions(myCameraOptions, { immediate: true, force: true, initial: false }) + * ``` + * + * @param options - The camera options to set. + * @param opts - The options for the change. + * + * @public */ + setCameraOptions(options: Partial, opts?: TLCameraMoveOptions) { + const next = structuredClone({ + ...this.getCameraOptions(), + ...options, + }) + if (next.zoomSteps?.length < 1) next.zoomSteps = [1] + this._cameraOptions.set(next) + this.setCamera(this.getCamera(), opts) + return this + } + /** @internal */ - private _setCamera(point: VecLike, immediate = false): this { + private _setCamera(point: VecLike, opts?: TLCameraMoveOptions): this { const currentCamera = this.getCamera() - if (currentCamera.x === point.x && currentCamera.y === point.y && currentCamera.z === point.z) { + let { x, y, z = currentCamera.z } = point + + // If force is true, then we'll set the camera to the point regardless of + // the camera options, so that we can handle gestures that permit elasticity + // or decay, or animations that occur while the camera is locked. + if (!opts?.force) { + // Apply any adjustments based on the camera options + + const cameraOptions = this.getCameraOptions() + + const zoomMin = cameraOptions.zoomSteps[0] + const zoomMax = last(cameraOptions.zoomSteps)! + + const vsb = this.getViewportScreenBounds() + + // If bounds are provided, then we'll keep those bounds on screen + if (cameraOptions.constraints) { + const { constraints } = cameraOptions + + // Clamp padding to half the viewport size on either dimension + const py = Math.min(constraints.padding.y, vsb.w / 2) + const px = Math.min(constraints.padding.x, vsb.h / 2) + + // Expand the bounds by the padding + const bounds = Box.From(cameraOptions.constraints.bounds) + + // For each axis, the "natural zoom" is the zoom at + // which the expanded bounds (with padding) would fit + // the current viewport screen bounds. Paddings are + // equal to screen pixels at 100% + // The min and max zooms are factors of the smaller natural zoom axis + + const zx = (vsb.w - px * 2) / bounds.w + const zy = (vsb.h - py * 2) / bounds.h + + const baseZoom = this.getBaseZoom() + const maxZ = zoomMax * baseZoom + const minZ = zoomMin * baseZoom + + if (opts?.reset) { + z = this.getInitialZoom() + } + + if (z < minZ || z > maxZ) { + // We're trying to zoom out past the minimum zoom level, + // or in past the maximum zoom level, so stop the camera + // but keep the current center + const { x: cx, y: cy, z: cz } = currentCamera + const cxA = -cx + vsb.w / cz / 2 + const cyA = -cy + vsb.h / cz / 2 + z = clamp(z, minZ, maxZ) + const cxB = -cx + vsb.w / z / 2 + const cyB = -cy + vsb.h / z / 2 + x = cx + cxB - cxA + y = cy + cyB - cyA + } + + // Calculate available space + const minX = px / z - bounds.x + const minY = py / z - bounds.y + const freeW = (vsb.w - px * 2) / z - bounds.w + const freeH = (vsb.h - py * 2) / z - bounds.h + const originX = minX + freeW * constraints.origin.x + const originY = minY + freeH * constraints.origin.y + + const behaviorX = + typeof constraints.behavior === 'string' ? constraints.behavior : constraints.behavior.x + const behaviorY = + typeof constraints.behavior === 'string' ? constraints.behavior : constraints.behavior.y + + // x axis + + if (opts?.reset) { + // Reset the camera according to the origin + x = originX + y = originY + } else { + // Apply constraints to the camera + switch (behaviorX) { + case 'fixed': { + // Center according to the origin + x = originX + break + } + case 'contain': { + // When below fit zoom, center the camera + if (z < zx) x = originX + // When above fit zoom, keep the bounds within padding distance of the viewport edge + else x = clamp(x, minX + freeW, minX) + break + } + case 'inside': { + // When below fit zoom, constrain the camera so that the bounds stay completely within the viewport + if (z < zx) x = clamp(x, minX, (vsb.w - px) / z - bounds.w) + // When above fit zoom, keep the bounds within padding distance of the viewport edge + else x = clamp(x, minX + freeW, minX) + break + } + case 'outside': { + // Constrain the camera so that the bounds never leaves the viewport + x = clamp(x, px / z - bounds.w, (vsb.w - px) / z) + break + } + case 'free': { + // noop, use whatever x is provided + break + } + default: { + throw exhaustiveSwitchError(behaviorX) + } + } + + // y axis + + switch (behaviorY) { + case 'fixed': { + y = originY + break + } + case 'contain': { + if (z < zy) y = originY + else y = clamp(y, minY + freeH, minY) + break + } + case 'inside': { + if (z < zy) y = clamp(y, minY, (vsb.h - py) / z - bounds.h) + else y = clamp(y, minY + freeH, minY) + break + } + case 'outside': { + y = clamp(y, py / z - bounds.h, (vsb.h - py) / z) + break + } + case 'free': { + // noop, use whatever x is provided + break + } + default: { + throw exhaustiveSwitchError(behaviorY) + } + } + } + } else { + // constrain the zoom, preserving the center + if (z > zoomMax || z < zoomMin) { + const { x: cx, y: cy, z: cz } = currentCamera + z = clamp(z, zoomMin, zoomMax) + x = cx + (-cx + vsb.w / z / 2) - (-cx + vsb.w / cz / 2) + y = cy + (-cy + vsb.h / z / 2) - (-cy + vsb.h / cz / 2) + } + } + } + + if (currentCamera.x === x && currentCamera.y === y && currentCamera.z === z) { return this } this.batch(() => { - const camera = { ...currentCamera, ...point } + const camera = { ...currentCamera, x, y, z } this.history.ignore(() => { this.store.put([camera]) // include id and meta here }) @@ -2044,8 +2345,8 @@ export class Editor extends EventEmitter { // compare the next page point (derived from the curent camera) to the current page point if ( - currentScreenPoint.x / camera.z - camera.x !== currentPagePoint.x || - currentScreenPoint.y / camera.z - camera.y !== currentPagePoint.y + currentScreenPoint.x / z - x !== currentPagePoint.x || + currentScreenPoint.y / z - y !== currentPagePoint.y ) { // If it's changed, dispatch a pointer event const event: TLPointerEventInfo = { @@ -2061,7 +2362,8 @@ export class Editor extends EventEmitter { button: 0, isPen: this.getInstanceState().isPenMode ?? false, } - if (immediate) { + + if (opts?.immediate) { this._flushEventForTick(event) } else { this.dispatch(event) @@ -2081,18 +2383,17 @@ export class Editor extends EventEmitter { * ```ts * editor.setCamera({ x: 0, y: 0}) * editor.setCamera({ x: 0, y: 0, z: 1.5}) - * editor.setCamera({ x: 0, y: 0, z: 1.5}, { duration: 1000, easing: (t) => t * t }) + * editor.setCamera({ x: 0, y: 0, z: 1.5}, { animation: { duration: 1000, easing: (t) => t * t } }) * ``` * * @param point - The new camera position. - * @param animation - Options for an animation. + * @param opts - The camera move options. * * @public */ - setCamera(point: VecLike, animation?: TLAnimationOptions): this { - const x = Number.isFinite(point.x) ? point.x : 0 - const y = Number.isFinite(point.y) ? point.y : 0 - const z = Number.isFinite(point.z) ? point.z! : this.getZoomLevel() + setCamera(point: VecLike, opts?: TLCameraMoveOptions): this { + const { isLocked } = this._cameraOptions.__unsafe__getWithoutCapture() + if (isLocked && !opts?.force) return this // Stop any camera animations this.stopCameraAnimation() @@ -2102,11 +2403,20 @@ export class Editor extends EventEmitter { this.stopFollowingUser() } - if (animation) { + const _point = Vec.Cast(point) + + if (!Number.isFinite(_point.x)) _point.x = 0 + if (!Number.isFinite(_point.y)) _point.y = 0 + if (_point.z === undefined || !Number.isFinite(_point.z)) point.z = this.getZoomLevel() + + if (opts?.animation) { const { width, height } = this.getViewportScreenBounds() - return this._animateToViewport(new Box(-x, -y, width / z, height / z), animation) + this._animateToViewport( + new Box(-point.x, -point.y, width / _point.z, height / _point.z), + opts + ) } else { - this._setCamera({ x, y, z }) + this._setCamera(_point, opts) } return this @@ -2118,46 +2428,18 @@ export class Editor extends EventEmitter { * @example * ```ts * editor.centerOnPoint({ x: 100, y: 100 }) - * editor.centerOnPoint({ x: 100, y: 100 }, { duration: 200 }) + * editor.centerOnPoint({ x: 100, y: 100 }, { animation: { duration: 200 } }) * ``` * * @param point - The point in the current page space to center on. - * @param animation - The options for an animation. + * @param animation - The camera move options. * * @public */ - centerOnPoint(point: VecLike, animation?: TLAnimationOptions): this { - if (!this.getInstanceState().canMoveCamera) return this - + centerOnPoint(point: VecLike, opts?: TLCameraMoveOptions): this { + if (this.getCameraOptions().isLocked) return this const { width: pw, height: ph } = this.getViewportPageBounds() - - this.setCamera( - { x: -(point.x - pw / 2), y: -(point.y - ph / 2), z: this.getCamera().z }, - animation - ) - return this - } - - /** - * Move the camera to the nearest content. - * - * @example - * ```ts - * editor.zoomToContent() - * editor.zoomToContent({ duration: 200 }) - * ``` - * - * @param opts - The options for an animation. - * - * @public - */ - zoomToContent(opts: TLAnimationOptions = { duration: 220 }): this { - const bounds = this.getSelectionPageBounds() ?? this.getCurrentPageBounds() - - if (bounds) { - this.zoomToBounds(bounds, { targetZoom: Math.min(1, this.getZoomLevel()), ...opts }) - } - + this.setCamera(new Vec(-(point.x - pw / 2), -(point.y - ph / 2), this.getCamera().z), opts) return this } @@ -2167,21 +2449,18 @@ export class Editor extends EventEmitter { * @example * ```ts * editor.zoomToFit() - * editor.zoomToFit({ duration: 200 }) + * editor.zoomToFit({ animation: { duration: 200 } }) * ``` * - * @param animation - The options for an animation. + * @param opts - The camera move options. * * @public */ - zoomToFit(animation?: TLAnimationOptions): this { - if (!this.getInstanceState().canMoveCamera) return this - + zoomToFit(opts?: TLCameraMoveOptions): this { const ids = [...this.getCurrentPageShapeIds()] if (ids.length <= 0) return this - const pageBounds = Box.Common(compact(ids.map((id) => this.getShapePageBounds(id)))) - this.zoomToBounds(pageBounds, animation) + this.zoomToBounds(pageBounds, opts) return this } @@ -2191,25 +2470,38 @@ export class Editor extends EventEmitter { * @example * ```ts * editor.resetZoom() - * editor.resetZoom(editor.getViewportScreenCenter(), { duration: 200 }) - * editor.resetZoom(editor.getViewportScreenCenter(), { duration: 200 }) + * editor.resetZoom(editor.getViewportScreenCenter(), { animation: { duration: 200 } }) + * editor.resetZoom(editor.getViewportScreenCenter(), { animation: { duration: 200 } }) * ``` * * @param point - The screen point to zoom out on. Defaults to the viewport screen center. - * @param animation - The options for an animation. + * @param opts - The camera move options. * * @public */ - resetZoom(point = this.getViewportScreenCenter(), animation?: TLAnimationOptions): this { - if (!this.getInstanceState().canMoveCamera) return this + resetZoom(point = this.getViewportScreenCenter(), opts?: TLCameraMoveOptions): this { + const { isLocked, constraints: constraints } = this.getCameraOptions() + if (isLocked) return this - const { x: cx, y: cy, z: cz } = this.getCamera() + const currentCamera = this.getCamera() + const { x: cx, y: cy, z: cz } = currentCamera const { x, y } = point - this.setCamera( - { x: cx + (x / 1 - x) - (x / cz - x), y: cy + (y / 1 - y) - (y / cz - y), z: 1 }, - animation - ) + let z = 1 + + if (constraints) { + // For non-infinite fit, we'll set the camera to the natural zoom level... + // unless it's already there, in which case we'll set zoom to 100% + const initialZoom = this.getInitialZoom() + if (cz !== initialZoom) { + z = initialZoom + } + } + + this.setCamera( + new Vec(cx + (x / z - x) - (x / cz - x), cy + (y / z - y) - (y / cz - y), z), + opts + ) return this } @@ -2219,35 +2511,41 @@ export class Editor extends EventEmitter { * @example * ```ts * editor.zoomIn() - * editor.zoomIn(editor.getViewportScreenCenter(), { duration: 120 }) - * editor.zoomIn(editor.inputs.currentScreenPoint, { duration: 120 }) + * editor.zoomIn(editor.getViewportScreenCenter(), { animation: { duration: 200 } }) + * editor.zoomIn(editor.inputs.currentScreenPoint, { animation: { duration: 200 } }) * ``` * - * @param animation - The options for an animation. + * @param point - The screen point to zoom in on. Defaults to the screen center + * @param opts - The camera move options. * * @public */ - zoomIn(point = this.getViewportScreenCenter(), animation?: TLAnimationOptions): this { - if (!this.getInstanceState().canMoveCamera) return this + zoomIn(point = this.getViewportScreenCenter(), opts?: TLCameraMoveOptions): this { + if (this.getCameraOptions().isLocked) return this const { x: cx, y: cy, z: cz } = this.getCamera() - let zoom = MAX_ZOOM - - for (let i = 1; i < ZOOMS.length; i++) { - const z1 = ZOOMS[i - 1] - const z2 = ZOOMS[i] - if (z2 - cz <= (z2 - z1) / 2) continue - zoom = z2 - break + const { zoomSteps } = this.getCameraOptions() + if (zoomSteps !== null && zoomSteps.length > 1) { + const baseZoom = this.getBaseZoom() + let zoom = last(zoomSteps)! * baseZoom + for (let i = 1; i < zoomSteps.length; i++) { + const z1 = zoomSteps[i - 1] * baseZoom + const z2 = zoomSteps[i] * baseZoom + if (z2 - cz <= (z2 - z1) / 2) continue + zoom = z2 + break + } + this.setCamera( + new Vec( + cx + (point.x / zoom - point.x) - (point.x / cz - point.x), + cy + (point.y / zoom - point.y) - (point.y / cz - point.y), + zoom + ), + opts + ) } - const { x, y } = point - this.setCamera( - { x: cx + (x / zoom - x) - (x / cz - x), y: cy + (y / zoom - y) - (y / cz - y), z: zoom }, - animation - ) - return this } @@ -2257,40 +2555,41 @@ export class Editor extends EventEmitter { * @example * ```ts * editor.zoomOut() - * editor.zoomOut(editor.getViewportScreenCenter(), { duration: 120 }) - * editor.zoomOut(editor.inputs.currentScreenPoint, { duration: 120 }) + * editor.zoomOut(editor.getViewportScreenCenter(), { animation: { duration: 120 } }) + * editor.zoomOut(editor.inputs.currentScreenPoint, { animation: { duration: 120 } }) * ``` * - * @param animation - The options for an animation. + * @param point - The point to zoom out on. Defaults to the viewport screen center. + * @param opts - The camera move options. * * @public */ - zoomOut(point = this.getViewportScreenCenter(), animation?: TLAnimationOptions): this { - if (!this.getInstanceState().canMoveCamera) return this + zoomOut(point = this.getViewportScreenCenter(), opts?: TLCameraMoveOptions): this { + if (this.getCameraOptions().isLocked) return this - const { x: cx, y: cy, z: cz } = this.getCamera() - - let zoom = MIN_ZOOM - - for (let i = ZOOMS.length - 1; i > 0; i--) { - const z1 = ZOOMS[i - 1] - const z2 = ZOOMS[i] - if (z2 - cz >= (z2 - z1) / 2) continue - zoom = z1 - break + const { zoomSteps } = this.getCameraOptions() + if (zoomSteps !== null && zoomSteps.length > 1) { + const baseZoom = this.getBaseZoom() + const { x: cx, y: cy, z: cz } = this.getCamera() + // start at the max + let zoom = zoomSteps[0] * baseZoom + for (let i = zoomSteps.length - 1; i > 0; i--) { + const z1 = zoomSteps[i - 1] * baseZoom + const z2 = zoomSteps[i] * baseZoom + if (z2 - cz >= (z2 - z1) / 2) continue + zoom = z1 + break + } + this.setCamera( + new Vec( + cx + (point.x / zoom - point.x) - (point.x / cz - point.x), + cy + (point.y / zoom - point.y) - (point.y / cz - point.y), + zoom + ), + opts + ) } - const { x, y } = point - - this.setCamera( - { - x: cx + (x / zoom - x) - (x / cz - x), - y: cy + (y / zoom - y) - (y / cz - y), - z: zoom, - }, - animation - ) - return this } @@ -2300,77 +2599,22 @@ export class Editor extends EventEmitter { * @example * ```ts * editor.zoomToSelection() + * editor.zoomToSelection({ animation: { duration: 200 } }) * ``` * - * @param animation - The options for an animation. + * @param animation - The camera move options. * * @public */ - zoomToSelection(animation?: TLAnimationOptions): this { - if (!this.getInstanceState().canMoveCamera) return this - + zoomToSelection(opts?: TLCameraMoveOptions): this { + if (this.getCameraOptions().isLocked) return this const selectionPageBounds = this.getSelectionPageBounds() - if (!selectionPageBounds) return this - - this.zoomToBounds(selectionPageBounds, { - targetZoom: Math.max(1, this.getZoomLevel()), - ...animation, - }) - - return this - } - - /** - * Pan or pan/zoom the selected ids into view. This method tries to not change the zoom if possible. - * - * @param ids - The ids of the shapes to pan and zoom into view. - * @param animation - The options for an animation. - * - * @public - */ - panZoomIntoView(ids: TLShapeId[], animation?: TLAnimationOptions): this { - if (!this.getInstanceState().canMoveCamera) return this - - if (ids.length <= 0) return this - const selectionBounds = Box.Common(compact(ids.map((id) => this.getShapePageBounds(id)))) - - const viewportPageBounds = this.getViewportPageBounds() - - if (viewportPageBounds.h < selectionBounds.h || viewportPageBounds.w < selectionBounds.w) { - this.zoomToBounds(selectionBounds, { targetZoom: this.getCamera().z, ...animation }) - - return this - } else { - const insetViewport = this.getViewportPageBounds() - .clone() - .expandBy(-32 / this.getZoomLevel()) - - let offsetX = 0 - let offsetY = 0 - if (insetViewport.maxY < selectionBounds.maxY) { - // off bottom - offsetY = insetViewport.maxY - selectionBounds.maxY - } else if (insetViewport.minY > selectionBounds.minY) { - // off top - offsetY = insetViewport.minY - selectionBounds.minY - } else { - // inside y-bounds - } - - if (insetViewport.maxX < selectionBounds.maxX) { - // off right - offsetX = insetViewport.maxX - selectionBounds.maxX - } else if (insetViewport.minX > selectionBounds.minX) { - // off left - offsetX = insetViewport.minX - selectionBounds.minX - } else { - // inside x-bounds - } - - const camera = this.getCamera() - this.setCamera({ x: camera.x + offsetX, y: camera.y + offsetY, z: camera.z }, animation) + if (selectionPageBounds) { + this.zoomToBounds(selectionPageBounds, { + targetZoom: Math.max(1, this.getZoomLevel()), + ...opts, + }) } - return this } @@ -2380,33 +2624,37 @@ export class Editor extends EventEmitter { * @example * ```ts * editor.zoomToBounds(myBounds) - * editor.zoomToBounds(myBounds) - * editor.zoomToBounds(myBounds, { duration: 100 }) - * editor.zoomToBounds(myBounds, { inset: 0, targetZoom: 1 }) + * editor.zoomToBounds(myBounds, { animation: { duration: 200 } }) + * editor.zoomToBounds(myBounds, { animation: { duration: 200 }, inset: 0, targetZoom: 1 }) * ``` * * @param bounds - The bounding box. - * @param options - The options for an animation, target zoom, or custom inset amount. + * @param opts - The camera move options, target zoom, or custom inset amount. * * @public */ zoomToBounds( - bounds: Box, - opts?: { targetZoom?: number; inset?: number } & TLAnimationOptions + bounds: BoxLike, + opts?: { targetZoom?: number; inset?: number } & TLCameraMoveOptions ): this { - if (!this.getInstanceState().canMoveCamera) return this + if (this.getCameraOptions().isLocked) return this const viewportScreenBounds = this.getViewportScreenBounds() const inset = opts?.inset ?? Math.min(256, viewportScreenBounds.width * 0.28) + const baseZoom = this.getBaseZoom() + const { zoomSteps } = this.getCameraOptions() + const zoomMin = zoomSteps[0] + const zoomMax = last(zoomSteps)! + let zoom = clamp( Math.min( - (viewportScreenBounds.width - inset) / bounds.width, - (viewportScreenBounds.height - inset) / bounds.height + (viewportScreenBounds.width - inset) / bounds.w, + (viewportScreenBounds.height - inset) / bounds.h ), - MIN_ZOOM, - MAX_ZOOM + zoomMin * baseZoom, + zoomMax * baseZoom ) if (opts?.targetZoom !== undefined) { @@ -2414,11 +2662,11 @@ export class Editor extends EventEmitter { } this.setCamera( - { - x: -bounds.minX + (viewportScreenBounds.width - bounds.width * zoom) / 2 / zoom, - y: -bounds.minY + (viewportScreenBounds.height - bounds.height * zoom) / 2 / zoom, - z: zoom, - }, + new Vec( + -bounds.x + (viewportScreenBounds.width - bounds.w * zoom) / 2 / zoom, + -bounds.y + (viewportScreenBounds.height - bounds.h * zoom) / 2 / zoom, + zoom + ), opts ) @@ -2426,28 +2674,13 @@ export class Editor extends EventEmitter { } /** - * Pan the camera. + * Stop the current camera animation, if any. * * @example * ```ts - * editor.pan({ x: 100, y: 100 }) - * editor.pan({ x: 100, y: 100 }, { duration: 1000 }) + * editor.stopCameraAnimation() * ``` * - * @param offset - The offset in the current page space. - * @param animation - The animation options. - */ - pan(offset: VecLike, animation?: TLAnimationOptions): this { - if (!this.getInstanceState().canMoveCamera) return this - const { x: cx, y: cy, z: cz } = this.getCamera() - this.setCamera({ x: cx + offset.x / cz, y: cy + offset.y / cz, z: cz }, animation) - this._flushEventsForTick(0) - return this - } - - /** - * Stop the current camera animation, if any. - * * @public */ stopCameraAnimation(): this { @@ -2465,24 +2698,23 @@ export class Editor extends EventEmitter { } /** @internal */ - private _animateViewport(ms: number) { + private _animateViewport(ms: number): void { if (!this._viewportAnimation) return - const cancel = () => { - this.removeListener('tick', this._animateViewport) - this.removeListener('stop-camera-animation', cancel) + const cancelAnimation = () => { + this.off('tick', this._animateViewport) + this.off('stop-camera-animation', cancelAnimation) this._viewportAnimation = null } - this.once('stop-camera-animation', cancel) + this.once('stop-camera-animation', cancelAnimation) this._viewportAnimation.elapsed += ms const { elapsed, easing, duration, start, end } = this._viewportAnimation if (elapsed > duration) { - this._setCamera({ x: -end.x, y: -end.y, z: this.getViewportScreenBounds().width / end.width }) - cancel() + this._setCamera(new Vec(-end.x, -end.y, this.getViewportScreenBounds().width / end.width)) return } @@ -2493,12 +2725,16 @@ export class Editor extends EventEmitter { const top = start.minY + (end.minY - start.minY) * t const right = start.maxX + (end.maxX - start.maxX) * t - this._setCamera({ x: -left, y: -top, z: this.getViewportScreenBounds().width / (right - left) }) + this._setCamera(new Vec(-left, -top, this.getViewportScreenBounds().width / (right - left))) } /** @internal */ - private _animateToViewport(targetViewportPage: Box, opts = {} as TLAnimationOptions) { - const { duration = 0, easing = EASINGS.easeInOutCubic } = opts + private _animateToViewport( + targetViewportPage: Box, + opts = { animation: DEFAULT_ANIMATION_OPTIONS } as TLCameraMoveOptions + ) { + if (!opts.animation) return + const { duration = 0, easing = EASINGS.easeInOutCubic } = opts.animation const animationSpeed = this.user.getAnimationSpeed() const viewportPageBounds = this.getViewportPageBounds() @@ -2512,11 +2748,13 @@ export class Editor extends EventEmitter { if (duration === 0 || animationSpeed === 0) { // If we have no animation, then skip the animation and just set the camera - return this._setCamera({ - x: -targetViewportPage.x, - y: -targetViewportPage.y, - z: this.getViewportScreenBounds().width / targetViewportPage.width, - }) + return this._setCamera( + new Vec( + -targetViewportPage.x, + -targetViewportPage.y, + this.getViewportScreenBounds().width / targetViewportPage.width + ) + ) } // Set our viewport animation @@ -2529,7 +2767,7 @@ export class Editor extends EventEmitter { } // On each tick, animate the viewport - this.addListener('tick', this._animateViewport) + this.on('tick', this._animateViewport) return this } @@ -2537,6 +2775,11 @@ export class Editor extends EventEmitter { /** * Slide the camera in a certain direction. * + * @example + * ```ts + * editor.slideCamera({ speed: 1, direction: { x: 1, y: 0 }, friction: 0.1 }) + * ``` + * * @param opts - Options for the slide * @public */ @@ -2548,20 +2791,19 @@ export class Editor extends EventEmitter { speedThreshold?: number } ): this { - if (!this.getInstanceState().canMoveCamera) return this - - this.stopCameraAnimation() + if (this.getCameraOptions().isLocked) return this const animationSpeed = this.user.getAnimationSpeed() - if (animationSpeed === 0) return this + this.stopCameraAnimation() + const { speed, friction, direction, speedThreshold = 0.01 } = opts let currentSpeed = Math.min(speed, 1) const cancel = () => { - this.removeListener('tick', moveCamera) - this.removeListener('stop-camera-animation', cancel) + this.off('tick', moveCamera) + this.off('stop-camera-animation', cancel) } this.once('stop-camera-animation', cancel) @@ -2575,23 +2817,29 @@ export class Editor extends EventEmitter { if (currentSpeed < speedThreshold) { cancel() } else { - this._setCamera({ x: cx + movementVec.x, y: cy + movementVec.y, z: cz }) + this._setCamera(new Vec(cx + movementVec.x, cy + movementVec.y, cz)) } } - this.addListener('tick', moveCamera) + this.on('tick', moveCamera) return this } /** - * Animate the camera to a user's cursor position. - * This also briefly show the user's cursor if it's not currently visible. + * Animate the camera to a user's cursor position. This also briefly show the user's cursor if it's not currently visible. + * + * @example + * ```ts + * editor.zoomToUser(myUserId) + * editor.zoomToUser(myUserId, { animation: { duration: 200 } }) + * ``` * * @param userId - The id of the user to aniamte to. + * @param opts - The camera move options. * @public */ - animateToUser(userId: string): this { + zoomToUser(userId: string, opts: TLCameraMoveOptions = { animation: { duration: 500 } }): this { const presence = this.getCollaborators().find((c) => c.userId === userId) if (!presence) return this @@ -2609,9 +2857,11 @@ export class Editor extends EventEmitter { } // Only animate the camera if the user is on the same page as us - const options = isOnSamePage ? { duration: 500 } : undefined + if (opts && opts.animation && !isOnSamePage) { + opts.animation = undefined + } - this.centerOnPoint(presence.cursor, options) + this.centerOnPoint(presence.cursor, opts) // Highlight the user's cursor const { highlightedUserIds } = this.getInstanceState() @@ -2630,47 +2880,10 @@ export class Editor extends EventEmitter { return this } - /** - * Animate the camera to a shape. - * - * @public - */ - animateToShape(shapeId: TLShapeId, opts: TLAnimationOptions = DEFAULT_ANIMATION_OPTIONS): this { - if (!this.getInstanceState().canMoveCamera) return this - - const activeArea = this.getViewportScreenBounds().clone().expandBy(-32) - const viewportAspectRatio = activeArea.width / activeArea.height - - const shapePageBounds = this.getShapePageBounds(shapeId) - - if (!shapePageBounds) return this - - const shapeAspectRatio = shapePageBounds.width / shapePageBounds.height - - const targetViewportPage = shapePageBounds.clone() - - const z = shapePageBounds.width / activeArea.width - targetViewportPage.width += (activeArea.minX + activeArea.maxX) * z - targetViewportPage.height += (activeArea.minY + activeArea.maxY) * z - targetViewportPage.x -= activeArea.minX * z - targetViewportPage.y -= activeArea.minY * z - - if (shapeAspectRatio > viewportAspectRatio) { - targetViewportPage.height = shapePageBounds.width / viewportAspectRatio - targetViewportPage.y -= (targetViewportPage.height - shapePageBounds.height) / 2 - } else { - targetViewportPage.width = shapePageBounds.height * viewportAspectRatio - targetViewportPage.x -= (targetViewportPage.width - shapePageBounds.width) / 2 - } - - return this._animateToViewport(targetViewportPage, opts) - } - // Viewport /** @internal */ private _willSetInitialBounds = true - private _wasInset = false /** * Update the viewport. The viewport will measure the size and screen position of its container @@ -2712,21 +2925,22 @@ export class Editor extends EventEmitter { // If we have just received the initial bounds, don't center the camera. this._willSetInitialBounds = false this.updateInstanceState({ screenBounds: screenBounds.toJson(), insets }) + this.setCamera(this.getCamera()) } else { if (center && !this.getInstanceState().followingUserId) { // Get the page center before the change, make the change, and restore it - const before = this.getViewportPageCenter() + const before = this.getViewportPageBounds().center this.updateInstanceState({ screenBounds: screenBounds.toJson(), insets }) this.centerOnPoint(before) } else { // Otherwise, this.updateInstanceState({ screenBounds: screenBounds.toJson(), insets }) + this._setCamera(Vec.From({ ...this.getCamera() })) } } } this._tickCameraState() - this.updateRenderingBounds() return this } @@ -2765,14 +2979,6 @@ export class Editor extends EventEmitter { return new Box(-cx, -cy, w / cz, h / cz) } - /** - * The center of the viewport in the current page space. - * - * @public - */ - @computed getViewportPageCenter() { - return this.getViewportPageBounds().center - } /** * Convert a point in screen space to a point in the current page space. * @@ -2788,11 +2994,11 @@ export class Editor extends EventEmitter { screenToPage(point: VecLike) { const { screenBounds } = this.store.unsafeGetWithoutCapture(TLINSTANCE_ID)! const { x: cx, y: cy, z: cz = 1 } = this.getCamera() - return { - x: (point.x - screenBounds.x) / cz - cx, - y: (point.y - screenBounds.y) / cz - cy, - z: point.z ?? 0.5, - } + return new Vec( + (point.x - screenBounds.x) / cz - cx, + (point.y - screenBounds.y) / cz - cy, + point.z ?? 0.5 + ) } /** @@ -2808,14 +3014,13 @@ export class Editor extends EventEmitter { * @public */ pageToScreen(point: VecLike) { - const screenBounds = this.getViewportScreenBounds() + const { screenBounds } = this.store.unsafeGetWithoutCapture(TLINSTANCE_ID)! const { x: cx, y: cy, z: cz = 1 } = this.getCamera() - - return { - x: (point.x + cx) * cz + screenBounds.x, - y: (point.y + cy) * cz + screenBounds.y, - z: point.z ?? 0.5, - } + return new Vec( + (point.x + cx) * cz + screenBounds.x, + (point.y + cy) * cz + screenBounds.y, + point.z ?? 0.5 + ) } /** @@ -2832,12 +3037,7 @@ export class Editor extends EventEmitter { */ pageToViewport(point: VecLike) { const { x: cx, y: cy, z: cz = 1 } = this.getCamera() - - return { - x: (point.x + cx) * cz, - y: (point.y + cy) * cz, - z: point.z ?? 0.5, - } + return new Vec((point.x + cx) * cz, (point.y + cy) * cz, point.z ?? 0.5) } // Collaborators @@ -2884,6 +3084,11 @@ export class Editor extends EventEmitter { /** * Start viewport-following a user. * + * @example + * ```ts + * editor.startFollowingUser(myUserId) + * ``` + * * @param userId - The id of the user to follow. * * @public @@ -2906,104 +3111,111 @@ export class Editor extends EventEmitter { transact(() => { this.stopFollowingUser() - this.updateInstanceState({ followingUserId: userId }) }) const cancel = () => { - this.removeListener('frame', moveTowardsUser) - this.removeListener('stop-following', cancel) + this.off('frame', moveTowardsUser) + this.off('stop-following', cancel) } let isCaughtUp = false const moveTowardsUser = () => { - // Stop following if we can't find the user - const leaderPresence = this._getCollaboratorsQuery() - .get() - .filter((p) => p.userId === userId) - .sort((a, b) => { - return a.lastActivityTimestamp - b.lastActivityTimestamp - }) - .pop() - if (!leaderPresence) { - this.stopFollowingUser() - return - } + transact(() => { + // Stop following if we can't find the user + const leaderPresence = this._getCollaboratorsQuery() + .get() + .filter((p) => p.userId === userId) + .sort((a, b) => { + return b.lastActivityTimestamp - a.lastActivityTimestamp + })[0] - // Change page if leader is on a different page - const isOnSamePage = leaderPresence.currentPageId === this.getCurrentPageId() - const chaseProportion = isOnSamePage ? FOLLOW_CHASE_PROPORTION : 1 - if (!isOnSamePage) { - this.stopFollowingUser() - this.setCurrentPage(leaderPresence.currentPageId) - this.startFollowingUser(userId) - return - } + if (!leaderPresence) { + this.stopFollowingUser() + return + } - // Get the bounds of the follower (me) and the leader (them) - const { center, width, height } = this.getViewportPageBounds() - const leaderScreen = Box.From(leaderPresence.screenBounds) - const leaderWidth = leaderScreen.width / leaderPresence.camera.z - const leaderHeight = leaderScreen.height / leaderPresence.camera.z - const leaderCenter = new Vec( - leaderWidth / 2 - leaderPresence.camera.x, - leaderHeight / 2 - leaderPresence.camera.y - ) + // Change page if leader is on a different page + const isOnSamePage = leaderPresence.currentPageId === this.getCurrentPageId() + const chaseProportion = isOnSamePage ? FOLLOW_CHASE_PROPORTION : 1 + if (!isOnSamePage) { + this.stopFollowingUser() + this.setCurrentPage(leaderPresence.currentPageId) + this.startFollowingUser(userId) + return + } - // At this point, let's check if we're following someone who's following us. - // If so, we can't try to contain their entire viewport - // because that would become a feedback loop where we zoom, they zoom, etc. - const isFollowingFollower = leaderPresence.followingUserId === thisUserId + // Get the bounds of the follower (me) and the leader (them) + const { center, width, height } = this.getViewportPageBounds() + const leaderScreen = Box.From(leaderPresence.screenBounds) + const leaderWidth = leaderScreen.width / leaderPresence.camera.z + const leaderHeight = leaderScreen.height / leaderPresence.camera.z + const leaderCenter = new Vec( + leaderWidth / 2 - leaderPresence.camera.x, + leaderHeight / 2 - leaderPresence.camera.y + ) - // Figure out how much to zoom - const desiredWidth = width + (leaderWidth - width) * chaseProportion - const desiredHeight = height + (leaderHeight - height) * chaseProportion - const ratio = !isFollowingFollower - ? Math.min(width / desiredWidth, height / desiredHeight) - : height / desiredHeight + // At this point, let's check if we're following someone who's following us. + // If so, we can't try to contain their entire viewport + // because that would become a feedback loop where we zoom, they zoom, etc. + const isFollowingFollower = leaderPresence.followingUserId === thisUserId - const targetZoom = clamp(this.getCamera().z * ratio, MIN_ZOOM, MAX_ZOOM) - const targetWidth = this.getViewportScreenBounds().w / targetZoom - const targetHeight = this.getViewportScreenBounds().h / targetZoom + // Figure out how much to zoom + const desiredWidth = width + (leaderWidth - width) * chaseProportion + const desiredHeight = height + (leaderHeight - height) * chaseProportion + const ratio = !isFollowingFollower + ? Math.min(width / desiredWidth, height / desiredHeight) + : height / desiredHeight - // Figure out where to move the camera - const displacement = leaderCenter.sub(center) - const targetCenter = Vec.Add(center, Vec.Mul(displacement, chaseProportion)) + const baseZoom = this.getBaseZoom() + const { zoomSteps } = this.getCameraOptions() + const zoomMin = zoomSteps[0] + const zoomMax = last(zoomSteps)! + const targetZoom = clamp(this.getCamera().z * ratio, zoomMin * baseZoom, zoomMax * baseZoom) + const targetWidth = this.getViewportScreenBounds().w / targetZoom + const targetHeight = this.getViewportScreenBounds().h / targetZoom - // Now let's assess whether we've caught up to the leader or not - const distance = Vec.Sub(targetCenter, center).len() - const zoomChange = Math.abs(targetZoom - this.getCamera().z) + // Figure out where to move the camera + const displacement = leaderCenter.sub(center) + const targetCenter = Vec.Add(center, Vec.Mul(displacement, chaseProportion)) - // If we're chasing the leader... - // Stop chasing if we're close enough - if (distance < FOLLOW_CHASE_PAN_SNAP && zoomChange < FOLLOW_CHASE_ZOOM_SNAP) { - isCaughtUp = true - return - } + // Now let's assess whether we've caught up to the leader or not + const distance = Vec.Sub(targetCenter, center).len() + const zoomChange = Math.abs(targetZoom - this.getCamera().z) - // If we're already caught up with the leader... - // Only start moving again if we're far enough away - if ( - isCaughtUp && - distance < FOLLOW_CHASE_PAN_UNSNAP && - zoomChange < FOLLOW_CHASE_ZOOM_UNSNAP - ) { - return - } + // If we're chasing the leader... + // Stop chasing if we're close enough + if (distance < FOLLOW_CHASE_PAN_SNAP && zoomChange < FOLLOW_CHASE_ZOOM_SNAP) { + isCaughtUp = true + return + } - // Update the camera! - isCaughtUp = false - this.stopCameraAnimation() - this._setCamera({ - x: -(targetCenter.x - targetWidth / 2), - y: -(targetCenter.y - targetHeight / 2), - z: targetZoom, + // If we're already caught up with the leader... + // Only start moving again if we're far enough away + if ( + isCaughtUp && + distance < FOLLOW_CHASE_PAN_UNSNAP && + zoomChange < FOLLOW_CHASE_ZOOM_UNSNAP + ) { + return + } + + // Update the camera! + isCaughtUp = false + this.stopCameraAnimation() + this._setCamera( + new Vec( + -(targetCenter.x - targetWidth / 2), + -(targetCenter.y - targetHeight / 2), + targetZoom + ) + ) }) } this.once('stop-following', cancel) - this.addListener('frame', moveTowardsUser) + this.on('frame', moveTowardsUser) return this } @@ -3011,6 +3223,10 @@ export class Editor extends EventEmitter { /** * Stop viewport-following a user. * + * @example + * ```ts + * editor.stopFollowingUser() + * ``` * @public */ stopFollowingUser(): this { @@ -3019,53 +3235,6 @@ export class Editor extends EventEmitter { return this } - // Camera state - - private _cameraState = atom('camera state', 'idle' as 'idle' | 'moving') - - /** - * Whether the camera is moving or idle. - * - * @public - */ - getCameraState() { - return this._cameraState.get() - } - - // Camera state does two things: first, it allows us to subscribe to whether - // the camera is moving or not; and second, it allows us to update the rendering - // shapes on the canvas. Changing the rendering shapes may cause shapes to - // unmount / remount in the DOM, which is expensive; and computing visibility is - // also expensive in large projects. For this reason, we use a second bounding - // box just for rendering, and we only update after the camera stops moving. - - private _cameraStateTimeoutRemaining = 0 - private _lastUpdateRenderingBoundsTimestamp = Date.now() - - private _decayCameraStateTimeout = (elapsed: number) => { - this._cameraStateTimeoutRemaining -= elapsed - - if (this._cameraStateTimeoutRemaining <= 0) { - this.off('tick', this._decayCameraStateTimeout) - this._cameraState.set('idle') - this.updateRenderingBounds() - } - } - - private _tickCameraState = () => { - // always reset the timeout - this._cameraStateTimeoutRemaining = CAMERA_MOVING_TIMEOUT - - const now = Date.now() - - // If the state is idle, then start the tick - if (this._cameraState.__unsafe__getWithoutCapture() === 'idle') { - this._lastUpdateRenderingBoundsTimestamp = now // don't render right away - this._cameraState.set('moving') - this.on('tick', this._decayCameraStateTimeout) - } - } - /** @internal */ getUnorderedRenderingShapes( // The rendering state. We use this method both for rendering, which @@ -3155,9 +3324,52 @@ export class Editor extends EventEmitter { return renderingShapes } + // Camera state + // Camera state does two things: first, it allows us to subscribe to whether + // the camera is moving or not; and second, it allows us to update the rendering + // shapes on the canvas. Changing the rendering shapes may cause shapes to + // unmount / remount in the DOM, which is expensive; and computing visibility is + // also expensive in large projects. For this reason, we use a second bounding + // box just for rendering, and we only update after the camera stops moving. + private _cameraState = atom('camera state', 'idle' as 'idle' | 'moving') + private _cameraStateTimeoutRemaining = 0 + private _decayCameraStateTimeout = (elapsed: number) => { + this._cameraStateTimeoutRemaining -= elapsed + if (this._cameraStateTimeoutRemaining > 0) return + this.off('tick', this._decayCameraStateTimeout) + this._cameraState.set('idle') + } + private _tickCameraState = () => { + // always reset the timeout + this._cameraStateTimeoutRemaining = CAMERA_MOVING_TIMEOUT + // If the state is idle, then start the tick + if (this._cameraState.__unsafe__getWithoutCapture() !== 'idle') return + this._cameraState.set('moving') + this.on('tick', this._decayCameraStateTimeout) + } + + /** + * Whether the camera is moving or idle. + * + * @example + * ```ts + * editor.getCameraState() + * ``` + * + * @public + */ + getCameraState() { + return this._cameraState.get() + } + /** * Get the shapes that should be displayed in the current viewport. * + * @example + * ```ts + * editor.getRenderingShapes() + * ``` + * * @public */ @computed getRenderingShapes() { @@ -3176,46 +3388,6 @@ export class Editor extends EventEmitter { return renderingShapes.sort(sortById) } - /** - * The current rendering bounds in the current page space, used for checking which shapes are "on screen". - * - * @public - */ - getRenderingBounds() { - return this._renderingBounds.get() - } - - /** @internal */ - private readonly _renderingBounds = atom('rendering viewport', new Box()) - - /** - * Update the rendering bounds. This should be called when the viewport has stopped changing, such - * as at the end of a pan, zoom, or animation. - * - * @example - * ```ts - * editor.updateRenderingBounds() - * ``` - * - * - * @internal - */ - updateRenderingBounds(): this { - const viewportPageBounds = this.getViewportPageBounds() - if (viewportPageBounds.equals(this._renderingBounds.__unsafe__getWithoutCapture())) return this - this._renderingBounds.set(viewportPageBounds.clone()) - - return this - } - - /** - * The distance to expand the viewport when measuring culling. A larger distance will - * mean that shapes near to the viewport (but still outside of it) will not be culled. - * - * @public - */ - renderingBoundsMargin = 100 - /* --------------------- Pages ---------------------- */ @computed private _getAllPagesQuery() { @@ -3225,6 +3397,11 @@ export class Editor extends EventEmitter { /** * Info about the project's current pages. * + * @example + * ```ts + * editor.getPages() + * ``` + * * @public */ @computed getPages(): TLPage[] { @@ -3234,6 +3411,11 @@ export class Editor extends EventEmitter { /** * The current page. * + * @example + * ```ts + * editor.getCurrentPage() + * ``` + * * @public */ getCurrentPage(): TLPage { @@ -3243,6 +3425,11 @@ export class Editor extends EventEmitter { /** * The current page id. * + * @example + * ```ts + * editor.getCurrentPageId() + * ``` + * * @public */ @computed getCurrentPageId(): TLPageId { @@ -3272,6 +3459,11 @@ export class Editor extends EventEmitter { /** * An array of all of the shapes on the current page. * + * @example + * ```ts + * editor.getCurrentPageIds() + * ``` + * * @public */ getCurrentPageShapeIds() { @@ -3320,7 +3512,6 @@ export class Editor extends EventEmitter { */ setCurrentPage(page: TLPageId | TLPage): this { const pageId = typeof page === 'string' ? page : page.id - if (!this.store.has(pageId)) { console.error("Tried to set the current page id to a page that doesn't exist.") return this @@ -3424,9 +3615,7 @@ export class Editor extends EventEmitter { const next = pages[index - 1] ?? pages[index + 1] this.setCurrentPage(next.id) } - this.store.remove([deletedPage.id]) - this.updateRenderingBounds() }) return this } @@ -5121,7 +5310,7 @@ export class Editor extends EventEmitter { const viewportPageBounds = this.getViewportPageBounds() if (selectionPageBounds && !viewportPageBounds.contains(selectionPageBounds)) { this.centerOnPoint(selectionPageBounds.center, { - duration: ANIMATION_MEDIUM_MS, + animation: { duration: ANIMATION_MEDIUM_MS }, }) } } @@ -6449,7 +6638,7 @@ export class Editor extends EventEmitter { * @example * ```ts * editor.animateShape({ id: 'box1', type: 'box', x: 100, y: 100 }) - * editor.animateShape({ id: 'box1', type: 'box', x: 100, y: 100 }, { duration: 100, ease: t => t*t }) + * editor.animateShape({ id: 'box1', type: 'box', x: 100, y: 100 }, { animation: { duration: 100, ease: t => t*t } }) * ``` * * @param partial - The shape partial to update. @@ -6459,9 +6648,9 @@ export class Editor extends EventEmitter { */ animateShape( partial: TLShapePartial | null | undefined, - animationOptions?: TLAnimationOptions + opts = { animation: DEFAULT_ANIMATION_OPTIONS } as TLCameraMoveOptions ): this { - return this.animateShapes([partial], animationOptions) + return this.animateShapes([partial], opts) } /** @@ -6470,7 +6659,7 @@ export class Editor extends EventEmitter { * @example * ```ts * editor.animateShapes([{ id: 'box1', type: 'box', x: 100, y: 100 }]) - * editor.animateShapes([{ id: 'box1', type: 'box', x: 100, y: 100 }], { duration: 100, ease: t => t*t }) + * editor.animateShapes([{ id: 'box1', type: 'box', x: 100, y: 100 }], { animation: { duration: 100, ease: t => t*t } }) * ``` * * @param partials - The shape partials to update. @@ -6480,9 +6669,10 @@ export class Editor extends EventEmitter { */ animateShapes( partials: (TLShapePartial | null | undefined)[], - animationOptions = {} as TLAnimationOptions + opts = { animation: DEFAULT_ANIMATION_OPTIONS } as TLCameraMoveOptions ): this { - const { duration = 500, easing = EASINGS.linear } = animationOptions + if (!opts.animation) return this + const { duration = 500, easing = EASINGS.linear } = opts.animation const animationId = uniqueId() @@ -6535,7 +6725,7 @@ export class Editor extends EventEmitter { // update shapes also removes the shape from animating shapes } - this.removeListener('tick', handleTick) + this.off('tick', handleTick) return } @@ -6566,7 +6756,7 @@ export class Editor extends EventEmitter { this._updateShapes(updates) } - this.addListener('tick', handleTick) + this.on('tick', handleTick) return this } @@ -7878,6 +8068,8 @@ export class Editor extends EventEmitter { // Reset velocity on pointer down, or when a pinch starts or ends if (info.name === 'pointer_down' || this.inputs.isPinching) { pointerVelocity.set(0, 0) + this.inputs.originScreenPoint.setTo(currentScreenPoint) + this.inputs.originPagePoint.setTo(currentPagePoint) } // todo: We only have to do this if there are multiple users in the document @@ -8129,15 +8321,20 @@ export class Editor extends EventEmitter { this._ctrlKeyTimeout = setTimeout(this._setCtrlKeyTimeout, 150) } - const { originPagePoint, originScreenPoint, currentPagePoint, currentScreenPoint } = inputs + const { originPagePoint, currentPagePoint } = inputs if (!inputs.isPointing) { inputs.isDragging = false } + const instanceState = this.store.unsafeGetWithoutCapture(TLINSTANCE_ID)! + const pageState = this.store.get(this._getCurrentPageStateId())! + const cameraOptions = this._cameraOptions.__unsafe__getWithoutCapture()! + const camera = this.store.unsafeGetWithoutCapture(this.getCameraId())! + switch (type) { case 'pinch': { - if (!this.getInstanceState().canMoveCamera) return + if (cameraOptions.isLocked) return clearTimeout(this._longPressTimeout) this._updateInputsFromEvent(info) @@ -8148,7 +8345,7 @@ export class Editor extends EventEmitter { if (!inputs.isEditing) { this._pinchStart = this.getCamera().z if (!this._selectedShapeIdsAtPointerDown.length) { - this._selectedShapeIdsAtPointerDown = this.getSelectedShapeIds() + this._selectedShapeIdsAtPointerDown = [...pageState.selectedShapeIds] } this._didPinch = true @@ -8168,24 +8365,28 @@ export class Editor extends EventEmitter { delta: { x: dx, y: dy }, } = info - const { screenBounds } = this.store.unsafeGetWithoutCapture(TLINSTANCE_ID)! - const { x, y } = Vec.SubXY(info.point, screenBounds.x, screenBounds.y) + // The center of the pinch in screen space + const { x, y } = Vec.SubXY( + info.point, + instanceState.screenBounds.x, + instanceState.screenBounds.y + ) - const { x: cx, y: cy, z: cz } = this.getCamera() - - const zoom = Math.min(MAX_ZOOM, Math.max(MIN_ZOOM, z)) + const { x: cx, y: cy, z: cz } = camera this.stopCameraAnimation() - if (this.getInstanceState().followingUserId) { + if (instanceState.followingUserId) { this.stopFollowingUser() } + + const { panSpeed, zoomSpeed } = cameraOptions this._setCamera( - { - x: cx + dx / cz - x / cz + x / zoom, - y: cy + dy / cz - y / cz + y / zoom, - z: zoom, - }, - true + new Vec( + cx + (dx * panSpeed) / cz - x / cz + x / (z * zoomSpeed), + cy + (dy * panSpeed) / cz - y / cz + y / (z * zoomSpeed), + z * zoomSpeed + ), + { immediate: true } ) return // Stop here! @@ -8193,18 +8394,25 @@ export class Editor extends EventEmitter { case 'pinch_end': { if (!inputs.isPinching) return this + // Stop pinching inputs.isPinching = false - const { _selectedShapeIdsAtPointerDown } = this + + // Stash and clear the shapes that were selected when the pinch started + const { _selectedShapeIdsAtPointerDown: shapesToReselect } = this this.setSelectedShapes(this._selectedShapeIdsAtPointerDown) this._selectedShapeIdsAtPointerDown = [] if (this._didPinch) { this._didPinch = false - this.once('tick', () => { - if (!this._didPinch) { - this.setSelectedShapes(_selectedShapeIdsAtPointerDown) - } - }) + if (shapesToReselect.length > 0) { + this.once('tick', () => { + if (!this._didPinch) { + // Unless we've started pinching again... + // Reselect the shapes that were selected when the pinch started + this.setSelectedShapes(shapesToReselect) + } + }) + } } return // Stop here! @@ -8212,168 +8420,176 @@ export class Editor extends EventEmitter { } } case 'wheel': { - if (!this.getInstanceState().canMoveCamera) return + if (cameraOptions.isLocked) return this._updateInputsFromEvent(info) if (this.getIsMenuOpen()) { // noop } else { - this.stopCameraAnimation() - if (this.getInstanceState().followingUserId) { - this.stopFollowingUser() - } - if (inputs.ctrlKey) { - // todo: Start or update the zoom end interval + const { panSpeed, zoomSpeed, wheelBehavior } = cameraOptions - // If the alt or ctrl keys are pressed, - // zoom or pan the camera and then return. + if (wheelBehavior !== 'none') { + // Stop any camera animation + this.stopCameraAnimation() + // Stop following any following user + if (instanceState.followingUserId) { + this.stopFollowingUser() + } - // Subtract the top left offset from the user's point + const { x: cx, y: cy, z: cz } = camera + const { x: dx, y: dy, z: dz = 0 } = info.delta - const { x, y } = this.inputs.currentScreenPoint + let behavior = wheelBehavior - const { x: cx, y: cy, z: cz } = this.getCamera() + // If the camera behavior is "zoom" and the ctrl key is presssed, then pan; + // If the camera behavior is "pan" and the ctrl key is not pressed, then zoom + if (inputs.ctrlKey) behavior = wheelBehavior === 'pan' ? 'zoom' : 'pan' - const zoom = Math.min(MAX_ZOOM, Math.max(MIN_ZOOM, cz + (info.delta.z ?? 0) * cz)) + switch (behavior) { + case 'zoom': { + // Zoom in on current screen point using the wheel delta + const { x, y } = this.inputs.currentScreenPoint + let delta = dz - this._setCamera( - { - x: cx + (x / zoom - x) - (x / cz - x), - y: cy + (y / zoom - y) - (y / cz - y), - z: zoom, - }, - true - ) + // If we're forcing zoom, then we need to do the wheel normalization math here + if (wheelBehavior === 'zoom') { + if (Math.abs(dy) > 10) { + delta = (10 * Math.sign(dy)) / 100 + } else { + delta = dy / 100 + } + } - // We want to return here because none of the states in our - // statechart should respond to this event (a camera zoom) - return - } - - // Update the camera here, which will dispatch a pointer move... - // this will also update the pointer position, etc - const { x: cx, y: cy, z: cz } = this.getCamera() - this._setCamera({ x: cx + info.delta.x / cz, y: cy + info.delta.y / cz, z: cz }, true) - - if ( - !inputs.isDragging && - inputs.isPointing && - Vec.Dist2(originPagePoint, currentPagePoint) > - (this.getInstanceState().isCoarsePointer ? COARSE_DRAG_DISTANCE : DRAG_DISTANCE) / - this.getZoomLevel() - ) { - clearTimeout(this._longPressTimeout) - inputs.isDragging = true + const zoom = cz + (delta ?? 0) * zoomSpeed * cz + this._setCamera( + new Vec( + cx + (x / zoom - x) - (x / cz - x), + cy + (y / zoom - y) - (y / cz - y), + zoom + ), + { immediate: true } + ) + return + } + case 'pan': { + // Pan the camera based on the wheel delta + this._setCamera(new Vec(cx + (dx * panSpeed) / cz, cy + (dy * panSpeed) / cz, cz), { + immediate: true, + }) + return + } + } } } break } case 'pointer': { - // If we're pinching, return + // Ignore pointer events while we're pinching if (inputs.isPinching) return this._updateInputsFromEvent(info) - const { isPen } = info + const { isPenMode } = instanceState switch (info.name) { case 'pointer_down': { + // If we're in pen mode and the input is not a pen type, then stop here + if (isPenMode && !isPen) return + + // Close any open menus this.clearOpenMenus() + // Start a long press timeout this._longPressTimeout = setTimeout(() => { this.dispatch({ ...info, name: 'long_press' }) }, LONG_PRESS_DURATION) + // Save the selected ids at pointer down this._selectedShapeIdsAtPointerDown = this.getSelectedShapeIds() // Firefox bug fix... // If it's a left-mouse-click, we store the pointer id for later user - if (info.button === 0) { - this.capturedPointerId = info.pointerId - } + if (info.button === LEFT_MOUSE_BUTTON) this.capturedPointerId = info.pointerId // Add the button from the buttons set inputs.buttons.add(info.button) + // Start pointing and stop dragging inputs.isPointing = true inputs.isDragging = false - if (this.getInstanceState().isPenMode) { - if (!isPen) { - return - } - } else { - if (isPen) { - this.updateInstanceState({ isPenMode: true }) - } - } + // If pen mode is off but we're not already in pen mode, turn that on + if (!isPenMode && isPen) this.updateInstanceState({ isPenMode: true }) - if (info.button === 5) { - // Eraser button activates eraser + // On devices with erasers (like the Surface Pen or Wacom Pen), button 5 is the eraser + if (info.button === STYLUS_ERASER_BUTTON) { this._restoreToolId = this.getCurrentToolId() this.complete() this.setCurrentTool('eraser') - } else if (info.button === 1) { - // Middle mouse pan activates panning + } else if (info.button === MIDDLE_MOUSE_BUTTON) { + // Middle mouse pan activates panning unless we're already panning (with spacebar) if (!this.inputs.isPanning) { this._prevCursor = this.getInstanceState().cursor.type } - this.inputs.isPanning = true + clearTimeout(this._longPressTimeout) } + // We might be panning because we did a middle mouse click, or because we're holding spacebar and started a regular click + // Also stop here, we don't want the state chart to receive the event if (this.inputs.isPanning) { this.stopCameraAnimation() this.setCursor({ type: 'grabbing', rotation: 0 }) return this } - originScreenPoint.setTo(currentScreenPoint) - originPagePoint.setTo(currentPagePoint) break } case 'pointer_move': { // If the user is in pen mode, but the pointer is not a pen, stop here. - if (!isPen && this.getInstanceState().isPenMode) { - return - } + if (!isPen && isPenMode) return + // If we've started panning, then clear any long press timeout if (this.inputs.isPanning && this.inputs.isPointing) { - clearTimeout(this._longPressTimeout) - // Handle panning + // Handle spacebar / middle mouse button panning const { currentScreenPoint, previousScreenPoint } = this.inputs - this.pan(Vec.Sub(currentScreenPoint, previousScreenPoint)) + const { x: cx, y: cy, z: cz } = camera + const { panSpeed } = cameraOptions + const offset = Vec.Sub(currentScreenPoint, previousScreenPoint) + this.setCamera( + new Vec(cx + (offset.x * panSpeed) / cz, cy + (offset.y * panSpeed) / cz, cz), + { immediate: true } + ) return } if ( - !inputs.isDragging && inputs.isPointing && + !inputs.isDragging && Vec.Dist2(originPagePoint, currentPagePoint) > - (this.getInstanceState().isCoarsePointer ? COARSE_DRAG_DISTANCE : DRAG_DISTANCE) / - this.getZoomLevel() + (instanceState.isCoarsePointer ? COARSE_DRAG_DISTANCE : DRAG_DISTANCE) / camera.z ) { - clearTimeout(this._longPressTimeout) + // Start dragging inputs.isDragging = true + clearTimeout(this._longPressTimeout) } break } case 'pointer_up': { + // Stop dragging / pointing + inputs.isDragging = false + inputs.isPointing = false + clearTimeout(this._longPressTimeout) + // Remove the button from the buttons set inputs.buttons.delete(info.button) - inputs.isPointing = false - inputs.isDragging = false + // Suppressing pointerup here as doesn't seem to do what we what here. + if (this.getIsMenuOpen()) return - if (this.getIsMenuOpen()) { - // Suppressing pointerup here as doesn't seem to do what we what here. - return - } - - if (!isPen && this.getInstanceState().isPenMode) { - return - } + // If we're in pen mode and we're not using a pen, stop here + if (instanceState.isPenMode && !isPen) return // Firefox bug fix... // If it's the same pointer that we stored earlier... @@ -8384,50 +8600,40 @@ export class Editor extends EventEmitter { } if (inputs.isPanning) { - if (info.button === 1) { - if (!this.inputs.keys.has(' ')) { - inputs.isPanning = false + const slideDirection = this.inputs.pointerVelocity + const slideSpeed = Math.min(2, slideDirection.len()) - this.slideCamera({ - speed: Math.min(2, this.inputs.pointerVelocity.len()), - direction: this.inputs.pointerVelocity, - friction: CAMERA_SLIDE_FRICTION, - }) - this.setCursor({ type: this._prevCursor, rotation: 0 }) - } else { - this.slideCamera({ - speed: Math.min(2, this.inputs.pointerVelocity.len()), - direction: this.inputs.pointerVelocity, - friction: CAMERA_SLIDE_FRICTION, - }) - this.setCursor({ - type: 'grab', - rotation: 0, - }) + switch (info.button) { + case LEFT_MOUSE_BUTTON: { + this.setCursor({ type: 'grab', rotation: 0 }) + break } - } else if (info.button === 0) { + case MIDDLE_MOUSE_BUTTON: { + if (this.inputs.keys.has(' ')) { + this.setCursor({ type: 'grab', rotation: 0 }) + } else { + this.setCursor({ type: this._prevCursor, rotation: 0 }) + } + } + } + + if (slideSpeed > 0) { this.slideCamera({ - speed: Math.min(2, this.inputs.pointerVelocity.len()), - direction: this.inputs.pointerVelocity, + speed: slideSpeed, + direction: slideDirection, friction: CAMERA_SLIDE_FRICTION, }) - this.setCursor({ - type: 'grab', - rotation: 0, - }) } } else { - if (info.button === 5) { - // Eraser button activates eraser + if (info.button === STYLUS_ERASER_BUTTON) { + // If we were erasing with a stylus button, restore the tool we were using before we started erasing this.complete() this.setCurrentTool(this._restoreToolId) } } - break } } - break } case 'keyboard': { @@ -8442,12 +8648,13 @@ export class Editor extends EventEmitter { inputs.keys.add(info.code) // If the space key is pressed (but meta / control isn't!) activate panning - if (!info.ctrlKey && info.code === 'Space') { + if (info.code === 'Space' && !info.ctrlKey) { if (!this.inputs.isPanning) { - this._prevCursor = this.getInstanceState().cursor.type + this._prevCursor = instanceState.cursor.type } this.inputs.isPanning = true + clearTimeout(this._longPressTimeout) this.setCursor({ type: this.inputs.isPointing ? 'grabbing' : 'grab', rotation: 0 }) } @@ -8457,11 +8664,16 @@ export class Editor extends EventEmitter { // Remove the key from the keys set inputs.keys.delete(info.code) - if (info.code === 'Space' && !this.inputs.buttons.has(1)) { - this.inputs.isPanning = false - this.setCursor({ type: this._prevCursor, rotation: 0 }) + // If we've lifted the space key, + if (info.code === 'Space') { + if (this.inputs.buttons.has(MIDDLE_MOUSE_BUTTON)) { + // If we're still middle dragging, continue panning + } else { + // otherwise, stop panning + this.inputs.isPanning = false + this.setCursor({ type: this._prevCursor, rotation: 0 }) + } } - break } case 'key_repeat': { @@ -8475,45 +8687,25 @@ export class Editor extends EventEmitter { // Correct the info name for right / middle clicks if (info.type === 'pointer') { - if (info.button === 1) { + if (info.button === MIDDLE_MOUSE_BUTTON) { info.name = 'middle_click' - } else if (info.button === 2) { + } else if (info.button === RIGHT_MOUSE_BUTTON) { info.name = 'right_click' } - // If a pointer event, send the event to the click manager. - if (info.isPen === this.getInstanceState().isPenMode) { - switch (info.name) { - case 'pointer_down': { - const otherEvent = this._clickManager.transformPointerDownEvent(info) - if (info.name !== otherEvent.name) { - this.root.handleEvent(info) - this.emit('event', info) - this.root.handleEvent(otherEvent) - this.emit('event', otherEvent) - return - } - - break - } - case 'pointer_up': { - clearTimeout(this._longPressTimeout) - - const otherEvent = this._clickManager.transformPointerUpEvent(info) - if (info.name !== otherEvent.name) { - this.root.handleEvent(info) - this.emit('event', info) - this.root.handleEvent(otherEvent) - this.emit('event', otherEvent) - return - } - - break - } - case 'pointer_move': { - this._clickManager.handleMove() - break - } + // If a left click pointer event, send the event to the click manager. + const { isPenMode } = this.store.unsafeGetWithoutCapture(TLINSTANCE_ID)! + if (info.isPen === isPenMode) { + // The click manager may return a new event, i.e. a double click event + // depending on the event coming in and its own state. If the event has + // changed then hand both events to the statechart + const clickInfo = this._clickManager.handlePointerEvent(info) + if (info.name !== clickInfo.name) { + this.root.handleEvent(info) + this.emit('event', info) + this.root.handleEvent(clickInfo) + this.emit('event', clickInfo) + return } } } @@ -8576,3 +8768,15 @@ function pushShapeWithDescendants(editor: Editor, id: TLShapeId, result: TLShape pushShapeWithDescendants(editor, childIds[i], result) } } + +function getCameraFitXFitY(editor: Editor, cameraOptions: TLCameraOptions) { + if (!cameraOptions.constraints) throw Error('Should have constraints here') + const { + padding: { x: px, y: py }, + } = cameraOptions.constraints + const vsb = editor.getViewportScreenBounds() + const bounds = Box.From(cameraOptions.constraints.bounds) + const zx = (vsb.w - px * 2) / bounds.w + const zy = (vsb.h - py * 2) / bounds.h + return { zx, zy } +} diff --git a/packages/editor/src/lib/editor/derivations/notVisibleShapes.ts b/packages/editor/src/lib/editor/derivations/notVisibleShapes.ts index 25a676706..5685cd91c 100644 --- a/packages/editor/src/lib/editor/derivations/notVisibleShapes.ts +++ b/packages/editor/src/lib/editor/derivations/notVisibleShapes.ts @@ -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 { const shapes = editor.getCurrentPageShapeIds() const viewportPageBounds = editor.getViewportPageBounds() @@ -34,8 +32,6 @@ export const notVisibleShapes = (editor: Editor) => { return notVisibleShapes } return computed>('getCulledShapes', (prevValue) => { - if (!isCullingOffScreenShapes) return new Set() - if (isUninitialized(prevValue)) { return fromScratch(editor) } diff --git a/packages/editor/src/lib/editor/managers/ClickManager.ts b/packages/editor/src/lib/editor/managers/ClickManager.ts index ef5e53b10..4c7a4253b 100644 --- a/packages/editor/src/lib/editor/managers/ClickManager.ts +++ b/packages/editor/src/lib/editor/managers/ClickManager.ts @@ -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() - } - } } diff --git a/packages/editor/src/lib/editor/managers/SnapManager/SnapManager.ts b/packages/editor/src/lib/editor/managers/SnapManager/SnapManager.ts index 71bc273fa..aa543585a 100644 --- a/packages/editor/src/lib/editor/managers/SnapManager/SnapManager.ts +++ b/packages/editor/src/lib/editor/managers/SnapManager/SnapManager.ts @@ -64,7 +64,7 @@ export class SnapManager { // TODO: make this an incremental derivation @computed getSnappableShapes(): Set { const { editor } = this - const renderingBounds = editor.getRenderingBounds() + const renderingBounds = editor.getViewportPageBounds() const selectedShapeIds = editor.getSelectedShapeIds() const snappableShapes: Set = new Set() diff --git a/packages/editor/src/lib/editor/types/misc-types.ts b/packages/editor/src/lib/editor/types/misc-types.ts index 6851e726d..f62459470 100644 --- a/packages/editor/src/lib/editor/types/misc-types.ts +++ b/packages/editor/src/lib/editor/types/misc-types.ts @@ -1,4 +1,6 @@ +import { BoxModel } from '@tldraw/tlschema' import { Box } from '../../primitives/Box' +import { VecLike } from '../../primitives/Vec' /** @public */ export type RequiredKeys = Partial> & Pick @@ -14,3 +16,110 @@ export type TLSvgOptions = { darkMode?: boolean preserveAspectRatio: React.SVGAttributes['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' + } + } +} diff --git a/packages/editor/src/lib/hooks/useCanvasEvents.ts b/packages/editor/src/lib/hooks/useCanvasEvents.ts index aef720398..f0aabfbbb 100644 --- a/packages/editor/src/lib/hooks/useCanvasEvents.ts +++ b/packages/editor/src/lib/hooks/useCanvasEvents.ts @@ -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', diff --git a/packages/editor/src/lib/hooks/useSelectionEvents.ts b/packages/editor/src/lib/hooks/useSelectionEvents.ts index 993330b2e..30b604e22 100644 --- a/packages/editor/src/lib/hooks/useSelectionEvents.ts +++ b/packages/editor/src/lib/hooks/useSelectionEvents.ts @@ -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', diff --git a/packages/editor/src/lib/utils/edgeScrolling.ts b/packages/editor/src/lib/utils/edgeScrolling.ts index b89e804fe..4c23eb0c1 100644 --- a/packages/editor/src/lib/utils/edgeScrolling.ts +++ b/packages/editor/src/lib/utils/edgeScrolling.ts @@ -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)) } diff --git a/packages/editor/src/lib/utils/normalizeWheel.ts b/packages/editor/src/lib/utils/normalizeWheel.ts index 1227ee66c..17b38f729 100644 --- a/packages/editor/src/lib/utils/normalizeWheel.ts +++ b/packages/editor/src/lib/utils/normalizeWheel.ts @@ -11,17 +11,9 @@ export function normalizeWheel(event: WheelEvent | React.WheelEvent 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 diff --git a/packages/tldraw/src/lib/defaultExternalContentHandlers.ts b/packages/tldraw/src/lib/defaultExternalContentHandlers.ts index cfbf69883..647792061 100644 --- a/packages/tldraw/src/lib/defaultExternalContentHandlers.ts +++ b/packages/tldraw/src/lib/defaultExternalContentHandlers.ts @@ -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('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) diff --git a/packages/tldraw/src/lib/shapes/shared/ShapeFill.tsx b/packages/tldraw/src/lib/shapes/shared/ShapeFill.tsx index dfc58ae3e..7cf2cb256 100644 --- a/packages/tldraw/src/lib/shapes/shared/ShapeFill.tsx +++ b/packages/tldraw/src/lib/shapes/shared/ShapeFill.tsx @@ -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]) diff --git a/packages/tldraw/src/lib/shapes/shared/defaultStyleDefs.tsx b/packages/tldraw/src/lib/shapes/shared/defaultStyleDefs.tsx index 16e4495be..d2407bb32 100644 --- a/packages/tldraw/src/lib/shapes/shared/defaultStyleDefs.tsx +++ b/packages/tldraw/src/lib/shapes/shared/defaultStyleDefs.tsx @@ -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 = {} + +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, diff --git a/packages/tldraw/src/lib/tools/HandTool/HandTool.ts b/packages/tldraw/src/lib/tools/HandTool/HandTool.ts index b1153a196..a33e034bd 100644 --- a/packages/tldraw/src/lib/tools/HandTool/HandTool.ts +++ b/packages/tldraw/src/lib/tools/HandTool/HandTool.ts @@ -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 }, + }) } } } diff --git a/packages/tldraw/src/lib/tools/SelectTool/selectHelpers.ts b/packages/tldraw/src/lib/tools/SelectTool/selectHelpers.ts index d242ed302..14aed1ac6 100644 --- a/packages/tldraw/src/lib/tools/SelectTool/selectHelpers.ts +++ b/packages/tldraw/src/lib/tools/SelectTool/selectHelpers.ts @@ -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, }) } diff --git a/packages/tldraw/src/lib/tools/ZoomTool/childStates/Pointing.ts b/packages/tldraw/src/lib/tools/ZoomTool/childStates/Pointing.ts index 14c90cfaa..42c227d2d 100644 --- a/packages/tldraw/src/lib/tools/ZoomTool/childStates/Pointing.ts +++ b/packages/tldraw/src/lib/tools/ZoomTool/childStates/Pointing.ts @@ -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) } diff --git a/packages/tldraw/src/lib/tools/ZoomTool/childStates/ZoomBrushing.ts b/packages/tldraw/src/lib/tools/ZoomTool/childStates/ZoomBrushing.ts index 2a29c6ca2..ab6373eb3 100644 --- a/packages/tldraw/src/lib/tools/ZoomTool/childStates/ZoomBrushing.ts +++ b/packages/tldraw/src/lib/tools/ZoomTool/childStates/ZoomBrushing.ts @@ -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) diff --git a/packages/tldraw/src/lib/ui/components/EmbedDialog.tsx b/packages/tldraw/src/lib/ui/components/EmbedDialog.tsx index e9e8d22a8..598c6dade 100644 --- a/packages/tldraw/src/lib/ui/components/EmbedDialog.tsx +++ b/packages/tldraw/src/lib/ui/components/EmbedDialog.tsx @@ -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, }) diff --git a/packages/tldraw/src/lib/ui/components/Minimap/DefaultMinimap.tsx b/packages/tldraw/src/lib/ui/components/Minimap/DefaultMinimap.tsx index 1373cb343..e033d551f 100644 --- a/packages/tldraw/src/lib/ui/components/Minimap/DefaultMinimap.tsx +++ b/packages/tldraw/src/lib/ui/components/Minimap/DefaultMinimap.tsx @@ -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, diff --git a/packages/tldraw/src/lib/ui/components/ZoomMenu/DefaultZoomMenu.tsx b/packages/tldraw/src/lib/ui/components/ZoomMenu/DefaultZoomMenu.tsx index 42e45f2b7..165b04737 100644 --- a/packages/tldraw/src/lib/ui/components/ZoomMenu/DefaultZoomMenu.tsx +++ b/packages/tldraw/src/lib/ui/components/ZoomMenu/DefaultZoomMenu.tsx @@ -55,7 +55,9 @@ const ZoomTriggerButton = forwardRef( 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 ( diff --git a/packages/tldraw/src/lib/ui/context/actions.tsx b/packages/tldraw/src/lib/ui/context/actions.tsx index da22fe0a8..889e87f95 100644 --- a/packages/tldraw/src/lib/ui/context/actions.tsx +++ b/packages/tldraw/src/lib/ui/context/actions.tsx @@ -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 }, + }) }, }, { diff --git a/packages/tldraw/src/lib/ui/hooks/useClipboardEvents.ts b/packages/tldraw/src/lib/ui/hooks/useClipboardEvents.ts index cb4148183..16c06b07a 100644 --- a/packages/tldraw/src/lib/ui/hooks/useClipboardEvents.ts +++ b/packages/tldraw/src/lib/ui/hooks/useClipboardEvents.ts @@ -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 diff --git a/packages/tldraw/src/lib/utils/tldr/file.ts b/packages/tldraw/src/lib/utils/tldr/file.ts index 8302d53df..54427ea34 100644 --- a/packages/tldraw/src/lib/utils/tldr/file.ts +++ b/packages/tldraw/src/lib/utils/tldr/file.ts @@ -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) { diff --git a/packages/tldraw/src/test/TestEditor.ts b/packages/tldraw/src/test/TestEditor.ts index 220833cb7..2e750c846 100644 --- a/packages/tldraw/src/test/TestEditor.ts +++ b/packages/tldraw/src/test/TestEditor.ts @@ -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, diff --git a/packages/tldraw/src/test/commands/animationSpeed.test.ts b/packages/tldraw/src/test/commands/animationSpeed.test.ts index f2a5b0e6d..0021f91c7 100644 --- a/packages/tldraw/src/test/commands/animationSpeed.test.ts +++ b/packages/tldraw/src/test/commands/animationSpeed.test.ts @@ -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 diff --git a/packages/tldraw/src/test/commands/centerOnPoint.test.ts b/packages/tldraw/src/test/commands/centerOnPoint.test.ts index 76a5e82a0..dbc0e0472 100644 --- a/packages/tldraw/src/test/commands/centerOnPoint.test.ts +++ b/packages/tldraw/src/test/commands/centerOnPoint.test.ts @@ -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 }) diff --git a/packages/tldraw/src/test/commands/getBaseZoom.test.ts b/packages/tldraw/src/test/commands/getBaseZoom.test.ts new file mode 100644 index 000000000..f752d0421 --- /dev/null +++ b/packages/tldraw/src/test/commands/getBaseZoom.test.ts @@ -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) + }) +}) diff --git a/packages/tldraw/src/test/commands/getInitialZoom.test.ts b/packages/tldraw/src/test/commands/getInitialZoom.test.ts new file mode 100644 index 000000000..192461bdd --- /dev/null +++ b/packages/tldraw/src/test/commands/getInitialZoom.test.ts @@ -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) + }) +}) diff --git a/packages/tldraw/src/test/commands/pan.test.ts b/packages/tldraw/src/test/commands/pan.test.ts index 487001b6f..b4e4fbb22 100644 --- a/packages/tldraw/src/test/commands/pan.test.ts +++ b/packages/tldraw/src/test/commands/pan.test.ts @@ -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 }) diff --git a/packages/tldraw/src/test/commands/setAppState.test.ts b/packages/tldraw/src/test/commands/setAppState.test.ts deleted file mode 100644 index a83780d70..000000000 --- a/packages/tldraw/src/test/commands/setAppState.test.ts +++ /dev/null @@ -1,9 +0,0 @@ -// import { TestEditor } from '../TestEditor' - -// let editor: TestEditor - -// beforeEach(() => { -// editor =new TestEditor() -// }) - -it.todo('sets the app state') diff --git a/packages/tldraw/src/test/commands/setCamera.test.ts b/packages/tldraw/src/test/commands/setCamera.test.ts new file mode 100644 index 000000000..6db6592e7 --- /dev/null +++ b/packages/tldraw/src/test/commands/setCamera.test.ts @@ -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') +}) diff --git a/packages/tldraw/src/test/commands/zoomIn.test.ts b/packages/tldraw/src/test/commands/zoomIn.test.ts index d9bd335fb..67f893bb0 100644 --- a/packages/tldraw/src/test/commands/zoomIn.test.ts +++ b/packages/tldraw/src/test/commands/zoomIn.test.ts @@ -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 }) }) diff --git a/packages/tldraw/src/test/commands/zoomOut.test.ts b/packages/tldraw/src/test/commands/zoomOut.test.ts index 1f0ea2fff..9cf5f31c2 100644 --- a/packages/tldraw/src/test/commands/zoomOut.test.ts +++ b/packages/tldraw/src/test/commands/zoomOut.test.ts @@ -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) +}) diff --git a/packages/tldraw/src/test/commands/zoomToBounds.test.ts b/packages/tldraw/src/test/commands/zoomToBounds.test.ts index 3f3152ed6..34e4ed70f 100644 --- a/packages/tldraw/src/test/commands/zoomToBounds.test.ts +++ b/packages/tldraw/src/test/commands/zoomToBounds.test.ts @@ -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 }) }) diff --git a/packages/tldraw/src/test/commands/zoomToFit.test.ts b/packages/tldraw/src/test/commands/zoomToFit.test.ts index d40a5b59f..367d73013 100644 --- a/packages/tldraw/src/test/commands/zoomToFit.test.ts +++ b/packages/tldraw/src/test/commands/zoomToFit.test.ts @@ -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) }) diff --git a/packages/tldraw/src/test/commands/zoomToSelection.test.ts b/packages/tldraw/src/test/commands/zoomToSelection.test.ts index e880a95c9..4343170b8 100644 --- a/packages/tldraw/src/test/commands/zoomToSelection.test.ts +++ b/packages/tldraw/src/test/commands/zoomToSelection.test.ts @@ -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) diff --git a/packages/tldraw/src/test/getCulledShapes.test.tsx b/packages/tldraw/src/test/getCulledShapes.test.tsx index eaf3ed701..bb84444e8 100644 --- a/packages/tldraw/src/test/getCulledShapes.test.tsx +++ b/packages/tldraw/src/test/getCulledShapes.test.tsx @@ -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() { diff --git a/packages/tldraw/src/test/paste.test.ts b/packages/tldraw/src/test/paste.test.ts index cc1c26d3b..0035fe8cf 100644 --- a/packages/tldraw/src/test/paste.test.ts +++ b/packages/tldraw/src/test/paste.test.ts @@ -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() diff --git a/packages/tldraw/src/test/renderingShapes.test.tsx b/packages/tldraw/src/test/renderingShapes.test.tsx index 8b774da5b..3a56fc5e1 100644 --- a/packages/tldraw/src/test/renderingShapes.test.tsx +++ b/packages/tldraw/src/test/renderingShapes.test.tsx @@ -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 diff --git a/packages/tlschema/api-report.md b/packages/tlschema/api-report.md index 062be5715..b797a041a 100644 --- a/packages/tlschema/api-report.md +++ b/packages/tlschema/api-report.md @@ -1059,8 +1059,6 @@ export interface TLInstance extends BaseRecord<'instance', TLInstanceId> { // (undocumented) brush: BoxModel | null; // (undocumented) - canMoveCamera: boolean; - // (undocumented) chatMessage: string; // (undocumented) currentPageId: TLPageId; diff --git a/packages/tlschema/src/migrations.test.ts b/packages/tlschema/src/migrations.test.ts index 1178e8d48..38a23b8c7 100644 --- a/packages/tlschema/src/migrations.test.ts +++ b/packages/tlschema/src/migrations.test.ts @@ -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) diff --git a/packages/tlschema/src/records/TLInstance.ts b/packages/tlschema/src/records/TLInstance.ts index 2f5b35d3c..c67a5addf 100644 --- a/packages/tlschema/src/records/TLInstance.ts +++ b/packages/tlschema/src/records/TLInstance.ts @@ -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 { + return { + ...record, + } + }, + down: (instance) => { + return { ...instance, canMoveCamera: true } + }, + }, ], })