diff --git a/apps/examples/e2e/shared-e2e.ts b/apps/examples/e2e/shared-e2e.ts index 426e3475b..a14f06753 100644 --- a/apps/examples/e2e/shared-e2e.ts +++ b/apps/examples/e2e/shared-e2e.ts @@ -10,7 +10,7 @@ export function sleep(ms: number) { // } // export async function expectToHaveNShapes(page: Page, numberOfShapes: number) { -// expect(await page.evaluate(() => editor.shapesOnCurrentPage.length)).toBe(numberOfShapes) +// expect(await page.evaluate(() => editor.currentPageShapes.length)).toBe(numberOfShapes) // } // export async function expectToHaveNSelectedShapes(page: Page, numberOfSelectedShapes: number) { diff --git a/apps/examples/e2e/tests/test-clipboard.spec.ts b/apps/examples/e2e/tests/test-clipboard.spec.ts index 8fb1b006b..55b9f2cc3 100644 --- a/apps/examples/e2e/tests/test-clipboard.spec.ts +++ b/apps/examples/e2e/tests/test-clipboard.spec.ts @@ -23,7 +23,7 @@ test.describe.skip('clipboard tests', () => { await page.mouse.down() await page.mouse.up() - expect(await page.evaluate(() => editor.shapesOnCurrentPage.length)).toBe(1) + expect(await page.evaluate(() => editor.currentPageShapes.length)).toBe(1) expect(await page.evaluate(() => editor.selectedShapes.length)).toBe(1) await page.keyboard.down('Control') @@ -32,7 +32,7 @@ test.describe.skip('clipboard tests', () => { await page.keyboard.press('KeyV') await page.keyboard.up('Control') - expect(await page.evaluate(() => editor.shapesOnCurrentPage.length)).toBe(2) + expect(await page.evaluate(() => editor.currentPageShapes.length)).toBe(2) expect(await page.evaluate(() => editor.selectedShapes.length)).toBe(1) }) @@ -42,7 +42,7 @@ test.describe.skip('clipboard tests', () => { await page.mouse.down() await page.mouse.up() - expect(await page.evaluate(() => editor.shapesOnCurrentPage.length)).toBe(1) + expect(await page.evaluate(() => editor.currentPageShapes.length)).toBe(1) expect(await page.evaluate(() => editor.selectedShapes.length)).toBe(1) await page.getByTestId('main.menu').click() @@ -53,7 +53,7 @@ test.describe.skip('clipboard tests', () => { await page.getByTestId('menu-item.edit').click() await page.getByTestId('menu-item.paste').click() - expect(await page.evaluate(() => editor.shapesOnCurrentPage.length)).toBe(2) + expect(await page.evaluate(() => editor.currentPageShapes.length)).toBe(2) expect(await page.evaluate(() => editor.selectedShapes.length)).toBe(1) }) @@ -63,7 +63,7 @@ test.describe.skip('clipboard tests', () => { await page.mouse.down() await page.mouse.up() - expect(await page.evaluate(() => editor.shapesOnCurrentPage.length)).toBe(1) + expect(await page.evaluate(() => editor.currentPageShapes.length)).toBe(1) expect(await page.evaluate(() => editor.selectedShapes.length)).toBe(1) await page.mouse.click(100, 100, { button: 'right' }) @@ -73,7 +73,7 @@ test.describe.skip('clipboard tests', () => { await page.mouse.click(100, 100, { button: 'right' }) await page.getByTestId('menu-item.paste').click() - expect(await page.evaluate(() => editor.shapesOnCurrentPage.length)).toBe(2) + expect(await page.evaluate(() => editor.currentPageShapes.length)).toBe(2) expect(await page.evaluate(() => editor.selectedShapes.length)).toBe(1) }) }) diff --git a/docs/docs/editor.mdx b/docs/docs/editor.mdx index b8aa0a3de..2c0c21b2c 100644 --- a/docs/docs/editor.mdx +++ b/docs/docs/editor.mdx @@ -24,7 +24,7 @@ keywords: The [`Editor`](/gen/editor/Editor) class is the main way of controlling tldraw's editor. You can use it to manage the editor's internal state, make changes to the document, or respond to changes that have occurred. -By design, the [`Editor`](/gen/editor/Editor)'s surface area is very large. Almost everything is available through it. Need to create some shapes? Use [`editor.createShapes()`](/gen/editor/Editor#createShapes). Need to delete them? Use [`editor.deleteShapes(editor.selectedShapeIds)`](/gen/editor/Editor#deleteShapes). Need a sorted array of every shape on the current page? Use [`editor.sortedShapesOnCurrentPage`](/gen/editor/Editor#sortedShapesOnCurrentPage). +By design, the [`Editor`](/gen/editor/Editor)'s surface area is very large. Almost everything is available through it. Need to create some shapes? Use [`editor.createShapes()`](/gen/editor/Editor#createShapes). Need to delete them? Use [`editor.deleteShapes(editor.selectedShapeIds)`](/gen/editor/Editor#deleteShapes). Need a sorted array of every shape on the current page? Use [`editor.currentPageShapesSorted`](/gen/editor/Editor#currentPageShapesSorted). This page gives a broad idea of how the [`Editor`](/gen/editor/Editor) class is organized and some of the architectural concepts involved. The full reference is available in the [Editor API](/gen/editor/Editor). diff --git a/packages/editor/api-report.md b/packages/editor/api-report.md index dd27aae34..706f96e12 100644 --- a/packages/editor/api-report.md +++ b/packages/editor/api-report.md @@ -40,6 +40,7 @@ import { TLAssetPartial } from '@tldraw/tlschema'; import { TLBaseShape } from '@tldraw/tlschema'; import { TLBookmarkAsset } from '@tldraw/tlschema'; import { TLCamera } from '@tldraw/tlschema'; +import { TLCameraId } from '@tldraw/tlschema'; import { TLCursorType } from '@tldraw/tlschema'; import { TLDefaultHorizontalAlignStyle } from '@tldraw/tlschema'; import { TLDocument } from '@tldraw/tlschema'; @@ -48,10 +49,13 @@ import { TLHandle } from '@tldraw/tlschema'; import { TLImageAsset } from '@tldraw/tlschema'; import { TLInstance } from '@tldraw/tlschema'; import { TLInstancePageState } from '@tldraw/tlschema'; +import { TLInstancePageStateId } from '@tldraw/tlschema'; import { TLInstancePresence } from '@tldraw/tlschema'; import { TLPage } from '@tldraw/tlschema'; import { TLPageId } from '@tldraw/tlschema'; import { TLParentId } from '@tldraw/tlschema'; +import { TLPointer } from '@tldraw/tlschema'; +import { TLPointerId } from '@tldraw/tlschema'; import { TLRecord } from '@tldraw/tlschema'; import { TLScribble } from '@tldraw/tlschema'; import { TLShape } from '@tldraw/tlschema'; @@ -530,7 +534,6 @@ export class Editor extends EventEmitter { alignShapes(shapes: TLShape[], operation: 'bottom' | 'center-horizontal' | 'center-vertical' | 'left' | 'right' | 'top'): this; // (undocumented) alignShapes(ids: TLShapeId[], operation: 'bottom' | 'center-horizontal' | 'center-vertical' | 'left' | 'right' | 'top'): this; - animateCamera(x: number, y: number, z?: number, opts?: TLAnimationOptions): this; animateShape(partial: null | TLShapePartial | undefined, options?: Partial<{ duration: number; ease: (t: number) => number; @@ -561,6 +564,7 @@ export class Editor extends EventEmitter { // (undocumented) bringToFront(ids: TLShapeId[]): this; get camera(): TLCamera; + get cameraId(): TLCameraId; get cameraState(): "idle" | "moving"; cancel(): this; cancelDoubleClick(): void; @@ -568,7 +572,9 @@ export class Editor extends EventEmitter { get canUndo(): boolean; // @internal (undocumented) capturedPointerId: null | number; - centerOnPoint(x: number, y: number, opts?: TLAnimationOptions): this; + centerOnPoint(point: VecLike, animation?: TLAnimationOptions): this; + // (undocumented) + readonly cleanup: CleanupManager; // @internal protected _clickManager: ClickManager; get commonBoundsOfAllShapesOnCurrentPage(): Box2d | undefined; @@ -577,7 +583,11 @@ export class Editor extends EventEmitter { crash(error: unknown): void; // @internal get crashingError(): unknown; - createAssets(assets: TLAsset[]): this; + createAsset(asset: TLAsset): this; + createAssets(assets: TLAsset[], opts?: { + ephemeral?: boolean; + squashing?: boolean; + }): this; // @internal (undocumented) createErrorAnnotations(origin: string, willCrashApp: 'unknown' | boolean): { tags: { @@ -592,21 +602,40 @@ export class Editor extends EventEmitter { }; }; createPage(title: string, id?: TLPageId, belowPageIndex?: string): this; - createShape(partial: TLShapePartial, select?: boolean): this; - createShapes(partials: TLShapePartial[], select?: boolean): this; + // (undocumented) + createRecords: (partials: OptionalKeys[], opts?: Partial<{ + squashing: boolean; + ephemeral: boolean; + preservesRedoStack: boolean; + }> | undefined) => this; + createShape(partial: OptionalKeys, 'id'>): this; + createShapes(partials: OptionalKeys, 'id'>[]): this; get croppingShapeId(): null | TLShapeId; get currentPage(): TLPage; get currentPageId(): TLPageId; + get currentPageShapeIds(): Set; + get currentPageShapes(): TLShape[]; + get currentPageShapesSorted(): TLShape[]; get currentPageState(): TLInstancePageState; + get currentPageStateId(): TLInstancePageStateId; get currentTool(): StateNode | undefined; get currentToolId(): string; + deleteAsset(assets: TLAsset): this; + // (undocumented) + deleteAsset(ids: TLAssetId): this; deleteAssets(assets: TLAsset[]): this; // (undocumented) deleteAssets(ids: TLAssetId[]): this; deleteOpenMenu(id: string): this; deletePage(page: TLPage): this; // (undocumented) - deletePage(id: TLPageId): this; + deletePage(pageId: TLPageId): this; + // (undocumented) + deleteRecords: (records: (TLCamera | TLPointer | TLAsset | TLInstancePageState | TLPage | TLShape)[] | (TLCameraId | TLInstancePageStateId | TLPointerId | TLAssetId | TLPageId | TLShapeId)[], opts?: Partial<{ + squashing: boolean; + ephemeral: boolean; + preservesRedoStack: boolean; + }> | undefined) => this; deleteShape(id: TLShapeId): this; // (undocumented) deleteShape(shape: TLShape): this; @@ -629,9 +658,13 @@ export class Editor extends EventEmitter { duplicateShapes(shapes: TLShape[], offset?: VecLike): this; // (undocumented) duplicateShapes(ids: TLShapeId[], offset?: VecLike): this; + // (undocumented) + get editingShape(): TLUnknownShape | undefined; get editingShapeId(): null | TLShapeId; + readonly environment: EnvironmentManager; get erasingShapeIds(): TLShapeId[]; - get erasingShapeIdsSet(): Set; + // (undocumented) + get erasingShapes(): NonNullable[]; // @internal (undocumented) externalAssetContentHandlers: { [K in TLExternalAssetContent_2['type']]: { @@ -712,6 +745,10 @@ export class Editor extends EventEmitter { getPageMask(id: TLShapeId): undefined | VecLike[]; // (undocumented) getPageMask(shape: TLShape): undefined | VecLike[]; + getPageShapeIds(page: TLPage): Set; + // (undocumented) + getPageShapeIds(pageId: TLPageId): Set; + getPageState(pageId: TLPageId): TLInstancePageState; getPageTransform(id: TLShapeId): Matrix2d; // (undocumented) getPageTransform(shape: TLShape): Matrix2d; @@ -739,9 +776,6 @@ export class Editor extends EventEmitter { hitFrameInside?: boolean | undefined; filter?: ((shape: TLShape) => boolean) | undefined; }): TLShape | undefined; - getShapeIdsInPage(page: TLPage): Set; - // (undocumented) - getShapeIdsInPage(pageId: TLPageId): Set; getShapesAtPoint(point: VecLike, opts?: { margin?: number | undefined; hitInside?: boolean | undefined; @@ -776,6 +810,8 @@ export class Editor extends EventEmitter { // (undocumented) hasAncestor(shapeId: TLShapeId | undefined, ancestorId: TLShapeId): boolean; get hintingShapeIds(): TLShapeId[]; + // (undocumented) + get hintingShapes(): NonNullable[]; readonly history: HistoryManager; // (undocumented) get hoveredShape(): TLUnknownShape | undefined; @@ -805,12 +841,8 @@ export class Editor extends EventEmitter { isAncestorSelected(id: TLShapeId): boolean; // (undocumented) isAncestorSelected(shape: TLShape): boolean; - readonly isAndroid: boolean; - readonly isChromeForIos: boolean; - readonly isFirefox: boolean; isIn(path: string): boolean; isInAny(...paths: string[]): boolean; - readonly isIos: boolean; get isMenuOpen(): boolean; isPointInShape(shape: TLShape, point: VecLike, opts?: { margin?: number; @@ -821,7 +853,6 @@ export class Editor extends EventEmitter { margin?: number; hitInside?: boolean; }): boolean; - readonly isSafari: boolean; isShapeInPage(shape: TLShape, pageId?: TLPageId): boolean; // (undocumented) isShapeInPage(shapeId: TLShapeId, pageId?: TLPageId): boolean; @@ -831,7 +862,7 @@ export class Editor extends EventEmitter { isShapeOrAncestorLocked(shape?: TLShape): boolean; // (undocumented) isShapeOrAncestorLocked(id?: TLShapeId): boolean; - mark(markId?: string, onUndo?: boolean, onRedo?: boolean): string; + mark(markId?: string, onUndo?: boolean, onRedo?: boolean): this; moveShapesToPage(shapes: TLShape[], pageId: TLPageId): this; // (undocumented) moveShapesToPage(ids: TLShapeId[], pageId: TLPageId): this; @@ -845,13 +876,13 @@ export class Editor extends EventEmitter { packShapes(ids: TLShapeId[], gap: number): this; get pages(): TLPage[]; get pageStates(): TLInstancePageState[]; - pageToScreen(x: number, y: number, z?: number, camera?: VecLike): { + pageToScreen(point: VecLike): { x: number; y: number; z: number; }; - pan(dx: number, dy: number, opts?: TLAnimationOptions): this; - panZoomIntoView(ids: TLShapeId[], opts?: TLAnimationOptions): this; + pan(offset: VecLike, animation?: TLAnimationOptions): this; + panZoomIntoView(ids: TLShapeId[], animation?: TLAnimationOptions): this; popFocusLayer(): this; putContent(content: TLContent, options?: { point?: VecLike; @@ -867,11 +898,10 @@ export class Editor extends EventEmitter { registerExternalContentHandler(type: T, handler: ((info: T extends TLExternalContent_2['type'] ? TLExternalContent_2 & { type: T; } : TLExternalContent_2) => void) | null): this; - renamePage(page: TLPage, name: string, squashing?: boolean): this; - // (undocumented) - renamePage(id: TLPageId, name: string, squashing?: boolean): this; get renderingBounds(): Box2d; get renderingBoundsExpanded(): Box2d; + // (undocumented) + renderingBoundsMargin: number; get renderingShapes(): { id: TLShapeId; shape: TLShape; @@ -883,10 +913,14 @@ export class Editor extends EventEmitter { isInViewport: boolean; maskedPageBounds: Box2d | undefined; }[]; - reparentShapes(shapes: TLShape[], parentId: TLParentId, insertIndex?: string): this; + reparentShapes(shapes: TLShape[], parentId: TLParentId, opts?: { + insertIndex?: string; + } & CommandHistoryOptions): this; // (undocumented) - reparentShapes(ids: TLShapeId[], parentId: TLParentId, insertIndex?: string): this; - resetZoom(point?: Vec2d, opts?: TLAnimationOptions): this; + reparentShapes(ids: TLShapeId[], parentId: TLParentId, opts?: { + insertIndex?: string; + } & CommandHistoryOptions): this; + resetZoom(point?: Vec2d, animation?: TLAnimationOptions): this; resizeShape(id: TLShapeId, scale: VecLike, options?: { initialBounds?: Box2d; scaleOrigin?: VecLike; @@ -900,7 +934,7 @@ export class Editor extends EventEmitter { rotateShapesBy(shapes: TLShape[], delta: number): this; // (undocumented) rotateShapesBy(ids: TLShapeId[], delta: number): this; - screenToPage(x: number, y: number, z?: number, camera?: VecLike): { + screenToPage(point: VecLike): { x: number; y: number; z: number; @@ -921,32 +955,29 @@ export class Editor extends EventEmitter { sendToBack(shapes: TLShape[]): this; // (undocumented) sendToBack(ids: TLShapeId[]): this; - setCamera(x: number, y: number, z?: number, { stopFollowing }?: TLViewportOptions): this; + setCamera(point: VecLike, animation?: TLAnimationOptions): this; // (undocumented) - setCroppingId(id: null | TLShapeId): this; - setCurrentPage(page: TLPage, opts?: TLViewportOptions): this; + setCroppingShapeId(id: null | TLShapeId): this; + setCurrentPage(page: TLPage): this; // (undocumented) - setCurrentPage(pageId: TLPageId, opts?: TLViewportOptions): this; + setCurrentPage(pageId: TLPageId): this; setCurrentTool(id: string, info?: {}): this; // (undocumented) - setEditingId(id: null | TLShapeId): this; + setEditingShapeId(id: null | TLShapeId): this; // (undocumented) - setErasingIds(ids: TLShapeId[]): this; + setErasingShapeIds(ids: TLShapeId[]): this; // (undocumented) - setFocusedGroupId(next: TLPageId | TLShapeId): this; + setFocusedGroupId(id: TLPageId | TLShapeId): this; // (undocumented) - setHintingIds(ids: TLShapeId[]): this; + setHintingShapeIds(ids: TLShapeId[]): this; // (undocumented) - setHoveredId(id: null | TLShapeId): this; - setOpacity(opacity: number, ephemeral?: boolean, squashing?: boolean): this; + setHoveredShapeId(id: null | TLShapeId): this; + setOpacity(opacity: number, opts?: CommandHistoryOptions): this; setSelectedShapeIds(ids: TLShapeId[], squashing?: boolean): this; - setStyle(style: StyleProp, value: T, ephemeral?: boolean, squashing?: boolean): this; - get shapeIdsOnCurrentPage(): Set; - get shapesOnCurrentPage(): TLShape[]; + setStyle(style: StyleProp, value: T, opts?: CommandHistoryOptions): this; shapeUtils: { readonly [K in string]?: ShapeUtil; }; - get sharedOpacity(): SharedStyle; get sharedStyles(): ReadonlySharedStyleMap; slideCamera(opts?: { speed: number; @@ -955,7 +986,6 @@ export class Editor extends EventEmitter { speedThreshold?: number | undefined; }): this | undefined; readonly snaps: SnapManager; - get sortedShapesOnCurrentPage(): TLShape[]; stackShapes(shapes: TLShape[], operation: 'horizontal' | 'vertical', gap: number): this; // (undocumented) stackShapes(ids: TLShapeId[], operation: 'horizontal' | 'vertical', gap: number): this; @@ -974,19 +1004,32 @@ export class Editor extends EventEmitter { toggleLock(shapes: TLShape[]): this; // (undocumented) toggleLock(ids: TLShapeId[]): this; - undo(): HistoryManager; + undo(): this; ungroupShapes(ids: TLShapeId[]): this; // (undocumented) ungroupShapes(ids: TLShape[]): this; - updateAssets(assets: TLAssetPartial[]): this; - updateCurrentPageState(partial: Partial>, ephemeral?: boolean): this; + updateAsset(partial: TLAssetPartial, opts?: { + ephemeral?: boolean; + squashing?: boolean; + }): this; + updateAssets(partials: TLAssetPartial[], opts?: { + ephemeral?: boolean; + squashing?: boolean; + }): this; updateDocumentSettings(settings: Partial): this; - updateInstanceState(partial: Partial>, ephemeral?: boolean, squashing?: boolean): this; + updateInstanceState(partial: Partial>, opts?: CommandHistoryOptions): this; updatePage(partial: RequiredKeys, squashing?: boolean): this; + updatePageState(partial: Partial>, opts?: CommandHistoryOptions): this; + // (undocumented) + updateRecords: (partials: Partial[], opts?: Partial<{ + squashing: boolean; + ephemeral: boolean; + preservesRedoStack: boolean; + }> | undefined) => this; // @internal updateRenderingBounds(): this; - updateShape(partial: null | TLShapePartial | undefined, squashing?: boolean): this; - updateShapes(partials: (null | TLShapePartial | undefined)[], squashing?: boolean): this; + updateShape(partial: null | TLShapePartial | undefined, opts?: CommandHistoryOptions): this; + updateShapes(partials: (null | TLShapePartial | undefined)[], opts?: CommandHistoryOptions): this; updateViewportScreenBounds(center?: boolean): this; readonly user: UserPreferencesManager; get viewportPageBounds(): Box2d; @@ -996,13 +1039,13 @@ export class Editor extends EventEmitter { visitDescendants(parent: TLPage | TLShape, visitor: (id: TLShapeId) => false | void): this; // (undocumented) visitDescendants(parentId: TLParentId, visitor: (id: TLShapeId) => false | void): this; - zoomIn(point?: Vec2d, opts?: TLAnimationOptions): this; + zoomIn(point?: Vec2d, animation?: TLAnimationOptions): this; get zoomLevel(): number; - zoomOut(point?: Vec2d, opts?: TLAnimationOptions): this; - zoomToBounds(x: number, y: number, width: number, height: number, targetZoom?: number, opts?: TLAnimationOptions): this; + zoomOut(point?: Vec2d, animation?: TLAnimationOptions): this; + zoomToBounds(bounds: Box2d, targetZoom?: number, animation?: TLAnimationOptions): this; zoomToContent(): this; - zoomToFit(opts?: TLAnimationOptions): this; - zoomToSelection(opts?: TLAnimationOptions): this; + zoomToFit(animation?: TLAnimationOptions): this; + zoomToSelection(animation?: TLAnimationOptions): this; } // @public (undocumented) @@ -1628,7 +1671,7 @@ export function refreshPage(): void; export function releasePointerCapture(element: Element, event: PointerEvent | React_2.PointerEvent): void; // @public (undocumented) -export type RequiredKeys = Pick & Partial; +export type RequiredKeys = Partial> & Pick; // @public (undocumented) export function resizeBox(shape: TLBaseBoxShape, info: { diff --git a/packages/editor/src/lib/config/createTLStore.ts b/packages/editor/src/lib/config/createTLStore.ts index b15bb9a52..2a4022f0c 100644 --- a/packages/editor/src/lib/config/createTLStore.ts +++ b/packages/editor/src/lib/config/createTLStore.ts @@ -33,7 +33,7 @@ export function createTLStore({ initialData, defaultName = '', ...rest }: TLStor 'schema' in rest ? rest.schema : createTLSchema({ - shapes: shapesOnCurrentPageToShapeMap(checkShapesAndAddCore(rest.shapeUtils)), + shapes: currentPageShapesToShapeMap(checkShapesAndAddCore(rest.shapeUtils)), }) return new Store({ schema, @@ -44,7 +44,7 @@ export function createTLStore({ initialData, defaultName = '', ...rest }: TLStor }) } -function shapesOnCurrentPageToShapeMap(shapeUtils: TLShapeUtilConstructor[]) { +function currentPageShapesToShapeMap(shapeUtils: TLShapeUtilConstructor[]) { return Object.fromEntries( shapeUtils.map((s): [string, SchemaShapeInfo] => [ s.type, diff --git a/packages/editor/src/lib/editor/Editor.ts b/packages/editor/src/lib/editor/Editor.ts index a4e54d4dd..c143d7201 100644 --- a/packages/editor/src/lib/editor/Editor.ts +++ b/packages/editor/src/lib/editor/Editor.ts @@ -20,10 +20,12 @@ import { TLImageAsset, TLInstance, TLInstancePageState, + TLInstancePresence, TLPOINTER_ID, TLPage, TLPageId, TLParentId, + TLRecord, TLShape, TLShapeId, TLShapePartial, @@ -84,7 +86,7 @@ import { Geometry2d } from '../primitives/geometry/Geometry2d' import { Group2d } from '../primitives/geometry/Group2d' import { intersectPolygonPolygon } from '../primitives/intersect' import { PI2, approximately, areAnglesCompatible, clamp, pointInPolygon } from '../primitives/utils' -import { ReadonlySharedStyleMap, SharedStyle, SharedStyleMap } from '../utils/SharedStylesMap' +import { ReadonlySharedStyleMap, SharedStyleMap } from '../utils/SharedStylesMap' import { WeakMapCache } from '../utils/WeakMapCache' import { dataUrlToFile } from '../utils/assets' import { getIncrementedName } from '../utils/getIncrementedName' @@ -102,8 +104,10 @@ import { uniqueId } from '../utils/uniqueId' import { arrowBindingsIndex } from './derivations/arrowBindingsIndex' import { parentsToChildren } from './derivations/parentsToChildren' import { deriveShapeIdsInCurrentPage } from './derivations/shapeIdsInCurrentPage' +import { CleanupManager } from './managers/CleanupManager' import { ClickManager } from './managers/ClickManager' -import { HistoryManager } from './managers/HistoryManager' +import { EnvironmentManager } from './managers/EnvironmentManager' +import { CommandHistoryOptions, HistoryManager } from './managers/HistoryManager' import { SnapManager } from './managers/SnapManager' import { TextManager } from './managers/TextManager' import { TickManager } from './managers/TickManager' @@ -119,7 +123,7 @@ import { SvgExportContext, SvgExportDef } from './types/SvgExportContext' import { TLContent } from './types/clipboard-types' import { TLEventMap } from './types/emit-types' import { TLEventInfo, TLPinchEventInfo, TLPointerEventInfo } from './types/event-types' -import { RequiredKeys } from './types/misc-types' +import { OptionalKeys, RequiredKeys } from './types/misc-types' import { TLResizeHandle } from './types/selection-types' /** @public */ @@ -128,12 +132,6 @@ export type TLAnimationOptions = Partial<{ easing: typeof EASINGS.easeInOutCubic }> -/** @public */ -export type TLViewportOptions = Partial<{ - /** Whether to animate the viewport change or not. Defaults to true. */ - stopFollowing: boolean -}> - /** @public */ export interface TLEditorOptions { /** @@ -239,70 +237,9 @@ export class Editor extends EventEmitter { this.root.children![Tool.id] = new Tool(this, this.root) } - if (typeof window !== 'undefined' && 'navigator' in window) { - this.isSafari = /^((?!chrome|android).)*safari/i.test(navigator.userAgent) - this.isIos = !!navigator.userAgent.match(/iPad/i) || !!navigator.userAgent.match(/iPhone/i) - this.isChromeForIos = /crios.*safari/i.test(navigator.userAgent) - this.isFirefox = /firefox/i.test(navigator.userAgent) - this.isAndroid = /android/i.test(navigator.userAgent) - } else { - this.isSafari = false - this.isIos = false - this.isChromeForIos = false - this.isFirefox = false - this.isAndroid = false - } + this.environment = new EnvironmentManager(this) - this.store.onBeforeDelete = (record) => { - if (record.typeName === 'shape') { - this._shapeWillBeDeleted(record) - } else if (record.typeName === 'page') { - this._pageWillBeDeleted(record) - } - } - - this.store.onAfterChange = (prev, next) => { - this._updateDepth++ - if (this._updateDepth > 1000) { - console.error('[onAfterChange] Maximum update depth exceeded, bailing out.') - } - if (prev.typeName === 'shape' && next.typeName === 'shape') { - this._shapeDidChange(prev, next) - } else if ( - prev.typeName === 'instance_page_state' && - next.typeName === 'instance_page_state' - ) { - this._pageStateDidChange(prev, next) - } - - this._updateDepth-- - } - this.store.onAfterCreate = (record) => { - if (record.typeName === 'shape' && this.isShapeOfType(record, 'arrow')) { - this._arrowDidUpdate(record) - } - if (record.typeName === 'page') { - const cameraId = CameraRecordType.createId(record.id) - const _pageStateId = InstancePageStateRecordType.createId(record.id) - if (!this.store.has(cameraId)) { - this.store.put([CameraRecordType.create({ id: cameraId })]) - } - if (!this.store.has(_pageStateId)) { - this.store.put([ - InstancePageStateRecordType.create({ id: _pageStateId, pageId: record.id }), - ]) - } - } - } - - this._shapeIdsOnCurrentPage = deriveShapeIdsInCurrentPage(this.store, () => this.currentPageId) - this._parentIdsToChildIds = parentsToChildren(this.store) - - this.disposables.add( - this.store.listen((changes) => { - this.emit('change', changes) - }) - ) + // Container const container = this.getContainer() @@ -335,16 +272,18 @@ export class Editor extends EventEmitter { }) this.store.ensureStoreIsUsable() + this._currentPageShapeIds = deriveShapeIdsInCurrentPage(this.store, () => this.currentPageId) + this._parentIdsToChildIds = parentsToChildren(this.store) // clear ephemeral state - this._setInstancePageState( + this.store.put([ { + ...this.currentPageState, editingShapeId: null, hoveredShapeId: null, erasingShapeIds: [], }, - true - ) + ]) if (initialState && this.root.children[initialState] === undefined) { throw Error(`No state found for initialState "${initialState}".`) @@ -358,6 +297,541 @@ export class Editor extends EventEmitter { this.updateRenderingBounds() + /* --------------------- Cleanup -------------------- */ + + const unbindArrowTerminal = ( + arrow: TLArrowShape, + handleId: 'start' | 'end', + source: 'user' | 'remote' + ) => { + const { x, y } = getArrowTerminalsInArrowSpace(this, arrow)[handleId] + + this.updateRecords( + [ + { + ...arrow, + id: arrow.id, + type: arrow.type, + props: { + ...arrow.props, + [handleId]: { type: 'point', x, y }, + }, + }, + ], + { + ephemeral: source === 'remote', + squashing: true, + } + ) + } + + const arrowsDidUpdateIndex = new Set() + + const reparentArrow = (id: TLArrowShape['id']) => { + let arrow = this.store.get(id) as TLArrowShape + const { start, end } = arrow.props + const startShape = start.type === 'binding' ? this.getShape(start.boundShapeId) : undefined + const endShape = end.type === 'binding' ? this.getShape(end.boundShapeId) : undefined + + const parentPageId = this.getAncestorPageId(arrow) + if (!parentPageId) return + + let nextParentId: TLParentId + if (startShape && endShape) { + // if arrow has two bindings, always parent arrow to closest common ancestor of the bindings + nextParentId = this.findCommonAncestor([startShape, endShape]) ?? parentPageId + } else if (startShape || endShape) { + // if arrow has one binding, keep arrow on its own page + nextParentId = parentPageId + } else { + return + } + + if (nextParentId && nextParentId !== arrow.parentId) { + this.reparentShapes([arrow], nextParentId, { + ephemeral: true, + squashing: true, + }) + arrow = this.getShape(arrow.id)! + if (!arrow) throw Error('no reparented arrow') + } else { + // There's a crazy loop around where this will happen twice + // and cause the undo redo history to fail. Remove and check + // the effects on tests. This was _really_ hard to find. + if (arrowsDidUpdateIndex.has(id)) return + arrowsDidUpdateIndex.add(id) + + const startSibling = this.getShapeNearestSibling(arrow, startShape) + const endSibling = this.getShapeNearestSibling(arrow, endShape) + + let highestSibling: TLShape | undefined + + if (startSibling && endSibling) { + highestSibling = startSibling.index > endSibling.index ? startSibling : endSibling + } else if (startSibling && !endSibling) { + highestSibling = startSibling + } else if (endSibling && !startSibling) { + highestSibling = endSibling + } else { + return + } + + let finalIndex: string + + const higherSiblings = this.getSortedChildIdsForParent(highestSibling.parentId) + .map((id) => this.getShape(id)!) + .filter((sibling) => sibling.index > highestSibling!.index) + + if (higherSiblings.length) { + // there are siblings above the highest bound sibling, we need to + // insert between them. + + // if the next sibling is also a bound arrow though, we can end up + // all fighting for the same indexes. so lets find the next + // non-arrow sibling... + const nextHighestNonArrowSibling = higherSiblings.find( + (sibling) => sibling.type !== 'arrow' + ) + + if ( + // ...then, if we're above the last shape we want to be above... + arrow.index > highestSibling.index && + // ...but below the next non-arrow sibling... + (!nextHighestNonArrowSibling || arrow.index < nextHighestNonArrowSibling.index) + ) { + // ...then we're already in the right place. no need to update! + return + } + + // otherwise, we need to find the index between the highest sibling + // we want to be above, and the next highest sibling we want to be + // below: + finalIndex = getIndexBetween(highestSibling.index, higherSiblings[0].index) + } else { + // if there are no siblings above us, we can just get the next index: + finalIndex = getIndexAbove(highestSibling.index) + } + + if (finalIndex !== arrow.index) { + // this puts a stale record + this.updateRecords([{ ...arrow, index: finalIndex }], { + ephemeral: true, + squashing: true, + }) + } + } + } + + const arrowDidUpdate = (arrow: TLArrowShape, source: 'user' | 'remote') => { + // if the shape is an arrow and its bound shape is on another page + // or was deleted, unbind it + for (const handle of ['start', 'end'] as const) { + const terminal = arrow.props[handle] + if (terminal.type !== 'binding') continue + const boundShape = this.getShape(terminal.boundShapeId) + const isShapeInSamePageAsArrow = + this.getAncestorPageId(arrow) === this.getAncestorPageId(boundShape) + if (!boundShape || !isShapeInSamePageAsArrow) { + unbindArrowTerminal(arrow, handle, source) + } + } + + // always check the arrow parents + reparentArrow(arrow.id) + } + + const cleanupInstancePageState = ( + prevPageState: TLInstancePageState, + shapesNoLongerInPage: Set + ) => { + let nextPageState = null as null | TLInstancePageState + + const selectedShapeIds = prevPageState.selectedShapeIds.filter( + (id) => !shapesNoLongerInPage.has(id) + ) + if (selectedShapeIds.length !== prevPageState.selectedShapeIds.length) { + if (!nextPageState) nextPageState = { ...prevPageState } + nextPageState.selectedShapeIds = selectedShapeIds + } + + const erasingShapeIds = prevPageState.erasingShapeIds.filter( + (id) => !shapesNoLongerInPage.has(id) + ) + if (erasingShapeIds.length !== prevPageState.erasingShapeIds.length) { + if (!nextPageState) nextPageState = { ...prevPageState } + nextPageState.erasingShapeIds = erasingShapeIds + } + + if (prevPageState.hoveredShapeId && shapesNoLongerInPage.has(prevPageState.hoveredShapeId)) { + if (!nextPageState) nextPageState = { ...prevPageState } + nextPageState.hoveredShapeId = null + } + + if (prevPageState.editingShapeId && shapesNoLongerInPage.has(prevPageState.editingShapeId)) { + if (!nextPageState) nextPageState = { ...prevPageState } + nextPageState.editingShapeId = null + } + + const hintingShapeIds = prevPageState.hintingShapeIds.filter( + (id) => !shapesNoLongerInPage.has(id) + ) + if (hintingShapeIds.length !== prevPageState.hintingShapeIds.length) { + if (!nextPageState) nextPageState = { ...prevPageState } + nextPageState.hintingShapeIds = hintingShapeIds + } + + if (prevPageState.focusedGroupId && shapesNoLongerInPage.has(prevPageState.focusedGroupId)) { + if (!nextPageState) nextPageState = { ...prevPageState } + nextPageState.focusedGroupId = null + } + return nextPageState + } + + // STORE / STATE CLEANUP HANDLERS + + this.cleanup = new CleanupManager(this) + + // invalidParents is used to trigger the 'onChildrenChange' callback that shapes can have. + const invalidParents = new Set() + const deletedShapeIdsInTransaction = new Set() + + // Before create cleanup handlers + + this.cleanup.registerBeforeCreateHandler('shape', (record, scope) => { + if (scope === 'user') { + if (record.typeName === 'shape') { + const shapeAfterUtilOnBeforeCreate = this.getShapeUtil(record)?.onBeforeCreate?.(record) + if (shapeAfterUtilOnBeforeCreate) { + return shapeAfterUtilOnBeforeCreate + } + } + } + return record + }) + + // After create cleanup handlers + + this.cleanup.registerAfterCreateHandler('shape', (record, scope) => { + if (scope === 'user') { + if (this.isShapeOfType(record, 'arrow')) { + arrowDidUpdate(record, scope) + } + } + }) + + this.cleanup.registerAfterCreateHandler('page', (record) => { + const cameraId = CameraRecordType.createId(record.id) + const _pageStateId = InstancePageStateRecordType.createId(record.id) + if (!this.store.has(cameraId)) { + this.store.put([CameraRecordType.create({ id: cameraId })]) + } + if (!this.store.has(_pageStateId)) { + this.store.put([ + InstancePageStateRecordType.create({ id: _pageStateId, pageId: record.id }), + ]) + } + }) + + // After before change cleanup handlers + + this.cleanup.registerBeforeChangeHandler('shape', (prev, next) => { + if (next.isLocked && next.isLocked === (prev as typeof next).isLocked) { + // If the shape is locked and the isLocked prop hasn't changed, don't update the shape + return prev + } + + const shapeAfterUtilOnBeforeUpdate = this.getShapeUtil(next)?.onBeforeUpdate?.( + prev as typeof next, + next + ) + if (shapeAfterUtilOnBeforeUpdate) { + next = shapeAfterUtilOnBeforeUpdate + } + + return next + }) + + this.cleanup.registerBeforeChangeHandler('instance', (prev, next) => { + // when the following user changes, stop following the previous user + if (prev.followingUserId && next.followingUserId !== prev.followingUserId) { + this.emit('stop-following') + } + + // When the page changes, stop following any user + if (next.currentPageId !== prev.currentPageId) { + if (next.followingUserId) { + next.followingUserId = null + this.emit('stop-following') + } + } + return next + }) + + this.cleanup.registerBeforeChangeHandler('instance_page_state', (prev, next, source) => { + if (source !== 'user') return next + + if (next.editingShapeId && next.editingShapeId !== (prev as typeof next).editingShapeId) { + // editing shape change + const shape = this.getShape(next.editingShapeId) + if (shape && shape.isLocked) { + next = { ...next, editingShapeId: (prev as typeof next).editingShapeId } + } + } + + // todo: double check this + if ( + next.selectedShapeIds.length && + next.selectedShapeIds !== (prev as typeof next).selectedShapeIds + ) { + // selected shape changes, make sure that they're not locked + next = { + ...next, + selectedShapeIds: next.selectedShapeIds.filter((id) => { + const shape = this.getShape(id) + if (shape) { + return !shape.isLocked + } + }), + } + } + + return next + }) + + // After change cleanup handlers + + this.cleanup.registerAfterChangeHandler('shape', (prev, next, source) => { + if (this.isShapeOfType(next, 'arrow')) { + arrowDidUpdate(next, source) + } + + // if the shape's parent changed and it is bound to an arrow, update the arrow's parent + if (prev.parentId !== next.parentId) { + const reparentBoundArrows = (id: TLShapeId) => { + const boundArrows = this._arrowBindingsIndex.value[id] + if (boundArrows?.length) { + for (const arrow of boundArrows) { + reparentArrow(arrow.arrowId) + } + } + } + reparentBoundArrows(next.id) + this.visitDescendants(next.id, reparentBoundArrows) + } + + // if this shape moved to a new page, clean up any previous page's instance state + if (prev.parentId !== next.parentId && isPageId(next.parentId)) { + const allMovingIds = new Set([prev.id]) + this.visitDescendants(prev.id, (id) => { + allMovingIds.add(id) + }) + + for (const instancePageState of this.pageStates) { + if (instancePageState.pageId === next.parentId) continue + const nextPageState = cleanupInstancePageState(instancePageState, allMovingIds) + + if (nextPageState) { + this.store.put([nextPageState]) + } + } + } + + if (prev.parentId && isShapeId(prev.parentId)) { + invalidParents.add(prev.parentId) + } + + if (next.parentId !== prev.parentId && isShapeId(next.parentId)) { + invalidParents.add(next.parentId) + } + }) + + this.cleanup.registerAfterChangeHandler('instance_page_state', (prev, next) => { + if (prev?.selectedShapeIds !== next?.selectedShapeIds) { + // ensure that descendants and ancestors are not selected at the same time + const filtered = next.selectedShapeIds.filter((id) => { + let parentId = this.getShape(id)?.parentId + while (isShapeId(parentId)) { + if (next.selectedShapeIds.includes(parentId)) { + return false + } + parentId = this.getShape(parentId)?.parentId + } + return true + }) + + let nextFocusedGroupId: null | TLShapeId = null + + if (filtered.length > 0) { + const commonGroupAncestor = this.findCommonAncestor( + compact(filtered.map((id) => this.getShape(id))), + (shape) => this.isShapeOfType(shape, 'group') + ) + + if (commonGroupAncestor) { + nextFocusedGroupId = commonGroupAncestor + } + } else { + if (next?.focusedGroupId) { + nextFocusedGroupId = next.focusedGroupId + } + } + + if ( + filtered.length !== next.selectedShapeIds.length || + nextFocusedGroupId !== next.focusedGroupId + ) { + this.store.put([ + { ...next, selectedShapeIds: filtered, focusedGroupId: nextFocusedGroupId ?? null }, + ]) + } + } + }) + + this.cleanup.registerAfterChangeHandler('instance', (prev, next) => { + if (prev.isChangingStyle !== next.isChangingStyle) { + clearTimeout(this._isChangingStyleTimeout) + if (next.isChangingStyle === true) { + // If we've set to true, set a new reset timeout to change the value back to false after 2 seconds + this._isChangingStyleTimeout = setTimeout(() => { + this.updateInstanceState({ isChangingStyle: false }) + }, 2000) + } + } + + if (prev.currentPageId !== next.currentPageId) { + this.updateRenderingBounds() + } + }) + + this.cleanup.registerAfterChangeHandler('instance_page_state', (prev, next) => { + if (prev.selectedShapeIds !== next.selectedShapeIds) { + // ensure that descendants and ancestors are not selected at the same time + const filtered = next.selectedShapeIds.filter((id) => { + let parentId = this.getShape(id)?.parentId + while (isShapeId(parentId)) { + if (next.selectedShapeIds.includes(parentId)) { + return false + } + parentId = this.getShape(parentId)?.parentId + } + return true + }) + let nextFocusedGroupId: null | TLShapeId = null + if (filtered.length > 0) { + const commonGroupAncestor = this.findCommonAncestor( + compact(filtered.map((id) => this.getShape(id))), + (shape) => this.isShapeOfType(shape, 'group') + ) + if (commonGroupAncestor) { + nextFocusedGroupId = commonGroupAncestor + } + } else { + if (next?.focusedGroupId) { + nextFocusedGroupId = next.focusedGroupId + } + } + if ( + filtered.length !== next.selectedShapeIds.length || + nextFocusedGroupId !== next.focusedGroupId + ) { + this.store.put([ + { ...next, selectedShapeIds: filtered, focusedGroupId: nextFocusedGroupId ?? null }, + ]) + } + } + + if (prev.editingShapeId !== next.editingShapeId && next.editingShapeId) { + const shape = this.getShape(next.editingShapeId) + if (shape && this.getShapeUtil(shape).canEdit(shape)) { + this.setHoveredShapeId(null) + } + } + }) + + // Before delete cleanup handlers + + this.cleanup.registerBeforeDeleteHandler('shape', (record, source) => { + if (record.isLocked) { + return false // don't delete the locked shape + } + + // collect descendants and delete them + // deselect the shape and its children + const { currentPageState } = this + if (currentPageState.selectedShapeIds.includes(record.id)) { + this.updatePageState( + { + ...currentPageState, + selectedShapeIds: currentPageState.selectedShapeIds.filter((id) => id !== record.id), + }, + { ephemeral: false, squashing: true } + ) + } + + const childIds = this.getSortedChildIdsForParent(record.id) + + if (childIds.length) { + this.deleteRecords(childIds) + } + + // if the deleted shape has a parent shape make sure we call it's onChildrenChange callback + if (record.parentId && isShapeId(record.parentId)) { + invalidParents.add(record.parentId) + } + + // clean up any arrows bound to this shape + const bindings = this._arrowBindingsIndex.value[record.id] + if (bindings?.length) { + for (const { arrowId, handleId } of bindings) { + const arrow = this.getShape(arrowId) + if (!arrow) continue + unbindArrowTerminal(arrow, handleId, source) + } + } + }) + + this.cleanup.registerBeforeDeleteHandler('page', (record) => { + // page was deleted, need to check whether it's the current page and select another one if so + if (this.instanceState.currentPageId !== record.id) return + + const backupPageId = this.pages.find((p) => p.id !== record.id)?.id + if (!backupPageId) return + this.store.put([{ ...this.instanceState, currentPageId: backupPageId }]) + + // delete the camera and state for the page if necessary + const cameraId = CameraRecordType.createId(record.id) + const instance_PageStateId = InstancePageStateRecordType.createId(record.id) + this.store.remove([cameraId, instance_PageStateId]) + }) + + this.cleanup.registerBatchCompleteHandler(() => { + for (const parentId of invalidParents) { + invalidParents.delete(parentId) + const parent = this.getShape(parentId) + if (!parent) continue + + const util = this.getShapeUtil(parent) + const changes = util.onChildrenChange?.(parent) + + if (changes?.length) { + this.updateShapes(changes, { squashing: true }) + } + } + + deletedShapeIdsInTransaction.clear() + arrowsDidUpdateIndex.clear() + invalidParents.clear() + + this.emit('update') + }) + + this.disposables.add( + this.store.listen((changes) => { + this.emit('change', changes) + }) + ) + + // Start tick manager requestAnimationFrame(() => { this._tickManager.start() }) @@ -370,6 +844,8 @@ export class Editor extends EventEmitter { */ readonly store: TLStore + readonly cleanup: CleanupManager + /** * The root state of the statechart. * @@ -412,40 +888,11 @@ export class Editor extends EventEmitter { readonly textMeasure: TextManager /** - * Whether the editor is running in Safari. + * A helper for managing the editor's environment. * * @public */ - readonly isSafari: boolean - - /** - * Whether the editor is running on iOS. - * - * @public - */ - readonly isIos: boolean - - /** - * Whether the editor is running on iOS. - * - * @public - */ - readonly isChromeForIos: boolean - - /** - * Whether the editor is running on Firefox. - * - * @public - */ - readonly isFirefox: boolean - - /** - * Whether the editor is running on Android. - * - * @public - */ - readonly isAndroid: boolean - + readonly environment: EnvironmentManager /** * The current HTML element containing the editor. * @@ -511,14 +958,10 @@ export class Editor extends EventEmitter { * * @readonly */ - readonly history = new HistoryManager( - this, - () => this._complete(), - (error) => { - this.annotateError(error, { origin: 'history.batch', willCrashApp: true }) - this.crash(error) - } - ) + readonly history = new HistoryManager(this, (error) => { + this.annotateError(error, { origin: 'history.batch', willCrashApp: true }) + this.crash(error) + }) /** * Undo to the last mark. @@ -531,7 +974,8 @@ export class Editor extends EventEmitter { * @public */ undo() { - return this.history.undo() + this.history.undo() + return this } /** @@ -583,8 +1027,9 @@ export class Editor extends EventEmitter { * * @public */ - mark(markId?: string, onUndo?: boolean, onRedo?: boolean) { - return this.history.mark(markId, onUndo, onRedo) + mark(markId?: string, onUndo?: boolean, onRedo?: boolean): this { + this.history.mark(markId, onUndo, onRedo) + return this } /** @@ -639,6 +1084,143 @@ export class Editor extends EventEmitter { return this } + /* ---------------------- Store --------------------- */ + + /** @public */ + createRecords = this.history.createCommand( + 'createRecords', + >( + partials: OptionalKeys[], + opts?: CommandHistoryOptions + ) => { + const records = partials.map((partial) => { + const recordType = this.store.schema.types[partial.typeName as TLRecord['typeName']] + + if (this.instanceState.isReadonly) { + if (!recordType) throw Error(`Cannot get record type for ${partial.typeName}`) + // When in readonly mode, do not update document records + if (recordType.scope === 'document') { + throw Error('Cannot delete records while in readOnly mode') + } + } + + return recordType.create(partial) + }) + + return { + data: { records }, + ephemeral: opts?.ephemeral ?? false, + preservesRedoStack: opts?.preservesRedoStack ?? false, + } + }, + { + do: ({ records }) => { + this.store.put(records) + }, + undo: ({ records }) => { + this.store.remove(records.map((r) => r.id)) + }, + } + ) + + /** @public */ + updateRecords = this.history.createCommand( + 'updateRecords', + (partials: Partial[], opts?: CommandHistoryOptions) => { + const compactedPartials = compact(partials) + const prevRecords = {} as Record + const nextRecords = {} as Record + + for (let i = 0, n = compactedPartials.length; i < n; i++) { + const partial = compactedPartials[i] + if (!partial.id) throw Error() + const prev = this.store.get(partial.id) + if (!prev) throw Error(`Cannot find record with id: "${partial.id}"`) + prevRecords[partial.id] = prev + nextRecords[partial.id] = { ...prev, ...partial } as TLRecord + } + + if (this.instanceState.isReadonly) { + const typeNames = dedupe(Object.values(nextRecords).map((record) => record.typeName)) + for (const typeName of typeNames) { + const recordType = this.store.schema.types[typeName as TLRecord['typeName']] + if (!recordType) throw Error(`Cannot get record type for ${typeName}`) + // When in readonly mode, do not update document records + if (recordType.scope === 'document') { + throw Error('Cannot update records while in readOnly mode') + } + } + } + + return { + data: { prevRecords, nextRecords }, + ephemeral: opts?.ephemeral ?? false, + squashing: opts?.squashing ?? false, // todo: we might set this to true always + preservesRedoStack: opts?.preservesRedoStack ?? false, + } + }, + { + do: ({ nextRecords }) => { + this.store.put(Object.values(nextRecords)) + }, + undo: ({ prevRecords }) => { + this.store.put(Object.values(prevRecords)) + }, + squash: ( + { prevRecords: prevPrev, nextRecords: prevNext }, + { prevRecords: nextPrev, nextRecords: nextNext } + ) => { + // Sooometimes the new records will have more "prev" records than the old records + const data = { + prevRecords: { ...nextPrev, ...prevPrev }, + nextRecords: { ...prevNext, ...nextNext }, + } + if (data === undefined) throw Error() + return data + }, + } + ) + + /** @public */ + deleteRecords = this.history.createCommand( + 'deleteRecords', + >( + records: T['id'][] | T[], + opts?: CommandHistoryOptions + ) => { + const ids = + typeof records[0] === 'string' ? (records as T['id'][]) : (records as T[]).map((r) => r.id) + + const deletedRecords = compact(ids.map((id) => this.store.get(id))) + + if (this.instanceState.isReadonly) { + const typeNames = dedupe(deletedRecords.map((record) => record.typeName)) + for (const typeName of typeNames) { + const recordType = this.store.schema.types[typeName as TLRecord['typeName']] + if (!recordType) throw Error(`Cannot get record type for ${typeName}`) + // When in readonly mode, do not update document records + if (recordType.scope === 'document') { + throw Error('Cannot delete records while in readOnly mode') + } + } + } + + return { + data: { deletedRecords }, + ephemeral: opts?.ephemeral ?? false, + preservesRedoStack: opts?.preservesRedoStack ?? false, + } + }, + { + do: ({ deletedRecords }) => { + this.store.remove(deletedRecords.map((r) => r.id)) + }, + undo: ({ deletedRecords }) => { + this.store.put(deletedRecords) + }, + } + ) + /* --------------------- Arrows --------------------- */ // todo: move these to tldraw or replace with a bindings API @@ -659,95 +1241,6 @@ export class Editor extends EventEmitter { return this._arrowBindingsIndex.value[shapeId] || EMPTY_ARRAY } - /** @internal */ - private _reparentArrow(arrowId: TLShapeId) { - const arrow = this.getShape(arrowId) - if (!arrow) return - const { start, end } = arrow.props - const startShape = start.type === 'binding' ? this.getShape(start.boundShapeId) : undefined - const endShape = end.type === 'binding' ? this.getShape(end.boundShapeId) : undefined - - const parentPageId = this.getAncestorPageId(arrow) - if (!parentPageId) return - - let nextParentId: TLParentId - if (startShape && endShape) { - // if arrow has two bindings, always parent arrow to closest common ancestor of the bindings - nextParentId = this.findCommonAncestor([startShape, endShape]) ?? parentPageId - } else if (startShape || endShape) { - // if arrow has one binding, keep arrow on its own page - nextParentId = parentPageId - } else { - return - } - - if (nextParentId && nextParentId !== arrow.parentId) { - this.reparentShapes([arrowId], nextParentId) - } - - const reparentedArrow = this.getShape(arrowId) - if (!reparentedArrow) throw Error('no reparented arrow') - - const startSibling = this.getShapeNearestSibling(reparentedArrow, startShape) - const endSibling = this.getShapeNearestSibling(reparentedArrow, endShape) - - let highestSibling: TLShape | undefined - - if (startSibling && endSibling) { - highestSibling = startSibling.index > endSibling.index ? startSibling : endSibling - } else if (startSibling && !endSibling) { - highestSibling = startSibling - } else if (endSibling && !startSibling) { - highestSibling = endSibling - } else { - return - } - - let finalIndex: string - - const higherSiblings = this.getSortedChildIdsForParent(highestSibling.parentId) - .map((id) => this.getShape(id)!) - .filter((sibling) => sibling.index > highestSibling!.index) - - if (higherSiblings.length) { - // there are siblings above the highest bound sibling, we need to - // insert between them. - - // if the next sibling is also a bound arrow though, we can end up - // all fighting for the same indexes. so lets find the next - // non-arrow sibling... - const nextHighestNonArrowSibling = higherSiblings.find((sibling) => sibling.type !== 'arrow') - - if ( - // ...then, if we're above the last shape we want to be above... - reparentedArrow.index > highestSibling.index && - // ...but below the next non-arrow sibling... - (!nextHighestNonArrowSibling || reparentedArrow.index < nextHighestNonArrowSibling.index) - ) { - // ...then we're already in the right place. no need to update! - return - } - - // otherwise, we need to find the index between the highest sibling - // we want to be above, and the next highest sibling we want to be - // below: - finalIndex = getIndexBetween(highestSibling.index, higherSiblings[0].index) - } else { - // if there are no siblings above us, we can just get the next index: - finalIndex = getIndexAbove(highestSibling.index) - } - - if (finalIndex !== reparentedArrow.index) { - this.updateShapes([{ id: arrowId, type: 'arrow', index: finalIndex }]) - } - } - - /** @internal */ - private _unbindArrowTerminal(arrow: TLArrowShape, handleId: 'start' | 'end') { - const { x, y } = getArrowTerminalsInArrowSpace(this, arrow)[handleId] - this.store.put([{ ...arrow, props: { ...arrow.props, [handleId]: { type: 'point', x, y } } }]) - } - @computed private get arrowInfoCache() { return this.store.createComputedCache('arrow infoCache', (shape) => { @@ -761,186 +1254,6 @@ export class Editor extends EventEmitter { return this.arrowInfoCache.get(shape.id) } - // private _shapeWillUpdate = (prev: TLShape, next: TLShape) => { - // const update = this.getShapeUtil(next).onUpdate?.(prev, next) - // return update ?? next - // } - - /** @internal */ - private _shapeWillBeDeleted(deletedShape: TLShape) { - // if the deleted shape has a parent shape make sure we call it's onChildrenChange callback - if (deletedShape.parentId && isShapeId(deletedShape.parentId)) { - this._invalidParents.add(deletedShape.parentId) - } - // clean up any arrows bound to this shape - const bindings = this._arrowBindingsIndex.value[deletedShape.id] - if (bindings?.length) { - for (const { arrowId, handleId } of bindings) { - const arrow = this.getShape(arrowId) - if (!arrow) continue - this._unbindArrowTerminal(arrow, handleId) - } - } - const deletedIds = new Set([deletedShape.id]) - const updates = compact( - this.pageStates.map((pageState) => { - return this._cleanupInstancePageState(pageState, deletedIds) - }) - ) - - if (updates.length) { - this.store.put(updates) - } - } - - /** @internal */ - private _arrowDidUpdate(arrow: TLArrowShape) { - // if the shape is an arrow and its bound shape is on another page - // or was deleted, unbind it - for (const handle of ['start', 'end'] as const) { - const terminal = arrow.props[handle] - if (terminal.type !== 'binding') continue - const boundShape = this.getShape(terminal.boundShapeId) - const isShapeInSamePageAsArrow = - this.getAncestorPageId(arrow) === this.getAncestorPageId(boundShape) - if (!boundShape || !isShapeInSamePageAsArrow) { - this._unbindArrowTerminal(arrow, handle) - } - } - - // always check the arrow parents - this._reparentArrow(arrow.id) - } - - /** - * _invalidParents is used to trigger the 'onChildrenChange' callback that shapes can have. - * - * @internal - */ - private readonly _invalidParents = new Set() - - /** @internal */ - private _complete() { - for (const parentId of this._invalidParents) { - this._invalidParents.delete(parentId) - const parent = this.getShape(parentId) - if (!parent) continue - - const util = this.getShapeUtil(parent) - const changes = util.onChildrenChange?.(parent) - - if (changes?.length) { - this.updateShapes(changes, true) - } - } - - this.emit('update') - } - - /** @internal */ - private _shapeDidChange(prev: TLShape, next: TLShape) { - if (this.isShapeOfType(next, 'arrow')) { - this._arrowDidUpdate(next) - } - - // if the shape's parent changed and it is bound to an arrow, update the arrow's parent - if (prev.parentId !== next.parentId) { - const reparentBoundArrows = (id: TLShapeId) => { - const boundArrows = this._arrowBindingsIndex.value[id] - if (boundArrows?.length) { - for (const arrow of boundArrows) { - this._reparentArrow(arrow.arrowId) - } - } - } - reparentBoundArrows(next.id) - this.visitDescendants(next.id, reparentBoundArrows) - } - - // if this shape moved to a new page, clean up any previous page's instance state - if (prev.parentId !== next.parentId && isPageId(next.parentId)) { - const allMovingIds = new Set([prev.id]) - this.visitDescendants(prev.id, (id) => { - allMovingIds.add(id) - }) - - for (const instancePageState of this.pageStates) { - if (instancePageState.pageId === next.parentId) continue - const nextPageState = this._cleanupInstancePageState(instancePageState, allMovingIds) - - if (nextPageState) { - this.store.put([nextPageState]) - } - } - } - - if (prev.parentId && isShapeId(prev.parentId)) { - this._invalidParents.add(prev.parentId) - } - - if (next.parentId !== prev.parentId && isShapeId(next.parentId)) { - this._invalidParents.add(next.parentId) - } - } - - /** @internal */ - private _pageStateDidChange(prev: TLInstancePageState, next: TLInstancePageState) { - if (prev?.selectedShapeIds !== next?.selectedShapeIds) { - // ensure that descendants and ancestors are not selected at the same time - const filtered = next.selectedShapeIds.filter((id) => { - let parentId = this.getShape(id)?.parentId - while (isShapeId(parentId)) { - if (next.selectedShapeIds.includes(parentId)) { - return false - } - parentId = this.getShape(parentId)?.parentId - } - return true - }) - - let nextFocusedGroupId: null | TLShapeId = null - - if (filtered.length > 0) { - const commonGroupAncestor = this.findCommonAncestor( - compact(filtered.map((id) => this.getShape(id))), - (shape) => this.isShapeOfType(shape, 'group') - ) - - if (commonGroupAncestor) { - nextFocusedGroupId = commonGroupAncestor - } - } else { - if (next?.focusedGroupId) { - nextFocusedGroupId = next.focusedGroupId - } - } - - if ( - filtered.length !== next.selectedShapeIds.length || - nextFocusedGroupId !== next.focusedGroupId - ) { - this.store.put([ - { ...next, selectedShapeIds: filtered, focusedGroupId: nextFocusedGroupId ?? null }, - ]) - } - } - } - - /** @internal */ - private _pageWillBeDeleted(page: TLPage) { - // page was deleted, need to check whether it's the current page and select another one if so - if (this.instanceState.currentPageId !== page.id) return - - const backupPageId = this.pages.find((p) => p.id !== page.id)?.id - if (!backupPageId) return - this.store.put([{ ...this.instanceState, currentPageId: backupPageId }]) - - // delete the camera and state for the page if necessary - const cameraId = CameraRecordType.createId(page.id) - const instance_PageStateId = InstancePageStateRecordType.createId(page.id) - this.store.remove([cameraId, instance_PageStateId]) - } - /* --------------------- Errors --------------------- */ /** @internal */ @@ -1181,50 +1494,16 @@ export class Editor extends EventEmitter { */ updateInstanceState( partial: Partial>, - ephemeral = true, - squashing = true + opts?: CommandHistoryOptions ) { - this._updateInstanceState(partial, ephemeral, squashing) - - if (partial.isChangingStyle !== undefined) { - clearTimeout(this._isChangingStyleTimeout) - if (partial.isChangingStyle === true) { - // If we've set to true, set a new reset timeout to change the value back to false after 2 seconds - this._isChangingStyleTimeout = setTimeout(() => { - this.updateInstanceState({ isChangingStyle: false }) - }, 2000) - } - } - + this.updateRecords([{ id: this.instanceState.id, ...partial }], { + ephemeral: true, + squashing: true, + ...opts, + }) return this } - /** @internal */ - private _updateInstanceState = this.history.createCommand( - 'updateInstanceState', - (partial: Partial>, ephemeral = false, squashing = false) => { - const prev = this.instanceState - const next = { ...prev, ...partial } - - return { - data: { prev, next }, - squashing, - ephemeral, - } - }, - { - do: ({ next }) => { - this.store.put([next]) - }, - undo: ({ prev }) => { - this.store.put([prev]) - }, - squash({ prev }, { next }) { - return { prev, next } - }, - } - ) - /** @internal */ private _isChangingStyleTimeout = -1 as any @@ -1267,7 +1546,7 @@ export class Editor extends EventEmitter { const menus = new Set(this.openMenus) if (!menus.has(id)) { menus.add(id) - this.updateInstanceState({ openMenus: [...menus] }) + this.updateInstanceState({ openMenus: [...menus] }, { ephemeral: true }) } return this } @@ -1286,7 +1565,7 @@ export class Editor extends EventEmitter { const menus = new Set(this.openMenus) if (menus.has(id)) { menus.delete(id) - this.updateInstanceState({ openMenus: [...menus] }) + this.updateInstanceState({ openMenus: [...menus] }, { ephemeral: true }) } return this } @@ -1320,16 +1599,32 @@ export class Editor extends EventEmitter { return this.store.query.records('instance_page_state') } + /** + * Get the page state for a given page. + * + * @param pageId - The id of the page to get the state for. + * + * @returns The page state. + */ + getPageState(pageId: TLPageId): TLInstancePageState { + return this.store.get(InstancePageStateRecordType.createId(pageId))! + } + /** * The current page state. * * @public */ @computed get currentPageState(): TLInstancePageState { - return this.store.get(this._currentPageStateId)! + return this.store.get(this.currentPageStateId)! } - /** @internal */ - @computed private get _currentPageStateId() { + + /** + * The current page state id. + * + * @public + */ + @computed get currentPageStateId() { return InstancePageStateRecordType.createId(this.currentPageId) } @@ -1338,8 +1633,8 @@ export class Editor extends EventEmitter { * * @example * ```ts - * editor.updateInstancePageState({ id: 'page1', editingShapeId: 'shape:123' }) - * editor.updateInstancePageState({ id: 'page1', editingShapeId: 'shape:123' }, true) + * editor.updatePageState({ id: 'page1', editingShapeId: 'shape:123' }) + * editor.updatePageState({ id: 'page1', editingShapeId: 'shape:123' }, true) * ``` * * @param partial - The partial of the page state object containing the changes. @@ -1347,33 +1642,14 @@ export class Editor extends EventEmitter { * * @public */ - updateCurrentPageState( - partial: Partial< - Omit - >, - ephemeral = false + updatePageState( + partial: Partial>, + opts?: CommandHistoryOptions ): this { - this._setInstancePageState(partial, ephemeral) + this.updateRecords([{ ...partial, id: partial.id ?? this.currentPageStateId }], opts) return this } - /** @internal */ - private _setInstancePageState = this.history.createCommand( - 'setInstancePageState', - (partial: Partial>, ephemeral = false) => { - const prev = this.store.get(partial.id ?? this.currentPageState.id)! - return { data: { prev, partial }, ephemeral } - }, - { - do: ({ prev, partial }) => { - this.store.update(prev.id, (state) => ({ ...state, ...partial })) - }, - undo: ({ prev }) => { - this.store.update(prev.id, () => prev) - }, - } - ) - /** * The current selected ids. * @@ -1399,45 +1675,19 @@ export class Editor extends EventEmitter { * @public */ setSelectedShapeIds(ids: TLShapeId[], squashing = false) { - this._setSelectedShapeIds(ids, squashing) - return this - } - - /** @internal */ - private _setSelectedShapeIds = this.history.createCommand( - 'setSelectedShapeIds', - (ids: TLShapeId[], squashing = false) => { - const { selectedShapeIds: prevSelectedShapeIds } = this.currentPageState - const prevSet = new Set(prevSelectedShapeIds) - - if (ids.length === prevSet.size && ids.every((id) => prevSet.has(id))) return null - - return { - data: { selectedShapeIds: ids, prevSelectedShapeIds }, + const { selectedShapeIds } = this.currentPageState + if ( + ids.length !== selectedShapeIds.length || + ids.some((id) => !selectedShapeIds.includes(id)) + ) { + this.updateRecords([{ id: this.currentPageStateId, selectedShapeIds: ids }], { + ephemeral: false, squashing, preservesRedoStack: true, - } - }, - { - do: ({ selectedShapeIds }) => { - this.store.put([{ ...this.currentPageState, selectedShapeIds }]) - }, - undo: ({ prevSelectedShapeIds }) => { - this.store.put([ - { - ...this.currentPageState, - selectedShapeIds: prevSelectedShapeIds, - }, - ]) - }, - squash({ prevSelectedShapeIds }, { selectedShapeIds }) { - return { - selectedShapeIds, - prevSelectedShapeIds, - } - }, + }) } - ) + return this + } /** * Determine whether or not any of a shape's ancestors are selected. @@ -1517,7 +1767,7 @@ export class Editor extends EventEmitter { const ids = this.getSortedChildIdsForParent(this.currentPageId) // page might have no shapes if (ids.length <= 0) return this - this.setSelectedShapeIds(this._getUnlockedShapeIds(ids)) + this.setSelectedShapeIds(ids) return this } @@ -1668,41 +1918,20 @@ export class Editor extends EventEmitter { return this.currentPageState.focusedGroupId ?? this.currentPageId } - setFocusedGroupId(next: TLShapeId | TLPageId): this { - this._setFocusedGroupId(next) + setFocusedGroupId(id: TLShapeId | TLPageId): this { + const { + currentPageState: { focusedGroupId, id: currentPageStateId }, + } = this + const next = isPageId(id as string) ? null : (id as TLShapeId) + const curr = focusedGroupId + if (next === curr) return this + this.updateRecords([{ id: currentPageStateId, focusedGroupId: next }], { + ephemeral: false, + squashing: true, + preservesRedoStack: true, + }) return this } - /** @internal */ - private _setFocusedGroupId = this.history.createCommand( - 'setFocusedGroupId', - (next: undefined | TLShapeId | TLPageId) => { - next = isPageId(next as string) ? undefined : (next as TLShapeId | undefined) - // When we first click an empty canvas we don't want this to show up in the undo stack - if (!next && !this.canUndo) { - return - } - const prev = this.currentPageState.focusedGroupId - return { - data: { - prev, - next, - }, - preservesRedoStack: true, - squashing: true, - } - }, - { - do: ({ next }) => { - this.store.update(this.currentPageState.id, (s) => ({ ...s, focusedGroupId: next ?? null })) - }, - undo: ({ prev }) => { - this.store.update(this.currentPageState.id, (s) => ({ ...s, focusedGroupId: prev })) - }, - squash({ prev }, { next }) { - return { prev, next } - }, - } - ) /** * Exit the current focus layer, moving up to the next group if there is one. @@ -1735,21 +1964,15 @@ export class Editor extends EventEmitter { * * @public */ - get editingShapeId() { + @computed get editingShapeId() { return this.currentPageState.editingShapeId } - setEditingId(id: TLShapeId | null): this { - if (!id) { - this._setInstancePageState({ editingShapeId: null }) - } else { - if (id !== this.editingShapeId) { - const shape = this.getShape(id)! - const util = this.getShapeUtil(shape) - if (shape && util.canEdit(shape)) { - this._setInstancePageState({ editingShapeId: id, hoveredShapeId: null }, false) - } - } - } + @computed get editingShape() { + return this.editingShapeId ? this.getShape(this.editingShapeId) : undefined + } + setEditingShapeId(id: TLShapeId | null): this { + if (id === this.currentPageState.editingShapeId) return this + this.updateRecords([{ id: this.currentPageStateId, editingShapeId: id }]) return this } @@ -1764,14 +1987,14 @@ export class Editor extends EventEmitter { @computed get hoveredShapeId() { return this.currentPageState.hoveredShapeId } - setHoveredId(id: TLShapeId | null): this { - if (id === this.currentPageState.hoveredShapeId) return this - this.updateCurrentPageState({ hoveredShapeId: id }, true) - return this - } @computed get hoveredShape() { return this.hoveredShapeId ? this.getShape(this.hoveredShapeId) : undefined } + setHoveredShapeId(id: TLShapeId | null): this { + if (id === this.currentPageState.hoveredShapeId) return this + this.updateRecords([{ id: this.currentPageStateId, hoveredShapeId: id }], { squashing: true }) + return this + } // Hinting ids @@ -1783,9 +2006,13 @@ export class Editor extends EventEmitter { @computed get hintingShapeIds() { return this.currentPageState.hintingShapeIds } - setHintingIds(ids: TLShapeId[]): this { - // always ephemeral - this.store.update(this.currentPageState.id, (s) => ({ ...s, hintingShapeIds: dedupe(ids) })) + @computed get hintingShapes() { + return compact(this.currentPageState.hintingShapeIds.map((id) => this.getShape(id))) + } + setHintingShapeIds(ids: TLShapeId[]): this { + this.updateRecords([{ id: this.currentPageStateId, hintingShapeIds: dedupe(ids) }], { + ephemeral: true, + }) return this } @@ -1797,22 +2024,20 @@ export class Editor extends EventEmitter { @computed get erasingShapeIds() { return this.currentPageState.erasingShapeIds } - - setErasingIds(ids: TLShapeId[]): this { - const erasingShapeIds = this.erasingShapeIdsSet - if (ids.length === erasingShapeIds.size && ids.every((id) => erasingShapeIds.has(id))) + @computed get erasingShapes() { + return compact(this.currentPageState.erasingShapeIds.map((id) => this.getShape(id))) + } + setErasingShapeIds(ids: TLShapeId[]): this { + const { erasingShapeIds } = this + if (ids.length === erasingShapeIds.length && ids.every((id) => erasingShapeIds.includes(id))) { return this - this._setInstancePageState({ erasingShapeIds: ids }, true) + } + this.updateRecords([{ id: this.currentPageStateId, erasingShapeIds: ids }], { squashing: true }) return this } - /** - * A derived set containing the current erasing ids. - * - * @public - */ - @computed get erasingShapeIdsSet() { - return new Set(this.erasingShapeIds) + @computed private get erasingShapeIdsSet() { + return new Set(this.erasingShapeIds) } /** @@ -1823,10 +2048,10 @@ export class Editor extends EventEmitter { get croppingShapeId() { return this.currentPageState.croppingShapeId } - setCroppingId(id: TLShapeId | null): this { + setCroppingShapeId(id: TLShapeId | null): this { if (id !== this.croppingShapeId) { if (!id) { - this.updateCurrentPageState({ croppingShapeId: null }) + this.updatePageState({ croppingShapeId: null }) if (this.isInAny('select.crop', 'select.pointing_crop_handle', 'select.cropping')) { this.setCurrentTool('select.idle') } @@ -1834,66 +2059,21 @@ export class Editor extends EventEmitter { const shape = this.getShape(id)! const util = this.getShapeUtil(shape) if (shape && util.canCrop(shape)) { - this.updateCurrentPageState({ croppingShapeId: id }) + this.updatePageState({ croppingShapeId: id }, { ephemeral: false, squashing: true }) // weird } } } return this } - /** @internal */ - private _cleanupInstancePageState( - prevPageState: TLInstancePageState, - shapesNoLongerInPage: Set - ) { - let nextPageState = null as null | TLInstancePageState - - const selectedShapeIds = prevPageState.selectedShapeIds.filter( - (id) => !shapesNoLongerInPage.has(id) - ) - if (selectedShapeIds.length !== prevPageState.selectedShapeIds.length) { - if (!nextPageState) nextPageState = { ...prevPageState } - nextPageState.selectedShapeIds = selectedShapeIds - } - - const erasingShapeIds = prevPageState.erasingShapeIds.filter( - (id) => !shapesNoLongerInPage.has(id) - ) - if (erasingShapeIds.length !== prevPageState.erasingShapeIds.length) { - if (!nextPageState) nextPageState = { ...prevPageState } - nextPageState.erasingShapeIds = erasingShapeIds - } - - if (prevPageState.hoveredShapeId && shapesNoLongerInPage.has(prevPageState.hoveredShapeId)) { - if (!nextPageState) nextPageState = { ...prevPageState } - nextPageState.hoveredShapeId = null - } - - if (prevPageState.editingShapeId && shapesNoLongerInPage.has(prevPageState.editingShapeId)) { - if (!nextPageState) nextPageState = { ...prevPageState } - nextPageState.editingShapeId = null - } - - const hintingShapeIds = prevPageState.hintingShapeIds.filter( - (id) => !shapesNoLongerInPage.has(id) - ) - if (hintingShapeIds.length !== prevPageState.hintingShapeIds.length) { - if (!nextPageState) nextPageState = { ...prevPageState } - nextPageState.hintingShapeIds = hintingShapeIds - } - - if (prevPageState.focusedGroupId && shapesNoLongerInPage.has(prevPageState.focusedGroupId)) { - if (!nextPageState) nextPageState = { ...prevPageState } - nextPageState.focusedGroupId = null - } - return nextPageState - } - /* --------------------- Camera --------------------- */ - /** @internal */ - @computed - private get cameraId() { + /** + * The id of the current camera. + * + * @public + */ + @computed get cameraId() { return CameraRecordType.createId(this.currentPageId) } @@ -1919,14 +2099,18 @@ export class Editor extends EventEmitter { private _willSetInitialBounds = true /** @internal */ - private _setCamera(x: number, y: number, z = this.camera.z): this { + private _setCamera(point: VecLike): this { const currentCamera = this.camera - if (currentCamera.x === x && currentCamera.y === y && currentCamera.z === z) return this - const nextCamera = { ...currentCamera, x, y, z } + + if (currentCamera.x === point.x && currentCamera.y === point.y && currentCamera.z === point.z) { + return this + } this.batch(() => { - this.store.put([nextCamera]) + this.store.put([{ ...currentCamera, ...point }]) // include id and meta here + // Dispatch a new pointer move because the pointer's page will have changed + // (its screen position will compute to a new page position given the new camera position) const { currentScreenPoint } = this.inputs this.dispatch({ @@ -1953,84 +2137,54 @@ export class Editor extends EventEmitter { * * @example * ```ts - * editor.setCamera(0, 0) - * editor.setCamera(0, 0, 1) + * editor.setCamera({ x: 0, y: 0}) + * editor.setCamera({ x: 0, y: 0, z: 1.5}) + * editor.setCamera({ x: 0, y: 0, z: 1.5}, { duration: 1000, easing: (t) => t * t }) * ``` * - * @param x - The camera's x position. - * @param y - The camera's y position. - * @param z - The camera's z position. Defaults to the current zoom. - * @param options - Options for the camera change. + * @param point - The new camera position. + * @param animation - (optional) Options for an animation. * * @public */ - setCamera( - x: number, - y: number, - z = this.camera.z, - { stopFollowing = true }: TLViewportOptions = {} - ): this { + setCamera(point: VecLike, animation?: TLAnimationOptions): this { + const x = Number.isFinite(point.x) ? point.x : 0 + const y = Number.isFinite(point.y) ? point.y : 0 + const z = Number.isFinite(point.z) ? point.z! : this.zoomLevel + + // Stop any camera animations this.stopCameraAnimation() - if (stopFollowing && this.instanceState.followingUserId) { + + // Stop following any user + if (this.instanceState.followingUserId) { this.stopFollowingUser() } - x = Number.isNaN(x) ? 0 : x - y = Number.isNaN(y) ? 0 : y - z = Number.isNaN(z) ? 1 : z - this._setCamera(x, y, z) + + if (animation) { + const { width, height } = this.viewportScreenBounds + return this._animateToViewport(new Box2d(-x, -y, width / z, height / z), animation) + } else { + this._setCamera({ x, y, z }) + } + return this } - /** - * Animate the camera. - * - * @example - * ```ts - * editor.animateCamera(0, 0) - * editor.animateCamera(0, 0, 1) - * editor.animateCamera(0, 0, 1, { duration: 1000, easing: (t) => t * t }) - * ``` - * - * @param x - The camera's x position. - * @param y - The camera's y position. - * @param z - The camera's z position. Defaults to the current zoom. - * @param opts - Options for the animation. - * - * @public - */ - animateCamera( - x: number, - y: number, - z = this.camera.z, - opts: TLAnimationOptions = DEFAULT_ANIMATION_OPTIONS - ): this { - x = Number.isNaN(x) ? 0 : x - y = Number.isNaN(y) ? 0 : y - z = Number.isNaN(z) ? 1 : z - const { width, height } = this.viewportScreenBounds - const w = width / z - const h = height / z - - const targetViewport = new Box2d(-x, -y, w, h) - - return this._animateToViewport(targetViewport, opts) - } - /** * Center the camera on a point (in page space). * * @example * ```ts - * editor.centerOnPoint(100, 100) + * editor.centerOnPoint({ x: 100, y: 100 }) + * editor.centerOnPoint({ x: 100, y: 100 }, { duration: 200 }) * ``` * - * @param x - The x position of the point. - * @param y - The y position of the point. - * @param opts - The options for an animation. + * @param point - The point in page space to center on. + * @param animation - (optional) The options for an animation. * * @public */ - centerOnPoint(x: number, y: number, opts?: TLAnimationOptions): this { + centerOnPoint(point: VecLike, animation?: TLAnimationOptions): this { if (!this.instanceState.canMoveCamera) return this const { @@ -2038,31 +2192,28 @@ export class Editor extends EventEmitter { camera, } = this - if (opts?.duration) { - this.animateCamera(-(x - pw / 2), -(y - ph / 2), camera.z, opts) - } else { - this.setCamera(-(x - pw / 2), -(y - ph / 2), camera.z) - } + this.setCamera({ x: -(point.x - pw / 2), y: -(point.y - ph / 2), z: camera.z }, animation) return this } /** * Move the camera to the nearest content. * + * @example + * ```ts + * editor.zoomToContent() + * editor.zoomToContent({ duration: 200 }) + * ``` + * + * @param opts - (optional) The options for an animation. + * * @public */ zoomToContent() { const bounds = this.selectionPageBounds ?? this.commonBoundsOfAllShapesOnCurrentPage if (bounds) { - this.zoomToBounds( - bounds.minX, - bounds.minY, - bounds.width, - bounds.height, - Math.min(1, this.zoomLevel), - { duration: 220 } - ) + this.zoomToBounds(bounds, Math.min(1, this.zoomLevel), { duration: 220 }) } return this @@ -2074,25 +2225,21 @@ export class Editor extends EventEmitter { * @example * ```ts * editor.zoomToFit() + * editor.zoomToFit({ duration: 200 }) * ``` * + * @param animation - (optional) The options for an animation. + * * @public */ - zoomToFit(opts?: TLAnimationOptions): this { + zoomToFit(animation?: TLAnimationOptions): this { if (!this.instanceState.canMoveCamera) return this - const ids = [...this.shapeIdsOnCurrentPage] + const ids = [...this.currentPageShapeIds] if (ids.length <= 0) return this const pageBounds = Box2d.Common(compact(ids.map((id) => this.getPageBounds(id)))) - this.zoomToBounds( - pageBounds.minX, - pageBounds.minY, - pageBounds.width, - pageBounds.height, - undefined, - opts - ) + this.zoomToBounds(pageBounds, undefined, animation) return this } @@ -2102,22 +2249,24 @@ export class Editor extends EventEmitter { * @example * ```ts * editor.resetZoom() + * editor.resetZoom(editor.viewportScreenCenter) + * editor.resetZoom(editor.viewportScreenCenter, { duration: 200 }) * ``` * - * @param opts - The options for an animation. + * @param point - (optional) The screen point to zoom out on. Defaults to the viewport screen center. + * @param animation - (optional) The options for an animation. * * @public */ - resetZoom(point = this.viewportScreenCenter, opts?: TLAnimationOptions): this { + resetZoom(point = this.viewportScreenCenter, animation?: TLAnimationOptions): this { if (!this.instanceState.canMoveCamera) return this const { x: cx, y: cy, z: cz } = this.camera const { x, y } = point - if (opts?.duration) { - this.animateCamera(cx + (x / 1 - x) - (x / cz - x), cy + (y / 1 - y) - (y / cz - y), 1, opts) - } else { - this.setCamera(cx + (x / 1 - x) - (x / cz - x), cy + (y / 1 - y) - (y / cz - y), 1) - } + this.setCamera( + { x: cx + (x / 1 - x) - (x / cz - x), y: cy + (y / 1 - y) - (y / cz - y), z: 1 }, + animation + ) return this } @@ -2132,11 +2281,11 @@ export class Editor extends EventEmitter { * editor.zoomIn(editor.inputs.currentScreenPoint, { duration: 120 }) * ``` * - * @param opts - The options for an animation. + * @param animation - (optional) The options for an animation. * * @public */ - zoomIn(point = this.viewportScreenCenter, opts?: TLAnimationOptions): this { + zoomIn(point = this.viewportScreenCenter, animation?: TLAnimationOptions): this { if (!this.instanceState.canMoveCamera) return this const { x: cx, y: cy, z: cz } = this.camera @@ -2152,16 +2301,10 @@ export class Editor extends EventEmitter { } const { x, y } = point - if (opts?.duration) { - this.animateCamera( - cx + (x / zoom - x) - (x / cz - x), - cy + (y / zoom - y) - (y / cz - y), - zoom, - opts - ) - } else { - this.setCamera(cx + (x / zoom - x) - (x / cz - x), cy + (y / zoom - y) - (y / cz - y), zoom) - } + this.setCamera( + { x: cx + (x / zoom - x) - (x / cz - x), y: cy + (y / zoom - y) - (y / cz - y), z: zoom }, + animation + ) return this } @@ -2176,11 +2319,11 @@ export class Editor extends EventEmitter { * editor.zoomOut(editor.inputs.currentScreenPoint, { duration: 120 }) * ``` * - * @param opts - The options for an animation. + * @param animation - (optional) The options for an animation. * * @public */ - zoomOut(point = this.viewportScreenCenter, opts?: TLAnimationOptions): this { + zoomOut(point = this.viewportScreenCenter, animation?: TLAnimationOptions): this { if (!this.instanceState.canMoveCamera) return this const { x: cx, y: cy, z: cz } = this.camera @@ -2197,16 +2340,14 @@ export class Editor extends EventEmitter { const { x, y } = point - if (opts?.duration) { - this.animateCamera( - cx + (x / zoom - x) - (x / cz - x), - cy + (y / zoom - y) - (y / cz - y), - zoom, - opts - ) - } else { - this.setCamera(cx + (x / zoom - x) - (x / cz - x), cy + (y / zoom - y) - (y / cz - y), zoom) - } + this.setCamera( + { + x: cx + (x / zoom - x) - (x / cz - x), + y: cy + (y / zoom - y) - (y / cz - y), + z: zoom, + }, + animation + ) return this } @@ -2219,26 +2360,19 @@ export class Editor extends EventEmitter { * editor.zoomToSelection() * ``` * - * @param opts - The options for an animation. + * @param animation - (optional) The options for an animation. * * @public */ - zoomToSelection(opts?: TLAnimationOptions): this { + zoomToSelection(animation?: TLAnimationOptions): this { if (!this.instanceState.canMoveCamera) return this const ids = this.selectedShapeIds if (ids.length <= 0) return this - const selectedBounds = Box2d.Common(compact(ids.map((id) => this.getPageBounds(id)))) + const selectionBounds = Box2d.Common(compact(ids.map((id) => this.getPageBounds(id)))) - this.zoomToBounds( - selectedBounds.minX, - selectedBounds.minY, - selectedBounds.width, - selectedBounds.height, - Math.max(1, this.camera.z), - opts - ) + this.zoomToBounds(selectionBounds, Math.max(1, this.camera.z), animation) return this } @@ -2247,27 +2381,20 @@ export class Editor extends EventEmitter { * Pan or pan/zoom the selected ids into view. This method tries to not change the zoom if possible. * * @param ids - The ids of the shapes to pan and zoom into view. - * @param opts - The options for an animation. + * @param animation - The options for an animation. * * @public */ - panZoomIntoView(ids: TLShapeId[], opts?: TLAnimationOptions): this { + panZoomIntoView(ids: TLShapeId[], animation?: TLAnimationOptions): this { if (!this.instanceState.canMoveCamera) return this if (ids.length <= 0) return this - const selectedBounds = Box2d.Common(compact(ids.map((id) => this.getPageBounds(id)))) + const selectionBounds = Box2d.Common(compact(ids.map((id) => this.getPageBounds(id)))) const { viewportPageBounds } = this - if (viewportPageBounds.h < selectedBounds.h || viewportPageBounds.w < selectedBounds.w) { - this.zoomToBounds( - selectedBounds.minX, - selectedBounds.minY, - selectedBounds.width, - selectedBounds.height, - this.camera.z, - opts - ) + if (viewportPageBounds.h < selectionBounds.h || viewportPageBounds.w < selectionBounds.w) { + this.zoomToBounds(selectionBounds, this.camera.z, animation) return this } else { @@ -2275,33 +2402,28 @@ export class Editor extends EventEmitter { let offsetX = 0 let offsetY = 0 - if (insetViewport.maxY < selectedBounds.maxY) { + if (insetViewport.maxY < selectionBounds.maxY) { // off bottom - offsetY = insetViewport.maxY - selectedBounds.maxY - } else if (insetViewport.minY > selectedBounds.minY) { + offsetY = insetViewport.maxY - selectionBounds.maxY + } else if (insetViewport.minY > selectionBounds.minY) { // off top - offsetY = insetViewport.minY - selectedBounds.minY + offsetY = insetViewport.minY - selectionBounds.minY } else { // inside y-bounds } - if (insetViewport.maxX < selectedBounds.maxX) { + if (insetViewport.maxX < selectionBounds.maxX) { // off right - offsetX = insetViewport.maxX - selectedBounds.maxX - } else if (insetViewport.minX > selectedBounds.minX) { + offsetX = insetViewport.maxX - selectionBounds.maxX + } else if (insetViewport.minX > selectionBounds.minX) { // off left - offsetX = insetViewport.minX - selectedBounds.minX + offsetX = insetViewport.minX - selectionBounds.minX } else { // inside x-bounds } const { camera } = this - - if (opts?.duration) { - this.animateCamera(camera.x + offsetX, camera.y + offsetY, camera.z, opts) - } else { - this.setCamera(camera.x + offsetX, camera.y + offsetY, camera.z) - } + this.setCamera({ x: camera.x + offsetX, y: camera.y + offsetY, z: camera.z }, animation) } return this @@ -2312,25 +2434,18 @@ export class Editor extends EventEmitter { * * @example * ```ts - * editor.zoomToBounds(0, 0, 100, 100) + * editor.zoomToBounds(myBounds) + * editor.zoomToBounds(myBounds, 1) + * editor.zoomToBounds(myBounds, 1, { duration: 100 }) * ``` * - * @param x - The bounding box's x position. - * @param y - The bounding box's y position. - * @param width - The bounding box's width. - * @param height - The bounding box's height. + * @param bounds - The bounding box. * @param targetZoom - The desired zoom level. Defaults to 0.1. + * @param animation - (optional) The options for an animation. * * @public */ - zoomToBounds( - x: number, - y: number, - width: number, - height: number, - targetZoom?: number, - opts?: TLAnimationOptions - ): this { + zoomToBounds(bounds: Box2d, targetZoom?: number, animation?: TLAnimationOptions): this { if (!this.instanceState.canMoveCamera) return this const { viewportScreenBounds } = this @@ -2339,8 +2454,8 @@ export class Editor extends EventEmitter { let zoom = clamp( Math.min( - (viewportScreenBounds.width - inset) / width, - (viewportScreenBounds.height - inset) / height + (viewportScreenBounds.width - inset) / bounds.width, + (viewportScreenBounds.height - inset) / bounds.height ), MIN_ZOOM, MAX_ZOOM @@ -2350,20 +2465,14 @@ export class Editor extends EventEmitter { zoom = Math.min(targetZoom, zoom) } - if (opts?.duration) { - this.animateCamera( - -x + (viewportScreenBounds.width - width * zoom) / 2 / zoom, - -y + (viewportScreenBounds.height - height * zoom) / 2 / zoom, - zoom, - opts - ) - } else { - this.setCamera( - -x + (viewportScreenBounds.width - width * zoom) / 2 / zoom, - -y + (viewportScreenBounds.height - height * zoom) / 2 / zoom, - zoom - ) - } + this.setCamera( + { + x: -bounds.minX + (viewportScreenBounds.width - bounds.width * zoom) / 2 / zoom, + y: -bounds.minY + (viewportScreenBounds.height - bounds.height * zoom) / 2 / zoom, + z: zoom, + }, + animation + ) return this } @@ -2373,27 +2482,17 @@ export class Editor extends EventEmitter { * * @example * ```ts - * editor.pan(100, 100) - * editor.pan(100, 100, { duration: 1000 }) + * editor.pan({ x: 100, y: 100 }) + * editor.pan({ x: 100, y: 100 }, { duration: 1000 }) * ``` * - * @param dx - The amount to pan on the x axis. - * @param dy - The amount to pan on the y axis. - * @param opts - The animation options + * @param offset - The offset in page space. + * @param animation - (optional) The animation options. */ - pan(dx: number, dy: number, opts?: TLAnimationOptions): this { + pan(offset: VecLike, animation?: TLAnimationOptions): this { if (!this.instanceState.canMoveCamera) return this - - const { camera } = this - const { x: cx, y: cy, z: cz } = camera - const d = new Vec2d(dx, dy).div(cz) - - if (opts?.duration ?? 0 > 0) { - return this.animateCamera(cx + d.x, cy + d.y, cz, opts) - } else { - this.setCamera(cx + d.x, cy + d.y, cz) - } - + const { x: cx, y: cy, z: cz } = this.camera + this.setCamera({ x: cx + offset.x / cz, y: cy + offset.y / cz, z: cz }, animation) return this } @@ -2438,7 +2537,7 @@ export class Editor extends EventEmitter { const x = -end.x const y = -end.y - this._setCamera(x, y, z) + this._setCamera({ x, y, z }) cancel() return } @@ -2457,7 +2556,7 @@ export class Editor extends EventEmitter { const x = -easedViewport.x const y = -easedViewport.y - this._setCamera(x, y, z) + this._setCamera({ x, y, z }) } /** @internal */ @@ -2476,11 +2575,11 @@ export class Editor extends EventEmitter { if (duration === 0 || animationSpeed === 0) { // If we have no animation, then skip the animation and just set the camera - return this._setCamera( - -targetViewportPage.x, - -targetViewportPage.y, - this.viewportScreenBounds.width / targetViewportPage.width - ) + return this._setCamera({ + x: -targetViewportPage.x, + y: -targetViewportPage.y, + z: this.viewportScreenBounds.width / targetViewportPage.width, + }) } // Set our viewport animation @@ -2539,7 +2638,7 @@ export class Editor extends EventEmitter { if (currentSpeed < speedThreshold) { cancel() } else { - this._setCamera(cx + movementVec.x, cy + movementVec.y, cz) + this._setCamera({ x: cx + movementVec.x, y: cy + movementVec.y, z: cz }) } } @@ -2583,9 +2682,7 @@ export class Editor extends EventEmitter { // Only animate the camera if the user is on the same page as us const options = isOnSamePage ? { duration: 500 } : undefined - const position = presence.cursor - - this.centerOnPoint(position.x, position.y, options) + this.centerOnPoint(presence.cursor, options) // Highlight the user's cursor const { highlightedUserIds } = this.instanceState @@ -2672,22 +2769,31 @@ export class Editor extends EventEmitter { if (_willSetInitialBounds) { // If we have just received the initial bounds, don't center the camera. this._willSetInitialBounds = false - this.updateInstanceState({ screenBounds: screenBounds.toJson() }, true, true) + this.updateInstanceState( + { screenBounds: screenBounds.toJson() }, + { ephemeral: true, squashing: true } + ) } else { const { zoomLevel } = this if (center) { const before = this.viewportPageCenter - this.updateInstanceState({ screenBounds: screenBounds.toJson() }, true, true) + this.updateInstanceState( + { screenBounds: screenBounds.toJson() }, + { ephemeral: true, squashing: true } + ) const after = this.viewportPageCenter if (!this.instanceState.followingUserId) { - this.pan((after.x - before.x) * zoomLevel, (after.y - before.y) * zoomLevel) + this.pan(Vec2d.Sub(after, before).mul(zoomLevel)) } } else { - const before = this.screenToPage(0, 0) - this.updateInstanceState({ screenBounds: screenBounds.toJson() }, true, true) - const after = this.screenToPage(0, 0) + const before = this.screenToPage({ x: 0, y: 0 }) + this.updateInstanceState( + { screenBounds: screenBounds.toJson() }, + { ephemeral: true, squashing: true } + ) + const after = this.screenToPage({ x: 0, y: 0 }) if (!this.instanceState.followingUserId) { - this.pan((after.x - before.x) * zoomLevel, (after.y - before.y) * zoomLevel) + this.pan(Vec2d.Sub(after, before).mul(zoomLevel)) } } } @@ -2730,10 +2836,9 @@ export class Editor extends EventEmitter { * @public */ @computed get viewportPageBounds() { - const { x, y, w, h } = this.viewportScreenBounds - const tl = this.screenToPage(x, y) - const br = this.screenToPage(x + w, y + h) - return new Box2d(tl.x, tl.y, br.x - tl.x, br.y - tl.y) + const { w, h } = this.viewportScreenBounds + const { x: cx, y: cy, z: cz } = this.camera + return new Box2d(-cx, -cy, w / cz, h / cz) } /** @@ -2750,45 +2855,43 @@ export class Editor extends EventEmitter { * * @example * ```ts - * editor.screenToPage(100, 100) + * editor.screenToPage({ x: 100, y: 100 }) * ``` * - * @param x - The x coordinate of the point in screen space. - * @param y - The y coordinate of the point in screen space. - * @param camera - The camera to use. Defaults to the current camera. + * @param point - The point in screen space. * * @public */ - screenToPage(x: number, y: number, z = 0.5, camera: VecLike = this.camera) { + screenToPage(point: VecLike) { const { screenBounds } = this.store.unsafeGetWithoutCapture(TLINSTANCE_ID)! - const { x: cx, y: cy, z: cz = 1 } = camera + const { x: cx, y: cy, z: cz = 1 } = this.camera return { - x: (x - screenBounds.x) / cz - cx, - y: (y - screenBounds.y) / cz - cy, - z, + x: (point.x - screenBounds.x - cx) / cz, + y: (point.y - screenBounds.y - cy) / cz, + z: point.z ?? 0.5, } } /** - * Convert a point in page space to a point in screen space. + * Convert a point in page space to a point in current screen space. * * @example * ```ts - * editor.pageToScreen(100, 100) + * editor.pageToScreen({ x: 100, y: 100 }) * ``` * - * @param x - The x coordinate of the point in screen space. - * @param y - The y coordinate of the point in screen space. - * @param camera - The camera to use. Defaults to the current camera. + * @param point - The point in screen space. * * @public */ - pageToScreen(x: number, y: number, z = 0.5, camera: VecLike = this.camera) { - const { x: cx, y: cy, z: cz = 1 } = camera + pageToScreen(point: VecLike) { + const { screenBounds } = this.store.unsafeGetWithoutCapture(TLINSTANCE_ID)! + const { x: cx, y: cy, z: cz = 1 } = this.camera + return { - x: x + cx * cz, - y: y + cy * cz, - z, + x: point.x * cz + cx + screenBounds.x, + y: point.y * cz + cy + screenBounds.y, + z: point.z ?? 0.5, } } @@ -2818,9 +2921,9 @@ export class Editor extends EventEmitter { } transact(() => { + // todo: move to side effects this.stopFollowingUser() - - this.updateInstanceState({ followingUserId: userId }, true) + this.updateInstanceState({ followingUserId: userId }, { ephemeral: true, squashing: true }) }) const cancel = () => { @@ -2831,22 +2934,27 @@ export class Editor extends EventEmitter { let isCaughtUp = false const moveTowardsUser = () => { - // Stop following if we can't find the user + // Look for the most recent presence of the leader const leaderPresence = [...leaderPresences.value] .sort((a, b) => { return a.lastActivityTimestamp - b.lastActivityTimestamp }) .pop() + + // If the leader is no longer present, then stop following them if (!leaderPresence) { this.stopFollowingUser() return } - // Change page if leader is on a different page + // If they've moved to a different page, change pages const isOnSamePage = leaderPresence.currentPageId === this.currentPageId const chaseProportion = isOnSamePage ? FOLLOW_CHASE_PROPORTION : 1 if (!isOnSamePage) { - this.setCurrentPage(leaderPresence.currentPageId, { stopFollowing: false }) + this.setCurrentPage(leaderPresence.currentPageId) + // changes pages will stop following user, so start following them again + this.startFollowingUser(userId) + return } // Get the bounds of the follower (me) and the leader (them) @@ -2900,15 +3008,14 @@ export class Editor extends EventEmitter { return } - // Update the camera! + // Update the camera! (manually) isCaughtUp = false this.stopCameraAnimation() - this.setCamera( - -(targetCenter.x - targetWidth / 2), - -(targetCenter.y - targetHeight / 2), - targetZoom, - { stopFollowing: false } - ) + this._setCamera({ + x: -(targetCenter.x - targetWidth / 2), + y: -(targetCenter.y - targetHeight / 2), + z: targetZoom, + }) } this.once('stop-following', cancel) @@ -2923,7 +3030,7 @@ export class Editor extends EventEmitter { * @public */ stopFollowingUser() { - this.updateInstanceState({ followingUserId: null }, true) + this.updateInstanceState({ followingUserId: null }, { ephemeral: true, squashing: true }) this.emit('stop-following') return this } @@ -2984,11 +3091,13 @@ export class Editor extends EventEmitter { { renderingBounds, renderingBoundsExpanded, + selectedShapeIds, erasingShapeIdsSet, editingShapeId, }: { renderingBounds?: Box2d renderingBoundsExpanded?: Box2d + selectedShapeIds?: TLShapeId[] erasingShapeIdsSet?: Set editingShapeId?: TLShapeId | null } = {} @@ -3048,9 +3157,10 @@ export class Editor extends EventEmitter { // Whether the shape should actually be culled / unmounted. // - Use the "expanded" rendering viewport to include shapes that are just off-screen. // - Editing shapes should never be culled. - const isCulled = maskedPageBounds - ? (editingShapeId !== id && !renderingBoundsExpanded?.includes(maskedPageBounds)) ?? true - : true + const isCulled = + (maskedPageBounds + ? (editingShapeId !== id && !renderingBoundsExpanded?.includes(maskedPageBounds)) ?? true + : true) && !selectedShapeIds?.includes(id) const util = this.getShapeUtil(shape) @@ -3104,6 +3214,7 @@ export class Editor extends EventEmitter { const renderingShapes = this.computeUnorderedRenderingShapes([this.currentPageId], { renderingBounds: this.renderingBounds, renderingBoundsExpanded: this.renderingBoundsExpanded, + selectedShapeIds: this.selectedShapeIds, erasingShapeIdsSet: this.erasingShapeIdsSet, editingShapeId: this.editingShapeId, }) @@ -3162,10 +3273,14 @@ export class Editor extends EventEmitter { const { viewportPageBounds } = this if (viewportPageBounds.equals(this._renderingBounds.__unsafe__getWithoutCapture())) return this this._renderingBounds.set(viewportPageBounds.clone()) - this._renderingBoundsExpanded.set(viewportPageBounds.clone().expandBy(100 / this.zoomLevel)) + this._renderingBoundsExpanded.set( + viewportPageBounds.clone().expandBy(this.renderingBoundsMargin / this.zoomLevel) + ) return this } + renderingBoundsMargin = 100 + /* --------------------- Pages ---------------------- */ /** @internal */ @@ -3182,25 +3297,6 @@ export class Editor extends EventEmitter { return this._pages.value.sort(sortByIndex) } - /** - * The current page. - * - * @public - */ - get currentPage(): TLPage { - const page = this.getPage(this.currentPageId)! - return page - } - - /** - * The current page id. - * - * @public - */ - get currentPageId(): TLPageId { - return this.instanceState.currentPageId - } - /** * Get a page. * @@ -3217,120 +3313,6 @@ export class Editor extends EventEmitter { return this.store.get(typeof id === 'string' ? id : id.id) } - /** - * A cache of shape ids in the current page. - * - * @internal - */ - private readonly _shapeIdsOnCurrentPage: ReturnType - - /** - * An array of all of the shapes on the current page. - * - * @public - */ - get shapeIdsOnCurrentPage() { - return this._shapeIdsOnCurrentPage.value - } - - /** - * Get the ids of shapes on a page. - * - * @example - * ```ts - * const idsOnPage1 = editor.getShapeIdsInPage('page1') - * const idsOnPage2 = editor.getShapeIdsInPage('page2') - * ``` - * - * @param pageId - The id of the page. - * - * @public - **/ - getShapeIdsInPage(page: TLPage): Set - getShapeIdsInPage(pageId: TLPageId): Set - getShapeIdsInPage(arg: TLPageId | TLPage): Set { - const pageId = typeof arg === 'string' ? arg : arg.id - const result = this.store.query.exec('shape', { parentId: { eq: pageId } }) - return this.getShapeAndDescendantIds(result.map((s) => s.id)) - } - - /** - * Set the current page. - * - * @example - * ```ts - * editor.setCurrentPage('page1') - * ``` - * - * @param pageId - The id of the page to set as the current page. - * @param options - Options for setting the current page. - * - * @public - */ - setCurrentPage(page: TLPage, opts?: TLViewportOptions): this - setCurrentPage(pageId: TLPageId, opts?: TLViewportOptions): this - setCurrentPage(arg: TLPageId | TLPage, { stopFollowing = true }: TLViewportOptions = {}): this { - const pageId = typeof arg === 'string' ? arg : arg.id - this._setCurrentPageId(pageId, { stopFollowing }) - return this - } - /** @internal */ - private _setCurrentPageId = this.history.createCommand( - 'setCurrentPage', - (pageId: TLPageId, { stopFollowing = true }: TLViewportOptions = {}) => { - if (!this.store.has(pageId)) { - console.error("Tried to set the current page id to a page that doesn't exist.") - return - } - - if (stopFollowing && this.instanceState.followingUserId) { - this.stopFollowingUser() - } - - return { - data: { toId: pageId, fromId: this.currentPageId }, - squashing: true, - preservesRedoStack: true, - } - }, - { - do: ({ toId }) => { - if (!this.store.has(toId)) { - // in multiplayer contexts this page might have been deleted - return - } - if (!this.pageStates.find((p) => p.pageId === toId)) { - const camera = CameraRecordType.create({ - id: CameraRecordType.createId(toId), - }) - this.store.put([ - camera, - InstancePageStateRecordType.create({ - id: InstancePageStateRecordType.createId(toId), - pageId: toId, - }), - ]) - } - - this.store.put([{ ...this.instanceState, currentPageId: toId }]) - - this.updateRenderingBounds() - }, - undo: ({ fromId }) => { - if (!this.store.has(fromId)) { - // in multiplayer contexts this page might have been deleted - return - } - this.store.put([{ ...this.instanceState, currentPageId: fromId }]) - - this.updateRenderingBounds() - }, - squash: ({ fromId }, { toId }) => { - return { toId, fromId } - }, - } - ) - /** * Update a page. * @@ -3344,36 +3326,9 @@ export class Editor extends EventEmitter { * @public */ updatePage(partial: RequiredKeys, squashing = false): this { - this._updatePage(partial, squashing) + this.updateRecords([partial], { squashing }) return this } - /** @internal */ - private _updatePage = this.history.createCommand( - 'updatePage', - (partial: RequiredKeys, squashing = false) => { - if (this.instanceState.isReadonly) return null - - const prev = this.getPage(partial.id) - - if (!prev) return null - - return { data: { prev, partial }, squashing } - }, - { - do: ({ partial }) => { - this.store.update(partial.id, (page) => ({ ...page, ...partial })) - }, - undo: ({ prev, partial }) => { - this.store.update(partial.id, () => prev) - }, - squash(prevData, nextData) { - return { - prev: { ...prevData.prev, ...nextData.prev }, - partial: nextData.partial, - } - }, - } - ) /** * Create a page. @@ -3394,74 +3349,44 @@ export class Editor extends EventEmitter { id: TLPageId = PageRecordType.createId(), belowPageIndex?: string ): this { - this._createPage(title, id, belowPageIndex) + if (this.instanceState.isReadonly) return this + if (this.pages.length >= MAX_PAGES) return this + + const pageInfo = this.pages + const topIndex = belowPageIndex ?? pageInfo[pageInfo.length - 1]?.index ?? 'a1' + const bottomIndex = pageInfo[pageInfo.findIndex((p) => p.index === topIndex) + 1]?.index + + title = getIncrementedName( + title, + pageInfo.map((p) => p.name) + ) + + const newPage = PageRecordType.create({ + id, + name: title, + index: + bottomIndex && topIndex !== bottomIndex + ? getIndexBetween(topIndex, bottomIndex) + : getIndexAbove(topIndex), + meta: {}, + }) + + const newCamera = CameraRecordType.create({ + id: CameraRecordType.createId(newPage.id), + }) + + const newPageState = InstancePageStateRecordType.create({ + id: InstancePageStateRecordType.createId(newPage.id), + pageId: newPage.id, + }) + + this.batch(() => { + this.createRecords([newPage, newCamera, newPageState]) + this.updateRecords([{ ...this.instanceState, currentPageId: newPage.id }]) + }) + return this } - /** @internal */ - private _createPage = this.history.createCommand( - 'createPage', - (title: string, id: TLPageId = PageRecordType.createId(), belowPageIndex?: string) => { - if (this.instanceState.isReadonly) return null - if (this.pages.length >= MAX_PAGES) return null - const pageInfo = this.pages - const topIndex = belowPageIndex ?? pageInfo[pageInfo.length - 1]?.index ?? 'a1' - const bottomIndex = pageInfo[pageInfo.findIndex((p) => p.index === topIndex) + 1]?.index - - title = getIncrementedName( - title, - pageInfo.map((p) => p.name) - ) - - const newPage = PageRecordType.create({ - id, - name: title, - index: - bottomIndex && topIndex !== bottomIndex - ? getIndexBetween(topIndex, bottomIndex) - : getIndexAbove(topIndex), - meta: {}, - }) - - const newCamera = CameraRecordType.create({ - id: CameraRecordType.createId(newPage.id), - }) - - const newTabPageState = InstancePageStateRecordType.create({ - id: InstancePageStateRecordType.createId(newPage.id), - pageId: newPage.id, - }) - - return { - data: { - prevSelectedPageId: this.currentPageId, - newPage, - newTabPageState, - newCamera, - }, - } - }, - { - do: ({ newPage, newTabPageState, newCamera }) => { - this.store.put([ - newPage, - newCamera, - newTabPageState, - { ...this.instanceState, currentPageId: newPage.id }, - ]) - this.updateRenderingBounds() - }, - undo: ({ newPage, prevSelectedPageId, newTabPageState, newCamera }) => { - if (this.pages.length === 1) return - this.store.remove([newTabPageState.id, newPage.id, newCamera.id]) - - if (this.store.has(prevSelectedPageId) && this.currentPageId !== prevSelectedPageId) { - this.store.put([{ ...this.instanceState, currentPageId: prevSelectedPageId }]) - } - - this.updateRenderingBounds() - }, - } - ) /** * Delete a page. @@ -3476,55 +3401,33 @@ export class Editor extends EventEmitter { * @public */ deletePage(page: TLPage): this - deletePage(id: TLPageId): this + deletePage(pageId: TLPageId): this deletePage(arg: TLPageId | TLPage): this { - const id = typeof arg === 'string' ? arg : arg.id - this._deletePage(id) - return this - } - /** @internal */ - private _deletePage = this.history.createCommand( - 'delete_page', - (id: TLPageId) => { - if (this.instanceState.isReadonly) return null - const { pages } = this - if (pages.length === 1) return null + const { pages } = this + if (this.instanceState.isReadonly) return this + if (this.pages.length === 1) return this - const deletedPage = this.getPage(id) - const deletedPageStates = this.pageStates.filter((s) => s.pageId === id) + const pageId = typeof arg === 'string' ? arg : arg.id + const deletedPage = this.getPage(pageId) + if (!deletedPage) return this - if (!deletedPage) return null - - if (id === this.currentPageId) { - const index = pages.findIndex((page) => page.id === id) + this.batch(() => { + if (pageId === this.currentPageId) { + const index = pages.findIndex((page) => page.id === pageId) const next = pages[index - 1] ?? pages[index + 1] this.setCurrentPage(next.id) } - return { data: { id, deletedPage, deletedPageStates } } - }, - { - do: ({ deletedPage, deletedPageStates }) => { - const { pages } = this - if (pages.length === 1) return + this.deleteRecords([pageId]) - if (deletedPage.id === this.currentPageId) { - const index = pages.findIndex((page) => page.id === deletedPage.id) - const next = pages[index - 1] ?? pages[index + 1] - this.setCurrentPage(next.id) - } + const deletedPageState = this.pageStates.find((s) => s.pageId === pageId) + if (deletedPageState) { + this.deleteRecords([deletedPageState]) + } + }) - this.store.remove(deletedPageStates.map((s) => s.id)) // remove the page state - this.store.remove([deletedPage.id]) // remove the page - this.updateRenderingBounds() - }, - undo: ({ deletedPage, deletedPageStates }) => { - this.store.put([deletedPage]) - this.store.put(deletedPageStates) - this.updateRenderingBounds() - }, - } - ) + return this + } /** * Duplicate a page. @@ -3548,7 +3451,7 @@ export class Editor extends EventEmitter { this.batch(() => { this.createPage(page.name + ' Copy', createId, page.index) this.setCurrentPage(createId) - this.setCamera(camera.x, camera.y, camera.z) + this.setCamera(camera) // will change page automatically if (content) { @@ -3559,32 +3462,114 @@ export class Editor extends EventEmitter { return this } + /* ------------------ Current Page ------------------ */ + /** - * Rename a page. - * - * @example - * ```ts - * editor.renamePage('page1', 'My Page') - * ``` - * - * @param id - The id of the page to rename. - * @param name - The new name. + * The current page id. * * @public */ - renamePage(page: TLPage, name: string, squashing?: boolean): this - renamePage(id: TLPageId, name: string, squashing?: boolean): this - renamePage(arg: TLPageId | TLPage, name: string, squashing = false) { - const id = typeof arg === 'string' ? arg : arg.id - if (this.instanceState.isReadonly) return this - this.updatePage({ id, name }, squashing) + get currentPageId(): TLPageId { + return this.instanceState.currentPageId + } + + /** + * The current page. + * + * @public + */ + get currentPage(): TLPage { + const page = this.getPage(this.currentPageId)! + return page + } + + /** + * Set the current page. + * + * @example + * ```ts + * editor.setCurrentPage('page1') + * ``` + * + * @param pageId - The id of the page to set as the current page. + * @param options - Options for setting the current page. + * + * @public + */ + setCurrentPage(page: TLPage): this + setCurrentPage(pageId: TLPageId): this + setCurrentPage(arg: TLPageId | TLPage): this { + const pageId = typeof arg === 'string' ? arg : arg.id + if (pageId === this.currentPageId) return this + + if (!this.store.has(pageId)) { + console.error("Tried to set the current page id to a page that doesn't exist.") + return this + } + + this.batch(() => { + // If we don't have page states, create them + // if (!this.pageStates.find((p) => p.pageId === pageId)) { + // this.createRecords([ + // CameraRecordType.create({ + // id: CameraRecordType.createId(pageId), + // }), + // InstancePageStateRecordType.create({ + // id: InstancePageStateRecordType.createId(pageId), + // pageId, + // }), + // ]) + // } + + this.updateRecords([{ ...this.instanceState, currentPageId: pageId }], { + preservesRedoStack: true, + }) + }) + return this } + /** + * Get the ids of shapes on a page. + * + * @example + * ```ts + * const idsOnPage1 = editor.getPageShapeIds('page1') + * const idsOnPage2 = editor.getPageShapeIds('page2') + * ``` + * + * @param pageId - The id of the page. + * + * @public + **/ + getPageShapeIds(page: TLPage): Set + getPageShapeIds(pageId: TLPageId): Set + getPageShapeIds(arg: TLPageId | TLPage): Set { + const pageId = typeof arg === 'string' ? arg : arg.id + const result = this.store.query.exec('shape', { parentId: { eq: pageId } }) + return this.getShapeAndDescendantIds(result.map((s) => s.id)) + } + + /** + * A cache of shape ids in the current page. + * + * @internal + */ + private readonly _currentPageShapeIds: ReturnType + + /** + * An array of all of the shapes on the current page. + * + * @public + */ + get currentPageShapeIds() { + return this._currentPageShapeIds.value + } + /* --------------------- Assets --------------------- */ /** @internal */ - @computed private get _assets() { + private get _assets() { return this.store.query.records('asset') } @@ -3593,10 +3578,46 @@ export class Editor extends EventEmitter { * * @public */ - get assets() { + @computed get assets() { return this._assets.value } + /** + * Get an asset. + * + * @example + * ```ts + * editor.getAsset('asset1') + * editor.getAsset(myAsset) + * ``` + * + * @param asset - The asset (or asset id) to get. + * + * @public + */ + getAsset(asset: TLAsset): TLAsset | undefined + getAsset(id: TLAssetId): TLAsset | undefined + getAsset(id: TLAssetId | TLAsset): TLAsset | undefined { + return this.store.get(typeof id === 'string' ? id : id.id) as TLAsset | undefined + } + + /** + * Create an assets. + * + * @example + * ```ts + * editor.createAsset(myAsset) + * ``` + * + * @param asset - The asset to create. + * + * @public + */ + createAsset(asset: TLAsset) { + this.createAssets([asset]) + return this + } + /** * Create one or more assets. * @@ -3609,29 +3630,10 @@ export class Editor extends EventEmitter { * * @public */ - createAssets(assets: TLAsset[]) { - this._createAssets(assets) + createAssets(assets: TLAsset[], opts?: { ephemeral?: boolean; squashing?: boolean }) { + this.createRecords(assets, opts) return this } - /** @internal */ - private _createAssets = this.history.createCommand( - 'createAssets', - (assets: TLAsset[]) => { - if (this.instanceState.isReadonly) return null - if (assets.length <= 0) return null - - return { data: { assets } } - }, - { - do: ({ assets }) => { - this.store.put(assets) - }, - undo: ({ assets }) => { - // todo: should we actually remove assets here? or on cleanup elsewhere? - this.store.remove(assets.map((a) => a.id)) - }, - } - ) /** * Update one or more assets. @@ -3645,40 +3647,53 @@ export class Editor extends EventEmitter { * * @public */ - updateAssets(assets: TLAssetPartial[]) { - this._updateAssets(assets) + updateAsset(partial: TLAssetPartial, opts?: { ephemeral?: boolean; squashing?: boolean }): this { + this.updateAssets([partial], opts) return this } - /** @internal */ - private _updateAssets = this.history.createCommand( - 'updateAssets', - (assets: TLAssetPartial[]) => { - if (this.instanceState.isReadonly) return - if (assets.length <= 0) return - const snapshots: Record = {} + /** + * Update one or more assets. + * + * @example + * ```ts + * editor.updateAssets([{ id: 'asset1', name: 'New name' }]) + * ``` + * + * @param assets - The assets to update. + * + * @public + */ + updateAssets( + partials: TLAssetPartial[], + opts?: { ephemeral?: boolean; squashing?: boolean } + ): this { + if (this.instanceState.isReadonly) return this + if (partials.length === 0) return this + this.updateRecords(compact(partials.map((partial) => this.getAsset(partial.id))), opts) + return this + } - return { data: { snapshots, assets } } - }, - { - do: ({ assets, snapshots }) => { - this.store.put( - assets.map((a) => { - const asset = this.store.get(a.id)! - snapshots[a.id] = asset - - return { - ...asset, - ...a, - } - }) - ) - }, - undo: ({ snapshots }) => { - this.store.put(Object.values(snapshots)) - }, - } - ) + /** + * Delete an asset. + * + * @example + * ```ts + * editor.deleteAsset('asset1') + * ``` + * + * @param id - The asset to delete. + * + * @public + */ + deleteAsset(assets: TLAsset): this + deleteAsset(ids: TLAssetId): this + deleteAsset(arg: TLAssetId | TLAsset) { + if (this.instanceState.isReadonly) return + const id = typeof arg === 'string' ? arg : arg.id + this.deleteAssets([id]) + return this + } /** * Delete one or more assets. @@ -3695,49 +3710,12 @@ export class Editor extends EventEmitter { deleteAssets(assets: TLAsset[]): this deleteAssets(ids: TLAssetId[]): this deleteAssets(arg: TLAssetId[] | TLAsset[]) { + if (this.instanceState.isReadonly) return const ids = typeof arg[0] === 'string' ? (arg as TLAssetId[]) : (arg as TLAsset[]).map((a) => a.id) - this._deleteAssets(ids) + this.deleteRecords(ids) return this } - /** @internal */ - private _deleteAssets = this.history.createCommand( - 'deleteAssets', - (ids: TLAssetId[]) => { - if (this.instanceState.isReadonly) return - if (ids.length <= 0) return - - const prev = compact(ids.map((id) => this.store.get(id))) - - return { data: { ids, prev } } - }, - { - do: ({ ids }) => { - this.store.remove(ids) - }, - undo: ({ prev }) => { - this.store.put(prev) - }, - } - ) - - /** - * Get an asset by its id. - * - * @example - * ```ts - * editor.getAsset('asset1') - * ``` - * - * @param asset - The asset (or asset id) to get. - * - * @public - */ - getAsset(asset: TLAsset): TLAsset | undefined - getAsset(id: TLAssetId): TLAsset | undefined - getAsset(id: TLAssetId | TLAsset): TLAsset | undefined { - return this.store.get(typeof id === 'string' ? id : id.id) as TLAsset | undefined - } /* --------------------- Shapes --------------------- */ @@ -4221,7 +4199,7 @@ export class Editor extends EventEmitter { @computed get commonBoundsOfAllShapesOnCurrentPage(): Box2d | undefined { let commonBounds: Box2d | undefined - this.shapeIdsOnCurrentPage.forEach((shapeId) => { + this.currentPageShapeIds.forEach((shapeId) => { const bounds = this.getMaskedPageBounds(shapeId) if (!bounds) return if (!commonBounds) { @@ -4243,7 +4221,7 @@ export class Editor extends EventEmitter { */ getSelectedShapeAtPoint(point: VecLike): TLShape | undefined { const { selectedShapeIds } = this - return this.sortedShapesOnCurrentPage + return this.currentPageShapesSorted .filter((shape) => shape.type !== 'group' && selectedShapeIds.includes(shape.id)) .findLast((shape) => this.isPointInShape(shape, point, { hitInside: true, margin: 0 })) } @@ -4267,7 +4245,7 @@ export class Editor extends EventEmitter { } ): TLShape | undefined { // are we inside of a shape but not hovering it? - const { viewportPageBounds, zoomLevel, sortedShapesOnCurrentPage } = this + const { viewportPageBounds, zoomLevel, currentPageShapesSorted } = this const { filter, margin = 0, hitInside = false, hitFrameInside = false } = opts let inHollowSmallestArea = Infinity @@ -4276,7 +4254,7 @@ export class Editor extends EventEmitter { let inMarginClosestToEdgeDistance = Infinity let inMarginClosestToEdgeHit: TLShape | null = null - const shapesToCheck = sortedShapesOnCurrentPage.filter((shape) => { + const shapesToCheck = currentPageShapesSorted.filter((shape) => { if (this.isShapeOfType(shape, 'group')) return false const pageMask = this.getPageMask(shape) if (pageMask && !pointInPolygon(point, pageMask)) return false @@ -4390,7 +4368,7 @@ export class Editor extends EventEmitter { point: VecLike, opts = {} as { margin?: number; hitInside?: boolean } ): TLShape[] { - return this.shapesOnCurrentPage.filter((shape) => this.isPointInShape(shape, point, opts)) + return this.currentPageShapes.filter((shape) => this.isPointInShape(shape, point, opts)) } /** @@ -4494,15 +4472,15 @@ export class Editor extends EventEmitter { * * @example * ```ts - * editor.shapesOnCurrentPage + * editor.currentPageShapes * ``` * * @readonly * * @public */ - @computed get shapesOnCurrentPage() { - return Array.from(this.shapeIdsOnCurrentPage, (id) => this.store.get(id)! as TLShape) + @computed get currentPageShapes() { + return Array.from(this.currentPageShapeIds, (id) => this.store.get(id)! as TLShape) } /** @@ -4511,17 +4489,17 @@ export class Editor extends EventEmitter { * * @example * ```ts - * editor.sortedShapesOnCurrentPage + * editor.currentPageShapesSorted * ``` * * @readonly * * @public */ - @computed get sortedShapesOnCurrentPage(): TLShape[] { + @computed get currentPageShapesSorted(): TLShape[] { // todo: consider making into a function call that includes options for selected-only, rendering, etc. // todo: consider making a derivation or something, or merging with rendering shapes - const shapes = new Set(this.shapesOnCurrentPage.sort(sortByIndex)) + const shapes = new Set(this.currentPageShapes.sort(sortByIndex)) const results: TLShape[] = [] @@ -4721,11 +4699,25 @@ export class Editor extends EventEmitter { * * @public */ - reparentShapes(shapes: TLShape[], parentId: TLParentId, insertIndex?: string): this - reparentShapes(ids: TLShapeId[], parentId: TLParentId, insertIndex?: string): this - reparentShapes(_ids: TLShapeId[] | TLShape[], parentId: TLParentId, insertIndex?: string) { + reparentShapes( + shapes: TLShape[], + parentId: TLParentId, + opts?: { insertIndex?: string } & CommandHistoryOptions + ): this + reparentShapes( + ids: TLShapeId[], + parentId: TLParentId, + opts?: { insertIndex?: string } & CommandHistoryOptions + ): this + reparentShapes( + _ids: TLShapeId[] | TLShape[], + parentId: TLParentId, + opts?: { insertIndex?: string } & CommandHistoryOptions + ) { const ids = typeof _ids[0] === 'string' ? (_ids as TLShapeId[]) : _ids.map((s) => (s as TLShape).id) + if (ids.length === 0) return this + const changes: TLShapePartial[] = [] const parentTransform = isPageId(parentId) @@ -4738,7 +4730,8 @@ export class Editor extends EventEmitter { const sibs = compact(this.getSortedChildIdsForParent(parentId).map((id) => this.getShape(id))) - if (insertIndex) { + if (opts && opts.insertIndex) { + const { insertIndex } = opts const sibWithInsertIndex = sibs.find((s) => s.index === insertIndex) if (sibWithInsertIndex) { // If there's a sibling with the same index as the insert index... @@ -4746,7 +4739,7 @@ export class Editor extends EventEmitter { if (sibAbove) { // If the sibling has a sibling above it, insert the shapes // between the sibling and its sibling above it. - indices = getIndicesBetween(insertIndex, sibAbove.index, ids.length) + indices = getIndicesBetween(opts.insertIndex, sibAbove.index, ids.length) } else { // Or if the sibling is the top sibling, insert the shapes // above the sibling @@ -4799,7 +4792,7 @@ export class Editor extends EventEmitter { }) } - this.updateShapes(changes) + this.updateShapes(changes, opts) return this } @@ -4920,7 +4913,7 @@ export class Editor extends EventEmitter { */ getDroppingOverShape(point: VecLike, droppingShapes: TLShape[] = []) { // starting from the top... - return this.sortedShapesOnCurrentPage.findLast((shape) => { + return this.currentPageShapesSorted.findLast((shape) => { if ( // only allow shapes that can receive children !this.getShapeUtil(shape).canDropShapes(shape, droppingShapes) || @@ -5065,7 +5058,7 @@ export class Editor extends EventEmitter { ) } - this.updateShapes(changes, ephemeral) + this.updateShapes(changes, { ephemeral }) return this } @@ -5230,14 +5223,14 @@ export class Editor extends EventEmitter { this.history.batch(() => { const maxShapesReached = - shapesToCreate.length + this.shapeIdsOnCurrentPage.size > MAX_SHAPES_PER_PAGE + shapesToCreate.length + this.currentPageShapeIds.size > MAX_SHAPES_PER_PAGE if (maxShapesReached) { alertMaxShapes(this) } const newShapes = maxShapesReached - ? shapesToCreate.slice(0, MAX_SHAPES_PER_PAGE - this.shapeIdsOnCurrentPage.size) + ? shapesToCreate.slice(0, MAX_SHAPES_PER_PAGE - this.currentPageShapeIds.size) : shapesToCreate const ids = newShapes.map((s) => s.id) @@ -5251,9 +5244,7 @@ export class Editor extends EventEmitter { // new shapes. const { viewportPageBounds, selectionPageBounds: selectionPageBounds } = this if (selectionPageBounds && !viewportPageBounds.contains(selectionPageBounds)) { - this.centerOnPoint(selectionPageBounds.center.x, selectionPageBounds.center.y, { - duration: ANIMATION_MEDIUM_MS, - }) + this.centerOnPoint(selectionPageBounds.center, { duration: ANIMATION_MEDIUM_MS }) } } }) @@ -5296,7 +5287,7 @@ export class Editor extends EventEmitter { // If there is no space on pageId, or if the selected shapes // would take the new page above the limit, don't move the shapes - if (this.getShapeIdsInPage(pageId).size + content.shapes.length > MAX_SHAPES_PER_PAGE) { + if (this.getPageShapeIds(pageId).size + content.shapes.length > MAX_SHAPES_PER_PAGE) { alertMaxShapes(this, pageId) return this } @@ -5320,11 +5311,9 @@ export class Editor extends EventEmitter { // Force the new page's camera to be at the same zoom level as the // "from" page's camera, then center the "to" page's camera on the // pasted shapes - const { - center: { x, y }, - } = this.selectionBounds! - this.setCamera(this.camera.x, this.camera.y, fromPageZ) - this.centerOnPoint(x, y) + const { center } = this.selectionBounds! + this.setCamera({ ...this.camera, z: fromPageZ }) + this.centerOnPoint(center) }) return this @@ -5359,16 +5348,23 @@ export class Editor extends EventEmitter { } } } - if (allUnlocked) { - this.updateShapes(shapes.map((shape) => ({ id: shape.id, type: shape.type, isLocked: true }))) - this.setSelectedShapeIds([]) - } else if (allLocked) { - this.updateShapes( - shapes.map((shape) => ({ id: shape.id, type: shape.type, isLocked: false })) - ) - } else { - this.updateShapes(shapes.map((shape) => ({ id: shape.id, type: shape.type, isLocked: true }))) - } + + this.batch(() => { + if (allUnlocked) { + this.updateShapes( + shapes.map((shape) => ({ id: shape.id, type: shape.type, isLocked: true })) + ) + this.setSelectedShapeIds([]) + } else if (allLocked) { + this.updateShapes( + shapes.map((shape) => ({ id: shape.id, type: shape.type, isLocked: false })) + ) + } else { + this.updateShapes( + shapes.map((shape) => ({ id: shape.id, type: shape.type, isLocked: true })) + ) + } + }) return this } @@ -6061,7 +6057,10 @@ export class Editor extends EventEmitter { if (parentTransform) localOffset.rot(-parentTransform.rotation()) const { x, y } = Vec2d.Add(localOffset, shape) - this.updateShapes([{ id: shape.id, type: shape.type, x, y }], true) + this.updateShapes([{ id: shape.id, type: shape.type, x, y }], { + squashing: true, + ephemeral: false, + }) const scale = new Vec2d(1, commonBounds.height / pageBounds.height) this.resizeShape(shape.id, scale, { initialBounds: bounds, @@ -6084,7 +6083,10 @@ export class Editor extends EventEmitter { if (parentTransform) localOffset.rot(-parentTransform.rotation()) const { x, y } = Vec2d.Add(localOffset, shape) - this.updateShapes([{ id: shape.id, type: shape.type, x, y }], true) + this.updateShapes([{ id: shape.id, type: shape.type, x, y }], { + squashing: true, + ephemeral: false, + }) const scale = new Vec2d(commonBounds.width / pageBounds.width, 1) this.resizeShape(shape.id, scale, { initialBounds: bounds, @@ -6227,7 +6229,7 @@ export class Editor extends EventEmitter { ), }, ], - true + { ephemeral: false, squashing: true } ) } else { const initialPageCenter = Matrix2d.applyToPoint(pageTransform, initialBounds.center) @@ -6256,7 +6258,7 @@ export class Editor extends EventEmitter { y: initialShape.y + delta.y, }, ], - true + { ephemeral: false, squashing: true } ) } @@ -6302,7 +6304,7 @@ export class Editor extends EventEmitter { if (Math.sign(scale.x) * Math.sign(scale.y) < 0) { let { rotation } = Matrix2d.Decompose(options.initialPageTransform) rotation -= 2 * rotation - this.updateShapes([{ id, type, rotation }], true) + this.updateShapes([{ id, type, rotation }], { squashing: true }) } // Next we need to translate the shape so that it's center point ends up in the right place. @@ -6332,7 +6334,7 @@ export class Editor extends EventEmitter { const postScaleShapePagePoint = Vec2d.Add(shapePageTransformOrigin, pageDelta) const { x, y } = this.getPointInParentSpace(id, postScaleShapePagePoint) - this.updateShapes([{ id, type, x, y }], true) + this.updateShapes([{ id, type, x, y }], { squashing: true }) return this } @@ -6391,8 +6393,8 @@ export class Editor extends EventEmitter { * * @public */ - createShape(partial: TLShapePartial, select = false) { - this._createShapes([partial], select) + createShape(partial: OptionalKeys, 'id'>) { + this.createShapes([partial]) return this } @@ -6409,212 +6411,159 @@ export class Editor extends EventEmitter { * * @public */ - createShapes(partials: TLShapePartial[], select = false) { + createShapes(partials: OptionalKeys, 'id'>[]): this { if (!Array.isArray(partials)) { throw Error('Editor.createShapes: must provide an array of shapes or shape partials') } - this._createShapes(partials, select) + if (this.instanceState.isReadonly) return this + if (partials.length <= 0) return this + + // can't create more shapes than fit on the page + if (partials.length + this.currentPageShapeIds.size > MAX_SHAPES_PER_PAGE) { + alertMaxShapes(this) + return this + } + + const { focusedGroupId, currentPageShapesSorted } = this + + // 1. Parents + + // Make sure that each partial will become the child of either the + // page or another shape that exists (or that will exist) in this page. + + const shapesToCreate: TLShapePartial[] = partials.map((partial) => { + const id = partial.id ?? createShapeId() + ;(partial as any).id = id + + // If the partial does not provide the parentId OR if the provided + // parentId is NOT in the store AND NOT among the other shapes being + // created, then we need to find a parent for the shape. This can be + // another shape that exists under that point and which can receive + // children of the creating shape's type, or else the page itself. + if ( + !partial.parentId || + !(this.store.has(partial.parentId) || partials.some((p) => p.id === partial.parentId)) + ) { + partial = { ...partial } + + const parentId = + currentPageShapesSorted.findLast( + (parent) => + // parent.type === 'frame' + this.getShapeUtil(parent).canReceiveNewChildrenOfType(parent, partial.type) && + this.isPointInShape( + parent, + // If no parent is provided, then we can treat the + // shape's provided x/y as being in the page's space. + { x: partial.x ?? 0, y: partial.y ?? 0 }, + { + margin: 0, + hitInside: true, + } + ) + )?.id ?? this.focusedGroupId + + partial.parentId = parentId + + // If the parent is a shape (rather than a page) then insert the + // shapes into the shape's children. Adjust the point and page rotation to be + // preserved relative to the parent. + if (isShapeId(parentId)) { + const point = this.getPointInShapeSpace(this.getShape(parentId)!, { + x: partial.x ?? 0, + y: partial.y ?? 0, + }) + partial.x = point.x + partial.y = point.y + partial.rotation = -this.getPageTransform(parentId)!.rotation() + (partial.rotation ?? 0) + } + + // a shape cannot be it's own parent. This was a rare issue with frames/groups in the syncFuzz tests. + if (partial.parentId === partial.id) { + partial.parentId = focusedGroupId + } + } + + return partial as TLShapePartial + }) + + // 2. Indices + + // Get the highest index among the parents of each of the + // the shapes being created; we'll increment from there. + + const parentIndices = new Map() + + const shapeRecordsToCreate: TLShape[] = [] + + for (const partial of shapesToCreate) { + const util = this.getShapeUtil(partial) + + // If an index is not explicitly provided, then add the + // shapes to the top of their parents' children; using the + // value in parentsMappedToIndex, get the index above, use it, + // and set it back to parentsMappedToIndex for next time. + let index = partial.index + + if (!index) { + // Hello bug-seeker: have you just created a frame and then a shape + // and found that the shape is automatically the child of the frame? + // this is the reason why! It would be harder to have each shape specify + // the frame as the parent when creating a shape inside of a frame, so + // we do it here. + const parentId = partial.parentId ?? focusedGroupId + + if (!parentIndices.has(parentId)) { + parentIndices.set(parentId, this.getHighestIndexForParent(parentId)) + } + index = parentIndices.get(parentId)! + parentIndices.set(parentId, getIndexAbove(index)) + } + + // The initial props starts as the shape utility's default props + const initialProps = util.getDefaultProps() + + // We then look up each key in the tab state's styles; and if it's there, + // we use the value from the tab state's styles instead of the default. + for (const [style, propKey] of this.styleProps[partial.type]) { + ;(initialProps as any)[propKey] = this.getStyleForNextShape(style) + } + + // When we create the shape, take in the partial (the props coming into the + // function) and merge it with the default props. + const shapeRecordToCreate = ( + this.store.schema.types.shape as RecordType< + TLShape, + 'type' | 'props' | 'index' | 'parentId' + > + ).create({ + ...partial, + index, + opacity: partial.opacity ?? this.instanceState.opacityForNextShape, + parentId: partial.parentId ?? focusedGroupId, + props: 'props' in partial ? { ...initialProps, ...partial.props } : initialProps, + }) + + if (shapeRecordToCreate.index === undefined) { + throw Error('no index!') + } + + shapeRecordsToCreate.push(shapeRecordToCreate) + } + + // Add meta properties, if any, to the shapes + shapeRecordsToCreate.forEach((shape) => { + shape.meta = { + ...this.getInitialMetaForShape(shape), + ...shape.meta, + } + }) + + this.createRecords(shapeRecordsToCreate) + return this } - /** @internal */ - private _createShapes = this.history.createCommand( - 'createShapes', - (partials: TLShapePartial[], select = false) => { - if (this.instanceState.isReadonly) return null - if (partials.length <= 0) return null - - const { shapeIdsOnCurrentPage: shapeIds } = this - - const maxShapesReached = partials.length + shapeIds.size > MAX_SHAPES_PER_PAGE - - if (maxShapesReached) { - // can't create more shapes than fit on the page - alertMaxShapes(this) - return - } - - if (partials.length === 0) return null - - const prevSelectedShapeIds = select ? this.selectedShapeIds : undefined - - return { - data: { - currentPageId: this.currentPageId, - createdIds: partials.map((p) => p.id), - prevSelectedShapeIds, - partials, - select, - }, - } - }, - { - do: ({ createdIds, partials, select }) => { - const { focusedGroupId } = this - - // 1. Parents - - // Make sure that each partial will become the child of either the - // page or another shape that exists (or that will exist) in this page. - - const { sortedShapesOnCurrentPage } = this - partials = partials.map((partial) => { - // If the partial does not provide the parentId OR if the provided - // parentId is NOT in the store AND NOT among the other shapes being - // created, then we need to find a parent for the shape. This can be - // another shape that exists under that point and which can receive - // children of the creating shape's type, or else the page itself. - if ( - !partial.parentId || - !(this.store.has(partial.parentId) || partials.some((p) => p.id === partial.parentId)) - ) { - partial = { ...partial } - - const parentId = - sortedShapesOnCurrentPage.findLast( - (parent) => - // parent.type === 'frame' - this.getShapeUtil(parent).canReceiveNewChildrenOfType(parent, partial.type) && - this.isPointInShape( - parent, - // If no parent is provided, then we can treat the - // shape's provided x/y as being in the page's space. - { x: partial.x ?? 0, y: partial.y ?? 0 }, - { - margin: 0, - hitInside: true, - } - ) - )?.id ?? this.focusedGroupId - - partial.parentId = parentId - - // If the parent is a shape (rather than a page) then insert the - // shapes into the shape's children. Adjust the point and page rotation to be - // preserved relative to the parent. - if (isShapeId(parentId)) { - const point = this.getPointInShapeSpace(this.getShape(parentId)!, { - x: partial.x ?? 0, - y: partial.y ?? 0, - }) - partial.x = point.x - partial.y = point.y - partial.rotation = - -this.getPageTransform(parentId)!.rotation() + (partial.rotation ?? 0) - } - - // a shape cannot be it's own parent. This was a rare issue with frames/groups in the syncFuzz tests. - if (partial.parentId === partial.id) { - partial.parentId = focusedGroupId - } - } - - return partial - }) - - // 2. Indices - - // Get the highest index among the parents of each of the - // the shapes being created; we'll increment from there. - - const parentIndices = new Map() - - const shapeRecordsToCreate: TLShape[] = [] - - for (const partial of partials) { - const util = this.getShapeUtil(partial) - - // If an index is not explicitly provided, then add the - // shapes to the top of their parents' children; using the - // value in parentsMappedToIndex, get the index above, use it, - // and set it back to parentsMappedToIndex for next time. - let index = partial.index - - if (!index) { - // Hello bug-seeker: have you just created a frame and then a shape - // and found that the shape is automatically the child of the frame? - // this is the reason why! It would be harder to have each shape specify - // the frame as the parent when creating a shape inside of a frame, so - // we do it here. - const parentId = partial.parentId ?? focusedGroupId - - if (!parentIndices.has(parentId)) { - parentIndices.set(parentId, this.getHighestIndexForParent(parentId)) - } - index = parentIndices.get(parentId)! - parentIndices.set(parentId, getIndexAbove(index)) - } - - // The initial props starts as the shape utility's default props - const initialProps = util.getDefaultProps() - - // We then look up each key in the tab state's styles; and if it's there, - // we use the value from the tab state's styles instead of the default. - for (const [style, propKey] of this.styleProps[partial.type]) { - ;(initialProps as any)[propKey] = this.getStyleForNextShape(style) - } - - // When we create the shape, take in the partial (the props coming into the - // function) and merge it with the default props. - let shapeRecordToCreate = ( - this.store.schema.types.shape as RecordType< - TLShape, - 'type' | 'props' | 'index' | 'parentId' - > - ).create({ - ...partial, - index, - opacity: partial.opacity ?? this.instanceState.opacityForNextShape, - parentId: partial.parentId ?? focusedGroupId, - props: 'props' in partial ? { ...initialProps, ...partial.props } : initialProps, - }) - - if (shapeRecordToCreate.index === undefined) { - throw Error('no index!') - } - - const next = this.getShapeUtil(shapeRecordToCreate).onBeforeCreate?.(shapeRecordToCreate) - - if (next) { - shapeRecordToCreate = next - } - - shapeRecordsToCreate.push(shapeRecordToCreate) - } - - // Add meta properties, if any, to the shapes - shapeRecordsToCreate.forEach((shape) => { - shape.meta = { - ...this.getInitialMetaForShape(shape), - ...shape.meta, - } - }) - - this.store.put(shapeRecordsToCreate) - - // If we're also selecting the newly created shapes, attempt to select all of them; - - // the engine will filter out any shapes that are descendants of other new shapes. - if (select) { - this.store.update(this.currentPageState.id, (state) => ({ - ...state, - selectedShapeIds: createdIds, - })) - } - }, - undo: ({ createdIds, prevSelectedShapeIds }) => { - this.store.remove(createdIds) - - if (prevSelectedShapeIds) { - this.store.update(this.currentPageState.id, (state) => ({ - ...state, - selectedShapeIds: prevSelectedShapeIds, - })) - } - }, - } - ) - private animatingShapes = new Map() /** @@ -6711,7 +6660,7 @@ export class Editor extends EventEmitter { (p) => p && animatingShapes.get(p.id) === animationId ) if (partialsToUpdate.length) { - this.updateShapes(partialsToUpdate, false) + this.updateShapes(partialsToUpdate, { squashing: true, ephemeral: false }) // update shapes also removes the shape from animating shapes } @@ -6741,7 +6690,7 @@ export class Editor extends EventEmitter { } } - this._updateShapes(tPartials, true) + this.updateShapes(tPartials, { squashing: true }) } catch (e) { // noop } @@ -6872,7 +6821,7 @@ export class Editor extends EventEmitter { idsToSelect.add(childIds[j]) } - this.reparentShapes(childIds, group.parentId, group.index) + this.reparentShapes(childIds, group.parentId, { insertIndex: group.index }) } this.deleteShapes(groups.map((group) => group.id)) @@ -6897,9 +6846,9 @@ export class Editor extends EventEmitter { */ updateShape( partial: TLShapePartial | null | undefined, - squashing = false + opts?: CommandHistoryOptions ) { - this.updateShapes([partial], squashing) + this.updateShapes([partial], opts) return this } @@ -6918,8 +6867,10 @@ export class Editor extends EventEmitter { */ updateShapes( partials: (TLShapePartial | null | undefined)[], - squashing = false + opts?: CommandHistoryOptions ) { + if (this.instanceState.isReadonly) return this + let compactedPartials = compact(partials) if (this.animatingShapes.size > 0) { compactedPartials.forEach((p) => this.animatingShapes.delete(p.id)) @@ -6934,141 +6885,66 @@ export class Editor extends EventEmitter { return true }) - this._updateShapes(compactedPartials, squashing) - return this - } + if (compactedPartials.length <= 0) return this - /** @internal */ - private _updateShapes = this.history.createCommand( - 'updateShapes', - (_partials: (TLShapePartial | null | undefined)[], squashing = false) => { - if (this.instanceState.isReadonly) return null + const shapesToUpdate = compact( + compactedPartials.map((partial) => { + const prev = this.getShape(partial.id) + if (!prev) return null - const partials = compact(_partials) + let newRecord = null as null | TLShape + for (const [k, v] of Object.entries(partial)) { + if (v === undefined) continue + switch (k) { + case 'id': + case 'type': + continue + default: { + if (v !== (prev as any)[k]) { + if (!newRecord) { + newRecord = { ...prev } + } - const snapshots = Object.fromEntries( - compact(partials.map(({ id }) => this.getShape(id))).map((shape) => { - return [shape.id, shape] - }) - ) - - if (partials.length <= 0) return null - - const updated = compact( - partials.map((partial) => { - const prev = snapshots[partial.id] - if (!prev) return null - let newRecord = null as null | TLShape - for (const [k, v] of Object.entries(partial)) { - if (v === undefined) continue - switch (k) { - case 'id': - case 'type': - continue - default: { - if (v !== (prev as any)[k]) { - if (!newRecord) { - newRecord = { ...prev } - } - - if (k === 'props') { - // props property - const nextProps = { ...prev.props } as JsonObject - for (const [propKey, propValue] of Object.entries(v as object)) { - if (propValue !== undefined) { - nextProps[propKey] = propValue - } + if (k === 'props') { + // props property + const nextProps = { ...prev.props } as JsonObject + for (const [propKey, propValue] of Object.entries(v as object)) { + if (propValue !== undefined) { + nextProps[propKey] = propValue } - newRecord!.props = nextProps - } else if (k === 'meta') { - // meta property - const nextMeta = { ...prev.meta } as JsonObject - for (const [metaKey, metaValue] of Object.entries(v as object)) { - if (metaValue !== undefined) { - nextMeta[metaKey] = metaValue - } - } - newRecord!.meta = nextMeta - } else { - // base property - ;(newRecord as any)[k] = v } + newRecord!.props = nextProps + } else if (k === 'meta') { + // meta property + const nextMeta = { ...prev.meta } as JsonObject + for (const [metaKey, metaValue] of Object.entries(v as object)) { + if (metaValue !== undefined) { + nextMeta[metaKey] = metaValue + } + } + newRecord!.meta = nextMeta + } else { + // base property + ;(newRecord as any)[k] = v } } } } - - return newRecord ?? prev - }) - ) - - const updates = Object.fromEntries(updated.map((shape) => [shape.id, shape])) - - return { data: { snapshots, updates }, squashing } - }, - { - do: ({ updates }) => { - // Iterate through array; if any shape has an onUpdate handler, call it - // and, if the handler returns a new shape, replace the old shape with - // the new one. This is used for example when repositioning a text shape - // based on its new text content. - const result = Object.values(updates) - for (let i = 0; i < result.length; i++) { - const shape = result[i] - const current = this.store.get(shape.id) - if (!current) continue - const next = this.getShapeUtil(shape).onBeforeUpdate?.(current, shape) - if (next) { - result[i] = next - } } - this.store.put(result) - }, - undo: ({ snapshots }) => { - this.store.put(Object.values(snapshots)) - }, - squash(prevData, nextData) { - return { - // keep the oldest snapshots - snapshots: { ...nextData.snapshots, ...prevData.snapshots }, - // keep the newest updates - updates: { ...prevData.updates, ...nextData.updates }, - } - }, - } - ) + + return newRecord ?? prev + }) + ) + + this.updateRecords(shapesToUpdate, opts) + return this + } /** @internal */ private _getUnlockedShapeIds(ids: TLShapeId[]): TLShapeId[] { return ids.filter((id) => !this.getShape(id)?.isLocked) } - /** - * Delete shapes. - * - * @example - * ```ts - * editor.deleteShapes(['box1', 'box2']) - * ``` - * - * @param ids - The ids of the shapes to delete. - * - * @public - */ - deleteShapes(ids: TLShapeId[]): this - deleteShapes(shapes: TLShape[]): this - deleteShapes(_ids: TLShapeId[] | TLShape[]) { - if (!Array.isArray(_ids)) { - throw Error('Editor.deleteShapes: must provide an array of shapes or shapeIds') - } - this._deleteShapes( - this._getUnlockedShapeIds( - typeof _ids[0] === 'string' ? (_ids as TLShapeId[]) : (_ids as TLShape[]).map((s) => s.id) - ) - ) - return this - } - /** * Delete a shape. * @@ -7088,58 +6964,31 @@ export class Editor extends EventEmitter { return this } - /** @internal */ - private _deleteShapes = this.history.createCommand( - 'delete_shapes', - (ids: TLShapeId[]) => { - if (this.instanceState.isReadonly) return null - if (ids.length === 0) return null - const prevSelectedShapeIds = [...this.currentPageState.selectedShapeIds] - - const allIds = new Set(ids) - - for (const id of ids) { - this.visitDescendants(id, (childId) => { - allIds.add(childId) - }) - } - - const deletedIds = [...allIds] - const arrowBindings = this._arrowBindingsIndex.value - const snapshots = compact( - deletedIds.flatMap((id) => { - const shape = this.getShape(id) - - // Add any bound arrows to the snapshots, so that we can restore the bindings on undo - const bindings = arrowBindings[id] - if (bindings && bindings.length > 0) { - return bindings.map(({ arrowId }) => this.getShape(arrowId)).concat(shape) - } - return shape - }) - ) - - const postSelectedShapeIds = prevSelectedShapeIds.filter((id) => !allIds.has(id)) - - return { data: { deletedIds, snapshots, prevSelectedShapeIds, postSelectedShapeIds } } - }, - { - do: ({ deletedIds, postSelectedShapeIds }) => { - this.store.remove(deletedIds) - this.store.update(this.currentPageState.id, (state) => ({ - ...state, - selectedShapeIds: postSelectedShapeIds, - })) - }, - undo: ({ snapshots, prevSelectedShapeIds }) => { - this.store.put(snapshots) - this.store.update(this.currentPageState.id, (state) => ({ - ...state, - selectedShapeIds: prevSelectedShapeIds, - })) - }, + /** + * Delete shapes. + * + * @example + * ```ts + * editor.deleteShapes(['box1', 'box2']) + * ``` + * + * @param ids - The ids of the shapes to delete. + * + * @public + */ + deleteShapes(ids: TLShapeId[]): this + deleteShapes(shapes: TLShape[]): this + deleteShapes(_ids: TLShapeId[] | TLShape[]) { + if (!Array.isArray(_ids)) { + throw Error('Editor.deleteShapes: must provide an array of shapes or shapeIds') } - ) + this.deleteRecords( + this._getUnlockedShapeIds( + typeof _ids[0] === 'string' ? (_ids as TLShapeId[]) : (_ids as TLShape[]).map((s) => s.id) + ) + ) + return this + } /* --------------------- Styles --------------------- */ @@ -7205,7 +7054,7 @@ export class Editor extends EventEmitter { * ```ts * const color = editor.sharedStyles.get(DefaultColorStyle) * if (color && color.type === 'shared') { - * console.log('All selected shapes have the same color:', color.value) + * print('All selected shapes have the same color:', color.value) * } * ``` * @@ -7232,48 +7081,6 @@ export class Editor extends EventEmitter { return styles } - /** - * Get the currently selected shared opacity. - * If any shapes are selected, this returns the shared opacity of the selected shapes. - * Otherwise, this returns the chosen opacity for the next shape. - * - * @public - */ - @computed get sharedOpacity(): SharedStyle { - if (this.isIn('select') && this.selectedShapeIds.length > 0) { - const shapesToCheck: TLShape[] = [] - const addShape = (shapeId: TLShapeId) => { - const shape = this.getShape(shapeId) - if (!shape) return - // For groups, ignore the opacity of the group shape and instead include - // the opacity of the group's children. These are the shapes that would have - // their opacity changed if the user called `setOpacity` on the current selection. - if (this.isShapeOfType(shape, 'group')) { - for (const childId of this.getSortedChildIdsForParent(shape.id)) { - addShape(childId) - } - } else { - shapesToCheck.push(shape) - } - } - for (const shapeId of this.selectedShapeIds) { - addShape(shapeId) - } - - let opacity: number | null = null - for (const shape of shapesToCheck) { - if (opacity === null) { - opacity = shape.opacity - } else if (opacity !== shape.opacity) { - return { type: 'mixed' } - } - } - - if (opacity !== null) return { type: 'shared', value: opacity } - } - return { type: 'shared', value: this.instanceState.opacityForNextShape } - } - /** * Set the current opacity. This will effect any selected shapes, or the * next-created shape. @@ -7288,50 +7095,8 @@ export class Editor extends EventEmitter { * @param ephemeral - Whether the opacity change is ephemeral. Ephemeral changes don't get added to the undo/redo stack. Defaults to false. * @param squashing - Whether the opacity change will be squashed into the existing history entry rather than creating a new one. Defaults to false. */ - setOpacity(opacity: number, ephemeral = false, squashing = false): this { - this.history.batch(() => { - if (this.isIn('select')) { - const { - currentPageState: { selectedShapeIds }, - } = this - - const shapesToUpdate: TLShape[] = [] - - // We can have many deep levels of grouped shape - // Making a recursive function to look through all the levels - const addShapeById = (id: TLShape['id']) => { - const shape = this.getShape(id) - if (!shape) return - if (this.isShapeOfType(shape, 'group')) { - const childIds = this.getSortedChildIdsForParent(id) - for (const childId of childIds) { - addShapeById(childId) - } - } else { - shapesToUpdate.push(shape) - } - } - - if (selectedShapeIds.length > 0) { - for (const id of selectedShapeIds) { - addShapeById(id) - } - - this.updateShapes( - shapesToUpdate.map((shape) => { - return { - id: shape.id, - type: shape.type, - opacity, - } - }), - ephemeral - ) - } - } - - this.updateInstanceState({ opacityForNextShape: opacity }, ephemeral, squashing) - }) + setOpacity(opacity: number, opts?: CommandHistoryOptions): this { + this.updateInstanceState({ opacityForNextShape: opacity }, opts) return this } @@ -7355,7 +7120,7 @@ export class Editor extends EventEmitter { * * @public */ - setStyle(style: StyleProp, value: T, ephemeral = false, squashing = false): this { + setStyle(style: StyleProp, value: T, opts?: CommandHistoryOptions): this { this.history.batch(() => { if (this.isIn('select')) { const { @@ -7403,17 +7168,14 @@ export class Editor extends EventEmitter { this.updateShapes( updates.map(({ updatePartial }) => updatePartial), - ephemeral + opts ) } } this.updateInstanceState( - { - stylesForNextShape: { ...this.instanceState.stylesForNextShape, [style.id]: value }, - }, - ephemeral, - squashing + { stylesForNextShape: { ...this.instanceState.stylesForNextShape, [style.id]: value } }, + { ...opts, ephemeral: true } ) }) @@ -7832,7 +7594,7 @@ export class Editor extends EventEmitter { return newShape }) - if (newShapes.length + this.shapeIdsOnCurrentPage.size > MAX_SHAPES_PER_PAGE) { + if (newShapes.length + this.currentPageShapeIds.size > MAX_SHAPES_PER_PAGE) { // There's some complexity here involving children // that might be created without their parents, so // if we're going over the limit then just don't paste. @@ -7922,7 +7684,7 @@ export class Editor extends EventEmitter { } // Create the shapes with root shapes as children of the page - this.createShapes(newShapes, select) + this.createShapes(newShapes) // And then, if needed, reparent the root shapes to the paste parent if (pasteParentId !== currentPageId) { @@ -7980,6 +7742,10 @@ export class Editor extends EventEmitter { return { id: s.id, type: s.type, x: point!.x + delta.x, y: point!.y + delta.y } }) ) + + if (select) { + this.setSelectedShapeIds(rootShapes.map((s) => s.id)) + } }) return this @@ -8552,11 +8318,11 @@ export class Editor extends EventEmitter { const zoom = Math.min(MAX_ZOOM, Math.max(MIN_ZOOM, z)) - this.setCamera( - cx + dx / cz - x / cz + x / zoom, - cy + dy / cz - y / cz + y / zoom, - zoom - ) + this.setCamera({ + x: cx + dx / cz - x / cz + x / zoom, + y: cy + dy / cz - y / cz + y / zoom, + z: zoom, + }) return // Stop here! } @@ -8586,10 +8352,12 @@ export class Editor extends EventEmitter { if (zoom !== undefined) { const { x, y } = this.viewportScreenCenter - this.animateCamera( - cx + (x / zoom - x) - (x / cz - x), - cy + (y / zoom - y) - (y / cz - y), - zoom, + this.setCamera( + { + x: cx + (x / zoom - x) - (x / cz - x), + y: cy + (y / zoom - y) - (y / cz - y), + z: zoom, + }, { duration: 100 } ) } @@ -8623,11 +8391,11 @@ export class Editor extends EventEmitter { const zoom = Math.min(MAX_ZOOM, Math.max(MIN_ZOOM, cz + (info.delta.z ?? 0) * cz)) - this.setCamera( - cx + (x / zoom - x) - (x / cz - x), - cy + (y / zoom - y) - (y / cz - y), - zoom - ) + this.setCamera({ + x: cx + (x / zoom - x) - (x / cz - x), + y: cy + (y / zoom - y) - (y / cz - y), + z: zoom, + }) // We want to return here because none of the states in our // statechart should respond to this event (a camera zoom) @@ -8636,7 +8404,7 @@ export class Editor extends EventEmitter { // Update the camera here, which will dispatch a pointer move... // this will also update the pointer position, etc - this.pan(info.delta.x, info.delta.y) + this.pan(info.delta) if ( !inputs.isDragging && @@ -8722,8 +8490,7 @@ export class Editor extends EventEmitter { if (this.inputs.isPanning && this.inputs.isPointing) { // Handle panning const { currentScreenPoint, previousScreenPoint } = this.inputs - const delta = Vec2d.Sub(currentScreenPoint, previousScreenPoint) - this.pan(delta.x, delta.y) + this.pan(Vec2d.Sub(currentScreenPoint, previousScreenPoint)) return } diff --git a/packages/editor/src/lib/editor/managers/CleanupManager.test.ts b/packages/editor/src/lib/editor/managers/CleanupManager.test.ts new file mode 100644 index 000000000..f3977982a --- /dev/null +++ b/packages/editor/src/lib/editor/managers/CleanupManager.test.ts @@ -0,0 +1,19 @@ +// let editor: Editor +// beforeEach(() => { +// editor = new Editor({ +// shapeUtils: [], +// tools: [], +// store: createTLStore({ shapeUtils: [] }), +// getContainer: () => document.body, +// }) +// }) + +it.todo('Registers an onBeforeCreate handler') +it.todo('Registers an onAfterCreate handler') +it.todo('Registers an onBeforeChange handler') +it.todo('Registers an onAfterChange handler') +it.todo('Registers an onBeforeDelete handler') +it.todo('Registers an onAfterDelete handler') + +it.todo('Registers a batch start handler') +it.todo('Registers a batch complete handler') diff --git a/packages/editor/src/lib/editor/managers/CleanupManager.ts b/packages/editor/src/lib/editor/managers/CleanupManager.ts new file mode 100644 index 000000000..fff31146f --- /dev/null +++ b/packages/editor/src/lib/editor/managers/CleanupManager.ts @@ -0,0 +1,243 @@ +import { TLRecord } from '@tldraw/tlschema' +import { Editor } from '../Editor' + +/** @public */ +export type TLBeforeCreateHandler = (record: R, source: 'remote' | 'user') => R +/** @public */ +export type TLAfterCreateHandler = ( + record: R, + source: 'remote' | 'user' +) => void +/** @public */ +export type TLBeforeChangeHandler = ( + prev: R, + next: R, + source: 'remote' | 'user' +) => R +/** @public */ +export type TLAfterChangeHandler = ( + prev: R, + next: R, + source: 'remote' | 'user' +) => void +/** @public */ +export type TLBeforeDeleteHandler = ( + record: R, + source: 'remote' | 'user' +) => void | false +/** @public */ +export type TLAfterDeleteHandler = ( + record: R, + source: 'remote' | 'user' +) => void +/** @public */ +export type TLBatchCompleteHandler = () => void + +/** + * The cleanup manager (aka a "side effect wrangler and correct state enforcer") + * is responsible for making sure that the editor's state is always correct. This + * includes things like: deleting a shape if its parent is deleted; unbinding + * arrows when their binding target is deleted; etc. + * + * We could consider moving this to the store instead. + * + * @public + */ +export class CleanupManager { + constructor(public editor: Editor) { + editor.store.onBeforeCreate = (record, source) => { + const handlers = this._beforeCreateHandlers[ + record.typeName + ] as TLBeforeCreateHandler[] + if (handlers) { + let r = record + for (const handler of handlers) { + r = handler(r, source) + } + return r + } + + return record + } + + editor.store.onAfterCreate = (record, source) => { + const handlers = this._afterCreateHandlers[ + record.typeName + ] as TLAfterCreateHandler[] + if (handlers) { + for (const handler of handlers) { + handler(record, source) + } + } + } + + editor.store.onBeforeChange = (prev, next, source) => { + const handlers = this._beforeChangeHandlers[ + next.typeName + ] as TLBeforeChangeHandler[] + if (handlers) { + let r = next + for (const handler of handlers) { + r = handler(prev, r, source) + } + return r + } + + return next + } + + let updateDepth = 0 + + editor.store.onAfterChange = (prev, next, source) => { + updateDepth++ + + if (updateDepth > 1000) { + console.error('[CleanupManager.onAfterChange] Maximum update depth exceeded, bailing out.') + } else { + const handlers = this._afterChangeHandlers[ + next.typeName + ] as TLAfterChangeHandler[] + if (handlers) { + for (const handler of handlers) { + handler(prev, next, source) + } + } + } + + updateDepth-- + } + + editor.store.onBeforeDelete = (record, source) => { + const handlers = this._beforeDeleteHandlers[ + record.typeName + ] as TLBeforeDeleteHandler[] + if (handlers) { + for (const handler of handlers) { + if (handler(record, source) === false) { + return false + } + } + } + } + + editor.store.onAfterDelete = (record, source) => { + const handlers = this._afterDeleteHandlers[ + record.typeName + ] as TLAfterDeleteHandler[] + if (handlers) { + for (const handler of handlers) { + handler(record, source) + } + } + } + + editor.history.onBatchComplete = () => { + this._batchCompleteHandlers.forEach((fn) => fn()) + } + } + + private _beforeCreateHandlers: Partial<{ + [K in TLRecord['typeName']]: TLBeforeCreateHandler[] + }> = {} + private _afterCreateHandlers: Partial<{ + [K in TLRecord['typeName']]: TLAfterCreateHandler[] + }> = {} + private _beforeChangeHandlers: Partial<{ + [K in TLRecord['typeName']]: TLBeforeChangeHandler[] + }> = {} + private _afterChangeHandlers: Partial<{ + [K in TLRecord['typeName']]: TLAfterChangeHandler[] + }> = {} + + private _beforeDeleteHandlers: Partial<{ + [K in TLRecord['typeName']]: TLBeforeDeleteHandler[] + }> = {} + + private _afterDeleteHandlers: Partial<{ + [K in TLRecord['typeName']]: TLAfterDeleteHandler[] + }> = {} + + private _batchCompleteHandlers: TLBatchCompleteHandler[] = [] + + registerBeforeCreateHandler( + typeName: T, + handler: TLBeforeCreateHandler + ) { + const handlers = this._beforeCreateHandlers[typeName] as TLBeforeCreateHandler[] + if (!handlers) this._beforeCreateHandlers[typeName] = [] + this._beforeCreateHandlers[typeName]!.push(handler) + } + + registerAfterCreateHandler( + typeName: T, + handler: TLAfterCreateHandler + ) { + const handlers = this._afterCreateHandlers[typeName] as TLAfterCreateHandler[] + if (!handlers) this._afterCreateHandlers[typeName] = [] + this._afterCreateHandlers[typeName]!.push(handler) + } + + registerBeforeChangeHandler( + typeName: T, + handler: TLBeforeChangeHandler + ) { + const handlers = this._beforeChangeHandlers[typeName] as TLBeforeChangeHandler[] + if (!handlers) this._beforeChangeHandlers[typeName] = [] + this._beforeChangeHandlers[typeName]!.push(handler) + } + + registerAfterChangeHandler( + typeName: T, + handler: TLAfterChangeHandler + ) { + const handlers = this._afterChangeHandlers[typeName] as TLAfterChangeHandler[] + if (!handlers) this._afterChangeHandlers[typeName] = [] + this._afterChangeHandlers[typeName]!.push(handler as TLAfterChangeHandler) + } + + registerBeforeDeleteHandler( + typeName: T, + handler: TLBeforeDeleteHandler + ) { + const handlers = this._beforeDeleteHandlers[typeName] as TLBeforeDeleteHandler[] + if (!handlers) this._beforeDeleteHandlers[typeName] = [] + this._beforeDeleteHandlers[typeName]!.push(handler as TLBeforeDeleteHandler) + } + + registerAfterDeleteHandler( + typeName: T, + handler: TLAfterDeleteHandler + ) { + const handlers = this._afterDeleteHandlers[typeName] as TLAfterDeleteHandler[] + if (!handlers) this._afterDeleteHandlers[typeName] = [] + this._afterDeleteHandlers[typeName]!.push(handler as TLAfterDeleteHandler) + } + + /** + * Register a handler to be called when a store completes a batch. + * + * @example + * ```ts + * let count = 0 + * + * editor.cleanup.registerBatchCompleteHandler(() => count++) + * + * editor.selectAll() + * expect(count).toBe(1) + * + * editor.batch(() => { + * editor.selectNone() + * editor.selectAll() + * }) + * + * expect(count).toBe(2) + * ``` + * + * @param handler - The handler to call + * + * @public + */ + registerBatchCompleteHandler(handler: TLBatchCompleteHandler) { + this._batchCompleteHandlers.push(handler) + } +} diff --git a/packages/editor/src/lib/editor/managers/EnvironmentManager.ts b/packages/editor/src/lib/editor/managers/EnvironmentManager.ts new file mode 100644 index 000000000..9a24c4ded --- /dev/null +++ b/packages/editor/src/lib/editor/managers/EnvironmentManager.ts @@ -0,0 +1,54 @@ +import { Editor } from '../Editor' + +export class EnvironmentManager { + constructor(public editor: Editor) { + if (typeof window !== 'undefined' && 'navigator' in window) { + this.isSafari = /^((?!chrome|android).)*safari/i.test(navigator.userAgent) + this.isIos = !!navigator.userAgent.match(/iPad/i) || !!navigator.userAgent.match(/iPhone/i) + this.isChromeForIos = /crios.*safari/i.test(navigator.userAgent) + this.isFirefox = /firefox/i.test(navigator.userAgent) + this.isAndroid = /android/i.test(navigator.userAgent) + } else { + this.isSafari = false + this.isIos = false + this.isChromeForIos = false + this.isFirefox = false + this.isAndroid = false + } + } + + /** + * Whether the editor is running in Safari. + * + * @public + */ + readonly isSafari: boolean + + /** + * Whether the editor is running on iOS. + * + * @public + */ + readonly isIos: boolean + + /** + * Whether the editor is running on iOS. + * + * @public + */ + readonly isChromeForIos: boolean + + /** + * Whether the editor is running on Firefox. + * + * @public + */ + readonly isFirefox: boolean + + /** + * Whether the editor is running on Android. + * + * @public + */ + readonly isAndroid: boolean +} diff --git a/packages/editor/src/lib/editor/managers/HistoryManager.test.ts b/packages/editor/src/lib/editor/managers/HistoryManager.test.ts index 4a73a43bd..60096b468 100644 --- a/packages/editor/src/lib/editor/managers/HistoryManager.test.ts +++ b/packages/editor/src/lib/editor/managers/HistoryManager.test.ts @@ -2,13 +2,9 @@ import { HistoryManager } from './HistoryManager' import { stack } from './Stack' function createCounterHistoryManager() { - const manager = new HistoryManager( - { emit: () => void null }, - () => null, - () => { - return - } - ) + const manager = new HistoryManager({ emit: () => void null }, () => { + return + }) const state = { count: 0, name: 'David', @@ -251,7 +247,10 @@ describe(HistoryManager, () => { expect(editor.getAge()).toBe(35) }) - it('does not allow new history entries to be pushed if a command invokes them while doing or undoing', () => { + // I had to turn off ignoringUpdates in command's initial "do" in order to make the cleanup methods undoable + // If there are other side effects here that we need to know about, then we should add tests for them; + // but so far everything seems ok. + it.skip('does not allow new history entries to be pushed if a command invokes them while doing or undoing', () => { editor.incrementTwice() expect(editor.history.numUndos).toBe(1) expect(editor.getCount()).toBe(2) @@ -260,7 +259,7 @@ describe(HistoryManager, () => { expect(editor.history.numUndos).toBe(0) }) - it('does not allow new history entries to be pushed if a command invokes them while bailing', () => { + it.skip('does not allow new history entries to be pushed if a command invokes them while bailing', () => { editor.history.mark('0') editor.incrementTwice() editor.history.mark('2') diff --git a/packages/editor/src/lib/editor/managers/HistoryManager.ts b/packages/editor/src/lib/editor/managers/HistoryManager.ts index 45d906c63..dbd8571ac 100644 --- a/packages/editor/src/lib/editor/managers/HistoryManager.ts +++ b/packages/editor/src/lib/editor/managers/HistoryManager.ts @@ -4,13 +4,17 @@ import { uniqueId } from '../../utils/uniqueId' import { TLCommandHandler, TLHistoryEntry } from '../types/history-types' import { Stack, stack } from './Stack' +/** @public */ +export type CommandHistoryOptions = Partial<{ + squashing: boolean + ephemeral: boolean + preservesRedoStack: boolean +}> + type CommandFn = (...args: any[]) => - | { + | ({ data: Data - squashing?: boolean - ephemeral?: boolean - preservesRedoStack?: boolean - } + } & CommandHistoryOptions) | null | undefined | void @@ -29,7 +33,6 @@ export class HistoryManager< constructor( private readonly ctx: CTX, - private readonly onBatchComplete: () => void, private readonly annotateError: (error: unknown) => void ) {} @@ -43,6 +46,8 @@ export class HistoryManager< return this._redos.value.length } + skipHistory = false + createCommand = >( name: Name, constructor: Constructor, @@ -68,13 +73,14 @@ export class HistoryManager< const { data, ephemeral, squashing, preservesRedoStack } = result - this.ignoringUpdates((undos, redos) => { - handle.do(data) - return { undos, redos } - }) + // this.ignoringUpdates((undos, redos) => { + handle.do(data) + // return { undos, redos } + // }) if (!ephemeral) { const prev = this._undos.value.head + if ( squashing && prev && @@ -103,6 +109,7 @@ export class HistoryManager< ) } + // clear the redo stack unless the command explicitly says not to if (!result.preservesRedoStack) { this._redos.set(stack()) } @@ -116,7 +123,19 @@ export class HistoryManager< return exec } + onBatchStart = () => { + // noop + } + + onBatchComplete = () => { + // noop + } + batch = (fn: () => void) => { + if (this._batchDepth === 0) { + this.onBatchStart() + } + try { this._batchDepth++ if (this._batchDepth === 1) { diff --git a/packages/editor/src/lib/editor/managers/SnapManager.ts b/packages/editor/src/lib/editor/managers/SnapManager.ts index 43b93daef..f8fc1636d 100644 --- a/packages/editor/src/lib/editor/managers/SnapManager.ts +++ b/packages/editor/src/lib/editor/managers/SnapManager.ts @@ -313,15 +313,15 @@ export class SnapManager { let startNode: GapNode, endNode: GapNode - const sortedShapesOnCurrentPageHorizontal = this.snappableShapes.sort((a, b) => { + const currentPageShapesSortedHorizontal = this.snappableShapes.sort((a, b) => { return a.pageBounds.minX - b.pageBounds.minX }) // Collect horizontal gaps - for (let i = 0; i < sortedShapesOnCurrentPageHorizontal.length; i++) { - startNode = sortedShapesOnCurrentPageHorizontal[i] - for (let j = i + 1; j < sortedShapesOnCurrentPageHorizontal.length; j++) { - endNode = sortedShapesOnCurrentPageHorizontal[j] + for (let i = 0; i < currentPageShapesSortedHorizontal.length; i++) { + startNode = currentPageShapesSortedHorizontal[i] + for (let j = i + 1; j < currentPageShapesSortedHorizontal.length; j++) { + endNode = currentPageShapesSortedHorizontal[j] if ( // is there space between the boxes @@ -358,14 +358,14 @@ export class SnapManager { } // Collect vertical gaps - const sortedShapesOnCurrentPageVertical = sortedShapesOnCurrentPageHorizontal.sort((a, b) => { + const currentPageShapesSortedVertical = currentPageShapesSortedHorizontal.sort((a, b) => { return a.pageBounds.minY - b.pageBounds.minY }) - for (let i = 0; i < sortedShapesOnCurrentPageVertical.length; i++) { - startNode = sortedShapesOnCurrentPageVertical[i] - for (let j = i + 1; j < sortedShapesOnCurrentPageVertical.length; j++) { - endNode = sortedShapesOnCurrentPageVertical[j] + for (let i = 0; i < currentPageShapesSortedVertical.length; i++) { + startNode = currentPageShapesSortedVertical[i] + for (let j = i + 1; j < currentPageShapesSortedVertical.length; j++) { + endNode = currentPageShapesSortedVertical[j] if ( // is there space between the boxes diff --git a/packages/editor/src/lib/editor/shapes/group/GroupShapeUtil.tsx b/packages/editor/src/lib/editor/shapes/group/GroupShapeUtil.tsx index 589f0714c..6966d9147 100644 --- a/packages/editor/src/lib/editor/shapes/group/GroupShapeUtil.tsx +++ b/packages/editor/src/lib/editor/shapes/group/GroupShapeUtil.tsx @@ -52,12 +52,12 @@ export class GroupShapeUtil extends ShapeUtil { component(shape: TLGroupShape) { // Not a class component, but eslint can't tell that :( const { - erasingShapeIdsSet, + erasingShapeIds, currentPageState: { hintingShapeIds, focusedGroupId }, zoomLevel, } = this.editor - const isErasing = erasingShapeIdsSet.has(shape.id) + const isErasing = erasingShapeIds.includes(shape.id) const isHintingOtherGroup = hintingShapeIds.length > 0 && diff --git a/packages/editor/src/lib/editor/tools/BaseBoxShapeTool/children/Idle.ts b/packages/editor/src/lib/editor/tools/BaseBoxShapeTool/children/Idle.ts index 29f2eb15c..8278ec6f5 100644 --- a/packages/editor/src/lib/editor/tools/BaseBoxShapeTool/children/Idle.ts +++ b/packages/editor/src/lib/editor/tools/BaseBoxShapeTool/children/Idle.ts @@ -9,7 +9,10 @@ export class Idle extends StateNode { } override onEnter = () => { - this.editor.updateInstanceState({ cursor: { type: 'cross', rotation: 0 } }, true) + this.editor.updateInstanceState( + { cursor: { type: 'cross', rotation: 0 } }, + { ephemeral: true, squashing: true } + ) } override onCancel = () => { diff --git a/packages/editor/src/lib/editor/tools/BaseBoxShapeTool/children/Pointing.ts b/packages/editor/src/lib/editor/tools/BaseBoxShapeTool/children/Pointing.ts index 8c1fb70f3..abda153aa 100644 --- a/packages/editor/src/lib/editor/tools/BaseBoxShapeTool/children/Pointing.ts +++ b/packages/editor/src/lib/editor/tools/BaseBoxShapeTool/children/Pointing.ts @@ -29,21 +29,19 @@ export class Pointing extends StateNode { this.editor.mark(this.markId) - this.editor.createShapes( - [ - { - id, - type: shapeType, - x: originPagePoint.x, - y: originPagePoint.y, - props: { - w: 1, - h: 1, - }, + this.editor.createShapes([ + { + id, + type: shapeType, + x: originPagePoint.x, + y: originPagePoint.y, + props: { + w: 1, + h: 1, }, - ], - true - ) + }, + ]) + this.editor.select(id) this.editor.setCurrentTool('select.resizing', { ...info, target: 'selection', diff --git a/packages/editor/src/lib/editor/types/misc-types.ts b/packages/editor/src/lib/editor/types/misc-types.ts index 08dd596bf..89b1043ed 100644 --- a/packages/editor/src/lib/editor/types/misc-types.ts +++ b/packages/editor/src/lib/editor/types/misc-types.ts @@ -1,2 +1,4 @@ /** @public */ -export type RequiredKeys = Pick & Partial +export type RequiredKeys = Partial> & Pick +/** @public */ +export type OptionalKeys = Omit & Partial> diff --git a/packages/editor/src/lib/hooks/useCanvasEvents.ts b/packages/editor/src/lib/hooks/useCanvasEvents.ts index 5af105294..f5632e4f9 100644 --- a/packages/editor/src/lib/hooks/useCanvasEvents.ts +++ b/packages/editor/src/lib/hooks/useCanvasEvents.ts @@ -84,12 +84,14 @@ export function useCanvasEvents() { const files = Array.from(e.dataTransfer.files) + // the drop point should be offset by the position of the editor's container const rect = editor.getContainer().getBoundingClientRect() + const point = editor.screenToPage({ x: e.clientX - rect.x, y: e.clientY - rect.y }) await editor.putExternalContent({ type: 'files', files, - point: editor.screenToPage(e.clientX - rect.x, e.clientY - rect.y), + point, ignoreParent: false, }) } diff --git a/packages/editor/src/lib/hooks/useCoarsePointer.ts b/packages/editor/src/lib/hooks/useCoarsePointer.ts index 99df042cd..e3f384c35 100644 --- a/packages/editor/src/lib/hooks/useCoarsePointer.ts +++ b/packages/editor/src/lib/hooks/useCoarsePointer.ts @@ -7,7 +7,11 @@ export function useCoarsePointer() { // This is a workaround for a Firefox bug where we don't correctly // detect coarse VS fine pointer. For now, let's assume that you have a fine // pointer if you're on Firefox on desktop. - if (editor.isFirefox && !editor.isAndroid && !editor.isIos) { + if ( + editor.environment.isFirefox && + !editor.environment.isAndroid && + !editor.environment.isIos + ) { editor.updateInstanceState({ isCoarsePointer: false }) return } diff --git a/packages/editor/src/lib/hooks/useZoomCss.ts b/packages/editor/src/lib/hooks/useZoomCss.ts index 70a1e5539..88664d300 100644 --- a/packages/editor/src/lib/hooks/useZoomCss.ts +++ b/packages/editor/src/lib/hooks/useZoomCss.ts @@ -13,7 +13,7 @@ export function useZoomCss() { const setScaleDebounced = debounce(setScale, 100) const scheduler = new EffectScheduler('useZoomCss', () => { - const numShapes = editor.shapeIdsOnCurrentPage.size + const numShapes = editor.currentPageShapeIds.size if (numShapes < 300) { setScale(editor.zoomLevel) } else { diff --git a/packages/state/src/lib/core/Atom.ts b/packages/state/src/lib/core/Atom.ts index 6836a80b5..8bcc2e518 100644 --- a/packages/state/src/lib/core/Atom.ts +++ b/packages/state/src/lib/core/Atom.ts @@ -44,7 +44,7 @@ export interface AtomOptions { * ```ts * const name = atom('name', 'John') * - * console.log(name.value) // 'John' + * print(name.value) // 'John' * ``` * * @public diff --git a/packages/state/src/lib/core/Computed.ts b/packages/state/src/lib/core/Computed.ts index 625944ed0..dc268099f 100644 --- a/packages/state/src/lib/core/Computed.ts +++ b/packages/state/src/lib/core/Computed.ts @@ -26,7 +26,7 @@ type UNINITIALIZED = typeof UNINITIALIZED * const count = atom('count', 0) * const double = computed('double', (prevValue) => { * if (isUninitialized(prevValue)) { - * console.log('First time!') + * print('First time!') * } * return count.value * 2 * }) @@ -296,7 +296,7 @@ export function getComputedInstance( * ```ts * const name = atom('name', 'John') * const greeting = computed('greeting', () => `Hello ${name.value}!`) - * console.log(greeting.value) // 'Hello John!' + * print(greeting.value) // 'Hello John!' * ``` * * `computed` may also be used as a decorator for creating computed class properties. diff --git a/packages/state/src/lib/core/capture.ts b/packages/state/src/lib/core/capture.ts index f84f8fba4..7fad84170 100644 --- a/packages/state/src/lib/core/capture.ts +++ b/packages/state/src/lib/core/capture.ts @@ -39,7 +39,7 @@ let stack: CaptureStackFrame | null = null * }) * * react('log name changes', () => { - * console.log(name.value, 'was changed at', unsafe__withoutCapture(() => time.value)) + * print(name.value, 'was changed at', unsafe__withoutCapture(() => time.value)) * }) * * ``` @@ -134,7 +134,7 @@ export function maybeCaptureParent(p: Signal) { * const name = atom('name', 'Bob') * react('greeting', () => { * whyAmIRunning() - * console.log('Hello', name.value) + * print('Hello', name.value) * }) * * name.set('Alice') diff --git a/packages/state/src/lib/core/transactions.ts b/packages/state/src/lib/core/transactions.ts index d7298ed92..adc574847 100644 --- a/packages/state/src/lib/core/transactions.ts +++ b/packages/state/src/lib/core/transactions.ts @@ -143,7 +143,7 @@ export let currentTransaction = null as Transaction | null * const lastName = atom('Doe') * * react('greet', () => { - * console.log(`Hello, ${firstName.value} ${lastName.value}!`) + * print(`Hello, ${firstName.value} ${lastName.value}!`) * }) * * // Logs "Hello, John Doe!" @@ -164,7 +164,7 @@ export let currentTransaction = null as Transaction | null * const lastName = atom('Doe') * * react('greet', () => { - * console.log(`Hello, ${firstName.value} ${lastName.value}!`) + * print(`Hello, ${firstName.value} ${lastName.value}!`) * }) * * // Logs "Hello, John Doe!" @@ -187,7 +187,7 @@ export let currentTransaction = null as Transaction | null * const lastName = atom('Doe') * * react('greet', () => { - * console.log(`Hello, ${firstName.value} ${lastName.value}!`) + * print(`Hello, ${firstName.value} ${lastName.value}!`) * }) * * // Logs "Hello, John Doe!" diff --git a/packages/store/api-report.md b/packages/store/api-report.md index efcbfb7b3..1537a867b 100644 --- a/packages/store/api-report.md +++ b/packages/store/api-report.md @@ -244,6 +244,8 @@ export class Store { // (undocumented) _flushHistory(): void; get: >(id: K) => RecFromId | undefined; + // (undocumented) + getRecordType: (record: R) => T; getSnapshot(scope?: 'all' | RecordScope): StoreSnapshot; has: >(id: K) => boolean; readonly history: Atom>; @@ -255,10 +257,12 @@ export class Store { // @internal (undocumented) markAsPossiblyCorrupted(): void; mergeRemoteChanges: (fn: () => void) => void; - onAfterChange?: (prev: R, next: R) => void; - onAfterCreate?: (record: R) => void; - onAfterDelete?: (prev: R) => void; - onBeforeDelete?: (prev: R) => void; + onAfterChange?: (prev: R, next: R, source: 'remote' | 'user') => void; + onAfterCreate?: (record: R, source: 'remote' | 'user') => void; + onAfterDelete?: (prev: R, source: 'remote' | 'user') => void; + onBeforeChange?: (prev: R, next: R, source: 'remote' | 'user') => R; + onBeforeCreate?: (next: R, source: 'remote' | 'user') => R; + onBeforeDelete?: (prev: R, source: 'remote' | 'user') => false | void; // (undocumented) readonly props: Props; put: (records: R[], phaseOverride?: 'initialize') => void; diff --git a/packages/store/src/lib/Store.ts b/packages/store/src/lib/Store.ts index ec3467b86..56c958447 100644 --- a/packages/store/src/lib/Store.ts +++ b/packages/store/src/lib/Store.ts @@ -297,13 +297,29 @@ export class Store { this.allRecords().forEach((record) => this.schema.validateRecord(this, record, phase, null)) } + /** + * A callback fired after each record's change. + * + * @param prev - The previous value, if any. + * @param next - The next value. + */ + onBeforeCreate?: (next: R, source: 'remote' | 'user') => R + /** * A callback fired after a record is created. Use this to perform related updates to other * records in the store. * * @param record - The record to be created */ - onAfterCreate?: (record: R) => void + onAfterCreate?: (record: R, source: 'remote' | 'user') => void + + /** + * A callback before after each record's change. + * + * @param prev - The previous value, if any. + * @param next - The next value. + */ + onBeforeChange?: (prev: R, next: R, source: 'remote' | 'user') => R /** * A callback fired after each record's change. @@ -311,21 +327,21 @@ export class Store { * @param prev - The previous value, if any. * @param next - The next value. */ - onAfterChange?: (prev: R, next: R) => void + onAfterChange?: (prev: R, next: R, source: 'remote' | 'user') => void /** * A callback fired before a record is deleted. * * @param prev - The record that will be deleted. */ - onBeforeDelete?: (prev: R) => void + onBeforeDelete?: (prev: R, source: 'remote' | 'user') => false | void /** * A callback fired after a record is deleted. * * @param prev - The record that will be deleted. */ - onAfterDelete?: (prev: R) => void + onAfterDelete?: (prev: R, source: 'remote' | 'user') => void // used to avoid running callbacks when rolling back changes in sync client private _runCallbacks = true @@ -353,12 +369,18 @@ export class Store { // changes (e.g. additions, deletions, or updates that produce a new value). let didChange = false + const beforeCreate = this.onBeforeCreate && this._runCallbacks ? this.onBeforeCreate : null + const beforeUpdate = this.onBeforeChange && this._runCallbacks ? this.onBeforeChange : null + const source = this.isMergingRemoteChanges ? 'remote' : 'user' + for (let i = 0, n = records.length; i < n; i++) { record = records[i] const recordAtom = (map ?? currentMap)[record.id as IdOf] if (recordAtom) { + if (beforeUpdate) record = beforeUpdate(recordAtom.value, record, source) + // If we already have an atom for this record, update its value. const initialValue = recordAtom.__unsafe__getWithoutCapture() @@ -382,6 +404,8 @@ export class Store { updates[record.id] = [initialValue, finalValue] } } else { + if (beforeCreate) record = beforeCreate(record, source) + didChange = true // If we don't have an atom, create one. @@ -418,20 +442,22 @@ export class Store { removed: {} as Record, R>, }) - const { onAfterCreate, onAfterChange } = this + if (this._runCallbacks) { + const { onAfterCreate, onAfterChange } = this - if (onAfterCreate && this._runCallbacks) { - // Run the onAfterChange callback for addition. - Object.values(additions).forEach((record) => { - onAfterCreate(record) - }) - } + if (onAfterCreate) { + // Run the onAfterChange callback for addition. + Object.values(additions).forEach((record) => { + onAfterCreate(record, source) + }) + } - if (onAfterChange && this._runCallbacks) { - // Run the onAfterChange callback for update. - Object.values(updates).forEach(([from, to]) => { - onAfterChange(from, to) - }) + if (onAfterChange) { + // Run the onAfterChange callback for update. + Object.values(updates).forEach(([from, to]) => { + onAfterChange(from, to, source) + }) + } } }) } @@ -444,12 +470,17 @@ export class Store { */ remove = (ids: IdOf[]): void => { transact(() => { + const cancelled = [] as IdOf[] + const source = this.isMergingRemoteChanges ? 'remote' : 'user' + if (this.onBeforeDelete && this._runCallbacks) { for (const id of ids) { const atom = this.atoms.__unsafe__getWithoutCapture()[id] if (!atom) continue - this.onBeforeDelete(atom.value) + if (this.onBeforeDelete(atom.value, source) === false) { + cancelled.push(id) + } } } @@ -460,6 +491,7 @@ export class Store { let result: typeof atoms | undefined = undefined for (const id of ids) { + if (cancelled.includes(id)) continue if (!(id in atoms)) continue if (!result) result = { ...atoms } if (!removed) removed = {} as Record, R> @@ -476,8 +508,12 @@ export class Store { // If we have an onAfterChange, run it for each removed record. if (this.onAfterDelete && this._runCallbacks) { + let record: R for (let i = 0, n = ids.length; i < n; i++) { - this.onAfterDelete(removed[ids[i]]) + record = removed[ids[i]] + if (record) { + this.onAfterDelete(record, source) + } } } }) @@ -596,6 +632,7 @@ export class Store { console.error(`Record ${id} not found. This is probably an error`) return } + this.put([updater(atom.__unsafe__getWithoutCapture() as any as RecFromId) as any]) } @@ -752,6 +789,14 @@ export class Store { } } + getRecordType = (record: R): T => { + const type = this.schema.types[record.typeName as R['typeName']] + if (!type) { + throw new Error(`Record type ${record.typeName} not found`) + } + return type as unknown as T + } + private _integrityChecker?: () => void | undefined /** @internal */ diff --git a/packages/tldraw/src/lib/shapes/arrow/ArrowShapeTool.test.ts b/packages/tldraw/src/lib/shapes/arrow/ArrowShapeTool.test.ts index 919879018..59a73e8ec 100644 --- a/packages/tldraw/src/lib/shapes/arrow/ArrowShapeTool.test.ts +++ b/packages/tldraw/src/lib/shapes/arrow/ArrowShapeTool.test.ts @@ -39,9 +39,9 @@ it('enters the arrow state', () => { describe('When in the idle state', () => { it('enters the pointing state and creates a shape on pointer down', () => { - const shapesBefore = editor.shapesOnCurrentPage.length + const shapesBefore = editor.currentPageShapes.length editor.setCurrentTool('arrow').pointerDown(0, 0) - const shapesAfter = editor.shapesOnCurrentPage.length + const shapesAfter = editor.currentPageShapes.length expect(shapesAfter).toBe(shapesBefore + 1) editor.expectPathToBe('root.arrow.pointing') }) @@ -55,18 +55,18 @@ describe('When in the idle state', () => { describe('When in the pointing state', () => { it('cancels on pointer up', () => { - const shapesBefore = editor.shapesOnCurrentPage.length + const shapesBefore = editor.currentPageShapes.length editor.setCurrentTool('arrow').pointerDown(0, 0).pointerUp(0, 0) - const shapesAfter = editor.shapesOnCurrentPage.length + const shapesAfter = editor.currentPageShapes.length expect(shapesAfter).toBe(shapesBefore) expect(editor.hintingShapeIds.length).toBe(0) editor.expectPathToBe('root.arrow.idle') }) it('bails on cancel', () => { - const shapesBefore = editor.shapesOnCurrentPage.length + const shapesBefore = editor.currentPageShapes.length editor.setCurrentTool('arrow').pointerDown(0, 0).cancel() - const shapesAfter = editor.shapesOnCurrentPage.length + const shapesAfter = editor.currentPageShapes.length expect(shapesAfter).toBe(shapesBefore) expect(editor.hintingShapeIds.length).toBe(0) editor.expectPathToBe('root.arrow.idle') @@ -82,7 +82,7 @@ describe('When in the pointing state', () => { describe('When dragging the arrow', () => { it('updates the arrow on pointer move', () => { editor.setCurrentTool('arrow').pointerDown(0, 0).pointerMove(10, 10) - const arrow = editor.shapesOnCurrentPage[editor.shapesOnCurrentPage.length - 1] + const arrow = editor.currentPageShapes[editor.currentPageShapes.length - 1] editor.expectShapeToMatch(arrow, { id: arrow.id, type: 'arrow', @@ -97,9 +97,9 @@ describe('When dragging the arrow', () => { }) it('returns to select.idle, keeping shape, on pointer up', () => { - const shapesBefore = editor.shapesOnCurrentPage.length + const shapesBefore = editor.currentPageShapes.length editor.setCurrentTool('arrow').pointerDown(0, 0).pointerMove(10, 10).pointerUp(10, 10) - const shapesAfter = editor.shapesOnCurrentPage.length + const shapesAfter = editor.currentPageShapes.length expect(shapesAfter).toBe(shapesBefore + 1) expect(editor.hintingShapeIds.length).toBe(0) editor.expectPathToBe('root.select.idle') @@ -107,18 +107,18 @@ describe('When dragging the arrow', () => { it('returns to arrow.idle, keeping shape, on pointer up when tool lock is active', () => { editor.updateInstanceState({ isToolLocked: true }) - const shapesBefore = editor.shapesOnCurrentPage.length + const shapesBefore = editor.currentPageShapes.length editor.setCurrentTool('arrow').pointerDown(0, 0).pointerMove(10, 10).pointerUp(10, 10) - const shapesAfter = editor.shapesOnCurrentPage.length + const shapesAfter = editor.currentPageShapes.length expect(shapesAfter).toBe(shapesBefore + 1) expect(editor.hintingShapeIds.length).toBe(0) editor.expectPathToBe('root.arrow.idle') }) it('bails on cancel', () => { - const shapesBefore = editor.shapesOnCurrentPage.length + const shapesBefore = editor.currentPageShapes.length editor.setCurrentTool('arrow').pointerDown(0, 0).pointerMove(10, 10).cancel() - const shapesAfter = editor.shapesOnCurrentPage.length + const shapesAfter = editor.currentPageShapes.length expect(shapesAfter).toBe(shapesBefore) editor.expectPathToBe('root.arrow.idle') }) @@ -139,7 +139,7 @@ describe('When pointing a start shape', () => { // Clear hinting ids when moving away expect(editor.hintingShapeIds.length).toBe(0) - const arrow = editor.shapesOnCurrentPage[editor.shapesOnCurrentPage.length - 1] + const arrow = editor.currentPageShapes[editor.currentPageShapes.length - 1] editor.expectShapeToMatch(arrow, { id: arrow.id, type: 'arrow', @@ -179,7 +179,7 @@ describe('When pointing an end shape', () => { // Set hinting id when pointing the shape expect(editor.hintingShapeIds.length).toBe(1) - const arrow = editor.shapesOnCurrentPage[editor.shapesOnCurrentPage.length - 1] + const arrow = editor.currentPageShapes[editor.currentPageShapes.length - 1] editor.expectShapeToMatch(arrow, { id: arrow.id, type: 'arrow', @@ -208,7 +208,7 @@ describe('When pointing an end shape', () => { editor.pointerMove(375, 375) - let arrow = editor.shapesOnCurrentPage[editor.shapesOnCurrentPage.length - 1] + let arrow = editor.currentPageShapes[editor.currentPageShapes.length - 1] expect(editor.hintingShapeIds.length).toBe(1) @@ -230,7 +230,7 @@ describe('When pointing an end shape', () => { jest.advanceTimersByTime(1000) - arrow = editor.shapesOnCurrentPage[editor.shapesOnCurrentPage.length - 1] + arrow = editor.currentPageShapes[editor.currentPageShapes.length - 1] editor.expectShapeToMatch(arrow, { id: arrow.id, @@ -250,7 +250,7 @@ describe('When pointing an end shape', () => { editor.pointerMove(375, 0) expect(editor.hintingShapeIds.length).toBe(0) - arrow = editor.shapesOnCurrentPage[editor.shapesOnCurrentPage.length - 1] + arrow = editor.currentPageShapes[editor.currentPageShapes.length - 1] editor.expectShapeToMatch(arrow, { id: arrow.id, @@ -268,7 +268,7 @@ describe('When pointing an end shape', () => { editor.pointerMove(325, 325) expect(editor.hintingShapeIds.length).toBe(1) - arrow = editor.shapesOnCurrentPage[editor.shapesOnCurrentPage.length - 1] + arrow = editor.currentPageShapes[editor.currentPageShapes.length - 1] editor.expectShapeToMatch(arrow, { id: arrow.id, @@ -289,7 +289,7 @@ describe('When pointing an end shape', () => { // Give time for the velocity to die down jest.advanceTimersByTime(1000) - arrow = editor.shapesOnCurrentPage[editor.shapesOnCurrentPage.length - 1] + arrow = editor.currentPageShapes[editor.currentPageShapes.length - 1] editor.expectShapeToMatch(arrow, { id: arrow.id, @@ -316,7 +316,7 @@ describe('When pointing an end shape', () => { editor.inputs.pointerVelocity = new Vec2d(1, 1) editor.pointerMove(370, 370) - const arrow = editor.shapesOnCurrentPage[editor.shapesOnCurrentPage.length - 1] + const arrow = editor.currentPageShapes[editor.currentPageShapes.length - 1] expect(editor.hintingShapeIds.length).toBe(1) @@ -340,7 +340,7 @@ describe('When pointing an end shape', () => { it('begins precise when moving slowly', () => { editor.setCurrentTool('arrow').pointerDown(0, 0) - let arrow = editor.shapesOnCurrentPage[editor.shapesOnCurrentPage.length - 1] + let arrow = editor.currentPageShapes[editor.currentPageShapes.length - 1] editor.expectShapeToMatch(arrow, { id: arrow.id, @@ -358,7 +358,7 @@ describe('When pointing an end shape', () => { editor.inputs.pointerVelocity = new Vec2d(0.001, 0.001) editor.pointerMove(375, 375) - arrow = editor.shapesOnCurrentPage[editor.shapesOnCurrentPage.length - 1] + arrow = editor.currentPageShapes[editor.currentPageShapes.length - 1] expect(editor.hintingShapeIds.length).toBe(1) @@ -390,7 +390,7 @@ describe('reparenting issue', () => { editor.pointerMove(100, 100) editor.pointerUp() - const arrowId = editor.sortedShapesOnCurrentPage[0].id + const arrowId = editor.currentPageShapesSorted[0].id // Now create three shapes editor.createShapes([ diff --git a/packages/tldraw/src/lib/shapes/arrow/ArrowShapeUtil.test.ts b/packages/tldraw/src/lib/shapes/arrow/ArrowShapeUtil.test.ts index 5f731152d..f596d4580 100644 --- a/packages/tldraw/src/lib/shapes/arrow/ArrowShapeUtil.test.ts +++ b/packages/tldraw/src/lib/shapes/arrow/ArrowShapeUtil.test.ts @@ -302,7 +302,7 @@ describe('Other cases when arrow are moved', () => { .groupShapes(editor.selectedShapeIds) editor.setCurrentTool('arrow').pointerDown(1000, 1000).pointerMove(50, 350).pointerUp(50, 350) - let arrow = editor.shapesOnCurrentPage[editor.shapesOnCurrentPage.length - 1] + let arrow = editor.currentPageShapes[editor.currentPageShapes.length - 1] assert(editor.isShapeOfType(arrow, 'arrow')) assert(arrow.props.end.type === 'binding') expect(arrow.props.end.boundShapeId).toBe(ids.box3) @@ -322,7 +322,7 @@ describe('When a shape it rotated', () => { it('binds correctly', () => { editor.setCurrentTool('arrow').pointerDown(0, 0).pointerMove(375, 375) - const arrow = editor.shapesOnCurrentPage[editor.shapesOnCurrentPage.length - 1] + const arrow = editor.currentPageShapes[editor.currentPageShapes.length - 1] expect(editor.getShape(arrow.id)).toMatchObject({ props: { @@ -371,8 +371,8 @@ describe('resizing', () => { .pointerUp() .setCurrentTool('select') - const arrow1 = editor.shapesOnCurrentPage.at(-2)! - const arrow2 = editor.shapesOnCurrentPage.at(-1)! + const arrow1 = editor.currentPageShapes.at(-2)! + const arrow2 = editor.currentPageShapes.at(-1)! editor .select(arrow1.id, arrow2.id) @@ -426,8 +426,8 @@ describe('resizing', () => { .pointerUp() .setCurrentTool('select') - const arrow1 = editor.shapesOnCurrentPage.at(-2)! - const arrow2 = editor.shapesOnCurrentPage.at(-1)! + const arrow1 = editor.currentPageShapes.at(-2)! + const arrow2 = editor.currentPageShapes.at(-1)! editor.updateShapes([{ id: arrow1.id, type: 'arrow', props: { bend: 50 } }]) @@ -551,6 +551,7 @@ describe("an arrow's parents", () => { }) // move b outside of frame editor.select(boxBid).translateSelection(200, 0) + jest.advanceTimersByTime(500) expect(editor.getShape(arrowId)).toMatchObject({ parentId: editor.currentPageId, props: { @@ -565,6 +566,10 @@ describe("an arrow's parents", () => { editor.setCurrentTool('arrow') editor.pointerDown(15, 15).pointerMove(115, 15).pointerUp() const arrowId = editor.onlySelectedShape!.id + + expect(editor.getShape(boxAid)!.parentId).toBe(frameId) + expect(editor.getShape(boxCid)!.parentId).not.toBe(frameId) + expect(editor.getShape(arrowId)).toMatchObject({ parentId: editor.currentPageId, props: { @@ -576,6 +581,12 @@ describe("an arrow's parents", () => { // move c inside of frame editor.select(boxCid).translateSelection(-40, 0) + jest.advanceTimersByTime(500) + + expect(editor.getShape(boxAid)!.parentId).toBe(frameId) + expect(editor.getShape(boxCid)!.parentId).toBe(frameId) + expect(editor.getShape(arrowId)!.parentId).toBe(frameId) + expect(editor.getShape(arrowId)).toMatchObject({ parentId: frameId, props: { diff --git a/packages/tldraw/src/lib/shapes/arrow/ArrowShapeUtil.tsx b/packages/tldraw/src/lib/shapes/arrow/ArrowShapeUtil.tsx index c328e2cc2..d1d68cf47 100644 --- a/packages/tldraw/src/lib/shapes/arrow/ArrowShapeUtil.tsx +++ b/packages/tldraw/src/lib/shapes/arrow/ArrowShapeUtil.tsx @@ -495,7 +495,7 @@ export class ArrowShapeUtil extends ShapeUtil { // eslint-disable-next-line react-hooks/rules-of-hooks const changeIndex = React.useMemo(() => { - return this.editor.isSafari ? (globalRenderIndex += 1) : 0 + return this.editor.environment.isSafari ? (globalRenderIndex += 1) : 0 // eslint-disable-next-line react-hooks/exhaustive-deps }, [shape]) diff --git a/packages/tldraw/src/lib/shapes/arrow/toolStates/Idle.ts b/packages/tldraw/src/lib/shapes/arrow/toolStates/Idle.ts index 4abe4ea4d..bd4e7f7df 100644 --- a/packages/tldraw/src/lib/shapes/arrow/toolStates/Idle.ts +++ b/packages/tldraw/src/lib/shapes/arrow/toolStates/Idle.ts @@ -8,7 +8,10 @@ export class Idle extends StateNode { } override onEnter = () => { - this.editor.updateInstanceState({ cursor: { type: 'cross', rotation: 0 } }, true) + this.editor.updateInstanceState( + { cursor: { type: 'cross', rotation: 0 } }, + { ephemeral: true, squashing: true } + ) } override onCancel = () => { diff --git a/packages/tldraw/src/lib/shapes/arrow/toolStates/Pointing.ts b/packages/tldraw/src/lib/shapes/arrow/toolStates/Pointing.ts index 73de24bea..6890dd40c 100644 --- a/packages/tldraw/src/lib/shapes/arrow/toolStates/Pointing.ts +++ b/packages/tldraw/src/lib/shapes/arrow/toolStates/Pointing.ts @@ -1,4 +1,4 @@ -import { StateNode, TLArrowShape, TLEventHandlers, createShapeId } from '@tldraw/editor' +import { StateNode, TLArrowShape, TLEventHandlers, TLHandle, createShapeId } from '@tldraw/editor' export class Pointing extends StateNode { static override id = 'pointing' @@ -7,6 +7,8 @@ export class Pointing extends StateNode { markId = '' + initialEndHandle = {} as TLHandle + override onEnter = () => { this.didTimeout = false @@ -19,7 +21,7 @@ export class Pointing extends StateNode { if (!target) { this.createArrowShape() } else { - this.editor.setHintingIds([target.id]) + this.editor.setHintingShapeIds([target.id]) } this.startPreciseTimeout() @@ -27,7 +29,7 @@ export class Pointing extends StateNode { override onExit = () => { this.shape = undefined - this.editor.setHintingIds([]) + this.editor.setHintingShapeIds([]) this.clearPreciseTimeout() } @@ -43,7 +45,7 @@ export class Pointing extends StateNode { this.editor.setCurrentTool('select.dragging_handle', { shape: this.shape, - handle: this.editor.getHandles(this.shape)!.find((h) => h.id === 'end')!, + handle: this.initialEndHandle, isCreating: true, onInteractionEnd: 'arrow', }) @@ -71,7 +73,7 @@ export class Pointing extends StateNode { // the arrow might not have been created yet! this.editor.bailToMark(this.markId) } - this.editor.setHintingIds([]) + this.editor.setHintingShapeIds([]) this.parent.transition('idle', {}) } @@ -80,9 +82,9 @@ export class Pointing extends StateNode { const id = createShapeId() - this.markId = this.editor.mark(`creating:${id}`) + this.markId = `creating:${id}` - this.editor.createShapes([ + this.editor.mark(this.markId).createShapes([ { id, type: 'arrow', @@ -107,28 +109,27 @@ export class Pointing extends StateNode { if (change) { const startTerminal = change.props?.start if (startTerminal?.type === 'binding') { - this.editor.setHintingIds([startTerminal.boundShapeId]) + this.editor.setHintingShapeIds([startTerminal.boundShapeId]) } - this.editor.updateShapes([change], true) + // squash me + this.editor.updateShape(change, { squashing: true }) } // Cache the current shape after those changes this.shape = this.editor.getShape(id) - this.editor.select(id) + this.editor.setSelectedShapeIds([id], true) + + this.initialEndHandle = this.editor.getHandles(this.shape!)!.find((h) => h.id === 'end')! } updateArrowShapeEndHandle() { - const shape = this.shape - if (!shape) throw Error(`expected shape`) - - const handles = this.editor.getHandles(shape) - if (!handles) throw Error(`expected handles for arrow`) + const util = this.editor.getShapeUtil('arrow') // end update { - const util = this.editor.getShapeUtil('arrow') + const shape = this.editor.getShape(this.shape!.id)! as TLArrowShape const point = this.editor.getPointInShapeSpace(shape, this.editor.inputs.currentPagePoint) - const endHandle = handles.find((h) => h.id === 'end')! + const endHandle = this.editor.getHandles(shape)!.find((h) => h.id === 'end')! const change = util.onHandleChange?.(shape, { handle: { ...endHandle, x: point.x, y: point.y }, isPrecise: false, // sure about that? @@ -137,28 +138,28 @@ export class Pointing extends StateNode { if (change) { const endTerminal = change.props?.end if (endTerminal?.type === 'binding') { - this.editor.setHintingIds([endTerminal.boundShapeId]) + this.editor.setHintingShapeIds([endTerminal.boundShapeId]) } - this.editor.updateShapes([change], true) + this.editor.updateShape(change, { squashing: true }) } } // start update { - const util = this.editor.getShapeUtil('arrow') - const startHandle = handles.find((h) => h.id === 'start')! + const shape = this.editor.getShape(this.shape!.id)! as TLArrowShape + const startHandle = this.editor.getHandles(shape)!.find((h) => h.id === 'start')! const change = util.onHandleChange?.(shape, { handle: { ...startHandle, x: 0, y: 0 }, isPrecise: this.didTimeout, // sure about that? }) if (change) { - this.editor.updateShapes([change], true) + this.editor.updateShape(change, { squashing: true }) } } // Cache the current shape after those changes - this.shape = this.editor.getShape(shape.id) + this.shape = this.editor.getShape(this.shape!.id) } private preciseTimeout = -1 diff --git a/packages/tldraw/src/lib/shapes/draw/toolStates/Drawing.ts b/packages/tldraw/src/lib/shapes/draw/toolStates/Drawing.ts index 6eb50a98d..9ecc2aa32 100644 --- a/packages/tldraw/src/lib/shapes/draw/toolStates/Drawing.ts +++ b/packages/tldraw/src/lib/shapes/draw/toolStates/Drawing.ts @@ -364,7 +364,7 @@ export class Drawing extends StateNode { ) } - this.editor.updateShapes([shapePartial], true) + this.editor.updateShape(shapePartial, { squashing: true }) } break } @@ -424,7 +424,7 @@ export class Drawing extends StateNode { ) } - this.editor.updateShapes([shapePartial], true) + this.editor.updateShape(shapePartial, { squashing: true }) } break @@ -566,7 +566,7 @@ export class Drawing extends StateNode { ) } - this.editor.updateShapes([shapePartial], true) + this.editor.updateShape(shapePartial, { squashing: true }) break } @@ -611,11 +611,14 @@ export class Drawing extends StateNode { ) } - this.editor.updateShapes([shapePartial], true) + this.editor.updateShape(shapePartial, { squashing: true }) // Set a maximum length for the lines array; after 200 points, complete the line. if (newPoints.length > 500) { - this.editor.updateShapes([{ id, type: this.shapeType, props: { isComplete: true } }]) + this.editor.updateShape( + { id, type: this.shapeType, props: { isComplete: true } }, + { squashing: true } + ) const { currentPagePoint } = this.editor.inputs diff --git a/packages/tldraw/src/lib/shapes/draw/toolStates/Idle.ts b/packages/tldraw/src/lib/shapes/draw/toolStates/Idle.ts index 4460bef28..9da9dbb63 100644 --- a/packages/tldraw/src/lib/shapes/draw/toolStates/Idle.ts +++ b/packages/tldraw/src/lib/shapes/draw/toolStates/Idle.ts @@ -8,7 +8,10 @@ export class Idle extends StateNode { } override onEnter = () => { - this.editor.updateInstanceState({ cursor: { type: 'cross', rotation: 0 } }, true) + this.editor.updateInstanceState( + { cursor: { type: 'cross', rotation: 0 } }, + { ephemeral: true, squashing: true } + ) } override onCancel = () => { diff --git a/packages/tldraw/src/lib/shapes/frame/FrameShapeTool.test.ts b/packages/tldraw/src/lib/shapes/frame/FrameShapeTool.test.ts index 2f6769d59..7bd118f33 100644 --- a/packages/tldraw/src/lib/shapes/frame/FrameShapeTool.test.ts +++ b/packages/tldraw/src/lib/shapes/frame/FrameShapeTool.test.ts @@ -12,44 +12,44 @@ afterEach(() => { describe(FrameShapeTool, () => { it('Creates frame shapes on click-and-drag, supports undo and redo', () => { - expect(editor.shapesOnCurrentPage.length).toBe(0) + expect(editor.currentPageShapes.length).toBe(0) editor.setCurrentTool('frame') editor.pointerDown(50, 50) editor.pointerMove(100, 100) editor.pointerUp(100, 100) - expect(editor.shapesOnCurrentPage.length).toBe(1) - expect(editor.shapesOnCurrentPage[0]?.type).toBe('frame') - expect(editor.selectedShapeIds[0]).toBe(editor.shapesOnCurrentPage[0]?.id) + expect(editor.currentPageShapes.length).toBe(1) + expect(editor.currentPageShapes[0]?.type).toBe('frame') + expect(editor.selectedShapeIds[0]).toBe(editor.currentPageShapes[0]?.id) editor.undo() - expect(editor.shapesOnCurrentPage.length).toBe(0) + expect(editor.currentPageShapes.length).toBe(0) editor.redo() - expect(editor.shapesOnCurrentPage.length).toBe(1) + expect(editor.currentPageShapes.length).toBe(1) }) it('Creates frame shapes on click, supports undo and redo', () => { - expect(editor.shapesOnCurrentPage.length).toBe(0) + expect(editor.currentPageShapes.length).toBe(0) editor.setCurrentTool('frame') editor.pointerDown(50, 50) editor.pointerUp(50, 50) - expect(editor.shapesOnCurrentPage.length).toBe(1) - expect(editor.shapesOnCurrentPage[0]?.type).toBe('frame') - expect(editor.selectedShapeIds[0]).toBe(editor.shapesOnCurrentPage[0]?.id) + expect(editor.currentPageShapes.length).toBe(1) + expect(editor.currentPageShapes[0]?.type).toBe('frame') + expect(editor.selectedShapeIds[0]).toBe(editor.currentPageShapes[0]?.id) editor.undo() - expect(editor.shapesOnCurrentPage.length).toBe(0) + expect(editor.currentPageShapes.length).toBe(0) editor.redo() - expect(editor.shapesOnCurrentPage.length).toBe(1) + expect(editor.currentPageShapes.length).toBe(1) }) }) @@ -114,22 +114,22 @@ describe('When in the pointing state', () => { }) it('Creates a frame and returns to select tool on pointer up', () => { - expect(editor.shapesOnCurrentPage.length).toBe(0) + expect(editor.currentPageShapes.length).toBe(0) editor.setCurrentTool('frame') editor.pointerDown(50, 50) editor.pointerUp(50, 50) editor.expectPathToBe('root.select.idle') - expect(editor.shapesOnCurrentPage.length).toBe(1) + expect(editor.currentPageShapes.length).toBe(1) }) it('Creates a frame and returns to frame.idle on pointer up if tool lock is enabled', () => { editor.updateInstanceState({ isToolLocked: true }) - expect(editor.shapesOnCurrentPage.length).toBe(0) + expect(editor.currentPageShapes.length).toBe(0) editor.setCurrentTool('frame') editor.pointerDown(50, 50) editor.pointerUp(50, 50) editor.expectPathToBe('root.frame.idle') - expect(editor.shapesOnCurrentPage.length).toBe(1) + expect(editor.currentPageShapes.length).toBe(1) }) }) diff --git a/packages/tldraw/src/lib/shapes/frame/components/FrameLabelInput.tsx b/packages/tldraw/src/lib/shapes/frame/components/FrameLabelInput.tsx index 8388946a6..2b39a8803 100644 --- a/packages/tldraw/src/lib/shapes/frame/components/FrameLabelInput.tsx +++ b/packages/tldraw/src/lib/shapes/frame/components/FrameLabelInput.tsx @@ -15,7 +15,7 @@ export const FrameLabelInput = forwardRef< // and sending us back into edit mode e.stopPropagation() e.currentTarget.blur() - editor.setEditingId(null) + editor.setEditingShapeId(null) } }, [editor] @@ -30,16 +30,7 @@ export const FrameLabelInput = forwardRef< const value = e.currentTarget.value.trim() if (name === value) return - editor.updateShapes( - [ - { - id, - type: 'frame', - props: { name: value }, - }, - ], - true - ) + editor.updateShape({ id, type: 'frame', props: { name: value } }, { squashing: true }) }, [id, editor] ) @@ -53,16 +44,7 @@ export const FrameLabelInput = forwardRef< const value = e.currentTarget.value if (name === value) return - editor.updateShapes( - [ - { - id, - type: 'frame', - props: { name: value }, - }, - ], - true - ) + editor.updateShape({ id, type: 'frame', props: { name: value } }, { squashing: true }) }, [id, editor] ) diff --git a/packages/tldraw/src/lib/shapes/geo/GeoShapeTool.test.ts b/packages/tldraw/src/lib/shapes/geo/GeoShapeTool.test.ts index f5ebb2adc..2c5ddd88a 100644 --- a/packages/tldraw/src/lib/shapes/geo/GeoShapeTool.test.ts +++ b/packages/tldraw/src/lib/shapes/geo/GeoShapeTool.test.ts @@ -12,44 +12,44 @@ afterEach(() => { describe(GeoShapeTool, () => { it('Creates geo shapes on click-and-drag, supports undo and redo', () => { - expect(editor.shapesOnCurrentPage.length).toBe(0) + expect(editor.currentPageShapes.length).toBe(0) editor.setCurrentTool('geo') editor.pointerDown(50, 50) editor.pointerMove(100, 100) editor.pointerUp() - expect(editor.shapesOnCurrentPage.length).toBe(1) - expect(editor.shapesOnCurrentPage[0]?.type).toBe('geo') - expect(editor.selectedShapeIds[0]).toBe(editor.shapesOnCurrentPage[0]?.id) + expect(editor.currentPageShapes.length).toBe(1) + expect(editor.currentPageShapes[0]?.type).toBe('geo') + expect(editor.selectedShapeIds[0]).toBe(editor.currentPageShapes[0]?.id) editor.undo() - expect(editor.shapesOnCurrentPage.length).toBe(0) + expect(editor.currentPageShapes.length).toBe(0) editor.redo() - expect(editor.shapesOnCurrentPage.length).toBe(1) + expect(editor.currentPageShapes.length).toBe(1) }) it('Creates geo shapes on click, supports undo and redo', () => { - expect(editor.shapesOnCurrentPage.length).toBe(0) + expect(editor.currentPageShapes.length).toBe(0) editor.setCurrentTool('geo') editor.pointerDown(50, 50) editor.pointerUp(50, 50) - expect(editor.shapesOnCurrentPage.length).toBe(1) - expect(editor.shapesOnCurrentPage[0]?.type).toBe('geo') - expect(editor.selectedShapeIds[0]).toBe(editor.shapesOnCurrentPage[0]?.id) + expect(editor.currentPageShapes.length).toBe(1) + expect(editor.currentPageShapes[0]?.type).toBe('geo') + expect(editor.selectedShapeIds[0]).toBe(editor.currentPageShapes[0]?.id) editor.undo() - expect(editor.shapesOnCurrentPage.length).toBe(0) + expect(editor.currentPageShapes.length).toBe(0) editor.redo() - expect(editor.shapesOnCurrentPage.length).toBe(1) + expect(editor.currentPageShapes.length).toBe(1) }) }) @@ -100,7 +100,7 @@ describe('When in the idle state', () => { editor.pointerMove(200, 200) editor.pointerUp(200, 200) - expect(editor.shapesOnCurrentPage.length).toBe(2) + expect(editor.currentPageShapes.length).toBe(2) editor.selectAll() expect(editor.selectedShapes.length).toBe(2) @@ -143,22 +143,22 @@ describe('When in the pointing state', () => { }) it('Creates a geo and returns to select tool on pointer up', () => { - expect(editor.shapesOnCurrentPage.length).toBe(0) + expect(editor.currentPageShapes.length).toBe(0) editor.setCurrentTool('geo') editor.pointerDown(50, 50) editor.pointerUp(50, 50) editor.expectPathToBe('root.select.idle') - expect(editor.shapesOnCurrentPage.length).toBe(1) + expect(editor.currentPageShapes.length).toBe(1) }) it('Creates a geo and returns to geo.idle on pointer up if tool lock is enabled', () => { editor.updateInstanceState({ isToolLocked: true }) - expect(editor.shapesOnCurrentPage.length).toBe(0) + expect(editor.currentPageShapes.length).toBe(0) editor.setCurrentTool('geo') editor.pointerDown(50, 50) editor.pointerUp(50, 50) editor.expectPathToBe('root.geo.idle') - expect(editor.shapesOnCurrentPage.length).toBe(1) + expect(editor.currentPageShapes.length).toBe(1) }) }) diff --git a/packages/tldraw/src/lib/shapes/geo/toolStates/Idle.ts b/packages/tldraw/src/lib/shapes/geo/toolStates/Idle.ts index 5f81bb51e..443476734 100644 --- a/packages/tldraw/src/lib/shapes/geo/toolStates/Idle.ts +++ b/packages/tldraw/src/lib/shapes/geo/toolStates/Idle.ts @@ -8,7 +8,10 @@ export class Idle extends StateNode { } override onEnter = () => { - this.editor.updateInstanceState({ cursor: { type: 'cross', rotation: 0 } }, true) + this.editor.updateInstanceState( + { cursor: { type: 'cross', rotation: 0 } }, + { ephemeral: true, squashing: true } + ) } override onKeyUp: TLEventHandlers['onKeyUp'] = (info) => { @@ -17,7 +20,7 @@ export class Idle extends StateNode { if (shape && this.editor.isShapeOfType(shape, 'geo')) { // todo: ensure that this only works with the most recently created shape, not just any geo shape that happens to be selected at the time this.editor.mark('editing shape') - this.editor.setEditingId(shape.id) + this.editor.setEditingShapeId(shape.id) this.editor.setCurrentTool('select.editing_shape', { ...info, target: 'shape', diff --git a/packages/tldraw/src/lib/shapes/line/LineShapeTool.test.ts b/packages/tldraw/src/lib/shapes/line/LineShapeTool.test.ts index 668a1ad6f..b3924b24d 100644 --- a/packages/tldraw/src/lib/shapes/line/LineShapeTool.test.ts +++ b/packages/tldraw/src/lib/shapes/line/LineShapeTool.test.ts @@ -15,9 +15,9 @@ it('enters the line state', () => { describe('When in the idle state', () => { it('enters the pointing state and creates a shape on pointer down', () => { - const shapesBefore = editor.shapesOnCurrentPage.length + const shapesBefore = editor.currentPageShapes.length editor.setCurrentTool('line').pointerDown(0, 0, { target: 'canvas' }) - const shapesAfter = editor.shapesOnCurrentPage.length + const shapesAfter = editor.currentPageShapes.length expect(shapesAfter).toBe(shapesBefore + 1) editor.expectPathToBe('root.line.pointing') }) @@ -31,18 +31,18 @@ describe('When in the idle state', () => { describe('When in the pointing state', () => { it('createes on pointer up', () => { - const shapesBefore = editor.shapesOnCurrentPage.length + const shapesBefore = editor.currentPageShapes.length editor.setCurrentTool('line').pointerDown(0, 0, { target: 'canvas' }).pointerUp(0, 0) - const shapesAfter = editor.shapesOnCurrentPage.length + const shapesAfter = editor.currentPageShapes.length expect(shapesAfter).toBe(shapesBefore + 1) expect(editor.hintingShapeIds.length).toBe(0) editor.expectPathToBe('root.line.idle') }) it('bails on cancel', () => { - const shapesBefore = editor.shapesOnCurrentPage.length + const shapesBefore = editor.currentPageShapes.length editor.setCurrentTool('line').pointerDown(0, 0, { target: 'canvas' }).cancel() - const shapesAfter = editor.shapesOnCurrentPage.length + const shapesAfter = editor.currentPageShapes.length expect(shapesAfter).toBe(shapesBefore) expect(editor.hintingShapeIds.length).toBe(0) editor.expectPathToBe('root.line.idle') @@ -58,7 +58,7 @@ describe('When in the pointing state', () => { describe('When dragging the line', () => { it('updates the line on pointer move', () => { editor.setCurrentTool('line').pointerDown(0, 0, { target: 'canvas' }).pointerMove(10, 10) - const line = editor.shapesOnCurrentPage[editor.shapesOnCurrentPage.length - 1] + const line = editor.currentPageShapes[editor.currentPageShapes.length - 1] editor.expectShapeToMatch(line, { id: line.id, type: 'line', @@ -75,13 +75,13 @@ describe('When dragging the line', () => { }) it('returns to select.idle, keeping shape, on pointer up', () => { - const shapesBefore = editor.shapesOnCurrentPage.length + const shapesBefore = editor.currentPageShapes.length editor .setCurrentTool('line') .pointerDown(0, 0, { target: 'canvas' }) .pointerMove(10, 10) .pointerUp(10, 10) - const shapesAfter = editor.shapesOnCurrentPage.length + const shapesAfter = editor.currentPageShapes.length expect(shapesAfter).toBe(shapesBefore + 1) expect(editor.hintingShapeIds.length).toBe(0) editor.expectPathToBe('root.select.idle') @@ -89,26 +89,26 @@ describe('When dragging the line', () => { it('returns to line.idle, keeping shape, on pointer up if tool lock is enabled', () => { editor.updateInstanceState({ isToolLocked: true }) - const shapesBefore = editor.shapesOnCurrentPage.length + const shapesBefore = editor.currentPageShapes.length editor .setCurrentTool('line') .pointerDown(0, 0, { target: 'canvas' }) .pointerMove(10, 10) .pointerUp(10, 10) - const shapesAfter = editor.shapesOnCurrentPage.length + const shapesAfter = editor.currentPageShapes.length expect(shapesAfter).toBe(shapesBefore + 1) expect(editor.hintingShapeIds.length).toBe(0) editor.expectPathToBe('root.line.idle') }) it('bails on cancel', () => { - const shapesBefore = editor.shapesOnCurrentPage.length + const shapesBefore = editor.currentPageShapes.length editor .setCurrentTool('line') .pointerDown(0, 0, { target: 'canvas' }) .pointerMove(10, 10) .cancel() - const shapesAfter = editor.shapesOnCurrentPage.length + const shapesAfter = editor.currentPageShapes.length expect(shapesAfter).toBe(shapesBefore) editor.expectPathToBe('root.line.idle') }) @@ -126,7 +126,7 @@ describe('When extending the line with the shift-key in tool-lock mode', () => { .pointerDown(20, 10, { target: 'canvas' }) .pointerUp(20, 10) - const line = editor.shapesOnCurrentPage[editor.shapesOnCurrentPage.length - 1] + const line = editor.currentPageShapes[editor.currentPageShapes.length - 1] assert(editor.isShapeOfType(line, 'line')) const handles = Object.values(line.props.handles) expect(handles.length).toBe(3) @@ -143,7 +143,7 @@ describe('When extending the line with the shift-key in tool-lock mode', () => { .pointerMove(30, 10) .pointerUp(30, 10) - const line = editor.shapesOnCurrentPage[editor.shapesOnCurrentPage.length - 1] + const line = editor.currentPageShapes[editor.currentPageShapes.length - 1] assert(editor.isShapeOfType(line, 'line')) const handles = Object.values(line.props.handles) expect(handles.length).toBe(3) @@ -161,7 +161,7 @@ describe('When extending the line with the shift-key in tool-lock mode', () => { .pointerMove(30, 10) .pointerUp(30, 10) - const line = editor.shapesOnCurrentPage[editor.shapesOnCurrentPage.length - 1] + const line = editor.currentPageShapes[editor.currentPageShapes.length - 1] assert(editor.isShapeOfType(line, 'line')) const handles = Object.values(line.props.handles) expect(handles.length).toBe(3) @@ -181,7 +181,7 @@ describe('When extending the line with the shift-key in tool-lock mode', () => { .pointerMove(30, 10) .pointerUp(30, 10) - const line = editor.shapesOnCurrentPage[editor.shapesOnCurrentPage.length - 1] + const line = editor.currentPageShapes[editor.currentPageShapes.length - 1] assert(editor.isShapeOfType(line, 'line')) const handles = Object.values(line.props.handles) expect(handles.length).toBe(3) @@ -203,7 +203,7 @@ describe('When extending the line with the shift-key in tool-lock mode', () => { .pointerMove(40, 10) .pointerUp(40, 10) - const line = editor.shapesOnCurrentPage[editor.shapesOnCurrentPage.length - 1] + const line = editor.currentPageShapes[editor.currentPageShapes.length - 1] assert(editor.isShapeOfType(line, 'line')) const handles = Object.values(line.props.handles) expect(handles.length).toBe(3) diff --git a/packages/tldraw/src/lib/shapes/line/LineShapeUtil.test.ts b/packages/tldraw/src/lib/shapes/line/LineShapeUtil.test.ts index ecbe88ebf..11158e29f 100644 --- a/packages/tldraw/src/lib/shapes/line/LineShapeUtil.test.ts +++ b/packages/tldraw/src/lib/shapes/line/LineShapeUtil.test.ts @@ -62,7 +62,7 @@ describe('Translating', () => { editor.select(id) const shape = editor.getShape(id)! - shape.rotation = Math.PI / 2 + editor.updateShape({ ...shape, rotation: Math.PI / 2 }) editor.pointerDown(250, 250, { target: 'shape', shape: shape }) editor.pointerMove(300, 400) // Move shape by 50, 150 @@ -195,7 +195,7 @@ describe('Misc', () => { editor.pointerMove(50, 50) // Move shape by 25, 25 editor.pointerUp().keyUp('Alt') - expect(Array.from(editor.shapeIdsOnCurrentPage.values()).length).toEqual(2) + expect(Array.from(editor.currentPageShapeIds.values()).length).toEqual(2) }) it('deletes', () => { @@ -207,7 +207,7 @@ describe('Misc', () => { editor.pointerMove(50, 50) // Move shape by 25, 25 editor.pointerUp().keyUp('Alt') - let ids = Array.from(editor.shapeIdsOnCurrentPage.values()) + let ids = Array.from(editor.currentPageShapeIds.values()) expect(ids.length).toEqual(2) const duplicate = ids.filter((i) => i !== id)[0] @@ -215,7 +215,7 @@ describe('Misc', () => { editor.deleteShapes(editor.selectedShapeIds) - ids = Array.from(editor.shapeIdsOnCurrentPage.values()) + ids = Array.from(editor.currentPageShapeIds.values()) expect(ids.length).toEqual(1) expect(ids[0]).toEqual(id) }) diff --git a/packages/tldraw/src/lib/shapes/line/__snapshots__/LineShapeUtil.test.ts.snap b/packages/tldraw/src/lib/shapes/line/__snapshots__/LineShapeUtil.test.ts.snap index 2e8f62f9e..029cc8780 100644 --- a/packages/tldraw/src/lib/shapes/line/__snapshots__/LineShapeUtil.test.ts.snap +++ b/packages/tldraw/src/lib/shapes/line/__snapshots__/LineShapeUtil.test.ts.snap @@ -7,7 +7,7 @@ Object { "isLocked": false, "meta": Object {}, "opacity": 1, - "parentId": "page:id51", + "parentId": "page:id60", "props": Object { "color": "black", "dash": "draw", diff --git a/packages/tldraw/src/lib/shapes/line/toolStates/Idle.ts b/packages/tldraw/src/lib/shapes/line/toolStates/Idle.ts index 01c0ef338..c5000456d 100644 --- a/packages/tldraw/src/lib/shapes/line/toolStates/Idle.ts +++ b/packages/tldraw/src/lib/shapes/line/toolStates/Idle.ts @@ -7,7 +7,10 @@ export class Idle extends StateNode { override onEnter = (info: { shapeId: TLShapeId }) => { this.shapeId = info.shapeId - this.editor.updateInstanceState({ cursor: { type: 'cross', rotation: 0 } }, true) + this.editor.updateInstanceState( + { cursor: { type: 'cross', rotation: 0 } }, + { ephemeral: true, squashing: true } + ) } override onPointerDown: TLEventHandlers['onPointerDown'] = () => { diff --git a/packages/tldraw/src/lib/shapes/line/toolStates/Pointing.ts b/packages/tldraw/src/lib/shapes/line/toolStates/Pointing.ts index 9e6a7fff1..f1b8d69ff 100644 --- a/packages/tldraw/src/lib/shapes/line/toolStates/Pointing.ts +++ b/packages/tldraw/src/lib/shapes/line/toolStates/Pointing.ts @@ -30,7 +30,8 @@ export class Pointing extends StateNode { const shape = info.shapeId && this.editor.getShape(info.shapeId) if (shape) { - this.markId = this.editor.mark(`creating:${shape.id}`) + this.markId = `creating:${shape.id}` + this.editor.mark(this.markId) this.shape = shape if (inputs.shiftKey) { @@ -85,18 +86,20 @@ export class Pointing extends StateNode { } else { const id = createShapeId() - this.markId = this.editor.mark(`creating:${id}`) + this.markId = `creating:${id}` - this.editor.createShapes([ - { - id, - type: 'line', - x: currentPagePoint.x, - y: currentPagePoint.y, - }, - ]) + this.editor + .mark(this.markId) + .createShapes([ + { + id, + type: 'line', + x: currentPagePoint.x, + y: currentPagePoint.y, + }, + ]) + .select(id) - this.editor.select(id) this.shape = this.editor.getShape(id)! } } diff --git a/packages/tldraw/src/lib/shapes/note/NoteShapeTool.test.ts b/packages/tldraw/src/lib/shapes/note/NoteShapeTool.test.ts index ec75c42d8..b9d77d8b3 100644 --- a/packages/tldraw/src/lib/shapes/note/NoteShapeTool.test.ts +++ b/packages/tldraw/src/lib/shapes/note/NoteShapeTool.test.ts @@ -12,47 +12,47 @@ afterEach(() => { describe(NoteShapeTool, () => { it('Creates note shapes on click-and-drag, supports undo and redo', () => { - expect(editor.shapesOnCurrentPage.length).toBe(0) + expect(editor.currentPageShapes.length).toBe(0) editor.setCurrentTool('note') editor.pointerDown(50, 50) editor.pointerMove(100, 100) editor.pointerUp(100, 100) - expect(editor.shapesOnCurrentPage.length).toBe(1) - expect(editor.shapesOnCurrentPage[0]?.type).toBe('note') - expect(editor.selectedShapeIds[0]).toBe(editor.shapesOnCurrentPage[0]?.id) + expect(editor.currentPageShapes.length).toBe(1) + expect(editor.currentPageShapes[0]?.type).toBe('note') + expect(editor.selectedShapeIds[0]).toBe(editor.currentPageShapes[0]?.id) editor.cancel() // leave edit mode editor.undo() // undoes the selection change editor.undo() - expect(editor.shapesOnCurrentPage.length).toBe(0) + expect(editor.currentPageShapes.length).toBe(0) editor.redo() - expect(editor.shapesOnCurrentPage.length).toBe(1) + expect(editor.currentPageShapes.length).toBe(1) }) it('Creates note shapes on click, supports undo and redo', () => { - expect(editor.shapesOnCurrentPage.length).toBe(0) + expect(editor.currentPageShapes.length).toBe(0) editor.setCurrentTool('note') editor.pointerDown(50, 50) editor.pointerUp(50, 50) - expect(editor.shapesOnCurrentPage.length).toBe(1) - expect(editor.shapesOnCurrentPage[0]?.type).toBe('note') - expect(editor.selectedShapeIds[0]).toBe(editor.shapesOnCurrentPage[0]?.id) + expect(editor.currentPageShapes.length).toBe(1) + expect(editor.currentPageShapes[0]?.type).toBe('note') + expect(editor.selectedShapeIds[0]).toBe(editor.currentPageShapes[0]?.id) editor.undo() - expect(editor.shapesOnCurrentPage.length).toBe(0) + expect(editor.currentPageShapes.length).toBe(0) editor.redo() - expect(editor.shapesOnCurrentPage.length).toBe(1) + expect(editor.currentPageShapes.length).toBe(1) }) }) @@ -126,21 +126,21 @@ describe('When in the pointing state', () => { }) it('Creates a note and begins editing on pointer up', () => { - expect(editor.shapesOnCurrentPage.length).toBe(0) + expect(editor.currentPageShapes.length).toBe(0) editor.setCurrentTool('note') editor.pointerDown(50, 50) editor.pointerUp(50, 50) editor.expectPathToBe('root.select.editing_shape') - expect(editor.shapesOnCurrentPage.length).toBe(1) + expect(editor.currentPageShapes.length).toBe(1) }) it('Creates a frame and returns to frame.idle on pointer up if tool lock is enabled', () => { editor.updateInstanceState({ isToolLocked: true }) - expect(editor.shapesOnCurrentPage.length).toBe(0) + expect(editor.currentPageShapes.length).toBe(0) editor.setCurrentTool('note') editor.pointerDown(50, 50) editor.pointerUp(50, 50) editor.expectPathToBe('root.note.idle') - expect(editor.shapesOnCurrentPage.length).toBe(1) + expect(editor.currentPageShapes.length).toBe(1) }) }) diff --git a/packages/tldraw/src/lib/shapes/note/toolStates/Idle.ts b/packages/tldraw/src/lib/shapes/note/toolStates/Idle.ts index 4abe4ea4d..bd4e7f7df 100644 --- a/packages/tldraw/src/lib/shapes/note/toolStates/Idle.ts +++ b/packages/tldraw/src/lib/shapes/note/toolStates/Idle.ts @@ -8,7 +8,10 @@ export class Idle extends StateNode { } override onEnter = () => { - this.editor.updateInstanceState({ cursor: { type: 'cross', rotation: 0 } }, true) + this.editor.updateInstanceState( + { cursor: { type: 'cross', rotation: 0 } }, + { ephemeral: true, squashing: true } + ) } override onCancel = () => { diff --git a/packages/tldraw/src/lib/shapes/note/toolStates/Pointing.ts b/packages/tldraw/src/lib/shapes/note/toolStates/Pointing.ts index 37272c3f9..8509ee954 100644 --- a/packages/tldraw/src/lib/shapes/note/toolStates/Pointing.ts +++ b/packages/tldraw/src/lib/shapes/note/toolStates/Pointing.ts @@ -65,7 +65,7 @@ export class Pointing extends StateNode { if (this.editor.instanceState.isToolLocked) { this.parent.transition('idle', {}) } else { - this.editor.setEditingId(this.shape.id) + this.editor.setEditingShapeId(this.shape.id) this.editor.setCurrentTool('select.editing_shape', { ...this.info, target: 'shape', @@ -87,19 +87,17 @@ export class Pointing extends StateNode { const id = createShapeId() this.markId = `creating:${id}` - this.editor.mark(this.markId) - - this.editor.createShapes( - [ + this.editor + .mark(this.markId) + .createShapes([ { id, type: 'note', x: originPagePoint.x, y: originPagePoint.y, }, - ], - true - ) + ]) + .select(id) const shape = this.editor.getShape(id)! const bounds = this.editor.getGeometry(shape).bounds diff --git a/packages/tldraw/src/lib/shapes/shared/defaultStyleDefs.tsx b/packages/tldraw/src/lib/shapes/shared/defaultStyleDefs.tsx index a225eb8bb..d3419ce98 100644 --- a/packages/tldraw/src/lib/shapes/shared/defaultStyleDefs.tsx +++ b/packages/tldraw/src/lib/shapes/shared/defaultStyleDefs.tsx @@ -250,7 +250,7 @@ function PatternFillDefForCanvas() { const { defs, isReady } = usePattern() useEffect(() => { - if (isReady && editor.isSafari) { + if (isReady && editor.environment.isSafari) { const htmlLayer = findHtmlLayerParent(containerRef.current!) if (htmlLayer) { // Wait for `patternContext` to be picked up diff --git a/packages/tldraw/src/lib/shapes/shared/useEditableText.ts b/packages/tldraw/src/lib/shapes/shared/useEditableText.ts index 5c16ad9e8..888253ccc 100644 --- a/packages/tldraw/src/lib/shapes/shared/useEditableText.ts +++ b/packages/tldraw/src/lib/shapes/shared/useEditableText.ts @@ -211,8 +211,8 @@ export function useEditableText { if (isEditableFromHover) { transact(() => { - editor.setEditingId(id) - editor.setHoveredId(id) + editor.setEditingShapeId(id) + editor.setHoveredShapeId(id) editor.setSelectedShapeIds([id]) }) } diff --git a/packages/tldraw/src/lib/shapes/text/TextShapeTool.test.ts b/packages/tldraw/src/lib/shapes/text/TextShapeTool.test.ts index a609ab014..6f2c17b3d 100644 --- a/packages/tldraw/src/lib/shapes/text/TextShapeTool.test.ts +++ b/packages/tldraw/src/lib/shapes/text/TextShapeTool.test.ts @@ -13,7 +13,7 @@ afterEach(() => { describe(TextShapeTool, () => { it('Creates text, edits it, undoes and redoes', () => { - expect(editor.shapesOnCurrentPage.length).toBe(0) + expect(editor.currentPageShapes.length).toBe(0) editor.setCurrentTool('text') editor.expectToBeIn('text.idle') editor.pointerDown(0, 0) @@ -22,28 +22,28 @@ describe(TextShapeTool, () => { editor.expectToBeIn('select.editing_shape') // This comes from the component, not the state chart editor.updateShapes([ - { ...editor.shapesOnCurrentPage[0]!, type: 'text', props: { text: 'Hello' } }, + { ...editor.currentPageShapes[0]!, type: 'text', props: { text: 'Hello' } }, ]) // Deselect the editing shape editor.cancel() editor.expectToBeIn('select.idle') - expect(editor.shapesOnCurrentPage.length).toBe(1) + expect(editor.currentPageShapes.length).toBe(1) editor.expectShapeToMatch({ - id: editor.shapesOnCurrentPage[0].id, + id: editor.currentPageShapes[0].id, type: 'text', props: { text: 'Hello' }, }) editor.undo() - expect(editor.shapesOnCurrentPage.length).toBe(0) + expect(editor.currentPageShapes.length).toBe(0) editor.redo() - expect(editor.shapesOnCurrentPage.length).toBe(1) + expect(editor.currentPageShapes.length).toBe(1) editor.expectShapeToMatch({ - id: editor.shapesOnCurrentPage[0].id, + id: editor.currentPageShapes[0].id, type: 'text', props: { text: 'Hello' }, }) @@ -71,7 +71,7 @@ describe('When in idle state', () => { editor.pointerDown(0, 0) editor.pointerUp() editor.expectToBeIn('select.editing_shape') - expect(editor.shapesOnCurrentPage.length).toBe(1) + expect(editor.currentPageShapes.length).toBe(1) }) it('returns to select on cancel', () => { @@ -87,7 +87,7 @@ describe('When in the pointing state', () => { editor.pointerDown(0, 0) editor.cancel() editor.expectToBeIn('text.idle') - expect(editor.shapesOnCurrentPage.length).toBe(0) + expect(editor.currentPageShapes.length).toBe(0) }) it('returns to idle on interrupt', () => { @@ -96,7 +96,7 @@ describe('When in the pointing state', () => { editor.expectToBeIn('text.pointing') editor.interrupt() editor.expectToBeIn('text.idle') - expect(editor.shapesOnCurrentPage.length).toBe(0) + expect(editor.currentPageShapes.length).toBe(0) }) it('transitions to select.resizing when dragging and edits on pointer up', () => { @@ -105,7 +105,7 @@ describe('When in the pointing state', () => { editor.pointerMove(10, 10) editor.expectToBeIn('select.resizing') editor.pointerUp() - expect(editor.shapesOnCurrentPage.length).toBe(1) + expect(editor.currentPageShapes.length).toBe(1) editor.expectToBeIn('select.editing_shape') }) @@ -115,8 +115,8 @@ describe('When in the pointing state', () => { const y = 0 editor.pointerDown(x, y) editor.pointerUp() - const bounds = editor.getPageBounds(editor.shapesOnCurrentPage[0])! - expect(editor.shapesOnCurrentPage[0]).toMatchObject({ + const bounds = editor.getPageBounds(editor.currentPageShapes[0])! + expect(editor.currentPageShapes[0]).toMatchObject({ x: x - bounds.width / 2, y: y - bounds.height / 2, }) @@ -131,7 +131,7 @@ describe('When resizing', () => { editor.expectToBeIn('select.resizing') editor.cancel() editor.expectToBeIn('text.idle') - expect(editor.shapesOnCurrentPage.length).toBe(0) + expect(editor.currentPageShapes.length).toBe(0) }) it('does not bails on interrupt while resizing', () => { @@ -140,7 +140,7 @@ describe('When resizing', () => { editor.pointerMove(100, 100) editor.expectToBeIn('select.resizing') editor.interrupt() - expect(editor.shapesOnCurrentPage.length).toBe(1) + expect(editor.currentPageShapes.length).toBe(1) }) it('preserves the top left when the text has a fixed width', () => { @@ -149,7 +149,7 @@ describe('When resizing', () => { const y = 0 editor.pointerDown(x, y) editor.pointerMove(x + 100, y + 100) - expect(editor.shapesOnCurrentPage[0]).toMatchObject({ + expect(editor.currentPageShapes[0]).toMatchObject({ x, y, }) diff --git a/packages/tldraw/src/lib/shapes/text/toolStates/Idle.ts b/packages/tldraw/src/lib/shapes/text/toolStates/Idle.ts index 8b3d32c60..134796b4e 100644 --- a/packages/tldraw/src/lib/shapes/text/toolStates/Idle.ts +++ b/packages/tldraw/src/lib/shapes/text/toolStates/Idle.ts @@ -23,7 +23,7 @@ export class Idle extends StateNode { if (this.editor.isShapeOfType(hitShape, 'text')) { requestAnimationFrame(() => { this.editor.setSelectedShapeIds([hitShape.id]) - this.editor.setEditingId(hitShape.id) + this.editor.setEditingShapeId(hitShape.id) this.editor.setCurrentTool('select.editing_shape', { ...info, target: 'shape', @@ -38,7 +38,10 @@ export class Idle extends StateNode { } override onEnter = () => { - this.editor.updateInstanceState({ cursor: { type: 'cross', rotation: 0 } }, true) + this.editor.updateInstanceState( + { cursor: { type: 'cross', rotation: 0 } }, + { ephemeral: true, squashing: true } + ) } override onKeyDown: TLEventHandlers['onKeyDown'] = (info) => { @@ -46,7 +49,7 @@ export class Idle extends StateNode { const shape = this.editor.selectedShapes[0] if (shape && this.editor.isShapeOfType(shape, 'geo')) { this.editor.setCurrentTool('select') - this.editor.setEditingId(shape.id) + this.editor.setEditingShapeId(shape.id) this.editor.root.current.value!.transition('editing_shape', { ...info, target: 'shape', diff --git a/packages/tldraw/src/lib/shapes/text/toolStates/Pointing.ts b/packages/tldraw/src/lib/shapes/text/toolStates/Pointing.ts index 7b7bdeca7..c5d4a34cd 100644 --- a/packages/tldraw/src/lib/shapes/text/toolStates/Pointing.ts +++ b/packages/tldraw/src/lib/shapes/text/toolStates/Pointing.ts @@ -8,7 +8,7 @@ export class Pointing extends StateNode { markId = '' override onExit = () => { - this.editor.setHintingIds([]) + this.editor.setHintingShapeIds([]) } override onPointerMove: TLEventHandlers['onPointerMove'] = (info) => { @@ -19,23 +19,23 @@ export class Pointing extends StateNode { const id = createShapeId() - this.markId = this.editor.mark(`creating:${id}`) - - this.editor.createShapes([ - { - id, - type: 'text', - x: originPagePoint.x, - y: originPagePoint.y, - props: { - text: '', - autoSize: false, - w: 20, + this.markId = `creating:${id}` + this.editor + .mark(this.markId) + .createShapes([ + { + id, + type: 'text', + x: originPagePoint.x, + y: originPagePoint.y, + props: { + text: '', + autoSize: false, + w: 20, + }, }, - }, - ]) - - this.editor.select(id) + ]) + .select(id) this.shape = this.editor.getShape(id) if (!this.shape) return @@ -72,8 +72,8 @@ export class Pointing extends StateNode { this.editor.mark('creating text shape') const id = createShapeId() const { x, y } = this.editor.inputs.currentPagePoint - this.editor.createShapes( - [ + this.editor + .createShapes([ { id, type: 'text', @@ -84,11 +84,10 @@ export class Pointing extends StateNode { autoSize: true, }, }, - ], - true - ) + ]) + .select(id) - this.editor.setEditingId(id) + this.editor.setEditingShapeId(id) this.editor.setCurrentTool('select') this.editor.root.current.value?.transition('editing_shape', {}) } diff --git a/packages/tldraw/src/lib/tools/EraserTool/EraserTool.ts b/packages/tldraw/src/lib/tools/EraserTool/EraserTool.ts index f01ae9832..1cb8a8c08 100644 --- a/packages/tldraw/src/lib/tools/EraserTool/EraserTool.ts +++ b/packages/tldraw/src/lib/tools/EraserTool/EraserTool.ts @@ -10,6 +10,9 @@ export class EraserTool extends StateNode { static override children = () => [Idle, Pointing, Erasing] override onEnter = () => { - this.editor.updateInstanceState({ cursor: { type: 'cross', rotation: 0 } }, true) + this.editor.updateInstanceState( + { cursor: { type: 'cross', rotation: 0 } }, + { ephemeral: true, squashing: true } + ) } } diff --git a/packages/tldraw/src/lib/tools/EraserTool/children/Erasing.ts b/packages/tldraw/src/lib/tools/EraserTool/children/Erasing.ts index b61425508..3ef4b2eb7 100644 --- a/packages/tldraw/src/lib/tools/EraserTool/children/Erasing.ts +++ b/packages/tldraw/src/lib/tools/EraserTool/children/Erasing.ts @@ -20,12 +20,14 @@ export class Erasing extends StateNode { private excludedShapeIds = new Set() override onEnter = (info: TLPointerEventInfo) => { - this.markId = this.editor.mark('erase scribble begin') this.info = info + this.markId = 'erase scribble begin' + this.editor.mark(this.markId) + const { originPagePoint } = this.editor.inputs this.excludedShapeIds = new Set( - this.editor.shapesOnCurrentPage + this.editor.currentPageShapes .filter( (shape) => this.editor.isShapeOrAncestorLocked(shape) || @@ -95,8 +97,8 @@ export class Erasing extends StateNode { update() { const { zoomLevel, - shapesOnCurrentPage, - erasingShapeIdsSet, + currentPageShapes, + erasingShapeIds, inputs: { currentPagePoint, previousPagePoint }, } = this.editor @@ -104,9 +106,9 @@ export class Erasing extends StateNode { this.pushPointToScribble() - const erasing = new Set(erasingShapeIdsSet) + const erasing = new Set(erasingShapeIds) - for (const shape of shapesOnCurrentPage) { + for (const shape of currentPageShapes) { if (this.editor.isShapeOfType(shape, 'group')) continue // Avoid testing masked shapes, unless the pointer is inside the mask @@ -128,17 +130,17 @@ export class Erasing extends StateNode { // Remove the hit shapes, except if they're in the list of excluded shapes // (these excluded shapes will be any frames or groups the pointer was inside of // when the user started erasing) - this.editor.setErasingIds([...erasing].filter((id) => !excludedShapeIds.has(id))) + this.editor.setErasingShapeIds([...erasing].filter((id) => !excludedShapeIds.has(id))) } complete() { this.editor.deleteShapes(this.editor.currentPageState.erasingShapeIds) - this.editor.setErasingIds([]) + this.editor.setErasingShapeIds([]) this.parent.transition('idle', {}) } cancel() { - this.editor.setErasingIds([]) + this.editor.setErasingShapeIds([]) this.editor.bailToMark(this.markId) this.parent.transition('idle', this.info) } diff --git a/packages/tldraw/src/lib/tools/EraserTool/children/Pointing.ts b/packages/tldraw/src/lib/tools/EraserTool/children/Pointing.ts index db6b072f0..843d7b4ca 100644 --- a/packages/tldraw/src/lib/tools/EraserTool/children/Pointing.ts +++ b/packages/tldraw/src/lib/tools/EraserTool/children/Pointing.ts @@ -13,7 +13,7 @@ export class Pointing extends StateNode { override onEnter = () => { const { inputs: { currentPagePoint }, - sortedShapesOnCurrentPage, + currentPageShapesSorted, zoomLevel, } = this.editor @@ -21,8 +21,8 @@ export class Pointing extends StateNode { const initialSize = erasing.size - for (let n = sortedShapesOnCurrentPage.length, i = n - 1; i >= 0; i--) { - const shape = sortedShapesOnCurrentPage[i] + for (let n = currentPageShapesSorted.length, i = n - 1; i >= 0; i--) { + const shape = currentPageShapesSorted[i] if (this.editor.isShapeOfType(shape, 'group')) { continue } @@ -46,7 +46,7 @@ export class Pointing extends StateNode { } } - this.editor.setErasingIds([...erasing]) + this.editor.setErasingShapeIds([...erasing]) } override onPointerMove: TLEventHandlers['onPointerMove'] = (info) => { @@ -79,12 +79,12 @@ export class Pointing extends StateNode { this.editor.deleteShapes(erasingShapeIds) } - this.editor.setErasingIds([]) + this.editor.setErasingShapeIds([]) this.parent.transition('idle', {}) } cancel() { - this.editor.setErasingIds([]) + this.editor.setErasingShapeIds([]) this.parent.transition('idle', {}) } } diff --git a/packages/tldraw/src/lib/tools/HandTool/children/Dragging.ts b/packages/tldraw/src/lib/tools/HandTool/children/Dragging.ts index e2dfae2a6..932529ef1 100644 --- a/packages/tldraw/src/lib/tools/HandTool/children/Dragging.ts +++ b/packages/tldraw/src/lib/tools/HandTool/children/Dragging.ts @@ -29,7 +29,7 @@ export class Dragging extends StateNode { const delta = Vec2d.Sub(currentScreenPoint, previousScreenPoint) if (Math.abs(delta.x) > 0 || Math.abs(delta.y) > 0) { - this.editor.pan(delta.x, delta.y) + this.editor.pan(delta) } } diff --git a/packages/tldraw/src/lib/tools/HandTool/children/Idle.ts b/packages/tldraw/src/lib/tools/HandTool/children/Idle.ts index c3dddb147..42eb616d4 100644 --- a/packages/tldraw/src/lib/tools/HandTool/children/Idle.ts +++ b/packages/tldraw/src/lib/tools/HandTool/children/Idle.ts @@ -4,7 +4,10 @@ export class Idle extends StateNode { static override id = 'idle' override onEnter = () => { - this.editor.updateInstanceState({ cursor: { type: 'grab', rotation: 0 } }, true) + this.editor.updateInstanceState( + { cursor: { type: 'grab', rotation: 0 } }, + { ephemeral: true, squashing: true } + ) } override onPointerDown: TLEventHandlers['onPointerDown'] = (info) => { diff --git a/packages/tldraw/src/lib/tools/HandTool/children/Pointing.ts b/packages/tldraw/src/lib/tools/HandTool/children/Pointing.ts index f4e7389ff..19fde51ce 100644 --- a/packages/tldraw/src/lib/tools/HandTool/children/Pointing.ts +++ b/packages/tldraw/src/lib/tools/HandTool/children/Pointing.ts @@ -5,7 +5,10 @@ export class Pointing extends StateNode { override onEnter = () => { this.editor.stopCameraAnimation() - this.editor.updateInstanceState({ cursor: { type: 'grabbing', rotation: 0 } }, true) + this.editor.updateInstanceState( + { cursor: { type: 'grabbing', rotation: 0 } }, + { ephemeral: true, squashing: true } + ) } override onPointerMove: TLEventHandlers['onPointerMove'] = (info) => { diff --git a/packages/tldraw/src/lib/tools/LaserTool/LaserTool.ts b/packages/tldraw/src/lib/tools/LaserTool/LaserTool.ts index dce62ea8f..408713ea2 100644 --- a/packages/tldraw/src/lib/tools/LaserTool/LaserTool.ts +++ b/packages/tldraw/src/lib/tools/LaserTool/LaserTool.ts @@ -9,6 +9,9 @@ export class LaserTool extends StateNode { static override children = () => [Idle, Lasering] override onEnter = () => { - this.editor.updateInstanceState({ cursor: { type: 'cross', rotation: 0 } }, true) + this.editor.updateInstanceState( + { cursor: { type: 'cross', rotation: 0 } }, + { ephemeral: true, squashing: true } + ) } } diff --git a/packages/tldraw/src/lib/tools/SelectTool/DragAndDropManager.ts b/packages/tldraw/src/lib/tools/SelectTool/DragAndDropManager.ts index 815037198..55da0b060 100644 --- a/packages/tldraw/src/lib/tools/SelectTool/DragAndDropManager.ts +++ b/packages/tldraw/src/lib/tools/SelectTool/DragAndDropManager.ts @@ -72,11 +72,11 @@ export class DragAndDropManager { .onDragShapesOver?.(nextDroppingShape, movingShapes) if (res && res.shouldHint) { - this.editor.setHintingIds([nextDroppingShape.id]) + this.editor.setHintingShapeIds([nextDroppingShape.id]) } } else { // If we're dropping onto the page, then clear hinting ids - this.editor.setHintingIds([]) + this.editor.setHintingShapeIds([]) } cb?.() @@ -103,7 +103,7 @@ export class DragAndDropManager { } this.droppingNodeTimer = null - this.editor.setHintingIds([]) + this.editor.setHintingShapeIds([]) } dispose = () => { diff --git a/packages/tldraw/src/lib/tools/SelectTool/SelectTool.ts b/packages/tldraw/src/lib/tools/SelectTool/SelectTool.ts index cf82955f2..5d09c1e58 100644 --- a/packages/tldraw/src/lib/tools/SelectTool/SelectTool.ts +++ b/packages/tldraw/src/lib/tools/SelectTool/SelectTool.ts @@ -43,7 +43,7 @@ export class SelectTool extends StateNode { override onExit = () => { if (this.editor.currentPageState.editingShapeId) { - this.editor.setEditingId(null) + this.editor.setEditingShapeId(null) } } } diff --git a/packages/tldraw/src/lib/tools/SelectTool/children/Brushing.ts b/packages/tldraw/src/lib/tools/SelectTool/children/Brushing.ts index f400c5d10..481379dcb 100644 --- a/packages/tldraw/src/lib/tools/SelectTool/children/Brushing.ts +++ b/packages/tldraw/src/lib/tools/SelectTool/children/Brushing.ts @@ -39,7 +39,7 @@ export class Brushing extends StateNode { } this.excludedShapeIds = new Set( - this.editor.shapesOnCurrentPage + this.editor.currentPageShapes .filter( (shape) => this.editor.isShapeOfType(shape, 'group') || @@ -96,7 +96,7 @@ export class Brushing extends StateNode { const { zoomLevel, currentPageId, - shapesOnCurrentPage, + currentPageShapes, inputs: { originPagePoint, currentPagePoint, shiftKey, ctrlKey }, } = this.editor @@ -118,8 +118,8 @@ export class Brushing extends StateNode { const { excludedShapeIds } = this - testAllShapes: for (let i = 0, n = shapesOnCurrentPage.length; i < n; i++) { - shape = shapesOnCurrentPage[i] + testAllShapes: for (let i = 0, n = currentPageShapes.length; i < n; i++) { + shape = currentPageShapes[i] if (excludedShapeIds.has(shape.id)) continue testAllShapes if (results.has(shape.id)) continue testAllShapes diff --git a/packages/tldraw/src/lib/tools/SelectTool/children/Crop/children/Idle.ts b/packages/tldraw/src/lib/tools/SelectTool/children/Crop/children/Idle.ts index 72620577a..f3a768145 100644 --- a/packages/tldraw/src/lib/tools/SelectTool/children/Crop/children/Idle.ts +++ b/packages/tldraw/src/lib/tools/SelectTool/children/Crop/children/Idle.ts @@ -5,7 +5,10 @@ export class Idle extends StateNode { static override id = 'idle' override onEnter = () => { - this.editor.updateInstanceState({ cursor: { type: 'default', rotation: 0 } }, true) + this.editor.updateInstanceState( + { cursor: { type: 'default', rotation: 0 } }, + { ephemeral: true, squashing: true } + ) const { onlySelectedShape } = this.editor @@ -14,21 +17,22 @@ export class Idle extends StateNode { // (which clears the cropping id) but still remain in this state. this.editor.on('change-history', this.cleanupCroppingState) - this.editor.mark('crop') - if (onlySelectedShape) { - this.editor.setCroppingId(onlySelectedShape.id) + this.editor.setCroppingShapeId(onlySelectedShape.id) } } override onExit: TLExitEventHandler = () => { - this.editor.updateInstanceState({ cursor: { type: 'default', rotation: 0 } }, true) + this.editor.updateInstanceState( + { cursor: { type: 'default', rotation: 0 } }, + { ephemeral: true, squashing: true } + ) this.editor.off('change-history', this.cleanupCroppingState) } override onCancel: TLEventHandlers['onCancel'] = () => { - this.editor.setCroppingId(null) + this.editor.setCroppingShapeId(null) this.editor.setCurrentTool('select.idle', {}) } @@ -36,7 +40,7 @@ export class Idle extends StateNode { if (this.editor.isMenuOpen) return if (info.ctrlKey) { - this.editor.setCroppingId(null) + this.editor.setCroppingShapeId(null) this.editor.setCurrentTool('select.brushing', info) return } @@ -66,7 +70,7 @@ export class Idle extends StateNode { return } else { if (this.editor.getShapeUtil(info.shape)?.canCrop(info.shape)) { - this.editor.setCroppingId(info.shape.id) + this.editor.setCroppingShapeId(info.shape.id) this.editor.setSelectedShapeIds([info.shape.id]) this.editor.setCurrentTool('select.crop.pointing_crop', info) } else { @@ -145,7 +149,7 @@ export class Idle extends StateNode { override onKeyUp: TLEventHandlers['onKeyUp'] = (info) => { switch (info.code) { case 'Enter': { - this.editor.setCroppingId(null) + this.editor.setCroppingShapeId(null) this.editor.setCurrentTool('select.idle', {}) break } @@ -153,7 +157,7 @@ export class Idle extends StateNode { } private cancel() { - this.editor.setCroppingId(null) + this.editor.setCroppingShapeId(null) this.editor.setCurrentTool('select.idle', {}) } diff --git a/packages/tldraw/src/lib/tools/SelectTool/children/Crop/children/TranslatingCrop.ts b/packages/tldraw/src/lib/tools/SelectTool/children/Crop/children/TranslatingCrop.ts index e774b5663..0077329ee 100644 --- a/packages/tldraw/src/lib/tools/SelectTool/children/Crop/children/TranslatingCrop.ts +++ b/packages/tldraw/src/lib/tools/SelectTool/children/Crop/children/TranslatingCrop.ts @@ -25,14 +25,20 @@ export class TranslatingCrop extends StateNode { ) => { this.info = info this.snapshot = this.createSnapshot() - this.editor.mark(this.markId) - this.editor.updateInstanceState({ cursor: { type: 'move', rotation: 0 } }, true) + + this.editor.updateInstanceState( + { cursor: { type: 'move', rotation: 0 } }, + { ephemeral: true, squashing: true } + ) this.updateShapes() } override onExit = () => { - this.editor.updateInstanceState({ cursor: { type: 'default', rotation: 0 } }, true) + this.editor.updateInstanceState( + { cursor: { type: 'default', rotation: 0 } }, + { ephemeral: true, squashing: true } + ) } override onPointerMove = () => { @@ -99,7 +105,7 @@ export class TranslatingCrop extends StateNode { const partial = getTranslateCroppedImageChange(this.editor, shape, delta) if (partial) { - this.editor.updateShapes([partial], true) + this.editor.updateShapes([partial], { squashing: true }) } } } diff --git a/packages/tldraw/src/lib/tools/SelectTool/children/Cropping.ts b/packages/tldraw/src/lib/tools/SelectTool/children/Cropping.ts index 13325f2b0..df1c4c6b4 100644 --- a/packages/tldraw/src/lib/tools/SelectTool/children/Cropping.ts +++ b/packages/tldraw/src/lib/tools/SelectTool/children/Cropping.ts @@ -37,7 +37,8 @@ export class Cropping extends StateNode { } ) => { this.info = info - this.markId = this.editor.mark('cropping') + this.markId = 'cropping' + this.editor.mark(this.markId) this.snapshot = this.createSnapshot() this.updateShapes() } @@ -199,7 +200,7 @@ export class Cropping extends StateNode { }, } - this.editor.updateShapes([partial], true) + this.editor.updateShapes([partial], { squashing: true }) this.updateCursor() } @@ -207,7 +208,7 @@ export class Cropping extends StateNode { if (this.info.onInteractionEnd) { this.editor.setCurrentTool(this.info.onInteractionEnd, this.info) } else { - this.editor.setCroppingId(null) + this.editor.setCroppingShapeId(null) this.parent.transition('idle', {}) } } @@ -217,7 +218,7 @@ export class Cropping extends StateNode { if (this.info.onInteractionEnd) { this.editor.setCurrentTool(this.info.onInteractionEnd, this.info) } else { - this.editor.setCroppingId(null) + this.editor.setCroppingShapeId(null) this.parent.transition('idle', {}) } } diff --git a/packages/tldraw/src/lib/tools/SelectTool/children/DraggingHandle.ts b/packages/tldraw/src/lib/tools/SelectTool/children/DraggingHandle.ts index 78713dec0..810b9855a 100644 --- a/packages/tldraw/src/lib/tools/SelectTool/children/DraggingHandle.ts +++ b/packages/tldraw/src/lib/tools/SelectTool/children/DraggingHandle.ts @@ -52,7 +52,8 @@ export class DraggingHandle extends StateNode { this.info = info this.parent.currentToolIdMask = info.onInteractionEnd this.shapeId = shape.id - this.markId = isCreating ? `creating:${shape.id}` : this.editor.mark('dragging handle') + this.markId = isCreating ? `creating:${shape.id}` : 'dragging handle' + if (!isCreating) this.editor.mark(this.markId) this.initialHandle = deepCopy(handle) this.initialPageTransform = this.editor.getPageTransform(shape)! this.initialPageRotation = this.initialPageTransform.rotation() @@ -60,7 +61,7 @@ export class DraggingHandle extends StateNode { this.editor.updateInstanceState( { cursor: { type: isCreating ? 'cross' : 'grabbing', rotation: 0 } }, - true + { ephemeral: true, squashing: true } ) // @@ -166,9 +167,12 @@ export class DraggingHandle extends StateNode { override onExit = () => { this.parent.currentToolIdMask = undefined - this.editor.setHintingIds([]) + this.editor.setHintingShapeIds([]) this.editor.snaps.clear() - this.editor.updateInstanceState({ cursor: { type: 'default', rotation: 0 } }, true) + this.editor.updateInstanceState( + { cursor: { type: 'default', rotation: 0 } }, + { ephemeral: true, squashing: true } + ) } private complete() { @@ -280,7 +284,7 @@ export class DraggingHandle extends StateNode { if (bindingAfter?.type === 'binding') { if (hintingShapeIds[0] !== bindingAfter.boundShapeId) { - editor.setHintingIds([bindingAfter.boundShapeId]) + editor.setHintingShapeIds([bindingAfter.boundShapeId]) this.pointingId = bindingAfter.boundShapeId this.isPrecise = pointerVelocity.len() < 0.5 || altKey this.isPreciseId = this.isPrecise ? bindingAfter.boundShapeId : null @@ -288,7 +292,7 @@ export class DraggingHandle extends StateNode { } } else { if (hintingShapeIds.length > 0) { - editor.setHintingIds([]) + editor.setHintingShapeIds([]) this.pointingId = null this.isPrecise = false this.isPreciseId = null @@ -298,7 +302,7 @@ export class DraggingHandle extends StateNode { } if (changes) { - editor.updateShapes([next], true) + editor.updateShapes([next], { squashing: false, ephemeral: false }) } } } diff --git a/packages/tldraw/src/lib/tools/SelectTool/children/EditingShape.ts b/packages/tldraw/src/lib/tools/SelectTool/children/EditingShape.ts index 120d3eeab..1ffd92fd2 100644 --- a/packages/tldraw/src/lib/tools/SelectTool/children/EditingShape.ts +++ b/packages/tldraw/src/lib/tools/SelectTool/children/EditingShape.ts @@ -19,7 +19,7 @@ export class EditingShape extends StateNode { if (!editingShapeId) return // Clear the editing shape - this.editor.setEditingId(null) + this.editor.setEditingShapeId(null) const shape = this.editor.getShape(editingShapeId)! const util = this.editor.getShapeUtil(shape) diff --git a/packages/tldraw/src/lib/tools/SelectTool/children/Idle.ts b/packages/tldraw/src/lib/tools/SelectTool/children/Idle.ts index a8a0b985c..792c4a99b 100644 --- a/packages/tldraw/src/lib/tools/SelectTool/children/Idle.ts +++ b/packages/tldraw/src/lib/tools/SelectTool/children/Idle.ts @@ -23,7 +23,10 @@ export class Idle extends StateNode { override onEnter = () => { this.parent.currentToolIdMask = undefined - this.editor.updateInstanceState({ cursor: { type: 'default', rotation: 0 } }, true) + this.editor.updateInstanceState( + { cursor: { type: 'default', rotation: 0 } }, + { ephemeral: true, squashing: true } + ) } override onPointerMove: TLEventHandlers['onPointerMove'] = () => { @@ -438,7 +441,7 @@ export class Idle extends StateNode { private startEditingShape(shape: TLShape, info: TLClickEventInfo | TLKeyboardEventInfo) { if (this.editor.isShapeOrAncestorLocked(shape) && shape.type !== 'embed') return this.editor.mark('editing shape') - this.editor.setEditingId(shape.id) + this.editor.setEditingShapeId(shape.id) this.parent.transition('editing_shape', info) } @@ -467,7 +470,7 @@ export class Idle extends StateNode { const shape = this.editor.getShape(id) if (!shape) return - this.editor.setEditingId(id) + this.editor.setEditingShapeId(id) this.editor.select(id) this.parent.transition('editing_shape', info) } diff --git a/packages/tldraw/src/lib/tools/SelectTool/children/PointingCropHandle.ts b/packages/tldraw/src/lib/tools/SelectTool/children/PointingCropHandle.ts index ccbe4c674..b77d8463f 100644 --- a/packages/tldraw/src/lib/tools/SelectTool/children/PointingCropHandle.ts +++ b/packages/tldraw/src/lib/tools/SelectTool/children/PointingCropHandle.ts @@ -29,11 +29,14 @@ export class PointingCropHandle extends StateNode { if (!selectedShape) return this.updateCursor(selectedShape) - this.editor.setCroppingId(selectedShape.id) + this.editor.setCroppingShapeId(selectedShape.id) } override onExit = () => { - this.editor.updateInstanceState({ cursor: { type: 'default', rotation: 0 } }, true) + this.editor.updateInstanceState( + { cursor: { type: 'default', rotation: 0 } }, + { ephemeral: true, squashing: true } + ) this.parent.currentToolIdMask = undefined } @@ -52,7 +55,7 @@ export class PointingCropHandle extends StateNode { if (this.info.onInteractionEnd) { this.editor.setCurrentTool(this.info.onInteractionEnd, this.info) } else { - this.editor.setCroppingId(null) + this.editor.setCroppingShapeId(null) this.parent.transition('idle', {}) } } @@ -73,7 +76,7 @@ export class PointingCropHandle extends StateNode { if (this.info.onInteractionEnd) { this.editor.setCurrentTool(this.info.onInteractionEnd, this.info) } else { - this.editor.setCroppingId(null) + this.editor.setCroppingShapeId(null) this.parent.transition('idle', {}) } } diff --git a/packages/tldraw/src/lib/tools/SelectTool/children/PointingHandle.ts b/packages/tldraw/src/lib/tools/SelectTool/children/PointingHandle.ts index 84442e8aa..54729a2e6 100644 --- a/packages/tldraw/src/lib/tools/SelectTool/children/PointingHandle.ts +++ b/packages/tldraw/src/lib/tools/SelectTool/children/PointingHandle.ts @@ -11,15 +11,21 @@ export class PointingHandle extends StateNode { const initialTerminal = (info.shape as TLArrowShape).props[info.handle.id as 'start' | 'end'] if (initialTerminal?.type === 'binding') { - this.editor.setHintingIds([initialTerminal.boundShapeId]) + this.editor.setHintingShapeIds([initialTerminal.boundShapeId]) } - this.editor.updateInstanceState({ cursor: { type: 'grabbing', rotation: 0 } }, true) + this.editor.updateInstanceState( + { cursor: { type: 'grabbing', rotation: 0 } }, + { ephemeral: true, squashing: true } + ) } override onExit = () => { - this.editor.setHintingIds([]) - this.editor.updateInstanceState({ cursor: { type: 'default', rotation: 0 } }, true) + this.editor.setHintingShapeIds([]) + this.editor.updateInstanceState( + { cursor: { type: 'default', rotation: 0 } }, + { ephemeral: true, squashing: true } + ) } override onPointerUp: TLEventHandlers['onPointerUp'] = () => { diff --git a/packages/tldraw/src/lib/tools/SelectTool/children/PointingRotateHandle.ts b/packages/tldraw/src/lib/tools/SelectTool/children/PointingRotateHandle.ts index 7c81f6d59..f3d790ec5 100644 --- a/packages/tldraw/src/lib/tools/SelectTool/children/PointingRotateHandle.ts +++ b/packages/tldraw/src/lib/tools/SelectTool/children/PointingRotateHandle.ts @@ -28,7 +28,10 @@ export class PointingRotateHandle extends StateNode { override onExit = () => { this.parent.currentToolIdMask = undefined - this.editor.updateInstanceState({ cursor: { type: 'default', rotation: 0 } }, true) + this.editor.updateInstanceState( + { cursor: { type: 'default', rotation: 0 } }, + { ephemeral: true, squashing: true } + ) } override onPointerMove = () => { diff --git a/packages/tldraw/src/lib/tools/SelectTool/children/Resizing.ts b/packages/tldraw/src/lib/tools/SelectTool/children/Resizing.ts index 6cc418e02..67302d457 100644 --- a/packages/tldraw/src/lib/tools/SelectTool/children/Resizing.ts +++ b/packages/tldraw/src/lib/tools/SelectTool/children/Resizing.ts @@ -56,13 +56,16 @@ export class Resizing extends StateNode { this.creationCursorOffset = creationCursorOffset if (info.isCreating) { - this.editor.updateInstanceState({ cursor: { type: 'cross', rotation: 0 } }, true) + this.editor.updateInstanceState( + { cursor: { type: 'cross', rotation: 0 } }, + { ephemeral: true, squashing: true } + ) } this.snapshot = this._createSnapshot() - this.markId = isCreating - ? `creating:${this.editor.onlySelectedShape!.id}` - : this.editor.mark('starting resizing') + + this.markId = isCreating ? `creating:${this.editor.onlySelectedShape!.id}` : 'starting resizing' + if (!isCreating) this.editor.mark(this.markId) this.handleResizeStart() this.updateShapes() @@ -105,7 +108,7 @@ export class Resizing extends StateNode { this.handleResizeEnd() if (this.editAfterComplete && this.editor.onlySelectedShape) { - this.editor.setEditingId(this.editor.onlySelectedShape.id) + this.editor.setEditingShapeId(this.editor.onlySelectedShape.id) this.editor.setCurrentTool('select') this.editor.root.current.value!.transition('editing_shape', {}) return @@ -349,12 +352,15 @@ export class Resizing extends StateNode { nextCursor.rotation = rotation - this.editor.updateInstanceState({ cursor: nextCursor }) + this.editor.updateInstanceState({ cursor: nextCursor }, { ephemeral: true, squashing: true }) } override onExit = () => { this.parent.currentToolIdMask = undefined - this.editor.updateInstanceState({ cursor: { type: 'default', rotation: 0 } }, true) + this.editor.updateInstanceState( + { cursor: { type: 'default', rotation: 0 } }, + { ephemeral: true, squashing: true } + ) this.editor.snaps.clear() } diff --git a/packages/tldraw/src/lib/tools/SelectTool/children/Rotating.ts b/packages/tldraw/src/lib/tools/SelectTool/children/Rotating.ts index 107dfcfe0..23428e7fe 100644 --- a/packages/tldraw/src/lib/tools/SelectTool/children/Rotating.ts +++ b/packages/tldraw/src/lib/tools/SelectTool/children/Rotating.ts @@ -29,7 +29,8 @@ export class Rotating extends StateNode { this.info = info this.parent.currentToolIdMask = info.onInteractionEnd - this.markId = this.editor.mark('rotate start') + this.markId = 'rotate start' + this.editor.mark(this.markId) const snapshot = getRotationSnapshot({ editor: this.editor }) if (!snapshot) return this.parent.transition('idle', this.info) @@ -40,7 +41,10 @@ export class Rotating extends StateNode { } override onExit = () => { - this.editor.updateInstanceState({ cursor: { type: 'none', rotation: 0 } }, true) + this.editor.updateInstanceState( + { cursor: { type: 'none', rotation: 0 } }, + { ephemeral: true, squashing: true } + ) this.parent.currentToolIdMask = undefined this.snapshot = {} as TLRotationSnapshot diff --git a/packages/tldraw/src/lib/tools/SelectTool/children/ScribbleBrushing.ts b/packages/tldraw/src/lib/tools/SelectTool/children/ScribbleBrushing.ts index 4da78742f..4944d5e7b 100644 --- a/packages/tldraw/src/lib/tools/SelectTool/children/ScribbleBrushing.ts +++ b/packages/tldraw/src/lib/tools/SelectTool/children/ScribbleBrushing.ts @@ -108,7 +108,7 @@ export class ScribbleBrushing extends StateNode { private updateScribbleSelection(addPoint: boolean) { const { zoomLevel, - shapesOnCurrentPage, + currentPageShapes, inputs: { shiftKey, originPagePoint, previousPagePoint, currentPagePoint }, } = this.editor @@ -118,7 +118,7 @@ export class ScribbleBrushing extends StateNode { this.pushPointToScribble() } - const shapes = shapesOnCurrentPage + const shapes = currentPageShapes let shape: TLShape, geometry: Geometry2d, A: Vec2d, B: Vec2d for (let i = 0, n = shapes.length; i < n; i++) { diff --git a/packages/tldraw/src/lib/tools/SelectTool/children/Translating.ts b/packages/tldraw/src/lib/tools/SelectTool/children/Translating.ts index 480dd8c60..ee9578cd0 100644 --- a/packages/tldraw/src/lib/tools/SelectTool/children/Translating.ts +++ b/packages/tldraw/src/lib/tools/SelectTool/children/Translating.ts @@ -31,6 +31,7 @@ export class Translating extends StateNode { snapshot: TranslatingSnapshot = {} as any markId = '' + initialMarkId = '' isCloning = false isCreating = false @@ -53,9 +54,12 @@ export class Translating extends StateNode { this.isCreating = isCreating this.editAfterComplete = editAfterComplete - this.markId = isCreating - ? this.editor.mark(`creating:${this.editor.onlySelectedShape!.id}`) - : this.editor.mark('translating') + this.initialMarkId = isCreating + ? `creating:${this.editor.onlySelectedShape!.id}` + : 'translating' + this.markId = this.initialMarkId + if (!isCreating) this.editor.mark(this.markId) + this.handleEnter(info) this.editor.on('tick', this.updateParent) } @@ -66,7 +70,10 @@ export class Translating extends StateNode { this.selectionSnapshot = {} as any this.snapshot = {} as any this.editor.snaps.clear() - this.editor.updateInstanceState({ cursor: { type: 'default', rotation: 0 } }, true) + this.editor.updateInstanceState( + { cursor: { type: 'default', rotation: 0 } }, + { ephemeral: true, squashing: true } + ) this.dragAndDropManager.clear() } @@ -111,7 +118,9 @@ export class Translating extends StateNode { this.isCloning = true this.reset() - this.markId = this.editor.mark('translating') + + this.markId = 'cloning' + this.editor.mark(this.markId) this.editor.duplicateShapes(Array.from(this.editor.selectedShapeIds)) @@ -124,7 +133,10 @@ export class Translating extends StateNode { this.isCloning = false this.snapshot = this.selectionSnapshot this.reset() - this.markId = this.editor.mark('translating') + + this.markId = this.initialMarkId + this.editor.mark(this.markId) + this.updateShapes() } @@ -148,7 +160,7 @@ export class Translating extends StateNode { if (this.editAfterComplete) { const onlySelected = this.editor.onlySelectedShape if (onlySelected) { - this.editor.setEditingId(onlySelected.id) + this.editor.setEditingShapeId(onlySelected.id) this.editor.setCurrentTool('select') this.editor.root.current.value!.transition('editing_shape', {}) } @@ -171,7 +183,10 @@ export class Translating extends StateNode { this.isCloning = false this.info = info - this.editor.updateInstanceState({ cursor: { type: 'move', rotation: 0 } }, true) + this.editor.updateInstanceState( + { cursor: { type: 'move', rotation: 0 } }, + { ephemeral: true, squashing: true } + ) this.selectionSnapshot = getTranslatingSnapshot(this.editor) // Don't clone on create; otherwise clone on altKey @@ -201,7 +216,7 @@ export class Translating extends StateNode { }) if (changes.length > 0) { - this.editor.updateShapes(changes) + this.editor.updateShapes(changes, { squashing: true }) } } @@ -406,6 +421,6 @@ export function moveShapesToPoint({ } }) ), - true + { squashing: true } ) } diff --git a/packages/tldraw/src/lib/tools/ZoomTool/ZoomTool.ts b/packages/tldraw/src/lib/tools/ZoomTool/ZoomTool.ts index d0580808c..f784a2dc6 100644 --- a/packages/tldraw/src/lib/tools/ZoomTool/ZoomTool.ts +++ b/packages/tldraw/src/lib/tools/ZoomTool/ZoomTool.ts @@ -21,7 +21,7 @@ export class ZoomTool extends StateNode { this.currentToolIdMask = undefined this.editor.updateInstanceState( { zoomBrush: null, cursor: { type: 'default', rotation: 0 } }, - true + { ephemeral: true, squashing: true } ) this.currentToolIdMask = undefined } @@ -53,9 +53,15 @@ export class ZoomTool extends StateNode { private updateCursor() { if (this.editor.inputs.altKey) { - this.editor.updateInstanceState({ cursor: { type: 'zoom-out', rotation: 0 } }, true) + this.editor.updateInstanceState( + { cursor: { type: 'zoom-out', rotation: 0 } }, + { ephemeral: true, squashing: true } + ) } else { - this.editor.updateInstanceState({ cursor: { type: 'zoom-in', rotation: 0 } }, true) + this.editor.updateInstanceState( + { cursor: { type: 'zoom-in', rotation: 0 } }, + { ephemeral: true, squashing: true } + ) } } } diff --git a/packages/tldraw/src/lib/tools/ZoomTool/children/ZoomBrushing.ts b/packages/tldraw/src/lib/tools/ZoomTool/children/ZoomBrushing.ts index 952088da1..1105b1c17 100644 --- a/packages/tldraw/src/lib/tools/ZoomTool/children/ZoomBrushing.ts +++ b/packages/tldraw/src/lib/tools/ZoomTool/children/ZoomBrushing.ts @@ -54,14 +54,7 @@ export class ZoomBrushing extends StateNode { } } else { const zoomLevel = this.editor.inputs.altKey ? this.editor.zoomLevel / 2 : undefined - this.editor.zoomToBounds( - zoomBrush.x, - zoomBrush.y, - zoomBrush.width, - zoomBrush.height, - zoomLevel, - { duration: 220 } - ) + this.editor.zoomToBounds(zoomBrush, zoomLevel, { duration: 220 }) } this.parent.transition('idle', this.info) diff --git a/packages/tldraw/src/lib/tools/selection-logic/updateHoveredId.ts b/packages/tldraw/src/lib/tools/selection-logic/updateHoveredId.ts index e202959cf..bd61ad508 100644 --- a/packages/tldraw/src/lib/tools/selection-logic/updateHoveredId.ts +++ b/packages/tldraw/src/lib/tools/selection-logic/updateHoveredId.ts @@ -7,7 +7,7 @@ export function updateHoveredId(editor: Editor) { margin: HIT_TEST_MARGIN / editor.zoomLevel, }) - if (!hitShape) return editor.setHoveredId(null) + if (!hitShape) return editor.setHoveredShapeId(null) let shapeToHover: TLShape | undefined = undefined @@ -26,5 +26,5 @@ export function updateHoveredId(editor: Editor) { } } - return editor.setHoveredId(shapeToHover.id) + return editor.setHoveredShapeId(shapeToHover.id) } diff --git a/packages/tldraw/src/lib/ui/components/BackToContent.tsx b/packages/tldraw/src/lib/ui/components/BackToContent.tsx index 63a316b26..9b4354124 100644 --- a/packages/tldraw/src/lib/ui/components/BackToContent.tsx +++ b/packages/tldraw/src/lib/ui/components/BackToContent.tsx @@ -22,8 +22,7 @@ export function BackToContent() { // viewport... so we also need to narrow down the list to only shapes that // are ALSO in the viewport. const visibleShapes = renderingShapes.filter((s) => s.isInViewport) - const showBackToContentNow = - visibleShapes.length === 0 && editor.shapesOnCurrentPage.length > 0 + const showBackToContentNow = visibleShapes.length === 0 && editor.currentPageShapes.length > 0 if (showBackToContentPrev !== showBackToContentNow) { setShowBackToContent(showBackToContentNow) diff --git a/packages/tldraw/src/lib/ui/components/HTMLCanvas.tsx b/packages/tldraw/src/lib/ui/components/HTMLCanvas.tsx index 1b1ec7ef4..8cbf05b6b 100644 --- a/packages/tldraw/src/lib/ui/components/HTMLCanvas.tsx +++ b/packages/tldraw/src/lib/ui/components/HTMLCanvas.tsx @@ -7,7 +7,7 @@ export const HTMLCanvas = track(function HTMLCanvas() { const rCanvas = React.useRef(null) const camera = editor.camera - const shapes = editor.shapesOnCurrentPage + const shapes = editor.currentPageShapes if (rCanvas.current) { const cvs = rCanvas.current const ctx = cvs.getContext('2d')! diff --git a/packages/tldraw/src/lib/ui/components/NavigationZone/Minimap.tsx b/packages/tldraw/src/lib/ui/components/NavigationZone/Minimap.tsx index 07289f137..5cc6c7459 100644 --- a/packages/tldraw/src/lib/ui/components/NavigationZone/Minimap.tsx +++ b/packages/tldraw/src/lib/ui/components/NavigationZone/Minimap.tsx @@ -54,16 +54,15 @@ export function Minimap({ shapeFill, selectFill, viewportFill }: MinimapProps) { const onDoubleClick = React.useCallback( (e: React.MouseEvent) => { - if (!editor.shapeIdsOnCurrentPage.size) return - - const { x, y } = minimap.minimapScreenPointToPagePoint(e.clientX, e.clientY, false, false) + if (!editor.currentPageShapeIds.size) return + const point = minimap.minimapScreenPointToPagePoint(e.clientX, e.clientY, false, false) const clampedPoint = minimap.minimapScreenPointToPagePoint(e.clientX, e.clientY, false, true) minimap.originPagePoint.setTo(clampedPoint) minimap.originPageCenter.setTo(editor.viewportPageBounds.center) - editor.centerOnPoint(x, y, { duration: ANIMATION_MEDIUM_MS }) + editor.centerOnPoint(point, { duration: ANIMATION_MEDIUM_MS }) }, [editor, minimap] ) @@ -71,14 +70,13 @@ export function Minimap({ shapeFill, selectFill, viewportFill }: MinimapProps) { const onPointerDown = React.useCallback( (e: React.PointerEvent) => { setPointerCapture(e.currentTarget, e) - if (!editor.shapeIdsOnCurrentPage.size) return + if (!editor.currentPageShapeIds.size) return rPointing.current = true minimap.isInViewport = false - const { x, y } = minimap.minimapScreenPointToPagePoint(e.clientX, e.clientY, false, false) - + const point = minimap.minimapScreenPointToPagePoint(e.clientX, e.clientY, false, false) const clampedPoint = minimap.minimapScreenPointToPagePoint(e.clientX, e.clientY, false, true) const _vpPageBounds = editor.viewportPageBounds @@ -89,7 +87,7 @@ export function Minimap({ shapeFill, selectFill, viewportFill }: MinimapProps) { minimap.isInViewport = _vpPageBounds.containsPoint(clampedPoint) if (!minimap.isInViewport) { - editor.centerOnPoint(x, y, { duration: ANIMATION_MEDIUM_MS }) + editor.centerOnPoint(point, { duration: ANIMATION_MEDIUM_MS }) } }, [editor, minimap] @@ -98,26 +96,21 @@ export function Minimap({ shapeFill, selectFill, viewportFill }: MinimapProps) { const onPointerMove = React.useCallback( (e: React.PointerEvent) => { if (rPointing.current) { - const { x, y } = minimap.minimapScreenPointToPagePoint( - e.clientX, - e.clientY, - e.shiftKey, - true - ) + const point = minimap.minimapScreenPointToPagePoint(e.clientX, e.clientY, e.shiftKey, true) if (minimap.isInViewport) { - const delta = Vec2d.Sub({ x, y }, minimap.originPagePoint) + const delta = Vec2d.Sub(point, minimap.originPagePoint) const center = Vec2d.Add(minimap.originPageCenter, delta) - editor.centerOnPoint(center.x, center.y) + editor.centerOnPoint(center) return } - editor.centerOnPoint(x, y) + editor.centerOnPoint(point) } const pagePoint = minimap.getPagePoint(e.clientX, e.clientY) - const screenPoint = editor.pageToScreen(pagePoint.x, pagePoint.y) + const screenPoint = editor.pageToScreen(pagePoint) const info: TLPointerEventInfo = { type: 'pointer', @@ -178,9 +171,10 @@ export function Minimap({ shapeFill, selectFill, viewportFill }: MinimapProps) { useQuickReactor( 'minimap render when pagebounds or collaborators changes', () => { - const { shapeIdsOnCurrentPage, viewportPageBounds, commonBoundsOfAllShapesOnCurrentPage } = + const { currentPageShapeIds, viewportPageBounds, commonBoundsOfAllShapesOnCurrentPage } = editor + // deref const _dpr = devicePixelRatio.value minimap.contentPageBounds = commonBoundsOfAllShapesOnCurrentPage @@ -193,7 +187,7 @@ export function Minimap({ shapeFill, selectFill, viewportFill }: MinimapProps) { const allShapeBounds = [] as (Box2d & { id: TLShapeId })[] - shapeIdsOnCurrentPage.forEach((id) => { + currentPageShapeIds.forEach((id) => { let pageBounds = editor.getPageBounds(id) as Box2d & { id: TLShapeId } if (!pageBounds) return @@ -217,7 +211,7 @@ export function Minimap({ shapeFill, selectFill, viewportFill }: MinimapProps) { minimap.collaborators = presences.value minimap.render() }, - [editor, minimap] + [editor, minimap, devicePixelRatio] ) return ( diff --git a/packages/tldraw/src/lib/ui/components/NavigationZone/ZoomMenu.tsx b/packages/tldraw/src/lib/ui/components/NavigationZone/ZoomMenu.tsx index 73a52765d..7eead4bd3 100644 --- a/packages/tldraw/src/lib/ui/components/NavigationZone/ZoomMenu.tsx +++ b/packages/tldraw/src/lib/ui/components/NavigationZone/ZoomMenu.tsx @@ -12,7 +12,7 @@ export const ZoomMenu = track(function ZoomMenu() { const breakpoint = useBreakpoint() const zoom = editor.zoomLevel - const hasShapes = editor.shapeIdsOnCurrentPage.size > 0 + const hasShapes = editor.currentPageShapeIds.size > 0 const hasSelected = editor.selectedShapeIds.length > 0 const isZoomedTo100 = editor.zoomLevel === 1 diff --git a/packages/tldraw/src/lib/ui/components/PageMenu/PageItemInput.tsx b/packages/tldraw/src/lib/ui/components/PageMenu/PageItemInput.tsx index f5d9a8439..63eaa202d 100644 --- a/packages/tldraw/src/lib/ui/components/PageMenu/PageItemInput.tsx +++ b/packages/tldraw/src/lib/ui/components/PageMenu/PageItemInput.tsx @@ -17,7 +17,7 @@ export const PageItemInput = function PageItemInput({ const handleChange = useCallback( (value: string) => { - editor.renamePage(id, value ? value : 'New Page', true) + editor.updatePage({ id, name: value ? value : 'New Page' }, true) }, [editor, id] ) @@ -25,7 +25,7 @@ export const PageItemInput = function PageItemInput({ const handleComplete = useCallback( (value: string) => { editor.mark('rename page') - editor.renamePage(id, value || 'New Page', false) + editor.updatePage({ id, name: value || 'New Page' }, false) }, [editor, id] ) diff --git a/packages/tldraw/src/lib/ui/components/PageMenu/PageMenu.tsx b/packages/tldraw/src/lib/ui/components/PageMenu/PageMenu.tsx index 5587325c7..573385330 100644 --- a/packages/tldraw/src/lib/ui/components/PageMenu/PageMenu.tsx +++ b/packages/tldraw/src/lib/ui/components/PageMenu/PageMenu.tsx @@ -326,7 +326,7 @@ export const PageMenu = function PageMenu() { onClick={() => { const name = window.prompt('Rename page', page.name) if (name && name !== page.name) { - editor.renamePage(page.id, name) + editor.updatePage({ id: page.id, name }, true) } }} onDoubleClick={toggleEditing} @@ -379,10 +379,10 @@ export const PageMenu = function PageMenu() { item={page} listSize={pages.length} onRename={() => { - if (editor.isIos) { + if (editor.environment.isIos) { const name = window.prompt('Rename page', page.name) if (name && name !== page.name) { - editor.renamePage(page.id, name) + editor.updatePage({ id: page.id, name }, true) } } else { setIsEditing(true) diff --git a/packages/tldraw/src/lib/ui/components/StylePanel/StylePanel.tsx b/packages/tldraw/src/lib/ui/components/StylePanel/StylePanel.tsx index f12427461..3308810a7 100644 --- a/packages/tldraw/src/lib/ui/components/StylePanel/StylePanel.tsx +++ b/packages/tldraw/src/lib/ui/components/StylePanel/StylePanel.tsx @@ -16,6 +16,7 @@ import { SharedStyleMap, StyleProp, minBy, + useComputed, useEditor, useValue, } from '@tldraw/editor' @@ -33,9 +34,7 @@ interface StylePanelProps { } const selectToolStyles = [DefaultColorStyle, DefaultDashStyle, DefaultFillStyle, DefaultSizeStyle] -function getRelevantStyles( - editor: Editor -): { styles: ReadonlySharedStyleMap; opacity: SharedStyle } | null { +function getRelevantStyles(editor: Editor): ReadonlySharedStyleMap | null { const styles = new SharedStyleMap(editor.sharedStyles) const hasShape = editor.selectedShapeIds.length > 0 || !!editor.root.current.value?.shapeType @@ -46,14 +45,38 @@ function getRelevantStyles( } if (styles.size === 0 && !hasShape) return null - return { styles, opacity: editor.sharedOpacity } + return styles } /** @internal */ export const StylePanel = function StylePanel({ isMobile }: StylePanelProps) { const editor = useEditor() + const sharedOpacity = useComputed>( + 'sharedOpacity', + () => { + if (editor.isIn('select') && editor.selectedShapeIds.length > 0) { + let opacity: number | null = null + for (const shape of editor.selectedShapes) { + if (opacity === null) { + opacity = shape.opacity + } else if (opacity !== shape.opacity) { + return { type: 'mixed' } + } + } + + if (opacity !== null) { + return { type: 'shared', value: opacity } + } + } + + return { type: 'shared', value: editor.instanceState.opacityForNextShape } + }, + [editor] + ) + const relevantStyles = useValue('getRelevantStyles', () => getRelevantStyles(editor), [editor]) + const opacity = useValue('opacity', () => sharedOpacity.value, [sharedOpacity]) const handlePointerOut = useCallback(() => { if (!isMobile) { @@ -63,7 +86,7 @@ export const StylePanel = function StylePanel({ isMobile }: StylePanelProps) { if (!relevantStyles) return null - const { styles, opacity } = relevantStyles + const styles = relevantStyles const geo = styles.get(GeoShapeGeoStyle) const arrowheadEnd = styles.get(ArrowShapeArrowheadEndStyle) const arrowheadStart = styles.get(ArrowShapeArrowheadStartStyle) @@ -95,8 +118,8 @@ function useStyleChangeCallback() { return React.useMemo(() => { return function (style: StyleProp, value: T, squashing: boolean) { - editor.setStyle(style, value, squashing) - editor.updateInstanceState({ isChangingStyle: true }) + editor.setStyle(style, value, { ephemeral: false, squashing }) + editor.updateInstanceState({ isChangingStyle: true }, { ephemeral: true, squashing: true }) } }, [editor]) } @@ -118,8 +141,16 @@ function CommonStylePickerSet({ const handleOpacityValueChange = React.useCallback( (value: number, ephemeral: boolean) => { const item = tldrawSupportedOpacities[value] - editor.setOpacity(item, ephemeral) - editor.updateInstanceState({ isChangingStyle: true }) + editor.batch(() => { + editor.setOpacity(item, { ephemeral, squashing: true }) + if (editor.isIn('select')) { + editor.updateShapes( + editor.selectedShapes.map((s) => ({ ...s, opacity: item })), + { squashing: true, ephemeral: false } + ) + } + editor.updateInstanceState({ isChangingStyle: true }, { ephemeral, squashing: true }) + }) }, [editor] ) diff --git a/packages/tldraw/src/lib/ui/hooks/useActions.tsx b/packages/tldraw/src/lib/ui/hooks/useActions.tsx index ebc05624d..18210f357 100644 --- a/packages/tldraw/src/lib/ui/hooks/useActions.tsx +++ b/packages/tldraw/src/lib/ui/hooks/useActions.tsx @@ -704,7 +704,7 @@ export function ActionsProvider({ overrides, children }: ActionsProviderProps) { { id: 'delete', label: 'action.delete', - kbd: '⌫,del,backspace', + kbd: '⌫,del', // removed backspace; it was firing twice on mac icon: 'trash', readonlyOk: false, onSelect(source) { @@ -838,10 +838,8 @@ export function ActionsProvider({ overrides, children }: ActionsProviderProps) { onSelect(source) { trackEvent('toggle-transparent', { source }) editor.updateInstanceState( - { - exportBackground: !editor.instanceState.exportBackground, - }, - true + { exportBackground: !editor.instanceState.exportBackground }, + { ephemeral: true, squashing: true } ) }, checkbox: true, @@ -901,7 +899,7 @@ export function ActionsProvider({ overrides, children }: ActionsProviderProps) { { isDebugMode: !editor.instanceState.isDebugMode, }, - true + { ephemeral: true, squashing: true } ) }, checkbox: true, diff --git a/packages/tldraw/src/lib/ui/hooks/useContextMenuSchema.tsx b/packages/tldraw/src/lib/ui/hooks/useContextMenuSchema.tsx index df6f07757..11dc475f4 100644 --- a/packages/tldraw/src/lib/ui/hooks/useContextMenuSchema.tsx +++ b/packages/tldraw/src/lib/ui/hooks/useContextMenuSchema.tsx @@ -93,7 +93,7 @@ export const TLUiContextMenuSchemaProvider = track(function TLUiContextMenuSchem const threeStackableItems = useThreeStackableItems() const atLeastOneShapeOnPage = useValue( 'atLeastOneShapeOnPage', - () => editor.shapeIdsOnCurrentPage.size > 0, + () => editor.currentPageShapeIds.size > 0, [] ) const isTransparentBg = useValue( diff --git a/packages/tldraw/src/lib/ui/hooks/useCopyAs.ts b/packages/tldraw/src/lib/ui/hooks/useCopyAs.ts index 8be737b7e..e66886eca 100644 --- a/packages/tldraw/src/lib/ui/hooks/useCopyAs.ts +++ b/packages/tldraw/src/lib/ui/hooks/useCopyAs.ts @@ -20,7 +20,7 @@ export function useCopyAs() { // little awkward. function copyAs(ids: TLShapeId[] = editor.selectedShapeIds, format: TLCopyType = 'svg') { if (ids.length === 0) { - ids = [...editor.shapeIdsOnCurrentPage] + ids = [...editor.currentPageShapeIds] } if (ids.length === 0) { diff --git a/packages/tldraw/src/lib/ui/hooks/useExportAs.ts b/packages/tldraw/src/lib/ui/hooks/useExportAs.ts index e5ad11a06..62be69039 100644 --- a/packages/tldraw/src/lib/ui/hooks/useExportAs.ts +++ b/packages/tldraw/src/lib/ui/hooks/useExportAs.ts @@ -21,7 +21,7 @@ export function useExportAs() { format: TLExportType = 'png' ) { if (ids.length === 0) { - ids = [...editor.shapeIdsOnCurrentPage] + ids = [...editor.currentPageShapeIds] } if (ids.length === 0) { diff --git a/packages/tldraw/src/lib/ui/hooks/useKeyboardShortcuts.ts b/packages/tldraw/src/lib/ui/hooks/useKeyboardShortcuts.ts index efe6b9279..637ef944d 100644 --- a/packages/tldraw/src/lib/ui/hooks/useKeyboardShortcuts.ts +++ b/packages/tldraw/src/lib/ui/hooks/useKeyboardShortcuts.ts @@ -1,4 +1,4 @@ -import { preventDefault, useEditor, useValue } from '@tldraw/editor' +import { preventDefault, uniqueId, useEditor, useValue } from '@tldraw/editor' import hotkeys from 'hotkeys-js' import { useEffect } from 'react' import { useActions } from './useActions' @@ -26,12 +26,12 @@ export function useKeyboardShortcuts() { useEffect(() => { if (!isFocused) return + const scope = uniqueId() + const container = editor.getContainer() - hotkeys.setScope(editor.store.id) - const hot = (keys: string, callback: (event: KeyboardEvent) => void) => { - hotkeys(keys, { element: container, scope: editor.store.id }, callback) + hotkeys(keys, { element: container, scope }, callback) } // Add hotkeys for actions and tools. @@ -65,8 +65,11 @@ export function useKeyboardShortcuts() { }) } + // When focused, hotkeys should only respond to kbds in this scope + hotkeys.setScope(scope) + return () => { - hotkeys.deleteScope(editor.store.id) + hotkeys.deleteScope(scope) } }, [actions, tools, isReadonly, editor, isFocused]) } diff --git a/packages/tldraw/src/lib/ui/hooks/useMenuSchema.tsx b/packages/tldraw/src/lib/ui/hooks/useMenuSchema.tsx index 834931e21..b4e73f06b 100644 --- a/packages/tldraw/src/lib/ui/hooks/useMenuSchema.tsx +++ b/packages/tldraw/src/lib/ui/hooks/useMenuSchema.tsx @@ -60,7 +60,7 @@ export function TLUiMenuSchemaProvider({ overrides, children }: TLUiMenuSchemaPr [editor] ) - const emptyPage = useValue('emptyPage', () => editor.shapeIdsOnCurrentPage.size === 0, [editor]) + const emptyPage = useValue('emptyPage', () => editor.currentPageShapeIds.size === 0, [editor]) const selectedCount = useValue('selectedCount', () => editor.selectedShapeIds.length, [editor]) const noneSelected = selectedCount === 0 diff --git a/packages/tldraw/src/lib/ui/hooks/usePrint.ts b/packages/tldraw/src/lib/ui/hooks/usePrint.ts index f6ffe6636..ef3580741 100644 --- a/packages/tldraw/src/lib/ui/hooks/usePrint.ts +++ b/packages/tldraw/src/lib/ui/hooks/usePrint.ts @@ -156,10 +156,10 @@ export function usePrint() { } function triggerPrint() { - if (editor.isChromeForIos) { + if (editor.environment.isChromeForIos) { beforePrintHandler() window.print() - } else if (editor.isSafari) { + } else if (editor.environment.isSafari) { beforePrintHandler() document.execCommand('print', false) } else { diff --git a/packages/tldraw/src/lib/ui/hooks/useTools.tsx b/packages/tldraw/src/lib/ui/hooks/useTools.tsx index 16afd8c16..06dfdd1a0 100644 --- a/packages/tldraw/src/lib/ui/hooks/useTools.tsx +++ b/packages/tldraw/src/lib/ui/hooks/useTools.tsx @@ -111,7 +111,7 @@ export function ToolsProvider({ overrides, children }: TLUiToolsProviderProps) { [GeoShapeGeoStyle.id]: id, }, }, - true + { ephemeral: true, squashing: true } ) editor.setCurrentTool('geo') trackEvent('select-tool', { source, id: `geo-${id}` }) diff --git a/packages/tldraw/src/lib/useRegisterExternalContentHandlers.ts b/packages/tldraw/src/lib/useRegisterExternalContentHandlers.ts index c77e8177f..0c902fa86 100644 --- a/packages/tldraw/src/lib/useRegisterExternalContentHandlers.ts +++ b/packages/tldraw/src/lib/useRegisterExternalContentHandlers.ts @@ -178,7 +178,7 @@ export function useRegisterExternalContentHandlers() { }, } - editor.createShapes([shapePartial], true) + editor.createShapes([shapePartial]).select(shapePartial.id) }) // files @@ -405,7 +405,7 @@ export async function createShapesForAssets(editor: Editor, assets: TLAsset[], p } // Create the shapes - editor.createShapes(paritals, true) + editor.createShapes(paritals).select(...paritals.map((p) => p.id)) // Re-position shapes so that the center of the group is at the provided point const { viewportPageBounds } = editor diff --git a/packages/tldraw/src/lib/utils/buildFromV1Document.ts b/packages/tldraw/src/lib/utils/buildFromV1Document.ts index 26b935fa2..e8180946b 100644 --- a/packages/tldraw/src/lib/utils/buildFromV1Document.ts +++ b/packages/tldraw/src/lib/utils/buildFromV1Document.ts @@ -593,7 +593,7 @@ export function buildFromV1Document(editor: Editor, document: LegacyTldrawDocume const bounds = editor.commonBoundsOfAllShapesOnCurrentPage if (bounds) { - editor.zoomToBounds(bounds.minX, bounds.minY, bounds.width, bounds.height, 1) + editor.zoomToBounds(bounds, 1) } }) } diff --git a/packages/tldraw/src/lib/utils/file.ts b/packages/tldraw/src/lib/utils/file.ts index 70b5f31d1..4b6809729 100644 --- a/packages/tldraw/src/lib/utils/file.ts +++ b/packages/tldraw/src/lib/utils/file.ts @@ -293,7 +293,7 @@ export async function parseAndLoadDocument( const bounds = editor.commonBoundsOfAllShapesOnCurrentPage if (bounds) { - editor.zoomToBounds(bounds.minX, bounds.minY, bounds.width, bounds.height, 1) + editor.zoomToBounds(bounds, 1) } }) diff --git a/packages/tldraw/src/test/Editor.test.tsx b/packages/tldraw/src/test/Editor.test.tsx index 59310cfd3..eb855aca7 100644 --- a/packages/tldraw/src/test/Editor.test.tsx +++ b/packages/tldraw/src/test/Editor.test.tsx @@ -1,6 +1,5 @@ import { BaseBoxShapeUtil, PageRecordType, TLShape, createShapeId } from '@tldraw/editor' import { TestEditor } from './TestEditor' -import { TL } from './test-jsx' let editor: TestEditor @@ -53,13 +52,13 @@ describe('shapes that are moved to another page', () => { describe("should be excluded from the previous page's hintingShapeIds", () => { test('[boxes]', () => { - editor.setHintingIds([ids.box1, ids.box2, ids.box3]) + editor.setHintingShapeIds([ids.box1, ids.box2, ids.box3]) expect(editor.hintingShapeIds).toEqual([ids.box1, ids.box2, ids.box3]) moveShapesToPage2() expect(editor.hintingShapeIds).toEqual([]) }) test('[frame that does not move]', () => { - editor.setHintingIds([ids.frame1]) + editor.setHintingShapeIds([ids.frame1]) expect(editor.hintingShapeIds).toEqual([ids.frame1]) moveShapesToPage2() expect(editor.hintingShapeIds).toEqual([ids.frame1]) @@ -68,25 +67,25 @@ describe('shapes that are moved to another page', () => { describe("should be excluded from the previous page's editingShapeId", () => { test('[root shape]', () => { - editor.setEditingId(ids.box1) + editor.setEditingShapeId(ids.box1) expect(editor.editingShapeId).toBe(ids.box1) moveShapesToPage2() expect(editor.editingShapeId).toBe(null) }) test('[child of frame]', () => { - editor.setEditingId(ids.box2) + editor.setEditingShapeId(ids.box2) expect(editor.editingShapeId).toBe(ids.box2) moveShapesToPage2() expect(editor.editingShapeId).toBe(null) }) test('[child of group]', () => { - editor.setEditingId(ids.box3) + editor.setEditingShapeId(ids.box3) expect(editor.editingShapeId).toBe(ids.box3) moveShapesToPage2() expect(editor.editingShapeId).toBe(null) }) test('[frame that doesnt move]', () => { - editor.setEditingId(ids.frame1) + editor.setEditingShapeId(ids.frame1) expect(editor.editingShapeId).toBe(ids.frame1) moveShapesToPage2() expect(editor.editingShapeId).toBe(ids.frame1) @@ -95,13 +94,13 @@ describe('shapes that are moved to another page', () => { describe("should be excluded from the previous page's erasingShapeIds", () => { test('[boxes]', () => { - editor.setErasingIds([ids.box1, ids.box2, ids.box3]) + editor.setErasingShapeIds([ids.box1, ids.box2, ids.box3]) expect(editor.erasingShapeIds).toEqual([ids.box1, ids.box2, ids.box3]) moveShapesToPage2() expect(editor.erasingShapeIds).toEqual([]) }) test('[frame that does not move]', () => { - editor.setErasingIds([ids.frame1]) + editor.setErasingShapeIds([ids.frame1]) expect(editor.erasingShapeIds).toEqual([ids.frame1]) moveShapesToPage2() expect(editor.erasingShapeIds).toEqual([ids.frame1]) @@ -147,89 +146,89 @@ it('Does not create an undo stack item when first clicking on an empty canvas', expect(editor.canUndo).toBe(false) }) -describe('Editor.sharedOpacity', () => { - it('should return the current opacity', () => { - expect(editor.sharedOpacity).toStrictEqual({ type: 'shared', value: 1 }) - editor.setOpacity(0.5) - expect(editor.sharedOpacity).toStrictEqual({ type: 'shared', value: 0.5 }) - }) +// describe('Editor.sharedOpacity', () => { +// it('should return the current opacity', () => { +// expect(editor.sharedOpacity).toStrictEqual({ type: 'shared', value: 1 }) +// editor.setOpacity(0.5) +// expect(editor.sharedOpacity).toStrictEqual({ type: 'shared', value: 0.5 }) +// }) - it('should return opacity for a single selected shape', () => { - const { A } = editor.createShapesFromJsx() - editor.setSelectedShapeIds([A]) - expect(editor.sharedOpacity).toStrictEqual({ type: 'shared', value: 0.3 }) - }) +// it('should return opacity for a single selected shape', () => { +// const { A } = editor.createShapesFromJsx() +// editor.setSelectedShapeIds([A]) +// expect(editor.sharedOpacity).toStrictEqual({ type: 'shared', value: 0.3 }) +// }) - it('should return opacity for multiple selected shapes', () => { - const { A, B } = editor.createShapesFromJsx([ - , - , - ]) - editor.setSelectedShapeIds([A, B]) - expect(editor.sharedOpacity).toStrictEqual({ type: 'shared', value: 0.3 }) - }) +// it('should return opacity for multiple selected shapes', () => { +// const { A, B } = editor.createShapesFromJsx([ +// , +// , +// ]) +// editor.setSelectedShapeIds([A, B]) +// expect(editor.sharedOpacity).toStrictEqual({ type: 'shared', value: 0.3 }) +// }) - it('should return mixed when multiple selected shapes have different opacity', () => { - const { A, B } = editor.createShapesFromJsx([ - , - , - ]) - editor.setSelectedShapeIds([A, B]) - expect(editor.sharedOpacity).toStrictEqual({ type: 'mixed' }) - }) +// it('should return mixed when multiple selected shapes have different opacity', () => { +// const { A, B } = editor.createShapesFromJsx([ +// , +// , +// ]) +// editor.setSelectedShapeIds([A, B]) +// expect(editor.sharedOpacity).toStrictEqual({ type: 'mixed' }) +// }) - it('ignores the opacity of groups and returns the opacity of their children', () => { - const ids = editor.createShapesFromJsx([ - - - , - ]) - editor.setSelectedShapeIds([ids.group]) - expect(editor.sharedOpacity).toStrictEqual({ type: 'shared', value: 0.3 }) - }) -}) +// it('ignores the opacity of groups and returns the opacity of their children', () => { +// const ids = editor.createShapesFromJsx([ +// +// +// , +// ]) +// editor.setSelectedShapeIds([ids.group]) +// expect(editor.sharedOpacity).toStrictEqual({ type: 'shared', value: 0.3 }) +// }) +// }) describe('Editor.setOpacity', () => { - it('should set opacity for selected shapes', () => { - const ids = editor.createShapesFromJsx([ - , - , - ]) + // it('should set opacity for selected shapes', () => { + // const ids = editor.createShapesFromJsx([ + // , + // , + // ]) - editor.setSelectedShapeIds([ids.A, ids.B]) - editor.setOpacity(0.5) + // editor.setSelectedShapeIds([ids.A, ids.B]) + // editor.setOpacity(0.5) - expect(editor.getShape(ids.A)!.opacity).toBe(0.5) - expect(editor.getShape(ids.B)!.opacity).toBe(0.5) - }) + // expect(editor.getShape(ids.A)!.opacity).toBe(0.5) + // expect(editor.getShape(ids.B)!.opacity).toBe(0.5) + // }) - it('should traverse into groups and set opacity in their children', () => { - const ids = editor.createShapesFromJsx([ - , - - - - - - - , - ]) + // it('should traverse into groups and set opacity in their children', () => { + // const ids = editor.createShapesFromJsx([ + // , + // + // + // + // + // + // + // , + // ]) - editor.setSelectedShapeIds([ids.groupA]) - editor.setOpacity(0.5) + // editor.setSelectedShapeIds([ids.groupA]) + // editor.setOpacity(0.5) - // a wasn't selected... - expect(editor.getShape(ids.boxA)!.opacity).toBe(1) + // // a wasn't selected... + // expect(editor.getShape(ids.boxA)!.opacity).toBe(1) - // b, c, & d were within a selected group... - expect(editor.getShape(ids.boxB)!.opacity).toBe(0.5) - expect(editor.getShape(ids.boxC)!.opacity).toBe(0.5) - expect(editor.getShape(ids.boxD)!.opacity).toBe(0.5) + // // b, c, & d were within a selected group... + // expect(editor.getShape(ids.boxB)!.opacity).toBe(0.5) + // expect(editor.getShape(ids.boxC)!.opacity).toBe(0.5) + // expect(editor.getShape(ids.boxD)!.opacity).toBe(0.5) - // groups get skipped - expect(editor.getShape(ids.groupA)!.opacity).toBe(1) - expect(editor.getShape(ids.groupB)!.opacity).toBe(1) - }) + // // groups get skipped + // expect(editor.getShape(ids.groupA)!.opacity).toBe(1) + // expect(editor.getShape(ids.groupB)!.opacity).toBe(1) + // }) it('stores opacity on opacityForNextShape', () => { editor.setOpacity(0.5) diff --git a/packages/tldraw/src/test/EraserTool.test.ts b/packages/tldraw/src/test/EraserTool.test.ts index 5189da186..ba77de8c0 100644 --- a/packages/tldraw/src/test/EraserTool.test.ts +++ b/packages/tldraw/src/test/EraserTool.test.ts @@ -99,7 +99,7 @@ describe('When clicking', () => { // Starts in idle editor.expectPathToBe('root.eraser.idle') - const shapesBeforeCount = editor.shapesOnCurrentPage.length + const shapesBeforeCount = editor.currentPageShapes.length editor.pointerDown(0, 0) // near enough to box1 @@ -108,11 +108,10 @@ describe('When clicking', () => { // Sets the erasingShapeIds array / erasingShapeIdsSet expect(editor.erasingShapeIds).toEqual([ids.box1]) - expect(editor.erasingShapeIdsSet).toEqual(new Set([ids.box1])) editor.pointerUp() - const shapesAfterCount = editor.shapesOnCurrentPage.length + const shapesAfterCount = editor.currentPageShapes.length // Deletes the erasing shapes expect(editor.getShape(ids.box1)).toBeUndefined() @@ -120,7 +119,6 @@ describe('When clicking', () => { // Also empties the erasingShapeIds array / erasingShapeIdsSet expect(editor.erasingShapeIds).toEqual([]) - expect(editor.erasingShapeIdsSet).toEqual(new Set([])) // Returns to idle editor.expectPathToBe('root.eraser.idle') @@ -128,30 +126,29 @@ describe('When clicking', () => { editor.undo() expect(editor.getShape(ids.box1)).toBeDefined() - expect(editor.shapesOnCurrentPage.length).toBe(shapesBeforeCount) + expect(editor.currentPageShapes.length).toBe(shapesBeforeCount) editor.redo() expect(editor.getShape(ids.box1)).toBeUndefined() - expect(editor.shapesOnCurrentPage.length).toBe(shapesBeforeCount - 1) + expect(editor.currentPageShapes.length).toBe(shapesBeforeCount - 1) }) it('Erases all shapes under the cursor on click', () => { editor.setCurrentTool('eraser') - const shapesBeforeCount = editor.shapesOnCurrentPage.length + const shapesBeforeCount = editor.currentPageShapes.length editor.pointerDown(99, 99) // next to box1 AND in box2 expect(new Set(editor.erasingShapeIds)).toEqual(new Set([ids.box1, ids.box2])) - expect(editor.erasingShapeIdsSet).toEqual(new Set([ids.box1, ids.box2])) editor.pointerUp() expect(editor.getShape(ids.box1)).toBeUndefined() expect(editor.getShape(ids.box2)).toBeUndefined() - const shapesAfterCount = editor.shapesOnCurrentPage.length + const shapesAfterCount = editor.currentPageShapes.length expect(shapesAfterCount).toBe(shapesBeforeCount - 2) }) @@ -159,16 +156,15 @@ describe('When clicking', () => { editor.groupShapes([ids.box2, ids.box3], ids.group1) editor.setCurrentTool('eraser') - const shapesBeforeCount = editor.shapesOnCurrentPage.length + const shapesBeforeCount = editor.currentPageShapes.length editor.pointerDown(350, 350) // in box3 expect(new Set(editor.erasingShapeIds)).toEqual(new Set([ids.group1])) - expect(editor.erasingShapeIdsSet).toEqual(new Set([ids.group1])) editor.pointerUp() - const shapesAfterCount = editor.shapesOnCurrentPage.length + const shapesAfterCount = editor.currentPageShapes.length expect(editor.getShape(ids.box2)).toBeUndefined() expect(editor.getShape(ids.box3)).toBeUndefined() @@ -181,28 +177,26 @@ describe('When clicking', () => { editor.groupShapes([ids.box2, ids.box3], ids.group1) editor.setCurrentTool('eraser') - const shapesBeforeCount = editor.shapesOnCurrentPage.length + const shapesBeforeCount = editor.currentPageShapes.length editor.pointerDown(275, 275) // in between box2 AND box3, so over of the new group - expect(editor.erasingShapeIdsSet).toEqual(new Set([])) editor.pointerUp() - const shapesAfterCount = editor.shapesOnCurrentPage.length + const shapesAfterCount = editor.currentPageShapes.length expect(shapesAfterCount).toBe(shapesBeforeCount) }) it('Stops erasing when it reaches a frame when the frame was not was the top-most hovered shape', () => { editor.setCurrentTool('eraser') - const shapesBeforeCount = editor.shapesOnCurrentPage.length + const shapesBeforeCount = editor.currentPageShapes.length editor.pointerDown(375, 75) // inside of the box4 shape inside of box3 - expect(editor.erasingShapeIdsSet).toEqual(new Set([ids.box4])) editor.pointerUp() - const shapesAfterCount = editor.shapesOnCurrentPage.length + const shapesAfterCount = editor.currentPageShapes.length expect(shapesAfterCount).toBe(shapesBeforeCount - 1) // Erases the child but does not erase the frame @@ -213,14 +207,13 @@ describe('When clicking', () => { it('Erases a frame only when its clicked on the edge', () => { editor.setCurrentTool('eraser') - const shapesBeforeCount = editor.shapesOnCurrentPage.length + const shapesBeforeCount = editor.currentPageShapes.length editor.pointerDown(325, 25) // directly on frame1, not its children - expect(editor.erasingShapeIdsSet).toEqual(new Set([])) editor.pointerUp() // without dragging! - const shapesAfterCount = editor.shapesOnCurrentPage.length + const shapesAfterCount = editor.currentPageShapes.length expect(shapesAfterCount).toBe(shapesBeforeCount) // Erases BOTH the frame and its child @@ -231,14 +224,13 @@ describe('When clicking', () => { it('Only erases masked shapes when pointer is inside the mask', () => { editor.setCurrentTool('eraser') - const shapesBeforeCount = editor.shapesOnCurrentPage.length + const shapesBeforeCount = editor.currentPageShapes.length editor.pointerDown(425, 125) // inside of box4's bounds, but outside of its parent's mask - expect(editor.erasingShapeIdsSet).toEqual(new Set([])) editor.pointerUp() // without dragging! - const shapesAfterCount = editor.shapesOnCurrentPage.length + const shapesAfterCount = editor.currentPageShapes.length expect(shapesAfterCount).toBe(shapesBeforeCount) // Erases NEITHER the frame nor its child @@ -250,25 +242,23 @@ describe('When clicking', () => { editor.setCurrentTool('eraser') editor.expectPathToBe('root.eraser.idle') - const shapesBeforeCount = editor.shapesOnCurrentPage.length + const shapesBeforeCount = editor.currentPageShapes.length editor.pointerDown(0, 0) // in box1 editor.expectPathToBe('root.eraser.pointing') expect(editor.erasingShapeIds).toEqual([ids.box1]) - expect(editor.erasingShapeIdsSet).toEqual(new Set([ids.box1])) editor.cancel() editor.pointerUp() - const shapesAfterCount = editor.shapesOnCurrentPage.length + const shapesAfterCount = editor.currentPageShapes.length editor.expectPathToBe('root.eraser.idle') // Does NOT erase the shape expect(editor.erasingShapeIds).toEqual([]) - expect(editor.erasingShapeIdsSet).toEqual(new Set([])) expect(editor.getShape(ids.box1)).toBeDefined() expect(shapesAfterCount).toBe(shapesBeforeCount) }) @@ -277,25 +267,23 @@ describe('When clicking', () => { editor.setCurrentTool('eraser') editor.expectPathToBe('root.eraser.idle') - const shapesBeforeCount = editor.shapesOnCurrentPage.length + const shapesBeforeCount = editor.currentPageShapes.length editor.pointerDown(0, 0) // near to box1 editor.expectPathToBe('root.eraser.pointing') expect(editor.erasingShapeIds).toEqual([ids.box1]) - expect(editor.erasingShapeIdsSet).toEqual(new Set([ids.box1])) editor.interrupt() editor.pointerUp() - const shapesAfterCount = editor.shapesOnCurrentPage.length + const shapesAfterCount = editor.currentPageShapes.length editor.expectPathToBe('root.eraser.idle') // Does NOT erase the shape expect(editor.erasingShapeIds).toEqual([]) - expect(editor.erasingShapeIdsSet).toEqual(new Set([])) expect(editor.getShape(ids.box1)).toBeDefined() expect(shapesAfterCount).toBe(shapesBeforeCount) }) @@ -320,24 +308,20 @@ describe('When clicking and dragging', () => { expect(editor.instanceState.scribble).not.toBe(null) expect(editor.erasingShapeIds).toEqual([ids.box1]) - // expect(editor.erasingShapeIdsSet).toEqual(new Set([ids.box1])) // editor.pointerUp() // editor.expectPathToBe('root.eraser.idle') // expect(editor.erasingShapeIds).toEqual([]) - // expect(editor.erasingShapeIdsSet).toEqual(new Set([])) // expect(editor.getShape(ids.box1)).not.toBeDefined() // editor.undo() // expect(editor.erasingShapeIds).toEqual([]) - // expect(editor.erasingShapeIdsSet).toEqual(new Set([])) // expect(editor.getShape(ids.box1)).toBeDefined() // editor.redo() // expect(editor.erasingShapeIds).toEqual([]) - // expect(editor.erasingShapeIdsSet).toEqual(new Set([])) // expect(editor.getShape(ids.box1)).not.toBeDefined() }) @@ -349,11 +333,9 @@ describe('When clicking and dragging', () => { jest.advanceTimersByTime(16) expect(editor.instanceState.scribble).not.toBe(null) expect(editor.erasingShapeIds).toEqual([ids.box1]) - expect(editor.erasingShapeIdsSet).toEqual(new Set([ids.box1])) editor.cancel() editor.expectPathToBe('root.eraser.idle') expect(editor.erasingShapeIds).toEqual([]) - expect(editor.erasingShapeIdsSet).toEqual(new Set([])) expect(editor.getShape(ids.box1)).toBeDefined() }) @@ -366,10 +348,8 @@ describe('When clicking and dragging', () => { jest.advanceTimersByTime(16) expect(editor.instanceState.scribble).not.toBe(null) expect(editor.erasingShapeIds).toEqual([]) - expect(editor.erasingShapeIdsSet).toEqual(new Set([])) editor.pointerMove(0, 0) expect(editor.erasingShapeIds).toEqual([ids.box1]) - expect(editor.erasingShapeIdsSet).toEqual(new Set([ids.box1])) expect(editor.getShape(ids.box1)).toBeDefined() editor.pointerUp() expect(editor.getShape(ids.group1)).toBeDefined() @@ -383,7 +363,6 @@ describe('When clicking and dragging', () => { jest.advanceTimersByTime(16) expect(editor.instanceState.scribble).not.toBe(null) expect(editor.erasingShapeIds).toEqual([ids.box3]) - expect(editor.erasingShapeIdsSet).toEqual(new Set([ids.box3])) editor.pointerUp() expect(editor.getShape(ids.frame1)).toBeDefined() expect(editor.getShape(ids.box3)).not.toBeDefined() @@ -397,7 +376,6 @@ describe('When clicking and dragging', () => { jest.advanceTimersByTime(16) expect(editor.instanceState.scribble).not.toBe(null) expect(editor.erasingShapeIds).toEqual([]) - expect(editor.erasingShapeIdsSet).toEqual(new Set([])) editor.pointerUp() expect(editor.getShape(ids.box3)).toBeDefined() @@ -405,7 +383,6 @@ describe('When clicking and dragging', () => { editor.pointerMove(375, 500) // Through the masked part of box3 expect(editor.instanceState.scribble).not.toBe(null) expect(editor.erasingShapeIds).toEqual([ids.box3]) - expect(editor.erasingShapeIdsSet).toEqual(new Set([ids.box3])) editor.pointerUp() expect(editor.getShape(ids.box3)).not.toBeDefined() }) @@ -437,7 +414,7 @@ describe('When clicking and dragging', () => { describe('Does not erase hollow shapes on click', () => { it('Returns to select on cancel', () => { editor.selectAll().deleteShapes(editor.selectedShapes) - expect(editor.shapesOnCurrentPage.length).toBe(0) + expect(editor.currentPageShapes.length).toBe(0) editor.createShape({ id: createShapeId(), type: 'geo', @@ -447,7 +424,7 @@ describe('Does not erase hollow shapes on click', () => { editor.pointerDown() expect(editor.erasingShapeIds).toEqual([]) editor.pointerUp() - expect(editor.shapesOnCurrentPage.length).toBe(1) + expect(editor.currentPageShapes.length).toBe(1) }) }) diff --git a/packages/tldraw/src/test/SelectTool.test.ts b/packages/tldraw/src/test/SelectTool.test.ts index 1791f7e3b..4f72f79b5 100644 --- a/packages/tldraw/src/test/SelectTool.test.ts +++ b/packages/tldraw/src/test/SelectTool.test.ts @@ -45,33 +45,33 @@ describe('TLSelectTool.Translating', () => { editor.pointerDown(150, 150, { target: 'shape', shape }) editor.pointerMove(200, 200) - expect(editor.shapesOnCurrentPage.length).toBe(1) + expect(editor.currentPageShapes.length).toBe(1) editor.expectShapeToMatch({ id: ids.box1, x: 150, y: 150 }) - const t1 = [...editor.shapeIdsOnCurrentPage.values()] + const t1 = [...editor.currentPageShapeIds.values()] editor.keyDown('Alt') - expect(editor.shapesOnCurrentPage.length).toBe(2) + expect(editor.currentPageShapes.length).toBe(2) editor.expectShapeToMatch({ id: ids.box1, x: 100, y: 100 }) // const t2 = [...editor.shapeIds.values()] editor.keyUp('Alt') // There's a timer here! We shouldn't end the clone until the timer is done - expect(editor.shapesOnCurrentPage.length).toBe(2) + expect(editor.currentPageShapes.length).toBe(2) jest.advanceTimersByTime(250) // tick tock // Timer is done! We should have ended the clone. - expect(editor.shapesOnCurrentPage.length).toBe(1) + expect(editor.currentPageShapes.length).toBe(1) editor.expectToBeIn('select.translating') editor.expectShapeToMatch({ id: ids.box1, x: 150, y: 150 }) - expect([...editor.shapeIdsOnCurrentPage.values()]).toMatchObject(t1) + expect([...editor.currentPageShapeIds.values()]).toMatchObject(t1) // todo: Should cloning again duplicate new shapes, or restore the last clone? // editor.keyDown('Alt') - // expect(editor.shapesOnCurrentPage.length).toBe(2) + // expect(editor.currentPageShapes.length).toBe(2) // editor.expectShapeToMatch({ id: ids.box1, x: 100, y: 100 }) // expect([...editor.shapeIds.values()]).toMatchObject(t2) }) @@ -95,7 +95,7 @@ describe('TLSelectTool.Translating', () => { editor.pointerMove(150, 250) editor.pointerUp() const box2Id = editor.onlySelectedShape!.id - expect(editor.shapesOnCurrentPage.length).toStrictEqual(2) + expect(editor.currentPageShapes.length).toStrictEqual(2) expect(ids.box1).not.toEqual(box2Id) // shift-alt-drag the original, we shouldn't duplicate the copy too: @@ -103,7 +103,7 @@ describe('TLSelectTool.Translating', () => { expect(editor.selectedShapeIds).toStrictEqual([ids.box1]) editor.pointerMove(250, 150) editor.pointerUp() - expect(editor.shapesOnCurrentPage.length).toStrictEqual(3) + expect(editor.currentPageShapes.length).toStrictEqual(3) }) }) @@ -173,7 +173,7 @@ describe('When double clicking a shape', () => { .deleteShapes(editor.selectedShapeIds) .selectNone() .createShapes([{ id: createShapeId(), type: 'geo' }]) - .doubleClick(50, 50, { target: 'shape', shape: editor.shapesOnCurrentPage[0] }) + .doubleClick(50, 50, { target: 'shape', shape: editor.currentPageShapes[0] }) .expectToBeIn('select.editing_shape') }) }) @@ -358,45 +358,45 @@ describe('When editing shapes', () => { it('Double clicking the canvas creates a new text shape', () => { expect(editor.editingShapeId).toBe(null) expect(editor.selectedShapeIds.length).toBe(0) - expect(editor.shapesOnCurrentPage.length).toBe(5) + expect(editor.currentPageShapes.length).toBe(5) editor.doubleClick(750, 750) - expect(editor.shapesOnCurrentPage.length).toBe(6) - expect(editor.shapesOnCurrentPage[5].type).toBe('text') + expect(editor.currentPageShapes.length).toBe(6) + expect(editor.currentPageShapes[5].type).toBe('text') }) it('It deletes an empty text shape when your click away', () => { expect(editor.editingShapeId).toBe(null) expect(editor.selectedShapeIds.length).toBe(0) - expect(editor.shapesOnCurrentPage.length).toBe(5) + expect(editor.currentPageShapes.length).toBe(5) // Create a new shape by double clicking editor.doubleClick(750, 750) expect(editor.selectedShapeIds.length).toBe(1) - expect(editor.shapesOnCurrentPage.length).toBe(6) + expect(editor.currentPageShapes.length).toBe(6) const shapeId = editor.selectedShapeIds[0] // Click away editor.click(1000, 1000) expect(editor.selectedShapeIds.length).toBe(0) - expect(editor.shapesOnCurrentPage.length).toBe(5) + expect(editor.currentPageShapes.length).toBe(5) expect(editor.getShape(shapeId)).toBe(undefined) }) it('It deletes an empty text shape when your click another text shape', () => { expect(editor.editingShapeId).toBe(null) expect(editor.selectedShapeIds.length).toBe(0) - expect(editor.shapesOnCurrentPage.length).toBe(5) + expect(editor.currentPageShapes.length).toBe(5) // Create a new shape by double clicking editor.doubleClick(750, 750) expect(editor.selectedShapeIds.length).toBe(1) - expect(editor.shapesOnCurrentPage.length).toBe(6) + expect(editor.currentPageShapes.length).toBe(6) const shapeId = editor.selectedShapeIds[0] // Click another text shape editor.click(50, 50, { target: 'shape', shape: editor.getShape(ids.text1) }) expect(editor.selectedShapeIds.length).toBe(1) - expect(editor.shapesOnCurrentPage.length).toBe(5) + expect(editor.currentPageShapes.length).toBe(5) expect(editor.getShape(shapeId)).toBe(undefined) }) diff --git a/packages/tldraw/src/test/TldrawEditor.test.tsx b/packages/tldraw/src/test/TldrawEditor.test.tsx index ed946e33d..b3c811f3d 100644 --- a/packages/tldraw/src/test/TldrawEditor.test.tsx +++ b/packages/tldraw/src/test/TldrawEditor.test.tsx @@ -219,7 +219,10 @@ describe('', () => { expect(editor).toBeTruthy() await act(async () => { - editor.updateInstanceState({ screenBounds: { x: 0, y: 0, w: 1080, h: 720 } }, true, true) + editor.updateInstanceState( + { screenBounds: { x: 0, y: 0, w: 1080, h: 720 } }, + { ephemeral: true, squashing: true } + ) }) const id = createShapeId() @@ -340,7 +343,10 @@ describe('Custom shapes', () => { expect(editor).toBeTruthy() await act(async () => { - editor.updateInstanceState({ screenBounds: { x: 0, y: 0, w: 1080, h: 720 } }, true, true) + editor.updateInstanceState( + { screenBounds: { x: 0, y: 0, w: 1080, h: 720 } }, + { ephemeral: true, squashing: true } + ) }) expect(editor.shapeUtils.card).toBeTruthy() diff --git a/packages/tldraw/src/test/ZoomTool.test.ts b/packages/tldraw/src/test/ZoomTool.test.ts index 6c4c654a1..4e6c2165f 100644 --- a/packages/tldraw/src/test/ZoomTool.test.ts +++ b/packages/tldraw/src/test/ZoomTool.test.ts @@ -40,7 +40,7 @@ describe('TLSelectTool.Zooming', () => { it('Correctly zooms in when clicking', () => { editor.keyDown('z') expect(editor.zoomLevel).toBe(1) - expect(editor.viewportPageBounds).toMatchObject({ x: 0, y: 0, w: 1080, h: 720 }) + expect(editor.viewportPageBounds).toMatchObject({ x: -0, y: -0, w: 1080, h: 720 }) expect(editor.viewportPageCenter).toMatchObject({ x: 540, y: 360 }) editor.click() editor.expectToBeIn('zoom.idle') @@ -52,7 +52,7 @@ describe('TLSelectTool.Zooming', () => { editor.keyDown('z') editor.keyDown('Alt') expect(editor.zoomLevel).toBe(1) - expect(editor.viewportPageBounds).toMatchObject({ x: 0, y: 0, w: 1080, h: 720 }) + expect(editor.viewportPageBounds).toMatchObject({ x: -0, y: -0, w: 1080, h: 720 }) expect(editor.viewportPageCenter).toMatchObject({ x: 540, y: 360 }) editor.click() jest.advanceTimersByTime(300) @@ -109,7 +109,7 @@ describe('TLSelectTool.Zooming', () => { it('When the dragged area is small it zooms in instead of zooming to the area', () => { const originalCenter = { x: 540, y: 360 } - const originalPageBounds = { x: 0, y: 0, w: 1080, h: 720 } + const originalPageBounds = { x: -0, y: -0, w: 1080, h: 720 } const change = 6 expect(editor.zoomLevel).toBe(1) expect(editor.viewportPageBounds).toMatchObject(originalPageBounds) @@ -143,7 +143,7 @@ describe('TLSelectTool.Zooming', () => { const newBoundsY = 200 editor.expectToBeIn('select.idle') expect(editor.zoomLevel).toBe(1) - expect(editor.viewportPageBounds).toMatchObject({ x: 0, y: 0, w: 1080, h: 720 }) + expect(editor.viewportPageBounds).toMatchObject({ x: -0, y: -0, w: 1080, h: 720 }) expect(editor.viewportPageCenter).toMatchObject({ x: 540, y: 360 }) editor.keyDown('z') editor.expectToBeIn('zoom.idle') @@ -179,7 +179,7 @@ describe('TLSelectTool.Zooming', () => { editor.expectToBeIn('select.idle') const originalZoomLevel = 1 expect(editor.zoomLevel).toBe(originalZoomLevel) - expect(editor.viewportPageBounds).toMatchObject({ x: 0, y: 0, w: 1080, h: 720 }) + expect(editor.viewportPageBounds).toMatchObject({ x: -0, y: -0, w: 1080, h: 720 }) expect(editor.viewportPageCenter).toMatchObject({ x: 540, y: 360 }) editor.keyDown('z') editor.expectToBeIn('zoom.idle') diff --git a/packages/tldraw/src/test/arrows-megabus.test.ts b/packages/tldraw/src/test/arrows-megabus.test.ts index 84a35b5de..cbc2c1f37 100644 --- a/packages/tldraw/src/test/arrows-megabus.test.ts +++ b/packages/tldraw/src/test/arrows-megabus.test.ts @@ -36,7 +36,7 @@ describe('Making an arrow on the page', () => { editor.setCurrentTool('arrow') editor.pointerMove(0, 0) editor.pointerDown() - expect(editor.shapesOnCurrentPage.length).toBe(1) + expect(editor.currentPageShapes.length).toBe(1) }) it('cleans up the arrow if the user did not start dragging', () => { @@ -44,24 +44,24 @@ describe('Making an arrow on the page', () => { editor.setCurrentTool('arrow') editor.pointerMove(0, 0) editor.click() - expect(editor.shapesOnCurrentPage.length).toBe(0) + expect(editor.currentPageShapes.length).toBe(0) // with double click editor.setCurrentTool('arrow') editor.pointerMove(0, 0) editor.doubleClick() - expect(editor.shapesOnCurrentPage.length).toBe(0) + expect(editor.currentPageShapes.length).toBe(0) // with pointer up editor.setCurrentTool('arrow') editor.pointerDown() editor.pointerUp() - expect(editor.shapesOnCurrentPage.length).toBe(0) + expect(editor.currentPageShapes.length).toBe(0) // did not add it to the history stack editor.undo() - expect(editor.shapesOnCurrentPage.length).toBe(0) + expect(editor.currentPageShapes.length).toBe(0) editor.redo() editor.redo() - expect(editor.shapesOnCurrentPage.length).toBe(0) + expect(editor.currentPageShapes.length).toBe(0) }) it('keeps the arrow if the user dragged', () => { @@ -75,7 +75,7 @@ describe('Making an arrow on the page', () => { editor.setCurrentTool('arrow') editor.pointerDown(0, 0) editor.pointerMove(100, 0) - const arrow1 = editor.shapesOnCurrentPage[0] + const arrow1 = editor.currentPageShapes[0] expect(arrow()).toMatchObject({ type: 'arrow', @@ -262,25 +262,25 @@ describe('When starting an arrow inside of multiple shapes', () => { it('does not create the arrow immediately', () => { editor.setCurrentTool('arrow') editor.pointerDown(50, 50) - expect(editor.shapesOnCurrentPage.length).toBe(1) + expect(editor.currentPageShapes.length).toBe(1) expect(arrow()).toBe(null) }) it('does not create a shape if pointer up before drag', () => { editor.setCurrentTool('arrow') editor.pointerDown(50, 50) - expect(editor.shapesOnCurrentPage.length).toBe(1) + expect(editor.currentPageShapes.length).toBe(1) editor.pointerUp(50, 50) - expect(editor.shapesOnCurrentPage.length).toBe(1) + expect(editor.currentPageShapes.length).toBe(1) }) it('creates the arrow after a drag, bound to the shape', () => { editor.setCurrentTool('arrow') editor.pointerDown(50, 50) - expect(editor.shapesOnCurrentPage.length).toBe(1) + expect(editor.currentPageShapes.length).toBe(1) expect(arrow()).toBe(null) editor.pointerMove(55, 50) - expect(editor.shapesOnCurrentPage.length).toBe(2) + expect(editor.currentPageShapes.length).toBe(2) expect(arrow()).toMatchObject({ x: 50, y: 50, @@ -308,10 +308,10 @@ describe('When starting an arrow inside of multiple shapes', () => { it('always creates the arrow with an imprecise start point', () => { editor.setCurrentTool('arrow') editor.pointerDown(20, 20) // upper left - expect(editor.shapesOnCurrentPage.length).toBe(1) + expect(editor.currentPageShapes.length).toBe(1) expect(arrow()).toBe(null) editor.pointerMove(25, 20) - expect(editor.shapesOnCurrentPage.length).toBe(2) + expect(editor.currentPageShapes.length).toBe(2) expect(arrow()).toMatchObject({ x: 20, y: 20, @@ -340,11 +340,11 @@ describe('When starting an arrow inside of multiple shapes', () => { it('after a pause before drag, creates an arrow with a precise start point', () => { editor.setCurrentTool('arrow') editor.pointerDown(20, 20) // upper left - expect(editor.shapesOnCurrentPage.length).toBe(1) + expect(editor.currentPageShapes.length).toBe(1) expect(arrow()).toBe(null) jest.advanceTimersByTime(1000) editor.pointerMove(25, 20) - expect(editor.shapesOnCurrentPage.length).toBe(2) + expect(editor.currentPageShapes.length).toBe(2) expect(arrow()).toMatchObject({ x: 20, y: 20, @@ -383,10 +383,10 @@ describe('When starting an arrow inside of multiple shapes', () => { editor.setCurrentTool('arrow') editor.pointerDown(25, 25) - expect(editor.shapesOnCurrentPage.length).toBe(2) + expect(editor.currentPageShapes.length).toBe(2) expect(arrow()).toBe(null) editor.pointerMove(30, 30) - expect(editor.shapesOnCurrentPage.length).toBe(3) + expect(editor.currentPageShapes.length).toBe(3) expect(arrow()).toMatchObject({ x: 25, y: 25, @@ -403,8 +403,8 @@ describe('When starting an arrow inside of multiple shapes', () => { type: 'binding', boundShapeId: ids.box2, normalizedAnchor: { - x: 0.6, - y: 0.6, + x: 0.55, + y: 0.5, }, }, }, @@ -417,10 +417,10 @@ describe('When starting an arrow inside of multiple shapes', () => { editor.setCurrentTool('arrow') editor.pointerDown(25, 25) - expect(editor.shapesOnCurrentPage.length).toBe(2) + expect(editor.currentPageShapes.length).toBe(2) expect(arrow()).toBe(null) editor.pointerMove(30, 30) - expect(editor.shapesOnCurrentPage.length).toBe(3) + expect(editor.currentPageShapes.length).toBe(3) expect(arrow()).toMatchObject({ x: 25, y: 25, @@ -437,8 +437,8 @@ describe('When starting an arrow inside of multiple shapes', () => { type: 'binding', boundShapeId: ids.box2, normalizedAnchor: { - x: 0.6, - y: 0.6, + x: 0.55, + y: 0.5, }, }, }, @@ -462,10 +462,10 @@ describe('When starting an arrow inside of multiple shapes', () => { editor.setCurrentTool('arrow') editor.pointerDown(25, 25) - expect(editor.shapesOnCurrentPage.length).toBe(2) + expect(editor.currentPageShapes.length).toBe(2) expect(arrow()).toBe(null) editor.pointerMove(30, 30) - expect(editor.shapesOnCurrentPage.length).toBe(3) + expect(editor.currentPageShapes.length).toBe(3) expect(arrow()).toMatchObject({ x: 25, y: 25, @@ -498,10 +498,10 @@ describe('When starting an arrow inside of multiple shapes', () => { editor.setCurrentTool('arrow') editor.pointerDown(25, 25) - expect(editor.shapesOnCurrentPage.length).toBe(2) + expect(editor.currentPageShapes.length).toBe(2) expect(arrow()).toBe(null) editor.pointerMove(30, 30) - expect(editor.shapesOnCurrentPage.length).toBe(3) + expect(editor.currentPageShapes.length).toBe(3) expect(arrow()).toMatchObject({ x: 25, y: 25, @@ -518,11 +518,87 @@ describe('When starting an arrow inside of multiple shapes', () => { type: 'binding', boundShapeId: ids.box2, normalizedAnchor: { - x: 0.6, - y: 0.6, + // kicked over because it was too close to the center, and we can't have both bound there + x: 0.55, + y: 0.5, + }, + }, + }, + }) + editor.pointerMove(35, 35) + expect(editor.currentPageShapes.length).toBe(3) + expect(arrow()).toMatchObject({ + x: 25, + y: 25, + props: { + start: { + type: 'binding', + boundShapeId: ids.box2, + normalizedAnchor: { + x: 0.5, + y: 0.5, + }, + }, + end: { + type: 'binding', + boundShapeId: ids.box2, + normalizedAnchor: { + // kicked over because it was too close to the center, and we can't have both bound there + x: 0.7, + y: 0.7, }, }, }, }) }) }) + +describe('When deleting shapes with bound arrows', () => { + beforeEach(() => { + editor.createShapes([ + { id: ids.box1, type: 'geo', x: 0, y: 0, props: { w: 100, h: 100, fill: 'solid' } }, + ]) + editor.createShapes([ + { id: ids.box2, type: 'geo', x: 200, y: 0, props: { w: 100, h: 100, fill: 'solid' } }, + ]) + }) + + it('also removes the binding', () => { + function arrow() { + return editor.currentPageShapes.find((s) => s.type === 'arrow') as TLArrowShape + } + + editor + // create arrow from box1 to box2 + .setCurrentTool('arrow') + .pointerMove(50, 50) + + editor.history.clear() + + editor.pointerDown() + + expect(editor.history._undos.value.length).toBe(0) + + editor.pointerMove(250, 50) + + editor.pointerUp() + + expect(arrow().props.end.type).toBe('binding') + + // select box2 + editor.click(275, 25) + + editor.mark('deleting') + editor.deleteShapes([ids.box2]) + + expect(arrow().props.end.type).toBe('point') + + editor.undo() + + expect(arrow().props.end.type).toBe('binding') + + editor.redo() + + expect(arrow().props.end.type).toBe('point') + }) +}) diff --git a/packages/tldraw/src/test/cleanup.test.ts b/packages/tldraw/src/test/cleanup.test.ts new file mode 100644 index 000000000..eb8204202 --- /dev/null +++ b/packages/tldraw/src/test/cleanup.test.ts @@ -0,0 +1,129 @@ +import { TLArrowShape, createShapeId } from '@tldraw/editor' +import { TestEditor } from './TestEditor' + +let editor: TestEditor + +const ids = { + box1: createShapeId('box1'), + box2: createShapeId('box2'), + box3: createShapeId('box3'), + box4: createShapeId('box4'), + box5: createShapeId('box5'), + frame1: createShapeId('frame1'), + group1: createShapeId('group1'), + group2: createShapeId('group2'), + group3: createShapeId('group3'), + arrow1: createShapeId('arrow1'), + arrow2: createShapeId('arrow2'), + arrow3: createShapeId('arrow3'), +} + +beforeEach(() => { + editor = new TestEditor() +}) + +function arrow() { + return editor.currentPageShapes.find((s) => s.type === 'arrow') as TLArrowShape +} + +describe('restoring bound arrows', () => { + beforeEach(() => { + editor.createShapes([ + { id: ids.box1, type: 'geo', x: 0, y: 0 }, + { id: ids.box2, type: 'geo', x: 200, y: 0 }, + ]) + // create arrow from box1 to box2 + editor + .setCurrentTool('arrow') + .pointerMove(50, 50) + .pointerDown() + .pointerMove(250, 50) + .pointerUp() + }) + + it('removes bound arrows on delete, restores them on undo but only when change was done by user', () => { + editor.mark('deleting') + editor.deleteShapes([ids.box2]) + expect(arrow().props.end.type).toBe('point') + editor.undo() + expect(arrow().props.end.type).toBe('binding') + editor.redo() + expect(arrow().props.end.type).toBe('point') + }) + + it('removes / restores multiple bindings', () => { + editor.mark('deleting') + expect(arrow().props.start.type).toBe('binding') + expect(arrow().props.end.type).toBe('binding') + + editor.deleteShapes([ids.box1, ids.box2]) + expect(arrow().props.start.type).toBe('point') + expect(arrow().props.end.type).toBe('point') + + editor.undo() + expect(arrow().props.start.type).toBe('binding') + expect(arrow().props.end.type).toBe('binding') + + editor.redo() + expect(arrow().props.start.type).toBe('point') + expect(arrow().props.end.type).toBe('point') + }) +}) + +describe('restoring bound arrows multiplayer', () => { + it('restores bound arrows after the shape was deleted by a different client', () => { + editor.mark() + editor.createShapes([{ id: ids.box2, type: 'geo', x: 100, y: 0 }]) + + editor.setCurrentTool('arrow').pointerMove(0, 50).pointerDown().pointerMove(150, 50).pointerUp() + + // console.log(JSON.stringify(editor.history._undos.value.toArray(), null, 2)) + + expect(arrow().props.start.type).toBe('point') + expect(arrow().props.end.type).toBe('binding') + + // Merge a change from a remote source that deletes box 2 + editor.store.mergeRemoteChanges(() => { + editor.store.remove([ids.box2]) + }) + + // box is gone + expect(editor.getShape(ids.box2)).toBeUndefined() + // arrow is still there, but without its binding + expect(arrow()).not.toBeUndefined() + expect(arrow().props.start.type).toBe('point') + expect(arrow().props.end.type).toBe('point') + + editor.undo() // undo creating the arrow + + // arrow is gone too now + expect(editor.currentPageShapeIds.size).toBe(0) + + editor.redo() // redo creating the arrow + + expect(editor.getShape(ids.box2)).toBeUndefined() + expect(arrow()).not.toBeUndefined() + expect(arrow().props.start.type).toBe('point') + expect(arrow().props.end.type).toBe('point') + + editor.undo() // undo creating arrow + + expect(editor.currentPageShapeIds.size).toBe(0) + + editor.undo() // undo creating box + + expect(editor.currentPageShapeIds.size).toBe(0) + + editor.redo() // redo creating box + + // box is back! arrow is gone + expect(editor.getShape(ids.box2)).not.toBeUndefined() + expect(arrow()).toBeUndefined() + + editor.redo() // redo creating arrow + + // box is back! arrow should be bound + expect(arrow().props.start.type).toBe('point') + expect(arrow().props.end.type).toBe('binding') + }) +}) diff --git a/packages/tldraw/src/test/commands/centerOnPoint.test.ts b/packages/tldraw/src/test/commands/centerOnPoint.test.ts index 0f1b09faf..e4a73cfbf 100644 --- a/packages/tldraw/src/test/commands/centerOnPoint.test.ts +++ b/packages/tldraw/src/test/commands/centerOnPoint.test.ts @@ -7,6 +7,15 @@ beforeEach(() => { }) it('centers on the point', () => { - editor.centerOnPoint(400, 400) + editor.centerOnPoint({ x: 400, y: 400 }) + expect(editor.viewportPageCenter).toMatchObject({ x: 400, y: 400 }) +}) + +it('centers on the point with animation', () => { + editor.centerOnPoint({ x: 400, y: 400 }, { duration: 200 }) + expect(editor.viewportPageCenter).not.toMatchObject({ x: 400, y: 400 }) + jest.advanceTimersByTime(100) + expect(editor.viewportPageCenter).not.toMatchObject({ x: 400, y: 400 }) + jest.advanceTimersByTime(200) expect(editor.viewportPageCenter).toMatchObject({ x: 400, y: 400 }) }) diff --git a/packages/tldraw/src/test/commands/clipboard.test.ts b/packages/tldraw/src/test/commands/clipboard.test.ts index c192923d5..e331644cd 100644 --- a/packages/tldraw/src/test/commands/clipboard.test.ts +++ b/packages/tldraw/src/test/commands/clipboard.test.ts @@ -68,17 +68,21 @@ describe('When copying and pasting', () => { { id: ids.box2, type: 'geo', x: 300, y: 300, props: { w: 100, h: 100 } }, ]) - const shapesBefore = editor.shapesOnCurrentPage + const shapesBefore = editor.currentPageShapes editor.selectAll().copy() await assertClipboardOfCorrectShape(mockClipboard.current) const testOffsetX = 100 const testOffsetY = 100 - editor.setCamera(editor.camera.x - testOffsetX, editor.camera.y - testOffsetY, editor.zoomLevel) + editor.setCamera({ + x: editor.camera.x - testOffsetX, + y: editor.camera.y - testOffsetY, + z: editor.zoomLevel, + }) editor.paste() - const shapesAfter = editor.shapesOnCurrentPage + const shapesAfter = editor.currentPageShapes // We should not have changed the original shapes expect(shapesBefore[0]).toMatchObject(shapesAfter[0]) @@ -109,17 +113,21 @@ describe('When copying and pasting', () => { { id: ids.box2, type: 'geo', x: 1900, y: 0, props: { w: 100, h: 100 } }, ]) - const shapesBefore = editor.shapesOnCurrentPage + const shapesBefore = editor.currentPageShapes editor.selectAll().copy() await assertClipboardOfCorrectShape(mockClipboard.current) const testOffsetX = 1800 const testOffsetY = 0 - editor.setCamera(editor.camera.x - testOffsetX, editor.camera.y - testOffsetY, editor.zoomLevel) + editor.setCamera({ + x: editor.camera.x - testOffsetX, + y: editor.camera.y - testOffsetY, + z: editor.zoomLevel, + }) editor.paste() - const shapesAfter = editor.shapesOnCurrentPage + const shapesAfter = editor.currentPageShapes // We should not have changed the original shapes expect(shapesBefore[0]).toMatchObject(shapesAfter[0]) @@ -145,7 +153,7 @@ describe('When copying and pasting', () => { { id: ids.box2, type: 'geo', x: 0, y: 0, props: { w: 100, h: 100 } }, ]) - const shapesBefore = editor.shapesOnCurrentPage + const shapesBefore = editor.currentPageShapes editor.selectAll().copy() await assertClipboardOfCorrectShape(mockClipboard.current) @@ -154,10 +162,14 @@ describe('When copying and pasting', () => { const testOffsetY = 3000 const { w: screenWidth, h: screenHeight } = editor.viewportScreenBounds - editor.setCamera(editor.camera.x - testOffsetX, editor.camera.y - testOffsetY, editor.zoomLevel) + editor.setCamera({ + x: editor.camera.x - testOffsetX, + y: editor.camera.y - testOffsetY, + z: editor.zoomLevel, + }) editor.paste() - const shapesAfter = editor.shapesOnCurrentPage + const shapesAfter = editor.currentPageShapes // We should not have changed the original shapes expect(shapesBefore[0]).toMatchObject(shapesAfter[0]) @@ -214,14 +226,14 @@ describe('When copying and pasting', () => { }, ]) - const shapesBefore = editor.shapesOnCurrentPage + const shapesBefore = editor.currentPageShapes editor.selectAll().copy() // Test the shape of the clipboard data. await assertClipboardOfCorrectShape(mockClipboard.current) editor.paste() - const shapesAfter = editor.shapesOnCurrentPage + const shapesAfter = editor.currentPageShapes // We should not have changed the original shapes expect(shapesBefore[0]).toMatchObject(shapesAfter[0]) @@ -273,17 +285,21 @@ describe('When copying and pasting', () => { { id: ids.box2, type: 'geo', x: 300, y: 300, props: { w: 100, h: 100 } }, ]) - const shapesBefore = editor.shapesOnCurrentPage + const shapesBefore = editor.currentPageShapes editor.selectAll().cut() await assertClipboardOfCorrectShape(mockClipboard.current) const testOffsetX = 100 const testOffsetY = 100 - editor.setCamera(editor.camera.x - testOffsetX, editor.camera.y - testOffsetY, editor.zoomLevel) + editor.setCamera({ + x: editor.camera.x - testOffsetX, + y: editor.camera.y - testOffsetY, + z: editor.zoomLevel, + }) editor.paste() - const shapesAfter = editor.shapesOnCurrentPage + const shapesAfter = editor.currentPageShapes // The new shapes should match the old shapes, except for their id expect(shapesAfter.length).toBe(shapesBefore.length) @@ -299,17 +315,21 @@ describe('When copying and pasting', () => { { id: ids.box2, type: 'geo', x: 1900, y: 0, props: { w: 100, h: 100 } }, ]) - const shapesBefore = editor.shapesOnCurrentPage + const shapesBefore = editor.currentPageShapes editor.selectAll().cut() await assertClipboardOfCorrectShape(mockClipboard.current) const testOffsetX = 1800 const testOffsetY = 0 - editor.setCamera(editor.camera.x - testOffsetX, editor.camera.y - testOffsetY, editor.zoomLevel) + editor.setCamera({ + x: editor.camera.x - testOffsetX, + y: editor.camera.y - testOffsetY, + z: editor.zoomLevel, + }) editor.paste() - const shapesAfter = editor.shapesOnCurrentPage + const shapesAfter = editor.currentPageShapes // The new shapes should match the old shapes, except for their id expect(shapesAfter.length).toBe(shapesBefore.length) @@ -326,7 +346,7 @@ describe('When copying and pasting', () => { { id: ids.box2, type: 'geo', x: 0, y: 0, props: { w: 100, h: 100 } }, ]) - const shapesBefore = editor.shapesOnCurrentPage + const shapesBefore = editor.currentPageShapes editor.selectAll().cut() await assertClipboardOfCorrectShape(mockClipboard.current) @@ -335,10 +355,14 @@ describe('When copying and pasting', () => { const testOffsetY = 3000 const { w: screenWidth, h: screenHeight } = editor.viewportScreenBounds - editor.setCamera(editor.camera.x - testOffsetX, editor.camera.y - testOffsetY, editor.zoomLevel) + editor.setCamera({ + x: editor.camera.x - testOffsetX, + y: editor.camera.y - testOffsetY, + z: editor.zoomLevel, + }) editor.paste() - const shapesAfter = editor.shapesOnCurrentPage + const shapesAfter = editor.currentPageShapes // The new shapes should match the old shapes, except for the should be positioned on the new viewport center. expect(shapesAfter.length).toBe(shapesBefore.length) @@ -384,7 +408,7 @@ describe('When copying and pasting', () => { }, ]) - const shapesBefore = editor.shapesOnCurrentPage + const shapesBefore = editor.currentPageShapes editor.selectAll().cut() @@ -392,7 +416,7 @@ describe('When copying and pasting', () => { await assertClipboardOfCorrectShape(mockClipboard.current) editor.paste() - const shapesAfter = editor.shapesOnCurrentPage + const shapesAfter = editor.currentPageShapes // The new shapes should match the old shapes, except for their id and the arrow's bindings! expect(shapesAfter.length).toBe(shapesBefore.length) @@ -426,7 +450,7 @@ describe('When copying and pasting', () => { // Move the group .updateShapes([ { - id: editor.shapesOnCurrentPage[2].id, + id: editor.currentPageShapes[2].id, type: 'group', x: 400, y: 400, @@ -440,12 +464,12 @@ describe('When copying and pasting', () => { await assertClipboardOfCorrectShape(mockClipboard.current) // Paste the shape - expect(editor.shapesOnCurrentPage.length).toEqual(3) + expect(editor.currentPageShapes.length).toEqual(3) editor.paste() - expect(editor.shapesOnCurrentPage.length).toEqual(4) + expect(editor.currentPageShapes.length).toEqual(4) // Check if the position is correct - const pastedShape = editor.shapesOnCurrentPage[editor.shapesOnCurrentPage.length - 1] + const pastedShape = editor.currentPageShapes[editor.currentPageShapes.length - 1] const pastedPoint = { x: pastedShape.x, y: pastedShape.y } expect(pastedPoint).toMatchObject({ x: 150, y: 150 }) // center of group diff --git a/packages/tldraw/src/test/commands/createShapes.test.ts b/packages/tldraw/src/test/commands/createShapes.test.ts index f1e29addd..ff6921cf7 100644 --- a/packages/tldraw/src/test/commands/createShapes.test.ts +++ b/packages/tldraw/src/test/commands/createShapes.test.ts @@ -117,13 +117,13 @@ it('Creates shapes at the correct index', () => { }) it('Throws out all shapes if any shape is invalid', () => { - const n = editor.shapeIdsOnCurrentPage.size + const n = editor.currentPageShapeIds.size expect(() => { editor.createShapes([{ id: ids.box1, type: 'geo' }]) }).not.toThrow() - expect(editor.shapeIdsOnCurrentPage.size).toBe(n + 1) + expect(editor.currentPageShapeIds.size).toBe(n + 1) console.error = jest.fn() @@ -136,5 +136,5 @@ it('Throws out all shapes if any shape is invalid', () => { ]) }).toThrow() - expect(editor.shapeIdsOnCurrentPage.size).toBe(n + 1) + expect(editor.currentPageShapeIds.size).toBe(n + 1) }) diff --git a/packages/tldraw/src/test/commands/duplicatePage.test.ts b/packages/tldraw/src/test/commands/duplicatePage.test.ts index 626901aa6..bb7b58eab 100644 --- a/packages/tldraw/src/test/commands/duplicatePage.test.ts +++ b/packages/tldraw/src/test/commands/duplicatePage.test.ts @@ -17,7 +17,7 @@ it('Duplicates a page', () => { const oldPageId = editor.currentPageId const camera = { ...editor.camera } const n = editor.pages.length - expect(editor.shapesOnCurrentPage.length).toBe(1) + expect(editor.currentPageShapes.length).toBe(1) const existingIds = new Set(editor.pages.map((s) => s.id)) @@ -31,7 +31,7 @@ it('Duplicates a page', () => { expect(editor.currentPageId).toBe(newPageId) // Duplicates the shapes - expect(editor.shapesOnCurrentPage.length).toBe(1) + expect(editor.currentPageShapes.length).toBe(1) // Also duplicates the camera expect(editor.camera.x).toBe(camera.x) diff --git a/packages/tldraw/src/test/commands/getInitialMetaForShape.test.ts b/packages/tldraw/src/test/commands/getInitialMetaForShape.test.ts index 007df7289..5cbcb2447 100644 --- a/packages/tldraw/src/test/commands/getInitialMetaForShape.test.ts +++ b/packages/tldraw/src/test/commands/getInitialMetaForShape.test.ts @@ -8,12 +8,16 @@ beforeEach(() => { }) it('Sets shape meta by default to an empty object', () => { - editor.createShapes([{ id: createShapeId(), type: 'geo' }], true) + const id = createShapeId() + editor.createShapes([{ id, type: 'geo' }]) + editor.select(id) expect(editor.onlySelectedShape!.meta).toStrictEqual({}) }) it('Sets shape meta', () => { editor.getInitialMetaForShape = (shape) => ({ firstThreeCharactersOfId: shape.id.slice(0, 3) }) - editor.createShapes([{ id: createShapeId(), type: 'geo' }], true) + const id = createShapeId() + editor.createShapes([{ id, type: 'geo' }]) + editor.select(id) expect(editor.onlySelectedShape!.meta).toStrictEqual({ firstThreeCharactersOfId: 'sha' }) }) diff --git a/packages/tldraw/src/test/commands/lockShapes.test.ts b/packages/tldraw/src/test/commands/lockShapes.test.ts index 4350a907d..cd363bc3b 100644 --- a/packages/tldraw/src/test/commands/lockShapes.test.ts +++ b/packages/tldraw/src/test/commands/lockShapes.test.ts @@ -92,9 +92,9 @@ describe('Locking', () => { describe('Locked shapes', () => { it('Cannot be deleted', () => { - const numberOfShapesBefore = editor.shapesOnCurrentPage.length + const numberOfShapesBefore = editor.currentPageShapes.length editor.deleteShapes([ids.lockedShapeA]) - expect(editor.shapesOnCurrentPage.length).toBe(numberOfShapesBefore) + expect(editor.currentPageShapes.length).toBe(numberOfShapesBefore) }) it('Cannot be changed', () => { @@ -133,20 +133,20 @@ describe('Locked shapes', () => { it('Cannot be edited', () => { const shape = editor.getShape(ids.lockedShapeA)! - const shapeCount = editor.shapesOnCurrentPage.length + const shapeCount = editor.currentPageShapes.length // We create a new shape and we edit that one editor.doubleClick(10, 10, { target: 'shape', shape }).expectToBeIn('select.editing_shape') - expect(editor.shapesOnCurrentPage.length).toBe(shapeCount + 1) + expect(editor.currentPageShapes.length).toBe(shapeCount + 1) expect(editor.selectedShapeIds).not.toContain(shape.id) }) it('Cannot be grouped', () => { - const shapeCount = editor.shapesOnCurrentPage.length + const shapeCount = editor.currentPageShapes.length const parentBefore = editor.getShape(ids.lockedShapeA)!.parentId editor.groupShapes([ids.lockedShapeA, ids.unlockedShapeA, ids.unlockedShapeB]) - expect(editor.shapesOnCurrentPage.length).toBe(shapeCount + 1) + expect(editor.currentPageShapes.length).toBe(shapeCount + 1) const parentAfter = editor.getShape(ids.lockedShapeA)!.parentId expect(parentAfter).toBe(parentBefore) @@ -165,12 +165,13 @@ describe('Locked shapes', () => { describe('Unlocking', () => { it('Can unlock shapes', () => { editor.setSelectedShapeIds([ids.lockedShapeA, ids.lockedShapeB]) - let lockedStatus = [ids.lockedShapeA, ids.lockedShapeB].map( - (id) => editor.getShape(id)!.isLocked - ) - expect(lockedStatus).toStrictEqual([true, true]) - editor.toggleLock(editor.selectedShapeIds) - lockedStatus = [ids.lockedShapeA, ids.lockedShapeB].map((id) => editor.getShape(id)!.isLocked) - expect(lockedStatus).toStrictEqual([false, false]) + expect(editor.selectedShapeIds).toEqual([]) // can't select locked shapes! + + const before = [ids.lockedShapeA, ids.lockedShapeB].map((id) => editor.getShape(id)!.isLocked) + expect(before).toStrictEqual([true, true]) + + editor.toggleLock([ids.lockedShapeA, ids.lockedShapeB]) + const after = [ids.lockedShapeA, ids.lockedShapeB].map((id) => editor.getShape(id)!.isLocked) + expect(after).toStrictEqual([false, false]) }) }) diff --git a/packages/tldraw/src/test/commands/moveShapesToPage.test.ts b/packages/tldraw/src/test/commands/moveShapesToPage.test.ts index b760abc06..6cbc85a1b 100644 --- a/packages/tldraw/src/test/commands/moveShapesToPage.test.ts +++ b/packages/tldraw/src/test/commands/moveShapesToPage.test.ts @@ -38,13 +38,13 @@ describe('Editor.moveShapesToPage', () => { // box1 didn't get moved, still on page 1 expect(editor.getShape(ids.box1)!.parentId).toBe(ids.page1) - expect([...editor.shapeIdsOnCurrentPage].sort()).toMatchObject([ids.box2, ids.ellipse1]) + expect([...editor.currentPageShapeIds].sort()).toMatchObject([ids.box2, ids.ellipse1]) expect(editor.currentPageId).toBe(ids.page2) editor.setCurrentPage(ids.page1) - expect([...editor.shapeIdsOnCurrentPage]).toEqual([ids.box1]) + expect([...editor.currentPageShapeIds]).toEqual([ids.box1]) }) it('Moves children to page', () => { @@ -80,31 +80,23 @@ describe('Editor.moveShapesToPage', () => { it('Restores on undo / redo', () => { expect(editor.currentPageId).toBe(ids.page1) - expect([...editor.shapeIdsOnCurrentPage].sort()).toMatchObject([ - ids.box1, - ids.box2, - ids.ellipse1, - ]) + expect([...editor.currentPageShapeIds].sort()).toMatchObject([ids.box1, ids.box2, ids.ellipse1]) editor.mark('move shapes to page') editor.moveShapesToPage([ids.box2], ids.page2) expect(editor.currentPageId).toBe(ids.page2) - expect([...editor.shapeIdsOnCurrentPage].sort()).toMatchObject([ids.box2]) + expect([...editor.currentPageShapeIds].sort()).toMatchObject([ids.box2]) editor.undo() expect(editor.currentPageId).toBe(ids.page1) - expect([...editor.shapeIdsOnCurrentPage].sort()).toMatchObject([ - ids.box1, - ids.box2, - ids.ellipse1, - ]) + expect([...editor.currentPageShapeIds].sort()).toMatchObject([ids.box1, ids.box2, ids.ellipse1]) editor.redo() expect(editor.currentPageId).toBe(ids.page2) - expect([...editor.shapeIdsOnCurrentPage].sort()).toMatchObject([ids.box2]) + expect([...editor.currentPageShapeIds].sort()).toMatchObject([ids.box2]) }) it('Sets the correct indices', () => { diff --git a/packages/tldraw/src/test/commands/packShapes.test.ts b/packages/tldraw/src/test/commands/packShapes.test.ts index 75436dccb..6376d7097 100644 --- a/packages/tldraw/src/test/commands/packShapes.test.ts +++ b/packages/tldraw/src/test/commands/packShapes.test.ts @@ -44,7 +44,7 @@ describe('editor.packShapes', () => { const centerBefore = editor.selectionBounds!.center.clone() editor.packShapes(editor.selectedShapeIds, 16) jest.advanceTimersByTime(1000) - expect(editor.shapesOnCurrentPage.map((s) => ({ ...s, parentId: 'wahtever' }))).toMatchSnapshot( + expect(editor.currentPageShapes.map((s) => ({ ...s, parentId: 'wahtever' }))).toMatchSnapshot( 'packed shapes' ) const centerAfter = editor.selectionBounds!.center.clone() @@ -55,7 +55,7 @@ describe('editor.packShapes', () => { editor.updateShapes([{ id: ids.boxA, type: 'geo', rotation: Math.PI }]) editor.selectAll().packShapes(editor.selectedShapeIds, 16) jest.advanceTimersByTime(1000) - expect(editor.shapesOnCurrentPage.map((s) => ({ ...s, parentId: 'wahtever' }))).toMatchSnapshot( + expect(editor.currentPageShapes.map((s) => ({ ...s, parentId: 'wahtever' }))).toMatchSnapshot( 'packed shapes' ) }) diff --git a/packages/tldraw/src/test/commands/pageToScreen.test.ts b/packages/tldraw/src/test/commands/pageToScreen.test.ts index 9f62f19cc..3419f626d 100644 --- a/packages/tldraw/src/test/commands/pageToScreen.test.ts +++ b/packages/tldraw/src/test/commands/pageToScreen.test.ts @@ -4,19 +4,22 @@ let editor: TestEditor beforeEach(() => { editor = new TestEditor() + editor.setCamera({ x: 0, y: 0, z: 1 }) }) describe('viewport.pageToScreen', () => { it('converts correctly', () => { - expect(editor.pageToScreen(0, 0)).toMatchObject({ x: 0, y: 0 }) - expect(editor.pageToScreen(200, 200)).toMatchObject({ + expect(editor.pageToScreen({ x: 0, y: 0 })).toMatchObject({ x: 0, y: 0 }) + expect(editor.pageToScreen({ x: 200, y: 200 })).toMatchObject({ x: 200, y: 200, }) - editor.setCamera(100, 100) - expect(editor.pageToScreen(200, 200)).toMatchObject({ + editor.setCamera({ x: 100, y: 100 }) + expect(editor.pageToScreen({ x: 200, y: 200 })).toMatchObject({ x: 300, y: 300, }) }) + + // see `screen to page` for paired tests }) diff --git a/packages/tldraw/src/test/commands/pan.test.ts b/packages/tldraw/src/test/commands/pan.test.ts index 12faf6752..690973ed4 100644 --- a/packages/tldraw/src/test/commands/pan.test.ts +++ b/packages/tldraw/src/test/commands/pan.test.ts @@ -10,7 +10,7 @@ beforeEach(() => { describe('When panning', () => { it('Updates the camera', () => { - editor.pan(200, 200) + editor.pan({ x: 200, y: 200 }) editor.expectCameraToBe(200, 200, 1) }) @@ -23,7 +23,7 @@ describe('When panning', () => { screenBounds.h ) const beforePageBounds = editor.viewportPageBounds.clone() - editor.pan(200, 200) + editor.pan({ x: 200, y: 200 }) expect(editor.viewportScreenBounds).toMatchObject(beforeScreenBounds.toJson()) expect(editor.viewportPageBounds.toJson()).toMatchObject( beforePageBounds.translate(new Vec2d(-200, -200)).toJson() diff --git a/packages/tldraw/src/test/commands/penmode.test.ts b/packages/tldraw/src/test/commands/penmode.test.ts index 84f53a3b7..fb0d397ab 100644 --- a/packages/tldraw/src/test/commands/penmode.test.ts +++ b/packages/tldraw/src/test/commands/penmode.test.ts @@ -23,5 +23,5 @@ it('ignores touch events while in pen mode', async () => { target: 'canvas', }) - expect(editor.shapesOnCurrentPage.length).toBe(0) + expect(editor.currentPageShapes.length).toBe(0) }) diff --git a/packages/tldraw/src/test/commands/reorderShapes.test.ts b/packages/tldraw/src/test/commands/reorderShapes.test.ts index 08f2ccecc..d24652f4c 100644 --- a/packages/tldraw/src/test/commands/reorderShapes.test.ts +++ b/packages/tldraw/src/test/commands/reorderShapes.test.ts @@ -4,7 +4,7 @@ import { TestEditor } from '../TestEditor' let editor: TestEditor function expectShapesInOrder(editor: TestEditor, ...ids: TLShapeId[]) { - expect(editor.sortedShapesOnCurrentPage.map((shape) => shape.id)).toMatchObject(ids) + expect(editor.currentPageShapesSorted.map((shape) => shape.id)).toMatchObject(ids) } function getSiblingBelow(editor: TestEditor, id: TLShapeId) { @@ -68,7 +68,7 @@ beforeEach(() => { describe('When running zindex tests', () => { it('Correctly initializes indices', () => { - expect(editor.sortedShapesOnCurrentPage.map((shape) => shape.index)).toMatchObject([ + expect(editor.currentPageShapesSorted.map((shape) => shape.index)).toMatchObject([ 'a1', 'a2', 'a3', diff --git a/packages/tldraw/src/test/commands/reparentShapesById.test.ts b/packages/tldraw/src/test/commands/reparentShapesById.test.ts index 392ac4cf4..ea0abf44c 100644 --- a/packages/tldraw/src/test/commands/reparentShapesById.test.ts +++ b/packages/tldraw/src/test/commands/reparentShapesById.test.ts @@ -105,7 +105,7 @@ it('adds children at a given index', () => { expect(editor.getShape(ids.ellipse1)!.index).toBe('a1') // Handles collisions (trying to move box3 to a0, but box2 is there already) - editor.reparentShapes([ids.box3], ids.box1, 'a1') + editor.reparentShapes([ids.box3], ids.box1, { insertIndex: 'a1' }) // Page // - box1 a1 @@ -124,7 +124,7 @@ it('adds children at a given index', () => { // Handles collisions (trying to move box5 to a0, but box2 is there already) // should end up between box 2 and box 3 (a0 and a1) - editor.reparentShapes([ids.box5], ids.box1, 'a1') + editor.reparentShapes([ids.box5], ids.box1, { insertIndex: 'a1' }) // Page // - box1 a1 @@ -143,7 +143,7 @@ it('adds children at a given index', () => { // Handles collisions (trying to move boxes 2, 3, and 5 to a0, but box1 is there already) // Should order them between box1 and box4 - editor.reparentShapes([ids.box2, ids.box3, ids.box5], editor.currentPageId, 'a1') + editor.reparentShapes([ids.box2, ids.box3, ids.box5], editor.currentPageId, { insertIndex: 'a1' }) // Page // - box1 a1 diff --git a/packages/tldraw/src/test/commands/resizeShape.test.ts b/packages/tldraw/src/test/commands/resizeShape.test.ts index f13d137c3..df6628973 100644 --- a/packages/tldraw/src/test/commands/resizeShape.test.ts +++ b/packages/tldraw/src/test/commands/resizeShape.test.ts @@ -21,16 +21,21 @@ beforeEach(() => { describe('resizing a shape', () => { it('always squashes history entries', () => { + const startHistoryLength = editor.history.numUndos + expect(startHistoryLength).toBe(0) + editor.createShapes([{ id: ids.boxA, type: 'geo', props: { w: 100, h: 100 } }]) + expect(editor.history.numUndos).toBe(startHistoryLength + 1) editor.mark('start') - const startHistoryLength = editor.history.numUndos + expect(editor.history.numUndos).toBe(startHistoryLength + 2) + editor.resizeShape(ids.boxA, { x: 2, y: 2 }) - expect(editor.history.numUndos).toBe(startHistoryLength + 1) + expect(editor.history.numUndos).toBe(startHistoryLength + 3) editor.resizeShape(ids.boxA, { x: 2, y: 2 }) - expect(editor.history.numUndos).toBe(startHistoryLength + 1) + expect(editor.history.numUndos).toBe(startHistoryLength + 3) editor.resizeShape(ids.boxA, { x: 2, y: 2 }) - expect(editor.history.numUndos).toBe(startHistoryLength + 1) + expect(editor.history.numUndos).toBe(startHistoryLength + 3) expect(editor.getPageBounds(ids.boxA)).toCloselyMatchObject({ w: 800, diff --git a/packages/tldraw/src/test/commands/screenToPage.test.ts b/packages/tldraw/src/test/commands/screenToPage.test.ts index 5a5573598..b7560a824 100644 --- a/packages/tldraw/src/test/commands/screenToPage.test.ts +++ b/packages/tldraw/src/test/commands/screenToPage.test.ts @@ -8,15 +8,293 @@ beforeEach(() => { describe('viewport.screenToPage', () => { it('converts correctly', () => { - expect(editor.screenToPage(0, 0)).toMatchObject({ x: 0, y: 0 }) - expect(editor.screenToPage(200, 200)).toMatchObject({ - x: 200, - y: 200, + expect(editor.screenToPage({ x: 0, y: 0 })).toMatchObject({ x: 0, y: 0 }) + expect(editor.pageToScreen({ x: 0, y: 0 })).toMatchObject({ x: 0, y: 0 }) + + expect(editor.screenToPage({ x: 100, y: 100 })).toMatchObject({ x: 100, y: 100 }) + expect(editor.pageToScreen({ x: 100, y: 100 })).toMatchObject({ x: 100, y: 100 }) + + expect(editor.screenToPage({ x: -100, y: -100 })).toMatchObject({ x: -100, y: -100 }) + expect(editor.pageToScreen({ x: -100, y: -100 })).toMatchObject({ x: -100, y: -100 }) + }) + + it('converts correctly when zoomed', () => { + editor.setCamera({ x: 0, y: 0, z: 0.5 }) + + expect(editor.screenToPage({ x: 0, y: 0 })).toMatchObject({ x: 0, y: 0 }) + expect(editor.pageToScreen({ x: 0, y: 0 })).toMatchObject({ x: 0, y: 0 }) + + expect(editor.screenToPage({ x: 100, y: 100 })).toMatchObject({ x: 200, y: 200 }) + expect(editor.pageToScreen({ x: 200, y: 200 })).toMatchObject({ x: 100, y: 100 }) + + expect(editor.screenToPage({ x: -100, y: -100 })).toMatchObject({ x: -200, y: -200 }) + expect(editor.pageToScreen({ x: -200, y: -200 })).toMatchObject({ x: -100, y: -100 }) + }) + + it('converts correctly when panned', () => { + editor.setCamera({ x: 100, y: 100 }) + + expect(editor.screenToPage({ x: 0, y: 0 })).toMatchObject({ x: -100, y: -100 }) + expect(editor.pageToScreen({ x: -100, y: -100 })).toMatchObject({ x: 0, y: 0 }) + + expect(editor.screenToPage({ x: 100, y: 100 })).toMatchObject({ x: 0, y: 0 }) + expect(editor.pageToScreen({ x: 0, y: 0 })).toMatchObject({ x: 100, y: 100 }) + + expect(editor.screenToPage({ x: -100, y: -100 })).toMatchObject({ x: -200, y: -200 }) + expect(editor.pageToScreen({ x: -200, y: -200 })).toMatchObject({ x: -100, y: -100 }) + }) + + it('converts correctly when panned and zoomed', () => { + editor.setCamera({ x: 100, y: 100, z: 0.5 }) + + expect(editor.screenToPage({ x: 0, y: 0 })).toMatchObject({ x: -200, y: -200 }) + expect(editor.pageToScreen({ x: -200, y: -200 })).toMatchObject({ x: 0, y: 0 }) + + expect(editor.screenToPage({ x: 100, y: 100 })).toMatchObject({ x: 0, y: 0 }) + expect(editor.pageToScreen({ x: 0, y: 0 })).toMatchObject({ x: 100, y: 100 }) + + expect(editor.screenToPage({ x: -100, y: -100 })).toMatchObject({ x: -400, y: -400 }) + expect(editor.pageToScreen({ x: -400, y: -400 })).toMatchObject({ x: -100, y: -100 }) + }) + + it('converts correctly when offset', () => { + // move the editor's page bounds down and to the left by 100, 100 + // 0,0 s + // +------------------------+ + // | 100,100 s | + // | c-----------------+ | + // | | 0,0 p | | + // | | | | + + editor.updateInstanceState({ screenBounds: { ...editor.viewportScreenBounds, x: 100, y: 100 } }) + + expect(editor.screenToPage({ x: 0, y: 0 })).toMatchObject({ x: -100, y: -100 }) + expect(editor.pageToScreen({ x: -100, y: -100 })).toMatchObject({ x: 0, y: 0 }) + + expect(editor.screenToPage({ x: -100, y: -100 })).toMatchObject({ x: -200, y: -200 }) + expect(editor.pageToScreen({ x: -200, y: -200 })).toMatchObject({ x: -100, y: -100 }) + + expect(editor.screenToPage({ x: 100, y: 100 })).toMatchObject({ x: 0, y: 0 }) + expect(editor.pageToScreen({ x: 0, y: 0 })).toMatchObject({ x: 100, y: 100 }) + + // 0,0 s + // c------------------------+ + // | 100,100 s | + // | +-----------------+ | + // | | 100,100 p | | + // | | | | + + editor.setCamera({ x: -100, y: -100 }) // -100, -100 + expect(editor.screenToPage({ x: -100, y: -100 })).toMatchObject({ x: -100, y: -100 }) + expect(editor.pageToScreen({ x: -100, y: -100 })).toMatchObject({ x: -100, y: -100 }) + expect(editor.screenToPage({ x: 0, y: 0 })).toMatchObject({ x: 0, y: 0 }) + expect(editor.pageToScreen({ x: 0, y: 0 })).toMatchObject({ x: 0, y: 0 }) + expect(editor.screenToPage({ x: 100, y: 100 })).toMatchObject({ x: 100, y: 100 }) + expect(editor.pageToScreen({ x: 100, y: 100 })).toMatchObject({ x: 100, y: 100 }) + + // 0,0 s no offset, zoom at 50% + // c------------------------+ + // | 0,0 p | + // | | + // | | + // | | + editor.setCamera({ x: 0, y: 0, z: 0.5 }) + editor.updateInstanceState({ screenBounds: { ...editor.viewportScreenBounds, x: 0, y: 0 } }) + expect(editor.screenToPage({ x: 0, y: 0 })).toMatchObject({ x: 0, y: 0 }) + expect(editor.pageToScreen({ x: 0, y: 0 })).toMatchObject({ x: 0, y: 0 }) + expect(editor.screenToPage({ x: -100, y: -100 })).toMatchObject({ x: -200, y: -200 }) + expect(editor.pageToScreen({ x: -200, y: -200 })).toMatchObject({ x: -100, y: -100 }) + expect(editor.screenToPage({ x: 100, y: 100 })).toMatchObject({ x: 200, y: 200 }) + expect(editor.pageToScreen({ x: 200, y: 200 })).toMatchObject({ x: 100, y: 100 }) + }) + + it('converts correctly when zoomed out', () => { + // camera at zero, screenbounds at zero, but zoom at .5 + editor.setCamera({ x: 0, y: 0, z: 0.5 }) + editor.updateInstanceState({ screenBounds: { ...editor.viewportScreenBounds, x: 0, y: 0 } }) + expect(editor.screenToPage({ x: 0, y: 0 })).toMatchObject({ x: 0, y: 0 }) + expect(editor.pageToScreen({ x: 0, y: 0 })).toMatchObject({ x: 0, y: 0 }) + expect(editor.screenToPage({ x: -100, y: -100 })).toMatchObject({ x: -200, y: -200 }) + expect(editor.pageToScreen({ x: -200, y: -200 })).toMatchObject({ x: -100, y: -100 }) + expect(editor.screenToPage({ x: 100, y: 100 })).toMatchObject({ x: 200, y: 200 }) + expect(editor.pageToScreen({ x: 200, y: 200 })).toMatchObject({ x: 100, y: 100 }) + }) + + it('converts correctly when zoomed in', () => { + editor.setCamera({ x: 0, y: 0, z: 2 }) + editor.updateInstanceState({ screenBounds: { ...editor.viewportScreenBounds, x: 0, y: 0 } }) + expect(editor.screenToPage({ x: 0, y: 0 })).toMatchObject({ x: 0, y: 0 }) + expect(editor.screenToPage({ x: -100, y: -100 })).toMatchObject({ x: -50, y: -50 }) + expect(editor.screenToPage({ x: 100, y: 100 })).toMatchObject({ x: 50, y: 50 }) + }) + + it('converts correctly when zoomed', () => { + // camera at zero, screenbounds at zero, but zoom at .5 + editor.updateInstanceState({ screenBounds: { ...editor.viewportScreenBounds, x: 0, y: 0 } }) + editor.setCamera({ x: 0, y: 0, z: 0.5 }) + + // zero point, where page and screen are the same + expect(editor.pageToScreen({ x: 0, y: 0 })).toMatchObject({ x: 0, y: 0 }) + expect(editor.screenToPage({ x: 0, y: 0 })).toMatchObject({ x: 0, y: 0 }) + + expect(editor.pageToScreen({ x: 100, y: 100 })).toMatchObject({ x: 50, y: 50 }) + expect(editor.screenToPage({ x: 50, y: 50 })).toMatchObject({ x: 100, y: 100 }) + + expect(editor.pageToScreen({ x: 200, y: 200 })).toMatchObject({ x: 100, y: 100 }) + expect(editor.screenToPage({ x: 100, y: 100 })).toMatchObject({ x: 200, y: 200 }) + }) + + it('converts correctly when zoomed and panned', () => { + editor.updateInstanceState({ screenBounds: { ...editor.viewportScreenBounds, x: 0, y: 0 } }) + editor.setCamera({ x: 100, y: 100, z: 0.5 }) + expect(editor.pageToScreen({ x: 0, y: 0 })).toMatchObject({ x: 100, y: 100 }) + expect(editor.screenToPage({ x: 100, y: 100 })).toMatchObject({ x: 0, y: 0 }) + + expect(editor.pageToScreen({ x: 100, y: 100 })).toMatchObject({ x: 150, y: 150 }) + expect(editor.screenToPage({ x: 150, y: 150 })).toMatchObject({ x: 100, y: 100 }) + + // zero point, where page and screen are the same + expect(editor.pageToScreen({ x: 200, y: 200 })).toMatchObject({ x: 200, y: 200 }) + expect(editor.screenToPage({ x: 200, y: 200 })).toMatchObject({ x: 200, y: 200 }) + }) + + it('converts correctly when offset', () => { + editor.updateInstanceState({ screenBounds: { ...editor.viewportScreenBounds, x: 100, y: 100 } }) + editor.setCamera({ x: 0, y: 0, z: 0.5 }) + + expect(editor.pageToScreen({ x: 0, y: 0 })).toMatchObject({ x: 100, y: 100 }) + expect(editor.pageToScreen({ x: 100, y: 100 })).toMatchObject({ x: 150, y: 150 }) + expect(editor.pageToScreen({ x: 200, y: 200 })).toMatchObject({ x: 200, y: 200 }) + + expect(editor.screenToPage({ x: 0, y: 0 })).toMatchObject({ x: -200, y: -200 }) + expect(editor.screenToPage({ x: 100, y: 100 })).toMatchObject({ x: 0, y: 0 }) + expect(editor.screenToPage({ x: 200, y: 200 })).toMatchObject({ x: 200, y: 200 }) + expect(editor.screenToPage({ x: 300, y: 300 })).toMatchObject({ x: 400, y: 400 }) + }) + + it('converts correctly when panned', () => { + editor.updateInstanceState({ screenBounds: { ...editor.viewportScreenBounds, x: 0, y: 0 } }) + editor.setCamera({ x: 100, y: 100, z: 1 }) + + expect(editor.pageToScreen({ x: 0, y: 0 })).toMatchObject({ x: 100, y: 100 }) + expect(editor.pageToScreen({ x: 100, y: 100 })).toMatchObject({ x: 200, y: 200 }) + expect(editor.pageToScreen({ x: 200, y: 200 })).toMatchObject({ x: 300, y: 300 }) + + expect(editor.screenToPage({ x: 100, y: 100 })).toMatchObject({ x: 0, y: 0 }) + expect(editor.screenToPage({ x: 200, y: 200 })).toMatchObject({ x: 100, y: 100 }) + expect(editor.screenToPage({ x: 300, y: 300 })).toMatchObject({ x: 200, y: 200 }) + }) + + it('converts correctly when panned and zoomed', () => { + editor.updateInstanceState({ screenBounds: { ...editor.viewportScreenBounds, x: 0, y: 0 } }) + editor.setCamera({ x: 100, y: 100, z: 0.5 }) + + expect(editor.pageToScreen({ x: 0, y: 0 })).toMatchObject({ x: 100, y: 100 }) + expect(editor.pageToScreen({ x: 100, y: 100 })).toMatchObject({ x: 150, y: 150 }) + expect(editor.pageToScreen({ x: 200, y: 200 })).toMatchObject({ x: 200, y: 200 }) + + expect(editor.screenToPage({ x: 100, y: 100 })).toMatchObject({ x: 0, y: 0 }) + expect(editor.screenToPage({ x: 150, y: 150 })).toMatchObject({ x: 100, y: 100 }) + expect(editor.screenToPage({ x: 200, y: 200 })).toMatchObject({ x: 200, y: 200 }) + }) + + it('converts correctly when panned and zoomed and offset', () => { + editor.updateInstanceState({ screenBounds: { ...editor.viewportScreenBounds, x: 100, y: 100 } }) + editor.setCamera({ x: 100, y: 100, z: 0.5 }) + + expect(editor.pageToScreen({ x: 0, y: 0 })).toMatchObject({ x: 200, y: 200 }) + expect(editor.pageToScreen({ x: 100, y: 100 })).toMatchObject({ x: 250, y: 250 }) + expect(editor.pageToScreen({ x: 200, y: 200 })).toMatchObject({ x: 300, y: 300 }) + + expect(editor.screenToPage({ x: 200, y: 200 })).toMatchObject({ x: 0, y: 0 }) + expect(editor.screenToPage({ x: 250, y: 250 })).toMatchObject({ x: 100, y: 100 }) + expect(editor.screenToPage({ x: 300, y: 300 })).toMatchObject({ x: 200, y: 200 }) + }) +}) + +describe('viewportPageBounds', () => { + it('sets the page bounds', () => { + editor.updateInstanceState({ + screenBounds: { ...editor.viewportScreenBounds, x: 0, y: 0, w: 1000, h: 1000 }, }) - editor.setCamera(100, 100) - expect(editor.screenToPage(200, 200)).toMatchObject({ - x: 100, - y: 100, + editor.setCamera({ x: 0, y: 0, z: 1 }) + + expect(editor.viewportPageBounds).toMatchObject({ + x: -0, + y: -0, + w: 1000, + h: 1000, + }) + }) + + it('sets the page bounds when camera is zoomed', () => { + editor.updateInstanceState({ + screenBounds: { ...editor.viewportScreenBounds, x: 0, y: 0, w: 1000, h: 1000 }, + }) + editor.setCamera({ x: 0, y: 0, z: 2 }) + + expect(editor.viewportPageBounds).toMatchObject({ + x: -0, + y: -0, + w: 500, + h: 500, + }) + editor.setCamera({ x: 0, y: 0, z: 0.5 }) + + expect(editor.viewportPageBounds).toMatchObject({ + x: -0, + y: -0, + w: 2000, + h: 2000, + }) + }) + + it('sets the page bounds when camera is panned', () => { + editor.updateInstanceState({ + screenBounds: { ...editor.viewportScreenBounds, x: 0, y: 0, w: 1000, h: 1000 }, + }) + editor.setCamera({ x: 100, y: 100, z: 1 }) + + expect(editor.viewportPageBounds).toMatchObject({ + x: -100, + y: -100, + w: 1000, + h: 1000, + maxX: 900, + maxY: 900, + }) + }) + + it('sets the page bounds when camera is panned and zoomed', () => { + editor.updateInstanceState({ + screenBounds: { ...editor.viewportScreenBounds, x: 0, y: 0, w: 1000, h: 1000 }, + }) + editor.setCamera({ x: 100, y: 100, z: 2 }) + + expect(editor.viewportPageBounds).toMatchObject({ + x: -100, + y: -100, + w: 500, + h: 500, + maxX: 400, + maxY: 400, + }) + }) + + it('sets the page bounds when viewport is offset', () => { + editor.updateInstanceState({ + screenBounds: { ...editor.viewportScreenBounds, x: 100, y: 100, w: 1000, h: 1000 }, + }) + editor.setCamera({ x: 0, y: 0, z: 2 }) + + // changing the screen bounds should not affect the page bounds + expect(editor.viewportPageBounds).toMatchObject({ + x: -0, + y: -0, + w: 500, + h: 500, + maxX: 500, + maxY: 500, }) }) }) diff --git a/packages/tldraw/src/test/commands/setBrush.test.ts b/packages/tldraw/src/test/commands/setBrush.test.ts index cbb5d5fda..4a1d4b04d 100644 --- a/packages/tldraw/src/test/commands/setBrush.test.ts +++ b/packages/tldraw/src/test/commands/setBrush.test.ts @@ -9,7 +9,10 @@ beforeEach(() => { it('Sets the brush', () => { expect(editor.instanceState.brush).toEqual(null) - editor.updateInstanceState({ brush: { x: 0, y: 0, w: 100, h: 100 } }) + editor.updateInstanceState( + { brush: { x: 0, y: 0, w: 100, h: 100 } }, + { ephemeral: true, squashing: true } + ) expect(editor.instanceState.brush).toMatchObject({ x: 0, diff --git a/packages/tldraw/src/test/commands/setSelectedIds.test.ts b/packages/tldraw/src/test/commands/setSelectedIds.test.ts index e1d5f082e..35f1801e7 100644 --- a/packages/tldraw/src/test/commands/setSelectedIds.test.ts +++ b/packages/tldraw/src/test/commands/setSelectedIds.test.ts @@ -1,5 +1,5 @@ -import { createShapeId } from '@tldraw/editor' -import { TestEditor, createDefaultShapes } from '../TestEditor' +import { TAU, createShapeId } from '@tldraw/editor' +import { TestEditor } from '../TestEditor' let editor: TestEditor @@ -11,10 +11,56 @@ const ids = { beforeEach(() => { editor = new TestEditor() - editor.createShapes(createDefaultShapes()) + editor.createShapes([ + { + id: ids.box1, + type: 'geo', + x: 100, + y: 100, + props: { + w: 100, + h: 100, + geo: 'rectangle', + }, + }, + { + id: ids.box2, + type: 'geo', + x: 200, + y: 200, + rotation: TAU / 2, + props: { + w: 100, + h: 100, + color: 'black', + fill: 'none', + dash: 'draw', + size: 'm', + geo: 'rectangle', + }, + }, + { + id: ids.ellipse1, + type: 'geo', + parentId: ids.box2, // parented to box 2 + x: 200, + y: 200, + props: { + w: 50, + h: 50, + color: 'black', + fill: 'none', + dash: 'draw', + size: 'm', + geo: 'ellipse', + }, + }, + ]) }) it('Sets selected shapes', () => { + editor.mark() + expect(editor.selectedShapeIds).toMatchObject([]) editor.setSelectedShapeIds([ids.box1, ids.box2]) expect(editor.selectedShapeIds).toMatchObject([ids.box1, ids.box2]) @@ -29,9 +75,28 @@ it('Prevents parent and child from both being selected', () => { expect(editor.selectedShapeIds).toMatchObject([ids.box2]) }) +it('Deleting a shape also deselects it', () => { + editor.setSelectedShapeIds([ids.box1]) + + editor.mark('here') + + editor.deleteShapes([ids.box1]) + expect(editor.selectedShapeIds).toMatchObject([]) + + editor.undo() + + expect(editor.selectedShapeIds).toMatchObject([ids.box1]) + + editor.redo() + + expect(editor.selectedShapeIds).toMatchObject([]) +}) + it('Deleting the parent also deletes descendants', () => { editor.setSelectedShapeIds([ids.box2]) + editor.mark('here') + expect(editor.selectedShapeIds).toMatchObject([ids.box2]) expect(editor.getShape(ids.box2)).not.toBeUndefined() expect(editor.getShape(ids.ellipse1)).not.toBeUndefined() @@ -41,7 +106,7 @@ it('Deleting the parent also deletes descendants', () => { expect(editor.selectedShapeIds).toMatchObject([]) expect(editor.getShape(ids.box2)).toBeUndefined() - expect(editor.getShape(ids.ellipse1)).toBeUndefined() + expect(editor.getShape(ids.ellipse1)).toBeUndefined() // should be deleted because it was a descendant of box1 editor.undo() diff --git a/packages/tldraw/src/test/commands/stretch.test.tsx b/packages/tldraw/src/test/commands/stretch.test.tsx index 495182cff..72ad79afe 100644 --- a/packages/tldraw/src/test/commands/stretch.test.tsx +++ b/packages/tldraw/src/test/commands/stretch.test.tsx @@ -89,10 +89,9 @@ describe('when multiple shapes are selected', () => { }) it('does, undoes and redoes command', () => { + editor.expectShapeToMatch({ id: ids.boxB, x: 100, props: { w: 50 } }) editor.mark('stretch') editor.stretchShapes(editor.selectedShapeIds, 'horizontal') - jest.advanceTimersByTime(1000) - editor.expectShapeToMatch({ id: ids.boxB, x: 0, props: { w: 500 } }) editor.undo() editor.expectShapeToMatch({ id: ids.boxB, x: 100, props: { w: 50 } }) diff --git a/packages/tldraw/src/test/commands/updateViewportPageBounds.test.ts b/packages/tldraw/src/test/commands/updateViewportPageBounds.test.ts index 1960ed709..8afa3875d 100644 --- a/packages/tldraw/src/test/commands/updateViewportPageBounds.test.ts +++ b/packages/tldraw/src/test/commands/updateViewportPageBounds.test.ts @@ -51,17 +51,17 @@ describe('When resizing', () => { describe('When center is false', () => { it('keeps the same top left when resized', () => { - const a = editor.screenToPage(0, 0) + const a = editor.screenToPage({ x: 0, y: 0 }) editor.setScreenBounds({ x: 100, y: 200, w: 500, h: 600 }, false) - const b = editor.screenToPage(0, 0) + const b = editor.screenToPage({ x: 0, y: 0 }) expect(a).toMatchObject(b) }) it('keeps the same top left when resized while panned / zoomed', () => { - editor.setCamera(-100, -100, 1.2) - const a = editor.screenToPage(0, 0) + editor.setCamera({ x: -100, y: -100, z: 1.2 }) + const a = editor.screenToPage({ x: 0, y: 0 }) editor.setScreenBounds({ x: 100, y: 200, w: 500, h: 600 }, false) - const b = editor.screenToPage(0, 0) + const b = editor.screenToPage({ x: 0, y: 0 }) expect(a).toMatchObject(b) }) }) @@ -75,7 +75,7 @@ describe('When center is true', () => { }) it('keep the same page center when resized while panned / zoomed', () => { - editor.setCamera(-100, -100, 1.2) + editor.setCamera({ x: -100, y: -100, z: 1.2 }) const a = editor.viewportPageCenter.toJson() editor.setScreenBounds({ x: 100, y: 200, w: 500, h: 600 }, true) const b = editor.viewportPageCenter.toJson() diff --git a/packages/tldraw/src/test/commands/zoomIn.test.ts b/packages/tldraw/src/test/commands/zoomIn.test.ts index 16bab1753..c2afc867f 100644 --- a/packages/tldraw/src/test/commands/zoomIn.test.ts +++ b/packages/tldraw/src/test/commands/zoomIn.test.ts @@ -26,16 +26,16 @@ it('zooms by increments', () => { }) it('zooms to from B to D when B >= (C - A)/2, else zooms from B to C', () => { - editor.setCamera(0, 0, (ZOOMS[2] + ZOOMS[3]) / 2) + editor.setCamera({ x: 0, y: 0, z: (ZOOMS[2] + ZOOMS[3]) / 2 }) editor.zoomIn() expect(editor.zoomLevel).toBe(ZOOMS[4]) - editor.setCamera(0, 0, (ZOOMS[2] + ZOOMS[3]) / 2 - 0.1) + editor.setCamera({ x: 0, y: 0, z: (ZOOMS[2] + ZOOMS[3]) / 2 - 0.1 }) editor.zoomIn() expect(editor.zoomLevel).toBe(ZOOMS[3]) }) it('does not zoom when camera is frozen', () => { - editor.setCamera(0, 0, 1) + editor.setCamera({ x: 0, y: 0, z: 1 }) expect(editor.camera).toMatchObject({ x: 0, y: 0, z: 1 }) editor.updateInstanceState({ canMoveCamera: false }) editor.zoomIn() diff --git a/packages/tldraw/src/test/commands/zoomOut.test.ts b/packages/tldraw/src/test/commands/zoomOut.test.ts index 91e06b40d..c94ba220f 100644 --- a/packages/tldraw/src/test/commands/zoomOut.test.ts +++ b/packages/tldraw/src/test/commands/zoomOut.test.ts @@ -23,7 +23,7 @@ it('zooms by increments', () => { }) it('does not zoom out when camera is frozen', () => { - editor.setCamera(0, 0, 1) + editor.setCamera({ x: 0, y: 0, z: 1 }) expect(editor.camera).toMatchObject({ x: 0, y: 0, z: 1 }) editor.updateInstanceState({ canMoveCamera: false }) editor.zoomOut() diff --git a/packages/tldraw/src/test/commands/zoomToBounds.test.ts b/packages/tldraw/src/test/commands/zoomToBounds.test.ts index 1cfe0d7fb..9a3ae1774 100644 --- a/packages/tldraw/src/test/commands/zoomToBounds.test.ts +++ b/packages/tldraw/src/test/commands/zoomToBounds.test.ts @@ -1,3 +1,4 @@ +import { Box2d } from '@tldraw/editor' import { TestEditor } from '../TestEditor' let editor: TestEditor @@ -11,19 +12,32 @@ describe('When zooming to bounds', () => { expect(editor.viewportPageCenter).toMatchObject({ x: 540, y: 360 }) editor.setScreenBounds({ x: 0, y: 0, w: 1000, h: 1000 }) - editor.setCamera(0, 0, 1) - editor.zoomToBounds(200, 300, 300, 300) - expect(editor.viewportPageCenter.toJson()).toCloselyMatchObject({ x: 350, y: 450 }) + + expect(editor.viewportPageCenter).toMatchObject({ x: 500, y: 500 }) + + editor.setCamera({ x: 0, y: 0, z: 1 }) + + expect(editor.viewportPageBounds).toCloselyMatchObject({ + x: -0, + y: -0, + w: 1000, + h: 1000, + }) + + editor.zoomToBounds(new Box2d(200, 300, 300, 300)) + expect(editor.camera.z).toCloselyMatchObject((1000 - 256) / 300) + expect(editor.viewportPageBounds.width).toCloselyMatchObject(1000 / ((1000 - 256) / 300)) + expect(editor.viewportPageBounds.height).toCloselyMatchObject(1000 / ((1000 - 256) / 300)) }) }) it('does not zoom past max', () => { - editor.zoomToBounds(0, 0, 1, 1) + editor.zoomToBounds(new Box2d(0, 0, 1, 1)) expect(editor.zoomLevel).toBe(8) }) it('does not zoom past min', () => { - editor.zoomToBounds(0, 0, 1000000, 100000) + editor.zoomToBounds(new Box2d(0, 0, 1000000, 100000)) expect(editor.zoomLevel).toBe(0.1) }) @@ -31,6 +45,6 @@ it('does not zoom to bounds when camera is frozen', () => { editor.setScreenBounds({ x: 0, y: 0, w: 1000, h: 1000 }) expect(editor.viewportPageCenter.toJson()).toCloselyMatchObject({ x: 500, y: 500 }) editor.updateInstanceState({ canMoveCamera: false }) - editor.zoomToBounds(200, 300, 300, 300) + editor.zoomToBounds(new Box2d(200, 300, 300, 300)) expect(editor.viewportPageCenter.toJson()).toCloselyMatchObject({ x: 500, y: 500 }) }) diff --git a/packages/tldraw/src/test/cropping.test.ts b/packages/tldraw/src/test/cropping.test.ts index 64f286239..b63de9be4 100644 --- a/packages/tldraw/src/test/cropping.test.ts +++ b/packages/tldraw/src/test/cropping.test.ts @@ -372,13 +372,19 @@ describe('When in the select.crop.pointing_crop state', () => { describe('When in the select.crop.translating_crop state', () => { it('moving the pointer should adjust the crop', () => { + expect(editor.history.numUndos).toBe(1) + editor .expectPathToBe('root.select.idle') .doubleClick(550, 550, ids.imageB) .expectPathToBe('root.select.crop.idle') + + editor .pointerDown(550, 550, { target: 'shape', shape: editor.getShape(ids.imageB) }) .expectPathToBe('root.select.crop.pointing_crop') + expect(editor.history.numUndos).toBe(5) + const before = editor.getShape(ids.imageB)!.props.crop! expect(before.topLeft.x).toBe(0) @@ -391,6 +397,8 @@ describe('When in the select.crop.translating_crop state', () => { .pointerMove(550 - imageWidth / 4, 550 - imageHeight / 4) .expectPathToBe('root.select.crop.translating_crop') + expect(editor.history.numUndos).toBe(7) // mark, then change + // Update should have run right away const afterFirst = editor.getShape(ids.imageB)!.props.crop! @@ -402,6 +410,8 @@ describe('When in the select.crop.translating_crop state', () => { // and back to the start editor.pointerMove(550, 550) + expect(editor.history.numUndos).toBe(7) // squashed + // Update should have run right away const afterSecond = editor.getShape(ids.imageB)!.props.crop! @@ -413,10 +423,21 @@ describe('When in the select.crop.translating_crop state', () => { // and back to the left again (first) editor.pointerMove(250, 250) + expect(editor.history.numUndos).toBe(7) // squashed + const afterEnd = editor.getShape(ids.imageB)!.props.crop! editor.pointerUp() + expect(afterEnd.topLeft.x).toBe(0.25) + expect(afterEnd.topLeft.y).toBe(0.375) + expect(afterEnd.bottomRight.x).toBe(0.75) + expect(afterEnd.bottomRight.y).toBe(0.875) + + expect(editor.history.numUndos).toBe(7) // squashed + + expect(editor.getShape(ids.imageB)!.props.crop!).toMatchObject(afterEnd) + editor.undo() expect(editor.getShape(ids.imageB)!.props.crop!).toMatchObject(before) diff --git a/packages/tldraw/src/test/drawing.test.ts b/packages/tldraw/src/test/drawing.test.ts index 4267b684f..f2b2d5140 100644 --- a/packages/tldraw/src/test/drawing.test.ts +++ b/packages/tldraw/src/test/drawing.test.ts @@ -27,9 +27,9 @@ for (const toolType of ['draw', 'highlight'] as const) { .pointerUp() .expectToBeIn(`${toolType}.idle`) - expect(editor.shapesOnCurrentPage).toHaveLength(1) + expect(editor.currentPageShapes).toHaveLength(1) - const shape = editor.shapesOnCurrentPage[0] as DrawableShape + const shape = editor.currentPageShapes[0] as DrawableShape expect(shape.type).toBe(toolType) expect(shape.props.segments.length).toBe(1) @@ -46,9 +46,9 @@ for (const toolType of ['draw', 'highlight'] as const) { .pointerUp() .expectToBeIn(`${toolType}.idle`) - expect(editor.shapesOnCurrentPage).toHaveLength(1) + expect(editor.currentPageShapes).toHaveLength(1) - const shape = editor.shapesOnCurrentPage[0] as DrawableShape + const shape = editor.currentPageShapes[0] as DrawableShape expect(shape.type).toBe(toolType) expect(shape.props.segments.length).toBe(1) @@ -59,7 +59,7 @@ for (const toolType of ['draw', 'highlight'] as const) { it('Creates a free draw line when shift is not held', () => { editor.setCurrentTool(toolType).pointerDown(10, 10).pointerMove(20, 20) - const shape = editor.shapesOnCurrentPage[0] as DrawableShape + const shape = editor.currentPageShapes[0] as DrawableShape expect(shape.props.segments.length).toBe(1) const segment = shape.props.segments[0] @@ -69,7 +69,7 @@ for (const toolType of ['draw', 'highlight'] as const) { it('Creates a straight line when shift is held', () => { editor.setCurrentTool(toolType).keyDown('Shift').pointerDown(10, 10).pointerMove(20, 20) - const shape = editor.shapesOnCurrentPage[0] as DrawableShape + const shape = editor.currentPageShapes[0] as DrawableShape expect(shape.props.segments.length).toBe(1) const segment = shape.props.segments[0] @@ -90,7 +90,7 @@ for (const toolType of ['draw', 'highlight'] as const) { .pointerMove(40, 40) .pointerUp() - const shape = editor.shapesOnCurrentPage[0] as DrawableShape + const shape = editor.currentPageShapes[0] as DrawableShape expect(shape.props.segments.length).toBe(3) expect(shape.props.segments[0].type).toBe('free') @@ -110,7 +110,7 @@ for (const toolType of ['draw', 'highlight'] as const) { .pointerMove(40, 40) .pointerUp() - const shape = editor.shapesOnCurrentPage[0] as DrawableShape + const shape = editor.currentPageShapes[0] as DrawableShape expect(shape.props.segments.length).toBe(3) expect(shape.props.segments[0].type).toBe('straight') @@ -126,14 +126,14 @@ for (const toolType of ['draw', 'highlight'] as const) { .pointerUp() .pointerDown(20, 20) - const shape1 = editor.shapesOnCurrentPage[0] as DrawableShape + const shape1 = editor.currentPageShapes[0] as DrawableShape expect(shape1.props.segments.length).toBe(2) expect(shape1.props.segments[0].type).toBe('straight') expect(shape1.props.segments[1].type).toBe('straight') editor.pointerUp().pointerDown(30, 30).pointerUp() - const shape2 = editor.shapesOnCurrentPage[0] as DrawableShape + const shape2 = editor.currentPageShapes[0] as DrawableShape expect(shape2.props.segments.length).toBe(3) expect(shape2.props.segments[2].type).toBe('straight') }) @@ -149,13 +149,13 @@ for (const toolType of ['draw', 'highlight'] as const) { .pointerDown(20, 20) .pointerMove(30, 30) - expect(editor.shapesOnCurrentPage).toHaveLength(2) + expect(editor.currentPageShapes).toHaveLength(2) - const shape1 = editor.shapesOnCurrentPage[0] as DrawableShape + const shape1 = editor.currentPageShapes[0] as DrawableShape expect(shape1.props.segments.length).toBe(1) expect(shape1.props.segments[0].type).toBe('free') - const shape2 = editor.shapesOnCurrentPage[1] as DrawableShape + const shape2 = editor.currentPageShapes[1] as DrawableShape expect(shape2.props.segments.length).toBe(1) expect(shape2.props.segments[0].type).toBe('straight') }) @@ -172,7 +172,7 @@ for (const toolType of ['draw', 'highlight'] as const) { editor.setCurrentTool(toolType).keyDown('Shift').pointerDown(0, 0).pointerMove(x, y) - const shape = editor.shapesOnCurrentPage[0] as DrawableShape + const shape = editor.currentPageShapes[0] as DrawableShape const segment = shape.props.segments[0] expect(segment.points[1].x).toBeCloseTo(snappedX) expect(segment.points[1].y).toBeCloseTo(snappedY) @@ -186,7 +186,7 @@ for (const toolType of ['draw', 'highlight'] as const) { editor.setCurrentTool(toolType).keyDown('Meta').pointerDown(0, 0).pointerMove(x, y) - const shape = editor.shapesOnCurrentPage[0] as DrawableShape + const shape = editor.currentPageShapes[0] as DrawableShape const segment = shape.props.segments[0] expect(segment.points[1].x).toBeCloseTo(x) expect(segment.points[1].y).toBeCloseTo(y) @@ -205,13 +205,13 @@ for (const toolType of ['draw', 'highlight'] as const) { .pointerDown(10, 0) .pointerMove(1, 0) - const shape1 = editor.shapesOnCurrentPage[0] as DrawableShape + const shape1 = editor.currentPageShapes[0] as DrawableShape const segment1 = last(shape1.props.segments)! const point1 = last(segment1.points)! expect(point1.x).toBe(1) editor.keyDown('Meta') - const shape2 = editor.shapesOnCurrentPage[0] as DrawableShape + const shape2 = editor.currentPageShapes[0] as DrawableShape const segment2 = last(shape2.props.segments)! const point2 = last(segment2.points)! expect(point2.x).toBe(0) @@ -230,13 +230,13 @@ for (const toolType of ['draw', 'highlight'] as const) { .pointerDown(10, 5) .pointerMove(1, 5) - const shape1 = editor.shapesOnCurrentPage[0] as DrawableShape + const shape1 = editor.currentPageShapes[0] as DrawableShape const segment1 = last(shape1.props.segments)! const point1 = last(segment1.points)! expect(point1.x).toBe(1) editor.keyDown('Meta') - const shape2 = editor.shapesOnCurrentPage[0] as DrawableShape + const shape2 = editor.currentPageShapes[0] as DrawableShape const segment2 = last(shape2.props.segments)! const point2 = last(segment2.points)! expect(point2.x).toBe(0) @@ -244,18 +244,18 @@ for (const toolType of ['draw', 'highlight'] as const) { it('Deletes very short lines on interrupt', () => { editor.setCurrentTool(toolType).pointerDown(0, 0).pointerMove(0.1, 0.1).interrupt() - expect(editor.shapesOnCurrentPage).toHaveLength(0) + expect(editor.currentPageShapes).toHaveLength(0) }) it('Does not delete longer lines on interrupt', () => { editor.setCurrentTool(toolType).pointerDown(0, 0).pointerMove(5, 5).interrupt() - expect(editor.shapesOnCurrentPage).toHaveLength(1) + expect(editor.currentPageShapes).toHaveLength(1) }) it('Completes on cancel', () => { editor.setCurrentTool(toolType).pointerDown(0, 0).pointerMove(5, 5).cancel() - expect(editor.shapesOnCurrentPage).toHaveLength(1) - const shape = editor.shapesOnCurrentPage[0] as DrawableShape + expect(editor.currentPageShapes).toHaveLength(1) + const shape = editor.currentPageShapes[0] as DrawableShape expect(shape.props.segments.length).toBe(1) }) }) diff --git a/packages/tldraw/src/test/duplicate.test.ts b/packages/tldraw/src/test/duplicate.test.ts index f5e4f200b..dff103321 100644 --- a/packages/tldraw/src/test/duplicate.test.ts +++ b/packages/tldraw/src/test/duplicate.test.ts @@ -45,11 +45,11 @@ it('creates new bindings for arrows when pasting', async () => { }, ]) - const shapesBefore = editor.shapesOnCurrentPage + const shapesBefore = editor.currentPageShapes editor.selectAll().duplicateShapes(editor.selectedShapeIds) - const shapesAfter = editor.shapesOnCurrentPage + const shapesAfter = editor.currentPageShapes // We should not have changed the original shapes expect(shapesBefore[0]).toMatchObject(shapesAfter[0]) @@ -187,7 +187,7 @@ describe('When duplicating shapes that include arrows', () => { .deleteShapes(editor.selectedShapeIds) .createShapes(shapes) .select( - ...editor.shapesOnCurrentPage + ...editor.currentPageShapes .filter((s) => editor.isShapeOfType(s, 'arrow')) .map((s) => s.id) ) diff --git a/packages/tldraw/src/test/flipShapes.test.ts b/packages/tldraw/src/test/flipShapes.test.ts index f97fd452d..ac5f181d5 100644 --- a/packages/tldraw/src/test/flipShapes.test.ts +++ b/packages/tldraw/src/test/flipShapes.test.ts @@ -234,8 +234,20 @@ it('Preserves the selection bounds.', () => { it('Does, undoes and redoes', () => { editor.mark('flip vertical') - editor.flipShapes([ids.boxA, ids.boxB], 'vertical') + editor.expectShapeToMatch( + { + id: ids.boxA, + type: 'geo', + y: 0, + }, + { + id: ids.boxB, + type: 'geo', + y: 150, + } + ) + editor.flipShapes([ids.boxA, ids.boxB], 'vertical') editor.expectShapeToMatch( { id: ids.boxA, @@ -248,6 +260,7 @@ it('Does, undoes and redoes', () => { y: 0, } ) + editor.undo() editor.expectShapeToMatch( { diff --git a/packages/tldraw/src/test/frames.test.ts b/packages/tldraw/src/test/frames.test.ts index 69c6958ac..bccb21295 100644 --- a/packages/tldraw/src/test/frames.test.ts +++ b/packages/tldraw/src/test/frames.test.ts @@ -44,7 +44,7 @@ describe('creating frames', () => { editor.setCurrentTool('frame') editor.pointerDown(100, 100).cancel().pointerUp(100, 100) expect(editor.onlySelectedShape?.type).toBe(undefined) - expect(editor.shapesOnCurrentPage).toHaveLength(0) + expect(editor.currentPageShapes).toHaveLength(0) }) it('can be canceled while dragging', () => { editor.setCurrentTool('frame') @@ -53,19 +53,19 @@ describe('creating frames', () => { editor.cancel() editor.pointerUp() expect(editor.onlySelectedShape?.type).toBe(undefined) - expect(editor.shapesOnCurrentPage).toHaveLength(0) + expect(editor.currentPageShapes).toHaveLength(0) }) it('can be undone', () => { editor.setCurrentTool('frame') editor.pointerDown(100, 100).pointerMove(200, 200).pointerUp(200, 200) expect(editor.onlySelectedShape?.type).toBe('frame') - expect(editor.shapesOnCurrentPage).toHaveLength(1) + expect(editor.currentPageShapes).toHaveLength(1) editor.undo() expect(editor.onlySelectedShape?.type).toBe(undefined) - expect(editor.shapesOnCurrentPage).toHaveLength(0) + expect(editor.currentPageShapes).toHaveLength(0) }) it('can be done inside other frames', () => { editor.setCurrentTool('frame') @@ -76,7 +76,7 @@ describe('creating frames', () => { editor.setCurrentTool('frame') editor.pointerDown(125, 125).pointerMove(175, 175).pointerUp(175, 175) - expect(editor.shapesOnCurrentPage).toHaveLength(2) + expect(editor.currentPageShapes).toHaveLength(2) expect(editor.onlySelectedShape?.parentId).toEqual(frameAId) @@ -98,7 +98,7 @@ describe('creating frames', () => { editor.setCurrentTool('frame') editor.pointerDown(125, 125).pointerMove(175, 175).pointerUp(175, 175) - expect(editor.shapesOnCurrentPage).toHaveLength(2) + expect(editor.currentPageShapes).toHaveLength(2) expect(editor.onlySelectedShape?.parentId).toEqual(frameAId) @@ -146,16 +146,14 @@ describe('creating frames', () => { describe('frame shapes', () => { it('can receive new children when shapes are drawn on top and the frame is rotated', () => { // We should be starting from an empty canvas - expect(editor.shapesOnCurrentPage).toHaveLength(0) + expect(editor.currentPageShapes).toHaveLength(0) const frameId = createShapeId('frame') editor // Create a frame - .createShapes( - [{ id: frameId, type: 'frame', x: 100, y: 100, props: { w: 100, h: 100 } }], - true - ) + .createShapes([{ id: frameId, type: 'frame', x: 100, y: 100, props: { w: 100, h: 100 } }]) + .select(frameId) // Rotate it by PI/2 .rotateSelection(Math.PI / 2) // Draw a shape into the frame @@ -169,7 +167,7 @@ describe('frame shapes', () => { .pointerUp() // The two shapes should have been created - expect(editor.shapesOnCurrentPage).toHaveLength(3) + expect(editor.currentPageShapes).toHaveLength(3) // The shapes should be the child of the frame const childIds = editor.getSortedChildIdsForParent(frameId) diff --git a/packages/tldraw/src/test/groups.test.ts b/packages/tldraw/src/test/groups.test.ts index ca70ebefb..8ff703fb3 100644 --- a/packages/tldraw/src/test/groups.test.ts +++ b/packages/tldraw/src/test/groups.test.ts @@ -76,7 +76,7 @@ afterEach(() => { editor?.dispose() }) -const getAllShapes = () => editor.shapesOnCurrentPage +const getAllShapes = () => editor.currentPageShapes const onlySelectedId = () => { expect(editor.selectedShapeIds).toHaveLength(1) @@ -515,14 +515,14 @@ describe('ungrouping shapes', () => { editor.groupShapes(editor.selectedShapeIds) editor.ungroupShapes(editor.selectedShapeIds) - const sortedShapesOnCurrentPage = editor.shapesOnCurrentPage + const currentPageShapesSorted = editor.currentPageShapes .sort(sortByIndex) .map((shape) => shape.id) - expect(sortedShapesOnCurrentPage.length).toBe(4) - expect(sortedShapesOnCurrentPage[0]).toBe(ids.boxA) - expect(sortedShapesOnCurrentPage[1]).toBe(ids.boxB) - expect(sortedShapesOnCurrentPage[2]).toBe(ids.boxC) - expect(sortedShapesOnCurrentPage[3]).toBe(ids.boxD) + expect(currentPageShapesSorted.length).toBe(4) + expect(currentPageShapesSorted[0]).toBe(ids.boxA) + expect(currentPageShapesSorted[1]).toBe(ids.boxB) + expect(currentPageShapesSorted[2]).toBe(ids.boxC) + expect(currentPageShapesSorted[3]).toBe(ids.boxD) }) it('keeps order correct complex', () => { // 0 10 20 30 40 50 60 70 @@ -540,14 +540,14 @@ describe('ungrouping shapes', () => { editor.groupShapes(editor.selectedShapeIds) editor.ungroupShapes(editor.selectedShapeIds) - const sortedShapesOnCurrentPage = editor.shapesOnCurrentPage + const currentPageShapesSorted = editor.currentPageShapes .sort(sortByIndex) .map((shape) => shape.id) - expect(sortedShapesOnCurrentPage.length).toBe(4) - expect(sortedShapesOnCurrentPage[0]).toBe(ids.boxB) - expect(sortedShapesOnCurrentPage[1]).toBe(ids.boxA) - expect(sortedShapesOnCurrentPage[2]).toBe(ids.boxC) - expect(sortedShapesOnCurrentPage[3]).toBe(ids.boxD) + expect(currentPageShapesSorted.length).toBe(4) + expect(currentPageShapesSorted[0]).toBe(ids.boxB) + expect(currentPageShapesSorted[1]).toBe(ids.boxA) + expect(currentPageShapesSorted[2]).toBe(ids.boxC) + expect(currentPageShapesSorted[3]).toBe(ids.boxD) }) }) @@ -1492,7 +1492,7 @@ describe('erasing', () => { // move to group B editor.pointerMove(65, 5) - expect(editor.erasingShapeIdsSet.size).toBe(2) + expect(editor.erasingShapeIds.length).toBe(2) }) }) diff --git a/packages/tldraw/src/test/paste.test.ts b/packages/tldraw/src/test/paste.test.ts index 86d3d362c..a35083d7f 100644 --- a/packages/tldraw/src/test/paste.test.ts +++ b/packages/tldraw/src/test/paste.test.ts @@ -87,7 +87,7 @@ beforeEach(() => { }) function getShapes() { - const arr = editor.shapesOnCurrentPage as any[] + const arr = editor.currentPageShapes as any[] const results = { old: {}, new: {} } as { old: Record @@ -110,7 +110,7 @@ it('Gets pasted shapes correctly', () => { editor.selectNone() let shapes = getShapes() - expect(editor.sortedShapesOnCurrentPage.map((m) => m.id)).toStrictEqual([ + expect(editor.currentPageShapesSorted.map((m) => m.id)).toStrictEqual([ shapes.old.frame1.id, shapes.old.frame2.id, shapes.old.frame3.id, @@ -124,7 +124,7 @@ it('Gets pasted shapes correctly', () => { shapes = getShapes() - expect(editor.sortedShapesOnCurrentPage.map((m) => m.id)).toStrictEqual([ + expect(editor.currentPageShapesSorted.map((m) => m.id)).toStrictEqual([ shapes.old.frame1.id, shapes.old.frame2.id, shapes.old.frame3.id, @@ -174,7 +174,7 @@ describe('When pasting', () => { expect(shapes.new.box1?.parentId).toBe(editor.currentPageId) expect(shapes.new.box2?.parentId).toBe(editor.currentPageId) - expect(editor.sortedShapesOnCurrentPage.map((m) => m.id)).toStrictEqual([ + expect(editor.currentPageShapesSorted.map((m) => m.id)).toStrictEqual([ shapes.old.frame1.id, shapes.old.frame2.id, shapes.old.frame3.id, @@ -418,17 +418,17 @@ describe('When pasting into frames...', () => { .select(ids.frame1) .bringToFront(editor.selectedShapeIds) - editor.setCamera(-2000, -2000, 1) + editor.setCamera({ x: -2000, y: -2000, z: 1 }) editor.updateRenderingBounds() // Copy box 1 (should be out of viewport) editor.select(ids.box1).copy() - const shapesBefore = editor.shapesOnCurrentPage + const shapesBefore = editor.currentPageShapes // Paste it editor.paste() - const newShape = editor.shapesOnCurrentPage.find((s) => !shapesBefore.includes(s))! + const newShape = editor.currentPageShapes.find((s) => !shapesBefore.includes(s))! // it should be on the canvas, NOT a child of frame2 expect(newShape.parentId).not.toBe(ids.frame2) diff --git a/packages/tldraw/src/test/renderingShapes.test.tsx b/packages/tldraw/src/test/renderingShapes.test.tsx index 2cb3b98fd..1bdb86578 100644 --- a/packages/tldraw/src/test/renderingShapes.test.tsx +++ b/packages/tldraw/src/test/renderingShapes.test.tsx @@ -51,7 +51,7 @@ it('updates the rendering viewport when the camera stops moving', () => { const ids = createShapes() editor.updateRenderingBounds = jest.fn(editor.updateRenderingBounds) - editor.pan(-201, -201) + editor.pan({ x: -201, y: -201 }) jest.advanceTimersByTime(500) expect(editor.updateRenderingBounds).toHaveBeenCalledTimes(1) @@ -71,7 +71,7 @@ it('lists shapes in viewport', () => { ]) // Move the camera 201 pixels to the right and 201 pixels down - editor.pan(-201, -201) + editor.pan({ x: -201, y: -201 }) jest.advanceTimersByTime(500) expect( @@ -83,7 +83,7 @@ it('lists shapes in viewport', () => { [ids.D, true, false], // D is clipped and so should always be culled / outside of viewport ]) - editor.pan(-100, -100) + editor.pan({ x: -100, y: -100 }) jest.advanceTimersByTime(500) expect( @@ -95,7 +95,7 @@ it('lists shapes in viewport', () => { [ids.D, true, false], // D is clipped and so should always be culled / outside of viewport ]) - editor.pan(-900, -900) + editor.pan({ x: -900, y: -900 }) jest.advanceTimersByTime(500) expect( editor.renderingShapes.map(({ id, isCulled, isInViewport }) => [id, isCulled, isInViewport]) diff --git a/packages/tldraw/src/test/select.test.tsx b/packages/tldraw/src/test/select.test.tsx index fd1968406..5b81658ad 100644 --- a/packages/tldraw/src/test/select.test.tsx +++ b/packages/tldraw/src/test/select.test.tsx @@ -129,7 +129,7 @@ describe('When brushing arrows', () => { const ids = editor .selectAll() .deleteShapes(editor.selectedShapeIds) - .setCamera(0, 0, 1) + .setCamera({ x: 0, y: 0, z: 1 }) .createShapesFromJsx([ { editor .selectAll() .deleteShapes(editor.selectedShapeIds) - .setCamera(0, 0, 1) + .setCamera({ x: 0, y: 0, z: 1 }) .createShapesFromJsx([ { editor.sendBackward([ids.frame1]) editor.sendBackward([ids.frame1]) - expect(editor.sortedShapesOnCurrentPage.map((s) => s.id)).toEqual([ + expect(editor.currentPageShapesSorted.map((s) => s.id)).toEqual([ ids.box1, ids.frame1, ids.box4, @@ -329,8 +329,8 @@ describe('When a shape is behind a frame', () => { it('does not select the shape when clicked inside', () => { editor.sendToBack([ids.box1]) // send it to back! - expect(editor.sortedShapesOnCurrentPage.map((s) => s.index)).toEqual(['a1', 'a2']) - expect(editor.sortedShapesOnCurrentPage.map((s) => s.id)).toEqual([ids.box1, ids.frame1]) + expect(editor.currentPageShapesSorted.map((s) => s.index)).toEqual(['a1', 'a2']) + expect(editor.currentPageShapesSorted.map((s) => s.id)).toEqual([ids.box1, ids.frame1]) editor.pointerMove(50, 50) expect(editor.hoveredShapeId).toBe(null) @@ -690,7 +690,7 @@ describe('When shapes are overlapping', () => { editor.bringToFront([ids.box5]) editor.bringToFront([ids.box2]) - expect(editor.sortedShapesOnCurrentPage.map((s) => s.id)).toEqual([ + expect(editor.currentPageShapesSorted.map((s) => s.id)).toEqual([ ids.box4, // filled ids.box1, // hollow ids.box3, // hollow @@ -709,7 +709,7 @@ describe('When shapes are overlapping', () => { }) it('selects the hollow above the filled shapes when in margin', () => { - expect(editor.sortedShapesOnCurrentPage.map((s) => s.id)).toEqual([ + expect(editor.currentPageShapesSorted.map((s) => s.id)).toEqual([ ids.box4, ids.box1, ids.box3, @@ -1492,7 +1492,7 @@ describe('scribble brushes to add to the selection', () => { describe('creating text on double click', () => { it('creates text on double click', () => { editor.doubleClick() - expect(editor.shapesOnCurrentPage.length).toBe(1) + expect(editor.currentPageShapes.length).toBe(1) editor.pointerMove(0, 100) editor.click() }) diff --git a/packages/tldraw/src/test/shapeIdsInCurrentPage.test.ts b/packages/tldraw/src/test/shapeIdsInCurrentPage.test.ts index eaee08cb4..7e542e771 100644 --- a/packages/tldraw/src/test/shapeIdsInCurrentPage.test.ts +++ b/packages/tldraw/src/test/shapeIdsInCurrentPage.test.ts @@ -19,30 +19,30 @@ const ids = { describe('shapeIdsInCurrentPage', () => { it('keeps the shape ids in the current page', () => { - expect(new Set(editor.shapeIdsOnCurrentPage)).toEqual(new Set([])) + expect(new Set(editor.currentPageShapeIds)).toEqual(new Set([])) editor.createShapes([{ type: 'geo', id: ids.box1 }]) - expect(new Set(editor.shapeIdsOnCurrentPage)).toEqual(new Set([ids.box1])) + expect(new Set(editor.currentPageShapeIds)).toEqual(new Set([ids.box1])) editor.createShapes([{ type: 'geo', id: ids.box2 }]) - expect(new Set(editor.shapeIdsOnCurrentPage)).toEqual(new Set([ids.box1, ids.box2])) + expect(new Set(editor.currentPageShapeIds)).toEqual(new Set([ids.box1, ids.box2])) editor.createShapes([{ type: 'geo', id: ids.box3 }]) - expect(new Set(editor.shapeIdsOnCurrentPage)).toEqual(new Set([ids.box1, ids.box2, ids.box3])) + expect(new Set(editor.currentPageShapeIds)).toEqual(new Set([ids.box1, ids.box2, ids.box3])) editor.deleteShapes([ids.box2]) - expect(new Set(editor.shapeIdsOnCurrentPage)).toEqual(new Set([ids.box1, ids.box3])) + expect(new Set(editor.currentPageShapeIds)).toEqual(new Set([ids.box1, ids.box3])) editor.deleteShapes([ids.box1]) - expect(new Set(editor.shapeIdsOnCurrentPage)).toEqual(new Set([ids.box3])) + expect(new Set(editor.currentPageShapeIds)).toEqual(new Set([ids.box3])) editor.deleteShapes([ids.box3]) - expect(new Set(editor.shapeIdsOnCurrentPage)).toEqual(new Set([])) + expect(new Set(editor.currentPageShapeIds)).toEqual(new Set([])) }) it('changes when the current page changes', () => { @@ -60,10 +60,10 @@ describe('shapeIdsInCurrentPage', () => { { type: 'geo', id: ids.box6 }, ]) - expect(new Set(editor.shapeIdsOnCurrentPage)).toEqual(new Set([ids.box4, ids.box5, ids.box6])) + expect(new Set(editor.currentPageShapeIds)).toEqual(new Set([ids.box4, ids.box5, ids.box6])) editor.setCurrentPage(editor.pages[0].id) - expect(new Set(editor.shapeIdsOnCurrentPage)).toEqual(new Set([ids.box1, ids.box2, ids.box3])) + expect(new Set(editor.currentPageShapeIds)).toEqual(new Set([ids.box1, ids.box2, ids.box3])) }) }) diff --git a/packages/tldraw/src/test/test-jsx.tsx b/packages/tldraw/src/test/test-jsx.tsx index 21d27bbfe..f3a211eac 100644 --- a/packages/tldraw/src/test/test-jsx.tsx +++ b/packages/tldraw/src/test/test-jsx.tsx @@ -49,7 +49,7 @@ export const TL = new Proxy( export function shapesFromJsx(shapes: JSX.Element | Array) { const ids = {} as Record - const shapesOnCurrentPage: Array = [] + const currentPageShapes: Array = [] function addChildren(children: JSX.Element | Array, parentId?: TLShapeId) { let nextIndex = 'a0' @@ -104,7 +104,7 @@ export function shapesFromJsx(shapes: JSX.Element | Array) { ;(shapePartial.props as Record)[key] = value } - shapesOnCurrentPage.push(shapePartial) + currentPageShapes.push(shapePartial) if (el.props.children) { addChildren(el.props.children, id) @@ -129,6 +129,6 @@ export function shapesFromJsx(shapes: JSX.Element | Array) { return target[key as string] }), }), - shapes: shapesOnCurrentPage, + shapes: currentPageShapes, } } diff --git a/packages/tldraw/src/test/translating.test.ts b/packages/tldraw/src/test/translating.test.ts index 19d2f49ea..b81e61d88 100644 --- a/packages/tldraw/src/test/translating.test.ts +++ b/packages/tldraw/src/test/translating.test.ts @@ -76,6 +76,7 @@ describe('When translating...', () => { props: { w: 100, h: 100, + fill: 'solid', }, }, { @@ -86,6 +87,7 @@ describe('When translating...', () => { props: { w: 100, h: 100, + fill: 'solid', }, }, { @@ -117,14 +119,27 @@ describe('When translating...', () => { }) it('translates a single shape', () => { + const before = editor.getShape(ids.box1)! + editor - .pointerDown(50, 50, ids.box1) + .pointerMove(50, 50) + .pointerDown() .pointerMove(50, 40) // [0, -10] .expectShapeToMatch({ id: ids.box1, x: 10, y: 0 }) .pointerMove(100, 100) // [50, 50] .expectShapeToMatch({ id: ids.box1, x: 60, y: 60 }) .pointerUp() .expectShapeToMatch({ id: ids.box1, x: 60, y: 60 }) + + const after = editor.getShape(ids.box1)! + + editor.undo() + + expect(editor.getShape(ids.box1)).toMatchObject(before) + + editor.redo() + + expect(editor.getShape(ids.box1)).toMatchObject(after) }) it('translates multiple shapes', () => { @@ -170,17 +185,19 @@ describe('When cloning...', () => { y: 100, }, ]) + editor.mark() }) + it('clones a single shape and restores when stopping cloning', () => { - expect(editor.shapeIdsOnCurrentPage.size).toBe(3) - expect(editor.shapeIdsOnCurrentPage.size).toBe(3) + expect(editor.currentPageShapeIds.size).toBe(3) + expect(editor.currentPageShapeIds.size).toBe(3) editor.select(ids.box1).pointerDown(50, 50, ids.box1).pointerMove(50, 40) // [0, -10] - expect(editor.shapeIdsOnCurrentPage.size).toBe(3) + expect(editor.currentPageShapeIds.size).toBe(3) editor.expectShapeToMatch({ id: ids.box1, x: 10, y: 0 }) // Translated A... // Start cloning! editor.keyDown('Alt') - expect(editor.shapeIdsOnCurrentPage.size).toBe(4) + expect(editor.currentPageShapeIds.size).toBe(4) const newShape = editor.selectedShapes[0] expect(newShape.id).not.toBe(ids.box1) @@ -201,13 +218,13 @@ describe('When cloning...', () => { it('clones multiple single shape and restores when stopping cloning', () => { editor.select(ids.box1, ids.box2).pointerDown(50, 50, ids.box1).pointerMove(50, 40) // [0, -10] - expect(editor.shapeIdsOnCurrentPage.size).toBe(3) + expect(editor.currentPageShapeIds.size).toBe(3) editor.expectShapeToMatch({ id: ids.box1, x: 10, y: 0 }) // Translated A... editor.expectShapeToMatch({ id: ids.box2, x: 200, y: 190 }) // Translated B... // Start cloning! editor.keyDown('Alt') - expect(editor.shapeIdsOnCurrentPage.size).toBe(5) // Two new shapes! + expect(editor.currentPageShapeIds.size).toBe(5) // Two new shapes! const newShapeA = editor.getShape(editor.selectedShapeIds[0])! const newShapeB = editor.getShape(editor.selectedShapeIds[1])! expect(newShapeA).toBeDefined() @@ -241,9 +258,9 @@ describe('When cloning...', () => { expect(editor.getShape(ids.line1)!.parentId).toBe(ids.box2) editor.select(ids.box2).pointerDown(250, 250, ids.box2).pointerMove(250, 240) // [0, -10] - expect(editor.shapeIdsOnCurrentPage.size).toBe(3) + expect(editor.currentPageShapeIds.size).toBe(3) editor.keyDown('Alt', { altKey: true }) - expect(editor.shapeIdsOnCurrentPage.size).toBe(5) // Creates a clone of B and C (its descendant) + expect(editor.currentPageShapeIds.size).toBe(5) // Creates a clone of B and C (its descendant) const newShapeA = editor.getShape(editor.selectedShapeIds[0])! const newShapeB = editor.getShape(editor.getSortedChildIdsForParent(newShapeA.id)[0])! @@ -276,28 +293,28 @@ describe('When cloning...', () => { it('Clones twice', () => { const groupId = createShapeId('g') editor.groupShapes([ids.box1, ids.box2], groupId) - const count1 = editor.shapesOnCurrentPage.length + const count1 = editor.currentPageShapes.length editor.pointerDown(50, 50, { shape: editor.getShape(groupId)!, target: 'shape' }) editor.expectPathToBe('root.select.pointing_shape') editor.pointerMove(199, 199) editor.expectPathToBe('root.select.translating') - expect(editor.shapesOnCurrentPage.length).toBe(count1) // 2 new box and group + expect(editor.currentPageShapes.length).toBe(count1) // 2 new box and group editor.keyDown('Alt') editor.expectPathToBe('root.select.translating') - expect(editor.shapesOnCurrentPage.length).toBe(count1 + 3) // 2 new box and group + expect(editor.currentPageShapes.length).toBe(count1 + 3) // 2 new box and group editor.keyUp('Alt') jest.advanceTimersByTime(500) - expect(editor.shapesOnCurrentPage.length).toBe(count1) // 2 new box and group + expect(editor.currentPageShapes.length).toBe(count1) // 2 new box and group editor.keyDown('Alt') - expect(editor.shapesOnCurrentPage.length).toBe(count1 + 3) // 2 new box and group + expect(editor.currentPageShapes.length).toBe(count1 + 3) // 2 new box and group }) }) @@ -1648,7 +1665,7 @@ describe('translating a shape with a bound shape', () => { props: { start: { type: 'binding' }, end: { type: 'binding' } }, }) - const newArrow = editor.shapesOnCurrentPage.find( + const newArrow = editor.currentPageShapes.find( (s) => editor.isShapeOfType(s, 'arrow') && s.id !== arrow1 ) expect(newArrow).toMatchObject({ @@ -1731,3 +1748,55 @@ describe('When dragging a shape onto a parent', () => { expect(editor.getShape(ids.box1)?.parentId).toBe(editor.currentPageId) }) }) + +describe('When dragging shapes', () => { + it('should drag and undo and redo', () => { + editor.deleteShapes(editor.currentPageShapes) + + editor.setCurrentTool('arrow').pointerMove(0, 0).pointerDown().pointerMove(100, 100).pointerUp() + + editor.expectShapeToMatch({ + id: editor.currentPageShapes[0]!.id, + x: 0, + y: 0, + }) + + editor.setCurrentTool('geo').pointerMove(-10, 100).pointerDown().pointerUp() + + editor.expectShapeToMatch({ + id: editor.currentPageShapes[1]!.id, + x: -110, + y: 0, + }) + + editor + .selectAll() + .pointerMove(50, 50) + .pointerDown() + .pointerMove(100, 50) + .pointerUp() + .expectShapeToMatch({ + id: editor.currentPageShapes[0]!.id, + x: 50, // 50 to the right + y: 0, + }) + .expectShapeToMatch({ + id: editor.currentPageShapes[1]!.id, + x: -60, // 50 to the right + y: 0, + }) + + editor + .undo() + .expectShapeToMatch({ + id: editor.currentPageShapes[0]!.id, + x: 0, // 50 to the right + y: 0, + }) + .expectShapeToMatch({ + id: editor.currentPageShapes[1]!.id, + x: -110, // 50 to the right + y: 0, + }) + }) +}) diff --git a/packages/tldraw/src/test/viewportFollowing.test.ts b/packages/tldraw/src/test/viewportFollowing.test.ts new file mode 100644 index 000000000..174e287c2 --- /dev/null +++ b/packages/tldraw/src/test/viewportFollowing.test.ts @@ -0,0 +1,18 @@ +import { TestEditor } from './TestEditor' + +let editor: TestEditor + +beforeEach(() => { + editor = new TestEditor() + editor +}) + +describe('When following a user', () => { + it.todo('starts following a user') + it.todo('stops following a user') + it.todo('stops following a user when the camera changes due to user action') + it.todo('moves the camera to follow the user without unfollowing them') + it.todo('stops any animations while following') + it.todo('stops following a user when the page changes due to user action') + it.todo('follows a user to another page without unfollowing them') +}) diff --git a/packages/tlschema/api-report.md b/packages/tlschema/api-report.md index 38ab72857..043896afb 100644 --- a/packages/tlschema/api-report.md +++ b/packages/tlschema/api-report.md @@ -1080,6 +1080,9 @@ export interface TLInstancePageState extends BaseRecord<'instance_page_state', T selectedShapeIds: TLShapeId[]; } +// @public (undocumented) +export type TLInstancePageStateId = RecordId; + // @public (undocumented) export interface TLInstancePresence extends BaseRecord<'instance_presence', TLInstancePresenceID> { // (undocumented) @@ -1149,9 +1152,24 @@ export type TLPageId = RecordId; // @public (undocumented) export type TLParentId = TLPageId | TLShapeId; +// @public +export interface TLPointer extends BaseRecord<'pointer', TLPointerId> { + // (undocumented) + lastActivityTimestamp: number; + // (undocumented) + meta: JsonObject; + // (undocumented) + x: number; + // (undocumented) + y: number; +} + // @public (undocumented) export const TLPOINTER_ID: TLPointerId; +// @public (undocumented) +export type TLPointerId = RecordId; + // @public (undocumented) export type TLRecord = TLAsset | TLCamera | TLDocument | TLInstance | TLInstancePageState | TLInstancePresence | TLPage | TLPointer | TLShape; diff --git a/packages/tlschema/src/index.ts b/packages/tlschema/src/index.ts index f8ac3b2cc..c71e6a881 100644 --- a/packages/tlschema/src/index.ts +++ b/packages/tlschema/src/index.ts @@ -47,8 +47,17 @@ export { type TLPage, type TLPageId, } from './records/TLPage' -export { InstancePageStateRecordType, type TLInstancePageState } from './records/TLPageState' -export { PointerRecordType, TLPOINTER_ID } from './records/TLPointer' +export { + InstancePageStateRecordType, + type TLInstancePageState, + type TLInstancePageStateId, +} from './records/TLPageState' +export { + PointerRecordType, + TLPOINTER_ID, + type TLPointer, + type TLPointerId, +} from './records/TLPointer' export { InstancePresenceRecordType, type TLInstancePresence } from './records/TLPresence' export { type TLRecord } from './records/TLRecord' export { diff --git a/packages/tlschema/src/records/TLRecord.ts b/packages/tlschema/src/records/TLRecord.ts index c8c4164a8..50ebfd8de 100644 --- a/packages/tlschema/src/records/TLRecord.ts +++ b/packages/tlschema/src/records/TLRecord.ts @@ -14,8 +14,8 @@ export type TLRecord = | TLCamera | TLDocument | TLInstance + | TLInstancePresence | TLInstancePageState | TLPage | TLShape - | TLInstancePresence | TLPointer