diff --git a/packages/excalidraw/transaction.ts b/packages/excalidraw/transaction.ts deleted file mode 100644 index 80866a3590..0000000000 --- a/packages/excalidraw/transaction.ts +++ /dev/null @@ -1,1346 +0,0 @@ -import { randomId } from "@excalidraw/common"; -import { - CaptureUpdateAction, - Delta, - deepCopyElement, - ElementsDelta, - mergeStoreDeltaSemantics, - newElementWith, - type ElementUpdate, - type StoreDelta, - type TxUndoOverride, -} from "@excalidraw/element"; - -import type { Mutable } from "@excalidraw/common/utility-types"; -import type { - ExcalidrawElement, - ExcalidrawNonSelectionElement, - OrderedExcalidrawElement, - SceneElementsMap, -} from "@excalidraw/element/types"; - -import { - HistoryDelta, - type HistoryBeforeRecordListener, - type HistoryEffectiveDeltaResolverContext, -} from "./history"; - -import type { - AppClassProperties, - AppState, - ObservedAppState, - SceneData, -} from "./types"; - -/** Per-element ledger record captured during a transaction session. */ -export type TransactionLedgerEntry = { - baselineElement: ExcalidrawElement | null; - targetElement: ExcalidrawElement | null; - touchedProps: Set; -}; - -// --------------------------------------------------------------------------- -// Ledger helpers -// --------------------------------------------------------------------------- - -const LEDGER_IGNORED_PROPS = new Set([ - "version", - "versionNonce", - "seed", - "updated", - "index", -]); - -type ElementRecord = Record; -type ElementUpdatedEntry = Delta>; -type ElementUpdatedEntryMap = Record; -type TransactionMap = Map; -type TransactionLifecyclePhase = "active" | "committed" | "canceled"; -type TransactionLifecycleRecord = { - phase: TransactionLifecyclePhase; - startedSeq: number; - endedSeq?: number; -}; -type TransactionLifecycleMap = Map; -type TransactionHistoryBridge = { - onBeforeRecord: (callback: HistoryBeforeRecordListener) => () => void; - setEffectiveDeltaResolver: ( - resolver: - | (( - delta: HistoryDelta, - context: HistoryEffectiveDeltaResolverContext, - ) => HistoryDelta) - | null, - ) => void; -}; - -const isPlainObject = (value: unknown): value is Record => - !!value && typeof value === "object" && !Array.isArray(value); - -const getElementProp = (element: ExcalidrawElement, prop: string): unknown => - (element as ElementRecord)[prop]; - -const setOrderedElementProp = ( - element: Mutable, - prop: string, - value: unknown, -) => { - (element as ElementRecord)[prop] = value; -}; - -/** Deep equality used by ledger conflict/touched-prop detection. */ -const isLedgerValueEqual = (left: unknown, right: unknown): boolean => { - if (Object.is(left, right)) { - return true; - } - - if (Array.isArray(left) && Array.isArray(right)) { - if (left.length !== right.length) { - return false; - } - for (let index = 0; index < left.length; index += 1) { - if (!isLedgerValueEqual(left[index], right[index])) { - return false; - } - } - return true; - } - - if (isPlainObject(left) && isPlainObject(right)) { - const leftKeys = Object.keys(left); - const rightKeys = Object.keys(right); - if (leftKeys.length !== rightKeys.length) { - return false; - } - for (const key of leftKeys) { - if (!Object.prototype.hasOwnProperty.call(right, key)) { - return false; - } - if (!isLedgerValueEqual(left[key], right[key])) { - return false; - } - } - return true; - } - - return false; -}; - -/** Shallow-copies a scene map. Entries share references with the original. */ -const shallowCopySceneMap = ( - elements: ReadonlyMap, -): SceneElementsMap => new Map(elements) as SceneElementsMap; - -/** Returns changed property names between two element snapshots. */ -const collectTouchedProps = ( - before: ExcalidrawElement | null, - after: ExcalidrawElement | null, -) => { - if (!before || !after) { - return new Set(["*"]); - } - - const touchedProps = new Set(); - const keys = new Set([...Object.keys(before), ...Object.keys(after)]); - - for (const key of keys) { - if (LEDGER_IGNORED_PROPS.has(key)) { - continue; - } - if ( - !isLedgerValueEqual( - getElementProp(before, key), - getElementProp(after, key), - ) - ) { - touchedProps.add(key); - } - } - - return touchedProps; -}; - -/** Returns ids whose element snapshot changed between two points in time. */ -export const collectChangedElementIds = ( - before: ReadonlyMap, - after: ReadonlyMap, -) => { - const changedIds = new Set(); - const candidateIds = new Set([...before.keys(), ...after.keys()]); - - for (const id of candidateIds) { - const beforeElement = before.get(id) ?? null; - const afterElement = after.get(id) ?? null; - if (collectTouchedProps(beforeElement, afterElement).size > 0) { - changedIds.add(id); - } - } - - return [...changedIds]; -}; - -const serializeConsumedPropKey = (elementId: string, prop: string) => - `${elementId}\u0000${prop}`; - -const TX_UNDO_OVERRIDE_IGNORED_PROPS = new Set([ - "version", - "versionNonce", - "isDeleted", -]); - -const getUpdatedElementEntries = (delta: StoreDelta) => - delta.elements.updated as ElementUpdatedEntryMap; -const hasUpdatedElementEntries = (delta: StoreDelta) => - Object.keys(getUpdatedElementEntries(delta)).length > 0; - -const serializeIntermediateValue = (value: unknown): string => { - const serialize = (input: unknown, seen: WeakSet): string => { - if (input === null) { - return "null"; - } - - switch (typeof input) { - case "undefined": - return "undefined"; - case "boolean": - return input ? "boolean:true" : "boolean:false"; - case "number": - if (Number.isNaN(input)) { - return "number:NaN"; - } - if (Object.is(input, -0)) { - return "number:-0"; - } - return `number:${input}`; - case "bigint": - return `bigint:${input.toString()}`; - case "string": - return `string:${JSON.stringify(input)}`; - case "symbol": - return `symbol:${String(input)}`; - case "function": - return `function:${input.name}`; - case "object": - break; - default: - return `unknown:${String(input)}`; - } - - if (Array.isArray(input)) { - if (seen.has(input)) { - return "[CircularArray]"; - } - seen.add(input); - const serialized = `[${input - .map((item) => serialize(item, seen)) - .join(",")}]`; - seen.delete(input); - return serialized; - } - - if (isPlainObject(input)) { - if (seen.has(input)) { - return "{CircularObject}"; - } - seen.add(input); - const serialized = `{${Object.keys(input) - .sort() - .map((key) => `${JSON.stringify(key)}:${serialize(input[key], seen)}`) - .join(",")}}`; - seen.delete(input); - return serialized; - } - - try { - return `object:${JSON.stringify(input)}`; - } catch { - return `object:${Object.prototype.toString.call(input)}`; - } - }; - - return serialize(value, new WeakSet()); -}; - -type TxUndoOverrideCandidate = Omit; -type TxIntermediatePropValues = { - values: unknown[]; - signatures: Set; - latestValue: unknown; - hasLatestValue: boolean; -}; - -/** - * Tracks tx intermediate values and computes undo baseline override markers - * for durable user deltas recorded while tx is active. - */ -class TxUndoOverridePlanner { - private readonly intermediateValuesByElementProp = new Map< - string, - Map - >(); - private readonly consumedOverridePropKeys = new Set(); - - clear() { - this.consumedOverridePropKeys.clear(); - this.intermediateValuesByElementProp.clear(); - } - - recordStep( - before: ReadonlyMap, - after: ReadonlyMap, - ) { - for (const elementId of collectChangedElementIds(before, after)) { - const beforeElement = before.get(elementId) ?? null; - const afterElement = after.get(elementId) ?? null; - const touchedProps = collectTouchedProps(beforeElement, afterElement); - - if (!afterElement || touchedProps.has("*")) { - continue; - } - - for (const prop of touchedProps) { - this.recordIntermediateValue( - elementId, - prop, - getElementProp(afterElement, prop), - ); - } - } - } - - collectCandidatesForDurableDelta( - delta: StoreDelta, - getLedgerEntry: (elementId: string) => TransactionLedgerEntry | undefined, - reservedConsumedKeys: Set, - ): TxUndoOverrideCandidate[] { - const candidates: TxUndoOverrideCandidate[] = []; - - for (const [elementId, deltaEntry] of Object.entries( - getUpdatedElementEntries(delta), - )) { - const ledgerEntry = getLedgerEntry(elementId); - if (!ledgerEntry) { - continue; - } - - const elementCandidates = this.collectCandidatesForElement( - elementId, - deltaEntry, - ledgerEntry, - reservedConsumedKeys, - ); - if (elementCandidates.length > 0) { - candidates.push(...elementCandidates); - } - } - - return candidates; - } - - private collectCandidatesForElement( - elementId: string, - deltaEntry: ElementUpdatedEntry, - ledgerEntry: TransactionLedgerEntry, - reservedConsumedKeys: Set, - ): TxUndoOverrideCandidate[] { - const { baselineElement, touchedProps } = ledgerEntry; - if (!baselineElement || touchedProps.has("*")) { - return []; - } - - const candidates: TxUndoOverrideCandidate[] = []; - for (const [prop, deletedValue] of Object.entries(deltaEntry.deleted)) { - const candidate = this.createCandidateForProp({ - elementId, - prop, - deletedValue, - baselineElement, - touchedProps, - reservedConsumedKeys, - }); - if (candidate) { - candidates.push(candidate); - } - } - - return candidates; - } - - private createCandidateForProp(args: { - elementId: string; - prop: string; - deletedValue: unknown; - baselineElement: ExcalidrawElement; - touchedProps: Set; - reservedConsumedKeys: Set; - }): TxUndoOverrideCandidate | null { - const { elementId, prop, deletedValue, baselineElement, touchedProps } = - args; - - if (TX_UNDO_OVERRIDE_IGNORED_PROPS.has(prop) || !touchedProps.has(prop)) { - return null; - } - - const consumedPropKey = serializeConsumedPropKey(elementId, prop); - // Override only the first polluted user entry for this element+prop. - // Later user actions should keep action-local undo baselines. - if ( - this.consumedOverridePropKeys.has(consumedPropKey) || - args.reservedConsumedKeys.has(consumedPropKey) - ) { - return null; - } - - if (!this.matchesIntermediateValue(elementId, prop, deletedValue)) { - return null; - } - - return { - elementId, - prop, - expectedInsertedValue: deletedValue, - preTxBaselineValue: getElementProp(baselineElement, prop), - consumedKey: consumedPropKey, - }; - } - - markConsumed(consumedPropKey: string) { - this.consumedOverridePropKeys.add(consumedPropKey); - } - - private getOrCreatePropValues(elementId: string) { - const existing = this.intermediateValuesByElementProp.get(elementId); - if (existing) { - return existing; - } - - const created = new Map(); - this.intermediateValuesByElementProp.set(elementId, created); - return created; - } - - private recordIntermediateValue( - elementId: string, - prop: string, - value: unknown, - ) { - const propValues = this.getOrCreatePropValues(elementId); - const prevValues = propValues.get(prop); - if (prevValues) { - if ( - prevValues.hasLatestValue && - isLedgerValueEqual(prevValues.latestValue, value) - ) { - return; - } - - prevValues.values.push(value); - prevValues.signatures.add(serializeIntermediateValue(value)); - prevValues.latestValue = value; - prevValues.hasLatestValue = true; - return; - } - - propValues.set(prop, { - values: [value], - signatures: new Set([serializeIntermediateValue(value)]), - latestValue: value, - hasLatestValue: true, - }); - } - - private matchesIntermediateValue( - elementId: string, - prop: string, - candidate: unknown, - ) { - const propValues = this.intermediateValuesByElementProp - .get(elementId) - ?.get(prop); - if (!propValues) { - return false; - } - - const candidateSignature = serializeIntermediateValue(candidate); - if (!propValues.signatures.has(candidateSignature)) { - return false; - } - - if ( - propValues.hasLatestValue && - isLedgerValueEqual(propValues.latestValue, candidate) - ) { - return true; - } - - return propValues.values.some((value) => - isLedgerValueEqual(value, candidate), - ); - } -} - -// --------------------------------------------------------------------------- -// TransactionLedger -// --------------------------------------------------------------------------- - -/** - * Keeps transaction-level scene mutations and materializes synthetic snapshots - * for a single durable history commit. - */ -export class TransactionLedger { - private readonly entries = new Map(); - - /** Whether the transaction has any net element mutations. */ - hasEntries() { - return this.entries.size > 0; - } - - /** Returns the ledger entry for an element, if any. */ - getEntry(elementId: string): TransactionLedgerEntry | undefined { - return this.entries.get(elementId); - } - - /** Releases all ledger entries. */ - clear() { - this.entries.clear(); - } - - /** Records one element mutation step into the ledger. */ - recordStep( - before: ReadonlyMap, - after: ReadonlyMap, - ) { - for (const elementId of collectChangedElementIds(before, after)) { - const beforeElement = before.get(elementId) ?? null; - const afterElement = after.get(elementId) ?? null; - const touchedProps = collectTouchedProps(beforeElement, afterElement); - - if (touchedProps.size === 0) { - continue; - } - - const existing = this.entries.get(elementId); - if (!existing) { - this.entries.set(elementId, { - baselineElement: beforeElement - ? deepCopyElement(beforeElement) - : null, - targetElement: afterElement ? deepCopyElement(afterElement) : null, - touchedProps, - }); - continue; - } - - existing.targetElement = afterElement - ? deepCopyElement(afterElement) - : null; - if (existing.touchedProps.has("*") || touchedProps.has("*")) { - existing.touchedProps = new Set(["*"]); - } else { - for (const prop of touchedProps) { - existing.touchedProps.add(prop); - } - } - - // Created then deleted inside one transaction leaves no durable footprint. - if (!existing.baselineElement && !existing.targetElement) { - this.entries.delete(elementId); - continue; - } - if (!existing.baselineElement && existing.targetElement?.isDeleted) { - this.entries.delete(elementId); - } - } - } - - /** - * Builds synthetic element before/after snapshots with a fixed - * "live-wins-per-prop" strategy. - */ - buildSyntheticSnapshots(live: ReadonlyMap) { - // Shallow copy — untouched elements stay as live references. - // Only elements mutated in-place (prop-level updates) are deep-copied below. - const elementsBefore = shallowCopySceneMap(live); - const elementsAfter = shallowCopySceneMap(live); - - for (const [elementId, entry] of this.entries) { - this.reconcileEntrySnapshots( - elementId, - entry, - live, - elementsBefore, - elementsAfter, - ); - } - - return { elementsBefore, elementsAfter }; - } - - private reconcileEntrySnapshots( - elementId: string, - entry: TransactionLedgerEntry, - live: ReadonlyMap, - elementsBefore: SceneElementsMap, - elementsAfter: SceneElementsMap, - ) { - if (!entry.baselineElement) { - this.applyCreatedElementSnapshots( - elementId, - entry.targetElement, - live, - elementsBefore, - elementsAfter, - ); - return; - } - - if (!entry.targetElement) { - this.applyDeletedElementSnapshots( - elementId, - entry.baselineElement, - live, - elementsBefore, - elementsAfter, - ); - return; - } - - this.applyUpdatedElementSnapshots( - elementId, - entry, - live, - elementsBefore, - elementsAfter, - ); - } - - private applyCreatedElementSnapshots( - elementId: string, - targetElement: ExcalidrawElement | null, - live: ReadonlyMap, - elementsBefore: SceneElementsMap, - elementsAfter: SceneElementsMap, - ) { - if (!targetElement) { - return; - } - - const liveElement = live.get(elementId) ?? null; - if ( - !liveElement || - liveElement.isDeleted || - collectTouchedProps(targetElement, liveElement).size > 0 - ) { - return; - } - - elementsBefore.delete(elementId); - elementsAfter.set( - elementId, - deepCopyElement(targetElement) as OrderedExcalidrawElement, - ); - } - - private applyDeletedElementSnapshots( - elementId: string, - baselineElement: ExcalidrawElement, - live: ReadonlyMap, - elementsBefore: SceneElementsMap, - elementsAfter: SceneElementsMap, - ) { - const liveElement = live.get(elementId) ?? null; - if (liveElement && !liveElement.isDeleted) { - return; - } - - elementsBefore.set( - elementId, - deepCopyElement(baselineElement) as OrderedExcalidrawElement, - ); - elementsAfter.delete(elementId); - } - - private applyUpdatedElementSnapshots( - elementId: string, - entry: TransactionLedgerEntry, - live: ReadonlyMap, - elementsBefore: SceneElementsMap, - elementsAfter: SceneElementsMap, - ) { - const liveElement = live.get(elementId) ?? null; - const targetElement = entry.targetElement; - const baselineElement = entry.baselineElement; - const beforeElement = elementsBefore.get(elementId); - const afterElement = elementsAfter.get(elementId); - - if ( - !liveElement || - !baselineElement || - !targetElement || - !beforeElement || - !afterElement - ) { - return; - } - - if (entry.touchedProps.has("*")) { - this.applyWholeElementSnapshots( - elementId, - baselineElement, - targetElement, - liveElement, - elementsBefore, - elementsAfter, - ); - return; - } - - this.applyPerPropSnapshots({ - entry, - liveElement, - baselineElement, - targetElement, - beforeElement, - afterElement, - elementId, - elementsBefore, - elementsAfter, - }); - } - - private applyWholeElementSnapshots( - elementId: string, - baselineElement: ExcalidrawElement, - targetElement: ExcalidrawElement, - liveElement: ExcalidrawElement, - elementsBefore: SceneElementsMap, - elementsAfter: SceneElementsMap, - ) { - const hasLiveConflict = - collectTouchedProps(targetElement, liveElement).size > 0; - if (hasLiveConflict) { - return; - } - - elementsBefore.set( - elementId, - deepCopyElement(baselineElement) as OrderedExcalidrawElement, - ); - elementsAfter.set( - elementId, - deepCopyElement(targetElement) as OrderedExcalidrawElement, - ); - } - - private applyPerPropSnapshots(args: { - entry: TransactionLedgerEntry; - liveElement: ExcalidrawElement; - baselineElement: ExcalidrawElement; - targetElement: ExcalidrawElement; - beforeElement: ExcalidrawElement; - afterElement: ExcalidrawElement; - elementId: string; - elementsBefore: SceneElementsMap; - elementsAfter: SceneElementsMap; - }) { - const { - entry, - liveElement, - baselineElement, - targetElement, - beforeElement, - afterElement, - elementId, - elementsBefore, - elementsAfter, - } = args; - - // Deep-copy before mutating so we never touch live elements. - const mutableBefore = deepCopyElement( - beforeElement, - ) as Mutable; - const mutableAfter = deepCopyElement( - afterElement, - ) as Mutable; - elementsBefore.set(elementId, mutableBefore as OrderedExcalidrawElement); - elementsAfter.set(elementId, mutableAfter as OrderedExcalidrawElement); - - let appliedProps = 0; - for (const prop of entry.touchedProps) { - const liveValue = getElementProp(liveElement, prop); - const targetValue = getElementProp(targetElement, prop); - if (!isLedgerValueEqual(liveValue, targetValue)) { - continue; - } - - setOrderedElementProp( - mutableBefore, - prop, - getElementProp(baselineElement, prop), - ); - setOrderedElementProp(mutableAfter, prop, targetValue); - appliedProps += 1; - } - - if (appliedProps === 0) { - return; - } - - mutableBefore.version = baselineElement.version; - mutableBefore.versionNonce = baselineElement.versionNonce; - mutableAfter.version = targetElement.version; - mutableAfter.versionNonce = targetElement.versionNonce; - } -} - -// --------------------------------------------------------------------------- -// Transaction types -// --------------------------------------------------------------------------- - -/** Lifecycle state of a transaction. */ -export type TransactionStatus = "active" | "committed" | "canceled"; - -/** Per-element partial patch used by tx.updateElements(). */ -type TransactionUpdatableElementType = ExcalidrawNonSelectionElement["type"]; -type TransactionElementOfType = - Extract; - -export type TransactionElementUpdate< - TType extends TransactionUpdatableElementType = TransactionUpdatableElementType, -> = TType extends TransactionUpdatableElementType - ? { - id: ExcalidrawElement["id"]; - type: TType; - updates: ElementUpdate>; - } - : never; - -/** Final summary returned when a transaction is committed or canceled. */ -export type TransactionSummary = { - id: string; - status: TransactionStatus; - historyCommitted: boolean; -}; - -/** Three-way appState context provided to the resolver at commit time. */ -export type AppStateResolverContext = { - /** AppState snapshot captured when the transaction was created. */ - initial: Partial; - /** Merged appState intent from all updateScene calls during the transaction. */ - accumulated: Partial; - /** Current live appState at commit time. */ - live: Partial; -}; - -/** - * Caller-provided resolver that determines which appState changes are - * recorded in the history entry. - * - * Unlike elements — where per-property conflict detection works because - * element properties are largely independent — appState keys are often - * interdependent (e.g. selectedElementIds ↔ selectedGroupIds must stay - * consistent). The correct merge strategy therefore depends on the - * caller's semantic context, not on a generic policy. - * - * Return the appState delta to record in history, or undefined to skip - * appState changes entirely. - */ -export type AppStateResolver = ( - context: AppStateResolverContext, -) => Partial | undefined; - -// --------------------------------------------------------------------------- -// Helpers -// --------------------------------------------------------------------------- - -/** - * Shallow-copies the scene's elements map so that in-place mutations - * (e.g. replaceAllElements clearing the map) don't affect our snapshot. - * - * Element references are shared — this is safe because: - * - updateScene creates new element objects for changed properties - * - syncInvalidIndices may mutate `index` in-place, but `index` is in - * LEDGER_IGNORED_PROPS so the ledger never considers it - * - the ledger deep-copies only the elements it actually records - */ -const shallowSnapshotElements = ( - elementsMap: Map, -): Map => new Map(elementsMap); - -// --------------------------------------------------------------------------- -// Transaction -// --------------------------------------------------------------------------- - -/** - * A transaction that records mutations via `updateScene(NEVER)` and commits - * a single synthetic durable history entry at the end. - */ -export class Transaction { - public readonly id = `tx-${randomId()}`; - - private readonly app: AppClassProperties; - private readonly manager: TransactionManager; - private readonly ledger = new TransactionLedger(); - private readonly undoOverridePlanner = new TxUndoOverridePlanner(); - private readonly initialAppState: Partial; - - private accumulatedAppState: Record = {}; - private statusValue: TransactionStatus = "active"; - private cachedSummary: TransactionSummary | null = null; - - constructor(app: AppClassProperties, manager: TransactionManager) { - this.app = app; - this.manager = manager; - this.initialAppState = { ...app.store.snapshot.appState }; - this.manager.registerTransaction(this); - } - - get status(): TransactionStatus { - return this.statusValue; - } - - private assertActive(action: string): void { - if (this.statusValue !== "active") { - throw new Error( - `Cannot ${action} — transaction ${this.id} is already ${this.statusValue}.`, - ); - } - } - - private closeTransaction() { - this.manager.unregisterTransaction(this.id); - this.undoOverridePlanner.clear(); - } - - public collectUndoOverridesForDelta( - delta: StoreDelta, - reservedConsumedKeys: Set, - ): TxUndoOverride[] { - if (this.statusValue !== "active") { - return []; - } - - const candidates = - this.undoOverridePlanner.collectCandidatesForDurableDelta( - delta, - (elementId) => this.ledger.getEntry(elementId), - reservedConsumedKeys, - ); - - if (candidates.length === 0) { - return []; - } - - const overrides: TxUndoOverride[] = []; - for (const candidate of candidates) { - this.undoOverridePlanner.markConsumed(candidate.consumedKey); - overrides.push({ - txId: this.id, - ...candidate, - }); - } - - return overrides; - } - - updateScene(data: { - elements?: SceneData["elements"]; - appState?: Pick | null; - }): void { - this.assertActive("updateScene"); - - // Snapshot before (shallow copy — replaceAllElements mutates the map in-place) - const before = shallowSnapshotElements( - this.app.scene.getElementsMapIncludingDeleted(), - ); - - // Apply through the real updateScene with NEVER. - this.app.api.updateScene({ - elements: data.elements, - appState: data.appState, - captureUpdate: CaptureUpdateAction.NEVER, - }); - - // Snapshot after - const after = this.app.scene.getElementsMapIncludingDeleted(); - - this.undoOverridePlanner.recordStep(before, after); - - // Record element diff into ledger - this.ledger.recordStep(before, after); - - // Accumulate appState intent - if (data.appState) { - this.accumulatedAppState = { - ...this.accumulatedAppState, - ...(data.appState as Record), - }; - } - } - - /** - * Partial element updates convenience API. - * - * Example: - * tx.updateElements({ - * elements: [ - * { id: "a", type: "rectangle", updates: { strokeColor: "#f00" } }, - * { id: "b", type: "rectangle", updates: { x: 10, y: 20 } }, - * ], - * }) - */ - updateElements(data: { - elements: readonly TransactionElementUpdate[]; - appState?: Pick | null; - }): void { - const updatesById = new Map(); - - for (const update of data.elements) { - updatesById.set(update.id, update); - } - - if (updatesById.size === 0) { - this.updateScene({ appState: data.appState }); - return; - } - - const nextElements = this.app.scene - .getElementsIncludingDeleted() - .map((element) => { - const update = updatesById.get(element.id); - if (!update) { - return element; - } - if (element.type !== update.type) { - throw new Error( - `Cannot apply tx.updateElements update for "${update.id}": expected "${update.type}", got "${element.type}".`, - ); - } - - type MatchingElement = TransactionElementOfType; - return newElementWith( - element as MatchingElement, - update.updates as ElementUpdate, - ); - }); - - this.updateScene({ - elements: nextElements, - appState: data.appState, - }); - } - - commit(options?: { - /** - * Resolver that determines which appState changes are recorded in the - * history entry. - * - * AppState keys are often interdependent (e.g. selectedElementIds ↔ - * selectedGroupIds) and the correct merge depends on the caller's - * semantic context — a generic conflict policy cannot cover these cases. - * The resolver receives all three states (initial, accumulated, live) so - * the caller can make an informed decision. - * - * When omitted, the accumulated appState from updateScene calls is used - * as-is — suitable when the caller has already ensured correctness at - * each updateScene step. - */ - resolveAppState?: AppStateResolver; - }): TransactionSummary { - if (this.cachedSummary) { - return this.cachedSummary; - } - - this.markCommittedIfActive(); - const historyCommitted = this.shouldCommitHistory() - ? this.commitHistoryEntry(options) - : false; - - this.closeTransaction(); - this.cachedSummary = { - id: this.id, - status: this.statusValue, - historyCommitted, - }; - this.ledger.clear(); - return this.cachedSummary; - } - - private markCommittedIfActive() { - if (this.statusValue === "active") { - this.statusValue = "committed"; - this.manager.markTransactionPhase(this.id, "committed"); - } - } - - private shouldCommitHistory() { - return this.statusValue === "committed" && this.hasPendingWork(); - } - - private hasPendingWork() { - return this.ledger.hasEntries() || this.hasAccumulatedAppStateIntent(); - } - - private hasAccumulatedAppStateIntent() { - return Object.keys(this.accumulatedAppState).length > 0; - } - - private commitHistoryEntry(options?: { resolveAppState?: AppStateResolver }) { - const liveMap = this.app.scene.getElementsMapIncludingDeleted(); - const { elementsBefore, elementsAfter } = - this.ledger.buildSyntheticSnapshots(liveMap); - - const appStateDelta = this.resolveCommitAppStateDelta(options); - - return this.app.store.commitSyntheticIncrement({ - logicalBefore: { elements: elementsBefore }, - logicalAfter: { - elements: elementsAfter, - appState: appStateDelta, - }, - }); - } - - private resolveCommitAppStateDelta(options?: { - resolveAppState?: AppStateResolver; - }): Partial | undefined { - if (!this.hasAccumulatedAppStateIntent()) { - return undefined; - } - - if (!options?.resolveAppState) { - return this.accumulatedAppState as Partial; - } - - const context: AppStateResolverContext = { - initial: this.initialAppState, - accumulated: this.accumulatedAppState as Partial, - live: { ...this.app.store.snapshot.appState }, - }; - const resolved = options.resolveAppState(context); - - if (!resolved || Object.keys(resolved).length === 0) { - return undefined; - } - return resolved; - } - - cancel(): TransactionSummary { - if (this.cachedSummary) { - return this.cachedSummary; - } - - if (this.statusValue === "active") { - this.statusValue = "canceled"; - this.manager.markTransactionPhase(this.id, "canceled"); - } - - this.closeTransaction(); - this.cachedSummary = { - id: this.id, - status: this.statusValue, - historyCommitted: false, - }; - this.ledger.clear(); - return this.cachedSummary; - } -} - -// --------------------------------------------------------------------------- -// TransactionManager -// --------------------------------------------------------------------------- - -/** - * Thin factory that holds the app reference and creates Transaction instances. - */ -export class TransactionManager { - private readonly app: AppClassProperties; - private readonly activeTransactions: TransactionMap = new Map(); - private readonly activeTransactionsByStartedSeqDesc: Transaction[] = []; - private readonly transactionLifecycle: TransactionLifecycleMap = new Map(); - private detachBeforeRecordHook: (() => void) | null = null; - private sequence = 0; - - constructor(app: AppClassProperties) { - this.app = app; - } - - /** - * Binds transaction bookkeeping to history lifecycle hooks. - * Call once during app initialization. - */ - attachHistory(history: TransactionHistoryBridge) { - this.detachBeforeRecordHook?.(); - history.setEffectiveDeltaResolver((delta, context) => - this.resolveEffectiveDelta(delta, context), - ); - this.detachBeforeRecordHook = history.onBeforeRecord((delta) => - this.onDurableIncrement(delta), - ); - } - - registerTransaction(tx: Transaction) { - const startedSeq = this.nextSequence(); - this.activeTransactions.set(tx.id, tx); - this.activeTransactionsByStartedSeqDesc.unshift(tx); - this.transactionLifecycle.set(tx.id, { - phase: "active", - startedSeq, - }); - } - - unregisterTransaction(txId: string) { - this.activeTransactions.delete(txId); - const txIndex = this.activeTransactionsByStartedSeqDesc.findIndex( - (tx) => tx.id === txId, - ); - if (txIndex >= 0) { - this.activeTransactionsByStartedSeqDesc.splice(txIndex, 1); - } - } - - markTransactionPhase( - txId: string, - phase: Exclude, - ) { - const record = this.transactionLifecycle.get(txId); - if (!record || record.phase !== "active") { - return; - } - - record.phase = phase; - record.endedSeq = this.nextSequence(); - } - - onDurableIncrement(delta: StoreDelta) { - if (this.activeTransactions.size === 0) { - return; - } - if (!hasUpdatedElementEntries(delta)) { - return; - } - - const txUndoOverrides = this.collectUndoOverrides(delta); - if (txUndoOverrides.length === 0) { - return; - } - - mergeStoreDeltaSemantics(delta, { txUndoOverrides }); - } - - private collectUndoOverrides(delta: StoreDelta): TxUndoOverride[] { - const overrides: TxUndoOverride[] = []; - const reservedConsumedKeys = new Set(); - - for (const tx of this.activeTransactionsByStartedSeqDesc) { - const txOverrides = tx.collectUndoOverridesForDelta( - delta, - reservedConsumedKeys, - ); - - for (const override of txOverrides) { - if (reservedConsumedKeys.has(override.consumedKey)) { - continue; - } - - reservedConsumedKeys.add(override.consumedKey); - overrides.push(override); - } - } - - return overrides; - } - - private resolveEffectiveDelta( - delta: HistoryDelta, - _context: HistoryEffectiveDeltaResolverContext, - ): HistoryDelta { - const txUndoOverrides = delta.semantics?.txUndoOverrides; - if (!txUndoOverrides || txUndoOverrides.length === 0) { - return delta; - } - - const updatedEntries = getUpdatedElementEntries(delta); - const insertedOverridesByElement = new Map< - string, - Record - >(); - - for (const override of txUndoOverrides) { - if (!this.shouldApplyUndoOverride(override.txId)) { - continue; - } - - const currentEntry = updatedEntries[override.elementId]; - if (!currentEntry) { - continue; - } - - const currentInsertedValue = currentEntry.inserted[override.prop]; - if ( - !isLedgerValueEqual( - currentInsertedValue, - override.expectedInsertedValue, - ) - ) { - // Guard against over-applying once the delta has already evolved. - continue; - } - - const elementOverrides = insertedOverridesByElement.get( - override.elementId, - ); - if (elementOverrides) { - elementOverrides[override.prop] = override.preTxBaselineValue; - } else { - insertedOverridesByElement.set(override.elementId, { - [override.prop]: override.preTxBaselineValue, - }); - } - } - - if (insertedOverridesByElement.size === 0) { - return delta; - } - - const nextUpdatedEntries: ElementUpdatedEntryMap = { - ...updatedEntries, - }; - for (const [elementId, insertedOverrides] of insertedOverridesByElement) { - const currentEntry = updatedEntries[elementId]; - if (!currentEntry) { - continue; - } - - nextUpdatedEntries[elementId] = Delta.create( - { ...currentEntry.deleted }, - { ...currentEntry.inserted, ...insertedOverrides }, - ); - } - - const effectiveElements = ElementsDelta.create( - delta.elements.added, - delta.elements.removed, - nextUpdatedEntries, - ); - - return HistoryDelta.create(effectiveElements, delta.appState, { - id: delta.id, - semantics: delta.semantics, - }) as HistoryDelta; - } - - private shouldApplyUndoOverride(txId: string): boolean { - const lifecycle = this.transactionLifecycle.get(txId); - return !!lifecycle && lifecycle.phase !== "active"; - } - - private nextSequence() { - this.sequence += 1; - return this.sequence; - } - - create(): Transaction { - return new Transaction(this.app, this); - } -} diff --git a/packages/excalidraw/transaction/diff.ts b/packages/excalidraw/transaction/diff.ts new file mode 100644 index 0000000000..a349b6a883 --- /dev/null +++ b/packages/excalidraw/transaction/diff.ts @@ -0,0 +1,289 @@ +import { type StoreDelta } from "@excalidraw/element"; + +import type { Delta } from "@excalidraw/element"; + +import type { Mutable } from "@excalidraw/common/utility-types"; +import type { + ExcalidrawElement, + OrderedExcalidrawElement, + SceneElementsMap, +} from "@excalidraw/element/types"; + +import type { + ElementChange, + ElementPropName, + TouchedElementProps, +} from "./types"; + +const LEDGER_IGNORED_PROPS = new Set([ + "version", + "versionNonce", + "seed", + "updated", + "index", +]); + +export const TX_UNDO_OVERRIDE_IGNORED_PROPS = new Set([ + "version", + "versionNonce", + "isDeleted", +]); + +type ElementRecord = Record; +export type ElementUpdatedProps = Omit< + Partial, + "id" | "updated" | "seed" +>; +export type ElementUpdatedPropName = Extract; +type ElementPropValueMap = ElementUpdatedProps; + +export type ElementUpdatedEntry = Delta; +export type ElementUpdatedEntryMap = Record; + +const isPlainObject = (value: unknown): value is Record => + !!value && typeof value === "object" && !Array.isArray(value); + +export const getElementProp = ( + element: ExcalidrawElement, + prop: TProp, +): ExcalidrawElement[TProp] => + (element as ElementRecord)[prop] as ExcalidrawElement[TProp]; + +export const setOrderedElementProp = ( + element: Mutable, + prop: TProp, + value: OrderedExcalidrawElement[TProp], +) => { + (element as ElementRecord)[prop] = value; +}; + +/** Deep equality used by ledger conflict/touched-prop detection. */ +export const isLedgerValueEqual = (left: unknown, right: unknown): boolean => { + if (Object.is(left, right)) { + return true; + } + + if (Array.isArray(left) && Array.isArray(right)) { + if (left.length !== right.length) { + return false; + } + for (let index = 0; index < left.length; index += 1) { + if (!isLedgerValueEqual(left[index], right[index])) { + return false; + } + } + return true; + } + + if (isPlainObject(left) && isPlainObject(right)) { + const leftKeys = Object.keys(left); + const rightKeys = Object.keys(right); + if (leftKeys.length !== rightKeys.length) { + return false; + } + for (const key of leftKeys) { + if (!Object.prototype.hasOwnProperty.call(right, key)) { + return false; + } + if (!isLedgerValueEqual(left[key], right[key])) { + return false; + } + } + return true; + } + + return false; +}; + +/** Shallow-copies a scene map. Entries share references with the original. */ +export const shallowCopySceneElements = ( + elements: ReadonlyMap, +): SceneElementsMap => new Map(elements) as SceneElementsMap; + +export const createAllTouchedElementProps = (): TouchedElementProps => ({ + kind: "all", +}); + +export const createPartialTouchedElementProps = ( + props: Iterable = [], +): TouchedElementProps => ({ + kind: "partial", + props: new Set(props), +}); + +export const hasTouchedProps = (touchedProps: TouchedElementProps): boolean => + touchedProps.kind === "all" || touchedProps.props.size > 0; + +export const touchesWholeElement = ( + touchedProps: TouchedElementProps, +): boolean => touchedProps.kind === "all"; + +export const isPartialTouchedProps = ( + touchedProps: TouchedElementProps, +): touchedProps is Extract => + touchedProps.kind === "partial"; + +export const hasTouchedProp = ( + touchedProps: TouchedElementProps, + prop: ElementPropName, +): boolean => touchedProps.kind === "all" || touchedProps.props.has(prop); + +export const mergeTouchedProps = ( + left: TouchedElementProps, + right: TouchedElementProps, +): TouchedElementProps => { + if (left.kind === "all" || right.kind === "all") { + return createAllTouchedElementProps(); + } + + return createPartialTouchedElementProps([...left.props, ...right.props]); +}; + +/** Returns changed property names between two element snapshots. */ +export const collectTouchedProps = ( + before: ExcalidrawElement | null, + after: ExcalidrawElement | null, +): TouchedElementProps => { + if (!before || !after) { + return createAllTouchedElementProps(); + } + + const touchedProps = new Set(); + const keys = new Set([ + ...(Object.keys(before) as ElementPropName[]), + ...(Object.keys(after) as ElementPropName[]), + ]); + + for (const key of keys) { + if (LEDGER_IGNORED_PROPS.has(key)) { + continue; + } + if ( + !isLedgerValueEqual( + getElementProp(before, key), + getElementProp(after, key), + ) + ) { + touchedProps.add(key); + } + } + + return createPartialTouchedElementProps(touchedProps); +}; + +/** Returns ids whose element snapshot changed between two points in time. */ +export const collectChangedElementIds = ( + before: ReadonlyMap, + after: ReadonlyMap, +) => collectElementChanges(before, after).map((change) => change.id); + +export const collectElementChanges = ( + before: ReadonlyMap, + after: ReadonlyMap, +): ElementChange[] => { + const changes: ElementChange[] = []; + const candidateIds = new Set([...before.keys(), ...after.keys()]); + + for (const id of candidateIds) { + const beforeElement = before.get(id) ?? null; + const afterElement = after.get(id) ?? null; + const touchedProps = collectTouchedProps(beforeElement, afterElement); + if (!hasTouchedProps(touchedProps)) { + continue; + } + + changes.push({ + id, + before: beforeElement, + after: afterElement, + touchedProps, + }); + } + + return changes; +}; + +export const serializeConsumedPropKey = ( + elementId: string, + prop: ElementPropName, +) => `${elementId}\u0000${prop}`; + +export const getUpdatedElementEntries = (delta: StoreDelta) => + delta.elements.updated as ElementUpdatedEntryMap; + +export const getElementPropEntries = (props: ElementPropValueMap) => + Object.entries(props) as [ + ElementUpdatedPropName, + ElementUpdatedProps[ElementUpdatedPropName], + ][]; + +export const hasUpdatedElementEntries = (delta: StoreDelta) => + Object.keys(getUpdatedElementEntries(delta)).length > 0; + +export const serializeIntermediateValue = (value: unknown): string => { + const serialize = (input: unknown, seen: WeakSet): string => { + if (input === null) { + return "null"; + } + + switch (typeof input) { + case "undefined": + return "undefined"; + case "boolean": + return input ? "boolean:true" : "boolean:false"; + case "number": + if (Number.isNaN(input)) { + return "number:NaN"; + } + if (Object.is(input, -0)) { + return "number:-0"; + } + return `number:${input}`; + case "bigint": + return `bigint:${input.toString()}`; + case "string": + return `string:${JSON.stringify(input)}`; + case "symbol": + return `symbol:${String(input)}`; + case "function": + return `function:${input.name}`; + case "object": + break; + default: + return `unknown:${String(input)}`; + } + + if (Array.isArray(input)) { + if (seen.has(input)) { + return "[CircularArray]"; + } + seen.add(input); + const serialized = `[${input + .map((item) => serialize(item, seen)) + .join(",")}]`; + seen.delete(input); + return serialized; + } + + if (isPlainObject(input)) { + if (seen.has(input)) { + return "{CircularObject}"; + } + seen.add(input); + const serialized = `{${Object.keys(input) + .sort() + .map((key) => `${JSON.stringify(key)}:${serialize(input[key], seen)}`) + .join(",")}}`; + seen.delete(input); + return serialized; + } + + try { + return `object:${JSON.stringify(input)}`; + } catch { + return `object:${Object.prototype.toString.call(input)}`; + } + }; + + return serialize(value, new WeakSet()); +}; diff --git a/packages/excalidraw/transaction/index.ts b/packages/excalidraw/transaction/index.ts new file mode 100644 index 0000000000..d7789b14ac --- /dev/null +++ b/packages/excalidraw/transaction/index.ts @@ -0,0 +1,13 @@ +export { collectChangedElementIds } from "./diff"; +export { TransactionLedger } from "./ledger"; +export { Transaction } from "./transaction"; +export { TransactionManager } from "./manager"; + +export type { + AppStateResolver, + AppStateResolverContext, + TransactionElementUpdate, + TransactionLedgerEntry, + TransactionStatus, + TransactionSummary, +} from "./types"; diff --git a/packages/excalidraw/transaction/ledger.ts b/packages/excalidraw/transaction/ledger.ts new file mode 100644 index 0000000000..6d4dec1037 --- /dev/null +++ b/packages/excalidraw/transaction/ledger.ts @@ -0,0 +1,333 @@ +import { deepCopyElement } from "@excalidraw/element"; + +import type { Mutable } from "@excalidraw/common/utility-types"; +import type { + ExcalidrawElement, + OrderedExcalidrawElement, + SceneElementsMap, +} from "@excalidraw/element/types"; + +import { + collectElementChanges, + collectTouchedProps, + getElementProp, + hasTouchedProps, + isLedgerValueEqual, + mergeTouchedProps, + setOrderedElementProp, + shallowCopySceneElements, + touchesWholeElement, +} from "./diff"; + +import type { TransactionLedgerEntry } from "./types"; + +/** + * Keeps transaction-level scene mutations and materializes synthetic snapshots + * for a single durable history commit. + */ +export class TransactionLedger { + private readonly entries = new Map(); + + /** Whether the transaction has any net element mutations. */ + hasEntries() { + return this.entries.size > 0; + } + + /** Returns the ledger entry for an element, if any. */ + getEntry(elementId: string): TransactionLedgerEntry | undefined { + return this.entries.get(elementId); + } + + /** Releases all ledger entries. */ + clear() { + this.entries.clear(); + } + + /** Records one element mutation step into the ledger. */ + recordStep( + before: ReadonlyMap, + after: ReadonlyMap, + ) { + for (const change of collectElementChanges(before, after)) { + const { + id: elementId, + before: beforeElement, + after: afterElement, + touchedProps, + } = change; + + const existing = this.entries.get(elementId); + if (!existing) { + this.entries.set(elementId, { + baselineElement: beforeElement + ? deepCopyElement(beforeElement) + : null, + targetElement: afterElement ? deepCopyElement(afterElement) : null, + touchedProps, + }); + continue; + } + + existing.targetElement = afterElement + ? deepCopyElement(afterElement) + : null; + existing.touchedProps = mergeTouchedProps( + existing.touchedProps, + touchedProps, + ); + + // Created then deleted inside one transaction leaves no durable footprint. + if (!existing.baselineElement && !existing.targetElement) { + this.entries.delete(elementId); + continue; + } + if (!existing.baselineElement && existing.targetElement?.isDeleted) { + this.entries.delete(elementId); + } + } + } + + /** + * Builds synthetic element before/after snapshots with a fixed + * "live-wins-per-prop" strategy. + */ + buildSyntheticSnapshots(live: ReadonlyMap) { + // Shallow copy — untouched elements stay as live references. + // Only elements mutated in-place (prop-level updates) are deep-copied below. + const elementsBefore = shallowCopySceneElements(live); + const elementsAfter = shallowCopySceneElements(live); + + for (const [elementId, entry] of this.entries) { + this.reconcileEntrySnapshots( + elementId, + entry, + live, + elementsBefore, + elementsAfter, + ); + } + + return { elementsBefore, elementsAfter }; + } + + private reconcileEntrySnapshots( + elementId: string, + entry: TransactionLedgerEntry, + live: ReadonlyMap, + elementsBefore: SceneElementsMap, + elementsAfter: SceneElementsMap, + ) { + if (!entry.baselineElement) { + this.applyCreatedElementSnapshots( + elementId, + entry.targetElement, + live, + elementsBefore, + elementsAfter, + ); + return; + } + + if (!entry.targetElement) { + this.applyDeletedElementSnapshots( + elementId, + entry.baselineElement, + live, + elementsBefore, + elementsAfter, + ); + return; + } + + this.applyUpdatedElementSnapshots( + elementId, + entry, + live, + elementsBefore, + elementsAfter, + ); + } + + private applyCreatedElementSnapshots( + elementId: string, + targetElement: ExcalidrawElement | null, + live: ReadonlyMap, + elementsBefore: SceneElementsMap, + elementsAfter: SceneElementsMap, + ) { + if (!targetElement) { + return; + } + + const liveElement = live.get(elementId) ?? null; + if ( + !liveElement || + liveElement.isDeleted || + hasTouchedProps(collectTouchedProps(targetElement, liveElement)) + ) { + return; + } + + elementsBefore.delete(elementId); + elementsAfter.set( + elementId, + deepCopyElement(targetElement) as OrderedExcalidrawElement, + ); + } + + private applyDeletedElementSnapshots( + elementId: string, + baselineElement: ExcalidrawElement, + live: ReadonlyMap, + elementsBefore: SceneElementsMap, + elementsAfter: SceneElementsMap, + ) { + const liveElement = live.get(elementId) ?? null; + if (liveElement && !liveElement.isDeleted) { + return; + } + + elementsBefore.set( + elementId, + deepCopyElement(baselineElement) as OrderedExcalidrawElement, + ); + elementsAfter.delete(elementId); + } + + private applyUpdatedElementSnapshots( + elementId: string, + entry: TransactionLedgerEntry, + live: ReadonlyMap, + elementsBefore: SceneElementsMap, + elementsAfter: SceneElementsMap, + ) { + const liveElement = live.get(elementId) ?? null; + const targetElement = entry.targetElement; + const baselineElement = entry.baselineElement; + const beforeElement = elementsBefore.get(elementId); + const afterElement = elementsAfter.get(elementId); + + if ( + !liveElement || + !baselineElement || + !targetElement || + !beforeElement || + !afterElement + ) { + return; + } + + if (touchesWholeElement(entry.touchedProps)) { + this.applyWholeElementSnapshots( + elementId, + baselineElement, + targetElement, + liveElement, + elementsBefore, + elementsAfter, + ); + return; + } + + this.applyPerPropSnapshots({ + entry, + liveElement, + baselineElement, + targetElement, + beforeElement, + afterElement, + elementId, + elementsBefore, + elementsAfter, + }); + } + + private applyWholeElementSnapshots( + elementId: string, + baselineElement: ExcalidrawElement, + targetElement: ExcalidrawElement, + liveElement: ExcalidrawElement, + elementsBefore: SceneElementsMap, + elementsAfter: SceneElementsMap, + ) { + const hasLiveConflict = hasTouchedProps( + collectTouchedProps(targetElement, liveElement), + ); + if (hasLiveConflict) { + return; + } + + elementsBefore.set( + elementId, + deepCopyElement(baselineElement) as OrderedExcalidrawElement, + ); + elementsAfter.set( + elementId, + deepCopyElement(targetElement) as OrderedExcalidrawElement, + ); + } + + private applyPerPropSnapshots(args: { + entry: TransactionLedgerEntry; + liveElement: ExcalidrawElement; + baselineElement: ExcalidrawElement; + targetElement: ExcalidrawElement; + beforeElement: ExcalidrawElement; + afterElement: ExcalidrawElement; + elementId: string; + elementsBefore: SceneElementsMap; + elementsAfter: SceneElementsMap; + }) { + const { + entry, + liveElement, + baselineElement, + targetElement, + beforeElement, + afterElement, + elementId, + elementsBefore, + elementsAfter, + } = args; + + // Deep-copy before mutating so we never touch live elements. + const mutableBefore = deepCopyElement( + beforeElement, + ) as Mutable; + const mutableAfter = deepCopyElement( + afterElement, + ) as Mutable; + elementsBefore.set(elementId, mutableBefore as OrderedExcalidrawElement); + elementsAfter.set(elementId, mutableAfter as OrderedExcalidrawElement); + + if (entry.touchedProps.kind !== "partial") { + return; + } + + let appliedProps = 0; + for (const prop of entry.touchedProps.props) { + const liveValue = getElementProp(liveElement, prop); + const targetValue = getElementProp(targetElement, prop); + if (!isLedgerValueEqual(liveValue, targetValue)) { + continue; + } + + setOrderedElementProp( + mutableBefore, + prop, + getElementProp(baselineElement, prop), + ); + setOrderedElementProp(mutableAfter, prop, targetValue); + appliedProps += 1; + } + + if (appliedProps === 0) { + return; + } + + mutableBefore.version = baselineElement.version; + mutableBefore.versionNonce = baselineElement.versionNonce; + mutableAfter.version = targetElement.version; + mutableAfter.versionNonce = targetElement.versionNonce; + } +} diff --git a/packages/excalidraw/transaction/manager.ts b/packages/excalidraw/transaction/manager.ts new file mode 100644 index 0000000000..c622c6a936 --- /dev/null +++ b/packages/excalidraw/transaction/manager.ts @@ -0,0 +1,291 @@ +import { + Delta, + ElementsDelta, + mergeStoreDeltaSemantics, + type StoreDelta, + type TxUndoOverride, +} from "@excalidraw/element"; + +import type { Mutable } from "@excalidraw/common/utility-types"; + +import { + HistoryDelta, + type HistoryBeforeRecordListener, + type HistoryEffectiveDeltaResolverContext, +} from "../history"; + +import { + type ElementUpdatedPropName, + getUpdatedElementEntries, + hasUpdatedElementEntries, + isLedgerValueEqual, + type ElementUpdatedProps, + type ElementUpdatedEntryMap, +} from "./diff"; +import { Transaction } from "./transaction"; + +import type { AppClassProperties } from "../types"; +import type { TransactionStatus } from "./types"; + +type TransactionRecord = { + tx: Transaction | null; + phase: TransactionStatus; +}; +type TransactionHistoryBridge = { + onBeforeRecord: (callback: HistoryBeforeRecordListener) => () => void; + setEffectiveDeltaResolver: ( + resolver: + | (( + delta: HistoryDelta, + context: HistoryEffectiveDeltaResolverContext, + ) => HistoryDelta) + | null, + ) => void; +}; + +type MutableElementUpdatedProps = Mutable; + +const setElementUpdatedOverride = ( + overrides: MutableElementUpdatedProps, + prop: ElementUpdatedPropName, + value: unknown, +) => { + (overrides as Record)[prop] = value; +}; + +/** + * Thin factory that holds the app reference and creates Transaction instances. + */ +export class TransactionManager { + private readonly app: AppClassProperties; + /** + * Single authoritative lifecycle registry for transactions. + * + * We retain ended/canceled metadata after the tx object is released because + * history semantics only persist `txId`; undo/redo still needs to resolve + * whether a tx was active or already ended when applying effective deltas. + */ + private readonly transactionRecords = new Map(); + /** + * Active transaction ids ordered by most-recent registration first. + * This preserves deterministic priority when multiple active txs overlap on + * the same element+prop and compete to reserve override markers. + */ + private readonly activeTransactionIdsByPriority: string[] = []; + private detachBeforeRecordHook: (() => void) | null = null; + + constructor(app: AppClassProperties) { + this.app = app; + } + + /** + * Binds transaction bookkeeping to history lifecycle hooks. + * Call once during app initialization. + */ + attachHistory(history: TransactionHistoryBridge) { + this.detachBeforeRecordHook?.(); + history.setEffectiveDeltaResolver((delta, context) => + this.resolveEffectiveDelta(delta, context), + ); + this.detachBeforeRecordHook = history.onBeforeRecord((delta) => + this.onDurableIncrement(delta), + ); + } + + private removeActiveTransactionId(txId: string) { + const txIndex = this.activeTransactionIdsByPriority.indexOf(txId); + if (txIndex >= 0) { + this.activeTransactionIdsByPriority.splice(txIndex, 1); + } + } + + private getRequiredTransactionRecord(txId: string): TransactionRecord { + const record = this.transactionRecords.get(txId); + if (!record) { + throw new Error(`Unknown transaction: ${txId}`); + } + + return record; + } + + registerTransaction(tx: Transaction) { + this.transactionRecords.set(tx.id, { + tx, + phase: "active", + }); + this.activeTransactionIdsByPriority.unshift(tx.id); + } + + detachTransactionInstance(txId: string) { + const record = this.getRequiredTransactionRecord(txId); + record.tx = null; + this.removeActiveTransactionId(txId); + } + + getStatus(txId: string): TransactionStatus { + return this.getRequiredTransactionRecord(txId).phase; + } + + private markTransactionFinished( + txId: string, + phase: Exclude, + ): TransactionStatus { + const record = this.getRequiredTransactionRecord(txId); + if (record.phase !== "active") { + return record.phase; + } + + record.phase = phase; + this.removeActiveTransactionId(txId); + return record.phase; + } + + markTransactionCommitted(txId: string): TransactionStatus { + return this.markTransactionFinished(txId, "committed"); + } + + markTransactionCanceled(txId: string): TransactionStatus { + return this.markTransactionFinished(txId, "canceled"); + } + + onDurableIncrement(delta: StoreDelta) { + if (this.activeTransactionIdsByPriority.length === 0) { + return; + } + if (!hasUpdatedElementEntries(delta)) { + return; + } + + const txUndoOverrides = this.collectUndoOverrides(delta); + if (txUndoOverrides.length === 0) { + return; + } + + mergeStoreDeltaSemantics(delta, { txUndoOverrides }); + } + + private collectUndoOverrides(delta: StoreDelta): TxUndoOverride[] { + const overrides: TxUndoOverride[] = []; + const reservedConsumedKeys = new Set(); + + for (const txId of this.activeTransactionIdsByPriority) { + const record = this.transactionRecords.get(txId); + if (!record || record.phase !== "active" || !record.tx) { + continue; + } + + const txOverrides = record.tx.collectUndoOverridesForDelta( + delta, + reservedConsumedKeys, + ); + + for (const override of txOverrides) { + if (reservedConsumedKeys.has(override.consumedKey)) { + continue; + } + + reservedConsumedKeys.add(override.consumedKey); + overrides.push(override); + } + } + + return overrides; + } + + private resolveEffectiveDelta( + delta: HistoryDelta, + _context: HistoryEffectiveDeltaResolverContext, + ): HistoryDelta { + const txUndoOverrides = delta.semantics?.txUndoOverrides; + if (!txUndoOverrides || txUndoOverrides.length === 0) { + return delta; + } + + const updatedEntries = getUpdatedElementEntries(delta); + const insertedOverridesByElement = new Map< + string, + MutableElementUpdatedProps + >(); + + for (const override of txUndoOverrides) { + if (!this.shouldApplyUndoOverride(override.txId)) { + continue; + } + + const currentEntry = updatedEntries[override.elementId]; + if (!currentEntry) { + continue; + } + + const prop = override.prop as ElementUpdatedPropName; + const currentInsertedValue = currentEntry.inserted[prop]; + if ( + !isLedgerValueEqual( + currentInsertedValue, + override.expectedInsertedValue, + ) + ) { + // Guard against over-applying once the delta has already evolved. + continue; + } + + const elementOverrides = insertedOverridesByElement.get( + override.elementId, + ); + if (elementOverrides) { + setElementUpdatedOverride( + elementOverrides, + prop, + override.preTxBaselineValue, + ); + } else { + const nextOverrides: MutableElementUpdatedProps = {}; + setElementUpdatedOverride( + nextOverrides, + prop, + override.preTxBaselineValue, + ); + insertedOverridesByElement.set(override.elementId, nextOverrides); + } + } + + if (insertedOverridesByElement.size === 0) { + return delta; + } + + const nextUpdatedEntries: ElementUpdatedEntryMap = { + ...updatedEntries, + }; + for (const [elementId, insertedOverrides] of insertedOverridesByElement) { + const currentEntry = updatedEntries[elementId]; + if (!currentEntry) { + continue; + } + + nextUpdatedEntries[elementId] = Delta.create( + { ...currentEntry.deleted }, + { ...currentEntry.inserted, ...insertedOverrides }, + ); + } + + const effectiveElements = ElementsDelta.create( + delta.elements.added, + delta.elements.removed, + nextUpdatedEntries, + ); + + return HistoryDelta.create(effectiveElements, delta.appState, { + id: delta.id, + semantics: delta.semantics, + }) as HistoryDelta; + } + + private shouldApplyUndoOverride(txId: string): boolean { + const record = this.transactionRecords.get(txId); + return !!record && record.phase !== "active"; + } + + create(): Transaction { + return new Transaction(this.app, this); + } +} diff --git a/packages/excalidraw/transaction/transaction.ts b/packages/excalidraw/transaction/transaction.ts new file mode 100644 index 0000000000..554b052ef2 --- /dev/null +++ b/packages/excalidraw/transaction/transaction.ts @@ -0,0 +1,286 @@ +import { randomId } from "@excalidraw/common"; +import { + CaptureUpdateAction, + newElementWith, + type ElementUpdate, + type StoreDelta, + type TxUndoOverride, +} from "@excalidraw/element"; + +import { shallowCopySceneElements } from "./diff"; +import { TransactionLedger } from "./ledger"; + +import { + type AppStateResolver, + type AppStateResolverContext, + type TransactionElementOfType, + type TransactionElementUpdate, + type TransactionStatus, + type TransactionSummary, +} from "./types"; +import { TxUndoOverridePlanner } from "./undoOverridePlanner"; + +import type { TransactionManager } from "./manager"; +import type { + AppClassProperties, + AppState, + ObservedAppState, + SceneData, +} from "../types"; + +type CommitOptions = { + resolveAppState?: AppStateResolver; +}; + +/** + * A transaction that records mutations via `updateScene(NEVER)` and commits + * a single synthetic durable history entry at the end. + */ +export class Transaction { + public readonly id = `tx-${randomId()}`; + + private readonly app: AppClassProperties; + private readonly manager: TransactionManager; + private readonly ledger = new TransactionLedger(); + private readonly undoOverridePlanner = new TxUndoOverridePlanner(); + private readonly initialAppState: Partial; + + private accumulatedAppState: Record = {}; + private cachedSummary: TransactionSummary | null = null; + + constructor(app: AppClassProperties, manager: TransactionManager) { + this.app = app; + this.manager = manager; + this.initialAppState = { ...app.store.snapshot.appState }; + this.manager.registerTransaction(this); + } + + get status(): TransactionStatus { + return this.manager.getStatus(this.id); + } + + private assertActive(action: string): void { + const status = this.status; + if (status !== "active") { + throw new Error( + `Cannot ${action} — transaction ${this.id} is already ${status}.`, + ); + } + } + + private closeTransaction() { + this.manager.detachTransactionInstance(this.id); + this.undoOverridePlanner.clear(); + } + + public collectUndoOverridesForDelta( + delta: StoreDelta, + reservedConsumedKeys: Set, + ): TxUndoOverride[] { + if (this.status !== "active") { + return []; + } + + const candidates = + this.undoOverridePlanner.collectCandidatesForDurableDelta( + delta, + (elementId) => this.ledger.getEntry(elementId), + reservedConsumedKeys, + ); + + if (candidates.length === 0) { + return []; + } + + const overrides: TxUndoOverride[] = []; + for (const candidate of candidates) { + this.undoOverridePlanner.markConsumed(candidate.consumedKey); + overrides.push({ + txId: this.id, + ...candidate, + }); + } + + return overrides; + } + + updateScene(data: { + elements?: SceneData["elements"]; + appState?: Pick | null; + }): void { + this.assertActive("updateScene"); + + // Snapshot before (shallow copy — replaceAllElements mutates the map in-place) + const before = shallowCopySceneElements( + this.app.scene.getElementsMapIncludingDeleted(), + ); + + // Apply through the real updateScene with NEVER. + this.app.api.updateScene({ + elements: data.elements, + appState: data.appState, + captureUpdate: CaptureUpdateAction.NEVER, + }); + + // Snapshot after + const after = this.app.scene.getElementsMapIncludingDeleted(); + + this.undoOverridePlanner.recordStep(before, after); + + // Record element diff into ledger + this.ledger.recordStep(before, after); + + // Accumulate appState intent + if (data.appState) { + this.accumulatedAppState = { + ...this.accumulatedAppState, + ...(data.appState as Record), + }; + } + } + + /** + * Partial element updates convenience API. + * + * Example: + * tx.updateElements({ + * elements: [ + * { id: "a", type: "rectangle", updates: { strokeColor: "#f00" } }, + * { id: "b", type: "rectangle", updates: { x: 10, y: 20 } }, + * ], + * }) + */ + updateElements(data: { + elements: readonly TransactionElementUpdate[]; + appState?: Pick | null; + }): void { + const updatesById = new Map(); + + for (const update of data.elements) { + updatesById.set(update.id, update); + } + + if (updatesById.size === 0) { + this.updateScene({ appState: data.appState }); + return; + } + + const nextElements = this.app.scene + .getElementsIncludingDeleted() + .map((element) => { + const update = updatesById.get(element.id); + if (!update) { + return element; + } + if (element.type !== update.type) { + throw new Error( + `Cannot apply tx.updateElements update for "${update.id}": expected "${update.type}", got "${element.type}".`, + ); + } + + type MatchingElement = TransactionElementOfType; + return newElementWith( + element as MatchingElement, + update.updates as ElementUpdate, + ); + }); + + this.updateScene({ + elements: nextElements, + appState: data.appState, + }); + } + + commit(options?: CommitOptions): TransactionSummary { + if (this.cachedSummary) { + return this.cachedSummary; + } + + this.manager.markTransactionCommitted(this.id); + let historyCommitted = false; + try { + historyCommitted = this.hasPendingWork() + ? this.commitHistoryEntry(options) + : false; + } finally { + this.closeTransaction(); + } + + const status = this.status; + this.cachedSummary = { + id: this.id, + status, + historyCommitted, + }; + this.ledger.clear(); + return this.cachedSummary; + } + + private hasPendingWork() { + return this.ledger.hasEntries() || this.hasAccumulatedAppStateIntent(); + } + + private hasAccumulatedAppStateIntent() { + return Object.keys(this.accumulatedAppState).length > 0; + } + + private commitHistoryEntry(options?: CommitOptions) { + const liveMap = this.app.scene.getElementsMapIncludingDeleted(); + const { elementsBefore, elementsAfter } = + this.ledger.buildSyntheticSnapshots(liveMap); + + const appStateDelta = this.resolveCommitAppStateDelta(options); + + return this.app.store.commitSyntheticIncrement({ + logicalBefore: { elements: elementsBefore }, + logicalAfter: { + elements: elementsAfter, + appState: appStateDelta, + }, + }); + } + + private resolveCommitAppStateDelta( + options?: CommitOptions, + ): Partial | undefined { + if (!this.hasAccumulatedAppStateIntent()) { + return undefined; + } + + if (!options?.resolveAppState) { + return this.accumulatedAppState as Partial; + } + + const context: AppStateResolverContext = { + initial: this.initialAppState, + accumulated: this.accumulatedAppState as Partial, + live: { ...this.app.store.snapshot.appState }, + }; + const resolved = options.resolveAppState(context); + + if (!resolved || Object.keys(resolved).length === 0) { + return undefined; + } + return resolved; + } + + cancel(): TransactionSummary { + if (this.cachedSummary) { + return this.cachedSummary; + } + + if (this.status === "active") { + this.manager.markTransactionCanceled(this.id); + } + + this.closeTransaction(); + const status = this.status; + this.cachedSummary = { + id: this.id, + status, + historyCommitted: false, + }; + this.ledger.clear(); + return this.cachedSummary; + } +} diff --git a/packages/excalidraw/transaction/types.ts b/packages/excalidraw/transaction/types.ts new file mode 100644 index 0000000000..293dd1493f --- /dev/null +++ b/packages/excalidraw/transaction/types.ts @@ -0,0 +1,83 @@ +import type { ElementUpdate } from "@excalidraw/element"; + +import type { + ExcalidrawElement, + ExcalidrawNonSelectionElement, +} from "@excalidraw/element/types"; + +import type { ObservedAppState } from "../types"; + +export type ElementPropName = Extract; + +export type TouchedElementProps = + | { kind: "all" } + | { kind: "partial"; props: Set }; + +export type ElementChange = { + id: ExcalidrawElement["id"]; + before: ExcalidrawElement | null; + after: ExcalidrawElement | null; + touchedProps: TouchedElementProps; +}; + +/** Per-element ledger record captured during a transaction session. */ +export type TransactionLedgerEntry = { + baselineElement: ExcalidrawElement | null; + targetElement: ExcalidrawElement | null; + touchedProps: TouchedElementProps; +}; + +/** Lifecycle state of a transaction. */ +export type TransactionStatus = "active" | "committed" | "canceled"; + +/** Per-element partial patch used by tx.updateElements(). */ +export type TransactionUpdatableElementType = + ExcalidrawNonSelectionElement["type"]; + +export type TransactionElementOfType< + TType extends TransactionUpdatableElementType, +> = Extract; + +export type TransactionElementUpdate< + TType extends TransactionUpdatableElementType = TransactionUpdatableElementType, +> = TType extends TransactionUpdatableElementType + ? { + id: ExcalidrawElement["id"]; + type: TType; + updates: ElementUpdate>; + } + : never; + +/** Final summary returned when a transaction is committed or canceled. */ +export type TransactionSummary = { + id: string; + status: TransactionStatus; + historyCommitted: boolean; +}; + +/** Three-way appState context provided to the resolver at commit time. */ +export type AppStateResolverContext = { + /** AppState snapshot captured when the transaction was created. */ + initial: Partial; + /** Merged appState intent from all updateScene calls during the transaction. */ + accumulated: Partial; + /** Current live appState at commit time. */ + live: Partial; +}; + +/** + * Caller-provided resolver that determines which appState changes are + * recorded in the history entry. + * + * Unlike elements — where per-property conflict detection works because + * element properties are largely independent — appState keys are often + * interdependent (e.g. selectedElementIds ↔ selectedGroupIds must stay + * consistent). The correct merge strategy therefore depends on the + * caller's semantic context, not on a generic policy. + * + * Return the appState delta to record in history, or undefined to skip + * appState changes entirely. + */ +export type AppStateResolver = ( + context: AppStateResolverContext, +) => Partial | undefined; diff --git a/packages/excalidraw/transaction/undoOverridePlanner.ts b/packages/excalidraw/transaction/undoOverridePlanner.ts new file mode 100644 index 0000000000..7a653aa32b --- /dev/null +++ b/packages/excalidraw/transaction/undoOverridePlanner.ts @@ -0,0 +1,283 @@ +import type { StoreDelta, TxUndoOverride } from "@excalidraw/element"; + +import type { ExcalidrawElement } from "@excalidraw/element/types"; + +import { + TX_UNDO_OVERRIDE_IGNORED_PROPS, + collectElementChanges, + getElementProp, + getElementPropEntries, + getUpdatedElementEntries, + hasTouchedProp, + isPartialTouchedProps, + isLedgerValueEqual, + serializeConsumedPropKey, + serializeIntermediateValue, + touchesWholeElement, + type ElementUpdatedEntry, +} from "./diff"; + +import type { + ElementPropName, + TouchedElementProps, + TransactionLedgerEntry, +} from "./types"; + +type TxUndoOverrideCandidate = Omit; + +/** + * Per-element-prop history of tx intermediate values. + * + * We keep: + * - the full sequence for exact deep-equality fallback + * - a serialized signature set for fast negative lookups + * - the latest value for the most common positive lookup path + */ +class TxIntermediateValueHistory { + private readonly values: unknown[] = []; + private readonly signatures = new Set(); + private latestValue: unknown; + private hasLatestValue = false; + + /** Appends a new intermediate value, skipping consecutive duplicates. */ + add(value: unknown) { + if (this.hasLatestValue && isLedgerValueEqual(this.latestValue, value)) { + return; + } + + this.values.push(value); + this.signatures.add(serializeIntermediateValue(value)); + this.latestValue = value; + this.hasLatestValue = true; + } + + /** Returns whether the candidate appeared in this tx prop history. */ + contains(candidate: unknown) { + const candidateSignature = serializeIntermediateValue(candidate); + if (!this.signatures.has(candidateSignature)) { + return false; + } + + if ( + this.hasLatestValue && + isLedgerValueEqual(this.latestValue, candidate) + ) { + return true; + } + + return this.values.some((value) => isLedgerValueEqual(value, candidate)); + } +} + +/** + * Tracks tx intermediate values and computes undo baseline override markers + * for durable user deltas recorded while tx is active. + * + * High-level flow: + * 1. `recordStep()` observes every in-tx scene mutation and records the + * intermediate value reached by each touched element prop. + * 2. When a durable user delta is about to be recorded, + * `collectCandidatesForDurableDelta()` checks whether that delta's + * deleted-baseline values match any tx intermediate value. + * 3. If they do, we emit override candidates so undo can restore the pre-tx + * baseline once the tx has ended. + * 4. `markConsumed()` ensures only the first polluted durable entry for a + * given element+prop gets patched; later user actions keep their own + * action-local undo baseline. + */ +export class TxUndoOverridePlanner { + private readonly intermediateValuesByElementProp = new Map< + string, + Map + >(); + private readonly consumedOverridePropKeys = new Set(); + + /** Resets planner state when the transaction finishes. */ + clear() { + this.consumedOverridePropKeys.clear(); + this.intermediateValuesByElementProp.clear(); + } + + /** Records per-prop intermediate values reached by one in-tx scene step. */ + recordStep( + before: ReadonlyMap, + after: ReadonlyMap, + ) { + for (const change of collectElementChanges(before, after)) { + const { id: elementId, after: afterElement, touchedProps } = change; + + if (!afterElement || !isPartialTouchedProps(touchedProps)) { + continue; + } + + for (const prop of touchedProps.props) { + this.recordIntermediateValue( + elementId, + prop, + getElementProp(afterElement, prop), + ); + } + } + } + + /** + * Collects override candidates for one durable user delta recorded while the + * tx is active. + */ + collectCandidatesForDurableDelta( + delta: StoreDelta, + getLedgerEntry: (elementId: string) => TransactionLedgerEntry | undefined, + reservedConsumedKeys: Set, + ): TxUndoOverrideCandidate[] { + const candidates: TxUndoOverrideCandidate[] = []; + + for (const [elementId, deltaEntry] of Object.entries( + getUpdatedElementEntries(delta), + )) { + const ledgerEntry = getLedgerEntry(elementId); + if (!ledgerEntry) { + continue; + } + + const elementCandidates = this.collectCandidatesForElement( + elementId, + deltaEntry, + ledgerEntry, + reservedConsumedKeys, + ); + if (elementCandidates.length > 0) { + candidates.push(...elementCandidates); + } + } + + return candidates; + } + + /** Evaluates one element's updated entry against the tx ledger snapshot. */ + private collectCandidatesForElement( + elementId: string, + deltaEntry: ElementUpdatedEntry, + ledgerEntry: TransactionLedgerEntry, + reservedConsumedKeys: Set, + ): TxUndoOverrideCandidate[] { + const { baselineElement, touchedProps } = ledgerEntry; + if (!baselineElement || touchesWholeElement(touchedProps)) { + return []; + } + + const candidates: TxUndoOverrideCandidate[] = []; + for (const [prop, deletedValue] of getElementPropEntries( + deltaEntry.deleted, + )) { + const candidate = this.createCandidateForProp({ + elementId, + prop, + deletedValue, + baselineElement, + touchedProps, + reservedConsumedKeys, + }); + if (candidate) { + candidates.push(candidate); + } + } + + return candidates; + } + + /** + * Returns an override candidate for one element+prop when the durable delta's + * deleted baseline was polluted by a tx intermediate value. + */ + private createCandidateForProp(args: { + elementId: string; + prop: ElementPropName; + deletedValue: unknown; + baselineElement: ExcalidrawElement; + touchedProps: TouchedElementProps; + reservedConsumedKeys: Set; + }): TxUndoOverrideCandidate | null { + const { elementId, prop, deletedValue, baselineElement, touchedProps } = + args; + + if ( + TX_UNDO_OVERRIDE_IGNORED_PROPS.has(prop) || + !hasTouchedProp(touchedProps, prop) + ) { + return null; + } + + const consumedPropKey = serializeConsumedPropKey(elementId, prop); + // Override only the first polluted user entry for this element+prop. + // Later user actions should keep action-local undo baselines. + if ( + this.consumedOverridePropKeys.has(consumedPropKey) || + args.reservedConsumedKeys.has(consumedPropKey) + ) { + return null; + } + + if (!this.matchesIntermediateValue(elementId, prop, deletedValue)) { + return null; + } + + return { + elementId, + prop, + expectedInsertedValue: deletedValue, + preTxBaselineValue: getElementProp(baselineElement, prop), + consumedKey: consumedPropKey, + }; + } + + /** Marks an element+prop override as consumed by an earlier durable entry. */ + markConsumed(consumedPropKey: string) { + this.consumedOverridePropKeys.add(consumedPropKey); + } + + /** Returns the per-prop history map for one element, creating it if needed. */ + private getOrCreatePropValues(elementId: string) { + const existing = this.intermediateValuesByElementProp.get(elementId); + if (existing) { + return existing; + } + + const created = new Map(); + this.intermediateValuesByElementProp.set(elementId, created); + return created; + } + + /** Appends one observed intermediate value for an element prop. */ + private recordIntermediateValue( + elementId: string, + prop: ElementPropName, + value: unknown, + ) { + const propValues = this.getOrCreatePropValues(elementId); + const history = propValues.get(prop); + if (history) { + history.add(value); + return; + } + + const nextHistory = new TxIntermediateValueHistory(); + nextHistory.add(value); + propValues.set(prop, nextHistory); + } + + /** Checks whether a durable delta baseline matches any tx intermediate value. */ + private matchesIntermediateValue( + elementId: string, + prop: ElementPropName, + candidate: unknown, + ) { + const history = this.intermediateValuesByElementProp + .get(elementId) + ?.get(prop); + if (!history) { + return false; + } + + return history.contains(candidate); + } +}