Porównaj commity

...

19 Commity

Autor SHA1 Wiadomość Data
alex 7d9093ee56
Merge 3448e53a64 into 152b915704 2024-04-12 06:50:18 +01:00
alex 3448e53a64 alex/auto-undo-redo: add bail-out 2024-04-11 16:33:45 +01:00
alex 2751a056f2 alex/auto-undo-redo: history.ephemeral -> history.ignore 2024-04-11 12:25:06 +01:00
alex e5bbaee67b Merge branch 'main' into alex/auto-undo-redo 2024-04-11 12:14:16 +01:00
alex a166f5402d alex/auto-undo-redo: cleanup 2024-04-10 16:04:23 +01:00
alex 0b4fd98f90 alex/auto-undo-redo: fix fuzz tests 2024-04-10 15:56:31 +01:00
alex 5277297285 alex/auto-undo-redo: tests 2024-04-09 15:45:12 +01:00
alex d494cbbd92 alex/auto-undo-redo: diffs 2024-04-09 14:49:26 +01:00
alex 82ea71ed31 alex/auto-undo-redo: cleanup 2024-04-09 14:25:13 +01:00
alex e7c9a0fcaa alex/auto-undo-redo: cleanup 2024-04-09 14:17:01 +01:00
alex 6d2018c897 alex/auto-undo-redo: auto undo-redo 2024-04-09 13:43:49 +01:00
alex b4f26556bf Revert "alex/auto-undo-redo: nullable RecordsDiff"
This reverts commit a631642b2d.
2024-04-09 11:41:46 +01:00
alex a631642b2d alex/auto-undo-redo: nullable RecordsDiff 2024-04-09 11:41:40 +01:00
alex 463957ae94 alex/auto-undo-redo: pending diff 2024-04-09 11:41:30 +01:00
alex d53f3f02d4 alex/auto-undo-redo: wip 2024-04-08 18:06:36 +01:00
alex f22e474557 alex/auto-undo-redo: no WorkingRecordsDiff 2024-04-08 17:57:45 +01:00
alex 5202048d6b alex/auto-undo-redo: wip: WorkingRecordsDiff 2024-04-08 17:53:47 +01:00
alex 118f8db854 alex/auto-undo-redo: auto undo/redo 2024-04-04 18:43:38 +01:00
alex 64f44b571a alex/auto-undo-redo: batched events 2024-04-04 16:36:29 +01:00
67 zmienionych plików z 2593 dodań i 2432 usunięć

Wyświetl plik

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

Wyświetl plik

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

Wyświetl plik

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

Wyświetl plik

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

Wyświetl plik

