Compare commits

..

33 Commits

Author SHA1 Message Date
Ryan Di 692802f8a6 fix: handle missing schemaState in migration 2026-03-25 20:10:43 +11:00
Ryan Di 91981159d2 feat: wire schema migration plugins end-to-end 2026-03-25 19:19:13 +11:00
Ryan Di b871d4ceb3 test: update coverage and snapshots for per-track schemaState 2026-03-25 18:04:22 +11:00
Ryan Di 16cf593978 refactor: replace global schema version with per-track migration 2026-03-25 18:04:13 +11:00
Ryan Di 1d35cb406b refactor: switch elements to per-track schemaState 2026-03-25 18:04:04 +11:00
Ryan Di 205d90592a test: refresh snapshots for schema changes 2026-03-23 17:30:23 +11:00
Ryan Di 08af0964f2 test: cover element schema migration flow 2026-03-23 17:30:16 +11:00
Ryan Di 0e4ae079ac refactor: remove payload-level schema version 2026-03-23 17:30:11 +11:00
Ryan Di 11ba6784aa refactor: migrate elements by schema in restore 2026-03-23 17:30:05 +11:00
Ryan Di 16838ea792 test: cover collab field preservation 2026-03-20 19:55:40 +11:00
Ryan Di 8168d46f87 chore: harden local schema hints and tests 2026-03-20 19:33:01 +11:00
Ryan Di eda7c8d6e9 fix: keep schema hints across clipboard roundtrips 2026-03-20 19:00:54 +11:00
Ryan Di b818b3fe04 chore: keep schema migration rules in code 2026-03-20 18:17:00 +11:00
Ryan Di 67967c05fa refactor: add schema migration helper APIs 2026-03-20 18:15:03 +11:00
Ryan Di 6c9914049e docs: jot down schema migration rules 2026-03-20 18:03:43 +11:00
Ryan Di 1e113e4a3b fix: wire schema scopes at import boundaries 2026-03-20 18:03:36 +11:00
Ryan Di ec458d92e3 chore: set up scoped schema migrations 2026-03-20 18:03:22 +11:00
Ryan Di b3b9b26979 fix: smooth out schema-version edges 2026-03-20 14:03:22 +11:00
Ryan Di 21d26b1afe schema: add legacy frame edge test and refresh snapshots 2026-03-20 01:11:05 +11:00
Ryan Di 6d3eb16531 schema: remove frame bg guard flag from runtime 2026-03-20 01:06:00 +11:00
Ryan Di 5bb3046dea schema: run v1->v2 frame migration on load/paste 2026-03-20 00:57:50 +11:00
Ryan Di 7b51a6ac54 schema: add payload schemaVersion plumbing 2026-03-20 00:26:08 +11:00
Ryan Di d1cbab855d pass full bg payload in actionProperties 2026-03-18 20:08:56 +11:00
Ryan Di 40158fa0a0 update test snapshots 2026-03-18 20:02:58 +11:00
Ryan Di d1144b4779 frame bg: normalize false flag to transparent 2026-03-18 19:59:17 +11:00
Ryan Di c4925dc5b9 frame bg: treat non-boolean legacy flag as missing 2026-03-18 19:55:52 +11:00
Ryan Di 48bc930c09 frame bg: drop legacy color if flag missing 2026-03-18 19:53:24 +11:00
Ryan Di 858d1d4cce frame bg: make legacy scenes opt-in 2026-03-18 19:49:46 +11:00
Ryan Di 3f405ab833 fix: align svg frame bg with canvas 2026-03-18 19:41:14 +11:00
Ryan Di 8d003a1d21 perf: tidy svg frame bg render pass 2026-03-18 19:38:53 +11:00
Ryan Di 2b3871856e perf: tidy frame bg render pass 2026-03-18 19:36:41 +11:00
Ryan Di a7281de157 fix: skip unsupported style updates 2026-03-18 19:22:48 +11:00
dwelle f944f1f7aa feat(editor): support frame backgrounds 2026-02-22 23:08:46 +01:00
284 changed files with 9996 additions and 19836 deletions
+1 -1
View File
@@ -1,4 +1,4 @@
FROM node:24-bullseye FROM node:18-bullseye
# Vite wants to open the browser using `open`, so we # Vite wants to open the browser using `open`, so we
# need to install those utils. # need to install those utils.
-7
View File
@@ -1,7 +0,0 @@
# 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
+1 -22
View File
@@ -39,26 +39,5 @@
"allowReferrer": true "allowReferrer": true
} }
] ]
}, }
"overrides": [
{
"files": ["packages/excalidraw/**/*.{ts,tsx}"],
"excludedFiles": ["packages/excalidraw/**/*.test.{ts,tsx}", "packages/excalidraw/**/*.test.*.{ts,tsx}"],
"rules": {
"@typescript-eslint/no-restricted-imports": [
"error",
{
"patterns": [
{
"group": ["@excalidraw/excalidraw"],
"message": "Do not import from the barrel 'index.tsx' files. Use direct relative imports to the specific module instead.",
"allowTypeImports": true
}
],
"paths": [".", "..", "../..", "../../..", "../../../..", "../../../../..", "../index", "../../index", "../../../index", "../../../../index"]
}
]
}
}
]
} }
+2 -2
View File
@@ -9,11 +9,11 @@ jobs:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 - uses: actions/checkout@v2
with: with:
fetch-depth: 2 fetch-depth: 2
- name: Setup Node.js - name: Setup Node.js
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4 uses: actions/setup-node@v2
with: with:
node-version: 20.x node-version: 20.x
- name: Set up publish access - name: Set up publish access
+1 -1
View File
@@ -9,5 +9,5 @@ jobs:
build-docker: build-docker:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 - uses: actions/checkout@v2
- run: docker build -t excalidraw . - run: docker build -t excalidraw .
+1 -1
View File
@@ -11,7 +11,7 @@ jobs:
runs-on: ubuntu-latest runs-on: ubuntu-latest
timeout-minutes: 3 timeout-minutes: 3
steps: steps:
- uses: styfle/cancel-workflow-action@ce177499ccf9fd2aded3b0426c97e5434c2e8a73 # 0.6.0 - uses: styfle/cancel-workflow-action@0.6.0
with: with:
workflow_id: 400555, 400556, 905313, 1451724, 1710116, 3185001, 3438604 workflow_id: 400555, 400556, 905313, 1451724, 1710116, 3185001, 3438604
access_token: ${{ secrets.GITHUB_TOKEN }} access_token: ${{ secrets.GITHUB_TOKEN }}
+2 -2
View File
@@ -7,10 +7,10 @@ jobs:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 - uses: actions/checkout@v2
- name: Setup Node.js - name: Setup Node.js
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4 uses: actions/setup-node@v2
with: with:
node-version: 20.x node-version: 20.x
+3 -3
View File
@@ -10,12 +10,12 @@ jobs:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 - uses: actions/checkout@v4
with: with:
token: ${{ secrets.PUSH_TRANSLATIONS_COVERAGE_PAT }} token: ${{ secrets.PUSH_TRANSLATIONS_COVERAGE_PAT }}
- name: Setup Node.js - name: Setup Node.js
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4 uses: actions/setup-node@v2
with: with:
node-version: 20.x node-version: 20.x
@@ -40,7 +40,7 @@ jobs:
echo ::set-output name=body::$body echo ::set-output name=body::$body
- name: Update description with coverage - name: Update description with coverage
uses: kt3k/update-pr-description@1b35a6dcd84d81aa0bc1889610efdcde7f37b0c0 # v1.0.1 uses: kt3k/update-pr-description@v1.0.1
with: with:
pr_body: ${{ steps.getCommentBody.outputs.body }} pr_body: ${{ steps.getCommentBody.outputs.body }}
pr_title: "chore: Update translations from Crowdin" pr_title: "chore: Update translations from Crowdin"
+5 -5
View File
@@ -11,18 +11,18 @@ jobs:
steps: steps:
- name: Checkout repository - name: Checkout repository
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 uses: actions/checkout@v3
- name: Login to DockerHub - name: Login to DockerHub
uses: docker/login-action@465a07811f14bebb1938fbed4728c6a1ff8901fc # v2 uses: docker/login-action@v2
with: with:
username: ${{ secrets.DOCKER_USERNAME }} username: ${{ secrets.DOCKER_USERNAME }}
password: ${{ secrets.DOCKER_PASSWORD }} password: ${{ secrets.DOCKER_PASSWORD }}
- name: Set up QEMU - name: Set up QEMU
uses: docker/setup-qemu-action@c7c53464625b32c7a7e944ae62b3e17d2b600130 # v3 uses: docker/setup-qemu-action@v3
- name: Set up Docker Buildx - name: Set up Docker Buildx
uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3 uses: docker/setup-buildx-action@v3
- name: Build and push - name: Build and push
uses: docker/build-push-action@ca052bb54ab0790a636c9b5f226502c73d547a25 # v5 uses: docker/build-push-action@v5
with: with:
context: . context: .
push: true push: true
+1 -87
View File
@@ -6,97 +6,11 @@ on:
- opened - opened
- edited - edited
- synchronize - synchronize
- labeled
- unlabeled
jobs: jobs:
semantic: semantic:
runs-on: ubuntu-latest runs-on: ubuntu-latest
permissions:
pull-requests: read
steps: steps:
- uses: amannn/action-semantic-pull-request@e32d7e603df1aa1ba07e981f2a23455dee596825 # v5 - uses: amannn/action-semantic-pull-request@v5
with:
requireScope: true
scopes: |
app
editor
packages/excalidraw
packages/utils
docker
repo
ignoreLabels: |
skip-semantic-title
env: env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
label-scope:
needs: semantic
if: github.event.pull_request.head.repo.full_name == github.repository
runs-on: ubuntu-latest
permissions:
issues: write
pull-requests: write
steps:
- name: Label scoped PR
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
PR_NUMBER: ${{ github.event.pull_request.number }}
PR_TITLE: ${{ github.event.pull_request.title }}
REPOSITORY: ${{ github.repository }}
run: |
set -euo pipefail
scope_labels=(s-app s-editor s-package)
readarray -t desired_labels < <(
node <<'NODE'
const title = process.env.PR_TITLE;
const match = title.match(/^[a-z]+(?:\(([^)]+)\))?!?:/i);
const scopes = match?.[1]?.split(",").map((scope) => scope.trim()) ?? [];
const labels = new Set();
for (const scope of scopes) {
if (scope === "app") {
labels.add("s-app");
} else if (scope === "editor") {
labels.add("s-editor");
} else if (scope.startsWith("packages/")) {
labels.add("s-package");
}
}
process.stdout.write([...labels].join("\n"));
NODE
)
should_apply_label() {
local label="$1"
for desired_label in "${desired_labels[@]}"; do
if [[ "$desired_label" == "$label" ]]; then
return 0
fi
done
return 1
}
for label in "${scope_labels[@]}"; do
if ! should_apply_label "$label"; then
gh api \
--method DELETE \
"repos/${REPOSITORY}/issues/${PR_NUMBER}/labels/${label}" \
--silent 2>/dev/null || true
fi
done
for label in "${desired_labels[@]}"; do
if ! gh api \
--method POST \
"repos/${REPOSITORY}/issues/${PR_NUMBER}/labels" \
--field "labels[]=${label}" \
--silent; then
echo "::warning::Could not apply ${label}. The workflow token likely does not have issues:write permission for this PR."
fi
done
+2 -2
View File
@@ -9,9 +9,9 @@ jobs:
sentry: sentry:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 - uses: actions/checkout@v2
- name: Setup Node.js - name: Setup Node.js
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4 uses: actions/setup-node@v2
with: with:
node-version: 20.x node-version: 20.x
- name: Install and build - name: Install and build
+3 -3
View File
@@ -10,9 +10,9 @@ jobs:
CI_JOB_NUMBER: 1 CI_JOB_NUMBER: 1
steps: steps:
- name: Checkout repository - name: Checkout repository
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 uses: actions/checkout@v3
- name: Setup Node.js - name: Setup Node.js
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4 uses: actions/setup-node@v3
with: with:
node-version: 20.x node-version: 20.x
- name: Install in packages/excalidraw - name: Install in packages/excalidraw
@@ -20,7 +20,7 @@ jobs:
working-directory: packages/excalidraw working-directory: packages/excalidraw
env: env:
CI: true CI: true
- uses: andresz1/size-limit-action@e7493a72a44b113341c0cf6186ab49c17c4b65c1 # v1 - uses: andresz1/size-limit-action@v1
with: with:
github_token: ${{ secrets.GITHUB_TOKEN }} github_token: ${{ secrets.GITHUB_TOKEN }}
build_script: build:esm build_script: build:esm
+3 -3
View File
@@ -10,9 +10,9 @@ jobs:
pull-requests: write pull-requests: write
steps: steps:
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 - uses: actions/checkout@v2
- name: "Install Node" - name: "Install Node"
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4 uses: actions/setup-node@v2
with: with:
node-version: "20.x" node-version: "20.x"
- name: "Install Deps" - name: "Install Deps"
@@ -21,6 +21,6 @@ jobs:
run: yarn test:coverage run: yarn test:coverage
- name: "Report Coverage" - name: "Report Coverage"
if: always() # Also generate the report if tests are failing if: always() # Also generate the report if tests are failing
uses: davelosert/vitest-coverage-report-action@2500dafcee7dd64f85ab689c0b83798a8359770e # v2 uses: davelosert/vitest-coverage-report-action@v2
with: with:
github-token: ${{ secrets.GITHUB_TOKEN }} github-token: ${{ secrets.GITHUB_TOKEN }}
+2 -2
View File
@@ -8,9 +8,9 @@ jobs:
test: test:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 - uses: actions/checkout@v4
- name: Setup Node.js - name: Setup Node.js
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4 uses: actions/setup-node@v4
with: with:
node-version: 20.x node-version: 20.x
- name: Install and test - name: Install and test
+3 -3
View File
@@ -1,4 +1,4 @@
FROM --platform=${BUILDPLATFORM} node:24@sha256:8530f76a96d88820d288761f022e318970dda93d01536919fbc16076b7983e63 AS build FROM --platform=${BUILDPLATFORM} node:18 AS build
WORKDIR /opt/node_app WORKDIR /opt/node_app
@@ -7,13 +7,13 @@ COPY . .
# do not ignore optional dependencies: # do not ignore optional dependencies:
# Error: Cannot find module @rollup/rollup-linux-x64-gnu # Error: Cannot find module @rollup/rollup-linux-x64-gnu
RUN --mount=type=cache,target=/root/.cache/yarn \ RUN --mount=type=cache,target=/root/.cache/yarn \
npm_config_target_arch=${TARGETARCH} yarn --frozen-lockfile --network-timeout 600000 npm_config_target_arch=${TARGETARCH} yarn --network-timeout 600000
ARG NODE_ENV=production ARG NODE_ENV=production
RUN npm_config_target_arch=${TARGETARCH} yarn build:app:docker RUN npm_config_target_arch=${TARGETARCH} yarn build:app:docker
FROM nginx:stable-alpine-slim@sha256:2c605dbeab79a6b2a63340474fe58119d0ef95bdc4b1f41df0aa689659b3d13b FROM --platform=${TARGETPLATFORM} nginx:1.27-alpine
COPY --from=build /opt/node_app/excalidraw-app/build /usr/share/nginx/html COPY --from=build /opt/node_app/excalidraw-app/build /usr/share/nginx/html
+1 -1
View File
@@ -29,7 +29,7 @@
<a href="https://docs.excalidraw.com/docs/introduction/contributing"> <a href="https://docs.excalidraw.com/docs/introduction/contributing">
<img alt="PRs welcome!" src="https://img.shields.io/badge/PRs-welcome-brightgreen.svg?style=flat" /></a> <img alt="PRs welcome!" src="https://img.shields.io/badge/PRs-welcome-brightgreen.svg?style=flat" /></a>
<a href="https://discord.gg/UexuTaE"> <a href="https://discord.gg/UexuTaE">
<img alt="Chat on Discord" src="https://img.shields.io/discord/723672430744174682?color=738ad6&label=Chat%20on%20Discord&logo=discord&logoColor=ffffff&widget=false"/></a> <img alt="Chat on Discord" src="https://img.shields.io/discord/723672430744174682?color=738ad6&label=Chat%20on%20Discord&logo=discord&logoColor=ffffff&widge=false"/></a>
<a href="https://deepwiki.com/excalidraw/excalidraw"> <a href="https://deepwiki.com/excalidraw/excalidraw">
<img alt="Ask DeepWiki" src="https://deepwiki.com/badge.svg" /></a> <img alt="Ask DeepWiki" src="https://deepwiki.com/badge.svg" /></a>
<a href="https://twitter.com/excalidraw"> <a href="https://twitter.com/excalidraw">
@@ -172,7 +172,7 @@ convertToExcalidrawElements([
type: "arrow", type: "arrow",
x: 450, x: 450,
y: 20, y: 20,
startArrowhead: "circle", startArrowhead: "dot",
endArrowhead: "triangle", endArrowhead: "triangle",
strokeColor: "#1971c2", strokeColor: "#1971c2",
strokeWidth: 2, strokeWidth: 2,
+2 -2
View File
@@ -97,8 +97,8 @@ const config = {
href: "https://discord.gg/UexuTaE", href: "https://discord.gg/UexuTaE",
}, },
{ {
label: "𝕏", label: "Twitter",
href: "https://x.com/excalidraw", href: "https://twitter.com/excalidraw",
}, },
{ {
label: "Linkedin", label: "Linkedin",
@@ -1,4 +1,4 @@
import type { ExcalidrawElementSkeleton } from "@excalidraw/excalidraw/element/transform"; import type { ExcalidrawElementSkeleton } from "@excalidraw/excalidraw/data/transform";
import type { FileId } from "@excalidraw/excalidraw/element/types"; import type { FileId } from "@excalidraw/excalidraw/element/types";
const elements: ExcalidrawElementSkeleton[] = [ const elements: ExcalidrawElementSkeleton[] = [
+5 -5
View File
@@ -3,14 +3,14 @@
"version": "1.0.0", "version": "1.0.0",
"private": true, "private": true,
"dependencies": { "dependencies": {
"@excalidraw/excalidraw": "*",
"browser-fs-access": "0.38.0",
"react": "19.0.0", "react": "19.0.0",
"react-dom": "19.0.0" "react-dom": "19.0.0",
"@excalidraw/excalidraw": "*",
"browser-fs-access": "0.29.1"
}, },
"devDependencies": { "devDependencies": {
"typescript": "^5", "vite": "5.0.12",
"vite": "5.0.12" "typescript": "^5"
}, },
"scripts": { "scripts": {
"start": "vite", "start": "vite",
+36
View File
@@ -4,6 +4,8 @@ import { unstable_batchedUpdates } from "react-dom";
type FILE_EXTENSION = Exclude<keyof typeof MIME_TYPES, "binary">; type FILE_EXTENSION = Exclude<keyof typeof MIME_TYPES, "binary">;
const INPUT_CHANGE_INTERVAL_MS = 500;
export type ResolvablePromise<T> = Promise<T> & { export type ResolvablePromise<T> = Promise<T> & {
resolve: [T] extends [undefined] ? (value?: T) => void : (value: T) => void; resolve: [T] extends [undefined] ? (value?: T) => void : (value: T) => void;
reject: (error: Error) => void; reject: (error: Error) => void;
@@ -52,6 +54,40 @@ export const fileOpen = <M extends boolean | undefined = false>(opts: {
extensions, extensions,
mimeTypes, mimeTypes,
multiple: opts.multiple ?? false, multiple: opts.multiple ?? false,
legacySetup: (resolve, reject, input) => {
const scheduleRejection = debounce(reject, INPUT_CHANGE_INTERVAL_MS);
const focusHandler = () => {
checkForFile();
document.addEventListener("keyup", scheduleRejection);
document.addEventListener("pointerup", scheduleRejection);
scheduleRejection();
};
const checkForFile = () => {
// this hack might not work when expecting multiple files
if (input.files?.length) {
const ret = opts.multiple ? [...input.files] : input.files[0];
resolve(ret as RetType);
}
};
requestAnimationFrame(() => {
window.addEventListener("focus", focusHandler);
});
const interval = window.setInterval(() => {
checkForFile();
}, INPUT_CHANGE_INTERVAL_MS);
return (rejectPromise) => {
clearInterval(interval);
scheduleRejection.cancel();
window.removeEventListener("focus", focusHandler);
document.removeEventListener("keyup", scheduleRejection);
document.removeEventListener("pointerup", scheduleRejection);
if (rejectPromise) {
// so that something is shown in console if we need to debug this
console.warn("Opening the file was canceled (legacy-fs).");
rejectPromise(new Error("Request Aborted"));
}
};
},
}) as Promise<RetType>; }) as Promise<RetType>;
}; };
+36 -95
View File
@@ -5,8 +5,6 @@ import {
CaptureUpdateAction, CaptureUpdateAction,
reconcileElements, reconcileElements,
useEditorInterface, useEditorInterface,
ExcalidrawAPIProvider,
useExcalidrawAPI,
} from "@excalidraw/excalidraw"; } from "@excalidraw/excalidraw";
import { trackEvent } from "@excalidraw/excalidraw/analytics"; import { trackEvent } from "@excalidraw/excalidraw/analytics";
import { getDefaultAppState } from "@excalidraw/excalidraw/appState"; import { getDefaultAppState } from "@excalidraw/excalidraw/appState";
@@ -22,6 +20,7 @@ import Trans from "@excalidraw/excalidraw/components/Trans";
import { import {
APP_NAME, APP_NAME,
EVENT, EVENT,
THEME,
VERSION_TIMEOUT, VERSION_TIMEOUT,
debounce, debounce,
getVersion, getVersion,
@@ -35,6 +34,7 @@ import {
import polyfill from "@excalidraw/excalidraw/polyfill"; import polyfill from "@excalidraw/excalidraw/polyfill";
import { useCallback, useEffect, useRef, useState } from "react"; import { useCallback, useEffect, useRef, useState } from "react";
import { loadFromBlob } from "@excalidraw/excalidraw/data/blob"; import { loadFromBlob } from "@excalidraw/excalidraw/data/blob";
import { useCallbackRefState } from "@excalidraw/excalidraw/hooks/useCallbackRefState";
import { t } from "@excalidraw/excalidraw/i18n"; import { t } from "@excalidraw/excalidraw/i18n";
import { import {
@@ -74,7 +74,6 @@ import type {
BinaryFiles, BinaryFiles,
ExcalidrawInitialDataState, ExcalidrawInitialDataState,
UIAppState, UIAppState,
ExcalidrawProps,
} from "@excalidraw/excalidraw/types"; } from "@excalidraw/excalidraw/types";
import type { ResolutionType } from "@excalidraw/common/utility-types"; import type { ResolutionType } from "@excalidraw/common/utility-types";
import type { ResolvablePromise } from "@excalidraw/common/utils"; import type { ResolvablePromise } from "@excalidraw/common/utils";
@@ -115,7 +114,6 @@ import {
} from "./data"; } from "./data";
import { updateStaleImageStatuses } from "./data/FileManager"; import { updateStaleImageStatuses } from "./data/FileManager";
import { FileStatusStore } from "./data/fileStatusStore";
import { import {
importFromLocalStorage, importFromLocalStorage,
importUsernameFromLocalStorage, importUsernameFromLocalStorage,
@@ -268,7 +266,7 @@ const initializeScene = async (opts: {
repairBindings: true, repairBindings: true,
deleteInvisibleElements: true, deleteInvisibleElements: true,
}), }),
localDataState?.elements, scene.elements,
), ),
appState: restoreAppState( appState: restoreAppState(
imported.appState, imported.appState,
@@ -371,8 +369,6 @@ const initializeScene = async (opts: {
}; };
const ExcalidrawWrapper = () => { const ExcalidrawWrapper = () => {
const excalidrawAPI = useExcalidrawAPI();
const [errorMessage, setErrorMessage] = useState(""); const [errorMessage, setErrorMessage] = useState("");
const isCollabDisabled = isRunningInIframe(); const isCollabDisabled = isRunningInIframe();
@@ -403,6 +399,9 @@ const ExcalidrawWrapper = () => {
}, VERSION_TIMEOUT); }, VERSION_TIMEOUT);
}, []); }, []);
const [excalidrawAPI, excalidrawRefCallback] =
useCallbackRefState<ExcalidrawImperativeAPI>();
const [, setShareDialogState] = useAtom(shareDialogStateAtom); const [, setShareDialogState] = useAtom(shareDialogStateAtom);
const [collabAPI] = useAtom(collabAPIAtom); const [collabAPI] = useAtom(collabAPIAtom);
const [isCollaborating] = useAtomWithInitialValue(isCollaboratingAtom, () => { const [isCollaborating] = useAtomWithInitialValue(isCollaboratingAtom, () => {
@@ -434,15 +433,18 @@ const ExcalidrawWrapper = () => {
} }
}, [excalidrawAPI]); }, [excalidrawAPI]);
// --------------------------------------------------------------------------- useEffect(() => {
// Hoisted loadImages if (!excalidrawAPI || (!isCollabDisabled && !collabAPI)) {
// --------------------------------------------------------------------------- return;
const loadImages = useCallback( }
(data: ResolutionType<typeof initializeScene>, isInitialLoad = false) => {
if (!data.scene || !excalidrawAPI) { const loadImages = (
data: ResolutionType<typeof initializeScene>,
isInitialLoad = false,
) => {
if (!data.scene) {
return; return;
} }
if (collabAPI?.isCollaborating()) { if (collabAPI?.isCollaborating()) {
if (data.scene.elements) { if (data.scene.elements) {
collabAPI collabAPI
@@ -469,12 +471,6 @@ const ExcalidrawWrapper = () => {
}, [] as FileId[]) || []; }, [] as FileId[]) || [];
if (data.isExternalScene) { if (data.isExternalScene) {
if (fileIds.length) {
// Direct Firebase call (not through FileManager), so track manually
FileStatusStore.updateStatuses(
fileIds.map((id) => [id, "loading"]),
);
}
loadFilesFromFirebase( loadFilesFromFirebase(
`${FIREBASE_STORAGE_PREFIXES.shareLinkFiles}/${data.id}`, `${FIREBASE_STORAGE_PREFIXES.shareLinkFiles}/${data.id}`,
data.key, data.key,
@@ -486,18 +482,12 @@ const ExcalidrawWrapper = () => {
erroredFiles, erroredFiles,
elements: excalidrawAPI.getSceneElementsIncludingDeleted(), elements: excalidrawAPI.getSceneElementsIncludingDeleted(),
}); });
FileStatusStore.updateStatuses([
...loadedFiles.map((f) => [f.id, "loaded"] as [FileId, "loaded"]),
...[...erroredFiles.keys()].map(
(id) => [id, "error"] as [FileId, "error"],
),
]);
}); });
} else if (isInitialLoad) { } else if (isInitialLoad) {
if (fileIds.length) { if (fileIds.length) {
LocalData.fileStorage LocalData.fileStorage
.getFiles(fileIds) .getFiles(fileIds)
.then(async ({ loadedFiles, erroredFiles }) => { .then(({ loadedFiles, erroredFiles }) => {
if (loadedFiles.length) { if (loadedFiles.length) {
excalidrawAPI.addFiles(loadedFiles); excalidrawAPI.addFiles(loadedFiles);
} }
@@ -510,19 +500,10 @@ const ExcalidrawWrapper = () => {
} }
// on fresh load, clear unused files from IDB (from previous // on fresh load, clear unused files from IDB (from previous
// session) // session)
LocalData.fileStorage.clearObsoleteFiles({ LocalData.fileStorage.clearObsoleteFiles({ currentFileIds: fileIds });
currentFileIds: fileIds,
});
} }
} }
}, };
[collabAPI, excalidrawAPI],
);
useEffect(() => {
if (!excalidrawAPI || (!isCollabDisabled && !collabAPI)) {
return;
}
initializeScene({ collabAPI, excalidrawAPI }).then(async (data) => { initializeScene({ collabAPI, excalidrawAPI }).then(async (data) => {
loadImages(data, /* isInitialLoad */ true); loadImages(data, /* isInitialLoad */ true);
@@ -570,7 +551,11 @@ const ExcalidrawWrapper = () => {
const username = importUsernameFromLocalStorage(); const username = importUsernameFromLocalStorage();
setLangCode(getPreferredLanguage()); setLangCode(getPreferredLanguage());
excalidrawAPI.updateScene({ excalidrawAPI.updateScene({
...localDataState, elements: restoreElements(localDataState?.elements, null, {
repairBindings: true,
deleteInvisibleElements: true,
}),
appState: restoreAppState(localDataState?.appState, null),
captureUpdate: CaptureUpdateAction.NEVER, captureUpdate: CaptureUpdateAction.NEVER,
}); });
LibraryIndexedDBAdapter.load().then((data) => { LibraryIndexedDBAdapter.load().then((data) => {
@@ -647,7 +632,7 @@ const ExcalidrawWrapper = () => {
false, false,
); );
}; };
}, [isCollabDisabled, collabAPI, excalidrawAPI, setLangCode, loadImages]); }, [isCollabDisabled, collabAPI, excalidrawAPI, setLangCode]);
useEffect(() => { useEffect(() => {
const unloadHandler = (event: BeforeUnloadEvent) => { const unloadHandler = (event: BeforeUnloadEvent) => {
@@ -792,56 +777,6 @@ const ExcalidrawWrapper = () => {
[setShareDialogState], [setShareDialogState],
); );
// ---------------------------------------------------------------------------
// onExport — intercepts file save to wait for pending image loads
// ---------------------------------------------------------------------------
const onExport: Required<ExcalidrawProps>["onExport"] = useCallback(
async function* () {
let snapshot = FileStatusStore.getSnapshot();
const { pending, total } = FileStatusStore.getPendingCount(
snapshot.value,
);
if (pending === 0) {
return;
}
// Yield initial progress
yield {
type: "progress",
progress: (total - pending) / total,
message: `Loading images (${total - pending}/${total})...`,
};
// Wait for all pending images to finish
while (true) {
snapshot = await FileStatusStore.pull(snapshot.version);
const { pending: nowPending, total: nowTotal } =
FileStatusStore.getPendingCount(snapshot.value);
yield {
type: "progress",
progress: (nowTotal - nowPending) / nowTotal,
message: `Loading images (${nowTotal - nowPending}/${nowTotal})...`,
};
if (nowPending === 0) {
await new Promise((r) => setTimeout(r, 500));
yield {
type: "progress",
message: `Preparing export...`,
};
return;
}
}
},
[],
);
// const onExport = () => {
// return new Promise((r) => setTimeout(r, 2500));
// // console.log("onExport");
// };
// browsers generally prevent infinite self-embedding, there are // browsers generally prevent infinite self-embedding, there are
// cases where it still happens, and while we disallow self-embedding // cases where it still happens, and while we disallow self-embedding
// by not whitelisting our own origin, this serves as an additional guard // by not whitelisting our own origin, this serves as an additional guard
@@ -908,8 +843,8 @@ const ExcalidrawWrapper = () => {
})} })}
> >
<Excalidraw <Excalidraw
excalidrawAPI={excalidrawRefCallback}
onChange={onChange} onChange={onChange}
onExport={onExport}
initialData={initialStatePromiseRef.current.promise} initialData={initialStatePromiseRef.current.promise}
isCollaborating={isCollaborating} isCollaborating={isCollaborating}
onPointerUpdate={collabAPI?.onPointerUpdate} onPointerUpdate={collabAPI?.onPointerUpdate}
@@ -951,7 +886,6 @@ 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,6 +922,7 @@ 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
@@ -1228,6 +1163,14 @@ 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,
@@ -1267,9 +1210,7 @@ const ExcalidrawApp = () => {
return ( return (
<TopErrorBoundary> <TopErrorBoundary>
<Provider store={appJotaiStore}> <Provider store={appJotaiStore}>
<ExcalidrawAPIProvider> <ExcalidrawWrapper />
<ExcalidrawWrapper />
</ExcalidrawAPIProvider>
</Provider> </Provider>
</TopErrorBoundary> </TopErrorBoundary>
); );
-2
View File
@@ -72,7 +72,6 @@ import {
FileManager, FileManager,
updateStaleImageStatuses, updateStaleImageStatuses,
} from "../data/FileManager"; } from "../data/FileManager";
import { FileStatusStore } from "../data/fileStatusStore";
import { LocalData } from "../data/LocalData"; import { LocalData } from "../data/LocalData";
import { import {
isSavedToFirebase, isSavedToFirebase,
@@ -150,7 +149,6 @@ class Collab extends PureComponent<CollabProps, CollabState> {
}; };
this.portal = new Portal(this); this.portal = new Portal(this);
this.fileManager = new FileManager({ this.fileManager = new FileManager({
onFileStatusChange: FileStatusStore.updateStatuses.bind(FileStatusStore),
getFiles: async (fileIds) => { getFiles: async (fileIds) => {
const { roomId, roomKey } = this.portal; const { roomId, roomKey } = this.portal;
if (!roomId || !roomKey) { if (!roomId || !roomKey) {
+6 -1
View File
@@ -20,6 +20,7 @@ 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 (
@@ -77,7 +78,11 @@ export const AppMainMenu: React.FC<{
)} )}
<MainMenu.Separator /> <MainMenu.Separator />
<MainMenu.DefaultItems.Preferences /> <MainMenu.DefaultItems.Preferences />
<MainMenu.DefaultItems.ToggleTheme allowSystemTheme theme={props.theme} /> <MainMenu.DefaultItems.ToggleTheme
allowSystemTheme
theme={props.theme}
onSelect={props.setTheme}
/>
<MainMenu.ItemCustom> <MainMenu.ItemCustom>
<LanguageList style={{ width: "100%" }} /> <LanguageList style={{ width: "100%" }} />
</MainMenu.ItemCustom> </MainMenu.ItemCustom>
@@ -414,6 +414,7 @@ export const debugRenderer = throttleRAF(
) => { ) => {
_debugRenderer(canvas, appState, elements, scale); _debugRenderer(canvas, appState, elements, scale);
}, },
{ trailing: true },
); );
export const loadSavedDebugState = () => { export const loadSavedDebugState = () => {
-22
View File
@@ -40,12 +40,10 @@ export class FileManager {
private _getFiles; private _getFiles;
private _saveFiles; private _saveFiles;
private _onFileStatusChange;
constructor({ constructor({
getFiles, getFiles,
saveFiles, saveFiles,
onFileStatusChange,
}: { }: {
getFiles: (fileIds: FileId[]) => Promise<{ getFiles: (fileIds: FileId[]) => Promise<{
loadedFiles: BinaryFileData[]; loadedFiles: BinaryFileData[];
@@ -55,13 +53,9 @@ export class FileManager {
savedFiles: Map<FileId, BinaryFileData>; savedFiles: Map<FileId, BinaryFileData>;
erroredFiles: Map<FileId, BinaryFileData>; erroredFiles: Map<FileId, BinaryFileData>;
}>; }>;
onFileStatusChange?: (
updates: Array<[FileId, "loading" | "loaded" | "error"]>,
) => void;
}) { }) {
this._getFiles = getFiles; this._getFiles = getFiles;
this._saveFiles = saveFiles; this._saveFiles = saveFiles;
this._onFileStatusChange = onFileStatusChange;
} }
/** /**
@@ -152,8 +146,6 @@ export class FileManager {
this.fetchingFiles.set(id, true); this.fetchingFiles.set(id, true);
} }
this._onFileStatusChange?.(ids.map((id) => [id, "loading"]));
try { try {
const { loadedFiles, erroredFiles } = await this._getFiles(ids); const { loadedFiles, erroredFiles } = await this._getFiles(ids);
@@ -164,13 +156,6 @@ export class FileManager {
this.erroredFiles_fetch.set(fileId, true); this.erroredFiles_fetch.set(fileId, true);
} }
this._onFileStatusChange?.([
...loadedFiles.map((f) => [f.id, "loaded"] as [FileId, "loaded"]),
...[...erroredFiles.keys()].map(
(id) => [id, "error"] as [FileId, "error"],
),
]);
return { loadedFiles, erroredFiles }; return { loadedFiles, erroredFiles };
} finally { } finally {
for (const id of ids) { for (const id of ids) {
@@ -210,13 +195,6 @@ export class FileManager {
}; };
reset() { reset() {
if (this._onFileStatusChange && this.fetchingFiles.size) {
this._onFileStatusChange(
[...this.fetchingFiles.keys()].map(
(id) => [id, "error"] as [FileId, "error"],
),
);
}
this.fetchingFiles.clear(); this.fetchingFiles.clear();
this.savingFiles.clear(); this.savingFiles.clear();
this.savedFiles.clear(); this.savedFiles.clear();
+4 -4
View File
@@ -26,6 +26,7 @@ import {
get, get,
} from "idb-keyval"; } from "idb-keyval";
import { appJotaiStore, atom } from "excalidraw-app/app-jotai";
import { getNonDeletedElements } from "@excalidraw/element"; import { getNonDeletedElements } from "@excalidraw/element";
import type { LibraryPersistedData } from "@excalidraw/excalidraw/data/library"; import type { LibraryPersistedData } from "@excalidraw/excalidraw/data/library";
@@ -38,11 +39,9 @@ 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";
import { FileStatusStore } from "./fileStatusStore";
import { Locker } from "./Locker"; import { Locker } from "./Locker";
import { updateBrowserStateVersion } from "./tabSync"; import { updateBrowserStateVersion } from "./tabSync";
@@ -87,9 +86,11 @@ const saveDataStateToLocalStorage = (
_appState.openSidebar = null; _appState.openSidebar = null;
} }
const persistedElements = getNonDeletedElements(elements);
localStorage.setItem( localStorage.setItem(
STORAGE_KEYS.LOCAL_STORAGE_ELEMENTS, STORAGE_KEYS.LOCAL_STORAGE_ELEMENTS,
JSON.stringify(getNonDeletedElements(elements)), JSON.stringify(persistedElements),
); );
localStorage.setItem( localStorage.setItem(
STORAGE_KEYS.LOCAL_STORAGE_APP_STATE, STORAGE_KEYS.LOCAL_STORAGE_APP_STATE,
@@ -167,7 +168,6 @@ export class LocalData {
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
static fileStorage = new LocalFileManager({ static fileStorage = new LocalFileManager({
onFileStatusChange: FileStatusStore.updateStatuses.bind(FileStatusStore),
getFiles(ids) { getFiles(ids) {
return getMany(ids, filesStore).then( return getMany(ids, filesStore).then(
async (filesData: (BinaryFileData | undefined)[]) => { async (filesData: (BinaryFileData | undefined)[]) => {
-48
View File
@@ -1,48 +0,0 @@
import { VersionedSnapshotStore } from "@excalidraw/common";
import type { FileId } from "@excalidraw/element/types";
export type FileLoadingStatus = "loading" | "loaded" | "error";
export class FileStatusStore {
private static store = new VersionedSnapshotStore<
Map<FileId, FileLoadingStatus>
>(new Map());
static getSnapshot() {
return this.store.getSnapshot();
}
static pull(sinceVersion?: number) {
return this.store.pull(sinceVersion);
}
static updateStatuses(updates: Array<[FileId, FileLoadingStatus]>) {
if (!updates.length) {
return;
}
this.store.update((prev) => {
let changed = false;
const next = new Map(prev);
for (const [id, status] of updates) {
if (next.get(id) !== status) {
next.set(id, status);
changed = true;
}
}
return changed ? next : prev;
});
}
static getPendingCount(statuses: Map<FileId, FileLoadingStatus>) {
let pending = 0;
let total = 0;
for (const status of statuses.values()) {
total++;
if (status === "loading") {
pending++;
}
}
return { pending, total };
}
}
@@ -1,3 +1,9 @@
declare global {
interface Window {
debug: typeof Debug;
}
}
const lessPrecise = (num: number, precision = 5) => const lessPrecise = (num: number, precision = 5) =>
parseFloat(num.toPrecision(precision)); parseFloat(num.toPrecision(precision));
@@ -151,70 +157,6 @@ export class Debug {
return ret; return ret;
}; };
}; };
private static CHANGED_CACHE: Record<string, Record<string, unknown>> = {};
public static logChanged(name: string, obj: Record<string, unknown>) {
const prev = Debug.CHANGED_CACHE[name];
Debug.CHANGED_CACHE[name] = obj;
if (!prev) {
return;
}
const allKeys = new Set([...Object.keys(prev), ...Object.keys(obj)]);
const changed: Record<string, { prev: unknown; next: unknown }> = {};
for (const key of allKeys) {
const prevVal = prev[key];
const nextVal = obj[key];
if (!deepEqual(prevVal, nextVal)) {
changed[key] = { prev: prevVal, next: nextVal };
}
}
if (Object.keys(changed).length > 0) {
console.info(`[${name}] changed:`, changed);
}
}
}
function deepEqual(a: unknown, b: unknown): boolean {
if (Object.is(a, b)) {
return true;
}
if (
a === null ||
b === null ||
typeof a !== "object" ||
typeof b !== "object"
) {
return false;
}
if (Array.isArray(a) !== Array.isArray(b)) {
return false;
}
const keysA = Object.keys(a as Record<string, unknown>);
const keysB = Object.keys(b as Record<string, unknown>);
if (keysA.length !== keysB.length) {
return false;
}
for (const key of keysA) {
if (
!deepEqual(
(a as Record<string, unknown>)[key],
(b as Record<string, unknown>)[key],
)
) {
return false;
}
}
return true;
} }
//@ts-ignore
window.debug = Debug;
+125 -14
View File
@@ -69,6 +69,114 @@ vi.mock("socket.io-client", () => {
* i.e. multiplayer history tests could be a good first candidate, as we could test both history stacks simultaneously. * i.e. multiplayer history tests could be a good first candidate, as we could test both history stacks simultaneously.
*/ */
describe("collaboration", () => { describe("collaboration", () => {
it("should preserve future element fields across collab reconciliation", async () => {
await render(<ExcalidrawApp />);
const frame = API.createElement({
type: "frame",
id: "A",
width: 100,
height: 100,
x: 0,
y: 0,
backgroundColor: "#ff0000",
});
const frameWithFutureFields = {
...frame,
schemaState: {
tracks: {
...frame.schemaState.tracks,
"host.myapp.frame": 1,
},
},
futureField: "keep-me",
} as typeof frame & {
schemaState: { tracks: Record<string, number> };
futureField: string;
};
API.updateScene({
elements: [frameWithFutureFields],
captureUpdate: CaptureUpdateAction.IMMEDIATELY,
});
await waitFor(() => {
expect((h.elements[0] as any).futureField).toBe("keep-me");
expect((h.elements[0] as any).schemaState).toEqual(
frameWithFutureFields.schemaState,
);
expect(h.elements[0].backgroundColor).toBe("#ff0000");
});
const remoteMovedFrame = newElementWith(h.elements[0] as any, {
x: 120,
y: 80,
});
const reconciled = (window.collab as any)._reconcileElements([
remoteMovedFrame,
]);
expect(reconciled[0]).toEqual(
expect.objectContaining({
x: 120,
y: 80,
backgroundColor: "#ff0000",
}),
);
expect((reconciled[0] as any).futureField).toBe("keep-me");
expect((reconciled[0] as any).schemaState).toEqual(
frameWithFutureFields.schemaState,
);
});
it("should preserve future element fields on local edits before broadcast", async () => {
await render(<ExcalidrawApp />);
const rect = API.createElement({
type: "rectangle",
id: "A",
width: 100,
height: 100,
x: 0,
y: 0,
});
const rectWithFutureFields = {
...rect,
schemaState: {
tracks: {
...rect.schemaState.tracks,
"host.myapp.rect": 1,
},
},
futureField: { value: "keep-me" },
} as typeof rect & {
schemaState: { tracks: Record<string, number> };
futureField: { value: string };
};
API.updateScene({
elements: [rectWithFutureFields],
captureUpdate: CaptureUpdateAction.IMMEDIATELY,
});
const locallyEdited = newElementWith(h.elements[0] as any, { x: 200 });
API.updateScene({
elements: [locallyEdited],
captureUpdate: CaptureUpdateAction.NEVER,
});
await waitFor(() => {
expect((h.elements[0] as any).futureField).toEqual({ value: "keep-me" });
expect((h.elements[0] as any).schemaState).toEqual(
rectWithFutureFields.schemaState,
);
expect(h.elements[0]).toEqual(expect.objectContaining({ x: 200 }));
});
});
it("should emit two ephemeral increments even though updates get batched", async () => { it("should emit two ephemeral increments even though updates get batched", async () => {
const durableIncrements: DurableIncrement[] = []; const durableIncrements: DurableIncrement[] = [];
const ephemeralIncrements: EphemeralIncrement[] = []; const ephemeralIncrements: EphemeralIncrement[] = [];
@@ -83,14 +191,18 @@ describe("collaboration", () => {
} }
}); });
// eslint-disable-next-line dot-notation // Ensure this test starts from a deterministic scene regardless of previous
expect(h.store["scheduledMicroActions"].length).toBe(0); // test state restored from persistence.
expect(durableIncrements.length).toBe(0); API.updateScene({
expect(ephemeralIncrements.length).toBe(0); elements: [],
captureUpdate: CaptureUpdateAction.NEVER,
});
const durableBaseline = durableIncrements.length;
const ephemeralBaseline = ephemeralIncrements.length;
const rectProps = { const rectProps = {
type: "rectangle", type: "rectangle",
id: "A",
height: 200, height: 200,
width: 100, width: 100,
x: 0, x: 0,
@@ -105,8 +217,7 @@ describe("collaboration", () => {
}); });
await waitFor(() => { await waitFor(() => {
// expect(commitSpy).toHaveBeenCalledTimes(1); expect(durableIncrements.length).toBe(durableBaseline + 1);
expect(durableIncrements.length).toBe(1);
}); });
// simulate two batched remote updates // simulate two batched remote updates
@@ -130,13 +241,13 @@ describe("collaboration", () => {
// altough the updates get batched, // altough the updates get batched,
// we expect two ephemeral increments for each update, // we expect two ephemeral increments for each update,
// and each such update should have the expected change // and each such update should have the expected change
expect(ephemeralIncrements.length).toBe(2); expect(ephemeralIncrements.length).toBe(ephemeralBaseline + 2);
expect(ephemeralIncrements[0].change.elements.A).toEqual( expect(
expect.objectContaining({ x: 100 }), ephemeralIncrements[ephemeralBaseline].change.elements[rect.id],
); ).toEqual(expect.objectContaining({ x: 100 }));
expect(ephemeralIncrements[1].change.elements.A).toEqual( expect(
expect.objectContaining({ x: 200 }), ephemeralIncrements[ephemeralBaseline + 1].change.elements[rect.id],
); ).toEqual(expect.objectContaining({ x: 200 }));
// eslint-disable-next-line dot-notation // eslint-disable-next-line dot-notation
expect(h.store["scheduledMicroActions"].length).toBe(0); expect(h.store["scheduledMicroActions"].length).toBe(0);
}); });
+20 -1
View File
@@ -1,4 +1,5 @@
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";
@@ -30,10 +31,28 @@ 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]); }, [appTheme, editorTheme, setAppTheme]);
useLayoutEffect(() => { useLayoutEffect(() => {
localStorage.setItem(STORAGE_KEYS.LOCAL_STORAGE_THEME, appTheme); localStorage.setItem(STORAGE_KEYS.LOCAL_STORAGE_THEME, appTheme);
+1 -24
View File
@@ -75,20 +75,6 @@ export default defineConfig(({ mode }) => {
find: /^@excalidraw\/utils\/(.*?)/, find: /^@excalidraw\/utils\/(.*?)/,
replacement: path.resolve(__dirname, "../packages/utils/src/$1"), replacement: path.resolve(__dirname, "../packages/utils/src/$1"),
}, },
{
find: /^@excalidraw\/fractional-indexing$/,
replacement: path.resolve(
__dirname,
"../packages/fractional-indexing/src/index.ts",
),
},
{
find: /^@excalidraw\/laser-pointer$/,
replacement: path.resolve(
__dirname,
"../packages/laser-pointer/src/index.ts",
),
},
], ],
}, },
build: { build: {
@@ -120,10 +106,6 @@ export default defineConfig(({ mode }) => {
if (id.includes("@excalidraw/mermaid-to-excalidraw")) { if (id.includes("@excalidraw/mermaid-to-excalidraw")) {
return "mermaid-to-excalidraw"; return "mermaid-to-excalidraw";
} }
if (id.includes("@codemirror/") || id.includes("@lezer/")) {
return "codemirror.chunk";
}
}, },
}, },
}, },
@@ -168,11 +150,6 @@ export default defineConfig(({ mode }) => {
"**/locales/**", "**/locales/**",
"service-worker.js", "service-worker.js",
"**/*.chunk-*.js", "**/*.chunk-*.js",
// CodeMirrorEditor can't be assigned a `.chunk` name via
// manualChunks because Rollup would hoist shared deps (React)
// via a static import from the main bundle, defeating lazy
// loading. So we exclude it by name instead.
"**/CodeMirrorEditor-*.js",
], ],
runtimeCaching: [ runtimeCaching: [
{ {
@@ -212,7 +189,7 @@ export default defineConfig(({ mode }) => {
}, },
}, },
{ {
urlPattern: new RegExp("(.chunk-.+|CodeMirrorEditor-.+)\\.js"), urlPattern: new RegExp(".chunk-.+.js"),
handler: "CacheFirst", handler: "CacheFirst",
options: { options: {
cacheName: "chunk", cacheName: "chunk",
+1 -3
View File
@@ -56,9 +56,7 @@
"build:element": "yarn --cwd ./packages/element build:esm", "build:element": "yarn --cwd ./packages/element build:esm",
"build:excalidraw": "yarn --cwd ./packages/excalidraw build:esm", "build:excalidraw": "yarn --cwd ./packages/excalidraw build:esm",
"build:math": "yarn --cwd ./packages/math build:esm", "build:math": "yarn --cwd ./packages/math build:esm",
"build:fractional-indexing": "yarn --cwd ./packages/fractional-indexing build:esm", "build:packages": "yarn build:common && yarn build:math && yarn build:element && yarn build:excalidraw",
"build:laser-pointer": "yarn --cwd ./packages/laser-pointer build:esm",
"build:packages": "yarn build:common && yarn build:fractional-indexing && yarn build:laser-pointer && 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",
-74
View File
@@ -1,74 +0,0 @@
import { AppEventBus } from "./appEventBus";
type TestEvents = {
initialize: [api: number];
pointerUp: [pointerId: string];
viewState: [zoom: number];
};
const behavior = {
initialize: { cardinality: "once", replay: "last" },
pointerUp: { cardinality: "many", replay: "none" },
viewState: { cardinality: "many", replay: "last" },
} as const;
const flushMicrotasks = async () => Promise.resolve();
describe("AppEventBus", () => {
it("replays once events to late callback and Promise subscribers", async () => {
const bus = new AppEventBus<TestEvents, typeof behavior>(behavior);
bus.emit("initialize", 42);
const calls: number[] = [];
bus.on("initialize", (value) => {
calls.push(value);
});
expect(calls).toEqual([]);
await flushMicrotasks();
expect(calls).toEqual([42]);
await expect(bus.on("initialize")).resolves.toBe(42);
});
it("does not replay stream events to late subscribers", async () => {
const bus = new AppEventBus<TestEvents, typeof behavior>(behavior);
bus.emit("pointerUp", "first");
const calls: string[] = [];
bus.on("pointerUp", (pointerId) => {
calls.push(pointerId);
});
await flushMicrotasks();
expect(calls).toEqual([]);
bus.emit("pointerUp", "second");
expect(calls).toEqual(["second"]);
});
it("replays replay-last stream events and stays subscribed", async () => {
const bus = new AppEventBus<TestEvents, typeof behavior>(behavior);
bus.emit("viewState", 1);
const calls: number[] = [];
bus.on("viewState", (zoom) => {
calls.push(zoom);
});
await flushMicrotasks();
expect(calls).toEqual([1]);
bus.emit("viewState", 2);
expect(calls).toEqual([1, 2]);
});
it("throws when emitting a once event twice", () => {
const bus = new AppEventBus<TestEvents, typeof behavior>(behavior);
bus.emit("initialize", 1);
expect(() => {
bus.emit("initialize", 2);
}).toThrow('Event "initialize" can only be emitted once');
});
});
-136
View File
@@ -1,136 +0,0 @@
import type { UnsubscribeCallback } from "@excalidraw/excalidraw/types";
import { Emitter } from "./emitter";
import { isProdEnv } from "./utils";
export type AppEventPayloadMap = Record<string, unknown[]>;
export type AppEventBehavior = {
cardinality: "once" | "many";
replay: "none" | "last";
};
export type AppEventBehaviorMap<Events extends AppEventPayloadMap> = {
[K in keyof Events]: AppEventBehavior;
};
type AwaitableAppEventKeys<
Events extends AppEventPayloadMap,
Behavior extends AppEventBehaviorMap<Events>,
> = {
[K in keyof Events]: Behavior[K]["cardinality"] extends "once"
? Behavior[K]["replay"] extends "last"
? K
: never
: never;
}[keyof Events];
type AppEventPromiseValue<Args extends any[]> = Args extends [infer Only]
? Only
: Args;
export class AppEventBus<
Events extends AppEventPayloadMap,
Behavior extends AppEventBehaviorMap<Events>,
> {
private readonly emitters = new Map<keyof Events, Emitter<any>>();
private readonly lastPayload = new Map<keyof Events, any[]>();
private readonly emittedOnce = new Set<keyof Events>();
constructor(private readonly behavior: Behavior) {}
private getEmitter<K extends keyof Events>(name: K): Emitter<Events[K]> {
let emitter = this.emitters.get(name);
if (!emitter) {
emitter = new Emitter<any>();
this.emitters.set(name, emitter);
}
return emitter as Emitter<Events[K]>;
}
private toPromiseValue<Args extends any[]>(
args: Args,
): AppEventPromiseValue<Args> {
return (args.length === 1 ? args[0] : args) as AppEventPromiseValue<Args>;
}
public on<K extends keyof Events>(
name: K,
callback: (...args: Events[K]) => void,
): UnsubscribeCallback;
public on<K extends AwaitableAppEventKeys<Events, Behavior>>(
name: K,
): Promise<AppEventPromiseValue<Events[K]>>;
public on<K extends keyof Events>(
name: K,
callback?: (...args: Events[K]) => void,
): UnsubscribeCallback | Promise<AppEventPromiseValue<Events[K]>> {
const eventBehavior = this.behavior[name];
const cachedPayload = this.lastPayload.get(name) as Events[K] | undefined;
if (callback) {
if (eventBehavior.replay === "last" && cachedPayload) {
queueMicrotask(() => callback(...cachedPayload));
if (eventBehavior.cardinality === "once") {
return () => {};
}
}
return this.getEmitter(name).on(callback);
}
if (
eventBehavior.cardinality !== "once" ||
eventBehavior.replay !== "last"
) {
throw new Error(`Event "${String(name)}" requires a callback`);
}
if (cachedPayload) {
return Promise.resolve(this.toPromiseValue(cachedPayload));
}
return new Promise<AppEventPromiseValue<Events[K]>>((resolve) => {
this.getEmitter(name).once((...args: Events[K]) => {
resolve(this.toPromiseValue(args));
});
});
}
public emit<K extends keyof Events>(name: K, ...args: Events[K]) {
const eventBehavior = this.behavior[name];
if (!isProdEnv()) {
if (eventBehavior.cardinality === "once") {
if (this.emittedOnce.has(name)) {
throw new Error(`Event "${String(name)}" can only be emitted once`);
}
this.emittedOnce.add(name);
}
}
if (eventBehavior.replay === "last") {
this.lastPayload.set(name, args);
}
try {
this.getEmitter(name).trigger(...args);
} finally {
if (eventBehavior.cardinality === "once") {
this.getEmitter(name).clear();
}
}
}
public clear() {
this.lastPayload.clear();
this.emittedOnce.clear();
for (const emitter of this.emitters.values()) {
emitter.clear();
}
this.emitters.clear();
}
}
+16 -19
View File
@@ -80,11 +80,7 @@ const cssInvert = (
return { r: invertedR, g: invertedG, b: invertedB }; return { r: invertedR, g: invertedG, b: invertedB };
}; };
export const applyDarkModeFilter = (color: string, enable = true): string => { export const applyDarkModeFilter = (color: string): 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;
@@ -244,21 +240,22 @@ export const DEFAULT_ELEMENT_BACKGROUND_COLOR_PALETTE = {
// ----------------------------------------------------------------------------- // -----------------------------------------------------------------------------
// !!!MUST BE WITHOUT GRAY, TRANSPARENT AND BLACK!!! // !!!MUST BE WITHOUT GRAY, TRANSPARENT AND BLACK!!!
export const getAllColorsSpecificShade = (index: 0 | 1 | 2 | 3 | 4) => [ export const getAllColorsSpecificShade = (index: 0 | 1 | 2 | 3 | 4) =>
// 2nd row [
COLOR_PALETTE.cyan[index], // 2nd row
COLOR_PALETTE.blue[index], COLOR_PALETTE.cyan[index],
COLOR_PALETTE.violet[index], COLOR_PALETTE.blue[index],
COLOR_PALETTE.grape[index], COLOR_PALETTE.violet[index],
COLOR_PALETTE.pink[index], COLOR_PALETTE.grape[index],
COLOR_PALETTE.pink[index],
// 3rd row // 3rd row
COLOR_PALETTE.green[index], COLOR_PALETTE.green[index],
COLOR_PALETTE.teal[index], COLOR_PALETTE.teal[index],
COLOR_PALETTE.yellow[index], COLOR_PALETTE.yellow[index],
COLOR_PALETTE.orange[index], COLOR_PALETTE.orange[index],
COLOR_PALETTE.red[index], COLOR_PALETTE.red[index],
]; ] as const;
// ----------------------------------------------------------------------------- // -----------------------------------------------------------------------------
// other helpers // other helpers
+7 -47
View File
@@ -337,10 +337,9 @@ export const MAX_DECIMALS_FOR_SVG_EXPORT = 2;
export const EXPORT_SCALES = [1, 2, 3]; export const EXPORT_SCALES = [1, 2, 3];
export const DEFAULT_EXPORT_PADDING = 10; // px export const DEFAULT_EXPORT_PADDING = 10; // px
export const DEFAULT_IMAGE_OPTIONS: AppProps["imageOptions"] = { export const DEFAULT_MAX_IMAGE_WIDTH_OR_HEIGHT = 1440;
maxWidthOrHeight: 1440,
maxFileSizeBytes: 4 * 1024 * 1024, export const MAX_ALLOWED_FILE_BYTES = 4 * 1024 * 1024;
};
export const SVG_NS = "http://www.w3.org/2000/svg"; export const SVG_NS = "http://www.w3.org/2000/svg";
export const SVG_DOCUMENT_PREAMBLE = `<?xml version="1.0" standalone="no"?> export const SVG_DOCUMENT_PREAMBLE = `<?xml version="1.0" standalone="no"?>
@@ -404,47 +403,11 @@ export const ROUGHNESS = {
cartoonist: 2, cartoonist: 2,
} as const; } as const;
export type StrokeWidthKey = "thin" | "medium" | "bold"; export const STROKE_WIDTH = {
export const STROKE_WIDTH_KEYS: readonly StrokeWidthKey[] = [
"thin",
"medium",
"bold",
];
export const STROKE_WIDTH: Readonly<
Record<StrokeWidthKey | "extraBold", ExcalidrawElement["strokeWidth"]>
> = {
thin: 1, thin: 1,
medium: 2,
bold: 4,
extraBold: 8, // unused (may be introduced in the future)
};
// freedraw schema 2.0 uses thinner stroke, but to maintain backwards and
// forwards compatibility, instead of changing the shape renderer, we scale
// the stroke width by 1/2 (previous, thin was 1, medium 2 etc.)
//
// note that in the UI, STROKE_WIDTH.thin == FREEDRAW_STROKE_WIDTH.thin still
export const FREEDRAW_STROKE_WIDTH: Readonly<
Record<StrokeWidthKey | "extraBold", ExcalidrawElement["strokeWidth"]>
> = {
thin: 0.5,
medium: 1,
bold: 2, bold: 2,
extraBold: 4, // legacy (may be used again in the future) extraBold: 4,
}; } as const;
export const getStrokeWidthByKey = (
elementType: ExcalidrawElement["type"],
strokeWidthKey: StrokeWidthKey,
): ExcalidrawElement["strokeWidth"] => {
return elementType === "freedraw"
? FREEDRAW_STROKE_WIDTH[strokeWidthKey]
: STROKE_WIDTH[strokeWidthKey];
};
export const DEFAULT_ELEMENT_STROKE_WIDTH_KEY: StrokeWidthKey = "medium";
export const DEFAULT_ELEMENT_PROPS: { export const DEFAULT_ELEMENT_PROPS: {
strokeColor: ExcalidrawElement["strokeColor"]; strokeColor: ExcalidrawElement["strokeColor"];
@@ -459,7 +422,7 @@ export const DEFAULT_ELEMENT_PROPS: {
strokeColor: COLOR_PALETTE.black, strokeColor: COLOR_PALETTE.black,
backgroundColor: COLOR_PALETTE.transparent, backgroundColor: COLOR_PALETTE.transparent,
fillStyle: "solid", fillStyle: "solid",
strokeWidth: STROKE_WIDTH[DEFAULT_ELEMENT_STROKE_WIDTH_KEY], strokeWidth: 2,
strokeStyle: "solid", strokeStyle: "solid",
roughness: ROUGHNESS.artist, roughness: ROUGHNESS.artist,
opacity: 100, opacity: 100,
@@ -550,6 +513,3 @@ export const BIND_MODE_TIMEOUT = 700; // ms
export const MOBILE_ACTION_BUTTON_BG = { export const MOBILE_ACTION_BUTTON_BG = {
background: "var(--mobile-action-button-bg)", background: "var(--mobile-action-button-bg)",
} as const; } as const;
export const DEFAULT_STROKE_STREAMLINE = 0.5;
export const DEFAULT_STROKE_STREAMLINE_PRECISE = 0.2;
-3
View File
@@ -11,7 +11,4 @@ export * from "./random";
export * from "./url"; export * from "./url";
export * from "./utils"; export * from "./utils";
export * from "./emitter"; export * from "./emitter";
export * from "./appEventBus";
export * from "./editorInterface"; export * from "./editorInterface";
export * from "./versionedSnapshotStore";
export { Debug } from "../debug";
-89
View File
@@ -3,12 +3,6 @@ import {
mapFind, mapFind,
reduceToCommonValue, reduceToCommonValue,
} from "@excalidraw/common"; } from "@excalidraw/common";
import { vi } from "vitest";
// Import directly to avoid the @excalidraw/common throttleRAF mock from setupTests.ts.
import { throttleRAF } from "./utils";
type RafCallback = FrameRequestCallback;
describe("@excalidraw/common/utils", () => { describe("@excalidraw/common/utils", () => {
describe("isTransparent()", () => { describe("isTransparent()", () => {
@@ -85,87 +79,4 @@ describe("@excalidraw/common/utils", () => {
expect(mapFind([1, 2], () => null)).toBe(undefined); expect(mapFind([1, 2], () => null)).toBe(undefined);
}); });
}); });
describe("throttleRAF()", () => {
let frameCallbacks: Map<number, RafCallback>;
let nextFrameId: number;
const runScheduledFrame = (timestamp = 16) => {
const callbacks = [...frameCallbacks.values()];
frameCallbacks.clear();
callbacks.forEach((callback) => callback(timestamp));
};
beforeEach(() => {
frameCallbacks = new Map();
nextFrameId = 0;
vi.spyOn(window, "requestAnimationFrame").mockImplementation(
(callback) => {
const frameId = ++nextFrameId;
frameCallbacks.set(frameId, callback);
return frameId;
},
);
vi.spyOn(window, "cancelAnimationFrame").mockImplementation((frameId) => {
frameCallbacks.delete(frameId);
});
});
afterEach(() => {
vi.restoreAllMocks();
});
it("should invoke the callback with the last args from the same frame", () => {
const fn = vi.fn();
const throttled = throttleRAF(fn);
throttled("first", 1);
throttled("second", 2);
throttled("last", 3);
expect(fn).not.toHaveBeenCalled();
expect(window.requestAnimationFrame).toHaveBeenCalledTimes(1);
runScheduledFrame();
expect(fn).toHaveBeenCalledTimes(1);
expect(fn).toHaveBeenCalledWith("last", 3);
});
it("should flush the pending callback immediately", () => {
const fn = vi.fn();
const throttled = throttleRAF(fn);
throttled("first");
throttled("last");
throttled.flush();
expect(window.cancelAnimationFrame).toHaveBeenCalledTimes(1);
expect(fn).toHaveBeenCalledTimes(1);
expect(fn).toHaveBeenCalledWith("last");
runScheduledFrame();
expect(fn).toHaveBeenCalledTimes(1);
});
it("should cancel the pending callback", () => {
const fn = vi.fn();
const throttled = throttleRAF(fn);
throttled("first");
throttled("last");
throttled.cancel();
expect(window.cancelAnimationFrame).toHaveBeenCalledTimes(1);
runScheduledFrame();
expect(fn).not.toHaveBeenCalled();
});
});
}); });
+153 -23
View File
@@ -1,7 +1,5 @@
import { average } from "@excalidraw/math"; import { average } from "@excalidraw/math";
import type { GlobalCoord } from "@excalidraw/math";
import type { FontFamilyValues, FontString } from "@excalidraw/element/types"; import type { FontFamilyValues, FontString } from "@excalidraw/element/types";
import type { import type {
@@ -88,8 +86,7 @@ export const isWritableElement = (
(target.type === "text" || (target.type === "text" ||
target.type === "number" || target.type === "number" ||
target.type === "password" || target.type === "password" ||
target.type === "search")) || target.type === "search"));
(target instanceof HTMLElement && target.closest(".cm-editor") !== null);
export const getFontFamilyString = ({ export const getFontFamilyString = ({
fontFamily, fontFamily,
@@ -151,27 +148,38 @@ export const debounce = <T extends any[]>(
return ret; return ret;
}; };
// throttle callback to execute once per animation frame using the latest args // throttle callback to execute once per animation frame
export const throttleRAF = <T extends any[]>(fn: (...args: T) => void) => { export const throttleRAF = <T extends any[]>(
fn: (...args: T) => void,
opts?: { trailing?: boolean },
) => {
let timerId: number | null = null; let timerId: number | null = null;
let lastArgs: T | null = null; let lastArgs: T | null = null;
let lastArgsTrailing: T | null = null;
const scheduleFunc = () => { const scheduleFunc = (args: T) => {
timerId = window.requestAnimationFrame(() => { timerId = window.requestAnimationFrame(() => {
timerId = null; timerId = null;
const args = lastArgs; fn(...args);
lastArgs = null; lastArgs = null;
if (lastArgsTrailing) {
if (args) { lastArgs = lastArgsTrailing;
fn(...args); lastArgsTrailing = null;
scheduleFunc(lastArgs);
} }
}); });
}; };
const ret = (...args: T) => { const ret = (...args: T) => {
if (isTestEnv()) {
fn(...args);
return;
}
lastArgs = args; lastArgs = args;
if (timerId === null) { if (timerId === null) {
scheduleFunc(); scheduleFunc(lastArgs);
} else if (opts?.trailing) {
lastArgsTrailing = args;
} }
}; };
ret.flush = () => { ret.flush = () => {
@@ -180,12 +188,12 @@ export const throttleRAF = <T extends any[]>(fn: (...args: T) => void) => {
timerId = null; timerId = null;
} }
if (lastArgs) { if (lastArgs) {
fn(...lastArgs); fn(...(lastArgsTrailing || lastArgs));
lastArgs = null; lastArgs = lastArgsTrailing = null;
} }
}; };
ret.cancel = () => { ret.cancel = () => {
lastArgs = null; lastArgs = lastArgsTrailing = null;
if (timerId !== null) { if (timerId !== null) {
cancelAnimationFrame(timerId); cancelAnimationFrame(timerId);
timerId = null; timerId = null;
@@ -204,6 +212,135 @@ export const easeOut = (k: number) => {
return 1 - Math.pow(1 - k, 4); return 1 - Math.pow(1 - k, 4);
}; };
const easeOutInterpolate = (from: number, to: number, progress: number) => {
return (to - from) * easeOut(progress) + from;
};
/**
* Animates values from `fromValues` to `toValues` using the requestAnimationFrame API.
* Executes the `onStep` callback on each step with the interpolated values.
* Returns a function that can be called to cancel the animation.
*
* @example
* // Example usage:
* const fromValues = { x: 0, y: 0 };
* const toValues = { x: 100, y: 200 };
* const onStep = ({x, y}) => {
* setState(x, y)
* };
* const onCancel = () => {
* console.log("Animation canceled");
* };
*
* const cancelAnimation = easeToValuesRAF({
* fromValues,
* toValues,
* onStep,
* onCancel,
* });
*
* // To cancel the animation:
* cancelAnimation();
*/
export const easeToValuesRAF = <
T extends Record<keyof T, number>,
K extends keyof T,
>({
fromValues,
toValues,
onStep,
duration = 250,
interpolateValue,
onStart,
onEnd,
onCancel,
}: {
fromValues: T;
toValues: T;
/**
* Interpolate a single value.
* Return undefined to be handled by the default interpolator.
*/
interpolateValue?: (
fromValue: number,
toValue: number,
/** no easing applied */
progress: number,
key: K,
) => number | undefined;
onStep: (values: T) => void;
duration?: number;
onStart?: () => void;
onEnd?: () => void;
onCancel?: () => void;
}) => {
let canceled = false;
let frameId = 0;
let startTime: number;
function step(timestamp: number) {
if (canceled) {
return;
}
if (startTime === undefined) {
startTime = timestamp;
onStart?.();
}
const elapsed = Math.min(timestamp - startTime, duration);
const factor = easeOut(elapsed / duration);
const newValues = {} as T;
Object.keys(fromValues).forEach((key) => {
const _key = key as keyof T;
const result = ((toValues[_key] - fromValues[_key]) * factor +
fromValues[_key]) as T[keyof T];
newValues[_key] = result;
});
onStep(newValues);
if (elapsed < duration) {
const progress = elapsed / duration;
const newValues = {} as T;
Object.keys(fromValues).forEach((key) => {
const _key = key as K;
const startValue = fromValues[_key];
const endValue = toValues[_key];
let result;
result = interpolateValue
? interpolateValue(startValue, endValue, progress, _key)
: easeOutInterpolate(startValue, endValue, progress);
if (result == null) {
result = easeOutInterpolate(startValue, endValue, progress);
}
newValues[_key] = result as T[K];
});
onStep(newValues);
frameId = window.requestAnimationFrame(step);
} else {
onStep(toValues);
onEnd?.();
}
}
frameId = window.requestAnimationFrame(step);
return () => {
onCancel?.();
canceled = true;
window.cancelAnimationFrame(frameId);
};
};
// https://github.com/lodash/lodash/blob/es/chunk.js // https://github.com/lodash/lodash/blob/es/chunk.js
export const chunk = <T extends any>( export const chunk = <T extends any>(
array: readonly T[], array: readonly T[],
@@ -304,7 +441,7 @@ export const viewportCoordsToSceneCoords = (
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; return { x, y };
}; };
export const sceneCoordsToViewportCoords = ( export const sceneCoordsToViewportCoords = (
@@ -1193,10 +1330,3 @@ export const setFeatureFlag = <F extends keyof FEATURE_FLAGS>(
console.error("unable to set feature flag", e); console.error("unable to set feature flag", e);
} }
}; };
export const oneOf = <N extends string | number | symbol | null, H extends N>(
needle: N,
haystack: readonly H[],
): needle is H => {
return haystack.includes(needle as any);
};
@@ -1,70 +0,0 @@
export type VersionedSnapshot<T> = Readonly<{
version: number;
value: T;
}>;
export class VersionedSnapshotStore<T> {
private version = 0;
private value: T;
private readonly waiters = new Set<
(snapshot: VersionedSnapshot<T>) => void
>();
private readonly subscribers = new Set<
(snapshot: VersionedSnapshot<T>) => void
>();
constructor(
initialValue: T,
private readonly isEqual: (prev: T, next: T) => boolean = Object.is,
) {
this.value = initialValue;
}
public getSnapshot(): VersionedSnapshot<T> {
return { version: this.version, value: this.value };
}
public set(nextValue: T): boolean {
if (this.isEqual(this.value, nextValue)) {
return false;
}
this.value = nextValue;
this.version += 1;
const snapshot = this.getSnapshot();
for (const subscriber of this.subscribers) {
subscriber(snapshot);
}
for (const waiter of this.waiters) {
waiter(snapshot);
}
this.waiters.clear();
return true;
}
public update(updater: (prev: T) => T): boolean {
return this.set(updater(this.value));
}
public subscribe(
subscriber: (snapshot: VersionedSnapshot<T>) => void,
): () => void {
this.subscribers.add(subscriber);
return () => {
this.subscribers.delete(subscriber);
};
}
public pull(sinceVersion = -1): Promise<VersionedSnapshot<T>> {
if (this.version !== sinceVersion) {
return Promise.resolve(this.getSnapshot());
}
return new Promise((resolve) => {
this.waiters.add(resolve);
});
}
}
-109
View File
@@ -1,109 +0,0 @@
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);
});
});
});
+1 -2
View File
@@ -1,8 +1,7 @@
{ {
"extends": "../tsconfig.base.json", "extends": "../tsconfig.base.json",
"compilerOptions": { "compilerOptions": {
"outDir": "./dist/types", "outDir": "./dist/types"
"rootDir": "../"
}, },
"include": ["src/**/*", "global.d.ts"], "include": ["src/**/*", "global.d.ts"],
"exclude": ["**/*.test.*", "tests", "types", "examples", "dist"] "exclude": ["**/*.test.*", "tests", "types", "examples", "dist"]
+1 -2
View File
@@ -64,7 +64,6 @@
}, },
"dependencies": { "dependencies": {
"@excalidraw/common": "0.18.0", "@excalidraw/common": "0.18.0",
"@excalidraw/math": "0.18.0", "@excalidraw/math": "0.18.0"
"@excalidraw/fractional-indexing": "3.3.0"
} }
} }
+36 -14
View File
@@ -338,18 +338,27 @@ export class Scene {
this.callbacks.clear(); this.callbacks.clear();
} }
/** low-level - generally use app.insertNewElements() */ insertElementAtIndex(element: ExcalidrawElement, index: number) {
insertElementsAtIndex( if (!Number.isFinite(index) || index < 0) {
elements: ExcalidrawElement[], throw new Error(
/** null indicates end of the array */ "insertElementAtIndex can only be called with index >= 0",
index: number | null, );
) {
if (!elements.length) {
return;
} }
if (index === null) { const nextElements = [
index = this.elements.length; ...this.elements.slice(0, index),
element,
...this.elements.slice(index),
];
syncMovedIndices(nextElements, arrayToMap([element]));
this.replaceAllElements(nextElements);
}
insertElementsAtIndex(elements: ExcalidrawElement[], index: number) {
if (!elements.length) {
return;
} }
if (!Number.isFinite(index) || index < 0) { if (!Number.isFinite(index) || index < 0) {
@@ -369,9 +378,24 @@ export class Scene {
this.replaceAllElements(nextElements); this.replaceAllElements(nextElements);
} }
/** low-level - generally use app.insertNewElement() */
insertElement = (element: ExcalidrawElement) => { insertElement = (element: ExcalidrawElement) => {
this.insertElementsAtIndex([element], null); const index = element.frameId
? this.getElementIndex(element.frameId)
: this.elements.length;
this.insertElementAtIndex(element, index);
};
insertElements = (elements: ExcalidrawElement[]) => {
if (!elements.length) {
return;
}
const index = elements[0]?.frameId
? this.getElementIndex(elements[0].frameId)
: this.elements.length;
this.insertElementsAtIndex(elements, index);
}; };
getElementIndex(elementId: string) { getElementIndex(elementId: string) {
@@ -414,8 +438,6 @@ export class Scene {
options: { options: {
informMutation: boolean; informMutation: boolean;
isDragging: boolean; isDragging: boolean;
isBindingEnabled?: boolean;
isMidpointSnappingEnabled?: boolean;
} = { } = {
informMutation: true, informMutation: true,
isDragging: false, isDragging: false,
@@ -27,6 +27,9 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to existing s
"opacity": 100, "opacity": 100,
"roughness": 1, "roughness": 1,
"roundness": null, "roundness": null,
"schemaState": {
"tracks": {},
},
"seed": Any<Number>, "seed": Any<Number>,
"strokeColor": "#66a80f", "strokeColor": "#66a80f",
"strokeStyle": "solid", "strokeStyle": "solid",
@@ -64,6 +67,9 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to existing s
"opacity": 100, "opacity": 100,
"roughness": 1, "roughness": 1,
"roundness": null, "roundness": null,
"schemaState": {
"tracks": {},
},
"seed": Any<Number>, "seed": Any<Number>,
"strokeColor": "#9c36b5", "strokeColor": "#9c36b5",
"strokeStyle": "solid", "strokeStyle": "solid",
@@ -116,6 +122,9 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to existing s
], ],
"roughness": 1, "roughness": 1,
"roundness": null, "roundness": null,
"schemaState": {
"tracks": {},
},
"seed": Any<Number>, "seed": Any<Number>,
"startArrowhead": null, "startArrowhead": null,
"startBinding": { "startBinding": {
@@ -177,6 +186,9 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to existing s
], ],
"roughness": 1, "roughness": 1,
"roundness": null, "roundness": null,
"schemaState": {
"tracks": {},
},
"seed": Any<Number>, "seed": Any<Number>,
"startArrowhead": null, "startArrowhead": null,
"startBinding": { "startBinding": {
@@ -223,6 +235,9 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to existing s
"opacity": 100, "opacity": 100,
"roughness": 1, "roughness": 1,
"roundness": null, "roundness": null,
"schemaState": {
"tracks": {},
},
"seed": Any<Number>, "seed": Any<Number>,
"strokeColor": "#1e1e1e", "strokeColor": "#1e1e1e",
"strokeStyle": "solid", "strokeStyle": "solid",
@@ -266,6 +281,9 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to existing t
"originalText": "HEYYYYY", "originalText": "HEYYYYY",
"roughness": 1, "roughness": 1,
"roundness": null, "roundness": null,
"schemaState": {
"tracks": {},
},
"seed": Any<Number>, "seed": Any<Number>,
"strokeColor": "#c2255c", "strokeColor": "#c2255c",
"strokeStyle": "solid", "strokeStyle": "solid",
@@ -312,6 +330,9 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to existing t
"originalText": "Whats up ?", "originalText": "Whats up ?",
"roughness": 1, "roughness": 1,
"roundness": null, "roundness": null,
"schemaState": {
"tracks": {},
},
"seed": Any<Number>, "seed": Any<Number>,
"strokeColor": "#1e1e1e", "strokeColor": "#1e1e1e",
"strokeStyle": "solid", "strokeStyle": "solid",
@@ -372,6 +393,9 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to existing t
], ],
"roughness": 1, "roughness": 1,
"roundness": null, "roundness": null,
"schemaState": {
"tracks": {},
},
"seed": Any<Number>, "seed": Any<Number>,
"startArrowhead": null, "startArrowhead": null,
"startBinding": { "startBinding": {
@@ -419,6 +443,9 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to existing t
"originalText": "HELLO WORLD!!", "originalText": "HELLO WORLD!!",
"roughness": 1, "roughness": 1,
"roundness": null, "roundness": null,
"schemaState": {
"tracks": {},
},
"seed": Any<Number>, "seed": Any<Number>,
"strokeColor": "#1e1e1e", "strokeColor": "#1e1e1e",
"strokeStyle": "solid", "strokeStyle": "solid",
@@ -479,6 +506,9 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to shapes whe
], ],
"roughness": 1, "roughness": 1,
"roundness": null, "roundness": null,
"schemaState": {
"tracks": {},
},
"seed": Any<Number>, "seed": Any<Number>,
"startArrowhead": null, "startArrowhead": null,
"startBinding": { "startBinding": {
@@ -526,6 +556,9 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to shapes whe
"originalText": "HELLO WORLD!!", "originalText": "HELLO WORLD!!",
"roughness": 1, "roughness": 1,
"roundness": null, "roundness": null,
"schemaState": {
"tracks": {},
},
"seed": Any<Number>, "seed": Any<Number>,
"strokeColor": "#1e1e1e", "strokeColor": "#1e1e1e",
"strokeStyle": "solid", "strokeStyle": "solid",
@@ -566,6 +599,9 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to shapes whe
"opacity": 100, "opacity": 100,
"roughness": 1, "roughness": 1,
"roundness": null, "roundness": null,
"schemaState": {
"tracks": {},
},
"seed": Any<Number>, "seed": Any<Number>,
"strokeColor": "#1e1e1e", "strokeColor": "#1e1e1e",
"strokeStyle": "solid", "strokeStyle": "solid",
@@ -603,6 +639,9 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to shapes whe
"opacity": 100, "opacity": 100,
"roughness": 1, "roughness": 1,
"roundness": null, "roundness": null,
"schemaState": {
"tracks": {},
},
"seed": Any<Number>, "seed": Any<Number>,
"strokeColor": "#1e1e1e", "strokeColor": "#1e1e1e",
"strokeStyle": "solid", "strokeStyle": "solid",
@@ -660,6 +699,9 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to text when
], ],
"roughness": 1, "roughness": 1,
"roundness": null, "roundness": null,
"schemaState": {
"tracks": {},
},
"seed": Any<Number>, "seed": Any<Number>,
"startArrowhead": null, "startArrowhead": null,
"startBinding": { "startBinding": {
@@ -707,6 +749,9 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to text when
"originalText": "HELLO WORLD!!", "originalText": "HELLO WORLD!!",
"roughness": 1, "roughness": 1,
"roundness": null, "roundness": null,
"schemaState": {
"tracks": {},
},
"seed": Any<Number>, "seed": Any<Number>,
"strokeColor": "#1e1e1e", "strokeColor": "#1e1e1e",
"strokeStyle": "solid", "strokeStyle": "solid",
@@ -753,6 +798,9 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to text when
"originalText": "HEYYYYY", "originalText": "HEYYYYY",
"roughness": 1, "roughness": 1,
"roundness": null, "roundness": null,
"schemaState": {
"tracks": {},
},
"seed": Any<Number>, "seed": Any<Number>,
"strokeColor": "#1e1e1e", "strokeColor": "#1e1e1e",
"strokeStyle": "solid", "strokeStyle": "solid",
@@ -799,6 +847,9 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to text when
"originalText": "WHATS UP ?", "originalText": "WHATS UP ?",
"roughness": 1, "roughness": 1,
"roundness": null, "roundness": null,
"schemaState": {
"tracks": {},
},
"seed": Any<Number>, "seed": Any<Number>,
"strokeColor": "#1e1e1e", "strokeColor": "#1e1e1e",
"strokeStyle": "solid", "strokeStyle": "solid",
@@ -834,6 +885,9 @@ exports[`Test Transform > should not allow duplicate ids 1`] = `
"opacity": 100, "opacity": 100,
"roughness": 1, "roughness": 1,
"roundness": null, "roundness": null,
"schemaState": {
"tracks": {},
},
"seed": Any<Number>, "seed": Any<Number>,
"strokeColor": "#1e1e1e", "strokeColor": "#1e1e1e",
"strokeStyle": "solid", "strokeStyle": "solid",
@@ -879,6 +933,9 @@ exports[`Test Transform > should transform linear elements 1`] = `
], ],
"roughness": 1, "roughness": 1,
"roundness": null, "roundness": null,
"schemaState": {
"tracks": {},
},
"seed": Any<Number>, "seed": Any<Number>,
"startArrowhead": null, "startArrowhead": null,
"startBinding": null, "startBinding": null,
@@ -926,6 +983,9 @@ exports[`Test Transform > should transform linear elements 2`] = `
], ],
"roughness": 1, "roughness": 1,
"roundness": null, "roundness": null,
"schemaState": {
"tracks": {},
},
"seed": Any<Number>, "seed": Any<Number>,
"startArrowhead": "dot", "startArrowhead": "dot",
"startBinding": null, "startBinding": null,
@@ -973,6 +1033,9 @@ exports[`Test Transform > should transform linear elements 3`] = `
"polygon": false, "polygon": false,
"roughness": 1, "roughness": 1,
"roundness": null, "roundness": null,
"schemaState": {
"tracks": {},
},
"seed": Any<Number>, "seed": Any<Number>,
"startArrowhead": null, "startArrowhead": null,
"startBinding": null, "startBinding": null,
@@ -1020,6 +1083,9 @@ exports[`Test Transform > should transform linear elements 4`] = `
"polygon": false, "polygon": false,
"roughness": 1, "roughness": 1,
"roundness": null, "roundness": null,
"schemaState": {
"tracks": {},
},
"seed": Any<Number>, "seed": Any<Number>,
"startArrowhead": null, "startArrowhead": null,
"startBinding": null, "startBinding": null,
@@ -1054,6 +1120,9 @@ exports[`Test Transform > should transform regular shapes 1`] = `
"opacity": 100, "opacity": 100,
"roughness": 1, "roughness": 1,
"roundness": null, "roundness": null,
"schemaState": {
"tracks": {},
},
"seed": Any<Number>, "seed": Any<Number>,
"strokeColor": "#1e1e1e", "strokeColor": "#1e1e1e",
"strokeStyle": "solid", "strokeStyle": "solid",
@@ -1086,6 +1155,9 @@ exports[`Test Transform > should transform regular shapes 2`] = `
"opacity": 100, "opacity": 100,
"roughness": 1, "roughness": 1,
"roundness": null, "roundness": null,
"schemaState": {
"tracks": {},
},
"seed": Any<Number>, "seed": Any<Number>,
"strokeColor": "#1e1e1e", "strokeColor": "#1e1e1e",
"strokeStyle": "solid", "strokeStyle": "solid",
@@ -1118,6 +1190,9 @@ exports[`Test Transform > should transform regular shapes 3`] = `
"opacity": 100, "opacity": 100,
"roughness": 1, "roughness": 1,
"roundness": null, "roundness": null,
"schemaState": {
"tracks": {},
},
"seed": Any<Number>, "seed": Any<Number>,
"strokeColor": "#1e1e1e", "strokeColor": "#1e1e1e",
"strokeStyle": "solid", "strokeStyle": "solid",
@@ -1150,6 +1225,9 @@ exports[`Test Transform > should transform regular shapes 4`] = `
"opacity": 100, "opacity": 100,
"roughness": 1, "roughness": 1,
"roundness": null, "roundness": null,
"schemaState": {
"tracks": {},
},
"seed": Any<Number>, "seed": Any<Number>,
"strokeColor": "#1e1e1e", "strokeColor": "#1e1e1e",
"strokeStyle": "solid", "strokeStyle": "solid",
@@ -1182,6 +1260,9 @@ exports[`Test Transform > should transform regular shapes 5`] = `
"opacity": 100, "opacity": 100,
"roughness": 1, "roughness": 1,
"roundness": null, "roundness": null,
"schemaState": {
"tracks": {},
},
"seed": Any<Number>, "seed": Any<Number>,
"strokeColor": "#1e1e1e", "strokeColor": "#1e1e1e",
"strokeStyle": "dotted", "strokeStyle": "dotted",
@@ -1214,6 +1295,9 @@ exports[`Test Transform > should transform regular shapes 6`] = `
"opacity": 100, "opacity": 100,
"roughness": 1, "roughness": 1,
"roundness": null, "roundness": null,
"schemaState": {
"tracks": {},
},
"seed": Any<Number>, "seed": Any<Number>,
"strokeColor": "#1971c2", "strokeColor": "#1971c2",
"strokeStyle": "dashed", "strokeStyle": "dashed",
@@ -1252,6 +1336,9 @@ exports[`Test Transform > should transform text element 1`] = `
"originalText": "HELLO WORLD!", "originalText": "HELLO WORLD!",
"roughness": 1, "roughness": 1,
"roundness": null, "roundness": null,
"schemaState": {
"tracks": {},
},
"seed": Any<Number>, "seed": Any<Number>,
"strokeColor": "#1e1e1e", "strokeColor": "#1e1e1e",
"strokeStyle": "solid", "strokeStyle": "solid",
@@ -1293,6 +1380,9 @@ exports[`Test Transform > should transform text element 2`] = `
"originalText": "STYLED HELLO WORLD!", "originalText": "STYLED HELLO WORLD!",
"roughness": 1, "roughness": 1,
"roundness": null, "roundness": null,
"schemaState": {
"tracks": {},
},
"seed": Any<Number>, "seed": Any<Number>,
"strokeColor": "#5f3dc4", "strokeColor": "#5f3dc4",
"strokeStyle": "solid", "strokeStyle": "solid",
@@ -1339,6 +1429,9 @@ exports[`Test Transform > should transform the elements correctly when linear el
"opacity": 100, "opacity": 100,
"roughness": 1, "roughness": 1,
"roundness": null, "roundness": null,
"schemaState": {
"tracks": {},
},
"seed": Any<Number>, "seed": Any<Number>,
"strokeColor": "#1e1e1e", "strokeColor": "#1e1e1e",
"strokeStyle": "solid", "strokeStyle": "solid",
@@ -1378,6 +1471,9 @@ exports[`Test Transform > should transform the elements correctly when linear el
"opacity": 100, "opacity": 100,
"roughness": 1, "roughness": 1,
"roundness": null, "roundness": null,
"schemaState": {
"tracks": {},
},
"seed": Any<Number>, "seed": Any<Number>,
"strokeColor": "#1e1e1e", "strokeColor": "#1e1e1e",
"strokeStyle": "solid", "strokeStyle": "solid",
@@ -1421,6 +1517,9 @@ exports[`Test Transform > should transform the elements correctly when linear el
"opacity": 100, "opacity": 100,
"roughness": 1, "roughness": 1,
"roundness": null, "roundness": null,
"schemaState": {
"tracks": {},
},
"seed": Any<Number>, "seed": Any<Number>,
"strokeColor": "#1e1e1e", "strokeColor": "#1e1e1e",
"strokeStyle": "solid", "strokeStyle": "solid",
@@ -1468,6 +1567,9 @@ exports[`Test Transform > should transform the elements correctly when linear el
"opacity": 100, "opacity": 100,
"roughness": 1, "roughness": 1,
"roundness": null, "roundness": null,
"schemaState": {
"tracks": {},
},
"seed": Any<Number>, "seed": Any<Number>,
"strokeColor": "#1e1e1e", "strokeColor": "#1e1e1e",
"strokeStyle": "solid", "strokeStyle": "solid",
@@ -1527,6 +1629,9 @@ exports[`Test Transform > should transform the elements correctly when linear el
"roundness": { "roundness": {
"type": 2, "type": 2,
}, },
"schemaState": {
"tracks": {},
},
"seed": Any<Number>, "seed": Any<Number>,
"startArrowhead": null, "startArrowhead": null,
"startBinding": { "startBinding": {
@@ -1591,6 +1696,9 @@ exports[`Test Transform > should transform the elements correctly when linear el
"roundness": { "roundness": {
"type": 2, "type": 2,
}, },
"schemaState": {
"tracks": {},
},
"seed": Any<Number>, "seed": Any<Number>,
"startArrowhead": null, "startArrowhead": null,
"startBinding": { "startBinding": {
@@ -1640,6 +1748,9 @@ exports[`Test Transform > should transform the elements correctly when linear el
"originalText": "B", "originalText": "B",
"roughness": 1, "roughness": 1,
"roundness": null, "roundness": null,
"schemaState": {
"tracks": {},
},
"seed": Any<Number>, "seed": Any<Number>,
"strokeColor": "#1e1e1e", "strokeColor": "#1e1e1e",
"strokeStyle": "solid", "strokeStyle": "solid",
@@ -1683,6 +1794,9 @@ exports[`Test Transform > should transform the elements correctly when linear el
"originalText": "A", "originalText": "A",
"roughness": 1, "roughness": 1,
"roundness": null, "roundness": null,
"schemaState": {
"tracks": {},
},
"seed": Any<Number>, "seed": Any<Number>,
"strokeColor": "#1e1e1e", "strokeColor": "#1e1e1e",
"strokeStyle": "solid", "strokeStyle": "solid",
@@ -1726,6 +1840,9 @@ exports[`Test Transform > should transform the elements correctly when linear el
"originalText": "Alice", "originalText": "Alice",
"roughness": 1, "roughness": 1,
"roundness": null, "roundness": null,
"schemaState": {
"tracks": {},
},
"seed": Any<Number>, "seed": Any<Number>,
"strokeColor": "#1e1e1e", "strokeColor": "#1e1e1e",
"strokeStyle": "solid", "strokeStyle": "solid",
@@ -1769,6 +1886,9 @@ exports[`Test Transform > should transform the elements correctly when linear el
"originalText": "Bob", "originalText": "Bob",
"roughness": 1, "roughness": 1,
"roundness": null, "roundness": null,
"schemaState": {
"tracks": {},
},
"seed": Any<Number>, "seed": Any<Number>,
"strokeColor": "#1e1e1e", "strokeColor": "#1e1e1e",
"strokeStyle": "solid", "strokeStyle": "solid",
@@ -1810,6 +1930,9 @@ exports[`Test Transform > should transform the elements correctly when linear el
"originalText": "How are you?", "originalText": "How are you?",
"roughness": 1, "roughness": 1,
"roundness": null, "roundness": null,
"schemaState": {
"tracks": {},
},
"seed": Any<Number>, "seed": Any<Number>,
"strokeColor": "#1e1e1e", "strokeColor": "#1e1e1e",
"strokeStyle": "solid", "strokeStyle": "solid",
@@ -1851,6 +1974,9 @@ exports[`Test Transform > should transform the elements correctly when linear el
"originalText": "Friendship", "originalText": "Friendship",
"roughness": 1, "roughness": 1,
"roundness": null, "roundness": null,
"schemaState": {
"tracks": {},
},
"seed": Any<Number>, "seed": Any<Number>,
"strokeColor": "#1e1e1e", "strokeColor": "#1e1e1e",
"strokeStyle": "solid", "strokeStyle": "solid",
@@ -1904,6 +2030,9 @@ exports[`Test Transform > should transform to labelled arrows when label provide
], ],
"roughness": 1, "roughness": 1,
"roundness": null, "roundness": null,
"schemaState": {
"tracks": {},
},
"seed": Any<Number>, "seed": Any<Number>,
"startArrowhead": null, "startArrowhead": null,
"startBinding": null, "startBinding": null,
@@ -1956,6 +2085,9 @@ exports[`Test Transform > should transform to labelled arrows when label provide
], ],
"roughness": 1, "roughness": 1,
"roundness": null, "roundness": null,
"schemaState": {
"tracks": {},
},
"seed": Any<Number>, "seed": Any<Number>,
"startArrowhead": null, "startArrowhead": null,
"startBinding": null, "startBinding": null,
@@ -2008,6 +2140,9 @@ exports[`Test Transform > should transform to labelled arrows when label provide
], ],
"roughness": 1, "roughness": 1,
"roundness": null, "roundness": null,
"schemaState": {
"tracks": {},
},
"seed": Any<Number>, "seed": Any<Number>,
"startArrowhead": null, "startArrowhead": null,
"startBinding": null, "startBinding": null,
@@ -2060,6 +2195,9 @@ exports[`Test Transform > should transform to labelled arrows when label provide
], ],
"roughness": 1, "roughness": 1,
"roundness": null, "roundness": null,
"schemaState": {
"tracks": {},
},
"seed": Any<Number>, "seed": Any<Number>,
"startArrowhead": null, "startArrowhead": null,
"startBinding": null, "startBinding": null,
@@ -2100,6 +2238,9 @@ exports[`Test Transform > should transform to labelled arrows when label provide
"originalText": "LABELED ARROW", "originalText": "LABELED ARROW",
"roughness": 1, "roughness": 1,
"roundness": null, "roundness": null,
"schemaState": {
"tracks": {},
},
"seed": Any<Number>, "seed": Any<Number>,
"strokeColor": "#1e1e1e", "strokeColor": "#1e1e1e",
"strokeStyle": "solid", "strokeStyle": "solid",
@@ -2141,6 +2282,9 @@ exports[`Test Transform > should transform to labelled arrows when label provide
"originalText": "STYLED LABELED ARROW", "originalText": "STYLED LABELED ARROW",
"roughness": 1, "roughness": 1,
"roundness": null, "roundness": null,
"schemaState": {
"tracks": {},
},
"seed": Any<Number>, "seed": Any<Number>,
"strokeColor": "#099268", "strokeColor": "#099268",
"strokeStyle": "solid", "strokeStyle": "solid",
@@ -2182,6 +2326,9 @@ exports[`Test Transform > should transform to labelled arrows when label provide
"originalText": "ANOTHER STYLED LABELLED ARROW", "originalText": "ANOTHER STYLED LABELLED ARROW",
"roughness": 1, "roughness": 1,
"roundness": null, "roundness": null,
"schemaState": {
"tracks": {},
},
"seed": Any<Number>, "seed": Any<Number>,
"strokeColor": "#1098ad", "strokeColor": "#1098ad",
"strokeStyle": "solid", "strokeStyle": "solid",
@@ -2224,6 +2371,9 @@ exports[`Test Transform > should transform to labelled arrows when label provide
"originalText": "ANOTHER STYLED LABELLED ARROW", "originalText": "ANOTHER STYLED LABELLED ARROW",
"roughness": 1, "roughness": 1,
"roundness": null, "roundness": null,
"schemaState": {
"tracks": {},
},
"seed": Any<Number>, "seed": Any<Number>,
"strokeColor": "#099268", "strokeColor": "#099268",
"strokeStyle": "solid", "strokeStyle": "solid",
@@ -2265,6 +2415,9 @@ exports[`Test Transform > should transform to text containers when label provide
"opacity": 100, "opacity": 100,
"roughness": 1, "roughness": 1,
"roundness": null, "roundness": null,
"schemaState": {
"tracks": {},
},
"seed": Any<Number>, "seed": Any<Number>,
"strokeColor": "#1e1e1e", "strokeColor": "#1e1e1e",
"strokeStyle": "solid", "strokeStyle": "solid",
@@ -2302,6 +2455,9 @@ exports[`Test Transform > should transform to text containers when label provide
"opacity": 100, "opacity": 100,
"roughness": 1, "roughness": 1,
"roundness": null, "roundness": null,
"schemaState": {
"tracks": {},
},
"seed": Any<Number>, "seed": Any<Number>,
"strokeColor": "#1e1e1e", "strokeColor": "#1e1e1e",
"strokeStyle": "solid", "strokeStyle": "solid",
@@ -2339,6 +2495,9 @@ exports[`Test Transform > should transform to text containers when label provide
"opacity": 100, "opacity": 100,
"roughness": 1, "roughness": 1,
"roundness": null, "roundness": null,
"schemaState": {
"tracks": {},
},
"seed": Any<Number>, "seed": Any<Number>,
"strokeColor": "#1e1e1e", "strokeColor": "#1e1e1e",
"strokeStyle": "solid", "strokeStyle": "solid",
@@ -2376,6 +2535,9 @@ exports[`Test Transform > should transform to text containers when label provide
"opacity": 100, "opacity": 100,
"roughness": 1, "roughness": 1,
"roundness": null, "roundness": null,
"schemaState": {
"tracks": {},
},
"seed": Any<Number>, "seed": Any<Number>,
"strokeColor": "#1e1e1e", "strokeColor": "#1e1e1e",
"strokeStyle": "solid", "strokeStyle": "solid",
@@ -2413,6 +2575,9 @@ exports[`Test Transform > should transform to text containers when label provide
"opacity": 100, "opacity": 100,
"roughness": 1, "roughness": 1,
"roundness": null, "roundness": null,
"schemaState": {
"tracks": {},
},
"seed": Any<Number>, "seed": Any<Number>,
"strokeColor": "#c2255c", "strokeColor": "#c2255c",
"strokeStyle": "solid", "strokeStyle": "solid",
@@ -2450,6 +2615,9 @@ exports[`Test Transform > should transform to text containers when label provide
"opacity": 100, "opacity": 100,
"roughness": 1, "roughness": 1,
"roundness": null, "roundness": null,
"schemaState": {
"tracks": {},
},
"seed": Any<Number>, "seed": Any<Number>,
"strokeColor": "#f08c00", "strokeColor": "#f08c00",
"strokeStyle": "solid", "strokeStyle": "solid",
@@ -2488,6 +2656,9 @@ exports[`Test Transform > should transform to text containers when label provide
"originalText": "RECTANGLE TEXT CONTAINER", "originalText": "RECTANGLE TEXT CONTAINER",
"roughness": 1, "roughness": 1,
"roundness": null, "roundness": null,
"schemaState": {
"tracks": {},
},
"seed": Any<Number>, "seed": Any<Number>,
"strokeColor": "#1e1e1e", "strokeColor": "#1e1e1e",
"strokeStyle": "solid", "strokeStyle": "solid",
@@ -2529,6 +2700,9 @@ exports[`Test Transform > should transform to text containers when label provide
"originalText": "ELLIPSE TEXT CONTAINER", "originalText": "ELLIPSE TEXT CONTAINER",
"roughness": 1, "roughness": 1,
"roundness": null, "roundness": null,
"schemaState": {
"tracks": {},
},
"seed": Any<Number>, "seed": Any<Number>,
"strokeColor": "#1e1e1e", "strokeColor": "#1e1e1e",
"strokeStyle": "solid", "strokeStyle": "solid",
@@ -2572,6 +2746,9 @@ exports[`Test Transform > should transform to text containers when label provide
TEXT CONTAINER", TEXT CONTAINER",
"roughness": 1, "roughness": 1,
"roundness": null, "roundness": null,
"schemaState": {
"tracks": {},
},
"seed": Any<Number>, "seed": Any<Number>,
"strokeColor": "#1e1e1e", "strokeColor": "#1e1e1e",
"strokeStyle": "solid", "strokeStyle": "solid",
@@ -2615,6 +2792,9 @@ exports[`Test Transform > should transform to text containers when label provide
"originalText": "STYLED DIAMOND TEXT CONTAINER", "originalText": "STYLED DIAMOND TEXT CONTAINER",
"roughness": 1, "roughness": 1,
"roundness": null, "roundness": null,
"schemaState": {
"tracks": {},
},
"seed": Any<Number>, "seed": Any<Number>,
"strokeColor": "#099268", "strokeColor": "#099268",
"strokeStyle": "solid", "strokeStyle": "solid",
@@ -2657,6 +2837,9 @@ exports[`Test Transform > should transform to text containers when label provide
"originalText": "TOP LEFT ALIGNED RECTANGLE TEXT CONTAINER", "originalText": "TOP LEFT ALIGNED RECTANGLE TEXT CONTAINER",
"roughness": 1, "roughness": 1,
"roundness": null, "roundness": null,
"schemaState": {
"tracks": {},
},
"seed": Any<Number>, "seed": Any<Number>,
"strokeColor": "#c2255c", "strokeColor": "#c2255c",
"strokeStyle": "solid", "strokeStyle": "solid",
@@ -2700,6 +2883,9 @@ exports[`Test Transform > should transform to text containers when label provide
"originalText": "STYLED ELLIPSE TEXT CONTAINER", "originalText": "STYLED ELLIPSE TEXT CONTAINER",
"roughness": 1, "roughness": 1,
"roundness": null, "roundness": null,
"schemaState": {
"tracks": {},
},
"seed": Any<Number>, "seed": Any<Number>,
"strokeColor": "#c2255c", "strokeColor": "#c2255c",
"strokeStyle": "solid", "strokeStyle": "solid",
-32
View File
@@ -1,32 +0,0 @@
import type { Arrowhead, AnyArrowhead } from "./types";
export const normalizeArrowhead = (
arrowhead: AnyArrowhead | null | undefined,
): Arrowhead | null => {
switch (arrowhead) {
case undefined:
case null:
return null;
case "dot":
return "circle";
case "crowfoot_one":
return "cardinality_one";
case "crowfoot_many":
return "cardinality_many";
case "crowfoot_one_or_many":
return "cardinality_one_or_many";
default:
return arrowhead;
}
};
export const getArrowheadForPicker = (
arrowhead: AnyArrowhead | null | undefined,
): Arrowhead | null => {
const normalizedArrowhead = normalizeArrowhead(arrowhead);
if (normalizedArrowhead === null) {
return null;
}
return normalizedArrowhead;
};
+51 -102
View File
@@ -1,4 +1,5 @@
import { import {
KEYS,
arrayToMap, arrayToMap,
getFeatureFlag, getFeatureFlag,
invariant, invariant,
@@ -136,6 +137,12 @@ export const maxBindingDistance_simple = (zoom?: AppState["zoom"]): number => {
); );
}; };
export const shouldEnableBindingForPointerEvent = (
event: React.PointerEvent<HTMLElement>,
) => {
return !event[KEYS.CTRL_OR_CMD];
};
export const isBindingEnabled = (appState: { export const isBindingEnabled = (appState: {
isBindingEnabled: AppState["isBindingEnabled"]; isBindingEnabled: AppState["isBindingEnabled"];
}): boolean => { }): boolean => {
@@ -170,20 +177,8 @@ export const bindOrUnbindBindingElement = (
}, },
); );
bindOrUnbindBindingElementEdge( bindOrUnbindBindingElementEdge(arrow, start, "start", scene);
arrow, bindOrUnbindBindingElementEdge(arrow, end, "end", scene);
start,
"start",
scene,
appState.isBindingEnabled,
);
bindOrUnbindBindingElementEdge(
arrow,
end,
"end",
scene,
appState.isBindingEnabled,
);
if (start.focusPoint || end.focusPoint) { if (start.focusPoint || end.focusPoint) {
// If the strategy dictates a focus point override, then // If the strategy dictates a focus point override, then
// update the arrow points to point to the focus point. // update the arrow points to point to the focus point.
@@ -226,21 +221,12 @@ const bindOrUnbindBindingElementEdge = (
{ mode, element, focusPoint }: BindingStrategy, { mode, element, focusPoint }: BindingStrategy,
startOrEnd: "start" | "end", startOrEnd: "start" | "end",
scene: Scene, scene: Scene,
shouldSnapToOutline = true,
): void => { ): void => {
if (mode === null) { if (mode === null) {
// null means break the binding // null means break the binding
unbindBindingElement(arrow, startOrEnd, scene); unbindBindingElement(arrow, startOrEnd, scene);
} else if (mode !== undefined) { } else if (mode !== undefined) {
bindBindingElement( bindBindingElement(arrow, element, mode, startOrEnd, scene, focusPoint);
arrow,
element,
mode,
startOrEnd,
scene,
focusPoint,
shouldSnapToOutline,
);
} }
}; };
@@ -643,13 +629,10 @@ const getBindingStrategyForDraggingBindingElementEndpoints_simple = (
let start: BindingStrategy = { mode: undefined }; let start: BindingStrategy = { mode: undefined };
let end: BindingStrategy = { mode: undefined }; let end: BindingStrategy = { mode: undefined };
if (arrow.points.length < 2) { invariant(
console.error( arrow.points.length > 1,
"Attempting to bind a linear element with less than 2 points", "Do not attempt to bind linear elements with a single point",
); );
// 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) {
@@ -737,11 +720,12 @@ const getBindingStrategyForDraggingBindingElementEndpoints_simple = (
}); });
// Handle outside-outside binding to the same element // Handle outside-outside binding to the same element
if ( if (otherBinding && otherBinding.elementId === hit?.id) {
otherBinding && invariant(
otherBinding.elementId === hit?.id && !opts?.newArrow || appState.selectedLinearElement?.initialState.origin,
(!opts?.newArrow || appState.selectedLinearElement?.initialState.origin) "appState.selectedLinearElement.initialState.origin must be defined for new arrows",
) { );
return { return {
start: { start: {
mode: "inside", mode: "inside",
@@ -814,7 +798,6 @@ const getBindingStrategyForDraggingBindingElementEndpoints_simple = (
startDragged ? "start" : "end", startDragged ? "start" : "end",
elementsMap, elementsMap,
appState.zoom, appState.zoom,
appState.isMidpointSnappingEnabled,
) || globalPoint, ) || globalPoint,
} }
: { mode: null }; : { mode: null };
@@ -859,7 +842,6 @@ const getBindingStrategyForDraggingBindingElementEndpoints_simple = (
startDragged ? "end" : "start", startDragged ? "end" : "start",
elementsMap, elementsMap,
appState.zoom, appState.zoom,
appState.isMidpointSnappingEnabled,
) || otherEndpoint, ) || otherEndpoint,
} }
: { mode: undefined } : { mode: undefined }
@@ -893,13 +875,10 @@ const getBindingStrategyForDraggingBindingElementEndpoints_complex = (
let start: BindingStrategy = { mode: undefined }; let start: BindingStrategy = { mode: undefined };
let end: BindingStrategy = { mode: undefined }; let end: BindingStrategy = { mode: undefined };
if (arrow.points.length < 2) { invariant(
console.error( arrow.points.length > 1,
"Attempting to bind a linear element with less than 2 points", "Do not attempt to bind linear elements with a single point",
); );
// 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) {
@@ -1026,7 +1005,6 @@ export const bindBindingElement = (
startOrEnd: "start" | "end", startOrEnd: "start" | "end",
scene: Scene, scene: Scene,
focusPoint?: GlobalPoint, focusPoint?: GlobalPoint,
shouldSnapToOutline = true,
): void => { ): void => {
const elementsMap = scene.getNonDeletedElementsMap(); const elementsMap = scene.getNonDeletedElementsMap();
@@ -1041,7 +1019,6 @@ export const bindBindingElement = (
hoveredElement, hoveredElement,
startOrEnd, startOrEnd,
elementsMap, elementsMap,
shouldSnapToOutline,
), ),
}; };
} else { } else {
@@ -1375,7 +1352,6 @@ export const bindPointToSnapToElementOutline = (
startOrEnd: "start" | "end", startOrEnd: "start" | "end",
elementsMap: ElementsMap, elementsMap: ElementsMap,
customIntersector?: LineSegment<GlobalPoint>, customIntersector?: LineSegment<GlobalPoint>,
isMidpointSnappingEnabled = true,
): GlobalPoint => { ): GlobalPoint => {
const elbowed = isElbowArrow(arrowElement); const elbowed = isElbowArrow(arrowElement);
const point = LinearElementEditor.getPointAtIndexGlobalCoordinates( const point = LinearElementEditor.getPointAtIndexGlobalCoordinates(
@@ -1415,9 +1391,13 @@ export const bindPointToSnapToElementOutline = (
const isHorizontal = headingIsHorizontal( const isHorizontal = headingIsHorizontal(
headingForPointFromElement(bindableElement, aabb, point), headingForPointFromElement(bindableElement, aabb, point),
); );
const snapPoint = isMidpointSnappingEnabled const snapPoint = snapToMid(
? snapToMid(bindableElement, elementsMap, edgePoint, 0.05, arrowElement) bindableElement,
: undefined; elementsMap,
edgePoint,
0.05,
arrowElement,
);
const resolved = snapPoint || point; const resolved = snapPoint || point;
const otherPoint = pointFrom<GlobalPoint>( const otherPoint = pointFrom<GlobalPoint>(
isHorizontal ? bindableCenter[0] : resolved[0], isHorizontal ? bindableCenter[0] : resolved[0],
@@ -1796,13 +1776,10 @@ export const updateBoundPoint = (
); );
const otherArrowPoint = LinearElementEditor.getPointAtIndexGlobalCoordinates( const otherArrowPoint = LinearElementEditor.getPointAtIndexGlobalCoordinates(
arrow, arrow,
startOrEnd === "startBinding" ? 1 : -2, startOrEnd === "startBinding" ? -1 : 0,
elementsMap, elementsMap,
); );
const otherFocusPointOrArrowPoint = const otherFocusPointOrArrowPoint = otherFocusPoint || otherArrowPoint;
arrow.points.length === 2
? otherFocusPoint || otherArrowPoint
: otherArrowPoint;
const intersector = const intersector =
otherFocusPointOrArrowPoint && otherFocusPointOrArrowPoint &&
lineSegment(focusPoint, otherFocusPointOrArrowPoint); lineSegment(focusPoint, otherFocusPointOrArrowPoint);
@@ -1912,8 +1889,6 @@ export const calculateFixedPointForElbowArrowBinding = (
hoveredElement: ExcalidrawBindableElement, hoveredElement: ExcalidrawBindableElement,
startOrEnd: "start" | "end", startOrEnd: "start" | "end",
elementsMap: ElementsMap, elementsMap: ElementsMap,
shouldSnapToOutline = true,
isMidpointSnappingEnabled = true,
): { fixedPoint: FixedPoint } => { ): { fixedPoint: FixedPoint } => {
const bounds = [ const bounds = [
hoveredElement.x, hoveredElement.x,
@@ -1921,20 +1896,12 @@ export const calculateFixedPointForElbowArrowBinding = (
hoveredElement.x + hoveredElement.width, hoveredElement.x + hoveredElement.width,
hoveredElement.y + hoveredElement.height, hoveredElement.y + hoveredElement.height,
] as Bounds; ] as Bounds;
const snappedPoint = shouldSnapToOutline const snappedPoint = bindPointToSnapToElementOutline(
? bindPointToSnapToElementOutline( linearElement,
linearElement, hoveredElement,
hoveredElement, startOrEnd,
startOrEnd, elementsMap,
elementsMap, );
undefined,
isMidpointSnappingEnabled,
)
: LinearElementEditor.getPointAtIndexGlobalCoordinates(
linearElement,
startOrEnd === "start" ? 0 : -1,
elementsMap,
);
const globalMidPoint = pointFrom( const globalMidPoint = pointFrom(
bounds[0] + (bounds[2] - bounds[0]) / 2, bounds[0] + (bounds[2] - bounds[0]) / 2,
bounds[1] + (bounds[3] - bounds[1]) / 2, bounds[1] + (bounds[3] - bounds[1]) / 2,
@@ -1948,9 +1915,9 @@ export const calculateFixedPointForElbowArrowBinding = (
return { return {
fixedPoint: normalizeFixedPoint([ fixedPoint: normalizeFixedPoint([
(nonRotatedSnappedGlobalPoint[0] - hoveredElement.x) / (nonRotatedSnappedGlobalPoint[0] - hoveredElement.x) /
Math.max(hoveredElement.width, PRECISION), hoveredElement.width,
(nonRotatedSnappedGlobalPoint[1] - hoveredElement.y) / (nonRotatedSnappedGlobalPoint[1] - hoveredElement.y) /
Math.max(hoveredElement.height, PRECISION), hoveredElement.height,
]), ]),
}; };
}; };
@@ -1981,11 +1948,9 @@ export const calculateFixedPointForNonElbowArrowBinding = (
// Calculate the ratio relative to the element's bounds // Calculate the ratio relative to the element's bounds
const fixedPointX = const fixedPointX =
(nonRotatedPoint[0] - hoveredElement.x) / (nonRotatedPoint[0] - hoveredElement.x) / hoveredElement.width;
Math.max(hoveredElement.width, PRECISION);
const fixedPointY = const fixedPointY =
(nonRotatedPoint[1] - hoveredElement.y) / (nonRotatedPoint[1] - hoveredElement.y) / hoveredElement.height;
Math.max(hoveredElement.height, PRECISION);
return { return {
fixedPoint: normalizeFixedPoint([fixedPointX, fixedPointY]), fixedPoint: normalizeFixedPoint([fixedPointX, fixedPointY]),
@@ -2482,37 +2447,21 @@ export const getArrowLocalFixedPoints = (
]; ];
}; };
export const isFixedPoint = ( export const normalizeFixedPoint = <T extends FixedPoint | null>(
fixedPoint: any,
): fixedPoint is FixedPointBinding["fixedPoint"] => {
return (
Array.isArray(fixedPoint) &&
fixedPoint.length === 2 &&
fixedPoint.every((coord) => Number.isFinite(coord))
);
};
export const normalizeFixedPoint = <T extends FixedPoint>(
fixedPoint: T, fixedPoint: T,
): FixedPoint => { ): T extends null ? null : FixedPoint => {
if (!isFixedPoint(fixedPoint)) {
return [0.5001, 0.5001];
}
const EPSILON = 0.0001;
// Do not allow a precise 0.5 for fixed point ratio // Do not allow a precise 0.5 for fixed point ratio
// to avoid jumping arrow heading due to floating point imprecision // to avoid jumping arrow heading due to floating point imprecision
if ( if (
Math.abs(fixedPoint[0] - 0.5) < EPSILON || fixedPoint &&
Math.abs(fixedPoint[1] - 0.5) < EPSILON (Math.abs(fixedPoint[0] - 0.5) < 0.0001 ||
Math.abs(fixedPoint[1] - 0.5) < 0.0001)
) { ) {
return fixedPoint.map((ratio) => return fixedPoint.map((ratio) =>
Math.abs(ratio - 0.5) < EPSILON ? 0.5001 : ratio, Math.abs(ratio - 0.5) < 0.0001 ? 0.5001 : ratio,
) as FixedPoint; ) as T extends null ? null : FixedPoint;
} }
return fixedPoint as any as T extends null ? null : FixedPoint;
return fixedPoint;
}; };
type Side = type Side =
+38 -357
View File
@@ -1,4 +1,5 @@
import rough from "roughjs/bin/rough"; import rough from "roughjs/bin/rough";
import { import {
arrayToMap, arrayToMap,
type Bounds, type Bounds,
@@ -6,6 +7,7 @@ import {
rescalePoints, rescalePoints,
sizeOf, sizeOf,
} from "@excalidraw/common"; } from "@excalidraw/common";
import { import {
degreesToRadians, degreesToRadians,
lineSegment, lineSegment,
@@ -14,7 +16,9 @@ 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 {
@@ -25,7 +29,9 @@ 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";
@@ -35,20 +41,18 @@ 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";
@@ -63,7 +67,6 @@ import type {
ExcalidrawRectanguloidElement, ExcalidrawRectanguloidElement,
ExcalidrawTextElementWithContainer, ExcalidrawTextElementWithContainer,
NonDeleted, NonDeleted,
NonDeletedExcalidrawElement,
} from "./types"; } from "./types";
export type RectangleBox = { export type RectangleBox = {
@@ -677,9 +680,8 @@ export const getMinMaxXYFromCurvePathOps = (
return [minX, minY, maxX, maxY]; return [minX, minY, maxX, maxY];
}; };
export const getBoundsFromPoints = <P extends GlobalPoint | LocalPoint>( export const getBoundsFromPoints = (
points: readonly P[], points: ExcalidrawFreeDrawElement["points"],
padding: number = 0,
): Bounds => { ): Bounds => {
let minX = Infinity; let minX = Infinity;
let minY = Infinity; let minY = Infinity;
@@ -693,7 +695,7 @@ export const getBoundsFromPoints = <P extends GlobalPoint | LocalPoint>(
maxY = Math.max(maxY, y); maxY = Math.max(maxY, y);
} }
return [minX - padding, minY - padding, maxX + padding, maxY + padding]; return [minX, minY, maxX, maxY];
}; };
const getFreeDrawElementAbsoluteCoords = ( const getFreeDrawElementAbsoluteCoords = (
@@ -707,9 +709,6 @@ const getFreeDrawElementAbsoluteCoords = (
return [x1, y1, x2, y2, (x1 + x2) / 2, (y1 + y2) / 2]; return [x1, y1, x2, y2, (x1 + x2) / 2, (y1 + y2) / 2];
}; };
const CARDINALITY_MARKER_SIZE = 20;
const CROWFOOT_ARROWHEAD_SIZE = 15;
/** @returns number in pixels */ /** @returns number in pixels */
export const getArrowheadSize = (arrowhead: Arrowhead): number => { export const getArrowheadSize = (arrowhead: Arrowhead): number => {
switch (arrowhead) { switch (arrowhead) {
@@ -718,14 +717,10 @@ export const getArrowheadSize = (arrowhead: Arrowhead): number => {
case "diamond": case "diamond":
case "diamond_outline": case "diamond_outline":
return 12; return 12;
case "cardinality_many": case "crowfoot_many":
case "cardinality_one_or_many": case "crowfoot_one":
case "cardinality_zero_or_many": case "crowfoot_one_or_many":
return CROWFOOT_ARROWHEAD_SIZE; return 20;
case "cardinality_one":
case "cardinality_exactly_one":
case "cardinality_zero_or_one":
return CARDINALITY_MARKER_SIZE;
default: default:
return 15; return 15;
} }
@@ -748,12 +743,7 @@ export const getArrowheadPoints = (
shape: Drawable[], shape: Drawable[],
position: "start" | "end", position: "start" | "end",
arrowhead: Arrowhead, arrowhead: Arrowhead,
offsetMultiplier = 0,
) => { ) => {
if (arrowhead === null) {
return null;
}
if (shape.length < 1) { if (shape.length < 1) {
return null; return null;
} }
@@ -834,30 +824,29 @@ export const getArrowheadPoints = (
const lengthMultiplier = const lengthMultiplier =
arrowhead === "diamond" || arrowhead === "diamond_outline" ? 0.25 : 0.5; arrowhead === "diamond" || arrowhead === "diamond_outline" ? 0.25 : 0.5;
const minSize = Math.min(size, length * lengthMultiplier); const minSize = Math.min(size, length * lengthMultiplier);
const tx = x2 - nx * minSize * offsetMultiplier; const xs = x2 - nx * minSize;
const ty = y2 - ny * minSize * offsetMultiplier; const ys = y2 - ny * minSize;
const xs = tx - nx * minSize;
const ys = ty - ny * minSize;
if (arrowhead === "circle" || arrowhead === "circle_outline") { if (
const diameter = Math.hypot(ys - ty, xs - tx) + element.strokeWidth - 2; arrowhead === "dot" ||
return [tx, ty, diameter]; arrowhead === "circle" ||
arrowhead === "circle_outline"
) {
const diameter = Math.hypot(ys - y2, xs - x2) + element.strokeWidth - 2;
return [x2, y2, diameter];
} }
const angle = getArrowheadAngle(arrowhead); const angle = getArrowheadAngle(arrowhead);
if ( if (arrowhead === "crowfoot_many" || arrowhead === "crowfoot_one_or_many") {
arrowhead === "cardinality_many" ||
arrowhead === "cardinality_one_or_many"
) {
// swap (xs, ys) with (x2, y2) // swap (xs, ys) with (x2, y2)
const [x3, y3] = pointRotateRads( const [x3, y3] = pointRotateRads(
pointFrom(tx, ty), pointFrom(x2, y2),
pointFrom(xs, ys), pointFrom(xs, ys),
degreesToRadians(-angle as Degrees), degreesToRadians(-angle as Degrees),
); );
const [x4, y4] = pointRotateRads( const [x4, y4] = pointRotateRads(
pointFrom(tx, ty), pointFrom(x2, y2),
pointFrom(xs, ys), pointFrom(xs, ys),
degreesToRadians(angle), degreesToRadians(angle),
); );
@@ -867,12 +856,12 @@ export const getArrowheadPoints = (
// Return points // Return points
const [x3, y3] = pointRotateRads( const [x3, y3] = pointRotateRads(
pointFrom(xs, ys), pointFrom(xs, ys),
pointFrom(tx, ty), pointFrom(x2, y2),
((-angle * Math.PI) / 180) as Radians, ((-angle * Math.PI) / 180) as Radians,
); );
const [x4, y4] = pointRotateRads( const [x4, y4] = pointRotateRads(
pointFrom(xs, ys), pointFrom(xs, ys),
pointFrom(tx, ty), pointFrom(x2, y2),
degreesToRadians(angle), degreesToRadians(angle),
); );
@@ -885,9 +874,9 @@ export const getArrowheadPoints = (
const [px, py] = element.points.length > 1 ? element.points[1] : [0, 0]; const [px, py] = element.points.length > 1 ? element.points[1] : [0, 0];
[ox, oy] = pointRotateRads( [ox, oy] = pointRotateRads(
pointFrom(tx + minSize * 2, ty), pointFrom(x2 + minSize * 2, y2),
pointFrom(tx, ty), pointFrom(x2, y2),
Math.atan2(py - ty, px - tx) as Radians, Math.atan2(py - y2, px - x2) as Radians,
); );
} else { } else {
const [px, py] = const [px, py] =
@@ -896,16 +885,16 @@ export const getArrowheadPoints = (
: [0, 0]; : [0, 0];
[ox, oy] = pointRotateRads( [ox, oy] = pointRotateRads(
pointFrom(tx - minSize * 2, ty), pointFrom(x2 - minSize * 2, y2),
pointFrom(tx, ty), pointFrom(x2, y2),
Math.atan2(ty - py, tx - px) as Radians, Math.atan2(y2 - py, x2 - px) as Radians,
); );
} }
return [tx, ty, x3, y3, ox, oy, x4, y4]; return [x2, y2, x3, y3, ox, oy, x4, y4];
} }
return [tx, ty, x3, y3, x4, y4]; return [x2, y2, x3, y3, x4, y4];
}; };
// TODO reuse shape.ts // TODO reuse shape.ts
@@ -1259,17 +1248,6 @@ export const pointInsideBounds = <P extends GlobalPoint | LocalPoint>(
): boolean => ): boolean =>
p[0] > bounds[0] && p[0] < bounds[2] && p[1] > bounds[1] && p[1] < bounds[3]; p[0] > bounds[0] && p[0] < bounds[2] && p[1] > bounds[1] && p[1] < bounds[3];
// TODO make pointInsideBounds inclusive and remove this function once we
// test nothing is breaking
export const pointInsideBoundsInclusive = <P extends GlobalPoint | LocalPoint>(
p: P,
bounds: Bounds,
): boolean =>
p[0] >= bounds[0] &&
p[0] <= bounds[2] &&
p[1] >= bounds[1] &&
p[1] <= bounds[3];
export const doBoundsIntersect = ( export const doBoundsIntersect = (
bounds1: Bounds | null, bounds1: Bounds | null,
bounds2: Bounds | null, bounds2: Bounds | null,
@@ -1284,310 +1262,13 @@ export const doBoundsIntersect = (
return minX1 < maxX2 && maxX1 > minX2 && minY1 < maxY2 && maxY1 > minY2; return minX1 < maxX2 && maxX1 > minX2 && minY1 < maxY2 && maxY1 > minY2;
}; };
export const boundsContainBounds = (outerBounds: Bounds, innerBounds: Bounds) =>
[
pointFrom<GlobalPoint>(innerBounds[0], innerBounds[1]),
pointFrom<GlobalPoint>(innerBounds[0], innerBounds[3]),
pointFrom<GlobalPoint>(innerBounds[2], innerBounds[1]),
pointFrom<GlobalPoint>(innerBounds[2], innerBounds[3]),
].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,
xOffset: number = 0, xOffset: number = 0,
yOffset: number = 0, yOffset: number = 0,
) => { ) => {
if (isLinearElement(element) || isFreeDrawElement(element)) { if (isLinearElement(element)) {
const [x1, y1, x2, y2] = getElementAbsoluteCoords(element, elementsMap); const [x1, y1, x2, y2] = getElementAbsoluteCoords(element, elementsMap);
const [x, y] = pointFrom<GlobalPoint>((x1 + x2) / 2, (y1 + y2) / 2); const [x, y] = pointFrom<GlobalPoint>((x1 + x2) / 2, (y1 + y2) / 2);
+25 -47
View File
@@ -61,8 +61,6 @@ import { distanceToElement } from "./distance";
import { getBindingGap } from "./binding"; import { getBindingGap } from "./binding";
import { hasBackground } from "./comparisons";
import type { import type {
ElementsMap, ElementsMap,
ExcalidrawArrowElement, ExcalidrawArrowElement,
@@ -80,12 +78,17 @@ import type {
} from "./types"; } from "./types";
export const shouldTestInside = (element: ExcalidrawElement) => { export const shouldTestInside = (element: ExcalidrawElement) => {
if (element.type === "arrow") { if (
element.type === "arrow" ||
// frame elements should ignore inside hit test even if background is not
// transparent, so we can select children easily
isFrameLikeElement(element)
) {
return false; return false;
} }
const isDraggableFromInside = const isDraggableFromInside =
(hasBackground(element.type) && !isTransparent(element.backgroundColor)) || !isTransparent(element.backgroundColor) ||
hasBoundTextElement(element) || hasBoundTextElement(element) ||
isIframeLikeElement(element) || isIframeLikeElement(element) ||
isTextElement(element); isTextElement(element);
@@ -156,11 +159,14 @@ export const hitElementItself = ({
// Hit test against the extended, rotated bounding box of the element first // Hit test against the extended, rotated bounding box of the element first
const bounds = getElementBounds(element, elementsMap, true); const bounds = getElementBounds(element, elementsMap, true);
const hitBounds = isPointInRotatedBounds( const hitBounds = isPointWithinBounds(
point, pointFrom(bounds[0] - threshold, bounds[1] - threshold),
bounds, pointRotateRads(
element.angle, point,
threshold, getCenterForBounds(bounds),
-element.angle as Radians,
),
pointFrom(bounds[2] + threshold, bounds[3] + threshold),
); );
// PERF: Bail out early if the point is not even in the // PERF: Bail out early if the point is not even in the
@@ -191,32 +197,18 @@ export const hitElementItself = ({
return result; return result;
}; };
const isPointInRotatedBounds = (
point: GlobalPoint,
bounds: Bounds,
angle: Radians,
tolerance = 0,
) => {
const adjustedPoint =
angle === 0
? point
: pointRotateRads(point, getCenterForBounds(bounds), -angle as Radians);
return isPointWithinBounds(
pointFrom(bounds[0] - tolerance, bounds[1] - tolerance),
adjustedPoint,
pointFrom(bounds[2] + tolerance, bounds[3] + tolerance),
);
};
export const hitElementBoundingBox = ( export const hitElementBoundingBox = (
point: GlobalPoint, point: GlobalPoint,
element: ExcalidrawElement, element: ExcalidrawElement,
elementsMap: ElementsMap, elementsMap: ElementsMap,
tolerance = 0, tolerance = 0,
) => { ) => {
const bounds = getElementBounds(element, elementsMap, true); let [x1, y1, x2, y2] = getElementBounds(element, elementsMap);
return isPointInRotatedBounds(point, bounds, element.angle, tolerance); x1 -= tolerance;
y1 -= tolerance;
x2 += tolerance;
y2 += tolerance;
return isPointWithinBounds(pointFrom(x1, y1), point, pointFrom(x2, y2));
}; };
export const hitElementBoundingBoxOnly = ( export const hitElementBoundingBoxOnly = (
@@ -326,10 +318,7 @@ export const getAllHoveredElementAtPoint = (
) { ) {
candidateElements.push(element); candidateElements.push(element);
if ( if (!isTransparent(element.backgroundColor)) {
hasBackground(element.type) &&
!isTransparent(element.backgroundColor)
) {
break; break;
} }
} }
@@ -481,12 +470,7 @@ export const intersectElementWithLineSegment = (
case "line": case "line":
case "freedraw": case "freedraw":
case "arrow": case "arrow":
return intersectLinearOrFreeDrawWithLineSegment( return intersectLinearOrFreeDrawWithLineSegment(element, line, onlyFirst);
element,
line,
elementsMap,
onlyFirst,
);
} }
}; };
@@ -553,15 +537,11 @@ const lineIntersections = (
const intersectLinearOrFreeDrawWithLineSegment = ( const intersectLinearOrFreeDrawWithLineSegment = (
element: ExcalidrawLinearElement | ExcalidrawFreeDrawElement, element: ExcalidrawLinearElement | ExcalidrawFreeDrawElement,
segment: LineSegment<GlobalPoint>, segment: LineSegment<GlobalPoint>,
elementsMap: ElementsMap,
onlyFirst = false, onlyFirst = false,
): GlobalPoint[] => { ): GlobalPoint[] => {
// NOTE: This is the only one which return the decomposed elements // NOTE: This is the only one which return the decomposed elements
// rotated! This is due to taking advantage of roughjs definitions. // rotated! This is due to taking advantage of roughjs definitions.
const [lines, curves] = deconstructLinearOrFreeDrawElement( const [lines, curves] = deconstructLinearOrFreeDrawElement(element);
element,
elementsMap,
);
const intersections: GlobalPoint[] = []; const intersections: GlobalPoint[] = [];
for (const l of lines) { for (const l of lines) {
@@ -589,9 +569,7 @@ const intersectLinearOrFreeDrawWithLineSegment = (
continue; continue;
} }
const hits = curveIntersectLineSegment(c, segment, { const hits = curveIntersectLineSegment(c, segment);
iterLimit: 10,
});
if (hits.length > 0) { if (hits.length > 0) {
intersections.push(...hits); intersections.push(...hits);
-2
View File
@@ -38,8 +38,6 @@ export const hasStrokeStyle = (type: ElementOrToolType) =>
type === "arrow" || type === "arrow" ||
type === "line"; type === "line";
export const hasFreedrawMode = (type: ElementOrToolType) => type === "freedraw";
export const canChangeRoundness = (type: ElementOrToolType) => export const canChangeRoundness = (type: ElementOrToolType) =>
type === "rectangle" || type === "rectangle" ||
type === "iframe" || type === "iframe" ||
+2 -6
View File
@@ -48,7 +48,7 @@ export const distanceToElement = (
case "line": case "line":
case "arrow": case "arrow":
case "freedraw": case "freedraw":
return distanceToLinearOrFreeDraElement(element, elementsMap, p); return distanceToLinearOrFreeDraElement(element, p);
} }
}; };
@@ -133,13 +133,9 @@ const distanceToEllipseElement = (
const distanceToLinearOrFreeDraElement = ( const distanceToLinearOrFreeDraElement = (
element: ExcalidrawLinearElement | ExcalidrawFreeDrawElement, element: ExcalidrawLinearElement | ExcalidrawFreeDrawElement,
elementsMap: ElementsMap,
p: GlobalPoint, p: GlobalPoint,
) => { ) => {
const [lines, curves] = deconstructLinearOrFreeDrawElement( const [lines, curves] = deconstructLinearOrFreeDrawElement(element);
element,
elementsMap,
);
return Math.min( return Math.min(
...lines.map((s) => distanceToLineSegment(p, s)), ...lines.map((s) => distanceToLineSegment(p, s)),
...curves.map((a) => curvePointDistance(a, p)), ...curves.map((a) => curvePointDistance(a, p)),
+2 -22
View File
@@ -111,9 +111,6 @@ export const duplicateElements = (
* user interaction. * user interaction.
*/ */
type: "everything"; type: "everything";
// TODO remove/review this once we add frame children order migration
// and invariant checks
preserveFrameChildrenOrder?: boolean;
} }
| { | {
/** /**
@@ -173,8 +170,6 @@ export const duplicateElements = (
opts.type === "in-place" opts.type === "in-place"
? opts.idsOfElementsToDuplicate ? opts.idsOfElementsToDuplicate
: new Map(elements.map((el) => [el.id, el])); : new Map(elements.map((el) => [el.id, el]));
const preserveFrameChildrenOrder =
opts.type === "everything" && opts.preserveFrameChildrenOrder;
// For sanity // For sanity
if (opts.type === "in-place") { if (opts.type === "in-place") {
@@ -255,9 +250,6 @@ export const duplicateElements = (
elementsWithDuplicates.splice(index + 1, 0, ...castArray(elements)); elementsWithDuplicates.splice(index + 1, 0, ...castArray(elements));
}; };
// main
// ---------------------------------------------------------------------------
const frameIdsToDuplicate = new Set( const frameIdsToDuplicate = new Set(
elements elements
.filter( .filter(
@@ -282,7 +274,7 @@ export const duplicateElements = (
if (groupId) { if (groupId) {
const groupElements = getElementsInGroup(elements, groupId).flatMap( const groupElements = getElementsInGroup(elements, groupId).flatMap(
(element) => (element) =>
isFrameLikeElement(element) && !preserveFrameChildrenOrder isFrameLikeElement(element)
? [...getFrameChildren(elements, element.id), element] ? [...getFrameChildren(elements, element.id), element]
: [element], : [element],
); );
@@ -298,25 +290,13 @@ export const duplicateElements = (
// frame duplication // frame duplication
// ------------------------------------------------------------------------- // -------------------------------------------------------------------------
if ( if (element.frameId && frameIdsToDuplicate.has(element.frameId)) {
!preserveFrameChildrenOrder &&
element.frameId &&
frameIdsToDuplicate.has(element.frameId)
) {
continue; continue;
} }
if (isFrameLikeElement(element)) { if (isFrameLikeElement(element)) {
const frameId = element.id; const frameId = element.id;
if (preserveFrameChildrenOrder) {
insertBeforeOrAfterIndex(
findLastIndex(elementsWithDuplicates, (el) => el.id === frameId),
copyElements(element),
);
continue;
}
const frameChildren = getFrameChildren(elements, frameId); const frameChildren = getFrameChildren(elements, frameId);
const targetIndex = findLastIndex(elementsWithDuplicates, (el) => { const targetIndex = findLastIndex(elementsWithDuplicates, (el) => {
+4 -16
View File
@@ -915,8 +915,6 @@ export const updateElbowArrowPoints = (
}, },
options?: { options?: {
isDragging?: boolean; isDragging?: boolean;
isBindingEnabled?: boolean;
isMidpointSnappingEnabled?: boolean;
}, },
): ElementUpdate<ExcalidrawElbowArrowElement> => { ): ElementUpdate<ExcalidrawElbowArrowElement> => {
if (arrow.points.length < 2) { if (arrow.points.length < 2) {
@@ -1204,8 +1202,6 @@ const getElbowArrowData = (
options?: { options?: {
isDragging?: boolean; isDragging?: boolean;
zoom?: AppState["zoom"]; zoom?: AppState["zoom"];
isBindingEnabled?: boolean;
isMidpointSnappingEnabled?: boolean;
}, },
) => { ) => {
const origStartGlobalPoint: GlobalPoint = pointTranslate< const origStartGlobalPoint: GlobalPoint = pointTranslate<
@@ -1219,7 +1215,7 @@ const getElbowArrowData = (
let hoveredStartElement = null; let hoveredStartElement = null;
let hoveredEndElement = null; let hoveredEndElement = null;
if (options?.isDragging && options?.isBindingEnabled !== false) { if (options?.isDragging) {
const elements = Array.from(elementsMap.values()); const elements = Array.from(elementsMap.values());
hoveredStartElement = hoveredStartElement =
getHoveredElement( getHoveredElement(
@@ -1259,8 +1255,6 @@ const getElbowArrowData = (
hoveredStartElement, hoveredStartElement,
elementsMap, elementsMap,
options?.isDragging, options?.isDragging,
options?.isBindingEnabled,
options?.isMidpointSnappingEnabled,
); );
const endGlobalPoint = getGlobalPoint( const endGlobalPoint = getGlobalPoint(
{ {
@@ -1276,8 +1270,6 @@ const getElbowArrowData = (
hoveredEndElement, hoveredEndElement,
elementsMap, elementsMap,
options?.isDragging, options?.isDragging,
options?.isBindingEnabled,
options?.isMidpointSnappingEnabled,
); );
const startHeading = getBindPointHeading( const startHeading = getBindPointHeading(
startGlobalPoint, startGlobalPoint,
@@ -2124,8 +2116,8 @@ const normalizeArrowElementUpdate = (
offsetY < -MAX_POS || offsetY < -MAX_POS ||
offsetY > MAX_POS || offsetY > MAX_POS ||
offsetX + points[points.length - 1][0] < -MAX_POS || offsetX + points[points.length - 1][0] < -MAX_POS ||
offsetX + points[points.length - 1][0] > MAX_POS || offsetY + points[points.length - 1][0] > MAX_POS ||
offsetY + points[points.length - 1][1] < -MAX_POS || offsetX + points[points.length - 1][1] < -MAX_POS ||
offsetY + points[points.length - 1][1] > MAX_POS offsetY + points[points.length - 1][1] > MAX_POS
) { ) {
console.error( console.error(
@@ -2221,18 +2213,14 @@ const getGlobalPoint = (
element?: ExcalidrawBindableElement | null, element?: ExcalidrawBindableElement | null,
elementsMap?: ElementsMap, elementsMap?: ElementsMap,
isDragging?: boolean, isDragging?: boolean,
isBindingEnabled = true,
isMidpointSnappingEnabled = true,
): GlobalPoint => { ): GlobalPoint => {
if (isDragging) { if (isDragging) {
if (isBindingEnabled && element && elementsMap) { if (element && elementsMap) {
return bindPointToSnapToElementOutline( return bindPointToSnapToElementOutline(
arrow, arrow,
element, element,
startOrEnd, startOrEnd,
elementsMap, elementsMap,
undefined,
isMidpointSnappingEnabled,
); );
} }
+2 -12
View File
@@ -1,9 +1,6 @@
import { arrayToMap } from "@excalidraw/common"; import { generateNKeysBetween } from "fractional-indexing";
import { import { arrayToMap } from "@excalidraw/common";
validateOrderKey,
generateNKeysBetween,
} from "@excalidraw/fractional-indexing";
import { mutateElement, newElementWith } from "./mutateElement"; import { mutateElement, newElementWith } from "./mutateElement";
import { getBoundTextElement } from "./textElement"; import { getBoundTextElement } from "./textElement";
@@ -385,13 +382,6 @@ const isValidFractionalIndex = (
return false; return false;
} }
try {
// Format validation
validateOrderKey(index);
} catch {
return false;
}
if (predecessor && successor) { if (predecessor && successor) {
return predecessor < index && index < successor; return predecessor < index && index < successor;
} }
+54 -113
View File
@@ -1,9 +1,7 @@
import { arrayToMap } from "@excalidraw/common"; import { arrayToMap } from "@excalidraw/common";
import { import { isPointWithinBounds, pointFrom } from "@excalidraw/math";
isPointWithinBounds, import { doLineSegmentsIntersect } from "@excalidraw/utils/bbox";
pointFrom, import { elementsOverlappingBBox } from "@excalidraw/utils/withinBounds";
segmentsIntersectAt,
} from "@excalidraw/math";
import type { import type {
AppClassProperties, AppClassProperties,
@@ -20,13 +18,9 @@ import {
getElementLineSegments, getElementLineSegments,
getCommonBounds, getCommonBounds,
getElementAbsoluteCoords, getElementAbsoluteCoords,
doBoundsIntersect,
getElementBounds,
boundsContainBounds,
} from "./bounds"; } from "./bounds";
import { mutateElement } from "./mutateElement"; import { mutateElement } from "./mutateElement";
import { getBoundTextElement, getContainerElement } from "./textElement"; import { getBoundTextElement, getContainerElement } from "./textElement";
import { syncMovedIndices } from "./fractionalIndex";
import { import {
isFrameElement, isFrameElement,
isFrameLikeElement, isFrameLikeElement,
@@ -81,7 +75,7 @@ export function isElementIntersectingFrame(
const intersecting = frameLineSegments.some((frameLineSegment) => const intersecting = frameLineSegments.some((frameLineSegment) =>
elementLineSegments.some((elementLineSegment) => elementLineSegments.some((elementLineSegment) =>
segmentsIntersectAt(frameLineSegment, elementLineSegment), doLineSegmentsIntersect(frameLineSegment, elementLineSegment),
), ),
); );
@@ -106,9 +100,8 @@ export const isElementContainingFrame = (
frame: ExcalidrawFrameLikeElement, frame: ExcalidrawFrameLikeElement,
elementsMap: ElementsMap, elementsMap: ElementsMap,
) => { ) => {
return boundsContainBounds( return getElementsWithinSelection([frame], element, elementsMap).some(
getElementBounds(element, elementsMap), (e) => e.id === frame.id,
getElementBounds(frame, elementsMap),
); );
}; };
@@ -495,44 +488,10 @@ export const filterElementsEligibleAsFrameChildren = (
return eligibleElements; return eligibleElements;
}; };
export const getCommonFrameId = (elements: readonly ExcalidrawElement[]) => {
let commonFrameId: ExcalidrawElement["frameId"] | undefined;
for (const element of elements) {
if (isFrameLikeElement(element) || !element.frameId) {
return null;
}
if (commonFrameId === undefined) {
commonFrameId = element.frameId;
} else if (commonFrameId !== element.frameId) {
return null;
}
}
return commonFrameId ?? null;
};
export const getFrameChildrenInsertionIndex = (
elements: readonly ExcalidrawElement[],
frameId: ExcalidrawFrameLikeElement["id"],
): number | null => {
for (let index = elements.length - 1; index >= 0; index--) {
const element = elements[index];
if (element.id === frameId) {
return index;
} else if (element.frameId === frameId) {
return index + 1;
}
}
return null;
};
/** /**
* Adds elements and their bound elements to frame. Reorders added elements to * Retains (or repairs for target frame) the ordering invriant where children
* be just below frame, or just above its highest child (whichever is higher). * elements come right before the parent frame:
* [el, el, child, child, frame, el]
* *
* @returns mutated allElements (same data structure) * @returns mutated allElements (same data structure)
*/ */
@@ -540,11 +499,19 @@ export const addElementsToFrame = <T extends ElementsMapOrArray>(
allElements: T, allElements: T,
elementsToAdd: NonDeletedExcalidrawElement[], elementsToAdd: NonDeletedExcalidrawElement[],
frame: ExcalidrawFrameLikeElement, frame: ExcalidrawFrameLikeElement,
appState: AppState,
): T => { ): T => {
const elementsMap = arrayToMap(allElements); const elementsMap = arrayToMap(allElements);
const commonFrameId = getCommonFrameId(elementsToAdd); const currTargetFrameChildrenMap = new Map<ExcalidrawElement["id"], true>();
for (const element of allElements.values()) {
if (element.frameId === frame.id) {
currTargetFrameChildrenMap.set(element.id, true);
}
}
const finalElementsToAdd = new Set<ExcalidrawElement>(); const suppliedElementsToAddSet = new Set(elementsToAdd.map((el) => el.id));
const finalElementsToAdd: ExcalidrawElement[] = [];
const otherFrames = new Set<ExcalidrawFrameLikeElement["id"]>(); const otherFrames = new Set<ExcalidrawFrameLikeElement["id"]>();
@@ -555,8 +522,7 @@ export const addElementsToFrame = <T extends ElementsMapOrArray>(
} }
// - add bound text elements if not already in the array // - add bound text elements if not already in the array
// - keep elements already in the frame so mixed selections can be reordered // - filter out elements that are already in the frame
// together
for (const element of omitGroupsContainingFrameLikes( for (const element of omitGroupsContainingFrameLikes(
allElements, allElements,
elementsToAdd, elementsToAdd,
@@ -569,64 +535,38 @@ export const addElementsToFrame = <T extends ElementsMapOrArray>(
continue; continue;
} }
finalElementsToAdd.add(element); // if the element is already in another frame (which is also in elementsToAdd),
// it means that frame and children are selected at the same time
// => keep original frame membership, do not add to the target frame
if (
element.frameId &&
appState.selectedElementIds[element.id] &&
appState.selectedElementIds[element.frameId]
) {
continue;
}
if (!currTargetFrameChildrenMap.has(element.id)) {
finalElementsToAdd.push(element);
}
const boundTextElement = getBoundTextElement(element, elementsMap); const boundTextElement = getBoundTextElement(element, elementsMap);
if (boundTextElement && !finalElementsToAdd.has(boundTextElement)) { if (
finalElementsToAdd.add(boundTextElement); boundTextElement &&
!suppliedElementsToAddSet.has(boundTextElement.id) &&
!currTargetFrameChildrenMap.has(boundTextElement.id)
) {
finalElementsToAdd.push(boundTextElement);
} }
} }
for (const element of finalElementsToAdd) { for (const element of finalElementsToAdd) {
// we don't always need to update the element if it's already in the frame, mutateElement(element, elementsMap, {
// but we still need to accumulate in finalElementsToAdd so we potentially frameId: frame.id,
// reorder them if added together });
if (element.frameId !== frame.id) {
mutateElement(element, elementsMap, {
frameId: frame.id,
});
}
} }
// (re)order elements to be just below the frame, return allElements;
// or just above the highest child if that is higher
// (latter case is denormalized order until we migrate)
// ---------------------------------------------------------------------------
if (
!finalElementsToAdd.size ||
// if all elements to add already belong to the frame, then we don't want to
// reorder (case: we're dragging element children within the frame)
commonFrameId === frame.id
) {
return allElements;
}
const otherElements = Array.from(allElements.values()).filter(
(element) => !finalElementsToAdd.has(element),
);
const insertionIndex = getFrameChildrenInsertionIndex(
otherElements,
frame.id,
);
if (insertionIndex === null) {
return allElements;
}
const reorderedElements = [
...otherElements.slice(0, insertionIndex),
...finalElementsToAdd,
...otherElements.slice(insertionIndex),
];
syncMovedIndices(reorderedElements, arrayToMap([...finalElementsToAdd]));
return (
Array.isArray(allElements)
? reorderedElements
: new Map(reorderedElements.map((element) => [element.id, element]))
) as T;
}; };
export const removeElementsFromFrame = ( export const removeElementsFromFrame = (
@@ -680,11 +620,13 @@ export const replaceAllElementsInFrame = <T extends ExcalidrawElement>(
allElements: readonly T[], allElements: readonly T[],
nextElementsInFrame: ExcalidrawElement[], nextElementsInFrame: ExcalidrawElement[],
frame: ExcalidrawFrameLikeElement, frame: ExcalidrawFrameLikeElement,
app: AppClassProperties,
): T[] => { ): T[] => {
return addElementsToFrame( return addElementsToFrame(
removeAllElementsFromFrame(allElements, frame), removeAllElementsFromFrame(allElements, frame),
nextElementsInFrame, nextElementsInFrame,
frame, frame,
app.state,
).slice(); ).slice();
}; };
@@ -978,17 +920,16 @@ export const getFrameLikeTitle = (element: ExcalidrawFrameLikeElement) => {
export const getElementsOverlappingFrame = ( export const getElementsOverlappingFrame = (
elements: readonly ExcalidrawElement[], elements: readonly ExcalidrawElement[],
frame: ExcalidrawFrameLikeElement, frame: ExcalidrawFrameLikeElement,
elementsMap: ElementsMap,
) => { ) => {
return elements.filter( return (
(el) => elementsOverlappingBBox({
// exclude elements which are overlapping, but are in a different frame, elements,
bounds: frame,
type: "overlap",
})
// removes elements who are overlapping, but are in a different frame,
// and thus invisible in target frame // and thus invisible in target frame
(!el.frameId || el.frameId === frame.id) && .filter((el) => !el.frameId || el.frameId === frame.id)
doBoundsIntersect(
getElementBounds(el, elementsMap),
getElementBounds(frame, elementsMap),
),
); );
}; };
+1 -1
View File
@@ -83,6 +83,7 @@ export * from "./positionElementsOnGrid";
export * from "./renderElement"; export * from "./renderElement";
export * from "./resizeElements"; export * from "./resizeElements";
export * from "./resizeTest"; export * from "./resizeTest";
export * from "./schema";
export * from "./Scene"; export * from "./Scene";
export * from "./selection"; export * from "./selection";
export * from "./shape"; export * from "./shape";
@@ -99,4 +100,3 @@ export * from "./typeChecks";
export * from "./utils"; export * from "./utils";
export * from "./zindex"; export * from "./zindex";
export * from "./arrows/helpers"; export * from "./arrows/helpers";
export * from "./arrowheads";
+36 -85
View File
@@ -359,20 +359,11 @@ export class LinearElementEditor {
linearElementEditor, linearElementEditor,
); );
LinearElementEditor.movePoints( LinearElementEditor.movePoints(element, app.scene, positions, {
element, startBinding: updates?.startBinding,
app.scene, endBinding: updates?.endBinding,
positions, moveMidPointsWithElement: updates?.moveMidPointsWithElement,
{ });
startBinding: updates?.startBinding,
endBinding: updates?.endBinding,
moveMidPointsWithElement: updates?.moveMidPointsWithElement,
},
{
isBindingEnabled: app.state.isBindingEnabled,
isMidpointSnappingEnabled: app.state.isMidpointSnappingEnabled,
},
);
// Set the suggested binding from the updates if available // Set the suggested binding from the updates if available
if (isBindingElement(element, false)) { if (isBindingElement(element, false)) {
if (isBindingEnabled(app.state)) { if (isBindingEnabled(app.state)) {
@@ -427,7 +418,6 @@ export class LinearElementEditor {
"start", "start",
elementsMap, elementsMap,
app.state.zoom, app.state.zoom,
app.state.isMidpointSnappingEnabled,
) )
: linearElementEditor.initialState.altFocusPoint, : linearElementEditor.initialState.altFocusPoint,
}, },
@@ -476,22 +466,16 @@ export class LinearElementEditor {
}); });
} }
if ( invariant(
lastClickedPoint < 0 || lastClickedPoint > -1 &&
!selectedPointsIndices.includes(lastClickedPoint) || selectedPointsIndices.includes(lastClickedPoint) &&
!element.points[lastClickedPoint] element.points[lastClickedPoint],
) { `There must be a valid lastClickedPoint in order to drag it. selectedPointsIndices(${JSON.stringify(
console.error( selectedPointsIndices,
`There must be a valid lastClickedPoint in order to drag it. selectedPointsIndices(${JSON.stringify( )}) points(0..${
selectedPointsIndices, element.points.length - 1
)}) points(0..${ }) lastClickedPoint(${lastClickedPoint})`,
element.points.length - 1 );
}) lastClickedPoint(${lastClickedPoint}) isElbowArrow: ${elbowed}`,
);
// Fall back to the actual last point as a last resort.
lastClickedPoint = element.points.length - 1;
}
// point that's being dragged (out of all selected points) // point that's being dragged (out of all selected points)
const draggingPoint = element.points[lastClickedPoint]; const draggingPoint = element.points[lastClickedPoint];
@@ -554,20 +538,11 @@ export class LinearElementEditor {
linearElementEditor, linearElementEditor,
); );
LinearElementEditor.movePoints( LinearElementEditor.movePoints(element, app.scene, positions, {
element, startBinding: updates?.startBinding,
app.scene, endBinding: updates?.endBinding,
positions, moveMidPointsWithElement: updates?.moveMidPointsWithElement,
{ });
startBinding: updates?.startBinding,
endBinding: updates?.endBinding,
moveMidPointsWithElement: updates?.moveMidPointsWithElement,
},
{
isBindingEnabled: app.state.isBindingEnabled,
isMidpointSnappingEnabled: app.state.isMidpointSnappingEnabled,
},
);
// Set the suggested binding from the updates if available // Set the suggested binding from the updates if available
if (isBindingElement(element, false)) { if (isBindingElement(element, false)) {
@@ -661,7 +636,6 @@ export class LinearElementEditor {
"start", "start",
elementsMap, elementsMap,
app.state.zoom, app.state.zoom,
app.state.isMidpointSnappingEnabled,
) )
: linearElementEditor.initialState.altFocusPoint, : linearElementEditor.initialState.altFocusPoint,
}, },
@@ -800,7 +774,6 @@ export class LinearElementEditor {
element.points[index + 1], element.points[index + 1],
index, index,
appState.zoom, appState.zoom,
elementsMap,
) )
) { ) {
midpoints.push(null); midpoints.push(null);
@@ -810,7 +783,6 @@ export class LinearElementEditor {
const segmentMidPoint = LinearElementEditor.getSegmentMidPoint( const segmentMidPoint = LinearElementEditor.getSegmentMidPoint(
element, element,
index + 1, index + 1,
elementsMap,
); );
midpoints.push(segmentMidPoint); midpoints.push(segmentMidPoint);
index++; index++;
@@ -898,7 +870,6 @@ export class LinearElementEditor {
endPoint: P, endPoint: P,
index: number, index: number,
zoom: Zoom, zoom: Zoom,
elementsMap: ElementsMap,
) { ) {
if (isElbowArrow(element)) { if (isElbowArrow(element)) {
if (index >= 0 && index < element.points.length) { if (index >= 0 && index < element.points.length) {
@@ -913,10 +884,7 @@ export class LinearElementEditor {
let distance = pointDistance(startPoint, endPoint); let distance = pointDistance(startPoint, endPoint);
if (element.points.length > 2 && element.roundness) { if (element.points.length > 2 && element.roundness) {
const [lines, curves] = deconstructLinearOrFreeDrawElement( const [lines, curves] = deconstructLinearOrFreeDrawElement(element);
element,
elementsMap,
);
invariant( invariant(
lines.length === 0 && curves.length > 0, lines.length === 0 && curves.length > 0,
@@ -936,7 +904,6 @@ export class LinearElementEditor {
static getSegmentMidPoint( static getSegmentMidPoint(
element: NonDeleted<ExcalidrawLinearElement>, element: NonDeleted<ExcalidrawLinearElement>,
index: number, index: number,
elementsMap: ElementsMap,
): GlobalPoint { ): GlobalPoint {
if (isElbowArrow(element)) { if (isElbowArrow(element)) {
invariant( invariant(
@@ -949,10 +916,7 @@ export class LinearElementEditor {
return pointFrom<GlobalPoint>(element.x + p[0], element.y + p[1]); return pointFrom<GlobalPoint>(element.x + p[0], element.y + p[1]);
} }
const [lines, curves] = deconstructLinearOrFreeDrawElement( const [lines, curves] = deconstructLinearOrFreeDrawElement(element);
element,
elementsMap,
);
invariant( invariant(
(lines.length === 0 && curves.length > 0) || (lines.length === 0 && curves.length > 0) ||
@@ -1560,10 +1524,6 @@ export class LinearElementEditor {
endBinding?: FixedPointBinding | null; endBinding?: FixedPointBinding | null;
moveMidPointsWithElement?: boolean | null; moveMidPointsWithElement?: boolean | null;
}, },
options?: {
isBindingEnabled?: boolean;
isMidpointSnappingEnabled?: boolean;
},
) { ) {
const { points } = element; const { points } = element;
@@ -1632,8 +1592,6 @@ export class LinearElementEditor {
otherUpdates, otherUpdates,
{ {
isDragging: Array.from(pointUpdates.values()).some((t) => t.isDragging), isDragging: Array.from(pointUpdates.values()).some((t) => t.isDragging),
isBindingEnabled: options?.isBindingEnabled,
isMidpointSnappingEnabled: options?.isMidpointSnappingEnabled,
}, },
); );
} }
@@ -1748,8 +1706,6 @@ export class LinearElementEditor {
isDragging?: boolean; isDragging?: boolean;
zoom?: AppState["zoom"]; zoom?: AppState["zoom"];
sceneElementsMap?: NonDeletedSceneElementsMap; sceneElementsMap?: NonDeletedSceneElementsMap;
isBindingEnabled?: boolean;
isMidpointSnappingEnabled?: boolean;
}, },
) { ) {
if (isElbowArrow(element)) { if (isElbowArrow(element)) {
@@ -1770,8 +1726,6 @@ export class LinearElementEditor {
scene.mutateElement(element, updates, { scene.mutateElement(element, updates, {
informMutation: true, informMutation: true,
isDragging: options?.isDragging ?? false, isDragging: options?.isDragging ?? false,
isBindingEnabled: options?.isBindingEnabled,
isMidpointSnappingEnabled: options?.isMidpointSnappingEnabled,
}); });
} else { } else {
// TODO do we need to get precise coords here just to calc centers? // TODO do we need to get precise coords here just to calc centers?
@@ -1867,7 +1821,6 @@ export class LinearElementEditor {
const midSegmentMidpoint = LinearElementEditor.getSegmentMidPoint( const midSegmentMidpoint = LinearElementEditor.getSegmentMidPoint(
element, element,
index + 1, index + 1,
elementsMap,
); );
x = midSegmentMidpoint[0] - boundTextElement.width / 2; x = midSegmentMidpoint[0] - boundTextElement.width / 2;
@@ -2139,13 +2092,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[0] + deltaX, point[1] + deltaY), point: pointFrom<LocalPoint>(
element.points[pointIndex][0] + deltaX,
element.points[pointIndex][1] + deltaY,
),
isDragging: true, isDragging: true,
}, },
]; ];
@@ -2192,16 +2145,14 @@ const pointDraggingUpdates = (
suggestedBinding: suggestedBindingElement suggestedBinding: suggestedBindingElement
? { ? {
element: suggestedBindingElement, element: suggestedBindingElement,
midPoint: app.state.isMidpointSnappingEnabled midPoint: snapToMid(
? snapToMid( suggestedBindingElement,
suggestedBindingElement, elementsMap,
elementsMap, pointFrom<GlobalPoint>(
pointFrom<GlobalPoint>( scenePointerX - linearElementEditor.pointerOffset.x,
scenePointerX - linearElementEditor.pointerOffset.x, scenePointerY - linearElementEditor.pointerOffset.y,
scenePointerY - linearElementEditor.pointerOffset.y, ),
), ),
)
: undefined,
} }
: null, : null,
}, },
@@ -2417,7 +2368,7 @@ const pointDraggingUpdates = (
? nextArrow.points[0] ? nextArrow.points[0]
: endBindable : endBindable
? updateBoundPoint( ? updateBoundPoint(
nextArrow, element,
"endBinding", "endBinding",
nextArrow.endBinding, nextArrow.endBinding,
endBindable, endBindable,
@@ -2448,7 +2399,7 @@ const pointDraggingUpdates = (
? endLocalPoint ? endLocalPoint
: startBindable : startBindable
? updateBoundPoint( ? updateBoundPoint(
nextArrow, element,
"startBinding", "startBinding",
nextArrow.startBinding, nextArrow.startBinding,
startBindable, startBindable,
+14 -3
View File
@@ -11,6 +11,7 @@ import type { Mutable } from "@excalidraw/common/utility-types";
import { ShapeCache } from "./shape"; import { ShapeCache } from "./shape";
import { updateElbowArrowPoints } from "./elbowArrow"; import { updateElbowArrowPoints } from "./elbowArrow";
import { ensureSchemaStateForElementType } from "./schema";
import { isElbowArrow } from "./typeChecks"; import { isElbowArrow } from "./typeChecks";
@@ -40,8 +41,6 @@ export const mutateElement = <TElement extends Mutable<ExcalidrawElement>>(
updates: ElementUpdate<TElement>, updates: ElementUpdate<TElement>,
options?: { options?: {
isDragging?: boolean; isDragging?: boolean;
isBindingEnabled?: boolean;
isMidpointSnappingEnabled?: boolean;
}, },
) => { ) => {
let didChange = false; let didChange = false;
@@ -139,6 +138,10 @@ export const mutateElement = <TElement extends Mutable<ExcalidrawElement>>(
element.version = updates.version ?? element.version + 1; element.version = updates.version ?? element.version + 1;
element.versionNonce = updates.versionNonce ?? randomInteger(); element.versionNonce = updates.versionNonce ?? randomInteger();
element.updated = getUpdatedTimestamp(); element.updated = getUpdatedTimestamp();
element.schemaState = ensureSchemaStateForElementType(
element.schemaState,
element.type,
) as TElement["schemaState"];
return element; return element;
}; };
@@ -168,13 +171,21 @@ export const newElementWith = <TElement extends ExcalidrawElement>(
return element; return element;
} }
return { const updatedElement = {
...element, ...element,
...updates, ...updates,
version: updates.version ?? element.version + 1, version: updates.version ?? element.version + 1,
versionNonce: updates.versionNonce ?? randomInteger(), versionNonce: updates.versionNonce ?? randomInteger(),
updated: getUpdatedTimestamp(), updated: getUpdatedTimestamp(),
}; };
return {
...updatedElement,
schemaState: ensureSchemaStateForElementType(
updatedElement.schemaState,
updatedElement.type,
),
};
}; };
/** /**
+3 -6
View File
@@ -4,7 +4,6 @@ import {
DEFAULT_FONT_SIZE, DEFAULT_FONT_SIZE,
DEFAULT_TEXT_ALIGN, DEFAULT_TEXT_ALIGN,
DEFAULT_VERTICAL_ALIGN, DEFAULT_VERTICAL_ALIGN,
DEFAULT_STROKE_STREAMLINE,
VERTICAL_ALIGN, VERTICAL_ALIGN,
randomInteger, randomInteger,
randomId, randomId,
@@ -21,6 +20,7 @@ import {
getElementAbsoluteCoords, getElementAbsoluteCoords,
getResizedElementAbsoluteCoords, getResizedElementAbsoluteCoords,
} from "./bounds"; } from "./bounds";
import { ensureSchemaStateForElementType } from "./schema";
import { newElementWith } from "./mutateElement"; import { newElementWith } from "./mutateElement";
import { getBoundTextMaxWidth } from "./textElement"; import { getBoundTextMaxWidth } from "./textElement";
import { normalizeText, measureText } from "./textMeasurements"; import { normalizeText, measureText } from "./textMeasurements";
@@ -71,6 +71,7 @@ export type ElementConstructorOpts = MarkOptional<
| "roughness" | "roughness"
| "strokeWidth" | "strokeWidth"
| "roundness" | "roundness"
| "schemaState"
| "locked" | "locked"
| "opacity" | "opacity"
| "customData" | "customData"
@@ -145,6 +146,7 @@ const _newElementBase = <T extends ExcalidrawElement>(
roundness, roundness,
seed: rest.seed ?? randomInteger(), seed: rest.seed ?? randomInteger(),
version: rest.version || 1, version: rest.version || 1,
schemaState: ensureSchemaStateForElementType(rest.schemaState, type),
versionNonce: rest.versionNonce ?? 0, versionNonce: rest.versionNonce ?? 0,
isDeleted: false as false, isDeleted: false as false,
boundElements, boundElements,
@@ -445,7 +447,6 @@ export const newFreeDrawElement = (
type: "freedraw"; type: "freedraw";
points?: ExcalidrawFreeDrawElement["points"]; points?: ExcalidrawFreeDrawElement["points"];
simulatePressure: boolean; simulatePressure: boolean;
strokeOptions?: ExcalidrawFreeDrawElement["strokeOptions"];
pressures?: ExcalidrawFreeDrawElement["pressures"]; pressures?: ExcalidrawFreeDrawElement["pressures"];
} & ElementConstructorOpts, } & ElementConstructorOpts,
): NonDeleted<ExcalidrawFreeDrawElement> => { ): NonDeleted<ExcalidrawFreeDrawElement> => {
@@ -454,10 +455,6 @@ export const newFreeDrawElement = (
points: opts.points || [], points: opts.points || [],
pressures: opts.pressures || [], pressures: opts.pressures || [],
simulatePressure: opts.simulatePressure, simulatePressure: opts.simulatePressure,
strokeOptions: opts.strokeOptions ?? {
variability: "variable",
streamline: DEFAULT_STROKE_STREAMLINE,
},
}; };
}; };
+104 -51
View File
@@ -22,6 +22,7 @@ import {
isRTL, isRTL,
getVerticalOffset, getVerticalOffset,
invariant, invariant,
isTransparent,
applyDarkModeFilter, applyDarkModeFilter,
isSafari, isSafari,
} from "@excalidraw/common"; } from "@excalidraw/common";
@@ -78,6 +79,7 @@ import type {
ExcalidrawFrameLikeElement, ExcalidrawFrameLikeElement,
NonDeletedSceneElementsMap, NonDeletedSceneElementsMap,
ElementsMap, ElementsMap,
ExcalidrawFrameElement,
} from "./types"; } from "./types";
import type { RoughCanvas } from "roughjs/bin/canvas"; import type { RoughCanvas } from "roughjs/bin/canvas";
@@ -422,10 +424,10 @@ const drawElementOnCanvas = (
for (const shape of shapes) { for (const shape of shapes) {
if (typeof shape === "string") { if (typeof shape === "string") {
context.fillStyle = applyDarkModeFilter( context.fillStyle =
element.strokeColor, renderConfig.theme === THEME.DARK
renderConfig.theme === THEME.DARK, ? applyDarkModeFilter(element.strokeColor)
); : element.strokeColor;
context.fill(new Path2D(shape)); context.fill(new Path2D(shape));
} else { } else {
rc.draw(shape); rc.draw(shape);
@@ -555,10 +557,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 = applyDarkModeFilter( context.fillStyle =
element.strokeColor, renderConfig.theme === THEME.DARK
renderConfig.theme === THEME.DARK, ? applyDarkModeFilter(element.strokeColor)
); : 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
@@ -777,6 +779,45 @@ export const renderSelectionElement = (
context.restore(); context.restore();
}; };
export const renderFrameBackground = (
frame: ExcalidrawFrameElement,
context: CanvasRenderingContext2D,
appState: StaticCanvasAppState | InteractiveCanvasAppState,
opts?: {
roundCorners?: boolean;
},
) => {
if (isTransparent(frame.backgroundColor)) {
return;
}
context.save();
context.translate(frame.x + appState.scrollX, frame.y + appState.scrollY);
context.fillStyle =
appState.theme === THEME.DARK
? applyDarkModeFilter(frame.backgroundColor)
: frame.backgroundColor;
const shouldRoundCorners = opts?.roundCorners ?? true;
if (shouldRoundCorners && FRAME_STYLE.radius && context.roundRect) {
context.beginPath();
context.roundRect(
0,
0,
frame.width,
frame.height,
FRAME_STYLE.radius / appState.zoom.value,
);
context.fill();
context.closePath();
} else {
context.fillRect(0, 0, frame.width, frame.height);
}
context.restore();
};
export const renderElement = ( export const renderElement = (
element: NonDeletedExcalidrawElement, element: NonDeletedExcalidrawElement,
elementsMap: RenderableElementsMap, elementsMap: RenderableElementsMap,
@@ -808,13 +849,12 @@ export const renderElement = (
element.x + appState.scrollX, element.x + appState.scrollX,
element.y + appState.scrollY, element.y + appState.scrollY,
); );
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 = applyDarkModeFilter( context.strokeStyle =
FRAME_STYLE.strokeColor, appState.theme === THEME.DARK
appState.theme === THEME.DARK, ? applyDarkModeFilter(FRAME_STYLE.strokeColor)
); : 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)) {
@@ -889,10 +929,8 @@ export const renderElement = (
case "embeddable": { case "embeddable": {
if (renderConfig.isExporting) { if (renderConfig.isExporting) {
const [x1, y1, x2, y2] = getElementAbsoluteCoords(element, elementsMap); const [x1, y1, x2, y2] = getElementAbsoluteCoords(element, elementsMap);
const centerX = (x1 + x2) / 2; const cx = (x1 + x2) / 2 + appState.scrollX;
const centerY = (y1 + y2) / 2; const cy = (y1 + y2) / 2 + appState.scrollY;
const cx = centerX + appState.scrollX;
const cy = centerY + appState.scrollY;
let shiftX = (x2 - x1) / 2 - (element.x - x1); let shiftX = (x2 - x1) / 2 - (element.x - x1);
let shiftY = (y2 - y1) / 2 - (element.y - y1); let shiftY = (y2 - y1) / 2 - (element.y - y1);
if (isTextElement(element)) { if (isTextElement(element)) {
@@ -914,49 +952,64 @@ export const renderElement = (
const boundTextElement = getBoundTextElement(element, elementsMap); const boundTextElement = getBoundTextElement(element, elementsMap);
if (isArrowElement(element) && boundTextElement) { if (isArrowElement(element) && boundTextElement) {
// Draw arrow directly as vector and clear label hole separately. const tempCanvas = document.createElement("canvas");
// This avoids temp-canvas bitmap blit which introduces resampling blur.
const tempCanvasContext = tempCanvas.getContext("2d")!;
// Take max dimensions of arrow canvas so that when canvas is rotated
// the arrow doesn't get clipped
const maxDim = Math.max(distance(x1, x2), distance(y1, y2));
const padding = getCanvasPadding(element);
tempCanvas.width =
maxDim * appState.exportScale + padding * 10 * appState.exportScale;
tempCanvas.height =
maxDim * appState.exportScale + padding * 10 * appState.exportScale;
tempCanvasContext.translate(
tempCanvas.width / 2,
tempCanvas.height / 2,
);
tempCanvasContext.scale(appState.exportScale, appState.exportScale);
// Shift the canvas to left most point of the arrow
shiftX = element.width / 2 - (element.x - x1); shiftX = element.width / 2 - (element.x - x1);
shiftY = element.height / 2 - (element.y - y1); shiftY = element.height / 2 - (element.y - y1);
context.save(); tempCanvasContext.rotate(element.angle);
context.rotate(element.angle); const tempRc = rough.canvas(tempCanvas);
context.translate(-shiftX, -shiftY);
drawElementOnCanvas(element, rc, context, renderConfig);
context.restore();
tempCanvasContext.translate(-shiftX, -shiftY);
drawElementOnCanvas(element, tempRc, tempCanvasContext, renderConfig);
tempCanvasContext.translate(shiftX, shiftY);
tempCanvasContext.rotate(-element.angle);
// Shift the canvas to center of bound text
const [, , , , boundTextCx, boundTextCy] = getElementAbsoluteCoords( const [, , , , boundTextCx, boundTextCy] = getElementAbsoluteCoords(
boundTextElement, boundTextElement,
elementsMap, elementsMap,
); );
const holeX = const boundTextShiftX = (x1 + x2) / 2 - boundTextCx;
boundTextCx - const boundTextShiftY = (y1 + y2) / 2 - boundTextCy;
centerX - tempCanvasContext.translate(-boundTextShiftX, -boundTextShiftY);
boundTextElement.width / 2 -
BOUND_TEXT_PADDING;
const holeY =
boundTextCy -
centerY -
boundTextElement.height / 2 -
BOUND_TEXT_PADDING;
const holeWidth = boundTextElement.width + BOUND_TEXT_PADDING * 2;
const holeHeight = boundTextElement.height + BOUND_TEXT_PADDING * 2;
const isTransparentHole = // Clear the bound text area
"viewBackgroundColor" in appState && tempCanvasContext.clearRect(
(appState.viewBackgroundColor === "transparent" || -boundTextElement.width / 2,
!appState.viewBackgroundColor); -boundTextElement.height / 2,
if (!isTransparentHole) { boundTextElement.width,
context.save(); boundTextElement.height,
context.fillStyle = applyDarkModeFilter( );
renderConfig.canvasBackgroundColor, context.scale(1 / appState.exportScale, 1 / appState.exportScale);
renderConfig.theme === THEME.DARK, context.drawImage(
); tempCanvas,
context.fillRect(holeX, holeY, holeWidth, holeHeight); -tempCanvas.width / 2,
context.restore(); -tempCanvas.height / 2,
} else { tempCanvas.width,
context.clearRect(holeX, holeY, holeWidth, holeHeight); tempCanvas.height,
} );
} else { } else {
context.rotate(element.angle); context.rotate(element.angle);
+91
View File
@@ -0,0 +1,91 @@
/**
* Shared schema primitives used by element types and higher-level migrations.
*/
export const SCHEMA_INITIAL_TRACK_VERSION = 1 as const;
/** Core namespace reserved for built-in Excalidraw migrations. */
export const SCHEMA_CORE_NAMESPACE = "core" as const;
export type SchemaNamespace = typeof SCHEMA_CORE_NAMESPACE | `host.${string}`;
/**
* A schema track is an independent version line:
* - core tracks: "excalidraw.*"
* - host tracks: "host.<appId>.<track>"
*/
export type SchemaTrack = `excalidraw.${string}` | `host.${string}.${string}`;
export type ElementSchemaState = Readonly<{
tracks: Readonly<Record<string, number>>;
}>;
/** Core frame track id used by the frame background migration. */
export const CORE_FRAME_SCHEMA_TRACK = "excalidraw.shape.frame" as const;
/** Latest core track versions supported by this build. */
export const CORE_SUPPORTED_TRACKS = {
[CORE_FRAME_SCHEMA_TRACK]: 2,
} as const;
const getRequiredCoreTracksForElementType = (type: string) => {
if (type === "frame") {
return {
[CORE_FRAME_SCHEMA_TRACK]: CORE_SUPPORTED_TRACKS[CORE_FRAME_SCHEMA_TRACK],
} as const;
}
return {} as const;
};
const isValidTrackVersion = (version: unknown): version is number =>
typeof version === "number" &&
Number.isInteger(version) &&
version >= SCHEMA_INITIAL_TRACK_VERSION;
/**
* Ensures an element schema state is normalized and satisfies type defaults.
* Required core tracks are only ever bumped forward (never downgraded).
*/
export const ensureSchemaStateForElementType = (
schemaState: ElementSchemaState | undefined,
type: string,
): ElementSchemaState => {
const requiredTracks = getRequiredCoreTracksForElementType(type);
const currentTracks = schemaState?.tracks || {};
const nextTracks: Record<string, number> = {};
let didChange = !schemaState;
for (const [track, version] of Object.entries(
currentTracks as Record<string, unknown>,
)) {
if (isValidTrackVersion(version)) {
nextTracks[track] = version;
continue;
}
nextTracks[track] = SCHEMA_INITIAL_TRACK_VERSION;
didChange = true;
}
for (const [track, requiredVersion] of Object.entries(requiredTracks)) {
const currentVersion = nextTracks[track];
if (
!isValidTrackVersion(currentVersion) ||
currentVersion < requiredVersion
) {
nextTracks[track] = requiredVersion;
didChange = true;
}
}
if (!didChange) {
return schemaState!;
}
return { tracks: nextTracks };
};
/**
* Default schema state for newly created elements.
* New frames are created at the latest supported frame track version.
*/
export const getDefaultSchemaStateForElementType = (
type: string,
): ElementSchemaState => ensureSchemaStateForElementType(undefined, type);
+59 -61
View File
@@ -1,20 +1,22 @@
import { arrayToMap, isShallowEqual, type Bounds } from "@excalidraw/common"; import { arrayToMap, isShallowEqual } from "@excalidraw/common";
import type { import type {
AppState, AppState,
BoxSelectionMode,
InteractiveCanvasAppState, InteractiveCanvasAppState,
} from "@excalidraw/excalidraw/types"; } from "@excalidraw/excalidraw/types";
import { elementsOverlappingBBox, getElementAbsoluteCoords } from "./bounds"; import { getElementAbsoluteCoords, getElementBounds } from "./bounds";
import { isElementInViewport } from "./sizeHelpers"; import { isElementInViewport } from "./sizeHelpers";
import { import {
isBoundToContainer, isBoundToContainer,
isFrameLikeElement, isFrameLikeElement,
isLinearElement, isLinearElement,
isTextElement,
} from "./typeChecks"; } from "./typeChecks";
import { getFrameChildren } from "./frame"; import {
elementOverlapsWithFrame,
getContainingFrame,
getFrameChildren,
} from "./frame";
import { LinearElementEditor } from "./linearElementEditor"; import { LinearElementEditor } from "./linearElementEditor";
import { selectGroupsForSelectedElements } from "./groups"; import { selectGroupsForSelectedElements } from "./groups";
@@ -23,27 +25,9 @@ import type {
ElementsMap, ElementsMap,
ElementsMapOrArray, ElementsMapOrArray,
ExcalidrawElement, ExcalidrawElement,
ExcalidrawFrameLikeElement,
NonDeleted,
NonDeletedExcalidrawElement, NonDeletedExcalidrawElement,
} from "./types"; } from "./types";
const shouldIgnoreElementFromSelection = (
element: NonDeletedExcalidrawElement,
) => element.locked || isBoundToContainer(element);
const excludeElementsFromFrames = <T extends ExcalidrawElement>(
selectedElements: readonly T[],
framesInSelection: Set<ExcalidrawFrameLikeElement["id"]>,
) => {
return selectedElements.filter((element) => {
if (element.frameId && framesInSelection.has(element.frameId)) {
return false;
}
return true;
});
};
/** /**
* Frames and their containing elements are not to be selected at the same time. * Frames and their containing elements are not to be selected at the same time.
* Given an array of selected elements, if there are frames and their containing elements * Given an array of selected elements, if there are frames and their containing elements
@@ -63,38 +47,68 @@ export const excludeElementsInFramesFromSelection = <
} }
}); });
return excludeElementsFromFrames(selectedElements, framesInSelection); return selectedElements.filter((element) => {
if (element.frameId && framesInSelection.has(element.frameId)) {
return false;
}
return true;
});
}; };
export const getElementsWithinSelection = ( export const getElementsWithinSelection = (
elements: readonly NonDeletedExcalidrawElement[], elements: readonly NonDeletedExcalidrawElement[],
selection: NonDeletedExcalidrawElement, selection: NonDeletedExcalidrawElement,
elementsMap: ElementsMap, elementsMap: ElementsMap,
// TODO remove (this flag is effectively unused AFAIK)
excludeElementsInFrames: boolean = true, excludeElementsInFrames: boolean = true,
boxSelectionMode: BoxSelectionMode = "contain", ) => {
): NonDeletedExcalidrawElement[] => { const [selectionX1, selectionY1, selectionX2, selectionY2] =
const [selectionStartX, selectionStartY, selectionEndX, selectionEndY] =
getElementAbsoluteCoords(selection, elementsMap); getElementAbsoluteCoords(selection, elementsMap);
const selectionX1 = Math.min(selectionStartX, selectionEndX);
const selectionY1 = Math.min(selectionStartY, selectionEndY);
const selectionX2 = Math.max(selectionStartX, selectionEndX);
const selectionY2 = Math.max(selectionStartY, selectionEndY);
const selectionBounds = [
selectionX1,
selectionY1,
selectionX2,
selectionY2,
] as Bounds;
return elementsOverlappingBBox({ let elementsInSelection = elements.filter((element) => {
elements, let [elementX1, elementY1, elementX2, elementY2] = getElementBounds(
bounds: selectionBounds, element,
elementsMap, elementsMap,
type: boxSelectionMode, );
shouldIgnoreElementFromSelection,
excludeElementsInFrames, const containingFrame = getContainingFrame(element, elementsMap);
if (containingFrame) {
const [fx1, fy1, fx2, fy2] = getElementBounds(
containingFrame,
elementsMap,
);
elementX1 = Math.max(fx1, elementX1);
elementY1 = Math.max(fy1, elementY1);
elementX2 = Math.min(fx2, elementX2);
elementY2 = Math.min(fy2, elementY2);
}
return (
element.locked === false &&
element.type !== "selection" &&
!isBoundToContainer(element) &&
selectionX1 <= elementX1 &&
selectionY1 <= elementY1 &&
selectionX2 >= elementX2 &&
selectionY2 >= elementY2
);
}); });
elementsInSelection = excludeElementsInFrames
? excludeElementsInFramesFromSelection(elementsInSelection)
: 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 = (
@@ -274,19 +288,3 @@ export const getSelectionStateForElements = (
), ),
}; };
}; };
/**
* Returns editing or single-selected text element, if any.
*/
export const getActiveTextElement = (
selectedElements: readonly NonDeleted<ExcalidrawElement>[],
appState: Pick<AppState, "editingTextElement">,
) => {
const activeTextElement =
appState.editingTextElement ||
(selectedElements.length === 1 &&
isTextElement(selectedElements[0]) &&
selectedElements[0]);
return activeTextElement || null;
};
+121 -304
View File
@@ -1,6 +1,5 @@
import { simplify } from "points-on-curve"; import { simplify } from "points-on-curve";
import { getStroke } from "perfect-freehand"; import { getStroke } from "perfect-freehand";
import { LaserPointer } from "@excalidraw/laser-pointer";
import { import {
type GeometricShape, type GeometricShape,
@@ -25,7 +24,6 @@ import {
COLOR_PALETTE, COLOR_PALETTE,
LINE_POLYGON_POINT_MERGE_DISTANCE, LINE_POLYGON_POINT_MERGE_DISTANCE,
applyDarkModeFilter, applyDarkModeFilter,
DEFAULT_STROKE_STREAMLINE,
} from "@excalidraw/common"; } from "@excalidraw/common";
import { RoughGenerator } from "roughjs/bin/generator"; import { RoughGenerator } from "roughjs/bin/generator";
@@ -59,8 +57,8 @@ import { headingForPointIsHorizontal } from "./heading";
import { canChangeRoundness } from "./comparisons"; import { canChangeRoundness } from "./comparisons";
import { import {
elementCenterPoint,
getArrowheadPoints, getArrowheadPoints,
getCenterForBounds,
getDiamondPoints, getDiamondPoints,
getElementAbsoluteCoords, getElementAbsoluteCoords,
} from "./bounds"; } from "./bounds";
@@ -71,10 +69,10 @@ import type {
NonDeletedExcalidrawElement, NonDeletedExcalidrawElement,
ExcalidrawSelectionElement, ExcalidrawSelectionElement,
ExcalidrawLinearElement, ExcalidrawLinearElement,
Arrowhead,
ExcalidrawFreeDrawElement, ExcalidrawFreeDrawElement,
ElementsMap, ElementsMap,
ExcalidrawLineElement, ExcalidrawLineElement,
Arrowhead,
} from "./types"; } from "./types";
import type { Drawable, Options } from "roughjs/bin/core"; import type { Drawable, Options } from "roughjs/bin/core";
@@ -220,7 +218,9 @@ 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: applyDarkModeFilter(element.strokeColor, isDarkMode), stroke: isDarkMode
? applyDarkModeFilter(element.strokeColor)
: element.strokeColor,
preserveVertices: preserveVertices:
continuousPath || element.roughness < ROUGHNESS.cartoonist, continuousPath || element.roughness < ROUGHNESS.cartoonist,
}; };
@@ -234,7 +234,9 @@ export const generateRoughOptions = (
options.fillStyle = element.fillStyle; options.fillStyle = element.fillStyle;
options.fill = isTransparent(element.backgroundColor) options.fill = isTransparent(element.backgroundColor)
? undefined ? undefined
: applyDarkModeFilter(element.backgroundColor, isDarkMode); : isDarkMode
? applyDarkModeFilter(element.backgroundColor)
: element.backgroundColor;
if (element.type === "ellipse") { if (element.type === "ellipse") {
options.curveFitting = 1; options.curveFitting = 1;
} }
@@ -247,7 +249,9 @@ export const generateRoughOptions = (
options.fill = options.fill =
element.backgroundColor === "transparent" element.backgroundColor === "transparent"
? undefined ? undefined
: applyDarkModeFilter(element.backgroundColor, isDarkMode); : isDarkMode
? applyDarkModeFilter(element.backgroundColor)
: element.backgroundColor;
} }
return options; return options;
} }
@@ -292,82 +296,6 @@ const modifyIframeLikeForRoughOptions = (
return element; return element;
}; };
const generateArrowheadCardinalityOne = (
generator: RoughGenerator,
arrowheadPoints: number[] | null,
lineOptions: Options,
) => {
if (arrowheadPoints === null) {
return [];
}
const [, , x3, y3, x4, y4] = arrowheadPoints;
return [generator.line(x3, y3, x4, y4, lineOptions)];
};
const generateArrowheadLinesToTip = (
generator: RoughGenerator,
arrowheadPoints: number[] | null,
lineOptions: Options,
) => {
if (arrowheadPoints === null) {
return [];
}
const [x2, y2, x3, y3, x4, y4] = arrowheadPoints;
return [
generator.line(x3, y3, x2, y2, lineOptions),
generator.line(x4, y4, x2, y2, lineOptions),
];
};
const getArrowheadLineOptions = (
element: ExcalidrawLinearElement,
options: Options,
) => {
const lineOptions = { ...options };
if (element.strokeStyle === "dotted") {
// for dotted arrows caps, reduce gap to make it more legible
const dash = getDashArrayDotted(element.strokeWidth - 1);
lineOptions.strokeLineDash = [dash[0], dash[1] - 1];
} else {
// for solid/dashed, keep solid arrow cap
delete lineOptions.strokeLineDash;
}
lineOptions.roughness = Math.min(1, lineOptions.roughness || 0);
return lineOptions;
};
const generateArrowheadOutlineCircle = (
generator: RoughGenerator,
options: Options,
strokeColor: string,
arrowheadPoints: number[] | null,
fill: string,
diameterScale = 1,
) => {
if (arrowheadPoints === null) {
return [];
}
const [x, y, diameter] = arrowheadPoints;
const circleOptions = {
...options,
fill,
fillStyle: "solid" as const,
stroke: strokeColor,
roughness: Math.min(0.5, options.roughness || 0),
};
delete circleOptions.strokeLineDash;
return [generator.circle(x, y, diameter * diameterScale, circleOptions)];
};
const getArrowheadShapes = ( const getArrowheadShapes = (
element: ExcalidrawLinearElement, element: ExcalidrawLinearElement,
shape: Drawable[], shape: Drawable[],
@@ -378,53 +306,63 @@ const getArrowheadShapes = (
canvasBackgroundColor: string, canvasBackgroundColor: string,
isDarkMode: boolean, isDarkMode: boolean,
) => { ) => {
if (arrowhead === null) { const arrowheadPoints = getArrowheadPoints(
element,
shape,
position,
arrowhead,
);
if (arrowheadPoints === null) {
return []; return [];
} }
const strokeColor = applyDarkModeFilter(element.strokeColor, isDarkMode); const generateCrowfootOne = (
const backgroundFillColor = applyDarkModeFilter( arrowheadPoints: number[] | null,
canvasBackgroundColor, options: Options,
isDarkMode, ) => {
); if (arrowheadPoints === null) {
const cardinalityOneOrManyOffset = -0.25; return [];
const cardinalityZeroCircleScale = 0.8; }
const [, , x3, y3, x4, y4] = arrowheadPoints;
return [generator.line(x3, y3, x4, y4, options)];
};
const strokeColor = isDarkMode
? applyDarkModeFilter(element.strokeColor)
: element.strokeColor;
switch (arrowhead) { switch (arrowhead) {
case "dot":
case "circle": case "circle":
case "circle_outline": { case "circle_outline": {
return generateArrowheadOutlineCircle( const [x, y, diameter] = arrowheadPoints;
generator,
options, // always use solid stroke for arrowhead
strokeColor, delete options.strokeLineDash;
getArrowheadPoints(element, shape, position, arrowhead),
arrowhead === "circle_outline" ? backgroundFillColor : strokeColor, return [
); generator.circle(x, y, diameter, {
...options,
fill:
arrowhead === "circle_outline"
? canvasBackgroundColor
: strokeColor,
fillStyle: "solid",
stroke: strokeColor,
roughness: Math.min(0.5, options.roughness || 0),
}),
];
} }
case "triangle": case "triangle":
case "triangle_outline": { case "triangle_outline": {
const arrowheadPoints = getArrowheadPoints(
element,
shape,
position,
arrowhead,
);
if (arrowheadPoints === null) {
return [];
}
const [x, y, x2, y2, x3, y3] = arrowheadPoints; const [x, y, x2, y2, x3, y3] = arrowheadPoints;
const triangleOptions = {
...options,
fill:
arrowhead === "triangle_outline" ? backgroundFillColor : strokeColor,
fillStyle: "solid" as const,
roughness: Math.min(1, options.roughness || 0),
};
// always use solid stroke for arrowhead // always use solid stroke for arrowhead
delete triangleOptions.strokeLineDash; delete options.strokeLineDash;
return [ return [
generator.polygon( generator.polygon(
@@ -434,34 +372,24 @@ const getArrowheadShapes = (
[x3, y3], [x3, y3],
[x, y], [x, y],
], ],
triangleOptions, {
...options,
fill:
arrowhead === "triangle_outline"
? canvasBackgroundColor
: strokeColor,
fillStyle: "solid",
roughness: Math.min(1, options.roughness || 0),
},
), ),
]; ];
} }
case "diamond": case "diamond":
case "diamond_outline": { case "diamond_outline": {
const arrowheadPoints = getArrowheadPoints(
element,
shape,
position,
arrowhead,
);
if (arrowheadPoints === null) {
return [];
}
const [x, y, x2, y2, x3, y3, x4, y4] = arrowheadPoints; const [x, y, x2, y2, x3, y3, x4, y4] = arrowheadPoints;
const diamondOptions = {
...options,
fill:
arrowhead === "diamond_outline" ? backgroundFillColor : strokeColor,
fillStyle: "solid" as const,
roughness: Math.min(1, options.roughness || 0),
};
// always use solid stroke for arrowhead // always use solid stroke for arrowhead
delete diamondOptions.strokeLineDash; delete options.strokeLineDash;
return [ return [
generator.polygon( generator.polygon(
@@ -472,117 +400,53 @@ const getArrowheadShapes = (
[x4, y4], [x4, y4],
[x, y], [x, y],
], ],
diamondOptions, {
), ...options,
]; fill:
} arrowhead === "diamond_outline"
case "cardinality_one": ? canvasBackgroundColor
return generateArrowheadCardinalityOne( : strokeColor,
generator, fillStyle: "solid",
getArrowheadPoints(element, shape, position, arrowhead), roughness: Math.min(1, options.roughness || 0),
getArrowheadLineOptions(element, options), },
);
case "cardinality_many":
return generateArrowheadLinesToTip(
generator,
getArrowheadPoints(element, shape, position, arrowhead),
getArrowheadLineOptions(element, options),
);
case "cardinality_one_or_many": {
const lineOptions = getArrowheadLineOptions(element, options);
return [
...generateArrowheadLinesToTip(
generator,
getArrowheadPoints(element, shape, position, "cardinality_many"),
lineOptions,
),
...generateArrowheadCardinalityOne(
generator,
getArrowheadPoints(
element,
shape,
position,
"cardinality_one",
cardinalityOneOrManyOffset,
),
lineOptions,
),
];
}
case "cardinality_exactly_one": {
const lineOptions = getArrowheadLineOptions(element, options);
return [
...generateArrowheadCardinalityOne(
generator,
getArrowheadPoints(element, shape, position, "cardinality_one", -0.5),
lineOptions,
),
...generateArrowheadCardinalityOne(
generator,
getArrowheadPoints(element, shape, position, "cardinality_one"),
lineOptions,
),
];
}
case "cardinality_zero_or_one": {
const lineOptions = getArrowheadLineOptions(element, options);
return [
...generateArrowheadOutlineCircle(
generator,
options,
strokeColor,
getArrowheadPoints(element, shape, position, "circle_outline", 1.5),
backgroundFillColor,
cardinalityZeroCircleScale,
),
...generateArrowheadCardinalityOne(
generator,
getArrowheadPoints(element, shape, position, "cardinality_one", -0.5),
lineOptions,
),
];
}
case "cardinality_zero_or_many": {
const lineOptions = getArrowheadLineOptions(element, options);
return [
...generateArrowheadLinesToTip(
generator,
getArrowheadPoints(element, shape, position, "cardinality_many"),
lineOptions,
),
...generateArrowheadOutlineCircle(
generator,
options,
strokeColor,
getArrowheadPoints(element, shape, position, "circle_outline", 1.5),
backgroundFillColor,
cardinalityZeroCircleScale,
), ),
]; ];
} }
case "crowfoot_one":
return generateCrowfootOne(arrowheadPoints, options);
case "bar": case "bar":
case "arrow": case "arrow":
case "crowfoot_many":
case "crowfoot_one_or_many":
default: { default: {
return generateArrowheadLinesToTip( const [x2, y2, x3, y3, x4, y4] = arrowheadPoints;
generator,
getArrowheadPoints(element, shape, position, arrowhead), if (element.strokeStyle === "dotted") {
getArrowheadLineOptions(element, options), // for dotted arrows caps, reduce gap to make it more legible
); const dash = getDashArrayDotted(element.strokeWidth - 1);
options.strokeLineDash = [dash[0], dash[1] - 1];
} else {
// for solid/dashed, keep solid arrow cap
delete options.strokeLineDash;
}
options.roughness = Math.min(1, options.roughness || 0);
return [
generator.line(x3, y3, x2, y2, options),
generator.line(x4, y4, x2, y2, options),
...(arrowhead === "crowfoot_one_or_many"
? generateCrowfootOne(
getArrowheadPoints(element, shape, position, "crowfoot_one"),
options,
)
: []),
];
} }
} }
}; };
export const generateLinearCollisionShape = ( export const generateLinearCollisionShape = (
element: ExcalidrawLinearElement | ExcalidrawFreeDrawElement, element: ExcalidrawLinearElement | ExcalidrawFreeDrawElement,
elementsMap: ElementsMap, ) => {
): {
op: string;
data: number[];
}[] => {
const generator = new RoughGenerator(); const generator = new RoughGenerator();
const options: Options = { const options: Options = {
seed: element.seed, seed: element.seed,
@@ -591,7 +455,20 @@ export const generateLinearCollisionShape = (
roughness: 0, roughness: 0,
preserveVertices: true, preserveVertices: true,
}; };
const center = elementCenterPoint(element, elementsMap); const center = getCenterForBounds(
// Need a non-rotated center point
element.points.reduce(
(acc, point) => {
return [
Math.min(element.x + point[0], acc[0]),
Math.min(element.y + point[1], acc[1]),
Math.max(element.x + point[0], acc[2]),
Math.max(element.y + point[1], acc[3]),
];
},
[Infinity, Infinity, -Infinity, -Infinity],
),
);
switch (element.type) { switch (element.type) {
case "line": case "line":
@@ -1173,87 +1050,27 @@ const getFreeDrawSvgPath = (element: ExcalidrawFreeDrawElement) => {
) as SVGPathString; ) as SVGPathString;
}; };
/** export const getFreedrawOutlinePoints = (
* Freedraw stroke geometry tuning constants.
*
* These factors are not derived analytically they were tuned empirically by
* visually comparing rendered strokes until they matched the desired feel.
* Treat them as magic numbers backed by visual verification.
*/
const VARIABLE_WIDTH_FREEDRAW = {
/** Stroke size relative to `strokeWidth` for pressure-sensitive strokes. */
SIZE_FACTOR: 4.25,
THINNING: 0.6,
SMOOTHING: 0.5,
} as const;
const CONSTANT_WIDTH_FREEDRAW = {
/** Stroke size relative to `strokeWidth` for uniform (laser) strokes. */
SIZE_FACTOR: 1.4,
} as const;
const getFreedrawStreamline = (element: ExcalidrawFreeDrawElement) =>
element.strokeOptions?.streamline ?? DEFAULT_STROKE_STREAMLINE;
/**
* Pressure-sensitive (variable width) freedraw outline, rendered with
* perfect-freehand. This is the original Excalidraw freedraw look.
*/
const getVariableWidthFreedrawOutline = (
element: ExcalidrawFreeDrawElement, element: ExcalidrawFreeDrawElement,
): [number, number][] => { ) => {
// If input points are empty (should they ever be?) return a dot // If input points are empty (should they ever be?) return a dot
const inputPoints = element.simulatePressure const inputPoints = element.simulatePressure
? element.points ? element.points
: element.points.length : element.points.length
? element.points.map( ? element.points.map(([x, y], i) => [x, y, element.pressures[i]])
([x, y], i) => [x, y, element.pressures[i]] as [number, number, number],
)
: [[0, 0, 0.5]]; : [[0, 0, 0.5]];
return getStroke(inputPoints as number[][], { return getStroke(inputPoints as number[][], {
simulatePressure: element.simulatePressure, simulatePressure: element.simulatePressure,
size: element.strokeWidth * VARIABLE_WIDTH_FREEDRAW.SIZE_FACTOR, size: element.strokeWidth * 4.25,
thinning: VARIABLE_WIDTH_FREEDRAW.THINNING, thinning: 0.6,
smoothing: VARIABLE_WIDTH_FREEDRAW.SMOOTHING, smoothing: 0.5,
streamline: getFreedrawStreamline(element), streamline: 0.5,
easing: (t) => Math.sin((t * Math.PI) / 2), // https://easings.net/#easeOutSine easing: (t) => Math.sin((t * Math.PI) / 2), // https://easings.net/#easeOutSine
last: true, last: true,
}) as [number, number][]; }) as [number, number][];
}; };
const createLaserPointer = (element: ExcalidrawFreeDrawElement) =>
new LaserPointer({
size: element.strokeWidth * CONSTANT_WIDTH_FREEDRAW.SIZE_FACTOR,
streamline: getFreedrawStreamline(element),
simplify: 0,
sizeMapping: (details) => Math.max(0.1, details.pressure),
});
/**
* Uniform (constant width) freedraw outline, rendered with the laser-pointer
* geometry. Pressure is pinned to 1 so the stroke keeps a constant width.
*/
const getConstantWidthFreedrawOutline = (
element: ExcalidrawFreeDrawElement,
): [number, number][] => {
const laserPointer = createLaserPointer(element);
element.points.map(([x, y]) => laserPointer.addPoint([x, y, 1]));
return laserPointer
.getStrokeOutline()
.map(([x, y]) => [x, y] as [number, number]);
};
export const getFreedrawOutlinePoints = (
element: ExcalidrawFreeDrawElement,
): [number, number][] => {
// Unknown/absent variability falls back to the original variable rendering.
return element.strokeOptions?.variability === "constant"
? getConstantWidthFreedrawOutline(element)
: getVariableWidthFreedrawOutline(element);
};
const med = (A: number[], B: number[]) => { const med = (A: number[], B: number[]) => {
return [(A[0] + B[0]) / 2, (A[1] + B[1]) / 2]; return [(A[0] + B[0]) / 2, (A[1] + B[1]) / 2];
}; };
+64 -62
View File
@@ -1,56 +1,59 @@
import { arrayToMap } from "@excalidraw/common"; import { arrayToMapWithIndex } from "@excalidraw/common";
import type { ExcalidrawElement } from "./types"; import type { ExcalidrawElement } from "./types";
const defragmentGroups = (elements: readonly ExcalidrawElement[]) => { const normalizeGroupElementOrder = (elements: readonly ExcalidrawElement[]) => {
const groupIdAtLevel = (element: ExcalidrawElement, level: number) => { const origElements: ExcalidrawElement[] = elements.slice();
return element.groupIds[element.groupIds.length - level - 1]; const sortedElements = new Set<ExcalidrawElement>();
};
const orderLevel = ( const orderInnerGroups = (
levelElements: readonly ExcalidrawElement[], elements: readonly ExcalidrawElement[],
level: number,
): ExcalidrawElement[] => { ): ExcalidrawElement[] => {
const buckets = new Map<string, ExcalidrawElement[]>(); const firstGroupSig = elements[0]?.groupIds?.join("");
// Slots preserve first-occurrence order: a groupId reserves its slot const aGroup: ExcalidrawElement[] = [elements[0]];
// the first time one of its members is seen; loose elements occupy const bGroup: ExcalidrawElement[] = [];
// their own slot. Groups are then expanded (and recursed into) in place. for (const element of elements.slice(1)) {
const slots: (ExcalidrawElement | string)[] = []; if (element.groupIds?.join("") === firstGroupSig) {
aGroup.push(element);
for (const element of levelElements) { } else {
const groupId = groupIdAtLevel(element, level); bGroup.push(element);
if (groupId === undefined) {
slots.push(element);
continue;
} }
let bucket = buckets.get(groupId);
if (!bucket) {
bucket = [];
buckets.set(groupId, bucket);
slots.push(groupId);
}
bucket.push(element);
} }
return bGroup.length ? [...aGroup, ...orderInnerGroups(bGroup)] : aGroup;
return slots.flatMap((slot) =>
typeof slot === "string"
? orderLevel(buckets.get(slot)!, level + 1)
: [slot],
);
}; };
// `groupIds` is stored innermost-first, so the outermost group is the const groupHandledElements = new Map<string, true>();
// last entry. We recurse from level 0 (outermost) inward.
const sortedElements = orderLevel(elements, 0); origElements.forEach((element, idx) => {
if (groupHandledElements.has(element.id)) {
return;
}
if (element.groupIds?.length) {
const topGroup = element.groupIds[element.groupIds.length - 1];
const groupElements = origElements.slice(idx).filter((element) => {
const ret = element?.groupIds?.some((id) => id === topGroup);
if (ret) {
groupHandledElements.set(element!.id, true);
}
return ret;
});
for (const elem of orderInnerGroups(groupElements)) {
sortedElements.add(elem);
}
} else {
sortedElements.add(element);
}
});
// if there's a bug which resulted in losing some of the elements, return // if there's a bug which resulted in losing some of the elements, return
// original instead as that's better than losing data // original instead as that's better than losing data
if (sortedElements.length !== elements.length) { if (sortedElements.size !== elements.length) {
console.error("defragmentGroups: lost some elements... bailing!"); console.error("normalizeGroupElementOrder: lost some elements... bailing!");
return elements; return elements;
} }
return sortedElements; return [...sortedElements];
}; };
/** /**
@@ -65,40 +68,39 @@ const defragmentGroups = (elements: readonly ExcalidrawElement[]) => {
const normalizeBoundElementsOrder = ( const normalizeBoundElementsOrder = (
elements: readonly ExcalidrawElement[], elements: readonly ExcalidrawElement[],
) => { ) => {
const elementsMap = arrayToMap(elements); const elementsMap = arrayToMapWithIndex(elements);
const origElements: (ExcalidrawElement | null)[] = elements.slice();
const sortedElements = new Set<ExcalidrawElement>(); const sortedElements = new Set<ExcalidrawElement>();
for (const element of elements) { origElements.forEach((element, idx) => {
if (sortedElements.has(element)) { if (!element) {
continue; return;
} }
if (element.boundElements?.length) { if (element.boundElements?.length) {
sortedElements.add(element); sortedElements.add(element);
for (const boundElement of element.boundElements) { origElements[idx] = null;
element.boundElements.forEach((boundElement) => {
const child = elementsMap.get(boundElement.id); const child = elementsMap.get(boundElement.id);
if (child && boundElement.type === "text") { if (child && boundElement.type === "text") {
sortedElements.add(child); sortedElements.add(child[0]);
origElements[child[1]] = null;
} }
});
} else if (element.type === "text" && element.containerId) {
const parent = elementsMap.get(element.containerId);
if (!parent?.[0].boundElements?.find((x) => x.id === element.id)) {
sortedElements.add(element);
origElements[idx] = null;
// if element has a container and container lists it, skip this element
// as it'll be taken care of by the container
} }
continue; } else {
sortedElements.add(element);
origElements[idx] = null;
} }
});
// if element has a container and container lists it, skip this element
// as it'll be taken care of by the container
if (
element.type === "text" &&
element.containerId &&
elementsMap
.get(element.containerId)
?.boundElements?.some((el) => el.id === element.id)
) {
continue;
}
sortedElements.add(element);
}
// if there's a bug which resulted in losing some of the elements, return // if there's a bug which resulted in losing some of the elements, return
// original instead as that's better than losing data // original instead as that's better than losing data
@@ -115,5 +117,5 @@ const normalizeBoundElementsOrder = (
export const normalizeElementOrder = ( export const normalizeElementOrder = (
elements: readonly ExcalidrawElement[], elements: readonly ExcalidrawElement[],
) => { ) => {
return normalizeBoundElementsOrder(defragmentGroups(elements)); return normalizeBoundElementsOrder(normalizeGroupElementOrder(elements));
}; };
+1 -3
View File
@@ -347,7 +347,6 @@ export const getContainerCenter = (
midSegmentMidpoint = LinearElementEditor.getSegmentMidPoint( midSegmentMidpoint = LinearElementEditor.getSegmentMidPoint(
container, container,
index + 1, index + 1,
elementsMap,
); );
} }
return { x: midSegmentMidpoint[0], y: midSegmentMidpoint[1] }; return { x: midSegmentMidpoint[0], y: midSegmentMidpoint[1] };
@@ -442,8 +441,7 @@ const VALID_CONTAINER_TYPES = new Set([
export const isValidTextContainer = (element: { export const isValidTextContainer = (element: {
type: ExcalidrawElementType; type: ExcalidrawElementType;
}): element is ExcalidrawTextContainer => }) => VALID_CONTAINER_TYPES.has(element.type);
VALID_CONTAINER_TYPES.has(element.type);
export const computeContainerDimensionForBoundText = ( export const computeContainerDimensionForBoundText = (
dimension: number, dimension: number,
+37 -207
View File
@@ -4,22 +4,6 @@ import { charWidth, getLineWidth } from "./textMeasurements";
import type { FontString } from "./types"; import type { FontString } from "./types";
/**
* This module approximates browser-like soft wrapping for Excalidraw text.
*
* The flow is:
* 1. `parseTokens()` splits a hard line into breakable tokens using a unicode-aware regex.
* 2. `getWrappedTextLines()` reflows each hard line into one or more visual lines and
* records where each visual line came from in the source text.
* 3. `wrapLine()` assembles tokens into lines, and `wrapWord()` handles a single token
* that is wider than the available width.
* 4. `trimLine()` / `trimLineEndAtSoftBreak()` mirror browser behavior around trailing
* whitespace so the rendered text stays consistent with what users see on canvas.
*
* Mostly, you'll want to use wrapText(). getWrappedTextLines() is for callers
* that need metadata such as mapping visual lines back to `originalText`
* for caret placement or future editor features.
*/
let cachedCjkRegex: RegExp | undefined; let cachedCjkRegex: RegExp | undefined;
let cachedLineBreakRegex: RegExp | undefined; let cachedLineBreakRegex: RegExp | undefined;
let cachedEmojiRegex: RegExp | undefined; let cachedEmojiRegex: RegExp | undefined;
@@ -374,10 +358,6 @@ const Break = {
/** /**
* Breaks the line into the tokens based on the found line break opporutnities. * Breaks the line into the tokens based on the found line break opporutnities.
*
* Note: tokenization normalizes to NFC first so decomposed graphemes are treated as
* their composed variants for wrapping. Any code that needs exact source offsets should
* keep in mind that this assumes the input text is already NFC-normalized.
*/ */
export const parseTokens = (line: string) => { export const parseTokens = (line: string) => {
const breakLineRegex = getLineBreakRegex(); const breakLineRegex = getLineBreakRegex();
@@ -390,120 +370,56 @@ export const parseTokens = (line: string) => {
/** /**
* Wraps the original text into the lines based on the given width. * Wraps the original text into the lines based on the given width.
*
* This is a convenience adapter over `getWrappedTextLines()` for call sites
* that only need the rendered wrapped string and not the source offsets.
*/ */
export const wrapText = ( export const wrapText = (
text: string, text: string,
font: FontString, font: FontString,
maxWidth: number, maxWidth: number,
): string => { ): string => {
return getWrappedTextLines(text, font, maxWidth)
.map((line) => line.text)
.join("\n");
};
/**
* A single rendered visual line produced from the original text.
*
* `start` and `end` are end-exclusive code-unit offsets into the original text, and do
* not include synthetic soft line breaks inserted by this module. If trailing whitespace
* was trimmed away at a wrap boundary, `end` points to the last rendered character.
*/
export type WrappedTextLine = {
text: string;
start: number;
end: number;
};
/**
* Splits only on existing hard line breaks and preserves original offsets.
*/
const getHardLineBreaks = (text: string): WrappedTextLine[] => {
let offset = 0;
return text.split("\n").map((line) => {
const start = offset;
const end = start + line.length;
offset = end + 1;
return {
text: line,
start,
end,
};
});
};
/**
* Returns the rendered visual lines together with their source offsets.
*
* This is the source-of-truth wrapping pipeline for callers that need more than the
* final wrapped string, for example caret placement or future editor/rich-text mapping.
*/
export const getWrappedTextLines = (
text: string,
font: FontString,
maxWidth: number,
): WrappedTextLine[] => {
// if maxWidth is not finite or NaN which can happen in case of bugs in // if maxWidth is not finite or NaN which can happen in case of bugs in
// computation, we need to make sure we don't continue as we'll end up // computation, we need to make sure we don't continue as we'll end up
// in an infinite loop // in an infinite loop
if (!Number.isFinite(maxWidth) || maxWidth < 0) { if (!Number.isFinite(maxWidth) || maxWidth < 0) {
return getHardLineBreaks(text); return text;
} }
const lines: WrappedTextLine[] = []; const lines: Array<string> = [];
let offset = 0; const originalLines = text.split("\n");
for (const originalLine of text.split("\n")) { for (const originalLine of originalLines) {
const originalLineWidth = getLineWidth(originalLine, font); const currentLineWidth = getLineWidth(originalLine, font);
if (originalLineWidth <= maxWidth) { if (currentLineWidth <= maxWidth) {
lines.push({ lines.push(originalLine);
text: originalLine, continue;
start: offset,
end: offset + originalLine.length,
});
} else {
lines.push(...wrapLine(originalLine, font, maxWidth, offset));
} }
offset += originalLine.length + 1; const wrappedLine = wrapLine(originalLine, font, maxWidth);
lines.push(...wrappedLine);
} }
return lines; return lines.join("\n");
}; };
/** /**
* Wraps a single hard line into one or more visual lines. * Wraps the original line into the lines based on the given width.
*
* The line-local offsets are tracked in original-text code units so
* we can map the visual line back to the source.
*/ */
const wrapLine = ( const wrapLine = (
line: string, line: string,
font: FontString, font: FontString,
maxWidth: number, maxWidth: number,
lineStart: number, ): string[] => {
): WrappedTextLine[] => { const lines: Array<string> = [];
const lines: WrappedTextLine[] = [];
const tokens = parseTokens(line); const tokens = parseTokens(line);
const tokenIterator = tokens[Symbol.iterator]();
let currentLine = ""; let currentLine = "";
let currentLineStart = lineStart;
let currentLineEnd = lineStart;
let currentLineWidth = 0; let currentLineWidth = 0;
// Tracks the next token's code-unit position in the original source string.
let tokenOffset = lineStart;
let tokenIndex = 0;
while (tokenIndex < tokens.length) { let iterator = tokenIterator.next();
const token = tokens[tokenIndex];
const tokenStart = tokenOffset; while (!iterator.done) {
const tokenEnd = tokenStart + token.length; const token = iterator.value;
const testLine = currentLine + token; const testLine = currentLine + token;
// cache single codepoint whitespace, CJK or emoji width calc. as kerning should not apply here // cache single codepoint whitespace, CJK or emoji width calc. as kerning should not apply here
@@ -513,59 +429,37 @@ const wrapLine = (
// build up the current line, skipping length check for possibly trailing whitespaces // build up the current line, skipping length check for possibly trailing whitespaces
if (/\s/.test(token) || testLineWidth <= maxWidth) { if (/\s/.test(token) || testLineWidth <= maxWidth) {
if (!currentLine) {
currentLineStart = tokenStart;
}
currentLine = testLine; currentLine = testLine;
currentLineEnd = tokenEnd;
currentLineWidth = testLineWidth; currentLineWidth = testLineWidth;
tokenOffset = tokenEnd; iterator = tokenIterator.next();
tokenIndex++;
continue; continue;
} }
// current line is empty => just the token (word) is longer than `maxWidth` and needs to be wrapped // current line is empty => just the token (word) is longer than `maxWidth` and needs to be wrapped
if (!currentLine) { if (!currentLine) {
const wrappedWord = wrapWord(token, font, maxWidth, tokenStart); const wrappedWord = wrapWord(token, font, maxWidth);
const trailingLine = wrappedWord[wrappedWord.length - 1] ?? { const trailingLine = wrappedWord[wrappedWord.length - 1] ?? "";
text: "",
start: tokenStart,
end: tokenStart,
};
const precedingLines = wrappedWord.slice(0, -1); const precedingLines = wrappedWord.slice(0, -1);
lines.push(...precedingLines); lines.push(...precedingLines);
// trailing line of the wrapped word might still be joined with next token/s // trailing line of the wrapped word might still be joined with next token/s
currentLine = trailingLine.text; currentLine = trailingLine;
currentLineStart = trailingLine.start; currentLineWidth = getLineWidth(trailingLine, font);
currentLineEnd = trailingLine.end; iterator = tokenIterator.next();
currentLineWidth = getLineWidth(trailingLine.text, font);
tokenOffset = tokenEnd;
tokenIndex++;
} else { } else {
// push & reset, but don't iterate on the next token, as we didn't use it yet! // push & reset, but don't iterate on the next token, as we didn't use it yet!
lines.push( lines.push(currentLine.trimEnd());
trimLineEndAtSoftBreak(currentLine, currentLineStart, currentLineEnd),
);
// purposefully not iterating and not setting `currentLine` to `token`, so that we could use a simple !currentLine check above // purposefully not iterating and not setting `currentLine` to `token`, so that we could use a simple !currentLine check above
currentLine = ""; currentLine = "";
currentLineStart = tokenStart;
currentLineEnd = tokenStart;
currentLineWidth = 0; currentLineWidth = 0;
} }
} }
// iterator done, push the trailing line if exists // iterator done, push the trailing line if exists
if (currentLine) { if (currentLine) {
const trailingLine = trimLine( const trailingLine = trimLine(currentLine, font, maxWidth);
currentLine,
currentLineStart,
currentLineEnd,
font,
maxWidth,
);
lines.push(trailingLine); lines.push(trailingLine);
} }
@@ -573,100 +467,59 @@ const wrapLine = (
}; };
/** /**
* Wraps a single word that could not be placed on an empty line as-is. * Wraps the word into the lines based on the given width.
*/ */
const wrapWord = ( const wrapWord = (
word: string, word: string,
font: FontString, font: FontString,
maxWidth: number, maxWidth: number,
wordStart: number, ): Array<string> => {
): WrappedTextLine[] => {
// multi-codepoint emojis are already broken apart and shouldn't be broken further // multi-codepoint emojis are already broken apart and shouldn't be broken further
if (getEmojiRegex().test(word)) { if (getEmojiRegex().test(word)) {
return [ return [word];
{
text: word,
start: wordStart,
end: wordStart + word.length,
},
];
} }
satisfiesWordInvariant(word); satisfiesWordInvariant(word);
const lines: WrappedTextLine[] = []; const lines: Array<string> = [];
const chars = Array.from(word); const chars = Array.from(word);
let currentLine = ""; let currentLine = "";
let currentLineStart = wordStart;
let currentLineEnd = wordStart;
let currentLineWidth = 0; let currentLineWidth = 0;
let offset = wordStart;
for (const char of chars) { for (const char of chars) {
const charStart = offset;
const charEnd = charStart + char.length;
const _charWidth = charWidth.calculate(char, font); const _charWidth = charWidth.calculate(char, font);
const testLineWidth = currentLineWidth + _charWidth; const testLineWidth = currentLineWidth + _charWidth;
if (testLineWidth <= maxWidth) { if (testLineWidth <= maxWidth) {
if (!currentLine) {
currentLineStart = charStart;
}
currentLine = currentLine + char; currentLine = currentLine + char;
currentLineEnd = charEnd;
currentLineWidth = testLineWidth; currentLineWidth = testLineWidth;
offset = charEnd;
continue; continue;
} }
if (currentLine) { if (currentLine) {
lines.push({ lines.push(currentLine);
text: currentLine,
start: currentLineStart,
end: currentLineEnd,
});
} }
currentLine = char; currentLine = char;
currentLineStart = charStart;
currentLineEnd = charEnd;
currentLineWidth = _charWidth; currentLineWidth = _charWidth;
offset = charEnd;
} }
if (currentLine) { if (currentLine) {
lines.push({ lines.push(currentLine);
text: currentLine,
start: currentLineStart,
end: currentLineEnd,
});
} }
return lines; return lines;
}; };
/** /**
* Trims trailing whitespace that is exceeding the `maxWidth`. * Similarly to browsers, does not trim all trailing whitespaces, but only those exceeding the `maxWidth`.
*
* Used for the trailing visual line of a hard line, where some trailing
* whitespace may still be visible if it fits into the available width.
*/ */
const trimLine = ( const trimLine = (line: string, font: FontString, maxWidth: number) => {
line: string,
start: number,
end: number,
font: FontString,
maxWidth: number,
): WrappedTextLine => {
const shouldTrimWhitespaces = getLineWidth(line, font) > maxWidth; const shouldTrimWhitespaces = getLineWidth(line, font) > maxWidth;
if (!shouldTrimWhitespaces) { if (!shouldTrimWhitespaces) {
return { return line;
text: line,
start,
end,
};
} }
// defensively default to `trimeEnd` in case the regex does not match // defensively default to `trimeEnd` in case the regex does not match
@@ -690,30 +543,7 @@ const trimLine = (
trimmedLineWidth = testLineWidth; trimmedLineWidth = testLineWidth;
} }
return { return trimmedLine;
text: trimmedLine,
start,
end: end - (line.length - trimmedLine.length),
};
};
/**
* Used for internal soft-wrap boundaries, where trailing whitespace should not
* survive into the rendered line even though it still exists in the original
* text.
*/
const trimLineEndAtSoftBreak = (
line: string,
start: number,
end: number,
): WrappedTextLine => {
const trimmedLine = line.trimEnd();
return {
text: trimmedLine,
start,
end: end - (line.length - trimmedLine.length),
};
}; };
/** /**
-20
View File
@@ -392,23 +392,3 @@ export const canBecomePolygon = (
(points.length === 3 && !pointsEqual(points[0], points[points.length - 1])) (points.length === 3 && !pointsEqual(points[0], points[points.length - 1]))
); );
}; };
export const isEligibleFrameChildType = (type: ElementOrToolType) => {
switch (type) {
case "rectangle":
case "diamond":
case "ellipse":
case "arrow":
case "line":
case "freedraw":
case "text":
case "image":
case "frame":
case "embeddable": {
return true;
}
default: {
return false;
}
}
};
+9 -26
View File
@@ -15,7 +15,9 @@ import type {
ValueOf, ValueOf,
} from "@excalidraw/common/utility-types"; } from "@excalidraw/common/utility-types";
export type ChartType = "bar" | "line" | "radar"; import type { ElementSchemaState } from "./schema";
export type ChartType = "bar" | "line";
export type FillStyle = "hachure" | "cross-hatch" | "solid" | "zigzag"; export type FillStyle = "hachure" | "cross-hatch" | "solid" | "zigzag";
export type FontFamilyKeys = keyof typeof FONT_FAMILY; export type FontFamilyKeys = keyof typeof FONT_FAMILY;
export type FontFamilyValues = typeof FONT_FAMILY[FontFamilyKeys]; export type FontFamilyValues = typeof FONT_FAMILY[FontFamilyKeys];
@@ -58,6 +60,8 @@ type _ExcalidrawElementBase = Readonly<{
/** Integer that is sequentially incremented on each change. Used to reconcile /** Integer that is sequentially incremented on each change. Used to reconcile
elements during collaboration or when saving to server. */ elements during collaboration or when saving to server. */
version: number; version: number;
/** Per-track schema state used by migrations during restore. */
schemaState: ElementSchemaState;
/** Random integer that is regenerated on each change. /** Random integer that is regenerated on each change.
Used for deterministic reconciliation of updates during collaboration, Used for deterministic reconciliation of updates during collaboration,
in case the versions (see above) are identical. */ in case the versions (see above) are identical. */
@@ -303,32 +307,19 @@ export type PointsPositionUpdates = Map<
{ point: LocalPoint; isDragging?: boolean } { point: LocalPoint; isDragging?: boolean }
>; >;
export type CardinalityArrowhead =
| "cardinality_one"
| "cardinality_many"
| "cardinality_one_or_many"
| "cardinality_exactly_one"
| "cardinality_zero_or_one"
| "cardinality_zero_or_many";
export type ArrowheadLegacy =
| "dot"
| "crowfoot_one"
| "crowfoot_many"
| "crowfoot_one_or_many";
export type Arrowhead = export type Arrowhead =
| "arrow" | "arrow"
| "bar" | "bar"
| "dot" // legacy. Do not use for new elements.
| "circle" | "circle"
| "circle_outline" | "circle_outline"
| "triangle" | "triangle"
| "triangle_outline" | "triangle_outline"
| "diamond" | "diamond"
| "diamond_outline" | "diamond_outline"
| CardinalityArrowhead; | "crowfoot_one"
| "crowfoot_many"
export type AnyArrowhead = Arrowhead | ArrowheadLegacy; | "crowfoot_one_or_many";
export type ExcalidrawLinearElement = _ExcalidrawElementBase & export type ExcalidrawLinearElement = _ExcalidrawElementBase &
Readonly<{ Readonly<{
@@ -384,20 +375,12 @@ export type ExcalidrawElbowArrowElement = Merge<
} }
>; >;
export type StrokeVariability = "variable" | "constant";
export type StrokeOptions = Readonly<{
variability: StrokeVariability;
streamline: number;
}>;
export type ExcalidrawFreeDrawElement = _ExcalidrawElementBase & export type ExcalidrawFreeDrawElement = _ExcalidrawElementBase &
Readonly<{ Readonly<{
type: "freedraw"; type: "freedraw";
points: readonly LocalPoint[]; points: readonly LocalPoint[];
pressures: readonly number[]; pressures: readonly number[];
simulatePressure: boolean; simulatePressure: boolean;
strokeOptions: StrokeOptions;
}>; }>;
export type FileId = string & { _brand: "FileId" }; export type FileId = string & { _brand: "FileId" };
+12 -13
View File
@@ -124,7 +124,6 @@ const setElementShapesCacheEntry = <T extends ExcalidrawElement>(
*/ */
export function deconstructLinearOrFreeDrawElement( export function deconstructLinearOrFreeDrawElement(
element: ExcalidrawLinearElement | ExcalidrawFreeDrawElement, element: ExcalidrawLinearElement | ExcalidrawFreeDrawElement,
elementsMap: ElementsMap,
): [LineSegment<GlobalPoint>[], Curve<GlobalPoint>[]] { ): [LineSegment<GlobalPoint>[], Curve<GlobalPoint>[]] {
const cachedShape = getElementShapesCacheEntry(element, 0); const cachedShape = getElementShapesCacheEntry(element, 0);
@@ -132,7 +131,10 @@ export function deconstructLinearOrFreeDrawElement(
return cachedShape; return cachedShape;
} }
const ops = generateLinearCollisionShape(element, elementsMap); const ops = generateLinearCollisionShape(element) as {
op: string;
data: number[];
}[];
const lines = []; const lines = [];
const curves = []; const curves = [];
@@ -657,23 +659,20 @@ export const projectFixedPointOntoDiagonal = (
startOrEnd: "start" | "end", startOrEnd: "start" | "end",
elementsMap: ElementsMap, elementsMap: ElementsMap,
zoom: AppState["zoom"], zoom: AppState["zoom"],
isMidpointSnappingEnabled: boolean = true,
): GlobalPoint | null => { ): GlobalPoint | null => {
invariant(arrow.points.length >= 2, "Arrow must have at least two points"); invariant(arrow.points.length >= 2, "Arrow must have at least two points");
if (arrow.width < 3 && arrow.height < 3) { if (arrow.width < 3 && arrow.height < 3) {
return null; return null;
} }
if (isMidpointSnappingEnabled) { const sideMidPoint = getSnapOutlineMidPoint(
const sideMidPoint = getSnapOutlineMidPoint( point,
point, element,
element, elementsMap,
elementsMap, zoom,
zoom, );
); if (sideMidPoint) {
if (sideMidPoint) { return sideMidPoint;
return sideMidPoint;
}
} }
// Do the projection onto the diagonals (or center lines // Do the projection onto the diagonals (or center lines
+1 -1
View File
@@ -5,7 +5,6 @@ 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 {
@@ -18,6 +17,7 @@ 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 {
+3 -69
View File
@@ -1,7 +1,5 @@
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";
@@ -315,46 +313,12 @@ 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);
@@ -425,10 +389,6 @@ const shiftElementsByOne = (
]; ];
}); });
if (!hasSameElementIds(originalElements, elements)) {
return originalElements;
}
syncMovedIndices(elements, targetElementsMap); syncMovedIndices(elements, targetElementsMap);
return elements; return elements;
@@ -442,20 +402,11 @@ 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 | undefined; let leadingIndex: number;
let trailingIndex: number | undefined; let trailingIndex: number;
if (direction === "left") { if (direction === "left") {
if (containingFrame) { if (containingFrame) {
leadingIndex = findIndex(elements, (el) => leadingIndex = findIndex(elements, (el) =>
@@ -500,19 +451,6 @@ 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]);
@@ -537,10 +475,6 @@ const shiftElementsToEnd = (
...trailingElements, ...trailingElements,
]; ];
if (!hasSameElementIds(elements, nextElements)) {
return elements;
}
syncMovedIndices(nextElements, targetElementsMap); syncMovedIndices(nextElements, targetElementsMap);
return nextElements; return nextElements;
@@ -609,7 +543,7 @@ function shiftElementsAccountingForFrames(
for (const [frameId, children] of frameChildrenSets) { for (const [frameId, children] of frameChildrenSets) {
nextElements = shiftFunction( nextElements = shiftFunction(
nextElements, allElements,
appState, appState,
direction, direction,
frameId, frameId,
-60
View File
@@ -178,64 +178,6 @@ 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();
@@ -461,7 +403,6 @@ 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);
@@ -506,7 +447,6 @@ 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;
+3 -69
View File
@@ -1,14 +1,10 @@
import { pointFrom } from "@excalidraw/math"; import { pointFrom } from "@excalidraw/math";
import { arrayToMap, type Bounds, ROUNDNESS } from "@excalidraw/common";
import { API } from "@excalidraw/excalidraw/tests/helpers/api"; import { arrayToMap, ROUNDNESS } from "@excalidraw/common";
import type { LocalPoint } from "@excalidraw/math"; import type { LocalPoint } from "@excalidraw/math";
import { import { getElementAbsoluteCoords, getElementBounds } from "../src/bounds";
elementsOverlappingBBox,
getElementAbsoluteCoords,
getElementBounds,
} from "../src/bounds";
import type { ExcalidrawElement, ExcalidrawLinearElement } from "../src/types"; import type { ExcalidrawElement, ExcalidrawLinearElement } from "../src/types";
@@ -145,65 +141,3 @@ 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]);
});
});
+48 -3
View File
@@ -1,4 +1,4 @@
import { arrayToMap, reseed } from "@excalidraw/common"; import { arrayToMap } from "@excalidraw/common";
import { type GlobalPoint, type LocalPoint, pointFrom } from "@excalidraw/math"; import { type GlobalPoint, type LocalPoint, pointFrom } from "@excalidraw/math";
import { Excalidraw } from "@excalidraw/excalidraw"; import { Excalidraw } from "@excalidraw/excalidraw";
import { API } from "@excalidraw/excalidraw/tests/helpers/api"; import { API } from "@excalidraw/excalidraw/tests/helpers/api";
@@ -12,7 +12,6 @@ import { hitElementItself } from "../src/collision";
describe("check rotated elements can be hit:", () => { describe("check rotated elements can be hit:", () => {
beforeEach(async () => { beforeEach(async () => {
localStorage.clear(); localStorage.clear();
reseed(7);
await render(<Excalidraw handleKeyboardGlobally={true} />); await render(<Excalidraw handleKeyboardGlobally={true} />);
}); });
@@ -39,6 +38,53 @@ describe("check rotated elements can be hit:", () => {
}); });
}); });
describe("frame hit testing", () => {
it.each(["transparent", "#ffffff"])(
"does not hit frame inside regardless of background color (%s)",
(backgroundColor) => {
const element = API.createElement({
type: "frame",
x: 0,
y: 0,
width: 100,
height: 100,
backgroundColor,
});
const elementsMap = arrayToMap([element]);
expect(
hitElementItself({
point: pointFrom<GlobalPoint>(50, 50),
element,
threshold: 10,
elementsMap,
}),
).toBe(false);
},
);
it("hits frame outline", () => {
const element = API.createElement({
type: "frame",
x: 0,
y: 0,
width: 100,
height: 100,
backgroundColor: "#ffffff",
});
const elementsMap = arrayToMap([element]);
expect(
hitElementItself({
point: pointFrom<GlobalPoint>(0, 50),
element,
threshold: 1,
elementsMap,
}),
).toBe(true);
});
});
describe("hitElementItself cache", () => { describe("hitElementItself cache", () => {
beforeEach(async () => { beforeEach(async () => {
// reset cache // reset cache
@@ -57,7 +103,6 @@ describe("hitElementItself cache", () => {
}); });
localStorage.clear(); localStorage.clear();
reseed(7);
await render(<Excalidraw handleKeyboardGlobally={true} />); await render(<Excalidraw handleKeyboardGlobally={true} />);
}); });
+3 -63
View File
@@ -1,8 +1,9 @@
/* eslint-disable no-lone-blocks */ /* eslint-disable no-lone-blocks */
import { generateKeyBetween } from "fractional-indexing";
import { arrayToMap } from "@excalidraw/common"; import { arrayToMap } from "@excalidraw/common";
import { import {
InvalidFractionalIndexError,
syncInvalidIndices, syncInvalidIndices,
syncMovedIndices, syncMovedIndices,
validateFractionalIndices, validateFractionalIndices,
@@ -12,34 +13,13 @@ import { deepCopyElement } from "@excalidraw/element";
import { API } from "@excalidraw/excalidraw/tests/helpers/api"; import { API } from "@excalidraw/excalidraw/tests/helpers/api";
import {
generateKeyBetween,
validateOrderKey,
} from "@excalidraw/fractional-indexing";
import type { import type {
ElementsMap, ElementsMap,
ExcalidrawElement, ExcalidrawElement,
FractionalIndex, FractionalIndex,
} from "@excalidraw/element/types"; } from "@excalidraw/element/types";
describe("fractional index format validation", () => { import { InvalidFractionalIndexError } from "../src/fractionalIndex";
it("should reject malformed base62 order keys", () => {
expect(() => validateOrderKey("a!")).toThrow();
expect(() => validateOrderKey("a_")).toThrow();
expect(() => validateOrderKey("a1!")).toThrow();
expect(() => validateOrderKey("a1_")).toThrow();
expect(() => validateOrderKey("zd0032")).toThrow();
});
it("should accept valid base62 order keys", () => {
expect(() => validateOrderKey("Zz")).not.toThrow();
expect(() => validateOrderKey("a0")).not.toThrow();
expect(() => validateOrderKey("a1")).not.toThrow();
expect(() => validateOrderKey("a1V")).not.toThrow();
expect(() => validateOrderKey("z".padEnd(28, "z"))).not.toThrow();
});
});
describe("sync invalid indices with array order", () => { describe("sync invalid indices with array order", () => {
describe("should NOT sync empty array", () => { describe("should NOT sync empty array", () => {
@@ -124,46 +104,6 @@ describe("sync invalid indices with array order", () => {
}); });
}); });
describe("should sync when fractional index is malformed", () => {
// "zd0032" has head "z" which requires length 28 per getIntegerLength,
// but the string is far too short, so validateOrderKey throws for it
testInvalidIndicesSync({
elements: [{ id: "A", index: "zd0032" }],
expect: {
unchangedElements: [],
},
});
testInvalidIndicesSync({
elements: [
{ id: "A", index: "a1" },
{ id: "B", index: "zd0032" },
{ id: "C", index: "a3" },
],
expect: {
unchangedElements: ["A", "C"],
},
});
testInvalidIndicesSync({
elements: [{ id: "A", index: "a!" }],
expect: {
unchangedElements: [],
},
});
testInvalidIndicesSync({
elements: [
{ id: "A", index: "a1" },
{ id: "B", index: "a!" },
{ id: "C", index: "a2" },
],
expect: {
unchangedElements: ["A", "C"],
},
});
});
describe("should sync when fractional indices are duplicated", () => { describe("should sync when fractional indices are duplicated", () => {
testInvalidIndicesSync({ testInvalidIndicesSync({
elements: [ elements: [
+2 -622
View File
@@ -2,24 +2,15 @@ import {
convertToExcalidrawElements, convertToExcalidrawElements,
Excalidraw, Excalidraw,
} from "@excalidraw/excalidraw"; } from "@excalidraw/excalidraw";
import { arrayToMap } from "@excalidraw/common";
import { API } from "@excalidraw/excalidraw/tests/helpers/api"; import { API } from "@excalidraw/excalidraw/tests/helpers/api";
import { Keyboard, Pointer, UI } from "@excalidraw/excalidraw/tests/helpers/ui"; import { Keyboard, Pointer } from "@excalidraw/excalidraw/tests/helpers/ui";
import { getTextEditor } from "@excalidraw/excalidraw/tests/queries/dom";
import { import {
getCloneByOrigId, getCloneByOrigId,
render, render,
} from "@excalidraw/excalidraw/tests/test-utils"; } from "@excalidraw/excalidraw/tests/test-utils";
import { getSelectedElements } from "@excalidraw/excalidraw/scene"; import type { ExcalidrawElement } from "../src/types";
import { elementOverlapsWithFrame } from "../src/frame";
import type {
ExcalidrawElement,
ExcalidrawFrameLikeElement,
} from "../src/types";
const { h } = window; const { h } = window;
const mouse = new Pointer("mouse"); const mouse = new Pointer("mouse");
@@ -134,250 +125,6 @@ describe("adding elements to frames", () => {
}); });
}); });
it("should treat an element fully containing a frame as overlapping the frame", () => {
const containingRect = API.createElement({
type: "rectangle",
x: -50,
y: -50,
width: 250,
height: 250,
});
API.setElements([containingRect, frame]);
expect(
elementOverlapsWithFrame(
containingRect,
frame as ExcalidrawFrameLikeElement,
arrayToMap(h.elements),
),
).toBe(true);
});
it("should not add a newly created element to a frame behind a non-frame element", () => {
const cover = API.createElement({
id: "cover",
type: "rectangle",
x: 10,
y: 10,
width: 80,
height: 80,
backgroundColor: "#ffc9c9",
});
API.setElements([frame, cover]);
UI.clickTool("rectangle");
mouse.downAt(20, 20);
mouse.moveTo(40, 40);
mouse.upAt(40, 40);
const createdElement = h.elements.find(
(element) => element.id !== frame.id && element.id !== cover.id,
);
expect(createdElement?.frameId).toBe(null);
expect(h.elements.map((element) => element.id)).toEqual([
frame.id,
cover.id,
createdElement?.id,
]);
});
it("should add a newly created element to a frame over a non-frame element", () => {
const cover = API.createElement({
id: "cover",
type: "rectangle",
x: 10,
y: 10,
width: 80,
height: 80,
backgroundColor: "#ffc9c9",
});
API.setElements([cover, frame]);
UI.clickTool("rectangle");
mouse.downAt(20, 20);
mouse.moveTo(40, 40);
mouse.upAt(40, 40);
const createdElement = h.elements.find(
(element) => element.id !== frame.id && element.id !== cover.id,
);
expect(createdElement?.frameId).toBe(frame.id);
});
it("should highlight the target frame while creating a new element", () => {
API.setElements([frame]);
UI.clickTool("rectangle");
mouse.downAt(20, 20);
mouse.moveTo(40, 40);
expect(h.state.frameToHighlight?.id).toBe(frame.id);
mouse.upAt(40, 40);
expect(h.state.frameToHighlight).toBe(null);
});
it("should highlight the target frame while hovering with a creation tool", () => {
API.setElements([frame]);
UI.clickTool("rectangle");
mouse.moveTo(20, 20);
expect(h.state.frameToHighlight?.id).toBe(frame.id);
mouse.moveTo(200, 200);
expect(h.state.frameToHighlight).toBe(null);
});
it("should not add grid-snapped text outside the frame to the clicked frame", async () => {
const offsetFrame = API.createElement({
id: "offsetFrame",
type: "frame",
x: 10,
y: 0,
width: 150,
height: 150,
});
API.setElements([offsetFrame]);
API.setAppState({
gridModeEnabled: true,
});
UI.clickTool("text");
mouse.clickAt(12, 0);
await getTextEditor();
const createdText = h.elements.find(
(element) => element.id !== offsetFrame.id,
);
expect(createdText?.x).toBe(0);
expect(createdText?.y).toBe(0);
expect(createdText?.frameId).toBe(null);
});
it("should add a newly created element to a frame behind another frame", () => {
const lockedFrame = API.createElement({
id: "lockedFrame",
type: "frame",
x: 10,
y: 10,
width: 80,
height: 80,
locked: true,
});
API.setElements([frame, lockedFrame]);
UI.clickTool("rectangle");
mouse.downAt(20, 20);
mouse.moveTo(40, 40);
mouse.upAt(40, 40);
const createdElement = h.elements.find(
(element) => element.id !== frame.id && element.id !== lockedFrame.id,
);
expect(createdElement?.frameId).toBe(frame.id);
});
it("should insert a newly created frame child just below its frame", () => {
const frameChildUnderCursor = API.createElement({
id: "frameChildUnderCursor",
type: "rectangle",
x: 10,
y: 10,
width: 80,
height: 80,
backgroundColor: "#ffc9c9",
frameId: frame.id,
});
const otherFrameChild = API.createElement({
id: "otherFrameChild",
type: "rectangle",
x: 100,
y: 20,
width: 20,
height: 20,
frameId: frame.id,
});
API.setElements([frameChildUnderCursor, otherFrameChild, frame]);
UI.clickTool("rectangle");
mouse.downAt(20, 20);
mouse.moveTo(40, 40);
mouse.upAt(40, 40);
const createdElement = h.elements.find(
(element) =>
element.id !== frame.id &&
element.id !== frameChildUnderCursor.id &&
element.id !== otherFrameChild.id,
);
expect(createdElement?.frameId).toBe(frame.id);
expect(h.elements.map((element) => element.id)).toEqual([
frameChildUnderCursor.id,
otherFrameChild.id,
createdElement?.id,
frame.id,
]);
});
it("should insert a newly created frame child above the highest frame child", () => {
const frameChildUnderCursor = API.createElement({
id: "frameChildUnderCursor",
type: "rectangle",
x: 10,
y: 10,
width: 80,
height: 80,
backgroundColor: "#ffc9c9",
frameId: frame.id,
});
const otherFrameChild = API.createElement({
id: "otherFrameChild",
type: "rectangle",
x: 100,
y: 20,
width: 20,
height: 20,
frameId: frame.id,
});
API.setElements([frame, frameChildUnderCursor, otherFrameChild]);
UI.clickTool("rectangle");
mouse.downAt(20, 20);
mouse.moveTo(40, 40);
mouse.upAt(40, 40);
const createdElement = h.elements.find(
(element) =>
element.id !== frame.id &&
element.id !== frameChildUnderCursor.id &&
element.id !== otherFrameChild.id,
);
expect(createdElement?.frameId).toBe(frame.id);
expect(h.elements.map((element) => element.id)).toEqual([
frame.id,
frameChildUnderCursor.id,
otherFrameChild.id,
createdElement?.id,
]);
});
const commonTestCases = async ( const commonTestCases = async (
func: typeof resizeFrameOverElement | typeof dragElementIntoFrame, func: typeof resizeFrameOverElement | typeof dragElementIntoFrame,
) => { ) => {
@@ -668,373 +415,6 @@ describe("adding elements to frames", () => {
describe("dragging elements into the frame", async () => { describe("dragging elements into the frame", async () => {
await commonTestCases(dragElementIntoFrame); await commonTestCases(dragElementIntoFrame);
it("should add a dragged element fully containing the frame", () => {
const containingRect = API.createElement({
type: "rectangle",
x: 220,
y: 20,
width: 300,
height: 300,
});
API.setElements([frame, containingRect]);
dragElementIntoFrame(frame, containingRect);
expect(API.getElement(containingRect).frameId).toBe(frame.id);
});
it("should drag an element into a frame", () => {
API.setElements([rect2, frame]);
dragElementIntoFrame(frame, rect2);
expect(rect2.frameId).toBe(frame.id);
});
it("should 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]);
+3 -43
View File
@@ -326,59 +326,19 @@ describe("normalizeElementsOrder", () => {
]), ]),
[ [
"BA_rect1", "BA_rect1",
"CBA_rect3",
"CBA_rect7",
"BA_rect5", "BA_rect5",
"BA_rect6", "BA_rect6",
"A_rect2", "A_rect2",
"A_rect5", "A_rect5",
"CBA_rect3",
"CBA_rect7",
"rect4", "rect4",
"X_rect8", "X_rect8",
"YX_rect10",
"X_rect11", "X_rect11",
"YX_rect10",
"rect9", "rect9",
], ],
); );
assertOrder(
normalizeElementOrder([
API.createElement({
id: "A_rect1",
type: "rectangle",
groupIds: ["A"],
}),
API.createElement({
id: "CBA_rect2",
type: "rectangle",
groupIds: ["C", "B", "A"],
}),
API.createElement({
id: "A_rect3",
type: "rectangle",
groupIds: ["A"],
}),
]),
["A_rect1", "CBA_rect2", "A_rect3"],
);
assertOrder(
normalizeElementOrder([
API.createElement({
id: "abcT_rect1",
type: "rectangle",
groupIds: ["ab", "c", "T"],
}),
API.createElement({
id: "abcT_rect2",
type: "rectangle",
groupIds: ["a", "bc", "T"],
}),
API.createElement({
id: "abcT_rect3",
type: "rectangle",
groupIds: ["ab", "c", "T"],
}),
]),
["abcT_rect1", "abcT_rect3", "abcT_rect2"],
);
}); });
// TODO // TODO
+1 -70
View File
@@ -1,8 +1,4 @@
import { import { wrapText, parseTokens } from "../src/textWrapping";
getWrappedTextLines,
parseTokens,
wrapText,
} from "../src/textWrapping";
import type { FontString } from "../src/types"; import type { FontString } from "../src/types";
@@ -106,71 +102,6 @@ describe("Test wrapText", () => {
expect(res2).toBe(`\tA)\none\ntab\n- two\ntabs\n- 8\nspace\ns`); expect(res2).toBe(`\tA)\none\ntab\n- two\ntabs\n- 8\nspace\ns`);
}); });
it("should retain original text offsets for wrapped lines", () => {
expect(getWrappedTextLines("Hello World!", font, 60)).toEqual([
{
text: "Hello",
start: 0,
end: 5,
},
{
text: "World!",
start: 6,
end: 12,
},
]);
});
it("should exclude whitespace trimmed away at soft-wrap boundaries from line offsets", () => {
expect(getWrappedTextLines(" Hello World", font, 90)).toEqual([
{
text: " Hello",
start: 0,
end: 7,
},
{
text: "World",
start: 9,
end: 14,
},
]);
});
it("should retain offsets when wrapping a single long token", () => {
expect(getWrappedTextLines("Excalidraw", font, 50)).toEqual([
{
text: "Excal",
start: 0,
end: 5,
},
{
text: "idraw",
start: 5,
end: 10,
},
]);
});
it("should preserve empty hard lines in metadata", () => {
expect(getWrappedTextLines("A\n\nB", font, 100)).toEqual([
{
text: "A",
start: 0,
end: 1,
},
{
text: "",
start: 2,
end: 2,
},
{
text: "B",
start: 3,
end: 4,
},
]);
});
describe("When text is CJK", () => { describe("When text is CJK", () => {
it("should break each CJK character when width is very small", () => { it("should break each CJK character when width is very small", () => {
// "안녕하세요" (Hangul) + "こんにちは世界" (Hiragana, Kanji) + "コンニチハ" (Katakana) + "你好" (Han) = "Hello Hello World Hello Hi" // "안녕하세요" (Hangul) + "こんにちは世界" (Hiragana, Kanji) + "コンニチハ" (Katakana) + "你好" (Han) = "Hello Hello World Hello Hi"
-186
View File
@@ -1509,190 +1509,4 @@ 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"]]],
});
});
}); });
-1
View File
@@ -1,7 +1,6 @@
{ {
"extends": "../tsconfig.base.json", "extends": "../tsconfig.base.json",
"compilerOptions": { "compilerOptions": {
"rootDir": "../",
"outDir": "./dist/types" "outDir": "./dist/types"
}, },
"include": ["src/**/*", "global.d.ts"], "include": ["src/**/*", "global.d.ts"],
-88
View File
@@ -11,94 +11,6 @@ The change should be grouped under one of the below section and must contain PR
Please add the latest change on the top under the correct section. Please add the latest change on the top under the correct section.
--> -->
## Unreleased
## Excalidraw API
### 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`.
- `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).
### Features
- Added `ExcalidrawAPI.isDestroyed` flag. Set to `true` once the editor unmounts. Calling any `get*` method, `onStateChange`, or `onEvent` on a destroyed API instance will throw in development and `console.error` in production. The `ExcalidrawAPI` will be reset to `null` on umount, but to be extra safe, you should check `ExcalidrawAPI.isDestroyed` before calling these methods to guard against subtle race conditions in your code.
- Added `onMount`, `onInitialize`, and `onUnmount` props. `onMount` receives `{ excalidrawAPI, container }` once the editor root is mounted. `onInitialize` fires once the initial scene has loaded. `onUnmount` fires just before unmounting.
- Same events are also accessible imperatively through `api.onEvent(...)`.
```tsx
<Excalidraw
onExcalidrawAPI={(api) => {
api.onEvent("editor:mount", ({ excalidrawAPI, container }) => {
console.log(container);
});
api.onEvent("editor:initialize").then((readyApi) => {
readyApi.scrollToContent();
});
}}
/>
```
Note that in future releases, most, if not all, `excalidrawAPI.on*` subscriptions will be removed in favor of `excalidrawAPI.onEvent(name)`.
- Also added `"editor:unmount"` lifecycle event, only accessible via `api.onEvent("editor:unmount")`.
- Exported `<ExcalidrawAPIProvider/>`, `useExcalidrawAPI()`, `useAppStateValue(prop | props | selectorFunction)`, and `useOnExcalidrawStateChange(prop | props | selectorFunction, callback)` from the package. The imperative API also now exposes `onStateChange(prop | props | selectorFunction, callback?)`, and `onEvent(name, callback)`.
```tsx
<ExcalidrawAPIProvider>
<Excalidraw />
<Logger />
</ExcalidrawAPIProvider>;
function Logger() {
// initially null before the ExcalidrawAPIProvider initializes ater
// <Excalidraw/> renders
// When <Excalidraw/> unmounts, is reset back to null
const api = useExcalidrawAPI();
useAppStateValue("viewModeEnabled", (viewModeEnabled) => {
console.log("view mode changed:", viewModeEnabled);
});
React.useEffect(() => {
if (api) {
console.log("editor instance id:", api.id);
}
}, [api]);
return null;
}
```
- Added `onExport` so host apps can delay JSON export until async work completes. The handler receives the export data plus an `AbortSignal`, and may return a `Promise` or an async generator that yields progress updates for the built-in toast UI.
```tsx
<Excalidraw
onExport={async function* (_type, { files }, { signal }) {
yield { type: "progress", message: "Waiting for images..." };
await waitForImagesToLoad(files, signal);
if (signal.aborted) {
return;
}
yield { type: "progress", message: "Export ready", progress: 1 };
}}
/>
```
## Excalidraw Library ## Excalidraw Library
## 0.18.0 (2025-03-11) ## 0.18.0 (2025-03-11)
+14 -111
View File
@@ -1,10 +1,10 @@
# Excalidraw # Excalidraw
**Excalidraw** is exported as a React component that you can embed directly in your app. **Excalidraw** is exported as a component to be directly embedded in your project.
## Installation ## Installation
Install the package together with its React peer dependencies. Use `npm` or `yarn` to install the package.
```bash ```bash
npm install react react-dom @excalidraw/excalidraw npm install react react-dom @excalidraw/excalidraw
@@ -12,131 +12,34 @@ npm install react react-dom @excalidraw/excalidraw
yarn add react react-dom @excalidraw/excalidraw yarn add react react-dom @excalidraw/excalidraw
``` ```
> **Note**: If you want to try unreleased changes, use `@excalidraw/excalidraw@next`. > **Note**: If you don't want to wait for the next stable release and try out the unreleased changes, use `@excalidraw/excalidraw@next`.
## Quick start #### Self-hosting fonts
The minimum working setup has two easy-to-miss requirements: By default, Excalidraw will try to download all the used fonts from the [CDN](https://esm.run/@excalidraw/excalidraw/dist/prod).
1. Import the package CSS: For self-hosting purposes, you'll have to copy the content of the folder `node_modules/@excalidraw/excalidraw/dist/prod/fonts` to the path where your assets should be served from (i.e. `public/` directory in your project). In that case, you should also set `window.EXCALIDRAW_ASSET_PATH` to the very same path, i.e. `/` in case it's in the root:
```ts ```js
import "@excalidraw/excalidraw/index.css"; <script>window.EXCALIDRAW_ASSET_PATH = "/";</script>
``` ```
2. Render Excalidraw inside a container with a non-zero height. ### Dimensions of Excalidraw
```tsx Excalidraw takes _100%_ of `width` and `height` of the containing block so make sure the container in which you render Excalidraw has non zero dimensions.
import { Excalidraw } from "@excalidraw/excalidraw";
import "@excalidraw/excalidraw/index.css";
export default function App() {
return (
<div style={{ height: "100vh" }}>
<Excalidraw />
</div>
);
}
```
Excalidraw fills `100%` of the width and height of its parent. If the parent has no height, the canvas will not be visible.
## Next.js / SSR frameworks
Excalidraw should be rendered on the client. In SSR frameworks such as Next.js, use a client component and load it dynamically with SSR disabled.
```tsx
// app/components/ExcalidrawClient.tsx
"use client";
import { Excalidraw } from "@excalidraw/excalidraw";
import "@excalidraw/excalidraw/index.css";
export default function ExcalidrawClient() {
return (
<div style={{ height: "100vh" }}>
<Excalidraw />
</div>
);
}
```
```tsx
// app/page.tsx
import dynamic from "next/dynamic";
const ExcalidrawClient = dynamic(
() => import("./components/ExcalidrawClient"),
{ ssr: false },
);
export default function Page() {
return <ExcalidrawClient />;
}
```
See the local examples for complete setups:
- [examples/with-nextjs](https://github.com/excalidraw/excalidraw/tree/master/examples/with-nextjs)
- [examples/with-script-in-browser](https://github.com/excalidraw/excalidraw/tree/master/examples/with-script-in-browser)
## LLM / agent tips
If an LLM or coding agent is setting up Excalidraw, these shortcuts usually save more time than re-prompting:
- Start with a plain `<Excalidraw />` in a `100vh` container. Add refs, `initialData`, persistence, or custom UI only after the base embed works.
- If the canvas is blank, check the CSS import and parent height first. Those are the two most common integration failures.
- In Next.js or other SSR frameworks, assume client-only rendering first. Use `"use client"` and `dynamic(..., { ssr: false })` before debugging hydration or `window is not defined` errors.
- If imports or entrypoints are unclear, inspect `node_modules/@excalidraw/excalidraw/package.json`. The installed package exports are the source of truth.
- Do not set `window.EXCALIDRAW_ASSET_PATH` unless you are intentionally self-hosting fonts/assets.
- When docs and generated code drift, copy the nearest working example from this repo, especially `examples/with-nextjs` or `examples/with-script-in-browser`.
## Migrating to `@excalidraw/excalidraw@0.18.x`
Version `0.18.x` removes the old `types/`-prefixed deep import paths. If you were importing types from `@excalidraw/excalidraw/types/...`, switch to the new type-only subpaths below.
| Old path | New path |
| --- | --- |
| `@excalidraw/excalidraw/types/data/transform.js` | `@excalidraw/excalidraw/element/transform` |
| `@excalidraw/excalidraw/types/data/types.js` | `@excalidraw/excalidraw/data/types` |
| `@excalidraw/excalidraw/types/element/types.js` | `@excalidraw/excalidraw/element/types` |
| `@excalidraw/excalidraw/types/utility-types.js` | `@excalidraw/excalidraw/common/utility-types` |
| `@excalidraw/excalidraw/types/types.js` | `@excalidraw/excalidraw/types` |
Drop the `.js` extension. The new package `exports` map resolves these paths without it.
These deep subpaths are for `import type` only. Runtime imports should come from the package root, plus `@excalidraw/excalidraw/index.css` for styles.
For example:
```ts
import { exportToSvg } from "@excalidraw/excalidraw";
```
## Self-hosting fonts
By default, Excalidraw downloads the fonts it needs from the [CDN](https://esm.run/@excalidraw/excalidraw/dist/prod).
For self-hosting, copy the contents of `node_modules/@excalidraw/excalidraw/dist/prod/fonts` into the path where your app serves static assets, for example `public/`. Then set `window.EXCALIDRAW_ASSET_PATH` to that same path:
```html
<script>
window.EXCALIDRAW_ASSET_PATH = "/";
</script>
```
## Demo ## Demo
Try the [CodeSandbox example](https://codesandbox.io/p/sandbox/github/excalidraw/excalidraw/tree/master/examples/with-script-in-browser). Go to [CodeSandbox](https://codesandbox.io/p/sandbox/github/excalidraw/excalidraw/tree/master/examples/with-script-in-browser) example.
## Integration ## Integration
Read the [integration docs](https://docs.excalidraw.com/docs/@excalidraw/excalidraw/integration). Head over to the [docs](https://docs.excalidraw.com/docs/@excalidraw/excalidraw/integration).
## API ## API
Read the [API docs](https://docs.excalidraw.com/docs/@excalidraw/excalidraw/api). Head over to the [docs](https://docs.excalidraw.com/docs/@excalidraw/excalidraw/api).
## Contributing ## Contributing
Read the [contributing docs](https://docs.excalidraw.com/docs/@excalidraw/excalidraw/contributing). Head over to the [docs](https://docs.excalidraw.com/docs/@excalidraw/excalidraw/contributing).
@@ -5,7 +5,6 @@ import {
VERTICAL_ALIGN, VERTICAL_ALIGN,
arrayToMap, arrayToMap,
getFontString, getFontString,
getStrokeWidthByKey,
} from "@excalidraw/common"; } from "@excalidraw/common";
import { import {
getOriginalContainerHeightFromCache, getOriginalContainerHeightFromCache,
@@ -250,10 +249,7 @@ export const actionWrapTextInContainer = register({
fillStyle: appState.currentItemFillStyle, fillStyle: appState.currentItemFillStyle,
strokeColor: appState.currentItemStrokeColor, strokeColor: appState.currentItemStrokeColor,
roughness: appState.currentItemRoughness, roughness: appState.currentItemRoughness,
strokeWidth: getStrokeWidthByKey( strokeWidth: appState.currentItemStrokeWidth,
"rectangle",
appState.currentItemStrokeWidthKey,
),
strokeStyle: appState.currentItemStrokeStyle, strokeStyle: appState.currentItemStrokeStyle,
roundness: roundness:
appState.currentItemRoundness === "round" appState.currentItemRoundness === "round"
+5 -15
View File
@@ -118,6 +118,7 @@ export const actionClearCanvas = register({
gridStep: appState.gridStep, gridStep: appState.gridStep,
gridModeEnabled: appState.gridModeEnabled, gridModeEnabled: appState.gridModeEnabled,
stats: appState.stats, stats: appState.stats,
pasteDialog: appState.pasteDialog,
activeTool: activeTool:
appState.activeTool.type === "image" appState.activeTool.type === "image"
? { ? {
@@ -477,28 +478,17 @@ 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, app) => { perform: (_, appState, value) => {
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: nextTheme, theme:
value || (appState.theme === THEME.LIGHT ? THEME.DARK : THEME.LIGHT),
}, },
captureUpdate: CaptureUpdateAction.EVENTUALLY, captureUpdate: CaptureUpdateAction.EVENTUALLY,
}; };
}, },
keyTest: (event) => keyTest: (event) => event.altKey && event.shiftKey && event.code === CODES.D,
!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;
}, },
@@ -30,7 +30,7 @@ import { getSelectedElements, isSomeElementSelected } from "../scene";
import { TrashIcon } from "../components/icons"; import { TrashIcon } from "../components/icons";
import { ToolButton } from "../components/ToolButton"; import { ToolButton } from "../components/ToolButton";
import { useStylesPanelMode } from "../components/App"; import { useStylesPanelMode } from "..";
import { register } from "./register"; import { register } from "./register";
@@ -1,147 +0,0 @@
import {
getElementsInGroup,
isSomeElementSelected,
makeNextSelectedElementIds,
selectGroupsForSelectedElements,
} from "@excalidraw/element";
import { CaptureUpdateAction } from "@excalidraw/element";
import { KEYS, isWritableElement, updateActiveTool } from "@excalidraw/common";
import type { GroupId } from "@excalidraw/element/types";
import { register } from "./register";
import type { AppClassProperties, AppState } from "../types";
const getNextActiveTool = (
appState: Readonly<AppState>,
app: AppClassProperties,
) => {
if (appState.activeTool.type === "eraser") {
return updateActiveTool(appState, {
...(appState.activeTool.lastActiveTool || {
type: app.state.preferredSelectionTool.type,
}),
lastActiveToolBeforeEraser: null,
});
}
return updateActiveTool(appState, {
type: app.state.preferredSelectionTool.type,
});
};
const getParentEditingGroupId = (
appState: Readonly<AppState>,
app: AppClassProperties,
selectedElementIds: AppState["selectedElementIds"],
): GroupId | null => {
if (!appState.editingGroupId) {
return null;
}
const nonDeletedElements = app.scene.getNonDeletedElements();
const selectedElements = app.scene.getSelectedElements({
selectedElementIds,
elements: nonDeletedElements,
});
const candidateElements = selectedElements.length
? selectedElements
: getElementsInGroup(nonDeletedElements, appState.editingGroupId);
for (const element of candidateElements) {
const editingGroupIndex = element.groupIds.indexOf(appState.editingGroupId);
if (editingGroupIndex !== -1 && element.groupIds[editingGroupIndex + 1]) {
return element.groupIds[editingGroupIndex + 1] as GroupId;
}
}
return null;
};
export const actionDeselect = register({
name: "deselect",
label: "",
trackEvent: false,
perform: (_elements, appState, _, app) => {
const activeTool = getNextActiveTool(appState, app);
if (appState.editingGroupId) {
const nonDeletedElements = app.scene.getNonDeletedElements();
const selectedElementIds =
Object.keys(appState.selectedElementIds).length > 0
? appState.selectedElementIds
: getElementsInGroup(
nonDeletedElements,
appState.editingGroupId,
).reduce((acc, element) => {
acc[element.id] = true;
return acc;
}, {} as Record<string, true>);
return {
appState: {
...appState,
...selectGroupsForSelectedElements(
{
editingGroupId: getParentEditingGroupId(
appState,
app,
selectedElementIds,
),
selectedElementIds,
},
nonDeletedElements,
appState,
app,
),
activeEmbeddable: null,
activeTool,
selectedLinearElement: null,
selectionElement: null,
showHyperlinkPopup: false,
suggestedBinding: null,
frameToHighlight: null,
},
captureUpdate: CaptureUpdateAction.IMMEDIATELY,
};
}
return {
appState: {
...appState,
activeEmbeddable: null,
activeTool,
editingGroupId: null,
selectedElementIds: makeNextSelectedElementIds({}, appState),
selectedGroupIds: {},
selectedLinearElement: null,
selectionElement: null,
showHyperlinkPopup: false,
suggestedBinding: null,
frameToHighlight: null,
},
captureUpdate: CaptureUpdateAction.IMMEDIATELY,
};
},
keyTest: (event, appState, _, app) => {
if (event.key !== KEYS.ESCAPE) {
return false;
}
if (isWritableElement(event.target)) {
return false;
}
return (
!appState.newElement &&
appState.multiElement === null &&
!appState.selectedLinearElement?.isEditing &&
(appState.activeEmbeddable !== null ||
appState.activeTool.type !== app.state.preferredSelectionTool.type ||
!!appState.editingGroupId ||
!!appState.selectedLinearElement ||
isSomeElementSelected(app.scene.getNonDeletedElements(), appState))
);
},
});
@@ -27,7 +27,7 @@ import { t } from "../i18n";
import { isSomeElementSelected } from "../scene"; import { isSomeElementSelected } from "../scene";
import { getShortcutKey } from "../shortcut"; import { getShortcutKey } from "../shortcut";
import { useStylesPanelMode } from "../components/App"; import { useStylesPanelMode } from "..";
import { register } from "./register"; import { register } from "./register";
+43 -221
View File
@@ -9,20 +9,18 @@ import { getNonDeletedElements } from "@excalidraw/element";
import { CaptureUpdateAction } from "@excalidraw/element"; import { CaptureUpdateAction } from "@excalidraw/element";
import type { ExcalidrawElement, Theme } from "@excalidraw/element/types"; import type { Theme } from "@excalidraw/element/types";
import { useEditorInterface } from "../components/App"; import { useEditorInterface } from "../components/App";
import { CheckboxItem } from "../components/CheckboxItem"; import { CheckboxItem } from "../components/CheckboxItem";
import { DarkModeToggle } from "../components/DarkModeToggle"; import { DarkModeToggle } from "../components/DarkModeToggle";
import { ProjectName } from "../components/ProjectName"; import { ProjectName } from "../components/ProjectName";
import { Toast } from "../components/Toast";
import { ToolButton } from "../components/ToolButton"; import { ToolButton } from "../components/ToolButton";
import { Tooltip } from "../components/Tooltip"; import { Tooltip } from "../components/Tooltip";
import { ExportIcon, questionCircle, saveAs } from "../components/icons"; import { ExportIcon, questionCircle, saveAs } from "../components/icons";
import { loadFromJSON, saveAsJSON } from "../data"; import { loadFromJSON, saveAsJSON } from "../data";
import { isImageFileHandle } from "../data/blob"; import { isImageFileHandle } from "../data/blob";
import { nativeFileSystemSupported } from "../data/filesystem"; import { nativeFileSystemSupported } from "../data/filesystem";
import { resaveAsImageWithScene } from "../data/resave"; import { resaveAsImageWithScene } from "../data/resave";
import { t } from "../i18n"; import { t } from "../i18n";
@@ -33,15 +31,7 @@ import "../components/ToolIcon.scss";
import { register } from "./register"; import { register } from "./register";
import type { JSONExportData } from "../data/json"; import type { AppState } from "../types";
import type {
AppClassProperties,
AppState,
BinaryFiles,
ExcalidrawProps,
OnExportProgress,
} from "../types";
export const actionChangeProjectName = register<AppState["name"]>({ export const actionChangeProjectName = register<AppState["name"]>({
name: "changeProjectName", name: "changeProjectName",
@@ -160,143 +150,6 @@ export const actionChangeExportEmbedScene = register<
), ),
}); });
// ---------------------------------------------------------------------------
// onExport interception helpers
// ---------------------------------------------------------------------------
let onExportInProgress = false;
const onProgressToast = (
app: AppClassProperties,
progress: {
message?: OnExportProgress["message"];
progress?: number | null;
},
) => {
const message = progress.message ?? t("progressDialog.defaultMessage");
app.setAppState({
toast: {
message:
progress.progress != null ? (
<>
{message}
<Toast.ProgressBar progress={progress.progress} />
</>
) : (
message
),
duration: Infinity,
},
});
};
/** awaits host app's onExport result, and renders progress to the UI */
async function handleOnExportResult(
onExportResult: ReturnType<NonNullable<ExcalidrawProps["onExport"]>>,
opts: {
signal: AbortSignal;
app: AppClassProperties;
},
): Promise<void> {
if (opts.app.state.isLoading) {
onProgressToast(opts.app, { progress: null });
await opts.app.onStateChange({ predicate: (state) => !state.isLoading });
}
if (
onExportResult != null &&
typeof onExportResult === "object" &&
Symbol.asyncIterator in onExportResult
) {
for await (const value of onExportResult) {
if (opts.signal.aborted) {
onExportResult.return();
return;
}
if (value.type === "progress") {
onProgressToast(opts.app, {
message: value.message,
progress: value.progress ?? null,
});
} else if (value.type === "done") {
return;
}
}
// Generator completed without explicit "done" message
return;
}
if (onExportResult instanceof Promise) {
onProgressToast(opts.app, { progress: null });
await onExportResult;
}
}
function prepareDataForJSONExport(
elements: readonly ExcalidrawElement[],
appState: AppState,
files: BinaryFiles,
app: AppClassProperties,
): { abortController: AbortController; data: Promise<JSONExportData> } {
const abortController = new AbortController();
const signal = abortController.signal;
const dataPromise = new Promise<JSONExportData>(async (resolve) => {
try {
if (app.props.onExport) {
await handleOnExportResult(
app.props.onExport(
"json",
{
elements,
appState,
files,
},
{
signal,
},
),
{
app,
signal,
},
);
}
} catch (error: any) {
if (error?.name === "AbortError") {
// if abort error, assume it's a reaction on the signal being aborted
console.warn(
`onExport() aborted by host app (signal aborted: ${signal.aborted})`,
);
} else {
// non-abort error
//
console.error("Error during props.onExport() handling", error);
}
// either way, we currently don't allow host apps to cancel save actions
// so we resolve to orig data
}
resolve({
elements,
appState,
// return latest files in case they finished loading during onExport
files: app.files,
});
});
return {
abortController,
data: dataPromise,
};
}
// ---------------------------------------------------------------------------
// Save actions
// ---------------------------------------------------------------------------
export const actionSaveToActiveFile = register({ export const actionSaveToActiveFile = register({
name: "saveToActiveFile", name: "saveToActiveFile",
label: "buttons.save", label: "buttons.save",
@@ -310,62 +163,42 @@ export const actionSaveToActiveFile = register({
); );
}, },
perform: async (elements, appState, value, app) => { perform: async (elements, appState, value, app) => {
if (onExportInProgress) { const fileHandleExists = !!appState.fileHandle;
return false;
}
onExportInProgress = true;
const previousFileHandle = appState.fileHandle;
const filename = app.getName();
const { abortController, data: exportedDataPromise } =
prepareDataForJSONExport(elements, appState, app.files, app);
try { try {
const { fileHandle } = isImageFileHandle(previousFileHandle) const { fileHandle } = isImageFileHandle(appState.fileHandle)
? await resaveAsImageWithScene( ? await resaveAsImageWithScene(
exportedDataPromise, elements,
previousFileHandle, appState,
filename, app.files,
app.getName(),
) )
: await saveAsJSON({ : await saveAsJSON(elements, appState, app.files, app.getName());
data: exportedDataPromise,
filename,
fileHandle: previousFileHandle,
});
return { return {
captureUpdate: CaptureUpdateAction.NEVER, captureUpdate: CaptureUpdateAction.EVENTUALLY,
appState: { appState: {
...appState,
fileHandle, fileHandle,
toast: { toast: fileHandleExists
message: ? {
previousFileHandle && fileHandle?.name message: fileHandle?.name
? t("toast.fileSavedToFilename").replace( ? t("toast.fileSavedToFilename").replace(
"{filename}", "{filename}",
`"${fileHandle.name}"`, `"${fileHandle.name}"`,
) )
: t("toast.fileSaved"), : t("toast.fileSaved"),
duration: 1500, }
}, : null,
}, },
}; };
} catch (error: any) { } catch (error: any) {
abortController.abort();
if (error?.name !== "AbortError") { if (error?.name !== "AbortError") {
console.error(error); console.error(error);
} else { } else {
console.warn(error); console.warn(error);
} }
return { return { captureUpdate: CaptureUpdateAction.EVENTUALLY };
captureUpdate: CaptureUpdateAction.NEVER,
appState: {
toast: null,
},
};
} finally {
onExportInProgress = false;
} }
}, },
keyTest: (event) => keyTest: (event) =>
@@ -379,50 +212,36 @@ export const actionSaveFileToDisk = register({
viewMode: true, viewMode: true,
trackEvent: { category: "export" }, trackEvent: { category: "export" },
perform: async (elements, appState, value, app) => { perform: async (elements, appState, value, app) => {
if (onExportInProgress) {
return false;
}
onExportInProgress = true;
const { abortController, data: exportedDataPromise } =
prepareDataForJSONExport(elements, appState, app.files, app);
try { try {
const { fileHandle: savedFileHandle } = await saveAsJSON({ const { fileHandle } = await saveAsJSON(
data: exportedDataPromise, elements,
filename: app.getName(), {
fileHandle: null, ...appState,
}); fileHandle: null,
},
app.files,
app.getName(),
);
return { return {
captureUpdate: CaptureUpdateAction.NEVER, captureUpdate: CaptureUpdateAction.EVENTUALLY,
appState: { appState: {
...appState,
openDialog: null, openDialog: null,
fileHandle: savedFileHandle, fileHandle,
toast: { message: t("toast.fileSaved"), duration: 3000 }, toast: { message: t("toast.fileSaved") },
}, },
}; };
} catch (error: any) { } catch (error: any) {
abortController.abort();
if (error?.name !== "AbortError") { if (error?.name !== "AbortError") {
console.error(error); console.error(error);
} else { } else {
console.warn(error); console.warn(error);
} }
return { return { captureUpdate: CaptureUpdateAction.EVENTUALLY };
captureUpdate: CaptureUpdateAction.NEVER,
appState: {
toast: null,
},
};
} finally {
onExportInProgress = false;
} }
}, },
keyTest: (event) => keyTest: (event) =>
event.key.toLowerCase() === KEYS.S && event.key === KEYS.S && event.shiftKey && event[KEYS.CTRL_OR_CMD],
event.shiftKey &&
event[KEYS.CTRL_OR_CMD],
PanelComponent: ({ updateData }) => ( PanelComponent: ({ updateData }) => (
<ToolButton <ToolButton
type="button" type="button"
@@ -452,7 +271,11 @@ export const actionLoadScene = register({
elements: loadedElements, elements: loadedElements,
appState: loadedAppState, appState: loadedAppState,
files, files,
} = await loadFromJSON(appState, elements); } = await loadFromJSON(
appState,
elements,
app.getSchemaMigrationRegistry(),
);
return { return {
elements: loadedElements, elements: loadedElements,
appState: loadedAppState, appState: loadedAppState,
@@ -481,8 +304,7 @@ export const actionExportWithDarkMode = register<
name: "exportWithDarkMode", name: "exportWithDarkMode",
label: "imageExportDialog.label.darkMode", label: "imageExportDialog.label.darkMode",
trackEvent: { category: "export", action: "toggleTheme" }, trackEvent: { category: "export", action: "toggleTheme" },
perform: (_elements, appState, value, app) => { perform: (_elements, appState, value) => {
app.sessionExportThemeOverride = value ? THEME.DARK : THEME.LIGHT;
return { return {
appState: { ...appState, exportWithDarkMode: value }, appState: { ...appState, exportWithDarkMode: value },
captureUpdate: CaptureUpdateAction.EVENTUALLY, captureUpdate: CaptureUpdateAction.EVENTUALLY,
+5 -41
View File
@@ -54,7 +54,6 @@ 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();
@@ -223,44 +222,9 @@ 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,
},
);
}
} }
} }
@@ -365,8 +329,8 @@ export const actionFinalize = register<FormData>({
selectionElement: null, selectionElement: null,
multiElement: null, multiElement: null,
editingTextElement: null, editingTextElement: null,
startBoundElement: null,
suggestedBinding: null, suggestedBinding: null,
frameToHighlight: null,
selectedElementIds: selectedElementIds:
element && element &&
!appState.activeTool.locked && !appState.activeTool.locked &&
@@ -380,13 +344,13 @@ 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: shouldCommit captureUpdate: CaptureUpdateAction.IMMEDIATELY,
? CaptureUpdateAction.IMMEDIATELY
: CaptureUpdateAction.NEVER,
}; };
}, },
keyTest: (event, appState) => keyTest: (event, appState) =>
(event.key === KEYS.ESCAPE && appState.selectedLinearElement?.isEditing) || (event.key === KEYS.ESCAPE &&
(appState.selectedLinearElement?.isEditing ||
(!appState.newElement && appState.multiElement === null))) ||
((event.key === KEYS.ESCAPE || event.key === KEYS.ENTER) && ((event.key === KEYS.ESCAPE || event.key === KEYS.ENTER) &&
appState.multiElement !== null), appState.multiElement !== null),
PanelComponent: ({ appState, updateData, data }) => ( PanelComponent: ({ appState, updateData, data }) => (
@@ -205,6 +205,7 @@ export const actionWrapSelectionInFrame = register({
[...app.scene.getElementsIncludingDeleted(), frame], [...app.scene.getElementsIncludingDeleted(), frame],
selectedElements, selectedElements,
frame, frame,
appState,
); );
return { return {
@@ -277,6 +277,7 @@ export const actionUngroup = register({
elementsMap, elementsMap,
), ),
frame, frame,
app,
); );
} }
}); });
@@ -1,4 +1,5 @@
import { import {
isWindows,
KEYS, KEYS,
matchKey, matchKey,
arrayToMap, arrayToMap,
@@ -17,7 +18,7 @@ import { HistoryChangedEvent } from "../history";
import { useEmitter } from "../hooks/useEmitter"; import { useEmitter } from "../hooks/useEmitter";
import { t } from "../i18n"; import { t } from "../i18n";
import { useStylesPanelMode } from "../components/App"; import { useStylesPanelMode } from "..";
import type { History } from "../history"; import type { History } from "../history";
import type { AppClassProperties, AppState } from "../types"; import type { AppClassProperties, AppState } from "../types";
@@ -113,7 +114,7 @@ export const createRedoAction: ActionCreator = (history) => ({
), ),
keyTest: (event) => keyTest: (event) =>
(event[KEYS.CTRL_OR_CMD] && event.shiftKey && matchKey(event, KEYS.Z)) || (event[KEYS.CTRL_OR_CMD] && event.shiftKey && matchKey(event, KEYS.Z)) ||
(event[KEYS.CTRL_OR_CMD] && !event.shiftKey && matchKey(event, KEYS.Y)), (isWindows && event.ctrlKey && !event.shiftKey && matchKey(event, KEYS.Y)),
PanelComponent: ({ appState, updateData, data, app }) => { PanelComponent: ({ appState, updateData, data, app }) => {
const { isRedoStackEmpty } = useEmitter( const { isRedoStackEmpty } = useEmitter(
history.onHistoryChangedEmitter, history.onHistoryChangedEmitter,
@@ -1,17 +1,28 @@
import { fireEvent, queryByTestId } from "@testing-library/react"; import { queryByTestId } from "@testing-library/react";
import { import {
COLOR_PALETTE, COLOR_PALETTE,
DEFAULT_ELEMENT_BACKGROUND_PICKS, DEFAULT_ELEMENT_BACKGROUND_PICKS,
FREEDRAW_STROKE_WIDTH,
FONT_FAMILY, FONT_FAMILY,
STROKE_WIDTH, STROKE_WIDTH,
} from "@excalidraw/common"; } from "@excalidraw/common";
import {
CORE_FRAME_SCHEMA_TRACK,
CORE_SUPPORTED_TRACKS,
} from "@excalidraw/element";
import { Excalidraw } from "../index"; import { Excalidraw } from "../index";
import { API } from "../tests/helpers/api"; import { API } from "../tests/helpers/api";
import { UI } from "../tests/helpers/ui"; import { UI } from "../tests/helpers/ui";
import { render } from "../tests/test-utils"; import { act, render } from "../tests/test-utils";
import {
actionChangeBackgroundColor,
actionChangeRoundness,
actionChangeStrokeWidth,
} from "./actionProperties";
const { h } = window;
describe("element locking", () => { describe("element locking", () => {
beforeEach(async () => { beforeEach(async () => {
@@ -110,6 +121,21 @@ describe("element locking", () => {
expect(crossHatchButton).toBe(null); expect(crossHatchButton).toBe(null);
}); });
it("should show background color picker for selected frame", () => {
const frame = API.createElement({
type: "frame",
});
API.setElements([frame]);
API.setSelectedElements([frame]);
expect(
queryByTestId(
document.body,
`color-top-pick-${DEFAULT_ELEMENT_BACKGROUND_PICKS[0]}`,
),
).not.toBe(null);
});
it("should highlight common stroke width of selected elements", () => { it("should highlight common stroke width of selected elements", () => {
const rect1 = API.createElement({ const rect1 = API.createElement({
type: "rectangle", type: "rectangle",
@@ -129,62 +155,6 @@ describe("element locking", () => {
expect(thinStrokeWidthButton).toBeChecked(); expect(thinStrokeWidthButton).toBeChecked();
}); });
it("should highlight common stroke width key across freedraw and non-freedraw elements", () => {
const rect = API.createElement({
type: "rectangle",
strokeWidth: STROKE_WIDTH.medium,
});
const freedraw = API.createElement({
type: "freedraw",
strokeWidth: FREEDRAW_STROKE_WIDTH.medium,
});
API.setElements([rect, freedraw]);
API.setSelectedElements([rect, freedraw]);
expect(queryByTestId(document.body, `strokeWidth-medium`)).toBeChecked();
});
it("should apply stroke width by element type", () => {
const rect = API.createElement({
type: "rectangle",
strokeWidth: STROKE_WIDTH.thin,
});
const freedraw = API.createElement({
type: "freedraw",
strokeWidth: FREEDRAW_STROKE_WIDTH.thin,
});
API.setElements([rect, freedraw]);
API.setSelectedElements([rect, freedraw]);
const boldStrokeWidthButton = queryByTestId(
document.body,
`strokeWidth-bold`,
);
expect(boldStrokeWidthButton).not.toBe(null);
fireEvent.click(boldStrokeWidthButton!);
const selectedElements = API.getSelectedElements();
const selectedRect = selectedElements.find(
(element) => element.type === "rectangle",
);
const selectedFreedraw = selectedElements.find(
(element) => element.type === "freedraw",
);
expect(selectedRect?.strokeWidth).toBe(STROKE_WIDTH.bold);
expect(selectedFreedraw?.strokeWidth).toBe(FREEDRAW_STROKE_WIDTH.bold);
});
it("should create new elements with stroke width by element type", () => {
API.setAppState({ currentItemStrokeWidthKey: "bold" });
const rect = API.createElement({ type: "rectangle" });
const freedraw = API.createElement({ type: "freedraw" });
expect(rect.strokeWidth).toBe(STROKE_WIDTH.bold);
expect(freedraw.strokeWidth).toBe(FREEDRAW_STROKE_WIDTH.bold);
});
it("should not highlight any stroke width button if no common style", () => { it("should not highlight any stroke width button if no common style", () => {
const rect1 = API.createElement({ const rect1 = API.createElement({
type: "rectangle", type: "rectangle",
@@ -192,7 +162,7 @@ describe("element locking", () => {
}); });
const rect2 = API.createElement({ const rect2 = API.createElement({
type: "rectangle", type: "rectangle",
strokeWidth: STROKE_WIDTH.medium, strokeWidth: STROKE_WIDTH.bold,
}); });
API.setElements([rect1, rect2]); API.setElements([rect1, rect2]);
API.setSelectedElements([rect1, rect2]); API.setSelectedElements([rect1, rect2]);
@@ -202,17 +172,17 @@ describe("element locking", () => {
queryByTestId(document.body, `strokeWidth-thin`), queryByTestId(document.body, `strokeWidth-thin`),
).not.toBeChecked(); ).not.toBeChecked();
expect( expect(
queryByTestId(document.body, `strokeWidth-medium`), queryByTestId(document.body, `strokeWidth-bold`),
).not.toBeChecked(); ).not.toBeChecked();
expect( expect(
queryByTestId(document.body, `strokeWidth-bold`), queryByTestId(document.body, `strokeWidth-extraBold`),
).not.toBeChecked(); ).not.toBeChecked();
}); });
it("should show properties of different element types when selected", () => { it("should show properties of different element types when selected", () => {
const rect = API.createElement({ const rect = API.createElement({
type: "rectangle", type: "rectangle",
strokeWidth: STROKE_WIDTH.medium, strokeWidth: STROKE_WIDTH.bold,
}); });
const text = API.createElement({ const text = API.createElement({
type: "text", type: "text",
@@ -221,10 +191,82 @@ describe("element locking", () => {
API.setElements([rect, text]); API.setElements([rect, text]);
API.setSelectedElements([rect, text]); API.setSelectedElements([rect, text]);
expect(queryByTestId(document.body, `strokeWidth-medium`)).toBeChecked(); expect(queryByTestId(document.body, `strokeWidth-bold`)).toBeChecked();
expect(queryByTestId(document.body, `font-family-code`)).toHaveClass( expect(queryByTestId(document.body, `font-family-code`)).toHaveClass(
"active", "active",
); );
}); });
it("should not update text background when changing background in mixed frame selection", () => {
const frame = API.createElement({
type: "frame",
schemaState: { tracks: {} },
});
const text = API.createElement({
type: "text",
backgroundColor: COLOR_PALETTE.transparent,
});
API.setElements([text, frame]);
API.setSelectedElements([text, frame]);
act(() => {
h.app.actionManager.executeAction(actionChangeBackgroundColor, "ui", {
viewBackgroundColor: h.state.viewBackgroundColor,
currentItemBackgroundColor: "#ffc9c9",
});
});
expect(API.getElement(frame).backgroundColor).toBe("#ffc9c9");
expect(API.getElement(text).backgroundColor).toBe(
COLOR_PALETTE.transparent,
);
expect(
API.getElement(frame).schemaState.tracks[CORE_FRAME_SCHEMA_TRACK],
).toBe(CORE_SUPPORTED_TRACKS[CORE_FRAME_SCHEMA_TRACK]);
});
it("should not update frame stroke width when changing stroke width in mixed selection", () => {
const frame = API.createElement({
type: "frame",
});
const rect = API.createElement({
type: "rectangle",
strokeWidth: STROKE_WIDTH.thin,
});
API.setElements([rect, frame]);
API.setSelectedElements([rect, frame]);
const originalFrameStrokeWidth = API.getElement(frame).strokeWidth;
act(() => {
h.app.actionManager.executeAction(
actionChangeStrokeWidth,
"ui",
STROKE_WIDTH.extraBold,
);
});
expect(API.getElement(rect).strokeWidth).toBe(STROKE_WIDTH.extraBold);
expect(API.getElement(frame).strokeWidth).toBe(originalFrameStrokeWidth);
});
it("should not update frame roundness when changing roundness in mixed selection", () => {
const frame = API.createElement({
type: "frame",
});
const rect = API.createElement({
type: "rectangle",
roundness: null,
});
API.setElements([rect, frame]);
API.setSelectedElements([rect, frame]);
act(() => {
h.app.actionManager.executeAction(actionChangeRoundness, "ui", "round");
});
expect(API.getElement(rect).roundness).not.toBe(null);
expect(API.getElement(frame).roundness).toBe(null);
});
}); });
}); });
+164 -315
View File
@@ -12,7 +12,7 @@ import {
DEFAULT_FONT_SIZE, DEFAULT_FONT_SIZE,
FONT_FAMILY, FONT_FAMILY,
ROUNDNESS, ROUNDNESS,
STROKE_WIDTH_KEYS, STROKE_WIDTH,
VERTICAL_ALIGN, VERTICAL_ALIGN,
KEYS, KEYS,
randomInteger, randomInteger,
@@ -20,11 +20,9 @@ import {
getFontFamilyString, getFontFamilyString,
getLineHeight, getLineHeight,
isTransparent, isTransparent,
getStrokeWidthByKey,
reduceToCommonValue, reduceToCommonValue,
invariant, invariant,
FONT_SIZES, FONT_SIZES,
type StrokeWidthKey,
} from "@excalidraw/common"; } from "@excalidraw/common";
import { canBecomePolygon, getNonDeletedElements } from "@excalidraw/element"; import { canBecomePolygon, getNonDeletedElements } from "@excalidraw/element";
@@ -38,7 +36,6 @@ import {
import { LinearElementEditor } from "@excalidraw/element"; import { LinearElementEditor } from "@excalidraw/element";
import { newElementWith } from "@excalidraw/element"; import { newElementWith } from "@excalidraw/element";
import { getArrowheadForPicker } from "@excalidraw/element";
import { import {
getBoundTextElement, getBoundTextElement,
@@ -48,6 +45,7 @@ import {
import { import {
isArrowElement, isArrowElement,
isBoundToContainer, isBoundToContainer,
isFrameElement,
isElbowArrow, isElbowArrow,
isLinearElement, isLinearElement,
isLineElement, isLineElement,
@@ -55,7 +53,13 @@ import {
isUsingAdaptiveRadius, isUsingAdaptiveRadius,
} from "@excalidraw/element"; } from "@excalidraw/element";
import { hasStrokeColor } from "@excalidraw/element"; import {
canChangeRoundness,
hasBackground,
hasStrokeColor,
hasStrokeStyle,
hasStrokeWidth,
} from "@excalidraw/element";
import { import {
updateElbowArrowPoints, updateElbowArrowPoints,
@@ -72,11 +76,9 @@ import type {
ElementsMap, ElementsMap,
ExcalidrawBindableElement, ExcalidrawBindableElement,
ExcalidrawElement, ExcalidrawElement,
ExcalidrawFreeDrawElement,
ExcalidrawLinearElement, ExcalidrawLinearElement,
ExcalidrawTextElement, ExcalidrawTextElement,
FontFamilyValues, FontFamilyValues,
StrokeVariability,
TextAlign, TextAlign,
VerticalAlign, VerticalAlign,
} from "@excalidraw/element/types"; } from "@excalidraw/element/types";
@@ -87,7 +89,6 @@ import type { CaptureUpdateActionType } from "@excalidraw/element";
import { trackEvent } from "../analytics"; import { trackEvent } from "../analytics";
import { RadioSelection } from "../components/RadioSelection"; import { RadioSelection } from "../components/RadioSelection";
import { ToolButton } from "../components/ToolButton";
import { ColorPicker } from "../components/ColorPicker/ColorPicker"; import { ColorPicker } from "../components/ColorPicker/ColorPicker";
import { FontPicker } from "../components/FontPicker/FontPicker"; import { FontPicker } from "../components/FontPicker/FontPicker";
import { IconPicker } from "../components/IconPicker"; import { IconPicker } from "../components/IconPicker";
@@ -130,14 +131,9 @@ import {
sharpArrowIcon, sharpArrowIcon,
roundArrowIcon, roundArrowIcon,
elbowArrowIcon, elbowArrowIcon,
ArrowheadCardinalityExactlyOneIcon, ArrowheadCrowfootIcon,
ArrowheadCardinalityManyIcon, ArrowheadCrowfootOneIcon,
ArrowheadCardinalityOneIcon, ArrowheadCrowfootOneOrManyIcon,
ArrowheadCardinalityOneOrManyIcon,
ArrowheadCardinalityZeroOrManyIcon,
ArrowheadCardinalityZeroOrOneIcon,
strokeVariabilityConstantIcon,
strokeVariabilityVariableIcon,
} from "../components/icons"; } from "../components/icons";
import { Fonts } from "../fonts"; import { Fonts } from "../fonts";
@@ -197,12 +193,8 @@ export const changeProperty = (
export const getFormValue = function <T extends Primitive>( export const getFormValue = function <T extends Primitive>(
elements: readonly ExcalidrawElement[], elements: readonly ExcalidrawElement[],
app: AppClassProperties, app: AppClassProperties,
/** getAttribute: (element: ExcalidrawElement) => T,
* input value (usually the element attribute value, isRelevantElement: true | ((element: ExcalidrawElement) => boolean),
* but depends on what the action's PanelComponent input expects)
*/
getValue: (element: ExcalidrawElement) => T,
elementPredicate: true | ((element: ExcalidrawElement) => boolean),
defaultValue: T | ((isSomeElementSelected: boolean) => T), defaultValue: T | ((isSomeElementSelected: boolean) => T),
): T { ): T {
const editingTextElement = app.state.editingTextElement; const editingTextElement = app.state.editingTextElement;
@@ -211,7 +203,7 @@ export const getFormValue = function <T extends Primitive>(
let ret: T | null = null; let ret: T | null = null;
if (editingTextElement) { if (editingTextElement) {
ret = getValue(editingTextElement); ret = getAttribute(editingTextElement);
} }
if (!ret) { if (!ret) {
@@ -220,12 +212,12 @@ export const getFormValue = function <T extends Primitive>(
if (hasSelection) { if (hasSelection) {
const selectedElements = app.scene.getSelectedElements(app.state); const selectedElements = app.scene.getSelectedElements(app.state);
const targetElements = const targetElements =
elementPredicate === true isRelevantElement === true
? selectedElements ? selectedElements
: selectedElements.filter((el) => elementPredicate(el)); : selectedElements.filter((el) => isRelevantElement(el));
ret = ret =
reduceToCommonValue(targetElements, getValue) ?? reduceToCommonValue(targetElements, getAttribute) ??
(typeof defaultValue === "function" (typeof defaultValue === "function"
? defaultValue(true) ? defaultValue(true)
: defaultValue); : defaultValue);
@@ -424,11 +416,18 @@ export const actionChangeBackgroundColor = register<
return el; return el;
}); });
} else { } else {
nextElements = changeProperty(elements, appState, (el) => nextElements = changeProperty(elements, appState, (el) => {
newElementWith(el, { if (isFrameElement(el)) {
backgroundColor: value.currentItemBackgroundColor, return newElementWith(el, {
}), backgroundColor: value.currentItemBackgroundColor,
); });
}
return hasBackground(el.type)
? newElementWith(el, {
backgroundColor: value.currentItemBackgroundColor,
})
: el;
});
} }
return { return {
@@ -459,7 +458,12 @@ export const actionChangeBackgroundColor = register<
(element) => element.backgroundColor, (element) => element.backgroundColor,
true, true,
(hasSelection) => (hasSelection) =>
!hasSelection ? appState.currentItemBackgroundColor : null, !hasSelection
? appState.activeTool.type === "frame"
? // background default shouldn't apply to new frames
"transparent"
: appState.currentItemBackgroundColor
: null,
)} )}
onChange={(color) => onChange={(color) =>
updateData({ currentItemBackgroundColor: color }) updateData({ currentItemBackgroundColor: color })
@@ -486,11 +490,13 @@ export const actionChangeFillStyle = register<ExcalidrawElement["fillStyle"]>({
})`, })`,
); );
return { return {
elements: changeProperty(elements, appState, (el) => elements: changeProperty(elements, appState, (el) => {
newElementWith(el, { return hasBackground(el.type)
fillStyle: value, ? newElementWith(el, {
}), fillStyle: value,
), })
: el;
}),
appState: { ...appState, currentItemFillStyle: value }, appState: { ...appState, currentItemFillStyle: value },
captureUpdate: CaptureUpdateAction.IMMEDIATELY, captureUpdate: CaptureUpdateAction.IMMEDIATELY,
}; };
@@ -555,37 +561,22 @@ export const actionChangeFillStyle = register<ExcalidrawElement["fillStyle"]>({
}, },
}); });
const getStrokeWidthKeyForElement = ( export const actionChangeStrokeWidth = register<
element: ExcalidrawElement, ExcalidrawElement["strokeWidth"]
): StrokeWidthKey | null => { >({
return (
STROKE_WIDTH_KEYS.find(
(key) => getStrokeWidthByKey(element.type, key) === element.strokeWidth,
) ?? null
);
};
const getStrokeWidthForElement = (
element: ExcalidrawElement,
strokeWidthKey: StrokeWidthKey,
): ExcalidrawElement["strokeWidth"] => {
return getStrokeWidthByKey(element.type, strokeWidthKey);
};
export const actionChangeStrokeWidth = register<StrokeWidthKey>({
name: "changeStrokeWidth", name: "changeStrokeWidth",
label: "labels.strokeWidth", label: "labels.strokeWidth",
trackEvent: false, trackEvent: false,
perform: (elements, appState, value) => { perform: (elements, appState, value) => {
invariant(value, "actionChangeStrokeWidth: value must be defined");
return { return {
elements: changeProperty(elements, appState, (el) => elements: changeProperty(elements, appState, (el) => {
newElementWith(el, { return hasStrokeWidth(el.type)
strokeWidth: getStrokeWidthForElement(el, value), ? newElementWith(el, {
}), strokeWidth: value,
), })
appState: { ...appState, currentItemStrokeWidthKey: value }, : el;
}),
appState: { ...appState, currentItemStrokeWidth: value },
captureUpdate: CaptureUpdateAction.IMMEDIATELY, captureUpdate: CaptureUpdateAction.IMMEDIATELY,
}; };
}, },
@@ -593,35 +584,35 @@ export const actionChangeStrokeWidth = register<StrokeWidthKey>({
<fieldset> <fieldset>
<legend>{t("labels.strokeWidth")}</legend> <legend>{t("labels.strokeWidth")}</legend>
<div className="buttonList"> <div className="buttonList">
<RadioSelection<StrokeWidthKey> <RadioSelection
group="stroke-width" group="stroke-width"
options={[ options={[
{ {
value: "thin", value: STROKE_WIDTH.thin,
text: t("labels.thin"), text: t("labels.thin"),
icon: StrokeWidthBaseIcon, icon: StrokeWidthBaseIcon,
testId: "strokeWidth-thin", testId: "strokeWidth-thin",
}, },
{ {
value: "medium", value: STROKE_WIDTH.bold,
text: t("labels.medium"), text: t("labels.bold"),
icon: StrokeWidthBoldIcon, icon: StrokeWidthBoldIcon,
testId: "strokeWidth-medium", testId: "strokeWidth-bold",
}, },
{ {
value: "bold", value: STROKE_WIDTH.extraBold,
text: t("labels.bold"), text: t("labels.extraBold"),
icon: StrokeWidthExtraBoldIcon, icon: StrokeWidthExtraBoldIcon,
testId: "strokeWidth-bold", testId: "strokeWidth-extraBold",
}, },
]} ]}
value={getFormValue( value={getFormValue(
elements, elements,
app, app,
getStrokeWidthKeyForElement, (element) => element.strokeWidth,
(element) => element.hasOwnProperty("strokeWidth"), (element) => element.hasOwnProperty("strokeWidth"),
(hasSelection) => (hasSelection) =>
hasSelection ? null : appState.currentItemStrokeWidthKey, hasSelection ? null : appState.currentItemStrokeWidth,
)} )}
onChange={(value) => updateData(value)} onChange={(value) => updateData(value)}
/> />
@@ -636,12 +627,14 @@ export const actionChangeSloppiness = register<ExcalidrawElement["roughness"]>({
trackEvent: false, trackEvent: false,
perform: (elements, appState, value) => { perform: (elements, appState, value) => {
return { return {
elements: changeProperty(elements, appState, (el) => elements: changeProperty(elements, appState, (el) => {
newElementWith(el, { return hasStrokeStyle(el.type)
seed: randomInteger(), ? newElementWith(el, {
roughness: value, seed: randomInteger(),
}), roughness: value,
), })
: el;
}),
appState: { ...appState, currentItemRoughness: value }, appState: { ...appState, currentItemRoughness: value },
captureUpdate: CaptureUpdateAction.IMMEDIATELY, captureUpdate: CaptureUpdateAction.IMMEDIATELY,
}; };
@@ -684,87 +677,6 @@ export const actionChangeSloppiness = register<ExcalidrawElement["roughness"]>({
), ),
}); });
export const actionChangeFreedrawMode = register<StrokeVariability>({
name: "changeFreedrawMode",
label: "labels.pressure",
trackEvent: false,
perform: (elements, appState, value) => {
const variability = value || "constant";
return {
elements: changeProperty(elements, appState, (el) => {
if (el.type !== "freedraw") {
return el;
}
return newElementWith(el, {
strokeOptions: {
...el.strokeOptions,
variability,
},
}) as ExcalidrawElement;
}),
appState: { ...appState, currentItemStrokeVariability: variability },
captureUpdate: CaptureUpdateAction.IMMEDIATELY,
};
},
PanelComponent: ({ elements, appState, updateData, app, data }) => {
const strokeVariability =
getFormValue(
elements,
app,
(element) =>
(element as ExcalidrawFreeDrawElement).strokeOptions?.variability,
(element) => element.type === "freedraw",
(hasSelection) =>
hasSelection ? null : appState.currentItemStrokeVariability,
) ?? appState.currentItemStrokeVariability;
// in the compact UI the pressure setting is rendered as a single button
// that cycles between the two variability modes on click
if (data?.cycle) {
const isVariable = strokeVariability === "variable";
return (
<ToolButton
type="button"
icon={
isVariable
? strokeVariabilityVariableIcon
: strokeVariabilityConstantIcon
}
title={t("labels.pressure")}
aria-label={t("labels.pressure")}
onClick={() => updateData(isVariable ? "constant" : "variable")}
/>
);
}
return (
<fieldset>
<legend>{t("labels.pressure")}</legend>
<div className="buttonList">
<RadioSelection<StrokeVariability>
group="strokeOptions.variability"
options={[
{
value: "constant",
text: t("labels.pressure_constant"),
icon: strokeVariabilityConstantIcon,
},
{
value: "variable",
text: t("labels.pressure_variable"),
icon: strokeVariabilityVariableIcon,
},
]}
value={strokeVariability}
onChange={(value) => updateData(value)}
/>
</div>
</fieldset>
);
},
});
export const actionChangeStrokeStyle = register< export const actionChangeStrokeStyle = register<
ExcalidrawElement["strokeStyle"] ExcalidrawElement["strokeStyle"]
>({ >({
@@ -773,11 +685,13 @@ export const actionChangeStrokeStyle = register<
trackEvent: false, trackEvent: false,
perform: (elements, appState, value) => { perform: (elements, appState, value) => {
return { return {
elements: changeProperty(elements, appState, (el) => elements: changeProperty(elements, appState, (el) => {
newElementWith(el, { return hasStrokeStyle(el.type)
strokeStyle: value, ? newElementWith(el, {
}), strokeStyle: value,
), })
: el;
}),
appState: { ...appState, currentItemStrokeStyle: value }, appState: { ...appState, currentItemStrokeStyle: value },
captureUpdate: CaptureUpdateAction.IMMEDIATELY, captureUpdate: CaptureUpdateAction.IMMEDIATELY,
}; };
@@ -839,28 +753,9 @@ export const actionChangeOpacity = register<ExcalidrawElement["opacity"]>({
captureUpdate: CaptureUpdateAction.IMMEDIATELY, captureUpdate: CaptureUpdateAction.IMMEDIATELY,
}; };
}, },
PanelComponent: ({ elements, appState, app, updateData }) => { PanelComponent: ({ app, updateData }) => (
const opacity = getFormValue( <Range updateData={updateData} app={app} testId="opacity" />
elements, ),
app,
(element) => element.opacity,
true,
(hasSelection) => (hasSelection ? null : appState.currentItemOpacity),
);
return (
<Range
label={t("labels.opacity")}
value={opacity ?? appState.currentItemOpacity}
hasCommonValue={opacity !== null}
onChange={updateData}
min={0}
max={100}
step={10}
testId="opacity"
/>
);
},
}); });
export const actionChangeFontSize = register<ExcalidrawTextElement["fontSize"]>( export const actionChangeFontSize = register<ExcalidrawTextElement["fontSize"]>(
@@ -1608,7 +1503,7 @@ export const actionChangeRoundness = register<"sharp" | "round">({
perform: (elements, appState, value) => { perform: (elements, appState, value) => {
return { return {
elements: changeProperty(elements, appState, (el) => { elements: changeProperty(elements, appState, (el) => {
if (isElbowArrow(el)) { if (isElbowArrow(el) || !canChangeRoundness(el.type)) {
return el; return el;
} }
@@ -1682,117 +1577,80 @@ export const actionChangeRoundness = register<"sharp" | "round">({
}); });
const getArrowheadOptions = (flip: boolean) => { const getArrowheadOptions = (flip: boolean) => {
return { return [
visibleSections: [ {
{ value: null,
name: "default", text: t("labels.arrowhead_none"),
options: [ keyBinding: "q",
{ icon: <ArrowheadNoneIcon flip={flip} />,
value: null, },
text: t("labels.arrowhead_none"), {
keyBinding: "q", value: "arrow",
icon: <ArrowheadNoneIcon flip={flip} />, text: t("labels.arrowhead_arrow"),
}, keyBinding: "w",
{ icon: <ArrowheadArrowIcon flip={flip} />,
value: "arrow", },
text: t("labels.arrowhead_arrow"), {
keyBinding: "w", value: "triangle",
icon: <ArrowheadArrowIcon flip={flip} />, text: t("labels.arrowhead_triangle"),
}, icon: <ArrowheadTriangleIcon flip={flip} />,
{ keyBinding: "e",
value: "triangle", },
text: t("labels.arrowhead_triangle"), {
icon: <ArrowheadTriangleIcon flip={flip} />, value: "triangle_outline",
keyBinding: "e", text: t("labels.arrowhead_triangle_outline"),
}, icon: <ArrowheadTriangleOutlineIcon flip={flip} />,
{ keyBinding: "r",
value: "triangle_outline", },
text: t("labels.arrowhead_triangle_outline"), {
icon: <ArrowheadTriangleOutlineIcon flip={flip} />, value: "circle",
keyBinding: "r", text: t("labels.arrowhead_circle"),
}, keyBinding: "a",
], icon: <ArrowheadCircleIcon flip={flip} />,
}, },
], {
hiddenSections: [ value: "circle_outline",
{ text: t("labels.arrowhead_circle_outline"),
name: "default", keyBinding: "s",
options: [ icon: <ArrowheadCircleOutlineIcon flip={flip} />,
{ },
value: "circle", {
text: t("labels.arrowhead_circle"), value: "diamond",
keyBinding: "a", text: t("labels.arrowhead_diamond"),
icon: <ArrowheadCircleIcon flip={flip} />, icon: <ArrowheadDiamondIcon flip={flip} />,
}, keyBinding: "d",
{ },
value: "circle_outline", {
text: t("labels.arrowhead_circle_outline"), value: "diamond_outline",
keyBinding: "s", text: t("labels.arrowhead_diamond_outline"),
icon: <ArrowheadCircleOutlineIcon flip={flip} />, icon: <ArrowheadDiamondOutlineIcon flip={flip} />,
}, keyBinding: "f",
{ },
value: "diamond", {
text: t("labels.arrowhead_diamond"), value: "bar",
icon: <ArrowheadDiamondIcon flip={flip} />, text: t("labels.arrowhead_bar"),
keyBinding: "d", keyBinding: "z",
}, icon: <ArrowheadBarIcon flip={flip} />,
{ },
value: "diamond_outline", {
text: t("labels.arrowhead_diamond_outline"), value: "crowfoot_one",
icon: <ArrowheadDiamondOutlineIcon flip={flip} />, text: t("labels.arrowhead_crowfoot_one"),
keyBinding: "f", icon: <ArrowheadCrowfootOneIcon flip={flip} />,
}, keyBinding: "x",
{ },
value: "bar", {
text: t("labels.arrowhead_bar"), value: "crowfoot_many",
keyBinding: "z", text: t("labels.arrowhead_crowfoot_many"),
icon: <ArrowheadBarIcon flip={flip} />, icon: <ArrowheadCrowfootIcon flip={flip} />,
}, keyBinding: "c",
], },
}, {
{ value: "crowfoot_one_or_many",
name: t("labels.cardinality"), text: t("labels.arrowhead_crowfoot_one_or_many"),
options: [ icon: <ArrowheadCrowfootOneOrManyIcon flip={flip} />,
{ keyBinding: "v",
value: "cardinality_one", },
text: t("labels.arrowhead_cardinality_one"), ] as const;
icon: <ArrowheadCardinalityOneIcon flip={flip} />,
keyBinding: "x",
},
{
value: "cardinality_many",
text: t("labels.arrowhead_cardinality_many"),
icon: <ArrowheadCardinalityManyIcon flip={flip} />,
keyBinding: "c",
},
{
value: "cardinality_one_or_many",
text: t("labels.arrowhead_cardinality_one_or_many"),
icon: <ArrowheadCardinalityOneOrManyIcon flip={flip} />,
keyBinding: "v",
},
{
value: "cardinality_exactly_one",
text: t("labels.arrowhead_cardinality_exactly_one"),
icon: <ArrowheadCardinalityExactlyOneIcon flip={flip} />,
keyBinding: null,
},
{
value: "cardinality_zero_or_one",
text: t("labels.arrowhead_cardinality_zero_or_one"),
icon: <ArrowheadCardinalityZeroOrOneIcon flip={flip} />,
keyBinding: null,
},
{
value: "cardinality_zero_or_many",
text: t("labels.arrowhead_cardinality_zero_or_many"),
icon: <ArrowheadCardinalityZeroOrManyIcon flip={flip} />,
keyBinding: null,
},
],
},
],
} as const;
}; };
export const actionChangeArrowhead = register<{ export const actionChangeArrowhead = register<{
@@ -1836,52 +1694,45 @@ export const actionChangeArrowhead = register<{
}, },
PanelComponent: ({ elements, appState, updateData, app }) => { PanelComponent: ({ elements, appState, updateData, app }) => {
const isRTL = getLanguage().rtl; const isRTL = getLanguage().rtl;
const startArrowheadOptions = useMemo(
() => getArrowheadOptions(!isRTL),
[isRTL],
);
const endArrowheadOptions = useMemo(
() => getArrowheadOptions(!!isRTL),
[isRTL],
);
return ( return (
<fieldset> <fieldset>
<legend>{t("labels.arrowheads")}</legend> <legend>{t("labels.arrowheads")}</legend>
<div className="iconSelectList buttonList"> <div className="iconSelectList buttonList">
<IconPicker <IconPicker
visibleSections={startArrowheadOptions.visibleSections}
hiddenSections={startArrowheadOptions.hiddenSections}
label="arrowhead_start" label="arrowhead_start"
options={getArrowheadOptions(!isRTL)}
value={getFormValue<Arrowhead | null>( value={getFormValue<Arrowhead | null>(
elements, elements,
app, app,
(element) => (element) =>
isLinearElement(element) && canHaveArrowheads(element.type) isLinearElement(element) && canHaveArrowheads(element.type)
? getArrowheadForPicker(element.startArrowhead) ? element.startArrowhead
: appState.currentItemStartArrowhead, : appState.currentItemStartArrowhead,
true, true,
(hasSelection) => (hasSelection) =>
hasSelection ? null : appState.currentItemStartArrowhead, hasSelection ? null : appState.currentItemStartArrowhead,
)} )}
onChange={(value) => updateData({ position: "start", type: value })} onChange={(value) => updateData({ position: "start", type: value })}
numberOfOptionsToAlwaysShow={4}
/> />
<IconPicker <IconPicker
visibleSections={endArrowheadOptions.visibleSections}
hiddenSections={endArrowheadOptions.hiddenSections}
label="arrowhead_end" label="arrowhead_end"
group="arrowheads"
options={getArrowheadOptions(!!isRTL)}
value={getFormValue<Arrowhead | null>( value={getFormValue<Arrowhead | null>(
elements, elements,
app, app,
(element) => (element) =>
isLinearElement(element) && canHaveArrowheads(element.type) isLinearElement(element) && canHaveArrowheads(element.type)
? getArrowheadForPicker(element.endArrowhead) ? element.endArrowhead
: appState.currentItemEndArrowhead, : appState.currentItemEndArrowhead,
true, true,
(hasSelection) => (hasSelection) =>
hasSelection ? null : appState.currentItemEndArrowhead, hasSelection ? null : appState.currentItemEndArrowhead,
)} )}
onChange={(value) => updateData({ position: "end", type: value })} onChange={(value) => updateData({ position: "end", type: value })}
numberOfOptionsToAlwaysShow={4}
/> />
</div> </div>
</fieldset> </fieldset>
@@ -2006,7 +1857,6 @@ export const actionChangeArrowType = register<keyof typeof ARROW_TYPE>({
startElement, startElement,
"start", "start",
elementsMap, elementsMap,
appState.isBindingEnabled,
), ),
} }
: null; : null;
@@ -2020,7 +1870,6 @@ export const actionChangeArrowType = register<keyof typeof ARROW_TYPE>({
endElement, endElement,
"end", "end",
elementsMap, elementsMap,
appState.isBindingEnabled,
), ),
} }
: null; : null;
@@ -1,24 +1,24 @@
import { getFontString } from "@excalidraw/common"; import { getFontString } from "@excalidraw/common";
import { isExcalidrawElement, newElementWith } from "@excalidraw/element"; import { newElementWith } from "@excalidraw/element";
import { measureText } from "@excalidraw/element"; import { measureText } from "@excalidraw/element";
import { isTextElement } from "@excalidraw/element"; import { isTextElement } from "@excalidraw/element";
import { CaptureUpdateAction } from "@excalidraw/element"; import { CaptureUpdateAction } from "@excalidraw/element";
import type { ExcalidrawElement } from "@excalidraw/element/types";
import { getSelectedElements } from "../scene"; import { getSelectedElements } from "../scene";
import { register } from "./register"; import { register } from "./register";
import type { AppClassProperties } from "../types";
export const actionTextAutoResize = register({ export const actionTextAutoResize = register({
name: "autoResize", name: "autoResize",
label: "labels.autoResize", label: "labels.autoResize",
icon: null, icon: null,
trackEvent: { category: "element" }, trackEvent: { category: "element" },
predicate: (elements, appState, _: unknown) => { predicate: (elements, appState, _: unknown, app: AppClassProperties) => {
const selectedElements = getSelectedElements(elements, appState); const selectedElements = getSelectedElements(elements, appState);
return ( return (
selectedElements.length === 1 && selectedElements.length === 1 &&
@@ -26,18 +26,13 @@ export const actionTextAutoResize = register({
!selectedElements[0].autoResize !selectedElements[0].autoResize
); );
}, },
perform: (elements, appState, targetElement) => { perform: (elements, appState, _, app) => {
const selectedElements = getSelectedElements(elements, appState); const selectedElements = getSelectedElements(elements, appState);
const targetTextElement =
isExcalidrawElement(targetElement) && isTextElement(targetElement)
? targetElement
: (selectedElements[0] as ExcalidrawElement | undefined);
return { return {
appState, appState,
elements: elements.map((element) => { elements: elements.map((element) => {
if (element.id === targetTextElement?.id && isTextElement(element)) { if (element.id === selectedElements[0].id && isTextElement(element)) {
const metrics = measureText( const metrics = measureText(
element.originalText, element.originalText,
getFontString(element), getFontString(element),
@@ -1,26 +0,0 @@
import { CaptureUpdateAction } from "@excalidraw/element";
import { register } from "./register";
export const actionToggleArrowBinding = register({
name: "arrowBinding",
label: "labels.arrowBinding",
viewMode: false,
trackEvent: {
category: "canvas",
predicate: (appState) => appState.bindingPreference === "disabled",
},
perform(elements, appState) {
const newPreference =
appState.bindingPreference === "enabled" ? "disabled" : "enabled";
return {
appState: {
...appState,
bindingPreference: newPreference,
isBindingEnabled: newPreference === "enabled",
},
captureUpdate: CaptureUpdateAction.NEVER,
};
},
checked: (appState) => appState.bindingPreference === "enabled",
});
@@ -1,23 +0,0 @@
import { CaptureUpdateAction } from "@excalidraw/element";
import { register } from "./register";
export const actionToggleMidpointSnapping = register({
name: "midpointSnapping",
label: "labels.midpointSnapping",
viewMode: false,
trackEvent: {
category: "canvas",
predicate: (appState) => !appState.isMidpointSnappingEnabled,
},
perform(elements, appState) {
return {
appState: {
...appState,
isMidpointSnappingEnabled: !this.checked!(appState),
},
captureUpdate: CaptureUpdateAction.NEVER,
};
},
checked: (appState) => appState.isMidpointSnappingEnabled,
});
-4
View File
@@ -13,7 +13,6 @@ export {
actionChangeStrokeWidth, actionChangeStrokeWidth,
actionChangeFillStyle, actionChangeFillStyle,
actionChangeSloppiness, actionChangeSloppiness,
actionChangeFreedrawMode,
actionChangeOpacity, actionChangeOpacity,
actionChangeFontSize, actionChangeFontSize,
actionChangeFontFamily, actionChangeFontFamily,
@@ -35,7 +34,6 @@ export {
export { actionSetEmbeddableAsActiveTool } from "./actionEmbeddable"; export { actionSetEmbeddableAsActiveTool } from "./actionEmbeddable";
export { actionFinalize } from "./actionFinalize"; export { actionFinalize } from "./actionFinalize";
export { actionDeselect } from "./actionDeselect";
export { export {
actionChangeProjectName, actionChangeProjectName,
@@ -81,8 +79,6 @@ export {
export { actionToggleGridMode } from "./actionToggleGridMode"; export { actionToggleGridMode } from "./actionToggleGridMode";
export { actionToggleZenMode } from "./actionToggleZenMode"; export { actionToggleZenMode } from "./actionToggleZenMode";
export { actionToggleObjectsSnapMode } from "./actionToggleObjectsSnapMode"; export { actionToggleObjectsSnapMode } from "./actionToggleObjectsSnapMode";
export { actionToggleArrowBinding } from "./actionToggleArrowBinding";
export { actionToggleMidpointSnapping } from "./actionToggleMidpointSnapping";
export { actionToggleStats } from "./actionToggleStats"; export { actionToggleStats } from "./actionToggleStats";
export { actionUnbindText, actionBindText } from "./actionBoundText"; export { actionUnbindText, actionBindText } from "./actionBoundText";
-4
View File
@@ -59,8 +59,6 @@ export type ActionName =
| "gridMode" | "gridMode"
| "zenMode" | "zenMode"
| "objectsSnapMode" | "objectsSnapMode"
| "arrowBinding"
| "midpointSnapping"
| "stats" | "stats"
| "changeStrokeColor" | "changeStrokeColor"
| "changeBackgroundColor" | "changeBackgroundColor"
@@ -68,7 +66,6 @@ export type ActionName =
| "changeStrokeWidth" | "changeStrokeWidth"
| "changeStrokeShape" | "changeStrokeShape"
| "changeSloppiness" | "changeSloppiness"
| "changeFreedrawMode"
| "changeStrokeStyle" | "changeStrokeStyle"
| "changeArrowhead" | "changeArrowhead"
| "changeArrowType" | "changeArrowType"
@@ -115,7 +112,6 @@ export type ActionName =
| "distributeVertically" | "distributeVertically"
| "flipHorizontal" | "flipHorizontal"
| "flipVertical" | "flipVertical"
| "deselect"
| "viewMode" | "viewMode"
| "exportWithDarkMode" | "exportWithDarkMode"
| "toggleTheme" | "toggleTheme"

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