Compare commits

..

15 Commits

Author SHA1 Message Date
Ryan Di 638c544bd1 lint 2026-04-24 17:13:37 +10:00
Ryan Di 79b5bab273 tests 2026-04-24 17:02:24 +10:00
Ryan Di b2920f0baf update docs 2026-04-24 16:32:46 +10:00
Ryan Di 4be8dd9879 refactor store to isolate synthetic tx commits and use markers instead 2026-04-24 16:15:32 +10:00
Ryan Di 801c936fd4 refactor(transaction): split module and tighten tx lifecycle 2026-04-24 14:35:25 +10:00
Ryan Di d4836f9e25 improve typing for partial updates 2026-04-24 11:10:02 +10:00
Ryan Di 75192db663 tx markers/effective-delta + perf tune 2026-04-23 14:34:30 +10:00
Ryan Di ef9afb0d37 chore(transaction): keep live-wins-per-prop + API clean up 2026-04-21 20:32:58 +10:00
Ryan Di a5769f96cd merge winner & scope 2026-04-20 17:23:14 +10:00
dwelle 2c6f513aed push more tests & failing test case 2026-04-19 23:08:21 +02:00
Ryan Di bfd4af2367 refactor(transaction): consolidate ledger into single file, fix store API, and optimize snapshots
- Move TransactionLedger + types from @excalidraw/element into
  packages/excalidraw/transaction.ts (no external consumers)
- Move commitSyntheticIncrement from App passthrough to Store directly,
  fix didAppStateChange to use Delta.isRightDifferent instead of
  presence-check, and replace isolated increments queue with direct
  scheduleMicroAction + flushMicroActions
- Remove commitSyntheticHistoryEntry from App, expose store and
  createTransaction on AppClassProperties instead
- Replace O(N) deep-copy snapshot in updateScene with shallow map copy
  (element refs are safe — ledger ignores index, the only in-place
  mutated prop, and deep-copies only recorded elements)
