From 7d1473fc691ab674a559847d2fe6e0d0ead29069 Mon Sep 17 00:00:00 2001 From: Aria Minaei Date: Wed, 4 Oct 2023 13:24:56 +0200 Subject: [PATCH] Implement cell-based sync + undo/redo --- .../saaz/src/__snapshots__/rogue.test.ts.snap | 172 +++++ packages/saaz/src/back/SaazBack.ts | 24 +- packages/saaz/src/front/SaazFront.ts | 304 ++++++-- packages/saaz/src/index.test.ts | 141 ++-- packages/saaz/src/index.ts | 1 + packages/saaz/src/rogue.test.ts | 279 +++++++ packages/saaz/src/rogue.ts | 696 ++++++++++++++++++ packages/saaz/src/shared/transactions.ts | 36 +- packages/saaz/src/shared/utils.ts | 37 +- packages/saaz/src/types.ts | 36 +- packages/sync-server/src/state/schema.ts | 205 ++++-- packages/sync-server/src/state/types/core.ts | 7 +- .../src/state/types/studio/ahistoric.ts | 9 +- .../src/state/types/studio/historic.ts | 3 +- .../src/state/types/studio/index.ts | 6 +- packages/utils/src/color.ts | 18 +- theatre/core/src/projects/Project.ts | 5 +- .../src/projects/initialiseProjectState.ts | 2 +- theatre/core/src/sequences/Sequence.test.ts | 10 +- theatre/core/src/sequences/Sequence.ts | 3 +- .../interpolationTripleAtPosition.ts | 16 +- .../core/src/sheetObjects/SheetObject.test.ts | 5 +- .../sheetObjects/SheetObjectTemplate.test.ts | 4 +- theatre/shared/src/keyframeUtils.ts | 4 + theatre/studio/src/PaneManager.ts | 18 +- theatre/studio/src/Studio.ts | 7 +- theatre/studio/src/StudioStore/StudioStore.ts | 134 ++-- .../createTransactionPrivateApi.ts | 53 +- theatre/studio/src/SyncStore/AppLink.ts | 3 +- .../AggregateKeyframeConnector.tsx | 10 +- .../BasicKeyframedTrack.tsx | 8 +- .../KeyframeEditor/BasicKeyframeConnector.tsx | 26 +- .../KeyframeEditor/SingleKeyframeDot.tsx | 11 +- .../KeyframeEditor/SingleKeyframeEditor.tsx | 9 +- .../Right/DopeSheetSelectionView.tsx | 5 +- .../DopeSheet/Right/KeyframeSnapTarget.tsx | 4 +- .../Right/collectAggregateKeyframes.tsx | 6 +- .../DopeSheet/selections.ts | 9 +- .../BasicKeyframedTrack.tsx | 14 +- .../KeyframeEditor/Curve.tsx | 13 +- .../KeyframeEditor/CurveHandle.tsx | 14 +- .../GraphEditorDotNonScalar.tsx | 12 +- .../KeyframeEditor/GraphEditorDotScalar.tsx | 26 +- .../GraphEditorNonScalarDash.tsx | 7 +- .../KeyframeEditor/KeyframeEditor.tsx | 8 +- .../propEditors/getNearbyKeyframesOfTrack.tsx | 15 +- theatre/studio/src/utils/assets.ts | 7 +- 47 files changed, 2015 insertions(+), 427 deletions(-) create mode 100644 packages/saaz/src/__snapshots__/rogue.test.ts.snap create mode 100644 packages/saaz/src/rogue.test.ts create mode 100644 packages/saaz/src/rogue.ts create mode 100644 theatre/shared/src/keyframeUtils.ts diff --git a/packages/saaz/src/__snapshots__/rogue.test.ts.snap b/packages/saaz/src/__snapshots__/rogue.test.ts.snap new file mode 100644 index 0000000000..87eebd9b8b --- /dev/null +++ b/packages/saaz/src/__snapshots__/rogue.test.ts.snap @@ -0,0 +1,172 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`Rogue merging defaults 1`] = ` +{ + "a": { + "aStep": 1, + "bStep": 1, + "foo": "setBy1", + "obj": { + "objA": "true", + "objB": "true", + }, + }, +} +`; + +exports[`Rogue overriding an existing prop 1`] = ` +[ + { + "branchName": "base", + "path": [ + [ + "base", + "a", + ], + ], + "type": "SetBoxedValue", + "value": 2, + }, +] +`; + +exports[`Rogue setting a non-existing prop 1`] = ` +[ + { + "$branches": { + "base": { + "$mapProps": { + "a": { + "$branches": { + "base": { + "$boxedValue": 1, + }, + }, + "$type": [ + "boxed", + "base", + ], + }, + }, + }, + }, + "$type": [ + "map", + "base", + ], + }, + [ + { + "path": [ + [ + "base", + "a", + ], + ], + "type": "ChangeType", + "value": [ + "boxed", + "base", + ], + }, + { + "branchName": "base", + "path": [ + [ + "base", + "a", + ], + ], + "type": "SetBoxedValue", + "value": 1, + }, + ], +] +`; + +exports[`Rogue setting a non-existing prop to an object 1`] = ` +[ + { + "$branches": { + "base": { + "$mapProps": { + "a": { + "$branches": { + "base": { + "$mapProps": { + "b": { + "$branches": { + "base": { + "$boxedValue": 1, + }, + }, + "$type": [ + "boxed", + "base", + ], + }, + }, + }, + }, + "$type": [ + "map", + "base", + ], + }, + }, + }, + }, + "$type": [ + "map", + "base", + ], + }, + [ + { + "path": [ + [ + "base", + "a", + ], + ], + "type": "ChangeType", + "value": [ + "map", + "base", + ], + }, + { + "path": [ + [ + "base", + "a", + ], + [ + "base", + "b", + ], + ], + "type": "ChangeType", + "value": [ + "boxed", + "base", + ], + }, + { + "branchName": "base", + "path": [ + [ + "base", + "a", + ], + [ + "base", + "b", + ], + ], + "type": "SetBoxedValue", + "value": 1, + }, + ], +] +`; diff --git a/packages/saaz/src/back/SaazBack.ts b/packages/saaz/src/back/SaazBack.ts index 6170b10174..e921f710bb 100644 --- a/packages/saaz/src/back/SaazBack.ts +++ b/packages/saaz/src/back/SaazBack.ts @@ -11,11 +11,12 @@ import type { PeerSubscribeCallback, AllPeersPresenceState, PeerPresenceState, + FullSnapshot, } from '../types' import {BackStorage} from './BackStorage' import type {DebouncedFunc} from 'lodash-es' import {cloneDeep, throttle} from 'lodash-es' -import {ensureStateIsUptodate} from '../shared/utils' +import {ensureStateIsUptodate as ensureOpStateIsUptodate} from '../shared/utils' import {Atom} from '@theatre/dataverse' import deepEqual from '@theatre/utils/deepEqual' @@ -23,7 +24,7 @@ export default class SaazBack implements SaazBackInterface { private _dbName: string private _storage: BackStorage private _readyDeferred = defer() - private _dbState: {} = {} + private _dbState: FullSnapshot<$IntentionalAny> = {cell: {}, op: {}} private _clock: number | null = null private _peerStates: { [peerId in string]?: { @@ -31,7 +32,7 @@ export default class SaazBack implements SaazBackInterface { } } = {} private _subsribers: Array = [] - private _schema: Schema<$IntentionalAny> + private _schema: Schema<{$schemaVersion: number}> private _presenceState: Atom = new Atom({}) private _schedulePresenseUpdate: DebouncedFunc<() => void> @@ -113,7 +114,10 @@ export default class SaazBack implements SaazBackInterface { clock: this._clock ?? -1, lastIncorporatedPeerClock: this._peerStates[opts.peerId]?.lastIncorporatedPeerClock ?? null, - snapshot: {type: 'Snapshot', value: this._dbState}, + snapshot: { + type: 'Snapshot', + value: this._dbState, + }, } } @@ -168,7 +172,7 @@ export default class SaazBack implements SaazBackInterface { const peerState = this._peerStates[opts.peerId]! const rebasing = opts.backendClock !== this._clock - let stateSoFar = ensureStateIsUptodate(this._dbState, this._schema) + let snapshotSoFar = ensureOpStateIsUptodate(this._dbState, this._schema) let lastAcknowledgedClock = peerState.lastIncorporatedPeerClock let backendClock = this._clock ?? -1 const updatesToIncorporate = [] @@ -178,20 +182,20 @@ export default class SaazBack implements SaazBackInterface { continue } - const before = stateSoFar - const [after] = applyOptimisticUpdateToState( + const snapshotBefore = snapshotSoFar + const [opSnapshotAfter] = applyOptimisticUpdateToState( update, - before, + snapshotBefore, this._schema, true, ) - stateSoFar = after + snapshotSoFar = opSnapshotAfter lastAcknowledgedClock = update.peerClock backendClock++ } if (lastAcknowledgedClock !== peerState.lastIncorporatedPeerClock) { - this._dbState = stateSoFar + this._dbState = snapshotSoFar peerState.lastIncorporatedPeerClock = lastAcknowledgedClock this._clock = backendClock this._callSubscribersForBackendStateUpdate() diff --git a/packages/saaz/src/front/SaazFront.ts b/packages/saaz/src/front/SaazFront.ts index 7669dc7676..e420252901 100644 --- a/packages/saaz/src/front/SaazFront.ts +++ b/packages/saaz/src/front/SaazFront.ts @@ -3,12 +3,13 @@ import type { AllPeersPresenceState, BackGetUpdateSinceClockResult, BackState, + FullSnapshot, SaazBackInterface, Schema, TempTransaction, TempTransactionApi, Transaction, - ValidSnapshot, + ValidOpSnapshot, } from '../types' import type {ValidGenerators, EditorDefinitionToEditorInvocable} from '../types' import type {OnDiskSnapshot} from '../types' @@ -26,13 +27,22 @@ import waitForPrism from '@theatre/utils/waitForPrism' import {subscribeDebounced} from '@theatre/utils/subscribeDebounced' import fastDeepEqual from 'fast-deep-equal' import {diff} from 'jest-diff' +import type {Ops} from '../rogue' +import {jsonFromCell, makeDraft} from '../rogue' +import memoizeFn from '@theatre/utils/memoizeFn' const emptyObject = {} +const MAX_UNDO_STACK_SIZE = 1000 -type AtomState = { +type UndoStackItem = { + forwardOps: Ops + backwardOps: Ops +} + +type AtomState = { optimisticUpdatesQueue: Transaction[] - backendState: BackState | null - emptySnapshot: Snapshot + backendState: BackState | null + emptySnapshot: FullSnapshot tempTransactions: TempTransaction[] allPeersPresenceState: AllPeersPresenceState initialized: boolean @@ -41,26 +51,33 @@ type AtomState = { * We can use this to determine if the front storage is up to date. */ frontStorageStateMirror: { - backendState: BackState | null + backendState: BackState | null optimisticUpdatesQueue: Transaction[] } peerClock: number closedSessions: ClosedSession[] + undoRedo: { + // the stack is a list of transactions that can be undone. The first item is the most recent transaction. + stack: UndoStackItem[] + // 0 means no undo has been called since the last transaction. + cursor: number + } } export class SaazFront< - Snapshot extends ValidSnapshot, + OpSnapshot extends ValidOpSnapshot, Editors extends {}, Generators extends ValidGenerators, + CellShape extends {} = {}, > { /** * Using an atom here so we can react to changes to its state */ - private readonly _atom: Atom> + private readonly _atom: Atom> /** * The initial snapshot that was saved to disk, and provided as opts.initialSnapshot to the constructor */ - private readonly _diskSnapshot: OnDiskSnapshot | null + private readonly _diskSnapshot: OnDiskSnapshot | null /** * The peer id of this frontend. It is supposed to be unique per-tab, and globally. If there are more than * one Saaz instance per tab, then each should have its own peer id. Better to generate this via UUID. @@ -100,7 +117,7 @@ export class SaazFront< /** * The schema includes the shape of the snapshot, a nested object of editors, and a shallow object of generator functions. */ - private readonly _schema: Schema + private readonly _schema: Schema /** * A counter that is used to generate unique ids for temp transactions. @@ -116,18 +133,19 @@ export class SaazFront< * We use dataverse prisms to derive several values from the atom. This makes the reactive parts of the code easier to maintain. */ private _prisms: { + base: Prism> /** * The state as it is on the backend, plus all the optimistic updates that have not been acknowledged by the backend yet. */ - optimisticState: Prism + optimisticState: Prism> /** * This is optimisticState (see above), plus the temp transactions. */ - withTemps: Prism + withTemps: Prism> /** * This is withTemps (see above), plus the temp transactions of all the peers. */ - withPeers: Prism + withPeers: Prism> /** * The number of optimistic updates that have not been acknowledged by the backend yet. @@ -144,12 +162,15 @@ export class SaazFront< */ allSyncedToFrontStorage: Prism } = { - optimisticState: prism(() => { - const base: Snapshot = - val(this._atom.pointer.backendState.value) ?? + base: prism>(() => { + return ( + val(this._atom.pointer.backendState.snapshot) ?? val(this._atom.pointer.emptySnapshot) - - let stateSoFar: Snapshot = base + ) + }), + optimisticState: prism>(() => { + const base = this._prisms.base.getValue() + let stateSoFar = base // this may become a bottleneck const closedSessions = val(this._atom.pointer.closedSessions) for (const session of closedSessions) { @@ -181,7 +202,7 @@ export class SaazFront< return stateSoFar }), - withTemps: prism(() => { + withTemps: prism>(() => { let currentState = this._prisms.optimisticState.getValue() const temps = val(this._atom.pointer.tempTransactions) for (const temp of temps) { @@ -195,7 +216,7 @@ export class SaazFront< return currentState }), - withPeers: prism(() => { + withPeers: prism>(() => { let currentState = this._prisms.withTemps.getValue() for (const [peerId, presence] of Object.entries( val(this._atom.pointer.allPeersPresenceState), @@ -269,14 +290,18 @@ export class SaazFront< private _caches = { transactionToState: new WeakMap< Transaction, - {before: Snapshot; after: Snapshot; base: Snapshot} + { + before: FullSnapshot + after: FullSnapshot + base: FullSnapshot + } >(), } constructor(opts: { - schema: Schema + schema: Schema backend: SaazBackInterface - diskSnapshot?: OnDiskSnapshot + diskSnapshot?: OnDiskSnapshot peerId: string dbName: string storageAdapter: FrontStorageAdapter @@ -296,9 +321,9 @@ export class SaazFront< } else { this._diskSnapshot = null } - this._atom = new Atom>({ + this._atom = new Atom>({ optimisticUpdatesQueue: [], - emptySnapshot: ensureStateIsUptodate({}, opts.schema), + emptySnapshot: ensureStateIsUptodate(null, opts.schema), backendState: null, tempTransactions: [], allPeersPresenceState: emptyObject, @@ -309,6 +334,10 @@ export class SaazFront< }, peerClock: -1, closedSessions: [], + undoRedo: { + stack: [], + cursor: 0, + }, }) this._initializedPromise = waitForPrism( @@ -359,7 +388,7 @@ export class SaazFront< backendClock: initialSnapshot?.clock ?? null, lastIncorporatedPeerClock: null, lastSyncTime: null, - value: initialSnapshot?.snapshot ?? null, + snapshot: initialSnapshot?.snapshot ?? null, }) } else { if (initialSnapshot) { @@ -370,9 +399,8 @@ export class SaazFront< lastSyncTime: null, backendClock: cachedBackendState.backendClock ?? null, lastIncorporatedPeerClock: null, - - value: ensureStateIsUptodate( - cachedBackendState.value, + snapshot: ensureStateIsUptodate( + cachedBackendState.snapshot as $IntentionalAny, this._schema, ), }) @@ -500,7 +528,7 @@ export class SaazFront< backendClock: s.clock, lastIncorporatedPeerClock: s.lastIncorporatedPeerClock, lastSyncTime: Date.now(), - value: snapshot.value as $IntentionalAny, + snapshot: snapshot.value, }) } } @@ -567,9 +595,9 @@ export class SaazFront< private _cachedApplyTransactionToState( transaction: Transaction, - base: Snapshot, - before: Snapshot, - ): Snapshot { + base: FullSnapshot, + before: FullSnapshot, + ): FullSnapshot { let cache = this._caches.transactionToState.get(transaction) if (cache) { if (cache.before === before) { @@ -594,8 +622,11 @@ export class SaazFront< return after } - _setBackendState(opts: BackState) { - const s = {...opts, value: ensureStateIsUptodate(opts.value, this._schema)} + _setBackendState(opts: BackState) { + const s = { + ...opts, + value: ensureStateIsUptodate(opts.snapshot, this._schema), + } this._atom.setByPointer((p) => p.backendState, s) // let's GC the updates the backend has incorporated @@ -650,8 +681,8 @@ export class SaazFront< return unsub } - get state(): Snapshot { - return this._prisms.withPeers.getValue() + get state(): {op: OpSnapshot; cell: CellShape} { + return finalState(this._prisms.withPeers.getValue()) as $IntentionalAny } get isReady(): boolean { @@ -662,35 +693,39 @@ export class SaazFront< return this._initializedPromise } - tx(fn: (editors: EditorDefinitionToEditorInvocable) => void): void { - const [update, isEmpty] = this._createTransaction( + tx( + editorFn?: (editors: EditorDefinitionToEditorInvocable) => void, + draftFn?: (cellDraft: CellShape) => void, + undoable: boolean = true, + ): void { + const [update, isEmpty, backwardOps] = this._createTransaction( this._prisms.optimisticState.getValue(), - (editors) => { - return fn(editors) - }, + editorFn, + draftFn, ) if (isEmpty) return - this._pushOptimisticUpdate(update) + this._pushOptimisticUpdate(update, undoable ? backwardOps : []) } tempTx( - fn: (editors: EditorDefinitionToEditorInvocable) => void, - existingTempTransaction?: TempTransactionApi, - ): TempTransactionApi { + editorFn?: (editors: EditorDefinitionToEditorInvocable) => void, + draftFn?: (cellDraft: CellShape) => void, + existingTempTransaction?: TempTransactionApi, + ): TempTransactionApi { if (existingTempTransaction) { - existingTempTransaction.recapture(fn) + existingTempTransaction.recapture(editorFn, draftFn) return existingTempTransaction } - const [o, originalIsEmpty] = this._createTransaction( + const [o, originalIsEmpty, originalBackwardOps] = this._createTransaction( this._prisms.optimisticState.getValue(), - (editors) => { - return fn(editors) - }, + editorFn, + draftFn, ) const originalTransaction: TempTransaction = { ...o, tempId: this._tempTransactionCounter++, + backwardOps: originalBackwardOps, } this._setTempTransaction(originalTransaction.tempId, originalTransaction) @@ -700,7 +735,7 @@ export class SaazFront< let transactionState: 'alive' | 'committed' | 'discarded' = 'alive' - const commit = (): void => { + const commit = (undoable: boolean = true): void => { if (transactionState !== 'alive') { throw new Error('Transaction is already ' + transactionState) } @@ -710,7 +745,10 @@ export class SaazFront< const finalUpdate = {...currentTransaction} - this._pushOptimisticUpdate(finalUpdate) + this._pushOptimisticUpdate( + finalUpdate, + undoable ? currentTransaction.backwardOps : [], + ) } const discard = (): void => { if (transactionState !== 'alive') { @@ -720,21 +758,22 @@ export class SaazFront< this._setTempTransaction(originalTransaction.tempId, undefined) } const recapture = ( - fn: (editors: EditorDefinitionToEditorInvocable) => void, + editorFn?: (editors: EditorDefinitionToEditorInvocable) => void, + draftFn?: (cellDraft: CellShape) => void, ): void => { if (transactionState !== 'alive') { throw new Error('Transaction is already ' + transactionState) } - const [update, newIsEmpty] = this._createTransaction( + const [update, newIsEmpty, backwardOps] = this._createTransaction( this._prisms.optimisticState.getValue(), - (editors) => { - return fn(editors) - }, + editorFn, + draftFn, ) const newTransaction: TempTransaction = { ...update, tempId: originalTransaction.tempId, + backwardOps, } currentTransaction = newTransaction currentIsEmpty = newIsEmpty @@ -781,21 +820,40 @@ export class SaazFront< } private _createTransaction( - snapshot: Snapshot, - fn: (editors: EditorDefinitionToEditorInvocable) => void, - warnIfNoInvokations: boolean = true, - ): [udpate: Omit, isEmpty: boolean] { - const invokations = recordInvokations(this._schema.editors, fn) + fullSnapshot: FullSnapshot, + editorFn?: (editors: EditorDefinitionToEditorInvocable) => void, + draftFn?: (draft: CellShape) => void, + warnIfNoInvokations: boolean = false, + ): [ + udpate: Omit, + isEmpty: boolean, + backwardOps: Ops, + ] { + const invokations = editorFn + ? recordInvokations(this._schema.editors, editorFn) + : [] if (invokations.length === 0) { - if (warnIfNoInvokations) + if (warnIfNoInvokations && editorFn) console.info(`Transaction didn't invoke any editors. It's a no-op.`) } + let backwardOps: Ops = [] + + let draftOps: any[] = [] + if (typeof draftFn === 'function') { + const [draft, fin] = makeDraft(fullSnapshot.cell) + draftFn(draft) + const [_, forwardOps, _backwardOps] = fin() + if (forwardOps.length > 0) { + draftOps = forwardOps + backwardOps = _backwardOps + } + } const [producedSnapshot, generatorRecordings] = applyOptimisticUpdateToState( - {invokations, generatorRecordings: {}}, - snapshot, + {invokations, generatorRecordings: {}, draftOps}, + fullSnapshot, this._schema, false, ) @@ -803,13 +861,16 @@ export class SaazFront< const transaction: Omit = { invokations, generatorRecordings: generatorRecordings, + draftOps: draftOps, peerId: this._peerId, } - // const after = finishDraft(draft) as $FixMe as State - if (process.env.NODE_ENV !== 'production') { + if (process.env.NODE_ENV !== 'production' && editorFn) { if ( - !fastDeepEqual(invokations, recordInvokations(this._schema.editors, fn)) + !fastDeepEqual( + invokations, + recordInvokations(this._schema.editors, editorFn), + ) ) { throw new Error( `Transaction function seems to invoke different editors each time it is called. This means it is not deterministic, and running it several times will create different states. To fix this, make sure the transaction calls exactly the same editors, in the same order, with the same arguments`, @@ -818,7 +879,7 @@ export class SaazFront< const [secondSnapshot] = applyOptimisticUpdateToState( transaction, - snapshot, + fullSnapshot, this._schema, true, ) @@ -836,8 +897,9 @@ export class SaazFront< { invokations: invokationsSoFar, generatorRecordings: transaction.generatorRecordings, + draftOps: transaction.draftOps, }, - snapshot, + fullSnapshot, this._schema, true, ) @@ -846,8 +908,9 @@ export class SaazFront< { invokations: invokationsSoFar, generatorRecordings: transaction.generatorRecordings, + draftOps: transaction.draftOps, }, - snapshot, + fullSnapshot, this._schema, true, ) @@ -874,7 +937,11 @@ export class SaazFront< } } - return [transaction, invokations.length === 0] + return [ + transaction, + invokations.length === 0 && transaction.draftOps.length === 0, + backwardOps, + ] } async waitForStorageSync() { @@ -883,6 +950,8 @@ export class SaazFront< private _pushOptimisticUpdate( updateWithoutPeerClock: Omit, + // if defined, then it'll constitute an undo-able operation + backwardOps: Ops | undefined, ): void { const clockBefore = this._atom.get().peerClock const newClock = clockBefore + 1 @@ -892,6 +961,7 @@ export class SaazFront< invokations: updateWithoutPeerClock.invokations, peerId: updateWithoutPeerClock.peerId, peerClock: newClock, + draftOps: updateWithoutPeerClock.draftOps, } this._atom.reduce((state) => ({ @@ -899,6 +969,12 @@ export class SaazFront< peerClock: newClock, optimisticUpdatesQueue: [...state.optimisticUpdatesQueue, transaction], })) + + if (backwardOps?.length === 0) { + console.log('no backward ops', transaction.draftOps) + } + if (backwardOps && backwardOps.length > 0) + this._addToUndoStack({backwardOps, forwardOps: transaction.draftOps}) } async waitForBackendSync(): Promise { @@ -909,22 +985,89 @@ export class SaazFront< ) } + private _addToUndoStack(op: UndoStackItem) { + this._atom.reduceByPointer( + (p) => p.undoRedo, + (o) => { + let stack = + // copy the stack + [...o.stack] + // and only keep the items that are before the cursor (so if the user has undone, and then does a new operation, we'll discard the redo stack) + .slice(o.cursor) + + stack.unshift(op) + + if (stack.length > MAX_UNDO_STACK_SIZE) + stack.length = MAX_UNDO_STACK_SIZE + + return { + cursor: 0, + stack, + } + }, + ) + } + undo() { - throw new Error('not implemented') + const undoRedo = this._atom.get().undoRedo + if (undoRedo.cursor >= undoRedo.stack.length) return + const item = undoRedo.stack[undoRedo.cursor] + this._atom.reduceByPointer( + (p) => p.undoRedo, + (o) => { + return { + ...o, + cursor: o.cursor + 1, + } + }, + ) + + this._pushOptimisticUpdate( + { + draftOps: item.backwardOps, + generatorRecordings: {}, + invokations: [], + peerId: this._peerId, + }, + undefined, + ) } redo() { - throw new Error('not implemented') + const undoRedo = this._atom.get().undoRedo + if (undoRedo.cursor === 0) return + const item = undoRedo.stack[undoRedo.cursor - 1] + this._atom.reduceByPointer( + (p) => p.undoRedo, + (o) => { + return { + ...o, + cursor: o.cursor - 1, + } + }, + ) + + this._pushOptimisticUpdate( + { + draftOps: item.forwardOps, + generatorRecordings: {}, + invokations: [], + peerId: this._peerId, + }, + undefined, + ) } - subscribe(fn: (newState: Snapshot) => void): () => void { + subscribe( + fn: (newState: {op: OpSnapshot; cell: CellShape}) => void, + ): () => void { const withPeers = this._prisms.withPeers let oldState = withPeers.getValue() return withPeers.onStale(() => { const newState = withPeers.getValue() if (newState !== oldState) { oldState = newState - fn(newState) + fn(finalState(newState) as $IntentionalAny) } }) } @@ -936,6 +1079,13 @@ export class SaazFront< type ClosedSession = { peerId: string - backState: BackState | null + backState: BackState | null optimisticUpdates: Transaction[] } + +const finalState = memoizeFn((s: FullSnapshot): {op: S; cell: {}} => { + return { + op: s.op, + cell: jsonFromCell(s.cell) as $IntentionalAny, + } +}) diff --git a/packages/saaz/src/index.test.ts b/packages/saaz/src/index.test.ts index 2991ebcceb..db83ce632b 100644 --- a/packages/saaz/src/index.test.ts +++ b/packages/saaz/src/index.test.ts @@ -1,16 +1,16 @@ import {SaazFront} from './front/SaazFront' import {FrontMemoryAdapter} from './front/FrontMemoryAdapter' import SaazBack from './back/SaazBack' -import type {$IntentionalAny} from './types' +import type {$IntentionalAny, Schema} from './types' import {BackMemoryAdapter} from './back/BackMemoryAdapter' jest.setTimeout(1000) describe(`saaz`, () => { test('everything', async () => { - type Snapshot = { + type OpShape = { $schemaVersion: number - count: number + opCount: number } type Generators = { @@ -25,31 +25,40 @@ describe(`saaz`, () => { }, } - const editors = { - increaseBy(state: Snapshot, generators: Generators, opts: {by: number}) { - state.count += opts.by + const opEditors = { + increaseBy(state: OpShape, generators: Generators, opts: {by: number}) { + let count = state.opCount ?? 0 + state.opCount = count + opts.by }, // this is a bad editor because it uses a random number generator, so it's not deterministic. // we expect saaz.tx() to throw an error if we try to use it. - randomizeCountBadly(state: Snapshot, generators: Generators, opts: {}) { - state.count = Math.random() + randomizeCountBadly(state: OpShape, generators: Generators, opts: {}) { + state.opCount = Math.random() }, // this is a good editor because it uses a random number generator, but it's deterministic. - randomizeCountWell(state: Snapshot, generators: Generators, opts: {}) { - state.count = generators.rand() + randomizeCountWell(state: OpShape, generators: Generators, opts: {}) { + state.opCount = generators.rand() }, } - const schema = { - shape: null as $IntentionalAny as Snapshot, - migrate(state: $IntentionalAny) { - state.count ??= 0 - }, + type CellShape = {cellCount?: number} + + const schema: Schema< + OpShape, + typeof opEditors, + typeof generators, + CellShape + > = { + opShape: null as $IntentionalAny as OpShape, + // migrateOp(state: $IntentionalAny) {}, + // migrateCell(s) {}, + version: 1, - editors, + editors: opEditors, generators: generators, + cellShape: null as any as CellShape, } as const const mem = new FrontMemoryAdapter() @@ -70,7 +79,8 @@ describe(`saaz`, () => { await saaz.ready - expect(saaz.state.count).toEqual(0) + expect(saaz.state.op.opCount).toEqual(undefined) + expect(saaz.state.cell.cellCount).toEqual(undefined) expect(() => saaz.tx((editors) => { @@ -78,7 +88,15 @@ describe(`saaz`, () => { }), ).toThrow() - expect(saaz.state.count).toEqual(0) + expect(() => + saaz.tx(undefined, (draft) => { + draft.cellCount = 1 + throw new Error('oops') + }), + ).toThrow() + + expect(saaz.state.op.opCount).toEqual(undefined) + expect(saaz.state.cell.cellCount).toEqual(undefined) expect(() => saaz.tx((editors) => { @@ -87,7 +105,7 @@ describe(`saaz`, () => { }), ).toThrow() - expect(saaz.state.count).toEqual(0) + expect(saaz.state.op.opCount).toEqual(undefined) expect(() => saaz.tx((editors) => { @@ -97,23 +115,28 @@ describe(`saaz`, () => { await new Promise((resolve) => setTimeout(resolve, 100)) - expect(saaz.state.count).toEqual(10) + expect(saaz.state.op.opCount).toEqual(10) + + saaz.tx(undefined, (draft) => { + draft.cellCount = 1 + }) + + expect(saaz.state.cell.cellCount).toEqual(1) saaz.tx((editors) => { editors.increaseBy({by: 3}) }) - expect(saaz.state.count).toEqual(13) + expect(saaz.state.op.opCount).toEqual(13) await saaz.waitForBackendSync() - // await new Promise((resolve) => setTimeout(resolve, 100)) - await saaz.waitForStorageSync() expect( - (mem.export() as $IntentionalAny).keyval['test/lastBackendState'].value, - ).toEqual({count: 13}) + (mem.export() as $IntentionalAny).keyval['test/lastBackendState'].value + .op, + ).toEqual({opCount: 13}) // const fauxBackennd: SaazBackInterface = { // async getUpdatesSinceClock() { @@ -155,30 +178,58 @@ describe(`saaz`, () => { expect(saaz3.state).toEqual(saaz.state) - saaz.teardown() - saaz2.teardown() - saaz3.teardown() + saaz.tx(undefined, (draft) => { + draft.cellCount = 2 + }) - // const s = saaz.scrub() - // s.capture((editors) => { - // editors.foo({by: 1}) - // }) + expect(saaz.state.cell.cellCount).toEqual(2) - // expect(saaz.state.count).toEqual(1) + saaz.tx(undefined, (draft) => { + draft.cellCount = 3 + }) - // s.reset() - // expect(saaz.state.count).toEqual(0) - // s.capture((editors) => { - // editors.foo({by: 1}) - // }) - // expect(saaz.state.count).toEqual(1) - // s.commit() - // expect(saaz.state.count).toEqual(1) + expect(saaz.state.cell.cellCount).toEqual(3) - // saaz.undo() - // expect(saaz.state.count).toEqual(0) + await saaz.waitForBackendSync() + await saaz3.waitForBackendSync() - // saaz.redo() - // expect(saaz.state.count).toEqual(0) + expect(saaz3.state).toEqual(saaz.state) + + saaz.undo() + + expect(saaz.state.cell.cellCount).toEqual(2) + + await saaz.waitForBackendSync() + await saaz3.waitForBackendSync() + + expect(saaz3.state).toEqual(saaz.state) + + saaz.undo() + + expect(saaz.state.cell.cellCount).toEqual(1) + + saaz.redo() + expect(saaz.state.cell.cellCount).toEqual(2) + + saaz.redo() + expect(saaz.state.cell.cellCount).toEqual(3) + + saaz.undo() + saaz.undo() + expect(saaz.state.cell.cellCount).toEqual(1) + + saaz.tx(undefined, (draft) => { + draft.cellCount = 4 + }) + + expect(saaz.state.cell.cellCount).toEqual(4) + saaz.redo() + expect(saaz.state.cell.cellCount).toEqual(4) + saaz.undo() + expect(saaz.state.cell.cellCount).toEqual(1) + + saaz.teardown() + saaz2.teardown() + saaz3.teardown() }) }) diff --git a/packages/saaz/src/index.ts b/packages/saaz/src/index.ts index 7fe144a3b2..8bd73b2b9d 100644 --- a/packages/saaz/src/index.ts +++ b/packages/saaz/src/index.ts @@ -9,3 +9,4 @@ export type { Schema, } from './types' export {BackMemoryAdapter} from './back/BackMemoryAdapter' +export {current} from './rogue' diff --git a/packages/saaz/src/rogue.test.ts b/packages/saaz/src/rogue.test.ts new file mode 100644 index 0000000000..3a6e235343 --- /dev/null +++ b/packages/saaz/src/rogue.test.ts @@ -0,0 +1,279 @@ +import {BackMemoryAdapter} from './back/BackMemoryAdapter' +import SaazBack from './back/SaazBack' +import {FrontMemoryAdapter} from './front/FrontMemoryAdapter' +import {SaazFront} from './front/SaazFront' +import type {Root} from './rogue' +import {change, fromOps, jsonFromCell} from './rogue' +import type { Schema} from './types' + +const ahistoricSnapshot: Root = { + $type: ['map', 'base'], + $branches: { + base: { + $mapProps: { + foo: { + $type: ['map', 'base'], + $branches: { + base: { + $boxedValue: + 'some value here, but this will be ignored, because this is an obj register.', + $mapProps: { + bar: { + $type: ['boxed', 'base'], + $branches: { + base: { + $boxedValue: + 'some value here. this is an lww register, and it can contain any json value.', + }, + }, + }, + }, + }, + }, + }, + }, + }, + }, +} + +describe(`Rogue`, () => { + test('setting a non-existing prop', () => { + const [rep, ops] = change({}, (draft) => { + expect(draft.a).toBe(undefined) + draft.a = 1 + expect(draft.a).toBe(1) + }) + expect(jsonFromCell(rep)).toEqual({a: 1}) + + expect([rep, ops]).toMatchSnapshot() + const [rep2] = fromOps({}, ops) + expect(rep2).toEqual(rep) + }) + test('overriding an existing prop with the same value', () => { + const [rep1] = change({}, (draft) => { + draft.a = 1 + }) + const [rep2, ops] = change(rep1, (draft) => { + expect(draft.a).toBe(1) + draft.a = 1 + expect(draft.a).toBe(1) + }) + expect(rep1).toBe(rep2) + expect(ops).toEqual([]) + }) + test('overriding an existing prop', () => { + const [rep1] = change({}, (draft) => { + draft.a = 1 + }) + const [rep2, ops] = change(rep1, (draft) => { + expect(draft.a).toBe(1) + draft.a = 2 + expect(draft.a).toBe(2) + }) + expect(jsonFromCell(rep2)).toEqual({a: 2}) + expect(ops).toHaveLength(1) + expect(ops).toMatchSnapshot() + const [rep3] = fromOps(rep1, ops) + expect(rep3).toEqual(rep2) + }) + test('setting a non-existing prop to an object', () => { + const [rep, ops] = change({}, (draft) => { + expect(draft.a).toBe(undefined) + draft.a = {b: 1} + expect(draft.a).toEqual(draft.a) + expect(draft.a).toEqual({b: 1}) + }) + expect(jsonFromCell(rep)).toEqual({a: {b: 1}}) + + expect([rep, ops]).toMatchSnapshot() + const [rep2] = fromOps({}, ops) + expect(rep2).toEqual(rep) + }) + + test('setting an existing prop to an object', () => { + const [rep] = change({}, (draft) => { + draft.a = {b: 1} + }) + expect(jsonFromCell(rep)).toEqual({a: {b: 1}}) + const [rep2, ops2] = change(rep, (draft) => { + expect(draft.a).toEqual({b: 1}) + draft.a = {b: 2} + expect(draft.a).toEqual({b: 2}) + }) + + expect(jsonFromCell(rep2)).toEqual({a: {b: 2}}) + const [rep3] = fromOps(rep, ops2) + expect(rep3).toEqual(rep2) + }) + test('setting an existing prop from an object', () => { + const [rep] = change({}, (draft) => { + draft.a = {b: 1} + }) + expect(jsonFromCell(rep)).toEqual({a: {b: 1}}) + const [rep2, ops2] = change(rep, (draft) => { + expect(draft.a).toEqual({b: 1}) + draft.a = {c: 1} + expect(draft.a).toEqual({c: 1}) + }) + + expect(jsonFromCell(rep2)).toEqual({a: {c: 1}}) + const [rep3] = fromOps(rep, ops2) + expect(rep3).toEqual(rep2) + }) + + test(`merging defaults`, () => { + const [, ops1_1] = change({}, (draft) => { + draft.a = {aStep: 1, foo: 'setBy1', obj: {objA: 'true'}} + }) + const [, ops2_1] = change({}, (draft) => { + draft.a = {bStep: 1, foo: 'setBy2', obj: {objB: 'true'}} + }) + + const merge1 = fromOps({}, [...ops2_1, ...ops1_1])[0] + + const _11 = jsonFromCell(merge1) + expect(_11).toMatchSnapshot() + // console.log(_11) + }) + + function scenario( + name: string, + steps: Record< + string, + (draft: any, lastSnapshot: any, lastOps: any[]) => void + >, + ) { + describe(name, () => { + type StepResult = { + json: any + ops: any[] + backwardOps: any[] + rep: any + next?: StepResult + prev?: StepResult + } + let last: StepResult = { + json: {}, + backwardOps: [], + ops: [], + rep: {}, + } + const byStep: Record = {} + + for (const [stepName, fn] of Object.entries(steps)) { + test(stepName, () => { + const prev: StepResult = {...last} + const [rep, ops, backwardOps] = change(prev.rep, (draft) => { + fn(draft, prev.json, prev.ops) + }) + const stepResult: StepResult = { + rep, + ops, + backwardOps, + json: jsonFromCell(rep), + prev, + } + last.next = stepResult + last = stepResult + byStep[stepName] = stepResult + }) + } + + // i = 0 so that we skip the first step, which is the initial state + for (let i = 1; i < Object.keys(steps).length; i++) { + const prevStepName = Object.keys(steps)[i - 1] + const stepName = Object.keys(steps)[i] + test(`${prevStepName} => ${stepName}`, () => { + const stepResult = byStep[stepName] + const [rep] = fromOps(stepResult.prev!.rep, stepResult.ops) + expect(rep).toEqual(stepResult.rep) + }) + + test(`${prevStepName} <= ${stepName}`, () => { + const stepResult = byStep[stepName] + const [rep] = fromOps(stepResult.rep, stepResult.backwardOps) + const s = jsonFromCell(rep) + // note that as opposed to the previous test, we're not comparing cells, we're + // comparing snapshots. This is because the cells are not guaranteed to be the + // same when undoing a change, but the snapshots are. + expect(s).toEqual(stepResult.prev!.json) + }) + } + }) + } + + scenario('scenario 1', { + step1: (draft) => { + expect(draft.a).toBe(undefined) + draft.a = 1 + expect(draft.a).toBe(1) + }, + step2: (_, snapshot, ops) => { + expect(snapshot).toEqual({a: 1}) + }, + }) + scenario('scenario 2', { + step1: (draft) => { + draft.a = {a1: {a11: 1}} + expect(draft.a.a1).toEqual({a11: 1}) + draft.a.a1.a11 = 2 + expect(draft.a).toEqual({a1: {a11: 2}}) + }, + step2: (_, snapshot, ops) => { + expect(snapshot).toEqual({a: {a1: {a11: 2}}}) + }, + }) + scenario('scenario 3', { + step1: (draft) => { + draft.a = {a1: {a11: 1}} + expect(draft.a.a1).toEqual({a11: 1}) + draft.a = {b: 1} + expect(draft.a).toEqual({b: 1}) + }, + step2: (draft, snapshot, ops) => { + expect(draft.a).toEqual({b: 1}) + draft.a = 1 + }, + step3: (draft, snapshot) => { + expect(snapshot).toEqual({a: 1}) + }, + }) + describe(`saaz integration`, () => { + test(`test`, async () => { + type State = { + $schemaVersion: number + count: number + } + + const schema: Schema = { + version: 1, + // migrateOp(state: $IntentionalAny) {}, + // migrateCell(s) {}, + generators: {}, + editors: { + increaseBy(state: State, generators: {}, opts: {by: number}) { + state.count += opts.by + }, + }, + opShape: null as any as State, + cellShape: null as any as {}, + } + const backend = new SaazBack({ + schema, + dbName: 'test', + storageAdapter: new BackMemoryAdapter(), + }) + const saaz = new SaazFront({ + schema, + dbName: 'test', + peerId: '1', + storageAdapter: new FrontMemoryAdapter(), + backend, + }) + + saaz.tx((editors) => {}) + + saaz.teardown() + }) + }) +}) diff --git a/packages/saaz/src/rogue.ts b/packages/saaz/src/rogue.ts new file mode 100644 index 0000000000..e01bc64527 --- /dev/null +++ b/packages/saaz/src/rogue.ts @@ -0,0 +1,696 @@ +import deepEqual from '@theatre/utils/deepEqual' +import type {$IntentionalAny} from './types' +import * as immer from 'immer' +import setDeep from 'lodash-es/set' +import memoizeFn from '@theatre/utils/memoizeFn' + +type BranchName = 'base' | string + +type Branch = { + $boxedValue?: any + $mapProps?: { + [key in string]?: Cell + } +} + +export type Cell = { + $type: [type: 'map' | 'boxed' | 'deleted', branchName: BranchName] + $branches?: { + [asOf in BranchName]?: Branch + } +} + +type CellToJSON = T extends { + $type: ['boxed', any] +} + ? CellBoxedToJSON + : T extends {$type: ['map']} + ? MapCellToJSON + : never + +type CellBoxedToJSON = T['$branches'] extends { + [key: string]: {$boxedValue: infer V} +} + ? V + : never + +type MapCellToJSON = T['$branches'] extends { + [key: string]: {$mapProps: infer V} +} + ? { + [Key in keyof V]: V[Key] extends Cell ? CellToJSON : never + } + : never + +export type Root = Cell + +const NOT_DEFINED = {} + +type Transaction = {clock: number; ops: Ops} + +export type Ops = Op[] + +type Op = ChnangeTypeOp | SetBoxedValue + +type ChnangeTypeOp = { + type: 'ChangeType' + path: Array<[branchName: BranchName, mapProp: string]> + value: Cell['$type'] +} + +/** + * { + * // type: base,foo + * // box: base,foo,base + * foo: bar + * // type: base,nested + * nested: { + * // type: base,nested,base,a + * // box: base,nested,base,a,base + * a: 1 + * } + * } + */ + +type SetBoxedValue = { + type: 'SetBoxedValue' + path: Array<[branchName: BranchName, mapProp: string]> + branchName: BranchName + value: any +} + +type PathSegment = [branchName: BranchName, mapProp: string] + +type Path = PathSegment[] + +function isCell(v: unknown): v is Cell { + if (typeof v !== 'object' || v === null) return false + const $type = (v as any).$type + if (!Array.isArray($type)) return false + if ($type.length !== 2 && $type.length !== 1) return false + if (typeof $type[0] !== 'string') return false + const [type, branchName] = $type + if (type === 'map' || type === 'boxed' || type === 'deleted') return true + return false +} + +export function makeDraft( + base: any, +): [draft: any, finish: () => [cell: Cell, forwardOps: Ops, backwardOps: Ops]] { + if (!isPlainObject(base)) { + throw Error(`Base must be a plain object`) + } + base = isCell(base) + ? base + : ({ + $type: ['map', 'base'], + $branches: {base: {$mapProps: base}}, + } as Cell) + + const immerDraft = immer.createDraft(base) + const state: State = { + imo: immerDraft, + } + + const draft = new Proxy(state, traps) + const finish = (): [cell: Cell, forwardOps: Ops, backwardOps: Ops] => { + const cell = immer.finishDraft(immerDraft) + const forwardOps = compare(base, cell) + const backwardOps = compare(cell, base) + + return [cell, forwardOps, backwardOps] + } + + return [draft, finish] +} + +export function change( + base: any, + fn: (draft: any) => void, +): [cell: any, ops: Ops, backwardOps: Ops] { + const [draft, finish] = makeDraft(base) + fn(draft) + return finish() +} + +export function fromOps(base: any, ops: Ops): [cell: any] { + if (!isPlainObject(base)) { + throw Error(`Base must be a plain object`) + } + base = isCell(base) + ? base + : ({ + $type: ['map', 'base'], + $branches: {base: {$mapProps: base}}, + } as Cell) + + const immerDraft = immer.createDraft(base) + const state: State = { + imo: immerDraft, + } + + for (const op of ops) { + const flatPath = op.path + .map(([branchName, mapProp]) => [ + '$branches', + branchName, + '$mapProps', + mapProp, + ]) + .flat() + if (op.type === 'ChangeType') { + const [type, branchName] = op.value + setDeep(immerDraft, [...flatPath, '$type'], [type, branchName]) + } else if (op.type === 'SetBoxedValue') { + setDeep( + immerDraft, + [...flatPath, '$branches', op.branchName, '$boxedValue'], + op.value, + ) + } else { + throw Error(`Unrecognized op type: ${(op as $IntentionalAny).type}`) + } + } + + return [immer.finishDraft(immerDraft)] +} + +function compare(before: Cell, after: Cell): Ops { + const ops: Ops = [] + compareCell(before, after, [], ops) + + return ops +} + +function compareCell( + before: Cell | undefined, + after: Cell, + path: Path, + ops: Ops, +) { + if (before === after) return + + if (!deepEqual(before?.$type, after.$type)) { + const beforeType = before + ? [before.$type[0], before.$type[1] ?? 'base'] + : null + const afterType = [after.$type[0], after.$type[1] ?? 'base'] + if (!deepEqual(beforeType, afterType)) { + ops.push({ + type: 'ChangeType', + path, + value: after.$type, + }) + } + } + + const [type, branchName] = [after.$type[0], after.$type[1] ?? 'base'] + const afterBranch = after.$branches?.[branchName] + const beforeBranch = before?.$branches?.[branchName] + if (afterBranch === beforeBranch) return + if (!afterBranch) return + + if (type === 'deleted') return + + if (type === 'boxed') { + if (afterBranch.$boxedValue !== beforeBranch?.$boxedValue) { + ops.push({ + type: 'SetBoxedValue', + path, + branchName, + value: afterBranch.$boxedValue, + }) + } + return + } + + if (type === 'map') { + const beforeMapProps = beforeBranch?.$mapProps ?? {} + const afterMapProps = afterBranch.$mapProps ?? {} + const afterKeys = Object.keys(afterMapProps) + for (const prop of afterKeys) { + compareCell( + Object.hasOwn(beforeMapProps, prop) ? beforeMapProps[prop] : undefined, + afterMapProps[prop]!, + [...path, [branchName, prop]], + ops, + ) + } + const beforeKeys = Object.keys(beforeMapProps) + for (const prop of beforeKeys) { + if (!Object.hasOwn(afterMapProps, prop)) { + ops.push({ + type: 'ChangeType', + path: [...path, [branchName, prop]], + value: ['deleted', generateBranchName()], + }) + } + } + return + } + + throw Error(`Unrecognized type: ${type}`) +} + +interface State { + imo: immer.Draft + parent?: State +} + +let _lastBranchName = 0 +const generateBranchName = () => { + _lastBranchName++ + return _lastBranchName.toString() +} + +const traps: ProxyHandler = { + get(state: State, prop) { + if (prop === DRAFT_STATE) return state + + if (typeof prop !== 'string') return undefined + + const imo = state.imo + const type = imo.$type[0] + const branchName = imo.$type[1] ?? 'base' + + if (type === 'deleted') + throw new Error(`This value is marked as deleted and cannot be accessed`) + + if (type === 'boxed') + throw new Error(`Implement getting inside a boxed value`) + + if (type === 'map') { + const mapProps = imo.$branches?.[branchName]?.$mapProps + if (!mapProps) return undefined + if (Object.hasOwn(mapProps, prop)) { + const value = mapProps[prop] + + if (!isCell(value)) + throw Error( + `mapProps[${prop}] is not an ahistoric cell. this is a bug.`, + ) + if (value.$type[0] === 'deleted') return undefined + if (value.$type[0] === 'boxed') { + const boxedValue = + value.$branches?.[value.$type[1] ?? 'base']?.$boxedValue + if (isPlainObject(boxedValue)) { + throw Error(`Implement getting a mapProp that is a boxed object`) + } else { + return boxedValue + } + } else if (value.$type[0] === 'map') { + const subState: State = { + imo: value, + parent: state, + } + return new Proxy(subState, traps) + } + } else { + return undefined + } + } + + throw new Error(`Unrecognized type: ${type}`) + }, + set(state: State, prop, _value: unknown): boolean { + if (prop === DRAFT_STATE) throw Error(`Unallowed`) + if (typeof prop !== 'string') + throw Error(`Non-string props are not allowed`) + + const value = valueType(_value) + + const imo = state.imo + const cellType = imo.$type[0] + const branchName = imo.$type[1] ?? 'base' + + // setting self.a=value, when self is deleted + if (cellType === 'deleted') + throw new Error(`This value is marked as deleted and cannot be changed`) + + // setting self.a=value, when self is a boxed value + if (cellType === 'boxed') + throw new Error(`Implement setting inside a boxed value`) + + // setting self.a=value when self is a map + if (cellType === 'map') { + let branches = imo.$branches + if (!branches) { + branches = {} + imo.$branches = branches + } + + let branch = branches[branchName] + if (!branch) { + branch = {} + branches[branchName] = branch + } + + let mapProps = branch.$mapProps + if (!mapProps) { + mapProps = {} + branch.$mapProps = mapProps + } + + // setting self.a=value when self.a is defined + if (Object.hasOwn(mapProps, prop)) { + if (!isCell(mapProps[prop])) + throw Error( + `mapProps[${prop}] is not an ahistoric cell. this is a bug.`, + ) + const currentPropCell = mapProps[prop]! + + // setting self.a={} + if (value.type === 'map') { + let currentBranch!: Branch + // setting self.a={} when self.a is not a map + + if (currentPropCell.$type[0] !== 'map') { + // we're switching from a non-map to a map, which means if a map was previously set, it was + // already deleted/overridden to be a boxed value, and the current user hasn't _seen_ the previous + // map yet. So we should generate a new branchName for the new map. + const newBranchName = generateBranchName() + currentPropCell.$type = ['map', newBranchName] + currentPropCell.$branches ??= {} + const newBranch: Branch = {$mapProps: {}} + currentPropCell.$branches[newBranchName] = newBranch + currentBranch = newBranch + } else { + currentPropCell.$branches ??= {} + currentPropCell.$branches[currentPropCell.$type[1] ?? 'base'] ??= {} + currentBranch = + currentPropCell.$branches[currentPropCell.$type[1] ?? 'base']! + } + const subState: State = { + imo: currentPropCell, + parent: state, + } + const proxy = new Proxy(subState, traps) + + const existingProps = Object.keys(proxy) + + // let's delete existing props that are not in the new value + for (const key of existingProps) { + if (!Object.hasOwn(value.value, key)) { + delete (proxy as $IntentionalAny)[key] + } + } + + for (const key of Object.keys(value.value)) { + ;(proxy as $IntentionalAny)[key] = value.value[key] + } + + return true + } else if (value.type === 'boxed') { + if (currentPropCell.$type[0] === 'boxed') { + currentPropCell.$branches ??= {} + const branches = currentPropCell.$branches! + const branchName = currentPropCell.$type[1] ?? 'base' + branches[branchName] ??= {} + const branch = branches[branchName]! + branch.$boxedValue = value.value + return true + } else { + const branchName = generateBranchName() + currentPropCell.$type = ['boxed', branchName] + currentPropCell.$branches ??= {} + const branches = currentPropCell.$branches! + branches[branchName] ??= {} + const branch = branches[branchName]! + branch.$boxedValue = value.value + return true + } + } + + throw new Error(`Unrecognized type: ${currentPropCell.$type[0]}`) + } else { + if (value.type === 'boxed') { + mapProps[prop] = { + $type: ['boxed', 'base'], + $branches: { + base: { + $boxedValue: value.value, + }, + }, + } + return true + } else if (value.type === 'map') { + mapProps[prop] = { + $type: ['map', 'base'], + } + const subState: State = { + imo: mapProps[prop]!, + parent: state, + } + const proxy = new Proxy(subState, traps) + for (const [k, v] of Object.entries(value.value)) { + ;(proxy as $IntentionalAny)[k] = v + } + return true + } + throw Error(`Unrecognized type: ${(value as $IntentionalAny).type}`) + } + } + + throw new Error(`Unrecognized type: ${cellType}`) + }, + has(state: State, prop) { + throw Error(`Implement has()`) + }, + ownKeys(state: State) { + const type = state.imo.$type[0] + if (type === 'boxed') { + const value = + state.imo.$branches?.[state.imo.$type[1] ?? 'base']?.$boxedValue + if (isPlainObject(value)) { + return Reflect.ownKeys(value) + } else { + return [] + } + } else if (type === 'deleted') { + return [] + } else if (type === 'map') { + const props = + state.imo.$branches?.[state.imo.$type[1] ?? 'base']?.$mapProps ?? {} + return Reflect.ownKeys(props).filter( + (key) => props[key as $IntentionalAny]!.$type[0] !== 'deleted', + ) + } else { + throw Error(`Unrecognized type: ${type}`) + } + }, + deleteProperty(state: State, prop) { + if (prop === DRAFT_STATE) throw Error(`Unallowed`) + if (typeof prop !== 'string') + throw Error(`Non-string props are not allowed`) + + const imo = state.imo + const type = imo.$type[0] + const branchName = imo.$type[1] ?? 'base' + + if (type === 'deleted') + throw new Error(`This value is marked as deleted and cannot be changed`) + + if (type === 'boxed') + throw new Error(`Implement deleting inside a boxed value`) + + if (type === 'map') { + const mapProps = imo.$branches?.[branchName]?.$mapProps + if (!mapProps) return false + if (!Object.hasOwn(mapProps, prop)) return false + + if (!isCell(mapProps[prop])) return false + const currentPropCell = mapProps[prop]! + + if (currentPropCell.$type[0] === 'deleted') return false + currentPropCell.$type = ['deleted', generateBranchName()] + return true + } + + throw new Error(`Unrecognized type: ${type}`) + }, + getOwnPropertyDescriptor(state: State, prop) { + const type = state.imo.$type[0] + if (type === 'boxed') { + const $boxedValue = + state.imo.$branches?.[state.imo.$type[1] ?? 'base']?.$boxedValue + if (isPlainObject($boxedValue)) { + return Reflect.getOwnPropertyDescriptor($boxedValue, prop) + } else { + return undefined + } + } else if (type === 'deleted') { + return undefined + } else if (type === 'map') { + const props = + state.imo.$branches?.[state.imo.$type[1] ?? 'base']?.$mapProps ?? {} + if (Object.hasOwn(props, prop)) { + return { + writable: true, + configurable: true, + enumerable: true, + value: (traps as $IntentionalAny).get(state, prop, {}), + } + } else { + return undefined + } + } else { + throw Error(`Unrecognized type: ${type}`) + } + }, + defineProperty(state: State, prop, descriptor) { + throw Error(`Implement defineProperty()`) + }, + getPrototypeOf(state: State) { + const type = state.imo.$type[0] + if (type === 'boxed') { + return Object.getPrototypeOf( + state.imo.$branches?.[state.imo.$type[1] ?? 'base']?.$boxedValue, + ) + } else if (type === 'deleted') { + return undefined + } else if (type === 'map') { + return Object.getPrototypeOf({}) + } else { + throw Error(`Unrecognized type: ${type}`) + } + }, + setPrototypeOf(state: State, prototype) { + throw Error(`Implement setPrototypeOf()`) + }, +} + +export const current = (draft: T): T => { + if (typeof draft !== 'object' || draft === null) { + return draft + } + const state = (draft as $IntentionalAny)[DRAFT_STATE] as State + if (!state) return draft + const currentImo = immer.current(state.imo) + return jsonFromCell(currentImo) as T +} + +function valueType( + v: V, +): + | {type: 'boxed'; value: V} + | {type: 'map'; value: {[key: string | number | symbol]: unknown}} { + if (typeof v === 'object' && v) { + if (Array.isArray(v)) { + return {type: 'boxed', value: v} + } + return {type: 'map', value: v as $IntentionalAny} + } + + if ( + typeof v === 'string' || + typeof v !== 'number' || + typeof v !== 'boolean' || + typeof v === 'undefined' || + v === null + ) { + return {type: 'boxed', value: v} + } + + throw Error(`Unrecognized value type: ${typeof v}`) +} + +const DRAFT_STATE: unique symbol = Symbol.for('draft-state') + +const objectCtorString = Object.prototype.constructor.toString() + +export function isPlainObject(value: any): boolean { + if (!value || typeof value !== 'object') return false + const proto = Object.getPrototypeOf(value) + if (proto === null) { + return true + } + const Ctor = + Object.hasOwnProperty.call(proto, 'constructor') && proto.constructor + + if (Ctor === Object) return true + + return ( + typeof Ctor == 'function' && + Function.toString.call(Ctor) === objectCtorString + ) +} + +const BOXED: unique symbol = Symbol.for('boxed') + +export function boxed(value: V): {[BOXED]: true; value: V} { + return {[BOXED]: true, value} +} + +function isBoxed(value: unknown): value is {[BOXED]: true; value: unknown} { + return typeof value === 'object' && + value && + (value as $IntentionalAny)[BOXED] === true + ? true + : false +} + +const RESET: unique symbol = Symbol.for('reset') + +export function reset(value: V): {[RESET]: true; value: V} { + return {[RESET]: true, value} +} + +function isReset(value: unknown): value is {[RESET]: true; value: unknown} { + return typeof value === 'object' && + value && + (value as $IntentionalAny)[RESET] === true + ? true + : false +} + +function is(x: any, y: any): boolean { + // Copied from https://github.com/immerjs/immer/blob/f6736a4beef727c6e5b41c312ce1b202ad3afb23/src/utils/common.ts#L115 + // Originally from: https://github.com/facebook/fbjs/blob/c69904a511b900266935168223063dd8772dfc40/packages/fbjs/src/core/shallowEqual.js + if (x === y) { + return x !== 0 || 1 / x === 1 / y + } else { + return x !== x && y !== y + } +} + +export function jsonFromCell( + v: V, +): V extends Cell ? CellToJSON : typeof NOT_DEFINED { + if (typeof v !== 'object' || v === null) { + return NOT_DEFINED as $IntentionalAny + } + + return _jsonFromCell(v as $IntentionalAny) as $IntentionalAny +} + +const _jsonFromCell = memoizeFn( + (v: V): CellToJSON | typeof NOT_DEFINED => { + const type = v.$type?.[0] + const branchName = v.$branches?.[v.$type?.[1] ?? 'base'] + + if (typeof type !== 'string') { + return NOT_DEFINED + } + + if (type === 'deleted') { + return NOT_DEFINED + } + + if (type === 'boxed') { + return branchName?.$boxedValue + } + + if (v.$type[0] === 'map') { + const props: {[key: string]: unknown} = {} + for (const [k, _value] of Object.entries(branchName?.$mapProps || {})) { + const value = jsonFromCell(_value as $IntentionalAny) + if (value !== NOT_DEFINED) { + props[k] = value + } + } + return props + } + + return NOT_DEFINED + }, +) diff --git a/packages/saaz/src/shared/transactions.ts b/packages/saaz/src/shared/transactions.ts index c7f6ce5393..ac32193103 100644 --- a/packages/saaz/src/shared/transactions.ts +++ b/packages/saaz/src/shared/transactions.ts @@ -11,23 +11,27 @@ import type { $IntentionalAny, EditorDefinitions, Schema, - ValidSnapshot as ValidSnapshot, + ValidOpSnapshot as ValidOpSnapshot, ValidGenerators, GeneratorRecordings, + FullSnapshot, } from '../types' +import {fromOps} from '../rogue' -export function applyOptimisticUpdateToState( +export function applyOptimisticUpdateToState< + OpSnapshot extends ValidOpSnapshot, +>( { invokations, generatorRecordings, - }: Pick, - before: State, - schema: Schema, + draftOps, + }: Pick, + before: FullSnapshot, + schema: Schema, playbackOnly: boolean = false, testDeterminism: boolean = true, -): [snapshot: State, generatorRecordings: GeneratorRecordings] { - const draft = createDraft(before) - // const {generatorRecordings, invokations} = update +): [after: FullSnapshot, generatorRecordings: GeneratorRecordings] { + const draft = createDraft(before.op) const [generatorSpy, newRecordings] = GeneratorSpy.createGeneratorsSpy( schema.generators, generatorRecordings, @@ -35,10 +39,12 @@ export function applyOptimisticUpdateToState( ) runInvokations(schema, draft, invokations, generatorSpy) - const after = finishDraft(draft) as $FixMe as State - return [after, newRecordings] + const opSnapshotAfter = finishDraft(draft) as $FixMe as OpSnapshot + const [cellAfter] = fromOps(before.cell, draftOps) + return [{op: opSnapshotAfter, cell: cellAfter}, newRecordings] } -export function recordInvokations( + +export function recordInvokations( editors: Editors, fn: (editors: EditorDefinitionToEditorInvocable) => void, ): Invokations { @@ -59,18 +65,12 @@ export function recordInvokations( return invokations } -function runInvokations( +function runInvokations( schema: Schema, prevState: Draft, invokations: Invokations, generatorSpy: ValidGenerators, ): void { - // const {generatorRecordings, invokations} = tr - // const [generatorSpy] = GeneratorSpy.createGeneratorsSpy( - // schema.generators, - // generatorRecordings, - // false, - // ) for (const [fnPath, opts] of invokations) { const fn = get(schema.editors, fnPath.split('.')) as EditorDefinitionFn if (typeof fn !== 'function') { diff --git a/packages/saaz/src/shared/utils.ts b/packages/saaz/src/shared/utils.ts index a235647884..e6a6a3133d 100644 --- a/packages/saaz/src/shared/utils.ts +++ b/packages/saaz/src/shared/utils.ts @@ -1,19 +1,28 @@ -import produce from 'immer' -import type {$IntentionalAny, Schema} from '../types' +import type {$IntentionalAny, FullSnapshot, Schema} from '../types' + +const empty = {op: {}, cell: {}} export function ensureStateIsUptodate( - original: $IntentionalAny, + original: FullSnapshot | null, schema: Schema, -): S { - if ( - !original || - typeof original.version !== 'number' || - original.version < schema.version - ) { - return produce((original ?? {}) as {}, (originalDraft) => { - schema.migrate(originalDraft) - }) as S - } else { - return original +): FullSnapshot { + if (original === null) { + return empty as $IntentionalAny } + return original as $IntentionalAny + + // if ( + // !original || + // typeof original.op.$schemaVersion !== 'number' || + // original.op.$schemaVersion < schema.version + // ) { + // return { + // op: produce((original?.op ?? {}) as {}, (originalDraft) => { + // schema.migrateOp(originalDraft) + // }) as S, + // cell: original?.cell ?? {}, + // } + // } else { + // return original + // } } diff --git a/packages/saaz/src/types.ts b/packages/saaz/src/types.ts index b8cabd4610..a4f14bed74 100644 --- a/packages/saaz/src/types.ts +++ b/packages/saaz/src/types.ts @@ -1,3 +1,5 @@ +import type {Cell, Ops} from './rogue' + export type $IntentionalAny = any export type $FixMe = any // Primitive values that are serializable to JSON. @@ -69,23 +71,24 @@ export type Transaction = { generatorRecordings: GeneratorRecordings peerId: string peerClock: number + draftOps: Ops } export type GeneratorRecordings = { [key in string]?: SerializableValue[] } -export type OnDiskSnapshot = { +export type OnDiskSnapshot = { // the url of the backend that this snapshot was taken from origin: string // the name of the database that this snapshot was taken from dbName: string // the clock of the server when this snapshot was taken. A positive integer. clock: number - snapshot: Snapshot + snapshot: FullSnapshot } -export type BackState = { +export type BackState = { /** * Unix timestamp of the last time the client synced with backend. Timestamp is produced on * the client, so it may be inaccurate. Null means never synced. @@ -103,12 +106,14 @@ export type BackState = { /** * The state of the backend. */ - value: null | State + snapshot: FullSnapshot | null } export type BackStateUpdateDescriptor = { clock: number - snapshot: {type: 'Snapshot'; value: unknown} | {type: 'Diff'; diff: 'todo'} + snapshot: + | {type: 'Snapshot'; value: FullSnapshot<$IntentionalAny>} + | {type: 'Diff'; diff: 'todo'} lastIncorporatedPeerClock: number | null tempTransactions?: 'todo' presense?: 'todo' @@ -233,28 +238,35 @@ export interface FrontStorageAdapter { export interface BackStorageAdapter {} export type Schema< - State extends {$schemaVersion: number}, + OpSnapshot extends {$schemaVersion: number}, Editors extends {} = {}, Generators extends ValidGenerators = {}, + CellShape extends {} = {}, > = { editors: Editors generators: Generators - shape: State + opShape: OpSnapshot + cellShape: CellShape version: number - migrate: (s: {}) => void } -export type ValidSnapshot = { +export type ValidOpSnapshot = { $schemaVersion: number } -export type TempTransaction = Omit & {tempId: number} +export type TempTransaction = Omit & { + tempId: number + backwardOps: Ops +} -export type TempTransactionApi = { +export type TempTransactionApi = { commit: () => void discard: () => void recapture: ( - fn: (editors: EditorDefinitionToEditorInvocable) => void, + editorFn?: (editors: EditorDefinitionToEditorInvocable) => void, + draftFn?: (draft: CellShape) => void, ) => void reset: () => void } + +export type FullSnapshot = {op: OpSnapshot; cell: Cell | {}} diff --git a/packages/sync-server/src/state/schema.ts b/packages/sync-server/src/state/schema.ts index eae55e62f9..19b715a211 100644 --- a/packages/sync-server/src/state/schema.ts +++ b/packages/sync-server/src/state/schema.ts @@ -35,7 +35,6 @@ import findLastIndex from 'lodash-es/findLastIndex' import keyBy from 'lodash-es/keyBy' import pullFromArray from 'lodash-es/pull' import set from 'lodash-es/set' -import sortBy from 'lodash-es/sortBy' import type { KeyframeWithPathToPropFromCommonRoot, OutlineSelectionState, @@ -48,13 +47,14 @@ import type { import {clamp, cloneDeep} from 'lodash-es' import {pointableSetUtil} from '@theatre/utils/PointableSet' import type {ProjectState_Historic} from './types' -import {current} from 'immer' +import {current} from '@theatre/saaz' import type {Draft as _Draft} from 'immer' import type { EditorDefinitionToEditorInvocable, Schema, } from '@theatre/saaz/src/types' import {nanoid as generateNonSecure} from 'nanoid/non-secure' +import memoizeFn from '@theatre/utils/memoizeFn' export const graphEditorColors: GraphEditorColors = { '1': {iconColor: '#b98b08'}, @@ -77,13 +77,13 @@ function generateSequenceTrackId(): SequenceTrackId { return generateNonSecure(10) as SequenceTrackId } -export const stateEditorAPI = { +const generators = { rand, generateKeyframeId, generateSequenceTrackId, } as const -export type StateEditorsAPI = typeof stateEditorAPI +export type StateEditorsAPI = {} type Draft = _Draft type API = StateEditorsAPI @@ -91,14 +91,7 @@ type API = StateEditorsAPI const initialState: StudioState = { $schemaVersion: 1, ahistoric: { - visibilityState: 'everythingIsVisible', - theTrigger: { - position: { - closestCorner: 'bottomLeft', - distanceFromHorizontalEdge: 0.02, - distanceFromVerticalEdge: 0.02, - }, - }, + // visibilityState: 'everythingIsVisible', projects: { stateByProjectId: {}, }, @@ -107,7 +100,6 @@ const initialState: StudioState = { projects: { stateByProjectId: {}, }, - autoKey: true, coreByProject: {}, panelInstanceDesceriptors: {}, }, @@ -119,6 +111,12 @@ const initialState: StudioState = { } export namespace stateEditors { + function _ensureAll(draft: Draft): Required { + draft.ahistoric ??= initialState.ahistoric + draft.historic ??= initialState.historic + draft.ephemeral ??= initialState.ephemeral + return draft as Required + } export namespace studio { export namespace historic { export namespace panelPositions { @@ -130,7 +128,7 @@ export namespace stateEditors { position: PanelPosition }, ) { - const h = draft.historic + const h = _ensureAll(draft).historic h.panelPositions ??= {} h.panelPositions[p.panelId] = p.position } @@ -142,7 +140,7 @@ export namespace stateEditors { api: API, {instanceId, paneClass}: PaneInstanceDescriptor, ) { - draft.historic.panelInstanceDesceriptors[instanceId] = { + _ensureAll(draft).historic.panelInstanceDesceriptors[instanceId] = { instanceId, paneClass, } @@ -153,13 +151,15 @@ export namespace stateEditors { api: API, p: {instanceId: PaneInstanceId}, ) { - delete draft.historic.panelInstanceDesceriptors[p.instanceId] + delete _ensureAll(draft).historic.panelInstanceDesceriptors[ + p.instanceId + ] } } export namespace panels { export function _ensure(draft: Draft, api: API) { - draft.historic.panels ??= {} - return draft.historic.panels! + _ensureAll(draft).historic.panels ??= {} + return _ensureAll(draft).historic.panels! } export namespace outline { @@ -214,7 +214,7 @@ export namespace stateEditors { export namespace projects { export namespace stateByProjectId { export function _ensure(draft: Draft, api: API, p: ProjectAddress) { - const s = draft.historic + const s = _ensureAll(draft).historic if (!s.projects.stateByProjectId[p.projectId]) { s.projects.stateByProjectId[p.projectId] = { stateBySheetId: {}, @@ -284,7 +284,6 @@ export namespace stateEditors { for (const [_, selectedProps] of Object.entries( selectedPropsByObject, )) { - // debugger for (const [_, takenColor] of Object.entries( selectedProps!, )) { @@ -452,7 +451,7 @@ export namespace stateEditors { export namespace projects { export namespace stateByProjectId { export function _ensure(draft: Draft, api: API, p: ProjectAddress) { - const s = draft.ephemeral + const s = _ensureAll(draft).ephemeral if (!s.projects.stateByProjectId[p.projectId]) { s.projects.stateByProjectId[p.projectId] = { stateBySheetId: {}, @@ -533,28 +532,28 @@ export namespace stateEditors { api: API, pinOutline: StudioAhistoricState['pinOutline'], ) { - draft.ahistoric.pinOutline = pinOutline + _ensureAll(draft).ahistoric.pinOutline = pinOutline } export function setPinDetails( draft: Draft, api: API, pinDetails: StudioAhistoricState['pinDetails'], ) { - draft.ahistoric.pinDetails = pinDetails + _ensureAll(draft).ahistoric.pinDetails = pinDetails } export function setPinNotifications( draft: Draft, api: API, pinNotifications: StudioAhistoricState['pinNotifications'], ) { - draft.ahistoric.pinNotifications = pinNotifications + _ensureAll(draft).ahistoric.pinNotifications = pinNotifications } export function setVisibilityState( draft: Draft, api: API, visibilityState: StudioAhistoricState['visibilityState'], ) { - draft.ahistoric.visibilityState = visibilityState + _ensureAll(draft).ahistoric.visibilityState = visibilityState } export function setClipboardKeyframes( @@ -573,12 +572,13 @@ export namespace stateEditors { }), ) + const ahistoric = _ensureAll(draft).ahistoric // save selection - if (draft.ahistoric.clipboard) { - draft.ahistoric.clipboard.keyframesWithRelativePaths = + if (ahistoric.clipboard) { + ahistoric.clipboard.keyframesWithRelativePaths = keyframesWithCommonRootPath } else { - draft.ahistoric.clipboard = { + _ensureAll(draft).ahistoric.clipboard = { keyframesWithRelativePaths: keyframesWithCommonRootPath, } } @@ -587,7 +587,7 @@ export namespace stateEditors { export namespace projects { export namespace stateByProjectId { export function _ensure(draft: Draft, api: API, p: ProjectAddress) { - const s = draft.ahistoric + const s = _ensureAll(draft).ahistoric if (!s.projects.stateByProjectId[p.projectId]) { s.projects.stateByProjectId[p.projectId] = { stateBySheetId: {}, @@ -760,7 +760,9 @@ export namespace stateEditors { api: API, p: ProjectAddress & {state: ProjectState_Historic}, ) { - draft.historic.coreByProject[p.projectId] = cloneDeep(p.state) + _ensureAll(draft).historic.coreByProject[p.projectId] = cloneDeep( + p.state, + ) } export namespace revisionHistory { export function add( @@ -769,7 +771,8 @@ export namespace stateEditors { p: ProjectAddress & {revision: string}, ) { const revisionHistory = - draft.historic.coreByProject[p.projectId].revisionHistory + _ensureAll(draft).historic.coreByProject[p.projectId] + .revisionHistory const maxNumOfRevisionsToKeep = 50 revisionHistory.unshift(p.revision) @@ -785,7 +788,7 @@ export namespace stateEditors { p: WithoutSheetInstance, ): SheetState_Historic { const sheetsById = - draft.historic.coreByProject[p.projectId].sheetsById + _ensureAll(draft).historic.coreByProject[p.projectId].sheetsById if (!sheetsById[p.sheetId]) { sheetsById[p.sheetId] = {staticOverrides: {byObject: {}}} @@ -799,7 +802,9 @@ export namespace stateEditors { p: WithoutSheetInstance, ) { const sheetState = - draft.historic.coreByProject[p.projectId].sheetsById[p.sheetId] + _ensureAll(draft).historic.coreByProject[p.projectId].sheetsById[ + p.sheetId + ] if (!sheetState) return delete sheetState.staticOverrides.byObject[p.objectKey] @@ -814,11 +819,12 @@ export namespace stateEditors { p: WithoutSheetInstance, ) { const sheetState = - draft.historic.coreByProject[p.projectId].sheetsById[p.sheetId] - if (sheetState) { - delete draft.historic.coreByProject[p.projectId].sheetsById[ + _ensureAll(draft).historic.coreByProject[p.projectId].sheetsById[ p.sheetId ] + if (sheetState) { + delete _ensureAll(draft).historic.coreByProject[p.projectId] + .sheetsById[p.sheetId] } } @@ -834,8 +840,6 @@ export namespace stateEditors { p, ) s.sequence ??= { - subUnitsPerUnit: 30, - length: 10, type: 'PositionalSequence', tracksByObject: {}, } @@ -882,12 +886,12 @@ export namespace stateEditors { const possibleTrackId = tracks.trackIdByPropPath[pathEncoded] if (typeof possibleTrackId === 'string') return - const trackId = api.generateSequenceTrackId() + const trackId = generators.generateSequenceTrackId() const track: BasicKeyframedTrack = { type: 'BasicKeyframedTrack', __debugName: `${p.objectKey}:${pathEncoded}`, - keyframes: [], + keyframes: {allIds: {}, byId: {}}, } tracks.trackData[trackId] = track @@ -968,7 +972,7 @@ export namespace stateEditors { ): Keyframe | undefined { const track = _getTrack(draft, api, p) if (!track) return - return track.keyframes.find((kf) => kf.id === p.keyframeId) + return track.keyframes.byId[p.keyframeId] } /** @@ -990,15 +994,20 @@ export namespace stateEditors { const position = p.snappingFunction(p.position) const track = _getTrack(draft, api, p) if (!track) return - const {keyframes} = track + + const prevById = current(track.keyframes) + const keyframes = keyframeUtils.getSortedKeyframes(prevById) + const existingKeyframeIndex = keyframes.findIndex( (kf) => kf.position === position, ) + if (existingKeyframeIndex !== -1) { const kf = keyframes[existingKeyframeIndex] - kf.value = p.value + track.keyframes.byId[kf.id]!.value = p.value return } + const indexOfLeftKeyframe = findLastIndex( keyframes, (kf) => kf.position < position, @@ -1008,24 +1017,26 @@ export namespace stateEditors { // generating the keyframe within the `setKeyframeAtPosition` makes it impossible for us // to make this business logic deterministic, which is important to guarantee for collaborative // editing. - id: api.generateKeyframeId(), + id: generators.generateKeyframeId(), position, connectedRight: true, handles: p.handles || [0.5, 1, 0.5, 0], type: p.type || 'bezier', value: p.value, }) + track.keyframes = keyframeUtils.fromArray(keyframes) return } const leftKeyframe = keyframes[indexOfLeftKeyframe] keyframes.splice(indexOfLeftKeyframe + 1, 0, { - id: api.generateKeyframeId(), + id: generators.generateKeyframeId(), position, connectedRight: leftKeyframe.connectedRight, handles: p.handles || [0.5, 1, 0.5, 0], type: p.type || 'bezier', value: p.value, }) + track.keyframes = keyframeUtils.fromArray(keyframes) } export function unsetKeyframeAtPosition( @@ -1038,13 +1049,16 @@ export namespace stateEditors { ) { const track = _getTrack(draft, api, p) if (!track) return - const {keyframes} = track + const keyframes = keyframeUtils.getSortedKeyframes( + current(track.keyframes), + ) const index = keyframes.findIndex( (kf) => kf.position === p.position, ) if (index === -1) return keyframes.splice(index, 1) + track.keyframes = keyframeUtils.fromArray(keyframes) } type SnappingFunction = (p: number) => number @@ -1063,7 +1077,9 @@ export namespace stateEditors { ) { const track = _getTrack(draft, api, p) if (!track) return - const initialKeyframes = current(track.keyframes) + const initialKeyframes = keyframeUtils.getSortedKeyframes( + current(track.keyframes), + ) const selectedKeyframes = initialKeyframes.filter((kf) => p.keyframeIds.includes(kf.id), @@ -1105,8 +1121,11 @@ export namespace stateEditors { const track = _getTrack(draft, api, p) if (!track) return - track.keyframes = track.keyframes.map((kf, i) => { - const prevKf = track.keyframes[i - 1] + const sorted = keyframeUtils.getSortedKeyframes( + current(track.keyframes), + ) + sorted.map((kf, i) => { + const prevKf = sorted[i - 1] const isBeingEdited = p.keyframeIds.includes(kf.id) const isAfterEditedKeyframe = p.keyframeIds.includes(prevKf?.id) @@ -1144,6 +1163,8 @@ export namespace stateEditors { return kf } }) + + track.keyframes = keyframeUtils.fromArray(sorted) } export function setHandlesForKeyframe( @@ -1178,9 +1199,10 @@ export namespace stateEditors { const track = _getTrack(draft, api, p) if (!track) return - track.keyframes = track.keyframes.filter( - (kf) => p.keyframeIds.indexOf(kf.id) === -1, - ) + for (const keyframeId of p.keyframeIds) { + delete track.keyframes.byId[keyframeId] + delete track.keyframes.allIds[keyframeId] + } } export function setKeyframeType( @@ -1212,7 +1234,7 @@ export namespace stateEditors { ) { const track = _getTrack(draft, api, p) if (!track) return - const initialKeyframes = current(track.keyframes) + const sanitizedKeyframes = p.keyframes .filter((kf) => { if (typeof kf.value === 'number' && !isFinite(kf.value)) @@ -1226,6 +1248,9 @@ export namespace stateEditors { const newKeyframesById = keyBy(sanitizedKeyframes, 'id') + const initialKeyframes = keyframeUtils.getSortedKeyframes( + current(track.keyframes), + ) const unselected = initialKeyframes.filter( (kf) => !newKeyframesById[kf.id], ) @@ -1242,12 +1267,13 @@ export namespace stateEditors { } }) - const sorted = sortBy( - [...unselected, ...sanitizedKeyframes], - 'position', - ) + const unsorted = [...unselected, ...sanitizedKeyframes] + // const sorted = sortBy( + // unsorted, + // 'position', + // ) - track.keyframes = sorted + track.keyframes = keyframeUtils.fromArray(unsorted) } } @@ -1313,18 +1339,61 @@ export namespace stateEditors { } } -export type IStateEditors = typeof stateEditors +export type IStateEditors = {} export type IInvokableStateEditors = EditorDefinitionToEditorInvocable -export const schema: Schema = { - shape: null as $IntentionalAny as StudioState, +export type IInvokableDraftEditors = EditorDefinitionToEditorInvocable< + typeof stateEditors +> + +export const schema: Schema<{$schemaVersion: number}, IStateEditors, {}> = { + opShape: null as $IntentionalAny as {$schemaVersion: number}, version: 1, - migrate(s: $IntentionalAny) { - s.ahistoric ??= initialState.ahistoric - s.historic ??= initialState.historic - s.ephemeral ??= initialState.ephemeral - }, - editors: stateEditors, - generators: stateEditorAPI, + // migrateOp(s: $IntentionalAny) { + // s.$schemaVersion ??= 1 + // return + // s.ahistoric ??= initialState.ahistoric + // s.historic ??= initialState.historic + // s.ephemeral ??= initialState.ephemeral + // }, + // migrateCell(s: $IntentionalAny) { + // s.ahistoric ??= initialState.ahistoric + // s.historic ??= initialState.historic + // s.ephemeral ??= initialState.ephemeral + // }, + editors: {}, + generators: {}, + cellShape: null as $IntentionalAny as StudioState, +} + +export namespace keyframeUtils { + export const getSortedKeyframes = ( + keyframes: BasicKeyframedTrack['keyframes'], + ): Keyframe[] => { + const sorted = Object.values( + keyframes.byId, + ) as $IntentionalAny as Keyframe[] + sorted.sort((a, b) => a.position! - b.position!) + + return cloneDeep(sorted) + } + + export const getSortedKeyframesCached = memoizeFn(getSortedKeyframes) + + export const fromArray = ( + keyframes: Keyframe[], + ): BasicKeyframedTrack['keyframes'] => { + const byId: BasicKeyframedTrack['keyframes']['byId'] = {} + const allIds: BasicKeyframedTrack['keyframes']['allIds'] = {} + + for (const keyframe of keyframes) { + byId[keyframe.id] = keyframe + allIds[keyframe.id] = true + } + + return cloneDeep({byId, allIds}) + } + + export const fromSortedKeyframesCached = memoizeFn(fromArray) } diff --git a/packages/sync-server/src/state/types/core.ts b/packages/sync-server/src/state/types/core.ts index 9b879a2218..1e831e558f 100644 --- a/packages/sync-server/src/state/types/core.ts +++ b/packages/sync-server/src/state/types/core.ts @@ -1,4 +1,5 @@ import type {Nominal} from '@theatre/utils/Nominal' +import type {PointableSet} from '@theatre/utils/PointableSet' import type {PathToProp_Encoded} from '@theatre/utils/pathToProp' import type { @@ -40,13 +41,13 @@ export type HistoricPositionalSequence = { * get truncated, but calling sequence.play() will play until it reaches the * length of the sequence. */ - length: number + length?: number /** * Given the most common case of tracking a sequence against time (where 1 second = position 1), * If set to, say, 30, then the keyframe editor will try to snap all keyframes * to a 30fps grid */ - subUnitsPerUnit: number + subUnitsPerUnit?: number tracksByObject: StrictRecord< ObjectAddressKey, @@ -105,7 +106,7 @@ export type BasicKeyframedTrack = TrackDataCommon<'BasicKeyframedTrack'> & { * {@link Keyframe} is not provided an explicit generic value `T`, because * a single track can technically have multiple different types for each keyframe. */ - keyframes: Keyframe[] + keyframes: PointableSet } type ProjectLoadingState = diff --git a/packages/sync-server/src/state/types/studio/ahistoric.ts b/packages/sync-server/src/state/types/studio/ahistoric.ts index f1086c87a0..9774d4b59e 100644 --- a/packages/sync-server/src/state/types/studio/ahistoric.ts +++ b/packages/sync-server/src/state/types/studio/ahistoric.ts @@ -18,18 +18,11 @@ export type StudioAhistoricState = { */ pinDetails?: boolean pinNotifications?: boolean - visibilityState: 'everythingIsHidden' | 'everythingIsVisible' + visibilityState?: 'everythingIsHidden' | 'everythingIsVisible' clipboard?: { keyframesWithRelativePaths?: KeyframeWithPathToPropFromCommonRoot[] // future clipboard data goes here } - theTrigger: { - position: { - closestCorner: 'topLeft' | 'topRight' | 'bottomLeft' | 'bottomRight' - distanceFromHorizontalEdge: number - distanceFromVerticalEdge: number - } - } projects: { stateByProjectId: StrictRecord< diff --git a/packages/sync-server/src/state/types/studio/historic.ts b/packages/sync-server/src/state/types/studio/historic.ts index 83b3ab18ad..cf43b172e8 100644 --- a/packages/sync-server/src/state/types/studio/historic.ts +++ b/packages/sync-server/src/state/types/studio/historic.ts @@ -89,7 +89,7 @@ export type StudioHistoricStateSequenceEditorMarker = { * See root {@link StudioHistoricState} */ export type StudioHistoricStateProjectSheet = { - selectedInstanceId: undefined | SheetInstanceId + selectedInstanceId?: undefined | SheetInstanceId sequenceEditor: { markerSet?: PointableSet< SequenceMarkerId, @@ -122,6 +122,5 @@ export type StudioHistoricState = { PaneInstanceId, PaneInstanceDescriptor > - autoKey: boolean coreByProject: Record } diff --git a/packages/sync-server/src/state/types/studio/index.ts b/packages/sync-server/src/state/types/studio/index.ts index e0a13b68f7..8abca6985c 100644 --- a/packages/sync-server/src/state/types/studio/index.ts +++ b/packages/sync-server/src/state/types/studio/index.ts @@ -14,16 +14,16 @@ export type StudioState = { /** * This is the part of the state that is undo/redo-able */ - historic: StudioHistoricState + historic?: StudioHistoricState /** * This is the part of the state that can't be undone, but it's * still persisted to localStorage */ - ahistoric: StudioAhistoricState + ahistoric?: StudioAhistoricState /** * This is entirely ephemeral, and gets lost if user refreshes the page */ - ephemeral: StudioEphemeralState + ephemeral?: StudioEphemeralState } export type PaneInstanceId = Nominal<'PaneInstanceId'> diff --git a/packages/utils/src/color.ts b/packages/utils/src/color.ts index d0c1b0a2d0..39901d3b06 100644 --- a/packages/utils/src/color.ts +++ b/packages/utils/src/color.ts @@ -1,4 +1,5 @@ import {clamp} from 'lodash-es' +import memoizeFn from './memoizeFn' /** * Robust check for a valid hex value (without the "#") in a string @@ -63,14 +64,19 @@ export function rgba2hex( // TODO: We should add a decorate property to the propConfig too. // Right now, each place that has anything to do with a color is individually // responsible for defining a toString() function on the object it returns. -export function decorateRgba(rgba: Rgba) { - return { +export const decorateRgba = memoizeFn((rgba: Rgba) => { + const obj = { ...rgba, - toString() { - return rgba2hex(this, {removeAlphaIfOpaque: true}) - }, + // toString: () => rgba2hex(rgba), } -} + Object.defineProperty(obj, 'toString', { + value: () => rgba2hex(rgba), + enumerable: false, + writable: false, + configurable: false, + }) + return obj +}) export function clampRgba(rgba: Rgba) { return Object.fromEntries( diff --git a/theatre/core/src/projects/Project.ts b/theatre/core/src/projects/Project.ts index 07158a70fd..6807cf38ee 100644 --- a/theatre/core/src/projects/Project.ts +++ b/theatre/core/src/projects/Project.ts @@ -27,6 +27,7 @@ import type { ITheatreLoggingConfig, } from '@theatre/shared/logger' import {_coreLogger} from '@theatre/core/_coreLogger' +import type {$IntentionalAny} from '@theatre/utils/types' type ICoreAssetStorage = { /** Returns a URL for the provided asset ID */ @@ -198,7 +199,9 @@ export default class Project { await initialiseProjectState(studio, this, this.config.state) this._pointerProxies.historic.setPointer( - studio.atomP.historic.coreByProject[this.address.projectId], + studio.atomP.historic.coreByProject[ + this.address.projectId + ] as $IntentionalAny, ) this._pointerProxies.ephemeral.setPointer( diff --git a/theatre/core/src/projects/initialiseProjectState.ts b/theatre/core/src/projects/initialiseProjectState.ts index 12b8186cab..0aeccdaab2 100644 --- a/theatre/core/src/projects/initialiseProjectState.ts +++ b/theatre/core/src/projects/initialiseProjectState.ts @@ -63,7 +63,7 @@ export default async function initialiseProjectState( revisionHistory: [], }, }) - }) + }, false) studio.ephemeralAtom.setByPointer( (p) => p.coreByProject[projectId].loadingState, { diff --git a/theatre/core/src/sequences/Sequence.test.ts b/theatre/core/src/sequences/Sequence.test.ts index 13b7ae384d..bf41c2c4ea 100644 --- a/theatre/core/src/sequences/Sequence.test.ts +++ b/theatre/core/src/sequences/Sequence.test.ts @@ -5,6 +5,7 @@ import type { SequenceTrackId, } from '@theatre/sync-server/state/types/core' import {asKeyframeId, asSequenceTrackId} from '@theatre/shared/utils/ids' +import {keyframeUtils} from '@theatre/sync-server/state/schema' describe(`Sequence`, () => { test('sequence.getKeyframesOfSimpleProp()', async () => { @@ -14,8 +15,8 @@ describe(`Sequence`, () => { }, sequence: { type: 'PositionalSequence', - length: 20, - subUnitsPerUnit: 30, + // length: 20, + // subUnitsPerUnit: 30, tracksByObject: { ['obj' as ObjectAddressKey]: { trackIdByPropPath: { @@ -24,7 +25,8 @@ describe(`Sequence`, () => { trackData: { ['1' as SequenceTrackId]: { type: 'BasicKeyframedTrack', - keyframes: [ + + keyframes: keyframeUtils.fromArray([ { id: asKeyframeId('0'), position: 10, @@ -41,7 +43,7 @@ describe(`Sequence`, () => { type: 'bezier', value: 6, }, - ], + ]), }, }, }, diff --git a/theatre/core/src/sequences/Sequence.ts b/theatre/core/src/sequences/Sequence.ts index 46373fd462..fd9c600470 100644 --- a/theatre/core/src/sequences/Sequence.ts +++ b/theatre/core/src/sequences/Sequence.ts @@ -27,6 +27,7 @@ import type {ISequence} from '..' import {notify} from '@theatre/shared/notify' import type {$IntentionalAny} from '@theatre/dataverse/src/types' import {isSheetObject} from '@theatre/shared/instanceTypes' +import {keyframeUtils} from '@theatre/sync-server/state/schema' export type IPlaybackRange = [from: number, to: number] @@ -154,7 +155,7 @@ export default class Sequence implements PointerToPrismProvider { return [] } - return track.keyframes + return keyframeUtils.getSortedKeyframesCached(track.keyframes) } get positionFormatter(): ISequencePositionFormatter { diff --git a/theatre/core/src/sequences/interpolationTripleAtPosition.ts b/theatre/core/src/sequences/interpolationTripleAtPosition.ts index c6ba8e2035..abc054c6b1 100644 --- a/theatre/core/src/sequences/interpolationTripleAtPosition.ts +++ b/theatre/core/src/sequences/interpolationTripleAtPosition.ts @@ -8,6 +8,7 @@ import {prism, val} from '@theatre/dataverse' import type {IUtilContext} from '@theatre/shared/logger' import type {SerializableValue} from '@theatre/utils/types' import UnitBezier from 'timing-function/lib/UnitBezier' +import {keyframeUtils} from '@theatre/sync-server/state/schema' /** `left` and `right` are not necessarily the same type. */ export type InterpolationTriple = { @@ -86,8 +87,9 @@ function updateState( progressionD: Prism, track: BasicKeyframedTrack, ): IStartedState { + const keyframes = keyframeUtils.getSortedKeyframesCached(track.keyframes) const progression = progressionD.getValue() - if (track.keyframes.length === 0) { + if (keyframes.length === 0) { return { started: true, validFrom: -Infinity, @@ -99,7 +101,7 @@ function updateState( let currentKeyframeIndex = 0 while (true) { - const currentKeyframe = track.keyframes[currentKeyframeIndex] + const currentKeyframe = keyframes[currentKeyframeIndex] if (!currentKeyframe) { if (process.env.NODE_ENV !== 'production') { @@ -108,7 +110,7 @@ function updateState( return states.error } - const isLastKeyframe = currentKeyframeIndex === track.keyframes.length - 1 + const isLastKeyframe = currentKeyframeIndex === keyframes.length - 1 if (progression < currentKeyframe.position) { if (currentKeyframeIndex === 0) { @@ -128,23 +130,23 @@ function updateState( } else { return states.between( currentKeyframe, - track.keyframes[currentKeyframeIndex + 1], + keyframes[currentKeyframeIndex + 1], progressionD, ) } } else { // last point - if (currentKeyframeIndex === track.keyframes.length - 1) { + if (currentKeyframeIndex === keyframes.length - 1) { return states.lastKeyframe(currentKeyframe) } else { const nextKeyframeIndex = currentKeyframeIndex + 1 - if (track.keyframes[nextKeyframeIndex].position <= progression) { + if (keyframes[nextKeyframeIndex].position <= progression) { currentKeyframeIndex = nextKeyframeIndex continue } else { return states.between( currentKeyframe, - track.keyframes[currentKeyframeIndex + 1], + keyframes[currentKeyframeIndex + 1], progressionD, ) } diff --git a/theatre/core/src/sheetObjects/SheetObject.test.ts b/theatre/core/src/sheetObjects/SheetObject.test.ts index b22992d0c3..5c5f4a668a 100644 --- a/theatre/core/src/sheetObjects/SheetObject.test.ts +++ b/theatre/core/src/sheetObjects/SheetObject.test.ts @@ -7,6 +7,7 @@ import type { import {iterateOver, prism} from '@theatre/dataverse' import type {SheetState_Historic} from '@theatre/sync-server/state/types/core' import {asKeyframeId, asSequenceTrackId} from '@theatre/shared/utils/ids' +import {keyframeUtils} from '@theatre/sync-server/state/schema' describe(`SheetObject`, () => { describe('static overrides', () => { @@ -268,7 +269,7 @@ describe(`SheetObject`, () => { trackData: { ['1' as SequenceTrackId]: { type: 'BasicKeyframedTrack', - keyframes: [ + keyframes: keyframeUtils.fromArray([ { id: asKeyframeId('0'), position: 10, @@ -285,7 +286,7 @@ describe(`SheetObject`, () => { type: 'bezier', value: 6, }, - ], + ]), }, }, }, diff --git a/theatre/core/src/sheetObjects/SheetObjectTemplate.test.ts b/theatre/core/src/sheetObjects/SheetObjectTemplate.test.ts index d268efa6e3..fc42d2bb77 100644 --- a/theatre/core/src/sheetObjects/SheetObjectTemplate.test.ts +++ b/theatre/core/src/sheetObjects/SheetObjectTemplate.test.ts @@ -18,7 +18,7 @@ describe(`SheetObjectTemplate`, () => { sequence: { type: 'PositionalSequence', subUnitsPerUnit: 30, - length: 10, + // length: 10, tracksByObject: { ['obj' as ObjectAddressKey]: { trackIdByPropPath: { @@ -54,8 +54,6 @@ describe(`SheetObjectTemplate`, () => { }, sequence: { type: 'PositionalSequence', - subUnitsPerUnit: 30, - length: 10, tracksByObject: {}, }, }) diff --git a/theatre/shared/src/keyframeUtils.ts b/theatre/shared/src/keyframeUtils.ts new file mode 100644 index 0000000000..8a2af822c6 --- /dev/null +++ b/theatre/shared/src/keyframeUtils.ts @@ -0,0 +1,4 @@ +import type { + Keyframe, +} from '@theatre/sync-server/state/types' + diff --git a/theatre/studio/src/PaneManager.ts b/theatre/studio/src/PaneManager.ts index e724f27693..43f76e7b76 100644 --- a/theatre/studio/src/PaneManager.ts +++ b/theatre/studio/src/PaneManager.ts @@ -4,6 +4,7 @@ import type {$IntentionalAny, StrictRecord} from '@theatre/utils/types' import type {Studio} from './Studio' import type {PaneInstance} from './TheatreStudio' import type {PaneInstanceId} from '@theatre/sync-server/state/types' +import {emptyObject} from '@theatre/shared/utils' export default class PaneManager { private readonly _cache = new SimpleCache() @@ -24,12 +25,12 @@ export default class PaneManager { prism((): StrictRecord> => { const core = val(this._studio.coreP) if (!core) return {} - const instanceDescriptors = val( - this._studio.atomP.historic.panelInstanceDesceriptors, - ) - const paneClasses = val( - this._studio.ephemeralAtom.pointer.extensions.paneClasses, - ) + const instanceDescriptors = + val(this._studio.atomP.historic.panelInstanceDesceriptors)! ?? + emptyObject + const paneClasses = + val(this._studio.ephemeralAtom.pointer.extensions.paneClasses) ?? + emptyObject const instances: StrictRecord> = {} for (const instanceDescriptor of Object.values(instanceDescriptors)) { @@ -78,9 +79,8 @@ export default class PaneManager { .extensionId, ) - const allPaneInstances = val( - this._studio.atomP.historic.panelInstanceDesceriptors, - ) + const allPaneInstances = + val(this._studio.atomP.historic.panelInstanceDesceriptors)! ?? emptyObject let instanceId!: PaneInstanceId for (let i = 1; i < 1000; i++) { instanceId = `${paneClass} #${i}` as PaneInstanceId diff --git a/theatre/studio/src/Studio.ts b/theatre/studio/src/Studio.ts index 4de94a8362..020e158615 100644 --- a/theatre/studio/src/Studio.ts +++ b/theatre/studio/src/Studio.ts @@ -327,8 +327,11 @@ export class Studio { return this._store.tempTransaction(fn, existingTransaction) } - transaction(fn: (api: ITransactionPrivateApi) => void): unknown { - return this._store.transaction(fn) + transaction( + fn: (api: ITransactionPrivateApi) => void, + undoable: boolean = true, + ): unknown { + return this._store.transaction(fn, undoable) } authenticate( diff --git a/theatre/studio/src/StudioStore/StudioStore.ts b/theatre/studio/src/StudioStore/StudioStore.ts index 954e3ffbc8..a65a9784e4 100644 --- a/theatre/studio/src/StudioStore/StudioStore.ts +++ b/theatre/studio/src/StudioStore/StudioStore.ts @@ -17,7 +17,7 @@ import SyncStoreAuth from '@theatre/studio/SyncStore/SyncStoreAuth' import SyncServerLink from '@theatre/studio/SyncStore/SyncServerLink' import {schema} from '@theatre/sync-server/state/schema' import type { - IInvokableStateEditors, + IInvokableDraftEditors, IStateEditors, StateEditorsAPI, } from '@theatre/sync-server/state/schema' @@ -39,11 +39,11 @@ export type StudioStoreOptions = { export interface ITransactionPrivateApi { set(pointer: Pointer, value: T): void unset(pointer: Pointer): void - stateEditors: IInvokableStateEditors + stateEditors: IInvokableDraftEditors } export type CommitOrDiscardOrRecapture = { - commit: VoidFn + commit: (undoable?: boolean) => void discard: VoidFn recapture: (fn: (api: ITransactionPrivateApi) => void) => void reset: VoidFn @@ -62,18 +62,23 @@ export default class StudioStore { }>({ready: false}) private _optionsDeferred = defer() - private _saaz: Saaz.SaazFront + private _saaz: Saaz.SaazFront< + {$schemaVersion: number}, + IStateEditors, + StateEditorsAPI, + StudioState + > constructor() { const syncServerLinkDeferred = defer() this._syncServerLink = syncServerLinkDeferred.promise this._appLink = this._optionsDeferred.promise.then(({serverUrl}) => - typeof window === 'undefined' + typeof window === 'undefined' && false ? (null as $IntentionalAny) : new AppLink(serverUrl), ) - if (typeof window !== 'undefined') { + if (typeof window !== 'undefined' && false) { void this._appLink .then((appLink) => { return appLink.api.syncServerUrl.query().then((url) => { @@ -89,7 +94,7 @@ export default class StudioStore { } this._auth = - typeof window !== 'undefined' + typeof window !== 'undefined' && false ? new SyncStoreAuth( this._optionsDeferred.promise, this._appLink, @@ -97,7 +102,7 @@ export default class StudioStore { ) : (null as $IntentionalAny) - if (typeof window !== 'undefined') { + if (typeof window !== 'undefined' && false) { void this._auth.ready.then(() => { this._state.setByPointer((p) => p.ready, true) }) @@ -106,7 +111,7 @@ export default class StudioStore { } const backend = - typeof window === 'undefined' + typeof window === 'undefined' || true ? new SaazBack({ storageAdapter: new Saaz.BackMemoryAdapter(), dbName: 'test', @@ -129,10 +134,10 @@ export default class StudioStore { this._saaz = saaz as $IntentionalAny this._atom = new Atom({} as StudioState) - this._atom.set(saaz.state) + this._atom.set(saaz.state.cell as $FixMe) saaz.subscribe((state) => { - this._atom.set(state as $IntentionalAny) + this._atom.set(state.cell as $IntentionalAny) }) this.atomP = this._atom.pointer } @@ -170,22 +175,29 @@ export default class StudioStore { // ) } - transaction(fn: (api: ITransactionPrivateApi) => void) { - this._saaz.tx((editors) => { - let running = true - - let ensureRunning = () => { - if (!running) { - throw new Error( - `You seem to have called the transaction api after studio.transaction() has finished running`, - ) + transaction( + fn: (api: ITransactionPrivateApi) => void, + undoable: boolean = true, + ) { + this._saaz.tx( + () => {}, + (draft) => { + let running = true + + let ensureRunning = () => { + if (!running) { + throw new Error( + `You seem to have called the transaction api after studio.transaction() has finished running`, + ) + } } - } - const transactionApi = createTransactionPrivateApi(ensureRunning, editors) - const ret = fn(transactionApi) - running = false - return ret - }) + const transactionApi = createTransactionPrivateApi(ensureRunning, draft) + const ret = fn(transactionApi) + running = false + return ret + }, + undoable, + ) return } @@ -198,56 +210,60 @@ export default class StudioStore { return existingTransaction } - const t = this._saaz.tempTx((editors) => { - let running = true + const t = this._saaz.tempTx( + () => {}, + (draft) => { + let running = true - let ensureRunning = () => { - if (!running) { - throw new Error( - `You seem to have called the transaction api after studio.transaction() has finished running`, - ) + let ensureRunning = () => { + if (!running) { + throw new Error( + `You seem to have called the transaction api after studio.transaction() has finished running`, + ) + } } - } - const transactionApi = createTransactionPrivateApi(ensureRunning, editors) - const ret = fn(transactionApi) - running = false - return ret - }) + const transactionApi = createTransactionPrivateApi(ensureRunning, draft) + const ret = fn(transactionApi) + running = false + return ret + }, + ) return { commit: t.commit, discard: t.discard, reset: t.reset, recapture: (fn: (api: ITransactionPrivateApi) => void): void => { - t.recapture((editors) => { - let running = true - - let ensureRunning = () => { - if (!running) { - throw new Error( - `You seem to have called the transaction api after studio.transaction() has finished running`, - ) + t.recapture( + () => {}, + (draft) => { + let running = true + + let ensureRunning = () => { + if (!running) { + throw new Error( + `You seem to have called the transaction api after studio.transaction() has finished running`, + ) + } } - } - const transactionApi = createTransactionPrivateApi( - ensureRunning, - editors, - ) - const ret = fn(transactionApi) - running = false - }) + const transactionApi = createTransactionPrivateApi( + ensureRunning, + draft, + ) + const ret = fn(transactionApi) + running = false + }, + ) }, } } undo() { - throw new Error(`Implement me`) - // this._reduxStore.dispatch(studioActions.historic.undo()) + this._saaz.undo() } redo() { - throw new Error(`Implement me`) - // this._reduxStore.dispatch(studioActions.historic.redo()) + this._saaz.redo() } createContentOfSaveFile(projectId: ProjectId): OnDiskState { diff --git a/theatre/studio/src/StudioStore/createTransactionPrivateApi.ts b/theatre/studio/src/StudioStore/createTransactionPrivateApi.ts index af2532be2f..655616854d 100644 --- a/theatre/studio/src/StudioStore/createTransactionPrivateApi.ts +++ b/theatre/studio/src/StudioStore/createTransactionPrivateApi.ts @@ -16,6 +16,9 @@ import type {PathToProp} from '@theatre/utils/pathToProp' import {getPropConfigByPath} from '@theatre/shared/propTypes/utils' import {isPlainObject} from 'lodash-es' import userReadableTypeOfValue from '@theatre/utils/userReadableTypeOfValue' +import type {StudioState} from '@theatre/sync-server/state/types' +import type {IInvokableDraftEditors} from '@theatre/sync-server/state/schema' +import {stateEditors} from '@theatre/sync-server/state/schema' /** * Deep-clones a plain JS object or a `string | number | boolean`. In case of a plain @@ -74,8 +77,13 @@ function forEachDeepSimplePropOfCompoundProp( export default function createTransactionPrivateApi( ensureRunning: () => void, - stateEditors: ITransactionPrivateApi['stateEditors'], + draft: StudioState, ): ITransactionPrivateApi { + const _invokableStateEditors = proxyStateEditors( + draft, + stateEditors, + ensureRunning, + ) as IInvokableDraftEditors return { set: (pointer, value) => { ensureRunning() @@ -98,13 +106,6 @@ export default function createTransactionPrivateApi( ) } - // if (isPropConfigComposite(propConfig)) { - // propConfig.validate(_value) - // } else { - - // propConfig.validate(_value) - // } - const setStaticOrKeyframeProp = ( value: T, propConfig: PropTypeConfig_AllSimples, @@ -136,7 +137,7 @@ export default function createTransactionPrivateApi( if (typeof trackId === 'string') { const seq = root.sheet.getSequence() seq.position = seq.closestGridPosition(seq.position) - stateEditors.coreByProject.historic.sheetsById.sequence.setKeyframeAtPosition( + _invokableStateEditors.coreByProject.historic.sheetsById.sequence.setKeyframeAtPosition( { ...propAddress, trackId, @@ -147,7 +148,7 @@ export default function createTransactionPrivateApi( }, ) } else { - stateEditors.coreByProject.historic.sheetsById.staticOverrides.byObject.setValueOfPrimitiveProp( + _invokableStateEditors.coreByProject.historic.sheetsById.staticOverrides.byObject.setValueOfPrimitiveProp( {...propAddress, value: value as $FixMe}, ) } @@ -217,7 +218,7 @@ export default function createTransactionPrivateApi( | undefined if (typeof trackId === 'string') { - stateEditors.coreByProject.historic.sheetsById.sequence.unsetKeyframeAtPosition( + _invokableStateEditors.coreByProject.historic.sheetsById.sequence.unsetKeyframeAtPosition( { ...propAddress, trackId, @@ -225,7 +226,7 @@ export default function createTransactionPrivateApi( }, ) } else if (propConfig !== undefined) { - stateEditors.coreByProject.historic.sheetsById.staticOverrides.byObject.unsetValueOfPrimitiveProp( + _invokableStateEditors.coreByProject.historic.sheetsById.staticOverrides.byObject.unsetValueOfPrimitiveProp( propAddress, ) } @@ -249,7 +250,33 @@ export default function createTransactionPrivateApi( } }, get stateEditors() { - return stateEditors + return _invokableStateEditors }, } } + +function proxyStateEditors( + draft: StudioState, + part: $IntentionalAny = stateEditors, + ensureRunning: () => void, +): {} { + return new Proxy(part, { + get(_, prop) { + ensureRunning() + if (Object.hasOwn(part, prop)) { + const v = part[prop as $IntentionalAny] + if (typeof v === 'function') { + return (opts: {}) => { + ensureRunning() + return v(draft, {}, opts) + } + } else if (typeof v === 'object' && v !== null) { + return proxyStateEditors(draft, v, ensureRunning) + } else { + return v + } + } + return undefined + }, + }) +} diff --git a/theatre/studio/src/SyncStore/AppLink.ts b/theatre/studio/src/SyncStore/AppLink.ts index f0b18a10ab..9b00d43f1d 100644 --- a/theatre/studio/src/SyncStore/AppLink.ts +++ b/theatre/studio/src/SyncStore/AppLink.ts @@ -7,6 +7,7 @@ export default class AppLink { private _client!: CreateTRPCProxyClient constructor(private _webAppUrl: string) { + if (process.env.NODE_ENV === 'test') return this._client = createTRPCProxyClient({ links: [ httpBatchLink({ @@ -21,7 +22,7 @@ export default class AppLink { transformer: superjson, }) - if (process.env.NODE_ENV === 'development') { + if (process.env.NODE_ENV === 'development' && false) { void this._client.healthCheck.query({name: 'the lib'}).then((res) => { console.log('app/healthCheck', res) }) diff --git a/theatre/studio/src/panels/SequenceEditorPanel/DopeSheet/Right/AggregatedKeyframeTrack/AggregateKeyframeEditor/AggregateKeyframeConnector.tsx b/theatre/studio/src/panels/SequenceEditorPanel/DopeSheet/Right/AggregatedKeyframeTrack/AggregateKeyframeEditor/AggregateKeyframeConnector.tsx index 04726f6331..e79e792e99 100644 --- a/theatre/studio/src/panels/SequenceEditorPanel/DopeSheet/Right/AggregatedKeyframeTrack/AggregateKeyframeEditor/AggregateKeyframeConnector.tsx +++ b/theatre/studio/src/panels/SequenceEditorPanel/DopeSheet/Right/AggregatedKeyframeTrack/AggregateKeyframeEditor/AggregateKeyframeConnector.tsx @@ -21,6 +21,7 @@ import { import useContextMenu from '@theatre/studio/uiComponents/simpleContextMenu/useContextMenu' import {commonRootOfPathsToProps} from '@theatre/utils/pathToProp' import type {KeyframeWithPathToPropFromCommonRoot} from '@theatre/sync-server/state/types' +import {keyframeUtils} from '@theatre/sync-server/state/schema' const POPOVER_MARGIN_PX = 5 const EasingPopoverWrapper = styled(BasicPopover)` @@ -160,17 +161,20 @@ function useDragKeyframe( tempTransaction.discard() tempTransaction = undefined } + tempTransaction = getStudio().tempTransaction(({stateEditors}) => { for (const keyframe of keyframes) { + const sortedKeyframes = keyframeUtils.getSortedKeyframesCached( + keyframe.track.data.keyframes, + ) stateEditors.coreByProject.historic.sheetsById.sequence.transformKeyframes( { ...keyframe.track.sheetObject.address, trackId: keyframe.track.id, keyframeIds: [ keyframe.kf.id, - keyframe.track.data.keyframes[ - keyframe.track.data.keyframes.indexOf(keyframe.kf) + 1 - ].id, + sortedKeyframes[sortedKeyframes.indexOf(keyframe.kf) + 1] + .id, ], translate: delta, scale: 1, diff --git a/theatre/studio/src/panels/SequenceEditorPanel/DopeSheet/Right/BasicKeyframedTrack/BasicKeyframedTrack.tsx b/theatre/studio/src/panels/SequenceEditorPanel/DopeSheet/Right/BasicKeyframedTrack/BasicKeyframedTrack.tsx index 449955b66d..ffeaf1ce72 100644 --- a/theatre/studio/src/panels/SequenceEditorPanel/DopeSheet/Right/BasicKeyframedTrack/BasicKeyframedTrack.tsx +++ b/theatre/studio/src/panels/SequenceEditorPanel/DopeSheet/Right/BasicKeyframedTrack/BasicKeyframedTrack.tsx @@ -18,6 +18,7 @@ import KeyframeSnapTarget, { snapPositionsStateD, } from '@theatre/studio/panels/SequenceEditorPanel/DopeSheet/Right/KeyframeSnapTarget' import {createStudioSheetItemKey} from '@theatre/shared/utils/ids' +import {keyframeUtils} from '@theatre/sync-server/state/schema' const Container = styled.div` position: relative; @@ -82,7 +83,10 @@ const BasicKeyframedTrack: React.VFC = React.memo( [trackData, leaf.trackId], ) - const keyframeEditors = trackData.keyframes.map((kf, index) => ( + const sortedKeyframes = keyframeUtils.getSortedKeyframesCached( + trackData.keyframes, + ) + const keyframeEditors = sortedKeyframes.map((kf, index) => ( = React.memo( const additionalSnapTargets = !snapToAllKeyframes ? null - : trackData.keyframes.map((kf) => ( + : sortedKeyframes.map((kf) => ( = ( props, ) => { const {index, track} = props - const cur = track.data.keyframes[index] - const next = track.data.keyframes[index + 1] + const cur = keyframeUtils.getSortedKeyframesCached(track.data.keyframes)[ + index + ] + const next = keyframeUtils.getSortedKeyframesCached(track.data.keyframes)[ + index + 1 + ] const [nodeRef, node] = useRefAndState(null) @@ -108,8 +113,10 @@ const SingleCurveEditorPopover: React.FC< track: {data: trackData}, selection, } = props - const cur = trackData.keyframes[index] - const next = trackData.keyframes[index + 1] + const cur = keyframeUtils.getSortedKeyframesCached(trackData.keyframes)[index] + const next = keyframeUtils.getSortedKeyframesCached(trackData.keyframes)[ + index + 1 + ] const trackId = props.leaf.trackId const address = props.leaf.sheetObject.address @@ -168,8 +175,9 @@ function useDragKeyframe( .getDragHandlers({ ...sheetObject.address, domNode: node!, - positionAtStartOfDrag: - props.track.data.keyframes[props.index].position, + positionAtStartOfDrag: keyframeUtils.getSortedKeyframesCached( + props.track.data.keyframes, + )[props.index].position, }) .onDragStart(event) } @@ -195,9 +203,9 @@ function useDragKeyframe( trackId: propsAtStartOfDrag.leaf.trackId, keyframeIds: [ propsAtStartOfDrag.keyframe.id, - propsAtStartOfDrag.track.data.keyframes[ - propsAtStartOfDrag.index + 1 - ].id, + keyframeUtils.getSortedKeyframesCached( + propsAtStartOfDrag.track.data.keyframes, + )[propsAtStartOfDrag.index + 1].id, ], translate: delta, scale: 1, diff --git a/theatre/studio/src/panels/SequenceEditorPanel/DopeSheet/Right/BasicKeyframedTrack/KeyframeEditor/SingleKeyframeDot.tsx b/theatre/studio/src/panels/SequenceEditorPanel/DopeSheet/Right/BasicKeyframedTrack/KeyframeEditor/SingleKeyframeDot.tsx index 5c274c64dc..30c2514f86 100644 --- a/theatre/studio/src/panels/SequenceEditorPanel/DopeSheet/Right/BasicKeyframedTrack/KeyframeEditor/SingleKeyframeDot.tsx +++ b/theatre/studio/src/panels/SequenceEditorPanel/DopeSheet/Right/BasicKeyframedTrack/KeyframeEditor/SingleKeyframeDot.tsx @@ -27,6 +27,7 @@ import {useKeyframeInlineEditorPopover} from './useSingleKeyframeInlineEditorPop import usePresence, { PresenceFlag, } from '@theatre/studio/uiComponents/usePresence' +import {keyframeUtils} from '@theatre/sync-server/state/schema' export const DOT_SIZE_PX = 6 const DOT_HOVER_SIZE_PX = DOT_SIZE_PX + 2 @@ -284,8 +285,9 @@ function useDragForSingleKeyframeDot( .getDragHandlers({ ...sheetObject.address, domNode: node!, - positionAtStartOfDrag: - props.track.data.keyframes[props.index].position, + positionAtStartOfDrag: keyframeUtils.getSortedKeyframesCached( + props.track.data.keyframes, + )[props.index].position, }) .onDragStart(event) @@ -314,8 +316,9 @@ function useDragForSingleKeyframeDot( return { onDrag(dx, dy, event) { - const original = - propsAtStartOfDrag.track.data.keyframes[propsAtStartOfDrag.index] + const original = keyframeUtils.getSortedKeyframesCached( + propsAtStartOfDrag.track.data.keyframes, + )[propsAtStartOfDrag.index] const newPosition = Math.max( // check if our event hoversover a [data-pos] element DopeSnap.checkIfMouseEventSnapToPos(event, { diff --git a/theatre/studio/src/panels/SequenceEditorPanel/DopeSheet/Right/BasicKeyframedTrack/KeyframeEditor/SingleKeyframeEditor.tsx b/theatre/studio/src/panels/SequenceEditorPanel/DopeSheet/Right/BasicKeyframedTrack/KeyframeEditor/SingleKeyframeEditor.tsx index 4504e9e846..6718aecd01 100644 --- a/theatre/studio/src/panels/SequenceEditorPanel/DopeSheet/Right/BasicKeyframedTrack/KeyframeEditor/SingleKeyframeEditor.tsx +++ b/theatre/studio/src/panels/SequenceEditorPanel/DopeSheet/Right/BasicKeyframedTrack/KeyframeEditor/SingleKeyframeEditor.tsx @@ -12,6 +12,7 @@ import SingleKeyframeConnector from './BasicKeyframeConnector' import SingleKeyframeDot from './SingleKeyframeDot' import type {TrackWithId} from '@theatre/studio/panels/SequenceEditorPanel/DopeSheet/Right/collectAggregateKeyframes' import type {StudioSheetItemKey} from '@theatre/sync-server/state/types' +import {keyframeUtils} from '@theatre/sync-server/state/schema' const SingleKeyframeEditorContainer = styled.div` position: absolute; @@ -35,8 +36,12 @@ const SingleKeyframeEditor: React.VFC = React.memo( index, track: {data: trackData}, } = props - const cur = trackData.keyframes[index] - const next = trackData.keyframes[index + 1] + const cur = keyframeUtils.getSortedKeyframesCached(trackData.keyframes)[ + index + ] + const next = keyframeUtils.getSortedKeyframesCached(trackData.keyframes)[ + index + 1 + ] const connected = cur.connectedRight && !!next diff --git a/theatre/studio/src/panels/SequenceEditorPanel/DopeSheet/Right/DopeSheetSelectionView.tsx b/theatre/studio/src/panels/SequenceEditorPanel/DopeSheet/Right/DopeSheetSelectionView.tsx index cd15bf0aa6..a1eb7634ab 100644 --- a/theatre/studio/src/panels/SequenceEditorPanel/DopeSheet/Right/DopeSheetSelectionView.tsx +++ b/theatre/studio/src/panels/SequenceEditorPanel/DopeSheet/Right/DopeSheetSelectionView.tsx @@ -25,6 +25,7 @@ import DopeSnap from '@theatre/studio/panels/SequenceEditorPanel/RightOverlay/Do import {collectAggregateKeyframesInPrism} from './collectAggregateKeyframes' import type {ILogger, IUtilLogger} from '@theatre/shared/logger' import {useLogger} from '@theatre/studio/uiComponents/useLogger' +import {keyframeUtils} from '@theatre/sync-server/state/schema' const HITBOX_SIZE_PX = 5 @@ -253,7 +254,9 @@ namespace utils { return } - for (const kf of trackData.keyframes) { + for (const kf of keyframeUtils.getSortedKeyframesCached( + trackData.keyframes, + )) { if ( kf.position + layout.scaledSpace.toUnitSpace(HITBOX_SIZE_PX) <= bounds.h[0] diff --git a/theatre/studio/src/panels/SequenceEditorPanel/DopeSheet/Right/KeyframeSnapTarget.tsx b/theatre/studio/src/panels/SequenceEditorPanel/DopeSheet/Right/KeyframeSnapTarget.tsx index 7400a82f59..25f1288643 100644 --- a/theatre/studio/src/panels/SequenceEditorPanel/DopeSheet/Right/KeyframeSnapTarget.tsx +++ b/theatre/studio/src/panels/SequenceEditorPanel/DopeSheet/Right/KeyframeSnapTarget.tsx @@ -14,6 +14,7 @@ import type { HistoricPositionalSequence, Keyframe, } from '@theatre/sync-server/state/types/core' +import {keyframeUtils} from '@theatre/sync-server/state/schema' const HitZone = styled.div` z-index: 1; @@ -115,7 +116,8 @@ export function collectKeyframeSnapPositions( Object.entries(trackDataAndTrackIdByPropPath!.trackData).map( ([trackId, track]) => [ trackId, - track!.keyframes + keyframeUtils + .getSortedKeyframesCached(track!.keyframes) .filter((kf) => shouldIncludeKeyframe(kf, { trackId, diff --git a/theatre/studio/src/panels/SequenceEditorPanel/DopeSheet/Right/collectAggregateKeyframes.tsx b/theatre/studio/src/panels/SequenceEditorPanel/DopeSheet/Right/collectAggregateKeyframes.tsx index e9c193ae52..6a1bbce0b7 100644 --- a/theatre/studio/src/panels/SequenceEditorPanel/DopeSheet/Right/collectAggregateKeyframes.tsx +++ b/theatre/studio/src/panels/SequenceEditorPanel/DopeSheet/Right/collectAggregateKeyframes.tsx @@ -13,6 +13,7 @@ import {uniq} from 'lodash-es' import type SheetObject from '@theatre/core/sheetObjects/SheetObject' import type {StudioSheetItemKey} from '@theatre/sync-server/state/types' import {createStudioSheetItemKey} from '@theatre/shared/utils/ids' +import {keyframeUtils} from '@theatre/sync-server/state/schema' /** * An index over a series of keyframes that have been collected from different tracks. @@ -77,7 +78,10 @@ function keyframesByPositionFromTrackWithIds(tracks: TrackWithId[]) { const byPosition = new Map() for (const track of tracks) { - for (const kf of track.data.keyframes) { + const keyframes = keyframeUtils.getSortedKeyframesCached( + track.data.keyframes, + ) + for (const kf of keyframes) { let existing = byPosition.get(kf.position) if (!existing) { existing = [] diff --git a/theatre/studio/src/panels/SequenceEditorPanel/DopeSheet/selections.ts b/theatre/studio/src/panels/SequenceEditorPanel/DopeSheet/selections.ts index a28214b219..437ede853c 100644 --- a/theatre/studio/src/panels/SequenceEditorPanel/DopeSheet/selections.ts +++ b/theatre/studio/src/panels/SequenceEditorPanel/DopeSheet/selections.ts @@ -16,6 +16,7 @@ import { } from '@theatre/utils/pathToProp' import type {StrictRecord} from '@theatre/utils/types' import type {KeyframeWithPathToPropFromCommonRoot} from '@theatre/sync-server/state/types' +import {keyframeUtils} from '@theatre/sync-server/state/schema' /** * Keyframe connections are considered to be selected if the first @@ -67,7 +68,9 @@ export function selectedKeyframeConnections( if (track) { ckfs = ckfs.concat( - keyframeConnections(track.keyframes) + keyframeConnections( + keyframeUtils.getSortedKeyframesCached(track.keyframes), + ) .filter((kfc) => isKeyframeConnectionInSelection(kfc, selection)) .map(({left, right}) => ({ left, @@ -176,7 +179,9 @@ export function keyframesWithPaths({ return keyframeIds .map((keyframeId) => ({ - keyframe: track.keyframes.find((keyframe) => keyframe.id === keyframeId), + keyframe: keyframeUtils + .getSortedKeyframesCached(track.keyframes) + .find((keyframe) => keyframe.id === keyframeId), pathToProp, })) .filter( diff --git a/theatre/studio/src/panels/SequenceEditorPanel/GraphEditor/BasicKeyframedTrack/BasicKeyframedTrack.tsx b/theatre/studio/src/panels/SequenceEditorPanel/GraphEditor/BasicKeyframedTrack/BasicKeyframedTrack.tsx index d858982530..536ae61454 100644 --- a/theatre/studio/src/panels/SequenceEditorPanel/GraphEditor/BasicKeyframedTrack/BasicKeyframedTrack.tsx +++ b/theatre/studio/src/panels/SequenceEditorPanel/GraphEditor/BasicKeyframedTrack/BasicKeyframedTrack.tsx @@ -16,6 +16,7 @@ import { import type {PropTypeConfig_AllSimples} from '@theatre/core/propTypes' import {useVal} from '@theatre/react' import type {GraphEditorColors} from '@theatre/sync-server/state/types' +import {keyframeUtils} from '@theatre/sync-server/state/schema' export type ExtremumSpace = { fromValueSpace: (v: number) => number @@ -62,10 +63,13 @@ const BasicKeyframedTrack: React.VFC<{ }, []) const extremumSpace: ExtremumSpace = useMemo(() => { + const sortedKeyframes = keyframeUtils.getSortedKeyframesCached( + trackData.keyframes, + ) const extremums = propConfig.type === 'number' - ? calculateScalarExtremums(trackData.keyframes, propConfig) - : calculateNonScalarExtremums(trackData.keyframes) + ? calculateScalarExtremums(sortedKeyframes, propConfig) + : calculateNonScalarExtremums(sortedKeyframes) const fromValueSpace = (val: number): number => (val - extremums[0]) / (extremums[1] - extremums[0]) @@ -91,7 +95,11 @@ const BasicKeyframedTrack: React.VFC<{ cachedExtremumSpace.current = extremumSpace } - const keyframeEditors = trackData.keyframes.map((kf, index) => ( + const sortedKeyframes = keyframeUtils.getSortedKeyframesCached( + trackData.keyframes, + ) + + const keyframeEditors = sortedKeyframes.map((kf, index) => ( = (props) => { const {index, trackData} = props - const cur = trackData.keyframes[index] - const next = trackData.keyframes[index + 1] + const cur = keyframeUtils.getSortedKeyframesCached(trackData.keyframes)[index] + const next = keyframeUtils.getSortedKeyframesCached(trackData.keyframes)[ + index + 1 + ] const connectorLengthInUnitSpace = next.position - cur.position @@ -108,8 +111,10 @@ export default Curve function useConnectorContextMenu(node: SVGElement | null, props: IProps) { const {index, trackData} = props - const cur = trackData.keyframes[index] - const next = trackData.keyframes[index + 1] + const cur = keyframeUtils.getSortedKeyframesCached(trackData.keyframes)[index] + const next = keyframeUtils.getSortedKeyframesCached(trackData.keyframes)[ + index + 1 + ] return useContextMenu(node, { menuItems: () => { diff --git a/theatre/studio/src/panels/SequenceEditorPanel/GraphEditor/BasicKeyframedTrack/KeyframeEditor/CurveHandle.tsx b/theatre/studio/src/panels/SequenceEditorPanel/GraphEditor/BasicKeyframedTrack/KeyframeEditor/CurveHandle.tsx index ca009f9645..5555387930 100644 --- a/theatre/studio/src/panels/SequenceEditorPanel/GraphEditor/BasicKeyframedTrack/KeyframeEditor/CurveHandle.tsx +++ b/theatre/studio/src/panels/SequenceEditorPanel/GraphEditor/BasicKeyframedTrack/KeyframeEditor/CurveHandle.tsx @@ -10,6 +10,7 @@ import styled from 'styled-components' import {transformBox} from './Curve' import type KeyframeEditor from './KeyframeEditor' import {pointerEventsAutoInNormalMode} from '@theatre/studio/css' +import {keyframeUtils} from '@theatre/sync-server/state/schema' export const dotSize = 6 @@ -51,8 +52,10 @@ const CurveHandle: React.VFC = (props) => { const [ref, node] = useRefAndState(null) const {index, trackData} = props - const cur = trackData.keyframes[index] - const next = trackData.keyframes[index + 1] + const cur = keyframeUtils.getSortedKeyframesCached(trackData.keyframes)[index] + const next = keyframeUtils.getSortedKeyframesCached(trackData.keyframes)[ + index + 1 + ] const [contextMenu] = useOurContextMenu(node, props) useOurDrags(node, props) @@ -148,8 +151,11 @@ function useOurDrags(node: SVGCircleElement | null, props: IProps): void { } const {index, trackData} = propsAtStartOfDrag - const cur = trackData.keyframes[index] - const next = trackData.keyframes[index + 1] + const sortedKeyframes = keyframeUtils.getSortedKeyframesCached( + trackData.keyframes, + ) + const cur = sortedKeyframes[index] + const next = sortedKeyframes[index + 1] const dPosInUnitSpace = scaledToUnitSpace(dxInScaledSpace) let dPosInKeyframeDiffSpace = diff --git a/theatre/studio/src/panels/SequenceEditorPanel/GraphEditor/BasicKeyframedTrack/KeyframeEditor/GraphEditorDotNonScalar.tsx b/theatre/studio/src/panels/SequenceEditorPanel/GraphEditor/BasicKeyframedTrack/KeyframeEditor/GraphEditorDotNonScalar.tsx index 75de89cb73..7f1303ed2c 100644 --- a/theatre/studio/src/panels/SequenceEditorPanel/GraphEditor/BasicKeyframedTrack/KeyframeEditor/GraphEditorDotNonScalar.tsx +++ b/theatre/studio/src/panels/SequenceEditorPanel/GraphEditor/BasicKeyframedTrack/KeyframeEditor/GraphEditorDotNonScalar.tsx @@ -20,6 +20,7 @@ import {useKeyframeInlineEditorPopover} from '@theatre/studio/panels/SequenceEdi import usePresence, { PresenceFlag, } from '@theatre/studio/uiComponents/usePresence' +import {keyframeUtils} from '@theatre/sync-server/state/schema' export const dotSize = 6 @@ -62,7 +63,10 @@ const GraphEditorDotNonScalar: React.VFC = (props) => { const [ref, node] = useRefAndState(null) const {index, trackData, itemKey} = props - const cur = trackData.keyframes[index] + const sortedKeyframes = keyframeUtils.getSortedKeyframesCached( + trackData.keyframes, + ) + const cur = sortedKeyframes[index] const [contextMenu] = useKeyframeContextMenu(node, props) @@ -151,8 +155,10 @@ function useDragKeyframe(options: { return { onDrag(dx, dy) { - const original = - propsAtStartOfDrag.trackData.keyframes[propsAtStartOfDrag.index] + const sortedKeyframes = keyframeUtils.getSortedKeyframesCached( + propsAtStartOfDrag.trackData.keyframes, + ) + const original = sortedKeyframes[propsAtStartOfDrag.index] const deltaPos = toUnitSpace(dx) diff --git a/theatre/studio/src/panels/SequenceEditorPanel/GraphEditor/BasicKeyframedTrack/KeyframeEditor/GraphEditorDotScalar.tsx b/theatre/studio/src/panels/SequenceEditorPanel/GraphEditor/BasicKeyframedTrack/KeyframeEditor/GraphEditorDotScalar.tsx index ca1d08669e..02237ff41c 100644 --- a/theatre/studio/src/panels/SequenceEditorPanel/GraphEditor/BasicKeyframedTrack/KeyframeEditor/GraphEditorDotScalar.tsx +++ b/theatre/studio/src/panels/SequenceEditorPanel/GraphEditor/BasicKeyframedTrack/KeyframeEditor/GraphEditorDotScalar.tsx @@ -20,6 +20,7 @@ import {useKeyframeInlineEditorPopover} from '@theatre/studio/panels/SequenceEdi import usePresence, { PresenceFlag, } from '@theatre/studio/uiComponents/usePresence' +import {keyframeUtils} from '@theatre/sync-server/state/schema' export const dotSize = 6 @@ -62,7 +63,10 @@ const GraphEditorDotScalar: React.VFC = (props) => { const [ref, node] = useRefAndState(null) const {index, trackData} = props - const cur = trackData.keyframes[index] + const sortedKeyframes = keyframeUtils.getSortedKeyframesCached( + trackData.keyframes, + ) + const cur = sortedKeyframes[index] const [contextMenu] = useKeyframeContextMenu(node, props) const presence = usePresence(props.itemKey) @@ -153,8 +157,10 @@ function useDragKeyframe(options: { return { onDrag(dx, dy) { - const original = - propsAtStartOfDrag.trackData.keyframes[propsAtStartOfDrag.index] + const sortedKeyframes = keyframeUtils.getSortedKeyframesCached( + propsAtStartOfDrag.trackData.keyframes, + ) + const original = sortedKeyframes[propsAtStartOfDrag.index] const deltaPos = toUnitSpace(dx) const dyInVerticalSpace = -dy @@ -177,10 +183,10 @@ function useDragKeyframe(options: { updatedKeyframes.push(cur) if (keepSpeeds) { - const prev = - propsAtStartOfDrag.trackData.keyframes[ - propsAtStartOfDrag.index - 1 - ] + const sortedKeyframes = keyframeUtils.getSortedKeyframesCached( + propsAtStartOfDrag.trackData.keyframes, + ) + const prev = sortedKeyframes[propsAtStartOfDrag.index - 1] if ( prev && @@ -200,10 +206,8 @@ function useDragKeyframe(options: { cur.value as number, ) } - const next = - propsAtStartOfDrag.trackData.keyframes[ - propsAtStartOfDrag.index + 1 - ] + + const next = sortedKeyframes[propsAtStartOfDrag.index + 1] if ( next && diff --git a/theatre/studio/src/panels/SequenceEditorPanel/GraphEditor/BasicKeyframedTrack/KeyframeEditor/GraphEditorNonScalarDash.tsx b/theatre/studio/src/panels/SequenceEditorPanel/GraphEditor/BasicKeyframedTrack/KeyframeEditor/GraphEditorNonScalarDash.tsx index f4baf0b895..bdc92a36fb 100644 --- a/theatre/studio/src/panels/SequenceEditorPanel/GraphEditor/BasicKeyframedTrack/KeyframeEditor/GraphEditorNonScalarDash.tsx +++ b/theatre/studio/src/panels/SequenceEditorPanel/GraphEditor/BasicKeyframedTrack/KeyframeEditor/GraphEditorNonScalarDash.tsx @@ -3,6 +3,7 @@ import styled from 'styled-components' import type KeyframeEditor from './KeyframeEditor' import type {Keyframe} from '@theatre/sync-server/state/types/core' import {transformBox} from './Curve' +import {keyframeUtils} from '@theatre/sync-server/state/schema' export const dotSize = 6 @@ -22,8 +23,12 @@ const GraphEditorNonScalarDash: React.VFC = (props) => { const pathD = `M 0 0 L 1 1` + const sortedKeyframes = keyframeUtils.getSortedKeyframesCached( + trackData.keyframes, + ) + const transform = transformBox( - trackData.keyframes[index].position, + sortedKeyframes[index].position, props.extremumSpace.fromValueSpace(0), 0, props.extremumSpace.fromValueSpace(1) - diff --git a/theatre/studio/src/panels/SequenceEditorPanel/GraphEditor/BasicKeyframedTrack/KeyframeEditor/KeyframeEditor.tsx b/theatre/studio/src/panels/SequenceEditorPanel/GraphEditor/BasicKeyframedTrack/KeyframeEditor/KeyframeEditor.tsx index cc61c062b2..c4821ae181 100644 --- a/theatre/studio/src/panels/SequenceEditorPanel/GraphEditor/BasicKeyframedTrack/KeyframeEditor/KeyframeEditor.tsx +++ b/theatre/studio/src/panels/SequenceEditorPanel/GraphEditor/BasicKeyframedTrack/KeyframeEditor/KeyframeEditor.tsx @@ -17,6 +17,7 @@ import type { GraphEditorColors, StudioSheetItemKey, } from '@theatre/sync-server/state/types' +import {keyframeUtils} from '@theatre/sync-server/state/schema' const Container = styled.g` /* position: absolute; */ @@ -41,8 +42,11 @@ type IKeyframeEditorProps = { const KeyframeEditor: React.VFC = (props) => { const {index, trackData, isScalar} = props - const cur = trackData.keyframes[index] - const next = trackData.keyframes[index + 1] + const sortedKeyframes = keyframeUtils.getSortedKeyframesCached( + trackData.keyframes, + ) + const cur = sortedKeyframes[index] + const next = sortedKeyframes[index + 1] const connected = cur.connectedRight && !!next const shouldShowCurve = connected && next.value !== cur.value diff --git a/theatre/studio/src/propEditors/getNearbyKeyframesOfTrack.tsx b/theatre/studio/src/propEditors/getNearbyKeyframesOfTrack.tsx index bfcb3a87e3..8eff646e33 100644 --- a/theatre/studio/src/propEditors/getNearbyKeyframesOfTrack.tsx +++ b/theatre/studio/src/propEditors/getNearbyKeyframesOfTrack.tsx @@ -5,6 +5,7 @@ import type { KeyframeWithTrack, TrackWithId, } from '@theatre/studio/panels/SequenceEditorPanel/DopeSheet/Right/collectAggregateKeyframes' +import {keyframeUtils} from '@theatre/sync-server/state/schema' const cache = new WeakMap< TrackData, @@ -18,16 +19,22 @@ export function getNearbyKeyframesOfTrack( track: TrackWithId | undefined, sequencePosition: number, ): NearbyKeyframes { - if (!track || track.data.keyframes.length === 0) return noKeyframes + if ( + !track || + keyframeUtils.getSortedKeyframesCached(track.data.keyframes).length === 0 + ) + return noKeyframes const cachedItem = cache.get(track.data) if (cachedItem && cachedItem[0] === sequencePosition) { return cachedItem[1] } + const sorted = keyframeUtils.getSortedKeyframesCached(track.data.keyframes) + function getKeyframeWithTrackId(idx: number): KeyframeWithTrack | undefined { if (!track) return - const found = track.data.keyframes[idx] + const found = sorted[idx] return ( found && { kf: found, @@ -42,13 +49,13 @@ export function getNearbyKeyframesOfTrack( } const calculate = (): NearbyKeyframes => { - const nextOrCurIdx = track.data.keyframes.findIndex( + const nextOrCurIdx = sorted.findIndex( (kf) => kf.position >= sequencePosition, ) if (nextOrCurIdx === -1) { return { - prev: getKeyframeWithTrackId(track.data.keyframes.length - 1), + prev: getKeyframeWithTrackId(sorted.length - 1), } } diff --git a/theatre/studio/src/utils/assets.ts b/theatre/studio/src/utils/assets.ts index eb9ae0e662..679175a5dd 100644 --- a/theatre/studio/src/utils/assets.ts +++ b/theatre/studio/src/utils/assets.ts @@ -3,6 +3,7 @@ import type Project from '@theatre/core/projects/Project' import {val} from '@theatre/dataverse' import forEachPropDeep from '@theatre/shared/utils/forEachDeep' import type {$IntentionalAny} from '@theatre/utils/types' +import {keyframeUtils} from '@theatre/sync-server/state/schema' export function getAllPossibleAssetIDs(project: Project, type?: string) { const sheets = Object.values(val(project.pointers.historic.sheetsById) ?? {}) @@ -13,7 +14,11 @@ export function getAllPossibleAssetIDs(project: Project, type?: string) { const keyframeValues = sheets .flatMap((sheet) => Object.values(sheet?.sequence?.tracksByObject ?? {})) .flatMap((tracks) => Object.values(tracks?.trackData ?? {})) - .flatMap((track) => track?.keyframes) + .flatMap((track) => + keyframeUtils.getSortedKeyframesCached( + track?.keyframes ?? {byId: {}, allIds: {}}, + ), + ) .map((keyframe) => keyframe?.value) const allValues = [...keyframeValues]