Editor commands API / effects (#1778)

This PR shrinks the commands API surface and adds a manager
(`CleanupManager`) for side effects.

### Change Type

- [x] `major` — Breaking change

### Test Plan

Use the app! Especially undo and redo. Our tests are passing but I've
found more cases where our coverage fails to catch issues.

### Release Notes

- tbd
This commit is contained in:
Steve Ruiz 2023-08-01 14:21:14 +01:00 committed by GitHub
parent 03514c00c4
commit e17074a8b3
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
139 changed files with 3741 additions and 2701 deletions

View file

@ -10,7 +10,7 @@ export function sleep(ms: number) {
// } // }
// export async function expectToHaveNShapes(page: Page, numberOfShapes: 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) { // export async function expectToHaveNSelectedShapes(page: Page, numberOfSelectedShapes: number) {

View file

@ -23,7 +23,7 @@ test.describe.skip('clipboard tests', () => {
await page.mouse.down() await page.mouse.down()
await page.mouse.up() 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) expect(await page.evaluate(() => editor.selectedShapes.length)).toBe(1)
await page.keyboard.down('Control') await page.keyboard.down('Control')
@ -32,7 +32,7 @@ test.describe.skip('clipboard tests', () => {
await page.keyboard.press('KeyV') await page.keyboard.press('KeyV')
await page.keyboard.up('Control') 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) 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.down()
await page.mouse.up() 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) expect(await page.evaluate(() => editor.selectedShapes.length)).toBe(1)
await page.getByTestId('main.menu').click() 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.edit').click()
await page.getByTestId('menu-item.paste').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) 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.down()
await page.mouse.up() 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) expect(await page.evaluate(() => editor.selectedShapes.length)).toBe(1)
await page.mouse.click(100, 100, { button: 'right' }) 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.mouse.click(100, 100, { button: 'right' })
await page.getByTestId('menu-item.paste').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) expect(await page.evaluate(() => editor.selectedShapes.length)).toBe(1)
}) })
}) })

View file

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

View file

