kopia lustrzana https://github.com/Tldraw/Tldraw
Porównaj commity
19 Commity
3115138c4f
...
7d9093ee56
Autor | SHA1 | Data |
---|---|---|
alex | 7d9093ee56 | |
alex | 3448e53a64 | |
alex | 2751a056f2 | |
alex | e5bbaee67b | |
alex | a166f5402d | |
alex | 0b4fd98f90 | |
alex | 5277297285 | |
alex | d494cbbd92 | |
alex | 82ea71ed31 | |
alex | e7c9a0fcaa | |
alex | 6d2018c897 | |
alex | b4f26556bf | |
alex | a631642b2d | |
alex | 463957ae94 | |
alex | d53f3f02d4 | |
alex | f22e474557 | |
alex | 5202048d6b | |
alex | 118f8db854 | |
alex | 64f44b571a |
|
@ -77,9 +77,7 @@ const ContextToolbarComponent = track(() => {
|
|||
width: 32,
|
||||
background: isActive ? 'var(--color-muted-2)' : 'transparent',
|
||||
}}
|
||||
onClick={() =>
|
||||
editor.setStyleForSelectedShapes(DefaultSizeStyle, value, { squashing: false })
|
||||
}
|
||||
onClick={() => editor.setStyleForSelectedShapes(DefaultSizeStyle, value)}
|
||||
>
|
||||
<TldrawUiIcon icon={icon} />
|
||||
</div>
|
||||
|
|
|
@ -25,7 +25,7 @@ function CustomStylePanel(props: TLUiStylePanelProps) {
|
|||
<TldrawUiButton
|
||||
type="menu"
|
||||
onClick={() => {
|
||||
editor.setStyleForSelectedShapes(DefaultColorStyle, 'red', { squashing: true })
|
||||
editor.setStyleForSelectedShapes(DefaultColorStyle, 'red')
|
||||
}}
|
||||
>
|
||||
<TldrawUiButtonLabel>Red</TldrawUiButtonLabel>
|
||||
|
@ -35,7 +35,7 @@ function CustomStylePanel(props: TLUiStylePanelProps) {
|
|||
<TldrawUiButton
|
||||
type="menu"
|
||||
onClick={() => {
|
||||
editor.setStyleForSelectedShapes(DefaultColorStyle, 'green', { squashing: true })
|
||||
editor.setStyleForSelectedShapes(DefaultColorStyle, 'green')
|
||||
}}
|
||||
>
|
||||
<TldrawUiButtonLabel>Green</TldrawUiButtonLabel>
|
||||
|
|
|
@ -29,7 +29,9 @@ export default function UserPresenceExample() {
|
|||
chatMessage: CURSOR_CHAT_MESSAGE,
|
||||
})
|
||||
|
||||
editor.store.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)
|
||||
}
|
||||
}}
|
||||
|
|
|
@ -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])
|
||||
|
||||
|
|
|
@ -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>;
|
||||
} | {
|
||||
|
|
Plik diff jest za duży
Load Diff
|
@ -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'
|
||||
|
|
|
@ -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
|
||||
})
|
||||
|
|
|
@ -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: {
|
||||
|
|
Plik diff jest za duży
Load Diff
|
@ -1,92 +1,75 @@
|
|||
import { TLCommandHistoryOptions } from '../types/history-types'
|
||||
import { BaseRecord, RecordId, Store, StoreSchema, createRecordType } from '@tldraw/store'
|
||||
import { TLHistoryBatchOptions } from '../types/history-types'
|
||||
import { HistoryManager } from './HistoryManager'
|
||||
import { stack } from './Stack'
|
||||
|
||||
interface TestRecord extends BaseRecord<'test', TestRecordId> {
|
||||
value: number | string
|
||||
}
|
||||
type TestRecordId = RecordId<TestRecord>
|
||||
const testSchema = StoreSchema.create<TestRecord, null>({
|
||||
test: createRecordType<TestRecord>('test', { scope: 'document' }),
|
||||
})
|
||||
|
||||
const ids = {
|
||||
count: testSchema.types.test.createId('count'),
|
||||
name: testSchema.types.test.createId('name'),
|
||||
age: testSchema.types.test.createId('age'),
|
||||
a: testSchema.types.test.createId('a'),
|
||||
b: testSchema.types.test.createId('b'),
|
||||
}
|
||||
|
||||
function createCounterHistoryManager() {
|
||||
const manager = new HistoryManager({ emit: () => void null }, () => {
|
||||
return
|
||||
})
|
||||
const state = {
|
||||
count: 0,
|
||||
name: 'David',
|
||||
age: 35,
|
||||
const 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 })
|
||||
})
|
||||
})
|
||||
|
|
|
@ -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() }
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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.
|
||||
|
|
|
@ -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 */
|
||||
|
|
|
@ -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'
|
||||
}
|
||||
|
|
|
@ -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>>;
|
||||
|
|
|
@ -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)",
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -0,0 +1,107 @@
|
|||
import { objectMapEntries } from '@tldraw/utils'
|
||||
import { IdOf, UnknownRecord } from './BaseRecord'
|
||||
|
||||
/**
|
||||
* A diff describing the changes to a record.
|
||||
*
|
||||
* @public
|
||||
*/
|
||||
export type RecordsDiff<R extends UnknownRecord> = {
|
||||
added: Record<IdOf<R>, R>
|
||||
updated: Record<IdOf<R>, [from: R, to: R]>
|
||||
removed: Record<IdOf<R>, R>
|
||||
}
|
||||
|
||||
/** @internal */
|
||||
export function createEmptyRecordsDiff<R extends UnknownRecord>(): RecordsDiff<R> {
|
||||
return { added: {}, updated: {}, removed: {} } as RecordsDiff<R>
|
||||
}
|
||||
|
||||
/** @public */
|
||||
export function reverseRecordsDiff(diff: RecordsDiff<any>) {
|
||||
const result: RecordsDiff<any> = { added: diff.removed, removed: diff.added, updated: {} }
|
||||
for (const [from, to] of Object.values(diff.updated)) {
|
||||
result.updated[from.id] = [to, from]
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
/**
|
||||
* Is a records diff empty?
|
||||
* @internal
|
||||
*/
|
||||
export function isRecordsDiffEmpty<T extends UnknownRecord>(diff: RecordsDiff<T>) {
|
||||
return (
|
||||
Object.keys(diff.added).length === 0 &&
|
||||
Object.keys(diff.updated).length === 0 &&
|
||||
Object.keys(diff.removed).length === 0
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Squash a collection of diffs into a single diff.
|
||||
*
|
||||
* @param diffs - An array of diffs to squash.
|
||||
* @returns A single diff that represents the squashed diffs.
|
||||
* @public
|
||||
*/
|
||||
export function squashRecordDiffs<T extends UnknownRecord>(
|
||||
diffs: RecordsDiff<T>[]
|
||||
): RecordsDiff<T> {
|
||||
const result = { added: {}, removed: {}, updated: {} } as RecordsDiff<T>
|
||||
|
||||
squashRecordDiffsMutable(result, diffs)
|
||||
return result
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply the array `diffs` to the `target` diff, mutating it in-place.
|
||||
* @internal
|
||||
*/
|
||||
export function squashRecordDiffsMutable<T extends UnknownRecord>(
|
||||
target: RecordsDiff<T>,
|
||||
diffs: RecordsDiff<T>[]
|
||||
): void {
|
||||
for (const diff of diffs) {
|
||||
for (const [id, value] of objectMapEntries(diff.added)) {
|
||||
if (target.removed[id]) {
|
||||
const original = target.removed[id]
|
||||
delete target.removed[id]
|
||||
if (original !== value) {
|
||||
target.updated[id] = [original, value]
|
||||
}
|
||||
} else {
|
||||
target.added[id] = value
|
||||
}
|
||||
}
|
||||
|
||||
for (const [id, [_from, to]] of objectMapEntries(diff.updated)) {
|
||||
if (target.added[id]) {
|
||||
target.added[id] = to
|
||||
delete target.updated[id]
|
||||
delete target.removed[id]
|
||||
continue
|
||||
}
|
||||
if (target.updated[id]) {
|
||||
target.updated[id] = [target.updated[id][0], to]
|
||||
delete target.removed[id]
|
||||
continue
|
||||
}
|
||||
|
||||
target.updated[id] = diff.updated[id]
|
||||
delete target.removed[id]
|
||||
}
|
||||
|
||||
for (const [id, value] of objectMapEntries(diff.removed)) {
|
||||
// the same record was added in this diff sequence, just drop it
|
||||
if (target.added[id]) {
|
||||
delete target.added[id]
|
||||
} else if (target.updated[id]) {
|
||||
target.removed[id] = target.updated[id][0]
|
||||
delete target.updated[id]
|
||||
} else {
|
||||
target.removed[id] = value
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
|
@ -1,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)
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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"`)
|
||||
})
|
||||
})
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -20539,7 +20539,7 @@
|
|||
},
|
||||
{
|
||||
"kind": "Content",
|
||||
"text": "<T>, value: T, squashing: boolean) => void"
|
||||
"text": "<T>, value: T) => void"
|
||||
},
|
||||
{
|
||||
"kind": "Content",
|
||||
|
|
|
@ -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])
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -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) {
|
||||
|
|
|
@ -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]
|
||||
)
|
||||
|
|
|
@ -25,7 +25,6 @@ describe(NoteShapeTool, () => {
|
|||
|
||||
editor.cancel() // leave edit mode
|
||||
|
||||
editor.undo() // undoes the selection change
|
||||
editor.undo()
|
||||
|
||||
expect(editor.getCurrentPageShapes().length).toBe(0)
|
||||
|
|
|
@ -5,10 +5,7 @@ export class Pointing extends StateNode {
|
|||
|
||||
override onEnter = () => {
|
||||
this.editor.stopCameraAnimation()
|
||||
this.editor.updateInstanceState(
|
||||
{ cursor: { type: 'grabbing', rotation: 0 } },
|
||||
{ ephemeral: true }
|
||||
)
|
||||
this.editor.setCursor({ type: 'grabbing', rotation: 0 })
|
||||
}
|
||||
|
||||
override onLongPress: TLEventHandlers['onLongPress'] = () => {
|
||||
|
|
|
@ -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 = () => {
|
||||
|
|
|
@ -6,17 +6,14 @@ export class Idle extends StateNode {
|
|||
static override id = 'idle'
|
||||
|
||||
override onEnter = () => {
|
||||
this.editor.updateInstanceState(
|
||||
{ cursor: { type: 'default', rotation: 0 } },
|
||||
{ ephemeral: true }
|
||||
)
|
||||
this.editor.setCursor({ type: 'default', rotation: 0 })
|
||||
|
||||
const onlySelectedShape = this.editor.getOnlySelectedShape()
|
||||
|
||||
// well this fucking sucks. what the fuck.
|
||||
// it's possible for a user to enter cropping, then undo
|
||||
// (which clears the cropping id) but still remain in this state.
|
||||
this.editor.on('change-history', this.cleanupCroppingState)
|
||||
this.editor.on('tick', this.cleanupCroppingState)
|
||||
|
||||
if (onlySelectedShape) {
|
||||
this.editor.mark('crop')
|
||||
|
@ -25,12 +22,9 @@ export class Idle extends StateNode {
|
|||
}
|
||||
|
||||
override onExit: TLExitEventHandler = () => {
|
||||
this.editor.updateInstanceState(
|
||||
{ cursor: { type: 'default', rotation: 0 } },
|
||||
{ ephemeral: true }
|
||||
)
|
||||
this.editor.setCursor({ type: 'default', rotation: 0 })
|
||||
|
||||
this.editor.off('change-history', this.cleanupCroppingState)
|
||||
this.editor.off('tick', this.cleanupCroppingState)
|
||||
}
|
||||
|
||||
override onCancel: TLEventHandlers['onCancel'] = () => {
|
||||
|
|
|
@ -32,10 +32,7 @@ export class TranslatingCrop extends StateNode {
|
|||
}
|
||||
|
||||
override onExit = () => {
|
||||
this.editor.updateInstanceState(
|
||||
{ cursor: { type: 'default', rotation: 0 } },
|
||||
{ ephemeral: true }
|
||||
)
|
||||
this.editor.setCursor({ type: 'default', rotation: 0 })
|
||||
}
|
||||
|
||||
override onPointerMove = () => {
|
||||
|
@ -102,7 +99,7 @@ export class TranslatingCrop extends StateNode {
|
|||
const partial = getTranslateCroppedImageChange(this.editor, shape, delta)
|
||||
|
||||
if (partial) {
|
||||
this.editor.updateShapes([partial], { squashing: true })
|
||||
this.editor.updateShapes([partial])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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()
|
||||
}
|
||||
|
||||
|
|
|
@ -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])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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'] = () => {
|
||||
|
|
|
@ -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 = () => {
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
|
||||
|
|
|
@ -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'] = () => {
|
||||
|
|
|
@ -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,
|
||||
})
|
||||
}
|
||||
|
||||
|
|
|
@ -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'] = () => {
|
||||
|
|
|
@ -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()
|
||||
}
|
||||
|
||||
|
|
|
@ -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,
|
||||
})
|
||||
}
|
||||
|
||||
|
|
|
@ -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')
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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 }
|
||||
)
|
||||
)
|
||||
}
|
||||
|
|
|
@ -19,10 +19,7 @@ export class ZoomTool extends StateNode {
|
|||
|
||||
override onExit = () => {
|
||||
this.parent.setCurrentToolIdMask(undefined)
|
||||
this.editor.updateInstanceState(
|
||||
{ zoomBrush: null, cursor: { type: 'default', rotation: 0 } },
|
||||
{ ephemeral: true }
|
||||
)
|
||||
this.editor.updateInstanceState({ zoomBrush: null, cursor: { type: 'default', rotation: 0 } })
|
||||
this.parent.setCurrentToolIdMask(undefined)
|
||||
}
|
||||
|
||||
|
@ -53,15 +50,9 @@ export class ZoomTool extends StateNode {
|
|||
|
||||
private updateCursor() {
|
||||
if (this.editor.inputs.altKey) {
|
||||
this.editor.updateInstanceState(
|
||||
{ cursor: { type: 'zoom-out', rotation: 0 } },
|
||||
{ ephemeral: true }
|
||||
)
|
||||
this.editor.setCursor({ type: 'zoom-out', rotation: 0 })
|
||||
} else {
|
||||
this.editor.updateInstanceState(
|
||||
{ cursor: { type: 'zoom-in', rotation: 0 } },
|
||||
{ ephemeral: true }
|
||||
)
|
||||
this.editor.setCursor({ type: 'zoom-in', rotation: 0 })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -37,7 +37,7 @@ export function MobileStylePanel() {
|
|||
const handleStylesOpenChange = useCallback(
|
||||
(isOpen: boolean) => {
|
||||
if (!isOpen) {
|
||||
editor.updateInstanceState({ isChangingStyle: false }, { ephemeral: true })
|
||||
editor.updateInstanceState({ isChangingStyle: false })
|
||||
}
|
||||
},
|
||||
[editor]
|
||||
|
|
|
@ -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]
|
||||
)
|
||||
|
|
|
@ -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])
|
||||
|
||||
|
|
|
@ -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 })
|
||||
|
|
|
@ -24,7 +24,7 @@ interface DoubleDropdownPickerProps<T extends string> {
|
|||
styleB: StyleProp<T>
|
||||
valueA: SharedStyle<T>
|
||||
valueB: SharedStyle<T>
|
||||
onValueChange: (style: StyleProp<T>, value: T, squashing: boolean) => void
|
||||
onValueChange: (style: StyleProp<T>, value: T) => void
|
||||
}
|
||||
|
||||
function _DoubleDropdownPicker<T extends string>({
|
||||
|
@ -88,7 +88,7 @@ function _DoubleDropdownPicker<T extends string>({
|
|||
<TldrawUiButton
|
||||
type="icon"
|
||||
key={item.value}
|
||||
onClick={() => onValueChange(styleA, item.value, false)}
|
||||
onClick={() => onValueChange(styleA, item.value)}
|
||||
title={`${msg(labelA)} — ${msg(`${uiTypeA}-style.${item.value}`)}`}
|
||||
>
|
||||
<TldrawUiButtonIcon icon={item.icon} invertIcon />
|
||||
|
@ -124,7 +124,7 @@ function _DoubleDropdownPicker<T extends string>({
|
|||
type="icon"
|
||||
title={`${msg(labelB)} — ${msg(`${uiTypeB}-style.${item.value}` as TLUiTranslationKey)}`}
|
||||
data-testid={`style.${uiTypeB}.${item.value}`}
|
||||
onClick={() => onValueChange(styleB, item.value, false)}
|
||||
onClick={() => onValueChange(styleB, item.value)}
|
||||
>
|
||||
<TldrawUiButtonIcon icon={item.icon} />
|
||||
</TldrawUiButton>
|
||||
|
|
|
@ -22,7 +22,7 @@ interface DropdownPickerProps<T extends string> {
|
|||
value: SharedStyle<T>
|
||||
items: StyleValuesForUi<T>
|
||||
type: TLUiButtonProps['type']
|
||||
onValueChange: (style: StyleProp<T>, value: T, squashing: boolean) => void
|
||||
onValueChange: (style: StyleProp<T>, value: T) => void
|
||||
}
|
||||
|
||||
function _DropdownPicker<T extends string>({
|
||||
|
@ -68,7 +68,7 @@ function _DropdownPicker<T extends string>({
|
|||
title={msg(`${uiType}-style.${item.value}` as TLUiTranslationKey)}
|
||||
onClick={() => {
|
||||
editor.mark('select style dropdown item')
|
||||
onValueChange(style, item.value, false)
|
||||
onValueChange(style, item.value)
|
||||
}}
|
||||
>
|
||||
<TldrawUiButtonIcon icon={item.icon} />
|
||||
|
|
|
@ -22,7 +22,7 @@ export interface TLUiButtonPickerProps<T extends string> {
|
|||
value: SharedStyle<T>
|
||||
items: StyleValuesForUi<T>
|
||||
theme: TLDefaultColorTheme
|
||||
onValueChange: (style: StyleProp<T>, value: T, squashing: boolean) => void
|
||||
onValueChange: (style: StyleProp<T>, value: T) => void
|
||||
}
|
||||
|
||||
function _TldrawUiButtonPicker<T extends string>(props: TLUiButtonPickerProps<T>) {
|
||||
|
@ -57,14 +57,14 @@ function _TldrawUiButtonPicker<T extends string>(props: TLUiButtonPickerProps<T>
|
|||
if (value.type === 'shared' && value.value === id) return
|
||||
|
||||
editor.mark('point picker item')
|
||||
onValueChange(style, id as T, false)
|
||||
onValueChange(style, id as T)
|
||||
}
|
||||
|
||||
const handleButtonPointerDown = (e: React.PointerEvent<HTMLButtonElement>) => {
|
||||
const { id } = e.currentTarget.dataset
|
||||
|
||||
editor.mark('point picker item')
|
||||
onValueChange(style, id as T, true)
|
||||
onValueChange(style, id as T)
|
||||
|
||||
rPointing.current = true
|
||||
window.addEventListener('pointerup', handlePointerUp) // see TLD-658
|
||||
|
@ -74,14 +74,14 @@ function _TldrawUiButtonPicker<T extends string>(props: TLUiButtonPickerProps<T>
|
|||
if (!rPointing.current) return
|
||||
|
||||
const { id } = e.currentTarget.dataset
|
||||
onValueChange(style, id as T, true)
|
||||
onValueChange(style, id as T)
|
||||
}
|
||||
|
||||
const handleButtonPointerUp = (e: React.PointerEvent<HTMLButtonElement>) => {
|
||||
const { id } = e.currentTarget.dataset
|
||||
if (value.type === 'shared' && value.value === id) return
|
||||
|
||||
onValueChange(style, id as T, false)
|
||||
onValueChange(style, id as T)
|
||||
}
|
||||
|
||||
return {
|
||||
|
|
|
@ -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 (
|
||||
|
|
|
@ -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' })
|
||||
},
|
||||
|
|
|
@ -24,9 +24,9 @@ export function pasteTldrawContent(editor: Editor, clipboard: TLContent, point?:
|
|||
seletionBoundsBefore?.collides(selectedBoundsAfter)
|
||||
) {
|
||||
// Creates a 'puff' to show a paste has happened.
|
||||
editor.updateInstanceState({ isChangingStyle: true }, { ephemeral: true })
|
||||
editor.updateInstanceState({ isChangingStyle: true })
|
||||
setTimeout(() => {
|
||||
editor.updateInstanceState({ isChangingStyle: false }, { ephemeral: true })
|
||||
editor.updateInstanceState({ isChangingStyle: false })
|
||||
}, 150)
|
||||
}
|
||||
}
|
||||
|
|
|
@ -134,7 +134,7 @@ export function usePrint() {
|
|||
}
|
||||
|
||||
const afterPrintHandler = () => {
|
||||
editor.once('change-history', () => {
|
||||
editor.once('tick', () => {
|
||||
clearElements(el, style)
|
||||
})
|
||||
}
|
||||
|
|
|
@ -102,15 +102,7 @@ export function ToolsProvider({ overrides, children }: TLUiToolsProviderProps) {
|
|||
icon: ('geo-' + id) as TLUiIconType,
|
||||
onSelect(source: TLUiEventSource) {
|
||||
editor.batch(() => {
|
||||
editor.updateInstanceState(
|
||||
{
|
||||
stylesForNextShape: {
|
||||
...editor.getInstanceState().stylesForNextShape,
|
||||
[GeoShapeGeoStyle.id]: id,
|
||||
},
|
||||
},
|
||||
{ ephemeral: true }
|
||||
)
|
||||
editor.setStyleForNextShapes(GeoShapeGeoStyle, id)
|
||||
editor.setCurrentTool('geo')
|
||||
trackEvent('select-tool', { source, id: `geo-${id}` })
|
||||
})
|
||||
|
|
|
@ -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()
|
||||
|
|
|
@ -23,7 +23,7 @@ describe('when less than two shapes are selected', () => {
|
|||
editor.setSelectedShapes([ids.boxB])
|
||||
|
||||
const fn = jest.fn()
|
||||
editor.on('update', fn)
|
||||
editor.store.listen(fn)
|
||||
editor.alignShapes(editor.getSelectedShapeIds(), 'top')
|
||||
jest.advanceTimersByTime(1000)
|
||||
expect(fn).not.toHaveBeenCalled()
|
||||
|
|
|
@ -46,7 +46,7 @@ describe('distributeShapes command', () => {
|
|||
it('does nothing', () => {
|
||||
editor.setSelectedShapes([ids.boxA, ids.boxB])
|
||||
const fn = jest.fn()
|
||||
editor.on('change-history', fn)
|
||||
editor.store.listen(fn)
|
||||
editor.distributeShapes(editor.getSelectedShapeIds(), 'horizontal')
|
||||
jest.advanceTimersByTime(1000)
|
||||
expect(fn).not.toHaveBeenCalled()
|
||||
|
|
|
@ -60,8 +60,9 @@ describe('Editor.moveShapesToPage', () => {
|
|||
|
||||
it('Adds undo items', () => {
|
||||
editor.history.clear()
|
||||
expect(editor.history.getNumUndos()).toBe(0)
|
||||
editor.moveShapesToPage([ids.box1], ids.page2)
|
||||
expect(editor.history.getNumUndos()).toBeGreaterThan(1)
|
||||
expect(editor.history.getNumUndos()).toBe(1)
|
||||
})
|
||||
|
||||
it('Does nothing on an empty ids array', () => {
|
||||
|
|
|
@ -1,13 +0,0 @@
|
|||
import { TestEditor } from '../TestEditor'
|
||||
|
||||
let editor: TestEditor
|
||||
|
||||
beforeEach(() => {
|
||||
editor = new TestEditor()
|
||||
})
|
||||
|
||||
describe('squashing', () => {
|
||||
editor
|
||||
|
||||
it.todo('squashes')
|
||||
})
|
|
@ -52,7 +52,7 @@ describe('distributeShapes command', () => {
|
|||
it('does nothing', () => {
|
||||
editor.setSelectedShapes([ids.boxA, ids.boxB])
|
||||
const fn = jest.fn()
|
||||
editor.on('change-history', fn)
|
||||
editor.store.listen(fn)
|
||||
editor.stackShapes(editor.getSelectedShapeIds(), 'horizontal', 0)
|
||||
jest.advanceTimersByTime(1000)
|
||||
expect(fn).not.toHaveBeenCalled()
|
||||
|
|
|
@ -27,7 +27,7 @@ describe('when less than two shapes are selected', () => {
|
|||
it('does nothing', () => {
|
||||
editor.setSelectedShapes([ids.boxB])
|
||||
const fn = jest.fn()
|
||||
editor.on('change-history', fn)
|
||||
editor.store.listen(fn)
|
||||
editor.stretchShapes(editor.getSelectedShapeIds(), 'horizontal')
|
||||
jest.advanceTimersByTime(1000)
|
||||
|
||||
|
|
|
@ -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()
|
||||
}
|
|
@ -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
|
||||
|
|
|
@ -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)
|
||||
|
|
Ładowanie…
Reference in New Issue