@ -28,10 +28,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';
@ -373,7 +375,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?: {
@ -590,7 +592,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;
@ -793,7 +795,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: {
originPagePoint: Vec;
originScreenPoint: Vec;
@ -829,9 +831,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;
@ -860,7 +862,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;
@ -880,7 +882,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;
@ -888,11 +890,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>;
};
@ -921,14 +923,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;
@ -1192,6 +1196,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: {
store: Store<R>;
annotateError?: (error: unknown) => void;
});
// (undocumented)
bail: () => this;
// (undocumented)
bailToMark: (id: string) => this;
// (undocumented)
batch: (fn: () => void, opts?: TLHistoryBatchOptions) => this;
// (undocumented)
clear(): void;
// @internal (undocumented)
debug(): {
undos: (NonNullable<TLHistoryEntry<R>> | undefined)[];
redos: (NonNullable<TLHistoryEntry<R>> | undefined)[];
pendingDiff: {
diff: RecordsDiff<R>;
isEmpty: boolean;
};
state: HistoryRecorderState;
};
// (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< {
undos: Stack<TLHistoryEntry<R>>;
redos: Stack<TLHistoryEntry<R>>;
}, unknown>;
// (undocumented)
undo: () => this;
}
// @public (undocumented)
export const HIT_TEST_MARGIN = 8;
@ -1706,6 +1759,17 @@ export class SideEffectManager<CTX extends {
constructor(editor: CTX);
// (undocumented)
editor: CTX;
// @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>;
};
}): () => void;
registerAfterChangeHandler<T extends TLRecord['typeName']>(typeName: T, handler: TLAfterChangeHandler<TLRecord & {
typeName: T;
}>): () => void;
@ -2017,29 +2081,6 @@ export type TLCollaboratorHintProps = {
color: string;
};
// @public (undocumented)
export type TLCommand<Name extends string = any, Data = any> = {
type: 'command';
data: Data;
name: Name;
preservesRedoStack?: boolean;
};
// @public (undocumented)
export type TLCommandHandler<Data> = {
do: (data: Data) => void;
undo: (data: Data) => void;
redo?: (data: Data) => void;
squash?: (prevData: Data, nextData: Data) => Data;
};
// @public (undocumented)
export type TLCommandHistoryOptions = Partial<{
squashing: boolean;
ephemeral: boolean;
preservesRedoStack: boolean;
}>;
// @public (undocumented)
export type TLCompleteEvent = (info: TLCompleteEventInfo) => void;
@ -2172,17 +2213,6 @@ export type TLEventInfo = TLCancelEventInfo | TLClickEventInfo | TLCompleteEvent
// @public (undocumented)
export interface TLEventMap {
// (undocumented)
'change-history': [{
reason: 'bail';
markId?: string;
} | {
reason: 'push' | 'redo' | 'undo';
}];
// (undocumented)
'mark-history': [{
id: string;
}];
// (undocumented)
'max-shapes': [{
name: string;
@ -2291,17 +2321,6 @@ export type TLHandlesProps = {
children: ReactNode;
};
// @public (undocumented)
export type TLHistoryEntry = TLCommand | TLHistoryMark;
// @public (undocumented)
export type TLHistoryMark = {
type: 'STOP';
id: string;
onUndo: boolean;
onRedo: boolean;
};
// @public (undocumented)
export type TLHoveredShapeIndicatorProps = {
shapeId: TLShapeId;
@ -2590,6 +2609,7 @@ export type TLStoreEventInfo = HistoryEntry<TLRecord>;
export type TLStoreOptions = {
initialData?: SerializedStore<TLRecord>;
defaultName?: string;
id?: string;
} & ({
schema?: StoreSchema<TLRecord, TLStoreProps>;
} | {

Wyświetl plik

@ -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
@ -134,6 +133,7 @@ export {
type TLEditorOptions,
type TLResizeShapeOptions,
} from './lib/editor/Editor'
export { HistoryManager } from './lib/editor/managers/HistoryManager'
export type {
SideEffectManager,
TLAfterChangeHandler,
@ -238,12 +238,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'

Wyświetl plik

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

Wyświetl plik

@ -14,6 +14,7 @@ import { TLAnyShapeUtilConstructor, checkShapesAndAddCore } from './defaultShape
export type TLStoreOptions = {
initialData?: SerializedStore<TLRecord>
defaultName?: string
id?: string
} & (
| { shapeUtils?: readonly TLAnyShapeUtilConstructor[] }
| { 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
@ -41,6 +47,7 @@ export function createTLStore({ initialData, defaultName = '', ...rest }: TLStor
})
return new Store({
id,
schema,
initialData,
props: {

Wyświetl plik

@ -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,109 @@ 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)
manager.batch(() => setB(1), { history: 'record' })
setA(2)
},
{ history: 'ignore' }
)
expect(getState()).toMatchObject({ a: 2, b: 1 })
// changes to A were ignore, but changes to B were recorded:
manager.undo()
expect(getState()).toMatchObject({ a: 2, b: 0 })
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 })
manager.redo()
expect(getState()).toMatchObject({ a: 3, b: 1 })
})
})

Wyświetl plik

@ -1,156 +1,120 @@
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
this.state = opts?.history ? modeToState[opts.history] : this.state
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 +125,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)
this.store.ensureStoreIsUsable()
this.stacks.set({ undos, redos })
} finally {
this.state = previousState
}
return this
}
@ -228,43 +196,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)
this.store.ensureStoreIsUsable()
this.stacks.set({ undos, redos })
} finally {
this.state = previousState
}
return this
}
@ -281,24 +249,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() }
}
}

Wyświetl plik

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

Wyświetl plik

@ -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 }]
}
/** @public */

