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,
|
||||
background: isActive ? 'var(--color-muted-2)' : 'transparent',
|
||||
}}
|
||||
onClick={() =>
|
||||
editor.setStyleForSelectedShapes(DefaultSizeStyle, value, { squashing: false })
|
||||
}
|
||||
onClick={() => editor.setStyleForSelectedShapes(DefaultSizeStyle, value)}
|
||||
>
|
||||
<TldrawUiIcon icon={icon} />
|
||||
</div>
|
||||
|
|
|
@ -25,7 +25,7 @@ function CustomStylePanel(props: TLUiStylePanelProps) {
|
|||
<TldrawUiButton
|
||||
type="menu"
|
||||
onClick={() => {
|
||||
editor.setStyleForSelectedShapes(DefaultColorStyle, 'red', { squashing: true })
|
||||
editor.setStyleForSelectedShapes(DefaultColorStyle, 'red')
|
||||
}}
|
||||
>
|
||||
<TldrawUiButtonLabel>Red</TldrawUiButtonLabel>
|
||||
|
@ -35,7 +35,7 @@ function CustomStylePanel(props: TLUiStylePanelProps) {
|
|||
<TldrawUiButton
|
||||
type="menu"
|
||||
onClick={() => {
|
||||
editor.setStyleForSelectedShapes(DefaultColorStyle, 'green', { squashing: true })
|
||||
editor.setStyleForSelectedShapes(DefaultColorStyle, 'green')
|
||||
}}
|
||||
>
|
||||
<TldrawUiButtonLabel>Green</TldrawUiButtonLabel>
|
||||
|
|
|
@ -29,7 +29,9 @@ export default function UserPresenceExample() {
|
|||
chatMessage: CURSOR_CHAT_MESSAGE,
|
||||
})
|
||||
|
||||
editor.store.mergeRemoteChanges(() => {
|
||||
editor.store.put([peerPresence])
|
||||
})
|
||||
|
||||
// [b]
|
||||
const raf = rRaf.current
|
||||
|
@ -67,6 +69,7 @@ export default function UserPresenceExample() {
|
|||
)
|
||||
}
|
||||
|
||||
editor.store.mergeRemoteChanges(() => {
|
||||
editor.store.put([
|
||||
{
|
||||
...peerPresence,
|
||||
|
@ -75,15 +78,20 @@ export default function UserPresenceExample() {
|
|||
lastActivityTimestamp: now,
|
||||
},
|
||||
])
|
||||
})
|
||||
|
||||
rRaf.current = requestAnimationFrame(loop)
|
||||
}
|
||||
|
||||
rRaf.current = requestAnimationFrame(loop)
|
||||
} else {
|
||||
editor.store.mergeRemoteChanges(() => {
|
||||
editor.store.put([{ ...peerPresence, lastActivityTimestamp: Date.now() }])
|
||||
})
|
||||
rRaf.current = setInterval(() => {
|
||||
editor.store.mergeRemoteChanges(() => {
|
||||
editor.store.put([{ ...peerPresence, lastActivityTimestamp: Date.now() }])
|
||||
})
|
||||
}, 1000)
|
||||
}
|
||||
}}
|
||||
|
|
|
@ -57,11 +57,11 @@ export const ChangeResponder = () => {
|
|||
type: 'vscode:editor-loaded',
|
||||
})
|
||||
|
||||
editor.on('change-history', handleChange)
|
||||
const dispose = editor.store.listen(handleChange, { scope: 'document' })
|
||||
|
||||
return () => {
|
||||
handleChange()
|
||||
editor.off('change-history', handleChange)
|
||||
dispose()
|
||||
}
|
||||
}, [editor])
|
||||
|
||||
|
|
|
@ -29,10 +29,12 @@ import { default as React_2 } from 'react';
|
|||
import * as React_3 from 'react';
|
||||
import { ReactElement } from 'react';
|
||||
import { ReactNode } from 'react';
|
||||
import { RecordsDiff } from '@tldraw/store';
|
||||
import { SerializedSchema } from '@tldraw/store';
|
||||
import { SerializedStore } from '@tldraw/store';
|
||||
import { ShapeProps } from '@tldraw/tlschema';
|
||||
import { Signal } from '@tldraw/state';
|
||||
import { Store } from '@tldraw/store';
|
||||
import { StoreSchema } from '@tldraw/store';
|
||||
import { StoreSnapshot } from '@tldraw/store';
|
||||
import { StyleProp } from '@tldraw/tlschema';
|
||||
|
@ -375,7 +377,7 @@ export function counterClockwiseAngleDist(a0: number, a1: number): number;
|
|||
export function createSessionStateSnapshotSignal(store: TLStore): Signal<null | TLSessionStateSnapshot>;
|
||||
|
||||
// @public
|
||||
export function createTLStore({ initialData, defaultName, ...rest }: TLStoreOptions): TLStore;
|
||||
export function createTLStore({ initialData, defaultName, id, ...rest }: TLStoreOptions): TLStore;
|
||||
|
||||
// @public (undocumented)
|
||||
export function createTLUser(opts?: {
|
||||
|
@ -602,7 +604,7 @@ export class Editor extends EventEmitter<TLEventMap> {
|
|||
}): this;
|
||||
bail(): this;
|
||||
bailToMark(id: string): this;
|
||||
batch(fn: () => void): this;
|
||||
batch(fn: () => void, opts?: TLHistoryBatchOptions): this;
|
||||
bringForward(shapes: TLShape[] | TLShapeId[]): this;
|
||||
bringToFront(shapes: TLShape[] | TLShapeId[]): this;
|
||||
cancel(): this;
|
||||
|
@ -810,7 +812,7 @@ export class Editor extends EventEmitter<TLEventMap> {
|
|||
getZoomLevel(): number;
|
||||
groupShapes(shapes: TLShape[] | TLShapeId[], groupId?: TLShapeId): this;
|
||||
hasAncestor(shape: TLShape | TLShapeId | undefined, ancestorId: TLShapeId): boolean;
|
||||
readonly history: HistoryManager<this>;
|
||||
readonly history: HistoryManager<TLRecord>;
|
||||
inputs: {
|
||||
buttons: Set<number>;
|
||||
keys: Set<string>;
|
||||
|
@ -832,6 +834,7 @@ export class Editor extends EventEmitter<TLEventMap> {
|
|||
isPointing: boolean;
|
||||
};
|
||||
interrupt(): this;
|
||||
isAncestorSelected(shape: TLShape | TLShapeId): boolean;
|
||||
isIn(path: string): boolean;
|
||||
isInAny(...paths: string[]): boolean;
|
||||
isPointInShape(shape: TLShape | TLShapeId, point: VecLike, opts?: {
|
||||
|
@ -845,9 +848,9 @@ export class Editor extends EventEmitter<TLEventMap> {
|
|||
isShapeOrAncestorLocked(shape?: TLShape): boolean;
|
||||
// (undocumented)
|
||||
isShapeOrAncestorLocked(id?: TLShapeId): boolean;
|
||||
mark(markId?: string, onUndo?: boolean, onRedo?: boolean): this;
|
||||
mark(markId?: string): this;
|
||||
moveShapesToPage(shapes: TLShape[] | TLShapeId[], pageId: TLPageId): this;
|
||||
nudgeShapes(shapes: TLShape[] | TLShapeId[], offset: VecLike, historyOptions?: TLCommandHistoryOptions): this;
|
||||
nudgeShapes(shapes: TLShape[] | TLShapeId[], offset: VecLike): this;
|
||||
packShapes(shapes: TLShape[] | TLShapeId[], gap: number): this;
|
||||
pageToScreen(point: VecLike): {
|
||||
x: number;
|
||||
|
@ -876,7 +879,7 @@ export class Editor extends EventEmitter<TLEventMap> {
|
|||
registerExternalContentHandler<T extends TLExternalContent['type']>(type: T, handler: ((info: T extends TLExternalContent['type'] ? TLExternalContent & {
|
||||
type: T;
|
||||
} : TLExternalContent) => void) | null): this;
|
||||
renamePage(page: TLPage | TLPageId, name: string, historyOptions?: TLCommandHistoryOptions): this;
|
||||
renamePage(page: TLPage | TLPageId, name: string): this;
|
||||
renderingBoundsMargin: number;
|
||||
reparentShapes(shapes: TLShape[] | TLShapeId[], parentId: TLParentId, insertIndex?: IndexKey): this;
|
||||
resetZoom(point?: Vec, animation?: TLAnimationOptions): this;
|
||||
|
@ -896,7 +899,7 @@ export class Editor extends EventEmitter<TLEventMap> {
|
|||
sendToBack(shapes: TLShape[] | TLShapeId[]): this;
|
||||
setCamera(point: VecLike, animation?: TLAnimationOptions): this;
|
||||
setCroppingShape(shape: null | TLShape | TLShapeId): this;
|
||||
setCurrentPage(page: TLPage | TLPageId, historyOptions?: TLCommandHistoryOptions): this;
|
||||
setCurrentPage(page: TLPage | TLPageId): this;
|
||||
setCurrentTool(id: string, info?: {}): this;
|
||||
setCursor: (cursor: Partial<TLCursor>) => this;
|
||||
setEditingShape(shape: null | TLShape | TLShapeId): this;
|
||||
|
@ -904,11 +907,11 @@ export class Editor extends EventEmitter<TLEventMap> {
|
|||
setFocusedGroup(shape: null | TLGroupShape | TLShapeId): this;
|
||||
setHintingShapes(shapes: TLShape[] | TLShapeId[]): this;
|
||||
setHoveredShape(shape: null | TLShape | TLShapeId): this;
|
||||
setOpacityForNextShapes(opacity: number, historyOptions?: TLCommandHistoryOptions): this;
|
||||
setOpacityForSelectedShapes(opacity: number, historyOptions?: TLCommandHistoryOptions): this;
|
||||
setSelectedShapes(shapes: TLShape[] | TLShapeId[], historyOptions?: TLCommandHistoryOptions): this;
|
||||
setStyleForNextShapes<T>(style: StyleProp<T>, value: T, historyOptions?: TLCommandHistoryOptions): this;
|
||||
setStyleForSelectedShapes<S extends StyleProp<any>>(style: S, value: StylePropValue<S>, historyOptions?: TLCommandHistoryOptions): this;
|
||||
setOpacityForNextShapes(opacity: number, historyOptions?: TLHistoryBatchOptions): this;
|
||||
setOpacityForSelectedShapes(opacity: number): this;
|
||||
setSelectedShapes(shapes: TLShape[] | TLShapeId[]): this;
|
||||
setStyleForNextShapes<T>(style: StyleProp<T>, value: T, historyOptions?: TLHistoryBatchOptions): this;
|
||||
setStyleForSelectedShapes<S extends StyleProp<any>>(style: S, value: StylePropValue<S>): this;
|
||||
shapeUtils: {
|
||||
readonly [K in string]?: ShapeUtil<TLUnknownShape>;
|
||||
};
|
||||
|
@ -937,14 +940,16 @@ export class Editor extends EventEmitter<TLEventMap> {
|
|||
// (undocumented)
|
||||
ungroupShapes(ids: TLShape[]): 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;
|
||||
updateInstanceState(partial: Partial<Omit<TLInstance, 'currentPageId'>>, historyOptions?: TLCommandHistoryOptions): this;
|
||||
updatePage(partial: RequiredKeys<TLPage, 'id'>, historyOptions?: TLCommandHistoryOptions): this;
|
||||
updateInstanceState(partial: Partial<Omit<TLInstance, 'currentPageId'>>, historyOptions?: TLHistoryBatchOptions): this;
|
||||
updatePage(partial: RequiredKeys<TLPage, 'id'>): this;
|
||||
// @internal
|
||||
updateRenderingBounds(): this;
|
||||
updateShape<T extends TLUnknownShape>(partial: null | TLShapePartial<T> | undefined, historyOptions?: TLCommandHistoryOptions): this;
|
||||
updateShapes<T extends TLUnknownShape>(partials: (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)[]): this;
|
||||
updateViewportScreenBounds(screenBounds: Box, center?: boolean): this;
|
||||
readonly user: UserPreferencesManager;
|
||||
visitDescendants(parent: TLPage | TLParentId | TLShape, visitor: (id: TLShapeId) => false | void): this;
|
||||
|
@ -1208,6 +1213,55 @@ export function hardResetEditor(): void;
|
|||
// @internal (undocumented)
|
||||
export const HASH_PATTERN_ZOOM_NAMES: Record<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)
|
||||
export const HIT_TEST_MARGIN = 8;
|
||||
|
||||
|
@ -1723,6 +1777,17 @@ export class SideEffectManager<CTX extends {
|
|||
constructor(editor: CTX);
|
||||
// (undocumented)
|
||||
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 & {
|
||||
typeName: T;
|
||||
}>): () => void;
|
||||
|
@ -2037,29 +2102,6 @@ export type TLCollaboratorHintProps = {
|
|||
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)
|
||||
export type TLCompleteEvent = (info: TLCompleteEventInfo) => void;
|
||||
|
||||
|
@ -2193,17 +2235,6 @@ export type TLEventInfo = TLCancelEventInfo | TLClickEventInfo | TLCompleteEvent
|
|||
|
||||
// @public (undocumented)
|
||||
export interface TLEventMap {
|
||||
// (undocumented)
|
||||
'change-history': [{
|
||||
markId?: string;
|
||||
reason: 'bail';
|
||||
} | {
|
||||
reason: 'push' | 'redo' | 'undo';
|
||||
}];
|
||||
// (undocumented)
|
||||
'mark-history': [{
|
||||
id: string;
|
||||
}];
|
||||
// (undocumented)
|
||||
'max-shapes': [{
|
||||
count: number;
|
||||
|
@ -2316,17 +2347,6 @@ export type TLHandlesProps = {
|
|||
children: ReactNode;
|
||||
};
|
||||
|
||||
// @public (undocumented)
|
||||
export type TLHistoryEntry = TLCommand | TLHistoryMark;
|
||||
|
||||
// @public (undocumented)
|
||||
export type TLHistoryMark = {
|
||||
id: string;
|
||||
onRedo: boolean;
|
||||
onUndo: boolean;
|
||||
type: 'STOP';
|
||||
};
|
||||
|
||||
// @public (undocumented)
|
||||
export type TLInterruptEvent = (info: TLInterruptEventInfo) => void;
|
||||
|
||||
|
@ -2610,6 +2630,7 @@ export type TLStoreEventInfo = HistoryEntry<TLRecord>;
|
|||
// @public (undocumented)
|
||||
export type TLStoreOptions = {
|
||||
defaultName?: string;
|
||||
id?: string;
|
||||
initialData?: SerializedStore<TLRecord>;
|
||||
} & ({
|
||||
migrations?: readonly MigrationSequence[];
|
||||
|
|
|
@ -17,7 +17,6 @@ export {
|
|||
type Atom,
|
||||
type Signal,
|
||||
} from '@tldraw/state'
|
||||
export type { TLCommandHistoryOptions } from './lib/editor/types/history-types'
|
||||
// eslint-disable-next-line local/no-export-star
|
||||
export * from '@tldraw/store'
|
||||
// eslint-disable-next-line local/no-export-star
|
||||
|
@ -131,6 +130,7 @@ export {
|
|||
type TLEditorOptions,
|
||||
type TLResizeShapeOptions,
|
||||
} from './lib/editor/Editor'
|
||||
export { HistoryManager } from './lib/editor/managers/HistoryManager'
|
||||
export type {
|
||||
SideEffectManager,
|
||||
TLAfterChangeHandler,
|
||||
|
@ -235,12 +235,6 @@ export {
|
|||
type TLExternalContent,
|
||||
type TLExternalContentSource,
|
||||
} from './lib/editor/types/external-content'
|
||||
export {
|
||||
type TLCommand,
|
||||
type TLCommandHandler,
|
||||
type TLHistoryEntry,
|
||||
type TLHistoryMark,
|
||||
} from './lib/editor/types/history-types'
|
||||
export { type RequiredKeys, type TLSvgOptions } from './lib/editor/types/misc-types'
|
||||
export { type TLResizeHandle, type TLSelectionHandle } from './lib/editor/types/selection-types'
|
||||
export { ContainerProvider, useContainer } from './lib/hooks/useContainer'
|
||||
|
|
|
@ -380,8 +380,11 @@ function useOnMount(onMount?: TLOnMountHandler) {
|
|||
const editor = useEditor()
|
||||
|
||||
const onMountEvent = useEvent((editor: Editor) => {
|
||||
const teardown = onMount?.(editor)
|
||||
let teardown: (() => void) | void = undefined
|
||||
editor.history.ignore(() => {
|
||||
teardown = onMount?.(editor)
|
||||
editor.emit('mount')
|
||||
})
|
||||
window.tldrawReady = true
|
||||
return teardown
|
||||
})
|
||||
|
|
|
@ -14,6 +14,7 @@ import { TLAnyShapeUtilConstructor, checkShapesAndAddCore } from './defaultShape
|
|||
export type TLStoreOptions = {
|
||||
initialData?: SerializedStore<TLRecord>
|
||||
defaultName?: string
|
||||
id?: string
|
||||
} & (
|
||||
| { shapeUtils?: readonly TLAnyShapeUtilConstructor[]; migrations?: readonly MigrationSequence[] }
|
||||
| { schema?: StoreSchema<TLRecord, TLStoreProps> }
|
||||
|
@ -28,7 +29,12 @@ export type TLStoreEventInfo = HistoryEntry<TLRecord>
|
|||
* @param opts - Options for creating the store.
|
||||
*
|
||||
* @public */
|
||||
export function createTLStore({ initialData, defaultName = '', ...rest }: TLStoreOptions): TLStore {
|
||||
export function createTLStore({
|
||||
initialData,
|
||||
defaultName = '',
|
||||
id,
|
||||
...rest
|
||||
}: TLStoreOptions): TLStore {
|
||||
const schema =
|
||||
'schema' in rest && rest.schema
|
||||
? // we have a schema
|
||||
|
@ -42,6 +48,7 @@ export function createTLStore({ initialData, defaultName = '', ...rest }: TLStor
|
|||
})
|
||||
|
||||
return new Store({
|
||||
id,
|
||||
schema,
|
||||
initialData,
|
||||
props: {
|
||||
|
|
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 { 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() {
|
||||
const manager = new HistoryManager({ emit: () => void null }, () => {
|
||||
return
|
||||
})
|
||||
const state = {
|
||||
count: 0,
|
||||
name: 'David',
|
||||
age: 35,
|
||||
}
|
||||
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 store = new Store({ schema: testSchema, props: null })
|
||||
store.put([
|
||||
testSchema.types.test.create({ id: ids.count, value: 0 }),
|
||||
testSchema.types.test.create({ id: ids.name, value: 'David' }),
|
||||
testSchema.types.test.create({ id: ids.age, value: 35 }),
|
||||
])
|
||||
|
||||
const decrement = manager.createCommand(
|
||||
'decrement',
|
||||
(n = 1, squashing = false) => ({
|
||||
data: { n },
|
||||
squashing,
|
||||
}),
|
||||
{
|
||||
do: ({ n }) => {
|
||||
state.count -= n
|
||||
},
|
||||
undo: ({ n }) => {
|
||||
state.count += n
|
||||
},
|
||||
squash: ({ n: n1 }, { n: n2 }) => ({ n: n1 + n2 }),
|
||||
}
|
||||
)
|
||||
const manager = new HistoryManager<TestRecord>({ store })
|
||||
|
||||
const setName = manager.createCommand(
|
||||
'setName',
|
||||
(name = 'David') => ({
|
||||
data: { name, prev: state.name },
|
||||
ephemeral: true,
|
||||
}),
|
||||
{
|
||||
do: ({ name }) => {
|
||||
state.name = name
|
||||
},
|
||||
undo: ({ prev }) => {
|
||||
state.name = prev
|
||||
},
|
||||
function getCount() {
|
||||
return store.get(ids.count)!.value as number
|
||||
}
|
||||
)
|
||||
|
||||
const setAge = manager.createCommand(
|
||||
'setAge',
|
||||
(age = 35) => ({
|
||||
data: { age, prev: state.age },
|
||||
preservesRedoStack: true,
|
||||
}),
|
||||
{
|
||||
do: ({ age }) => {
|
||||
state.age = age
|
||||
},
|
||||
undo: ({ prev }) => {
|
||||
state.age = prev
|
||||
},
|
||||
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 incrementTwice = manager.createCommand('incrementTwice', () => ({ data: {} }), {
|
||||
do: () => {
|
||||
const increment = (n = 1) => {
|
||||
_setCount(getCount() + n)
|
||||
}
|
||||
|
||||
const decrement = (n = 1) => {
|
||||
_setCount(getCount() - n)
|
||||
}
|
||||
|
||||
const setName = (name = 'David') => {
|
||||
manager.ignore(() => _setName(name))
|
||||
}
|
||||
|
||||
const setAge = (age = 35) => {
|
||||
manager.batch(() => _setAge(age), { history: 'record-preserveRedoStack' })
|
||||
}
|
||||
|
||||
const incrementTwice = () => {
|
||||
manager.batch(() => {
|
||||
increment()
|
||||
increment()
|
||||
},
|
||||
undo: () => {
|
||||
decrement()
|
||||
decrement()
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
return {
|
||||
increment,
|
||||
|
@ -95,9 +78,9 @@ function createCounterHistoryManager() {
|
|||
setName,
|
||||
setAge,
|
||||
history: manager,
|
||||
getCount: () => state.count,
|
||||
getName: () => state.name,
|
||||
getAge: () => state.age,
|
||||
getCount,
|
||||
getName,
|
||||
getAge,
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -116,9 +99,9 @@ describe(HistoryManager, () => {
|
|||
editor.decrement()
|
||||
expect(editor.getCount()).toBe(3)
|
||||
|
||||
const undos = [...editor.history._undos.get()]
|
||||
const undos = [...editor.history.stacks.get().undos]
|
||||
const parsedUndos = JSON.parse(JSON.stringify(undos))
|
||||
editor.history._undos.set(stack(parsedUndos))
|
||||
editor.history.stacks.update(({ redos }) => ({ undos: stack(parsedUndos), redos }))
|
||||
|
||||
editor.history.undo()
|
||||
|
||||
|
@ -200,17 +183,16 @@ describe(HistoryManager, () => {
|
|||
editor.history.mark('stop at 1')
|
||||
expect(editor.getCount()).toBe(1)
|
||||
|
||||
editor.increment(1, true)
|
||||
editor.increment(1, true)
|
||||
editor.increment(1, true)
|
||||
editor.increment(1, true)
|
||||
editor.increment(1)
|
||||
editor.increment(1)
|
||||
editor.increment(1)
|
||||
editor.increment(1)
|
||||
|
||||
expect(editor.getCount()).toBe(5)
|
||||
|
||||
expect(editor.history.getNumUndos()).toBe(3)
|
||||
})
|
||||
|
||||
it('allows ephemeral commands that do not affect the stack', () => {
|
||||
it('allows ignore commands that do not affect the stack', () => {
|
||||
editor.increment()
|
||||
editor.history.mark('stop at 1')
|
||||
editor.increment()
|
||||
|
@ -263,7 +245,7 @@ describe(HistoryManager, () => {
|
|||
editor.history.mark('2')
|
||||
editor.incrementTwice()
|
||||
editor.incrementTwice()
|
||||
expect(editor.history.getNumUndos()).toBe(5)
|
||||
expect(editor.history.getNumUndos()).toBe(4)
|
||||
expect(editor.getCount()).toBe(6)
|
||||
editor.history.bail()
|
||||
expect(editor.getCount()).toBe(2)
|
||||
|
@ -289,58 +271,35 @@ describe(HistoryManager, () => {
|
|||
})
|
||||
|
||||
describe('history options', () => {
|
||||
let manager: HistoryManager<any>
|
||||
let state: { a: number; b: number }
|
||||
let manager: HistoryManager<TestRecord>
|
||||
|
||||
let setA: (n: number, historyOptions?: TLCommandHistoryOptions) => any
|
||||
let setB: (n: number, historyOptions?: TLCommandHistoryOptions) => any
|
||||
let getState: () => { a: number; b: number }
|
||||
let setA: (n: number, historyOptions?: TLHistoryBatchOptions) => any
|
||||
let setB: (n: number, historyOptions?: TLHistoryBatchOptions) => any
|
||||
|
||||
beforeEach(() => {
|
||||
manager = new HistoryManager({ emit: () => void null }, () => {
|
||||
return
|
||||
const store = new Store({ schema: testSchema, props: null })
|
||||
store.put([
|
||||
testSchema.types.test.create({ id: ids.a, value: 0 }),
|
||||
testSchema.types.test.create({ id: ids.b, value: 0 }),
|
||||
])
|
||||
|
||||
manager = new HistoryManager<TestRecord>({ store })
|
||||
|
||||
getState = () => {
|
||||
return { a: store.get(ids.a)!.value as number, b: store.get(ids.b)!.value as number }
|
||||
}
|
||||
|
||||
setA = (n: number, historyOptions?: TLHistoryBatchOptions) => {
|
||||
manager.batch(() => store.update(ids.a, (s) => ({ ...s, value: n })), historyOptions)
|
||||
}
|
||||
|
||||
setB = (n: number, historyOptions?: TLHistoryBatchOptions) => {
|
||||
manager.batch(() => store.update(ids.b, (s) => ({ ...s, value: n })), historyOptions)
|
||||
}
|
||||
})
|
||||
|
||||
state = {
|
||||
a: 0,
|
||||
b: 0,
|
||||
}
|
||||
|
||||
setA = manager.createCommand(
|
||||
'setA',
|
||||
(n: number, historyOptions?: TLCommandHistoryOptions) => ({
|
||||
data: { next: n, prev: state.a },
|
||||
...historyOptions,
|
||||
}),
|
||||
{
|
||||
do: ({ next }) => {
|
||||
state = { ...state, a: next }
|
||||
},
|
||||
undo: ({ prev }) => {
|
||||
state = { ...state, a: prev }
|
||||
},
|
||||
squash: ({ prev }, { next }) => ({ prev, next }),
|
||||
}
|
||||
)
|
||||
|
||||
setB = manager.createCommand(
|
||||
'setB',
|
||||
(n: number, historyOptions?: TLCommandHistoryOptions) => ({
|
||||
data: { next: n, prev: state.b },
|
||||
...historyOptions,
|
||||
}),
|
||||
{
|
||||
do: ({ next }) => {
|
||||
state = { ...state, b: next }
|
||||
},
|
||||
undo: ({ prev }) => {
|
||||
state = { ...state, b: prev }
|
||||
},
|
||||
squash: ({ prev }, { next }) => ({ prev, next }),
|
||||
}
|
||||
)
|
||||
})
|
||||
|
||||
it('sets, undoes, redoes', () => {
|
||||
it('undos, redoes, separate marks', () => {
|
||||
manager.mark()
|
||||
setA(1)
|
||||
manager.mark()
|
||||
|
@ -348,18 +307,18 @@ describe('history options', () => {
|
|||
manager.mark()
|
||||
setB(2)
|
||||
|
||||
expect(state).toMatchObject({ a: 1, b: 2 })
|
||||
expect(getState()).toMatchObject({ a: 1, b: 2 })
|
||||
|
||||
manager.undo()
|
||||
|
||||
expect(state).toMatchObject({ a: 1, b: 1 })
|
||||
expect(getState()).toMatchObject({ a: 1, b: 1 })
|
||||
|
||||
manager.redo()
|
||||
|
||||
expect(state).toMatchObject({ a: 1, b: 2 })
|
||||
expect(getState()).toMatchObject({ a: 1, b: 2 })
|
||||
})
|
||||
|
||||
it('sets, undoes, redoes', () => {
|
||||
it('undos, redos, squashing', () => {
|
||||
manager.mark()
|
||||
setA(1)
|
||||
manager.mark()
|
||||
|
@ -369,71 +328,107 @@ describe('history options', () => {
|
|||
setB(3)
|
||||
setB(4)
|
||||
|
||||
expect(state).toMatchObject({ a: 1, b: 4 })
|
||||
expect(getState()).toMatchObject({ a: 1, b: 4 })
|
||||
|
||||
manager.undo()
|
||||
|
||||
expect(state).toMatchObject({ a: 1, b: 1 })
|
||||
expect(getState()).toMatchObject({ a: 1, b: 1 })
|
||||
|
||||
manager.redo()
|
||||
|
||||
expect(state).toMatchObject({ a: 1, b: 4 })
|
||||
expect(getState()).toMatchObject({ a: 1, b: 4 })
|
||||
})
|
||||
|
||||
it('sets ephemeral, undoes, redos', () => {
|
||||
it('undos, redos, ignore', () => {
|
||||
manager.mark()
|
||||
setA(1)
|
||||
manager.mark()
|
||||
setB(1) // B 0->1
|
||||
manager.mark()
|
||||
setB(2, { ephemeral: true }) // B 0->2, but ephemeral
|
||||
setB(2, { history: 'ignore' }) // B 0->2, but ignore
|
||||
|
||||
expect(state).toMatchObject({ a: 1, b: 2 })
|
||||
expect(getState()).toMatchObject({ a: 1, b: 2 })
|
||||
|
||||
manager.undo() // undoes B 2->0
|
||||
|
||||
expect(state).toMatchObject({ a: 1, b: 0 })
|
||||
expect(getState()).toMatchObject({ a: 1, b: 0 })
|
||||
|
||||
manager.redo() // redoes B 0->1, but not B 1-> 2
|
||||
|
||||
expect(state).toMatchObject({ a: 1, b: 1 }) // no change, b 1->2 was ephemeral
|
||||
expect(getState()).toMatchObject({ a: 1, b: 1 }) // no change, b 1->2 was ignore
|
||||
})
|
||||
|
||||
it('sets squashing, undoes, redos', () => {
|
||||
it('squashing, undos, redos', () => {
|
||||
manager.mark()
|
||||
setA(1)
|
||||
manager.mark()
|
||||
setB(1)
|
||||
setB(2, { squashing: true }) // squashes with the previous command
|
||||
setB(3, { squashing: true }) // squashes with the previous command
|
||||
setB(2) // squashes with the previous command
|
||||
setB(3) // squashes with the previous command
|
||||
|
||||
expect(state).toMatchObject({ a: 1, b: 3 })
|
||||
expect(getState()).toMatchObject({ a: 1, b: 3 })
|
||||
|
||||
manager.undo()
|
||||
|
||||
expect(state).toMatchObject({ a: 1, b: 0 })
|
||||
expect(getState()).toMatchObject({ a: 1, b: 0 })
|
||||
|
||||
manager.redo()
|
||||
|
||||
expect(state).toMatchObject({ a: 1, b: 3 })
|
||||
expect(getState()).toMatchObject({ a: 1, b: 3 })
|
||||
})
|
||||
|
||||
it('sets squashing and ephemeral, undoes, redos', () => {
|
||||
it('squashing, undos, redos, ignore', () => {
|
||||
manager.mark()
|
||||
setA(1)
|
||||
manager.mark()
|
||||
setB(1)
|
||||
setB(2, { squashing: true }) // squashes with the previous command
|
||||
setB(3, { squashing: true, ephemeral: true }) // squashes with the previous command
|
||||
setB(2) // squashes with the previous command
|
||||
setB(3, { history: 'ignore' }) // squashes with the previous command
|
||||
|
||||
expect(state).toMatchObject({ a: 1, b: 3 })
|
||||
expect(getState()).toMatchObject({ a: 1, b: 3 })
|
||||
|
||||
manager.undo()
|
||||
|
||||
expect(state).toMatchObject({ a: 1, b: 0 })
|
||||
expect(getState()).toMatchObject({ a: 1, b: 0 })
|
||||
|
||||
manager.redo()
|
||||
|
||||
expect(state).toMatchObject({ a: 1, b: 2 }) // B2->3 was ephemeral
|
||||
expect(getState()).toMatchObject({ a: 1, b: 2 }) // B2->3 was ignore
|
||||
})
|
||||
|
||||
it('nested ignore', () => {
|
||||
manager.mark()
|
||||
manager.batch(
|
||||
() => {
|
||||
setA(1)
|
||||
// even though we set this to record, it will still be ignored
|
||||
manager.batch(() => setB(1), { history: 'record' })
|
||||
setA(2)
|
||||
},
|
||||
{ history: 'ignore' }
|
||||
)
|
||||
expect(getState()).toMatchObject({ a: 2, b: 1 })
|
||||
|
||||
// changes were ignored:
|
||||
manager.undo()
|
||||
expect(getState()).toMatchObject({ a: 2, b: 1 })
|
||||
|
||||
manager.mark()
|
||||
manager.batch(
|
||||
() => {
|
||||
setA(3)
|
||||
manager.batch(() => setB(2), { history: 'ignore' })
|
||||
},
|
||||
{ history: 'record-preserveRedoStack' }
|
||||
)
|
||||
expect(getState()).toMatchObject({ a: 3, b: 2 })
|
||||
|
||||
// changes to A were recorded, but changes to B were ignore:
|
||||
manager.undo()
|
||||
expect(getState()).toMatchObject({ a: 2, b: 2 })
|
||||
|
||||
// We can still redo because we preserved the redo stack:
|
||||
manager.redo()
|
||||
expect(getState()).toMatchObject({ a: 3, b: 2 })
|
||||
})
|
||||
})
|
||||
|
|
|
@ -1,158 +1,126 @@
|
|||
import { atom, transact } from '@tldraw/state'
|
||||
import { devFreeze } from '@tldraw/store'
|
||||
import {
|
||||
RecordsDiff,
|
||||
Store,
|
||||
UnknownRecord,
|
||||
createEmptyRecordsDiff,
|
||||
isRecordsDiffEmpty,
|
||||
reverseRecordsDiff,
|
||||
squashRecordDiffsMutable,
|
||||
} from '@tldraw/store'
|
||||
import { exhaustiveSwitchError, noop } from '@tldraw/utils'
|
||||
import { uniqueId } from '../../utils/uniqueId'
|
||||
import { TLCommandHandler, TLCommandHistoryOptions, TLHistoryEntry } from '../types/history-types'
|
||||
import { Stack, stack } from './Stack'
|
||||
import { TLHistoryBatchOptions, TLHistoryEntry } from '../types/history-types'
|
||||
import { stack } from './Stack'
|
||||
|
||||
type CommandFn<Data> = (...args: any[]) =>
|
||||
| ({
|
||||
data: Data
|
||||
} & TLCommandHistoryOptions)
|
||||
| null
|
||||
| undefined
|
||||
| void
|
||||
enum HistoryRecorderState {
|
||||
Recording = 'recording',
|
||||
RecordingPreserveRedoStack = 'recordingPreserveRedoStack',
|
||||
Paused = 'paused',
|
||||
}
|
||||
|
||||
type ExtractData<Fn> = Fn extends CommandFn<infer Data> ? Data : never
|
||||
type ExtractArgs<Fn> = Parameters<Extract<Fn, (...args: any[]) => any>>
|
||||
/** @public */
|
||||
export class HistoryManager<R extends UnknownRecord> {
|
||||
private readonly store: Store<R>
|
||||
|
||||
export class HistoryManager<
|
||||
CTX extends {
|
||||
emit: (name: 'change-history' | 'mark-history', ...args: any) => void
|
||||
readonly dispose: () => void
|
||||
|
||||
private state: HistoryRecorderState = HistoryRecorderState.Recording
|
||||
private readonly pendingDiff = new PendingDiff<R>()
|
||||
/** @internal */
|
||||
stacks = atom(
|
||||
'HistoryManager.stacks',
|
||||
{
|
||||
undos: stack<TLHistoryEntry<R>>(),
|
||||
redos: stack<TLHistoryEntry<R>>(),
|
||||
},
|
||||
> {
|
||||
_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
|
||||
{
|
||||
isEqual: (a, b) => a.undos === b.undos && a.redos === b.redos,
|
||||
}
|
||||
)
|
||||
|
||||
constructor(
|
||||
private readonly ctx: CTX,
|
||||
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
|
||||
|
||||
private _commands: Record<string, TLCommandHandler<any>> = {}
|
||||
|
||||
getNumUndos() {
|
||||
return this._undos.get().length
|
||||
return this.stacks.get().undos.length + (this.pendingDiff.isEmpty() ? 0 : 1)
|
||||
}
|
||||
getNumRedos() {
|
||||
return this._redos.get().length
|
||||
}
|
||||
createCommand = <Name 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
|
||||
return this.stacks.get().redos.length
|
||||
}
|
||||
|
||||
const result = constructor(...args)
|
||||
/** @internal */
|
||||
_isInBatch = false
|
||||
batch = (fn: () => void, opts?: TLHistoryBatchOptions) => {
|
||||
const previousState = this.state
|
||||
|
||||
if (!result) {
|
||||
return this.ctx
|
||||
// we move to the new state only if we haven't explicitly paused
|
||||
if (previousState !== HistoryRecorderState.Paused && opts?.history) {
|
||||
this.state = modeToState[opts.history]
|
||||
}
|
||||
|
||||
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) => {
|
||||
try {
|
||||
this._batchDepth++
|
||||
if (this._batchDepth === 1) {
|
||||
if (this._isInBatch) {
|
||||
fn()
|
||||
return this
|
||||
}
|
||||
|
||||
this._isInBatch = true
|
||||
try {
|
||||
transact(() => {
|
||||
const mostRecentAction = this._undos.get().head
|
||||
fn()
|
||||
if (mostRecentAction !== this._undos.get().head) {
|
||||
this.onBatchComplete()
|
||||
}
|
||||
})
|
||||
} else {
|
||||
fn()
|
||||
}
|
||||
} catch (error) {
|
||||
this.annotateError(error)
|
||||
throw error
|
||||
} finally {
|
||||
this._batchDepth--
|
||||
this._isInBatch = false
|
||||
}
|
||||
|
||||
return this
|
||||
}
|
||||
|
||||
private ignoringUpdates = (
|
||||
fn: (
|
||||
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)
|
||||
this.state = previousState
|
||||
}
|
||||
}
|
||||
|
||||
ignore(fn: () => void) {
|
||||
return this.batch(fn, { history: 'ignore' })
|
||||
}
|
||||
|
||||
// History
|
||||
private _undo = ({
|
||||
pushToRedoStack,
|
||||
|
@ -161,62 +129,66 @@ export class HistoryManager<
|
|||
pushToRedoStack: boolean
|
||||
toMark?: string
|
||||
}) => {
|
||||
this.ignoringUpdates((undos, redos) => {
|
||||
if (undos.length === 0) {
|
||||
return { undos, redos }
|
||||
const previousState = this.state
|
||||
this.state = HistoryRecorderState.Paused
|
||||
try {
|
||||
let { undos, redos } = this.stacks.get()
|
||||
|
||||
// start by collecting the pending diff (everything since the last mark).
|
||||
// we'll accumulate the diff to undo in this variable so we can apply it atomically.
|
||||
const pendingDiff = this.pendingDiff.clear()
|
||||
const isPendingDiffEmpty = isRecordsDiffEmpty(pendingDiff)
|
||||
const diffToUndo = reverseRecordsDiff(pendingDiff)
|
||||
|
||||
if (pushToRedoStack && !isPendingDiffEmpty) {
|
||||
redos = redos.push({ type: 'diff', diff: pendingDiff })
|
||||
}
|
||||
|
||||
while (undos.head?.type === 'STOP') {
|
||||
let didFindMark = false
|
||||
if (isPendingDiffEmpty) {
|
||||
// if nothing has happened since the last mark, pop any intermediate marks off the stack
|
||||
while (undos.head?.type === 'stop') {
|
||||
const mark = undos.head
|
||||
undos = undos.tail
|
||||
if (pushToRedoStack) {
|
||||
redos = redos.push(mark)
|
||||
}
|
||||
if (mark.id === toMark) {
|
||||
this.ctx.emit(
|
||||
'change-history',
|
||||
pushToRedoStack ? { reason: 'undo' } : { reason: 'bail', markId: toMark }
|
||||
)
|
||||
return { undos, redos }
|
||||
didFindMark = true
|
||||
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
|
||||
if (!didFindMark) {
|
||||
loop: while (undos.head) {
|
||||
const undo = undos.head
|
||||
undos = undos.tail
|
||||
|
||||
if (pushToRedoStack) {
|
||||
redos = redos.push(command)
|
||||
redos = redos.push(undo)
|
||||
}
|
||||
|
||||
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 }
|
||||
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)
|
||||
}
|
||||
} else {
|
||||
const handler = this._commands[command.name]
|
||||
handler.undo(command.data)
|
||||
}
|
||||
}
|
||||
|
||||
this.ctx.emit(
|
||||
'change-history',
|
||||
pushToRedoStack ? { reason: 'undo' } : { reason: 'bail', markId: toMark }
|
||||
)
|
||||
return { undos, redos }
|
||||
})
|
||||
this.store.applyDiff(diffToUndo, { ignoreEphemeralKeys: true })
|
||||
this.store.ensureStoreIsUsable()
|
||||
this.stacks.set({ undos, redos })
|
||||
} finally {
|
||||
this.state = previousState
|
||||
}
|
||||
|
||||
return this
|
||||
}
|
||||
|
@ -228,43 +200,43 @@ export class HistoryManager<
|
|||
}
|
||||
|
||||
redo = () => {
|
||||
this.ignoringUpdates((undos, redos) => {
|
||||
const previousState = this.state
|
||||
this.state = HistoryRecorderState.Paused
|
||||
try {
|
||||
this.flushPendingDiff()
|
||||
|
||||
let { undos, redos } = this.stacks.get()
|
||||
if (redos.length === 0) {
|
||||
return { undos, redos }
|
||||
return
|
||||
}
|
||||
|
||||
while (redos.head?.type === 'STOP') {
|
||||
// ignore any intermediate marks - this should take us to the first `diff` entry
|
||||
while (redos.head?.type === 'stop') {
|
||||
undos = undos.push(redos.head)
|
||||
redos = redos.tail
|
||||
}
|
||||
|
||||
if (redos.length === 0) {
|
||||
this.ctx.emit('change-history', { reason: 'redo' })
|
||||
return { undos, redos }
|
||||
}
|
||||
// accumulate diffs to be redone so they can be applied atomically
|
||||
const diffToRedo = createEmptyRecordsDiff<R>()
|
||||
|
||||
while (redos.head) {
|
||||
const command = redos.head
|
||||
undos = undos.push(redos.head)
|
||||
const redo = redos.head
|
||||
undos = undos.push(redo)
|
||||
redos = redos.tail
|
||||
|
||||
if (command.type === 'STOP') {
|
||||
if (command.onRedo) {
|
||||
if (redo.type === 'diff') {
|
||||
squashRecordDiffsMutable(diffToRedo, [redo.diff])
|
||||
} else {
|
||||
break
|
||||
}
|
||||
} else {
|
||||
const handler = this._commands[command.name]
|
||||
if (handler.redo) {
|
||||
handler.redo(command.data)
|
||||
} else {
|
||||
handler.do(command.data)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
this.ctx.emit('change-history', { reason: 'redo' })
|
||||
return { undos, redos }
|
||||
})
|
||||
this.store.applyDiff(diffToRedo, { ignoreEphemeralKeys: true })
|
||||
this.store.ensureStoreIsUsable()
|
||||
this.stacks.set({ undos, redos })
|
||||
} finally {
|
||||
this.state = previousState
|
||||
}
|
||||
|
||||
return this
|
||||
}
|
||||
|
@ -281,24 +253,59 @@ export class HistoryManager<
|
|||
return this
|
||||
}
|
||||
|
||||
mark = (id = uniqueId(), onUndo = true, onRedo = true) => {
|
||||
const mostRecent = this._undos.get().head
|
||||
// dedupe marks, why not
|
||||
if (mostRecent && mostRecent.type === 'STOP') {
|
||||
if (mostRecent.id === id && mostRecent.onUndo === onUndo && mostRecent.onRedo === onRedo) {
|
||||
return mostRecent.id
|
||||
}
|
||||
}
|
||||
|
||||
this._undos.update((undos) => undos.push({ type: 'STOP', id, onUndo, onRedo }))
|
||||
|
||||
this.ctx.emit('mark-history', { id })
|
||||
mark = (id = uniqueId()) => {
|
||||
transact(() => {
|
||||
this.flushPendingDiff()
|
||||
this.stacks.update(({ undos, redos }) => ({ undos: undos.push({ type: 'stop', id }), redos }))
|
||||
})
|
||||
|
||||
return id
|
||||
}
|
||||
|
||||
clear() {
|
||||
this._undos.set(stack())
|
||||
this._redos.set(stack())
|
||||
this.stacks.set({ undos: stack(), redos: stack() })
|
||||
this.pendingDiff.clear()
|
||||
}
|
||||
|
||||
/** @internal */
|
||||
debug() {
|
||||
const { undos, redos } = this.stacks.get()
|
||||
return {
|
||||
undos: undos.toArray(),
|
||||
redos: redos.toArray(),
|
||||
pendingDiff: this.pendingDiff.debug(),
|
||||
state: this.state,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const modeToState = {
|
||||
record: HistoryRecorderState.Recording,
|
||||
'record-preserveRedoStack': HistoryRecorderState.RecordingPreserveRedoStack,
|
||||
ignore: HistoryRecorderState.Paused,
|
||||
} as const
|
||||
|
||||
class PendingDiff<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,17 +88,8 @@ export class SideEffectManager<
|
|||
return next
|
||||
}
|
||||
|
||||
let updateDepth = 0
|
||||
|
||||
editor.store.onAfterChange = (prev, next, source) => {
|
||||
updateDepth++
|
||||
|
||||
if (updateDepth > 1000) {
|
||||
console.error('[CleanupManager.onAfterChange] Maximum update depth exceeded, bailing out.')
|
||||
} else {
|
||||
const handlers = this._afterChangeHandlers[
|
||||
next.typeName
|
||||
] as TLAfterChangeHandler<TLRecord>[]
|
||||
const handlers = this._afterChangeHandlers[next.typeName] as TLAfterChangeHandler<TLRecord>[]
|
||||
if (handlers) {
|
||||
for (const handler of handlers) {
|
||||
handler(prev, next, source)
|
||||
|
@ -106,9 +97,6 @@ export class SideEffectManager<
|
|||
}
|
||||
}
|
||||
|
||||
updateDepth--
|
||||
}
|
||||
|
||||
editor.store.onBeforeDelete = (record, source) => {
|
||||
const handlers = this._beforeDeleteHandlers[
|
||||
record.typeName
|
||||
|
@ -161,6 +149,46 @@ export class SideEffectManager<
|
|||
|
||||
private _batchCompleteHandlers: TLBatchCompleteHandler[] = []
|
||||
|
||||
/**
|
||||
* Internal helper for registering a bunch of side effects at once and keeping them organized.
|
||||
* @internal
|
||||
*/
|
||||
register(handlersByType: {
|
||||
[R in TLRecord as R['typeName']]?: {
|
||||
beforeCreate?: TLBeforeCreateHandler<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
|
||||
* modified record from the handler to change the record that will be created.
|
||||
|
|
|
@ -15,8 +15,6 @@ export interface TLEventMap {
|
|||
event: [TLEventInfo]
|
||||
tick: [number]
|
||||
frame: [number]
|
||||
'change-history': [{ reason: 'undo' | 'redo' | 'push' } | { reason: 'bail'; markId?: string }]
|
||||
'mark-history': [{ id: string }]
|
||||
'select-all-text': [{ shapeId: TLShapeId }]
|
||||
}
|
||||
|
||||
|
|
|
@ -1,50 +1,27 @@
|
|||
/** @public */
|
||||
export type TLCommandHistoryOptions = Partial<{
|
||||
/**
|
||||
* When true, this command will be squashed with the previous command in the undo / redo stack.
|
||||
*/
|
||||
squashing: boolean
|
||||
/**
|
||||
* When true, this command will not add anything to the undo / redo stack. Its change will never be undone or redone.
|
||||
*/
|
||||
ephemeral: boolean
|
||||
/**
|
||||
* When true, adding this this command will not clear out the redo stack.
|
||||
*/
|
||||
preservesRedoStack: boolean
|
||||
}>
|
||||
import { RecordsDiff, UnknownRecord } from '@tldraw/store'
|
||||
|
||||
/** @public */
|
||||
export type TLHistoryMark = {
|
||||
type: 'STOP'
|
||||
export interface TLHistoryMark {
|
||||
type: 'stop'
|
||||
id: string
|
||||
onUndo: boolean
|
||||
onRedo: boolean
|
||||
}
|
||||
|
||||
/** @public */
|
||||
export type TLCommand<Name extends string = any, Data = any> = {
|
||||
type: 'command'
|
||||
data: Data
|
||||
name: Name
|
||||
export interface TLHistoryDiff<R extends UnknownRecord> {
|
||||
type: 'diff'
|
||||
diff: RecordsDiff<R>
|
||||
}
|
||||
|
||||
/** @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
|
||||
* should not clear the redo stack. e.g. modifying the set of selected ids.
|
||||
* How should this change interact with the history stack?
|
||||
* - record: Add to the undo stack and clear the redo stack
|
||||
* - record-preserveRedoStack: Add to the undo stack but do not clear the redo stack
|
||||
* - ignore: Do not add to the undo stack or the redo stack
|
||||
*/
|
||||
preservesRedoStack?: boolean
|
||||
}
|
||||
|
||||
/** @public */
|
||||
export type TLHistoryEntry = TLHistoryMark | TLCommand
|
||||
|
||||
/** @public */
|
||||
export type TLCommandHandler<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
|
||||
history?: 'record' | 'record-preserveRedoStack' | 'ignore'
|
||||
}
|
||||
|
|
|
@ -33,6 +33,9 @@ export type ComputedCache<Data, R extends UnknownRecord> = {
|
|||
get(id: IdOf<R>): Data | undefined;
|
||||
};
|
||||
|
||||
// @internal (undocumented)
|
||||
export function createEmptyRecordsDiff<R extends UnknownRecord>(): RecordsDiff<R>;
|
||||
|
||||
// @public
|
||||
export function createMigrationIds<ID extends string, Versions extends Record<string, number>>(sequenceId: ID, versions: Versions): {
|
||||
[K in keyof Versions]: `${ID}/${Versions[K]}`;
|
||||
|
@ -58,6 +61,9 @@ export function createRecordMigrationSequence(opts: {
|
|||
|
||||
// @public
|
||||
export function createRecordType<R extends UnknownRecord>(typeName: R['typeName'], config: {
|
||||
ephemeralKeys?: {
|
||||
readonly [K in Exclude<keyof R, 'id' | 'typeName'>]: boolean;
|
||||
};
|
||||
scope: RecordScope;
|
||||
validator?: StoreValidator<R>;
|
||||
}): RecordType<R, keyof Omit<R, 'id' | 'typeName'>>;
|
||||
|
@ -98,6 +104,9 @@ export class IncrementalSetConstructor<T> {
|
|||
remove(item: T): void;
|
||||
}
|
||||
|
||||
// @internal
|
||||
export function isRecordsDiffEmpty<T extends UnknownRecord>(diff: RecordsDiff<T>): boolean;
|
||||
|
||||
// @public (undocumented)
|
||||
export type LegacyMigration<Before = any, After = any> = {
|
||||
down: (newState: After) => Before;
|
||||
|
@ -187,6 +196,9 @@ export class RecordType<R extends UnknownRecord, RequiredProperties extends keyo
|
|||
constructor(
|
||||
typeName: R['typeName'], config: {
|
||||
readonly createDefaultProperties: () => Exclude<OmitMeta<R>, RequiredProperties>;
|
||||
readonly ephemeralKeys?: {
|
||||
readonly [K in Exclude<keyof R, 'id' | 'typeName'>]: boolean;
|
||||
};
|
||||
readonly scope?: RecordScope;
|
||||
readonly validator?: StoreValidator<R>;
|
||||
});
|
||||
|
@ -197,6 +209,12 @@ export class RecordType<R extends UnknownRecord, RequiredProperties extends keyo
|
|||
// (undocumented)
|
||||
readonly createDefaultProperties: () => Exclude<OmitMeta<R>, RequiredProperties>;
|
||||
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>;
|
||||
isInstance: (record?: UnknownRecord) => record is R;
|
||||
parseId(id: IdOf<R>): string;
|
||||
|
@ -244,22 +262,32 @@ export type SerializedStore<R extends UnknownRecord> = Record<IdOf<R>, R>;
|
|||
// @public
|
||||
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
|
||||
export class Store<R extends UnknownRecord = UnknownRecord, Props = unknown> {
|
||||
constructor(config: {
|
||||
schema: StoreSchema<R, Props>;
|
||||
initialData?: SerializedStore<R>;
|
||||
id?: string;
|
||||
props: Props;
|
||||
});
|
||||
// @internal (undocumented)
|
||||
addHistoryInterceptor(fn: (entry: HistoryEntry<R>, source: ChangeSource) => void): () => void;
|
||||
allRecords: () => R[];
|
||||
// (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;
|
||||
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>;
|
||||
// @internal (undocumented)
|
||||
ensureStoreIsUsable(): void;
|
||||
// (undocumented)
|
||||
extractingChanges(fn: () => void): RecordsDiff<R>;
|
||||
filterChangesByScope(change: RecordsDiff<R>, scope: RecordScope): {
|
||||
added: { [K in IdOf<R>]: R; };
|
||||
|
@ -269,8 +297,6 @@ export class Store<R extends UnknownRecord = UnknownRecord, Props = unknown> {
|
|||
// (undocumented)
|
||||
_flushHistory(): void;
|
||||
get: <K extends IdOf<R>>(id: K) => RecFromId<K> | undefined;
|
||||
// (undocumented)
|
||||
getRecordType: <T extends R>(record: R) => T;
|
||||
getSnapshot(scope?: 'all' | RecordScope): StoreSnapshot<R>;
|
||||
has: <K extends IdOf<R>>(id: K) => boolean;
|
||||
readonly history: Atom<number, RecordsDiff<R>>;
|
||||
|
@ -331,6 +357,8 @@ export class StoreSchema<R extends UnknownRecord, P = unknown> {
|
|||
createIntegrityChecker(store: Store<R, P>): (() => void) | undefined;
|
||||
// (undocumented)
|
||||
getMigrationsSince(persistedSchema: SerializedSchema): Result<Migration[], string>;
|
||||
// @internal (undocumented)
|
||||
getType(typeName: string): RecordType<R, any>;
|
||||
// (undocumented)
|
||||
migratePersistedRecord(record: R, persistedSchema: SerializedSchema, direction?: 'down' | 'up'): MigrationResult<R>;
|
||||
// (undocumented)
|
||||
|
|
|
@ -1,12 +1,19 @@
|
|||
export type { BaseRecord, IdOf, RecordId, UnknownRecord } from './lib/BaseRecord'
|
||||
export { IncrementalSetConstructor } from './lib/IncrementalSetConstructor'
|
||||
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 {
|
||||
CollectionDiff,
|
||||
ComputedCache,
|
||||
HistoryEntry,
|
||||
RecordsDiff,
|
||||
SerializedStore,
|
||||
StoreError,
|
||||
StoreListener,
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
import { structuredClone } from '@tldraw/utils'
|
||||
import { objectMapEntries, structuredClone } from '@tldraw/utils'
|
||||
import { nanoid } from 'nanoid'
|
||||
import { IdOf, OmitMeta, UnknownRecord } from './BaseRecord'
|
||||
import { StoreValidator } from './Store'
|
||||
|
@ -28,7 +28,8 @@ export class RecordType<
|
|||
> {
|
||||
readonly createDefaultProperties: () => Exclude<OmitMeta<R>, RequiredProperties>
|
||||
readonly validator: StoreValidator<R>
|
||||
|
||||
readonly ephemeralKeys?: { readonly [K in Exclude<keyof R, 'id' | 'typeName'>]: boolean }
|
||||
readonly ephemeralKeySet: ReadonlySet<string>
|
||||
readonly scope: RecordScope
|
||||
|
||||
constructor(
|
||||
|
@ -43,11 +44,21 @@ export class RecordType<
|
|||
readonly createDefaultProperties: () => Exclude<OmitMeta<R>, RequiredProperties>
|
||||
readonly validator?: StoreValidator<R>
|
||||
readonly scope?: RecordScope
|
||||
readonly ephemeralKeys?: { readonly [K in Exclude<keyof R, 'id' | 'typeName'>]: boolean }
|
||||
}
|
||||
) {
|
||||
this.createDefaultProperties = config.createDefaultProperties
|
||||
this.validator = config.validator ?? { validate: (r: unknown) => r as R }
|
||||
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,
|
||||
validator: this.validator,
|
||||
scope: this.scope,
|
||||
ephemeralKeys: this.ephemeralKeys,
|
||||
})
|
||||
}
|
||||
|
||||
|
@ -218,12 +230,14 @@ export function createRecordType<R extends UnknownRecord>(
|
|||
config: {
|
||||
validator?: StoreValidator<R>
|
||||
scope: RecordScope
|
||||
ephemeralKeys?: { readonly [K in Exclude<keyof R, 'id' | 'typeName'>]: boolean }
|
||||
}
|
||||
): RecordType<R, keyof Omit<R, 'id' | 'typeName'>> {
|
||||
return new RecordType<R, keyof Omit<R, 'id' | 'typeName'>>(typeName, {
|
||||
createDefaultProperties: () => ({}) as any,
|
||||
validator: config.validator,
|
||||
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 {
|
||||
assert,
|
||||
filterEntries,
|
||||
getOwnProperty,
|
||||
objectMapEntries,
|
||||
objectMapFromEntries,
|
||||
objectMapKeys,
|
||||
|
@ -11,23 +13,13 @@ import { nanoid } from 'nanoid'
|
|||
import { IdOf, RecordId, UnknownRecord } from './BaseRecord'
|
||||
import { Cache } from './Cache'
|
||||
import { RecordScope } from './RecordType'
|
||||
import { RecordsDiff, squashRecordDiffs } from './RecordsDiff'
|
||||
import { StoreQueries } from './StoreQueries'
|
||||
import { SerializedSchema, StoreSchema } from './StoreSchema'
|
||||
import { devFreeze } from './devFreeze'
|
||||
|
||||
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.
|
||||
*
|
||||
|
@ -113,7 +105,7 @@ export class Store<R extends UnknownRecord = UnknownRecord, Props = unknown> {
|
|||
/**
|
||||
* The random id of the store.
|
||||
*/
|
||||
public readonly id = nanoid()
|
||||
public readonly id: string
|
||||
/**
|
||||
* 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']> }
|
||||
|
||||
constructor(config: {
|
||||
id?: string
|
||||
/** The store's initial data. */
|
||||
initialData?: SerializedStore<R>
|
||||
/**
|
||||
|
@ -178,8 +171,9 @@ export class Store<R extends UnknownRecord = UnknownRecord, Props = unknown> {
|
|||
schema: StoreSchema<R, Props>
|
||||
props: Props
|
||||
}) {
|
||||
const { initialData, schema } = config
|
||||
const { initialData, schema, id } = config
|
||||
|
||||
this.id = id ?? nanoid()
|
||||
this.schema = schema
|
||||
this.props = config.props
|
||||
|
||||
|
@ -357,7 +351,7 @@ export class Store<R extends UnknownRecord = UnknownRecord, Props = unknown> {
|
|||
* @public
|
||||
*/
|
||||
put = (records: R[], phaseOverride?: 'initialize'): void => {
|
||||
transact(() => {
|
||||
this.atomic(() => {
|
||||
const updates: Record<IdOf<UnknownRecord>, [from: R, to: R]> = {}
|
||||
const additions: Record<IdOf<UnknownRecord>, R> = {}
|
||||
|
||||
|
@ -402,7 +396,9 @@ export class Store<R extends UnknownRecord = UnknownRecord, Props = unknown> {
|
|||
recordAtom.set(devFreeze(record))
|
||||
|
||||
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 {
|
||||
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.
|
||||
additions[record.id] = record
|
||||
this.addDiffForAfterEvent(null, record, source)
|
||||
|
||||
// Assign the atom to the map under the record's id.
|
||||
if (!map) {
|
||||
|
@ -441,24 +438,6 @@ export class Store<R extends UnknownRecord = UnknownRecord, Props = unknown> {
|
|||
updated: updates,
|
||||
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
|
||||
*/
|
||||
remove = (ids: IdOf<R>[]): void => {
|
||||
transact(() => {
|
||||
this.atomic(() => {
|
||||
const cancelled = [] as IdOf<R>[]
|
||||
const source = this.isMergingRemoteChanges ? 'remote' : 'user'
|
||||
|
||||
|
@ -496,7 +475,9 @@ export class Store<R extends UnknownRecord = UnknownRecord, Props = unknown> {
|
|||
if (!result) result = { ...atoms }
|
||||
if (!removed) removed = {} as Record<IdOf<R>, R>
|
||||
delete result[id]
|
||||
removed[id] = atoms[id].get()
|
||||
const record = atoms[id].get()
|
||||
removed[id] = record
|
||||
this.addDiffForAfterEvent(record, null, source)
|
||||
}
|
||||
|
||||
return result ?? atoms
|
||||
|
@ -505,17 +486,6 @@ export class Store<R extends UnknownRecord = UnknownRecord, Props = unknown> {
|
|||
if (!removed) return
|
||||
// Update the history with the removed records.
|
||||
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
|
||||
try {
|
||||
this._runCallbacks = false
|
||||
transact(() => {
|
||||
this.atomic(() => {
|
||||
this.clear()
|
||||
this.put(Object.values(migrationResult.value))
|
||||
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> {
|
||||
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 {
|
||||
transact(fn)
|
||||
return squashRecordDiffs(changes)
|
||||
|
@ -742,14 +715,39 @@ export class Store<R extends UnknownRecord = UnknownRecord, Props = unknown> {
|
|||
}
|
||||
}
|
||||
|
||||
applyDiff(diff: RecordsDiff<R>, runCallbacks = true) {
|
||||
const prevRunCallbacks = this._runCallbacks
|
||||
try {
|
||||
this._runCallbacks = runCallbacks
|
||||
transact(() => {
|
||||
const toPut = objectMapValues(diff.added).concat(
|
||||
objectMapValues(diff.updated).map(([_from, to]) => to)
|
||||
)
|
||||
applyDiff(
|
||||
diff: RecordsDiff<R>,
|
||||
{
|
||||
runCallbacks = true,
|
||||
ignoreEphemeralKeys = false,
|
||||
}: { runCallbacks?: boolean; ignoreEphemeralKeys?: boolean } = {}
|
||||
) {
|
||||
this.atomic(() => {
|
||||
const toPut = objectMapValues(diff.added)
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
const toRemove = objectMapKeys(diff.removed)
|
||||
if (toPut.length) {
|
||||
this.put(toPut)
|
||||
|
@ -757,10 +755,7 @@ export class Store<R extends UnknownRecord = UnknownRecord, Props = unknown> {
|
|||
if (toRemove.length) {
|
||||
this.remove(toRemove)
|
||||
}
|
||||
})
|
||||
} finally {
|
||||
this._runCallbacks = prevRunCallbacks
|
||||
}
|
||||
}, 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
|
||||
|
||||
/** @internal */
|
||||
ensureStoreIsUsable() {
|
||||
this.atomic(() => {
|
||||
this._integrityChecker ??= this.schema.createIntegrityChecker(this)
|
||||
this._integrityChecker?.()
|
||||
})
|
||||
}
|
||||
|
||||
private _isPossiblyCorrupted = false
|
||||
|
@ -852,64 +841,82 @@ export class Store<R extends UnknownRecord = UnknownRecord, Props = unknown> {
|
|||
isPossiblyCorrupted() {
|
||||
return this._isPossiblyCorrupted
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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>
|
||||
|
||||
for (const diff of diffs) {
|
||||
for (const [id, value] of objectMapEntries(diff.added)) {
|
||||
if (result.removed[id]) {
|
||||
const original = result.removed[id]
|
||||
delete result.removed[id]
|
||||
if (original !== value) {
|
||||
result.updated[id] = [original, value]
|
||||
}
|
||||
private pendingAfterEvents: Map<
|
||||
IdOf<R>,
|
||||
{ before: R | null; after: R | null; source: 'remote' | 'user' }
|
||||
> | null = null
|
||||
private addDiffForAfterEvent(before: R | null, after: R | null, source: 'remote' | 'user') {
|
||||
assert(this.pendingAfterEvents, 'must be in event operation')
|
||||
if (before === after) return
|
||||
if (before && after) assert(before.id === after.id)
|
||||
if (!before && !after) return
|
||||
const id = (before || after)!.id
|
||||
const existing = this.pendingAfterEvents.get(id)
|
||||
if (existing) {
|
||||
assert(existing.source === source, 'source cannot change within a single event operation')
|
||||
existing.after = after
|
||||
} else {
|
||||
result.added[id] = value
|
||||
this.pendingAfterEvents.set(id, { before, after, source })
|
||||
}
|
||||
}
|
||||
private flushAtomicCallbacks() {
|
||||
let updateDepth = 0
|
||||
while (this.pendingAfterEvents) {
|
||||
const events = this.pendingAfterEvents
|
||||
this.pendingAfterEvents = null
|
||||
|
||||
if (!this._runCallbacks) continue
|
||||
|
||||
updateDepth++
|
||||
if (updateDepth > 100) {
|
||||
throw new Error('Maximum store update depth exceeded, bailing out')
|
||||
}
|
||||
|
||||
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
|
||||
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)
|
||||
}
|
||||
if (result.updated[id]) {
|
||||
result.updated[id] = [result.updated[id][0], to]
|
||||
delete result.removed[id]
|
||||
continue
|
||||
}
|
||||
}
|
||||
}
|
||||
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()
|
||||
}
|
||||
|
||||
result.updated[id] = diff.updated[id]
|
||||
delete result.removed[id]
|
||||
}
|
||||
this.pendingAfterEvents = new Map()
|
||||
const prevRunCallbacks = this._runCallbacks
|
||||
this._runCallbacks = runCallbacks ?? prevRunCallbacks
|
||||
this._isInAtomicOp = true
|
||||
try {
|
||||
const result = fn()
|
||||
|
||||
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
|
||||
}
|
||||
}
|
||||
}
|
||||
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> {
|
||||
private _history: HistoryEntry<T>[] = []
|
||||
|
||||
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)
|
||||
return () => {
|
||||
this._interceptors.delete(fn)
|
||||
|
|
|
@ -12,8 +12,9 @@ import isEqual from 'lodash.isequal'
|
|||
import { IdOf, UnknownRecord } from './BaseRecord'
|
||||
import { executeQuery, objectMatchesQuery, QueryExpression } from './executeQuery'
|
||||
import { IncrementalSetConstructor } from './IncrementalSetConstructor'
|
||||
import { RecordsDiff } from './RecordsDiff'
|
||||
import { diffSets } from './setUtils'
|
||||
import { CollectionDiff, RecordsDiff } from './Store'
|
||||
import { CollectionDiff } from './Store'
|
||||
|
||||
export type RSIndexDiff<
|
||||
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 { BaseRecord, RecordId } from '../BaseRecord'
|
||||
import { createMigrationSequence } from '../migrate'
|
||||
import { RecordsDiff, reverseRecordsDiff } from '../RecordsDiff'
|
||||
import { createRecordType } from '../RecordType'
|
||||
import { CollectionDiff, RecordsDiff, Store } from '../Store'
|
||||
import { CollectionDiff, Store } from '../Store'
|
||||
import { StoreSchema } from '../StoreSchema'
|
||||
|
||||
interface Book extends BaseRecord<'book', RecordId<Book>> {
|
||||
|
@ -881,3 +882,270 @@ describe('snapshots', () => {
|
|||
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)
|
||||
items: StyleValuesForUi<T>;
|
||||
// (undocumented)
|
||||
onValueChange: (style: StyleProp<T>, value: T, squashing: boolean) => void;
|
||||
onValueChange: (style: StyleProp<T>, value: T) => void;
|
||||
// (undocumented)
|
||||
style: StyleProp<T>;
|
||||
// (undocumented)
|
||||
|
@ -2206,6 +2206,8 @@ export interface TLUiInputProps {
|
|||
// (undocumented)
|
||||
onComplete?: (value: string) => void;
|
||||
// (undocumented)
|
||||
onFocus?: () => void;
|
||||
// (undocumented)
|
||||
onValueChange?: (value: string) => void;
|
||||
// (undocumented)
|
||||
placeholder?: string;
|
||||
|
@ -2332,7 +2334,7 @@ export interface TLUiSliderProps {
|
|||
// (undocumented)
|
||||
label: string;
|
||||
// (undocumented)
|
||||
onValueChange: (value: number, squashing: boolean) => void;
|
||||
onValueChange: (value: number) => void;
|
||||
// (undocumented)
|
||||
steps: number;
|
||||
// (undocumented)
|
||||
|
|
|
@ -115,7 +115,7 @@ export class Pointing extends StateNode {
|
|||
if (startTerminal?.type === 'binding') {
|
||||
this.editor.setHintingShapes([startTerminal.boundShapeId])
|
||||
}
|
||||
this.editor.updateShapes([change], { squashing: true })
|
||||
this.editor.updateShapes([change])
|
||||
}
|
||||
|
||||
// Cache the current shape after those changes
|
||||
|
@ -152,7 +152,7 @@ export class Pointing extends StateNode {
|
|||
if (endTerminal?.type === 'binding') {
|
||||
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) {
|
||||
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], {
|
||||
squashing: true,
|
||||
})
|
||||
this.editor.updateShapes<TLDrawShape | TLHighlightShape>([shapePartial])
|
||||
}
|
||||
break
|
||||
}
|
||||
|
@ -433,7 +431,7 @@ export class Drawing extends StateNode {
|
|||
)
|
||||
}
|
||||
|
||||
this.editor.updateShapes([shapePartial], { squashing: true })
|
||||
this.editor.updateShapes([shapePartial])
|
||||
}
|
||||
|
||||
break
|
||||
|
@ -574,7 +572,7 @@ export class Drawing extends StateNode {
|
|||
)
|
||||
}
|
||||
|
||||
this.editor.updateShapes([shapePartial], { squashing: true })
|
||||
this.editor.updateShapes([shapePartial])
|
||||
|
||||
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.
|
||||
if (newPoints.length > 500) {
|
||||
|
|
|
@ -30,16 +30,13 @@ export const FrameLabelInput = forwardRef<
|
|||
const value = e.currentTarget.value.trim()
|
||||
if (name === value) return
|
||||
|
||||
editor.updateShapes(
|
||||
[
|
||||
editor.updateShapes([
|
||||
{
|
||||
id,
|
||||
type: 'frame',
|
||||
props: { name: value },
|
||||
},
|
||||
],
|
||||
{ squashing: true }
|
||||
)
|
||||
])
|
||||
},
|
||||
[id, editor]
|
||||
)
|
||||
|
@ -53,16 +50,13 @@ export const FrameLabelInput = forwardRef<
|
|||
const value = e.currentTarget.value
|
||||
if (name === value) return
|
||||
|
||||
editor.updateShapes(
|
||||
[
|
||||
editor.updateShapes([
|
||||
{
|
||||
id,
|
||||
type: 'frame',
|
||||
props: { name: value },
|
||||
},
|
||||
],
|
||||
{ squashing: true }
|
||||
)
|
||||
])
|
||||
},
|
||||
[id, editor]
|
||||
)
|
||||
|
|
|
@ -25,7 +25,6 @@ describe(NoteShapeTool, () => {
|
|||
|
||||
editor.cancel() // leave edit mode
|
||||
|
||||
editor.undo() // undoes the selection change
|
||||
editor.undo()
|
||||
|
||||
expect(editor.getCurrentPageShapes().length).toBe(0)
|
||||
|
|
|
@ -5,10 +5,7 @@ export class Pointing extends StateNode {
|
|||
|
||||
override onEnter = () => {
|
||||
this.editor.stopCameraAnimation()
|
||||
this.editor.updateInstanceState(
|
||||
{ cursor: { type: 'grabbing', rotation: 0 } },
|
||||
{ ephemeral: true }
|
||||
)
|
||||
this.editor.setCursor({ type: 'grabbing', rotation: 0 })
|
||||
}
|
||||
|
||||
override onLongPress: TLEventHandlers['onLongPress'] = () => {
|
||||
|
|
|
@ -79,7 +79,7 @@ export class Brushing extends StateNode {
|
|||
}
|
||||
|
||||
override onCancel?: TLCancelEvent | undefined = (info) => {
|
||||
this.editor.setSelectedShapes(this.initialSelectedShapeIds, { squashing: true })
|
||||
this.editor.setSelectedShapes(this.initialSelectedShapeIds)
|
||||
this.parent.transition('idle', info)
|
||||
}
|
||||
|
||||
|
@ -176,7 +176,7 @@ export class Brushing extends StateNode {
|
|||
|
||||
const current = editor.getSelectedShapeIds()
|
||||
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'
|
||||
|
||||
override onEnter = () => {
|
||||
this.editor.updateInstanceState(
|
||||
{ cursor: { type: 'default', rotation: 0 } },
|
||||
{ ephemeral: true }
|
||||
)
|
||||
this.editor.setCursor({ type: 'default', rotation: 0 })
|
||||
|
||||
const onlySelectedShape = this.editor.getOnlySelectedShape()
|
||||
|
||||
// well this fucking sucks. what the fuck.
|
||||
// it's possible for a user to enter cropping, then undo
|
||||
// (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) {
|
||||
this.editor.mark('crop')
|
||||
|
@ -25,12 +22,9 @@ export class Idle extends StateNode {
|
|||
}
|
||||
|
||||
override onExit: TLExitEventHandler = () => {
|
||||
this.editor.updateInstanceState(
|
||||
{ cursor: { type: 'default', rotation: 0 } },
|
||||
{ ephemeral: true }
|
||||
)
|
||||
this.editor.setCursor({ type: 'default', rotation: 0 })
|
||||
|
||||
this.editor.off('change-history', this.cleanupCroppingState)
|
||||
this.editor.off('tick', this.cleanupCroppingState)
|
||||
}
|
||||
|
||||
override onCancel: TLEventHandlers['onCancel'] = () => {
|
||||
|
|
|
@ -32,10 +32,7 @@ export class TranslatingCrop extends StateNode {
|
|||
}
|
||||
|
||||
override onExit = () => {
|
||||
this.editor.updateInstanceState(
|
||||
{ cursor: { type: 'default', rotation: 0 } },
|
||||
{ ephemeral: true }
|
||||
)
|
||||
this.editor.setCursor({ type: 'default', rotation: 0 })
|
||||
}
|
||||
|
||||
override onPointerMove = () => {
|
||||
|
@ -102,7 +99,7 @@ export class TranslatingCrop extends StateNode {
|
|||
const partial = getTranslateCroppedImageChange(this.editor, shape, delta)
|
||||
|
||||
if (partial) {
|
||||
this.editor.updateShapes([partial], { squashing: true })
|
||||
this.editor.updateShapes([partial])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -65,12 +65,7 @@ export class Cropping extends StateNode {
|
|||
if (!selectedShape) return
|
||||
|
||||
const cursorType = CursorTypeMap[this.info.handle!]
|
||||
this.editor.updateInstanceState({
|
||||
cursor: {
|
||||
type: cursorType,
|
||||
rotation: this.editor.getSelectionRotation(),
|
||||
},
|
||||
})
|
||||
this.editor.setCursor({ type: cursorType, rotation: this.editor.getSelectionRotation() })
|
||||
}
|
||||
|
||||
private getDefaultCrop = (): TLImageShapeCrop => ({
|
||||
|
@ -201,7 +196,7 @@ export class Cropping extends StateNode {
|
|||
},
|
||||
}
|
||||
|
||||
this.editor.updateShapes([partial], { squashing: true })
|
||||
this.editor.updateShapes([partial])
|
||||
this.updateCursor()
|
||||
}
|
||||
|
||||
|
|
|
@ -82,10 +82,7 @@ export class DraggingHandle extends StateNode {
|
|||
this.initialPageRotation = this.initialPageTransform.rotation()
|
||||
this.initialPagePoint = this.editor.inputs.originPagePoint.clone()
|
||||
|
||||
this.editor.updateInstanceState(
|
||||
{ cursor: { type: isCreating ? 'cross' : 'grabbing', rotation: 0 } },
|
||||
{ ephemeral: true }
|
||||
)
|
||||
this.editor.setCursor({ type: isCreating ? 'cross' : 'grabbing', rotation: 0 })
|
||||
|
||||
const handles = this.editor.getShapeHandles(shape)!.sort(sortByIndex)
|
||||
const index = handles.findIndex((h) => h.id === info.handle.id)
|
||||
|
@ -196,10 +193,7 @@ export class DraggingHandle extends StateNode {
|
|||
this.editor.setHintingShapes([])
|
||||
this.editor.snaps.clearIndicators()
|
||||
|
||||
this.editor.updateInstanceState(
|
||||
{ cursor: { type: 'default', rotation: 0 } },
|
||||
{ ephemeral: true }
|
||||
)
|
||||
this.editor.setCursor({ type: 'default', rotation: 0 })
|
||||
}
|
||||
|
||||
private complete() {
|
||||
|
@ -312,7 +306,7 @@ export class DraggingHandle extends StateNode {
|
|||
}
|
||||
|
||||
if (changes) {
|
||||
editor.updateShapes([next], { squashing: true })
|
||||
editor.updateShapes([next])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -39,10 +39,7 @@ export class Idle extends StateNode {
|
|||
override onEnter = () => {
|
||||
this.parent.setCurrentToolIdMask(undefined)
|
||||
updateHoveredId(this.editor)
|
||||
this.editor.updateInstanceState(
|
||||
{ cursor: { type: 'default', rotation: 0 } },
|
||||
{ ephemeral: true }
|
||||
)
|
||||
this.editor.setCursor({ type: 'default', rotation: 0 })
|
||||
}
|
||||
|
||||
override onPointerMove: TLEventHandlers['onPointerMove'] = () => {
|
||||
|
|
|
@ -62,10 +62,7 @@ export class PointingArrowLabel extends StateNode {
|
|||
override onExit = () => {
|
||||
this.parent.setCurrentToolIdMask(undefined)
|
||||
|
||||
this.editor.updateInstanceState(
|
||||
{ cursor: { type: 'default', rotation: 0 } },
|
||||
{ ephemeral: true }
|
||||
)
|
||||
this.editor.setCursor({ type: 'default', rotation: 0 })
|
||||
}
|
||||
|
||||
private _labelDragOffset = new Vec(0, 0)
|
||||
|
@ -105,10 +102,11 @@ export class PointingArrowLabel extends StateNode {
|
|||
}
|
||||
|
||||
this.didDrag = true
|
||||
this.editor.updateShape<TLArrowShape>(
|
||||
{ id: shape.id, type: shape.type, props: { labelPosition: nextLabelPosition } },
|
||||
{ squashing: true }
|
||||
)
|
||||
this.editor.updateShape<TLArrowShape>({
|
||||
id: shape.id,
|
||||
type: shape.type,
|
||||
props: { labelPosition: nextLabelPosition },
|
||||
})
|
||||
}
|
||||
|
||||
override onPointerUp = () => {
|
||||
|
|
|
@ -19,20 +19,12 @@ export class PointingCropHandle extends StateNode {
|
|||
if (!selectedShape) return
|
||||
|
||||
const cursorType = CursorTypeMap[this.info.handle!]
|
||||
this.editor.updateInstanceState({
|
||||
cursor: {
|
||||
type: cursorType,
|
||||
rotation: this.editor.getSelectionRotation(),
|
||||
},
|
||||
})
|
||||
this.editor.setCursor({ type: cursorType, rotation: this.editor.getSelectionRotation() })
|
||||
this.editor.setCroppingShape(selectedShape.id)
|
||||
}
|
||||
|
||||
override onExit = () => {
|
||||
this.editor.updateInstanceState(
|
||||
{ cursor: { type: 'default', rotation: 0 } },
|
||||
{ ephemeral: true }
|
||||
)
|
||||
this.editor.setCursor({ type: 'default', rotation: 0 })
|
||||
this.parent.setCurrentToolIdMask(undefined)
|
||||
}
|
||||
|
||||
|
|
|
@ -32,18 +32,12 @@ export class PointingHandle extends StateNode {
|
|||
}
|
||||
}
|
||||
|
||||
this.editor.updateInstanceState(
|
||||
{ cursor: { type: 'grabbing', rotation: 0 } },
|
||||
{ ephemeral: true }
|
||||
)
|
||||
this.editor.setCursor({ type: 'grabbing', rotation: 0 })
|
||||
}
|
||||
|
||||
override onExit = () => {
|
||||
this.editor.setHintingShapes([])
|
||||
this.editor.updateInstanceState(
|
||||
{ cursor: { type: 'default', rotation: 0 } },
|
||||
{ ephemeral: true }
|
||||
)
|
||||
this.editor.setCursor({ type: 'default', rotation: 0 })
|
||||
}
|
||||
|
||||
override onPointerUp: TLEventHandlers['onPointerUp'] = () => {
|
||||
|
|
|
@ -34,11 +34,9 @@ export class PointingResizeHandle extends StateNode {
|
|||
private updateCursor() {
|
||||
const selected = this.editor.getSelectedShapes()
|
||||
const cursorType = CursorTypeMap[this.info.handle!]
|
||||
this.editor.updateInstanceState({
|
||||
cursor: {
|
||||
this.editor.setCursor({
|
||||
type: cursorType,
|
||||
rotation: selected.length === 1 ? this.editor.getSelectionRotation() : 0,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
|
|
|
@ -11,11 +11,9 @@ export class PointingRotateHandle extends StateNode {
|
|||
private info = {} as PointingRotateHandleInfo
|
||||
|
||||
private updateCursor() {
|
||||
this.editor.updateInstanceState({
|
||||
cursor: {
|
||||
this.editor.setCursor({
|
||||
type: CursorTypeMap[this.info.handle as RotateCorner],
|
||||
rotation: this.editor.getSelectionRotation(),
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
|
@ -27,10 +25,7 @@ export class PointingRotateHandle extends StateNode {
|
|||
|
||||
override onExit = () => {
|
||||
this.parent.setCurrentToolIdMask(undefined)
|
||||
this.editor.updateInstanceState(
|
||||
{ cursor: { type: 'default', rotation: 0 } },
|
||||
{ ephemeral: true }
|
||||
)
|
||||
this.editor.setCursor({ type: 'default', rotation: 0 })
|
||||
}
|
||||
|
||||
override onPointerMove: TLEventHandlers['onPointerMove'] = () => {
|
||||
|
|
|
@ -61,10 +61,7 @@ export class Resizing extends StateNode {
|
|||
if (isCreating) {
|
||||
this.markId = `creating:${this.editor.getOnlySelectedShape()!.id}`
|
||||
|
||||
this.editor.updateInstanceState(
|
||||
{ cursor: { type: 'cross', rotation: 0 } },
|
||||
{ ephemeral: true }
|
||||
)
|
||||
this.editor.setCursor({ type: 'cross', rotation: 0 })
|
||||
} else {
|
||||
this.markId = 'starting resizing'
|
||||
this.editor.mark(this.markId)
|
||||
|
@ -407,10 +404,7 @@ export class Resizing extends StateNode {
|
|||
|
||||
override onExit = () => {
|
||||
this.parent.setCurrentToolIdMask(undefined)
|
||||
this.editor.updateInstanceState(
|
||||
{ cursor: { type: 'default', rotation: 0 } },
|
||||
{ ephemeral: true }
|
||||
)
|
||||
this.editor.setCursor({ type: 'default', rotation: 0 })
|
||||
this.editor.snaps.clearIndicators()
|
||||
}
|
||||
|
||||
|
|
|
@ -51,11 +51,9 @@ export class Rotating extends StateNode {
|
|||
})
|
||||
|
||||
// Update cursor
|
||||
this.editor.updateInstanceState({
|
||||
cursor: {
|
||||
this.editor.setCursor({
|
||||
type: CursorTypeMap[this.info.handle as RotateCorner],
|
||||
rotation: newSelectionRotation + this.snapshot.initialSelectionRotation,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
|
@ -105,11 +103,9 @@ export class Rotating extends StateNode {
|
|||
})
|
||||
|
||||
// Update cursor
|
||||
this.editor.updateInstanceState({
|
||||
cursor: {
|
||||
this.editor.setCursor({
|
||||
type: CursorTypeMap[this.info.handle as RotateCorner],
|
||||
rotation: newSelectionRotation + this.snapshot.initialSelectionRotation,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
|
|
|
@ -164,7 +164,7 @@ export class ScribbleBrushing extends StateNode {
|
|||
shiftKey ? [...newlySelectedShapeIds, ...initialSelectedShapeIds] : [...newlySelectedShapeIds]
|
||||
)
|
||||
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() {
|
||||
this.editor.setSelectedShapes([...this.initialSelectedShapeIds], { squashing: true })
|
||||
this.editor.setSelectedShapes([...this.initialSelectedShapeIds])
|
||||
this.parent.transition('idle')
|
||||
}
|
||||
}
|
||||
|
|
|
@ -505,7 +505,6 @@ export function moveShapesToPoint({
|
|||
y: newLocalPoint.y,
|
||||
}
|
||||
})
|
||||
),
|
||||
{ squashing: true }
|
||||
)
|
||||
)
|
||||
}
|
||||
|
|
|
@ -19,10 +19,7 @@ export class ZoomTool extends StateNode {
|
|||
|
||||
override onExit = () => {
|
||||
this.parent.setCurrentToolIdMask(undefined)
|
||||
this.editor.updateInstanceState(
|
||||
{ zoomBrush: null, cursor: { type: 'default', rotation: 0 } },
|
||||
{ ephemeral: true }
|
||||
)
|
||||
this.editor.updateInstanceState({ zoomBrush: null, cursor: { type: 'default', rotation: 0 } })
|
||||
this.parent.setCurrentToolIdMask(undefined)
|
||||
}
|
||||
|
||||
|
@ -53,15 +50,9 @@ export class ZoomTool extends StateNode {
|
|||
|
||||
private updateCursor() {
|
||||
if (this.editor.inputs.altKey) {
|
||||
this.editor.updateInstanceState(
|
||||
{ cursor: { type: 'zoom-out', rotation: 0 } },
|
||||
{ ephemeral: true }
|
||||
)
|
||||
this.editor.setCursor({ type: 'zoom-out', rotation: 0 })
|
||||
} else {
|
||||
this.editor.updateInstanceState(
|
||||
{ cursor: { type: 'zoom-in', rotation: 0 } },
|
||||
{ ephemeral: true }
|
||||
)
|
||||
this.editor.setCursor({ type: 'zoom-in', rotation: 0 })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -37,7 +37,7 @@ export function MobileStylePanel() {
|
|||
const handleStylesOpenChange = useCallback(
|
||||
(isOpen: boolean) => {
|
||||
if (!isOpen) {
|
||||
editor.updateInstanceState({ isChangingStyle: false }, { ephemeral: true })
|
||||
editor.updateInstanceState({ isChangingStyle: false })
|
||||
}
|
||||
},
|
||||
[editor]
|
||||
|
|
|
@ -15,17 +15,13 @@ export const PageItemInput = function PageItemInput({
|
|||
|
||||
const rInput = useRef<HTMLInputElement | null>(null)
|
||||
|
||||
const handleFocus = useCallback(() => {
|
||||
editor.mark('rename page')
|
||||
}, [editor])
|
||||
|
||||
const handleChange = useCallback(
|
||||
(value: string) => {
|
||||
editor.renamePage(id, value ? value : 'New Page', { ephemeral: true })
|
||||
},
|
||||
[editor, id]
|
||||
)
|
||||
|
||||
const handleComplete = useCallback(
|
||||
(value: string) => {
|
||||
editor.mark('rename page')
|
||||
editor.renamePage(id, value || 'New Page', { ephemeral: false })
|
||||
editor.renamePage(id, value || 'New Page')
|
||||
},
|
||||
[editor, id]
|
||||
)
|
||||
|
@ -36,8 +32,7 @@ export const PageItemInput = function PageItemInput({
|
|||
ref={(el) => (rInput.current = el)}
|
||||
defaultValue={name}
|
||||
onValueChange={handleChange}
|
||||
onComplete={handleComplete}
|
||||
onCancel={handleComplete}
|
||||
onFocus={handleFocus}
|
||||
shouldManuallyMaintainScrollPositionWhenFocused
|
||||
autofocus={isCurrentPage}
|
||||
autoselect
|
||||
|
|
|
@ -21,7 +21,7 @@ export const DefaultStylePanel = memo(function DefaultStylePanel({
|
|||
|
||||
const handlePointerOut = useCallback(() => {
|
||||
if (!isMobile) {
|
||||
editor.updateInstanceState({ isChangingStyle: false }, { ephemeral: true })
|
||||
editor.updateInstanceState({ isChangingStyle: false })
|
||||
}
|
||||
}, [editor, isMobile])
|
||||
|
||||
|
|
|
@ -78,13 +78,13 @@ function useStyleChangeCallback() {
|
|||
|
||||
return React.useMemo(
|
||||
() =>
|
||||
function handleStyleChange<T>(style: StyleProp<T>, value: T, squashing: boolean) {
|
||||
function handleStyleChange<T>(style: StyleProp<T>, value: T) {
|
||||
editor.batch(() => {
|
||||
if (editor.isIn('select')) {
|
||||
editor.setStyleForSelectedShapes(style, value, { squashing })
|
||||
editor.setStyleForSelectedShapes(style, value)
|
||||
}
|
||||
editor.setStyleForNextShapes(style, value, { squashing })
|
||||
editor.updateInstanceState({ isChangingStyle: true }, { ephemeral: true })
|
||||
editor.setStyleForNextShapes(style, value)
|
||||
editor.updateInstanceState({ isChangingStyle: true })
|
||||
})
|
||||
|
||||
trackEvent('set-style', { source: 'style-panel', id: style.id, value: value as string })
|
||||
|
@ -165,8 +165,8 @@ export function CommonStylePickerSet({
|
|||
style={DefaultSizeStyle}
|
||||
items={STYLES.size}
|
||||
value={size}
|
||||
onValueChange={(style, value, squashing) => {
|
||||
handleValueChange(style, value, squashing)
|
||||
onValueChange={(style, value) => {
|
||||
handleValueChange(style, value)
|
||||
const selectedShapeIds = editor.getSelectedShapeIds()
|
||||
if (selectedShapeIds.length > 0) {
|
||||
kickoutOccludedShapes(editor, selectedShapeIds)
|
||||
|
@ -333,14 +333,14 @@ export function OpacitySlider() {
|
|||
const msg = useTranslation()
|
||||
|
||||
const handleOpacityValueChange = React.useCallback(
|
||||
(value: number, squashing: boolean) => {
|
||||
(value: number) => {
|
||||
const item = tldrawSupportedOpacities[value]
|
||||
editor.batch(() => {
|
||||
if (editor.isIn('select')) {
|
||||
editor.setOpacityForSelectedShapes(item, { squashing })
|
||||
editor.setOpacityForSelectedShapes(item)
|
||||
}
|
||||
editor.setOpacityForNextShapes(item, { squashing })
|
||||
editor.updateInstanceState({ isChangingStyle: true }, { ephemeral: true })
|
||||
editor.setOpacityForNextShapes(item)
|
||||
editor.updateInstanceState({ isChangingStyle: true })
|
||||
})
|
||||
|
||||
trackEvent('set-style', { source: 'style-panel', id: 'opacity', value })
|
||||
|
|
|
@ -24,7 +24,7 @@ interface DoubleDropdownPickerProps<T extends string> {
|
|||
styleB: StyleProp<T>
|
||||
valueA: 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>({
|
||||
|
@ -88,7 +88,7 @@ function _DoubleDropdownPicker<T extends string>({
|
|||
<TldrawUiButton
|
||||
type="icon"
|
||||
key={item.value}
|
||||
onClick={() => onValueChange(styleA, item.value, false)}
|
||||
onClick={() => onValueChange(styleA, item.value)}
|
||||
title={`${msg(labelA)} — ${msg(`${uiTypeA}-style.${item.value}`)}`}
|
||||
>
|
||||
<TldrawUiButtonIcon icon={item.icon} invertIcon />
|
||||
|
@ -124,7 +124,7 @@ function _DoubleDropdownPicker<T extends string>({
|
|||
type="icon"
|
||||
title={`${msg(labelB)} — ${msg(`${uiTypeB}-style.${item.value}` as TLUiTranslationKey)}`}
|
||||
data-testid={`style.${uiTypeB}.${item.value}`}
|
||||
onClick={() => onValueChange(styleB, item.value, false)}
|
||||
onClick={() => onValueChange(styleB, item.value)}
|
||||
>
|
||||
<TldrawUiButtonIcon icon={item.icon} />
|
||||
</TldrawUiButton>
|
||||
|
|
|
@ -22,7 +22,7 @@ interface DropdownPickerProps<T extends string> {
|
|||
value: SharedStyle<T>
|
||||
items: StyleValuesForUi<T>
|
||||
type: TLUiButtonProps['type']
|
||||
onValueChange: (style: StyleProp<T>, value: T, squashing: boolean) => void
|
||||
onValueChange: (style: StyleProp<T>, value: T) => void
|
||||
}
|
||||
|
||||
function _DropdownPicker<T extends string>({
|
||||
|
@ -68,7 +68,7 @@ function _DropdownPicker<T extends string>({
|
|||
title={msg(`${uiType}-style.${item.value}` as TLUiTranslationKey)}
|
||||
onClick={() => {
|
||||
editor.mark('select style dropdown item')
|
||||
onValueChange(style, item.value, false)
|
||||
onValueChange(style, item.value)
|
||||
}}
|
||||
>
|
||||
<TldrawUiButtonIcon icon={item.icon} />
|
||||
|
|
|
@ -22,7 +22,7 @@ export interface TLUiButtonPickerProps<T extends string> {
|
|||
value: SharedStyle<T>
|
||||
items: StyleValuesForUi<T>
|
||||
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>) {
|
||||
|
@ -57,14 +57,14 @@ function _TldrawUiButtonPicker<T extends string>(props: TLUiButtonPickerProps<T>
|
|||
if (value.type === 'shared' && value.value === id) return
|
||||
|
||||
editor.mark('point picker item')
|
||||
onValueChange(style, id as T, false)
|
||||
onValueChange(style, id as T)
|
||||
}
|
||||
|
||||
const handleButtonPointerDown = (e: React.PointerEvent<HTMLButtonElement>) => {
|
||||
const { id } = e.currentTarget.dataset
|
||||
|
||||
editor.mark('point picker item')
|
||||
onValueChange(style, id as T, true)
|
||||
onValueChange(style, id as T)
|
||||
|
||||
rPointing.current = true
|
||||
window.addEventListener('pointerup', handlePointerUp) // see TLD-658
|
||||
|
@ -74,14 +74,14 @@ function _TldrawUiButtonPicker<T extends string>(props: TLUiButtonPickerProps<T>
|
|||
if (!rPointing.current) return
|
||||
|
||||
const { id } = e.currentTarget.dataset
|
||||
onValueChange(style, id as T, true)
|
||||
onValueChange(style, id as T)
|
||||
}
|
||||
|
||||
const handleButtonPointerUp = (e: React.PointerEvent<HTMLButtonElement>) => {
|
||||
const { id } = e.currentTarget.dataset
|
||||
if (value.type === 'shared' && value.value === id) return
|
||||
|
||||
onValueChange(style, id as T, false)
|
||||
onValueChange(style, id as T)
|
||||
}
|
||||
|
||||
return {
|
||||
|
|
|
@ -21,6 +21,7 @@ export interface TLUiInputProps {
|
|||
onValueChange?: (value: string) => void
|
||||
onCancel?: (value: string) => void
|
||||
onBlur?: (value: string) => void
|
||||
onFocus?: () => void
|
||||
className?: string
|
||||
/**
|
||||
* 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,
|
||||
onValueChange,
|
||||
onCancel,
|
||||
onFocus,
|
||||
onBlur,
|
||||
shouldManuallyMaintainScrollPositionWhenFocused = false,
|
||||
children,
|
||||
|
@ -77,8 +79,9 @@ export const TldrawUiInput = React.forwardRef<HTMLInputElement, TLUiInputProps>(
|
|||
elm.select()
|
||||
}
|
||||
})
|
||||
onFocus?.()
|
||||
},
|
||||
[autoselect]
|
||||
[autoselect, onFocus]
|
||||
)
|
||||
|
||||
const handleChange = React.useCallback(
|
||||
|
|
|
@ -10,7 +10,7 @@ export interface TLUiSliderProps {
|
|||
value: number | null
|
||||
label: string
|
||||
title: string
|
||||
onValueChange: (value: number, squashing: boolean) => void
|
||||
onValueChange: (value: number) => void
|
||||
'data-testid'?: string
|
||||
}
|
||||
|
||||
|
@ -22,7 +22,7 @@ export const TldrawUiSlider = memo(function Slider(props: TLUiSliderProps) {
|
|||
|
||||
const handleValueChange = useCallback(
|
||||
(value: number[]) => {
|
||||
onValueChange(value[0], true)
|
||||
onValueChange(value[0])
|
||||
},
|
||||
[onValueChange]
|
||||
)
|
||||
|
@ -33,7 +33,7 @@ export const TldrawUiSlider = memo(function Slider(props: TLUiSliderProps) {
|
|||
|
||||
const handlePointerUp = useCallback(() => {
|
||||
if (!value) return
|
||||
onValueChange(value, false)
|
||||
onValueChange(value)
|
||||
}, [value, onValueChange])
|
||||
|
||||
return (
|
||||
|
|
|
@ -1164,12 +1164,9 @@ export function ActionsProvider({ overrides, children }: ActionsProviderProps) {
|
|||
readonlyOk: true,
|
||||
onSelect(source) {
|
||||
trackEvent('toggle-transparent', { source })
|
||||
editor.updateInstanceState(
|
||||
{
|
||||
editor.updateInstanceState({
|
||||
exportBackground: !editor.getInstanceState().exportBackground,
|
||||
},
|
||||
{ ephemeral: true }
|
||||
)
|
||||
})
|
||||
},
|
||||
checkbox: true,
|
||||
},
|
||||
|
@ -1326,10 +1323,10 @@ export function ActionsProvider({ overrides, children }: ActionsProviderProps) {
|
|||
editor.batch(() => {
|
||||
editor.mark('change-color')
|
||||
if (editor.isIn('select')) {
|
||||
editor.setStyleForSelectedShapes(style, 'white', { squashing: false })
|
||||
editor.setStyleForSelectedShapes(style, 'white')
|
||||
}
|
||||
editor.setStyleForNextShapes(style, 'white', { squashing: false })
|
||||
editor.updateInstanceState({ isChangingStyle: true }, { ephemeral: true })
|
||||
editor.setStyleForNextShapes(style, 'white')
|
||||
editor.updateInstanceState({ isChangingStyle: true })
|
||||
})
|
||||
trackEvent('set-style', { source, id: style.id, value: 'white' })
|
||||
},
|
||||
|
|
|
@ -24,9 +24,9 @@ export function pasteTldrawContent(editor: Editor, clipboard: TLContent, point?:
|
|||
seletionBoundsBefore?.collides(selectedBoundsAfter)
|
||||
) {
|
||||
// Creates a 'puff' to show a paste has happened.
|
||||
editor.updateInstanceState({ isChangingStyle: true }, { ephemeral: true })
|
||||
editor.updateInstanceState({ isChangingStyle: true })
|
||||
setTimeout(() => {
|
||||
editor.updateInstanceState({ isChangingStyle: false }, { ephemeral: true })
|
||||
editor.updateInstanceState({ isChangingStyle: false })
|
||||
}, 150)
|
||||
}
|
||||
}
|
||||
|
|
|
@ -134,7 +134,7 @@ export function usePrint() {
|
|||
}
|
||||
|
||||
const afterPrintHandler = () => {
|
||||
editor.once('change-history', () => {
|
||||
editor.once('tick', () => {
|
||||
clearElements(el, style)
|
||||
})
|
||||
}
|
||||
|
|
|
@ -102,15 +102,7 @@ export function ToolsProvider({ overrides, children }: TLUiToolsProviderProps) {
|
|||
icon: ('geo-' + id) as TLUiIconType,
|
||||
onSelect(source: TLUiEventSource) {
|
||||
editor.batch(() => {
|
||||
editor.updateInstanceState(
|
||||
{
|
||||
stylesForNextShape: {
|
||||
...editor.getInstanceState().stylesForNextShape,
|
||||
[GeoShapeGeoStyle.id]: id,
|
||||
},
|
||||
},
|
||||
{ ephemeral: true }
|
||||
)
|
||||
editor.setStyleForNextShapes(GeoShapeGeoStyle, id)
|
||||
editor.setCurrentTool('geo')
|
||||
trackEvent('select-tool', { source, id: `geo-${id}` })
|
||||
})
|
||||
|
|
|
@ -179,10 +179,7 @@ describe('<TldrawEditor />', () => {
|
|||
|
||||
expect(editor).toBeTruthy()
|
||||
await act(async () => {
|
||||
editor.updateInstanceState(
|
||||
{ screenBounds: { x: 0, y: 0, w: 1080, h: 720 } },
|
||||
{ ephemeral: true, squashing: true }
|
||||
)
|
||||
editor.updateInstanceState({ screenBounds: { x: 0, y: 0, w: 1080, h: 720 } })
|
||||
})
|
||||
|
||||
const id = createShapeId()
|
||||
|
@ -299,10 +296,7 @@ describe('Custom shapes', () => {
|
|||
|
||||
expect(editor).toBeTruthy()
|
||||
await act(async () => {
|
||||
editor.updateInstanceState(
|
||||
{ screenBounds: { x: 0, y: 0, w: 1080, h: 720 } },
|
||||
{ ephemeral: true, squashing: true }
|
||||
)
|
||||
editor.updateInstanceState({ screenBounds: { x: 0, y: 0, w: 1080, h: 720 } })
|
||||
})
|
||||
|
||||
expect(editor.shapeUtils.card).toBeTruthy()
|
||||
|
|
|
@ -23,7 +23,7 @@ describe('when less than two shapes are selected', () => {
|
|||
editor.setSelectedShapes([ids.boxB])
|
||||
|
||||
const fn = jest.fn()
|
||||
editor.on('update', fn)
|
||||
editor.store.listen(fn)
|
||||
editor.alignShapes(editor.getSelectedShapeIds(), 'top')
|
||||
jest.advanceTimersByTime(1000)
|
||||
expect(fn).not.toHaveBeenCalled()
|
||||
|
|
|
@ -46,7 +46,7 @@ describe('distributeShapes command', () => {
|
|||
it('does nothing', () => {
|
||||
editor.setSelectedShapes([ids.boxA, ids.boxB])
|
||||
const fn = jest.fn()
|
||||
editor.on('change-history', fn)
|
||||
editor.store.listen(fn)
|
||||
editor.distributeShapes(editor.getSelectedShapeIds(), 'horizontal')
|
||||
jest.advanceTimersByTime(1000)
|
||||
expect(fn).not.toHaveBeenCalled()
|
||||
|
|
|
@ -60,8 +60,9 @@ describe('Editor.moveShapesToPage', () => {
|
|||
|
||||
it('Adds undo items', () => {
|
||||
editor.history.clear()
|
||||
expect(editor.history.getNumUndos()).toBe(0)
|
||||
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', () => {
|
||||
|
|
|
@ -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', () => {
|
||||
editor.setSelectedShapes([ids.boxA, ids.boxB])
|
||||
const fn = jest.fn()
|
||||
editor.on('change-history', fn)
|
||||
editor.store.listen(fn)
|
||||
editor.stackShapes(editor.getSelectedShapeIds(), 'horizontal', 0)
|
||||
jest.advanceTimersByTime(1000)
|
||||
expect(fn).not.toHaveBeenCalled()
|
||||
|
|
|
@ -27,7 +27,7 @@ describe('when less than two shapes are selected', () => {
|
|||
it('does nothing', () => {
|
||||
editor.setSelectedShapes([ids.boxB])
|
||||
const fn = jest.fn()
|
||||
editor.on('change-history', fn)
|
||||
editor.store.listen(fn)
|
||||
editor.stretchShapes(editor.getSelectedShapeIds(), 'horizontal')
|
||||
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
|
||||
opacityForNextShape: TLOpacityType
|
||||
stylesForNextShape: Record<string, unknown>
|
||||
// ephemeral
|
||||
followingUserId: string | null
|
||||
highlightedUserIds: string[]
|
||||
brush: BoxModel | null
|
||||
|
@ -129,6 +128,38 @@ export function createInstanceRecordType(stylesById: Map<string, StyleProp<unkno
|
|||
return createRecordType<TLInstance>('instance', {
|
||||
validator: instanceTypeValidator,
|
||||
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(
|
||||
(): Omit<TLInstance, 'typeName' | 'id' | 'currentPageId'> => ({
|
||||
followingUserId: null,
|
||||
|
|
|
@ -138,6 +138,18 @@ export const InstancePageStateRecordType = createRecordType<TLInstancePageState>
|
|||
{
|
||||
validator: instancePageStateValidator,
|
||||
scope: 'session',
|
||||
ephemeralKeys: {
|
||||
pageId: false,
|
||||
selectedShapeIds: false,
|
||||
editingShapeId: false,
|
||||
croppingShapeId: false,
|
||||
meta: false,
|
||||
|
||||
hintingShapeIds: true,
|
||||
erasingShapeIds: true,
|
||||
hoveredShapeId: true,
|
||||
focusedGroupId: true,
|
||||
},
|
||||
}
|
||||
).withDefaultProperties(
|
||||
(): Omit<TLInstancePageState, 'id' | 'typeName' | 'pageId'> => ({
|
||||
|
|
|
@ -272,7 +272,9 @@ export class TLSyncClient<R extends UnknownRecord, S extends Store<R> = Store<R>
|
|||
this.lastServerClock = 0
|
||||
}
|
||||
// kill all presence state
|
||||
this.store.mergeRemoteChanges(() => {
|
||||
this.store.remove(Object.keys(this.store.serialize('presence')) as any)
|
||||
})
|
||||
this.lastPushedPresenceState = null
|
||||
this.isConnectedToRoom = false
|
||||
this.pendingPushRequests = []
|
||||
|
@ -321,7 +323,7 @@ export class TLSyncClient<R extends UnknownRecord, S extends Store<R> = Store<R>
|
|||
const wipeAll = event.hydrationType === 'wipe_all'
|
||||
if (!wipeAll) {
|
||||
// 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
|
||||
|
@ -336,12 +338,22 @@ export class TLSyncClient<R extends UnknownRecord, S extends Store<R> = Store<R>
|
|||
|
||||
// then apply the upstream changes
|
||||
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
|
||||
// creating a new push request with the appropriate diff
|
||||
this.isConnectedToRoom = true
|
||||
this.store.applyDiff(stashedChanges)
|
||||
// this.isConnectedToRoom = true
|
||||
// this.store.applyDiff(stashedChanges, false)
|
||||
|
||||
this.store.ensureStoreIsUsable()
|
||||
// TODO: reinstate isNew
|
||||
|
@ -525,7 +537,7 @@ export class TLSyncClient<R extends UnknownRecord, S extends Store<R> = Store<R>
|
|||
}
|
||||
}
|
||||
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 {
|
||||
this.store.mergeRemoteChanges(() => {
|
||||
// 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
|
||||
for (const diff of diffs) {
|
||||
|
|
|
@ -1,4 +1,3 @@
|
|||
import isEqual from 'lodash.isequal'
|
||||
import { nanoid } from 'nanoid'
|
||||
import {
|
||||
Editor,
|
||||
|
@ -8,7 +7,9 @@ import {
|
|||
computed,
|
||||
createPresenceStateDerivation,
|
||||
createTLStore,
|
||||
isRecordsDiffEmpty,
|
||||
} from 'tldraw'
|
||||
import { prettyPrintDiff } from '../../../tldraw/src/test/testutils/pretty'
|
||||
import { TLSyncClient } from '../lib/TLSyncClient'
|
||||
import { schema } from '../lib/schema'
|
||||
import { FuzzEditor, Op } from './FuzzEditor'
|
||||
|
@ -74,8 +75,8 @@ class FuzzTestInstance extends RandomSource {
|
|||
) {
|
||||
super(seed)
|
||||
|
||||
this.store = createTLStore({ schema })
|
||||
this.id = nanoid()
|
||||
this.store = createTLStore({ schema, id: this.id })
|
||||
this.socketPair = new TestSocketPair(this.id, server)
|
||||
this.client = new TLSyncClient<TLRecord>({
|
||||
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 totalNumPages = 0
|
||||
|
||||
|
@ -173,6 +181,7 @@ function runTest(seed: number) {
|
|||
|
||||
allOk('before applyOp')
|
||||
peer.editor.applyOp(op)
|
||||
assertPeerStoreIsUsable(peer)
|
||||
allOk('after applyOp')
|
||||
|
||||
server.flushDebouncingMessages()
|
||||
|
@ -210,6 +219,7 @@ function runTest(seed: number) {
|
|||
if (!peer.socketPair.isConnected && peer.randomInt(2) === 0) {
|
||||
peer.socketPair.connect()
|
||||
allOk('final connect')
|
||||
assertPeerStoreIsUsable(peer)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -223,33 +233,29 @@ function runTest(seed: number) {
|
|||
allOk('final flushServer')
|
||||
peer.socketPair.flushClientSentEvents()
|
||||
allOk('final flushClient')
|
||||
assertPeerStoreIsUsable(peer)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const equalityResults = []
|
||||
for (let i = 0; i < peers.length; i++) {
|
||||
const row = []
|
||||
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)
|
||||
// peers should all be usable without changes:
|
||||
for (const peer of peers) {
|
||||
assertPeerStoreIsUsable(peer)
|
||||
}
|
||||
|
||||
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'))
|
||||
|
||||
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
|
||||
totalNumPages += peers[0].store.query.ids('page').get().size
|
||||
totalNumShapes += peers[0].store.query.ids('shape').get().size
|
||||
} catch (e) {
|
||||
console.error('seed', seed)
|
||||
console.error(
|
||||
|
@ -269,21 +275,25 @@ const NUM_TESTS = 50
|
|||
const NUM_OPS_PER_TEST = 100
|
||||
const MAX_PEERS = 4
|
||||
|
||||
// test.only('seed 8343632005032947', () => {
|
||||
// runTest(8343632005032947)
|
||||
// })
|
||||
test('seed 8360926944486245 - undo/redo page integrity regression', () => {
|
||||
runTest(8360926944486245)
|
||||
})
|
||||
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)
|
||||
})
|
||||
|
||||
test('fuzzzzz', () => {
|
||||
for (let i = 0; i < NUM_TESTS; i++) {
|
||||
const seed = Math.floor(Math.random() * Number.MAX_SAFE_INTEGER)
|
||||
try {
|
||||
test(`seed ${seed}`, () => {
|
||||
runTest(seed)
|
||||
} catch (e) {
|
||||
console.error('seed', seed)
|
||||
throw e
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
test('totalNumPages', () => {
|
||||
expect(totalNumPages).not.toBe(0)
|
||||
|
|
Loading…
Reference in a new issue