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

View file

@ -23,7 +23,7 @@ test.describe.skip('clipboard tests', () => {
await page.mouse.down()
await page.mouse.up()
expect(await page.evaluate(() => editor.shapesOnCurrentPage.length)).toBe(1)
expect(await page.evaluate(() => editor.currentPageShapes.length)).toBe(1)
expect(await page.evaluate(() => editor.selectedShapes.length)).toBe(1)
await page.keyboard.down('Control')
@ -32,7 +32,7 @@ test.describe.skip('clipboard tests', () => {
await page.keyboard.press('KeyV')
await page.keyboard.up('Control')
expect(await page.evaluate(() => editor.shapesOnCurrentPage.length)).toBe(2)
expect(await page.evaluate(() => editor.currentPageShapes.length)).toBe(2)
expect(await page.evaluate(() => editor.selectedShapes.length)).toBe(1)
})
@ -42,7 +42,7 @@ test.describe.skip('clipboard tests', () => {
await page.mouse.down()
await page.mouse.up()
expect(await page.evaluate(() => editor.shapesOnCurrentPage.length)).toBe(1)
expect(await page.evaluate(() => editor.currentPageShapes.length)).toBe(1)
expect(await page.evaluate(() => editor.selectedShapes.length)).toBe(1)
await page.getByTestId('main.menu').click()
@ -53,7 +53,7 @@ test.describe.skip('clipboard tests', () => {
await page.getByTestId('menu-item.edit').click()
await page.getByTestId('menu-item.paste').click()
expect(await page.evaluate(() => editor.shapesOnCurrentPage.length)).toBe(2)
expect(await page.evaluate(() => editor.currentPageShapes.length)).toBe(2)
expect(await page.evaluate(() => editor.selectedShapes.length)).toBe(1)
})
@ -63,7 +63,7 @@ test.describe.skip('clipboard tests', () => {
await page.mouse.down()
await page.mouse.up()
expect(await page.evaluate(() => editor.shapesOnCurrentPage.length)).toBe(1)
expect(await page.evaluate(() => editor.currentPageShapes.length)).toBe(1)
expect(await page.evaluate(() => editor.selectedShapes.length)).toBe(1)
await page.mouse.click(100, 100, { button: 'right' })
@ -73,7 +73,7 @@ test.describe.skip('clipboard tests', () => {
await page.mouse.click(100, 100, { button: 'right' })
await page.getByTestId('menu-item.paste').click()
expect(await page.evaluate(() => editor.shapesOnCurrentPage.length)).toBe(2)
expect(await page.evaluate(() => editor.currentPageShapes.length)).toBe(2)
expect(await page.evaluate(() => editor.selectedShapes.length)).toBe(1)
})
})

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

View file

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

View file

@ -33,7 +33,7 @@ export function createTLStore({ initialData, defaultName = '', ...rest }: TLStor
'schema' in rest
? rest.schema
: createTLSchema({
shapes: shapesOnCurrentPageToShapeMap(checkShapesAndAddCore(rest.shapeUtils)),
shapes: currentPageShapesToShapeMap(checkShapesAndAddCore(rest.shapeUtils)),
})
return new Store({
schema,
@ -44,7 +44,7 @@ export function createTLStore({ initialData, defaultName = '', ...rest }: TLStor
})
}
function shapesOnCurrentPageToShapeMap(shapeUtils: TLShapeUtilConstructor<TLUnknownShape>[]) {
function currentPageShapesToShapeMap(shapeUtils: TLShapeUtilConstructor<TLUnknownShape>[]) {
return Object.fromEntries(
shapeUtils.map((s): [string, SchemaShapeInfo] => [
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'
function createCounterHistoryManager() {
const manager = new HistoryManager(
{ emit: () => void null },
() => null,
() => {
return
}
)
const manager = new HistoryManager({ emit: () => void null }, () => {
return
})
const state = {
count: 0,
name: 'David',
@ -251,7 +247,10 @@ describe(HistoryManager, () => {
expect(editor.getAge()).toBe(35)
})
it('does not allow new history entries to be pushed if a command invokes them while doing or undoing', () => {
// I had to turn off ignoringUpdates in command's initial "do" in order to make the cleanup methods undoable
// If there are other side effects here that we need to know about, then we should add tests for them;
// but so far everything seems ok.
it.skip('does not allow new history entries to be pushed if a command invokes them while doing or undoing', () => {
editor.incrementTwice()
expect(editor.history.numUndos).toBe(1)
expect(editor.getCount()).toBe(2)
@ -260,7 +259,7 @@ describe(HistoryManager, () => {
expect(editor.history.numUndos).toBe(0)
})
it('does not allow new history entries to be pushed if a command invokes them while bailing', () => {
it.skip('does not allow new history entries to be pushed if a command invokes them while bailing', () => {
editor.history.mark('0')
editor.incrementTwice()
editor.history.mark('2')

View file

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

View file

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

View file

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

View file

@ -9,7 +9,10 @@ export class Idle extends StateNode {
}
override onEnter = () => {
this.editor.updateInstanceState({ cursor: { type: 'cross', rotation: 0 } }, true)
this.editor.updateInstanceState(
{ cursor: { type: 'cross', rotation: 0 } },
{ ephemeral: true, squashing: true }
)
}
override onCancel = () => {

View file

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

View file

@ -1,2 +1,4 @@
/** @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)
// the drop point should be offset by the position of the editor's container
const rect = editor.getContainer().getBoundingClientRect()
const point = editor.screenToPage({ x: e.clientX - rect.x, y: e.clientY - rect.y })
await editor.putExternalContent({
type: 'files',
files,
point: editor.screenToPage(e.clientX - rect.x, e.clientY - rect.y),
point,
ignoreParent: false,
})
}

View file

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

View file

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

View file

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

View file

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

View file

@ -39,7 +39,7 @@ let stack: CaptureStackFrame | null = null
* })
*
* react('log name changes', () => {
* console.log(name.value, 'was changed at', unsafe__withoutCapture(() => time.value))
* print(name.value, 'was changed at', unsafe__withoutCapture(() => time.value))
* })
*
* ```
@ -134,7 +134,7 @@ export function maybeCaptureParent(p: Signal<any, any>) {
* const name = atom('name', 'Bob')
* react('greeting', () => {
* whyAmIRunning()
* console.log('Hello', name.value)
* print('Hello', name.value)
* })
*
* name.set('Alice')

View file

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

View file

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

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

View file

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

View file

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

View file

@ -495,7 +495,7 @@ export class ArrowShapeUtil extends ShapeUtil<TLArrowShape> {
// eslint-disable-next-line react-hooks/rules-of-hooks
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
}, [shape])

View file

@ -8,7 +8,10 @@ export class Idle extends StateNode {
}
override onEnter = () => {
this.editor.updateInstanceState({ cursor: { type: 'cross', rotation: 0 } }, true)
this.editor.updateInstanceState(
{ cursor: { type: 'cross', rotation: 0 } },
{ ephemeral: true, squashing: true }
)
}
override onCancel = () => {

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

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

View file

@ -8,7 +8,10 @@ export class Idle extends StateNode {
}
override onEnter = () => {
this.editor.updateInstanceState({ cursor: { type: 'cross', rotation: 0 } }, true)
this.editor.updateInstanceState(
{ cursor: { type: 'cross', rotation: 0 } },
{ ephemeral: true, squashing: true }
)
}
override onCancel = () => {

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -7,7 +7,10 @@ export class Idle extends StateNode {
override onEnter = (info: { shapeId: TLShapeId }) => {
this.shapeId = info.shapeId
this.editor.updateInstanceState({ cursor: { type: 'cross', rotation: 0 } }, true)
this.editor.updateInstanceState(
{ cursor: { type: 'cross', rotation: 0 } },
{ ephemeral: true, squashing: true }
)
}
override onPointerDown: TLEventHandlers['onPointerDown'] = () => {

View file

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

View file

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

View file

@ -8,7 +8,10 @@ export class Idle extends StateNode {
}
override onEnter = () => {
this.editor.updateInstanceState({ cursor: { type: 'cross', rotation: 0 } }, true)
this.editor.updateInstanceState(
{ cursor: { type: 'cross', rotation: 0 } },
{ ephemeral: true, squashing: true }
)
}
override onCancel = () => {

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -4,7 +4,10 @@ export class Idle extends StateNode {
static override id = 'idle'
override onEnter = () => {
this.editor.updateInstanceState({ cursor: { type: 'grab', rotation: 0 } }, true)
this.editor.updateInstanceState(
{ cursor: { type: 'grab', rotation: 0 } },
{ ephemeral: true, squashing: true }
)
}
override onPointerDown: TLEventHandlers['onPointerDown'] = (info) => {

View file

@ -5,7 +5,10 @@ export class Pointing extends StateNode {
override onEnter = () => {
this.editor.stopCameraAnimation()
this.editor.updateInstanceState({ cursor: { type: 'grabbing', rotation: 0 } }, true)
this.editor.updateInstanceState(
{ cursor: { type: 'grabbing', rotation: 0 } },
{ ephemeral: true, squashing: true }
)
}
override onPointerMove: TLEventHandlers['onPointerMove'] = (info) => {

View file

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

View file

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

View file

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

View file

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

View file

@ -25,14 +25,20 @@ export class TranslatingCrop extends StateNode {
) => {
this.info = info
this.snapshot = this.createSnapshot()
this.editor.mark(this.markId)
this.editor.updateInstanceState({ cursor: { type: 'move', rotation: 0 } }, true)
this.editor.updateInstanceState(
{ cursor: { type: 'move', rotation: 0 } },
{ ephemeral: true, squashing: true }
)
this.updateShapes()
}
override onExit = () => {
this.editor.updateInstanceState({ cursor: { type: 'default', rotation: 0 } }, true)
this.editor.updateInstanceState(
{ cursor: { type: 'default', rotation: 0 } },
{ ephemeral: true, squashing: true }
)
}
override onPointerMove = () => {
@ -99,7 +105,7 @@ export class TranslatingCrop extends StateNode {
const partial = getTranslateCroppedImageChange(this.editor, shape, delta)
if (partial) {
this.editor.updateShapes([partial], true)
this.editor.updateShapes([partial], { squashing: true })
}
}
}

View file

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

View file

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

View file

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

View file

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

View file

@ -29,11 +29,14 @@ export class PointingCropHandle extends StateNode {
if (!selectedShape) return
this.updateCursor(selectedShape)
this.editor.setCroppingId(selectedShape.id)
this.editor.setCroppingShapeId(selectedShape.id)
}
override onExit = () => {
this.editor.updateInstanceState({ cursor: { type: 'default', rotation: 0 } }, true)
this.editor.updateInstanceState(
{ cursor: { type: 'default', rotation: 0 } },
{ ephemeral: true, squashing: true }
)
this.parent.currentToolIdMask = undefined
}
@ -52,7 +55,7 @@ export class PointingCropHandle extends StateNode {
if (this.info.onInteractionEnd) {
this.editor.setCurrentTool(this.info.onInteractionEnd, this.info)
} else {
this.editor.setCroppingId(null)
this.editor.setCroppingShapeId(null)
this.parent.transition('idle', {})
}
}
@ -73,7 +76,7 @@ export class PointingCropHandle extends StateNode {
if (this.info.onInteractionEnd) {
this.editor.setCurrentTool(this.info.onInteractionEnd, this.info)
} else {
this.editor.setCroppingId(null)
this.editor.setCroppingShapeId(null)
this.parent.transition('idle', {})
}
}

View file

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

View file

@ -28,7 +28,10 @@ export class PointingRotateHandle extends StateNode {
override onExit = () => {
this.parent.currentToolIdMask = undefined
this.editor.updateInstanceState({ cursor: { type: 'default', rotation: 0 } }, true)
this.editor.updateInstanceState(
{ cursor: { type: 'default', rotation: 0 } },
{ ephemeral: true, squashing: true }
)
}
override onPointerMove = () => {

View file

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

View file

@ -29,7 +29,8 @@ export class Rotating extends StateNode {
this.info = info
this.parent.currentToolIdMask = info.onInteractionEnd
this.markId = this.editor.mark('rotate start')
this.markId = 'rotate start'
this.editor.mark(this.markId)
const snapshot = getRotationSnapshot({ editor: this.editor })
if (!snapshot) return this.parent.transition('idle', this.info)
@ -40,7 +41,10 @@ export class Rotating extends StateNode {
}
override onExit = () => {
this.editor.updateInstanceState({ cursor: { type: 'none', rotation: 0 } }, true)
this.editor.updateInstanceState(
{ cursor: { type: 'none', rotation: 0 } },
{ ephemeral: true, squashing: true }
)
this.parent.currentToolIdMask = undefined
this.snapshot = {} as TLRotationSnapshot

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -21,7 +21,7 @@ export function useExportAs() {
format: TLExportType = 'png'
) {
if (ids.length === 0) {
ids = [...editor.shapeIdsOnCurrentPage]
ids = [...editor.currentPageShapeIds]
}
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 { useEffect } from 'react'
import { useActions } from './useActions'
@ -26,12 +26,12 @@ export function useKeyboardShortcuts() {
useEffect(() => {
if (!isFocused) return
const scope = uniqueId()
const container = editor.getContainer()
hotkeys.setScope(editor.store.id)
const hot = (keys: string, callback: (event: KeyboardEvent) => void) => {
hotkeys(keys, { element: container, scope: editor.store.id }, callback)
hotkeys(keys, { element: container, scope }, callback)
}
// Add hotkeys for actions and tools.
@ -65,8 +65,11 @@ export function useKeyboardShortcuts() {
})
}
// When focused, hotkeys should only respond to kbds in this scope
hotkeys.setScope(scope)
return () => {
hotkeys.deleteScope(editor.store.id)
hotkeys.deleteScope(scope)
}
}, [actions, tools, isReadonly, editor, isFocused])
}

View file

@ -60,7 +60,7 @@ export function TLUiMenuSchemaProvider({ overrides, children }: TLUiMenuSchemaPr
[editor]
)
const emptyPage = useValue('emptyPage', () => editor.shapeIdsOnCurrentPage.size === 0, [editor])
const emptyPage = useValue('emptyPage', () => editor.currentPageShapeIds.size === 0, [editor])
const selectedCount = useValue('selectedCount', () => editor.selectedShapeIds.length, [editor])
const noneSelected = selectedCount === 0

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -219,7 +219,10 @@ describe('<TldrawEditor />', () => {
expect(editor).toBeTruthy()
await act(async () => {
editor.updateInstanceState({ screenBounds: { x: 0, y: 0, w: 1080, h: 720 } }, true, true)
editor.updateInstanceState(
{ screenBounds: { x: 0, y: 0, w: 1080, h: 720 } },
{ ephemeral: true, squashing: true }
)
})
const id = createShapeId()
@ -340,7 +343,10 @@ describe('Custom shapes', () => {
expect(editor).toBeTruthy()
await act(async () => {
editor.updateInstanceState({ screenBounds: { x: 0, y: 0, w: 1080, h: 720 } }, true, true)
editor.updateInstanceState(
{ screenBounds: { x: 0, y: 0, w: 1080, h: 720 } },
{ ephemeral: true, squashing: true }
)
})
expect(editor.shapeUtils.card).toBeTruthy()

View file

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

View file

@ -36,7 +36,7 @@ describe('Making an arrow on the page', () => {
editor.setCurrentTool('arrow')
editor.pointerMove(0, 0)
editor.pointerDown()
expect(editor.shapesOnCurrentPage.length).toBe(1)
expect(editor.currentPageShapes.length).toBe(1)
})
it('cleans up the arrow if the user did not start dragging', () => {
@ -44,24 +44,24 @@ describe('Making an arrow on the page', () => {
editor.setCurrentTool('arrow')
editor.pointerMove(0, 0)
editor.click()
expect(editor.shapesOnCurrentPage.length).toBe(0)
expect(editor.currentPageShapes.length).toBe(0)
// with double click
editor.setCurrentTool('arrow')
editor.pointerMove(0, 0)
editor.doubleClick()
expect(editor.shapesOnCurrentPage.length).toBe(0)
expect(editor.currentPageShapes.length).toBe(0)
// with pointer up
editor.setCurrentTool('arrow')
editor.pointerDown()
editor.pointerUp()
expect(editor.shapesOnCurrentPage.length).toBe(0)
expect(editor.currentPageShapes.length).toBe(0)
// did not add it to the history stack
editor.undo()
expect(editor.shapesOnCurrentPage.length).toBe(0)
expect(editor.currentPageShapes.length).toBe(0)
editor.redo()
editor.redo()
expect(editor.shapesOnCurrentPage.length).toBe(0)
expect(editor.currentPageShapes.length).toBe(0)
})
it('keeps the arrow if the user dragged', () => {
@ -75,7 +75,7 @@ describe('Making an arrow on the page', () => {
editor.setCurrentTool('arrow')
editor.pointerDown(0, 0)
editor.pointerMove(100, 0)
const arrow1 = editor.shapesOnCurrentPage[0]
const arrow1 = editor.currentPageShapes[0]
expect(arrow()).toMatchObject({
type: 'arrow',
@ -262,25 +262,25 @@ describe('When starting an arrow inside of multiple shapes', () => {
it('does not create the arrow immediately', () => {
editor.setCurrentTool('arrow')
editor.pointerDown(50, 50)
expect(editor.shapesOnCurrentPage.length).toBe(1)
expect(editor.currentPageShapes.length).toBe(1)
expect(arrow()).toBe(null)
})
it('does not create a shape if pointer up before drag', () => {
editor.setCurrentTool('arrow')
editor.pointerDown(50, 50)
expect(editor.shapesOnCurrentPage.length).toBe(1)
expect(editor.currentPageShapes.length).toBe(1)
editor.pointerUp(50, 50)
expect(editor.shapesOnCurrentPage.length).toBe(1)
expect(editor.currentPageShapes.length).toBe(1)
})
it('creates the arrow after a drag, bound to the shape', () => {
editor.setCurrentTool('arrow')
editor.pointerDown(50, 50)
expect(editor.shapesOnCurrentPage.length).toBe(1)
expect(editor.currentPageShapes.length).toBe(1)
expect(arrow()).toBe(null)
editor.pointerMove(55, 50)
expect(editor.shapesOnCurrentPage.length).toBe(2)
expect(editor.currentPageShapes.length).toBe(2)
expect(arrow()).toMatchObject({
x: 50,
y: 50,
@ -308,10 +308,10 @@ describe('When starting an arrow inside of multiple shapes', () => {
it('always creates the arrow with an imprecise start point', () => {
editor.setCurrentTool('arrow')
editor.pointerDown(20, 20) // upper left
expect(editor.shapesOnCurrentPage.length).toBe(1)
expect(editor.currentPageShapes.length).toBe(1)
expect(arrow()).toBe(null)
editor.pointerMove(25, 20)
expect(editor.shapesOnCurrentPage.length).toBe(2)
expect(editor.currentPageShapes.length).toBe(2)
expect(arrow()).toMatchObject({
x: 20,
y: 20,
@ -340,11 +340,11 @@ describe('When starting an arrow inside of multiple shapes', () => {
it('after a pause before drag, creates an arrow with a precise start point', () => {
editor.setCurrentTool('arrow')
editor.pointerDown(20, 20) // upper left
expect(editor.shapesOnCurrentPage.length).toBe(1)
expect(editor.currentPageShapes.length).toBe(1)
expect(arrow()).toBe(null)
jest.advanceTimersByTime(1000)
editor.pointerMove(25, 20)
expect(editor.shapesOnCurrentPage.length).toBe(2)
expect(editor.currentPageShapes.length).toBe(2)
expect(arrow()).toMatchObject({
x: 20,
y: 20,
@ -383,10 +383,10 @@ describe('When starting an arrow inside of multiple shapes', () => {
editor.setCurrentTool('arrow')
editor.pointerDown(25, 25)
expect(editor.shapesOnCurrentPage.length).toBe(2)
expect(editor.currentPageShapes.length).toBe(2)
expect(arrow()).toBe(null)
editor.pointerMove(30, 30)
expect(editor.shapesOnCurrentPage.length).toBe(3)
expect(editor.currentPageShapes.length).toBe(3)
expect(arrow()).toMatchObject({
x: 25,
y: 25,
@ -403,8 +403,8 @@ describe('When starting an arrow inside of multiple shapes', () => {
type: 'binding',
boundShapeId: ids.box2,
normalizedAnchor: {
x: 0.6,
y: 0.6,
x: 0.55,
y: 0.5,
},
},
},
@ -417,10 +417,10 @@ describe('When starting an arrow inside of multiple shapes', () => {
editor.setCurrentTool('arrow')
editor.pointerDown(25, 25)
expect(editor.shapesOnCurrentPage.length).toBe(2)
expect(editor.currentPageShapes.length).toBe(2)
expect(arrow()).toBe(null)
editor.pointerMove(30, 30)
expect(editor.shapesOnCurrentPage.length).toBe(3)
expect(editor.currentPageShapes.length).toBe(3)
expect(arrow()).toMatchObject({
x: 25,
y: 25,
@ -437,8 +437,8 @@ describe('When starting an arrow inside of multiple shapes', () => {
type: 'binding',
boundShapeId: ids.box2,
normalizedAnchor: {
x: 0.6,
y: 0.6,
x: 0.55,
y: 0.5,
},
},
},
@ -462,10 +462,10 @@ describe('When starting an arrow inside of multiple shapes', () => {
editor.setCurrentTool('arrow')
editor.pointerDown(25, 25)
expect(editor.shapesOnCurrentPage.length).toBe(2)
expect(editor.currentPageShapes.length).toBe(2)
expect(arrow()).toBe(null)
editor.pointerMove(30, 30)
expect(editor.shapesOnCurrentPage.length).toBe(3)
expect(editor.currentPageShapes.length).toBe(3)
expect(arrow()).toMatchObject({
x: 25,
y: 25,
@ -498,10 +498,10 @@ describe('When starting an arrow inside of multiple shapes', () => {
editor.setCurrentTool('arrow')
editor.pointerDown(25, 25)
expect(editor.shapesOnCurrentPage.length).toBe(2)
expect(editor.currentPageShapes.length).toBe(2)
expect(arrow()).toBe(null)
editor.pointerMove(30, 30)
expect(editor.shapesOnCurrentPage.length).toBe(3)
expect(editor.currentPageShapes.length).toBe(3)
expect(arrow()).toMatchObject({
x: 25,
y: 25,
@ -518,11 +518,87 @@ describe('When starting an arrow inside of multiple shapes', () => {
type: 'binding',
boundShapeId: ids.box2,
normalizedAnchor: {
x: 0.6,
y: 0.6,
// kicked over because it was too close to the center, and we can't have both bound there
x: 0.55,
y: 0.5,
},
},
},
})
editor.pointerMove(35, 35)
expect(editor.currentPageShapes.length).toBe(3)
expect(arrow()).toMatchObject({
x: 25,
y: 25,
props: {
start: {
type: 'binding',
boundShapeId: ids.box2,
normalizedAnchor: {
x: 0.5,
y: 0.5,
},
},
end: {
type: 'binding',
boundShapeId: ids.box2,
normalizedAnchor: {
// kicked over because it was too close to the center, and we can't have both bound there
x: 0.7,
y: 0.7,
},
},
},
})
})
})
describe('When deleting shapes with bound arrows', () => {
beforeEach(() => {
editor.createShapes([
{ id: ids.box1, type: 'geo', x: 0, y: 0, props: { w: 100, h: 100, fill: 'solid' } },
])
editor.createShapes([
{ id: ids.box2, type: 'geo', x: 200, y: 0, props: { w: 100, h: 100, fill: 'solid' } },
])
})
it('also removes the binding', () => {
function arrow() {
return editor.currentPageShapes.find((s) => s.type === 'arrow') as TLArrowShape
}
editor
// create arrow from box1 to box2
.setCurrentTool('arrow')
.pointerMove(50, 50)
editor.history.clear()
editor.pointerDown()
expect(editor.history._undos.value.length).toBe(0)
editor.pointerMove(250, 50)
editor.pointerUp()
expect(arrow().props.end.type).toBe('binding')
// select box2
editor.click(275, 25)
editor.mark('deleting')
editor.deleteShapes([ids.box2])
expect(arrow().props.end.type).toBe('point')
editor.undo()
expect(arrow().props.end.type).toBe('binding')
editor.redo()
expect(arrow().props.end.type).toBe('point')
})
})

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