diff --git a/apps/examples/src/examples/context-toolbar/ContextToolbar.tsx b/apps/examples/src/examples/context-toolbar/ContextToolbar.tsx
index 4a92f88ba..c28cb4763 100644
--- a/apps/examples/src/examples/context-toolbar/ContextToolbar.tsx
+++ b/apps/examples/src/examples/context-toolbar/ContextToolbar.tsx
@@ -77,9 +77,7 @@ const ContextToolbarComponent = track(() => {
width: 32,
background: isActive ? 'var(--color-muted-2)' : 'transparent',
}}
- onClick={() =>
- editor.setStyleForSelectedShapes(DefaultSizeStyle, value, { squashing: false })
- }
+ onClick={() => editor.setStyleForSelectedShapes(DefaultSizeStyle, value)}
>
diff --git a/apps/examples/src/examples/custom-style-panel/CustomStylePanelExample.tsx b/apps/examples/src/examples/custom-style-panel/CustomStylePanelExample.tsx
index c8d27b5c3..f8df67102 100644
--- a/apps/examples/src/examples/custom-style-panel/CustomStylePanelExample.tsx
+++ b/apps/examples/src/examples/custom-style-panel/CustomStylePanelExample.tsx
@@ -25,7 +25,7 @@ function CustomStylePanel(props: TLUiStylePanelProps) {
{
- editor.setStyleForSelectedShapes(DefaultColorStyle, 'red', { squashing: true })
+ editor.setStyleForSelectedShapes(DefaultColorStyle, 'red')
}}
>
Red
@@ -35,7 +35,7 @@ function CustomStylePanel(props: TLUiStylePanelProps) {
{
- editor.setStyleForSelectedShapes(DefaultColorStyle, 'green', { squashing: true })
+ editor.setStyleForSelectedShapes(DefaultColorStyle, 'green')
}}
>
Green
diff --git a/apps/examples/src/examples/user-presence/UserPresenceExample.tsx b/apps/examples/src/examples/user-presence/UserPresenceExample.tsx
index 070e6b7a5..1816c3e99 100644
--- a/apps/examples/src/examples/user-presence/UserPresenceExample.tsx
+++ b/apps/examples/src/examples/user-presence/UserPresenceExample.tsx
@@ -29,7 +29,9 @@ export default function UserPresenceExample() {
chatMessage: CURSOR_CHAT_MESSAGE,
})
- editor.store.put([peerPresence])
+ editor.store.mergeRemoteChanges(() => {
+ editor.store.put([peerPresence])
+ })
// [b]
const raf = rRaf.current
@@ -67,23 +69,29 @@ export default function UserPresenceExample() {
)
}
- editor.store.put([
- {
- ...peerPresence,
- cursor,
- chatMessage,
- lastActivityTimestamp: now,
- },
- ])
+ editor.store.mergeRemoteChanges(() => {
+ editor.store.put([
+ {
+ ...peerPresence,
+ cursor,
+ chatMessage,
+ lastActivityTimestamp: now,
+ },
+ ])
+ })
rRaf.current = requestAnimationFrame(loop)
}
rRaf.current = requestAnimationFrame(loop)
} else {
- editor.store.put([{ ...peerPresence, lastActivityTimestamp: Date.now() }])
- rRaf.current = setInterval(() => {
+ editor.store.mergeRemoteChanges(() => {
editor.store.put([{ ...peerPresence, lastActivityTimestamp: Date.now() }])
+ })
+ rRaf.current = setInterval(() => {
+ editor.store.mergeRemoteChanges(() => {
+ editor.store.put([{ ...peerPresence, lastActivityTimestamp: Date.now() }])
+ })
}, 1000)
}
}}
diff --git a/apps/vscode/editor/src/ChangeResponder.tsx b/apps/vscode/editor/src/ChangeResponder.tsx
index 7cb77f6c7..a0bbb5cdd 100644
--- a/apps/vscode/editor/src/ChangeResponder.tsx
+++ b/apps/vscode/editor/src/ChangeResponder.tsx
@@ -57,11 +57,11 @@ export const ChangeResponder = () => {
type: 'vscode:editor-loaded',
})
- editor.on('change-history', handleChange)
+ const dispose = editor.store.listen(handleChange, { scope: 'document' })
return () => {
handleChange()
- editor.off('change-history', handleChange)
+ dispose()
}
}, [editor])
diff --git a/packages/editor/api-report.md b/packages/editor/api-report.md
index a8039822c..99c6779e9 100644
--- a/packages/editor/api-report.md
+++ b/packages/editor/api-report.md
@@ -29,10 +29,12 @@ import { default as React_2 } from 'react';
import * as React_3 from 'react';
import { ReactElement } from 'react';
import { ReactNode } from 'react';
+import { RecordsDiff } from '@tldraw/store';
import { SerializedSchema } from '@tldraw/store';
import { SerializedStore } from '@tldraw/store';
import { ShapeProps } from '@tldraw/tlschema';
import { Signal } from '@tldraw/state';
+import { Store } from '@tldraw/store';
import { StoreSchema } from '@tldraw/store';
import { StoreSnapshot } from '@tldraw/store';
import { StyleProp } from '@tldraw/tlschema';
@@ -375,7 +377,7 @@ export function counterClockwiseAngleDist(a0: number, a1: number): number;
export function createSessionStateSnapshotSignal(store: TLStore): Signal;
// @public
-export function createTLStore({ initialData, defaultName, ...rest }: TLStoreOptions): TLStore;
+export function createTLStore({ initialData, defaultName, id, ...rest }: TLStoreOptions): TLStore;
// @public (undocumented)
export function createTLUser(opts?: {
@@ -602,7 +604,7 @@ export class Editor extends EventEmitter {
}): this;
bail(): this;
bailToMark(id: string): this;
- batch(fn: () => void): this;
+ batch(fn: () => void, opts?: TLHistoryBatchOptions): this;
bringForward(shapes: TLShape[] | TLShapeId[]): this;
bringToFront(shapes: TLShape[] | TLShapeId[]): this;
cancel(): this;
@@ -810,7 +812,7 @@ export class Editor extends EventEmitter {
getZoomLevel(): number;
groupShapes(shapes: TLShape[] | TLShapeId[], groupId?: TLShapeId): this;
hasAncestor(shape: TLShape | TLShapeId | undefined, ancestorId: TLShapeId): boolean;
- readonly history: HistoryManager;
+ readonly history: HistoryManager;
inputs: {
buttons: Set;
keys: Set;
@@ -832,6 +834,7 @@ export class Editor extends EventEmitter {
isPointing: boolean;
};
interrupt(): this;
+ isAncestorSelected(shape: TLShape | TLShapeId): boolean;
isIn(path: string): boolean;
isInAny(...paths: string[]): boolean;
isPointInShape(shape: TLShape | TLShapeId, point: VecLike, opts?: {
@@ -845,9 +848,9 @@ export class Editor extends EventEmitter {
isShapeOrAncestorLocked(shape?: TLShape): boolean;
// (undocumented)
isShapeOrAncestorLocked(id?: TLShapeId): boolean;
- mark(markId?: string, onUndo?: boolean, onRedo?: boolean): this;
+ mark(markId?: string): this;
moveShapesToPage(shapes: TLShape[] | TLShapeId[], pageId: TLPageId): this;
- nudgeShapes(shapes: TLShape[] | TLShapeId[], offset: VecLike, historyOptions?: TLCommandHistoryOptions): this;
+ nudgeShapes(shapes: TLShape[] | TLShapeId[], offset: VecLike): this;
packShapes(shapes: TLShape[] | TLShapeId[], gap: number): this;
pageToScreen(point: VecLike): {
x: number;
@@ -876,7 +879,7 @@ export class Editor extends EventEmitter {
registerExternalContentHandler(type: T, handler: ((info: T extends TLExternalContent['type'] ? TLExternalContent & {
type: T;
} : TLExternalContent) => void) | null): this;
- renamePage(page: TLPage | TLPageId, name: string, historyOptions?: TLCommandHistoryOptions): this;
+ renamePage(page: TLPage | TLPageId, name: string): this;
renderingBoundsMargin: number;
reparentShapes(shapes: TLShape[] | TLShapeId[], parentId: TLParentId, insertIndex?: IndexKey): this;
resetZoom(point?: Vec, animation?: TLAnimationOptions): this;
@@ -896,7 +899,7 @@ export class Editor extends EventEmitter {
sendToBack(shapes: TLShape[] | TLShapeId[]): this;
setCamera(point: VecLike, animation?: TLAnimationOptions): this;
setCroppingShape(shape: null | TLShape | TLShapeId): this;
- setCurrentPage(page: TLPage | TLPageId, historyOptions?: TLCommandHistoryOptions): this;
+ setCurrentPage(page: TLPage | TLPageId): this;
setCurrentTool(id: string, info?: {}): this;
setCursor: (cursor: Partial) => this;
setEditingShape(shape: null | TLShape | TLShapeId): this;
@@ -904,11 +907,11 @@ export class Editor extends EventEmitter {
setFocusedGroup(shape: null | TLGroupShape | TLShapeId): this;
setHintingShapes(shapes: TLShape[] | TLShapeId[]): this;
setHoveredShape(shape: null | TLShape | TLShapeId): this;
- setOpacityForNextShapes(opacity: number, historyOptions?: TLCommandHistoryOptions): this;
- setOpacityForSelectedShapes(opacity: number, historyOptions?: TLCommandHistoryOptions): this;
- setSelectedShapes(shapes: TLShape[] | TLShapeId[], historyOptions?: TLCommandHistoryOptions): this;
- setStyleForNextShapes(style: StyleProp, value: T, historyOptions?: TLCommandHistoryOptions): this;
- setStyleForSelectedShapes>(style: S, value: StylePropValue, historyOptions?: TLCommandHistoryOptions): this;
+ setOpacityForNextShapes(opacity: number, historyOptions?: TLHistoryBatchOptions): this;
+ setOpacityForSelectedShapes(opacity: number): this;
+ setSelectedShapes(shapes: TLShape[] | TLShapeId[]): this;
+ setStyleForNextShapes(style: StyleProp, value: T, historyOptions?: TLHistoryBatchOptions): this;
+ setStyleForSelectedShapes>(style: S, value: StylePropValue): this;
shapeUtils: {
readonly [K in string]?: ShapeUtil;
};
@@ -937,14 +940,16 @@ export class Editor extends EventEmitter {
// (undocumented)
ungroupShapes(ids: TLShape[]): this;
updateAssets(assets: TLAssetPartial[]): this;
- updateCurrentPageState(partial: Partial>, historyOptions?: TLCommandHistoryOptions): this;
+ updateCurrentPageState(partial: Partial>, historyOptions?: TLHistoryBatchOptions): this;
+ // (undocumented)
+ _updateCurrentPageState: (partial: Partial>, historyOptions?: TLHistoryBatchOptions) => void;
updateDocumentSettings(settings: Partial): this;
- updateInstanceState(partial: Partial>, historyOptions?: TLCommandHistoryOptions): this;
- updatePage(partial: RequiredKeys, historyOptions?: TLCommandHistoryOptions): this;
+ updateInstanceState(partial: Partial>, historyOptions?: TLHistoryBatchOptions): this;
+ updatePage(partial: RequiredKeys): this;
// @internal
updateRenderingBounds(): this;
- updateShape(partial: null | TLShapePartial | undefined, historyOptions?: TLCommandHistoryOptions): this;
- updateShapes(partials: (null | TLShapePartial | undefined)[], historyOptions?: TLCommandHistoryOptions): this;
+ updateShape(partial: null | TLShapePartial | undefined): this;
+ updateShapes(partials: (null | TLShapePartial | undefined)[]): this;
updateViewportScreenBounds(screenBounds: Box, center?: boolean): this;
readonly user: UserPreferencesManager;
visitDescendants(parent: TLPage | TLParentId | TLShape, visitor: (id: TLShapeId) => false | void): this;
@@ -1208,6 +1213,55 @@ export function hardResetEditor(): void;
// @internal (undocumented)
export const HASH_PATTERN_ZOOM_NAMES: Record;
+// @public (undocumented)
+export class HistoryManager {
+ constructor(opts: {
+ annotateError?: (error: unknown) => void;
+ store: Store;
+ });
+ // (undocumented)
+ bail: () => this;
+ // (undocumented)
+ bailToMark: (id: string) => this;
+ // (undocumented)
+ batch: (fn: () => void, opts?: TLHistoryBatchOptions) => this;
+ // (undocumented)
+ clear(): void;
+ // @internal (undocumented)
+ debug(): {
+ pendingDiff: {
+ diff: RecordsDiff;
+ isEmpty: boolean;
+ };
+ redos: (NonNullable> | undefined)[];
+ state: HistoryRecorderState;
+ undos: (NonNullable> | undefined)[];
+ };
+ // (undocumented)
+ readonly dispose: () => void;
+ // (undocumented)
+ getNumRedos(): number;
+ // (undocumented)
+ getNumUndos(): number;
+ // (undocumented)
+ ignore(fn: () => void): this;
+ // @internal (undocumented)
+ _isInBatch: boolean;
+ // (undocumented)
+ mark: (id?: string) => string;
+ // (undocumented)
+ onBatchComplete: () => void;
+ // (undocumented)
+ redo: () => this | undefined;
+ // @internal (undocumented)
+ stacks: Atom< {
+ redos: Stack>;
+ undos: Stack>;
+ }, unknown>;
+ // (undocumented)
+ undo: () => this;
+}
+
// @public (undocumented)
export const HIT_TEST_MARGIN = 8;
@@ -1723,6 +1777,17 @@ export class SideEffectManager;
+ afterCreate?: TLAfterCreateHandler;
+ afterDelete?: TLAfterDeleteHandler;
+ beforeChange?: TLBeforeChangeHandler;
+ beforeCreate?: TLBeforeCreateHandler;
+ beforeDelete?: TLBeforeDeleteHandler;
+ };
+ }): () => void;
registerAfterChangeHandler(typeName: T, handler: TLAfterChangeHandler): () => void;
@@ -2037,29 +2102,6 @@ export type TLCollaboratorHintProps = {
zoom: number;
};
-// @public (undocumented)
-export type TLCommand = {
- preservesRedoStack?: boolean;
- data: Data;
- name: Name;
- type: 'command';
-};
-
-// @public (undocumented)
-export type TLCommandHandler = {
- squash?: (prevData: Data, nextData: Data) => Data;
- do: (data: Data) => void;
- redo?: (data: Data) => void;
- undo: (data: Data) => void;
-};
-
-// @public (undocumented)
-export type TLCommandHistoryOptions = Partial<{
- preservesRedoStack: boolean;
- squashing: boolean;
- ephemeral: boolean;
-}>;
-
// @public (undocumented)
export type TLCompleteEvent = (info: TLCompleteEventInfo) => void;
@@ -2193,17 +2235,6 @@ export type TLEventInfo = TLCancelEventInfo | TLClickEventInfo | TLCompleteEvent
// @public (undocumented)
export interface TLEventMap {
- // (undocumented)
- 'change-history': [{
- markId?: string;
- reason: 'bail';
- } | {
- reason: 'push' | 'redo' | 'undo';
- }];
- // (undocumented)
- 'mark-history': [{
- id: string;
- }];
// (undocumented)
'max-shapes': [{
count: number;
@@ -2316,17 +2347,6 @@ export type TLHandlesProps = {
children: ReactNode;
};
-// @public (undocumented)
-export type TLHistoryEntry = TLCommand | TLHistoryMark;
-
-// @public (undocumented)
-export type TLHistoryMark = {
- id: string;
- onRedo: boolean;
- onUndo: boolean;
- type: 'STOP';
-};
-
// @public (undocumented)
export type TLInterruptEvent = (info: TLInterruptEventInfo) => void;
@@ -2610,6 +2630,7 @@ export type TLStoreEventInfo = HistoryEntry;
// @public (undocumented)
export type TLStoreOptions = {
defaultName?: string;
+ id?: string;
initialData?: SerializedStore;
} & ({
migrations?: readonly MigrationSequence[];
diff --git a/packages/editor/src/index.ts b/packages/editor/src/index.ts
index 4577b1395..d89708f3e 100644
--- a/packages/editor/src/index.ts
+++ b/packages/editor/src/index.ts
@@ -17,7 +17,6 @@ export {
type Atom,
type Signal,
} from '@tldraw/state'
-export type { TLCommandHistoryOptions } from './lib/editor/types/history-types'
// eslint-disable-next-line local/no-export-star
export * from '@tldraw/store'
// eslint-disable-next-line local/no-export-star
@@ -131,6 +130,7 @@ export {
type TLEditorOptions,
type TLResizeShapeOptions,
} from './lib/editor/Editor'
+export { HistoryManager } from './lib/editor/managers/HistoryManager'
export type {
SideEffectManager,
TLAfterChangeHandler,
@@ -235,12 +235,6 @@ export {
type TLExternalContent,
type TLExternalContentSource,
} from './lib/editor/types/external-content'
-export {
- type TLCommand,
- type TLCommandHandler,
- type TLHistoryEntry,
- type TLHistoryMark,
-} from './lib/editor/types/history-types'
export { type RequiredKeys, type TLSvgOptions } from './lib/editor/types/misc-types'
export { type TLResizeHandle, type TLSelectionHandle } from './lib/editor/types/selection-types'
export { ContainerProvider, useContainer } from './lib/hooks/useContainer'
diff --git a/packages/editor/src/lib/TldrawEditor.tsx b/packages/editor/src/lib/TldrawEditor.tsx
index 32e47764d..87ea9b1fe 100644
--- a/packages/editor/src/lib/TldrawEditor.tsx
+++ b/packages/editor/src/lib/TldrawEditor.tsx
@@ -380,8 +380,11 @@ function useOnMount(onMount?: TLOnMountHandler) {
const editor = useEditor()
const onMountEvent = useEvent((editor: Editor) => {
- const teardown = onMount?.(editor)
- editor.emit('mount')
+ let teardown: (() => void) | void = undefined
+ editor.history.ignore(() => {
+ teardown = onMount?.(editor)
+ editor.emit('mount')
+ })
window.tldrawReady = true
return teardown
})
diff --git a/packages/editor/src/lib/config/createTLStore.ts b/packages/editor/src/lib/config/createTLStore.ts
index b910b6561..3361a18a5 100644
--- a/packages/editor/src/lib/config/createTLStore.ts
+++ b/packages/editor/src/lib/config/createTLStore.ts
@@ -14,6 +14,7 @@ import { TLAnyShapeUtilConstructor, checkShapesAndAddCore } from './defaultShape
export type TLStoreOptions = {
initialData?: SerializedStore
defaultName?: string
+ id?: string
} & (
| { shapeUtils?: readonly TLAnyShapeUtilConstructor[]; migrations?: readonly MigrationSequence[] }
| { schema?: StoreSchema }
@@ -28,7 +29,12 @@ export type TLStoreEventInfo = HistoryEntry
* @param opts - Options for creating the store.
*
* @public */
-export function createTLStore({ initialData, defaultName = '', ...rest }: TLStoreOptions): TLStore {
+export function createTLStore({
+ initialData,
+ defaultName = '',
+ id,
+ ...rest
+}: TLStoreOptions): TLStore {
const schema =
'schema' in rest && rest.schema
? // we have a schema
@@ -42,6 +48,7 @@ export function createTLStore({ initialData, defaultName = '', ...rest }: TLStor
})
return new Store({
+ id,
schema,
initialData,
props: {
diff --git a/packages/editor/src/lib/editor/Editor.ts b/packages/editor/src/lib/editor/Editor.ts
index c74be045e..88e44208f 100644
--- a/packages/editor/src/lib/editor/Editor.ts
+++ b/packages/editor/src/lib/editor/Editor.ts
@@ -52,7 +52,6 @@ import {
getIndicesBetween,
getOwnProperty,
hasOwnProperty,
- objectMapValues,
sortById,
sortByIndex,
structuredClone,
@@ -129,7 +128,7 @@ import {
TLWheelEventInfo,
} from './types/event-types'
import { TLExternalAssetContent, TLExternalContent } from './types/external-content'
-import { TLCommandHistoryOptions } from './types/history-types'
+import { TLHistoryBatchOptions } from './types/history-types'
import { OptionalKeys, RequiredKeys, TLSvgOptions } from './types/misc-types'
import { TLResizeHandle } from './types/selection-types'
@@ -198,6 +197,13 @@ export class Editor extends EventEmitter {
super()
this.store = store
+ this.history = new HistoryManager({
+ store,
+ annotateError: (error) => {
+ this.annotateError(error, { origin: 'history.batch', willCrashApp: true })
+ this.crash(error)
+ },
+ })
this.snaps = new SnapManager(this)
@@ -421,168 +427,204 @@ export class Editor extends EventEmitter {
this.sideEffects = new SideEffectManager(this)
- this.sideEffects.registerBatchCompleteHandler(() => {
- for (const parentId of invalidParents) {
- invalidParents.delete(parentId)
- const parent = this.getShape(parentId)
- if (!parent) continue
+ 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)
+ const util = this.getShapeUtil(parent)
+ const changes = util.onChildrenChange?.(parent)
- if (changes?.length) {
- this.updateShapes(changes, { squashing: true })
+ if (changes?.length) {
+ this.updateShapes(changes)
+ }
}
- }
- this.emit('update')
- })
+ this.emit('update')
+ })
+ )
- this.sideEffects.registerBeforeDeleteHandler('shape', (record) => {
- // if the deleted shape has a parent shape make sure we call it's onChildrenChange callback
- if (record.parentId && isShapeId(record.parentId)) {
- invalidParents.add(record.parentId)
- }
- // clean up any arrows bound to this shape
- const bindings = this._getArrowBindingsIndex().get()[record.id]
- if (bindings?.length) {
- for (const { arrowId, handleId } of bindings) {
- const arrow = this.getShape(arrowId)
- if (!arrow) continue
- unbindArrowTerminal(arrow, handleId)
- }
- }
- const deletedIds = new Set([record.id])
- const updates = compact(
- this.getPageStates().map((pageState) => {
- return cleanupInstancePageState(pageState, deletedIds)
- })
- )
-
- if (updates.length) {
- this.store.put(updates)
- }
- })
-
- this.sideEffects.registerBeforeDeleteHandler('page', (record) => {
- // page was deleted, need to check whether it's the current page and select another one if so
- if (this.getInstanceState().currentPageId !== record.id) return
-
- const backupPageId = this.getPages().find((p) => p.id !== record.id)?.id
- if (!backupPageId) return
- this.store.put([{ ...this.getInstanceState(), currentPageId: backupPageId }])
-
- // delete the camera and state for the page if necessary
- const cameraId = CameraRecordType.createId(record.id)
- const instance_PageStateId = InstancePageStateRecordType.createId(record.id)
- this.store.remove([cameraId, instance_PageStateId])
- })
-
- this.sideEffects.registerAfterChangeHandler('shape', (prev, next) => {
- if (this.isShapeOfType(next, 'arrow')) {
- arrowDidUpdate(next)
- }
-
- // if the shape's parent changed and it is bound to an arrow, update the arrow's parent
- if (prev.parentId !== next.parentId) {
- const reparentBoundArrows = (id: TLShapeId) => {
- const boundArrows = this._getArrowBindingsIndex().get()[id]
- if (boundArrows?.length) {
- for (const arrow of boundArrows) {
- reparentArrow(arrow.arrowId)
+ this.disposables.add(
+ this.sideEffects.register({
+ shape: {
+ afterCreate: (record) => {
+ if (this.isShapeOfType(record, 'arrow')) {
+ arrowDidUpdate(record)
}
- }
- }
- reparentBoundArrows(next.id)
- this.visitDescendants(next.id, reparentBoundArrows)
- }
-
- // if this shape moved to a new page, clean up any previous page's instance state
- if (prev.parentId !== next.parentId && isPageId(next.parentId)) {
- const allMovingIds = new Set([prev.id])
- this.visitDescendants(prev.id, (id) => {
- allMovingIds.add(id)
- })
-
- for (const instancePageState of this.getPageStates()) {
- if (instancePageState.pageId === next.parentId) continue
- const nextPageState = cleanupInstancePageState(instancePageState, allMovingIds)
-
- if (nextPageState) {
- this.store.put([nextPageState])
- }
- }
- }
-
- if (prev.parentId && isShapeId(prev.parentId)) {
- invalidParents.add(prev.parentId)
- }
-
- if (next.parentId !== prev.parentId && isShapeId(next.parentId)) {
- invalidParents.add(next.parentId)
- }
- })
-
- this.sideEffects.registerAfterChangeHandler('instance_page_state', (prev, next) => {
- if (prev?.selectedShapeIds !== next?.selectedShapeIds) {
- // ensure that descendants and ancestors are not selected at the same time
- const filtered = next.selectedShapeIds.filter((id) => {
- let parentId = this.getShape(id)?.parentId
- while (isShapeId(parentId)) {
- if (next.selectedShapeIds.includes(parentId)) {
- return false
+ },
+ afterChange: (prev, next) => {
+ if (this.isShapeOfType(next, 'arrow')) {
+ arrowDidUpdate(next)
}
- parentId = this.getShape(parentId)?.parentId
- }
- return true
- })
- let nextFocusedGroupId: null | TLShapeId = null
+ // if the shape's parent changed and it is bound to an arrow, update the arrow's parent
+ if (prev.parentId !== next.parentId) {
+ const reparentBoundArrows = (id: TLShapeId) => {
+ const boundArrows = this._getArrowBindingsIndex().get()[id]
+ if (boundArrows?.length) {
+ for (const arrow of boundArrows) {
+ reparentArrow(arrow.arrowId)
+ }
+ }
+ }
+ reparentBoundArrows(next.id)
+ this.visitDescendants(next.id, reparentBoundArrows)
+ }
- if (filtered.length > 0) {
- const commonGroupAncestor = this.findCommonAncestor(
- compact(filtered.map((id) => this.getShape(id))),
- (shape) => this.isShapeOfType(shape, 'group')
- )
+ // if this shape moved to a new page, clean up any previous page's instance state
+ if (prev.parentId !== next.parentId && isPageId(next.parentId)) {
+ const allMovingIds = new Set([prev.id])
+ this.visitDescendants(prev.id, (id) => {
+ allMovingIds.add(id)
+ })
- if (commonGroupAncestor) {
- nextFocusedGroupId = commonGroupAncestor
- }
- } else {
- if (next?.focusedGroupId) {
- nextFocusedGroupId = next.focusedGroupId
- }
- }
+ for (const instancePageState of this.getPageStates()) {
+ if (instancePageState.pageId === next.parentId) continue
+ const nextPageState = cleanupInstancePageState(instancePageState, allMovingIds)
- if (
- filtered.length !== next.selectedShapeIds.length ||
- nextFocusedGroupId !== next.focusedGroupId
- ) {
- this.store.put([
- { ...next, selectedShapeIds: filtered, focusedGroupId: nextFocusedGroupId ?? null },
- ])
- }
- }
- })
+ if (nextPageState) {
+ this.store.put([nextPageState])
+ }
+ }
+ }
- this.sideEffects.registerAfterCreateHandler('shape', (record) => {
- if (this.isShapeOfType(record, 'arrow')) {
- arrowDidUpdate(record)
- }
- })
+ if (prev.parentId && isShapeId(prev.parentId)) {
+ invalidParents.add(prev.parentId)
+ }
- this.sideEffects.registerAfterCreateHandler('page', (record) => {
- const cameraId = CameraRecordType.createId(record.id)
- const _pageStateId = InstancePageStateRecordType.createId(record.id)
- if (!this.store.has(cameraId)) {
- this.store.put([CameraRecordType.create({ id: cameraId })])
- }
- if (!this.store.has(_pageStateId)) {
- this.store.put([
- InstancePageStateRecordType.create({ id: _pageStateId, pageId: record.id }),
- ])
- }
- })
+ if (next.parentId !== prev.parentId && isShapeId(next.parentId)) {
+ invalidParents.add(next.parentId)
+ }
+ },
+ beforeDelete: (record) => {
+ // if the deleted shape has a parent shape make sure we call it's onChildrenChange callback
+ if (record.parentId && isShapeId(record.parentId)) {
+ invalidParents.add(record.parentId)
+ }
+ // clean up any arrows bound to this shape
+ const bindings = this._getArrowBindingsIndex().get()[record.id]
+ if (bindings?.length) {
+ for (const { arrowId, handleId } of bindings) {
+ const arrow = this.getShape(arrowId)
+ if (!arrow) continue
+ unbindArrowTerminal(arrow, handleId)
+ }
+ }
+ const deletedIds = new Set([record.id])
+ const updates = compact(
+ this.getPageStates().map((pageState) => {
+ return cleanupInstancePageState(pageState, deletedIds)
+ })
+ )
+
+ if (updates.length) {
+ this.store.put(updates)
+ }
+ },
+ },
+ page: {
+ afterCreate: (record) => {
+ const cameraId = CameraRecordType.createId(record.id)
+ const _pageStateId = InstancePageStateRecordType.createId(record.id)
+ if (!this.store.has(cameraId)) {
+ this.store.put([CameraRecordType.create({ id: cameraId })])
+ }
+ if (!this.store.has(_pageStateId)) {
+ this.store.put([
+ InstancePageStateRecordType.create({ id: _pageStateId, pageId: record.id }),
+ ])
+ }
+ },
+ afterDelete: (record, source) => {
+ // page was deleted, need to check whether it's the current page and select another one if so
+ if (this.getInstanceState()?.currentPageId === record.id) {
+ const backupPageId = this.getPages().find((p) => p.id !== record.id)?.id
+ if (backupPageId) {
+ this.store.put([{ ...this.getInstanceState(), currentPageId: backupPageId }])
+ } else if (source === 'user') {
+ // fall back to ensureStoreIsUsable:
+ this.store.ensureStoreIsUsable()
+ }
+ }
+
+ // delete the camera and state for the page if necessary
+ const cameraId = CameraRecordType.createId(record.id)
+ const instance_PageStateId = InstancePageStateRecordType.createId(record.id)
+ this.store.remove([cameraId, instance_PageStateId])
+ },
+ },
+ instance: {
+ afterChange: (prev, next, source) => {
+ // instance should never be updated to a page that no longer exists (this can
+ // happen when undoing a change that involves switching to a page that has since
+ // been deleted by another user)
+ if (!this.store.has(next.currentPageId)) {
+ const backupPageId = this.store.has(prev.currentPageId)
+ ? prev.currentPageId
+ : this.getPages()[0]?.id
+ if (backupPageId) {
+ this.store.update(next.id, (instance) => ({
+ ...instance,
+ currentPageId: backupPageId,
+ }))
+ } else if (source === 'user') {
+ // fall back to ensureStoreIsUsable:
+ this.store.ensureStoreIsUsable()
+ }
+ }
+ },
+ },
+ instance_page_state: {
+ afterChange: (prev, next) => {
+ if (prev?.selectedShapeIds !== next?.selectedShapeIds) {
+ // ensure that descendants and ancestors are not selected at the same time
+ const filtered = next.selectedShapeIds.filter((id) => {
+ let parentId = this.getShape(id)?.parentId
+ while (isShapeId(parentId)) {
+ if (next.selectedShapeIds.includes(parentId)) {
+ return false
+ }
+ parentId = this.getShape(parentId)?.parentId
+ }
+ return true
+ })
+
+ let nextFocusedGroupId: null | TLShapeId = null
+
+ if (filtered.length > 0) {
+ const commonGroupAncestor = this.findCommonAncestor(
+ compact(filtered.map((id) => this.getShape(id))),
+ (shape) => this.isShapeOfType(shape, 'group')
+ )
+
+ if (commonGroupAncestor) {
+ nextFocusedGroupId = commonGroupAncestor
+ }
+ } else {
+ if (next?.focusedGroupId) {
+ nextFocusedGroupId = next.focusedGroupId
+ }
+ }
+
+ if (
+ filtered.length !== next.selectedShapeIds.length ||
+ nextFocusedGroupId !== next.focusedGroupId
+ ) {
+ this.store.put([
+ {
+ ...next,
+ selectedShapeIds: filtered,
+ focusedGroupId: nextFocusedGroupId ?? null,
+ },
+ ])
+ }
+ }
+ },
+ },
+ })
+ )
this._currentPageShapeIds = deriveShapeIdsInCurrentPage(this.store, () =>
this.getCurrentPageId()
@@ -594,18 +636,18 @@ export class Editor extends EventEmitter {
this.emit('change', changes)
})
)
+ this.disposables.add(this.history.dispose)
- this.store.ensureStoreIsUsable()
+ this.history.ignore(() => {
+ this.store.ensureStoreIsUsable()
- // clear ephemeral state
- this._setInstancePageState(
- {
+ // clear ephemeral state
+ this._updateCurrentPageState({
editingShapeId: null,
hoveredShapeId: null,
erasingShapeIds: [],
- },
- { ephemeral: true }
- )
+ })
+ })
if (initialState && this.root.children[initialState] === undefined) {
throw Error(`No state found for initialState "${initialState}".`)
@@ -757,14 +799,7 @@ export class Editor extends EventEmitter {
*
* @readonly
*/
- readonly history = new HistoryManager(
- this,
- // () => this._complete(),
- (error) => {
- this.annotateError(error, { origin: 'history.batch', willCrashApp: true })
- this.crash(error)
- }
- )
+ readonly history: HistoryManager
/**
* Undo to the last mark.
@@ -827,13 +862,11 @@ export class Editor extends EventEmitter {
* ```
*
* @param markId - The mark's id, usually the reason for adding the mark.
- * @param onUndo - Whether to stop at the mark when undoing.
- * @param onRedo - Whether to stop at the mark when redoing.
*
* @public
*/
- mark(markId?: string, onUndo?: boolean, onRedo?: boolean): this {
- this.history.mark(markId, onUndo, onRedo)
+ mark(markId?: string): this {
+ this.history.mark(markId)
return this
}
@@ -872,8 +905,8 @@ export class Editor extends EventEmitter {
*
* @public
*/
- batch(fn: () => void): this {
- this.history.batch(fn)
+ batch(fn: () => void, opts?: TLHistoryBatchOptions): this {
+ this.history.batch(fn, opts)
return this
}
@@ -1155,7 +1188,9 @@ export class Editor extends EventEmitter {
* @public
**/
updateDocumentSettings(settings: Partial): this {
- this.store.put([{ ...this.getDocumentSettings(), ...settings }])
+ this.history.ignore(() => {
+ this.store.put([{ ...this.getDocumentSettings(), ...settings }])
+ })
return this
}
@@ -1174,22 +1209,21 @@ export class Editor extends EventEmitter {
* Update the instance's state.
*
* @param partial - A partial object to update the instance state with.
- * @param historyOptions - The history options for the change.
*
* @public
*/
updateInstanceState(
partial: Partial>,
- historyOptions?: TLCommandHistoryOptions
+ historyOptions?: TLHistoryBatchOptions
): this {
- this._updateInstanceState(partial, { ephemeral: true, squashing: true, ...historyOptions })
+ this._updateInstanceState(partial, { history: 'ignore', ...historyOptions })
if (partial.isChangingStyle !== undefined) {
clearTimeout(this._isChangingStyleTimeout)
if (partial.isChangingStyle === true) {
// If we've set to true, set a new reset timeout to change the value back to false after 2 seconds
this._isChangingStyleTimeout = setTimeout(() => {
- this.updateInstanceState({ isChangingStyle: false }, { ephemeral: true })
+ this._updateInstanceState({ isChangingStyle: false }, { history: 'ignore' })
}, 2000)
}
}
@@ -1198,34 +1232,19 @@ export class Editor extends EventEmitter {
}
/** @internal */
- private _updateInstanceState = this.history.createCommand(
- 'updateInstanceState',
- (
- partial: Partial>,
- historyOptions?: TLCommandHistoryOptions
- ) => {
- const prev = this.store.get(this.getInstanceState().id)!
- const next = { ...prev, ...partial }
-
- return {
- data: { prev, next },
- ephemeral: false,
- squashing: false,
- ...historyOptions,
- }
- },
- {
- do: ({ next }) => {
- this.store.put([next])
- },
- undo: ({ prev }) => {
- this.store.put([prev])
- },
- squash({ prev }, { next }) {
- return { prev, next }
- },
- }
- )
+ private _updateInstanceState = (
+ partial: Partial>,
+ opts?: TLHistoryBatchOptions
+ ) => {
+ this.batch(() => {
+ this.store.put([
+ {
+ ...this.getInstanceState(),
+ ...partial,
+ },
+ ])
+ }, opts)
+ }
/** @internal */
private _isChangingStyleTimeout = -1 as any
@@ -1327,10 +1346,7 @@ export class Editor extends EventEmitter {
* @public
*/
setCursor = (cursor: Partial): this => {
- this.updateInstanceState(
- { cursor: { ...this.getInstanceState().cursor, ...cursor } },
- { ephemeral: true }
- )
+ this.updateInstanceState({ cursor: { ...this.getInstanceState().cursor, ...cursor } })
return this
}
@@ -1382,31 +1398,22 @@ export class Editor extends EventEmitter {
partial: Partial<
Omit
>,
- historyOptions?: TLCommandHistoryOptions
+ historyOptions?: TLHistoryBatchOptions
): this {
- this._setInstancePageState(partial, historyOptions)
+ this._updateCurrentPageState(partial, historyOptions)
return this
}
-
- /** @internal */
- private _setInstancePageState = this.history.createCommand(
- 'setInstancePageState',
- (
- partial: Partial>,
- historyOptions?: TLCommandHistoryOptions
- ) => {
- const prev = this.store.get(partial.id ?? this.getCurrentPageState().id)!
- return { data: { prev, partial }, ...historyOptions }
- },
- {
- do: ({ prev, partial }) => {
- this.store.update(prev.id, (state) => ({ ...state, ...partial }))
- },
- undo: ({ prev }) => {
- this.store.update(prev.id, () => prev)
- },
- }
- )
+ _updateCurrentPageState = (
+ partial: Partial>,
+ historyOptions?: TLHistoryBatchOptions
+ ) => {
+ this.batch(() => {
+ this.store.update(partial.id ?? this.getCurrentPageState().id, (state) => ({
+ ...state,
+ ...partial,
+ }))
+ }, historyOptions)
+ }
/**
* The current selected ids.
@@ -1438,54 +1445,35 @@ export class Editor extends EventEmitter {
* ```
*
* @param ids - The ids to select.
- * @param historyOptions - The history options for the change.
*
* @public
*/
- setSelectedShapes(
- shapes: TLShapeId[] | TLShape[],
- historyOptions?: TLCommandHistoryOptions
- ): this {
- const ids = shapes.map((shape) => (typeof shape === 'string' ? shape : shape.id))
- this._setSelectedShapes(ids, historyOptions)
- return this
- }
-
- /** @internal */
- private _setSelectedShapes = this.history.createCommand(
- 'setSelectedShapes',
- (ids: TLShapeId[], historyOptions?: TLCommandHistoryOptions) => {
+ 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)
if (ids.length === prevSet.size && ids.every((id) => prevSet.has(id))) return null
- return {
- data: { selectedShapeIds: ids, prevSelectedShapeIds },
- preservesRedoStack: true,
- ...historyOptions,
- }
- },
- {
- do: ({ selectedShapeIds }) => {
- this.store.put([{ ...this.getCurrentPageState(), selectedShapeIds }])
- },
- undo: ({ prevSelectedShapeIds }) => {
- this.store.put([
- {
- ...this.getCurrentPageState(),
- selectedShapeIds: prevSelectedShapeIds,
- },
- ])
- },
- squash({ prevSelectedShapeIds }, { selectedShapeIds }) {
- return {
- selectedShapeIds,
- prevSelectedShapeIds,
- }
- },
- }
- )
+ this.store.put([{ ...this.getCurrentPageState(), selectedShapeIds: ids }])
+ })
+ }
+
+ /**
+ * Determine whether or not any of a shape's ancestors are selected.
+ *
+ * @param id - The id of the shape to check.
+ *
+ * @public
+ */
+ isAncestorSelected(shape: TLShape | TLShapeId): boolean {
+ const id = typeof shape === 'string' ? shape : shape?.id ?? null
+ const _shape = this.getShape(id)
+ if (!_shape) return false
+ const selectedShapeIds = this.getSelectedShapeIds()
+ return !!this.findShapeAncestor(_shape, (parent) => selectedShapeIds.includes(parent.id))
+ }
/**
* Select one or more shapes.
@@ -1736,37 +1724,14 @@ export class Editor extends EventEmitter {
}
if (id === this.getFocusedGroupId()) return this
- this._setFocusedGroupId(id)
- return this
- }
- /** @internal */
- private _setFocusedGroupId = this.history.createCommand(
- 'setFocusedGroupId',
- (next: TLShapeId | null) => {
- const prev = this.getCurrentPageState().focusedGroupId
- if (prev === next) return
- return {
- data: {
- prev,
- next,
- },
- preservesRedoStack: true,
- squashing: true,
- }
- },
- {
- do: ({ next }) => {
- this.store.update(this.getCurrentPageState().id, (s) => ({ ...s, focusedGroupId: next }))
+ return this.batch(
+ () => {
+ this.store.update(this.getCurrentPageState().id, (s) => ({ ...s, focusedGroupId: id }))
},
- undo: ({ prev }) => {
- this.store.update(this.getCurrentPageState().id, (s) => ({ ...s, focusedGroupId: prev }))
- },
- squash({ prev }, { next }) {
- return { prev, next }
- },
- }
- )
+ { history: 'record-preserveRedoStack' }
+ )
+ }
/**
* Exit the current focused group, moving up to the next parent group if there is one.
@@ -1831,13 +1796,13 @@ export class Editor extends EventEmitter {
if (id) {
const shape = this.getShape(id)
if (shape && this.getShapeUtil(shape).canEdit(shape)) {
- this._setInstancePageState({ editingShapeId: id })
+ this._updateCurrentPageState({ editingShapeId: id })
return this
}
}
// Either we just set the editing id to null, or the shape was missing or not editable
- this._setInstancePageState({ editingShapeId: null })
+ this._updateCurrentPageState({ editingShapeId: null })
}
return this
}
@@ -1879,7 +1844,7 @@ export class Editor extends EventEmitter {
setHoveredShape(shape: TLShapeId | TLShape | null): this {
const id = typeof shape === 'string' ? shape : shape?.id ?? null
if (id === this.getHoveredShapeId()) return this
- this.updateCurrentPageState({ hoveredShapeId: id }, { ephemeral: true })
+ this.updateCurrentPageState({ hoveredShapeId: id })
return this
}
@@ -1922,7 +1887,7 @@ export class Editor extends EventEmitter {
? (shapes as TLShapeId[])
: (shapes as TLShape[]).map((shape) => shape.id)
// always ephemeral
- this.updateCurrentPageState({ hintingShapeIds: dedupe(ids) }, { ephemeral: true })
+ this.updateCurrentPageState({ hintingShapeIds: dedupe(ids) }, { history: 'ignore' })
return this
}
@@ -1967,20 +1932,22 @@ export class Editor extends EventEmitter {
: (shapes as TLShape[]).map((shape) => shape.id)
ids.sort() // sort the incoming ids
const erasingShapeIds = this.getErasingShapeIds()
- if (ids.length === erasingShapeIds.length) {
- // if the new ids are the same length as the current ids, they might be the same.
- // presuming the current ids are also sorted, check each item to see if it's the same;
- // if we find any unequal, then we know the new ids are different.
- for (let i = 0; i < ids.length; i++) {
- if (ids[i] !== erasingShapeIds[i]) {
- this._setInstancePageState({ erasingShapeIds: ids }, { ephemeral: true })
- break
+ this.history.ignore(() => {
+ if (ids.length === erasingShapeIds.length) {
+ // if the new ids are the same length as the current ids, they might be the same.
+ // presuming the current ids are also sorted, check each item to see if it's the same;
+ // if we find any unequal, then we know the new ids are different.
+ for (let i = 0; i < ids.length; i++) {
+ if (ids[i] !== erasingShapeIds[i]) {
+ this._updateCurrentPageState({ erasingShapeIds: ids })
+ break
+ }
}
+ } else {
+ // if the ids are a different length, then we know they're different.
+ this._updateCurrentPageState({ erasingShapeIds: ids })
}
- } else {
- // if the ids are a different length, then we know they're different.
- this._setInstancePageState({ erasingShapeIds: ids }, { ephemeral: true })
- }
+ })
return this
}
@@ -2738,25 +2705,16 @@ export class Editor extends EventEmitter {
if (_willSetInitialBounds) {
// If we have just received the initial bounds, don't center the camera.
this._willSetInitialBounds = false
- this.updateInstanceState(
- { screenBounds: screenBounds.toJson(), insets },
- { squashing: true, ephemeral: true }
- )
+ this.updateInstanceState({ screenBounds: screenBounds.toJson(), insets })
} else {
if (center && !this.getInstanceState().followingUserId) {
// Get the page center before the change, make the change, and restore it
const before = this.getViewportPageCenter()
- this.updateInstanceState(
- { screenBounds: screenBounds.toJson(), insets },
- { squashing: true, ephemeral: true }
- )
+ this.updateInstanceState({ screenBounds: screenBounds.toJson(), insets })
this.centerOnPoint(before)
} else {
// Otherwise,
- this.updateInstanceState(
- { screenBounds: screenBounds.toJson(), insets },
- { squashing: true, ephemeral: true }
- )
+ this.updateInstanceState({ screenBounds: screenBounds.toJson(), insets })
}
}
}
@@ -2943,7 +2901,7 @@ export class Editor extends EventEmitter {
transact(() => {
this.stopFollowingUser()
- this.updateInstanceState({ followingUserId: userId }, { ephemeral: true })
+ this.updateInstanceState({ followingUserId: userId })
})
const cancel = () => {
@@ -3048,7 +3006,7 @@ export class Editor extends EventEmitter {
* @public
*/
stopFollowingUser(): this {
- this.updateInstanceState({ followingUserId: null }, { ephemeral: true })
+ this.updateInstanceState({ followingUserId: null })
this.emit('stop-following')
return this
}
@@ -3349,70 +3307,24 @@ export class Editor extends EventEmitter {
* ```
*
* @param page - The page (or page id) to set as the current page.
- * @param historyOptions - The history options for the change.
*
* @public
*/
- setCurrentPage(page: TLPageId | TLPage, historyOptions?: TLCommandHistoryOptions): this {
+ setCurrentPage(page: TLPageId | TLPage): this {
const pageId = typeof page === 'string' ? page : page.id
- this._setCurrentPageId(pageId, historyOptions)
- return this
- }
- /** @internal */
- private _setCurrentPageId = this.history.createCommand(
- 'setCurrentPage',
- (pageId: TLPageId, historyOptions?: TLCommandHistoryOptions) => {
- if (!this.store.has(pageId)) {
- console.error("Tried to set the current page id to a page that doesn't exist.")
- return
- }
- this.stopFollowingUser()
-
- return {
- data: { toId: pageId, fromId: this.getCurrentPageId() },
- squashing: true,
- preservesRedoStack: true,
- ...historyOptions,
- }
- },
- {
- do: ({ toId }) => {
- if (!this.store.has(toId)) {
- // in multiplayer contexts this page might have been deleted
- return
- }
- if (!this.getPageStates().find((p) => p.pageId === toId)) {
- const camera = CameraRecordType.create({
- id: CameraRecordType.createId(toId),
- })
- this.store.put([
- camera,
- InstancePageStateRecordType.create({
- id: InstancePageStateRecordType.createId(toId),
- pageId: toId,
- }),
- ])
- }
-
- this.store.put([{ ...this.getInstanceState(), currentPageId: toId }])
-
- this.updateRenderingBounds()
- },
- undo: ({ fromId }) => {
- if (!this.store.has(fromId)) {
- // in multiplayer contexts this page might have been deleted
- return
- }
- this.store.put([{ ...this.getInstanceState(), currentPageId: fromId }])
-
- this.updateRenderingBounds()
- },
- squash: ({ fromId }, { toId }) => {
- return { toId, fromId }
- },
+ if (!this.store.has(pageId)) {
+ console.error("Tried to set the current page id to a page that doesn't exist.")
+ return this
}
- )
+
+ this.stopFollowingUser()
+
+ return this.batch(
+ () => this.store.put([{ ...this.getInstanceState(), currentPageId: pageId }]),
+ { history: 'record-preserveRedoStack' }
+ )
+ }
/**
* Update a page.
@@ -3420,45 +3332,20 @@ export class Editor extends EventEmitter {
* @example
* ```ts
* editor.updatePage({ id: 'page2', name: 'Page 2' })
- * editor.updatePage({ id: 'page2', name: 'Page 2' }, { squashing: true })
* ```
*
* @param partial - The partial of the shape to update.
- * @param historyOptions - The history options for the change.
*
* @public
*/
- updatePage(partial: RequiredKeys, historyOptions?: TLCommandHistoryOptions): this {
- this._updatePage(partial, historyOptions)
- return this
+ updatePage(partial: RequiredKeys): this {
+ if (this.getInstanceState().isReadonly) return this
+
+ const prev = this.getPage(partial.id)
+ if (!prev) return this
+
+ return this.batch(() => this.store.update(partial.id, (page) => ({ ...page, ...partial })))
}
- /** @internal */
- private _updatePage = this.history.createCommand(
- 'updatePage',
- (partial: RequiredKeys, historyOptions?: TLCommandHistoryOptions) => {
- if (this.getInstanceState().isReadonly) return null
-
- const prev = this.getPage(partial.id)
-
- if (!prev) return null
-
- return { data: { prev, partial }, ...historyOptions }
- },
- {
- do: ({ partial }) => {
- this.store.update(partial.id, (page) => ({ ...page, ...partial }))
- },
- undo: ({ prev, partial }) => {
- this.store.update(partial.id, () => prev)
- },
- squash(prevData, nextData) {
- return {
- prev: { ...prevData.prev, ...nextData.prev },
- partial: nextData.partial,
- }
- },
- }
- )
/**
* Create a page.
@@ -3474,15 +3361,9 @@ export class Editor extends EventEmitter {
* @public
*/
createPage(page: Partial): this {
- this._createPage(page)
- return this
- }
- /** @internal */
- private _createPage = this.history.createCommand(
- 'createPage',
- (page: Partial) => {
- if (this.getInstanceState().isReadonly) return null
- if (this.getPages().length >= MAX_PAGES) return null
+ this.history.batch(() => {
+ if (this.getInstanceState().isReadonly) return
+ if (this.getPages().length >= MAX_PAGES) return
const pages = this.getPages()
const name = getIncrementedName(
@@ -3503,33 +3384,10 @@ export class Editor extends EventEmitter {
index,
})
- const newCamera = CameraRecordType.create({
- id: CameraRecordType.createId(newPage.id),
- })
-
- const newTabPageState = InstancePageStateRecordType.create({
- id: InstancePageStateRecordType.createId(newPage.id),
- pageId: newPage.id,
- })
-
- return {
- data: {
- newPage,
- newTabPageState,
- newCamera,
- },
- }
- },
- {
- do: ({ newPage, newTabPageState, newCamera }) => {
- this.store.put([newPage, newCamera, newTabPageState])
- },
- undo: ({ newPage, newTabPageState, newCamera }) => {
- if (this.getPages().length === 1) return
- this.store.remove([newTabPageState.id, newPage.id, newCamera.id])
- },
- }
- )
+ this.store.put([newPage])
+ })
+ return this
+ }
/**
* Delete a page.
@@ -3545,21 +3403,13 @@ export class Editor extends EventEmitter {
*/
deletePage(page: TLPageId | TLPage): this {
const id = typeof page === 'string' ? page : page.id
- this._deletePage(id)
- return this
- }
- /** @internal */
- private _deletePage = this.history.createCommand(
- 'delete_page',
- (id: TLPageId) => {
- if (this.getInstanceState().isReadonly) return null
+ this.batch(() => {
+ if (this.getInstanceState().isReadonly) return
const pages = this.getPages()
- if (pages.length === 1) return null
+ if (pages.length === 1) return
const deletedPage = this.getPage(id)
- const deletedPageStates = this.getPageStates().filter((s) => s.pageId === id)
-
- if (!deletedPage) return null
+ if (!deletedPage) return
if (id === this.getCurrentPageId()) {
const index = pages.findIndex((page) => page.id === id)
@@ -3567,30 +3417,11 @@ export class Editor extends EventEmitter {
this.setCurrentPage(next.id)
}
- return { data: { id, deletedPage, deletedPageStates } }
- },
- {
- do: ({ deletedPage, deletedPageStates }) => {
- const pages = this.getPages()
- if (pages.length === 1) return
-
- if (deletedPage.id === this.getCurrentPageId()) {
- const index = pages.findIndex((page) => page.id === deletedPage.id)
- const next = pages[index - 1] ?? pages[index + 1]
- this.setCurrentPage(next.id)
- }
-
- this.store.remove(deletedPageStates.map((s) => s.id)) // remove the page state
- this.store.remove([deletedPage.id]) // remove the page
- this.updateRenderingBounds()
- },
- undo: ({ deletedPage, deletedPageStates }) => {
- this.store.put([deletedPage])
- this.store.put(deletedPageStates)
- this.updateRenderingBounds()
- },
- }
- )
+ this.store.remove([deletedPage.id])
+ this.updateRenderingBounds()
+ })
+ return this
+ }
/**
* Duplicate a page.
@@ -3642,10 +3473,10 @@ export class Editor extends EventEmitter {
*
* @public
*/
- renamePage(page: TLPageId | TLPage, name: string, historyOptions?: TLCommandHistoryOptions) {
+ renamePage(page: TLPageId | TLPage, name: string) {
const id = typeof page === 'string' ? page : page.id
if (this.getInstanceState().isReadonly) return this
- this.updatePage({ id, name }, historyOptions)
+ this.updatePage({ id, name })
return this
}
@@ -3678,28 +3509,10 @@ export class Editor extends EventEmitter {
* @public
*/
createAssets(assets: TLAsset[]): this {
- this._createAssets(assets)
- return this
+ if (this.getInstanceState().isReadonly) return this
+ if (assets.length <= 0) return this
+ return this.batch(() => this.store.put(assets))
}
- /** @internal */
- private _createAssets = this.history.createCommand(
- 'createAssets',
- (assets: TLAsset[]) => {
- if (this.getInstanceState().isReadonly) return null
- if (assets.length <= 0) return null
-
- return { data: { assets } }
- },
- {
- do: ({ assets }) => {
- this.store.put(assets)
- },
- undo: ({ assets }) => {
- // todo: should we actually remove assets here? or on cleanup elsewhere?
- this.store.remove(assets.map((a) => a.id))
- },
- }
- )
/**
* Update one or more assets.
@@ -3714,39 +3527,17 @@ export class Editor extends EventEmitter {
* @public
*/
updateAssets(assets: TLAssetPartial[]): this {
- this._updateAssets(assets)
- return 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,
+ }))
+ )
+ })
}
- /** @internal */
- private _updateAssets = this.history.createCommand(
- 'updateAssets',
- (assets: TLAssetPartial[]) => {
- if (this.getInstanceState().isReadonly) return
- if (assets.length <= 0) return
-
- const snapshots: Record = {}
-
- return { data: { snapshots, assets } }
- },
- {
- do: ({ assets, snapshots }) => {
- this.store.put(
- assets.map((a) => {
- const asset = this.store.get(a.id)!
- snapshots[a.id] = asset
-
- return {
- ...asset,
- ...a,
- }
- })
- )
- },
- undo: ({ snapshots }) => {
- this.store.put(Object.values(snapshots))
- },
- }
- )
/**
* Delete one or more assets.
@@ -3761,33 +3552,16 @@ export class Editor extends EventEmitter {
* @public
*/
deleteAssets(assets: TLAssetId[] | TLAsset[]): this {
+ if (this.getInstanceState().isReadonly) return this
+
const ids =
typeof assets[0] === 'string'
? (assets as TLAssetId[])
: (assets as TLAsset[]).map((a) => a.id)
- this._deleteAssets(ids)
- return this
+ if (ids.length <= 0) return this
+
+ return this.batch(() => this.store.remove(ids))
}
- /** @internal */
- private _deleteAssets = this.history.createCommand(
- 'deleteAssets',
- (ids: TLAssetId[]) => {
- if (this.getInstanceState().isReadonly) return
- if (ids.length <= 0) return
-
- const prev = compact(ids.map((id) => this.store.get(id)))
-
- return { data: { ids, prev } }
- },
- {
- do: ({ ids }) => {
- this.store.remove(ids)
- },
- undo: ({ prev }) => {
- this.store.put(prev)
- },
- }
- )
/**
* Get an asset by its id.
@@ -5127,18 +4901,13 @@ export class Editor extends EventEmitter {
* @example
* ```ts
* editor.nudgeShapes(['box1', 'box2'], { x: 8, y: 8 })
- * editor.nudgeShapes(editor.getSelectedShapes(), { x: 8, y: 8 }, { squashing: true })
* ```
*
* @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,
- historyOptions?: TLCommandHistoryOptions
- ): this {
+ nudgeShapes(shapes: TLShapeId[] | TLShape[], offset: VecLike): this {
const ids =
typeof shapes[0] === 'string'
? (shapes as TLShapeId[])
@@ -5156,10 +4925,7 @@ export class Editor extends EventEmitter {
changes.push(this.getChangesToTranslateShape(shape, localDelta.add(shape)))
}
- this.updateShapes(changes, {
- squashing: true,
- ...historyOptions,
- })
+ this.updateShapes(changes)
return this
}
@@ -6141,7 +5907,7 @@ export class Editor extends EventEmitter {
if (parentTransform) localOffset.rot(-parentTransform.rotation())
const { x, y } = Vec.Add(localOffset, shape)
- this.updateShapes([{ id: shape.id, type: shape.type, x, y }], { squashing: true })
+ 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,
@@ -6164,7 +5930,7 @@ export class Editor extends EventEmitter {
if (parentTransform) localOffset.rot(-parentTransform.rotation())
const { x, y } = Vec.Add(localOffset, shape)
- this.updateShapes([{ id: shape.id, type: shape.type, x, y }], { squashing: true })
+ 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,
@@ -6277,30 +6043,27 @@ export class Editor extends EventEmitter {
// need to adjust the shape's x and y points in case the parent has moved since start of resizing
const { x, y } = this.getPointInParentSpace(initialShape.id, initialPagePoint)
- this.updateShapes(
- [
- {
- id,
- type: initialShape.type as any,
- x: newLocalPoint.x,
- y: newLocalPoint.y,
- ...util.onResize(
- { ...initialShape, x, y },
- {
- newPoint: newLocalPoint,
- handle: options.dragHandle ?? 'bottom_right',
- // don't set isSingle to true for children
- mode: options.mode ?? 'scale_shape',
- scaleX: myScale.x,
- scaleY: myScale.y,
- initialBounds,
- initialShape,
- }
- ),
- },
- ],
- { squashing: true }
- )
+ this.updateShapes([
+ {
+ id,
+ type: initialShape.type as any,
+ x: newLocalPoint.x,
+ y: newLocalPoint.y,
+ ...util.onResize(
+ { ...initialShape, x, y },
+ {
+ newPoint: newLocalPoint,
+ handle: options.dragHandle ?? 'bottom_right',
+ // don't set isSingle to true for children
+ mode: options.mode ?? 'scale_shape',
+ scaleX: myScale.x,
+ scaleY: myScale.y,
+ initialBounds,
+ initialShape,
+ }
+ ),
+ },
+ ])
} else {
const initialPageCenter = Mat.applyToPoint(pageTransform, initialBounds.center)
// get the model changes from the shape util
@@ -6319,17 +6082,14 @@ export class Editor extends EventEmitter {
const delta = Vec.Sub(newPageCenterInParentSpace, initialPageCenterInParentSpace)
// apply the changes to the model
- this.updateShapes(
- [
- {
- id,
- type: initialShape.type as any,
- x: initialShape.x + delta.x,
- y: initialShape.y + delta.y,
- },
- ],
- { squashing: true }
- )
+ this.updateShapes([
+ {
+ id,
+ type: initialShape.type as any,
+ x: initialShape.x + delta.x,
+ y: initialShape.y + delta.y,
+ },
+ ])
}
return this
@@ -6395,7 +6155,7 @@ export class Editor extends EventEmitter {
if (Math.sign(scale.x) * Math.sign(scale.y) < 0) {
let { rotation } = Mat.Decompose(options.initialPageTransform)
rotation -= 2 * rotation
- this.updateShapes([{ id, type, rotation }], { squashing: true })
+ this.updateShapes([{ id, type, rotation }])
}
// Next we need to translate the shape so that it's center point ends up in the right place.
@@ -6425,7 +6185,7 @@ export class Editor extends EventEmitter {
const postScaleShapePagePoint = Vec.Add(shapePageTransformOrigin, pageDelta)
const { x, y } = this.getPointInParentSpace(id, postScaleShapePagePoint)
- this.updateShapes([{ id, type, x, y }], { squashing: true })
+ this.updateShapes([{ id, type, x, y }])
return this
}
@@ -6464,7 +6224,7 @@ export class Editor extends EventEmitter {
* @public
*/
createShape(shape: OptionalKeys, 'id'>): this {
- this._createShapes([shape])
+ this.createShapes([shape])
return this
}
@@ -6482,204 +6242,184 @@ export class Editor extends EventEmitter {
*
* @public
*/
- createShapes(shapes: OptionalKeys, 'id'>[]) {
+ createShapes(shapes: OptionalKeys, 'id'>[]): this {
if (!Array.isArray(shapes)) {
throw Error('Editor.createShapes: must provide an array of shapes or shape partials')
}
- this._createShapes(shapes)
- return this
- }
+ if (this.getInstanceState().isReadonly) return this
+ if (shapes.length <= 0) return this
- /** @internal */
- private _createShapes = this.history.createCommand(
- 'createShapes',
- (partials: OptionalKeys[]) => {
- if (this.getInstanceState().isReadonly) return null
- if (partials.length <= 0) return null
+ const currentPageShapeIds = this.getCurrentPageShapeIds()
- const currentPageShapeIds = this.getCurrentPageShapeIds()
+ const maxShapesReached = shapes.length + currentPageShapeIds.size > MAX_SHAPES_PER_PAGE
- const maxShapesReached = partials.length + currentPageShapeIds.size > MAX_SHAPES_PER_PAGE
+ if (maxShapesReached) {
+ // can't create more shapes than fit on the page
+ alertMaxShapes(this)
+ return this
+ }
- if (maxShapesReached) {
- // can't create more shapes than fit on the page
- alertMaxShapes(this)
- return
- }
+ const focusedGroupId = this.getFocusedGroupId()
- if (partials.length === 0) return null
+ return this.batch(() => {
+ // 1. Parents
- return {
- data: {
- currentPageId: this.getCurrentPageId(),
- partials: partials.map((p) =>
- p.id ? p : { ...p, id: createShapeId() }
- ) as TLShapePartial[],
- },
- }
- },
- {
- do: ({ partials }) => {
- const focusedGroupId = this.getFocusedGroupId()
+ // 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.
- // 1. Parents
+ // find last parent id
+ const currentPageShapesSorted = this.getCurrentPageShapesSorted()
- // 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()
-
- partials = partials.map((partial) => {
- // If the partial does not provide the parentId OR if the provided
- // parentId is NOT in the store AND NOT among the other shapes being
- // created, then we need to find a parent for the shape. This can be
- // another shape that exists under that point and which can receive
- // children of the creating shape's type, or else the page itself.
- if (
- !partial.parentId ||
- !(this.store.has(partial.parentId) || partials.some((p) => p.id === partial.parentId))
- ) {
- 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)
-
- // 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.
+ 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,
})
- this.store.put(shapeRecordsToCreate)
- },
- undo: ({ partials }) => {
- this.store.remove(partials.map((p) => p.id))
- },
- }
- )
+ if (shapeRecordToCreate.index === undefined) {
+ throw Error('no index!')
+ }
+
+ const next = this.getShapeUtil(shapeRecordToCreate).onBeforeCreate?.(shapeRecordToCreate)
+
+ if (next) {
+ shapeRecordToCreate = next
+ }
+
+ shapeRecordsToCreate.push(shapeRecordToCreate)
+ }
+
+ // Add meta properties, if any, to the shapes
+ shapeRecordsToCreate.forEach((shape) => {
+ shape.meta = {
+ ...this.getInitialMetaForShape(shape),
+ ...shape.meta,
+ }
+ })
+
+ this.store.put(shapeRecordsToCreate)
+ })
+ }
private animatingShapes = new Map()
@@ -6771,7 +6511,7 @@ export class Editor extends EventEmitter {
(p) => p && animatingShapes.get(p.id) === animationId
)
if (partialsToUpdate.length) {
- this.updateShapes(partialsToUpdate, { squashing: false })
+ this.updateShapes(partialsToUpdate)
// update shapes also removes the shape from animating shapes
}
@@ -6803,7 +6543,7 @@ export class Editor extends EventEmitter {
})
}
- this._updateShapes(updates, { squashing: true })
+ this._updateShapes(updates)
}
this.addListener('tick', handleTick)
@@ -6948,15 +6688,11 @@ export class Editor extends EventEmitter {
* ```
*
* @param partial - The shape partial to update.
- * @param historyOptions - The history options for the change.
*
* @public
*/
- updateShape(
- partial: TLShapePartial | null | undefined,
- historyOptions?: TLCommandHistoryOptions
- ) {
- this.updateShapes([partial], historyOptions)
+ updateShape(partial: TLShapePartial | null | undefined) {
+ this.updateShapes([partial])
return this
}
@@ -6969,14 +6705,10 @@ export class Editor extends EventEmitter {
* ```
*
* @param partials - The shape partials to update.
- * @param historyOptions - The history options for the change.
*
* @public
*/
- updateShapes(
- partials: (TLShapePartial | null | undefined)[],
- historyOptions?: TLCommandHistoryOptions
- ) {
+ updateShapes(partials: (TLShapePartial | null | undefined)[]) {
const compactedPartials: TLShapePartial[] = Array(partials.length)
for (let i = 0, n = partials.length; i < n; i++) {
@@ -6995,21 +6727,16 @@ export class Editor extends EventEmitter {
compactedPartials.push(partial)
}
- this._updateShapes(compactedPartials, historyOptions)
+ this._updateShapes(compactedPartials)
return this
}
/** @internal */
- private _updateShapes = this.history.createCommand(
- 'updateShapes',
- (
- _partials: (TLShapePartial | null | undefined)[],
- historyOptions?: TLCommandHistoryOptions
- ) => {
- if (this.getInstanceState().isReadonly) return null
+ private _updateShapes = (_partials: (TLShapePartial | null | undefined)[]) => {
+ if (this.getInstanceState().isReadonly) return
- const snapshots: Record = {}
- const updates: Record = {}
+ this.batch(() => {
+ const updates = []
let shape: TLShape | undefined
let updated: TLShape
@@ -7029,42 +6756,17 @@ export class Editor extends EventEmitter {
updated = applyPartialToShape(shape, partial)
if (updated === shape) continue
- snapshots[shape.id] = shape
- updates[shape.id] = updated
+ //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)
}
- return { data: { snapshots, updates }, ...historyOptions }
- },
- {
- do: ({ updates }) => {
- // Iterate through array; 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.
- this.store.put(
- objectMapValues(updates).map((shape) => {
- const current = this.store.get(shape.id)
- if (current) {
- const next = this.getShapeUtil(shape).onBeforeUpdate?.(current, shape)
- if (next) return next
- }
- return shape
- })
- )
- },
- undo: ({ snapshots }) => {
- this.store.put(Object.values(snapshots))
- },
- squash(prevData, nextData) {
- return {
- // keep the oldest snapshots
- snapshots: { ...nextData.snapshots, ...prevData.snapshots },
- // keep the newest updates
- updates: { ...prevData.updates, ...nextData.updates },
- }
- },
- }
- )
+ this.store.put(updates)
+ })
+ }
/** @internal */
private _getUnlockedShapeIds(ids: TLShapeId[]): TLShapeId[] {
@@ -7085,16 +6787,28 @@ export class Editor extends EventEmitter {
*/
deleteShapes(ids: TLShapeId[]): this
deleteShapes(shapes: TLShape[]): this
- deleteShapes(_ids: TLShapeId[] | TLShape[]) {
+ deleteShapes(_ids: TLShapeId[] | TLShape[]): this {
if (!Array.isArray(_ids)) {
throw Error('Editor.deleteShapes: must provide an array of shapes or shapeIds')
}
- this._deleteShapes(
- this._getUnlockedShapeIds(
- typeof _ids[0] === 'string' ? (_ids as TLShapeId[]) : (_ids as TLShape[]).map((s) => s.id)
- )
+
+ const ids = this._getUnlockedShapeIds(
+ typeof _ids[0] === 'string' ? (_ids as TLShapeId[]) : (_ids as TLShape[]).map((s) => s.id)
)
- return this
+
+ if (this.getInstanceState().isReadonly) return this
+ if (ids.length === 0) return this
+
+ const allIds = new Set(ids)
+
+ for (const id of ids) {
+ this.visitDescendants(id, (childId) => {
+ allIds.add(childId)
+ })
+ }
+
+ const deletedIds = [...allIds]
+ return this.batch(() => this.store.remove(deletedIds))
}
/**
@@ -7116,59 +6830,6 @@ export class Editor extends EventEmitter {
return this
}
- /** @internal */
- private _deleteShapes = this.history.createCommand(
- 'delete_shapes',
- (ids: TLShapeId[]) => {
- if (this.getInstanceState().isReadonly) return null
- if (ids.length === 0) return null
- const prevSelectedShapeIds = [...this.getCurrentPageState().selectedShapeIds]
-
- const allIds = new Set(ids)
-
- for (const id of ids) {
- this.visitDescendants(id, (childId) => {
- allIds.add(childId)
- })
- }
-
- const deletedIds = [...allIds]
- const arrowBindings = this._getArrowBindingsIndex().get()
- const snapshots = compact(
- deletedIds.flatMap((id) => {
- const shape = this.getShape(id)
-
- // Add any bound arrows to the snapshots, so that we can restore the bindings on undo
- const bindings = arrowBindings[id]
- if (bindings && bindings.length > 0) {
- return bindings.map(({ arrowId }) => this.getShape(arrowId)).concat(shape)
- }
- return shape
- })
- )
-
- const postSelectedShapeIds = prevSelectedShapeIds.filter((id) => !allIds.has(id))
-
- return { data: { deletedIds, snapshots, prevSelectedShapeIds, postSelectedShapeIds } }
- },
- {
- do: ({ deletedIds, postSelectedShapeIds }) => {
- this.store.remove(deletedIds)
- this.store.update(this.getCurrentPageState().id, (state) => ({
- ...state,
- selectedShapeIds: postSelectedShapeIds,
- }))
- },
- undo: ({ snapshots, prevSelectedShapeIds }) => {
- this.store.put(snapshots)
- this.store.update(this.getCurrentPageState().id, (state) => ({
- ...state,
- selectedShapeIds: prevSelectedShapeIds,
- }))
- },
- }
- )
-
/* --------------------- Styles --------------------- */
/**
@@ -7319,13 +6980,12 @@ export class Editor extends EventEmitter {
* @example
* ```ts
* editor.setOpacityForNextShapes(0.5)
- * editor.setOpacityForNextShapes(0.5, { squashing: true })
* ```
*
* @param opacity - The opacity to set. Must be a number between 0 and 1 inclusive.
* @param historyOptions - The history options for the change.
*/
- setOpacityForNextShapes(opacity: number, historyOptions?: TLCommandHistoryOptions): this {
+ setOpacityForNextShapes(opacity: number, historyOptions?: TLHistoryBatchOptions): this {
this.updateInstanceState({ opacityForNextShape: opacity }, historyOptions)
return this
}
@@ -7336,13 +6996,11 @@ export class Editor extends EventEmitter {
* @example
* ```ts
* editor.setOpacityForSelectedShapes(0.5)
- * editor.setOpacityForSelectedShapes(0.5, { squashing: true })
* ```
*
* @param opacity - The opacity to set. Must be a number between 0 and 1 inclusive.
- * @param historyOptions - The history options for the change.
*/
- setOpacityForSelectedShapes(opacity: number, historyOptions?: TLCommandHistoryOptions): this {
+ setOpacityForSelectedShapes(opacity: number): this {
const selectedShapes = this.getSelectedShapes()
if (selectedShapes.length > 0) {
@@ -7372,8 +7030,7 @@ export class Editor extends EventEmitter {
type: shape.type,
opacity,
}
- }),
- historyOptions
+ })
)
}
@@ -7398,7 +7055,7 @@ export class Editor extends EventEmitter {
setStyleForNextShapes(
style: StyleProp,
value: T,
- historyOptions?: TLCommandHistoryOptions
+ historyOptions?: TLHistoryBatchOptions
): this {
const stylesForNextShape = this.getInstanceState().stylesForNextShape
@@ -7416,7 +7073,6 @@ export class Editor extends EventEmitter {
* @example
* ```ts
* editor.setStyleForSelectedShapes(DefaultColorStyle, 'red')
- * editor.setStyleForSelectedShapes(DefaultColorStyle, 'red', { ephemeral: true })
* ```
*
* @param style - The style to set.
@@ -7425,11 +7081,7 @@ export class Editor extends EventEmitter {
*
* @public
*/
- setStyleForSelectedShapes>(
- style: S,
- value: StylePropValue,
- historyOptions?: TLCommandHistoryOptions
- ): this {
+ setStyleForSelectedShapes>(style: S, value: StylePropValue): this {
const selectedShapes = this.getSelectedShapes()
if (selectedShapes.length > 0) {
@@ -7469,10 +7121,7 @@ export class Editor extends EventEmitter {
addShapeById(shape)
}
- this.updateShapes(
- updates.map(({ updatePartial }) => updatePartial),
- historyOptions
- )
+ this.updateShapes(updates.map(({ updatePartial }) => updatePartial))
}
return this
@@ -8212,22 +7861,24 @@ export class Editor extends EventEmitter {
}
// todo: We only have to do this if there are multiple users in the document
- this.store.put([
- {
- id: TLPOINTER_ID,
- typeName: 'pointer',
- x: currentPagePoint.x,
- y: currentPagePoint.y,
- lastActivityTimestamp:
- // If our pointer moved only because we're following some other user, then don't
- // update our last activity timestamp; otherwise, update it to the current timestamp.
- info.type === 'pointer' && info.pointerId === INTERNAL_POINTER_IDS.CAMERA_MOVE
- ? this.store.unsafeGetWithoutCapture(TLPOINTER_ID)?.lastActivityTimestamp ??
- this._tickManager.now
- : this._tickManager.now,
- meta: {},
- },
- ])
+ this.history.ignore(() => {
+ this.store.put([
+ {
+ id: TLPOINTER_ID,
+ typeName: 'pointer',
+ x: currentPagePoint.x,
+ y: currentPagePoint.y,
+ lastActivityTimestamp:
+ // If our pointer moved only because we're following some other user, then don't
+ // update our last activity timestamp; otherwise, update it to the current timestamp.
+ info.type === 'pointer' && info.pointerId === INTERNAL_POINTER_IDS.CAMERA_MOVE
+ ? this.store.unsafeGetWithoutCapture(TLPOINTER_ID)?.lastActivityTimestamp ??
+ this._tickManager.now
+ : this._tickManager.now,
+ meta: {},
+ },
+ ])
+ })
}
/**
@@ -8426,12 +8077,7 @@ export class Editor extends EventEmitter {
if (this.inputs.isPanning) {
this.inputs.isPanning = false
- this.updateInstanceState({
- cursor: {
- type: this._prevCursor,
- rotation: 0,
- },
- })
+ this.setCursor({ type: this._prevCursor, rotation: 0 })
}
}
@@ -8529,14 +8175,14 @@ export class Editor extends EventEmitter {
inputs.isPinching = false
const { _selectedShapeIdsAtPointerDown } = this
- this.setSelectedShapes(this._selectedShapeIdsAtPointerDown, { squashing: true })
+ this.setSelectedShapes(this._selectedShapeIdsAtPointerDown)
this._selectedShapeIdsAtPointerDown = []
if (this._didPinch) {
this._didPinch = false
this.once('tick', () => {
if (!this._didPinch) {
- this.setSelectedShapes(_selectedShapeIdsAtPointerDown, { squashing: true })
+ this.setSelectedShapes(_selectedShapeIdsAtPointerDown)
}
})
}
diff --git a/packages/editor/src/lib/editor/managers/HistoryManager.test.ts b/packages/editor/src/lib/editor/managers/HistoryManager.test.ts
index 5dd0124df..12429bcf5 100644
--- a/packages/editor/src/lib/editor/managers/HistoryManager.test.ts
+++ b/packages/editor/src/lib/editor/managers/HistoryManager.test.ts
@@ -1,92 +1,75 @@
-import { TLCommandHistoryOptions } from '../types/history-types'
+import { BaseRecord, RecordId, Store, StoreSchema, createRecordType } from '@tldraw/store'
+import { TLHistoryBatchOptions } from '../types/history-types'
import { HistoryManager } from './HistoryManager'
import { stack } from './Stack'
+interface TestRecord extends BaseRecord<'test', TestRecordId> {
+ value: number | string
+}
+type TestRecordId = RecordId
+const testSchema = StoreSchema.create({
+ test: createRecordType('test', { scope: 'document' }),
+})
+
+const ids = {
+ count: testSchema.types.test.createId('count'),
+ name: testSchema.types.test.createId('name'),
+ age: testSchema.types.test.createId('age'),
+ a: testSchema.types.test.createId('a'),
+ b: testSchema.types.test.createId('b'),
+}
+
function createCounterHistoryManager() {
- const manager = new HistoryManager({ emit: () => void null }, () => {
- return
- })
- const state = {
- count: 0,
- name: 'David',
- age: 35,
+ const store = new Store({ schema: testSchema, props: null })
+ store.put([
+ testSchema.types.test.create({ id: ids.count, value: 0 }),
+ testSchema.types.test.create({ id: ids.name, value: 'David' }),
+ testSchema.types.test.create({ id: ids.age, value: 35 }),
+ ])
+
+ const manager = new HistoryManager({ store })
+
+ function getCount() {
+ return store.get(ids.count)!.value as number
+ }
+ function getName() {
+ return store.get(ids.name)!.value as string
+ }
+ function getAge() {
+ return store.get(ids.age)!.value as number
+ }
+ function _setCount(n: number) {
+ store.update(ids.count, (c) => ({ ...c, value: n }))
+ }
+ function _setName(name: string) {
+ store.update(ids.name, (c) => ({ ...c, value: name }))
+ }
+ function _setAge(age: number) {
+ store.update(ids.age, (c) => ({ ...c, value: age }))
}
- const increment = manager.createCommand(
- 'increment',
- (n = 1, squashing = false) => ({
- data: { n },
- squashing,
- }),
- {
- do: ({ n }) => {
- state.count += n
- },
- undo: ({ n }) => {
- state.count -= n
- },
- squash: ({ n: n1 }, { n: n2 }) => ({ n: n1 + n2 }),
- }
- )
- const decrement = manager.createCommand(
- 'decrement',
- (n = 1, squashing = false) => ({
- data: { n },
- squashing,
- }),
- {
- do: ({ n }) => {
- state.count -= n
- },
- undo: ({ n }) => {
- state.count += n
- },
- squash: ({ n: n1 }, { n: n2 }) => ({ n: n1 + n2 }),
- }
- )
+ const increment = (n = 1) => {
+ _setCount(getCount() + n)
+ }
- const setName = manager.createCommand(
- 'setName',
- (name = 'David') => ({
- data: { name, prev: state.name },
- ephemeral: true,
- }),
- {
- do: ({ name }) => {
- state.name = name
- },
- undo: ({ prev }) => {
- state.name = prev
- },
- }
- )
+ const decrement = (n = 1) => {
+ _setCount(getCount() - n)
+ }
- const setAge = manager.createCommand(
- 'setAge',
- (age = 35) => ({
- data: { age, prev: state.age },
- preservesRedoStack: true,
- }),
- {
- do: ({ age }) => {
- state.age = age
- },
- undo: ({ prev }) => {
- state.age = prev
- },
- }
- )
+ const setName = (name = 'David') => {
+ manager.ignore(() => _setName(name))
+ }
- const incrementTwice = manager.createCommand('incrementTwice', () => ({ data: {} }), {
- do: () => {
+ const setAge = (age = 35) => {
+ manager.batch(() => _setAge(age), { history: 'record-preserveRedoStack' })
+ }
+
+ const incrementTwice = () => {
+ manager.batch(() => {
increment()
increment()
- },
- undo: () => {
- decrement()
- decrement()
- },
- })
+ })
+ }
return {
increment,
@@ -95,9 +78,9 @@ function createCounterHistoryManager() {
setName,
setAge,
history: manager,
- getCount: () => state.count,
- getName: () => state.name,
- getAge: () => state.age,
+ getCount,
+ getName,
+ getAge,
}
}
@@ -116,9 +99,9 @@ describe(HistoryManager, () => {
editor.decrement()
expect(editor.getCount()).toBe(3)
- const undos = [...editor.history._undos.get()]
+ const undos = [...editor.history.stacks.get().undos]
const parsedUndos = JSON.parse(JSON.stringify(undos))
- editor.history._undos.set(stack(parsedUndos))
+ editor.history.stacks.update(({ redos }) => ({ undos: stack(parsedUndos), redos }))
editor.history.undo()
@@ -200,17 +183,16 @@ describe(HistoryManager, () => {
editor.history.mark('stop at 1')
expect(editor.getCount()).toBe(1)
- editor.increment(1, true)
- editor.increment(1, true)
- editor.increment(1, true)
- editor.increment(1, true)
+ editor.increment(1)
+ editor.increment(1)
+ editor.increment(1)
+ editor.increment(1)
expect(editor.getCount()).toBe(5)
expect(editor.history.getNumUndos()).toBe(3)
})
-
- it('allows ephemeral commands that do not affect the stack', () => {
+ it('allows ignore commands that do not affect the stack', () => {
editor.increment()
editor.history.mark('stop at 1')
editor.increment()
@@ -263,7 +245,7 @@ describe(HistoryManager, () => {
editor.history.mark('2')
editor.incrementTwice()
editor.incrementTwice()
- expect(editor.history.getNumUndos()).toBe(5)
+ expect(editor.history.getNumUndos()).toBe(4)
expect(editor.getCount()).toBe(6)
editor.history.bail()
expect(editor.getCount()).toBe(2)
@@ -289,58 +271,35 @@ describe(HistoryManager, () => {
})
describe('history options', () => {
- let manager: HistoryManager
- let state: { a: number; b: number }
+ let manager: HistoryManager
- let setA: (n: number, historyOptions?: TLCommandHistoryOptions) => any
- let setB: (n: number, historyOptions?: TLCommandHistoryOptions) => any
+ let getState: () => { a: number; b: number }
+ let setA: (n: number, historyOptions?: TLHistoryBatchOptions) => any
+ let setB: (n: number, historyOptions?: TLHistoryBatchOptions) => any
beforeEach(() => {
- manager = new HistoryManager({ emit: () => void null }, () => {
- return
- })
+ const store = new Store({ schema: testSchema, props: null })
+ store.put([
+ testSchema.types.test.create({ id: ids.a, value: 0 }),
+ testSchema.types.test.create({ id: ids.b, value: 0 }),
+ ])
- state = {
- a: 0,
- b: 0,
+ manager = new HistoryManager({ store })
+
+ getState = () => {
+ return { a: store.get(ids.a)!.value as number, b: store.get(ids.b)!.value as number }
}
- setA = manager.createCommand(
- 'setA',
- (n: number, historyOptions?: TLCommandHistoryOptions) => ({
- data: { next: n, prev: state.a },
- ...historyOptions,
- }),
- {
- do: ({ next }) => {
- state = { ...state, a: next }
- },
- undo: ({ prev }) => {
- state = { ...state, a: prev }
- },
- squash: ({ prev }, { next }) => ({ prev, next }),
- }
- )
+ setA = (n: number, historyOptions?: TLHistoryBatchOptions) => {
+ manager.batch(() => store.update(ids.a, (s) => ({ ...s, value: n })), historyOptions)
+ }
- setB = manager.createCommand(
- 'setB',
- (n: number, historyOptions?: TLCommandHistoryOptions) => ({
- data: { next: n, prev: state.b },
- ...historyOptions,
- }),
- {
- do: ({ next }) => {
- state = { ...state, b: next }
- },
- undo: ({ prev }) => {
- state = { ...state, b: prev }
- },
- squash: ({ prev }, { next }) => ({ prev, next }),
- }
- )
+ setB = (n: number, historyOptions?: TLHistoryBatchOptions) => {
+ manager.batch(() => store.update(ids.b, (s) => ({ ...s, value: n })), historyOptions)
+ }
})
- it('sets, undoes, redoes', () => {
+ it('undos, redoes, separate marks', () => {
manager.mark()
setA(1)
manager.mark()
@@ -348,18 +307,18 @@ describe('history options', () => {
manager.mark()
setB(2)
- expect(state).toMatchObject({ a: 1, b: 2 })
+ expect(getState()).toMatchObject({ a: 1, b: 2 })
manager.undo()
- expect(state).toMatchObject({ a: 1, b: 1 })
+ expect(getState()).toMatchObject({ a: 1, b: 1 })
manager.redo()
- expect(state).toMatchObject({ a: 1, b: 2 })
+ expect(getState()).toMatchObject({ a: 1, b: 2 })
})
- it('sets, undoes, redoes', () => {
+ it('undos, redos, squashing', () => {
manager.mark()
setA(1)
manager.mark()
@@ -369,71 +328,107 @@ describe('history options', () => {
setB(3)
setB(4)
- expect(state).toMatchObject({ a: 1, b: 4 })
+ expect(getState()).toMatchObject({ a: 1, b: 4 })
manager.undo()
- expect(state).toMatchObject({ a: 1, b: 1 })
+ expect(getState()).toMatchObject({ a: 1, b: 1 })
manager.redo()
- expect(state).toMatchObject({ a: 1, b: 4 })
+ expect(getState()).toMatchObject({ a: 1, b: 4 })
})
- it('sets ephemeral, undoes, redos', () => {
+ it('undos, redos, ignore', () => {
manager.mark()
setA(1)
manager.mark()
setB(1) // B 0->1
manager.mark()
- setB(2, { ephemeral: true }) // B 0->2, but ephemeral
+ setB(2, { history: 'ignore' }) // B 0->2, but ignore
- expect(state).toMatchObject({ a: 1, b: 2 })
+ expect(getState()).toMatchObject({ a: 1, b: 2 })
manager.undo() // undoes B 2->0
- expect(state).toMatchObject({ a: 1, b: 0 })
+ expect(getState()).toMatchObject({ a: 1, b: 0 })
manager.redo() // redoes B 0->1, but not B 1-> 2
- expect(state).toMatchObject({ a: 1, b: 1 }) // no change, b 1->2 was ephemeral
+ expect(getState()).toMatchObject({ a: 1, b: 1 }) // no change, b 1->2 was ignore
})
- it('sets squashing, undoes, redos', () => {
+ it('squashing, undos, redos', () => {
manager.mark()
setA(1)
manager.mark()
setB(1)
- setB(2, { squashing: true }) // squashes with the previous command
- setB(3, { squashing: true }) // squashes with the previous command
+ setB(2) // squashes with the previous command
+ setB(3) // squashes with the previous command
- expect(state).toMatchObject({ a: 1, b: 3 })
+ expect(getState()).toMatchObject({ a: 1, b: 3 })
manager.undo()
- expect(state).toMatchObject({ a: 1, b: 0 })
+ expect(getState()).toMatchObject({ a: 1, b: 0 })
manager.redo()
- expect(state).toMatchObject({ a: 1, b: 3 })
+ expect(getState()).toMatchObject({ a: 1, b: 3 })
})
- it('sets squashing and ephemeral, undoes, redos', () => {
+ it('squashing, undos, redos, ignore', () => {
manager.mark()
setA(1)
manager.mark()
setB(1)
- setB(2, { squashing: true }) // squashes with the previous command
- setB(3, { squashing: true, ephemeral: true }) // squashes with the previous command
+ setB(2) // squashes with the previous command
+ setB(3, { history: 'ignore' }) // squashes with the previous command
- expect(state).toMatchObject({ a: 1, b: 3 })
+ expect(getState()).toMatchObject({ a: 1, b: 3 })
manager.undo()
- expect(state).toMatchObject({ a: 1, b: 0 })
+ expect(getState()).toMatchObject({ a: 1, b: 0 })
manager.redo()
- expect(state).toMatchObject({ a: 1, b: 2 }) // B2->3 was ephemeral
+ expect(getState()).toMatchObject({ a: 1, b: 2 }) // B2->3 was ignore
+ })
+
+ it('nested ignore', () => {
+ manager.mark()
+ manager.batch(
+ () => {
+ setA(1)
+ // even though we set this to record, it will still be ignored
+ manager.batch(() => setB(1), { history: 'record' })
+ setA(2)
+ },
+ { history: 'ignore' }
+ )
+ expect(getState()).toMatchObject({ a: 2, b: 1 })
+
+ // changes were ignored:
+ manager.undo()
+ expect(getState()).toMatchObject({ a: 2, b: 1 })
+
+ manager.mark()
+ manager.batch(
+ () => {
+ setA(3)
+ manager.batch(() => setB(2), { history: 'ignore' })
+ },
+ { history: 'record-preserveRedoStack' }
+ )
+ expect(getState()).toMatchObject({ a: 3, b: 2 })
+
+ // changes to A were recorded, but changes to B were ignore:
+ manager.undo()
+ expect(getState()).toMatchObject({ a: 2, b: 2 })
+
+ // We can still redo because we preserved the redo stack:
+ manager.redo()
+ expect(getState()).toMatchObject({ a: 3, b: 2 })
})
})
diff --git a/packages/editor/src/lib/editor/managers/HistoryManager.ts b/packages/editor/src/lib/editor/managers/HistoryManager.ts
index 2b0511598..5a6a7bb29 100644
--- a/packages/editor/src/lib/editor/managers/HistoryManager.ts
+++ b/packages/editor/src/lib/editor/managers/HistoryManager.ts
@@ -1,156 +1,124 @@
import { atom, transact } from '@tldraw/state'
-import { devFreeze } from '@tldraw/store'
+import {
+ RecordsDiff,
+ Store,
+ UnknownRecord,
+ createEmptyRecordsDiff,
+ isRecordsDiffEmpty,
+ reverseRecordsDiff,
+ squashRecordDiffsMutable,
+} from '@tldraw/store'
+import { exhaustiveSwitchError, noop } from '@tldraw/utils'
import { uniqueId } from '../../utils/uniqueId'
-import { TLCommandHandler, TLCommandHistoryOptions, TLHistoryEntry } from '../types/history-types'
-import { Stack, stack } from './Stack'
+import { TLHistoryBatchOptions, TLHistoryEntry } from '../types/history-types'
+import { stack } from './Stack'
-type CommandFn = (...args: any[]) =>
- | ({
- data: Data
- } & TLCommandHistoryOptions)
- | null
- | undefined
- | void
+enum HistoryRecorderState {
+ Recording = 'recording',
+ RecordingPreserveRedoStack = 'recordingPreserveRedoStack',
+ Paused = 'paused',
+}
-type ExtractData = Fn extends CommandFn ? Data : never
-type ExtractArgs = Parameters any>>
+/** @public */
+export class HistoryManager {
+ private readonly store: Store
-export class HistoryManager<
- CTX extends {
- emit: (name: 'change-history' | 'mark-history', ...args: any) => void
- },
-> {
- _undos = atom>('HistoryManager.undos', stack()) // Updated by each action that includes and undo
- _redos = atom>('HistoryManager.redos', stack()) // Updated when a user undoes
- _batchDepth = 0 // A flag for whether the user is in a batch operation
+ readonly dispose: () => void
- constructor(
- private readonly ctx: CTX,
- private readonly annotateError: (error: unknown) => void
- ) {}
+ private state: HistoryRecorderState = HistoryRecorderState.Recording
+ private readonly pendingDiff = new PendingDiff()
+ /** @internal */
+ stacks = atom(
+ 'HistoryManager.stacks',
+ {
+ undos: stack>(),
+ redos: stack>(),
+ },
+ {
+ isEqual: (a, b) => a.undos === b.undos && a.redos === b.redos,
+ }
+ )
+
+ private readonly annotateError: (error: unknown) => void
+
+ constructor(opts: { store: Store; annotateError?: (error: unknown) => void }) {
+ this.store = opts.store
+ this.annotateError = opts.annotateError ?? noop
+ this.dispose = this.store.addHistoryInterceptor((entry, source) => {
+ if (source !== 'user') return
+
+ switch (this.state) {
+ case HistoryRecorderState.Recording:
+ this.pendingDiff.apply(entry.changes)
+ this.stacks.update(({ undos }) => ({ undos, redos: stack() }))
+ break
+ case HistoryRecorderState.RecordingPreserveRedoStack:
+ this.pendingDiff.apply(entry.changes)
+ break
+ case HistoryRecorderState.Paused:
+ break
+ default:
+ exhaustiveSwitchError(this.state)
+ }
+ })
+ }
+
+ private flushPendingDiff() {
+ if (this.pendingDiff.isEmpty()) return
+
+ const diff = this.pendingDiff.clear()
+ this.stacks.update(({ undos, redos }) => ({
+ undos: undos.push({ type: 'diff', diff }),
+ redos,
+ }))
+ }
onBatchComplete: () => void = () => void null
- private _commands: Record> = {}
-
getNumUndos() {
- return this._undos.get().length
+ return this.stacks.get().undos.length + (this.pendingDiff.isEmpty() ? 0 : 1)
}
getNumRedos() {
- return this._redos.get().length
- }
- createCommand = >(
- name: Name,
- constructor: Constructor,
- handle: TLCommandHandler>
- ) => {
- if (this._commands[name]) {
- throw new Error(`Duplicate command: ${name}`)
- }
- this._commands[name] = handle
-
- const exec = (...args: ExtractArgs) => {
- if (!this._batchDepth) {
- // If we're not batching, run again in a batch
- this.batch(() => exec(...args))
- return this.ctx
- }
-
- const result = constructor(...args)
-
- if (!result) {
- return this.ctx
- }
-
- const { data, ephemeral, squashing, preservesRedoStack } = result
-
- this.ignoringUpdates((undos, redos) => {
- handle.do(data)
- return { undos, redos }
- })
-
- if (!ephemeral) {
- const prev = this._undos.get().head
- if (
- squashing &&
- prev &&
- prev.type === 'command' &&
- prev.name === name &&
- prev.preservesRedoStack === preservesRedoStack
- ) {
- // replace the last command with a squashed version
- this._undos.update((undos) =>
- undos.tail.push({
- ...prev,
- data: devFreeze(handle.squash!(prev.data, data)),
- })
- )
- } else {
- // add to the undo stack
- this._undos.update((undos) =>
- undos.push({
- type: 'command',
- name,
- data: devFreeze(data),
- preservesRedoStack: preservesRedoStack,
- })
- )
- }
-
- if (!result.preservesRedoStack) {
- this._redos.set(stack())
- }
-
- this.ctx.emit('change-history', { reason: 'push' })
- }
-
- return this.ctx
- }
-
- return exec
+ return this.stacks.get().redos.length
}
- batch = (fn: () => void) => {
+ /** @internal */
+ _isInBatch = false
+ batch = (fn: () => void, opts?: TLHistoryBatchOptions) => {
+ const previousState = this.state
+
+ // we move to the new state only if we haven't explicitly paused
+ if (previousState !== HistoryRecorderState.Paused && opts?.history) {
+ this.state = modeToState[opts.history]
+ }
+
try {
- this._batchDepth++
- if (this._batchDepth === 1) {
- transact(() => {
- const mostRecentAction = this._undos.get().head
- fn()
- if (mostRecentAction !== this._undos.get().head) {
- this.onBatchComplete()
- }
- })
- } else {
+ if (this._isInBatch) {
fn()
+ return this
}
- } catch (error) {
- this.annotateError(error)
- throw error
- } finally {
- this._batchDepth--
- }
- return this
+ this._isInBatch = true
+ try {
+ transact(() => {
+ fn()
+ this.onBatchComplete()
+ })
+ } catch (error) {
+ this.annotateError(error)
+ throw error
+ } finally {
+ this._isInBatch = false
+ }
+
+ return this
+ } finally {
+ this.state = previousState
+ }
}
- private ignoringUpdates = (
- fn: (
- undos: Stack,
- redos: Stack
- ) => { undos: Stack; redos: Stack }
- ) => {
- let undos = this._undos.get()
- let redos = this._redos.get()
-
- this._undos.set(stack())
- this._redos.set(stack())
- try {
- ;({ undos, redos } = transact(() => fn(undos, redos)))
- } finally {
- this._undos.set(undos)
- this._redos.set(redos)
- }
+ ignore(fn: () => void) {
+ return this.batch(fn, { history: 'ignore' })
}
// History
@@ -161,62 +129,66 @@ export class HistoryManager<
pushToRedoStack: boolean
toMark?: string
}) => {
- this.ignoringUpdates((undos, redos) => {
- if (undos.length === 0) {
- return { undos, redos }
+ const previousState = this.state
+ this.state = HistoryRecorderState.Paused
+ try {
+ let { undos, redos } = this.stacks.get()
+
+ // start by collecting the pending diff (everything since the last mark).
+ // we'll accumulate the diff to undo in this variable so we can apply it atomically.
+ const pendingDiff = this.pendingDiff.clear()
+ const isPendingDiffEmpty = isRecordsDiffEmpty(pendingDiff)
+ const diffToUndo = reverseRecordsDiff(pendingDiff)
+
+ if (pushToRedoStack && !isPendingDiffEmpty) {
+ redos = redos.push({ type: 'diff', diff: pendingDiff })
}
- while (undos.head?.type === 'STOP') {
- const mark = undos.head
- undos = undos.tail
- if (pushToRedoStack) {
- redos = redos.push(mark)
- }
- if (mark.id === toMark) {
- this.ctx.emit(
- 'change-history',
- pushToRedoStack ? { reason: 'undo' } : { reason: 'bail', markId: toMark }
- )
- return { undos, redos }
- }
- }
-
- if (undos.length === 0) {
- this.ctx.emit(
- 'change-history',
- pushToRedoStack ? { reason: 'undo' } : { reason: 'bail', markId: toMark }
- )
- return { undos, redos }
- }
-
- while (undos.head) {
- const command = undos.head
- undos = undos.tail
-
- if (pushToRedoStack) {
- redos = redos.push(command)
- }
-
- if (command.type === 'STOP') {
- if (command.onUndo && (!toMark || command.id === toMark)) {
- this.ctx.emit(
- 'change-history',
- pushToRedoStack ? { reason: 'undo' } : { reason: 'bail', markId: toMark }
- )
- return { undos, redos }
+ let didFindMark = false
+ if (isPendingDiffEmpty) {
+ // if nothing has happened since the last mark, pop any intermediate marks off the stack
+ while (undos.head?.type === 'stop') {
+ const mark = undos.head
+ undos = undos.tail
+ if (pushToRedoStack) {
+ redos = redos.push(mark)
+ }
+ if (mark.id === toMark) {
+ didFindMark = true
+ break
}
- } else {
- const handler = this._commands[command.name]
- handler.undo(command.data)
}
}
- this.ctx.emit(
- 'change-history',
- pushToRedoStack ? { reason: 'undo' } : { reason: 'bail', markId: toMark }
- )
- return { undos, redos }
- })
+ if (!didFindMark) {
+ loop: while (undos.head) {
+ const undo = undos.head
+ undos = undos.tail
+
+ if (pushToRedoStack) {
+ redos = redos.push(undo)
+ }
+
+ switch (undo.type) {
+ case 'diff':
+ squashRecordDiffsMutable(diffToUndo, [reverseRecordsDiff(undo.diff)])
+ break
+ case 'stop':
+ if (!toMark) break loop
+ if (undo.id === toMark) break loop
+ break
+ default:
+ exhaustiveSwitchError(undo)
+ }
+ }
+ }
+
+ this.store.applyDiff(diffToUndo, { ignoreEphemeralKeys: true })
+ this.store.ensureStoreIsUsable()
+ this.stacks.set({ undos, redos })
+ } finally {
+ this.state = previousState
+ }
return this
}
@@ -228,43 +200,43 @@ export class HistoryManager<
}
redo = () => {
- this.ignoringUpdates((undos, redos) => {
+ const previousState = this.state
+ this.state = HistoryRecorderState.Paused
+ try {
+ this.flushPendingDiff()
+
+ let { undos, redos } = this.stacks.get()
if (redos.length === 0) {
- return { undos, redos }
+ return
}
- while (redos.head?.type === 'STOP') {
+ // ignore any intermediate marks - this should take us to the first `diff` entry
+ while (redos.head?.type === 'stop') {
undos = undos.push(redos.head)
redos = redos.tail
}
- if (redos.length === 0) {
- this.ctx.emit('change-history', { reason: 'redo' })
- return { undos, redos }
- }
+ // accumulate diffs to be redone so they can be applied atomically
+ const diffToRedo = createEmptyRecordsDiff()
while (redos.head) {
- const command = redos.head
- undos = undos.push(redos.head)
+ const redo = redos.head
+ undos = undos.push(redo)
redos = redos.tail
- if (command.type === 'STOP') {
- if (command.onRedo) {
- break
- }
+ if (redo.type === 'diff') {
+ squashRecordDiffsMutable(diffToRedo, [redo.diff])
} else {
- const handler = this._commands[command.name]
- if (handler.redo) {
- handler.redo(command.data)
- } else {
- handler.do(command.data)
- }
+ break
}
}
- this.ctx.emit('change-history', { reason: 'redo' })
- return { undos, redos }
- })
+ this.store.applyDiff(diffToRedo, { ignoreEphemeralKeys: true })
+ this.store.ensureStoreIsUsable()
+ this.stacks.set({ undos, redos })
+ } finally {
+ this.state = previousState
+ }
return this
}
@@ -281,24 +253,59 @@ export class HistoryManager<
return this
}
- mark = (id = uniqueId(), onUndo = true, onRedo = true) => {
- const mostRecent = this._undos.get().head
- // dedupe marks, why not
- if (mostRecent && mostRecent.type === 'STOP') {
- if (mostRecent.id === id && mostRecent.onUndo === onUndo && mostRecent.onRedo === onRedo) {
- return mostRecent.id
- }
- }
-
- this._undos.update((undos) => undos.push({ type: 'STOP', id, onUndo, onRedo }))
-
- this.ctx.emit('mark-history', { id })
+ mark = (id = uniqueId()) => {
+ transact(() => {
+ this.flushPendingDiff()
+ this.stacks.update(({ undos, redos }) => ({ undos: undos.push({ type: 'stop', id }), redos }))
+ })
return id
}
clear() {
- this._undos.set(stack())
- this._redos.set(stack())
+ this.stacks.set({ undos: stack(), redos: stack() })
+ this.pendingDiff.clear()
+ }
+
+ /** @internal */
+ debug() {
+ const { undos, redos } = this.stacks.get()
+ return {
+ undos: undos.toArray(),
+ redos: redos.toArray(),
+ pendingDiff: this.pendingDiff.debug(),
+ state: this.state,
+ }
+ }
+}
+
+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)
+
+ clear() {
+ const diff = this.diff
+ this.diff = createEmptyRecordsDiff()
+ this.isEmptyAtom.set(true)
+ return diff
+ }
+
+ isEmpty() {
+ return this.isEmptyAtom.get()
+ }
+
+ apply(diff: RecordsDiff) {
+ squashRecordDiffsMutable(this.diff, [diff])
+ this.isEmptyAtom.set(isRecordsDiffEmpty(this.diff))
+ }
+
+ debug() {
+ return { diff: this.diff, isEmpty: this.isEmpty() }
}
}
diff --git a/packages/editor/src/lib/editor/managers/SideEffectManager.ts b/packages/editor/src/lib/editor/managers/SideEffectManager.ts
index c7f569eec..51d742bf9 100644
--- a/packages/editor/src/lib/editor/managers/SideEffectManager.ts
+++ b/packages/editor/src/lib/editor/managers/SideEffectManager.ts
@@ -88,25 +88,13 @@ export class SideEffectManager<
return next
}
- let updateDepth = 0
-
editor.store.onAfterChange = (prev, next, source) => {
- updateDepth++
-
- if (updateDepth > 1000) {
- console.error('[CleanupManager.onAfterChange] Maximum update depth exceeded, bailing out.')
- } else {
- const handlers = this._afterChangeHandlers[
- next.typeName
- ] as TLAfterChangeHandler[]
- if (handlers) {
- for (const handler of handlers) {
- handler(prev, next, source)
- }
+ const handlers = this._afterChangeHandlers[next.typeName] as TLAfterChangeHandler[]
+ if (handlers) {
+ for (const handler of handlers) {
+ handler(prev, next, source)
}
}
-
- updateDepth--
}
editor.store.onBeforeDelete = (record, source) => {
@@ -161,6 +149,46 @@ export class SideEffectManager<
private _batchCompleteHandlers: TLBatchCompleteHandler[] = []
+ /**
+ * 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
+ }
+ }) {
+ const disposes: (() => void)[] = []
+ for (const [type, handlers] of Object.entries(handlersByType) as any) {
+ if (handlers?.beforeCreate) {
+ disposes.push(this.registerBeforeCreateHandler(type, handlers.beforeCreate))
+ }
+ if (handlers?.afterCreate) {
+ disposes.push(this.registerAfterCreateHandler(type, handlers.afterCreate))
+ }
+ if (handlers?.beforeChange) {
+ disposes.push(this.registerBeforeChangeHandler(type, handlers.beforeChange))
+ }
+ if (handlers?.afterChange) {
+ disposes.push(this.registerAfterChangeHandler(type, handlers.afterChange))
+ }
+ if (handlers?.beforeDelete) {
+ disposes.push(this.registerBeforeDeleteHandler(type, handlers.beforeDelete))
+ }
+ if (handlers?.afterDelete) {
+ disposes.push(this.registerAfterDeleteHandler(type, handlers.afterDelete))
+ }
+ }
+ return () => {
+ for (const dispose of disposes) dispose()
+ }
+ }
+
/**
* 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.
diff --git a/packages/editor/src/lib/editor/types/emit-types.ts b/packages/editor/src/lib/editor/types/emit-types.ts
index 6a6185d0a..3104f51e9 100644
--- a/packages/editor/src/lib/editor/types/emit-types.ts
+++ b/packages/editor/src/lib/editor/types/emit-types.ts
@@ -15,8 +15,6 @@ export interface TLEventMap {
event: [TLEventInfo]
tick: [number]
frame: [number]
- 'change-history': [{ reason: 'undo' | 'redo' | 'push' } | { reason: 'bail'; markId?: string }]
- 'mark-history': [{ id: string }]
'select-all-text': [{ shapeId: TLShapeId }]
}
diff --git a/packages/editor/src/lib/editor/types/history-types.ts b/packages/editor/src/lib/editor/types/history-types.ts
index 6adfa2de7..1df29aaaa 100644
--- a/packages/editor/src/lib/editor/types/history-types.ts
+++ b/packages/editor/src/lib/editor/types/history-types.ts
@@ -1,50 +1,27 @@
-/** @public */
-export type TLCommandHistoryOptions = Partial<{
- /**
- * When true, this command will be squashed with the previous command in the undo / redo stack.
- */
- squashing: boolean
- /**
- * When true, this command will not add anything to the undo / redo stack. Its change will never be undone or redone.
- */
- ephemeral: boolean
- /**
- * When true, adding this this command will not clear out the redo stack.
- */
- preservesRedoStack: boolean
-}>
+import { RecordsDiff, UnknownRecord } from '@tldraw/store'
/** @public */
-export type TLHistoryMark = {
- type: 'STOP'
+export interface TLHistoryMark {
+ type: 'stop'
id: string
- onUndo: boolean
- onRedo: boolean
}
/** @public */
-export type TLCommand = {
- type: 'command'
- data: Data
- name: Name
+export interface TLHistoryDiff {
+ type: 'diff'
+ diff: RecordsDiff
+}
+
+/** @public */
+export type TLHistoryEntry = TLHistoryMark | TLHistoryDiff
+
+/** @public */
+export interface TLHistoryBatchOptions {
/**
- * Allows for commands that change state and should be undoable, but are 'inconsequential' and
- * should not clear the redo stack. e.g. modifying the set of selected ids.
+ * 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
*/
- preservesRedoStack?: boolean
-}
-
-/** @public */
-export type TLHistoryEntry = TLHistoryMark | TLCommand
-
-/** @public */
-export type TLCommandHandler = {
- do: (data: Data) => void
- undo: (data: Data) => void
- redo?: (data: Data) => void
- /**
- * Allow to combine the next command with the previous one if possible. Useful for, e.g. combining
- * a series of shape translation commands into one command in the undo stack
- */
- squash?: (prevData: Data, nextData: Data) => Data
+ history?: 'record' | 'record-preserveRedoStack' | 'ignore'
}
diff --git a/packages/store/api-report.md b/packages/store/api-report.md
index 3d125a866..cd2ba6928 100644
--- a/packages/store/api-report.md
+++ b/packages/store/api-report.md
@@ -33,6 +33,9 @@ export type ComputedCache = {
get(id: IdOf): Data | undefined;
};
+// @internal (undocumented)
+export function createEmptyRecordsDiff(): RecordsDiff;
+
// @public
export function createMigrationIds>(sequenceId: ID, versions: Versions): {
[K in keyof Versions]: `${ID}/${Versions[K]}`;
@@ -58,6 +61,9 @@ export function createRecordMigrationSequence(opts: {
// @public
export function createRecordType(typeName: R['typeName'], config: {
+ ephemeralKeys?: {
+ readonly [K in Exclude]: boolean;
+ };
scope: RecordScope;
validator?: StoreValidator;
}): RecordType>;
@@ -98,6 +104,9 @@ export class IncrementalSetConstructor {
remove(item: T): void;
}
+// @internal
+export function isRecordsDiffEmpty(diff: RecordsDiff): boolean;
+
// @public (undocumented)
export type LegacyMigration = {
down: (newState: After) => Before;
@@ -187,6 +196,9 @@ export class RecordType Exclude, RequiredProperties>;
+ readonly ephemeralKeys?: {
+ readonly [K in Exclude]: boolean;
+ };
readonly scope?: RecordScope;
readonly validator?: StoreValidator;
});
@@ -197,6 +209,12 @@ export class RecordType Exclude, RequiredProperties>;
createId(customUniquePart?: string): IdOf;
+ // (undocumented)
+ readonly ephemeralKeys?: {
+ readonly [K in Exclude]: boolean;
+ };
+ // (undocumented)
+ readonly ephemeralKeySet: ReadonlySet;
isId(id?: string): id is IdOf;
isInstance: (record?: UnknownRecord) => record is R;
parseId(id: IdOf): string;
@@ -244,22 +262,32 @@ export type SerializedStore = Record, R>;
// @public
export function squashRecordDiffs(diffs: RecordsDiff[]): RecordsDiff;
+// @internal
+export function squashRecordDiffsMutable(target: RecordsDiff, diffs: RecordsDiff[]): void;
+
// @public
export class Store {
constructor(config: {
schema: StoreSchema