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:
alex 2024-04-24 19:26:10 +01:00 committed by GitHub
parent c9b7d328fe
commit 8151e6f586
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
69 changed files with 2106 additions and 1907 deletions

View file

@ -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>

View file

@ -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>

View file

@ -29,7 +29,9 @@ export default function UserPresenceExample() {
chatMessage: CURSOR_CHAT_MESSAGE,
})
editor.store.put([peerPresence])
editor.store.mergeRemoteChanges(() => {
editor.store.put([peerPresence])
})
// [b]
const raf = rRaf.current
@ -67,23 +69,29 @@ export default function UserPresenceExample() {
)
}
editor.store.put([
{
...peerPresence,
cursor,
chatMessage,
lastActivityTimestamp: now,
},
])
editor.store.mergeRemoteChanges(() => {
editor.store.put([
{
...peerPresence,
cursor,
chatMessage,
lastActivityTimestamp: now,
},
])
})
rRaf.current = requestAnimationFrame(loop)
}
rRaf.current = requestAnimationFrame(loop)
} else {
editor.store.put([{ ...peerPresence, lastActivityTimestamp: Date.now() }])
rRaf.current = setInterval(() => {
editor.store.mergeRemoteChanges(() => {
editor.store.put([{ ...peerPresence, lastActivityTimestamp: Date.now() }])
})
rRaf.current = setInterval(() => {
editor.store.mergeRemoteChanges(() => {
editor.store.put([{ ...peerPresence, lastActivityTimestamp: Date.now() }])
})
}, 1000)
}
}}

View file

@ -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])

View file

@ -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[];

View file

@ -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'

View file

