Compare commits

...
Author SHA1 Message Date
Mark Tolmacs 10854002dc fix: Vercel.json
Signed-off-by: Mark Tolmacs <mark@lazycat.hu>
2026-06-22 13:32:11 +00:00
Mark Tolmacs 435b4a1684 feat: Rounding coordinates
Signed-off-by: Mark Tolmacs <mark@lazycat.hu>
2026-06-22 14:39:39 +02:00
David LuzarandGitHub 28a9b1711d test(repo): less noisy test output (#11505) 2026-06-15 18:19:20 +02:00
1cb9fff569 fix(editor): Double history (#11445)
---------

Signed-off-by: Mark Tolmacs <mark@lazycat.hu>
Co-authored-by: dwelle <5153846+dwelle@users.noreply.github.com>
2026-06-15 18:19:08 +02:00
069982606d fix(editor): update element.frameId on frame change (#11490)
Co-authored-by: Diego Mateos <dimateos@ucm.es>
2026-06-13 22:39:04 +02:00
David LuzarandGitHub b324a85ab1 fix(editor): elements duplicated when moving frame children (#11485)
* fix(editor): elements duplicated when moving frame children

* fix(editor): accumulate reorders across frames in bring-to-front/back

* add invalid-order tests

* fix: make sure moved indices are within range

* add length/duplicate elements guards
2026-06-13 20:32:45 +02:00
a83ac48853 fix(editor): recalculate roundness type when switching shape types (#11473)
When converting between generic shapes, the roundness type was only
recalculated when the target type was a diamond. Converting a diamond
(which uses the proportional radius algorithm) back to a rectangle kept
the proportional radius, so rectangles ended up rendering with overly
round corners.

---------

Signed-off-by: Mark Tolmacs <mark@lazycat.hu>
Co-authored-by: Maruthan G <maruthang4@gmail.com>
Co-authored-by: Sivram <withsivram@gmail.com>
Co-authored-by: Jai Kumar Dewani <jai.dewani.99@gmail.com>
Co-authored-by: Mark Tolmacs <mark@lazycat.hu>
2026-06-11 19:17:15 +02:00
KrishhnaTandGitHub 0cf56b19c7 test(editor): add unit tests for BinaryHeap (#11419) 2026-06-10 17:12:42 +02:00
KrishhnaTandGitHub 61fe15a51d fix(editor): cardinal direction arrows with label are invisible in exported SVG (#11441)
fix: arrows with bound text labels missing from SVG export

Axis-aligned (horizontal/vertical) arrows with a bound label vanished from SVG exports. The label-gap mask defaulted to objectBoundingBox units, whose region collapses to zero area for a zero-size bounding box, masking out the whole line. Pin the mask to userSpaceOnUse with an explicit user-space region (the coords already used by the visible rect).

Fixes #11439
2026-06-07 19:19:44 +02:00
David LuzarandGitHub 647a264a48 feat(packages/excalidraw): consolidate theme state handling (#11453) 2026-06-06 18:18:06 +02:00
Márk TolmácsandGitHub b6d80e4256 fix(packages/excalidraw): consolidate bounds checks (#11275)
Signed-off-by: Mark Tolmacs <mark@lazycat.hu>
2026-06-04 19:27:25 +02:00
David LuzarandGitHub 3372149277 feat(packages/excalidraw): export applyDarkModeFilter and simplify (#11429) 2026-06-01 15:43:45 +02:00
David LuzarandGitHub c08be69618 ci(docker): fix docker dep bundling and pin remaining actions (#11398)
* docker: use slim alpine image to remove bundling deps in Docker image

* pin remaining yml actions

* use lockfile

* remove pulling
2026-05-25 14:39:21 +02:00
b42b1a193d fix(editor): excessive battery usage (#11377)
* fix: Excessive battery usage

* chore: Refactor Eraser, Lasso and Laser pointer to use AnimationController

* fix: Last laser trail element is not removed from SVG
---------

Signed-off-by: Mark Tolmacs <mark@lazycat.hu>
Co-authored-by: dwelle <5153846+dwelle@users.noreply.github.com>
2026-05-24 16:12:17 +02:00
David LuzarandGitHub f6d85bc80f feat(packages/excalidraw): expose image size config and optimize resizing (#11332) 2026-05-14 22:45:03 +02:00
David LuzarandGitHub 0457ac9063 fix(editor): handle invalid points on restore (#11321)
* fix: handle invalid points on restore

* move isValidPoint to @excalidraw/math
2026-05-12 18:44:49 +02:00
Praneeth KodumagullaandGitHub b2b2815954 fix(editor): prevent duplicate lasso toolbar item (#11286) 2026-05-06 23:10:50 +02:00
d992c10bc1 fix(app): resolve app-jotai import path in LocalData (#11290)
Co-authored-by: chulkin-mdb <oleksandr.chulkin@mongodb.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-06 23:03:10 +02:00
091b9053a3 fix(editor): enabled ctrl+y redo shortcut on linux and mac (#11179)
* enabled ctrl+y redo shortcut on linux and mac

* Apply suggestion from @dwelle

---------

Co-authored-by: David Luzar <5153846+dwelle@users.noreply.github.com>
2026-05-06 22:59:28 +02:00
David LuzarandGitHub 97274a74b2 ci(repo): enforce scopes (#11292) 2026-05-06 19:54:30 +02:00
c59fb8dcbc fix: LocalStorage is empty object on node@25 which breaks tests (#11240)
Co-authored-by: dwelle <5153846+dwelle@users.noreply.github.com>
2026-05-06 17:42:36 +02:00
7f56cc0cf3 fix: Speculative fixes for arrow invariant failures (#11241)
Co-authored-by: dwelle <5153846+dwelle@users.noreply.github.com>
2026-05-06 14:39:45 +00:00
974b338b7e fix: Group selection (#11234)
* fix: Group selection

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

* fix: Tests

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

* fix: Frames and overlap

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

* fix: Remove unnecessary crust

Signed-off-by: Mark Tolmacs <mark@lazycat.hu>

* revert unused Set signature

* skip ignored elements from group condition

when wrap-mode selecting grouped elements, we should not require to select those we ignore (bound elements or locked ones), else it's impossible to select grouped text containers

unclear whether locked elements should also be excluded - but it feels like a good heuristic on the whole

* apply exclusion

* simplify

* feat: return all elements in group for overlap selection

---------

Signed-off-by: Mark Tolmacs <mark@lazycat.hu>
Co-authored-by: Copilot <copilot@github.com>
Co-authored-by: dwelle <5153846+dwelle@users.noreply.github.com>
2026-05-06 15:27:34 +02:00
David LuzarandGitHub d2557474e2 fix(editor): fix target element index when creating/adding elements to frames (#11257) 2026-05-05 21:35:32 +02:00
3004c642da fix: Fractional index validation (#11258)
- Vendored fractional-indexing and converted to TypeScript
- Stricter index format validation in fractional-indexing
- Added format validation to fractional index validation

---
Signed-off-by: Mark Tolmacs <mark@lazycat.hu>
Co-authored-by: dwelle <5153846+dwelle@users.noreply.github.com>
2026-05-04 11:37:17 +02:00
131 changed files with 4843 additions and 2138 deletions
+7
View File
@@ -0,0 +1,7 @@
# VITE_DEBUG_DOM
# When "true", testing-library failures (waitFor / getBy*) include the full
# serialized DOM in the error message. It's off by default because it's noisy.
#
# Flip it to "true" (or use `VITE_DEBUG_DOM=true yarn test`) when you need to
# inspect the DOM of a failing test.
VITE_DEBUG_DOM=false
+2 -2
View File
@@ -9,11 +9,11 @@ jobs:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- uses: actions/checkout@v2 - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
with: with:
fetch-depth: 2 fetch-depth: 2
- name: Setup Node.js - name: Setup Node.js
uses: actions/setup-node@v2 uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
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@v2 - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
- 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@v2 - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
- name: Setup Node.js - name: Setup Node.js
uses: actions/setup-node@v2 uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
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@v4 - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # 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@v2 uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
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@v3 uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
- 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,11 +6,97 @@ 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@v2 - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
- name: Setup Node.js - name: Setup Node.js
uses: actions/setup-node@v2 uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
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@v3 uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
- name: Setup Node.js - name: Setup Node.js
uses: actions/setup-node@v3 uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
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@v2 - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
- name: "Install Node" - name: "Install Node"
uses: actions/setup-node@v2 uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
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@v4 - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
- name: Setup Node.js - name: Setup Node.js
uses: actions/setup-node@v4 uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # 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 AS build FROM --platform=${BUILDPLATFORM} node:24@sha256:8530f76a96d88820d288761f022e318970dda93d01536919fbc16076b7983e63 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 --network-timeout 600000 npm_config_target_arch=${TARGETARCH} yarn --frozen-lockfile --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:1.27-alpine FROM nginx:stable-alpine-slim@sha256:2c605dbeab79a6b2a63340474fe58119d0ef95bdc4b1f41df0aa689659b3d13b
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
+1 -10
View File
@@ -22,7 +22,6 @@ import Trans from "@excalidraw/excalidraw/components/Trans";
import { import {
APP_NAME, APP_NAME,
EVENT, EVENT,
THEME,
VERSION_TIMEOUT, VERSION_TIMEOUT,
debounce, debounce,
getVersion, getVersion,
@@ -952,6 +951,7 @@ const ExcalidrawWrapper = () => {
handleKeyboardGlobally={true} handleKeyboardGlobally={true}
autoFocus={true} autoFocus={true}
theme={editorTheme} theme={editorTheme}
onThemeChange={setAppTheme}
renderTopRightUI={(isMobile) => { renderTopRightUI={(isMobile) => {
if (isMobile || !collabAPI || isCollabDisabled) { if (isMobile || !collabAPI || isCollabDisabled) {
return null; return null;
@@ -988,7 +988,6 @@ const ExcalidrawWrapper = () => {
isCollaborating={isCollaborating} isCollaborating={isCollaborating}
isCollabEnabled={!isCollabDisabled} isCollabEnabled={!isCollabDisabled}
theme={appTheme} theme={appTheme}
setTheme={(theme) => setAppTheme(theme)}
refresh={() => forceRefresh((prev) => !prev)} refresh={() => forceRefresh((prev) => !prev)}
/> />
<AppWelcomeScreen <AppWelcomeScreen
@@ -1229,14 +1228,6 @@ const ExcalidrawWrapper = () => {
} }
}, },
}, },
{
...CommandPalette.defaultItems.toggleTheme,
perform: () => {
setAppTheme(
editorTheme === THEME.DARK ? THEME.LIGHT : THEME.DARK,
);
},
},
{ {
label: t("labels.installPWA"), label: t("labels.installPWA"),
category: DEFAULT_CATEGORIES.app, category: DEFAULT_CATEGORIES.app,
+1 -6
View File
@@ -20,7 +20,6 @@ export const AppMainMenu: React.FC<{
isCollaborating: boolean; isCollaborating: boolean;
isCollabEnabled: boolean; isCollabEnabled: boolean;
theme: Theme | "system"; theme: Theme | "system";
setTheme: (theme: Theme | "system") => void;
refresh: () => void; refresh: () => void;
}> = React.memo((props) => { }> = React.memo((props) => {
return ( return (
@@ -78,11 +77,7 @@ export const AppMainMenu: React.FC<{
)} )}
<MainMenu.Separator /> <MainMenu.Separator />
<MainMenu.DefaultItems.Preferences /> <MainMenu.DefaultItems.Preferences />
<MainMenu.DefaultItems.ToggleTheme <MainMenu.DefaultItems.ToggleTheme allowSystemTheme theme={props.theme} />
allowSystemTheme
theme={props.theme}
onSelect={props.setTheme}
/>
<MainMenu.ItemCustom> <MainMenu.ItemCustom>
<LanguageList style={{ width: "100%" }} /> <LanguageList style={{ width: "100%" }} />
</MainMenu.ItemCustom> </MainMenu.ItemCustom>
+3 -2
View File
@@ -11,6 +11,7 @@
*/ */
import { clearAppStateForLocalStorage } from "@excalidraw/excalidraw/appState"; import { clearAppStateForLocalStorage } from "@excalidraw/excalidraw/appState";
import { stringifyWithPrecision } from "@excalidraw/excalidraw/data/json";
import { import {
CANVAS_SEARCH_TAB, CANVAS_SEARCH_TAB,
DEFAULT_SIDEBAR, DEFAULT_SIDEBAR,
@@ -26,7 +27,6 @@ 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";
@@ -39,6 +39,7 @@ 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";
@@ -89,7 +90,7 @@ const saveDataStateToLocalStorage = (
localStorage.setItem( localStorage.setItem(
STORAGE_KEYS.LOCAL_STORAGE_ELEMENTS, STORAGE_KEYS.LOCAL_STORAGE_ELEMENTS,
JSON.stringify(getNonDeletedElements(elements)), stringifyWithPrecision(getNonDeletedElements(elements)),
); );
localStorage.setItem( localStorage.setItem(
STORAGE_KEYS.LOCAL_STORAGE_APP_STATE, STORAGE_KEYS.LOCAL_STORAGE_APP_STATE,
+2 -1
View File
@@ -1,6 +1,7 @@
import { reconcileElements } from "@excalidraw/excalidraw"; import { reconcileElements } from "@excalidraw/excalidraw";
import { MIME_TYPES, toBrandedType } from "@excalidraw/common"; import { MIME_TYPES, toBrandedType } from "@excalidraw/common";
import { decompressData } from "@excalidraw/excalidraw/data/encode"; import { decompressData } from "@excalidraw/excalidraw/data/encode";
import { stringifyWithPrecision } from "@excalidraw/excalidraw/data/json";
import { import {
encryptData, encryptData,
decryptData, decryptData,
@@ -94,7 +95,7 @@ const encryptElements = async (
key: string, key: string,
elements: readonly ExcalidrawElement[], elements: readonly ExcalidrawElement[],
): Promise<{ ciphertext: ArrayBuffer; iv: Uint8Array }> => { ): Promise<{ ciphertext: ArrayBuffer; iv: Uint8Array }> => {
const json = JSON.stringify(elements); const json = stringifyWithPrecision(elements);
const encoded = new TextEncoder().encode(json); const encoded = new TextEncoder().encode(json);
const { encryptedBuffer, iv } = await encryptData(key, encoded); const { encryptedBuffer, iv } = await encryptData(key, encoded);
+1 -20
View File
@@ -1,5 +1,4 @@
import { THEME } from "@excalidraw/excalidraw"; import { THEME } from "@excalidraw/excalidraw";
import { EVENT, CODES, KEYS } from "@excalidraw/common";
import { useEffect, useLayoutEffect, useState } from "react"; import { useEffect, useLayoutEffect, useState } from "react";
import type { Theme } from "@excalidraw/element/types"; import type { Theme } from "@excalidraw/element/types";
@@ -31,28 +30,10 @@ export const useHandleAppTheme = () => {
mediaQuery?.addEventListener("change", handleChange); mediaQuery?.addEventListener("change", handleChange);
} }
const handleKeydown = (event: KeyboardEvent) => {
if (
!event[KEYS.CTRL_OR_CMD] &&
event.altKey &&
event.shiftKey &&
event.code === CODES.D
) {
event.preventDefault();
event.stopImmediatePropagation();
setAppTheme(editorTheme === THEME.DARK ? THEME.LIGHT : THEME.DARK);
}
};
document.addEventListener(EVENT.KEYDOWN, handleKeydown, { capture: true });
return () => { return () => {
mediaQuery?.removeEventListener("change", handleChange); mediaQuery?.removeEventListener("change", handleChange);
document.removeEventListener(EVENT.KEYDOWN, handleKeydown, {
capture: true,
});
}; };
}, [appTheme, editorTheme, setAppTheme]); }, [appTheme]);
useLayoutEffect(() => { useLayoutEffect(() => {
localStorage.setItem(STORAGE_KEYS.LOCAL_STORAGE_THEME, appTheme); localStorage.setItem(STORAGE_KEYS.LOCAL_STORAGE_THEME, appTheme);
+7
View File
@@ -75,6 +75,13 @@ 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: {
+2 -1
View File
@@ -56,7 +56,8 @@
"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:packages": "yarn build:common && yarn build:math && yarn build:element && yarn build:excalidraw", "build:fractional-indexing": "yarn --cwd ./packages/fractional-indexing build:esm",
"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",
+5 -1
View File
@@ -80,7 +80,11 @@ const cssInvert = (
return { r: invertedR, g: invertedG, b: invertedB }; return { r: invertedR, g: invertedG, b: invertedB };
}; };
export const applyDarkModeFilter = (color: string): string => { export const applyDarkModeFilter = (color: string, enable = true): string => {
if (!enable) {
return color;
}
const cached = DARK_MODE_COLORS_CACHE?.get(color); const cached = DARK_MODE_COLORS_CACHE?.get(color);
if (cached) { if (cached) {
return cached; return cached;
+4 -3
View File
@@ -337,9 +337,10 @@ 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_MAX_IMAGE_WIDTH_OR_HEIGHT = 1440; export const DEFAULT_IMAGE_OPTIONS: AppProps["imageOptions"] = {
maxWidthOrHeight: 1440,
export const MAX_ALLOWED_FILE_BYTES = 4 * 1024 * 1024; maxFileSizeBytes: 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"?>
+12 -2
View File
@@ -1,4 +1,4 @@
import { average } from "@excalidraw/math"; import { average, round } from "@excalidraw/math";
import type { GlobalCoord } from "@excalidraw/math"; import type { GlobalCoord } from "@excalidraw/math";
@@ -429,11 +429,21 @@ export const viewportCoordsToSceneCoords = (
scrollX: number; scrollX: number;
scrollY: number; scrollY: number;
}, },
decimals: number = 2,
) => { ) => {
const x = (clientX - offsetLeft) / zoom.value - scrollX; const x = (clientX - offsetLeft) / zoom.value - scrollX;
const y = (clientY - offsetTop) / zoom.value - scrollY; const y = (clientY - offsetTop) / zoom.value - scrollY;
return { x, y } as GlobalCoord; if (decimals === 0) {
return toBrandedType<GlobalCoord>({ x, y });
}
const precision = Math.pow(10, decimals);
return toBrandedType<GlobalCoord>({
x: round(x, precision),
y: round(y, precision),
});
}; };
export const sceneCoordsToViewportCoords = ( export const sceneCoordsToViewportCoords = (
+109
View File
@@ -0,0 +1,109 @@
import { BinaryHeap } from "../src/binary-heap";
describe("BinaryHeap", () => {
const numberHeap = () => new BinaryHeap<number>((n) => n);
const drain = (heap: BinaryHeap<number>) => {
const out: number[] = [];
while (heap.size() > 0) {
out.push(heap.pop()!);
}
return out;
};
describe("empty heap", () => {
it("has size 0", () => {
expect(numberHeap().size()).toBe(0);
});
it("pop() returns null", () => {
expect(numberHeap().pop()).toBe(null);
});
it("remove() is a no-op and does not throw", () => {
const heap = numberHeap();
expect(() => heap.remove(1)).not.toThrow();
expect(heap.size()).toBe(0);
});
});
describe("push / pop", () => {
it("tracks size as items are added and removed", () => {
const heap = numberHeap();
[3, 1, 2].forEach((n) => heap.push(n));
expect(heap.size()).toBe(3);
heap.pop();
expect(heap.size()).toBe(2);
});
it("pops a single pushed element back out", () => {
const heap = numberHeap();
heap.push(42);
expect(heap.pop()).toBe(42);
expect(heap.pop()).toBe(null);
});
it("always pops the smallest score first", () => {
const heap = numberHeap();
[5, 3, 8, 1, 9, 2, 7].forEach((n) => heap.push(n));
expect(drain(heap)).toEqual([1, 2, 3, 5, 7, 8, 9]);
});
it("handles duplicate scores", () => {
const heap = numberHeap();
[4, 1, 4, 1, 2].forEach((n) => heap.push(n));
expect(drain(heap)).toEqual([1, 1, 2, 4, 4]);
});
it("maintains the heap invariant for a large adversarial (reverse-sorted) input", () => {
const heap = numberHeap();
// pushing in descending order forces a sift-up on every insert
const input = Array.from({ length: 1000 }, (_, i) => 1000 - i);
input.forEach((n) => heap.push(n));
expect(drain(heap)).toEqual([...input].sort((a, b) => a - b));
});
});
describe("remove", () => {
it("removes an interior element and keeps the rest ordered", () => {
const heap = numberHeap();
[5, 3, 8, 1, 9].forEach((n) => heap.push(n));
heap.remove(8);
expect(heap.size()).toBe(4);
expect(drain(heap)).toEqual([1, 3, 5, 9]);
});
it("can remove the current minimum", () => {
const heap = numberHeap();
[5, 3, 8, 1, 9].forEach((n) => heap.push(n));
heap.remove(1);
expect(heap.size()).toBe(4);
expect(heap.pop()).toBe(3);
});
});
describe("rescoreElement", () => {
type Node = { id: string; f: number };
it("re-sorts a node after its score is lowered", () => {
const heap = new BinaryHeap<Node>((node) => node.f);
const a = { id: "a", f: 10 };
const b = { id: "b", f: 20 };
const c = { id: "c", f: 30 };
[a, b, c].forEach((node) => heap.push(node));
c.f = 5;
heap.rescoreElement(c);
expect(heap.pop()).toBe(c);
expect(heap.pop()).toBe(a);
expect(heap.pop()).toBe(b);
});
});
});
+2 -1
View File
@@ -64,6 +64,7 @@
}, },
"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"
} }
} }
+12 -36
View File
@@ -338,29 +338,20 @@ export class Scene {
this.callbacks.clear(); this.callbacks.clear();
} }
insertElementAtIndex(element: ExcalidrawElement, index: number) { /** low-level - generally use app.insertNewElements() */
if (!Number.isFinite(index) || index < 0) { insertElementsAtIndex(
throw new Error( elements: ExcalidrawElement[],
"insertElementAtIndex can only be called with index >= 0", /** null indicates end of the array */
); index: number | null,
} ) {
const nextElements = [
...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) { if (!elements.length) {
return; return;
} }
if (index === null) {
index = this.elements.length;
}
if (!Number.isFinite(index) || index < 0) { if (!Number.isFinite(index) || index < 0) {
throw new Error( throw new Error(
"insertElementAtIndex can only be called with index >= 0", "insertElementAtIndex can only be called with index >= 0",
@@ -378,24 +369,9 @@ export class Scene {
this.replaceAllElements(nextElements); this.replaceAllElements(nextElements);
} }
/** low-level - generally use app.insertNewElement() */
insertElement = (element: ExcalidrawElement) => { insertElement = (element: ExcalidrawElement) => {
const index = element.frameId this.insertElementsAtIndex([element], null);
? 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) {
+19 -14
View File
@@ -643,10 +643,13 @@ const getBindingStrategyForDraggingBindingElementEndpoints_simple = (
let start: BindingStrategy = { mode: undefined }; let start: BindingStrategy = { mode: undefined };
let end: BindingStrategy = { mode: undefined }; let end: BindingStrategy = { mode: undefined };
invariant( if (arrow.points.length < 2) {
arrow.points.length > 1, console.error(
"Do not attempt to bind linear elements with a single point", "Attempting to bind a linear element with less than 2 points",
); );
// a single-point can't be bound -> cancel
return { start: { mode: undefined }, end: { mode: undefined } };
}
// If none of the ends are dragged, we don't change anything // If none of the ends are dragged, we don't change anything
if (!startDragged && !endDragged) { if (!startDragged && !endDragged) {
@@ -734,12 +737,11 @@ const getBindingStrategyForDraggingBindingElementEndpoints_simple = (
}); });
// Handle outside-outside binding to the same element // Handle outside-outside binding to the same element
if (otherBinding && otherBinding.elementId === hit?.id) { if (
invariant( otherBinding &&
!opts?.newArrow || appState.selectedLinearElement?.initialState.origin, otherBinding.elementId === hit?.id &&
"appState.selectedLinearElement.initialState.origin must be defined for new arrows", (!opts?.newArrow || appState.selectedLinearElement?.initialState.origin)
); ) {
return { return {
start: { start: {
mode: "inside", mode: "inside",
@@ -891,10 +893,13 @@ const getBindingStrategyForDraggingBindingElementEndpoints_complex = (
let start: BindingStrategy = { mode: undefined }; let start: BindingStrategy = { mode: undefined };
let end: BindingStrategy = { mode: undefined }; let end: BindingStrategy = { mode: undefined };
invariant( if (arrow.points.length < 2) {
arrow.points.length > 1, console.error(
"Do not attempt to bind linear elements with a single point", "Attempting to bind a linear element with less than 2 points",
); );
// a single-point can't be bound -> cancel
return { start: { mode: undefined }, end: { mode: undefined } };
}
// If none of the ends are dragged, we don't change anything // If none of the ends are dragged, we don't change anything
if (!startDragged && !endDragged) { if (!startDragged && !endDragged) {
+294 -8
View File
@@ -1,5 +1,4 @@
import rough from "roughjs/bin/rough"; import rough from "roughjs/bin/rough";
import { import {
arrayToMap, arrayToMap,
type Bounds, type Bounds,
@@ -7,7 +6,6 @@ import {
rescalePoints, rescalePoints,
sizeOf, sizeOf,
} from "@excalidraw/common"; } from "@excalidraw/common";
import { import {
degreesToRadians, degreesToRadians,
lineSegment, lineSegment,
@@ -16,9 +14,7 @@ import {
pointFromArray, pointFromArray,
pointRotateRads, pointRotateRads,
} from "@excalidraw/math"; } from "@excalidraw/math";
import { getCurvePathOps } from "@excalidraw/utils/shape"; import { getCurvePathOps } from "@excalidraw/utils/shape";
import { pointsOnBezierCurves } from "points-on-curve"; import { pointsOnBezierCurves } from "points-on-curve";
import type { import type {
@@ -29,9 +25,7 @@ import type {
LocalPoint, LocalPoint,
Radians, Radians,
} from "@excalidraw/math"; } from "@excalidraw/math";
import type { AppState } from "@excalidraw/excalidraw/types"; import type { AppState } from "@excalidraw/excalidraw/types";
import type { Mutable } from "@excalidraw/common/utility-types"; import type { Mutable } from "@excalidraw/common/utility-types";
import { generateRoughOptions } from "./shape"; import { generateRoughOptions } from "./shape";
@@ -41,18 +35,20 @@ import { getBoundTextElement, getContainerElement } from "./textElement";
import { import {
isArrowElement, isArrowElement,
isBoundToContainer, isBoundToContainer,
isFrameLikeElement,
isFreeDrawElement, isFreeDrawElement,
isLinearElement, isLinearElement,
isLineElement, isLineElement,
isTextElement, isTextElement,
isExcalidrawElement,
} from "./typeChecks"; } from "./typeChecks";
import { getElementShape } from "./shape"; import { getElementShape } from "./shape";
import { import {
deconstructDiamondElement, deconstructDiamondElement,
deconstructRectanguloidElement, deconstructRectanguloidElement,
} from "./utils"; } from "./utils";
import { intersectElementWithLineSegment } from "./collision";
import { elementOverlapsWithFrame, getContainingFrame } from "./frame";
import type { Drawable, Op } from "roughjs/bin/core"; import type { Drawable, Op } from "roughjs/bin/core";
import type { Point as RoughPoint } from "roughjs/bin/geometry"; import type { Point as RoughPoint } from "roughjs/bin/geometry";
@@ -67,6 +63,7 @@ import type {
ExcalidrawRectanguloidElement, ExcalidrawRectanguloidElement,
ExcalidrawTextElementWithContainer, ExcalidrawTextElementWithContainer,
NonDeleted, NonDeleted,
NonDeletedExcalidrawElement,
} from "./types"; } from "./types";
export type RectangleBox = { export type RectangleBox = {
@@ -1295,6 +1292,295 @@ export const boundsContainBounds = (outerBounds: Bounds, innerBounds: Bounds) =>
pointFrom<GlobalPoint>(innerBounds[2], innerBounds[3]), pointFrom<GlobalPoint>(innerBounds[2], innerBounds[3]),
].every((point) => pointInsideBoundsInclusive(point, outerBounds)); ].every((point) => pointInsideBoundsInclusive(point, outerBounds));
/**
* High level helper to get elements overlapping a bounding box.
* It can be used to get elements overlapping a selection box, for example.
*
*/
export const elementsOverlappingBBox = ({
elements,
elementsMap,
bounds,
type,
excludeElementsInFrames,
shouldIgnoreElementFromSelection,
}: {
elements: readonly NonDeletedExcalidrawElement[];
elementsMap?: ElementsMap;
bounds: Bounds | ExcalidrawElement;
/**
* - overlap: elements overlapping or inside bounds
* - contain: elements inside bounds
**/
type: "contain" | "overlap";
excludeElementsInFrames?: boolean;
shouldIgnoreElementFromSelection?: (
element: NonDeletedExcalidrawElement,
) => boolean;
}) => {
if (!elementsMap) {
elementsMap = arrayToMap(elements) as ElementsMap;
}
const selectionBounds = isExcalidrawElement(bounds)
? getElementBounds(bounds, elementsMap)
: bounds;
const [selectionX1, selectionY1, selectionX2, selectionY2] = selectionBounds;
const selectionEdges = [
lineSegment<GlobalPoint>(
pointFrom(selectionX1, selectionY1),
pointFrom(selectionX2, selectionY1),
),
lineSegment<GlobalPoint>(
pointFrom(selectionX2, selectionY1),
pointFrom(selectionX2, selectionY2),
),
lineSegment<GlobalPoint>(
pointFrom(selectionX2, selectionY2),
pointFrom(selectionX1, selectionY2),
),
lineSegment<GlobalPoint>(
pointFrom(selectionX1, selectionY2),
pointFrom(selectionX1, selectionY1),
),
];
const framesInSelection = excludeElementsInFrames
? new Set<NonDeletedExcalidrawElement["id"]>()
: null;
const groups: Record<string, NonDeletedExcalidrawElement[]> = {};
const elementsInSelection: Set<NonDeletedExcalidrawElement> = new Set();
for (const element of elements) {
if (shouldIgnoreElementFromSelection?.(element)) {
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;
let labelAABB: Bounds | null = null;
let elementAABB = getElementBounds(element, elementsMap);
elementAABB = [
elementAABB[0] - strokeWidth / 2,
elementAABB[1] - strokeWidth / 2,
elementAABB[2] + strokeWidth / 2,
elementAABB[3] + strokeWidth / 2,
] as Bounds;
// Whether the element bounds should include the bound text element bounds
const boundTextElement =
isArrowElement(element) && getBoundTextElement(element, elementsMap);
if (boundTextElement) {
const { x, y } = LinearElementEditor.getBoundTextElementPosition(
element,
boundTextElement,
elementsMap,
);
labelAABB = [
x,
y,
x + boundTextElement.width,
y + boundTextElement.height,
] as Bounds;
}
// Clip element bounds by its containing frame (if any), since only the
// visible (frame-clipped) portion of the element is relevant for selection.
const associatedFrame = getContainingFrame(element, elementsMap);
if (
associatedFrame &&
elementOverlapsWithFrame(element, associatedFrame, elementsMap)
) {
const frameAABB = getElementBounds(associatedFrame, elementsMap);
elementAABB = [
Math.max(elementAABB[0], frameAABB[0]),
Math.max(elementAABB[1], frameAABB[1]),
Math.min(elementAABB[2], frameAABB[2]),
Math.min(elementAABB[3], frameAABB[3]),
] as Bounds;
labelAABB = labelAABB
? ([
Math.max(labelAABB[0], frameAABB[0]),
Math.max(labelAABB[1], frameAABB[1]),
Math.min(labelAABB[2], frameAABB[2]),
Math.min(labelAABB[3], frameAABB[3]),
] as Bounds)
: null;
}
const commonAABB = labelAABB
? ([
Math.min(labelAABB[0], elementAABB[0]),
Math.min(labelAABB[1], elementAABB[1]),
Math.max(labelAABB[2], elementAABB[2]),
Math.max(labelAABB[3], elementAABB[3]),
] as Bounds)
: elementAABB;
// ============== Evaluation ==============
// 1. If the selection box WRAPs the element's AABB, then add it to the
// selection and move on, regardless of the selection mode.
//
// PERF: This trick only works with axis-aligned box selection and the
// current convex element shapes!
if (boundsContainBounds(selectionBounds, commonAABB)) {
if (framesInSelection && isFrameLikeElement(element)) {
framesInSelection.add(element.id);
}
elementsInSelection.add(element);
continue;
}
// 2. Handle the case where the label is overlapped by the selection box
if (
type === "overlap" &&
labelAABB &&
doBoundsIntersect(selectionBounds, labelAABB)
) {
elementsInSelection.add(element);
continue;
}
// 3. Handle the case where the selection is not wrapping the element, but
// it does intersect the element's outline (non-AABB).
if (type === "overlap" && doBoundsIntersect(selectionBounds, elementAABB)) {
let hasIntersection = false;
// Preliminary check potential intersection imprecision
if (isLinearElement(element) || isFreeDrawElement(element)) {
const center = elementCenterPoint(element, elementsMap);
hasIntersection = element.points.some((point) => {
const rotatedPoint = pointRotateRads(
pointFrom<GlobalPoint>(element.x + point[0], element.y + point[1]),
center,
element.angle,
);
return pointInsideBounds(rotatedPoint, selectionBounds);
});
} else {
const nonRotatedElementBounds = getElementBounds(
element,
elementsMap,
true,
);
const center = elementCenterPoint(element, elementsMap);
hasIntersection = [
pointRotateRads(
pointFrom<GlobalPoint>(
(nonRotatedElementBounds[0] + nonRotatedElementBounds[2]) / 2,
nonRotatedElementBounds[1],
),
center,
element.angle,
),
pointRotateRads(
pointFrom<GlobalPoint>(
nonRotatedElementBounds[2],
(nonRotatedElementBounds[1] + nonRotatedElementBounds[3]) / 2,
),
center,
element.angle,
),
pointRotateRads(
pointFrom<GlobalPoint>(
(nonRotatedElementBounds[0] + nonRotatedElementBounds[2]) / 2,
nonRotatedElementBounds[3],
),
center,
element.angle,
),
pointRotateRads(
pointFrom<GlobalPoint>(
nonRotatedElementBounds[0],
(nonRotatedElementBounds[1] + nonRotatedElementBounds[3]) / 2,
),
center,
element.angle,
),
].some((point) => {
return pointInsideBounds(
pointRotateRads(point, center, element.angle),
selectionBounds,
);
});
}
if (!hasIntersection) {
hasIntersection = selectionEdges.some(
(selectionEdge) =>
intersectElementWithLineSegment(
element,
elementsMap,
selectionEdge,
strokeWidth / 2,
true, // Stop at first hit for better performance
).length > 0,
);
}
if (hasIntersection) {
if (framesInSelection && isFrameLikeElement(element)) {
framesInSelection.add(element.id);
}
elementsInSelection.add(element);
continue;
}
}
// 4. We don't need to handle when the selection is inside the element
// as it is separately handled in App.
}
if (framesInSelection) {
elementsInSelection.forEach((element) => {
if (element.frameId && framesInSelection.has(element.frameId)) {
elementsInSelection.delete(element);
}
});
}
if (type === "overlap") {
Array.from(elementsInSelection).forEach((element) => {
const groupId = element.groupIds.at(-1);
const group = groupId ? groups[groupId] : null;
group?.forEach((groupElement) => elementsInSelection.add(groupElement));
});
} else if (type === "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;
if (
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 elementCenterPoint = ( export const elementCenterPoint = (
element: ExcalidrawElement, element: ExcalidrawElement,
elementsMap: ElementsMap, elementsMap: ElementsMap,
+7 -2
View File
@@ -61,6 +61,8 @@ import { distanceToElement } from "./distance";
import { getBindingGap } from "./binding"; import { getBindingGap } from "./binding";
import { hasBackground } from "./comparisons";
import type { import type {
ElementsMap, ElementsMap,
ExcalidrawArrowElement, ExcalidrawArrowElement,
@@ -83,7 +85,7 @@ export const shouldTestInside = (element: ExcalidrawElement) => {
} }
const isDraggableFromInside = const isDraggableFromInside =
!isTransparent(element.backgroundColor) || (hasBackground(element.type) && !isTransparent(element.backgroundColor)) ||
hasBoundTextElement(element) || hasBoundTextElement(element) ||
isIframeLikeElement(element) || isIframeLikeElement(element) ||
isTextElement(element); isTextElement(element);
@@ -324,7 +326,10 @@ export const getAllHoveredElementAtPoint = (
) { ) {
candidateElements.push(element); candidateElements.push(element);
if (!isTransparent(element.backgroundColor)) { if (
hasBackground(element.type) &&
!isTransparent(element.backgroundColor)
) {
break; break;
} }
} }
+19 -2
View File
@@ -111,6 +111,9 @@ 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;
} }
| { | {
/** /**
@@ -170,6 +173,8 @@ 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") {
@@ -277,7 +282,7 @@ export const duplicateElements = (
if (groupId) { if (groupId) {
const groupElements = getElementsInGroup(elements, groupId).flatMap( const groupElements = getElementsInGroup(elements, groupId).flatMap(
(element) => (element) =>
isFrameLikeElement(element) isFrameLikeElement(element) && !preserveFrameChildrenOrder
? [...getFrameChildren(elements, element.id), element] ? [...getFrameChildren(elements, element.id), element]
: [element], : [element],
); );
@@ -293,13 +298,25 @@ export const duplicateElements = (
// frame duplication // frame duplication
// ------------------------------------------------------------------------- // -------------------------------------------------------------------------
if (element.frameId && frameIdsToDuplicate.has(element.frameId)) { if (
!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) => {
+12 -2
View File
@@ -1,7 +1,10 @@
import { generateNKeysBetween } from "fractional-indexing";
import { arrayToMap } from "@excalidraw/common"; import { arrayToMap } from "@excalidraw/common";
import {
validateOrderKey,
generateNKeysBetween,
} from "@excalidraw/fractional-indexing";
import { mutateElement, newElementWith } from "./mutateElement"; import { mutateElement, newElementWith } from "./mutateElement";
import { getBoundTextElement } from "./textElement"; import { getBoundTextElement } from "./textElement";
import { hasBoundTextElement } from "./typeChecks"; import { hasBoundTextElement } from "./typeChecks";
@@ -382,6 +385,13 @@ 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;
} }
+98 -43
View File
@@ -1,6 +1,9 @@
import { arrayToMap } from "@excalidraw/common"; import { arrayToMap } from "@excalidraw/common";
import { isPointWithinBounds, pointFrom } from "@excalidraw/math"; import {
import { doLineSegmentsIntersect } from "@excalidraw/utils/bbox"; isPointWithinBounds,
pointFrom,
segmentsIntersectAt,
} from "@excalidraw/math";
import type { import type {
AppClassProperties, AppClassProperties,
@@ -23,6 +26,7 @@ import {
} 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,
@@ -77,7 +81,7 @@ export function isElementIntersectingFrame(
const intersecting = frameLineSegments.some((frameLineSegment) => const intersecting = frameLineSegments.some((frameLineSegment) =>
elementLineSegments.some((elementLineSegment) => elementLineSegments.some((elementLineSegment) =>
doLineSegmentsIntersect(frameLineSegment, elementLineSegment), segmentsIntersectAt(frameLineSegment, elementLineSegment),
), ),
); );
@@ -491,10 +495,44 @@ 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;
};
/** /**
* Retains (or repairs for target frame) the ordering invriant where children * Adds elements and their bound elements to frame. Reorders added elements to
* elements come right before the parent frame: * be just below frame, or just above its highest child (whichever is higher).
* [el, el, child, child, frame, el]
* *
* @returns mutated allElements (same data structure) * @returns mutated allElements (same data structure)
*/ */
@@ -502,19 +540,11 @@ 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 currTargetFrameChildrenMap = new Map<ExcalidrawElement["id"], true>(); const commonFrameId = getCommonFrameId(elementsToAdd);
for (const element of allElements.values()) {
if (element.frameId === frame.id) {
currTargetFrameChildrenMap.set(element.id, true);
}
}
const suppliedElementsToAddSet = new Set(elementsToAdd.map((el) => el.id)); const finalElementsToAdd = new Set<ExcalidrawElement>();
const finalElementsToAdd: ExcalidrawElement[] = [];
const otherFrames = new Set<ExcalidrawFrameLikeElement["id"]>(); const otherFrames = new Set<ExcalidrawFrameLikeElement["id"]>();
@@ -525,7 +555,8 @@ 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
// - filter out elements that are already in the frame // - keep elements already in the frame so mixed selections can be reordered
// together
for (const element of omitGroupsContainingFrameLikes( for (const element of omitGroupsContainingFrameLikes(
allElements, allElements,
elementsToAdd, elementsToAdd,
@@ -538,38 +569,64 @@ export const addElementsToFrame = <T extends ElementsMapOrArray>(
continue; continue;
} }
// if the element is already in another frame (which is also in elementsToAdd), finalElementsToAdd.add(element);
// 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;
}
if (!currTargetFrameChildrenMap.has(element.id)) {
finalElementsToAdd.push(element);
}
const boundTextElement = getBoundTextElement(element, elementsMap); const boundTextElement = getBoundTextElement(element, elementsMap);
if ( if (boundTextElement && !finalElementsToAdd.has(boundTextElement)) {
boundTextElement && finalElementsToAdd.add(boundTextElement);
!suppliedElementsToAddSet.has(boundTextElement.id) &&
!currTargetFrameChildrenMap.has(boundTextElement.id)
) {
finalElementsToAdd.push(boundTextElement);
} }
} }
for (const element of finalElementsToAdd) { for (const element of finalElementsToAdd) {
mutateElement(element, elementsMap, { // we don't always need to update the element if it's already in the frame,
frameId: frame.id, // but we still need to accumulate in finalElementsToAdd so we potentially
}); // reorder them if added together
if (element.frameId !== frame.id) {
mutateElement(element, elementsMap, {
frameId: frame.id,
});
}
} }
return allElements; // (re)order elements to be just below the frame,
// 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 = (
@@ -623,13 +680,11 @@ 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();
}; };
+5 -5
View File
@@ -486,7 +486,7 @@ export class LinearElementEditor {
selectedPointsIndices, selectedPointsIndices,
)}) points(0..${ )}) points(0..${
element.points.length - 1 element.points.length - 1
}) lastClickedPoint(${lastClickedPoint})`, }) lastClickedPoint(${lastClickedPoint}) isElbowArrow: ${elbowed}`,
); );
// Fall back to the actual last point as a last resort. // Fall back to the actual last point as a last resort.
@@ -2139,13 +2139,13 @@ const pointDraggingUpdates = (
} => { } => {
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: pointFrom<LocalPoint>(point[0] + deltaX, point[1] + deltaY),
element.points[pointIndex][0] + deltaX,
element.points[pointIndex][1] + deltaY,
),
isDragging: true, isDragging: true,
}, },
]; ];
+12 -12
View File
@@ -422,10 +422,10 @@ const drawElementOnCanvas = (
for (const shape of shapes) { for (const shape of shapes) {
if (typeof shape === "string") { if (typeof shape === "string") {
context.fillStyle = context.fillStyle = applyDarkModeFilter(
renderConfig.theme === THEME.DARK element.strokeColor,
? applyDarkModeFilter(element.strokeColor) renderConfig.theme === THEME.DARK,
: element.strokeColor; );
context.fill(new Path2D(shape)); context.fill(new Path2D(shape));
} else { } else {
rc.draw(shape); rc.draw(shape);
@@ -555,10 +555,10 @@ const drawElementOnCanvas = (
context.canvas.setAttribute("dir", rtl ? "rtl" : "ltr"); context.canvas.setAttribute("dir", rtl ? "rtl" : "ltr");
context.save(); context.save();
context.font = getFontString(element); context.font = getFontString(element);
context.fillStyle = context.fillStyle = applyDarkModeFilter(
renderConfig.theme === THEME.DARK element.strokeColor,
? applyDarkModeFilter(element.strokeColor) renderConfig.theme === THEME.DARK,
: element.strokeColor; );
context.textAlign = element.textAlign as CanvasTextAlign; context.textAlign = element.textAlign as CanvasTextAlign;
// Canvas does not support multiline text by default // Canvas does not support multiline text by default
@@ -811,10 +811,10 @@ export const renderElement = (
context.fillStyle = "rgba(0, 0, 200, 0.04)"; context.fillStyle = "rgba(0, 0, 200, 0.04)";
context.lineWidth = FRAME_STYLE.strokeWidth / appState.zoom.value; context.lineWidth = FRAME_STYLE.strokeWidth / appState.zoom.value;
context.strokeStyle = context.strokeStyle = applyDarkModeFilter(
appState.theme === THEME.DARK FRAME_STYLE.strokeColor,
? applyDarkModeFilter(FRAME_STYLE.strokeColor) appState.theme === THEME.DARK,
: FRAME_STYLE.strokeColor; );
// TODO change later to only affect AI frames // TODO change later to only affect AI frames
if (isMagicFrameElement(element)) { if (isMagicFrameElement(element)) {
+9 -247
View File
@@ -1,10 +1,4 @@
import { arrayToMap, isShallowEqual, type Bounds } from "@excalidraw/common"; import { arrayToMap, isShallowEqual, type Bounds } from "@excalidraw/common";
import {
lineSegment,
pointFrom,
pointRotateRads,
type GlobalPoint,
} from "@excalidraw/math";
import type { import type {
AppState, AppState,
@@ -12,33 +6,18 @@ import type {
InteractiveCanvasAppState, InteractiveCanvasAppState,
} from "@excalidraw/excalidraw/types"; } from "@excalidraw/excalidraw/types";
import { import { elementsOverlappingBBox, getElementAbsoluteCoords } from "./bounds";
boundsContainBounds,
doBoundsIntersect,
elementCenterPoint,
getElementAbsoluteCoords,
getElementBounds,
pointInsideBounds,
} from "./bounds";
import { intersectElementWithLineSegment } from "./collision";
import { isElementInViewport } from "./sizeHelpers"; import { isElementInViewport } from "./sizeHelpers";
import { import {
isArrowElement,
isBoundToContainer, isBoundToContainer,
isFrameLikeElement, isFrameLikeElement,
isFreeDrawElement,
isLinearElement, isLinearElement,
isTextElement, isTextElement,
} from "./typeChecks"; } from "./typeChecks";
import { import { getFrameChildren } from "./frame";
elementOverlapsWithFrame,
getContainingFrame,
getFrameChildren,
} from "./frame";
import { LinearElementEditor } from "./linearElementEditor"; import { LinearElementEditor } from "./linearElementEditor";
import { selectGroupsForSelectedElements } from "./groups"; import { selectGroupsForSelectedElements } from "./groups";
import { getBoundTextElement } from "./textElement";
import type { import type {
ElementsMap, ElementsMap,
@@ -107,232 +86,15 @@ export const getElementsWithinSelection = (
selectionX2, selectionX2,
selectionY2, selectionY2,
] as Bounds; ] as Bounds;
const selectionEdges = [
lineSegment<GlobalPoint>(
pointFrom(selectionX1, selectionY1),
pointFrom(selectionX2, selectionY1),
),
lineSegment<GlobalPoint>(
pointFrom(selectionX2, selectionY1),
pointFrom(selectionX2, selectionY2),
),
lineSegment<GlobalPoint>(
pointFrom(selectionX2, selectionY2),
pointFrom(selectionX1, selectionY2),
),
lineSegment<GlobalPoint>(
pointFrom(selectionX1, selectionY2),
pointFrom(selectionX1, selectionY1),
),
];
const framesInSelection = excludeElementsInFrames return elementsOverlappingBBox({
? new Set<NonDeletedExcalidrawElement["id"]>() elements,
: null; bounds: selectionBounds,
let elementsInSelection: NonDeletedExcalidrawElement[] = []; elementsMap,
type: boxSelectionMode,
for (const element of elements) { shouldIgnoreElementFromSelection,
if (shouldIgnoreElementFromSelection(element)) { excludeElementsInFrames,
continue;
}
const strokeWidth = element.strokeWidth;
let labelAABB: Bounds | null = null;
let elementAABB = getElementBounds(element, elementsMap);
elementAABB = [
elementAABB[0] - strokeWidth / 2,
elementAABB[1] - strokeWidth / 2,
elementAABB[2] + strokeWidth / 2,
elementAABB[3] + strokeWidth / 2,
] as Bounds;
// Whether the element bounds should include the bound text element bounds
const boundTextElement =
isArrowElement(element) && getBoundTextElement(element, elementsMap);
if (boundTextElement) {
const { x, y } = LinearElementEditor.getBoundTextElementPosition(
element,
boundTextElement,
elementsMap,
);
labelAABB = [
x,
y,
x + boundTextElement.width,
y + boundTextElement.height,
] as Bounds;
}
// Clip element bounds by its containing frame (if any), since only the
// visible (frame-clipped) portion of the element is relevant for selection.
const associatedFrame = getContainingFrame(element, elementsMap);
if (
associatedFrame &&
elementOverlapsWithFrame(element, associatedFrame, elementsMap)
) {
const frameAABB = getElementBounds(associatedFrame, elementsMap);
elementAABB = [
Math.max(elementAABB[0], frameAABB[0]),
Math.max(elementAABB[1], frameAABB[1]),
Math.min(elementAABB[2], frameAABB[2]),
Math.min(elementAABB[3], frameAABB[3]),
] as Bounds;
labelAABB = labelAABB
? ([
Math.max(labelAABB[0], frameAABB[0]),
Math.max(labelAABB[1], frameAABB[1]),
Math.min(labelAABB[2], frameAABB[2]),
Math.min(labelAABB[3], frameAABB[3]),
] as Bounds)
: null;
}
const commonAABB = labelAABB
? ([
Math.min(labelAABB[0], elementAABB[0]),
Math.min(labelAABB[1], elementAABB[1]),
Math.max(labelAABB[2], elementAABB[2]),
Math.max(labelAABB[3], elementAABB[3]),
] as Bounds)
: elementAABB;
// ============== Evaluation ==============
// 1. If the selection box WRAPs the element's AABB, then add it to the
// selection and move on, regardless of the selection mode.
//
// PERF: This trick only works with axis-aligned box selection and the
// current convex element shapes!
if (boundsContainBounds(selectionBounds, commonAABB)) {
if (framesInSelection && isFrameLikeElement(element)) {
framesInSelection.add(element.id);
}
elementsInSelection.push(element);
continue;
}
// 2. Handle the case where the label is overlapped by the selection box
if (
boxSelectionMode === "overlap" &&
labelAABB &&
doBoundsIntersect(selectionBounds, labelAABB)
) {
elementsInSelection.push(element);
continue;
}
// 3. Handle the case where the selection is not wrapping the element, but
// it does intersect the element's outline (non-AABB).
if (
boxSelectionMode === "overlap" &&
doBoundsIntersect(selectionBounds, elementAABB)
) {
let hasIntersection = false;
// Preliminary check potential intersection imprecision
if (isLinearElement(element) || isFreeDrawElement(element)) {
const center = elementCenterPoint(element, elementsMap);
hasIntersection = element.points.some((point) => {
const rotatedPoint = pointRotateRads(
pointFrom<GlobalPoint>(element.x + point[0], element.y + point[1]),
center,
element.angle,
);
return pointInsideBounds(rotatedPoint, selectionBounds);
});
} else {
const nonRotatedElementBounds = getElementBounds(
element,
elementsMap,
true,
);
const center = elementCenterPoint(element, elementsMap);
hasIntersection = [
pointRotateRads(
pointFrom<GlobalPoint>(
(nonRotatedElementBounds[0] + nonRotatedElementBounds[2]) / 2,
nonRotatedElementBounds[1],
),
center,
element.angle,
),
pointRotateRads(
pointFrom<GlobalPoint>(
nonRotatedElementBounds[2],
(nonRotatedElementBounds[1] + nonRotatedElementBounds[3]) / 2,
),
center,
element.angle,
),
pointRotateRads(
pointFrom<GlobalPoint>(
(nonRotatedElementBounds[0] + nonRotatedElementBounds[2]) / 2,
nonRotatedElementBounds[3],
),
center,
element.angle,
),
pointRotateRads(
pointFrom<GlobalPoint>(
nonRotatedElementBounds[0],
(nonRotatedElementBounds[1] + nonRotatedElementBounds[3]) / 2,
),
center,
element.angle,
),
].some((point) => {
return pointInsideBounds(
pointRotateRads(point, center, element.angle),
selectionBounds,
);
});
}
if (!hasIntersection) {
hasIntersection = selectionEdges.some(
(selectionEdge) =>
intersectElementWithLineSegment(
element,
elementsMap,
selectionEdge,
strokeWidth / 2,
true, // Stop at first hit for better performance
).length > 0,
);
}
if (hasIntersection) {
if (framesInSelection && isFrameLikeElement(element)) {
framesInSelection.add(element.id);
}
elementsInSelection.push(element);
continue;
}
}
// 4. We don't need to handle when the selection is inside the element
// as it is separately handled in App.
}
elementsInSelection = framesInSelection
? excludeElementsFromFrames(elementsInSelection, framesInSelection)
: elementsInSelection;
elementsInSelection = elementsInSelection.filter((element) => {
const containingFrame = getContainingFrame(element, elementsMap);
if (containingFrame) {
return elementOverlapsWithFrame(element, containingFrame, elementsMap);
}
return true;
}); });
return elementsInSelection;
}; };
export const getVisibleAndNonSelectedElements = ( export const getVisibleAndNonSelectedElements = (
+8 -15
View File
@@ -218,9 +218,7 @@ export const generateRoughOptions = (
fillWeight: element.strokeWidth / 2, fillWeight: element.strokeWidth / 2,
hachureGap: element.strokeWidth * 4, hachureGap: element.strokeWidth * 4,
roughness: adjustRoughness(element), roughness: adjustRoughness(element),
stroke: isDarkMode stroke: applyDarkModeFilter(element.strokeColor, isDarkMode),
? applyDarkModeFilter(element.strokeColor)
: element.strokeColor,
preserveVertices: preserveVertices:
continuousPath || element.roughness < ROUGHNESS.cartoonist, continuousPath || element.roughness < ROUGHNESS.cartoonist,
}; };
@@ -234,9 +232,7 @@ export const generateRoughOptions = (
options.fillStyle = element.fillStyle; options.fillStyle = element.fillStyle;
options.fill = isTransparent(element.backgroundColor) options.fill = isTransparent(element.backgroundColor)
? undefined ? undefined
: isDarkMode : applyDarkModeFilter(element.backgroundColor, isDarkMode);
? applyDarkModeFilter(element.backgroundColor)
: element.backgroundColor;
if (element.type === "ellipse") { if (element.type === "ellipse") {
options.curveFitting = 1; options.curveFitting = 1;
} }
@@ -249,9 +245,7 @@ export const generateRoughOptions = (
options.fill = options.fill =
element.backgroundColor === "transparent" element.backgroundColor === "transparent"
? undefined ? undefined
: isDarkMode : applyDarkModeFilter(element.backgroundColor, isDarkMode);
? applyDarkModeFilter(element.backgroundColor)
: element.backgroundColor;
} }
return options; return options;
} }
@@ -386,12 +380,11 @@ const getArrowheadShapes = (
return []; return [];
} }
const strokeColor = isDarkMode const strokeColor = applyDarkModeFilter(element.strokeColor, isDarkMode);
? applyDarkModeFilter(element.strokeColor) const backgroundFillColor = applyDarkModeFilter(
: element.strokeColor; canvasBackgroundColor,
const backgroundFillColor = isDarkMode isDarkMode,
? applyDarkModeFilter(canvasBackgroundColor) );
: canvasBackgroundColor;
const cardinalityOneOrManyOffset = -0.25; const cardinalityOneOrManyOffset = -0.25;
const cardinalityZeroCircleScale = 0.8; const cardinalityZeroCircleScale = 0.8;
+20
View File
@@ -392,3 +392,23 @@ 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;
}
}
};
+1 -1
View File
@@ -5,6 +5,7 @@ import {
pointFrom, pointFrom,
type GlobalPoint, type GlobalPoint,
type LocalPoint, type LocalPoint,
type LineSegment,
} from "@excalidraw/math"; } from "@excalidraw/math";
import { type Bounds, isBounds } from "@excalidraw/common"; import { type Bounds, isBounds } from "@excalidraw/common";
import { import {
@@ -17,7 +18,6 @@ import {
import type { ElementsMap, ExcalidrawElement } from "@excalidraw/element/types"; import type { ElementsMap, ExcalidrawElement } from "@excalidraw/element/types";
import type { Curve } from "@excalidraw/math"; import type { Curve } from "@excalidraw/math";
import type { LineSegment } from "@excalidraw/utils";
// The global data holder to collect the debug operations // The global data holder to collect the debug operations
declare global { declare global {
+69 -3
View File
@@ -1,5 +1,7 @@
import { arrayToMap, findIndex, findLastIndex } from "@excalidraw/common"; import { arrayToMap, findIndex, findLastIndex } from "@excalidraw/common";
import { isFiniteNumber } from "@excalidraw/math";
import type { AppState } from "@excalidraw/excalidraw/types"; import type { AppState } from "@excalidraw/excalidraw/types";
import type { GlobalPoint } from "@excalidraw/math"; import type { GlobalPoint } from "@excalidraw/math";
@@ -313,12 +315,46 @@ const getTargetElementsMap = <T extends ExcalidrawElement>(
}, new Map<string, ExcalidrawElement>()); }, new Map<string, ExcalidrawElement>());
}; };
const hasSameElementIds = (
prevElements: readonly ExcalidrawElement[],
nextElements: readonly ExcalidrawElement[],
) => {
if (prevElements.length !== nextElements.length) {
console.error(
"z-index reordering failed: resulting array have different lengths",
);
return false;
}
const prevElementIdCounts = new Map<ExcalidrawElement["id"], number>();
for (const element of prevElements) {
prevElementIdCounts.set(
element.id,
(prevElementIdCounts.get(element.id) || 0) + 1,
);
}
for (const element of nextElements) {
const count = prevElementIdCounts.get(element.id);
if (!count) {
console.error(
"z-index reordering failed: element id mismatch / duplicate ids",
);
return false;
}
prevElementIdCounts.set(element.id, count - 1);
}
return true;
};
const shiftElementsByOne = ( const shiftElementsByOne = (
elements: readonly ExcalidrawElement[], elements: readonly ExcalidrawElement[],
appState: AppState, appState: AppState,
direction: "left" | "right", direction: "left" | "right",
scene: Scene, scene: Scene,
) => { ) => {
const originalElements = elements;
const indicesToMove = getIndicesToMove(elements, appState); const indicesToMove = getIndicesToMove(elements, appState);
const targetElementsMap = getTargetElementsMap(elements, indicesToMove); const targetElementsMap = getTargetElementsMap(elements, indicesToMove);
@@ -389,6 +425,10 @@ const shiftElementsByOne = (
]; ];
}); });
if (!hasSameElementIds(originalElements, elements)) {
return originalElements;
}
syncMovedIndices(elements, targetElementsMap); syncMovedIndices(elements, targetElementsMap);
return elements; return elements;
@@ -402,11 +442,20 @@ const shiftElementsToEnd = (
elementsToBeMoved?: readonly ExcalidrawElement[], elementsToBeMoved?: readonly ExcalidrawElement[],
) => { ) => {
const indicesToMove = getIndicesToMove(elements, appState, elementsToBeMoved); const indicesToMove = getIndicesToMove(elements, appState, elementsToBeMoved);
// Nothing to move (e.g. `elementsToBeMoved` is empty because all selected
// elements were frame children handled in a prior pass). Bail out early —
// otherwise `leadingIndex`/`trailingIndex` below resolve to `undefined` and
// the resulting `slice()` calls overlap, duplicating elements.
if (indicesToMove.length === 0) {
return elements;
}
const targetElementsMap = getTargetElementsMap(elements, indicesToMove); const targetElementsMap = getTargetElementsMap(elements, indicesToMove);
const displacedElements: ExcalidrawElement[] = []; const displacedElements: ExcalidrawElement[] = [];
let leadingIndex: number; let leadingIndex: number | undefined;
let trailingIndex: number; let trailingIndex: number | undefined;
if (direction === "left") { if (direction === "left") {
if (containingFrame) { if (containingFrame) {
leadingIndex = findIndex(elements, (el) => leadingIndex = findIndex(elements, (el) =>
@@ -451,6 +500,19 @@ const shiftElementsToEnd = (
leadingIndex = 0; leadingIndex = 0;
} }
const isValidIndex = (index: number | undefined): index is number => {
return isFiniteNumber(index) && index >= 0;
};
if (
!isValidIndex(leadingIndex) ||
!isValidIndex(trailingIndex) ||
leadingIndex > trailingIndex ||
indicesToMove.some((index) => index < leadingIndex || index > trailingIndex)
) {
return elements;
}
for (let index = leadingIndex; index < trailingIndex + 1; index++) { for (let index = leadingIndex; index < trailingIndex + 1; index++) {
if (!indicesToMove.includes(index)) { if (!indicesToMove.includes(index)) {
displacedElements.push(elements[index]); displacedElements.push(elements[index]);
@@ -475,6 +537,10 @@ const shiftElementsToEnd = (
...trailingElements, ...trailingElements,
]; ];
if (!hasSameElementIds(elements, nextElements)) {
return elements;
}
syncMovedIndices(nextElements, targetElementsMap); syncMovedIndices(nextElements, targetElementsMap);
return nextElements; return nextElements;
@@ -543,7 +609,7 @@ function shiftElementsAccountingForFrames(
for (const [frameId, children] of frameChildrenSets) { for (const [frameId, children] of frameChildrenSets) {
nextElements = shiftFunction( nextElements = shiftFunction(
allElements, nextElements,
appState, appState,
direction, direction,
frameId, frameId,
+300 -300
View File
@@ -72,123 +72,123 @@ describe("aligning", () => {
it("aligns two objects correctly to the top", () => { it("aligns two objects correctly to the top", () => {
createAndSelectTwoRectangles(); createAndSelectTwoRectangles();
expect(API.getSelectedElements()[0].x).toEqual(0); expect(API.getSelectedElements()[0].x).toBeCloseTo(0);
expect(API.getSelectedElements()[1].x).toEqual(110); expect(API.getSelectedElements()[1].x).toBeCloseTo(110);
expect(API.getSelectedElements()[0].y).toEqual(0); expect(API.getSelectedElements()[0].y).toBeCloseTo(0);
expect(API.getSelectedElements()[1].y).toEqual(110); expect(API.getSelectedElements()[1].y).toBeCloseTo(110);
Keyboard.withModifierKeys({ ctrl: true, shift: true }, () => { Keyboard.withModifierKeys({ ctrl: true, shift: true }, () => {
Keyboard.keyPress(KEYS.ARROW_UP); Keyboard.keyPress(KEYS.ARROW_UP);
}); });
// Check if x position did not change // Check if x position did not change
expect(API.getSelectedElements()[0].x).toEqual(0); expect(API.getSelectedElements()[0].x).toBeCloseTo(0);
expect(API.getSelectedElements()[1].x).toEqual(110); expect(API.getSelectedElements()[1].x).toBeCloseTo(110);
expect(API.getSelectedElements()[0].y).toEqual(0); expect(API.getSelectedElements()[0].y).toBeCloseTo(0);
expect(API.getSelectedElements()[1].y).toEqual(0); expect(API.getSelectedElements()[1].y).toBeCloseTo(0);
}); });
it("aligns two objects correctly to the bottom", () => { it("aligns two objects correctly to the bottom", () => {
createAndSelectTwoRectangles(); createAndSelectTwoRectangles();
expect(API.getSelectedElements()[0].x).toEqual(0); expect(API.getSelectedElements()[0].x).toBeCloseTo(0);
expect(API.getSelectedElements()[1].x).toEqual(110); expect(API.getSelectedElements()[1].x).toBeCloseTo(110);
expect(API.getSelectedElements()[0].y).toEqual(0); expect(API.getSelectedElements()[0].y).toBeCloseTo(0);
expect(API.getSelectedElements()[1].y).toEqual(110); expect(API.getSelectedElements()[1].y).toBeCloseTo(110);
Keyboard.withModifierKeys({ ctrl: true, shift: true }, () => { Keyboard.withModifierKeys({ ctrl: true, shift: true }, () => {
Keyboard.keyPress(KEYS.ARROW_DOWN); Keyboard.keyPress(KEYS.ARROW_DOWN);
}); });
// Check if x position did not change // Check if x position did not change
expect(API.getSelectedElements()[0].x).toEqual(0); expect(API.getSelectedElements()[0].x).toBeCloseTo(0);
expect(API.getSelectedElements()[1].x).toEqual(110); expect(API.getSelectedElements()[1].x).toBeCloseTo(110);
expect(API.getSelectedElements()[0].y).toEqual(110); expect(API.getSelectedElements()[0].y).toBeCloseTo(110);
expect(API.getSelectedElements()[1].y).toEqual(110); expect(API.getSelectedElements()[1].y).toBeCloseTo(110);
}); });
it("aligns two objects correctly to the left", () => { it("aligns two objects correctly to the left", () => {
createAndSelectTwoRectangles(); createAndSelectTwoRectangles();
expect(API.getSelectedElements()[0].x).toEqual(0); expect(API.getSelectedElements()[0].x).toBeCloseTo(0);
expect(API.getSelectedElements()[1].x).toEqual(110); expect(API.getSelectedElements()[1].x).toBeCloseTo(110);
expect(API.getSelectedElements()[0].y).toEqual(0); expect(API.getSelectedElements()[0].y).toBeCloseTo(0);
expect(API.getSelectedElements()[1].y).toEqual(110); expect(API.getSelectedElements()[1].y).toBeCloseTo(110);
Keyboard.withModifierKeys({ ctrl: true, shift: true }, () => { Keyboard.withModifierKeys({ ctrl: true, shift: true }, () => {
Keyboard.keyPress(KEYS.ARROW_LEFT); Keyboard.keyPress(KEYS.ARROW_LEFT);
}); });
expect(API.getSelectedElements()[0].x).toEqual(0); expect(API.getSelectedElements()[0].x).toBeCloseTo(0);
expect(API.getSelectedElements()[1].x).toEqual(0); expect(API.getSelectedElements()[1].x).toBeCloseTo(0);
// Check if y position did not change // Check if y position did not change
expect(API.getSelectedElements()[0].y).toEqual(0); expect(API.getSelectedElements()[0].y).toBeCloseTo(0);
expect(API.getSelectedElements()[1].y).toEqual(110); expect(API.getSelectedElements()[1].y).toBeCloseTo(110);
}); });
it("aligns two objects correctly to the right", () => { it("aligns two objects correctly to the right", () => {
createAndSelectTwoRectangles(); createAndSelectTwoRectangles();
expect(API.getSelectedElements()[0].x).toEqual(0); expect(API.getSelectedElements()[0].x).toBeCloseTo(0);
expect(API.getSelectedElements()[1].x).toEqual(110); expect(API.getSelectedElements()[1].x).toBeCloseTo(110);
expect(API.getSelectedElements()[0].y).toEqual(0); expect(API.getSelectedElements()[0].y).toBeCloseTo(0);
expect(API.getSelectedElements()[1].y).toEqual(110); expect(API.getSelectedElements()[1].y).toBeCloseTo(110);
Keyboard.withModifierKeys({ ctrl: true, shift: true }, () => { Keyboard.withModifierKeys({ ctrl: true, shift: true }, () => {
Keyboard.keyPress(KEYS.ARROW_RIGHT); Keyboard.keyPress(KEYS.ARROW_RIGHT);
}); });
expect(API.getSelectedElements()[0].x).toEqual(110); expect(API.getSelectedElements()[0].x).toBeCloseTo(110);
expect(API.getSelectedElements()[1].x).toEqual(110); expect(API.getSelectedElements()[1].x).toBeCloseTo(110);
// Check if y position did not change // Check if y position did not change
expect(API.getSelectedElements()[0].y).toEqual(0); expect(API.getSelectedElements()[0].y).toBeCloseTo(0);
expect(API.getSelectedElements()[1].y).toEqual(110); expect(API.getSelectedElements()[1].y).toBeCloseTo(110);
}); });
it("centers two objects with different sizes correctly vertically", () => { it("centers two objects with different sizes correctly vertically", () => {
createAndSelectTwoRectanglesWithDifferentSizes(); createAndSelectTwoRectanglesWithDifferentSizes();
expect(API.getSelectedElements()[0].x).toEqual(0); expect(API.getSelectedElements()[0].x).toBeCloseTo(0);
expect(API.getSelectedElements()[1].x).toEqual(110); expect(API.getSelectedElements()[1].x).toBeCloseTo(110);
expect(API.getSelectedElements()[0].y).toEqual(0); expect(API.getSelectedElements()[0].y).toBeCloseTo(0);
expect(API.getSelectedElements()[1].y).toEqual(110); expect(API.getSelectedElements()[1].y).toBeCloseTo(110);
API.executeAction(actionAlignVerticallyCentered); API.executeAction(actionAlignVerticallyCentered);
// Check if x position did not change // Check if x position did not change
expect(API.getSelectedElements()[0].x).toEqual(0); expect(API.getSelectedElements()[0].x).toBeCloseTo(0);
expect(API.getSelectedElements()[1].x).toEqual(110); expect(API.getSelectedElements()[1].x).toBeCloseTo(110);
expect(API.getSelectedElements()[0].y).toEqual(60); expect(API.getSelectedElements()[0].y).toBeCloseTo(60);
expect(API.getSelectedElements()[1].y).toEqual(55); expect(API.getSelectedElements()[1].y).toBeCloseTo(55);
}); });
it("centers two objects with different sizes correctly horizontally", () => { it("centers two objects with different sizes correctly horizontally", () => {
createAndSelectTwoRectanglesWithDifferentSizes(); createAndSelectTwoRectanglesWithDifferentSizes();
expect(API.getSelectedElements()[0].x).toEqual(0); expect(API.getSelectedElements()[0].x).toBeCloseTo(0);
expect(API.getSelectedElements()[1].x).toEqual(110); expect(API.getSelectedElements()[1].x).toBeCloseTo(110);
expect(API.getSelectedElements()[0].y).toEqual(0); expect(API.getSelectedElements()[0].y).toBeCloseTo(0);
expect(API.getSelectedElements()[1].y).toEqual(110); expect(API.getSelectedElements()[1].y).toBeCloseTo(110);
API.executeAction(actionAlignHorizontallyCentered); API.executeAction(actionAlignHorizontallyCentered);
expect(API.getSelectedElements()[0].x).toEqual(60); expect(API.getSelectedElements()[0].x).toBeCloseTo(60);
expect(API.getSelectedElements()[1].x).toEqual(55); expect(API.getSelectedElements()[1].x).toBeCloseTo(55);
// Check if y position did not change // Check if y position did not change
expect(API.getSelectedElements()[0].y).toEqual(0); expect(API.getSelectedElements()[0].y).toBeCloseTo(0);
expect(API.getSelectedElements()[1].y).toEqual(110); expect(API.getSelectedElements()[1].y).toBeCloseTo(110);
}); });
const createAndSelectGroupAndRectangle = () => { const createAndSelectGroupAndRectangle = () => {
@@ -226,85 +226,85 @@ describe("aligning", () => {
it("aligns a group with another element correctly to the top", () => { it("aligns a group with another element correctly to the top", () => {
createAndSelectGroupAndRectangle(); createAndSelectGroupAndRectangle();
expect(API.getSelectedElements()[0].y).toEqual(0); expect(API.getSelectedElements()[0].y).toBeCloseTo(0);
expect(API.getSelectedElements()[1].y).toEqual(100); expect(API.getSelectedElements()[1].y).toBeCloseTo(100);
expect(API.getSelectedElements()[2].y).toEqual(200); expect(API.getSelectedElements()[2].y).toBeCloseTo(200);
API.executeAction(actionAlignTop); API.executeAction(actionAlignTop);
expect(API.getSelectedElements()[0].y).toEqual(0); expect(API.getSelectedElements()[0].y).toBeCloseTo(0);
expect(API.getSelectedElements()[1].y).toEqual(100); expect(API.getSelectedElements()[1].y).toBeCloseTo(100);
expect(API.getSelectedElements()[2].y).toEqual(0); expect(API.getSelectedElements()[2].y).toBeCloseTo(0);
}); });
it("aligns a group with another element correctly to the bottom", () => { it("aligns a group with another element correctly to the bottom", () => {
createAndSelectGroupAndRectangle(); createAndSelectGroupAndRectangle();
expect(API.getSelectedElements()[0].y).toEqual(0); expect(API.getSelectedElements()[0].y).toBeCloseTo(0);
expect(API.getSelectedElements()[1].y).toEqual(100); expect(API.getSelectedElements()[1].y).toBeCloseTo(100);
expect(API.getSelectedElements()[2].y).toEqual(200); expect(API.getSelectedElements()[2].y).toBeCloseTo(200);
API.executeAction(actionAlignBottom); API.executeAction(actionAlignBottom);
expect(API.getSelectedElements()[0].y).toEqual(100); expect(API.getSelectedElements()[0].y).toBeCloseTo(100);
expect(API.getSelectedElements()[1].y).toEqual(200); expect(API.getSelectedElements()[1].y).toBeCloseTo(200);
expect(API.getSelectedElements()[2].y).toEqual(200); expect(API.getSelectedElements()[2].y).toBeCloseTo(200);
}); });
it("aligns a group with another element correctly to the left", () => { it("aligns a group with another element correctly to the left", () => {
createAndSelectGroupAndRectangle(); createAndSelectGroupAndRectangle();
expect(API.getSelectedElements()[0].x).toEqual(0); expect(API.getSelectedElements()[0].x).toBeCloseTo(0);
expect(API.getSelectedElements()[1].x).toEqual(100); expect(API.getSelectedElements()[1].x).toBeCloseTo(100);
expect(API.getSelectedElements()[2].x).toEqual(200); expect(API.getSelectedElements()[2].x).toBeCloseTo(200);
API.executeAction(actionAlignLeft); API.executeAction(actionAlignLeft);
expect(API.getSelectedElements()[0].x).toEqual(0); expect(API.getSelectedElements()[0].x).toBeCloseTo(0);
expect(API.getSelectedElements()[1].x).toEqual(100); expect(API.getSelectedElements()[1].x).toBeCloseTo(100);
expect(API.getSelectedElements()[2].x).toEqual(0); expect(API.getSelectedElements()[2].x).toBeCloseTo(0);
}); });
it("aligns a group with another element correctly to the right", () => { it("aligns a group with another element correctly to the right", () => {
createAndSelectGroupAndRectangle(); createAndSelectGroupAndRectangle();
expect(API.getSelectedElements()[0].x).toEqual(0); expect(API.getSelectedElements()[0].x).toBeCloseTo(0);
expect(API.getSelectedElements()[1].x).toEqual(100); expect(API.getSelectedElements()[1].x).toBeCloseTo(100);
expect(API.getSelectedElements()[2].x).toEqual(200); expect(API.getSelectedElements()[2].x).toBeCloseTo(200);
API.executeAction(actionAlignRight); API.executeAction(actionAlignRight);
expect(API.getSelectedElements()[0].x).toEqual(100); expect(API.getSelectedElements()[0].x).toBeCloseTo(100);
expect(API.getSelectedElements()[1].x).toEqual(200); expect(API.getSelectedElements()[1].x).toBeCloseTo(200);
expect(API.getSelectedElements()[2].x).toEqual(200); expect(API.getSelectedElements()[2].x).toBeCloseTo(200);
}); });
it("centers a group with another element correctly vertically", () => { it("centers a group with another element correctly vertically", () => {
createAndSelectGroupAndRectangle(); createAndSelectGroupAndRectangle();
expect(API.getSelectedElements()[0].y).toEqual(0); expect(API.getSelectedElements()[0].y).toBeCloseTo(0);
expect(API.getSelectedElements()[1].y).toEqual(100); expect(API.getSelectedElements()[1].y).toBeCloseTo(100);
expect(API.getSelectedElements()[2].y).toEqual(200); expect(API.getSelectedElements()[2].y).toBeCloseTo(200);
API.executeAction(actionAlignVerticallyCentered); API.executeAction(actionAlignVerticallyCentered);
expect(API.getSelectedElements()[0].y).toEqual(50); expect(API.getSelectedElements()[0].y).toBeCloseTo(50);
expect(API.getSelectedElements()[1].y).toEqual(150); expect(API.getSelectedElements()[1].y).toBeCloseTo(150);
expect(API.getSelectedElements()[2].y).toEqual(100); expect(API.getSelectedElements()[2].y).toBeCloseTo(100);
}); });
it("centers a group with another element correctly horizontally", () => { it("centers a group with another element correctly horizontally", () => {
createAndSelectGroupAndRectangle(); createAndSelectGroupAndRectangle();
expect(API.getSelectedElements()[0].x).toEqual(0); expect(API.getSelectedElements()[0].x).toBeCloseTo(0);
expect(API.getSelectedElements()[1].x).toEqual(100); expect(API.getSelectedElements()[1].x).toBeCloseTo(100);
expect(API.getSelectedElements()[2].x).toEqual(200); expect(API.getSelectedElements()[2].x).toBeCloseTo(200);
API.executeAction(actionAlignHorizontallyCentered); API.executeAction(actionAlignHorizontallyCentered);
expect(API.getSelectedElements()[0].x).toEqual(50); expect(API.getSelectedElements()[0].x).toBeCloseTo(50);
expect(API.getSelectedElements()[1].x).toEqual(150); expect(API.getSelectedElements()[1].x).toBeCloseTo(150);
expect(API.getSelectedElements()[2].x).toEqual(100); expect(API.getSelectedElements()[2].x).toBeCloseTo(100);
}); });
const createAndSelectTwoGroups = () => { const createAndSelectTwoGroups = () => {
@@ -354,97 +354,97 @@ describe("aligning", () => {
it("aligns two groups correctly to the top", () => { it("aligns two groups correctly to the top", () => {
createAndSelectTwoGroups(); createAndSelectTwoGroups();
expect(API.getSelectedElements()[0].y).toEqual(0); expect(API.getSelectedElements()[0].y).toBeCloseTo(0);
expect(API.getSelectedElements()[1].y).toEqual(100); expect(API.getSelectedElements()[1].y).toBeCloseTo(100);
expect(API.getSelectedElements()[2].y).toEqual(200); expect(API.getSelectedElements()[2].y).toBeCloseTo(200);
expect(API.getSelectedElements()[3].y).toEqual(300); expect(API.getSelectedElements()[3].y).toBeCloseTo(300);
API.executeAction(actionAlignTop); API.executeAction(actionAlignTop);
expect(API.getSelectedElements()[0].y).toEqual(0); expect(API.getSelectedElements()[0].y).toBeCloseTo(0);
expect(API.getSelectedElements()[1].y).toEqual(100); expect(API.getSelectedElements()[1].y).toBeCloseTo(100);
expect(API.getSelectedElements()[2].y).toEqual(0); expect(API.getSelectedElements()[2].y).toBeCloseTo(0);
expect(API.getSelectedElements()[3].y).toEqual(100); expect(API.getSelectedElements()[3].y).toBeCloseTo(100);
}); });
it("aligns two groups correctly to the bottom", () => { it("aligns two groups correctly to the bottom", () => {
createAndSelectTwoGroups(); createAndSelectTwoGroups();
expect(API.getSelectedElements()[0].y).toEqual(0); expect(API.getSelectedElements()[0].y).toBeCloseTo(0);
expect(API.getSelectedElements()[1].y).toEqual(100); expect(API.getSelectedElements()[1].y).toBeCloseTo(100);
expect(API.getSelectedElements()[2].y).toEqual(200); expect(API.getSelectedElements()[2].y).toBeCloseTo(200);
expect(API.getSelectedElements()[3].y).toEqual(300); expect(API.getSelectedElements()[3].y).toBeCloseTo(300);
API.executeAction(actionAlignBottom); API.executeAction(actionAlignBottom);
expect(API.getSelectedElements()[0].y).toEqual(200); expect(API.getSelectedElements()[0].y).toBeCloseTo(200);
expect(API.getSelectedElements()[1].y).toEqual(300); expect(API.getSelectedElements()[1].y).toBeCloseTo(300);
expect(API.getSelectedElements()[2].y).toEqual(200); expect(API.getSelectedElements()[2].y).toBeCloseTo(200);
expect(API.getSelectedElements()[3].y).toEqual(300); expect(API.getSelectedElements()[3].y).toBeCloseTo(300);
}); });
it("aligns two groups correctly to the left", () => { it("aligns two groups correctly to the left", () => {
createAndSelectTwoGroups(); createAndSelectTwoGroups();
expect(API.getSelectedElements()[0].x).toEqual(0); expect(API.getSelectedElements()[0].x).toBeCloseTo(0);
expect(API.getSelectedElements()[1].x).toEqual(100); expect(API.getSelectedElements()[1].x).toBeCloseTo(100);
expect(API.getSelectedElements()[2].x).toEqual(200); expect(API.getSelectedElements()[2].x).toBeCloseTo(200);
expect(API.getSelectedElements()[3].x).toEqual(300); expect(API.getSelectedElements()[3].x).toBeCloseTo(300);
API.executeAction(actionAlignLeft); API.executeAction(actionAlignLeft);
expect(API.getSelectedElements()[0].x).toEqual(0); expect(API.getSelectedElements()[0].x).toBeCloseTo(0);
expect(API.getSelectedElements()[1].x).toEqual(100); expect(API.getSelectedElements()[1].x).toBeCloseTo(100);
expect(API.getSelectedElements()[2].x).toEqual(0); expect(API.getSelectedElements()[2].x).toBeCloseTo(0);
expect(API.getSelectedElements()[3].x).toEqual(100); expect(API.getSelectedElements()[3].x).toBeCloseTo(100);
}); });
it("aligns two groups correctly to the right", () => { it("aligns two groups correctly to the right", () => {
createAndSelectTwoGroups(); createAndSelectTwoGroups();
expect(API.getSelectedElements()[0].x).toEqual(0); expect(API.getSelectedElements()[0].x).toBeCloseTo(0);
expect(API.getSelectedElements()[1].x).toEqual(100); expect(API.getSelectedElements()[1].x).toBeCloseTo(100);
expect(API.getSelectedElements()[2].x).toEqual(200); expect(API.getSelectedElements()[2].x).toBeCloseTo(200);
expect(API.getSelectedElements()[3].x).toEqual(300); expect(API.getSelectedElements()[3].x).toBeCloseTo(300);
API.executeAction(actionAlignRight); API.executeAction(actionAlignRight);
expect(API.getSelectedElements()[0].x).toEqual(200); expect(API.getSelectedElements()[0].x).toBeCloseTo(200);
expect(API.getSelectedElements()[1].x).toEqual(300); expect(API.getSelectedElements()[1].x).toBeCloseTo(300);
expect(API.getSelectedElements()[2].x).toEqual(200); expect(API.getSelectedElements()[2].x).toBeCloseTo(200);
expect(API.getSelectedElements()[3].x).toEqual(300); expect(API.getSelectedElements()[3].x).toBeCloseTo(300);
}); });
it("centers two groups correctly vertically", () => { it("centers two groups correctly vertically", () => {
createAndSelectTwoGroups(); createAndSelectTwoGroups();
expect(API.getSelectedElements()[0].y).toEqual(0); expect(API.getSelectedElements()[0].y).toBeCloseTo(0);
expect(API.getSelectedElements()[1].y).toEqual(100); expect(API.getSelectedElements()[1].y).toBeCloseTo(100);
expect(API.getSelectedElements()[2].y).toEqual(200); expect(API.getSelectedElements()[2].y).toBeCloseTo(200);
expect(API.getSelectedElements()[3].y).toEqual(300); expect(API.getSelectedElements()[3].y).toBeCloseTo(300);
API.executeAction(actionAlignVerticallyCentered); API.executeAction(actionAlignVerticallyCentered);
expect(API.getSelectedElements()[0].y).toEqual(100); expect(API.getSelectedElements()[0].y).toBeCloseTo(100);
expect(API.getSelectedElements()[1].y).toEqual(200); expect(API.getSelectedElements()[1].y).toBeCloseTo(200);
expect(API.getSelectedElements()[2].y).toEqual(100); expect(API.getSelectedElements()[2].y).toBeCloseTo(100);
expect(API.getSelectedElements()[3].y).toEqual(200); expect(API.getSelectedElements()[3].y).toBeCloseTo(200);
}); });
it("centers two groups correctly horizontally", () => { it("centers two groups correctly horizontally", () => {
createAndSelectTwoGroups(); createAndSelectTwoGroups();
expect(API.getSelectedElements()[0].x).toEqual(0); expect(API.getSelectedElements()[0].x).toBeCloseTo(0);
expect(API.getSelectedElements()[1].x).toEqual(100); expect(API.getSelectedElements()[1].x).toBeCloseTo(100);
expect(API.getSelectedElements()[2].x).toEqual(200); expect(API.getSelectedElements()[2].x).toBeCloseTo(200);
expect(API.getSelectedElements()[3].x).toEqual(300); expect(API.getSelectedElements()[3].x).toBeCloseTo(300);
API.executeAction(actionAlignHorizontallyCentered); API.executeAction(actionAlignHorizontallyCentered);
expect(API.getSelectedElements()[0].x).toEqual(100); expect(API.getSelectedElements()[0].x).toBeCloseTo(100);
expect(API.getSelectedElements()[1].x).toEqual(200); expect(API.getSelectedElements()[1].x).toBeCloseTo(200);
expect(API.getSelectedElements()[2].x).toEqual(100); expect(API.getSelectedElements()[2].x).toBeCloseTo(100);
expect(API.getSelectedElements()[3].x).toEqual(200); expect(API.getSelectedElements()[3].x).toBeCloseTo(200);
}); });
const createAndSelectNestedGroupAndRectangle = () => { const createAndSelectNestedGroupAndRectangle = () => {
@@ -497,97 +497,97 @@ describe("aligning", () => {
it("aligns nested group and other element correctly to the top", () => { it("aligns nested group and other element correctly to the top", () => {
createAndSelectNestedGroupAndRectangle(); createAndSelectNestedGroupAndRectangle();
expect(API.getSelectedElements()[0].y).toEqual(0); expect(API.getSelectedElements()[0].y).toBeCloseTo(0);
expect(API.getSelectedElements()[1].y).toEqual(100); expect(API.getSelectedElements()[1].y).toBeCloseTo(100);
expect(API.getSelectedElements()[2].y).toEqual(200); expect(API.getSelectedElements()[2].y).toBeCloseTo(200);
expect(API.getSelectedElements()[3].y).toEqual(300); expect(API.getSelectedElements()[3].y).toBeCloseTo(300);
API.executeAction(actionAlignTop); API.executeAction(actionAlignTop);
expect(API.getSelectedElements()[0].y).toEqual(0); expect(API.getSelectedElements()[0].y).toBeCloseTo(0);
expect(API.getSelectedElements()[1].y).toEqual(100); expect(API.getSelectedElements()[1].y).toBeCloseTo(100);
expect(API.getSelectedElements()[2].y).toEqual(200); expect(API.getSelectedElements()[2].y).toBeCloseTo(200);
expect(API.getSelectedElements()[3].y).toEqual(0); expect(API.getSelectedElements()[3].y).toBeCloseTo(0);
}); });
it("aligns nested group and other element correctly to the bottom", () => { it("aligns nested group and other element correctly to the bottom", () => {
createAndSelectNestedGroupAndRectangle(); createAndSelectNestedGroupAndRectangle();
expect(API.getSelectedElements()[0].y).toEqual(0); expect(API.getSelectedElements()[0].y).toBeCloseTo(0);
expect(API.getSelectedElements()[1].y).toEqual(100); expect(API.getSelectedElements()[1].y).toBeCloseTo(100);
expect(API.getSelectedElements()[2].y).toEqual(200); expect(API.getSelectedElements()[2].y).toBeCloseTo(200);
expect(API.getSelectedElements()[3].y).toEqual(300); expect(API.getSelectedElements()[3].y).toBeCloseTo(300);
API.executeAction(actionAlignBottom); API.executeAction(actionAlignBottom);
expect(API.getSelectedElements()[0].y).toEqual(100); expect(API.getSelectedElements()[0].y).toBeCloseTo(100);
expect(API.getSelectedElements()[1].y).toEqual(200); expect(API.getSelectedElements()[1].y).toBeCloseTo(200);
expect(API.getSelectedElements()[2].y).toEqual(300); expect(API.getSelectedElements()[2].y).toBeCloseTo(300);
expect(API.getSelectedElements()[3].y).toEqual(300); expect(API.getSelectedElements()[3].y).toBeCloseTo(300);
}); });
it("aligns nested group and other element correctly to the left", () => { it("aligns nested group and other element correctly to the left", () => {
createAndSelectNestedGroupAndRectangle(); createAndSelectNestedGroupAndRectangle();
expect(API.getSelectedElements()[0].x).toEqual(0); expect(API.getSelectedElements()[0].x).toBeCloseTo(0);
expect(API.getSelectedElements()[1].x).toEqual(100); expect(API.getSelectedElements()[1].x).toBeCloseTo(100);
expect(API.getSelectedElements()[2].x).toEqual(200); expect(API.getSelectedElements()[2].x).toBeCloseTo(200);
expect(API.getSelectedElements()[3].x).toEqual(300); expect(API.getSelectedElements()[3].x).toBeCloseTo(300);
API.executeAction(actionAlignLeft); API.executeAction(actionAlignLeft);
expect(API.getSelectedElements()[0].x).toEqual(0); expect(API.getSelectedElements()[0].x).toBeCloseTo(0);
expect(API.getSelectedElements()[1].x).toEqual(100); expect(API.getSelectedElements()[1].x).toBeCloseTo(100);
expect(API.getSelectedElements()[2].x).toEqual(200); expect(API.getSelectedElements()[2].x).toBeCloseTo(200);
expect(API.getSelectedElements()[3].x).toEqual(0); expect(API.getSelectedElements()[3].x).toBeCloseTo(0);
}); });
it("aligns nested group and other element correctly to the right", () => { it("aligns nested group and other element correctly to the right", () => {
createAndSelectNestedGroupAndRectangle(); createAndSelectNestedGroupAndRectangle();
expect(API.getSelectedElements()[0].x).toEqual(0); expect(API.getSelectedElements()[0].x).toBeCloseTo(0);
expect(API.getSelectedElements()[1].x).toEqual(100); expect(API.getSelectedElements()[1].x).toBeCloseTo(100);
expect(API.getSelectedElements()[2].x).toEqual(200); expect(API.getSelectedElements()[2].x).toBeCloseTo(200);
expect(API.getSelectedElements()[3].x).toEqual(300); expect(API.getSelectedElements()[3].x).toBeCloseTo(300);
API.executeAction(actionAlignRight); API.executeAction(actionAlignRight);
expect(API.getSelectedElements()[0].x).toEqual(100); expect(API.getSelectedElements()[0].x).toBeCloseTo(100);
expect(API.getSelectedElements()[1].x).toEqual(200); expect(API.getSelectedElements()[1].x).toBeCloseTo(200);
expect(API.getSelectedElements()[2].x).toEqual(300); expect(API.getSelectedElements()[2].x).toBeCloseTo(300);
expect(API.getSelectedElements()[3].x).toEqual(300); expect(API.getSelectedElements()[3].x).toBeCloseTo(300);
}); });
it("centers nested group and other element correctly vertically", () => { it("centers nested group and other element correctly vertically", () => {
createAndSelectNestedGroupAndRectangle(); createAndSelectNestedGroupAndRectangle();
expect(API.getSelectedElements()[0].y).toEqual(0); expect(API.getSelectedElements()[0].y).toBeCloseTo(0);
expect(API.getSelectedElements()[1].y).toEqual(100); expect(API.getSelectedElements()[1].y).toBeCloseTo(100);
expect(API.getSelectedElements()[2].y).toEqual(200); expect(API.getSelectedElements()[2].y).toBeCloseTo(200);
expect(API.getSelectedElements()[3].y).toEqual(300); expect(API.getSelectedElements()[3].y).toBeCloseTo(300);
API.executeAction(actionAlignVerticallyCentered); API.executeAction(actionAlignVerticallyCentered);
expect(API.getSelectedElements()[0].y).toEqual(50); expect(API.getSelectedElements()[0].y).toBeCloseTo(50);
expect(API.getSelectedElements()[1].y).toEqual(150); expect(API.getSelectedElements()[1].y).toBeCloseTo(150);
expect(API.getSelectedElements()[2].y).toEqual(250); expect(API.getSelectedElements()[2].y).toBeCloseTo(250);
expect(API.getSelectedElements()[3].y).toEqual(150); expect(API.getSelectedElements()[3].y).toBeCloseTo(150);
}); });
it("centers nested group and other element correctly horizontally", () => { it("centers nested group and other element correctly horizontally", () => {
createAndSelectNestedGroupAndRectangle(); createAndSelectNestedGroupAndRectangle();
expect(API.getSelectedElements()[0].x).toEqual(0); expect(API.getSelectedElements()[0].x).toBeCloseTo(0);
expect(API.getSelectedElements()[1].x).toEqual(100); expect(API.getSelectedElements()[1].x).toBeCloseTo(100);
expect(API.getSelectedElements()[2].x).toEqual(200); expect(API.getSelectedElements()[2].x).toBeCloseTo(200);
expect(API.getSelectedElements()[3].x).toEqual(300); expect(API.getSelectedElements()[3].x).toBeCloseTo(300);
API.executeAction(actionAlignHorizontallyCentered); API.executeAction(actionAlignHorizontallyCentered);
expect(API.getSelectedElements()[0].x).toEqual(50); expect(API.getSelectedElements()[0].x).toBeCloseTo(50);
expect(API.getSelectedElements()[1].x).toEqual(150); expect(API.getSelectedElements()[1].x).toBeCloseTo(150);
expect(API.getSelectedElements()[2].x).toEqual(250); expect(API.getSelectedElements()[2].x).toBeCloseTo(250);
expect(API.getSelectedElements()[3].x).toEqual(150); expect(API.getSelectedElements()[3].x).toBeCloseTo(150);
}); });
const createGroupAndSelectInEditGroupMode = () => { const createGroupAndSelectInEditGroupMode = () => {
@@ -622,68 +622,68 @@ describe("aligning", () => {
it("aligns elements within a group while in group edit mode correctly to the top", () => { it("aligns elements within a group while in group edit mode correctly to the top", () => {
createGroupAndSelectInEditGroupMode(); createGroupAndSelectInEditGroupMode();
expect(API.getSelectedElements()[0].y).toEqual(0); expect(API.getSelectedElements()[0].y).toBeCloseTo(0);
expect(API.getSelectedElements()[1].y).toEqual(100); expect(API.getSelectedElements()[1].y).toBeCloseTo(100);
API.executeAction(actionAlignTop); API.executeAction(actionAlignTop);
expect(API.getSelectedElements()[0].y).toEqual(0); expect(API.getSelectedElements()[0].y).toBeCloseTo(0);
expect(API.getSelectedElements()[1].y).toEqual(0); expect(API.getSelectedElements()[1].y).toBeCloseTo(0);
}); });
it("aligns elements within a group while in group edit mode correctly to the bottom", () => { it("aligns elements within a group while in group edit mode correctly to the bottom", () => {
createGroupAndSelectInEditGroupMode(); createGroupAndSelectInEditGroupMode();
expect(API.getSelectedElements()[0].y).toEqual(0); expect(API.getSelectedElements()[0].y).toBeCloseTo(0);
expect(API.getSelectedElements()[1].y).toEqual(100); expect(API.getSelectedElements()[1].y).toBeCloseTo(100);
API.executeAction(actionAlignBottom); API.executeAction(actionAlignBottom);
expect(API.getSelectedElements()[0].y).toEqual(100); expect(API.getSelectedElements()[0].y).toBeCloseTo(100);
expect(API.getSelectedElements()[1].y).toEqual(100); expect(API.getSelectedElements()[1].y).toBeCloseTo(100);
}); });
it("aligns elements within a group while in group edit mode correctly to the left", () => { it("aligns elements within a group while in group edit mode correctly to the left", () => {
createGroupAndSelectInEditGroupMode(); createGroupAndSelectInEditGroupMode();
expect(API.getSelectedElements()[0].x).toEqual(0); expect(API.getSelectedElements()[0].x).toBeCloseTo(0);
expect(API.getSelectedElements()[1].x).toEqual(100); expect(API.getSelectedElements()[1].x).toBeCloseTo(100);
API.executeAction(actionAlignLeft); API.executeAction(actionAlignLeft);
expect(API.getSelectedElements()[0].x).toEqual(0); expect(API.getSelectedElements()[0].x).toBeCloseTo(0);
expect(API.getSelectedElements()[1].x).toEqual(0); expect(API.getSelectedElements()[1].x).toBeCloseTo(0);
}); });
it("aligns elements within a group while in group edit mode correctly to the right", () => { it("aligns elements within a group while in group edit mode correctly to the right", () => {
createGroupAndSelectInEditGroupMode(); createGroupAndSelectInEditGroupMode();
expect(API.getSelectedElements()[0].x).toEqual(0); expect(API.getSelectedElements()[0].x).toBeCloseTo(0);
expect(API.getSelectedElements()[1].x).toEqual(100); expect(API.getSelectedElements()[1].x).toBeCloseTo(100);
API.executeAction(actionAlignRight); API.executeAction(actionAlignRight);
expect(API.getSelectedElements()[0].x).toEqual(100); expect(API.getSelectedElements()[0].x).toBeCloseTo(100);
expect(API.getSelectedElements()[1].x).toEqual(100); expect(API.getSelectedElements()[1].x).toBeCloseTo(100);
}); });
it("aligns elements within a group while in group edit mode correctly to the vertical center", () => { it("aligns elements within a group while in group edit mode correctly to the vertical center", () => {
createGroupAndSelectInEditGroupMode(); createGroupAndSelectInEditGroupMode();
expect(API.getSelectedElements()[0].y).toEqual(0); expect(API.getSelectedElements()[0].y).toBeCloseTo(0);
expect(API.getSelectedElements()[1].y).toEqual(100); expect(API.getSelectedElements()[1].y).toBeCloseTo(100);
API.executeAction(actionAlignVerticallyCentered); API.executeAction(actionAlignVerticallyCentered);
expect(API.getSelectedElements()[0].y).toEqual(50); expect(API.getSelectedElements()[0].y).toBeCloseTo(50);
expect(API.getSelectedElements()[1].y).toEqual(50); expect(API.getSelectedElements()[1].y).toBeCloseTo(50);
}); });
it("aligns elements within a group while in group edit mode correctly to the horizontal center", () => { it("aligns elements within a group while in group edit mode correctly to the horizontal center", () => {
createGroupAndSelectInEditGroupMode(); createGroupAndSelectInEditGroupMode();
expect(API.getSelectedElements()[0].x).toEqual(0); expect(API.getSelectedElements()[0].x).toBeCloseTo(0);
expect(API.getSelectedElements()[1].x).toEqual(100); expect(API.getSelectedElements()[1].x).toBeCloseTo(100);
API.executeAction(actionAlignHorizontallyCentered); API.executeAction(actionAlignHorizontallyCentered);
expect(API.getSelectedElements()[0].x).toEqual(50); expect(API.getSelectedElements()[0].x).toBeCloseTo(50);
expect(API.getSelectedElements()[1].x).toEqual(50); expect(API.getSelectedElements()[1].x).toBeCloseTo(50);
}); });
const createNestedGroupAndSelectInEditGroupMode = () => { const createNestedGroupAndSelectInEditGroupMode = () => {
@@ -735,80 +735,80 @@ describe("aligning", () => {
it("aligns element and nested group while in group edit mode correctly to the top", () => { it("aligns element and nested group while in group edit mode correctly to the top", () => {
createNestedGroupAndSelectInEditGroupMode(); createNestedGroupAndSelectInEditGroupMode();
expect(API.getSelectedElements()[0].y).toEqual(0); expect(API.getSelectedElements()[0].y).toBeCloseTo(0);
expect(API.getSelectedElements()[1].y).toEqual(100); expect(API.getSelectedElements()[1].y).toBeCloseTo(100);
expect(API.getSelectedElements()[2].y).toEqual(200); expect(API.getSelectedElements()[2].y).toBeCloseTo(200);
API.executeAction(actionAlignTop); API.executeAction(actionAlignTop);
expect(API.getSelectedElements()[0].y).toEqual(0); expect(API.getSelectedElements()[0].y).toBeCloseTo(0);
expect(API.getSelectedElements()[1].y).toEqual(100); expect(API.getSelectedElements()[1].y).toBeCloseTo(100);
expect(API.getSelectedElements()[2].y).toEqual(0); expect(API.getSelectedElements()[2].y).toBeCloseTo(0);
}); });
it("aligns element and nested group while in group edit mode correctly to the bottom", () => { it("aligns element and nested group while in group edit mode correctly to the bottom", () => {
createNestedGroupAndSelectInEditGroupMode(); createNestedGroupAndSelectInEditGroupMode();
expect(API.getSelectedElements()[0].y).toEqual(0); expect(API.getSelectedElements()[0].y).toBeCloseTo(0);
expect(API.getSelectedElements()[1].y).toEqual(100); expect(API.getSelectedElements()[1].y).toBeCloseTo(100);
expect(API.getSelectedElements()[2].y).toEqual(200); expect(API.getSelectedElements()[2].y).toBeCloseTo(200);
API.executeAction(actionAlignBottom); API.executeAction(actionAlignBottom);
expect(API.getSelectedElements()[0].y).toEqual(100); expect(API.getSelectedElements()[0].y).toBeCloseTo(100);
expect(API.getSelectedElements()[1].y).toEqual(200); expect(API.getSelectedElements()[1].y).toBeCloseTo(200);
expect(API.getSelectedElements()[2].y).toEqual(200); expect(API.getSelectedElements()[2].y).toBeCloseTo(200);
}); });
it("aligns element and nested group while in group edit mode correctly to the left", () => { it("aligns element and nested group while in group edit mode correctly to the left", () => {
createNestedGroupAndSelectInEditGroupMode(); createNestedGroupAndSelectInEditGroupMode();
expect(API.getSelectedElements()[0].x).toEqual(0); expect(API.getSelectedElements()[0].x).toBeCloseTo(0);
expect(API.getSelectedElements()[1].x).toEqual(100); expect(API.getSelectedElements()[1].x).toBeCloseTo(100);
expect(API.getSelectedElements()[2].x).toEqual(200); expect(API.getSelectedElements()[2].x).toBeCloseTo(200);
API.executeAction(actionAlignLeft); API.executeAction(actionAlignLeft);
expect(API.getSelectedElements()[0].x).toEqual(0); expect(API.getSelectedElements()[0].x).toBeCloseTo(0);
expect(API.getSelectedElements()[1].x).toEqual(100); expect(API.getSelectedElements()[1].x).toBeCloseTo(100);
expect(API.getSelectedElements()[2].x).toEqual(0); expect(API.getSelectedElements()[2].x).toBeCloseTo(0);
}); });
it("aligns element and nested group while in group edit mode correctly to the right", () => { it("aligns element and nested group while in group edit mode correctly to the right", () => {
createNestedGroupAndSelectInEditGroupMode(); createNestedGroupAndSelectInEditGroupMode();
expect(API.getSelectedElements()[0].x).toEqual(0); expect(API.getSelectedElements()[0].x).toBeCloseTo(0);
expect(API.getSelectedElements()[1].x).toEqual(100); expect(API.getSelectedElements()[1].x).toBeCloseTo(100);
expect(API.getSelectedElements()[2].x).toEqual(200); expect(API.getSelectedElements()[2].x).toBeCloseTo(200);
API.executeAction(actionAlignRight); API.executeAction(actionAlignRight);
expect(API.getSelectedElements()[0].x).toEqual(100); expect(API.getSelectedElements()[0].x).toBeCloseTo(100);
expect(API.getSelectedElements()[1].x).toEqual(200); expect(API.getSelectedElements()[1].x).toBeCloseTo(200);
expect(API.getSelectedElements()[2].x).toEqual(200); expect(API.getSelectedElements()[2].x).toBeCloseTo(200);
}); });
it("aligns element and nested group while in group edit mode correctly to the vertical center", () => { it("aligns element and nested group while in group edit mode correctly to the vertical center", () => {
createNestedGroupAndSelectInEditGroupMode(); createNestedGroupAndSelectInEditGroupMode();
expect(API.getSelectedElements()[0].y).toEqual(0); expect(API.getSelectedElements()[0].y).toBeCloseTo(0);
expect(API.getSelectedElements()[1].y).toEqual(100); expect(API.getSelectedElements()[1].y).toBeCloseTo(100);
expect(API.getSelectedElements()[2].y).toEqual(200); expect(API.getSelectedElements()[2].y).toBeCloseTo(200);
API.executeAction(actionAlignVerticallyCentered); API.executeAction(actionAlignVerticallyCentered);
expect(API.getSelectedElements()[0].y).toEqual(50); expect(API.getSelectedElements()[0].y).toBeCloseTo(50);
expect(API.getSelectedElements()[1].y).toEqual(150); expect(API.getSelectedElements()[1].y).toBeCloseTo(150);
expect(API.getSelectedElements()[2].y).toEqual(100); expect(API.getSelectedElements()[2].y).toBeCloseTo(100);
}); });
it("aligns elements and nested group within a group while in group edit mode correctly to the horizontal center", () => { it("aligns elements and nested group within a group while in group edit mode correctly to the horizontal center", () => {
createNestedGroupAndSelectInEditGroupMode(); createNestedGroupAndSelectInEditGroupMode();
expect(API.getSelectedElements()[0].x).toEqual(0); expect(API.getSelectedElements()[0].x).toBeCloseTo(0);
expect(API.getSelectedElements()[1].x).toEqual(100); expect(API.getSelectedElements()[1].x).toBeCloseTo(100);
expect(API.getSelectedElements()[2].x).toEqual(200); expect(API.getSelectedElements()[2].x).toBeCloseTo(200);
API.executeAction(actionAlignHorizontallyCentered); API.executeAction(actionAlignHorizontallyCentered);
expect(API.getSelectedElements()[0].x).toEqual(50); expect(API.getSelectedElements()[0].x).toBeCloseTo(50);
expect(API.getSelectedElements()[1].x).toEqual(150); expect(API.getSelectedElements()[1].x).toBeCloseTo(150);
expect(API.getSelectedElements()[2].x).toEqual(100); expect(API.getSelectedElements()[2].x).toBeCloseTo(100);
}); });
const createAndSelectSingleGroup = () => { const createAndSelectSingleGroup = () => {
@@ -834,68 +834,68 @@ describe("aligning", () => {
it("aligns elements within a single-selected group correctly to the top", () => { it("aligns elements within a single-selected group correctly to the top", () => {
createAndSelectSingleGroup(); createAndSelectSingleGroup();
expect(API.getSelectedElements()[0].y).toEqual(0); expect(API.getSelectedElements()[0].y).toBeCloseTo(0);
expect(API.getSelectedElements()[1].y).toEqual(100); expect(API.getSelectedElements()[1].y).toBeCloseTo(100);
API.executeAction(actionAlignTop); API.executeAction(actionAlignTop);
expect(API.getSelectedElements()[0].y).toEqual(0); expect(API.getSelectedElements()[0].y).toBeCloseTo(0);
expect(API.getSelectedElements()[1].y).toEqual(0); expect(API.getSelectedElements()[1].y).toBeCloseTo(0);
}); });
it("aligns elements within a single-selected group correctly to the bottom", () => { it("aligns elements within a single-selected group correctly to the bottom", () => {
createAndSelectSingleGroup(); createAndSelectSingleGroup();
expect(API.getSelectedElements()[0].y).toEqual(0); expect(API.getSelectedElements()[0].y).toBeCloseTo(0);
expect(API.getSelectedElements()[1].y).toEqual(100); expect(API.getSelectedElements()[1].y).toBeCloseTo(100);
API.executeAction(actionAlignBottom); API.executeAction(actionAlignBottom);
expect(API.getSelectedElements()[0].y).toEqual(100); expect(API.getSelectedElements()[0].y).toBeCloseTo(100);
expect(API.getSelectedElements()[1].y).toEqual(100); expect(API.getSelectedElements()[1].y).toBeCloseTo(100);
}); });
it("aligns elements within a single-selected group correctly to the left", () => { it("aligns elements within a single-selected group correctly to the left", () => {
createAndSelectSingleGroup(); createAndSelectSingleGroup();
expect(API.getSelectedElements()[0].x).toEqual(0); expect(API.getSelectedElements()[0].x).toBeCloseTo(0);
expect(API.getSelectedElements()[1].x).toEqual(100); expect(API.getSelectedElements()[1].x).toBeCloseTo(100);
API.executeAction(actionAlignLeft); API.executeAction(actionAlignLeft);
expect(API.getSelectedElements()[0].x).toEqual(0); expect(API.getSelectedElements()[0].x).toBeCloseTo(0);
expect(API.getSelectedElements()[1].x).toEqual(0); expect(API.getSelectedElements()[1].x).toBeCloseTo(0);
}); });
it("aligns elements within a single-selected group correctly to the right", () => { it("aligns elements within a single-selected group correctly to the right", () => {
createAndSelectSingleGroup(); createAndSelectSingleGroup();
expect(API.getSelectedElements()[0].x).toEqual(0); expect(API.getSelectedElements()[0].x).toBeCloseTo(0);
expect(API.getSelectedElements()[1].x).toEqual(100); expect(API.getSelectedElements()[1].x).toBeCloseTo(100);
API.executeAction(actionAlignRight); API.executeAction(actionAlignRight);
expect(API.getSelectedElements()[0].x).toEqual(100); expect(API.getSelectedElements()[0].x).toBeCloseTo(100);
expect(API.getSelectedElements()[1].x).toEqual(100); expect(API.getSelectedElements()[1].x).toBeCloseTo(100);
}); });
it("aligns elements within a single-selected group correctly to the vertical center", () => { it("aligns elements within a single-selected group correctly to the vertical center", () => {
createAndSelectSingleGroup(); createAndSelectSingleGroup();
expect(API.getSelectedElements()[0].y).toEqual(0); expect(API.getSelectedElements()[0].y).toBeCloseTo(0);
expect(API.getSelectedElements()[1].y).toEqual(100); expect(API.getSelectedElements()[1].y).toBeCloseTo(100);
API.executeAction(actionAlignVerticallyCentered); API.executeAction(actionAlignVerticallyCentered);
expect(API.getSelectedElements()[0].y).toEqual(50); expect(API.getSelectedElements()[0].y).toBeCloseTo(50);
expect(API.getSelectedElements()[1].y).toEqual(50); expect(API.getSelectedElements()[1].y).toBeCloseTo(50);
}); });
it("aligns elements within a single-selected group correctly to the horizontal center", () => { it("aligns elements within a single-selected group correctly to the horizontal center", () => {
createAndSelectSingleGroup(); createAndSelectSingleGroup();
expect(API.getSelectedElements()[0].x).toEqual(0); expect(API.getSelectedElements()[0].x).toBeCloseTo(0);
expect(API.getSelectedElements()[1].x).toEqual(100); expect(API.getSelectedElements()[1].x).toBeCloseTo(100);
API.executeAction(actionAlignHorizontallyCentered); API.executeAction(actionAlignHorizontallyCentered);
expect(API.getSelectedElements()[0].x).toEqual(50); expect(API.getSelectedElements()[0].x).toBeCloseTo(50);
expect(API.getSelectedElements()[1].x).toEqual(50); expect(API.getSelectedElements()[1].x).toBeCloseTo(50);
}); });
const createAndSelectSingleGroupWithNestedGroup = () => { const createAndSelectSingleGroupWithNestedGroup = () => {
@@ -934,79 +934,79 @@ describe("aligning", () => {
it("aligns elements within a single-selected group containing a nested group correctly to the top", () => { it("aligns elements within a single-selected group containing a nested group correctly to the top", () => {
createAndSelectSingleGroupWithNestedGroup(); createAndSelectSingleGroupWithNestedGroup();
expect(API.getSelectedElements()[0].y).toEqual(0); expect(API.getSelectedElements()[0].y).toBeCloseTo(0);
expect(API.getSelectedElements()[1].y).toEqual(100); expect(API.getSelectedElements()[1].y).toBeCloseTo(100);
expect(API.getSelectedElements()[2].y).toEqual(200); expect(API.getSelectedElements()[2].y).toBeCloseTo(200);
API.executeAction(actionAlignTop); API.executeAction(actionAlignTop);
expect(API.getSelectedElements()[0].y).toEqual(0); expect(API.getSelectedElements()[0].y).toBeCloseTo(0);
expect(API.getSelectedElements()[1].y).toEqual(100); expect(API.getSelectedElements()[1].y).toBeCloseTo(100);
expect(API.getSelectedElements()[2].y).toEqual(0); expect(API.getSelectedElements()[2].y).toBeCloseTo(0);
}); });
it("aligns elements within a single-selected group containing a nested group correctly to the bottom", () => { it("aligns elements within a single-selected group containing a nested group correctly to the bottom", () => {
createAndSelectSingleGroupWithNestedGroup(); createAndSelectSingleGroupWithNestedGroup();
expect(API.getSelectedElements()[0].y).toEqual(0); expect(API.getSelectedElements()[0].y).toBeCloseTo(0);
expect(API.getSelectedElements()[1].y).toEqual(100); expect(API.getSelectedElements()[1].y).toBeCloseTo(100);
expect(API.getSelectedElements()[2].y).toEqual(200); expect(API.getSelectedElements()[2].y).toBeCloseTo(200);
API.executeAction(actionAlignBottom); API.executeAction(actionAlignBottom);
expect(API.getSelectedElements()[0].y).toEqual(100); expect(API.getSelectedElements()[0].y).toBeCloseTo(100);
expect(API.getSelectedElements()[1].y).toEqual(200); expect(API.getSelectedElements()[1].y).toBeCloseTo(200);
expect(API.getSelectedElements()[2].y).toEqual(200); expect(API.getSelectedElements()[2].y).toBeCloseTo(200);
}); });
it("aligns elements within a single-selected group containing a nested group correctly to the left", () => { it("aligns elements within a single-selected group containing a nested group correctly to the left", () => {
createAndSelectSingleGroupWithNestedGroup(); createAndSelectSingleGroupWithNestedGroup();
expect(API.getSelectedElements()[0].x).toEqual(0); expect(API.getSelectedElements()[0].x).toBeCloseTo(0);
expect(API.getSelectedElements()[1].x).toEqual(100); expect(API.getSelectedElements()[1].x).toBeCloseTo(100);
expect(API.getSelectedElements()[2].x).toEqual(200); expect(API.getSelectedElements()[2].x).toBeCloseTo(200);
API.executeAction(actionAlignLeft); API.executeAction(actionAlignLeft);
expect(API.getSelectedElements()[0].x).toEqual(0); expect(API.getSelectedElements()[0].x).toBeCloseTo(0);
expect(API.getSelectedElements()[1].x).toEqual(100); expect(API.getSelectedElements()[1].x).toBeCloseTo(100);
expect(API.getSelectedElements()[2].x).toEqual(0); expect(API.getSelectedElements()[2].x).toBeCloseTo(0);
}); });
it("aligns elements within a single-selected group containing a nested group correctly to the right", () => { it("aligns elements within a single-selected group containing a nested group correctly to the right", () => {
createAndSelectSingleGroupWithNestedGroup(); createAndSelectSingleGroupWithNestedGroup();
expect(API.getSelectedElements()[0].x).toEqual(0); expect(API.getSelectedElements()[0].x).toBeCloseTo(0);
expect(API.getSelectedElements()[1].x).toEqual(100); expect(API.getSelectedElements()[1].x).toBeCloseTo(100);
expect(API.getSelectedElements()[2].x).toEqual(200); expect(API.getSelectedElements()[2].x).toBeCloseTo(200);
API.executeAction(actionAlignRight); API.executeAction(actionAlignRight);
expect(API.getSelectedElements()[0].x).toEqual(100); expect(API.getSelectedElements()[0].x).toBeCloseTo(100);
expect(API.getSelectedElements()[1].x).toEqual(200); expect(API.getSelectedElements()[1].x).toBeCloseTo(200);
expect(API.getSelectedElements()[2].x).toEqual(200); expect(API.getSelectedElements()[2].x).toBeCloseTo(200);
}); });
it("aligns elements within a single-selected group containing a nested group correctly to the vertical center", () => { it("aligns elements within a single-selected group containing a nested group correctly to the vertical center", () => {
createAndSelectSingleGroupWithNestedGroup(); createAndSelectSingleGroupWithNestedGroup();
expect(API.getSelectedElements()[0].y).toEqual(0); expect(API.getSelectedElements()[0].y).toBeCloseTo(0);
expect(API.getSelectedElements()[1].y).toEqual(100); expect(API.getSelectedElements()[1].y).toBeCloseTo(100);
expect(API.getSelectedElements()[2].y).toEqual(200); expect(API.getSelectedElements()[2].y).toBeCloseTo(200);
API.executeAction(actionAlignVerticallyCentered); API.executeAction(actionAlignVerticallyCentered);
expect(API.getSelectedElements()[0].y).toEqual(50); expect(API.getSelectedElements()[0].y).toBeCloseTo(50);
expect(API.getSelectedElements()[1].y).toEqual(150); expect(API.getSelectedElements()[1].y).toBeCloseTo(150);
expect(API.getSelectedElements()[2].y).toEqual(100); expect(API.getSelectedElements()[2].y).toBeCloseTo(100);
}); });
it("aligns elements within a single-selected group containing a nested group correctly to the horizontal center", () => { it("aligns elements within a single-selected group containing a nested group correctly to the horizontal center", () => {
createAndSelectSingleGroupWithNestedGroup(); createAndSelectSingleGroupWithNestedGroup();
expect(API.getSelectedElements()[0].x).toEqual(0); expect(API.getSelectedElements()[0].x).toBeCloseTo(0);
expect(API.getSelectedElements()[1].x).toEqual(100); expect(API.getSelectedElements()[1].x).toBeCloseTo(100);
expect(API.getSelectedElements()[2].x).toEqual(200); expect(API.getSelectedElements()[2].x).toBeCloseTo(200);
API.executeAction(actionAlignHorizontallyCentered); API.executeAction(actionAlignHorizontallyCentered);
expect(API.getSelectedElements()[0].x).toEqual(50); expect(API.getSelectedElements()[0].x).toBeCloseTo(50);
expect(API.getSelectedElements()[1].x).toEqual(150); expect(API.getSelectedElements()[1].x).toBeCloseTo(150);
expect(API.getSelectedElements()[2].x).toEqual(100); expect(API.getSelectedElements()[2].x).toBeCloseTo(100);
}); });
}); });
+60
View File
@@ -178,6 +178,64 @@ describe("binding for simple arrows", () => {
}); });
}); });
describe("self-binding (both ends to the same element) single-click finalize", () => {
// rect spans x:200..400, y:200..400; orbit ring is ~15px outside the outline
const INSIDE: [number, number] = [250, 250];
const ORBIT_LEFT: [number, number] = [187, 300];
const ORBIT_RIGHT: [number, number] = [413, 300];
const MIDDLE: [number, number] = [550, 100];
beforeEach(async () => {
mouse.reset();
await act(() => setLanguage(defaultLang));
await render(<Excalidraw handleKeyboardGlobally={true} />);
UI.createElement("rectangle", {
x: 200,
y: 200,
width: 200,
height: 200,
});
});
const drawSelfArrow = (start: [number, number], end: [number, number]) => {
UI.clickTool("arrow");
mouse.reset();
mouse.clickAt(...start);
mouse.moveTo(...MIDDLE);
mouse.clickAt(...MIDDLE); // commit a middle point so it's a multi-point arrow
mouse.moveTo(...end);
mouse.clickAt(...end); // single click at the end
};
it("orbit -> orbit finalizes on a single click", () => {
drawSelfArrow(ORBIT_LEFT, ORBIT_RIGHT);
const arrow = h.elements[h.elements.length - 1] as ExcalidrawArrowElement;
expect(h.state.multiElement).toBe(null);
expect(h.state.activeTool.type).toBe("selection");
expect(arrow.startBinding?.elementId).toBe(arrow.endBinding?.elementId);
expect(arrow.endBinding?.elementId).not.toBe(undefined);
});
it("inside -> orbit finalizes on a single click", () => {
drawSelfArrow(INSIDE, ORBIT_RIGHT);
const arrow = h.elements[h.elements.length - 1] as ExcalidrawArrowElement;
expect(h.state.multiElement).toBe(null);
expect(h.state.activeTool.type).toBe("selection");
expect(arrow.startBinding?.elementId).toBe(arrow.endBinding?.elementId);
expect(arrow.endBinding?.elementId).not.toBe(undefined);
});
it("inside -> inside keep in multi-point mode (no single-click finalize)", () => {
drawSelfArrow(INSIDE, [INSIDE[0] + 50, INSIDE[1] + 50]); // end dropped inside the rect
// ambiguous → must be confirmed with a second click, so still in progress
expect(h.state.multiElement).not.toBe(null);
expect(h.state.activeTool.type).toBe("arrow");
});
});
describe("when arrow is outside of shape", () => { describe("when arrow is outside of shape", () => {
beforeEach(async () => { beforeEach(async () => {
mouse.reset(); mouse.reset();
@@ -403,6 +461,7 @@ describe("binding for simple arrows", () => {
mouse.moveTo(340, 251); mouse.moveTo(340, 251);
mouse.moveTo(410, 251); mouse.moveTo(410, 251);
mouse.clickAt(410, 251); mouse.clickAt(410, 251);
mouse.clickAt(410, 251);
const arrow = h.elements[h.elements.length - 1] as any; const arrow = h.elements[h.elements.length - 1] as any;
expect(arrow.startBinding?.elementId).toBe(rectLeft.id); expect(arrow.startBinding?.elementId).toBe(rectLeft.id);
@@ -447,6 +506,7 @@ describe("binding for simple arrows", () => {
mouse.moveTo(350, 251); mouse.moveTo(350, 251);
mouse.moveTo(410, 251); mouse.moveTo(410, 251);
mouse.clickAt(410, 251); mouse.clickAt(410, 251);
mouse.clickAt(410, 251);
const arrow = API.getSelectedElement() as ExcalidrawArrowElement; const arrow = API.getSelectedElement() as ExcalidrawArrowElement;
+69 -3
View File
@@ -1,10 +1,14 @@
import { pointFrom } from "@excalidraw/math"; import { pointFrom } from "@excalidraw/math";
import { arrayToMap, type Bounds, ROUNDNESS } from "@excalidraw/common";
import { arrayToMap, ROUNDNESS } from "@excalidraw/common"; import { API } from "@excalidraw/excalidraw/tests/helpers/api";
import type { LocalPoint } from "@excalidraw/math"; import type { LocalPoint } from "@excalidraw/math";
import { getElementAbsoluteCoords, getElementBounds } from "../src/bounds"; import {
elementsOverlappingBBox,
getElementAbsoluteCoords,
getElementBounds,
} from "../src/bounds";
import type { ExcalidrawElement, ExcalidrawLinearElement } from "../src/types"; import type { ExcalidrawElement, ExcalidrawLinearElement } from "../src/types";
@@ -141,3 +145,65 @@ describe("getElementBounds", () => {
expect(y2).toEqual(319.8162855827246); expect(y2).toEqual(319.8162855827246);
}); });
}); });
const makeElement = (x: number, y: number, width: number, height: number) =>
API.createElement({
type: "rectangle",
x,
y,
width,
height,
});
const makeBBox = (
minX: number,
minY: number,
maxX: number,
maxY: number,
): Bounds => [minX, minY, maxX, maxY];
describe("elementsOverlappingBBox()", () => {
it("should return elements that overlap bbox", () => {
const bbox = makeBBox(0, 0, 100, 100);
const rectOutside = makeElement(110, 110, 100, 100);
const rectInside = makeElement(10, 10, 85, 85);
const rectContainingBBox = makeElement(-10, -10, 110, 110);
const rectOverlappingTopLeft = makeElement(-10, -10, 50, 50);
expect(
elementsOverlappingBBox({
bounds: bbox,
type: "overlap",
elements: [
rectOutside,
rectInside,
rectContainingBBox,
rectOverlappingTopLeft,
],
}),
).toEqual([rectInside, rectOverlappingTopLeft]);
});
it("should return elements inside/containing bbox", () => {
const bbox = makeBBox(0, 0, 100, 100);
const rectOutside = makeElement(110, 110, 100, 100);
const rectInside = makeElement(10, 10, 85, 85);
const rectContainingBBox = makeElement(-10, -10, 110, 110);
const rectOverlappingTopLeft = makeElement(-10, -10, 50, 50);
expect(
elementsOverlappingBBox({
bounds: bbox,
type: "contain",
elements: [
rectOutside,
rectInside,
rectContainingBBox,
rectOverlappingTopLeft,
],
}),
).toEqual([rectInside]);
});
});
+24 -24
View File
@@ -76,53 +76,53 @@ describe("distributing", () => {
it("should distribute selected elements horizontally", async () => { it("should distribute selected elements horizontally", async () => {
createAndSelectThreeRectanglesWithGap(); createAndSelectThreeRectanglesWithGap();
expect(API.getSelectedElements()[0].x).toEqual(0); expect(API.getSelectedElements()[0].x).toBeCloseTo(0);
expect(API.getSelectedElements()[1].x).toEqual(10); expect(API.getSelectedElements()[1].x).toBeCloseTo(10);
expect(API.getSelectedElements()[2].x).toEqual(300); expect(API.getSelectedElements()[2].x).toBeCloseTo(300);
API.executeAction(distributeHorizontally); API.executeAction(distributeHorizontally);
expect(API.getSelectedElements()[0].x).toEqual(0); expect(API.getSelectedElements()[0].x).toBeCloseTo(0);
expect(API.getSelectedElements()[1].x).toEqual(150); expect(API.getSelectedElements()[1].x).toBeCloseTo(150);
expect(API.getSelectedElements()[2].x).toEqual(300); expect(API.getSelectedElements()[2].x).toBeCloseTo(300);
}); });
it("should distribute selected elements vertically", async () => { it("should distribute selected elements vertically", async () => {
createAndSelectThreeRectanglesWithGap(); createAndSelectThreeRectanglesWithGap();
expect(API.getSelectedElements()[0].y).toEqual(0); expect(API.getSelectedElements()[0].y).toBeCloseTo(0);
expect(API.getSelectedElements()[1].y).toEqual(10); expect(API.getSelectedElements()[1].y).toBeCloseTo(10);
expect(API.getSelectedElements()[2].y).toEqual(300); expect(API.getSelectedElements()[2].y).toBeCloseTo(300);
API.executeAction(distributeVertically); API.executeAction(distributeVertically);
expect(API.getSelectedElements()[0].y).toEqual(0); expect(API.getSelectedElements()[0].y).toBeCloseTo(0);
expect(API.getSelectedElements()[1].y).toEqual(150); expect(API.getSelectedElements()[1].y).toBeCloseTo(150);
expect(API.getSelectedElements()[2].y).toEqual(300); expect(API.getSelectedElements()[2].y).toBeCloseTo(300);
}); });
it("should distribute selected elements horizontally based on their centers", async () => { it("should distribute selected elements horizontally based on their centers", async () => {
createAndSelectThreeRectanglesWithoutGap(); createAndSelectThreeRectanglesWithoutGap();
expect(API.getSelectedElements()[0].x).toEqual(0); expect(API.getSelectedElements()[0].x).toBeCloseTo(0);
expect(API.getSelectedElements()[1].x).toEqual(10); expect(API.getSelectedElements()[1].x).toBeCloseTo(10);
expect(API.getSelectedElements()[2].x).toEqual(200); expect(API.getSelectedElements()[2].x).toBeCloseTo(200);
API.executeAction(distributeHorizontally); API.executeAction(distributeHorizontally);
expect(API.getSelectedElements()[0].x).toEqual(0); expect(API.getSelectedElements()[0].x).toBeCloseTo(0);
expect(API.getSelectedElements()[1].x).toEqual(50); expect(API.getSelectedElements()[1].x).toBeCloseTo(50);
expect(API.getSelectedElements()[2].x).toEqual(200); expect(API.getSelectedElements()[2].x).toBeCloseTo(200);
}); });
it("should distribute selected elements vertically with based on their centers", async () => { it("should distribute selected elements vertically with based on their centers", async () => {
createAndSelectThreeRectanglesWithoutGap(); createAndSelectThreeRectanglesWithoutGap();
expect(API.getSelectedElements()[0].y).toEqual(0); expect(API.getSelectedElements()[0].y).toBeCloseTo(0);
expect(API.getSelectedElements()[1].y).toEqual(10); expect(API.getSelectedElements()[1].y).toBeCloseTo(10);
expect(API.getSelectedElements()[2].y).toEqual(200); expect(API.getSelectedElements()[2].y).toBeCloseTo(200);
API.executeAction(distributeVertically); API.executeAction(distributeVertically);
expect(API.getSelectedElements()[0].y).toEqual(0); expect(API.getSelectedElements()[0].y).toBeCloseTo(0);
expect(API.getSelectedElements()[1].y).toEqual(50); expect(API.getSelectedElements()[1].y).toBeCloseTo(50);
expect(API.getSelectedElements()[2].y).toEqual(200); expect(API.getSelectedElements()[2].y).toBeCloseTo(200);
}); });
}); });
+63 -3
View File
@@ -1,9 +1,8 @@
/* 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,
@@ -13,13 +12,34 @@ 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";
import { InvalidFractionalIndexError } from "../src/fractionalIndex"; describe("fractional index format validation", () => {
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", () => {
@@ -104,6 +124,46 @@ 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: [
+579 -1
View File
@@ -5,12 +5,15 @@ import {
import { arrayToMap } from "@excalidraw/common"; import { arrayToMap } from "@excalidraw/common";
import { API } from "@excalidraw/excalidraw/tests/helpers/api"; import { API } from "@excalidraw/excalidraw/tests/helpers/api";
import { Keyboard, Pointer } from "@excalidraw/excalidraw/tests/helpers/ui"; import { Keyboard, Pointer, UI } 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 { elementOverlapsWithFrame } from "../src/frame"; import { elementOverlapsWithFrame } from "../src/frame";
import type { import type {
@@ -151,6 +154,230 @@ describe("adding elements to frames", () => {
).toBe(true); ).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,
) => { ) => {
@@ -457,6 +684,357 @@ describe("adding elements to frames", () => {
expect(API.getElement(containingRect).frameId).toBe(frame.id); 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 move an element dragged from one frame into another", () => {
const otherFrame = API.createElement({
id: "otherFrame",
type: "frame",
x: 300,
y: 0,
width: 150,
height: 150,
});
const frameChild = API.createElement({
id: "frameChild",
type: "rectangle",
x: 50,
y: 50,
width: 20,
height: 20,
frameId: frame.id,
});
API.setElements([frame, frameChild, otherFrame]);
expect(frameChild.frameId).toBe(frame.id);
dragElementIntoFrame(otherFrame, frameChild);
expect(frameChild.frameId).toBe(otherFrame.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]);
@@ -1318,8 +1318,8 @@ describe("Test Linear Elements", () => {
expect(arrow.endBinding?.elementId).toBe(rect.id); expect(arrow.endBinding?.elementId).toBe(rect.id);
expect(arrow.width).toBeCloseTo(404); expect(arrow.width).toBeCloseTo(404);
expect(rect.x).toBe(400); expect(rect.x).toBeCloseTo(400);
expect(rect.y).toBe(0); expect(rect.y).toBeCloseTo(0);
expect( expect(
wrapText( wrapText(
textElement.originalText, textElement.originalText,
@@ -1340,9 +1340,8 @@ describe("Test Linear Elements", () => {
expect(rect.x).toBe(200); expect(rect.x).toBe(200);
expect(rect.y).toBe(0); expect(rect.y).toBe(0);
expect(handleBindTextResizeSpy).toHaveBeenCalledWith( expect(handleBindTextResizeSpy).toHaveBeenCalledWith(
h.elements[0], h.elements[1],
h.app.scene, h.app.scene,
"nw",
false, false,
); );
expect( expect(
+186
View File
@@ -1509,4 +1509,190 @@ describe("z-indexing with frames", () => {
], ],
}); });
}); });
it("bringing to front / sending to back children of MULTIPLE frames at once moves all of them", () => {
assertZindex({
elements: [
{ id: "F1_1", frameId: "F1", isSelected: true },
{ id: "F1_2", frameId: "F1" },
{ id: "F1", type: "frame" },
{ id: "F2_1", frameId: "F2", isSelected: true },
{ id: "F2_2", frameId: "F2" },
{ id: "F2", type: "frame" },
],
operations: [
// +∞: each selected child moves to the front of its own frame
[actionBringToFront, ["F1_2", "F1", "F1_1", "F2_2", "F2", "F2_1"]],
// -∞: each selected child moves to the back of its own frame
[actionSendToBack, ["F1_1", "F1_2", "F1", "F2_1", "F2_2", "F2"]],
],
});
});
it("send to back / bring to front of a grouped frame child (in group-editing mode) must not duplicate elements", () => {
assertZindex({
elements: [
{ id: "F1_1", frameId: "F1", groupIds: ["g1"] },
{ id: "F1_2", frameId: "F1", groupIds: ["g1"], isSelected: true },
{ id: "F1", type: "frame" },
{ id: "F2_1", frameId: "F2", groupIds: ["g2"] },
{ id: "F2_2", frameId: "F2", groupIds: ["g2"] },
{ id: "F2", type: "frame" },
],
appState: { editingGroupId: "g1" },
operations: [
// -∞ (send to back, within the frame)
[actionSendToBack, ["F1_2", "F1_1", "F1", "F2_1", "F2_2", "F2"]],
// +∞ (bring to front, within the frame)
[actionBringToFront, ["F1_1", "F1", "F1_2", "F2_1", "F2_2", "F2"]],
],
});
});
});
/**
* The inputs in this block intentionally VIOLATE the (soft) invariant that a
* frame's children — and a group's members are contiguous in the elements
* array. Such states shouldn't occur in normal use, but they CAN arise from
* bugs or broken input, because nothing re-defragments element order during
* a reorder (`normalizeElementOrder` only runs on duplication). We keep these
* tests so the reordering ops stay exercised against malformed order.
*
* HARD CONTRACT (a failure here is a real bug): a reorder must never throw,
* duplicate, or drop elements. `assertReorderPreservesElements` checks this.
*
* SOFT SNAPSHOT (read before "fixing"): the exact resulting ORDER is NOT a
* contract for invalid input it's whatever the slice math happens to
* produce. If a future change alters an `expected` order below, that is NOT
* necessarily a functional regression. First confirm from the diff that the
* hard contract still holds (nothing duplicated/lost), then update the
* expected order to match, provided it's deemed an improvement over the
* previous order, or it's an acceptable change given the underlying logic
* change.
*/
describe("z-index reordering with broken contiguity (invariant-violating input)", () => {
beforeEach(async () => {
await render(<Excalidraw />);
});
const assertReorderPreservesElements = (
elements: Parameters<typeof populateElements>[0],
appState: Parameters<typeof populateElements>[1],
// each op is applied to a freshly-populated (broken) state
cases: [Actions, string[]][],
) => {
for (const [action, expected] of cases) {
populateElements(elements, appState);
const before = h.elements.map((el) => el.id);
expect(() => API.executeAction(action)).not.toThrow();
const after = h.elements.map((el) => el.id);
// hard contract:
expect(after.length).toBe(before.length); // no loss
expect(new Set(after).size).toBe(after.length); // no duplication
// soft snapshot (see block comment before changing):
expect(after).toEqual(expected);
}
};
it("discontiguous frame children (foreign frame's child interleaved in span)", () => {
// F2_1 (a child of frame F2) sits INSIDE frame F1's z-span. Reordering F1's
// child sweeps F2_1 along (span-based frame handling) — wrong ordering, but
// never a duplication/loss, and the op does not throw.
const elements: Parameters<typeof populateElements>[0] = [
{ id: "F1_1", frameId: "F1", isSelected: true },
{ id: "F2_1", frameId: "F2" },
{ id: "F1_2", frameId: "F1" },
{ id: "F1", type: "frame" },
{ id: "F2", type: "frame" },
];
assertReorderPreservesElements(elements, undefined, [
[actionBringForward, ["F2_1", "F1_2", "F1_1", "F1", "F2"]],
[actionSendBackward, ["F1_1", "F2_1", "F1_2", "F1", "F2"]],
[actionBringToFront, ["F2_1", "F1_2", "F1", "F1_1", "F2"]],
[actionSendToBack, ["F1_1", "F2_1", "F1_2", "F1", "F2"]],
]);
});
it("discontiguous group, whole group selected", () => {
// g1 = {A, C}, scattered by the loose elements B and D.
const elements: Parameters<typeof populateElements>[0] = [
{ id: "A", groupIds: ["g1"], isSelected: true },
{ id: "B" },
{ id: "C", groupIds: ["g1"], isSelected: true },
{ id: "D" },
];
assertReorderPreservesElements(elements, undefined, [
// move-by-one leaves the group scattered (each run moves independently)
[actionBringForward, ["B", "A", "D", "C"]],
[actionSendBackward, ["A", "C", "B", "D"]],
// to-front / to-back gather the scattered members back into one block
[actionBringToFront, ["B", "D", "A", "C"]],
[actionSendToBack, ["A", "C", "B", "D"]],
]);
});
it("discontiguous group, single member selected in group-editing mode", () => {
const elements: Parameters<typeof populateElements>[0] = [
{ id: "A", groupIds: ["g1"] },
{ id: "B" },
{ id: "C", groupIds: ["g1"], isSelected: true },
{ id: "D" },
];
assertReorderPreservesElements(elements, { editingGroupId: "g1" }, [
[actionBringForward, ["A", "B", "C", "D"]],
[actionSendBackward, ["C", "A", "B", "D"]],
[actionBringToFront, ["A", "B", "C", "D"]],
[actionSendToBack, ["C", "A", "B", "D"]],
]);
});
it("two interleaved groups, both fully selected", () => {
const elements: Parameters<typeof populateElements>[0] = [
{ id: "A", groupIds: ["g1"], isSelected: true },
{ id: "X", groupIds: ["g2"], isSelected: true },
{ id: "C", groupIds: ["g1"], isSelected: true },
{ id: "Y", groupIds: ["g2"], isSelected: true },
{ id: "Z" },
];
assertReorderPreservesElements(elements, undefined, [
[actionBringForward, ["Z", "A", "X", "C", "Y"]],
[actionSendBackward, ["A", "X", "C", "Y", "Z"]],
[actionBringToFront, ["Z", "A", "X", "C", "Y"]],
[actionSendToBack, ["A", "X", "C", "Y", "Z"]],
]);
});
});
describe("z-index reordering with inconsistent group-editing state", () => {
beforeEach(async () => {
await render(<Excalidraw />);
});
it("does not duplicate or drop elements when selected elements fall outside the edited group scope", () => {
assertZindex({
elements: [
{ id: "A", groupIds: ["g1"], isSelected: true },
{ id: "C", groupIds: ["g1"] },
{ id: "X", groupIds: ["g2"] },
{ id: "Y", groupIds: ["g2"] },
{ id: "R" },
],
appState: { editingGroupId: "g2" },
operations: [[actionSendToBack, ["A", "C", "X", "Y", "R"]]],
});
assertZindex({
elements: [
{ id: "A", groupIds: ["g1"] },
{ id: "C", groupIds: ["g1"] },
{ id: "X", groupIds: ["g2"], isSelected: true },
{ id: "Y", groupIds: ["g2"] },
{ id: "R" },
],
appState: { editingGroupId: "g1" },
operations: [[actionBringToFront, ["A", "C", "X", "Y", "R"]]],
});
});
}); });
+7
View File
@@ -17,6 +17,13 @@ Please add the latest change on the top under the correct section.
### Breaking changes ### Breaking changes
- Theme changes initiated by the default UI are now delegated to `<Excalidraw onThemeChange={(theme) => ...} />` when supplied. If `onThemeChange` is not supplied, light/dark theme toggling still falls back to updating the internal editor state.
- `MainMenu.DefaultItems.ToggleTheme` no longer accepts the item-level `onSelect` callback. Host apps that need to control light/dark/system theme should pass `onThemeChange` to `<Excalidraw />` instead.
- `MainMenu.DefaultItems.ToggleTheme` with system theme support now uses `allowSystemTheme` together with `theme={Theme | "system"}` only to render the selected value. For the regular light/dark item, pass `allowSystemTheme={false}`.
- `CommandPalette.defaultItems.toggleTheme` was removed. The default theme command is now rendered by the command palette itself when `UIOptions.canvasActions.toggleTheme` enables the action (see below).
- `UIOptions.canvasActions.toggleTheme` still controls default theme UI availability. When it is `null`, it defaults to `true` if `props.theme` is omitted or `props.onThemeChange` is supplied, and otherwise defaults to disabled.
- Renamed the `excalidrawAPI` prop to `onExcalidrawAPI`. - Renamed the `excalidrawAPI` prop to `onExcalidrawAPI`.
- `onExcalidrawAPI` is now called on mount (instead of during constructor), and later on unmount (with `null` value). The API may be removed altogether in the future (you can use `onMount` & `onUmount` to manage the `ExcalidrawAPI` object (e.g. to cache it to a global state), already). - `onExcalidrawAPI` is now called on mount (instead of during constructor), and later on unmount (with `null` value). The API may be removed altogether in the future (you can use `onMount` & `onUmount` to manage the `ExcalidrawAPI` object (e.g. to cache it to a global state), already).
+15 -4
View File
@@ -477,17 +477,28 @@ export const actionToggleTheme = register<AppState["theme"]>({
appState.theme === THEME.LIGHT ? MoonIcon : SunIcon, appState.theme === THEME.LIGHT ? MoonIcon : SunIcon,
viewMode: true, viewMode: true,
trackEvent: { category: "canvas" }, trackEvent: { category: "canvas" },
perform: (_, appState, value) => { perform: (_, appState, value, app) => {
const nextTheme =
value || (appState.theme === THEME.LIGHT ? THEME.DARK : THEME.LIGHT);
if (app.props.onThemeChange) {
app.props.onThemeChange(nextTheme);
return false;
}
return { return {
appState: { appState: {
...appState, ...appState,
theme: theme: nextTheme,
value || (appState.theme === THEME.LIGHT ? THEME.DARK : THEME.LIGHT),
}, },
captureUpdate: CaptureUpdateAction.EVENTUALLY, captureUpdate: CaptureUpdateAction.EVENTUALLY,
}; };
}, },
keyTest: (event) => event.altKey && event.shiftKey && event.code === CODES.D, keyTest: (event) =>
!event[KEYS.CTRL_OR_CMD] &&
event.altKey &&
event.shiftKey &&
event.code === CODES.D,
predicate: (elements, appState, props, app) => { predicate: (elements, appState, props, app) => {
return !!app.props.UIOptions.canvasActions.toggleTheme; return !!app.props.UIOptions.canvasActions.toggleTheme;
}, },
@@ -101,6 +101,7 @@ export const actionDeselect = register({
selectionElement: null, selectionElement: null,
showHyperlinkPopup: false, showHyperlinkPopup: false,
suggestedBinding: null, suggestedBinding: null,
frameToHighlight: null,
}, },
captureUpdate: CaptureUpdateAction.IMMEDIATELY, captureUpdate: CaptureUpdateAction.IMMEDIATELY,
}; };
@@ -118,6 +119,7 @@ export const actionDeselect = register({
selectionElement: null, selectionElement: null,
showHyperlinkPopup: false, showHyperlinkPopup: false,
suggestedBinding: null, suggestedBinding: null,
frameToHighlight: null,
}, },
captureUpdate: CaptureUpdateAction.IMMEDIATELY, captureUpdate: CaptureUpdateAction.IMMEDIATELY,
}; };
+40 -1
View File
@@ -54,6 +54,7 @@ export const actionFinalize = register<FormData>({
label: "", label: "",
trackEvent: false, trackEvent: false,
perform: (elements, appState, data, app) => { perform: (elements, appState, data, app) => {
let shouldCommit = true;
let newElements = elements; let newElements = elements;
const { interactiveCanvas, focusContainer, scene } = app; const { interactiveCanvas, focusContainer, scene } = app;
const elementsMap = scene.getNonDeletedElementsMap(); const elementsMap = scene.getNonDeletedElementsMap();
@@ -222,9 +223,44 @@ export const actionFinalize = register<FormData>({
!lastCommittedPoint || !lastCommittedPoint ||
points[points.length - 1] !== lastCommittedPoint points[points.length - 1] !== lastCommittedPoint
) { ) {
shouldCommit = false;
scene.mutateElement(element, { scene.mutateElement(element, {
points: element.points.slice(0, -1), points: element.points.slice(0, -1),
}); });
if (
isBindingElement(element) &&
element.endBinding &&
// after slicing the trailing point a <2-point arrow may be left
element.points.length > 1
) {
const newArrow = !!appState.newElement;
const draggedPoints: PointsPositionUpdates = new Map([
[
element.points.length - 1,
{
point: element.points[element.points.length - 1],
isDragging: false,
},
],
]);
const globalPoint =
LinearElementEditor.getPointAtIndexGlobalCoordinates(
element,
-1,
elementsMap,
);
bindOrUnbindBindingElement(
element,
draggedPoints,
globalPoint[0],
globalPoint[1],
scene,
appState,
{
newArrow,
},
);
}
} }
} }
@@ -330,6 +366,7 @@ export const actionFinalize = register<FormData>({
multiElement: null, multiElement: null,
editingTextElement: null, editingTextElement: null,
suggestedBinding: null, suggestedBinding: null,
frameToHighlight: null,
selectedElementIds: selectedElementIds:
element && element &&
!appState.activeTool.locked && !appState.activeTool.locked &&
@@ -343,7 +380,9 @@ export const actionFinalize = register<FormData>({
selectedLinearElement, selectedLinearElement,
}, },
// TODO: #7348 we should not capture everything, but if we don't, it leads to incosistencies -> revisit // TODO: #7348 we should not capture everything, but if we don't, it leads to incosistencies -> revisit
captureUpdate: CaptureUpdateAction.IMMEDIATELY, captureUpdate: shouldCommit
? CaptureUpdateAction.IMMEDIATELY
: CaptureUpdateAction.NEVER,
}; };
}, },
keyTest: (event, appState) => keyTest: (event, appState) =>
@@ -205,7 +205,6 @@ export const actionWrapSelectionInFrame = register({
[...app.scene.getElementsIncludingDeleted(), frame], [...app.scene.getElementsIncludingDeleted(), frame],
selectedElements, selectedElements,
frame, frame,
appState,
); );
return { return {
@@ -277,7 +277,6 @@ export const actionUngroup = register({
elementsMap, elementsMap,
), ),
frame, frame,
app,
); );
} }
}); });
@@ -1,5 +1,4 @@
import { import {
isWindows,
KEYS, KEYS,
matchKey, matchKey,
arrayToMap, arrayToMap,
@@ -114,7 +113,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)) ||
(isWindows && event.ctrlKey && !event.shiftKey && matchKey(event, KEYS.Y)), (event[KEYS.CTRL_OR_CMD] && !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,
@@ -1,5 +1,4 @@
import { LaserPointer } from "@excalidraw/laser-pointer"; import { LaserPointer } from "@excalidraw/laser-pointer";
import { import {
SVG_NS, SVG_NS,
getSvgPathFromStroke, getSvgPathFromStroke,
@@ -8,7 +7,8 @@ import {
import type { LaserPointerOptions } from "@excalidraw/laser-pointer"; import type { LaserPointerOptions } from "@excalidraw/laser-pointer";
import type { AnimationFrameHandler } from "./animation-frame-handler"; import { AnimationController } from "./renderer/animation";
import type App from "./components/App"; import type App from "./components/App";
import type { AppState } from "./types"; import type { AppState } from "./types";
@@ -34,15 +34,16 @@ 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.animationFrameHandler.register(this, this.onFrame.bind(this)); this.key = `animated-trail-${AnimatedTrail.counter++}`;
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");
@@ -73,6 +74,15 @@ 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;
@@ -82,15 +92,23 @@ export class AnimatedTrail implements Trail {
this.container.appendChild(this.trailElement); this.container.appendChild(this.trailElement);
} }
this.animationFrameHandler.start(this); if (!AnimationController.running(this.key)) {
AnimationController.start(this.key, () => {
const needsNext = this.onFrame();
if (needsNext) {
return { keep: true };
}
this.cleanup();
return null;
});
}
} }
stop() { stop() {
this.animationFrameHandler.stop(this); AnimationController.cancel(this.key);
this.cleanup();
if (this.trailElement.parentNode === this.container) {
this.container?.removeChild(this.trailElement);
}
} }
startPath(x: number, y: number) { startPath(x: number, y: number) {
@@ -145,21 +163,25 @@ 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((trail) => { this.pastTrails = this.pastTrails.filter(
return trail.getStrokeOutline().length !== 0; (t) =>
}); t.getStrokeOutline(t.options.size / this.app.state.zoom.value)
.length !== 0,
);
if (paths.length === 0) { if (paths.length === 0) {
this.stop(); // Clean up the SVG path if there are no trails to render
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",
@@ -175,6 +197,8 @@ 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 {
@@ -1,79 +0,0 @@
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;
}
}
+3 -1
View File
@@ -25,6 +25,8 @@ import type {
NonDeletedExcalidrawElement, NonDeletedExcalidrawElement,
} from "@excalidraw/element/types"; } from "@excalidraw/element/types";
import { stringifyWithPrecision } from "./data/json";
import { ExcalidrawError } from "./errors"; import { ExcalidrawError } from "./errors";
import { import {
createFile, createFile,
@@ -188,7 +190,7 @@ export const serializeAsClipboardJSON = ({
files: files ? _files : undefined, files: files ? _files : undefined,
}; };
return JSON.stringify(contents); return stringifyWithPrecision(contents);
}; };
export const copyToClipboard = async ( export const copyToClipboard = async (
+351 -172
View File
@@ -28,7 +28,6 @@ import {
APP_NAME, APP_NAME,
CURSOR_TYPE, CURSOR_TYPE,
DEFAULT_TRANSFORM_HANDLE_SPACING, DEFAULT_TRANSFORM_HANDLE_SPACING,
DEFAULT_MAX_IMAGE_WIDTH_OR_HEIGHT,
DEFAULT_VERTICAL_ALIGN, DEFAULT_VERTICAL_ALIGN,
DRAGGING_THRESHOLD, DRAGGING_THRESHOLD,
ELEMENT_SHIFT_TRANSLATE_AMOUNT, ELEMENT_SHIFT_TRANSLATE_AMOUNT,
@@ -38,7 +37,6 @@ import {
IMAGE_MIME_TYPES, IMAGE_MIME_TYPES,
IMAGE_RENDER_TIMEOUT, IMAGE_RENDER_TIMEOUT,
LINE_CONFIRM_THRESHOLD, LINE_CONFIRM_THRESHOLD,
MAX_ALLOWED_FILE_BYTES,
MIME_TYPES, MIME_TYPES,
MQ_RIGHT_SIDEBAR_MIN_WIDTH, MQ_RIGHT_SIDEBAR_MIN_WIDTH,
POINTER_BUTTON, POINTER_BUTTON,
@@ -176,7 +174,9 @@ import {
isValidTextContainer, isValidTextContainer,
redrawTextBoundingBox, redrawTextBoundingBox,
hasBoundingBox, hasBoundingBox,
getCommonFrameId,
getFrameChildren, getFrameChildren,
getFrameChildrenInsertionIndex,
isCursorInFrame, isCursorInFrame,
addElementsToFrame, addElementsToFrame,
replaceAllElementsInFrame, replaceAllElementsInFrame,
@@ -259,6 +259,8 @@ import {
maybeHandleArrowPointlikeDrag, maybeHandleArrowPointlikeDrag,
getUncroppedWidthAndHeight, getUncroppedWidthAndHeight,
getActiveTextElement, getActiveTextElement,
isEligibleFrameChildType,
getBindingStrategyForDraggingBindingElementEndpoints,
} from "@excalidraw/element"; } from "@excalidraw/element";
import type { GlobalPoint, LocalPoint, Radians } from "@excalidraw/math"; import type { GlobalPoint, LocalPoint, Radians } from "@excalidraw/math";
@@ -342,7 +344,6 @@ import { ActionManager } from "../actions/manager";
import { actions } from "../actions/register"; import { actions } from "../actions/register";
import { getShortcutFromShortcutName } from "../actions/shortcuts"; import { getShortcutFromShortcutName } from "../actions/shortcuts";
import { trackEvent } from "../analytics"; import { trackEvent } from "../analytics";
import { AnimationFrameHandler } from "../animation-frame-handler";
import { import {
getDefaultAppState, getDefaultAppState,
isEraserActive, isEraserActive,
@@ -416,7 +417,7 @@ import {
setCursorForShape, setCursorForShape,
} from "../cursor"; } from "../cursor";
import { ElementCanvasButtons } from "../components/ElementCanvasButtons"; import { ElementCanvasButtons } from "../components/ElementCanvasButtons";
import { LaserTrails } from "../laser-trails"; import { LaserTrails } from "../laserTrails";
import { withBatchedUpdates, withBatchedUpdatesThrottled } from "../reactUtils"; import { withBatchedUpdates, withBatchedUpdatesThrottled } from "../reactUtils";
import { isPointHittingTextAutoResizeHandle } from "../textAutoResizeHandle"; import { isPointHittingTextAutoResizeHandle } from "../textAutoResizeHandle";
import { textWysiwyg } from "../wysiwyg/textWysiwyg"; import { textWysiwyg } from "../wysiwyg/textWysiwyg";
@@ -702,11 +703,9 @@ class App extends React.Component<AppProps, AppState> {
previousPointerMoveCoords: { x: number; y: number } | null = null; previousPointerMoveCoords: { x: number; y: number } | null = null;
lastViewportPosition = { x: 0, y: 0 }; lastViewportPosition = { x: 0, y: 0 };
animationFrameHandler = new AnimationFrameHandler(); laserTrails = new LaserTrails(this);
eraserTrail = new EraserTrail(this);
laserTrails = new LaserTrails(this.animationFrameHandler, this); lassoTrail = new LassoTrail(this);
eraserTrail = new EraserTrail(this.animationFrameHandler, this);
lassoTrail = new LassoTrail(this.animationFrameHandler, this);
onChangeEmitter = new Emitter< onChangeEmitter = new Emitter<
[ [
@@ -1997,9 +1996,10 @@ class App extends React.Component<AppProps, AppState> {
} }
}} }}
style={{ style={{
background: isDarkTheme background: applyDarkModeFilter(
? applyDarkModeFilter(this.state.viewBackgroundColor) this.state.viewBackgroundColor,
: this.state.viewBackgroundColor, isDarkTheme,
),
zIndex: 2, zIndex: 2,
border: "none", border: "none",
display: "block", display: "block",
@@ -2092,20 +2092,30 @@ class App extends React.Component<AppProps, AppState> {
const selectedElements = this.scene.getSelectedElements(this.state); const selectedElements = this.scene.getSelectedElements(this.state);
const { renderTopRightUI, renderTopLeftUI, renderCustomStats } = this.props; const { renderTopRightUI, renderTopLeftUI, renderCustomStats } = this.props;
const sceneNonce = this.scene.getSceneNonce(); const {
const { elementsMap, visibleElements } = elementsMap,
this.renderer.getRenderableElements({ visibleElements,
sceneNonce, canvasNonce,
zoom: this.state.zoom, /**
offsetLeft: this.state.offsetLeft, * element to draw on the <NewElementCanvas> for optimization purposes.
offsetTop: this.state.offsetTop, * Can be null even if this.state.newElement defined
scrollX: this.state.scrollX, * (e.g. when its zIndex isn't on top) */
scrollY: this.state.scrollY, newElementCanvasElement,
height: this.state.height, } = this.renderer.getRenderableElements({
width: this.state.width, zoom: this.state.zoom,
editingTextElement: this.state.editingTextElement, offsetLeft: this.state.offsetLeft,
newElementId: this.state.newElement?.id, offsetTop: this.state.offsetTop,
}); scrollX: this.state.scrollX,
scrollY: this.state.scrollY,
height: this.state.height,
width: this.state.width,
editingTextElement: this.state.editingTextElement,
newElement: this.state.newElement,
selectedElements,
selectedElementsAreBeingDragged:
this.state.selectedElementsAreBeingDragged,
frameToHighlight: this.state.frameToHighlight,
});
this.visibleElements = visibleElements; this.visibleElements = visibleElements;
const allElementsMap = this.scene.getNonDeletedElementsMap(); const allElementsMap = this.scene.getNonDeletedElementsMap();
@@ -2324,7 +2334,7 @@ class App extends React.Component<AppProps, AppState> {
elementsMap={elementsMap} elementsMap={elementsMap}
allElementsMap={allElementsMap} allElementsMap={allElementsMap}
visibleElements={visibleElements} visibleElements={visibleElements}
sceneNonce={sceneNonce} canvasNonce={canvasNonce}
selectionNonce={ selectionNonce={
this.state.selectionElement?.versionNonce this.state.selectionElement?.versionNonce
} }
@@ -2345,9 +2355,10 @@ class App extends React.Component<AppProps, AppState> {
theme: this.state.theme, theme: this.state.theme,
}} }}
/> />
{this.state.newElement && ( {newElementCanvasElement && (
<NewElementCanvas <NewElementCanvas
appState={this.state} appState={this.state}
newElement={newElementCanvasElement}
scale={window.devicePixelRatio} scale={window.devicePixelRatio}
rc={this.rc} rc={this.rc}
elementsMap={elementsMap} elementsMap={elementsMap}
@@ -2375,7 +2386,7 @@ class App extends React.Component<AppProps, AppState> {
visibleElements={visibleElements} visibleElements={visibleElements}
allElementsMap={allElementsMap} allElementsMap={allElementsMap}
selectedElements={selectedElements} selectedElements={selectedElements}
sceneNonce={sceneNonce} canvasNonce={canvasNonce}
selectionNonce={ selectionNonce={
this.state.selectionElement?.versionNonce this.state.selectionElement?.versionNonce
} }
@@ -2687,7 +2698,7 @@ class App extends React.Component<AppProps, AppState> {
locked: false, locked: false,
}); });
this.scene.insertElement(frame); this.insertNewElement(frame);
for (const child of selectedElements) { for (const child of selectedElements) {
this.scene.mutateElement(child, { frameId: frame.id }); this.scene.mutateElement(child, { frameId: frame.id });
@@ -3765,6 +3776,7 @@ class App extends React.Component<AppProps, AppState> {
position: position:
this.editorInterface.formFactor === "desktop" ? "cursor" : "center", this.editorInterface.formFactor === "desktop" ? "cursor" : "center",
retainSeed: isPlainPaste, retainSeed: isPlainPaste,
preserveFrameChildrenOrder: true,
}); });
return; return;
} }
@@ -3908,6 +3920,7 @@ class App extends React.Component<AppProps, AppState> {
position: { clientX: number; clientY: number } | "cursor" | "center"; position: { clientX: number; clientY: number } | "cursor" | "center";
retainSeed?: boolean; retainSeed?: boolean;
fitToContent?: boolean; fitToContent?: boolean;
preserveFrameChildrenOrder?: boolean;
}) => { }) => {
const elements = restoreElements(opts.elements, null, { const elements = restoreElements(opts.elements, null, {
deleteInvisibleElements: true, deleteInvisibleElements: true,
@@ -3949,6 +3962,7 @@ class App extends React.Component<AppProps, AppState> {
}); });
}), }),
randomizeSeed: !opts.retainSeed, randomizeSeed: !opts.retainSeed,
preserveFrameChildrenOrder: opts.preserveFrameChildrenOrder,
}); });
const prevElements = this.scene.getElementsIncludingDeleted(); const prevElements = this.scene.getElementsIncludingDeleted();
@@ -3970,11 +3984,10 @@ class App extends React.Component<AppProps, AppState> {
duplicatedElements, duplicatedElements,
topLayerFrame, topLayerFrame,
); );
addElementsToFrame( nextElements = addElementsToFrame(
nextElements, nextElements,
eligibleElements, eligibleElements,
topLayerFrame, topLayerFrame,
this.state,
); );
} }
@@ -4200,7 +4213,7 @@ class App extends React.Component<AppProps, AppState> {
return; return;
} }
this.scene.insertElements(textElements); this.insertNewElements(textElements);
this.store.scheduleCapture(); this.store.scheduleCapture();
this.setState({ this.setState({
selectedElementIds: makeNextSelectedElementIds( selectedElementIds: makeNextSelectedElementIds(
@@ -4601,6 +4614,7 @@ class App extends React.Component<AppProps, AppState> {
} }
if (collaborators) { if (collaborators) {
this.laserTrails.updateCollabTrails(collaborators);
this.setState({ collaborators }); this.setState({ collaborators });
} }
}, },
@@ -5456,7 +5470,7 @@ class App extends React.Component<AppProps, AppState> {
if (!event[KEYS.CTRL_OR_CMD]) { if (!event[KEYS.CTRL_OR_CMD]) {
if (this.flowChartCreator.isCreatingChart) { if (this.flowChartCreator.isCreatingChart) {
if (this.flowChartCreator.pendingNodes?.length) { if (this.flowChartCreator.pendingNodes?.length) {
this.scene.insertElements(this.flowChartCreator.pendingNodes); this.insertNewElements(this.flowChartCreator.pendingNodes);
} }
const firstNode = this.flowChartCreator.pendingNodes?.[0]; const firstNode = this.flowChartCreator.pendingNodes?.[0];
@@ -5554,6 +5568,7 @@ class App extends React.Component<AppProps, AppState> {
selectedLinearElement: isSelectionLikeTool(nextActiveTool.type) selectedLinearElement: isSelectionLikeTool(nextActiveTool.type)
? prevState.selectedLinearElement ? prevState.selectedLinearElement
: null, : null,
frameToHighlight: null,
} as const; } as const;
if (nextActiveTool.type === "freedraw") { if (nextActiveTool.type === "freedraw") {
@@ -6151,6 +6166,12 @@ class App extends React.Component<AppProps, AppState> {
hitElement = elements[index]; hitElement = elements[index];
break; break;
} else if (x1 < x && x < x2 && y1 < y && y < y2) { } else if (x1 < x && x < x2 && y1 < y && y < y2) {
// to allow binding to containers within frames,
// ignore frames in hit testing
if (isFrameLikeElement(elements[index])) {
continue;
}
hitElement = elements[index]; hitElement = elements[index];
break; break;
} }
@@ -6240,11 +6261,6 @@ class App extends React.Component<AppProps, AppState> {
} }
} }
const topLayerFrame = this.getTopLayerFrameAtSceneCoords({
x: sceneX,
y: sceneY,
});
const textCreationGridPoint = this.getTextCreationGridPoint(sceneX, sceneY); const textCreationGridPoint = this.getTextCreationGridPoint(sceneX, sceneY);
const newTextElementPosition = parentCenterPosition const newTextElementPosition = parentCenterPosition
@@ -6266,6 +6282,20 @@ class App extends React.Component<AppProps, AppState> {
y: sceneY, y: sceneY,
}; };
const topLayerFrame = this.getTopLayerFrameAtSceneCoords({
x: newTextElementPosition.x,
y: newTextElementPosition.y,
});
// container has higher priority. Only add to frame if container is in the same frame.
const frameId =
topLayerFrame &&
(!shouldBindToContainer ||
!container ||
container.frameId === topLayerFrame.id)
? topLayerFrame.id
: null;
const element = const element =
existingTextElement || existingTextElement ||
newTextElement({ newTextElement({
@@ -6295,7 +6325,7 @@ class App extends React.Component<AppProps, AppState> {
? (0 as Radians) ? (0 as Radians)
: container.angle : container.angle
: (0 as Radians), : (0 as Radians),
frameId: topLayerFrame ? topLayerFrame.id : null, frameId,
}); });
if (!existingTextElement && shouldBindToContainer && container) { if (!existingTextElement && shouldBindToContainer && container) {
@@ -6311,9 +6341,12 @@ class App extends React.Component<AppProps, AppState> {
if (!existingTextElement) { if (!existingTextElement) {
if (container && shouldBindToContainer) { if (container && shouldBindToContainer) {
const containerIndex = this.scene.getElementIndex(container.id); const containerIndex = this.scene.getElementIndex(container.id);
this.scene.insertElementAtIndex(element, containerIndex + 1); // TODO should use insertNewElement, after we update it to handle
// elements with containerId + frameId at the same time (containerId
// should take precedence when it comes to z-index)
this.scene.insertElementsAtIndex([element], containerIndex + 1);
} else { } else {
this.scene.insertElement(element); this.insertNewElement(element);
} }
} }
@@ -6695,19 +6728,161 @@ class App extends React.Component<AppProps, AppState> {
} }
}; };
private getTopLayerFrameAtSceneCoords = (sceneCoords: { /**
x: number; * finds candidate frame under cursor (when dragging frame children/elements
y: number; * inside frames)
}) => { */
private getTopLayerFrameAtSceneCoords = (
/**
* should be already grid aligned (basically should be what the call site
* sets the element's coords to, if applicable)
*/
sceneCoords: {
x: number;
y: number;
},
opts?: {
/** to exclude selected elements when dragging, etc. */
excludeElementIds?: AppState["selectedElementIds"];
currentFrameId?: ExcalidrawElement["frameId"];
},
) => {
const elementsMap = this.scene.getNonDeletedElementsMap(); const elementsMap = this.scene.getNonDeletedElementsMap();
const frames = this.scene const framesUnderCursor = this.scene
.getNonDeletedFramesLikes() .getNonDeletedFramesLikes()
.filter( .filter(
(frame): frame is ExcalidrawFrameLikeElement => (frame): frame is ExcalidrawFrameLikeElement =>
!frame.locked && isCursorInFrame(sceneCoords, frame, elementsMap), !frame.locked && isCursorInFrame(sceneCoords, frame, elementsMap),
); );
return frames.length ? frames[frames.length - 1] : null; if (!framesUnderCursor.length) {
return null;
}
const topLayerFrame = framesUnderCursor.at(-1)!;
const hitElement = this.getElementsAtPosition(
sceneCoords.x,
sceneCoords.y,
{
includeLockedElements: true,
},
).findLast((element) => !opts?.excludeElementIds?.[element.id]);
if (hitElement) {
if (
isFrameLikeElement(hitElement) &&
// case: we're hitting a locked frame itself (frame's outline
// or later its bg once implemented)
!hitElement.locked
) {
return topLayerFrame;
}
const hitElementIndex = this.scene.getElementIndex(hitElement.id);
const topLayerFrameIndex = this.scene.getElementIndex(topLayerFrame.id);
if (
hitElementIndex !== -1 &&
topLayerFrameIndex !== -1 &&
hitElementIndex <= topLayerFrameIndex
) {
return topLayerFrame;
}
// to support a case of dragging a pre-existing frame child underneath
// a non-frame element covering the cursor
const currentFrame = opts?.currentFrameId
? framesUnderCursor.find((frame) => frame.id === opts.currentFrameId) ??
null
: null;
if (currentFrame) {
return currentFrame;
}
return hitElement.frameId
? framesUnderCursor.find((frame) => frame.id === hitElement.frameId) ??
null
: null;
}
return topLayerFrame;
};
private updateFrameToHighlight = (
frameToHighlight: AppState["frameToHighlight"],
) => {
if (this.state.frameToHighlight !== frameToHighlight) {
this.setState({ frameToHighlight });
}
};
private maybeUpdateFrameToHighlightOnPointerMove = (
sceneCoords: { x: number; y: number },
isOverScrollBar: boolean,
) => {
// currently this function is being called even during pointerdown so we
// need to make sure we don't re-set the state when dragging and similar
//
// But, we still want to reset on pointermove in case the state is stale
// so we updte even for non-eligible tool types
if (
this.state.newElement ||
this.state.multiElement ||
this.state.selectionElement ||
this.state.selectedElementsAreBeingDragged
) {
return;
}
this.updateFrameToHighlight(
!isOverScrollBar && isEligibleFrameChildType(this.state.activeTool.type)
? this.getTopLayerFrameAtSceneCoords(sceneCoords)
: null,
);
};
private insertNewElements = (elements: readonly ExcalidrawElement[]) => {
if (!elements.length) {
return;
}
const chunkedElements: ExcalidrawElement[][] = [];
for (const element of elements) {
const currentChunk = chunkedElements[chunkedElements.length - 1];
if (currentChunk?.[0].frameId === element.frameId) {
currentChunk.push(element);
} else {
chunkedElements.push([element]);
}
}
for (const chunk of chunkedElements) {
const frameId = chunk[0].frameId;
const insertionIndex = frameId
? getFrameChildrenInsertionIndex(
this.scene.getElementsIncludingDeleted(),
frameId,
)
: null;
this.scene.insertElementsAtIndex(chunk, insertionIndex);
}
};
private insertNewElement = (element: ExcalidrawElement) => {
this.insertNewElements([element]);
const frame = element.frameId
? this.scene.getNonDeletedElement(element.frameId)
: null;
this.updateFrameToHighlight(
frame && isFrameLikeElement(frame) ? frame : null,
);
}; };
private handleCanvasPointerMove = ( private handleCanvasPointerMove = (
@@ -6809,6 +6984,14 @@ class App extends React.Component<AppProps, AppState> {
} }
} }
this.maybeUpdateFrameToHighlightOnPointerMove(
{
x: scenePointerX,
y: scenePointerY,
},
isOverScrollBar,
);
if ( if (
!this.state.newElement && !this.state.newElement &&
isActiveToolNonLinearSnappable(this.state.activeTool.type) isActiveToolNonLinearSnappable(this.state.activeTool.type)
@@ -6930,45 +7113,7 @@ class App extends React.Component<AppProps, AppState> {
setCursorForShape(this.interactiveCanvas, this.state); setCursorForShape(this.interactiveCanvas, this.state);
if (lastPoint === lastCommittedPoint) { if (lastPoint === lastCommittedPoint) {
const hoveredElement = if (
isArrowElement(this.state.newElement) &&
isBindingEnabled(this.state) &&
getHoveredElementForBinding(
pointFrom<GlobalPoint>(scenePointerX, scenePointerY),
this.scene.getNonDeletedElements(),
this.scene.getNonDeletedElementsMap(),
maxBindingDistance_simple(this.state.zoom),
);
if (hoveredElement) {
this.actionManager.executeAction(actionFinalize, "ui", {
event: event.nativeEvent,
sceneCoords: {
x: scenePointerX,
y: scenePointerY,
},
});
this.setState({ suggestedBinding: null });
if (!this.state.activeTool.locked) {
resetCursor(this.interactiveCanvas);
this.setState((prevState) => ({
newElement: null,
activeTool: updateActiveTool(this.state, {
type: this.state.preferredSelectionTool.type,
}),
selectedElementIds: makeNextSelectedElementIds(
{
...prevState.selectedElementIds,
[multiElement.id]: true,
},
prevState,
),
selectedLinearElement: new LinearElementEditor(
multiElement,
this.scene.getNonDeletedElementsMap(),
),
}));
}
} else if (
// if we haven't yet created a temp point and we're beyond commit-zone // if we haven't yet created a temp point and we're beyond commit-zone
// threshold, add a point // threshold, add a point
pointDistance( pointDistance(
@@ -6976,6 +7121,24 @@ class App extends React.Component<AppProps, AppState> {
lastPoint, lastPoint,
) >= LINE_CONFIRM_THRESHOLD ) >= LINE_CONFIRM_THRESHOLD
) { ) {
this.store.scheduleCapture();
flushSync(() => {
invariant(
this.state.selectedLinearElement?.initialState,
"initialState must be set",
);
this.setState({
selectedLinearElement: {
...this.state.selectedLinearElement,
lastCommittedPoint: points[points.length - 1],
selectedPointsIndices: [multiElement.points.length],
initialState: {
...this.state.selectedLinearElement.initialState,
lastClickedPoint: multiElement.points.length,
},
},
});
});
this.scene.mutateElement( this.scene.mutateElement(
multiElement, multiElement,
{ {
@@ -6986,21 +7149,6 @@ class App extends React.Component<AppProps, AppState> {
}, },
{ informMutation: false, isDragging: false }, { informMutation: false, isDragging: false },
); );
invariant(
this.state.selectedLinearElement?.initialState,
"initialState must be set",
);
this.setState({
selectedLinearElement: {
...this.state.selectedLinearElement,
lastCommittedPoint: points[points.length - 1],
selectedPointsIndices: [multiElement.points.length - 1],
initialState: {
...this.state.selectedLinearElement.initialState,
lastClickedPoint: multiElement.points.length - 1,
},
},
});
} else { } else {
setCursor(this.interactiveCanvas, CURSOR_TYPE.POINTER); setCursor(this.interactiveCanvas, CURSOR_TYPE.POINTER);
// in this branch, we're inside the commit zone, and no uncommitted // in this branch, we're inside the commit zone, and no uncommitted
@@ -8863,7 +9011,7 @@ class App extends React.Component<AppProps, AppState> {
pressures: simulatePressure ? [] : [event.pressure], pressures: simulatePressure ? [] : [event.pressure],
}); });
this.scene.insertElement(element); this.insertNewElement(element);
this.setState((prevState) => { this.setState((prevState) => {
const nextSelectedElementIds = { const nextSelectedElementIds = {
@@ -8920,7 +9068,7 @@ class App extends React.Component<AppProps, AppState> {
height, height,
}); });
this.scene.insertElement(element); this.insertNewElement(element);
return element; return element;
}; };
@@ -8974,7 +9122,7 @@ class App extends React.Component<AppProps, AppState> {
link, link,
}); });
this.scene.insertElement(element); this.insertNewElement(element);
return element; return element;
}; };
@@ -9082,32 +9230,58 @@ class App extends React.Component<AppProps, AppState> {
const { x: rx, y: ry } = multiElement; const { x: rx, y: ry } = multiElement;
const { lastCommittedPoint } = selectedLinearElement; const { lastCommittedPoint } = selectedLinearElement;
const sceneCoords = viewportCoordsToSceneCoords(event, this.state);
const { start, end } =
isBindingElement(multiElement) && isBindingEnabled(this.state)
? getBindingStrategyForDraggingBindingElementEndpoints(
multiElement,
new Map([
[
multiElement.points.length - 1,
{
point: multiElement.points[multiElement.points.length - 1],
isDragging: false,
},
],
]),
sceneCoords.x,
sceneCoords.y,
this.scene.getNonDeletedElementsMap(),
this.scene.getNonDeletedElements(),
this.state,
{
newArrow: Boolean(this.state.newElement),
zoom: this.state.zoom,
},
)
: { end: { mode: undefined } };
const hoveredElementForBinding = const elementsMap = this.scene.getNonDeletedElementsMap();
isBindingEnabled(this.state) && // Auto-confirm when both ends bind to the SAME element and the end point
getHoveredElementForBinding( // lands on the outline rather than inside it
pointFrom<GlobalPoint>( const endOutsideSameElement =
this.lastPointerMoveCoords?.x ?? start?.mode != null &&
rx + multiElement.points[multiElement.points.length - 1][0], end.mode != null &&
this.lastPointerMoveCoords?.y ?? start.element.id === end.element.id &&
ry + multiElement.points[multiElement.points.length - 1][1], !isPointInElement(end.focusPoint, end.element, elementsMap);
const boundOutsideFromElsewhere =
end.mode === "orbit" &&
multiElement.startBinding?.elementId !== end.element?.id;
const lastCommittedPointIsInsideCommitZone =
lastCommittedPoint &&
pointDistance(
pointFrom(
pointerDownState.origin.x - rx,
pointerDownState.origin.y - ry,
), ),
this.scene.getNonDeletedElements(), lastCommittedPoint,
this.scene.getNonDeletedElementsMap(), ) < LINE_CONFIRM_THRESHOLD;
);
// clicking inside commit zone → finalize arrow // clicking inside commit zone → finalize arrow
if ( if (
(isBindingElement(multiElement) && hoveredElementForBinding) || boundOutsideFromElsewhere || // Outside -> orbit: Bind immediately
(multiElement.points.length > 1 && endOutsideSameElement || // End outside the start's element: Bind immediately
lastCommittedPoint && (multiElement.points.length > 1 && lastCommittedPointIsInsideCommitZone)
pointDistance(
pointFrom(
pointerDownState.origin.x - rx,
pointerDownState.origin.y - ry,
),
lastCommittedPoint,
) < LINE_CONFIRM_THRESHOLD)
) { ) {
this.actionManager.executeAction(actionFinalize, "ui", { this.actionManager.executeAction(actionFinalize, "ui", {
event: event.nativeEvent, event: event.nativeEvent,
@@ -9218,7 +9392,7 @@ class App extends React.Component<AppProps, AppState> {
points: [pointFrom<LocalPoint>(0, 0), pointFrom<LocalPoint>(0, 0)], points: [pointFrom<LocalPoint>(0, 0), pointFrom<LocalPoint>(0, 0)],
}); });
this.scene.insertElement(element); this.insertNewElement(element);
if (isBindingElement(element)) { if (isBindingElement(element)) {
// Do the initial binding so the binding strategy has the initial state // Do the initial binding so the binding strategy has the initial state
@@ -9376,7 +9550,7 @@ class App extends React.Component<AppProps, AppState> {
selectionElement: element, selectionElement: element,
}); });
} else { } else {
this.scene.insertElement(element); this.insertNewElement(element);
this.setState({ this.setState({
multiElement: null, multiElement: null,
newElement: element, newElement: element,
@@ -9409,7 +9583,7 @@ class App extends React.Component<AppProps, AppState> {
? newMagicFrameElement(constructorOpts) ? newMagicFrameElement(constructorOpts)
: newFrameElement(constructorOpts); : newFrameElement(constructorOpts);
this.scene.insertElement(frame); this.insertNewElement(frame);
this.setState({ this.setState({
multiElement: null, multiElement: null,
@@ -9777,18 +9951,17 @@ class App extends React.Component<AppProps, AppState> {
return; return;
} }
const selectedElementsHasAFrame = selectedElements.find((e) => const selectedElementsHasAFrame = selectedElements.some((e) =>
isFrameLikeElement(e), isFrameLikeElement(e),
); );
const topLayerFrame = this.getTopLayerFrameAtSceneCoords(pointerCoords); const frameToHighlight = selectedElementsHasAFrame
const frameToHighlight = ? null
topLayerFrame && !selectedElementsHasAFrame ? topLayerFrame : null; : this.getTopLayerFrameAtSceneCoords(pointerCoords, {
currentFrameId: getCommonFrameId(selectedElements),
excludeElementIds: this.state.selectedElementIds,
});
// Only update the state if there is a difference // Only update the state if there is a difference
if (this.state.frameToHighlight !== frameToHighlight) { this.updateFrameToHighlight(frameToHighlight);
flushSync(() => {
this.setState({ frameToHighlight });
});
}
// Marking that click was used for dragging to check // Marking that click was used for dragging to check
// if elements should be deselected on pointerup // if elements should be deselected on pointerup
@@ -10224,20 +10397,38 @@ class App extends React.Component<AppProps, AppState> {
); );
let linearElementEditor = this.state.selectedLinearElement; let linearElementEditor = this.state.selectedLinearElement;
if (!linearElementEditor) {
if (
!linearElementEditor ||
linearElementEditor.elementId !== newElement.id
) {
linearElementEditor = new LinearElementEditor( linearElementEditor = new LinearElementEditor(
newElement, newElement,
this.scene.getNonDeletedElementsMap(), this.scene.getNonDeletedElementsMap(),
); );
}
const lastClickedPointOutOfBounds =
linearElementEditor &&
(linearElementEditor.initialState.lastClickedPoint < 0 ||
linearElementEditor.initialState.lastClickedPoint >=
points.length);
if (lastClickedPointOutOfBounds) {
console.warn(
"Last clicked point is out of bounds. Attempting to fix it.",
);
linearElementEditor = { linearElementEditor = {
...linearElementEditor, ...linearElementEditor,
selectedPointsIndices: [1], selectedPointsIndices: [points.length - 1],
initialState: { initialState: {
...linearElementEditor.initialState, ...linearElementEditor.initialState,
lastClickedPoint: 1, prevSelectedPointsIndices: null,
lastClickedPoint: points.length - 1,
}, },
hoverPointIndex: points.length - 1,
}; };
} }
this.setState({ this.setState({
newElement, newElement,
...LinearElementEditor.handlePointDragging( ...LinearElementEditor.handlePointDragging(
@@ -10665,13 +10856,6 @@ class App extends React.Component<AppProps, AppState> {
} }
if (isLinearElement(newElement)) { if (isLinearElement(newElement)) {
if (
newElement!.points.length > 1 &&
newElement.points[1][0] !== 0 &&
newElement.points[1][1] !== 0
) {
this.store.scheduleCapture();
}
const pointerCoords = viewportCoordsToSceneCoords( const pointerCoords = viewportCoordsToSceneCoords(
childEvent, childEvent,
this.state, this.state,
@@ -10709,23 +10893,15 @@ class App extends React.Component<AppProps, AppState> {
this.actionManager.executeAction(actionFinalize); this.actionManager.executeAction(actionFinalize);
} else { } else {
const dx = pointerCoords.x - newElement.x; // Movement out of commit area will create the point
const dy = pointerCoords.y - newElement.y;
this.scene.mutateElement(
newElement,
{
points: [newElement.points[0], pointFrom<LocalPoint>(dx, dy)],
},
{ informMutation: false, isDragging: false },
);
this.setState({ this.setState({
multiElement: newElement, multiElement: newElement,
newElement, newElement,
}); });
} }
} else if (pointerDownState.drag.hasOccurred && !multiElement) { } else if (pointerDownState.drag.hasOccurred && !multiElement) {
this.store.scheduleCapture();
if (isLinearElement(newElement)) { if (isLinearElement(newElement)) {
this.actionManager.executeAction(actionFinalize, "ui", { this.actionManager.executeAction(actionFinalize, "ui", {
event: childEvent, event: childEvent,
@@ -10817,7 +10993,6 @@ class App extends React.Component<AppProps, AppState> {
this.scene.getElementsMapIncludingDeleted(), this.scene.getElementsMapIncludingDeleted(),
elementsInsideFrame, elementsInsideFrame,
newElement, newElement,
this.state,
), ),
); );
} }
@@ -10877,9 +11052,14 @@ class App extends React.Component<AppProps, AppState> {
} }
} else { } else {
// update the relationships between selected elements and frames // update the relationships between selected elements and frames
const topLayerFrame = this.getTopLayerFrameAtSceneCoords(sceneCoords);
const selectedElements = this.scene.getSelectedElements(this.state); const selectedElements = this.scene.getSelectedElements(this.state);
const topLayerFrame = this.getTopLayerFrameAtSceneCoords(
sceneCoords,
{
currentFrameId: getCommonFrameId(selectedElements),
excludeElementIds: this.state.selectedElementIds,
},
);
let nextElements = this.scene.getElementsMapIncludingDeleted(); let nextElements = this.scene.getElementsMapIncludingDeleted();
const updateGroupIdsAfterEditingGroup = ( const updateGroupIdsAfterEditingGroup = (
@@ -10928,10 +11108,8 @@ class App extends React.Component<AppProps, AppState> {
topLayerFrame && topLayerFrame &&
!this.state.selectedElementIds[topLayerFrame.id] !this.state.selectedElementIds[topLayerFrame.id]
) { ) {
const elementsToAdd = selectedElements.filter( const elementsToAdd = selectedElements.filter((element) =>
(element) => isElementInFrame(element, nextElements, this.state),
element.frameId !== topLayerFrame.id &&
isElementInFrame(element, nextElements, this.state),
); );
if (this.state.editingGroupId) { if (this.state.editingGroupId) {
@@ -10942,7 +11120,6 @@ class App extends React.Component<AppProps, AppState> {
nextElements, nextElements,
elementsToAdd, elementsToAdd,
topLayerFrame, topLayerFrame,
this.state,
); );
} else if (!topLayerFrame) { } else if (!topLayerFrame) {
if (this.state.editingGroupId) { if (this.state.editingGroupId) {
@@ -11004,7 +11181,6 @@ class App extends React.Component<AppProps, AppState> {
elementsMap, elementsMap,
), ),
frame, frame,
this,
); );
} }
@@ -11519,9 +11695,11 @@ class App extends React.Component<AppProps, AppState> {
const existingFileData = this.files[fileId]; const existingFileData = this.files[fileId];
if (!existingFileData?.dataURL) { if (!existingFileData?.dataURL) {
const { maxWidthOrHeight, maxFileSizeBytes } = this.props.imageOptions;
try { try {
imageFile = await resizeImageFile(imageFile, { imageFile = await resizeImageFile(imageFile, {
maxWidthOrHeight: DEFAULT_MAX_IMAGE_WIDTH_OR_HEIGHT, maxWidthOrHeight,
}); });
} catch (error: any) { } catch (error: any) {
console.error( console.error(
@@ -11530,10 +11708,10 @@ class App extends React.Component<AppProps, AppState> {
); );
} }
if (imageFile.size > MAX_ALLOWED_FILE_BYTES) { if (imageFile.size > maxFileSizeBytes) {
throw new Error( throw new Error(
t("errors.fileTooBig", { t("errors.fileTooBig", {
maxSize: `${Math.trunc(MAX_ALLOWED_FILE_BYTES / 1024 / 1024)}MB`, maxSize: `${Math.trunc(maxFileSizeBytes / 1024 / 1024)}MB`,
}), }),
); );
} }
@@ -11820,7 +11998,7 @@ class App extends React.Component<AppProps, AppState> {
sceneY, sceneY,
gridPadding, gridPadding,
); );
placeholders.forEach((el) => this.scene.insertElement(el)); this.insertNewElements(placeholders);
// Create, position, insert and select initialized (replacing placeholders) // Create, position, insert and select initialized (replacing placeholders)
const initialized = await Promise.all( const initialized = await Promise.all(
@@ -11948,6 +12126,7 @@ class App extends React.Component<AppProps, AppState> {
type: "everything", type: "everything",
elements: item.elements, elements: item.elements,
randomizeSeed: true, randomizeSeed: true,
preserveFrameChildrenOrder: true,
}).duplicatedElements, }).duplicatedElements,
})); }));
@@ -19,6 +19,7 @@ import {
actionClearCanvas, actionClearCanvas,
actionLink, actionLink,
actionToggleSearchMenu, actionToggleSearchMenu,
actionToggleTheme,
} from "../../actions"; } from "../../actions";
import { import {
actionCopyElementLink, actionCopyElementLink,
@@ -424,6 +425,7 @@ function CommandPaletteInner({
]; ];
const additionalCommands: CommandPaletteItem[] = [ const additionalCommands: CommandPaletteItem[] = [
actionToCommand(actionToggleTheme, DEFAULT_CATEGORIES.app),
{ {
label: t("toolBar.library"), label: t("toolBar.library"),
category: DEFAULT_CATEGORIES.app, category: DEFAULT_CATEGORIES.app,
@@ -1,12 +1 @@
import { actionToggleTheme } from "../../actions"; export {};
import type { CommandPaletteItem } from "./types";
export const toggleTheme: CommandPaletteItem = {
...actionToggleTheme,
category: "App",
label: "Toggle theme",
perform: ({ actionManager }) => {
actionManager.executeAction(actionToggleTheme, "commandPalette");
},
};
@@ -831,14 +831,13 @@ const convertElementType = <
newElement({ newElement({
...element, ...element,
type: targetType, type: targetType,
roundness: roundness: element.roundness
targetType === "diamond" && element.roundness ? {
? { type: isUsingAdaptiveRadius(targetType)
type: isUsingAdaptiveRadius(targetType) ? ROUNDNESS.ADAPTIVE_RADIUS
? ROUNDNESS.ADAPTIVE_RADIUS : ROUNDNESS.PROPORTIONAL_RADIUS,
: ROUNDNESS.PROPORTIONAL_RADIUS, }
} : element.roundness,
: element.roundness,
}), }),
) as typeof element; ) as typeof element;
@@ -4,11 +4,13 @@ import { isDarwin, isFirefox, isWindows } from "@excalidraw/common";
import { KEYS } from "@excalidraw/common"; import { KEYS } from "@excalidraw/common";
import { actionToggleTheme } from "../actions";
import { getShortcutFromShortcutName } from "../actions/shortcuts"; import { getShortcutFromShortcutName } from "../actions/shortcuts";
import { probablySupportsClipboardBlob } from "../clipboard"; import { probablySupportsClipboardBlob } from "../clipboard";
import { t } from "../i18n"; import { t } from "../i18n";
import { getShortcutKey } from "../shortcut"; import { getShortcutKey } from "../shortcut";
import { useExcalidrawActionManager } from "./App";
import { Dialog } from "./Dialog"; import { Dialog } from "./Dialog";
import { ExternalLinkIcon, GithubIcon, youtubeIcon } from "./icons"; import { ExternalLinkIcon, GithubIcon, youtubeIcon } from "./icons";
@@ -124,6 +126,7 @@ const ShortcutKey = (props: { children: React.ReactNode }) => (
); );
export const HelpDialog = ({ onClose }: { onClose?: () => void }) => { export const HelpDialog = ({ onClose }: { onClose?: () => void }) => {
const actionManager = useExcalidrawActionManager();
const handleClose = React.useCallback(() => { const handleClose = React.useCallback(() => {
if (onClose) { if (onClose) {
onClose(); onClose();
@@ -302,10 +305,12 @@ export const HelpDialog = ({ onClose }: { onClose?: () => void }) => {
label={t("labels.viewMode")} label={t("labels.viewMode")}
shortcuts={[getShortcutKey("Alt+R")]} shortcuts={[getShortcutKey("Alt+R")]}
/> />
<Shortcut {actionManager.isActionEnabled(actionToggleTheme) && (
label={t("labels.toggleTheme")} <Shortcut
shortcuts={[getShortcutKey("Alt+Shift+D")]} label={t("labels.toggleTheme")}
/> shortcuts={[getShortcutKey("Alt+Shift+D")]}
/>
)}
<Shortcut <Shortcut
label={t("stats.fullTitle")} label={t("stats.fullTitle")}
shortcuts={[getShortcutKey("Alt+/")]} shortcuts={[getShortcutKey("Alt+/")]}
@@ -7,7 +7,6 @@
border-radius: var(--border-radius-lg); border-radius: var(--border-radius-lg);
padding: calc(var(--padding) * var(--space-factor)); padding: calc(var(--padding) * var(--space-factor));
position: relative; position: relative;
transition: box-shadow 0.5s ease-in-out;
&.zen-mode { &.zen-mode {
box-shadow: none; box-shadow: none;
+1 -1
View File
@@ -122,7 +122,7 @@ const DefaultMainMenu: React.FC<{
<MainMenu.DefaultItems.Socials /> <MainMenu.DefaultItems.Socials />
</MainMenu.Group> </MainMenu.Group>
<MainMenu.Separator /> <MainMenu.Separator />
<MainMenu.DefaultItems.ToggleTheme /> <MainMenu.DefaultItems.ToggleTheme allowSystemTheme={false} />
<MainMenu.DefaultItems.ChangeCanvasBackground /> <MainMenu.DefaultItems.ChangeCanvasBackground />
</MainMenu> </MainMenu>
); );
@@ -199,6 +199,7 @@ export default function LibraryMenuItems({
type: "everything", type: "everything",
elements: item.elements, elements: item.elements,
randomizeSeed: true, randomizeSeed: true,
preserveFrameChildrenOrder: true,
}).duplicatedElements, }).duplicatedElements,
}; };
}); });
@@ -29,6 +29,7 @@
gap: 2px; gap: 2px;
&__choice { &__choice {
box-sizing: content-box;
position: relative; position: relative;
display: flex; display: flex;
align-items: center; align-items: center;
@@ -50,13 +51,11 @@
user-select: none; user-select: none;
letter-spacing: 0.4px; letter-spacing: 0.4px;
transition: all 75ms ease-out;
&:hover { &:hover {
color: var(--RadioGroup-choice-color-off-hover); color: var(--RadioGroup-choice-color-off-hover);
} }
&:active { &:not(.active):active {
background: var(--RadioGroup-choice-background-off-active); background: var(--RadioGroup-choice-background-off-active);
} }
+1 -1
View File
@@ -2,7 +2,7 @@ import { useEffect, useRef } from "react";
import "./SVGLayer.scss"; import "./SVGLayer.scss";
import type { Trail } from "../animated-trail"; import type { Trail } from "../animatedTrail";
type SVGLayerProps = { type SVGLayerProps = {
trails: Trail[]; trails: Trail[];
@@ -206,7 +206,6 @@ const handleDimensionChange: DragInputCallbackType<
scene.getElementsIncludingDeleted(), scene.getElementsIncludingDeleted(),
nextElementsInFrame, nextElementsInFrame,
latestElement, latestElement,
app,
); );
scene.replaceAllElements(updatedElements); scene.replaceAllElements(updatedElements);
@@ -302,7 +301,6 @@ const handleDragFinished: DragFinishedCallbackType = ({
app.scene.getElementsIncludingDeleted(), app.scene.getElementsIncludingDeleted(),
nextElementsInFrame, nextElementsInFrame,
latestElement, latestElement,
app,
); );
app.scene.replaceAllElements(updatedElements); app.scene.replaceAllElements(updatedElements);
@@ -261,7 +261,6 @@ const handleDimensionChange: DragInputCallbackType<
scene.getElementsIncludingDeleted(), scene.getElementsIncludingDeleted(),
nextElementsInFrame, nextElementsInFrame,
latestElement, latestElement,
app,
); );
scene.replaceAllElements(updatedElements); scene.replaceAllElements(updatedElements);
@@ -416,7 +415,6 @@ 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;
sceneNonce: number | undefined; canvasNonce: string;
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.sceneNonce !== nextProps.sceneNonce || prevProps.canvasNonce !== nextProps.canvasNonce ||
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 sceneNonce didn't change (e.g. we filter elements out based // even if canvasNonce 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,6 +14,7 @@ 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;
@@ -31,7 +32,7 @@ const NewElementCanvas = (props: NewElementCanvasProps) => {
{ {
canvas: canvasRef.current, canvas: canvasRef.current,
scale: props.scale, scale: props.scale,
newElement: props.appState.newElement, newElement: props.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[];
sceneNonce: number | undefined; canvasNonce: string;
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.sceneNonce !== nextProps.sceneNonce || prevProps.canvasNonce !== nextProps.canvasNonce ||
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 sceneNonce didn't change (e.g. we filter elements out based // even if canvasNonce 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
@@ -232,18 +232,22 @@ export const ToggleTheme = (
props: props:
| { | {
allowSystemTheme: true; allowSystemTheme: true;
/**
* Controls the theme of this UI component only.
* You should subscribe to `props.onThemeChange` and control the theme
* upstream.
*/
theme: Theme | "system"; theme: Theme | "system";
onSelect: (theme: Theme | "system") => void;
} }
| { | {
allowSystemTheme?: false; allowSystemTheme: false;
onSelect?: (theme: Theme) => void;
}, },
) => { ) => {
const { t } = useI18n(); const { t } = useI18n();
const appState = useUIAppState(); const appState = useUIAppState();
const actionManager = useExcalidrawActionManager(); const actionManager = useExcalidrawActionManager();
const shortcut = getShortcutFromShortcutName("toggleTheme"); const shortcut = getShortcutFromShortcutName("toggleTheme");
const appProps = useAppProps();
if (!actionManager.isActionEnabled(actionToggleTheme)) { if (!actionManager.isActionEnabled(actionToggleTheme)) {
return null; return null;
@@ -254,7 +258,16 @@ export const ToggleTheme = (
<DropdownMenuItemContentRadio <DropdownMenuItemContentRadio
name="theme" name="theme"
value={props.theme} value={props.theme}
onChange={(value: Theme | "system") => props.onSelect(value)} onChange={(value: Theme | "system") => {
if (appProps.onThemeChange) {
appProps.onThemeChange(value);
return;
}
console.warn(
"MainMenu.DefaultItems.ToggleTheme: `<Excalidraw/> props.onThemeChange` must be defined to use system theme selection.",
);
}}
choices={[ choices={[
{ {
value: THEME.LIGHT, value: THEME.LIGHT,
@@ -284,13 +297,7 @@ export const ToggleTheme = (
// do not close the menu when changing theme // do not close the menu when changing theme
event.preventDefault(); event.preventDefault();
if (props?.onSelect) { actionManager.executeAction(actionToggleTheme);
props.onSelect(
appState.theme === THEME.DARK ? THEME.LIGHT : THEME.DARK,
);
} else {
return actionManager.executeAction(actionToggleTheme);
}
}} }}
icon={appState.theme === THEME.DARK ? SunIcon : MoonIcon} icon={appState.theme === THEME.DARK ? SunIcon : MoonIcon}
data-testid="toggle-dark-mode" data-testid="toggle-dark-mode"
+3 -6
View File
@@ -119,15 +119,12 @@ 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(1), ...SHAPES.slice(2),
] as const) ] as const)
: SHAPES; : SHAPES;
}; };
+56 -4
View File
@@ -311,6 +311,48 @@ 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: {
@@ -324,6 +366,20 @@ 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
@@ -347,10 +403,6 @@ 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,
+37 -2
View File
@@ -22,6 +22,41 @@ import type {
ImportedLibraryData, ImportedLibraryData,
} from "./types"; } from "./types";
const SCALAR_ROUNDED_KEYS = new Set(["x", "y", "width", "height"]);
// JSON.stringify encodes \x00 as \u0000 (6-char literal sequence) in the output
// string. We use this as a sentinel so we can strip the surrounding quotes
// afterward, emitting raw number tokens without a float round-trip.
const PRECISION_SENTINEL = "\x00";
const PRECISION_SENTINEL_RE = /"\\u0000([^"]+)\\u0000"/g;
export const stringifyWithPrecision = (
value: unknown,
precision = 2,
space?: number | string,
): string => {
const fmt = (n: number) =>
`${PRECISION_SENTINEL}${n.toFixed(precision)}${PRECISION_SENTINEL}`;
return JSON.stringify(
value,
(key, val) => {
if (SCALAR_ROUNDED_KEYS.has(key) && typeof val === "number") {
return fmt(val);
}
if (key === "points" && Array.isArray(val)) {
return (val as number[][]).map((pt) =>
Array.isArray(pt)
? pt.map((n) => (typeof n === "number" ? fmt(n) : n))
: pt,
);
}
return val;
},
space,
).replace(PRECISION_SENTINEL_RE, "$1");
};
export type JSONExportData = { export type JSONExportData = {
elements: readonly NonDeleted<ExcalidrawElement>[]; elements: readonly NonDeleted<ExcalidrawElement>[];
appState: AppState; appState: AppState;
@@ -71,7 +106,7 @@ export const serializeAsJSON = (
undefined, undefined,
}; };
return JSON.stringify(data, null, 2); return stringifyWithPrecision(data, 2, 2);
}; };
export const saveAsJSON = async ({ export const saveAsJSON = async ({
@@ -141,7 +176,7 @@ export const serializeLibraryAsJSON = (libraryItems: LibraryItems) => {
source: getExportSource(), source: getExportSource(),
libraryItems, libraryItems,
}; };
return JSON.stringify(data, null, 2); return stringifyWithPrecision(data, 2, 2);
}; };
export const saveLibraryAsJSON = async (libraryItems: LibraryItems) => { export const saveLibraryAsJSON = async (libraryItems: LibraryItems) => {
+94 -15
View File
@@ -1,4 +1,4 @@
import { isFiniteNumber, pointFrom } from "@excalidraw/math"; import { isFiniteNumber, isValidPoint, pointFrom } from "@excalidraw/math";
import { import {
type CombineBrandsIfNeeded, type CombineBrandsIfNeeded,
@@ -98,6 +98,67 @@ type RestoredAppState = Omit<
const MAX_ARROW_PX = 75_000; 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
@@ -414,10 +475,15 @@ 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: element.points, points,
simulatePressure: element.simulatePressure, simulatePressure: element.simulatePressure,
pressures: element.pressures, pressures,
}); });
} }
case "image": case "image":
@@ -435,14 +501,20 @@ 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 = // migrate old arrow model to new one let points = restoreLinearElementPoints(
!Array.isArray(element.points) || element.points.length < 2 element.points,
? [pointFrom(0, 0), pointFrom(element.width, element.height)] element.width,
: element.points; element.height,
);
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(element)); LinearElementEditor.getNormalizeElementPointsAndCoords({
...element,
points,
x: x ?? 0,
y: y ?? 0,
} as ExcalidrawLinearElement));
} }
return restoreElementWithProperties(element, { return restoreElementWithProperties(element, {
@@ -456,7 +528,7 @@ export const restoreElement = (
y, y,
...(isLineElement(element) ...(isLineElement(element)
? { ? {
polygon: isValidPolygon(element.points) polygon: isValidPolygon(points)
? element.polygon ?? false ? element.polygon ?? false
: false, : false,
} }
@@ -471,22 +543,29 @@ export const restoreElement = (
: normalizeArrowhead(element.endArrowhead); : normalizeArrowhead(element.endArrowhead);
const x = element.x as number | undefined; const x = element.x as number | undefined;
const y = element.y as number | undefined; const y = element.y as number | undefined;
const points: readonly LocalPoint[] | undefined = // migrate old arrow model to new one const points = restoreLinearElementPoints(
!Array.isArray(element.points) || element.points.length < 2 element.points,
? [pointFrom(0, 0), pointFrom(element.width, element.height)] element.width,
: element.points; element.height,
);
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(
element as ExcalidrawArrowElement, elementWithRestoredPoints,
element.startBinding, element.startBinding,
targetElementsMap, targetElementsMap,
existingElementsMap, existingElementsMap,
"start", "start",
), ),
endBinding: repairBinding( endBinding: repairBinding(
element as ExcalidrawArrowElement, elementWithRestoredPoints,
element.endBinding, element.endBinding,
targetElementsMap, targetElementsMap,
existingElementsMap, existingElementsMap,
+3 -5
View File
@@ -33,9 +33,7 @@ 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 "../animated-trail"; import { AnimatedTrail } from "../animatedTrail";
import type { AnimationFrameHandler } from "../animation-frame-handler";
import type App from "../components/App"; import type App from "../components/App";
@@ -43,8 +41,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(animationFrameHandler: AnimationFrameHandler, app: App) { constructor(app: App) {
super(animationFrameHandler, app, { super(app, {
streamline: 0.2, streamline: 0.2,
size: 5, size: 5,
keepHead: true, keepHead: true,
+34 -8
View File
@@ -6,7 +6,12 @@ import React, {
useState, useState,
} from "react"; } from "react";
import { DEFAULT_UI_OPTIONS, isShallowEqual } from "@excalidraw/common"; import {
applyDarkModeFilter,
DEFAULT_IMAGE_OPTIONS,
DEFAULT_UI_OPTIONS,
isShallowEqual,
} from "@excalidraw/common";
import App, { import App, {
ExcalidrawAPIContext, ExcalidrawAPIContext,
@@ -62,6 +67,7 @@ const ExcalidrawBase = (props: ExcalidrawProps) => {
const { const {
onExport, onExport,
onChange, onChange,
onThemeChange,
onIncrement, onIncrement,
initialData, initialData,
onExcalidrawAPI, onExcalidrawAPI,
@@ -98,6 +104,7 @@ const ExcalidrawBase = (props: ExcalidrawProps) => {
aiEnabled, aiEnabled,
showDeprecatedFonts, showDeprecatedFonts,
renderScrollbars, renderScrollbars,
imageOptions,
} = props; } = props;
const canvasActions = props.UIOptions?.canvasActions; const canvasActions = props.UIOptions?.canvasActions;
@@ -123,11 +130,18 @@ const ExcalidrawBase = (props: ExcalidrawProps) => {
if ( if (
UIOptions.canvasActions.toggleTheme === null && UIOptions.canvasActions.toggleTheme === null &&
typeof theme === "undefined" (theme == null || onThemeChange)
) { ) {
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);
@@ -172,6 +186,7 @@ const ExcalidrawBase = (props: ExcalidrawProps) => {
<App <App
onExport={onExport} onExport={onExport}
onChange={onChange} onChange={onChange}
onThemeChange={onThemeChange}
onIncrement={onIncrement} onIncrement={onIncrement}
initialData={initialData} initialData={initialData}
onExcalidrawAPI={handleExcalidrawAPI} onExcalidrawAPI={handleExcalidrawAPI}
@@ -208,6 +223,7 @@ const ExcalidrawBase = (props: ExcalidrawProps) => {
aiEnabled={aiEnabled !== false} aiEnabled={aiEnabled !== false}
showDeprecatedFonts={showDeprecatedFonts} showDeprecatedFonts={showDeprecatedFonts}
renderScrollbars={renderScrollbars} renderScrollbars={renderScrollbars}
imageOptions={normalizedImageOptions}
> >
{children} {children}
</App> </App>
@@ -225,11 +241,13 @@ 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;
@@ -273,7 +291,17 @@ const areEqual = (prevProps: ExcalidrawProps, nextProps: ExcalidrawProps) => {
return prevUIOptions[key] === nextUIOptions[key]; return prevUIOptions[key] === nextUIOptions[key];
}); });
return isUIOptionsSame && isShallowEqual(prev, next); const isImageOptionsSame =
(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);
@@ -372,11 +400,7 @@ export {
convertToExcalidrawElements, convertToExcalidrawElements,
} from "@excalidraw/element"; } from "@excalidraw/element";
export { export { elementsOverlappingBBox } from "@excalidraw/element";
elementsOverlappingBBox,
isElementInsideBBox,
elementPartiallyOverlapsWithOrContainsBBox,
} from "@excalidraw/utils/withinBounds";
export { DiagramToCodePlugin } from "./components/DiagramToCodePlugin/DiagramToCodePlugin"; export { DiagramToCodePlugin } from "./components/DiagramToCodePlugin/DiagramToCodePlugin";
export { getDataURL } from "./data/blob"; export { getDataURL } from "./data/blob";
@@ -425,3 +449,5 @@ export function useExcalidrawStateValue(
// ----------------------------------------------------------------------------- // -----------------------------------------------------------------------------
export { _useOnAppStateChange as useOnExcalidrawStateChange }; export { _useOnAppStateChange as useOnExcalidrawStateChange };
export { applyDarkModeFilter };
@@ -2,27 +2,20 @@ 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 "./animated-trail"; import { AnimatedTrail } from "./animatedTrail";
import { getClientColor } from "./clients"; import { getClientColor } from "./clients";
import type { Trail } from "./animated-trail"; import type { Trail } from "./animatedTrail";
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( constructor(private app: App) {
private animationFrameHandler: AnimationFrameHandler, this.localTrail = new AnimatedTrail(app, {
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,
}); });
@@ -63,30 +56,45 @@ 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;
} }
onFrame() { private stopCollabTrails(collaborators?: App["state"]["collaborators"]) {
this.updateCollabTrails(); for (const [key, trail] of this.collabTrails) {
const collaborator = collaborators?.get(key);
if (!collaborator) {
trail.stop();
this.collabTrails.delete(key);
}
}
} }
private updateCollabTrails() { updateCollabTrails(collaborators: App["state"]["collaborators"]) {
if (!this.container || this.app.state.collaborators.size === 0) { this.stopCollabTrails(collaborators);
if (!this.container || collaborators.size === 0) {
return; return;
} }
for (const [key, collaborator] of this.app.state.collaborators.entries()) { for (const [key, collaborator] of collaborators.entries()) {
let trail!: AnimatedTrail; // Current user has their own trail drawn via localTrail
if (collaborator.isCurrentUser) {
continue;
}
if (!this.collabTrails.has(key)) { // IDEA: Use the collaborator pointer coordinates to trace out the
trail = new AnimatedTrail(this.animationFrameHandler, this.app, { // laser pointer trail when 1) the selected collab tool is the laser
// 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 ||
@@ -95,36 +103,33 @@ 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") {
if (collaborator.button === "down" && !trail.hasCurrentTrail) { const buttonDown = collaborator.button === "down";
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);
} }
if ( // Add only original points
collaborator.button === "down" && const lastPointOriginal = !trail.hasLastPoint(
trail.hasCurrentTrail && collaborator.pointer.x,
!trail.hasLastPoint(collaborator.pointer.x, collaborator.pointer.y) collaborator.pointer.y,
) { );
if (buttonDown && lastPointOriginal) {
trail.addPointToPath(collaborator.pointer.x, collaborator.pointer.y); trail.addPointToPath(collaborator.pointer.x, collaborator.pointer.y);
} }
if (collaborator.button === "up" && trail.hasCurrentTrail) { // End the trail on button up
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);
}
}
} }
} }
+3 -5
View File
@@ -25,9 +25,7 @@ import type {
NonDeleted, NonDeleted,
} from "@excalidraw/element/types"; } from "@excalidraw/element/types";
import { type AnimationFrameHandler } from "../animation-frame-handler"; import { AnimatedTrail } from "../animatedTrail";
import { AnimatedTrail } from "../animated-trail";
import { getLassoSelectedElementIds } from "./utils"; import { getLassoSelectedElementIds } from "./utils";
@@ -47,8 +45,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(animationFrameHandler: AnimationFrameHandler, app: App) { constructor(app: App) {
super(animationFrameHandler, app, { super(app, {
animateTrail: true, animateTrail: true,
streamline: 0.4, streamline: 0.4,
sizeMapping: (c) => { sizeMapping: (c) => {
-1
View File
@@ -95,7 +95,6 @@
"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,6 +23,94 @@ 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 = "";
+58 -16
View File
@@ -6,7 +6,10 @@ export type Animation<R extends object> = (params: {
}) => R | null | undefined; }) => R | null | undefined;
export class AnimationController { export class AnimationController {
private static isRunning = false; private static scheduledFrame:
| { 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,
{ {
@@ -17,6 +20,10 @@ 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,
@@ -29,19 +36,54 @@ export class AnimationController {
state: initialState, state: initialState,
}); });
if (!AnimationController.isRunning) { AnimationController.scheduleNextFrame();
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();
@@ -56,8 +98,7 @@ export class AnimationController {
if (!state) { if (!state) {
AnimationController.animations.delete(key); AnimationController.animations.delete(key);
if (AnimationController.animations.size === 0) { if (AnimationController.cancelScheduledFrameIfIdle()) {
AnimationController.isRunning = false;
return; return;
} }
} else { } else {
@@ -66,11 +107,11 @@ export class AnimationController {
} }
} }
if (isRenderThrottlingEnabled()) { if (AnimationController.cancelScheduledFrameIfIdle()) {
requestAnimationFrame(AnimationController.tick); return;
} else {
setTimeout(AnimationController.tick, 0);
} }
AnimationController.scheduleNextFrame();
} }
} }
@@ -80,5 +121,6 @@ export class AnimationController {
static cancel(key: string) { static cancel(key: string) {
AnimationController.animations.delete(key); AnimationController.animations.delete(key);
AnimationController.cancelScheduledFrameIfIdle();
} }
} }
+4 -4
View File
@@ -62,10 +62,10 @@ export const bootstrapCanvas = ({
context.clearRect(0, 0, normalizedWidth, normalizedHeight); context.clearRect(0, 0, normalizedWidth, normalizedHeight);
} }
context.save(); context.save();
context.fillStyle = context.fillStyle = applyDarkModeFilter(
theme === THEME.DARK viewBackgroundColor,
? applyDarkModeFilter(viewBackgroundColor) theme === THEME.DARK,
: viewBackgroundColor; );
context.fillRect(0, 0, normalizedWidth, normalizedHeight); context.fillRect(0, 0, normalizedWidth, normalizedHeight);
context.restore(); context.restore();
} else { } else {
+20 -9
View File
@@ -291,6 +291,14 @@ const renderElementToSvg = (
); );
offsetX = offsetX || 0; offsetX = offsetX || 0;
offsetY = offsetY || 0; offsetY = offsetY || 0;
// Pin the mask to user space; the default maskUnits="objectBoundingBox"
// collapses to zero area for axis-aligned arrows (zero-size bbox),
// hiding the whole line from SVG exports (#11439).
maskPath.setAttribute("maskUnits", "userSpaceOnUse");
maskPath.setAttribute("x", "0");
maskPath.setAttribute("y", "0");
maskPath.setAttribute("width", `${element.width + 100 + offsetX}`);
maskPath.setAttribute("height", `${element.height + 100 + offsetY}`);
maskRectVisible.setAttribute("x", "0"); maskRectVisible.setAttribute("x", "0");
maskRectVisible.setAttribute("y", "0"); maskRectVisible.setAttribute("y", "0");
maskRectVisible.setAttribute("fill", "#fff"); maskRectVisible.setAttribute("fill", "#fff");
@@ -386,9 +394,10 @@ const renderElementToSvg = (
const path = svgRoot.ownerDocument.createElementNS(SVG_NS, "path"); const path = svgRoot.ownerDocument.createElementNS(SVG_NS, "path");
path.setAttribute( path.setAttribute(
"fill", "fill",
renderConfig.theme === THEME.DARK applyDarkModeFilter(
? applyDarkModeFilter(element.strokeColor) element.strokeColor,
: element.strokeColor, renderConfig.theme === THEME.DARK,
),
); );
path.setAttribute("d", shape); path.setAttribute("d", shape);
wrapper.appendChild(path); wrapper.appendChild(path);
@@ -621,9 +630,10 @@ const renderElementToSvg = (
rect.setAttribute("fill", "none"); rect.setAttribute("fill", "none");
rect.setAttribute( rect.setAttribute(
"stroke", "stroke",
renderConfig.theme === THEME.DARK applyDarkModeFilter(
? applyDarkModeFilter(FRAME_STYLE.strokeColor) FRAME_STYLE.strokeColor,
: FRAME_STYLE.strokeColor, renderConfig.theme === THEME.DARK,
),
); );
rect.setAttribute("stroke-width", FRAME_STYLE.strokeWidth.toString()); rect.setAttribute("stroke-width", FRAME_STYLE.strokeWidth.toString());
@@ -677,9 +687,10 @@ const renderElementToSvg = (
text.setAttribute("font-size", `${element.fontSize}px`); text.setAttribute("font-size", `${element.fontSize}px`);
text.setAttribute( text.setAttribute(
"fill", "fill",
renderConfig.theme === THEME.DARK applyDarkModeFilter(
? applyDarkModeFilter(element.strokeColor) element.strokeColor,
: element.strokeColor, renderConfig.theme === THEME.DARK,
),
); );
text.setAttribute("text-anchor", textAnchor); text.setAttribute("text-anchor", textAnchor);
text.setAttribute("style", "white-space: pre;"); text.setAttribute("style", "white-space: pre;");
+210 -103
View File
@@ -1,9 +1,15 @@
import { isElementInViewport } from "@excalidraw/element"; import {
getCommonFrameId,
getFrameChildrenInsertionIndex,
isElementInViewport,
} from "@excalidraw/element";
import { memoize, toBrandedType } from "@excalidraw/common"; import { arrayToMap, 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";
@@ -16,6 +22,21 @@ 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;
@@ -23,9 +44,121 @@ export class Renderer {
this.scene = scene; this.scene = scene;
} }
public getRenderableElements = (() => { private getVisibleCanvasElements({
const getVisibleCanvasElements = ({ elementsMap,
elementsMap, zoom,
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,
@@ -33,70 +166,27 @@ 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,
newElementId, newElement,
}: { }: Omit<
elements: readonly NonDeletedExcalidrawElement[]; GetRenderableElementsOpts,
editingTextElement: AppState["editingTextElement"]; | "selectedElements"
newElementId: ExcalidrawElement["id"] | undefined; | "selectedElementsAreBeingDragged"
| "frameToHighlight"
> & {
canvasNonce: string;
}) => { }) => {
const elementsMap = toBrandedType<RenderableElementsMap>(new Map()); const elements = this.scene.getNonDeletedElements();
for (const element of elements) { const { elementsMap, newElementCanvasElement } =
if (newElementId === element.id) { this.getRenderableElementsMap({
continue; elements,
} editingTextElement,
newElement,
});
// we don't want to render text element that's being currently edited const visibleElements = this.getVisibleCanvasElements({
// (it's rendered on remote only) elementsMap,
if (
!editingTextElement ||
editingTextElement.type !== "text" ||
element.id !== editingTextElement.id
) {
elementsMap.set(element.id, element);
}
}
return elementsMap;
};
return memoize(
({
zoom, zoom,
offsetLeft, offsetLeft,
offsetTop, offsetTop,
@@ -104,52 +194,69 @@ 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();
const elementsMap = getRenderableElements({ return {
elements, elementsMap,
editingTextElement, visibleElements,
newElementId, newElementCanvasElement,
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,
}); });
const visibleElements = getVisibleCanvasElements({ return {
elementsMap, ...ret,
zoom, visibleElements: reorderedVisibleElements,
offsetLeft, };
offsetTop, }
scrollX,
scrollY,
height,
width,
});
return { elementsMap, visibleElements }; return ret;
}, };
);
})();
// 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();
} }
} }
+1 -3
View File
@@ -459,9 +459,7 @@ export const exportToSvg = async (
rect.setAttribute("height", `${height}`); rect.setAttribute("height", `${height}`);
rect.setAttribute( rect.setAttribute(
"fill", "fill",
exportWithDarkMode applyDarkModeFilter(viewBackgroundColor, exportWithDarkMode),
? applyDarkModeFilter(viewBackgroundColor)
: viewBackgroundColor,
); );
svgRoot.appendChild(rect); svgRoot.appendChild(rect);
} }
@@ -83,6 +83,26 @@ 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(
@@ -99,7 +119,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(dialog.outerHTML).toMatchSnapshot(); expect(normalizeDialogSnapshot(dialog)).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" 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 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 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
@@ -1464,7 +1464,7 @@ exports[`contextMenu element > selecting 'Bring forward' in context menu brings
"versionNonce": 493213705, "versionNonce": 493213705,
"width": 20, "width": 20,
"x": -10, "x": -10,
"y": 0, "y": "0.00000",
} }
`; `;
@@ -1516,7 +1516,7 @@ exports[`contextMenu element > selecting 'Bring forward' in context menu brings
"version": 3, "version": 3,
"width": 20, "width": 20,
"x": -10, "x": -10,
"y": 0, "y": "0.00000",
}, },
"inserted": { "inserted": {
"isDeleted": true, "isDeleted": true,
@@ -1795,7 +1795,7 @@ exports[`contextMenu element > selecting 'Bring to front' in context menu brings
"versionNonce": 493213705, "versionNonce": 493213705,
"width": 20, "width": 20,
"x": -10, "x": -10,
"y": 0, "y": "0.00000",
} }
`; `;
@@ -1847,7 +1847,7 @@ exports[`contextMenu element > selecting 'Bring to front' in context menu brings
"version": 3, "version": 3,
"width": 20, "width": 20,
"x": -10, "x": -10,
"y": 0, "y": "0.00000",
}, },
"inserted": { "inserted": {
"isDeleted": true, "isDeleted": true,
@@ -2490,7 +2490,7 @@ exports[`contextMenu element > selecting 'Duplicate' in context menu duplicates
"resizingElement": null, "resizingElement": null,
"scrollX": 0, "scrollX": 0,
"scrollY": 0, "scrollY": 0,
"scrolledOutside": false, "scrolledOutside": true,
"searchMatches": null, "searchMatches": null,
"selectedElementIds": { "selectedElementIds": {
"id3": true, "id3": true,
@@ -2854,7 +2854,7 @@ exports[`contextMenu element > selecting 'Group selection' in context menu group
"versionNonce": 915032327, "versionNonce": 915032327,
"width": 20, "width": 20,
"x": -10, "x": -10,
"y": 0, "y": "0.00000",
} }
`; `;
@@ -2940,7 +2940,7 @@ exports[`contextMenu element > selecting 'Group selection' in context menu group
"version": 3, "version": 3,
"width": 20, "width": 20,
"x": -10, "x": -10,
"y": 0, "y": "0.00000",
}, },
"inserted": { "inserted": {
"isDeleted": true, "isDeleted": true,
@@ -3221,7 +3221,7 @@ exports[`contextMenu element > selecting 'Paste styles' in context menu pastes s
"versionNonce": 1402203177, "versionNonce": 1402203177,
"width": 20, "width": 20,
"x": -10, "x": -10,
"y": 0, "y": "0.00000",
} }
`; `;
@@ -3305,7 +3305,7 @@ exports[`contextMenu element > selecting 'Paste styles' in context menu pastes s
"version": 3, "version": 3,
"width": 20, "width": 20,
"x": -10, "x": -10,
"y": 0, "y": "0.00000",
}, },
"inserted": { "inserted": {
"isDeleted": true, "isDeleted": true,
@@ -3744,7 +3744,7 @@ exports[`contextMenu element > selecting 'Send backward' in context menu sends e
"versionNonce": 2019559783, "versionNonce": 2019559783,
"width": 20, "width": 20,
"x": -10, "x": -10,
"y": 0, "y": "0.00000",
} }
`; `;
@@ -3796,7 +3796,7 @@ exports[`contextMenu element > selecting 'Send backward' in context menu sends e
"version": 3, "version": 3,
"width": 20, "width": 20,
"x": -10, "x": -10,
"y": 0, "y": "0.00000",
}, },
"inserted": { "inserted": {
"isDeleted": true, "isDeleted": true,
@@ -4067,7 +4067,7 @@ exports[`contextMenu element > selecting 'Send to back' in context menu sends el
"versionNonce": 2019559783, "versionNonce": 2019559783,
"width": 20, "width": 20,
"x": -10, "x": -10,
"y": 0, "y": "0.00000",
} }
`; `;
@@ -4119,7 +4119,7 @@ exports[`contextMenu element > selecting 'Send to back' in context menu sends el
"version": 3, "version": 3,
"width": 20, "width": 20,
"x": -10, "x": -10,
"y": 0, "y": "0.00000",
}, },
"inserted": { "inserted": {
"isDeleted": true, "isDeleted": true,
@@ -4361,7 +4361,7 @@ exports[`contextMenu element > selecting 'Ungroup selection' in context menu ung
"versionNonce": 1006504105, "versionNonce": 1006504105,
"width": 20, "width": 20,
"x": -10, "x": -10,
"y": 0, "y": "0.00000",
} }
`; `;
@@ -4445,7 +4445,7 @@ exports[`contextMenu element > selecting 'Ungroup selection' in context menu ung
"version": 3, "version": 3,
"width": 20, "width": 20,
"x": -10, "x": -10,
"y": 0, "y": "0.00000",
}, },
"inserted": { "inserted": {
"isDeleted": true, "isDeleted": true,
@@ -5646,7 +5646,7 @@ exports[`contextMenu element > shows 'Group selection' in context menu for multi
"versionNonce": 1150084233, "versionNonce": 1150084233,
"width": 10, "width": 10,
"x": -10, "x": -10,
"y": 0, "y": "0.00000",
} }
`; `;
@@ -5678,7 +5678,7 @@ exports[`contextMenu element > shows 'Group selection' in context menu for multi
"versionNonce": 23633383, "versionNonce": 23633383,
"width": 10, "width": 10,
"x": 12, "x": 12,
"y": 0, "y": "0.00000",
} }
`; `;
@@ -5730,7 +5730,7 @@ exports[`contextMenu element > shows 'Group selection' in context menu for multi
"version": 3, "version": 3,
"width": 10, "width": 10,
"x": -10, "x": -10,
"y": 0, "y": "0.00000",
}, },
"inserted": { "inserted": {
"isDeleted": true, "isDeleted": true,
@@ -5784,7 +5784,7 @@ exports[`contextMenu element > shows 'Group selection' in context menu for multi
"version": 3, "version": 3,
"width": 10, "width": 10,
"x": 12, "x": 12,
"y": 0, "y": "0.00000",
}, },
"inserted": { "inserted": {
"isDeleted": true, "isDeleted": true,
@@ -6867,7 +6867,7 @@ exports[`contextMenu element > shows 'Ungroup selection' in context menu for gro
"versionNonce": 1723083209, "versionNonce": 1723083209,
"width": 10, "width": 10,
"x": -10, "x": -10,
"y": 0, "y": "0.00000",
} }
`; `;
@@ -6901,7 +6901,7 @@ exports[`contextMenu element > shows 'Ungroup selection' in context menu for gro
"versionNonce": 760410951, "versionNonce": 760410951,
"width": 10, "width": 10,
"x": 12, "x": 12,
"y": 0, "y": "0.00000",
} }
`; `;
@@ -6953,7 +6953,7 @@ exports[`contextMenu element > shows 'Ungroup selection' in context menu for gro
"version": 3, "version": 3,
"width": 10, "width": 10,
"x": -10, "x": -10,
"y": 0, "y": "0.00000",
}, },
"inserted": { "inserted": {
"isDeleted": true, "isDeleted": true,
@@ -7007,7 +7007,7 @@ exports[`contextMenu element > shows 'Ungroup selection' in context menu for gro
"version": 3, "version": 3,
"width": 10, "width": 10,
"x": 12, "x": 12,
"y": 0, "y": "0.00000",
}, },
"inserted": { "inserted": {
"isDeleted": true, "isDeleted": true,
@@ -1,7 +1,7 @@
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html // Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
exports[`export > export svg-embedded scene > svg-embdedded scene export output 1`] = ` exports[`export > export svg-embedded scene > svg-embdedded scene export output 1`] = `
"<svg version="1.1" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 36 36" width="36" height="36"><!-- svg-source:excalidraw --><metadata><!-- payload-type:application/vnd.excalidraw+json --><!-- payload-version:2 --><!-- payload-start -->eyJ2ZXJzaW9uIjoiMSIsImVuY29kaW5nIjoiYnN0cmluZyIsImNvbXByZXNzZWQiOnRydWUsImVuY29kZWQiOiJ4nHVTS27bMFx1MDAxMN33XHUwMDE0grItXHUwMDEy2UW68C7NXHUwMDA3zVwiXdRcdTAwMDW6KLpgxLE0ME1cdTAwMTLkKLZrXHUwMDE4yDG661x1MDAxNXOEXGZpVTTlRFx1MDAwMlxi8M3vzZvh7kNRlLS1UM6KXHUwMDEyNrVQKJ1Yl1x1MDAxZlx1MDAwM/5cdTAwMDTOo9Fsmsa7N52ro2dLZGdcdTAwMTdcdTAwMTfKcEBrPM0+VVV1XGJcdTAwMDJcdTAwMDUr0OTZ7Vx1MDAxN9+LYlx1MDAxN0+2oFxmoVfRLVx1MDAwMv/rXHUwMDEybCihXHUwMDFihqrhts1ua5TUMjL5PEAtYNNSjlx03SjIXHUwMDAyPTmzhGujjFx1MDAwYlx1MDAxNc8mXHUwMDEw/lT0UdTLxplOy8GHnNDeXG7HzSS/XHUwMDA1KjWnbczOerBa5ajGz57idIS/XHUwMDE3xUWbVoNcdTAwMGaCTVx1MDAwNtRYUSOF5idV6lwiMLT3Mmr7O3FyYlx1MDAwNfdBXFzdKTXAqCVsxmBssa+WXHUwMDE5PIDMXHUwMDE4pOGfYN+MrnN50d/w3CmmWFxi5SFcdFx1MDAxYlxu3qadyIp2VlxuXHUwMDFh1VWol2M/3rPlXHUwMDFiuePesKIv//4+XHUwMDFmjchomuOfQHBaZeidWKFcbppeZimuXHUwMDE0NqHPUsHiaNTcLCHv92AmY5O15nxcdTAwMDI1uFPhjcNcdTAwMDa1UD/epCc6Mt/BXHUwMDFmXGKS6+C4c/g6bPP59DJcdTAwMWH2fMZZl8LaObFebD28Kd5cdTAwMDeUo1ZcdTAwMGZcdTAwMTiBTW1G6MFIuNXiUY11LJ9cdTAwMTDWX07X/2xcdTAwMTG/nng/godOXHUwMDExznnUNfFcdTAwMWWEee5cdTAwMDK/feTHb1x1MDAwM3po/1xua2IoWiJ9<!-- payload-end --></metadata><defs><style class="style-fonts"> "<svg version="1.1" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 36 36" width="36" height="36"><!-- svg-source:excalidraw --><metadata><!-- payload-type:application/vnd.excalidraw+json --><!-- payload-version:2 --><!-- payload-start -->eyJ2ZXJzaW9uIjoiMSIsImVuY29kaW5nIjoiYnN0cmluZyIsImNvbXByZXNzZWQiOnRydWUsImVuY29kZWQiOiJ4nHVTTW/bMFxmve9XXHUwMDE47rVonVx1MDAxNN0ht36iPbSHZsBcdTAwMGXDXHUwMDBlqsXYRFx1MDAxNEmQ6CZZXHUwMDEwYD9jt/3F/YRRimvZTmtcdTAwMDNcdTAwMDb8SJGPj0+7L1mW09ZCPsty2JRCoXRinZ9cdTAwMDb8XHKcR6M5NI3/3jSujJk1kZ2dnyvDXHUwMDA3auNpdlFcdTAwMTTF4Vx1MDAxMChYgSbPaT/4P8t28ctcdTAwMTGU4ehVTIvAe1+CXHIldMNQcVZcdTAwMTRcdTAwMWSwXHUwMDFkXHUwMDAza5RUMzj52kdrwKqmI1joSoUuXHTx5MxcdTAwMTJujDIudD+ZQHhcdTAwMTOBV1EuK2dcdTAwMWEtu1x1MDAxY3JCeytcdTAwMWNcdTAwMGaW8lx1MDAxNqjUnLaxOmvDyuWjXHUwMDFl31ui01x1MDAxMf7ZKW5a1Vx1MDAxYXxcdTAwMTBv0qHGilx1MDAxMimoMOnNXHUwMDE1XHUwMDE42kdcdTAwMTl1/pk4ObGCxyC0bpTqYNRcdTAwMTI2YzCO2HZcdTAwMWJcdTAwMDQ8gFx1MDAxYzBIRjjCno0uh/Kiv2VcdTAwMGZQLLFcdTAwMTDKQ1x1MDAxMjY0vEv+XHUwMDE4NG2sXHUwMDE0NOqrUC/Heey55Vx1MDAwN7Wjh1jRf3///O6tyGia469AcFpcZtB7sUJcdTAwMTU0vVx1MDAxY5S4UliFOXNcdTAwMDWL3qp5WEL2elx1MDAxNyZjU7Tkelx1MDAwMjW4Y+GNw1xutVDfPqQnXHUwMDFhMi/gXHUwMDBmXHUwMDA0yTXQn1x1MDAxY1x1MDAxZTpDn00vY2DP37jrXFxYOyfWi6OH+8V+QDlcdTAwMWH1gFx1MDAxMdg0ZoSejIQ7LV7VWMf8XHJhfX1s/5NFfFri7Vxunlx1MDAxYUU451WXxD5cYvvcXHUwMDA1fvvIj+9cdTAwMDa00P4/o1sqkiJ9<!-- payload-end --></metadata><defs><style class="style-fonts">
</style></defs><rect x="0" y="0" width="36" height="36" fill="#ffffff"></rect><g transform="translate(10 10) rotate(0 8 8)" data-id="A"><text x="0" y="17.619999999999997" font-family="Excalifont, Xiaolai, sans-serif, Segoe UI Emoji" font-size="20px" fill="#1e1e1e" text-anchor="start" style="white-space: pre;" direction="ltr" dominant-baseline="alphabetic">😀</text></g></svg>" </style></defs><rect x="0" y="0" width="36" height="36" fill="#ffffff"></rect><g transform="translate(10 10) rotate(0 8 8)" data-id="A"><text x="0" y="17.619999999999997" font-family="Excalifont, Xiaolai, sans-serif, Segoe UI Emoji" font-size="20px" fill="#1e1e1e" text-anchor="start" style="white-space: pre;" direction="ltr" dominant-baseline="alphabetic">😀</text></g></svg>"
`; `;
@@ -227,8 +227,8 @@ exports[`history > multiplayer undo/redo > conflicts in arrows and their bindabl
"updated": 1, "updated": 1,
"version": 22, "version": 22,
"width": "94.00000", "width": "94.00000",
"x": 0, "x": "0.00000",
"y": 0, "y": "0.00000",
} }
`; `;
@@ -442,8 +442,8 @@ exports[`history > multiplayer undo/redo > conflicts in arrows and their bindabl
"startBinding": null, "startBinding": null,
"version": 22, "version": 22,
"width": "94.00000", "width": "94.00000",
"x": 0, "x": "0.00000",
"y": 0, "y": "0.00000",
}, },
"inserted": { "inserted": {
"height": "105.58874", "height": "105.58874",
@@ -618,8 +618,8 @@ exports[`history > multiplayer undo/redo > conflicts in arrows and their bindabl
"type": "arrow", "type": "arrow",
"version": 4, "version": 4,
"width": 100, "width": 100,
"x": 0, "x": "0.00000",
"y": 0, "y": "0.00000",
}, },
"inserted": { "inserted": {
"isDeleted": true, "isDeleted": true,
@@ -855,7 +855,7 @@ exports[`history > multiplayer undo/redo > conflicts in arrows and their bindabl
"version": 25, "version": 25,
"width": 100, "width": 100,
"x": 150, "x": 150,
"y": 0, "y": "0.00000",
} }
`; `;
@@ -1005,7 +1005,7 @@ exports[`history > multiplayer undo/redo > conflicts in arrows and their bindabl
"version": 25, "version": 25,
"width": 100, "width": 100,
"x": 150, "x": 150,
"y": 0, "y": "0.00000",
}, },
"inserted": { "inserted": {
"height": "0.01000", "height": "0.01000",
@@ -1180,8 +1180,8 @@ exports[`history > multiplayer undo/redo > conflicts in arrows and their bindabl
"type": "arrow", "type": "arrow",
"version": 4, "version": 4,
"width": 100, "width": 100,
"x": 0, "x": "0.00000",
"y": 0, "y": "0.00000",
}, },
"inserted": { "inserted": {
"isDeleted": true, "isDeleted": true,
@@ -2277,40 +2277,7 @@ exports[`history > multiplayer undo/redo > conflicts in arrows and their bindabl
"showHyperlinkPopup": false, "showHyperlinkPopup": false,
"showWelcomeScreen": true, "showWelcomeScreen": true,
"snapLines": [], "snapLines": [],
"startBoundElement": { "startBoundElement": null,
"angle": 0,
"backgroundColor": "transparent",
"boundElements": [
{
"id": "id4",
"type": "arrow",
},
],
"customData": undefined,
"fillStyle": "solid",
"frameId": null,
"groupIds": [],
"height": 100,
"id": "id0",
"index": "a0",
"isDeleted": false,
"link": null,
"locked": false,
"opacity": 100,
"roughness": 1,
"roundness": null,
"seed": 1,
"strokeColor": "#1e1e1e",
"strokeStyle": "solid",
"strokeWidth": 2,
"type": "rectangle",
"updated": 1,
"version": 3,
"versionNonce": 493213705,
"width": 100,
"x": -100,
"y": -50,
},
"stats": { "stats": {
"open": false, "open": false,
"panels": 3, "panels": 3,
@@ -6321,7 +6288,7 @@ exports[`history > multiplayer undo/redo > should iterate through the history wh
"version": 3, "version": 3,
"width": 10, "width": 10,
"x": 10, "x": 10,
"y": 0, "y": "0.00000",
} }
`; `;
@@ -6351,7 +6318,7 @@ exports[`history > multiplayer undo/redo > should iterate through the history wh
"version": 9, "version": 9,
"width": 10, "width": 10,
"x": 20, "x": 20,
"y": 0, "y": "0.00000",
} }
`; `;
@@ -6433,7 +6400,7 @@ exports[`history > multiplayer undo/redo > should iterate through the history wh
"version": 3, "version": 3,
"width": 10, "width": 10,
"x": 10, "x": 10,
"y": 0, "y": "0.00000",
}, },
"inserted": { "inserted": {
"isDeleted": true, "isDeleted": true,
@@ -6488,7 +6455,7 @@ exports[`history > multiplayer undo/redo > should iterate through the history wh
"version": 8, "version": 8,
"width": 10, "width": 10,
"x": 20, "x": 20,
"y": 0, "y": "0.00000",
}, },
"inserted": { "inserted": {
"isDeleted": true, "isDeleted": true,
@@ -7449,8 +7416,8 @@ exports[`history > multiplayer undo/redo > should iterate through the history wh
"updated": 1, "updated": 1,
"version": 7, "version": 7,
"width": 10, "width": 10,
"x": 0, "x": "0.00000",
"y": 0, "y": "0.00000",
} }
`; `;
@@ -7524,8 +7491,8 @@ exports[`history > multiplayer undo/redo > should iterate through the history wh
"type": "arrow", "type": "arrow",
"version": 7, "version": 7,
"width": 10, "width": 10,
"x": 0, "x": "0.00000",
"y": 0, "y": "0.00000",
}, },
"inserted": { "inserted": {
"isDeleted": true, "isDeleted": true,
@@ -7725,7 +7692,7 @@ exports[`history > multiplayer undo/redo > should iterate through the history wh
"version": 9, "version": 9,
"width": 10, "width": 10,
"x": 10, "x": 10,
"y": 0, "y": "0.00000",
} }
`; `;
@@ -7778,7 +7745,7 @@ exports[`history > multiplayer undo/redo > should iterate through the history wh
"version": 8, "version": 8,
"width": 10, "width": 10,
"x": 10, "x": 10,
"y": 0, "y": "0.00000",
}, },
"inserted": { "inserted": {
"isDeleted": true, "isDeleted": true,
@@ -9610,7 +9577,7 @@ exports[`history > multiplayer undo/redo > should not override remote changes on
"version": 7, "version": 7,
"width": 10, "width": 10,
"x": 10, "x": 10,
"y": 0, "y": "0.00000",
} }
`; `;
@@ -9720,7 +9687,7 @@ exports[`history > multiplayer undo/redo > should not override remote changes on
"version": 3, "version": 3,
"width": 10, "width": 10,
"x": 10, "x": 10,
"y": 0, "y": "0.00000",
}, },
"inserted": { "inserted": {
"isDeleted": true, "isDeleted": true,
@@ -9875,7 +9842,7 @@ exports[`history > multiplayer undo/redo > should not override remote changes on
"version": 7, "version": 7,
"width": 10, "width": 10,
"x": 10, "x": 10,
"y": 0, "y": "0.00000",
} }
`; `;
@@ -9927,7 +9894,7 @@ exports[`history > multiplayer undo/redo > should not override remote changes on
"version": 3, "version": 3,
"width": 10, "width": 10,
"x": 10, "x": 10,
"y": 0, "y": "0.00000",
}, },
"inserted": { "inserted": {
"isDeleted": true, "isDeleted": true,
@@ -10433,8 +10400,8 @@ exports[`history > multiplayer undo/redo > should override remotely added points
"updated": 1, "updated": 1,
"version": 10, "version": 10,
"width": 30, "width": 30,
"x": 0, "x": "0.00000",
"y": 0, "y": "0.00000",
} }
`; `;
@@ -10507,8 +10474,8 @@ exports[`history > multiplayer undo/redo > should override remotely added points
"type": "arrow", "type": "arrow",
"version": 9, "version": 9,
"width": 10, "width": 10,
"x": 0, "x": "0.00000",
"y": 0, "y": "0.00000",
}, },
"inserted": { "inserted": {
"isDeleted": true, "isDeleted": true,
@@ -10720,7 +10687,7 @@ exports[`history > multiplayer undo/redo > should redistribute deltas when eleme
"version": 9, "version": 9,
"width": 10, "width": 10,
"x": 10, "x": 10,
"y": 0, "y": "0.00000",
} }
`; `;
@@ -10772,7 +10739,7 @@ exports[`history > multiplayer undo/redo > should redistribute deltas when eleme
"version": 8, "version": 8,
"width": 10, "width": 10,
"x": 10, "x": 10,
"y": 0, "y": "0.00000",
}, },
"inserted": { "inserted": {
"isDeleted": true, "isDeleted": true,
@@ -11883,7 +11850,7 @@ exports[`history > multiplayer undo/redo > should update history entries after r
"version": 13, "version": 13,
"width": 10, "width": 10,
"x": 10, "x": 10,
"y": 0, "y": "0.00000",
} }
`; `;
@@ -11988,7 +11955,7 @@ exports[`history > multiplayer undo/redo > should update history entries after r
"version": 3, "version": 3,
"width": 10, "width": 10,
"x": 10, "x": 10,
"y": 0, "y": "0.00000",
}, },
"inserted": { "inserted": {
"isDeleted": true, "isDeleted": true,
@@ -12170,8 +12137,8 @@ exports[`history > singleplayer undo/redo > remounting undo/redo buttons should
"updated": 1, "updated": 1,
"version": 4, "version": 4,
"width": 10, "width": 10,
"x": 0, "x": "0.00000",
"y": 0, "y": "0.00000",
} }
`; `;
@@ -12223,8 +12190,8 @@ exports[`history > singleplayer undo/redo > remounting undo/redo buttons should
"type": "rectangle", "type": "rectangle",
"version": 3, "version": 3,
"width": 10, "width": 10,
"x": 0, "x": "0.00000",
"y": 0, "y": "0.00000",
}, },
}, },
}, },
@@ -12378,7 +12345,7 @@ exports[`history > singleplayer undo/redo > should clear the redo stack on eleme
"version": 4, "version": 4,
"width": 10, "width": 10,
"x": 10, "x": 10,
"y": 0, "y": "0.00000",
} }
`; `;
@@ -12408,7 +12375,7 @@ exports[`history > singleplayer undo/redo > should clear the redo stack on eleme
"version": 3, "version": 3,
"width": 10, "width": 10,
"x": 20, "x": 20,
"y": 0, "y": "0.00000",
} }
`; `;
@@ -12460,7 +12427,7 @@ exports[`history > singleplayer undo/redo > should clear the redo stack on eleme
"version": 3, "version": 3,
"width": 10, "width": 10,
"x": 20, "x": 20,
"y": 0, "y": "0.00000",
}, },
"inserted": { "inserted": {
"isDeleted": true, "isDeleted": true,
@@ -13008,8 +12975,8 @@ exports[`history > singleplayer undo/redo > should create new history entry on e
"updated": 1, "updated": 1,
"version": 4, "version": 4,
"width": 560, "width": 560,
"x": 0, "x": "0.00000",
"y": 0, "y": "0.00000",
} }
`; `;
@@ -13060,8 +13027,8 @@ exports[`history > singleplayer undo/redo > should create new history entry on e
"type": "embeddable", "type": "embeddable",
"version": 4, "version": 4,
"width": 560, "width": 560,
"x": 0, "x": "0.00000",
"y": 0, "y": "0.00000",
}, },
"inserted": { "inserted": {
"isDeleted": true, "isDeleted": true,
@@ -13215,8 +13182,8 @@ exports[`history > singleplayer undo/redo > should create new history entry on e
"updated": 1, "updated": 1,
"version": 4, "version": 4,
"width": 560, "width": 560,
"x": 0, "x": "0.00000",
"y": 0, "y": "0.00000",
} }
`; `;
@@ -13267,8 +13234,8 @@ exports[`history > singleplayer undo/redo > should create new history entry on e
"type": "embeddable", "type": "embeddable",
"version": 4, "version": 4,
"width": 560, "width": 560,
"x": 0, "x": "0.00000",
"y": 0, "y": "0.00000",
}, },
"inserted": { "inserted": {
"isDeleted": true, "isDeleted": true,
@@ -14296,8 +14263,8 @@ exports[`history > singleplayer undo/redo > should disable undo/redo buttons whe
"updated": 1, "updated": 1,
"version": 5, "version": 5,
"width": 10, "width": 10,
"x": 0, "x": "0.00000",
"y": 0, "y": "0.00000",
} }
`; `;
@@ -14348,8 +14315,8 @@ exports[`history > singleplayer undo/redo > should disable undo/redo buttons whe
"type": "rectangle", "type": "rectangle",
"version": 5, "version": 5,
"width": 10, "width": 10,
"x": 0, "x": "0.00000",
"y": 0, "y": "0.00000",
}, },
"inserted": { "inserted": {
"isDeleted": true, "isDeleted": true,
@@ -14533,8 +14500,8 @@ exports[`history > singleplayer undo/redo > should end up with no history entry
"updated": 1, "updated": 1,
"version": 5, "version": 5,
"width": 10, "width": 10,
"x": 0, "x": "0.00000",
"y": 0, "y": "0.00000",
} }
`; `;
@@ -14585,8 +14552,8 @@ exports[`history > singleplayer undo/redo > should end up with no history entry
"type": "rectangle", "type": "rectangle",
"version": 5, "version": 5,
"width": 10, "width": 10,
"x": 0, "x": "0.00000",
"y": 0, "y": "0.00000",
}, },
"inserted": { "inserted": {
"isDeleted": true, "isDeleted": true,
@@ -14741,7 +14708,7 @@ exports[`history > singleplayer undo/redo > should iterate through the history w
"version": 4, "version": 4,
"width": 10, "width": 10,
"x": 10, "x": 10,
"y": 0, "y": "0.00000",
} }
`; `;
@@ -14834,7 +14801,7 @@ exports[`history > singleplayer undo/redo > should iterate through the history w
"version": 3, "version": 3,
"width": 10, "width": 10,
"x": 10, "x": 10,
"y": 0, "y": "0.00000",
}, },
}, },
}, },
@@ -14988,7 +14955,7 @@ exports[`history > singleplayer undo/redo > should not clear the redo stack on s
"version": 3, "version": 3,
"width": 10, "width": 10,
"x": 10, "x": 10,
"y": 0, "y": "0.00000",
} }
`; `;
@@ -15018,7 +14985,7 @@ exports[`history > singleplayer undo/redo > should not clear the redo stack on s
"version": 5, "version": 5,
"width": 10, "width": 10,
"x": 20, "x": 20,
"y": 0, "y": "0.00000",
} }
`; `;
@@ -15070,7 +15037,7 @@ exports[`history > singleplayer undo/redo > should not clear the redo stack on s
"version": 3, "version": 3,
"width": 10, "width": 10,
"x": 10, "x": 10,
"y": 0, "y": "0.00000",
}, },
"inserted": { "inserted": {
"isDeleted": true, "isDeleted": true,
@@ -15164,7 +15131,7 @@ exports[`history > singleplayer undo/redo > should not clear the redo stack on s
"version": 5, "version": 5,
"width": 10, "width": 10,
"x": 20, "x": 20,
"y": 0, "y": "0.00000",
}, },
"inserted": { "inserted": {
"isDeleted": true, "isDeleted": true,
@@ -16191,7 +16158,7 @@ exports[`history > singleplayer undo/redo > should not override appstate changes
"version": 11, "version": 11,
"width": 10, "width": 10,
"x": 10, "x": 10,
"y": 0, "y": "0.00000",
} }
`; `;
@@ -16316,7 +16283,7 @@ exports[`history > singleplayer undo/redo > should not override appstate changes
"version": 3, "version": 3,
"width": 10, "width": 10,
"x": 10, "x": 10,
"y": 0, "y": "0.00000",
}, },
"inserted": { "inserted": {
"isDeleted": true, "isDeleted": true,
@@ -20228,7 +20195,7 @@ exports[`history > singleplayer undo/redo > should support changes in elements'
"version": 7, "version": 7,
"width": 10, "width": 10,
"x": 10, "x": 10,
"y": 0, "y": "0.00000",
} }
`; `;
@@ -20310,7 +20277,7 @@ exports[`history > singleplayer undo/redo > should support changes in elements'
"version": 3, "version": 3,
"width": 10, "width": 10,
"x": 10, "x": 10,
"y": 0, "y": "0.00000",
}, },
"inserted": { "inserted": {
"isDeleted": true, "isDeleted": true,
@@ -21186,7 +21153,7 @@ exports[`history > singleplayer undo/redo > should support element creation, del
"version": 5, "version": 5,
"width": 10, "width": 10,
"x": 10, "x": 10,
"y": 0, "y": "0.00000",
} }
`; `;
@@ -21298,7 +21265,7 @@ exports[`history > singleplayer undo/redo > should support element creation, del
"version": 5, "version": 5,
"width": 10, "width": 10,
"x": 10, "x": 10,
"y": 0, "y": "0.00000",
}, },
"inserted": { "inserted": {
"isDeleted": true, "isDeleted": true,
@@ -21668,8 +21635,8 @@ exports[`history > singleplayer undo/redo > should support linear element creati
"updated": 1, "updated": 1,
"version": 12, "version": 12,
"width": 20, "width": 20,
"x": 0, "x": "0.00000",
"y": 0, "y": "0.00000",
} }
`; `;
@@ -21742,8 +21709,8 @@ exports[`history > singleplayer undo/redo > should support linear element creati
"type": "arrow", "type": "arrow",
"version": 10, "version": 10,
"width": 10, "width": 10,
"x": 0, "x": "0.00000",
"y": 0, "y": "0.00000",
}, },
"inserted": { "inserted": {
"isDeleted": true, "isDeleted": true,
@@ -128,8 +128,8 @@ exports[`move element > rectangles with binding arrow 5`] = `
"version": 4, "version": 4,
"versionNonce": 760410951, "versionNonce": 760410951,
"width": 100, "width": 100,
"x": 0, "x": "0.00000",
"y": 0, "y": "0.00000",
} }
`; `;
@@ -171,8 +171,8 @@ exports[`given element A and group of elements B and given both are selected whe
"type": "rectangle", "type": "rectangle",
"version": 3, "version": 3,
"width": 10, "width": 10,
"x": 0, "x": "0.00000",
"y": 0, "y": "0.00000",
}, },
"inserted": { "inserted": {
"isDeleted": true, "isDeleted": true,
@@ -225,7 +225,7 @@ exports[`given element A and group of elements B and given both are selected whe
"type": "rectangle", "type": "rectangle",
"version": 3, "version": 3,
"width": 10, "width": 10,
"x": 0, "x": "0.00000",
"y": 30, "y": 30,
}, },
"inserted": { "inserted": {
@@ -279,7 +279,7 @@ exports[`given element A and group of elements B and given both are selected whe
"type": "rectangle", "type": "rectangle",
"version": 3, "version": 3,
"width": 10, "width": 10,
"x": 0, "x": "0.00000",
"y": 60, "y": 60,
}, },
"inserted": { "inserted": {
@@ -599,8 +599,8 @@ exports[`given element A and group of elements B and given both are selected whe
"type": "rectangle", "type": "rectangle",
"version": 3, "version": 3,
"width": 100, "width": 100,
"x": 0, "x": "0.00000",
"y": 0, "y": "0.00000",
}, },
"inserted": { "inserted": {
"isDeleted": true, "isDeleted": true,
@@ -1006,8 +1006,8 @@ exports[`regression tests > Cmd/Ctrl-click exclusively select element under poin
"type": "rectangle", "type": "rectangle",
"version": 3, "version": 3,
"width": 10, "width": 10,
"x": 0, "x": "0.00000",
"y": 0, "y": "0.00000",
}, },
"inserted": { "inserted": {
"isDeleted": true, "isDeleted": true,
@@ -1061,7 +1061,7 @@ exports[`regression tests > Cmd/Ctrl-click exclusively select element under poin
"version": 3, "version": 3,
"width": 10, "width": 10,
"x": 30, "x": 30,
"y": 0, "y": "0.00000",
}, },
"inserted": { "inserted": {
"isDeleted": true, "isDeleted": true,
@@ -1230,7 +1230,7 @@ exports[`regression tests > Cmd/Ctrl-click exclusively select element under poin
"version": 3, "version": 3,
"width": 10, "width": 10,
"x": 60, "x": 60,
"y": 0, "y": "0.00000",
}, },
"inserted": { "inserted": {
"isDeleted": true, "isDeleted": true,
@@ -1572,8 +1572,8 @@ exports[`regression tests > Drags selected element when hitting only bounding bo
"type": "ellipse", "type": "ellipse",
"version": 3, "version": 3,
"width": 10, "width": 10,
"x": 0, "x": "0.00000",
"y": 0, "y": "0.00000",
}, },
"inserted": { "inserted": {
"isDeleted": true, "isDeleted": true,
@@ -1604,8 +1604,8 @@ exports[`regression tests > Drags selected element when hitting only bounding bo
}, },
"inserted": { "inserted": {
"version": 3, "version": 3,
"x": 0, "x": "0.00000",
"y": 0, "y": "0.00000",
}, },
}, },
}, },
@@ -4220,7 +4220,7 @@ exports[`regression tests > deleting last but one element in editing group shoul
"version": 3, "version": 3,
"width": 10, "width": 10,
"x": 10, "x": 10,
"y": 0, "y": "0.00000",
}, },
"inserted": { "inserted": {
"isDeleted": true, "isDeleted": true,
@@ -4274,7 +4274,7 @@ exports[`regression tests > deleting last but one element in editing group shoul
"version": 3, "version": 3,
"width": 10, "width": 10,
"x": 50, "x": 50,
"y": 0, "y": "0.00000",
}, },
"inserted": { "inserted": {
"isDeleted": true, "isDeleted": true,
@@ -4682,8 +4682,8 @@ exports[`regression tests > deselects group of selected elements on pointer down
"type": "rectangle", "type": "rectangle",
"version": 3, "version": 3,
"width": 10, "width": 10,
"x": 0, "x": "0.00000",
"y": 0, "y": "0.00000",
}, },
"inserted": { "inserted": {
"isDeleted": true, "isDeleted": true,
@@ -4937,8 +4937,8 @@ exports[`regression tests > deselects group of selected elements on pointer up w
"type": "rectangle", "type": "rectangle",
"version": 3, "version": 3,
"width": 10, "width": 10,
"x": 0, "x": "0.00000",
"y": 0, "y": "0.00000",
}, },
"inserted": { "inserted": {
"isDeleted": true, "isDeleted": true,
@@ -5240,8 +5240,8 @@ exports[`regression tests > deselects selected element on pointer down when poin
"type": "rectangle", "type": "rectangle",
"version": 3, "version": 3,
"width": 10, "width": 10,
"x": 0, "x": "0.00000",
"y": 0, "y": "0.00000",
}, },
"inserted": { "inserted": {
"isDeleted": true, "isDeleted": true,
@@ -5420,8 +5420,8 @@ exports[`regression tests > deselects selected element, on pointer up, when clic
"type": "ellipse", "type": "ellipse",
"version": 3, "version": 3,
"width": 100, "width": 100,
"x": 0, "x": "0.00000",
"y": 0, "y": "0.00000",
}, },
"inserted": { "inserted": {
"isDeleted": true, "isDeleted": true,
@@ -6017,8 +6017,8 @@ exports[`regression tests > drags selected elements from point inside common bou
"type": "rectangle", "type": "rectangle",
"version": 3, "version": 3,
"width": 10, "width": 10,
"x": 0, "x": "0.00000",
"y": 0, "y": "0.00000",
}, },
"inserted": { "inserted": {
"isDeleted": true, "isDeleted": true,
@@ -6123,8 +6123,8 @@ exports[`regression tests > drags selected elements from point inside common bou
}, },
"inserted": { "inserted": {
"version": 3, "version": 3,
"x": 0, "x": "0.00000",
"y": 0, "y": "0.00000",
}, },
}, },
"id3": { "id3": {
@@ -6814,12 +6814,12 @@ exports[`regression tests > draw every type of shape > [end of test] undo stack
10, 10,
], ],
[ [
80, "80.00000",
20, 20,
], ],
], ],
"version": 5, "version": 5,
"width": 80, "width": "80.00000",
}, },
"inserted": { "inserted": {
"height": 10, "height": 10,
@@ -7102,8 +7102,8 @@ exports[`regression tests > given a group of selected elements with an element t
"type": "rectangle", "type": "rectangle",
"version": 3, "version": 3,
"width": 10, "width": 10,
"x": 0, "x": "0.00000",
"y": 0, "y": "0.00000",
}, },
"inserted": { "inserted": {
"isDeleted": true, "isDeleted": true,
@@ -7436,8 +7436,8 @@ exports[`regression tests > given a selected element A and a not selected elemen
"type": "rectangle", "type": "rectangle",
"version": 3, "version": 3,
"width": 1000, "width": 1000,
"x": 0, "x": "0.00000",
"y": 0, "y": "0.00000",
}, },
"inserted": { "inserted": {
"isDeleted": true, "isDeleted": true,
@@ -11513,8 +11513,8 @@ exports[`regression tests > shift click on selected element should deselect it o
"type": "rectangle", "type": "rectangle",
"version": 3, "version": 3,
"width": 10, "width": 10,
"x": 0, "x": "0.00000",
"y": 0, "y": "0.00000",
}, },
"inserted": { "inserted": {
"isDeleted": true, "isDeleted": true,
@@ -12472,7 +12472,7 @@ exports[`regression tests > single-clicking on a subgroup of a selected group sh
"version": 3, "version": 3,
"width": 10, "width": 10,
"x": 10, "x": 10,
"y": 0, "y": "0.00000",
}, },
"inserted": { "inserted": {
"isDeleted": true, "isDeleted": true,
@@ -12526,7 +12526,7 @@ exports[`regression tests > single-clicking on a subgroup of a selected group sh
"version": 3, "version": 3,
"width": 10, "width": 10,
"x": 50, "x": 50,
"y": 0, "y": "0.00000",
}, },
"inserted": { "inserted": {
"isDeleted": true, "isDeleted": true,
@@ -13229,8 +13229,8 @@ exports[`regression tests > supports nested groups > [end of test] undo stack 1`
"type": "rectangle", "type": "rectangle",
"version": 3, "version": 3,
"width": 50, "width": 50,
"x": 0, "x": "0.00000",
"y": 0, "y": "0.00000",
}, },
"inserted": { "inserted": {
"isDeleted": true, "isDeleted": true,
@@ -13817,8 +13817,8 @@ exports[`regression tests > switches from group of selected elements to another
"version": 1, "version": 1,
"versionNonce": 0, "versionNonce": 0,
"width": 0, "width": 0,
"x": 0, "x": "0.00000",
"y": 0, "y": "0.00000",
}, },
"shouldCacheIgnoreZoom": false, "shouldCacheIgnoreZoom": false,
"showHyperlinkPopup": false, "showHyperlinkPopup": false,
@@ -13889,8 +13889,8 @@ exports[`regression tests > switches from group of selected elements to another
"type": "rectangle", "type": "rectangle",
"version": 3, "version": 3,
"width": 10, "width": 10,
"x": 0, "x": "0.00000",
"y": 0, "y": "0.00000",
}, },
"inserted": { "inserted": {
"isDeleted": true, "isDeleted": true,
@@ -14155,8 +14155,8 @@ exports[`regression tests > switches selected element on pointer down > [end of
"version": 1, "version": 1,
"versionNonce": 0, "versionNonce": 0,
"width": 0, "width": 0,
"x": 0, "x": "0.00000",
"y": 0, "y": "0.00000",
}, },
"shouldCacheIgnoreZoom": false, "shouldCacheIgnoreZoom": false,
"showHyperlinkPopup": false, "showHyperlinkPopup": false,
@@ -14227,8 +14227,8 @@ exports[`regression tests > switches selected element on pointer down > [end of
"type": "rectangle", "type": "rectangle",
"version": 3, "version": 3,
"width": 10, "width": 10,
"x": 0, "x": "0.00000",
"y": 0, "y": "0.00000",
}, },
"inserted": { "inserted": {
"isDeleted": true, "isDeleted": true,
@@ -14583,12 +14583,12 @@ exports[`regression tests > undo/redo drawing an element > [end of test] redo st
10, 10,
], ],
[ [
100, "100.00000",
20, 20,
], ],
], ],
"version": 5, "version": 5,
"width": 100, "width": "100.00000",
}, },
}, },
}, },
@@ -14770,7 +14770,7 @@ exports[`regression tests > undo/redo drawing an element > [end of test] undo st
"version": 5, "version": 5,
"width": 30, "width": 30,
"x": 40, "x": 40,
"y": 0, "y": "0.00000",
}, },
"inserted": { "inserted": {
"isDeleted": true, "isDeleted": true,
@@ -0,0 +1,73 @@
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);
});
});
+90 -7
View File
@@ -305,8 +305,88 @@ 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);
}); });
}); });
@@ -379,8 +459,9 @@ describe("pasting & frames", () => {
await waitFor(() => { await waitFor(() => {
expect(h.elements.length).toBe(3); expect(h.elements.length).toBe(3);
expect(h.elements[1].type).toBe(rect.type); expect(h.elements[0].type).toBe(rect.type);
expect(h.elements[1].frameId).toBe(frame.id); expect(h.elements[0].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);
}); });
@@ -422,10 +503,11 @@ describe("pasting & frames", () => {
await waitFor(() => { await waitFor(() => {
expect(h.elements.length).toBe(3); expect(h.elements.length).toBe(3);
expect(h.elements[1].type).toBe(rect.type); expect(h.elements[0].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].type).toBe(rect2.type); expect(h.elements[2].id).toBe(frame.id);
expect(h.elements[2].frameId).toBe(frame.id);
}); });
}); });
@@ -473,8 +555,9 @@ describe("pasting & frames", () => {
await waitFor(() => { await waitFor(() => {
expect(h.elements.length).toBe(4); expect(h.elements.length).toBe(4);
expect(h.elements[1].type).toBe(rect.type); expect(h.elements[0].type).toBe(rect.type);
expect(h.elements[1].frameId).toBe(frame.id); expect(h.elements[0].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);
@@ -0,0 +1,46 @@
import { ROUNDNESS } from "@excalidraw/common";
import { convertElementTypes } from "../components/ConvertElementTypePopup";
import { Excalidraw } from "../index";
import { API } from "./helpers/api";
import { act, render } from "./test-utils";
const { h } = window;
describe("convert element type", () => {
beforeEach(async () => {
await render(<Excalidraw handleKeyboardGlobally={true} />);
});
// #9662
it("recalculates roundness type when switching between generic shapes", () => {
const rectangle = API.createElement({
type: "rectangle",
roundness: { type: ROUNDNESS.ADAPTIVE_RADIUS }, // Dooesn't matter as long as it is set
});
API.setElements([rectangle]);
API.setSelectedElements([rectangle]);
act(() => {
convertElementTypes(h.app, {
conversionType: "generic",
nextType: "diamond",
});
});
expect(h.elements[0].type).toBe("diamond");
expect(h.elements[0].roundness?.type).toBe(ROUNDNESS.PROPORTIONAL_RADIUS);
act(() => {
convertElementTypes(h.app, {
conversionType: "generic",
nextType: "rectangle",
});
});
expect(h.elements[0].type).toBe("rectangle");
expect(h.elements[0].roundness?.type).toBe(ROUNDNESS.ADAPTIVE_RADIUS);
});
});
@@ -154,7 +154,7 @@ exports[`restoreElements > should restore correctly with rectangle, ellipse and
"opacity": 10, "opacity": 10,
"roughness": 2, "roughness": 2,
"roundness": { "roundness": {
"type": 3, "type": 2,
}, },
"seed": Any<Number>, "seed": Any<Number>,
"strokeColor": "red", "strokeColor": "red",
@@ -192,7 +192,7 @@ exports[`restoreElements > should restore correctly with rectangle, ellipse and
"opacity": 10, "opacity": 10,
"roughness": 2, "roughness": 2,
"roundness": { "roundness": {
"type": 3, "type": 2,
}, },
"seed": Any<Number>, "seed": Any<Number>,
"strokeColor": "red", "strokeColor": "red",
@@ -160,6 +160,39 @@ 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" });
@@ -400,6 +433,52 @@ 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",
+28 -2
View File
@@ -1,5 +1,4 @@
import { queryByText, queryByTestId } from "@testing-library/react"; import { queryByText, queryByTestId } from "@testing-library/react";
import React from "react";
import { useMemo } from "react"; import { useMemo } from "react";
import { THEME } from "@excalidraw/common"; import { THEME } from "@excalidraw/common";
@@ -433,7 +432,7 @@ describe("<Excalidraw/>", () => {
const customMenu = useMemo(() => { const customMenu = useMemo(() => {
return ( return (
<MainMenu> <MainMenu>
<MainMenu.DefaultItems.ToggleTheme /> <MainMenu.DefaultItems.ToggleTheme allowSystemTheme={false} />
</MainMenu> </MainMenu>
); );
}, []); }, []);
@@ -457,5 +456,32 @@ describe("<Excalidraw/>", () => {
queryByTestId(container, "toggle-dark-mode")?.textContent, queryByTestId(container, "toggle-dark-mode")?.textContent,
).toContain(t("buttons.lightMode")); ).toContain(t("buttons.lightMode"));
}); });
it("should show theme toggle when the theme prop and onThemeChange are defined", async () => {
const onThemeChange = vi.fn();
const { container } = await render(
<Excalidraw theme={THEME.DARK} onThemeChange={onThemeChange} />,
);
expect(h.state.theme).toBe(THEME.DARK);
//open menu
toggleMenu(container);
const darkModeToggle = queryByTestId(container, "toggle-dark-mode");
expect(darkModeToggle).toBeTruthy();
});
it("should call onThemeChange instead of mutating theme when defined", async () => {
const onThemeChange = vi.fn();
const { container } = await render(
<Excalidraw theme={THEME.LIGHT} onThemeChange={onThemeChange} />,
);
//open menu
toggleMenu(container);
fireEvent.click(queryByTestId(container, "toggle-dark-mode")!);
expect(onThemeChange).toHaveBeenCalledWith(THEME.DARK);
expect(h.state.theme).toBe(THEME.LIGHT);
});
}); });
}); });

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