From 43edeb09b50b650ef3cf9e5c8837c4ebfd152d09 Mon Sep 17 00:00:00 2001 From: Steve Ruiz Date: Thu, 4 Apr 2024 19:16:17 +0100 Subject: [PATCH 01/26] Add white migration (#3334) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This PR adds a down migration for #3321. ### Change Type - [x] `sdk` — Changes the tldraw SDK - [x] `dunno` — I don't know --- packages/state/api/api.json | 4 ++-- packages/tlschema/src/migrations.test.ts | 28 ++++++++++++++++++++++++ packages/tlschema/src/records/TLShape.ts | 19 +++++++++++++++- 3 files changed, 48 insertions(+), 3 deletions(-) diff --git a/packages/state/api/api.json b/packages/state/api/api.json index fc6d8c913..6b0f0291a 100644 --- a/packages/state/api/api.json +++ b/packages/state/api/api.json @@ -2387,7 +2387,7 @@ }, { "kind": "Content", - "text": "Value | (() => Value)" + "text": "(() => Value) | Value" }, { "kind": "Content", @@ -2813,7 +2813,7 @@ }, { "kind": "Content", - "text": "undefined | any[]" + "text": "any[] | undefined" }, { "kind": "Content", diff --git a/packages/tlschema/src/migrations.test.ts b/packages/tlschema/src/migrations.test.ts index 377920f2d..f9a6d9548 100644 --- a/packages/tlschema/src/migrations.test.ts +++ b/packages/tlschema/src/migrations.test.ts @@ -2030,6 +2030,34 @@ describe('Fractional indexing for line points', () => { }) }) +describe('add white', () => { + const { up, down } = rootShapeMigrations.migrators[rootShapeVersions.AddWhite] + + test('up works as expected', () => { + expect( + up({ + props: {}, + }) + ).toEqual({ + props: {}, + }) + }) + + test('down works as expected', () => { + expect( + down({ + props: { + color: 'white', + }, + }) + ).toEqual({ + props: { + color: 'black', + }, + }) + }) +}) + /* --- PUT YOUR MIGRATIONS TESTS ABOVE HERE --- */ for (const migrator of allMigrators) { diff --git a/packages/tlschema/src/records/TLShape.ts b/packages/tlschema/src/records/TLShape.ts index 5821ae09e..300c5fad4 100644 --- a/packages/tlschema/src/records/TLShape.ts +++ b/packages/tlschema/src/records/TLShape.ts @@ -87,11 +87,12 @@ export const rootShapeVersions = { AddIsLocked: 1, HoistOpacity: 2, AddMeta: 3, + AddWhite: 4, } as const /** @internal */ export const rootShapeMigrations = defineMigrations({ - currentVersion: rootShapeVersions.AddMeta, + currentVersion: rootShapeVersions.AddWhite, migrators: { [rootShapeVersions.AddIsLocked]: { up: (record) => { @@ -147,6 +148,22 @@ export const rootShapeMigrations = defineMigrations({ } }, }, + [rootShapeVersions.AddWhite]: { + up: (record) => { + return { + ...record, + } + }, + down: (record) => { + return { + ...record, + props: { + ...record.props, + color: record.props.color === 'white' ? 'black' : record.props.color, + }, + } + }, + }, }, }) From 58286db90c77ca9cc4516c802bcec041cdb2a323 Mon Sep 17 00:00:00 2001 From: Steve Ruiz Date: Thu, 4 Apr 2024 22:50:01 +0100 Subject: [PATCH 02/26] Add long press event (#3275) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This PR adds a "long press" event that fires when pointing for more than 500ms. This event is used in the same way that dragging is used (e.g. to transition to from pointing_selection to translating) but only on desktop. On mobile, long presses are used to open the context menu. ![Kapture 2024-03-26 at 18 57 15](https://github.com/tldraw/tldraw/assets/23072548/34a7ee2b-bde6-443b-93e0-082453a1cb61) ## Background This idea came out of @TodePond's #3208 PR. We use a "dead zone" to avoid accidentally moving / rotating things when clicking on them, which is especially common on mobile if a dead zone feature isn't implemented. However, this makes it difficult to make "fine adjustments" because you need to drag out of the dead zone (to start translating) and then drag back to where you want to go. ![Kapture 2024-03-26 at 19 00 38](https://github.com/tldraw/tldraw/assets/23072548/9a15852d-03d0-4b88-b594-27dbd3b68780) With this change, you can long press on desktop to get to that translating state. It's a micro UX optimization but especially nice if apps want to display different UI for "dragging" shapes before the user leaves the dead zone. ![Kapture 2024-03-26 at 19 02 59](https://github.com/tldraw/tldraw/assets/23072548/f0ff337e-2cbd-4b73-9ef5-9b7deaf0ae91) ### Change Type - [x] `sdk` — Changes the tldraw SDK - [ ] `dotcom` — Changes the tldraw.com web app - [ ] `docs` — Changes to the documentation, examples, or templates. - [ ] `vs code` — Changes to the vscode plugin - [ ] `internal` — Does not affect user-facing stuff - [ ] `bugfix` — Bug fix - [x] `feature` — New feature - [ ] `improvement` — Improving existing features - [ ] `chore` — Updating dependencies, other boring stuff - [ ] `galaxy brain` — Architectural changes - [ ] `tests` — Changes to any test code - [ ] `tools` — Changes to infrastructure, CI, internal scripts, debugging tools, etc. - [ ] `dunno` — I don't know ### Test Plan 1. Long press shapes, selections, resize handles, rotate handles, crop handles. 2. You should enter the corresponding states, just as you would have with a drag. - [ ] Unit Tests TODO ### Release Notes - Add support for long pressing on desktop. --- packages/editor/api-report.md | 6 +- packages/editor/api/api.json | 65 ++++++++++++++++++- packages/editor/src/lib/constants.ts | 3 + packages/editor/src/lib/editor/Editor.ts | 15 ++++- .../editor/src/lib/editor/tools/StateNode.ts | 1 + .../src/lib/editor/types/event-types.ts | 3 + .../childStates/PointingCropHandle.ts | 21 ++++-- .../SelectTool/childStates/PointingHandle.ts | 11 +++- .../childStates/PointingResizeHandle.ts | 15 +++-- .../childStates/PointingRotateHandle.ts | 17 +++-- .../childStates/PointingSelection.ts | 12 +++- .../SelectTool/childStates/PointingShape.ts | 12 +++- .../childStates/ScribbleBrushing.ts | 4 +- 13 files changed, 158 insertions(+), 27 deletions(-) diff --git a/packages/editor/api-report.md b/packages/editor/api-report.md index c3a7827ab..14c0d41c8 100644 --- a/packages/editor/api-report.md +++ b/packages/editor/api-report.md @@ -1824,6 +1824,8 @@ export abstract class StateNode implements Partial { // (undocumented) onKeyUp?: TLEventHandlers['onKeyUp']; // (undocumented) + onLongPress?: TLEventHandlers['onLongPress']; + // (undocumented) onMiddleClick?: TLEventHandlers['onMiddleClick']; // (undocumented) onPointerDown?: TLEventHandlers['onPointerDown']; @@ -2144,6 +2146,8 @@ export interface TLEventHandlers { // (undocumented) onKeyUp: TLKeyboardEvent; // (undocumented) + onLongPress: TLPointerEvent; + // (undocumented) onMiddleClick: TLPointerEvent; // (undocumented) onPointerDown: TLPointerEvent; @@ -2418,7 +2422,7 @@ export type TLPointerEventInfo = TLBaseEventInfo & { } & TLPointerEventTarget; // @public (undocumented) -export type TLPointerEventName = 'middle_click' | 'pointer_down' | 'pointer_move' | 'pointer_up' | 'right_click'; +export type TLPointerEventName = 'long_press' | 'middle_click' | 'pointer_down' | 'pointer_move' | 'pointer_up' | 'right_click'; // @public (undocumented) export type TLPointerEventTarget = { diff --git a/packages/editor/api/api.json b/packages/editor/api/api.json index 135223a10..b7ebe74b8 100644 --- a/packages/editor/api/api.json +++ b/packages/editor/api/api.json @@ -34940,6 +34940,41 @@ "isProtected": false, "isAbstract": false }, + { + "kind": "Property", + "canonicalReference": "@tldraw/editor!StateNode#onLongPress:member", + "docComment": "", + "excerptTokens": [ + { + "kind": "Content", + "text": "onLongPress?: " + }, + { + "kind": "Reference", + "text": "TLEventHandlers", + "canonicalReference": "@tldraw/editor!TLEventHandlers:interface" + }, + { + "kind": "Content", + "text": "['onLongPress']" + }, + { + "kind": "Content", + "text": ";" + } + ], + "isReadonly": false, + "isOptional": true, + "releaseTag": "Public", + "name": "onLongPress", + "propertyTypeTokenRange": { + "startIndex": 1, + "endIndex": 3 + }, + "isStatic": false, + "isProtected": false, + "isAbstract": false + }, { "kind": "Property", "canonicalReference": "@tldraw/editor!StateNode#onMiddleClick:member", @@ -38355,6 +38390,34 @@ "endIndex": 2 } }, + { + "kind": "PropertySignature", + "canonicalReference": "@tldraw/editor!TLEventHandlers#onLongPress:member", + "docComment": "", + "excerptTokens": [ + { + "kind": "Content", + "text": "onLongPress: " + }, + { + "kind": "Reference", + "text": "TLPointerEvent", + "canonicalReference": "@tldraw/editor!TLPointerEvent:type" + }, + { + "kind": "Content", + "text": ";" + } + ], + "isReadonly": false, + "isOptional": false, + "releaseTag": "Public", + "name": "onLongPress", + "propertyTypeTokenRange": { + "startIndex": 1, + "endIndex": 2 + } + }, { "kind": "PropertySignature", "canonicalReference": "@tldraw/editor!TLEventHandlers#onMiddleClick:member", @@ -41004,7 +41067,7 @@ }, { "kind": "Content", - "text": "'middle_click' | 'pointer_down' | 'pointer_move' | 'pointer_up' | 'right_click'" + "text": "'long_press' | 'middle_click' | 'pointer_down' | 'pointer_move' | 'pointer_up' | 'right_click'" }, { "kind": "Content", diff --git a/packages/editor/src/lib/constants.ts b/packages/editor/src/lib/constants.ts index fbdb79814..43dcbfb10 100644 --- a/packages/editor/src/lib/constants.ts +++ b/packages/editor/src/lib/constants.ts @@ -104,3 +104,6 @@ export const COARSE_HANDLE_RADIUS = 20 /** @internal */ export const HANDLE_RADIUS = 12 + +/** @internal */ +export const LONG_PRESS_DURATION = 500 diff --git a/packages/editor/src/lib/editor/Editor.ts b/packages/editor/src/lib/editor/Editor.ts index d44f2c75e..c05a93365 100644 --- a/packages/editor/src/lib/editor/Editor.ts +++ b/packages/editor/src/lib/editor/Editor.ts @@ -79,6 +79,7 @@ import { FOLLOW_CHASE_ZOOM_UNSNAP, HIT_TEST_MARGIN, INTERNAL_POINTER_IDS, + LONG_PRESS_DURATION, MAX_PAGES, MAX_SHAPES_PER_PAGE, MAX_ZOOM, @@ -8348,6 +8349,9 @@ export class Editor extends EventEmitter { /** @internal */ private _selectedShapeIdsAtPointerDown: TLShapeId[] = [] + /** @internal */ + private _longPressTimeout = -1 as any + /** @internal */ capturedPointerId: number | null = null @@ -8384,8 +8388,8 @@ export class Editor extends EventEmitter { } if (elapsed > 0) { this.root.handleEvent({ type: 'misc', name: 'tick', elapsed }) - this.scribbles.tick(elapsed) } + this.scribbles.tick(elapsed) }) } @@ -8450,6 +8454,7 @@ export class Editor extends EventEmitter { switch (type) { case 'pinch': { if (!this.getInstanceState().canMoveCamera) return + clearTimeout(this._longPressTimeout) this._updateInputsFromEvent(info) switch (info.name) { @@ -8574,6 +8579,7 @@ export class Editor extends EventEmitter { (this.getInstanceState().isCoarsePointer ? COARSE_DRAG_DISTANCE : DRAG_DISTANCE) / this.getZoomLevel() ) { + clearTimeout(this._longPressTimeout) inputs.isDragging = true } } @@ -8591,6 +8597,10 @@ export class Editor extends EventEmitter { case 'pointer_down': { this.clearOpenMenus() + this._longPressTimeout = setTimeout(() => { + this.dispatch({ ...info, name: 'long_press' }) + }, LONG_PRESS_DURATION) + this._selectedShapeIdsAtPointerDown = this.getSelectedShapeIds() // Firefox bug fix... @@ -8659,6 +8669,7 @@ export class Editor extends EventEmitter { (this.getInstanceState().isCoarsePointer ? COARSE_DRAG_DISTANCE : DRAG_DISTANCE) / this.getZoomLevel() ) { + clearTimeout(this._longPressTimeout) inputs.isDragging = true } break @@ -8801,6 +8812,8 @@ export class Editor extends EventEmitter { break } case 'pointer_up': { + clearTimeout(this._longPressTimeout) + const otherEvent = this._clickManager.transformPointerUpEvent(info) if (info.name !== otherEvent.name) { this.root.handleEvent(info) diff --git a/packages/editor/src/lib/editor/tools/StateNode.ts b/packages/editor/src/lib/editor/tools/StateNode.ts index 5c5378b63..f170fe90e 100644 --- a/packages/editor/src/lib/editor/tools/StateNode.ts +++ b/packages/editor/src/lib/editor/tools/StateNode.ts @@ -198,6 +198,7 @@ export abstract class StateNode implements Partial { onWheel?: TLEventHandlers['onWheel'] onPointerDown?: TLEventHandlers['onPointerDown'] onPointerMove?: TLEventHandlers['onPointerMove'] + onLongPress?: TLEventHandlers['onLongPress'] onPointerUp?: TLEventHandlers['onPointerUp'] onDoubleClick?: TLEventHandlers['onDoubleClick'] onTripleClick?: TLEventHandlers['onTripleClick'] diff --git a/packages/editor/src/lib/editor/types/event-types.ts b/packages/editor/src/lib/editor/types/event-types.ts index 5fa41deb8..89ab5725e 100644 --- a/packages/editor/src/lib/editor/types/event-types.ts +++ b/packages/editor/src/lib/editor/types/event-types.ts @@ -16,6 +16,7 @@ export type TLPointerEventTarget = export type TLPointerEventName = | 'pointer_down' | 'pointer_move' + | 'long_press' | 'pointer_up' | 'right_click' | 'middle_click' @@ -152,6 +153,7 @@ export type TLExitEventHandler = (info: any, to: string) => void export interface TLEventHandlers { onPointerDown: TLPointerEvent onPointerMove: TLPointerEvent + onLongPress: TLPointerEvent onRightClick: TLPointerEvent onDoubleClick: TLClickEvent onTripleClick: TLClickEvent @@ -176,6 +178,7 @@ export const EVENT_NAME_MAP: Record< wheel: 'onWheel', pointer_down: 'onPointerDown', pointer_move: 'onPointerMove', + long_press: 'onLongPress', pointer_up: 'onPointerUp', right_click: 'onRightClick', middle_click: 'onMiddleClick', diff --git a/packages/tldraw/src/lib/tools/SelectTool/childStates/PointingCropHandle.ts b/packages/tldraw/src/lib/tools/SelectTool/childStates/PointingCropHandle.ts index c17db4fc2..aefbbdc7d 100644 --- a/packages/tldraw/src/lib/tools/SelectTool/childStates/PointingCropHandle.ts +++ b/packages/tldraw/src/lib/tools/SelectTool/childStates/PointingCropHandle.ts @@ -37,16 +37,23 @@ export class PointingCropHandle extends StateNode { } override onPointerMove: TLEventHandlers['onPointerMove'] = () => { - const isDragging = this.editor.inputs.isDragging - - if (isDragging) { - this.parent.transition('cropping', { - ...this.info, - onInteractionEnd: this.info.onInteractionEnd, - }) + if (this.editor.inputs.isDragging) { + this.startCropping() } } + override onLongPress: TLEventHandlers['onLongPress'] = () => { + this.startCropping() + } + + private startCropping() { + if (this.editor.getInstanceState().isReadonly) return + this.parent.transition('cropping', { + ...this.info, + onInteractionEnd: this.info.onInteractionEnd, + }) + } + override onPointerUp: TLEventHandlers['onPointerUp'] = () => { if (this.info.onInteractionEnd) { this.editor.setCurrentTool(this.info.onInteractionEnd, this.info) diff --git a/packages/tldraw/src/lib/tools/SelectTool/childStates/PointingHandle.ts b/packages/tldraw/src/lib/tools/SelectTool/childStates/PointingHandle.ts index c199dc9e8..2d8220841 100644 --- a/packages/tldraw/src/lib/tools/SelectTool/childStates/PointingHandle.ts +++ b/packages/tldraw/src/lib/tools/SelectTool/childStates/PointingHandle.ts @@ -37,10 +37,19 @@ export class PointingHandle extends StateNode { override onPointerMove: TLEventHandlers['onPointerMove'] = () => { if (this.editor.inputs.isDragging) { - this.parent.transition('dragging_handle', this.info) + this.startDraggingHandle() } } + override onLongPress: TLEventHandlers['onLongPress'] = () => { + this.startDraggingHandle() + } + + private startDraggingHandle() { + if (this.editor.getInstanceState().isReadonly) return + this.parent.transition('dragging_handle', this.info) + } + override onCancel: TLEventHandlers['onCancel'] = () => { this.cancel() } diff --git a/packages/tldraw/src/lib/tools/SelectTool/childStates/PointingResizeHandle.ts b/packages/tldraw/src/lib/tools/SelectTool/childStates/PointingResizeHandle.ts index 477306814..e353d50ea 100644 --- a/packages/tldraw/src/lib/tools/SelectTool/childStates/PointingResizeHandle.ts +++ b/packages/tldraw/src/lib/tools/SelectTool/childStates/PointingResizeHandle.ts @@ -48,13 +48,20 @@ export class PointingResizeHandle extends StateNode { } override onPointerMove: TLEventHandlers['onPointerMove'] = () => { - const isDragging = this.editor.inputs.isDragging - - if (isDragging) { - this.parent.transition('resizing', this.info) + if (this.editor.inputs.isDragging) { + this.startResizing() } } + override onLongPress: TLEventHandlers['onLongPress'] = () => { + this.startResizing() + } + + private startResizing() { + if (this.editor.getInstanceState().isReadonly) return + this.parent.transition('resizing', this.info) + } + override onPointerUp: TLEventHandlers['onPointerUp'] = () => { this.complete() } diff --git a/packages/tldraw/src/lib/tools/SelectTool/childStates/PointingRotateHandle.ts b/packages/tldraw/src/lib/tools/SelectTool/childStates/PointingRotateHandle.ts index 989d1473d..4053a7764 100644 --- a/packages/tldraw/src/lib/tools/SelectTool/childStates/PointingRotateHandle.ts +++ b/packages/tldraw/src/lib/tools/SelectTool/childStates/PointingRotateHandle.ts @@ -33,14 +33,21 @@ export class PointingRotateHandle extends StateNode { ) } - override onPointerMove = () => { - const { isDragging } = this.editor.inputs - - if (isDragging) { - this.parent.transition('rotating', this.info) + override onPointerMove: TLEventHandlers['onPointerMove'] = () => { + if (this.editor.inputs.isDragging) { + this.startRotating() } } + override onLongPress: TLEventHandlers['onLongPress'] = () => { + this.startRotating() + } + + private startRotating() { + if (this.editor.getInstanceState().isReadonly) return + this.parent.transition('rotating', this.info) + } + override onPointerUp = () => { this.complete() } diff --git a/packages/tldraw/src/lib/tools/SelectTool/childStates/PointingSelection.ts b/packages/tldraw/src/lib/tools/SelectTool/childStates/PointingSelection.ts index 14ce18f57..8ac57a1e1 100644 --- a/packages/tldraw/src/lib/tools/SelectTool/childStates/PointingSelection.ts +++ b/packages/tldraw/src/lib/tools/SelectTool/childStates/PointingSelection.ts @@ -25,11 +25,19 @@ export class PointingSelection extends StateNode { override onPointerMove: TLEventHandlers['onPointerMove'] = (info) => { if (this.editor.inputs.isDragging) { - if (this.editor.getInstanceState().isReadonly) return - this.parent.transition('translating', info) + this.startTranslating(info) } } + override onLongPress: TLEventHandlers['onLongPress'] = (info) => { + this.startTranslating(info) + } + + private startTranslating(info: TLPointerEventInfo) { + if (this.editor.getInstanceState().isReadonly) return + this.parent.transition('translating', info) + } + override onDoubleClick?: TLClickEvent | undefined = (info) => { const hoveredShape = this.editor.getHoveredShape() const hitShape = diff --git a/packages/tldraw/src/lib/tools/SelectTool/childStates/PointingShape.ts b/packages/tldraw/src/lib/tools/SelectTool/childStates/PointingShape.ts index 4dad8d5e4..7a08ee826 100644 --- a/packages/tldraw/src/lib/tools/SelectTool/childStates/PointingShape.ts +++ b/packages/tldraw/src/lib/tools/SelectTool/childStates/PointingShape.ts @@ -195,11 +195,19 @@ export class PointingShape extends StateNode { override onPointerMove: TLEventHandlers['onPointerMove'] = (info) => { if (this.editor.inputs.isDragging) { - if (this.editor.getInstanceState().isReadonly) return - this.parent.transition('translating', info) + this.startTranslating(info) } } + override onLongPress: TLEventHandlers['onLongPress'] = (info) => { + this.startTranslating(info) + } + + private startTranslating(info: TLPointerEventInfo) { + if (this.editor.getInstanceState().isReadonly) return + this.parent.transition('translating', info) + } + override onCancel: TLEventHandlers['onCancel'] = () => { this.cancel() } diff --git a/packages/tldraw/src/lib/tools/SelectTool/childStates/ScribbleBrushing.ts b/packages/tldraw/src/lib/tools/SelectTool/childStates/ScribbleBrushing.ts index 4d869e018..d94a953c5 100644 --- a/packages/tldraw/src/lib/tools/SelectTool/childStates/ScribbleBrushing.ts +++ b/packages/tldraw/src/lib/tools/SelectTool/childStates/ScribbleBrushing.ts @@ -42,9 +42,7 @@ export class ScribbleBrushing extends StateNode { this.updateScribbleSelection(true) - requestAnimationFrame(() => { - this.editor.updateInstanceState({ brush: null }) - }) + this.editor.updateInstanceState({ brush: null }) } override onExit = () => { From e8de70ec85a290d785ab9363cdddb30ee379b31e Mon Sep 17 00:00:00 2001 From: Taha <98838967+Taha-Hassan-Git@users.noreply.github.com> Date: Fri, 5 Apr 2024 11:04:38 +0100 Subject: [PATCH 03/26] Examples: update kbd shortcuts, add actions overrides example (#3330) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit I think the keyboard shortcuts example already teaches the concept that the actions overrides example does. I've updated the keyboard shortcuts example and included an action override example in case we want that too. ### Change Type - [ ] `sdk` — Changes the tldraw SDK - [ ] `dotcom` — Changes the tldraw.com web app - [x] `docs` — Changes to the documentation, examples, or templates. - [ ] `vs code` — Changes to the vscode plugin - [ ] `internal` — Does not affect user-facing stuff - [ ] `bugfix` — Bug fix - [ ] `feature` — New feature - [x] `improvement` — Improving existing features - [ ] `chore` — Updating dependencies, other boring stuff - [ ] `galaxy brain` — Architectural changes - [ ] `tests` — Changes to any test code - [ ] `tools` — Changes to infrastructure, CI, internal scripts, debugging tools, etc. - [ ] `dunno` — I don't know ### Test Plan 1. Add a step-by-step description of how to test your PR here. 2. - [ ] Unit Tests - [ ] End to end tests ### Release Notes - Add action overrides example, update keyboard shortcuts example --------- Co-authored-by: Steve Ruiz --- .../ActionOverridesExample.tsx | 27 +++ .../src/examples/action-overrides/README.md | 12 ++ .../keyboard-shortcuts/KeyboardShortcuts.tsx | 16 +- .../src/examples/keyboard-shortcuts/README.md | 10 +- .../examples/keyboard-shortcuts/snapshot.json | 156 ------------------ 5 files changed, 57 insertions(+), 164 deletions(-) create mode 100644 apps/examples/src/examples/action-overrides/ActionOverridesExample.tsx create mode 100644 apps/examples/src/examples/action-overrides/README.md delete mode 100644 apps/examples/src/examples/keyboard-shortcuts/snapshot.json diff --git a/apps/examples/src/examples/action-overrides/ActionOverridesExample.tsx b/apps/examples/src/examples/action-overrides/ActionOverridesExample.tsx new file mode 100644 index 000000000..90d21d1ee --- /dev/null +++ b/apps/examples/src/examples/action-overrides/ActionOverridesExample.tsx @@ -0,0 +1,27 @@ +import { Tldraw } from 'tldraw' +import 'tldraw/tldraw.css' + +export default function BasicExample() { + return ( +
+ { + const newActions = { + ...actions, + delete: { ...actions['delete'], kbd: 'x' }, + } + return newActions + }, + }} + /> +
+ ) +} + +/* +This example shows how you can override tldraw's actions object to change the keyboard shortcuts. +In this case we're changing the delete action's shortcut to 'x'. To customize the actions menu +please see the custom actions menu example. For more information on keyboard shortcuts see the +keyboard shortcuts example. +*/ diff --git a/apps/examples/src/examples/action-overrides/README.md b/apps/examples/src/examples/action-overrides/README.md new file mode 100644 index 000000000..87b182bab --- /dev/null +++ b/apps/examples/src/examples/action-overrides/README.md @@ -0,0 +1,12 @@ +--- +title: Action overrides +component: ./ActionOverridesExample.tsx +category: ui +priority: 2 +--- + +Override tldraw's actions + +--- + +This example shows how you can override tldraw's actions object to change the keyboard shortcuts. In this case we're changing the delete action's shortcut to 'x'. To customize the actions menu please see the custom actions menu example. For more information on keyboard shortcuts see the keyboard shortcuts example. diff --git a/apps/examples/src/examples/keyboard-shortcuts/KeyboardShortcuts.tsx b/apps/examples/src/examples/keyboard-shortcuts/KeyboardShortcuts.tsx index b7c01e4eb..c0b7c1436 100644 --- a/apps/examples/src/examples/keyboard-shortcuts/KeyboardShortcuts.tsx +++ b/apps/examples/src/examples/keyboard-shortcuts/KeyboardShortcuts.tsx @@ -1,6 +1,5 @@ import { TLUiActionsContextType, TLUiOverrides, TLUiToolsContextType, Tldraw } from 'tldraw' import 'tldraw/tldraw.css' -import jsonSnapshot from './snapshot.json' // There's a guide at the bottom of this file! @@ -8,13 +7,18 @@ import jsonSnapshot from './snapshot.json' const overrides: TLUiOverrides = { //[a] actions(_editor, actions): TLUiActionsContextType { - actions['toggle-grid'].kbd = 'x' - return actions + const newActions = { + ...actions, + 'toggle-grid': { ...actions['toggle-grid'], kbd: 'x' }, + 'copy-as-png': { ...actions['copy-as-png'], kbd: '$1' }, + } + + return newActions }, //[b] tools(_editor, tools): TLUiToolsContextType { - tools['draw'].kbd = 'p' - return tools + const newTools = { ...tools, draw: { ...tools.draw, kbd: 'p' } } + return newTools }, } @@ -22,7 +26,7 @@ const overrides: TLUiOverrides = { export default function KeyboardShortcuts() { return (
- +
) } diff --git a/apps/examples/src/examples/keyboard-shortcuts/README.md b/apps/examples/src/examples/keyboard-shortcuts/README.md index f726e110c..bd050528e 100644 --- a/apps/examples/src/examples/keyboard-shortcuts/README.md +++ b/apps/examples/src/examples/keyboard-shortcuts/README.md @@ -5,8 +5,14 @@ category: ui priority: 2 --- -Override default keyboard shortcuts. +How to replace tldraw's default keyboard shortcuts with your own. --- -How to replace tldraw's default keyboard shortcuts with your own. +This example shows how you can replace tldraw's default keyboard shortcuts with your own, +or add a shortcut for an action that doesn't have one. An example of how to add shortcuts +for custom tools can be found in the custom-config example. + +- Toggle show grid by pressing 'x' +- Select the Draw tool by pressing 'p' +- Copy as png by pressing 'ctrl/cmd + 1' diff --git a/apps/examples/src/examples/keyboard-shortcuts/snapshot.json b/apps/examples/src/examples/keyboard-shortcuts/snapshot.json deleted file mode 100644 index 65718fd4b..000000000 --- a/apps/examples/src/examples/keyboard-shortcuts/snapshot.json +++ /dev/null @@ -1,156 +0,0 @@ -{ - "store": { - "asset:2051922215": { - "meta": {}, - "type": "image", - "props": { - "name": "tldrawFile", - "src": "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAA+wAAAJWCAYAAADC/w0vAAAAAXNSR0IArs4c6QAAIABJREFUeF7s3Qe4VMX9//FvghrBqNiNAUzU2LtgwUZRAQvYCyjYRbqoqCCgooINGyhFwQ4oRQSNIiBNBFTsgAIW0MQOJoqaqPn/Pye/NXv3nplzdu/uvQO853nyJHk4e86c18zdPd8zM9/5zY//+vd/jIIAAggggAACCCCAAAIIIIAAAkEJ/IaAPaj2oDIIIIAAAggggAACCCCAAAIIRAIE7HQEBBBAAAEEEEAAAQQQQAABBAIUIGAPsFGoEgIIIIAAAggggAACCCCAAAIE7PQBBBBAAAEEEEAAAQQQQAABBAIUIGAPsFGoEgIIIIAAAggggAACCCCAAAIE7PQBBBBAAAEEEEAAAQQQQAABBAIUIGAPsFGoEgIIIIAAAggggAACCCCAAAIE7PQBBBBAAAEEEEAAAQQQQAABBAIUIGAPsFGoEgIIIIAAAggggAACCCCAAAIE7PQBBBBAAAEEEEAAAQQQQAABBAIUIGAPsFGoEgIIIIAAAggggAACCCCAAAIE7PQBBBBAAAEEEEAAAQQQQAABBAIUIGAPsFGoEgIIIIAAAggggAACCCCAAAIE7PQBBBBAAAEEEEAAAQQQQAABBAIUIGAPsFGoEgIIIIAAAggggAACCCCAAAIE7PQBBBBAAAEEEEAAAQQQQAABBAIUIGAPsFGoEgIIIIAAAggggAACCCCAAAIE7PQBBBBAAAEEEEAAAQQQQAABBAIUIGAPsFGoEgIIIIAAAggggAACCCCAAAIE7PQBBBBAAAEEEEAAAQQQQAABBAIUIGAPsFGoEgIIIIAAAggggAACCCCAAAIE7PQBBBBAAAEEEEAAAQQQQAABBAIUIGAPsFGoEgIIIIAAAggggAACCCCAAAIE7PQBBBBAAAEEEEAAAQQQQAABBAIUIGAPsFGoEgIIIIAAAggggAACCCCAAAIE7PQBBBBAAAEEEEAAAQQQQAABBAIUIGAPsFGoEgIIIIAAAggggAACCCCAAAIE7PQBBBBAAAEEEEAAAQQQQAABBAIUIGAPsFGoEgIIIIAAAggggAACCCCAAAIE7PQBBBBAAAEEEEAAAQQQQAABBAIUIGAPsFGoEgIIIIAAAggggAACCCCAAAIE7PQBBBBAAAEEEEAAAQQQQAABBAIUIGAPsFGoEgIIIIAAAggggAACCCCAAAIE7PQBBBBAAAEEEEAAAQQQQAABBAIUIGAPsFGoEgIIIIAAAggggAACCCCAAAIE7PQBBBBAAAEEEEAAAQQQQAABBAIUIGAPsFGoEgIIIIAAAggggAACCCCAAAIE7PQBBBBAAAEEEEAAAQQQQAABBAIUIGAPsFGoEgIIIIAAAggggAACCCCAAAIE7PQBBBBAAAEEEEAAAQQQQAABBAIUIGAPsFGoEgIIIIAAAggggAACCCCAAAIE7PQBBBBAAAEEEEAAAQQQQAABBAIUIGAPsFGoEgIIIIAAAggggAACCCCAAAIE7PQBBBBAAAEEEEAAAQQQQAABBAIUIGAPsFGoEgIIIIAAAggggAACCCCAAAIE7PQBBBBAAAEEEEAAAQQQQAABBAIUIGAPsFGoEgIIIIAAAggggAACCCCAAAIE7PQBBBBAAAEEEEAAAQQQQAABBAIUIGAPsFGoEgIIIIAAAggggAACCCCAAAIE7PQBBBBAAAEEEEAAAQQQQAABBAIUIGAPsFGoEgIIIIAAAggggAACCCCAAAIE7PQBBBBAAAEEEEAAAQQQQAABBAIUIGAPsFGoEgIIIIAAAggggAACCCCAAAIE7PQBBBBAAAEEEEAAAQQQQAABBAIUIGAPsFGoEgIIIIAAAggggAACCCCAAAIE7PQBBBBAAAEEEEAAAQQQQAABBAIUIGAPsFGoEgIIIIAAAggggAACCCCAAAIE7PQBBBBAAAEEEEAAAQQQQAABBAIUIGAPsFGoEgIIIIAAAggggAACCCCAAAIE7PQBBBBAAAEEEEAAAQQQQAABBAIUIGAPsFGoEgIIIIAAAggggAACCCCAAAIE7PQBBBBAAAEEEEAAAQQQQAABBAIUIGAPsFGoEgIIIIAAAggggAACCCCAAAIE7PQBBBBAAAEEEEAAAQQQQAABBAIUIGAPsFGoEgIIIIAAAggggAACCCCAAAIE7PQBBBBAAAEEEEAAAQQQQAABBAIUIGAPsFGoEgIIIIAAAggggAACCCCAAAIE7PQBBBBAAAEEEEAAAQQQQAABBAIUIGAPsFGoEgIIIIAAAggggAACCCCAAAIE7PQBBBBAAAEEEEAAAQQQQAABBAIUIGAPsFGoEgIIIIAAAggggAACCCCAAAIE7PQBBBBAAAEEEEAAAQQQQAABBAIUIGAPsFGoEgIIIIAAAggggAACCCCAAAIE7PQBBBBAAAEEEEAAAQQQQAABBAIUIGAPsFGoEgIIIIAAAggggAACCCCAAAIE7PQBBFZTgenTp9mIx0bY5MnP284772xtL77Yjj32uNX0bsKr9qOPPGL33XefLV++zP785z9bu/bt7YQTTgyvotQIgQSBf/zjH/af//zHNt5440qz+n7VKhs+fLg9+OAD0TX3P+AAa9u2re222+4lr8M777wdXfv9pUttp513tlatzrTddy/9dUt+Y1wAgRIK/Pzzz/bVV1/ZZpttZtWqVSvhlTg1AgjkK0DAnq8YxyMQgMBbb71lJxzfolxNRowcZfXq1Qughqt3FZYuXWpNjjqy3E3c1v92a9GivPvqfbfUfk0UUJA+dOgQe+jBB+27776LblEv9g4/vIF16NDBqteoUdLbHjDgbrvj9tvLXWPck+Ntjz32KNm1V61aZXvuUT44HzBgoDVt1qxk1+XECKyuAnPmvGS39+9vr776anQLG2ywgR166KF2+uln2CGHHrq63hb1RmCNEiBgX6Oak5tZWwR69expjz32aLnbPeWUU6xvv5vWFoaS3aceXgYOHFDu/Bp5mP3SHEYfSibPiYsh8MMPP9iZZ7ay1197LfZ0CtoHDR5s6667bjEuF3uO00495dcAIPsAzQK64847S3bdWTNn2tlnt4n92502fYZVr169ZNfmxAisbgKTJj1n7S6+2Fnthx951A466KDV7bZWi/p++OGHdtedd5gGYP683XbWrFkza9q0WZV+Rz3//CQbOWKkLVv2kR10UH076qijeGkTSG8iYA+kIagGAvkIHLB/vWjqWm7Ze599bPToMfmcimNjBK679lp76KEHY20ef2K07bvvvrghEKzAxIkTrEvnzt76XXvtddbqzDNLdg977bnHryP7uRd5+50Ftv7665fk2o88/LBdc03v2HOXenS/JDfESREokYCWyTRq2DBa9uUqGm2f/9rrvKQucht8/tlnVr9++Rch55x7rvXocXWRr5budM/+9a/WoUP7cgcPHjzEGh9xRLqTcFTJBAjYS0Zb9SfWl/CUKVPsg/fft2rV1rFq61Sz9dZdz35b7bf2u9/9zjaosYHVqFHD1q9e3TbYoIZVr17D1lt3Xau2zjq2TvSfavbLL/+x77//3lat+s5Wrlhpf//0U/vi88+j///td9/Zzz/9bAcffLAd17y5bbTRRlV/02tBDVasWGH16u4Xe6daa/385ClrgUJpb9EXsN9088120kknl7YCnB2BCghce+019vBDD3nPUMqRbq2F3WnHvzivP3fey9E62VIU11R8XWvIkKHWqHHjUlyWcyKw2gm4gsbcG3n2uedshx3cf8+r3Y0HUOF7Bg60/v1vi63J9Bkz7Y9//GOl1/KM00+zl19+udx1a9euYy9Mm1bp9eGCZQUI2NfQHuGaFliq22129NF2993lpxCX6npr83nnzZ1rLVueEUuw9dZb26wXZ6/NPEW59z7XXfdrsqzcE3bu0sU6duxUlOtwEgRKIXDLzTfb4MGDvKfWevaJTz9Tisvb119/bfvXq+s89+zZL9mWW21VkmvfcMP1NnzYsNhzDxh4jzVt2rQk1+WkCKxuAspzse8+eydWe8jQ+6xRo0aJx3FAegHXskadQbMkNVuyskvDBg2csy0WL1lqv/nNbyq7SlwvS4CAfQ3tDlqTpLVJlVkWLFxk6623XmVecq28lm/KpxLOKfEcpWICN990kw0ZMjj2JB06dLQul1xSsQvwaQRKKDD+ySft0ku7eq/Q4vjj7bbb+pekFq6kjZmL6aWiXi6WonRo386effbZ2FOPGTPW9to7OUApRb04JwIhChxycH379NNPvVWb9Pxk22677UKs/mpbJ9+gwP3DhkXJQSu7NG3SxJYsWRx72dffeNN+//vfV3aVuB4B+5rfB1q1bGlz586p1Bt96+13qjRZRqXebBVe7KqrrrQnHn88tgaaqq0p25SKCdx66y026N57Y09y6aWX2cXt2lXsAnwagRIK/PTTT3b66ac5k87p0qUcxVG2aSWdc5VS/lb4HjqVMHLLLbcsoTynRmD1EnjhhRfsgvPPc1a6YcOGNvS++1evm1oNanvrLTfboEHxs6Buv+NOO+64yt+i98QTjrc333wzVm/GzFm2zTbbrAaya24VGWFfQ9vWN92mFLe855572thxT5bi1JwzR6BF8+amfYbjyiVdu1r79h0wq6CA7+23EsIoMQwFgZAFVq5caXfdeWe55IlKItWv302mZUylKpMnP29tL7oo9vRau6417KUoSWvn31u8xH7729+W4tKcE4EqE9ALui+++MK0Jl2J5GrXqZNXjojp06fZ9X362AcffFDmHo46qoldf8MNtummm1bZva2pF/YNvNw7aJAdeeRRlX7rvtkWpcw7Uuk3uppekIB9NW24pGovW7bMGjWsnCk1egB85NHHSrq3btL9ri3//u9//9t22Xkn5+1W1ZvZ0PyVKPHzKDniKtts001tiy23zGv9VbfLL7exY+Oz7fft289OOfXU0G6Z+iAQK6C/heXLl0e7Smy11VZWu3btkm7npko8PmqUde9+VWx9tEWUtooqRfn73/9uhx5ycOypSchZCnHOmSTwyy+/RDkd9PdXrdpv7Y9/rFXhmYgKzJ8c/6S9MHVq9LcdN6VdS060E0Ta7N4K9JVQeNny5bZOtWpWZ9tt14hA/dtvv40SJ/9+gw2seo0aSc1Vaf9+3rnnml6UxJWq2IlG/XTHv+zgvP9331vMTgGV1jviL0TAXsUNUMrL60t96H1D7a/PPJO4RqnQemikUSO6NWvWLPQUa9znvvnmG1uwYIF99dWX9uWXX9p/ftEb79pWt269Cju99957dnQzd9KkpyZMsF133W2NM/XdkEYXZs6cadrK6t1Fi6IHmO+++67cR1q1amWXXnZ5qt0MTj75JOd0Yt+PqUY53n77bdugRg37+ZdfbPvttitZcq21qpGr8GY//vhje//9pdED94qvV5heUG6++ea24047mrLnUsoL3HbbrXbvPffE0rRs2cqu69OnJGzad15/u3FFo4X3OJa5VLQy2rv4oQcftJdeeinqE5pGrDwXaXZOeeWVVyKrV1552XbddddoZO3sc85hJkBFG6WKPv/JJ5/YhKeein6TPvrow9hnL7086t37mrz2t9bskRkzZkTL4fLJT3TjjX3t1NNOqyKNyr2sBjRmz37Rpk+bbjNnzig3Y0CJ3A477DBr3Lix7bbb7pVbuayrKTjeZ++9nNteTp4y1f70pz9Vav2WLlliTZrEj+qXclZUpd7kan4xAvbVvAHTVl9vGBVMKJj8ZuVKi946/vC9Pffsc6bpi3FF09wPO+zwMv+0zrrrWO1ataMpV/rR2WSTTdJWoVKP04+mtrP74ssvou3o1l13Xdtl111N96T/XeyiL+Bx48ba2DFjvbkDdP0LL7zImjZrVlAVpkyebBdddKHzs8Xe3/jHH3/871S7zz83TbHVaLXaPoQpckqOMm7sOBs9+onYPenjkNRnJ0x8OnEPaN8e0q/Of8023njjMqfX/qU33XRTbIZV5RXocfXVqR7eC+oUfKjoAnrhM3z4MJs4YaIzCY8uqiSPva+51pRxvSqKRsX+9a9/Rdt0hlR8SU+vuPJKu+AC93dYRe7jueees/btLo49RduLL7bLLru8IqeP/axrvX7amQRxa+7Pat06Cugo/xOoaF9XwPvVl1/a5198Ef2m1ahRPXq5olkn1apVqxC1nq+mTJlso594wmbNmpX6XEr6qOSPSUW/veeff543J4XvHNpHPc3Lo6R6FOPf9Qz61ptvRmulf/nPL7bLzrvYTjvvbLVq1arQ6efMecl6Xn11uSDdddLLLu9mbdu2rdA1C/3wokWL7Nhj4pckVdW+948+8oj17t0r9pa0s4Z22KBUrQABe9X6V/nVfYkvOnXqbJ06d67yOqatgB5ctfewAmd9IbqK1m5eccWVFf6ByJxf1+rdq6fpwS1t6XbFFdFD6+LFi61G9er2x1q1Uk3Z9mWIL9ZemXr4mD5tmo0dN9amTonf010/Kvvvv791vfQy22WXXdLedtGOe+ihB017pRdSkgIGvZyof9CBsafOfdOsB7/rrrs2msXiK9rDdsLEiQW/LFKbTJo0ySZOeCp60NFor14+6C28RhU23Ggj26TmJrbV1ltZ3bp1bc8990o97VJ/N/Pnv2qL31scjRrn89nce9asno8++si22nrr6O+rIuuFdY+aUbLhhhtG2Wkra8RBLzB7dO+e+iWQDB57bES0Dc/ixe/Z1lv/Ia/1o/n2YfXPUaNG2vz5823+q69GozT62+/Vu3c0sptbtDxq4cKF9sP330cPxvm+XNDLyNdeey3Km/HZp5/ZZ59/Zt/+89vopZVmGtSqXcvq1z/Ytt12218vfeQRjZ0PzgPvudeaNGmS722nOn7UyJHWo0f32GNdI43//Oc/oxdtP//8SzQjaosttoiWDeS+lHNVoGPHDs6//6Ts2itWrLB6dfeLPXVlrmPVUgK171dffmXy0HfCJptuEllssfkWttnmm0f7Qlf2tk759vVcSPXdV195xcaPH28TJjzlHNHUaGvHTh3tiCOOTNXPsg/67LNPTdObfc8crpPqd3TO3Hne72qdv03rNt4Xh0mVHjFipNXbf3/vYd+vWmULFy2yDz54P/q73nff/aLv3mKVpN/Kc84516686qq8X56oj9x880325LhxeVdV35eff/5F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Lr/cxo4dU5Qb2W233W3kqFEFbwOWphI333STDRky2Hko+RbSKHJMCAKXXtrVNHW8mGWzzTaz5yY9bzVr1izmaTkXAhUWIGCvMCEnqEoBBetnnH66dw1p2vppneq4J8fbdtu5k5lkn+urr76yNq3PKlrQ3qhxYxs0aHCq7KZp74njEEAgnYCydrdp07poQfuVV15l519wQbqLc9RqK/D555+btvn05TFIc3PaTm30mLGm/y5lGTx4kN1y882xl2BksZTynLvYAsoLpKD9maefLtqpS7mdYtEqyYnWSgEC9rWy2desm1YCuttv72/Dhw0r+MZatWplXS+9LEomlU/RD4ZGK27vX/ie6Xqj273H1aaHJfZXzUefYxEoroBewmmpy5PjxhV8Yo2SXnvttVW2f2/BFeeDBQt89NFHdnabNgXP9tJWVN179Mj796eQCs+YMd3OPeec2I8+89dnbccddyzktHwGgSoTePrpiaatCvX9XWjR89fl3a4oWe6IQuvF5xDICBCw0xfWGAHthfzMM0/blMlT7JVXXk4cKVOgvPvue1jHjh0r/HCtlwbPT5pkTz/ztM2dMyfx2kKvXbuOKZO8ElIpgz0FAQTCEFAm7QlPTbApUybbyy+/nKpSWq/e5uw2duqppyUuqUl1Qg5arQR++uknmzhhgj355DibNWtWqroff8IJpmBdiUYrs1zRrVu5LNkkm6vMFuBaxRb4+eef7aXZs23ChAk2b968VC/PNKtSu3h06tzF6tWrV+wqcT4EiipAwF5UTk4WkoAeuj/55G+mTJvfffdttK1ajRobmAJ1bc+U72h6Pvf2+WefmUZd/vntt7Zq1Xf23bffRdffZpttomvXql3bfve73+VzSo5FAIEqENCD4PLly6MtuTJ/y9+t+i5auqKXbtvWqWN/2GYbgvQqaJtQL6nfnL///e+ml8grV6ywr1essL998omt97vfWe3atWzrrf9gu+6yi1WvUaPKbuHlefNsxswZVqd2Hduvbt3US8GqrMJcGIE8BL799ltbumSJrfxmpa36bpV9+9139uMPP0Tbctaps63VqVPbatbcJI8zcigCVStAwF61/lwdAQQQQAABBBBAAAEEEEAAgVgBAnY6BgIIIIAAAggggAACCCCAAAIBChCwB9goVAkBBBBAAAEEEEAAAQQQQAABAnb6AAIIIIAAAggggAACCCCAAAIBChCwB9goVAkBBBBAAAEEEEAAAQQQQAABAnb6AAIIIIAAAggggAACCCCAAAIBChCwB9goVAkBBBBAAAEEEEAAAQQQQAABAnb6AAIIIIAAAggggAACCCCAAAIBChCwB9goVAkBBBBAAAEEEEAAAQQQQAABAnb6AAIIIIAAAggggAACCCCAAAIBChCwB9goVAkBBBBAAAEEEEAAAQQQQAABAnb6AAIIIIAAAggggAACCCCAAAIBChCwB9goVAkBBBBAAAEEEEAAAQQQQAABAnb6AAIIIIAAAggggAACCCCAAAIBChCwB9goVAkBBBBAAAEEEEAAAQQQQAABAnb6AAIIIIAAAggggAACCCCAAAIBChCwB9goVAkBBBBAAAEEEEAAAQQQQAABAnb6AAIIIIAAAggggAACCCCAAAIBChCwB9goVAkBBBBAAAEEEEAAAQQQQAABAnb6AAIIIIAAAggggAACCCCAAAIBChCwB9goVAkBBBBAAAEEEEAAAQQQQAABAnb6AAIIIIAAAggggAACCCCAAAIBChCwB9goVAkBBBBAAAEEEEAAAQQQQAABAnb6AAIIIIAAAggggAACCCCAAAIBChCwB9goVAkBBBBAAAEEEEAAAQQQQAABAnb6AAIIIIAAAggggAACCCCAAAIBChCwB9goVAkBBBBAAAEEEEAAAQQQQAABAnb6AAIIIIAAAggggAACCCCAAAIBChCwB9goVAkBBBBAAAEEEEAAAQQQQAABAnb6AAIIIIAAAggggAACCCCAAAIBChCwB9goVAkBBBBAAAEEEEAAAQQQQAABAnb6AAIIIIAAAggggAACCCCAAAIBChCwB9goVAkBBBBAAAEEEEAAAQQQQAABAnb6AAIIIIAAAggggAACCCCAAAIBChCwB9goVAkBBBBAAAEEEEAAAQQQQAABAnb6AAIIIIAAAggggAACCCCAAAIBChCwB9goVAkBBBBAAAEEEEAAAQQQQAABAnb6AAIIIIAAAggggAACCCCAAAIBChCwB9goVAkBBBBAAAEEEEAAAQQQQAABAnb6AAIIIIAAAggggAACCCCAAAIBChCwB9goVAkBBBBAAAEEEEAAAQQQQAABAnb6AAIIIIAAAggggAACCCCAAAIBChCwB9goVAkBBBBAAAEEEEAAAQQQQAABAnb6AAIIIIAAAggggAACCCCAAAIBChCwB9goVAkBBBBAAAEEEEAAAQQQQAABAnb6AAIIIIAAAggggAACCCCAAAIBChCwB9goVAkBBBBAAAEEEEAAAQQQQAABAnb6AAIIIIAAAggggAACCCCAAAIBChCwB9goVAkBBBBAAAEEEEAAAQQQQAABAnb6AAIIIIAAAggggAACCCCAAAIBChCwB9goVAkBBBBAAAEEEEAAAQQQQAABAnb6AAIIIIAAAggggAACCCCAAAIBChCwB9goVAkBBBBAAAEEEEAAAQQQQAABAnb6AAIIIIAAAggggAACCCCAAAIBChCwB9goVAkBBBBAAAEEEEAAAQQQQAABAnb6AAIIIIAAAggggAACCCCAAAIBChCwB9goVAkBBBBAAAEEEEAAAQQQQAABAnb6AAIIIIAAAggggAACCCCAAAIBChCwB9goVAkBBBBAAAEEEEAAAQQQQAABAnb6AAIIIIAAAggggAACCCCAAAIBChCwB9goVAkBBBBAAAEEEEAAAQQQQAABAnb6AAIIIIAAAggggAACCCCAAAIBChCwB9goVAkBBBBAAAEEEEAAAQQQQAABAnb6AAIIIIAAAggggAACCCCAAAIBChCwB9goVAkBBBBAAAEEEEAAAQQQQAABAnb6AAIIIIAAAggggAACCCCAAAIBChCwB9goVAkBBBBAAAEEEEAAAQQQQAABAnb6AAIIIIAAAggggAACCCCAAAIBChCwB9goVAkBBBBAAAEEEEAAAQQQQAABAnb6AAIIIIAAAggggAACCCCAAAIBChCwB9goVAkBBBBAAAEEEEAAAQQQQAABAnb6AAIIIIAAAggggAACCCCAAAIBChCwB9goVAkBBBBAAAEEEEAAAQQQQAABAnb6AAIIIIAAAggggAACCCCAAAIBChCwB9goVAkBBBBAAAEEEEAAAQQQQAABAnb6AAIIIIAAAggggAACCCCAAAIBChCwB9goVAkBBBBAAAEEEEAAAQQQQAABAnb6AAIIIIAAAggggAACCCCAAAIBChCwB9goVAkBBBBAAAEEEEAAAQQQQAABAnb6AAIIIIAAAggggAACCCCAAAIBChCwB9goVAkBBBBAAAEEEEAAAQQQQAABAnb6AAIIIIAAAggggAACCCCAAAIBChCwB9goVAkBBBBAAAEEEEAAAQQQQAABAnb6AAIIIIAAAggggAACCCCAAAIBChCwB9goVAkBBBBAAAEEEEAAAQQQQAABAnb6AAIIIIAAAggggAACCCCAAAIBChCwB9goVAkBBBBAAAEEEEAAAQQQQAABAnb6AAIIIIAAAggggAACCCCAAAIBChCwB9goVAkBBBBAAAEEEEAAAQQQQAABAnb6AAIIIIAAAggggAACCCCAAAIBChCwB9goVAkBBBBAAAEEEEAAAQQQQAABAnb6AAIIIIAAAggggAACCCCAAAIBChCwB9goVAkBBBBAAAEEEEAAAQQQQAABAnb6AAIIIIAAAggggAACCCCAAAIBChCwB9goVAkBBBBAAAEEEEAAAQQQQAABAnb6AAIIIIAAAggggAACCCCAAAIBChCwB9goVAkBBBBAAAEEEEAAAQQQQAABAnb6AAIIIIAAAggggAACCCCAAAIBChCwB9goVAkBBBBAAAEEEEAAAQQQQAABAnb6AAIIIIAAAggggAACCCCAAAIBChCwB9goVAkBBBBAAAEEEEAAAQQQQAABAnb6AAIIIIAAAggggAACCCCAAAIBChCwB9goVAkBBBBAAAEEEEAAAQQQQAABAnb6AAIIIIAAAggggAACCCCAAAIBChCwB9goVAkBBBBAAAEEEEAAAQQQQAABAnb6AAIIIIAAAggggAACCCCAAAIBChCwB9goVAkBBBBAAAEEEEAAAQQQQAABAnb6AAIIIIAAAggggAACCCCAAAIBChCwB9goVAkBBBBAAAEEEEAAAQQQQAABAnb6AAIIIIAAAggggAACCCCAAAIBChCwB9goVAkBBBBAAAEEEEAAAQQQQAABAnb6AAIIIIAAAggggAACCCCAAAIBChCwB9goVAkBBBBAAAEEEEAAAQQQQAABAnb6AAIIIIAAAggggAACCCCAAAIBChCwB9goVAkBBBBAAAEEEEAAAQQQQAABAnb6AAIIIIAAAggggAACCCCAAAIBChCwB9goVAkBBBBAAAEEEEAAAQQQQAABAnb6AAIIIIAAAggggAACCCCAAAIBChCwB9goVAkBBBBAAAEEEEAAAQQQQAABAnb6AAIIIIAAAggggAACCCCAAAIBChCwB9goVAkBBBBAAAEEEEAAAQQQQAABAnb6AAIIIIAAAggggAACCCCAAAIBChCwB9goVAkBBBBAAAEEEEAAAQQQQAABAnb6AAIIIIAAAggggAACCCCAAAIBChCwB9goVAkBBBBAAAEEEEAAAQQQQAABAnb6AAIIIIAAAggggAACCCCAAAIBChCwB9goVAkBBBBAAAEEEEAAAQQQQAABAnb6AAIIIIAAAggggAACCCCAAAIBChCwB9goVAkBBBBAAAEEEEAAAQQQQAABAnb6AAIIIIAAAggggAACCCCAAAIBChCwB9goVAkBBBBAAAEEEEAAAQQQQAABAnb6AAIIIIAAAggggAACCCCAAAIBChCwB9goVAkBBBBAAAEEEEAAAQQQQAABAnb6AAIIIIAAAggggAACCCCAAAIBChCwB9goVAkBBBBAAAEEEEAAAQQQQAABAnb6AAIIIIAAAggggAACCCCAAAIBChCwB9goVAkBBBBAAAEEEEAAAQQQQAABAnb6AAIIIIAAAggggAACCCCAAAIBChCwB9goVAkBBBBAAAEEEEAAAQQQQAABAnb6AAIIIIAAAggggAACCCCAAAIBChCwB9goVAkBBBBAAAEEEEAAAQQQQAABAnb6AAIIIIAAAggggAACCCCAAAIBChCwB9goVAkBBBBAAAEEEEAAAQQQQAABAnb6AAIIIIAAAggggAACCCCAAAIBChCwB9goVAkBBBBAAAEEEEAAAQQQQAABAnb6AAIIIIAAAggggAACCCCAAAIBChCwB9goVAkBBBBAAAEEEEAAAQQQQAABAnb6AAIIIIAAAggggAACCCCAAAIBChCwB9goVAkBBBBAAAEEEEAAAQQQQAABAnb6AAIIIIAAAggggAACCCCAAAIBChCwB9goVAkBBBBAAAEEEEAAAQQQQAABAnb6AAIIIIAAAggggAACCCCAAAIBChCwB9goVAkBBBBAAAEEEEAAAQQQQAABAnb6AAIIIIAAAggggAACCCCAAAIBChCwB9goVAkBBBBAAAEEEEAAAQQQQAABAnb6AAIIIIAAAggggAACCCCAAAIBChCwB9goVAkBBBBAAAEEEEAAAQQQQAABAnb6AAIIIIAAAggggAACCCCAAAIBChCwB9goVAkBBBBAAAEEEEAAAQQQQAABAnb6AAIIIIAAAggggAACCCCAAAIBChCwB9goVAkBBBBAAAEEEEAAAQQQQAABAnb6AAIIIIAAAggggAACCCCAAAIBChCwB9goVAkBBBBAAAEEEEAAAQQQQAABAnb6AAIIIIAAAggggAACCCCAAAIBChCwB9goVAkBBBBAAAEEEEAAAQQQQAABAnb6AAIIIIAAAggggAACCCCAAAIBChCwB9goVAkBBBBAAAEEEEAAAQQQQAABAnb6AAIIIIAAAggggAACCCCAAAIBChCwB9goVAkBBBBAAAEEEEAAAQQQQAABAnb6AAIIIIAAAggggAACCCCAAAIBChCwB9goVAkBBBBAAAEEEEAAAQQQQAABAnb6AAIIIIAAAggggAACCCCAAAIBChCwB9goVAkBBBBAAAEEEEAAAQQQQAABAnb6AAIIIIAAAggggAACCCCAAAIBChCwB9goVAkBBBBAAAEEEEAAAQQQQAABAnb6AAIIIIAAAggggAACCCCAAAIBChCwB9goVAkBBBBAAAEEEEAAAQQQQAABAnb6AAIIIIAAAggggAACCCCAAAIBChCwB9goVAkBBBBAAAEEEEAAAQQQQAABAnb6AAIId8fKeQAAIABJREFUIIAAAggggAACCCCAAAIBChCwB9goVAkBBBBAAAEEEEAAAQQQQAABAnb6AAIIIIAAAggggAACCCCAAAIBChCwB9goVAkBBBBAAAEEEEAAAQQQQAABAnb6AAIIIIAAAggggAACCCCAAAIBChCwB9goVAkBBBBAAAEEEEAAAQQQQAABAnb6AAIIIIAAAggggAACCCCAAAIBChCwB9goVAkBBBBAAAEEEEAAAQQQQAABAnb6AAIIIIAAAggggAACCCCAAAIBChCwB9goVAkBBBBAAAEEEEAAAQQQQAABAnb6AAIIIIAAAggggAACCCCAAAIBChCwB9goVAkBBBBAAAEEEEAAAQQQQAABAnb6AAIIIIAAAggggAACCCCAAAIBChCwB9goVAkBBBBAAAEEEEAAAQQQQAABAnb6AAIIIIAAAggggAACCCCAAAIBChCwB9goVAkBBBBAAAEEEEAAAQQQQAABAnb6AAIIIIAAAggggAACCCCAAAIBChCwB9goVAkBBBBAAAEEEEAAAQQQQAABAnb6AAIIIIAAAggggAACCCCAAAIBChCwB9goVAkBBBBAAAEEEEAAAQQQQAABAnb6AAIIIIAAAggggAACCCCAAAIBChCwB9goVAkBBBBAAAEEEEAAAQQQQAABAnb6AAIIIIAAAggggAACCCCAAAIBChCwB9goVAkBBBBAAAEEEEAAAQQQQAABAnb6AAIIIIAAAggggAACCCCAAAIBChCwB9goVAkBBBBAAAEEEEAAAQQQQAABAnb6AAIIIIAAAggggAACCCCAAAIBChCwB9goVAkBBBBAAAEEEEAAAQQQQAABAnb6AAIIIIAAAggggAACCCCAAAIBChCwB9goVAkBBBBAAAEEEEAAAQQQQAABAnb6AAIIIIAAAggggAACCCCAAAIBChCwB9goVAkBBBBAAAEEEEAAAQQQQAABAnb6AAIIIIAAAggggAACCCCAAAIBChCwB9goVAkBBBBAAAEEEEAAAQQQQAABAnb6AAIIIIAAAggggAACCCCAAAIBChCwB9goVAkBBBBAAAEEEEAAAQQQQAABAnb6AAIIIIAAAggggAACCCCAAAIBChCwB9goVAkBBBBAAAEEEEAAAQQQQAABAnb6AAIIIIAAAggggAACCCCAAAIBChCwB9goVAkBBBBAAAEEEEAAAQQQQAABAnb6AAIIIIAAAggggAACCCCAAAIBChCwB9goVAkBBBBAAAEEEEAAAQQQQAABAnb6AAIIIIAAAggggAACCCCAAAIBChCwB9goVAkBBBBAAAEEEEAAAQQQQAABAnb6AAIIIIAAAggggAACCCCAAAIBChCwB9goVAkBBBBAAAEEEEAAAQQQQAABAnb6AAIIIIAAAggggAACCCCAAAIBChCwB9goVAkBBBBAAAEEEEAAAQQQQAABAnb6AAIIIIAAAggggAACCCCAAAIBChCwB9goVAkBBBBAAAEEEEAAAQQQQAABAnb6AAIIIIAAAggggAACCCCAAAIBChCwB9goVAkBBBBAAAEEEEAAAQQQQAABAnb6AAIIIIAAAggggAACCCCAAAIBChCwB9goVAkBBBBAAAEEEEAAAQQQQAABAnb6AAIIIIAAAggggAACCCCAAAIBChCwB9goVAkBBBBAAAEEEEAAAQQQQAABAnb6AAIIIIAAAggggAACCCCAAAIBChCwB9goVAkBBBBAAAEEEEAAAQQQQAABAnb6AAIIIIAAAggggAACCCCAAAIBChCwB9goVAkBBBBAAAEEEEAAAQQQQAABAnb6AAIIIIAAAggggAACCCCAAAIBChCwB9goVAkBBBBAAAEEEEAAAQQQQAABAnb6AAIIIIAAAggggAACCCCAAAIBChCwB9goVAkBBBBAAAEEEEAAAQQQQAABAnb6AAIIIIAAAggggAACCCCAAAIBChCwB9goVAkBBBBAAAEEEEAAAQQQQAABAnb6AAIIIIAAAggggAACCCCAAAIBChCwB9goVAkBBBBAAAEEEEAAAQQQQAABAnb6AAIIIIAAAggggAACCCCAAAIBChCwB9goVAkBBBBAAAEEEEAAAQQQQAABAnb6AAIIIIAAAggggAACCCCAAAIBChCwB9goVAkBBBBAAAEEEEAAAQQQQAABAnb6AAIIIIAAAggggAACCCCAAAIBChCwB9goVAkBBBBAAAEEEEAAAQQQQAABAnb6AAIIIIAAAggggAACCCCAAAIBChCwB9goVAkBBBBAAAEEEEAAAQQQQAABAnb6AAIIIIAAAggggAACCCCAAAIBChCwB9goVAkBBBBAAAEEEEAAAQQQQAABAnb6AAIIIIAAAggggAACCCCAAAIBChCwB9goVAkBBBBAAAEEEEAAAQQQQAABAnb6AAIIIIAAAggggAACCCCAAAIBChCwB9goVAkBBBBAAAEEEEAAAQQQQAABAnb6AAIIIIAAAggggAACCCCAAAIBChCwB9goVAkBBBBAAAEEEEAAAQQQQAABAnb6AAIIIIAAAggggAACCCCAAAIBChCwB9goVAkBBBBAAAEEEEAAAQQQQAABAnb6AAIIIIAAAggggAACCCCAAAIBChCwB9goVAkBBBBAAAEEEEAAAQQQQAABAnb6AAIIIIAAAggggAACCCCAAAIBChCwB9goVAkBBBBAAAEEEEAAAQQQQAABAnb6AAIIIIAAAggggAACCCCAAAIBChCwB9goVAkBBBBAAAEEEEAAAQQQQAABAnb6AAIIIIAAAggggAACCCCAAAIBChCwB9goVAkBBBBAAAEEEEAAAQQQQAABAnb6AAIIIIAAAggggAACCCCAAAIBChCwB9goVAkBBBBAAAEEEEAAAQQQQAABAnb6AAIIIIAAAggggAACCCCAAAIBChCwB9goVAkBBBBAAAEEEEAAAQQQQAABAnb6AAIIIIAAAggggAACCCCAAAIBChCwB9goVAkBBBBAAAEEEEAAAQQQQAABAnb6AAIIIIAAAggggAACCCCAAAIBChCwB9goVAkBBBBAAAEEEEAAAQQQQAABAnb6AAIIIIAAAggggAACCCCAAAIBChCwB9goVAkBBBBAAAEEEEAAAQQQQAABAnb6AAIIIIAAAggggAACCCCAAAIBChCwB9goVAkBBBBAAAEEEEAAAQQQQAABAnb6AAIIIIAAAggggAACCCCAAAIBChCwB9goVAkBBBBAAAEEEEAAAQQQQAABAnb6AAIIIIAAAggggAACCCCAAAIBChCwB9goVAkBBBBAAAEEEEAAAQQQQAABAnb6AAIIIIAAAggggAACCCCAAAIBChCwB9goVAkBBBBAAAEEEEAAAQQQQAABAnb6AAIIIIAAAggggAACCCCAAAIBChCwB9goVAkBBBBAAAEEEEAAAQQQQAABAnb6AAIIIIAAAggggAACCCCAAAIBChCwB9goVAkBBBBAAAEEEEAAAQQQQAABAnb6AAIIIIAAAggggAACCCCAAAIBChCwB9goVAkBBBBAAAEEEEAAAQQQQAABAnb6AAIIIIAAAggggAACCCCAAAIBChCwB9goVAkBBBBAAAEEEEAAAQQQQACB/9feWcBdUaV//FGxXQmDXQWDEEGRUEBCQUC6OwQJCWmkSwRpFKRUQOmSDmmQUBFUuiRXBddccVWw3f//N7uXve+8J2bm3nm5L/zO5+NHPu+dOfE9Z2bO85wnKLBzDZAACZAACZAACZAACZAACZAACZBAAhKgwJ6Ak8IukQAJkAAJkAAJkAAJkAAJkAAJkAAFdq4BEiABEiABEiABEiABEiABEiABEkhAAhTYE3BS2CUSIAESIAESIAESIAESIAESIAESoMDONUACJEACJEACJEACJEACJEACJEACCUiAAnsCTgq7RAIkQAIkQAIkQAIkQAIkQAIkQAIU2LkGSIAESIAESIAESIAESIAESIAESCABCVBgT8BJYZdIgARIgARIgARIgARIgARIgARIgAI71wAJkAAJkAAJkAAJkAAJkAAJkAAJJCABCuwJOCnsEgmQAAmQAAmQAAmQAAmQAAmQAAlQYOcaIAESIAESIAESIAESIAESIAESIIEEJECBPQEnhV0iARIgARIgARIgARIgARIgARIgAQrsXAMkQAIkQAIkQAIkQAIkQAIkQAIkkIAEKLAn4KSwSyRAAiRAAiRAAiRAAiRAAiRAAiRAgZ1rgARIgARIgARIgARIgARIgARIgAQSkAAF9gScFHaJBEiABEiABEiABEiABEiABEiABCiwcw2QAAmQAAmQAAmQAAmQAAmQAAmQQAISoMCegJPCLpEACZAACZAACZAACZAACZAACZAABXauARIgARIgARIgARIgARIgARIgARJIQAIU2BNwUtglEiABEiABEiABEiABEiABEiABEqDAzjVAAiRAAiRAAiRAAiRAAiRAAiRAAglIgAJ7Ak4Ku0QCJEACJEACJEACJEACJEACJEACFNi5BkiABEiABEiABEiABEiABEiABEggAQlQYE/ASWGXSIAESIAESIAESIAESIAESIAESIACO9cACZAACZAACZAACZAACZAACZAACSQgAQrsCTgp7BIJkAAJkAAJkAAJkAAJkAAJkAAJUGDnGiABEiABEiABEiABEiABEiABEiCBBCRAgT0BJ4VdIgESIAESIAESIAESIAESIAESIAEK7FwDJEACJEACJEACJEACJEACJEACJJCABCiwJ+CksEskQAIkQAIkQAIkQAIkQAIkQAIkQIGda4AESIAESIAESIAESIAESIAESIAEEpAABfYEnBR2iQRIgARIgARIgARIgARIgARIgAQosHMNkAAJkAAJkAAJkAAJkAAJkAAJkEACEqDAnoCTwi6RAAmQAAmQAAmQAAmQAAmQAAmQAAV2rgESIAESIAESIAESIAESIAESIAESSEACFNgTcFLYJRIgARIgARIgARIgARIgARIgARKgwM41QAIkQAIkQAIkQAIkQAIkQAIkQAIJSIACewJOCrtEAiRAAiRAAiRAAiRAAiRAAiRAAhTYuQZIgARIgARIgARIgARIgARIgARIIAEJUGBPwElhl0iABEiABEiABEiABEiABEiABEiAAjvXAAmQAAmQAAmQAAmQAAmQAAmQAAkkIAEK7Ak4KewSCZAACZAACZAACZAACZAACZAACVBg5xogARIgARIgARIgARIgARIgARIggQQkQIE9ASeFXSIBEiABEiABEiABEiABEiABEiABCuxcAyRAAiRAAiRAAiRAAiRAAiRAAiSQgAQosCfgpLBLJEACJEACJEACJEACJEACJEACJECBnWuABEiABEiABEiABEiABEiABEiABBKQAAX2BJwUdokESIAESIAESIAESIAESIAESIAEKLBzDZAACZAACZAACZAACZAACZAACZBAAhKgwJ6Ak8IukQAJkAAJkAAJkAAJkAAJkAAJkAAFdq4BEiABEiABEiABEiABEiABEiABEkhAAhTYE3BS2CUS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", - "w": 1000, - "h": 595.6175298804781, - "mimeType": "image/png", - "isAnimated": false - }, - "id": "asset:2051922215", - "typeName": "asset" - }, - "document:document": { - "gridSize": 10, - "name": "", - "meta": {}, - "id": "document:document", - "typeName": "document" - }, - "page:NXBP7PKuITv3tMvoIRxFz": { - "meta": {}, - "id": "page:NXBP7PKuITv3tMvoIRxFz", - "name": "Page 1", - "index": "a1", - "typeName": "page" - }, - "shape:0O-D3-H12T9edVMgA0cDh": { - "x": 224.28665129528747, - "y": 474.44055394139673, - "rotation": 0, - "isLocked": false, - "opacity": 1, - "meta": {}, - "type": "text", - "props": { - "color": "black", - "size": "m", - "w": 438.1640625, - "text": "Select the Draw tool by pressing 'p'", - "font": "draw", - "align": "middle", - "autoSize": true, - "scale": 1 - }, - "parentId": "page:NXBP7PKuITv3tMvoIRxFz", - "index": "a2", - "id": "shape:0O-D3-H12T9edVMgA0cDh", - "typeName": "shape" - }, - "shape:tAsz1L8N4kLDwIvpCcjqN": { - "x": 241.59357019335232, - "y": 367.6707932953341, - "rotation": 0, - "isLocked": false, - "opacity": 1, - "meta": {}, - "type": "text", - "props": { - "color": "black", - "size": "m", - "w": 397.3125, - "text": "Toggle show grid by pressing 'x'", - "font": "draw", - "align": "middle", - "autoSize": true, - "scale": 1 - }, - "parentId": "page:NXBP7PKuITv3tMvoIRxFz", - "index": "a1", - "id": "shape:tAsz1L8N4kLDwIvpCcjqN", - "typeName": "shape" - }, - "shape:Jv8vs9T77_Ojum2OGZd7D": { - "x": 204.13270568300845, - "y": 579.9171242617172, - "rotation": 0, - "isLocked": false, - "opacity": 1, - "meta": {}, - "id": "shape:Jv8vs9T77_Ojum2OGZd7D", - "type": "text", - "props": { - "color": "black", - "size": "m", - "w": 462.7421875, - "text": "Copy as png by pressing 'ctrl/cmd + 1'", - "font": "draw", - "align": "middle", - "autoSize": true, - "scale": 1 - }, - "parentId": "page:NXBP7PKuITv3tMvoIRxFz", - "index": "a3", - "typeName": "shape" - } - }, - "schema": { - "schemaVersion": 1, - "storeVersion": 4, - "recordVersions": { - "asset": { - "version": 1, - "subTypeKey": "type", - "subTypeVersions": { - "image": 3, - "video": 3, - "bookmark": 1 - } - }, - "camera": { - "version": 1 - }, - "document": { - "version": 2 - }, - "instance": { - "version": 23 - }, - "instance_page_state": { - "version": 5 - }, - "page": { - "version": 1 - }, - "shape": { - "version": 3, - "subTypeKey": "type", - "subTypeVersions": { - "group": 0, - "text": 1, - "bookmark": 2, - "draw": 1, - "geo": 8, - "note": 5, - "line": 1, - "frame": 0, - "arrow": 2, - "highlight": 0, - "embed": 4, - "image": 3, - "video": 2 - } - }, - "instance_presence": { - "version": 5 - }, - "pointer": { - "version": 1 - } - } - } -} From 4a494a2eaff3d4e5bbf082e59836cf62fc9372f2 Mon Sep 17 00:00:00 2001 From: Steve Ruiz Date: Fri, 5 Apr 2024 12:40:28 +0100 Subject: [PATCH 04/26] Update useFileSystem.tsx (#3371) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This PR makes a small change to how useFileSystem reports errors, so that legitimate errors may be caught. ### Change Type - [x] `sdk` — Changes the tldraw SDK - [x] `feature` — New feature --- apps/dotcom/src/utils/useFileSystem.tsx | 43 ++++++++++++------------- 1 file changed, 21 insertions(+), 22 deletions(-) diff --git a/apps/dotcom/src/utils/useFileSystem.tsx b/apps/dotcom/src/utils/useFileSystem.tsx index c76e1934d..c328bb138 100644 --- a/apps/dotcom/src/utils/useFileSystem.tsx +++ b/apps/dotcom/src/utils/useFileSystem.tsx @@ -108,34 +108,33 @@ export function getSaveFileCopyAction( readonlyOk: true, kbd: '$s', async onSelect(source) { + handleUiEvent('save-project-to-file', { source }) + const documentName = + editor.getDocumentSettings().name === '' + ? defaultDocumentName + : editor.getDocumentSettings().name + const defaultName = + saveFileNames.get(editor.store) || `${documentName}${TLDRAW_FILE_EXTENSION}` + + const blobToSave = serializeTldrawJsonBlob(editor.store) + let handle try { - handleUiEvent('save-project-to-file', { source }) - const documentName = - editor.getDocumentSettings().name === '' - ? defaultDocumentName - : editor.getDocumentSettings().name - const defaultName = - saveFileNames.get(editor.store) || `${documentName}${TLDRAW_FILE_EXTENSION}` - - const blobToSave = serializeTldrawJsonBlob(editor.store) - - const handle = await fileSave(blobToSave, { + handle = await fileSave(blobToSave, { fileName: defaultName, extensions: [TLDRAW_FILE_EXTENSION], description: 'tldraw project', }) - - if (handle) { - // we deliberately don't store the handle for re-use - // next time. we always want to save a copy, but to - // help the user out we'll remember the last name - // they used - saveFileNames.set(editor.store, handle.name) - } else { - throw Error('Could not save file.') - } } catch (e) { - console.error(e) + // user cancelled + return + } + + if (handle) { + // we deliberately don't store the handle for re-use + // next time. we always want to save a copy, but to + // help the user out we'll remember the last name + // they used + saveFileNames.set(editor.store, handle.name) } }, } From f1e0af763184584a3297e7137d745af8567f1895 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mitja=20Bezen=C5=A1ek?= Date: Fri, 5 Apr 2024 15:23:02 +0200 Subject: [PATCH 05/26] Display none for culled shapes (#3291) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Comparing different culling optimizations: https://github.com/tldraw/tldraw/assets/2523721/0b3b8b42-ed70-45b7-bf83-41023c36a563 I think we should go with the `display: none` + showing the skeleteon. The way it works is: - We now add a sibling to the shape wrapper div which serves as the skeleton for the culled shapes. - Only one of the two divs (shape wrapper and skeleton div) is displayed. The other one is using `display: none` to improve performance. ### Change Type - [ ] `sdk` — Changes the tldraw SDK - [ ] `dotcom` — Changes the tldraw.com web app - [ ] `docs` — Changes to the documentation, examples, or templates. - [ ] `vs code` — Changes to the vscode plugin - [x] `internal` — Does not affect user-facing stuff - [ ] `bugfix` — Bug fix - [ ] `feature` — New feature - [x] `improvement` — Improving existing features - [ ] `chore` — Updating dependencies, other boring stuff - [ ] `galaxy brain` — Architectural changes - [ ] `tests` — Changes to any test code - [ ] `tools` — Changes to infrastructure, CI, internal scripts, debugging tools, etc. - [ ] `dunno` — I don't know - Improve performance of culled shapes by using `display: none`. --------- Co-authored-by: Steve Ruiz --- packages/editor/api-report.md | 1 - packages/editor/api/api.json | 35 ---------- packages/editor/editor.css | 9 ++- packages/editor/src/lib/components/Shape.tsx | 70 +++++++++---------- packages/editor/src/lib/editor/Editor.ts | 2 - .../editor/src/lib/editor/shapes/ShapeUtil.ts | 7 -- packages/tldraw/api-report.md | 2 - packages/tldraw/api/api.json | 44 ------------ .../src/lib/shapes/embed/EmbedShapeUtil.tsx | 3 - .../HelperButtons/BackToContent.tsx | 6 +- packages/tlschema/api-report.md | 18 ----- packages/tlschema/api/api.json | 4 +- packages/tlschema/src/shapes/TLEmbedShape.ts | 18 ----- 13 files changed, 43 insertions(+), 176 deletions(-) diff --git a/packages/editor/api-report.md b/packages/editor/api-report.md index 14c0d41c8..8366a85cd 100644 --- a/packages/editor/api-report.md +++ b/packages/editor/api-report.md @@ -1623,7 +1623,6 @@ export abstract class ShapeUtil { canResize: TLShapeUtilFlag; canScroll: TLShapeUtilFlag; canSnap: TLShapeUtilFlag; - canUnmount: TLShapeUtilFlag; abstract component(shape: Shape): any; // (undocumented) editor: Editor; diff --git a/packages/editor/api/api.json b/packages/editor/api/api.json index b7ebe74b8..eeb1401c2 100644 --- a/packages/editor/api/api.json +++ b/packages/editor/api/api.json @@ -30760,41 +30760,6 @@ "isProtected": false, "isAbstract": false }, - { - "kind": "Property", - "canonicalReference": "@tldraw/editor!ShapeUtil#canUnmount:member", - "docComment": "/**\n * Whether the shape should unmount when not visible in the editor. Consider keeping this to false if the shape's `component` has local state.\n *\n * @public\n */\n", - "excerptTokens": [ - { - "kind": "Content", - "text": "canUnmount: " - }, - { - "kind": "Reference", - "text": "TLShapeUtilFlag", - "canonicalReference": "@tldraw/editor!TLShapeUtilFlag:type" - }, - { - "kind": "Content", - "text": "" - }, - { - "kind": "Content", - "text": ";" - } - ], - "isReadonly": false, - "isOptional": false, - "releaseTag": "Public", - "name": "canUnmount", - "propertyTypeTokenRange": { - "startIndex": 1, - "endIndex": 3 - }, - "isStatic": false, - "isProtected": false, - "isAbstract": false - }, { "kind": "Method", "canonicalReference": "@tldraw/editor!ShapeUtil#component:member(1)", diff --git a/packages/editor/editor.css b/packages/editor/editor.css index 7ed918505..4d69cf887 100644 --- a/packages/editor/editor.css +++ b/packages/editor/editor.css @@ -338,10 +338,13 @@ input, } .tl-shape__culled { - position: relative; + position: absolute; + pointer-events: none; + overflow: visible; + transform-origin: top left; + contain: size layout; background-color: var(--color-culled); - width: 100%; - height: 100%; + z-index: 0; } /* ---------------- Shape Containers ---------------- */ diff --git a/packages/editor/src/lib/components/Shape.tsx b/packages/editor/src/lib/components/Shape.tsx index 3762290b3..8c7dedd0b 100644 --- a/packages/editor/src/lib/components/Shape.tsx +++ b/packages/editor/src/lib/components/Shape.tsx @@ -1,12 +1,10 @@ import { useQuickReactor, useStateTracking } from '@tldraw/state' -import { IdOf } from '@tldraw/store' import { TLShape, TLShapeId } from '@tldraw/tlschema' -import { memo, useCallback, useRef } from 'react' +import { memo, useCallback, useLayoutEffect, useRef } from 'react' import { ShapeUtil } from '../editor/shapes/ShapeUtil' import { useEditor } from '../hooks/useEditor' import { useEditorComponents } from '../hooks/useEditorComponents' import { Mat } from '../primitives/Mat' -import { toDomPrecision } from '../primitives/utils' import { setStyleProperty } from '../utils/dom' import { OptionalErrorBoundary } from './ErrorBoundary' @@ -45,6 +43,7 @@ export const Shape = memo(function Shape({ const { ShapeErrorFallback } = useEditorComponents() const containerRef = useRef(null) + const culledContainerRef = useRef(null) const bgContainerRef = useRef(null) const memoizedStuffRef = useRef({ @@ -52,6 +51,8 @@ export const Shape = memo(function Shape({ clipPath: 'none', width: 0, height: 0, + x: 0, + y: 0, }) useQuickReactor( @@ -66,22 +67,31 @@ export const Shape = memo(function Shape({ const clipPath = editor.getShapeClipPath(id) ?? 'none' if (clipPath !== prev.clipPath) { setStyleProperty(containerRef.current, 'clip-path', clipPath) + setStyleProperty(culledContainerRef.current, 'clip-path', clipPath) setStyleProperty(bgContainerRef.current, 'clip-path', clipPath) prev.clipPath = clipPath } // Page transform - const transform = Mat.toCssString(editor.getShapePageTransform(id)) + const pageTransform = editor.getShapePageTransform(id) + const transform = Mat.toCssString(pageTransform) + const bounds = editor.getShapeGeometry(shape).bounds + + // Update if the tranform has changed if (transform !== prev.transform) { setStyleProperty(containerRef.current, 'transform', transform) setStyleProperty(bgContainerRef.current, 'transform', transform) + setStyleProperty( + culledContainerRef.current, + 'transform', + `${Mat.toCssString(pageTransform)} translate(${bounds.x}px, ${bounds.y}px)` + ) prev.transform = transform } // Width / Height // We round the shape width and height up to the nearest multiple of dprMultiple // to avoid the browser making miscalculations when applying the transform. - const bounds = editor.getShapeGeometry(shape).bounds const widthRemainder = bounds.w % dprMultiple const heightRemainder = bounds.h % dprMultiple const width = widthRemainder === 0 ? bounds.w : bounds.w + (dprMultiple - widthRemainder) @@ -90,6 +100,8 @@ export const Shape = memo(function Shape({ if (width !== prev.width || height !== prev.height) { setStyleProperty(containerRef.current, 'width', Math.max(width, dprMultiple) + 'px') setStyleProperty(containerRef.current, 'height', Math.max(height, dprMultiple) + 'px') + setStyleProperty(culledContainerRef.current, 'width', Math.max(width, dprMultiple) + 'px') + setStyleProperty(culledContainerRef.current, 'height', Math.max(height, dprMultiple) + 'px') setStyleProperty(bgContainerRef.current, 'width', Math.max(width, dprMultiple) + 'px') setStyleProperty(bgContainerRef.current, 'height', Math.max(height, dprMultiple) + 'px') prev.width = width @@ -117,6 +129,15 @@ export const Shape = memo(function Shape({ [opacity, index, backgroundIndex] ) + useLayoutEffect(() => { + const container = containerRef.current + const bgContainer = bgContainerRef.current + const culledContainer = culledContainerRef.current + setStyleProperty(container, 'display', isCulled ? 'none' : 'block') + setStyleProperty(bgContainer, 'display', isCulled ? 'none' : 'block') + setStyleProperty(culledContainer, 'display', isCulled ? 'block' : 'none') + }, [isCulled]) + const annotateError = useCallback( (error: any) => editor.annotateError(error, { origin: 'shape', willCrashApp: false }), [editor] @@ -126,6 +147,7 @@ export const Shape = memo(function Shape({ return ( <> +
{util.backgroundComponent && (
- {isCulled ? null : ( - - - - )} + + +
)}
- {isCulled ? ( - - ) : ( - - - - )} + + +
) @@ -172,23 +188,3 @@ const InnerShapeBackground = memo( }, (prev, next) => prev.shape.props === next.shape.props && prev.shape.meta === next.shape.meta ) - -const CulledShape = function CulledShape({ shapeId }: { shapeId: IdOf }) { - const editor = useEditor() - const culledRef = useRef(null) - - useQuickReactor( - 'set shape stuff', - () => { - const bounds = editor.getShapeGeometry(shapeId).bounds - setStyleProperty( - culledRef.current, - 'transform', - `translate(${toDomPrecision(bounds.minX)}px, ${toDomPrecision(bounds.minY)}px)` - ) - }, - [editor] - ) - - return
-} diff --git a/packages/editor/src/lib/editor/Editor.ts b/packages/editor/src/lib/editor/Editor.ts index c05a93365..96fa1729e 100644 --- a/packages/editor/src/lib/editor/Editor.ts +++ b/packages/editor/src/lib/editor/Editor.ts @@ -3160,8 +3160,6 @@ export class Editor extends EventEmitter { isCulled = isCullingOffScreenShapes && - // only cull shapes that allow unmounting, i.e. not stateful components - util.canUnmount(shape) && // never cull editingg shapes editingShapeId !== id && // if the shape is fully outside of its parent's clipping bounds... diff --git a/packages/editor/src/lib/editor/shapes/ShapeUtil.ts b/packages/editor/src/lib/editor/shapes/ShapeUtil.ts index d2c3c9553..fcdd38ce4 100644 --- a/packages/editor/src/lib/editor/shapes/ShapeUtil.ts +++ b/packages/editor/src/lib/editor/shapes/ShapeUtil.ts @@ -89,13 +89,6 @@ export abstract class ShapeUtil { */ canScroll: TLShapeUtilFlag = () => false - /** - * Whether the shape should unmount when not visible in the editor. Consider keeping this to false if the shape's `component` has local state. - * - * @public - */ - canUnmount: TLShapeUtilFlag = () => true - /** * Whether the shape can be bound to by an arrow. * diff --git a/packages/tldraw/api-report.md b/packages/tldraw/api-report.md index 5f232c169..b4a51618c 100644 --- a/packages/tldraw/api-report.md +++ b/packages/tldraw/api-report.md @@ -536,8 +536,6 @@ export class EmbedShapeUtil extends BaseBoxShapeUtil { // (undocumented) canResize: (shape: TLEmbedShape) => boolean; // (undocumented) - canUnmount: TLShapeUtilFlag; - // (undocumented) component(shape: TLEmbedShape): JSX_2.Element; // (undocumented) getDefaultProps(): TLEmbedShape['props']; diff --git a/packages/tldraw/api/api.json b/packages/tldraw/api/api.json index 0d43660f4..103f06b7e 100644 --- a/packages/tldraw/api/api.json +++ b/packages/tldraw/api/api.json @@ -5705,50 +5705,6 @@ "isProtected": false, "isAbstract": false }, - { - "kind": "Property", - "canonicalReference": "tldraw!EmbedShapeUtil#canUnmount:member", - "docComment": "", - "excerptTokens": [ - { - "kind": "Content", - "text": "canUnmount: " - }, - { - "kind": "Reference", - "text": "TLShapeUtilFlag", - "canonicalReference": "@tldraw/editor!TLShapeUtilFlag:type" - }, - { - "kind": "Content", - "text": "<" - }, - { - "kind": "Reference", - "text": "TLEmbedShape", - "canonicalReference": "@tldraw/tlschema!TLEmbedShape:type" - }, - { - "kind": "Content", - "text": ">" - }, - { - "kind": "Content", - "text": ";" - } - ], - "isReadonly": false, - "isOptional": false, - "releaseTag": "Public", - "name": "canUnmount", - "propertyTypeTokenRange": { - "startIndex": 1, - "endIndex": 5 - }, - "isStatic": false, - "isProtected": false, - "isAbstract": false - }, { "kind": "Method", "canonicalReference": "tldraw!EmbedShapeUtil#component:member(1)", diff --git a/packages/tldraw/src/lib/shapes/embed/EmbedShapeUtil.tsx b/packages/tldraw/src/lib/shapes/embed/EmbedShapeUtil.tsx index 80f11a83e..470fb3e0f 100644 --- a/packages/tldraw/src/lib/shapes/embed/EmbedShapeUtil.tsx +++ b/packages/tldraw/src/lib/shapes/embed/EmbedShapeUtil.tsx @@ -34,9 +34,6 @@ export class EmbedShapeUtil extends BaseBoxShapeUtil { override hideSelectionBoundsFg: TLShapeUtilFlag = (shape) => !this.canResize(shape) override canEdit: TLShapeUtilFlag = () => true - override canUnmount: TLShapeUtilFlag = (shape: TLEmbedShape) => { - return !!getEmbedInfo(shape.props.url)?.definition?.canUnmount - } override canResize = (shape: TLEmbedShape) => { return !!getEmbedInfo(shape.props.url)?.definition?.doesResize } diff --git a/packages/tldraw/src/lib/ui/components/HelperButtons/BackToContent.tsx b/packages/tldraw/src/lib/ui/components/HelperButtons/BackToContent.tsx index f2968a86f..89bfd2660 100644 --- a/packages/tldraw/src/lib/ui/components/HelperButtons/BackToContent.tsx +++ b/packages/tldraw/src/lib/ui/components/HelperButtons/BackToContent.tsx @@ -17,10 +17,8 @@ export function BackToContent() { const renderingShapes = editor.getRenderingShapes() const renderingBounds = editor.getRenderingBounds() - // renderingShapes will also include shapes that have the canUnmount flag - // set to true. These shapes will be on the canvas but may not be in the - // viewport... so we also need to narrow down the list to only shapes that - // are ALSO in the viewport. + // Rendering shapes includes all the shapes in the current page. + // We have to filter them down to just the shapes that are inside the renderingBounds. const visibleShapes = renderingShapes.filter( (s) => s.maskedPageBounds && renderingBounds.includes(s.maskedPageBounds) ) diff --git a/packages/tlschema/api-report.md b/packages/tlschema/api-report.md index c272c8d22..d3e74a98a 100644 --- a/packages/tlschema/api-report.md +++ b/packages/tlschema/api-report.md @@ -218,7 +218,6 @@ export const EMBED_DEFINITIONS: readonly [{ readonly width: 720; readonly height: 500; readonly doesResize: true; - readonly canUnmount: true; readonly overridePermissions: { readonly 'allow-top-navigation': true; }; @@ -231,7 +230,6 @@ export const EMBED_DEFINITIONS: readonly [{ readonly width: 720; readonly height: 500; readonly doesResize: true; - readonly canUnmount: true; readonly toEmbedUrl: (url: string) => string | undefined; readonly fromEmbedUrl: (url: string) => string | undefined; }, { @@ -241,7 +239,6 @@ export const EMBED_DEFINITIONS: readonly [{ readonly width: 720; readonly height: 500; readonly doesResize: true; - readonly canUnmount: false; readonly toEmbedUrl: (url: string) => string | undefined; readonly fromEmbedUrl: (url: string) => string | undefined; }, { @@ -253,7 +250,6 @@ export const EMBED_DEFINITIONS: readonly [{ readonly width: 720; readonly height: 500; readonly doesResize: true; - readonly canUnmount: false; readonly toEmbedUrl: (url: string) => string | undefined; readonly fromEmbedUrl: (url: string) => string | undefined; }, { @@ -265,7 +261,6 @@ export const EMBED_DEFINITIONS: readonly [{ readonly width: 720; readonly height: 500; readonly doesResize: true; - readonly canUnmount: false; readonly toEmbedUrl: (url: string) => string | undefined; readonly fromEmbedUrl: (url: string) => string | undefined; }, { @@ -277,7 +272,6 @@ export const EMBED_DEFINITIONS: readonly [{ readonly width: 520; readonly height: 400; readonly doesResize: true; - readonly canUnmount: false; readonly toEmbedUrl: (url: string) => string | undefined; readonly fromEmbedUrl: (url: string) => string | undefined; }, { @@ -287,7 +281,6 @@ export const EMBED_DEFINITIONS: readonly [{ readonly width: 520; readonly height: 400; readonly doesResize: false; - readonly canUnmount: false; readonly toEmbedUrl: (url: string) => string | undefined; readonly fromEmbedUrl: (url: string) => string | undefined; }, { @@ -297,7 +290,6 @@ export const EMBED_DEFINITIONS: readonly [{ readonly width: 800; readonly height: 450; readonly doesResize: true; - readonly canUnmount: false; readonly overridePermissions: { readonly 'allow-presentation': true; }; @@ -313,7 +305,6 @@ export const EMBED_DEFINITIONS: readonly [{ readonly minWidth: 460; readonly minHeight: 360; readonly doesResize: true; - readonly canUnmount: false; readonly instructionLink: "https://support.google.com/calendar/answer/41207?hl=en"; readonly toEmbedUrl: (url: string) => string | undefined; readonly fromEmbedUrl: (url: string) => string | undefined; @@ -326,7 +317,6 @@ export const EMBED_DEFINITIONS: readonly [{ readonly minWidth: 460; readonly minHeight: 360; readonly doesResize: true; - readonly canUnmount: false; readonly toEmbedUrl: (url: string) => string | undefined; readonly fromEmbedUrl: (url: string) => string | undefined; }, { @@ -336,7 +326,6 @@ export const EMBED_DEFINITIONS: readonly [{ readonly width: 720; readonly height: 500; readonly doesResize: true; - readonly canUnmount: true; readonly toEmbedUrl: (url: string) => string | undefined; readonly fromEmbedUrl: (url: string) => string | undefined; }, { @@ -346,7 +335,6 @@ export const EMBED_DEFINITIONS: readonly [{ readonly width: 720; readonly height: 500; readonly doesResize: true; - readonly canUnmount: false; readonly toEmbedUrl: (url: string) => string | undefined; readonly fromEmbedUrl: (url: string) => string | undefined; }, { @@ -356,7 +344,6 @@ export const EMBED_DEFINITIONS: readonly [{ readonly width: 720; readonly height: 500; readonly doesResize: true; - readonly canUnmount: false; readonly toEmbedUrl: (url: string) => string | undefined; readonly fromEmbedUrl: (url: string) => string | undefined; }, { @@ -368,7 +355,6 @@ export const EMBED_DEFINITIONS: readonly [{ readonly minHeight: 500; readonly overrideOutlineRadius: 12; readonly doesResize: true; - readonly canUnmount: false; readonly toEmbedUrl: (url: string) => string | undefined; readonly fromEmbedUrl: (url: string) => string | undefined; }, { @@ -378,7 +364,6 @@ export const EMBED_DEFINITIONS: readonly [{ readonly width: 640; readonly height: 360; readonly doesResize: true; - readonly canUnmount: false; readonly isAspectRatioLocked: true; readonly toEmbedUrl: (url: string) => string | undefined; readonly fromEmbedUrl: (url: string) => string | undefined; @@ -389,7 +374,6 @@ export const EMBED_DEFINITIONS: readonly [{ readonly width: 720; readonly height: 500; readonly doesResize: true; - readonly canUnmount: false; readonly isAspectRatioLocked: true; readonly toEmbedUrl: (url: string) => string | undefined; readonly fromEmbedUrl: (url: string) => string | undefined; @@ -400,7 +384,6 @@ export const EMBED_DEFINITIONS: readonly [{ readonly width: 720; readonly height: 500; readonly doesResize: true; - readonly canUnmount: false; readonly isAspectRatioLocked: false; readonly backgroundColor: "#fff"; readonly toEmbedUrl: (url: string) => string | undefined; @@ -417,7 +400,6 @@ export type EmbedDefinition = { readonly width: number; readonly height: number; readonly doesResize: boolean; - readonly canUnmount: boolean; readonly isAspectRatioLocked?: boolean; readonly overridePermissions?: TLEmbedShapePermissions; readonly instructionLink?: string; diff --git a/packages/tlschema/api/api.json b/packages/tlschema/api/api.json index 3ecd1144d..30ee2f5f4 100644 --- a/packages/tlschema/api/api.json +++ b/packages/tlschema/api/api.json @@ -1801,7 +1801,7 @@ }, { "kind": "Content", - "text": "readonly [{\n readonly type: \"tldraw\";\n readonly title: \"tldraw\";\n readonly hostnames: readonly [\"beta.tldraw.com\", \"tldraw.com\", \"localhost:3000\"];\n readonly minWidth: 300;\n readonly minHeight: 300;\n readonly width: 720;\n readonly height: 500;\n readonly doesResize: true;\n readonly canUnmount: true;\n readonly overridePermissions: {\n readonly 'allow-top-navigation': true;\n };\n readonly toEmbedUrl: (url: string) => string | undefined;\n readonly fromEmbedUrl: (url: string) => string | undefined;\n}, {\n readonly type: \"figma\";\n readonly title: \"Figma\";\n readonly hostnames: readonly [\"figma.com\"];\n readonly width: 720;\n readonly height: 500;\n readonly doesResize: true;\n readonly canUnmount: true;\n readonly toEmbedUrl: (url: string) => string | undefined;\n readonly fromEmbedUrl: (url: string) => string | undefined;\n}, {\n readonly type: \"google_maps\";\n readonly title: \"Google Maps\";\n readonly hostnames: readonly [\"google.*\"];\n readonly width: 720;\n readonly height: 500;\n readonly doesResize: true;\n readonly canUnmount: false;\n readonly toEmbedUrl: (url: string) => string | undefined;\n readonly fromEmbedUrl: (url: string) => string | undefined;\n}, {\n readonly type: \"val_town\";\n readonly title: \"Val Town\";\n readonly hostnames: readonly [\"val.town\"];\n readonly minWidth: 260;\n readonly minHeight: 100;\n readonly width: 720;\n readonly height: 500;\n readonly doesResize: true;\n readonly canUnmount: false;\n readonly toEmbedUrl: (url: string) => string | undefined;\n readonly fromEmbedUrl: (url: string) => string | undefined;\n}, {\n readonly type: \"codesandbox\";\n readonly title: \"CodeSandbox\";\n readonly hostnames: readonly [\"codesandbox.io\"];\n readonly minWidth: 300;\n readonly minHeight: 300;\n readonly width: 720;\n readonly height: 500;\n readonly doesResize: true;\n readonly canUnmount: false;\n readonly toEmbedUrl: (url: string) => string | undefined;\n readonly fromEmbedUrl: (url: string) => string | undefined;\n}, {\n readonly type: \"codepen\";\n readonly title: \"Codepen\";\n readonly hostnames: readonly [\"codepen.io\"];\n readonly minWidth: 300;\n readonly minHeight: 300;\n readonly width: 520;\n readonly height: 400;\n readonly doesResize: true;\n readonly canUnmount: false;\n readonly toEmbedUrl: (url: string) => string | undefined;\n readonly fromEmbedUrl: (url: string) => string | undefined;\n}, {\n readonly type: \"scratch\";\n readonly title: \"Scratch\";\n readonly hostnames: readonly [\"scratch.mit.edu\"];\n readonly width: 520;\n readonly height: 400;\n readonly doesResize: false;\n readonly canUnmount: false;\n readonly toEmbedUrl: (url: string) => string | undefined;\n readonly fromEmbedUrl: (url: string) => string | undefined;\n}, {\n readonly type: \"youtube\";\n readonly title: \"YouTube\";\n readonly hostnames: readonly [\"*.youtube.com\", \"youtube.com\", \"youtu.be\"];\n readonly width: 800;\n readonly height: 450;\n readonly doesResize: true;\n readonly canUnmount: false;\n readonly overridePermissions: {\n readonly 'allow-presentation': true;\n };\n readonly isAspectRatioLocked: true;\n readonly toEmbedUrl: (url: string) => string | undefined;\n readonly fromEmbedUrl: (url: string) => string | undefined;\n}, {\n readonly type: \"google_calendar\";\n readonly title: \"Google Calendar\";\n readonly hostnames: readonly [\"calendar.google.*\"];\n readonly width: 720;\n readonly height: 500;\n readonly minWidth: 460;\n readonly minHeight: 360;\n readonly doesResize: true;\n readonly canUnmount: false;\n readonly instructionLink: \"https://support.google.com/calendar/answer/41207?hl=en\";\n readonly toEmbedUrl: (url: string) => string | undefined;\n readonly fromEmbedUrl: (url: string) => string | undefined;\n}, {\n readonly type: \"google_slides\";\n readonly title: \"Google Slides\";\n readonly hostnames: readonly [\"docs.google.*\"];\n readonly width: 720;\n readonly height: 500;\n readonly minWidth: 460;\n readonly minHeight: 360;\n readonly doesResize: true;\n readonly canUnmount: false;\n readonly toEmbedUrl: (url: string) => string | undefined;\n readonly fromEmbedUrl: (url: string) => string | undefined;\n}, {\n readonly type: \"github_gist\";\n readonly title: \"GitHub Gist\";\n readonly hostnames: readonly [\"gist.github.com\"];\n readonly width: 720;\n readonly height: 500;\n readonly doesResize: true;\n readonly canUnmount: true;\n readonly toEmbedUrl: (url: string) => string | undefined;\n readonly fromEmbedUrl: (url: string) => string | undefined;\n}, {\n readonly type: \"replit\";\n readonly title: \"Replit\";\n readonly hostnames: readonly [\"replit.com\"];\n readonly width: 720;\n readonly height: 500;\n readonly doesResize: true;\n readonly canUnmount: false;\n readonly toEmbedUrl: (url: string) => string | undefined;\n readonly fromEmbedUrl: (url: string) => string | undefined;\n}, {\n readonly type: \"felt\";\n readonly title: \"Felt\";\n readonly hostnames: readonly [\"felt.com\"];\n readonly width: 720;\n readonly height: 500;\n readonly doesResize: true;\n readonly canUnmount: false;\n readonly toEmbedUrl: (url: string) => string | undefined;\n readonly fromEmbedUrl: (url: string) => string | undefined;\n}, {\n readonly type: \"spotify\";\n readonly title: \"Spotify\";\n readonly hostnames: readonly [\"open.spotify.com\"];\n readonly width: 720;\n readonly height: 500;\n readonly minHeight: 500;\n readonly overrideOutlineRadius: 12;\n readonly doesResize: true;\n readonly canUnmount: false;\n readonly toEmbedUrl: (url: string) => string | undefined;\n readonly fromEmbedUrl: (url: string) => string | undefined;\n}, {\n readonly type: \"vimeo\";\n readonly title: \"Vimeo\";\n readonly hostnames: readonly [\"vimeo.com\", \"player.vimeo.com\"];\n readonly width: 640;\n readonly height: 360;\n readonly doesResize: true;\n readonly canUnmount: false;\n readonly isAspectRatioLocked: true;\n readonly toEmbedUrl: (url: string) => string | undefined;\n readonly fromEmbedUrl: (url: string) => string | undefined;\n}, {\n readonly type: \"excalidraw\";\n readonly title: \"Excalidraw\";\n readonly hostnames: readonly [\"excalidraw.com\"];\n readonly width: 720;\n readonly height: 500;\n readonly doesResize: true;\n readonly canUnmount: false;\n readonly isAspectRatioLocked: true;\n readonly toEmbedUrl: (url: string) => string | undefined;\n readonly fromEmbedUrl: (url: string) => string | undefined;\n}, {\n readonly type: \"observable\";\n readonly title: \"Observable\";\n readonly hostnames: readonly [\"observablehq.com\"];\n readonly width: 720;\n readonly height: 500;\n readonly doesResize: true;\n readonly canUnmount: false;\n readonly isAspectRatioLocked: false;\n readonly backgroundColor: \"#fff\";\n readonly toEmbedUrl: (url: string) => string | undefined;\n readonly fromEmbedUrl: (url: string) => string | undefined;\n}]" + "text": "readonly [{\n readonly type: \"tldraw\";\n readonly title: \"tldraw\";\n readonly hostnames: readonly [\"beta.tldraw.com\", \"tldraw.com\", \"localhost:3000\"];\n readonly minWidth: 300;\n readonly minHeight: 300;\n readonly width: 720;\n readonly height: 500;\n readonly doesResize: true;\n readonly overridePermissions: {\n readonly 'allow-top-navigation': true;\n };\n readonly toEmbedUrl: (url: string) => string | undefined;\n readonly fromEmbedUrl: (url: string) => string | undefined;\n}, {\n readonly type: \"figma\";\n readonly title: \"Figma\";\n readonly hostnames: readonly [\"figma.com\"];\n readonly width: 720;\n readonly height: 500;\n readonly doesResize: true;\n readonly toEmbedUrl: (url: string) => string | undefined;\n readonly fromEmbedUrl: (url: string) => string | undefined;\n}, {\n readonly type: \"google_maps\";\n readonly title: \"Google Maps\";\n readonly hostnames: readonly [\"google.*\"];\n readonly width: 720;\n readonly height: 500;\n readonly doesResize: true;\n readonly toEmbedUrl: (url: string) => string | undefined;\n readonly fromEmbedUrl: (url: string) => string | undefined;\n}, {\n readonly type: \"val_town\";\n readonly title: \"Val Town\";\n readonly hostnames: readonly [\"val.town\"];\n readonly minWidth: 260;\n readonly minHeight: 100;\n readonly width: 720;\n readonly height: 500;\n readonly doesResize: true;\n readonly toEmbedUrl: (url: string) => string | undefined;\n readonly fromEmbedUrl: (url: string) => string | undefined;\n}, {\n readonly type: \"codesandbox\";\n readonly title: \"CodeSandbox\";\n readonly hostnames: readonly [\"codesandbox.io\"];\n readonly minWidth: 300;\n readonly minHeight: 300;\n readonly width: 720;\n readonly height: 500;\n readonly doesResize: true;\n readonly toEmbedUrl: (url: string) => string | undefined;\n readonly fromEmbedUrl: (url: string) => string | undefined;\n}, {\n readonly type: \"codepen\";\n readonly title: \"Codepen\";\n readonly hostnames: readonly [\"codepen.io\"];\n readonly minWidth: 300;\n readonly minHeight: 300;\n readonly width: 520;\n readonly height: 400;\n readonly doesResize: true;\n readonly toEmbedUrl: (url: string) => string | undefined;\n readonly fromEmbedUrl: (url: string) => string | undefined;\n}, {\n readonly type: \"scratch\";\n readonly title: \"Scratch\";\n readonly hostnames: readonly [\"scratch.mit.edu\"];\n readonly width: 520;\n readonly height: 400;\n readonly doesResize: false;\n readonly toEmbedUrl: (url: string) => string | undefined;\n readonly fromEmbedUrl: (url: string) => string | undefined;\n}, {\n readonly type: \"youtube\";\n readonly title: \"YouTube\";\n readonly hostnames: readonly [\"*.youtube.com\", \"youtube.com\", \"youtu.be\"];\n readonly width: 800;\n readonly height: 450;\n readonly doesResize: true;\n readonly overridePermissions: {\n readonly 'allow-presentation': true;\n };\n readonly isAspectRatioLocked: true;\n readonly toEmbedUrl: (url: string) => string | undefined;\n readonly fromEmbedUrl: (url: string) => string | undefined;\n}, {\n readonly type: \"google_calendar\";\n readonly title: \"Google Calendar\";\n readonly hostnames: readonly [\"calendar.google.*\"];\n readonly width: 720;\n readonly height: 500;\n readonly minWidth: 460;\n readonly minHeight: 360;\n readonly doesResize: true;\n readonly instructionLink: \"https://support.google.com/calendar/answer/41207?hl=en\";\n readonly toEmbedUrl: (url: string) => string | undefined;\n readonly fromEmbedUrl: (url: string) => string | undefined;\n}, {\n readonly type: \"google_slides\";\n readonly title: \"Google Slides\";\n readonly hostnames: readonly [\"docs.google.*\"];\n readonly width: 720;\n readonly height: 500;\n readonly minWidth: 460;\n readonly minHeight: 360;\n readonly doesResize: true;\n readonly toEmbedUrl: (url: string) => string | undefined;\n readonly fromEmbedUrl: (url: string) => string | undefined;\n}, {\n readonly type: \"github_gist\";\n readonly title: \"GitHub Gist\";\n readonly hostnames: readonly [\"gist.github.com\"];\n readonly width: 720;\n readonly height: 500;\n readonly doesResize: true;\n readonly toEmbedUrl: (url: string) => string | undefined;\n readonly fromEmbedUrl: (url: string) => string | undefined;\n}, {\n readonly type: \"replit\";\n readonly title: \"Replit\";\n readonly hostnames: readonly [\"replit.com\"];\n readonly width: 720;\n readonly height: 500;\n readonly doesResize: true;\n readonly toEmbedUrl: (url: string) => string | undefined;\n readonly fromEmbedUrl: (url: string) => string | undefined;\n}, {\n readonly type: \"felt\";\n readonly title: \"Felt\";\n readonly hostnames: readonly [\"felt.com\"];\n readonly width: 720;\n readonly height: 500;\n readonly doesResize: true;\n readonly toEmbedUrl: (url: string) => string | undefined;\n readonly fromEmbedUrl: (url: string) => string | undefined;\n}, {\n readonly type: \"spotify\";\n readonly title: \"Spotify\";\n readonly hostnames: readonly [\"open.spotify.com\"];\n readonly width: 720;\n readonly height: 500;\n readonly minHeight: 500;\n readonly overrideOutlineRadius: 12;\n readonly doesResize: true;\n readonly toEmbedUrl: (url: string) => string | undefined;\n readonly fromEmbedUrl: (url: string) => string | undefined;\n}, {\n readonly type: \"vimeo\";\n readonly title: \"Vimeo\";\n readonly hostnames: readonly [\"vimeo.com\", \"player.vimeo.com\"];\n readonly width: 640;\n readonly height: 360;\n readonly doesResize: true;\n readonly isAspectRatioLocked: true;\n readonly toEmbedUrl: (url: string) => string | undefined;\n readonly fromEmbedUrl: (url: string) => string | undefined;\n}, {\n readonly type: \"excalidraw\";\n readonly title: \"Excalidraw\";\n readonly hostnames: readonly [\"excalidraw.com\"];\n readonly width: 720;\n readonly height: 500;\n readonly doesResize: true;\n readonly isAspectRatioLocked: true;\n readonly toEmbedUrl: (url: string) => string | undefined;\n readonly fromEmbedUrl: (url: string) => string | undefined;\n}, {\n readonly type: \"observable\";\n readonly title: \"Observable\";\n readonly hostnames: readonly [\"observablehq.com\"];\n readonly width: 720;\n readonly height: 500;\n readonly doesResize: true;\n readonly isAspectRatioLocked: false;\n readonly backgroundColor: \"#fff\";\n readonly toEmbedUrl: (url: string) => string | undefined;\n readonly fromEmbedUrl: (url: string) => string | undefined;\n}]" } ], "fileUrlPath": "packages/tlschema/src/shapes/TLEmbedShape.ts", @@ -1824,7 +1824,7 @@ }, { "kind": "Content", - "text": "{\n readonly type: string;\n readonly title: string;\n readonly hostnames: readonly string[];\n readonly minWidth?: number;\n readonly minHeight?: number;\n readonly width: number;\n readonly height: number;\n readonly doesResize: boolean;\n readonly canUnmount: boolean;\n readonly isAspectRatioLocked?: boolean;\n readonly overridePermissions?: " + "text": "{\n readonly type: string;\n readonly title: string;\n readonly hostnames: readonly string[];\n readonly minWidth?: number;\n readonly minHeight?: number;\n readonly width: number;\n readonly height: number;\n readonly doesResize: boolean;\n readonly isAspectRatioLocked?: boolean;\n readonly overridePermissions?: " }, { "kind": "Reference", diff --git a/packages/tlschema/src/shapes/TLEmbedShape.ts b/packages/tlschema/src/shapes/TLEmbedShape.ts index 6037f2aad..416ab83a3 100644 --- a/packages/tlschema/src/shapes/TLEmbedShape.ts +++ b/packages/tlschema/src/shapes/TLEmbedShape.ts @@ -24,7 +24,6 @@ export const EMBED_DEFINITIONS = [ width: 720, height: 500, doesResize: true, - canUnmount: true, overridePermissions: { 'allow-top-navigation': true, }, @@ -50,7 +49,6 @@ export const EMBED_DEFINITIONS = [ width: 720, height: 500, doesResize: true, - canUnmount: true, toEmbedUrl: (url) => { if ( !!url.match( @@ -81,7 +79,6 @@ export const EMBED_DEFINITIONS = [ width: 720, height: 500, doesResize: true, - canUnmount: false, toEmbedUrl: (url) => { if (url.includes('/maps/')) { const match = url.match(/@(.*),(.*),(.*)z/) @@ -120,7 +117,6 @@ export const EMBED_DEFINITIONS = [ width: 720, height: 500, doesResize: true, - canUnmount: false, toEmbedUrl: (url) => { const urlObj = safeParseUrl(url) // e.g. extract "steveruizok.mathFact" from https://www.val.town/v/steveruizok.mathFact @@ -149,7 +145,6 @@ export const EMBED_DEFINITIONS = [ width: 720, height: 500, doesResize: true, - canUnmount: false, toEmbedUrl: (url) => { const urlObj = safeParseUrl(url) const matches = urlObj && urlObj.pathname.match(/\/s\/([^/]+)\/?/) @@ -176,7 +171,6 @@ export const EMBED_DEFINITIONS = [ width: 520, height: 400, doesResize: true, - canUnmount: false, toEmbedUrl: (url) => { const CODEPEN_URL_REGEXP = /https:\/\/codepen.io\/([^/]+)\/pen\/([^/]+)/ const matches = url.match(CODEPEN_URL_REGEXP) @@ -203,7 +197,6 @@ export const EMBED_DEFINITIONS = [ width: 520, height: 400, doesResize: false, - canUnmount: false, toEmbedUrl: (url) => { const SCRATCH_URL_REGEXP = /https?:\/\/scratch.mit.edu\/projects\/([^/]+)/ const matches = url.match(SCRATCH_URL_REGEXP) @@ -230,7 +223,6 @@ export const EMBED_DEFINITIONS = [ width: 800, height: 450, doesResize: true, - canUnmount: false, overridePermissions: { 'allow-presentation': true, }, @@ -275,7 +267,6 @@ export const EMBED_DEFINITIONS = [ minWidth: 460, minHeight: 360, doesResize: true, - canUnmount: false, instructionLink: 'https://support.google.com/calendar/answer/41207?hl=en', toEmbedUrl: (url) => { const urlObj = safeParseUrl(url) @@ -318,7 +309,6 @@ export const EMBED_DEFINITIONS = [ minWidth: 460, minHeight: 360, doesResize: true, - canUnmount: false, toEmbedUrl: (url) => { const urlObj = safeParseUrl(url) @@ -353,7 +343,6 @@ export const EMBED_DEFINITIONS = [ width: 720, height: 500, doesResize: true, - canUnmount: true, toEmbedUrl: (url) => { const urlObj = safeParseUrl(url) if (urlObj && urlObj.pathname.match(/\/([^/]+)\/([^/]+)/)) { @@ -378,7 +367,6 @@ export const EMBED_DEFINITIONS = [ width: 720, height: 500, doesResize: true, - canUnmount: false, toEmbedUrl: (url) => { const urlObj = safeParseUrl(url) if (urlObj && urlObj.pathname.match(/\/@([^/]+)\/([^/]+)/)) { @@ -406,7 +394,6 @@ export const EMBED_DEFINITIONS = [ width: 720, height: 500, doesResize: true, - canUnmount: false, toEmbedUrl: (url) => { const urlObj = safeParseUrl(url) if (urlObj && urlObj.pathname.match(/^\/map\//)) { @@ -432,7 +419,6 @@ export const EMBED_DEFINITIONS = [ minHeight: 500, overrideOutlineRadius: 12, doesResize: true, - canUnmount: false, toEmbedUrl: (url) => { const urlObj = safeParseUrl(url) if (urlObj && urlObj.pathname.match(/^\/(artist|album)\//)) { @@ -455,7 +441,6 @@ export const EMBED_DEFINITIONS = [ width: 640, height: 360, doesResize: true, - canUnmount: false, isAspectRatioLocked: true, toEmbedUrl: (url) => { const urlObj = safeParseUrl(url) @@ -486,7 +471,6 @@ export const EMBED_DEFINITIONS = [ width: 720, height: 500, doesResize: true, - canUnmount: false, isAspectRatioLocked: true, toEmbedUrl: (url) => { const urlObj = safeParseUrl(url) @@ -510,7 +494,6 @@ export const EMBED_DEFINITIONS = [ width: 720, height: 500, doesResize: true, - canUnmount: false, isAspectRatioLocked: false, backgroundColor: '#fff', toEmbedUrl: (url) => { @@ -619,7 +602,6 @@ export type EmbedDefinition = { readonly width: number readonly height: number readonly doesResize: boolean - readonly canUnmount: boolean readonly isAspectRatioLocked?: boolean readonly overridePermissions?: TLEmbedShapePermissions readonly instructionLink?: string From 4d32a38cf8ff34304c01b660e491b220e3b1bd9a Mon Sep 17 00:00:00 2001 From: Steve Ruiz Date: Fri, 5 Apr 2024 17:02:11 +0100 Subject: [PATCH 06/26] put `getCurrentPageId` into a computed (#3378) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This PR makes the `getCurrentPageId` method use a computed. Previously, anything that referenced the current page id would pick up any change to instance state. This will help a bunch of interactions like brushing that would update the instance state on every frame. ### Change Type - [x] `sdk` — Changes the tldraw SDK - [x] `improvement` — Improving existing features --- packages/editor/src/lib/editor/Editor.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/editor/src/lib/editor/Editor.ts b/packages/editor/src/lib/editor/Editor.ts index 96fa1729e..7c635e66f 100644 --- a/packages/editor/src/lib/editor/Editor.ts +++ b/packages/editor/src/lib/editor/Editor.ts @@ -3325,7 +3325,7 @@ export class Editor extends EventEmitter { * * @public */ - getCurrentPageId(): TLPageId { + @computed getCurrentPageId(): TLPageId { return this.getInstanceState().currentPageId } From 97b5e4093abd0f0e4ff09932bc15e4b5b94239a6 Mon Sep 17 00:00:00 2001 From: Steve Ruiz Date: Fri, 5 Apr 2024 19:03:22 +0100 Subject: [PATCH 07/26] [culling] minimal culled diff with webgl (#3377) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This PR extracts the #3344 changes to a smaller diff against main. It does not include the changes to how / where culled shapes are calculated, though I understand this could be much more efficiently done! ### Change Type - [x] `sdk` — Changes the tldraw SDK - [x] `improvement` — Improving existing features --------- Co-authored-by: Mitja Bezenšek --- packages/editor/editor.css | 20 +- .../src/lib/components/CulledShapes.tsx | 178 ++++++++++++++++++ packages/editor/src/lib/components/Shape.tsx | 12 -- .../default-components/DefaultCanvas.tsx | 13 +- 4 files changed, 207 insertions(+), 16 deletions(-) create mode 100644 packages/editor/src/lib/components/CulledShapes.tsx diff --git a/packages/editor/editor.css b/packages/editor/editor.css index 4d69cf887..f4cd8dcf6 100644 --- a/packages/editor/editor.css +++ b/packages/editor/editor.css @@ -24,6 +24,7 @@ /* Z Index */ --layer-background: 100; --layer-grid: 150; + --layer-culled-shapes: 175; --layer-canvas: 200; --layer-shapes: 300; --layer-overlays: 400; @@ -236,6 +237,20 @@ input, contain: strict; } +.tl-culled-shapes { + width: 100%; + height: 100%; + z-index: var(--layer-culled-shapes); + position: absolute; + pointer-events: none; + contain: size layout; +} + +.tl-culled-shapes__canvas { + width: 100%; + height: 100%; +} + .tl-shapes { position: relative; z-index: var(--layer-shapes); @@ -269,13 +284,16 @@ input, /* ------------------- Background ------------------- */ +.tl-background__wrapper { + z-index: var(--layer-background); +} + .tl-background { position: absolute; background-color: var(--color-background); inset: 0px; height: 100%; width: 100%; - z-index: var(--layer-background); } /* --------------------- Grid Layer --------------------- */ diff --git a/packages/editor/src/lib/components/CulledShapes.tsx b/packages/editor/src/lib/components/CulledShapes.tsx new file mode 100644 index 000000000..5a6020621 --- /dev/null +++ b/packages/editor/src/lib/components/CulledShapes.tsx @@ -0,0 +1,178 @@ +import { computed, react } from '@tldraw/state' +import { useEffect, useRef } from 'react' +import { useEditor } from '../hooks/useEditor' +import { useIsDarkMode } from '../hooks/useIsDarkMode' + +// Parts of the below code are taken from MIT licensed project: +// https://github.com/sessamekesh/webgl-tutorials-2023 +function setupWebGl(canvas: HTMLCanvasElement | null, isDarkMode: boolean) { + if (!canvas) return + + const context = canvas.getContext('webgl2') + if (!context) return + + const vertexShaderSourceCode = `#version 300 es + precision mediump float; + + in vec2 shapeVertexPosition; + uniform vec2 viewportStart; + uniform vec2 viewportEnd; + + void main() { + // We need to transform from page coordinates to something WebGl understands + float viewportWidth = viewportEnd.x - viewportStart.x; + float viewportHeight = viewportEnd.y - viewportStart.y; + vec2 finalPosition = vec2( + 2.0 * (shapeVertexPosition.x - viewportStart.x) / viewportWidth - 1.0, + 1.0 - 2.0 * (shapeVertexPosition.y - viewportStart.y) / viewportHeight + ); + gl_Position = vec4(finalPosition, 0.0, 1.0); + }` + + const vertexShader = context.createShader(context.VERTEX_SHADER) + if (!vertexShader) return + context.shaderSource(vertexShader, vertexShaderSourceCode) + context.compileShader(vertexShader) + if (!context.getShaderParameter(vertexShader, context.COMPILE_STATUS)) { + return + } + // Dark = hsl(210, 11%, 19%) + // Light = hsl(204, 14%, 93%) + const color = isDarkMode ? 'vec4(0.169, 0.188, 0.212, 1.0)' : 'vec4(0.922, 0.933, 0.941, 1.0)' + + const fragmentShaderSourceCode = `#version 300 es + precision mediump float; + + out vec4 outputColor; + + void main() { + outputColor = ${color}; + }` + + const fragmentShader = context.createShader(context.FRAGMENT_SHADER) + if (!fragmentShader) return + context.shaderSource(fragmentShader, fragmentShaderSourceCode) + context.compileShader(fragmentShader) + if (!context.getShaderParameter(fragmentShader, context.COMPILE_STATUS)) { + return + } + + const program = context.createProgram() + if (!program) return + context.attachShader(program, vertexShader) + context.attachShader(program, fragmentShader) + context.linkProgram(program) + if (!context.getProgramParameter(program, context.LINK_STATUS)) { + return + } + context.useProgram(program) + + const shapeVertexPositionAttributeLocation = context.getAttribLocation( + program, + 'shapeVertexPosition' + ) + if (shapeVertexPositionAttributeLocation < 0) { + return + } + context.enableVertexAttribArray(shapeVertexPositionAttributeLocation) + + const viewportStartUniformLocation = context.getUniformLocation(program, 'viewportStart') + const viewportEndUniformLocation = context.getUniformLocation(program, 'viewportEnd') + if (!viewportStartUniformLocation || !viewportEndUniformLocation) { + return + } + return { + context, + program, + shapeVertexPositionAttributeLocation, + viewportStartUniformLocation, + viewportEndUniformLocation, + } +} + +export function CulledShapes() { + const editor = useEditor() + const isDarkMode = useIsDarkMode() + const canvasRef = useRef(null) + + const isCullingOffScreenShapes = Number.isFinite(editor.renderingBoundsMargin) + + useEffect(() => { + const webGl = setupWebGl(canvasRef.current, isDarkMode) + if (!webGl) return + if (!isCullingOffScreenShapes) return + + const { + context, + shapeVertexPositionAttributeLocation, + viewportStartUniformLocation, + viewportEndUniformLocation, + } = webGl + + const shapeVertices = computed('shape vertices', function calculateCulledShapeVertices() { + const results: number[] = [] + + for (const { isCulled, maskedPageBounds } of editor.getRenderingShapes()) { + if (isCulled && maskedPageBounds) { + results.push( + // triangle 1 + maskedPageBounds.minX, + maskedPageBounds.minY, + maskedPageBounds.minX, + maskedPageBounds.maxY, + maskedPageBounds.maxX, + maskedPageBounds.maxY, + // triangle 2 + maskedPageBounds.minX, + maskedPageBounds.minY, + maskedPageBounds.maxX, + maskedPageBounds.minY, + maskedPageBounds.maxX, + maskedPageBounds.maxY + ) + } + } + + return results + }) + + return react('render culled shapes ', function renderCulledShapes() { + const canvas = canvasRef.current + if (!canvas) return + + const width = canvas.clientWidth + const height = canvas.clientHeight + if (width !== canvas.width || height !== canvas.height) { + canvas.width = width + canvas.height = height + context.viewport(0, 0, width, height) + } + + const verticesArray = shapeVertices.get() + + context.clear(context.COLOR_BUFFER_BIT | context.DEPTH_BUFFER_BIT) + + if (verticesArray.length > 0) { + const viewport = editor.getViewportPageBounds() // when the viewport changes... + context.uniform2f(viewportStartUniformLocation, viewport.minX, viewport.minY) + context.uniform2f(viewportEndUniformLocation, viewport.maxX, viewport.maxY) + const triangleGeoCpuBuffer = new Float32Array(verticesArray) + const triangleGeoBuffer = context.createBuffer() + context.bindBuffer(context.ARRAY_BUFFER, triangleGeoBuffer) + context.bufferData(context.ARRAY_BUFFER, triangleGeoCpuBuffer, context.STATIC_DRAW) + context.vertexAttribPointer( + shapeVertexPositionAttributeLocation, + 2, + context.FLOAT, + false, + 2 * Float32Array.BYTES_PER_ELEMENT, + 0 + ) + context.drawArrays(context.TRIANGLES, 0, verticesArray.length / 2) + } + }) + }, [isCullingOffScreenShapes, isDarkMode, editor]) + return isCullingOffScreenShapes ? ( + + ) : null +} diff --git a/packages/editor/src/lib/components/Shape.tsx b/packages/editor/src/lib/components/Shape.tsx index 8c7dedd0b..19a33b64a 100644 --- a/packages/editor/src/lib/components/Shape.tsx +++ b/packages/editor/src/lib/components/Shape.tsx @@ -43,7 +43,6 @@ export const Shape = memo(function Shape({ const { ShapeErrorFallback } = useEditorComponents() const containerRef = useRef(null) - const culledContainerRef = useRef(null) const bgContainerRef = useRef(null) const memoizedStuffRef = useRef({ @@ -67,7 +66,6 @@ export const Shape = memo(function Shape({ const clipPath = editor.getShapeClipPath(id) ?? 'none' if (clipPath !== prev.clipPath) { setStyleProperty(containerRef.current, 'clip-path', clipPath) - setStyleProperty(culledContainerRef.current, 'clip-path', clipPath) setStyleProperty(bgContainerRef.current, 'clip-path', clipPath) prev.clipPath = clipPath } @@ -81,11 +79,6 @@ export const Shape = memo(function Shape({ if (transform !== prev.transform) { setStyleProperty(containerRef.current, 'transform', transform) setStyleProperty(bgContainerRef.current, 'transform', transform) - setStyleProperty( - culledContainerRef.current, - 'transform', - `${Mat.toCssString(pageTransform)} translate(${bounds.x}px, ${bounds.y}px)` - ) prev.transform = transform } @@ -100,8 +93,6 @@ export const Shape = memo(function Shape({ if (width !== prev.width || height !== prev.height) { setStyleProperty(containerRef.current, 'width', Math.max(width, dprMultiple) + 'px') setStyleProperty(containerRef.current, 'height', Math.max(height, dprMultiple) + 'px') - setStyleProperty(culledContainerRef.current, 'width', Math.max(width, dprMultiple) + 'px') - setStyleProperty(culledContainerRef.current, 'height', Math.max(height, dprMultiple) + 'px') setStyleProperty(bgContainerRef.current, 'width', Math.max(width, dprMultiple) + 'px') setStyleProperty(bgContainerRef.current, 'height', Math.max(height, dprMultiple) + 'px') prev.width = width @@ -132,10 +123,8 @@ export const Shape = memo(function Shape({ useLayoutEffect(() => { const container = containerRef.current const bgContainer = bgContainerRef.current - const culledContainer = culledContainerRef.current setStyleProperty(container, 'display', isCulled ? 'none' : 'block') setStyleProperty(bgContainer, 'display', isCulled ? 'none' : 'block') - setStyleProperty(culledContainer, 'display', isCulled ? 'block' : 'none') }, [isCulled]) const annotateError = useCallback( @@ -147,7 +136,6 @@ export const Shape = memo(function Shape({ return ( <> -
{util.backgroundComponent && (
{ + function positionLayersWhenCameraMoves() { const { x, y, z } = editor.getCamera() // Because the html container has a width/height of 1px, we @@ -105,9 +106,15 @@ export function DefaultCanvas({ className }: TLCanvasComponentProps) { {SvgDefs && } - {Background && } + {Background && ( +
+ +
+ )} - +
+ +
From d01a2223be4f5c2400d80a5a9ad366371223de87 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mitja=20Bezen=C5=A1ek?= Date: Fri, 5 Apr 2024 21:09:07 +0200 Subject: [PATCH 08/26] Fix an issue with layers when moving shapes. (#3380) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit https://github.com/tldraw/tldraw/assets/2523721/d35b5e41-5270-4fad-8f9e-f8d7ac46558c https://github.com/tldraw/tldraw/assets/2523721/2e1d1f54-f980-437d-aa51-f598b59d56b9 ### Change Type - [x] `sdk` — Changes the tldraw SDK - [ ] `dotcom` — Changes the tldraw.com web app - [ ] `docs` — Changes to the documentation, examples, or templates. - [ ] `vs code` — Changes to the vscode plugin - [ ] `internal` — Does not affect user-facing stuff - [x] `bugfix` — Bug fix - [ ] `feature` — New feature - [ ] `improvement` — Improving existing features - [ ] `chore` — Updating dependencies, other boring stuff - [ ] `galaxy brain` — Architectural changes - [ ] `tests` — Changes to any test code - [ ] `tools` — Changes to infrastructure, CI, internal scripts, debugging tools, etc. - [ ] `dunno` — I don't know --- .../default-components/DefaultCanvas.tsx | 74 ++++++++++--------- 1 file changed, 38 insertions(+), 36 deletions(-) diff --git a/packages/editor/src/lib/components/default-components/DefaultCanvas.tsx b/packages/editor/src/lib/components/default-components/DefaultCanvas.tsx index 414f69606..3f89197b1 100644 --- a/packages/editor/src/lib/components/default-components/DefaultCanvas.tsx +++ b/packages/editor/src/lib/components/default-components/DefaultCanvas.tsx @@ -91,52 +91,54 @@ export function DefaultCanvas({ className }: TLCanvasComponentProps) { ]) return ( -
- - - {shapeSvgDefs} - - - {SvgDefs && } - - + <> {Background && (
)} -
-
- - - {hideShapes ? null : debugSvg ? : } -
-
-
- {debugGeometry ? : null} - - - - - - - - - - +
+ + + {shapeSvgDefs} + + + {SvgDefs && } + + + +
+ + + {hideShapes ? null : debugSvg ? : } +
+
+
+ {debugGeometry ? : null} + + + + + + + + + + +
+
-
-
+ ) } From 86403c1b0d6ffb853e4d320be506b3be39491342 Mon Sep 17 00:00:00 2001 From: Orion Reed Date: Mon, 8 Apr 2024 01:06:24 -0700 Subject: [PATCH 09/26] Fix typo in Store.ts (#3385) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit An immense contribution, I know. ### Change Type - [ x ] `docs` — Changes to the documentation, examples, or templates. - [ x ] `chore` — Updating dependencies, other boring stuff --- packages/store/src/lib/Store.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/store/src/lib/Store.ts b/packages/store/src/lib/Store.ts index c0feabd64..87856ef1a 100644 --- a/packages/store/src/lib/Store.ts +++ b/packages/store/src/lib/Store.ts @@ -318,7 +318,7 @@ export class Store { onAfterCreate?: (record: R, source: 'remote' | 'user') => void /** - * A callback before after each record's change. + * A callback fired before each record's change. * * @param prev - The previous value, if any. * @param next - The next value. From 947f7b1d765917ce546de24a1338ab8c9d31e8d5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mitja=20Bezen=C5=A1ek?= Date: Mon, 8 Apr 2024 13:36:12 +0200 Subject: [PATCH 10/26] [culling] Improve setting of display none. (#3376) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Small improvement for culling shapes. We now use reactor to do it. . Before: ![image](https://github.com/tldraw/tldraw/assets/2523721/7f791cdd-c0e2-4b92-84d1-8b071540de10) After: ![image](https://github.com/tldraw/tldraw/assets/2523721/ca2e2a9e-f9f6-48a8-936f-05a402c1e7a2) ### Change Type - [ ] `sdk` — Changes the tldraw SDK - [ ] `dotcom` — Changes the tldraw.com web app - [ ] `docs` — Changes to the documentation, examples, or templates. - [ ] `vs code` — Changes to the vscode plugin - [x] `internal` — Does not affect user-facing stuff - [ ] `bugfix` — Bug fix - [ ] `feature` — New feature - [x] `improvement` — Improving existing features - [ ] `chore` — Updating dependencies, other boring stuff - [ ] `galaxy brain` — Architectural changes - [ ] `tests` — Changes to any test code - [ ] `tools` — Changes to infrastructure, CI, internal scripts, debugging tools, etc. - [ ] `dunno` — I don't know --- .../custom-renderer/CustomRenderer.tsx | 3 +- .../useRenderingShapesChange.ts | 6 +- packages/editor/api-report.md | 5 +- packages/editor/api/api.json | 71 +++++++++++++++--- .../src/lib/components/CulledShapes.tsx | 5 +- packages/editor/src/lib/components/Shape.tsx | 21 +++--- packages/editor/src/lib/editor/Editor.ts | 73 ++++++++++++------- packages/editor/src/lib/editor/getSvgJsx.tsx | 3 +- .../HelperButtons/BackToContent.tsx | 7 +- .../tldraw/src/test/renderingShapes.test.tsx | 56 ++++++++------ 10 files changed, 167 insertions(+), 83 deletions(-) diff --git a/apps/examples/src/examples/custom-renderer/CustomRenderer.tsx b/apps/examples/src/examples/custom-renderer/CustomRenderer.tsx index 7e20457ea..66cbe70a1 100644 --- a/apps/examples/src/examples/custom-renderer/CustomRenderer.tsx +++ b/apps/examples/src/examples/custom-renderer/CustomRenderer.tsx @@ -36,7 +36,8 @@ export function CustomRenderer() { const theme = getDefaultColorTheme({ isDarkMode: editor.user.getIsDarkMode() }) const currentPageId = editor.getCurrentPageId() - for (const { shape, maskedPageBounds, opacity } of renderingShapes) { + for (const { shape, opacity } of renderingShapes) { + const maskedPageBounds = editor.getShapeMaskedPageBounds(shape) if (!maskedPageBounds) continue ctx.save() diff --git a/apps/examples/src/examples/rendering-shape-changes/useRenderingShapesChange.ts b/apps/examples/src/examples/rendering-shape-changes/useRenderingShapesChange.ts index ae181b28d..39b6145f3 100644 --- a/apps/examples/src/examples/rendering-shape-changes/useRenderingShapesChange.ts +++ b/apps/examples/src/examples/rendering-shape-changes/useRenderingShapesChange.ts @@ -22,9 +22,11 @@ export function useChangedShapesReactor( if (!beforeInfo) { continue } else { - if (afterInfo.isCulled && !beforeInfo.isCulled) { + const isAfterCulled = editor.isShapeCulled(afterInfo.id) + const isBeforeCulled = editor.isShapeCulled(beforeInfo.id) + if (isAfterCulled && !isBeforeCulled) { culled.push(afterInfo.shape) - } else if (!afterInfo.isCulled && beforeInfo.isCulled) { + } else if (!isAfterCulled && isBeforeCulled) { restored.push(afterInfo.shape) } beforeToVisit.delete(beforeInfo) diff --git a/packages/editor/api-report.md b/packages/editor/api-report.md index 8366a85cd..6b5a73d95 100644 --- a/packages/editor/api-report.md +++ b/packages/editor/api-report.md @@ -720,8 +720,6 @@ export class Editor extends EventEmitter { index: number; backgroundIndex: number; opacity: number; - isCulled: boolean; - maskedPageBounds: Box | undefined; }[]; getSelectedShapeAtPoint(point: VecLike): TLShape | undefined; getSelectedShapeIds(): TLShapeId[]; @@ -782,8 +780,6 @@ export class Editor extends EventEmitter { index: number; backgroundIndex: number; opacity: number; - isCulled: boolean; - maskedPageBounds: Box | undefined; }[]; getViewportPageBounds(): Box; getViewportPageCenter(): Vec; @@ -821,6 +817,7 @@ export class Editor extends EventEmitter { margin?: number | undefined; hitInside?: boolean | undefined; }): boolean; + isShapeCulled(shape: TLShape | TLShapeId): boolean; isShapeInPage(shape: TLShape | TLShapeId, pageId?: TLPageId): boolean; isShapeOfType(shape: TLUnknownShape, type: T['type']): shape is T; // (undocumented) diff --git a/packages/editor/api/api.json b/packages/editor/api/api.json index eeb1401c2..a607d0b27 100644 --- a/packages/editor/api/api.json +++ b/packages/editor/api/api.json @@ -11955,16 +11955,7 @@ }, { "kind": "Content", - "text": ">;\n index: number;\n backgroundIndex: number;\n opacity: number;\n isCulled: boolean;\n maskedPageBounds: " - }, - { - "kind": "Reference", - "text": "Box", - "canonicalReference": "@tldraw/editor!Box:class" - }, - { - "kind": "Content", - "text": " | undefined;\n }[]" + "text": ">;\n index: number;\n backgroundIndex: number;\n opacity: number;\n }[]" }, { "kind": "Content", @@ -11974,7 +11965,7 @@ "isStatic": false, "returnTypeTokenRange": { "startIndex": 1, - "endIndex": 12 + "endIndex": 10 }, "releaseTag": "Public", "isProtected": false, @@ -14823,6 +14814,64 @@ "isAbstract": false, "name": "isPointInShape" }, + { + "kind": "Method", + "canonicalReference": "@tldraw/editor!Editor#isShapeCulled:member(1)", + "docComment": "/**\n * Get whether the shape is culled or not.\n *\n * @param shape - The shape (or shape id) to get the culled info for.\n *\n * @example\n * ```ts\n * editor.isShapeCulled(myShape)\n * editor.isShapeCulled(myShapeId)\n * ```\n *\n * @public\n */\n", + "excerptTokens": [ + { + "kind": "Content", + "text": "isShapeCulled(shape: " + }, + { + "kind": "Reference", + "text": "TLShape", + "canonicalReference": "@tldraw/tlschema!TLShape:type" + }, + { + "kind": "Content", + "text": " | " + }, + { + "kind": "Reference", + "text": "TLShapeId", + "canonicalReference": "@tldraw/tlschema!TLShapeId:type" + }, + { + "kind": "Content", + "text": "): " + }, + { + "kind": "Content", + "text": "boolean" + }, + { + "kind": "Content", + "text": ";" + } + ], + "isStatic": false, + "returnTypeTokenRange": { + "startIndex": 5, + "endIndex": 6 + }, + "releaseTag": "Public", + "isProtected": false, + "overloadIndex": 1, + "parameters": [ + { + "parameterName": "shape", + "parameterTypeTokenRange": { + "startIndex": 1, + "endIndex": 4 + }, + "isOptional": false + } + ], + "isOptional": false, + "isAbstract": false, + "name": "isShapeCulled" + }, { "kind": "Method", "canonicalReference": "@tldraw/editor!Editor#isShapeInPage:member(1)", diff --git a/packages/editor/src/lib/components/CulledShapes.tsx b/packages/editor/src/lib/components/CulledShapes.tsx index 5a6020621..9b2851534 100644 --- a/packages/editor/src/lib/components/CulledShapes.tsx +++ b/packages/editor/src/lib/components/CulledShapes.tsx @@ -112,8 +112,9 @@ export function CulledShapes() { const shapeVertices = computed('shape vertices', function calculateCulledShapeVertices() { const results: number[] = [] - for (const { isCulled, maskedPageBounds } of editor.getRenderingShapes()) { - if (isCulled && maskedPageBounds) { + for (const { id } of editor.getUnorderedRenderingShapes(true)) { + const maskedPageBounds = editor.getShapeMaskedPageBounds(id) + if (editor.isShapeCulled(id) && maskedPageBounds) { results.push( // triangle 1 maskedPageBounds.minX, diff --git a/packages/editor/src/lib/components/Shape.tsx b/packages/editor/src/lib/components/Shape.tsx index 19a33b64a..8e21a66ed 100644 --- a/packages/editor/src/lib/components/Shape.tsx +++ b/packages/editor/src/lib/components/Shape.tsx @@ -1,6 +1,6 @@ import { useQuickReactor, useStateTracking } from '@tldraw/state' import { TLShape, TLShapeId } from '@tldraw/tlschema' -import { memo, useCallback, useLayoutEffect, useRef } from 'react' +import { memo, useCallback, useRef } from 'react' import { ShapeUtil } from '../editor/shapes/ShapeUtil' import { useEditor } from '../hooks/useEditor' import { useEditorComponents } from '../hooks/useEditorComponents' @@ -26,7 +26,6 @@ export const Shape = memo(function Shape({ index, backgroundIndex, opacity, - isCulled, dprMultiple, }: { id: TLShapeId @@ -35,7 +34,6 @@ export const Shape = memo(function Shape({ index: number backgroundIndex: number opacity: number - isCulled: boolean dprMultiple: number }) { const editor = useEditor() @@ -120,13 +118,18 @@ export const Shape = memo(function Shape({ [opacity, index, backgroundIndex] ) - useLayoutEffect(() => { - const container = containerRef.current - const bgContainer = bgContainerRef.current - setStyleProperty(container, 'display', isCulled ? 'none' : 'block') - setStyleProperty(bgContainer, 'display', isCulled ? 'none' : 'block') - }, [isCulled]) + useQuickReactor( + 'set display', + () => { + const shape = editor.getShape(id) + if (!shape) return // probably the shape was just deleted + const isCulled = editor.isShapeCulled(shape) + setStyleProperty(containerRef.current, 'display', isCulled ? 'none' : 'block') + setStyleProperty(bgContainerRef.current, 'display', isCulled ? 'none' : 'block') + }, + [editor] + ) const annotateError = useCallback( (error: any) => editor.annotateError(error, { origin: 'shape', willCrashApp: false }), [editor] diff --git a/packages/editor/src/lib/editor/Editor.ts b/packages/editor/src/lib/editor/Editor.ts index 7c635e66f..695471389 100644 --- a/packages/editor/src/lib/editor/Editor.ts +++ b/packages/editor/src/lib/editor/Editor.ts @@ -3126,48 +3126,26 @@ export class Editor extends EventEmitter { index: number backgroundIndex: number opacity: number - isCulled: boolean - maskedPageBounds: Box | undefined }[] = [] let nextIndex = MAX_SHAPES_PER_PAGE * 2 let nextBackgroundIndex = MAX_SHAPES_PER_PAGE - // We only really need these if we're using editor state, but that's ok - const editingShapeId = this.getEditingShapeId() - const selectedShapeIds = this.getSelectedShapeIds() const erasingShapeIds = this.getErasingShapeIds() - const renderingBoundsExpanded = this.getRenderingBoundsExpanded() - - // If renderingBoundsMargin is set to Infinity, then we won't cull offscreen shapes - const isCullingOffScreenShapes = Number.isFinite(this.renderingBoundsMargin) const addShapeById = (id: TLShapeId, opacity: number, isAncestorErasing: boolean) => { const shape = this.getShape(id) if (!shape) return opacity *= shape.opacity - let isCulled = false let isShapeErasing = false const util = this.getShapeUtil(shape) - const maskedPageBounds = this.getShapeMaskedPageBounds(id) if (useEditorState) { isShapeErasing = !isAncestorErasing && erasingShapeIds.includes(id) if (isShapeErasing) { opacity *= 0.32 } - - isCulled = - isCullingOffScreenShapes && - // never cull editingg shapes - editingShapeId !== id && - // if the shape is fully outside of its parent's clipping bounds... - (maskedPageBounds === undefined || - // ...or if the shape is outside of the expanded viewport bounds... - (!renderingBoundsExpanded.includes(maskedPageBounds) && - // ...and if it's not selected... then cull it - !selectedShapeIds.includes(id))) } renderingShapes.push({ @@ -3177,8 +3155,6 @@ export class Editor extends EventEmitter { index: nextIndex, backgroundIndex: nextBackgroundIndex, opacity, - isCulled, - maskedPageBounds, }) nextIndex += 1 @@ -4266,6 +4242,51 @@ export class Editor extends EventEmitter { return this.isShapeOrAncestorLocked(this.getShapeParent(shape)) } + @computed + private _getShapeCullingInfoCache(): ComputedCache { + return this.store.createComputedCache( + 'shapeCullingInfo', + ({ id }) => { + // We don't cull shapes that are being edited + if (this.getEditingShapeId() === id) return false + + const maskedPageBounds = this.getShapeMaskedPageBounds(id) + // if the shape is fully outside of its parent's clipping bounds... + if (maskedPageBounds === undefined) return true + + // We don't cull selected shapes + if (this.getSelectedShapeIds().includes(id)) return false + const renderingBoundsExpanded = this.getRenderingBoundsExpanded() + // the shape is outside of the expanded viewport bounds... + return !renderingBoundsExpanded.includes(maskedPageBounds) + }, + (a, b) => this.getShapeMaskedPageBounds(a) === this.getShapeMaskedPageBounds(b) + ) + } + + /** + * Get whether the shape is culled or not. + * + * @example + * ```ts + * editor.isShapeCulled(myShape) + * editor.isShapeCulled(myShapeId) + * ``` + * + * @param shape - The shape (or shape id) to get the culled info for. + * + * @public + */ + isShapeCulled(shape: TLShape | TLShapeId): boolean { + // If renderingBoundsMargin is set to Infinity, then we won't cull offscreen shapes + const isCullingOffScreenShapes = Number.isFinite(this.renderingBoundsMargin) + if (!isCullingOffScreenShapes) return false + + const id = typeof shape === 'string' ? shape : shape.id + + return this._getShapeCullingInfoCache().get(id)! as boolean + } + /** * The bounds of the current page (the common bounds of all of the shapes on the page). * @@ -4637,8 +4658,8 @@ export class Editor extends EventEmitter { * @public */ @computed getCurrentPageRenderingShapesSorted(): TLShape[] { - return this.getRenderingShapes() - .filter(({ isCulled }) => !isCulled) + return this.getUnorderedRenderingShapes(true) + .filter(({ id }) => !this.isShapeCulled(id)) .sort((a, b) => a.index - b.index) .map(({ shape }) => shape) } diff --git a/packages/editor/src/lib/editor/getSvgJsx.tsx b/packages/editor/src/lib/editor/getSvgJsx.tsx index 34965135c..1f16b0ce1 100644 --- a/packages/editor/src/lib/editor/getSvgJsx.tsx +++ b/packages/editor/src/lib/editor/getSvgJsx.tsx @@ -38,7 +38,8 @@ export async function getSvgJsx( if (opts.bounds) { bbox = opts.bounds } else { - for (const { maskedPageBounds } of renderingShapes) { + for (const { id } of renderingShapes) { + const maskedPageBounds = editor.getShapeMaskedPageBounds(id) if (!maskedPageBounds) continue if (bbox) { bbox.union(maskedPageBounds) diff --git a/packages/tldraw/src/lib/ui/components/HelperButtons/BackToContent.tsx b/packages/tldraw/src/lib/ui/components/HelperButtons/BackToContent.tsx index 89bfd2660..004dedfdf 100644 --- a/packages/tldraw/src/lib/ui/components/HelperButtons/BackToContent.tsx +++ b/packages/tldraw/src/lib/ui/components/HelperButtons/BackToContent.tsx @@ -19,9 +19,10 @@ export function BackToContent() { // Rendering shapes includes all the shapes in the current page. // We have to filter them down to just the shapes that are inside the renderingBounds. - const visibleShapes = renderingShapes.filter( - (s) => s.maskedPageBounds && renderingBounds.includes(s.maskedPageBounds) - ) + const visibleShapes = renderingShapes.filter((s) => { + const maskedPageBounds = editor.getShapeMaskedPageBounds(s.id) + return maskedPageBounds && renderingBounds.includes(maskedPageBounds) + }) const showBackToContentNow = visibleShapes.length === 0 && editor.getCurrentPageShapes().length > 0 diff --git a/packages/tldraw/src/test/renderingShapes.test.tsx b/packages/tldraw/src/test/renderingShapes.test.tsx index 322dfc34f..9b7c2ebbd 100644 --- a/packages/tldraw/src/test/renderingShapes.test.tsx +++ b/packages/tldraw/src/test/renderingShapes.test.tsx @@ -63,42 +63,50 @@ it('updates the rendering viewport when the camera stops moving', () => { it('lists shapes in viewport', () => { const ids = createShapes() editor.selectNone() - expect(editor.getRenderingShapes().map(({ id, isCulled }) => [id, isCulled])).toStrictEqual([ - [ids.A, false], // A is within the expanded rendering bounds, so should not be culled; and it's in the regular viewport too, so it's on screen. - [ids.B, false], - [ids.C, false], - [ids.D, true], // D is clipped and so should always be culled / outside of viewport - ]) + expect(editor.getRenderingShapes().map(({ id }) => [id, editor.isShapeCulled(id)])).toStrictEqual( + [ + [ids.A, false], // A is within the expanded rendering bounds, so should not be culled; and it's in the regular viewport too, so it's on screen. + [ids.B, false], + [ids.C, false], + [ids.D, true], // D is clipped and so should always be culled / outside of viewport + ] + ) // Move the camera 201 pixels to the right and 201 pixels down editor.pan({ x: -201, y: -201 }) jest.advanceTimersByTime(500) - expect(editor.getRenderingShapes().map(({ id, isCulled }) => [id, isCulled])).toStrictEqual([ - [ids.A, false], // A should not be culled, even though it's no longer in the viewport (because it's still in the EXPANDED viewport) - [ids.B, false], - [ids.C, false], - [ids.D, true], // D is clipped and so should always be culled / outside of viewport - ]) + expect(editor.getRenderingShapes().map(({ id }) => [id, editor.isShapeCulled(id)])).toStrictEqual( + [ + [ids.A, false], // A should not be culled, even though it's no longer in the viewport (because it's still in the EXPANDED viewport) + [ids.B, false], + [ids.C, false], + [ids.D, true], // D is clipped and so should always be culled / outside of viewport + ] + ) editor.pan({ x: -100, y: -100 }) jest.advanceTimersByTime(500) - expect(editor.getRenderingShapes().map(({ id, isCulled }) => [id, isCulled])).toStrictEqual([ - [ids.A, true], // A should be culled now that it's outside of the expanded viewport too - [ids.B, false], - [ids.C, false], - [ids.D, true], // D is clipped and so should always be culled, even if it's in the viewport - ]) + expect(editor.getRenderingShapes().map(({ id }) => [id, editor.isShapeCulled(id)])).toStrictEqual( + [ + [ids.A, true], // A should be culled now that it's outside of the expanded viewport too + [ids.B, false], + [ids.C, false], + [ids.D, true], // D is clipped and so should always be culled, even if it's in the viewport + ] + ) editor.pan({ x: -900, y: -900 }) jest.advanceTimersByTime(500) - expect(editor.getRenderingShapes().map(({ id, isCulled }) => [id, isCulled])).toStrictEqual([ - [ids.A, true], - [ids.B, true], - [ids.C, true], - [ids.D, true], - ]) + expect(editor.getRenderingShapes().map(({ id }) => [id, editor.isShapeCulled(id)])).toStrictEqual( + [ + [ids.A, true], + [ids.B, true], + [ids.C, true], + [ids.D, true], + ] + ) }) it('lists shapes in viewport sorted by id with correct indexes & background indexes', () => { From fb2d3b437239cc5a45a346de7bc7be1753238738 Mon Sep 17 00:00:00 2001 From: Steve Ruiz Date: Mon, 8 Apr 2024 14:31:05 +0100 Subject: [PATCH 11/26] Perf: (slightly) faster min dist checks (#3401) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This PR improves a bunch of places where we do "minimum distance checks". Previously, we were using `Vec.Dist`, which uses `Math.hypot` to find the actual distance, but we can just as well use the squared distance. So this PR makes a small improvement to `Vec.Dist2` and then switches to that method when checking minimum distances. ### Change Type - [x] `sdk` — Changes the tldraw SDK - [x] `improvement` — Improving existing features ### Test Plan - [x] Unit Tests ### Release Notes - Performance: small improvements to hit testing. --- packages/editor/api-report.md | 2 +- packages/editor/src/lib/constants.ts | 4 ++-- packages/editor/src/lib/editor/Editor.ts | 4 ++-- .../src/lib/editor/managers/ClickManager.ts | 2 +- packages/editor/src/lib/primitives/Vec.ts | 11 +++++----- .../src/lib/primitives/geometry/Arc2d.ts | 13 ++++++------ .../lib/primitives/geometry/CubicBezier2d.ts | 6 ++++-- .../lib/primitives/geometry/CubicSpline2d.ts | 7 ++++--- .../src/lib/primitives/geometry/Ellipse2d.ts | 7 ++++--- .../src/lib/primitives/geometry/Geometry2d.ts | 17 +++++++++------- .../src/lib/primitives/geometry/Group2d.ts | 20 ++++++++++--------- .../src/lib/primitives/geometry/Polyline2d.ts | 5 ++--- packages/store/api/api.json | 2 +- .../src/lib/shapes/draw/toolStates/Drawing.ts | 4 ++-- 14 files changed, 57 insertions(+), 47 deletions(-) diff --git a/packages/editor/api-report.md b/packages/editor/api-report.md index 6b5a73d95..2b8563890 100644 --- a/packages/editor/api-report.md +++ b/packages/editor/api-report.md @@ -514,7 +514,7 @@ export function degreesToRadians(d: number): number; export const DOUBLE_CLICK_DURATION = 450; // @internal (undocumented) -export const DRAG_DISTANCE = 4; +export const DRAG_DISTANCE = 16; // @public (undocumented) export const EASINGS: { diff --git a/packages/editor/src/lib/constants.ts b/packages/editor/src/lib/constants.ts index 43dcbfb10..233119932 100644 --- a/packages/editor/src/lib/constants.ts +++ b/packages/editor/src/lib/constants.ts @@ -34,10 +34,10 @@ export const DOUBLE_CLICK_DURATION = 450 export const MULTI_CLICK_DURATION = 200 /** @internal */ -export const COARSE_DRAG_DISTANCE = 6 +export const COARSE_DRAG_DISTANCE = 36 // 6 squared /** @internal */ -export const DRAG_DISTANCE = 4 +export const DRAG_DISTANCE = 16 // 4 squared /** @internal */ export const SVG_PADDING = 32 diff --git a/packages/editor/src/lib/editor/Editor.ts b/packages/editor/src/lib/editor/Editor.ts index 695471389..b93832f3b 100644 --- a/packages/editor/src/lib/editor/Editor.ts +++ b/packages/editor/src/lib/editor/Editor.ts @@ -8594,7 +8594,7 @@ export class Editor extends EventEmitter { if ( !inputs.isDragging && inputs.isPointing && - originPagePoint.dist(currentPagePoint) > + Vec.Dist2(originPagePoint, currentPagePoint) > (this.getInstanceState().isCoarsePointer ? COARSE_DRAG_DISTANCE : DRAG_DISTANCE) / this.getZoomLevel() ) { @@ -8684,7 +8684,7 @@ export class Editor extends EventEmitter { if ( !inputs.isDragging && inputs.isPointing && - originPagePoint.dist(currentPagePoint) > + Vec.Dist2(originPagePoint, currentPagePoint) > (this.getInstanceState().isCoarsePointer ? COARSE_DRAG_DISTANCE : DRAG_DISTANCE) / this.getZoomLevel() ) { diff --git a/packages/editor/src/lib/editor/managers/ClickManager.ts b/packages/editor/src/lib/editor/managers/ClickManager.ts index ef2c11111..ef5e53b10 100644 --- a/packages/editor/src/lib/editor/managers/ClickManager.ts +++ b/packages/editor/src/lib/editor/managers/ClickManager.ts @@ -227,7 +227,7 @@ export class ClickManager { if ( this._clickState !== 'idle' && this._clickScreenPoint && - this._clickScreenPoint.dist(this.editor.inputs.currentScreenPoint) > + 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/primitives/Vec.ts b/packages/editor/src/lib/primitives/Vec.ts index 82bf47395..e0ee4b717 100644 --- a/packages/editor/src/lib/primitives/Vec.ts +++ b/packages/editor/src/lib/primitives/Vec.ts @@ -308,19 +308,20 @@ export class Vec { static Per(A: VecLike): Vec { return new Vec(A.y, -A.x) } - - static Dist2(A: VecLike, B: VecLike): number { - return Vec.Sub(A, B).len2() - } - static Abs(A: VecLike): Vec { return new Vec(Math.abs(A.x), Math.abs(A.y)) } + // Get the distance between two points. static Dist(A: VecLike, B: VecLike): number { return Math.hypot(A.y - B.y, A.x - B.x) } + // Get the squared distance between two points. This is faster to calculate (no square root) so useful for "minimum distance" checks where the actual measurement does not matter. + static Dist2(A: VecLike, B: VecLike): number { + return (A.x - B.x) * (A.x - B.x) + (A.y - B.y) * (A.y - B.y) + } + /** * Dot product of two vectors which is used to calculate the angle between them. */ diff --git a/packages/editor/src/lib/primitives/geometry/Arc2d.ts b/packages/editor/src/lib/primitives/geometry/Arc2d.ts index 5feee17ef..3ca170a47 100644 --- a/packages/editor/src/lib/primitives/geometry/Arc2d.ts +++ b/packages/editor/src/lib/primitives/geometry/Arc2d.ts @@ -52,15 +52,16 @@ export class Arc2d extends Geometry2d { // Get the point (P) on the arc, then pick the nearest of A, B, and P const P = _center.clone().add(point.clone().sub(_center).uni().mul(radius)) - let distance = Infinity let nearest: Vec | undefined - for (const pt of [A, B, P]) { - if (point.dist(pt) < distance) { - nearest = pt - distance = point.dist(pt) + let dist = Infinity + let d: number + for (const p of [A, B, P]) { + d = Vec.Dist2(point, p) + if (d < dist) { + nearest = p + dist = d } } - if (!nearest) throw Error('nearest point not found') return nearest } diff --git a/packages/editor/src/lib/primitives/geometry/CubicBezier2d.ts b/packages/editor/src/lib/primitives/geometry/CubicBezier2d.ts index 9923891b7..912144378 100644 --- a/packages/editor/src/lib/primitives/geometry/CubicBezier2d.ts +++ b/packages/editor/src/lib/primitives/geometry/CubicBezier2d.ts @@ -55,9 +55,11 @@ export class CubicBezier2d extends Polyline2d { nearestPoint(A: Vec): Vec { let nearest: Vec | undefined let dist = Infinity + let d: number + let p: Vec for (const edge of this.segments) { - const p = edge.nearestPoint(A) - const d = p.dist(A) + p = edge.nearestPoint(A) + d = Vec.Dist2(p, A) if (d < dist) { nearest = p dist = d diff --git a/packages/editor/src/lib/primitives/geometry/CubicSpline2d.ts b/packages/editor/src/lib/primitives/geometry/CubicSpline2d.ts index 3c362b91b..8ff99bb0a 100644 --- a/packages/editor/src/lib/primitives/geometry/CubicSpline2d.ts +++ b/packages/editor/src/lib/primitives/geometry/CubicSpline2d.ts @@ -67,15 +67,16 @@ export class CubicSpline2d extends Geometry2d { nearestPoint(A: Vec): Vec { let nearest: Vec | undefined let dist = Infinity + let d: number + let p: Vec for (const segment of this.segments) { - const p = segment.nearestPoint(A) - const d = p.dist(A) + p = segment.nearestPoint(A) + d = Vec.Dist2(p, A) if (d < dist) { nearest = p dist = d } } - if (!nearest) throw Error('nearest point not found') return nearest } diff --git a/packages/editor/src/lib/primitives/geometry/Ellipse2d.ts b/packages/editor/src/lib/primitives/geometry/Ellipse2d.ts index d15b864af..a2340c298 100644 --- a/packages/editor/src/lib/primitives/geometry/Ellipse2d.ts +++ b/packages/editor/src/lib/primitives/geometry/Ellipse2d.ts @@ -76,15 +76,16 @@ export class Ellipse2d extends Geometry2d { nearestPoint(A: Vec): Vec { let nearest: Vec | undefined let dist = Infinity + let d: number + let p: Vec for (const edge of this.edges) { - const p = edge.nearestPoint(A) - const d = p.dist(A) + p = edge.nearestPoint(A) + d = Vec.Dist2(p, A) if (d < dist) { nearest = p dist = d } } - if (!nearest) throw Error('nearest point not found') return nearest } diff --git a/packages/editor/src/lib/primitives/geometry/Geometry2d.ts b/packages/editor/src/lib/primitives/geometry/Geometry2d.ts index e028de31e..b7df7a84d 100644 --- a/packages/editor/src/lib/primitives/geometry/Geometry2d.ts +++ b/packages/editor/src/lib/primitives/geometry/Geometry2d.ts @@ -55,14 +55,17 @@ export abstract class Geometry2d { } nearestPointOnLineSegment(A: Vec, B: Vec): Vec { - let distance = Infinity + const { vertices } = this let nearest: Vec | undefined - for (let i = 0; i < this.vertices.length; i++) { - const point = this.vertices[i] - const d = Vec.DistanceToLineSegment(A, B, point) - if (d < distance) { - distance = d - nearest = point + let dist = Infinity + let d: number + let p: Vec + for (let i = 0; i < vertices.length; i++) { + p = vertices[i] + d = Vec.DistanceToLineSegment(A, B, p) + if (d < dist) { + dist = d + nearest = p } } if (!nearest) throw Error('nearest point not found') diff --git a/packages/editor/src/lib/primitives/geometry/Group2d.ts b/packages/editor/src/lib/primitives/geometry/Group2d.ts index 3caeabfc8..b98f1ea99 100644 --- a/packages/editor/src/lib/primitives/geometry/Group2d.ts +++ b/packages/editor/src/lib/primitives/geometry/Group2d.ts @@ -30,8 +30,8 @@ export class Group2d extends Geometry2d { } override nearestPoint(point: Vec): Vec { - let d = Infinity - let p: Vec | undefined + let dist = Infinity + let nearest: Vec | undefined const { children } = this @@ -39,16 +39,18 @@ export class Group2d extends Geometry2d { throw Error('no children') } + let p: Vec + let d: number for (const child of children) { - const nearest = child.nearestPoint(point) - const dist = nearest.dist(point) - if (dist < d) { - d = dist - p = nearest + p = child.nearestPoint(point) + d = Vec.Dist2(p, point) + if (d < dist) { + dist = d + nearest = p } } - if (!p) throw Error('nearest point not found') - return p + if (!nearest) throw Error('nearest point not found') + return nearest } override distanceToPoint(point: Vec, hitInside = false) { diff --git a/packages/editor/src/lib/primitives/geometry/Polyline2d.ts b/packages/editor/src/lib/primitives/geometry/Polyline2d.ts index ffa541cb2..84c8e7471 100644 --- a/packages/editor/src/lib/primitives/geometry/Polyline2d.ts +++ b/packages/editor/src/lib/primitives/geometry/Polyline2d.ts @@ -51,18 +51,17 @@ export class Polyline2d extends Geometry2d { const { segments } = this let nearest = this.points[0] let dist = Infinity - let p: Vec // current point on segment let d: number // distance from A to p for (let i = 0; i < segments.length; i++) { p = segments[i].nearestPoint(A) - d = p.dist(A) + d = Vec.Dist2(p, A) if (d < dist) { nearest = p dist = d } } - + if (!nearest) throw Error('nearest point not found') return nearest } diff --git a/packages/store/api/api.json b/packages/store/api/api.json index d676a9c7e..cb4e90c11 100644 --- a/packages/store/api/api.json +++ b/packages/store/api/api.json @@ -4231,7 +4231,7 @@ { "kind": "Property", "canonicalReference": "@tldraw/store!Store#onBeforeChange:member", - "docComment": "/**\n * A callback before after each record's change.\n *\n * @param prev - The previous value, if any.\n *\n * @param next - The next value.\n */\n", + "docComment": "/**\n * A callback fired before each record's change.\n *\n * @param prev - The previous value, if any.\n *\n * @param next - The next value.\n */\n", "excerptTokens": [ { "kind": "Content", diff --git a/packages/tldraw/src/lib/shapes/draw/toolStates/Drawing.ts b/packages/tldraw/src/lib/shapes/draw/toolStates/Drawing.ts index 7ba9b582b..6946fec1a 100644 --- a/packages/tldraw/src/lib/shapes/draw/toolStates/Drawing.ts +++ b/packages/tldraw/src/lib/shapes/draw/toolStates/Drawing.ts @@ -309,7 +309,7 @@ export class Drawing extends StateNode { } const hasMovedFarEnough = - Vec.Dist(pagePointWhereNextSegmentChanged, inputs.currentPagePoint) > DRAG_DISTANCE + Vec.Dist2(pagePointWhereNextSegmentChanged, inputs.currentPagePoint) > DRAG_DISTANCE // Find the distance from where the pointer was when shift was released and // where it is now; if it's far enough away, then update the page point where @@ -382,7 +382,7 @@ export class Drawing extends StateNode { } const hasMovedFarEnough = - Vec.Dist(pagePointWhereNextSegmentChanged, inputs.currentPagePoint) > DRAG_DISTANCE + Vec.Dist2(pagePointWhereNextSegmentChanged, inputs.currentPagePoint) > DRAG_DISTANCE // Find the distance from where the pointer was when shift was released and // where it is now; if it's far enough away, then update the page point where From 5347c5f30e593fb2616011dddab64f1b225d4d41 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mitja=20Bezen=C5=A1ek?= Date: Mon, 8 Apr 2024 15:41:09 +0200 Subject: [PATCH 12/26] Add two simple perf helpers. (#3399) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Can be useful for ad-hoc measure of performance. One is a method decorator, which can be use on methods like so: ```typescript @measureDuration someLongRunningProccess() { // .... } ``` And the other offer more granular control. It also returns what the callback returns, so it can be use in assignments / return statements. ```typescript return measureCbDuration('sorting took', () => renderingShapes.sort(sortById)) ``` ### Change Type - [ ] `sdk` — Changes the tldraw SDK - [ ] `dotcom` — Changes the tldraw.com web app - [ ] `docs` — Changes to the documentation, examples, or templates. - [ ] `vs code` — Changes to the vscode plugin - [x] `internal` — Does not affect user-facing stuff - [ ] `bugfix` — Bug fix - [ ] `feature` — New feature - [ ] `improvement` — Improving existing features - [ ] `chore` — Updating dependencies, other boring stuff - [ ] `galaxy brain` — Architectural changes - [ ] `tests` — Changes to any test code - [x] `tools` — Changes to infrastructure, CI, internal scripts, debugging tools, etc. - [ ] `dunno` — I don't know --- packages/utils/api-report.md | 6 ++++++ packages/utils/src/index.ts | 1 + packages/utils/src/lib/perf.ts | 22 ++++++++++++++++++++++ 3 files changed, 29 insertions(+) create mode 100644 packages/utils/src/lib/perf.ts diff --git a/packages/utils/api-report.md b/packages/utils/api-report.md index e1379999c..fdf4cd9b4 100644 --- a/packages/utils/api-report.md +++ b/packages/utils/api-report.md @@ -176,6 +176,12 @@ export function mapObjectMapValues( [K in Key]: ValueAfter; }; +// @internal (undocumented) +export function measureCbDuration(name: string, cb: () => any): any; + +// @internal (undocumented) +export function measureDuration(_target: any, propertyKey: string, descriptor: PropertyDescriptor): PropertyDescriptor; + // @public export class MediaHelpers { static getImageSize(blob: Blob): Promise<{ diff --git a/packages/utils/src/index.ts b/packages/utils/src/index.ts index b008b9753..598a26dd7 100644 --- a/packages/utils/src/index.ts +++ b/packages/utils/src/index.ts @@ -36,6 +36,7 @@ export { objectMapKeys, objectMapValues, } from './lib/object' +export { measureCbDuration, measureDuration } from './lib/perf' export { PngHelpers } from './lib/png' export { type IndexKey } from './lib/reordering/IndexKey' export { diff --git a/packages/utils/src/lib/perf.ts b/packages/utils/src/lib/perf.ts new file mode 100644 index 000000000..689f8640a --- /dev/null +++ b/packages/utils/src/lib/perf.ts @@ -0,0 +1,22 @@ +/** @internal */ +export function measureCbDuration(name: string, cb: () => any) { + const now = performance.now() + const result = cb() + // eslint-disable-next-line no-console + console.log(`${name} took`, performance.now() - now, 'ms') + return result +} + +/** @internal */ +export function measureDuration(_target: any, propertyKey: string, descriptor: PropertyDescriptor) { + const originalMethod = descriptor.value + descriptor.value = function (...args: any[]) { + const start = performance.now() + const result = originalMethod.apply(this, args) + const end = performance.now() + // eslint-disable-next-line no-console + console.log(`${propertyKey} took ${end - start}ms `) + return result + } + return descriptor +} From 3f64bf8c5bbd02c741e0db5c3e6a5ee726f7d29f Mon Sep 17 00:00:00 2001 From: Steve Ruiz Date: Tue, 9 Apr 2024 13:57:46 +0100 Subject: [PATCH 13/26] Perf: slightly faster `getShapeAtPoint` (#3416) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This PR makes a small improvement to the speed of `getShapeAtPoint`. It removes `Editor.getCurrentPageRenderingShapesSorted`. ### Change Type - [x] `sdk` — Changes the tldraw SDK - [x] `improvement` — Improving existing features --- packages/editor/src/lib/editor/Editor.ts | 8 +++---- packages/utils/api-report.md | 3 +++ packages/utils/src/index.ts | 2 +- packages/utils/src/lib/perf.ts | 28 ++++++++++++++++++++++++ 4 files changed, 35 insertions(+), 6 deletions(-) diff --git a/packages/editor/src/lib/editor/Editor.ts b/packages/editor/src/lib/editor/Editor.ts index b93832f3b..0658a7f47 100644 --- a/packages/editor/src/lib/editor/Editor.ts +++ b/packages/editor/src/lib/editor/Editor.ts @@ -4657,11 +4657,9 @@ export class Editor extends EventEmitter { * * @public */ - @computed getCurrentPageRenderingShapesSorted(): TLShape[] { - return this.getUnorderedRenderingShapes(true) - .filter(({ id }) => !this.isShapeCulled(id)) - .sort((a, b) => a.index - b.index) - .map(({ shape }) => shape) + @computed + getCurrentPageRenderingShapesSorted(): TLShape[] { + return this.getCurrentPageShapesSorted().filter((shape) => !this.isShapeCulled(shape)) } /** diff --git a/packages/utils/api-report.md b/packages/utils/api-report.md index fdf4cd9b4..022651e9a 100644 --- a/packages/utils/api-report.md +++ b/packages/utils/api-report.md @@ -176,6 +176,9 @@ export function mapObjectMapValues( [K in Key]: ValueAfter; }; +// @internal (undocumented) +export function measureAverageDuration(_target: any, propertyKey: string, descriptor: PropertyDescriptor): PropertyDescriptor; + // @internal (undocumented) export function measureCbDuration(name: string, cb: () => any): any; diff --git a/packages/utils/src/index.ts b/packages/utils/src/index.ts index 598a26dd7..90273933b 100644 --- a/packages/utils/src/index.ts +++ b/packages/utils/src/index.ts @@ -36,7 +36,7 @@ export { objectMapKeys, objectMapValues, } from './lib/object' -export { measureCbDuration, measureDuration } from './lib/perf' +export { measureAverageDuration, measureCbDuration, measureDuration } from './lib/perf' export { PngHelpers } from './lib/png' export { type IndexKey } from './lib/reordering/IndexKey' export { diff --git a/packages/utils/src/lib/perf.ts b/packages/utils/src/lib/perf.ts index 689f8640a..e6ac86450 100644 --- a/packages/utils/src/lib/perf.ts +++ b/packages/utils/src/lib/perf.ts @@ -20,3 +20,31 @@ export function measureDuration(_target: any, propertyKey: string, descriptor: P } return descriptor } + +const averages = new Map() + +/** @internal */ +export function measureAverageDuration( + _target: any, + propertyKey: string, + descriptor: PropertyDescriptor +) { + const originalMethod = descriptor.value + descriptor.value = function (...args: any[]) { + const start = performance.now() + const result = originalMethod.apply(this, args) + const end = performance.now() + const value = averages.get(descriptor.value)! + const length = end - start + const total = value.total + length + const count = value.count + 1 + averages.set(descriptor.value, { total, count }) + // eslint-disable-next-line no-console + console.log( + `${propertyKey} took ${(end - start).toFixed(2)}ms | average ${(total / count).toFixed(2)}ms` + ) + return result + } + averages.set(descriptor.value, { total: 0, count: 0 }) + return descriptor +} From dadb57edcd544f31f3969ce772c1ae98e70edb05 Mon Sep 17 00:00:00 2001 From: Steve Ruiz Date: Tue, 9 Apr 2024 15:34:24 +0100 Subject: [PATCH 14/26] Perf: block hit tests while moving camera (#3418) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This PR uses an element that prevents hit tests on shapes while the camera is moving. https://github.com/tldraw/tldraw/assets/23072548/9905f3d4-ba64-4e4d-ae99-194f513eaac8 ### Change Type - [x] `sdk` — Changes the tldraw SDK - [x] `improvement` — Improving existing features ### Test Plan 1. Move the camera. 2. Interact with the canvas. 3. Zoom in and out. ### Release Notes - Improves performance of canvas while the camera is moving. --- packages/editor/editor.css | 16 ++++++++++++++++ .../default-components/DefaultCanvas.tsx | 14 ++++++++++++++ 2 files changed, 30 insertions(+) diff --git a/packages/editor/editor.css b/packages/editor/editor.css index f4cd8dcf6..6d06bfb69 100644 --- a/packages/editor/editor.css +++ b/packages/editor/editor.css @@ -29,6 +29,7 @@ --layer-shapes: 300; --layer-overlays: 400; --layer-following-indicator: 1000; + --layer-blocker: 10000; /* Misc */ --tl-zoom: 1; @@ -1501,3 +1502,18 @@ it from receiving any pointer events or affecting the cursor. */ font-size: 12px; font-family: monospace; } + +/* ---------------- Hit test blocker ---------------- */ + +.tl-hit-test-blocker { + position: absolute; + z-index: var(--layer-blocker); + inset: 0px; + width: 100%; + height: 100%; + pointer-events: all; +} + +.tl-hit-test-blocker__hidden { + display: none; +} diff --git a/packages/editor/src/lib/components/default-components/DefaultCanvas.tsx b/packages/editor/src/lib/components/default-components/DefaultCanvas.tsx index 3f89197b1..6acbdad19 100644 --- a/packages/editor/src/lib/components/default-components/DefaultCanvas.tsx +++ b/packages/editor/src/lib/components/default-components/DefaultCanvas.tsx @@ -137,6 +137,7 @@ export function DefaultCanvas({ className }: TLCanvasComponentProps) {
+
) @@ -590,3 +591,16 @@ function InFrontOfTheCanvasWrapper() { if (!InFrontOfTheCanvas) return null return } + +function MovingCameraHitTestBlocker() { + const editor = useEditor() + const cameraState = useValue('camera state', () => editor.getCameraState(), [editor]) + + return ( +
+ ) +} From 988dbbde28e3cd9b43891c08c462855dc68bab82 Mon Sep 17 00:00:00 2001 From: Steve Ruiz Date: Tue, 9 Apr 2024 16:30:33 +0100 Subject: [PATCH 15/26] Fix text bug on iOS (#3423) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit In this PR, we no longer buffer pointer down/ups. We now batch only `pointer_move`, `wheel`, and `pinch` events. Batched inputs were causing text not to work on iOS. On iOS, the keyboard is only shown if we call `focus` during the same event loop as a user input. ### Change Type - [x] `sdk` — Changes the tldraw SDK - [x] `bugfix` — Bug fix ### Test Plan 1. Use text on iOS. --- packages/editor/src/lib/editor/Editor.ts | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/packages/editor/src/lib/editor/Editor.ts b/packages/editor/src/lib/editor/Editor.ts index 0658a7f47..62dccf35f 100644 --- a/packages/editor/src/lib/editor/Editor.ts +++ b/packages/editor/src/lib/editor/Editor.ts @@ -8386,7 +8386,13 @@ export class Editor extends EventEmitter { */ dispatch = (info: TLEventInfo): this => { this._pendingEventsForNextTick.push(info) - if (!(info.type === 'pointer' || info.type === 'wheel' || info.type === 'pinch')) { + if ( + !( + (info.type === 'pointer' && info.name === 'pointer_move') || + info.type === 'wheel' || + info.type === 'pinch' + ) + ) { this._flushEventsForTick(0) } return this From 3b98e36914b5b665082e09a187f3b592d015425f Mon Sep 17 00:00:00 2001 From: Steve Ruiz Date: Tue, 9 Apr 2024 16:33:07 +0100 Subject: [PATCH 16/26] Perf: throttle `updateHoveredId` (#3419) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This PR throttles the `updateHoveredId` call so that it happens ever 30ms. ### Change Type - [x] `sdk` — Changes the tldraw SDK - [x] `improvement` — Improving existing features ### Release Notes - Improves canvas performance by throttling the update to the editor's hovered id. --- .../src/lib/tools/selection-logic/updateHoveredId.ts | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/packages/tldraw/src/lib/tools/selection-logic/updateHoveredId.ts b/packages/tldraw/src/lib/tools/selection-logic/updateHoveredId.ts index 9f0d44202..f03d333c3 100644 --- a/packages/tldraw/src/lib/tools/selection-logic/updateHoveredId.ts +++ b/packages/tldraw/src/lib/tools/selection-logic/updateHoveredId.ts @@ -1,6 +1,6 @@ -import { Editor, HIT_TEST_MARGIN, TLShape } from '@tldraw/editor' +import { Editor, HIT_TEST_MARGIN, TLShape, throttle } from '@tldraw/editor' -export function updateHoveredId(editor: Editor) { +function _updateHoveredId(editor: Editor) { // todo: consider replacing `get hoveredShapeId` with this; it would mean keeping hoveredShapeId in memory rather than in the store and possibly re-computing it more often than necessary const hitShape = editor.getShapeAtPoint(editor.inputs.currentPagePoint, { hitInside: false, @@ -30,3 +30,6 @@ export function updateHoveredId(editor: Editor) { return editor.setHoveredShape(shapeToHover.id) } + +export const updateHoveredId = + process.env.NODE_ENV === 'test' ? _updateHoveredId : throttle(_updateHoveredId, 32) From 6305e838306782c4092121a2a17299ecd04838eb Mon Sep 17 00:00:00 2001 From: Steve Ruiz Date: Tue, 9 Apr 2024 16:42:54 +0100 Subject: [PATCH 17/26] Fix some tests (#3403) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This PR fixes some jest test. - We skip the culling shapes in test environments. - We skip rendering patterns in test environments. ### Change Type - [x] `sdk` — Changes the tldraw SDK - [x] `tests` — Changes to any test code --- packages/editor/src/lib/components/CulledShapes.tsx | 9 ++++++++- .../tldraw/src/lib/shapes/shared/defaultStyleDefs.tsx | 5 +++++ 2 files changed, 13 insertions(+), 1 deletion(-) diff --git a/packages/editor/src/lib/components/CulledShapes.tsx b/packages/editor/src/lib/components/CulledShapes.tsx index 9b2851534..937514083 100644 --- a/packages/editor/src/lib/components/CulledShapes.tsx +++ b/packages/editor/src/lib/components/CulledShapes.tsx @@ -90,7 +90,7 @@ function setupWebGl(canvas: HTMLCanvasElement | null, isDarkMode: boolean) { } } -export function CulledShapes() { +function _CulledShapes() { const editor = useEditor() const isDarkMode = useIsDarkMode() const canvasRef = useRef(null) @@ -177,3 +177,10 @@ export function CulledShapes() { ) : null } + +export function CulledShapes() { + if (process.env.NODE_ENV === 'test') { + return null + } + return _CulledShapes() +} diff --git a/packages/tldraw/src/lib/shapes/shared/defaultStyleDefs.tsx b/packages/tldraw/src/lib/shapes/shared/defaultStyleDefs.tsx index 8a6ec0787..16e4495be 100644 --- a/packages/tldraw/src/lib/shapes/shared/defaultStyleDefs.tsx +++ b/packages/tldraw/src/lib/shapes/shared/defaultStyleDefs.tsx @@ -179,6 +179,11 @@ function usePattern() { const [backgroundUrls, setBackgroundUrls] = useState(defaultPatterns) useEffect(() => { + if (process.env.NODE_ENV === 'test') { + setIsReady(true) + return + } + const promises: Promise<{ zoom: number; url: string; darkMode: boolean }>[] = [] for (let i = 1; i <= Math.ceil(MAX_ZOOM); i++) { From 2bbab1a79025383e84cd85a45066455a9b09d693 Mon Sep 17 00:00:00 2001 From: Steve Ruiz Date: Wed, 10 Apr 2024 11:20:16 +0100 Subject: [PATCH 18/26] Perf: Improve text outline performance (#3429) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit We use text shadows to create "outlines" around text shapes. These shadows are rendered on the GPU. In Chrome (and on computers with a capable GPU) text shadows work pretty well, however on Safari—and in particular on iOS—they cause massive frame drops. https://github.com/tldraw/tldraw/assets/23072548/b65cbcaa-6cc3-46f3-b54d-1f9cc07fc499 This PR: - adds an LOD to text shadows, removing them at < 35% zoom - removes text shadows entirely on Safari If we had a "high performance" or "low-end device" mode, then shadows / text shadows would be the first to go. ### Change Type - [x] `sdk` — Changes the tldraw SDK - [x] `improvement` — Improving existing features ### Test Plan 1. Use text shapes on iOS. 2. Use text shapes on Safari. 3. Use text shapes on Chrome. ### Release Notes - Improves performance of text shapes on iOS / Safari. --- .../default-components/DefaultCanvas.tsx | 32 +++++++++++++++++-- packages/editor/src/lib/constants.ts | 3 ++ 2 files changed, 33 insertions(+), 2 deletions(-) diff --git a/packages/editor/src/lib/components/default-components/DefaultCanvas.tsx b/packages/editor/src/lib/components/default-components/DefaultCanvas.tsx index 6acbdad19..7d14a6cd2 100644 --- a/packages/editor/src/lib/components/default-components/DefaultCanvas.tsx +++ b/packages/editor/src/lib/components/default-components/DefaultCanvas.tsx @@ -3,9 +3,10 @@ import { TLHandle, TLShapeId } from '@tldraw/tlschema' import { dedupe, modulate, objectMapValues } from '@tldraw/utils' import classNames from 'classnames' import { Fragment, JSX, useEffect, useRef, useState } from 'react' -import { COARSE_HANDLE_RADIUS, HANDLE_RADIUS } from '../../constants' +import { COARSE_HANDLE_RADIUS, HANDLE_RADIUS, TEXT_SHADOW_LOD } from '../../constants' import { useCanvasEvents } from '../../hooks/useCanvasEvents' import { useCoarsePointer } from '../../hooks/useCoarsePointer' +import { useContainer } from '../../hooks/useContainer' import { useDocumentEvents } from '../../hooks/useDocumentEvents' import { useEditor } from '../../hooks/useEditor' import { useEditorComponents } from '../../hooks/useEditorComponents' @@ -37,6 +38,7 @@ export function DefaultCanvas({ className }: TLCanvasComponentProps) { const rCanvas = useRef(null) const rHtmlLayer = useRef(null) const rHtmlLayer2 = useRef(null) + const container = useContainer() useScreenBounds(rCanvas) useDocumentEvents() @@ -45,11 +47,37 @@ export function DefaultCanvas({ className }: TLCanvasComponentProps) { useGestureEvents(rCanvas) useFixSafariDoubleTapZoomPencilEvents(rCanvas) + const rMemoizedStuff = useRef({ lodDisableTextOutline: false, allowTextOutline: true }) + useQuickReactor( 'position layers', function positionLayersWhenCameraMoves() { const { x, y, z } = editor.getCamera() + // This should only run once on first load + if (rMemoizedStuff.current.allowTextOutline && editor.environment.isSafari) { + container.style.setProperty('--tl-text-outline', 'none') + rMemoizedStuff.current.allowTextOutline = false + } + + // And this should only run if we're not in Safari; + // If we're below the lod distance for text shadows, turn them off + if ( + rMemoizedStuff.current.allowTextOutline && + z < TEXT_SHADOW_LOD !== rMemoizedStuff.current.lodDisableTextOutline + ) { + const lodDisableTextOutline = z < TEXT_SHADOW_LOD + container.style.setProperty( + '--tl-text-outline', + lodDisableTextOutline + ? 'none' + : `0 var(--b) 0 var(--color-background), 0 var(--a) 0 var(--color-background), + var(--b) var(--b) 0 var(--color-background), var(--a) var(--b) 0 var(--color-background), + var(--a) var(--a) 0 var(--color-background), var(--b) var(--a) 0 var(--color-background)` + ) + rMemoizedStuff.current.lodDisableTextOutline = lodDisableTextOutline + } + // Because the html container has a width/height of 1px, we // need to create a small offset when zoomed to ensure that // the html container and svg container are lined up exactly. @@ -62,7 +90,7 @@ export function DefaultCanvas({ className }: TLCanvasComponentProps) { setStyleProperty(rHtmlLayer.current, 'transform', transform) setStyleProperty(rHtmlLayer2.current, 'transform', transform) }, - [editor] + [editor, container] ) const events = useCanvasEvents() diff --git a/packages/editor/src/lib/constants.ts b/packages/editor/src/lib/constants.ts index 233119932..418491770 100644 --- a/packages/editor/src/lib/constants.ts +++ b/packages/editor/src/lib/constants.ts @@ -107,3 +107,6 @@ export const HANDLE_RADIUS = 12 /** @internal */ export const LONG_PRESS_DURATION = 500 + +/** @internal */ +export const TEXT_SHADOW_LOD = 0.35 From 987b1ac0b93f6088e7affdd1b597afff50a0fd51 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mitja=20Bezen=C5=A1ek?= Date: Wed, 10 Apr 2024 12:29:11 +0200 Subject: [PATCH 19/26] Perf: Incremental culled shapes calculation. (#3411) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Reworks our culling logic: - No longer show the gray rectangles for culled shapes. - Don't use `renderingBoundExpanded`, instead we now use `viewportPageBounds`. I've removed `renderingBoundsExpanded`, but we might want to deprecate it? - There's now a incremental computation of non visible shapes, which are shapes outside of `viewportPageBounds` and shapes that outside of their parents' clipping bounds. - There's also a new `getCulledShapes` function in `Editor`, which uses the non visible shapes computation as a part of the culled shape computation. - Also moved some of the `getRenderingShapes` tests to newly created `getCullingShapes` tests. Feels much better on my old, 2017 ipad (first tab is this PR, second is current prod, third is staging). https://github.com/tldraw/tldraw/assets/2523721/327a7313-9273-4350-89a0-617a30fc01a2 ### Change Type - [x] `sdk` — Changes the tldraw SDK - [ ] `dotcom` — Changes the tldraw.com web app - [ ] `docs` — Changes to the documentation, examples, or templates. - [ ] `vs code` — Changes to the vscode plugin - [ ] `internal` — Does not affect user-facing stuff - [ ] `bugfix` — Bug fix - [ ] `feature` — New feature - [x] `improvement` — Improving existing features - [ ] `chore` — Updating dependencies, other boring stuff - [ ] `galaxy brain` — Architectural changes - [ ] `tests` — Changes to any test code - [ ] `tools` — Changes to infrastructure, CI, internal scripts, debugging tools, etc. - [ ] `dunno` — I don't know ### Test Plan 1. Regular culling shapes tests. Pan / zoom around. Use minimap. Change pages. - [x] Unit Tests - [ ] End to end tests --------- Co-authored-by: Steve Ruiz --- .../useRenderingShapesChange.ts | 20 +- packages/editor/api-report.md | 3 +- packages/editor/api/api.json | 135 +++++-------- .../src/lib/components/CulledShapes.tsx | 186 ------------------ packages/editor/src/lib/components/Shape.tsx | 11 +- .../default-components/DefaultCanvas.tsx | 29 ++- packages/editor/src/lib/editor/Editor.ts | 82 +++----- .../editor/derivations/notVisibleShapes.ts | 105 ++++++++++ .../tldraw/src/test/getCulledShapes.test.tsx | 138 +++++++++++++ .../tldraw/src/test/renderingShapes.test.tsx | 49 ----- 10 files changed, 357 insertions(+), 401 deletions(-) delete mode 100644 packages/editor/src/lib/components/CulledShapes.tsx create mode 100644 packages/editor/src/lib/editor/derivations/notVisibleShapes.ts create mode 100644 packages/tldraw/src/test/getCulledShapes.test.tsx diff --git a/apps/examples/src/examples/rendering-shape-changes/useRenderingShapesChange.ts b/apps/examples/src/examples/rendering-shape-changes/useRenderingShapesChange.ts index 39b6145f3..4b4ac137f 100644 --- a/apps/examples/src/examples/rendering-shape-changes/useRenderingShapesChange.ts +++ b/apps/examples/src/examples/rendering-shape-changes/useRenderingShapesChange.ts @@ -5,25 +5,31 @@ export function useChangedShapesReactor( cb: (info: { culled: TLShape[]; restored: TLShape[] }) => void ) { const editor = useEditor() - const rPrevShapes = useRef(editor.getRenderingShapes()) + const rPrevShapes = useRef({ + renderingShapes: editor.getRenderingShapes(), + culledShapes: editor.getCulledShapes(), + }) useEffect(() => { return react('when rendering shapes change', () => { - const after = editor.getRenderingShapes() + const after = { + culledShapes: editor.getCulledShapes(), + renderingShapes: editor.getRenderingShapes(), + } const before = rPrevShapes.current const culled: TLShape[] = [] const restored: TLShape[] = [] - const beforeToVisit = new Set(before) + const beforeToVisit = new Set(before.renderingShapes) - for (const afterInfo of after) { - const beforeInfo = before.find((s) => s.id === afterInfo.id) + for (const afterInfo of after.renderingShapes) { + const beforeInfo = before.renderingShapes.find((s) => s.id === afterInfo.id) if (!beforeInfo) { continue } else { - const isAfterCulled = editor.isShapeCulled(afterInfo.id) - const isBeforeCulled = editor.isShapeCulled(beforeInfo.id) + 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) { diff --git a/packages/editor/api-report.md b/packages/editor/api-report.md index 2b8563890..f78dd11c7 100644 --- a/packages/editor/api-report.md +++ b/packages/editor/api-report.md @@ -675,6 +675,7 @@ export class Editor extends EventEmitter { // @internal getCrashingError(): unknown; getCroppingShapeId(): null | TLShapeId; + getCulledShapes(): Set; getCurrentPage(): TLPage; getCurrentPageBounds(): Box | undefined; getCurrentPageId(): TLPageId; @@ -712,7 +713,6 @@ export class Editor extends EventEmitter { getPointInParentSpace(shape: TLShape | TLShapeId, point: VecLike): Vec; getPointInShapeSpace(shape: TLShape | TLShapeId, point: VecLike): Vec; getRenderingBounds(): Box; - getRenderingBoundsExpanded(): Box; getRenderingShapes(): { id: TLShapeId; shape: TLShape; @@ -817,7 +817,6 @@ export class Editor extends EventEmitter { margin?: number | undefined; hitInside?: boolean | undefined; }): boolean; - isShapeCulled(shape: TLShape | TLShapeId): boolean; isShapeInPage(shape: TLShape | TLShapeId, pageId?: TLPageId): boolean; isShapeOfType(shape: TLUnknownShape, type: T['type']): shape is T; // (undocumented) diff --git a/packages/editor/api/api.json b/packages/editor/api/api.json index a607d0b27..e0d8317a7 100644 --- a/packages/editor/api/api.json +++ b/packages/editor/api/api.json @@ -10284,6 +10284,51 @@ "isAbstract": false, "name": "getCroppingShapeId" }, + { + "kind": "Method", + "canonicalReference": "@tldraw/editor!Editor#getCulledShapes:member(1)", + "docComment": "/**\n * Get culled shapes.\n *\n * @public\n */\n", + "excerptTokens": [ + { + "kind": "Content", + "text": "getCulledShapes(): " + }, + { + "kind": "Reference", + "text": "Set", + "canonicalReference": "!Set:interface" + }, + { + "kind": "Content", + "text": "<" + }, + { + "kind": "Reference", + "text": "TLShapeId", + "canonicalReference": "@tldraw/tlschema!TLShapeId:type" + }, + { + "kind": "Content", + "text": ">" + }, + { + "kind": "Content", + "text": ";" + } + ], + "isStatic": false, + "returnTypeTokenRange": { + "startIndex": 1, + "endIndex": 5 + }, + "releaseTag": "Public", + "isProtected": false, + "overloadIndex": 1, + "parameters": [], + "isOptional": false, + "isAbstract": false, + "name": "getCulledShapes" + }, { "kind": "Method", "canonicalReference": "@tldraw/editor!Editor#getCurrentPage:member(1)", @@ -11876,38 +11921,6 @@ "isAbstract": false, "name": "getRenderingBounds" }, - { - "kind": "Method", - "canonicalReference": "@tldraw/editor!Editor#getRenderingBoundsExpanded:member(1)", - "docComment": "/**\n * The current rendering bounds in the current page space, expanded slightly. Used for determining which shapes to render and which to \"cull\".\n *\n * @public\n */\n", - "excerptTokens": [ - { - "kind": "Content", - "text": "getRenderingBoundsExpanded(): " - }, - { - "kind": "Reference", - "text": "Box", - "canonicalReference": "@tldraw/editor!Box:class" - }, - { - "kind": "Content", - "text": ";" - } - ], - "isStatic": false, - "returnTypeTokenRange": { - "startIndex": 1, - "endIndex": 2 - }, - "releaseTag": "Public", - "isProtected": false, - "overloadIndex": 1, - "parameters": [], - "isOptional": false, - "isAbstract": false, - "name": "getRenderingBoundsExpanded" - }, { "kind": "Method", "canonicalReference": "@tldraw/editor!Editor#getRenderingShapes:member(1)", @@ -14814,64 +14827,6 @@ "isAbstract": false, "name": "isPointInShape" }, - { - "kind": "Method", - "canonicalReference": "@tldraw/editor!Editor#isShapeCulled:member(1)", - "docComment": "/**\n * Get whether the shape is culled or not.\n *\n * @param shape - The shape (or shape id) to get the culled info for.\n *\n * @example\n * ```ts\n * editor.isShapeCulled(myShape)\n * editor.isShapeCulled(myShapeId)\n * ```\n *\n * @public\n */\n", - "excerptTokens": [ - { - "kind": "Content", - "text": "isShapeCulled(shape: " - }, - { - "kind": "Reference", - "text": "TLShape", - "canonicalReference": "@tldraw/tlschema!TLShape:type" - }, - { - "kind": "Content", - "text": " | " - }, - { - "kind": "Reference", - "text": "TLShapeId", - "canonicalReference": "@tldraw/tlschema!TLShapeId:type" - }, - { - "kind": "Content", - "text": "): " - }, - { - "kind": "Content", - "text": "boolean" - }, - { - "kind": "Content", - "text": ";" - } - ], - "isStatic": false, - "returnTypeTokenRange": { - "startIndex": 5, - "endIndex": 6 - }, - "releaseTag": "Public", - "isProtected": false, - "overloadIndex": 1, - "parameters": [ - { - "parameterName": "shape", - "parameterTypeTokenRange": { - "startIndex": 1, - "endIndex": 4 - }, - "isOptional": false - } - ], - "isOptional": false, - "isAbstract": false, - "name": "isShapeCulled" - }, { "kind": "Method", "canonicalReference": "@tldraw/editor!Editor#isShapeInPage:member(1)", diff --git a/packages/editor/src/lib/components/CulledShapes.tsx b/packages/editor/src/lib/components/CulledShapes.tsx deleted file mode 100644 index 937514083..000000000 --- a/packages/editor/src/lib/components/CulledShapes.tsx +++ /dev/null @@ -1,186 +0,0 @@ -import { computed, react } from '@tldraw/state' -import { useEffect, useRef } from 'react' -import { useEditor } from '../hooks/useEditor' -import { useIsDarkMode } from '../hooks/useIsDarkMode' - -// Parts of the below code are taken from MIT licensed project: -// https://github.com/sessamekesh/webgl-tutorials-2023 -function setupWebGl(canvas: HTMLCanvasElement | null, isDarkMode: boolean) { - if (!canvas) return - - const context = canvas.getContext('webgl2') - if (!context) return - - const vertexShaderSourceCode = `#version 300 es - precision mediump float; - - in vec2 shapeVertexPosition; - uniform vec2 viewportStart; - uniform vec2 viewportEnd; - - void main() { - // We need to transform from page coordinates to something WebGl understands - float viewportWidth = viewportEnd.x - viewportStart.x; - float viewportHeight = viewportEnd.y - viewportStart.y; - vec2 finalPosition = vec2( - 2.0 * (shapeVertexPosition.x - viewportStart.x) / viewportWidth - 1.0, - 1.0 - 2.0 * (shapeVertexPosition.y - viewportStart.y) / viewportHeight - ); - gl_Position = vec4(finalPosition, 0.0, 1.0); - }` - - const vertexShader = context.createShader(context.VERTEX_SHADER) - if (!vertexShader) return - context.shaderSource(vertexShader, vertexShaderSourceCode) - context.compileShader(vertexShader) - if (!context.getShaderParameter(vertexShader, context.COMPILE_STATUS)) { - return - } - // Dark = hsl(210, 11%, 19%) - // Light = hsl(204, 14%, 93%) - const color = isDarkMode ? 'vec4(0.169, 0.188, 0.212, 1.0)' : 'vec4(0.922, 0.933, 0.941, 1.0)' - - const fragmentShaderSourceCode = `#version 300 es - precision mediump float; - - out vec4 outputColor; - - void main() { - outputColor = ${color}; - }` - - const fragmentShader = context.createShader(context.FRAGMENT_SHADER) - if (!fragmentShader) return - context.shaderSource(fragmentShader, fragmentShaderSourceCode) - context.compileShader(fragmentShader) - if (!context.getShaderParameter(fragmentShader, context.COMPILE_STATUS)) { - return - } - - const program = context.createProgram() - if (!program) return - context.attachShader(program, vertexShader) - context.attachShader(program, fragmentShader) - context.linkProgram(program) - if (!context.getProgramParameter(program, context.LINK_STATUS)) { - return - } - context.useProgram(program) - - const shapeVertexPositionAttributeLocation = context.getAttribLocation( - program, - 'shapeVertexPosition' - ) - if (shapeVertexPositionAttributeLocation < 0) { - return - } - context.enableVertexAttribArray(shapeVertexPositionAttributeLocation) - - const viewportStartUniformLocation = context.getUniformLocation(program, 'viewportStart') - const viewportEndUniformLocation = context.getUniformLocation(program, 'viewportEnd') - if (!viewportStartUniformLocation || !viewportEndUniformLocation) { - return - } - return { - context, - program, - shapeVertexPositionAttributeLocation, - viewportStartUniformLocation, - viewportEndUniformLocation, - } -} - -function _CulledShapes() { - const editor = useEditor() - const isDarkMode = useIsDarkMode() - const canvasRef = useRef(null) - - const isCullingOffScreenShapes = Number.isFinite(editor.renderingBoundsMargin) - - useEffect(() => { - const webGl = setupWebGl(canvasRef.current, isDarkMode) - if (!webGl) return - if (!isCullingOffScreenShapes) return - - const { - context, - shapeVertexPositionAttributeLocation, - viewportStartUniformLocation, - viewportEndUniformLocation, - } = webGl - - const shapeVertices = computed('shape vertices', function calculateCulledShapeVertices() { - const results: number[] = [] - - for (const { id } of editor.getUnorderedRenderingShapes(true)) { - const maskedPageBounds = editor.getShapeMaskedPageBounds(id) - if (editor.isShapeCulled(id) && maskedPageBounds) { - results.push( - // triangle 1 - maskedPageBounds.minX, - maskedPageBounds.minY, - maskedPageBounds.minX, - maskedPageBounds.maxY, - maskedPageBounds.maxX, - maskedPageBounds.maxY, - // triangle 2 - maskedPageBounds.minX, - maskedPageBounds.minY, - maskedPageBounds.maxX, - maskedPageBounds.minY, - maskedPageBounds.maxX, - maskedPageBounds.maxY - ) - } - } - - return results - }) - - return react('render culled shapes ', function renderCulledShapes() { - const canvas = canvasRef.current - if (!canvas) return - - const width = canvas.clientWidth - const height = canvas.clientHeight - if (width !== canvas.width || height !== canvas.height) { - canvas.width = width - canvas.height = height - context.viewport(0, 0, width, height) - } - - const verticesArray = shapeVertices.get() - - context.clear(context.COLOR_BUFFER_BIT | context.DEPTH_BUFFER_BIT) - - if (verticesArray.length > 0) { - const viewport = editor.getViewportPageBounds() // when the viewport changes... - context.uniform2f(viewportStartUniformLocation, viewport.minX, viewport.minY) - context.uniform2f(viewportEndUniformLocation, viewport.maxX, viewport.maxY) - const triangleGeoCpuBuffer = new Float32Array(verticesArray) - const triangleGeoBuffer = context.createBuffer() - context.bindBuffer(context.ARRAY_BUFFER, triangleGeoBuffer) - context.bufferData(context.ARRAY_BUFFER, triangleGeoCpuBuffer, context.STATIC_DRAW) - context.vertexAttribPointer( - shapeVertexPositionAttributeLocation, - 2, - context.FLOAT, - false, - 2 * Float32Array.BYTES_PER_ELEMENT, - 0 - ) - context.drawArrays(context.TRIANGLES, 0, verticesArray.length / 2) - } - }) - }, [isCullingOffScreenShapes, isDarkMode, editor]) - return isCullingOffScreenShapes ? ( - - ) : null -} - -export function CulledShapes() { - if (process.env.NODE_ENV === 'test') { - return null - } - return _CulledShapes() -} diff --git a/packages/editor/src/lib/components/Shape.tsx b/packages/editor/src/lib/components/Shape.tsx index 8e21a66ed..909d51885 100644 --- a/packages/editor/src/lib/components/Shape.tsx +++ b/packages/editor/src/lib/components/Shape.tsx @@ -50,6 +50,7 @@ export const Shape = memo(function Shape({ height: 0, x: 0, y: 0, + isCulled: false, }) useQuickReactor( @@ -124,9 +125,13 @@ export const Shape = memo(function Shape({ const shape = editor.getShape(id) if (!shape) return // probably the shape was just deleted - const isCulled = editor.isShapeCulled(shape) - setStyleProperty(containerRef.current, 'display', isCulled ? 'none' : 'block') - setStyleProperty(bgContainerRef.current, 'display', isCulled ? 'none' : 'block') + const culledShapes = editor.getCulledShapes() + const isCulled = culledShapes.has(id) + if (isCulled !== memoizedStuffRef.current.isCulled) { + setStyleProperty(containerRef.current, 'display', isCulled ? 'none' : 'block') + setStyleProperty(bgContainerRef.current, 'display', isCulled ? 'none' : 'block') + memoizedStuffRef.current.isCulled = isCulled + } }, [editor] ) diff --git a/packages/editor/src/lib/components/default-components/DefaultCanvas.tsx b/packages/editor/src/lib/components/default-components/DefaultCanvas.tsx index 7d14a6cd2..9efa5b477 100644 --- a/packages/editor/src/lib/components/default-components/DefaultCanvas.tsx +++ b/packages/editor/src/lib/components/default-components/DefaultCanvas.tsx @@ -21,7 +21,6 @@ import { toDomPrecision } from '../../primitives/utils' import { debugFlags } from '../../utils/debug-flags' import { setStyleProperty } from '../../utils/dom' import { nearestMultiple } from '../../utils/nearestMultiple' -import { CulledShapes } from '../CulledShapes' import { GeometryDebuggingView } from '../GeometryDebuggingView' import { LiveCollaborators } from '../LiveCollaborators' import { Shape } from '../Shape' @@ -125,9 +124,6 @@ export function DefaultCanvas({ className }: TLCanvasComponentProps) {
)} -
- -
) } +function ReflowIfNeeded() { + const editor = useEditor() + const culledShapesRef = useRef>(new Set()) + useQuickReactor( + 'reflow for culled shapes', + () => { + const culledShapes = editor.getCulledShapes() + if ( + culledShapesRef.current.size === culledShapes.size && + [...culledShapes].every((id) => culledShapesRef.current.has(id)) + ) + return + + culledShapesRef.current = culledShapes + const canvas = document.getElementsByClassName('tl-canvas') + if (canvas.length === 0) return + // This causes a reflow + // https://gist.github.com/paulirish/5d52fb081b3570c81e3a + const _height = (canvas[0] as HTMLDivElement).offsetHeight + }, + [editor] + ) + return null +} function ShapesToDisplay() { const editor = useEditor() @@ -408,6 +428,7 @@ function ShapesToDisplay() { {renderingShapes.map((result) => ( ))} + {editor.environment.isSafari && } ) } diff --git a/packages/editor/src/lib/editor/Editor.ts b/packages/editor/src/lib/editor/Editor.ts index 62dccf35f..33a1de566 100644 --- a/packages/editor/src/lib/editor/Editor.ts +++ b/packages/editor/src/lib/editor/Editor.ts @@ -102,6 +102,7 @@ import { getReorderingShapesChanges } from '../utils/reorderShapes' import { applyRotationToSnapshotShapes, getRotationSnapshot } from '../utils/rotation' import { uniqueId } from '../utils/uniqueId' import { arrowBindingsIndex } from './derivations/arrowBindingsIndex' +import { notVisibleShapes } from './derivations/notVisibleShapes' import { parentsToChildren } from './derivations/parentsToChildren' import { deriveShapeIdsInCurrentPage } from './derivations/shapeIdsInCurrentPage' import { getSvgJsx } from './getSvgJsx' @@ -3224,19 +3225,6 @@ export class Editor extends EventEmitter { /** @internal */ private readonly _renderingBounds = atom('rendering viewport', new Box()) - /** - * The current rendering bounds in the current page space, expanded slightly. Used for determining which shapes - * to render and which to "cull". - * - * @public - */ - getRenderingBoundsExpanded() { - return this._renderingBoundsExpanded.get() - } - - /** @internal */ - private readonly _renderingBoundsExpanded = atom('rendering viewport expanded', 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. @@ -3254,13 +3242,6 @@ export class Editor extends EventEmitter { if (viewportPageBounds.equals(this._renderingBounds.__unsafe__getWithoutCapture())) return this this._renderingBounds.set(viewportPageBounds.clone()) - if (Number.isFinite(this.renderingBoundsMargin)) { - this._renderingBoundsExpanded.set( - viewportPageBounds.clone().expandBy(this.renderingBoundsMargin / this.getZoomLevel()) - ) - } else { - this._renderingBoundsExpanded.set(viewportPageBounds) - } return this } @@ -4243,48 +4224,30 @@ export class Editor extends EventEmitter { } @computed - private _getShapeCullingInfoCache(): ComputedCache { - return this.store.createComputedCache( - 'shapeCullingInfo', - ({ id }) => { - // We don't cull shapes that are being edited - if (this.getEditingShapeId() === id) return false - - const maskedPageBounds = this.getShapeMaskedPageBounds(id) - // if the shape is fully outside of its parent's clipping bounds... - if (maskedPageBounds === undefined) return true - - // We don't cull selected shapes - if (this.getSelectedShapeIds().includes(id)) return false - const renderingBoundsExpanded = this.getRenderingBoundsExpanded() - // the shape is outside of the expanded viewport bounds... - return !renderingBoundsExpanded.includes(maskedPageBounds) - }, - (a, b) => this.getShapeMaskedPageBounds(a) === this.getShapeMaskedPageBounds(b) - ) + private _notVisibleShapes() { + return notVisibleShapes(this) } /** - * Get whether the shape is culled or not. - * - * @example - * ```ts - * editor.isShapeCulled(myShape) - * editor.isShapeCulled(myShapeId) - * ``` - * - * @param shape - The shape (or shape id) to get the culled info for. + * Get culled shapes. * * @public */ - isShapeCulled(shape: TLShape | TLShapeId): boolean { - // If renderingBoundsMargin is set to Infinity, then we won't cull offscreen shapes - const isCullingOffScreenShapes = Number.isFinite(this.renderingBoundsMargin) - if (!isCullingOffScreenShapes) return false - - const id = typeof shape === 'string' ? shape : shape.id - - return this._getShapeCullingInfoCache().get(id)! as boolean + @computed + getCulledShapes() { + const notVisibleShapes = this._notVisibleShapes().get() + const selectedShapeIds = this.getSelectedShapeIds() + const editingId = this.getEditingShapeId() + const culledShapes = new Set(notVisibleShapes) + // we don't cull the shape we are editing + if (editingId) { + culledShapes.delete(editingId) + } + // we also don't cull selected shapes + selectedShapeIds.forEach((id) => { + culledShapes.delete(id) + }) + return culledShapes } /** @@ -4369,7 +4332,6 @@ export class Editor extends EventEmitter { if (filter) return filter(shape) return true }) - for (let i = shapesToCheck.length - 1; i >= 0; i--) { const shape = shapesToCheck[i] const geometry = this.getShapeGeometry(shape) @@ -4657,9 +4619,9 @@ export class Editor extends EventEmitter { * * @public */ - @computed - getCurrentPageRenderingShapesSorted(): TLShape[] { - return this.getCurrentPageShapesSorted().filter((shape) => !this.isShapeCulled(shape)) + @computed getCurrentPageRenderingShapesSorted(): TLShape[] { + const culledShapes = this.getCulledShapes() + return this.getCurrentPageShapesSorted().filter(({ id }) => !culledShapes.has(id)) } /** diff --git a/packages/editor/src/lib/editor/derivations/notVisibleShapes.ts b/packages/editor/src/lib/editor/derivations/notVisibleShapes.ts new file mode 100644 index 000000000..461835500 --- /dev/null +++ b/packages/editor/src/lib/editor/derivations/notVisibleShapes.ts @@ -0,0 +1,105 @@ +import { RESET_VALUE, computed, isUninitialized } from '@tldraw/state' +import { TLPageId, TLShapeId, isShape, isShapeId } from '@tldraw/tlschema' +import { Box } from '../../primitives/Box' +import { Editor } from '../Editor' + +function isShapeNotVisible(editor: Editor, id: TLShapeId, viewportPageBounds: Box): boolean { + const maskedPageBounds = editor.getShapeMaskedPageBounds(id) + // if the shape is fully outside of its parent's clipping bounds... + if (maskedPageBounds === undefined) return true + + // if the shape is fully outside of the viewport page bounds... + return !viewportPageBounds.includes(maskedPageBounds) +} + +/** + * Incremental derivation of not visible shapes. + * Non visible shapes are shapes outside of the viewport page bounds and shapes outside of parent's clipping bounds. + * + * @param editor - Instance of the tldraw Editor. + * @returns Incremental derivation of non visible shapes. + */ +export const notVisibleShapes = (editor: Editor) => { + const isCullingOffScreenShapes = Number.isFinite(editor.renderingBoundsMargin) + const shapeHistory = editor.store.query.filterHistory('shape') + let lastPageId: TLPageId | null = null + let prevViewportPageBounds: Box + + function fromScratch(editor: Editor): Set { + const shapes = editor.getCurrentPageShapeIds() + lastPageId = editor.getCurrentPageId() + const viewportPageBounds = editor.getViewportPageBounds() + prevViewportPageBounds = viewportPageBounds.clone() + const notVisibleShapes = new Set() + shapes.forEach((id) => { + if (isShapeNotVisible(editor, id, viewportPageBounds)) { + notVisibleShapes.add(id) + } + }) + return notVisibleShapes + } + return computed>('getCulledShapes', (prevValue, lastComputedEpoch) => { + if (!isCullingOffScreenShapes) return new Set() + + if (isUninitialized(prevValue)) { + return fromScratch(editor) + } + const diff = shapeHistory.getDiffSince(lastComputedEpoch) + + if (diff === RESET_VALUE) { + return fromScratch(editor) + } + + const currentPageId = editor.getCurrentPageId() + if (lastPageId !== currentPageId) { + return fromScratch(editor) + } + const viewportPageBounds = editor.getViewportPageBounds() + if (!prevViewportPageBounds || !viewportPageBounds.equals(prevViewportPageBounds)) { + return fromScratch(editor) + } + + let nextValue = null as null | Set + const addId = (id: TLShapeId) => { + // Already added + if (prevValue.has(id)) return + if (!nextValue) nextValue = new Set(prevValue) + nextValue.add(id) + } + const deleteId = (id: TLShapeId) => { + // No need to delete since it's not there + if (!prevValue.has(id)) return + if (!nextValue) nextValue = new Set(prevValue) + nextValue.delete(id) + } + + for (const changes of diff) { + for (const record of Object.values(changes.added)) { + if (isShape(record)) { + const isCulled = isShapeNotVisible(editor, record.id, viewportPageBounds) + if (isCulled) { + addId(record.id) + } + } + } + + for (const [_from, to] of Object.values(changes.updated)) { + if (isShape(to)) { + const isCulled = isShapeNotVisible(editor, to.id, viewportPageBounds) + if (isCulled) { + addId(to.id) + } else { + deleteId(to.id) + } + } + } + for (const id of Object.keys(changes.removed)) { + if (isShapeId(id)) { + deleteId(id) + } + } + } + + return nextValue ?? prevValue + }) +} diff --git a/packages/tldraw/src/test/getCulledShapes.test.tsx b/packages/tldraw/src/test/getCulledShapes.test.tsx new file mode 100644 index 000000000..53c99bd35 --- /dev/null +++ b/packages/tldraw/src/test/getCulledShapes.test.tsx @@ -0,0 +1,138 @@ +import { Box, TLShapeId, createShapeId } from '@tldraw/editor' +import { TestEditor } from './TestEditor' +import { TL } from './test-jsx' + +let editor: TestEditor + +beforeEach(() => { + editor = new TestEditor() + editor.setScreenBounds({ x: 0, y: 0, w: 1800, h: 900 }) + editor.renderingBoundsMargin = 100 +}) + +function createShapes() { + return editor.createShapesFromJsx([ + , + + + {/* this is outside of the frames clipping bounds, so it should never be rendered */} + + , + ]) +} + +it('lists shapes in viewport', () => { + const ids = createShapes() + editor.selectNone() + // D is clipped and so should always be culled / outside of viewport + expect(editor.getCulledShapes()).toStrictEqual(new Set([ids.D])) + + // Move the camera 201 pixels to the right and 201 pixels down + editor.pan({ x: -201, y: -201 }) + jest.advanceTimersByTime(500) + + // A is now outside of the viewport + expect(editor.getCulledShapes()).toStrictEqual(new Set([ids.A, ids.D])) + + editor.pan({ x: -900, y: -900 }) + jest.advanceTimersByTime(500) + // Now all shapes are outside of the viewport + expect(editor.getCulledShapes()).toStrictEqual(new Set([ids.A, ids.B, ids.C, ids.D])) + + editor.select(ids.B) + // We don't cull selected shapes + expect(editor.getCulledShapes()).toStrictEqual(new Set([ids.A, ids.C, ids.D])) + + editor.setEditingShape(ids.C) + // or shapes being edited + expect(editor.getCulledShapes()).toStrictEqual(new Set([ids.A, ids.D])) +}) + +const shapeSize = 100 +const numberOfShapes = 100 + +function getChangeOutsideBounds(viewportSize: number) { + const changeDirection = Math.random() > 0.5 ? 1 : -1 + const maxChange = 1000 + const changeAmount = 1 + Math.random() * maxChange + if (changeDirection === 1) { + // We need to get past the viewport size and then add a bit more + return viewportSize + changeAmount + } else { + // We also need to take the shape size into account + return -changeAmount - shapeSize + } +} + +function getChangeInsideBounds(viewportSize: number) { + // We can go from -shapeSize to viewportSize + return -shapeSize + Math.random() * (viewportSize + shapeSize) +} + +function createFuzzShape(viewport: Box) { + const id = createShapeId() + if (Math.random() > 0.5) { + const positionChange = Math.random() + // Should x, or y, or both go outside the bounds? + const dimensionChange = positionChange < 0.33 ? 'x' : positionChange < 0.66 ? 'y' : 'both' + const xOutsideBounds = dimensionChange === 'x' || dimensionChange === 'both' + const yOutsideBounds = dimensionChange === 'y' || dimensionChange === 'both' + + // Create a shape outside the viewport + editor.createShape({ + id, + type: 'geo', + x: + viewport.x + + (xOutsideBounds ? getChangeOutsideBounds(viewport.w) : getChangeInsideBounds(viewport.w)), + y: + viewport.y + + (yOutsideBounds ? getChangeOutsideBounds(viewport.h) : getChangeInsideBounds(viewport.h)), + props: { w: shapeSize, h: shapeSize }, + }) + return { isCulled: true, id } + } else { + // Create a shape inside the viewport + editor.createShape({ + id, + type: 'geo', + x: viewport.x + getChangeInsideBounds(viewport.w), + y: viewport.y + getChangeInsideBounds(viewport.h), + props: { w: shapeSize, h: shapeSize }, + }) + return { isCulled: false, id } + } +} + +it('correctly calculates the culled shapes when adding and deleting shapes', () => { + const viewport = editor.getViewportPageBounds() + const shapes: Array = [] + for (let i = 0; i < numberOfShapes; i++) { + const { isCulled, id } = createFuzzShape(viewport) + shapes.push(id) + if (isCulled) { + expect(editor.getCulledShapes()).toContain(id) + } else { + expect(editor.getCulledShapes()).not.toContain(id) + } + } + const numberOfShapesToDelete = Math.floor((Math.random() * numberOfShapes) / 2) + for (let i = 0; i < numberOfShapesToDelete; i++) { + const index = Math.floor(Math.random() * (shapes.length - 1)) + const id = shapes[index] + if (id) { + editor.deleteShape(id) + shapes[index] = undefined + expect(editor.getCulledShapes()).not.toContain(id) + } + } + + const culledShapesIncremental = editor.getCulledShapes() + + // force full refresh + editor.pan({ x: -1, y: 0 }) + editor.pan({ x: 1, y: 0 }) + + const culledShapeFromScratch = editor.getCulledShapes() + expect(culledShapesIncremental).toEqual(culledShapeFromScratch) +}) diff --git a/packages/tldraw/src/test/renderingShapes.test.tsx b/packages/tldraw/src/test/renderingShapes.test.tsx index 9b7c2ebbd..8b774da5b 100644 --- a/packages/tldraw/src/test/renderingShapes.test.tsx +++ b/packages/tldraw/src/test/renderingShapes.test.tsx @@ -60,55 +60,6 @@ it('updates the rendering viewport when the camera stops moving', () => { expect(editor.getShapePageBounds(ids.A)).toMatchObject({ x: 100, y: 100, w: 100, h: 100 }) }) -it('lists shapes in viewport', () => { - const ids = createShapes() - editor.selectNone() - expect(editor.getRenderingShapes().map(({ id }) => [id, editor.isShapeCulled(id)])).toStrictEqual( - [ - [ids.A, false], // A is within the expanded rendering bounds, so should not be culled; and it's in the regular viewport too, so it's on screen. - [ids.B, false], - [ids.C, false], - [ids.D, true], // D is clipped and so should always be culled / outside of viewport - ] - ) - - // Move the camera 201 pixels to the right and 201 pixels down - editor.pan({ x: -201, y: -201 }) - jest.advanceTimersByTime(500) - - expect(editor.getRenderingShapes().map(({ id }) => [id, editor.isShapeCulled(id)])).toStrictEqual( - [ - [ids.A, false], // A should not be culled, even though it's no longer in the viewport (because it's still in the EXPANDED viewport) - [ids.B, false], - [ids.C, false], - [ids.D, true], // D is clipped and so should always be culled / outside of viewport - ] - ) - - editor.pan({ x: -100, y: -100 }) - jest.advanceTimersByTime(500) - - expect(editor.getRenderingShapes().map(({ id }) => [id, editor.isShapeCulled(id)])).toStrictEqual( - [ - [ids.A, true], // A should be culled now that it's outside of the expanded viewport too - [ids.B, false], - [ids.C, false], - [ids.D, true], // D is clipped and so should always be culled, even if it's in the viewport - ] - ) - - editor.pan({ x: -900, y: -900 }) - jest.advanceTimersByTime(500) - expect(editor.getRenderingShapes().map(({ id }) => [id, editor.isShapeCulled(id)])).toStrictEqual( - [ - [ids.A, true], - [ids.B, true], - [ids.C, true], - [ids.D, true], - ] - ) -}) - 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 From 180cb6725065a40914ccfdbb1d98f4e32d0b64a3 Mon Sep 17 00:00:00 2001 From: Steve Ruiz Date: Wed, 10 Apr 2024 13:02:50 +0100 Subject: [PATCH 20/26] Improve hand dragging with long press (#3432) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This PR makes a small improvement to the hand tool to address a "long press"-related issues. ### Change Type - [x] `sdk` — Changes the tldraw SDK - [x] `bugfix` — Bug fix --- packages/editor/editor.css | 2 ++ packages/store/src/lib/devFreeze.ts | 2 ++ .../tools/EraserTool/childStates/Pointing.ts | 11 ++++++- .../tools/HandTool/childStates/Dragging.ts | 33 ++++++++++++------- .../tools/HandTool/childStates/Pointing.ts | 12 +++++-- 5 files changed, 45 insertions(+), 15 deletions(-) diff --git a/packages/editor/editor.css b/packages/editor/editor.css index 6d06bfb69..8ca9da246 100644 --- a/packages/editor/editor.css +++ b/packages/editor/editor.css @@ -280,6 +280,8 @@ input, position: absolute; top: 0px; left: 0px; + width: 100%; + height: 100%; pointer-events: none; } diff --git a/packages/store/src/lib/devFreeze.ts b/packages/store/src/lib/devFreeze.ts index d1c9622d4..d481ad37e 100644 --- a/packages/store/src/lib/devFreeze.ts +++ b/packages/store/src/lib/devFreeze.ts @@ -15,6 +15,8 @@ import { STRUCTURED_CLONE_OBJECT_PROTOTYPE } from '@tldraw/utils' * @public */ export function devFreeze(object: T): T { + return object + if (process.env.NODE_ENV === 'production') { return object } diff --git a/packages/tldraw/src/lib/tools/EraserTool/childStates/Pointing.ts b/packages/tldraw/src/lib/tools/EraserTool/childStates/Pointing.ts index 8d3da33aa..bf46a8c01 100644 --- a/packages/tldraw/src/lib/tools/EraserTool/childStates/Pointing.ts +++ b/packages/tldraw/src/lib/tools/EraserTool/childStates/Pointing.ts @@ -4,6 +4,7 @@ import { TLEventHandlers, TLFrameShape, TLGroupShape, + TLPointerEventInfo, TLShapeId, } from '@tldraw/editor' @@ -52,9 +53,13 @@ export class Pointing extends StateNode { this.editor.setErasingShapes([...erasing]) } + override onLongPress: TLEventHandlers['onLongPress'] = (info) => { + this.startErasing(info) + } + override onPointerMove: TLEventHandlers['onPointerMove'] = (info) => { if (this.editor.inputs.isDragging) { - this.parent.transition('erasing', info) + this.startErasing(info) } } @@ -74,6 +79,10 @@ export class Pointing extends StateNode { this.cancel() } + private startErasing(info: TLPointerEventInfo) { + this.parent.transition('erasing', info) + } + complete() { const erasingShapeIds = this.editor.getErasingShapeIds() diff --git a/packages/tldraw/src/lib/tools/HandTool/childStates/Dragging.ts b/packages/tldraw/src/lib/tools/HandTool/childStates/Dragging.ts index 8d6e60553..a0138ef0e 100644 --- a/packages/tldraw/src/lib/tools/HandTool/childStates/Dragging.ts +++ b/packages/tldraw/src/lib/tools/HandTool/childStates/Dragging.ts @@ -3,7 +3,10 @@ import { CAMERA_SLIDE_FRICTION, StateNode, TLEventHandlers, Vec } from '@tldraw/ export class Dragging extends StateNode { static override id = 'dragging' + initialCamera = new Vec() + override onEnter = () => { + this.initialCamera = Vec.From(this.editor.getCamera()) this.update() } @@ -16,7 +19,7 @@ export class Dragging extends StateNode { } override onCancel: TLEventHandlers['onCancel'] = () => { - this.complete() + this.parent.transition('idle') } override onComplete = () => { @@ -24,21 +27,27 @@ export class Dragging extends StateNode { } private update() { - const { currentScreenPoint, previousScreenPoint } = this.editor.inputs + const { initialCamera, editor } = this + const { currentScreenPoint, originScreenPoint } = editor.inputs - const delta = Vec.Sub(currentScreenPoint, previousScreenPoint) - - if (Math.abs(delta.x) > 0 || Math.abs(delta.y) > 0) { - this.editor.pan(delta) - } + const delta = Vec.Sub(currentScreenPoint, originScreenPoint).div(editor.getZoomLevel()) + if (delta.len2() === 0) return + editor.setCamera(initialCamera.clone().add(delta)) } private complete() { - this.editor.slideCamera({ - speed: Math.min(2, this.editor.inputs.pointerVelocity.len()), - direction: this.editor.inputs.pointerVelocity, - friction: CAMERA_SLIDE_FRICTION, - }) + const { editor } = this + const { pointerVelocity } = editor.inputs + + const velocityAtPointerUp = Math.min(pointerVelocity.len(), 2) + + if (velocityAtPointerUp > 0.1) { + this.editor.slideCamera({ + speed: velocityAtPointerUp, + direction: pointerVelocity, + friction: CAMERA_SLIDE_FRICTION, + }) + } this.parent.transition('idle') } diff --git a/packages/tldraw/src/lib/tools/HandTool/childStates/Pointing.ts b/packages/tldraw/src/lib/tools/HandTool/childStates/Pointing.ts index 6a585f3b8..0d8390d21 100644 --- a/packages/tldraw/src/lib/tools/HandTool/childStates/Pointing.ts +++ b/packages/tldraw/src/lib/tools/HandTool/childStates/Pointing.ts @@ -11,12 +11,20 @@ export class Pointing extends StateNode { ) } - override onPointerMove: TLEventHandlers['onPointerMove'] = (info) => { + override onLongPress: TLEventHandlers['onLongPress'] = () => { + this.startDragging() + } + + override onPointerMove: TLEventHandlers['onPointerMove'] = () => { if (this.editor.inputs.isDragging) { - this.parent.transition('dragging', info) + this.startDragging() } } + private startDragging() { + this.parent.transition('dragging') + } + override onPointerUp: TLEventHandlers['onPointerUp'] = () => { this.complete() } From de951dee59201ae90404bc2ba6bca5a48b19f1f9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mitja=20Bezen=C5=A1ek?= Date: Wed, 10 Apr 2024 14:03:09 +0200 Subject: [PATCH 21/26] Reorder dom elements. (#3431) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit We reorded the dom a bit when we added the web gl rendered culled shapes. We can now revert that. Also noticed we weren't positioning the wrapper, so the z-index didn't not apply. ### Change Type - [ ] `sdk` — Changes the tldraw SDK - [ ] `dotcom` — Changes the tldraw.com web app - [ ] `docs` — Changes to the documentation, examples, or templates. - [ ] `vs code` — Changes to the vscode plugin - [x] `internal` — Does not affect user-facing stuff - [ ] `bugfix` — Bug fix - [ ] `feature` — New feature - [ ] `improvement` — Improving existing features - [x] `chore` — Updating dependencies, other boring stuff - [ ] `galaxy brain` — Architectural changes - [ ] `tests` — Changes to any test code - [ ] `tools` — Changes to infrastructure, CI, internal scripts, debugging tools, etc. - [ ] `dunno` — I don't know --------- Co-authored-by: Steve Ruiz --- packages/editor/editor.css | 10 ++- .../default-components/DefaultCanvas.tsx | 78 +++++++++---------- 2 files changed, 44 insertions(+), 44 deletions(-) diff --git a/packages/editor/editor.css b/packages/editor/editor.css index 8ca9da246..03bbb64f4 100644 --- a/packages/editor/editor.css +++ b/packages/editor/editor.css @@ -289,16 +289,18 @@ input, .tl-background__wrapper { z-index: var(--layer-background); -} - -.tl-background { position: absolute; - background-color: var(--color-background); inset: 0px; height: 100%; width: 100%; } +.tl-background { + background-color: var(--color-background); + width: 100%; + height: 100%; +} + /* --------------------- Grid Layer --------------------- */ .tl-grid { diff --git a/packages/editor/src/lib/components/default-components/DefaultCanvas.tsx b/packages/editor/src/lib/components/default-components/DefaultCanvas.tsx index 9efa5b477..a752edb54 100644 --- a/packages/editor/src/lib/components/default-components/DefaultCanvas.tsx +++ b/packages/editor/src/lib/components/default-components/DefaultCanvas.tsx @@ -118,52 +118,50 @@ export function DefaultCanvas({ className }: TLCanvasComponentProps) { ]) return ( - <> +
+ + + {shapeSvgDefs} + + + {SvgDefs && } + + {Background && (
)} -
- - - {shapeSvgDefs} - - - {SvgDefs && } - - - -
- - - {hideShapes ? null : debugSvg ? : } -
-
-
- {debugGeometry ? : null} - - - - - - - - - - -
- -
- + +
+ + + {hideShapes ? null : debugSvg ? : }
- +
+
+ {debugGeometry ? : null} + + + + + + + + + + +
+ +
+ +
) } From f40099e04eb4bf3924b54f48c30655074185d25c Mon Sep 17 00:00:00 2001 From: Taha <98838967+Taha-Hassan-Git@users.noreply.github.com> Date: Wed, 10 Apr 2024 13:46:55 +0100 Subject: [PATCH 22/26] Update font import URL in quick-start.mdx (#3430) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fixes font import link in quickstart guide ### Change Type - [ ] `sdk` — Changes the tldraw SDK - [ ] `dotcom` — Changes the tldraw.com web app - [x] `docs` — Changes to the documentation, examples, or templates. - [ ] `vs code` — Changes to the vscode plugin - [ ] `internal` — Does not affect user-facing stuff - [x] `bugfix` — Bug fix - [ ] `feature` — New feature - [ ] `improvement` — Improving existing features - [ ] `chore` — Updating dependencies, other boring stuff - [ ] `galaxy brain` — Architectural changes - [ ] `tests` — Changes to any test code - [ ] `tools` — Changes to infrastructure, CI, internal scripts, debugging tools, etc. - [ ] `dunno` — I don't know ### Test Plan 1. Add a step-by-step description of how to test your PR here. 2. - [ ] Unit Tests - [ ] End to end tests ### Release Notes - Fixes font import link in tldraw.dev quickstart guide --- apps/docs/content/getting-started/quick-start.mdx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/docs/content/getting-started/quick-start.mdx b/apps/docs/content/getting-started/quick-start.mdx index fbfa2e481..7cc374c82 100644 --- a/apps/docs/content/getting-started/quick-start.mdx +++ b/apps/docs/content/getting-started/quick-start.mdx @@ -31,7 +31,7 @@ To import fonts and CSS for tldraw: - Copy and paste this into the file: ```CSS -@import url("https://fonts.googleapis.com/css2?family=Inter:wght@500;700;&display=swap"); +@import url("https://fonts.googleapis.com/css2?family=Inter:wght@500;700&display=swap"); @import url("tldraw/tldraw.css"); body { From ae6ecf35b1db0014920bf54fef197c81aa4437d3 Mon Sep 17 00:00:00 2001 From: Steve Ruiz Date: Wed, 10 Apr 2024 13:51:59 +0100 Subject: [PATCH 23/26] Fix cursor chat in context menu. (#3435) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This PR fixes flipped boolean logic for displaying the cursor chat option on coarse pointer devices. ### Change Type - [x] `dotcom` — Changes the tldraw.com web app - [x] `bugfix` — Bug fix --- apps/dotcom/src/utils/context-menu/CursorChatMenuItem.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/dotcom/src/utils/context-menu/CursorChatMenuItem.tsx b/apps/dotcom/src/utils/context-menu/CursorChatMenuItem.tsx index db0e25d19..5a6872d56 100644 --- a/apps/dotcom/src/utils/context-menu/CursorChatMenuItem.tsx +++ b/apps/dotcom/src/utils/context-menu/CursorChatMenuItem.tsx @@ -7,7 +7,7 @@ export function CursorChatMenuItem() { const shouldShow = useValue( 'show cursor chat', () => { - return editor.getInstanceState().isCoarsePointer && !editor.getSelectedShapes().length + return !editor.getInstanceState().isCoarsePointer }, [editor] ) From 2cc8f44f836609b01b8f999ff88dad9b2303818b Mon Sep 17 00:00:00 2001 From: Steve Ruiz Date: Wed, 10 Apr 2024 13:53:11 +0100 Subject: [PATCH 24/26] Make minimap display sharp rectangles. (#3434) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The minimap now uses faster sharp rectangles for shapes. ### Change Type - [x] `sdk` — Changes the tldraw SDK - [x] `improvement` — Improving existing features ### Release Notes - Improve --- .../ui/components/Minimap/MinimapManager.ts | 24 +++++-------------- 1 file changed, 6 insertions(+), 18 deletions(-) diff --git a/packages/tldraw/src/lib/ui/components/Minimap/MinimapManager.ts b/packages/tldraw/src/lib/ui/components/Minimap/MinimapManager.ts index 993030791..0b5dd9cf0 100644 --- a/packages/tldraw/src/lib/ui/components/Minimap/MinimapManager.ts +++ b/packages/tldraw/src/lib/ui/components/Minimap/MinimapManager.ts @@ -6,6 +6,7 @@ import { TLShapeId, Vec, clamp, + throttle, uniqueId, } from '@tldraw/editor' @@ -181,7 +182,7 @@ export class MinimapManager { } } - render = () => { + render = throttle(() => { const { cvs, pageBounds } = this this.updateCanvasPageBounds() @@ -189,7 +190,7 @@ export class MinimapManager { this const { width: cw, height: ch } = canvasScreenBounds - const selectedShapeIds = editor.getSelectedShapeIds() + const selectedShapeIds = new Set(editor.getSelectedShapeIds()) const viewportPageBounds = editor.getViewportPageBounds() if (!cvs || !pageBounds) { @@ -215,16 +216,6 @@ export class MinimapManager { ctx.scale(sx, sy) ctx.translate(-contentPageBounds.minX, -contentPageBounds.minY) - // Default radius for rounded rects - const rx = 8 / sx - const ry = 8 / sx - // Min radius - const ax = 1 / sx - const ay = 1 / sx - // Max radius factor - const bx = rx / 4 - const by = ry / 4 - // shapes const shapesPath = new Path2D() const selectedPath = new Path2D() @@ -237,14 +228,11 @@ export class MinimapManager { let pb: Box & { id: TLShapeId } for (let i = 0, n = pageBounds.length; i < n; i++) { pb = pageBounds[i] - MinimapManager.roundedRect( - selectedShapeIds.includes(pb.id) ? selectedPath : shapesPath, + ;(selectedShapeIds.has(pb.id) ? selectedPath : shapesPath).rect( pb.minX, pb.minY, pb.width, - pb.height, - clamp(rx, ax, pb.width / bx), - clamp(ry, ay, pb.height / by) + pb.height ) } @@ -351,7 +339,7 @@ export class MinimapManager { ctx.strokeRect(minX + 1 / sx, minY + 1 / sy, width - 2 / sx, height - 2 / sy) } } - } + }, 32) static roundedRect( ctx: CanvasRenderingContext2D | Path2D, From b3a1db90ece907bae572158e174f0c1d9c27234f Mon Sep 17 00:00:00 2001 From: Steve Ruiz Date: Wed, 10 Apr 2024 15:12:08 +0100 Subject: [PATCH 25/26] Remove minimap throttling (#3438) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Our throttling isn't right for the minimap. Yanking this back. ### Change Type - [x] `sdk` — Changes the tldraw SDK - [x] `bugfix` — Bug fix --- .../tldraw/src/lib/ui/components/Minimap/MinimapManager.ts | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/packages/tldraw/src/lib/ui/components/Minimap/MinimapManager.ts b/packages/tldraw/src/lib/ui/components/Minimap/MinimapManager.ts index 0b5dd9cf0..eeef0fd7f 100644 --- a/packages/tldraw/src/lib/ui/components/Minimap/MinimapManager.ts +++ b/packages/tldraw/src/lib/ui/components/Minimap/MinimapManager.ts @@ -6,7 +6,6 @@ import { TLShapeId, Vec, clamp, - throttle, uniqueId, } from '@tldraw/editor' @@ -182,7 +181,7 @@ export class MinimapManager { } } - render = throttle(() => { + render = () => { const { cvs, pageBounds } = this this.updateCanvasPageBounds() @@ -339,7 +338,7 @@ export class MinimapManager { ctx.strokeRect(minX + 1 / sx, minY + 1 / sy, width - 2 / sx, height - 2 / sy) } } - }, 32) + } static roundedRect( ctx: CanvasRenderingContext2D | Path2D, From 84dbf2df209c7c22e848013094d90af14f11c596 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mitja=20Bezen=C5=A1ek?= Date: Thu, 11 Apr 2024 11:42:16 +0200 Subject: [PATCH 26/26] VS Code 2.0.27 (#3442) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Version bump. ### Change Type - [ ] `sdk` — Changes the tldraw SDK - [ ] `dotcom` — Changes the tldraw.com web app - [ ] `docs` — Changes to the documentation, examples, or templates. - [x] `vs code` — Changes to the vscode plugin - [ ] `internal` — Does not affect user-facing stuff - [ ] `bugfix` — Bug fix - [ ] `feature` — New feature - [ ] `improvement` — Improving existing features - [x] `chore` — Updating dependencies, other boring stuff - [ ] `galaxy brain` — Architectural changes - [ ] `tests` — Changes to any test code - [ ] `tools` — Changes to infrastructure, CI, internal scripts, debugging tools, etc. - [ ] `dunno` — I don't know --- apps/vscode/extension/CHANGELOG.md | 4 ++++ apps/vscode/extension/package.json | 2 +- 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/apps/vscode/extension/CHANGELOG.md b/apps/vscode/extension/CHANGELOG.md index 2713f418c..c40252b56 100644 --- a/apps/vscode/extension/CHANGELOG.md +++ b/apps/vscode/extension/CHANGELOG.md @@ -1,3 +1,7 @@ +## 2.0.27 + +- Bug fixes and performance improvements. + ## 2.0.26 - Bug fixes and performance improvements. diff --git a/apps/vscode/extension/package.json b/apps/vscode/extension/package.json index 35c3332bb..9558bbf95 100644 --- a/apps/vscode/extension/package.json +++ b/apps/vscode/extension/package.json @@ -1,7 +1,7 @@ { "name": "tldraw-vscode", "description": "The tldraw extension for VS Code.", - "version": "2.0.26", + "version": "2.0.27", "private": true, "author": { "name": "tldraw Inc.",