Automatic undo/redo (#3364)
Our undo-redo system before this diff is based on commands. A command is: - A function that produces some data required to perform and undo a change - A function that actually performs the change, based on the data - Another function that undoes the change, based on the data - Optionally, a function to _redo_ the change, although in practice we never use this Each command that gets run is added to the undo/redo stack unless it says it shouldn't be. This diff replaces this system of commands with a new one where all changes to the store are automatically recorded in the undo/redo stack. You can imagine the new history manager like a tape recorder - it automatically records everything that happens to the store in a special diff, unless you "pause" the recording and ask it not to. Undo and redo rewind/fast-forward the tape to certain marks. As the command concept is gone, the things that were commands are now just functions that manipulate the store. One other change here is that the store's after-phase callbacks (and the after-phase side-effects as a result) are now batched up and called at the end of certain key operations. For example, `applyDiff` would previously call all the `afterCreate` callbacks before making any removals from the diff. Now, it (and anything else that uses `store.atomic(fn)` will defer firing any after callbacks until the end of an operation. before callbacks are still called part-way through operations. ## Design options Automatic recording is a fairly large big semantic change, particularly to the standalone `store.put`/`store.remove` etc. commands. We could instead make not-recording the default, and make recording opt-in instead. However, I think auto-record-by-default is the right choice for a few reasons: 1. Switching to a recording-based vs command-based undo-redo model is fundamentally a big semantic change. In the past, `store.put` etc. were always ignored. Now, regardless of whether we choose record-by-default or ignore-by-default, the behaviour of `store.put` is _context_ dependant. 2. Switching to ignore-by-default means that either our commands don't record undo/redo history any more (unless wrapped in `editor.history.record`, a far larger semantic change) or they have to always-record/all accept a history options bag. If we choose always-record, we can't use commands within `history.ignore` as they'll start recording again. If we choose the history options bag, we have to accept those options in 10s of methods - basically the entire `Editor` api surface. Overall, given that some breaking semantic change here is unavoidable, I think that record-by-default hits the right balance of tradeoffs. I think it's a better API going forward, whilst also not being too disruptive as the APIs it affects are very "deep" ones that we don't typically encourage people to use. ### Change Type - [x] `sdk` — Changes the tldraw SDK - [x] `improvement` — Improving existing features - [x] `galaxy brain` — Architectural changes ### Release Note #### Breaking changes ##### 1. History Options Previously, some (not all!) commands accepted a history options object with `squashing`, `ephemeral`, and `preserveRedoStack` flags. Squashing enabled/disabled a memory optimisation (storing individual commands vs squashing them together). Ephemeral stopped a command from affecting the undo/redo stack at all. Preserve redo stack stopped commands from wiping the redo stack. These flags were never available consistently - some commands had them and others didn't. In this version, most of these flags have been removed. `squashing` is gone entirely (everything squashes & does so much faster than before). There were a couple of commands that had a special default - for example, `updateInstanceState` used to default to being `ephemeral`. Those maintain the defaults, but the options look a little different now - `{ephemeral: true}` is now `{history: 'ignore'}` and `{preserveRedoStack: true}` is now `{history: 'record-preserveRedoStack'}`. If you were previously using these options in places where they've now been removed, you can use wrap them with `editor.history.ignore(fn)` or `editor.history.batch(fn, {history: 'record-preserveRedoStack'})`. For example, ```ts editor.nudgeShapes(..., { ephemeral: true }) ``` can now be written as ```ts editor.history.ignore(() => { editor.nudgeShapes(...) }) ``` ##### 2. Automatic recording Previously, only commands (e.g. `editor.updateShapes` and things that use it) were added to the undo/redo stack. Everything else (e.g. `editor.store.put`) wasn't. Now, _everything_ that touches the store is recorded in the undo/redo stack (unless it's part of `mergeRemoteChanges`). You can use `editor.history.ignore(fn)` as above if you want to make other changes to the store that aren't recorded - this is short for `editor.history.batch(fn, {history: 'ignore'})` When upgrading to this version of tldraw, you shouldn't need to change anything unless you're using `store.put`, `store.remove`, or `store.applyDiff` outside of `store.mergeRemoteChanges`. If you are, you can preserve the functionality of those not being recorded by wrapping them either in `mergeRemoteChanges` (if they're multiplayer-related) or `history.ignore` as appropriate. ##### 3. Side effects Before this diff, any changes in side-effects weren't captured by the undo-redo stack. This was actually the motivation for this change in the first place! But it's a pretty big change, and if you're using side effects we recommend you double-check how they interact with undo/redo before/after this change. To get the old behaviour back, wrap your side effects in `editor.history.ignore`. ##### 4. Mark options Previously, `editor.mark(id)` accepted two additional boolean parameters: `onUndo` and `onRedo`. If these were set to false, then when undoing or redoing we'd skip over that mark and keep going until we found one with those values set to true. We've removed those options - if you're using them, let us know and we'll figure out an alternative!
This commit is contained in:
parent
c9b7d328fe
commit
8151e6f586
69 changed files with 2106 additions and 1907 deletions
|
@ -77,9 +77,7 @@ const ContextToolbarComponent = track(() => {
|
||||||
width: 32,
|
width: 32,
|
||||||
background: isActive ? 'var(--color-muted-2)' : 'transparent',
|
background: isActive ? 'var(--color-muted-2)' : 'transparent',
|
||||||
}}
|
}}
|
||||||
onClick={() =>
|
onClick={() => editor.setStyleForSelectedShapes(DefaultSizeStyle, value)}
|
||||||
editor.setStyleForSelectedShapes(DefaultSizeStyle, value, { squashing: false })
|
|
||||||
}
|
|
||||||
>
|
>
|
||||||
<TldrawUiIcon icon={icon} />
|
<TldrawUiIcon icon={icon} />
|
||||||
</div>
|
</div>
|
||||||
|
|
|
@ -25,7 +25,7 @@ function CustomStylePanel(props: TLUiStylePanelProps) {
|
||||||
<TldrawUiButton
|
<TldrawUiButton
|
||||||
type="menu"
|
type="menu"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
editor.setStyleForSelectedShapes(DefaultColorStyle, 'red', { squashing: true })
|
editor.setStyleForSelectedShapes(DefaultColorStyle, 'red')
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<TldrawUiButtonLabel>Red</TldrawUiButtonLabel>
|
<TldrawUiButtonLabel>Red</TldrawUiButtonLabel>
|
||||||
|
@ -35,7 +35,7 @@ function CustomStylePanel(props: TLUiStylePanelProps) {
|
||||||
<TldrawUiButton
|
<TldrawUiButton
|
||||||
type="menu"
|
type="menu"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
editor.setStyleForSelectedShapes(DefaultColorStyle, 'green', { squashing: true })
|
editor.setStyleForSelectedShapes(DefaultColorStyle, 'green')
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<TldrawUiButtonLabel>Green</TldrawUiButtonLabel>
|
<TldrawUiButtonLabel>Green</TldrawUiButtonLabel>
|
||||||
|
|
|
@ -29,7 +29,9 @@ export default function UserPresenceExample() {
|
||||||
chatMessage: CURSOR_CHAT_MESSAGE,
|
chatMessage: CURSOR_CHAT_MESSAGE,
|
||||||
})
|
})
|
||||||
|
|
||||||
editor.store.put([peerPresence])
|
editor.store.mergeRemoteChanges(() => {
|
||||||
|
editor.store.put([peerPresence])
|
||||||
|
})
|
||||||
|
|
||||||
// [b]
|
// [b]
|
||||||
const raf = rRaf.current
|
const raf = rRaf.current
|
||||||
|
@ -67,23 +69,29 @@ export default function UserPresenceExample() {
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
editor.store.put([
|
editor.store.mergeRemoteChanges(() => {
|
||||||
{
|
editor.store.put([
|
||||||
...peerPresence,
|
{
|
||||||
cursor,
|
...peerPresence,
|
||||||
chatMessage,
|
cursor,
|
||||||
lastActivityTimestamp: now,
|
chatMessage,
|
||||||
},
|
lastActivityTimestamp: now,
|
||||||
])
|
},
|
||||||
|
])
|
||||||
|
})
|
||||||
|
|
||||||
rRaf.current = requestAnimationFrame(loop)
|
rRaf.current = requestAnimationFrame(loop)
|
||||||
}
|
}
|
||||||
|
|
||||||
rRaf.current = requestAnimationFrame(loop)
|
rRaf.current = requestAnimationFrame(loop)
|
||||||
} else {
|
} else {
|
||||||
editor.store.put([{ ...peerPresence, lastActivityTimestamp: Date.now() }])
|
editor.store.mergeRemoteChanges(() => {
|
||||||
rRaf.current = setInterval(() => {
|
|
||||||
editor.store.put([{ ...peerPresence, lastActivityTimestamp: Date.now() }])
|
editor.store.put([{ ...peerPresence, lastActivityTimestamp: Date.now() }])
|
||||||
|
})
|
||||||
|
rRaf.current = setInterval(() => {
|
||||||
|
editor.store.mergeRemoteChanges(() => {
|
||||||
|
editor.store.put([{ ...peerPresence, lastActivityTimestamp: Date.now() }])
|
||||||
|
})
|
||||||
}, 1000)
|
}, 1000)
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
|
|
|
@ -57,11 +57,11 @@ export const ChangeResponder = () => {
|
||||||
type: 'vscode:editor-loaded',
|
type: 'vscode:editor-loaded',
|
||||||
})
|
})
|
||||||
|
|
||||||
editor.on('change-history', handleChange)
|
const dispose = editor.store.listen(handleChange, { scope: 'document' })
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
handleChange()
|
handleChange()
|
||||||
editor.off('change-history', handleChange)
|
dispose()
|
||||||
}
|
}
|
||||||
}, [editor])
|
}, [editor])
|
||||||
|
|
||||||
|
|
|
@ -29,10 +29,12 @@ import { default as React_2 } from 'react';
|
||||||
import * as React_3 from 'react';
|
import * as React_3 from 'react';
|
||||||
import { ReactElement } from 'react';
|
import { ReactElement } from 'react';
|
||||||
import { ReactNode } from 'react';
|
import { ReactNode } from 'react';
|
||||||
|
import { RecordsDiff } from '@tldraw/store';
|
||||||
import { SerializedSchema } from '@tldraw/store';
|
import { SerializedSchema } from '@tldraw/store';
|
||||||
import { SerializedStore } from '@tldraw/store';
|
import { SerializedStore } from '@tldraw/store';
|
||||||
import { ShapeProps } from '@tldraw/tlschema';
|
import { ShapeProps } from '@tldraw/tlschema';
|
||||||
import { Signal } from '@tldraw/state';
|
import { Signal } from '@tldraw/state';
|
||||||
|
import { Store } from '@tldraw/store';
|
||||||
import { StoreSchema } from '@tldraw/store';
|
import { StoreSchema } from '@tldraw/store';
|
||||||
import { StoreSnapshot } from '@tldraw/store';
|
import { StoreSnapshot } from '@tldraw/store';
|
||||||
import { StyleProp } from '@tldraw/tlschema';
|
import { StyleProp } from '@tldraw/tlschema';
|
||||||
|
@ -375,7 +377,7 @@ export function counterClockwiseAngleDist(a0: number, a1: number): number;
|
||||||
export function createSessionStateSnapshotSignal(store: TLStore): Signal<null | TLSessionStateSnapshot>;
|
export function createSessionStateSnapshotSignal(store: TLStore): Signal<null | TLSessionStateSnapshot>;
|
||||||
|
|
||||||
// @public
|
// @public
|
||||||
export function createTLStore({ initialData, defaultName, ...rest }: TLStoreOptions): TLStore;
|
export function createTLStore({ initialData, defaultName, id, ...rest }: TLStoreOptions): TLStore;
|
||||||
|
|
||||||
// @public (undocumented)
|
// @public (undocumented)
|
||||||
export function createTLUser(opts?: {
|
export function createTLUser(opts?: {
|
||||||
|
@ -602,7 +604,7 @@ export class Editor extends EventEmitter<TLEventMap> {
|
||||||
}): this;
|
}): this;
|
||||||
bail(): this;
|
bail(): this;
|
||||||
bailToMark(id: string): this;
|
bailToMark(id: string): this;
|
||||||
batch(fn: () => void): this;
|
batch(fn: () => void, opts?: TLHistoryBatchOptions): this;
|
||||||
bringForward(shapes: TLShape[] | TLShapeId[]): this;
|
bringForward(shapes: TLShape[] | TLShapeId[]): this;
|
||||||
bringToFront(shapes: TLShape[] | TLShapeId[]): this;
|
bringToFront(shapes: TLShape[] | TLShapeId[]): this;
|
||||||
cancel(): this;
|
cancel(): this;
|
||||||
|
@ -810,7 +812,7 @@ export class Editor extends EventEmitter<TLEventMap> {
|
||||||
getZoomLevel(): number;
|
getZoomLevel(): number;
|
||||||
groupShapes(shapes: TLShape[] | TLShapeId[], groupId?: TLShapeId): this;
|
groupShapes(shapes: TLShape[] | TLShapeId[], groupId?: TLShapeId): this;
|
||||||
hasAncestor(shape: TLShape | TLShapeId | undefined, ancestorId: TLShapeId): boolean;
|
hasAncestor(shape: TLShape | TLShapeId | undefined, ancestorId: TLShapeId): boolean;
|
||||||
readonly history: HistoryManager<this>;
|
readonly history: HistoryManager<TLRecord>;
|
||||||
inputs: {
|
inputs: {
|
||||||
buttons: Set<number>;
|
buttons: Set<number>;
|
||||||
keys: Set<string>;
|
keys: Set<string>;
|
||||||
|
@ -832,6 +834,7 @@ export class Editor extends EventEmitter<TLEventMap> {
|
||||||
isPointing: boolean;
|
isPointing: boolean;
|
||||||
};
|
};
|
||||||
interrupt(): this;
|
interrupt(): this;
|
||||||
|
isAncestorSelected(shape: TLShape | TLShapeId): boolean;
|
||||||
isIn(path: string): boolean;
|
isIn(path: string): boolean;
|
||||||
isInAny(...paths: string[]): boolean;
|
isInAny(...paths: string[]): boolean;
|
||||||
isPointInShape(shape: TLShape | TLShapeId, point: VecLike, opts?: {
|
isPointInShape(shape: TLShape | TLShapeId, point: VecLike, opts?: {
|
||||||
|
@ -845,9 +848,9 @@ export class Editor extends EventEmitter<TLEventMap> {
|
||||||
isShapeOrAncestorLocked(shape?: TLShape): boolean;
|
isShapeOrAncestorLocked(shape?: TLShape): boolean;
|
||||||
// (undocumented)
|
// (undocumented)
|
||||||
isShapeOrAncestorLocked(id?: TLShapeId): boolean;
|
isShapeOrAncestorLocked(id?: TLShapeId): boolean;
|
||||||
mark(markId?: string, onUndo?: boolean, onRedo?: boolean): this;
|
mark(markId?: string): this;
|
||||||
moveShapesToPage(shapes: TLShape[] | TLShapeId[], pageId: TLPageId): 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;
|
packShapes(shapes: TLShape[] | TLShapeId[], gap: number): this;
|
||||||
pageToScreen(point: VecLike): {
|
pageToScreen(point: VecLike): {
|
||||||
x: number;
|
x: number;
|
||||||
|
@ -876,7 +879,7 @@ export class Editor extends EventEmitter<TLEventMap> {
|
||||||
registerExternalContentHandler<T extends TLExternalContent['type']>(type: T, handler: ((info: T extends TLExternalContent['type'] ? TLExternalContent & {
|
registerExternalContentHandler<T extends TLExternalContent['type']>(type: T, handler: ((info: T extends TLExternalContent['type'] ? TLExternalContent & {
|
||||||
type: T;
|
type: T;
|
||||||
} : TLExternalContent) => void) | null): this;
|
} : TLExternalContent) => void) | null): this;
|
||||||
renamePage(page: TLPage | TLPageId, name: string, historyOptions?: TLCommandHistoryOptions): this;
|
renamePage(page: TLPage | TLPageId, name: string): this;
|
||||||
renderingBoundsMargin: number;
|
renderingBoundsMargin: number;
|
||||||
reparentShapes(shapes: TLShape[] | TLShapeId[], parentId: TLParentId, insertIndex?: IndexKey): this;
|
reparentShapes(shapes: TLShape[] | TLShapeId[], parentId: TLParentId, insertIndex?: IndexKey): this;
|
||||||
resetZoom(point?: Vec, animation?: TLAnimationOptions): this;
|
resetZoom(point?: Vec, animation?: TLAnimationOptions): this;
|
||||||
|
@ -896,7 +899,7 @@ export class Editor extends EventEmitter<TLEventMap> {
|
||||||
sendToBack(shapes: TLShape[] | TLShapeId[]): this;
|
sendToBack(shapes: TLShape[] | TLShapeId[]): this;
|
||||||
setCamera(point: VecLike, animation?: TLAnimationOptions): this;
|
setCamera(point: VecLike, animation?: TLAnimationOptions): this;
|
||||||
setCroppingShape(shape: null | TLShape | TLShapeId): this;
|
setCroppingShape(shape: null | TLShape | TLShapeId): this;
|
||||||
setCurrentPage(page: TLPage | TLPageId, historyOptions?: TLCommandHistoryOptions): this;
|
setCurrentPage(page: TLPage | TLPageId): this;
|
||||||
setCurrentTool(id: string, info?: {}): this;
|
setCurrentTool(id: string, info?: {}): this;
|
||||||
setCursor: (cursor: Partial<TLCursor>) => this;
|
setCursor: (cursor: Partial<TLCursor>) => this;
|
||||||
setEditingShape(shape: null | TLShape | TLShapeId): this;
|
setEditingShape(shape: null | TLShape | TLShapeId): this;
|
||||||
|
@ -904,11 +907,11 @@ export class Editor extends EventEmitter<TLEventMap> {
|
||||||
setFocusedGroup(shape: null | TLGroupShape | TLShapeId): this;
|
setFocusedGroup(shape: null | TLGroupShape | TLShapeId): this;
|
||||||
setHintingShapes(shapes: TLShape[] | TLShapeId[]): this;
|
setHintingShapes(shapes: TLShape[] | TLShapeId[]): this;
|
||||||
setHoveredShape(shape: null | TLShape | TLShapeId): this;
|
setHoveredShape(shape: null | TLShape | TLShapeId): this;
|
||||||
setOpacityForNextShapes(opacity: number, historyOptions?: TLCommandHistoryOptions): this;
|
setOpacityForNextShapes(opacity: number, historyOptions?: TLHistoryBatchOptions): this;
|
||||||
setOpacityForSelectedShapes(opacity: number, historyOptions?: TLCommandHistoryOptions): this;
|
setOpacityForSelectedShapes(opacity: number): this;
|
||||||
setSelectedShapes(shapes: TLShape[] | TLShapeId[], historyOptions?: TLCommandHistoryOptions): this;
|
setSelectedShapes(shapes: TLShape[] | TLShapeId[]): this;
|
||||||
setStyleForNextShapes<T>(style: StyleProp<T>, value: T, historyOptions?: TLCommandHistoryOptions): this;
|
setStyleForNextShapes<T>(style: StyleProp<T>, value: T, historyOptions?: TLHistoryBatchOptions): this;
|
||||||
setStyleForSelectedShapes<S extends StyleProp<any>>(style: S, value: StylePropValue<S>, historyOptions?: TLCommandHistoryOptions): this;
|
setStyleForSelectedShapes<S extends StyleProp<any>>(style: S, value: StylePropValue<S>): this;
|
||||||
shapeUtils: {
|
shapeUtils: {
|
||||||
readonly [K in string]?: ShapeUtil<TLUnknownShape>;
|
readonly [K in string]?: ShapeUtil<TLUnknownShape>;
|
||||||
};
|
};
|
||||||
|
@ -937,14 +940,16 @@ export class Editor extends EventEmitter<TLEventMap> {
|
||||||
// (undocumented)
|
// (undocumented)
|
||||||
ungroupShapes(ids: TLShape[]): this;
|
ungroupShapes(ids: TLShape[]): this;
|
||||||
updateAssets(assets: TLAssetPartial[]): this;
|
updateAssets(assets: TLAssetPartial[]): this;
|
||||||
updateCurrentPageState(partial: Partial<Omit<TLInstancePageState, 'editingShapeId' | 'focusedGroupId' | 'pageId' | 'selectedShapeIds'>>, historyOptions?: TLCommandHistoryOptions): this;
|
updateCurrentPageState(partial: Partial<Omit<TLInstancePageState, 'editingShapeId' | 'focusedGroupId' | 'pageId' | 'selectedShapeIds'>>, historyOptions?: TLHistoryBatchOptions): this;
|
||||||
|
// (undocumented)
|
||||||
|
_updateCurrentPageState: (partial: Partial<Omit<TLInstancePageState, 'selectedShapeIds'>>, historyOptions?: TLHistoryBatchOptions) => void;
|
||||||
updateDocumentSettings(settings: Partial<TLDocument>): this;
|
updateDocumentSettings(settings: Partial<TLDocument>): this;
|
||||||
updateInstanceState(partial: Partial<Omit<TLInstance, 'currentPageId'>>, historyOptions?: TLCommandHistoryOptions): this;
|
updateInstanceState(partial: Partial<Omit<TLInstance, 'currentPageId'>>, historyOptions?: TLHistoryBatchOptions): this;
|
||||||
updatePage(partial: RequiredKeys<TLPage, 'id'>, historyOptions?: TLCommandHistoryOptions): this;
|
updatePage(partial: RequiredKeys<TLPage, 'id'>): this;
|
||||||
// @internal
|
// @internal
|
||||||
updateRenderingBounds(): this;
|
updateRenderingBounds(): this;
|
||||||
updateShape<T extends TLUnknownShape>(partial: null | TLShapePartial<T> | undefined, historyOptions?: TLCommandHistoryOptions): this;
|
updateShape<T extends TLUnknownShape>(partial: null | TLShapePartial<T> | undefined): this;
|
||||||
updateShapes<T extends TLUnknownShape>(partials: (null | TLShapePartial<T> | undefined)[], historyOptions?: TLCommandHistoryOptions): this;
|
updateShapes<T extends TLUnknownShape>(partials: (null | TLShapePartial<T> | undefined)[]): this;
|
||||||
updateViewportScreenBounds(screenBounds: Box, center?: boolean): this;
|
updateViewportScreenBounds(screenBounds: Box, center?: boolean): this;
|
||||||
readonly user: UserPreferencesManager;
|
readonly user: UserPreferencesManager;
|
||||||
visitDescendants(parent: TLPage | TLParentId | TLShape, visitor: (id: TLShapeId) => false | void): this;
|
visitDescendants(parent: TLPage | TLParentId | TLShape, visitor: (id: TLShapeId) => false | void): this;
|
||||||
|
@ -1208,6 +1213,55 @@ export function hardResetEditor(): void;
|
||||||
// @internal (undocumented)
|
// @internal (undocumented)
|
||||||
export const HASH_PATTERN_ZOOM_NAMES: Record<string, string>;
|
export const HASH_PATTERN_ZOOM_NAMES: Record<string, string>;
|
||||||
|
|
||||||
|
// @public (undocumented)
|
||||||
|
export class HistoryManager<R extends UnknownRecord> {
|
||||||
|
constructor(opts: {
|
||||||
|
annotateError?: (error: unknown) => void;
|
||||||
|
store: Store<R>;
|
||||||
|
});
|
||||||
|
// (undocumented)
|
||||||
|
bail: () => this;
|
||||||
|
// (undocumented)
|
||||||
|
bailToMark: (id: string) => this;
|
||||||
|
// (undocumented)
|
||||||
|
batch: (fn: () => void, opts?: TLHistoryBatchOptions) => this;
|
||||||
|
// (undocumented)
|
||||||
|
clear(): void;
|
||||||
|
// @internal (undocumented)
|
||||||
|
debug(): {
|
||||||
|
pendingDiff: {
|
||||||
|
diff: RecordsDiff<R>;
|
||||||
|
isEmpty: boolean;
|
||||||
|
};
|
||||||
|
redos: (NonNullable<TLHistoryEntry<R>> | undefined)[];
|
||||||
|
state: HistoryRecorderState;
|
||||||
|
undos: (NonNullable<TLHistoryEntry<R>> | 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<TLHistoryEntry<R>>;
|
||||||
|
undos: Stack<TLHistoryEntry<R>>;
|
||||||
|
}, unknown>;
|
||||||
|
// (undocumented)
|
||||||
|
undo: () => this;
|
||||||
|
}
|
||||||
|
|
||||||
// @public (undocumented)
|
// @public (undocumented)
|
||||||
export const HIT_TEST_MARGIN = 8;
|
export const HIT_TEST_MARGIN = 8;
|
||||||
|
|
||||||
|
@ -1723,6 +1777,17 @@ export class SideEffectManager<CTX extends {
|
||||||
constructor(editor: CTX);
|
constructor(editor: CTX);
|
||||||
// (undocumented)
|
// (undocumented)
|
||||||
editor: CTX;
|
editor: CTX;
|
||||||
|
// @internal
|
||||||
|
register(handlersByType: {
|
||||||
|
[R in TLRecord as R['typeName']]?: {
|
||||||
|
afterChange?: TLAfterChangeHandler<R>;
|
||||||
|
afterCreate?: TLAfterCreateHandler<R>;
|
||||||
|
afterDelete?: TLAfterDeleteHandler<R>;
|
||||||
|
beforeChange?: TLBeforeChangeHandler<R>;
|
||||||
|
beforeCreate?: TLBeforeCreateHandler<R>;
|
||||||
|
beforeDelete?: TLBeforeDeleteHandler<R>;
|
||||||
|
};
|
||||||
|
}): () => void;
|
||||||
registerAfterChangeHandler<T extends TLRecord['typeName']>(typeName: T, handler: TLAfterChangeHandler<TLRecord & {
|
registerAfterChangeHandler<T extends TLRecord['typeName']>(typeName: T, handler: TLAfterChangeHandler<TLRecord & {
|
||||||
typeName: T;
|
typeName: T;
|
||||||
}>): () => void;
|
}>): () => void;
|
||||||
|
@ -2037,29 +2102,6 @@ export type TLCollaboratorHintProps = {
|
||||||
zoom: number;
|
zoom: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
// @public (undocumented)
|
|
||||||
export type TLCommand<Name extends string = any, Data = any> = {
|
|
||||||
preservesRedoStack?: boolean;
|
|
||||||
data: Data;
|
|
||||||
name: Name;
|
|
||||||
type: 'command';
|
|
||||||
};
|
|
||||||
|
|
||||||
// @public (undocumented)
|
|
||||||
export type TLCommandHandler<Data> = {
|
|
||||||
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)
|
// @public (undocumented)
|
||||||
export type TLCompleteEvent = (info: TLCompleteEventInfo) => void;
|
export type TLCompleteEvent = (info: TLCompleteEventInfo) => void;
|
||||||
|
|
||||||
|
@ -2193,17 +2235,6 @@ export type TLEventInfo = TLCancelEventInfo | TLClickEventInfo | TLCompleteEvent
|
||||||
|
|
||||||
// @public (undocumented)
|
// @public (undocumented)
|
||||||
export interface TLEventMap {
|
export interface TLEventMap {
|
||||||
// (undocumented)
|
|
||||||
'change-history': [{
|
|
||||||
markId?: string;
|
|
||||||
reason: 'bail';
|
|
||||||
} | {
|
|
||||||
reason: 'push' | 'redo' | 'undo';
|
|
||||||
}];
|
|
||||||
// (undocumented)
|
|
||||||
'mark-history': [{
|
|
||||||
id: string;
|
|
||||||
}];
|
|
||||||
// (undocumented)
|
// (undocumented)
|
||||||
'max-shapes': [{
|
'max-shapes': [{
|
||||||
count: number;
|
count: number;
|
||||||
|
@ -2316,17 +2347,6 @@ export type TLHandlesProps = {
|
||||||
children: ReactNode;
|
children: ReactNode;
|
||||||
};
|
};
|
||||||
|
|
||||||
// @public (undocumented)
|
|
||||||
export type TLHistoryEntry = TLCommand | TLHistoryMark;
|
|
||||||
|
|
||||||
// @public (undocumented)
|
|
||||||
export type TLHistoryMark = {
|
|
||||||
id: string;
|
|
||||||
onRedo: boolean;
|
|
||||||
onUndo: boolean;
|
|
||||||
type: 'STOP';
|
|
||||||
};
|
|
||||||
|
|
||||||
// @public (undocumented)
|
// @public (undocumented)
|
||||||
export type TLInterruptEvent = (info: TLInterruptEventInfo) => void;
|
export type TLInterruptEvent = (info: TLInterruptEventInfo) => void;
|
||||||
|
|
||||||
|
@ -2610,6 +2630,7 @@ export type TLStoreEventInfo = HistoryEntry<TLRecord>;
|
||||||
// @public (undocumented)
|
// @public (undocumented)
|
||||||
export type TLStoreOptions = {
|
export type TLStoreOptions = {
|
||||||
defaultName?: string;
|
defaultName?: string;
|
||||||
|
id?: string;
|
||||||
initialData?: SerializedStore<TLRecord>;
|
initialData?: SerializedStore<TLRecord>;
|
||||||
} & ({
|
} & ({
|
||||||
migrations?: readonly MigrationSequence[];
|
migrations?: readonly MigrationSequence[];
|
||||||
|
|
|
@ -17,7 +17,6 @@ export {
|
||||||
type Atom,
|
type Atom,
|
||||||
type Signal,
|
type Signal,
|
||||||
} from '@tldraw/state'
|
} from '@tldraw/state'
|
||||||
export type { TLCommandHistoryOptions } from './lib/editor/types/history-types'
|
|
||||||
// eslint-disable-next-line local/no-export-star
|
// eslint-disable-next-line local/no-export-star
|
||||||
export * from '@tldraw/store'
|
export * from '@tldraw/store'
|
||||||
// eslint-disable-next-line local/no-export-star
|
// eslint-disable-next-line local/no-export-star
|
||||||
|
@ -131,6 +130,7 @@ export {
|
||||||
type TLEditorOptions,
|
type TLEditorOptions,
|
||||||
type TLResizeShapeOptions,
|
type TLResizeShapeOptions,
|
||||||
} from './lib/editor/Editor'
|
} from './lib/editor/Editor'
|
||||||
|
export { HistoryManager } from './lib/editor/managers/HistoryManager'
|
||||||
export type {
|
export type {
|
||||||
SideEffectManager,
|
SideEffectManager,
|
||||||
TLAfterChangeHandler,
|
TLAfterChangeHandler,
|
||||||
|
@ -235,12 +235,6 @@ export {
|
||||||
type TLExternalContent,
|
type TLExternalContent,
|
||||||
type TLExternalContentSource,
|
type TLExternalContentSource,
|
||||||
} from './lib/editor/types/external-content'
|
} 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 RequiredKeys, type TLSvgOptions } from './lib/editor/types/misc-types'
|
||||||
export { type TLResizeHandle, type TLSelectionHandle } from './lib/editor/types/selection-types'
|
export { type TLResizeHandle, type TLSelectionHandle } from './lib/editor/types/selection-types'
|
||||||
export { ContainerProvider, useContainer } from './lib/hooks/useContainer'
|
export { ContainerProvider, useContainer } from './lib/hooks/useContainer'
|
||||||
|
|
|
@ -380,8 +380,11 @@ function useOnMount(onMount?: TLOnMountHandler) {
|
||||||
const editor = useEditor()
|
const editor = useEditor()
|
||||||
|
|
||||||
const onMountEvent = useEvent((editor: Editor) => {
|
const onMountEvent = useEvent((editor: Editor) => {
|
||||||
const teardown = onMount?.(editor)
|
let teardown: (() => void) | void = undefined
|
||||||
editor.emit('mount')
|
editor.history.ignore(() => {
|
||||||
|
teardown = onMount?.(editor)
|
||||||
|
editor.emit('mount')
|
||||||
|
})
|
||||||
window.tldrawReady = true
|
window.tldrawReady = true
|
||||||
return teardown
|
return teardown
|
||||||
})
|
})
|
||||||
|
|
|
@ -14,6 +14,7 @@ import { TLAnyShapeUtilConstructor, checkShapesAndAddCore } from './defaultShape
|
||||||
export type TLStoreOptions = {
|
export type TLStoreOptions = {
|
||||||
initialData?: SerializedStore<TLRecord>
|
initialData?: SerializedStore<TLRecord>
|
||||||
defaultName?: string
|
defaultName?: string
|
||||||
|
id?: string
|
||||||
} & (
|
} & (
|
||||||
| { shapeUtils?: readonly TLAnyShapeUtilConstructor[]; migrations?: readonly MigrationSequence[] }
|
| { shapeUtils?: readonly TLAnyShapeUtilConstructor[]; migrations?: readonly MigrationSequence[] }
|
||||||
| { schema?: StoreSchema<TLRecord, TLStoreProps> }
|
| { schema?: StoreSchema<TLRecord, TLStoreProps> }
|
||||||
|
@ -28,7 +29,12 @@ export type TLStoreEventInfo = HistoryEntry<TLRecord>
|
||||||
* @param opts - Options for creating the store.
|
* @param opts - Options for creating the store.
|
||||||
*
|
*
|
||||||
* @public */
|
* @public */
|
||||||
export function createTLStore({ initialData, defaultName = '', ...rest }: TLStoreOptions): TLStore {
|
export function createTLStore({
|
||||||
|
initialData,
|
||||||
|
defaultName = '',
|
||||||
|
id,
|
||||||
|
...rest
|
||||||
|
}: TLStoreOptions): TLStore {
|
||||||
const schema =
|
const schema =
|
||||||
'schema' in rest && rest.schema
|
'schema' in rest && rest.schema
|
||||||
? // we have a schema
|
? // we have a schema
|
||||||
|
@ -42,6 +48,7 @@ export function createTLStore({ initialData, defaultName = '', ...rest }: TLStor
|
||||||
})
|
})
|
||||||
|
|
||||||
return new Store({
|
return new Store({
|
||||||
|
id,
|
||||||
schema,
|
schema,
|
||||||
initialData,
|
initialData,
|
||||||
props: {
|
props: {
|
||||||
|
|
File diff suppressed because it is too large
Load diff
|
@ -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 { HistoryManager } from './HistoryManager'
|
||||||
import { stack } from './Stack'
|
import { stack } from './Stack'
|
||||||
|
|
||||||
|
interface TestRecord extends BaseRecord<'test', TestRecordId> {
|
||||||
|
value: number | string
|
||||||
|
}
|
||||||
|
type TestRecordId = RecordId<TestRecord>
|
||||||
|
const testSchema = StoreSchema.create<TestRecord, null>({
|
||||||
|
test: createRecordType<TestRecord>('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() {
|
function createCounterHistoryManager() {
|
||||||
const manager = new HistoryManager({ emit: () => void null }, () => {
|
const store = new Store({ schema: testSchema, props: null })
|
||||||
return
|
store.put([
|
||||||
})
|
testSchema.types.test.create({ id: ids.count, value: 0 }),
|
||||||
const state = {
|
testSchema.types.test.create({ id: ids.name, value: 'David' }),
|
||||||
count: 0,
|
testSchema.types.test.create({ id: ids.age, value: 35 }),
|
||||||
name: 'David',
|
])
|
||||||
age: 35,
|
|
||||||
|
const manager = new HistoryManager<TestRecord>({ 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(
|
const increment = (n = 1) => {
|
||||||
'decrement',
|
_setCount(getCount() + n)
|
||||||
(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 setName = manager.createCommand(
|
const decrement = (n = 1) => {
|
||||||
'setName',
|
_setCount(getCount() - n)
|
||||||
(name = 'David') => ({
|
}
|
||||||
data: { name, prev: state.name },
|
|
||||||
ephemeral: true,
|
|
||||||
}),
|
|
||||||
{
|
|
||||||
do: ({ name }) => {
|
|
||||||
state.name = name
|
|
||||||
},
|
|
||||||
undo: ({ prev }) => {
|
|
||||||
state.name = prev
|
|
||||||
},
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
const setAge = manager.createCommand(
|
const setName = (name = 'David') => {
|
||||||
'setAge',
|
manager.ignore(() => _setName(name))
|
||||||
(age = 35) => ({
|
}
|
||||||
data: { age, prev: state.age },
|
|
||||||
preservesRedoStack: true,
|
|
||||||
}),
|
|
||||||
{
|
|
||||||
do: ({ age }) => {
|
|
||||||
state.age = age
|
|
||||||
},
|
|
||||||
undo: ({ prev }) => {
|
|
||||||
state.age = prev
|
|
||||||
},
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
const incrementTwice = manager.createCommand('incrementTwice', () => ({ data: {} }), {
|
const setAge = (age = 35) => {
|
||||||
do: () => {
|
manager.batch(() => _setAge(age), { history: 'record-preserveRedoStack' })
|
||||||
|
}
|
||||||
|
|
||||||
|
const incrementTwice = () => {
|
||||||
|
manager.batch(() => {
|
||||||
increment()
|
increment()
|
||||||
increment()
|
increment()
|
||||||
},
|
})
|
||||||
undo: () => {
|
}
|
||||||
decrement()
|
|
||||||
decrement()
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
increment,
|
increment,
|
||||||
|
@ -95,9 +78,9 @@ function createCounterHistoryManager() {
|
||||||
setName,
|
setName,
|
||||||
setAge,
|
setAge,
|
||||||
history: manager,
|
history: manager,
|
||||||
getCount: () => state.count,
|
getCount,
|
||||||
getName: () => state.name,
|
getName,
|
||||||
getAge: () => state.age,
|
getAge,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -116,9 +99,9 @@ describe(HistoryManager, () => {
|
||||||
editor.decrement()
|
editor.decrement()
|
||||||
expect(editor.getCount()).toBe(3)
|
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))
|
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()
|
editor.history.undo()
|
||||||
|
|
||||||
|
@ -200,17 +183,16 @@ describe(HistoryManager, () => {
|
||||||
editor.history.mark('stop at 1')
|
editor.history.mark('stop at 1')
|
||||||
expect(editor.getCount()).toBe(1)
|
expect(editor.getCount()).toBe(1)
|
||||||
|
|
||||||
editor.increment(1, true)
|
editor.increment(1)
|
||||||
editor.increment(1, true)
|
editor.increment(1)
|
||||||
editor.increment(1, true)
|
editor.increment(1)
|
||||||
editor.increment(1, true)
|
editor.increment(1)
|
||||||
|
|
||||||
expect(editor.getCount()).toBe(5)
|
expect(editor.getCount()).toBe(5)
|
||||||
|
|
||||||
expect(editor.history.getNumUndos()).toBe(3)
|
expect(editor.history.getNumUndos()).toBe(3)
|
||||||
})
|
})
|
||||||
|
it('allows ignore commands that do not affect the stack', () => {
|
||||||
it('allows ephemeral commands that do not affect the stack', () => {
|
|
||||||
editor.increment()
|
editor.increment()
|
||||||
editor.history.mark('stop at 1')
|
editor.history.mark('stop at 1')
|
||||||
editor.increment()
|
editor.increment()
|
||||||
|
@ -263,7 +245,7 @@ describe(HistoryManager, () => {
|
||||||
editor.history.mark('2')
|
editor.history.mark('2')
|
||||||
editor.incrementTwice()
|
editor.incrementTwice()
|
||||||
editor.incrementTwice()
|
editor.incrementTwice()
|
||||||
expect(editor.history.getNumUndos()).toBe(5)
|
expect(editor.history.getNumUndos()).toBe(4)
|
||||||
expect(editor.getCount()).toBe(6)
|
expect(editor.getCount()).toBe(6)
|
||||||
editor.history.bail()
|
editor.history.bail()
|
||||||
expect(editor.getCount()).toBe(2)
|
expect(editor.getCount()).toBe(2)
|
||||||
|
@ -289,58 +271,35 @@ describe(HistoryManager, () => {
|
||||||
})
|
})
|
||||||
|
|
||||||
describe('history options', () => {
|
describe('history options', () => {
|
||||||
let manager: HistoryManager<any>
|
let manager: HistoryManager<TestRecord>
|
||||||
let state: { a: number; b: number }
|
|
||||||
|
|
||||||
let setA: (n: number, historyOptions?: TLCommandHistoryOptions) => any
|
let getState: () => { a: number; b: number }
|
||||||
let setB: (n: number, historyOptions?: TLCommandHistoryOptions) => any
|
let setA: (n: number, historyOptions?: TLHistoryBatchOptions) => any
|
||||||
|
let setB: (n: number, historyOptions?: TLHistoryBatchOptions) => any
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
manager = new HistoryManager({ emit: () => void null }, () => {
|
const store = new Store({ schema: testSchema, props: null })
|
||||||
return
|
store.put([
|
||||||
})
|
testSchema.types.test.create({ id: ids.a, value: 0 }),
|
||||||
|
testSchema.types.test.create({ id: ids.b, value: 0 }),
|
||||||
|
])
|
||||||
|
|
||||||
state = {
|
manager = new HistoryManager<TestRecord>({ store })
|
||||||
a: 0,
|
|
||||||
b: 0,
|
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?: TLHistoryBatchOptions) => {
|
||||||
'setA',
|
manager.batch(() => store.update(ids.a, (s) => ({ ...s, value: n })), historyOptions)
|
||||||
(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 }),
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
setB = manager.createCommand(
|
setB = (n: number, historyOptions?: TLHistoryBatchOptions) => {
|
||||||
'setB',
|
manager.batch(() => store.update(ids.b, (s) => ({ ...s, value: n })), historyOptions)
|
||||||
(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 }),
|
|
||||||
}
|
|
||||||
)
|
|
||||||
})
|
})
|
||||||
|
|
||||||
it('sets, undoes, redoes', () => {
|
it('undos, redoes, separate marks', () => {
|
||||||
manager.mark()
|
manager.mark()
|
||||||
setA(1)
|
setA(1)
|
||||||
manager.mark()
|
manager.mark()
|
||||||
|
@ -348,18 +307,18 @@ describe('history options', () => {
|
||||||
manager.mark()
|
manager.mark()
|
||||||
setB(2)
|
setB(2)
|
||||||
|
|
||||||
expect(state).toMatchObject({ a: 1, b: 2 })
|
expect(getState()).toMatchObject({ a: 1, b: 2 })
|
||||||
|
|
||||||
manager.undo()
|
manager.undo()
|
||||||
|
|
||||||
expect(state).toMatchObject({ a: 1, b: 1 })
|
expect(getState()).toMatchObject({ a: 1, b: 1 })
|
||||||
|
|
||||||
manager.redo()
|
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()
|
manager.mark()
|
||||||
setA(1)
|
setA(1)
|
||||||
manager.mark()
|
manager.mark()
|
||||||
|
@ -369,71 +328,107 @@ describe('history options', () => {
|
||||||
setB(3)
|
setB(3)
|
||||||
setB(4)
|
setB(4)
|
||||||
|
|
||||||
expect(state).toMatchObject({ a: 1, b: 4 })
|
expect(getState()).toMatchObject({ a: 1, b: 4 })
|
||||||
|
|
||||||
manager.undo()
|
manager.undo()
|
||||||
|
|
||||||
expect(state).toMatchObject({ a: 1, b: 1 })
|
expect(getState()).toMatchObject({ a: 1, b: 1 })
|
||||||
|
|
||||||
manager.redo()
|
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()
|
manager.mark()
|
||||||
setA(1)
|
setA(1)
|
||||||
manager.mark()
|
manager.mark()
|
||||||
setB(1) // B 0->1
|
setB(1) // B 0->1
|
||||||
manager.mark()
|
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
|
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
|
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()
|
manager.mark()
|
||||||
setA(1)
|
setA(1)
|
||||||
manager.mark()
|
manager.mark()
|
||||||
setB(1)
|
setB(1)
|
||||||
setB(2, { squashing: true }) // squashes with the previous command
|
setB(2) // squashes with the previous command
|
||||||
setB(3, { squashing: true }) // 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()
|
manager.undo()
|
||||||
|
|
||||||
expect(state).toMatchObject({ a: 1, b: 0 })
|
expect(getState()).toMatchObject({ a: 1, b: 0 })
|
||||||
|
|
||||||
manager.redo()
|
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()
|
manager.mark()
|
||||||
setA(1)
|
setA(1)
|
||||||
manager.mark()
|
manager.mark()
|
||||||
setB(1)
|
setB(1)
|
||||||
setB(2, { squashing: true }) // squashes with the previous command
|
setB(2) // squashes with the previous command
|
||||||
setB(3, { squashing: true, ephemeral: true }) // 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()
|
manager.undo()
|
||||||
|
|
||||||
expect(state).toMatchObject({ a: 1, b: 0 })
|
expect(getState()).toMatchObject({ a: 1, b: 0 })
|
||||||
|
|
||||||
manager.redo()
|
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 })
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
|
@ -1,156 +1,124 @@
|
||||||
import { atom, transact } from '@tldraw/state'
|
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 { uniqueId } from '../../utils/uniqueId'
|
||||||
import { TLCommandHandler, TLCommandHistoryOptions, TLHistoryEntry } from '../types/history-types'
|
import { TLHistoryBatchOptions, TLHistoryEntry } from '../types/history-types'
|
||||||
import { Stack, stack } from './Stack'
|
import { stack } from './Stack'
|
||||||
|
|
||||||
type CommandFn<Data> = (...args: any[]) =>
|
enum HistoryRecorderState {
|
||||||
| ({
|
Recording = 'recording',
|
||||||
data: Data
|
RecordingPreserveRedoStack = 'recordingPreserveRedoStack',
|
||||||
} & TLCommandHistoryOptions)
|
Paused = 'paused',
|
||||||
| null
|
}
|
||||||
| undefined
|
|
||||||
| void
|
|
||||||
|
|
||||||
type ExtractData<Fn> = Fn extends CommandFn<infer Data> ? Data : never
|
/** @public */
|
||||||
type ExtractArgs<Fn> = Parameters<Extract<Fn, (...args: any[]) => any>>
|
export class HistoryManager<R extends UnknownRecord> {
|
||||||
|
private readonly store: Store<R>
|
||||||
|
|
||||||
export class HistoryManager<
|
readonly dispose: () => void
|
||||||
CTX extends {
|
|
||||||
emit: (name: 'change-history' | 'mark-history', ...args: any) => void
|
|
||||||
},
|
|
||||||
> {
|
|
||||||
_undos = atom<Stack<TLHistoryEntry>>('HistoryManager.undos', stack()) // Updated by each action that includes and undo
|
|
||||||
_redos = atom<Stack<TLHistoryEntry>>('HistoryManager.redos', stack()) // Updated when a user undoes
|
|
||||||
_batchDepth = 0 // A flag for whether the user is in a batch operation
|
|
||||||
|
|
||||||
constructor(
|
private state: HistoryRecorderState = HistoryRecorderState.Recording
|
||||||
private readonly ctx: CTX,
|
private readonly pendingDiff = new PendingDiff<R>()
|
||||||
private readonly annotateError: (error: unknown) => void
|
/** @internal */
|
||||||
) {}
|
stacks = atom(
|
||||||
|
'HistoryManager.stacks',
|
||||||
|
{
|
||||||
|
undos: stack<TLHistoryEntry<R>>(),
|
||||||
|
redos: stack<TLHistoryEntry<R>>(),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
isEqual: (a, b) => a.undos === b.undos && a.redos === b.redos,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
private readonly annotateError: (error: unknown) => void
|
||||||
|
|
||||||
|
constructor(opts: { store: Store<R>; 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
|
onBatchComplete: () => void = () => void null
|
||||||
|
|
||||||
private _commands: Record<string, TLCommandHandler<any>> = {}
|
|
||||||
|
|
||||||
getNumUndos() {
|
getNumUndos() {
|
||||||
return this._undos.get().length
|
return this.stacks.get().undos.length + (this.pendingDiff.isEmpty() ? 0 : 1)
|
||||||
}
|
}
|
||||||
getNumRedos() {
|
getNumRedos() {
|
||||||
return this._redos.get().length
|
return this.stacks.get().redos.length
|
||||||
}
|
|
||||||
createCommand = <Name extends string, Constructor extends CommandFn<any>>(
|
|
||||||
name: Name,
|
|
||||||
constructor: Constructor,
|
|
||||||
handle: TLCommandHandler<ExtractData<Constructor>>
|
|
||||||
) => {
|
|
||||||
if (this._commands[name]) {
|
|
||||||
throw new Error(`Duplicate command: ${name}`)
|
|
||||||
}
|
|
||||||
this._commands[name] = handle
|
|
||||||
|
|
||||||
const exec = (...args: ExtractArgs<Constructor>) => {
|
|
||||||
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
|
|
||||||
}
|
}
|
||||||
|
|
||||||
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 {
|
try {
|
||||||
this._batchDepth++
|
if (this._isInBatch) {
|
||||||
if (this._batchDepth === 1) {
|
|
||||||
transact(() => {
|
|
||||||
const mostRecentAction = this._undos.get().head
|
|
||||||
fn()
|
|
||||||
if (mostRecentAction !== this._undos.get().head) {
|
|
||||||
this.onBatchComplete()
|
|
||||||
}
|
|
||||||
})
|
|
||||||
} else {
|
|
||||||
fn()
|
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 = (
|
ignore(fn: () => void) {
|
||||||
fn: (
|
return this.batch(fn, { history: 'ignore' })
|
||||||
undos: Stack<TLHistoryEntry>,
|
|
||||||
redos: Stack<TLHistoryEntry>
|
|
||||||
) => { undos: Stack<TLHistoryEntry>; redos: Stack<TLHistoryEntry> }
|
|
||||||
) => {
|
|
||||||
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)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// History
|
// History
|
||||||
|
@ -161,62 +129,66 @@ export class HistoryManager<
|
||||||
pushToRedoStack: boolean
|
pushToRedoStack: boolean
|
||||||
toMark?: string
|
toMark?: string
|
||||||
}) => {
|
}) => {
|
||||||
this.ignoringUpdates((undos, redos) => {
|
const previousState = this.state
|
||||||
if (undos.length === 0) {
|
this.state = HistoryRecorderState.Paused
|
||||||
return { undos, redos }
|
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') {
|
let didFindMark = false
|
||||||
const mark = undos.head
|
if (isPendingDiffEmpty) {
|
||||||
undos = undos.tail
|
// if nothing has happened since the last mark, pop any intermediate marks off the stack
|
||||||
if (pushToRedoStack) {
|
while (undos.head?.type === 'stop') {
|
||||||
redos = redos.push(mark)
|
const mark = undos.head
|
||||||
}
|
undos = undos.tail
|
||||||
if (mark.id === toMark) {
|
if (pushToRedoStack) {
|
||||||
this.ctx.emit(
|
redos = redos.push(mark)
|
||||||
'change-history',
|
}
|
||||||
pushToRedoStack ? { reason: 'undo' } : { reason: 'bail', markId: toMark }
|
if (mark.id === toMark) {
|
||||||
)
|
didFindMark = true
|
||||||
return { undos, redos }
|
break
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
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 }
|
|
||||||
}
|
}
|
||||||
} else {
|
|
||||||
const handler = this._commands[command.name]
|
|
||||||
handler.undo(command.data)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
this.ctx.emit(
|
if (!didFindMark) {
|
||||||
'change-history',
|
loop: while (undos.head) {
|
||||||
pushToRedoStack ? { reason: 'undo' } : { reason: 'bail', markId: toMark }
|
const undo = undos.head
|
||||||
)
|
undos = undos.tail
|
||||||
return { undos, redos }
|
|
||||||
})
|
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
|
return this
|
||||||
}
|
}
|
||||||
|
@ -228,43 +200,43 @@ export class HistoryManager<
|
||||||
}
|
}
|
||||||
|
|
||||||
redo = () => {
|
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) {
|
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)
|
undos = undos.push(redos.head)
|
||||||
redos = redos.tail
|
redos = redos.tail
|
||||||
}
|
}
|
||||||
|
|
||||||
if (redos.length === 0) {
|
// accumulate diffs to be redone so they can be applied atomically
|
||||||
this.ctx.emit('change-history', { reason: 'redo' })
|
const diffToRedo = createEmptyRecordsDiff<R>()
|
||||||
return { undos, redos }
|
|
||||||
}
|
|
||||||
|
|
||||||
while (redos.head) {
|
while (redos.head) {
|
||||||
const command = redos.head
|
const redo = redos.head
|
||||||
undos = undos.push(redos.head)
|
undos = undos.push(redo)
|
||||||
redos = redos.tail
|
redos = redos.tail
|
||||||
|
|
||||||
if (command.type === 'STOP') {
|
if (redo.type === 'diff') {
|
||||||
if (command.onRedo) {
|
squashRecordDiffsMutable(diffToRedo, [redo.diff])
|
||||||
break
|
|
||||||
}
|
|
||||||
} else {
|
} else {
|
||||||
const handler = this._commands[command.name]
|
break
|
||||||
if (handler.redo) {
|
|
||||||
handler.redo(command.data)
|
|
||||||
} else {
|
|
||||||
handler.do(command.data)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
this.ctx.emit('change-history', { reason: 'redo' })
|
this.store.applyDiff(diffToRedo, { ignoreEphemeralKeys: true })
|
||||||
return { undos, redos }
|
this.store.ensureStoreIsUsable()
|
||||||
})
|
this.stacks.set({ undos, redos })
|
||||||
|
} finally {
|
||||||
|
this.state = previousState
|
||||||
|
}
|
||||||
|
|
||||||
return this
|
return this
|
||||||
}
|
}
|
||||||
|
@ -281,24 +253,59 @@ export class HistoryManager<
|
||||||
return this
|
return this
|
||||||
}
|
}
|
||||||
|
|
||||||
mark = (id = uniqueId(), onUndo = true, onRedo = true) => {
|
mark = (id = uniqueId()) => {
|
||||||
const mostRecent = this._undos.get().head
|
transact(() => {
|
||||||
// dedupe marks, why not
|
this.flushPendingDiff()
|
||||||
if (mostRecent && mostRecent.type === 'STOP') {
|
this.stacks.update(({ undos, redos }) => ({ undos: undos.push({ type: 'stop', id }), redos }))
|
||||||
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 })
|
|
||||||
|
|
||||||
return id
|
return id
|
||||||
}
|
}
|
||||||
|
|
||||||
clear() {
|
clear() {
|
||||||
this._undos.set(stack())
|
this.stacks.set({ undos: stack(), redos: stack() })
|
||||||
this._redos.set(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<R extends UnknownRecord> {
|
||||||
|
private diff = createEmptyRecordsDiff<R>()
|
||||||
|
private isEmptyAtom = atom('PendingDiff.isEmpty', true)
|
||||||
|
|
||||||
|
clear() {
|
||||||
|
const diff = this.diff
|
||||||
|
this.diff = createEmptyRecordsDiff<R>()
|
||||||
|
this.isEmptyAtom.set(true)
|
||||||
|
return diff
|
||||||
|
}
|
||||||
|
|
||||||
|
isEmpty() {
|
||||||
|
return this.isEmptyAtom.get()
|
||||||
|
}
|
||||||
|
|
||||||
|
apply(diff: RecordsDiff<R>) {
|
||||||
|
squashRecordDiffsMutable(this.diff, [diff])
|
||||||
|
this.isEmptyAtom.set(isRecordsDiffEmpty(this.diff))
|
||||||
|
}
|
||||||
|
|
||||||
|
debug() {
|
||||||
|
return { diff: this.diff, isEmpty: this.isEmpty() }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -88,25 +88,13 @@ export class SideEffectManager<
|
||||||
return next
|
return next
|
||||||
}
|
}
|
||||||
|
|
||||||
let updateDepth = 0
|
|
||||||
|
|
||||||
editor.store.onAfterChange = (prev, next, source) => {
|
editor.store.onAfterChange = (prev, next, source) => {
|
||||||
updateDepth++
|
const handlers = this._afterChangeHandlers[next.typeName] as TLAfterChangeHandler<TLRecord>[]
|
||||||
|
if (handlers) {
|
||||||
if (updateDepth > 1000) {
|
for (const handler of handlers) {
|
||||||
console.error('[CleanupManager.onAfterChange] Maximum update depth exceeded, bailing out.')
|
handler(prev, next, source)
|
||||||
} else {
|
|
||||||
const handlers = this._afterChangeHandlers[
|
|
||||||
next.typeName
|
|
||||||
] as TLAfterChangeHandler<TLRecord>[]
|
|
||||||
if (handlers) {
|
|
||||||
for (const handler of handlers) {
|
|
||||||
handler(prev, next, source)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
updateDepth--
|
|
||||||
}
|
}
|
||||||
|
|
||||||
editor.store.onBeforeDelete = (record, source) => {
|
editor.store.onBeforeDelete = (record, source) => {
|
||||||
|
@ -161,6 +149,46 @@ export class SideEffectManager<
|
||||||
|
|
||||||
private _batchCompleteHandlers: TLBatchCompleteHandler[] = []
|
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<R>
|
||||||
|
afterCreate?: TLAfterCreateHandler<R>
|
||||||
|
beforeChange?: TLBeforeChangeHandler<R>
|
||||||
|
afterChange?: TLAfterChangeHandler<R>
|
||||||
|
beforeDelete?: TLBeforeDeleteHandler<R>
|
||||||
|
afterDelete?: TLAfterDeleteHandler<R>
|
||||||
|
}
|
||||||
|
}) {
|
||||||
|
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
|
* 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.
|
* modified record from the handler to change the record that will be created.
|
||||||
|
|
|
@ -15,8 +15,6 @@ export interface TLEventMap {
|
||||||
event: [TLEventInfo]
|
event: [TLEventInfo]
|
||||||
tick: [number]
|
tick: [number]
|
||||||
frame: [number]
|
frame: [number]
|
||||||
'change-history': [{ reason: 'undo' | 'redo' | 'push' } | { reason: 'bail'; markId?: string }]
|
|
||||||
'mark-history': [{ id: string }]
|
|
||||||
'select-all-text': [{ shapeId: TLShapeId }]
|
'select-all-text': [{ shapeId: TLShapeId }]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -1,50 +1,27 @@
|
||||||
/** @public */
|
import { RecordsDiff, UnknownRecord } from '@tldraw/store'
|
||||||
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
|
|
||||||
}>
|
|
||||||
|
|
||||||
/** @public */
|
/** @public */
|
||||||
export type TLHistoryMark = {
|
export interface TLHistoryMark {
|
||||||
type: 'STOP'
|
type: 'stop'
|
||||||
id: string
|
id: string
|
||||||
onUndo: boolean
|
|
||||||
onRedo: boolean
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** @public */
|
/** @public */
|
||||||
export type TLCommand<Name extends string = any, Data = any> = {
|
export interface TLHistoryDiff<R extends UnknownRecord> {
|
||||||
type: 'command'
|
type: 'diff'
|
||||||
data: Data
|
diff: RecordsDiff<R>
|
||||||
name: Name
|
}
|
||||||
|
|
||||||
|
/** @public */
|
||||||
|
export type TLHistoryEntry<R extends UnknownRecord> = TLHistoryMark | TLHistoryDiff<R>
|
||||||
|
|
||||||
|
/** @public */
|
||||||
|
export interface TLHistoryBatchOptions {
|
||||||
/**
|
/**
|
||||||
* Allows for commands that change state and should be undoable, but are 'inconsequential' and
|
* How should this change interact with the history stack?
|
||||||
* should not clear the redo stack. e.g. modifying the set of selected ids.
|
* - 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
|
history?: 'record' | 'record-preserveRedoStack' | 'ignore'
|
||||||
}
|
|
||||||
|
|
||||||
/** @public */
|
|
||||||
export type TLHistoryEntry = TLHistoryMark | TLCommand
|
|
||||||
|
|
||||||
/** @public */
|
|
||||||
export type TLCommandHandler<Data> = {
|
|
||||||
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
|
|
||||||
}
|
}
|
||||||
|
|
|
@ -33,6 +33,9 @@ export type ComputedCache<Data, R extends UnknownRecord> = {
|
||||||
get(id: IdOf<R>): Data | undefined;
|
get(id: IdOf<R>): Data | undefined;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// @internal (undocumented)
|
||||||
|
export function createEmptyRecordsDiff<R extends UnknownRecord>(): RecordsDiff<R>;
|
||||||
|
|
||||||
// @public
|
// @public
|
||||||
export function createMigrationIds<ID extends string, Versions extends Record<string, number>>(sequenceId: ID, versions: Versions): {
|
export function createMigrationIds<ID extends string, Versions extends Record<string, number>>(sequenceId: ID, versions: Versions): {
|
||||||
[K in keyof Versions]: `${ID}/${Versions[K]}`;
|
[K in keyof Versions]: `${ID}/${Versions[K]}`;
|
||||||
|
@ -58,6 +61,9 @@ export function createRecordMigrationSequence(opts: {
|
||||||
|
|
||||||
// @public
|
// @public
|
||||||
export function createRecordType<R extends UnknownRecord>(typeName: R['typeName'], config: {
|
export function createRecordType<R extends UnknownRecord>(typeName: R['typeName'], config: {
|
||||||
|
ephemeralKeys?: {
|
||||||
|
readonly [K in Exclude<keyof R, 'id' | 'typeName'>]: boolean;
|
||||||
|
};
|
||||||
scope: RecordScope;
|
scope: RecordScope;
|
||||||
validator?: StoreValidator<R>;
|
validator?: StoreValidator<R>;
|
||||||
}): RecordType<R, keyof Omit<R, 'id' | 'typeName'>>;
|
}): RecordType<R, keyof Omit<R, 'id' | 'typeName'>>;
|
||||||
|
@ -98,6 +104,9 @@ export class IncrementalSetConstructor<T> {
|
||||||
remove(item: T): void;
|
remove(item: T): void;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// @internal
|
||||||
|
export function isRecordsDiffEmpty<T extends UnknownRecord>(diff: RecordsDiff<T>): boolean;
|
||||||
|
|
||||||
// @public (undocumented)
|
// @public (undocumented)
|
||||||
export type LegacyMigration<Before = any, After = any> = {
|
export type LegacyMigration<Before = any, After = any> = {
|
||||||
down: (newState: After) => Before;
|
down: (newState: After) => Before;
|
||||||
|
@ -187,6 +196,9 @@ export class RecordType<R extends UnknownRecord, RequiredProperties extends keyo
|
||||||
constructor(
|
constructor(
|
||||||
typeName: R['typeName'], config: {
|
typeName: R['typeName'], config: {
|
||||||
readonly createDefaultProperties: () => Exclude<OmitMeta<R>, RequiredProperties>;
|
readonly createDefaultProperties: () => Exclude<OmitMeta<R>, RequiredProperties>;
|
||||||
|
readonly ephemeralKeys?: {
|
||||||
|
readonly [K in Exclude<keyof R, 'id' | 'typeName'>]: boolean;
|
||||||
|
};
|
||||||
readonly scope?: RecordScope;
|
readonly scope?: RecordScope;
|
||||||
readonly validator?: StoreValidator<R>;
|
readonly validator?: StoreValidator<R>;
|
||||||
});
|
});
|
||||||
|
@ -197,6 +209,12 @@ export class RecordType<R extends UnknownRecord, RequiredProperties extends keyo
|
||||||
// (undocumented)
|
// (undocumented)
|
||||||
readonly createDefaultProperties: () => Exclude<OmitMeta<R>, RequiredProperties>;
|
readonly createDefaultProperties: () => Exclude<OmitMeta<R>, RequiredProperties>;
|
||||||
createId(customUniquePart?: string): IdOf<R>;
|
createId(customUniquePart?: string): IdOf<R>;
|
||||||
|
// (undocumented)
|
||||||
|
readonly ephemeralKeys?: {
|
||||||
|
readonly [K in Exclude<keyof R, 'id' | 'typeName'>]: boolean;
|
||||||
|
};
|
||||||
|
// (undocumented)
|
||||||
|
readonly ephemeralKeySet: ReadonlySet<string>;
|
||||||
isId(id?: string): id is IdOf<R>;
|
isId(id?: string): id is IdOf<R>;
|
||||||
isInstance: (record?: UnknownRecord) => record is R;
|
isInstance: (record?: UnknownRecord) => record is R;
|
||||||
parseId(id: IdOf<R>): string;
|
parseId(id: IdOf<R>): string;
|
||||||
|
@ -244,22 +262,32 @@ export type SerializedStore<R extends UnknownRecord> = Record<IdOf<R>, R>;
|
||||||
// @public
|
// @public
|
||||||
export function squashRecordDiffs<T extends UnknownRecord>(diffs: RecordsDiff<T>[]): RecordsDiff<T>;
|
export function squashRecordDiffs<T extends UnknownRecord>(diffs: RecordsDiff<T>[]): RecordsDiff<T>;
|
||||||
|
|
||||||
|
// @internal
|
||||||
|
export function squashRecordDiffsMutable<T extends UnknownRecord>(target: RecordsDiff<T>, diffs: RecordsDiff<T>[]): void;
|
||||||
|
|
||||||
// @public
|
// @public
|
||||||
export class Store<R extends UnknownRecord = UnknownRecord, Props = unknown> {
|
export class Store<R extends UnknownRecord = UnknownRecord, Props = unknown> {
|
||||||
constructor(config: {
|
constructor(config: {
|
||||||
schema: StoreSchema<R, Props>;
|
schema: StoreSchema<R, Props>;
|
||||||
initialData?: SerializedStore<R>;
|
initialData?: SerializedStore<R>;
|
||||||
|
id?: string;
|
||||||
props: Props;
|
props: Props;
|
||||||
});
|
});
|
||||||
|
// @internal (undocumented)
|
||||||
|
addHistoryInterceptor(fn: (entry: HistoryEntry<R>, source: ChangeSource) => void): () => void;
|
||||||
allRecords: () => R[];
|
allRecords: () => R[];
|
||||||
// (undocumented)
|
// (undocumented)
|
||||||
applyDiff(diff: RecordsDiff<R>, runCallbacks?: boolean): void;
|
applyDiff(diff: RecordsDiff<R>, { runCallbacks, ignoreEphemeralKeys, }?: {
|
||||||
|
ignoreEphemeralKeys?: boolean;
|
||||||
|
runCallbacks?: boolean;
|
||||||
|
}): void;
|
||||||
|
// @internal (undocumented)
|
||||||
|
atomic<T>(fn: () => T, runCallbacks?: boolean): T;
|
||||||
clear: () => void;
|
clear: () => void;
|
||||||
createComputedCache: <T, V extends R = R>(name: string, derive: (record: V) => T | undefined, isEqual?: ((a: V, b: V) => boolean) | undefined) => ComputedCache<T, V>;
|
createComputedCache: <T, V extends R = R>(name: string, derive: (record: V) => T | undefined, isEqual?: ((a: V, b: V) => boolean) | undefined) => ComputedCache<T, V>;
|
||||||
createSelectedComputedCache: <T, J, V extends R = R>(name: string, selector: (record: V) => T | undefined, derive: (input: T) => J | undefined) => ComputedCache<J, V>;
|
createSelectedComputedCache: <T, J, V extends R = R>(name: string, selector: (record: V) => T | undefined, derive: (input: T) => J | undefined) => ComputedCache<J, V>;
|
||||||
// @internal (undocumented)
|
// @internal (undocumented)
|
||||||
ensureStoreIsUsable(): void;
|
ensureStoreIsUsable(): void;
|
||||||
// (undocumented)
|
|
||||||
extractingChanges(fn: () => void): RecordsDiff<R>;
|
extractingChanges(fn: () => void): RecordsDiff<R>;
|
||||||
filterChangesByScope(change: RecordsDiff<R>, scope: RecordScope): {
|
filterChangesByScope(change: RecordsDiff<R>, scope: RecordScope): {
|
||||||
added: { [K in IdOf<R>]: R; };
|
added: { [K in IdOf<R>]: R; };
|
||||||
|
@ -269,8 +297,6 @@ export class Store<R extends UnknownRecord = UnknownRecord, Props = unknown> {
|
||||||
// (undocumented)
|
// (undocumented)
|
||||||
_flushHistory(): void;
|
_flushHistory(): void;
|
||||||
get: <K extends IdOf<R>>(id: K) => RecFromId<K> | undefined;
|
get: <K extends IdOf<R>>(id: K) => RecFromId<K> | undefined;
|
||||||
// (undocumented)
|
|
||||||
getRecordType: <T extends R>(record: R) => T;
|
|
||||||
getSnapshot(scope?: 'all' | RecordScope): StoreSnapshot<R>;
|
getSnapshot(scope?: 'all' | RecordScope): StoreSnapshot<R>;
|
||||||
has: <K extends IdOf<R>>(id: K) => boolean;
|
has: <K extends IdOf<R>>(id: K) => boolean;
|
||||||
readonly history: Atom<number, RecordsDiff<R>>;
|
readonly history: Atom<number, RecordsDiff<R>>;
|
||||||
|
@ -331,6 +357,8 @@ export class StoreSchema<R extends UnknownRecord, P = unknown> {
|
||||||
createIntegrityChecker(store: Store<R, P>): (() => void) | undefined;
|
createIntegrityChecker(store: Store<R, P>): (() => void) | undefined;
|
||||||
// (undocumented)
|
// (undocumented)
|
||||||
getMigrationsSince(persistedSchema: SerializedSchema): Result<Migration[], string>;
|
getMigrationsSince(persistedSchema: SerializedSchema): Result<Migration[], string>;
|
||||||
|
// @internal (undocumented)
|
||||||
|
getType(typeName: string): RecordType<R, any>;
|
||||||
// (undocumented)
|
// (undocumented)
|
||||||
migratePersistedRecord(record: R, persistedSchema: SerializedSchema, direction?: 'down' | 'up'): MigrationResult<R>;
|
migratePersistedRecord(record: R, persistedSchema: SerializedSchema, direction?: 'down' | 'up'): MigrationResult<R>;
|
||||||
// (undocumented)
|
// (undocumented)
|
||||||
|
|
|
@ -1,12 +1,19 @@
|
||||||
export type { BaseRecord, IdOf, RecordId, UnknownRecord } from './lib/BaseRecord'
|
export type { BaseRecord, IdOf, RecordId, UnknownRecord } from './lib/BaseRecord'
|
||||||
export { IncrementalSetConstructor } from './lib/IncrementalSetConstructor'
|
export { IncrementalSetConstructor } from './lib/IncrementalSetConstructor'
|
||||||
export { RecordType, assertIdType, createRecordType } from './lib/RecordType'
|
export { RecordType, assertIdType, createRecordType } from './lib/RecordType'
|
||||||
export { Store, reverseRecordsDiff, squashRecordDiffs } from './lib/Store'
|
export {
|
||||||
|
createEmptyRecordsDiff,
|
||||||
|
isRecordsDiffEmpty,
|
||||||
|
reverseRecordsDiff,
|
||||||
|
squashRecordDiffs,
|
||||||
|
squashRecordDiffsMutable,
|
||||||
|
type RecordsDiff,
|
||||||
|
} from './lib/RecordsDiff'
|
||||||
|
export { Store } from './lib/Store'
|
||||||
export type {
|
export type {
|
||||||
CollectionDiff,
|
CollectionDiff,
|
||||||
ComputedCache,
|
ComputedCache,
|
||||||
HistoryEntry,
|
HistoryEntry,
|
||||||
RecordsDiff,
|
|
||||||
SerializedStore,
|
SerializedStore,
|
||||||
StoreError,
|
StoreError,
|
||||||
StoreListener,
|
StoreListener,
|
||||||
|
|
|
@ -1,4 +1,4 @@
|
||||||
import { structuredClone } from '@tldraw/utils'
|
import { objectMapEntries, structuredClone } from '@tldraw/utils'
|
||||||
import { nanoid } from 'nanoid'
|
import { nanoid } from 'nanoid'
|
||||||
import { IdOf, OmitMeta, UnknownRecord } from './BaseRecord'
|
import { IdOf, OmitMeta, UnknownRecord } from './BaseRecord'
|
||||||
import { StoreValidator } from './Store'
|
import { StoreValidator } from './Store'
|
||||||
|
@ -28,7 +28,8 @@ export class RecordType<
|
||||||
> {
|
> {
|
||||||
readonly createDefaultProperties: () => Exclude<OmitMeta<R>, RequiredProperties>
|
readonly createDefaultProperties: () => Exclude<OmitMeta<R>, RequiredProperties>
|
||||||
readonly validator: StoreValidator<R>
|
readonly validator: StoreValidator<R>
|
||||||
|
readonly ephemeralKeys?: { readonly [K in Exclude<keyof R, 'id' | 'typeName'>]: boolean }
|
||||||
|
readonly ephemeralKeySet: ReadonlySet<string>
|
||||||
readonly scope: RecordScope
|
readonly scope: RecordScope
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
|
@ -43,11 +44,21 @@ export class RecordType<
|
||||||
readonly createDefaultProperties: () => Exclude<OmitMeta<R>, RequiredProperties>
|
readonly createDefaultProperties: () => Exclude<OmitMeta<R>, RequiredProperties>
|
||||||
readonly validator?: StoreValidator<R>
|
readonly validator?: StoreValidator<R>
|
||||||
readonly scope?: RecordScope
|
readonly scope?: RecordScope
|
||||||
|
readonly ephemeralKeys?: { readonly [K in Exclude<keyof R, 'id' | 'typeName'>]: boolean }
|
||||||
}
|
}
|
||||||
) {
|
) {
|
||||||
this.createDefaultProperties = config.createDefaultProperties
|
this.createDefaultProperties = config.createDefaultProperties
|
||||||
this.validator = config.validator ?? { validate: (r: unknown) => r as R }
|
this.validator = config.validator ?? { validate: (r: unknown) => r as R }
|
||||||
this.scope = config.scope ?? 'document'
|
this.scope = config.scope ?? 'document'
|
||||||
|
this.ephemeralKeys = config.ephemeralKeys
|
||||||
|
|
||||||
|
const ephemeralKeySet = new Set<string>()
|
||||||
|
if (config.ephemeralKeys) {
|
||||||
|
for (const [key, isEphemeral] of objectMapEntries(config.ephemeralKeys)) {
|
||||||
|
if (isEphemeral) ephemeralKeySet.add(key)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
this.ephemeralKeySet = ephemeralKeySet
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
@ -186,6 +197,7 @@ export class RecordType<
|
||||||
createDefaultProperties: createDefaultProperties as any,
|
createDefaultProperties: createDefaultProperties as any,
|
||||||
validator: this.validator,
|
validator: this.validator,
|
||||||
scope: this.scope,
|
scope: this.scope,
|
||||||
|
ephemeralKeys: this.ephemeralKeys,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -218,12 +230,14 @@ export function createRecordType<R extends UnknownRecord>(
|
||||||
config: {
|
config: {
|
||||||
validator?: StoreValidator<R>
|
validator?: StoreValidator<R>
|
||||||
scope: RecordScope
|
scope: RecordScope
|
||||||
|
ephemeralKeys?: { readonly [K in Exclude<keyof R, 'id' | 'typeName'>]: boolean }
|
||||||
}
|
}
|
||||||
): RecordType<R, keyof Omit<R, 'id' | 'typeName'>> {
|
): RecordType<R, keyof Omit<R, 'id' | 'typeName'>> {
|
||||||
return new RecordType<R, keyof Omit<R, 'id' | 'typeName'>>(typeName, {
|
return new RecordType<R, keyof Omit<R, 'id' | 'typeName'>>(typeName, {
|
||||||
createDefaultProperties: () => ({}) as any,
|
createDefaultProperties: () => ({}) as any,
|
||||||
validator: config.validator,
|
validator: config.validator,
|
||||||
scope: config.scope,
|
scope: config.scope,
|
||||||
|
ephemeralKeys: config.ephemeralKeys,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
107
packages/store/src/lib/RecordsDiff.ts
Normal file
107
packages/store/src/lib/RecordsDiff.ts
Normal file
|
@ -0,0 +1,107 @@
|
||||||
|
import { objectMapEntries } from '@tldraw/utils'
|
||||||
|
import { IdOf, UnknownRecord } from './BaseRecord'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A diff describing the changes to a record.
|
||||||
|
*
|
||||||
|
* @public
|
||||||
|
*/
|
||||||
|
export type RecordsDiff<R extends UnknownRecord> = {
|
||||||
|
added: Record<IdOf<R>, R>
|
||||||
|
updated: Record<IdOf<R>, [from: R, to: R]>
|
||||||
|
removed: Record<IdOf<R>, R>
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @internal */
|
||||||
|
export function createEmptyRecordsDiff<R extends UnknownRecord>(): RecordsDiff<R> {
|
||||||
|
return { added: {}, updated: {}, removed: {} } as RecordsDiff<R>
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @public */
|
||||||
|
export function reverseRecordsDiff(diff: RecordsDiff<any>) {
|
||||||
|
const result: RecordsDiff<any> = { added: diff.removed, removed: diff.added, updated: {} }
|
||||||
|
for (const [from, to] of Object.values(diff.updated)) {
|
||||||
|
result.updated[from.id] = [to, from]
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Is a records diff empty?
|
||||||
|
* @internal
|
||||||
|
*/
|
||||||
|
export function isRecordsDiffEmpty<T extends UnknownRecord>(diff: RecordsDiff<T>) {
|
||||||
|
return (
|
||||||
|
Object.keys(diff.added).length === 0 &&
|
||||||
|
Object.keys(diff.updated).length === 0 &&
|
||||||
|
Object.keys(diff.removed).length === 0
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Squash a collection of diffs into a single diff.
|
||||||
|
*
|
||||||
|
* @param diffs - An array of diffs to squash.
|
||||||
|
* @returns A single diff that represents the squashed diffs.
|
||||||
|
* @public
|
||||||
|
*/
|
||||||
|
export function squashRecordDiffs<T extends UnknownRecord>(
|
||||||
|
diffs: RecordsDiff<T>[]
|
||||||
|
): RecordsDiff<T> {
|
||||||
|
const result = { added: {}, removed: {}, updated: {} } as RecordsDiff<T>
|
||||||
|
|
||||||
|
squashRecordDiffsMutable(result, diffs)
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Apply the array `diffs` to the `target` diff, mutating it in-place.
|
||||||
|
* @internal
|
||||||
|
*/
|
||||||
|
export function squashRecordDiffsMutable<T extends UnknownRecord>(
|
||||||
|
target: RecordsDiff<T>,
|
||||||
|
diffs: RecordsDiff<T>[]
|
||||||
|
): void {
|
||||||
|
for (const diff of diffs) {
|
||||||
|
for (const [id, value] of objectMapEntries(diff.added)) {
|
||||||
|
if (target.removed[id]) {
|
||||||
|
const original = target.removed[id]
|
||||||
|
delete target.removed[id]
|
||||||
|
if (original !== value) {
|
||||||
|
target.updated[id] = [original, value]
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
target.added[id] = value
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const [id, [_from, to]] of objectMapEntries(diff.updated)) {
|
||||||
|
if (target.added[id]) {
|
||||||
|
target.added[id] = to
|
||||||
|
delete target.updated[id]
|
||||||
|
delete target.removed[id]
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if (target.updated[id]) {
|
||||||
|
target.updated[id] = [target.updated[id][0], to]
|
||||||
|
delete target.removed[id]
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
target.updated[id] = diff.updated[id]
|
||||||
|
delete target.removed[id]
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const [id, value] of objectMapEntries(diff.removed)) {
|
||||||
|
// the same record was added in this diff sequence, just drop it
|
||||||
|
if (target.added[id]) {
|
||||||
|
delete target.added[id]
|
||||||
|
} else if (target.updated[id]) {
|
||||||
|
target.removed[id] = target.updated[id][0]
|
||||||
|
delete target.updated[id]
|
||||||
|
} else {
|
||||||
|
target.removed[id] = value
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -1,6 +1,8 @@
|
||||||
import { Atom, Computed, Reactor, atom, computed, reactor, transact } from '@tldraw/state'
|
import { Atom, Computed, Reactor, atom, computed, reactor, transact } from '@tldraw/state'
|
||||||
import {
|
import {
|
||||||
|
assert,
|
||||||
filterEntries,
|
filterEntries,
|
||||||
|
getOwnProperty,
|
||||||
objectMapEntries,
|
objectMapEntries,
|
||||||
objectMapFromEntries,
|
objectMapFromEntries,
|
||||||
objectMapKeys,
|
objectMapKeys,
|
||||||
|
@ -11,23 +13,13 @@ import { nanoid } from 'nanoid'
|
||||||
import { IdOf, RecordId, UnknownRecord } from './BaseRecord'
|
import { IdOf, RecordId, UnknownRecord } from './BaseRecord'
|
||||||
import { Cache } from './Cache'
|
import { Cache } from './Cache'
|
||||||
import { RecordScope } from './RecordType'
|
import { RecordScope } from './RecordType'
|
||||||
|
import { RecordsDiff, squashRecordDiffs } from './RecordsDiff'
|
||||||
import { StoreQueries } from './StoreQueries'
|
import { StoreQueries } from './StoreQueries'
|
||||||
import { SerializedSchema, StoreSchema } from './StoreSchema'
|
import { SerializedSchema, StoreSchema } from './StoreSchema'
|
||||||
import { devFreeze } from './devFreeze'
|
import { devFreeze } from './devFreeze'
|
||||||
|
|
||||||
type RecFromId<K extends RecordId<UnknownRecord>> = K extends RecordId<infer R> ? R : never
|
type RecFromId<K extends RecordId<UnknownRecord>> = K extends RecordId<infer R> ? R : never
|
||||||
|
|
||||||
/**
|
|
||||||
* A diff describing the changes to a record.
|
|
||||||
*
|
|
||||||
* @public
|
|
||||||
*/
|
|
||||||
export type RecordsDiff<R extends UnknownRecord> = {
|
|
||||||
added: Record<IdOf<R>, R>
|
|
||||||
updated: Record<IdOf<R>, [from: R, to: R]>
|
|
||||||
removed: Record<IdOf<R>, R>
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* A diff describing the changes to a collection.
|
* A diff describing the changes to a collection.
|
||||||
*
|
*
|
||||||
|
@ -113,7 +105,7 @@ export class Store<R extends UnknownRecord = UnknownRecord, Props = unknown> {
|
||||||
/**
|
/**
|
||||||
* The random id of the store.
|
* The random id of the store.
|
||||||
*/
|
*/
|
||||||
public readonly id = nanoid()
|
public readonly id: string
|
||||||
/**
|
/**
|
||||||
* An atom containing the store's atoms.
|
* An atom containing the store's atoms.
|
||||||
*
|
*
|
||||||
|
@ -169,6 +161,7 @@ export class Store<R extends UnknownRecord = UnknownRecord, Props = unknown> {
|
||||||
public readonly scopedTypes: { readonly [K in RecordScope]: ReadonlySet<R['typeName']> }
|
public readonly scopedTypes: { readonly [K in RecordScope]: ReadonlySet<R['typeName']> }
|
||||||
|
|
||||||
constructor(config: {
|
constructor(config: {
|
||||||
|
id?: string
|
||||||
/** The store's initial data. */
|
/** The store's initial data. */
|
||||||
initialData?: SerializedStore<R>
|
initialData?: SerializedStore<R>
|
||||||
/**
|
/**
|
||||||
|
@ -178,8 +171,9 @@ export class Store<R extends UnknownRecord = UnknownRecord, Props = unknown> {
|
||||||
schema: StoreSchema<R, Props>
|
schema: StoreSchema<R, Props>
|
||||||
props: Props
|
props: Props
|
||||||
}) {
|
}) {
|
||||||
const { initialData, schema } = config
|
const { initialData, schema, id } = config
|
||||||
|
|
||||||
|
this.id = id ?? nanoid()
|
||||||
this.schema = schema
|
this.schema = schema
|
||||||
this.props = config.props
|
this.props = config.props
|
||||||
|
|
||||||
|
@ -357,7 +351,7 @@ export class Store<R extends UnknownRecord = UnknownRecord, Props = unknown> {
|
||||||
* @public
|
* @public
|
||||||
*/
|
*/
|
||||||
put = (records: R[], phaseOverride?: 'initialize'): void => {
|
put = (records: R[], phaseOverride?: 'initialize'): void => {
|
||||||
transact(() => {
|
this.atomic(() => {
|
||||||
const updates: Record<IdOf<UnknownRecord>, [from: R, to: R]> = {}
|
const updates: Record<IdOf<UnknownRecord>, [from: R, to: R]> = {}
|
||||||
const additions: Record<IdOf<UnknownRecord>, R> = {}
|
const additions: Record<IdOf<UnknownRecord>, R> = {}
|
||||||
|
|
||||||
|
@ -402,7 +396,9 @@ export class Store<R extends UnknownRecord = UnknownRecord, Props = unknown> {
|
||||||
recordAtom.set(devFreeze(record))
|
recordAtom.set(devFreeze(record))
|
||||||
|
|
||||||
didChange = true
|
didChange = true
|
||||||
updates[record.id] = [initialValue, recordAtom.__unsafe__getWithoutCapture()]
|
const updated = recordAtom.__unsafe__getWithoutCapture()
|
||||||
|
updates[record.id] = [initialValue, updated]
|
||||||
|
this.addDiffForAfterEvent(initialValue, updated, source)
|
||||||
} else {
|
} else {
|
||||||
if (beforeCreate) record = beforeCreate(record, source)
|
if (beforeCreate) record = beforeCreate(record, source)
|
||||||
|
|
||||||
|
@ -420,6 +416,7 @@ export class Store<R extends UnknownRecord = UnknownRecord, Props = unknown> {
|
||||||
|
|
||||||
// Mark the change as a new addition.
|
// Mark the change as a new addition.
|
||||||
additions[record.id] = record
|
additions[record.id] = record
|
||||||
|
this.addDiffForAfterEvent(null, record, source)
|
||||||
|
|
||||||
// Assign the atom to the map under the record's id.
|
// Assign the atom to the map under the record's id.
|
||||||
if (!map) {
|
if (!map) {
|
||||||
|
@ -441,24 +438,6 @@ export class Store<R extends UnknownRecord = UnknownRecord, Props = unknown> {
|
||||||
updated: updates,
|
updated: updates,
|
||||||
removed: {} as Record<IdOf<R>, R>,
|
removed: {} as Record<IdOf<R>, R>,
|
||||||
})
|
})
|
||||||
|
|
||||||
if (this._runCallbacks) {
|
|
||||||
const { onAfterCreate, onAfterChange } = this
|
|
||||||
|
|
||||||
if (onAfterCreate) {
|
|
||||||
// Run the onAfterChange callback for addition.
|
|
||||||
Object.values(additions).forEach((record) => {
|
|
||||||
onAfterCreate(record, source)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
if (onAfterChange) {
|
|
||||||
// Run the onAfterChange callback for update.
|
|
||||||
Object.values(updates).forEach(([from, to]) => {
|
|
||||||
onAfterChange(from, to, source)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -469,7 +448,7 @@ export class Store<R extends UnknownRecord = UnknownRecord, Props = unknown> {
|
||||||
* @public
|
* @public
|
||||||
*/
|
*/
|
||||||
remove = (ids: IdOf<R>[]): void => {
|
remove = (ids: IdOf<R>[]): void => {
|
||||||
transact(() => {
|
this.atomic(() => {
|
||||||
const cancelled = [] as IdOf<R>[]
|
const cancelled = [] as IdOf<R>[]
|
||||||
const source = this.isMergingRemoteChanges ? 'remote' : 'user'
|
const source = this.isMergingRemoteChanges ? 'remote' : 'user'
|
||||||
|
|
||||||
|
@ -496,7 +475,9 @@ export class Store<R extends UnknownRecord = UnknownRecord, Props = unknown> {
|
||||||
if (!result) result = { ...atoms }
|
if (!result) result = { ...atoms }
|
||||||
if (!removed) removed = {} as Record<IdOf<R>, R>
|
if (!removed) removed = {} as Record<IdOf<R>, R>
|
||||||
delete result[id]
|
delete result[id]
|
||||||
removed[id] = atoms[id].get()
|
const record = atoms[id].get()
|
||||||
|
removed[id] = record
|
||||||
|
this.addDiffForAfterEvent(record, null, source)
|
||||||
}
|
}
|
||||||
|
|
||||||
return result ?? atoms
|
return result ?? atoms
|
||||||
|
@ -505,17 +486,6 @@ export class Store<R extends UnknownRecord = UnknownRecord, Props = unknown> {
|
||||||
if (!removed) return
|
if (!removed) return
|
||||||
// Update the history with the removed records.
|
// Update the history with the removed records.
|
||||||
this.updateHistory({ added: {}, updated: {}, removed } as RecordsDiff<R>)
|
this.updateHistory({ added: {}, updated: {}, removed } as RecordsDiff<R>)
|
||||||
|
|
||||||
// If we have an onAfterChange, run it for each removed record.
|
|
||||||
if (this.onAfterDelete && this._runCallbacks) {
|
|
||||||
let record: R
|
|
||||||
for (let i = 0, n = ids.length; i < n; i++) {
|
|
||||||
record = removed[ids[i]]
|
|
||||||
if (record) {
|
|
||||||
this.onAfterDelete(record, source)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -620,7 +590,7 @@ export class Store<R extends UnknownRecord = UnknownRecord, Props = unknown> {
|
||||||
const prevRunCallbacks = this._runCallbacks
|
const prevRunCallbacks = this._runCallbacks
|
||||||
try {
|
try {
|
||||||
this._runCallbacks = false
|
this._runCallbacks = false
|
||||||
transact(() => {
|
this.atomic(() => {
|
||||||
this.clear()
|
this.clear()
|
||||||
this.put(Object.values(migrationResult.value))
|
this.put(Object.values(migrationResult.value))
|
||||||
this.ensureStoreIsUsable()
|
this.ensureStoreIsUsable()
|
||||||
|
@ -731,9 +701,12 @@ export class Store<R extends UnknownRecord = UnknownRecord, Props = unknown> {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Run `fn` and return a {@link RecordsDiff} of the changes that occurred as a result.
|
||||||
|
*/
|
||||||
extractingChanges(fn: () => void): RecordsDiff<R> {
|
extractingChanges(fn: () => void): RecordsDiff<R> {
|
||||||
const changes: Array<RecordsDiff<R>> = []
|
const changes: Array<RecordsDiff<R>> = []
|
||||||
const dispose = this.historyAccumulator.intercepting((entry) => changes.push(entry.changes))
|
const dispose = this.historyAccumulator.addInterceptor((entry) => changes.push(entry.changes))
|
||||||
try {
|
try {
|
||||||
transact(fn)
|
transact(fn)
|
||||||
return squashRecordDiffs(changes)
|
return squashRecordDiffs(changes)
|
||||||
|
@ -742,25 +715,47 @@ export class Store<R extends UnknownRecord = UnknownRecord, Props = unknown> {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
applyDiff(diff: RecordsDiff<R>, runCallbacks = true) {
|
applyDiff(
|
||||||
const prevRunCallbacks = this._runCallbacks
|
diff: RecordsDiff<R>,
|
||||||
try {
|
{
|
||||||
this._runCallbacks = runCallbacks
|
runCallbacks = true,
|
||||||
transact(() => {
|
ignoreEphemeralKeys = false,
|
||||||
const toPut = objectMapValues(diff.added).concat(
|
}: { runCallbacks?: boolean; ignoreEphemeralKeys?: boolean } = {}
|
||||||
objectMapValues(diff.updated).map(([_from, to]) => to)
|
) {
|
||||||
)
|
this.atomic(() => {
|
||||||
const toRemove = objectMapKeys(diff.removed)
|
const toPut = objectMapValues(diff.added)
|
||||||
if (toPut.length) {
|
|
||||||
this.put(toPut)
|
for (const [_from, to] of objectMapValues(diff.updated)) {
|
||||||
|
const type = this.schema.getType(to.typeName)
|
||||||
|
if (ignoreEphemeralKeys && type.ephemeralKeySet.size) {
|
||||||
|
const existing = this.get(to.id)
|
||||||
|
if (!existing) {
|
||||||
|
toPut.push(to)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
let changed: R | null = null
|
||||||
|
for (const [key, value] of Object.entries(to)) {
|
||||||
|
if (type.ephemeralKeySet.has(key) || Object.is(value, getOwnProperty(existing, key))) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!changed) changed = { ...existing } as R
|
||||||
|
;(changed as any)[key] = value
|
||||||
|
}
|
||||||
|
if (changed) toPut.push(changed)
|
||||||
|
} else {
|
||||||
|
toPut.push(to)
|
||||||
}
|
}
|
||||||
if (toRemove.length) {
|
}
|
||||||
this.remove(toRemove)
|
|
||||||
}
|
const toRemove = objectMapKeys(diff.removed)
|
||||||
})
|
if (toPut.length) {
|
||||||
} finally {
|
this.put(toPut)
|
||||||
this._runCallbacks = prevRunCallbacks
|
}
|
||||||
}
|
if (toRemove.length) {
|
||||||
|
this.remove(toRemove)
|
||||||
|
}
|
||||||
|
}, runCallbacks)
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
@ -827,20 +822,14 @@ export class Store<R extends UnknownRecord = UnknownRecord, Props = unknown> {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
getRecordType = <T extends R>(record: R): T => {
|
|
||||||
const type = this.schema.types[record.typeName as R['typeName']]
|
|
||||||
if (!type) {
|
|
||||||
throw new Error(`Record type ${record.typeName} not found`)
|
|
||||||
}
|
|
||||||
return type as unknown as T
|
|
||||||
}
|
|
||||||
|
|
||||||
private _integrityChecker?: () => void | undefined
|
private _integrityChecker?: () => void | undefined
|
||||||
|
|
||||||
/** @internal */
|
/** @internal */
|
||||||
ensureStoreIsUsable() {
|
ensureStoreIsUsable() {
|
||||||
this._integrityChecker ??= this.schema.createIntegrityChecker(this)
|
this.atomic(() => {
|
||||||
this._integrityChecker?.()
|
this._integrityChecker ??= this.schema.createIntegrityChecker(this)
|
||||||
|
this._integrityChecker?.()
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
private _isPossiblyCorrupted = false
|
private _isPossiblyCorrupted = false
|
||||||
|
@ -852,64 +841,82 @@ export class Store<R extends UnknownRecord = UnknownRecord, Props = unknown> {
|
||||||
isPossiblyCorrupted() {
|
isPossiblyCorrupted() {
|
||||||
return this._isPossiblyCorrupted
|
return this._isPossiblyCorrupted
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
private pendingAfterEvents: Map<
|
||||||
* Squash a collection of diffs into a single diff.
|
IdOf<R>,
|
||||||
*
|
{ before: R | null; after: R | null; source: 'remote' | 'user' }
|
||||||
* @param diffs - An array of diffs to squash.
|
> | null = null
|
||||||
* @returns A single diff that represents the squashed diffs.
|
private addDiffForAfterEvent(before: R | null, after: R | null, source: 'remote' | 'user') {
|
||||||
* @public
|
assert(this.pendingAfterEvents, 'must be in event operation')
|
||||||
*/
|
if (before === after) return
|
||||||
export function squashRecordDiffs<T extends UnknownRecord>(
|
if (before && after) assert(before.id === after.id)
|
||||||
diffs: RecordsDiff<T>[]
|
if (!before && !after) return
|
||||||
): RecordsDiff<T> {
|
const id = (before || after)!.id
|
||||||
const result = { added: {}, removed: {}, updated: {} } as RecordsDiff<T>
|
const existing = this.pendingAfterEvents.get(id)
|
||||||
|
if (existing) {
|
||||||
|
assert(existing.source === source, 'source cannot change within a single event operation')
|
||||||
|
existing.after = after
|
||||||
|
} else {
|
||||||
|
this.pendingAfterEvents.set(id, { before, after, source })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
private flushAtomicCallbacks() {
|
||||||
|
let updateDepth = 0
|
||||||
|
while (this.pendingAfterEvents) {
|
||||||
|
const events = this.pendingAfterEvents
|
||||||
|
this.pendingAfterEvents = null
|
||||||
|
|
||||||
for (const diff of diffs) {
|
if (!this._runCallbacks) continue
|
||||||
for (const [id, value] of objectMapEntries(diff.added)) {
|
|
||||||
if (result.removed[id]) {
|
updateDepth++
|
||||||
const original = result.removed[id]
|
if (updateDepth > 100) {
|
||||||
delete result.removed[id]
|
throw new Error('Maximum store update depth exceeded, bailing out')
|
||||||
if (original !== value) {
|
}
|
||||||
result.updated[id] = [original, value]
|
|
||||||
|
for (const { before, after, source } of events.values()) {
|
||||||
|
if (before && after) {
|
||||||
|
this.onAfterChange?.(before, after, source)
|
||||||
|
} else if (before && !after) {
|
||||||
|
this.onAfterDelete?.(before, source)
|
||||||
|
} else if (!before && after) {
|
||||||
|
this.onAfterCreate?.(after, source)
|
||||||
}
|
}
|
||||||
} else {
|
|
||||||
result.added[id] = value
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
for (const [id, [_from, to]] of objectMapEntries(diff.updated)) {
|
|
||||||
if (result.added[id]) {
|
|
||||||
result.added[id] = to
|
|
||||||
delete result.updated[id]
|
|
||||||
delete result.removed[id]
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if (result.updated[id]) {
|
|
||||||
result.updated[id] = [result.updated[id][0], to]
|
|
||||||
delete result.removed[id]
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
result.updated[id] = diff.updated[id]
|
|
||||||
delete result.removed[id]
|
|
||||||
}
|
|
||||||
|
|
||||||
for (const [id, value] of objectMapEntries(diff.removed)) {
|
|
||||||
// the same record was added in this diff sequence, just drop it
|
|
||||||
if (result.added[id]) {
|
|
||||||
delete result.added[id]
|
|
||||||
} else if (result.updated[id]) {
|
|
||||||
result.removed[id] = result.updated[id][0]
|
|
||||||
delete result.updated[id]
|
|
||||||
} else {
|
|
||||||
result.removed[id] = value
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
private _isInAtomicOp = false
|
||||||
|
/** @internal */
|
||||||
|
atomic<T>(fn: () => T, runCallbacks = true): T {
|
||||||
|
return transact(() => {
|
||||||
|
if (this._isInAtomicOp) {
|
||||||
|
if (!this.pendingAfterEvents) this.pendingAfterEvents = new Map()
|
||||||
|
return fn()
|
||||||
|
}
|
||||||
|
|
||||||
return result
|
this.pendingAfterEvents = new Map()
|
||||||
|
const prevRunCallbacks = this._runCallbacks
|
||||||
|
this._runCallbacks = runCallbacks ?? prevRunCallbacks
|
||||||
|
this._isInAtomicOp = true
|
||||||
|
try {
|
||||||
|
const result = fn()
|
||||||
|
|
||||||
|
this.flushAtomicCallbacks()
|
||||||
|
|
||||||
|
return result
|
||||||
|
} finally {
|
||||||
|
this.pendingAfterEvents = null
|
||||||
|
this._runCallbacks = prevRunCallbacks
|
||||||
|
this._isInAtomicOp = false
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @internal */
|
||||||
|
addHistoryInterceptor(fn: (entry: HistoryEntry<R>, source: ChangeSource) => void) {
|
||||||
|
return this.historyAccumulator.addInterceptor((entry) =>
|
||||||
|
fn(entry, this.isMergingRemoteChanges ? 'remote' : 'user')
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
@ -949,21 +956,12 @@ function squashHistoryEntries<T extends UnknownRecord>(
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
/** @public */
|
|
||||||
export function reverseRecordsDiff(diff: RecordsDiff<any>) {
|
|
||||||
const result: RecordsDiff<any> = { added: diff.removed, removed: diff.added, updated: {} }
|
|
||||||
for (const [from, to] of Object.values(diff.updated)) {
|
|
||||||
result.updated[from.id] = [to, from]
|
|
||||||
}
|
|
||||||
return result
|
|
||||||
}
|
|
||||||
|
|
||||||
class HistoryAccumulator<T extends UnknownRecord> {
|
class HistoryAccumulator<T extends UnknownRecord> {
|
||||||
private _history: HistoryEntry<T>[] = []
|
private _history: HistoryEntry<T>[] = []
|
||||||
|
|
||||||
private _interceptors: Set<(entry: HistoryEntry<T>) => void> = new Set()
|
private _interceptors: Set<(entry: HistoryEntry<T>) => void> = new Set()
|
||||||
|
|
||||||
intercepting(fn: (entry: HistoryEntry<T>) => void) {
|
addInterceptor(fn: (entry: HistoryEntry<T>) => void) {
|
||||||
this._interceptors.add(fn)
|
this._interceptors.add(fn)
|
||||||
return () => {
|
return () => {
|
||||||
this._interceptors.delete(fn)
|
this._interceptors.delete(fn)
|
||||||
|
|
|
@ -12,8 +12,9 @@ import isEqual from 'lodash.isequal'
|
||||||
import { IdOf, UnknownRecord } from './BaseRecord'
|
import { IdOf, UnknownRecord } from './BaseRecord'
|
||||||
import { executeQuery, objectMatchesQuery, QueryExpression } from './executeQuery'
|
import { executeQuery, objectMatchesQuery, QueryExpression } from './executeQuery'
|
||||||
import { IncrementalSetConstructor } from './IncrementalSetConstructor'
|
import { IncrementalSetConstructor } from './IncrementalSetConstructor'
|
||||||
|
import { RecordsDiff } from './RecordsDiff'
|
||||||
import { diffSets } from './setUtils'
|
import { diffSets } from './setUtils'
|
||||||
import { CollectionDiff, RecordsDiff } from './Store'
|
import { CollectionDiff } from './Store'
|
||||||
|
|
||||||
export type RSIndexDiff<
|
export type RSIndexDiff<
|
||||||
R extends UnknownRecord,
|
R extends UnknownRecord,
|
||||||
|
|
|
@ -329,4 +329,11 @@ export class StoreSchema<R extends UnknownRecord, P = unknown> {
|
||||||
),
|
),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** @internal */
|
||||||
|
getType(typeName: string) {
|
||||||
|
const type = getOwnProperty(this.types, typeName)
|
||||||
|
assert(type, 'record type does not exists')
|
||||||
|
return type
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,8 +1,9 @@
|
||||||
import { Computed, react, RESET_VALUE, transact } from '@tldraw/state'
|
import { Computed, react, RESET_VALUE, transact } from '@tldraw/state'
|
||||||
import { BaseRecord, RecordId } from '../BaseRecord'
|
import { BaseRecord, RecordId } from '../BaseRecord'
|
||||||
import { createMigrationSequence } from '../migrate'
|
import { createMigrationSequence } from '../migrate'
|
||||||
|
import { RecordsDiff, reverseRecordsDiff } from '../RecordsDiff'
|
||||||
import { createRecordType } from '../RecordType'
|
import { createRecordType } from '../RecordType'
|
||||||
import { CollectionDiff, RecordsDiff, Store } from '../Store'
|
import { CollectionDiff, Store } from '../Store'
|
||||||
import { StoreSchema } from '../StoreSchema'
|
import { StoreSchema } from '../StoreSchema'
|
||||||
|
|
||||||
interface Book extends BaseRecord<'book', RecordId<Book>> {
|
interface Book extends BaseRecord<'book', RecordId<Book>> {
|
||||||
|
@ -881,3 +882,270 @@ describe('snapshots', () => {
|
||||||
expect(store2.get(Book.createId('lotr'))!.numPages).toBe(42)
|
expect(store2.get(Book.createId('lotr'))!.numPages).toBe(42)
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
describe('diffs', () => {
|
||||||
|
let store: Store<LibraryType>
|
||||||
|
const authorId = Author.createId('tolkein')
|
||||||
|
const bookId = Book.createId('hobbit')
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
store = new Store({
|
||||||
|
props: {},
|
||||||
|
schema: StoreSchema.create<LibraryType>({
|
||||||
|
book: Book,
|
||||||
|
author: Author,
|
||||||
|
visit: Visit,
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('produces diffs from `extractingChanges`', () => {
|
||||||
|
expect(
|
||||||
|
store.extractingChanges(() => {
|
||||||
|
store.put([Author.create({ name: 'J.R.R Tolkein', id: authorId })])
|
||||||
|
store.put([
|
||||||
|
Book.create({ title: 'The Hobbit', id: bookId, author: authorId, numPages: 300 }),
|
||||||
|
])
|
||||||
|
})
|
||||||
|
).toMatchInlineSnapshot(`
|
||||||
|
{
|
||||||
|
"added": {
|
||||||
|
"author:tolkein": {
|
||||||
|
"id": "author:tolkein",
|
||||||
|
"isPseudonym": false,
|
||||||
|
"name": "J.R.R Tolkein",
|
||||||
|
"typeName": "author",
|
||||||
|
},
|
||||||
|
"book:hobbit": {
|
||||||
|
"author": "author:tolkein",
|
||||||
|
"id": "book:hobbit",
|
||||||
|
"numPages": 300,
|
||||||
|
"title": "The Hobbit",
|
||||||
|
"typeName": "book",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"removed": {},
|
||||||
|
"updated": {},
|
||||||
|
}
|
||||||
|
`)
|
||||||
|
|
||||||
|
expect(
|
||||||
|
store.extractingChanges(() => {
|
||||||
|
store.remove([authorId])
|
||||||
|
store.update(bookId, (book) => ({ ...book, title: 'The Hobbit: There and Back Again' }))
|
||||||
|
})
|
||||||
|
).toMatchInlineSnapshot(`
|
||||||
|
{
|
||||||
|
"added": {},
|
||||||
|
"removed": {
|
||||||
|
"author:tolkein": {
|
||||||
|
"id": "author:tolkein",
|
||||||
|
"isPseudonym": false,
|
||||||
|
"name": "J.R.R Tolkein",
|
||||||
|
"typeName": "author",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"updated": {
|
||||||
|
"book:hobbit": [
|
||||||
|
{
|
||||||
|
"author": "author:tolkein",
|
||||||
|
"id": "book:hobbit",
|
||||||
|
"numPages": 300,
|
||||||
|
"title": "The Hobbit",
|
||||||
|
"typeName": "book",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"author": "author:tolkein",
|
||||||
|
"id": "book:hobbit",
|
||||||
|
"numPages": 300,
|
||||||
|
"title": "The Hobbit: There and Back Again",
|
||||||
|
"typeName": "book",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
}
|
||||||
|
`)
|
||||||
|
})
|
||||||
|
it('produces diffs from `addHistoryInterceptor`', () => {
|
||||||
|
const diffs: any[] = []
|
||||||
|
const interceptor = jest.fn((diff) => diffs.push(diff))
|
||||||
|
store.addHistoryInterceptor(interceptor)
|
||||||
|
|
||||||
|
store.put([
|
||||||
|
Author.create({ name: 'J.R.R Tolkein', id: Author.createId('tolkein') }),
|
||||||
|
Book.create({ title: 'The Hobbit', id: bookId, author: authorId, numPages: 300 }),
|
||||||
|
])
|
||||||
|
expect(interceptor).toHaveBeenCalledTimes(1)
|
||||||
|
|
||||||
|
store.extractingChanges(() => {
|
||||||
|
store.remove([authorId])
|
||||||
|
|
||||||
|
store.update(bookId, (book) => ({ ...book, title: 'The Hobbit: There and Back Again' }))
|
||||||
|
})
|
||||||
|
expect(interceptor).toHaveBeenCalledTimes(3)
|
||||||
|
|
||||||
|
expect(diffs).toMatchInlineSnapshot(`
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"changes": {
|
||||||
|
"added": {
|
||||||
|
"author:tolkein": {
|
||||||
|
"id": "author:tolkein",
|
||||||
|
"isPseudonym": false,
|
||||||
|
"name": "J.R.R Tolkein",
|
||||||
|
"typeName": "author",
|
||||||
|
},
|
||||||
|
"book:hobbit": {
|
||||||
|
"author": "author:tolkein",
|
||||||
|
"id": "book:hobbit",
|
||||||
|
"numPages": 300,
|
||||||
|
"title": "The Hobbit",
|
||||||
|
"typeName": "book",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"removed": {},
|
||||||
|
"updated": {},
|
||||||
|
},
|
||||||
|
"source": "user",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"changes": {
|
||||||
|
"added": {},
|
||||||
|
"removed": {
|
||||||
|
"author:tolkein": {
|
||||||
|
"id": "author:tolkein",
|
||||||
|
"isPseudonym": false,
|
||||||
|
"name": "J.R.R Tolkein",
|
||||||
|
"typeName": "author",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"updated": {},
|
||||||
|
},
|
||||||
|
"source": "user",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"changes": {
|
||||||
|
"added": {},
|
||||||
|
"removed": {},
|
||||||
|
"updated": {
|
||||||
|
"book:hobbit": [
|
||||||
|
{
|
||||||
|
"author": "author:tolkein",
|
||||||
|
"id": "book:hobbit",
|
||||||
|
"numPages": 300,
|
||||||
|
"title": "The Hobbit",
|
||||||
|
"typeName": "book",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"author": "author:tolkein",
|
||||||
|
"id": "book:hobbit",
|
||||||
|
"numPages": 300,
|
||||||
|
"title": "The Hobbit: There and Back Again",
|
||||||
|
"typeName": "book",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"source": "user",
|
||||||
|
},
|
||||||
|
]
|
||||||
|
`)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('can apply and invert diffs', () => {
|
||||||
|
store.put([
|
||||||
|
Author.create({ name: 'J.R.R Tolkein', id: Author.createId('tolkein') }),
|
||||||
|
Book.create({ title: 'The Hobbit', id: bookId, author: authorId, numPages: 300 }),
|
||||||
|
])
|
||||||
|
|
||||||
|
const checkpoint1 = store.getSnapshot()
|
||||||
|
|
||||||
|
const forwardsDiff = store.extractingChanges(() => {
|
||||||
|
store.remove([authorId])
|
||||||
|
store.update(bookId, (book) => ({ ...book, title: 'The Hobbit: There and Back Again' }))
|
||||||
|
})
|
||||||
|
|
||||||
|
const checkpoint2 = store.getSnapshot()
|
||||||
|
|
||||||
|
store.applyDiff(reverseRecordsDiff(forwardsDiff))
|
||||||
|
expect(store.getSnapshot()).toEqual(checkpoint1)
|
||||||
|
|
||||||
|
store.applyDiff(forwardsDiff)
|
||||||
|
expect(store.getSnapshot()).toEqual(checkpoint2)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('after callbacks', () => {
|
||||||
|
let store: Store<LibraryType>
|
||||||
|
let callbacks: any[] = []
|
||||||
|
|
||||||
|
const authorId = Author.createId('tolkein')
|
||||||
|
const bookId = Book.createId('hobbit')
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
store = new Store({
|
||||||
|
props: {},
|
||||||
|
schema: StoreSchema.create<LibraryType>({
|
||||||
|
book: Book,
|
||||||
|
author: Author,
|
||||||
|
visit: Visit,
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
|
||||||
|
store.onAfterCreate = jest.fn((record) => callbacks.push({ type: 'create', record }))
|
||||||
|
store.onAfterChange = jest.fn((from, to) => callbacks.push({ type: 'change', from, to }))
|
||||||
|
store.onAfterDelete = jest.fn((record) => callbacks.push({ type: 'delete', record }))
|
||||||
|
callbacks = []
|
||||||
|
})
|
||||||
|
|
||||||
|
it('fires callbacks at the end of an `atomic` op', () => {
|
||||||
|
store.atomic(() => {
|
||||||
|
expect(callbacks).toHaveLength(0)
|
||||||
|
|
||||||
|
store.put([
|
||||||
|
Author.create({ name: 'J.R.R Tolkein', id: authorId }),
|
||||||
|
Book.create({ title: 'The Hobbit', id: bookId, author: authorId, numPages: 300 }),
|
||||||
|
])
|
||||||
|
|
||||||
|
expect(callbacks).toHaveLength(0)
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(callbacks).toMatchObject([
|
||||||
|
{ type: 'create', record: { id: authorId } },
|
||||||
|
{ type: 'create', record: { id: bookId } },
|
||||||
|
])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('doesnt fire callback for a record created then deleted', () => {
|
||||||
|
store.atomic(() => {
|
||||||
|
store.put([Author.create({ name: 'J.R.R Tolkein', id: authorId })])
|
||||||
|
store.remove([authorId])
|
||||||
|
})
|
||||||
|
expect(callbacks).toHaveLength(0)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('bails out if too many callbacks are fired', () => {
|
||||||
|
let limit = 10
|
||||||
|
store.onAfterCreate = (record) => {
|
||||||
|
if (record.typeName === 'book' && record.numPages < limit) {
|
||||||
|
store.put([{ ...record, numPages: record.numPages + 1 }])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
store.onAfterChange = (from, to) => {
|
||||||
|
if (to.typeName === 'book' && to.numPages < limit) {
|
||||||
|
store.put([{ ...to, numPages: to.numPages + 1 }])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// this should be fine:
|
||||||
|
store.put([Book.create({ title: 'The Hobbit', id: bookId, author: authorId, numPages: 0 })])
|
||||||
|
expect(store.get(bookId)!.numPages).toBe(limit)
|
||||||
|
|
||||||
|
// if we increase the limit thought, it should crash:
|
||||||
|
limit = 10000
|
||||||
|
store.clear()
|
||||||
|
expect(() => {
|
||||||
|
store.put([Book.create({ title: 'The Hobbit', id: bookId, author: authorId, numPages: 0 })])
|
||||||
|
}).toThrowErrorMatchingInlineSnapshot(`"Maximum store update depth exceeded, bailing out"`)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
|
@ -1814,7 +1814,7 @@ export interface TLUiButtonPickerProps<T extends string> {
|
||||||
// (undocumented)
|
// (undocumented)
|
||||||
items: StyleValuesForUi<T>;
|
items: StyleValuesForUi<T>;
|
||||||
// (undocumented)
|
// (undocumented)
|
||||||
onValueChange: (style: StyleProp<T>, value: T, squashing: boolean) => void;
|
onValueChange: (style: StyleProp<T>, value: T) => void;
|
||||||
// (undocumented)
|
// (undocumented)
|
||||||
style: StyleProp<T>;
|
style: StyleProp<T>;
|
||||||
// (undocumented)
|
// (undocumented)
|
||||||
|
@ -2206,6 +2206,8 @@ export interface TLUiInputProps {
|
||||||
// (undocumented)
|
// (undocumented)
|
||||||
onComplete?: (value: string) => void;
|
onComplete?: (value: string) => void;
|
||||||
// (undocumented)
|
// (undocumented)
|
||||||
|
onFocus?: () => void;
|
||||||
|
// (undocumented)
|
||||||
onValueChange?: (value: string) => void;
|
onValueChange?: (value: string) => void;
|
||||||
// (undocumented)
|
// (undocumented)
|
||||||
placeholder?: string;
|
placeholder?: string;
|
||||||
|
@ -2332,7 +2334,7 @@ export interface TLUiSliderProps {
|
||||||
// (undocumented)
|
// (undocumented)
|
||||||
label: string;
|
label: string;
|
||||||
// (undocumented)
|
// (undocumented)
|
||||||
onValueChange: (value: number, squashing: boolean) => void;
|
onValueChange: (value: number) => void;
|
||||||
// (undocumented)
|
// (undocumented)
|
||||||
steps: number;
|
steps: number;
|
||||||
// (undocumented)
|
// (undocumented)
|
||||||
|
|
|
@ -115,7 +115,7 @@ export class Pointing extends StateNode {
|
||||||
if (startTerminal?.type === 'binding') {
|
if (startTerminal?.type === 'binding') {
|
||||||
this.editor.setHintingShapes([startTerminal.boundShapeId])
|
this.editor.setHintingShapes([startTerminal.boundShapeId])
|
||||||
}
|
}
|
||||||
this.editor.updateShapes([change], { squashing: true })
|
this.editor.updateShapes([change])
|
||||||
}
|
}
|
||||||
|
|
||||||
// Cache the current shape after those changes
|
// Cache the current shape after those changes
|
||||||
|
@ -152,7 +152,7 @@ export class Pointing extends StateNode {
|
||||||
if (endTerminal?.type === 'binding') {
|
if (endTerminal?.type === 'binding') {
|
||||||
this.editor.setHintingShapes([endTerminal.boundShapeId])
|
this.editor.setHintingShapes([endTerminal.boundShapeId])
|
||||||
}
|
}
|
||||||
this.editor.updateShapes([change], { squashing: true })
|
this.editor.updateShapes([change])
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -168,7 +168,7 @@ export class Pointing extends StateNode {
|
||||||
})
|
})
|
||||||
|
|
||||||
if (change) {
|
if (change) {
|
||||||
this.editor.updateShapes([change], { squashing: true })
|
this.editor.updateShapes([change])
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -370,9 +370,7 @@ export class Drawing extends StateNode {
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
this.editor.updateShapes<TLDrawShape | TLHighlightShape>([shapePartial], {
|
this.editor.updateShapes<TLDrawShape | TLHighlightShape>([shapePartial])
|
||||||
squashing: true,
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
|
@ -433,7 +431,7 @@ export class Drawing extends StateNode {
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
this.editor.updateShapes([shapePartial], { squashing: true })
|
this.editor.updateShapes([shapePartial])
|
||||||
}
|
}
|
||||||
|
|
||||||
break
|
break
|
||||||
|
@ -574,7 +572,7 @@ export class Drawing extends StateNode {
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
this.editor.updateShapes([shapePartial], { squashing: true })
|
this.editor.updateShapes([shapePartial])
|
||||||
|
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
|
@ -621,7 +619,7 @@ export class Drawing extends StateNode {
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
this.editor.updateShapes([shapePartial], { squashing: true })
|
this.editor.updateShapes([shapePartial])
|
||||||
|
|
||||||
// Set a maximum length for the lines array; after 200 points, complete the line.
|
// Set a maximum length for the lines array; after 200 points, complete the line.
|
||||||
if (newPoints.length > 500) {
|
if (newPoints.length > 500) {
|
||||||
|
|
|
@ -30,16 +30,13 @@ export const FrameLabelInput = forwardRef<
|
||||||
const value = e.currentTarget.value.trim()
|
const value = e.currentTarget.value.trim()
|
||||||
if (name === value) return
|
if (name === value) return
|
||||||
|
|
||||||
editor.updateShapes(
|
editor.updateShapes([
|
||||||
[
|
{
|
||||||
{
|
id,
|
||||||
id,
|
type: 'frame',
|
||||||
type: 'frame',
|
props: { name: value },
|
||||||
props: { name: value },
|
},
|
||||||
},
|
])
|
||||||
],
|
|
||||||
{ squashing: true }
|
|
||||||
)
|
|
||||||
},
|
},
|
||||||
[id, editor]
|
[id, editor]
|
||||||
)
|
)
|
||||||
|
@ -53,16 +50,13 @@ export const FrameLabelInput = forwardRef<
|
||||||
const value = e.currentTarget.value
|
const value = e.currentTarget.value
|
||||||
if (name === value) return
|
if (name === value) return
|
||||||
|
|
||||||
editor.updateShapes(
|
editor.updateShapes([
|
||||||
[
|
{
|
||||||
{
|
id,
|
||||||
id,
|
type: 'frame',
|
||||||
type: 'frame',
|
props: { name: value },
|
||||||
props: { name: value },
|
},
|
||||||
},
|
])
|
||||||
],
|
|
||||||
{ squashing: true }
|
|
||||||
)
|
|
||||||
},
|
},
|
||||||
[id, editor]
|
[id, editor]
|
||||||
)
|
)
|
||||||
|
|
|
@ -25,7 +25,6 @@ describe(NoteShapeTool, () => {
|
||||||
|
|
||||||
editor.cancel() // leave edit mode
|
editor.cancel() // leave edit mode
|
||||||
|
|
||||||
editor.undo() // undoes the selection change
|
|
||||||
editor.undo()
|
editor.undo()
|
||||||
|
|
||||||
expect(editor.getCurrentPageShapes().length).toBe(0)
|
expect(editor.getCurrentPageShapes().length).toBe(0)
|
||||||
|
|
|
@ -5,10 +5,7 @@ export class Pointing extends StateNode {
|
||||||
|
|
||||||
override onEnter = () => {
|
override onEnter = () => {
|
||||||
this.editor.stopCameraAnimation()
|
this.editor.stopCameraAnimation()
|
||||||
this.editor.updateInstanceState(
|
this.editor.setCursor({ type: 'grabbing', rotation: 0 })
|
||||||
{ cursor: { type: 'grabbing', rotation: 0 } },
|
|
||||||
{ ephemeral: true }
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
override onLongPress: TLEventHandlers['onLongPress'] = () => {
|
override onLongPress: TLEventHandlers['onLongPress'] = () => {
|
||||||
|
|
|
@ -79,7 +79,7 @@ export class Brushing extends StateNode {
|
||||||
}
|
}
|
||||||
|
|
||||||
override onCancel?: TLCancelEvent | undefined = (info) => {
|
override onCancel?: TLCancelEvent | undefined = (info) => {
|
||||||
this.editor.setSelectedShapes(this.initialSelectedShapeIds, { squashing: true })
|
this.editor.setSelectedShapes(this.initialSelectedShapeIds)
|
||||||
this.parent.transition('idle', info)
|
this.parent.transition('idle', info)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -176,7 +176,7 @@ export class Brushing extends StateNode {
|
||||||
|
|
||||||
const current = editor.getSelectedShapeIds()
|
const current = editor.getSelectedShapeIds()
|
||||||
if (current.length !== results.size || current.some((id) => !results.has(id))) {
|
if (current.length !== results.size || current.some((id) => !results.has(id))) {
|
||||||
editor.setSelectedShapes(Array.from(results), { squashing: true })
|
editor.setSelectedShapes(Array.from(results))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -6,17 +6,14 @@ export class Idle extends StateNode {
|
||||||
static override id = 'idle'
|
static override id = 'idle'
|
||||||
|
|
||||||
override onEnter = () => {
|
override onEnter = () => {
|
||||||
this.editor.updateInstanceState(
|
this.editor.setCursor({ type: 'default', rotation: 0 })
|
||||||
{ cursor: { type: 'default', rotation: 0 } },
|
|
||||||
{ ephemeral: true }
|
|
||||||
)
|
|
||||||
|
|
||||||
const onlySelectedShape = this.editor.getOnlySelectedShape()
|
const onlySelectedShape = this.editor.getOnlySelectedShape()
|
||||||
|
|
||||||
// well this fucking sucks. what the fuck.
|
// well this fucking sucks. what the fuck.
|
||||||
// it's possible for a user to enter cropping, then undo
|
// it's possible for a user to enter cropping, then undo
|
||||||
// (which clears the cropping id) but still remain in this state.
|
// (which clears the cropping id) but still remain in this state.
|
||||||
this.editor.on('change-history', this.cleanupCroppingState)
|
this.editor.on('tick', this.cleanupCroppingState)
|
||||||
|
|
||||||
if (onlySelectedShape) {
|
if (onlySelectedShape) {
|
||||||
this.editor.mark('crop')
|
this.editor.mark('crop')
|
||||||
|
@ -25,12 +22,9 @@ export class Idle extends StateNode {
|
||||||
}
|
}
|
||||||
|
|
||||||
override onExit: TLExitEventHandler = () => {
|
override onExit: TLExitEventHandler = () => {
|
||||||
this.editor.updateInstanceState(
|
this.editor.setCursor({ type: 'default', rotation: 0 })
|
||||||
{ cursor: { type: 'default', rotation: 0 } },
|
|
||||||
{ ephemeral: true }
|
|
||||||
)
|
|
||||||
|
|
||||||
this.editor.off('change-history', this.cleanupCroppingState)
|
this.editor.off('tick', this.cleanupCroppingState)
|
||||||
}
|
}
|
||||||
|
|
||||||
override onCancel: TLEventHandlers['onCancel'] = () => {
|
override onCancel: TLEventHandlers['onCancel'] = () => {
|
||||||
|
|
|
@ -32,10 +32,7 @@ export class TranslatingCrop extends StateNode {
|
||||||
}
|
}
|
||||||
|
|
||||||
override onExit = () => {
|
override onExit = () => {
|
||||||
this.editor.updateInstanceState(
|
this.editor.setCursor({ type: 'default', rotation: 0 })
|
||||||
{ cursor: { type: 'default', rotation: 0 } },
|
|
||||||
{ ephemeral: true }
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
override onPointerMove = () => {
|
override onPointerMove = () => {
|
||||||
|
@ -102,7 +99,7 @@ export class TranslatingCrop extends StateNode {
|
||||||
const partial = getTranslateCroppedImageChange(this.editor, shape, delta)
|
const partial = getTranslateCroppedImageChange(this.editor, shape, delta)
|
||||||
|
|
||||||
if (partial) {
|
if (partial) {
|
||||||
this.editor.updateShapes([partial], { squashing: true })
|
this.editor.updateShapes([partial])
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -65,12 +65,7 @@ export class Cropping extends StateNode {
|
||||||
if (!selectedShape) return
|
if (!selectedShape) return
|
||||||
|
|
||||||
const cursorType = CursorTypeMap[this.info.handle!]
|
const cursorType = CursorTypeMap[this.info.handle!]
|
||||||
this.editor.updateInstanceState({
|
this.editor.setCursor({ type: cursorType, rotation: this.editor.getSelectionRotation() })
|
||||||
cursor: {
|
|
||||||
type: cursorType,
|
|
||||||
rotation: this.editor.getSelectionRotation(),
|
|
||||||
},
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private getDefaultCrop = (): TLImageShapeCrop => ({
|
private getDefaultCrop = (): TLImageShapeCrop => ({
|
||||||
|
@ -201,7 +196,7 @@ export class Cropping extends StateNode {
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
this.editor.updateShapes([partial], { squashing: true })
|
this.editor.updateShapes([partial])
|
||||||
this.updateCursor()
|
this.updateCursor()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -82,10 +82,7 @@ export class DraggingHandle extends StateNode {
|
||||||
this.initialPageRotation = this.initialPageTransform.rotation()
|
this.initialPageRotation = this.initialPageTransform.rotation()
|
||||||
this.initialPagePoint = this.editor.inputs.originPagePoint.clone()
|
this.initialPagePoint = this.editor.inputs.originPagePoint.clone()
|
||||||
|
|
||||||
this.editor.updateInstanceState(
|
this.editor.setCursor({ type: isCreating ? 'cross' : 'grabbing', rotation: 0 })
|
||||||
{ cursor: { type: isCreating ? 'cross' : 'grabbing', rotation: 0 } },
|
|
||||||
{ ephemeral: true }
|
|
||||||
)
|
|
||||||
|
|
||||||
const handles = this.editor.getShapeHandles(shape)!.sort(sortByIndex)
|
const handles = this.editor.getShapeHandles(shape)!.sort(sortByIndex)
|
||||||
const index = handles.findIndex((h) => h.id === info.handle.id)
|
const index = handles.findIndex((h) => h.id === info.handle.id)
|
||||||
|
@ -196,10 +193,7 @@ export class DraggingHandle extends StateNode {
|
||||||
this.editor.setHintingShapes([])
|
this.editor.setHintingShapes([])
|
||||||
this.editor.snaps.clearIndicators()
|
this.editor.snaps.clearIndicators()
|
||||||
|
|
||||||
this.editor.updateInstanceState(
|
this.editor.setCursor({ type: 'default', rotation: 0 })
|
||||||
{ cursor: { type: 'default', rotation: 0 } },
|
|
||||||
{ ephemeral: true }
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private complete() {
|
private complete() {
|
||||||
|
@ -312,7 +306,7 @@ export class DraggingHandle extends StateNode {
|
||||||
}
|
}
|
||||||
|
|
||||||
if (changes) {
|
if (changes) {
|
||||||
editor.updateShapes([next], { squashing: true })
|
editor.updateShapes([next])
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -39,10 +39,7 @@ export class Idle extends StateNode {
|
||||||
override onEnter = () => {
|
override onEnter = () => {
|
||||||
this.parent.setCurrentToolIdMask(undefined)
|
this.parent.setCurrentToolIdMask(undefined)
|
||||||
updateHoveredId(this.editor)
|
updateHoveredId(this.editor)
|
||||||
this.editor.updateInstanceState(
|
this.editor.setCursor({ type: 'default', rotation: 0 })
|
||||||
{ cursor: { type: 'default', rotation: 0 } },
|
|
||||||
{ ephemeral: true }
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
override onPointerMove: TLEventHandlers['onPointerMove'] = () => {
|
override onPointerMove: TLEventHandlers['onPointerMove'] = () => {
|
||||||
|
|
|
@ -62,10 +62,7 @@ export class PointingArrowLabel extends StateNode {
|
||||||
override onExit = () => {
|
override onExit = () => {
|
||||||
this.parent.setCurrentToolIdMask(undefined)
|
this.parent.setCurrentToolIdMask(undefined)
|
||||||
|
|
||||||
this.editor.updateInstanceState(
|
this.editor.setCursor({ type: 'default', rotation: 0 })
|
||||||
{ cursor: { type: 'default', rotation: 0 } },
|
|
||||||
{ ephemeral: true }
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private _labelDragOffset = new Vec(0, 0)
|
private _labelDragOffset = new Vec(0, 0)
|
||||||
|
@ -105,10 +102,11 @@ export class PointingArrowLabel extends StateNode {
|
||||||
}
|
}
|
||||||
|
|
||||||
this.didDrag = true
|
this.didDrag = true
|
||||||
this.editor.updateShape<TLArrowShape>(
|
this.editor.updateShape<TLArrowShape>({
|
||||||
{ id: shape.id, type: shape.type, props: { labelPosition: nextLabelPosition } },
|
id: shape.id,
|
||||||
{ squashing: true }
|
type: shape.type,
|
||||||
)
|
props: { labelPosition: nextLabelPosition },
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
override onPointerUp = () => {
|
override onPointerUp = () => {
|
||||||
|
|
|
@ -19,20 +19,12 @@ export class PointingCropHandle extends StateNode {
|
||||||
if (!selectedShape) return
|
if (!selectedShape) return
|
||||||
|
|
||||||
const cursorType = CursorTypeMap[this.info.handle!]
|
const cursorType = CursorTypeMap[this.info.handle!]
|
||||||
this.editor.updateInstanceState({
|
this.editor.setCursor({ type: cursorType, rotation: this.editor.getSelectionRotation() })
|
||||||
cursor: {
|
|
||||||
type: cursorType,
|
|
||||||
rotation: this.editor.getSelectionRotation(),
|
|
||||||
},
|
|
||||||
})
|
|
||||||
this.editor.setCroppingShape(selectedShape.id)
|
this.editor.setCroppingShape(selectedShape.id)
|
||||||
}
|
}
|
||||||
|
|
||||||
override onExit = () => {
|
override onExit = () => {
|
||||||
this.editor.updateInstanceState(
|
this.editor.setCursor({ type: 'default', rotation: 0 })
|
||||||
{ cursor: { type: 'default', rotation: 0 } },
|
|
||||||
{ ephemeral: true }
|
|
||||||
)
|
|
||||||
this.parent.setCurrentToolIdMask(undefined)
|
this.parent.setCurrentToolIdMask(undefined)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -32,18 +32,12 @@ export class PointingHandle extends StateNode {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
this.editor.updateInstanceState(
|
this.editor.setCursor({ type: 'grabbing', rotation: 0 })
|
||||||
{ cursor: { type: 'grabbing', rotation: 0 } },
|
|
||||||
{ ephemeral: true }
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
override onExit = () => {
|
override onExit = () => {
|
||||||
this.editor.setHintingShapes([])
|
this.editor.setHintingShapes([])
|
||||||
this.editor.updateInstanceState(
|
this.editor.setCursor({ type: 'default', rotation: 0 })
|
||||||
{ cursor: { type: 'default', rotation: 0 } },
|
|
||||||
{ ephemeral: true }
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
override onPointerUp: TLEventHandlers['onPointerUp'] = () => {
|
override onPointerUp: TLEventHandlers['onPointerUp'] = () => {
|
||||||
|
|
|
@ -34,11 +34,9 @@ export class PointingResizeHandle extends StateNode {
|
||||||
private updateCursor() {
|
private updateCursor() {
|
||||||
const selected = this.editor.getSelectedShapes()
|
const selected = this.editor.getSelectedShapes()
|
||||||
const cursorType = CursorTypeMap[this.info.handle!]
|
const cursorType = CursorTypeMap[this.info.handle!]
|
||||||
this.editor.updateInstanceState({
|
this.editor.setCursor({
|
||||||
cursor: {
|
type: cursorType,
|
||||||
type: cursorType,
|
rotation: selected.length === 1 ? this.editor.getSelectionRotation() : 0,
|
||||||
rotation: selected.length === 1 ? this.editor.getSelectionRotation() : 0,
|
|
||||||
},
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -11,11 +11,9 @@ export class PointingRotateHandle extends StateNode {
|
||||||
private info = {} as PointingRotateHandleInfo
|
private info = {} as PointingRotateHandleInfo
|
||||||
|
|
||||||
private updateCursor() {
|
private updateCursor() {
|
||||||
this.editor.updateInstanceState({
|
this.editor.setCursor({
|
||||||
cursor: {
|
type: CursorTypeMap[this.info.handle as RotateCorner],
|
||||||
type: CursorTypeMap[this.info.handle as RotateCorner],
|
rotation: this.editor.getSelectionRotation(),
|
||||||
rotation: this.editor.getSelectionRotation(),
|
|
||||||
},
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -27,10 +25,7 @@ export class PointingRotateHandle extends StateNode {
|
||||||
|
|
||||||
override onExit = () => {
|
override onExit = () => {
|
||||||
this.parent.setCurrentToolIdMask(undefined)
|
this.parent.setCurrentToolIdMask(undefined)
|
||||||
this.editor.updateInstanceState(
|
this.editor.setCursor({ type: 'default', rotation: 0 })
|
||||||
{ cursor: { type: 'default', rotation: 0 } },
|
|
||||||
{ ephemeral: true }
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
override onPointerMove: TLEventHandlers['onPointerMove'] = () => {
|
override onPointerMove: TLEventHandlers['onPointerMove'] = () => {
|
||||||
|
|
|
@ -61,10 +61,7 @@ export class Resizing extends StateNode {
|
||||||
if (isCreating) {
|
if (isCreating) {
|
||||||
this.markId = `creating:${this.editor.getOnlySelectedShape()!.id}`
|
this.markId = `creating:${this.editor.getOnlySelectedShape()!.id}`
|
||||||
|
|
||||||
this.editor.updateInstanceState(
|
this.editor.setCursor({ type: 'cross', rotation: 0 })
|
||||||
{ cursor: { type: 'cross', rotation: 0 } },
|
|
||||||
{ ephemeral: true }
|
|
||||||
)
|
|
||||||
} else {
|
} else {
|
||||||
this.markId = 'starting resizing'
|
this.markId = 'starting resizing'
|
||||||
this.editor.mark(this.markId)
|
this.editor.mark(this.markId)
|
||||||
|
@ -407,10 +404,7 @@ export class Resizing extends StateNode {
|
||||||
|
|
||||||
override onExit = () => {
|
override onExit = () => {
|
||||||
this.parent.setCurrentToolIdMask(undefined)
|
this.parent.setCurrentToolIdMask(undefined)
|
||||||
this.editor.updateInstanceState(
|
this.editor.setCursor({ type: 'default', rotation: 0 })
|
||||||
{ cursor: { type: 'default', rotation: 0 } },
|
|
||||||
{ ephemeral: true }
|
|
||||||
)
|
|
||||||
this.editor.snaps.clearIndicators()
|
this.editor.snaps.clearIndicators()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -51,11 +51,9 @@ export class Rotating extends StateNode {
|
||||||
})
|
})
|
||||||
|
|
||||||
// Update cursor
|
// Update cursor
|
||||||
this.editor.updateInstanceState({
|
this.editor.setCursor({
|
||||||
cursor: {
|
type: CursorTypeMap[this.info.handle as RotateCorner],
|
||||||
type: CursorTypeMap[this.info.handle as RotateCorner],
|
rotation: newSelectionRotation + this.snapshot.initialSelectionRotation,
|
||||||
rotation: newSelectionRotation + this.snapshot.initialSelectionRotation,
|
|
||||||
},
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -105,11 +103,9 @@ export class Rotating extends StateNode {
|
||||||
})
|
})
|
||||||
|
|
||||||
// Update cursor
|
// Update cursor
|
||||||
this.editor.updateInstanceState({
|
this.editor.setCursor({
|
||||||
cursor: {
|
type: CursorTypeMap[this.info.handle as RotateCorner],
|
||||||
type: CursorTypeMap[this.info.handle as RotateCorner],
|
rotation: newSelectionRotation + this.snapshot.initialSelectionRotation,
|
||||||
rotation: newSelectionRotation + this.snapshot.initialSelectionRotation,
|
|
||||||
},
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -164,7 +164,7 @@ export class ScribbleBrushing extends StateNode {
|
||||||
shiftKey ? [...newlySelectedShapeIds, ...initialSelectedShapeIds] : [...newlySelectedShapeIds]
|
shiftKey ? [...newlySelectedShapeIds, ...initialSelectedShapeIds] : [...newlySelectedShapeIds]
|
||||||
)
|
)
|
||||||
if (current.length !== next.size || current.some((id) => !next.has(id))) {
|
if (current.length !== next.size || current.some((id) => !next.has(id))) {
|
||||||
this.editor.setSelectedShapes(Array.from(next), { squashing: true })
|
this.editor.setSelectedShapes(Array.from(next))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -174,7 +174,7 @@ export class ScribbleBrushing extends StateNode {
|
||||||
}
|
}
|
||||||
|
|
||||||
private cancel() {
|
private cancel() {
|
||||||
this.editor.setSelectedShapes([...this.initialSelectedShapeIds], { squashing: true })
|
this.editor.setSelectedShapes([...this.initialSelectedShapeIds])
|
||||||
this.parent.transition('idle')
|
this.parent.transition('idle')
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -505,7 +505,6 @@ export function moveShapesToPoint({
|
||||||
y: newLocalPoint.y,
|
y: newLocalPoint.y,
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
),
|
)
|
||||||
{ squashing: true }
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
@ -19,10 +19,7 @@ export class ZoomTool extends StateNode {
|
||||||
|
|
||||||
override onExit = () => {
|
override onExit = () => {
|
||||||
this.parent.setCurrentToolIdMask(undefined)
|
this.parent.setCurrentToolIdMask(undefined)
|
||||||
this.editor.updateInstanceState(
|
this.editor.updateInstanceState({ zoomBrush: null, cursor: { type: 'default', rotation: 0 } })
|
||||||
{ zoomBrush: null, cursor: { type: 'default', rotation: 0 } },
|
|
||||||
{ ephemeral: true }
|
|
||||||
)
|
|
||||||
this.parent.setCurrentToolIdMask(undefined)
|
this.parent.setCurrentToolIdMask(undefined)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -53,15 +50,9 @@ export class ZoomTool extends StateNode {
|
||||||
|
|
||||||
private updateCursor() {
|
private updateCursor() {
|
||||||
if (this.editor.inputs.altKey) {
|
if (this.editor.inputs.altKey) {
|
||||||
this.editor.updateInstanceState(
|
this.editor.setCursor({ type: 'zoom-out', rotation: 0 })
|
||||||
{ cursor: { type: 'zoom-out', rotation: 0 } },
|
|
||||||
{ ephemeral: true }
|
|
||||||
)
|
|
||||||
} else {
|
} else {
|
||||||
this.editor.updateInstanceState(
|
this.editor.setCursor({ type: 'zoom-in', rotation: 0 })
|
||||||
{ cursor: { type: 'zoom-in', rotation: 0 } },
|
|
||||||
{ ephemeral: true }
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -37,7 +37,7 @@ export function MobileStylePanel() {
|
||||||
const handleStylesOpenChange = useCallback(
|
const handleStylesOpenChange = useCallback(
|
||||||
(isOpen: boolean) => {
|
(isOpen: boolean) => {
|
||||||
if (!isOpen) {
|
if (!isOpen) {
|
||||||
editor.updateInstanceState({ isChangingStyle: false }, { ephemeral: true })
|
editor.updateInstanceState({ isChangingStyle: false })
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[editor]
|
[editor]
|
||||||
|
|
|
@ -15,17 +15,13 @@ export const PageItemInput = function PageItemInput({
|
||||||
|
|
||||||
const rInput = useRef<HTMLInputElement | null>(null)
|
const rInput = useRef<HTMLInputElement | null>(null)
|
||||||
|
|
||||||
|
const handleFocus = useCallback(() => {
|
||||||
|
editor.mark('rename page')
|
||||||
|
}, [editor])
|
||||||
|
|
||||||
const handleChange = useCallback(
|
const handleChange = useCallback(
|
||||||
(value: string) => {
|
(value: string) => {
|
||||||
editor.renamePage(id, value ? value : 'New Page', { ephemeral: true })
|
editor.renamePage(id, value || 'New Page')
|
||||||
},
|
|
||||||
[editor, id]
|
|
||||||
)
|
|
||||||
|
|
||||||
const handleComplete = useCallback(
|
|
||||||
(value: string) => {
|
|
||||||
editor.mark('rename page')
|
|
||||||
editor.renamePage(id, value || 'New Page', { ephemeral: false })
|
|
||||||
},
|
},
|
||||||
[editor, id]
|
[editor, id]
|
||||||
)
|
)
|
||||||
|
@ -36,8 +32,7 @@ export const PageItemInput = function PageItemInput({
|
||||||
ref={(el) => (rInput.current = el)}
|
ref={(el) => (rInput.current = el)}
|
||||||
defaultValue={name}
|
defaultValue={name}
|
||||||
onValueChange={handleChange}
|
onValueChange={handleChange}
|
||||||
onComplete={handleComplete}
|
onFocus={handleFocus}
|
||||||
onCancel={handleComplete}
|
|
||||||
shouldManuallyMaintainScrollPositionWhenFocused
|
shouldManuallyMaintainScrollPositionWhenFocused
|
||||||
autofocus={isCurrentPage}
|
autofocus={isCurrentPage}
|
||||||
autoselect
|
autoselect
|
||||||
|
|
|
@ -21,7 +21,7 @@ export const DefaultStylePanel = memo(function DefaultStylePanel({
|
||||||
|
|
||||||
const handlePointerOut = useCallback(() => {
|
const handlePointerOut = useCallback(() => {
|
||||||
if (!isMobile) {
|
if (!isMobile) {
|
||||||
editor.updateInstanceState({ isChangingStyle: false }, { ephemeral: true })
|
editor.updateInstanceState({ isChangingStyle: false })
|
||||||
}
|
}
|
||||||
}, [editor, isMobile])
|
}, [editor, isMobile])
|
||||||
|
|
||||||
|
|
|
@ -78,13 +78,13 @@ function useStyleChangeCallback() {
|
||||||
|
|
||||||
return React.useMemo(
|
return React.useMemo(
|
||||||
() =>
|
() =>
|
||||||
function handleStyleChange<T>(style: StyleProp<T>, value: T, squashing: boolean) {
|
function handleStyleChange<T>(style: StyleProp<T>, value: T) {
|
||||||
editor.batch(() => {
|
editor.batch(() => {
|
||||||
if (editor.isIn('select')) {
|
if (editor.isIn('select')) {
|
||||||
editor.setStyleForSelectedShapes(style, value, { squashing })
|
editor.setStyleForSelectedShapes(style, value)
|
||||||
}
|
}
|
||||||
editor.setStyleForNextShapes(style, value, { squashing })
|
editor.setStyleForNextShapes(style, value)
|
||||||
editor.updateInstanceState({ isChangingStyle: true }, { ephemeral: true })
|
editor.updateInstanceState({ isChangingStyle: true })
|
||||||
})
|
})
|
||||||
|
|
||||||
trackEvent('set-style', { source: 'style-panel', id: style.id, value: value as string })
|
trackEvent('set-style', { source: 'style-panel', id: style.id, value: value as string })
|
||||||
|
@ -165,8 +165,8 @@ export function CommonStylePickerSet({
|
||||||
style={DefaultSizeStyle}
|
style={DefaultSizeStyle}
|
||||||
items={STYLES.size}
|
items={STYLES.size}
|
||||||
value={size}
|
value={size}
|
||||||
onValueChange={(style, value, squashing) => {
|
onValueChange={(style, value) => {
|
||||||
handleValueChange(style, value, squashing)
|
handleValueChange(style, value)
|
||||||
const selectedShapeIds = editor.getSelectedShapeIds()
|
const selectedShapeIds = editor.getSelectedShapeIds()
|
||||||
if (selectedShapeIds.length > 0) {
|
if (selectedShapeIds.length > 0) {
|
||||||
kickoutOccludedShapes(editor, selectedShapeIds)
|
kickoutOccludedShapes(editor, selectedShapeIds)
|
||||||
|
@ -333,14 +333,14 @@ export function OpacitySlider() {
|
||||||
const msg = useTranslation()
|
const msg = useTranslation()
|
||||||
|
|
||||||
const handleOpacityValueChange = React.useCallback(
|
const handleOpacityValueChange = React.useCallback(
|
||||||
(value: number, squashing: boolean) => {
|
(value: number) => {
|
||||||
const item = tldrawSupportedOpacities[value]
|
const item = tldrawSupportedOpacities[value]
|
||||||
editor.batch(() => {
|
editor.batch(() => {
|
||||||
if (editor.isIn('select')) {
|
if (editor.isIn('select')) {
|
||||||
editor.setOpacityForSelectedShapes(item, { squashing })
|
editor.setOpacityForSelectedShapes(item)
|
||||||
}
|
}
|
||||||
editor.setOpacityForNextShapes(item, { squashing })
|
editor.setOpacityForNextShapes(item)
|
||||||
editor.updateInstanceState({ isChangingStyle: true }, { ephemeral: true })
|
editor.updateInstanceState({ isChangingStyle: true })
|
||||||
})
|
})
|
||||||
|
|
||||||
trackEvent('set-style', { source: 'style-panel', id: 'opacity', value })
|
trackEvent('set-style', { source: 'style-panel', id: 'opacity', value })
|
||||||
|
|
|
@ -24,7 +24,7 @@ interface DoubleDropdownPickerProps<T extends string> {
|
||||||
styleB: StyleProp<T>
|
styleB: StyleProp<T>
|
||||||
valueA: SharedStyle<T>
|
valueA: SharedStyle<T>
|
||||||
valueB: SharedStyle<T>
|
valueB: SharedStyle<T>
|
||||||
onValueChange: (style: StyleProp<T>, value: T, squashing: boolean) => void
|
onValueChange: (style: StyleProp<T>, value: T) => void
|
||||||
}
|
}
|
||||||
|
|
||||||
function _DoubleDropdownPicker<T extends string>({
|
function _DoubleDropdownPicker<T extends string>({
|
||||||
|
@ -88,7 +88,7 @@ function _DoubleDropdownPicker<T extends string>({
|
||||||
<TldrawUiButton
|
<TldrawUiButton
|
||||||
type="icon"
|
type="icon"
|
||||||
key={item.value}
|
key={item.value}
|
||||||
onClick={() => onValueChange(styleA, item.value, false)}
|
onClick={() => onValueChange(styleA, item.value)}
|
||||||
title={`${msg(labelA)} — ${msg(`${uiTypeA}-style.${item.value}`)}`}
|
title={`${msg(labelA)} — ${msg(`${uiTypeA}-style.${item.value}`)}`}
|
||||||
>
|
>
|
||||||
<TldrawUiButtonIcon icon={item.icon} invertIcon />
|
<TldrawUiButtonIcon icon={item.icon} invertIcon />
|
||||||
|
@ -124,7 +124,7 @@ function _DoubleDropdownPicker<T extends string>({
|
||||||
type="icon"
|
type="icon"
|
||||||
title={`${msg(labelB)} — ${msg(`${uiTypeB}-style.${item.value}` as TLUiTranslationKey)}`}
|
title={`${msg(labelB)} — ${msg(`${uiTypeB}-style.${item.value}` as TLUiTranslationKey)}`}
|
||||||
data-testid={`style.${uiTypeB}.${item.value}`}
|
data-testid={`style.${uiTypeB}.${item.value}`}
|
||||||
onClick={() => onValueChange(styleB, item.value, false)}
|
onClick={() => onValueChange(styleB, item.value)}
|
||||||
>
|
>
|
||||||
<TldrawUiButtonIcon icon={item.icon} />
|
<TldrawUiButtonIcon icon={item.icon} />
|
||||||
</TldrawUiButton>
|
</TldrawUiButton>
|
||||||
|
|
|
@ -22,7 +22,7 @@ interface DropdownPickerProps<T extends string> {
|
||||||
value: SharedStyle<T>
|
value: SharedStyle<T>
|
||||||
items: StyleValuesForUi<T>
|
items: StyleValuesForUi<T>
|
||||||
type: TLUiButtonProps['type']
|
type: TLUiButtonProps['type']
|
||||||
onValueChange: (style: StyleProp<T>, value: T, squashing: boolean) => void
|
onValueChange: (style: StyleProp<T>, value: T) => void
|
||||||
}
|
}
|
||||||
|
|
||||||
function _DropdownPicker<T extends string>({
|
function _DropdownPicker<T extends string>({
|
||||||
|
@ -68,7 +68,7 @@ function _DropdownPicker<T extends string>({
|
||||||
title={msg(`${uiType}-style.${item.value}` as TLUiTranslationKey)}
|
title={msg(`${uiType}-style.${item.value}` as TLUiTranslationKey)}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
editor.mark('select style dropdown item')
|
editor.mark('select style dropdown item')
|
||||||
onValueChange(style, item.value, false)
|
onValueChange(style, item.value)
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<TldrawUiButtonIcon icon={item.icon} />
|
<TldrawUiButtonIcon icon={item.icon} />
|
||||||
|
|
|
@ -22,7 +22,7 @@ export interface TLUiButtonPickerProps<T extends string> {
|
||||||
value: SharedStyle<T>
|
value: SharedStyle<T>
|
||||||
items: StyleValuesForUi<T>
|
items: StyleValuesForUi<T>
|
||||||
theme: TLDefaultColorTheme
|
theme: TLDefaultColorTheme
|
||||||
onValueChange: (style: StyleProp<T>, value: T, squashing: boolean) => void
|
onValueChange: (style: StyleProp<T>, value: T) => void
|
||||||
}
|
}
|
||||||
|
|
||||||
function _TldrawUiButtonPicker<T extends string>(props: TLUiButtonPickerProps<T>) {
|
function _TldrawUiButtonPicker<T extends string>(props: TLUiButtonPickerProps<T>) {
|
||||||
|
@ -57,14 +57,14 @@ function _TldrawUiButtonPicker<T extends string>(props: TLUiButtonPickerProps<T>
|
||||||
if (value.type === 'shared' && value.value === id) return
|
if (value.type === 'shared' && value.value === id) return
|
||||||
|
|
||||||
editor.mark('point picker item')
|
editor.mark('point picker item')
|
||||||
onValueChange(style, id as T, false)
|
onValueChange(style, id as T)
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleButtonPointerDown = (e: React.PointerEvent<HTMLButtonElement>) => {
|
const handleButtonPointerDown = (e: React.PointerEvent<HTMLButtonElement>) => {
|
||||||
const { id } = e.currentTarget.dataset
|
const { id } = e.currentTarget.dataset
|
||||||
|
|
||||||
editor.mark('point picker item')
|
editor.mark('point picker item')
|
||||||
onValueChange(style, id as T, true)
|
onValueChange(style, id as T)
|
||||||
|
|
||||||
rPointing.current = true
|
rPointing.current = true
|
||||||
window.addEventListener('pointerup', handlePointerUp) // see TLD-658
|
window.addEventListener('pointerup', handlePointerUp) // see TLD-658
|
||||||
|
@ -74,14 +74,14 @@ function _TldrawUiButtonPicker<T extends string>(props: TLUiButtonPickerProps<T>
|
||||||
if (!rPointing.current) return
|
if (!rPointing.current) return
|
||||||
|
|
||||||
const { id } = e.currentTarget.dataset
|
const { id } = e.currentTarget.dataset
|
||||||
onValueChange(style, id as T, true)
|
onValueChange(style, id as T)
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleButtonPointerUp = (e: React.PointerEvent<HTMLButtonElement>) => {
|
const handleButtonPointerUp = (e: React.PointerEvent<HTMLButtonElement>) => {
|
||||||
const { id } = e.currentTarget.dataset
|
const { id } = e.currentTarget.dataset
|
||||||
if (value.type === 'shared' && value.value === id) return
|
if (value.type === 'shared' && value.value === id) return
|
||||||
|
|
||||||
onValueChange(style, id as T, false)
|
onValueChange(style, id as T)
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
|
|
@ -21,6 +21,7 @@ export interface TLUiInputProps {
|
||||||
onValueChange?: (value: string) => void
|
onValueChange?: (value: string) => void
|
||||||
onCancel?: (value: string) => void
|
onCancel?: (value: string) => void
|
||||||
onBlur?: (value: string) => void
|
onBlur?: (value: string) => void
|
||||||
|
onFocus?: () => void
|
||||||
className?: string
|
className?: string
|
||||||
/**
|
/**
|
||||||
* Usually on iOS when you focus an input, the browser will adjust the viewport to bring the input
|
* Usually on iOS when you focus an input, the browser will adjust the viewport to bring the input
|
||||||
|
@ -49,6 +50,7 @@ export const TldrawUiInput = React.forwardRef<HTMLInputElement, TLUiInputProps>(
|
||||||
onComplete,
|
onComplete,
|
||||||
onValueChange,
|
onValueChange,
|
||||||
onCancel,
|
onCancel,
|
||||||
|
onFocus,
|
||||||
onBlur,
|
onBlur,
|
||||||
shouldManuallyMaintainScrollPositionWhenFocused = false,
|
shouldManuallyMaintainScrollPositionWhenFocused = false,
|
||||||
children,
|
children,
|
||||||
|
@ -77,8 +79,9 @@ export const TldrawUiInput = React.forwardRef<HTMLInputElement, TLUiInputProps>(
|
||||||
elm.select()
|
elm.select()
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
onFocus?.()
|
||||||
},
|
},
|
||||||
[autoselect]
|
[autoselect, onFocus]
|
||||||
)
|
)
|
||||||
|
|
||||||
const handleChange = React.useCallback(
|
const handleChange = React.useCallback(
|
||||||
|
|
|
@ -10,7 +10,7 @@ export interface TLUiSliderProps {
|
||||||
value: number | null
|
value: number | null
|
||||||
label: string
|
label: string
|
||||||
title: string
|
title: string
|
||||||
onValueChange: (value: number, squashing: boolean) => void
|
onValueChange: (value: number) => void
|
||||||
'data-testid'?: string
|
'data-testid'?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -22,7 +22,7 @@ export const TldrawUiSlider = memo(function Slider(props: TLUiSliderProps) {
|
||||||
|
|
||||||
const handleValueChange = useCallback(
|
const handleValueChange = useCallback(
|
||||||
(value: number[]) => {
|
(value: number[]) => {
|
||||||
onValueChange(value[0], true)
|
onValueChange(value[0])
|
||||||
},
|
},
|
||||||
[onValueChange]
|
[onValueChange]
|
||||||
)
|
)
|
||||||
|
@ -33,7 +33,7 @@ export const TldrawUiSlider = memo(function Slider(props: TLUiSliderProps) {
|
||||||
|
|
||||||
const handlePointerUp = useCallback(() => {
|
const handlePointerUp = useCallback(() => {
|
||||||
if (!value) return
|
if (!value) return
|
||||||
onValueChange(value, false)
|
onValueChange(value)
|
||||||
}, [value, onValueChange])
|
}, [value, onValueChange])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|
|
@ -1164,12 +1164,9 @@ export function ActionsProvider({ overrides, children }: ActionsProviderProps) {
|
||||||
readonlyOk: true,
|
readonlyOk: true,
|
||||||
onSelect(source) {
|
onSelect(source) {
|
||||||
trackEvent('toggle-transparent', { source })
|
trackEvent('toggle-transparent', { source })
|
||||||
editor.updateInstanceState(
|
editor.updateInstanceState({
|
||||||
{
|
exportBackground: !editor.getInstanceState().exportBackground,
|
||||||
exportBackground: !editor.getInstanceState().exportBackground,
|
})
|
||||||
},
|
|
||||||
{ ephemeral: true }
|
|
||||||
)
|
|
||||||
},
|
},
|
||||||
checkbox: true,
|
checkbox: true,
|
||||||
},
|
},
|
||||||
|
@ -1326,10 +1323,10 @@ export function ActionsProvider({ overrides, children }: ActionsProviderProps) {
|
||||||
editor.batch(() => {
|
editor.batch(() => {
|
||||||
editor.mark('change-color')
|
editor.mark('change-color')
|
||||||
if (editor.isIn('select')) {
|
if (editor.isIn('select')) {
|
||||||
editor.setStyleForSelectedShapes(style, 'white', { squashing: false })
|
editor.setStyleForSelectedShapes(style, 'white')
|
||||||
}
|
}
|
||||||
editor.setStyleForNextShapes(style, 'white', { squashing: false })
|
editor.setStyleForNextShapes(style, 'white')
|
||||||
editor.updateInstanceState({ isChangingStyle: true }, { ephemeral: true })
|
editor.updateInstanceState({ isChangingStyle: true })
|
||||||
})
|
})
|
||||||
trackEvent('set-style', { source, id: style.id, value: 'white' })
|
trackEvent('set-style', { source, id: style.id, value: 'white' })
|
||||||
},
|
},
|
||||||
|
|
|
@ -24,9 +24,9 @@ export function pasteTldrawContent(editor: Editor, clipboard: TLContent, point?:
|
||||||
seletionBoundsBefore?.collides(selectedBoundsAfter)
|
seletionBoundsBefore?.collides(selectedBoundsAfter)
|
||||||
) {
|
) {
|
||||||
// Creates a 'puff' to show a paste has happened.
|
// Creates a 'puff' to show a paste has happened.
|
||||||
editor.updateInstanceState({ isChangingStyle: true }, { ephemeral: true })
|
editor.updateInstanceState({ isChangingStyle: true })
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
editor.updateInstanceState({ isChangingStyle: false }, { ephemeral: true })
|
editor.updateInstanceState({ isChangingStyle: false })
|
||||||
}, 150)
|
}, 150)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -134,7 +134,7 @@ export function usePrint() {
|
||||||
}
|
}
|
||||||
|
|
||||||
const afterPrintHandler = () => {
|
const afterPrintHandler = () => {
|
||||||
editor.once('change-history', () => {
|
editor.once('tick', () => {
|
||||||
clearElements(el, style)
|
clearElements(el, style)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
|
@ -102,15 +102,7 @@ export function ToolsProvider({ overrides, children }: TLUiToolsProviderProps) {
|
||||||
icon: ('geo-' + id) as TLUiIconType,
|
icon: ('geo-' + id) as TLUiIconType,
|
||||||
onSelect(source: TLUiEventSource) {
|
onSelect(source: TLUiEventSource) {
|
||||||
editor.batch(() => {
|
editor.batch(() => {
|
||||||
editor.updateInstanceState(
|
editor.setStyleForNextShapes(GeoShapeGeoStyle, id)
|
||||||
{
|
|
||||||
stylesForNextShape: {
|
|
||||||
...editor.getInstanceState().stylesForNextShape,
|
|
||||||
[GeoShapeGeoStyle.id]: id,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{ ephemeral: true }
|
|
||||||
)
|
|
||||||
editor.setCurrentTool('geo')
|
editor.setCurrentTool('geo')
|
||||||
trackEvent('select-tool', { source, id: `geo-${id}` })
|
trackEvent('select-tool', { source, id: `geo-${id}` })
|
||||||
})
|
})
|
||||||
|
|
|
@ -179,10 +179,7 @@ describe('<TldrawEditor />', () => {
|
||||||
|
|
||||||
expect(editor).toBeTruthy()
|
expect(editor).toBeTruthy()
|
||||||
await act(async () => {
|
await act(async () => {
|
||||||
editor.updateInstanceState(
|
editor.updateInstanceState({ screenBounds: { x: 0, y: 0, w: 1080, h: 720 } })
|
||||||
{ screenBounds: { x: 0, y: 0, w: 1080, h: 720 } },
|
|
||||||
{ ephemeral: true, squashing: true }
|
|
||||||
)
|
|
||||||
})
|
})
|
||||||
|
|
||||||
const id = createShapeId()
|
const id = createShapeId()
|
||||||
|
@ -299,10 +296,7 @@ describe('Custom shapes', () => {
|
||||||
|
|
||||||
expect(editor).toBeTruthy()
|
expect(editor).toBeTruthy()
|
||||||
await act(async () => {
|
await act(async () => {
|
||||||
editor.updateInstanceState(
|
editor.updateInstanceState({ screenBounds: { x: 0, y: 0, w: 1080, h: 720 } })
|
||||||
{ screenBounds: { x: 0, y: 0, w: 1080, h: 720 } },
|
|
||||||
{ ephemeral: true, squashing: true }
|
|
||||||
)
|
|
||||||
})
|
})
|
||||||
|
|
||||||
expect(editor.shapeUtils.card).toBeTruthy()
|
expect(editor.shapeUtils.card).toBeTruthy()
|
||||||
|
|
|
@ -23,7 +23,7 @@ describe('when less than two shapes are selected', () => {
|
||||||
editor.setSelectedShapes([ids.boxB])
|
editor.setSelectedShapes([ids.boxB])
|
||||||
|
|
||||||
const fn = jest.fn()
|
const fn = jest.fn()
|
||||||
editor.on('update', fn)
|
editor.store.listen(fn)
|
||||||
editor.alignShapes(editor.getSelectedShapeIds(), 'top')
|
editor.alignShapes(editor.getSelectedShapeIds(), 'top')
|
||||||
jest.advanceTimersByTime(1000)
|
jest.advanceTimersByTime(1000)
|
||||||
expect(fn).not.toHaveBeenCalled()
|
expect(fn).not.toHaveBeenCalled()
|
||||||
|
|
|
@ -46,7 +46,7 @@ describe('distributeShapes command', () => {
|
||||||
it('does nothing', () => {
|
it('does nothing', () => {
|
||||||
editor.setSelectedShapes([ids.boxA, ids.boxB])
|
editor.setSelectedShapes([ids.boxA, ids.boxB])
|
||||||
const fn = jest.fn()
|
const fn = jest.fn()
|
||||||
editor.on('change-history', fn)
|
editor.store.listen(fn)
|
||||||
editor.distributeShapes(editor.getSelectedShapeIds(), 'horizontal')
|
editor.distributeShapes(editor.getSelectedShapeIds(), 'horizontal')
|
||||||
jest.advanceTimersByTime(1000)
|
jest.advanceTimersByTime(1000)
|
||||||
expect(fn).not.toHaveBeenCalled()
|
expect(fn).not.toHaveBeenCalled()
|
||||||
|
|
|
@ -60,8 +60,9 @@ describe('Editor.moveShapesToPage', () => {
|
||||||
|
|
||||||
it('Adds undo items', () => {
|
it('Adds undo items', () => {
|
||||||
editor.history.clear()
|
editor.history.clear()
|
||||||
|
expect(editor.history.getNumUndos()).toBe(0)
|
||||||
editor.moveShapesToPage([ids.box1], ids.page2)
|
editor.moveShapesToPage([ids.box1], ids.page2)
|
||||||
expect(editor.history.getNumUndos()).toBeGreaterThan(1)
|
expect(editor.history.getNumUndos()).toBe(1)
|
||||||
})
|
})
|
||||||
|
|
||||||
it('Does nothing on an empty ids array', () => {
|
it('Does nothing on an empty ids array', () => {
|
||||||
|
|
|
@ -1,13 +0,0 @@
|
||||||
import { TestEditor } from '../TestEditor'
|
|
||||||
|
|
||||||
let editor: TestEditor
|
|
||||||
|
|
||||||
beforeEach(() => {
|
|
||||||
editor = new TestEditor()
|
|
||||||
})
|
|
||||||
|
|
||||||
describe('squashing', () => {
|
|
||||||
editor
|
|
||||||
|
|
||||||
it.todo('squashes')
|
|
||||||
})
|
|
|
@ -52,7 +52,7 @@ describe('distributeShapes command', () => {
|
||||||
it('does nothing', () => {
|
it('does nothing', () => {
|
||||||
editor.setSelectedShapes([ids.boxA, ids.boxB])
|
editor.setSelectedShapes([ids.boxA, ids.boxB])
|
||||||
const fn = jest.fn()
|
const fn = jest.fn()
|
||||||
editor.on('change-history', fn)
|
editor.store.listen(fn)
|
||||||
editor.stackShapes(editor.getSelectedShapeIds(), 'horizontal', 0)
|
editor.stackShapes(editor.getSelectedShapeIds(), 'horizontal', 0)
|
||||||
jest.advanceTimersByTime(1000)
|
jest.advanceTimersByTime(1000)
|
||||||
expect(fn).not.toHaveBeenCalled()
|
expect(fn).not.toHaveBeenCalled()
|
||||||
|
|
|
@ -27,7 +27,7 @@ describe('when less than two shapes are selected', () => {
|
||||||
it('does nothing', () => {
|
it('does nothing', () => {
|
||||||
editor.setSelectedShapes([ids.boxB])
|
editor.setSelectedShapes([ids.boxB])
|
||||||
const fn = jest.fn()
|
const fn = jest.fn()
|
||||||
editor.on('change-history', fn)
|
editor.store.listen(fn)
|
||||||
editor.stretchShapes(editor.getSelectedShapeIds(), 'horizontal')
|
editor.stretchShapes(editor.getSelectedShapeIds(), 'horizontal')
|
||||||
jest.advanceTimersByTime(1000)
|
jest.advanceTimersByTime(1000)
|
||||||
|
|
||||||
|
|
129
packages/tldraw/src/test/testutils/pretty.ts
Normal file
129
packages/tldraw/src/test/testutils/pretty.ts
Normal file
|
@ -0,0 +1,129 @@
|
||||||
|
/* eslint-disable no-console */
|
||||||
|
import { HistoryManager, RecordsDiff } from '@tldraw/editor'
|
||||||
|
// eslint-disable-next-line import/no-extraneous-dependencies
|
||||||
|
import { DiffOptions, diff as jestDiff } from 'jest-diff'
|
||||||
|
import { inspect } from 'util'
|
||||||
|
|
||||||
|
class Printer {
|
||||||
|
private _output = ''
|
||||||
|
private _indent = 0
|
||||||
|
|
||||||
|
appendLines(str: string) {
|
||||||
|
const indent = ' '.repeat(this._indent)
|
||||||
|
this._output +=
|
||||||
|
str
|
||||||
|
.split('\n')
|
||||||
|
.map((line) => indent + line)
|
||||||
|
.join('\n') + '\n'
|
||||||
|
}
|
||||||
|
|
||||||
|
indent() {
|
||||||
|
this._indent++
|
||||||
|
}
|
||||||
|
dedent() {
|
||||||
|
this._indent--
|
||||||
|
}
|
||||||
|
|
||||||
|
log(...args: any[]) {
|
||||||
|
this.appendLines(args.map((arg) => (typeof arg === 'string' ? arg : inspect(arg))).join(' '))
|
||||||
|
}
|
||||||
|
|
||||||
|
print() {
|
||||||
|
console.log(this._output)
|
||||||
|
}
|
||||||
|
|
||||||
|
get() {
|
||||||
|
return this._output
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function prettyPrintDiff(diff: RecordsDiff<any>, opts?: DiffOptions) {
|
||||||
|
const before = {} as Record<string, any>
|
||||||
|
const after = {} as Record<string, any>
|
||||||
|
|
||||||
|
for (const added of Object.values(diff.added)) {
|
||||||
|
after[added.id] = added
|
||||||
|
}
|
||||||
|
for (const [from, to] of Object.values(diff.updated)) {
|
||||||
|
before[from.id] = from
|
||||||
|
after[to.id] = to
|
||||||
|
}
|
||||||
|
for (const removed of Object.values(diff.removed)) {
|
||||||
|
before[removed.id] = removed
|
||||||
|
}
|
||||||
|
|
||||||
|
const prettyDiff = jestDiff(after, before, {
|
||||||
|
aAnnotation: 'After',
|
||||||
|
bAnnotation: 'Before',
|
||||||
|
aIndicator: '+',
|
||||||
|
bIndicator: '-',
|
||||||
|
...opts,
|
||||||
|
})
|
||||||
|
|
||||||
|
if (prettyDiff?.includes('Compared values have no visual difference.')) {
|
||||||
|
const p = new Printer()
|
||||||
|
p.log('Before & after have no visual difference.')
|
||||||
|
p.log('Diff:')
|
||||||
|
p.indent()
|
||||||
|
p.log(diff)
|
||||||
|
return p.get()
|
||||||
|
}
|
||||||
|
|
||||||
|
return prettyDiff
|
||||||
|
}
|
||||||
|
|
||||||
|
export function logHistory(history: HistoryManager<any>) {
|
||||||
|
const { undos, redos, pendingDiff } = history.debug()
|
||||||
|
const p = new Printer()
|
||||||
|
p.log('=== History ===')
|
||||||
|
p.indent()
|
||||||
|
|
||||||
|
p.log('Pending diff:')
|
||||||
|
p.indent()
|
||||||
|
if (pendingDiff.isEmpty) {
|
||||||
|
p.log('(empty)')
|
||||||
|
} else {
|
||||||
|
p.log(prettyPrintDiff(pendingDiff.diff))
|
||||||
|
}
|
||||||
|
p.log('')
|
||||||
|
p.dedent()
|
||||||
|
|
||||||
|
p.log('Undos:')
|
||||||
|
p.indent()
|
||||||
|
if (undos.length === 0) {
|
||||||
|
p.log('(empty)\n')
|
||||||
|
}
|
||||||
|
for (const undo of undos) {
|
||||||
|
if (!undo) continue
|
||||||
|
if (undo.type === 'stop') {
|
||||||
|
p.log('Stop', undo.id)
|
||||||
|
} else {
|
||||||
|
p.log('- Diff')
|
||||||
|
p.indent()
|
||||||
|
p.log(prettyPrintDiff(undo.diff))
|
||||||
|
p.dedent()
|
||||||
|
}
|
||||||
|
p.log('')
|
||||||
|
}
|
||||||
|
p.dedent()
|
||||||
|
|
||||||
|
p.log('Redos:')
|
||||||
|
p.indent()
|
||||||
|
if (redos.length === 0) {
|
||||||
|
p.log('(empty)\n')
|
||||||
|
}
|
||||||
|
for (const redo of redos) {
|
||||||
|
if (!redo) continue
|
||||||
|
if (redo.type === 'stop') {
|
||||||
|
p.log('> Stop', redo.id)
|
||||||
|
} else {
|
||||||
|
p.log('- Diff')
|
||||||
|
p.indent()
|
||||||
|
p.log(prettyPrintDiff(redo.diff))
|
||||||
|
p.dedent()
|
||||||
|
}
|
||||||
|
p.log('')
|
||||||
|
}
|
||||||
|
|
||||||
|
p.print()
|
||||||
|
}
|
|
@ -27,7 +27,6 @@ export interface TLInstance extends BaseRecord<'instance', TLInstanceId> {
|
||||||
currentPageId: TLPageId
|
currentPageId: TLPageId
|
||||||
opacityForNextShape: TLOpacityType
|
opacityForNextShape: TLOpacityType
|
||||||
stylesForNextShape: Record<string, unknown>
|
stylesForNextShape: Record<string, unknown>
|
||||||
// ephemeral
|
|
||||||
followingUserId: string | null
|
followingUserId: string | null
|
||||||
highlightedUserIds: string[]
|
highlightedUserIds: string[]
|
||||||
brush: BoxModel | null
|
brush: BoxModel | null
|
||||||
|
@ -129,6 +128,38 @@ export function createInstanceRecordType(stylesById: Map<string, StyleProp<unkno
|
||||||
return createRecordType<TLInstance>('instance', {
|
return createRecordType<TLInstance>('instance', {
|
||||||
validator: instanceTypeValidator,
|
validator: instanceTypeValidator,
|
||||||
scope: 'session',
|
scope: 'session',
|
||||||
|
ephemeralKeys: {
|
||||||
|
currentPageId: false,
|
||||||
|
meta: false,
|
||||||
|
|
||||||
|
followingUserId: true,
|
||||||
|
opacityForNextShape: true,
|
||||||
|
stylesForNextShape: true,
|
||||||
|
brush: true,
|
||||||
|
cursor: true,
|
||||||
|
scribbles: true,
|
||||||
|
isFocusMode: true,
|
||||||
|
isDebugMode: true,
|
||||||
|
isToolLocked: true,
|
||||||
|
exportBackground: true,
|
||||||
|
screenBounds: true,
|
||||||
|
insets: true,
|
||||||
|
zoomBrush: true,
|
||||||
|
isPenMode: true,
|
||||||
|
isGridMode: true,
|
||||||
|
chatMessage: true,
|
||||||
|
isChatting: true,
|
||||||
|
highlightedUserIds: true,
|
||||||
|
canMoveCamera: true,
|
||||||
|
isFocused: true,
|
||||||
|
devicePixelRatio: true,
|
||||||
|
isCoarsePointer: true,
|
||||||
|
isHoveringCanvas: true,
|
||||||
|
openMenus: true,
|
||||||
|
isChangingStyle: true,
|
||||||
|
isReadonly: true,
|
||||||
|
duplicateProps: true,
|
||||||
|
},
|
||||||
}).withDefaultProperties(
|
}).withDefaultProperties(
|
||||||
(): Omit<TLInstance, 'typeName' | 'id' | 'currentPageId'> => ({
|
(): Omit<TLInstance, 'typeName' | 'id' | 'currentPageId'> => ({
|
||||||
followingUserId: null,
|
followingUserId: null,
|
||||||
|
|
|
@ -138,6 +138,18 @@ export const InstancePageStateRecordType = createRecordType<TLInstancePageState>
|
||||||
{
|
{
|
||||||
validator: instancePageStateValidator,
|
validator: instancePageStateValidator,
|
||||||
scope: 'session',
|
scope: 'session',
|
||||||
|
ephemeralKeys: {
|
||||||
|
pageId: false,
|
||||||
|
selectedShapeIds: false,
|
||||||
|
editingShapeId: false,
|
||||||
|
croppingShapeId: false,
|
||||||
|
meta: false,
|
||||||
|
|
||||||
|
hintingShapeIds: true,
|
||||||
|
erasingShapeIds: true,
|
||||||
|
hoveredShapeId: true,
|
||||||
|
focusedGroupId: true,
|
||||||
|
},
|
||||||
}
|
}
|
||||||
).withDefaultProperties(
|
).withDefaultProperties(
|
||||||
(): Omit<TLInstancePageState, 'id' | 'typeName' | 'pageId'> => ({
|
(): Omit<TLInstancePageState, 'id' | 'typeName' | 'pageId'> => ({
|
||||||
|
|
|
@ -272,7 +272,9 @@ export class TLSyncClient<R extends UnknownRecord, S extends Store<R> = Store<R>
|
||||||
this.lastServerClock = 0
|
this.lastServerClock = 0
|
||||||
}
|
}
|
||||||
// kill all presence state
|
// kill all presence state
|
||||||
this.store.remove(Object.keys(this.store.serialize('presence')) as any)
|
this.store.mergeRemoteChanges(() => {
|
||||||
|
this.store.remove(Object.keys(this.store.serialize('presence')) as any)
|
||||||
|
})
|
||||||
this.lastPushedPresenceState = null
|
this.lastPushedPresenceState = null
|
||||||
this.isConnectedToRoom = false
|
this.isConnectedToRoom = false
|
||||||
this.pendingPushRequests = []
|
this.pendingPushRequests = []
|
||||||
|
@ -321,7 +323,7 @@ export class TLSyncClient<R extends UnknownRecord, S extends Store<R> = Store<R>
|
||||||
const wipeAll = event.hydrationType === 'wipe_all'
|
const wipeAll = event.hydrationType === 'wipe_all'
|
||||||
if (!wipeAll) {
|
if (!wipeAll) {
|
||||||
// if we're only wiping presence data, undo the speculative changes first
|
// if we're only wiping presence data, undo the speculative changes first
|
||||||
this.store.applyDiff(reverseRecordsDiff(stashedChanges), false)
|
this.store.applyDiff(reverseRecordsDiff(stashedChanges), { runCallbacks: false })
|
||||||
}
|
}
|
||||||
|
|
||||||
// now wipe all presence data and, if needed, all document data
|
// now wipe all presence data and, if needed, all document data
|
||||||
|
@ -336,12 +338,22 @@ export class TLSyncClient<R extends UnknownRecord, S extends Store<R> = Store<R>
|
||||||
|
|
||||||
// then apply the upstream changes
|
// then apply the upstream changes
|
||||||
this.applyNetworkDiff({ ...wipeDiff, ...event.diff }, true)
|
this.applyNetworkDiff({ ...wipeDiff, ...event.diff }, true)
|
||||||
|
|
||||||
|
this.isConnectedToRoom = true
|
||||||
|
|
||||||
|
// now re-apply the speculative changes creating a new push request with the
|
||||||
|
// appropriate diff
|
||||||
|
const speculativeChanges = this.store.filterChangesByScope(
|
||||||
|
this.store.extractingChanges(() => {
|
||||||
|
this.store.applyDiff(stashedChanges)
|
||||||
|
}),
|
||||||
|
'document'
|
||||||
|
)
|
||||||
|
if (speculativeChanges) this.push(speculativeChanges)
|
||||||
})
|
})
|
||||||
|
|
||||||
// now re-apply the speculative changes as a 'user' to trigger
|
// this.isConnectedToRoom = true
|
||||||
// creating a new push request with the appropriate diff
|
// this.store.applyDiff(stashedChanges, false)
|
||||||
this.isConnectedToRoom = true
|
|
||||||
this.store.applyDiff(stashedChanges)
|
|
||||||
|
|
||||||
this.store.ensureStoreIsUsable()
|
this.store.ensureStoreIsUsable()
|
||||||
// TODO: reinstate isNew
|
// TODO: reinstate isNew
|
||||||
|
@ -525,7 +537,7 @@ export class TLSyncClient<R extends UnknownRecord, S extends Store<R> = Store<R>
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (hasChanges) {
|
if (hasChanges) {
|
||||||
this.store.applyDiff(changes, runCallbacks)
|
this.store.applyDiff(changes, { runCallbacks })
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -541,7 +553,7 @@ export class TLSyncClient<R extends UnknownRecord, S extends Store<R> = Store<R>
|
||||||
try {
|
try {
|
||||||
this.store.mergeRemoteChanges(() => {
|
this.store.mergeRemoteChanges(() => {
|
||||||
// first undo speculative changes
|
// first undo speculative changes
|
||||||
this.store.applyDiff(reverseRecordsDiff(this.speculativeChanges), false)
|
this.store.applyDiff(reverseRecordsDiff(this.speculativeChanges), { runCallbacks: false })
|
||||||
|
|
||||||
// then apply network diffs on top of known-to-be-synced data
|
// then apply network diffs on top of known-to-be-synced data
|
||||||
for (const diff of diffs) {
|
for (const diff of diffs) {
|
||||||
|
|
|
@ -1,4 +1,3 @@
|
||||||
import isEqual from 'lodash.isequal'
|
|
||||||
import { nanoid } from 'nanoid'
|
import { nanoid } from 'nanoid'
|
||||||
import {
|
import {
|
||||||
Editor,
|
Editor,
|
||||||
|
@ -8,7 +7,9 @@ import {
|
||||||
computed,
|
computed,
|
||||||
createPresenceStateDerivation,
|
createPresenceStateDerivation,
|
||||||
createTLStore,
|
createTLStore,
|
||||||
|
isRecordsDiffEmpty,
|
||||||
} from 'tldraw'
|
} from 'tldraw'
|
||||||
|
import { prettyPrintDiff } from '../../../tldraw/src/test/testutils/pretty'
|
||||||
import { TLSyncClient } from '../lib/TLSyncClient'
|
import { TLSyncClient } from '../lib/TLSyncClient'
|
||||||
import { schema } from '../lib/schema'
|
import { schema } from '../lib/schema'
|
||||||
import { FuzzEditor, Op } from './FuzzEditor'
|
import { FuzzEditor, Op } from './FuzzEditor'
|
||||||
|
@ -74,8 +75,8 @@ class FuzzTestInstance extends RandomSource {
|
||||||
) {
|
) {
|
||||||
super(seed)
|
super(seed)
|
||||||
|
|
||||||
this.store = createTLStore({ schema })
|
|
||||||
this.id = nanoid()
|
this.id = nanoid()
|
||||||
|
this.store = createTLStore({ schema, id: this.id })
|
||||||
this.socketPair = new TestSocketPair(this.id, server)
|
this.socketPair = new TestSocketPair(this.id, server)
|
||||||
this.client = new TLSyncClient<TLRecord>({
|
this.client = new TLSyncClient<TLRecord>({
|
||||||
store: this.store,
|
store: this.store,
|
||||||
|
@ -105,6 +106,13 @@ class FuzzTestInstance extends RandomSource {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function assertPeerStoreIsUsable(peer: FuzzTestInstance) {
|
||||||
|
const diffToEnsureUsable = peer.store.extractingChanges(() => peer.store.ensureStoreIsUsable())
|
||||||
|
if (!isRecordsDiffEmpty(diffToEnsureUsable)) {
|
||||||
|
throw new Error(`store of ${peer.id} was not usable\n${prettyPrintDiff(diffToEnsureUsable)}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
let totalNumShapes = 0
|
let totalNumShapes = 0
|
||||||
let totalNumPages = 0
|
let totalNumPages = 0
|
||||||
|
|
||||||
|
@ -173,6 +181,7 @@ function runTest(seed: number) {
|
||||||
|
|
||||||
allOk('before applyOp')
|
allOk('before applyOp')
|
||||||
peer.editor.applyOp(op)
|
peer.editor.applyOp(op)
|
||||||
|
assertPeerStoreIsUsable(peer)
|
||||||
allOk('after applyOp')
|
allOk('after applyOp')
|
||||||
|
|
||||||
server.flushDebouncingMessages()
|
server.flushDebouncingMessages()
|
||||||
|
@ -210,6 +219,7 @@ function runTest(seed: number) {
|
||||||
if (!peer.socketPair.isConnected && peer.randomInt(2) === 0) {
|
if (!peer.socketPair.isConnected && peer.randomInt(2) === 0) {
|
||||||
peer.socketPair.connect()
|
peer.socketPair.connect()
|
||||||
allOk('final connect')
|
allOk('final connect')
|
||||||
|
assertPeerStoreIsUsable(peer)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -223,33 +233,29 @@ function runTest(seed: number) {
|
||||||
allOk('final flushServer')
|
allOk('final flushServer')
|
||||||
peer.socketPair.flushClientSentEvents()
|
peer.socketPair.flushClientSentEvents()
|
||||||
allOk('final flushClient')
|
allOk('final flushClient')
|
||||||
|
assertPeerStoreIsUsable(peer)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const equalityResults = []
|
// peers should all be usable without changes:
|
||||||
for (let i = 0; i < peers.length; i++) {
|
for (const peer of peers) {
|
||||||
const row = []
|
assertPeerStoreIsUsable(peer)
|
||||||
for (let j = 0; j < peers.length; j++) {
|
|
||||||
row.push(
|
|
||||||
isEqual(
|
|
||||||
peers[i].editor?.store.serialize('document'),
|
|
||||||
peers[j].editor?.store.serialize('document')
|
|
||||||
)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
equalityResults.push(row)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const [first, ...rest] = peers.map((peer) => peer.editor?.store.serialize('document'))
|
// all stores should be the same
|
||||||
|
for (let i = 1; i < peers.length; i++) {
|
||||||
|
const expected = peers[i - 1]
|
||||||
|
const actual = peers[i]
|
||||||
|
try {
|
||||||
|
expect(actual.store.serialize('document')).toEqual(expected.store.serialize('document'))
|
||||||
|
} catch (e: any) {
|
||||||
|
throw new Error(`received = ${actual.id}, expected = ${expected.id}\n${e.message}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// writeFileSync(`./test-results.${seed}.json`, JSON.stringify(ops, null, '\t'))
|
totalNumPages += peers[0].store.query.ids('page').get().size
|
||||||
|
totalNumShapes += peers[0].store.query.ids('shape').get().size
|
||||||
expect(first).toEqual(rest[0])
|
|
||||||
// all snapshots should be the same
|
|
||||||
expect(rest.every((other) => isEqual(other, first))).toBe(true)
|
|
||||||
totalNumPages += Object.values(first!).filter((v) => v.typeName === 'page').length
|
|
||||||
totalNumShapes += Object.values(first!).filter((v) => v.typeName === 'shape').length
|
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error('seed', seed)
|
console.error('seed', seed)
|
||||||
console.error(
|
console.error(
|
||||||
|
@ -269,21 +275,25 @@ const NUM_TESTS = 50
|
||||||
const NUM_OPS_PER_TEST = 100
|
const NUM_OPS_PER_TEST = 100
|
||||||
const MAX_PEERS = 4
|
const MAX_PEERS = 4
|
||||||
|
|
||||||
// test.only('seed 8343632005032947', () => {
|
test('seed 8360926944486245 - undo/redo page integrity regression', () => {
|
||||||
// runTest(8343632005032947)
|
runTest(8360926944486245)
|
||||||
// })
|
|
||||||
|
|
||||||
test('fuzzzzz', () => {
|
|
||||||
for (let i = 0; i < NUM_TESTS; i++) {
|
|
||||||
const seed = Math.floor(Math.random() * Number.MAX_SAFE_INTEGER)
|
|
||||||
try {
|
|
||||||
runTest(seed)
|
|
||||||
} catch (e) {
|
|
||||||
console.error('seed', seed)
|
|
||||||
throw e
|
|
||||||
}
|
|
||||||
}
|
|
||||||
})
|
})
|
||||||
|
test('seed 3467175630814895 - undo/redo page integrity regression', () => {
|
||||||
|
runTest(3467175630814895)
|
||||||
|
})
|
||||||
|
test('seed 6820615056006575 - undo/redo page integrity regression', () => {
|
||||||
|
runTest(6820615056006575)
|
||||||
|
})
|
||||||
|
test('seed 5279266392988747 - undo/redo page integrity regression', () => {
|
||||||
|
runTest(5279266392988747)
|
||||||
|
})
|
||||||
|
|
||||||
|
for (let i = 0; i < NUM_TESTS; i++) {
|
||||||
|
const seed = Math.floor(Math.random() * Number.MAX_SAFE_INTEGER)
|
||||||
|
test(`seed ${seed}`, () => {
|
||||||
|
runTest(seed)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
test('totalNumPages', () => {
|
test('totalNumPages', () => {
|
||||||
expect(totalNumPages).not.toBe(0)
|
expect(totalNumPages).not.toBe(0)
|
||||||
|
|
Loading…
Reference in a new issue