feat(editor): add ledger-backed transaction manager with isolated history lane
This commit is contained in:
@@ -0,0 +1,121 @@
|
||||
# Transactions
|
||||
|
||||
## Transaction + Ledger System
|
||||
|
||||
This system lets many canvas mutations over a time window (for example AI streaming patch/action/create) render immediately, while still producing a single undoable durable history entry.
|
||||
|
||||
- AI streaming typically emits many `NEVER` updates (for real-time visual feedback).
|
||||
- `NEVER` updates store snapshots, but does not write undo/redo history.
|
||||
- If we only do a final "one IMMEDIATELY at the end", we often miss the intended delta because snapshots may already be aligned.
|
||||
- Transaction + Ledger creates synthetic logical before/after snapshots, then commits one durable increment with correct undo semantics.
|
||||
|
||||
## Key Files
|
||||
|
||||
### `packages/excalidraw/transaction/manager.ts`
|
||||
|
||||
- `TransactionManager`: session entry point.
|
||||
- `TransactionSession`:
|
||||
- `apply()`: runs a mutation and records before/after diff automatically.
|
||||
- `capture()`: records caller-provided before/after snapshots.
|
||||
- `setAppStatePatch()`: optional appState before/after patch for synthetic history.
|
||||
- `commit()` / `cancel()`: finalize session and return an idempotent summary.
|
||||
|
||||
### `packages/element/src/transaction/ledger.ts`
|
||||
|
||||
- `TransactionLedger` keeps net mutation state across steps.
|
||||
- `recordStep()`: folds multiple updates into per-element baseline/target/touchedProps.
|
||||
- `buildSyntheticSnapshots()`: reconciles ledger state with live scene under merge policy and returns `logicalBefore` / `logicalAfter` for history commit.
|
||||
|
||||
### `packages/excalidraw/components/App.tsx`
|
||||
|
||||
- `commitSyntheticHistoryEntry()` converts `logicalBefore/logicalAfter` into `StoreSnapshot -> StoreChange -> StoreDelta`.
|
||||
- It does not mutate the live scene; it only commits a synthetic durable increment.
|
||||
- `appStateBefore/After` are overlaid on top of current observed appState baseline, so only provided keys participate in synthetic diff.
|
||||
|
||||
### `packages/element/src/store.ts`
|
||||
|
||||
- `enqueueIsolatedIncrement()` / `flushIsolatedIncrements()` / `requestIsolatedIncrementFlush()`.
|
||||
- Adds an isolated lane/queue for synthetic durable increments outside regular micro/macro capture flow.
|
||||
- Flush runs only when store is idle (`isIdle()`); otherwise it defers and retries next frame.
|
||||
|
||||
### `packages/excalidraw/types.ts` + `packages/element/src/transaction/types.ts` + `packages/excalidraw/transaction/types.ts`
|
||||
|
||||
- App/type wiring and merge-policy types used by the transaction system.
|
||||
|
||||
## How They Work Together
|
||||
|
||||
1. Caller opens a session (`app.transactionManager.open()`).
|
||||
2. AI steps keep rendering in real time (commonly via `NEVER`).
|
||||
3. Session records diffs continuously via `apply()` / `capture()` into ledger.
|
||||
4. On session `commit()`:
|
||||
- ledger builds `logicalBefore/logicalAfter`
|
||||
- `App.commitSyntheticHistoryEntry()` computes change/delta
|
||||
- `store.enqueueIsolatedIncrement()` pushes to isolated queue
|
||||
5. Store flushes isolated queue when idle and emits durable increment to undo stack.
|
||||
|
||||
Result:
|
||||
|
||||
- streaming visuals stay immediate;
|
||||
- history remains one entry per transaction;
|
||||
- synthetic durable commit stays isolated from ongoing regular capture batches.
|
||||
|
||||
## Why Isolated Lane/Queue Matters
|
||||
|
||||
Without isolated lane, synthetic increments may interleave with normal editor flows (pointer interactions, macro captures, pending micro actions), causing:
|
||||
|
||||
- polluted undo boundaries,
|
||||
- unstable commit timing,
|
||||
- non-deterministic behavior due to shared capture channel contention.
|
||||
|
||||
Core value of isolated lane:
|
||||
|
||||
- structural isolation from regular capture flow,
|
||||
- timing control (flush only on idle),
|
||||
- deferred retry when not idle.
|
||||
|
||||
## Sample Usage
|
||||
|
||||
### AI Short Session (compact multi-step)
|
||||
|
||||
```ts
|
||||
const { summary } = await app.transactionManager.run(async (tx) => {
|
||||
// run() auto-commits on success and auto-cancels on thrown errors.
|
||||
// Short session can still include multiple steps.
|
||||
await tx.apply(async () => executeUpdateStepA());
|
||||
await tx.apply(async () => executeUpdateStepB());
|
||||
});
|
||||
```
|
||||
|
||||
### AI Long Session (multi-step mixed flow)
|
||||
|
||||
```ts
|
||||
const session = app.transactionManager.open();
|
||||
|
||||
try {
|
||||
// step 1
|
||||
await session.apply(async () => executeUpdateStep1());
|
||||
|
||||
// step 2
|
||||
await session.apply(async () => executeCreateStep2());
|
||||
|
||||
// step 3
|
||||
await session.apply(async () => executeUpdateStep3());
|
||||
|
||||
// Optional: include selected appState keys in synthetic history
|
||||
session.setAppStatePatch({
|
||||
before: { selectedElementIds: prevSelectedIds },
|
||||
after: { selectedElementIds: nextSelectedIds },
|
||||
});
|
||||
|
||||
session.commit();
|
||||
} catch (error) {
|
||||
session.cancel();
|
||||
throw error;
|
||||
}
|
||||
```
|
||||
|
||||
## Notes
|
||||
|
||||
- `commit()` and `cancel()` are idempotent.
|
||||
- merge policy currently uses `conflictWinner` + `conflictScope`.
|
||||
- current implementation prioritizes semantic correctness and readability; fine-grained performance optimizations can be added later.
|
||||
@@ -95,6 +95,8 @@ export * from "./textMeasurements";
|
||||
export * from "./textWrapping";
|
||||
export * from "./transform";
|
||||
export * from "./transformHandles";
|
||||
export * from "./transaction/ledger";
|
||||
export * from "./transaction/types";
|
||||
export * from "./typeChecks";
|
||||
export * from "./utils";
|
||||
export * from "./zindex";
|
||||
|
||||
@@ -71,6 +71,12 @@ export const CaptureUpdateAction = {
|
||||
export type CaptureUpdateActionType = ValueOf<typeof CaptureUpdateAction>;
|
||||
|
||||
type MicroActionsQueue = (() => void)[];
|
||||
// Dedicated queue for synthetic durable increments that must commit
|
||||
// outside regular micro/macro capture batches.
|
||||
type IsolatedIncrementsQueue = Array<{
|
||||
change: StoreChange;
|
||||
delta: StoreDelta;
|
||||
}>;
|
||||
|
||||
/**
|
||||
* Store which captures the observed changes and emits them as `StoreIncrement` events.
|
||||
@@ -85,6 +91,9 @@ export class Store {
|
||||
|
||||
private scheduledMacroActions: Set<CaptureUpdateActionType> = new Set();
|
||||
private scheduledMicroActions: MicroActionsQueue = [];
|
||||
private isolatedIncrementsQueue: IsolatedIncrementsQueue = [];
|
||||
private isFlushingIsolatedIncrements = false;
|
||||
private cancelIsolatedFlushRetry: (() => void) | null = null;
|
||||
|
||||
private _snapshot = StoreSnapshot.empty();
|
||||
|
||||
@@ -197,6 +206,91 @@ export class Store {
|
||||
this.satisfiesScheduledActionsInvariant();
|
||||
// defensively reset all scheduled "macro" actions, possibly cleans up other runtime garbage
|
||||
this.scheduledMacroActions = new Set();
|
||||
if (this.hasPendingIsolatedIncrements()) {
|
||||
this.requestIsolatedIncrementFlush();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns true when no micro/macro actions are currently pending.
|
||||
*/
|
||||
public isIdle() {
|
||||
return (
|
||||
this.scheduledMicroActions.length === 0 &&
|
||||
this.scheduledMacroActions.size === 0
|
||||
);
|
||||
}
|
||||
|
||||
/** Queues a durable increment to be committed through the isolated lane. */
|
||||
public enqueueIsolatedIncrement(params: {
|
||||
change: StoreChange;
|
||||
delta: StoreDelta;
|
||||
}) {
|
||||
this.isolatedIncrementsQueue.push(params);
|
||||
this.requestIsolatedIncrementFlush();
|
||||
}
|
||||
|
||||
public hasPendingIsolatedIncrements() {
|
||||
return this.isolatedIncrementsQueue.length > 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Flushes isolated increments only when the regular store queue is idle.
|
||||
* Returns whether at least one isolated increment was committed.
|
||||
*/
|
||||
public flushIsolatedIncrements() {
|
||||
if (this.isFlushingIsolatedIncrements) {
|
||||
return false;
|
||||
}
|
||||
if (!this.hasPendingIsolatedIncrements() || !this.isIdle()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
this.isFlushingIsolatedIncrements = true;
|
||||
let flushedAny = false;
|
||||
try {
|
||||
while (this.hasPendingIsolatedIncrements() && this.isIdle()) {
|
||||
const entry = this.isolatedIncrementsQueue.shift()!;
|
||||
this.scheduleMicroAction({
|
||||
action: CaptureUpdateAction.IMMEDIATELY,
|
||||
change: entry.change,
|
||||
delta: entry.delta,
|
||||
});
|
||||
this.commit(
|
||||
this.app.scene.getElementsMapIncludingDeleted(),
|
||||
this.app.state,
|
||||
);
|
||||
flushedAny = true;
|
||||
}
|
||||
} finally {
|
||||
this.isFlushingIsolatedIncrements = false;
|
||||
}
|
||||
|
||||
return flushedAny;
|
||||
}
|
||||
|
||||
/**
|
||||
* Retries isolated queue flushing once store queues are idle again.
|
||||
* This keeps isolated durable commits out of active micro/macro batches.
|
||||
*/
|
||||
private scheduleDeferredIsolatedIncrementFlush() {
|
||||
if (this.cancelIsolatedFlushRetry) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Store is used in the browser editor runtime; defer one frame and retry.
|
||||
const rafId = requestAnimationFrame(() => {
|
||||
this.cancelIsolatedFlushRetry = null;
|
||||
this.requestIsolatedIncrementFlush();
|
||||
});
|
||||
this.cancelIsolatedFlushRetry = () => cancelAnimationFrame(rafId);
|
||||
}
|
||||
|
||||
private requestIsolatedIncrementFlush() {
|
||||
const flushed = this.flushIsolatedIncrements();
|
||||
if (!flushed && this.hasPendingIsolatedIncrements()) {
|
||||
this.scheduleDeferredIsolatedIncrementFlush();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -204,8 +298,15 @@ export class Store {
|
||||
* Clears the store instance.
|
||||
*/
|
||||
public clear(): void {
|
||||
if (this.cancelIsolatedFlushRetry) {
|
||||
this.cancelIsolatedFlushRetry();
|
||||
this.cancelIsolatedFlushRetry = null;
|
||||
}
|
||||
this.snapshot = StoreSnapshot.empty();
|
||||
this.scheduledMacroActions = new Set();
|
||||
this.scheduledMicroActions = [];
|
||||
this.isolatedIncrementsQueue = [];
|
||||
this.isFlushingIsolatedIncrements = false;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -0,0 +1,345 @@
|
||||
/**
|
||||
* Transaction-scoped mutation ledger:
|
||||
* - records net scene mutations across streamed steps
|
||||
* - reconstructs synthetic logical before/after snapshots
|
||||
* - feeds a single durable history entry at transaction end
|
||||
*/
|
||||
import type { Mutable } from "@excalidraw/common/utility-types";
|
||||
|
||||
import { deepCopyElement } from "../duplicate";
|
||||
|
||||
import type {
|
||||
ExcalidrawElement,
|
||||
OrderedExcalidrawElement,
|
||||
SceneElementsMap,
|
||||
} from "../types";
|
||||
import type { TransactionLedgerEntry, TransactionMergePolicy } from "./types";
|
||||
|
||||
const LEDGER_IGNORED_PROPS = new Set([
|
||||
"version",
|
||||
"versionNonce",
|
||||
"seed",
|
||||
"updated",
|
||||
"index",
|
||||
]);
|
||||
|
||||
type ElementRecord = Record<string, unknown>;
|
||||
|
||||
const isPlainObject = (value: unknown): value is Record<string, unknown> =>
|
||||
!!value && typeof value === "object" && !Array.isArray(value);
|
||||
|
||||
const getElementProp = (element: ExcalidrawElement, prop: string): unknown =>
|
||||
(element as ElementRecord)[prop];
|
||||
|
||||
const setOrderedElementProp = (
|
||||
element: Mutable<OrderedExcalidrawElement>,
|
||||
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;
|
||||
};
|
||||
|
||||
/** Clones a scene map so synthetic before/after edits never mutate live elements. */
|
||||
const cloneSceneElementsMap = (
|
||||
elements: ReadonlyMap<string, ExcalidrawElement>,
|
||||
): SceneElementsMap =>
|
||||
new Map(
|
||||
[...elements.entries()].map(([id, element]) => [
|
||||
id,
|
||||
deepCopyElement(element) as OrderedExcalidrawElement,
|
||||
]),
|
||||
) as SceneElementsMap;
|
||||
|
||||
/** Returns changed property names between two element snapshots. */
|
||||
const collectTouchedProps = (
|
||||
before: ExcalidrawElement | null,
|
||||
after: ExcalidrawElement | null,
|
||||
) => {
|
||||
if (!before || !after) {
|
||||
return new Set<string>(["*"]);
|
||||
}
|
||||
|
||||
const touchedProps = new Set<string>();
|
||||
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<string, ExcalidrawElement>,
|
||||
after: ReadonlyMap<string, ExcalidrawElement>,
|
||||
) => {
|
||||
const changedIds = new Set<string>();
|
||||
const candidateIds = new Set<string>([...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];
|
||||
};
|
||||
|
||||
/** Determines whether live conflicts should skip the whole updated element. */
|
||||
const shouldSkipUpdateElementOnLiveConflict = (
|
||||
entry: TransactionLedgerEntry,
|
||||
liveElement: ExcalidrawElement,
|
||||
policy: TransactionMergePolicy,
|
||||
) => {
|
||||
if (policy.conflictWinner === "transaction") {
|
||||
return false;
|
||||
}
|
||||
if (entry.touchedProps.has("*")) {
|
||||
return true;
|
||||
}
|
||||
return policy.conflictScope === "element";
|
||||
};
|
||||
|
||||
/**
|
||||
* Keeps transaction-level scene mutations and materializes synthetic snapshots
|
||||
* for a single durable history commit.
|
||||
*/
|
||||
export class TransactionLedger {
|
||||
private readonly entries = new Map<string, TransactionLedgerEntry>();
|
||||
|
||||
/** Whether the transaction has any net mutations to commit. */
|
||||
hasEntries() {
|
||||
return this.entries.size > 0;
|
||||
}
|
||||
|
||||
/** Records one mutation step into a net per-element ledger entry. */
|
||||
recordStep(
|
||||
before: ReadonlyMap<string, ExcalidrawElement>,
|
||||
after: ReadonlyMap<string, ExcalidrawElement>,
|
||||
) {
|
||||
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 logical before/after snapshots by reconciling transaction targets
|
||||
* with current live scene state under the selected merge policy.
|
||||
*/
|
||||
buildSyntheticSnapshots(
|
||||
live: ReadonlyMap<string, ExcalidrawElement>,
|
||||
mergePolicy: TransactionMergePolicy,
|
||||
) {
|
||||
const logicalBefore = cloneSceneElementsMap(live);
|
||||
const logicalAfter = cloneSceneElementsMap(live);
|
||||
|
||||
for (const [elementId, entry] of this.entries) {
|
||||
if (!entry.baselineElement) {
|
||||
const liveElement = live.get(elementId) ?? null;
|
||||
const targetElement = entry.targetElement;
|
||||
if (!targetElement) {
|
||||
continue;
|
||||
}
|
||||
if (
|
||||
mergePolicy.conflictWinner === "live" &&
|
||||
(!liveElement ||
|
||||
liveElement.isDeleted ||
|
||||
collectTouchedProps(targetElement, liveElement).size > 0)
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
logicalBefore.delete(elementId);
|
||||
logicalAfter.set(
|
||||
elementId,
|
||||
deepCopyElement(targetElement) as OrderedExcalidrawElement,
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!entry.targetElement) {
|
||||
const liveElement = live.get(elementId) ?? null;
|
||||
if (
|
||||
mergePolicy.conflictWinner === "live" &&
|
||||
liveElement &&
|
||||
!liveElement.isDeleted
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
logicalBefore.set(
|
||||
elementId,
|
||||
deepCopyElement(entry.baselineElement) as OrderedExcalidrawElement,
|
||||
);
|
||||
logicalAfter.delete(elementId);
|
||||
continue;
|
||||
}
|
||||
|
||||
const liveElement = live.get(elementId) ?? null;
|
||||
const targetElement = entry.targetElement;
|
||||
const baselineElement = entry.baselineElement;
|
||||
const logicalBeforeElement = logicalBefore.get(elementId);
|
||||
const logicalAfterElement = logicalAfter.get(elementId);
|
||||
|
||||
if (
|
||||
!liveElement ||
|
||||
!baselineElement ||
|
||||
!targetElement ||
|
||||
!logicalBeforeElement ||
|
||||
!logicalAfterElement
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (entry.touchedProps.has("*")) {
|
||||
const hasLiveConflict =
|
||||
collectTouchedProps(targetElement, liveElement).size > 0;
|
||||
if (mergePolicy.conflictWinner === "live" && hasLiveConflict) {
|
||||
continue;
|
||||
}
|
||||
logicalBefore.set(
|
||||
elementId,
|
||||
deepCopyElement(baselineElement) as OrderedExcalidrawElement,
|
||||
);
|
||||
logicalAfter.set(
|
||||
elementId,
|
||||
deepCopyElement(targetElement) as OrderedExcalidrawElement,
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (
|
||||
shouldSkipUpdateElementOnLiveConflict(entry, liveElement, mergePolicy)
|
||||
) {
|
||||
let hasConflict = false;
|
||||
for (const prop of entry.touchedProps) {
|
||||
const liveValue = getElementProp(liveElement, prop);
|
||||
const targetValue = getElementProp(targetElement, prop);
|
||||
if (!isLedgerValueEqual(liveValue, targetValue)) {
|
||||
hasConflict = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (hasConflict) {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
const mutableBefore =
|
||||
logicalBeforeElement as Mutable<OrderedExcalidrawElement>;
|
||||
const mutableAfter =
|
||||
logicalAfterElement as Mutable<OrderedExcalidrawElement>;
|
||||
|
||||
let appliedProps = 0;
|
||||
for (const prop of entry.touchedProps) {
|
||||
const liveValue = getElementProp(liveElement, prop);
|
||||
const targetValue = getElementProp(targetElement, prop);
|
||||
const hasConflict = !isLedgerValueEqual(liveValue, targetValue);
|
||||
if (mergePolicy.conflictWinner === "live" && hasConflict) {
|
||||
continue;
|
||||
}
|
||||
|
||||
setOrderedElementProp(
|
||||
mutableBefore,
|
||||
prop,
|
||||
getElementProp(baselineElement, prop),
|
||||
);
|
||||
setOrderedElementProp(mutableAfter, prop, targetValue);
|
||||
appliedProps += 1;
|
||||
}
|
||||
|
||||
if (appliedProps > 0) {
|
||||
mutableBefore.version = baselineElement.version;
|
||||
mutableBefore.versionNonce = baselineElement.versionNonce;
|
||||
mutableAfter.version = targetElement.version;
|
||||
mutableAfter.versionNonce = targetElement.versionNonce;
|
||||
}
|
||||
}
|
||||
|
||||
return { logicalBefore, logicalAfter };
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
import type { ExcalidrawElement } from "../types";
|
||||
|
||||
/** Which side wins when transaction output and live scene diverge. */
|
||||
export type ConflictWinner = "live" | "transaction";
|
||||
|
||||
/** Conflict granularity used by the merge policy. */
|
||||
export type ConflictScope = "prop" | "element";
|
||||
|
||||
/** Merge policy used when building synthetic before/after snapshots. */
|
||||
export type TransactionMergePolicy = {
|
||||
conflictWinner: ConflictWinner;
|
||||
conflictScope: ConflictScope;
|
||||
};
|
||||
|
||||
export const DEFAULT_TRANSACTION_MERGE_POLICY: TransactionMergePolicy = {
|
||||
conflictWinner: "live",
|
||||
conflictScope: "prop",
|
||||
};
|
||||
|
||||
/** Per-element ledger record captured during a transaction session. */
|
||||
export type TransactionLedgerEntry = {
|
||||
baselineElement: ExcalidrawElement | null;
|
||||
targetElement: ExcalidrawElement | null;
|
||||
touchedProps: Set<string>;
|
||||
};
|
||||
@@ -239,7 +239,9 @@ import {
|
||||
hitElementBoundingBox,
|
||||
isLineElement,
|
||||
isSimpleArrow,
|
||||
StoreChange,
|
||||
StoreDelta,
|
||||
StoreSnapshot,
|
||||
type ApplyToOptions,
|
||||
positionElementsOnGrid,
|
||||
calculateFixedPointForNonElbowArrowBinding,
|
||||
@@ -361,6 +363,7 @@ import { restoreAppState, restoreElements } from "../data/restore";
|
||||
import { getCenter, getDistance } from "../gesture";
|
||||
import { History } from "../history";
|
||||
import { defaultLang, getLanguage, languages, setLanguage, t } from "../i18n";
|
||||
import { TransactionManager } from "../transaction/manager";
|
||||
|
||||
import {
|
||||
calculateScrollCenter,
|
||||
@@ -488,6 +491,7 @@ import type {
|
||||
CollaboratorPointer,
|
||||
ToolType,
|
||||
OnUserFollowedPayload,
|
||||
ObservedAppState,
|
||||
UnsubscribeCallback,
|
||||
EmbedsValidationStatus,
|
||||
ElementsPendingErasure,
|
||||
@@ -637,6 +641,7 @@ class App extends React.Component<AppProps, AppState> {
|
||||
public library: AppClassProperties["library"];
|
||||
public libraryItemsFromStorage: LibraryItems | undefined;
|
||||
public id: string;
|
||||
public transactionManager: TransactionManager;
|
||||
private store: Store;
|
||||
private history: History;
|
||||
public excalidrawContainerValue: {
|
||||
@@ -832,6 +837,7 @@ class App extends React.Component<AppProps, AppState> {
|
||||
|
||||
this.store = new Store(this);
|
||||
this.history = new History(this.store);
|
||||
this.transactionManager = new TransactionManager(this);
|
||||
|
||||
this.excalidrawContainerValue = {
|
||||
container: this.excalidrawContainerRef.current,
|
||||
@@ -4602,6 +4608,59 @@ class App extends React.Component<AppProps, AppState> {
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* Records a synthetic durable history entry without changing the live scene.
|
||||
* Useful for batching external streams into a single undoable entry after
|
||||
* their visual state has already been applied with NEVER.
|
||||
*
|
||||
* appState patches are merged on top of the current observed appState
|
||||
* baseline. This keeps unspecified observed fields stable while only the
|
||||
* provided appState keys participate in the synthetic before/after diff.
|
||||
*/
|
||||
public commitSyntheticHistoryEntry = (params: {
|
||||
logicalBefore: SceneElementsMap;
|
||||
logicalAfter: SceneElementsMap;
|
||||
appStateBefore?: Partial<ObservedAppState>;
|
||||
appStateAfter?: Partial<ObservedAppState>;
|
||||
}): boolean => {
|
||||
const { logicalBefore, logicalAfter, appStateBefore, appStateAfter } =
|
||||
params;
|
||||
const observedAppStateBaseline = this.store.snapshot.appState;
|
||||
const syntheticAppStateBefore = appStateBefore
|
||||
? { ...observedAppStateBaseline, ...appStateBefore }
|
||||
: observedAppStateBaseline;
|
||||
const syntheticAppStateAfter = appStateAfter
|
||||
? { ...observedAppStateBaseline, ...appStateAfter }
|
||||
: observedAppStateBaseline;
|
||||
const didAppStateChange = Boolean(appStateBefore || appStateAfter);
|
||||
const prevSnapshot = StoreSnapshot.create(
|
||||
logicalBefore,
|
||||
syntheticAppStateBefore,
|
||||
{
|
||||
didElementsChange: true,
|
||||
didAppStateChange,
|
||||
},
|
||||
);
|
||||
const nextSnapshot = StoreSnapshot.create(
|
||||
logicalAfter,
|
||||
syntheticAppStateAfter,
|
||||
{
|
||||
didElementsChange: true,
|
||||
didAppStateChange,
|
||||
},
|
||||
);
|
||||
const change = StoreChange.create(prevSnapshot, nextSnapshot);
|
||||
const delta = StoreDelta.calculate(prevSnapshot, nextSnapshot);
|
||||
|
||||
if (delta.isEmpty()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
this.store.enqueueIsolatedIncrement({ change, delta });
|
||||
|
||||
return true;
|
||||
};
|
||||
|
||||
public mutateElement = <TElement extends Mutable<ExcalidrawElement>>(
|
||||
element: TElement,
|
||||
updates: ElementUpdate<TElement>,
|
||||
|
||||
@@ -0,0 +1,241 @@
|
||||
import { randomId } from "@excalidraw/common";
|
||||
import {
|
||||
DEFAULT_TRANSACTION_MERGE_POLICY,
|
||||
TransactionLedger,
|
||||
collectChangedElementIds,
|
||||
} from "@excalidraw/element";
|
||||
import { deepCopyElement } from "@excalidraw/element";
|
||||
|
||||
import type { TransactionMergePolicy } from "@excalidraw/element";
|
||||
import type { ExcalidrawElement } from "@excalidraw/element/types";
|
||||
|
||||
import type { AppClassProperties } from "../types";
|
||||
import type {
|
||||
TransactionAppStateSnapshot,
|
||||
TransactionMutationResult,
|
||||
TransactionStatus,
|
||||
TransactionSummary,
|
||||
} from "./types";
|
||||
|
||||
type ApplyOptions<T> = {
|
||||
isApplied?: (value: T, changedElementIds: readonly string[]) => boolean;
|
||||
reasonWhenSkipped?: (value: T) => string | undefined;
|
||||
};
|
||||
|
||||
type TransactionFinalState = "finalized" | "canceled";
|
||||
|
||||
/** Takes a point-in-time deep snapshot so later mutation diffing stays stable. */
|
||||
const snapshotSceneElementsById = (app: AppClassProperties) => {
|
||||
const snapshot = new Map<string, ExcalidrawElement>();
|
||||
for (const element of app.scene.getElementsIncludingDeleted()) {
|
||||
snapshot.set(element.id, deepCopyElement(element));
|
||||
}
|
||||
return snapshot;
|
||||
};
|
||||
|
||||
/** App-level transaction session that records mutations and commits one synthetic history entry. */
|
||||
export class TransactionSession {
|
||||
public readonly id = `tx-${randomId()}`;
|
||||
|
||||
private readonly app: AppClassProperties;
|
||||
private readonly mergePolicy: TransactionMergePolicy;
|
||||
private readonly ledger = new TransactionLedger();
|
||||
private readonly touchedElementIds = new Set<string>();
|
||||
|
||||
private appStateSnapshot: TransactionAppStateSnapshot | null = null;
|
||||
private appliedMutations = 0;
|
||||
private historyCommitted = false;
|
||||
private statusValue: TransactionStatus = "active";
|
||||
private cachedSummary: TransactionSummary | null = null;
|
||||
|
||||
constructor(
|
||||
app: AppClassProperties,
|
||||
mergePolicy?: Partial<TransactionMergePolicy>,
|
||||
) {
|
||||
this.app = app;
|
||||
this.mergePolicy = {
|
||||
...DEFAULT_TRANSACTION_MERGE_POLICY,
|
||||
...mergePolicy,
|
||||
};
|
||||
}
|
||||
|
||||
get status() {
|
||||
return this.statusValue;
|
||||
}
|
||||
|
||||
/** Returns a standardized skipped result once the session is no longer active. */
|
||||
private rejectIfInactive<T>(): TransactionMutationResult<T> | null {
|
||||
if (this.statusValue === "active") {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
applied: false,
|
||||
changedElementIds: [],
|
||||
reason: `Transaction already ${this.statusValue}.`,
|
||||
};
|
||||
}
|
||||
|
||||
/** Computes changed element ids between two snapshots. */
|
||||
private getChangedElementIds(
|
||||
before: ReadonlyMap<string, ExcalidrawElement>,
|
||||
after: ReadonlyMap<string, ExcalidrawElement>,
|
||||
) {
|
||||
return collectChangedElementIds(before, after);
|
||||
}
|
||||
|
||||
/** Persists one mutation into ledger state and session-level counters. */
|
||||
private recordMutation(
|
||||
before: ReadonlyMap<string, ExcalidrawElement>,
|
||||
after: ReadonlyMap<string, ExcalidrawElement>,
|
||||
changedElementIds: readonly string[],
|
||||
applied: boolean,
|
||||
) {
|
||||
if (applied) {
|
||||
for (const changedId of changedElementIds) {
|
||||
this.touchedElementIds.add(changedId);
|
||||
}
|
||||
this.ledger.recordStep(before, after);
|
||||
this.appliedMutations += 1;
|
||||
}
|
||||
}
|
||||
|
||||
/** Executes one mutation function and records the resulting scene diff. */
|
||||
async apply<T>(
|
||||
mutate: () => T | Promise<T>,
|
||||
options?: ApplyOptions<T>,
|
||||
): Promise<TransactionMutationResult<T>> {
|
||||
const inactiveResult = this.rejectIfInactive<T>();
|
||||
if (inactiveResult) {
|
||||
return inactiveResult;
|
||||
}
|
||||
|
||||
const before = snapshotSceneElementsById(this.app);
|
||||
const value = await mutate();
|
||||
const after = this.app.scene.getElementsMapIncludingDeleted();
|
||||
const changedElementIds = this.getChangedElementIds(before, after);
|
||||
const applied = options?.isApplied
|
||||
? options.isApplied(value, changedElementIds)
|
||||
: changedElementIds.length > 0;
|
||||
|
||||
this.recordMutation(before, after, changedElementIds, applied);
|
||||
|
||||
return {
|
||||
value,
|
||||
applied,
|
||||
changedElementIds,
|
||||
reason: !applied ? options?.reasonWhenSkipped?.(value) : undefined,
|
||||
};
|
||||
}
|
||||
|
||||
/** Records a caller-provided before/after snapshot pair into the session ledger. */
|
||||
capture(
|
||||
before: ReadonlyMap<string, ExcalidrawElement>,
|
||||
after: ReadonlyMap<string, ExcalidrawElement>,
|
||||
): TransactionMutationResult<void> {
|
||||
const inactiveResult = this.rejectIfInactive<void>();
|
||||
if (inactiveResult) {
|
||||
return inactiveResult;
|
||||
}
|
||||
|
||||
const changedElementIds = this.getChangedElementIds(before, after);
|
||||
this.recordMutation(
|
||||
before,
|
||||
after,
|
||||
changedElementIds,
|
||||
changedElementIds.length > 0,
|
||||
);
|
||||
|
||||
return {
|
||||
applied: changedElementIds.length > 0,
|
||||
changedElementIds,
|
||||
};
|
||||
}
|
||||
|
||||
/** Stores optional observed appState before/after for synthetic history commit. */
|
||||
setAppStatePatch(snapshot: TransactionAppStateSnapshot | null) {
|
||||
this.appStateSnapshot = snapshot;
|
||||
}
|
||||
|
||||
/** Finalizes once and memoizes the resulting summary for idempotent calls. */
|
||||
private complete(state: TransactionFinalState): TransactionSummary {
|
||||
if (this.cachedSummary) {
|
||||
return this.cachedSummary;
|
||||
}
|
||||
|
||||
if (this.ledger.hasEntries()) {
|
||||
const liveMap = this.app.scene.getElementsMapIncludingDeleted();
|
||||
const { logicalBefore, logicalAfter } =
|
||||
this.ledger.buildSyntheticSnapshots(liveMap, this.mergePolicy);
|
||||
this.historyCommitted = this.app.commitSyntheticHistoryEntry({
|
||||
logicalBefore,
|
||||
logicalAfter,
|
||||
appStateBefore: this.appStateSnapshot?.before,
|
||||
appStateAfter: this.appStateSnapshot?.after,
|
||||
});
|
||||
}
|
||||
|
||||
this.statusValue = state;
|
||||
this.cachedSummary = {
|
||||
id: this.id,
|
||||
state,
|
||||
appliedMutations: this.appliedMutations,
|
||||
touchedElementIds: [...this.touchedElementIds],
|
||||
historyCommitted: this.historyCommitted,
|
||||
};
|
||||
return this.cachedSummary;
|
||||
}
|
||||
|
||||
/** Commits the session and returns a stable summary on repeated calls. */
|
||||
commit() {
|
||||
if (this.statusValue === "active") {
|
||||
return this.complete("finalized");
|
||||
}
|
||||
return this.complete(this.statusValue);
|
||||
}
|
||||
|
||||
/** Cancels the session while preserving already-applied visual mutations. */
|
||||
cancel() {
|
||||
if (this.statusValue === "active") {
|
||||
return this.complete("canceled");
|
||||
}
|
||||
return this.complete(this.statusValue);
|
||||
}
|
||||
}
|
||||
|
||||
/** Factory/executor facade used by app features to open and run transaction sessions. */
|
||||
export class TransactionManager {
|
||||
private readonly app: AppClassProperties;
|
||||
|
||||
constructor(app: AppClassProperties) {
|
||||
this.app = app;
|
||||
}
|
||||
|
||||
/** Opens a new transaction session with optional merge-policy overrides. */
|
||||
open(input?: { mergePolicy?: Partial<TransactionMergePolicy> }) {
|
||||
return new TransactionSession(this.app, input?.mergePolicy);
|
||||
}
|
||||
|
||||
/** Runs work inside one session and always attempts cleanup on failure. */
|
||||
async run<TResult>(
|
||||
work: (session: TransactionSession) => TResult | Promise<TResult>,
|
||||
input?: { mergePolicy?: Partial<TransactionMergePolicy> },
|
||||
) {
|
||||
const session = this.open(input);
|
||||
try {
|
||||
const result = await work(session);
|
||||
const summary = session.commit();
|
||||
return { result, summary };
|
||||
} catch (error) {
|
||||
// Preserve the original work() failure even if cancel path fails.
|
||||
try {
|
||||
session.cancel();
|
||||
} catch (cancelError) {
|
||||
console.error("Failed to cancel transaction after run() failure.", {
|
||||
error,
|
||||
cancelError,
|
||||
});
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
import type { ObservedAppState } from "../types";
|
||||
|
||||
/** Lifecycle state of a transaction session. */
|
||||
export type TransactionStatus = "active" | "finalized" | "canceled";
|
||||
|
||||
/** Optional appState patch to include in synthetic history before/after snapshots. */
|
||||
export type TransactionAppStateSnapshot = {
|
||||
before: Partial<ObservedAppState>;
|
||||
after: Partial<ObservedAppState>;
|
||||
};
|
||||
|
||||
/** Result returned by one recorded mutation attempt inside a transaction. */
|
||||
export type TransactionMutationResult<T> = {
|
||||
value?: T;
|
||||
applied: boolean;
|
||||
changedElementIds: readonly string[];
|
||||
reason?: string;
|
||||
};
|
||||
|
||||
/** Final summary returned when a transaction is committed or canceled. */
|
||||
export type TransactionSummary = {
|
||||
id: string;
|
||||
state: "finalized" | "canceled";
|
||||
appliedMutations: number;
|
||||
touchedElementIds: readonly string[];
|
||||
historyCommitted: boolean;
|
||||
};
|
||||
@@ -796,7 +796,9 @@ export type AppClassProperties = {
|
||||
files: BinaryFiles;
|
||||
editorInterface: App["editorInterface"];
|
||||
scene: App["scene"];
|
||||
transactionManager: App["transactionManager"];
|
||||
syncActionResult: App["syncActionResult"];
|
||||
commitSyntheticHistoryEntry: App["commitSyntheticHistoryEntry"];
|
||||
fonts: App["fonts"];
|
||||
pasteFromClipboard: App["pasteFromClipboard"];
|
||||
id: App["id"];
|
||||
|
||||
Reference in New Issue
Block a user