Wyświetl plik

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

Wyświetl plik

@ -38,6 +38,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 createRecordType<R extends UnknownRecord>(typeName: R['typeName'], config: {
migrations?: Migrations;
@ -90,6 +93,9 @@ export class IncrementalSetConstructor<T> {
remove(item: T): void;
}
// @internal
export function isRecordsDiffEmpty<T extends UnknownRecord>(diff: RecordsDiff<T>): boolean;
// @public (undocumented)
export function migrate<T>({ value, migrations, fromVersion, toVersion, }: {
value: unknown;
@ -215,22 +221,29 @@ 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: {
id?: string;
initialData?: SerializedStore<R>;
schema: StoreSchema<R, Props>;
props: Props;
});
// @internal (undocumented)
addHistoryInterceptor(fn: (entry: HistoryEntry<R>, source: ChangeSource) => void): () => void;
allRecords: () => R[];
// (undocumented)
applyDiff(diff: RecordsDiff<R>, 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; };
@ -240,8 +253,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>>;

Wyświetl plik

@ -1947,7 +1947,7 @@
"text": ";"
}
],
"fileUrlPath": "packages/store/src/lib/Store.ts",
"fileUrlPath": "packages/store/src/lib/RecordsDiff.ts",
"releaseTag": "Public",
"name": "RecordsDiff",
"typeParameters": [
@ -2916,7 +2916,7 @@
"text": ";"
}
],
"fileUrlPath": "packages/store/src/lib/Store.ts",
"fileUrlPath": "packages/store/src/lib/RecordsDiff.ts",
"returnTypeTokenRange": {
"startIndex": 4,
"endIndex": 6
@ -3155,7 +3155,7 @@
"text": ";"
}
],
"fileUrlPath": "packages/store/src/lib/Store.ts",
"fileUrlPath": "packages/store/src/lib/RecordsDiff.ts",
"returnTypeTokenRange": {
"startIndex": 6,
"endIndex": 8
@ -3295,7 +3295,7 @@
},
{
"kind": "Content",
"text": "{\n initialData?: "
"text": "{\n id?: string;\n initialData?: "
},
{
"kind": "Reference",
@ -3544,7 +3544,7 @@
{
"kind": "Method",
"canonicalReference": "@tldraw/store!Store#extractingChanges:member(1)",
"docComment": "",
"docComment": "/**\n * Run `fn` and return a {@link RecordsDiff} of the changes that occurred as a result.\n */\n",
"excerptTokens": [
{
"kind": "Content",
@ -3739,36 +3739,6 @@
"isProtected": false,
"isAbstract": false
},
{
"kind": "Property",
"canonicalReference": "@tldraw/store!Store#getRecordType:member",
"docComment": "",
"excerptTokens": [
{
"kind": "Content",
"text": "getRecordType: "
},
{
"kind": "Content",
"text": "<T extends R>(record: R) => T"
},
{
"kind": "Content",
"text": ";"
}
],
"isReadonly": false,
"isOptional": false,
"releaseTag": "Public",
"name": "getRecordType",
"propertyTypeTokenRange": {
"startIndex": 1,
"endIndex": 2
},
"isStatic": false,
"isProtected": false,
"isAbstract": false
},
{
"kind": "Method",
"canonicalReference": "@tldraw/store!Store#getSnapshot:member(1)",

Wyświetl plik

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

Wyświetl plik

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

Wyświetl plik

@ -1,5 +1,6 @@
import { Atom, Computed, Reactor, atom, computed, reactor, transact } from '@tldraw/state'
import {
assert,
filterEntries,
objectMapEntries,
objectMapFromEntries,
@ -11,23 +12,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 +104,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 +160,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 +170,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 +350,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 +395,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 +415,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 +437,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 +447,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 +474,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 +485,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)
}
}
}
})
}
@ -617,7 +586,7 @@ export class Store<R extends UnknownRecord = UnknownRecord, Props = unknown> {
throw new Error(`Failed to migrate snapshot: ${migrationResult.reason}`)
}
transact(() => {
this.atomic(() => {
this.clear()
this.put(Object.values(migrationResult.value))
this.ensureStoreIsUsable()
@ -725,9 +694,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)
@ -737,24 +709,18 @@ 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)
}
if (toRemove.length) {
this.remove(toRemove)
}
})
} finally {
this._runCallbacks = prevRunCallbacks
}
this.atomic(() => {
const toPut = objectMapValues(diff.added).concat(
objectMapValues(diff.updated).map(([_from, to]) => to)
)
const toRemove = objectMapKeys(diff.removed)
if (toPut.length) {
this.put(toPut)
}
if (toRemove.length) {
this.remove(toRemove)
}
}, runCallbacks)
}
/**
@ -821,20 +787,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
@ -846,64 +806,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')
)
}
}
/**
@ -943,21 +921,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)

Wyświetl plik

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

Wyświetl plik

@ -1,7 +1,8 @@
import { Computed, react, RESET_VALUE, transact } from '@tldraw/state'
import { BaseRecord, RecordId } from '../BaseRecord'
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>> {
@ -915,3 +916,270 @@ describe('snapshots', () => {
}).not.toThrow()
})
})
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"`)
})
})

Wyświetl plik

@ -1772,7 +1772,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)
@ -2290,7 +2290,7 @@ export interface TLUiSliderProps {
// (undocumented)
label: string;
// (undocumented)
onValueChange: (value: number, squashing: boolean) => void;
onValueChange: (value: number) => void;
// (undocumented)
steps: number;
// (undocumented)

Wyświetl plik

@ -20539,7 +20539,7 @@
},
{
"kind": "Content",
"text": "<T>, value: T, squashing: boolean) => void"
"text": "<T>, value: T) => void"
},
{
"kind": "Content",

Wyświetl plik

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

Wyświetl plik

@ -368,9 +368,7 @@ export class Drawing extends StateNode {
)
}
this.editor.updateShapes<TLDrawShape | TLHighlightShape>([shapePartial], {
squashing: true,
})
this.editor.updateShapes<TLDrawShape | TLHighlightShape>([shapePartial])
}
break
}
@ -428,7 +426,7 @@ export class Drawing extends StateNode {
)
}
this.editor.updateShapes([shapePartial], { squashing: true })
this.editor.updateShapes([shapePartial])
}
break
@ -570,7 +568,7 @@ export class Drawing extends StateNode {
)
}
this.editor.updateShapes([shapePartial], { squashing: true })
this.editor.updateShapes([shapePartial])
break
}
@ -615,7 +613,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) {

Wyświetl plik

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

Wyświetl plik

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

Wyświetl plik

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

Wyświetl plik

@ -81,7 +81,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)
}
@ -180,7 +180,7 @@ export class Brushing extends StateNode {
}
this.editor.updateInstanceState({ brush: { ...this.brush.toJson() } })
this.editor.setSelectedShapes(Array.from(results), { squashing: true })
this.editor.setSelectedShapes(Array.from(results))
}
override onInterrupt: TLInterruptEvent = () => {

Wyświetl plik

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

Wyświetl plik

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

Wyświetl plik

@ -64,12 +64,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 => ({
@ -200,7 +195,7 @@ export class Cropping extends StateNode {
},
}
this.editor.updateShapes([partial], { squashing: true })
this.editor.updateShapes([partial])
this.updateCursor()
}

Wyświetl plik

@ -81,10 +81,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)
@ -195,10 +192,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() {
@ -310,7 +304,7 @@ export class DraggingHandle extends StateNode {
}
if (changes) {
editor.updateShapes([next], { squashing: true })
editor.updateShapes([next])
}
}
}

Wyświetl plik

@ -26,10 +26,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'] = () => {

Wyświetl plik

@ -58,10 +58,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)
@ -100,10 +97,11 @@ export class PointingArrowLabel extends StateNode {
nextLabelPosition = 0.5
}
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 = () => {

Wyświetl plik

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

Wyświetl plik

@ -17,18 +17,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'] = () => {

Wyświetl plik

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

Wyświetl plik

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

Wyświetl plik

@ -60,10 +60,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)
@ -404,10 +401,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()
}

Wyświetl plik

@ -50,11 +50,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,
})
}
@ -104,11 +102,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,
})
}

Wyświetl plik

@ -142,16 +142,13 @@ export class ScribbleBrushing extends StateNode {
}
}
this.editor.setSelectedShapes(
[
...new Set<TLShapeId>(
shiftKey
? [...newlySelectedShapeIds, ...initialSelectedShapeIds]
: [...newlySelectedShapeIds]
),
],
{ squashing: true }
)
this.editor.setSelectedShapes([
...new Set<TLShapeId>(
shiftKey
? [...newlySelectedShapeIds, ...initialSelectedShapeIds]
: [...newlySelectedShapeIds]
),
])
}
private complete() {
@ -160,7 +157,7 @@ export class ScribbleBrushing extends StateNode {
}
private cancel() {
this.editor.setSelectedShapes([...this.initialSelectedShapeIds], { squashing: true })
this.editor.setSelectedShapes([...this.initialSelectedShapeIds])
this.parent.transition('idle')
}
}

Wyświetl plik

@ -85,10 +85,7 @@ export class Translating extends StateNode {
this.selectionSnapshot = {} as any
this.snapshot = {} as any
this.editor.snaps.clearIndicators()
this.editor.updateInstanceState(
{ cursor: { type: 'default', rotation: 0 } },
{ ephemeral: true }
)
this.editor.setCursor({ type: 'default', rotation: 0 })
this.dragAndDropManager.clear()
}
@ -429,7 +426,6 @@ export function moveShapesToPoint({
y: newLocalPoint.y,
}
})
),
{ squashing: true }
)
)
}

Wyświetl plik

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

Wyświetl plik

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

Wyświetl plik

@ -17,7 +17,9 @@ export const PageItemInput = function PageItemInput({
const handleChange = useCallback(
(value: string) => {
editor.renamePage(id, value ? value : 'New Page', { ephemeral: true })
editor.history.ignore(() => {
editor.renamePage(id, value ? value : 'New Page')
})
},
[editor, id]
)
@ -25,7 +27,7 @@ export const PageItemInput = function PageItemInput({
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]
)

Wyświetl plik

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

Wyświetl plik

@ -77,13 +77,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 })
@ -325,14 +325,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 })

Wyświetl plik

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

Wyświetl plik

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

Wyświetl plik

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

Wyświetl plik

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

Wyświetl plik

@ -1129,12 +1129,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,
},
@ -1291,10 +1288,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' })
},

Wyświetl plik

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

Wyświetl plik

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

Wyświetl plik

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

Wyświetl plik

@ -183,10 +183,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()
@ -304,10 +301,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()

Wyświetl plik

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

Wyświetl plik

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

Wyświetl plik

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

Wyświetl plik

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

Wyświetl plik

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

Wyświetl plik

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

Wyświetl plik

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

Wyświetl plik

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

Wyświetl plik

@ -1,4 +1,3 @@
import isEqual from 'lodash.isequal'
import { nanoid } from 'nanoid'
import {
Editor,
@ -8,6 +7,7 @@ import {
computed,
createPresenceStateDerivation,
createTLStore,
isRecordsDiffEmpty,
} from 'tldraw'
import { TLSyncClient } from '../lib/TLSyncClient'
import { schema } from '../lib/schema'
@ -16,6 +16,9 @@ import { RandomSource } from './RandomSource'
import { TestServer } from './TestServer'
import { TestSocketPair } from './TestSocketPair'
// eslint-disable-next-line import/no-internal-modules
import { prettyPrintDiff } from '../../../tldraw/src/test/testutils/pretty'
jest.mock('@tldraw/editor/src/lib/editor/managers/TickManager.ts', () => {
return {
TickManager: class {
@ -74,8 +77,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 +108,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 +183,7 @@ function runTest(seed: number) {
allOk('before applyOp')
peer.editor.applyOp(op)
assertPeerStoreIsUsable(peer)
allOk('after applyOp')
server.flushDebouncingMessages()
@ -210,6 +221,7 @@ function runTest(seed: number) {
if (!peer.socketPair.isConnected && peer.randomInt(2) === 0) {
peer.socketPair.connect()
allOk('final connect')
assertPeerStoreIsUsable(peer)
}
}
}
@ -223,33 +235,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 +277,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)