Compare commits

..

4 Commits

Author SHA1 Message Date
dwelle 88dc25865b wip2 2026-04-28 12:21:56 +02:00
dwelle 1d4e2731cf wip 2026-04-28 12:18:18 +02:00
Márk Tolmács 2e1a529c67 fix(editor): remove extremely large arrows on restore (#11235)
* fix: Temp fix for elbow arrow at restore

Co-authored-by: Copilot <copilot@github.com>
Signed-off-by: Mark Tolmacs <mark@lazycat.hu>

* fix: Speculative fixes to avoid Infinity

Co-authored-by: Copilot <copilot@github.com>
Signed-off-by: Mark Tolmacs <mark@lazycat.hu>

* validate/remove arrow size after point normalization & move binding repairs back

* validate even simple arrows

* remove x/y check

* remove duplicate constant

---------

Signed-off-by: Mark Tolmacs <mark@lazycat.hu>
Co-authored-by: Copilot <copilot@github.com>
Co-authored-by: dwelle <5153846+dwelle@users.noreply.github.com>
2026-04-25 12:03:50 +02:00
David Luzar b1c6bfcf40 chore(docker): bump node (#11208) 2026-04-20 22:07:00 +02:00
23 changed files with 555 additions and 4214 deletions
+1 -1
View File
@@ -1,4 +1,4 @@
FROM node:18-bullseye
FROM node:24-bullseye
# Vite wants to open the browser using `open`, so we
# need to install those utils.
+2 -2
View File
@@ -1,4 +1,4 @@
FROM --platform=${BUILDPLATFORM} node:18 AS build
FROM --platform=${BUILDPLATFORM} node:24 AS build
WORKDIR /opt/node_app
@@ -13,7 +13,7 @@ ARG NODE_ENV=production
RUN npm_config_target_arch=${TARGETARCH} yarn build:app:docker
FROM --platform=${TARGETPLATFORM} nginx:1.27-alpine
FROM nginx:1.27-alpine
COPY --from=build /opt/node_app/excalidraw-app/build /usr/share/nginx/html
-125
View File
@@ -1,125 +0,0 @@
# Transactions
Transactions let many live scene updates render immediately while still committing a single durable history entry.
Typical use case:
- AI streaming emits many `NEVER` updates for real-time feedback.
- Those updates advance the live scene and store snapshot, but do not create undo history.
- A transaction records the intended logical delta separately, then commits one synthetic durable increment at the end.
## Key Files
### `packages/excalidraw/transaction/`
- `transaction.ts`
- `Transaction` is the per-tx instance.
- `updateScene()` applies live updates with `captureUpdate: NEVER`.
- `updateElements()` is a typed partial-patch helper over `updateScene()`.
- `commit()` builds synthetic snapshots and commits one durable entry.
- `cancel()` ends the tx without committing history.
- `ledger.ts`
- `TransactionLedger` keeps per-element baseline, target, and touched props across tx steps.
- `buildSyntheticSnapshots()` reconciles tx intent with the live scene using `live-wins-per-prop`.
- `undoOverridePlanner.ts`
- Tracks tx intermediate values.
- Produces undo override candidates for regular user history entries that were recorded while a tx was active.
- `manager.ts`
- Owns tx lifecycle state and active-tx priority.
- Injects tx undo markers into durable deltas at record time.
- Resolves effective history deltas at undo/redo time.
- `diff.ts` / `types.ts`
- Shared diff helpers and transaction-specific types.
### `packages/element/src/store.ts`
- `store.commitSyntheticIncrement()`
- Builds `StoreSnapshot -> StoreChange -> StoreDelta` from logical before/after snapshots.
- Commits the synthetic durable increment immediately through an isolated path.
- Does not flush unrelated pending micro actions.
- `StoreDelta.markers`
- Carries extra history markers such as `txUndoOverrides`.
### `packages/excalidraw/history.ts`
- `onBeforeRecord()`
- Lets the transaction manager attach markers before a durable delta becomes a history entry.
- `setEffectiveDeltaResolver()`
- Lets the transaction manager rewrite a history delta at undo/redo time before it is applied.
## Flow
1. Caller creates a transaction.
2. `tx.updateScene()` applies live updates with `NEVER`.
3. The ledger records net element changes; appState intent is accumulated separately.
4. Regular user durable edits that happen while the tx is active may receive tx undo markers.
5. `tx.commit()` asks the ledger for logical before/after snapshots and calls `store.commitSyntheticIncrement()`.
6. Undo/redo later uses the manager's effective-delta resolver to decide whether ended tx markers should patch the baseline of a regular history entry.
Result:
- live rendering stays immediate;
- one tx still produces one durable history entry;
- interleaved regular edits can undo back to the correct pre-tx baseline.
## Undo Examples
### Interleaved Regular Edit
1. Baseline: `rect = (x: 0, stroke: black)`
2. Tx updates it live to `(x: 200, stroke: red)` with `NEVER`
3. User makes a regular durable edit: `(x: 200, stroke: purple)`
4. Tx commits; live stays purple
The user history entry was recorded while the tx was active, so its local baseline is `red`.
If undo later runs after the tx has ended, restoring `red` is wrong: the correct pre-tx baseline is `black`.
To fix this:
- record-time attaches a tx undo marker to the user history entry
- perform-time checks whether the tx has already ended
- if ended, the effective delta is patched so undo restores `black` instead of `red`
### Active Tx vs Ended Tx
Using the same example:
- if the user undoes while the tx is still active, the marker stays inactive and undo restores the tx intermediate value (`red`)
- if the user undoes after the tx has ended, the marker becomes active and undo restores the pre-tx baseline (`black`)
So markers are recorded early, but applied conditionally.
## AppState
Elements use ledger snapshots. AppState does not.
The transaction instead keeps:
- `initialAppState`: observed appState at tx start
- `accumulatedAppState`: merged caller intent from `tx.updateScene({ appState })`
At commit, the transaction computes the final appState patch and passes it into `store.commitSyntheticIncrement()`.
## Example
```ts
const tx = app.transactionManager.create();
tx.updateScene({ elements: updatedElementsA });
tx.updateScene({ elements: updatedElementsB });
tx.updateElements({
elements: [
{ id: "rect-1", type: "rectangle", updates: { strokeColor: "#f00" } },
{ id: "rect-2", type: "rectangle", updates: { x: 120 } },
],
});
tx.commit();
```
## Notes
- `commit()` and `cancel()` are idempotent.
- `updateScene()` and `updateElements()` throw after the tx has ended.
- Conflict handling inside synthetic snapshots is fixed to `live-wins-per-prop`.
- Multi-tx interleaving is supported through manager-owned lifecycle tracking and active-tx priority.
+6 -4
View File
@@ -1943,9 +1943,9 @@ export const calculateFixedPointForElbowArrowBinding = (
return {
fixedPoint: normalizeFixedPoint([
(nonRotatedSnappedGlobalPoint[0] - hoveredElement.x) /
hoveredElement.width,
Math.max(hoveredElement.width, PRECISION),
(nonRotatedSnappedGlobalPoint[1] - hoveredElement.y) /
hoveredElement.height,
Math.max(hoveredElement.height, PRECISION),
]),
};
};
@@ -1976,9 +1976,11 @@ export const calculateFixedPointForNonElbowArrowBinding = (
// Calculate the ratio relative to the element's bounds
const fixedPointX =
(nonRotatedPoint[0] - hoveredElement.x) / hoveredElement.width;
(nonRotatedPoint[0] - hoveredElement.x) /
Math.max(hoveredElement.width, PRECISION);
const fixedPointY =
(nonRotatedPoint[1] - hoveredElement.y) / hoveredElement.height;
(nonRotatedPoint[1] - hoveredElement.y) /
Math.max(hoveredElement.height, PRECISION);
return {
fixedPoint: normalizeFixedPoint([fixedPointX, fixedPointY]),
+2 -2
View File
@@ -2124,8 +2124,8 @@ const normalizeArrowElementUpdate = (
offsetY < -MAX_POS ||
offsetY > MAX_POS ||
offsetX + points[points.length - 1][0] < -MAX_POS ||
offsetY + points[points.length - 1][0] > MAX_POS ||
offsetX + points[points.length - 1][1] < -MAX_POS ||
offsetX + points[points.length - 1][0] > MAX_POS ||
offsetY + points[points.length - 1][1] < -MAX_POS ||
offsetY + points[points.length - 1][1] > MAX_POS
) {
console.error(
+9 -189
View File
@@ -111,67 +111,6 @@ export class Store {
this.scheduleAction(CaptureUpdateAction.IMMEDIATELY);
}
/**
* Commits a synthetic durable history entry without changing the live scene.
*
* Builds StoreSnapshot → StoreChange → StoreDelta from the provided
* logical before/after element maps and optional appState patches, then
* emits the resulting durable increment immediately through an isolated
* path that does not flush pending micro actions.
*
* appState patches are merged on top of the current observed appState
* baseline so only the provided keys participate in the synthetic diff.
*/
public commitSyntheticIncrement(params: {
logicalBefore: {
elements: SceneElementsMap;
appState?: Partial<ObservedAppState>;
};
logicalAfter: {
elements: SceneElementsMap;
appState?: Partial<ObservedAppState>;
};
}): boolean {
const { logicalBefore, logicalAfter } = params;
const observedAppStateBaseline = this.snapshot.appState;
const syntheticAppStateBefore = logicalBefore.appState
? { ...observedAppStateBaseline, ...logicalBefore.appState }
: observedAppStateBaseline;
const syntheticAppStateAfter = logicalAfter.appState
? { ...observedAppStateBaseline, ...logicalAfter.appState }
: observedAppStateBaseline;
const didAppStateChange = Delta.isRightDifferent(
syntheticAppStateBefore,
syntheticAppStateAfter,
);
const prevSnapshot = StoreSnapshot.create(
logicalBefore.elements,
syntheticAppStateBefore,
{
didElementsChange: true,
didAppStateChange,
},
);
const nextSnapshot = StoreSnapshot.create(
logicalAfter.elements,
syntheticAppStateAfter,
{
didElementsChange: true,
didAppStateChange,
},
);
const change = StoreChange.create(prevSnapshot, nextSnapshot);
const delta = StoreDelta.calculate(prevSnapshot, nextSnapshot);
if (delta.isEmpty()) {
return false;
}
this.emitIsolatedDurableIncrement(change, delta);
return true;
}
/**
* Schedule special "micro" actions, to-be executed before the next commit, before it executes a scheduled "macro" action.
*/
@@ -267,7 +206,6 @@ export class Store {
public clear(): void {
this.snapshot = StoreSnapshot.empty();
this.scheduledMacroActions = new Set();
this.scheduledMicroActions = [];
}
/**
@@ -307,24 +245,6 @@ export class Store {
}
}
/**
* Emits a synthetic durable increment immediately without draining the
* regular micro-action queue.
*/
private emitIsolatedDurableIncrement(change: StoreChange, delta: StoreDelta) {
const nextSnapshot = this.applyChangeToSnapshot(change);
if (!nextSnapshot) {
return;
}
try {
this.emitDurableIncrement(nextSnapshot, change, delta);
} finally {
this.snapshot = nextSnapshot;
}
}
/**
* Performs change calculation and emits an ephemeral increment.
*
@@ -571,89 +491,6 @@ export class EphemeralIncrement extends StoreIncrement {
}
}
/**
* Serializable delta marker used to recover pre-tx undo baselines
* without rewriting history stack entries.
*/
export type TxUndoOverride = {
txId: string;
elementId: string;
prop: string;
expectedInsertedValue: unknown;
preTxBaselineValue: unknown;
consumedKey: string;
};
export type StoreDeltaMarkers = {
txUndoOverrides?: TxUndoOverride[];
};
const cloneTxUndoOverride = (override: TxUndoOverride): TxUndoOverride => ({
...override,
});
const cloneStoreDeltaMarkers = (
markers: StoreDeltaMarkers | undefined,
): StoreDeltaMarkers | undefined =>
markers
? {
...markers,
txUndoOverrides: markers.txUndoOverrides?.map(cloneTxUndoOverride),
}
: undefined;
const pruneStoreDeltaMarkers = (delta: StoreDelta) => {
const txUndoOverrides = delta.markers?.txUndoOverrides;
if (!txUndoOverrides || txUndoOverrides.length === 0) {
return;
}
const updatedEntries = delta.elements.updated as Record<
string,
Delta<Record<string, unknown>>
>;
const nextTxUndoOverrides = txUndoOverrides.filter((override) => {
const updatedEntry = updatedEntries[override.elementId];
return (
!!updatedEntry &&
Object.prototype.hasOwnProperty.call(updatedEntry.inserted, override.prop)
);
});
if (nextTxUndoOverrides.length === txUndoOverrides.length) {
return;
}
if (nextTxUndoOverrides.length === 0) {
delta.markers = undefined;
return;
}
delta.markers = {
...delta.markers,
txUndoOverrides: nextTxUndoOverrides.map(cloneTxUndoOverride),
};
};
export const mergeStoreDeltaMarkers = (
delta: StoreDelta,
markers: StoreDeltaMarkers,
) => {
const txUndoOverrides = markers.txUndoOverrides ?? [];
if (txUndoOverrides.length === 0) {
return;
}
const existing = delta.markers?.txUndoOverrides ?? [];
delta.markers = {
...delta.markers,
txUndoOverrides: [
...existing.map(cloneTxUndoOverride),
...txUndoOverrides.map(cloneTxUndoOverride),
],
};
};
/**
* Represents a captured delta by the Store.
*/
@@ -662,7 +499,6 @@ export class StoreDelta {
public readonly id: string,
public readonly elements: ElementsDelta,
public readonly appState: AppStateDelta,
public markers?: StoreDeltaMarkers,
) {}
/**
@@ -672,16 +508,12 @@ export class StoreDelta {
elements: ElementsDelta,
appState: AppStateDelta,
opts: {
id?: string;
markers?: StoreDeltaMarkers;
} = {},
id: string;
} = {
id: randomId(),
},
) {
return new this(
opts.id ?? randomId(),
elements,
appState,
cloneStoreDeltaMarkers(opts.markers),
);
return new this(opts.id, elements, appState);
}
/**
@@ -706,12 +538,11 @@ export class StoreDelta {
* Restore a store delta instance from a DTO.
*/
public static restore(storeDeltaDTO: DTO<StoreDelta>) {
const { id, elements, appState, markers } = storeDeltaDTO;
const { id, elements, appState } = storeDeltaDTO;
return new this(
id,
ElementsDelta.restore(elements),
AppStateDelta.restore(appState),
cloneStoreDeltaMarkers(markers),
);
}
@@ -722,12 +553,11 @@ export class StoreDelta {
id,
elements: { added, removed, updated },
appState: { delta: appStateDelta },
markers,
}: DTO<StoreDelta>) {
const elements = ElementsDelta.create(added, removed, updated);
const appState = AppStateDelta.create(appStateDelta);
return new this(id, elements, appState, cloneStoreDeltaMarkers(markers));
return new this(id, elements, appState);
}
/**
@@ -739,10 +569,8 @@ export class StoreDelta {
for (const delta of deltas) {
aggregatedDelta.elements.squash(delta.elements);
aggregatedDelta.appState.squash(delta.appState);
mergeStoreDeltaMarkers(aggregatedDelta, delta.markers ?? {});
}
pruneStoreDeltaMarkers(aggregatedDelta);
return aggregatedDelta;
}
@@ -750,12 +578,7 @@ export class StoreDelta {
* Inverse store delta, creates new instance of `StoreDelta`.
*/
public static inverse(delta: StoreDelta) {
const inversed = this.create(
delta.elements.inverse(),
delta.appState.inverse(),
{ markers: delta.markers },
);
return inversed;
return this.create(delta.elements.inverse(), delta.appState.inverse());
}
/**
@@ -791,7 +614,7 @@ export class StoreDelta {
nextElements: SceneElementsMap,
modifierOptions?: "deleted" | "inserted",
): StoreDelta {
const nextDelta = this.create(
return this.create(
delta.elements.applyLatestChanges(
prevElements,
nextElements,
@@ -800,11 +623,8 @@ export class StoreDelta {
delta.appState,
{
id: delta.id,
markers: delta.markers,
},
);
pruneStoreDeltaMarkers(nextDelta);
return nextDelta;
}
public static empty() {
-140
View File
@@ -1,140 +0,0 @@
import { API } from "@excalidraw/excalidraw/tests/helpers/api";
import { arrayToMap } from "@excalidraw/common";
import type App from "@excalidraw/excalidraw/components/App";
import type { ObservedAppState } from "@excalidraw/excalidraw/types";
import type {
ExcalidrawElement,
SceneElementsMap,
} from "@excalidraw/element/types";
import {
CaptureUpdateAction,
Store,
StoreSnapshot,
newElementWith,
} from "../src";
const getScheduledMicroActionCount = (store: Store) =>
(
store as unknown as {
scheduledMicroActions: Array<() => void>;
}
).scheduledMicroActions.length;
const flushMicroActions = (store: Store) => {
(
store as unknown as {
flushMicroActions: () => void;
}
).flushMicroActions();
};
const toSceneElementsMap = (
elements: readonly ExcalidrawElement[],
): SceneElementsMap => arrayToMap(elements) as SceneElementsMap;
const createStoreHarness = (elements: SceneElementsMap) => {
const appState: ObservedAppState = StoreSnapshot.empty().appState;
const app = {
scene: {
getElementsMapIncludingDeleted: () => elements,
},
state: appState,
} as unknown as App;
const store = new Store(app);
store.snapshot = StoreSnapshot.create(elements, appState);
return { store, appState };
};
describe("Store synthetic increment isolation", () => {
it("keeps pending immediate micro actions queued across synthetic commit", () => {
const element = API.createElement({
type: "rectangle",
id: "store-isolated-immediate",
x: 0,
y: 0,
});
const baselineElements = toSceneElementsMap([element]);
const { store } = createStoreHarness(baselineElements);
const incrementTypes: Array<"durable" | "ephemeral"> = [];
const detach = store.onStoreIncrementEmitter.on((increment) => {
incrementTypes.push(increment.type);
});
store.scheduleMicroAction({
action: CaptureUpdateAction.IMMEDIATELY,
elements: [newElementWith(element, { y: 240 })],
appState: undefined,
});
expect(getScheduledMicroActionCount(store)).toBe(1);
const committed = store.commitSyntheticIncrement({
logicalBefore: {
elements: baselineElements,
},
logicalAfter: {
elements: toSceneElementsMap([newElementWith(element, { x: 120 })]),
},
});
expect(committed).toBe(true);
expect(incrementTypes).toEqual(["durable"]);
expect(getScheduledMicroActionCount(store)).toBe(1);
flushMicroActions(store);
expect(incrementTypes).toEqual(["durable", "durable"]);
expect(getScheduledMicroActionCount(store)).toBe(0);
detach();
});
it("keeps pending eventually micro actions queued across synthetic commit", () => {
const element = API.createElement({
type: "rectangle",
id: "store-isolated-eventually",
x: 0,
y: 0,
});
const baselineElements = toSceneElementsMap([element]);
const { store } = createStoreHarness(baselineElements);
const incrementTypes: Array<"durable" | "ephemeral"> = [];
const detach = store.onStoreIncrementEmitter.on((increment) => {
incrementTypes.push(increment.type);
});
store.scheduleMicroAction({
action: CaptureUpdateAction.EVENTUALLY,
elements: [newElementWith(element, { y: 240 })],
appState: undefined,
});
expect(getScheduledMicroActionCount(store)).toBe(1);
const committed = store.commitSyntheticIncrement({
logicalBefore: {
elements: baselineElements,
},
logicalAfter: {
elements: toSceneElementsMap([newElementWith(element, { x: 120 })]),
},
});
expect(committed).toBe(true);
expect(incrementTypes).toEqual(["durable"]);
expect(getScheduledMicroActionCount(store)).toBe(1);
flushMicroActions(store);
expect(incrementTypes).toEqual(["durable", "ephemeral"]);
expect(getScheduledMicroActionCount(store)).toBe(0);
detach();
});
});
+148 -12
View File
@@ -361,7 +361,6 @@ 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";
import {
calculateScrollCenter,
@@ -604,6 +603,55 @@ const YOUTUBE_VIDEO_STATES = new Map<
ValueOf<typeof YOUTUBE_STATES>
>();
const isGoogleDrivePreviewLink = (link: string | null | undefined) => {
if (!link) {
return false;
}
try {
const url = new URL(link);
return (
url.hostname === "drive.google.com" &&
/^\/file\/d\/[^/]+\/preview\/?$/.test(url.pathname)
);
} catch {
return false;
}
};
const isGoogleDriveEmbeddableElement = (
element: ExcalidrawElement | null | undefined,
) => {
if (!isEmbeddableElement(element)) {
return false;
}
const embedLink = getEmbedLink(toValidURL(element.link || ""));
return (
embedLink?.type === "video" && isGoogleDrivePreviewLink(embedLink.link)
);
};
const EMBEDDABLE_CANVAS_GUARD_CLASS = "excalidraw__embeddable-canvas-guard";
const EMBEDDABLE_CANVAS_GUARDS = [
{
key: "top",
style: { top: 0, left: 0, right: 0, height: "33.333%" },
},
{
key: "bottom",
style: { bottom: 0, left: 0, right: 0, height: "33.333%" },
},
{
key: "left",
style: { top: "33.333%", bottom: "33.333%", left: 0, width: "33.333%" },
},
{
key: "right",
style: { top: "33.333%", bottom: "33.333%", right: 0, width: "33.333%" },
},
] as const;
let IS_PLAIN_PASTE = false;
let IS_PLAIN_PASTE_TIMER = 0;
let PLAIN_PASTE_TOAST_SHOWN = false;
@@ -638,8 +686,7 @@ class App extends React.Component<AppProps, AppState> {
public library: AppClassProperties["library"];
public libraryItemsFromStorage: LibraryItems | undefined;
public id: string;
public transactionManager: TransactionManager;
public store: Store;
private store: Store;
private history: History;
public excalidrawContainerValue: {
container: HTMLDivElement | null;
@@ -834,8 +881,6 @@ class App extends React.Component<AppProps, AppState> {
this.store = new Store(this);
this.history = new History(this.store);
this.transactionManager = new TransactionManager(this);
this.transactionManager.attachHistory(this.history);
this.excalidrawContainerValue = {
container: this.excalidrawContainerRef.current,
@@ -843,6 +888,7 @@ class App extends React.Component<AppProps, AppState> {
};
this.fonts = new Fonts(this.scene);
this.history = new History(this.store);
this.actionManager.registerAll(actions);
this.actionManager.registerAction(createUndoAction(this.history));
@@ -1286,6 +1332,46 @@ class App extends React.Component<AppProps, AppState> {
return this.iFrameRefs.get(element.id);
}
private deactivateActiveEmbeddable = () => {
if (this.state.activeEmbeddable) {
flushSync(() => {
this.setState({ activeEmbeddable: null });
});
}
};
private handleEmbeddableCanvasGuardPointerDown = (
event: React.PointerEvent<HTMLDivElement>,
) => {
this.deactivateActiveEmbeddable();
this.handleCanvasPointerDown(event);
};
private handleEmbeddableCanvasGuardPointerMove = (
event: React.PointerEvent<HTMLDivElement>,
) => {
this.lastViewportPosition.x = event.clientX;
this.lastViewportPosition.y = event.clientY;
this.deactivateActiveEmbeddable();
};
private handleEmbeddableCanvasGuardTouchStart = (
event: React.TouchEvent<HTMLDivElement>,
) => {
if (event.touches.length >= 2) {
this.deactivateActiveEmbeddable();
}
};
private handleEmbeddableCanvasGuardWheel = (
event: React.WheelEvent<HTMLDivElement>,
) => {
this.lastViewportPosition.x = event.clientX;
this.lastViewportPosition.y = event.clientY;
this.deactivateActiveEmbeddable();
this.handleWheel(event);
};
private handleIframeLikeElementHover = ({
hitElement,
scenePointer,
@@ -1308,12 +1394,25 @@ class App extends React.Component<AppProps, AppState> {
))
) {
setCursor(this.interactiveCanvas, CURSOR_TYPE.POINTER);
const isDriveEmbedActivating =
(this.state.viewModeEnabled ||
this.state.activeTool.type === "selection") &&
isGoogleDriveEmbeddableElement(hitElement);
this.setState({
activeEmbeddable: { element: hitElement, state: "hover" },
activeEmbeddable: {
element: hitElement,
state: isDriveEmbedActivating ? "active" : "hover",
},
});
return true;
} else if (this.state.activeEmbeddable?.state === "hover") {
this.setState({ activeEmbeddable: null });
} else if (
this.state.activeEmbeddable?.state === "active" &&
isGoogleDriveEmbeddableElement(this.state.activeEmbeddable.element) &&
this.state.activeEmbeddable.element !== hitElement
) {
this.setState({ activeEmbeddable: null });
}
return false;
};
@@ -1737,6 +1836,8 @@ class App extends React.Component<AppProps, AppState> {
const isHovered =
this.state.activeEmbeddable?.element === el &&
this.state.activeEmbeddable?.state === "hover";
const shouldConstrainDriveInteraction =
isActive && isGoogleDriveEmbeddableElement(el);
return (
<div
@@ -1788,6 +1889,7 @@ class App extends React.Component<AppProps, AppState> {
width: isVisible ? `${el.width}px` : 0,
height: isVisible ? `${el.height}px` : 0,
transform: isVisible ? `rotate(${el.angle}rad)` : "none",
position: "relative",
pointerEvents: isActive
? POINTER_EVENTS.enabled
: POINTER_EVENTS.disabled,
@@ -1830,6 +1932,26 @@ class App extends React.Component<AppProps, AppState> {
/>
)}
</div>
{shouldConstrainDriveInteraction &&
EMBEDDABLE_CANVAS_GUARDS.map(({ key, style }) => (
<div
key={key}
className={EMBEDDABLE_CANVAS_GUARD_CLASS}
onPointerDown={
this.handleEmbeddableCanvasGuardPointerDown
}
onPointerMove={
this.handleEmbeddableCanvasGuardPointerMove
}
onTouchStart={this.handleEmbeddableCanvasGuardTouchStart}
onWheel={this.handleEmbeddableCanvasGuardWheel}
style={{
position: "absolute",
zIndex: 2,
...style,
}}
/>
))}
</div>
</div>
);
@@ -4605,11 +4727,6 @@ class App extends React.Component<AppProps, AppState> {
);
};
/** Creates a new transaction for batching mutations into a single undo entry. */
public createTransaction: TransactionManager["create"] = () => {
return this.transactionManager.create();
};
public mutateElement = <TElement extends Mutable<ExcalidrawElement>>(
element: TElement,
updates: ElementUpdate<TElement>,
@@ -4741,6 +4858,16 @@ class App extends React.Component<AppProps, AppState> {
});
}
if (
this.state.activeEmbeddable &&
(event.ctrlKey ||
event.metaKey ||
event.key === "Control" ||
event.key === "Meta")
) {
this.setState({ activeEmbeddable: null });
}
if (!isInputLike(event.target)) {
if (
(event.key === KEYS.ESCAPE || event.key === KEYS.ENTER) &&
@@ -5619,6 +5746,10 @@ class App extends React.Component<AppProps, AppState> {
selectedElementIds: makeNextSelectedElementIds({}, this.state),
activeEmbeddable: null,
});
} else if (this.state.activeEmbeddable) {
this.setState({
activeEmbeddable: null,
});
}
gesture.initialScale = this.state.zoom.value;
});
@@ -8173,6 +8304,10 @@ class App extends React.Component<AppProps, AppState> {
y: event.clientY,
});
if (gesture.pointers.size >= 2 && this.state.activeEmbeddable) {
this.setState({ activeEmbeddable: null });
}
if (gesture.pointers.size === 2) {
gesture.lastCenter = getCenter(gesture.pointers);
gesture.initialScale = this.state.zoom.value;
@@ -12592,7 +12727,8 @@ class App extends React.Component<AppProps, AppState> {
event.target instanceof HTMLTextAreaElement ||
event.target instanceof HTMLIFrameElement ||
(event.target instanceof HTMLElement &&
event.target.classList.contains(CLASSES.FRAME_NAME))
(event.target.classList.contains(CLASSES.FRAME_NAME) ||
event.target.classList.contains(EMBEDDABLE_CANVAS_GUARD_CLASS)))
)
) {
// prevent zooming the browser (but allow scrolling DOM)
+41 -6
View File
@@ -96,6 +96,8 @@ type RestoredAppState = Omit<
"offsetTop" | "offsetLeft" | "width" | "height"
>;
const MAX_ARROW_PX = 75_000;
export const AllowedExcalidrawActiveTools: Record<
AppState["activeTool"]["type"],
boolean
@@ -467,8 +469,8 @@ export const restoreElement = (
element.endArrowhead === undefined
? "arrow"
: normalizeArrowhead(element.endArrowhead);
const x: number | undefined = element.x;
const y: number | undefined = element.y;
const x = element.x as number | undefined;
const y = element.y as number | undefined;
const points: readonly LocalPoint[] | undefined = // migrate old arrow model to new one
!Array.isArray(element.points) || element.points.length < 2
? [pointFrom(0, 0), pointFrom(element.width, element.height)]
@@ -493,8 +495,8 @@ export const restoreElement = (
startArrowhead,
endArrowhead,
points,
x,
y,
x: x ?? 0,
y: y ?? 0,
elbowed: (element as ExcalidrawArrowElement).elbowed,
...getSizeFromPoints(points),
};
@@ -513,12 +515,44 @@ export const restoreElement = (
})
: restoreElementWithProperties(element as ExcalidrawArrowElement, base);
return {
const normalizedRestoredElement = {
...restoredElement,
...LinearElementEditor.getNormalizeElementPointsAndCoords(
restoredElement,
),
};
// Last resort fix for extremely large arrows
if (
normalizedRestoredElement.width > MAX_ARROW_PX ||
normalizedRestoredElement.height > MAX_ARROW_PX
) {
console.error(
`Removing extremely large arrow ${
normalizedRestoredElement.id
} (type: ${
isElbowArrow(normalizedRestoredElement) ? "elbow" : "simple"
}, width: ${normalizedRestoredElement.width}, height: ${
normalizedRestoredElement.height
}, x: ${normalizedRestoredElement.x}, y: ${
normalizedRestoredElement.y
})`,
);
return {
...normalizedRestoredElement,
x: 0,
y: 0,
width: 100,
height: 100,
points: [
pointFrom<LocalPoint>(0, 0),
pointFrom<LocalPoint>(100, 100),
],
isDeleted: true,
};
}
return normalizedRestoredElement;
}
// generic elements
@@ -666,6 +700,7 @@ export const restoreElements = <T extends ExcalidrawElement>(
const existingElementsMap = existingElements
? arrayToMap(existingElements)
: null;
const restoredElements = syncInvalidIndices(
(targetElements || []).reduce((elements, element) => {
// filtering out selection, which is legacy, no longer kept in elements,
@@ -762,7 +797,7 @@ export const restoreElements = <T extends ExcalidrawElement>(
}
}
// NOTE (mtolmacs): Temporary fix for extremely large arrows
// NOTE (mtolmacs): Temporary fix for invalid/self-bound elbow arrows
// Need to iterate again so we have attached text nodes in elementsMap
return restoredElements.map((element) => {
if (
+10 -60
View File
@@ -87,22 +87,10 @@ export class HistoryChangedEvent {
) {}
}
export type HistoryBeforeRecordListener = (delta: StoreDelta) => void;
export type HistoryPerformDirection = "undo" | "redo";
export type HistoryEffectiveDeltaResolverContext = {
direction: HistoryPerformDirection;
};
export type HistoryEffectiveDeltaResolver = (
delta: HistoryDelta,
context: HistoryEffectiveDeltaResolverContext,
) => HistoryDelta;
export class History {
public readonly onHistoryChangedEmitter = new Emitter<
[HistoryChangedEvent]
>();
private readonly onBeforeRecordEmitter = new Emitter<[StoreDelta]>();
private effectiveDeltaResolver: HistoryEffectiveDeltaResolver | null = null;
public readonly undoStack: HistoryDelta[] = [];
public readonly redoStack: HistoryDelta[] = [];
@@ -117,20 +105,6 @@ export class History {
constructor(private readonly store: Store) {}
/**
* Registers a hook that runs before a durable delta is converted
* into a history entry.
*/
public onBeforeRecord(callback: HistoryBeforeRecordListener) {
return this.onBeforeRecordEmitter.on(callback);
}
public setEffectiveDeltaResolver(
resolver: HistoryEffectiveDeltaResolver | null,
) {
this.effectiveDeltaResolver = resolver;
}
public clear() {
this.undoStack.length = 0;
this.redoStack.length = 0;
@@ -145,8 +119,6 @@ export class History {
return;
}
this.onBeforeRecordEmitter.trigger(delta);
// construct history entry, so once it's emitted, it's not recorded again
const historyDelta = HistoryDelta.inverse(delta);
@@ -159,14 +131,15 @@ export class History {
this.redoStack.length = 0;
}
this.emitHistoryChanged();
this.onHistoryChangedEmitter.trigger(
new HistoryChangedEvent(this.isUndoStackEmpty, this.isRedoStackEmpty),
);
}
public undo(elements: SceneElementsMap, appState: AppState) {
return this.perform(
elements,
appState,
"undo",
() => History.pop(this.undoStack),
(entry: HistoryDelta) => History.push(this.redoStack, entry),
);
@@ -176,7 +149,6 @@ export class History {
return this.perform(
elements,
appState,
"redo",
() => History.pop(this.redoStack),
(entry: HistoryDelta) => History.push(this.undoStack, entry),
);
@@ -185,7 +157,6 @@ export class History {
private perform(
elements: SceneElementsMap,
appState: AppState,
direction: HistoryPerformDirection,
pop: () => HistoryDelta | null,
push: (entry: HistoryDelta) => void,
): [SceneElementsMap, AppState] | void {
@@ -206,15 +177,9 @@ export class History {
// iterate through the history entries in case they result in no visible changes
while (historyDelta) {
// Roundtrip invariant: whichever delta we execute must be the one that
// continues through applyLatestChanges -> inverse -> opposite stack.
let entryToPush = historyDelta;
try {
const effectiveDelta = this.resolveEffectiveDelta(historyDelta, {
direction,
});
[nextElements, nextAppState, containsVisibleChange] =
effectiveDelta.applyTo(nextElements, nextAppState, prevSnapshot);
historyDelta.applyTo(nextElements, nextAppState, prevSnapshot);
const prevElements = prevSnapshot.elements;
const nextSnapshot = prevSnapshot.maybeClone(
@@ -225,7 +190,7 @@ export class History {
const change = StoreChange.create(prevSnapshot, nextSnapshot);
const delta = HistoryDelta.applyLatestChanges(
effectiveDelta,
historyDelta,
prevElements,
nextElements,
);
@@ -238,12 +203,12 @@ export class History {
delta,
});
entryToPush = delta;
historyDelta = delta;
}
prevSnapshot = nextSnapshot;
} finally {
push(entryToPush);
push(historyDelta);
}
if (containsVisibleChange) {
@@ -257,16 +222,12 @@ export class History {
} finally {
// trigger the history change event before returning completely
// also trigger it just once, no need doing so on each entry
this.emitHistoryChanged();
this.onHistoryChangedEmitter.trigger(
new HistoryChangedEvent(this.isUndoStackEmpty, this.isRedoStackEmpty),
);
}
}
private emitHistoryChanged() {
this.onHistoryChangedEmitter.trigger(
new HistoryChangedEvent(this.isUndoStackEmpty, this.isRedoStackEmpty),
);
}
private static pop(stack: HistoryDelta[]): HistoryDelta | null {
if (!stack.length) {
return null;
@@ -285,15 +246,4 @@ export class History {
const inversedEntry = HistoryDelta.inverse(entry);
return stack.push(inversedEntry);
}
private resolveEffectiveDelta(
delta: HistoryDelta,
context: HistoryEffectiveDeltaResolverContext,
): HistoryDelta {
if (!this.effectiveDeltaResolver) {
return delta;
}
return this.effectiveDeltaResolver(delta, context);
}
}
+1 -1
View File
@@ -83,13 +83,13 @@
"@codemirror/language": "^6.0.0",
"@codemirror/state": "^6.0.0",
"@codemirror/view": "^6.0.0",
"@lezer/highlight": "^1.0.0",
"@excalidraw/common": "0.18.0",
"@excalidraw/element": "0.18.0",
"@excalidraw/laser-pointer": "1.3.1",
"@excalidraw/math": "0.18.0",
"@excalidraw/mermaid-to-excalidraw": "2.2.2",
"@excalidraw/random-username": "1.1.0",
"@lezer/highlight": "^1.0.0",
"browser-fs-access": "0.38.0",
"canvas-roundrect-polyfill": "0.0.1",
"clsx": "1.1.1",
@@ -0,0 +1,315 @@
import { Excalidraw } from "../index";
import { Pointer } from "./helpers/ui";
import { act, fireEvent, GlobalTestState, render, waitFor } from "./test-utils";
import type { ExcalidrawProps } from "../types";
describe("embeddable interactions", () => {
const h = window.h;
const mouse = new Pointer("mouse");
const renderGoogleDriveEmbeddable = async (
excalidrawProps: Partial<ExcalidrawProps> = {},
) => {
const renderResult = await render(<Excalidraw {...excalidrawProps} />);
const fileId = "1AbCdEfGhIjKlMnOpQrStUvWxYz123456";
const src = `https://drive.google.com/file/d/${fileId}/preview`;
let embeddable!: NonNullable<
ReturnType<typeof h.app.insertEmbeddableElement>
>;
act(() => {
const insertedEmbeddable = h.app.insertEmbeddableElement({
sceneX: 40,
sceneY: 40,
link: `https://drive.google.com/file/d/${fileId}/view?usp=sharing`,
});
if (!insertedEmbeddable) {
throw new Error("Google Drive embeddable not inserted");
}
embeddable = insertedEmbeddable;
});
(
h.app as unknown as {
embedsValidationStatus: Map<string, boolean>;
}
).embedsValidationStatus.set(embeddable.id, true);
act(() => {
h.setState({ width: 1000, height: 1000 });
h.app.scene.triggerUpdate();
});
const getIframe = () => {
const iframe = renderResult.container.querySelector<HTMLIFrameElement>(
"iframe.excalidraw__embeddable",
);
if (!iframe) {
throw new Error("Google Drive iframe not rendered");
}
return iframe;
};
await waitFor(() => {
expect(getIframe().src).toBe(src);
});
return {
...renderResult,
embeddable,
getIframe,
src,
};
};
const renderYouTubeEmbeddable = async (
excalidrawProps: Partial<ExcalidrawProps> = {},
) => {
const renderResult = await render(<Excalidraw {...excalidrawProps} />);
let embeddable!: NonNullable<
ReturnType<typeof h.app.insertEmbeddableElement>
>;
act(() => {
const insertedEmbeddable = h.app.insertEmbeddableElement({
sceneX: 40,
sceneY: 40,
link: "https://www.youtube.com/watch?v=gkGMXY0wekg",
});
if (!insertedEmbeddable) {
throw new Error("YouTube embeddable not inserted");
}
embeddable = insertedEmbeddable;
});
(
h.app as unknown as {
embedsValidationStatus: Map<string, boolean>;
}
).embedsValidationStatus.set(embeddable.id, true);
act(() => {
h.setState({ width: 1000, height: 1000 });
h.app.scene.triggerUpdate();
});
await waitFor(() => {
expect(
renderResult.container.querySelector("iframe.excalidraw__embeddable"),
).not.toBeNull();
});
return {
...renderResult,
embeddable,
};
};
const setActiveEmbeddable = (
embeddable: NonNullable<ReturnType<typeof h.app.insertEmbeddableElement>>,
) => {
act(() => {
h.setState({
activeEmbeddable: { element: embeddable, state: "active" },
});
});
};
it("lets the initial Google Drive video click land in the iframe center", async () => {
const { container, embeddable, getIframe, src } =
await renderGoogleDriveEmbeddable();
mouse.moveTo(
embeddable.x + embeddable.width / 2,
embeddable.y + embeddable.height / 2,
);
await waitFor(() => {
expect(
container.querySelector<HTMLElement>(
".excalidraw__embeddable-container__inner",
)?.style.pointerEvents,
).toBe("all");
expect(h.state.activeEmbeddable?.element.id).toBe(embeddable.id);
expect(h.state.activeEmbeddable?.state).toBe("active");
expect(
container.querySelectorAll(".excalidraw__embeddable-canvas-guard"),
).toHaveLength(4);
expect(
container.querySelector(".excalidraw__embeddable-hint"),
).toBeNull();
expect(getIframe().src).toBe(src);
});
});
it("returns Drive embeddable edge interactions to the canvas", async () => {
const { container, embeddable } = await renderGoogleDriveEmbeddable();
act(() => {
h.setState({
activeEmbeddable: { element: embeddable, state: "active" },
});
});
const guard = container.querySelector<HTMLElement>(
".excalidraw__embeddable-canvas-guard",
);
expect(guard).not.toBeNull();
fireEvent.pointerMove(guard!, {
clientX: embeddable.x + 1,
clientY: embeddable.y + 1,
});
await waitFor(() => {
expect(h.state.activeEmbeddable).toBeNull();
expect(
container.querySelector<HTMLElement>(
".excalidraw__embeddable-container__inner",
)?.style.pointerEvents,
).toBe("none");
});
});
it("deactivates an interactive embeddable on Ctrl/Cmd keydown", async () => {
const { embeddable } = await renderGoogleDriveEmbeddable({
handleKeyboardGlobally: true,
});
mouse.moveTo(
embeddable.x + embeddable.width / 2,
embeddable.y + embeddable.height / 2,
);
await waitFor(() => {
expect(h.state.activeEmbeddable?.state).toBe("active");
});
fireEvent.keyDown(document, {
key: "Control",
ctrlKey: true,
});
await waitFor(() => {
expect(h.state.activeEmbeddable).toBeNull();
});
});
it("handles Ctrl/Cmd wheel on Drive embeddable guards", async () => {
const { container, embeddable } = await renderGoogleDriveEmbeddable();
act(() => {
h.setState({
activeEmbeddable: { element: embeddable, state: "active" },
});
});
const guard = container.querySelector<HTMLElement>(
".excalidraw__embeddable-canvas-guard",
);
expect(guard).not.toBeNull();
const prevZoom = h.state.zoom.value;
fireEvent.wheel(guard!, {
clientX: embeddable.x + 1,
clientY: embeddable.y + 1,
ctrlKey: true,
deltaY: -100,
});
await waitFor(() => {
expect(h.state.activeEmbeddable).toBeNull();
expect(h.state.zoom.value).toBeGreaterThan(prevZoom);
});
});
it("deactivates a non-Drive interactive embeddable on Ctrl/Cmd keydown", async () => {
const { container, embeddable } = await renderYouTubeEmbeddable({
handleKeyboardGlobally: true,
});
setActiveEmbeddable(embeddable);
await waitFor(() => {
expect(
container.querySelector<HTMLElement>(
".excalidraw__embeddable-container__inner",
)?.style.pointerEvents,
).toBe("all");
expect(
container.querySelectorAll(".excalidraw__embeddable-canvas-guard"),
).toHaveLength(0);
});
fireEvent.keyDown(document, {
key: "Control",
ctrlKey: true,
});
await waitFor(() => {
expect(h.state.activeEmbeddable).toBeNull();
expect(
container.querySelector<HTMLElement>(
".excalidraw__embeddable-container__inner",
)?.style.pointerEvents,
).toBe("none");
});
});
it("deactivates a non-Drive interactive embeddable on parent-observed pinch", async () => {
const { embeddable } = await renderYouTubeEmbeddable();
setActiveEmbeddable(embeddable);
fireEvent.pointerDown(GlobalTestState.interactiveCanvas, {
clientX: embeddable.x + 10,
clientY: embeddable.y + 10,
pointerId: 1,
pointerType: "touch",
});
fireEvent.pointerDown(GlobalTestState.interactiveCanvas, {
clientX: embeddable.x + 30,
clientY: embeddable.y + 30,
pointerId: 2,
pointerType: "touch",
});
await waitFor(() => {
expect(h.state.activeEmbeddable).toBeNull();
});
fireEvent.pointerUp(GlobalTestState.interactiveCanvas, {
pointerId: 1,
pointerType: "touch",
});
fireEvent.pointerUp(GlobalTestState.interactiveCanvas, {
pointerId: 2,
pointerType: "touch",
});
});
it("deactivates a non-Drive interactive embeddable on Safari gesturestart", async () => {
const { embeddable } = await renderYouTubeEmbeddable();
setActiveEmbeddable(embeddable);
act(() => {
document.dispatchEvent(
new Event("gesturestart", { bubbles: true, cancelable: true }),
);
});
await waitFor(() => {
expect(h.state.activeEmbeddable).toBeNull();
});
});
});
+20 -19
View File
@@ -449,28 +449,29 @@ const stripProps = (
}, {} as Record<string, any>);
export const checkpointHistory = (history: History, name: string) => {
const normalizeHistoryEntry = (entry: History["undoStack"][number]) => {
const { markers, ...rest } = entry;
return {
...rest,
...(markers ? { markers } : {}),
expect(
history.undoStack.map((x) => ({
...x,
elements: {
...entry.elements,
added: stripProps(entry.elements.added, ["seed", "versionNonce"]),
removed: stripProps(entry.elements.removed, ["seed", "versionNonce"]),
updated: stripProps(entry.elements.updated, ["seed", "versionNonce"]),
...x.elements,
added: stripProps(x.elements.added, ["seed", "versionNonce"]),
removed: stripProps(x.elements.removed, ["seed", "versionNonce"]),
updated: stripProps(x.elements.updated, ["seed", "versionNonce"]),
},
};
};
})),
).toMatchSnapshot(`[${name}] undo stack`);
expect(history.undoStack.map(normalizeHistoryEntry)).toMatchSnapshot(
`[${name}] undo stack`,
);
expect(history.redoStack.map(normalizeHistoryEntry)).toMatchSnapshot(
`[${name}] redo stack`,
);
expect(
history.redoStack.map((x) => ({
...x,
elements: {
...x.elements,
added: stripProps(x.elements.added, ["seed", "versionNonce"]),
removed: stripProps(x.elements.removed, ["seed", "versionNonce"]),
updated: stripProps(x.elements.updated, ["seed", "versionNonce"]),
},
})),
).toMatchSnapshot(`[${name}] redo stack`);
};
/**
File diff suppressed because it is too large Load Diff
-289
View File
@@ -1,289 +0,0 @@
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<string, unknown>;
export type ElementUpdatedProps = Omit<
Partial<OrderedExcalidrawElement>,
"id" | "updated" | "seed"
>;
export type ElementUpdatedPropName = Extract<keyof ElementUpdatedProps, string>;
type ElementPropValueMap = ElementUpdatedProps;
export type ElementUpdatedEntry = Delta<ElementPropValueMap>;
export type ElementUpdatedEntryMap = Record<string, ElementUpdatedEntry>;
const isPlainObject = (value: unknown): value is Record<string, unknown> =>
!!value && typeof value === "object" && !Array.isArray(value);
export const getElementProp = <TProp extends ElementPropName>(
element: ExcalidrawElement,
prop: TProp,
): ExcalidrawElement[TProp] =>
(element as ElementRecord)[prop] as ExcalidrawElement[TProp];
export const setOrderedElementProp = <TProp extends ElementPropName>(
element: Mutable<OrderedExcalidrawElement>,
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<string, ExcalidrawElement>,
): SceneElementsMap => new Map(elements) as SceneElementsMap;
export const createAllTouchedElementProps = (): TouchedElementProps => ({
kind: "all",
});
export const createPartialTouchedElementProps = (
props: Iterable<ElementPropName> = [],
): 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<TouchedElementProps, { kind: "partial" }> =>
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<ElementPropName>();
const keys = new Set<ElementPropName>([
...(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<string, ExcalidrawElement>,
after: ReadonlyMap<string, ExcalidrawElement>,
) => collectElementChanges(before, after).map((change) => change.id);
export const collectElementChanges = (
before: ReadonlyMap<string, ExcalidrawElement>,
after: ReadonlyMap<string, ExcalidrawElement>,
): ElementChange[] => {
const changes: ElementChange[] = [];
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;
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<object>): 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<object>());
};
-13
View File
@@ -1,13 +0,0 @@
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";
-333
View File
@@ -1,333 +0,0 @@
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<string, TransactionLedgerEntry>();
/** 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<string, ExcalidrawElement>,
after: ReadonlyMap<string, ExcalidrawElement>,
) {
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<string, ExcalidrawElement>) {
// 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<string, ExcalidrawElement>,
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<string, ExcalidrawElement>,
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<string, ExcalidrawElement>,
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<string, ExcalidrawElement>,
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<OrderedExcalidrawElement>;
const mutableAfter = deepCopyElement(
afterElement,
) as Mutable<OrderedExcalidrawElement>;
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;
}
}
-291
View File
@@ -1,291 +0,0 @@
import {
Delta,
ElementsDelta,
mergeStoreDeltaMarkers,
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<ElementUpdatedProps>;
const setElementUpdatedOverride = (
overrides: MutableElementUpdatedProps,
prop: ElementUpdatedPropName,
value: unknown,
) => {
(overrides as Record<ElementUpdatedPropName, unknown>)[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 markers 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<string, TransactionRecord>();
/**
* 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, "active">,
): 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;
}
mergeStoreDeltaMarkers(delta, { txUndoOverrides });
}
private collectUndoOverrides(delta: StoreDelta): TxUndoOverride[] {
const overrides: TxUndoOverride[] = [];
const reservedConsumedKeys = new Set<string>();
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.markers?.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,
markers: delta.markers,
}) 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);
}
}
@@ -1,286 +0,0 @@
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<ObservedAppState>;
private accumulatedAppState: Record<string, unknown> = {};
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<string>,
): 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<K extends keyof AppState>(data: {
elements?: SceneData["elements"];
appState?: Pick<AppState, K> | 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<string, unknown>),
};
}
}
/**
* 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<K extends keyof AppState>(data: {
elements: readonly TransactionElementUpdate[];
appState?: Pick<AppState, K> | null;
}): void {
const updatesById = new Map<string, TransactionElementUpdate>();
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<typeof update.type>;
return newElementWith(
element as MatchingElement,
update.updates as ElementUpdate<MatchingElement>,
);
});
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<ObservedAppState> | undefined {
if (!this.hasAccumulatedAppStateIntent()) {
return undefined;
}
if (!options?.resolveAppState) {
return this.accumulatedAppState as Partial<ObservedAppState>;
}
const context: AppStateResolverContext = {
initial: this.initialAppState,
accumulated: this.accumulatedAppState as Partial<ObservedAppState>,
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;
}
}
-83
View File
@@ -1,83 +0,0 @@
import type { ElementUpdate } from "@excalidraw/element";
import type {
ExcalidrawElement,
ExcalidrawNonSelectionElement,
} from "@excalidraw/element/types";
import type { ObservedAppState } from "../types";
export type ElementPropName = Extract<keyof ExcalidrawElement, string>;
export type TouchedElementProps =
| { kind: "all" }
| { kind: "partial"; props: Set<ElementPropName> };
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<ExcalidrawNonSelectionElement, { type: TType }>;
export type TransactionElementUpdate<
TType extends TransactionUpdatableElementType = TransactionUpdatableElementType,
> = TType extends TransactionUpdatableElementType
? {
id: ExcalidrawElement["id"];
type: TType;
updates: ElementUpdate<TransactionElementOfType<TType>>;
}
: 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<ObservedAppState>;
/** Merged appState intent from all updateScene calls during the transaction. */
accumulated: Partial<ObservedAppState>;
/** Current live appState at commit time. */
live: Partial<ObservedAppState>;
};
/**
* 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<ObservedAppState> | undefined;
@@ -1,283 +0,0 @@
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<TxUndoOverride, "txId">;
/**
* 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<string>();
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<ElementPropName, TxIntermediateValueHistory>
>();
private readonly consumedOverridePropKeys = new Set<string>();
/** 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<string, ExcalidrawElement>,
after: ReadonlyMap<string, ExcalidrawElement>,
) {
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<string>,
): 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<string>,
): 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<string>;
}): 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<ElementPropName, TxIntermediateValueHistory>();
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);
}
}
-3
View File
@@ -796,9 +796,6 @@ export type AppClassProperties = {
files: BinaryFiles;
editorInterface: App["editorInterface"];
scene: App["scene"];
store: App["store"];
transactionManager: App["transactionManager"];
createTransaction: App["createTransaction"];
syncActionResult: App["syncActionResult"];
fonts: App["fonts"];
pasteFromClipboard: App["pasteFromClipboard"];
-2
View File
@@ -1,5 +1,3 @@
export const PRECISION = 10e-5;
// Legendre-Gauss abscissae (x values) and weights for n=24
// Refeerence: https://pomax.github.io/bezierinfo/legendre-gauss.html
export const LegendreGaussN24TValues = [