@ -380,8 +380,11 @@ function useOnMount(onMount?: TLOnMountHandler) {
const editor = useEditor()
const onMountEvent = useEvent((editor: Editor) => {
const teardown = onMount?.(editor)
editor.emit('mount')
let teardown: (() => void) | void = undefined
editor.history.ignore(() => {
teardown = onMount?.(editor)
editor.emit('mount')
})
window.tldrawReady = true
return teardown
})

View file

@ -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

View file

@ -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 store = new Store({ schema: testSchema, props: null })
store.put([
testSchema.types.test.create({ id: ids.count, value: 0 }),
testSchema.types.test.create({ id: ids.name, value: 'David' }),
testSchema.types.test.create({ id: ids.age, value: 35 }),
])
const manager = new HistoryManager<TestRecord>({ store })
function getCount() {
return store.get(ids.count)!.value as number
}
function getName() {
return store.get(ids.name)!.value as string
}
function getAge() {
return store.get(ids.age)!.value as number
}
function _setCount(n: number) {
store.update(ids.count, (c) => ({ ...c, value: n }))
}
function _setName(name: string) {
store.update(ids.name, (c) => ({ ...c, value: name }))
}
function _setAge(age: number) {
store.update(ids.age, (c) => ({ ...c, value: age }))
}
const increment = manager.createCommand(
'increment',
(n = 1, squashing = false) => ({
data: { n },
squashing,
}),
{
do: ({ n }) => {
state.count += n
},
undo: ({ n }) => {
state.count -= n
},
squash: ({ n: n1 }, { n: n2 }) => ({ n: n1 + n2 }),
}
)
const decrement = manager.createCommand(
'decrement',
(n = 1, squashing = false) => ({
data: { n },
squashing,
}),
{
do: ({ n }) => {
state.count -= n
},
undo: ({ n }) => {
state.count += n
},
squash: ({ n: n1 }, { n: n2 }) => ({ n: n1 + n2 }),
}
)
const increment = (n = 1) => {
_setCount(getCount() + n)
}
const setName = manager.createCommand(
'setName',
(name = 'David') => ({
data: { name, prev: state.name },
ephemeral: true,
}),
{
do: ({ name }) => {
state.name = name
},
undo: ({ prev }) => {
state.name = prev
},
}
)
const decrement = (n = 1) => {
_setCount(getCount() - n)
}
const setAge = manager.createCommand(
'setAge',
(age = 35) => ({
data: { age, prev: state.age },
preservesRedoStack: true,
}),
{
do: ({ age }) => {
state.age = age
},
undo: ({ prev }) => {
state.age = prev
},
}
)
const setName = (name = 'David') => {
manager.ignore(() => _setName(name))
}
const incrementTwice = manager.createCommand('incrementTwice', () => ({ data: {} }), {
do: () => {
const setAge = (age = 35) => {
manager.batch(() => _setAge(age), { history: 'record-preserveRedoStack' })
}
const incrementTwice = () => {
manager.batch(() => {
increment()
increment()
},
undo: () => {
decrement()
decrement()
},
})
})
}
return {
increment,
@ -95,9 +78,9 @@ function createCounterHistoryManager() {
setName,
setAge,
history: manager,
getCount: () => state.count,
getName: () => state.name,
getAge: () => state.age,
getCount,
getName,
getAge,
}
}
@ -116,9 +99,9 @@ describe(HistoryManager, () => {
editor.decrement()
expect(editor.getCount()).toBe(3)
const undos = [...editor.history._undos.get()]
const undos = [...editor.history.stacks.get().undos]
const parsedUndos = JSON.parse(JSON.stringify(undos))
editor.history._undos.set(stack(parsedUndos))
editor.history.stacks.update(({ redos }) => ({ undos: stack(parsedUndos), redos }))
editor.history.undo()
@ -200,17 +183,16 @@ describe(HistoryManager, () => {
editor.history.mark('stop at 1')
expect(editor.getCount()).toBe(1)
editor.increment(1, true)
editor.increment(1, true)
editor.increment(1, true)
editor.increment(1, true)
editor.increment(1)
editor.increment(1)
editor.increment(1)
editor.increment(1)
expect(editor.getCount()).toBe(5)
expect(editor.history.getNumUndos()).toBe(3)
})
it('allows ephemeral commands that do not affect the stack', () => {
it('allows ignore commands that do not affect the stack', () => {
editor.increment()
editor.history.mark('stop at 1')
editor.increment()
@ -263,7 +245,7 @@ describe(HistoryManager, () => {
editor.history.mark('2')
editor.incrementTwice()
editor.incrementTwice()
expect(editor.history.getNumUndos()).toBe(5)
expect(editor.history.getNumUndos()).toBe(4)
expect(editor.getCount()).toBe(6)
editor.history.bail()
expect(editor.getCount()).toBe(2)
@ -289,58 +271,35 @@ describe(HistoryManager, () => {
})
describe('history options', () => {
let manager: HistoryManager<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 }),
])
state = {
a: 0,
b: 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 = manager.createCommand(
'setA',
(n: number, historyOptions?: TLCommandHistoryOptions) => ({
data: { next: n, prev: state.a },
...historyOptions,
}),
{
do: ({ next }) => {
state = { ...state, a: next }
},
undo: ({ prev }) => {
state = { ...state, a: prev }
},
squash: ({ prev }, { next }) => ({ prev, next }),
}
)
setA = (n: number, historyOptions?: TLHistoryBatchOptions) => {
manager.batch(() => store.update(ids.a, (s) => ({ ...s, value: n })), historyOptions)
}
setB = manager.createCommand(
'setB',
(n: number, historyOptions?: TLCommandHistoryOptions) => ({
data: { next: n, prev: state.b },
...historyOptions,
}),
{
do: ({ next }) => {
state = { ...state, b: next }
},
undo: ({ prev }) => {
state = { ...state, b: prev }
},
squash: ({ prev }, { next }) => ({ prev, next }),
}
)
setB = (n: number, historyOptions?: TLHistoryBatchOptions) => {
manager.batch(() => store.update(ids.b, (s) => ({ ...s, value: n })), historyOptions)
}
})
it('sets, undoes, redoes', () => {
it('undos, redoes, separate marks', () => {
manager.mark()
setA(1)
manager.mark()
@ -348,18 +307,18 @@ describe('history options', () => {
manager.mark()
setB(2)
expect(state).toMatchObject({ a: 1, b: 2 })
expect(getState()).toMatchObject({ a: 1, b: 2 })
manager.undo()
expect(state).toMatchObject({ a: 1, b: 1 })
expect(getState()).toMatchObject({ a: 1, b: 1 })
manager.redo()
expect(state).toMatchObject({ a: 1, b: 2 })
expect(getState()).toMatchObject({ a: 1, b: 2 })
})
it('sets, undoes, redoes', () => {
it('undos, redos, squashing', () => {
manager.mark()
setA(1)
manager.mark()
@ -369,71 +328,107 @@ describe('history options', () => {
setB(3)
setB(4)
expect(state).toMatchObject({ a: 1, b: 4 })
expect(getState()).toMatchObject({ a: 1, b: 4 })
manager.undo()
expect(state).toMatchObject({ a: 1, b: 1 })
expect(getState()).toMatchObject({ a: 1, b: 1 })
manager.redo()
expect(state).toMatchObject({ a: 1, b: 4 })
expect(getState()).toMatchObject({ a: 1, b: 4 })
})
it('sets ephemeral, undoes, redos', () => {
it('undos, redos, ignore', () => {
manager.mark()
setA(1)
manager.mark()
setB(1) // B 0->1
manager.mark()
setB(2, { ephemeral: true }) // B 0->2, but ephemeral
setB(2, { history: 'ignore' }) // B 0->2, but ignore
expect(state).toMatchObject({ a: 1, b: 2 })
expect(getState()).toMatchObject({ a: 1, b: 2 })
manager.undo() // undoes B 2->0
expect(state).toMatchObject({ a: 1, b: 0 })
expect(getState()).toMatchObject({ a: 1, b: 0 })
manager.redo() // redoes B 0->1, but not B 1-> 2
expect(state).toMatchObject({ a: 1, b: 1 }) // no change, b 1->2 was ephemeral
expect(getState()).toMatchObject({ a: 1, b: 1 }) // no change, b 1->2 was ignore
})
it('sets squashing, undoes, redos', () => {
it('squashing, undos, redos', () => {
manager.mark()
setA(1)
manager.mark()
setB(1)
setB(2, { squashing: true }) // squashes with the previous command
setB(3, { squashing: true }) // squashes with the previous command
setB(2) // squashes with the previous command
setB(3) // squashes with the previous command
expect(state).toMatchObject({ a: 1, b: 3 })
expect(getState()).toMatchObject({ a: 1, b: 3 })
manager.undo()
expect(state).toMatchObject({ a: 1, b: 0 })
expect(getState()).toMatchObject({ a: 1, b: 0 })
manager.redo()
expect(state).toMatchObject({ a: 1, b: 3 })
expect(getState()).toMatchObject({ a: 1, b: 3 })
})
it('sets squashing and ephemeral, undoes, redos', () => {
it('squashing, undos, redos, ignore', () => {
manager.mark()
setA(1)
manager.mark()
setB(1)
setB(2, { squashing: true }) // squashes with the previous command
setB(3, { squashing: true, ephemeral: true }) // squashes with the previous command
setB(2) // squashes with the previous command
setB(3, { history: 'ignore' }) // squashes with the previous command
expect(state).toMatchObject({ a: 1, b: 3 })
expect(getState()).toMatchObject({ a: 1, b: 3 })
manager.undo()
expect(state).toMatchObject({ a: 1, b: 0 })
expect(getState()).toMatchObject({ a: 1, b: 0 })
manager.redo()
expect(state).toMatchObject({ a: 1, b: 2 }) // B2->3 was ephemeral
expect(getState()).toMatchObject({ a: 1, b: 2 }) // B2->3 was ignore
})
it('nested ignore', () => {
manager.mark()
manager.batch(
() => {
setA(1)
// even though we set this to record, it will still be ignored
manager.batch(() => setB(1), { history: 'record' })
setA(2)
},
{ history: 'ignore' }
)
expect(getState()).toMatchObject({ a: 2, b: 1 })
// changes were ignored:
manager.undo()
expect(getState()).toMatchObject({ a: 2, b: 1 })
manager.mark()
manager.batch(
() => {
setA(3)
manager.batch(() => setB(2), { history: 'ignore' })
},
{ history: 'record-preserveRedoStack' }
)
expect(getState()).toMatchObject({ a: 3, b: 2 })
// changes to A were recorded, but changes to B were ignore:
manager.undo()
expect(getState()).toMatchObject({ a: 2, b: 2 })
// We can still redo because we preserved the redo stack:
manager.redo()
expect(getState()).toMatchObject({ a: 3, b: 2 })
})
})

View file

@ -1,156 +1,124 @@
import { atom, transact } from '@tldraw/state'
import { devFreeze } from '@tldraw/store'
import {
RecordsDiff,
Store,
UnknownRecord,
createEmptyRecordsDiff,
isRecordsDiffEmpty,
reverseRecordsDiff,
squashRecordDiffsMutable,
} from '@tldraw/store'
import { exhaustiveSwitchError, noop } from '@tldraw/utils'
import { uniqueId } from '../../utils/uniqueId'
import { TLCommandHandler, TLCommandHistoryOptions, TLHistoryEntry } from '../types/history-types'
import { Stack, stack } from './Stack'
import { TLHistoryBatchOptions, TLHistoryEntry } from '../types/history-types'
import { stack } from './Stack'
type CommandFn<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
},
> {
_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
readonly dispose: () => void
constructor(
private readonly ctx: CTX,
private readonly annotateError: (error: unknown) => 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>>(),
},
{
isEqual: (a, b) => a.undos === b.undos && a.redos === b.redos,
}
)
private readonly annotateError: (error: unknown) => void
constructor(opts: { store: Store<R>; annotateError?: (error: unknown) => void }) {
this.store = opts.store
this.annotateError = opts.annotateError ?? noop
this.dispose = this.store.addHistoryInterceptor((entry, source) => {
if (source !== 'user') return
switch (this.state) {
case HistoryRecorderState.Recording:
this.pendingDiff.apply(entry.changes)
this.stacks.update(({ undos }) => ({ undos, redos: stack() }))
break
case HistoryRecorderState.RecordingPreserveRedoStack:
this.pendingDiff.apply(entry.changes)
break
case HistoryRecorderState.Paused:
break
default:
exhaustiveSwitchError(this.state)
}
})
}
private flushPendingDiff() {
if (this.pendingDiff.isEmpty()) return
const diff = this.pendingDiff.clear()
this.stacks.update(({ undos, redos }) => ({
undos: undos.push({ type: 'diff', diff }),
redos,
}))
}
onBatchComplete: () => void = () => void null
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
}
const result = constructor(...args)
if (!result) {
return this.ctx
}
const { data, ephemeral, squashing, preservesRedoStack } = result
this.ignoringUpdates((undos, redos) => {
handle.do(data)
return { undos, redos }
})
if (!ephemeral) {
const prev = this._undos.get().head
if (
squashing &&
prev &&
prev.type === 'command' &&
prev.name === name &&
prev.preservesRedoStack === preservesRedoStack
) {
// replace the last command with a squashed version
this._undos.update((undos) =>
undos.tail.push({
...prev,
data: devFreeze(handle.squash!(prev.data, data)),
})
)
} else {
// add to the undo stack
this._undos.update((undos) =>
undos.push({
type: 'command',
name,
data: devFreeze(data),
preservesRedoStack: preservesRedoStack,
})
)
}
if (!result.preservesRedoStack) {
this._redos.set(stack())
}
this.ctx.emit('change-history', { reason: 'push' })
}
return this.ctx
}
return exec
return this.stacks.get().redos.length
}
batch = (fn: () => void) => {
/** @internal */
_isInBatch = false
batch = (fn: () => void, opts?: TLHistoryBatchOptions) => {
const previousState = this.state
// we move to the new state only if we haven't explicitly paused
if (previousState !== HistoryRecorderState.Paused && opts?.history) {
this.state = modeToState[opts.history]
}
try {
this._batchDepth++
if (this._batchDepth === 1) {
transact(() => {
const mostRecentAction = this._undos.get().head
fn()
if (mostRecentAction !== this._undos.get().head) {
this.onBatchComplete()
}
})
} else {
if (this._isInBatch) {
fn()
return this
}
} catch (error) {
this.annotateError(error)
throw error
} finally {
this._batchDepth--
}
return this
this._isInBatch = true
try {
transact(() => {
fn()
this.onBatchComplete()
})
} catch (error) {
this.annotateError(error)
throw error
} finally {
this._isInBatch = false
}
return this
} finally {
this.state = previousState
}
}
private ignoringUpdates = (
fn: (
undos: Stack<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)
}
ignore(fn: () => void) {
return this.batch(fn, { history: 'ignore' })
}
// History
@ -161,62 +129,66 @@ export class HistoryManager<
pushToRedoStack: boolean
toMark?: string
}) => {
this.ignoringUpdates((undos, redos) => {
if (undos.length === 0) {
return { undos, redos }
const previousState = this.state
this.state = HistoryRecorderState.Paused
try {
let { undos, redos } = this.stacks.get()
// start by collecting the pending diff (everything since the last mark).
// we'll accumulate the diff to undo in this variable so we can apply it atomically.
const pendingDiff = this.pendingDiff.clear()
const isPendingDiffEmpty = isRecordsDiffEmpty(pendingDiff)
const diffToUndo = reverseRecordsDiff(pendingDiff)
if (pushToRedoStack && !isPendingDiffEmpty) {
redos = redos.push({ type: 'diff', diff: pendingDiff })
}
while (undos.head?.type === 'STOP') {
const mark = undos.head
undos = undos.tail
if (pushToRedoStack) {
redos = redos.push(mark)
}
if (mark.id === toMark) {
this.ctx.emit(
'change-history',
pushToRedoStack ? { reason: 'undo' } : { reason: 'bail', markId: toMark }
)
return { undos, redos }
}
}
if (undos.length === 0) {
this.ctx.emit(
'change-history',
pushToRedoStack ? { reason: 'undo' } : { reason: 'bail', markId: toMark }
)
return { undos, redos }
}
while (undos.head) {
const command = undos.head
undos = undos.tail
if (pushToRedoStack) {
redos = redos.push(command)
}
if (command.type === 'STOP') {
if (command.onUndo && (!toMark || command.id === toMark)) {
this.ctx.emit(
'change-history',
pushToRedoStack ? { reason: 'undo' } : { reason: 'bail', markId: toMark }
)
return { undos, redos }
let didFindMark = false
if (isPendingDiffEmpty) {
// if nothing has happened since the last mark, pop any intermediate marks off the stack
while (undos.head?.type === 'stop') {
const mark = undos.head
undos = undos.tail
if (pushToRedoStack) {
redos = redos.push(mark)
}
if (mark.id === toMark) {
didFindMark = true
break
}
} else {
const handler = this._commands[command.name]
handler.undo(command.data)
}
}
this.ctx.emit(
'change-history',
pushToRedoStack ? { reason: 'undo' } : { reason: 'bail', markId: toMark }
)
return { undos, redos }
})
if (!didFindMark) {
loop: while (undos.head) {
const undo = undos.head
undos = undos.tail
if (pushToRedoStack) {
redos = redos.push(undo)
}
switch (undo.type) {
case 'diff':
squashRecordDiffsMutable(diffToUndo, [reverseRecordsDiff(undo.diff)])
break
case 'stop':
if (!toMark) break loop
if (undo.id === toMark) break loop
break
default:
exhaustiveSwitchError(undo)
}
}
}
this.store.applyDiff(diffToUndo, { ignoreEphemeralKeys: true })
this.store.ensureStoreIsUsable()
this.stacks.set({ undos, redos })
} finally {
this.state = previousState
}
return this
}
@ -228,43 +200,43 @@ export class HistoryManager<
}
redo = () => {
this.ignoringUpdates((undos, redos) => {
const previousState = this.state
this.state = HistoryRecorderState.Paused
try {
this.flushPendingDiff()
let { undos, redos } = this.stacks.get()
if (redos.length === 0) {
return { undos, redos }
return
}
while (redos.head?.type === 'STOP') {
// ignore any intermediate marks - this should take us to the first `diff` entry
while (redos.head?.type === 'stop') {
undos = undos.push(redos.head)
redos = redos.tail
}
if (redos.length === 0) {
this.ctx.emit('change-history', { reason: 'redo' })
return { undos, redos }
}
// accumulate diffs to be redone so they can be applied atomically
const diffToRedo = createEmptyRecordsDiff<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) {
break
}
if (redo.type === 'diff') {
squashRecordDiffsMutable(diffToRedo, [redo.diff])
} else {
const handler = this._commands[command.name]
if (handler.redo) {
handler.redo(command.data)
} else {
handler.do(command.data)
}
break
}
}
this.ctx.emit('change-history', { reason: 'redo' })
return { undos, redos }
})
this.store.applyDiff(diffToRedo, { ignoreEphemeralKeys: true })
this.store.ensureStoreIsUsable()
this.stacks.set({ undos, redos })
} finally {
this.state = previousState
}
return this
}
@ -281,24 +253,59 @@ export class HistoryManager<
return this
}
mark = (id = uniqueId(), onUndo = true, onRedo = true) => {
const mostRecent = this._undos.get().head
// dedupe marks, why not
if (mostRecent && mostRecent.type === 'STOP') {
if (mostRecent.id === id && mostRecent.onUndo === onUndo && mostRecent.onRedo === onRedo) {
return mostRecent.id
}
}
this._undos.update((undos) => undos.push({ type: 'STOP', id, onUndo, onRedo }))
this.ctx.emit('mark-history', { id })
mark = (id = uniqueId()) => {
transact(() => {
this.flushPendingDiff()
this.stacks.update(({ undos, redos }) => ({ undos: undos.push({ type: 'stop', id }), redos }))
})
return id
}
clear() {
this._undos.set(stack())
this._redos.set(stack())
this.stacks.set({ undos: stack(), redos: stack() })
this.pendingDiff.clear()
}
/** @internal */
debug() {
const { undos, redos } = this.stacks.get()
return {
undos: undos.toArray(),
redos: redos.toArray(),
pendingDiff: this.pendingDiff.debug(),
state: this.state,
}
}
}
const modeToState = {
record: HistoryRecorderState.Recording,
'record-preserveRedoStack': HistoryRecorderState.RecordingPreserveRedoStack,
ignore: HistoryRecorderState.Paused,
} as const
class PendingDiff<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() }
}
}

View file

@ -88,25 +88,13 @@ export class SideEffectManager<
return next
}
let updateDepth = 0
editor.store.onAfterChange = (prev, next, source) => {
updateDepth++
if (updateDepth > 1000) {
console.error('[CleanupManager.onAfterChange] Maximum update depth exceeded, bailing out.')
} else {
const handlers = this._afterChangeHandlers[
next.typeName
] as TLAfterChangeHandler<TLRecord>[]
if (handlers) {
for (const handler of handlers) {
handler(prev, next, source)
}
const handlers = this._afterChangeHandlers[next.typeName] as TLAfterChangeHandler<TLRecord>[]
if (handlers) {
for (const handler of handlers) {
handler(prev, next, source)
}
}
updateDepth--
}
editor.store.onBeforeDelete = (record, source) => {
@ -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.

View file

@ -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 }]
}

View file

@ -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'
}

View file

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

View file

@ -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,

View file

@ -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,
})
}

View 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
}
}
}
}

View file

@ -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,25 +715,47 @@ 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)
)
const toRemove = objectMapKeys(diff.removed)
if (toPut.length) {
this.put(toPut)
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)
}
if (toRemove.length) {
this.remove(toRemove)
}
})
} finally {
this._runCallbacks = prevRunCallbacks
}
}
const toRemove = objectMapKeys(diff.removed)
if (toPut.length) {
this.put(toPut)
}
if (toRemove.length) {
this.remove(toRemove)
}
}, runCallbacks)
}
/**
@ -827,20 +822,14 @@ export class Store<R extends UnknownRecord = UnknownRecord, Props = unknown> {
}
}
getRecordType = <T extends R>(record: R): T => {
const type = this.schema.types[record.typeName as R['typeName']]
if (!type) {
throw new Error(`Record type ${record.typeName} not found`)
}
return type as unknown as T
}
private _integrityChecker?: () => void | undefined
/** @internal */
ensureStoreIsUsable() {
this._integrityChecker ??= this.schema.createIntegrityChecker(this)
this._integrityChecker?.()
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>
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 {
this.pendingAfterEvents.set(id, { before, after, source })
}
}
private flushAtomicCallbacks() {
let updateDepth = 0
while (this.pendingAfterEvents) {
const events = this.pendingAfterEvents
this.pendingAfterEvents = null
for (const diff of diffs) {
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]
if (!this._runCallbacks) continue
updateDepth++
if (updateDepth > 100) {
throw new Error('Maximum store update depth exceeded, bailing out')
}
for (const { before, after, source } of events.values()) {
if (before && after) {
this.onAfterChange?.(before, after, source)
} else if (before && !after) {
this.onAfterDelete?.(before, source)
} else if (!before && after) {
this.onAfterCreate?.(after, source)
}
} else {
result.added[id] = value
}
}
for (const [id, [_from, to]] of objectMapEntries(diff.updated)) {
if (result.added[id]) {
result.added[id] = to
delete result.updated[id]
delete result.removed[id]
continue
}
if (result.updated[id]) {
result.updated[id] = [result.updated[id][0], to]
delete result.removed[id]
continue
}
result.updated[id] = diff.updated[id]
delete result.removed[id]
}
for (const [id, value] of objectMapEntries(diff.removed)) {
// the same record was added in this diff sequence, just drop it
if (result.added[id]) {
delete result.added[id]
} else if (result.updated[id]) {
result.removed[id] = result.updated[id][0]
delete result.updated[id]
} else {
result.removed[id] = value
}
}
}
private _isInAtomicOp = false
/** @internal */
atomic<T>(fn: () => T, runCallbacks = true): T {
return transact(() => {
if (this._isInAtomicOp) {
if (!this.pendingAfterEvents) this.pendingAfterEvents = new Map()
return fn()
}
return result
this.pendingAfterEvents = new Map()
const prevRunCallbacks = this._runCallbacks
this._runCallbacks = runCallbacks ?? prevRunCallbacks
this._isInAtomicOp = true
try {
const result = fn()
this.flushAtomicCallbacks()
return result
} finally {
this.pendingAfterEvents = null
this._runCallbacks = prevRunCallbacks
this._isInAtomicOp = false
}
})
}
/** @internal */
addHistoryInterceptor(fn: (entry: HistoryEntry<R>, source: ChangeSource) => void) {
return this.historyAccumulator.addInterceptor((entry) =>
fn(entry, this.isMergingRemoteChanges ? 'remote' : 'user')
)
}
}
/**
@ -949,21 +956,12 @@ function squashHistoryEntries<T extends UnknownRecord>(
)
}
/** @public */
export function reverseRecordsDiff(diff: RecordsDiff<any>) {
const result: RecordsDiff<any> = { added: diff.removed, removed: diff.added, updated: {} }
for (const [from, to] of Object.values(diff.updated)) {
result.updated[from.id] = [to, from]
}
return result
}
class HistoryAccumulator<T extends UnknownRecord> {
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)

