diff --git a/packages/editor/api-report.md b/packages/editor/api-report.md index 5ae3653b4..921d33d13 100644 --- a/packages/editor/api-report.md +++ b/packages/editor/api-report.md @@ -592,7 +592,6 @@ export class Editor extends EventEmitter { }): this; bail(): this; bailToMark(id: string): this; - batch(fn: () => void, opts?: TLHistoryBatchOptions): this; bringForward(shapes: TLShape[] | TLShapeId[]): this; bringToFront(shapes: TLShape[] | TLShapeId[]): this; cancel(): this; @@ -893,7 +892,7 @@ export class Editor extends EventEmitter { shapeUtils: { readonly [K in string]?: ShapeUtil; }; - readonly sideEffects: SideEffectManager; + readonly sideEffects: SideEffectManager; slideCamera(opts?: { speed: number; direction: VecLike; @@ -920,9 +919,9 @@ export class Editor extends EventEmitter { updateAssets(assets: TLAssetPartial[]): this; updateCurrentPageState(partial: Partial>, historyOptions?: TLHistoryBatchOptions): this; // (undocumented) - _updateCurrentPageState: (partial: Partial>, historyOptions?: TLHistoryBatchOptions) => void; + _updateCurrentPageState: (partial: Partial>, options?: TLHistoryBatchOptions) => void; updateDocumentSettings(settings: Partial): this; - updateInstanceState(partial: Partial>, historyOptions?: TLHistoryBatchOptions): this; + updateInstanceState(partial: Partial>, options?: TLHistoryBatchOptions): this; updatePage(partial: RequiredKeys): this; // @internal updateRenderingBounds(): this; @@ -1202,8 +1201,6 @@ export class HistoryManager { // (undocumented) bailToMark: (id: string) => this; // (undocumented) - batch: (fn: () => void, opts?: TLHistoryBatchOptions) => this; - // (undocumented) clear(): void; // @internal (undocumented) debug(): { @@ -1213,7 +1210,7 @@ export class HistoryManager { diff: RecordsDiff; isEmpty: boolean; }; - state: HistoryRecorderState; + state: TLHistoryMode; }; // (undocumented) readonly dispose: () => void; @@ -1223,14 +1220,16 @@ export class HistoryManager { getNumUndos(): number; // (undocumented) ignore(fn: () => void): this; - // @internal (undocumented) - _isInBatch: boolean; // (undocumented) mark: (id?: string) => string; // (undocumented) - onBatchComplete: () => void; + record(fn: () => void): this; + // (undocumented) + recordPreservingRedoStack(fn: () => void): this; // (undocumented) redo: () => this | undefined; + // (undocumented) + runInMode(mode: null | TLHistoryMode | undefined, fn: () => void): this; // @internal (undocumented) stacks: Atom< { undos: Stack>; @@ -1745,45 +1744,42 @@ export class SharedStyleMap extends ReadonlySharedStyleMap { export function shortAngleDist(a0: number, a1: number): number; // @public -export class SideEffectManager void; - }; -}> { - constructor(editor: CTX); - // (undocumented) - editor: CTX; +export class SideEffectManager { + constructor(store: Store); // @internal register(handlersByType: { - [R in TLRecord as R['typeName']]?: { - beforeCreate?: TLBeforeCreateHandler; - afterCreate?: TLAfterCreateHandler; - beforeChange?: TLBeforeChangeHandler; - afterChange?: TLAfterChangeHandler; - beforeDelete?: TLBeforeDeleteHandler; - afterDelete?: TLAfterDeleteHandler; + [T in R as T['typeName']]?: { + beforeCreate?: TLBeforeCreateHandler; + afterCreate?: TLAfterCreateHandler; + beforeChange?: TLBeforeChangeHandler; + afterChange?: TLAfterChangeHandler; + beforeDelete?: TLBeforeDeleteHandler; + afterDelete?: TLAfterDeleteHandler; }; + } & { + complete?: TLCompleteHandler; }): () => void; - registerAfterChangeHandler(typeName: T, handler: TLAfterChangeHandler(typeName: T, handler: TLAfterChangeHandler): () => void; - registerAfterCreateHandler(typeName: T, handler: TLAfterCreateHandler(typeName: T, handler: TLAfterCreateHandler): () => void; - registerAfterDeleteHandler(typeName: T, handler: TLAfterDeleteHandler(typeName: T, handler: TLAfterDeleteHandler): () => void; - registerBatchCompleteHandler(handler: TLBatchCompleteHandler): () => void; - registerBeforeChangeHandler(typeName: T, handler: TLBeforeChangeHandler(typeName: T, handler: TLBeforeChangeHandler): () => void; - registerBeforeCreateHandler(typeName: T, handler: TLBeforeCreateHandler(typeName: T, handler: TLBeforeCreateHandler): () => void; - registerBeforeDeleteHandler(typeName: T, handler: TLBeforeDeleteHandler(typeName: T, handler: TLBeforeDeleteHandler): () => void; + registerCompleteHandler(handler: TLCompleteHandler): () => void; + // (undocumented) + readonly store: Store; } export { Signal } @@ -1945,13 +1941,13 @@ export interface SvgExportDef { export const TAB_ID: string; // @public (undocumented) -export type TLAfterChangeHandler = (prev: R, next: R, source: 'remote' | 'user') => void; +export type TLAfterChangeHandler = (prev: R, next: R, source: 'remote' | 'user') => void; // @public (undocumented) -export type TLAfterCreateHandler = (record: R, source: 'remote' | 'user') => void; +export type TLAfterCreateHandler = (record: R, source: 'remote' | 'user') => void; // @public (undocumented) -export type TLAfterDeleteHandler = (record: R, source: 'remote' | 'user') => void; +export type TLAfterDeleteHandler = (record: R, source: 'remote' | 'user') => void; // @public (undocumented) export type TLAnimationOptions = Partial<{ @@ -2022,16 +2018,13 @@ export interface TLBaseEventInfo { } // @public (undocumented) -export type TLBatchCompleteHandler = () => void; +export type TLBeforeChangeHandler = (prev: R, next: R, source: 'remote' | 'user') => R; // @public (undocumented) -export type TLBeforeChangeHandler = (prev: R, next: R, source: 'remote' | 'user') => R; +export type TLBeforeCreateHandler = (record: R, source: 'remote' | 'user') => R; // @public (undocumented) -export type TLBeforeCreateHandler = (record: R, source: 'remote' | 'user') => R; - -// @public (undocumented) -export type TLBeforeDeleteHandler = (record: R, source: 'remote' | 'user') => false | void; +export type TLBeforeDeleteHandler = (record: R, source: 'remote' | 'user') => false | void; // @public (undocumented) export type TLBrushProps = { @@ -2232,8 +2225,6 @@ export interface TLEventMap { mount: []; // (undocumented) tick: [number]; - // (undocumented) - update: []; } // @public (undocumented) diff --git a/packages/editor/api/api.json b/packages/editor/api/api.json index f28f84392..96e5af5a9 100644 --- a/packages/editor/api/api.json +++ b/packages/editor/api/api.json @@ -7478,7 +7478,7 @@ }, { "kind": "Content", - "text": ", 'selectedShapeIds'>>, historyOptions?: " + "text": ", 'selectedShapeIds'>>, options?: " }, { "kind": "Reference", @@ -8010,71 +8010,6 @@ "isAbstract": false, "name": "bailToMark" }, - { - "kind": "Method", - "canonicalReference": "@tldraw/editor!Editor#batch:member(1)", - "docComment": "/**\n * Run a function in a batch, which will be undone/redone as a single action.\n *\n * @example\n * ```ts\n * editor.batch(() => {\n * \teditor.selectAll()\n * \teditor.deleteShapes(editor.getSelectedShapeIds())\n * \teditor.createShapes(myShapes)\n * \teditor.selectNone()\n * })\n *\n * editor.undo() // will undo all of the above\n * ```\n *\n * @public\n */\n", - "excerptTokens": [ - { - "kind": "Content", - "text": "batch(fn: " - }, - { - "kind": "Content", - "text": "() => void" - }, - { - "kind": "Content", - "text": ", opts?: " - }, - { - "kind": "Reference", - "text": "TLHistoryBatchOptions", - "canonicalReference": "@tldraw/editor!~TLHistoryBatchOptions:interface" - }, - { - "kind": "Content", - "text": "): " - }, - { - "kind": "Content", - "text": "this" - }, - { - "kind": "Content", - "text": ";" - } - ], - "isStatic": false, - "returnTypeTokenRange": { - "startIndex": 5, - "endIndex": 6 - }, - "releaseTag": "Public", - "isProtected": false, - "overloadIndex": 1, - "parameters": [ - { - "parameterName": "fn", - "parameterTypeTokenRange": { - "startIndex": 1, - "endIndex": 2 - }, - "isOptional": false - }, - { - "parameterName": "opts", - "parameterTypeTokenRange": { - "startIndex": 3, - "endIndex": 4 - }, - "isOptional": true - } - ], - "isOptional": false, - "isAbstract": false, - "name": "batch" - }, { "kind": "Method", "canonicalReference": "@tldraw/editor!Editor#bringForward:member(1)", @@ -15410,7 +15345,7 @@ { "kind": "Method", "canonicalReference": "@tldraw/editor!Editor#nudgeShapes:member(1)", - "docComment": "/**\n * Move shapes by a delta.\n *\n * @param shapes - The shapes (or shape ids) to move.\n *\n * @param direction - The direction in which to move the shapes.\n *\n * @param historyOptions - The history options for the change.\n *\n * @example\n * ```ts\n * editor.nudgeShapes(['box1', 'box2'], { x: 8, y: 8 })\n * ```\n *\n */\n", + "docComment": "/**\n * Move shapes by a delta.\n *\n * @param shapes - The shapes (or shape ids) to move.\n *\n * @param direction - The direction in which to move the shapes.\n *\n * @example\n * ```ts\n * editor.nudgeShapes(['box1', 'box2'], { x: 8, y: 8 })\n * ```\n *\n */\n", "excerptTokens": [ { "kind": "Content", @@ -18058,7 +17993,16 @@ }, { "kind": "Content", - "text": "" + "text": "<" + }, + { + "kind": "Reference", + "text": "TLRecord", + "canonicalReference": "@tldraw/tlschema!TLRecord:type" + }, + { + "kind": "Content", + "text": ">" }, { "kind": "Content", @@ -18071,7 +18015,7 @@ "name": "sideEffects", "propertyTypeTokenRange": { "startIndex": 1, - "endIndex": 3 + "endIndex": 5 }, "isStatic": false, "isProtected": false, @@ -18997,7 +18941,7 @@ }, { "kind": "Content", - "text": ", historyOptions?: " + "text": ", options?: " }, { "kind": "Reference", @@ -19035,7 +18979,7 @@ "isOptional": false }, { - "parameterName": "historyOptions", + "parameterName": "options", "parameterTypeTokenRange": { "startIndex": 8, "endIndex": 9 @@ -23840,45 +23784,6 @@ "isProtected": false, "isAbstract": false }, - { - "kind": "Property", - "canonicalReference": "@tldraw/editor!HistoryManager#batch:member", - "docComment": "", - "excerptTokens": [ - { - "kind": "Content", - "text": "batch: " - }, - { - "kind": "Content", - "text": "(fn: () => void, opts?: " - }, - { - "kind": "Reference", - "text": "TLHistoryBatchOptions", - "canonicalReference": "@tldraw/editor!~TLHistoryBatchOptions:interface" - }, - { - "kind": "Content", - "text": ") => this" - }, - { - "kind": "Content", - "text": ";" - } - ], - "isReadonly": false, - "isOptional": false, - "releaseTag": "Public", - "name": "batch", - "propertyTypeTokenRange": { - "startIndex": 1, - "endIndex": 4 - }, - "isStatic": false, - "isProtected": false, - "isAbstract": false - }, { "kind": "Method", "canonicalReference": "@tldraw/editor!HistoryManager#clear:member(1)", @@ -24081,34 +23986,100 @@ "isAbstract": false }, { - "kind": "Property", - "canonicalReference": "@tldraw/editor!HistoryManager#onBatchComplete:member", + "kind": "Method", + "canonicalReference": "@tldraw/editor!HistoryManager#record:member(1)", "docComment": "", "excerptTokens": [ { "kind": "Content", - "text": "onBatchComplete: " + "text": "record(fn: " }, { "kind": "Content", "text": "() => void" }, + { + "kind": "Content", + "text": "): " + }, + { + "kind": "Content", + "text": "this" + }, { "kind": "Content", "text": ";" } ], - "isReadonly": false, - "isOptional": false, - "releaseTag": "Public", - "name": "onBatchComplete", - "propertyTypeTokenRange": { - "startIndex": 1, - "endIndex": 2 - }, "isStatic": false, + "returnTypeTokenRange": { + "startIndex": 3, + "endIndex": 4 + }, + "releaseTag": "Public", "isProtected": false, - "isAbstract": false + "overloadIndex": 1, + "parameters": [ + { + "parameterName": "fn", + "parameterTypeTokenRange": { + "startIndex": 1, + "endIndex": 2 + }, + "isOptional": false + } + ], + "isOptional": false, + "isAbstract": false, + "name": "record" + }, + { + "kind": "Method", + "canonicalReference": "@tldraw/editor!HistoryManager#recordPreservingRedoStack:member(1)", + "docComment": "", + "excerptTokens": [ + { + "kind": "Content", + "text": "recordPreservingRedoStack(fn: " + }, + { + "kind": "Content", + "text": "() => void" + }, + { + "kind": "Content", + "text": "): " + }, + { + "kind": "Content", + "text": "this" + }, + { + "kind": "Content", + "text": ";" + } + ], + "isStatic": false, + "returnTypeTokenRange": { + "startIndex": 3, + "endIndex": 4 + }, + "releaseTag": "Public", + "isProtected": false, + "overloadIndex": 1, + "parameters": [ + { + "parameterName": "fn", + "parameterTypeTokenRange": { + "startIndex": 1, + "endIndex": 2 + }, + "isOptional": false + } + ], + "isOptional": false, + "isAbstract": false, + "name": "recordPreservingRedoStack" }, { "kind": "Property", @@ -24140,6 +24111,79 @@ "isProtected": false, "isAbstract": false }, + { + "kind": "Method", + "canonicalReference": "@tldraw/editor!HistoryManager#runInMode:member(1)", + "docComment": "", + "excerptTokens": [ + { + "kind": "Content", + "text": "runInMode(mode: " + }, + { + "kind": "Content", + "text": "null | " + }, + { + "kind": "Reference", + "text": "TLHistoryMode", + "canonicalReference": "@tldraw/editor!~TLHistoryMode:type" + }, + { + "kind": "Content", + "text": " | undefined" + }, + { + "kind": "Content", + "text": ", fn: " + }, + { + "kind": "Content", + "text": "() => void" + }, + { + "kind": "Content", + "text": "): " + }, + { + "kind": "Content", + "text": "this" + }, + { + "kind": "Content", + "text": ";" + } + ], + "isStatic": false, + "returnTypeTokenRange": { + "startIndex": 7, + "endIndex": 8 + }, + "releaseTag": "Public", + "isProtected": false, + "overloadIndex": 1, + "parameters": [ + { + "parameterName": "mode", + "parameterTypeTokenRange": { + "startIndex": 1, + "endIndex": 4 + }, + "isOptional": false + }, + { + "parameterName": "fn", + "parameterTypeTokenRange": { + "startIndex": 5, + "endIndex": 6 + }, + "isOptional": false + } + ], + "isOptional": false, + "isAbstract": false, + "name": "runInMode" + }, { "kind": "Property", "canonicalReference": "@tldraw/editor!HistoryManager#undo:member", @@ -32838,20 +32882,12 @@ "excerptTokens": [ { "kind": "Content", - "text": "export declare class SideEffectManager void;\n };\n}" + "text": "UnknownRecord", + "canonicalReference": "@tldraw/store!UnknownRecord:type" }, { "kind": "Content", @@ -32862,10 +32898,10 @@ "releaseTag": "Public", "typeParameters": [ { - "typeParameterName": "CTX", + "typeParameterName": "R", "constraintTokenRange": { "startIndex": 1, - "endIndex": 4 + "endIndex": 2 }, "defaultTypeTokenRange": { "startIndex": 0, @@ -32884,11 +32920,16 @@ "excerptTokens": [ { "kind": "Content", - "text": "constructor(editor: " + "text": "constructor(store: " + }, + { + "kind": "Reference", + "text": "Store", + "canonicalReference": "@tldraw/store!Store:class" }, { "kind": "Content", - "text": "CTX" + "text": "" }, { "kind": "Content", @@ -32900,72 +32941,15 @@ "overloadIndex": 1, "parameters": [ { - "parameterName": "editor", + "parameterName": "store", "parameterTypeTokenRange": { "startIndex": 1, - "endIndex": 2 + "endIndex": 3 }, "isOptional": false } ] }, - { - "kind": "Property", - "canonicalReference": "@tldraw/editor!SideEffectManager#editor:member", - "docComment": "", - "excerptTokens": [ - { - "kind": "Content", - "text": "editor: " - }, - { - "kind": "Content", - "text": "CTX" - }, - { - "kind": "Content", - "text": ";" - } - ], - "isReadonly": false, - "isOptional": false, - "releaseTag": "Public", - "name": "editor", - "propertyTypeTokenRange": { - "startIndex": 1, - "endIndex": 2 - }, - "isStatic": false, - "isProtected": false, - "isAbstract": false - }, - { - "kind": "PropertySignature", - "canonicalReference": "@tldraw/editor!SideEffectManager#history:member", - "docComment": "", - "excerptTokens": [ - { - "kind": "Content", - "text": "history: " - }, - { - "kind": "Content", - "text": "{\n onBatchComplete: () => void;\n }" - }, - { - "kind": "Content", - "text": ";" - } - ], - "isReadonly": false, - "isOptional": false, - "releaseTag": "Public", - "name": "history", - "propertyTypeTokenRange": { - "startIndex": 1, - "endIndex": 2 - } - }, { "kind": "Method", "canonicalReference": "@tldraw/editor!SideEffectManager#registerAfterChangeHandler:member(1)", @@ -32975,14 +32959,9 @@ "kind": "Content", "text": "registerAfterChangeHandler" + "text": "" }, { "kind": "Content", @@ -33032,7 +33002,7 @@ "typeParameterName": "T", "constraintTokenRange": { "startIndex": 1, - "endIndex": 3 + "endIndex": 2 }, "defaultTypeTokenRange": { "startIndex": 0, @@ -33042,8 +33012,8 @@ ], "isStatic": false, "returnTypeTokenRange": { - "startIndex": 11, - "endIndex": 12 + "startIndex": 8, + "endIndex": 9 }, "releaseTag": "Public", "isProtected": false, @@ -33052,16 +33022,16 @@ { "parameterName": "typeName", "parameterTypeTokenRange": { - "startIndex": 4, - "endIndex": 5 + "startIndex": 3, + "endIndex": 4 }, "isOptional": false }, { "parameterName": "handler", "parameterTypeTokenRange": { - "startIndex": 6, - "endIndex": 10 + "startIndex": 5, + "endIndex": 7 }, "isOptional": false } @@ -33079,14 +33049,9 @@ "kind": "Content", "text": "registerAfterCreateHandler" + "text": "" }, { "kind": "Content", @@ -33136,7 +33092,7 @@ "typeParameterName": "T", "constraintTokenRange": { "startIndex": 1, - "endIndex": 3 + "endIndex": 2 }, "defaultTypeTokenRange": { "startIndex": 0, @@ -33146,8 +33102,8 @@ ], "isStatic": false, "returnTypeTokenRange": { - "startIndex": 11, - "endIndex": 12 + "startIndex": 8, + "endIndex": 9 }, "releaseTag": "Public", "isProtected": false, @@ -33156,16 +33112,16 @@ { "parameterName": "typeName", "parameterTypeTokenRange": { - "startIndex": 4, - "endIndex": 5 + "startIndex": 3, + "endIndex": 4 }, "isOptional": false }, { "parameterName": "handler", "parameterTypeTokenRange": { - "startIndex": 6, - "endIndex": 10 + "startIndex": 5, + "endIndex": 7 }, "isOptional": false } @@ -33183,14 +33139,9 @@ "kind": "Content", "text": "registerAfterDeleteHandler" + "text": "" }, { "kind": "Content", @@ -33240,7 +33182,7 @@ "typeParameterName": "T", "constraintTokenRange": { "startIndex": 1, - "endIndex": 3 + "endIndex": 2 }, "defaultTypeTokenRange": { "startIndex": 0, @@ -33250,8 +33192,8 @@ ], "isStatic": false, "returnTypeTokenRange": { - "startIndex": 11, - "endIndex": 12 + "startIndex": 8, + "endIndex": 9 }, "releaseTag": "Public", "isProtected": false, @@ -33260,16 +33202,16 @@ { "parameterName": "typeName", "parameterTypeTokenRange": { - "startIndex": 4, - "endIndex": 5 + "startIndex": 3, + "endIndex": 4 }, "isOptional": false }, { "parameterName": "handler", "parameterTypeTokenRange": { - "startIndex": 6, - "endIndex": 10 + "startIndex": 5, + "endIndex": 7 }, "isOptional": false } @@ -33280,17 +33222,287 @@ }, { "kind": "Method", - "canonicalReference": "@tldraw/editor!SideEffectManager#registerBatchCompleteHandler:member(1)", - "docComment": "/**\n * Register a handler to be called when a store completes a batch.\n *\n * @param handler - The handler to call\n *\n * @example\n * ```ts\n * let count = 0\n *\n * editor.cleanup.registerBatchCompleteHandler(() => count++)\n *\n * editor.selectAll()\n * expect(count).toBe(1)\n *\n * editor.batch(() => {\n * \teditor.selectNone()\n * \teditor.selectAll()\n * })\n *\n * expect(count).toBe(2)\n * ```\n *\n * @public\n */\n", + "canonicalReference": "@tldraw/editor!SideEffectManager#registerBeforeChangeHandler:member(1)", + "docComment": "/**\n * Register a handler to be called before a record is changed. The handler is given the old and new record - you can return a modified record to apply a different update, or the old record to block the update entirely.\n *\n * Use this handler only for intercepting updates to the record itself. If you want to update other records in response to a change, use {@link SideEffectManager.registerAfterChangeHandler} instead.\n *\n * @param typeName - The type of record to listen for\n *\n * @param handler - The handler to call\n *\n * @example\n * ```ts\n * editor.sideEffects.registerBeforeChangeHandler('shape', (prev, next, source) => {\n * if (next.isLocked && !prev.isLocked) {\n * // prevent shapes from ever being locked:\n * return prev\n * }\n * // other types of change are allowed\n * return next\n * })\n * ```\n *\n */\n", "excerptTokens": [ { "kind": "Content", - "text": "registerBatchCompleteHandler(handler: " + "text": "registerBeforeChangeHandler(typeName: " + }, + { + "kind": "Content", + "text": "T" + }, + { + "kind": "Content", + "text": ", handler: " }, { "kind": "Reference", - "text": "TLBatchCompleteHandler", - "canonicalReference": "@tldraw/editor!TLBatchCompleteHandler:type" + "text": "TLBeforeChangeHandler", + "canonicalReference": "@tldraw/editor!TLBeforeChangeHandler:type" + }, + { + "kind": "Content", + "text": "" + }, + { + "kind": "Content", + "text": "): " + }, + { + "kind": "Content", + "text": "() => void" + }, + { + "kind": "Content", + "text": ";" + } + ], + "typeParameters": [ + { + "typeParameterName": "T", + "constraintTokenRange": { + "startIndex": 1, + "endIndex": 2 + }, + "defaultTypeTokenRange": { + "startIndex": 0, + "endIndex": 0 + } + } + ], + "isStatic": false, + "returnTypeTokenRange": { + "startIndex": 8, + "endIndex": 9 + }, + "releaseTag": "Public", + "isProtected": false, + "overloadIndex": 1, + "parameters": [ + { + "parameterName": "typeName", + "parameterTypeTokenRange": { + "startIndex": 3, + "endIndex": 4 + }, + "isOptional": false + }, + { + "parameterName": "handler", + "parameterTypeTokenRange": { + "startIndex": 5, + "endIndex": 7 + }, + "isOptional": false + } + ], + "isOptional": false, + "isAbstract": false, + "name": "registerBeforeChangeHandler" + }, + { + "kind": "Method", + "canonicalReference": "@tldraw/editor!SideEffectManager#registerBeforeCreateHandler:member(1)", + "docComment": "/**\n * Register a handler to be called before a record of a certain type is created. Return a modified record from the handler to change the record that will be created.\n *\n * Use this handle only to modify the creation of the record itself. If you want to trigger a side-effect on a different record (for example, moving one shape when another is created), use {@link SideEffectManager.registerAfterCreateHandler} instead.\n *\n * @param typeName - The type of record to listen for\n *\n * @param handler - The handler to call\n *\n * @example\n * ```ts\n * editor.sideEffects.registerBeforeCreateHandler('shape', (shape, source) => {\n * // only modify shapes created by the user\n * if (source !== 'user') return shape\n *\n * //by default, arrow shapes have no label. Let's make sure they always have a label.\n * if (shape.type === 'arrow') {\n * return {...shape, props: {...shape.props, text: 'an arrow'}}\n * }\n *\n * // other shapes get returned unmodified\n * return shape\n * })\n * ```\n *\n */\n", + "excerptTokens": [ + { + "kind": "Content", + "text": "registerBeforeCreateHandler(typeName: " + }, + { + "kind": "Content", + "text": "T" + }, + { + "kind": "Content", + "text": ", handler: " + }, + { + "kind": "Reference", + "text": "TLBeforeCreateHandler", + "canonicalReference": "@tldraw/editor!TLBeforeCreateHandler:type" + }, + { + "kind": "Content", + "text": "" + }, + { + "kind": "Content", + "text": "): " + }, + { + "kind": "Content", + "text": "() => void" + }, + { + "kind": "Content", + "text": ";" + } + ], + "typeParameters": [ + { + "typeParameterName": "T", + "constraintTokenRange": { + "startIndex": 1, + "endIndex": 2 + }, + "defaultTypeTokenRange": { + "startIndex": 0, + "endIndex": 0 + } + } + ], + "isStatic": false, + "returnTypeTokenRange": { + "startIndex": 8, + "endIndex": 9 + }, + "releaseTag": "Public", + "isProtected": false, + "overloadIndex": 1, + "parameters": [ + { + "parameterName": "typeName", + "parameterTypeTokenRange": { + "startIndex": 3, + "endIndex": 4 + }, + "isOptional": false + }, + { + "parameterName": "handler", + "parameterTypeTokenRange": { + "startIndex": 5, + "endIndex": 7 + }, + "isOptional": false + } + ], + "isOptional": false, + "isAbstract": false, + "name": "registerBeforeCreateHandler" + }, + { + "kind": "Method", + "canonicalReference": "@tldraw/editor!SideEffectManager#registerBeforeDeleteHandler:member(1)", + "docComment": "/**\n * Register a handler to be called before a record is deleted. The handler can return `false` to prevent the deletion.\n *\n * Use this handler only for intercepting deletions of the record itself. If you want to do something to other records in response to a deletion, use {@link SideEffectManager.registerAfterDeleteHandler} instead.\n *\n * @param typeName - The type of record to listen for\n *\n * @param handler - The handler to call\n *\n * @example\n * ```ts\n * editor.sideEffects.registerBeforeDeleteHandler('shape', (shape, source) => {\n * if (shape.props.color === 'red') {\n * // prevent red shapes from being deleted\n * \t return false\n * }\n * })\n * ```\n *\n */\n", + "excerptTokens": [ + { + "kind": "Content", + "text": "registerBeforeDeleteHandler(typeName: " + }, + { + "kind": "Content", + "text": "T" + }, + { + "kind": "Content", + "text": ", handler: " + }, + { + "kind": "Reference", + "text": "TLBeforeDeleteHandler", + "canonicalReference": "@tldraw/editor!TLBeforeDeleteHandler:type" + }, + { + "kind": "Content", + "text": "" + }, + { + "kind": "Content", + "text": "): " + }, + { + "kind": "Content", + "text": "() => void" + }, + { + "kind": "Content", + "text": ";" + } + ], + "typeParameters": [ + { + "typeParameterName": "T", + "constraintTokenRange": { + "startIndex": 1, + "endIndex": 2 + }, + "defaultTypeTokenRange": { + "startIndex": 0, + "endIndex": 0 + } + } + ], + "isStatic": false, + "returnTypeTokenRange": { + "startIndex": 8, + "endIndex": 9 + }, + "releaseTag": "Public", + "isProtected": false, + "overloadIndex": 1, + "parameters": [ + { + "parameterName": "typeName", + "parameterTypeTokenRange": { + "startIndex": 3, + "endIndex": 4 + }, + "isOptional": false + }, + { + "parameterName": "handler", + "parameterTypeTokenRange": { + "startIndex": 5, + "endIndex": 7 + }, + "isOptional": false + } + ], + "isOptional": false, + "isAbstract": false, + "name": "registerBeforeDeleteHandler" + }, + { + "kind": "Method", + "canonicalReference": "@tldraw/editor!SideEffectManager#registerCompleteHandler:member(1)", + "docComment": "/**\n * Register a handler to be called when the store completes an operation.\n *\n * @param handler - The handler to call\n *\n * @example\n * ```ts\n * let count = 0\n *\n * editor.cleanup.registerBatchCompleteHandler(() => count++)\n *\n * editor.selectAll()\n * expect(count).toBe(1)\n *\n * editor.store.atomic(() => {\n * \teditor.selectNone()\n * \teditor.selectAll()\n * })\n *\n * expect(count).toBe(2)\n * ```\n *\n * @public\n */\n", + "excerptTokens": [ + { + "kind": "Content", + "text": "registerCompleteHandler(handler: " + }, + { + "kind": "Reference", + "text": "TLCompleteHandler", + "canonicalReference": "@tldraw/editor!~TLCompleteHandler:type" }, { "kind": "Content", @@ -33325,347 +33537,42 @@ ], "isOptional": false, "isAbstract": false, - "name": "registerBatchCompleteHandler" + "name": "registerCompleteHandler" }, { - "kind": "Method", - "canonicalReference": "@tldraw/editor!SideEffectManager#registerBeforeChangeHandler:member(1)", - "docComment": "/**\n * Register a handler to be called before a record is changed. The handler is given the old and new record - you can return a modified record to apply a different update, or the old record to block the update entirely.\n *\n * Use this handler only for intercepting updates to the record itself. If you want to update other records in response to a change, use {@link SideEffectManager.registerAfterChangeHandler} instead.\n *\n * @param typeName - The type of record to listen for\n *\n * @param handler - The handler to call\n *\n * @example\n * ```ts\n * editor.sideEffects.registerBeforeChangeHandler('shape', (prev, next, source) => {\n * if (next.isLocked && !prev.isLocked) {\n * // prevent shapes from ever being locked:\n * return prev\n * }\n * // other types of change are allowed\n * return next\n * })\n * ```\n *\n */\n", - "excerptTokens": [ - { - "kind": "Content", - "text": "registerBeforeChangeHandler(typeName: " - }, - { - "kind": "Content", - "text": "T" - }, - { - "kind": "Content", - "text": ", handler: " - }, - { - "kind": "Reference", - "text": "TLBeforeChangeHandler", - "canonicalReference": "@tldraw/editor!TLBeforeChangeHandler:type" - }, - { - "kind": "Content", - "text": "<" - }, - { - "kind": "Reference", - "text": "TLRecord", - "canonicalReference": "@tldraw/tlschema!TLRecord:type" - }, - { - "kind": "Content", - "text": " & {\n typeName: T;\n }>" - }, - { - "kind": "Content", - "text": "): " - }, - { - "kind": "Content", - "text": "() => void" - }, - { - "kind": "Content", - "text": ";" - } - ], - "typeParameters": [ - { - "typeParameterName": "T", - "constraintTokenRange": { - "startIndex": 1, - "endIndex": 3 - }, - "defaultTypeTokenRange": { - "startIndex": 0, - "endIndex": 0 - } - } - ], - "isStatic": false, - "returnTypeTokenRange": { - "startIndex": 11, - "endIndex": 12 - }, - "releaseTag": "Public", - "isProtected": false, - "overloadIndex": 1, - "parameters": [ - { - "parameterName": "typeName", - "parameterTypeTokenRange": { - "startIndex": 4, - "endIndex": 5 - }, - "isOptional": false - }, - { - "parameterName": "handler", - "parameterTypeTokenRange": { - "startIndex": 6, - "endIndex": 10 - }, - "isOptional": false - } - ], - "isOptional": false, - "isAbstract": false, - "name": "registerBeforeChangeHandler" - }, - { - "kind": "Method", - "canonicalReference": "@tldraw/editor!SideEffectManager#registerBeforeCreateHandler:member(1)", - "docComment": "/**\n * Register a handler to be called before a record of a certain type is created. Return a modified record from the handler to change the record that will be created.\n *\n * Use this handle only to modify the creation of the record itself. If you want to trigger a side-effect on a different record (for example, moving one shape when another is created), use {@link SideEffectManager.registerAfterCreateHandler} instead.\n *\n * @param typeName - The type of record to listen for\n *\n * @param handler - The handler to call\n *\n * @example\n * ```ts\n * editor.sideEffects.registerBeforeCreateHandler('shape', (shape, source) => {\n * // only modify shapes created by the user\n * if (source !== 'user') return shape\n *\n * //by default, arrow shapes have no label. Let's make sure they always have a label.\n * if (shape.type === 'arrow') {\n * return {...shape, props: {...shape.props, text: 'an arrow'}}\n * }\n *\n * // other shapes get returned unmodified\n * return shape\n * })\n * ```\n *\n */\n", - "excerptTokens": [ - { - "kind": "Content", - "text": "registerBeforeCreateHandler(typeName: " - }, - { - "kind": "Content", - "text": "T" - }, - { - "kind": "Content", - "text": ", handler: " - }, - { - "kind": "Reference", - "text": "TLBeforeCreateHandler", - "canonicalReference": "@tldraw/editor!TLBeforeCreateHandler:type" - }, - { - "kind": "Content", - "text": "<" - }, - { - "kind": "Reference", - "text": "TLRecord", - "canonicalReference": "@tldraw/tlschema!TLRecord:type" - }, - { - "kind": "Content", - "text": " & {\n typeName: T;\n }>" - }, - { - "kind": "Content", - "text": "): " - }, - { - "kind": "Content", - "text": "() => void" - }, - { - "kind": "Content", - "text": ";" - } - ], - "typeParameters": [ - { - "typeParameterName": "T", - "constraintTokenRange": { - "startIndex": 1, - "endIndex": 3 - }, - "defaultTypeTokenRange": { - "startIndex": 0, - "endIndex": 0 - } - } - ], - "isStatic": false, - "returnTypeTokenRange": { - "startIndex": 11, - "endIndex": 12 - }, - "releaseTag": "Public", - "isProtected": false, - "overloadIndex": 1, - "parameters": [ - { - "parameterName": "typeName", - "parameterTypeTokenRange": { - "startIndex": 4, - "endIndex": 5 - }, - "isOptional": false - }, - { - "parameterName": "handler", - "parameterTypeTokenRange": { - "startIndex": 6, - "endIndex": 10 - }, - "isOptional": false - } - ], - "isOptional": false, - "isAbstract": false, - "name": "registerBeforeCreateHandler" - }, - { - "kind": "Method", - "canonicalReference": "@tldraw/editor!SideEffectManager#registerBeforeDeleteHandler:member(1)", - "docComment": "/**\n * Register a handler to be called before a record is deleted. The handler can return `false` to prevent the deletion.\n *\n * Use this handler only for intercepting deletions of the record itself. If you want to do something to other records in response to a deletion, use {@link SideEffectManager.registerAfterDeleteHandler} instead.\n *\n * @param typeName - The type of record to listen for\n *\n * @param handler - The handler to call\n *\n * @example\n * ```ts\n * editor.sideEffects.registerBeforeDeleteHandler('shape', (shape, source) => {\n * if (shape.props.color === 'red') {\n * // prevent red shapes from being deleted\n * \t return false\n * }\n * })\n * ```\n *\n */\n", - "excerptTokens": [ - { - "kind": "Content", - "text": "registerBeforeDeleteHandler(typeName: " - }, - { - "kind": "Content", - "text": "T" - }, - { - "kind": "Content", - "text": ", handler: " - }, - { - "kind": "Reference", - "text": "TLBeforeDeleteHandler", - "canonicalReference": "@tldraw/editor!TLBeforeDeleteHandler:type" - }, - { - "kind": "Content", - "text": "<" - }, - { - "kind": "Reference", - "text": "TLRecord", - "canonicalReference": "@tldraw/tlschema!TLRecord:type" - }, - { - "kind": "Content", - "text": " & {\n typeName: T;\n }>" - }, - { - "kind": "Content", - "text": "): " - }, - { - "kind": "Content", - "text": "() => void" - }, - { - "kind": "Content", - "text": ";" - } - ], - "typeParameters": [ - { - "typeParameterName": "T", - "constraintTokenRange": { - "startIndex": 1, - "endIndex": 3 - }, - "defaultTypeTokenRange": { - "startIndex": 0, - "endIndex": 0 - } - } - ], - "isStatic": false, - "returnTypeTokenRange": { - "startIndex": 11, - "endIndex": 12 - }, - "releaseTag": "Public", - "isProtected": false, - "overloadIndex": 1, - "parameters": [ - { - "parameterName": "typeName", - "parameterTypeTokenRange": { - "startIndex": 4, - "endIndex": 5 - }, - "isOptional": false - }, - { - "parameterName": "handler", - "parameterTypeTokenRange": { - "startIndex": 6, - "endIndex": 10 - }, - "isOptional": false - } - ], - "isOptional": false, - "isAbstract": false, - "name": "registerBeforeDeleteHandler" - }, - { - "kind": "PropertySignature", + "kind": "Property", "canonicalReference": "@tldraw/editor!SideEffectManager#store:member", "docComment": "", "excerptTokens": [ { "kind": "Content", - "text": "store: " + "text": "readonly store: " }, { "kind": "Reference", - "text": "TLStore", - "canonicalReference": "@tldraw/tlschema!TLStore:type" + "text": "Store", + "canonicalReference": "@tldraw/store!Store:class" + }, + { + "kind": "Content", + "text": "" }, { "kind": "Content", "text": ";" } ], - "isReadonly": false, + "isReadonly": true, "isOptional": false, "releaseTag": "Public", "name": "store", "propertyTypeTokenRange": { "startIndex": 1, - "endIndex": 2 - } + "endIndex": 3 + }, + "isStatic": false, + "isProtected": false, + "isAbstract": false } ], "implementsTokenRanges": [] @@ -36168,8 +36075,8 @@ }, { "kind": "Reference", - "text": "TLRecord", - "canonicalReference": "@tldraw/tlschema!TLRecord:type" + "text": "UnknownRecord", + "canonicalReference": "@tldraw/store!UnknownRecord:type" }, { "kind": "Content", @@ -36216,8 +36123,8 @@ }, { "kind": "Reference", - "text": "TLRecord", - "canonicalReference": "@tldraw/tlschema!TLRecord:type" + "text": "UnknownRecord", + "canonicalReference": "@tldraw/store!UnknownRecord:type" }, { "kind": "Content", @@ -36264,8 +36171,8 @@ }, { "kind": "Reference", - "text": "TLRecord", - "canonicalReference": "@tldraw/tlschema!TLRecord:type" + "text": "UnknownRecord", + "canonicalReference": "@tldraw/store!UnknownRecord:type" }, { "kind": "Content", @@ -36853,32 +36760,6 @@ ], "extendsTokenRanges": [] }, - { - "kind": "TypeAlias", - "canonicalReference": "@tldraw/editor!TLBatchCompleteHandler:type", - "docComment": "/**\n * @public\n */\n", - "excerptTokens": [ - { - "kind": "Content", - "text": "export type TLBatchCompleteHandler = " - }, - { - "kind": "Content", - "text": "() => void" - }, - { - "kind": "Content", - "text": ";" - } - ], - "fileUrlPath": "packages/editor/src/lib/editor/managers/SideEffectManager.ts", - "releaseTag": "Public", - "name": "TLBatchCompleteHandler", - "typeTokenRange": { - "startIndex": 1, - "endIndex": 2 - } - }, { "kind": "TypeAlias", "canonicalReference": "@tldraw/editor!TLBeforeChangeHandler:type", @@ -36890,8 +36771,8 @@ }, { "kind": "Reference", - "text": "TLRecord", - "canonicalReference": "@tldraw/tlschema!TLRecord:type" + "text": "UnknownRecord", + "canonicalReference": "@tldraw/store!UnknownRecord:type" }, { "kind": "Content", @@ -36938,8 +36819,8 @@ }, { "kind": "Reference", - "text": "TLRecord", - "canonicalReference": "@tldraw/tlschema!TLRecord:type" + "text": "UnknownRecord", + "canonicalReference": "@tldraw/store!UnknownRecord:type" }, { "kind": "Content", @@ -36986,8 +36867,8 @@ }, { "kind": "Reference", - "text": "TLRecord", - "canonicalReference": "@tldraw/tlschema!TLRecord:type" + "text": "UnknownRecord", + "canonicalReference": "@tldraw/store!UnknownRecord:type" }, { "kind": "Content", @@ -39269,33 +39150,6 @@ "startIndex": 1, "endIndex": 2 } - }, - { - "kind": "PropertySignature", - "canonicalReference": "@tldraw/editor!TLEventMap#update:member", - "docComment": "", - "excerptTokens": [ - { - "kind": "Content", - "text": "update: " - }, - { - "kind": "Content", - "text": "[]" - }, - { - "kind": "Content", - "text": ";" - } - ], - "isReadonly": false, - "isOptional": false, - "releaseTag": "Public", - "name": "update", - "propertyTypeTokenRange": { - "startIndex": 1, - "endIndex": 2 - } } ], "extendsTokenRanges": [] diff --git a/packages/editor/src/index.ts b/packages/editor/src/index.ts index a3b6c5d08..4be256213 100644 --- a/packages/editor/src/index.ts +++ b/packages/editor/src/index.ts @@ -139,7 +139,6 @@ export type { TLAfterChangeHandler, TLAfterCreateHandler, TLAfterDeleteHandler, - TLBatchCompleteHandler, TLBeforeChangeHandler, TLBeforeCreateHandler, TLBeforeDeleteHandler, diff --git a/packages/editor/src/lib/editor/Editor.ts b/packages/editor/src/lib/editor/Editor.ts index 5c4a62bf5..cd6c93e65 100644 --- a/packages/editor/src/lib/editor/Editor.ts +++ b/packages/editor/src/lib/editor/Editor.ts @@ -57,6 +57,7 @@ import { sortByIndex, structuredClone, } from '@tldraw/utils' +import { Number } from 'core-js' import { EventEmitter } from 'eventemitter3' import { flushSync } from 'react-dom' import { createRoot } from 'react-dom/client' @@ -445,35 +446,42 @@ export class Editor extends EventEmitter { return nextPageState } - this.sideEffects = new SideEffectManager(this) - - this.disposables.add( - this.sideEffects.registerBatchCompleteHandler(() => { - for (const parentId of invalidParents) { - invalidParents.delete(parentId) - const parent = this.getShape(parentId) - if (!parent) continue - - const util = this.getShapeUtil(parent) - const changes = util.onChildrenChange?.(parent) - - if (changes?.length) { - this.updateShapes(changes) - } - } - - this.emit('update') - }) - ) + this.sideEffects = new SideEffectManager(this.store) this.disposables.add( this.sideEffects.register({ + complete: (source) => { + if (source !== 'user') return + + for (const parentId of invalidParents) { + invalidParents.delete(parentId) + const parent = this.getShape(parentId) + if (!parent) continue + + const util = this.getShapeUtil(parent) + const changes = util.onChildrenChange?.(parent) + + if (changes?.length) { + this.updateShapes(changes) + } + } + }, shape: { + beforeCreate: (shape, source) => { + if (source !== 'user') return shape + const modified = this.getShapeUtil(shape).onBeforeCreate?.(shape) + return modified ?? shape + }, afterCreate: (record) => { if (this.isShapeOfType(record, 'arrow')) { arrowDidUpdate(record) } }, + beforeChange: (prev, next, source) => { + if (source !== 'user') return next + const modified = this.getShapeUtil(next).onBeforeUpdate?.(prev, next) + return modified ?? next + }, afterChange: (prev, next) => { if (this.isShapeOfType(next, 'arrow')) { arrowDidUpdate(next) @@ -764,7 +772,7 @@ export class Editor extends EventEmitter { * * @public */ - readonly sideEffects: SideEffectManager + readonly sideEffects: SideEffectManager /** * Dispose the editor. @@ -918,28 +926,6 @@ export class Editor extends EventEmitter { return this } - /** - * Run a function in a batch, which will be undone/redone as a single action. - * - * @example - * ```ts - * editor.batch(() => { - * editor.selectAll() - * editor.deleteShapes(editor.getSelectedShapeIds()) - * editor.createShapes(myShapes) - * editor.selectNone() - * }) - * - * editor.undo() // will undo all of the above - * ``` - * - * @public - */ - batch(fn: () => void, opts?: TLHistoryBatchOptions): this { - this.history.batch(fn, opts) - return this - } - /* --------------------- Arrows --------------------- */ // todo: move these to tldraw or replace with a bindings API @@ -1244,9 +1230,9 @@ export class Editor extends EventEmitter { */ updateInstanceState( partial: Partial>, - historyOptions?: TLHistoryBatchOptions + options?: TLHistoryBatchOptions ): this { - this._updateInstanceState(partial, { history: 'ignore', ...historyOptions }) + this._updateInstanceState(partial, { history: 'ignore', ...options }) if (partial.isChangingStyle !== undefined) { clearTimeout(this._isChangingStyleTimeout) @@ -1266,14 +1252,14 @@ export class Editor extends EventEmitter { partial: Partial>, opts?: TLHistoryBatchOptions ) => { - this.batch(() => { + this.history.runInMode(opts?.history, () => { this.store.put([ { ...this.getInstanceState(), ...partial, }, ]) - }, opts) + }) } /** @internal */ @@ -1435,14 +1421,14 @@ export class Editor extends EventEmitter { } _updateCurrentPageState = ( partial: Partial>, - historyOptions?: TLHistoryBatchOptions + options?: TLHistoryBatchOptions ) => { - this.batch(() => { + this.history.runInMode(options?.history, () => { this.store.update(partial.id ?? this.getCurrentPageState().id, (state) => ({ ...state, ...partial, })) - }, historyOptions) + }) } /** @@ -1479,18 +1465,16 @@ export class Editor extends EventEmitter { * @public */ setSelectedShapes(shapes: TLShapeId[] | TLShape[]): this { - return this.batch( - () => { - const ids = shapes.map((shape) => (typeof shape === 'string' ? shape : shape.id)) - const { selectedShapeIds: prevSelectedShapeIds } = this.getCurrentPageState() - const prevSet = new Set(prevSelectedShapeIds) + this.history.recordPreservingRedoStack(() => { + const ids = shapes.map((shape) => (typeof shape === 'string' ? shape : shape.id)) + const { selectedShapeIds: prevSelectedShapeIds } = this.getCurrentPageState() + const prevSet = new Set(prevSelectedShapeIds) - if (ids.length === prevSet.size && ids.every((id) => prevSet.has(id))) return + if (ids.length === prevSet.size && ids.every((id) => prevSet.has(id))) return - this.store.put([{ ...this.getCurrentPageState(), selectedShapeIds: ids }]) - }, - { history: 'record-preserveRedoStack' } - ) + this.store.put([{ ...this.getCurrentPageState(), selectedShapeIds: ids }]) + }) + return this } /** @@ -1747,12 +1731,10 @@ export class Editor extends EventEmitter { if (id === this.getFocusedGroupId()) return this - return this.batch( - () => { - this.store.update(this.getCurrentPageState().id, (s) => ({ ...s, focusedGroupId: id })) - }, - { history: 'record-preserveRedoStack' } - ) + this.history.recordPreservingRedoStack(() => { + this.store.update(this.getCurrentPageState().id, (s) => ({ ...s, focusedGroupId: id })) + }) + return this } /** @@ -2049,43 +2031,41 @@ export class Editor extends EventEmitter { return this } - this.batch(() => { - const camera = { ...currentCamera, ...point } - this.store.put([camera]) // include id and meta here + const camera = { ...currentCamera, ...point } + this.store.put([camera]) // include id and meta here - // Dispatch a new pointer move because the pointer's page will have changed - // (its screen position will compute to a new page position given the new camera position) - const { currentScreenPoint, currentPagePoint } = this.inputs - const { screenBounds } = this.store.unsafeGetWithoutCapture(TLINSTANCE_ID)! + // Dispatch a new pointer move because the pointer's page will have changed + // (its screen position will compute to a new page position given the new camera position) + const { currentScreenPoint, currentPagePoint } = this.inputs + const { screenBounds } = this.store.unsafeGetWithoutCapture(TLINSTANCE_ID)! - // compare the next page point (derived from the curent camera) to the current page point - if ( - currentScreenPoint.x / camera.z - camera.x !== currentPagePoint.x || - currentScreenPoint.y / camera.z - camera.y !== currentPagePoint.y - ) { - // If it's changed, dispatch a pointer event - const event: TLPointerEventInfo = { - type: 'pointer', - target: 'canvas', - name: 'pointer_move', - // weird but true: we need to put the screen point back into client space - point: Vec.AddXY(currentScreenPoint, screenBounds.x, screenBounds.y), - pointerId: INTERNAL_POINTER_IDS.CAMERA_MOVE, - ctrlKey: this.inputs.ctrlKey, - altKey: this.inputs.altKey, - shiftKey: this.inputs.shiftKey, - button: 0, - isPen: this.getInstanceState().isPenMode ?? false, - } - if (immediate) { - this._flushEventForTick(event) - } else { - this.dispatch(event) - } + // compare the next page point (derived from the curent camera) to the current page point + if ( + currentScreenPoint.x / camera.z - camera.x !== currentPagePoint.x || + currentScreenPoint.y / camera.z - camera.y !== currentPagePoint.y + ) { + // If it's changed, dispatch a pointer event + const event: TLPointerEventInfo = { + type: 'pointer', + target: 'canvas', + name: 'pointer_move', + // weird but true: we need to put the screen point back into client space + point: Vec.AddXY(currentScreenPoint, screenBounds.x, screenBounds.y), + pointerId: INTERNAL_POINTER_IDS.CAMERA_MOVE, + ctrlKey: this.inputs.ctrlKey, + altKey: this.inputs.altKey, + shiftKey: this.inputs.shiftKey, + button: 0, + isPen: this.getInstanceState().isPenMode ?? false, } + if (immediate) { + this._flushEventForTick(event) + } else { + this.dispatch(event) + } + } - this._tickCameraState() - }) + this._tickCameraState() return this } @@ -2108,7 +2088,7 @@ export class Editor extends EventEmitter { setCamera(point: VecLike, animation?: TLAnimationOptions): this { const x = Number.isFinite(point.x) ? point.x : 0 const y = Number.isFinite(point.y) ? point.y : 0 - const z = Number.isFinite(point.z) ? point.z! : this.getZoomLevel() + const z = Number.isFinite(point.z!) ? point.z! : this.getZoomLevel() // Stop any camera animations this.stopCameraAnimation() @@ -2620,36 +2600,34 @@ export class Editor extends EventEmitter { if (!presence) return this - this.batch(() => { - // If we're following someone, stop following them - if (this.getInstanceState().followingUserId !== null) { - this.stopFollowingUser() - } + // If we're following someone, stop following them + if (this.getInstanceState().followingUserId !== null) { + this.stopFollowingUser() + } - // If we're not on the same page, move to the page they're on - const isOnSamePage = presence.currentPageId === this.getCurrentPageId() - if (!isOnSamePage) { - this.setCurrentPage(presence.currentPageId) - } + // If we're not on the same page, move to the page they're on + const isOnSamePage = presence.currentPageId === this.getCurrentPageId() + if (!isOnSamePage) { + this.setCurrentPage(presence.currentPageId) + } - // Only animate the camera if the user is on the same page as us - const options = isOnSamePage ? { duration: 500 } : undefined + // Only animate the camera if the user is on the same page as us + const options = isOnSamePage ? { duration: 500 } : undefined - this.centerOnPoint(presence.cursor, options) + this.centerOnPoint(presence.cursor, options) - // Highlight the user's cursor - const { highlightedUserIds } = this.getInstanceState() - this.updateInstanceState({ highlightedUserIds: [...highlightedUserIds, userId] }) + // Highlight the user's cursor + const { highlightedUserIds } = this.getInstanceState() + this.updateInstanceState({ highlightedUserIds: [...highlightedUserIds, userId] }) - // Unhighlight the user's cursor after a few seconds - setTimeout(() => { - const highlightedUserIds = [...this.getInstanceState().highlightedUserIds] - const index = highlightedUserIds.indexOf(userId) - if (index < 0) return - highlightedUserIds.splice(index, 1) - this.updateInstanceState({ highlightedUserIds }) - }, COLLABORATOR_IDLE_TIMEOUT) - }) + // Unhighlight the user's cursor after a few seconds + setTimeout(() => { + const highlightedUserIds = [...this.getInstanceState().highlightedUserIds] + const index = highlightedUserIds.indexOf(userId) + if (index < 0) return + highlightedUserIds.splice(index, 1) + this.updateInstanceState({ highlightedUserIds }) + }, COLLABORATOR_IDLE_TIMEOUT) return this } @@ -3303,10 +3281,11 @@ export class Editor extends EventEmitter { this.stopFollowingUser() - return this.batch( - () => this.store.put([{ ...this.getInstanceState(), currentPageId: pageId }]), - { history: 'record-preserveRedoStack' } + this.history.recordPreservingRedoStack(() => + this.store.put([{ ...this.getInstanceState(), currentPageId: pageId }]) ) + + return this } /** @@ -3327,7 +3306,9 @@ export class Editor extends EventEmitter { const prev = this.getPage(partial.id) if (!prev) return this - return this.batch(() => this.store.update(partial.id, (page) => ({ ...page, ...partial }))) + this.store.update(partial.id, (page) => ({ ...page, ...partial })) + + return this } /** @@ -3344,31 +3325,29 @@ export class Editor extends EventEmitter { * @public */ createPage(page: Partial): this { - this.history.batch(() => { - if (this.getInstanceState().isReadonly) return - if (this.getPages().length >= MAX_PAGES) return - const pages = this.getPages() + if (this.getInstanceState().isReadonly) return this + if (this.getPages().length >= MAX_PAGES) return this + const pages = this.getPages() - const name = getIncrementedName( - page.name ?? 'Page 1', - pages.map((p) => p.name) - ) + const name = getIncrementedName( + page.name ?? 'Page 1', + pages.map((p) => p.name) + ) - let index = page.index + let index = page.index - if (!index || pages.some((p) => p.index === index)) { - index = getIndexAbove(pages[pages.length - 1].index) - } + if (!index || pages.some((p) => p.index === index)) { + index = getIndexAbove(pages[pages.length - 1].index) + } - const newPage = PageRecordType.create({ - meta: {}, - ...page, - name, - index, - }) - - this.store.put([newPage]) + const newPage = PageRecordType.create({ + meta: {}, + ...page, + name, + index, }) + + this.store.put([newPage]) return this } @@ -3386,23 +3365,21 @@ export class Editor extends EventEmitter { */ deletePage(page: TLPageId | TLPage): this { const id = typeof page === 'string' ? page : page.id - this.batch(() => { - if (this.getInstanceState().isReadonly) return - const pages = this.getPages() - if (pages.length === 1) return + if (this.getInstanceState().isReadonly) return this + const pages = this.getPages() + if (pages.length === 1) return this - const deletedPage = this.getPage(id) - if (!deletedPage) return + const deletedPage = this.getPage(id) + if (!deletedPage) return this - if (id === this.getCurrentPageId()) { - const index = pages.findIndex((page) => page.id === id) - const next = pages[index - 1] ?? pages[index + 1] - this.setCurrentPage(next.id) - } + if (id === this.getCurrentPageId()) { + const index = pages.findIndex((page) => page.id === id) + const next = pages[index - 1] ?? pages[index + 1] + this.setCurrentPage(next.id) + } - this.store.remove([deletedPage.id]) - this.updateRenderingBounds() - }) + this.store.remove([deletedPage.id]) + this.updateRenderingBounds() return this } @@ -3423,22 +3400,20 @@ export class Editor extends EventEmitter { const prevCamera = { ...this.getCamera() } const content = this.getContentFromCurrentPage(this.getSortedChildIdsForParent(freshPage.id)) - this.batch(() => { - const pages = this.getPages() - const index = getIndexBetween(freshPage.index, pages[pages.indexOf(freshPage) + 1]?.index) + const pages = this.getPages() + const index = getIndexBetween(freshPage.index, pages[pages.indexOf(freshPage) + 1]?.index) - // create the page (also creates the pagestate and camera for the new page) - this.createPage({ name: freshPage.name + ' Copy', id: createId, index }) - // set the new page as the current page - this.setCurrentPage(createId) - // update the new page's camera to the previous page's camera - this.setCamera(prevCamera) + // create the page (also creates the pagestate and camera for the new page) + this.createPage({ name: freshPage.name + ' Copy', id: createId, index }) + // set the new page as the current page + this.setCurrentPage(createId) + // update the new page's camera to the previous page's camera + this.setCamera(prevCamera) - if (content) { - // If we had content on the previous page, put it on the new page - return this.putContentOntoCurrentPage(content) - } - }) + if (content) { + // If we had content on the previous page, put it on the new page + return this.putContentOntoCurrentPage(content) + } return this } @@ -3494,7 +3469,8 @@ export class Editor extends EventEmitter { createAssets(assets: TLAsset[]): this { if (this.getInstanceState().isReadonly) return this if (assets.length <= 0) return this - return this.batch(() => this.store.put(assets)) + this.store.put(assets) + return this } /** @@ -3512,14 +3488,14 @@ export class Editor extends EventEmitter { updateAssets(assets: TLAssetPartial[]): this { if (this.getInstanceState().isReadonly) return this if (assets.length <= 0) return this - return this.batch(() => { - this.store.put( - assets.map((partial) => ({ - ...this.store.get(partial.id)!, - ...partial, - })) - ) - }) + this.store.put( + assets.map((partial) => ({ + ...this.store.get(partial.id)!, + ...partial, + })) + ) + + return this } /** @@ -3543,7 +3519,8 @@ export class Editor extends EventEmitter { : (assets as TLAsset[]).map((a) => a.id) if (ids.length <= 0) return this - return this.batch(() => this.store.remove(ids)) + this.store.remove(ids) + return this } /** @@ -4898,7 +4875,6 @@ export class Editor extends EventEmitter { * * @param shapes - The shapes (or shape ids) to move. * @param direction - The direction in which to move the shapes. - * @param historyOptions - The history options for the change. */ nudgeShapes(shapes: TLShapeId[] | TLShape[], offset: VecLike): this { const ids = @@ -5081,36 +5057,34 @@ export class Editor extends EventEmitter { } }) - this.history.batch(() => { - const maxShapesReached = - shapesToCreate.length + this.getCurrentPageShapeIds().size > MAX_SHAPES_PER_PAGE + const maxShapesReached = + shapesToCreate.length + this.getCurrentPageShapeIds().size > MAX_SHAPES_PER_PAGE - if (maxShapesReached) { - alertMaxShapes(this) + if (maxShapesReached) { + alertMaxShapes(this) + } + + const newShapes = maxShapesReached + ? shapesToCreate.slice(0, MAX_SHAPES_PER_PAGE - this.getCurrentPageShapeIds().size) + : shapesToCreate + + const newShapeIds = newShapes.map((s) => s.id) + + this.createShapes(newShapes) + this.setSelectedShapes(newShapeIds) + + if (offset !== undefined) { + // If we've offset the duplicated shapes, check to see whether their new bounds is entirely + // contained in the current viewport. If not, then animate the camera to be centered on the + // new shapes. + const selectionPageBounds = this.getSelectionPageBounds() + const viewportPageBounds = this.getViewportPageBounds() + if (selectionPageBounds && !viewportPageBounds.contains(selectionPageBounds)) { + this.centerOnPoint(selectionPageBounds.center, { + duration: ANIMATION_MEDIUM_MS, + }) } - - const newShapes = maxShapesReached - ? shapesToCreate.slice(0, MAX_SHAPES_PER_PAGE - this.getCurrentPageShapeIds().size) - : shapesToCreate - - const ids = newShapes.map((s) => s.id) - - this.createShapes(newShapes) - this.setSelectedShapes(ids) - - if (offset !== undefined) { - // If we've offset the duplicated shapes, check to see whether their new bounds is entirely - // contained in the current viewport. If not, then animate the camera to be centered on the - // new shapes. - const selectionPageBounds = this.getSelectionPageBounds() - const viewportPageBounds = this.getViewportPageBounds() - if (selectionPageBounds && !viewportPageBounds.contains(selectionPageBounds)) { - this.centerOnPoint(selectionPageBounds.center, { - duration: ANIMATION_MEDIUM_MS, - }) - } - } - }) + } return this } @@ -5157,31 +5131,29 @@ export class Editor extends EventEmitter { const fromPageZ = this.getCamera().z - this.history.batch(() => { - // Delete the shapes on the current page - this.deleteShapes(ids) + // Delete the shapes on the current page + this.deleteShapes(ids) - // Move to the next page - this.setCurrentPage(pageId) + // Move to the next page + this.setCurrentPage(pageId) - // Put the shape content onto the new page; parents and indices will - // be taken care of by the putContent method; make sure to pop any focus - // layers so that the content will be put onto the page. - this.setFocusedGroup(null) - this.selectNone() - this.putContentOntoCurrentPage(content, { - select: true, - preserveIds: true, - preservePosition: true, - }) - - // Force the new page's camera to be at the same zoom level as the - // "from" page's camera, then center the "to" page's camera on the - // pasted shapes - this.setCamera({ ...this.getCamera(), z: fromPageZ }) - this.centerOnPoint(this.getSelectionRotatedPageBounds()!.center) + // Put the shape content onto the new page; parents and indices will + // be taken care of by the putContent method; make sure to pop any focus + // layers so that the content will be put onto the page. + this.setFocusedGroup(null) + this.selectNone() + this.putContentOntoCurrentPage(content, { + select: true, + preserveIds: true, + preservePosition: true, }) + // Force the new page's camera to be at the same zoom level as the + // "from" page's camera, then center the "to" page's camera on the + // pasted shapes + this.setCamera({ ...this.getCamera(), z: fromPageZ }) + this.centerOnPoint(this.getSelectionRotatedPageBounds()!.center) + return this } @@ -5214,22 +5186,20 @@ export class Editor extends EventEmitter { } } } - this.batch(() => { - if (allUnlocked) { - this.updateShapes( - shapesToToggle.map((shape) => ({ id: shape.id, type: shape.type, isLocked: true })) - ) - this.setSelectedShapes([]) - } else if (allLocked) { - this.updateShapes( - shapesToToggle.map((shape) => ({ id: shape.id, type: shape.type, isLocked: false })) - ) - } else { - this.updateShapes( - shapesToToggle.map((shape) => ({ id: shape.id, type: shape.type, isLocked: true })) - ) - } - }) + if (allUnlocked) { + this.updateShapes( + shapesToToggle.map((shape) => ({ id: shape.id, type: shape.type, isLocked: true })) + ) + this.setSelectedShapes([]) + } else if (allLocked) { + this.updateShapes( + shapesToToggle.map((shape) => ({ id: shape.id, type: shape.type, isLocked: false })) + ) + } else { + this.updateShapes( + shapesToToggle.map((shape) => ({ id: shape.id, type: shape.type, isLocked: true })) + ) + } return this } @@ -5368,25 +5338,23 @@ export class Editor extends EventEmitter { compact(shapesToFlip.map((id) => this.getShapePageBounds(id))) ).center - this.batch(() => { - for (const shape of shapesToFlip) { - const bounds = this.getShapeGeometry(shape).bounds - const initialPageTransform = this.getShapePageTransform(shape.id) - if (!initialPageTransform) continue - this.resizeShape( - shape.id, - { x: operation === 'horizontal' ? -1 : 1, y: operation === 'vertical' ? -1 : 1 }, - { - initialBounds: bounds, - initialPageTransform, - initialShape: shape, - mode: 'scale_shape', - scaleOrigin: scaleOriginPage, - scaleAxisRotation: 0, - } - ) - } - }) + for (const shape of shapesToFlip) { + const bounds = this.getShapeGeometry(shape).bounds + const initialPageTransform = this.getShapePageTransform(shape.id) + if (!initialPageTransform) continue + this.resizeShape( + shape.id, + { x: operation === 'horizontal' ? -1 : 1, y: operation === 'vertical' ? -1 : 1 }, + { + initialBounds: bounds, + initialPageTransform, + initialShape: shape, + mode: 'scale_shape', + scaleOrigin: scaleOriginPage, + scaleAxisRotation: 0, + } + ) + } return this } @@ -5889,49 +5857,45 @@ export class Editor extends EventEmitter { switch (operation) { case 'vertical': { - this.batch(() => { - for (const shape of shapesToStretch) { - const pageRotation = this.getShapePageTransform(shape)!.rotation() - if (pageRotation % PI2) continue - const bounds = shapeBounds[shape.id] - const pageBounds = shapePageBounds[shape.id] - const localOffset = new Vec(0, commonBounds.minY - pageBounds.minY) - const parentTransform = this.getShapeParentTransform(shape) - if (parentTransform) localOffset.rot(-parentTransform.rotation()) + for (const shape of shapesToStretch) { + const pageRotation = this.getShapePageTransform(shape)!.rotation() + if (pageRotation % PI2) continue + const bounds = shapeBounds[shape.id] + const pageBounds = shapePageBounds[shape.id] + const localOffset = new Vec(0, commonBounds.minY - pageBounds.minY) + const parentTransform = this.getShapeParentTransform(shape) + if (parentTransform) localOffset.rot(-parentTransform.rotation()) - const { x, y } = Vec.Add(localOffset, shape) - this.updateShapes([{ id: shape.id, type: shape.type, x, y }]) - const scale = new Vec(1, commonBounds.height / pageBounds.height) - this.resizeShape(shape.id, scale, { - initialBounds: bounds, - scaleOrigin: new Vec(pageBounds.center.x, commonBounds.minY), - scaleAxisRotation: 0, - }) - } - }) + const { x, y } = Vec.Add(localOffset, shape) + this.updateShapes([{ id: shape.id, type: shape.type, x, y }]) + const scale = new Vec(1, commonBounds.height / pageBounds.height) + this.resizeShape(shape.id, scale, { + initialBounds: bounds, + scaleOrigin: new Vec(pageBounds.center.x, commonBounds.minY), + scaleAxisRotation: 0, + }) + } break } case 'horizontal': { - this.batch(() => { - for (const shape of shapesToStretch) { - const bounds = shapeBounds[shape.id] - const pageBounds = shapePageBounds[shape.id] - const pageRotation = this.getShapePageTransform(shape)!.rotation() - if (pageRotation % PI2) continue - const localOffset = new Vec(commonBounds.minX - pageBounds.minX, 0) - const parentTransform = this.getShapeParentTransform(shape) - if (parentTransform) localOffset.rot(-parentTransform.rotation()) + for (const shape of shapesToStretch) { + const bounds = shapeBounds[shape.id] + const pageBounds = shapePageBounds[shape.id] + const pageRotation = this.getShapePageTransform(shape)!.rotation() + if (pageRotation % PI2) continue + const localOffset = new Vec(commonBounds.minX - pageBounds.minX, 0) + const parentTransform = this.getShapeParentTransform(shape) + if (parentTransform) localOffset.rot(-parentTransform.rotation()) - const { x, y } = Vec.Add(localOffset, shape) - this.updateShapes([{ id: shape.id, type: shape.type, x, y }]) - const scale = new Vec(commonBounds.width / pageBounds.width, 1) - this.resizeShape(shape.id, scale, { - initialBounds: bounds, - scaleOrigin: new Vec(commonBounds.minX, pageBounds.center.y), - scaleAxisRotation: 0, - }) - } - }) + const { x, y } = Vec.Add(localOffset, shape) + this.updateShapes([{ id: shape.id, type: shape.type, x, y }]) + const scale = new Vec(commonBounds.width / pageBounds.width, 1) + this.resizeShape(shape.id, scale, { + initialBounds: bounds, + scaleOrigin: new Vec(commonBounds.minX, pageBounds.center.y), + scaleAxisRotation: 0, + }) + } break } @@ -6254,164 +6218,158 @@ export class Editor extends EventEmitter { const focusedGroupId = this.getFocusedGroupId() - return this.batch(() => { - // 1. Parents + // 1. Parents - // Make sure that each partial will become the child of either the - // page or another shape that exists (or that will exist) in this page. + // Make sure that each partial will become the child of either the + // page or another shape that exists (or that will exist) in this page. - // find last parent id - const currentPageShapesSorted = this.getCurrentPageShapesSorted() + // find last parent id + const currentPageShapesSorted = this.getCurrentPageShapesSorted() - const partials = shapes.map((partial) => { - if (!partial.id) { - partial = { id: createShapeId(), ...partial } - } - - // If the partial does not provide the parentId OR if the provided - // parentId is NOT in the store AND NOT among the other shapes being - // created, then we need to find a parent for the shape. This can be - // another shape that exists under that point and which can receive - // children of the creating shape's type, or else the page itself. - if ( - !partial.parentId || - !(this.store.has(partial.parentId) || shapes.some((p) => p.id === partial.parentId)) - ) { - let parentId: TLParentId = this.getFocusedGroupId() - - for (let i = currentPageShapesSorted.length - 1; i >= 0; i--) { - const parent = currentPageShapesSorted[i] - if ( - // parent.type === 'frame' - this.getShapeUtil(parent).canReceiveNewChildrenOfType(parent, partial.type) && - this.isPointInShape( - parent, - // If no parent is provided, then we can treat the - // shape's provided x/y as being in the page's space. - { x: partial.x ?? 0, y: partial.y ?? 0 }, - { - margin: 0, - hitInside: true, - } - ) - ) { - parentId = parent.id - break - } - } - - const prevParentId = partial.parentId - - // a shape cannot be it's own parent. This was a rare issue with frames/groups in the syncFuzz tests. - if (parentId === partial.id) { - parentId = focusedGroupId - } - - // If the parentid has changed... - if (parentId !== prevParentId) { - partial = { ...partial } - - partial.parentId = parentId - - // If the parent is a shape (rather than a page) then insert the - // shapes into the shape's children. Adjust the point and page rotation to be - // preserved relative to the parent. - if (isShapeId(parentId)) { - const point = this.getPointInShapeSpace(this.getShape(parentId)!, { - x: partial.x ?? 0, - y: partial.y ?? 0, - }) - partial.x = point.x - partial.y = point.y - partial.rotation = - -this.getShapePageTransform(parentId)!.rotation() + (partial.rotation ?? 0) - } - } - } - - return partial - }) - - // 2. Indices - - // Get the highest index among the parents of each of the - // the shapes being created; we'll increment from there. - - const parentIndices = new Map() - - const shapeRecordsToCreate: TLShape[] = [] - - for (const partial of partials) { - const util = this.getShapeUtil(partial as TLShapePartial) - - // If an index is not explicitly provided, then add the - // shapes to the top of their parents' children; using the - // value in parentsMappedToIndex, get the index above, use it, - // and set it back to parentsMappedToIndex for next time. - let index = partial.index - - if (!index) { - // Hello bug-seeker: have you just created a frame and then a shape - // and found that the shape is automatically the child of the frame? - // this is the reason why! It would be harder to have each shape specify - // the frame as the parent when creating a shape inside of a frame, so - // we do it here. - const parentId = partial.parentId ?? focusedGroupId - - if (!parentIndices.has(parentId)) { - parentIndices.set(parentId, this.getHighestIndexForParent(parentId)) - } - index = parentIndices.get(parentId)! - parentIndices.set(parentId, getIndexAbove(index)) - } - - // The initial props starts as the shape utility's default props - const initialProps = util.getDefaultProps() - - // We then look up each key in the tab state's styles; and if it's there, - // we use the value from the tab state's styles instead of the default. - for (const [style, propKey] of this.styleProps[partial.type]) { - ;(initialProps as any)[propKey] = this.getStyleForNextShape(style) - } - - // When we create the shape, take in the partial (the props coming into the - // function) and merge it with the default props. - let shapeRecordToCreate = ( - this.store.schema.types.shape as RecordType< - TLShape, - 'type' | 'props' | 'index' | 'parentId' - > - ).create({ - ...partial, - index, - opacity: partial.opacity ?? this.getInstanceState().opacityForNextShape, - parentId: partial.parentId ?? focusedGroupId, - props: 'props' in partial ? { ...initialProps, ...partial.props } : initialProps, - }) - - if (shapeRecordToCreate.index === undefined) { - throw Error('no index!') - } - - const next = this.getShapeUtil(shapeRecordToCreate).onBeforeCreate?.(shapeRecordToCreate) - - if (next) { - shapeRecordToCreate = next - } - - shapeRecordsToCreate.push(shapeRecordToCreate) + const partials = shapes.map((partial) => { + if (!partial.id) { + partial = { id: createShapeId(), ...partial } } - // Add meta properties, if any, to the shapes - shapeRecordsToCreate.forEach((shape) => { - shape.meta = { - ...this.getInitialMetaForShape(shape), - ...shape.meta, + // If the partial does not provide the parentId OR if the provided + // parentId is NOT in the store AND NOT among the other shapes being + // created, then we need to find a parent for the shape. This can be + // another shape that exists under that point and which can receive + // children of the creating shape's type, or else the page itself. + if ( + !partial.parentId || + !(this.store.has(partial.parentId) || shapes.some((p) => p.id === partial.parentId)) + ) { + let parentId: TLParentId = this.getFocusedGroupId() + + for (let i = currentPageShapesSorted.length - 1; i >= 0; i--) { + const parent = currentPageShapesSorted[i] + if ( + // parent.type === 'frame' + this.getShapeUtil(parent).canReceiveNewChildrenOfType(parent, partial.type) && + this.isPointInShape( + parent, + // If no parent is provided, then we can treat the + // shape's provided x/y as being in the page's space. + { x: partial.x ?? 0, y: partial.y ?? 0 }, + { + margin: 0, + hitInside: true, + } + ) + ) { + parentId = parent.id + break + } } + + const prevParentId = partial.parentId + + // a shape cannot be it's own parent. This was a rare issue with frames/groups in the syncFuzz tests. + if (parentId === partial.id) { + parentId = focusedGroupId + } + + // If the parentid has changed... + if (parentId !== prevParentId) { + partial = { ...partial } + + partial.parentId = parentId + + // If the parent is a shape (rather than a page) then insert the + // shapes into the shape's children. Adjust the point and page rotation to be + // preserved relative to the parent. + if (isShapeId(parentId)) { + const point = this.getPointInShapeSpace(this.getShape(parentId)!, { + x: partial.x ?? 0, + y: partial.y ?? 0, + }) + partial.x = point.x + partial.y = point.y + partial.rotation = + -this.getShapePageTransform(parentId)!.rotation() + (partial.rotation ?? 0) + } + } + } + + return partial + }) + + // 2. Indices + + // Get the highest index among the parents of each of the + // the shapes being created; we'll increment from there. + + const parentIndices = new Map() + + const shapeRecordsToCreate: TLShape[] = [] + + for (const partial of partials) { + const util = this.getShapeUtil(partial as TLShapePartial) + + // If an index is not explicitly provided, then add the + // shapes to the top of their parents' children; using the + // value in parentsMappedToIndex, get the index above, use it, + // and set it back to parentsMappedToIndex for next time. + let index = partial.index + + if (!index) { + // Hello bug-seeker: have you just created a frame and then a shape + // and found that the shape is automatically the child of the frame? + // this is the reason why! It would be harder to have each shape specify + // the frame as the parent when creating a shape inside of a frame, so + // we do it here. + const parentId = partial.parentId ?? focusedGroupId + + if (!parentIndices.has(parentId)) { + parentIndices.set(parentId, this.getHighestIndexForParent(parentId)) + } + index = parentIndices.get(parentId)! + parentIndices.set(parentId, getIndexAbove(index)) + } + + // The initial props starts as the shape utility's default props + const initialProps = util.getDefaultProps() + + // We then look up each key in the tab state's styles; and if it's there, + // we use the value from the tab state's styles instead of the default. + for (const [style, propKey] of this.styleProps[partial.type]) { + ;(initialProps as any)[propKey] = this.getStyleForNextShape(style) + } + + // When we create the shape, take in the partial (the props coming into the + // function) and merge it with the default props. + const shapeRecordToCreate = ( + this.store.schema.types.shape as RecordType< + TLShape, + 'type' | 'props' | 'index' | 'parentId' + > + ).create({ + ...partial, + index, + opacity: partial.opacity ?? this.getInstanceState().opacityForNextShape, + parentId: partial.parentId ?? focusedGroupId, + props: 'props' in partial ? { ...initialProps, ...partial.props } : initialProps, }) - this.store.put(shapeRecordsToCreate) + if (shapeRecordToCreate.index === undefined) { + throw Error('no index!') + } + + shapeRecordsToCreate.push(shapeRecordToCreate) + } + + // Add meta properties, if any, to the shapes + shapeRecordsToCreate.forEach((shape) => { + shape.meta = { + ...this.getInitialMetaForShape(shape), + ...shape.meta, + } }) + + this.store.put(shapeRecordsToCreate) + + return this } private animatingShapes = new Map() @@ -6588,22 +6546,20 @@ export class Editor extends EventEmitter { const highestIndex = shapesWithRootParent[shapesWithRootParent.length - 1]?.index - this.batch(() => { - this.createShapes([ - { - id: groupId, - type: 'group', - parentId, - index: highestIndex, - x, - y, - opacity: 1, - props: {}, - }, - ]) - this.reparentShapes(sortedShapeIds, groupId) - this.select(groupId) - }) + this.createShapes([ + { + id: groupId, + type: 'group', + parentId, + index: highestIndex, + x, + y, + opacity: 1, + props: {}, + }, + ]) + this.reparentShapes(sortedShapeIds, groupId) + this.select(groupId) return this } @@ -6651,23 +6607,21 @@ export class Editor extends EventEmitter { if (groups.length === 0) return this - this.batch(() => { - let group: TLGroupShape + let group: TLGroupShape - for (let i = 0, n = groups.length; i < n; i++) { - group = groups[i] - const childIds = this.getSortedChildIdsForParent(group.id) + for (let i = 0, n = groups.length; i < n; i++) { + group = groups[i] + const childIds = this.getSortedChildIdsForParent(group.id) - for (let j = 0, n = childIds.length; j < n; j++) { - idsToSelect.add(childIds[j]) - } - - this.reparentShapes(childIds, group.parentId, group.index) + for (let j = 0, n = childIds.length; j < n; j++) { + idsToSelect.add(childIds[j]) } - this.deleteShapes(groups.map((group) => group.id)) - this.select(...idsToSelect) - }) + this.reparentShapes(childIds, group.parentId, group.index) + } + + this.deleteShapes(groups.map((group) => group.id)) + this.select(...idsToSelect) return this } @@ -6728,37 +6682,30 @@ export class Editor extends EventEmitter { private _updateShapes = (_partials: (TLShapePartial | null | undefined)[]) => { if (this.getInstanceState().isReadonly) return - this.batch(() => { - const updates = [] + const updates = [] - let shape: TLShape | undefined - let updated: TLShape + let shape: TLShape | undefined + let updated: TLShape - for (let i = 0, n = _partials.length; i < n; i++) { - const partial = _partials[i] - // Skip nullish partials (sometimes created by map fns returning undefined) - if (!partial) continue + for (let i = 0, n = _partials.length; i < n; i++) { + const partial = _partials[i] + // Skip nullish partials (sometimes created by map fns returning undefined) + if (!partial) continue - // Get the current shape referenced by the partial - // If there is no current shape, we'll skip this update - shape = this.getShape(partial.id) - if (!shape) continue + // Get the current shape referenced by the partial + // If there is no current shape, we'll skip this update + shape = this.getShape(partial.id) + if (!shape) continue - // Get the updated version of the shape - // If the update had no effect, we'll skip this update - updated = applyPartialToShape(shape, partial) - if (updated === shape) continue + // Get the updated version of the shape + // If the update had no effect, we'll skip this update + updated = applyPartialToShape(shape, partial) + if (updated === shape) continue - //if any shape has an onBeforeUpdate handler, call it and, if the handler returns a - // new shape, replace the old shape with the new one. This is used for example when - // repositioning a text shape based on its new text content. - updated = this.getShapeUtil(shape).onBeforeUpdate?.(shape, updated) ?? updated + updates.push(updated) + } - updates.push(updated) - } - - this.store.put(updates) - }) + this.store.put(updates) } /** @internal */ @@ -6801,7 +6748,8 @@ export class Editor extends EventEmitter { } const deletedIds = [...allIds] - return this.batch(() => this.store.remove(deletedIds)) + this.store.remove(deletedIds) + return this } /** @@ -7625,84 +7573,82 @@ export class Editor extends EventEmitter { }) ) - this.batch(() => { - // Create any assets that need to be created - if (assetsToCreate.length > 0) { - this.createAssets(assetsToCreate) - } + // Create any assets that need to be created + if (assetsToCreate.length > 0) { + this.createAssets(assetsToCreate) + } - // Create the shapes with root shapes as children of the page - this.createShapes(newShapes) + // Create the shapes with root shapes as children of the page + this.createShapes(newShapes) - if (select) { - this.select(...rootShapes.map((s) => s.id)) - } + if (select) { + this.select(...rootShapes.map((s) => s.id)) + } - // And then, if needed, reparent the root shapes to the paste parent - if (pasteParentId !== currentPageId) { - this.reparentShapes( - rootShapes.map((s) => s.id), - pasteParentId - ) - } - - const newCreatedShapes = newShapes.map((s) => this.getShape(s.id)!) - const bounds = Box.Common(newCreatedShapes.map((s) => this.getShapePageBounds(s)!)) - - if (point === undefined) { - if (!isPageId(pasteParentId)) { - // Put the shapes in the middle of the (on screen) parent - const shape = this.getShape(pasteParentId)! - point = Mat.applyToPoint( - this.getShapePageTransform(shape), - this.getShapeGeometry(shape).bounds.center - ) - } else { - const viewportPageBounds = this.getViewportPageBounds() - if (preservePosition || viewportPageBounds.includes(Box.From(bounds))) { - // Otherwise, put shapes where they used to be - point = bounds.center - } else { - // If the old bounds are outside of the viewport... - // put the shapes in the middle of the viewport - point = viewportPageBounds.center - } - } - } - - if (rootShapes.length === 1) { - const onlyRoot = rootShapes[0] as TLFrameShape - // If the old bounds are in the viewport... - if (this.isShapeOfType(onlyRoot, 'frame')) { - while ( - this.getShapesAtPoint(point).some( - (shape) => - this.isShapeOfType(shape, 'frame') && - shape.props.w === onlyRoot.props.w && - shape.props.h === onlyRoot.props.h - ) - ) { - point.x += bounds.w + 16 - } - } - } - - const pageCenter = Box.Common( - compact(rootShapes.map(({ id }) => this.getShapePageBounds(id))) - ).center - - const offset = Vec.Sub(point, pageCenter) - - this.updateShapes( - rootShapes.map(({ id }) => { - const s = this.getShape(id)! - const localRotation = this.getShapeParentTransform(id).decompose().rotation - const localDelta = Vec.Rot(offset, -localRotation) - - return { id: s.id, type: s.type, x: s.x + localDelta.x, y: s.y + localDelta.y } - }) + // And then, if needed, reparent the root shapes to the paste parent + if (pasteParentId !== currentPageId) { + this.reparentShapes( + rootShapes.map((s) => s.id), + pasteParentId ) - }) + } + + const newCreatedShapes = newShapes.map((s) => this.getShape(s.id)!) + const bounds = Box.Common(newCreatedShapes.map((s) => this.getShapePageBounds(s)!)) + + if (point === undefined) { + if (!isPageId(pasteParentId)) { + // Put the shapes in the middle of the (on screen) parent + const shape = this.getShape(pasteParentId)! + point = Mat.applyToPoint( + this.getShapePageTransform(shape), + this.getShapeGeometry(shape).bounds.center + ) + } else { + const viewportPageBounds = this.getViewportPageBounds() + if (preservePosition || viewportPageBounds.includes(Box.From(bounds))) { + // Otherwise, put shapes where they used to be + point = bounds.center + } else { + // If the old bounds are outside of the viewport... + // put the shapes in the middle of the viewport + point = viewportPageBounds.center + } + } + } + + if (rootShapes.length === 1) { + const onlyRoot = rootShapes[0] as TLFrameShape + // If the old bounds are in the viewport... + if (this.isShapeOfType(onlyRoot, 'frame')) { + while ( + this.getShapesAtPoint(point).some( + (shape) => + this.isShapeOfType(shape, 'frame') && + shape.props.w === onlyRoot.props.w && + shape.props.h === onlyRoot.props.h + ) + ) { + point.x += bounds.w + 16 + } + } + } + + const pageCenter = Box.Common( + compact(rootShapes.map(({ id }) => this.getShapePageBounds(id))) + ).center + + const offset = Vec.Sub(point, pageCenter) + + this.updateShapes( + rootShapes.map(({ id }) => { + const s = this.getShape(id)! + const localRotation = this.getShapeParentTransform(id).decompose().rotation + const localDelta = Vec.Rot(offset, -localRotation) + + return { id: s.id, type: s.type, x: s.x + localDelta.x, y: s.y + localDelta.y } + }) + ) return this } @@ -8002,19 +7948,17 @@ export class Editor extends EventEmitter { private _pendingEventsForNextTick: TLEventInfo[] = [] private _flushEventsForTick = (elapsed: number) => { - this.batch(() => { - if (this._pendingEventsForNextTick.length > 0) { - const events = [...this._pendingEventsForNextTick] - this._pendingEventsForNextTick.length = 0 - for (const info of events) { - this._flushEventForTick(info) - } + if (this._pendingEventsForNextTick.length > 0) { + const events = [...this._pendingEventsForNextTick] + this._pendingEventsForNextTick.length = 0 + for (const info of events) { + this._flushEventForTick(info) } - if (elapsed > 0) { - this.root.handleEvent({ type: 'misc', name: 'tick', elapsed }) - } - this.scribbles.tick(elapsed) - }) + } + if (elapsed > 0) { + this.root.handleEvent({ type: 'misc', name: 'tick', elapsed }) + } + this.scribbles.tick(elapsed) } private _flushEventForTick = (info: TLEventInfo) => { diff --git a/packages/editor/src/lib/editor/managers/HistoryManager.test.ts b/packages/editor/src/lib/editor/managers/HistoryManager.test.ts index 157901bc4..eb7f773e0 100644 --- a/packages/editor/src/lib/editor/managers/HistoryManager.test.ts +++ b/packages/editor/src/lib/editor/managers/HistoryManager.test.ts @@ -61,14 +61,12 @@ function createCounterHistoryManager() { } const setAge = (age = 35) => { - manager.batch(() => _setAge(age), { history: 'record-preserveRedoStack' }) + manager.recordPreservingRedoStack(() => _setAge(age)) } const incrementTwice = () => { - manager.batch(() => { - increment() - increment() - }) + increment() + increment() } return { @@ -290,12 +288,12 @@ describe('history options', () => { return { a: store.get(ids.a)!.value as number, b: store.get(ids.b)!.value as number } } - setA = (n: number, historyOptions?: TLHistoryBatchOptions) => { - manager.batch(() => store.update(ids.a, (s) => ({ ...s, value: n })), historyOptions) + setA = (n: number, opts?: TLHistoryBatchOptions) => { + manager.runInMode(opts?.history, () => store.update(ids.a, (s) => ({ ...s, value: n }))) } - setB = (n: number, historyOptions?: TLHistoryBatchOptions) => { - manager.batch(() => store.update(ids.b, (s) => ({ ...s, value: n })), historyOptions) + setB = (n: number, opts?: TLHistoryBatchOptions) => { + manager.runInMode(opts?.history, () => store.update(ids.b, (s) => ({ ...s, value: n }))) } }) @@ -398,14 +396,11 @@ describe('history options', () => { it('nested ignore', () => { manager.mark() - manager.batch( - () => { - setA(1) - manager.batch(() => setB(1), { history: 'record' }) - setA(2) - }, - { history: 'ignore' } - ) + manager.ignore(() => { + setA(1) + manager.record(() => setB(1)) + setA(2) + }) expect(getState()).toMatchObject({ a: 2, b: 1 }) // changes to A were ignore, but changes to B were recorded: @@ -413,13 +408,10 @@ describe('history options', () => { expect(getState()).toMatchObject({ a: 2, b: 0 }) manager.mark() - manager.batch( - () => { - setA(3) - manager.batch(() => setB(2), { history: 'ignore' }) - }, - { history: 'record-preserveRedoStack' } - ) + manager.recordPreservingRedoStack(() => { + setA(3) + manager.ignore(() => setB(2)) + }) expect(getState()).toMatchObject({ a: 3, b: 2 }) // changes to A were recorded, but changes to B were ignore: diff --git a/packages/editor/src/lib/editor/managers/HistoryManager.ts b/packages/editor/src/lib/editor/managers/HistoryManager.ts index fa9ecacdd..f77d1fc92 100644 --- a/packages/editor/src/lib/editor/managers/HistoryManager.ts +++ b/packages/editor/src/lib/editor/managers/HistoryManager.ts @@ -10,22 +10,16 @@ import { } from '@tldraw/store' import { exhaustiveSwitchError, noop } from '@tldraw/utils' import { uniqueId } from '../../utils/uniqueId' -import { TLHistoryBatchOptions, TLHistoryEntry } from '../types/history-types' +import { TLHistoryEntry, TLHistoryMode } from '../types/history-types' import { stack } from './Stack' -enum HistoryRecorderState { - Recording = 'recording', - RecordingPreserveRedoStack = 'recordingPreserveRedoStack', - Paused = 'paused', -} - /** @public */ export class HistoryManager { private readonly store: Store readonly dispose: () => void - private state: HistoryRecorderState = HistoryRecorderState.Recording + private mode: TLHistoryMode = 'record' private readonly pendingDiff = new PendingDiff() /** @internal */ stacks = atom( @@ -47,18 +41,18 @@ export class HistoryManager { this.dispose = this.store.addHistoryInterceptor((entry, source) => { if (source !== 'user') return - switch (this.state) { - case HistoryRecorderState.Recording: + switch (this.mode) { + case 'record': this.pendingDiff.apply(entry.changes) this.stacks.update(({ undos }) => ({ undos, redos: stack() })) break - case HistoryRecorderState.RecordingPreserveRedoStack: + case 'record-preserveRedoStack': this.pendingDiff.apply(entry.changes) break - case HistoryRecorderState.Paused: + case 'ignore': break default: - exhaustiveSwitchError(this.state) + exhaustiveSwitchError(this.mode) } }) } @@ -73,8 +67,6 @@ export class HistoryManager { })) } - onBatchComplete: () => void = () => void null - getNumUndos() { return this.stacks.get().undos.length + (this.pendingDiff.isEmpty() ? 0 : 1) } @@ -82,39 +74,34 @@ export class HistoryManager { return this.stacks.get().redos.length } - /** @internal */ - _isInBatch = false - batch = (fn: () => void, opts?: TLHistoryBatchOptions) => { - const previousState = this.state - this.state = opts?.history ? modeToState[opts.history] : this.state + runInMode(mode: TLHistoryMode | undefined | null, fn: () => void) { + if (!mode) { + fn() + return this + } + + const previousMode = this.mode + this.mode = mode try { - if (this._isInBatch) { - fn() - return this - } - - this._isInBatch = true - try { - transact(() => { - fn() - this.onBatchComplete() - }) - } catch (error) { - this.annotateError(error) - throw error - } finally { - this._isInBatch = false - } + transact(fn) return this } finally { - this.state = previousState + this.mode = previousMode } } ignore(fn: () => void) { - return this.batch(fn, { history: 'ignore' }) + return this.runInMode('ignore', fn) + } + + record(fn: () => void) { + return this.runInMode('record', fn) + } + + recordPreservingRedoStack(fn: () => void) { + return this.runInMode('record-preserveRedoStack', fn) } // History @@ -125,8 +112,8 @@ export class HistoryManager { pushToRedoStack: boolean toMark?: string }) => { - const previousState = this.state - this.state = HistoryRecorderState.Paused + const previousState = this.mode + this.mode = 'ignore' try { let { undos, redos } = this.stacks.get() @@ -183,7 +170,7 @@ export class HistoryManager { this.store.ensureStoreIsUsable() this.stacks.set({ undos, redos }) } finally { - this.state = previousState + this.mode = previousState } return this @@ -196,8 +183,8 @@ export class HistoryManager { } redo = () => { - const previousState = this.state - this.state = HistoryRecorderState.Paused + const previousState = this.mode + this.mode = 'ignore' try { this.flushPendingDiff() @@ -231,7 +218,7 @@ export class HistoryManager { this.store.ensureStoreIsUsable() this.stacks.set({ undos, redos }) } finally { - this.state = previousState + this.mode = previousState } return this @@ -270,17 +257,11 @@ export class HistoryManager { undos: undos.toArray(), redos: redos.toArray(), pendingDiff: this.pendingDiff.debug(), - state: this.state, + state: this.mode, } } } -const modeToState = { - record: HistoryRecorderState.Recording, - 'record-preserveRedoStack': HistoryRecorderState.RecordingPreserveRedoStack, - ignore: HistoryRecorderState.Paused, -} as const - class PendingDiff { private diff = createEmptyRecordsDiff() private isEmptyAtom = atom('PendingDiff.isEmpty', true) diff --git a/packages/editor/src/lib/editor/managers/ScribbleManager.ts b/packages/editor/src/lib/editor/managers/ScribbleManager.ts index 5b232caf5..843380126 100644 --- a/packages/editor/src/lib/editor/managers/ScribbleManager.ts +++ b/packages/editor/src/lib/editor/managers/ScribbleManager.ts @@ -86,96 +86,94 @@ export class ScribbleManager { */ tick = (elapsed: number) => { if (this.scribbleItems.size === 0) return - this.editor.batch(() => { - this.scribbleItems.forEach((item) => { - // let the item get at least eight points before - // switching from starting to active - if (item.scribble.state === 'starting') { - const { next, prev } = item + this.scribbleItems.forEach((item) => { + // let the item get at least eight points before + // switching from starting to active + if (item.scribble.state === 'starting') { + const { next, prev } = item + if (next && next !== prev) { + item.prev = next + item.scribble.points.push(next) + } + + if (item.scribble.points.length > 8) { + item.scribble.state = 'active' + } + return + } + + if (item.delayRemaining > 0) { + item.delayRemaining = Math.max(0, item.delayRemaining - elapsed) + } + + item.timeoutMs += elapsed + if (item.timeoutMs >= 16) { + item.timeoutMs = 0 + } + + const { delayRemaining, timeoutMs, prev, next, scribble } = item + + switch (scribble.state) { + case 'active': { if (next && next !== prev) { item.prev = next - item.scribble.points.push(next) - } + scribble.points.push(next) - if (item.scribble.points.length > 8) { - item.scribble.state = 'active' - } - return - } - - if (item.delayRemaining > 0) { - item.delayRemaining = Math.max(0, item.delayRemaining - elapsed) - } - - item.timeoutMs += elapsed - if (item.timeoutMs >= 16) { - item.timeoutMs = 0 - } - - const { delayRemaining, timeoutMs, prev, next, scribble } = item - - switch (scribble.state) { - case 'active': { - if (next && next !== prev) { - item.prev = next - scribble.points.push(next) - - // If we've run out of delay, then shrink the scribble from the start - if (delayRemaining === 0) { - if (scribble.points.length > 8) { - scribble.points.shift() - } - } - } else { - // While not moving, shrink the scribble from the start - if (timeoutMs === 0) { - if (scribble.points.length > 1) { - scribble.points.shift() - } else { - // Reset the item's delay - item.delayRemaining = scribble.delay - } - } - } - break - } - case 'stopping': { - if (item.delayRemaining === 0) { - if (timeoutMs === 0) { - // If the scribble is down to one point, we're done! - if (scribble.points.length === 1) { - this.scribbleItems.delete(item.id) // Remove the scribble - return - } - - if (scribble.shrink) { - // Drop the scribble's size as it shrinks - scribble.size = Math.max(1, scribble.size * (1 - scribble.shrink)) - } - - // Drop the scribble's first point (its tail) + // If we've run out of delay, then shrink the scribble from the start + if (delayRemaining === 0) { + if (scribble.points.length > 8) { scribble.points.shift() } } - break - } - case 'paused': { - // Nothing to do while paused. - break + } else { + // While not moving, shrink the scribble from the start + if (timeoutMs === 0) { + if (scribble.points.length > 1) { + scribble.points.shift() + } else { + // Reset the item's delay + item.delayRemaining = scribble.delay + } + } } + break } - }) + case 'stopping': { + if (item.delayRemaining === 0) { + if (timeoutMs === 0) { + // If the scribble is down to one point, we're done! + if (scribble.points.length === 1) { + this.scribbleItems.delete(item.id) // Remove the scribble + return + } - // The object here will get frozen into the record, so we need to - // create a copies of the parts that what we'll be mutating later. - this.editor.updateInstanceState({ - scribbles: Array.from(this.scribbleItems.values()) - .map(({ scribble }) => ({ - ...scribble, - points: [...scribble.points], - })) - .slice(-5), // limit to three as a minor sanity check - }) + if (scribble.shrink) { + // Drop the scribble's size as it shrinks + scribble.size = Math.max(1, scribble.size * (1 - scribble.shrink)) + } + + // Drop the scribble's first point (its tail) + scribble.points.shift() + } + } + break + } + case 'paused': { + // Nothing to do while paused. + break + } + } + }) + + // The object here will get frozen into the record, so we need to + // create a copies of the parts that what we'll be mutating later. + this.editor.updateInstanceState({ + scribbles: Array.from(this.scribbleItems.values()) + .map(({ scribble }) => ({ + ...scribble, + points: [...scribble.points], + })) + .slice(-5), // limit to three as a minor sanity check }) } } diff --git a/packages/editor/src/lib/editor/managers/SideEffectManager.ts b/packages/editor/src/lib/editor/managers/SideEffectManager.ts index 51d742bf9..c03401d2a 100644 --- a/packages/editor/src/lib/editor/managers/SideEffectManager.ts +++ b/packages/editor/src/lib/editor/managers/SideEffectManager.ts @@ -1,36 +1,38 @@ -import { TLRecord, TLStore } from '@tldraw/tlschema' +import { Store, UnknownRecord } from '@tldraw/store' /** @public */ -export type TLBeforeCreateHandler = (record: R, source: 'remote' | 'user') => R +export type TLBeforeCreateHandler = ( + record: R, + source: 'remote' | 'user' +) => R /** @public */ -export type TLAfterCreateHandler = ( +export type TLAfterCreateHandler = ( record: R, source: 'remote' | 'user' ) => void /** @public */ -export type TLBeforeChangeHandler = ( +export type TLBeforeChangeHandler = ( prev: R, next: R, source: 'remote' | 'user' ) => R /** @public */ -export type TLAfterChangeHandler = ( +export type TLAfterChangeHandler = ( prev: R, next: R, source: 'remote' | 'user' ) => void /** @public */ -export type TLBeforeDeleteHandler = ( +export type TLBeforeDeleteHandler = ( record: R, source: 'remote' | 'user' ) => void | false /** @public */ -export type TLAfterDeleteHandler = ( +export type TLAfterDeleteHandler = ( record: R, source: 'remote' | 'user' ) => void -/** @public */ -export type TLBatchCompleteHandler = () => void +export type TLCompleteHandler = (source: 'remote' | 'user') => void /** * The side effect manager (aka a "correct state enforcer") is responsible @@ -40,17 +42,10 @@ export type TLBatchCompleteHandler = () => void * * @public */ -export class SideEffectManager< - CTX extends { - store: TLStore - history: { onBatchComplete: () => void } - }, -> { - constructor(public editor: CTX) { - editor.store.onBeforeCreate = (record, source) => { - const handlers = this._beforeCreateHandlers[ - record.typeName - ] as TLBeforeCreateHandler[] +export class SideEffectManager { + constructor(public readonly store: Store) { + store.onBeforeCreate = (record, source) => { + const handlers = this._beforeCreateHandlers[record.typeName as R['typeName']] if (handlers) { let r = record for (const handler of handlers) { @@ -62,10 +57,8 @@ export class SideEffectManager< return record } - editor.store.onAfterCreate = (record, source) => { - const handlers = this._afterCreateHandlers[ - record.typeName - ] as TLAfterCreateHandler[] + store.onAfterCreate = (record, source) => { + const handlers = this._afterCreateHandlers[record.typeName as R['typeName']] if (handlers) { for (const handler of handlers) { handler(record, source) @@ -73,10 +66,8 @@ export class SideEffectManager< } } - editor.store.onBeforeChange = (prev, next, source) => { - const handlers = this._beforeChangeHandlers[ - next.typeName - ] as TLBeforeChangeHandler[] + store.onBeforeChange = (prev, next, source) => { + const handlers = this._beforeChangeHandlers[next.typeName as R['typeName']] if (handlers) { let r = next for (const handler of handlers) { @@ -88,8 +79,8 @@ export class SideEffectManager< return next } - editor.store.onAfterChange = (prev, next, source) => { - const handlers = this._afterChangeHandlers[next.typeName] as TLAfterChangeHandler[] + store.onAfterChange = (prev, next, source) => { + const handlers = this._afterChangeHandlers[next.typeName as R['typeName']] if (handlers) { for (const handler of handlers) { handler(prev, next, source) @@ -97,10 +88,8 @@ export class SideEffectManager< } } - editor.store.onBeforeDelete = (record, source) => { - const handlers = this._beforeDeleteHandlers[ - record.typeName - ] as TLBeforeDeleteHandler[] + store.onBeforeDelete = (record, source) => { + const handlers = this._beforeDeleteHandlers[record.typeName as R['typeName']] if (handlers) { for (const handler of handlers) { if (handler(record, source) === false) { @@ -110,10 +99,8 @@ export class SideEffectManager< } } - editor.store.onAfterDelete = (record, source) => { - const handlers = this._afterDeleteHandlers[ - record.typeName - ] as TLAfterDeleteHandler[] + store.onAfterDelete = (record, source) => { + const handlers = this._afterDeleteHandlers[record.typeName as R['typeName']] if (handlers) { for (const handler of handlers) { handler(record, source) @@ -121,49 +108,56 @@ export class SideEffectManager< } } - editor.history.onBatchComplete = () => { - this._batchCompleteHandlers.forEach((fn) => fn()) + store.onAfterAtomic = (source) => { + const handlers = this._completeHandlers + if (handlers) { + for (const handler of handlers) { + handler(source) + } + } } } private _beforeCreateHandlers: Partial<{ - [K in TLRecord['typeName']]: TLBeforeCreateHandler[] + [K in R['typeName']]: TLBeforeCreateHandler[] }> = {} private _afterCreateHandlers: Partial<{ - [K in TLRecord['typeName']]: TLAfterCreateHandler[] + [K in R['typeName']]: TLAfterCreateHandler[] }> = {} private _beforeChangeHandlers: Partial<{ - [K in TLRecord['typeName']]: TLBeforeChangeHandler[] + [K in R['typeName']]: TLBeforeChangeHandler[] }> = {} private _afterChangeHandlers: Partial<{ - [K in TLRecord['typeName']]: TLAfterChangeHandler[] + [K in R['typeName']]: TLAfterChangeHandler[] }> = {} - private _beforeDeleteHandlers: Partial<{ - [K in TLRecord['typeName']]: TLBeforeDeleteHandler[] + [K in R['typeName']]: TLBeforeDeleteHandler[] }> = {} - private _afterDeleteHandlers: Partial<{ - [K in TLRecord['typeName']]: TLAfterDeleteHandler[] + [K in R['typeName']]: TLAfterDeleteHandler[] }> = {} - - private _batchCompleteHandlers: TLBatchCompleteHandler[] = [] + private _completeHandlers: TLCompleteHandler[] = [] /** * Internal helper for registering a bunch of side effects at once and keeping them organized. * @internal */ - register(handlersByType: { - [R in TLRecord as R['typeName']]?: { - beforeCreate?: TLBeforeCreateHandler - afterCreate?: TLAfterCreateHandler - beforeChange?: TLBeforeChangeHandler - afterChange?: TLAfterChangeHandler - beforeDelete?: TLBeforeDeleteHandler - afterDelete?: TLAfterDeleteHandler - } - }) { + register( + handlersByType: { + [T in R as T['typeName']]?: { + beforeCreate?: TLBeforeCreateHandler + afterCreate?: TLAfterCreateHandler + beforeChange?: TLBeforeChangeHandler + afterChange?: TLAfterChangeHandler + beforeDelete?: TLBeforeDeleteHandler + afterDelete?: TLAfterDeleteHandler + } + } & { complete?: TLCompleteHandler } + ) { const disposes: (() => void)[] = [] + if (handlersByType.complete) { + this._completeHandlers.push(handlersByType.complete) + } for (const [type, handlers] of Object.entries(handlersByType) as any) { if (handlers?.beforeCreate) { disposes.push(this.registerBeforeCreateHandler(type, handlers.beforeCreate)) @@ -216,9 +210,9 @@ export class SideEffectManager< * @param typeName - The type of record to listen for * @param handler - The handler to call */ - registerBeforeCreateHandler( + registerBeforeCreateHandler( typeName: T, - handler: TLBeforeCreateHandler + handler: TLBeforeCreateHandler ) { const handlers = this._beforeCreateHandlers[typeName] as TLBeforeCreateHandler[] if (!handlers) this._beforeCreateHandlers[typeName] = [] @@ -246,9 +240,9 @@ export class SideEffectManager< * @param typeName - The type of record to listen for * @param handler - The handler to call */ - registerAfterCreateHandler( + registerAfterCreateHandler( typeName: T, - handler: TLAfterCreateHandler + handler: TLAfterCreateHandler ) { const handlers = this._afterCreateHandlers[typeName] as TLAfterCreateHandler[] if (!handlers) this._afterCreateHandlers[typeName] = [] @@ -280,9 +274,9 @@ export class SideEffectManager< * @param typeName - The type of record to listen for * @param handler - The handler to call */ - registerBeforeChangeHandler( + registerBeforeChangeHandler( typeName: T, - handler: TLBeforeChangeHandler + handler: TLBeforeChangeHandler ) { const handlers = this._beforeChangeHandlers[typeName] as TLBeforeChangeHandler[] if (!handlers) this._beforeChangeHandlers[typeName] = [] @@ -309,9 +303,9 @@ export class SideEffectManager< * @param typeName - The type of record to listen for * @param handler - The handler to call */ - registerAfterChangeHandler( + registerAfterChangeHandler( typeName: T, - handler: TLAfterChangeHandler + handler: TLAfterChangeHandler ) { const handlers = this._afterChangeHandlers[typeName] as TLAfterChangeHandler[] if (!handlers) this._afterChangeHandlers[typeName] = [] @@ -340,9 +334,9 @@ export class SideEffectManager< * @param typeName - The type of record to listen for * @param handler - The handler to call */ - registerBeforeDeleteHandler( + registerBeforeDeleteHandler( typeName: T, - handler: TLBeforeDeleteHandler + handler: TLBeforeDeleteHandler ) { const handlers = this._beforeDeleteHandlers[typeName] as TLBeforeDeleteHandler[] if (!handlers) this._beforeDeleteHandlers[typeName] = [] @@ -372,9 +366,9 @@ export class SideEffectManager< * @param typeName - The type of record to listen for * @param handler - The handler to call */ - registerAfterDeleteHandler( + registerAfterDeleteHandler( typeName: T, - handler: TLAfterDeleteHandler + handler: TLAfterDeleteHandler ) { const handlers = this._afterDeleteHandlers[typeName] as TLAfterDeleteHandler[] if (!handlers) this._afterDeleteHandlers[typeName] = [] @@ -383,7 +377,7 @@ export class SideEffectManager< } /** - * Register a handler to be called when a store completes a batch. + * Register a handler to be called when the store completes an operation. * * @example * ```ts @@ -394,7 +388,7 @@ export class SideEffectManager< * editor.selectAll() * expect(count).toBe(1) * - * editor.batch(() => { + * editor.store.atomic(() => { * editor.selectNone() * editor.selectAll() * }) @@ -406,9 +400,9 @@ export class SideEffectManager< * * @public */ - registerBatchCompleteHandler(handler: TLBatchCompleteHandler) { - this._batchCompleteHandlers.push(handler) - return () => remove(this._batchCompleteHandlers, handler) + registerCompleteHandler(handler: TLCompleteHandler) { + this._completeHandlers.push(handler) + return () => remove(this._completeHandlers, handler) } } diff --git a/packages/editor/src/lib/editor/types/emit-types.ts b/packages/editor/src/lib/editor/types/emit-types.ts index 9a1053d4d..320fa5bfb 100644 --- a/packages/editor/src/lib/editor/types/emit-types.ts +++ b/packages/editor/src/lib/editor/types/emit-types.ts @@ -8,7 +8,6 @@ export interface TLEventMap { mount: [] 'max-shapes': [{ name: string; pageId: TLPageId; count: number }] change: [HistoryEntry] - update: [] crash: [{ error: unknown }] 'stop-camera-animation': [] 'stop-following': [] diff --git a/packages/editor/src/lib/editor/types/history-types.ts b/packages/editor/src/lib/editor/types/history-types.ts index 1df29aaaa..a20848ae3 100644 --- a/packages/editor/src/lib/editor/types/history-types.ts +++ b/packages/editor/src/lib/editor/types/history-types.ts @@ -17,11 +17,13 @@ export type TLHistoryEntry = TLHistoryMark | TLHistoryD /** @public */ export interface TLHistoryBatchOptions { - /** - * How should this change interact with the history stack? - * - record: Add to the undo stack and clear the redo stack - * - record-preserveRedoStack: Add to the undo stack but do not clear the redo stack - * - ignore: Do not add to the undo stack or the redo stack - */ - history?: 'record' | 'record-preserveRedoStack' | 'ignore' + history?: TLHistoryMode } + +/** + * How should this change interact with the history stack? + * - record: Add to the undo stack and clear the redo stack + * - record-preserveRedoStack: Add to the undo stack but do not clear the redo stack + * - ignore: Do not add to the undo stack or the redo stack + */ +export type TLHistoryMode = 'record' | 'record-preserveRedoStack' | 'ignore' diff --git a/packages/store/api-report.md b/packages/store/api-report.md index 65bb48b33..243e8f70c 100644 --- a/packages/store/api-report.md +++ b/packages/store/api-report.md @@ -265,6 +265,7 @@ export class Store { markAsPossiblyCorrupted(): void; mergeRemoteChanges: (fn: () => void) => void; migrateSnapshot(snapshot: StoreSnapshot): StoreSnapshot; + onAfterAtomic?: (source: 'remote' | 'user') => void; onAfterChange?: (prev: R, next: R, source: 'remote' | 'user') => void; onAfterCreate?: (record: R, source: 'remote' | 'user') => void; onAfterDelete?: (prev: R, source: 'remote' | 'user') => void; diff --git a/packages/store/api/api.json b/packages/store/api/api.json index 668ba8f04..825be9cca 100644 --- a/packages/store/api/api.json +++ b/packages/store/api/api.json @@ -4108,6 +4108,36 @@ "isAbstract": false, "name": "migrateSnapshot" }, + { + "kind": "Property", + "canonicalReference": "@tldraw/store!Store#onAfterAtomic:member", + "docComment": "/**\n * A callback fired after an atomic operation is completed.\n */\n", + "excerptTokens": [ + { + "kind": "Content", + "text": "onAfterAtomic?: " + }, + { + "kind": "Content", + "text": "(source: 'remote' | 'user') => void" + }, + { + "kind": "Content", + "text": ";" + } + ], + "isReadonly": false, + "isOptional": true, + "releaseTag": "Public", + "name": "onAfterAtomic", + "propertyTypeTokenRange": { + "startIndex": 1, + "endIndex": 2 + }, + "isStatic": false, + "isProtected": false, + "isAbstract": false + }, { "kind": "Property", "canonicalReference": "@tldraw/store!Store#onAfterChange:member", diff --git a/packages/store/src/lib/Store.ts b/packages/store/src/lib/Store.ts index 3c061b6f8..237e3d5a1 100644 --- a/packages/store/src/lib/Store.ts +++ b/packages/store/src/lib/Store.ts @@ -340,6 +340,11 @@ export class Store { */ onAfterDelete?: (prev: R, source: 'remote' | 'user') => void + /** + * A callback fired after an atomic operation is completed. + */ + onAfterAtomic?: (source: 'remote' | 'user') => void + // used to avoid running callbacks when rolling back changes in sync client private _runCallbacks = true @@ -682,6 +687,10 @@ export class Store { * @public */ mergeRemoteChanges = (fn: () => void) => { + if (this._isInAtomicOp) { + throw new Error('Cannot call `mergeRemoteChanges` from within an atomic operation') + } + if (this.isMergingRemoteChanges) { return fn() } @@ -827,6 +836,9 @@ export class Store { } private flushAtomicCallbacks() { let updateDepth = 0 + let didAnythingHappen = false + + // first, we fire any pending after events: while (this.pendingAfterEvents) { const events = this.pendingAfterEvents this.pendingAfterEvents = null @@ -840,15 +852,27 @@ export class Store { for (const { before, after, source } of events.values()) { if (before && after) { + didAnythingHappen = true this.onAfterChange?.(before, after, source) } else if (before && !after) { + didAnythingHappen = true this.onAfterDelete?.(before, source) } else if (!before && after) { + didAnythingHappen = true this.onAfterCreate?.(after, source) } } } + + // then we fire the atomic callback + if (didAnythingHappen) { + this.onAfterAtomic?.(this.isMergingRemoteChanges ? 'remote' : 'user') + + // that might have caused more changes, so we need to flush again: + this.flushAtomicCallbacks() + } } + private _isInAtomicOp = false /** @internal */ atomic(fn: () => T, runCallbacks = true): T { diff --git a/packages/tldraw/src/lib/defaultExternalContentHandlers.ts b/packages/tldraw/src/lib/defaultExternalContentHandlers.ts index ba2ff614a..fcc7aad10 100644 --- a/packages/tldraw/src/lib/defaultExternalContentHandlers.ts +++ b/packages/tldraw/src/lib/defaultExternalContentHandlers.ts @@ -377,21 +377,19 @@ export function registerDefaultExternalContentHandlers( } } - editor.batch(() => { - if (shouldAlsoCreateAsset) { - editor.createAssets([asset]) - } + if (shouldAlsoCreateAsset) { + editor.createAssets([asset]) + } - editor.updateShapes([ - { - id: shape.id, - type: shape.type, - props: { - assetId: asset.id, - }, + editor.updateShapes([ + { + id: shape.id, + type: shape.type, + props: { + assetId: asset.id, }, - ]) - }) + }, + ]) }) } @@ -459,19 +457,17 @@ export async function createShapesForAssets( } } - editor.batch(() => { - // Create any assets - const assetsToCreate = assets.filter((asset) => !editor.getAsset(asset.id)) - if (assetsToCreate.length) { - editor.createAssets(assetsToCreate) - } + // Create any assets + const assetsToCreate = assets.filter((asset) => !editor.getAsset(asset.id)) + if (assetsToCreate.length) { + editor.createAssets(assetsToCreate) + } - // Create the shapes - editor.createShapes(partials).select(...partials.map((p) => p.id)) + // Create the shapes + editor.createShapes(partials).select(...partials.map((p) => p.id)) - // Re-position shapes so that the center of the group is at the provided point - centerSelectionAroundPoint(editor, position) - }) + // Re-position shapes so that the center of the group is at the provided point + centerSelectionAroundPoint(editor, position) return partials.map((p) => p.id) } @@ -522,10 +518,8 @@ export function createEmptyBookmarkShape( }, } - editor.batch(() => { - editor.createShapes([partial]).select(partial.id) - centerSelectionAroundPoint(editor, position) - }) + editor.createShapes([partial]).select(partial.id) + centerSelectionAroundPoint(editor, position) return editor.getShape(partial.id) as TLBookmarkShape } diff --git a/packages/tldraw/src/lib/shapes/bookmark/BookmarkShapeUtil.tsx b/packages/tldraw/src/lib/shapes/bookmark/BookmarkShapeUtil.tsx index df8cd1b31..11b36c6da 100644 --- a/packages/tldraw/src/lib/shapes/bookmark/BookmarkShapeUtil.tsx +++ b/packages/tldraw/src/lib/shapes/bookmark/BookmarkShapeUtil.tsx @@ -180,17 +180,15 @@ const createBookmarkAssetOnUrlChange = debounce(async (editor: Editor, shape: TL return } - editor.batch(() => { - // Create the new asset - editor.createAssets([asset]) + // Create the new asset + editor.createAssets([asset]) - // And update the shape - editor.updateShapes([ - { - id: shape.id, - type: shape.type, - props: { assetId: asset.id }, - }, - ]) - }) + // And update the shape + editor.updateShapes([ + { + id: shape.id, + type: shape.type, + props: { assetId: asset.id }, + }, + ]) }, 500) diff --git a/packages/tldraw/src/lib/tools/SelectTool/DragAndDropManager.ts b/packages/tldraw/src/lib/tools/SelectTool/DragAndDropManager.ts index 9829e904f..931870d9e 100644 --- a/packages/tldraw/src/lib/tools/SelectTool/DragAndDropManager.ts +++ b/packages/tldraw/src/lib/tools/SelectTool/DragAndDropManager.ts @@ -32,9 +32,7 @@ export class DragAndDropManager { private setDragTimer(movingShapes: TLShape[], duration: number, cb: () => void) { this.droppingNodeTimer = setTimeout(() => { - this.editor.batch(() => { - this.handleDrag(this.editor.inputs.currentPagePoint, movingShapes, cb) - }) + this.handleDrag(this.editor.inputs.currentPagePoint, movingShapes, cb) this.droppingNodeTimer = null }, duration) } diff --git a/packages/tldraw/src/lib/tools/SelectTool/childStates/PointingShape.ts b/packages/tldraw/src/lib/tools/SelectTool/childStates/PointingShape.ts index 7a08ee826..3660b91d1 100644 --- a/packages/tldraw/src/lib/tools/SelectTool/childStates/PointingShape.ts +++ b/packages/tldraw/src/lib/tools/SelectTool/childStates/PointingShape.ts @@ -146,20 +146,18 @@ export class PointingShape extends StateNode { labelGeometry.bounds.containsPoint(pointInShapeSpace, 0) && labelGeometry.hitTestPoint(pointInShapeSpace) ) { - this.editor.batch(() => { - this.editor.mark('editing on pointer up') - this.editor.select(selectingShape.id) + this.editor.mark('editing on pointer up') + this.editor.select(selectingShape.id) - const util = this.editor.getShapeUtil(selectingShape) - if (this.editor.getInstanceState().isReadonly) { - if (!util.canEditInReadOnly(selectingShape)) { - return - } + const util = this.editor.getShapeUtil(selectingShape) + if (this.editor.getInstanceState().isReadonly) { + if (!util.canEditInReadOnly(selectingShape)) { + return } + } - this.editor.setEditingShape(selectingShape.id) - this.editor.setCurrentTool('select.editing_shape') - }) + this.editor.setEditingShape(selectingShape.id) + this.editor.setCurrentTool('select.editing_shape') return } } diff --git a/packages/tldraw/src/lib/ui/components/DebugMenu/DefaultDebugMenuContent.tsx b/packages/tldraw/src/lib/ui/components/DebugMenu/DefaultDebugMenuContent.tsx index 68b69382a..5b406ecc5 100644 --- a/packages/tldraw/src/lib/ui/components/DebugMenu/DefaultDebugMenuContent.tsx +++ b/packages/tldraw/src/lib/ui/components/DebugMenu/DefaultDebugMenuContent.tsx @@ -294,7 +294,5 @@ function createNShapes(editor: Editor, n: number) { } } - editor.batch(() => { - editor.createShapes(shapesToCreate).setSelectedShapes(shapesToCreate.map((s) => s.id)) - }) + editor.createShapes(shapesToCreate).setSelectedShapes(shapesToCreate.map((s) => s.id)) } diff --git a/packages/tldraw/src/lib/ui/components/PageMenu/DefaultPageMenu.tsx b/packages/tldraw/src/lib/ui/components/PageMenu/DefaultPageMenu.tsx index 9a6569566..5bceda40c 100644 --- a/packages/tldraw/src/lib/ui/components/PageMenu/DefaultPageMenu.tsx +++ b/packages/tldraw/src/lib/ui/components/PageMenu/DefaultPageMenu.tsx @@ -251,13 +251,11 @@ export const DefaultPageMenu = memo(function DefaultPageMenu() { const handleCreatePageClick = useCallback(() => { if (isReadonlyMode) return - editor.batch(() => { - editor.mark('creating page') - const newPageId = PageRecordType.createId() - editor.createPage({ name: msg('page-menu.new-page-initial-name'), id: newPageId }) - editor.setCurrentPage(newPageId) - setIsEditing(true) - }) + editor.mark('creating page') + const newPageId = PageRecordType.createId() + editor.createPage({ name: msg('page-menu.new-page-initial-name'), id: newPageId }) + editor.setCurrentPage(newPageId) + setIsEditing(true) }, [editor, msg, isReadonlyMode]) return ( @@ -400,10 +398,8 @@ export const DefaultPageMenu = memo(function DefaultPageMenu() { editor.renamePage(page.id, name) } } else { - editor.batch(() => { - setIsEditing(true) - editor.setCurrentPage(page.id) - }) + setIsEditing(true) + editor.setCurrentPage(page.id) } }} /> diff --git a/packages/tldraw/src/lib/ui/components/StylePanel/DefaultStylePanelContent.tsx b/packages/tldraw/src/lib/ui/components/StylePanel/DefaultStylePanelContent.tsx index 68ecaa943..6b7b8eae5 100644 --- a/packages/tldraw/src/lib/ui/components/StylePanel/DefaultStylePanelContent.tsx +++ b/packages/tldraw/src/lib/ui/components/StylePanel/DefaultStylePanelContent.tsx @@ -78,13 +78,11 @@ function useStyleChangeCallback() { return React.useMemo( () => function handleStyleChange(style: StyleProp, value: T) { - editor.batch(() => { - if (editor.isIn('select')) { - editor.setStyleForSelectedShapes(style, value) - } - editor.setStyleForNextShapes(style, value) - editor.updateInstanceState({ isChangingStyle: true }) - }) + if (editor.isIn('select')) { + editor.setStyleForSelectedShapes(style, value) + } + editor.setStyleForNextShapes(style, value) + editor.updateInstanceState({ isChangingStyle: true }) trackEvent('set-style', { source: 'style-panel', id: style.id, value: value as string }) }, @@ -327,13 +325,11 @@ export function OpacitySlider() { const handleOpacityValueChange = React.useCallback( (value: number) => { const item = tldrawSupportedOpacities[value] - editor.batch(() => { - if (editor.isIn('select')) { - editor.setOpacityForSelectedShapes(item) - } - editor.setOpacityForNextShapes(item) - editor.updateInstanceState({ isChangingStyle: true }) - }) + if (editor.isIn('select')) { + editor.setOpacityForSelectedShapes(item) + } + editor.setOpacityForNextShapes(item) + editor.updateInstanceState({ isChangingStyle: true }) trackEvent('set-style', { source: 'style-panel', id: 'opacity', value }) }, diff --git a/packages/tldraw/src/lib/ui/context/actions.tsx b/packages/tldraw/src/lib/ui/context/actions.tsx index da9809e0a..0100946c4 100644 --- a/packages/tldraw/src/lib/ui/context/actions.tsx +++ b/packages/tldraw/src/lib/ui/context/actions.tsx @@ -386,40 +386,38 @@ export function ActionsProvider({ overrides, children }: ActionsProviderProps) { if (!canApplySelectionAction()) return if (mustGoBackToSelectToolFirst()) return - editor.batch(() => { - trackEvent('convert-to-bookmark', { source }) - const shapes = editor.getSelectedShapes() + trackEvent('convert-to-bookmark', { source }) + const shapes = editor.getSelectedShapes() - const createList: TLShapePartial[] = [] - const deleteList: TLShapeId[] = [] - for (const shape of shapes) { - if (!shape || !editor.isShapeOfType(shape, 'embed') || !shape.props.url) - continue + const createList: TLShapePartial[] = [] + const deleteList: TLShapeId[] = [] + for (const shape of shapes) { + if (!shape || !editor.isShapeOfType(shape, 'embed') || !shape.props.url) + continue - const newPos = new Vec(shape.x, shape.y) - newPos.rot(-shape.rotation) - newPos.add(new Vec(shape.props.w / 2 - 300 / 2, shape.props.h / 2 - 320 / 2)) // see bookmark shape util - newPos.rot(shape.rotation) - const partial: TLShapePartial = { - id: createShapeId(), - type: 'bookmark', - rotation: shape.rotation, - x: newPos.x, - y: newPos.y, - opacity: 1, - props: { - url: shape.props.url, - }, - } - - createList.push(partial) - deleteList.push(shape.id) + const newPos = new Vec(shape.x, shape.y) + newPos.rot(-shape.rotation) + newPos.add(new Vec(shape.props.w / 2 - 300 / 2, shape.props.h / 2 - 320 / 2)) // see bookmark shape util + newPos.rot(shape.rotation) + const partial: TLShapePartial = { + id: createShapeId(), + type: 'bookmark', + rotation: shape.rotation, + x: newPos.x, + y: newPos.y, + opacity: 1, + props: { + url: shape.props.url, + }, } - editor.mark('convert shapes to bookmark') - editor.deleteShapes(deleteList) - editor.createShapes(createList) - }) + createList.push(partial) + deleteList.push(shape.id) + } + + editor.mark('convert shapes to bookmark') + editor.deleteShapes(deleteList) + editor.createShapes(createList) }, }, { @@ -431,50 +429,48 @@ export function ActionsProvider({ overrides, children }: ActionsProviderProps) { trackEvent('convert-to-embed', { source }) - editor.batch(() => { - const ids = editor.getSelectedShapeIds() - const shapes = compact(ids.map((id) => editor.getShape(id))) + const ids = editor.getSelectedShapeIds() + const shapes = compact(ids.map((id) => editor.getShape(id))) - const createList: TLShapePartial[] = [] - const deleteList: TLShapeId[] = [] - for (const shape of shapes) { - if (!editor.isShapeOfType(shape, 'bookmark')) continue + const createList: TLShapePartial[] = [] + const deleteList: TLShapeId[] = [] + for (const shape of shapes) { + if (!editor.isShapeOfType(shape, 'bookmark')) continue - const { url } = shape.props + const { url } = shape.props - const embedInfo = getEmbedInfo(shape.props.url) + const embedInfo = getEmbedInfo(shape.props.url) - if (!embedInfo) continue - if (!embedInfo.definition) continue + if (!embedInfo) continue + if (!embedInfo.definition) continue - const { width, height } = embedInfo.definition + const { width, height } = embedInfo.definition - const newPos = new Vec(shape.x, shape.y) - newPos.rot(-shape.rotation) - newPos.add(new Vec(shape.props.w / 2 - width / 2, shape.props.h / 2 - height / 2)) - newPos.rot(shape.rotation) + const newPos = new Vec(shape.x, shape.y) + newPos.rot(-shape.rotation) + newPos.add(new Vec(shape.props.w / 2 - width / 2, shape.props.h / 2 - height / 2)) + newPos.rot(shape.rotation) - const shapeToCreate: TLShapePartial = { - id: createShapeId(), - type: 'embed', - x: newPos.x, - y: newPos.y, - rotation: shape.rotation, - props: { - url: url, - w: width, - h: height, - }, - } - - createList.push(shapeToCreate) - deleteList.push(shape.id) + const shapeToCreate: TLShapePartial = { + id: createShapeId(), + type: 'embed', + x: newPos.x, + y: newPos.y, + rotation: shape.rotation, + props: { + url: url, + w: width, + h: height, + }, } - editor.mark('convert shapes to embed') - editor.deleteShapes(deleteList) - editor.createShapes(createList) - }) + createList.push(shapeToCreate) + deleteList.push(shape.id) + } + + editor.mark('convert shapes to embed') + editor.deleteShapes(deleteList) + editor.createShapes(createList) }, }, { @@ -921,14 +917,12 @@ export function ActionsProvider({ overrides, children }: ActionsProviderProps) { kbd: '$a', readonlyOk: true, onSelect(source) { - editor.batch(() => { - if (mustGoBackToSelectToolFirst()) return + if (mustGoBackToSelectToolFirst()) return - trackEvent('select-all-shapes', { source }) + trackEvent('select-all-shapes', { source }) - editor.mark('select all kbd') - editor.selectAll() - }) + editor.mark('select all kbd') + editor.selectAll() }, }, { @@ -1177,12 +1171,10 @@ export function ActionsProvider({ overrides, children }: ActionsProviderProps) { // this needs to be deferred because it causes the menu // UI to unmount which puts us in a dodgy state requestAnimationFrame(() => { - editor.batch(() => { - trackEvent('toggle-focus-mode', { source }) - clearDialogs() - clearToasts() - editor.updateInstanceState({ isFocusMode: !editor.getInstanceState().isFocusMode }) - }) + trackEvent('toggle-focus-mode', { source }) + clearDialogs() + clearToasts() + editor.updateInstanceState({ isFocusMode: !editor.getInstanceState().isFocusMode }) }) }, }, @@ -1271,11 +1263,9 @@ export function ActionsProvider({ overrides, children }: ActionsProviderProps) { onSelect(source) { const newPageId = PageRecordType.createId() const ids = editor.getSelectedShapeIds() - editor.batch(() => { - editor.mark('move_shapes_to_page') - editor.createPage({ name: msg('page-menu.new-page-initial-name'), id: newPageId }) - editor.moveShapesToPage(ids, newPageId) - }) + editor.mark('move_shapes_to_page') + editor.createPage({ name: msg('page-menu.new-page-initial-name'), id: newPageId }) + editor.moveShapesToPage(ids, newPageId) trackEvent('new-page', { source }) }, }, @@ -1285,14 +1275,12 @@ export function ActionsProvider({ overrides, children }: ActionsProviderProps) { kbd: '?t', onSelect(source) { const style = DefaultColorStyle - editor.batch(() => { - editor.mark('change-color') - if (editor.isIn('select')) { - editor.setStyleForSelectedShapes(style, 'white') - } - editor.setStyleForNextShapes(style, 'white') - editor.updateInstanceState({ isChangingStyle: true }) - }) + editor.mark('change-color') + if (editor.isIn('select')) { + editor.setStyleForSelectedShapes(style, 'white') + } + editor.setStyleForNextShapes(style, 'white') + editor.updateInstanceState({ isChangingStyle: true }) trackEvent('set-style', { source, id: style.id, value: 'white' }) }, }, diff --git a/packages/tldraw/src/lib/ui/hooks/useMenuIsOpen.ts b/packages/tldraw/src/lib/ui/hooks/useMenuIsOpen.ts index 3f4e214a2..b05219b2d 100644 --- a/packages/tldraw/src/lib/ui/hooks/useMenuIsOpen.ts +++ b/packages/tldraw/src/lib/ui/hooks/useMenuIsOpen.ts @@ -12,18 +12,16 @@ export function useMenuIsOpen(id: string, cb?: (isOpen: boolean) => void) { (isOpen: boolean) => { rIsOpen.current = isOpen - editor.batch(() => { - if (isOpen) { - editor.complete() - editor.addOpenMenu(id) - } else { - editor.updateInstanceState({ - openMenus: editor.getOpenMenus().filter((m) => !m.startsWith(id)), - }) - } + if (isOpen) { + editor.complete() + editor.addOpenMenu(id) + } else { + editor.updateInstanceState({ + openMenus: editor.getOpenMenus().filter((m) => !m.startsWith(id)), + }) + } - cb?.(isOpen) - }) + cb?.(isOpen) }, [editor, id, cb] ) diff --git a/packages/tldraw/src/lib/ui/hooks/useTools.tsx b/packages/tldraw/src/lib/ui/hooks/useTools.tsx index fd05a340b..66f8589c2 100644 --- a/packages/tldraw/src/lib/ui/hooks/useTools.tsx +++ b/packages/tldraw/src/lib/ui/hooks/useTools.tsx @@ -101,11 +101,9 @@ export function ToolsProvider({ overrides, children }: TLUiToolsProviderProps) { kbd: id === 'rectangle' ? 'r' : id === 'ellipse' ? 'o' : undefined, icon: ('geo-' + id) as TLUiIconType, onSelect(source: TLUiEventSource) { - editor.batch(() => { - editor.setStyleForNextShapes(GeoShapeGeoStyle, id) - editor.setCurrentTool('geo') - trackEvent('select-tool', { source, id: `geo-${id}` }) - }) + editor.setStyleForNextShapes(GeoShapeGeoStyle, id) + editor.setCurrentTool('geo') + trackEvent('select-tool', { source, id: `geo-${id}` }) }, })), { diff --git a/packages/tldraw/src/lib/utils/frames/frames.ts b/packages/tldraw/src/lib/utils/frames/frames.ts index f43b341ff..4bcad8ae1 100644 --- a/packages/tldraw/src/lib/utils/frames/frames.ts +++ b/packages/tldraw/src/lib/utils/frames/frames.ts @@ -17,17 +17,15 @@ export function removeFrame(editor: Editor, ids: TLShapeId[]) { if (!frames.length) return const allChildren: TLShapeId[] = [] - editor.batch(() => { - frames.map((frame) => { - const children = editor.getSortedChildIdsForParent(frame.id) - if (children.length) { - editor.reparentShapes(children, frame.parentId, frame.index) - allChildren.push(...children) - } - }) - editor.setSelectedShapes(allChildren) - editor.deleteShapes(ids) + frames.map((frame) => { + const children = editor.getSortedChildIdsForParent(frame.id) + if (children.length) { + editor.reparentShapes(children, frame.parentId, frame.index) + allChildren.push(...children) + } }) + editor.setSelectedShapes(allChildren) + editor.deleteShapes(ids) } /** @internal */ @@ -66,28 +64,26 @@ export function fitFrameToContent(editor: Editor, id: TLShapeId, opts = {} as { if (dx === 0 && dy === 0 && frame.props.w === w && frame.props.h === h) return const diff = new Vec(dx, dy).rot(frame.rotation) - editor.batch(() => { - const changes: TLShapePartial[] = childIds.map((child) => { - const shape = editor.getShape(child)! - return { - id: shape.id, - type: shape.type, - x: shape.x + dx, - y: shape.y + dy, - } - }) - - changes.push({ - id: frame.id, - type: frame.type, - x: frame.x - diff.x, - y: frame.y - diff.y, - props: { - w, - h, - }, - }) - - editor.updateShapes(changes) + const changes: TLShapePartial[] = childIds.map((child) => { + const shape = editor.getShape(child)! + return { + id: shape.id, + type: shape.type, + x: shape.x + dx, + y: shape.y + dy, + } }) + + changes.push({ + id: frame.id, + type: frame.type, + x: frame.x - diff.x, + y: frame.y - diff.y, + props: { + w, + h, + }, + }) + + editor.updateShapes(changes) } diff --git a/packages/tldraw/src/lib/utils/tldr/buildFromV1Document.ts b/packages/tldraw/src/lib/utils/tldr/buildFromV1Document.ts index 1d894703d..1fb6c0ea1 100644 --- a/packages/tldraw/src/lib/utils/tldr/buildFromV1Document.ts +++ b/packages/tldraw/src/lib/utils/tldr/buildFromV1Document.ts @@ -31,569 +31,567 @@ const TLDRAW_V1_VERSION = 15.5 /** @internal */ export function buildFromV1Document(editor: Editor, document: LegacyTldrawDocument) { - editor.batch(() => { - document = migrate(document, TLDRAW_V1_VERSION) - // Cancel any interactions / states - editor.cancel().cancel().cancel().cancel() + document = migrate(document, TLDRAW_V1_VERSION) + // Cancel any interactions / states + editor.cancel().cancel().cancel().cancel() - const firstPageId = editor.getPages()[0].id + const firstPageId = editor.getPages()[0].id - // Set the current page to the first page - editor.setCurrentPage(firstPageId) + // Set the current page to the first page + editor.setCurrentPage(firstPageId) - // Delete all pages except first page - for (const page of editor.getPages().slice(1)) { - editor.deletePage(page.id) - } + // Delete all pages except first page + for (const page of editor.getPages().slice(1)) { + editor.deletePage(page.id) + } - // Delete all of the shapes on the current page - editor.selectAll() - editor.deleteShapes(editor.getSelectedShapeIds()) + // Delete all of the shapes on the current page + editor.selectAll() + editor.deleteShapes(editor.getSelectedShapeIds()) - // Create assets - const v1AssetIdsToV2AssetIds = new Map() + // Create assets + const v1AssetIdsToV2AssetIds = new Map() - Object.values(document.assets ?? {}).forEach((v1Asset) => { - switch (v1Asset.type) { - case TDAssetType.Image: { + Object.values(document.assets ?? {}).forEach((v1Asset) => { + switch (v1Asset.type) { + case TDAssetType.Image: { + const assetId: TLAssetId = AssetRecordType.createId() + v1AssetIdsToV2AssetIds.set(v1Asset.id, assetId) + const placeholderAsset: TLAsset = { + id: assetId, + typeName: 'asset', + type: 'image', + props: { + w: coerceDimension(v1Asset.size[0]), + h: coerceDimension(v1Asset.size[1]), + name: v1Asset.fileName ?? 'Untitled', + isAnimated: false, + mimeType: null, + src: v1Asset.src, + }, + meta: {}, + } + editor.createAssets([placeholderAsset]) + tryMigrateAsset(editor, placeholderAsset) + break + } + case TDAssetType.Video: + { const assetId: TLAssetId = AssetRecordType.createId() v1AssetIdsToV2AssetIds.set(v1Asset.id, assetId) - const placeholderAsset: TLAsset = { - id: assetId, - typeName: 'asset', - type: 'image', - props: { - w: coerceDimension(v1Asset.size[0]), - h: coerceDimension(v1Asset.size[1]), - name: v1Asset.fileName ?? 'Untitled', - isAnimated: false, - mimeType: null, - src: v1Asset.src, - }, - meta: {}, - } - editor.createAssets([placeholderAsset]) - tryMigrateAsset(editor, placeholderAsset) - break - } - case TDAssetType.Video: - { - const assetId: TLAssetId = AssetRecordType.createId() - v1AssetIdsToV2AssetIds.set(v1Asset.id, assetId) - editor.createAssets([ - { - id: assetId, - typeName: 'asset', - type: 'video', - props: { - w: coerceDimension(v1Asset.size[0]), - h: coerceDimension(v1Asset.size[1]), - name: v1Asset.fileName ?? 'Untitled', - isAnimated: true, - mimeType: null, - src: v1Asset.src, - }, - meta: {}, + editor.createAssets([ + { + id: assetId, + typeName: 'asset', + type: 'video', + props: { + w: coerceDimension(v1Asset.size[0]), + h: coerceDimension(v1Asset.size[1]), + name: v1Asset.fileName ?? 'Untitled', + isAnimated: true, + mimeType: null, + src: v1Asset.src, }, - ]) - } - break + meta: {}, + }, + ]) + } + break + } + }) + + // Create pages + + const v1PageIdsToV2PageIds = new Map() + + Object.values(document.pages ?? {}) + .sort((a, b) => ((a.childIndex ?? 1) < (b.childIndex ?? 1) ? -1 : 1)) + .forEach((v1Page, i) => { + if (i === 0) { + v1PageIdsToV2PageIds.set(v1Page.id, editor.getCurrentPageId()) + } else { + const pageId = PageRecordType.createId() + v1PageIdsToV2PageIds.set(v1Page.id, pageId) + editor.createPage({ name: v1Page.name ?? 'Page', id: pageId }) } }) - // Create pages + Object.values(document.pages ?? {}) + .sort((a, b) => ((a.childIndex ?? 1) < (b.childIndex ?? 1) ? -1 : 1)) + .forEach((v1Page) => { + // Set the current page id to the current page + editor.setCurrentPage(v1PageIdsToV2PageIds.get(v1Page.id)!) - const v1PageIdsToV2PageIds = new Map() + const v1ShapeIdsToV2ShapeIds = new Map() + const v1GroupShapeIdsToV1ChildIds = new Map() - Object.values(document.pages ?? {}) - .sort((a, b) => ((a.childIndex ?? 1) < (b.childIndex ?? 1) ? -1 : 1)) - .forEach((v1Page, i) => { - if (i === 0) { - v1PageIdsToV2PageIds.set(v1Page.id, editor.getCurrentPageId()) - } else { - const pageId = PageRecordType.createId() - v1PageIdsToV2PageIds.set(v1Page.id, pageId) - editor.createPage({ name: v1Page.name ?? 'Page', id: pageId }) + const v1Shapes = Object.values(v1Page.shapes ?? {}) + .sort((a, b) => (a.childIndex < b.childIndex ? -1 : 1)) + .slice(0, MAX_SHAPES_PER_PAGE) + + // Groups only + v1Shapes.forEach((v1Shape) => { + if (v1Shape.type !== TDShapeType.Group) return + + const shapeId = createShapeId() + v1ShapeIdsToV2ShapeIds.set(v1Shape.id, shapeId) + v1GroupShapeIdsToV1ChildIds.set(v1Shape.id, []) + }) + + function decideNotToCreateShape(v1Shape: TDShape) { + v1ShapeIdsToV2ShapeIds.delete(v1Shape.id) + const v1GroupParent = v1GroupShapeIdsToV1ChildIds.has(v1Shape.parentId) + if (v1GroupParent) { + const ids = v1GroupShapeIdsToV1ChildIds + .get(v1Shape.parentId)! + .filter((id) => id !== v1Shape.id) + v1GroupShapeIdsToV1ChildIds.set(v1Shape.parentId, ids) + } + } + + // Non-groups only + v1Shapes.forEach((v1Shape) => { + // Skip groups for now, we'll create groups via the app's API + if (v1Shape.type === TDShapeType.Group) { + return + } + + const shapeId = createShapeId() + v1ShapeIdsToV2ShapeIds.set(v1Shape.id, shapeId) + + if (v1Shape.parentId !== v1Page.id) { + // If the parent is a group, then add the shape to the group's children + if (v1GroupShapeIdsToV1ChildIds.has(v1Shape.parentId)) { + v1GroupShapeIdsToV1ChildIds.get(v1Shape.parentId)!.push(v1Shape.id) + } else { + console.warn('parent does not exist', v1Shape) + } + } + + // First, try to find the shape's parent among the existing groups + const parentId = v1PageIdsToV2PageIds.get(v1Page.id)! + + const inCommon = { + id: shapeId, + parentId, + x: coerceNumber(v1Shape.point[0]), + y: coerceNumber(v1Shape.point[1]), + rotation: 0, + isLocked: !!v1Shape.isLocked, + } + + switch (v1Shape.type) { + case TDShapeType.Sticky: { + editor.createShapes([ + { + ...inCommon, + type: 'note', + props: { + text: v1Shape.text ?? '', + color: getV2Color(v1Shape.style.color), + size: getV2Size(v1Shape.style.size), + font: getV2Font(v1Shape.style.font), + align: getV2Align(v1Shape.style.textAlign), + }, + }, + ]) + break + } + case TDShapeType.Rectangle: { + editor.createShapes([ + { + ...inCommon, + type: 'geo', + props: { + geo: 'rectangle', + w: coerceDimension(v1Shape.size[0]), + h: coerceDimension(v1Shape.size[1]), + text: v1Shape.label ?? '', + fill: getV2Fill(v1Shape.style.isFilled, v1Shape.style.color), + labelColor: getV2Color(v1Shape.style.color), + color: getV2Color(v1Shape.style.color), + size: getV2Size(v1Shape.style.size), + font: getV2Font(v1Shape.style.font), + dash: getV2Dash(v1Shape.style.dash), + align: 'middle', + }, + }, + ]) + + const pageBoundsBeforeLabel = editor.getShapePageBounds(inCommon.id)! + + editor.updateShapes([ + { + id: inCommon.id, + type: 'geo', + props: { + text: v1Shape.label ?? '', + }, + }, + ]) + + if (pageBoundsBeforeLabel.width === pageBoundsBeforeLabel.height) { + const shape = editor.getShape(inCommon.id)! + const { growY } = shape.props + const w = coerceDimension(shape.props.w) + const h = coerceDimension(shape.props.h) + const newW = w + growY / 2 + const newH = h + growY / 2 + + editor.updateShapes([ + { + id: inCommon.id, + type: 'geo', + x: coerceNumber(shape.x) - (newW - w) / 2, + y: coerceNumber(shape.y) - (newH - h) / 2, + props: { + w: newW, + h: newH, + }, + }, + ]) + } + break + } + case TDShapeType.Triangle: { + editor.createShapes([ + { + ...inCommon, + type: 'geo', + props: { + geo: 'triangle', + w: coerceDimension(v1Shape.size[0]), + h: coerceDimension(v1Shape.size[1]), + fill: getV2Fill(v1Shape.style.isFilled, v1Shape.style.color), + labelColor: getV2Color(v1Shape.style.color), + color: getV2Color(v1Shape.style.color), + size: getV2Size(v1Shape.style.size), + font: getV2Font(v1Shape.style.font), + dash: getV2Dash(v1Shape.style.dash), + align: 'middle', + }, + }, + ]) + + const pageBoundsBeforeLabel = editor.getShapePageBounds(inCommon.id)! + + editor.updateShapes([ + { + id: inCommon.id, + type: 'geo', + props: { + text: v1Shape.label ?? '', + }, + }, + ]) + + if (pageBoundsBeforeLabel.width === pageBoundsBeforeLabel.height) { + const shape = editor.getShape(inCommon.id)! + const { growY } = shape.props + const w = coerceDimension(shape.props.w) + const h = coerceDimension(shape.props.h) + const newW = w + growY / 2 + const newH = h + growY / 2 + + editor.updateShapes([ + { + id: inCommon.id, + type: 'geo', + x: coerceNumber(shape.x) - (newW - w) / 2, + y: coerceNumber(shape.y) - (newH - h) / 2, + props: { + w: newW, + h: newH, + }, + }, + ]) + } + break + } + case TDShapeType.Ellipse: { + editor.createShapes([ + { + ...inCommon, + type: 'geo', + props: { + geo: 'ellipse', + w: coerceDimension(v1Shape.radius[0]) * 2, + h: coerceDimension(v1Shape.radius[1]) * 2, + fill: getV2Fill(v1Shape.style.isFilled, v1Shape.style.color), + labelColor: getV2Color(v1Shape.style.color), + color: getV2Color(v1Shape.style.color), + size: getV2Size(v1Shape.style.size), + font: getV2Font(v1Shape.style.font), + dash: getV2Dash(v1Shape.style.dash), + align: 'middle', + }, + }, + ]) + + const pageBoundsBeforeLabel = editor.getShapePageBounds(inCommon.id)! + + editor.updateShapes([ + { + id: inCommon.id, + type: 'geo', + props: { + text: v1Shape.label ?? '', + }, + }, + ]) + + if (pageBoundsBeforeLabel.width === pageBoundsBeforeLabel.height) { + const shape = editor.getShape(inCommon.id)! + const { growY } = shape.props + const w = coerceDimension(shape.props.w) + const h = coerceDimension(shape.props.h) + const newW = w + growY / 2 + const newH = h + growY / 2 + + editor.updateShapes([ + { + id: inCommon.id, + type: 'geo', + x: coerceNumber(shape.x) - (newW - w) / 2, + y: coerceNumber(shape.y) - (newH - h) / 2, + props: { + w: newW, + h: newH, + }, + }, + ]) + } + + break + } + case TDShapeType.Draw: { + if (v1Shape.points.length === 0) { + decideNotToCreateShape(v1Shape) + break + } + + editor.createShapes([ + { + ...inCommon, + type: 'draw', + props: { + fill: getV2Fill(v1Shape.style.isFilled, v1Shape.style.color), + color: getV2Color(v1Shape.style.color), + size: getV2Size(v1Shape.style.size), + dash: getV2Dash(v1Shape.style.dash), + isPen: false, + isComplete: v1Shape.isComplete, + segments: [{ type: 'free', points: v1Shape.points.map(getV2Point) }], + }, + }, + ]) + break + } + case TDShapeType.Arrow: { + const v1Bend = coerceNumber(v1Shape.bend) + const v1Start = getV2Point(v1Shape.handles.start.point) + const v1End = getV2Point(v1Shape.handles.end.point) + const dist = Vec.Dist(v1Start, v1End) + const v2Bend = (dist * -v1Bend) / 2 + + // Could also be a line... but we'll use it as an arrow anyway + editor.createShapes([ + { + ...inCommon, + type: 'arrow', + props: { + text: v1Shape.label ?? '', + color: getV2Color(v1Shape.style.color), + labelColor: getV2Color(v1Shape.style.color), + size: getV2Size(v1Shape.style.size), + font: getV2Font(v1Shape.style.font), + dash: getV2Dash(v1Shape.style.dash), + arrowheadStart: getV2Arrowhead(v1Shape.decorations?.start), + arrowheadEnd: getV2Arrowhead(v1Shape.decorations?.end), + start: { + type: 'point', + x: coerceNumber(v1Shape.handles.start.point[0]), + y: coerceNumber(v1Shape.handles.start.point[1]), + }, + end: { + type: 'point', + x: coerceNumber(v1Shape.handles.end.point[0]), + y: coerceNumber(v1Shape.handles.end.point[1]), + }, + bend: v2Bend, + }, + }, + ]) + + break + } + case TDShapeType.Text: { + editor.createShapes([ + { + ...inCommon, + type: 'text', + props: { + text: v1Shape.text ?? ' ', + color: getV2Color(v1Shape.style.color), + size: getV2TextSize(v1Shape.style.size), + font: getV2Font(v1Shape.style.font), + align: getV2Align(v1Shape.style.textAlign), + scale: v1Shape.style.scale ?? 1, + }, + }, + ]) + break + } + case TDShapeType.Image: { + const assetId = v1AssetIdsToV2AssetIds.get(v1Shape.assetId) + + if (!assetId) { + console.warn('Could not find asset id', v1Shape.assetId) + return + } + + editor.createShapes([ + { + ...inCommon, + type: 'image', + props: { + w: coerceDimension(v1Shape.size[0]), + h: coerceDimension(v1Shape.size[1]), + assetId, + }, + }, + ]) + break + } + case TDShapeType.Video: { + const assetId = v1AssetIdsToV2AssetIds.get(v1Shape.assetId) + + if (!assetId) { + console.warn('Could not find asset id', v1Shape.assetId) + return + } + + editor.createShapes([ + { + ...inCommon, + type: 'video', + props: { + w: coerceDimension(v1Shape.size[0]), + h: coerceDimension(v1Shape.size[1]), + assetId, + }, + }, + ]) + break + } + } + + const rotation = coerceNumber(v1Shape.rotation) + + if (rotation !== 0) { + editor.select(shapeId) + editor.rotateShapesBy([shapeId], rotation) } }) - Object.values(document.pages ?? {}) - .sort((a, b) => ((a.childIndex ?? 1) < (b.childIndex ?? 1) ? -1 : 1)) - .forEach((v1Page) => { - // Set the current page id to the current page - editor.setCurrentPage(v1PageIdsToV2PageIds.get(v1Page.id)!) + // Create groups + v1GroupShapeIdsToV1ChildIds.forEach((v1ChildIds, v1GroupId) => { + const v2ChildShapeIds = v1ChildIds.map((id) => v1ShapeIdsToV2ShapeIds.get(id)!) + const v2GroupId = v1ShapeIdsToV2ShapeIds.get(v1GroupId)! + editor.groupShapes(v2ChildShapeIds, v2GroupId) - const v1ShapeIdsToV2ShapeIds = new Map() - const v1GroupShapeIdsToV1ChildIds = new Map() + const v1Group = v1Page.shapes[v1GroupId] + const rotation = coerceNumber(v1Group.rotation) - const v1Shapes = Object.values(v1Page.shapes ?? {}) - .sort((a, b) => (a.childIndex < b.childIndex ? -1 : 1)) - .slice(0, MAX_SHAPES_PER_PAGE) + if (rotation !== 0) { + editor.select(v2GroupId) + editor.rotateShapesBy([v2GroupId], rotation) + } + }) - // Groups only - v1Shapes.forEach((v1Shape) => { - if (v1Shape.type !== TDShapeType.Group) return + // Bind arrows to shapes - const shapeId = createShapeId() - v1ShapeIdsToV2ShapeIds.set(v1Shape.id, shapeId) - v1GroupShapeIdsToV1ChildIds.set(v1Shape.id, []) - }) - - function decideNotToCreateShape(v1Shape: TDShape) { - v1ShapeIdsToV2ShapeIds.delete(v1Shape.id) - const v1GroupParent = v1GroupShapeIdsToV1ChildIds.has(v1Shape.parentId) - if (v1GroupParent) { - const ids = v1GroupShapeIdsToV1ChildIds - .get(v1Shape.parentId)! - .filter((id) => id !== v1Shape.id) - v1GroupShapeIdsToV1ChildIds.set(v1Shape.parentId, ids) - } + v1Shapes.forEach((v1Shape) => { + if (v1Shape.type !== TDShapeType.Arrow) { + return } - // Non-groups only - v1Shapes.forEach((v1Shape) => { - // Skip groups for now, we'll create groups via the app's API - if (v1Shape.type === TDShapeType.Group) { - return - } + const v2ShapeId = v1ShapeIdsToV2ShapeIds.get(v1Shape.id)! + const util = editor.getShapeUtil('arrow') - const shapeId = createShapeId() - v1ShapeIdsToV2ShapeIds.set(v1Shape.id, shapeId) + // dumb but necessary + editor.inputs.ctrlKey = false - if (v1Shape.parentId !== v1Page.id) { - // If the parent is a group, then add the shape to the group's children - if (v1GroupShapeIdsToV1ChildIds.has(v1Shape.parentId)) { - v1GroupShapeIdsToV1ChildIds.get(v1Shape.parentId)!.push(v1Shape.id) - } else { - console.warn('parent does not exist', v1Shape) + for (const handleId of ['start', 'end'] as const) { + const bindingId = v1Shape.handles[handleId].bindingId + if (bindingId) { + const binding = v1Page.bindings[bindingId] + if (!binding) { + // arrow has a reference to a binding that no longer exists + continue } - } - // First, try to find the shape's parent among the existing groups - const parentId = v1PageIdsToV2PageIds.get(v1Page.id)! + const targetId = v1ShapeIdsToV2ShapeIds.get(binding.toId)! - const inCommon = { - id: shapeId, - parentId, - x: coerceNumber(v1Shape.point[0]), - y: coerceNumber(v1Shape.point[1]), - rotation: 0, - isLocked: !!v1Shape.isLocked, - } + const targetShape = editor.getShape(targetId)! - switch (v1Shape.type) { - case TDShapeType.Sticky: { - editor.createShapes([ - { - ...inCommon, - type: 'note', - props: { - text: v1Shape.text ?? '', - color: getV2Color(v1Shape.style.color), - size: getV2Size(v1Shape.style.size), - font: getV2Font(v1Shape.style.font), - align: getV2Align(v1Shape.style.textAlign), - }, + // (unexpected) We didn't create the target shape + if (!targetShape) continue + + if (targetId) { + const bounds = editor.getShapePageBounds(targetId)! + + const v2ShapeFresh = editor.getShape(v2ShapeId)! + + const nx = clamp((coerceNumber(binding.point[0]) + 0.5) / 2, 0.2, 0.8) + const ny = clamp((coerceNumber(binding.point[1]) + 0.5) / 2, 0.2, 0.8) + + const point = editor.getPointInShapeSpace(v2ShapeFresh, { + x: bounds.minX + bounds.width * nx, + y: bounds.minY + bounds.height * ny, + }) + + const handles = editor.getShapeHandles(v2ShapeFresh)! + const change = util.onHandleDrag!(v2ShapeFresh, { + handle: { + ...handles.find((h) => h.id === handleId)!, + x: point.x, + y: point.y, }, - ]) - break - } - case TDShapeType.Rectangle: { - editor.createShapes([ - { - ...inCommon, - type: 'geo', - props: { - geo: 'rectangle', - w: coerceDimension(v1Shape.size[0]), - h: coerceDimension(v1Shape.size[1]), - text: v1Shape.label ?? '', - fill: getV2Fill(v1Shape.style.isFilled, v1Shape.style.color), - labelColor: getV2Color(v1Shape.style.color), - color: getV2Color(v1Shape.style.color), - size: getV2Size(v1Shape.style.size), - font: getV2Font(v1Shape.style.font), - dash: getV2Dash(v1Shape.style.dash), - align: 'middle', - }, - }, - ]) + isPrecise: point.x !== 0.5 || point.y !== 0.5, + }) - const pageBoundsBeforeLabel = editor.getShapePageBounds(inCommon.id)! + if (change) { + if (change.props?.[handleId]) { + const terminal = change.props?.[handleId] as TLArrowShapeTerminal + if (terminal.type === 'binding') { + terminal.isExact = binding.distance === 0 - editor.updateShapes([ - { - id: inCommon.id, - type: 'geo', - props: { - text: v1Shape.label ?? '', - }, - }, - ]) - - if (pageBoundsBeforeLabel.width === pageBoundsBeforeLabel.height) { - const shape = editor.getShape(inCommon.id)! - const { growY } = shape.props - const w = coerceDimension(shape.props.w) - const h = coerceDimension(shape.props.h) - const newW = w + growY / 2 - const newH = h + growY / 2 - - editor.updateShapes([ - { - id: inCommon.id, - type: 'geo', - x: coerceNumber(shape.x) - (newW - w) / 2, - y: coerceNumber(shape.y) - (newH - h) / 2, - props: { - w: newW, - h: newH, - }, - }, - ]) - } - break - } - case TDShapeType.Triangle: { - editor.createShapes([ - { - ...inCommon, - type: 'geo', - props: { - geo: 'triangle', - w: coerceDimension(v1Shape.size[0]), - h: coerceDimension(v1Shape.size[1]), - fill: getV2Fill(v1Shape.style.isFilled, v1Shape.style.color), - labelColor: getV2Color(v1Shape.style.color), - color: getV2Color(v1Shape.style.color), - size: getV2Size(v1Shape.style.size), - font: getV2Font(v1Shape.style.font), - dash: getV2Dash(v1Shape.style.dash), - align: 'middle', - }, - }, - ]) - - const pageBoundsBeforeLabel = editor.getShapePageBounds(inCommon.id)! - - editor.updateShapes([ - { - id: inCommon.id, - type: 'geo', - props: { - text: v1Shape.label ?? '', - }, - }, - ]) - - if (pageBoundsBeforeLabel.width === pageBoundsBeforeLabel.height) { - const shape = editor.getShape(inCommon.id)! - const { growY } = shape.props - const w = coerceDimension(shape.props.w) - const h = coerceDimension(shape.props.h) - const newW = w + growY / 2 - const newH = h + growY / 2 - - editor.updateShapes([ - { - id: inCommon.id, - type: 'geo', - x: coerceNumber(shape.x) - (newW - w) / 2, - y: coerceNumber(shape.y) - (newH - h) / 2, - props: { - w: newW, - h: newH, - }, - }, - ]) - } - break - } - case TDShapeType.Ellipse: { - editor.createShapes([ - { - ...inCommon, - type: 'geo', - props: { - geo: 'ellipse', - w: coerceDimension(v1Shape.radius[0]) * 2, - h: coerceDimension(v1Shape.radius[1]) * 2, - fill: getV2Fill(v1Shape.style.isFilled, v1Shape.style.color), - labelColor: getV2Color(v1Shape.style.color), - color: getV2Color(v1Shape.style.color), - size: getV2Size(v1Shape.style.size), - font: getV2Font(v1Shape.style.font), - dash: getV2Dash(v1Shape.style.dash), - align: 'middle', - }, - }, - ]) - - const pageBoundsBeforeLabel = editor.getShapePageBounds(inCommon.id)! - - editor.updateShapes([ - { - id: inCommon.id, - type: 'geo', - props: { - text: v1Shape.label ?? '', - }, - }, - ]) - - if (pageBoundsBeforeLabel.width === pageBoundsBeforeLabel.height) { - const shape = editor.getShape(inCommon.id)! - const { growY } = shape.props - const w = coerceDimension(shape.props.w) - const h = coerceDimension(shape.props.h) - const newW = w + growY / 2 - const newH = h + growY / 2 - - editor.updateShapes([ - { - id: inCommon.id, - type: 'geo', - x: coerceNumber(shape.x) - (newW - w) / 2, - y: coerceNumber(shape.y) - (newH - h) / 2, - props: { - w: newW, - h: newH, - }, - }, - ]) - } - - break - } - case TDShapeType.Draw: { - if (v1Shape.points.length === 0) { - decideNotToCreateShape(v1Shape) - break - } - - editor.createShapes([ - { - ...inCommon, - type: 'draw', - props: { - fill: getV2Fill(v1Shape.style.isFilled, v1Shape.style.color), - color: getV2Color(v1Shape.style.color), - size: getV2Size(v1Shape.style.size), - dash: getV2Dash(v1Shape.style.dash), - isPen: false, - isComplete: v1Shape.isComplete, - segments: [{ type: 'free', points: v1Shape.points.map(getV2Point) }], - }, - }, - ]) - break - } - case TDShapeType.Arrow: { - const v1Bend = coerceNumber(v1Shape.bend) - const v1Start = getV2Point(v1Shape.handles.start.point) - const v1End = getV2Point(v1Shape.handles.end.point) - const dist = Vec.Dist(v1Start, v1End) - const v2Bend = (dist * -v1Bend) / 2 - - // Could also be a line... but we'll use it as an arrow anyway - editor.createShapes([ - { - ...inCommon, - type: 'arrow', - props: { - text: v1Shape.label ?? '', - color: getV2Color(v1Shape.style.color), - labelColor: getV2Color(v1Shape.style.color), - size: getV2Size(v1Shape.style.size), - font: getV2Font(v1Shape.style.font), - dash: getV2Dash(v1Shape.style.dash), - arrowheadStart: getV2Arrowhead(v1Shape.decorations?.start), - arrowheadEnd: getV2Arrowhead(v1Shape.decorations?.end), - start: { - type: 'point', - x: coerceNumber(v1Shape.handles.start.point[0]), - y: coerceNumber(v1Shape.handles.start.point[1]), - }, - end: { - type: 'point', - x: coerceNumber(v1Shape.handles.end.point[0]), - y: coerceNumber(v1Shape.handles.end.point[1]), - }, - bend: v2Bend, - }, - }, - ]) - - break - } - case TDShapeType.Text: { - editor.createShapes([ - { - ...inCommon, - type: 'text', - props: { - text: v1Shape.text ?? ' ', - color: getV2Color(v1Shape.style.color), - size: getV2TextSize(v1Shape.style.size), - font: getV2Font(v1Shape.style.font), - align: getV2Align(v1Shape.style.textAlign), - scale: v1Shape.style.scale ?? 1, - }, - }, - ]) - break - } - case TDShapeType.Image: { - const assetId = v1AssetIdsToV2AssetIds.get(v1Shape.assetId) - - if (!assetId) { - console.warn('Could not find asset id', v1Shape.assetId) - return - } - - editor.createShapes([ - { - ...inCommon, - type: 'image', - props: { - w: coerceDimension(v1Shape.size[0]), - h: coerceDimension(v1Shape.size[1]), - assetId, - }, - }, - ]) - break - } - case TDShapeType.Video: { - const assetId = v1AssetIdsToV2AssetIds.get(v1Shape.assetId) - - if (!assetId) { - console.warn('Could not find asset id', v1Shape.assetId) - return - } - - editor.createShapes([ - { - ...inCommon, - type: 'video', - props: { - w: coerceDimension(v1Shape.size[0]), - h: coerceDimension(v1Shape.size[1]), - assetId, - }, - }, - ]) - break - } - } - - const rotation = coerceNumber(v1Shape.rotation) - - if (rotation !== 0) { - editor.select(shapeId) - editor.rotateShapesBy([shapeId], rotation) - } - }) - - // Create groups - v1GroupShapeIdsToV1ChildIds.forEach((v1ChildIds, v1GroupId) => { - const v2ChildShapeIds = v1ChildIds.map((id) => v1ShapeIdsToV2ShapeIds.get(id)!) - const v2GroupId = v1ShapeIdsToV2ShapeIds.get(v1GroupId)! - editor.groupShapes(v2ChildShapeIds, v2GroupId) - - const v1Group = v1Page.shapes[v1GroupId] - const rotation = coerceNumber(v1Group.rotation) - - if (rotation !== 0) { - editor.select(v2GroupId) - editor.rotateShapesBy([v2GroupId], rotation) - } - }) - - // Bind arrows to shapes - - v1Shapes.forEach((v1Shape) => { - if (v1Shape.type !== TDShapeType.Arrow) { - return - } - - const v2ShapeId = v1ShapeIdsToV2ShapeIds.get(v1Shape.id)! - const util = editor.getShapeUtil('arrow') - - // dumb but necessary - editor.inputs.ctrlKey = false - - for (const handleId of ['start', 'end'] as const) { - const bindingId = v1Shape.handles[handleId].bindingId - if (bindingId) { - const binding = v1Page.bindings[bindingId] - if (!binding) { - // arrow has a reference to a binding that no longer exists - continue - } - - const targetId = v1ShapeIdsToV2ShapeIds.get(binding.toId)! - - const targetShape = editor.getShape(targetId)! - - // (unexpected) We didn't create the target shape - if (!targetShape) continue - - if (targetId) { - const bounds = editor.getShapePageBounds(targetId)! - - const v2ShapeFresh = editor.getShape(v2ShapeId)! - - const nx = clamp((coerceNumber(binding.point[0]) + 0.5) / 2, 0.2, 0.8) - const ny = clamp((coerceNumber(binding.point[1]) + 0.5) / 2, 0.2, 0.8) - - const point = editor.getPointInShapeSpace(v2ShapeFresh, { - x: bounds.minX + bounds.width * nx, - y: bounds.minY + bounds.height * ny, - }) - - const handles = editor.getShapeHandles(v2ShapeFresh)! - const change = util.onHandleDrag!(v2ShapeFresh, { - handle: { - ...handles.find((h) => h.id === handleId)!, - x: point.x, - y: point.y, - }, - isPrecise: point.x !== 0.5 || point.y !== 0.5, - }) - - if (change) { - if (change.props?.[handleId]) { - const terminal = change.props?.[handleId] as TLArrowShapeTerminal - if (terminal.type === 'binding') { - terminal.isExact = binding.distance === 0 - - if (terminal.boundShapeId !== targetId) { - console.warn('Hit the wrong shape!') - terminal.boundShapeId = targetId - terminal.normalizedAnchor = { x: 0.5, y: 0.5 } - } + if (terminal.boundShapeId !== targetId) { + console.warn('Hit the wrong shape!') + terminal.boundShapeId = targetId + terminal.normalizedAnchor = { x: 0.5, y: 0.5 } } } - editor.updateShapes([change]) } + editor.updateShapes([change]) } } } - }) + } }) + }) - // Set the current page to the first page again - editor.setCurrentPage(firstPageId) + // Set the current page to the first page again + editor.setCurrentPage(firstPageId) - editor.history.clear() - editor.selectNone() + editor.history.clear() + editor.selectNone() - const bounds = editor.getCurrentPageBounds() - if (bounds) { - editor.zoomToBounds(bounds, { targetZoom: 1 }) - } - }) + const bounds = editor.getCurrentPageBounds() + if (bounds) { + editor.zoomToBounds(bounds, { targetZoom: 1 }) + } } function coerceNumber(n: unknown): number {