@ -40,6 +40,7 @@ import { TLAssetPartial } from '@tldraw/tlschema';
import { TLBaseShape } from '@tldraw/tlschema'; import { TLBaseShape } from '@tldraw/tlschema';
import { TLBookmarkAsset } from '@tldraw/tlschema'; import { TLBookmarkAsset } from '@tldraw/tlschema';
import { TLCamera } from '@tldraw/tlschema'; import { TLCamera } from '@tldraw/tlschema';
import { TLCameraId } from '@tldraw/tlschema';
import { TLCursorType } from '@tldraw/tlschema'; import { TLCursorType } from '@tldraw/tlschema';
import { TLDefaultHorizontalAlignStyle } from '@tldraw/tlschema'; import { TLDefaultHorizontalAlignStyle } from '@tldraw/tlschema';
import { TLDocument } from '@tldraw/tlschema'; import { TLDocument } from '@tldraw/tlschema';
@ -48,10 +49,13 @@ import { TLHandle } from '@tldraw/tlschema';
import { TLImageAsset } from '@tldraw/tlschema'; import { TLImageAsset } from '@tldraw/tlschema';
import { TLInstance } from '@tldraw/tlschema'; import { TLInstance } from '@tldraw/tlschema';
import { TLInstancePageState } from '@tldraw/tlschema'; import { TLInstancePageState } from '@tldraw/tlschema';
import { TLInstancePageStateId } from '@tldraw/tlschema';
import { TLInstancePresence } from '@tldraw/tlschema'; import { TLInstancePresence } from '@tldraw/tlschema';
import { TLPage } from '@tldraw/tlschema'; import { TLPage } from '@tldraw/tlschema';
import { TLPageId } from '@tldraw/tlschema'; import { TLPageId } from '@tldraw/tlschema';
import { TLParentId } from '@tldraw/tlschema'; import { TLParentId } from '@tldraw/tlschema';
import { TLPointer } from '@tldraw/tlschema';
import { TLPointerId } from '@tldraw/tlschema';
import { TLRecord } from '@tldraw/tlschema'; import { TLRecord } from '@tldraw/tlschema';
import { TLScribble } from '@tldraw/tlschema'; import { TLScribble } from '@tldraw/tlschema';
import { TLShape } from '@tldraw/tlschema'; import { TLShape } from '@tldraw/tlschema';
@ -530,7 +534,6 @@ export class Editor extends EventEmitter<TLEventMap> {
alignShapes(shapes: TLShape[], operation: 'bottom' | 'center-horizontal' | 'center-vertical' | 'left' | 'right' | 'top'): this; alignShapes(shapes: TLShape[], operation: 'bottom' | 'center-horizontal' | 'center-vertical' | 'left' | 'right' | 'top'): this;
// (undocumented) // (undocumented)
alignShapes(ids: TLShapeId[], operation: 'bottom' | 'center-horizontal' | 'center-vertical' | 'left' | 'right' | 'top'): this; 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<{ animateShape(partial: null | TLShapePartial | undefined, options?: Partial<{
duration: number; duration: number;
ease: (t: number) => number; ease: (t: number) => number;
@ -561,6 +564,7 @@ export class Editor extends EventEmitter<TLEventMap> {
// (undocumented) // (undocumented)
bringToFront(ids: TLShapeId[]): this; bringToFront(ids: TLShapeId[]): this;
get camera(): TLCamera; get camera(): TLCamera;
get cameraId(): TLCameraId;
get cameraState(): "idle" | "moving"; get cameraState(): "idle" | "moving";
cancel(): this; cancel(): this;
cancelDoubleClick(): void; cancelDoubleClick(): void;
@ -568,7 +572,9 @@ export class Editor extends EventEmitter<TLEventMap> {
get canUndo(): boolean; get canUndo(): boolean;
// @internal (undocumented) // @internal (undocumented)
capturedPointerId: null | number; capturedPointerId: null | number;
centerOnPoint(x: number, y: number, opts?: TLAnimationOptions): this; centerOnPoint(point: VecLike, animation?: TLAnimationOptions): this;
// (undocumented)
readonly cleanup: CleanupManager;
// @internal // @internal
protected _clickManager: ClickManager; protected _clickManager: ClickManager;
get commonBoundsOfAllShapesOnCurrentPage(): Box2d | undefined; get commonBoundsOfAllShapesOnCurrentPage(): Box2d | undefined;
@ -577,7 +583,11 @@ export class Editor extends EventEmitter<TLEventMap> {
crash(error: unknown): void; crash(error: unknown): void;
// @internal // @internal
get crashingError(): unknown; get crashingError(): unknown;
createAssets(assets: TLAsset[]): this; createAsset(asset: TLAsset): this;
createAssets(assets: TLAsset[], opts?: {
ephemeral?: boolean;
squashing?: boolean;
}): this;
// @internal (undocumented) // @internal (undocumented)
createErrorAnnotations(origin: string, willCrashApp: 'unknown' | boolean): { createErrorAnnotations(origin: string, willCrashApp: 'unknown' | boolean): {
tags: { tags: {
@ -592,21 +602,40 @@ export class Editor extends EventEmitter<TLEventMap> {
}; };
}; };
createPage(title: string, id?: TLPageId, belowPageIndex?: string): this; createPage(title: string, id?: TLPageId, belowPageIndex?: string): this;
createShape<T extends TLUnknownShape>(partial: TLShapePartial<T>, select?: boolean): this; // (undocumented)
createShapes<T extends TLUnknownShape>(partials: TLShapePartial<T>[], select?: boolean): this; createRecords: (partials: OptionalKeys<TLCamera | TLPointer | TLAsset | TLInstancePageState | TLPage | TLShape, "meta">[], opts?: Partial<{
squashing: boolean;
ephemeral: boolean;
preservesRedoStack: boolean;
}> | undefined) => this;
createShape<T extends TLUnknownShape>(partial: OptionalKeys<TLShapePartial<T>, 'id'>): this;
createShapes<T extends TLUnknownShape>(partials: OptionalKeys<TLShapePartial<T>, 'id'>[]): this;
get croppingShapeId(): null | TLShapeId; get croppingShapeId(): null | TLShapeId;
get currentPage(): TLPage; get currentPage(): TLPage;
get currentPageId(): TLPageId; get currentPageId(): TLPageId;
get currentPageShapeIds(): Set<TLShapeId>;
get currentPageShapes(): TLShape[];
get currentPageShapesSorted(): TLShape[];
get currentPageState(): TLInstancePageState; get currentPageState(): TLInstancePageState;
get currentPageStateId(): TLInstancePageStateId;
get currentTool(): StateNode | undefined; get currentTool(): StateNode | undefined;
get currentToolId(): string; get currentToolId(): string;
deleteAsset(assets: TLAsset): this;
// (undocumented)
deleteAsset(ids: TLAssetId): this;
deleteAssets(assets: TLAsset[]): this; deleteAssets(assets: TLAsset[]): this;
// (undocumented) // (undocumented)
deleteAssets(ids: TLAssetId[]): this; deleteAssets(ids: TLAssetId[]): this;
deleteOpenMenu(id: string): this; deleteOpenMenu(id: string): this;
deletePage(page: TLPage): this; deletePage(page: TLPage): this;
// (undocumented) // (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; deleteShape(id: TLShapeId): this;
// (undocumented) // (undocumented)
deleteShape(shape: TLShape): this; deleteShape(shape: TLShape): this;
@ -629,9 +658,13 @@ export class Editor extends EventEmitter<TLEventMap> {
duplicateShapes(shapes: TLShape[], offset?: VecLike): this; duplicateShapes(shapes: TLShape[], offset?: VecLike): this;
// (undocumented) // (undocumented)
duplicateShapes(ids: TLShapeId[], offset?: VecLike): this; duplicateShapes(ids: TLShapeId[], offset?: VecLike): this;
// (undocumented)
get editingShape(): TLUnknownShape | undefined;
get editingShapeId(): null | TLShapeId; get editingShapeId(): null | TLShapeId;
readonly environment: EnvironmentManager;
get erasingShapeIds(): TLShapeId[]; get erasingShapeIds(): TLShapeId[];
get erasingShapeIdsSet(): Set<TLShapeId>; // (undocumented)
get erasingShapes(): NonNullable<TLShape | undefined>[];
// @internal (undocumented) // @internal (undocumented)
externalAssetContentHandlers: { externalAssetContentHandlers: {
[K in TLExternalAssetContent_2['type']]: { [K in TLExternalAssetContent_2['type']]: {
@ -712,6 +745,10 @@ export class Editor extends EventEmitter<TLEventMap> {
getPageMask(id: TLShapeId): undefined | VecLike[]; getPageMask(id: TLShapeId): undefined | VecLike[];
// (undocumented) // (undocumented)
getPageMask(shape: TLShape): undefined | VecLike[]; getPageMask(shape: TLShape): undefined | VecLike[];
getPageShapeIds(page: TLPage): Set<TLShapeId>;
// (undocumented)
getPageShapeIds(pageId: TLPageId): Set<TLShapeId>;
getPageState(pageId: TLPageId): TLInstancePageState;
getPageTransform(id: TLShapeId): Matrix2d; getPageTransform(id: TLShapeId): Matrix2d;
// (undocumented) // (undocumented)
getPageTransform(shape: TLShape): Matrix2d; getPageTransform(shape: TLShape): Matrix2d;
@ -739,9 +776,6 @@ export class Editor extends EventEmitter<TLEventMap> {
hitFrameInside?: boolean | undefined; hitFrameInside?: boolean | undefined;
filter?: ((shape: TLShape) => boolean) | undefined; filter?: ((shape: TLShape) => boolean) | undefined;
}): TLShape | undefined; }): TLShape | undefined;
getShapeIdsInPage(page: TLPage): Set<TLShapeId>;
// (undocumented)
getShapeIdsInPage(pageId: TLPageId): Set<TLShapeId>;
getShapesAtPoint(point: VecLike, opts?: { getShapesAtPoint(point: VecLike, opts?: {
margin?: number | undefined; margin?: number | undefined;
hitInside?: boolean | undefined; hitInside?: boolean | undefined;
@ -776,6 +810,8 @@ export class Editor extends EventEmitter<TLEventMap> {
// (undocumented) // (undocumented)
hasAncestor(shapeId: TLShapeId | undefined, ancestorId: TLShapeId): boolean; hasAncestor(shapeId: TLShapeId | undefined, ancestorId: TLShapeId): boolean;
get hintingShapeIds(): TLShapeId[]; get hintingShapeIds(): TLShapeId[];
// (undocumented)
get hintingShapes(): NonNullable<TLShape | undefined>[];
readonly history: HistoryManager<this>; readonly history: HistoryManager<this>;
// (undocumented) // (undocumented)
get hoveredShape(): TLUnknownShape | undefined; get hoveredShape(): TLUnknownShape | undefined;
@ -805,12 +841,8 @@ export class Editor extends EventEmitter<TLEventMap> {
isAncestorSelected(id: TLShapeId): boolean; isAncestorSelected(id: TLShapeId): boolean;
// (undocumented) // (undocumented)
isAncestorSelected(shape: TLShape): boolean; isAncestorSelected(shape: TLShape): boolean;
readonly isAndroid: boolean;
readonly isChromeForIos: boolean;
readonly isFirefox: boolean;
isIn(path: string): boolean; isIn(path: string): boolean;
isInAny(...paths: string[]): boolean; isInAny(...paths: string[]): boolean;
readonly isIos: boolean;
get isMenuOpen(): boolean; get isMenuOpen(): boolean;
isPointInShape(shape: TLShape, point: VecLike, opts?: { isPointInShape(shape: TLShape, point: VecLike, opts?: {
margin?: number; margin?: number;
@ -821,7 +853,6 @@ export class Editor extends EventEmitter<TLEventMap> {
margin?: number; margin?: number;
hitInside?: boolean; hitInside?: boolean;
}): boolean; }): boolean;
readonly isSafari: boolean;
isShapeInPage(shape: TLShape, pageId?: TLPageId): boolean; isShapeInPage(shape: TLShape, pageId?: TLPageId): boolean;
// (undocumented) // (undocumented)
isShapeInPage(shapeId: TLShapeId, pageId?: TLPageId): boolean; isShapeInPage(shapeId: TLShapeId, pageId?: TLPageId): boolean;
@ -831,7 +862,7 @@ export class Editor extends EventEmitter<TLEventMap> {
isShapeOrAncestorLocked(shape?: TLShape): boolean; isShapeOrAncestorLocked(shape?: TLShape): boolean;
// (undocumented) // (undocumented)
isShapeOrAncestorLocked(id?: TLShapeId): boolean; 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; moveShapesToPage(shapes: TLShape[], pageId: TLPageId): this;
// (undocumented) // (undocumented)
moveShapesToPage(ids: TLShapeId[], pageId: TLPageId): this; moveShapesToPage(ids: TLShapeId[], pageId: TLPageId): this;
@ -845,13 +876,13 @@ export class Editor extends EventEmitter<TLEventMap> {
packShapes(ids: TLShapeId[], gap: number): this; packShapes(ids: TLShapeId[], gap: number): this;
get pages(): TLPage[]; get pages(): TLPage[];
get pageStates(): TLInstancePageState[]; get pageStates(): TLInstancePageState[];
pageToScreen(x: number, y: number, z?: number, camera?: VecLike): { pageToScreen(point: VecLike): {
x: number; x: number;
y: number; y: number;
z: number; z: number;
}; };
pan(dx: number, dy: number, opts?: TLAnimationOptions): this; pan(offset: VecLike, animation?: TLAnimationOptions): this;
panZoomIntoView(ids: TLShapeId[], opts?: TLAnimationOptions): this; panZoomIntoView(ids: TLShapeId[], animation?: TLAnimationOptions): this;
popFocusLayer(): this; popFocusLayer(): this;
putContent(content: TLContent, options?: { putContent(content: TLContent, options?: {
point?: VecLike; point?: VecLike;
@ -867,11 +898,10 @@ export class Editor extends EventEmitter<TLEventMap> {
registerExternalContentHandler<T extends TLExternalContent_2['type']>(type: T, handler: ((info: T extends TLExternalContent_2['type'] ? TLExternalContent_2 & { registerExternalContentHandler<T extends TLExternalContent_2['type']>(type: T, handler: ((info: T extends TLExternalContent_2['type'] ? TLExternalContent_2 & {
type: T; type: T;
} : TLExternalContent_2) => void) | null): this; } : 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 renderingBounds(): Box2d;
get renderingBoundsExpanded(): Box2d; get renderingBoundsExpanded(): Box2d;
// (undocumented)
renderingBoundsMargin: number;
get renderingShapes(): { get renderingShapes(): {
id: TLShapeId; id: TLShapeId;
shape: TLShape; shape: TLShape;
@ -883,10 +913,14 @@ export class Editor extends EventEmitter<TLEventMap> {
isInViewport: boolean; isInViewport: boolean;
maskedPageBounds: Box2d | undefined; maskedPageBounds: Box2d | undefined;
}[]; }[];
reparentShapes(shapes: TLShape[], parentId: TLParentId, insertIndex?: string): this; reparentShapes(shapes: TLShape[], parentId: TLParentId, opts?: {
insertIndex?: string;
} & CommandHistoryOptions): this;
// (undocumented) // (undocumented)
reparentShapes(ids: TLShapeId[], parentId: TLParentId, insertIndex?: string): this; reparentShapes(ids: TLShapeId[], parentId: TLParentId, opts?: {
resetZoom(point?: Vec2d, opts?: TLAnimationOptions): this; insertIndex?: string;
} & CommandHistoryOptions): this;
resetZoom(point?: Vec2d, animation?: TLAnimationOptions): this;
resizeShape(id: TLShapeId, scale: VecLike, options?: { resizeShape(id: TLShapeId, scale: VecLike, options?: {
initialBounds?: Box2d; initialBounds?: Box2d;
scaleOrigin?: VecLike; scaleOrigin?: VecLike;
@ -900,7 +934,7 @@ export class Editor extends EventEmitter<TLEventMap> {
rotateShapesBy(shapes: TLShape[], delta: number): this; rotateShapesBy(shapes: TLShape[], delta: number): this;
// (undocumented) // (undocumented)
rotateShapesBy(ids: TLShapeId[], delta: number): this; rotateShapesBy(ids: TLShapeId[], delta: number): this;
screenToPage(x: number, y: number, z?: number, camera?: VecLike): { screenToPage(point: VecLike): {
x: number; x: number;
y: number; y: number;
z: number; z: number;
@ -921,32 +955,29 @@ export class Editor extends EventEmitter<TLEventMap> {
sendToBack(shapes: TLShape[]): this; sendToBack(shapes: TLShape[]): this;
// (undocumented) // (undocumented)
sendToBack(ids: TLShapeId[]): this; sendToBack(ids: TLShapeId[]): this;
setCamera(x: number, y: number, z?: number, { stopFollowing }?: TLViewportOptions): this; setCamera(point: VecLike, animation?: TLAnimationOptions): this;
// (undocumented) // (undocumented)
setCroppingId(id: null | TLShapeId): this; setCroppingShapeId(id: null | TLShapeId): this;
setCurrentPage(page: TLPage, opts?: TLViewportOptions): this; setCurrentPage(page: TLPage): this;
// (undocumented) // (undocumented)
setCurrentPage(pageId: TLPageId, opts?: TLViewportOptions): this; setCurrentPage(pageId: TLPageId): this;
setCurrentTool(id: string, info?: {}): this; setCurrentTool(id: string, info?: {}): this;
// (undocumented) // (undocumented)
setEditingId(id: null | TLShapeId): this; setEditingShapeId(id: null | TLShapeId): this;
// (undocumented) // (undocumented)
setErasingIds(ids: TLShapeId[]): this; setErasingShapeIds(ids: TLShapeId[]): this;
// (undocumented) // (undocumented)
setFocusedGroupId(next: TLPageId | TLShapeId): this; setFocusedGroupId(id: TLPageId | TLShapeId): this;
// (undocumented) // (undocumented)
setHintingIds(ids: TLShapeId[]): this; setHintingShapeIds(ids: TLShapeId[]): this;
// (undocumented) // (undocumented)
setHoveredId(id: null | TLShapeId): this; setHoveredShapeId(id: null | TLShapeId): this;
setOpacity(opacity: number, ephemeral?: boolean, squashing?: boolean): this; setOpacity(opacity: number, opts?: CommandHistoryOptions): this;
setSelectedShapeIds(ids: TLShapeId[], squashing?: boolean): this; setSelectedShapeIds(ids: TLShapeId[], squashing?: boolean): this;
setStyle<T>(style: StyleProp<T>, value: T, ephemeral?: boolean, squashing?: boolean): this; setStyle<T>(style: StyleProp<T>, value: T, opts?: CommandHistoryOptions): this;
get shapeIdsOnCurrentPage(): Set<TLShapeId>;
get shapesOnCurrentPage(): TLShape[];
shapeUtils: { shapeUtils: {
readonly [K in string]?: ShapeUtil<TLUnknownShape>; readonly [K in string]?: ShapeUtil<TLUnknownShape>;
}; };
get sharedOpacity(): SharedStyle<number>;
get sharedStyles(): ReadonlySharedStyleMap; get sharedStyles(): ReadonlySharedStyleMap;
slideCamera(opts?: { slideCamera(opts?: {
speed: number; speed: number;
@ -955,7 +986,6 @@ export class Editor extends EventEmitter<TLEventMap> {
speedThreshold?: number | undefined; speedThreshold?: number | undefined;
}): this | undefined; }): this | undefined;
readonly snaps: SnapManager; readonly snaps: SnapManager;
get sortedShapesOnCurrentPage(): TLShape[];
stackShapes(shapes: TLShape[], operation: 'horizontal' | 'vertical', gap: number): this; stackShapes(shapes: TLShape[], operation: 'horizontal' | 'vertical', gap: number): this;
// (undocumented) // (undocumented)
stackShapes(ids: TLShapeId[], operation: 'horizontal' | 'vertical', gap: number): this; stackShapes(ids: TLShapeId[], operation: 'horizontal' | 'vertical', gap: number): this;
@ -974,19 +1004,32 @@ export class Editor extends EventEmitter<TLEventMap> {
toggleLock(shapes: TLShape[]): this; toggleLock(shapes: TLShape[]): this;
// (undocumented) // (undocumented)
toggleLock(ids: TLShapeId[]): this; toggleLock(ids: TLShapeId[]): this;
undo(): HistoryManager<this>; undo(): this;
ungroupShapes(ids: TLShapeId[]): this; ungroupShapes(ids: TLShapeId[]): this;
// (undocumented) // (undocumented)
ungroupShapes(ids: TLShape[]): this; ungroupShapes(ids: TLShape[]): this;
updateAssets(assets: TLAssetPartial[]): this; updateAsset(partial: TLAssetPartial, opts?: {
updateCurrentPageState(partial: Partial<Omit<TLInstancePageState, 'editingShapeId' | 'focusedGroupId' | 'pageId' | 'selectedShapeIds'>>, ephemeral?: boolean): this; ephemeral?: boolean;
squashing?: boolean;
}): this;
updateAssets(partials: TLAssetPartial[], opts?: {
ephemeral?: boolean;
squashing?: boolean;
}): this;
updateDocumentSettings(settings: Partial<TLDocument>): this; updateDocumentSettings(settings: Partial<TLDocument>): this;
updateInstanceState(partial: Partial<Omit<TLInstance, 'currentPageId'>>, ephemeral?: boolean, squashing?: boolean): this; updateInstanceState(partial: Partial<Omit<TLInstance, 'currentPageId'>>, opts?: CommandHistoryOptions): this;
updatePage(partial: RequiredKeys<TLPage, 'id'>, squashing?: boolean): this; updatePage(partial: RequiredKeys<TLPage, 'id'>, squashing?: boolean): this;
updatePageState(partial: Partial<Omit<TLInstancePageState, 'editingShapeId' | 'focusedGroupId' | 'pageId'>>, opts?: CommandHistoryOptions): this;
// (undocumented)
updateRecords: (partials: Partial<TLRecord>[], opts?: Partial<{
squashing: boolean;
ephemeral: boolean;
preservesRedoStack: boolean;
}> | undefined) => this;
// @internal // @internal
updateRenderingBounds(): this; updateRenderingBounds(): this;
updateShape<T extends TLUnknownShape>(partial: null | TLShapePartial<T> | undefined, squashing?: boolean): this; updateShape<T extends TLUnknownShape>(partial: null | TLShapePartial<T> | undefined, opts?: CommandHistoryOptions): this;
updateShapes<T extends TLUnknownShape>(partials: (null | TLShapePartial<T> | undefined)[], squashing?: boolean): this; updateShapes<T extends TLUnknownShape>(partials: (null | TLShapePartial<T> | undefined)[], opts?: CommandHistoryOptions): this;
updateViewportScreenBounds(center?: boolean): this; updateViewportScreenBounds(center?: boolean): this;
readonly user: UserPreferencesManager; readonly user: UserPreferencesManager;
get viewportPageBounds(): Box2d; get viewportPageBounds(): Box2d;
@ -996,13 +1039,13 @@ export class Editor extends EventEmitter<TLEventMap> {
visitDescendants(parent: TLPage | TLShape, visitor: (id: TLShapeId) => false | void): this; visitDescendants(parent: TLPage | TLShape, visitor: (id: TLShapeId) => false | void): this;
// (undocumented) // (undocumented)
visitDescendants(parentId: TLParentId, visitor: (id: TLShapeId) => false | void): this; visitDescendants(parentId: TLParentId, visitor: (id: TLShapeId) => false | void): this;
zoomIn(point?: Vec2d, opts?: TLAnimationOptions): this; zoomIn(point?: Vec2d, animation?: TLAnimationOptions): this;
get zoomLevel(): number; get zoomLevel(): number;
zoomOut(point?: Vec2d, opts?: TLAnimationOptions): this; zoomOut(point?: Vec2d, animation?: TLAnimationOptions): this;
zoomToBounds(x: number, y: number, width: number, height: number, targetZoom?: number, opts?: TLAnimationOptions): this; zoomToBounds(bounds: Box2d, targetZoom?: number, animation?: TLAnimationOptions): this;
zoomToContent(): this; zoomToContent(): this;
zoomToFit(opts?: TLAnimationOptions): this; zoomToFit(animation?: TLAnimationOptions): this;
zoomToSelection(opts?: TLAnimationOptions): this; zoomToSelection(animation?: TLAnimationOptions): this;
} }
// @public (undocumented) // @public (undocumented)
@ -1628,7 +1671,7 @@ export function refreshPage(): void;
export function releasePointerCapture(element: Element, event: PointerEvent | React_2.PointerEvent<Element>): void; export function releasePointerCapture(element: Element, event: PointerEvent | React_2.PointerEvent<Element>): void;
// @public (undocumented) // @public (undocumented)
export type RequiredKeys<T, K extends keyof T> = Pick<T, K> & Partial<T>; export type RequiredKeys<T, K extends keyof T> = Partial<Omit<T, K>> & Pick<T, K>;
// @public (undocumented) // @public (undocumented)
export function resizeBox(shape: TLBaseBoxShape, info: { export function resizeBox(shape: TLBaseBoxShape, info: {

View file

@ -33,7 +33,7 @@ export function createTLStore({ initialData, defaultName = '', ...rest }: TLStor
'schema' in rest 'schema' in rest
? rest.schema ? rest.schema
: createTLSchema({ : createTLSchema({
shapes: shapesOnCurrentPageToShapeMap(checkShapesAndAddCore(rest.shapeUtils)), shapes: currentPageShapesToShapeMap(checkShapesAndAddCore(rest.shapeUtils)),
}) })
return new Store({ return new Store({
schema, schema,
@ -44,7 +44,7 @@ export function createTLStore({ initialData, defaultName = '', ...rest }: TLStor
}) })
} }
function shapesOnCurrentPageToShapeMap(shapeUtils: TLShapeUtilConstructor<TLUnknownShape>[]) { function currentPageShapesToShapeMap(shapeUtils: TLShapeUtilConstructor<TLUnknownShape>[]) {
return Object.fromEntries( return Object.fromEntries(
shapeUtils.map((s): [string, SchemaShapeInfo] => [ shapeUtils.map((s): [string, SchemaShapeInfo] => [
s.type, s.type,

File diff suppressed because it is too large Load diff

View file

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

View file

@ -0,0 +1,243 @@
import { TLRecord } from '@tldraw/tlschema'
import { Editor } from '../Editor'
/** @public */
export type TLBeforeCreateHandler<R extends TLRecord> = (record: R, source: 'remote' | 'user') => R
/** @public */
export type TLAfterCreateHandler<R extends TLRecord> = (
record: R,
source: 'remote' | 'user'
) => void
/** @public */
export type TLBeforeChangeHandler<R extends TLRecord> = (
prev: R,
next: R,
source: 'remote' | 'user'
) => R
/** @public */
export type TLAfterChangeHandler<R extends TLRecord> = (
prev: R,
next: R,
source: 'remote' | 'user'
) => void
/** @public */
export type TLBeforeDeleteHandler<R extends TLRecord> = (
record: R,
source: 'remote' | 'user'
) => void | false
/** @public */
export type TLAfterDeleteHandler<R extends TLRecord> = (
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<TLRecord>[]
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<TLRecord>[]
if (handlers) {
for (const handler of handlers) {
handler(record, source)
}
}
}
editor.store.onBeforeChange = (prev, next, source) => {
const handlers = this._beforeChangeHandlers[
next.typeName
] as TLBeforeChangeHandler<TLRecord>[]
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<TLRecord>[]
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<TLRecord>[]
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<TLRecord>[]
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<TLRecord & { typeName: K }>[]
}> = {}
private _afterCreateHandlers: Partial<{
[K in TLRecord['typeName']]: TLAfterCreateHandler<TLRecord & { typeName: K }>[]
}> = {}
private _beforeChangeHandlers: Partial<{
[K in TLRecord['typeName']]: TLBeforeChangeHandler<TLRecord & { typeName: K }>[]
}> = {}
private _afterChangeHandlers: Partial<{
[K in TLRecord['typeName']]: TLAfterChangeHandler<TLRecord & { typeName: K }>[]
}> = {}
private _beforeDeleteHandlers: Partial<{
[K in TLRecord['typeName']]: TLBeforeDeleteHandler<TLRecord & { typeName: K }>[]
}> = {}
private _afterDeleteHandlers: Partial<{
[K in TLRecord['typeName']]: TLAfterDeleteHandler<TLRecord & { typeName: K }>[]
}> = {}
private _batchCompleteHandlers: TLBatchCompleteHandler[] = []
registerBeforeCreateHandler<T extends TLRecord['typeName']>(
typeName: T,
handler: TLBeforeCreateHandler<TLRecord & { typeName: T }>
) {
const handlers = this._beforeCreateHandlers[typeName] as TLBeforeCreateHandler<any>[]
if (!handlers) this._beforeCreateHandlers[typeName] = []
this._beforeCreateHandlers[typeName]!.push(handler)
}
registerAfterCreateHandler<T extends TLRecord['typeName']>(
typeName: T,
handler: TLAfterCreateHandler<TLRecord & { typeName: T }>
) {
const handlers = this._afterCreateHandlers[typeName] as TLAfterCreateHandler<any>[]
if (!handlers) this._afterCreateHandlers[typeName] = []
this._afterCreateHandlers[typeName]!.push(handler)
}
registerBeforeChangeHandler<T extends TLRecord['typeName']>(
typeName: T,
handler: TLBeforeChangeHandler<TLRecord & { typeName: T }>
) {
const handlers = this._beforeChangeHandlers[typeName] as TLBeforeChangeHandler<any>[]
if (!handlers) this._beforeChangeHandlers[typeName] = []
this._beforeChangeHandlers[typeName]!.push(handler)
}
registerAfterChangeHandler<T extends TLRecord['typeName']>(
typeName: T,
handler: TLAfterChangeHandler<TLRecord & { typeName: T }>
) {
const handlers = this._afterChangeHandlers[typeName] as TLAfterChangeHandler<any>[]
if (!handlers) this._afterChangeHandlers[typeName] = []
this._afterChangeHandlers[typeName]!.push(handler as TLAfterChangeHandler<any>)
}
registerBeforeDeleteHandler<T extends TLRecord['typeName']>(
typeName: T,
handler: TLBeforeDeleteHandler<TLRecord & { typeName: T }>
) {
const handlers = this._beforeDeleteHandlers[typeName] as TLBeforeDeleteHandler<any>[]
if (!handlers) this._beforeDeleteHandlers[typeName] = []
this._beforeDeleteHandlers[typeName]!.push(handler as TLBeforeDeleteHandler<any>)
}
registerAfterDeleteHandler<T extends TLRecord['typeName']>(
typeName: T,
handler: TLAfterDeleteHandler<TLRecord & { typeName: T }>
) {
const handlers = this._afterDeleteHandlers[typeName] as TLAfterDeleteHandler<any>[]
if (!handlers) this._afterDeleteHandlers[typeName] = []
this._afterDeleteHandlers[typeName]!.push(handler as TLAfterDeleteHandler<any>)
}
/**
* 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)
}
}

View file

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

View file

@ -2,13 +2,9 @@ import { HistoryManager } from './HistoryManager'
import { stack } from './Stack' import { stack } from './Stack'
function createCounterHistoryManager() { function createCounterHistoryManager() {
const manager = new HistoryManager( const manager = new HistoryManager({ emit: () => void null }, () => {
{ emit: () => void null }, return
() => null, })
() => {
return
}
)
const state = { const state = {
count: 0, count: 0,
name: 'David', name: 'David',
@ -251,7 +247,10 @@ describe(HistoryManager, () => {
expect(editor.getAge()).toBe(35) 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() editor.incrementTwice()
expect(editor.history.numUndos).toBe(1) expect(editor.history.numUndos).toBe(1)
expect(editor.getCount()).toBe(2) expect(editor.getCount()).toBe(2)
@ -260,7 +259,7 @@ describe(HistoryManager, () => {
expect(editor.history.numUndos).toBe(0) 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.history.mark('0')
editor.incrementTwice() editor.incrementTwice()
editor.history.mark('2') editor.history.mark('2')

View file

@ -4,13 +4,17 @@ import { uniqueId } from '../../utils/uniqueId'
import { TLCommandHandler, TLHistoryEntry } from '../types/history-types' import { TLCommandHandler, TLHistoryEntry } from '../types/history-types'
import { Stack, stack } from './Stack' import { Stack, stack } from './Stack'
/** @public */
export type CommandHistoryOptions = Partial<{
squashing: boolean
ephemeral: boolean
preservesRedoStack: boolean
}>
type CommandFn<Data> = (...args: any[]) => type CommandFn<Data> = (...args: any[]) =>
| { | ({
data: Data data: Data
squashing?: boolean } & CommandHistoryOptions)
ephemeral?: boolean
preservesRedoStack?: boolean
}
| null | null
| undefined | undefined
| void | void
@ -29,7 +33,6 @@ export class HistoryManager<
constructor( constructor(
private readonly ctx: CTX, private readonly ctx: CTX,
private readonly onBatchComplete: () => void,
private readonly annotateError: (error: unknown) => void private readonly annotateError: (error: unknown) => void
) {} ) {}
@ -43,6 +46,8 @@ export class HistoryManager<
return this._redos.value.length return this._redos.value.length
} }
skipHistory = false
createCommand = <Name extends string, Constructor extends CommandFn<any>>( createCommand = <Name extends string, Constructor extends CommandFn<any>>(
name: Name, name: Name,
constructor: Constructor, constructor: Constructor,
@ -68,13 +73,14 @@ export class HistoryManager<
const { data, ephemeral, squashing, preservesRedoStack } = result const { data, ephemeral, squashing, preservesRedoStack } = result
this.ignoringUpdates((undos, redos) => { // this.ignoringUpdates((undos, redos) => {
handle.do(data) handle.do(data)
return { undos, redos } // return { undos, redos }
}) // })
if (!ephemeral) { if (!ephemeral) {
const prev = this._undos.value.head const prev = this._undos.value.head
if ( if (
squashing && squashing &&
prev && prev &&
@ -103,6 +109,7 @@ export class HistoryManager<
) )
} }
// clear the redo stack unless the command explicitly says not to
if (!result.preservesRedoStack) { if (!result.preservesRedoStack) {
this._redos.set(stack()) this._redos.set(stack())
} }
@ -116,7 +123,19 @@ export class HistoryManager<
return exec return exec
} }
onBatchStart = () => {
// noop
}
onBatchComplete = () => {
// noop
}
batch = (fn: () => void) => { batch = (fn: () => void) => {
if (this._batchDepth === 0) {
this.onBatchStart()
}
try { try {
this._batchDepth++ this._batchDepth++
if (this._batchDepth === 1) { if (this._batchDepth === 1) {

View file

@ -313,15 +313,15 @@ export class SnapManager {
let startNode: GapNode, endNode: GapNode 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 return a.pageBounds.minX - b.pageBounds.minX
}) })
// Collect horizontal gaps // Collect horizontal gaps
for (let i = 0; i < sortedShapesOnCurrentPageHorizontal.length; i++) { for (let i = 0; i < currentPageShapesSortedHorizontal.length; i++) {
startNode = sortedShapesOnCurrentPageHorizontal[i] startNode = currentPageShapesSortedHorizontal[i]
for (let j = i + 1; j < sortedShapesOnCurrentPageHorizontal.length; j++) { for (let j = i + 1; j < currentPageShapesSortedHorizontal.length; j++) {
endNode = sortedShapesOnCurrentPageHorizontal[j] endNode = currentPageShapesSortedHorizontal[j]
if ( if (
// is there space between the boxes // is there space between the boxes
@ -358,14 +358,14 @@ export class SnapManager {
} }
// Collect vertical gaps // Collect vertical gaps
const sortedShapesOnCurrentPageVertical = sortedShapesOnCurrentPageHorizontal.sort((a, b) => { const currentPageShapesSortedVertical = currentPageShapesSortedHorizontal.sort((a, b) => {
return a.pageBounds.minY - b.pageBounds.minY return a.pageBounds.minY - b.pageBounds.minY
}) })
for (let i = 0; i < sortedShapesOnCurrentPageVertical.length; i++) { for (let i = 0; i < currentPageShapesSortedVertical.length; i++) {
startNode = sortedShapesOnCurrentPageVertical[i] startNode = currentPageShapesSortedVertical[i]
for (let j = i + 1; j < sortedShapesOnCurrentPageVertical.length; j++) { for (let j = i + 1; j < currentPageShapesSortedVertical.length; j++) {
endNode = sortedShapesOnCurrentPageVertical[j] endNode = currentPageShapesSortedVertical[j]
if ( if (
// is there space between the boxes // is there space between the boxes

View file

@ -52,12 +52,12 @@ export class GroupShapeUtil extends ShapeUtil<TLGroupShape> {
component(shape: TLGroupShape) { component(shape: TLGroupShape) {
// Not a class component, but eslint can't tell that :( // Not a class component, but eslint can't tell that :(
const { const {
erasingShapeIdsSet, erasingShapeIds,
currentPageState: { hintingShapeIds, focusedGroupId }, currentPageState: { hintingShapeIds, focusedGroupId },
zoomLevel, zoomLevel,
} = this.editor } = this.editor
const isErasing = erasingShapeIdsSet.has(shape.id) const isErasing = erasingShapeIds.includes(shape.id)
const isHintingOtherGroup = const isHintingOtherGroup =
hintingShapeIds.length > 0 && hintingShapeIds.length > 0 &&

View file

@ -9,7 +9,10 @@ export class Idle extends StateNode {
} }
override onEnter = () => { 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 = () => { override onCancel = () => {

View file

@ -29,21 +29,19 @@ export class Pointing extends StateNode {
this.editor.mark(this.markId) this.editor.mark(this.markId)
this.editor.createShapes<TLBaseBoxShape>( this.editor.createShapes<TLBaseBoxShape>([
[ {
{ id,
id, type: shapeType,
type: shapeType, x: originPagePoint.x,
x: originPagePoint.x, y: originPagePoint.y,
y: originPagePoint.y, props: {
props: { w: 1,
w: 1, h: 1,
h: 1,
},
}, },
], },
true ])
) this.editor.select(id)
this.editor.setCurrentTool('select.resizing', { this.editor.setCurrentTool('select.resizing', {
...info, ...info,
target: 'selection', target: 'selection',

View file

@ -1,2 +1,4 @@
/** @public */ /** @public */
export type RequiredKeys<T, K extends keyof T> = Pick<T, K> & Partial<T> export type RequiredKeys<T, K extends keyof T> = Partial<Omit<T, K>> & Pick<T, K>
/** @public */
export type OptionalKeys<T, K extends keyof T> = Omit<T, K> & Partial<Pick<T, K>>

View file

@ -84,12 +84,14 @@ export function useCanvasEvents() {
const files = Array.from(e.dataTransfer.files) 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 rect = editor.getContainer().getBoundingClientRect()
const point = editor.screenToPage({ x: e.clientX - rect.x, y: e.clientY - rect.y })
await editor.putExternalContent({ await editor.putExternalContent({
type: 'files', type: 'files',
files, files,
point: editor.screenToPage(e.clientX - rect.x, e.clientY - rect.y), point,
ignoreParent: false, ignoreParent: false,
}) })
} }

View file

@ -7,7 +7,11 @@ export function useCoarsePointer() {
// This is a workaround for a Firefox bug where we don't correctly // 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 // detect coarse VS fine pointer. For now, let's assume that you have a fine
// pointer if you're on Firefox on desktop. // 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 }) editor.updateInstanceState({ isCoarsePointer: false })
return return
} }

View file

@ -13,7 +13,7 @@ export function useZoomCss() {
const setScaleDebounced = debounce(setScale, 100) const setScaleDebounced = debounce(setScale, 100)
const scheduler = new EffectScheduler('useZoomCss', () => { const scheduler = new EffectScheduler('useZoomCss', () => {
const numShapes = editor.shapeIdsOnCurrentPage.size const numShapes = editor.currentPageShapeIds.size
if (numShapes < 300) { if (numShapes < 300) {
setScale(editor.zoomLevel) setScale(editor.zoomLevel)
} else { } else {

View file

@ -44,7 +44,7 @@ export interface AtomOptions<Value, Diff> {
* ```ts * ```ts
* const name = atom('name', 'John') * const name = atom('name', 'John')
* *
* console.log(name.value) // 'John' * print(name.value) // 'John'
* ``` * ```
* *
* @public * @public

View file

@ -26,7 +26,7 @@ type UNINITIALIZED = typeof UNINITIALIZED
* const count = atom('count', 0) * const count = atom('count', 0)
* const double = computed('double', (prevValue) => { * const double = computed('double', (prevValue) => {
* if (isUninitialized(prevValue)) { * if (isUninitialized(prevValue)) {
* console.log('First time!') * print('First time!')
* } * }
* return count.value * 2 * return count.value * 2
* }) * })
@ -296,7 +296,7 @@ export function getComputedInstance<Obj extends object, Prop extends keyof Obj>(
* ```ts * ```ts
* const name = atom('name', 'John') * const name = atom('name', 'John')
* const greeting = computed('greeting', () => `Hello ${name.value}!`) * 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. * `computed` may also be used as a decorator for creating computed class properties.

View file

@ -39,7 +39,7 @@ let stack: CaptureStackFrame | null = null
* }) * })
* *
* react('log name changes', () => { * 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<any, any>) {
* const name = atom('name', 'Bob') * const name = atom('name', 'Bob')
* react('greeting', () => { * react('greeting', () => {
* whyAmIRunning() * whyAmIRunning()
* console.log('Hello', name.value) * print('Hello', name.value)
* }) * })
* *
* name.set('Alice') * name.set('Alice')

View file

@ -143,7 +143,7 @@ export let currentTransaction = null as Transaction | null
* const lastName = atom('Doe') * const lastName = atom('Doe')
* *
* react('greet', () => { * react('greet', () => {
* console.log(`Hello, ${firstName.value} ${lastName.value}!`) * print(`Hello, ${firstName.value} ${lastName.value}!`)
* }) * })
* *
* // Logs "Hello, John Doe!" * // Logs "Hello, John Doe!"
@ -164,7 +164,7 @@ export let currentTransaction = null as Transaction | null
* const lastName = atom('Doe') * const lastName = atom('Doe')
* *
* react('greet', () => { * react('greet', () => {
* console.log(`Hello, ${firstName.value} ${lastName.value}!`) * print(`Hello, ${firstName.value} ${lastName.value}!`)
* }) * })
* *
* // Logs "Hello, John Doe!" * // Logs "Hello, John Doe!"
@ -187,7 +187,7 @@ export let currentTransaction = null as Transaction | null
* const lastName = atom('Doe') * const lastName = atom('Doe')
* *
* react('greet', () => { * react('greet', () => {
* console.log(`Hello, ${firstName.value} ${lastName.value}!`) * print(`Hello, ${firstName.value} ${lastName.value}!`)
* }) * })
* *
* // Logs "Hello, John Doe!" * // Logs "Hello, John Doe!"

View file

@ -244,6 +244,8 @@ export class Store<R extends UnknownRecord = UnknownRecord, Props = unknown> {
// (undocumented) // (undocumented)
_flushHistory(): void; _flushHistory(): void;
get: <K extends IdOf<R>>(id: K) => RecFromId<K> | undefined; get: <K extends IdOf<R>>(id: K) => RecFromId<K> | undefined;
// (undocumented)
getRecordType: <T extends R>(record: R) => T;
getSnapshot(scope?: 'all' | RecordScope): StoreSnapshot<R>; getSnapshot(scope?: 'all' | RecordScope): StoreSnapshot<R>;
has: <K extends IdOf<R>>(id: K) => boolean; has: <K extends IdOf<R>>(id: K) => boolean;
readonly history: Atom<number, RecordsDiff<R>>; readonly history: Atom<number, RecordsDiff<R>>;
@ -255,10 +257,12 @@ export class Store<R extends UnknownRecord = UnknownRecord, Props = unknown> {
// @internal (undocumented) // @internal (undocumented)
markAsPossiblyCorrupted(): void; markAsPossiblyCorrupted(): void;
mergeRemoteChanges: (fn: () => void) => void; mergeRemoteChanges: (fn: () => void) => void;
onAfterChange?: (prev: R, next: R) => void; onAfterChange?: (prev: R, next: R, source: 'remote' | 'user') => void;
onAfterCreate?: (record: R) => void; onAfterCreate?: (record: R, source: 'remote' | 'user') => void;
onAfterDelete?: (prev: R) => void; onAfterDelete?: (prev: R, source: 'remote' | 'user') => void;
onBeforeDelete?: (prev: R) => 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) // (undocumented)
readonly props: Props; readonly props: Props;
put: (records: R[], phaseOverride?: 'initialize') => void; put: (records: R[], phaseOverride?: 'initialize') => void;

View file

@ -297,13 +297,29 @@ export class Store<R extends UnknownRecord = UnknownRecord, Props = unknown> {
this.allRecords().forEach((record) => this.schema.validateRecord(this, record, phase, null)) 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 * A callback fired after a record is created. Use this to perform related updates to other
* records in the store. * records in the store.
* *
* @param record - The record to be created * @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. * A callback fired after each record's change.
@ -311,21 +327,21 @@ export class Store<R extends UnknownRecord = UnknownRecord, Props = unknown> {
* @param prev - The previous value, if any. * @param prev - The previous value, if any.
* @param next - The next value. * @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. * A callback fired before a record is deleted.
* *
* @param prev - The record that will be 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. * A callback fired after a record is deleted.
* *
* @param prev - The record that will be 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 // used to avoid running callbacks when rolling back changes in sync client
private _runCallbacks = true private _runCallbacks = true
@ -353,12 +369,18 @@ export class Store<R extends UnknownRecord = UnknownRecord, Props = unknown> {
// changes (e.g. additions, deletions, or updates that produce a new value). // changes (e.g. additions, deletions, or updates that produce a new value).
let didChange = false 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++) { for (let i = 0, n = records.length; i < n; i++) {
record = records[i] record = records[i]
const recordAtom = (map ?? currentMap)[record.id as IdOf<R>] const recordAtom = (map ?? currentMap)[record.id as IdOf<R>]
if (recordAtom) { if (recordAtom) {
if (beforeUpdate) record = beforeUpdate(recordAtom.value, record, source)
// If we already have an atom for this record, update its value. // If we already have an atom for this record, update its value.
const initialValue = recordAtom.__unsafe__getWithoutCapture() const initialValue = recordAtom.__unsafe__getWithoutCapture()
@ -382,6 +404,8 @@ export class Store<R extends UnknownRecord = UnknownRecord, Props = unknown> {
updates[record.id] = [initialValue, finalValue] updates[record.id] = [initialValue, finalValue]
} }
} else { } else {
if (beforeCreate) record = beforeCreate(record, source)
didChange = true didChange = true
// If we don't have an atom, create one. // If we don't have an atom, create one.
@ -418,20 +442,22 @@ export class Store<R extends UnknownRecord = UnknownRecord, Props = unknown> {
removed: {} as Record<IdOf<R>, R>, removed: {} as Record<IdOf<R>, R>,
}) })
const { onAfterCreate, onAfterChange } = this if (this._runCallbacks) {
const { onAfterCreate, onAfterChange } = this
if (onAfterCreate && this._runCallbacks) { if (onAfterCreate) {
// Run the onAfterChange callback for addition. // Run the onAfterChange callback for addition.
Object.values(additions).forEach((record) => { Object.values(additions).forEach((record) => {
onAfterCreate(record) onAfterCreate(record, source)
}) })
} }
if (onAfterChange && this._runCallbacks) { if (onAfterChange) {
// Run the onAfterChange callback for update. // Run the onAfterChange callback for update.
Object.values(updates).forEach(([from, to]) => { Object.values(updates).forEach(([from, to]) => {
onAfterChange(from, to) onAfterChange(from, to, source)
}) })
}
} }
}) })
} }
@ -444,12 +470,17 @@ export class Store<R extends UnknownRecord = UnknownRecord, Props = unknown> {
*/ */
remove = (ids: IdOf<R>[]): void => { remove = (ids: IdOf<R>[]): void => {
transact(() => { transact(() => {
const cancelled = [] as IdOf<R>[]
const source = this.isMergingRemoteChanges ? 'remote' : 'user'
if (this.onBeforeDelete && this._runCallbacks) { if (this.onBeforeDelete && this._runCallbacks) {
for (const id of ids) { for (const id of ids) {
const atom = this.atoms.__unsafe__getWithoutCapture()[id] const atom = this.atoms.__unsafe__getWithoutCapture()[id]
if (!atom) continue if (!atom) continue
this.onBeforeDelete(atom.value) if (this.onBeforeDelete(atom.value, source) === false) {
cancelled.push(id)
}
} }
} }
@ -460,6 +491,7 @@ export class Store<R extends UnknownRecord = UnknownRecord, Props = unknown> {
let result: typeof atoms | undefined = undefined let result: typeof atoms | undefined = undefined
for (const id of ids) { for (const id of ids) {
if (cancelled.includes(id)) continue
if (!(id in atoms)) continue if (!(id in atoms)) continue
if (!result) result = { ...atoms } if (!result) result = { ...atoms }
if (!removed) removed = {} as Record<IdOf<R>, R> if (!removed) removed = {} as Record<IdOf<R>, R>
@ -476,8 +508,12 @@ export class Store<R extends UnknownRecord = UnknownRecord, Props = unknown> {
// If we have an onAfterChange, run it for each removed record. // If we have an onAfterChange, run it for each removed record.
if (this.onAfterDelete && this._runCallbacks) { if (this.onAfterDelete && this._runCallbacks) {
let record: R
for (let i = 0, n = ids.length; i < n; i++) { 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<R extends UnknownRecord = UnknownRecord, Props = unknown> {
console.error(`Record ${id} not found. This is probably an error`) console.error(`Record ${id} not found. This is probably an error`)
return return
} }
this.put([updater(atom.__unsafe__getWithoutCapture() as any as RecFromId<K>) as any]) this.put([updater(atom.__unsafe__getWithoutCapture() as any as RecFromId<K>) as any])
} }
@ -752,6 +789,14 @@ export class Store<R extends UnknownRecord = UnknownRecord, Props = unknown> {
} }
} }
getRecordType = <T extends R>(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 private _integrityChecker?: () => void | undefined
/** @internal */ /** @internal */

View file

@ -39,9 +39,9 @@ it('enters the arrow state', () => {
describe('When in the idle state', () => { describe('When in the idle state', () => {
it('enters the pointing state and creates a shape on pointer down', () => { 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) editor.setCurrentTool('arrow').pointerDown(0, 0)
const shapesAfter = editor.shapesOnCurrentPage.length const shapesAfter = editor.currentPageShapes.length
expect(shapesAfter).toBe(shapesBefore + 1) expect(shapesAfter).toBe(shapesBefore + 1)
editor.expectPathToBe('root.arrow.pointing') editor.expectPathToBe('root.arrow.pointing')
}) })
@ -55,18 +55,18 @@ describe('When in the idle state', () => {
describe('When in the pointing state', () => { describe('When in the pointing state', () => {
it('cancels on pointer up', () => { it('cancels on pointer up', () => {
const shapesBefore = editor.shapesOnCurrentPage.length const shapesBefore = editor.currentPageShapes.length
editor.setCurrentTool('arrow').pointerDown(0, 0).pointerUp(0, 0) editor.setCurrentTool('arrow').pointerDown(0, 0).pointerUp(0, 0)
const shapesAfter = editor.shapesOnCurrentPage.length const shapesAfter = editor.currentPageShapes.length
expect(shapesAfter).toBe(shapesBefore) expect(shapesAfter).toBe(shapesBefore)
expect(editor.hintingShapeIds.length).toBe(0) expect(editor.hintingShapeIds.length).toBe(0)
editor.expectPathToBe('root.arrow.idle') editor.expectPathToBe('root.arrow.idle')
}) })
it('bails on cancel', () => { it('bails on cancel', () => {
const shapesBefore = editor.shapesOnCurrentPage.length const shapesBefore = editor.currentPageShapes.length
editor.setCurrentTool('arrow').pointerDown(0, 0).cancel() editor.setCurrentTool('arrow').pointerDown(0, 0).cancel()
const shapesAfter = editor.shapesOnCurrentPage.length const shapesAfter = editor.currentPageShapes.length
expect(shapesAfter).toBe(shapesBefore) expect(shapesAfter).toBe(shapesBefore)
expect(editor.hintingShapeIds.length).toBe(0) expect(editor.hintingShapeIds.length).toBe(0)
editor.expectPathToBe('root.arrow.idle') editor.expectPathToBe('root.arrow.idle')
@ -82,7 +82,7 @@ describe('When in the pointing state', () => {
describe('When dragging the arrow', () => { describe('When dragging the arrow', () => {
it('updates the arrow on pointer move', () => { it('updates the arrow on pointer move', () => {
editor.setCurrentTool('arrow').pointerDown(0, 0).pointerMove(10, 10) 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, { editor.expectShapeToMatch(arrow, {
id: arrow.id, id: arrow.id,
type: 'arrow', type: 'arrow',
@ -97,9 +97,9 @@ describe('When dragging the arrow', () => {
}) })
it('returns to select.idle, keeping shape, on pointer up', () => { 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) 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(shapesAfter).toBe(shapesBefore + 1)
expect(editor.hintingShapeIds.length).toBe(0) expect(editor.hintingShapeIds.length).toBe(0)
editor.expectPathToBe('root.select.idle') 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', () => { it('returns to arrow.idle, keeping shape, on pointer up when tool lock is active', () => {
editor.updateInstanceState({ isToolLocked: true }) 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) 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(shapesAfter).toBe(shapesBefore + 1)
expect(editor.hintingShapeIds.length).toBe(0) expect(editor.hintingShapeIds.length).toBe(0)
editor.expectPathToBe('root.arrow.idle') editor.expectPathToBe('root.arrow.idle')
}) })
it('bails on cancel', () => { it('bails on cancel', () => {
const shapesBefore = editor.shapesOnCurrentPage.length const shapesBefore = editor.currentPageShapes.length
editor.setCurrentTool('arrow').pointerDown(0, 0).pointerMove(10, 10).cancel() editor.setCurrentTool('arrow').pointerDown(0, 0).pointerMove(10, 10).cancel()
const shapesAfter = editor.shapesOnCurrentPage.length const shapesAfter = editor.currentPageShapes.length
expect(shapesAfter).toBe(shapesBefore) expect(shapesAfter).toBe(shapesBefore)
editor.expectPathToBe('root.arrow.idle') editor.expectPathToBe('root.arrow.idle')
}) })
@ -139,7 +139,7 @@ describe('When pointing a start shape', () => {
// Clear hinting ids when moving away // Clear hinting ids when moving away
expect(editor.hintingShapeIds.length).toBe(0) 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, { editor.expectShapeToMatch(arrow, {
id: arrow.id, id: arrow.id,
type: 'arrow', type: 'arrow',
@ -179,7 +179,7 @@ describe('When pointing an end shape', () => {
// Set hinting id when pointing the shape // Set hinting id when pointing the shape
expect(editor.hintingShapeIds.length).toBe(1) 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, { editor.expectShapeToMatch(arrow, {
id: arrow.id, id: arrow.id,
type: 'arrow', type: 'arrow',
@ -208,7 +208,7 @@ describe('When pointing an end shape', () => {
editor.pointerMove(375, 375) 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) expect(editor.hintingShapeIds.length).toBe(1)
@ -230,7 +230,7 @@ describe('When pointing an end shape', () => {
jest.advanceTimersByTime(1000) jest.advanceTimersByTime(1000)
arrow = editor.shapesOnCurrentPage[editor.shapesOnCurrentPage.length - 1] arrow = editor.currentPageShapes[editor.currentPageShapes.length - 1]
editor.expectShapeToMatch(arrow, { editor.expectShapeToMatch(arrow, {
id: arrow.id, id: arrow.id,
@ -250,7 +250,7 @@ describe('When pointing an end shape', () => {
editor.pointerMove(375, 0) editor.pointerMove(375, 0)
expect(editor.hintingShapeIds.length).toBe(0) expect(editor.hintingShapeIds.length).toBe(0)
arrow = editor.shapesOnCurrentPage[editor.shapesOnCurrentPage.length - 1] arrow = editor.currentPageShapes[editor.currentPageShapes.length - 1]
editor.expectShapeToMatch(arrow, { editor.expectShapeToMatch(arrow, {
id: arrow.id, id: arrow.id,
@ -268,7 +268,7 @@ describe('When pointing an end shape', () => {
editor.pointerMove(325, 325) editor.pointerMove(325, 325)
expect(editor.hintingShapeIds.length).toBe(1) expect(editor.hintingShapeIds.length).toBe(1)
arrow = editor.shapesOnCurrentPage[editor.shapesOnCurrentPage.length - 1] arrow = editor.currentPageShapes[editor.currentPageShapes.length - 1]
editor.expectShapeToMatch(arrow, { editor.expectShapeToMatch(arrow, {
id: arrow.id, id: arrow.id,
@ -289,7 +289,7 @@ describe('When pointing an end shape', () => {
// Give time for the velocity to die down // Give time for the velocity to die down
jest.advanceTimersByTime(1000) jest.advanceTimersByTime(1000)
arrow = editor.shapesOnCurrentPage[editor.shapesOnCurrentPage.length - 1] arrow = editor.currentPageShapes[editor.currentPageShapes.length - 1]
editor.expectShapeToMatch(arrow, { editor.expectShapeToMatch(arrow, {
id: arrow.id, id: arrow.id,
@ -316,7 +316,7 @@ describe('When pointing an end shape', () => {
editor.inputs.pointerVelocity = new Vec2d(1, 1) editor.inputs.pointerVelocity = new Vec2d(1, 1)
editor.pointerMove(370, 370) 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) expect(editor.hintingShapeIds.length).toBe(1)
@ -340,7 +340,7 @@ describe('When pointing an end shape', () => {
it('begins precise when moving slowly', () => { it('begins precise when moving slowly', () => {
editor.setCurrentTool('arrow').pointerDown(0, 0) 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, { editor.expectShapeToMatch(arrow, {
id: arrow.id, id: arrow.id,
@ -358,7 +358,7 @@ describe('When pointing an end shape', () => {
editor.inputs.pointerVelocity = new Vec2d(0.001, 0.001) editor.inputs.pointerVelocity = new Vec2d(0.001, 0.001)
editor.pointerMove(375, 375) editor.pointerMove(375, 375)
arrow = editor.shapesOnCurrentPage[editor.shapesOnCurrentPage.length - 1] arrow = editor.currentPageShapes[editor.currentPageShapes.length - 1]
expect(editor.hintingShapeIds.length).toBe(1) expect(editor.hintingShapeIds.length).toBe(1)
@ -390,7 +390,7 @@ describe('reparenting issue', () => {
editor.pointerMove(100, 100) editor.pointerMove(100, 100)
editor.pointerUp() editor.pointerUp()
const arrowId = editor.sortedShapesOnCurrentPage[0].id const arrowId = editor.currentPageShapesSorted[0].id
// Now create three shapes // Now create three shapes
editor.createShapes([ editor.createShapes([

View file

@ -302,7 +302,7 @@ describe('Other cases when arrow are moved', () => {
.groupShapes(editor.selectedShapeIds) .groupShapes(editor.selectedShapeIds)
editor.setCurrentTool('arrow').pointerDown(1000, 1000).pointerMove(50, 350).pointerUp(50, 350) 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<TLArrowShape>(arrow, 'arrow')) assert(editor.isShapeOfType<TLArrowShape>(arrow, 'arrow'))
assert(arrow.props.end.type === 'binding') assert(arrow.props.end.type === 'binding')
expect(arrow.props.end.boundShapeId).toBe(ids.box3) expect(arrow.props.end.boundShapeId).toBe(ids.box3)
@ -322,7 +322,7 @@ describe('When a shape it rotated', () => {
it('binds correctly', () => { it('binds correctly', () => {
editor.setCurrentTool('arrow').pointerDown(0, 0).pointerMove(375, 375) 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({ expect(editor.getShape(arrow.id)).toMatchObject({
props: { props: {
@ -371,8 +371,8 @@ describe('resizing', () => {
.pointerUp() .pointerUp()
.setCurrentTool('select') .setCurrentTool('select')
const arrow1 = editor.shapesOnCurrentPage.at(-2)! const arrow1 = editor.currentPageShapes.at(-2)!
const arrow2 = editor.shapesOnCurrentPage.at(-1)! const arrow2 = editor.currentPageShapes.at(-1)!
editor editor
.select(arrow1.id, arrow2.id) .select(arrow1.id, arrow2.id)
@ -426,8 +426,8 @@ describe('resizing', () => {
.pointerUp() .pointerUp()
.setCurrentTool('select') .setCurrentTool('select')
const arrow1 = editor.shapesOnCurrentPage.at(-2)! const arrow1 = editor.currentPageShapes.at(-2)!
const arrow2 = editor.shapesOnCurrentPage.at(-1)! const arrow2 = editor.currentPageShapes.at(-1)!
editor.updateShapes([{ id: arrow1.id, type: 'arrow', props: { bend: 50 } }]) editor.updateShapes([{ id: arrow1.id, type: 'arrow', props: { bend: 50 } }])
@ -551,6 +551,7 @@ describe("an arrow's parents", () => {
}) })
// move b outside of frame // move b outside of frame
editor.select(boxBid).translateSelection(200, 0) editor.select(boxBid).translateSelection(200, 0)
jest.advanceTimersByTime(500)
expect(editor.getShape(arrowId)).toMatchObject({ expect(editor.getShape(arrowId)).toMatchObject({
parentId: editor.currentPageId, parentId: editor.currentPageId,
props: { props: {
@ -565,6 +566,10 @@ describe("an arrow's parents", () => {
editor.setCurrentTool('arrow') editor.setCurrentTool('arrow')
editor.pointerDown(15, 15).pointerMove(115, 15).pointerUp() editor.pointerDown(15, 15).pointerMove(115, 15).pointerUp()
const arrowId = editor.onlySelectedShape!.id 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({ expect(editor.getShape(arrowId)).toMatchObject({
parentId: editor.currentPageId, parentId: editor.currentPageId,
props: { props: {
@ -576,6 +581,12 @@ describe("an arrow's parents", () => {
// move c inside of frame // move c inside of frame
editor.select(boxCid).translateSelection(-40, 0) 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({ expect(editor.getShape(arrowId)).toMatchObject({
parentId: frameId, parentId: frameId,
props: { props: {

View file

@ -495,7 +495,7 @@ export class ArrowShapeUtil extends ShapeUtil<TLArrowShape> {
// eslint-disable-next-line react-hooks/rules-of-hooks // eslint-disable-next-line react-hooks/rules-of-hooks
const changeIndex = React.useMemo<number>(() => { const changeIndex = React.useMemo<number>(() => {
return this.editor.isSafari ? (globalRenderIndex += 1) : 0 return this.editor.environment.isSafari ? (globalRenderIndex += 1) : 0
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, [shape]) }, [shape])

View file

@ -8,7 +8,10 @@ export class Idle extends StateNode {
} }
override onEnter = () => { 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 = () => { override onCancel = () => {

View file

@ -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 { export class Pointing extends StateNode {
static override id = 'pointing' static override id = 'pointing'
@ -7,6 +7,8 @@ export class Pointing extends StateNode {
markId = '' markId = ''
initialEndHandle = {} as TLHandle
override onEnter = () => { override onEnter = () => {
this.didTimeout = false this.didTimeout = false
@ -19,7 +21,7 @@ export class Pointing extends StateNode {
if (!target) { if (!target) {
this.createArrowShape() this.createArrowShape()
} else { } else {
this.editor.setHintingIds([target.id]) this.editor.setHintingShapeIds([target.id])
} }
this.startPreciseTimeout() this.startPreciseTimeout()
@ -27,7 +29,7 @@ export class Pointing extends StateNode {
override onExit = () => { override onExit = () => {
this.shape = undefined this.shape = undefined
this.editor.setHintingIds([]) this.editor.setHintingShapeIds([])
this.clearPreciseTimeout() this.clearPreciseTimeout()
} }
@ -43,7 +45,7 @@ export class Pointing extends StateNode {
this.editor.setCurrentTool('select.dragging_handle', { this.editor.setCurrentTool('select.dragging_handle', {
shape: this.shape, shape: this.shape,
handle: this.editor.getHandles(this.shape)!.find((h) => h.id === 'end')!, handle: this.initialEndHandle,
isCreating: true, isCreating: true,
onInteractionEnd: 'arrow', onInteractionEnd: 'arrow',
}) })
@ -71,7 +73,7 @@ export class Pointing extends StateNode {
// the arrow might not have been created yet! // the arrow might not have been created yet!
this.editor.bailToMark(this.markId) this.editor.bailToMark(this.markId)
} }
this.editor.setHintingIds([]) this.editor.setHintingShapeIds([])
this.parent.transition('idle', {}) this.parent.transition('idle', {})
} }
@ -80,9 +82,9 @@ export class Pointing extends StateNode {
const id = createShapeId() const id = createShapeId()
this.markId = this.editor.mark(`creating:${id}`) this.markId = `creating:${id}`
this.editor.createShapes<TLArrowShape>([ this.editor.mark(this.markId).createShapes<TLArrowShape>([
{ {
id, id,
type: 'arrow', type: 'arrow',
@ -107,28 +109,27 @@ export class Pointing extends StateNode {
if (change) { if (change) {
const startTerminal = change.props?.start const startTerminal = change.props?.start
if (startTerminal?.type === 'binding') { 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 // Cache the current shape after those changes
this.shape = this.editor.getShape(id) 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() { updateArrowShapeEndHandle() {
const shape = this.shape const util = this.editor.getShapeUtil<TLArrowShape>('arrow')
if (!shape) throw Error(`expected shape`)
const handles = this.editor.getHandles(shape)
if (!handles) throw Error(`expected handles for arrow`)
// end update // end update
{ {
const util = this.editor.getShapeUtil<TLArrowShape>('arrow') const shape = this.editor.getShape(this.shape!.id)! as TLArrowShape
const point = this.editor.getPointInShapeSpace(shape, this.editor.inputs.currentPagePoint) 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, { const change = util.onHandleChange?.(shape, {
handle: { ...endHandle, x: point.x, y: point.y }, handle: { ...endHandle, x: point.x, y: point.y },
isPrecise: false, // sure about that? isPrecise: false, // sure about that?
@ -137,28 +138,28 @@ export class Pointing extends StateNode {
if (change) { if (change) {
const endTerminal = change.props?.end const endTerminal = change.props?.end
if (endTerminal?.type === 'binding') { 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 // start update
{ {
const util = this.editor.getShapeUtil<TLArrowShape>('arrow') const shape = this.editor.getShape(this.shape!.id)! as TLArrowShape
const startHandle = handles.find((h) => h.id === 'start')! const startHandle = this.editor.getHandles(shape)!.find((h) => h.id === 'start')!
const change = util.onHandleChange?.(shape, { const change = util.onHandleChange?.(shape, {
handle: { ...startHandle, x: 0, y: 0 }, handle: { ...startHandle, x: 0, y: 0 },
isPrecise: this.didTimeout, // sure about that? isPrecise: this.didTimeout, // sure about that?
}) })
if (change) { if (change) {
this.editor.updateShapes([change], true) this.editor.updateShape(change, { squashing: true })
} }
} }
// Cache the current shape after those changes // 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 private preciseTimeout = -1

View file

@ -364,7 +364,7 @@ export class Drawing extends StateNode {
) )
} }
this.editor.updateShapes<TLDrawShape | TLHighlightShape>([shapePartial], true) this.editor.updateShape<TLDrawShape | TLHighlightShape>(shapePartial, { squashing: true })
} }
break break
} }
@ -424,7 +424,7 @@ export class Drawing extends StateNode {
) )
} }
this.editor.updateShapes([shapePartial], true) this.editor.updateShape(shapePartial, { squashing: true })
} }
break break
@ -566,7 +566,7 @@ export class Drawing extends StateNode {
) )
} }
this.editor.updateShapes([shapePartial], true) this.editor.updateShape(shapePartial, { squashing: true })
break 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. // Set a maximum length for the lines array; after 200 points, complete the line.
if (newPoints.length > 500) { 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 const { currentPagePoint } = this.editor.inputs

View file

@ -8,7 +8,10 @@ export class Idle extends StateNode {
} }
override onEnter = () => { 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 = () => { override onCancel = () => {

View file

@ -12,44 +12,44 @@ afterEach(() => {
describe(FrameShapeTool, () => { describe(FrameShapeTool, () => {
it('Creates frame shapes on click-and-drag, supports undo and redo', () => { 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.setCurrentTool('frame')
editor.pointerDown(50, 50) editor.pointerDown(50, 50)
editor.pointerMove(100, 100) editor.pointerMove(100, 100)
editor.pointerUp(100, 100) editor.pointerUp(100, 100)
expect(editor.shapesOnCurrentPage.length).toBe(1) expect(editor.currentPageShapes.length).toBe(1)
expect(editor.shapesOnCurrentPage[0]?.type).toBe('frame') expect(editor.currentPageShapes[0]?.type).toBe('frame')
expect(editor.selectedShapeIds[0]).toBe(editor.shapesOnCurrentPage[0]?.id) expect(editor.selectedShapeIds[0]).toBe(editor.currentPageShapes[0]?.id)
editor.undo() editor.undo()
expect(editor.shapesOnCurrentPage.length).toBe(0) expect(editor.currentPageShapes.length).toBe(0)
editor.redo() editor.redo()
expect(editor.shapesOnCurrentPage.length).toBe(1) expect(editor.currentPageShapes.length).toBe(1)
}) })
it('Creates frame shapes on click, supports undo and redo', () => { 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.setCurrentTool('frame')
editor.pointerDown(50, 50) editor.pointerDown(50, 50)
editor.pointerUp(50, 50) editor.pointerUp(50, 50)
expect(editor.shapesOnCurrentPage.length).toBe(1) expect(editor.currentPageShapes.length).toBe(1)
expect(editor.shapesOnCurrentPage[0]?.type).toBe('frame') expect(editor.currentPageShapes[0]?.type).toBe('frame')
expect(editor.selectedShapeIds[0]).toBe(editor.shapesOnCurrentPage[0]?.id) expect(editor.selectedShapeIds[0]).toBe(editor.currentPageShapes[0]?.id)
editor.undo() editor.undo()
expect(editor.shapesOnCurrentPage.length).toBe(0) expect(editor.currentPageShapes.length).toBe(0)
editor.redo() 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', () => { 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.setCurrentTool('frame')
editor.pointerDown(50, 50) editor.pointerDown(50, 50)
editor.pointerUp(50, 50) editor.pointerUp(50, 50)
editor.expectPathToBe('root.select.idle') 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', () => { it('Creates a frame and returns to frame.idle on pointer up if tool lock is enabled', () => {
editor.updateInstanceState({ isToolLocked: true }) editor.updateInstanceState({ isToolLocked: true })
expect(editor.shapesOnCurrentPage.length).toBe(0) expect(editor.currentPageShapes.length).toBe(0)
editor.setCurrentTool('frame') editor.setCurrentTool('frame')
editor.pointerDown(50, 50) editor.pointerDown(50, 50)
editor.pointerUp(50, 50) editor.pointerUp(50, 50)
editor.expectPathToBe('root.frame.idle') editor.expectPathToBe('root.frame.idle')
expect(editor.shapesOnCurrentPage.length).toBe(1) expect(editor.currentPageShapes.length).toBe(1)
}) })
}) })

View file

@ -15,7 +15,7 @@ export const FrameLabelInput = forwardRef<
// and sending us back into edit mode // and sending us back into edit mode
e.stopPropagation() e.stopPropagation()
e.currentTarget.blur() e.currentTarget.blur()
editor.setEditingId(null) editor.setEditingShapeId(null)
} }
}, },
[editor] [editor]
@ -30,16 +30,7 @@ export const FrameLabelInput = forwardRef<
const value = e.currentTarget.value.trim() const value = e.currentTarget.value.trim()
if (name === value) return if (name === value) return
editor.updateShapes( editor.updateShape({ id, type: 'frame', props: { name: value } }, { squashing: true })
[
{
id,
type: 'frame',
props: { name: value },
},
],
true
)
}, },
[id, editor] [id, editor]
) )
@ -53,16 +44,7 @@ export const FrameLabelInput = forwardRef<
const value = e.currentTarget.value const value = e.currentTarget.value
if (name === value) return if (name === value) return
editor.updateShapes( editor.updateShape({ id, type: 'frame', props: { name: value } }, { squashing: true })
[
{
id,
type: 'frame',
props: { name: value },
},
],
true
)
}, },
[id, editor] [id, editor]
) )

View file

@ -12,44 +12,44 @@ afterEach(() => {
describe(GeoShapeTool, () => { describe(GeoShapeTool, () => {
it('Creates geo shapes on click-and-drag, supports undo and redo', () => { 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.setCurrentTool('geo')
editor.pointerDown(50, 50) editor.pointerDown(50, 50)
editor.pointerMove(100, 100) editor.pointerMove(100, 100)
editor.pointerUp() editor.pointerUp()
expect(editor.shapesOnCurrentPage.length).toBe(1) expect(editor.currentPageShapes.length).toBe(1)
expect(editor.shapesOnCurrentPage[0]?.type).toBe('geo') expect(editor.currentPageShapes[0]?.type).toBe('geo')
expect(editor.selectedShapeIds[0]).toBe(editor.shapesOnCurrentPage[0]?.id) expect(editor.selectedShapeIds[0]).toBe(editor.currentPageShapes[0]?.id)
editor.undo() editor.undo()
expect(editor.shapesOnCurrentPage.length).toBe(0) expect(editor.currentPageShapes.length).toBe(0)
editor.redo() editor.redo()
expect(editor.shapesOnCurrentPage.length).toBe(1) expect(editor.currentPageShapes.length).toBe(1)
}) })
it('Creates geo shapes on click, supports undo and redo', () => { 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.setCurrentTool('geo')
editor.pointerDown(50, 50) editor.pointerDown(50, 50)
editor.pointerUp(50, 50) editor.pointerUp(50, 50)
expect(editor.shapesOnCurrentPage.length).toBe(1) expect(editor.currentPageShapes.length).toBe(1)
expect(editor.shapesOnCurrentPage[0]?.type).toBe('geo') expect(editor.currentPageShapes[0]?.type).toBe('geo')
expect(editor.selectedShapeIds[0]).toBe(editor.shapesOnCurrentPage[0]?.id) expect(editor.selectedShapeIds[0]).toBe(editor.currentPageShapes[0]?.id)
editor.undo() editor.undo()
expect(editor.shapesOnCurrentPage.length).toBe(0) expect(editor.currentPageShapes.length).toBe(0)
editor.redo() 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.pointerMove(200, 200)
editor.pointerUp(200, 200) editor.pointerUp(200, 200)
expect(editor.shapesOnCurrentPage.length).toBe(2) expect(editor.currentPageShapes.length).toBe(2)
editor.selectAll() editor.selectAll()
expect(editor.selectedShapes.length).toBe(2) 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', () => { 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.setCurrentTool('geo')
editor.pointerDown(50, 50) editor.pointerDown(50, 50)
editor.pointerUp(50, 50) editor.pointerUp(50, 50)
editor.expectPathToBe('root.select.idle') 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', () => { it('Creates a geo and returns to geo.idle on pointer up if tool lock is enabled', () => {
editor.updateInstanceState({ isToolLocked: true }) editor.updateInstanceState({ isToolLocked: true })
expect(editor.shapesOnCurrentPage.length).toBe(0) expect(editor.currentPageShapes.length).toBe(0)
editor.setCurrentTool('geo') editor.setCurrentTool('geo')
editor.pointerDown(50, 50) editor.pointerDown(50, 50)
editor.pointerUp(50, 50) editor.pointerUp(50, 50)
editor.expectPathToBe('root.geo.idle') editor.expectPathToBe('root.geo.idle')
expect(editor.shapesOnCurrentPage.length).toBe(1) expect(editor.currentPageShapes.length).toBe(1)
}) })
}) })

View file

@ -8,7 +8,10 @@ export class Idle extends StateNode {
} }
override onEnter = () => { 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) => { override onKeyUp: TLEventHandlers['onKeyUp'] = (info) => {
@ -17,7 +20,7 @@ export class Idle extends StateNode {
if (shape && this.editor.isShapeOfType<TLGeoShape>(shape, 'geo')) { if (shape && this.editor.isShapeOfType<TLGeoShape>(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 // 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.mark('editing shape')
this.editor.setEditingId(shape.id) this.editor.setEditingShapeId(shape.id)
this.editor.setCurrentTool('select.editing_shape', { this.editor.setCurrentTool('select.editing_shape', {
...info, ...info,
target: 'shape', target: 'shape',

View file

@ -15,9 +15,9 @@ it('enters the line state', () => {
describe('When in the idle state', () => { describe('When in the idle state', () => {
it('enters the pointing state and creates a shape on pointer down', () => { 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' }) editor.setCurrentTool('line').pointerDown(0, 0, { target: 'canvas' })
const shapesAfter = editor.shapesOnCurrentPage.length const shapesAfter = editor.currentPageShapes.length
expect(shapesAfter).toBe(shapesBefore + 1) expect(shapesAfter).toBe(shapesBefore + 1)
editor.expectPathToBe('root.line.pointing') editor.expectPathToBe('root.line.pointing')
}) })
@ -31,18 +31,18 @@ describe('When in the idle state', () => {
describe('When in the pointing state', () => { describe('When in the pointing state', () => {
it('createes on pointer up', () => { 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) 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(shapesAfter).toBe(shapesBefore + 1)
expect(editor.hintingShapeIds.length).toBe(0) expect(editor.hintingShapeIds.length).toBe(0)
editor.expectPathToBe('root.line.idle') editor.expectPathToBe('root.line.idle')
}) })
it('bails on cancel', () => { it('bails on cancel', () => {
const shapesBefore = editor.shapesOnCurrentPage.length const shapesBefore = editor.currentPageShapes.length
editor.setCurrentTool('line').pointerDown(0, 0, { target: 'canvas' }).cancel() editor.setCurrentTool('line').pointerDown(0, 0, { target: 'canvas' }).cancel()
const shapesAfter = editor.shapesOnCurrentPage.length const shapesAfter = editor.currentPageShapes.length
expect(shapesAfter).toBe(shapesBefore) expect(shapesAfter).toBe(shapesBefore)
expect(editor.hintingShapeIds.length).toBe(0) expect(editor.hintingShapeIds.length).toBe(0)
editor.expectPathToBe('root.line.idle') editor.expectPathToBe('root.line.idle')
@ -58,7 +58,7 @@ describe('When in the pointing state', () => {
describe('When dragging the line', () => { describe('When dragging the line', () => {
it('updates the line on pointer move', () => { it('updates the line on pointer move', () => {
editor.setCurrentTool('line').pointerDown(0, 0, { target: 'canvas' }).pointerMove(10, 10) 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, { editor.expectShapeToMatch(line, {
id: line.id, id: line.id,
type: 'line', type: 'line',
@ -75,13 +75,13 @@ describe('When dragging the line', () => {
}) })
it('returns to select.idle, keeping shape, on pointer up', () => { it('returns to select.idle, keeping shape, on pointer up', () => {
const shapesBefore = editor.shapesOnCurrentPage.length const shapesBefore = editor.currentPageShapes.length
editor editor
.setCurrentTool('line') .setCurrentTool('line')
.pointerDown(0, 0, { target: 'canvas' }) .pointerDown(0, 0, { target: 'canvas' })
.pointerMove(10, 10) .pointerMove(10, 10)
.pointerUp(10, 10) .pointerUp(10, 10)
const shapesAfter = editor.shapesOnCurrentPage.length const shapesAfter = editor.currentPageShapes.length
expect(shapesAfter).toBe(shapesBefore + 1) expect(shapesAfter).toBe(shapesBefore + 1)
expect(editor.hintingShapeIds.length).toBe(0) expect(editor.hintingShapeIds.length).toBe(0)
editor.expectPathToBe('root.select.idle') 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', () => { it('returns to line.idle, keeping shape, on pointer up if tool lock is enabled', () => {
editor.updateInstanceState({ isToolLocked: true }) editor.updateInstanceState({ isToolLocked: true })
const shapesBefore = editor.shapesOnCurrentPage.length const shapesBefore = editor.currentPageShapes.length
editor editor
.setCurrentTool('line') .setCurrentTool('line')
.pointerDown(0, 0, { target: 'canvas' }) .pointerDown(0, 0, { target: 'canvas' })
.pointerMove(10, 10) .pointerMove(10, 10)
.pointerUp(10, 10) .pointerUp(10, 10)
const shapesAfter = editor.shapesOnCurrentPage.length const shapesAfter = editor.currentPageShapes.length
expect(shapesAfter).toBe(shapesBefore + 1) expect(shapesAfter).toBe(shapesBefore + 1)
expect(editor.hintingShapeIds.length).toBe(0) expect(editor.hintingShapeIds.length).toBe(0)
editor.expectPathToBe('root.line.idle') editor.expectPathToBe('root.line.idle')
}) })
it('bails on cancel', () => { it('bails on cancel', () => {
const shapesBefore = editor.shapesOnCurrentPage.length const shapesBefore = editor.currentPageShapes.length
editor editor
.setCurrentTool('line') .setCurrentTool('line')
.pointerDown(0, 0, { target: 'canvas' }) .pointerDown(0, 0, { target: 'canvas' })
.pointerMove(10, 10) .pointerMove(10, 10)
.cancel() .cancel()
const shapesAfter = editor.shapesOnCurrentPage.length const shapesAfter = editor.currentPageShapes.length
expect(shapesAfter).toBe(shapesBefore) expect(shapesAfter).toBe(shapesBefore)
editor.expectPathToBe('root.line.idle') 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' }) .pointerDown(20, 10, { target: 'canvas' })
.pointerUp(20, 10) .pointerUp(20, 10)
const line = editor.shapesOnCurrentPage[editor.shapesOnCurrentPage.length - 1] const line = editor.currentPageShapes[editor.currentPageShapes.length - 1]
assert(editor.isShapeOfType<TLLineShape>(line, 'line')) assert(editor.isShapeOfType<TLLineShape>(line, 'line'))
const handles = Object.values(line.props.handles) const handles = Object.values(line.props.handles)
expect(handles.length).toBe(3) 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) .pointerMove(30, 10)
.pointerUp(30, 10) .pointerUp(30, 10)
const line = editor.shapesOnCurrentPage[editor.shapesOnCurrentPage.length - 1] const line = editor.currentPageShapes[editor.currentPageShapes.length - 1]
assert(editor.isShapeOfType<TLLineShape>(line, 'line')) assert(editor.isShapeOfType<TLLineShape>(line, 'line'))
const handles = Object.values(line.props.handles) const handles = Object.values(line.props.handles)
expect(handles.length).toBe(3) 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) .pointerMove(30, 10)
.pointerUp(30, 10) .pointerUp(30, 10)
const line = editor.shapesOnCurrentPage[editor.shapesOnCurrentPage.length - 1] const line = editor.currentPageShapes[editor.currentPageShapes.length - 1]
assert(editor.isShapeOfType<TLLineShape>(line, 'line')) assert(editor.isShapeOfType<TLLineShape>(line, 'line'))
const handles = Object.values(line.props.handles) const handles = Object.values(line.props.handles)
expect(handles.length).toBe(3) 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) .pointerMove(30, 10)
.pointerUp(30, 10) .pointerUp(30, 10)
const line = editor.shapesOnCurrentPage[editor.shapesOnCurrentPage.length - 1] const line = editor.currentPageShapes[editor.currentPageShapes.length - 1]
assert(editor.isShapeOfType<TLLineShape>(line, 'line')) assert(editor.isShapeOfType<TLLineShape>(line, 'line'))
const handles = Object.values(line.props.handles) const handles = Object.values(line.props.handles)
expect(handles.length).toBe(3) 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) .pointerMove(40, 10)
.pointerUp(40, 10) .pointerUp(40, 10)
const line = editor.shapesOnCurrentPage[editor.shapesOnCurrentPage.length - 1] const line = editor.currentPageShapes[editor.currentPageShapes.length - 1]
assert(editor.isShapeOfType<TLLineShape>(line, 'line')) assert(editor.isShapeOfType<TLLineShape>(line, 'line'))
const handles = Object.values(line.props.handles) const handles = Object.values(line.props.handles)
expect(handles.length).toBe(3) expect(handles.length).toBe(3)

View file

@ -62,7 +62,7 @@ describe('Translating', () => {
editor.select(id) editor.select(id)
const shape = editor.getShape<TLLineShape>(id)! const shape = editor.getShape<TLLineShape>(id)!
shape.rotation = Math.PI / 2 editor.updateShape({ ...shape, rotation: Math.PI / 2 })
editor.pointerDown(250, 250, { target: 'shape', shape: shape }) editor.pointerDown(250, 250, { target: 'shape', shape: shape })
editor.pointerMove(300, 400) // Move shape by 50, 150 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.pointerMove(50, 50) // Move shape by 25, 25
editor.pointerUp().keyUp('Alt') editor.pointerUp().keyUp('Alt')
expect(Array.from(editor.shapeIdsOnCurrentPage.values()).length).toEqual(2) expect(Array.from(editor.currentPageShapeIds.values()).length).toEqual(2)
}) })
it('deletes', () => { it('deletes', () => {
@ -207,7 +207,7 @@ describe('Misc', () => {
editor.pointerMove(50, 50) // Move shape by 25, 25 editor.pointerMove(50, 50) // Move shape by 25, 25
editor.pointerUp().keyUp('Alt') editor.pointerUp().keyUp('Alt')
let ids = Array.from(editor.shapeIdsOnCurrentPage.values()) let ids = Array.from(editor.currentPageShapeIds.values())
expect(ids.length).toEqual(2) expect(ids.length).toEqual(2)
const duplicate = ids.filter((i) => i !== id)[0] const duplicate = ids.filter((i) => i !== id)[0]
@ -215,7 +215,7 @@ describe('Misc', () => {
editor.deleteShapes(editor.selectedShapeIds) editor.deleteShapes(editor.selectedShapeIds)
ids = Array.from(editor.shapeIdsOnCurrentPage.values()) ids = Array.from(editor.currentPageShapeIds.values())
expect(ids.length).toEqual(1) expect(ids.length).toEqual(1)
expect(ids[0]).toEqual(id) expect(ids[0]).toEqual(id)
}) })

View file

@ -7,7 +7,7 @@ Object {
"isLocked": false, "isLocked": false,
"meta": Object {}, "meta": Object {},
"opacity": 1, "opacity": 1,
"parentId": "page:id51", "parentId": "page:id60",
"props": Object { "props": Object {
"color": "black", "color": "black",
"dash": "draw", "dash": "draw",

View file

@ -7,7 +7,10 @@ export class Idle extends StateNode {
override onEnter = (info: { shapeId: TLShapeId }) => { override onEnter = (info: { shapeId: TLShapeId }) => {
this.shapeId = info.shapeId 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'] = () => { override onPointerDown: TLEventHandlers['onPointerDown'] = () => {

View file

@ -30,7 +30,8 @@ export class Pointing extends StateNode {
const shape = info.shapeId && this.editor.getShape<TLLineShape>(info.shapeId) const shape = info.shapeId && this.editor.getShape<TLLineShape>(info.shapeId)
if (shape) { if (shape) {
this.markId = this.editor.mark(`creating:${shape.id}`) this.markId = `creating:${shape.id}`
this.editor.mark(this.markId)
this.shape = shape this.shape = shape
if (inputs.shiftKey) { if (inputs.shiftKey) {
@ -85,18 +86,20 @@ export class Pointing extends StateNode {
} else { } else {
const id = createShapeId() const id = createShapeId()
this.markId = this.editor.mark(`creating:${id}`) this.markId = `creating:${id}`
this.editor.createShapes<TLLineShape>([ this.editor
{ .mark(this.markId)
id, .createShapes<TLLineShape>([
type: 'line', {
x: currentPagePoint.x, id,
y: currentPagePoint.y, type: 'line',
}, x: currentPagePoint.x,
]) y: currentPagePoint.y,
},
])
.select(id)
this.editor.select(id)
this.shape = this.editor.getShape(id)! this.shape = this.editor.getShape(id)!
} }
} }

View file

@ -12,47 +12,47 @@ afterEach(() => {
describe(NoteShapeTool, () => { describe(NoteShapeTool, () => {
it('Creates note shapes on click-and-drag, supports undo and redo', () => { 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.setCurrentTool('note')
editor.pointerDown(50, 50) editor.pointerDown(50, 50)
editor.pointerMove(100, 100) editor.pointerMove(100, 100)
editor.pointerUp(100, 100) editor.pointerUp(100, 100)
expect(editor.shapesOnCurrentPage.length).toBe(1) expect(editor.currentPageShapes.length).toBe(1)
expect(editor.shapesOnCurrentPage[0]?.type).toBe('note') expect(editor.currentPageShapes[0]?.type).toBe('note')
expect(editor.selectedShapeIds[0]).toBe(editor.shapesOnCurrentPage[0]?.id) expect(editor.selectedShapeIds[0]).toBe(editor.currentPageShapes[0]?.id)
editor.cancel() // leave edit mode editor.cancel() // leave edit mode
editor.undo() // undoes the selection change editor.undo() // undoes the selection change
editor.undo() editor.undo()
expect(editor.shapesOnCurrentPage.length).toBe(0) expect(editor.currentPageShapes.length).toBe(0)
editor.redo() editor.redo()
expect(editor.shapesOnCurrentPage.length).toBe(1) expect(editor.currentPageShapes.length).toBe(1)
}) })
it('Creates note shapes on click, supports undo and redo', () => { 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.setCurrentTool('note')
editor.pointerDown(50, 50) editor.pointerDown(50, 50)
editor.pointerUp(50, 50) editor.pointerUp(50, 50)
expect(editor.shapesOnCurrentPage.length).toBe(1) expect(editor.currentPageShapes.length).toBe(1)
expect(editor.shapesOnCurrentPage[0]?.type).toBe('note') expect(editor.currentPageShapes[0]?.type).toBe('note')
expect(editor.selectedShapeIds[0]).toBe(editor.shapesOnCurrentPage[0]?.id) expect(editor.selectedShapeIds[0]).toBe(editor.currentPageShapes[0]?.id)
editor.undo() editor.undo()
expect(editor.shapesOnCurrentPage.length).toBe(0) expect(editor.currentPageShapes.length).toBe(0)
editor.redo() 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', () => { 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.setCurrentTool('note')
editor.pointerDown(50, 50) editor.pointerDown(50, 50)
editor.pointerUp(50, 50) editor.pointerUp(50, 50)
editor.expectPathToBe('root.select.editing_shape') 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', () => { it('Creates a frame and returns to frame.idle on pointer up if tool lock is enabled', () => {
editor.updateInstanceState({ isToolLocked: true }) editor.updateInstanceState({ isToolLocked: true })
expect(editor.shapesOnCurrentPage.length).toBe(0) expect(editor.currentPageShapes.length).toBe(0)
editor.setCurrentTool('note') editor.setCurrentTool('note')
editor.pointerDown(50, 50) editor.pointerDown(50, 50)
editor.pointerUp(50, 50) editor.pointerUp(50, 50)
editor.expectPathToBe('root.note.idle') editor.expectPathToBe('root.note.idle')
expect(editor.shapesOnCurrentPage.length).toBe(1) expect(editor.currentPageShapes.length).toBe(1)
}) })
}) })

View file

@ -8,7 +8,10 @@ export class Idle extends StateNode {
} }
override onEnter = () => { 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 = () => { override onCancel = () => {

View file

@ -65,7 +65,7 @@ export class Pointing extends StateNode {
if (this.editor.instanceState.isToolLocked) { if (this.editor.instanceState.isToolLocked) {
this.parent.transition('idle', {}) this.parent.transition('idle', {})
} else { } else {
this.editor.setEditingId(this.shape.id) this.editor.setEditingShapeId(this.shape.id)
this.editor.setCurrentTool('select.editing_shape', { this.editor.setCurrentTool('select.editing_shape', {
...this.info, ...this.info,
target: 'shape', target: 'shape',
@ -87,19 +87,17 @@ export class Pointing extends StateNode {
const id = createShapeId() const id = createShapeId()
this.markId = `creating:${id}` this.markId = `creating:${id}`
this.editor.mark(this.markId) this.editor
.mark(this.markId)
this.editor.createShapes( .createShapes([
[
{ {
id, id,
type: 'note', type: 'note',
x: originPagePoint.x, x: originPagePoint.x,
y: originPagePoint.y, y: originPagePoint.y,
}, },
], ])
true .select(id)
)
const shape = this.editor.getShape<TLNoteShape>(id)! const shape = this.editor.getShape<TLNoteShape>(id)!
const bounds = this.editor.getGeometry(shape).bounds const bounds = this.editor.getGeometry(shape).bounds

View file

@ -250,7 +250,7 @@ function PatternFillDefForCanvas() {
const { defs, isReady } = usePattern() const { defs, isReady } = usePattern()
useEffect(() => { useEffect(() => {
if (isReady && editor.isSafari) { if (isReady && editor.environment.isSafari) {
const htmlLayer = findHtmlLayerParent(containerRef.current!) const htmlLayer = findHtmlLayerParent(containerRef.current!)
if (htmlLayer) { if (htmlLayer) {
// Wait for `patternContext` to be picked up // Wait for `patternContext` to be picked up

View file

@ -211,8 +211,8 @@ export function useEditableText<T extends Extract<TLShape, { props: { text: stri
(e: React.PointerEvent) => { (e: React.PointerEvent) => {
if (isEditableFromHover) { if (isEditableFromHover) {
transact(() => { transact(() => {
editor.setEditingId(id) editor.setEditingShapeId(id)
editor.setHoveredId(id) editor.setHoveredShapeId(id)
editor.setSelectedShapeIds([id]) editor.setSelectedShapeIds([id])
}) })
} }

View file

@ -13,7 +13,7 @@ afterEach(() => {
describe(TextShapeTool, () => { describe(TextShapeTool, () => {
it('Creates text, edits it, undoes and redoes', () => { it('Creates text, edits it, undoes and redoes', () => {
expect(editor.shapesOnCurrentPage.length).toBe(0) expect(editor.currentPageShapes.length).toBe(0)
editor.setCurrentTool('text') editor.setCurrentTool('text')
editor.expectToBeIn('text.idle') editor.expectToBeIn('text.idle')
editor.pointerDown(0, 0) editor.pointerDown(0, 0)
@ -22,28 +22,28 @@ describe(TextShapeTool, () => {
editor.expectToBeIn('select.editing_shape') editor.expectToBeIn('select.editing_shape')
// This comes from the component, not the state chart // This comes from the component, not the state chart
editor.updateShapes([ editor.updateShapes([
{ ...editor.shapesOnCurrentPage[0]!, type: 'text', props: { text: 'Hello' } }, { ...editor.currentPageShapes[0]!, type: 'text', props: { text: 'Hello' } },
]) ])
// Deselect the editing shape // Deselect the editing shape
editor.cancel() editor.cancel()
editor.expectToBeIn('select.idle') editor.expectToBeIn('select.idle')
expect(editor.shapesOnCurrentPage.length).toBe(1) expect(editor.currentPageShapes.length).toBe(1)
editor.expectShapeToMatch({ editor.expectShapeToMatch({
id: editor.shapesOnCurrentPage[0].id, id: editor.currentPageShapes[0].id,
type: 'text', type: 'text',
props: { text: 'Hello' }, props: { text: 'Hello' },
}) })
editor.undo() editor.undo()
expect(editor.shapesOnCurrentPage.length).toBe(0) expect(editor.currentPageShapes.length).toBe(0)
editor.redo() editor.redo()
expect(editor.shapesOnCurrentPage.length).toBe(1) expect(editor.currentPageShapes.length).toBe(1)
editor.expectShapeToMatch({ editor.expectShapeToMatch({
id: editor.shapesOnCurrentPage[0].id, id: editor.currentPageShapes[0].id,
type: 'text', type: 'text',
props: { text: 'Hello' }, props: { text: 'Hello' },
}) })
@ -71,7 +71,7 @@ describe('When in idle state', () => {
editor.pointerDown(0, 0) editor.pointerDown(0, 0)
editor.pointerUp() editor.pointerUp()
editor.expectToBeIn('select.editing_shape') editor.expectToBeIn('select.editing_shape')
expect(editor.shapesOnCurrentPage.length).toBe(1) expect(editor.currentPageShapes.length).toBe(1)
}) })
it('returns to select on cancel', () => { it('returns to select on cancel', () => {
@ -87,7 +87,7 @@ describe('When in the pointing state', () => {
editor.pointerDown(0, 0) editor.pointerDown(0, 0)
editor.cancel() editor.cancel()
editor.expectToBeIn('text.idle') editor.expectToBeIn('text.idle')
expect(editor.shapesOnCurrentPage.length).toBe(0) expect(editor.currentPageShapes.length).toBe(0)
}) })
it('returns to idle on interrupt', () => { it('returns to idle on interrupt', () => {
@ -96,7 +96,7 @@ describe('When in the pointing state', () => {
editor.expectToBeIn('text.pointing') editor.expectToBeIn('text.pointing')
editor.interrupt() editor.interrupt()
editor.expectToBeIn('text.idle') 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', () => { 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.pointerMove(10, 10)
editor.expectToBeIn('select.resizing') editor.expectToBeIn('select.resizing')
editor.pointerUp() editor.pointerUp()
expect(editor.shapesOnCurrentPage.length).toBe(1) expect(editor.currentPageShapes.length).toBe(1)
editor.expectToBeIn('select.editing_shape') editor.expectToBeIn('select.editing_shape')
}) })
@ -115,8 +115,8 @@ describe('When in the pointing state', () => {
const y = 0 const y = 0
editor.pointerDown(x, y) editor.pointerDown(x, y)
editor.pointerUp() editor.pointerUp()
const bounds = editor.getPageBounds(editor.shapesOnCurrentPage[0])! const bounds = editor.getPageBounds(editor.currentPageShapes[0])!
expect(editor.shapesOnCurrentPage[0]).toMatchObject({ expect(editor.currentPageShapes[0]).toMatchObject({
x: x - bounds.width / 2, x: x - bounds.width / 2,
y: y - bounds.height / 2, y: y - bounds.height / 2,
}) })
@ -131,7 +131,7 @@ describe('When resizing', () => {
editor.expectToBeIn('select.resizing') editor.expectToBeIn('select.resizing')
editor.cancel() editor.cancel()
editor.expectToBeIn('text.idle') editor.expectToBeIn('text.idle')
expect(editor.shapesOnCurrentPage.length).toBe(0) expect(editor.currentPageShapes.length).toBe(0)
}) })
it('does not bails on interrupt while resizing', () => { it('does not bails on interrupt while resizing', () => {
@ -140,7 +140,7 @@ describe('When resizing', () => {
editor.pointerMove(100, 100) editor.pointerMove(100, 100)
editor.expectToBeIn('select.resizing') editor.expectToBeIn('select.resizing')
editor.interrupt() 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', () => { it('preserves the top left when the text has a fixed width', () => {
@ -149,7 +149,7 @@ describe('When resizing', () => {
const y = 0 const y = 0
editor.pointerDown(x, y) editor.pointerDown(x, y)
editor.pointerMove(x + 100, y + 100) editor.pointerMove(x + 100, y + 100)
expect(editor.shapesOnCurrentPage[0]).toMatchObject({ expect(editor.currentPageShapes[0]).toMatchObject({
x, x,
y, y,
}) })

View file

@ -23,7 +23,7 @@ export class Idle extends StateNode {
if (this.editor.isShapeOfType<TLTextShape>(hitShape, 'text')) { if (this.editor.isShapeOfType<TLTextShape>(hitShape, 'text')) {
requestAnimationFrame(() => { requestAnimationFrame(() => {
this.editor.setSelectedShapeIds([hitShape.id]) this.editor.setSelectedShapeIds([hitShape.id])
this.editor.setEditingId(hitShape.id) this.editor.setEditingShapeId(hitShape.id)
this.editor.setCurrentTool('select.editing_shape', { this.editor.setCurrentTool('select.editing_shape', {
...info, ...info,
target: 'shape', target: 'shape',
@ -38,7 +38,10 @@ export class Idle extends StateNode {
} }
override onEnter = () => { 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) => { override onKeyDown: TLEventHandlers['onKeyDown'] = (info) => {
@ -46,7 +49,7 @@ export class Idle extends StateNode {
const shape = this.editor.selectedShapes[0] const shape = this.editor.selectedShapes[0]
if (shape && this.editor.isShapeOfType<TLGeoShape>(shape, 'geo')) { if (shape && this.editor.isShapeOfType<TLGeoShape>(shape, 'geo')) {
this.editor.setCurrentTool('select') this.editor.setCurrentTool('select')
this.editor.setEditingId(shape.id) this.editor.setEditingShapeId(shape.id)
this.editor.root.current.value!.transition('editing_shape', { this.editor.root.current.value!.transition('editing_shape', {
...info, ...info,
target: 'shape', target: 'shape',

View file

@ -8,7 +8,7 @@ export class Pointing extends StateNode {
markId = '' markId = ''
override onExit = () => { override onExit = () => {
this.editor.setHintingIds([]) this.editor.setHintingShapeIds([])
} }
override onPointerMove: TLEventHandlers['onPointerMove'] = (info) => { override onPointerMove: TLEventHandlers['onPointerMove'] = (info) => {
@ -19,23 +19,23 @@ export class Pointing extends StateNode {
const id = createShapeId() const id = createShapeId()
this.markId = this.editor.mark(`creating:${id}`) this.markId = `creating:${id}`
this.editor
this.editor.createShapes<TLTextShape>([ .mark(this.markId)
{ .createShapes<TLTextShape>([
id, {
type: 'text', id,
x: originPagePoint.x, type: 'text',
y: originPagePoint.y, x: originPagePoint.x,
props: { y: originPagePoint.y,
text: '', props: {
autoSize: false, text: '',
w: 20, autoSize: false,
w: 20,
},
}, },
}, ])
]) .select(id)
this.editor.select(id)
this.shape = this.editor.getShape(id) this.shape = this.editor.getShape(id)
if (!this.shape) return if (!this.shape) return
@ -72,8 +72,8 @@ export class Pointing extends StateNode {
this.editor.mark('creating text shape') this.editor.mark('creating text shape')
const id = createShapeId() const id = createShapeId()
const { x, y } = this.editor.inputs.currentPagePoint const { x, y } = this.editor.inputs.currentPagePoint
this.editor.createShapes( this.editor
[ .createShapes([
{ {
id, id,
type: 'text', type: 'text',
@ -84,11 +84,10 @@ export class Pointing extends StateNode {
autoSize: true, autoSize: true,
}, },
}, },
], ])
true .select(id)
)
this.editor.setEditingId(id) this.editor.setEditingShapeId(id)
this.editor.setCurrentTool('select') this.editor.setCurrentTool('select')
this.editor.root.current.value?.transition('editing_shape', {}) this.editor.root.current.value?.transition('editing_shape', {})
} }

View file

@ -10,6 +10,9 @@ export class EraserTool extends StateNode {
static override children = () => [Idle, Pointing, Erasing] static override children = () => [Idle, Pointing, Erasing]
override onEnter = () => { override onEnter = () => {
this.editor.updateInstanceState({ cursor: { type: 'cross', rotation: 0 } }, true) this.editor.updateInstanceState(
{ cursor: { type: 'cross', rotation: 0 } },
{ ephemeral: true, squashing: true }
)
} }
} }

View file

@ -20,12 +20,14 @@ export class Erasing extends StateNode {
private excludedShapeIds = new Set<TLShapeId>() private excludedShapeIds = new Set<TLShapeId>()
override onEnter = (info: TLPointerEventInfo) => { override onEnter = (info: TLPointerEventInfo) => {
this.markId = this.editor.mark('erase scribble begin')
this.info = info this.info = info
this.markId = 'erase scribble begin'
this.editor.mark(this.markId)
const { originPagePoint } = this.editor.inputs const { originPagePoint } = this.editor.inputs
this.excludedShapeIds = new Set( this.excludedShapeIds = new Set(
this.editor.shapesOnCurrentPage this.editor.currentPageShapes
.filter( .filter(
(shape) => (shape) =>
this.editor.isShapeOrAncestorLocked(shape) || this.editor.isShapeOrAncestorLocked(shape) ||
@ -95,8 +97,8 @@ export class Erasing extends StateNode {
update() { update() {
const { const {
zoomLevel, zoomLevel,
shapesOnCurrentPage, currentPageShapes,
erasingShapeIdsSet, erasingShapeIds,
inputs: { currentPagePoint, previousPagePoint }, inputs: { currentPagePoint, previousPagePoint },
} = this.editor } = this.editor
@ -104,9 +106,9 @@ export class Erasing extends StateNode {
this.pushPointToScribble() this.pushPointToScribble()
const erasing = new Set<TLShapeId>(erasingShapeIdsSet) const erasing = new Set<TLShapeId>(erasingShapeIds)
for (const shape of shapesOnCurrentPage) { for (const shape of currentPageShapes) {
if (this.editor.isShapeOfType<TLGroupShape>(shape, 'group')) continue if (this.editor.isShapeOfType<TLGroupShape>(shape, 'group')) continue
// Avoid testing masked shapes, unless the pointer is inside the mask // 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 // 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 // (these excluded shapes will be any frames or groups the pointer was inside of
// when the user started erasing) // when the user started erasing)
this.editor.setErasingIds([...erasing].filter((id) => !excludedShapeIds.has(id))) this.editor.setErasingShapeIds([...erasing].filter((id) => !excludedShapeIds.has(id)))
} }
complete() { complete() {
this.editor.deleteShapes(this.editor.currentPageState.erasingShapeIds) this.editor.deleteShapes(this.editor.currentPageState.erasingShapeIds)
this.editor.setErasingIds([]) this.editor.setErasingShapeIds([])
this.parent.transition('idle', {}) this.parent.transition('idle', {})
} }
cancel() { cancel() {
this.editor.setErasingIds([]) this.editor.setErasingShapeIds([])
this.editor.bailToMark(this.markId) this.editor.bailToMark(this.markId)
this.parent.transition('idle', this.info) this.parent.transition('idle', this.info)
} }

View file

@ -13,7 +13,7 @@ export class Pointing extends StateNode {
override onEnter = () => { override onEnter = () => {
const { const {
inputs: { currentPagePoint }, inputs: { currentPagePoint },
sortedShapesOnCurrentPage, currentPageShapesSorted,
zoomLevel, zoomLevel,
} = this.editor } = this.editor
@ -21,8 +21,8 @@ export class Pointing extends StateNode {
const initialSize = erasing.size const initialSize = erasing.size
for (let n = sortedShapesOnCurrentPage.length, i = n - 1; i >= 0; i--) { for (let n = currentPageShapesSorted.length, i = n - 1; i >= 0; i--) {
const shape = sortedShapesOnCurrentPage[i] const shape = currentPageShapesSorted[i]
if (this.editor.isShapeOfType<TLGroupShape>(shape, 'group')) { if (this.editor.isShapeOfType<TLGroupShape>(shape, 'group')) {
continue continue
} }
@ -46,7 +46,7 @@ export class Pointing extends StateNode {
} }
} }
this.editor.setErasingIds([...erasing]) this.editor.setErasingShapeIds([...erasing])
} }
override onPointerMove: TLEventHandlers['onPointerMove'] = (info) => { override onPointerMove: TLEventHandlers['onPointerMove'] = (info) => {
@ -79,12 +79,12 @@ export class Pointing extends StateNode {
this.editor.deleteShapes(erasingShapeIds) this.editor.deleteShapes(erasingShapeIds)
} }
this.editor.setErasingIds([]) this.editor.setErasingShapeIds([])
this.parent.transition('idle', {}) this.parent.transition('idle', {})
} }
cancel() { cancel() {
this.editor.setErasingIds([]) this.editor.setErasingShapeIds([])
this.parent.transition('idle', {}) this.parent.transition('idle', {})
} }
} }

View file

@ -29,7 +29,7 @@ export class Dragging extends StateNode {
const delta = Vec2d.Sub(currentScreenPoint, previousScreenPoint) const delta = Vec2d.Sub(currentScreenPoint, previousScreenPoint)
if (Math.abs(delta.x) > 0 || Math.abs(delta.y) > 0) { if (Math.abs(delta.x) > 0 || Math.abs(delta.y) > 0) {
this.editor.pan(delta.x, delta.y) this.editor.pan(delta)
} }
} }

View file

@ -4,7 +4,10 @@ export class Idle extends StateNode {
static override id = 'idle' static override id = 'idle'
override onEnter = () => { 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) => { override onPointerDown: TLEventHandlers['onPointerDown'] = (info) => {

View file

@ -5,7 +5,10 @@ export class Pointing extends StateNode {
override onEnter = () => { override onEnter = () => {
this.editor.stopCameraAnimation() 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) => { override onPointerMove: TLEventHandlers['onPointerMove'] = (info) => {

View file

@ -9,6 +9,9 @@ export class LaserTool extends StateNode {
static override children = () => [Idle, Lasering] static override children = () => [Idle, Lasering]
override onEnter = () => { override onEnter = () => {
this.editor.updateInstanceState({ cursor: { type: 'cross', rotation: 0 } }, true) this.editor.updateInstanceState(
{ cursor: { type: 'cross', rotation: 0 } },
{ ephemeral: true, squashing: true }
)
} }
} }

View file

@ -72,11 +72,11 @@ export class DragAndDropManager {
.onDragShapesOver?.(nextDroppingShape, movingShapes) .onDragShapesOver?.(nextDroppingShape, movingShapes)
if (res && res.shouldHint) { if (res && res.shouldHint) {
this.editor.setHintingIds([nextDroppingShape.id]) this.editor.setHintingShapeIds([nextDroppingShape.id])
} }
} else { } else {
// If we're dropping onto the page, then clear hinting ids // If we're dropping onto the page, then clear hinting ids
this.editor.setHintingIds([]) this.editor.setHintingShapeIds([])
} }
cb?.() cb?.()
@ -103,7 +103,7 @@ export class DragAndDropManager {
} }
this.droppingNodeTimer = null this.droppingNodeTimer = null
this.editor.setHintingIds([]) this.editor.setHintingShapeIds([])
} }
dispose = () => { dispose = () => {

View file

@ -43,7 +43,7 @@ export class SelectTool extends StateNode {
override onExit = () => { override onExit = () => {
if (this.editor.currentPageState.editingShapeId) { if (this.editor.currentPageState.editingShapeId) {
this.editor.setEditingId(null) this.editor.setEditingShapeId(null)
} }
} }
} }

View file

@ -39,7 +39,7 @@ export class Brushing extends StateNode {
} }
this.excludedShapeIds = new Set( this.excludedShapeIds = new Set(
this.editor.shapesOnCurrentPage this.editor.currentPageShapes
.filter( .filter(
(shape) => (shape) =>
this.editor.isShapeOfType<TLGroupShape>(shape, 'group') || this.editor.isShapeOfType<TLGroupShape>(shape, 'group') ||
@ -96,7 +96,7 @@ export class Brushing extends StateNode {
const { const {
zoomLevel, zoomLevel,
currentPageId, currentPageId,
shapesOnCurrentPage, currentPageShapes,
inputs: { originPagePoint, currentPagePoint, shiftKey, ctrlKey }, inputs: { originPagePoint, currentPagePoint, shiftKey, ctrlKey },
} = this.editor } = this.editor
@ -118,8 +118,8 @@ export class Brushing extends StateNode {
const { excludedShapeIds } = this const { excludedShapeIds } = this
testAllShapes: for (let i = 0, n = shapesOnCurrentPage.length; i < n; i++) { testAllShapes: for (let i = 0, n = currentPageShapes.length; i < n; i++) {
shape = shapesOnCurrentPage[i] shape = currentPageShapes[i]
if (excludedShapeIds.has(shape.id)) continue testAllShapes if (excludedShapeIds.has(shape.id)) continue testAllShapes
if (results.has(shape.id)) continue testAllShapes if (results.has(shape.id)) continue testAllShapes

View file

@ -5,7 +5,10 @@ export class Idle extends StateNode {
static override id = 'idle' static override id = 'idle'
override onEnter = () => { 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 const { onlySelectedShape } = this.editor
@ -14,21 +17,22 @@ export class Idle extends StateNode {
// (which clears the cropping id) but still remain in this state. // (which clears the cropping id) but still remain in this state.
this.editor.on('change-history', this.cleanupCroppingState) this.editor.on('change-history', this.cleanupCroppingState)
this.editor.mark('crop')
if (onlySelectedShape) { if (onlySelectedShape) {
this.editor.setCroppingId(onlySelectedShape.id) this.editor.setCroppingShapeId(onlySelectedShape.id)
} }
} }
override onExit: TLExitEventHandler = () => { 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) this.editor.off('change-history', this.cleanupCroppingState)
} }
override onCancel: TLEventHandlers['onCancel'] = () => { override onCancel: TLEventHandlers['onCancel'] = () => {
this.editor.setCroppingId(null) this.editor.setCroppingShapeId(null)
this.editor.setCurrentTool('select.idle', {}) this.editor.setCurrentTool('select.idle', {})
} }
@ -36,7 +40,7 @@ export class Idle extends StateNode {
if (this.editor.isMenuOpen) return if (this.editor.isMenuOpen) return
if (info.ctrlKey) { if (info.ctrlKey) {
this.editor.setCroppingId(null) this.editor.setCroppingShapeId(null)
this.editor.setCurrentTool('select.brushing', info) this.editor.setCurrentTool('select.brushing', info)
return return
} }
@ -66,7 +70,7 @@ export class Idle extends StateNode {
return return
} else { } else {
if (this.editor.getShapeUtil(info.shape)?.canCrop(info.shape)) { 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.setSelectedShapeIds([info.shape.id])
this.editor.setCurrentTool('select.crop.pointing_crop', info) this.editor.setCurrentTool('select.crop.pointing_crop', info)
} else { } else {
@ -145,7 +149,7 @@ export class Idle extends StateNode {
override onKeyUp: TLEventHandlers['onKeyUp'] = (info) => { override onKeyUp: TLEventHandlers['onKeyUp'] = (info) => {
switch (info.code) { switch (info.code) {
case 'Enter': { case 'Enter': {
this.editor.setCroppingId(null) this.editor.setCroppingShapeId(null)
this.editor.setCurrentTool('select.idle', {}) this.editor.setCurrentTool('select.idle', {})
break break
} }
@ -153,7 +157,7 @@ export class Idle extends StateNode {
} }
private cancel() { private cancel() {
this.editor.setCroppingId(null) this.editor.setCroppingShapeId(null)
this.editor.setCurrentTool('select.idle', {}) this.editor.setCurrentTool('select.idle', {})
} }

View file

@ -25,14 +25,20 @@ export class TranslatingCrop extends StateNode {
) => { ) => {
this.info = info this.info = info
this.snapshot = this.createSnapshot() this.snapshot = this.createSnapshot()
this.editor.mark(this.markId) 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() this.updateShapes()
} }
override onExit = () => { 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 = () => { override onPointerMove = () => {
@ -99,7 +105,7 @@ export class TranslatingCrop extends StateNode {
const partial = getTranslateCroppedImageChange(this.editor, shape, delta) const partial = getTranslateCroppedImageChange(this.editor, shape, delta)
if (partial) { if (partial) {
this.editor.updateShapes([partial], true) this.editor.updateShapes([partial], { squashing: true })
} }
} }
} }

View file

@ -37,7 +37,8 @@ export class Cropping extends StateNode {
} }
) => { ) => {
this.info = info this.info = info
this.markId = this.editor.mark('cropping') this.markId = 'cropping'
this.editor.mark(this.markId)
this.snapshot = this.createSnapshot() this.snapshot = this.createSnapshot()
this.updateShapes() this.updateShapes()
} }
@ -199,7 +200,7 @@ export class Cropping extends StateNode {
}, },
} }
this.editor.updateShapes([partial], true) this.editor.updateShapes([partial], { squashing: true })
this.updateCursor() this.updateCursor()
} }
@ -207,7 +208,7 @@ export class Cropping extends StateNode {
if (this.info.onInteractionEnd) { if (this.info.onInteractionEnd) {
this.editor.setCurrentTool(this.info.onInteractionEnd, this.info) this.editor.setCurrentTool(this.info.onInteractionEnd, this.info)
} else { } else {
this.editor.setCroppingId(null) this.editor.setCroppingShapeId(null)
this.parent.transition('idle', {}) this.parent.transition('idle', {})
} }
} }
@ -217,7 +218,7 @@ export class Cropping extends StateNode {
if (this.info.onInteractionEnd) { if (this.info.onInteractionEnd) {
this.editor.setCurrentTool(this.info.onInteractionEnd, this.info) this.editor.setCurrentTool(this.info.onInteractionEnd, this.info)
} else { } else {
this.editor.setCroppingId(null) this.editor.setCroppingShapeId(null)
this.parent.transition('idle', {}) this.parent.transition('idle', {})
} }
} }

View file

@ -52,7 +52,8 @@ export class DraggingHandle extends StateNode {
this.info = info this.info = info
this.parent.currentToolIdMask = info.onInteractionEnd this.parent.currentToolIdMask = info.onInteractionEnd
this.shapeId = shape.id 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.initialHandle = deepCopy(handle)
this.initialPageTransform = this.editor.getPageTransform(shape)! this.initialPageTransform = this.editor.getPageTransform(shape)!
this.initialPageRotation = this.initialPageTransform.rotation() this.initialPageRotation = this.initialPageTransform.rotation()
@ -60,7 +61,7 @@ export class DraggingHandle extends StateNode {
this.editor.updateInstanceState( this.editor.updateInstanceState(
{ cursor: { type: isCreating ? 'cross' : 'grabbing', rotation: 0 } }, { cursor: { type: isCreating ? 'cross' : 'grabbing', rotation: 0 } },
true { ephemeral: true, squashing: true }
) )
// <!-- Only relevant to arrows // <!-- Only relevant to arrows
@ -95,7 +96,7 @@ export class DraggingHandle extends StateNode {
this.isPrecise = false this.isPrecise = false
if (initialTerminal?.type === 'binding') { if (initialTerminal?.type === 'binding') {
this.editor.setHintingIds([initialTerminal.boundShapeId]) this.editor.setHintingShapeIds([initialTerminal.boundShapeId])
this.isPrecise = !Vec2d.Equals(initialTerminal.normalizedAnchor, { x: 0.5, y: 0.5 }) this.isPrecise = !Vec2d.Equals(initialTerminal.normalizedAnchor, { x: 0.5, y: 0.5 })
if (this.isPrecise) { if (this.isPrecise) {
@ -104,7 +105,7 @@ export class DraggingHandle extends StateNode {
this.resetExactTimeout() this.resetExactTimeout()
} }
} else { } else {
this.editor.setHintingIds([]) this.editor.setHintingShapeIds([])
} }
// --> // -->
@ -166,9 +167,12 @@ export class DraggingHandle extends StateNode {
override onExit = () => { override onExit = () => {
this.parent.currentToolIdMask = undefined this.parent.currentToolIdMask = undefined
this.editor.setHintingIds([]) this.editor.setHintingShapeIds([])
this.editor.snaps.clear() 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() { private complete() {
@ -280,7 +284,7 @@ export class DraggingHandle extends StateNode {
if (bindingAfter?.type === 'binding') { if (bindingAfter?.type === 'binding') {
if (hintingShapeIds[0] !== bindingAfter.boundShapeId) { if (hintingShapeIds[0] !== bindingAfter.boundShapeId) {
editor.setHintingIds([bindingAfter.boundShapeId]) editor.setHintingShapeIds([bindingAfter.boundShapeId])
this.pointingId = bindingAfter.boundShapeId this.pointingId = bindingAfter.boundShapeId
this.isPrecise = pointerVelocity.len() < 0.5 || altKey this.isPrecise = pointerVelocity.len() < 0.5 || altKey
this.isPreciseId = this.isPrecise ? bindingAfter.boundShapeId : null this.isPreciseId = this.isPrecise ? bindingAfter.boundShapeId : null
@ -288,7 +292,7 @@ export class DraggingHandle extends StateNode {
} }
} else { } else {
if (hintingShapeIds.length > 0) { if (hintingShapeIds.length > 0) {
editor.setHintingIds([]) editor.setHintingShapeIds([])
this.pointingId = null this.pointingId = null
this.isPrecise = false this.isPrecise = false
this.isPreciseId = null this.isPreciseId = null
@ -298,7 +302,7 @@ export class DraggingHandle extends StateNode {
} }
if (changes) { if (changes) {
editor.updateShapes([next], true) editor.updateShapes([next], { squashing: false, ephemeral: false })
} }
} }
} }

View file

@ -19,7 +19,7 @@ export class EditingShape extends StateNode {
if (!editingShapeId) return if (!editingShapeId) return
// Clear the editing shape // Clear the editing shape
this.editor.setEditingId(null) this.editor.setEditingShapeId(null)
const shape = this.editor.getShape(editingShapeId)! const shape = this.editor.getShape(editingShapeId)!
const util = this.editor.getShapeUtil(shape) const util = this.editor.getShapeUtil(shape)

View file

@ -23,7 +23,10 @@ export class Idle extends StateNode {
override onEnter = () => { override onEnter = () => {
this.parent.currentToolIdMask = undefined 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'] = () => { override onPointerMove: TLEventHandlers['onPointerMove'] = () => {
@ -438,7 +441,7 @@ export class Idle extends StateNode {
private startEditingShape(shape: TLShape, info: TLClickEventInfo | TLKeyboardEventInfo) { private startEditingShape(shape: TLShape, info: TLClickEventInfo | TLKeyboardEventInfo) {
if (this.editor.isShapeOrAncestorLocked(shape) && shape.type !== 'embed') return if (this.editor.isShapeOrAncestorLocked(shape) && shape.type !== 'embed') return
this.editor.mark('editing shape') this.editor.mark('editing shape')
this.editor.setEditingId(shape.id) this.editor.setEditingShapeId(shape.id)
this.parent.transition('editing_shape', info) this.parent.transition('editing_shape', info)
} }
@ -467,7 +470,7 @@ export class Idle extends StateNode {
const shape = this.editor.getShape(id) const shape = this.editor.getShape(id)
if (!shape) return if (!shape) return
this.editor.setEditingId(id) this.editor.setEditingShapeId(id)
this.editor.select(id) this.editor.select(id)
this.parent.transition('editing_shape', info) this.parent.transition('editing_shape', info)
} }

View file

@ -29,11 +29,14 @@ export class PointingCropHandle extends StateNode {
if (!selectedShape) return if (!selectedShape) return
this.updateCursor(selectedShape) this.updateCursor(selectedShape)
this.editor.setCroppingId(selectedShape.id) this.editor.setCroppingShapeId(selectedShape.id)
} }
override onExit = () => { 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 this.parent.currentToolIdMask = undefined
} }
@ -52,7 +55,7 @@ export class PointingCropHandle extends StateNode {
if (this.info.onInteractionEnd) { if (this.info.onInteractionEnd) {
this.editor.setCurrentTool(this.info.onInteractionEnd, this.info) this.editor.setCurrentTool(this.info.onInteractionEnd, this.info)
} else { } else {
this.editor.setCroppingId(null) this.editor.setCroppingShapeId(null)
this.parent.transition('idle', {}) this.parent.transition('idle', {})
} }
} }
@ -73,7 +76,7 @@ export class PointingCropHandle extends StateNode {
if (this.info.onInteractionEnd) { if (this.info.onInteractionEnd) {
this.editor.setCurrentTool(this.info.onInteractionEnd, this.info) this.editor.setCurrentTool(this.info.onInteractionEnd, this.info)
} else { } else {
this.editor.setCroppingId(null) this.editor.setCroppingShapeId(null)
this.parent.transition('idle', {}) this.parent.transition('idle', {})
} }
} }

View file

@ -11,15 +11,21 @@ export class PointingHandle extends StateNode {
const initialTerminal = (info.shape as TLArrowShape).props[info.handle.id as 'start' | 'end'] const initialTerminal = (info.shape as TLArrowShape).props[info.handle.id as 'start' | 'end']
if (initialTerminal?.type === 'binding') { 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 = () => { override onExit = () => {
this.editor.setHintingIds([]) this.editor.setHintingShapeIds([])
this.editor.updateInstanceState({ cursor: { type: 'default', rotation: 0 } }, true) this.editor.updateInstanceState(
{ cursor: { type: 'default', rotation: 0 } },
{ ephemeral: true, squashing: true }
)
} }
override onPointerUp: TLEventHandlers['onPointerUp'] = () => { override onPointerUp: TLEventHandlers['onPointerUp'] = () => {

View file

@ -28,7 +28,10 @@ export class PointingRotateHandle extends StateNode {
override onExit = () => { override onExit = () => {
this.parent.currentToolIdMask = undefined 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 = () => { override onPointerMove = () => {

View file

@ -56,13 +56,16 @@ export class Resizing extends StateNode {
this.creationCursorOffset = creationCursorOffset this.creationCursorOffset = creationCursorOffset
if (info.isCreating) { 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.snapshot = this._createSnapshot()
this.markId = isCreating
? `creating:${this.editor.onlySelectedShape!.id}` this.markId = isCreating ? `creating:${this.editor.onlySelectedShape!.id}` : 'starting resizing'
: this.editor.mark('starting resizing') if (!isCreating) this.editor.mark(this.markId)
this.handleResizeStart() this.handleResizeStart()
this.updateShapes() this.updateShapes()
@ -105,7 +108,7 @@ export class Resizing extends StateNode {
this.handleResizeEnd() this.handleResizeEnd()
if (this.editAfterComplete && this.editor.onlySelectedShape) { 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.setCurrentTool('select')
this.editor.root.current.value!.transition('editing_shape', {}) this.editor.root.current.value!.transition('editing_shape', {})
return return
@ -349,12 +352,15 @@ export class Resizing extends StateNode {
nextCursor.rotation = rotation nextCursor.rotation = rotation
this.editor.updateInstanceState({ cursor: nextCursor }) this.editor.updateInstanceState({ cursor: nextCursor }, { ephemeral: true, squashing: true })
} }
override onExit = () => { override onExit = () => {
this.parent.currentToolIdMask = undefined 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() this.editor.snaps.clear()
} }

View file

@ -29,7 +29,8 @@ export class Rotating extends StateNode {
this.info = info this.info = info
this.parent.currentToolIdMask = info.onInteractionEnd 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 }) const snapshot = getRotationSnapshot({ editor: this.editor })
if (!snapshot) return this.parent.transition('idle', this.info) if (!snapshot) return this.parent.transition('idle', this.info)
@ -40,7 +41,10 @@ export class Rotating extends StateNode {
} }
override onExit = () => { 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.parent.currentToolIdMask = undefined
this.snapshot = {} as TLRotationSnapshot this.snapshot = {} as TLRotationSnapshot

View file

@ -108,7 +108,7 @@ export class ScribbleBrushing extends StateNode {
private updateScribbleSelection(addPoint: boolean) { private updateScribbleSelection(addPoint: boolean) {
const { const {
zoomLevel, zoomLevel,
shapesOnCurrentPage, currentPageShapes,
inputs: { shiftKey, originPagePoint, previousPagePoint, currentPagePoint }, inputs: { shiftKey, originPagePoint, previousPagePoint, currentPagePoint },
} = this.editor } = this.editor
@ -118,7 +118,7 @@ export class ScribbleBrushing extends StateNode {
this.pushPointToScribble() this.pushPointToScribble()
} }
const shapes = shapesOnCurrentPage const shapes = currentPageShapes
let shape: TLShape, geometry: Geometry2d, A: Vec2d, B: Vec2d let shape: TLShape, geometry: Geometry2d, A: Vec2d, B: Vec2d
for (let i = 0, n = shapes.length; i < n; i++) { for (let i = 0, n = shapes.length; i < n; i++) {

View file

@ -31,6 +31,7 @@ export class Translating extends StateNode {
snapshot: TranslatingSnapshot = {} as any snapshot: TranslatingSnapshot = {} as any
markId = '' markId = ''
initialMarkId = ''
isCloning = false isCloning = false
isCreating = false isCreating = false
@ -53,9 +54,12 @@ export class Translating extends StateNode {
this.isCreating = isCreating this.isCreating = isCreating
this.editAfterComplete = editAfterComplete this.editAfterComplete = editAfterComplete
this.markId = isCreating this.initialMarkId = isCreating
? this.editor.mark(`creating:${this.editor.onlySelectedShape!.id}`) ? `creating:${this.editor.onlySelectedShape!.id}`
: this.editor.mark('translating') : 'translating'
this.markId = this.initialMarkId
if (!isCreating) this.editor.mark(this.markId)
this.handleEnter(info) this.handleEnter(info)
this.editor.on('tick', this.updateParent) this.editor.on('tick', this.updateParent)
} }
@ -66,7 +70,10 @@ export class Translating extends StateNode {
this.selectionSnapshot = {} as any this.selectionSnapshot = {} as any
this.snapshot = {} as any this.snapshot = {} as any
this.editor.snaps.clear() 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() this.dragAndDropManager.clear()
} }
@ -111,7 +118,9 @@ export class Translating extends StateNode {
this.isCloning = true this.isCloning = true
this.reset() this.reset()
this.markId = this.editor.mark('translating')
this.markId = 'cloning'
this.editor.mark(this.markId)
this.editor.duplicateShapes(Array.from(this.editor.selectedShapeIds)) this.editor.duplicateShapes(Array.from(this.editor.selectedShapeIds))
@ -124,7 +133,10 @@ export class Translating extends StateNode {
this.isCloning = false this.isCloning = false
this.snapshot = this.selectionSnapshot this.snapshot = this.selectionSnapshot
this.reset() this.reset()
this.markId = this.editor.mark('translating')
this.markId = this.initialMarkId
this.editor.mark(this.markId)
this.updateShapes() this.updateShapes()
} }
@ -148,7 +160,7 @@ export class Translating extends StateNode {
if (this.editAfterComplete) { if (this.editAfterComplete) {
const onlySelected = this.editor.onlySelectedShape const onlySelected = this.editor.onlySelectedShape
if (onlySelected) { if (onlySelected) {
this.editor.setEditingId(onlySelected.id) this.editor.setEditingShapeId(onlySelected.id)
this.editor.setCurrentTool('select') this.editor.setCurrentTool('select')
this.editor.root.current.value!.transition('editing_shape', {}) this.editor.root.current.value!.transition('editing_shape', {})
} }
@ -171,7 +183,10 @@ export class Translating extends StateNode {
this.isCloning = false this.isCloning = false
this.info = info 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) this.selectionSnapshot = getTranslatingSnapshot(this.editor)
// Don't clone on create; otherwise clone on altKey // Don't clone on create; otherwise clone on altKey
@ -201,7 +216,7 @@ export class Translating extends StateNode {
}) })
if (changes.length > 0) { if (changes.length > 0) {
this.editor.updateShapes(changes) this.editor.updateShapes(changes, { squashing: true })
} }
} }
@ -406,6 +421,6 @@ export function moveShapesToPoint({
} }
}) })
), ),
true { squashing: true }
) )
} }

View file

@ -21,7 +21,7 @@ export class ZoomTool extends StateNode {
this.currentToolIdMask = undefined this.currentToolIdMask = undefined
this.editor.updateInstanceState( this.editor.updateInstanceState(
{ zoomBrush: null, cursor: { type: 'default', rotation: 0 } }, { zoomBrush: null, cursor: { type: 'default', rotation: 0 } },
true { ephemeral: true, squashing: true }
) )
this.currentToolIdMask = undefined this.currentToolIdMask = undefined
} }
@ -53,9 +53,15 @@ export class ZoomTool extends StateNode {
private updateCursor() { private updateCursor() {
if (this.editor.inputs.altKey) { 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 { } else {
this.editor.updateInstanceState({ cursor: { type: 'zoom-in', rotation: 0 } }, true) this.editor.updateInstanceState(
{ cursor: { type: 'zoom-in', rotation: 0 } },
{ ephemeral: true, squashing: true }
)
} }
} }
} }

View file

@ -54,14 +54,7 @@ export class ZoomBrushing extends StateNode {
} }
} else { } else {
const zoomLevel = this.editor.inputs.altKey ? this.editor.zoomLevel / 2 : undefined const zoomLevel = this.editor.inputs.altKey ? this.editor.zoomLevel / 2 : undefined
this.editor.zoomToBounds( this.editor.zoomToBounds(zoomBrush, zoomLevel, { duration: 220 })
zoomBrush.x,
zoomBrush.y,
zoomBrush.width,
zoomBrush.height,
zoomLevel,
{ duration: 220 }
)
} }
this.parent.transition('idle', this.info) this.parent.transition('idle', this.info)

View file

@ -7,7 +7,7 @@ export function updateHoveredId(editor: Editor) {
margin: HIT_TEST_MARGIN / editor.zoomLevel, margin: HIT_TEST_MARGIN / editor.zoomLevel,
}) })
if (!hitShape) return editor.setHoveredId(null) if (!hitShape) return editor.setHoveredShapeId(null)
let shapeToHover: TLShape | undefined = undefined let shapeToHover: TLShape | undefined = undefined
@ -26,5 +26,5 @@ export function updateHoveredId(editor: Editor) {
} }
} }
return editor.setHoveredId(shapeToHover.id) return editor.setHoveredShapeId(shapeToHover.id)
} }

View file

@ -22,8 +22,7 @@ export function BackToContent() {
// viewport... so we also need to narrow down the list to only shapes that // viewport... so we also need to narrow down the list to only shapes that
// are ALSO in the viewport. // are ALSO in the viewport.
const visibleShapes = renderingShapes.filter((s) => s.isInViewport) const visibleShapes = renderingShapes.filter((s) => s.isInViewport)
const showBackToContentNow = const showBackToContentNow = visibleShapes.length === 0 && editor.currentPageShapes.length > 0
visibleShapes.length === 0 && editor.shapesOnCurrentPage.length > 0
if (showBackToContentPrev !== showBackToContentNow) { if (showBackToContentPrev !== showBackToContentNow) {
setShowBackToContent(showBackToContentNow) setShowBackToContent(showBackToContentNow)

View file

@ -7,7 +7,7 @@ export const HTMLCanvas = track(function HTMLCanvas() {
const rCanvas = React.useRef<HTMLCanvasElement>(null) const rCanvas = React.useRef<HTMLCanvasElement>(null)
const camera = editor.camera const camera = editor.camera
const shapes = editor.shapesOnCurrentPage const shapes = editor.currentPageShapes
if (rCanvas.current) { if (rCanvas.current) {
const cvs = rCanvas.current const cvs = rCanvas.current
const ctx = cvs.getContext('2d')! const ctx = cvs.getContext('2d')!

View file

@ -54,16 +54,15 @@ export function Minimap({ shapeFill, selectFill, viewportFill }: MinimapProps) {
const onDoubleClick = React.useCallback( const onDoubleClick = React.useCallback(
(e: React.MouseEvent<HTMLCanvasElement>) => { (e: React.MouseEvent<HTMLCanvasElement>) => {
if (!editor.shapeIdsOnCurrentPage.size) return if (!editor.currentPageShapeIds.size) return
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 clampedPoint = minimap.minimapScreenPointToPagePoint(e.clientX, e.clientY, false, true)
minimap.originPagePoint.setTo(clampedPoint) minimap.originPagePoint.setTo(clampedPoint)
minimap.originPageCenter.setTo(editor.viewportPageBounds.center) minimap.originPageCenter.setTo(editor.viewportPageBounds.center)
editor.centerOnPoint(x, y, { duration: ANIMATION_MEDIUM_MS }) editor.centerOnPoint(point, { duration: ANIMATION_MEDIUM_MS })
}, },
[editor, minimap] [editor, minimap]
) )
@ -71,14 +70,13 @@ export function Minimap({ shapeFill, selectFill, viewportFill }: MinimapProps) {
const onPointerDown = React.useCallback( const onPointerDown = React.useCallback(
(e: React.PointerEvent<HTMLCanvasElement>) => { (e: React.PointerEvent<HTMLCanvasElement>) => {
setPointerCapture(e.currentTarget, e) setPointerCapture(e.currentTarget, e)
if (!editor.shapeIdsOnCurrentPage.size) return if (!editor.currentPageShapeIds.size) return
rPointing.current = true rPointing.current = true
minimap.isInViewport = false 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 clampedPoint = minimap.minimapScreenPointToPagePoint(e.clientX, e.clientY, false, true)
const _vpPageBounds = editor.viewportPageBounds const _vpPageBounds = editor.viewportPageBounds
@ -89,7 +87,7 @@ export function Minimap({ shapeFill, selectFill, viewportFill }: MinimapProps) {
minimap.isInViewport = _vpPageBounds.containsPoint(clampedPoint) minimap.isInViewport = _vpPageBounds.containsPoint(clampedPoint)
if (!minimap.isInViewport) { if (!minimap.isInViewport) {
editor.centerOnPoint(x, y, { duration: ANIMATION_MEDIUM_MS }) editor.centerOnPoint(point, { duration: ANIMATION_MEDIUM_MS })
} }
}, },
[editor, minimap] [editor, minimap]
@ -98,26 +96,21 @@ export function Minimap({ shapeFill, selectFill, viewportFill }: MinimapProps) {
const onPointerMove = React.useCallback( const onPointerMove = React.useCallback(
(e: React.PointerEvent<HTMLCanvasElement>) => { (e: React.PointerEvent<HTMLCanvasElement>) => {
if (rPointing.current) { if (rPointing.current) {
const { x, y } = minimap.minimapScreenPointToPagePoint( const point = minimap.minimapScreenPointToPagePoint(e.clientX, e.clientY, e.shiftKey, true)
e.clientX,
e.clientY,
e.shiftKey,
true
)
if (minimap.isInViewport) { 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) const center = Vec2d.Add(minimap.originPageCenter, delta)
editor.centerOnPoint(center.x, center.y) editor.centerOnPoint(center)
return return
} }
editor.centerOnPoint(x, y) editor.centerOnPoint(point)
} }
const pagePoint = minimap.getPagePoint(e.clientX, e.clientY) const pagePoint = minimap.getPagePoint(e.clientX, e.clientY)
const screenPoint = editor.pageToScreen(pagePoint.x, pagePoint.y) const screenPoint = editor.pageToScreen(pagePoint)
const info: TLPointerEventInfo = { const info: TLPointerEventInfo = {
type: 'pointer', type: 'pointer',
@ -178,9 +171,10 @@ export function Minimap({ shapeFill, selectFill, viewportFill }: MinimapProps) {
useQuickReactor( useQuickReactor(
'minimap render when pagebounds or collaborators changes', 'minimap render when pagebounds or collaborators changes',
() => { () => {
const { shapeIdsOnCurrentPage, viewportPageBounds, commonBoundsOfAllShapesOnCurrentPage } = const { currentPageShapeIds, viewportPageBounds, commonBoundsOfAllShapesOnCurrentPage } =
editor editor
// deref
const _dpr = devicePixelRatio.value const _dpr = devicePixelRatio.value
minimap.contentPageBounds = commonBoundsOfAllShapesOnCurrentPage minimap.contentPageBounds = commonBoundsOfAllShapesOnCurrentPage
@ -193,7 +187,7 @@ export function Minimap({ shapeFill, selectFill, viewportFill }: MinimapProps) {
const allShapeBounds = [] as (Box2d & { id: TLShapeId })[] const allShapeBounds = [] as (Box2d & { id: TLShapeId })[]
shapeIdsOnCurrentPage.forEach((id) => { currentPageShapeIds.forEach((id) => {
let pageBounds = editor.getPageBounds(id) as Box2d & { id: TLShapeId } let pageBounds = editor.getPageBounds(id) as Box2d & { id: TLShapeId }
if (!pageBounds) return if (!pageBounds) return
@ -217,7 +211,7 @@ export function Minimap({ shapeFill, selectFill, viewportFill }: MinimapProps) {
minimap.collaborators = presences.value minimap.collaborators = presences.value
minimap.render() minimap.render()
}, },
[editor, minimap] [editor, minimap, devicePixelRatio]
) )
return ( return (

View file

@ -12,7 +12,7 @@ export const ZoomMenu = track(function ZoomMenu() {
const breakpoint = useBreakpoint() const breakpoint = useBreakpoint()
const zoom = editor.zoomLevel const zoom = editor.zoomLevel
const hasShapes = editor.shapeIdsOnCurrentPage.size > 0 const hasShapes = editor.currentPageShapeIds.size > 0
const hasSelected = editor.selectedShapeIds.length > 0 const hasSelected = editor.selectedShapeIds.length > 0
const isZoomedTo100 = editor.zoomLevel === 1 const isZoomedTo100 = editor.zoomLevel === 1

View file

@ -17,7 +17,7 @@ export const PageItemInput = function PageItemInput({
const handleChange = useCallback( const handleChange = useCallback(
(value: string) => { (value: string) => {
editor.renamePage(id, value ? value : 'New Page', true) editor.updatePage({ id, name: value ? value : 'New Page' }, true)
}, },
[editor, id] [editor, id]
) )
@ -25,7 +25,7 @@ export const PageItemInput = function PageItemInput({
const handleComplete = useCallback( const handleComplete = useCallback(
(value: string) => { (value: string) => {
editor.mark('rename page') editor.mark('rename page')
editor.renamePage(id, value || 'New Page', false) editor.updatePage({ id, name: value || 'New Page' }, false)
}, },
[editor, id] [editor, id]
) )

View file

@ -326,7 +326,7 @@ export const PageMenu = function PageMenu() {
onClick={() => { onClick={() => {
const name = window.prompt('Rename page', page.name) const name = window.prompt('Rename page', page.name)
if (name && name !== page.name) { if (name && name !== page.name) {
editor.renamePage(page.id, name) editor.updatePage({ id: page.id, name }, true)
} }
}} }}
onDoubleClick={toggleEditing} onDoubleClick={toggleEditing}
@ -379,10 +379,10 @@ export const PageMenu = function PageMenu() {
item={page} item={page}
listSize={pages.length} listSize={pages.length}
onRename={() => { onRename={() => {
if (editor.isIos) { if (editor.environment.isIos) {
const name = window.prompt('Rename page', page.name) const name = window.prompt('Rename page', page.name)
if (name && name !== page.name) { if (name && name !== page.name) {
editor.renamePage(page.id, name) editor.updatePage({ id: page.id, name }, true)
} }
} else { } else {
setIsEditing(true) setIsEditing(true)

View file

@ -16,6 +16,7 @@ import {
SharedStyleMap, SharedStyleMap,
StyleProp, StyleProp,
minBy, minBy,
useComputed,
useEditor, useEditor,
useValue, useValue,
} from '@tldraw/editor' } from '@tldraw/editor'
@ -33,9 +34,7 @@ interface StylePanelProps {
} }
const selectToolStyles = [DefaultColorStyle, DefaultDashStyle, DefaultFillStyle, DefaultSizeStyle] const selectToolStyles = [DefaultColorStyle, DefaultDashStyle, DefaultFillStyle, DefaultSizeStyle]
function getRelevantStyles( function getRelevantStyles(editor: Editor): ReadonlySharedStyleMap | null {
editor: Editor
): { styles: ReadonlySharedStyleMap; opacity: SharedStyle<number> } | null {
const styles = new SharedStyleMap(editor.sharedStyles) const styles = new SharedStyleMap(editor.sharedStyles)
const hasShape = editor.selectedShapeIds.length > 0 || !!editor.root.current.value?.shapeType const hasShape = editor.selectedShapeIds.length > 0 || !!editor.root.current.value?.shapeType
@ -46,14 +45,38 @@ function getRelevantStyles(
} }
if (styles.size === 0 && !hasShape) return null if (styles.size === 0 && !hasShape) return null
return { styles, opacity: editor.sharedOpacity } return styles
} }
/** @internal */ /** @internal */
export const StylePanel = function StylePanel({ isMobile }: StylePanelProps) { export const StylePanel = function StylePanel({ isMobile }: StylePanelProps) {
const editor = useEditor() const editor = useEditor()
const sharedOpacity = useComputed<SharedStyle<number>>(
'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 relevantStyles = useValue('getRelevantStyles', () => getRelevantStyles(editor), [editor])
const opacity = useValue('opacity', () => sharedOpacity.value, [sharedOpacity])
const handlePointerOut = useCallback(() => { const handlePointerOut = useCallback(() => {
if (!isMobile) { if (!isMobile) {
@ -63,7 +86,7 @@ export const StylePanel = function StylePanel({ isMobile }: StylePanelProps) {
if (!relevantStyles) return null if (!relevantStyles) return null
const { styles, opacity } = relevantStyles const styles = relevantStyles
const geo = styles.get(GeoShapeGeoStyle) const geo = styles.get(GeoShapeGeoStyle)
const arrowheadEnd = styles.get(ArrowShapeArrowheadEndStyle) const arrowheadEnd = styles.get(ArrowShapeArrowheadEndStyle)
const arrowheadStart = styles.get(ArrowShapeArrowheadStartStyle) const arrowheadStart = styles.get(ArrowShapeArrowheadStartStyle)
@ -95,8 +118,8 @@ function useStyleChangeCallback() {
return React.useMemo(() => { return React.useMemo(() => {
return function <T>(style: StyleProp<T>, value: T, squashing: boolean) { return function <T>(style: StyleProp<T>, value: T, squashing: boolean) {
editor.setStyle(style, value, squashing) editor.setStyle(style, value, { ephemeral: false, squashing })
editor.updateInstanceState({ isChangingStyle: true }) editor.updateInstanceState({ isChangingStyle: true }, { ephemeral: true, squashing: true })
} }
}, [editor]) }, [editor])
} }
@ -118,8 +141,16 @@ function CommonStylePickerSet({
const handleOpacityValueChange = React.useCallback( const handleOpacityValueChange = React.useCallback(
(value: number, ephemeral: boolean) => { (value: number, ephemeral: boolean) => {
const item = tldrawSupportedOpacities[value] const item = tldrawSupportedOpacities[value]
editor.setOpacity(item, ephemeral) editor.batch(() => {
editor.updateInstanceState({ isChangingStyle: true }) 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] [editor]
) )

View file

@ -704,7 +704,7 @@ export function ActionsProvider({ overrides, children }: ActionsProviderProps) {
{ {
id: 'delete', id: 'delete',
label: 'action.delete', label: 'action.delete',
kbd: '⌫,del,backspace', kbd: '⌫,del', // removed backspace; it was firing twice on mac
icon: 'trash', icon: 'trash',
readonlyOk: false, readonlyOk: false,
onSelect(source) { onSelect(source) {
@ -838,10 +838,8 @@ export function ActionsProvider({ overrides, children }: ActionsProviderProps) {
onSelect(source) { onSelect(source) {
trackEvent('toggle-transparent', { source }) trackEvent('toggle-transparent', { source })
editor.updateInstanceState( editor.updateInstanceState(
{ { exportBackground: !editor.instanceState.exportBackground },
exportBackground: !editor.instanceState.exportBackground, { ephemeral: true, squashing: true }
},
true
) )
}, },
checkbox: true, checkbox: true,
@ -901,7 +899,7 @@ export function ActionsProvider({ overrides, children }: ActionsProviderProps) {
{ {
isDebugMode: !editor.instanceState.isDebugMode, isDebugMode: !editor.instanceState.isDebugMode,
}, },
true { ephemeral: true, squashing: true }
) )
}, },
checkbox: true, checkbox: true,

View file

@ -93,7 +93,7 @@ export const TLUiContextMenuSchemaProvider = track(function TLUiContextMenuSchem
const threeStackableItems = useThreeStackableItems() const threeStackableItems = useThreeStackableItems()
const atLeastOneShapeOnPage = useValue( const atLeastOneShapeOnPage = useValue(
'atLeastOneShapeOnPage', 'atLeastOneShapeOnPage',
() => editor.shapeIdsOnCurrentPage.size > 0, () => editor.currentPageShapeIds.size > 0,
[] []
) )
const isTransparentBg = useValue( const isTransparentBg = useValue(

View file

@ -20,7 +20,7 @@ export function useCopyAs() {
// little awkward. // little awkward.
function copyAs(ids: TLShapeId[] = editor.selectedShapeIds, format: TLCopyType = 'svg') { function copyAs(ids: TLShapeId[] = editor.selectedShapeIds, format: TLCopyType = 'svg') {
if (ids.length === 0) { if (ids.length === 0) {
ids = [...editor.shapeIdsOnCurrentPage] ids = [...editor.currentPageShapeIds]
} }
if (ids.length === 0) { if (ids.length === 0) {

View file

@ -21,7 +21,7 @@ export function useExportAs() {
format: TLExportType = 'png' format: TLExportType = 'png'
) { ) {
if (ids.length === 0) { if (ids.length === 0) {
ids = [...editor.shapeIdsOnCurrentPage] ids = [...editor.currentPageShapeIds]
} }
if (ids.length === 0) { if (ids.length === 0) {

View file

@ -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 hotkeys from 'hotkeys-js'
import { useEffect } from 'react' import { useEffect } from 'react'
import { useActions } from './useActions' import { useActions } from './useActions'
@ -26,12 +26,12 @@ export function useKeyboardShortcuts() {
useEffect(() => { useEffect(() => {
if (!isFocused) return if (!isFocused) return
const scope = uniqueId()
const container = editor.getContainer() const container = editor.getContainer()
hotkeys.setScope(editor.store.id)
const hot = (keys: string, callback: (event: KeyboardEvent) => void) => { 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. // 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 () => { return () => {
hotkeys.deleteScope(editor.store.id) hotkeys.deleteScope(scope)
} }
}, [actions, tools, isReadonly, editor, isFocused]) }, [actions, tools, isReadonly, editor, isFocused])
} }

View file

@ -60,7 +60,7 @@ export function TLUiMenuSchemaProvider({ overrides, children }: TLUiMenuSchemaPr
[editor] [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 selectedCount = useValue('selectedCount', () => editor.selectedShapeIds.length, [editor])
const noneSelected = selectedCount === 0 const noneSelected = selectedCount === 0

View file

@ -156,10 +156,10 @@ export function usePrint() {
} }
function triggerPrint() { function triggerPrint() {
if (editor.isChromeForIos) { if (editor.environment.isChromeForIos) {
beforePrintHandler() beforePrintHandler()
window.print() window.print()
} else if (editor.isSafari) { } else if (editor.environment.isSafari) {
beforePrintHandler() beforePrintHandler()
document.execCommand('print', false) document.execCommand('print', false)
} else { } else {

View file

@ -111,7 +111,7 @@ export function ToolsProvider({ overrides, children }: TLUiToolsProviderProps) {
[GeoShapeGeoStyle.id]: id, [GeoShapeGeoStyle.id]: id,
}, },
}, },
true { ephemeral: true, squashing: true }
) )
editor.setCurrentTool('geo') editor.setCurrentTool('geo')
trackEvent('select-tool', { source, id: `geo-${id}` }) trackEvent('select-tool', { source, id: `geo-${id}` })

View file

@ -178,7 +178,7 @@ export function useRegisterExternalContentHandlers() {
}, },
} }
editor.createShapes([shapePartial], true) editor.createShapes([shapePartial]).select(shapePartial.id)
}) })
// files // files
@ -405,7 +405,7 @@ export async function createShapesForAssets(editor: Editor, assets: TLAsset[], p
} }
// Create the shapes // 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 // Re-position shapes so that the center of the group is at the provided point
const { viewportPageBounds } = editor const { viewportPageBounds } = editor

View file

@ -593,7 +593,7 @@ export function buildFromV1Document(editor: Editor, document: LegacyTldrawDocume
const bounds = editor.commonBoundsOfAllShapesOnCurrentPage const bounds = editor.commonBoundsOfAllShapesOnCurrentPage
if (bounds) { if (bounds) {
editor.zoomToBounds(bounds.minX, bounds.minY, bounds.width, bounds.height, 1) editor.zoomToBounds(bounds, 1)
} }
}) })
} }

View file

@ -293,7 +293,7 @@ export async function parseAndLoadDocument(
const bounds = editor.commonBoundsOfAllShapesOnCurrentPage const bounds = editor.commonBoundsOfAllShapesOnCurrentPage
if (bounds) { if (bounds) {
editor.zoomToBounds(bounds.minX, bounds.minY, bounds.width, bounds.height, 1) editor.zoomToBounds(bounds, 1)
} }
}) })

View file

@ -1,6 +1,5 @@
import { BaseBoxShapeUtil, PageRecordType, TLShape, createShapeId } from '@tldraw/editor' import { BaseBoxShapeUtil, PageRecordType, TLShape, createShapeId } from '@tldraw/editor'
import { TestEditor } from './TestEditor' import { TestEditor } from './TestEditor'
import { TL } from './test-jsx'
let editor: TestEditor 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", () => { describe("should be excluded from the previous page's hintingShapeIds", () => {
test('[boxes]', () => { 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]) expect(editor.hintingShapeIds).toEqual([ids.box1, ids.box2, ids.box3])
moveShapesToPage2() moveShapesToPage2()
expect(editor.hintingShapeIds).toEqual([]) expect(editor.hintingShapeIds).toEqual([])
}) })
test('[frame that does not move]', () => { test('[frame that does not move]', () => {
editor.setHintingIds([ids.frame1]) editor.setHintingShapeIds([ids.frame1])
expect(editor.hintingShapeIds).toEqual([ids.frame1]) expect(editor.hintingShapeIds).toEqual([ids.frame1])
moveShapesToPage2() moveShapesToPage2()
expect(editor.hintingShapeIds).toEqual([ids.frame1]) 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", () => { describe("should be excluded from the previous page's editingShapeId", () => {
test('[root shape]', () => { test('[root shape]', () => {
editor.setEditingId(ids.box1) editor.setEditingShapeId(ids.box1)
expect(editor.editingShapeId).toBe(ids.box1) expect(editor.editingShapeId).toBe(ids.box1)
moveShapesToPage2() moveShapesToPage2()
expect(editor.editingShapeId).toBe(null) expect(editor.editingShapeId).toBe(null)
}) })
test('[child of frame]', () => { test('[child of frame]', () => {
editor.setEditingId(ids.box2) editor.setEditingShapeId(ids.box2)
expect(editor.editingShapeId).toBe(ids.box2) expect(editor.editingShapeId).toBe(ids.box2)
moveShapesToPage2() moveShapesToPage2()
expect(editor.editingShapeId).toBe(null) expect(editor.editingShapeId).toBe(null)
}) })
test('[child of group]', () => { test('[child of group]', () => {
editor.setEditingId(ids.box3) editor.setEditingShapeId(ids.box3)
expect(editor.editingShapeId).toBe(ids.box3) expect(editor.editingShapeId).toBe(ids.box3)
moveShapesToPage2() moveShapesToPage2()
expect(editor.editingShapeId).toBe(null) expect(editor.editingShapeId).toBe(null)
}) })
test('[frame that doesnt move]', () => { test('[frame that doesnt move]', () => {
editor.setEditingId(ids.frame1) editor.setEditingShapeId(ids.frame1)
expect(editor.editingShapeId).toBe(ids.frame1) expect(editor.editingShapeId).toBe(ids.frame1)
moveShapesToPage2() moveShapesToPage2()
expect(editor.editingShapeId).toBe(ids.frame1) 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", () => { describe("should be excluded from the previous page's erasingShapeIds", () => {
test('[boxes]', () => { 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]) expect(editor.erasingShapeIds).toEqual([ids.box1, ids.box2, ids.box3])
moveShapesToPage2() moveShapesToPage2()
expect(editor.erasingShapeIds).toEqual([]) expect(editor.erasingShapeIds).toEqual([])
}) })
test('[frame that does not move]', () => { test('[frame that does not move]', () => {
editor.setErasingIds([ids.frame1]) editor.setErasingShapeIds([ids.frame1])
expect(editor.erasingShapeIds).toEqual([ids.frame1]) expect(editor.erasingShapeIds).toEqual([ids.frame1])
moveShapesToPage2() moveShapesToPage2()
expect(editor.erasingShapeIds).toEqual([ids.frame1]) 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) expect(editor.canUndo).toBe(false)
}) })
describe('Editor.sharedOpacity', () => { // describe('Editor.sharedOpacity', () => {
it('should return the current opacity', () => { // it('should return the current opacity', () => {
expect(editor.sharedOpacity).toStrictEqual({ type: 'shared', value: 1 }) // expect(editor.sharedOpacity).toStrictEqual({ type: 'shared', value: 1 })
editor.setOpacity(0.5) // editor.setOpacity(0.5)
expect(editor.sharedOpacity).toStrictEqual({ type: 'shared', value: 0.5 }) // expect(editor.sharedOpacity).toStrictEqual({ type: 'shared', value: 0.5 })
}) // })
it('should return opacity for a single selected shape', () => { // it('should return opacity for a single selected shape', () => {
const { A } = editor.createShapesFromJsx(<TL.geo ref="A" opacity={0.3} x={0} y={0} />) // const { A } = editor.createShapesFromJsx(<TL.geo ref="A" opacity={0.3} x={0} y={0} />)
editor.setSelectedShapeIds([A]) // editor.setSelectedShapeIds([A])
expect(editor.sharedOpacity).toStrictEqual({ type: 'shared', value: 0.3 }) // expect(editor.sharedOpacity).toStrictEqual({ type: 'shared', value: 0.3 })
}) // })
it('should return opacity for multiple selected shapes', () => { // it('should return opacity for multiple selected shapes', () => {
const { A, B } = editor.createShapesFromJsx([ // const { A, B } = editor.createShapesFromJsx([
<TL.geo ref="A" opacity={0.3} x={0} y={0} />, // <TL.geo ref="A" opacity={0.3} x={0} y={0} />,
<TL.geo ref="B" opacity={0.3} x={0} y={0} />, // <TL.geo ref="B" opacity={0.3} x={0} y={0} />,
]) // ])
editor.setSelectedShapeIds([A, B]) // editor.setSelectedShapeIds([A, B])
expect(editor.sharedOpacity).toStrictEqual({ type: 'shared', value: 0.3 }) // expect(editor.sharedOpacity).toStrictEqual({ type: 'shared', value: 0.3 })
}) // })
it('should return mixed when multiple selected shapes have different opacity', () => { // it('should return mixed when multiple selected shapes have different opacity', () => {
const { A, B } = editor.createShapesFromJsx([ // const { A, B } = editor.createShapesFromJsx([
<TL.geo ref="A" opacity={0.3} x={0} y={0} />, // <TL.geo ref="A" opacity={0.3} x={0} y={0} />,
<TL.geo ref="B" opacity={0.5} x={0} y={0} />, // <TL.geo ref="B" opacity={0.5} x={0} y={0} />,
]) // ])
editor.setSelectedShapeIds([A, B]) // editor.setSelectedShapeIds([A, B])
expect(editor.sharedOpacity).toStrictEqual({ type: 'mixed' }) // expect(editor.sharedOpacity).toStrictEqual({ type: 'mixed' })
}) // })
it('ignores the opacity of groups and returns the opacity of their children', () => { // it('ignores the opacity of groups and returns the opacity of their children', () => {
const ids = editor.createShapesFromJsx([ // const ids = editor.createShapesFromJsx([
<TL.group ref="group" x={0} y={0}> // <TL.group ref="group" x={0} y={0}>
<TL.geo ref="A" opacity={0.3} x={0} y={0} /> // <TL.geo ref="A" opacity={0.3} x={0} y={0} />
</TL.group>, // </TL.group>,
]) // ])
editor.setSelectedShapeIds([ids.group]) // editor.setSelectedShapeIds([ids.group])
expect(editor.sharedOpacity).toStrictEqual({ type: 'shared', value: 0.3 }) // expect(editor.sharedOpacity).toStrictEqual({ type: 'shared', value: 0.3 })
}) // })
}) // })
describe('Editor.setOpacity', () => { describe('Editor.setOpacity', () => {
it('should set opacity for selected shapes', () => { // it('should set opacity for selected shapes', () => {
const ids = editor.createShapesFromJsx([ // const ids = editor.createShapesFromJsx([
<TL.geo ref="A" opacity={0.3} x={0} y={0} />, // <TL.geo ref="A" opacity={0.3} x={0} y={0} />,
<TL.geo ref="B" opacity={0.4} x={0} y={0} />, // <TL.geo ref="B" opacity={0.4} x={0} y={0} />,
]) // ])
editor.setSelectedShapeIds([ids.A, ids.B]) // editor.setSelectedShapeIds([ids.A, ids.B])
editor.setOpacity(0.5) // editor.setOpacity(0.5)
expect(editor.getShape(ids.A)!.opacity).toBe(0.5) // expect(editor.getShape(ids.A)!.opacity).toBe(0.5)
expect(editor.getShape(ids.B)!.opacity).toBe(0.5) // expect(editor.getShape(ids.B)!.opacity).toBe(0.5)
}) // })
it('should traverse into groups and set opacity in their children', () => { // it('should traverse into groups and set opacity in their children', () => {
const ids = editor.createShapesFromJsx([ // const ids = editor.createShapesFromJsx([
<TL.geo ref="boxA" x={0} y={0} />, // <TL.geo ref="boxA" x={0} y={0} />,
<TL.group ref="groupA" x={0} y={0}> // <TL.group ref="groupA" x={0} y={0}>
<TL.geo ref="boxB" x={0} y={0} /> // <TL.geo ref="boxB" x={0} y={0} />
<TL.group ref="groupB" x={0} y={0}> // <TL.group ref="groupB" x={0} y={0}>
<TL.geo ref="boxC" x={0} y={0} /> // <TL.geo ref="boxC" x={0} y={0} />
<TL.geo ref="boxD" x={0} y={0} /> // <TL.geo ref="boxD" x={0} y={0} />
</TL.group> // </TL.group>
</TL.group>, // </TL.group>,
]) // ])
editor.setSelectedShapeIds([ids.groupA]) // editor.setSelectedShapeIds([ids.groupA])
editor.setOpacity(0.5) // editor.setOpacity(0.5)
// a wasn't selected... // // a wasn't selected...
expect(editor.getShape(ids.boxA)!.opacity).toBe(1) // expect(editor.getShape(ids.boxA)!.opacity).toBe(1)
// b, c, & d were within a selected group... // // b, c, & d were within a selected group...
expect(editor.getShape(ids.boxB)!.opacity).toBe(0.5) // expect(editor.getShape(ids.boxB)!.opacity).toBe(0.5)
expect(editor.getShape(ids.boxC)!.opacity).toBe(0.5) // expect(editor.getShape(ids.boxC)!.opacity).toBe(0.5)
expect(editor.getShape(ids.boxD)!.opacity).toBe(0.5) // expect(editor.getShape(ids.boxD)!.opacity).toBe(0.5)
// groups get skipped // // groups get skipped
expect(editor.getShape(ids.groupA)!.opacity).toBe(1) // expect(editor.getShape(ids.groupA)!.opacity).toBe(1)
expect(editor.getShape(ids.groupB)!.opacity).toBe(1) // expect(editor.getShape(ids.groupB)!.opacity).toBe(1)
}) // })
it('stores opacity on opacityForNextShape', () => { it('stores opacity on opacityForNextShape', () => {
editor.setOpacity(0.5) editor.setOpacity(0.5)

View file

@ -99,7 +99,7 @@ describe('When clicking', () => {
// Starts in idle // Starts in idle
editor.expectPathToBe('root.eraser.idle') editor.expectPathToBe('root.eraser.idle')
const shapesBeforeCount = editor.shapesOnCurrentPage.length const shapesBeforeCount = editor.currentPageShapes.length
editor.pointerDown(0, 0) // near enough to box1 editor.pointerDown(0, 0) // near enough to box1
@ -108,11 +108,10 @@ describe('When clicking', () => {
// Sets the erasingShapeIds array / erasingShapeIdsSet // Sets the erasingShapeIds array / erasingShapeIdsSet
expect(editor.erasingShapeIds).toEqual([ids.box1]) expect(editor.erasingShapeIds).toEqual([ids.box1])
expect(editor.erasingShapeIdsSet).toEqual(new Set([ids.box1]))
editor.pointerUp() editor.pointerUp()
const shapesAfterCount = editor.shapesOnCurrentPage.length const shapesAfterCount = editor.currentPageShapes.length
// Deletes the erasing shapes // Deletes the erasing shapes
expect(editor.getShape(ids.box1)).toBeUndefined() expect(editor.getShape(ids.box1)).toBeUndefined()
@ -120,7 +119,6 @@ describe('When clicking', () => {
// Also empties the erasingShapeIds array / erasingShapeIdsSet // Also empties the erasingShapeIds array / erasingShapeIdsSet
expect(editor.erasingShapeIds).toEqual([]) expect(editor.erasingShapeIds).toEqual([])
expect(editor.erasingShapeIdsSet).toEqual(new Set([]))
// Returns to idle // Returns to idle
editor.expectPathToBe('root.eraser.idle') editor.expectPathToBe('root.eraser.idle')
@ -128,30 +126,29 @@ describe('When clicking', () => {
editor.undo() editor.undo()
expect(editor.getShape(ids.box1)).toBeDefined() expect(editor.getShape(ids.box1)).toBeDefined()
expect(editor.shapesOnCurrentPage.length).toBe(shapesBeforeCount) expect(editor.currentPageShapes.length).toBe(shapesBeforeCount)
editor.redo() editor.redo()
expect(editor.getShape(ids.box1)).toBeUndefined() 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', () => { it('Erases all shapes under the cursor on click', () => {
editor.setCurrentTool('eraser') editor.setCurrentTool('eraser')
const shapesBeforeCount = editor.shapesOnCurrentPage.length const shapesBeforeCount = editor.currentPageShapes.length
editor.pointerDown(99, 99) // next to box1 AND in box2 editor.pointerDown(99, 99) // next to box1 AND in box2
expect(new Set(editor.erasingShapeIds)).toEqual(new Set([ids.box1, ids.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() editor.pointerUp()
expect(editor.getShape(ids.box1)).toBeUndefined() expect(editor.getShape(ids.box1)).toBeUndefined()
expect(editor.getShape(ids.box2)).toBeUndefined() expect(editor.getShape(ids.box2)).toBeUndefined()
const shapesAfterCount = editor.shapesOnCurrentPage.length const shapesAfterCount = editor.currentPageShapes.length
expect(shapesAfterCount).toBe(shapesBeforeCount - 2) expect(shapesAfterCount).toBe(shapesBeforeCount - 2)
}) })
@ -159,16 +156,15 @@ describe('When clicking', () => {
editor.groupShapes([ids.box2, ids.box3], ids.group1) editor.groupShapes([ids.box2, ids.box3], ids.group1)
editor.setCurrentTool('eraser') editor.setCurrentTool('eraser')
const shapesBeforeCount = editor.shapesOnCurrentPage.length const shapesBeforeCount = editor.currentPageShapes.length
editor.pointerDown(350, 350) // in box3 editor.pointerDown(350, 350) // in box3
expect(new Set(editor.erasingShapeIds)).toEqual(new Set([ids.group1])) expect(new Set(editor.erasingShapeIds)).toEqual(new Set([ids.group1]))
expect(editor.erasingShapeIdsSet).toEqual(new Set([ids.group1]))
editor.pointerUp() editor.pointerUp()
const shapesAfterCount = editor.shapesOnCurrentPage.length const shapesAfterCount = editor.currentPageShapes.length
expect(editor.getShape(ids.box2)).toBeUndefined() expect(editor.getShape(ids.box2)).toBeUndefined()
expect(editor.getShape(ids.box3)).toBeUndefined() expect(editor.getShape(ids.box3)).toBeUndefined()
@ -181,28 +177,26 @@ describe('When clicking', () => {
editor.groupShapes([ids.box2, ids.box3], ids.group1) editor.groupShapes([ids.box2, ids.box3], ids.group1)
editor.setCurrentTool('eraser') 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 editor.pointerDown(275, 275) // in between box2 AND box3, so over of the new group
expect(editor.erasingShapeIdsSet).toEqual(new Set([]))
editor.pointerUp() editor.pointerUp()
const shapesAfterCount = editor.shapesOnCurrentPage.length const shapesAfterCount = editor.currentPageShapes.length
expect(shapesAfterCount).toBe(shapesBeforeCount) expect(shapesAfterCount).toBe(shapesBeforeCount)
}) })
it('Stops erasing when it reaches a frame when the frame was not was the top-most hovered shape', () => { it('Stops erasing when it reaches a frame when the frame was not was the top-most hovered shape', () => {
editor.setCurrentTool('eraser') 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 editor.pointerDown(375, 75) // inside of the box4 shape inside of box3
expect(editor.erasingShapeIdsSet).toEqual(new Set([ids.box4]))
editor.pointerUp() editor.pointerUp()
const shapesAfterCount = editor.shapesOnCurrentPage.length const shapesAfterCount = editor.currentPageShapes.length
expect(shapesAfterCount).toBe(shapesBeforeCount - 1) expect(shapesAfterCount).toBe(shapesBeforeCount - 1)
// Erases the child but does not erase the frame // 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', () => { it('Erases a frame only when its clicked on the edge', () => {
editor.setCurrentTool('eraser') editor.setCurrentTool('eraser')
const shapesBeforeCount = editor.shapesOnCurrentPage.length const shapesBeforeCount = editor.currentPageShapes.length
editor.pointerDown(325, 25) // directly on frame1, not its children editor.pointerDown(325, 25) // directly on frame1, not its children
expect(editor.erasingShapeIdsSet).toEqual(new Set([]))
editor.pointerUp() // without dragging! editor.pointerUp() // without dragging!
const shapesAfterCount = editor.shapesOnCurrentPage.length const shapesAfterCount = editor.currentPageShapes.length
expect(shapesAfterCount).toBe(shapesBeforeCount) expect(shapesAfterCount).toBe(shapesBeforeCount)
// Erases BOTH the frame and its child // 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', () => { it('Only erases masked shapes when pointer is inside the mask', () => {
editor.setCurrentTool('eraser') 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 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! editor.pointerUp() // without dragging!
const shapesAfterCount = editor.shapesOnCurrentPage.length const shapesAfterCount = editor.currentPageShapes.length
expect(shapesAfterCount).toBe(shapesBeforeCount) expect(shapesAfterCount).toBe(shapesBeforeCount)
// Erases NEITHER the frame nor its child // Erases NEITHER the frame nor its child
@ -250,25 +242,23 @@ describe('When clicking', () => {
editor.setCurrentTool('eraser') editor.setCurrentTool('eraser')
editor.expectPathToBe('root.eraser.idle') editor.expectPathToBe('root.eraser.idle')
const shapesBeforeCount = editor.shapesOnCurrentPage.length const shapesBeforeCount = editor.currentPageShapes.length
editor.pointerDown(0, 0) // in box1 editor.pointerDown(0, 0) // in box1
editor.expectPathToBe('root.eraser.pointing') editor.expectPathToBe('root.eraser.pointing')
expect(editor.erasingShapeIds).toEqual([ids.box1]) expect(editor.erasingShapeIds).toEqual([ids.box1])
expect(editor.erasingShapeIdsSet).toEqual(new Set([ids.box1]))
editor.cancel() editor.cancel()
editor.pointerUp() editor.pointerUp()
const shapesAfterCount = editor.shapesOnCurrentPage.length const shapesAfterCount = editor.currentPageShapes.length
editor.expectPathToBe('root.eraser.idle') editor.expectPathToBe('root.eraser.idle')
// Does NOT erase the shape // Does NOT erase the shape
expect(editor.erasingShapeIds).toEqual([]) expect(editor.erasingShapeIds).toEqual([])
expect(editor.erasingShapeIdsSet).toEqual(new Set([]))
expect(editor.getShape(ids.box1)).toBeDefined() expect(editor.getShape(ids.box1)).toBeDefined()
expect(shapesAfterCount).toBe(shapesBeforeCount) expect(shapesAfterCount).toBe(shapesBeforeCount)
}) })
@ -277,25 +267,23 @@ describe('When clicking', () => {
editor.setCurrentTool('eraser') editor.setCurrentTool('eraser')
editor.expectPathToBe('root.eraser.idle') editor.expectPathToBe('root.eraser.idle')
const shapesBeforeCount = editor.shapesOnCurrentPage.length const shapesBeforeCount = editor.currentPageShapes.length
editor.pointerDown(0, 0) // near to box1 editor.pointerDown(0, 0) // near to box1
editor.expectPathToBe('root.eraser.pointing') editor.expectPathToBe('root.eraser.pointing')
expect(editor.erasingShapeIds).toEqual([ids.box1]) expect(editor.erasingShapeIds).toEqual([ids.box1])
expect(editor.erasingShapeIdsSet).toEqual(new Set([ids.box1]))
editor.interrupt() editor.interrupt()
editor.pointerUp() editor.pointerUp()
const shapesAfterCount = editor.shapesOnCurrentPage.length const shapesAfterCount = editor.currentPageShapes.length
editor.expectPathToBe('root.eraser.idle') editor.expectPathToBe('root.eraser.idle')
// Does NOT erase the shape // Does NOT erase the shape
expect(editor.erasingShapeIds).toEqual([]) expect(editor.erasingShapeIds).toEqual([])
expect(editor.erasingShapeIdsSet).toEqual(new Set([]))
expect(editor.getShape(ids.box1)).toBeDefined() expect(editor.getShape(ids.box1)).toBeDefined()
expect(shapesAfterCount).toBe(shapesBeforeCount) expect(shapesAfterCount).toBe(shapesBeforeCount)
}) })
@ -320,24 +308,20 @@ describe('When clicking and dragging', () => {
expect(editor.instanceState.scribble).not.toBe(null) expect(editor.instanceState.scribble).not.toBe(null)
expect(editor.erasingShapeIds).toEqual([ids.box1]) expect(editor.erasingShapeIds).toEqual([ids.box1])
// expect(editor.erasingShapeIdsSet).toEqual(new Set([ids.box1]))
// editor.pointerUp() // editor.pointerUp()
// editor.expectPathToBe('root.eraser.idle') // editor.expectPathToBe('root.eraser.idle')
// expect(editor.erasingShapeIds).toEqual([]) // expect(editor.erasingShapeIds).toEqual([])
// expect(editor.erasingShapeIdsSet).toEqual(new Set([]))
// expect(editor.getShape(ids.box1)).not.toBeDefined() // expect(editor.getShape(ids.box1)).not.toBeDefined()
// editor.undo() // editor.undo()
// expect(editor.erasingShapeIds).toEqual([]) // expect(editor.erasingShapeIds).toEqual([])
// expect(editor.erasingShapeIdsSet).toEqual(new Set([]))
// expect(editor.getShape(ids.box1)).toBeDefined() // expect(editor.getShape(ids.box1)).toBeDefined()
// editor.redo() // editor.redo()
// expect(editor.erasingShapeIds).toEqual([]) // expect(editor.erasingShapeIds).toEqual([])
// expect(editor.erasingShapeIdsSet).toEqual(new Set([]))
// expect(editor.getShape(ids.box1)).not.toBeDefined() // expect(editor.getShape(ids.box1)).not.toBeDefined()
}) })
@ -349,11 +333,9 @@ describe('When clicking and dragging', () => {
jest.advanceTimersByTime(16) jest.advanceTimersByTime(16)
expect(editor.instanceState.scribble).not.toBe(null) expect(editor.instanceState.scribble).not.toBe(null)
expect(editor.erasingShapeIds).toEqual([ids.box1]) expect(editor.erasingShapeIds).toEqual([ids.box1])
expect(editor.erasingShapeIdsSet).toEqual(new Set([ids.box1]))
editor.cancel() editor.cancel()
editor.expectPathToBe('root.eraser.idle') editor.expectPathToBe('root.eraser.idle')
expect(editor.erasingShapeIds).toEqual([]) expect(editor.erasingShapeIds).toEqual([])
expect(editor.erasingShapeIdsSet).toEqual(new Set([]))
expect(editor.getShape(ids.box1)).toBeDefined() expect(editor.getShape(ids.box1)).toBeDefined()
}) })
@ -366,10 +348,8 @@ describe('When clicking and dragging', () => {
jest.advanceTimersByTime(16) jest.advanceTimersByTime(16)
expect(editor.instanceState.scribble).not.toBe(null) expect(editor.instanceState.scribble).not.toBe(null)
expect(editor.erasingShapeIds).toEqual([]) expect(editor.erasingShapeIds).toEqual([])
expect(editor.erasingShapeIdsSet).toEqual(new Set([]))
editor.pointerMove(0, 0) editor.pointerMove(0, 0)
expect(editor.erasingShapeIds).toEqual([ids.box1]) expect(editor.erasingShapeIds).toEqual([ids.box1])
expect(editor.erasingShapeIdsSet).toEqual(new Set([ids.box1]))
expect(editor.getShape(ids.box1)).toBeDefined() expect(editor.getShape(ids.box1)).toBeDefined()
editor.pointerUp() editor.pointerUp()
expect(editor.getShape(ids.group1)).toBeDefined() expect(editor.getShape(ids.group1)).toBeDefined()
@ -383,7 +363,6 @@ describe('When clicking and dragging', () => {
jest.advanceTimersByTime(16) jest.advanceTimersByTime(16)
expect(editor.instanceState.scribble).not.toBe(null) expect(editor.instanceState.scribble).not.toBe(null)
expect(editor.erasingShapeIds).toEqual([ids.box3]) expect(editor.erasingShapeIds).toEqual([ids.box3])
expect(editor.erasingShapeIdsSet).toEqual(new Set([ids.box3]))
editor.pointerUp() editor.pointerUp()
expect(editor.getShape(ids.frame1)).toBeDefined() expect(editor.getShape(ids.frame1)).toBeDefined()
expect(editor.getShape(ids.box3)).not.toBeDefined() expect(editor.getShape(ids.box3)).not.toBeDefined()
@ -397,7 +376,6 @@ describe('When clicking and dragging', () => {
jest.advanceTimersByTime(16) jest.advanceTimersByTime(16)
expect(editor.instanceState.scribble).not.toBe(null) expect(editor.instanceState.scribble).not.toBe(null)
expect(editor.erasingShapeIds).toEqual([]) expect(editor.erasingShapeIds).toEqual([])
expect(editor.erasingShapeIdsSet).toEqual(new Set([]))
editor.pointerUp() editor.pointerUp()
expect(editor.getShape(ids.box3)).toBeDefined() 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 editor.pointerMove(375, 500) // Through the masked part of box3
expect(editor.instanceState.scribble).not.toBe(null) expect(editor.instanceState.scribble).not.toBe(null)
expect(editor.erasingShapeIds).toEqual([ids.box3]) expect(editor.erasingShapeIds).toEqual([ids.box3])
expect(editor.erasingShapeIdsSet).toEqual(new Set([ids.box3]))
editor.pointerUp() editor.pointerUp()
expect(editor.getShape(ids.box3)).not.toBeDefined() expect(editor.getShape(ids.box3)).not.toBeDefined()
}) })
@ -437,7 +414,7 @@ describe('When clicking and dragging', () => {
describe('Does not erase hollow shapes on click', () => { describe('Does not erase hollow shapes on click', () => {
it('Returns to select on cancel', () => { it('Returns to select on cancel', () => {
editor.selectAll().deleteShapes(editor.selectedShapes) editor.selectAll().deleteShapes(editor.selectedShapes)
expect(editor.shapesOnCurrentPage.length).toBe(0) expect(editor.currentPageShapes.length).toBe(0)
editor.createShape({ editor.createShape({
id: createShapeId(), id: createShapeId(),
type: 'geo', type: 'geo',
@ -447,7 +424,7 @@ describe('Does not erase hollow shapes on click', () => {
editor.pointerDown() editor.pointerDown()
expect(editor.erasingShapeIds).toEqual([]) expect(editor.erasingShapeIds).toEqual([])
editor.pointerUp() editor.pointerUp()
expect(editor.shapesOnCurrentPage.length).toBe(1) expect(editor.currentPageShapes.length).toBe(1)
}) })
}) })

View file

@ -45,33 +45,33 @@ describe('TLSelectTool.Translating', () => {
editor.pointerDown(150, 150, { target: 'shape', shape }) editor.pointerDown(150, 150, { target: 'shape', shape })
editor.pointerMove(200, 200) 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 }) editor.expectShapeToMatch({ id: ids.box1, x: 150, y: 150 })
const t1 = [...editor.shapeIdsOnCurrentPage.values()] const t1 = [...editor.currentPageShapeIds.values()]
editor.keyDown('Alt') editor.keyDown('Alt')
expect(editor.shapesOnCurrentPage.length).toBe(2) expect(editor.currentPageShapes.length).toBe(2)
editor.expectShapeToMatch({ id: ids.box1, x: 100, y: 100 }) editor.expectShapeToMatch({ id: ids.box1, x: 100, y: 100 })
// const t2 = [...editor.shapeIds.values()] // const t2 = [...editor.shapeIds.values()]
editor.keyUp('Alt') editor.keyUp('Alt')
// There's a timer here! We shouldn't end the clone until the timer is done // 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 jest.advanceTimersByTime(250) // tick tock
// Timer is done! We should have ended the clone. // 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.expectToBeIn('select.translating')
editor.expectShapeToMatch({ id: ids.box1, x: 150, y: 150 }) 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? // todo: Should cloning again duplicate new shapes, or restore the last clone?
// editor.keyDown('Alt') // editor.keyDown('Alt')
// expect(editor.shapesOnCurrentPage.length).toBe(2) // expect(editor.currentPageShapes.length).toBe(2)
// editor.expectShapeToMatch({ id: ids.box1, x: 100, y: 100 }) // editor.expectShapeToMatch({ id: ids.box1, x: 100, y: 100 })
// expect([...editor.shapeIds.values()]).toMatchObject(t2) // expect([...editor.shapeIds.values()]).toMatchObject(t2)
}) })
@ -95,7 +95,7 @@ describe('TLSelectTool.Translating', () => {
editor.pointerMove(150, 250) editor.pointerMove(150, 250)
editor.pointerUp() editor.pointerUp()
const box2Id = editor.onlySelectedShape!.id const box2Id = editor.onlySelectedShape!.id
expect(editor.shapesOnCurrentPage.length).toStrictEqual(2) expect(editor.currentPageShapes.length).toStrictEqual(2)
expect(ids.box1).not.toEqual(box2Id) expect(ids.box1).not.toEqual(box2Id)
// shift-alt-drag the original, we shouldn't duplicate the copy too: // 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]) expect(editor.selectedShapeIds).toStrictEqual([ids.box1])
editor.pointerMove(250, 150) editor.pointerMove(250, 150)
editor.pointerUp() 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) .deleteShapes(editor.selectedShapeIds)
.selectNone() .selectNone()
.createShapes([{ id: createShapeId(), type: 'geo' }]) .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') .expectToBeIn('select.editing_shape')
}) })
}) })
@ -358,45 +358,45 @@ describe('When editing shapes', () => {
it('Double clicking the canvas creates a new text shape', () => { it('Double clicking the canvas creates a new text shape', () => {
expect(editor.editingShapeId).toBe(null) expect(editor.editingShapeId).toBe(null)
expect(editor.selectedShapeIds.length).toBe(0) expect(editor.selectedShapeIds.length).toBe(0)
expect(editor.shapesOnCurrentPage.length).toBe(5) expect(editor.currentPageShapes.length).toBe(5)
editor.doubleClick(750, 750) editor.doubleClick(750, 750)
expect(editor.shapesOnCurrentPage.length).toBe(6) expect(editor.currentPageShapes.length).toBe(6)
expect(editor.shapesOnCurrentPage[5].type).toBe('text') expect(editor.currentPageShapes[5].type).toBe('text')
}) })
it('It deletes an empty text shape when your click away', () => { it('It deletes an empty text shape when your click away', () => {
expect(editor.editingShapeId).toBe(null) expect(editor.editingShapeId).toBe(null)
expect(editor.selectedShapeIds.length).toBe(0) 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 // Create a new shape by double clicking
editor.doubleClick(750, 750) editor.doubleClick(750, 750)
expect(editor.selectedShapeIds.length).toBe(1) expect(editor.selectedShapeIds.length).toBe(1)
expect(editor.shapesOnCurrentPage.length).toBe(6) expect(editor.currentPageShapes.length).toBe(6)
const shapeId = editor.selectedShapeIds[0] const shapeId = editor.selectedShapeIds[0]
// Click away // Click away
editor.click(1000, 1000) editor.click(1000, 1000)
expect(editor.selectedShapeIds.length).toBe(0) expect(editor.selectedShapeIds.length).toBe(0)
expect(editor.shapesOnCurrentPage.length).toBe(5) expect(editor.currentPageShapes.length).toBe(5)
expect(editor.getShape(shapeId)).toBe(undefined) expect(editor.getShape(shapeId)).toBe(undefined)
}) })
it('It deletes an empty text shape when your click another text shape', () => { it('It deletes an empty text shape when your click another text shape', () => {
expect(editor.editingShapeId).toBe(null) expect(editor.editingShapeId).toBe(null)
expect(editor.selectedShapeIds.length).toBe(0) 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 // Create a new shape by double clicking
editor.doubleClick(750, 750) editor.doubleClick(750, 750)
expect(editor.selectedShapeIds.length).toBe(1) expect(editor.selectedShapeIds.length).toBe(1)
expect(editor.shapesOnCurrentPage.length).toBe(6) expect(editor.currentPageShapes.length).toBe(6)
const shapeId = editor.selectedShapeIds[0] const shapeId = editor.selectedShapeIds[0]
// Click another text shape // Click another text shape
editor.click(50, 50, { target: 'shape', shape: editor.getShape(ids.text1) }) editor.click(50, 50, { target: 'shape', shape: editor.getShape(ids.text1) })
expect(editor.selectedShapeIds.length).toBe(1) expect(editor.selectedShapeIds.length).toBe(1)
expect(editor.shapesOnCurrentPage.length).toBe(5) expect(editor.currentPageShapes.length).toBe(5)
expect(editor.getShape(shapeId)).toBe(undefined) expect(editor.getShape(shapeId)).toBe(undefined)
}) })

View file

@ -219,7 +219,10 @@ describe('<TldrawEditor />', () => {
expect(editor).toBeTruthy() expect(editor).toBeTruthy()
await act(async () => { 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() const id = createShapeId()
@ -340,7 +343,10 @@ describe('Custom shapes', () => {
expect(editor).toBeTruthy() expect(editor).toBeTruthy()
await act(async () => { 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() expect(editor.shapeUtils.card).toBeTruthy()

View file

@ -40,7 +40,7 @@ describe('TLSelectTool.Zooming', () => {
it('Correctly zooms in when clicking', () => { it('Correctly zooms in when clicking', () => {
editor.keyDown('z') editor.keyDown('z')
expect(editor.zoomLevel).toBe(1) 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 }) expect(editor.viewportPageCenter).toMatchObject({ x: 540, y: 360 })
editor.click() editor.click()
editor.expectToBeIn('zoom.idle') editor.expectToBeIn('zoom.idle')
@ -52,7 +52,7 @@ describe('TLSelectTool.Zooming', () => {
editor.keyDown('z') editor.keyDown('z')
editor.keyDown('Alt') editor.keyDown('Alt')
expect(editor.zoomLevel).toBe(1) 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 }) expect(editor.viewportPageCenter).toMatchObject({ x: 540, y: 360 })
editor.click() editor.click()
jest.advanceTimersByTime(300) 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', () => { it('When the dragged area is small it zooms in instead of zooming to the area', () => {
const originalCenter = { x: 540, y: 360 } 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 const change = 6
expect(editor.zoomLevel).toBe(1) expect(editor.zoomLevel).toBe(1)
expect(editor.viewportPageBounds).toMatchObject(originalPageBounds) expect(editor.viewportPageBounds).toMatchObject(originalPageBounds)
@ -143,7 +143,7 @@ describe('TLSelectTool.Zooming', () => {
const newBoundsY = 200 const newBoundsY = 200
editor.expectToBeIn('select.idle') editor.expectToBeIn('select.idle')
expect(editor.zoomLevel).toBe(1) 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 }) expect(editor.viewportPageCenter).toMatchObject({ x: 540, y: 360 })
editor.keyDown('z') editor.keyDown('z')
editor.expectToBeIn('zoom.idle') editor.expectToBeIn('zoom.idle')
@ -179,7 +179,7 @@ describe('TLSelectTool.Zooming', () => {
editor.expectToBeIn('select.idle') editor.expectToBeIn('select.idle')
const originalZoomLevel = 1 const originalZoomLevel = 1
expect(editor.zoomLevel).toBe(originalZoomLevel) 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 }) expect(editor.viewportPageCenter).toMatchObject({ x: 540, y: 360 })
editor.keyDown('z') editor.keyDown('z')
editor.expectToBeIn('zoom.idle') editor.expectToBeIn('zoom.idle')

View file

@ -36,7 +36,7 @@ describe('Making an arrow on the page', () => {
editor.setCurrentTool('arrow') editor.setCurrentTool('arrow')
editor.pointerMove(0, 0) editor.pointerMove(0, 0)
editor.pointerDown() 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', () => { 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.setCurrentTool('arrow')
editor.pointerMove(0, 0) editor.pointerMove(0, 0)
editor.click() editor.click()
expect(editor.shapesOnCurrentPage.length).toBe(0) expect(editor.currentPageShapes.length).toBe(0)
// with double click // with double click
editor.setCurrentTool('arrow') editor.setCurrentTool('arrow')
editor.pointerMove(0, 0) editor.pointerMove(0, 0)
editor.doubleClick() editor.doubleClick()
expect(editor.shapesOnCurrentPage.length).toBe(0) expect(editor.currentPageShapes.length).toBe(0)
// with pointer up // with pointer up
editor.setCurrentTool('arrow') editor.setCurrentTool('arrow')
editor.pointerDown() editor.pointerDown()
editor.pointerUp() editor.pointerUp()
expect(editor.shapesOnCurrentPage.length).toBe(0) expect(editor.currentPageShapes.length).toBe(0)
// did not add it to the history stack // did not add it to the history stack
editor.undo() editor.undo()
expect(editor.shapesOnCurrentPage.length).toBe(0) expect(editor.currentPageShapes.length).toBe(0)
editor.redo() editor.redo()
editor.redo() editor.redo()
expect(editor.shapesOnCurrentPage.length).toBe(0) expect(editor.currentPageShapes.length).toBe(0)
}) })
it('keeps the arrow if the user dragged', () => { it('keeps the arrow if the user dragged', () => {
@ -75,7 +75,7 @@ describe('Making an arrow on the page', () => {
editor.setCurrentTool('arrow') editor.setCurrentTool('arrow')
editor.pointerDown(0, 0) editor.pointerDown(0, 0)
editor.pointerMove(100, 0) editor.pointerMove(100, 0)
const arrow1 = editor.shapesOnCurrentPage[0] const arrow1 = editor.currentPageShapes[0]
expect(arrow()).toMatchObject({ expect(arrow()).toMatchObject({
type: 'arrow', type: 'arrow',
@ -262,25 +262,25 @@ describe('When starting an arrow inside of multiple shapes', () => {
it('does not create the arrow immediately', () => { it('does not create the arrow immediately', () => {
editor.setCurrentTool('arrow') editor.setCurrentTool('arrow')
editor.pointerDown(50, 50) editor.pointerDown(50, 50)
expect(editor.shapesOnCurrentPage.length).toBe(1) expect(editor.currentPageShapes.length).toBe(1)
expect(arrow()).toBe(null) expect(arrow()).toBe(null)
}) })
it('does not create a shape if pointer up before drag', () => { it('does not create a shape if pointer up before drag', () => {
editor.setCurrentTool('arrow') editor.setCurrentTool('arrow')
editor.pointerDown(50, 50) editor.pointerDown(50, 50)
expect(editor.shapesOnCurrentPage.length).toBe(1) expect(editor.currentPageShapes.length).toBe(1)
editor.pointerUp(50, 50) 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', () => { it('creates the arrow after a drag, bound to the shape', () => {
editor.setCurrentTool('arrow') editor.setCurrentTool('arrow')
editor.pointerDown(50, 50) editor.pointerDown(50, 50)
expect(editor.shapesOnCurrentPage.length).toBe(1) expect(editor.currentPageShapes.length).toBe(1)
expect(arrow()).toBe(null) expect(arrow()).toBe(null)
editor.pointerMove(55, 50) editor.pointerMove(55, 50)
expect(editor.shapesOnCurrentPage.length).toBe(2) expect(editor.currentPageShapes.length).toBe(2)
expect(arrow()).toMatchObject({ expect(arrow()).toMatchObject({
x: 50, x: 50,
y: 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', () => { it('always creates the arrow with an imprecise start point', () => {
editor.setCurrentTool('arrow') editor.setCurrentTool('arrow')
editor.pointerDown(20, 20) // upper left editor.pointerDown(20, 20) // upper left
expect(editor.shapesOnCurrentPage.length).toBe(1) expect(editor.currentPageShapes.length).toBe(1)
expect(arrow()).toBe(null) expect(arrow()).toBe(null)
editor.pointerMove(25, 20) editor.pointerMove(25, 20)
expect(editor.shapesOnCurrentPage.length).toBe(2) expect(editor.currentPageShapes.length).toBe(2)
expect(arrow()).toMatchObject({ expect(arrow()).toMatchObject({
x: 20, x: 20,
y: 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', () => { it('after a pause before drag, creates an arrow with a precise start point', () => {
editor.setCurrentTool('arrow') editor.setCurrentTool('arrow')
editor.pointerDown(20, 20) // upper left editor.pointerDown(20, 20) // upper left
expect(editor.shapesOnCurrentPage.length).toBe(1) expect(editor.currentPageShapes.length).toBe(1)
expect(arrow()).toBe(null) expect(arrow()).toBe(null)
jest.advanceTimersByTime(1000) jest.advanceTimersByTime(1000)
editor.pointerMove(25, 20) editor.pointerMove(25, 20)
expect(editor.shapesOnCurrentPage.length).toBe(2) expect(editor.currentPageShapes.length).toBe(2)
expect(arrow()).toMatchObject({ expect(arrow()).toMatchObject({
x: 20, x: 20,
y: 20, y: 20,
@ -383,10 +383,10 @@ describe('When starting an arrow inside of multiple shapes', () => {
editor.setCurrentTool('arrow') editor.setCurrentTool('arrow')
editor.pointerDown(25, 25) editor.pointerDown(25, 25)
expect(editor.shapesOnCurrentPage.length).toBe(2) expect(editor.currentPageShapes.length).toBe(2)
expect(arrow()).toBe(null) expect(arrow()).toBe(null)
editor.pointerMove(30, 30) editor.pointerMove(30, 30)
expect(editor.shapesOnCurrentPage.length).toBe(3) expect(editor.currentPageShapes.length).toBe(3)
expect(arrow()).toMatchObject({ expect(arrow()).toMatchObject({
x: 25, x: 25,
y: 25, y: 25,
@ -403,8 +403,8 @@ describe('When starting an arrow inside of multiple shapes', () => {
type: 'binding', type: 'binding',
boundShapeId: ids.box2, boundShapeId: ids.box2,
normalizedAnchor: { normalizedAnchor: {
x: 0.6, x: 0.55,
y: 0.6, y: 0.5,
}, },
}, },
}, },
@ -417,10 +417,10 @@ describe('When starting an arrow inside of multiple shapes', () => {
editor.setCurrentTool('arrow') editor.setCurrentTool('arrow')
editor.pointerDown(25, 25) editor.pointerDown(25, 25)
expect(editor.shapesOnCurrentPage.length).toBe(2) expect(editor.currentPageShapes.length).toBe(2)
expect(arrow()).toBe(null) expect(arrow()).toBe(null)
editor.pointerMove(30, 30) editor.pointerMove(30, 30)
expect(editor.shapesOnCurrentPage.length).toBe(3) expect(editor.currentPageShapes.length).toBe(3)
expect(arrow()).toMatchObject({ expect(arrow()).toMatchObject({
x: 25, x: 25,
y: 25, y: 25,
@ -437,8 +437,8 @@ describe('When starting an arrow inside of multiple shapes', () => {
type: 'binding', type: 'binding',
boundShapeId: ids.box2, boundShapeId: ids.box2,
normalizedAnchor: { normalizedAnchor: {
x: 0.6, x: 0.55,
y: 0.6, y: 0.5,
}, },
}, },
}, },
@ -462,10 +462,10 @@ describe('When starting an arrow inside of multiple shapes', () => {
editor.setCurrentTool('arrow') editor.setCurrentTool('arrow')
editor.pointerDown(25, 25) editor.pointerDown(25, 25)
expect(editor.shapesOnCurrentPage.length).toBe(2) expect(editor.currentPageShapes.length).toBe(2)
expect(arrow()).toBe(null) expect(arrow()).toBe(null)
editor.pointerMove(30, 30) editor.pointerMove(30, 30)
expect(editor.shapesOnCurrentPage.length).toBe(3) expect(editor.currentPageShapes.length).toBe(3)
expect(arrow()).toMatchObject({ expect(arrow()).toMatchObject({
x: 25, x: 25,
y: 25, y: 25,
@ -498,10 +498,10 @@ describe('When starting an arrow inside of multiple shapes', () => {
editor.setCurrentTool('arrow') editor.setCurrentTool('arrow')
editor.pointerDown(25, 25) editor.pointerDown(25, 25)
expect(editor.shapesOnCurrentPage.length).toBe(2) expect(editor.currentPageShapes.length).toBe(2)
expect(arrow()).toBe(null) expect(arrow()).toBe(null)
editor.pointerMove(30, 30) editor.pointerMove(30, 30)
expect(editor.shapesOnCurrentPage.length).toBe(3) expect(editor.currentPageShapes.length).toBe(3)
expect(arrow()).toMatchObject({ expect(arrow()).toMatchObject({
x: 25, x: 25,
y: 25, y: 25,
@ -518,11 +518,87 @@ describe('When starting an arrow inside of multiple shapes', () => {
type: 'binding', type: 'binding',
boundShapeId: ids.box2, boundShapeId: ids.box2,
normalizedAnchor: { normalizedAnchor: {
x: 0.6, // kicked over because it was too close to the center, and we can't have both bound there
y: 0.6, 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')
})
})

View file

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

Some files were not shown because too many files have changed in this diff Show more