View file

@ -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,

View file

@ -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
}
}

View file

@ -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"`)
})
})

View file

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

View file

@ -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])
}
}

View file

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

View file

@ -30,16 +30,13 @@ export const FrameLabelInput = forwardRef<
const value = e.currentTarget.value.trim()
if (name === value) return
editor.updateShapes(
[
{
id,
type: 'frame',
props: { name: value },
},
],
{ squashing: true }
)
editor.updateShapes([
{
id,
type: 'frame',
props: { name: value },
},
])
},
[id, editor]
)
@ -53,16 +50,13 @@ export const FrameLabelInput = forwardRef<
const value = e.currentTarget.value
if (name === value) return
editor.updateShapes(
[
{
id,
type: 'frame',
props: { name: value },
},
],
{ squashing: true }
)
editor.updateShapes([
{
id,
type: 'frame',
props: { name: value },
},
])
},
[id, editor]
)

View file

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

View file

@ -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'] = () => {

View file

@ -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))
}
}

View file

@ -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'] = () => {

View file

@ -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])
}
}
}

View file

@ -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()
}

View file

@ -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])
}
}
}

View file

@ -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'] = () => {

View file

@ -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 = () => {

View file

@ -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)
}

View file

@ -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'] = () => {

View file

@ -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: {
type: cursorType,
rotation: selected.length === 1 ? this.editor.getSelectionRotation() : 0,
},
this.editor.setCursor({
type: cursorType,
rotation: selected.length === 1 ? this.editor.getSelectionRotation() : 0,
})
}