- Merge ledger + manager tests into single transaction.test.tsx
- Update dev-docs to reflect new file structure

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-17 21:59:11 +10:00
Ryan Di 2177e1cfa0 test(transaction): add deferred flush and conflict-scope coverage 2026-04-15 14:20:00 +10:00
Ryan Di 5cd609cea7 test(transaction): add interleaved history isolation coverage 2026-04-14 16:43:00 +10:00
Ryan Di 226a6eef0a feat(editor): add ledger-backed transaction manager with isolated history lane 2026-04-13 23:17:58 +10:00
Tom Louveau 1caec99b29 docs: change twitter label by X (#11158)
Co-authored-by: David Luzar <5153846+dwelle@users.noreply.github.com>
2026-04-13 10:30:58 +02:00
37 changed files with 4306 additions and 2046 deletions
@@ -1,124 +0,0 @@
- generic [ref=e1]:
- banner:
- heading "Excalidraw" [level=1] [ref=e2]
- generic [active] [ref=e5]:
- generic:
- generic:
- generic:
- button [ref=e9] [cursor=pointer]:
- img [ref=e10]
- region "Shapes":
- generic [ref=e16]:
- generic:
- generic:
- text: To move canvas, hold
- generic: Scroll wheel
- text: or
- generic: Space
- text: while dragging, or use the hand tool
- heading "Shapes" [level=2] [ref=e17]
- generic [ref=e18]:
- generic "Keep selected tool active after drawing — Q" [ref=e19] [cursor=pointer]:
- checkbox "Keep selected tool active after drawing"
- img [ref=e21]
- generic "Hand (panning tool) — H or null" [ref=e29] [cursor=pointer]:
- radio "Hand (panning tool)"
- img [ref=e31]
- generic "Selection — V or 1" [ref=e38] [cursor=pointer]:
- radio "Selection" [checked]
- generic [ref=e39]:
- img [ref=e40]
- generic [ref=e45]: "1"
- generic "Rectangle — R or 2" [ref=e46] [cursor=pointer]:
- radio "Rectangle"
- generic [ref=e47]:
- img [ref=e48]
- generic [ref=e52]: "2"
- generic "Diamond — D or 3" [ref=e53] [cursor=pointer]:
- radio "Diamond"
- generic [ref=e54]:
- img [ref=e55]
- generic [ref=e59]: "3"
- generic "Ellipse — O or 4" [ref=e60] [cursor=pointer]:
- radio "Ellipse"
- generic [ref=e61]:
- img [ref=e62]
- generic [ref=e66]: "4"
- generic "Arrow — A or 5" [ref=e67] [cursor=pointer]:
- radio "Arrow"
- generic [ref=e68]:
- img [ref=e69]
- generic [ref=e74]: "5"
- generic "Line — L or 6" [ref=e75] [cursor=pointer]:
- radio "Line"
- generic [ref=e76]:
- img [ref=e77]
- generic [ref=e78]: "6"
- generic "Draw — P or 7" [ref=e79] [cursor=pointer]:
- radio "Draw"
- generic [ref=e80]:
- img [ref=e81]
- generic [ref=e85]: "7"
- generic "Text — T or 8" [ref=e86] [cursor=pointer]:
- radio "Text"
- generic [ref=e87]:
- img [ref=e88]
- generic [ref=e93]: "8"
- generic "Insert image — 9" [ref=e94] [cursor=pointer]:
- radio "Insert image"
- generic [ref=e95]:
- img [ref=e96]
- generic [ref=e101]: "9"
- generic "Eraser — E or 0" [ref=e102] [cursor=pointer]:
- radio "Eraser"
- generic [ref=e103]:
- img [ref=e104]
- generic [ref=e109]: "0"
- button "More tools" [ref=e112] [cursor=pointer]:
- img [ref=e113]
- generic:
- generic [ref=e119]:
- link "Excalidraw+" [ref=e120] [cursor=pointer]:
- /url: https://plus.excalidraw.com/plus?utm_source=excalidraw&utm_medium=app&utm_content=guestBanner#excalidraw-redirect
- button "Live collaboration..." [ref=e121] [cursor=pointer]:
- img [ref=e122]
- generic "Library" [ref=e124]:
- checkbox "Library"
- img [ref=e127] [cursor=pointer]
- contentinfo:
- region "Canvas actions" [ref=e132]:
- heading "Canvas actions" [level=2] [ref=e133]
- generic [ref=e135]:
- button "Zoom out" [ref=e136] [cursor=pointer]:
- img [ref=e138]
- button "Reset zoom" [ref=e140] [cursor=pointer]: 100%
- button "Zoom in" [ref=e141] [cursor=pointer]:
- img [ref=e143]
- generic [ref=e145]:
- button "Undo" [disabled] [ref=e148]:
- img [ref=e150]
- button "Redo" [disabled] [ref=e154]:
- img [ref=e156]
- generic [ref=e158]:
- generic [ref=e159]:
- button "lock scroll" [ref=e160] [cursor=pointer]
- generic [ref=e161]:
- text: overscroll
- spinbutton "overscroll" [ref=e162]: "0.2"
- generic [ref=e163]:
- text: zoom factor
- spinbutton "zoom factor" [ref=e164]: "0.2"
- generic [ref=e165]:
- checkbox "lock zoom" [ref=e166]
- text: lock zoom
- generic [ref=e167]:
- checkbox "animate" [ref=e168]
- text: animate
- link "Blog post on end-to-end encryption in Excalidraw" [ref=e169] [cursor=pointer]:
- /url: https://plus.excalidraw.com/blog/end-to-end-encryption
- img [ref=e171]
- button "Help" [ref=e174] [cursor=pointer]:
- img [ref=e175]
- generic:
- img
- generic [ref=e180]: Drawing canvas
@@ -1,126 +0,0 @@
- generic [ref=e1]:
- banner:
- heading "Excalidraw" [level=1] [ref=e2]
- generic [ref=e5]:
- generic:
- generic:
- generic:
- generic:
- img
- img
- generic:
- text: Your drawings are saved in your browser's storage.
- text: Browser storage can be cleared unexpectedly.
- text: Save your work to a file regularly to avoid losing it.
- generic:
- button "Open Ctrl+O" [ref=e181] [cursor=pointer]:
- img [ref=e183]
- generic [ref=e185]: Open
- generic [ref=e186]: Ctrl+O
- button "Help ?" [ref=e187] [cursor=pointer]:
- img [ref=e189]
- generic [ref=e194]: Help
- generic [ref=e195]: "?"
- button "Live collaboration..." [ref=e196] [cursor=pointer]:
- img [ref=e198]
- generic [ref=e205]: Live collaboration...
- link "Sign up" [ref=e206] [cursor=pointer]:
- /url: https://plus.excalidraw.com/plus?utm_source=excalidraw&utm_medium=app&utm_content=welcomeScreenGuest
- img [ref=e208]
- generic [ref=e214]: Sign up
- generic:
- generic:
- button [ref=e9] [cursor=pointer]:
- img [ref=e10]
- region "Shapes":
- generic [ref=e16]:
- generic:
- generic:
- text: To move canvas, hold
- generic: Scroll wheel
- text: or
- generic: Space
- text: while dragging, or use the hand tool
- heading "Shapes" [level=2] [ref=e17]
- generic [ref=e18]:
- generic "Keep selected tool active after drawing — Q" [ref=e19] [cursor=pointer]:
- checkbox "Keep selected tool active after drawing"
- img [ref=e21]
- generic "Hand (panning tool) — H or null" [ref=e29] [cursor=pointer]:
- radio "Hand (panning tool)"
- img [ref=e31]
- generic "Selection" [ref=e215] [cursor=pointer]:
- radio "Selection" [checked]
- img [ref=e217]
- generic "Rectangle — R or 2" [ref=e46] [cursor=pointer]:
- radio "Rectangle"
- img [ref=e48]
- generic "Diamond — D or 3" [ref=e53] [cursor=pointer]:
- radio "Diamond"
- img [ref=e55]
- generic "Ellipse — O or 4" [ref=e60] [cursor=pointer]:
- radio "Ellipse"
- img [ref=e62]
- generic "Arrow — A or 5" [ref=e67] [cursor=pointer]:
- radio "Arrow"
- img [ref=e69]
- generic "Line — L or 6" [ref=e75] [cursor=pointer]:
- radio "Line"
- img [ref=e77]
- generic "Draw — P or 7" [ref=e79] [cursor=pointer]:
- radio "Draw"
- img [ref=e81]
- generic "Text — T or 8" [ref=e86] [cursor=pointer]:
- radio "Text"
- img [ref=e88]
- generic "Insert image — 9" [ref=e94] [cursor=pointer]:
- radio "Insert image"
- img [ref=e96]
- generic "Eraser — E or 0" [ref=e102] [cursor=pointer]:
- radio "Eraser"
- img [ref=e104]
- button "More tools" [ref=e112] [cursor=pointer]:
- img [ref=e113]
- generic:
- button "Live collaboration..." [ref=e121] [cursor=pointer]:
- img [ref=e122]
- generic "Library" [ref=e124]:
- checkbox "Library"
- img [ref=e127] [cursor=pointer]
- contentinfo:
- region "Canvas actions" [ref=e132]:
- heading "Canvas actions" [level=2] [ref=e133]
- generic [ref=e135]:
- button "Zoom out" [ref=e136] [cursor=pointer]:
- img [ref=e138]
- button "Reset zoom" [disabled] [ref=e140]: 100%
- button "Zoom in" [ref=e141] [cursor=pointer]:
- img [ref=e143]
- generic [ref=e145]:
- button "Undo" [disabled] [ref=e148]:
- img [ref=e150]
- button "Redo" [disabled] [ref=e154]:
- img [ref=e156]
- generic [ref=e158]:
- generic [ref=e159]:
- button "disable scroll lock" [active] [ref=e222] [cursor=pointer]
- generic [ref=e161]:
- text: overscroll
- spinbutton "overscroll" [ref=e162]: "0.2"
- generic [ref=e163]:
- text: zoom factor
- spinbutton "zoom factor" [ref=e164]: "0.2"
- generic [ref=e165]:
- checkbox "lock zoom" [ref=e166]
- text: lock zoom
- generic [ref=e167]:
- checkbox "animate" [ref=e168]
- text: animate
- link "Blog post on end-to-end encryption in Excalidraw" [ref=e169] [cursor=pointer]:
- /url: https://plus.excalidraw.com/blog/end-to-end-encryption
- img [ref=e171]
- button "Help" [ref=e174] [cursor=pointer]:
- img [ref=e175]
- generic:
- img
- generic [ref=e180]: Drawing canvas
+125
View File
@@ -0,0 +1,125 @@
# 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.
+2 -2
View File
@@ -97,8 +97,8 @@ const config = {
href: "https://discord.gg/UexuTaE",
},
{
label: "Twitter",
href: "https://twitter.com/excalidraw",
label: "𝕏",
href: "https://x.com/excalidraw",
},
{
label: "Linkedin",
+1 -13
View File
@@ -4,20 +4,8 @@ import { Excalidraw } from "@excalidraw/excalidraw";
import "@excalidraw/excalidraw/index.css";
import type { ScrollConstraints } from "@excalidraw/excalidraw/types";
import App from "../../with-script-in-browser/components/ExampleApp";
const scrollConstraints: ScrollConstraints = {
x: 0,
y: 0,
width: 850,
height: 400,
lockZoom: true,
overscrollAllowance: 0,
viewportZoomFactor: 1,
};
const ExcalidrawWrapper: React.FC = () => {
return (
<>
@@ -26,7 +14,7 @@ const ExcalidrawWrapper: React.FC = () => {
useCustom={(api: any, args?: any[]) => {}}
excalidrawLib={excalidrawLib}
>
<Excalidraw scrollConstraints={scrollConstraints} />
<Excalidraw />
</App>
</>
);
@@ -178,8 +178,7 @@ export default function ExampleApp({
const newElement = cloneElement(
Excalidraw,
{
onExcalidrawAPI: (api: ExcalidrawImperativeAPI | null) =>
setExcalidrawAPI(api),
excalidrawAPI: (api: ExcalidrawImperativeAPI) => setExcalidrawAPI(api),
initialData: initialStatePromiseRef.current.promise,
onChange: (
elements: NonDeletedExcalidrawElement[],
@@ -821,48 +820,6 @@ export default function ExampleApp({
/>
Export with embed scene
</label>
<button
type="button"
onClick={() => {
if (!excalidrawAPI) {
return;
}
const elements = excalidrawAPI.getSceneElements();
excalidrawAPI.scrollToContent(elements[0], {
animate: true,
fitToViewport: true,
viewportZoomFactor: 0.9,
scrollLock: {
lockZoom: true,
overscrollAllowance: 0,
},
});
}}
>
Fit and lock first element
</button>
<button
type="button"
onClick={() => {
if (!excalidrawAPI) {
return;
}
const elements = excalidrawAPI.getSceneElements();
excalidrawAPI.scrollToContent(elements[1], {
animate: true,
fitToViewport: true,
viewportZoomFactor: 0.9,
scrollLock: {
lockZoom: true,
overscrollAllowance: 0,
},
});
}}
>
Fit and lock second element
</button>
<button
onClick={async () => {
if (!excalidrawAPI) {
+1 -4
View File
@@ -1015,10 +1015,7 @@ const ExcalidrawWrapper = () => {
</OverwriteConfirmDialog.Action>
)}
</OverwriteConfirmDialog>
<AppFooter
excalidrawAPI={excalidrawAPI}
onChange={() => excalidrawAPI?.refresh()}
/>
<AppFooter onChange={() => excalidrawAPI?.refresh()} />
{excalidrawAPI && <AIComponents excalidrawAPI={excalidrawAPI} />}
<TTDDialogTrigger />
+2 -168
View File
@@ -1,178 +1,13 @@
import { Footer } from "@excalidraw/excalidraw/index";
import {
getCommonBounds,
getSelectedElements,
getVisibleSceneBounds,
} from "@excalidraw/element";
import React, { useCallback, useState } from "react";
import type {
ExcalidrawImperativeAPI,
ScrollConstraints,
} from "@excalidraw/excalidraw/types";
import React from "react";
import { isExcalidrawPlusSignedUser } from "../app_constants";
import { DebugFooter, isVisualDebuggerEnabled } from "./DebugCanvas";
import { EncryptedIcon } from "./EncryptedIcon";
type ScrollConstraintOptions = Pick<
ScrollConstraints,
"lockZoom" | "overscrollAllowance" | "viewportZoomFactor"
>;
const ScrollConstraintsDebugFooter = ({
excalidrawAPI,
}: {
excalidrawAPI: ExcalidrawImperativeAPI | null;
}) => {
const [activeLock, setActiveLock] = useState<ScrollConstraints | null>(null);
const [options, setOptions] = useState<ScrollConstraintOptions>({
lockZoom: false,
overscrollAllowance: 0.2,
viewportZoomFactor: 0.2,
});
const setLock = useCallback(
(nextLock: ScrollConstraints) => {
excalidrawAPI?.setScrollConstraints(nextLock);
setActiveLock(nextLock);
},
[excalidrawAPI],
);
const toggleScrollLock = useCallback(() => {
if (!excalidrawAPI) {
return;
}
if (activeLock) {
excalidrawAPI.setScrollConstraints(null);
setActiveLock(null);
return;
}
const selectedElements = getSelectedElements(
excalidrawAPI.getSceneElements(),
excalidrawAPI.getAppState(),
{
includeBoundTextElement: true,
includeElementsInFrames: true,
},
);
const [x1, y1, x2, y2] = selectedElements.length
? getCommonBounds(
selectedElements,
excalidrawAPI.getSceneElementsMapIncludingDeleted(),
)
: getVisibleSceneBounds(excalidrawAPI.getAppState());
setLock({
x: x1,
y: y1,
width: x2 - x1,
height: y2 - y1,
...options,
});
}, [activeLock, excalidrawAPI, options, setLock]);
const updateOptions = useCallback(
(nextOptions: ScrollConstraintOptions) => {
setOptions(nextOptions);
if (activeLock) {
setLock({ ...activeLock, ...nextOptions });
}
},
[activeLock, setLock],
);
const updateNumberOption = useCallback(
(option: "overscrollAllowance" | "viewportZoomFactor", value: string) => {
updateOptions({
...options,
[option]: value === "" ? undefined : Number(value),
});
},
[options, updateOptions],
);
const updateBooleanOption = useCallback(
(option: "lockZoom", value: boolean) => {
updateOptions({
...options,
[option]: value,
});
},
[options, updateOptions],
);
return (
<div
style={{
display: "flex",
gap: ".45rem",
alignItems: "center",
padding: "0 .35rem",
fontSize: 12,
}}
>
<button
className="ToolIcon_type_button"
type="button"
onClick={toggleScrollLock}
>
{activeLock ? "disable scroll lock" : "lock scroll"}
</button>
<label style={{ display: "flex", gap: ".25rem", alignItems: "center" }}>
overscroll
<input
type="number"
min={0}
max={1}
step={0.05}
value={options.overscrollAllowance ?? ""}
onChange={(event) =>
updateNumberOption("overscrollAllowance", event.target.value)
}
style={{ width: 56 }}
/>
</label>
<label style={{ display: "flex", gap: ".25rem", alignItems: "center" }}>
zoom factor
<input
type="number"
min={0.1}
step={0.1}
value={options.viewportZoomFactor ?? ""}
onChange={(event) =>
updateNumberOption("viewportZoomFactor", event.target.value)
}
style={{ width: 56 }}
/>
</label>
<label style={{ display: "flex", gap: ".2rem", alignItems: "center" }}>
<input
type="checkbox"
checked={!!options.lockZoom}
onChange={(event) =>
updateBooleanOption("lockZoom", event.target.checked)
}
/>
lock zoom
</label>
</div>
);
};
export const AppFooter = React.memo(
({
excalidrawAPI,
onChange,
}: {
excalidrawAPI: ExcalidrawImperativeAPI | null;
onChange: () => void;
}) => {
({ onChange }: { onChange: () => void }) => {
return (
<Footer>
<div
@@ -182,7 +17,6 @@ export const AppFooter = React.memo(
alignItems: "center",
}}
>
<ScrollConstraintsDebugFooter excalidrawAPI={excalidrawAPI} />
{isVisualDebuggerEnabled() && <DebugFooter onChange={onChange} />}
{!isExcalidrawPlusSignedUser && <EncryptedIcon />}
</div>
+189 -9
View File
@@ -111,6 +111,67 @@ 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.
*/
@@ -206,6 +267,7 @@ export class Store {
public clear(): void {
this.snapshot = StoreSnapshot.empty();
this.scheduledMacroActions = new Set();
this.scheduledMicroActions = [];
}
/**
@@ -245,6 +307,24 @@ 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.
*
@@ -491,6 +571,89 @@ 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.
*/
@@ -499,6 +662,7 @@ export class StoreDelta {
public readonly id: string,
public readonly elements: ElementsDelta,
public readonly appState: AppStateDelta,
public markers?: StoreDeltaMarkers,
) {}
/**
@@ -508,12 +672,16 @@ export class StoreDelta {
elements: ElementsDelta,
appState: AppStateDelta,
opts: {
id: string;
} = {
id: randomId(),
},
id?: string;
markers?: StoreDeltaMarkers;
} = {},
) {
return new this(opts.id, elements, appState);
return new this(
opts.id ?? randomId(),
elements,
appState,
cloneStoreDeltaMarkers(opts.markers),
);
}
/**
@@ -538,11 +706,12 @@ export class StoreDelta {
* Restore a store delta instance from a DTO.
*/
public static restore(storeDeltaDTO: DTO<StoreDelta>) {
const { id, elements, appState } = storeDeltaDTO;
const { id, elements, appState, markers } = storeDeltaDTO;
return new this(
id,
ElementsDelta.restore(elements),
AppStateDelta.restore(appState),
cloneStoreDeltaMarkers(markers),
);
}
@@ -553,11 +722,12 @@ 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);
return new this(id, elements, appState, cloneStoreDeltaMarkers(markers));
}
/**
@@ -569,8 +739,10 @@ 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;
}
@@ -578,7 +750,12 @@ export class StoreDelta {
* Inverse store delta, creates new instance of `StoreDelta`.
*/
public static inverse(delta: StoreDelta) {
return this.create(delta.elements.inverse(), delta.appState.inverse());
const inversed = this.create(
delta.elements.inverse(),
delta.appState.inverse(),
{ markers: delta.markers },
);
return inversed;
}
/**
@@ -614,7 +791,7 @@ export class StoreDelta {
nextElements: SceneElementsMap,
modifierOptions?: "deleted" | "inserted",
): StoreDelta {
return this.create(
const nextDelta = this.create(
delta.elements.applyLatestChanges(
prevElements,
nextElements,
@@ -623,8 +800,11 @@ export class StoreDelta {
delta.appState,
{
id: delta.id,
markers: delta.markers,
},
);
pruneStoreDeltaMarkers(nextDelta);
return nextDelta;
}
public static empty() {
+140
View File
@@ -0,0 +1,140 @@
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();
});
});
+9 -63
View File
@@ -40,7 +40,6 @@ import {
ZoomResetIcon,
} from "../components/icons";
import { setCursor } from "../cursor";
import { constrainScrollState } from "../scene/scrollConstraints";
import { t } from "../i18n";
import { getNormalizedZoom } from "../scene";
@@ -52,8 +51,6 @@ import { register } from "./register";
import type { AppState, Offsets } from "../types";
const canUseZoomActions = (appState: AppState) => !appState.scrollConstraints;
export const actionChangeViewBackgroundColor = register<Partial<AppState>>({
name: "changeViewBackgroundColor",
label: "labels.canvasBackground",
@@ -142,7 +139,7 @@ export const actionZoomIn = register({
trackEvent: { category: "canvas" },
perform: (_elements, appState, _, app) => {
return {
appState: constrainScrollState({
appState: {
...appState,
...getStateForZoom(
{
@@ -153,7 +150,7 @@ export const actionZoomIn = register({
appState,
),
userToFollow: null,
}),
},
captureUpdate: CaptureUpdateAction.EVENTUALLY,
};
},
@@ -183,7 +180,7 @@ export const actionZoomOut = register({
trackEvent: { category: "canvas" },
perform: (_elements, appState, _, app) => {
return {
appState: constrainScrollState({
appState: {
...appState,
...getStateForZoom(
{
@@ -194,7 +191,7 @@ export const actionZoomOut = register({
appState,
),
userToFollow: null,
}),
},
captureUpdate: CaptureUpdateAction.EVENTUALLY,
};
},
@@ -246,7 +243,6 @@ export const actionResetZoom = register({
className="reset-zoom-button zoom-button"
title={t("buttons.resetZoom")}
aria-label={t("buttons.resetZoom")}
disabled={!canUseZoomActions(appState)}
onClick={() => {
updateData(null);
}}
@@ -255,7 +251,6 @@ export const actionResetZoom = register({
</ToolButton>
</Tooltip>
),
predicate: (_elements, appState) => canUseZoomActions(appState),
keyTest: (event) =>
(event.code === CODES.ZERO || event.code === CODES.NUM_ZERO) &&
(event[KEYS.CTRL_OR_CMD] || event.shiftKey),
@@ -279,7 +274,7 @@ const zoomValueToFitBoundsOnViewport = (
return Math.min(adjustedZoomValue, 1);
};
export function resolveViewportForBounds({
export const zoomToFitBounds = ({
bounds,
appState,
canvasOffsets,
@@ -297,7 +292,7 @@ export function resolveViewportForBounds({
viewportZoomFactor?: number;
minZoom?: number;
maxZoom?: number;
}) {
}) => {
viewportZoomFactor = clamp(viewportZoomFactor, MIN_ZOOM, MAX_ZOOM);
const [x1, y1, x2, y2] = bounds;
@@ -350,58 +345,12 @@ export function resolveViewportForBounds({
zoom: { value: newZoomValue },
});
return {
bounds,
scrollX: centerScroll.scrollX,
scrollY: centerScroll.scrollY,
zoom: { value: newZoomValue },
viewportZoomFactor,
viewportDimensions: {
width: appState.width,
height: appState.height,
},
effectiveViewportDimensions: {
width: effectiveCanvasWidth,
height: effectiveCanvasHeight,
},
};
}
export const zoomToFitBounds = ({
bounds,
appState,
canvasOffsets,
fitToViewport = false,
viewportZoomFactor = 1,
minZoom = -Infinity,
maxZoom = Infinity,
}: {
bounds: SceneBounds;
canvasOffsets?: Offsets;
appState: Readonly<AppState>;
/** whether to fit content to viewport (beyond >100%) */
fitToViewport: boolean;
/** zoom content to cover X of the viewport, when fitToViewport=true */
viewportZoomFactor?: number;
minZoom?: number;
maxZoom?: number;
}) => {
const resolvedViewport = resolveViewportForBounds({
bounds,
appState,
canvasOffsets,
fitToViewport,
viewportZoomFactor,
minZoom,
maxZoom,
});
return {
appState: {
...appState,
scrollX: resolvedViewport.scrollX,
scrollY: resolvedViewport.scrollY,
zoom: resolvedViewport.zoom,
scrollX: centerScroll.scrollX,
scrollY: centerScroll.scrollY,
zoom: { value: newZoomValue },
},
captureUpdate: CaptureUpdateAction.EVENTUALLY,
};
@@ -459,7 +408,6 @@ export const actionZoomToFitSelectionInViewport = register({
canvasOffsets: app.getEditorUIOffsets(),
});
},
predicate: (_elements, appState) => canUseZoomActions(appState),
// NOTE shift-2 should have been assigned actionZoomToFitSelection.
// TBD on how proceed
keyTest: (event) =>
@@ -486,7 +434,6 @@ export const actionZoomToFitSelection = register({
canvasOffsets: app.getEditorUIOffsets(),
});
},
predicate: (_elements, appState) => canUseZoomActions(appState),
// NOTE this action should use shift-2 per figma, alas
keyTest: (event) =>
event.code === CODES.THREE &&
@@ -511,7 +458,6 @@ export const actionZoomToFit = register({
fitToViewport: false,
canvasOffsets: app.getEditorUIOffsets(),
}),
predicate: (_elements, appState) => canUseZoomActions(appState),
keyTest: (event) =>
event.code === CODES.ONE &&
event.shiftKey &&
+3 -12
View File
@@ -49,18 +49,9 @@ import { register } from "./register";
import type { AppClassProperties, AppState } from "../types";
const allElementsInSameGroup = (
elements: readonly ExcalidrawElement[],
editingGroupId: AppState["editingGroupId"],
) => {
const allElementsInSameGroup = (elements: readonly ExcalidrawElement[]) => {
if (elements.length >= 2) {
const editingGroupIndex = editingGroupId
? elements[0].groupIds.indexOf(editingGroupId)
: -1;
const groupIds =
editingGroupIndex > -1
? elements[0].groupIds.slice(0, editingGroupIndex)
: elements[0].groupIds;
const groupIds = elements[0].groupIds;
for (const groupId of groupIds) {
if (
elements.reduce(
@@ -87,7 +78,7 @@ const enableActionGroup = (
return (
selectedElements.length >= 2 &&
!allElementsInSameGroup(selectedElements, appState.editingGroupId) &&
!allElementsInSameGroup(selectedElements) &&
!frameAndChildrenSelectedTogether(selectedElements)
);
};
@@ -53,5 +53,8 @@ export const actionToggleSearchMenu = register({
};
},
checked: (appState: AppState) => appState.gridModeEnabled,
predicate: (element, appState, props) => {
return props.gridModeEnabled === undefined;
},
keyTest: (event) => event[KEYS.CTRL_OR_CMD] && event.key === KEYS.F,
});
-7
View File
@@ -95,13 +95,6 @@ export class ActionManager {
(action.name in canvasActions
? canvasActions[action.name as keyof typeof canvasActions]
: true) &&
(!action.predicate ||
action.predicate(
this.getElementsIncludingDeleted(),
this.getAppState(),
this.app.props,
this.app,
)) &&
action.keyTest &&
action.keyTest(
event,
-2
View File
@@ -122,7 +122,6 @@ export const getDefaultAppState = (): Omit<
objectsSnapModeEnabled: false,
userToFollow: null,
followedBy: new Set(),
scrollConstraints: null,
isCropping: false,
croppingElementId: null,
searchMatches: null,
@@ -251,7 +250,6 @@ const APP_STATE_STORAGE_CONF = (<
objectsSnapModeEnabled: { browser: true, export: false, server: false },
userToFollow: { browser: false, export: false, server: false },
followedBy: { browser: false, export: false, server: false },
scrollConstraints: { browser: true, export: false, server: false },
isCropping: { browser: false, export: false, server: false },
croppingElementId: { browser: false, export: false, server: false },
searchMatches: { browser: false, export: false, server: false },
+98 -447
View File
@@ -326,10 +326,7 @@ import {
actionToggleCropEditor,
} from "../actions";
import { actionWrapTextInContainer } from "../actions/actionBoundText";
import {
actionToggleHandTool,
resolveViewportForBounds,
} from "../actions/actionCanvas";
import { actionToggleHandTool, zoomToFit } from "../actions/actionCanvas";
import { actionPaste } from "../actions/actionClipboard";
import { actionCopyElementLink } from "../actions/actionElementLink";
import { actionUnlockAllElements } from "../actions/actionElementLock";
@@ -364,6 +361,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";
import {
calculateScrollCenter,
@@ -373,7 +371,7 @@ import {
hasBackground,
isSomeElementSelected,
} from "../scene";
import { getConstrainedZoomAnchor, getStateForZoom } from "../scene/zoom";
import { getStateForZoom } from "../scene/zoom";
import {
dataURLToString,
generateIdFromFile,
@@ -429,12 +427,6 @@ import { isMaybeMermaidDefinition } from "../mermaid";
import { LassoTrail } from "../lasso";
import {
constrainScrollState,
calculateConstrainedScrollCenter,
areCanvasTranslatesClose,
getScrollConstraintsForBounds,
} from "../scene/scrollConstraints";
import { EraserTrail } from "../eraser";
import { getShortcutKey } from "../shortcut";
@@ -497,9 +489,6 @@ import type {
CollaboratorPointer,
ToolType,
OnUserFollowedPayload,
ScrollConstraints,
AnimateTranslateCanvasValues,
ScrollToContentOptions,
UnsubscribeCallback,
EmbedsValidationStatus,
ElementsPendingErasure,
@@ -552,7 +541,6 @@ const ExcalidrawAppStateContext = React.createContext<AppState>({
height: 0,
offsetLeft: 0,
offsetTop: 0,
scrollConstraints: null,
});
ExcalidrawAppStateContext.displayName = "ExcalidrawAppStateContext";
@@ -607,20 +595,6 @@ let isDraggingScrollBar: boolean = false;
let currentScrollBars: ScrollBars = { horizontal: null, vertical: null };
let touchTimeout = 0;
let invalidateContextMenu = false;
let scrollConstraintsAnimationTimeout: ReturnType<typeof setTimeout> | null =
null;
const areScrollConstraintsEqual = (
left: ScrollConstraints | null | undefined,
right: ScrollConstraints | null | undefined,
) => {
return left === right || (!!left && !!right && isShallowEqual(left, right));
};
const SET_SCROLL_CONSTRAINTS_CONTROLLED_WARNING =
"Excalidraw: `setScrollConstraints()` is ignored when the `scrollConstraints` prop is controlled.";
const SCROLL_TO_CONTENT_SCROLL_LOCK_CONTROLLED_WARNING =
"Excalidraw: `scrollToContent()` with `scrollLock` is ignored when the `scrollConstraints` prop is controlled.";
/**
* Map of youtube embed video states
@@ -664,7 +638,8 @@ class App extends React.Component<AppProps, AppState> {
public library: AppClassProperties["library"];
public libraryItemsFromStorage: LibraryItems | undefined;
public id: string;
private store: Store;
public transactionManager: TransactionManager;
public store: Store;
private history: History;
public excalidrawContainerValue: {
container: HTMLDivElement | null;
@@ -808,8 +783,6 @@ class App extends React.Component<AppProps, AppState> {
onUserFollow: (cb) => this.onUserFollowEmitter.on(cb),
onStateChange: this.onStateChange,
onEvent: this.onEvent,
app: this,
setScrollConstraints: this.setScrollConstraints,
};
return api;
}
@@ -824,7 +797,6 @@ class App extends React.Component<AppProps, AppState> {
objectsSnapModeEnabled = false,
theme = defaultAppState.theme,
name = `${t("labels.untitled")}-${getDateTime()}`,
scrollConstraints,
} = props;
this.state = {
@@ -840,7 +812,6 @@ class App extends React.Component<AppProps, AppState> {
name,
width: window.innerWidth,
height: window.innerHeight,
scrollConstraints: scrollConstraints ?? null,
};
this.refreshEditorInterface();
@@ -863,6 +834,8 @@ 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,
@@ -870,7 +843,6 @@ 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));
@@ -2976,13 +2948,7 @@ class App extends React.Component<AppProps, AppState> {
toast: this.state.toast,
};
if (this.props.scrollConstraints) {
restoredAppState = {
...restoredAppState,
scrollConstraints: this.props.scrollConstraints,
...calculateConstrainedScrollCenter(this.state, restoredAppState),
};
} else if (initialData?.scrollToContent) {
if (initialData?.scrollToContent) {
restoredAppState = {
...restoredAppState,
...calculateScrollCenter(restoredElements, {
@@ -2991,18 +2957,10 @@ class App extends React.Component<AppProps, AppState> {
height: this.state.height,
offsetTop: this.state.offsetTop,
offsetLeft: this.state.offsetLeft,
scrollConstraints: this.state.scrollConstraints,
}),
};
}
if (initialData?.scrollX != null) {
restoredAppState.scrollX = initialData.scrollX;
}
if (initialData?.scrollY != null) {
restoredAppState.scrollY = initialData.scrollY;
}
this.resetStore();
this.resetHistory();
this.syncActionResult({
@@ -3253,11 +3211,7 @@ class App extends React.Component<AppProps, AppState> {
.forEach((element) => ShapeCache.delete(element));
this.refreshEditorInterface();
this.updateDOMRect();
if (this.state.scrollConstraints) {
this.setState((state) => constrainScrollState(state));
} else {
this.setState({});
}
this.setState({});
});
/** generally invoked only if fullscreen was invoked programmatically */
@@ -3507,15 +3461,6 @@ class App extends React.Component<AppProps, AppState> {
this.setState({ viewModeEnabled: !!this.props.viewModeEnabled });
}
if (
!areScrollConstraintsEqual(
prevProps.scrollConstraints,
this.props.scrollConstraints,
)
) {
this.syncScrollConstraints(this.props.scrollConstraints ?? null);
}
if (prevState.viewModeEnabled !== this.state.viewModeEnabled) {
this.addEventListeners();
this.deselectElements();
@@ -3576,33 +3521,6 @@ class App extends React.Component<AppProps, AppState> {
this.props.onChange?.(elements, this.state, this.files);
this.onChangeEmitter.trigger(elements, this.state, this.files);
}
if (this.state.scrollConstraints?.animateOnNextUpdate) {
const newState = constrainScrollState(this.state, "rigid");
const fromValues = {
scrollX: this.state.scrollX,
scrollY: this.state.scrollY,
zoom: this.state.zoom.value,
};
const toValues = {
scrollX: newState.scrollX,
scrollY: newState.scrollY,
zoom: newState.zoom.value,
};
if (areCanvasTranslatesClose(fromValues, toValues)) {
return;
}
if (scrollConstraintsAnimationTimeout) {
clearTimeout(scrollConstraintsAnimationTimeout);
}
scrollConstraintsAnimationTimeout = setTimeout(() => {
this.cancelInProgressAnimation?.();
this.animateToConstrainedArea(fromValues, toValues);
}, 200);
}
}
private renderInteractiveSceneCallback = ({
@@ -4370,8 +4288,8 @@ class App extends React.Component<AppProps, AppState> {
*/
value: number,
) => {
this.setState(
getStateForZoom(
this.setState({
...getStateForZoom(
{
viewportX: this.state.width / 2 + this.state.offsetLeft,
viewportY: this.state.height / 2 + this.state.offsetTop,
@@ -4379,13 +4297,11 @@ class App extends React.Component<AppProps, AppState> {
},
this.state,
),
);
});
};
private cancelInProgressAnimation: (() => void) | null = null;
private scrollToContentRequestId = 0;
scrollToContent = (
/**
* target to scroll to
@@ -4397,13 +4313,30 @@ class App extends React.Component<AppProps, AppState> {
| string
| ExcalidrawElement
| readonly ExcalidrawElement[] = this.scene.getNonDeletedElements(),
opts?: ScrollToContentOptions,
opts?: (
| {
fitToContent?: boolean;
fitToViewport?: never;
viewportZoomFactor?: number;
animate?: boolean;
duration?: number;
}
| {
fitToContent?: never;
fitToViewport?: boolean;
/** when fitToViewport=true, how much screen should the content cover,
* between 0.1 (10%) and 1 (100%)
*/
viewportZoomFactor?: number;
animate?: boolean;
duration?: number;
}
) & {
minZoom?: number;
maxZoom?: number;
canvasOffsets?: Offsets;
},
) => {
if (opts?.scrollLock && this.props.scrollConstraints !== undefined) {
console.warn(SCROLL_TO_CONTENT_SCROLL_LOCK_CONTROLLED_WARNING);
opts = { ...opts, scrollLock: undefined };
}
if (typeof target === "string") {
let id: string | null;
if (isElementLink(target)) {
@@ -4415,29 +4348,10 @@ class App extends React.Component<AppProps, AppState> {
const elements = this.scene.getElementsFromId(id);
if (elements?.length) {
if (opts?.fitToViewport) {
this.scrollToContent(elements, {
fitToViewport: true,
viewportZoomFactor: opts.viewportZoomFactor,
animate: opts.animate ?? true,
duration: opts.duration,
scrollLock: opts.scrollLock,
minZoom: opts.minZoom,
maxZoom: opts.maxZoom,
canvasOffsets: opts.canvasOffsets,
});
} else {
this.scrollToContent(elements, {
fitToContent: opts?.fitToContent ?? true,
viewportZoomFactor: opts?.viewportZoomFactor,
animate: opts?.animate ?? true,
duration: opts?.duration,
scrollLock: opts?.scrollLock,
minZoom: opts?.minZoom,
maxZoom: opts?.maxZoom,
canvasOffsets: opts?.canvasOffsets,
});
}
this.scrollToContent(elements, {
fitToContent: opts?.fitToContent ?? true,
animate: opts?.animate ?? true,
});
} else if (isElementLink(target)) {
this.setState({
toast: {
@@ -4452,45 +4366,27 @@ class App extends React.Component<AppProps, AppState> {
}
this.cancelInProgressAnimation?.();
this.scrollToContentRequestId += 1;
this.debounceConstrainScrollState.cancel();
if (scrollConstraintsAnimationTimeout) {
clearTimeout(scrollConstraintsAnimationTimeout);
scrollConstraintsAnimationTimeout = null;
}
if (opts?.scrollLock != null && this.state.scrollConstraints) {
// Clear the previous lock before starting the next locked transition so
// stale constraint enforcement cannot snap us back mid-flight.
this.setState({ scrollConstraints: null });
}
// convert provided target into ExcalidrawElement[] if necessary
const targetElements = Array.isArray(target) ? target : [target];
if (!targetElements.length) {
return;
}
let zoom = this.state.zoom;
let scrollX = this.state.scrollX;
let scrollY = this.state.scrollY;
const targetBounds = getCommonBounds(targetElements);
if (opts?.fitToContent || opts?.fitToViewport) {
const resolvedViewport = resolveViewportForBounds({
bounds: targetBounds,
const { appState } = zoomToFit({
canvasOffsets: opts.canvasOffsets,
targetElements,
appState: this.state,
fitToViewport: !!opts?.fitToViewport,
viewportZoomFactor: opts?.viewportZoomFactor,
minZoom: opts?.minZoom,
maxZoom: opts?.maxZoom,
});
zoom = resolvedViewport.zoom;
scrollX = resolvedViewport.scrollX;
scrollY = resolvedViewport.scrollY;
zoom = appState.zoom;
scrollX = appState.scrollX;
scrollY = appState.scrollY;
} else {
// compute only the viewport location, without any zoom adjustment
const scroll = calculateScrollCenter(targetElements, this.state);
@@ -4498,72 +4394,51 @@ class App extends React.Component<AppProps, AppState> {
scrollY = scroll.scrollY;
}
const scrollConstraints = opts?.scrollLock
? getScrollConstraintsForBounds({
bounds: targetBounds,
zoom,
viewportDimensions: {
width: this.state.width,
height: this.state.height,
},
scrollLock: opts.scrollLock,
})
: null;
const requestId = this.scrollToContentRequestId;
const installScrollConstraints = () => {
if (!scrollConstraints || requestId !== this.scrollToContentRequestId) {
return;
}
this.setState({
scrollConstraints,
});
};
// when animating, we use RequestAnimationFrame to prevent the animation
// from slowing down other processes
if (opts?.animate) {
const fromValues = {
scrollX: this.state.scrollX,
scrollY: this.state.scrollY,
zoom: this.state.zoom.value,
};
const origScrollX = this.state.scrollX;
const origScrollY = this.state.scrollY;
const origZoom = this.state.zoom.value;
const toValues = { scrollX, scrollY, zoom: zoom.value };
this.animateTranslateCanvas({
fromValues,
toValues,
duration: opts?.duration ?? 500,
const cancel = easeToValuesRAF({
fromValues: {
scrollX: origScrollX,
scrollY: origScrollY,
zoom: origZoom,
},
toValues: { scrollX, scrollY, zoom: zoom.value },
interpolateValue: (from, to, progress, key) => {
// for zoom, use different easing
if (key === "zoom") {
return from * Math.pow(to / from, easeOut(progress));
}
// handle using default
return undefined;
},
onStep: ({ scrollX, scrollY, zoom }) => {
this.setState({
scrollX,
scrollY,
zoom: { value: zoom },
});
},
onStart: () => {
this.setState({ shouldCacheIgnoreZoom: true });
},
onEnd: () => {
if (requestId !== this.scrollToContentRequestId) {
return;
}
this.setState(
{ shouldCacheIgnoreZoom: false },
installScrollConstraints,
);
},
onCancel: () => {
if (requestId !== this.scrollToContentRequestId) {
return;
}
this.setState({ shouldCacheIgnoreZoom: false });
},
onCancel: () => {
this.setState({ shouldCacheIgnoreZoom: false });
},
duration: opts?.duration ?? 500,
});
} else if (scrollConstraints) {
this.setState({
scrollX,
scrollY,
zoom,
scrollConstraints,
});
this.cancelInProgressAnimation = () => {
cancel();
this.cancelInProgressAnimation = null;
};
} else {
this.setState({ scrollX, scrollY, zoom });
}
@@ -4577,165 +4452,11 @@ class App extends React.Component<AppProps, AppState> {
/** use when changing scrollX/scrollY/zoom based on user interaction */
private translateCanvas: React.Component<any, AppState>["setState"] = (
stateUpdate,
state,
) => {
this.cancelInProgressAnimation?.();
this.maybeUnfollowRemoteUser();
if (scrollConstraintsAnimationTimeout) {
clearTimeout(scrollConstraintsAnimationTimeout);
}
const partialNewState =
typeof stateUpdate === "function"
? (
stateUpdate as (
prevState: Readonly<AppState>,
props: Readonly<AppProps>,
) => AppState
)(this.state, this.props)
: stateUpdate;
const newState: AppState = {
...this.state,
...partialNewState,
...(this.state.scrollConstraints && {
// manually reset if setState in onCancel wasn't committed yet
shouldCacheIgnoreZoom: false,
}),
};
// RULE: cannot go below the minimum zoom level if zoom lock is enabled
const constrainedState =
newState.scrollConstraints && newState.scrollConstraints.lockZoom
? constrainScrollState(newState, "elastic")
: newState;
if (constrainedState.zoom.value > newState.zoom.value) {
newState.zoom = constrainedState.zoom;
newState.scrollX = constrainedState.scrollX;
newState.scrollY = constrainedState.scrollY;
this.debounceConstrainScrollState(newState);
return;
}
this.setState(newState);
if (this.state.scrollConstraints) {
if (newState.zoom.value < this.state.zoom.value) {
// zoom-out: debounce to allow centering on user's cursor position before constraining
this.debounceConstrainScrollState(newState);
} else {
// zoom-in or pan: valid range only expands on zoom-in, constrain immediately
this.setState(constrainScrollState(newState));
}
}
};
private debounceConstrainScrollState = debounce((state: AppState) => {
const newState = constrainScrollState(state, "rigid");
const fromValues = {
scrollX: this.state.scrollX,
scrollY: this.state.scrollY,
zoom: this.state.zoom.value,
};
const toValues = {
scrollX: newState.scrollX,
scrollY: newState.scrollY,
zoom: newState.zoom.value,
};
if (areCanvasTranslatesClose(fromValues, toValues)) {
return;
}
this.cancelInProgressAnimation?.();
this.animateToConstrainedArea(fromValues, toValues);
}, 200);
private animateToConstrainedArea = (
fromValues: AnimateTranslateCanvasValues,
toValues: AnimateTranslateCanvasValues,
) => {
const cleanUp = () => {
this.setState((state) => ({
shouldCacheIgnoreZoom: false,
scrollConstraints: {
...state.scrollConstraints!,
animateOnNextUpdate: false,
},
}));
};
this.animateTranslateCanvas({
fromValues,
toValues,
duration: 200,
onStart: () => {
this.setState((state) => {
return {
shouldCacheIgnoreZoom: true,
scrollConstraints: {
...state.scrollConstraints!,
animateOnNextUpdate: false,
},
};
});
},
onEnd: cleanUp,
onCancel: cleanUp,
});
};
private animateTranslateCanvas = ({
fromValues,
toValues,
duration,
onStart,
onEnd,
onCancel,
}: {
fromValues: AnimateTranslateCanvasValues;
toValues: AnimateTranslateCanvasValues;
duration: number;
onStart: () => void;
onEnd: () => void;
onCancel: () => void;
}) => {
const cancel = easeToValuesRAF({
fromValues,
toValues,
interpolateValue: (from, to, progress, key) => {
// for zoom, use different easing
if (key === "zoom") {
return from * Math.pow(to / from, easeOut(progress));
}
// handle using default
return undefined;
},
onStep: ({ scrollX, scrollY, zoom }) => {
this.setState({
scrollX,
scrollY,
zoom: { value: zoom },
});
},
onStart,
onEnd: () => {
this.cancelInProgressAnimation = null;
onEnd();
},
onCancel: () => {
this.cancelInProgressAnimation = null;
onCancel();
},
duration,
});
this.cancelInProgressAnimation = () => {
cancel();
this.cancelInProgressAnimation = null;
};
this.setState(state);
};
setToast = (toast: AppState["toast"]) => {
@@ -4884,6 +4605,11 @@ 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>,
@@ -5916,22 +5642,16 @@ class App extends React.Component<AppProps, AppState> {
const initialScale = gesture.initialScale;
if (initialScale) {
this.setState((state) =>
constrainScrollState(
this.setState((state) => ({
...getStateForZoom(
{
...state,
...getStateForZoom(
{
viewportX: this.lastViewportPosition.x,
viewportY: this.lastViewportPosition.y,
nextZoom: getNormalizedZoom(initialScale * event.scale),
},
state,
),
viewportX: this.lastViewportPosition.x,
viewportY: this.lastViewportPosition.y,
nextZoom: getNormalizedZoom(initialScale * event.scale),
},
"loose",
state,
),
);
}));
}
});
@@ -12908,21 +12628,17 @@ class App extends React.Component<AppProps, AppState> {
// reduced amplification for small deltas (small movements on a trackpad)
Math.min(1, absDelta / 20);
this.translateCanvas((state) => {
const nextZoom = getNormalizedZoom(newZoom);
const anchor = getConstrainedZoomAnchor(
this.translateCanvas((state) => ({
...getStateForZoom(
{
viewportX: this.lastViewportPosition.x,
viewportY: this.lastViewportPosition.y,
nextZoom,
nextZoom: getNormalizedZoom(newZoom),
},
state,
);
return {
...getStateForZoom({ ...anchor, nextZoom }, state),
shouldCacheIgnoreZoom: true,
};
});
),
shouldCacheIgnoreZoom: true,
}));
this.resetShouldCacheIgnoreZoomDebounced();
return;
}
@@ -13080,71 +12796,6 @@ class App extends React.Component<AppProps, AppState> {
await setLanguage(currentLang);
this.setAppState({});
}
/**
* Sets the scroll constraints of the application state.
*
* @param scrollConstraints - The new scroll constraints.
*/
public setScrollConstraints = (
scrollConstraints: ScrollConstraints | null,
) => {
if (this.props.scrollConstraints !== undefined) {
console.warn(SET_SCROLL_CONSTRAINTS_CONTROLLED_WARNING);
return;
}
this.syncScrollConstraints(scrollConstraints);
};
private syncScrollConstraints = (
scrollConstraints: ScrollConstraints | null,
) => {
this.debounceConstrainScrollState.cancel();
if (scrollConstraintsAnimationTimeout) {
clearTimeout(scrollConstraintsAnimationTimeout);
scrollConstraintsAnimationTimeout = null;
}
this.cancelInProgressAnimation?.();
if (scrollConstraints) {
this.setState(
{
scrollConstraints,
shouldCacheIgnoreZoom: false,
},
() => {
const newState = constrainScrollState(
{
...this.state,
scrollConstraints,
},
"rigid",
);
this.animateToConstrainedArea(
{
scrollX: this.state.scrollX,
scrollY: this.state.scrollY,
zoom: this.state.zoom.value,
},
{
scrollX: newState.scrollX,
scrollY: newState.scrollY,
zoom: newState.zoom.value,
},
);
},
);
} else {
this.setState({
scrollConstraints: null,
shouldCacheIgnoreZoom: false,
});
}
};
}
// -----------------------------------------------------------------------------
+60 -10
View File
@@ -87,10 +87,22 @@ 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[] = [];
@@ -105,6 +117,20 @@ 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;
@@ -119,6 +145,8 @@ 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);
@@ -131,15 +159,14 @@ export class History {
this.redoStack.length = 0;
}
this.onHistoryChangedEmitter.trigger(
new HistoryChangedEvent(this.isUndoStackEmpty, this.isRedoStackEmpty),
);
this.emitHistoryChanged();
}
public undo(elements: SceneElementsMap, appState: AppState) {
return this.perform(
elements,
appState,
"undo",
() => History.pop(this.undoStack),
(entry: HistoryDelta) => History.push(this.redoStack, entry),
);
@@ -149,6 +176,7 @@ export class History {
return this.perform(
elements,
appState,
"redo",
() => History.pop(this.redoStack),
(entry: HistoryDelta) => History.push(this.undoStack, entry),
);
@@ -157,6 +185,7 @@ export class History {
private perform(
elements: SceneElementsMap,
appState: AppState,
direction: HistoryPerformDirection,
pop: () => HistoryDelta | null,
push: (entry: HistoryDelta) => void,
): [SceneElementsMap, AppState] | void {
@@ -177,9 +206,15 @@ 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] =
historyDelta.applyTo(nextElements, nextAppState, prevSnapshot);
effectiveDelta.applyTo(nextElements, nextAppState, prevSnapshot);
const prevElements = prevSnapshot.elements;
const nextSnapshot = prevSnapshot.maybeClone(
@@ -190,7 +225,7 @@ export class History {
const change = StoreChange.create(prevSnapshot, nextSnapshot);
const delta = HistoryDelta.applyLatestChanges(
historyDelta,
effectiveDelta,
prevElements,
nextElements,
);
@@ -203,12 +238,12 @@ export class History {
delta,
});
historyDelta = delta;
entryToPush = delta;
}
prevSnapshot = nextSnapshot;
} finally {
push(historyDelta);
push(entryToPush);
}
if (containsVisibleChange) {
@@ -222,12 +257,16 @@ 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.onHistoryChangedEmitter.trigger(
new HistoryChangedEvent(this.isUndoStackEmpty, this.isRedoStackEmpty),
);
this.emitHistoryChanged();
}
}
private emitHistoryChanged() {
this.onHistoryChangedEmitter.trigger(
new HistoryChangedEvent(this.isUndoStackEmpty, this.isRedoStackEmpty),
);
}
private static pop(stack: HistoryDelta[]): HistoryDelta | null {
if (!stack.length) {
return null;
@@ -246,4 +285,15 @@ 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);
}
}
-2
View File
@@ -98,7 +98,6 @@ const ExcalidrawBase = (props: ExcalidrawProps) => {
aiEnabled,
showDeprecatedFonts,
renderScrollbars,
scrollConstraints,
} = props;
const canvasActions = props.UIOptions?.canvasActions;
@@ -209,7 +208,6 @@ const ExcalidrawBase = (props: ExcalidrawProps) => {
aiEnabled={aiEnabled !== false}
showDeprecatedFonts={showDeprecatedFonts}
renderScrollbars={renderScrollbars}
scrollConstraints={scrollConstraints}
>
{children}
</App>
+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",
@@ -1,590 +0,0 @@
import { MAX_ZOOM } from "@excalidraw/common";
import { isShallowEqual } from "@excalidraw/common";
import { clamp } from "@excalidraw/math";
import { getNormalizedZoom } from "./normalize";
import type {
AnimateTranslateCanvasValues,
AppState,
ScrollConstraints,
ScrollToContentLockOptions,
} from "../types";
// Constants for viewport zoom factor and overscroll allowance
const MIN_VIEWPORT_ZOOM_FACTOR = 0.1;
const MAX_VIEWPORT_ZOOM_FACTOR = MAX_ZOOM;
const DEFAULT_VIEWPORT_ZOOM_FACTOR = 0.2;
const DEFAULT_OVERSCROLL_ALLOWANCE = 0.2;
// Memoization variable to cache constraints for performance optimization
let memoizedValues: {
previousState: Pick<
AppState,
"zoom" | "width" | "height" | "scrollConstraints"
>;
constraints: ReturnType<typeof calculateConstraints>;
allowOverscroll: boolean;
} | null = null;
type CanvasTranslate = Pick<AppState, "scrollX" | "scrollY" | "zoom">;
export const getScrollConstraintsForBounds = ({
bounds,
zoom,
viewportDimensions,
scrollLock,
}: {
bounds: readonly [number, number, number, number];
zoom: AppState["zoom"];
viewportDimensions: Pick<AppState, "width" | "height">;
scrollLock?: boolean | ScrollToContentLockOptions;
}): ScrollConstraints => {
const [x1, y1, x2, y2] = bounds;
const width = x2 - x1;
const height = y2 - y1;
const baseZoom = Math.min(
viewportDimensions.width / width,
viewportDimensions.height / height,
);
const lockOptions =
scrollLock && typeof scrollLock === "object" ? scrollLock : undefined;
const viewportZoomFactor =
lockOptions?.viewportZoomFactor ?? zoom.value / baseZoom;
return {
x: x1,
y: y1,
width,
height,
animateOnNextUpdate: false,
lockZoom: !!lockOptions?.lockZoom,
overscrollAllowance: lockOptions?.overscrollAllowance,
viewportZoomFactor,
};
};
/**
* Calculates the zoom levels necessary to fit the constrained scrollable area within the viewport on the X and Y axes.
*
* The function considers the dimensions of the scrollable area, the dimensions of the viewport, the viewport zoom factor,
* and whether the zoom should be locked. It then calculates the necessary zoom levels for the X and Y axes separately.
* If the zoom should be locked, it calculates the maximum zoom level that fits the scrollable area within the viewport,
* factoring in the viewport zoom factor. If the zoom should not be locked, the maximum zoom level is set to null.
*
* @param scrollConstraints - The constraints of the scrollable area including width, height, and position.
* @param width - The width of the viewport.
* @param height - The height of the viewport.
* @returns An object containing the calculated zoom levels for the X and Y axes, and the initial zoom level.
*/
const calculateZoomLevel = (
scrollConstraints: ScrollConstraints,
width: AppState["width"],
height: AppState["height"],
) => {
const viewportZoomFactor = scrollConstraints.viewportZoomFactor
? clamp(
scrollConstraints.viewportZoomFactor,
MIN_VIEWPORT_ZOOM_FACTOR,
MAX_VIEWPORT_ZOOM_FACTOR,
)
: DEFAULT_VIEWPORT_ZOOM_FACTOR;
const scrollableWidth = scrollConstraints.width;
const scrollableHeight = scrollConstraints.height;
const zoomLevelX = width / scrollableWidth;
const zoomLevelY = height / scrollableHeight;
const initialZoomLevel = getNormalizedZoom(
Math.min(zoomLevelX, zoomLevelY) * viewportZoomFactor,
);
return { zoomLevelX, zoomLevelY, initialZoomLevel };
};
/**
* Calculates the effective zoom level based on the scroll constraints and current zoom.
*
* @param params - Object containing scrollConstraints, width, height, and zoom.
* @returns An object with the effective zoom level, initial zoom level, and zoom levels for X and Y axes.
*/
const calculateZoom = ({
scrollConstraints,
width,
height,
zoom,
}: {
scrollConstraints: ScrollConstraints;
width: AppState["width"];
height: AppState["height"];
zoom: AppState["zoom"];
}) => {
const { zoomLevelX, zoomLevelY, initialZoomLevel } = calculateZoomLevel(
scrollConstraints,
width,
height,
);
const effectiveZoom = scrollConstraints.lockZoom
? Math.max(initialZoomLevel, zoom.value)
: zoom.value;
return {
effectiveZoom: getNormalizedZoom(effectiveZoom),
initialZoomLevel,
zoomLevelX,
zoomLevelY,
};
};
/**
* Calculates the scroll bounds (min and max scroll values) based on the scroll constraints and zoom level.
*
* @param params - Object containing scrollConstraints, width, height, effectiveZoom, zoomLevelX, zoomLevelY, and allowOverscroll.
* @returns An object with min and max scroll values for X and Y axes.
*/
const calculateScrollBounds = ({
scrollConstraints,
width,
height,
effectiveZoom,
zoomLevelX,
zoomLevelY,
allowOverscroll,
}: {
scrollConstraints: ScrollConstraints;
width: AppState["width"];
height: AppState["height"];
effectiveZoom: number;
zoomLevelX: number;
zoomLevelY: number;
allowOverscroll: boolean;
}) => {
const overscrollAllowance =
scrollConstraints.overscrollAllowance ?? DEFAULT_OVERSCROLL_ALLOWANCE;
const validatedOverscroll = clamp(overscrollAllowance, 0, 1);
const calculateCenter = (zoom: number) => {
const centerX =
scrollConstraints.x + (scrollConstraints.width - width / zoom) / -2;
const centerY =
scrollConstraints.y + (scrollConstraints.height - height / zoom) / -2;
return { centerX, centerY };
};
const { centerX, centerY } = calculateCenter(effectiveZoom);
const overscrollValue = Math.min(
validatedOverscroll * scrollConstraints.width,
validatedOverscroll * scrollConstraints.height,
);
const fitsX = effectiveZoom <= zoomLevelX;
const fitsY = effectiveZoom <= zoomLevelY;
const getScrollRange = (
axis: "x" | "y",
fits: boolean,
constraint: ScrollConstraints,
viewportSize: number,
zoom: number,
overscroll: number,
) => {
const { pos, size } =
axis === "x"
? { pos: constraint.x, size: constraint.width }
: { pos: constraint.y, size: constraint.height };
const center = axis === "x" ? centerX : centerY;
if (allowOverscroll) {
return fits
? { min: center - overscroll, max: center + overscroll }
: {
min: pos - size + viewportSize / zoom - overscroll,
max: pos + overscroll,
};
}
return fits
? { min: center, max: center }
: { min: pos - size + viewportSize / zoom, max: pos };
};
const xRange = getScrollRange(
"x",
fitsX,
scrollConstraints,
width,
effectiveZoom,
overscrollValue,
);
const yRange = getScrollRange(
"y",
fitsY,
scrollConstraints,
height,
effectiveZoom,
overscrollValue,
);
return {
minScrollX: xRange.min,
maxScrollX: xRange.max,
minScrollY: yRange.min,
maxScrollY: yRange.max,
};
};
/**
* Calculates the scroll constraints including min and max scroll values and the effective zoom level.
*
* @param params - Object containing scrollConstraints, width, height, zoom, and allowOverscroll.
* @returns An object with min and max scroll values, effective zoom, and initial zoom level.
*/
const calculateConstraints = ({
scrollConstraints,
width,
height,
zoom,
allowOverscroll,
}: {
scrollConstraints: ScrollConstraints;
width: AppState["width"];
height: AppState["height"];
zoom: AppState["zoom"];
allowOverscroll: boolean;
}) => {
const { effectiveZoom, initialZoomLevel, zoomLevelX, zoomLevelY } =
calculateZoom({ scrollConstraints, width, height, zoom });
const scrollBounds = calculateScrollBounds({
scrollConstraints,
width,
height,
effectiveZoom,
zoomLevelX,
zoomLevelY,
allowOverscroll,
});
return {
...scrollBounds,
effectiveZoom: { value: effectiveZoom },
initialZoomLevel,
};
};
/**
* Constrains the scroll values within the provided min and max bounds.
*
* @param params - Object containing scrollX, scrollY, minScrollX, maxScrollX, minScrollY, maxScrollY, and constrainedZoom.
* @returns An object with constrained scrollX, scrollY, and zoom.
*/
const constrainScrollValues = ({
scrollX,
scrollY,
minScrollX,
maxScrollX,
minScrollY,
maxScrollY,
constrainedZoom,
}: {
scrollX: number;
scrollY: number;
minScrollX: number;
maxScrollX: number;
minScrollY: number;
maxScrollY: number;
constrainedZoom: AppState["zoom"];
}): CanvasTranslate => {
const constrainedScrollX = clamp(scrollX, minScrollX, maxScrollX);
const constrainedScrollY = clamp(scrollY, minScrollY, maxScrollY);
return {
scrollX: constrainedScrollX,
scrollY: constrainedScrollY,
zoom: constrainedZoom,
};
};
/**
* Inverts the scroll constraints to align with the state scrollX and scrollY values, which are inverted.
* This is a temporary fix and should be removed once issue #5965 is resolved.
*
* @param originalScrollConstraints - The original scroll constraints.
* @returns The aligned scroll constraints with inverted x and y coordinates.
*/
const alignScrollConstraints = (
originalScrollConstraints: ScrollConstraints,
): ScrollConstraints => {
return {
...originalScrollConstraints,
x: originalScrollConstraints.x * -1,
y: originalScrollConstraints.y * -1,
};
};
/**
* Determines whether the current viewport is outside the constrained area.
*
* @param state - The application state.
* @returns True if the viewport is outside the constrained area, false otherwise.
*/
const isViewportOutsideOfConstrainedArea = (state: AppState): boolean => {
if (!state.scrollConstraints) {
return false;
}
const {
scrollX,
scrollY,
width,
height,
scrollConstraints: inverseScrollConstraints,
zoom,
} = state;
const scrollConstraints = alignScrollConstraints(inverseScrollConstraints);
const adjustedWidth = width / zoom.value;
const adjustedHeight = height / zoom.value;
return (
scrollX > scrollConstraints.x ||
scrollX - adjustedWidth < scrollConstraints.x - scrollConstraints.width ||
scrollY > scrollConstraints.y ||
scrollY - adjustedHeight < scrollConstraints.y - scrollConstraints.height
);
};
/**
* Calculates the scroll center coordinates and the optimal zoom level to fit the constrained scrollable area within the viewport.
*
* @param state - The application state.
* @param scroll - Object containing current scrollX and scrollY.
* @returns An object with the calculated scrollX, scrollY, and zoom.
*/
export const calculateConstrainedScrollCenter = (
state: AppState,
{ scrollX, scrollY }: Pick<AppState, "scrollX" | "scrollY">,
): CanvasTranslate => {
const { width, height, scrollConstraints } = state;
if (!scrollConstraints) {
return { scrollX, scrollY, zoom: state.zoom };
}
const adjustedConstraints = alignScrollConstraints(scrollConstraints);
const zoomLevels = calculateZoomLevel(adjustedConstraints, width, height);
const initialZoom = { value: zoomLevels.initialZoomLevel };
const constraints = calculateConstraints({
scrollConstraints: adjustedConstraints,
width,
height,
zoom: initialZoom,
allowOverscroll: false,
});
return {
scrollX: constraints.minScrollX,
scrollY: constraints.minScrollY,
zoom: constraints.effectiveZoom,
};
};
/**
* Encodes scroll constraints into a compact string.
*
* @param constraints - The scroll constraints to encode.
* @returns A compact encoded string representing the scroll constraints.
*/
export const encodeConstraints = (constraints: ScrollConstraints): string => {
const payload = {
x: constraints.x,
y: constraints.y,
w: constraints.width,
h: constraints.height,
a: !!constraints.animateOnNextUpdate,
l: !!constraints.lockZoom,
v: constraints.viewportZoomFactor ?? 1,
oa: constraints.overscrollAllowance ?? DEFAULT_OVERSCROLL_ALLOWANCE,
};
const serialized = JSON.stringify(payload);
return encodeURIComponent(window.btoa(serialized).replace(/=+/, ""));
};
/**
* Decodes a compact string back into scroll constraints.
*
* @param encoded - The encoded string representing the scroll constraints.
* @returns The decoded scroll constraints object.
*/
export const decodeConstraints = (encoded: string): ScrollConstraints => {
try {
const decodedStr = window.atob(decodeURIComponent(encoded));
const parsed = JSON.parse(decodedStr) as {
x: number;
y: number;
w: number;
h: number;
a: boolean;
l: boolean;
v: number;
oa: number;
};
return {
x: parsed.x || 0,
y: parsed.y || 0,
width: parsed.w || 0,
height: parsed.h || 0,
lockZoom: parsed.l || false,
viewportZoomFactor: parsed.v || 1,
animateOnNextUpdate: parsed.a || false,
overscrollAllowance: parsed.oa || DEFAULT_OVERSCROLL_ALLOWANCE,
};
} catch (error) {
return {
x: 0,
y: 0,
width: 0,
height: 0,
animateOnNextUpdate: false,
lockZoom: false,
viewportZoomFactor: 1,
overscrollAllowance: DEFAULT_OVERSCROLL_ALLOWANCE,
};
}
};
type Options = { allowOverscroll: boolean; disableAnimation: boolean };
const DEFAULT_OPTION: Options = {
allowOverscroll: true,
disableAnimation: false,
};
/**
* Constrains the AppState scroll values within the defined scroll constraints.
*
* constraintMode can be "elastic", "rigid", or "loose":
* - "elastic": snaps to constraints but allows overscroll
* - "rigid": snaps to constraints without overscroll
* - "loose": allows overscroll and disables animation/snapping to constraints
*
* @param state - The original AppState.
* @param options - Options for allowing overscroll and disabling animation.
* @returns A new AppState object with constrained scroll values.
*/
export const constrainScrollState = (
state: AppState,
constraintMode: "elastic" | "rigid" | "loose" = "elastic",
): AppState => {
if (!state.scrollConstraints) {
return state;
}
const {
scrollX,
scrollY,
width,
height,
scrollConstraints: inverseScrollConstraints,
zoom,
} = state;
let allowOverscroll: boolean;
let disableAnimation: boolean;
switch (constraintMode) {
case "elastic":
({ allowOverscroll, disableAnimation } = DEFAULT_OPTION);
break;
case "rigid":
allowOverscroll = false;
disableAnimation = false;
break;
case "loose":
allowOverscroll = true;
disableAnimation = true;
break;
default:
({ allowOverscroll, disableAnimation } = DEFAULT_OPTION);
break;
}
const scrollConstraints = alignScrollConstraints(inverseScrollConstraints);
const canUseMemoizedValues =
memoizedValues &&
memoizedValues.previousState.scrollConstraints &&
memoizedValues.allowOverscroll === allowOverscroll &&
isShallowEqual(
state.scrollConstraints,
memoizedValues.previousState.scrollConstraints,
) &&
isShallowEqual(
{ zoom: zoom.value, width, height },
{
zoom: memoizedValues.previousState.zoom.value,
width: memoizedValues.previousState.width,
height: memoizedValues.previousState.height,
},
);
const constraints = canUseMemoizedValues
? memoizedValues!.constraints
: calculateConstraints({
scrollConstraints,
width,
height,
zoom,
allowOverscroll,
});
if (!canUseMemoizedValues) {
memoizedValues = {
previousState: {
zoom: state.zoom,
width: state.width,
height: state.height,
scrollConstraints: state.scrollConstraints,
},
constraints,
allowOverscroll,
};
}
const constrainedValues =
zoom.value >= constraints.effectiveZoom.value
? constrainScrollValues({
scrollX,
scrollY,
minScrollX: constraints.minScrollX,
maxScrollX: constraints.maxScrollX,
minScrollY: constraints.minScrollY,
maxScrollY: constraints.maxScrollY,
constrainedZoom: constraints.effectiveZoom,
})
: calculateConstrainedScrollCenter(state, { scrollX, scrollY });
return {
...state,
scrollConstraints: {
...state.scrollConstraints,
animateOnNextUpdate: disableAnimation
? false
: isViewportOutsideOfConstrainedArea(state),
},
...constrainedValues,
};
};
/**
* Checks if two canvas translate values are close within a threshold.
*
* @param from - First set of canvas translate values.
* @param to - Second set of canvas translate values.
* @returns True if the values are close, false otherwise.
*/
export const areCanvasTranslatesClose = (
from: AnimateTranslateCanvasValues,
to: AnimateTranslateCanvasValues,
): boolean => {
const threshold = 0.1;
return (
Math.abs(from.scrollX - to.scrollX) < threshold &&
Math.abs(from.scrollY - to.scrollY) < threshold &&
Math.abs(from.zoom - to.zoom) < threshold
);
};
-62
View File
@@ -1,67 +1,5 @@
import { constrainScrollState } from "./scrollConstraints";
import type { AppState, NormalizedZoomValue } from "../types";
/**
* When zooming out with scroll constraints active, the cursor-anchored zoom may
* produce a scroll position outside the valid bounds, causing a snap-back.
*
* This function adjusts the effective zoom anchor point so the resulting scroll
* stays within bounds — silently, without any snap. When no adjustment is needed
* (zoom-in, no constraints, or already within bounds) it returns the original
* viewport position unchanged.
*/
export const getConstrainedZoomAnchor = (
{
viewportX,
viewportY,
nextZoom,
}: { viewportX: number; viewportY: number; nextZoom: NormalizedZoomValue },
state: AppState,
): { viewportX: number; viewportY: number } => {
if (!state.scrollConstraints || nextZoom >= state.zoom.value) {
return { viewportX, viewportY };
}
const appLayerX = viewportX - state.offsetLeft;
const appLayerY = viewportY - state.offsetTop;
const factor = 1 / nextZoom - 1 / state.zoom.value;
const newScrollX = state.scrollX + appLayerX * factor;
const newScrollY = state.scrollY + appLayerY * factor;
const constrained = constrainScrollState(
{
...state,
scrollX: newScrollX,
scrollY: newScrollY,
zoom: { value: nextZoom },
},
"rigid",
);
if (
constrained.scrollX === newScrollX &&
constrained.scrollY === newScrollY
) {
return { viewportX, viewportY };
}
const adjustedAppLayerX =
constrained.scrollX !== newScrollX
? (constrained.scrollX - state.scrollX) / factor
: appLayerX;
const adjustedAppLayerY =
constrained.scrollY !== newScrollY
? (constrained.scrollY - state.scrollY) / factor
: appLayerY;
return {
viewportX: adjustedAppLayerX + state.offsetLeft,
viewportY: adjustedAppLayerY + state.offsetTop,
};
};
export const getStateForZoom = (
{
viewportX,
@@ -961,7 +961,6 @@ exports[`contextMenu element > right-clicking on a group should select whole gro
},
"previousSelectedElementIds": {},
"resizingElement": null,
"scrollConstraints": null,
"scrollX": 0,
"scrollY": 0,
"scrolledOutside": false,
@@ -1159,7 +1158,6 @@ exports[`contextMenu element > selecting 'Add to library' in context menu adds e
},
"previousSelectedElementIds": {},
"resizingElement": null,
"scrollConstraints": null,
"scrollX": 0,
"scrollY": 0,
"scrolledOutside": true,
@@ -1375,7 +1373,6 @@ exports[`contextMenu element > selecting 'Bring forward' in context menu brings
},
"previousSelectedElementIds": {},
"resizingElement": null,
"scrollConstraints": null,
"scrollX": 0,
"scrollY": 0,
"scrolledOutside": false,
@@ -1708,7 +1705,6 @@ exports[`contextMenu element > selecting 'Bring to front' in context menu brings
},
"previousSelectedElementIds": {},
"resizingElement": null,
"scrollConstraints": null,
"scrollX": 0,
"scrollY": 0,
"scrolledOutside": false,
@@ -2041,7 +2037,6 @@ exports[`contextMenu element > selecting 'Copy styles' in context menu copies st
},
"previousSelectedElementIds": {},
"resizingElement": null,
"scrollConstraints": null,
"scrollX": 0,
"scrollY": 0,
"scrolledOutside": true,
@@ -2257,7 +2252,6 @@ exports[`contextMenu element > selecting 'Delete' in context menu deletes elemen
},
"previousSelectedElementIds": {},
"resizingElement": null,
"scrollConstraints": null,
"scrollX": 0,
"scrollY": 0,
"scrolledOutside": false,
@@ -2500,7 +2494,6 @@ exports[`contextMenu element > selecting 'Duplicate' in context menu duplicates
},
"previousSelectedElementIds": {},
"resizingElement": null,
"scrollConstraints": null,
"scrollX": 0,
"scrollY": 0,
"scrolledOutside": false,
@@ -2802,7 +2795,6 @@ exports[`contextMenu element > selecting 'Group selection' in context menu group
"id3": true,
},
"resizingElement": null,
"scrollConstraints": null,
"scrollX": 0,
"scrollY": 0,
"scrolledOutside": false,
@@ -3174,7 +3166,6 @@ exports[`contextMenu element > selecting 'Paste styles' in context menu pastes s
},
"previousSelectedElementIds": {},
"resizingElement": null,
"scrollConstraints": null,
"scrollX": 0,
"scrollY": 0,
"scrolledOutside": false,
@@ -3669,7 +3660,6 @@ exports[`contextMenu element > selecting 'Send backward' in context menu sends e
},
"previousSelectedElementIds": {},
"resizingElement": null,
"scrollConstraints": null,
"scrollX": 0,
"scrollY": 0,
"scrolledOutside": false,
@@ -3994,7 +3984,6 @@ exports[`contextMenu element > selecting 'Send to back' in context menu sends el
},
"previousSelectedElementIds": {},
"resizingElement": null,
"scrollConstraints": null,
"scrollX": 0,
"scrollY": 0,
"scrolledOutside": false,
@@ -4321,7 +4310,6 @@ exports[`contextMenu element > selecting 'Ungroup selection' in context menu ung
"id3": true,
},
"resizingElement": null,
"scrollConstraints": null,
"scrollX": 0,
"scrollY": 0,
"scrolledOutside": false,
@@ -5608,7 +5596,6 @@ exports[`contextMenu element > shows 'Group selection' in context menu for multi
"id0": true,
},
"resizingElement": null,
"scrollConstraints": null,
"scrollX": 0,
"scrollY": 0,
"scrolledOutside": false,
@@ -6827,7 +6814,6 @@ exports[`contextMenu element > shows 'Ungroup selection' in context menu for gro
"id0": true,
},
"resizingElement": null,
"scrollConstraints": null,
"scrollX": 0,
"scrollY": 0,
"scrolledOutside": false,
@@ -7787,7 +7773,6 @@ exports[`contextMenu element > shows context menu for canvas > [end of test] app
},
"previousSelectedElementIds": {},
"resizingElement": null,
"scrollConstraints": null,
"scrollX": 0,
"scrollY": 0,
"scrolledOutside": false,
@@ -8786,7 +8771,6 @@ exports[`contextMenu element > shows context menu for element > [end of test] ap
},
"previousSelectedElementIds": {},
"resizingElement": null,
"scrollConstraints": null,
"scrollX": 0,
"scrollY": 0,
"scrolledOutside": true,
@@ -9782,7 +9766,6 @@ exports[`contextMenu element > shows context menu for element > [end of test] ap
},
"previousSelectedElementIds": {},
"resizingElement": null,
"scrollConstraints": null,
"scrollX": 0,
"scrollY": 0,
"scrolledOutside": false,
@@ -85,7 +85,6 @@ exports[`history > multiplayer undo/redo > conflicts in arrows and their bindabl
"id4": true,
},
"resizingElement": null,
"scrollConstraints": null,
"scrollX": 0,
"scrollY": 0,
"searchMatches": null,
@@ -720,7 +719,6 @@ exports[`history > multiplayer undo/redo > conflicts in arrows and their bindabl
"id4": true,
},
"resizingElement": null,
"scrollConstraints": null,
"scrollX": 0,
"scrollY": 0,
"searchMatches": null,
@@ -1284,7 +1282,6 @@ exports[`history > multiplayer undo/redo > conflicts in arrows and their bindabl
},
"previousSelectedElementIds": {},
"resizingElement": null,
"scrollConstraints": null,
"scrollX": 0,
"scrollY": 0,
"searchMatches": null,
@@ -1646,7 +1643,6 @@ exports[`history > multiplayer undo/redo > conflicts in arrows and their bindabl
},
"previousSelectedElementIds": {},
"resizingElement": null,
"scrollConstraints": null,
"scrollX": 0,
"scrollY": 0,
"searchMatches": null,
@@ -2010,7 +2006,6 @@ exports[`history > multiplayer undo/redo > conflicts in arrows and their bindabl
},
"previousSelectedElementIds": {},
"resizingElement": null,
"scrollConstraints": null,
"scrollX": 0,
"scrollY": 0,
"searchMatches": null,
@@ -2272,7 +2267,6 @@ exports[`history > multiplayer undo/redo > conflicts in arrows and their bindabl
},
"previousSelectedElementIds": {},
"resizingElement": null,
"scrollConstraints": null,
"scrollX": 0,
"scrollY": 0,
"searchMatches": null,
@@ -2730,7 +2724,6 @@ exports[`history > multiplayer undo/redo > conflicts in bound text elements and
},
"previousSelectedElementIds": {},
"resizingElement": null,
"scrollConstraints": null,
"scrollX": 0,
"scrollY": 0,
"searchMatches": null,
@@ -3035,7 +3028,6 @@ exports[`history > multiplayer undo/redo > conflicts in bound text elements and
},
"previousSelectedElementIds": {},
"resizingElement": null,
"scrollConstraints": null,
"scrollX": 0,
"scrollY": 0,
"searchMatches": null,
@@ -3356,7 +3348,6 @@ exports[`history > multiplayer undo/redo > conflicts in bound text elements and
},
"previousSelectedElementIds": {},
"resizingElement": null,
"scrollConstraints": null,
"scrollX": 0,
"scrollY": 0,
"searchMatches": null,
@@ -3652,7 +3643,6 @@ exports[`history > multiplayer undo/redo > conflicts in bound text elements and
},
"previousSelectedElementIds": {},
"resizingElement": null,
"scrollConstraints": null,
"scrollX": 0,
"scrollY": 0,
"searchMatches": null,
@@ -3940,7 +3930,6 @@ exports[`history > multiplayer undo/redo > conflicts in bound text elements and
},
"previousSelectedElementIds": {},
"resizingElement": null,
"scrollConstraints": null,
"scrollX": 0,
"scrollY": 0,
"searchMatches": null,
@@ -4177,7 +4166,6 @@ exports[`history > multiplayer undo/redo > conflicts in bound text elements and
},
"previousSelectedElementIds": {},
"resizingElement": null,
"scrollConstraints": null,
"scrollX": 0,
"scrollY": 0,
"searchMatches": null,
@@ -4436,7 +4424,6 @@ exports[`history > multiplayer undo/redo > conflicts in bound text elements and
},
"previousSelectedElementIds": {},
"resizingElement": null,
"scrollConstraints": null,
"scrollX": 0,
"scrollY": 0,
"searchMatches": null,
@@ -4709,7 +4696,6 @@ exports[`history > multiplayer undo/redo > conflicts in bound text elements and
},
"previousSelectedElementIds": {},
"resizingElement": null,
"scrollConstraints": null,
"scrollX": 0,
"scrollY": 0,
"searchMatches": null,
@@ -4940,7 +4926,6 @@ exports[`history > multiplayer undo/redo > conflicts in bound text elements and
},
"previousSelectedElementIds": {},
"resizingElement": null,
"scrollConstraints": null,
"scrollX": 0,
"scrollY": 0,
"searchMatches": null,
@@ -5171,7 +5156,6 @@ exports[`history > multiplayer undo/redo > conflicts in bound text elements and
},
"previousSelectedElementIds": {},
"resizingElement": null,
"scrollConstraints": null,
"scrollX": 0,
"scrollY": 0,
"searchMatches": null,
@@ -5420,7 +5404,6 @@ exports[`history > multiplayer undo/redo > conflicts in bound text elements and
},
"previousSelectedElementIds": {},
"resizingElement": null,
"scrollConstraints": null,
"scrollX": 0,
"scrollY": 0,
"searchMatches": null,
@@ -5678,7 +5661,6 @@ exports[`history > multiplayer undo/redo > conflicts in frames and their childre
},
"previousSelectedElementIds": {},
"resizingElement": null,
"scrollConstraints": null,
"scrollX": 0,
"scrollY": 0,
"searchMatches": null,
@@ -5937,7 +5919,6 @@ exports[`history > multiplayer undo/redo > should iterate through the history wh
"id1": true,
},
"resizingElement": null,
"scrollConstraints": null,
"scrollX": 0,
"scrollY": 0,
"searchMatches": null,
@@ -6268,7 +6249,6 @@ exports[`history > multiplayer undo/redo > should iterate through the history wh
"id8": true,
},
"resizingElement": null,
"scrollConstraints": null,
"scrollX": 0,
"scrollY": 0,
"searchMatches": null,
@@ -6697,7 +6677,6 @@ exports[`history > multiplayer undo/redo > should iterate through the history wh
"id1": true,
},
"resizingElement": null,
"scrollConstraints": null,
"scrollX": 0,
"scrollY": 0,
"searchMatches": null,
@@ -7074,7 +7053,6 @@ exports[`history > multiplayer undo/redo > should iterate through the history wh
},
"previousSelectedElementIds": {},
"resizingElement": null,
"scrollConstraints": null,
"scrollX": 0,
"scrollY": 0,
"searchMatches": null,
@@ -7385,7 +7363,6 @@ exports[`history > multiplayer undo/redo > should iterate through the history wh
},
"previousSelectedElementIds": {},
"resizingElement": null,
"scrollConstraints": null,
"scrollX": 0,
"scrollY": 0,
"searchMatches": null,
@@ -7679,7 +7656,6 @@ exports[`history > multiplayer undo/redo > should iterate through the history wh
},
"previousSelectedElementIds": {},
"resizingElement": null,
"scrollConstraints": null,
"scrollX": 0,
"scrollY": 0,
"searchMatches": null,
@@ -7911,7 +7887,6 @@ exports[`history > multiplayer undo/redo > should iterate through the history wh
},
"previousSelectedElementIds": {},
"resizingElement": null,
"scrollConstraints": null,
"scrollX": 0,
"scrollY": 0,
"searchMatches": null,
@@ -8265,7 +8240,6 @@ exports[`history > multiplayer undo/redo > should iterate through the history wh
},
"previousSelectedElementIds": {},
"resizingElement": null,
"scrollConstraints": null,
"scrollX": 0,
"scrollY": 0,
"searchMatches": null,
@@ -8622,7 +8596,6 @@ exports[`history > multiplayer undo/redo > should not let remote changes to inte
"id3": true,
},
"resizingElement": null,
"scrollConstraints": null,
"scrollX": 0,
"scrollY": 0,
"searchMatches": null,
@@ -9027,7 +9000,6 @@ exports[`history > multiplayer undo/redo > should not let remote changes to inte
},
"previousSelectedElementIds": {},
"resizingElement": null,
"scrollConstraints": null,
"scrollX": 0,
"scrollY": 0,
"searchMatches": null,
@@ -9308,7 +9280,6 @@ exports[`history > multiplayer undo/redo > should not let remote changes to inte
},
"previousSelectedElementIds": {},
"resizingElement": null,
"scrollConstraints": null,
"scrollX": 0,
"scrollY": 0,
"searchMatches": null,
@@ -9574,7 +9545,6 @@ exports[`history > multiplayer undo/redo > should not override remote changes on
},
"previousSelectedElementIds": {},
"resizingElement": null,
"scrollConstraints": null,
"scrollX": 0,
"scrollY": 0,
"searchMatches": null,
@@ -9841,7 +9811,6 @@ exports[`history > multiplayer undo/redo > should not override remote changes on
},
"previousSelectedElementIds": {},
"resizingElement": null,
"scrollConstraints": null,
"scrollX": 0,
"scrollY": 0,
"searchMatches": null,
@@ -10078,7 +10047,6 @@ exports[`history > multiplayer undo/redo > should override remotely added groups
},
"previousSelectedElementIds": {},
"resizingElement": null,
"scrollConstraints": null,
"scrollX": 0,
"scrollY": 0,
"searchMatches": null,
@@ -10374,7 +10342,6 @@ exports[`history > multiplayer undo/redo > should override remotely added points
},
"previousSelectedElementIds": {},
"resizingElement": null,
"scrollConstraints": null,
"scrollX": 0,
"scrollY": 0,
"searchMatches": null,
@@ -10693,7 +10660,6 @@ exports[`history > multiplayer undo/redo > should redistribute deltas when eleme
},
"previousSelectedElementIds": {},
"resizingElement": null,
"scrollConstraints": null,
"scrollX": 0,
"scrollY": 0,
"searchMatches": null,
@@ -10934,7 +10900,6 @@ exports[`history > multiplayer undo/redo > should redraw arrows on undo > [end o
},
"previousSelectedElementIds": {},
"resizingElement": null,
"scrollConstraints": null,
"scrollX": 0,
"scrollY": 0,
"searchMatches": null,
@@ -11858,7 +11823,6 @@ exports[`history > multiplayer undo/redo > should update history entries after r
},
"previousSelectedElementIds": {},
"resizingElement": null,
"scrollConstraints": null,
"scrollX": 0,
"scrollY": 0,
"searchMatches": null,
@@ -12120,7 +12084,6 @@ exports[`history > singleplayer undo/redo > remounting undo/redo buttons should
},
"previousSelectedElementIds": {},
"resizingElement": null,
"scrollConstraints": null,
"scrollX": 0,
"scrollY": 0,
"searchMatches": null,
@@ -12357,7 +12320,6 @@ exports[`history > singleplayer undo/redo > should clear the redo stack on eleme
},
"previousSelectedElementIds": {},
"resizingElement": null,
"scrollConstraints": null,
"scrollX": 0,
"scrollY": 0,
"searchMatches": null,
@@ -12596,7 +12558,6 @@ exports[`history > singleplayer undo/redo > should create entry when selecting f
},
"previousSelectedElementIds": {},
"resizingElement": null,
"scrollConstraints": null,
"scrollX": 0,
"scrollY": 0,
"searchMatches": null,
@@ -12992,7 +12953,6 @@ exports[`history > singleplayer undo/redo > should create new history entry on e
},
"previousSelectedElementIds": {},
"resizingElement": null,
"scrollConstraints": null,
"scrollX": 0,
"scrollY": 0,
"searchMatches": null,
@@ -13201,7 +13161,6 @@ exports[`history > singleplayer undo/redo > should create new history entry on e
},
"previousSelectedElementIds": {},
"resizingElement": null,
"scrollConstraints": null,
"scrollX": 0,
"scrollY": 0,
"searchMatches": null,
@@ -13413,7 +13372,6 @@ exports[`history > singleplayer undo/redo > should create new history entry on i
},
"previousSelectedElementIds": {},
"resizingElement": null,
"scrollConstraints": null,
"scrollX": 0,
"scrollY": 0,
"searchMatches": null,
@@ -13713,7 +13671,6 @@ exports[`history > singleplayer undo/redo > should create new history entry on i
},
"previousSelectedElementIds": {},
"resizingElement": null,
"scrollConstraints": null,
"scrollX": 0,
"scrollY": 0,
"searchMatches": null,
@@ -14016,7 +13973,6 @@ exports[`history > singleplayer undo/redo > should create new history entry on s
},
"previousSelectedElementIds": {},
"resizingElement": null,
"scrollConstraints": null,
"scrollX": -50,
"scrollY": -50,
"searchMatches": null,
@@ -14260,7 +14216,6 @@ exports[`history > singleplayer undo/redo > should disable undo/redo buttons whe
},
"previousSelectedElementIds": {},
"resizingElement": null,
"scrollConstraints": null,
"scrollX": 0,
"scrollY": 0,
"searchMatches": null,
@@ -14499,7 +14454,6 @@ exports[`history > singleplayer undo/redo > should end up with no history entry
},
"previousSelectedElementIds": {},
"resizingElement": null,
"scrollConstraints": null,
"scrollX": 0,
"scrollY": 0,
"searchMatches": null,
@@ -14740,7 +14694,6 @@ exports[`history > singleplayer undo/redo > should iterate through the history w
"id0": true,
},
"resizingElement": null,
"scrollConstraints": null,
"scrollX": 0,
"scrollY": 0,
"searchMatches": null,
@@ -14987,7 +14940,6 @@ exports[`history > singleplayer undo/redo > should not clear the redo stack on s
},
"previousSelectedElementIds": {},
"resizingElement": null,
"scrollConstraints": null,
"scrollX": 0,
"scrollY": 0,
"searchMatches": null,
@@ -15323,7 +15275,6 @@ exports[`history > singleplayer undo/redo > should not collapse when applying co
},
"previousSelectedElementIds": {},
"resizingElement": null,
"scrollConstraints": null,
"scrollX": 0,
"scrollY": 0,
"searchMatches": null,
@@ -15492,7 +15443,6 @@ exports[`history > singleplayer undo/redo > should not end up with history entry
},
"previousSelectedElementIds": {},
"resizingElement": null,
"scrollConstraints": null,
"scrollX": 0,
"scrollY": 0,
"searchMatches": null,
@@ -15781,7 +15731,6 @@ exports[`history > singleplayer undo/redo > should not end up with history entry
},
"previousSelectedElementIds": {},
"resizingElement": null,
"scrollConstraints": null,
"scrollX": 0,
"scrollY": 0,
"searchMatches": null,
@@ -16046,7 +15995,6 @@ exports[`history > singleplayer undo/redo > should not modify anything on unrela
},
"previousSelectedElementIds": {},
"resizingElement": null,
"scrollConstraints": null,
"scrollX": 0,
"scrollY": 0,
"searchMatches": null,
@@ -16200,7 +16148,6 @@ exports[`history > singleplayer undo/redo > should not override appstate changes
"id0": true,
},
"resizingElement": null,
"scrollConstraints": null,
"scrollX": 0,
"scrollY": 0,
"searchMatches": null,
@@ -16485,7 +16432,6 @@ exports[`history > singleplayer undo/redo > should support appstate name or view
},
"previousSelectedElementIds": {},
"resizingElement": null,
"scrollConstraints": null,
"scrollX": 0,
"scrollY": 0,
"searchMatches": null,
@@ -16648,7 +16594,6 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding
"id0": true,
},
"resizingElement": null,
"scrollConstraints": null,
"scrollX": 0,
"scrollY": 0,
"searchMatches": null,
@@ -17398,7 +17343,6 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding
"id0": true,
},
"resizingElement": null,
"scrollConstraints": null,
"scrollX": 0,
"scrollY": 0,
"searchMatches": null,
@@ -18046,7 +17990,6 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding
"id0": true,
},
"resizingElement": null,
"scrollConstraints": null,
"scrollX": 0,
"scrollY": 0,
"searchMatches": null,
@@ -18692,7 +18635,6 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding
},
"previousSelectedElementIds": {},
"resizingElement": null,
"scrollConstraints": null,
"scrollX": 0,
"scrollY": 0,
"searchMatches": null,
@@ -19445,7 +19387,6 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding
"id0": true,
},
"resizingElement": null,
"scrollConstraints": null,
"scrollX": 0,
"scrollY": 0,
"searchMatches": null,
@@ -20215,7 +20156,6 @@ exports[`history > singleplayer undo/redo > should support changes in elements'
"id0": true,
},
"resizingElement": null,
"scrollConstraints": null,
"scrollX": 0,
"scrollY": 0,
"searchMatches": null,
@@ -20697,7 +20637,6 @@ exports[`history > singleplayer undo/redo > should support duplication of groups
"id1": true,
},
"resizingElement": null,
"scrollConstraints": null,
"scrollX": 0,
"scrollY": 0,
"searchMatches": null,
@@ -21210,7 +21149,6 @@ exports[`history > singleplayer undo/redo > should support element creation, del
"id3": true,
},
"resizingElement": null,
"scrollConstraints": null,
"scrollX": 0,
"scrollY": 0,
"searchMatches": null,
@@ -21671,7 +21609,6 @@ exports[`history > singleplayer undo/redo > should support linear element creati
"id0": true,
},
"resizingElement": null,
"scrollConstraints": null,
"scrollX": 0,
"scrollY": 0,
"searchMatches": null,
@@ -88,7 +88,6 @@ exports[`given element A and group of elements B and given both are selected whe
"id6": true,
},
"resizingElement": null,
"scrollConstraints": null,
"scrollX": 0,
"scrollY": 0,
"scrolledOutside": false,
@@ -516,7 +515,6 @@ exports[`given element A and group of elements B and given both are selected whe
"id6": true,
},
"resizingElement": null,
"scrollConstraints": null,
"scrollX": 0,
"scrollY": 0,
"scrolledOutside": false,
@@ -930,7 +928,6 @@ exports[`regression tests > Cmd/Ctrl-click exclusively select element under poin
},
"previousSelectedElementIds": {},
"resizingElement": null,
"scrollConstraints": null,
"scrollX": 0,
"scrollY": 0,
"scrolledOutside": false,
@@ -1498,7 +1495,6 @@ exports[`regression tests > Drags selected element when hitting only bounding bo
},
"previousSelectedElementIds": {},
"resizingElement": null,
"scrollConstraints": null,
"scrollX": 0,
"scrollY": 0,
"scrolledOutside": false,
@@ -1709,7 +1705,6 @@ exports[`regression tests > adjusts z order when grouping > [end of test] appSta
"id0": true,
},
"resizingElement": null,
"scrollConstraints": null,
"scrollX": 0,
"scrollY": 0,
"scrolledOutside": false,
@@ -2095,7 +2090,6 @@ exports[`regression tests > alt-drag duplicates an element > [end of test] appSt
"id0": true,
},
"resizingElement": null,
"scrollConstraints": null,
"scrollX": 0,
"scrollY": 0,
"scrolledOutside": false,
@@ -2340,7 +2334,6 @@ exports[`regression tests > arrow keys > [end of test] appState 1`] = `
},
"previousSelectedElementIds": {},
"resizingElement": null,
"scrollConstraints": null,
"scrollX": 0,
"scrollY": 0,
"scrolledOutside": false,
@@ -2524,7 +2517,6 @@ exports[`regression tests > can drag element that covers another element, while
"id6": true,
},
"resizingElement": null,
"scrollConstraints": null,
"scrollX": 0,
"scrollY": 0,
"scrolledOutside": false,
@@ -2849,7 +2841,6 @@ exports[`regression tests > change the properties of a shape > [end of test] app
},
"previousSelectedElementIds": {},
"resizingElement": null,
"scrollConstraints": null,
"scrollX": 0,
"scrollY": 0,
"scrolledOutside": false,
@@ -3108,7 +3099,6 @@ exports[`regression tests > click on an element and drag it > [dragged] appState
"id0": true,
},
"resizingElement": null,
"scrollConstraints": null,
"scrollX": 0,
"scrollY": 0,
"scrolledOutside": false,
@@ -3351,7 +3341,6 @@ exports[`regression tests > click on an element and drag it > [end of test] appS
"id0": true,
},
"resizingElement": null,
"scrollConstraints": null,
"scrollX": 0,
"scrollY": 0,
"scrolledOutside": false,
@@ -3589,7 +3578,6 @@ exports[`regression tests > click to select a shape > [end of test] appState 1`]
"id3": true,
},
"resizingElement": null,
"scrollConstraints": null,
"scrollX": 0,
"scrollY": 0,
"scrolledOutside": false,
@@ -3849,7 +3837,6 @@ exports[`regression tests > click-drag to select a group > [end of test] appStat
"id6": true,
},
"resizingElement": null,
"scrollConstraints": null,
"scrollX": 0,
"scrollY": 0,
"scrolledOutside": false,
@@ -4163,7 +4150,6 @@ exports[`regression tests > deleting last but one element in editing group shoul
},
"previousSelectedElementIds": {},
"resizingElement": null,
"scrollConstraints": null,
"scrollX": 0,
"scrollY": 0,
"scrolledOutside": false,
@@ -4604,7 +4590,6 @@ exports[`regression tests > deselects group of selected elements on pointer down
"id3": true,
},
"resizingElement": null,
"scrollConstraints": null,
"scrollX": 0,
"scrollY": 0,
"scrolledOutside": false,
@@ -4889,7 +4874,6 @@ exports[`regression tests > deselects group of selected elements on pointer up w
"id3": true,
},
"resizingElement": null,
"scrollConstraints": null,
"scrollX": 0,
"scrollY": 0,
"scrolledOutside": false,
@@ -5166,7 +5150,6 @@ exports[`regression tests > deselects selected element on pointer down when poin
"id0": true,
},
"resizingElement": null,
"scrollConstraints": null,
"scrollX": 0,
"scrollY": 0,
"scrolledOutside": false,
@@ -5376,7 +5359,6 @@ exports[`regression tests > deselects selected element, on pointer up, when clic
"id0": true,
},
"resizingElement": null,
"scrollConstraints": null,
"scrollX": 0,
"scrollY": 0,
"scrolledOutside": false,
@@ -5576,7 +5558,6 @@ exports[`regression tests > double click to edit a group > [end of test] appStat
},
"previousSelectedElementIds": {},
"resizingElement": null,
"scrollConstraints": null,
"scrollX": 0,
"scrollY": 0,
"scrolledOutside": false,
@@ -5974,7 +5955,6 @@ exports[`regression tests > drags selected elements from point inside common bou
"id3": true,
},
"resizingElement": null,
"scrollConstraints": null,
"scrollX": 0,
"scrollY": 0,
"scrolledOutside": false,
@@ -6270,7 +6250,6 @@ exports[`regression tests > draw every type of shape > [end of test] appState 1`
},
"previousSelectedElementIds": {},
"resizingElement": null,
"scrollConstraints": null,
"scrollX": 0,
"scrollY": 0,
"scrolledOutside": false,
@@ -7063,7 +7042,6 @@ exports[`regression tests > given a group of selected elements with an element t
"id6": true,
},
"resizingElement": null,
"scrollConstraints": null,
"scrollX": 0,
"scrollY": 0,
"scrolledOutside": false,
@@ -7398,7 +7376,6 @@ exports[`regression tests > given a selected element A and a not selected elemen
"id0": true,
},
"resizingElement": null,
"scrollConstraints": null,
"scrollX": 0,
"scrollY": 0,
"scrolledOutside": false,
@@ -7679,7 +7656,6 @@ exports[`regression tests > given selected element A with lower z-index than uns
"id0": true,
},
"resizingElement": null,
"scrollConstraints": null,
"scrollX": 0,
"scrollY": 0,
"scrolledOutside": false,
@@ -7916,7 +7892,6 @@ exports[`regression tests > given selected element A with lower z-index than uns
"id0": true,
},
"resizingElement": null,
"scrollConstraints": null,
"scrollX": 0,
"scrollY": 0,
"scrolledOutside": false,
@@ -8156,7 +8131,6 @@ exports[`regression tests > key 2 selects rectangle tool > [end of test] appStat
},
"previousSelectedElementIds": {},
"resizingElement": null,
"scrollConstraints": null,
"scrollX": 0,
"scrollY": 0,
"scrolledOutside": false,
@@ -8338,7 +8312,6 @@ exports[`regression tests > key 3 selects diamond tool > [end of test] appState
},
"previousSelectedElementIds": {},
"resizingElement": null,
"scrollConstraints": null,
"scrollX": 0,
"scrollY": 0,
"scrolledOutside": false,
@@ -8520,7 +8493,6 @@ exports[`regression tests > key 4 selects ellipse tool > [end of test] appState
},
"previousSelectedElementIds": {},
"resizingElement": null,
"scrollConstraints": null,
"scrollX": 0,
"scrollY": 0,
"scrolledOutside": false,
@@ -8702,7 +8674,6 @@ exports[`regression tests > key 5 selects arrow tool > [end of test] appState 1`
},
"previousSelectedElementIds": {},
"resizingElement": null,
"scrollConstraints": null,
"scrollX": 0,
"scrollY": 0,
"scrolledOutside": false,
@@ -8936,7 +8907,6 @@ exports[`regression tests > key 6 selects line tool > [end of test] appState 1`]
},
"previousSelectedElementIds": {},
"resizingElement": null,
"scrollConstraints": null,
"scrollX": 0,
"scrollY": 0,
"scrolledOutside": false,
@@ -9168,7 +9138,6 @@ exports[`regression tests > key 7 selects freedraw tool > [end of test] appState
},
"previousSelectedElementIds": {},
"resizingElement": null,
"scrollConstraints": null,
"scrollX": 0,
"scrollY": 0,
"scrolledOutside": false,
@@ -9362,7 +9331,6 @@ exports[`regression tests > key a selects arrow tool > [end of test] appState 1`
},
"previousSelectedElementIds": {},
"resizingElement": null,
"scrollConstraints": null,
"scrollX": 0,
"scrollY": 0,
"scrolledOutside": false,
@@ -9596,7 +9564,6 @@ exports[`regression tests > key d selects diamond tool > [end of test] appState
},
"previousSelectedElementIds": {},
"resizingElement": null,
"scrollConstraints": null,
"scrollX": 0,
"scrollY": 0,
"scrolledOutside": false,
@@ -9778,7 +9745,6 @@ exports[`regression tests > key l selects line tool > [end of test] appState 1`]
},
"previousSelectedElementIds": {},
"resizingElement": null,
"scrollConstraints": null,
"scrollX": 0,
"scrollY": 0,
"scrolledOutside": false,
@@ -10010,7 +9976,6 @@ exports[`regression tests > key o selects ellipse tool > [end of test] appState
},
"previousSelectedElementIds": {},
"resizingElement": null,
"scrollConstraints": null,
"scrollX": 0,
"scrollY": 0,
"scrolledOutside": false,
@@ -10192,7 +10157,6 @@ exports[`regression tests > key p selects freedraw tool > [end of test] appState
},
"previousSelectedElementIds": {},
"resizingElement": null,
"scrollConstraints": null,
"scrollX": 0,
"scrollY": 0,
"scrolledOutside": false,
@@ -10386,7 +10350,6 @@ exports[`regression tests > key r selects rectangle tool > [end of test] appStat
},
"previousSelectedElementIds": {},
"resizingElement": null,
"scrollConstraints": null,
"scrollX": 0,
"scrollY": 0,
"scrolledOutside": false,
@@ -10572,7 +10535,6 @@ exports[`regression tests > make a group and duplicate it > [end of test] appSta
"id6": true,
},
"resizingElement": null,
"scrollConstraints": null,
"scrollX": 0,
"scrollY": 0,
"scrolledOutside": false,
@@ -11103,7 +11065,6 @@ exports[`regression tests > noop interaction after undo shouldn't create history
"id0": true,
},
"resizingElement": null,
"scrollConstraints": null,
"scrollX": 0,
"scrollY": 0,
"scrolledOutside": false,
@@ -11383,7 +11344,6 @@ exports[`regression tests > pinch-to-zoom works > [end of test] appState 1`] = `
},
"previousSelectedElementIds": {},
"resizingElement": null,
"scrollConstraints": null,
"scrollX": "-6.25000",
"scrollY": 0,
"scrolledOutside": false,
@@ -11510,7 +11470,6 @@ exports[`regression tests > shift click on selected element should deselect it o
"id0": true,
},
"resizingElement": null,
"scrollConstraints": null,
"scrollX": 0,
"scrollY": 0,
"scrolledOutside": false,
@@ -11713,7 +11672,6 @@ exports[`regression tests > shift-click to multiselect, then drag > [end of test
"id3": true,
},
"resizingElement": null,
"scrollConstraints": null,
"scrollX": 0,
"scrollY": 0,
"scrolledOutside": false,
@@ -12035,7 +11993,6 @@ exports[`regression tests > should group elements and ungroup them > [end of tes
"id6": true,
},
"resizingElement": null,
"scrollConstraints": null,
"scrollX": 0,
"scrollY": 0,
"scrolledOutside": false,
@@ -12467,7 +12424,6 @@ exports[`regression tests > single-clicking on a subgroup of a selected group sh
"id3": true,
},
"resizingElement": null,
"scrollConstraints": null,
"scrollX": 0,
"scrollY": 0,
"scrolledOutside": false,
@@ -13107,7 +13063,6 @@ exports[`regression tests > spacebar + drag scrolls the canvas > [end of test] a
},
"previousSelectedElementIds": {},
"resizingElement": null,
"scrollConstraints": null,
"scrollX": 60,
"scrollY": 60,
"scrolledOutside": false,
@@ -13234,7 +13189,6 @@ exports[`regression tests > supports nested groups > [end of test] appState 1`]
"id0": true,
},
"resizingElement": null,
"scrollConstraints": null,
"scrollX": 0,
"scrollY": 0,
"scrolledOutside": false,
@@ -13868,7 +13822,6 @@ exports[`regression tests > switches from group of selected elements to another
"id6": true,
},
"resizingElement": null,
"scrollConstraints": null,
"scrollX": 0,
"scrollY": 0,
"scrolledOutside": false,
@@ -14208,7 +14161,6 @@ exports[`regression tests > switches selected element on pointer down > [end of
"id3": true,
},
"resizingElement": null,
"scrollConstraints": null,
"scrollX": 0,
"scrollY": 0,
"scrolledOutside": false,
@@ -14472,7 +14424,6 @@ exports[`regression tests > two-finger scroll works > [end of test] appState 1`]
},
"previousSelectedElementIds": {},
"resizingElement": null,
"scrollConstraints": null,
"scrollX": 20,
"scrollY": "-18.53553",
"scrolledOutside": false,
@@ -14597,7 +14548,6 @@ exports[`regression tests > undo/redo drawing an element > [end of test] appStat
},
"previousSelectedElementIds": {},
"resizingElement": null,
"scrollConstraints": null,
"scrollX": 0,
"scrollY": 0,
"scrolledOutside": false,
@@ -14963,7 +14913,6 @@ exports[`regression tests > updates fontSize & fontFamily appState > [end of tes
},
"previousSelectedElementIds": {},
"resizingElement": null,
"scrollConstraints": null,
"scrollX": 0,
"scrollY": 0,
"scrolledOutside": false,
@@ -15091,7 +15040,6 @@ exports[`regression tests > zoom hotkeys > [end of test] appState 1`] = `
},
"previousSelectedElementIds": {},
"resizingElement": null,
"scrollConstraints": null,
"scrollX": 0,
"scrollY": 0,
"scrolledOutside": false,
@@ -1,135 +0,0 @@
import React from "react";
import { vi } from "vitest";
import { resolvablePromise } from "@excalidraw/common";
import { Excalidraw } from "../index";
import { API } from "./helpers/api";
import { act, render, waitFor } from "./test-utils";
import type { ExcalidrawImperativeAPI, ScrollConstraints } from "../types";
const FIRST_SCROLL_CONSTRAINTS: ScrollConstraints = {
x: 0,
y: 0,
width: 400,
height: 300,
lockZoom: true,
overscrollAllowance: 0,
viewportZoomFactor: 1,
animateOnNextUpdate: false,
};
const SECOND_SCROLL_CONSTRAINTS: ScrollConstraints = {
x: 100,
y: 200,
width: 500,
height: 350,
lockZoom: false,
overscrollAllowance: 0.2,
viewportZoomFactor: 0.8,
animateOnNextUpdate: false,
};
describe("scrollConstraints", () => {
afterEach(() => {
vi.restoreAllMocks();
});
it("syncs prop updates after mount", async () => {
const { rerender } = await render(<Excalidraw />);
expect(window.h.state.scrollConstraints).toBe(null);
rerender(<Excalidraw scrollConstraints={FIRST_SCROLL_CONSTRAINTS} />);
await waitFor(() => {
expect(window.h.state.scrollConstraints).toEqual(
FIRST_SCROLL_CONSTRAINTS,
);
});
rerender(<Excalidraw scrollConstraints={SECOND_SCROLL_CONSTRAINTS} />);
await waitFor(() => {
expect(window.h.state.scrollConstraints).toEqual(
SECOND_SCROLL_CONSTRAINTS,
);
});
rerender(<Excalidraw />);
await waitFor(() => {
expect(window.h.state.scrollConstraints).toBe(null);
});
});
it("ignores setScrollConstraints() when the prop is controlled", async () => {
const excalidrawAPIPromise = resolvablePromise<ExcalidrawImperativeAPI>();
const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
await render(
<Excalidraw
scrollConstraints={FIRST_SCROLL_CONSTRAINTS}
onExcalidrawAPI={(api) => {
if (api) {
excalidrawAPIPromise.resolve(api);
}
}}
/>,
);
const excalidrawAPI = await excalidrawAPIPromise;
act(() => {
excalidrawAPI.setScrollConstraints(SECOND_SCROLL_CONSTRAINTS);
});
expect(warn).toHaveBeenCalledWith(
"Excalidraw: `setScrollConstraints()` is ignored when the `scrollConstraints` prop is controlled. Update the prop value instead.",
);
expect(window.h.state.scrollConstraints).toEqual(FIRST_SCROLL_CONSTRAINTS);
});
it("ignores scrollToContent() scrollLock when the prop is controlled", async () => {
const excalidrawAPIPromise = resolvablePromise<ExcalidrawImperativeAPI>();
const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
await render(
<Excalidraw
scrollConstraints={FIRST_SCROLL_CONSTRAINTS}
onExcalidrawAPI={(api) => {
if (api) {
excalidrawAPIPromise.resolve(api);
}
}}
/>,
);
const excalidrawAPI = await excalidrawAPIPromise;
const rectangle = API.createElement({
x: 1000,
y: 1000,
width: 100,
height: 100,
});
API.setElements([rectangle]);
act(() => {
excalidrawAPI.scrollToContent(rectangle, {
animate: false,
fitToViewport: true,
scrollLock: {
lockZoom: true,
overscrollAllowance: 0,
},
});
});
expect(warn).toHaveBeenCalledWith(
"Excalidraw: `scrollToContent()` with `scrollLock` is ignored when the `scrollConstraints` prop is controlled. Update the prop value instead.",
);
expect(window.h.state.scrollConstraints).toEqual(FIRST_SCROLL_CONSTRAINTS);
});
});
+19 -20
View File
@@ -449,29 +449,28 @@ const stripProps = (
}, {} as Record<string, any>);
export const checkpointHistory = (history: History, name: string) => {
expect(
history.undoStack.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}] undo stack`);
const normalizeHistoryEntry = (entry: History["undoStack"][number]) => {
const { markers, ...rest } = entry;
expect(
history.redoStack.map((x) => ({
...x,
return {
...rest,
...(markers ? { markers } : {}),
elements: {
...x.elements,
added: stripProps(x.elements.added, ["seed", "versionNonce"]),
removed: stripProps(x.elements.removed, ["seed", "versionNonce"]),
updated: stripProps(x.elements.updated, ["seed", "versionNonce"]),
...entry.elements,
added: stripProps(entry.elements.added, ["seed", "versionNonce"]),
removed: stripProps(entry.elements.removed, ["seed", "versionNonce"]),
updated: stripProps(entry.elements.updated, ["seed", "versionNonce"]),
},
})),
).toMatchSnapshot(`[${name}] redo stack`);
};
};
expect(history.undoStack.map(normalizeHistoryEntry)).toMatchSnapshot(
`[${name}] undo stack`,
);
expect(history.redoStack.map(normalizeHistoryEntry)).toMatchSnapshot(
`[${name}] redo stack`,
);
};
/**
File diff suppressed because it is too large Load Diff
+289
View File
@@ -0,0 +1,289 @@
import { type StoreDelta } from "@excalidraw/element";
import type { Delta } from "@excalidraw/element";
import type { Mutable } from "@excalidraw/common/utility-types";
import type {
ExcalidrawElement,
OrderedExcalidrawElement,
SceneElementsMap,
} from "@excalidraw/element/types";
import type {
ElementChange,
ElementPropName,
TouchedElementProps,
} from "./types";
const LEDGER_IGNORED_PROPS = new Set([
"version",
"versionNonce",
"seed",
"updated",
"index",
]);
export const TX_UNDO_OVERRIDE_IGNORED_PROPS = new Set([
"version",
"versionNonce",
"isDeleted",
]);
type ElementRecord = Record<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
@@ -0,0 +1,13 @@
export { collectChangedElementIds } from "./diff";
export { TransactionLedger } from "./ledger";
export { Transaction } from "./transaction";
export { TransactionManager } from "./manager";
export type {
AppStateResolver,
AppStateResolverContext,
TransactionElementUpdate,
TransactionLedgerEntry,
TransactionStatus,
TransactionSummary,
} from "./types";
+333
View File
@@ -0,0 +1,333 @@
import { deepCopyElement } from "@excalidraw/element";
import type { Mutable } from "@excalidraw/common/utility-types";
import type {
ExcalidrawElement,
OrderedExcalidrawElement,
SceneElementsMap,
} from "@excalidraw/element/types";
import {
collectElementChanges,
collectTouchedProps,
getElementProp,
hasTouchedProps,
isLedgerValueEqual,
mergeTouchedProps,
setOrderedElementProp,
shallowCopySceneElements,
touchesWholeElement,
} from "./diff";
import type { TransactionLedgerEntry } from "./types";
/**
* Keeps transaction-level scene mutations and materializes synthetic snapshots
* for a single durable history commit.
*/
export class TransactionLedger {
private readonly entries = new Map<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
@@ -0,0 +1,291 @@
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);
}
}
@@ -0,0 +1,286 @@
import { randomId } from "@excalidraw/common";
import {
CaptureUpdateAction,
newElementWith,
type ElementUpdate,
type StoreDelta,
type TxUndoOverride,
} from "@excalidraw/element";
import { shallowCopySceneElements } from "./diff";
import { TransactionLedger } from "./ledger";
import {
type AppStateResolver,
type AppStateResolverContext,
type TransactionElementOfType,
type TransactionElementUpdate,
type TransactionStatus,
type TransactionSummary,
} from "./types";
import { TxUndoOverridePlanner } from "./undoOverridePlanner";
import type { TransactionManager } from "./manager";
import type {
AppClassProperties,
AppState,
ObservedAppState,
SceneData,
} from "../types";
type CommitOptions = {
resolveAppState?: AppStateResolver;
};
/**
* A transaction that records mutations via `updateScene(NEVER)` and commits
* a single synthetic durable history entry at the end.
*/
export class Transaction {
public readonly id = `tx-${randomId()}`;
private readonly app: AppClassProperties;
private readonly manager: TransactionManager;
private readonly ledger = new TransactionLedger();
private readonly undoOverridePlanner = new TxUndoOverridePlanner();
private readonly initialAppState: Partial<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
@@ -0,0 +1,83 @@
import type { ElementUpdate } from "@excalidraw/element";
import type {
ExcalidrawElement,
ExcalidrawNonSelectionElement,
} from "@excalidraw/element/types";
import type { ObservedAppState } from "../types";
export type ElementPropName = Extract<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;
@@ -0,0 +1,283 @@
import type { StoreDelta, TxUndoOverride } from "@excalidraw/element";
import type { ExcalidrawElement } from "@excalidraw/element/types";
import {
TX_UNDO_OVERRIDE_IGNORED_PROPS,
collectElementChanges,
getElementProp,
getElementPropEntries,
getUpdatedElementEntries,
hasTouchedProp,
isPartialTouchedProps,
isLedgerValueEqual,
serializeConsumedPropKey,
serializeIntermediateValue,
touchesWholeElement,
type ElementUpdatedEntry,
} from "./diff";
import type {
ElementPropName,
TouchedElementProps,
TransactionLedgerEntry,
} from "./types";
type TxUndoOverrideCandidate = Omit<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 -72
View File
@@ -454,7 +454,6 @@ export interface AppState {
userToFollow: UserToFollow | null;
/** the socket ids of the users following the current user */
followedBy: Set<SocketId>;
scrollConstraints: ScrollConstraints | null;
/** image cropping */
isCropping: boolean;
@@ -547,8 +546,6 @@ export type ExcalidrawInitialDataState = Merge<
ImportedDataState,
{
libraryItems?: MaybePromise<Required<ImportedDataState>["libraryItems"]>;
scrollX?: number;
scrollY?: number;
}
>;
@@ -665,7 +662,6 @@ export interface ExcalidrawProps {
onScrollChange?: (scrollX: number, scrollY: number, zoom: Zoom) => void;
onUserFollow?: (payload: OnUserFollowedPayload) => void;
children?: React.ReactNode;
scrollConstraints?: AppState["scrollConstraints"];
validateEmbeddable?:
| boolean
| string[]
@@ -800,6 +796,9 @@ 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"];
@@ -992,8 +991,6 @@ export interface ExcalidrawImperativeAPI {
) => UnsubscribeCallback;
onStateChange: InstanceType<typeof App>["onStateChange"];
onEvent: InstanceType<typeof App>["onEvent"];
app: InstanceType<typeof App>;
setScrollConstraints: InstanceType<typeof App>["setScrollConstraints"];
}
export type FrameNameBounds = {
@@ -1060,69 +1057,3 @@ export type Offsets = Partial<{
bottom: number;
left: number;
}>;
export type ScrollConstraints = {
x: number;
y: number;
width: number;
height: number;
animateOnNextUpdate?: boolean;
/**
* A factor that determines the minimum zoom level that should fit the
* constrained area into the viewport.
*/
viewportZoomFactor?: number;
/**
* If true, the user will not be able to zoom out beyond the scroll
* constraints (taking into account the viewportZoomFactor).
*/
lockZoom?: boolean;
/**
* <0-1> - how much can you scroll beyond the constrained area within the
* timeout window. Note you will still be snapped back to the constrained area
* after the timeout.
*/
overscrollAllowance?: number;
};
/**
* Optional scroll constraint settings derived from the final viewport computed
* by `scrollToContent()`.
*/
export type ScrollToContentLockOptions = {
lockZoom?: boolean;
overscrollAllowance?: number;
viewportZoomFactor?: number;
};
export type ScrollToContentOptions =
| ({
fitToContent?: boolean;
fitToViewport?: never;
viewportZoomFactor?: number;
animate?: boolean;
duration?: number;
scrollLock?: boolean | ScrollToContentLockOptions;
} & {
minZoom?: number;
maxZoom?: number;
canvasOffsets?: Offsets;
})
| ({
fitToContent?: never;
fitToViewport?: boolean;
viewportZoomFactor?: number;
animate?: boolean;
duration?: number;
scrollLock?: boolean | ScrollToContentLockOptions;
} & {
minZoom?: number;
maxZoom?: number;
canvasOffsets?: Offsets;
});
export type AnimateTranslateCanvasValues = {
scrollX: AppState["scrollX"];
scrollY: AppState["scrollY"];
zoom: AppState["zoom"]["value"];
};
@@ -85,7 +85,6 @@ exports[`exportToSvg > with default arguments 1`] = `
},
"previousSelectedElementIds": {},
"resizingElement": null,
"scrollConstraints": null,
"scrollX": 0,
"scrollY": 0,
"scrolledOutside": false,
+1
View File
@@ -1,4 +1,5 @@
{
"public": true,
"headers": [
{
"source": "/(.*)",