Compare 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 DiandClaude Opus 4.6 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
115 changed files with 5546 additions and 3977 deletions
+1 -1
View File
@@ -1,4 +1,4 @@
FROM node:24-bullseye FROM node:18-bullseye
# Vite wants to open the browser using `open`, so we # Vite wants to open the browser using `open`, so we
# need to install those utils. # need to install those utils.
+2 -2
View File
@@ -9,11 +9,11 @@ jobs:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 - uses: actions/checkout@v2
with: with:
fetch-depth: 2 fetch-depth: 2
- name: Setup Node.js - name: Setup Node.js
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4 uses: actions/setup-node@v2
with: with:
node-version: 20.x node-version: 20.x
- name: Set up publish access - name: Set up publish access
+1 -1
View File
@@ -9,5 +9,5 @@ jobs:
build-docker: build-docker:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 - uses: actions/checkout@v2
- run: docker build -t excalidraw . - run: docker build -t excalidraw .
+2 -2
View File
@@ -7,10 +7,10 @@ jobs:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 - uses: actions/checkout@v2
- name: Setup Node.js - name: Setup Node.js
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4 uses: actions/setup-node@v2
with: with:
node-version: 20.x node-version: 20.x
+2 -2
View File
@@ -10,12 +10,12 @@ jobs:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 - uses: actions/checkout@v4
with: with:
token: ${{ secrets.PUSH_TRANSLATIONS_COVERAGE_PAT }} token: ${{ secrets.PUSH_TRANSLATIONS_COVERAGE_PAT }}
- name: Setup Node.js - name: Setup Node.js
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4 uses: actions/setup-node@v2
with: with:
node-version: 20.x node-version: 20.x
+1 -1
View File
@@ -11,7 +11,7 @@ jobs:
steps: steps:
- name: Checkout repository - name: Checkout repository
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 uses: actions/checkout@v3
- name: Login to DockerHub - name: Login to DockerHub
uses: docker/login-action@465a07811f14bebb1938fbed4728c6a1ff8901fc # v2 uses: docker/login-action@465a07811f14bebb1938fbed4728c6a1ff8901fc # v2
with: with:
-86
View File
@@ -6,97 +6,11 @@ on:
- opened - opened
- edited - edited
- synchronize - synchronize
- labeled
- unlabeled
jobs: jobs:
semantic: semantic:
runs-on: ubuntu-latest runs-on: ubuntu-latest
permissions:
pull-requests: read
steps: steps:
- uses: amannn/action-semantic-pull-request@e32d7e603df1aa1ba07e981f2a23455dee596825 # v5 - uses: amannn/action-semantic-pull-request@e32d7e603df1aa1ba07e981f2a23455dee596825 # v5
with:
requireScope: true
scopes: |
app
editor
packages/excalidraw
packages/utils
docker
repo
ignoreLabels: |
skip-semantic-title
env: env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
label-scope:
needs: semantic
if: github.event.pull_request.head.repo.full_name == github.repository
runs-on: ubuntu-latest
permissions:
issues: write
pull-requests: write
steps:
- name: Label scoped PR
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
PR_NUMBER: ${{ github.event.pull_request.number }}
PR_TITLE: ${{ github.event.pull_request.title }}
REPOSITORY: ${{ github.repository }}
run: |
set -euo pipefail
scope_labels=(s-app s-editor s-package)
readarray -t desired_labels < <(
node <<'NODE'
const title = process.env.PR_TITLE;
const match = title.match(/^[a-z]+(?:\(([^)]+)\))?!?:/i);
const scopes = match?.[1]?.split(",").map((scope) => scope.trim()) ?? [];
const labels = new Set();
for (const scope of scopes) {
if (scope === "app") {
labels.add("s-app");
} else if (scope === "editor") {
labels.add("s-editor");
} else if (scope.startsWith("packages/")) {
labels.add("s-package");
}
}
process.stdout.write([...labels].join("\n"));
NODE
)
should_apply_label() {
local label="$1"
for desired_label in "${desired_labels[@]}"; do
if [[ "$desired_label" == "$label" ]]; then
return 0
fi
done
return 1
}
for label in "${scope_labels[@]}"; do
if ! should_apply_label "$label"; then
gh api \
--method DELETE \
"repos/${REPOSITORY}/issues/${PR_NUMBER}/labels/${label}" \
--silent 2>/dev/null || true
fi
done
for label in "${desired_labels[@]}"; do
if ! gh api \
--method POST \
"repos/${REPOSITORY}/issues/${PR_NUMBER}/labels" \
--field "labels[]=${label}" \
--silent; then
echo "::warning::Could not apply ${label}. The workflow token likely does not have issues:write permission for this PR."
fi
done
+2 -2
View File
@@ -9,9 +9,9 @@ jobs:
sentry: sentry:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 - uses: actions/checkout@v2
- name: Setup Node.js - name: Setup Node.js
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4 uses: actions/setup-node@v2
with: with:
node-version: 20.x node-version: 20.x
- name: Install and build - name: Install and build
+2 -2
View File
@@ -10,9 +10,9 @@ jobs:
CI_JOB_NUMBER: 1 CI_JOB_NUMBER: 1
steps: steps:
- name: Checkout repository - name: Checkout repository
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 uses: actions/checkout@v3
- name: Setup Node.js - name: Setup Node.js
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4 uses: actions/setup-node@v3
with: with:
node-version: 20.x node-version: 20.x
- name: Install in packages/excalidraw - name: Install in packages/excalidraw
+2 -2
View File
@@ -10,9 +10,9 @@ jobs:
pull-requests: write pull-requests: write
steps: steps:
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 - uses: actions/checkout@v2
- name: "Install Node" - name: "Install Node"
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4 uses: actions/setup-node@v2
with: with:
node-version: "20.x" node-version: "20.x"
- name: "Install Deps" - name: "Install Deps"
+2 -2
View File
@@ -8,9 +8,9 @@ jobs:
test: test:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 - uses: actions/checkout@v4
- name: Setup Node.js - name: Setup Node.js
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4 uses: actions/setup-node@v4
with: with:
node-version: 20.x node-version: 20.x
- name: Install and test - name: Install and test
+3 -3
View File
@@ -1,4 +1,4 @@
FROM --platform=${BUILDPLATFORM} node:24@sha256:8530f76a96d88820d288761f022e318970dda93d01536919fbc16076b7983e63 AS build FROM --platform=${BUILDPLATFORM} node:18 AS build
WORKDIR /opt/node_app WORKDIR /opt/node_app
@@ -7,13 +7,13 @@ COPY . .
# do not ignore optional dependencies: # do not ignore optional dependencies:
# Error: Cannot find module @rollup/rollup-linux-x64-gnu # Error: Cannot find module @rollup/rollup-linux-x64-gnu
RUN --mount=type=cache,target=/root/.cache/yarn \ RUN --mount=type=cache,target=/root/.cache/yarn \
npm_config_target_arch=${TARGETARCH} yarn --frozen-lockfile --network-timeout 600000 npm_config_target_arch=${TARGETARCH} yarn --network-timeout 600000
ARG NODE_ENV=production ARG NODE_ENV=production
RUN npm_config_target_arch=${TARGETARCH} yarn build:app:docker RUN npm_config_target_arch=${TARGETARCH} yarn build:app:docker
FROM nginx:stable-alpine-slim@sha256:2c605dbeab79a6b2a63340474fe58119d0ef95bdc4b1f41df0aa689659b3d13b FROM --platform=${TARGETPLATFORM} nginx:1.27-alpine
COPY --from=build /opt/node_app/excalidraw-app/build /usr/share/nginx/html COPY --from=build /opt/node_app/excalidraw-app/build /usr/share/nginx/html
+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.
+1 -1
View File
@@ -26,6 +26,7 @@ import {
get, get,
} from "idb-keyval"; } from "idb-keyval";
import { appJotaiStore, atom } from "excalidraw-app/app-jotai";
import { getNonDeletedElements } from "@excalidraw/element"; import { getNonDeletedElements } from "@excalidraw/element";
import type { LibraryPersistedData } from "@excalidraw/excalidraw/data/library"; import type { LibraryPersistedData } from "@excalidraw/excalidraw/data/library";
@@ -38,7 +39,6 @@ import type {
} from "@excalidraw/excalidraw/types"; } from "@excalidraw/excalidraw/types";
import type { MaybePromise } from "@excalidraw/common/utility-types"; import type { MaybePromise } from "@excalidraw/common/utility-types";
import { appJotaiStore, atom } from "../app-jotai";
import { SAVE_TO_LOCAL_STORAGE_TIMEOUT, STORAGE_KEYS } from "../app_constants"; import { SAVE_TO_LOCAL_STORAGE_TIMEOUT, STORAGE_KEYS } from "../app_constants";
import { FileManager } from "./FileManager"; import { FileManager } from "./FileManager";
-7
View File
@@ -75,13 +75,6 @@ export default defineConfig(({ mode }) => {
find: /^@excalidraw\/utils\/(.*?)/, find: /^@excalidraw\/utils\/(.*?)/,
replacement: path.resolve(__dirname, "../packages/utils/src/$1"), replacement: path.resolve(__dirname, "../packages/utils/src/$1"),
}, },
{
find: /^@excalidraw\/fractional-indexing$/,
replacement: path.resolve(
__dirname,
"../packages/fractional-indexing/src/index.ts",
),
},
], ],
}, },
build: { build: {
+1 -2
View File
@@ -56,8 +56,7 @@
"build:element": "yarn --cwd ./packages/element build:esm", "build:element": "yarn --cwd ./packages/element build:esm",
"build:excalidraw": "yarn --cwd ./packages/excalidraw build:esm", "build:excalidraw": "yarn --cwd ./packages/excalidraw build:esm",
"build:math": "yarn --cwd ./packages/math build:esm", "build:math": "yarn --cwd ./packages/math build:esm",
"build:fractional-indexing": "yarn --cwd ./packages/fractional-indexing build:esm", "build:packages": "yarn build:common && yarn build:math && yarn build:element && yarn build:excalidraw",
"build:packages": "yarn build:common && yarn build:fractional-indexing && yarn build:math && yarn build:element && yarn build:excalidraw",
"build:version": "yarn --cwd ./excalidraw-app build:version", "build:version": "yarn --cwd ./excalidraw-app build:version",
"build": "yarn --cwd ./excalidraw-app build", "build": "yarn --cwd ./excalidraw-app build",
"build:preview": "yarn --cwd ./excalidraw-app build:preview", "build:preview": "yarn --cwd ./excalidraw-app build:preview",
+3 -4
View File
@@ -337,10 +337,9 @@ export const MAX_DECIMALS_FOR_SVG_EXPORT = 2;
export const EXPORT_SCALES = [1, 2, 3]; export const EXPORT_SCALES = [1, 2, 3];
export const DEFAULT_EXPORT_PADDING = 10; // px export const DEFAULT_EXPORT_PADDING = 10; // px
export const DEFAULT_IMAGE_OPTIONS: AppProps["imageOptions"] = { export const DEFAULT_MAX_IMAGE_WIDTH_OR_HEIGHT = 1440;
maxWidthOrHeight: 1440,
maxFileSizeBytes: 4 * 1024 * 1024, export const MAX_ALLOWED_FILE_BYTES = 4 * 1024 * 1024;
};
export const SVG_NS = "http://www.w3.org/2000/svg"; export const SVG_NS = "http://www.w3.org/2000/svg";
export const SVG_DOCUMENT_PREAMBLE = `<?xml version="1.0" standalone="no"?> export const SVG_DOCUMENT_PREAMBLE = `<?xml version="1.0" standalone="no"?>
+1 -2
View File
@@ -64,7 +64,6 @@
}, },
"dependencies": { "dependencies": {
"@excalidraw/common": "0.18.0", "@excalidraw/common": "0.18.0",
"@excalidraw/math": "0.18.0", "@excalidraw/math": "0.18.0"
"@excalidraw/fractional-indexing": "3.3.0"
} }
} }
+36 -12
View File
@@ -338,18 +338,27 @@ export class Scene {
this.callbacks.clear(); this.callbacks.clear();
} }
/** low-level - generally use app.insertNewElements() */ insertElementAtIndex(element: ExcalidrawElement, index: number) {
insertElementsAtIndex( if (!Number.isFinite(index) || index < 0) {
elements: ExcalidrawElement[], throw new Error(
/** null indicates end of the array */ "insertElementAtIndex can only be called with index >= 0",
index: number | null, );
) {
if (!elements.length) {
return;
} }
if (index === null) { const nextElements = [
index = this.elements.length; ...this.elements.slice(0, index),
element,
...this.elements.slice(index),
];
syncMovedIndices(nextElements, arrayToMap([element]));
this.replaceAllElements(nextElements);
}
insertElementsAtIndex(elements: ExcalidrawElement[], index: number) {
if (!elements.length) {
return;
} }
if (!Number.isFinite(index) || index < 0) { if (!Number.isFinite(index) || index < 0) {
@@ -369,9 +378,24 @@ export class Scene {
this.replaceAllElements(nextElements); this.replaceAllElements(nextElements);
} }
/** low-level - generally use app.insertNewElement() */
insertElement = (element: ExcalidrawElement) => { insertElement = (element: ExcalidrawElement) => {
this.insertElementsAtIndex([element], null); const index = element.frameId
? this.getElementIndex(element.frameId)
: this.elements.length;
this.insertElementAtIndex(element, index);
};
insertElements = (elements: ExcalidrawElement[]) => {
if (!elements.length) {
return;
}
const index = elements[0]?.frameId
? this.getElementIndex(elements[0].frameId)
: this.elements.length;
this.insertElementsAtIndex(elements, index);
}; };
getElementIndex(elementId: string) { getElementIndex(elementId: string) {
+6 -6
View File
@@ -10,6 +10,7 @@ import {
getBindingGap, getBindingGap,
getGlobalFixedPointForBindableElement, getGlobalFixedPointForBindableElement,
isBindingEnabled, isBindingEnabled,
maxBindingDistance_simple,
unbindBindingElement, unbindBindingElement,
updateBoundPoint, updateBoundPoint,
} from "../binding"; } from "../binding";
@@ -19,7 +20,7 @@ import {
isElbowArrow, isElbowArrow,
} from "../typeChecks"; } from "../typeChecks";
import { LinearElementEditor } from "../linearElementEditor"; import { LinearElementEditor } from "../linearElementEditor";
import { getHoveredElementForBinding, hitElementItself } from "../collision"; import { getHoveredElementForFocusPoint, hitElementItself } from "../collision";
import { moveArrowAboveBindable } from "../zindex"; import { moveArrowAboveBindable } from "../zindex";
import type { import type {
@@ -91,7 +92,7 @@ export const isFocusPointVisible = (
element: bindableElement, element: bindableElement,
elementsMap, elementsMap,
point: focusPoint, point: focusPoint,
threshold: getBindingGap(bindableElement), threshold: getBindingGap(bindableElement, arrow),
overrideShouldTestInside: true, overrideShouldTestInside: true,
}) })
); );
@@ -233,12 +234,12 @@ export const handleFocusPointDrag = (
pointerCoords.y - offsetY, pointerCoords.y - offsetY,
); );
const bindingField = isStartBinding ? "startBinding" : "endBinding"; const bindingField = isStartBinding ? "startBinding" : "endBinding";
const hit = getHoveredElementForBinding( const hit = getHoveredElementForFocusPoint(
arrow,
point, point,
arrow,
scene.getNonDeletedElements(), scene.getNonDeletedElements(),
elementsMap, elementsMap,
appState.zoom, maxBindingDistance_simple(appState.zoom),
); );
// Hovering a bindable element // Hovering a bindable element
@@ -269,7 +270,6 @@ export const handleFocusPointDrag = (
newMode || "orbit", newMode || "orbit",
linearElementEditor.draggedFocusPointBinding, linearElementEditor.draggedFocusPointBinding,
scene, scene,
appState.zoom,
point, point,
); );
} }
+144 -49
View File
@@ -60,7 +60,6 @@ import { updateElbowArrowPoints } from "./elbowArrow";
import { import {
deconstructDiamondElement, deconstructDiamondElement,
deconstructRectanguloidElement, deconstructRectanguloidElement,
getSnapOutlineMidPoint,
projectFixedPointOntoDiagonal, projectFixedPointOntoDiagonal,
} from "./utils"; } from "./utils";
@@ -111,13 +110,18 @@ export type BindingStrategy =
* IMPORTANT: currently must be > 0 (this also applies to the computed gap) * IMPORTANT: currently must be > 0 (this also applies to the computed gap)
*/ */
export const BASE_BINDING_GAP = 5; export const BASE_BINDING_GAP = 5;
export const BASE_BINDING_GAP_ELBOW = 5;
export const BASE_ARROW_MIN_LENGTH = 10; export const BASE_ARROW_MIN_LENGTH = 10;
export const FOCUS_POINT_SIZE = 10 / 1.5; export const FOCUS_POINT_SIZE = 10 / 1.5;
export const getBindingGap = ( export const getBindingGap = (
bindTarget: ExcalidrawBindableElement, bindTarget: ExcalidrawBindableElement,
opts: Pick<ExcalidrawArrowElement, "elbowed">,
): number => { ): number => {
return BASE_BINDING_GAP + bindTarget.strokeWidth / 2; return (
(opts.elbowed ? BASE_BINDING_GAP_ELBOW : BASE_BINDING_GAP) +
bindTarget.strokeWidth / 2
);
}; };
export const maxBindingDistance_simple = (zoom?: AppState["zoom"]): number => { export const maxBindingDistance_simple = (zoom?: AppState["zoom"]): number => {
@@ -171,7 +175,6 @@ export const bindOrUnbindBindingElement = (
start, start,
"start", "start",
scene, scene,
appState.zoom,
appState.isBindingEnabled, appState.isBindingEnabled,
); );
bindOrUnbindBindingElementEdge( bindOrUnbindBindingElementEdge(
@@ -179,7 +182,6 @@ export const bindOrUnbindBindingElement = (
end, end,
"end", "end",
scene, scene,
appState.zoom,
appState.isBindingEnabled, appState.isBindingEnabled,
); );
if (start.focusPoint || end.focusPoint) { if (start.focusPoint || end.focusPoint) {
@@ -224,7 +226,6 @@ const bindOrUnbindBindingElementEdge = (
{ mode, element, focusPoint }: BindingStrategy, { mode, element, focusPoint }: BindingStrategy,
startOrEnd: "start" | "end", startOrEnd: "start" | "end",
scene: Scene, scene: Scene,
zoom: AppState["zoom"],
shouldSnapToOutline = true, shouldSnapToOutline = true,
): void => { ): void => {
if (mode === null) { if (mode === null) {
@@ -237,7 +238,6 @@ const bindOrUnbindBindingElementEdge = (
mode, mode,
startOrEnd, startOrEnd,
scene, scene,
zoom,
focusPoint, focusPoint,
shouldSnapToOutline, shouldSnapToOutline,
); );
@@ -270,11 +270,10 @@ const bindingStrategyForElbowArrowEndpointDragging = (
elementsMap, elementsMap,
); );
const hit = getHoveredElementForBinding( const hit = getHoveredElementForBinding(
arrow,
globalPoint, globalPoint,
elements, elements,
elementsMap, elementsMap,
zoom, maxBindingDistance_simple(zoom),
); );
const current = hit const current = hit
@@ -322,7 +321,7 @@ const bindingStrategyForNewSimpleArrowEndpointDragging = (
draggingPoints.get(startDragged ? startIdx : endIdx)!.point, draggingPoints.get(startDragged ? startIdx : endIdx)!.point,
elementsMap, elementsMap,
); );
const hit = getHoveredElementForBinding(arrow, point, elements, elementsMap); const hit = getHoveredElementForBinding(point, elements, elementsMap);
// With new arrows this handles the binding at arrow creation // With new arrows this handles the binding at arrow creation
if (startDragged) { if (startDragged) {
@@ -367,12 +366,7 @@ const bindingStrategyForNewSimpleArrowEndpointDragging = (
// Check and handle nested shapes // Check and handle nested shapes
if (hit && arrow.startBinding) { if (hit && arrow.startBinding) {
const startBinding = arrow.startBinding; const startBinding = arrow.startBinding;
const allHits = getAllHoveredElementAtPoint( const allHits = getAllHoveredElementAtPoint(point, elements, elementsMap);
arrow,
point,
elements,
elementsMap,
);
if (allHits.find((el) => el.id === startBinding.elementId)) { if (allHits.find((el) => el.id === startBinding.elementId)) {
const otherElement = elementsMap.get( const otherElement = elementsMap.get(
@@ -474,9 +468,9 @@ const bindingStrategyForSimpleArrowEndpointDragging_complex = (
let other: BindingStrategy = { mode: undefined }; let other: BindingStrategy = { mode: undefined };
const isMultiPoint = arrow.points.length > 2; const isMultiPoint = arrow.points.length > 2;
const hit = getHoveredElementForBinding(arrow, point, elements, elementsMap); const hit = getHoveredElementForBinding(point, elements, elementsMap);
const isOverlapping = oppositeBinding const isOverlapping = oppositeBinding
? getAllHoveredElementAtPoint(arrow, point, elements, elementsMap).some( ? getAllHoveredElementAtPoint(point, elements, elementsMap).some(
(el) => el.id === oppositeBinding.elementId, (el) => el.id === oppositeBinding.elementId,
) )
: false; : false;
@@ -701,11 +695,10 @@ const getBindingStrategyForDraggingBindingElementEndpoints_simple = (
elementsMap, elementsMap,
); );
const hit = getHoveredElementForBinding( const hit = getHoveredElementForBinding(
arrow,
globalPoint, globalPoint,
elements, elements,
elementsMap, elementsMap,
appState.zoom, maxBindingDistance_simple(appState.zoom),
); );
const pointInElement = const pointInElement =
hit && hit &&
@@ -741,11 +734,12 @@ const getBindingStrategyForDraggingBindingElementEndpoints_simple = (
}); });
// Handle outside-outside binding to the same element // Handle outside-outside binding to the same element
if ( if (otherBinding && otherBinding.elementId === hit?.id) {
otherBinding && invariant(
otherBinding.elementId === hit?.id && !opts?.newArrow || appState.selectedLinearElement?.initialState.origin,
(!opts?.newArrow || appState.selectedLinearElement?.initialState.origin) "appState.selectedLinearElement.initialState.origin must be defined for new arrows",
) { );
return { return {
start: { start: {
mode: "inside", mode: "inside",
@@ -1026,7 +1020,6 @@ export const bindBindingElement = (
mode: BindMode, mode: BindMode,
startOrEnd: "start" | "end", startOrEnd: "start" | "end",
scene: Scene, scene: Scene,
zoom: AppState["zoom"],
focusPoint?: GlobalPoint, focusPoint?: GlobalPoint,
shouldSnapToOutline = true, shouldSnapToOutline = true,
): void => { ): void => {
@@ -1043,7 +1036,6 @@ export const bindBindingElement = (
hoveredElement, hoveredElement,
startOrEnd, startOrEnd,
elementsMap, elementsMap,
zoom,
shouldSnapToOutline, shouldSnapToOutline,
), ),
}; };
@@ -1268,7 +1260,6 @@ const updateArrowBindings = (
strategy[strategyName].mode, strategy[strategyName].mode,
strategyName, strategyName,
scene, scene,
appState.zoom,
strategy[strategyName].focusPoint, strategy[strategyName].focusPoint,
); );
} }
@@ -1378,7 +1369,6 @@ export const bindPointToSnapToElementOutline = (
bindableElement: ExcalidrawBindableElement, bindableElement: ExcalidrawBindableElement,
startOrEnd: "start" | "end", startOrEnd: "start" | "end",
elementsMap: ElementsMap, elementsMap: ElementsMap,
zoom: AppState["zoom"],
customIntersector?: LineSegment<GlobalPoint>, customIntersector?: LineSegment<GlobalPoint>,
isMidpointSnappingEnabled = true, isMidpointSnappingEnabled = true,
): GlobalPoint => { ): GlobalPoint => {
@@ -1411,7 +1401,7 @@ export const bindPointToSnapToElementOutline = (
startOrEnd === "start" ? 1 : -2, startOrEnd === "start" ? 1 : -2,
elementsMap, elementsMap,
); );
const bindingGap = getBindingGap(bindableElement); const bindingGap = getBindingGap(bindableElement, arrowElement);
const aabb = aabbForElement(bindableElement, elementsMap); const aabb = aabbForElement(bindableElement, elementsMap);
const bindableCenter = getCenterForBounds(aabb); const bindableCenter = getCenterForBounds(aabb);
@@ -1421,13 +1411,7 @@ export const bindPointToSnapToElementOutline = (
headingForPointFromElement(bindableElement, aabb, point), headingForPointFromElement(bindableElement, aabb, point),
); );
const snapPoint = isMidpointSnappingEnabled const snapPoint = isMidpointSnappingEnabled
? getSnapOutlineMidPoint( ? snapToMid(bindableElement, elementsMap, edgePoint, 0.05, arrowElement)
edgePoint,
bindableElement,
elementsMap,
zoom,
arrowElement,
)
: undefined; : undefined;
const resolved = snapPoint || point; const resolved = snapPoint || point;
const otherPoint = pointFrom<GlobalPoint>( const otherPoint = pointFrom<GlobalPoint>(
@@ -1472,7 +1456,7 @@ export const bindPointToSnapToElementOutline = (
bindableElement, bindableElement,
elementsMap, elementsMap,
anotherIntersector, anotherIntersector,
BASE_BINDING_GAP, BASE_BINDING_GAP_ELBOW,
).sort(pointDistanceSq)[0]; ).sort(pointDistanceSq)[0];
} }
} else { } else {
@@ -1531,7 +1515,7 @@ export const avoidRectangularCorner = (
-bindTarget.angle as Radians, -bindTarget.angle as Radians,
); );
const bindingGap = getBindingGap(bindTarget); const bindingGap = getBindingGap(bindTarget, arrowElement);
if (nonRotatedPoint[0] < bindTarget.x && nonRotatedPoint[1] < bindTarget.y) { if (nonRotatedPoint[0] < bindTarget.x && nonRotatedPoint[1] < bindTarget.y) {
// Top left // Top left
@@ -1609,6 +1593,121 @@ export const avoidRectangularCorner = (
return p; return p;
}; };
export const snapToMid = (
bindTarget: ExcalidrawBindableElement,
elementsMap: ElementsMap,
p: GlobalPoint,
tolerance: number = 0.05,
arrowElement?: ExcalidrawArrowElement,
): GlobalPoint | undefined => {
const { x, y, width, height, angle } = bindTarget;
const center = elementCenterPoint(bindTarget, elementsMap, -0.1, -0.1);
const nonRotated = pointRotateRads(p, center, -angle as Radians);
const bindingGap = arrowElement ? getBindingGap(bindTarget, arrowElement) : 0;
// snap-to-center point is adaptive to element size, but we don't want to go
// above and below certain px distance
const verticalThreshold = clamp(tolerance * height, 5, 80);
const horizontalThreshold = clamp(tolerance * width, 5, 80);
// Too close to the center makes it hard to resolve direction precisely
if (pointDistance(center, nonRotated) < bindingGap) {
return undefined;
}
if (
nonRotated[0] <= x + width / 2 &&
nonRotated[1] > center[1] - verticalThreshold &&
nonRotated[1] < center[1] + verticalThreshold
) {
// LEFT
return pointRotateRads(
pointFrom<GlobalPoint>(x - bindingGap, center[1]),
center,
angle,
);
} else if (
nonRotated[1] <= y + height / 2 &&
nonRotated[0] > center[0] - horizontalThreshold &&
nonRotated[0] < center[0] + horizontalThreshold
) {
// TOP
return pointRotateRads(
pointFrom<GlobalPoint>(center[0], y - bindingGap),
center,
angle,
);
} else if (
nonRotated[0] >= x + width / 2 &&
nonRotated[1] > center[1] - verticalThreshold &&
nonRotated[1] < center[1] + verticalThreshold
) {
// RIGHT
return pointRotateRads(
pointFrom<GlobalPoint>(x + width + bindingGap, center[1]),
center,
angle,
);
} else if (
nonRotated[1] >= y + height / 2 &&
nonRotated[0] > center[0] - horizontalThreshold &&
nonRotated[0] < center[0] + horizontalThreshold
) {
// DOWN
return pointRotateRads(
pointFrom<GlobalPoint>(center[0], y + height + bindingGap),
center,
angle,
);
} else if (bindTarget.type === "diamond") {
const distance = bindingGap;
const topLeft = pointFrom<GlobalPoint>(
x + width / 4 - distance,
y + height / 4 - distance,
);
const topRight = pointFrom<GlobalPoint>(
x + (3 * width) / 4 + distance,
y + height / 4 - distance,
);
const bottomLeft = pointFrom<GlobalPoint>(
x + width / 4 - distance,
y + (3 * height) / 4 + distance,
);
const bottomRight = pointFrom<GlobalPoint>(
x + (3 * width) / 4 + distance,
y + (3 * height) / 4 + distance,
);
if (
pointDistance(topLeft, nonRotated) <
Math.max(horizontalThreshold, verticalThreshold)
) {
return pointRotateRads(topLeft, center, angle);
}
if (
pointDistance(topRight, nonRotated) <
Math.max(horizontalThreshold, verticalThreshold)
) {
return pointRotateRads(topRight, center, angle);
}
if (
pointDistance(bottomLeft, nonRotated) <
Math.max(horizontalThreshold, verticalThreshold)
) {
return pointRotateRads(bottomLeft, center, angle);
}
if (
pointDistance(bottomRight, nonRotated) <
Math.max(horizontalThreshold, verticalThreshold)
) {
return pointRotateRads(bottomRight, center, angle);
}
}
return undefined;
};
const extractBinding = ( const extractBinding = (
arrow: ExcalidrawArrowElement, arrow: ExcalidrawArrowElement,
startOrEnd: "startBinding" | "endBinding", startOrEnd: "startBinding" | "endBinding",
@@ -1709,7 +1808,7 @@ export const updateBoundPoint = (
otherBindable, otherBindable,
elementsMap, elementsMap,
intersector, intersector,
getBindingGap(otherBindable), getBindingGap(otherBindable, arrow),
).sort( ).sort(
(a, b) => pointDistanceSq(a, focusPoint) - pointDistanceSq(b, focusPoint), (a, b) => pointDistanceSq(a, focusPoint) - pointDistanceSq(b, focusPoint),
)[0]; )[0];
@@ -1719,7 +1818,7 @@ export const updateBoundPoint = (
bindableElement, bindableElement,
elementsMap, elementsMap,
intersector, intersector,
getBindingGap(bindableElement), getBindingGap(bindableElement, arrow),
).sort( ).sort(
(a, b) => (a, b) =>
pointDistanceSq(a, otherFocusPointOrArrowPoint) - pointDistanceSq(a, otherFocusPointOrArrowPoint) -
@@ -1747,7 +1846,7 @@ export const updateBoundPoint = (
element: otherBindable, element: otherBindable,
point: outlinePoint, point: outlinePoint,
elementsMap, elementsMap,
threshold: getBindingGap(otherBindable), threshold: getBindingGap(otherBindable, arrow),
overrideShouldTestInside: true, overrideShouldTestInside: true,
}) })
) { ) {
@@ -1808,7 +1907,6 @@ export const calculateFixedPointForElbowArrowBinding = (
hoveredElement: ExcalidrawBindableElement, hoveredElement: ExcalidrawBindableElement,
startOrEnd: "start" | "end", startOrEnd: "start" | "end",
elementsMap: ElementsMap, elementsMap: ElementsMap,
zoom: AppState["zoom"],
shouldSnapToOutline = true, shouldSnapToOutline = true,
isMidpointSnappingEnabled = true, isMidpointSnappingEnabled = true,
): { fixedPoint: FixedPoint } => { ): { fixedPoint: FixedPoint } => {
@@ -1824,7 +1922,6 @@ export const calculateFixedPointForElbowArrowBinding = (
hoveredElement, hoveredElement,
startOrEnd, startOrEnd,
elementsMap, elementsMap,
zoom,
undefined, undefined,
isMidpointSnappingEnabled, isMidpointSnappingEnabled,
) )
@@ -1846,9 +1943,9 @@ export const calculateFixedPointForElbowArrowBinding = (
return { return {
fixedPoint: normalizeFixedPoint([ fixedPoint: normalizeFixedPoint([
(nonRotatedSnappedGlobalPoint[0] - hoveredElement.x) / (nonRotatedSnappedGlobalPoint[0] - hoveredElement.x) /
Math.max(hoveredElement.width, PRECISION), hoveredElement.width,
(nonRotatedSnappedGlobalPoint[1] - hoveredElement.y) / (nonRotatedSnappedGlobalPoint[1] - hoveredElement.y) /
Math.max(hoveredElement.height, PRECISION), hoveredElement.height,
]), ]),
}; };
}; };
@@ -1879,11 +1976,9 @@ export const calculateFixedPointForNonElbowArrowBinding = (
// Calculate the ratio relative to the element's bounds // Calculate the ratio relative to the element's bounds
const fixedPointX = const fixedPointX =
(nonRotatedPoint[0] - hoveredElement.x) / (nonRotatedPoint[0] - hoveredElement.x) / hoveredElement.width;
Math.max(hoveredElement.width, PRECISION);
const fixedPointY = const fixedPointY =
(nonRotatedPoint[1] - hoveredElement.y) / (nonRotatedPoint[1] - hoveredElement.y) / hoveredElement.height;
Math.max(hoveredElement.height, PRECISION);
return { return {
fixedPoint: normalizeFixedPoint([fixedPointX, fixedPointY]), fixedPoint: normalizeFixedPoint([fixedPointX, fixedPointY]),
+92 -103
View File
@@ -25,7 +25,7 @@ import type {
Radians, Radians,
} from "@excalidraw/math"; } from "@excalidraw/math";
import type { AppState, FrameNameBounds } from "@excalidraw/excalidraw/types"; import type { FrameNameBounds } from "@excalidraw/excalidraw/types";
import { isPathALoop } from "./utils"; import { isPathALoop } from "./utils";
import { import {
@@ -59,12 +59,11 @@ import { LinearElementEditor } from "./linearElementEditor";
import { distanceToElement } from "./distance"; import { distanceToElement } from "./distance";
import { getBindingGap, maxBindingDistance_simple } from "./binding"; import { getBindingGap } from "./binding";
import { hasBackground } from "./comparisons";
import type { import type {
ElementsMap, ElementsMap,
ExcalidrawArrowElement,
ExcalidrawBindableElement, ExcalidrawBindableElement,
ExcalidrawDiamondElement, ExcalidrawDiamondElement,
ExcalidrawElement, ExcalidrawElement,
@@ -84,7 +83,7 @@ export const shouldTestInside = (element: ExcalidrawElement) => {
} }
const isDraggableFromInside = const isDraggableFromInside =
(hasBackground(element.type) && !isTransparent(element.backgroundColor)) || !isTransparent(element.backgroundColor) ||
hasBoundTextElement(element) || hasBoundTextElement(element) ||
isIframeLikeElement(element) || isIframeLikeElement(element) ||
isTextElement(element); isTextElement(element);
@@ -253,20 +252,25 @@ export const hitElementBoundText = (
return isPointInElement(point, boundTextElement, elementsMap); return isPointInElement(point, boundTextElement, elementsMap);
}; };
const bindableElementBorderDistanceIfClose = ( const bindingBorderTest = (
element: NonDeleted<ExcalidrawBindableElement>, element: NonDeleted<ExcalidrawBindableElement>,
point: GlobalPoint, [x, y]: Readonly<GlobalPoint>,
elementsMap: ElementsMap, elementsMap: NonDeletedSceneElementsMap,
tolerance: number = 0, tolerance: number = 0,
) => { ): boolean => {
const p = pointFrom<GlobalPoint>(x, y);
const shouldTestInside =
// disable fullshape snapping for frame elements so we
// can bind to frame children
!isFrameLikeElement(element);
// PERF: Run a cheap test to see if the binding element // PERF: Run a cheap test to see if the binding element
// is even close to the element // is even close to the element
const [x, y] = point;
const t = Math.max(1, tolerance); const t = Math.max(1, tolerance);
const bounds = [x - t, y - t, x + t, y + t] as Bounds; const bounds = [x - t, y - t, x + t, y + t] as Bounds;
const elementBounds = getElementBounds(element, elementsMap); const elementBounds = getElementBounds(element, elementsMap);
if (!doBoundsIntersect(bounds, elementBounds)) { if (!doBoundsIntersect(bounds, elementBounds)) {
return -Infinity; return false;
} }
// If the element is inside a frame, we should clip the element // If the element is inside a frame, we should clip the element
@@ -277,29 +281,33 @@ const bindableElementBorderDistanceIfClose = (
enclosingFrame, enclosingFrame,
elementsMap, elementsMap,
); );
if (!pointInsideBounds(point, enclosingFrameBounds)) { if (!pointInsideBounds(p, enclosingFrameBounds)) {
return -Infinity; return false;
} }
} }
} }
const distance = distanceToElement(element, elementsMap, point); // Do the intersection test against the element since it's close enough
if (isPointInElement(point, element, elementsMap)) { const intersections = intersectElementWithLineSegment(
return distance; element,
} elementsMap,
lineSegment(elementCenterPoint(element, elementsMap), p),
);
const distance = distanceToElement(element, elementsMap, p);
return distance > tolerance ? -Infinity : -distance; return shouldTestInside
? intersections.length === 0 || distance <= tolerance
: intersections.length > 0 && distance <= t;
}; };
export const getAllHoveredElementAtPoint = ( export const getAllHoveredElementAtPoint = (
arrow: { elbowed: boolean },
point: Readonly<GlobalPoint>, point: Readonly<GlobalPoint>,
elements: readonly Ordered<NonDeletedExcalidrawElement>[], elements: readonly Ordered<NonDeletedExcalidrawElement>[],
elementsMap: NonDeletedSceneElementsMap, elementsMap: NonDeletedSceneElementsMap,
tolerance?: number, tolerance?: number,
): NonDeleted<ExcalidrawBindableElement>[] => { ): NonDeleted<ExcalidrawBindableElement>[] => {
const candidateElements: NonDeleted<ExcalidrawBindableElement>[] = []; const candidateElements: NonDeleted<ExcalidrawBindableElement>[] = [];
// We need to do hit testing from front (end of the array) to back (beginning of the array) // We need to to hit testing from front (end of the array) to back (beginning of the array)
// because array is ordered from lower z-index to highest and we want element z-index // because array is ordered from lower z-index to highest and we want element z-index
// with higher z-index // with higher z-index
for (let index = elements.length - 1; index >= 0; --index) { for (let index = elements.length - 1; index >= 0; --index) {
@@ -312,20 +320,11 @@ export const getAllHoveredElementAtPoint = (
if ( if (
isBindableElement(element, false) && isBindableElement(element, false) &&
hitElementItself({ bindingBorderTest(element, point, elementsMap, tolerance)
element,
point,
elementsMap,
threshold: tolerance ?? getBindingGap(element),
overrideShouldTestInside: true,
})
) { ) {
candidateElements.push(element); candidateElements.push(element);
if ( if (!isTransparent(element.backgroundColor)) {
hasBackground(element.type) &&
!isTransparent(element.backgroundColor)
) {
break; break;
} }
} }
@@ -335,92 +334,82 @@ export const getAllHoveredElementAtPoint = (
}; };
export const getHoveredElementForBinding = ( export const getHoveredElementForBinding = (
arrow: { elbowed: boolean },
point: Readonly<GlobalPoint>, point: Readonly<GlobalPoint>,
elements: readonly Ordered<NonDeletedExcalidrawElement>[], elements: readonly Ordered<NonDeletedExcalidrawElement>[],
elementsMap: NonDeletedSceneElementsMap, elementsMap: NonDeletedSceneElementsMap,
zoom?: AppState["zoom"], tolerance?: number,
): NonDeleted<ExcalidrawBindableElement> | null => { ): NonDeleted<ExcalidrawBindableElement> | null => {
type Candidate = { const candidateElements = getAllHoveredElementAtPoint(
element: NonDeleted<ExcalidrawBindableElement>; point,
distance: number; elements,
overlapPercent?: number; elementsMap,
relativeArea?: number; tolerance,
}; );
const candidates: Candidate[] = []; if (!candidateElements || candidateElements.length === 0) {
for (let index = elements.length - 1; index >= 0; --index) {
const element = elements[index];
if (!isBindableElement(element, false)) {
continue;
}
const maxDistance = maxBindingDistance_simple(zoom);
const distance = bindableElementBorderDistanceIfClose(
element,
point,
elementsMap,
maxDistance,
);
if (distance > -maxDistance) {
candidates.push({ element, distance });
if (!isTransparent(element.backgroundColor) && distance >= 0) {
break;
}
}
}
if (candidates.length === 0) {
return null; return null;
} }
if (candidates.length === 1) { if (candidateElements.length === 1) {
return candidates[0].element; return candidateElements[0];
} }
const closestElements = candidates.sort( // Prefer smaller shapes
(a, b) => Math.abs(a.distance) - Math.abs(b.distance), return candidateElements
); .sort(
(a, b) => b.width ** 2 + b.height ** 2 - (a.width ** 2 + a.height ** 2),
)
.pop() as NonDeleted<ExcalidrawBindableElement>;
};
const candidate = closestElements[0]; export const getHoveredElementForFocusPoint = (
const [cx1, cy1, cx2, cy2] = getElementBounds(candidate.element, elementsMap); point: GlobalPoint,
const candidateArea = Math.max( arrow: ExcalidrawArrowElement,
0.00001, elements: readonly Ordered<NonDeletedExcalidrawElement>[],
Math.abs(cx2 - cx1) * Math.abs(cy2 - cy1), elementsMap: NonDeletedSceneElementsMap,
); tolerance?: number,
const overlaps = closestElements ): ExcalidrawBindableElement | null => {
.map((c) => { const candidateElements: NonDeleted<ExcalidrawBindableElement>[] = [];
if (c.element === candidate.element) { // We need to to hit testing from front (end of the array) to back (beginning of the array)
return { ...c, overlapPercent: 0, relativeArea: 1 }; // because array is ordered from lower z-index to highest and we want element z-index
} // with higher z-index
for (let index = elements.length - 1; index >= 0; --index) {
const element = elements[index];
const [x1, y1, x2, y2] = getElementBounds(c.element, elementsMap); invariant(
const overlapX1 = x1 > cx1 && x1 < cx2 ? x1 : cx1; !element.isDeleted,
const overlapY1 = y1 > cy1 && y1 < cy2 ? y1 : cy1; "Elements in the function parameter for getAllElementsAtPositionForBinding() should not contain deleted elements",
const overlapX2 = x2 < cx2 && x2 > cx1 ? x2 : cx2; );
const overlapY2 = y2 < cy2 && y2 > cy1 ? y2 : cy2;
const overlapWdith =
overlapX1 !== cx1 || overlapX2 !== cx2 ? overlapX2 - overlapX1 : 0;
const overlapHeight =
overlapY1 !== cy1 || overlapY2 !== cy2 ? overlapY2 - overlapY1 : 0;
const area = Math.max(0.00001, Math.abs(x2 - x1) * Math.abs(y2 - y1));
const overlapPercent = Math.abs(overlapHeight * overlapWdith) / area;
return { if (
...c, isBindableElement(element, false) &&
overlapPercent, bindingBorderTest(element, point, elementsMap, tolerance)
relativeArea: ) {
overlapPercent === 0 ? 1 : Math.min(area / candidateArea, 1), candidateElements.push(element);
}; }
}) }
.filter((c) => c.overlapPercent > 0.25 && c.relativeArea < 0.75);
return candidate.distance >= 0 && overlaps.length > 0 if (!candidateElements || candidateElements.length === 0) {
? overlaps[0].element return null;
: candidate.element; }
if (candidateElements.length === 1) {
return candidateElements[0];
}
const distanceFilteredCandidateElements = candidateElements
// Resolve by distance
.filter(
(el) =>
distanceToElement(el, elementsMap, point) <= getBindingGap(el, arrow) ||
isPointInElement(point, el, elementsMap),
);
if (distanceFilteredCandidateElements.length === 0) {
return null;
}
return distanceFilteredCandidateElements[0] as NonDeleted<ExcalidrawBindableElement>;
}; };
/** /**
+18 -8
View File
@@ -21,7 +21,7 @@ import { getPerfectElementSize } from "./sizeHelpers";
import { getBoundTextElement } from "./textElement"; import { getBoundTextElement } from "./textElement";
import { getMinTextElementWidth } from "./textMeasurements"; import { getMinTextElementWidth } from "./textMeasurements";
import { import {
isArrowElement as isBindingElement, isArrowElement,
isElbowArrow, isElbowArrow,
isFrameLikeElement, isFrameLikeElement,
isImageElement, isImageElement,
@@ -108,7 +108,19 @@ export const dragSelectedElements = (
); );
elementsToUpdate.forEach((element) => { elementsToUpdate.forEach((element) => {
if (!isBindingElement(element)) { const isArrow = !isArrowElement(element);
const isStartBoundElementSelected =
isArrow ||
(element.startBinding
? elementsToUpdateIds.has(element.startBinding.elementId)
: false);
const isEndBoundElementSelected =
isArrow ||
(element.endBinding
? elementsToUpdateIds.has(element.endBinding.elementId)
: false);
if (!isArrowElement(element)) {
updateElementCoords(pointerDownState, element, scene, adjustedOffset); updateElementCoords(pointerDownState, element, scene, adjustedOffset);
// skip arrow labels since we calculate its position during render // skip arrow labels since we calculate its position during render
@@ -131,6 +143,7 @@ export const dragSelectedElements = (
// NOTE: Add a little initial drag to the arrow dragging when the arrow // NOTE: Add a little initial drag to the arrow dragging when the arrow
// is the single element being dragged to avoid accidentally unbinding // is the single element being dragged to avoid accidentally unbinding
// the arrow when the user just wants to select it. // the arrow when the user just wants to select it.
elementsToUpdate.size > 1 || elementsToUpdate.size > 1 ||
Math.max(Math.abs(adjustedOffset.x), Math.abs(adjustedOffset.y)) > Math.max(Math.abs(adjustedOffset.x), Math.abs(adjustedOffset.y)) >
DRAGGING_THRESHOLD || DRAGGING_THRESHOLD ||
@@ -138,12 +151,9 @@ export const dragSelectedElements = (
) { ) {
updateElementCoords(pointerDownState, element, scene, adjustedOffset); updateElementCoords(pointerDownState, element, scene, adjustedOffset);
const shouldUnbindStart = element.startBinding const shouldUnbindStart =
? !elementsToUpdateIds.has(element.startBinding.elementId) element.startBinding && !isStartBoundElementSelected;
: true; const shouldUnbindEnd = element.endBinding && !isEndBoundElementSelected;
const shouldUnbindEnd = element.endBinding
? !elementsToUpdateIds.has(element.endBinding.elementId)
: true;
if (shouldUnbindStart || shouldUnbindEnd) { if (shouldUnbindStart || shouldUnbindEnd) {
// NOTE: Moving the bound arrow should unbind it, otherwise we would // NOTE: Moving the bound arrow should unbind it, otherwise we would
// have weird situations, like 0 lenght arrow when the user moves // have weird situations, like 0 lenght arrow when the user moves
+2 -22
View File
@@ -111,9 +111,6 @@ export const duplicateElements = (
* user interaction. * user interaction.
*/ */
type: "everything"; type: "everything";
// TODO remove/review this once we add frame children order migration
// and invariant checks
preserveFrameChildrenOrder?: boolean;
} }
| { | {
/** /**
@@ -173,8 +170,6 @@ export const duplicateElements = (
opts.type === "in-place" opts.type === "in-place"
? opts.idsOfElementsToDuplicate ? opts.idsOfElementsToDuplicate
: new Map(elements.map((el) => [el.id, el])); : new Map(elements.map((el) => [el.id, el]));
const preserveFrameChildrenOrder =
opts.type === "everything" && opts.preserveFrameChildrenOrder;
// For sanity // For sanity
if (opts.type === "in-place") { if (opts.type === "in-place") {
@@ -255,9 +250,6 @@ export const duplicateElements = (
elementsWithDuplicates.splice(index + 1, 0, ...castArray(elements)); elementsWithDuplicates.splice(index + 1, 0, ...castArray(elements));
}; };
// main
// ---------------------------------------------------------------------------
const frameIdsToDuplicate = new Set( const frameIdsToDuplicate = new Set(
elements elements
.filter( .filter(
@@ -282,7 +274,7 @@ export const duplicateElements = (
if (groupId) { if (groupId) {
const groupElements = getElementsInGroup(elements, groupId).flatMap( const groupElements = getElementsInGroup(elements, groupId).flatMap(
(element) => (element) =>
isFrameLikeElement(element) && !preserveFrameChildrenOrder isFrameLikeElement(element)
? [...getFrameChildren(elements, element.id), element] ? [...getFrameChildren(elements, element.id), element]
: [element], : [element],
); );
@@ -298,25 +290,13 @@ export const duplicateElements = (
// frame duplication // frame duplication
// ------------------------------------------------------------------------- // -------------------------------------------------------------------------
if ( if (element.frameId && frameIdsToDuplicate.has(element.frameId)) {
!preserveFrameChildrenOrder &&
element.frameId &&
frameIdsToDuplicate.has(element.frameId)
) {
continue; continue;
} }
if (isFrameLikeElement(element)) { if (isFrameLikeElement(element)) {
const frameId = element.id; const frameId = element.id;
if (preserveFrameChildrenOrder) {
insertBeforeOrAfterIndex(
findLastIndex(elementsWithDuplicates, (el) => el.id === frameId),
copyElements(element),
);
continue;
}
const frameChildren = getFrameChildren(elements, frameId); const frameChildren = getFrameChildren(elements, frameId);
const targetIndex = findLastIndex(elementsWithDuplicates, (el) => { const targetIndex = findLastIndex(elementsWithDuplicates, (el) => {
+22 -23
View File
@@ -30,7 +30,8 @@ import {
getHeadingForElbowArrowSnap, getHeadingForElbowArrowSnap,
getGlobalFixedPointForBindableElement, getGlobalFixedPointForBindableElement,
getBindingGap, getBindingGap,
BASE_BINDING_GAP, maxBindingDistance_simple,
BASE_BINDING_GAP_ELBOW,
} from "./binding"; } from "./binding";
import { distanceToElement } from "./distance"; import { distanceToElement } from "./distance";
import { import {
@@ -317,7 +318,6 @@ const handleSegmentRelease = (
...rest ...rest
} = getElbowArrowData( } = getElbowArrowData(
{ {
...arrow,
x, x,
y, y,
startBinding, startBinding,
@@ -1041,7 +1041,6 @@ export const updateElbowArrowPoints = (
...rest ...rest
} = getElbowArrowData( } = getElbowArrowData(
{ {
...arrow,
x: arrow.x, x: arrow.x,
y: arrow.y, y: arrow.y,
startBinding, startBinding,
@@ -1191,7 +1190,15 @@ export const updateElbowArrowPoints = (
* - hoveredEndElement: The element being hovered over at the end point. * - hoveredEndElement: The element being hovered over at the end point.
*/ */
const getElbowArrowData = ( const getElbowArrowData = (
arrow: ExcalidrawElbowArrowElement, arrow: {
x: number;
y: number;
startBinding: FixedPointBinding | null;
endBinding: FixedPointBinding | null;
startArrowhead: Arrowhead | null;
endArrowhead: Arrowhead | null;
points: readonly LocalPoint[];
},
elementsMap: NonDeletedSceneElementsMap, elementsMap: NonDeletedSceneElementsMap,
nextPoints: readonly LocalPoint[], nextPoints: readonly LocalPoint[],
options?: { options?: {
@@ -1216,7 +1223,6 @@ const getElbowArrowData = (
const elements = Array.from(elementsMap.values()); const elements = Array.from(elementsMap.values());
hoveredStartElement = hoveredStartElement =
getHoveredElement( getHoveredElement(
arrow,
origStartGlobalPoint, origStartGlobalPoint,
elementsMap, elementsMap,
elements, elements,
@@ -1224,7 +1230,6 @@ const getElbowArrowData = (
) || null; ) || null;
hoveredEndElement = hoveredEndElement =
getHoveredElement( getHoveredElement(
arrow,
origEndGlobalPoint, origEndGlobalPoint,
elementsMap, elementsMap,
elements, elements,
@@ -1251,7 +1256,6 @@ const getElbowArrowData = (
"start", "start",
arrow.startBinding?.fixedPoint, arrow.startBinding?.fixedPoint,
origStartGlobalPoint, origStartGlobalPoint,
options?.zoom || ({ value: 1 } as AppState["zoom"]),
hoveredStartElement, hoveredStartElement,
elementsMap, elementsMap,
options?.isDragging, options?.isDragging,
@@ -1269,7 +1273,6 @@ const getElbowArrowData = (
"end", "end",
arrow.endBinding?.fixedPoint, arrow.endBinding?.fixedPoint,
origEndGlobalPoint, origEndGlobalPoint,
options?.zoom || ({ value: 1 } as AppState["zoom"]),
hoveredEndElement, hoveredEndElement,
elementsMap, elementsMap,
options?.isDragging, options?.isDragging,
@@ -1311,8 +1314,8 @@ const getElbowArrowData = (
offsetFromHeading( offsetFromHeading(
startHeading, startHeading,
arrow.startArrowhead arrow.startArrowhead
? getBindingGap(hoveredStartElement) * 6 ? getBindingGap(hoveredStartElement, { elbowed: true }) * 6
: getBindingGap(hoveredStartElement) * 2, : getBindingGap(hoveredStartElement, { elbowed: true }) * 2,
1, 1,
), ),
) )
@@ -1324,8 +1327,8 @@ const getElbowArrowData = (
offsetFromHeading( offsetFromHeading(
endHeading, endHeading,
arrow.endArrowhead arrow.endArrowhead
? getBindingGap(hoveredEndElement) * 6 ? getBindingGap(hoveredEndElement, { elbowed: true }) * 6
: getBindingGap(hoveredEndElement) * 2, : getBindingGap(hoveredEndElement, { elbowed: true }) * 2,
1, 1,
), ),
) )
@@ -1372,8 +1375,8 @@ const getElbowArrowData = (
? 0 ? 0
: BASE_PADDING - : BASE_PADDING -
(arrow.startArrowhead (arrow.startArrowhead
? BASE_BINDING_GAP * 6 ? BASE_BINDING_GAP_ELBOW * 6
: BASE_BINDING_GAP * 2), : BASE_BINDING_GAP_ELBOW * 2),
BASE_PADDING, BASE_PADDING,
), ),
boundsOverlap boundsOverlap
@@ -1388,8 +1391,8 @@ const getElbowArrowData = (
? 0 ? 0
: BASE_PADDING - : BASE_PADDING -
(arrow.endArrowhead (arrow.endArrowhead
? BASE_BINDING_GAP * 6 ? BASE_BINDING_GAP_ELBOW * 6
: BASE_BINDING_GAP * 2), : BASE_BINDING_GAP_ELBOW * 2),
BASE_PADDING, BASE_PADDING,
), ),
boundsOverlap, boundsOverlap,
@@ -2121,8 +2124,8 @@ const normalizeArrowElementUpdate = (
offsetY < -MAX_POS || offsetY < -MAX_POS ||
offsetY > MAX_POS || offsetY > MAX_POS ||
offsetX + points[points.length - 1][0] < -MAX_POS || offsetX + points[points.length - 1][0] < -MAX_POS ||
offsetX + points[points.length - 1][0] > MAX_POS || offsetY + points[points.length - 1][0] > MAX_POS ||
offsetY + points[points.length - 1][1] < -MAX_POS || offsetX + points[points.length - 1][1] < -MAX_POS ||
offsetY + points[points.length - 1][1] > MAX_POS offsetY + points[points.length - 1][1] > MAX_POS
) { ) {
console.error( console.error(
@@ -2215,7 +2218,6 @@ const getGlobalPoint = (
startOrEnd: "start" | "end", startOrEnd: "start" | "end",
fixedPointRatio: [number, number] | undefined | null, fixedPointRatio: [number, number] | undefined | null,
initialPoint: GlobalPoint, initialPoint: GlobalPoint,
zoom: AppState["zoom"],
element?: ExcalidrawBindableElement | null, element?: ExcalidrawBindableElement | null,
elementsMap?: ElementsMap, elementsMap?: ElementsMap,
isDragging?: boolean, isDragging?: boolean,
@@ -2229,7 +2231,6 @@ const getGlobalPoint = (
element, element,
startOrEnd, startOrEnd,
elementsMap, elementsMap,
zoom,
undefined, undefined,
isMidpointSnappingEnabled, isMidpointSnappingEnabled,
); );
@@ -2278,18 +2279,16 @@ const getBindPointHeading = (
); );
const getHoveredElement = ( const getHoveredElement = (
arrow: ExcalidrawElbowArrowElement,
origPoint: GlobalPoint, origPoint: GlobalPoint,
elementsMap: NonDeletedSceneElementsMap, elementsMap: NonDeletedSceneElementsMap,
elements: readonly Ordered<NonDeletedExcalidrawElement>[], elements: readonly Ordered<NonDeletedExcalidrawElement>[],
zoom?: AppState["zoom"], zoom?: AppState["zoom"],
) => { ) => {
return getHoveredElementForBinding( return getHoveredElementForBinding(
arrow,
origPoint, origPoint,
elements, elements,
elementsMap, elementsMap,
zoom, maxBindingDistance_simple(zoom),
); );
}; };
+1 -9
View File
@@ -452,16 +452,8 @@ const createBindingArrow = (
"orbit", "orbit",
"start", "start",
scene, scene,
appState.zoom,
);
bindBindingElement(
bindingArrow,
endBindingElement,
"orbit",
"end",
scene,
appState.zoom,
); );
bindBindingElement(bindingArrow, endBindingElement, "orbit", "end", scene);
const changedElements = new Map<string, OrderedExcalidrawElement>(); const changedElements = new Map<string, OrderedExcalidrawElement>();
changedElements.set( changedElements.set(
+2 -12
View File
@@ -1,9 +1,6 @@
import { arrayToMap } from "@excalidraw/common"; import { generateNKeysBetween } from "fractional-indexing";
import { import { arrayToMap } from "@excalidraw/common";
validateOrderKey,
generateNKeysBetween,
} from "@excalidraw/fractional-indexing";
import { mutateElement, newElementWith } from "./mutateElement"; import { mutateElement, newElementWith } from "./mutateElement";
import { getBoundTextElement } from "./textElement"; import { getBoundTextElement } from "./textElement";
@@ -385,13 +382,6 @@ const isValidFractionalIndex = (
return false; return false;
} }
try {
// Format validation
validateOrderKey(index);
} catch {
return false;
}
if (predecessor && successor) { if (predecessor && successor) {
return predecessor < index && index < successor; return predecessor < index && index < successor;
} }
+39 -97
View File
@@ -19,11 +19,9 @@ import {
getElementAbsoluteCoords, getElementAbsoluteCoords,
doBoundsIntersect, doBoundsIntersect,
getElementBounds, getElementBounds,
boundsContainBounds,
} from "./bounds"; } from "./bounds";
import { mutateElement } from "./mutateElement"; import { mutateElement } from "./mutateElement";
import { getBoundTextElement, getContainerElement } from "./textElement"; import { getBoundTextElement, getContainerElement } from "./textElement";
import { syncMovedIndices } from "./fractionalIndex";
import { import {
isFrameElement, isFrameElement,
isFrameLikeElement, isFrameLikeElement,
@@ -103,9 +101,8 @@ export const isElementContainingFrame = (
frame: ExcalidrawFrameLikeElement, frame: ExcalidrawFrameLikeElement,
elementsMap: ElementsMap, elementsMap: ElementsMap,
) => { ) => {
return boundsContainBounds( return getElementsWithinSelection([frame], element, elementsMap).some(
getElementBounds(element, elementsMap), (e) => e.id === frame.id,
getElementBounds(frame, elementsMap),
); );
}; };
@@ -492,44 +489,10 @@ export const filterElementsEligibleAsFrameChildren = (
return eligibleElements; return eligibleElements;
}; };
export const getCommonFrameId = (elements: readonly ExcalidrawElement[]) => {
let commonFrameId: ExcalidrawElement["frameId"] | undefined;
for (const element of elements) {
if (isFrameLikeElement(element) || !element.frameId) {
return null;
}
if (commonFrameId === undefined) {
commonFrameId = element.frameId;
} else if (commonFrameId !== element.frameId) {
return null;
}
}
return commonFrameId ?? null;
};
export const getFrameChildrenInsertionIndex = (
elements: readonly ExcalidrawElement[],
frameId: ExcalidrawFrameLikeElement["id"],
): number | null => {
for (let index = elements.length - 1; index >= 0; index--) {
const element = elements[index];
if (element.id === frameId) {
return index;
} else if (element.frameId === frameId) {
return index + 1;
}
}
return null;
};
/** /**
* Adds elements and their bound elements to frame. Reorders added elements to * Retains (or repairs for target frame) the ordering invriant where children
* be just below frame, or just above its highest child (whichever is higher). * elements come right before the parent frame:
* [el, el, child, child, frame, el]
* *
* @returns mutated allElements (same data structure) * @returns mutated allElements (same data structure)
*/ */
@@ -537,11 +500,19 @@ export const addElementsToFrame = <T extends ElementsMapOrArray>(
allElements: T, allElements: T,
elementsToAdd: NonDeletedExcalidrawElement[], elementsToAdd: NonDeletedExcalidrawElement[],
frame: ExcalidrawFrameLikeElement, frame: ExcalidrawFrameLikeElement,
appState: AppState,
): T => { ): T => {
const elementsMap = arrayToMap(allElements); const elementsMap = arrayToMap(allElements);
const commonFrameId = getCommonFrameId(elementsToAdd); const currTargetFrameChildrenMap = new Map<ExcalidrawElement["id"], true>();
for (const element of allElements.values()) {
if (element.frameId === frame.id) {
currTargetFrameChildrenMap.set(element.id, true);
}
}
const finalElementsToAdd = new Set<ExcalidrawElement>(); const suppliedElementsToAddSet = new Set(elementsToAdd.map((el) => el.id));
const finalElementsToAdd: ExcalidrawElement[] = [];
const otherFrames = new Set<ExcalidrawFrameLikeElement["id"]>(); const otherFrames = new Set<ExcalidrawFrameLikeElement["id"]>();
@@ -552,8 +523,7 @@ export const addElementsToFrame = <T extends ElementsMapOrArray>(
} }
// - add bound text elements if not already in the array // - add bound text elements if not already in the array
// - keep elements already in the frame so mixed selections can be reordered // - filter out elements that are already in the frame
// together
for (const element of omitGroupsContainingFrameLikes( for (const element of omitGroupsContainingFrameLikes(
allElements, allElements,
elementsToAdd, elementsToAdd,
@@ -566,68 +536,38 @@ export const addElementsToFrame = <T extends ElementsMapOrArray>(
continue; continue;
} }
if (element.frameId && element.frameId !== frame.id) { // if the element is already in another frame (which is also in elementsToAdd),
// it means that frame and children are selected at the same time
// => keep original frame membership, do not add to the target frame
if (
element.frameId &&
appState.selectedElementIds[element.id] &&
appState.selectedElementIds[element.frameId]
) {
continue; continue;
} }
finalElementsToAdd.add(element); if (!currTargetFrameChildrenMap.has(element.id)) {
finalElementsToAdd.push(element);
}
const boundTextElement = getBoundTextElement(element, elementsMap); const boundTextElement = getBoundTextElement(element, elementsMap);
if (boundTextElement && !finalElementsToAdd.has(boundTextElement)) { if (
finalElementsToAdd.add(boundTextElement); boundTextElement &&
!suppliedElementsToAddSet.has(boundTextElement.id) &&
!currTargetFrameChildrenMap.has(boundTextElement.id)
) {
finalElementsToAdd.push(boundTextElement);
} }
} }
for (const element of finalElementsToAdd) { for (const element of finalElementsToAdd) {
// we don't always need to update the element if it's already in the frame, mutateElement(element, elementsMap, {
// but we still need to accumulate in finalElementsToAdd so we potentially frameId: frame.id,
// reorder them if added together });
if (element.frameId !== frame.id) {
mutateElement(element, elementsMap, {
frameId: frame.id,
});
}
} }
// (re)order elements to be just below the frame, return allElements;
// or just above the highest child if that is higher
// (latter case is denormalized order until we migrate)
// ---------------------------------------------------------------------------
if (
!finalElementsToAdd.size ||
// if all elements to add already belong to the frame, then we don't want to
// reorder (case: we're dragging element children within the frame)
commonFrameId === frame.id
) {
return allElements;
}
const otherElements = Array.from(allElements.values()).filter(
(element) => !finalElementsToAdd.has(element),
);
const insertionIndex = getFrameChildrenInsertionIndex(
otherElements,
frame.id,
);
if (insertionIndex === null) {
return allElements;
}
const reorderedElements = [
...otherElements.slice(0, insertionIndex),
...finalElementsToAdd,
...otherElements.slice(insertionIndex),
];
syncMovedIndices(reorderedElements, arrayToMap([...finalElementsToAdd]));
return (
Array.isArray(allElements)
? reorderedElements
: new Map(reorderedElements.map((element) => [element.id, element]))
) as T;
}; };
export const removeElementsFromFrame = ( export const removeElementsFromFrame = (
@@ -681,11 +621,13 @@ export const replaceAllElementsInFrame = <T extends ExcalidrawElement>(
allElements: readonly T[], allElements: readonly T[],
nextElementsInFrame: ExcalidrawElement[], nextElementsInFrame: ExcalidrawElement[],
frame: ExcalidrawFrameLikeElement, frame: ExcalidrawFrameLikeElement,
app: AppClassProperties,
): T[] => { ): T[] => {
return addElementsToFrame( return addElementsToFrame(
removeAllElementsFromFrame(allElements, frame), removeAllElementsFromFrame(allElements, frame),
nextElementsInFrame, nextElementsInFrame,
frame, frame,
app.state,
).slice(); ).slice();
}; };
+31 -42
View File
@@ -48,6 +48,7 @@ import {
calculateFixedPointForNonElbowArrowBinding, calculateFixedPointForNonElbowArrowBinding,
getBindingStrategyForDraggingBindingElementEndpoints, getBindingStrategyForDraggingBindingElementEndpoints,
isBindingEnabled, isBindingEnabled,
snapToMid,
updateBoundPoint, updateBoundPoint,
} from "./binding"; } from "./binding";
import { import {
@@ -343,7 +344,7 @@ export class LinearElementEditor {
// Apply the point movement if needed // Apply the point movement if needed
let suggestedBinding: AppState["suggestedBinding"] = null; let suggestedBinding: AppState["suggestedBinding"] = null;
const { positions, updates, hit } = pointDraggingUpdates( const { positions, updates } = pointDraggingUpdates(
[idx], [idx],
deltaX, deltaX,
deltaY, deltaY,
@@ -381,20 +382,17 @@ export class LinearElementEditor {
// Move the arrow over the bindable object in terms of z-index // Move the arrow over the bindable object in terms of z-index
if (isBindingElement(element)) { if (isBindingElement(element)) {
if (hit) { moveArrowAboveBindable(
moveArrowAboveBindable( LinearElementEditor.getPointGlobalCoordinates(
LinearElementEditor.getPointGlobalCoordinates(
element,
element.points[element.points.length - 1],
elementsMap,
),
element, element,
elements, element.points[element.points.length - 1],
elementsMap, elementsMap,
app.scene, ),
hit, element,
); elements,
} elementsMap,
app.scene,
);
} }
// PERF: Avoid state updates if not absolutely necessary // PERF: Avoid state updates if not absolutely necessary
@@ -488,7 +486,7 @@ export class LinearElementEditor {
selectedPointsIndices, selectedPointsIndices,
)}) points(0..${ )}) points(0..${
element.points.length - 1 element.points.length - 1
}) lastClickedPoint(${lastClickedPoint}) isElbowArrow: ${elbowed}`, }) lastClickedPoint(${lastClickedPoint})`,
); );
// Fall back to the actual last point as a last resort. // Fall back to the actual last point as a last resort.
@@ -541,7 +539,7 @@ export class LinearElementEditor {
// Apply the point movement if needed // Apply the point movement if needed
let suggestedBinding: AppState["suggestedBinding"] = null; let suggestedBinding: AppState["suggestedBinding"] = null;
const { positions, updates, hit } = pointDraggingUpdates( const { positions, updates } = pointDraggingUpdates(
selectedPointsIndices, selectedPointsIndices,
deltaX, deltaX,
deltaY, deltaY,
@@ -580,22 +578,19 @@ export class LinearElementEditor {
// Move the arrow over the bindable object in terms of z-index // Move the arrow over the bindable object in terms of z-index
if (isBindingElement(element) && startIsSelected !== endIsSelected) { if (isBindingElement(element) && startIsSelected !== endIsSelected) {
if (hit) { moveArrowAboveBindable(
moveArrowAboveBindable( LinearElementEditor.getPointGlobalCoordinates(
LinearElementEditor.getPointGlobalCoordinates(
element,
startIsSelected
? element.points[0]
: element.points[element.points.length - 1],
elementsMap,
),
element, element,
elements, startIsSelected
? element.points[0]
: element.points[element.points.length - 1],
elementsMap, elementsMap,
app.scene, ),
hit, element,
); elements,
} elementsMap,
app.scene,
);
} }
// Attached text might need to update if arrow dimensions change // Attached text might need to update if arrow dimensions change
@@ -2141,17 +2136,16 @@ const pointDraggingUpdates = (
): { ): {
positions: PointsPositionUpdates; positions: PointsPositionUpdates;
updates?: PointMoveOtherUpdates; updates?: PointMoveOtherUpdates;
hit?: ExcalidrawBindableElement | null;
} => { } => {
const naiveDraggingPoints = new Map( const naiveDraggingPoints = new Map(
selectedPointsIndices.map((pointIndex) => { selectedPointsIndices.map((pointIndex) => {
// NOTE: Avoid stale point index issue potentially caused by elbow
// arrows unpredictably changing the number of points during dragging
const point = element.points[pointIndex] ?? element.points.at(-1);
return [ return [
pointIndex, pointIndex,
{ {
point: pointFrom<LocalPoint>(point[0] + deltaX, point[1] + deltaY), point: pointFrom<LocalPoint>(
element.points[pointIndex][0] + deltaX,
element.points[pointIndex][1] + deltaY,
),
isDragging: true, isDragging: true,
}, },
]; ];
@@ -2199,15 +2193,13 @@ const pointDraggingUpdates = (
? { ? {
element: suggestedBindingElement, element: suggestedBindingElement,
midPoint: app.state.isMidpointSnappingEnabled midPoint: app.state.isMidpointSnappingEnabled
? getSnapOutlineMidPoint( ? snapToMid(
suggestedBindingElement,
elementsMap,
pointFrom<GlobalPoint>( pointFrom<GlobalPoint>(
scenePointerX - linearElementEditor.pointerOffset.x, scenePointerX - linearElementEditor.pointerOffset.x,
scenePointerY - linearElementEditor.pointerOffset.y, scenePointerY - linearElementEditor.pointerOffset.y,
), ),
suggestedBindingElement,
elementsMap,
app.state.zoom,
element,
) )
: undefined, : undefined,
} }
@@ -2314,7 +2306,6 @@ const pointDraggingUpdates = (
start.element, start.element,
elementsMap, elementsMap,
app.state.zoom, app.state.zoom,
element,
), ),
} }
: null; : null;
@@ -2354,7 +2345,6 @@ const pointDraggingUpdates = (
end.element, end.element,
elementsMap, elementsMap,
app.state.zoom, app.state.zoom,
element,
), ),
} }
: null; : null;
@@ -2507,7 +2497,6 @@ const pointDraggingUpdates = (
]; ];
}), }),
), ),
hit: startIsDragged ? start.element : end.element,
}; };
}; };
+19 -48
View File
@@ -34,6 +34,7 @@ import {
elementOverlapsWithFrame, elementOverlapsWithFrame,
getContainingFrame, getContainingFrame,
getFrameChildren, getFrameChildren,
isElementIntersectingFrame,
} from "./frame"; } from "./frame";
import { LinearElementEditor } from "./linearElementEditor"; import { LinearElementEditor } from "./linearElementEditor";
@@ -129,24 +130,13 @@ export const getElementsWithinSelection = (
const framesInSelection = excludeElementsInFrames const framesInSelection = excludeElementsInFrames
? new Set<NonDeletedExcalidrawElement["id"]>() ? new Set<NonDeletedExcalidrawElement["id"]>()
: null; : null;
const groups: Record<string, NonDeletedExcalidrawElement[]> = {}; let elementsInSelection: NonDeletedExcalidrawElement[] = [];
const elementsInSelection: Set<NonDeletedExcalidrawElement> = new Set();
for (const element of elements) { for (const element of elements) {
if (shouldIgnoreElementFromSelection(element)) { if (shouldIgnoreElementFromSelection(element)) {
continue; continue;
} }
// Track only selectable top-level group members, so ignored elements such
// as bound text and locked elements don't affect group selection.
const groupId = element.groupIds.at(-1);
if (groupId) {
if (!groups[groupId]) {
groups[groupId] = [];
}
groups[groupId].push(element);
}
const strokeWidth = element.strokeWidth; const strokeWidth = element.strokeWidth;
let labelAABB: Bounds | null = null; let labelAABB: Bounds | null = null;
let elementAABB = getElementBounds(element, elementsMap); let elementAABB = getElementBounds(element, elementsMap);
@@ -180,7 +170,7 @@ export const getElementsWithinSelection = (
const associatedFrame = getContainingFrame(element, elementsMap); const associatedFrame = getContainingFrame(element, elementsMap);
if ( if (
associatedFrame && associatedFrame &&
elementOverlapsWithFrame(element, associatedFrame, elementsMap) isElementIntersectingFrame(element, associatedFrame, elementsMap)
) { ) {
const frameAABB = getElementBounds(associatedFrame, elementsMap); const frameAABB = getElementBounds(associatedFrame, elementsMap);
elementAABB = [ elementAABB = [
@@ -219,9 +209,10 @@ export const getElementsWithinSelection = (
if (boundsContainBounds(selectionBounds, commonAABB)) { if (boundsContainBounds(selectionBounds, commonAABB)) {
if (framesInSelection && isFrameLikeElement(element)) { if (framesInSelection && isFrameLikeElement(element)) {
framesInSelection.add(element.id); framesInSelection.add(element.id);
} else {
elementsInSelection.push(element);
continue;
} }
elementsInSelection.add(element);
continue;
} }
// 2. Handle the case where the label is overlapped by the selection box // 2. Handle the case where the label is overlapped by the selection box
@@ -230,7 +221,7 @@ export const getElementsWithinSelection = (
labelAABB && labelAABB &&
doBoundsIntersect(selectionBounds, labelAABB) doBoundsIntersect(selectionBounds, labelAABB)
) { ) {
elementsInSelection.add(element); elementsInSelection.push(element);
continue; continue;
} }
@@ -320,7 +311,7 @@ export const getElementsWithinSelection = (
framesInSelection.add(element.id); framesInSelection.add(element.id);
} }
elementsInSelection.add(element); elementsInSelection.push(element);
continue; continue;
} }
} }
@@ -329,41 +320,21 @@ export const getElementsWithinSelection = (
// as it is separately handled in App. // as it is separately handled in App.
} }
if (framesInSelection) { elementsInSelection = framesInSelection
elementsInSelection.forEach((element) => { ? excludeElementsFromFrames(elementsInSelection, framesInSelection)
if (element.frameId && framesInSelection.has(element.frameId)) { : elementsInSelection;
elementsInSelection.delete(element);
}
});
}
if (boxSelectionMode === "overlap") { elementsInSelection = elementsInSelection.filter((element) => {
Array.from(elementsInSelection).forEach((element) => { const containingFrame = getContainingFrame(element, elementsMap);
const groupId = element.groupIds.at(-1);
const group = groupId ? groups[groupId] : null;
group?.forEach((groupElement) => elementsInSelection.add(groupElement)); if (containingFrame) {
}); return elementOverlapsWithFrame(element, containingFrame, elementsMap);
} else if (boxSelectionMode === "contain") { }
elementsInSelection.forEach((element) => {
// note: currently we only support top-level group handling since
// we don't support box selecting while editing the group/subgroup
// see https://github.com/excalidraw/excalidraw/pull/11234#issuecomment-4387654451
const groupId = element.groupIds.at(-1);
const group = groupId ? groups[groupId] : null; return true;
});
if ( return elementsInSelection;
group &&
!group.every((groupElement) => elementsInSelection.has(groupElement))
) {
elementsInSelection.delete(element);
}
});
}
// to maintain original order elements (namely for group selection)
return elements.filter((element) => elementsInSelection.has(element));
}; };
export const getVisibleAndNonSelectedElements = ( export const getVisibleAndNonSelectedElements = (
+64 -62
View File
@@ -1,56 +1,59 @@
import { arrayToMap } from "@excalidraw/common"; import { arrayToMapWithIndex } from "@excalidraw/common";
import type { ExcalidrawElement } from "./types"; import type { ExcalidrawElement } from "./types";
const defragmentGroups = (elements: readonly ExcalidrawElement[]) => { const normalizeGroupElementOrder = (elements: readonly ExcalidrawElement[]) => {
const groupIdAtLevel = (element: ExcalidrawElement, level: number) => { const origElements: ExcalidrawElement[] = elements.slice();
return element.groupIds[element.groupIds.length - level - 1]; const sortedElements = new Set<ExcalidrawElement>();
};
const orderLevel = ( const orderInnerGroups = (
levelElements: readonly ExcalidrawElement[], elements: readonly ExcalidrawElement[],
level: number,
): ExcalidrawElement[] => { ): ExcalidrawElement[] => {
const buckets = new Map<string, ExcalidrawElement[]>(); const firstGroupSig = elements[0]?.groupIds?.join("");
// Slots preserve first-occurrence order: a groupId reserves its slot const aGroup: ExcalidrawElement[] = [elements[0]];
// the first time one of its members is seen; loose elements occupy const bGroup: ExcalidrawElement[] = [];
// their own slot. Groups are then expanded (and recursed into) in place. for (const element of elements.slice(1)) {
const slots: (ExcalidrawElement | string)[] = []; if (element.groupIds?.join("") === firstGroupSig) {
aGroup.push(element);
for (const element of levelElements) { } else {
const groupId = groupIdAtLevel(element, level); bGroup.push(element);
if (groupId === undefined) {
slots.push(element);
continue;
} }
let bucket = buckets.get(groupId);
if (!bucket) {
bucket = [];
buckets.set(groupId, bucket);
slots.push(groupId);
}
bucket.push(element);
} }
return bGroup.length ? [...aGroup, ...orderInnerGroups(bGroup)] : aGroup;
return slots.flatMap((slot) =>
typeof slot === "string"
? orderLevel(buckets.get(slot)!, level + 1)
: [slot],
);
}; };
// `groupIds` is stored innermost-first, so the outermost group is the const groupHandledElements = new Map<string, true>();
// last entry. We recurse from level 0 (outermost) inward.
const sortedElements = orderLevel(elements, 0); origElements.forEach((element, idx) => {
if (groupHandledElements.has(element.id)) {
return;
}
if (element.groupIds?.length) {
const topGroup = element.groupIds[element.groupIds.length - 1];
const groupElements = origElements.slice(idx).filter((element) => {
const ret = element?.groupIds?.some((id) => id === topGroup);
if (ret) {
groupHandledElements.set(element!.id, true);
}
return ret;
});
for (const elem of orderInnerGroups(groupElements)) {
sortedElements.add(elem);
}
} else {
sortedElements.add(element);
}
});
// if there's a bug which resulted in losing some of the elements, return // if there's a bug which resulted in losing some of the elements, return
// original instead as that's better than losing data // original instead as that's better than losing data
if (sortedElements.length !== elements.length) { if (sortedElements.size !== elements.length) {
console.error("defragmentGroups: lost some elements... bailing!"); console.error("normalizeGroupElementOrder: lost some elements... bailing!");
return elements; return elements;
} }
return sortedElements; return [...sortedElements];
}; };
/** /**
@@ -65,40 +68,39 @@ const defragmentGroups = (elements: readonly ExcalidrawElement[]) => {
const normalizeBoundElementsOrder = ( const normalizeBoundElementsOrder = (
elements: readonly ExcalidrawElement[], elements: readonly ExcalidrawElement[],
) => { ) => {
const elementsMap = arrayToMap(elements); const elementsMap = arrayToMapWithIndex(elements);
const origElements: (ExcalidrawElement | null)[] = elements.slice();
const sortedElements = new Set<ExcalidrawElement>(); const sortedElements = new Set<ExcalidrawElement>();
for (const element of elements) { origElements.forEach((element, idx) => {
if (sortedElements.has(element)) { if (!element) {
continue; return;
} }
if (element.boundElements?.length) { if (element.boundElements?.length) {
sortedElements.add(element); sortedElements.add(element);
for (const boundElement of element.boundElements) { origElements[idx] = null;
element.boundElements.forEach((boundElement) => {
const child = elementsMap.get(boundElement.id); const child = elementsMap.get(boundElement.id);
if (child && boundElement.type === "text") { if (child && boundElement.type === "text") {
sortedElements.add(child); sortedElements.add(child[0]);
origElements[child[1]] = null;
} }
});
} else if (element.type === "text" && element.containerId) {
const parent = elementsMap.get(element.containerId);
if (!parent?.[0].boundElements?.find((x) => x.id === element.id)) {
sortedElements.add(element);
origElements[idx] = null;
// if element has a container and container lists it, skip this element
// as it'll be taken care of by the container
} }
continue; } else {
sortedElements.add(element);
origElements[idx] = null;
} }
});
// if element has a container and container lists it, skip this element
// as it'll be taken care of by the container
if (
element.type === "text" &&
element.containerId &&
elementsMap
.get(element.containerId)
?.boundElements?.some((el) => el.id === element.id)
) {
continue;
}
sortedElements.add(element);
}
// if there's a bug which resulted in losing some of the elements, return // if there's a bug which resulted in losing some of the elements, return
// original instead as that's better than losing data // original instead as that's better than losing data
@@ -115,5 +117,5 @@ const normalizeBoundElementsOrder = (
export const normalizeElementOrder = ( export const normalizeElementOrder = (
elements: readonly ExcalidrawElement[], elements: readonly ExcalidrawElement[],
) => { ) => {
return normalizeBoundElementsOrder(defragmentGroups(elements)); return normalizeBoundElementsOrder(normalizeGroupElementOrder(elements));
}; };
+189 -9
View File
@@ -111,6 +111,67 @@ export class Store {
this.scheduleAction(CaptureUpdateAction.IMMEDIATELY); 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. * 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 { public clear(): void {
this.snapshot = StoreSnapshot.empty(); this.snapshot = StoreSnapshot.empty();
this.scheduledMacroActions = new Set(); 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. * 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. * Represents a captured delta by the Store.
*/ */
@@ -499,6 +662,7 @@ export class StoreDelta {
public readonly id: string, public readonly id: string,
public readonly elements: ElementsDelta, public readonly elements: ElementsDelta,
public readonly appState: AppStateDelta, public readonly appState: AppStateDelta,
public markers?: StoreDeltaMarkers,
) {} ) {}
/** /**
@@ -508,12 +672,16 @@ export class StoreDelta {
elements: ElementsDelta, elements: ElementsDelta,
appState: AppStateDelta, appState: AppStateDelta,
opts: { opts: {
id: string; id?: string;
} = { markers?: StoreDeltaMarkers;
id: randomId(), } = {},
},
) { ) {
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. * Restore a store delta instance from a DTO.
*/ */
public static restore(storeDeltaDTO: DTO<StoreDelta>) { public static restore(storeDeltaDTO: DTO<StoreDelta>) {
const { id, elements, appState } = storeDeltaDTO; const { id, elements, appState, markers } = storeDeltaDTO;
return new this( return new this(
id, id,
ElementsDelta.restore(elements), ElementsDelta.restore(elements),
AppStateDelta.restore(appState), AppStateDelta.restore(appState),
cloneStoreDeltaMarkers(markers),
); );
} }
@@ -553,11 +722,12 @@ export class StoreDelta {
id, id,
elements: { added, removed, updated }, elements: { added, removed, updated },
appState: { delta: appStateDelta }, appState: { delta: appStateDelta },
markers,
}: DTO<StoreDelta>) { }: DTO<StoreDelta>) {
const elements = ElementsDelta.create(added, removed, updated); const elements = ElementsDelta.create(added, removed, updated);
const appState = AppStateDelta.create(appStateDelta); 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) { for (const delta of deltas) {
aggregatedDelta.elements.squash(delta.elements); aggregatedDelta.elements.squash(delta.elements);
aggregatedDelta.appState.squash(delta.appState); aggregatedDelta.appState.squash(delta.appState);
mergeStoreDeltaMarkers(aggregatedDelta, delta.markers ?? {});
} }
pruneStoreDeltaMarkers(aggregatedDelta);
return aggregatedDelta; return aggregatedDelta;
} }
@@ -578,7 +750,12 @@ export class StoreDelta {
* Inverse store delta, creates new instance of `StoreDelta`. * Inverse store delta, creates new instance of `StoreDelta`.
*/ */
public static inverse(delta: 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, nextElements: SceneElementsMap,
modifierOptions?: "deleted" | "inserted", modifierOptions?: "deleted" | "inserted",
): StoreDelta { ): StoreDelta {
return this.create( const nextDelta = this.create(
delta.elements.applyLatestChanges( delta.elements.applyLatestChanges(
prevElements, prevElements,
nextElements, nextElements,
@@ -623,8 +800,11 @@ export class StoreDelta {
delta.appState, delta.appState,
{ {
id: delta.id, id: delta.id,
markers: delta.markers,
}, },
); );
pruneStoreDeltaMarkers(nextDelta);
return nextDelta;
} }
public static empty() { public static empty() {
-6
View File
@@ -17,7 +17,6 @@ import {
} from "@excalidraw/common"; } from "@excalidraw/common";
import type { MarkOptional } from "@excalidraw/common/utility-types"; import type { MarkOptional } from "@excalidraw/common/utility-types";
import type { Zoom } from "@excalidraw/excalidraw/types";
import { bindBindingElement } from "./binding"; import { bindBindingElement } from "./binding";
import { import {
@@ -249,7 +248,6 @@ const bindLinearElementToElement = (
end: ValidLinearElement["end"], end: ValidLinearElement["end"],
elementStore: ElementStore, elementStore: ElementStore,
scene: Scene, scene: Scene,
zoom: Zoom,
): { ): {
linearElement: ExcalidrawLinearElement; linearElement: ExcalidrawLinearElement;
startBoundElement?: ExcalidrawElement; startBoundElement?: ExcalidrawElement;
@@ -337,7 +335,6 @@ const bindLinearElementToElement = (
"orbit", "orbit",
"start", "start",
scene, scene,
zoom,
); );
} }
} }
@@ -414,7 +411,6 @@ const bindLinearElementToElement = (
"orbit", "orbit",
"end", "end",
scene, scene,
zoom,
); );
} }
} }
@@ -700,7 +696,6 @@ export const convertToExcalidrawElements = (
originalEnd, originalEnd,
elementStore, elementStore,
scene, scene,
{ value: 1 } as Zoom,
); );
container = linearElement; container = linearElement;
elementStore.add(linearElement); elementStore.add(linearElement);
@@ -726,7 +721,6 @@ export const convertToExcalidrawElements = (
end, end,
elementStore, elementStore,
scene, scene,
{ value: 1 } as Zoom,
); );
elementStore.add(linearElement); elementStore.add(linearElement);
-20
View File
@@ -392,23 +392,3 @@ export const canBecomePolygon = (
(points.length === 3 && !pointsEqual(points[0], points[points.length - 1])) (points.length === 3 && !pointsEqual(points[0], points[points.length - 1]))
); );
}; };
export const isEligibleFrameChildType = (type: ElementOrToolType) => {
switch (type) {
case "rectangle":
case "diamond":
case "ellipse":
case "arrow":
case "line":
case "freedraw":
case "text":
case "image":
case "frame":
case "embeddable": {
return true;
}
default: {
return false;
}
}
};
+53 -178
View File
@@ -8,7 +8,6 @@ import {
import { import {
bezierEquation, bezierEquation,
clamp,
curve, curve,
curveCatmullRomCubicApproxPoints, curveCatmullRomCubicApproxPoints,
curveOffsetPoints, curveOffsetPoints,
@@ -27,7 +26,7 @@ import {
type GlobalPoint, type GlobalPoint,
} from "@excalidraw/math"; } from "@excalidraw/math";
import type { Curve, LineSegment, LocalPoint, Radians } from "@excalidraw/math"; import type { Curve, LineSegment, LocalPoint } from "@excalidraw/math";
import type { import type {
AppState, AppState,
@@ -42,7 +41,7 @@ import { generateLinearCollisionShape } from "./shape";
import { hitElementItself, isPointInElement } from "./collision"; import { hitElementItself, isPointInElement } from "./collision";
import { LinearElementEditor } from "./linearElementEditor"; import { LinearElementEditor } from "./linearElementEditor";
import { isRectangularElement } from "./typeChecks"; import { isRectangularElement } from "./typeChecks";
import { getBindingGap, maxBindingDistance_simple } from "./binding"; import { maxBindingDistance_simple } from "./binding";
import { import {
getGlobalFixedPointForBindableElement, getGlobalFixedPointForBindableElement,
@@ -588,193 +587,67 @@ const getDiagonalsForBindableElement = (
return [diagonalOne, diagonalTwo]; return [diagonalOne, diagonalTwo];
}; };
const getSnappedMidpointIndexForElbowArrow = ( export const getSnapOutlineMidPoint = (
element: ExcalidrawBindableElement,
point: GlobalPoint, point: GlobalPoint,
center: GlobalPoint,
horizontalThreshold: number,
verticalThreshold: number,
) => {
const { x, y, width, height, angle } = element;
const nonRotated = pointRotateRads(point, center, -angle as Radians);
const bindingGap = getBindingGap(element);
if (pointDistance(center, nonRotated) < bindingGap) {
return -1;
}
if (
nonRotated[0] <= x + width / 2 &&
nonRotated[1] > center[1] - verticalThreshold &&
nonRotated[1] < center[1] + verticalThreshold
) {
return 2;
} else if (
nonRotated[1] <= y + height / 2 &&
nonRotated[0] > center[0] - horizontalThreshold &&
nonRotated[0] < center[0] + horizontalThreshold
) {
return 3;
} else if (
nonRotated[0] >= x + width / 2 &&
nonRotated[1] > center[1] - verticalThreshold &&
nonRotated[1] < center[1] + verticalThreshold
) {
return 0;
} else if (
nonRotated[1] >= y + height / 2 &&
nonRotated[0] > center[0] - horizontalThreshold &&
nonRotated[0] < center[0] + horizontalThreshold
) {
return 1;
} else if (element.type === "diamond") {
const distance = bindingGap;
const topLeft = pointFrom<GlobalPoint>(
x + width / 4 - distance,
y + height / 4 - distance,
);
const topRight = pointFrom<GlobalPoint>(
x + (3 * width) / 4 + distance,
y + height / 4 - distance,
);
const bottomLeft = pointFrom<GlobalPoint>(
x + width / 4 - distance,
y + (3 * height) / 4 + distance,
);
const bottomRight = pointFrom<GlobalPoint>(
x + (3 * width) / 4 + distance,
y + (3 * height) / 4 + distance,
);
if (
pointDistance(bottomLeft, nonRotated) <
Math.max(horizontalThreshold, verticalThreshold)
) {
return 1;
}
if (
pointDistance(bottomRight, nonRotated) <
Math.max(horizontalThreshold, verticalThreshold)
) {
return 0;
}
if (
pointDistance(topLeft, nonRotated) <
Math.max(horizontalThreshold, verticalThreshold)
) {
return 2;
}
if (
pointDistance(topRight, nonRotated) <
Math.max(horizontalThreshold, verticalThreshold)
) {
return 3;
}
}
return -1;
};
const getSnappedMidpointIndexForSimpleArrow = (
element: ExcalidrawBindableElement, element: ExcalidrawBindableElement,
point: GlobalPoint,
elementsMap: ElementsMap, elementsMap: ElementsMap,
horizontalThreshold: number, zoom: AppState["zoom"],
verticalThreshold: number,
) => { ) => {
const baseMidpoints = getAllMidpoints(element, elementsMap); const center = elementCenterPoint(element, elementsMap);
const sideMidpoints =
element.type === "diamond"
? getDiamondBaseCorners(element).map((curve) => {
const point = bezierEquation(curve, 0.5);
const rotatedPoint = pointRotateRads(point, center, element.angle);
for (let i = 0; i < baseMidpoints.length; i++) { return pointFrom<GlobalPoint>(rotatedPoint[0], rotatedPoint[1]);
const threshold = i % 2 === 0 ? horizontalThreshold : verticalThreshold; })
: [
if ( // RIGHT midpoint
pointDistance(baseMidpoints[i], point) <= threshold && pointRotateRads(
pointFrom<GlobalPoint>(
element.x + element.width,
element.y + element.height / 2,
),
center,
element.angle,
),
// BOTTOM midpoint
pointRotateRads(
pointFrom<GlobalPoint>(
element.x + element.width / 2,
element.y + element.height,
),
center,
element.angle,
),
// LEFT midpoint
pointRotateRads(
pointFrom<GlobalPoint>(element.x, element.y + element.height / 2),
center,
element.angle,
),
// TOP midpoint
pointRotateRads(
pointFrom<GlobalPoint>(element.x + element.width / 2, element.y),
center,
element.angle,
),
];
const candidate = sideMidpoints.find(
(midpoint) =>
pointDistance(point, midpoint) <=
maxBindingDistance_simple(zoom) + element.strokeWidth / 2 &&
!hitElementItself({ !hitElementItself({
point, point,
element, element,
threshold: 0, threshold: 0,
elementsMap, elementsMap,
overrideShouldTestInside: true, overrideShouldTestInside: true,
}) }),
) {
return i;
}
}
return -1;
};
export const getAllMidpoints = (
element: ExcalidrawBindableElement,
elementsMap: ElementsMap,
): GlobalPoint[] => {
const center = elementCenterPoint(element, elementsMap);
if (element.type === "diamond") {
return getDiamondBaseCorners(element).map((curve) =>
pointRotateRads(bezierEquation(curve, 0.5), center, element.angle),
);
}
return [
pointFrom(element.width, element.height / 2),
pointFrom(element.width / 2, element.height),
pointFrom(0, element.height / 2),
pointFrom(element.width / 2, 0),
].map(([x, y]) =>
pointRotateRads(
pointFrom<GlobalPoint>(element.x + x, element.y + y),
center,
element.angle,
),
); );
};
export const getSnapOutlineMidPoint = ( return candidate;
point: GlobalPoint,
element: ExcalidrawBindableElement,
elementsMap: ElementsMap,
zoom: AppState["zoom"],
arrow: { elbowed: boolean },
): GlobalPoint | undefined => {
const center = elementCenterPoint(element, elementsMap);
const baseMidpoints = getAllMidpoints(element, elementsMap);
const sideMidpoints =
element.type === "diamond"
? baseMidpoints.map((midpoint) => {
return pointFrom<GlobalPoint>(
midpoint[0] + (midpoint[0] - center[0]) * 0.1,
midpoint[1] + (midpoint[1] - center[1]) * 0.1,
);
})
: baseMidpoints;
const TOLERANCE = 0.05;
const maxDistance = maxBindingDistance_simple(zoom) + element.strokeWidth / 2;
const verticalThreshold = clamp(TOLERANCE * element.height, 5, maxDistance);
const horizontalThreshold = clamp(TOLERANCE * element.width, 5, maxDistance);
const idx = arrow.elbowed
? getSnappedMidpointIndexForElbowArrow(
element,
point,
center,
horizontalThreshold,
verticalThreshold,
)
: getSnappedMidpointIndexForSimpleArrow(
element,
point,
elementsMap,
horizontalThreshold,
verticalThreshold,
);
if (idx === -1) {
return undefined;
}
return sideMidpoints[idx];
}; };
export const projectFixedPointOntoDiagonal = ( export const projectFixedPointOntoDiagonal = (
@@ -787,6 +660,9 @@ export const projectFixedPointOntoDiagonal = (
isMidpointSnappingEnabled: boolean = true, isMidpointSnappingEnabled: boolean = true,
): GlobalPoint | null => { ): GlobalPoint | null => {
invariant(arrow.points.length >= 2, "Arrow must have at least two points"); invariant(arrow.points.length >= 2, "Arrow must have at least two points");
if (arrow.width < 3 && arrow.height < 3) {
return null;
}
if (isMidpointSnappingEnabled) { if (isMidpointSnappingEnabled) {
const sideMidPoint = getSnapOutlineMidPoint( const sideMidPoint = getSnapOutlineMidPoint(
@@ -794,7 +670,6 @@ export const projectFixedPointOntoDiagonal = (
element, element,
elementsMap, elementsMap,
zoom, zoom,
arrow,
); );
if (sideMidPoint) { if (sideMidPoint) {
return sideMidPoint; return sideMidPoint;
+6 -1
View File
@@ -8,6 +8,7 @@ import { getElementsInGroup } from "./groups";
import { syncMovedIndices } from "./fractionalIndex"; import { syncMovedIndices } from "./fractionalIndex";
import { getSelectedElements } from "./selection"; import { getSelectedElements } from "./selection";
import { getBoundTextElement, getContainerElement } from "./textElement"; import { getBoundTextElement, getContainerElement } from "./textElement";
import { getHoveredElementForBinding } from "./collision";
import type { Scene } from "./Scene"; import type { Scene } from "./Scene";
import type { import type {
@@ -155,8 +156,12 @@ export const moveArrowAboveBindable = (
elements: readonly Ordered<NonDeletedExcalidrawElement>[], elements: readonly Ordered<NonDeletedExcalidrawElement>[],
elementsMap: NonDeletedSceneElementsMap, elementsMap: NonDeletedSceneElementsMap,
scene: Scene, scene: Scene,
hoveredElement: NonDeletedExcalidrawElement, hit?: NonDeletedExcalidrawElement,
): readonly OrderedExcalidrawElement[] => { ): readonly OrderedExcalidrawElement[] => {
const hoveredElement = hit
? hit
: getHoveredElementForBinding(point, elements, elementsMap);
if (!hoveredElement) { if (!hoveredElement) {
return elements; return elements;
} }
+2 -7
View File
@@ -16,7 +16,6 @@ import "@excalidraw/utils/test-utils";
import { bindBindingElement } from "@excalidraw/element"; import { bindBindingElement } from "@excalidraw/element";
import type { LocalPoint } from "@excalidraw/math"; import type { LocalPoint } from "@excalidraw/math";
import type { Zoom } from "@excalidraw/excalidraw/types";
import { Scene } from "../src/Scene"; import { Scene } from "../src/Scene";
@@ -188,12 +187,8 @@ describe("elbow arrow routing", () => {
}) as ExcalidrawElbowArrowElement; }) as ExcalidrawElbowArrowElement;
API.setElements([rectangle1, rectangle2, arrow]); API.setElements([rectangle1, rectangle2, arrow]);
bindBindingElement(arrow, rectangle1, "orbit", "start", h.scene, { bindBindingElement(arrow, rectangle1, "orbit", "start", h.scene);
value: 1, bindBindingElement(arrow, rectangle2, "orbit", "end", h.scene);
} as Zoom);
bindBindingElement(arrow, rectangle2, "orbit", "end", h.scene, {
value: 1,
} as Zoom);
expect(arrow.startBinding).not.toBe(null); expect(arrow.startBinding).not.toBe(null);
expect(arrow.endBinding).not.toBe(null); expect(arrow.endBinding).not.toBe(null);
+3 -63
View File
@@ -1,8 +1,9 @@
/* eslint-disable no-lone-blocks */ /* eslint-disable no-lone-blocks */
import { generateKeyBetween } from "fractional-indexing";
import { arrayToMap } from "@excalidraw/common"; import { arrayToMap } from "@excalidraw/common";
import { import {
InvalidFractionalIndexError,
syncInvalidIndices, syncInvalidIndices,
syncMovedIndices, syncMovedIndices,
validateFractionalIndices, validateFractionalIndices,
@@ -12,34 +13,13 @@ import { deepCopyElement } from "@excalidraw/element";
import { API } from "@excalidraw/excalidraw/tests/helpers/api"; import { API } from "@excalidraw/excalidraw/tests/helpers/api";
import {
generateKeyBetween,
validateOrderKey,
} from "@excalidraw/fractional-indexing";
import type { import type {
ElementsMap, ElementsMap,
ExcalidrawElement, ExcalidrawElement,
FractionalIndex, FractionalIndex,
} from "@excalidraw/element/types"; } from "@excalidraw/element/types";
describe("fractional index format validation", () => { import { InvalidFractionalIndexError } from "../src/fractionalIndex";
it("should reject malformed base62 order keys", () => {
expect(() => validateOrderKey("a!")).toThrow();
expect(() => validateOrderKey("a_")).toThrow();
expect(() => validateOrderKey("a1!")).toThrow();
expect(() => validateOrderKey("a1_")).toThrow();
expect(() => validateOrderKey("zd0032")).toThrow();
});
it("should accept valid base62 order keys", () => {
expect(() => validateOrderKey("Zz")).not.toThrow();
expect(() => validateOrderKey("a0")).not.toThrow();
expect(() => validateOrderKey("a1")).not.toThrow();
expect(() => validateOrderKey("a1V")).not.toThrow();
expect(() => validateOrderKey("z".padEnd(28, "z"))).not.toThrow();
});
});
describe("sync invalid indices with array order", () => { describe("sync invalid indices with array order", () => {
describe("should NOT sync empty array", () => { describe("should NOT sync empty array", () => {
@@ -124,46 +104,6 @@ describe("sync invalid indices with array order", () => {
}); });
}); });
describe("should sync when fractional index is malformed", () => {
// "zd0032" has head "z" which requires length 28 per getIntegerLength,
// but the string is far too short, so validateOrderKey throws for it
testInvalidIndicesSync({
elements: [{ id: "A", index: "zd0032" }],
expect: {
unchangedElements: [],
},
});
testInvalidIndicesSync({
elements: [
{ id: "A", index: "a1" },
{ id: "B", index: "zd0032" },
{ id: "C", index: "a3" },
],
expect: {
unchangedElements: ["A", "C"],
},
});
testInvalidIndicesSync({
elements: [{ id: "A", index: "a!" }],
expect: {
unchangedElements: [],
},
});
testInvalidIndicesSync({
elements: [
{ id: "A", index: "a1" },
{ id: "B", index: "a!" },
{ id: "C", index: "a2" },
],
expect: {
unchangedElements: ["A", "C"],
},
});
});
describe("should sync when fractional indices are duplicated", () => { describe("should sync when fractional indices are duplicated", () => {
testInvalidIndicesSync({ testInvalidIndicesSync({
elements: [ elements: [
+2 -594
View File
@@ -2,24 +2,15 @@ import {
convertToExcalidrawElements, convertToExcalidrawElements,
Excalidraw, Excalidraw,
} from "@excalidraw/excalidraw"; } from "@excalidraw/excalidraw";
import { arrayToMap } from "@excalidraw/common";
import { API } from "@excalidraw/excalidraw/tests/helpers/api"; import { API } from "@excalidraw/excalidraw/tests/helpers/api";
import { Keyboard, Pointer, UI } from "@excalidraw/excalidraw/tests/helpers/ui"; import { Keyboard, Pointer } from "@excalidraw/excalidraw/tests/helpers/ui";
import { getTextEditor } from "@excalidraw/excalidraw/tests/queries/dom";
import { import {
getCloneByOrigId, getCloneByOrigId,
render, render,
} from "@excalidraw/excalidraw/tests/test-utils"; } from "@excalidraw/excalidraw/tests/test-utils";
import { getSelectedElements } from "@excalidraw/excalidraw/scene"; import type { ExcalidrawElement } from "../src/types";
import { elementOverlapsWithFrame } from "../src/frame";
import type {
ExcalidrawElement,
ExcalidrawFrameLikeElement,
} from "../src/types";
const { h } = window; const { h } = window;
const mouse = new Pointer("mouse"); const mouse = new Pointer("mouse");
@@ -134,250 +125,6 @@ describe("adding elements to frames", () => {
}); });
}); });
it("should treat an element fully containing a frame as overlapping the frame", () => {
const containingRect = API.createElement({
type: "rectangle",
x: -50,
y: -50,
width: 250,
height: 250,
});
API.setElements([containingRect, frame]);
expect(
elementOverlapsWithFrame(
containingRect,
frame as ExcalidrawFrameLikeElement,
arrayToMap(h.elements),
),
).toBe(true);
});
it("should not add a newly created element to a frame behind a non-frame element", () => {
const cover = API.createElement({
id: "cover",
type: "rectangle",
x: 10,
y: 10,
width: 80,
height: 80,
backgroundColor: "#ffc9c9",
});
API.setElements([frame, cover]);
UI.clickTool("rectangle");
mouse.downAt(20, 20);
mouse.moveTo(40, 40);
mouse.upAt(40, 40);
const createdElement = h.elements.find(
(element) => element.id !== frame.id && element.id !== cover.id,
);
expect(createdElement?.frameId).toBe(null);
expect(h.elements.map((element) => element.id)).toEqual([
frame.id,
cover.id,
createdElement?.id,
]);
});
it("should add a newly created element to a frame over a non-frame element", () => {
const cover = API.createElement({
id: "cover",
type: "rectangle",
x: 10,
y: 10,
width: 80,
height: 80,
backgroundColor: "#ffc9c9",
});
API.setElements([cover, frame]);
UI.clickTool("rectangle");
mouse.downAt(20, 20);
mouse.moveTo(40, 40);
mouse.upAt(40, 40);
const createdElement = h.elements.find(
(element) => element.id !== frame.id && element.id !== cover.id,
);
expect(createdElement?.frameId).toBe(frame.id);
});
it("should highlight the target frame while creating a new element", () => {
API.setElements([frame]);
UI.clickTool("rectangle");
mouse.downAt(20, 20);
mouse.moveTo(40, 40);
expect(h.state.frameToHighlight?.id).toBe(frame.id);
mouse.upAt(40, 40);
expect(h.state.frameToHighlight).toBe(null);
});
it("should highlight the target frame while hovering with a creation tool", () => {
API.setElements([frame]);
UI.clickTool("rectangle");
mouse.moveTo(20, 20);
expect(h.state.frameToHighlight?.id).toBe(frame.id);
mouse.moveTo(200, 200);
expect(h.state.frameToHighlight).toBe(null);
});
it("should not add grid-snapped text outside the frame to the clicked frame", async () => {
const offsetFrame = API.createElement({
id: "offsetFrame",
type: "frame",
x: 10,
y: 0,
width: 150,
height: 150,
});
API.setElements([offsetFrame]);
API.setAppState({
gridModeEnabled: true,
});
UI.clickTool("text");
mouse.clickAt(12, 0);
await getTextEditor();
const createdText = h.elements.find(
(element) => element.id !== offsetFrame.id,
);
expect(createdText?.x).toBe(0);
expect(createdText?.y).toBe(0);
expect(createdText?.frameId).toBe(null);
});
it("should add a newly created element to a frame behind another frame", () => {
const lockedFrame = API.createElement({
id: "lockedFrame",
type: "frame",
x: 10,
y: 10,
width: 80,
height: 80,
locked: true,
});
API.setElements([frame, lockedFrame]);
UI.clickTool("rectangle");
mouse.downAt(20, 20);
mouse.moveTo(40, 40);
mouse.upAt(40, 40);
const createdElement = h.elements.find(
(element) => element.id !== frame.id && element.id !== lockedFrame.id,
);
expect(createdElement?.frameId).toBe(frame.id);
});
it("should insert a newly created frame child just below its frame", () => {
const frameChildUnderCursor = API.createElement({
id: "frameChildUnderCursor",
type: "rectangle",
x: 10,
y: 10,
width: 80,
height: 80,
backgroundColor: "#ffc9c9",
frameId: frame.id,
});
const otherFrameChild = API.createElement({
id: "otherFrameChild",
type: "rectangle",
x: 100,
y: 20,
width: 20,
height: 20,
frameId: frame.id,
});
API.setElements([frameChildUnderCursor, otherFrameChild, frame]);
UI.clickTool("rectangle");
mouse.downAt(20, 20);
mouse.moveTo(40, 40);
mouse.upAt(40, 40);
const createdElement = h.elements.find(
(element) =>
element.id !== frame.id &&
element.id !== frameChildUnderCursor.id &&
element.id !== otherFrameChild.id,
);
expect(createdElement?.frameId).toBe(frame.id);
expect(h.elements.map((element) => element.id)).toEqual([
frameChildUnderCursor.id,
otherFrameChild.id,
createdElement?.id,
frame.id,
]);
});
it("should insert a newly created frame child above the highest frame child", () => {
const frameChildUnderCursor = API.createElement({
id: "frameChildUnderCursor",
type: "rectangle",
x: 10,
y: 10,
width: 80,
height: 80,
backgroundColor: "#ffc9c9",
frameId: frame.id,
});
const otherFrameChild = API.createElement({
id: "otherFrameChild",
type: "rectangle",
x: 100,
y: 20,
width: 20,
height: 20,
frameId: frame.id,
});
API.setElements([frame, frameChildUnderCursor, otherFrameChild]);
UI.clickTool("rectangle");
mouse.downAt(20, 20);
mouse.moveTo(40, 40);
mouse.upAt(40, 40);
const createdElement = h.elements.find(
(element) =>
element.id !== frame.id &&
element.id !== frameChildUnderCursor.id &&
element.id !== otherFrameChild.id,
);
expect(createdElement?.frameId).toBe(frame.id);
expect(h.elements.map((element) => element.id)).toEqual([
frame.id,
frameChildUnderCursor.id,
otherFrameChild.id,
createdElement?.id,
]);
});
const commonTestCases = async ( const commonTestCases = async (
func: typeof resizeFrameOverElement | typeof dragElementIntoFrame, func: typeof resizeFrameOverElement | typeof dragElementIntoFrame,
) => { ) => {
@@ -668,345 +415,6 @@ describe("adding elements to frames", () => {
describe("dragging elements into the frame", async () => { describe("dragging elements into the frame", async () => {
await commonTestCases(dragElementIntoFrame); await commonTestCases(dragElementIntoFrame);
it("should add a dragged element fully containing the frame", () => {
const containingRect = API.createElement({
type: "rectangle",
x: 220,
y: 20,
width: 300,
height: 300,
});
API.setElements([frame, containingRect]);
dragElementIntoFrame(frame, containingRect);
expect(API.getElement(containingRect).frameId).toBe(frame.id);
});
it("should drag an element into a frame", () => {
API.setElements([rect2, frame]);
dragElementIntoFrame(frame, rect2);
expect(rect2.frameId).toBe(frame.id);
});
it("should layer a dragged element above the highest frame child", () => {
const frameChild = API.createElement({
id: "frameChild",
type: "rectangle",
x: 10,
y: 10,
width: 20,
height: 20,
frameId: frame.id,
});
API.setElements([frame, frameChild, rect2]);
dragElementIntoFrame(frame, rect2);
expect(rect2.frameId).toBe(frame.id);
expect(h.elements.map((element) => element.id)).toEqual([
frame.id,
frameChild.id,
rect2.id,
]);
expect(rect2.index! > frameChild.index!).toBe(true);
expect(rect2.index! > frame.index!).toBe(true);
});
it("should preview a dragged element above the highest frame child before pointerup", () => {
const frameChild = API.createElement({
id: "frameChild",
type: "rectangle",
x: 10,
y: 10,
width: 20,
height: 20,
frameId: frame.id,
});
API.setElements([rect2, frame, frameChild]);
API.setSelectedElements([rect2]);
API.updateElement(rect2, {
x: 10,
y: 10,
});
const getRenderableElementIds = (
selectedElementsAreBeingDragged: boolean,
) => {
return h.app.renderer
.getRenderableElements({
zoom: h.state.zoom,
offsetLeft: 0,
offsetTop: 0,
scrollX: 0,
scrollY: 0,
height: 1000,
width: 1000,
editingTextElement: h.state.editingTextElement,
newElement: h.state.newElement,
selectedElements: getSelectedElements(h.elements, h.state),
selectedElementsAreBeingDragged,
frameToHighlight: frame as ExcalidrawFrameLikeElement,
})
.visibleElements.map((element) => element.id);
};
expect(h.elements.map((element) => element.id)).toEqual([
rect2.id,
frame.id,
frameChild.id,
]);
expect(getRenderableElementIds(false)).toEqual([
rect2.id,
frame.id,
frameChild.id,
]);
expect(getRenderableElementIds(true)).toEqual([
frame.id,
frameChild.id,
rect2.id,
]);
expect(h.elements.map((element) => element.id)).toEqual([
rect2.id,
frame.id,
frameChild.id,
]);
expect(rect2.frameId).toBe(null);
});
it("should not preview reorder dragged elements already in the highlighted frame", () => {
const frameChild = API.createElement({
id: "frameChild",
type: "rectangle",
x: 10,
y: 10,
width: 20,
height: 20,
frameId: frame.id,
});
const otherFrameChild = API.createElement({
id: "otherFrameChild",
type: "rectangle",
x: 40,
y: 10,
width: 20,
height: 20,
frameId: frame.id,
});
API.setElements([frameChild, frame, otherFrameChild]);
API.setSelectedElements([frameChild]);
const renderableElementIds = h.app.renderer
.getRenderableElements({
zoom: h.state.zoom,
offsetLeft: 0,
offsetTop: 0,
scrollX: 0,
scrollY: 0,
height: 1000,
width: 1000,
editingTextElement: h.state.editingTextElement,
newElement: h.state.newElement,
selectedElements: getSelectedElements(h.elements, h.state),
selectedElementsAreBeingDragged: true,
frameToHighlight: frame as ExcalidrawFrameLikeElement,
})
.visibleElements.map((element) => element.id);
expect(renderableElementIds).toEqual([
frameChild.id,
frame.id,
otherFrameChild.id,
]);
});
it("should put a dragged mixed selection above the highest frame child", () => {
const frameChild = API.createElement({
id: "frameChild",
type: "rectangle",
x: 50,
y: 10,
width: 20,
height: 20,
frameId: frame.id,
boundElements: [{ id: "boundText", type: "text" }],
});
const boundText = API.createElement({
id: "boundText",
type: "text",
x: 50,
y: 10,
width: 20,
height: 20,
containerId: frameChild.id,
frameId: frame.id,
});
const otherFrameChild = API.createElement({
id: "otherFrameChild",
type: "rectangle",
x: 80,
y: 10,
width: 20,
height: 20,
frameId: frame.id,
});
const nonFrameElement = API.createElement({
id: "nonFrameElement",
type: "rectangle",
x: 155,
y: 10,
width: 20,
height: 20,
});
API.setElements([
frame,
frameChild,
boundText,
otherFrameChild,
nonFrameElement,
]);
API.setSelectedElements([frameChild, nonFrameElement]);
mouse.downAt(
nonFrameElement.x + nonFrameElement.width / 2,
nonFrameElement.y + nonFrameElement.height / 2,
);
mouse.moveTo(frame.x + frame.width - 5, nonFrameElement.y + 10);
mouse.up();
expect(frameChild.frameId).toBe(frame.id);
expect(boundText.frameId).toBe(frame.id);
expect(nonFrameElement.frameId).toBe(frame.id);
expect(h.elements.map((element) => element.id)).toEqual([
frame.id,
otherFrameChild.id,
frameChild.id,
boundText.id,
nonFrameElement.id,
]);
});
it("should not reorder dragged elements already in the highlighted frame", () => {
const frameChild = API.createElement({
id: "frameChild",
type: "rectangle",
x: 50,
y: 10,
width: 20,
height: 20,
frameId: frame.id,
});
const otherFrameChild = API.createElement({
id: "otherFrameChild",
type: "rectangle",
x: 80,
y: 10,
width: 20,
height: 20,
frameId: frame.id,
});
API.setElements([frame, frameChild, otherFrameChild]);
API.setSelectedElements([frameChild]);
mouse.downAt(
frameChild.x + frameChild.width / 2,
frameChild.y + frameChild.height / 2,
);
mouse.moveTo(frameChild.x + frameChild.width / 2 + 5, frameChild.y + 10);
mouse.up();
expect(frameChild.frameId).toBe(frame.id);
expect(h.elements.map((element) => element.id)).toEqual([
frame.id,
frameChild.id,
otherFrameChild.id,
]);
});
it("should not drag an element into a frame behind a non-frame element", () => {
const cover = API.createElement({
id: "cover",
type: "rectangle",
x: 10,
y: 10,
width: 80,
height: 80,
backgroundColor: "#ffc9c9",
});
API.setElements([frame, cover, rect2]);
mouse.clickAt(rect2.x, rect2.y);
mouse.downAt(rect2.x + rect2.width / 2, rect2.y + rect2.height / 2);
mouse.moveTo(20, 20);
mouse.upAt(20, 20);
expect(rect2.frameId).toBe(null);
});
it("should drag an element into a frame over a non-frame element", () => {
const cover = API.createElement({
id: "cover",
type: "rectangle",
x: 10,
y: 10,
width: 80,
height: 80,
backgroundColor: "#ffc9c9",
});
API.setElements([cover, rect2, frame]);
mouse.clickAt(rect2.x, rect2.y);
mouse.downAt(rect2.x + rect2.width / 2, rect2.y + rect2.height / 2);
mouse.moveTo(20, 20);
mouse.upAt(20, 20);
expect(rect2.frameId).toBe(frame.id);
});
it("should keep dragging a frame child over a non-frame element above its frame", () => {
const cover = API.createElement({
id: "cover",
type: "rectangle",
x: 10,
y: 10,
width: 80,
height: 80,
backgroundColor: "#ffc9c9",
});
const frameChild = API.createElement({
id: "frameChild",
type: "rectangle",
x: 100,
y: 20,
width: 20,
height: 20,
frameId: frame.id,
});
API.setElements([frameChild, frame, cover]);
API.setSelectedElements([frameChild]);
mouse.downAt(
frameChild.x + frameChild.width / 2,
frameChild.y + frameChild.height / 2,
);
mouse.moveTo(20, 20);
expect(h.state.frameToHighlight?.id).toBe(frame.id);
mouse.upAt(20, 20);
expect(frameChild.frameId).toBe(frame.id);
});
it.skip("should drag element inside, duplicate it and keep it in frame", () => { it.skip("should drag element inside, duplicate it and keep it in frame", () => {
API.setElements([frame, rect2]); API.setElements([frame, rect2]);
+3 -43
View File
@@ -326,59 +326,19 @@ describe("normalizeElementsOrder", () => {
]), ]),
[ [
"BA_rect1", "BA_rect1",
"CBA_rect3",
"CBA_rect7",
"BA_rect5", "BA_rect5",
"BA_rect6", "BA_rect6",
"A_rect2", "A_rect2",
"A_rect5", "A_rect5",
"CBA_rect3",
"CBA_rect7",
"rect4", "rect4",
"X_rect8", "X_rect8",
"YX_rect10",
"X_rect11", "X_rect11",
"YX_rect10",
"rect9", "rect9",
], ],
); );
assertOrder(
normalizeElementOrder([
API.createElement({
id: "A_rect1",
type: "rectangle",
groupIds: ["A"],
}),
API.createElement({
id: "CBA_rect2",
type: "rectangle",
groupIds: ["C", "B", "A"],
}),
API.createElement({
id: "A_rect3",
type: "rectangle",
groupIds: ["A"],
}),
]),
["A_rect1", "CBA_rect2", "A_rect3"],
);
assertOrder(
normalizeElementOrder([
API.createElement({
id: "abcT_rect1",
type: "rectangle",
groupIds: ["ab", "c", "T"],
}),
API.createElement({
id: "abcT_rect2",
type: "rectangle",
groupIds: ["a", "bc", "T"],
}),
API.createElement({
id: "abcT_rect3",
type: "rectangle",
groupIds: ["ab", "c", "T"],
}),
]),
["abcT_rect1", "abcT_rect3", "abcT_rect2"],
);
}); });
// TODO // TODO
+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();
});
});
@@ -101,7 +101,6 @@ export const actionDeselect = register({
selectionElement: null, selectionElement: null,
showHyperlinkPopup: false, showHyperlinkPopup: false,
suggestedBinding: null, suggestedBinding: null,
frameToHighlight: null,
}, },
captureUpdate: CaptureUpdateAction.IMMEDIATELY, captureUpdate: CaptureUpdateAction.IMMEDIATELY,
}; };
@@ -119,7 +118,6 @@ export const actionDeselect = register({
selectionElement: null, selectionElement: null,
showHyperlinkPopup: false, showHyperlinkPopup: false,
suggestedBinding: null, suggestedBinding: null,
frameToHighlight: null,
}, },
captureUpdate: CaptureUpdateAction.IMMEDIATELY, captureUpdate: CaptureUpdateAction.IMMEDIATELY,
}; };
@@ -329,8 +329,8 @@ export const actionFinalize = register<FormData>({
selectionElement: null, selectionElement: null,
multiElement: null, multiElement: null,
editingTextElement: null, editingTextElement: null,
startBoundElement: null,
suggestedBinding: null, suggestedBinding: null,
frameToHighlight: null,
selectedElementIds: selectedElementIds:
element && element &&
!appState.activeTool.locked && !appState.activeTool.locked &&
@@ -205,6 +205,7 @@ export const actionWrapSelectionInFrame = register({
[...app.scene.getElementsIncludingDeleted(), frame], [...app.scene.getElementsIncludingDeleted(), frame],
selectedElements, selectedElements,
frame, frame,
appState,
); );
return { return {
@@ -277,6 +277,7 @@ export const actionUngroup = register({
elementsMap, elementsMap,
), ),
frame, frame,
app,
); );
} }
}); });
@@ -1,4 +1,5 @@
import { import {
isWindows,
KEYS, KEYS,
matchKey, matchKey,
arrayToMap, arrayToMap,
@@ -113,7 +114,7 @@ export const createRedoAction: ActionCreator = (history) => ({
), ),
keyTest: (event) => keyTest: (event) =>
(event[KEYS.CTRL_OR_CMD] && event.shiftKey && matchKey(event, KEYS.Z)) || (event[KEYS.CTRL_OR_CMD] && event.shiftKey && matchKey(event, KEYS.Z)) ||
(event[KEYS.CTRL_OR_CMD] && !event.shiftKey && matchKey(event, KEYS.Y)), (isWindows && event.ctrlKey && !event.shiftKey && matchKey(event, KEYS.Y)),
PanelComponent: ({ appState, updateData, data, app }) => { PanelComponent: ({ appState, updateData, data, app }) => {
const { isRedoStackEmpty } = useEmitter( const { isRedoStackEmpty } = useEmitter(
history.onHistoryChangedEmitter, history.onHistoryChangedEmitter,
@@ -1897,7 +1897,6 @@ export const actionChangeArrowType = register<keyof typeof ARROW_TYPE>({
startElement, startElement,
"start", "start",
elementsMap, elementsMap,
appState.zoom,
appState.isBindingEnabled, appState.isBindingEnabled,
), ),
} }
@@ -1912,7 +1911,6 @@ export const actionChangeArrowType = register<keyof typeof ARROW_TYPE>({
endElement, endElement,
"end", "end",
elementsMap, elementsMap,
appState.zoom,
appState.isBindingEnabled, appState.isBindingEnabled,
), ),
} }
@@ -1945,7 +1943,6 @@ export const actionChangeArrowType = register<keyof typeof ARROW_TYPE>({
appState.bindMode === "inside" ? "inside" : "orbit", appState.bindMode === "inside" ? "inside" : "orbit",
"start", "start",
app.scene, app.scene,
appState.zoom,
); );
} }
} }
@@ -1960,7 +1957,6 @@ export const actionChangeArrowType = register<keyof typeof ARROW_TYPE>({
appState.bindMode === "inside" ? "inside" : "orbit", appState.bindMode === "inside" ? "inside" : "orbit",
"end", "end",
app.scene, app.scene,
appState.zoom,
); );
} }
} }
@@ -1,4 +1,5 @@
import { LaserPointer } from "@excalidraw/laser-pointer"; import { LaserPointer } from "@excalidraw/laser-pointer";
import { import {
SVG_NS, SVG_NS,
getSvgPathFromStroke, getSvgPathFromStroke,
@@ -7,8 +8,7 @@ import {
import type { LaserPointerOptions } from "@excalidraw/laser-pointer"; import type { LaserPointerOptions } from "@excalidraw/laser-pointer";
import { AnimationController } from "./renderer/animation"; import type { AnimationFrameHandler } from "./animation-frame-handler";
import type App from "./components/App"; import type App from "./components/App";
import type { AppState } from "./types"; import type { AppState } from "./types";
@@ -34,16 +34,15 @@ export class AnimatedTrail implements Trail {
private container?: SVGSVGElement; private container?: SVGSVGElement;
private trailElement: SVGPathElement; private trailElement: SVGPathElement;
private trailAnimation?: SVGAnimateElement; private trailAnimation?: SVGAnimateElement;
private key: string;
private static counter = 0;
constructor( constructor(
private animationFrameHandler: AnimationFrameHandler,
protected app: App, protected app: App,
private options: Partial<LaserPointerOptions> & private options: Partial<LaserPointerOptions> &
Partial<AnimatedTrailOptions>, Partial<AnimatedTrailOptions>,
) { ) {
this.key = `animated-trail-${AnimatedTrail.counter++}`; this.animationFrameHandler.register(this, this.onFrame.bind(this));
this.trailElement = document.createElementNS(SVG_NS, "path"); this.trailElement = document.createElementNS(SVG_NS, "path");
if (this.options.animateTrail) { if (this.options.animateTrail) {
this.trailAnimation = document.createElementNS(SVG_NS, "animate"); this.trailAnimation = document.createElementNS(SVG_NS, "animate");
@@ -74,15 +73,6 @@ export class AnimatedTrail implements Trail {
return false; return false;
} }
private cleanup() {
this.pastTrails = [];
this.currentTrail = undefined;
if (this.trailElement.parentNode === this.container) {
this.container?.removeChild(this.trailElement);
}
}
start(container?: SVGSVGElement) { start(container?: SVGSVGElement) {
if (container) { if (container) {
this.container = container; this.container = container;
@@ -92,23 +82,15 @@ export class AnimatedTrail implements Trail {
this.container.appendChild(this.trailElement); this.container.appendChild(this.trailElement);
} }
if (!AnimationController.running(this.key)) { this.animationFrameHandler.start(this);
AnimationController.start(this.key, () => {
const needsNext = this.onFrame();
if (needsNext) {
return { keep: true };
}
this.cleanup();
return null;
});
}
} }
stop() { stop() {
AnimationController.cancel(this.key); this.animationFrameHandler.stop(this);
this.cleanup();
if (this.trailElement.parentNode === this.container) {
this.container?.removeChild(this.trailElement);
}
} }
startPath(x: number, y: number) { startPath(x: number, y: number) {
@@ -163,25 +145,21 @@ export class AnimatedTrail implements Trail {
if (this.currentTrail) { if (this.currentTrail) {
const currentPath = this.drawTrail(this.currentTrail, this.app.state); const currentPath = this.drawTrail(this.currentTrail, this.app.state);
paths.push(currentPath); paths.push(currentPath);
} }
this.pastTrails = this.pastTrails.filter( this.pastTrails = this.pastTrails.filter((trail) => {
(t) => return trail.getStrokeOutline().length !== 0;
t.getStrokeOutline(t.options.size / this.app.state.zoom.value) });
.length !== 0,
);
if (paths.length === 0) { if (paths.length === 0) {
// Clean up the SVG path if there are no trails to render this.stop();
this.trailElement.setAttribute("d", "");
return false;
} }
const svgPaths = paths.join(" ").trim(); const svgPaths = paths.join(" ").trim();
this.trailElement.setAttribute("d", svgPaths);
this.trailElement.setAttribute("d", svgPaths);
if (this.trailAnimation) { if (this.trailAnimation) {
this.trailElement.setAttribute( this.trailElement.setAttribute(
"fill", "fill",
@@ -197,8 +175,6 @@ export class AnimatedTrail implements Trail {
(this.options.fill ?? (() => "black"))(this), (this.options.fill ?? (() => "black"))(this),
); );
} }
return true;
} }
private drawTrail(trail: LaserPointer, state: AppState): string { private drawTrail(trail: LaserPointer, state: AppState): string {
@@ -0,0 +1,79 @@
export type AnimationCallback = (timestamp: number) => void | boolean;
export type AnimationTarget = {
callback: AnimationCallback;
stopped: boolean;
};
export class AnimationFrameHandler {
private targets = new WeakMap<object, AnimationTarget>();
private rafIds = new WeakMap<object, number>();
register(key: object, callback: AnimationCallback) {
this.targets.set(key, { callback, stopped: true });
}
start(key: object) {
const target = this.targets.get(key);
if (!target) {
return;
}
if (this.rafIds.has(key)) {
return;
}
this.targets.set(key, { ...target, stopped: false });
this.scheduleFrame(key);
}
stop(key: object) {
const target = this.targets.get(key);
if (target && !target.stopped) {
this.targets.set(key, { ...target, stopped: true });
}
this.cancelFrame(key);
}
private constructFrame(key: object): FrameRequestCallback {
return (timestamp: number) => {
const target = this.targets.get(key);
if (!target) {
return;
}
const shouldAbort = this.onFrame(target, timestamp);
if (!target.stopped && !shouldAbort) {
this.scheduleFrame(key);
} else {
this.cancelFrame(key);
}
};
}
private scheduleFrame(key: object) {
const rafId = requestAnimationFrame(this.constructFrame(key));
this.rafIds.set(key, rafId);
}
private cancelFrame(key: object) {
if (this.rafIds.has(key)) {
const rafId = this.rafIds.get(key)!;
cancelAnimationFrame(rafId);
}
this.rafIds.delete(key);
}
private onFrame(target: AnimationTarget, timestamp: number): boolean {
const shouldAbort = target.callback(timestamp);
return shouldAbort ?? false;
}
}
+2
View File
@@ -99,6 +99,7 @@ export const getDefaultAppState = (): Omit<
open: false, open: false,
panels: STATS_PANELS.generalStats | STATS_PANELS.elementProperties, panels: STATS_PANELS.generalStats | STATS_PANELS.elementProperties,
}, },
startBoundElement: null,
suggestedBinding: null, suggestedBinding: null,
frameRendering: { enabled: true, clip: true, name: true, outline: true }, frameRendering: { enabled: true, clip: true, name: true, outline: true },
frameToHighlight: null, frameToHighlight: null,
@@ -230,6 +231,7 @@ const APP_STATE_STORAGE_CONF = (<
selectionElement: { browser: false, export: false, server: false }, selectionElement: { browser: false, export: false, server: false },
shouldCacheIgnoreZoom: { browser: true, export: false, server: false }, shouldCacheIgnoreZoom: { browser: true, export: false, server: false },
stats: { browser: true, export: false, server: false }, stats: { browser: true, export: false, server: false },
startBoundElement: { browser: false, export: false, server: false },
suggestedBinding: { browser: false, export: false, server: false }, suggestedBinding: { browser: false, export: false, server: false },
frameRendering: { browser: false, export: false, server: false }, frameRendering: { browser: false, export: false, server: false },
frameToHighlight: { browser: false, export: false, server: false }, frameToHighlight: { browser: false, export: false, server: false },
File diff suppressed because it is too large Load Diff
+2 -1
View File
@@ -650,7 +650,8 @@ const LayerUI = ({
}; };
const stripIrrelevantAppStateProps = (appState: AppState): UIAppState => { const stripIrrelevantAppStateProps = (appState: AppState): UIAppState => {
const { cursorButton, scrollX, scrollY, ...ret } = appState; const { startBoundElement, cursorButton, scrollX, scrollY, ...ret } =
appState;
return ret; return ret;
}; };
@@ -199,7 +199,6 @@ export default function LibraryMenuItems({
type: "everything", type: "everything",
elements: item.elements, elements: item.elements,
randomizeSeed: true, randomizeSeed: true,
preserveFrameChildrenOrder: true,
}).duplicatedElements, }).duplicatedElements,
}; };
}); });
+1 -1
View File
@@ -2,7 +2,7 @@ import { useEffect, useRef } from "react";
import "./SVGLayer.scss"; import "./SVGLayer.scss";
import type { Trail } from "../animatedTrail"; import type { Trail } from "../animated-trail";
type SVGLayerProps = { type SVGLayerProps = {
trails: Trail[]; trails: Trail[];
@@ -206,6 +206,7 @@ const handleDimensionChange: DragInputCallbackType<
scene.getElementsIncludingDeleted(), scene.getElementsIncludingDeleted(),
nextElementsInFrame, nextElementsInFrame,
latestElement, latestElement,
app,
); );
scene.replaceAllElements(updatedElements); scene.replaceAllElements(updatedElements);
@@ -301,6 +302,7 @@ const handleDragFinished: DragFinishedCallbackType = ({
app.scene.getElementsIncludingDeleted(), app.scene.getElementsIncludingDeleted(),
nextElementsInFrame, nextElementsInFrame,
latestElement, latestElement,
app,
); );
app.scene.replaceAllElements(updatedElements); app.scene.replaceAllElements(updatedElements);
@@ -261,6 +261,7 @@ const handleDimensionChange: DragInputCallbackType<
scene.getElementsIncludingDeleted(), scene.getElementsIncludingDeleted(),
nextElementsInFrame, nextElementsInFrame,
latestElement, latestElement,
app,
); );
scene.replaceAllElements(updatedElements); scene.replaceAllElements(updatedElements);
@@ -415,6 +416,7 @@ const handleDragFinished: DragFinishedCallbackType = ({
app.scene.getElementsIncludingDeleted(), app.scene.getElementsIncludingDeleted(),
nextElementsInFrame, nextElementsInFrame,
latestElement, latestElement,
app,
); );
app.scene.replaceAllElements(updatedElements); app.scene.replaceAllElements(updatedElements);
@@ -39,7 +39,7 @@ type InteractiveCanvasProps = {
visibleElements: readonly NonDeletedExcalidrawElement[]; visibleElements: readonly NonDeletedExcalidrawElement[];
selectedElements: readonly NonDeletedExcalidrawElement[]; selectedElements: readonly NonDeletedExcalidrawElement[];
allElementsMap: NonDeletedSceneElementsMap; allElementsMap: NonDeletedSceneElementsMap;
canvasNonce: string; sceneNonce: number | undefined;
selectionNonce: number | undefined; selectionNonce: number | undefined;
scale: number; scale: number;
appState: InteractiveCanvasAppState; appState: InteractiveCanvasAppState;
@@ -279,10 +279,10 @@ const areEqual = (
// This could be further optimised if needed, as we don't have to render interactive canvas on each scene mutation // This could be further optimised if needed, as we don't have to render interactive canvas on each scene mutation
if ( if (
prevProps.selectionNonce !== nextProps.selectionNonce || prevProps.selectionNonce !== nextProps.selectionNonce ||
prevProps.canvasNonce !== nextProps.canvasNonce || prevProps.sceneNonce !== nextProps.sceneNonce ||
prevProps.scale !== nextProps.scale || prevProps.scale !== nextProps.scale ||
// we need to memoize on elementsMap because they may have renewed // we need to memoize on elementsMap because they may have renewed
// even if canvasNonce didn't change (e.g. we filter elements out based // even if sceneNonce didn't change (e.g. we filter elements out based
// on appState) // on appState)
prevProps.elementsMap !== nextProps.elementsMap || prevProps.elementsMap !== nextProps.elementsMap ||
prevProps.visibleElements !== nextProps.visibleElements || prevProps.visibleElements !== nextProps.visibleElements ||
@@ -14,7 +14,6 @@ import type { RoughCanvas } from "roughjs/bin/canvas";
interface NewElementCanvasProps { interface NewElementCanvasProps {
appState: AppState; appState: AppState;
newElement: NonNullable<AppState["newElement"]>;
elementsMap: RenderableElementsMap; elementsMap: RenderableElementsMap;
allElementsMap: NonDeletedSceneElementsMap; allElementsMap: NonDeletedSceneElementsMap;
scale: number; scale: number;
@@ -32,7 +31,7 @@ const NewElementCanvas = (props: NewElementCanvasProps) => {
{ {
canvas: canvasRef.current, canvas: canvasRef.current,
scale: props.scale, scale: props.scale,
newElement: props.newElement, newElement: props.appState.newElement,
elementsMap: props.elementsMap, elementsMap: props.elementsMap,
allElementsMap: props.allElementsMap, allElementsMap: props.allElementsMap,
rc: props.rc, rc: props.rc,
@@ -23,7 +23,7 @@ type StaticCanvasProps = {
elementsMap: RenderableElementsMap; elementsMap: RenderableElementsMap;
allElementsMap: NonDeletedSceneElementsMap; allElementsMap: NonDeletedSceneElementsMap;
visibleElements: readonly NonDeletedExcalidrawElement[]; visibleElements: readonly NonDeletedExcalidrawElement[];
canvasNonce: string; sceneNonce: number | undefined;
selectionNonce: number | undefined; selectionNonce: number | undefined;
scale: number; scale: number;
appState: StaticCanvasAppState; appState: StaticCanvasAppState;
@@ -110,10 +110,10 @@ const areEqual = (
nextProps: StaticCanvasProps, nextProps: StaticCanvasProps,
) => { ) => {
if ( if (
prevProps.canvasNonce !== nextProps.canvasNonce || prevProps.sceneNonce !== nextProps.sceneNonce ||
prevProps.scale !== nextProps.scale || prevProps.scale !== nextProps.scale ||
// we need to memoize on elementsMap because they may have renewed // we need to memoize on elementsMap because they may have renewed
// even if canvasNonce didn't change (e.g. we filter elements out based // even if sceneNonce didn't change (e.g. we filter elements out based
// on appState) // on appState)
prevProps.elementsMap !== nextProps.elementsMap || prevProps.elementsMap !== nextProps.elementsMap ||
prevProps.visibleElements !== nextProps.visibleElements prevProps.visibleElements !== nextProps.visibleElements
+6 -3
View File
@@ -119,12 +119,15 @@ export const SHAPES = [
export const getToolbarTools = (app: AppClassProperties) => { export const getToolbarTools = (app: AppClassProperties) => {
return app.state.preferredSelectionTool.type === "lasso" return app.state.preferredSelectionTool.type === "lasso"
? ([ ? ([
SHAPES[0],
{ {
...SHAPES[1],
value: "lasso", value: "lasso",
icon: SelectionIcon,
key: KEYS.V,
numericKey: KEYS["1"],
fillable: true,
toolbar: true,
}, },
...SHAPES.slice(2), ...SHAPES.slice(1),
] as const) ] as const)
: SHAPES; : SHAPES;
}; };
-8
View File
@@ -814,14 +814,6 @@ body.excalidraw-cursor-resize * {
.excalidraw__embeddable__outer { .excalidraw__embeddable__outer {
width: 100%; width: 100%;
height: 100%; height: 100%;
}
.excalidraw__embeddable__content {
width: 100%;
height: 100%;
transform-origin: top left;
&,
& > * { & > * {
border-radius: var(--embeddable-radius); border-radius: var(--embeddable-radius);
} }
+4 -56
View File
@@ -311,48 +311,6 @@ export const dataURLToString = (dataURL: DataURL) => {
return base64ToString(dataURL.slice(dataURL.indexOf(",") + 1)); return base64ToString(dataURL.slice(dataURL.indexOf(",") + 1));
}; };
const getImageFileDimensions = async (file: File) => {
const browserURL = typeof window !== "undefined" ? window.URL : undefined;
let objectURL: string | null = null;
let imageSource: string;
try {
imageSource = browserURL?.createObjectURL
? (objectURL = browserURL.createObjectURL(file))
: await getDataURL(file);
} catch {
objectURL = null;
imageSource = await getDataURL(file);
}
return new Promise<{ width: number; height: number }>((resolve, reject) => {
const image = new Image();
const cleanup = () => {
image.onload = null;
image.onerror = null;
if (objectURL && browserURL?.revokeObjectURL) {
browserURL.revokeObjectURL(objectURL);
}
};
image.onload = () => {
cleanup();
resolve({
width: image.naturalWidth || image.width,
height: image.naturalHeight || image.height,
});
};
image.onerror = (error) => {
cleanup();
reject(error);
};
image.src = imageSource;
});
};
export const resizeImageFile = async ( export const resizeImageFile = async (
file: File, file: File,
opts: { opts: {
@@ -366,20 +324,6 @@ export const resizeImageFile = async (
return file; return file;
} }
if (!isSupportedImageFile(file)) {
throw new Error("Error: unsupported file type", { cause: "UNSUPPORTED" });
}
if (!opts.outputType || opts.outputType === file.type) {
const dimensions = await getImageFileDimensions(file);
if (
Math.max(dimensions.width, dimensions.height) <= opts.maxWidthOrHeight
) {
return file;
}
}
const [pica, imageBlobReduce] = await Promise.all([ const [pica, imageBlobReduce] = await Promise.all([
import("pica").then((res) => res.default), import("pica").then((res) => res.default),
// a wrapper for pica for better API // a wrapper for pica for better API
@@ -403,6 +347,10 @@ export const resizeImageFile = async (
}; };
} }
if (!isSupportedImageFile(file)) {
throw new Error("Error: unsupported file type", { cause: "UNSUPPORTED" });
}
return new File( return new File(
[await reduce.toBlob(file, { max: opts.maxWidthOrHeight, alpha: true })], [await reduce.toBlob(file, { max: opts.maxWidthOrHeight, alpha: true })],
file.name, file.name,
+21 -135
View File
@@ -1,4 +1,4 @@
import { isFiniteNumber, isValidPoint, pointFrom } from "@excalidraw/math"; import { isFiniteNumber, pointFrom } from "@excalidraw/math";
import { import {
type CombineBrandsIfNeeded, type CombineBrandsIfNeeded,
@@ -96,69 +96,6 @@ type RestoredAppState = Omit<
"offsetTop" | "offsetLeft" | "width" | "height" "offsetTop" | "offsetLeft" | "width" | "height"
>; >;
const MAX_ARROW_PX = 75_000;
const restoreLinearElementPoints = (
points: unknown,
width: unknown,
height: unknown,
): LocalPoint[] => {
const restoredPoints = Array.isArray(points)
? points.reduce<LocalPoint[]>((acc, point) => {
if (isValidPoint(point)) {
acc.push(pointFrom<LocalPoint>(point[0], point[1]));
}
return acc;
}, [])
: [];
return restoredPoints.length < 2
? [
pointFrom<LocalPoint>(0, 0),
pointFrom<LocalPoint>(
isFiniteNumber(width) ? width : 0,
isFiniteNumber(height) ? height : 0,
),
]
: restoredPoints;
};
const restoreFreedrawPoints = (
points: unknown,
pressures: unknown,
): {
points: LocalPoint[];
pressures: number[];
} => {
if (!Array.isArray(points)) {
return {
points: [],
pressures: [],
};
}
const pressureValues: readonly unknown[] = Array.isArray(pressures)
? pressures
: [];
const restoredPoints: LocalPoint[] = [];
const restoredPressures: number[] = [];
points.forEach((point, index) => {
if (isValidPoint(point)) {
restoredPoints.push(pointFrom<LocalPoint>(point[0], point[1]));
if (index in pressureValues) {
const pressure = pressureValues[index];
restoredPressures.push(isFiniteNumber(pressure) ? pressure : 0.5);
}
}
});
return {
points: restoredPoints,
pressures: restoredPressures,
};
};
export const AllowedExcalidrawActiveTools: Record< export const AllowedExcalidrawActiveTools: Record<
AppState["activeTool"]["type"], AppState["activeTool"]["type"],
boolean boolean
@@ -475,15 +412,10 @@ export const restoreElement = (
return element; return element;
case "freedraw": { case "freedraw": {
const { points, pressures } = restoreFreedrawPoints(
element.points,
element.pressures,
);
return restoreElementWithProperties(element, { return restoreElementWithProperties(element, {
points, points: element.points,
simulatePressure: element.simulatePressure, simulatePressure: element.simulatePressure,
pressures, pressures: element.pressures,
}); });
} }
case "image": case "image":
@@ -501,20 +433,14 @@ export const restoreElement = (
const endArrowhead = normalizeArrowhead(element.endArrowhead); const endArrowhead = normalizeArrowhead(element.endArrowhead);
let x = element.x; let x = element.x;
let y = element.y; let y = element.y;
let points = restoreLinearElementPoints( let points = // migrate old arrow model to new one
element.points, !Array.isArray(element.points) || element.points.length < 2
element.width, ? [pointFrom(0, 0), pointFrom(element.width, element.height)]
element.height, : element.points;
);
if (points[0][0] !== 0 || points[0][1] !== 0) { if (points[0][0] !== 0 || points[0][1] !== 0) {
({ points, x, y } = ({ points, x, y } =
LinearElementEditor.getNormalizeElementPointsAndCoords({ LinearElementEditor.getNormalizeElementPointsAndCoords(element));
...element,
points,
x: x ?? 0,
y: y ?? 0,
} as ExcalidrawLinearElement));
} }
return restoreElementWithProperties(element, { return restoreElementWithProperties(element, {
@@ -528,7 +454,7 @@ export const restoreElement = (
y, y,
...(isLineElement(element) ...(isLineElement(element)
? { ? {
polygon: isValidPolygon(points) polygon: isValidPolygon(element.points)
? element.polygon ?? false ? element.polygon ?? false
: false, : false,
} }
@@ -541,31 +467,24 @@ export const restoreElement = (
element.endArrowhead === undefined element.endArrowhead === undefined
? "arrow" ? "arrow"
: normalizeArrowhead(element.endArrowhead); : normalizeArrowhead(element.endArrowhead);
const x = element.x as number | undefined; const x: number | undefined = element.x;
const y = element.y as number | undefined; const y: number | undefined = element.y;
const points = restoreLinearElementPoints( const points: readonly LocalPoint[] | undefined = // migrate old arrow model to new one
element.points, !Array.isArray(element.points) || element.points.length < 2
element.width, ? [pointFrom(0, 0), pointFrom(element.width, element.height)]
element.height, : element.points;
);
const elementWithRestoredPoints = {
...element,
points,
x: x ?? 0,
y: y ?? 0,
} as ExcalidrawArrowElement;
const base = { const base = {
type: element.type, type: element.type,
startBinding: repairBinding( startBinding: repairBinding(
elementWithRestoredPoints, element as ExcalidrawArrowElement,
element.startBinding, element.startBinding,
targetElementsMap, targetElementsMap,
existingElementsMap, existingElementsMap,
"start", "start",
), ),
endBinding: repairBinding( endBinding: repairBinding(
elementWithRestoredPoints, element as ExcalidrawArrowElement,
element.endBinding, element.endBinding,
targetElementsMap, targetElementsMap,
existingElementsMap, existingElementsMap,
@@ -574,8 +493,8 @@ export const restoreElement = (
startArrowhead, startArrowhead,
endArrowhead, endArrowhead,
points, points,
x: x ?? 0, x,
y: y ?? 0, y,
elbowed: (element as ExcalidrawArrowElement).elbowed, elbowed: (element as ExcalidrawArrowElement).elbowed,
...getSizeFromPoints(points), ...getSizeFromPoints(points),
}; };
@@ -594,44 +513,12 @@ export const restoreElement = (
}) })
: restoreElementWithProperties(element as ExcalidrawArrowElement, base); : restoreElementWithProperties(element as ExcalidrawArrowElement, base);
const normalizedRestoredElement = { return {
...restoredElement, ...restoredElement,
...LinearElementEditor.getNormalizeElementPointsAndCoords( ...LinearElementEditor.getNormalizeElementPointsAndCoords(
restoredElement, restoredElement,
), ),
}; };
// Last resort fix for extremely large arrows
if (
normalizedRestoredElement.width > MAX_ARROW_PX ||
normalizedRestoredElement.height > MAX_ARROW_PX
) {
console.error(
`Removing extremely large arrow ${
normalizedRestoredElement.id
} (type: ${
isElbowArrow(normalizedRestoredElement) ? "elbow" : "simple"
}, width: ${normalizedRestoredElement.width}, height: ${
normalizedRestoredElement.height
}, x: ${normalizedRestoredElement.x}, y: ${
normalizedRestoredElement.y
})`,
);
return {
...normalizedRestoredElement,
x: 0,
y: 0,
width: 100,
height: 100,
points: [
pointFrom<LocalPoint>(0, 0),
pointFrom<LocalPoint>(100, 100),
],
isDeleted: true,
};
}
return normalizedRestoredElement;
} }
// generic elements // generic elements
@@ -779,7 +666,6 @@ export const restoreElements = <T extends ExcalidrawElement>(
const existingElementsMap = existingElements const existingElementsMap = existingElements
? arrayToMap(existingElements) ? arrayToMap(existingElements)
: null; : null;
const restoredElements = syncInvalidIndices( const restoredElements = syncInvalidIndices(
(targetElements || []).reduce((elements, element) => { (targetElements || []).reduce((elements, element) => {
// filtering out selection, which is legacy, no longer kept in elements, // filtering out selection, which is legacy, no longer kept in elements,
@@ -876,7 +762,7 @@ export const restoreElements = <T extends ExcalidrawElement>(
} }
} }
// NOTE (mtolmacs): Temporary fix for invalid/self-bound elbow arrows // NOTE (mtolmacs): Temporary fix for extremely large arrows
// Need to iterate again so we have attached text nodes in elementsMap // Need to iterate again so we have attached text nodes in elementsMap
return restoredElements.map((element) => { return restoredElements.map((element) => {
if ( if (
+5 -3
View File
@@ -33,7 +33,9 @@ import type { Bounds } from "@excalidraw/common";
import type { GlobalPoint, LineSegment } from "@excalidraw/math/types"; import type { GlobalPoint, LineSegment } from "@excalidraw/math/types";
import type { ElementsMap, ExcalidrawElement } from "@excalidraw/element/types"; import type { ElementsMap, ExcalidrawElement } from "@excalidraw/element/types";
import { AnimatedTrail } from "../animatedTrail"; import { AnimatedTrail } from "../animated-trail";
import type { AnimationFrameHandler } from "../animation-frame-handler";
import type App from "../components/App"; import type App from "../components/App";
@@ -41,8 +43,8 @@ export class EraserTrail extends AnimatedTrail {
private elementsToErase: Set<ExcalidrawElement["id"]> = new Set(); private elementsToErase: Set<ExcalidrawElement["id"]> = new Set();
private groupsToErase: Set<ExcalidrawElement["id"]> = new Set(); private groupsToErase: Set<ExcalidrawElement["id"]> = new Set();
constructor(app: App) { constructor(animationFrameHandler: AnimationFrameHandler, app: App) {
super(app, { super(animationFrameHandler, app, {
streamline: 0.2, streamline: 0.2,
size: 5, size: 5,
keepHead: true, keepHead: true,
+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 { export class History {
public readonly onHistoryChangedEmitter = new Emitter< public readonly onHistoryChangedEmitter = new Emitter<
[HistoryChangedEvent] [HistoryChangedEvent]
>(); >();
private readonly onBeforeRecordEmitter = new Emitter<[StoreDelta]>();
private effectiveDeltaResolver: HistoryEffectiveDeltaResolver | null = null;
public readonly undoStack: HistoryDelta[] = []; public readonly undoStack: HistoryDelta[] = [];
public readonly redoStack: HistoryDelta[] = []; public readonly redoStack: HistoryDelta[] = [];
@@ -105,6 +117,20 @@ export class History {
constructor(private readonly store: Store) {} 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() { public clear() {
this.undoStack.length = 0; this.undoStack.length = 0;
this.redoStack.length = 0; this.redoStack.length = 0;
@@ -119,6 +145,8 @@ export class History {
return; return;
} }
this.onBeforeRecordEmitter.trigger(delta);
// construct history entry, so once it's emitted, it's not recorded again // construct history entry, so once it's emitted, it's not recorded again
const historyDelta = HistoryDelta.inverse(delta); const historyDelta = HistoryDelta.inverse(delta);
@@ -131,15 +159,14 @@ export class History {
this.redoStack.length = 0; this.redoStack.length = 0;
} }
this.onHistoryChangedEmitter.trigger( this.emitHistoryChanged();
new HistoryChangedEvent(this.isUndoStackEmpty, this.isRedoStackEmpty),
);
} }
public undo(elements: SceneElementsMap, appState: AppState) { public undo(elements: SceneElementsMap, appState: AppState) {
return this.perform( return this.perform(
elements, elements,
appState, appState,
"undo",
() => History.pop(this.undoStack), () => History.pop(this.undoStack),
(entry: HistoryDelta) => History.push(this.redoStack, entry), (entry: HistoryDelta) => History.push(this.redoStack, entry),
); );
@@ -149,6 +176,7 @@ export class History {
return this.perform( return this.perform(
elements, elements,
appState, appState,
"redo",
() => History.pop(this.redoStack), () => History.pop(this.redoStack),
(entry: HistoryDelta) => History.push(this.undoStack, entry), (entry: HistoryDelta) => History.push(this.undoStack, entry),
); );
@@ -157,6 +185,7 @@ export class History {
private perform( private perform(
elements: SceneElementsMap, elements: SceneElementsMap,
appState: AppState, appState: AppState,
direction: HistoryPerformDirection,
pop: () => HistoryDelta | null, pop: () => HistoryDelta | null,
push: (entry: HistoryDelta) => void, push: (entry: HistoryDelta) => void,
): [SceneElementsMap, AppState] | void { ): [SceneElementsMap, AppState] | void {
@@ -177,9 +206,15 @@ export class History {
// iterate through the history entries in case they result in no visible changes // iterate through the history entries in case they result in no visible changes
while (historyDelta) { while (historyDelta) {
// Roundtrip invariant: whichever delta we execute must be the one that
// continues through applyLatestChanges -> inverse -> opposite stack.
let entryToPush = historyDelta;
try { try {
const effectiveDelta = this.resolveEffectiveDelta(historyDelta, {
direction,
});
[nextElements, nextAppState, containsVisibleChange] = [nextElements, nextAppState, containsVisibleChange] =
historyDelta.applyTo(nextElements, nextAppState, prevSnapshot); effectiveDelta.applyTo(nextElements, nextAppState, prevSnapshot);
const prevElements = prevSnapshot.elements; const prevElements = prevSnapshot.elements;
const nextSnapshot = prevSnapshot.maybeClone( const nextSnapshot = prevSnapshot.maybeClone(
@@ -190,7 +225,7 @@ export class History {
const change = StoreChange.create(prevSnapshot, nextSnapshot); const change = StoreChange.create(prevSnapshot, nextSnapshot);
const delta = HistoryDelta.applyLatestChanges( const delta = HistoryDelta.applyLatestChanges(
historyDelta, effectiveDelta,
prevElements, prevElements,
nextElements, nextElements,
); );
@@ -203,12 +238,12 @@ export class History {
delta, delta,
}); });
historyDelta = delta; entryToPush = delta;
} }
prevSnapshot = nextSnapshot; prevSnapshot = nextSnapshot;
} finally { } finally {
push(historyDelta); push(entryToPush);
} }
if (containsVisibleChange) { if (containsVisibleChange) {
@@ -222,12 +257,16 @@ export class History {
} finally { } finally {
// trigger the history change event before returning completely // trigger the history change event before returning completely
// also trigger it just once, no need doing so on each entry // also trigger it just once, no need doing so on each entry
this.onHistoryChangedEmitter.trigger( this.emitHistoryChanged();
new HistoryChangedEvent(this.isUndoStackEmpty, this.isRedoStackEmpty),
);
} }
} }
private emitHistoryChanged() {
this.onHistoryChangedEmitter.trigger(
new HistoryChangedEvent(this.isUndoStackEmpty, this.isRedoStackEmpty),
);
}
private static pop(stack: HistoryDelta[]): HistoryDelta | null { private static pop(stack: HistoryDelta[]): HistoryDelta | null {
if (!stack.length) { if (!stack.length) {
return null; return null;
@@ -246,4 +285,15 @@ export class History {
const inversedEntry = HistoryDelta.inverse(entry); const inversedEntry = HistoryDelta.inverse(entry);
return stack.push(inversedEntry); return stack.push(inversedEntry);
} }
private resolveEffectiveDelta(
delta: HistoryDelta,
context: HistoryEffectiveDeltaResolverContext,
): HistoryDelta {
if (!this.effectiveDeltaResolver) {
return delta;
}
return this.effectiveDeltaResolver(delta, context);
}
} }
+2 -27
View File
@@ -6,11 +6,7 @@ import React, {
useState, useState,
} from "react"; } from "react";
import { import { DEFAULT_UI_OPTIONS, isShallowEqual } from "@excalidraw/common";
DEFAULT_IMAGE_OPTIONS,
DEFAULT_UI_OPTIONS,
isShallowEqual,
} from "@excalidraw/common";
import App, { import App, {
ExcalidrawAPIContext, ExcalidrawAPIContext,
@@ -102,7 +98,6 @@ const ExcalidrawBase = (props: ExcalidrawProps) => {
aiEnabled, aiEnabled,
showDeprecatedFonts, showDeprecatedFonts,
renderScrollbars, renderScrollbars,
imageOptions,
} = props; } = props;
const canvasActions = props.UIOptions?.canvasActions; const canvasActions = props.UIOptions?.canvasActions;
@@ -133,13 +128,6 @@ const ExcalidrawBase = (props: ExcalidrawProps) => {
UIOptions.canvasActions.toggleTheme = true; UIOptions.canvasActions.toggleTheme = true;
} }
const normalizedImageOptions: AppProps["imageOptions"] = {
maxFileSizeBytes:
imageOptions?.maxFileSizeBytes ?? DEFAULT_IMAGE_OPTIONS.maxFileSizeBytes,
maxWidthOrHeight:
imageOptions?.maxWidthOrHeight ?? DEFAULT_IMAGE_OPTIONS.maxWidthOrHeight,
};
const setExcalidrawAPI = useContext(ExcalidrawAPISetContext); const setExcalidrawAPI = useContext(ExcalidrawAPISetContext);
const onExcalidrawAPIRef = useRef(onExcalidrawAPI); const onExcalidrawAPIRef = useRef(onExcalidrawAPI);
@@ -220,7 +208,6 @@ const ExcalidrawBase = (props: ExcalidrawProps) => {
aiEnabled={aiEnabled !== false} aiEnabled={aiEnabled !== false}
showDeprecatedFonts={showDeprecatedFonts} showDeprecatedFonts={showDeprecatedFonts}
renderScrollbars={renderScrollbars} renderScrollbars={renderScrollbars}
imageOptions={normalizedImageOptions}
> >
{children} {children}
</App> </App>
@@ -238,13 +225,11 @@ const areEqual = (prevProps: ExcalidrawProps, nextProps: ExcalidrawProps) => {
const { const {
initialData: prevInitialData, initialData: prevInitialData,
UIOptions: prevUIOptions = {}, UIOptions: prevUIOptions = {},
imageOptions: prevImageOptions,
...prev ...prev
} = prevProps; } = prevProps;
const { const {
initialData: nextInitialData, initialData: nextInitialData,
UIOptions: nextUIOptions = {}, UIOptions: nextUIOptions = {},
imageOptions: nextImageOptions,
...next ...next
} = nextProps; } = nextProps;
@@ -288,17 +273,7 @@ const areEqual = (prevProps: ExcalidrawProps, nextProps: ExcalidrawProps) => {
return prevUIOptions[key] === nextUIOptions[key]; return prevUIOptions[key] === nextUIOptions[key];
}); });
const isImageOptionsSame = return isUIOptionsSame && isShallowEqual(prev, next);
(prevImageOptions?.maxWidthOrHeight ??
DEFAULT_IMAGE_OPTIONS.maxWidthOrHeight) ===
(nextImageOptions?.maxWidthOrHeight ??
DEFAULT_IMAGE_OPTIONS.maxWidthOrHeight) &&
(prevImageOptions?.maxFileSizeBytes ??
DEFAULT_IMAGE_OPTIONS.maxFileSizeBytes) ===
(nextImageOptions?.maxFileSizeBytes ??
DEFAULT_IMAGE_OPTIONS.maxFileSizeBytes);
return isUIOptionsSame && isImageOptionsSame && isShallowEqual(prev, next);
}; };
export const Excalidraw = React.memo(ExcalidrawBase, areEqual); export const Excalidraw = React.memo(ExcalidrawBase, areEqual);
@@ -2,20 +2,27 @@ import { DEFAULT_LASER_COLOR, easeOut } from "@excalidraw/common";
import type { LaserPointerOptions } from "@excalidraw/laser-pointer"; import type { LaserPointerOptions } from "@excalidraw/laser-pointer";
import { AnimatedTrail } from "./animatedTrail"; import { AnimatedTrail } from "./animated-trail";
import { getClientColor } from "./clients"; import { getClientColor } from "./clients";
import type { Trail } from "./animatedTrail"; import type { Trail } from "./animated-trail";
import type { AnimationFrameHandler } from "./animation-frame-handler";
import type App from "./components/App"; import type App from "./components/App";
import type { SocketId } from "./types"; import type { SocketId } from "./types";
export class LaserTrails implements Trail { export class LaserTrails implements Trail {
public localTrail: AnimatedTrail; public localTrail: AnimatedTrail;
private collabTrails = new Map<SocketId, AnimatedTrail>(); private collabTrails = new Map<SocketId, AnimatedTrail>();
private container?: SVGSVGElement; private container?: SVGSVGElement;
constructor(private app: App) { constructor(
this.localTrail = new AnimatedTrail(app, { private animationFrameHandler: AnimationFrameHandler,
private app: App,
) {
this.animationFrameHandler.register(this, this.onFrame.bind(this));
this.localTrail = new AnimatedTrail(animationFrameHandler, app, {
...this.getTrailOptions(), ...this.getTrailOptions(),
fill: () => DEFAULT_LASER_COLOR, fill: () => DEFAULT_LASER_COLOR,
}); });
@@ -56,45 +63,30 @@ export class LaserTrails implements Trail {
start(container: SVGSVGElement) { start(container: SVGSVGElement) {
this.container = container; this.container = container;
this.animationFrameHandler.start(this);
this.localTrail.start(container); this.localTrail.start(container);
} }
stop() { stop() {
this.animationFrameHandler.stop(this);
this.localTrail.stop(); this.localTrail.stop();
this.stopCollabTrails();
this.container = undefined;
} }
private stopCollabTrails(collaborators?: App["state"]["collaborators"]) { onFrame() {
for (const [key, trail] of this.collabTrails) { this.updateCollabTrails();
const collaborator = collaborators?.get(key);
if (!collaborator) {
trail.stop();
this.collabTrails.delete(key);
}
}
} }
updateCollabTrails(collaborators: App["state"]["collaborators"]) { private updateCollabTrails() {
this.stopCollabTrails(collaborators); if (!this.container || this.app.state.collaborators.size === 0) {
if (!this.container || collaborators.size === 0) {
return; return;
} }
for (const [key, collaborator] of collaborators.entries()) { for (const [key, collaborator] of this.app.state.collaborators.entries()) {
// Current user has their own trail drawn via localTrail let trail!: AnimatedTrail;
if (collaborator.isCurrentUser) {
continue;
}
// IDEA: Use the collaborator pointer coordinates to trace out the if (!this.collabTrails.has(key)) {
// laser pointer trail when 1) the selected collab tool is the laser trail = new AnimatedTrail(this.animationFrameHandler, this.app, {
// pointer and 2) the collab pointer button is in the "down" state.
let trail = this.collabTrails.get(key);
if (!trail) {
trail = new AnimatedTrail(this.app, {
...this.getTrailOptions(), ...this.getTrailOptions(),
fill: () => fill: () =>
collaborator.pointer?.laserColor || collaborator.pointer?.laserColor ||
@@ -103,33 +95,36 @@ export class LaserTrails implements Trail {
trail.start(this.container); trail.start(this.container);
this.collabTrails.set(key, trail); this.collabTrails.set(key, trail);
} else {
trail = this.collabTrails.get(key)!;
} }
if (collaborator.pointer && collaborator.pointer.tool === "laser") { if (collaborator.pointer && collaborator.pointer.tool === "laser") {
const buttonDown = collaborator.button === "down"; if (collaborator.button === "down" && !trail.hasCurrentTrail) {
const buttonUp = collaborator.button === "up";
const hasTrail = trail.hasCurrentTrail;
// Initialize a new trail
if (buttonDown && !hasTrail) {
trail.startPath(collaborator.pointer.x, collaborator.pointer.y); trail.startPath(collaborator.pointer.x, collaborator.pointer.y);
} }
// Add only original points if (
const lastPointOriginal = !trail.hasLastPoint( collaborator.button === "down" &&
collaborator.pointer.x, trail.hasCurrentTrail &&
collaborator.pointer.y, !trail.hasLastPoint(collaborator.pointer.x, collaborator.pointer.y)
); ) {
if (buttonDown && lastPointOriginal) {
trail.addPointToPath(collaborator.pointer.x, collaborator.pointer.y); trail.addPointToPath(collaborator.pointer.x, collaborator.pointer.y);
} }
// End the trail on button up if (collaborator.button === "up" && trail.hasCurrentTrail) {
if (buttonUp && hasTrail) {
trail.addPointToPath(collaborator.pointer.x, collaborator.pointer.y); trail.addPointToPath(collaborator.pointer.x, collaborator.pointer.y);
trail.endPath(); trail.endPath();
} }
} }
} }
for (const key of this.collabTrails.keys()) {
if (!this.app.state.collaborators.has(key)) {
const trail = this.collabTrails.get(key)!;
trail.stop();
this.collabTrails.delete(key);
}
}
} }
} }
+5 -3
View File
@@ -25,7 +25,9 @@ import type {
NonDeleted, NonDeleted,
} from "@excalidraw/element/types"; } from "@excalidraw/element/types";
import { AnimatedTrail } from "../animatedTrail"; import { type AnimationFrameHandler } from "../animation-frame-handler";
import { AnimatedTrail } from "../animated-trail";
import { getLassoSelectedElementIds } from "./utils"; import { getLassoSelectedElementIds } from "./utils";
@@ -45,8 +47,8 @@ export class LassoTrail extends AnimatedTrail {
private canvasTranslate: CanvasTranslate | null = null; private canvasTranslate: CanvasTranslate | null = null;
private keepPreviousSelection: boolean = false; private keepPreviousSelection: boolean = false;
constructor(app: App) { constructor(animationFrameHandler: AnimationFrameHandler, app: App) {
super(app, { super(animationFrameHandler, app, {
animateTrail: true, animateTrail: true,
streamline: 0.4, streamline: 0.4,
sizeMapping: (c) => { sizeMapping: (c) => {
+2 -1
View File
@@ -83,18 +83,19 @@
"@codemirror/language": "^6.0.0", "@codemirror/language": "^6.0.0",
"@codemirror/state": "^6.0.0", "@codemirror/state": "^6.0.0",
"@codemirror/view": "^6.0.0", "@codemirror/view": "^6.0.0",
"@lezer/highlight": "^1.0.0",
"@excalidraw/common": "0.18.0", "@excalidraw/common": "0.18.0",
"@excalidraw/element": "0.18.0", "@excalidraw/element": "0.18.0",
"@excalidraw/laser-pointer": "1.3.1", "@excalidraw/laser-pointer": "1.3.1",
"@excalidraw/math": "0.18.0", "@excalidraw/math": "0.18.0",
"@excalidraw/mermaid-to-excalidraw": "2.2.2", "@excalidraw/mermaid-to-excalidraw": "2.2.2",
"@excalidraw/random-username": "1.1.0", "@excalidraw/random-username": "1.1.0",
"@lezer/highlight": "^1.0.0",
"browser-fs-access": "0.38.0", "browser-fs-access": "0.38.0",
"canvas-roundrect-polyfill": "0.0.1", "canvas-roundrect-polyfill": "0.0.1",
"clsx": "1.1.1", "clsx": "1.1.1",
"cross-env": "7.0.3", "cross-env": "7.0.3",
"es6-promise-pool": "2.5.0", "es6-promise-pool": "2.5.0",
"fractional-indexing": "3.2.0",
"fuzzy": "0.1.3", "fuzzy": "0.1.3",
"image-blob-reduce": "3.0.1", "image-blob-reduce": "3.0.1",
"jotai": "2.11.0", "jotai": "2.11.0",
-88
View File
@@ -23,94 +23,6 @@ const polyfill = () => {
}); });
} }
if (!Array.prototype.findLast) {
Object.defineProperty(Array.prototype, "findLast", {
value: function <T>(
this: T[],
predicate: (value: T, index: number, array: T[]) => unknown,
thisArg?: unknown,
) {
return this
.slice()
.reverse()
.find((value, index) =>
predicate.call(thisArg, value, this.length - index - 1, this),
);
},
writable: true,
enumerable: false,
configurable: true,
});
}
if (!Array.prototype.findIndex) {
Object.defineProperty(Array.prototype, "findIndex", {
value: function <T>(
this: T[],
predicate: (value: T, index: number, array: T[]) => unknown,
thisArg?: unknown,
) {
for (let index = 0; index < this.length; index++) {
if (predicate.call(thisArg, this[index], index, this)) {
return index;
}
}
return -1;
},
writable: true,
enumerable: false,
configurable: true,
});
}
if (!Array.prototype.findLastIndex) {
Object.defineProperty(Array.prototype, "findLastIndex", {
value: function <T>(
this: T[],
predicate: (value: T, index: number, array: T[]) => unknown,
thisArg?: unknown,
) {
const index = this
.slice()
.reverse()
.findIndex((value, index) =>
predicate.call(thisArg, value, this.length - index - 1, this),
);
return index === -1 ? -1 : this.length - index - 1;
},
writable: true,
enumerable: false,
configurable: true,
});
}
if (!Array.prototype.toReversed) {
Object.defineProperty(Array.prototype, "toReversed", {
value: function <T>(this: T[]) {
return this.slice().reverse();
},
writable: true,
enumerable: false,
configurable: true,
});
}
if (!Array.prototype.toSorted) {
Object.defineProperty(Array.prototype, "toSorted", {
value: function <T>(
this: T[],
compareFn?: (a: T, b: T) => number,
) {
return this.slice().sort(compareFn);
},
writable: true,
enumerable: false,
configurable: true,
});
}
if (!Element.prototype.replaceChildren) { if (!Element.prototype.replaceChildren) {
Element.prototype.replaceChildren = function (...nodes) { Element.prototype.replaceChildren = function (...nodes) {
this.innerHTML = ""; this.innerHTML = "";
+16 -58
View File
@@ -6,10 +6,7 @@ export type Animation<R extends object> = (params: {
}) => R | null | undefined; }) => R | null | undefined;
export class AnimationController { export class AnimationController {
private static scheduledFrame: private static isRunning = false;
| { id: ReturnType<typeof requestAnimationFrame>; type: "raf" }
| { id: ReturnType<typeof setTimeout>; type: "timeout" }
| null = null;
private static animations = new Map< private static animations = new Map<
string, string,
{ {
@@ -20,10 +17,6 @@ export class AnimationController {
>(); >();
static start<R extends object>(key: string, animation: Animation<R>) { static start<R extends object>(key: string, animation: Animation<R>) {
if (AnimationController.animations.has(key)) {
return;
}
const initialState = animation({ const initialState = animation({
deltaTime: 0, deltaTime: 0,
state: undefined, state: undefined,
@@ -36,54 +29,19 @@ export class AnimationController {
state: initialState, state: initialState,
}); });
AnimationController.scheduleNextFrame(); if (!AnimationController.isRunning) {
AnimationController.isRunning = true;
if (isRenderThrottlingEnabled()) {
requestAnimationFrame(AnimationController.tick);
} else {
setTimeout(AnimationController.tick, 0);
}
}
} }
} }
private static scheduleNextFrame() {
if (AnimationController.scheduledFrame) {
return;
}
if (isRenderThrottlingEnabled()) {
AnimationController.scheduledFrame = {
id: requestAnimationFrame(AnimationController.tick),
type: "raf",
};
} else {
AnimationController.scheduledFrame = {
id: setTimeout(AnimationController.tick, 0),
type: "timeout",
};
}
}
private static cancelScheduledFrame() {
if (!AnimationController.scheduledFrame) {
return;
}
if (AnimationController.scheduledFrame.type === "raf") {
cancelAnimationFrame(AnimationController.scheduledFrame.id);
} else {
clearTimeout(AnimationController.scheduledFrame.id);
}
AnimationController.scheduledFrame = null;
}
private static cancelScheduledFrameIfIdle() {
if (AnimationController.animations.size > 0) {
return false;
}
AnimationController.cancelScheduledFrame();
return true;
}
private static tick() { private static tick() {
AnimationController.scheduledFrame = null;
if (AnimationController.animations.size > 0) { if (AnimationController.animations.size > 0) {
for (const [key, animation] of AnimationController.animations) { for (const [key, animation] of AnimationController.animations) {
const now = performance.now(); const now = performance.now();
@@ -98,7 +56,8 @@ export class AnimationController {
if (!state) { if (!state) {
AnimationController.animations.delete(key); AnimationController.animations.delete(key);
if (AnimationController.cancelScheduledFrameIfIdle()) { if (AnimationController.animations.size === 0) {
AnimationController.isRunning = false;
return; return;
} }
} else { } else {
@@ -107,11 +66,11 @@ export class AnimationController {
} }
} }
if (AnimationController.cancelScheduledFrameIfIdle()) { if (isRenderThrottlingEnabled()) {
return; requestAnimationFrame(AnimationController.tick);
} else {
setTimeout(AnimationController.tick, 0);
} }
AnimationController.scheduleNextFrame();
} }
} }
@@ -121,6 +80,5 @@ export class AnimationController {
static cancel(key: string) { static cancel(key: string) {
AnimationController.animations.delete(key); AnimationController.animations.delete(key);
AnimationController.cancelScheduledFrameIfIdle();
} }
} }
+135 -29
View File
@@ -7,6 +7,7 @@ import {
type Radians, type Radians,
bezierEquation, bezierEquation,
pointRotateRads, pointRotateRads,
pointDistance,
} from "@excalidraw/math"; } from "@excalidraw/math";
import { import {
@@ -23,12 +24,13 @@ import {
deconstructDiamondElement, deconstructDiamondElement,
deconstructRectanguloidElement, deconstructRectanguloidElement,
elementCenterPoint, elementCenterPoint,
getAllMidpoints, getDiamondBaseCorners,
FOCUS_POINT_SIZE, FOCUS_POINT_SIZE,
getOmitSidesForEditorInterface, getOmitSidesForEditorInterface,
getTransformHandles, getTransformHandles,
getTransformHandlesFromCoords, getTransformHandlesFromCoords,
hasBoundingBox, hasBoundingBox,
hitElementItself,
isArrowElement, isArrowElement,
isBindableElement, isBindableElement,
isElbowArrow, isElbowArrow,
@@ -36,6 +38,7 @@ import {
isImageElement, isImageElement,
isLinearElement, isLinearElement,
isLineElement, isLineElement,
maxBindingDistance_simple,
isTextElement, isTextElement,
LinearElementEditor, LinearElementEditor,
getActiveTextElement, getActiveTextElement,
@@ -410,42 +413,145 @@ const renderBindingHighlightForBindableElement_simple = (
break; break;
} }
// Draw midpoint indicators
if ( if (
appState.isMidpointSnappingEnabled && appState.isMidpointSnappingEnabled &&
(isFrameLikeElement(suggestedBinding.element) || (isFrameLikeElement(suggestedBinding.element) ||
isBindableElement(suggestedBinding.element)) isBindableElement(suggestedBinding.element))
) { ) {
context.save(); // Draw midpoint indicators
const linearElement = appState.selectedLinearElement;
const arrow =
linearElement?.elementId &&
LinearElementEditor.getElement(linearElement?.elementId, elementsMap);
const cursorIsInsideBindable =
pointerCoords &&
hitElementItself({
point: pointerCoords,
element: suggestedBinding.element,
elementsMap,
threshold: 0,
overrideShouldTestInside: true,
});
const midpointRadius = 4 / appState.zoom.value; const isElbow =
(arrow && isElbowArrow(arrow)) ||
(appState.activeTool.type === "arrow" &&
appState.currentItemArrowType === "elbow");
// Render base midpoints if (!cursorIsInsideBindable || isElbow) {
const midpoints = getAllMidpoints(suggestedBinding.element, elementsMap); context.save();
for (const midpoint of midpoints) {
context.fillStyle = const center = elementCenterPoint(suggestedBinding.element, elementsMap);
appState.theme === THEME.DARK
? `rgba(0, 0, 0, 0.8)` let midpoints: GlobalPoint[];
: `rgba(65, 65, 65, 0.5)`; if (suggestedBinding.element.type === "diamond") {
context.beginPath(); const center = elementCenterPoint(
context.arc(midpoint[0], midpoint[1], midpointRadius, 0, 2 * Math.PI); suggestedBinding.element,
context.fill(); elementsMap,
);
midpoints = getDiamondBaseCorners(suggestedBinding.element).map(
(curve) => {
const point = bezierEquation(curve, 0.5);
const rotatedPoint = pointRotateRads(
point,
center,
suggestedBinding.element.angle,
);
return pointFrom<GlobalPoint>(rotatedPoint[0], rotatedPoint[1]);
},
);
} else {
const basePoints = [
{
x: suggestedBinding.element.width,
y: suggestedBinding.element.height / 2,
}, // RIGHT
{
x: suggestedBinding.element.width / 2,
y: suggestedBinding.element.height,
}, // BOTTOM
{ x: 0, y: suggestedBinding.element.height / 2 }, // LEFT
{ x: suggestedBinding.element.width / 2, y: 0 }, // TOP
];
midpoints = basePoints.map((point) => {
const globalPoint = pointFrom<GlobalPoint>(
point.x + suggestedBinding.element.x,
point.y + suggestedBinding.element.y,
);
const rotatedPoint = pointRotateRads(
globalPoint,
center,
suggestedBinding.element.angle,
);
return pointFrom<GlobalPoint>(rotatedPoint[0], rotatedPoint[1]);
});
}
const hoveredMidpoint =
pointerCoords &&
midpoints.reduce(
(
closestIdx: {
idx: number;
distance: number;
},
point,
idx,
) => {
const distance = pointDistance(point, pointerCoords);
if (idx === -1 || distance < closestIdx.distance) {
return { idx, distance };
}
return closestIdx;
},
{
idx: -1,
distance: Infinity,
},
);
const midpointRadius = 4 / appState.zoom.value;
const highlightThreshold =
maxBindingDistance_simple(appState.zoom) +
suggestedBinding.element.strokeWidth / 2;
midpoints.forEach((midpoint, idx) => {
const isHighlighted =
(!cursorIsInsideBindable || isElbow) &&
hoveredMidpoint?.idx === idx &&
hoveredMidpoint.distance <= highlightThreshold;
// also render midpoint if cursor close but not highlighted
// (for elbows, always show all points)
const isShown =
!isHighlighted &&
(isElbow ||
(idx === hoveredMidpoint?.idx &&
hoveredMidpoint.distance <= highlightThreshold * 2));
if (isHighlighted) {
context.fillStyle =
appState.theme === THEME.DARK
? `rgba(3, 93, 161, 1)`
: `rgba(106, 189, 252, 1)`;
context.beginPath();
context.arc(midpoint[0], midpoint[1], midpointRadius, 0, 2 * Math.PI);
context.fill();
} else if (isShown) {
context.fillStyle =
appState.theme === THEME.DARK
? `rgba(0, 0, 0, 0.8)`
: `rgba(65, 65, 65, 0.5)`;
context.beginPath();
context.arc(midpoint[0], midpoint[1], midpointRadius, 0, 2 * Math.PI);
context.fill();
}
});
context.restore();
} }
// Render the highlighted midpoint if any
const midpoint = appState.suggestedBinding?.midPoint;
if (midpoint) {
context.fillStyle =
appState.theme === THEME.DARK
? `rgba(3, 93, 161, 1)`
: `rgba(106, 189, 252, 1)`;
context.beginPath();
context.arc(midpoint[0], midpoint[1], midpointRadius, 0, 2 * Math.PI);
context.fill();
}
context.restore();
} }
}; };
+103 -210
View File
@@ -1,15 +1,9 @@
import { import { isElementInViewport } from "@excalidraw/element";
getCommonFrameId,
getFrameChildrenInsertionIndex,
isElementInViewport,
} from "@excalidraw/element";
import { arrayToMap, memoize, toBrandedType } from "@excalidraw/common"; import { memoize, toBrandedType } from "@excalidraw/common";
import type { import type {
ExcalidrawElement, ExcalidrawElement,
ExcalidrawFrameLikeElement,
NonDeleted,
NonDeletedElementsMap, NonDeletedElementsMap,
NonDeletedExcalidrawElement, NonDeletedExcalidrawElement,
} from "@excalidraw/element/types"; } from "@excalidraw/element/types";
@@ -22,21 +16,6 @@ import type { RenderableElementsMap } from "./types";
import type { AppState } from "../types"; import type { AppState } from "../types";
type GetRenderableElementsOpts = {
zoom: AppState["zoom"];
offsetLeft: AppState["offsetLeft"];
offsetTop: AppState["offsetTop"];
scrollX: AppState["scrollX"];
scrollY: AppState["scrollY"];
height: AppState["height"];
width: AppState["width"];
editingTextElement: AppState["editingTextElement"];
newElement: AppState["newElement"];
selectedElements: readonly NonDeletedExcalidrawElement[];
selectedElementsAreBeingDragged: AppState["selectedElementsAreBeingDragged"];
frameToHighlight: AppState["frameToHighlight"];
};
export class Renderer { export class Renderer {
private scene: Scene; private scene: Scene;
@@ -44,121 +23,9 @@ export class Renderer {
this.scene = scene; this.scene = scene;
} }
private getVisibleCanvasElements({ public getRenderableElements = (() => {
elementsMap, const getVisibleCanvasElements = ({
zoom, elementsMap,
offsetLeft,
offsetTop,
scrollX,
scrollY,
height,
width,
}: {
elementsMap: NonDeletedElementsMap;
zoom: AppState["zoom"];
offsetLeft: AppState["offsetLeft"];
offsetTop: AppState["offsetTop"];
scrollX: AppState["scrollX"];
scrollY: AppState["scrollY"];
height: AppState["height"];
width: AppState["width"];
}): readonly NonDeletedExcalidrawElement[] {
const visibleElements: NonDeletedExcalidrawElement[] = [];
for (const element of elementsMap.values()) {
if (
isElementInViewport(
element,
width,
height,
{
zoom,
offsetLeft,
offsetTop,
scrollX,
scrollY,
},
elementsMap,
)
) {
visibleElements.push(element);
}
}
return visibleElements;
}
private getRenderableElementsMap({
elements,
editingTextElement,
newElement,
}: {
elements: readonly NonDeletedExcalidrawElement[];
editingTextElement: AppState["editingTextElement"];
newElement: AppState["newElement"];
}) {
const elementsMap = toBrandedType<RenderableElementsMap>(new Map());
const newElementCanvasElement = newElement?.frameId ? null : newElement;
for (const element of elements) {
if (newElementCanvasElement?.id === element.id) {
continue;
}
// we don't want to render text element that's being currently edited
// (it's rendered on remote only)
if (
!editingTextElement ||
editingTextElement.type !== "text" ||
element.id !== editingTextElement.id
) {
elementsMap.set(element.id, element);
}
}
return { elementsMap, newElementCanvasElement };
}
private sortSelectedElementsIntoHighlightedFrame<
T extends ExcalidrawElement,
>({
visibleElements,
selectedElements,
frameToHighlight,
}: {
selectedElements: readonly NonDeletedExcalidrawElement[];
visibleElements: readonly T[];
frameToHighlight: NonDeleted<ExcalidrawFrameLikeElement>;
}): readonly T[] {
if (!selectedElements.length) {
return visibleElements;
}
// we assume all selected elements are eligible frame children if
// frameToHighlight is defined
const selectedElementsMap = arrayToMap(selectedElements);
// thus, all deselected elements are the ones we won't reorder
const deselectedElements = visibleElements.filter(
(element) => !selectedElementsMap.has(element.id),
);
const insertionIndex = getFrameChildrenInsertionIndex(
deselectedElements,
frameToHighlight.id,
);
if (insertionIndex === null) {
return visibleElements;
}
return [
...deselectedElements.slice(0, insertionIndex),
...selectedElements,
...deselectedElements.slice(insertionIndex),
] as readonly T[];
}
private _getRenderableElements = memoize(
({
canvasNonce,
zoom, zoom,
offsetLeft, offsetLeft,
offsetTop, offsetTop,
@@ -166,27 +33,70 @@ export class Renderer {
scrollY, scrollY,
height, height,
width, width,
}: {
elementsMap: NonDeletedElementsMap;
zoom: AppState["zoom"];
offsetLeft: AppState["offsetLeft"];
offsetTop: AppState["offsetTop"];
scrollX: AppState["scrollX"];
scrollY: AppState["scrollY"];
height: AppState["height"];
width: AppState["width"];
}): readonly NonDeletedExcalidrawElement[] => {
const visibleElements: NonDeletedExcalidrawElement[] = [];
for (const element of elementsMap.values()) {
if (
isElementInViewport(
element,
width,
height,
{
zoom,
offsetLeft,
offsetTop,
scrollX,
scrollY,
},
elementsMap,
)
) {
visibleElements.push(element);
}
}
return visibleElements;
};
const getRenderableElements = ({
elements,
editingTextElement, editingTextElement,
newElement, newElementId,
}: Omit< }: {
GetRenderableElementsOpts, elements: readonly NonDeletedExcalidrawElement[];
| "selectedElements" editingTextElement: AppState["editingTextElement"];
| "selectedElementsAreBeingDragged" newElementId: ExcalidrawElement["id"] | undefined;
| "frameToHighlight"
> & {
canvasNonce: string;
}) => { }) => {
const elements = this.scene.getNonDeletedElements(); const elementsMap = toBrandedType<RenderableElementsMap>(new Map());
const { elementsMap, newElementCanvasElement } = for (const element of elements) {
this.getRenderableElementsMap({ if (newElementId === element.id) {
elements, continue;
editingTextElement, }
newElement,
});
const visibleElements = this.getVisibleCanvasElements({ // we don't want to render text element that's being currently edited
elementsMap, // (it's rendered on remote only)
if (
!editingTextElement ||
editingTextElement.type !== "text" ||
element.id !== editingTextElement.id
) {
elementsMap.set(element.id, element);
}
}
return elementsMap;
};
return memoize(
({
zoom, zoom,
offsetLeft, offsetLeft,
offsetTop, offsetTop,
@@ -194,69 +104,52 @@ export class Renderer {
scrollY, scrollY,
height, height,
width, width,
}); editingTextElement,
newElementId,
// cache-invalidation nonce
sceneNonce: _sceneNonce,
}: {
zoom: AppState["zoom"];
offsetLeft: AppState["offsetLeft"];
offsetTop: AppState["offsetTop"];
scrollX: AppState["scrollX"];
scrollY: AppState["scrollY"];
height: AppState["height"];
width: AppState["width"];
editingTextElement: AppState["editingTextElement"];
/** note: first render of newElement will always bust the cache
* (we'd have to prefilter elements outside of this function) */
newElementId: ExcalidrawElement["id"] | undefined;
sceneNonce: ReturnType<InstanceType<typeof Scene>["getSceneNonce"]>;
}) => {
const elements = this.scene.getNonDeletedElements();
return { const elementsMap = getRenderableElements({
elementsMap, elements,
visibleElements, editingTextElement,
newElementCanvasElement, newElementId,
canvasNonce,
};
},
);
public getRenderableElements = (opts: GetRenderableElementsOpts) => {
const { newElement } = opts;
const canvasNonce = `${this.scene.getSceneNonce()}${
newElement?.frameId ? `:${newElement.versionNonce}` : ""
}`;
const ret = this._getRenderableElements({
canvasNonce,
// don't spread `opts` because we don't want to memoize on some props
zoom: opts.zoom,
offsetLeft: opts.offsetLeft,
offsetTop: opts.offsetTop,
scrollX: opts.scrollX,
scrollY: opts.scrollY,
height: opts.height,
width: opts.width,
editingTextElement: opts.editingTextElement,
newElement: opts.newElement,
});
// if we're dragging elements over a frame, reorder the selected elements
// inside the frame during render (we don't set the `element.frameId` until
// pointerup else we'd have to painstainly restore the orig index if user
// didn't end up adding elements to the frame)
if (
opts.frameToHighlight &&
opts.selectedElementsAreBeingDragged &&
// if all dragged elements are already in the frame, don't reorder
getCommonFrameId(opts.selectedElements) !== opts.frameToHighlight.id
) {
const reorderedVisibleElements =
this.sortSelectedElementsIntoHighlightedFrame({
visibleElements: ret.visibleElements,
selectedElements: opts.selectedElements,
frameToHighlight: opts.frameToHighlight,
}); });
return { const visibleElements = getVisibleCanvasElements({
...ret, elementsMap,
visibleElements: reorderedVisibleElements, zoom,
}; offsetLeft,
} offsetTop,
scrollX,
scrollY,
height,
width,
});
return ret; return { elementsMap, visibleElements };
}; },
);
})();
// NOTE Doesn't destroy everything (scene, rc, etc.) because it may not be // NOTE Doesn't destroy everything (scene, rc, etc.) because it may not be
// safe to break TS contract here (for upstream cases) // safe to break TS contract here (for upstream cases)
public destroy() { public destroy() {
renderStaticSceneThrottled.cancel(); renderStaticSceneThrottled.cancel();
this._getRenderableElements.clear(); this.getRenderableElements.clear();
} }
} }
@@ -83,26 +83,6 @@ mockMermaidToExcalidraw({
}, },
}); });
const normalizeDialogSnapshot = (dialog: Element) => {
const dialogClone = dialog.cloneNode(true) as HTMLElement;
dialogClone
.querySelectorAll<HTMLElement>(".ttd-dialog-content")
.forEach((element) => {
// Radix Tabs injects this during initial mount animation prevention.
// Its presence depends on render timing and is unrelated to this test.
if (element.style.animationDuration === "0s") {
element.style.removeProperty("animation-duration");
}
if (!element.getAttribute("style")) {
element.removeAttribute("style");
}
});
return dialogClone.outerHTML;
};
describe("Test <MermaidToExcalidraw/>", () => { describe("Test <MermaidToExcalidraw/>", () => {
beforeEach(async () => { beforeEach(async () => {
await render( await render(
@@ -119,7 +99,7 @@ describe("Test <MermaidToExcalidraw/>", () => {
it("should open mermaid popup when active tool is mermaid", async () => { it("should open mermaid popup when active tool is mermaid", async () => {
const dialog = document.querySelector(".ttd-dialog")!; const dialog = document.querySelector(".ttd-dialog")!;
await waitFor(() => expect(dialog.querySelector("canvas")).not.toBeNull()); await waitFor(() => expect(dialog.querySelector("canvas")).not.toBeNull());
expect(normalizeDialogSnapshot(dialog)).toMatchSnapshot(); expect(dialog.outerHTML).toMatchSnapshot();
}); });
it("should show error in preview when mermaid library throws error", async () => { it("should show error in preview when mermaid library throws error", async () => {
@@ -1,6 +1,6 @@
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html // Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
exports[`Test <MermaidToExcalidraw/> > should open mermaid popup when active tool is mermaid 1`] = `"<div class="Modal Dialog ttd-dialog" role="dialog" aria-modal="true" aria-labelledby="dialog-title"><div class="Modal__background"></div><div class="Modal__content" style="--max-width: 1520px;" tabindex="0"><div class="Island"><div class="Dialog__content"><div dir="ltr" data-orientation="horizontal" class="ttd-dialog-tabs-root"><p class="dialog-mermaid-title">Mermaid to Excalidraw</p><div data-state="active" data-orientation="horizontal" role="tabpanel" aria-labelledby="radix-:r4:-trigger-mermaid" id="radix-:r4:-content-mermaid" tabindex="0" class="ttd-dialog-content"><div class="ttd-dialog-desc">Currently only <a href="https://mermaid.js.org/syntax/flowchart.html" target="_blank" rel="noreferrer">Flowchart</a>, <a href="https://mermaid.js.org/syntax/sequenceDiagram.html" target="_blank" rel="noreferrer">Sequence</a>, <a href="https://mermaid.js.org/syntax/classDiagram.html" target="_blank" rel="noreferrer">Class</a>, and <a href="https://mermaid.js.org/syntax/entityRelationshipDiagram.html" target="_blank" rel="noreferrer">Entity Relationship</a> Diagrams are supported. The other types will be rendered as image in Excalidraw.</div><div class="ttd-dialog-panels"><div class="ttd-dialog-panel"><div class="ttd-dialog-panel-button-container invisible" style="justify-content: flex-start;"></div></div><div class="ttd-dialog-panel"><div class="ttd-dialog-output-wrapper "><div class="ttd-dialog-output-canvas-container"><div class="ttd-dialog-output-canvas-content"><canvas width="89" height="158" dir="ltr"></canvas></div></div></div><div class="ttd-dialog-panel-button-container" style="justify-content: flex-start;"><button type="button" class="excalidraw-button ttd-dialog-panel-button"><div class="">Insert<span><svg aria-hidden="true" focusable="false" role="img" viewBox="0 0 20 20" class="" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"><g stroke-width="1.25"><path d="M4.16602 10H15.8327"></path><path d="M12.5 13.3333L15.8333 10"></path><path d="M12.5 6.66666L15.8333 9.99999"></path></g></svg></span></div></button><div class="ttd-dialog-submit-shortcut"><div class="ttd-dialog-submit-shortcut__key">Ctrl</div><div class="ttd-dialog-submit-shortcut__key">Enter</div></div></div></div></div></div></div></div></div></div></div>"`; exports[`Test <MermaidToExcalidraw/> > should open mermaid popup when active tool is mermaid 1`] = `"<div class="Modal Dialog ttd-dialog" role="dialog" aria-modal="true" aria-labelledby="dialog-title"><div class="Modal__background"></div><div class="Modal__content" style="--max-width: 1520px;" tabindex="0"><div class="Island"><div class="Dialog__content"><div dir="ltr" data-orientation="horizontal" class="ttd-dialog-tabs-root"><p class="dialog-mermaid-title">Mermaid to Excalidraw</p><div data-state="active" data-orientation="horizontal" role="tabpanel" aria-labelledby="radix-:r4:-trigger-mermaid" id="radix-:r4:-content-mermaid" tabindex="0" class="ttd-dialog-content" style=""><div class="ttd-dialog-desc">Currently only <a href="https://mermaid.js.org/syntax/flowchart.html" target="_blank" rel="noreferrer">Flowchart</a>, <a href="https://mermaid.js.org/syntax/sequenceDiagram.html" target="_blank" rel="noreferrer">Sequence</a>, <a href="https://mermaid.js.org/syntax/classDiagram.html" target="_blank" rel="noreferrer">Class</a>, and <a href="https://mermaid.js.org/syntax/entityRelationshipDiagram.html" target="_blank" rel="noreferrer">Entity Relationship</a> Diagrams are supported. The other types will be rendered as image in Excalidraw.</div><div class="ttd-dialog-panels"><div class="ttd-dialog-panel"><div class="ttd-dialog-panel-button-container invisible" style="justify-content: flex-start;"></div></div><div class="ttd-dialog-panel"><div class="ttd-dialog-output-wrapper "><div class="ttd-dialog-output-canvas-container"><div class="ttd-dialog-output-canvas-content"><canvas width="89" height="158" dir="ltr"></canvas></div></div></div><div class="ttd-dialog-panel-button-container" style="justify-content: flex-start;"><button type="button" class="excalidraw-button ttd-dialog-panel-button"><div class="">Insert<span><svg aria-hidden="true" focusable="false" role="img" viewBox="0 0 20 20" class="" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"><g stroke-width="1.25"><path d="M4.16602 10H15.8327"></path><path d="M12.5 13.3333L15.8333 10"></path><path d="M12.5 6.66666L15.8333 9.99999"></path></g></svg></span></div></button><div class="ttd-dialog-submit-shortcut"><div class="ttd-dialog-submit-shortcut__key">Ctrl</div><div class="ttd-dialog-submit-shortcut__key">Enter</div></div></div></div></div></div></div></div></div></div></div>"`;
exports[`Test <MermaidToExcalidraw/> > should show error in preview when mermaid library throws error 1`] = ` exports[`Test <MermaidToExcalidraw/> > should show error in preview when mermaid library throws error 1`] = `
"flowchart TD "flowchart TD
@@ -979,6 +979,7 @@ exports[`contextMenu element > right-clicking on a group should select whole gro
"showHyperlinkPopup": false, "showHyperlinkPopup": false,
"showWelcomeScreen": true, "showWelcomeScreen": true,
"snapLines": [], "snapLines": [],
"startBoundElement": null,
"stats": { "stats": {
"open": false, "open": false,
"panels": 3, "panels": 3,
@@ -1172,6 +1173,7 @@ exports[`contextMenu element > selecting 'Add to library' in context menu adds e
"showHyperlinkPopup": false, "showHyperlinkPopup": false,
"showWelcomeScreen": true, "showWelcomeScreen": true,
"snapLines": [], "snapLines": [],
"startBoundElement": null,
"stats": { "stats": {
"open": false, "open": false,
"panels": 3, "panels": 3,
@@ -1386,6 +1388,7 @@ exports[`contextMenu element > selecting 'Bring forward' in context menu brings
"showHyperlinkPopup": false, "showHyperlinkPopup": false,
"showWelcomeScreen": true, "showWelcomeScreen": true,
"snapLines": [], "snapLines": [],
"startBoundElement": null,
"stats": { "stats": {
"open": false, "open": false,
"panels": 3, "panels": 3,
@@ -1717,6 +1720,7 @@ exports[`contextMenu element > selecting 'Bring to front' in context menu brings
"showHyperlinkPopup": false, "showHyperlinkPopup": false,
"showWelcomeScreen": true, "showWelcomeScreen": true,
"snapLines": [], "snapLines": [],
"startBoundElement": null,
"stats": { "stats": {
"open": false, "open": false,
"panels": 3, "panels": 3,
@@ -2048,6 +2052,7 @@ exports[`contextMenu element > selecting 'Copy styles' in context menu copies st
"showHyperlinkPopup": false, "showHyperlinkPopup": false,
"showWelcomeScreen": true, "showWelcomeScreen": true,
"snapLines": [], "snapLines": [],
"startBoundElement": null,
"stats": { "stats": {
"open": false, "open": false,
"panels": 3, "panels": 3,
@@ -2260,6 +2265,7 @@ exports[`contextMenu element > selecting 'Delete' in context menu deletes elemen
"showHyperlinkPopup": false, "showHyperlinkPopup": false,
"showWelcomeScreen": true, "showWelcomeScreen": true,
"snapLines": [], "snapLines": [],
"startBoundElement": null,
"stats": { "stats": {
"open": false, "open": false,
"panels": 3, "panels": 3,
@@ -2503,6 +2509,7 @@ exports[`contextMenu element > selecting 'Duplicate' in context menu duplicates
"showHyperlinkPopup": false, "showHyperlinkPopup": false,
"showWelcomeScreen": true, "showWelcomeScreen": true,
"snapLines": [], "snapLines": [],
"startBoundElement": null,
"stats": { "stats": {
"open": false, "open": false,
"panels": 3, "panels": 3,
@@ -2806,6 +2813,7 @@ exports[`contextMenu element > selecting 'Group selection' in context menu group
"showHyperlinkPopup": false, "showHyperlinkPopup": false,
"showWelcomeScreen": true, "showWelcomeScreen": true,
"snapLines": [], "snapLines": [],
"startBoundElement": null,
"stats": { "stats": {
"open": false, "open": false,
"panels": 3, "panels": 3,
@@ -3173,6 +3181,7 @@ exports[`contextMenu element > selecting 'Paste styles' in context menu pastes s
"showHyperlinkPopup": false, "showHyperlinkPopup": false,
"showWelcomeScreen": true, "showWelcomeScreen": true,
"snapLines": [], "snapLines": [],
"startBoundElement": null,
"stats": { "stats": {
"open": false, "open": false,
"panels": 3, "panels": 3,
@@ -3666,6 +3675,7 @@ exports[`contextMenu element > selecting 'Send backward' in context menu sends e
"showHyperlinkPopup": false, "showHyperlinkPopup": false,
"showWelcomeScreen": true, "showWelcomeScreen": true,
"snapLines": [], "snapLines": [],
"startBoundElement": null,
"stats": { "stats": {
"open": false, "open": false,
"panels": 3, "panels": 3,
@@ -3989,6 +3999,7 @@ exports[`contextMenu element > selecting 'Send to back' in context menu sends el
"showHyperlinkPopup": false, "showHyperlinkPopup": false,
"showWelcomeScreen": true, "showWelcomeScreen": true,
"snapLines": [], "snapLines": [],
"startBoundElement": null,
"stats": { "stats": {
"open": false, "open": false,
"panels": 3, "panels": 3,
@@ -4315,6 +4326,7 @@ exports[`contextMenu element > selecting 'Ungroup selection' in context menu ung
"showHyperlinkPopup": false, "showHyperlinkPopup": false,
"showWelcomeScreen": true, "showWelcomeScreen": true,
"snapLines": [], "snapLines": [],
"startBoundElement": null,
"stats": { "stats": {
"open": false, "open": false,
"panels": 3, "panels": 3,
@@ -5600,6 +5612,7 @@ exports[`contextMenu element > shows 'Group selection' in context menu for multi
"showHyperlinkPopup": false, "showHyperlinkPopup": false,
"showWelcomeScreen": true, "showWelcomeScreen": true,
"snapLines": [], "snapLines": [],
"startBoundElement": null,
"stats": { "stats": {
"open": false, "open": false,
"panels": 3, "panels": 3,
@@ -6819,6 +6832,7 @@ exports[`contextMenu element > shows 'Ungroup selection' in context menu for gro
"showHyperlinkPopup": false, "showHyperlinkPopup": false,
"showWelcomeScreen": true, "showWelcomeScreen": true,
"snapLines": [], "snapLines": [],
"startBoundElement": null,
"stats": { "stats": {
"open": false, "open": false,
"panels": 3, "panels": 3,
@@ -7772,6 +7786,7 @@ exports[`contextMenu element > shows context menu for canvas > [end of test] app
"showHyperlinkPopup": false, "showHyperlinkPopup": false,
"showWelcomeScreen": true, "showWelcomeScreen": true,
"snapLines": [], "snapLines": [],
"startBoundElement": null,
"stats": { "stats": {
"open": false, "open": false,
"panels": 3, "panels": 3,
@@ -8771,6 +8786,7 @@ exports[`contextMenu element > shows context menu for element > [end of test] ap
"showHyperlinkPopup": false, "showHyperlinkPopup": false,
"showWelcomeScreen": true, "showWelcomeScreen": true,
"snapLines": [], "snapLines": [],
"startBoundElement": null,
"stats": { "stats": {
"open": false, "open": false,
"panels": 3, "panels": 3,
@@ -9765,6 +9781,7 @@ exports[`contextMenu element > shows context menu for element > [end of test] ap
"showHyperlinkPopup": false, "showHyperlinkPopup": false,
"showWelcomeScreen": true, "showWelcomeScreen": true,
"snapLines": [], "snapLines": [],
"startBoundElement": null,
"stats": { "stats": {
"open": false, "open": false,
"panels": 3, "panels": 3,
@@ -98,6 +98,7 @@ exports[`history > multiplayer undo/redo > conflicts in arrows and their bindabl
"showHyperlinkPopup": false, "showHyperlinkPopup": false,
"showWelcomeScreen": true, "showWelcomeScreen": true,
"snapLines": [], "snapLines": [],
"startBoundElement": null,
"stats": { "stats": {
"open": false, "open": false,
"panels": 3, "panels": 3,
@@ -731,6 +732,7 @@ exports[`history > multiplayer undo/redo > conflicts in arrows and their bindabl
"showHyperlinkPopup": false, "showHyperlinkPopup": false,
"showWelcomeScreen": true, "showWelcomeScreen": true,
"snapLines": [], "snapLines": [],
"startBoundElement": null,
"stats": { "stats": {
"open": false, "open": false,
"panels": 3, "panels": 3,
@@ -1291,6 +1293,7 @@ exports[`history > multiplayer undo/redo > conflicts in arrows and their bindabl
"showHyperlinkPopup": false, "showHyperlinkPopup": false,
"showWelcomeScreen": true, "showWelcomeScreen": true,
"snapLines": [], "snapLines": [],
"startBoundElement": null,
"stats": { "stats": {
"open": false, "open": false,
"panels": 3, "panels": 3,
@@ -1651,6 +1654,7 @@ exports[`history > multiplayer undo/redo > conflicts in arrows and their bindabl
"showHyperlinkPopup": false, "showHyperlinkPopup": false,
"showWelcomeScreen": true, "showWelcomeScreen": true,
"snapLines": [], "snapLines": [],
"startBoundElement": null,
"stats": { "stats": {
"open": false, "open": false,
"panels": 3, "panels": 3,
@@ -2013,6 +2017,7 @@ exports[`history > multiplayer undo/redo > conflicts in arrows and their bindabl
"showHyperlinkPopup": false, "showHyperlinkPopup": false,
"showWelcomeScreen": true, "showWelcomeScreen": true,
"snapLines": [], "snapLines": [],
"startBoundElement": null,
"stats": { "stats": {
"open": false, "open": false,
"panels": 3, "panels": 3,
@@ -2275,6 +2280,7 @@ exports[`history > multiplayer undo/redo > conflicts in arrows and their bindabl
"showHyperlinkPopup": false, "showHyperlinkPopup": false,
"showWelcomeScreen": true, "showWelcomeScreen": true,
"snapLines": [], "snapLines": [],
"startBoundElement": null,
"stats": { "stats": {
"open": false, "open": false,
"panels": 3, "panels": 3,
@@ -2374,7 +2380,7 @@ exports[`history > multiplayer undo/redo > conflicts in arrows and their bindabl
"endBinding": { "endBinding": {
"elementId": "id1", "elementId": "id1",
"fixedPoint": [ "fixedPoint": [
"0.50010", 0,
"0.50010", "0.50010",
], ],
"mode": "orbit", "mode": "orbit",
@@ -2382,7 +2388,7 @@ exports[`history > multiplayer undo/redo > conflicts in arrows and their bindabl
"fillStyle": "solid", "fillStyle": "solid",
"frameId": null, "frameId": null,
"groupIds": [], "groupIds": [],
"height": "399.26547", "height": "439.20000",
"id": "id4", "id": "id4",
"index": "a2", "index": "a2",
"isDeleted": false, "isDeleted": false,
@@ -2396,8 +2402,8 @@ exports[`history > multiplayer undo/redo > conflicts in arrows and their bindabl
0, 0,
], ],
[ [
"488.00000", 488,
"-399.26547", "-439.20000",
], ],
], ],
"roughness": 1, "roughness": 1,
@@ -2419,9 +2425,9 @@ exports[`history > multiplayer undo/redo > conflicts in arrows and their bindabl
"type": "arrow", "type": "arrow",
"updated": 1, "updated": 1,
"version": 11, "version": 11,
"width": "488.00000", "width": 488,
"x": 6, "x": 6,
"y": "-4.89900", "y": "-5.39000",
} }
`; `;
@@ -2542,7 +2548,7 @@ exports[`history > multiplayer undo/redo > conflicts in arrows and their bindabl
"endBinding": { "endBinding": {
"elementId": "id1", "elementId": "id1",
"fixedPoint": [ "fixedPoint": [
"0.50010", 0,
"0.50010", "0.50010",
], ],
"mode": "orbit", "mode": "orbit",
@@ -2550,7 +2556,7 @@ exports[`history > multiplayer undo/redo > conflicts in arrows and their bindabl
"fillStyle": "solid", "fillStyle": "solid",
"frameId": null, "frameId": null,
"groupIds": [], "groupIds": [],
"height": "399.26547", "height": "439.20000",
"index": "a2", "index": "a2",
"isDeleted": false, "isDeleted": false,
"link": null, "link": null,
@@ -2562,8 +2568,8 @@ exports[`history > multiplayer undo/redo > conflicts in arrows and their bindabl
0, 0,
], ],
[ [
"488.00000", 488,
"-399.26547", "-439.20000",
], ],
], ],
"roughness": 1, "roughness": 1,
@@ -2584,9 +2590,9 @@ exports[`history > multiplayer undo/redo > conflicts in arrows and their bindabl
"strokeWidth": 2, "strokeWidth": 2,
"type": "arrow", "type": "arrow",
"version": 11, "version": 11,
"width": "488.00000", "width": 488,
"x": 6, "x": 6,
"y": "-4.89900", "y": "-5.39000",
}, },
"inserted": { "inserted": {
"isDeleted": true, "isDeleted": true,
@@ -2729,6 +2735,7 @@ exports[`history > multiplayer undo/redo > conflicts in bound text elements and
"showHyperlinkPopup": false, "showHyperlinkPopup": false,
"showWelcomeScreen": true, "showWelcomeScreen": true,
"snapLines": [], "snapLines": [],
"startBoundElement": null,
"stats": { "stats": {
"open": false, "open": false,
"panels": 3, "panels": 3,
@@ -3032,6 +3039,7 @@ exports[`history > multiplayer undo/redo > conflicts in bound text elements and
"showHyperlinkPopup": false, "showHyperlinkPopup": false,
"showWelcomeScreen": true, "showWelcomeScreen": true,
"snapLines": [], "snapLines": [],
"startBoundElement": null,
"stats": { "stats": {
"open": false, "open": false,
"panels": 3, "panels": 3,
@@ -3351,6 +3359,7 @@ exports[`history > multiplayer undo/redo > conflicts in bound text elements and
"showHyperlinkPopup": false, "showHyperlinkPopup": false,
"showWelcomeScreen": true, "showWelcomeScreen": true,
"snapLines": [], "snapLines": [],
"startBoundElement": null,
"stats": { "stats": {
"open": false, "open": false,
"panels": 3, "panels": 3,
@@ -3645,6 +3654,7 @@ exports[`history > multiplayer undo/redo > conflicts in bound text elements and
"showHyperlinkPopup": false, "showHyperlinkPopup": false,
"showWelcomeScreen": true, "showWelcomeScreen": true,
"snapLines": [], "snapLines": [],
"startBoundElement": null,
"stats": { "stats": {
"open": false, "open": false,
"panels": 3, "panels": 3,
@@ -3931,6 +3941,7 @@ exports[`history > multiplayer undo/redo > conflicts in bound text elements and
"showHyperlinkPopup": false, "showHyperlinkPopup": false,
"showWelcomeScreen": true, "showWelcomeScreen": true,
"snapLines": [], "snapLines": [],
"startBoundElement": null,
"stats": { "stats": {
"open": false, "open": false,
"panels": 3, "panels": 3,
@@ -4166,6 +4177,7 @@ exports[`history > multiplayer undo/redo > conflicts in bound text elements and
"showHyperlinkPopup": false, "showHyperlinkPopup": false,
"showWelcomeScreen": true, "showWelcomeScreen": true,
"snapLines": [], "snapLines": [],
"startBoundElement": null,
"stats": { "stats": {
"open": false, "open": false,
"panels": 3, "panels": 3,
@@ -4423,6 +4435,7 @@ exports[`history > multiplayer undo/redo > conflicts in bound text elements and
"showHyperlinkPopup": false, "showHyperlinkPopup": false,
"showWelcomeScreen": true, "showWelcomeScreen": true,
"snapLines": [], "snapLines": [],
"startBoundElement": null,
"stats": { "stats": {
"open": false, "open": false,
"panels": 3, "panels": 3,
@@ -4694,6 +4707,7 @@ exports[`history > multiplayer undo/redo > conflicts in bound text elements and
"showHyperlinkPopup": false, "showHyperlinkPopup": false,
"showWelcomeScreen": true, "showWelcomeScreen": true,
"snapLines": [], "snapLines": [],
"startBoundElement": null,
"stats": { "stats": {
"open": false, "open": false,
"panels": 3, "panels": 3,
@@ -4923,6 +4937,7 @@ exports[`history > multiplayer undo/redo > conflicts in bound text elements and
"showHyperlinkPopup": false, "showHyperlinkPopup": false,
"showWelcomeScreen": true, "showWelcomeScreen": true,
"snapLines": [], "snapLines": [],
"startBoundElement": null,
"stats": { "stats": {
"open": false, "open": false,
"panels": 3, "panels": 3,
@@ -5152,6 +5167,7 @@ exports[`history > multiplayer undo/redo > conflicts in bound text elements and
"showHyperlinkPopup": false, "showHyperlinkPopup": false,
"showWelcomeScreen": true, "showWelcomeScreen": true,
"snapLines": [], "snapLines": [],
"startBoundElement": null,
"stats": { "stats": {
"open": false, "open": false,
"panels": 3, "panels": 3,
@@ -5399,6 +5415,7 @@ exports[`history > multiplayer undo/redo > conflicts in bound text elements and
"showHyperlinkPopup": false, "showHyperlinkPopup": false,
"showWelcomeScreen": true, "showWelcomeScreen": true,
"snapLines": [], "snapLines": [],
"startBoundElement": null,
"stats": { "stats": {
"open": false, "open": false,
"panels": 3, "panels": 3,
@@ -5655,6 +5672,7 @@ exports[`history > multiplayer undo/redo > conflicts in frames and their childre
"showHyperlinkPopup": false, "showHyperlinkPopup": false,
"showWelcomeScreen": true, "showWelcomeScreen": true,
"snapLines": [], "snapLines": [],
"startBoundElement": null,
"stats": { "stats": {
"open": false, "open": false,
"panels": 3, "panels": 3,
@@ -5912,6 +5930,7 @@ exports[`history > multiplayer undo/redo > should iterate through the history wh
"showHyperlinkPopup": false, "showHyperlinkPopup": false,
"showWelcomeScreen": true, "showWelcomeScreen": true,
"snapLines": [], "snapLines": [],
"startBoundElement": null,
"stats": { "stats": {
"open": false, "open": false,
"panels": 3, "panels": 3,
@@ -6241,6 +6260,7 @@ exports[`history > multiplayer undo/redo > should iterate through the history wh
"showHyperlinkPopup": false, "showHyperlinkPopup": false,
"showWelcomeScreen": true, "showWelcomeScreen": true,
"snapLines": [], "snapLines": [],
"startBoundElement": null,
"stats": { "stats": {
"open": false, "open": false,
"panels": 3, "panels": 3,
@@ -6671,6 +6691,7 @@ exports[`history > multiplayer undo/redo > should iterate through the history wh
"showHyperlinkPopup": false, "showHyperlinkPopup": false,
"showWelcomeScreen": true, "showWelcomeScreen": true,
"snapLines": [], "snapLines": [],
"startBoundElement": null,
"stats": { "stats": {
"open": false, "open": false,
"panels": 3, "panels": 3,
@@ -7051,6 +7072,7 @@ exports[`history > multiplayer undo/redo > should iterate through the history wh
"showHyperlinkPopup": false, "showHyperlinkPopup": false,
"showWelcomeScreen": true, "showWelcomeScreen": true,
"snapLines": [], "snapLines": [],
"startBoundElement": null,
"stats": { "stats": {
"open": false, "open": false,
"panels": 3, "panels": 3,
@@ -7352,6 +7374,7 @@ exports[`history > multiplayer undo/redo > should iterate through the history wh
"showHyperlinkPopup": false, "showHyperlinkPopup": false,
"showWelcomeScreen": true, "showWelcomeScreen": true,
"snapLines": [], "snapLines": [],
"startBoundElement": null,
"stats": { "stats": {
"open": false, "open": false,
"panels": 3, "panels": 3,
@@ -7644,6 +7667,7 @@ exports[`history > multiplayer undo/redo > should iterate through the history wh
"showHyperlinkPopup": false, "showHyperlinkPopup": false,
"showWelcomeScreen": true, "showWelcomeScreen": true,
"snapLines": [], "snapLines": [],
"startBoundElement": null,
"stats": { "stats": {
"open": false, "open": false,
"panels": 3, "panels": 3,
@@ -7874,6 +7898,7 @@ exports[`history > multiplayer undo/redo > should iterate through the history wh
"showHyperlinkPopup": false, "showHyperlinkPopup": false,
"showWelcomeScreen": true, "showWelcomeScreen": true,
"snapLines": [], "snapLines": [],
"startBoundElement": null,
"stats": { "stats": {
"open": false, "open": false,
"panels": 3, "panels": 3,
@@ -8226,6 +8251,7 @@ exports[`history > multiplayer undo/redo > should iterate through the history wh
"showHyperlinkPopup": false, "showHyperlinkPopup": false,
"showWelcomeScreen": true, "showWelcomeScreen": true,
"snapLines": [], "snapLines": [],
"startBoundElement": null,
"stats": { "stats": {
"open": false, "open": false,
"panels": 3, "panels": 3,
@@ -8584,6 +8610,7 @@ exports[`history > multiplayer undo/redo > should not let remote changes to inte
"showHyperlinkPopup": false, "showHyperlinkPopup": false,
"showWelcomeScreen": true, "showWelcomeScreen": true,
"snapLines": [], "snapLines": [],
"startBoundElement": null,
"stats": { "stats": {
"open": false, "open": false,
"panels": 3, "panels": 3,
@@ -8984,6 +9011,7 @@ exports[`history > multiplayer undo/redo > should not let remote changes to inte
"showHyperlinkPopup": false, "showHyperlinkPopup": false,
"showWelcomeScreen": true, "showWelcomeScreen": true,
"snapLines": [], "snapLines": [],
"startBoundElement": null,
"stats": { "stats": {
"open": false, "open": false,
"panels": 3, "panels": 3,
@@ -9265,6 +9293,7 @@ exports[`history > multiplayer undo/redo > should not let remote changes to inte
"showHyperlinkPopup": false, "showHyperlinkPopup": false,
"showWelcomeScreen": true, "showWelcomeScreen": true,
"snapLines": [], "snapLines": [],
"startBoundElement": null,
"stats": { "stats": {
"open": false, "open": false,
"panels": 3, "panels": 3,
@@ -9529,6 +9558,7 @@ exports[`history > multiplayer undo/redo > should not override remote changes on
"showHyperlinkPopup": false, "showHyperlinkPopup": false,
"showWelcomeScreen": true, "showWelcomeScreen": true,
"snapLines": [], "snapLines": [],
"startBoundElement": null,
"stats": { "stats": {
"open": false, "open": false,
"panels": 3, "panels": 3,
@@ -9794,6 +9824,7 @@ exports[`history > multiplayer undo/redo > should not override remote changes on
"showHyperlinkPopup": false, "showHyperlinkPopup": false,
"showWelcomeScreen": true, "showWelcomeScreen": true,
"snapLines": [], "snapLines": [],
"startBoundElement": null,
"stats": { "stats": {
"open": false, "open": false,
"panels": 3, "panels": 3,
@@ -10027,6 +10058,7 @@ exports[`history > multiplayer undo/redo > should override remotely added groups
"showHyperlinkPopup": false, "showHyperlinkPopup": false,
"showWelcomeScreen": true, "showWelcomeScreen": true,
"snapLines": [], "snapLines": [],
"startBoundElement": null,
"stats": { "stats": {
"open": false, "open": false,
"panels": 3, "panels": 3,
@@ -10323,6 +10355,7 @@ exports[`history > multiplayer undo/redo > should override remotely added points
"showHyperlinkPopup": false, "showHyperlinkPopup": false,
"showWelcomeScreen": true, "showWelcomeScreen": true,
"snapLines": [], "snapLines": [],
"startBoundElement": null,
"stats": { "stats": {
"open": false, "open": false,
"panels": 3, "panels": 3,
@@ -10638,6 +10671,7 @@ exports[`history > multiplayer undo/redo > should redistribute deltas when eleme
"showHyperlinkPopup": false, "showHyperlinkPopup": false,
"showWelcomeScreen": true, "showWelcomeScreen": true,
"snapLines": [], "snapLines": [],
"startBoundElement": null,
"stats": { "stats": {
"open": false, "open": false,
"panels": 3, "panels": 3,
@@ -10877,6 +10911,7 @@ exports[`history > multiplayer undo/redo > should redraw arrows on undo > [end o
"showHyperlinkPopup": false, "showHyperlinkPopup": false,
"showWelcomeScreen": true, "showWelcomeScreen": true,
"snapLines": [], "snapLines": [],
"startBoundElement": null,
"stats": { "stats": {
"open": false, "open": false,
"panels": 3, "panels": 3,
@@ -11801,6 +11836,7 @@ exports[`history > multiplayer undo/redo > should update history entries after r
"showHyperlinkPopup": false, "showHyperlinkPopup": false,
"showWelcomeScreen": true, "showWelcomeScreen": true,
"snapLines": [], "snapLines": [],
"startBoundElement": null,
"stats": { "stats": {
"open": false, "open": false,
"panels": 3, "panels": 3,
@@ -12059,6 +12095,7 @@ exports[`history > singleplayer undo/redo > remounting undo/redo buttons should
"showHyperlinkPopup": false, "showHyperlinkPopup": false,
"showWelcomeScreen": false, "showWelcomeScreen": false,
"snapLines": [], "snapLines": [],
"startBoundElement": null,
"stats": { "stats": {
"open": false, "open": false,
"panels": 3, "panels": 3,
@@ -12296,6 +12333,7 @@ exports[`history > singleplayer undo/redo > should clear the redo stack on eleme
"showHyperlinkPopup": false, "showHyperlinkPopup": false,
"showWelcomeScreen": true, "showWelcomeScreen": true,
"snapLines": [], "snapLines": [],
"startBoundElement": null,
"stats": { "stats": {
"open": false, "open": false,
"panels": 3, "panels": 3,
@@ -12531,6 +12569,7 @@ exports[`history > singleplayer undo/redo > should create entry when selecting f
"showHyperlinkPopup": false, "showHyperlinkPopup": false,
"showWelcomeScreen": true, "showWelcomeScreen": true,
"snapLines": [], "snapLines": [],
"startBoundElement": null,
"stats": { "stats": {
"open": false, "open": false,
"panels": 3, "panels": 3,
@@ -12927,6 +12966,7 @@ exports[`history > singleplayer undo/redo > should create new history entry on e
"showHyperlinkPopup": false, "showHyperlinkPopup": false,
"showWelcomeScreen": true, "showWelcomeScreen": true,
"snapLines": [], "snapLines": [],
"startBoundElement": null,
"stats": { "stats": {
"open": false, "open": false,
"panels": 3, "panels": 3,
@@ -13134,6 +13174,7 @@ exports[`history > singleplayer undo/redo > should create new history entry on e
"showHyperlinkPopup": false, "showHyperlinkPopup": false,
"showWelcomeScreen": true, "showWelcomeScreen": true,
"snapLines": [], "snapLines": [],
"startBoundElement": null,
"stats": { "stats": {
"open": false, "open": false,
"panels": 3, "panels": 3,
@@ -13345,6 +13386,7 @@ exports[`history > singleplayer undo/redo > should create new history entry on i
"showHyperlinkPopup": false, "showHyperlinkPopup": false,
"showWelcomeScreen": true, "showWelcomeScreen": true,
"snapLines": [], "snapLines": [],
"startBoundElement": null,
"stats": { "stats": {
"open": false, "open": false,
"panels": 3, "panels": 3,
@@ -13643,6 +13685,7 @@ exports[`history > singleplayer undo/redo > should create new history entry on i
"showHyperlinkPopup": false, "showHyperlinkPopup": false,
"showWelcomeScreen": true, "showWelcomeScreen": true,
"snapLines": [], "snapLines": [],
"startBoundElement": null,
"stats": { "stats": {
"open": false, "open": false,
"panels": 3, "panels": 3,
@@ -13941,6 +13984,7 @@ exports[`history > singleplayer undo/redo > should create new history entry on s
"showHyperlinkPopup": false, "showHyperlinkPopup": false,
"showWelcomeScreen": false, "showWelcomeScreen": false,
"snapLines": [], "snapLines": [],
"startBoundElement": null,
"stats": { "stats": {
"open": false, "open": false,
"panels": 3, "panels": 3,
@@ -14185,6 +14229,7 @@ exports[`history > singleplayer undo/redo > should disable undo/redo buttons whe
"showHyperlinkPopup": false, "showHyperlinkPopup": false,
"showWelcomeScreen": false, "showWelcomeScreen": false,
"snapLines": [], "snapLines": [],
"startBoundElement": null,
"stats": { "stats": {
"open": false, "open": false,
"panels": 3, "panels": 3,
@@ -14422,6 +14467,7 @@ exports[`history > singleplayer undo/redo > should end up with no history entry
"showHyperlinkPopup": false, "showHyperlinkPopup": false,
"showWelcomeScreen": false, "showWelcomeScreen": false,
"snapLines": [], "snapLines": [],
"startBoundElement": null,
"stats": { "stats": {
"open": false, "open": false,
"panels": 3, "panels": 3,
@@ -14659,6 +14705,7 @@ exports[`history > singleplayer undo/redo > should iterate through the history w
"showHyperlinkPopup": false, "showHyperlinkPopup": false,
"showWelcomeScreen": true, "showWelcomeScreen": true,
"snapLines": [], "snapLines": [],
"startBoundElement": null,
"stats": { "stats": {
"open": false, "open": false,
"panels": 3, "panels": 3,
@@ -14906,6 +14953,7 @@ exports[`history > singleplayer undo/redo > should not clear the redo stack on s
"showHyperlinkPopup": false, "showHyperlinkPopup": false,
"showWelcomeScreen": true, "showWelcomeScreen": true,
"snapLines": [], "snapLines": [],
"startBoundElement": null,
"stats": { "stats": {
"open": false, "open": false,
"panels": 3, "panels": 3,
@@ -15238,6 +15286,7 @@ exports[`history > singleplayer undo/redo > should not collapse when applying co
"showHyperlinkPopup": false, "showHyperlinkPopup": false,
"showWelcomeScreen": true, "showWelcomeScreen": true,
"snapLines": [], "snapLines": [],
"startBoundElement": null,
"stats": { "stats": {
"open": false, "open": false,
"panels": 3, "panels": 3,
@@ -15410,6 +15459,7 @@ exports[`history > singleplayer undo/redo > should not end up with history entry
"showHyperlinkPopup": false, "showHyperlinkPopup": false,
"showWelcomeScreen": true, "showWelcomeScreen": true,
"snapLines": [], "snapLines": [],
"startBoundElement": null,
"stats": { "stats": {
"open": false, "open": false,
"panels": 3, "panels": 3,
@@ -15692,6 +15742,7 @@ exports[`history > singleplayer undo/redo > should not end up with history entry
"showHyperlinkPopup": false, "showHyperlinkPopup": false,
"showWelcomeScreen": true, "showWelcomeScreen": true,
"snapLines": [], "snapLines": [],
"startBoundElement": null,
"stats": { "stats": {
"open": false, "open": false,
"panels": 3, "panels": 3,
@@ -15955,6 +16006,7 @@ exports[`history > singleplayer undo/redo > should not modify anything on unrela
"showHyperlinkPopup": false, "showHyperlinkPopup": false,
"showWelcomeScreen": false, "showWelcomeScreen": false,
"snapLines": [], "snapLines": [],
"startBoundElement": null,
"stats": { "stats": {
"open": false, "open": false,
"panels": 3, "panels": 3,
@@ -16109,6 +16161,7 @@ exports[`history > singleplayer undo/redo > should not override appstate changes
"showHyperlinkPopup": false, "showHyperlinkPopup": false,
"showWelcomeScreen": true, "showWelcomeScreen": true,
"snapLines": [], "snapLines": [],
"startBoundElement": null,
"stats": { "stats": {
"open": false, "open": false,
"panels": 3, "panels": 3,
@@ -16390,6 +16443,7 @@ exports[`history > singleplayer undo/redo > should support appstate name or view
"showHyperlinkPopup": false, "showHyperlinkPopup": false,
"showWelcomeScreen": true, "showWelcomeScreen": true,
"snapLines": [], "snapLines": [],
"startBoundElement": null,
"stats": { "stats": {
"open": false, "open": false,
"panels": 3, "panels": 3,
@@ -16553,6 +16607,7 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding
"showHyperlinkPopup": false, "showHyperlinkPopup": false,
"showWelcomeScreen": true, "showWelcomeScreen": true,
"snapLines": [], "snapLines": [],
"startBoundElement": null,
"stats": { "stats": {
"open": false, "open": false,
"panels": 3, "panels": 3,
@@ -17301,6 +17356,7 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding
"showHyperlinkPopup": false, "showHyperlinkPopup": false,
"showWelcomeScreen": true, "showWelcomeScreen": true,
"snapLines": [], "snapLines": [],
"startBoundElement": null,
"stats": { "stats": {
"open": false, "open": false,
"panels": 3, "panels": 3,
@@ -17947,6 +18003,7 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding
"showHyperlinkPopup": false, "showHyperlinkPopup": false,
"showWelcomeScreen": true, "showWelcomeScreen": true,
"snapLines": [], "snapLines": [],
"startBoundElement": null,
"stats": { "stats": {
"open": false, "open": false,
"panels": 3, "panels": 3,
@@ -18591,6 +18648,7 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding
"showHyperlinkPopup": false, "showHyperlinkPopup": false,
"showWelcomeScreen": true, "showWelcomeScreen": true,
"snapLines": [], "snapLines": [],
"startBoundElement": null,
"stats": { "stats": {
"open": false, "open": false,
"panels": 3, "panels": 3,
@@ -19343,6 +19401,7 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding
"showHyperlinkPopup": false, "showHyperlinkPopup": false,
"showWelcomeScreen": true, "showWelcomeScreen": true,
"snapLines": [], "snapLines": [],
"startBoundElement": null,
"stats": { "stats": {
"open": false, "open": false,
"panels": 3, "panels": 3,
@@ -20111,6 +20170,7 @@ exports[`history > singleplayer undo/redo > should support changes in elements'
"showHyperlinkPopup": false, "showHyperlinkPopup": false,
"showWelcomeScreen": true, "showWelcomeScreen": true,
"snapLines": [], "snapLines": [],
"startBoundElement": null,
"stats": { "stats": {
"open": false, "open": false,
"panels": 3, "panels": 3,
@@ -20593,6 +20653,7 @@ exports[`history > singleplayer undo/redo > should support duplication of groups
"showHyperlinkPopup": false, "showHyperlinkPopup": false,
"showWelcomeScreen": true, "showWelcomeScreen": true,
"snapLines": [], "snapLines": [],
"startBoundElement": null,
"stats": { "stats": {
"open": false, "open": false,
"panels": 3, "panels": 3,
@@ -21099,6 +21160,7 @@ exports[`history > singleplayer undo/redo > should support element creation, del
"showHyperlinkPopup": false, "showHyperlinkPopup": false,
"showWelcomeScreen": true, "showWelcomeScreen": true,
"snapLines": [], "snapLines": [],
"startBoundElement": null,
"stats": { "stats": {
"open": false, "open": false,
"panels": 3, "panels": 3,
@@ -21560,6 +21622,7 @@ exports[`history > singleplayer undo/redo > should support linear element creati
"showHyperlinkPopup": false, "showHyperlinkPopup": false,
"showWelcomeScreen": true, "showWelcomeScreen": true,
"snapLines": [], "snapLines": [],
"startBoundElement": null,
"stats": { "stats": {
"open": false, "open": false,
"panels": 3, "panels": 3,
@@ -106,6 +106,7 @@ exports[`given element A and group of elements B and given both are selected whe
"showHyperlinkPopup": false, "showHyperlinkPopup": false,
"showWelcomeScreen": true, "showWelcomeScreen": true,
"snapLines": [], "snapLines": [],
"startBoundElement": null,
"stats": { "stats": {
"open": false, "open": false,
"panels": 3, "panels": 3,
@@ -534,6 +535,7 @@ exports[`given element A and group of elements B and given both are selected whe
"showHyperlinkPopup": false, "showHyperlinkPopup": false,
"showWelcomeScreen": true, "showWelcomeScreen": true,
"snapLines": [], "snapLines": [],
"startBoundElement": null,
"stats": { "stats": {
"open": false, "open": false,
"panels": 3, "panels": 3,
@@ -941,6 +943,7 @@ exports[`regression tests > Cmd/Ctrl-click exclusively select element under poin
"showHyperlinkPopup": false, "showHyperlinkPopup": false,
"showWelcomeScreen": true, "showWelcomeScreen": true,
"snapLines": [], "snapLines": [],
"startBoundElement": null,
"stats": { "stats": {
"open": false, "open": false,
"panels": 3, "panels": 3,
@@ -1507,6 +1510,7 @@ exports[`regression tests > Drags selected element when hitting only bounding bo
"showHyperlinkPopup": false, "showHyperlinkPopup": false,
"showWelcomeScreen": true, "showWelcomeScreen": true,
"snapLines": [], "snapLines": [],
"startBoundElement": null,
"stats": { "stats": {
"open": false, "open": false,
"panels": 3, "panels": 3,
@@ -1719,6 +1723,7 @@ exports[`regression tests > adjusts z order when grouping > [end of test] appSta
"showHyperlinkPopup": false, "showHyperlinkPopup": false,
"showWelcomeScreen": true, "showWelcomeScreen": true,
"snapLines": [], "snapLines": [],
"startBoundElement": null,
"stats": { "stats": {
"open": false, "open": false,
"panels": 3, "panels": 3,
@@ -2100,6 +2105,7 @@ exports[`regression tests > alt-drag duplicates an element > [end of test] appSt
"showHyperlinkPopup": false, "showHyperlinkPopup": false,
"showWelcomeScreen": true, "showWelcomeScreen": true,
"snapLines": [], "snapLines": [],
"startBoundElement": null,
"stats": { "stats": {
"open": false, "open": false,
"panels": 3, "panels": 3,
@@ -2343,6 +2349,7 @@ exports[`regression tests > arrow keys > [end of test] appState 1`] = `
"showHyperlinkPopup": false, "showHyperlinkPopup": false,
"showWelcomeScreen": true, "showWelcomeScreen": true,
"snapLines": [], "snapLines": [],
"startBoundElement": null,
"stats": { "stats": {
"open": false, "open": false,
"panels": 3, "panels": 3,
@@ -2525,6 +2532,7 @@ exports[`regression tests > can drag element that covers another element, while
"showHyperlinkPopup": false, "showHyperlinkPopup": false,
"showWelcomeScreen": true, "showWelcomeScreen": true,
"snapLines": [], "snapLines": [],
"startBoundElement": null,
"stats": { "stats": {
"open": false, "open": false,
"panels": 3, "panels": 3,
@@ -2848,6 +2856,7 @@ exports[`regression tests > change the properties of a shape > [end of test] app
"showHyperlinkPopup": false, "showHyperlinkPopup": false,
"showWelcomeScreen": true, "showWelcomeScreen": true,
"snapLines": [], "snapLines": [],
"startBoundElement": null,
"stats": { "stats": {
"open": false, "open": false,
"panels": 3, "panels": 3,
@@ -3105,6 +3114,7 @@ exports[`regression tests > click on an element and drag it > [dragged] appState
"showHyperlinkPopup": false, "showHyperlinkPopup": false,
"showWelcomeScreen": true, "showWelcomeScreen": true,
"snapLines": [], "snapLines": [],
"startBoundElement": null,
"stats": { "stats": {
"open": false, "open": false,
"panels": 3, "panels": 3,
@@ -3346,6 +3356,7 @@ exports[`regression tests > click on an element and drag it > [end of test] appS
"showHyperlinkPopup": false, "showHyperlinkPopup": false,
"showWelcomeScreen": true, "showWelcomeScreen": true,
"snapLines": [], "snapLines": [],
"startBoundElement": null,
"stats": { "stats": {
"open": false, "open": false,
"panels": 3, "panels": 3,
@@ -3582,6 +3593,7 @@ exports[`regression tests > click to select a shape > [end of test] appState 1`]
"showHyperlinkPopup": false, "showHyperlinkPopup": false,
"showWelcomeScreen": true, "showWelcomeScreen": true,
"snapLines": [], "snapLines": [],
"startBoundElement": null,
"stats": { "stats": {
"open": false, "open": false,
"panels": 3, "panels": 3,
@@ -3841,6 +3853,7 @@ exports[`regression tests > click-drag to select a group > [end of test] appStat
"showHyperlinkPopup": false, "showHyperlinkPopup": false,
"showWelcomeScreen": true, "showWelcomeScreen": true,
"snapLines": [], "snapLines": [],
"startBoundElement": null,
"stats": { "stats": {
"open": false, "open": false,
"panels": 3, "panels": 3,
@@ -4154,6 +4167,7 @@ exports[`regression tests > deleting last but one element in editing group shoul
"showHyperlinkPopup": false, "showHyperlinkPopup": false,
"showWelcomeScreen": true, "showWelcomeScreen": true,
"snapLines": [], "snapLines": [],
"startBoundElement": null,
"stats": { "stats": {
"open": false, "open": false,
"panels": 3, "panels": 3,
@@ -4617,6 +4631,7 @@ exports[`regression tests > deselects group of selected elements on pointer down
"showHyperlinkPopup": false, "showHyperlinkPopup": false,
"showWelcomeScreen": true, "showWelcomeScreen": true,
"snapLines": [], "snapLines": [],
"startBoundElement": null,
"stats": { "stats": {
"open": false, "open": false,
"panels": 3, "panels": 3,
@@ -4872,6 +4887,7 @@ exports[`regression tests > deselects group of selected elements on pointer up w
"showHyperlinkPopup": false, "showHyperlinkPopup": false,
"showWelcomeScreen": true, "showWelcomeScreen": true,
"snapLines": [], "snapLines": [],
"startBoundElement": null,
"stats": { "stats": {
"open": false, "open": false,
"panels": 3, "panels": 3,
@@ -5175,6 +5191,7 @@ exports[`regression tests > deselects selected element on pointer down when poin
"showHyperlinkPopup": false, "showHyperlinkPopup": false,
"showWelcomeScreen": true, "showWelcomeScreen": true,
"snapLines": [], "snapLines": [],
"startBoundElement": null,
"stats": { "stats": {
"open": false, "open": false,
"panels": 3, "panels": 3,
@@ -5355,6 +5372,7 @@ exports[`regression tests > deselects selected element, on pointer up, when clic
"showHyperlinkPopup": false, "showHyperlinkPopup": false,
"showWelcomeScreen": true, "showWelcomeScreen": true,
"snapLines": [], "snapLines": [],
"startBoundElement": null,
"stats": { "stats": {
"open": false, "open": false,
"panels": 3, "panels": 3,
@@ -5555,6 +5573,7 @@ exports[`regression tests > double click to edit a group > [end of test] appStat
"showHyperlinkPopup": false, "showHyperlinkPopup": false,
"showWelcomeScreen": true, "showWelcomeScreen": true,
"snapLines": [], "snapLines": [],
"startBoundElement": null,
"stats": { "stats": {
"open": false, "open": false,
"panels": 3, "panels": 3,
@@ -5952,6 +5971,7 @@ exports[`regression tests > drags selected elements from point inside common bou
"showHyperlinkPopup": false, "showHyperlinkPopup": false,
"showWelcomeScreen": true, "showWelcomeScreen": true,
"snapLines": [], "snapLines": [],
"startBoundElement": null,
"stats": { "stats": {
"open": false, "open": false,
"panels": 3, "panels": 3,
@@ -6243,6 +6263,7 @@ exports[`regression tests > draw every type of shape > [end of test] appState 1`
"showHyperlinkPopup": false, "showHyperlinkPopup": false,
"showWelcomeScreen": true, "showWelcomeScreen": true,
"snapLines": [], "snapLines": [],
"startBoundElement": null,
"stats": { "stats": {
"open": false, "open": false,
"panels": 3, "panels": 3,
@@ -7036,6 +7057,7 @@ exports[`regression tests > given a group of selected elements with an element t
"showHyperlinkPopup": false, "showHyperlinkPopup": false,
"showWelcomeScreen": true, "showWelcomeScreen": true,
"snapLines": [], "snapLines": [],
"startBoundElement": null,
"stats": { "stats": {
"open": false, "open": false,
"panels": 3, "panels": 3,
@@ -7370,6 +7392,7 @@ exports[`regression tests > given a selected element A and a not selected elemen
"showHyperlinkPopup": false, "showHyperlinkPopup": false,
"showWelcomeScreen": true, "showWelcomeScreen": true,
"snapLines": [], "snapLines": [],
"startBoundElement": null,
"stats": { "stats": {
"open": false, "open": false,
"panels": 3, "panels": 3,
@@ -7648,6 +7671,7 @@ exports[`regression tests > given selected element A with lower z-index than uns
"showHyperlinkPopup": false, "showHyperlinkPopup": false,
"showWelcomeScreen": true, "showWelcomeScreen": true,
"snapLines": [], "snapLines": [],
"startBoundElement": null,
"stats": { "stats": {
"open": false, "open": false,
"panels": 3, "panels": 3,
@@ -7883,6 +7907,7 @@ exports[`regression tests > given selected element A with lower z-index than uns
"showHyperlinkPopup": false, "showHyperlinkPopup": false,
"showWelcomeScreen": true, "showWelcomeScreen": true,
"snapLines": [], "snapLines": [],
"startBoundElement": null,
"stats": { "stats": {
"open": false, "open": false,
"panels": 3, "panels": 3,
@@ -8121,6 +8146,7 @@ exports[`regression tests > key 2 selects rectangle tool > [end of test] appStat
"showHyperlinkPopup": false, "showHyperlinkPopup": false,
"showWelcomeScreen": true, "showWelcomeScreen": true,
"snapLines": [], "snapLines": [],
"startBoundElement": null,
"stats": { "stats": {
"open": false, "open": false,
"panels": 3, "panels": 3,
@@ -8301,6 +8327,7 @@ exports[`regression tests > key 3 selects diamond tool > [end of test] appState
"showHyperlinkPopup": false, "showHyperlinkPopup": false,
"showWelcomeScreen": true, "showWelcomeScreen": true,
"snapLines": [], "snapLines": [],
"startBoundElement": null,
"stats": { "stats": {
"open": false, "open": false,
"panels": 3, "panels": 3,
@@ -8481,6 +8508,7 @@ exports[`regression tests > key 4 selects ellipse tool > [end of test] appState
"showHyperlinkPopup": false, "showHyperlinkPopup": false,
"showWelcomeScreen": true, "showWelcomeScreen": true,
"snapLines": [], "snapLines": [],
"startBoundElement": null,
"stats": { "stats": {
"open": false, "open": false,
"panels": 3, "panels": 3,
@@ -8691,6 +8719,7 @@ exports[`regression tests > key 5 selects arrow tool > [end of test] appState 1`
"showHyperlinkPopup": false, "showHyperlinkPopup": false,
"showWelcomeScreen": true, "showWelcomeScreen": true,
"snapLines": [], "snapLines": [],
"startBoundElement": null,
"stats": { "stats": {
"open": false, "open": false,
"panels": 3, "panels": 3,
@@ -8923,6 +8952,7 @@ exports[`regression tests > key 6 selects line tool > [end of test] appState 1`]
"showHyperlinkPopup": false, "showHyperlinkPopup": false,
"showWelcomeScreen": true, "showWelcomeScreen": true,
"snapLines": [], "snapLines": [],
"startBoundElement": null,
"stats": { "stats": {
"open": false, "open": false,
"panels": 3, "panels": 3,
@@ -9121,6 +9151,7 @@ exports[`regression tests > key 7 selects freedraw tool > [end of test] appState
"showHyperlinkPopup": false, "showHyperlinkPopup": false,
"showWelcomeScreen": true, "showWelcomeScreen": true,
"snapLines": [], "snapLines": [],
"startBoundElement": null,
"stats": { "stats": {
"open": false, "open": false,
"panels": 3, "panels": 3,
@@ -9345,6 +9376,7 @@ exports[`regression tests > key a selects arrow tool > [end of test] appState 1`
"showHyperlinkPopup": false, "showHyperlinkPopup": false,
"showWelcomeScreen": true, "showWelcomeScreen": true,
"snapLines": [], "snapLines": [],
"startBoundElement": null,
"stats": { "stats": {
"open": false, "open": false,
"panels": 3, "panels": 3,
@@ -9547,6 +9579,7 @@ exports[`regression tests > key d selects diamond tool > [end of test] appState
"showHyperlinkPopup": false, "showHyperlinkPopup": false,
"showWelcomeScreen": true, "showWelcomeScreen": true,
"snapLines": [], "snapLines": [],
"startBoundElement": null,
"stats": { "stats": {
"open": false, "open": false,
"panels": 3, "panels": 3,
@@ -9757,6 +9790,7 @@ exports[`regression tests > key l selects line tool > [end of test] appState 1`]
"showHyperlinkPopup": false, "showHyperlinkPopup": false,
"showWelcomeScreen": true, "showWelcomeScreen": true,
"snapLines": [], "snapLines": [],
"startBoundElement": null,
"stats": { "stats": {
"open": false, "open": false,
"panels": 3, "panels": 3,
@@ -9957,6 +9991,7 @@ exports[`regression tests > key o selects ellipse tool > [end of test] appState
"showHyperlinkPopup": false, "showHyperlinkPopup": false,
"showWelcomeScreen": true, "showWelcomeScreen": true,
"snapLines": [], "snapLines": [],
"startBoundElement": null,
"stats": { "stats": {
"open": false, "open": false,
"panels": 3, "panels": 3,
@@ -10135,6 +10170,7 @@ exports[`regression tests > key p selects freedraw tool > [end of test] appState
"showHyperlinkPopup": false, "showHyperlinkPopup": false,
"showWelcomeScreen": true, "showWelcomeScreen": true,
"snapLines": [], "snapLines": [],
"startBoundElement": null,
"stats": { "stats": {
"open": false, "open": false,
"panels": 3, "panels": 3,
@@ -10329,6 +10365,7 @@ exports[`regression tests > key r selects rectangle tool > [end of test] appStat
"showHyperlinkPopup": false, "showHyperlinkPopup": false,
"showWelcomeScreen": true, "showWelcomeScreen": true,
"snapLines": [], "snapLines": [],
"startBoundElement": null,
"stats": { "stats": {
"open": false, "open": false,
"panels": 3, "panels": 3,
@@ -10517,6 +10554,7 @@ exports[`regression tests > make a group and duplicate it > [end of test] appSta
"showHyperlinkPopup": false, "showHyperlinkPopup": false,
"showWelcomeScreen": true, "showWelcomeScreen": true,
"snapLines": [], "snapLines": [],
"startBoundElement": null,
"stats": { "stats": {
"open": false, "open": false,
"panels": 3, "panels": 3,
@@ -11042,6 +11080,7 @@ exports[`regression tests > noop interaction after undo shouldn't create history
"showHyperlinkPopup": false, "showHyperlinkPopup": false,
"showWelcomeScreen": true, "showWelcomeScreen": true,
"snapLines": [], "snapLines": [],
"startBoundElement": null,
"stats": { "stats": {
"open": false, "open": false,
"panels": 3, "panels": 3,
@@ -11318,6 +11357,7 @@ exports[`regression tests > pinch-to-zoom works > [end of test] appState 1`] = `
"showHyperlinkPopup": false, "showHyperlinkPopup": false,
"showWelcomeScreen": true, "showWelcomeScreen": true,
"snapLines": [], "snapLines": [],
"startBoundElement": null,
"stats": { "stats": {
"open": false, "open": false,
"panels": 3, "panels": 3,
@@ -11443,6 +11483,7 @@ exports[`regression tests > shift click on selected element should deselect it o
"showHyperlinkPopup": false, "showHyperlinkPopup": false,
"showWelcomeScreen": true, "showWelcomeScreen": true,
"snapLines": [], "snapLines": [],
"startBoundElement": null,
"stats": { "stats": {
"open": false, "open": false,
"panels": 3, "panels": 3,
@@ -11647,6 +11688,7 @@ exports[`regression tests > shift-click to multiselect, then drag > [end of test
"showHyperlinkPopup": false, "showHyperlinkPopup": false,
"showWelcomeScreen": true, "showWelcomeScreen": true,
"snapLines": [], "snapLines": [],
"startBoundElement": null,
"stats": { "stats": {
"open": false, "open": false,
"panels": 3, "panels": 3,
@@ -11968,6 +12010,7 @@ exports[`regression tests > should group elements and ungroup them > [end of tes
"showHyperlinkPopup": false, "showHyperlinkPopup": false,
"showWelcomeScreen": true, "showWelcomeScreen": true,
"snapLines": [], "snapLines": [],
"startBoundElement": null,
"stats": { "stats": {
"open": false, "open": false,
"panels": 3, "panels": 3,
@@ -12401,6 +12444,7 @@ exports[`regression tests > single-clicking on a subgroup of a selected group sh
"showHyperlinkPopup": false, "showHyperlinkPopup": false,
"showWelcomeScreen": true, "showWelcomeScreen": true,
"snapLines": [], "snapLines": [],
"startBoundElement": null,
"stats": { "stats": {
"open": false, "open": false,
"panels": 3, "panels": 3,
@@ -13032,6 +13076,7 @@ exports[`regression tests > spacebar + drag scrolls the canvas > [end of test] a
"showHyperlinkPopup": false, "showHyperlinkPopup": false,
"showWelcomeScreen": true, "showWelcomeScreen": true,
"snapLines": [], "snapLines": [],
"startBoundElement": null,
"stats": { "stats": {
"open": false, "open": false,
"panels": 3, "panels": 3,
@@ -13159,6 +13204,7 @@ exports[`regression tests > supports nested groups > [end of test] appState 1`]
"showHyperlinkPopup": false, "showHyperlinkPopup": false,
"showWelcomeScreen": true, "showWelcomeScreen": true,
"snapLines": [], "snapLines": [],
"startBoundElement": null,
"stats": { "stats": {
"open": false, "open": false,
"panels": 3, "panels": 3,
@@ -13819,6 +13865,7 @@ exports[`regression tests > switches from group of selected elements to another
"showHyperlinkPopup": false, "showHyperlinkPopup": false,
"showWelcomeScreen": true, "showWelcomeScreen": true,
"snapLines": [], "snapLines": [],
"startBoundElement": null,
"stats": { "stats": {
"open": false, "open": false,
"panels": 3, "panels": 3,
@@ -14157,6 +14204,7 @@ exports[`regression tests > switches selected element on pointer down > [end of
"showHyperlinkPopup": false, "showHyperlinkPopup": false,
"showWelcomeScreen": true, "showWelcomeScreen": true,
"snapLines": [], "snapLines": [],
"startBoundElement": null,
"stats": { "stats": {
"open": false, "open": false,
"panels": 3, "panels": 3,
@@ -14389,6 +14437,7 @@ exports[`regression tests > two-finger scroll works > [end of test] appState 1`]
"showHyperlinkPopup": false, "showHyperlinkPopup": false,
"showWelcomeScreen": true, "showWelcomeScreen": true,
"snapLines": [], "snapLines": [],
"startBoundElement": null,
"stats": { "stats": {
"open": false, "open": false,
"panels": 3, "panels": 3,
@@ -14514,6 +14563,7 @@ exports[`regression tests > undo/redo drawing an element > [end of test] appStat
"showHyperlinkPopup": false, "showHyperlinkPopup": false,
"showWelcomeScreen": true, "showWelcomeScreen": true,
"snapLines": [], "snapLines": [],
"startBoundElement": null,
"stats": { "stats": {
"open": false, "open": false,
"panels": 3, "panels": 3,
@@ -14876,6 +14926,7 @@ exports[`regression tests > updates fontSize & fontFamily appState > [end of tes
"showHyperlinkPopup": false, "showHyperlinkPopup": false,
"showWelcomeScreen": true, "showWelcomeScreen": true,
"snapLines": [], "snapLines": [],
"startBoundElement": null,
"stats": { "stats": {
"open": false, "open": false,
"panels": 3, "panels": 3,
@@ -15002,6 +15053,7 @@ exports[`regression tests > zoom hotkeys > [end of test] appState 1`] = `
"showHyperlinkPopup": false, "showHyperlinkPopup": false,
"showWelcomeScreen": true, "showWelcomeScreen": true,
"snapLines": [], "snapLines": [],
"startBoundElement": null,
"stats": { "stats": {
"open": false, "open": false,
"panels": 3, "panels": 3,
@@ -1,73 +0,0 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { AnimationController } from "../renderer/animation";
const FIRST_KEY = "animation-test-first";
const SECOND_KEY = "animation-test-second";
describe("AnimationController", () => {
beforeEach(() => {
vi.useFakeTimers();
window.EXCALIDRAW_THROTTLE_RENDER = false;
});
afterEach(() => {
AnimationController.cancel(FIRST_KEY);
AnimationController.cancel(SECOND_KEY);
window.EXCALIDRAW_THROTTLE_RENDER = undefined;
vi.useRealTimers();
});
it("starts a new animation after the previous last animation was cancelled", async () => {
let firstFrames = 0;
AnimationController.start(FIRST_KEY, () => {
firstFrames++;
return { keep: true };
});
expect(firstFrames).toBe(1);
AnimationController.cancel(FIRST_KEY);
await vi.runOnlyPendingTimersAsync();
let secondFrames = 0;
AnimationController.start(SECOND_KEY, () => {
secondFrames++;
return secondFrames === 1 ? { keep: true } : null;
});
expect(secondFrames).toBe(1);
await vi.runOnlyPendingTimersAsync();
expect(secondFrames).toBe(2);
expect(AnimationController.running(SECOND_KEY)).toBe(false);
});
it("cancels a frame scheduled during a tick if no animations remain", async () => {
let firstFrames = 0;
let secondFrames = 0;
AnimationController.start(FIRST_KEY, ({ state }) => {
if (!state) {
return { keep: true };
}
firstFrames++;
AnimationController.start(SECOND_KEY, () => {
secondFrames++;
return { keep: true };
});
AnimationController.cancel(SECOND_KEY);
return null;
});
await vi.runOnlyPendingTimersAsync();
expect(firstFrames).toBe(1);
expect(secondFrames).toBe(1);
expect(vi.getTimerCount()).toBe(0);
});
});
+7 -90
View File
@@ -305,88 +305,8 @@ describe("pasting & frames", () => {
await waitFor(() => { await waitFor(() => {
expect(h.elements.length).toBe(2); expect(h.elements.length).toBe(2);
expect(h.elements[0].type).toBe(rect.type);
expect(h.elements[0].frameId).toBe(frame.id);
expect(h.elements[1].id).toBe(frame.id);
expect(h.elements[0].index! < frame.index!).toBe(true);
});
});
it("should layer pasted elements above the highest frame child", async () => {
const frame = API.createElement({
type: "frame",
width: 100,
height: 100,
x: 0,
y: 0,
});
const frameChild = API.createElement({
id: "frameChild",
type: "rectangle",
x: 10,
y: 10,
width: 20,
height: 20,
frameId: frame.id,
});
const rect = API.createElement({ type: "rectangle" });
API.setElements([frameChild, frame]);
const clipboardJSON = await serializeAsClipboardJSON({
elements: [rect],
files: null,
});
mouse.moveTo(50, 50);
pasteWithCtrlCmdV(clipboardJSON);
await waitFor(() => {
expect(h.elements.length).toBe(3);
expect(h.elements[1].type).toBe(rect.type); expect(h.elements[1].type).toBe(rect.type);
expect(h.elements[1].frameId).toBe(frame.id); expect(h.elements[1].frameId).toBe(frame.id);
expect(h.elements.map((element) => element.id)).toEqual([
frameChild.id,
h.elements[1].id,
frame.id,
]);
expect(h.elements[1].index! > frameChild.index!).toBe(true);
expect(h.elements[1].index! < frame.index!).toBe(true);
});
});
it("should preserve denormalized pasted frame child order", async () => {
const frame = API.createElement({
type: "frame",
width: 100,
height: 100,
x: 0,
y: 0,
});
const frameChild = API.createElement({
type: "rectangle",
x: 10,
y: 10,
width: 20,
height: 20,
frameId: frame.id,
});
const clipboardJSON = await serializeAsClipboardJSON({
elements: [frame, frameChild],
files: null,
});
mouse.moveTo(200, 200);
pasteWithCtrlCmdV(clipboardJSON);
await waitFor(() => {
expect(h.elements.length).toBe(2);
expect(h.elements[0].type).toBe(frame.type);
expect(h.elements[1].type).toBe(frameChild.type);
expect(h.elements[1].frameId).toBe(h.elements[0].id);
}); });
}); });
@@ -459,9 +379,8 @@ describe("pasting & frames", () => {
await waitFor(() => { await waitFor(() => {
expect(h.elements.length).toBe(3); expect(h.elements.length).toBe(3);
expect(h.elements[0].type).toBe(rect.type); expect(h.elements[1].type).toBe(rect.type);
expect(h.elements[0].frameId).toBe(frame.id); expect(h.elements[1].frameId).toBe(frame.id);
expect(h.elements[1].id).toBe(frame.id);
expect(h.elements[2].type).toBe(rect2.type); expect(h.elements[2].type).toBe(rect2.type);
expect(h.elements[2].frameId).toBe(null); expect(h.elements[2].frameId).toBe(null);
}); });
@@ -503,11 +422,10 @@ describe("pasting & frames", () => {
await waitFor(() => { await waitFor(() => {
expect(h.elements.length).toBe(3); expect(h.elements.length).toBe(3);
expect(h.elements[0].type).toBe(rect.type); expect(h.elements[1].type).toBe(rect.type);
expect(h.elements[0].frameId).toBe(frame.id);
expect(h.elements[1].type).toBe(rect2.type);
expect(h.elements[1].frameId).toBe(frame.id); expect(h.elements[1].frameId).toBe(frame.id);
expect(h.elements[2].id).toBe(frame.id); expect(h.elements[2].type).toBe(rect2.type);
expect(h.elements[2].frameId).toBe(frame.id);
}); });
}); });
@@ -555,9 +473,8 @@ describe("pasting & frames", () => {
await waitFor(() => { await waitFor(() => {
expect(h.elements.length).toBe(4); expect(h.elements.length).toBe(4);
expect(h.elements[0].type).toBe(rect.type); expect(h.elements[1].type).toBe(rect.type);
expect(h.elements[0].frameId).toBe(frame.id); expect(h.elements[1].frameId).toBe(frame.id);
expect(h.elements[1].id).toBe(frame.id);
expect(h.elements[2].type).toBe(rect2.type); expect(h.elements[2].type).toBe(rect2.type);
expect(h.elements[2].frameId).toBe(h.elements[3].id); expect(h.elements[2].frameId).toBe(h.elements[3].id);
expect(h.elements[3].type).toBe(frame2.type); expect(h.elements[3].type).toBe(frame2.type);
@@ -160,39 +160,6 @@ describe("restoreElements", () => {
}); });
}); });
it("should restore only valid freedraw points and keep pressures aligned", () => {
const freedrawElement = API.createElement({
type: "freedraw",
id: "id-freedraw-invalid-points",
points: [pointFrom(0, 0), pointFrom(10, 10)],
});
const restoredFreedraw = restore.restoreElements(
[
{
...freedrawElement,
simulatePressure: false,
points: [
pointFrom(0, 0),
[Infinity, 10],
null,
pointFrom(20, 20),
[NaN, 30],
[40, null],
],
pressures: [0.1, 0.2, 0.3, 0.4, 0.5, 0.6],
} as any,
],
null,
)[0] as ExcalidrawFreeDrawElement;
expect(restoredFreedraw.points).toEqual([
pointFrom(0, 0),
pointFrom(20, 20),
]);
expect(restoredFreedraw.pressures).toEqual([0.1, 0.4]);
});
it("should restore line and draw elements correctly", () => { it("should restore line and draw elements correctly", () => {
const lineElement = API.createElement({ type: "line", id: "id-line01" }); const lineElement = API.createElement({ type: "line", id: "id-line01" });
@@ -433,52 +400,6 @@ describe("restoreElements", () => {
expect(restoredLine.points).toMatchObject(expectedLinePoints); expect(restoredLine.points).toMatchObject(expectedLinePoints);
}); });
it("should restore only valid linear points", () => {
const lineElement: any = API.createElement({
type: "line",
x: 10,
y: 20,
width: 100,
height: 200,
});
const arrowElement: any = API.createElement({
type: "arrow",
width: 100,
height: 200,
});
lineElement.points = [
[2, 3],
null,
[Infinity, 4],
[5, 7],
[NaN, 8],
[9, null],
];
arrowElement.points = [
[null, 0],
[Infinity, 4],
];
const restoredElements = restore.restoreElements(
[lineElement, arrowElement],
null,
);
const restoredLine = restoredElements[0] as ExcalidrawLinearElement;
const restoredArrow = restoredElements[1] as ExcalidrawArrowElement;
expect(restoredLine.points).toEqual([pointFrom(0, 0), pointFrom(3, 4)]);
expect(restoredLine.x).toBe(12);
expect(restoredLine.y).toBe(23);
expect(restoredLine.width).toBe(3);
expect(restoredLine.height).toBe(4);
expect(restoredArrow.points).toEqual([
pointFrom(0, 0),
pointFrom(100, 200),
]);
});
it("when the number of points of a line is greater or equal 2", () => { it("when the number of points of a line is greater or equal 2", () => {
const lineElement_0 = API.createElement({ const lineElement_0 = API.createElement({
type: "line", type: "line",
@@ -94,42 +94,3 @@ export const testPolyfills = {
// https://github.com/vitest-dev/vitest/pull/4164#issuecomment-2172729965 // https://github.com/vitest-dev/vitest/pull/4164#issuecomment-2172729965
URL, URL,
}; };
export const PolyfillLocalStorage = () => {
// Node.js 25+ provides a native localStorage global that shadows jsdom's,
// and jsdom's own localStorage also uses the native one -- both are broken
// (empty objects without Storage methods). On older Node versions, jsdom
// provides a working localStorage. This polyfill replaces localStorage on
// all supported versions with a standard Storage implementation backed by
// a Map, ensuring consistent behavior regardless of the Node.js version.
const storage = new Map<string, string>();
const storagePolyfill: Storage = {
get length() {
return storage.size;
},
clear() {
storage.clear();
},
key(index) {
return Array.from(storage.keys())[index] ?? null;
},
getItem(key) {
return storage.get(key) ?? null;
},
setItem(key, value) {
storage.set(key, value);
},
removeItem(key) {
storage.delete(key);
},
*[Symbol.iterator]() {
yield* storage.entries();
},
};
Object.defineProperty(window, "localStorage", {
value: storagePolyfill,
writable: true,
configurable: true,
});
};
+1 -1
View File
@@ -5132,7 +5132,7 @@ describe("history", () => {
}), }),
endBinding: expect.objectContaining({ endBinding: expect.objectContaining({
elementId: rect2.id, elementId: rect2.id,
fixedPoint: expect.arrayContaining([0.5001, 0.5001]), fixedPoint: expect.arrayContaining([0, 0.5001]),
}), }),
isDeleted: true, isDeleted: true,
}), }),
+2 -63
View File
@@ -1,4 +1,4 @@
import { MIME_TYPES, randomId, reseed } from "@excalidraw/common"; import { randomId, reseed } from "@excalidraw/common";
import type { FileId } from "@excalidraw/element/types"; import type { FileId } from "@excalidraw/element/types";
@@ -17,41 +17,18 @@ import {
} from "./fixtures/constants"; } from "./fixtures/constants";
import { INITIALIZED_IMAGE_PROPS } from "./helpers/constants"; import { INITIALIZED_IMAGE_PROPS } from "./helpers/constants";
import type { ExcalidrawProps } from "../types";
const { h } = window; const { h } = window;
export const setupImageTest = async ( export const setupImageTest = async (
sizes: { width: number; height: number }[], sizes: { width: number; height: number }[],
props?: ExcalidrawProps,
) => { ) => {
await render( await render(<Excalidraw autoFocus={true} handleKeyboardGlobally={true} />);
<Excalidraw autoFocus={true} handleKeyboardGlobally={true} {...props} />,
);
h.state.height = 1000; h.state.height = 1000;
mockMultipleHTMLImageElements(sizes.map((size) => [size.width, size.height])); mockMultipleHTMLImageElements(sizes.map((size) => [size.width, size.height]));
}; };
describe("resizeImageFile", () => {
beforeEach(() => {
vi.unstubAllGlobals();
});
it("returns the original file when it already fits the max dimensions", async () => {
mockMultipleHTMLImageElements([[100, 100]]);
const imageFile = new File([new Uint8Array([1, 2, 3])], "image.png", {
type: MIME_TYPES.png,
});
await expect(
blobModule.resizeImageFile(imageFile, { maxWidthOrHeight: 200 }),
).resolves.toBe(imageFile);
});
});
describe("image insertion", () => { describe("image insertion", () => {
beforeEach(() => { beforeEach(() => {
vi.clearAllMocks(); vi.clearAllMocks();
@@ -135,42 +112,4 @@ describe("image insertion", () => {
await assert(); await assert();
}); });
it("passes host-configured max image dimensions to the resize helper", async () => {
await setupImageTest([DEER_IMAGE_DIMENSIONS], {
imageOptions: { maxWidthOrHeight: 2048 },
});
await API.drop([
{ kind: "file", file: await API.loadFile("./fixtures/deer.png") },
]);
await waitFor(() => {
expect(blobModule.resizeImageFile).toHaveBeenCalledWith(
expect.any(File),
{ maxWidthOrHeight: 2048 },
);
});
});
it("enforces host-configured max image file size", async () => {
await setupImageTest([DEER_IMAGE_DIMENSIONS], {
imageOptions: { maxFileSizeBytes: 1024 * 1024 },
});
await API.drop([
{
kind: "file",
file: new File([new Uint8Array(2 * 1024 * 1024)], "image.png", {
type: MIME_TYPES.png,
}),
},
]);
await waitFor(() => {
expect(h.state.errorMessage).toBe(
"File is too big. Maximum allowed size is 1MB.",
);
});
});
}); });
+1 -33
View File
@@ -10,7 +10,7 @@ import { API } from "./helpers/api";
import { Pointer } from "./helpers/ui"; import { Pointer } from "./helpers/ui";
import { act, GlobalTestState, render, waitFor } from "./test-utils"; import { act, GlobalTestState, render, waitFor } from "./test-utils";
import type { Collaborator, ExcalidrawProps, SocketId } from "../types"; import type { ExcalidrawProps } from "../types";
describe("laser tool interactions", () => { describe("laser tool interactions", () => {
const h = window.h; const h = window.h;
@@ -128,36 +128,4 @@ describe("laser tool interactions", () => {
expect(h.state.scrollY).toBe(initialScrollY); expect(h.state.scrollY).toBe(initialScrollY);
expect(GlobalTestState.interactiveCanvas.style.cursor).toContain(""); expect(GlobalTestState.interactiveCanvas.style.cursor).toContain("");
}); });
it("cleans up remote laser trails when the last collaborator leaves", async () => {
await render(<Excalidraw />);
const socketId = "socket-id" as SocketId;
const collaborators = new Map<SocketId, Collaborator>([
[
socketId,
{
pointer: {
x: 10,
y: 10,
tool: "laser",
},
button: "down",
},
],
]);
const svgLayer = document.querySelector(".SVGLayer svg")!;
act(() => {
h.app.updateScene({ collaborators });
});
expect(svgLayer.querySelectorAll("path")).toHaveLength(1);
act(() => {
h.app.updateScene({ collaborators: new Map() });
});
expect(svgLayer.querySelectorAll("path")).toHaveLength(0);
});
}); });
+4 -14
View File
@@ -16,8 +16,6 @@ import * as StaticScene from "../renderer/staticScene";
import { UI, Pointer, Keyboard } from "./helpers/ui"; import { UI, Pointer, Keyboard } from "./helpers/ui";
import { render, fireEvent, act, unmountComponent } from "./test-utils"; import { render, fireEvent, act, unmountComponent } from "./test-utils";
import type { Zoom } from "../types";
unmountComponent(); unmountComponent();
const renderInteractiveScene = vi.spyOn( const renderInteractiveScene = vi.spyOn(
@@ -90,7 +88,6 @@ describe("move element", () => {
"orbit", "orbit",
"start", "start",
h.app.scene, h.app.scene,
{ value: 1 } as Zoom,
); );
bindBindingElement( bindBindingElement(
arrow.get() as NonDeleted<ExcalidrawArrowElement>, arrow.get() as NonDeleted<ExcalidrawArrowElement>,
@@ -98,7 +95,6 @@ describe("move element", () => {
"orbit", "orbit",
"end", "end",
h.app.scene, h.app.scene,
{ value: 1 } as Zoom,
); );
}); });
@@ -115,13 +111,10 @@ describe("move element", () => {
expect([rectA.x, rectA.y]).toEqual([0, 0]); expect([rectA.x, rectA.y]).toEqual([0, 0]);
expect([rectB.x, rectB.y]).toEqual([200, 0]); expect([rectB.x, rectB.y]).toEqual([200, 0]);
expect([[arrow.x, arrow.y]]).toCloselyEqualPoints( expect([[arrow.x, arrow.y]]).toCloselyEqualPoints(
[[106, 56.011199999998695]], [[106.00000000000001, 55.6867741935484]],
0,
);
expect([[arrow.width, arrow.height]]).toCloselyEqualPoints(
[[88, 88.01760000000121]],
0, 0,
); );
expect([[arrow.width, arrow.height]]).toCloselyEqualPoints([[88, 88]], 0);
renderInteractiveScene.mockClear(); renderInteractiveScene.mockClear();
renderStaticScene.mockClear(); renderStaticScene.mockClear();
@@ -140,13 +133,10 @@ describe("move element", () => {
expect([rectA.x, rectA.y]).toEqual([0, 0]); expect([rectA.x, rectA.y]).toEqual([0, 0]);
expect([rectB.x, rectB.y]).toEqual([201, 2]); expect([rectB.x, rectB.y]).toEqual([201, 2]);
expect([[arrow.x, arrow.y]]).toCloselyEqualPoints( expect([[arrow.x, arrow.y]]).toCloselyEqualPoints(
[[106, 56.011199999998695]], [[106, 55.6867741935484]],
0,
);
expect([[arrow.width, arrow.height]]).toCloselyEqualPoints(
[[89, 90.01760000000121]],
0, 0,
); );
expect([[arrow.width, arrow.height]]).toCloselyEqualPoints([[89, 90]], 0);
h.elements.forEach((element) => expect(element).toMatchSnapshot()); h.elements.forEach((element) => expect(element).toMatchSnapshot());
}); });
+3 -336
View File
@@ -1,12 +1,8 @@
import React from "react"; import React from "react";
import { vi } from "vitest"; import { vi } from "vitest";
import { KEYS, ROUNDNESS, arrayToMap, reseed } from "@excalidraw/common"; import { KEYS, ROUNDNESS, reseed } from "@excalidraw/common";
import { import { getElementBounds, getElementLineSegments } from "@excalidraw/element";
getElementBounds,
getElementLineSegments,
getElementsWithinSelection,
} from "@excalidraw/element";
import { pointFrom, pointRotateRads, type LocalPoint } from "@excalidraw/math"; import { pointFrom, pointRotateRads, type LocalPoint } from "@excalidraw/math";
import { SHAPES } from "../components/shapes"; import { SHAPES } from "../components/shapes";
@@ -273,145 +269,6 @@ describe("box-selection overlap mode", () => {
assertSelectedElements([rect1.id]); assertSelectedElements([rect1.id]);
}); });
it("should select the whole group when overlapping one group member", () => {
const rect1 = API.createElement({
type: "rectangle",
x: 0,
y: 0,
width: 50,
height: 50,
groupIds: ["A"],
});
const rect2 = API.createElement({
type: "rectangle",
x: 100,
y: 0,
width: 50,
height: 50,
groupIds: ["A"],
});
API.setElements([rect1, rect2]);
boxSelect(25, -20, 75, 70);
assertSelectedElements([rect1.id, rect2.id]);
expect(h.state.selectedGroupIds).toEqual({ A: true });
});
it("should return all group elements when overlapping one group member", () => {
const rect1 = API.createElement({
type: "rectangle",
id: "rect1",
x: 0,
y: 0,
width: 50,
height: 50,
groupIds: ["A"],
});
const rect2 = API.createElement({
type: "rectangle",
id: "rect2",
x: 100,
y: 0,
width: 50,
height: 50,
groupIds: ["A"],
});
const rect3 = API.createElement({
type: "rectangle",
id: "rect3",
x: 200,
y: 0,
width: 50,
height: 50,
});
const selection = API.createElement({
type: "rectangle",
x: 125,
y: -10,
width: 10,
height: 70,
});
const elements = [rect1, rect2, rect3];
expect(
getElementsWithinSelection(
elements,
selection,
arrayToMap([...elements, selection]),
false,
"overlap",
).map((element) => element.id),
).toEqual([rect1.id, rect2.id]);
});
it("should retain nested and interleaved group element order", () => {
const outerNested1 = API.createElement({
type: "rectangle",
id: "outerNested1",
x: 0,
y: 0,
width: 50,
height: 50,
groupIds: ["inner", "outer"],
});
const other1 = API.createElement({
type: "rectangle",
id: "other1",
x: 70,
y: 0,
width: 50,
height: 50,
groupIds: ["other"],
});
const outerOnly = API.createElement({
type: "rectangle",
id: "outerOnly",
x: 140,
y: 0,
width: 50,
height: 50,
groupIds: ["outer"],
});
const other2 = API.createElement({
type: "rectangle",
id: "other2",
x: 210,
y: 0,
width: 50,
height: 50,
groupIds: ["other"],
});
const outerNested2 = API.createElement({
type: "rectangle",
id: "outerNested2",
x: 280,
y: 0,
width: 50,
height: 50,
groupIds: ["inner", "outer"],
});
const selection = API.createElement({
type: "rectangle",
x: 295,
y: -10,
width: 10,
height: 70,
});
const elements = [outerNested1, other1, outerOnly, other2, outerNested2];
expect(
getElementsWithinSelection(
elements,
selection,
arrayToMap([...elements, selection]),
false,
"overlap",
).map((element) => element.id),
).toEqual([outerNested1.id, outerOnly.id, outerNested2.id]);
});
it("should not select a transparent rectangle when the selection box stays inside it", () => { it("should not select a transparent rectangle when the selection box stays inside it", () => {
const rect1 = API.createElement({ const rect1 = API.createElement({
type: "rectangle", type: "rectangle",
@@ -758,32 +615,6 @@ describe("box-selection overlap mode", () => {
assertSelectedElements([]); assertSelectedElements([]);
}); });
it("should not select a framed element when selection only overlaps its clipped-out outline", () => {
const frame = API.createElement({
type: "frame",
x: 100,
y: 100,
width: 100,
height: 100,
});
const rect1 = API.createElement({
type: "rectangle",
x: 50,
y: 50,
width: 200,
height: 200,
frameId: frame.id,
backgroundColor: "red",
fillStyle: "solid",
});
API.setElements([frame, rect1]);
boxSelect(40, 170, 70, 220);
assertSelectedElements([]);
});
}); });
describe("inner box-selection", () => { describe("inner box-selection", () => {
@@ -859,175 +690,11 @@ describe("inner box-selection", () => {
mouse.moveTo(rect2.x + rect2.width + 10, rect2.y + rect2.height + 10); mouse.moveTo(rect2.x + rect2.width + 10, rect2.y + rect2.height + 10);
mouse.up(); mouse.up();
assertSelectedElements([rect1.id]);
expect(h.state.selectedGroupIds).toEqual({});
});
Keyboard.withModifierKeys({ ctrl: true }, () => {
mouse.downAt(40, 40);
mouse.move(-1000, -1000);
mouse.moveTo(rect3.x + rect3.width + 10, rect3.y + rect3.height + 10);
mouse.up();
assertSelectedElements([rect2.id, rect3.id]); assertSelectedElements([rect2.id, rect3.id]);
expect(h.state.selectedGroupIds).toEqual({ A: true }); expect(h.state.selectedGroupIds).toEqual({ A: true });
}); });
}); });
it("does not select a nested outer group until all members are contained", async () => {
const innerRect1 = API.createElement({
type: "rectangle",
x: 50,
y: 50,
width: 50,
height: 50,
groupIds: ["inner", "outer"],
});
const innerRect2 = API.createElement({
type: "rectangle",
x: 120,
y: 50,
width: 50,
height: 50,
groupIds: ["inner", "outer"],
});
const outerRect = API.createElement({
type: "rectangle",
x: 190,
y: 50,
width: 50,
height: 50,
groupIds: ["outer"],
});
API.setElements([innerRect1, innerRect2, outerRect]);
Keyboard.withModifierKeys({ ctrl: true }, () => {
mouse.downAt(0, 0);
mouse.move(-1000, -1000);
mouse.moveTo(
innerRect2.x + innerRect2.width + 10,
innerRect2.y + innerRect2.height + 10,
);
mouse.up();
assertSelectedElements([]);
expect(h.state.selectedGroupIds).toEqual({});
});
Keyboard.withModifierKeys({ ctrl: true }, () => {
mouse.downAt(0, 0);
mouse.move(-1000, -1000);
mouse.moveTo(
outerRect.x + outerRect.width + 10,
outerRect.y + outerRect.height + 10,
);
mouse.up();
assertSelectedElements([innerRect1.id, innerRect2.id, outerRect.id]);
expect(h.state.selectedGroupIds).toEqual({ outer: true });
});
});
it.skip("checks nested containment against the current editing depth", async () => {
const innerRect1 = API.createElement({
type: "rectangle",
x: 50,
y: 50,
width: 50,
height: 50,
groupIds: ["inner", "outer"],
});
const innerRect2 = API.createElement({
type: "rectangle",
x: 120,
y: 50,
width: 50,
height: 50,
groupIds: ["inner", "outer"],
});
const outerRect = API.createElement({
type: "rectangle",
x: 190,
y: 50,
width: 50,
height: 50,
groupIds: ["outer"],
});
const selection = API.createElement({
type: "rectangle",
x: 40,
y: 40,
width: 140,
height: 70,
});
const elements = [innerRect1, innerRect2, outerRect];
const elementsMap = arrayToMap([...elements, selection]);
expect(
getElementsWithinSelection(
elements,
selection,
elementsMap,
false,
"contain",
).map((element) => element.id),
).toEqual([]);
expect(
getElementsWithinSelection(
elements,
selection,
elementsMap,
false,
"contain",
// "outer", /* editingGroupId - add as param once we implement nested group handling */
).map((element) => element.id),
).toEqual([innerRect1.id, innerRect2.id]);
});
it("ignores grouped bound text when checking box-selection containment", async () => {
const container = API.createElement({
type: "rectangle",
id: "container",
x: 50,
y: 50,
width: 50,
height: 50,
groupIds: ["A"],
boundElements: [{ type: "text", id: "bound-text" }],
});
const boundText = API.createElement({
type: "text",
id: "bound-text",
x: 50,
y: 50,
width: 50,
height: 20,
containerId: container.id,
groupIds: ["A"],
});
const rect = API.createElement({
type: "rectangle",
x: 150,
y: 150,
width: 50,
height: 50,
groupIds: ["A"],
});
API.setElements([container, boundText, rect]);
Keyboard.withModifierKeys({ ctrl: true }, () => {
mouse.downAt(40, 40);
mouse.move(-1000, -1000);
mouse.moveTo(rect.x + rect.width + 10, rect.y + rect.height + 10);
mouse.up();
expect(h.state.selectedElementIds[container.id]).toBe(true);
expect(h.state.selectedElementIds[rect.id]).toBe(true);
expect(h.state.selectedGroupIds).toEqual({ A: true });
});
});
it("selecting & deselecting grouped elements visually nested inside another", async () => { it("selecting & deselecting grouped elements visually nested inside another", async () => {
const rect1 = API.createElement({ const rect1 = API.createElement({
type: "rectangle", type: "rectangle",
@@ -1058,7 +725,7 @@ describe("inner box-selection", () => {
Keyboard.withModifierKeys({ ctrl: true }, () => { Keyboard.withModifierKeys({ ctrl: true }, () => {
mouse.downAt(rect2.x - 20, rect2.y - 20); mouse.downAt(rect2.x - 20, rect2.y - 20);
mouse.move(-1000, -1000); mouse.move(-1000, -1000);
mouse.moveTo(rect3.x + rect3.width + 10, rect3.y + rect3.height + 10); mouse.moveTo(rect2.x + rect2.width + 10, rect2.y + rect2.height + 10);
assertSelectedElements([rect2.id, rect3.id]); assertSelectedElements([rect2.id, rect3.id]);
expect(h.state.selectedGroupIds).toEqual({ A: true }); expect(h.state.selectedGroupIds).toEqual({ A: true });
mouse.moveTo(rect2.x - 10, rect2.y - 10); mouse.moveTo(rect2.x - 10, rect2.y - 10);
+19 -20
View File
@@ -449,29 +449,28 @@ const stripProps = (
}, {} as Record<string, any>); }, {} as Record<string, any>);
export const checkpointHistory = (history: History, name: string) => { export const checkpointHistory = (history: History, name: string) => {
expect( const normalizeHistoryEntry = (entry: History["undoStack"][number]) => {
history.undoStack.map((x) => ({ const { markers, ...rest } = entry;
...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`);
expect( return {
history.redoStack.map((x) => ({ ...rest,
...x, ...(markers ? { markers } : {}),
elements: { elements: {
...x.elements, ...entry.elements,
added: stripProps(x.elements.added, ["seed", "versionNonce"]), added: stripProps(entry.elements.added, ["seed", "versionNonce"]),
removed: stripProps(x.elements.removed, ["seed", "versionNonce"]), removed: stripProps(entry.elements.removed, ["seed", "versionNonce"]),
updated: stripProps(x.elements.updated, ["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`,
);
}; };
/** /**
+1 -27
View File
@@ -4,12 +4,10 @@ import { resolvablePromise } from "@excalidraw/common";
import { Excalidraw } from "../index"; import { Excalidraw } from "../index";
import { getToolbarTools } from "../components/shapes";
import { Pointer } from "./helpers/ui"; import { Pointer } from "./helpers/ui";
import { act, render } from "./test-utils"; import { act, render } from "./test-utils";
import type { AppClassProperties, ExcalidrawImperativeAPI } from "../types"; import type { ExcalidrawImperativeAPI } from "../types";
describe("setActiveTool()", () => { describe("setActiveTool()", () => {
const h = window.h; const h = window.h;
@@ -68,27 +66,3 @@ describe("setActiveTool()", () => {
expect(h.state.activeTool.customType).toBe("comment"); expect(h.state.activeTool.customType).toBe("comment");
}); });
}); });
describe("getToolbarTools()", () => {
const getToolValues = (preferredSelectionTool: "selection" | "lasso") =>
getToolbarTools({
state: {
preferredSelectionTool: {
type: preferredSelectionTool,
},
},
} as AppClassProperties).map((tool) => tool.value);
it("does not include lasso when selection is preferred", () => {
const toolValues = getToolValues("selection");
expect(toolValues.filter((value) => value === "selection")).toHaveLength(1);
expect(toolValues.filter((value) => value === "lasso")).toHaveLength(0);
});
it("replaces selection with lasso when lasso is preferred", () => {
const toolValues = getToolValues("lasso");
expect(toolValues.filter((value) => value === "lasso")).toHaveLength(1);
expect(toolValues.filter((value) => value === "selection")).toHaveLength(0);
});
});
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);
}
}
+8 -27
View File
@@ -315,10 +315,7 @@ export interface AppState {
bindingPreference: "enabled" | "disabled"; bindingPreference: "enabled" | "disabled";
/** user preference whether arrow snap to midpoints while binding */ /** user preference whether arrow snap to midpoints while binding */
isMidpointSnappingEnabled: boolean; isMidpointSnappingEnabled: boolean;
/** startBoundElement: NonDeleted<ExcalidrawBindableElement> | null;
* The bindable element the UI highlights for the user when an arrow is
* dragged or otherwise its endpoint being close to said element.
*/
suggestedBinding: { suggestedBinding: {
element: NonDeleted<ExcalidrawBindableElement>; element: NonDeleted<ExcalidrawBindableElement>;
midPoint?: GlobalPoint; midPoint?: GlobalPoint;
@@ -350,11 +347,8 @@ export interface AppState {
type: "selection" | "lasso"; type: "selection" | "lasso";
initialized: boolean; initialized: boolean;
}; };
// Pen handling
penMode: boolean; penMode: boolean;
penDetected: boolean; penDetected: boolean;
exportBackground: boolean; exportBackground: boolean;
exportEmbedScene: boolean; exportEmbedScene: boolean;
exportWithDarkMode: boolean; exportWithDarkMode: boolean;
@@ -478,9 +472,6 @@ export interface AppState {
// as elements are unlocked, we remove the groupId from the elements // as elements are unlocked, we remove the groupId from the elements
// and also remove groupId from this map // and also remove groupId from this map
lockedMultiSelections: { [groupId: string]: true }; lockedMultiSelections: { [groupId: string]: true };
// Stores the current bind mode which is detemined at various points during
// a drag operation (like pointer position vs bindable element) but needed
// globally for calculating the binding strategy
bindMode: BindMode; bindMode: BindMode;
} }
@@ -496,7 +487,10 @@ export type SearchMatch = {
}[]; }[];
}; };
export type UIAppState = Omit<AppState, "cursorButton" | "scrollX" | "scrollY">; export type UIAppState = Omit<
AppState,
"startBoundElement" | "cursorButton" | "scrollX" | "scrollY"
>;
export type NormalizedZoomValue = number & { _brand: "normalizedZoom" }; export type NormalizedZoomValue = number & { _brand: "normalizedZoom" };
@@ -645,10 +639,6 @@ export interface ExcalidrawProps {
appState: UIAppState, appState: UIAppState,
) => JSX.Element; ) => JSX.Element;
UIOptions?: Partial<UIOptions>; UIOptions?: Partial<UIOptions>;
/**
* dimensions and size constraints for inserted images
*/
imageOptions?: ImageOptions;
detectScroll?: boolean; detectScroll?: boolean;
handleKeyboardGlobally?: boolean; handleKeyboardGlobally?: boolean;
onLibraryChange?: (libraryItems: LibraryItems) => void | Promise<any>; onLibraryChange?: (libraryItems: LibraryItems) => void | Promise<any>;
@@ -735,11 +725,6 @@ export type ExportOpts = {
) => JSX.Element; ) => JSX.Element;
}; };
export type ImageOptions = Partial<{
maxWidthOrHeight: number;
maxFileSizeBytes: number;
}>;
// NOTE at the moment, if action name corresponds to canvasAction prop, its // NOTE at the moment, if action name corresponds to canvasAction prop, its
// truthiness value will determine whether the action is rendered or not // truthiness value will determine whether the action is rendered or not
// (see manager renderAction). We also override canvasAction values in // (see manager renderAction). We also override canvasAction values in
@@ -781,7 +766,6 @@ export type AppProps = Merge<
canvasActions: Required<CanvasActions> & { export: ExportOpts }; canvasActions: Required<CanvasActions> & { export: ExportOpts };
} }
>; >;
imageOptions: Required<ImageOptions>;
detectScroll: boolean; detectScroll: boolean;
handleKeyboardGlobally: boolean; handleKeyboardGlobally: boolean;
isCollaborating: boolean; isCollaborating: boolean;
@@ -812,6 +796,9 @@ export type AppClassProperties = {
files: BinaryFiles; files: BinaryFiles;
editorInterface: App["editorInterface"]; editorInterface: App["editorInterface"];
scene: App["scene"]; scene: App["scene"];
store: App["store"];
transactionManager: App["transactionManager"];
createTransaction: App["createTransaction"];
syncActionResult: App["syncActionResult"]; syncActionResult: App["syncActionResult"];
fonts: App["fonts"]; fonts: App["fonts"];
pasteFromClipboard: App["pasteFromClipboard"]; pasteFromClipboard: App["pasteFromClipboard"];
@@ -886,13 +873,8 @@ export type PointerDownState = Readonly<{
// Whether selected element(s) were duplicated, might change during the // Whether selected element(s) were duplicated, might change during the
// pointer interaction // pointer interaction
hasBeenDuplicated: boolean; hasBeenDuplicated: boolean;
// Whether the pointer is hitting the common bounding box of selected
// elements, which is useful for discriminating between selecitng
// the entire selection vs a specific element
hasHitCommonBoundingBoxOfSelectedElements: boolean; hasHitCommonBoundingBoxOfSelectedElements: boolean;
}; };
// This is determined on the initial pointer down event to
// set various interaction modalities
withCmdOrCtrl: boolean; withCmdOrCtrl: boolean;
drag: { drag: {
// Might change during the pointer interaction // Might change during the pointer interaction
@@ -918,7 +900,6 @@ export type PointerDownState = Readonly<{
onKeyUp: null | ((event: KeyboardEvent) => void); onKeyUp: null | ((event: KeyboardEvent) => void);
}; };
boxSelection: { boxSelection: {
// If the box selection tool is activated on pointer down
hasOccurred: boolean; hasOccurred: boolean;
}; };
}>; }>;
@@ -769,63 +769,6 @@ describe("textWysiwyg", () => {
]); ]);
}); });
it("should not add bound text to a frame when its container is not a frame child", async () => {
const frame = API.createElement({
type: "frame",
x: 0,
y: 0,
width: 200,
height: 200,
});
const rectangle = API.createElement({
type: "rectangle",
x: 10,
y: 20,
width: 90,
height: 75,
backgroundColor: "red",
});
API.setElements([frame, rectangle]);
mouse.doubleClickAt(rectangle.x + 10, rectangle.y + 10);
const text = h.elements[2] as ExcalidrawTextElementWithContainer;
expect(text.type).toBe("text");
expect(text.containerId).toBe(rectangle.id);
expect(text.frameId).toBe(null);
});
it("should bind text to a frame child container when single clicking its center", async () => {
const frame = API.createElement({
type: "frame",
x: 0,
y: 0,
width: 200,
height: 200,
});
const rectangle = API.createElement({
type: "rectangle",
x: 10,
y: 20,
width: 90,
height: 75,
backgroundColor: "red",
frameId: frame.id,
});
API.setElements([rectangle, frame]);
UI.clickTool("text");
mouse.clickAt(
rectangle.x + rectangle.width / 2,
rectangle.y + rectangle.height / 2,
);
const text = h.elements[1] as ExcalidrawTextElementWithContainer;
expect(text.type).toBe("text");
expect(text.containerId).toBe(rectangle.id);
expect(text.frameId).toBe(frame.id);
});
it("should set the text element angle to same as container angle when binding to rotated container", async () => { it("should set the text element angle to same as container angle when binding to rotated container", async () => {
const rectangle = API.createElement({ const rectangle = API.createElement({
type: "rectangle", type: "rectangle",
View File

Some files were not shown because too many files have changed in this diff Show More