View file

@ -11,11 +11,9 @@ export class PointingRotateHandle extends StateNode {
private info = {} as PointingRotateHandleInfo
private updateCursor() {
this.editor.updateInstanceState({
cursor: {
type: CursorTypeMap[this.info.handle as RotateCorner],
rotation: this.editor.getSelectionRotation(),
},
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'] = () => {

View file

@ -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()
}

View file

@ -51,11 +51,9 @@ export class Rotating extends StateNode {
})
// Update cursor
this.editor.updateInstanceState({
cursor: {
type: CursorTypeMap[this.info.handle as RotateCorner],
rotation: newSelectionRotation + this.snapshot.initialSelectionRotation,
},
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: {
type: CursorTypeMap[this.info.handle as RotateCorner],
rotation: newSelectionRotation + this.snapshot.initialSelectionRotation,
},
this.editor.setCursor({
type: CursorTypeMap[this.info.handle as RotateCorner],
rotation: newSelectionRotation + this.snapshot.initialSelectionRotation,
})
}

View file

@ -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')
}
}

View file

@ -505,7 +505,6 @@ export function moveShapesToPoint({
y: newLocalPoint.y,
}
})
),
{ squashing: true }
)
)
}

View file

@ -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 })
}
}
}

View file

@ -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]

View file

@ -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

View file

@ -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])

View file

@ -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 })

View file

@ -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>

View file

@ -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} />

View file

@ -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 {

View file

@ -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(

View file

@ -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 (

View file

@ -1164,12 +1164,9 @@ export function ActionsProvider({ overrides, children }: ActionsProviderProps) {
readonlyOk: true,
onSelect(source) {
trackEvent('toggle-transparent', { source })
editor.updateInstanceState(
{
exportBackground: !editor.getInstanceState().exportBackground,
},
{ ephemeral: true }
)
editor.updateInstanceState({
exportBackground: !editor.getInstanceState().exportBackground,
})
},
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' })
},

View file

@ -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)
}
}

View file

@ -134,7 +134,7 @@ export function usePrint() {
}
const afterPrintHandler = () => {
editor.once('change-history', () => {
editor.once('tick', () => {
clearElements(el, style)
})
}

View file

@ -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}` })
})

View file

@ -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()

View file

@ -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()

View file

@ -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()

View file

@ -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', () => {

View file

@ -1,13 +0,0 @@
import { TestEditor } from '../TestEditor'
let editor: TestEditor
beforeEach(() => {
editor = new TestEditor()
})
describe('squashing', () => {
editor
it.todo('squashes')
})

View file

@ -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()

View file

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

View 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()
}

View file

@ -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,

View file

@ -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'> => ({

View file

@ -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.remove(Object.keys(this.store.serialize('presence')) as any)
this.store.mergeRemoteChanges(() => {
this.store.remove(Object.keys(this.store.serialize('presence')) as any)
})
this.lastPushedPresenceState = null
this.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) {

View file

@ -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('fuzzzzz', () => {
for (let i = 0; i < NUM_TESTS; i++) {
const seed = Math.floor(Math.random() * Number.MAX_SAFE_INTEGER)
try {
runTest(seed)
} catch (e) {
console.error('seed', seed)
throw e
}
}
test('seed 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)
})
for (let i = 0; i < NUM_TESTS; i++) {
const seed = Math.floor(Math.random() * Number.MAX_SAFE_INTEGER)
test(`seed ${seed}`, () => {
runTest(seed)
})
}
test('totalNumPages', () => {
expect(totalNumPages).not.toBe(0)