Compare commits

..

67 Commits

Author SHA1 Message Date
Mark Tolmacs dbf05ead2a Merge branch 'master' into mtolmacs/feat/freedraw-research-1
Signed-off-by: Mark Tolmacs <mark@lazycat.hu>
2026-06-04 16:34:58 +00:00
Mark Tolmacs a8bb038a75 Merge branch 'master' into mtolmacs/feat/freedraw-research-1
Signed-off-by: Mark Tolmacs <mark@lazycat.hu>
2026-06-04 16:29:05 +00:00
David Luzar 3372149277 feat(packages/excalidraw): export applyDarkModeFilter and simplify (#11429) 2026-06-01 15:43:45 +02:00
David Luzar c08be69618 ci(docker): fix docker dep bundling and pin remaining actions (#11398)
* docker: use slim alpine image to remove bundling deps in Docker image

* pin remaining yml actions

* use lockfile

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

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

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

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

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

* Apply suggestion from @dwelle

---------

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

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

* fix: Tests

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

* fix: Frames and overlap

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

* fix: Remove unnecessary crust

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

* revert unused Set signature

* skip ignored elements from group condition

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

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

* apply exclusion

* simplify

* feat: return all elements in group for overlap selection

---------

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

---
Signed-off-by: Mark Tolmacs <mark@lazycat.hu>
Co-authored-by: dwelle <5153846+dwelle@users.noreply.github.com>
2026-05-04 11:37:17 +02:00
Márk Tolmács 2dfcc6f0ce chore: Remove startBoundElement from state (#11264) 2026-05-02 16:36:42 +02:00
David Luzar 3f5fdec04e fix: group defragmenting (#11269) 2026-05-02 15:50:58 +02:00
David Luzar 278cd35772 feat(editor): scale video embeddables based on zoom (#11251) 2026-04-28 21:49:40 +02:00
David Luzar 43fa4b5602 fix: frame selection and membership (#11250)
fix: element fully overlapping frame
2026-04-28 18:23:10 +02:00
Márk Tolmács 2e1a529c67 fix(editor): remove extremely large arrows on restore (#11235)
* fix: Temp fix for elbow arrow at restore

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

* fix: Speculative fixes to avoid Infinity

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

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

* validate even simple arrows

* remove x/y check

* remove duplicate constant

---------

Signed-off-by: Mark Tolmacs <mark@lazycat.hu>
Co-authored-by: Copilot <copilot@github.com>
Co-authored-by: dwelle <5153846+dwelle@users.noreply.github.com>
2026-04-25 12:03:50 +02:00
Mark Tolmacs ae86f61dac fix: Use curved segments
Signed-off-by: Mark Tolmacs <mark@lazycat.hu>
2026-04-22 21:40:53 +02:00
David Luzar b1c6bfcf40 chore(docker): bump node (#11208) 2026-04-20 22:07:00 +02:00
Mark Tolmacs c221c4d112 fix: Resolution
Signed-off-by: Mark Tolmacs <mark@lazycat.hu>
2026-04-20 12:56:08 +00:00
Mark Tolmacs 185ea83d1b fix: Off-by-one
Signed-off-by: Mark Tolmacs <mark@lazycat.hu>
2026-04-17 14:57:37 +00:00
Mark Tolmacs f96d8e6b08 fix: Collab event throttling
Signed-off-by: Mark Tolmacs <mark@lazycat.hu>
2026-04-17 14:16:29 +00:00
Mark Tolmacs 0fd631f107 fix: Test issue
Signed-off-by: Mark Tolmacs <mark@lazycat.hu>
2026-04-17 13:27:01 +00:00
Mark Tolmacs 27b9f8e2ff chore: Trigger build 2026-04-16 20:09:52 +00:00
Mark Tolmacs a2150593ca fix: Lint
Signed-off-by: Mark Tolmacs <mark@lazycat.hu>
2026-04-16 17:31:15 +00:00
Mark Tolmacs f495b00472 chore: Update tests
Signed-off-by: Mark Tolmacs <mark@lazycat.hu>
2026-04-16 17:31:15 +00:00
Mark Tolmacs ddcc8f3aad fix: Initial smoothing improved
Signed-off-by: Mark Tolmacs <mark@lazycat.hu>
2026-04-16 17:31:14 +00:00
Mark Tolmacs 21a7f35345 fix: Refactor smoothing
Signed-off-by: Mark Tolmacs <mark@lazycat.hu>
2026-04-16 17:31:14 +00:00
Mark Tolmacs 0536b0e707 feat: Smoothing
Signed-off-by: Mark Tolmacs <mark@lazycat.hu>
2026-04-16 17:31:13 +00:00
Mark Tolmacs 400d98d95d fix: Overdraw lookback
Signed-off-by: Mark Tolmacs <mark@lazycat.hu>
2026-04-16 17:31:13 +00:00
Mark Tolmacs 25ec8d0869 fix: Various fixes
Signed-off-by: Mark Tolmacs <mark@lazycat.hu>
2026-04-16 17:31:13 +00:00
Mark Tolmacs 56b2b3a41e fix: Fixes and SVG parity
Signed-off-by: Mark Tolmacs <mark@lazycat.hu>
2026-04-16 17:31:12 +00:00
Mark Tolmacs a9543f22b2 feat: Pressure smoothing causal
Signed-off-by: Mark Tolmacs <mark@lazycat.hu>
2026-04-16 17:31:12 +00:00
Mark Tolmacs c2ee6c32a4 fix: Approximate start bump reset
Signed-off-by: Mark Tolmacs <mark@lazycat.hu>
2026-04-16 17:31:11 +00:00
Mark Tolmacs d328b21e7d fix: Dots at resolution
Signed-off-by: Mark Tolmacs <mark@lazycat.hu>
2026-04-16 17:31:11 +00:00
Mark Tolmacs cf7393cabb fix: Blurring
Signed-off-by: Mark Tolmacs <mark@lazycat.hu>
2026-04-16 17:31:10 +00:00
Mark Tolmacs b547dc4f7a fix: Expose variable width on tablet UI
Signed-off-by: Mark Tolmacs <mark@lazycat.hu>
2026-04-16 17:31:10 +00:00
Mark Tolmacs 5aa16c3052 fix: Speedy predictions
Signed-off-by: Mark Tolmacs <mark@lazycat.hu>
2026-04-16 17:31:10 +00:00
Mark Tolmacs a0489c459c fix : Curve sampling
Signed-off-by: Mark Tolmacs <mark@lazycat.hu>
2026-04-16 17:31:09 +00:00
Mark Tolmacs 548a11794e fix: taper
Signed-off-by: Mark Tolmacs <mark@lazycat.hu>
2026-04-16 17:31:09 +00:00
Mark Tolmacs 846720a286 feat: Cached rendering
Signed-off-by: Mark Tolmacs <mark@lazycat.hu>
2026-04-16 17:31:08 +00:00
Mark Tolmacs d76f7b1cbc feat: Console log overlay preference
Signed-off-by: Mark Tolmacs <mark@lazycat.hu>
2026-04-16 17:31:04 +00:00
Tom Louveau 1caec99b29 docs: change twitter label by X (#11158)
Co-authored-by: David Luzar <5153846+dwelle@users.noreply.github.com>
2026-04-13 10:30:58 +02:00
Nand Gopal Sharma e18c1dd213 Fix typo in Discord badge URL parameter (#11096) 2026-04-02 10:37:02 +02:00
David Luzar d9e8a33aa4 feat(editor): implement overlap box selection (#11053)
Co-authored-by: Mark Tolmacs <mark@lazycat.hu>
2026-04-01 18:41:11 +02:00
dagecko 4be4cc0ed0 fix: pin 9 actions to commit SHA (#11075) 2026-03-30 16:49:27 +02:00
David Luzar 4a5c9e990c fix(editor): ensure font picker font names are not quoted (#11036) 2026-03-25 17:56:48 +01:00
David Luzar c09e170bdd feat(editor): deselect on esc (#11035)
Co-authored-by: Jawahar <jawahars_16@live.in>
Co-authored-by: Andrew Aquino <dawneraq@gmail.com>
2026-03-25 17:14:24 +01:00
David Luzar c1082923ee feat(editor): support mermaid staate diagrams (#11031) 2026-03-24 20:20:28 +01:00
Kundan 1c292e4936 fix(math): correctly validate second point in isLineSegment (#11007)
Co-authored-by: Mark Tolmacs <mark@lazycat.hu>
2026-03-24 19:01:08 +01:00
Márk Tolmács d6f0f34fe9 fix: Rotated rounded arrow center point (#10962) 2026-03-23 15:54:59 +01:00
Márk Tolmács 75789f620d fix: Other endpoint is not immediately updated on midpoint snap (#10933)
Co-authored-by: dwelle <5153846+dwelle@users.noreply.github.com>
2026-03-23 14:54:44 +00:00
David Luzar a9ca16eb42 chore(packages/excalidraw): export Fonts helper class (#11008) 2026-03-21 22:44:27 +01:00
Márk Tolmács 987173b52f fix: Arrow point index Out-of-Bounds (#10922)
* fix: Make OOB not fatal

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

* fix: More conservative temp arrow state update

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

* chore: Capture condition variables in binding restoration failure

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

---------

Signed-off-by: Mark Tolmacs <mark@lazycat.hu>
2026-03-21 19:26:47 +01:00
David Luzar 81ab857a6f feat(editor): various text related improvements (#10979) 2026-03-19 16:00:58 +01:00
David Luzar e8b4620a96 feat(editor): put caret at pointer coords when clicking on selected text element (#10970) 2026-03-18 19:14:44 +01:00
David Luzar 2b0e4c9623 fix(editor): remove leftover debug code path (#10954) 2026-03-14 13:12:48 +01:00
David Luzar c9ba7f839c chore(editor): bump @excalidraw/mermaid-to-excalidraw@2.1.1 (#10944) 2026-03-12 17:32:13 +01:00
David Luzar b4ce7c713b fix(editor): arrowhead picker overflowing viewport (#10943) 2026-03-12 16:08:46 +01:00
David Luzar 816c81c12e feat(editor): ERD arrowheads and diagrams (#10940) 2026-03-11 22:00:31 +01:00
David Luzar 92d25446d6 feat(packages/excalidraw): tweak and expose more API around state and lifecycle (#10939) 2026-03-11 16:51:03 +01:00
David Luzar e73a5b0116 docs(packages/excalidraw): improve readme (#10932) 2026-03-11 09:49:12 +01:00
156 changed files with 9307 additions and 1991 deletions
+1 -1
View File
@@ -1,4 +1,4 @@
FROM node:18-bullseye
FROM node:24-bullseye
# Vite wants to open the browser using `open`, so we
# need to install those utils.
+2 -2
View File
@@ -9,11 +9,11 @@ jobs:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v2
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
with:
fetch-depth: 2
- name: Setup Node.js
uses: actions/setup-node@v2
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
with:
node-version: 20.x
- name: Set up publish access
+1 -1
View File
@@ -9,5 +9,5 @@ jobs:
build-docker:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v2
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
- run: docker build -t excalidraw .
+1 -1
View File
@@ -11,7 +11,7 @@ jobs:
runs-on: ubuntu-latest
timeout-minutes: 3
steps:
- uses: styfle/cancel-workflow-action@0.6.0
- uses: styfle/cancel-workflow-action@ce177499ccf9fd2aded3b0426c97e5434c2e8a73 # 0.6.0
with:
workflow_id: 400555, 400556, 905313, 1451724, 1710116, 3185001, 3438604
access_token: ${{ secrets.GITHUB_TOKEN }}
+2 -2
View File
@@ -7,10 +7,10 @@ jobs:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v2
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
- name: Setup Node.js
uses: actions/setup-node@v2
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
with:
node-version: 20.x
+3 -3
View File
@@ -10,12 +10,12 @@ jobs:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
with:
token: ${{ secrets.PUSH_TRANSLATIONS_COVERAGE_PAT }}
- name: Setup Node.js
uses: actions/setup-node@v2
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
with:
node-version: 20.x
@@ -40,7 +40,7 @@ jobs:
echo ::set-output name=body::$body
- name: Update description with coverage
uses: kt3k/update-pr-description@v1.0.1
uses: kt3k/update-pr-description@1b35a6dcd84d81aa0bc1889610efdcde7f37b0c0 # v1.0.1
with:
pr_body: ${{ steps.getCommentBody.outputs.body }}
pr_title: "chore: Update translations from Crowdin"
+5 -5
View File
@@ -11,18 +11,18 @@ jobs:
steps:
- name: Checkout repository
uses: actions/checkout@v3
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
- name: Login to DockerHub
uses: docker/login-action@v2
uses: docker/login-action@465a07811f14bebb1938fbed4728c6a1ff8901fc # v2
with:
username: ${{ secrets.DOCKER_USERNAME }}
password: ${{ secrets.DOCKER_PASSWORD }}
- name: Set up QEMU
uses: docker/setup-qemu-action@v3
uses: docker/setup-qemu-action@c7c53464625b32c7a7e944ae62b3e17d2b600130 # v3
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3
- name: Build and push
uses: docker/build-push-action@v5
uses: docker/build-push-action@ca052bb54ab0790a636c9b5f226502c73d547a25 # v5
with:
context: .
push: true
+87 -1
View File
@@ -6,11 +6,97 @@ on:
- opened
- edited
- synchronize
- labeled
- unlabeled
jobs:
semantic:
runs-on: ubuntu-latest
permissions:
pull-requests: read
steps:
- uses: amannn/action-semantic-pull-request@v5
- uses: amannn/action-semantic-pull-request@e32d7e603df1aa1ba07e981f2a23455dee596825 # v5
with:
requireScope: true
scopes: |
app
editor
packages/excalidraw
packages/utils
docker
repo
ignoreLabels: |
skip-semantic-title
env:
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:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v2
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
- name: Setup Node.js
uses: actions/setup-node@v2
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
with:
node-version: 20.x
- name: Install and build
+3 -3
View File
@@ -10,9 +10,9 @@ jobs:
CI_JOB_NUMBER: 1
steps:
- name: Checkout repository
uses: actions/checkout@v3
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
- name: Setup Node.js
uses: actions/setup-node@v3
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
with:
node-version: 20.x
- name: Install in packages/excalidraw
@@ -20,7 +20,7 @@ jobs:
working-directory: packages/excalidraw
env:
CI: true
- uses: andresz1/size-limit-action@v1
- uses: andresz1/size-limit-action@e7493a72a44b113341c0cf6186ab49c17c4b65c1 # v1
with:
github_token: ${{ secrets.GITHUB_TOKEN }}
build_script: build:esm
+3 -3
View File
@@ -10,9 +10,9 @@ jobs:
pull-requests: write
steps:
- uses: actions/checkout@v2
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
- name: "Install Node"
uses: actions/setup-node@v2
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
with:
node-version: "20.x"
- name: "Install Deps"
@@ -21,6 +21,6 @@ jobs:
run: yarn test:coverage
- name: "Report Coverage"
if: always() # Also generate the report if tests are failing
uses: davelosert/vitest-coverage-report-action@v2
uses: davelosert/vitest-coverage-report-action@2500dafcee7dd64f85ab689c0b83798a8359770e # v2
with:
github-token: ${{ secrets.GITHUB_TOKEN }}
+2 -2
View File
@@ -8,9 +8,9 @@ jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
- name: Setup Node.js
uses: actions/setup-node@v4
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
with:
node-version: 20.x
- name: Install and test
+3 -3
View File
@@ -1,4 +1,4 @@
FROM --platform=${BUILDPLATFORM} node:18 AS build
FROM --platform=${BUILDPLATFORM} node:24@sha256:8530f76a96d88820d288761f022e318970dda93d01536919fbc16076b7983e63 AS build
WORKDIR /opt/node_app
@@ -7,13 +7,13 @@ COPY . .
# do not ignore optional dependencies:
# Error: Cannot find module @rollup/rollup-linux-x64-gnu
RUN --mount=type=cache,target=/root/.cache/yarn \
npm_config_target_arch=${TARGETARCH} yarn --network-timeout 600000
npm_config_target_arch=${TARGETARCH} yarn --frozen-lockfile --network-timeout 600000
ARG NODE_ENV=production
RUN npm_config_target_arch=${TARGETARCH} yarn build:app:docker
FROM --platform=${TARGETPLATFORM} nginx:1.27-alpine
FROM nginx:stable-alpine-slim@sha256:2c605dbeab79a6b2a63340474fe58119d0ef95bdc4b1f41df0aa689659b3d13b
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">
<img alt="PRs welcome!" src="https://img.shields.io/badge/PRs-welcome-brightgreen.svg?style=flat" /></a>
<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&widge=false"/></a>
<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>
<a href="https://deepwiki.com/excalidraw/excalidraw">
<img alt="Ask DeepWiki" src="https://deepwiki.com/badge.svg" /></a>
<a href="https://twitter.com/excalidraw">
@@ -172,7 +172,7 @@ convertToExcalidrawElements([
type: "arrow",
x: 450,
y: 20,
startArrowhead: "dot",
startArrowhead: "circle",
endArrowhead: "triangle",
strokeColor: "#1971c2",
strokeWidth: 2,
+2 -2
View File
@@ -97,8 +97,8 @@ const config = {
href: "https://discord.gg/UexuTaE",
},
{
label: "Twitter",
href: "https://twitter.com/excalidraw",
label: "𝕏",
href: "https://x.com/excalidraw",
},
{
label: "Linkedin",
@@ -1,4 +1,4 @@
import type { ExcalidrawElementSkeleton } from "@excalidraw/excalidraw/data/transform";
import type { ExcalidrawElementSkeleton } from "@excalidraw/excalidraw/element/transform";
import type { FileId } from "@excalidraw/excalidraw/element/types";
const elements: ExcalidrawElementSkeleton[] = [
+2
View File
@@ -136,6 +136,7 @@ import { useHandleAppTheme } from "./useHandleAppTheme";
import { getPreferredLanguage } from "./app-language/language-detector";
import { useAppLangCode } from "./app-language/language-state";
import DebugCanvas, {
ConsoleLogger,
debugRenderer,
isVisualDebuggerEnabled,
loadSavedDebugState,
@@ -1261,6 +1262,7 @@ const ExcalidrawWrapper = () => {
ref={debugCanvasRef}
/>
)}
{isVisualDebuggerEnabled() && <ConsoleLogger />}
</Excalidraw>
</div>
);
+1
View File
@@ -42,6 +42,7 @@ export const STORAGE_KEYS = {
LOCAL_STORAGE_COLLAB: "excalidraw-collab",
LOCAL_STORAGE_THEME: "excalidraw-theme",
LOCAL_STORAGE_DEBUG: "excalidraw-debug",
LOCAL_STORAGE_DEBUG_CONSOLE: "excalidraw-debug-console",
VERSION_DATA_STATE: "version-dataState",
VERSION_FILES: "version-files",
+12 -1
View File
@@ -355,6 +355,8 @@ class Collab extends PureComponent<CollabProps, CollabState> {
};
stopCollaboration = (keepRemoteState = true) => {
this.broadcastElements.flush();
this.broadcastElements.cancel();
this.queueBroadcastAllElements.cancel();
this.queueSaveToFirebase.cancel();
this.loadImageFiles.cancel();
@@ -941,7 +943,9 @@ class Collab extends PureComponent<CollabProps, CollabState> {
this.portal.broadcastIdleChange(userState);
};
broadcastElements = (elements: readonly OrderedExcalidrawElement[]) => {
private _broadcastElements = (
elements: readonly OrderedExcalidrawElement[],
) => {
if (
getSceneVersion(elements) >
this.getLastBroadcastedOrReceivedSceneVersion()
@@ -952,6 +956,13 @@ class Collab extends PureComponent<CollabProps, CollabState> {
}
};
broadcastElements = throttle(
(elements: readonly OrderedExcalidrawElement[]) =>
this._broadcastElements(elements),
10,
{ leading: true, trailing: true },
);
syncElements = (elements: readonly OrderedExcalidrawElement[]) => {
this.broadcastElements(elements);
this.queueSaveToFirebase();
+32 -3
View File
@@ -4,7 +4,8 @@ import {
eyeIcon,
} from "@excalidraw/excalidraw/components/icons";
import { MainMenu } from "@excalidraw/excalidraw/index";
import React from "react";
import DropdownMenuItemCheckbox from "@excalidraw/excalidraw/components/dropdownMenu/DropdownMenuItemCheckbox";
import React, { useState } from "react";
import { isDevEnv } from "@excalidraw/common";
@@ -13,7 +14,29 @@ import type { Theme } from "@excalidraw/element/types";
import { LanguageList } from "../app-language/LanguageList";
import { isExcalidrawPlusSignedUser } from "../app_constants";
import { saveDebugState } from "./DebugCanvas";
import {
isVisualDebuggerEnabled,
loadConsoleLoggerState,
saveDebugState,
setConsoleLoggerEnabled,
} from "./DebugCanvas";
const ConsoleLoggerToggle = () => {
const [checked, setChecked] = useState(() => loadConsoleLoggerState());
return (
<DropdownMenuItemCheckbox
checked={checked}
onSelect={(event) => {
const next = !checked;
setChecked(next);
setConsoleLoggerEnabled(next);
event.preventDefault();
}}
>
Show console log overlay
</DropdownMenuItemCheckbox>
);
};
export const AppMainMenu: React.FC<{
onCollabDialogOpen: () => any;
@@ -77,7 +100,13 @@ export const AppMainMenu: React.FC<{
</MainMenu.Item>
)}
<MainMenu.Separator />
<MainMenu.DefaultItems.Preferences />
<MainMenu.DefaultItems.Preferences
additionalItems={
isDevEnv() && isVisualDebuggerEnabled() ? (
<ConsoleLoggerToggle />
) : null
}
/>
<MainMenu.DefaultItems.ToggleTheme
allowSystemTheme
theme={props.theme}
+208 -1
View File
@@ -9,7 +9,7 @@ import {
} from "@excalidraw/excalidraw/renderer/helpers";
import { type AppState } from "@excalidraw/excalidraw/types";
import { arrayToMap, throttleRAF } from "@excalidraw/common";
import { useCallback } from "react";
import { useCallback, useEffect, useRef, useState } from "react";
import {
getGlobalFixedPointForBindableElement,
@@ -435,6 +435,34 @@ export const loadSavedDebugState = () => {
export const isVisualDebuggerEnabled = () =>
Array.isArray(window.visualDebug?.data);
export const loadConsoleLoggerState = (): boolean => {
try {
const raw = localStorage.getItem(STORAGE_KEYS.LOCAL_STORAGE_DEBUG_CONSOLE);
if (raw !== null) {
return JSON.parse(raw) === true;
}
} catch {}
return false;
};
export const saveConsoleLoggerState = (enabled: boolean) => {
try {
localStorage.setItem(
STORAGE_KEYS.LOCAL_STORAGE_DEBUG_CONSOLE,
JSON.stringify(enabled),
);
} catch {}
};
const CONSOLE_LOGGER_TOGGLE_EVENT = "excalidraw-debug-console-toggle";
export const setConsoleLoggerEnabled = (enabled: boolean) => {
saveConsoleLoggerState(enabled);
window.dispatchEvent(
new CustomEvent<boolean>(CONSOLE_LOGGER_TOGGLE_EVENT, { detail: enabled }),
);
};
export const DebugFooter = ({ onChange }: { onChange: () => void }) => {
const moveForward = useCallback(() => {
if (
@@ -459,6 +487,7 @@ export const DebugFooter = ({ onChange }: { onChange: () => void }) => {
}, [onChange]);
const reset = useCallback(() => {
window.visualDebug!.currentFrame = undefined;
_clearLogsCallback?.();
onChange();
}, [onChange]);
const trashFrames = useCallback(() => {
@@ -466,6 +495,7 @@ export const DebugFooter = ({ onChange }: { onChange: () => void }) => {
window.visualDebug.currentFrame = undefined;
window.visualDebug.data = [];
}
_clearLogsCallback?.();
onChange();
}, [onChange]);
@@ -563,4 +593,181 @@ const DebugCanvas = React.forwardRef<HTMLCanvasElement, DebugCanvasProps>(
},
);
type LogLevel = "log" | "info" | "warn" | "error";
interface LogEntry {
id: number;
level: LogLevel;
message: string;
timestamp: number;
}
const LOG_COLORS: Record<LogLevel, string> = {
log: "rgba(220,220,220,0.9)",
info: "rgba(100,180,255,0.9)",
warn: "rgba(255,200,60,0.9)",
error: "rgba(255,90,90,0.9)",
};
const MAX_LOGS = 500;
let logIdCounter = 0;
let _clearLogsCallback: (() => void) | null = null;
export const ConsoleLogger = () => {
const [enabled, setEnabled] = useState(() => loadConsoleLoggerState());
const [logs, setLogs] = useState<LogEntry[]>([]);
const logsRef = useRef<LogEntry[]>([]);
const scrollRef = useRef<HTMLDivElement>(null);
const dragState = useRef<{ startY: number; startScrollTop: number } | null>(
null,
);
const isDragging = useRef(false);
useEffect(() => {
const handler = (e: Event) => {
setEnabled((e as CustomEvent<boolean>).detail);
};
window.addEventListener(CONSOLE_LOGGER_TOGGLE_EVENT, handler);
return () => {
window.removeEventListener(CONSOLE_LOGGER_TOGGLE_EVENT, handler);
};
}, []);
useEffect(() => {
_clearLogsCallback = () => {
logsRef.current = [];
setLogs([]);
};
return () => {
_clearLogsCallback = null;
};
}, []);
useEffect(() => {
const originals: Record<LogLevel, (...args: unknown[]) => void> = {
// eslint-disable-next-line no-console
log: console.log.bind(console),
info: console.info.bind(console),
warn: console.warn.bind(console),
error: console.error.bind(console),
};
const patch = (level: LogLevel) => {
// eslint-disable-next-line no-console
console[level] = (...args: unknown[]) => {
originals[level](...args);
const message = args
.map((a) =>
typeof a === "object" ? JSON.stringify(a, null, 0) : String(a),
)
.join(" ");
const entry: LogEntry = {
id: ++logIdCounter,
level,
message,
timestamp: Date.now(),
};
logsRef.current = [...logsRef.current, entry].slice(-MAX_LOGS);
setLogs([...logsRef.current]);
if (!isDragging.current && scrollRef.current) {
scrollRef.current.scrollTop = scrollRef.current.scrollHeight;
}
};
};
(["log", "info", "warn", "error"] as LogLevel[]).forEach(patch);
return () => {
(["log", "info", "warn", "error"] as LogLevel[]).forEach((level) => {
// eslint-disable-next-line no-console
console[level] = originals[level];
});
};
}, []);
const onPointerDown = useCallback((e: React.PointerEvent<HTMLDivElement>) => {
if (!scrollRef.current) {
return;
}
dragState.current = {
startY: e.clientY,
startScrollTop: scrollRef.current.scrollTop,
};
scrollRef.current.setPointerCapture(e.pointerId);
isDragging.current = true;
e.preventDefault();
}, []);
const onPointerMove = useCallback((e: React.PointerEvent<HTMLDivElement>) => {
if (!dragState.current || !scrollRef.current) {
return;
}
const delta = dragState.current.startY - e.clientY;
scrollRef.current.scrollTop = dragState.current.startScrollTop + delta;
e.preventDefault();
}, []);
const onPointerUp = useCallback(() => {
dragState.current = null;
isDragging.current = false;
}, []);
if (!enabled || logs.length === 0) {
return null;
}
return (
<div
ref={scrollRef}
onPointerDown={onPointerDown}
onPointerMove={onPointerMove}
onPointerUp={onPointerUp}
onPointerCancel={onPointerUp}
style={{
position: "fixed",
top: 8,
right: 8,
zIndex: 9999,
maxWidth: 420,
maxHeight: "60vh",
overflowY: "auto",
display: "flex",
flexDirection: "column",
gap: 2,
pointerEvents: "all",
cursor: "grab",
userSelect: "none",
scrollbarWidth: "none",
}}
>
{logs.map((entry) => (
<div
key={entry.id}
style={{
background: "rgba(18,18,20,0.55)",
backdropFilter: "blur(8px) saturate(1.4)",
WebkitBackdropFilter: "blur(8px) saturate(1.4)",
borderLeft: `3px solid ${LOG_COLORS[entry.level]}`,
borderRadius: 4,
padding: "2px 8px",
fontFamily: "monospace",
fontSize: 11,
lineHeight: 1.5,
color: LOG_COLORS[entry.level],
wordBreak: "break-all",
whiteSpace: "pre-wrap",
opacity: 0.95,
boxShadow: "0 1px 4px rgba(0,0,0,0.35)",
}}
>
<span style={{ opacity: 0.5, marginRight: 6 }}>
{entry.level.toUpperCase()}
</span>
{entry.message}
</div>
))}
</div>
);
};
export default DebugCanvas;
+1 -1
View File
@@ -26,7 +26,6 @@ import {
get,
} from "idb-keyval";
import { appJotaiStore, atom } from "excalidraw-app/app-jotai";
import { getNonDeletedElements } from "@excalidraw/element";
import type { LibraryPersistedData } from "@excalidraw/excalidraw/data/library";
@@ -39,6 +38,7 @@ import type {
} from "@excalidraw/excalidraw/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 { FileManager } from "./FileManager";
+7
View File
@@ -75,6 +75,13 @@ export default defineConfig(({ mode }) => {
find: /^@excalidraw\/utils\/(.*?)/,
replacement: path.resolve(__dirname, "../packages/utils/src/$1"),
},
{
find: /^@excalidraw\/fractional-indexing$/,
replacement: path.resolve(
__dirname,
"../packages/fractional-indexing/src/index.ts",
),
},
],
},
build: {
+2 -1
View File
@@ -56,7 +56,8 @@
"build:element": "yarn --cwd ./packages/element build:esm",
"build:excalidraw": "yarn --cwd ./packages/excalidraw build:esm",
"build:math": "yarn --cwd ./packages/math build:esm",
"build:packages": "yarn build:common && yarn build:math && yarn build:element && yarn build:excalidraw",
"build:fractional-indexing": "yarn --cwd ./packages/fractional-indexing build:esm",
"build:packages": "yarn build:common && yarn build:fractional-indexing && yarn build:math && yarn build:element && yarn build:excalidraw",
"build:version": "yarn --cwd ./excalidraw-app build:version",
"build": "yarn --cwd ./excalidraw-app build",
"build:preview": "yarn --cwd ./excalidraw-app build:preview",
+5 -1
View File
@@ -80,7 +80,11 @@ const cssInvert = (
return { r: invertedR, g: invertedG, b: invertedB };
};
export const applyDarkModeFilter = (color: string): string => {
export const applyDarkModeFilter = (color: string, enable = true): string => {
if (!enable) {
return color;
}
const cached = DARK_MODE_COLORS_CACHE?.get(color);
if (cached) {
return cached;
+4 -3
View File
@@ -337,9 +337,10 @@ export const MAX_DECIMALS_FOR_SVG_EXPORT = 2;
export const EXPORT_SCALES = [1, 2, 3];
export const DEFAULT_EXPORT_PADDING = 10; // px
export const DEFAULT_MAX_IMAGE_WIDTH_OR_HEIGHT = 1440;
export const MAX_ALLOWED_FILE_BYTES = 4 * 1024 * 1024;
export const DEFAULT_IMAGE_OPTIONS: AppProps["imageOptions"] = {
maxWidthOrHeight: 1440,
maxFileSizeBytes: 4 * 1024 * 1024,
};
export const SVG_NS = "http://www.w3.org/2000/svg";
export const SVG_DOCUMENT_PREAMBLE = `<?xml version="1.0" standalone="no"?>
+2 -1
View File
@@ -64,6 +64,7 @@
},
"dependencies": {
"@excalidraw/common": "0.18.0",
"@excalidraw/math": "0.18.0"
"@excalidraw/math": "0.18.0",
"@excalidraw/fractional-indexing": "3.3.0"
}
}
+12 -36
View File
@@ -338,29 +338,20 @@ export class Scene {
this.callbacks.clear();
}
insertElementAtIndex(element: ExcalidrawElement, index: number) {
if (!Number.isFinite(index) || index < 0) {
throw new Error(
"insertElementAtIndex can only be called with index >= 0",
);
}
const nextElements = [
...this.elements.slice(0, index),
element,
...this.elements.slice(index),
];
syncMovedIndices(nextElements, arrayToMap([element]));
this.replaceAllElements(nextElements);
}
insertElementsAtIndex(elements: ExcalidrawElement[], index: number) {
/** low-level - generally use app.insertNewElements() */
insertElementsAtIndex(
elements: ExcalidrawElement[],
/** null indicates end of the array */
index: number | null,
) {
if (!elements.length) {
return;
}
if (index === null) {
index = this.elements.length;
}
if (!Number.isFinite(index) || index < 0) {
throw new Error(
"insertElementAtIndex can only be called with index >= 0",
@@ -378,24 +369,9 @@ export class Scene {
this.replaceAllElements(nextElements);
}
/** low-level - generally use app.insertNewElement() */
insertElement = (element: ExcalidrawElement) => {
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);
this.insertElementsAtIndex([element], null);
};
getElementIndex(elementId: string) {
+32
View File
@@ -0,0 +1,32 @@
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;
};
+11 -10
View File
@@ -734,12 +734,11 @@ const getBindingStrategyForDraggingBindingElementEndpoints_simple = (
});
// Handle outside-outside binding to the same element
if (otherBinding && otherBinding.elementId === hit?.id) {
invariant(
!opts?.newArrow || appState.selectedLinearElement?.initialState.origin,
"appState.selectedLinearElement.initialState.origin must be defined for new arrows",
);
if (
otherBinding &&
otherBinding.elementId === hit?.id &&
(!opts?.newArrow || appState.selectedLinearElement?.initialState.origin)
) {
return {
start: {
mode: "inside",
@@ -1943,9 +1942,9 @@ export const calculateFixedPointForElbowArrowBinding = (
return {
fixedPoint: normalizeFixedPoint([
(nonRotatedSnappedGlobalPoint[0] - hoveredElement.x) /
hoveredElement.width,
Math.max(hoveredElement.width, PRECISION),
(nonRotatedSnappedGlobalPoint[1] - hoveredElement.y) /
hoveredElement.height,
Math.max(hoveredElement.height, PRECISION),
]),
};
};
@@ -1976,9 +1975,11 @@ export const calculateFixedPointForNonElbowArrowBinding = (
// Calculate the ratio relative to the element's bounds
const fixedPointX =
(nonRotatedPoint[0] - hoveredElement.x) / hoveredElement.width;
(nonRotatedPoint[0] - hoveredElement.x) /
Math.max(hoveredElement.width, PRECISION);
const fixedPointY =
(nonRotatedPoint[1] - hoveredElement.y) / hoveredElement.height;
(nonRotatedPoint[1] - hoveredElement.y) /
Math.max(hoveredElement.height, PRECISION);
return {
fixedPoint: normalizeFixedPoint([fixedPointX, fixedPointY]),
+63 -30
View File
@@ -680,8 +680,9 @@ export const getMinMaxXYFromCurvePathOps = (
return [minX, minY, maxX, maxY];
};
export const getBoundsFromPoints = (
points: ExcalidrawFreeDrawElement["points"],
export const getBoundsFromPoints = <P extends GlobalPoint | LocalPoint>(
points: readonly P[],
padding: number = 0,
): Bounds => {
let minX = Infinity;
let minY = Infinity;
@@ -695,7 +696,7 @@ export const getBoundsFromPoints = (
maxY = Math.max(maxY, y);
}
return [minX, minY, maxX, maxY];
return [minX - padding, minY - padding, maxX + padding, maxY + padding];
};
const getFreeDrawElementAbsoluteCoords = (
@@ -709,6 +710,9 @@ const getFreeDrawElementAbsoluteCoords = (
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 */
export const getArrowheadSize = (arrowhead: Arrowhead): number => {
switch (arrowhead) {
@@ -717,10 +721,14 @@ export const getArrowheadSize = (arrowhead: Arrowhead): number => {
case "diamond":
case "diamond_outline":
return 12;
case "crowfoot_many":
case "crowfoot_one":
case "crowfoot_one_or_many":
return 20;
case "cardinality_many":
case "cardinality_one_or_many":
case "cardinality_zero_or_many":
return CROWFOOT_ARROWHEAD_SIZE;
case "cardinality_one":
case "cardinality_exactly_one":
case "cardinality_zero_or_one":
return CARDINALITY_MARKER_SIZE;
default:
return 15;
}
@@ -743,7 +751,12 @@ export const getArrowheadPoints = (
shape: Drawable[],
position: "start" | "end",
arrowhead: Arrowhead,
offsetMultiplier = 0,
) => {
if (arrowhead === null) {
return null;
}
if (shape.length < 1) {
return null;
}
@@ -824,29 +837,30 @@ export const getArrowheadPoints = (
const lengthMultiplier =
arrowhead === "diamond" || arrowhead === "diamond_outline" ? 0.25 : 0.5;
const minSize = Math.min(size, length * lengthMultiplier);
const xs = x2 - nx * minSize;
const ys = y2 - ny * minSize;
const tx = x2 - nx * minSize * offsetMultiplier;
const ty = y2 - ny * minSize * offsetMultiplier;
const xs = tx - nx * minSize;
const ys = ty - ny * minSize;
if (
arrowhead === "dot" ||
arrowhead === "circle" ||
arrowhead === "circle_outline"
) {
const diameter = Math.hypot(ys - y2, xs - x2) + element.strokeWidth - 2;
return [x2, y2, diameter];
if (arrowhead === "circle" || arrowhead === "circle_outline") {
const diameter = Math.hypot(ys - ty, xs - tx) + element.strokeWidth - 2;
return [tx, ty, diameter];
}
const angle = getArrowheadAngle(arrowhead);
if (arrowhead === "crowfoot_many" || arrowhead === "crowfoot_one_or_many") {
if (
arrowhead === "cardinality_many" ||
arrowhead === "cardinality_one_or_many"
) {
// swap (xs, ys) with (x2, y2)
const [x3, y3] = pointRotateRads(
pointFrom(x2, y2),
pointFrom(tx, ty),
pointFrom(xs, ys),
degreesToRadians(-angle as Degrees),
);
const [x4, y4] = pointRotateRads(
pointFrom(x2, y2),
pointFrom(tx, ty),
pointFrom(xs, ys),
degreesToRadians(angle),
);
@@ -856,12 +870,12 @@ export const getArrowheadPoints = (
// Return points
const [x3, y3] = pointRotateRads(
pointFrom(xs, ys),
pointFrom(x2, y2),
pointFrom(tx, ty),
((-angle * Math.PI) / 180) as Radians,
);
const [x4, y4] = pointRotateRads(
pointFrom(xs, ys),
pointFrom(x2, y2),
pointFrom(tx, ty),
degreesToRadians(angle),
);
@@ -874,9 +888,9 @@ export const getArrowheadPoints = (
const [px, py] = element.points.length > 1 ? element.points[1] : [0, 0];
[ox, oy] = pointRotateRads(
pointFrom(x2 + minSize * 2, y2),
pointFrom(x2, y2),
Math.atan2(py - y2, px - x2) as Radians,
pointFrom(tx + minSize * 2, ty),
pointFrom(tx, ty),
Math.atan2(py - ty, px - tx) as Radians,
);
} else {
const [px, py] =
@@ -885,16 +899,16 @@ export const getArrowheadPoints = (
: [0, 0];
[ox, oy] = pointRotateRads(
pointFrom(x2 - minSize * 2, y2),
pointFrom(x2, y2),
Math.atan2(y2 - py, x2 - px) as Radians,
pointFrom(tx - minSize * 2, ty),
pointFrom(tx, ty),
Math.atan2(ty - py, tx - px) as Radians,
);
}
return [x2, y2, x3, y3, ox, oy, x4, y4];
return [tx, ty, x3, y3, ox, oy, x4, y4];
}
return [x2, y2, x3, y3, x4, y4];
return [tx, ty, x3, y3, x4, y4];
};
// TODO reuse shape.ts
@@ -1248,6 +1262,17 @@ export const pointInsideBounds = <P extends GlobalPoint | LocalPoint>(
): boolean =>
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 = (
bounds1: Bounds | null,
bounds2: Bounds | null,
@@ -1262,13 +1287,21 @@ export const doBoundsIntersect = (
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));
export const elementCenterPoint = (
element: ExcalidrawElement,
elementsMap: ElementsMap,
xOffset: number = 0,
yOffset: number = 0,
) => {
if (isLinearElement(element)) {
if (isLinearElement(element) || isFreeDrawElement(element)) {
const [x1, y1, x2, y2] = getElementAbsoluteCoords(element, elementsMap);
const [x, y] = pointFrom<GlobalPoint>((x1 + x2) / 2, (y1 + y2) / 2);
+46 -19
View File
@@ -61,6 +61,8 @@ import { distanceToElement } from "./distance";
import { getBindingGap } from "./binding";
import { hasBackground } from "./comparisons";
import type {
ElementsMap,
ExcalidrawArrowElement,
@@ -83,7 +85,7 @@ export const shouldTestInside = (element: ExcalidrawElement) => {
}
const isDraggableFromInside =
!isTransparent(element.backgroundColor) ||
(hasBackground(element.type) && !isTransparent(element.backgroundColor)) ||
hasBoundTextElement(element) ||
isIframeLikeElement(element) ||
isTextElement(element);
@@ -154,14 +156,11 @@ export const hitElementItself = ({
// Hit test against the extended, rotated bounding box of the element first
const bounds = getElementBounds(element, elementsMap, true);
const hitBounds = isPointWithinBounds(
pointFrom(bounds[0] - threshold, bounds[1] - threshold),
pointRotateRads(
point,
getCenterForBounds(bounds),
-element.angle as Radians,
),
pointFrom(bounds[2] + threshold, bounds[3] + threshold),
const hitBounds = isPointInRotatedBounds(
point,
bounds,
element.angle,
threshold,
);
// PERF: Bail out early if the point is not even in the
@@ -192,18 +191,32 @@ export const hitElementItself = ({
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 = (
point: GlobalPoint,
element: ExcalidrawElement,
elementsMap: ElementsMap,
tolerance = 0,
) => {
let [x1, y1, x2, y2] = getElementBounds(element, elementsMap);
x1 -= tolerance;
y1 -= tolerance;
x2 += tolerance;
y2 += tolerance;
return isPointWithinBounds(pointFrom(x1, y1), point, pointFrom(x2, y2));
const bounds = getElementBounds(element, elementsMap, true);
return isPointInRotatedBounds(point, bounds, element.angle, tolerance);
};
export const hitElementBoundingBoxOnly = (
@@ -313,7 +326,10 @@ export const getAllHoveredElementAtPoint = (
) {
candidateElements.push(element);
if (!isTransparent(element.backgroundColor)) {
if (
hasBackground(element.type) &&
!isTransparent(element.backgroundColor)
) {
break;
}
}
@@ -465,7 +481,12 @@ export const intersectElementWithLineSegment = (
case "line":
case "freedraw":
case "arrow":
return intersectLinearOrFreeDrawWithLineSegment(element, line, onlyFirst);
return intersectLinearOrFreeDrawWithLineSegment(
element,
line,
elementsMap,
onlyFirst,
);
}
};
@@ -532,11 +553,15 @@ const lineIntersections = (
const intersectLinearOrFreeDrawWithLineSegment = (
element: ExcalidrawLinearElement | ExcalidrawFreeDrawElement,
segment: LineSegment<GlobalPoint>,
elementsMap: ElementsMap,
onlyFirst = false,
): GlobalPoint[] => {
// NOTE: This is the only one which return the decomposed elements
// rotated! This is due to taking advantage of roughjs definitions.
const [lines, curves] = deconstructLinearOrFreeDrawElement(element);
const [lines, curves] = deconstructLinearOrFreeDrawElement(
element,
elementsMap,
);
const intersections: GlobalPoint[] = [];
for (const l of lines) {
@@ -564,7 +589,9 @@ const intersectLinearOrFreeDrawWithLineSegment = (
continue;
}
const hits = curveIntersectLineSegment(c, segment);
const hits = curveIntersectLineSegment(c, segment, {
iterLimit: 10,
});
if (hits.length > 0) {
intersections.push(...hits);
+6 -2
View File
@@ -48,7 +48,7 @@ export const distanceToElement = (
case "line":
case "arrow":
case "freedraw":
return distanceToLinearOrFreeDraElement(element, p);
return distanceToLinearOrFreeDraElement(element, elementsMap, p);
}
};
@@ -133,9 +133,13 @@ const distanceToEllipseElement = (
const distanceToLinearOrFreeDraElement = (
element: ExcalidrawLinearElement | ExcalidrawFreeDrawElement,
elementsMap: ElementsMap,
p: GlobalPoint,
) => {
const [lines, curves] = deconstructLinearOrFreeDrawElement(element);
const [lines, curves] = deconstructLinearOrFreeDrawElement(
element,
elementsMap,
);
return Math.min(
...lines.map((s) => distanceToLineSegment(p, s)),
...curves.map((a) => curvePointDistance(a, p)),
+22 -2
View File
@@ -111,6 +111,9 @@ export const duplicateElements = (
* user interaction.
*/
type: "everything";
// TODO remove/review this once we add frame children order migration
// and invariant checks
preserveFrameChildrenOrder?: boolean;
}
| {
/**
@@ -170,6 +173,8 @@ export const duplicateElements = (
opts.type === "in-place"
? opts.idsOfElementsToDuplicate
: new Map(elements.map((el) => [el.id, el]));
const preserveFrameChildrenOrder =
opts.type === "everything" && opts.preserveFrameChildrenOrder;
// For sanity
if (opts.type === "in-place") {
@@ -250,6 +255,9 @@ export const duplicateElements = (
elementsWithDuplicates.splice(index + 1, 0, ...castArray(elements));
};
// main
// ---------------------------------------------------------------------------
const frameIdsToDuplicate = new Set(
elements
.filter(
@@ -274,7 +282,7 @@ export const duplicateElements = (
if (groupId) {
const groupElements = getElementsInGroup(elements, groupId).flatMap(
(element) =>
isFrameLikeElement(element)
isFrameLikeElement(element) && !preserveFrameChildrenOrder
? [...getFrameChildren(elements, element.id), element]
: [element],
);
@@ -290,13 +298,25 @@ export const duplicateElements = (
// frame duplication
// -------------------------------------------------------------------------
if (element.frameId && frameIdsToDuplicate.has(element.frameId)) {
if (
!preserveFrameChildrenOrder &&
element.frameId &&
frameIdsToDuplicate.has(element.frameId)
) {
continue;
}
if (isFrameLikeElement(element)) {
const frameId = element.id;
if (preserveFrameChildrenOrder) {
insertBeforeOrAfterIndex(
findLastIndex(elementsWithDuplicates, (el) => el.id === frameId),
copyElements(element),
);
continue;
}
const frameChildren = getFrameChildren(elements, frameId);
const targetIndex = findLastIndex(elementsWithDuplicates, (el) => {
+2 -2
View File
@@ -2124,8 +2124,8 @@ const normalizeArrowElementUpdate = (
offsetY < -MAX_POS ||
offsetY > MAX_POS ||
offsetX + points[points.length - 1][0] < -MAX_POS ||
offsetY + points[points.length - 1][0] > MAX_POS ||
offsetX + points[points.length - 1][1] < -MAX_POS ||
offsetX + points[points.length - 1][0] > MAX_POS ||
offsetY + points[points.length - 1][1] < -MAX_POS ||
offsetY + points[points.length - 1][1] > MAX_POS
) {
console.error(
+12 -2
View File
@@ -1,7 +1,10 @@
import { generateNKeysBetween } from "fractional-indexing";
import { arrayToMap } from "@excalidraw/common";
import {
validateOrderKey,
generateNKeysBetween,
} from "@excalidraw/fractional-indexing";
import { mutateElement, newElementWith } from "./mutateElement";
import { getBoundTextElement } from "./textElement";
import { hasBoundTextElement } from "./typeChecks";
@@ -382,6 +385,13 @@ const isValidFractionalIndex = (
return false;
}
try {
// Format validation
validateOrderKey(index);
} catch {
return false;
}
if (predecessor && successor) {
return predecessor < index && index < successor;
}
+108 -48
View File
@@ -1,7 +1,6 @@
import { arrayToMap } from "@excalidraw/common";
import { isPointWithinBounds, pointFrom } from "@excalidraw/math";
import { doLineSegmentsIntersect } from "@excalidraw/utils/bbox";
import { elementsOverlappingBBox } from "@excalidraw/utils/withinBounds";
import type {
AppClassProperties,
@@ -18,9 +17,13 @@ import {
getElementLineSegments,
getCommonBounds,
getElementAbsoluteCoords,
doBoundsIntersect,
getElementBounds,
boundsContainBounds,
} from "./bounds";
import { mutateElement } from "./mutateElement";
import { getBoundTextElement, getContainerElement } from "./textElement";
import { syncMovedIndices } from "./fractionalIndex";
import {
isFrameElement,
isFrameLikeElement,
@@ -100,8 +103,9 @@ export const isElementContainingFrame = (
frame: ExcalidrawFrameLikeElement,
elementsMap: ElementsMap,
) => {
return getElementsWithinSelection([frame], element, elementsMap).some(
(e) => e.id === frame.id,
return boundsContainBounds(
getElementBounds(element, elementsMap),
getElementBounds(frame, elementsMap),
);
};
@@ -488,10 +492,44 @@ export const filterElementsEligibleAsFrameChildren = (
return eligibleElements;
};
export const getCommonFrameId = (elements: readonly ExcalidrawElement[]) => {
let commonFrameId: ExcalidrawElement["frameId"] | undefined;
for (const element of elements) {
if (isFrameLikeElement(element) || !element.frameId) {
return null;
}
if (commonFrameId === undefined) {
commonFrameId = element.frameId;
} else if (commonFrameId !== element.frameId) {
return null;
}
}
return commonFrameId ?? null;
};
export const getFrameChildrenInsertionIndex = (
elements: readonly ExcalidrawElement[],
frameId: ExcalidrawFrameLikeElement["id"],
): number | null => {
for (let index = elements.length - 1; index >= 0; index--) {
const element = elements[index];
if (element.id === frameId) {
return index;
} else if (element.frameId === frameId) {
return index + 1;
}
}
return null;
};
/**
* Retains (or repairs for target frame) the ordering invriant where children
* elements come right before the parent frame:
* [el, el, child, child, frame, el]
* Adds elements and their bound elements to frame. Reorders added elements to
* be just below frame, or just above its highest child (whichever is higher).
*
* @returns mutated allElements (same data structure)
*/
@@ -499,19 +537,11 @@ export const addElementsToFrame = <T extends ElementsMapOrArray>(
allElements: T,
elementsToAdd: NonDeletedExcalidrawElement[],
frame: ExcalidrawFrameLikeElement,
appState: AppState,
): T => {
const elementsMap = arrayToMap(allElements);
const currTargetFrameChildrenMap = new Map<ExcalidrawElement["id"], true>();
for (const element of allElements.values()) {
if (element.frameId === frame.id) {
currTargetFrameChildrenMap.set(element.id, true);
}
}
const commonFrameId = getCommonFrameId(elementsToAdd);
const suppliedElementsToAddSet = new Set(elementsToAdd.map((el) => el.id));
const finalElementsToAdd: ExcalidrawElement[] = [];
const finalElementsToAdd = new Set<ExcalidrawElement>();
const otherFrames = new Set<ExcalidrawFrameLikeElement["id"]>();
@@ -522,7 +552,8 @@ export const addElementsToFrame = <T extends ElementsMapOrArray>(
}
// - add bound text elements if not already in the array
// - filter out elements that are already in the frame
// - keep elements already in the frame so mixed selections can be reordered
// together
for (const element of omitGroupsContainingFrameLikes(
allElements,
elementsToAdd,
@@ -535,38 +566,68 @@ export const addElementsToFrame = <T extends ElementsMapOrArray>(
continue;
}
// 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]
) {
if (element.frameId && element.frameId !== frame.id) {
continue;
}
if (!currTargetFrameChildrenMap.has(element.id)) {
finalElementsToAdd.push(element);
}
finalElementsToAdd.add(element);
const boundTextElement = getBoundTextElement(element, elementsMap);
if (
boundTextElement &&
!suppliedElementsToAddSet.has(boundTextElement.id) &&
!currTargetFrameChildrenMap.has(boundTextElement.id)
) {
finalElementsToAdd.push(boundTextElement);
if (boundTextElement && !finalElementsToAdd.has(boundTextElement)) {
finalElementsToAdd.add(boundTextElement);
}
}
for (const element of finalElementsToAdd) {
mutateElement(element, elementsMap, {
frameId: frame.id,
});
// we don't always need to update the element if it's already in the frame,
// but we still need to accumulate in finalElementsToAdd so we potentially
// reorder them if added together
if (element.frameId !== frame.id) {
mutateElement(element, elementsMap, {
frameId: frame.id,
});
}
}
return allElements;
// (re)order elements to be just below the frame,
// or just above the highest child if that is higher
// (latter case is denormalized order until we migrate)
// ---------------------------------------------------------------------------
if (
!finalElementsToAdd.size ||
// if all elements to add already belong to the frame, then we don't want to
// reorder (case: we're dragging element children within the frame)
commonFrameId === frame.id
) {
return allElements;
}
const otherElements = Array.from(allElements.values()).filter(
(element) => !finalElementsToAdd.has(element),
);
const insertionIndex = getFrameChildrenInsertionIndex(
otherElements,
frame.id,
);
if (insertionIndex === null) {
return allElements;
}
const reorderedElements = [
...otherElements.slice(0, insertionIndex),
...finalElementsToAdd,
...otherElements.slice(insertionIndex),
];
syncMovedIndices(reorderedElements, arrayToMap([...finalElementsToAdd]));
return (
Array.isArray(allElements)
? reorderedElements
: new Map(reorderedElements.map((element) => [element.id, element]))
) as T;
};
export const removeElementsFromFrame = (
@@ -620,13 +681,11 @@ export const replaceAllElementsInFrame = <T extends ExcalidrawElement>(
allElements: readonly T[],
nextElementsInFrame: ExcalidrawElement[],
frame: ExcalidrawFrameLikeElement,
app: AppClassProperties,
): T[] => {
return addElementsToFrame(
removeAllElementsFromFrame(allElements, frame),
nextElementsInFrame,
frame,
app.state,
).slice();
};
@@ -920,16 +979,17 @@ export const getFrameLikeTitle = (element: ExcalidrawFrameLikeElement) => {
export const getElementsOverlappingFrame = (
elements: readonly ExcalidrawElement[],
frame: ExcalidrawFrameLikeElement,
elementsMap: ElementsMap,
) => {
return (
elementsOverlappingBBox({
elements,
bounds: frame,
type: "overlap",
})
// removes elements who are overlapping, but are in a different frame,
return elements.filter(
(el) =>
// exclude elements which are overlapping, but are in a different frame,
// and thus invisible in target frame
.filter((el) => !el.frameId || el.frameId === frame.id)
(!el.frameId || el.frameId === frame.id) &&
doBoundsIntersect(
getElementBounds(el, elementsMap),
getElementBounds(frame, elementsMap),
),
);
};
+2
View File
@@ -81,6 +81,7 @@ export * from "./mutateElement";
export * from "./newElement";
export * from "./positionElementsOnGrid";
export * from "./renderElement";
export { invalidateFreeDrawIncrementalCanvas } from "./renderFreedraw";
export * from "./resizeElements";
export * from "./resizeTest";
export * from "./Scene";
@@ -99,3 +100,4 @@ export * from "./typeChecks";
export * from "./utils";
export * from "./zindex";
export * from "./arrows/helpers";
export * from "./arrowheads";
+35 -18
View File
@@ -476,16 +476,22 @@ export class LinearElementEditor {
});
}
invariant(
lastClickedPoint > -1 &&
selectedPointsIndices.includes(lastClickedPoint) &&
element.points[lastClickedPoint],
`There must be a valid lastClickedPoint in order to drag it. selectedPointsIndices(${JSON.stringify(
selectedPointsIndices,
)}) points(0..${
element.points.length - 1
}) lastClickedPoint(${lastClickedPoint})`,
);
if (
lastClickedPoint < 0 ||
!selectedPointsIndices.includes(lastClickedPoint) ||
!element.points[lastClickedPoint]
) {
console.error(
`There must be a valid lastClickedPoint in order to drag it. selectedPointsIndices(${JSON.stringify(
selectedPointsIndices,
)}) points(0..${
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)
const draggingPoint = element.points[lastClickedPoint];
@@ -794,6 +800,7 @@ export class LinearElementEditor {
element.points[index + 1],
index,
appState.zoom,
elementsMap,
)
) {
midpoints.push(null);
@@ -803,6 +810,7 @@ export class LinearElementEditor {
const segmentMidPoint = LinearElementEditor.getSegmentMidPoint(
element,
index + 1,
elementsMap,
);
midpoints.push(segmentMidPoint);
index++;
@@ -890,6 +898,7 @@ export class LinearElementEditor {
endPoint: P,
index: number,
zoom: Zoom,
elementsMap: ElementsMap,
) {
if (isElbowArrow(element)) {
if (index >= 0 && index < element.points.length) {
@@ -904,7 +913,10 @@ export class LinearElementEditor {
let distance = pointDistance(startPoint, endPoint);
if (element.points.length > 2 && element.roundness) {
const [lines, curves] = deconstructLinearOrFreeDrawElement(element);
const [lines, curves] = deconstructLinearOrFreeDrawElement(
element,
elementsMap,
);
invariant(
lines.length === 0 && curves.length > 0,
@@ -924,6 +936,7 @@ export class LinearElementEditor {
static getSegmentMidPoint(
element: NonDeleted<ExcalidrawLinearElement>,
index: number,
elementsMap: ElementsMap,
): GlobalPoint {
if (isElbowArrow(element)) {
invariant(
@@ -936,7 +949,10 @@ export class LinearElementEditor {
return pointFrom<GlobalPoint>(element.x + p[0], element.y + p[1]);
}
const [lines, curves] = deconstructLinearOrFreeDrawElement(element);
const [lines, curves] = deconstructLinearOrFreeDrawElement(
element,
elementsMap,
);
invariant(
(lines.length === 0 && curves.length > 0) ||
@@ -1851,6 +1867,7 @@ export class LinearElementEditor {
const midSegmentMidpoint = LinearElementEditor.getSegmentMidPoint(
element,
index + 1,
elementsMap,
);
x = midSegmentMidpoint[0] - boundTextElement.width / 2;
@@ -2122,13 +2139,13 @@ const pointDraggingUpdates = (
} => {
const naiveDraggingPoints = new Map(
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 [
pointIndex,
{
point: pointFrom<LocalPoint>(
element.points[pointIndex][0] + deltaX,
element.points[pointIndex][1] + deltaY,
),
point: pointFrom<LocalPoint>(point[0] + deltaX, point[1] + deltaY),
isDragging: true,
},
];
@@ -2400,7 +2417,7 @@ const pointDraggingUpdates = (
? nextArrow.points[0]
: endBindable
? updateBoundPoint(
element,
nextArrow,
"endBinding",
nextArrow.endBinding,
endBindable,
@@ -2431,7 +2448,7 @@ const pointDraggingUpdates = (
? endLocalPoint
: startBindable
? updateBoundPoint(
element,
nextArrow,
"startBinding",
nextArrow.startBinding,
startBindable,
+82 -32
View File
@@ -65,8 +65,12 @@ import {
} from "./typeChecks";
import { getContainingFrame } from "./frame";
import { getCornerRadius } from "./utils";
import { ShapeCache } from "./shape";
import {
drawFreeDrawSegments,
generateOrUpdateFreeDrawIncrementalCanvas,
getFreedrawCanvasPadding,
} from "./renderFreedraw";
import type {
ExcalidrawElement,
@@ -92,7 +96,7 @@ const isPendingImageElement = (
const getCanvasPadding = (element: ExcalidrawElement) => {
switch (element.type) {
case "freedraw":
return element.strokeWidth * 12;
return getFreedrawCanvasPadding(element);
case "text":
return element.fontSize / 2;
case "arrow":
@@ -144,6 +148,15 @@ export interface ExcalidrawElementWithCanvas {
imageCrop: ExcalidrawImageElement["crop"] | null;
containingFrameOpacity: number;
boundTextCanvas: HTMLCanvasElement;
canvasOriginSceneX?: number;
canvasOriginSceneY?: number;
/**
* Tip canvas for incremental freedraw rendering. Contains only the last
* unfinalised segment (whose Catmull-Rom right-hand tangent changes with
* each new point) and is cleared + redrawn every frame. Composited on top
* of `canvas` (the committed accumulation canvas) in drawElementFromCanvas.
*/
tipCanvas?: HTMLCanvasElement;
}
const cappedElementCanvasSize = (
@@ -253,7 +266,7 @@ const generateElementCanvas = (
const rc = rough.canvas(canvas);
drawElementOnCanvas(element, rc, context, renderConfig);
drawElementOnCanvas(element, rc, context, renderConfig, scale);
context.restore();
@@ -389,6 +402,7 @@ const drawElementOnCanvas = (
rc: RoughCanvas,
context: CanvasRenderingContext2D,
renderConfig: StaticCanvasRenderConfig,
scale = 1,
) => {
switch (element.type) {
case "rectangle":
@@ -415,23 +429,8 @@ const drawElementOnCanvas = (
break;
}
case "freedraw": {
// Draw directly to canvas
context.save();
const shapes = ShapeCache.generateElementShape(element, renderConfig);
for (const shape of shapes) {
if (typeof shape === "string") {
context.fillStyle =
renderConfig.theme === THEME.DARK
? applyDarkModeFilter(element.strokeColor)
: element.strokeColor;
context.fill(new Path2D(shape));
} else {
rc.draw(shape);
}
}
drawFreeDrawSegments(element, context, renderConfig, 0, undefined, scale);
context.restore();
break;
}
@@ -555,10 +554,10 @@ const drawElementOnCanvas = (
context.canvas.setAttribute("dir", rtl ? "rtl" : "ltr");
context.save();
context.font = getFontString(element);
context.fillStyle =
renderConfig.theme === THEME.DARK
? applyDarkModeFilter(element.strokeColor)
: element.strokeColor;
context.fillStyle = applyDarkModeFilter(
element.strokeColor,
renderConfig.theme === THEME.DARK,
);
context.textAlign = element.textAlign as CanvasTextAlign;
// Canvas does not support multiline text by default
@@ -616,6 +615,24 @@ const generateElementWithCanvas = (
: {
value: 1 as NormalizedZoomValue,
};
// Incremental rendering path for freedraw elements being actively drawn.
// ShapeCache.delete() clears elementWithCanvasCache on every added point, so
// we bypass that cache entirely and use freedrawIncrementalCache instead.
if (
isFreeDrawElement(element) &&
"newElement" in appState &&
appState.newElement?.id === element.id
) {
return generateOrUpdateFreeDrawIncrementalCanvas(
element as ExcalidrawFreeDrawElement,
elementsMap,
zoom,
renderConfig,
appState,
);
}
const prevElementWithCanvas = elementWithCanvasCache.get(element);
const shouldRegenerateBecauseZoom =
prevElementWithCanvas &&
@@ -718,16 +735,39 @@ const drawElementFromCanvas = (
// revert afterwards we don't have account for it during drawing
context.translate(-cx, -cy);
// For the incremental freedraw path, the canvas origin is stored explicitly
// because the canvas is over-allocated beyond the tight element bounds.
const destX =
elementWithCanvas.canvasOriginSceneX !== undefined
? (elementWithCanvas.canvasOriginSceneX + appState.scrollX) *
window.devicePixelRatio
: (x1 + appState.scrollX) * window.devicePixelRatio - padding;
const destY =
elementWithCanvas.canvasOriginSceneY !== undefined
? (elementWithCanvas.canvasOriginSceneY + appState.scrollY) *
window.devicePixelRatio
: (y1 + appState.scrollY) * window.devicePixelRatio - padding;
context.drawImage(
elementWithCanvas.canvas!,
(x1 + appState.scrollX) * window.devicePixelRatio -
(padding * elementWithCanvas.scale) / elementWithCanvas.scale,
(y1 + appState.scrollY) * window.devicePixelRatio -
(padding * elementWithCanvas.scale) / elementWithCanvas.scale,
destX,
destY,
elementWithCanvas.canvas!.width / elementWithCanvas.scale,
elementWithCanvas.canvas!.height / elementWithCanvas.scale,
);
// Composite the tip canvas (incremental freedraw path) on top. It is
// the same size and at the same scene origin as the committed canvas, so
// it uses identical destX / destY / dimensions.
if (elementWithCanvas.tipCanvas) {
context.drawImage(
elementWithCanvas.tipCanvas,
destX,
destY,
elementWithCanvas.tipCanvas.width / elementWithCanvas.scale,
elementWithCanvas.tipCanvas.height / elementWithCanvas.scale,
);
}
if (
import.meta.env.VITE_APP_DEBUG_ENABLE_TEXT_CONTAINER_BOUNDING_BOX ===
"true" &&
@@ -811,10 +851,10 @@ export const renderElement = (
context.fillStyle = "rgba(0, 0, 200, 0.04)";
context.lineWidth = FRAME_STYLE.strokeWidth / appState.zoom.value;
context.strokeStyle =
appState.theme === THEME.DARK
? applyDarkModeFilter(FRAME_STYLE.strokeColor)
: FRAME_STYLE.strokeColor;
context.strokeStyle = applyDarkModeFilter(
FRAME_STYLE.strokeColor,
appState.theme === THEME.DARK,
);
// TODO change later to only affect AI frames
if (isMagicFrameElement(element)) {
@@ -867,6 +907,14 @@ export const renderElement = (
return;
}
const currentImageSmoothingStatus = context.imageSmoothingEnabled;
if (
!appState?.shouldCacheIgnoreZoom &&
(!element.angle || isRightAngleRads(element.angle))
) {
context.imageSmoothingEnabled = false;
}
drawElementFromCanvas(
elementWithCanvas,
context,
@@ -874,6 +922,8 @@ export const renderElement = (
appState,
allElementsMap,
);
context.imageSmoothingEnabled = currentImageSmoothingStatus;
}
break;
@@ -983,7 +1033,7 @@ export const renderElement = (
}
context.restore();
// not exporting optimized rendering (cache & render from element
// not exporting -> optimized rendering (cache & render from element
// canvases)
} else {
const elementWithCanvas = generateElementWithCanvas(
+549
View File
@@ -0,0 +1,549 @@
import { applyDarkModeFilter, THEME } from "@excalidraw/common";
import type { StaticCanvasRenderConfig } from "@excalidraw/excalidraw/scene/types";
import type {
AppState,
InteractiveCanvasAppState,
StaticCanvasAppState,
Zoom,
} from "@excalidraw/excalidraw/types";
import { getElementAbsoluteCoords } from "./bounds";
import { getContainingFrame } from "./frame";
import type { ExcalidrawElementWithCanvas } from "./renderElement";
import type {
ExcalidrawFreeDrawElement,
NonDeletedSceneElementsMap,
} from "./types";
const DEFAULT_FREEDRAW_PRESSURE = 0.5;
/**
* Half-width (in samples) of the triangular smoothing kernel applied to raw
* pressure values before computing stroke radii. A radius of R means each
* pressure sample is averaged with R neighbours on each side, weighted
* linearly so the centre sample has weight R+1 and the outermost weight 1.
* Larger values produce a smoother, more uniform stroke width.
*/
const PRESSURE_SMOOTHING_RADIUS = 6;
/**
* Draws a single stroke segment primitive for the triplet (pPrev, pCur, pNext).
*
* The primitive is a closed quadrilateral with curved top and bottom edges:
* A = midpoint(pPrev, pCur) left junction, shared with the previous primitive
* B = midpoint(pCur, pNext) right junction, shared with the next primitive
* M'1/M'2 at A: ±rA perpendicular to the pPrevpCur direction
* M1/M2 at pCur: ±rCur along the bisector normal of the two edge directions
* M''1/M''2 at B: ±rB perpendicular to the pCurpNext direction
*
* Shape boundary (clockwise):
* M'1 →[quadratic Bezier through M1]→ M''1 →[line]→ M''2
* [quadratic Bezier through M2] M'2 →[line]→ M'1
*
* Adjacent primitives share their junction points so the stroke outline is
* geometrically continuous with no gaps or overlaps.
*/
const drawStrokeSegment = (
context: CanvasRenderingContext2D,
pPrevX: number,
pPrevY: number,
rPrev: number,
pCurX: number,
pCurY: number,
rCur: number,
pNextX: number,
pNextY: number,
rNext: number,
) => {
// A = midpoint(pPrev, pCur), B = midpoint(pCur, pNext)
const ax = (pPrevX + pCurX) * 0.5;
const ay = (pPrevY + pCurY) * 0.5;
const rA = (rPrev + rCur) * 0.5;
const bx = (pCurX + pNextX) * 0.5;
const by = (pCurY + pNextY) * 0.5;
const rB = (rCur + rNext) * 0.5;
// Perpendicular unit vector at A (normal to pPrev→pCur)
const daX = pCurX - pPrevX;
const daY = pCurY - pPrevY;
const daLenInv = 1 / (Math.sqrt(daX * daX + daY * daY) || 1e-10);
const nAX = -daY * daLenInv;
const nAY = daX * daLenInv;
// Perpendicular unit vector at B (normal to pCur→pNext)
const dbX = pNextX - pCurX;
const dbY = pNextY - pCurY;
const dbLenInv = 1 / (Math.sqrt(dbX * dbX + dbY * dbY) || 1e-10);
const nBX = -dbY * dbLenInv;
const nBY = dbX * dbLenInv;
// Bisector normal at pCur: normalised average of nA and nB
const bisRawX = nAX + nBX;
const bisRawY = nAY + nBY;
const bisLen = Math.sqrt(bisRawX * bisRawX + bisRawY * bisRawY);
const bisNX = bisLen > 1e-10 ? bisRawX / bisLen : nAX;
const bisNY = bisLen > 1e-10 ? bisRawY / bisLen : nAY;
// M'1, M'2 at A
const mp1x = ax + nAX * rA;
const mp1y = ay + nAY * rA;
const mp2x = ax - nAX * rA;
const mp2y = ay - nAY * rA;
// M1, M2 at pCur — used directly as the quadratic Bézier control points.
// The junction points (M'1, M''1, etc.) are midpoints between consecutive
// control points, which is the classic midpoint quadratic B-spline scheme.
// This guarantees C1 continuity: the shared junction is always the midpoint
// of the two flanking CPs, so the tangent is continuous across segments.
const m1x = pCurX + bisNX * rCur;
const m1y = pCurY + bisNY * rCur;
const m2x = pCurX - bisNX * rCur;
const m2y = pCurY - bisNY * rCur;
// M''1, M''2 at B
const mpp1x = bx + nBX * rB;
const mpp1y = by + nBY * rB;
const mpp2x = bx - nBX * rB;
const mpp2y = by - nBY * rB;
context.beginPath();
context.moveTo(mp1x, mp1y);
// Top edge: M'1 → M''1, control point = M1 (bisector offset at pCur)
context.quadraticCurveTo(m1x, m1y, mpp1x, mpp1y);
// Right cap: M''1 → M''2
context.lineTo(mpp2x, mpp2y);
// Bottom edge: M''2 → M'2, control point = M2
context.quadraticCurveTo(m2x, m2y, mp2x, mp2y);
// Left cap: M'2 → M'1
context.closePath();
context.fill();
// Filled circles at the junction midpoints seal any sub-pixel anti-aliasing
// gap where adjacent segment fills share a boundary edge.
context.beginPath();
context.arc(ax, ay, rA, 0, Math.PI * 2);
context.fill();
context.beginPath();
context.arc(bx, by, rB, 0, Math.PI * 2);
context.fill();
};
/**
* Draws freedraw points as pressure-aware curved stroke segment primitives.
* For each consecutive triplet of points (i-1, i, i+1) a curved quadrilateral
* is drawn whose side edges sit at the midpoints of the consecutive point pairs
* and whose top/bottom edges are quadratic Bezier curves passing through the
* stroke-width offset at the centre point. Adjacent primitives share their
* side-edge positions, so the rendered outline is continuous with no gaps.
*
* @param fromIndex Draw segments starting from this point index (inclusive).
* Pass 0 to draw from the beginning.
* @param upToIndex Draw segments only up to (but not including) this point
* index. Omit or pass `undefined` to draw all remaining
* points. Used by the incremental canvas to stop short of
* the last segment so the committed canvas only contains
* segments whose geometry is fully determined by immutable
* points.
*/
export const drawFreeDrawSegments = (
element: ExcalidrawFreeDrawElement,
context: CanvasRenderingContext2D,
renderConfig: StaticCanvasRenderConfig,
fromIndex: number,
upToIndex?: number,
scale = 1,
) => {
const { points, pressures } = element;
const N = points.length;
const strokeColor =
renderConfig.theme === THEME.DARK
? applyDarkModeFilter(element.strokeColor)
: element.strokeColor;
context.fillStyle = applyDarkModeFilter(
strokeColor,
renderConfig.theme === THEME.DARK,
);
const baseRadius = (element.strokeWidth * 1.25) / 2;
// Causal (one-sided) triangular-kernel weighted average of past pressure
// samples. Only looks backward [i-R .. i], so a newly-arrived point never
// retroactively changes the smoothed pressure of any previously rendered
// segment. This ensures live and final renders are identical at all points.
// When simulatePressure is true, constant pressure is used for all points.
const getSmoothedPressure = (i: number): number => {
if (element.simulatePressure || pressures.length === 0) {
return DEFAULT_FREEDRAW_PRESSURE;
}
let sum = 0;
let totalWeight = 0;
for (let k = -PRESSURE_SMOOTHING_RADIUS; k <= 0; k++) {
const idx = i + k;
if (idx < 0) {
continue;
}
const p =
idx < pressures.length ? pressures[idx] : DEFAULT_FREEDRAW_PRESSURE;
const w = PRESSURE_SMOOTHING_RADIUS + 1 + k; // 1 at i-R, R+1 at i
sum += p * w;
totalWeight += w;
}
return totalWeight > 0 ? sum / totalWeight : DEFAULT_FREEDRAW_PRESSURE;
};
if (
fromIndex === 0 &&
N === 1 &&
(upToIndex === undefined || upToIndex >= 1)
) {
// Single-point stroke -> filled circle (dot)
const r = baseRadius * getSmoothedPressure(0) * 2;
context.beginPath();
context.arc(points[0][0], points[0][1], r, 0, Math.PI * 2);
context.fill();
return;
}
const end = upToIndex !== undefined ? Math.min(upToIndex, N) : N;
const start = Math.max(fromIndex, 1);
for (let i = start; i < end; i++) {
const p0 = points[i - 1];
const p1 = points[i];
const r0 = baseRadius * getSmoothedPressure(i - 1) * 2;
const r1 = baseRadius * getSmoothedPressure(i) * 2;
// Triplet: need i+1; if at the last point, mirror i-1 around i (degenerate tip).
let p2x: number;
let p2y: number;
let r2: number;
if (i < N - 1) {
p2x = points[i + 1][0];
p2y = points[i + 1][1];
r2 = baseRadius * getSmoothedPressure(i + 1) * 2;
} else {
p2x = 2 * p1[0] - p0[0];
p2y = 2 * p1[1] - p0[1];
r2 = r0;
}
drawStrokeSegment(
context,
p0[0],
p0[1],
r0,
p1[0],
p1[1],
r1,
p2x,
p2y,
r2,
);
}
};
// ─── Incremental freedraw canvas cache ───────────────────────────────────────
// A separate WeakMap that survives ShapeCache.delete() calls so that the raster
// accumulates new capsule segments without full regeneration on every added
// point.
// screen pixels - minimum extra lookahead space on each side
// (divided by scale at use)
const FREEDRAW_CANVAS_OVERSHOOT_MIN = 200;
// allocate current_dimension * factor extra on each side
const FREEDRAW_CANVAS_OVERSHOOT_FACTOR = 0.5;
interface FreeDrawIncrementalCanvas {
/**
* Accumulation canvas - contains all segments whose Catmull-Rom tangents are
* fully finalised (right-hand neighbour is known). With N points the last
* finalised segment ends at index `committedPointCount - 1`, meaning segment
* `[committedPointCount-2 -> committedPointCount-1]` has been drawn with the
* correct tangent at `committedPointCount-1` (since point
* `committedPointCount` existed when it was drawn). Never cleared; only
* appended to (or copied when bounds grow).
*/
committedCanvas: HTMLCanvasElement;
/**
* Tip canvas - same pixel dimensions and scene origin as `committedCanvas`.
* Cleared and redrawn every frame to contain only the last segment
* `[committedPointCount-1 -> N-1]` whose tangent at `N-1` is still
* provisional (no right-hand neighbour yet). Composited on top of
* `committedCanvas` at display time.
*/
tipCanvas: HTMLCanvasElement;
/**
* Number of points that have been permanently drawn on `committedCanvas`.
* The committed canvas contains segments through point index
* `committedPointCount - 1` with final tangents. Always lags the current
* point count by 1 (the tip holds the last unfinalisable segment).
*/
committedPointCount: number;
canvasOriginSceneX: number;
canvasOriginSceneY: number;
canvasAllocX1: number;
canvasAllocY1: number;
canvasAllocX2: number;
canvasAllocY2: number;
scale: number;
theme: AppState["theme"];
}
const freedrawIncrementalCache = new WeakMap<
ExcalidrawFreeDrawElement,
FreeDrawIncrementalCanvas
>();
export const getFreedrawCanvasPadding = (element: ExcalidrawFreeDrawElement) =>
element.strokeWidth * 12;
/**
* Generates or incrementally updates the two-canvas (committed + tip) raster
* for a freedraw element being actively drawn.
*
* ## Two-canvas split
*
* A Catmull-Rom tangent at point `i` depends on `points[i+1]`. Until
* `points[i+1]` arrives, the tangent at `i` uses a mirrored fallback and is
* therefore provisional. The segment ending at the current tip `[N-2 -> N-1]`
* is the only one with a provisional tangent.
*
* - **`committedCanvas`** - contains all segments whose tangents are final.
* With N points: segments `[0->1, ..., N-3->N-2]` (`committedPointCount =
* N-1`). This canvas is append-only; its pixels are never invalidated.
* When a new point `N` arrives, the segment `[N-2 -> N-1]` is now
* finalised (tangent at `N-1` uses `N` as the right-hand neighbour) and is
* drawn onto the committed canvas. `committedPointCount` advances to `N`.
*
* - **`tipCanvas`** - cleared and redrawn every frame to contain only the
* last provisional segment `[committedPointCount-1 -> N-1]`. Composited on
* top of `committedCanvas` at display time.
*/
export const generateOrUpdateFreeDrawIncrementalCanvas = (
element: ExcalidrawFreeDrawElement,
elementsMap: NonDeletedSceneElementsMap,
zoom: Zoom,
renderConfig: StaticCanvasRenderConfig,
appState: StaticCanvasAppState | InteractiveCanvasAppState,
): ExcalidrawElementWithCanvas | null => {
const scale = zoom.value;
const dpr = window.devicePixelRatio;
const padding = getFreedrawCanvasPadding(element);
const [x1, y1, x2, y2] = getElementAbsoluteCoords(element, elementsMap);
const containingFrameOpacity =
getContainingFrame(element, elementsMap)?.opacity || 100;
const N = element.points.length;
const prevInc = freedrawIncrementalCache.get(element);
const boundsExceeded =
prevInc !== undefined &&
(x1 < prevInc.canvasAllocX1 ||
y1 < prevInc.canvasAllocY1 ||
x2 > prevInc.canvasAllocX2 ||
y2 > prevInc.canvasAllocY2);
const needsAlloc =
prevInc === undefined ||
boundsExceeded ||
prevInc.scale !== scale ||
prevInc.theme !== appState.theme;
// ── Canvas allocation / reallocation ──────────────────────────────────────
let committedCanvas: HTMLCanvasElement;
let tipCanvas: HTMLCanvasElement;
let canvasOriginSceneX: number;
let canvasOriginSceneY: number;
let canvasScale: number;
// How many points to start the committed-canvas update from. On a full
// regen this is 0; on a bounds-exceeded realloc it is the existing committed
// count so we only append the new segments.
let committedFromIndex: number;
if (needsAlloc) {
// Over-allocate proportionally to the current bounding box so fast large
// strokes trigger far fewer reallocations.
const overshootX = Math.max(
FREEDRAW_CANVAS_OVERSHOOT_MIN / scale,
(x2 - x1) * FREEDRAW_CANVAS_OVERSHOOT_FACTOR,
);
const overshootY = Math.max(
FREEDRAW_CANVAS_OVERSHOOT_MIN / scale,
(y2 - y1) * FREEDRAW_CANVAS_OVERSHOOT_FACTOR,
);
const allocX1 = x1 - overshootX;
const allocY1 = y1 - overshootY;
const allocX2 = x2 + overshootX;
const allocY2 = y2 + overshootY;
canvasOriginSceneX = allocX1 - padding / dpr;
canvasOriginSceneY = allocY1 - padding / dpr;
const rawW = (allocX2 - allocX1) * dpr + padding * 2;
const rawH = (allocY2 - allocY1) * dpr + padding * 2;
// Respect browser canvas size limits.
const AREA_LIMIT = 16777216;
const WIDTH_HEIGHT_LIMIT = 32767;
canvasScale = scale;
if (
rawW * canvasScale > WIDTH_HEIGHT_LIMIT ||
rawH * canvasScale > WIDTH_HEIGHT_LIMIT
) {
canvasScale = Math.min(
WIDTH_HEIGHT_LIMIT / rawW,
WIDTH_HEIGHT_LIMIT / rawH,
);
}
if (rawW * rawH * canvasScale * canvasScale > AREA_LIMIT) {
canvasScale = Math.sqrt(AREA_LIMIT / (rawW * rawH));
}
const canvasWidth = Math.floor(rawW * canvasScale);
const canvasHeight = Math.floor(rawH * canvasScale);
if (!canvasWidth || !canvasHeight) {
return null;
}
committedCanvas = document.createElement("canvas");
committedCanvas.width = canvasWidth;
committedCanvas.height = canvasHeight;
tipCanvas = document.createElement("canvas");
tipCanvas.width = canvasWidth;
tipCanvas.height = canvasHeight;
if (
prevInc !== undefined &&
boundsExceeded &&
prevInc.scale === canvasScale &&
prevInc.theme === appState.theme
) {
// Bounds grew: copy committed raster to new canvas at the correct offset
// and keep accumulating. Tip will be redrawn below.
const copyX =
(prevInc.canvasOriginSceneX - canvasOriginSceneX) * dpr * canvasScale;
const copyY =
(prevInc.canvasOriginSceneY - canvasOriginSceneY) * dpr * canvasScale;
committedCanvas
.getContext("2d")!
.drawImage(prevInc.committedCanvas, copyX, copyY);
committedFromIndex = prevInc.committedPointCount;
} else {
// Full regeneration: zoom/theme change or first frame.
committedFromIndex = 0;
}
freedrawIncrementalCache.set(element, {
committedCanvas,
tipCanvas,
committedPointCount: committedFromIndex,
canvasOriginSceneX,
canvasOriginSceneY,
canvasAllocX1: allocX1,
canvasAllocY1: allocY1,
canvasAllocX2: allocX2,
canvasAllocY2: allocY2,
scale: canvasScale,
theme: appState.theme,
});
} else {
committedCanvas = prevInc.committedCanvas;
tipCanvas = prevInc.tipCanvas;
canvasOriginSceneX = prevInc.canvasOriginSceneX;
canvasOriginSceneY = prevInc.canvasOriginSceneY;
canvasScale = prevInc.scale;
committedFromIndex = prevInc.committedPointCount;
}
const inc = freedrawIncrementalCache.get(element)!;
// ── Helper: draw onto a canvas with the element's scene->pixel transform ──
const withElementContext = (
target: HTMLCanvasElement,
fn: (ctx: CanvasRenderingContext2D) => void,
) => {
const ctx = target.getContext("2d")!;
const offsetX = (element.x - canvasOriginSceneX) * dpr * canvasScale;
const offsetY = (element.y - canvasOriginSceneY) * dpr * canvasScale;
ctx.save();
ctx.translate(offsetX, offsetY);
ctx.scale(dpr * canvasScale, dpr * canvasScale);
fn(ctx);
ctx.restore();
};
// ── Update committed canvas ───────────────────────────────────────────────
// With N points the last finalisable segment ends at N-2 (needs N-1 as
// right-hand neighbour for the tangent at N-2, and N-1 is always present).
// We draw from `committedFromIndex` up to (but not including) point N-1,
// so the committed canvas contains segments [0->1, ..., N-3->N-2].
const newCommittedCount = Math.max(1, N - 1);
if (committedFromIndex < newCommittedCount) {
withElementContext(committedCanvas, (ctx) => {
drawFreeDrawSegments(
element,
ctx,
renderConfig,
committedFromIndex,
newCommittedCount, // upToIndex - stop before the last provisional segment
canvasScale,
);
});
inc.committedPointCount = newCommittedCount;
}
// ── Redraw tip canvas ─────────────────────────────────────────────────────
// Always cleared and redrawn: contains the single provisional segment
// [committedPointCount-1 -> N-1] with a predicted-point ghost if available.
withElementContext(tipCanvas, (ctx) => {
ctx.clearRect(
-(element.x - canvasOriginSceneX),
-(element.y - canvasOriginSceneY),
tipCanvas.width / (dpr * canvasScale),
tipCanvas.height / (dpr * canvasScale),
);
drawFreeDrawSegments(
element,
ctx,
renderConfig,
inc.committedPointCount,
undefined, // draw to natural end (the tip segment)
canvasScale,
);
});
return {
element,
canvas: committedCanvas,
tipCanvas,
theme: appState.theme,
scale: canvasScale,
angle: element.angle,
zoomValue: zoom.value,
canvasOffsetX: 0,
canvasOffsetY: 0,
boundTextElementVersion: null,
imageCrop: null,
containingFrameOpacity,
boundTextCanvas: document.createElement("canvas"),
canvasOriginSceneX: inc.canvasOriginSceneX,
canvasOriginSceneY: inc.canvasOriginSceneY,
};
};
/**
* Removes the incremental freedraw canvas for the given element.
* Call this when a freedraw stroke is finalised so the next render
* produces a fresh tight-bounds canvas instead of the over-allocated one.
*/
export const invalidateFreeDrawIncrementalCanvas = (
element: ExcalidrawFreeDrawElement,
) => {
freedrawIncrementalCache.delete(element);
};
+317 -46
View File
@@ -1,16 +1,34 @@
import { arrayToMap, isShallowEqual } from "@excalidraw/common";
import { arrayToMap, isShallowEqual, type Bounds } from "@excalidraw/common";
import {
lineSegment,
pointFrom,
pointRotateRads,
type GlobalPoint,
} from "@excalidraw/math";
import type {
AppState,
BoxSelectionMode,
InteractiveCanvasAppState,
} from "@excalidraw/excalidraw/types";
import { getElementAbsoluteCoords, getElementBounds } from "./bounds";
import {
boundsContainBounds,
doBoundsIntersect,
elementCenterPoint,
getElementAbsoluteCoords,
getElementBounds,
pointInsideBounds,
} from "./bounds";
import { intersectElementWithLineSegment } from "./collision";
import { isElementInViewport } from "./sizeHelpers";
import {
isArrowElement,
isBoundToContainer,
isFrameLikeElement,
isFreeDrawElement,
isLinearElement,
isTextElement,
} from "./typeChecks";
import {
elementOverlapsWithFrame,
@@ -20,14 +38,33 @@ import {
import { LinearElementEditor } from "./linearElementEditor";
import { selectGroupsForSelectedElements } from "./groups";
import { getBoundTextElement } from "./textElement";
import type {
ElementsMap,
ElementsMapOrArray,
ExcalidrawElement,
ExcalidrawFrameLikeElement,
NonDeleted,
NonDeletedExcalidrawElement,
} 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.
* Given an array of selected elements, if there are frames and their containing elements
@@ -47,68 +84,286 @@ export const excludeElementsInFramesFromSelection = <
}
});
return selectedElements.filter((element) => {
if (element.frameId && framesInSelection.has(element.frameId)) {
return false;
}
return true;
});
return excludeElementsFromFrames(selectedElements, framesInSelection);
};
export const getElementsWithinSelection = (
elements: readonly NonDeletedExcalidrawElement[],
selection: NonDeletedExcalidrawElement,
elementsMap: ElementsMap,
// TODO remove (this flag is effectively unused AFAIK)
excludeElementsInFrames: boolean = true,
) => {
const [selectionX1, selectionY1, selectionX2, selectionY2] =
boxSelectionMode: BoxSelectionMode = "contain",
): NonDeletedExcalidrawElement[] => {
const [selectionStartX, selectionStartY, selectionEndX, selectionEndY] =
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;
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),
),
];
let elementsInSelection = elements.filter((element) => {
let [elementX1, elementY1, elementX2, elementY2] = getElementBounds(
element,
elementsMap,
);
const framesInSelection = excludeElementsInFrames
? new Set<NonDeletedExcalidrawElement["id"]>()
: null;
const groups: Record<string, NonDeletedExcalidrawElement[]> = {};
const elementsInSelection: Set<NonDeletedExcalidrawElement> = new Set();
const containingFrame = getContainingFrame(element, elementsMap);
if (containingFrame) {
const [fx1, fy1, fx2, fy2] = getElementBounds(
containingFrame,
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,
);
elementX1 = Math.max(fx1, elementX1);
elementY1 = Math.max(fy1, elementY1);
elementX2 = Math.min(fx2, elementX2);
elementY2 = Math.min(fy2, elementY2);
labelAABB = [
x,
y,
x + boundTextElement.width,
y + boundTextElement.height,
] as Bounds;
}
return (
element.locked === false &&
element.type !== "selection" &&
!isBoundToContainer(element) &&
selectionX1 <= elementX1 &&
selectionY1 <= elementY1 &&
selectionX2 >= elementX2 &&
selectionY2 >= elementY2
);
});
// 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;
elementsInSelection = excludeElementsInFrames
? excludeElementsInFramesFromSelection(elementsInSelection)
: elementsInSelection;
elementsInSelection = elementsInSelection.filter((element) => {
const containingFrame = getContainingFrame(element, elementsMap);
if (containingFrame) {
return elementOverlapsWithFrame(element, containingFrame, elementsMap);
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;
}
return true;
});
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;
return elementsInSelection;
// ============== 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 (
boxSelectionMode === "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 (
boxSelectionMode === "overlap" &&
doBoundsIntersect(selectionBounds, elementAABB)
) {
let hasIntersection = false;
// Preliminary check potential intersection imprecision
if (isLinearElement(element) || isFreeDrawElement(element)) {
const center = elementCenterPoint(element, elementsMap);
hasIntersection = element.points.some((point) => {
const rotatedPoint = pointRotateRads(
pointFrom<GlobalPoint>(element.x + point[0], element.y + point[1]),
center,
element.angle,
);
return pointInsideBounds(rotatedPoint, selectionBounds);
});
} else {
const nonRotatedElementBounds = getElementBounds(
element,
elementsMap,
true,
);
const center = elementCenterPoint(element, elementsMap);
hasIntersection = [
pointRotateRads(
pointFrom<GlobalPoint>(
(nonRotatedElementBounds[0] + nonRotatedElementBounds[2]) / 2,
nonRotatedElementBounds[1],
),
center,
element.angle,
),
pointRotateRads(
pointFrom<GlobalPoint>(
nonRotatedElementBounds[2],
(nonRotatedElementBounds[1] + nonRotatedElementBounds[3]) / 2,
),
center,
element.angle,
),
pointRotateRads(
pointFrom<GlobalPoint>(
(nonRotatedElementBounds[0] + nonRotatedElementBounds[2]) / 2,
nonRotatedElementBounds[3],
),
center,
element.angle,
),
pointRotateRads(
pointFrom<GlobalPoint>(
nonRotatedElementBounds[0],
(nonRotatedElementBounds[1] + nonRotatedElementBounds[3]) / 2,
),
center,
element.angle,
),
].some((point) => {
return pointInsideBounds(
pointRotateRads(point, center, element.angle),
selectionBounds,
);
});
}
if (!hasIntersection) {
hasIntersection = selectionEdges.some(
(selectionEdge) =>
intersectElementWithLineSegment(
element,
elementsMap,
selectionEdge,
strokeWidth / 2,
true, // Stop at first hit for better performance
).length > 0,
);
}
if (hasIntersection) {
if (framesInSelection && isFrameLikeElement(element)) {
framesInSelection.add(element.id);
}
elementsInSelection.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 (boxSelectionMode === "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 (boxSelectionMode === "contain") {
elementsInSelection.forEach((element) => {
// note: currently we only support top-level group handling since
// we don't support box selecting while editing the group/subgroup
// see https://github.com/excalidraw/excalidraw/pull/11234#issuecomment-4387654451
const groupId = element.groupIds.at(-1);
const group = groupId ? groups[groupId] : null;
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 getVisibleAndNonSelectedElements = (
@@ -288,3 +543,19 @@ 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;
};
+581 -173
View File
@@ -1,5 +1,4 @@
import { simplify } from "points-on-curve";
import { getStroke } from "perfect-freehand";
import {
type GeometricShape,
@@ -28,6 +27,12 @@ import {
import { RoughGenerator } from "roughjs/bin/generator";
import type {
ElementShape,
ElementShapes,
SVGPathString,
} from "@excalidraw/excalidraw/scene/types";
import type { GlobalPoint } from "@excalidraw/math";
import type { Mutable } from "@excalidraw/common/utility-types";
@@ -36,11 +41,6 @@ import type {
AppState,
EmbedsValidationStatus,
} from "@excalidraw/excalidraw/types";
import type {
ElementShape,
ElementShapes,
SVGPathString,
} from "@excalidraw/excalidraw/scene/types";
import { elementWithCanvasCache } from "./renderElement";
@@ -57,8 +57,8 @@ import { headingForPointIsHorizontal } from "./heading";
import { canChangeRoundness } from "./comparisons";
import {
elementCenterPoint,
getArrowheadPoints,
getCenterForBounds,
getDiamondPoints,
getElementAbsoluteCoords,
} from "./bounds";
@@ -69,10 +69,10 @@ import type {
NonDeletedExcalidrawElement,
ExcalidrawSelectionElement,
ExcalidrawLinearElement,
Arrowhead,
ExcalidrawFreeDrawElement,
ElementsMap,
ExcalidrawLineElement,
Arrowhead,
} from "./types";
import type { Drawable, Options } from "roughjs/bin/core";
@@ -218,9 +218,7 @@ export const generateRoughOptions = (
fillWeight: element.strokeWidth / 2,
hachureGap: element.strokeWidth * 4,
roughness: adjustRoughness(element),
stroke: isDarkMode
? applyDarkModeFilter(element.strokeColor)
: element.strokeColor,
stroke: applyDarkModeFilter(element.strokeColor, isDarkMode),
preserveVertices:
continuousPath || element.roughness < ROUGHNESS.cartoonist,
};
@@ -234,9 +232,7 @@ export const generateRoughOptions = (
options.fillStyle = element.fillStyle;
options.fill = isTransparent(element.backgroundColor)
? undefined
: isDarkMode
? applyDarkModeFilter(element.backgroundColor)
: element.backgroundColor;
: applyDarkModeFilter(element.backgroundColor, isDarkMode);
if (element.type === "ellipse") {
options.curveFitting = 1;
}
@@ -249,9 +245,7 @@ export const generateRoughOptions = (
options.fill =
element.backgroundColor === "transparent"
? undefined
: isDarkMode
? applyDarkModeFilter(element.backgroundColor)
: element.backgroundColor;
: applyDarkModeFilter(element.backgroundColor, isDarkMode);
}
return options;
}
@@ -296,6 +290,82 @@ const modifyIframeLikeForRoughOptions = (
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 = (
element: ExcalidrawLinearElement,
shape: Drawable[],
@@ -306,63 +376,53 @@ const getArrowheadShapes = (
canvasBackgroundColor: string,
isDarkMode: boolean,
) => {
const arrowheadPoints = getArrowheadPoints(
element,
shape,
position,
arrowhead,
);
if (arrowheadPoints === null) {
if (arrowhead === null) {
return [];
}
const generateCrowfootOne = (
arrowheadPoints: number[] | null,
options: Options,
) => {
if (arrowheadPoints === null) {
return [];
}
const [, , x3, y3, x4, y4] = arrowheadPoints;
return [generator.line(x3, y3, x4, y4, options)];
};
const strokeColor = isDarkMode
? applyDarkModeFilter(element.strokeColor)
: element.strokeColor;
const strokeColor = applyDarkModeFilter(element.strokeColor, isDarkMode);
const backgroundFillColor = applyDarkModeFilter(
canvasBackgroundColor,
isDarkMode,
);
const cardinalityOneOrManyOffset = -0.25;
const cardinalityZeroCircleScale = 0.8;
switch (arrowhead) {
case "dot":
case "circle":
case "circle_outline": {
const [x, y, diameter] = arrowheadPoints;
// always use solid stroke for arrowhead
delete options.strokeLineDash;
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),
}),
];
return generateArrowheadOutlineCircle(
generator,
options,
strokeColor,
getArrowheadPoints(element, shape, position, arrowhead),
arrowhead === "circle_outline" ? backgroundFillColor : strokeColor,
);
}
case "triangle":
case "triangle_outline": {
const arrowheadPoints = getArrowheadPoints(
element,
shape,
position,
arrowhead,
);
if (arrowheadPoints === null) {
return [];
}
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
delete options.strokeLineDash;
delete triangleOptions.strokeLineDash;
return [
generator.polygon(
@@ -372,24 +432,34 @@ const getArrowheadShapes = (
[x3, y3],
[x, y],
],
{
...options,
fill:
arrowhead === "triangle_outline"
? canvasBackgroundColor
: strokeColor,
fillStyle: "solid",
roughness: Math.min(1, options.roughness || 0),
},
triangleOptions,
),
];
}
case "diamond":
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 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
delete options.strokeLineDash;
delete diamondOptions.strokeLineDash;
return [
generator.polygon(
@@ -400,53 +470,117 @@ const getArrowheadShapes = (
[x4, y4],
[x, y],
],
{
...options,
fill:
arrowhead === "diamond_outline"
? canvasBackgroundColor
: strokeColor,
fillStyle: "solid",
roughness: Math.min(1, options.roughness || 0),
},
diamondOptions,
),
];
}
case "cardinality_one":
return generateArrowheadCardinalityOne(
generator,
getArrowheadPoints(element, shape, position, arrowhead),
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 "arrow":
case "crowfoot_many":
case "crowfoot_one_or_many":
default: {
const [x2, y2, x3, y3, x4, y4] = arrowheadPoints;
if (element.strokeStyle === "dotted") {
// 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,
)
: []),
];
return generateArrowheadLinesToTip(
generator,
getArrowheadPoints(element, shape, position, arrowhead),
getArrowheadLineOptions(element, options),
);
}
}
};
export const generateLinearCollisionShape = (
element: ExcalidrawLinearElement | ExcalidrawFreeDrawElement,
) => {
elementsMap: ElementsMap,
): {
op: string;
data: number[];
}[] => {
const generator = new RoughGenerator();
const options: Options = {
seed: element.seed,
@@ -455,20 +589,7 @@ export const generateLinearCollisionShape = (
roughness: 0,
preserveVertices: true,
};
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],
),
);
const center = elementCenterPoint(element, elementsMap);
switch (element.type) {
case "line":
@@ -853,7 +974,7 @@ const _generateElementShape = (
}
// (2) stroke
shapes.push(getFreeDrawSvgPath(element));
shapes.push(...getFreeDrawCapsulePaths(element));
return shapes;
}
@@ -1043,64 +1164,351 @@ export const toggleLinePolygonState = (
// freedraw shape helper
// -----------------------------------------------------------------------------
// NOTE not cached (-> for SVG export)
const getFreeDrawSvgPath = (element: ExcalidrawFreeDrawElement) => {
return getSvgPathFromStroke(
getFreedrawOutlinePoints(element),
) as SVGPathString;
};
const FREEDRAW_DEFAULT_PRESSURE = 0.5;
const FREEDRAW_BEZIER_SUBDIVIDE_TARGET_SPACING = 3;
const FREEDRAW_PRESSURE_SMOOTHING_RADIUS = 6;
export const getFreedrawOutlinePoints = (
element: ExcalidrawFreeDrawElement,
) => {
// If input points are empty (should they ever be?) return a dot
const inputPoints = element.simulatePressure
? element.points
: element.points.length
? element.points.map(([x, y], i) => [x, y, element.pressures[i]])
: [[0, 0, 0.5]];
// Round to 2 dp — sub-pixel accuracy at SVG 96 dpi
const r2 = (v: number) => Math.round(v * 100) / 100;
return getStroke(inputPoints as number[][], {
simulatePressure: element.simulatePressure,
size: element.strokeWidth * 4.25,
thinning: 0.6,
smoothing: 0.5,
streamline: 0.5,
easing: (t) => Math.sin((t * Math.PI) / 2), // https://easings.net/#easeOutSine
last: true,
}) as [number, number][];
};
/**
* SVG path `d` string for a single tapered capsule. Uses clockwise arcs
* (sweep=1) so the geometry matches the canvas 2D
* `arc(..., anticlockwise=false)` calls.
*/
const freedrawTaperedCapsulePath = (
x0: number,
y0: number,
r0: number,
x1: number,
y1: number,
r1: number,
): string => {
const dx = x1 - x0;
const dy = y1 - y0;
const len = Math.sqrt(dx * dx + dy * dy);
const r = Math.max(r0, r1);
const med = (A: number[], B: number[]) => {
return [(A[0] + B[0]) / 2, (A[1] + B[1]) / 2];
};
// Trim SVG path data so number are each two decimal points. This
// improves SVG exports, and prevents rendering errors on points
// with long decimals.
const TO_FIXED_PRECISION = /(\s?[A-Z]?,?-?[0-9]*\.[0-9]{0,2})(([0-9]|e|-)*)/g;
const getSvgPathFromStroke = (points: number[][]): string => {
if (!points.length) {
return "";
if (len < r / 2) {
// Degenerate — full circle at midpoint via two clockwise 180° arcs.
const cx = r2((x0 + x1) / 2);
const cy = r2((y0 + y1) / 2);
const rr = r2(r);
return (
`M ${(cx - rr).toFixed(2)} ${cy.toFixed(2)} ` +
`A ${rr} ${rr} 0 1 1 ${(cx + rr).toFixed(2)} ${cy.toFixed(2)} ` +
`A ${rr} ${rr} 0 1 1 ${(cx - rr).toFixed(2)} ${cy.toFixed(2)} Z`
);
}
const max = points.length - 1;
const px = -dy / len; // perpendicular unit x
const py = dx / len; // perpendicular unit y
return points
.reduce(
(acc, point, i, arr) => {
if (i === max) {
acc.push(point, med(point, arr[0]), "L", arr[0], "Z");
} else {
acc.push(point, med(point, arr[i + 1]));
}
return acc;
},
["M", points[0], "Q"],
)
.join(" ")
.replace(TO_FIXED_PRECISION, "$1");
// P0 +/- perp·r0 (start / back cap tangent points)
const b0x = r2(x0 + px * r0);
const b0y = r2(y0 + py * r0);
const b1x = r2(x0 - px * r0);
const b1y = r2(y0 - py * r0);
// P1 +/- perp·r1 (end / front cap tangent points)
const f0x = r2(x1 - px * r1);
const f0y = r2(y1 - py * r1);
const f1x = r2(x1 + px * r1);
const f1y = r2(y1 + py * r1);
const rr0 = r2(r0);
const rr1 = r2(r1);
// Back cap: clockwise 180° arc from (b0) to (b1) around P0.
// Front cap: clockwise 180° arc from (f0) to (f1) around P1.
return (
`M ${b0x.toFixed(2)} ${b0y.toFixed(2)} ` +
`A ${rr0.toFixed(2)} ${rr0.toFixed(2)} 0 1 1 ${b1x.toFixed(
2,
)} ${b1y.toFixed(2)} ` +
`L ${f0x.toFixed(2)} ${f0y.toFixed(2)} ` +
`A ${rr1.toFixed(2)} ${rr1.toFixed(2)} 0 1 1 ${f1x.toFixed(
2,
)} ${f1y.toFixed(2)} Z`
);
};
/**
* Catmull-Rom tangent at points[i].Identical math to `getCatmullRomTangent`
* in renderElement.ts (predictedPoint is not needed for finalised strokes).
*/
const freedrawCatmullRomTangent = (
points: readonly (readonly [number, number])[],
i: number,
): [number, number] => {
const N = points.length;
const cur = points[i];
let next: readonly [number, number];
if (i < N - 1) {
next = points[i + 1];
} else {
const prev2 = i > 0 ? points[i - 1] : cur;
next = [2 * cur[0] - prev2[0], 2 * cur[1] - prev2[1]];
}
let tx: number;
let ty: number;
if (i === 0) {
tx = (next[0] - cur[0]) * 0.5;
ty = (next[1] - cur[1]) * 0.5;
} else {
const prev = points[i - 1];
tx = (next[0] - prev[0]) * 0.5;
ty = (next[1] - prev[1]) * 0.5;
}
// Chord-length clamping (PCHIP-style).
const magSq = tx * tx + ty * ty;
if (magSq > 0) {
const dNx = next[0] - cur[0];
const dNy = next[1] - cur[1];
const chordNext = Math.sqrt(dNx * dNx + dNy * dNy);
let chordPrev = chordNext;
if (i > 0) {
const prev = points[i - 1];
const dPx = cur[0] - prev[0];
const dPy = cur[1] - prev[1];
chordPrev = Math.sqrt(dPx * dPx + dPy * dPy);
}
const maxMag = 3 * Math.min(chordNext, chordPrev);
const mag = Math.sqrt(magSq);
if (mag > maxMag) {
const s = maxMag / mag;
tx *= s;
ty *= s;
}
}
return [tx, ty];
};
/**
* Triangular-kernel causal weighted pressure average (backward-only window).
* When `simulatePressure` is true or pressures array is empty, returns the
* default constant pressure so the geometry mirrors constant-pressure rendering.
*/
const getFreeDrawSmoothedPressure = (
element: ExcalidrawFreeDrawElement,
i: number,
): number => {
const { pressures } = element;
if (element.simulatePressure || pressures.length === 0) {
return FREEDRAW_DEFAULT_PRESSURE;
}
let sum = 0;
let totalWeight = 0;
for (let k = -FREEDRAW_PRESSURE_SMOOTHING_RADIUS; k <= 0; k++) {
const idx = i + k;
if (idx < 0) {
continue;
}
const p =
idx < pressures.length ? pressures[idx] : FREEDRAW_DEFAULT_PRESSURE;
const w = FREEDRAW_PRESSURE_SMOOTHING_RADIUS + 1 + k;
sum += p * w;
totalWeight += w;
}
return totalWeight > 0 ? sum / totalWeight : FREEDRAW_DEFAULT_PRESSURE;
};
/**
* Returns one SVG path `d` string per tapered-capsule sub-segment for a
* freedraw element, using the same Catmull-Rom Bezier subdivision and pressure
* smoothing as the canvas renderer.
*/
const getFreeDrawCapsulePaths = (
element: ExcalidrawFreeDrawElement,
): SVGPathString[] => {
const { points } = element;
const N = points.length;
const baseRadius = (element.strokeWidth * 1.25) / 2;
const getSmoothedPressure = (i: number): number =>
getFreeDrawSmoothedPressure(element, i);
const paths: SVGPathString[] = [];
if (N === 1) {
// Single-point stroke — filled circle.
const rr = r2(baseRadius * getSmoothedPressure(0) * 2);
const cx = r2(points[0][0]);
const cy = r2(points[0][1]);
paths.push(
`M ${(cx - rr).toFixed(2)} ${cy.toFixed(2)} A ${rr} ${rr} 0 1 1 ${(
cx + rr
).toFixed(2)} ${cy.toFixed(2)} A ${rr} ${rr} 0 1 1 ${(cx - rr).toFixed(
2,
)} ${cy.toFixed(2)} Z` as SVGPathString,
);
return paths;
}
for (let i = 1; i < N; i++) {
const p0 = points[i - 1];
const p1 = points[i];
const r0 = baseRadius * getSmoothedPressure(i - 1) * 2;
const r1 = baseRadius * getSmoothedPressure(i) * 2;
const t0 = freedrawCatmullRomTangent(points, i - 1);
const t1 = freedrawCatmullRomTangent(points, i);
// Bezier subdivision.
const segLen = Math.sqrt((p1[0] - p0[0]) ** 2 + (p1[1] - p0[1]) ** 2);
const nSubdiv = Math.max(
1,
Math.ceil(segLen / FREEDRAW_BEZIER_SUBDIVIDE_TARGET_SPACING),
);
const cp1x = p0[0] + t0[0] / 3;
const cp1y = p0[1] + t0[1] / 3;
const cp2x = p1[0] - t1[0] / 3;
const cp2y = p1[1] - t1[1] / 3;
let prevX = p0[0];
let prevY = p0[1];
let prevR = r0;
for (let k = 1; k <= nSubdiv; k++) {
const t = k / nSubdiv;
const mt = 1 - t;
const mt2 = mt * mt;
const t2 = t * t;
const mt3 = mt2 * mt;
const t3 = t2 * t;
const x =
mt3 * p0[0] + 3 * mt2 * t * cp1x + 3 * mt * t2 * cp2x + t3 * p1[0];
const y =
mt3 * p0[1] + 3 * mt2 * t * cp1y + 3 * mt * t2 * cp2y + t3 * p1[1];
const r = r0 + (r1 - r0) * t;
paths.push(
freedrawTaperedCapsulePath(
prevX,
prevY,
prevR,
x,
y,
r,
) as SVGPathString,
);
prevX = x;
prevY = y;
prevR = r;
}
}
return paths;
};
/**
* Generates an outline polygon for a freedraw element using the same
* Catmull-Rom Bezier subdivision and pressure smoothing as the canvas renderer.
* Returns `[x, y]` points in element-local coordinates that form a closed
* polygon around the stroke (left side + right side reversed), suitable for
* hit-testing and eraser intersection.
*/
export const getFreedrawOutlinePoints = (
element: ExcalidrawFreeDrawElement,
): [number, number][] => {
const { points } = element;
const N = points.length;
const baseRadius = (element.strokeWidth * 1.25) / 2;
if (N === 0) {
return [];
}
const radius0 = baseRadius * getFreeDrawSmoothedPressure(element, 0) * 2;
if (N === 1) {
// Single point case
const cx = points[0][0];
const cy = points[0][1];
const result: [number, number][] = [];
for (let i = 0; i < 8; i++) {
const angle = (i / 8) * Math.PI * 2;
result.push([
cx + Math.cos(angle) * radius0,
cy + Math.sin(angle) * radius0,
]);
}
return result;
}
const leftPoints: [number, number][] = [];
const rightPoints: [number, number][] = [];
for (let i = 1; i < N; i++) {
const p0 = points[i - 1];
const p1 = points[i];
const r0 = baseRadius * getFreeDrawSmoothedPressure(element, i - 1) * 2;
const r1 = baseRadius * getFreeDrawSmoothedPressure(element, i) * 2;
const t0 = freedrawCatmullRomTangent(points, i - 1);
const t1 = freedrawCatmullRomTangent(points, i);
const segLen = Math.sqrt((p1[0] - p0[0]) ** 2 + (p1[1] - p0[1]) ** 2);
const nSubdiv = Math.max(
1,
Math.ceil(segLen / FREEDRAW_BEZIER_SUBDIVIDE_TARGET_SPACING),
);
const cp1x = p0[0] + t0[0] / 3;
const cp1y = p0[1] + t0[1] / 3;
const cp2x = p1[0] - t1[0] / 3;
const cp2y = p1[1] - t1[1] / 3;
// Include the start point of the first segment.
const kStart = i === 1 ? 0 : 1;
for (let k = kStart; k <= nSubdiv; k++) {
const tParam = k / nSubdiv;
const mt = 1 - tParam;
const mt2 = mt * mt;
const t2 = tParam * tParam;
const mt3 = mt2 * mt;
const t3 = t2 * tParam;
const x =
mt3 * p0[0] + 3 * mt2 * tParam * cp1x + 3 * mt * t2 * cp2x + t3 * p1[0];
const y =
mt3 * p0[1] + 3 * mt2 * tParam * cp1y + 3 * mt * t2 * cp2y + t3 * p1[1];
// Bezier first derivative for the tangent direction.
const dtx =
3 *
(mt2 * (cp1x - p0[0]) +
2 * mt * tParam * (cp2x - cp1x) +
t2 * (p1[0] - cp2x));
const dty =
3 *
(mt2 * (cp1y - p0[1]) +
2 * mt * tParam * (cp2y - cp1y) +
t2 * (p1[1] - cp2y));
const len = Math.sqrt(dtx * dtx + dty * dty);
if (len === 0) {
continue;
}
// Perpendicular (left = +, right = ).
const px = -dty / len;
const py = dtx / len;
const r = r0 + (r1 - r0) * tParam;
leftPoints.push([x + px * r, y + py * r]);
rightPoints.push([x - px * r, y - py * r]);
}
}
// Closed polygon: left side (start -> end) + right side (end -> start).
return [...leftPoints, ...rightPoints.reverse()];
};
// -----------------------------------------------------------------------------
+63 -65
View File
@@ -1,59 +1,56 @@
import { arrayToMapWithIndex } from "@excalidraw/common";
import { arrayToMap } from "@excalidraw/common";
import type { ExcalidrawElement } from "./types";
const normalizeGroupElementOrder = (elements: readonly ExcalidrawElement[]) => {
const origElements: ExcalidrawElement[] = elements.slice();
const sortedElements = new Set<ExcalidrawElement>();
const orderInnerGroups = (
elements: readonly ExcalidrawElement[],
): ExcalidrawElement[] => {
const firstGroupSig = elements[0]?.groupIds?.join("");
const aGroup: ExcalidrawElement[] = [elements[0]];
const bGroup: ExcalidrawElement[] = [];
for (const element of elements.slice(1)) {
if (element.groupIds?.join("") === firstGroupSig) {
aGroup.push(element);
} else {
bGroup.push(element);
}
}
return bGroup.length ? [...aGroup, ...orderInnerGroups(bGroup)] : aGroup;
const defragmentGroups = (elements: readonly ExcalidrawElement[]) => {
const groupIdAtLevel = (element: ExcalidrawElement, level: number) => {
return element.groupIds[element.groupIds.length - level - 1];
};
const groupHandledElements = new Map<string, true>();
const orderLevel = (
levelElements: readonly ExcalidrawElement[],
level: number,
): ExcalidrawElement[] => {
const buckets = new Map<string, ExcalidrawElement[]>();
// Slots preserve first-occurrence order: a groupId reserves its slot
// the first time one of its members is seen; loose elements occupy
// their own slot. Groups are then expanded (and recursed into) in place.
const slots: (ExcalidrawElement | string)[] = [];
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);
for (const element of levelElements) {
const groupId = groupIdAtLevel(element, level);
if (groupId === undefined) {
slots.push(element);
continue;
}
} else {
sortedElements.add(element);
let bucket = buckets.get(groupId);
if (!bucket) {
bucket = [];
buckets.set(groupId, bucket);
slots.push(groupId);
}
bucket.push(element);
}
});
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
// last entry. We recurse from level 0 (outermost) inward.
const sortedElements = orderLevel(elements, 0);
// if there's a bug which resulted in losing some of the elements, return
// original instead as that's better than losing data
if (sortedElements.size !== elements.length) {
console.error("normalizeGroupElementOrder: lost some elements... bailing!");
if (sortedElements.length !== elements.length) {
console.error("defragmentGroups: lost some elements... bailing!");
return elements;
}
return [...sortedElements];
return sortedElements;
};
/**
@@ -68,39 +65,40 @@ const normalizeGroupElementOrder = (elements: readonly ExcalidrawElement[]) => {
const normalizeBoundElementsOrder = (
elements: readonly ExcalidrawElement[],
) => {
const elementsMap = arrayToMapWithIndex(elements);
const elementsMap = arrayToMap(elements);
const origElements: (ExcalidrawElement | null)[] = elements.slice();
const sortedElements = new Set<ExcalidrawElement>();
origElements.forEach((element, idx) => {
if (!element) {
return;
for (const element of elements) {
if (sortedElements.has(element)) {
continue;
}
if (element.boundElements?.length) {
sortedElements.add(element);
origElements[idx] = null;
element.boundElements.forEach((boundElement) => {
for (const boundElement of element.boundElements) {
const child = elementsMap.get(boundElement.id);
if (child && boundElement.type === "text") {
sortedElements.add(child[0]);
origElements[child[1]] = null;
sortedElements.add(child);
}
});
} 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
}
} else {
sortedElements.add(element);
origElements[idx] = null;
continue;
}
});
// 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
// original instead as that's better than losing data
@@ -117,5 +115,5 @@ const normalizeBoundElementsOrder = (
export const normalizeElementOrder = (
elements: readonly ExcalidrawElement[],
) => {
return normalizeBoundElementsOrder(normalizeGroupElementOrder(elements));
return normalizeBoundElementsOrder(defragmentGroups(elements));
};
+3 -1
View File
@@ -347,6 +347,7 @@ export const getContainerCenter = (
midSegmentMidpoint = LinearElementEditor.getSegmentMidPoint(
container,
index + 1,
elementsMap,
);
}
return { x: midSegmentMidpoint[0], y: midSegmentMidpoint[1] };
@@ -441,7 +442,8 @@ const VALID_CONTAINER_TYPES = new Set([
export const isValidTextContainer = (element: {
type: ExcalidrawElementType;
}) => VALID_CONTAINER_TYPES.has(element.type);
}): element is ExcalidrawTextContainer =>
VALID_CONTAINER_TYPES.has(element.type);
export const computeContainerDimensionForBoundText = (
dimension: number,
+207 -37
View File
@@ -4,6 +4,22 @@ import { charWidth, getLineWidth } from "./textMeasurements";
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 cachedLineBreakRegex: RegExp | undefined;
let cachedEmojiRegex: RegExp | undefined;
@@ -358,6 +374,10 @@ const Break = {
/**
* 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) => {
const breakLineRegex = getLineBreakRegex();
@@ -370,56 +390,120 @@ export const parseTokens = (line: string) => {
/**
* 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 = (
text: string,
font: FontString,
maxWidth: number,
): 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
// computation, we need to make sure we don't continue as we'll end up
// in an infinite loop
if (!Number.isFinite(maxWidth) || maxWidth < 0) {
return text;
return getHardLineBreaks(text);
}
const lines: Array<string> = [];
const originalLines = text.split("\n");
const lines: WrappedTextLine[] = [];
let offset = 0;
for (const originalLine of originalLines) {
const currentLineWidth = getLineWidth(originalLine, font);
for (const originalLine of text.split("\n")) {
const originalLineWidth = getLineWidth(originalLine, font);
if (currentLineWidth <= maxWidth) {
lines.push(originalLine);
continue;
if (originalLineWidth <= maxWidth) {
lines.push({
text: originalLine,
start: offset,
end: offset + originalLine.length,
});
} else {
lines.push(...wrapLine(originalLine, font, maxWidth, offset));
}
const wrappedLine = wrapLine(originalLine, font, maxWidth);
lines.push(...wrappedLine);
offset += originalLine.length + 1;
}
return lines.join("\n");
return lines;
};
/**
* Wraps the original line into the lines based on the given width.
* Wraps a single hard line into one or more visual lines.
*
* The line-local offsets are tracked in original-text code units so
* we can map the visual line back to the source.
*/
const wrapLine = (
line: string,
font: FontString,
maxWidth: number,
): string[] => {
const lines: Array<string> = [];
lineStart: number,
): WrappedTextLine[] => {
const lines: WrappedTextLine[] = [];
const tokens = parseTokens(line);
const tokenIterator = tokens[Symbol.iterator]();
let currentLine = "";
let currentLineStart = lineStart;
let currentLineEnd = lineStart;
let currentLineWidth = 0;
// Tracks the next token's code-unit position in the original source string.
let tokenOffset = lineStart;
let tokenIndex = 0;
let iterator = tokenIterator.next();
while (!iterator.done) {
const token = iterator.value;
while (tokenIndex < tokens.length) {
const token = tokens[tokenIndex];
const tokenStart = tokenOffset;
const tokenEnd = tokenStart + token.length;
const testLine = currentLine + token;
// cache single codepoint whitespace, CJK or emoji width calc. as kerning should not apply here
@@ -429,37 +513,59 @@ const wrapLine = (
// build up the current line, skipping length check for possibly trailing whitespaces
if (/\s/.test(token) || testLineWidth <= maxWidth) {
if (!currentLine) {
currentLineStart = tokenStart;
}
currentLine = testLine;
currentLineEnd = tokenEnd;
currentLineWidth = testLineWidth;
iterator = tokenIterator.next();
tokenOffset = tokenEnd;
tokenIndex++;
continue;
}
// current line is empty => just the token (word) is longer than `maxWidth` and needs to be wrapped
if (!currentLine) {
const wrappedWord = wrapWord(token, font, maxWidth);
const trailingLine = wrappedWord[wrappedWord.length - 1] ?? "";
const wrappedWord = wrapWord(token, font, maxWidth, tokenStart);
const trailingLine = wrappedWord[wrappedWord.length - 1] ?? {
text: "",
start: tokenStart,
end: tokenStart,
};
const precedingLines = wrappedWord.slice(0, -1);
lines.push(...precedingLines);
// trailing line of the wrapped word might still be joined with next token/s
currentLine = trailingLine;
currentLineWidth = getLineWidth(trailingLine, font);
iterator = tokenIterator.next();
currentLine = trailingLine.text;
currentLineStart = trailingLine.start;
currentLineEnd = trailingLine.end;
currentLineWidth = getLineWidth(trailingLine.text, font);
tokenOffset = tokenEnd;
tokenIndex++;
} else {
// push & reset, but don't iterate on the next token, as we didn't use it yet!
lines.push(currentLine.trimEnd());
lines.push(
trimLineEndAtSoftBreak(currentLine, currentLineStart, currentLineEnd),
);
// purposefully not iterating and not setting `currentLine` to `token`, so that we could use a simple !currentLine check above
currentLine = "";
currentLineStart = tokenStart;
currentLineEnd = tokenStart;
currentLineWidth = 0;
}
}
// iterator done, push the trailing line if exists
if (currentLine) {
const trailingLine = trimLine(currentLine, font, maxWidth);
const trailingLine = trimLine(
currentLine,
currentLineStart,
currentLineEnd,
font,
maxWidth,
);
lines.push(trailingLine);
}
@@ -467,59 +573,100 @@ const wrapLine = (
};
/**
* Wraps the word into the lines based on the given width.
* Wraps a single word that could not be placed on an empty line as-is.
*/
const wrapWord = (
word: string,
font: FontString,
maxWidth: number,
): Array<string> => {
wordStart: number,
): WrappedTextLine[] => {
// multi-codepoint emojis are already broken apart and shouldn't be broken further
if (getEmojiRegex().test(word)) {
return [word];
return [
{
text: word,
start: wordStart,
end: wordStart + word.length,
},
];
}
satisfiesWordInvariant(word);
const lines: Array<string> = [];
const lines: WrappedTextLine[] = [];
const chars = Array.from(word);
let currentLine = "";
let currentLineStart = wordStart;
let currentLineEnd = wordStart;
let currentLineWidth = 0;
let offset = wordStart;
for (const char of chars) {
const charStart = offset;
const charEnd = charStart + char.length;
const _charWidth = charWidth.calculate(char, font);
const testLineWidth = currentLineWidth + _charWidth;
if (testLineWidth <= maxWidth) {
if (!currentLine) {
currentLineStart = charStart;
}
currentLine = currentLine + char;
currentLineEnd = charEnd;
currentLineWidth = testLineWidth;
offset = charEnd;
continue;
}
if (currentLine) {
lines.push(currentLine);
lines.push({
text: currentLine,
start: currentLineStart,
end: currentLineEnd,
});
}
currentLine = char;
currentLineStart = charStart;
currentLineEnd = charEnd;
currentLineWidth = _charWidth;
offset = charEnd;
}
if (currentLine) {
lines.push(currentLine);
lines.push({
text: currentLine,
start: currentLineStart,
end: currentLineEnd,
});
}
return lines;
};
/**
* Similarly to browsers, does not trim all trailing whitespaces, but only those exceeding the `maxWidth`.
* Trims trailing whitespace that is 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 = (line: string, font: FontString, maxWidth: number) => {
const trimLine = (
line: string,
start: number,
end: number,
font: FontString,
maxWidth: number,
): WrappedTextLine => {
const shouldTrimWhitespaces = getLineWidth(line, font) > maxWidth;
if (!shouldTrimWhitespaces) {
return line;
return {
text: line,
start,
end,
};
}
// defensively default to `trimeEnd` in case the regex does not match
@@ -543,7 +690,30 @@ const trimLine = (line: string, font: FontString, maxWidth: number) => {
trimmedLineWidth = testLineWidth;
}
return trimmedLine;
return {
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,3 +392,23 @@ export const canBecomePolygon = (
(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;
}
}
};
+17 -4
View File
@@ -303,19 +303,32 @@ export type PointsPositionUpdates = Map<
{ 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 =
| "arrow"
| "bar"
| "dot" // legacy. Do not use for new elements.
| "circle"
| "circle_outline"
| "triangle"
| "triangle_outline"
| "diamond"
| "diamond_outline"
| "crowfoot_one"
| "crowfoot_many"
| "crowfoot_one_or_many";
| CardinalityArrowhead;
export type AnyArrowhead = Arrowhead | ArrowheadLegacy;
export type ExcalidrawLinearElement = _ExcalidrawElementBase &
Readonly<{
+2 -4
View File
@@ -124,6 +124,7 @@ const setElementShapesCacheEntry = <T extends ExcalidrawElement>(
*/
export function deconstructLinearOrFreeDrawElement(
element: ExcalidrawLinearElement | ExcalidrawFreeDrawElement,
elementsMap: ElementsMap,
): [LineSegment<GlobalPoint>[], Curve<GlobalPoint>[]] {
const cachedShape = getElementShapesCacheEntry(element, 0);
@@ -131,10 +132,7 @@ export function deconstructLinearOrFreeDrawElement(
return cachedShape;
}
const ops = generateLinearCollisionShape(element) as {
op: string;
data: number[];
}[];
const ops = generateLinearCollisionShape(element, elementsMap);
const lines = [];
const curves = [];
+3 -1
View File
@@ -1,4 +1,4 @@
import { arrayToMap } from "@excalidraw/common";
import { arrayToMap, reseed } from "@excalidraw/common";
import { type GlobalPoint, type LocalPoint, pointFrom } from "@excalidraw/math";
import { Excalidraw } from "@excalidraw/excalidraw";
import { API } from "@excalidraw/excalidraw/tests/helpers/api";
@@ -12,6 +12,7 @@ import { hitElementItself } from "../src/collision";
describe("check rotated elements can be hit:", () => {
beforeEach(async () => {
localStorage.clear();
reseed(7);
await render(<Excalidraw handleKeyboardGlobally={true} />);
});
@@ -56,6 +57,7 @@ describe("hitElementItself cache", () => {
});
localStorage.clear();
reseed(7);
await render(<Excalidraw handleKeyboardGlobally={true} />);
});
+63 -3
View File
@@ -1,9 +1,8 @@
/* eslint-disable no-lone-blocks */
import { generateKeyBetween } from "fractional-indexing";
import { arrayToMap } from "@excalidraw/common";
import {
InvalidFractionalIndexError,
syncInvalidIndices,
syncMovedIndices,
validateFractionalIndices,
@@ -13,13 +12,34 @@ import { deepCopyElement } from "@excalidraw/element";
import { API } from "@excalidraw/excalidraw/tests/helpers/api";
import {
generateKeyBetween,
validateOrderKey,
} from "@excalidraw/fractional-indexing";
import type {
ElementsMap,
ExcalidrawElement,
FractionalIndex,
} from "@excalidraw/element/types";
import { InvalidFractionalIndexError } from "../src/fractionalIndex";
describe("fractional index format validation", () => {
it("should reject malformed base62 order keys", () => {
expect(() => validateOrderKey("a!")).toThrow();
expect(() => validateOrderKey("a_")).toThrow();
expect(() => validateOrderKey("a1!")).toThrow();
expect(() => validateOrderKey("a1_")).toThrow();
expect(() => validateOrderKey("zd0032")).toThrow();
});
it("should accept valid base62 order keys", () => {
expect(() => validateOrderKey("Zz")).not.toThrow();
expect(() => validateOrderKey("a0")).not.toThrow();
expect(() => validateOrderKey("a1")).not.toThrow();
expect(() => validateOrderKey("a1V")).not.toThrow();
expect(() => validateOrderKey("z".padEnd(28, "z"))).not.toThrow();
});
});
describe("sync invalid indices with array order", () => {
describe("should NOT sync empty array", () => {
@@ -104,6 +124,46 @@ describe("sync invalid indices with array order", () => {
});
});
describe("should sync when fractional index is malformed", () => {
// "zd0032" has head "z" which requires length 28 per getIntegerLength,
// but the string is far too short, so validateOrderKey throws for it
testInvalidIndicesSync({
elements: [{ id: "A", index: "zd0032" }],
expect: {
unchangedElements: [],
},
});
testInvalidIndicesSync({
elements: [
{ id: "A", index: "a1" },
{ id: "B", index: "zd0032" },
{ id: "C", index: "a3" },
],
expect: {
unchangedElements: ["A", "C"],
},
});
testInvalidIndicesSync({
elements: [{ id: "A", index: "a!" }],
expect: {
unchangedElements: [],
},
});
testInvalidIndicesSync({
elements: [
{ id: "A", index: "a1" },
{ id: "B", index: "a!" },
{ id: "C", index: "a2" },
],
expect: {
unchangedElements: ["A", "C"],
},
});
});
describe("should sync when fractional indices are duplicated", () => {
testInvalidIndicesSync({
elements: [
+594 -2
View File
@@ -2,15 +2,24 @@ import {
convertToExcalidrawElements,
Excalidraw,
} from "@excalidraw/excalidraw";
import { arrayToMap } from "@excalidraw/common";
import { API } from "@excalidraw/excalidraw/tests/helpers/api";
import { Keyboard, Pointer } from "@excalidraw/excalidraw/tests/helpers/ui";
import { Keyboard, Pointer, UI } from "@excalidraw/excalidraw/tests/helpers/ui";
import { getTextEditor } from "@excalidraw/excalidraw/tests/queries/dom";
import {
getCloneByOrigId,
render,
} from "@excalidraw/excalidraw/tests/test-utils";
import type { ExcalidrawElement } from "../src/types";
import { getSelectedElements } from "@excalidraw/excalidraw/scene";
import { elementOverlapsWithFrame } from "../src/frame";
import type {
ExcalidrawElement,
ExcalidrawFrameLikeElement,
} from "../src/types";
const { h } = window;
const mouse = new Pointer("mouse");
@@ -125,6 +134,250 @@ 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 (
func: typeof resizeFrameOverElement | typeof dragElementIntoFrame,
) => {
@@ -415,6 +668,345 @@ describe("adding elements to frames", () => {
describe("dragging elements into the frame", async () => {
await commonTestCases(dragElementIntoFrame);
it("should add a dragged element fully containing the frame", () => {
const containingRect = API.createElement({
type: "rectangle",
x: 220,
y: 20,
width: 300,
height: 300,
});
API.setElements([frame, containingRect]);
dragElementIntoFrame(frame, containingRect);
expect(API.getElement(containingRect).frameId).toBe(frame.id);
});
it("should drag an element into a frame", () => {
API.setElements([rect2, frame]);
dragElementIntoFrame(frame, rect2);
expect(rect2.frameId).toBe(frame.id);
});
it("should layer a dragged element above the highest frame child", () => {
const frameChild = API.createElement({
id: "frameChild",
type: "rectangle",
x: 10,
y: 10,
width: 20,
height: 20,
frameId: frame.id,
});
API.setElements([frame, frameChild, rect2]);
dragElementIntoFrame(frame, rect2);
expect(rect2.frameId).toBe(frame.id);
expect(h.elements.map((element) => element.id)).toEqual([
frame.id,
frameChild.id,
rect2.id,
]);
expect(rect2.index! > frameChild.index!).toBe(true);
expect(rect2.index! > frame.index!).toBe(true);
});
it("should preview a dragged element above the highest frame child before pointerup", () => {
const frameChild = API.createElement({
id: "frameChild",
type: "rectangle",
x: 10,
y: 10,
width: 20,
height: 20,
frameId: frame.id,
});
API.setElements([rect2, frame, frameChild]);
API.setSelectedElements([rect2]);
API.updateElement(rect2, {
x: 10,
y: 10,
});
const getRenderableElementIds = (
selectedElementsAreBeingDragged: boolean,
) => {
return h.app.renderer
.getRenderableElements({
zoom: h.state.zoom,
offsetLeft: 0,
offsetTop: 0,
scrollX: 0,
scrollY: 0,
height: 1000,
width: 1000,
editingTextElement: h.state.editingTextElement,
newElement: h.state.newElement,
selectedElements: getSelectedElements(h.elements, h.state),
selectedElementsAreBeingDragged,
frameToHighlight: frame as ExcalidrawFrameLikeElement,
})
.visibleElements.map((element) => element.id);
};
expect(h.elements.map((element) => element.id)).toEqual([
rect2.id,
frame.id,
frameChild.id,
]);
expect(getRenderableElementIds(false)).toEqual([
rect2.id,
frame.id,
frameChild.id,
]);
expect(getRenderableElementIds(true)).toEqual([
frame.id,
frameChild.id,
rect2.id,
]);
expect(h.elements.map((element) => element.id)).toEqual([
rect2.id,
frame.id,
frameChild.id,
]);
expect(rect2.frameId).toBe(null);
});
it("should not preview reorder dragged elements already in the highlighted frame", () => {
const frameChild = API.createElement({
id: "frameChild",
type: "rectangle",
x: 10,
y: 10,
width: 20,
height: 20,
frameId: frame.id,
});
const otherFrameChild = API.createElement({
id: "otherFrameChild",
type: "rectangle",
x: 40,
y: 10,
width: 20,
height: 20,
frameId: frame.id,
});
API.setElements([frameChild, frame, otherFrameChild]);
API.setSelectedElements([frameChild]);
const renderableElementIds = h.app.renderer
.getRenderableElements({
zoom: h.state.zoom,
offsetLeft: 0,
offsetTop: 0,
scrollX: 0,
scrollY: 0,
height: 1000,
width: 1000,
editingTextElement: h.state.editingTextElement,
newElement: h.state.newElement,
selectedElements: getSelectedElements(h.elements, h.state),
selectedElementsAreBeingDragged: true,
frameToHighlight: frame as ExcalidrawFrameLikeElement,
})
.visibleElements.map((element) => element.id);
expect(renderableElementIds).toEqual([
frameChild.id,
frame.id,
otherFrameChild.id,
]);
});
it("should put a dragged mixed selection above the highest frame child", () => {
const frameChild = API.createElement({
id: "frameChild",
type: "rectangle",
x: 50,
y: 10,
width: 20,
height: 20,
frameId: frame.id,
boundElements: [{ id: "boundText", type: "text" }],
});
const boundText = API.createElement({
id: "boundText",
type: "text",
x: 50,
y: 10,
width: 20,
height: 20,
containerId: frameChild.id,
frameId: frame.id,
});
const otherFrameChild = API.createElement({
id: "otherFrameChild",
type: "rectangle",
x: 80,
y: 10,
width: 20,
height: 20,
frameId: frame.id,
});
const nonFrameElement = API.createElement({
id: "nonFrameElement",
type: "rectangle",
x: 155,
y: 10,
width: 20,
height: 20,
});
API.setElements([
frame,
frameChild,
boundText,
otherFrameChild,
nonFrameElement,
]);
API.setSelectedElements([frameChild, nonFrameElement]);
mouse.downAt(
nonFrameElement.x + nonFrameElement.width / 2,
nonFrameElement.y + nonFrameElement.height / 2,
);
mouse.moveTo(frame.x + frame.width - 5, nonFrameElement.y + 10);
mouse.up();
expect(frameChild.frameId).toBe(frame.id);
expect(boundText.frameId).toBe(frame.id);
expect(nonFrameElement.frameId).toBe(frame.id);
expect(h.elements.map((element) => element.id)).toEqual([
frame.id,
otherFrameChild.id,
frameChild.id,
boundText.id,
nonFrameElement.id,
]);
});
it("should not reorder dragged elements already in the highlighted frame", () => {
const frameChild = API.createElement({
id: "frameChild",
type: "rectangle",
x: 50,
y: 10,
width: 20,
height: 20,
frameId: frame.id,
});
const otherFrameChild = API.createElement({
id: "otherFrameChild",
type: "rectangle",
x: 80,
y: 10,
width: 20,
height: 20,
frameId: frame.id,
});
API.setElements([frame, frameChild, otherFrameChild]);
API.setSelectedElements([frameChild]);
mouse.downAt(
frameChild.x + frameChild.width / 2,
frameChild.y + frameChild.height / 2,
);
mouse.moveTo(frameChild.x + frameChild.width / 2 + 5, frameChild.y + 10);
mouse.up();
expect(frameChild.frameId).toBe(frame.id);
expect(h.elements.map((element) => element.id)).toEqual([
frame.id,
frameChild.id,
otherFrameChild.id,
]);
});
it("should not drag an element into a frame behind a non-frame element", () => {
const cover = API.createElement({
id: "cover",
type: "rectangle",
x: 10,
y: 10,
width: 80,
height: 80,
backgroundColor: "#ffc9c9",
});
API.setElements([frame, cover, rect2]);
mouse.clickAt(rect2.x, rect2.y);
mouse.downAt(rect2.x + rect2.width / 2, rect2.y + rect2.height / 2);
mouse.moveTo(20, 20);
mouse.upAt(20, 20);
expect(rect2.frameId).toBe(null);
});
it("should drag an element into a frame over a non-frame element", () => {
const cover = API.createElement({
id: "cover",
type: "rectangle",
x: 10,
y: 10,
width: 80,
height: 80,
backgroundColor: "#ffc9c9",
});
API.setElements([cover, rect2, frame]);
mouse.clickAt(rect2.x, rect2.y);
mouse.downAt(rect2.x + rect2.width / 2, rect2.y + rect2.height / 2);
mouse.moveTo(20, 20);
mouse.upAt(20, 20);
expect(rect2.frameId).toBe(frame.id);
});
it("should keep dragging a frame child over a non-frame element above its frame", () => {
const cover = API.createElement({
id: "cover",
type: "rectangle",
x: 10,
y: 10,
width: 80,
height: 80,
backgroundColor: "#ffc9c9",
});
const frameChild = API.createElement({
id: "frameChild",
type: "rectangle",
x: 100,
y: 20,
width: 20,
height: 20,
frameId: frame.id,
});
API.setElements([frameChild, frame, cover]);
API.setSelectedElements([frameChild]);
mouse.downAt(
frameChild.x + frameChild.width / 2,
frameChild.y + frameChild.height / 2,
);
mouse.moveTo(20, 20);
expect(h.state.frameToHighlight?.id).toBe(frame.id);
mouse.upAt(20, 20);
expect(frameChild.frameId).toBe(frame.id);
});
it.skip("should drag element inside, duplicate it and keep it in frame", () => {
API.setElements([frame, rect2]);
+43 -3
View File
@@ -326,19 +326,59 @@ describe("normalizeElementsOrder", () => {
]),
[
"BA_rect1",
"CBA_rect3",
"CBA_rect7",
"BA_rect5",
"BA_rect6",
"A_rect2",
"A_rect5",
"CBA_rect3",
"CBA_rect7",
"rect4",
"X_rect8",
"X_rect11",
"YX_rect10",
"X_rect11",
"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
+70 -1
View File
@@ -1,4 +1,8 @@
import { wrapText, parseTokens } from "../src/textWrapping";
import {
getWrappedTextLines,
parseTokens,
wrapText,
} from "../src/textWrapping";
import type { FontString } from "../src/types";
@@ -102,6 +106,71 @@ describe("Test wrapText", () => {
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", () => {
it("should break each CJK character when width is very small", () => {
// "안녕하세요" (Hangul) + "こんにちは世界" (Hiragana, Kanji) + "コンニチハ" (Katakana) + "你好" (Han) = "Hello Hello World Hello Hi"
+9 -14
View File
@@ -18,22 +18,13 @@ Please add the latest change on the top under the correct section.
### Breaking changes
- 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 `onMount` and `onInitialize` props. `onMount` receives `{ excalidrawAPI, container }` once the editor root is mounted, and `onInitialize` fires once the initial scene has loaded.
- 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.
```tsx
<Excalidraw
onMount={({ excalidrawAPI, container }) => {
console.log(container);
excalidrawAPI.scrollToContent();
}}
onInitialize={(api) => {
api.refresh();
}}
/>
```
- 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(...)`.
@@ -41,7 +32,6 @@ Please add the latest change on the top under the correct section.
<Excalidraw
onExcalidrawAPI={(api) => {
api.onEvent("editor:mount", ({ excalidrawAPI, container }) => {
excalidrawAPI.scrollToContent();
console.log(container);
});
@@ -54,7 +44,9 @@ Please add the latest change on the top under the correct section.
Note that in future releases, most, if not all, `excalidrawAPI.on*` subscriptions will be removed in favor of `excalidrawAPI.onEvent(name)`.
- Exported `ExcalidrawAPIProvider`, `useExcalidrawAPI`, and `useAppStateValue` from the package entrypoint. The imperative API also now exposes `onStateChange`.
- 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>
@@ -63,6 +55,9 @@ Please add the latest change on the top under the correct section.
</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) => {
+111 -14
View File
@@ -1,10 +1,10 @@
# Excalidraw
**Excalidraw** is exported as a component to be directly embedded in your project.
**Excalidraw** is exported as a React component that you can embed directly in your app.
## Installation
Use `npm` or `yarn` to install the package.
Install the package together with its React peer dependencies.
```bash
npm install react react-dom @excalidraw/excalidraw
@@ -12,34 +12,131 @@ npm install react react-dom @excalidraw/excalidraw
yarn add react react-dom @excalidraw/excalidraw
```
> **Note**: If you don't want to wait for the next stable release and try out the unreleased changes, use `@excalidraw/excalidraw@next`.
> **Note**: If you want to try unreleased changes, use `@excalidraw/excalidraw@next`.
#### Self-hosting fonts
## Quick start
By default, Excalidraw will try to download all the used fonts from the [CDN](https://esm.run/@excalidraw/excalidraw/dist/prod).
The minimum working setup has two easy-to-miss requirements:
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:
1. Import the package CSS:
```js
<script>window.EXCALIDRAW_ASSET_PATH = "/";</script>
```ts
import "@excalidraw/excalidraw/index.css";
```
### Dimensions of Excalidraw
2. Render Excalidraw inside a container with a non-zero height.
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.
```tsx
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
Go to [CodeSandbox](https://codesandbox.io/p/sandbox/github/excalidraw/excalidraw/tree/master/examples/with-script-in-browser) example.
Try the [CodeSandbox example](https://codesandbox.io/p/sandbox/github/excalidraw/excalidraw/tree/master/examples/with-script-in-browser).
## Integration
Head over to the [docs](https://docs.excalidraw.com/docs/@excalidraw/excalidraw/integration).
Read the [integration docs](https://docs.excalidraw.com/docs/@excalidraw/excalidraw/integration).
## API
Head over to the [docs](https://docs.excalidraw.com/docs/@excalidraw/excalidraw/api).
Read the [API docs](https://docs.excalidraw.com/docs/@excalidraw/excalidraw/api).
## Contributing
Head over to the [docs](https://docs.excalidraw.com/docs/@excalidraw/excalidraw/contributing).
Read the [contributing docs](https://docs.excalidraw.com/docs/@excalidraw/excalidraw/contributing).
@@ -0,0 +1,147 @@
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))
);
},
});
+1 -1
View File
@@ -399,7 +399,7 @@ export const actionSaveFileToDisk = register({
appState: {
openDialog: null,
fileHandle: savedFileHandle,
toast: { message: t("toast.fileSaved") },
toast: { message: t("toast.fileSaved"), duration: 3000 },
},
};
} catch (error: any) {
@@ -14,6 +14,8 @@ import {
isLineElement,
} from "@excalidraw/element";
import { invalidateFreeDrawIncrementalCanvas } from "@excalidraw/element";
import {
KEYS,
arrayToMap,
@@ -313,6 +315,10 @@ export const actionFinalize = register<FormData>({
}
: selectedLinearElement;
if (element && isFreeDrawElement(element)) {
invalidateFreeDrawIncrementalCanvas(element);
}
return {
elements: newElements,
appState: {
@@ -329,8 +335,8 @@ export const actionFinalize = register<FormData>({
selectionElement: null,
multiElement: null,
editingTextElement: null,
startBoundElement: null,
suggestedBinding: null,
frameToHighlight: null,
selectedElementIds:
element &&
!appState.activeTool.locked &&
@@ -348,9 +354,7 @@ export const actionFinalize = register<FormData>({
};
},
keyTest: (event, appState) =>
(event.key === KEYS.ESCAPE &&
(appState.selectedLinearElement?.isEditing ||
(!appState.newElement && appState.multiElement === null))) ||
(event.key === KEYS.ESCAPE && appState.selectedLinearElement?.isEditing) ||
((event.key === KEYS.ESCAPE || event.key === KEYS.ENTER) &&
appState.multiElement !== null),
PanelComponent: ({ appState, updateData, data }) => (
@@ -205,7 +205,6 @@ export const actionWrapSelectionInFrame = register({
[...app.scene.getElementsIncludingDeleted(), frame],
selectedElements,
frame,
appState,
);
return {
@@ -277,7 +277,6 @@ export const actionUngroup = register({
elementsMap,
),
frame,
app,
);
}
});
@@ -1,5 +1,4 @@
import {
isWindows,
KEYS,
matchKey,
arrayToMap,
@@ -114,7 +113,7 @@ export const createRedoAction: ActionCreator = (history) => ({
),
keyTest: (event) =>
(event[KEYS.CTRL_OR_CMD] && event.shiftKey && matchKey(event, KEYS.Z)) ||
(isWindows && event.ctrlKey && !event.shiftKey && matchKey(event, KEYS.Y)),
(event[KEYS.CTRL_OR_CMD] && !event.shiftKey && matchKey(event, KEYS.Y)),
PanelComponent: ({ appState, updateData, data, app }) => {
const { isRedoStackEmpty } = useEmitter(
history.onHistoryChangedEmitter,
+248 -90
View File
@@ -1,3 +1,5 @@
import clsx from "clsx";
import { pointFrom } from "@excalidraw/math";
import { useEffect, useMemo, useRef, useState } from "react";
@@ -36,6 +38,7 @@ import {
import { LinearElementEditor } from "@excalidraw/element";
import { newElementWith } from "@excalidraw/element";
import { getArrowheadForPicker } from "@excalidraw/element";
import {
getBoundTextElement,
@@ -46,6 +49,7 @@ import {
isArrowElement,
isBoundToContainer,
isElbowArrow,
isFreeDrawElement,
isLinearElement,
isLineElement,
isTextElement,
@@ -124,9 +128,14 @@ import {
sharpArrowIcon,
roundArrowIcon,
elbowArrowIcon,
ArrowheadCrowfootIcon,
ArrowheadCrowfootOneIcon,
ArrowheadCrowfootOneOrManyIcon,
ArrowheadCardinalityExactlyOneIcon,
ArrowheadCardinalityManyIcon,
ArrowheadCardinalityOneIcon,
ArrowheadCardinalityOneOrManyIcon,
ArrowheadCardinalityZeroOrManyIcon,
ArrowheadCardinalityZeroOrOneIcon,
FreedrawPressureConstantIcon,
FreedrawPressureSensitiveIcon,
} from "../components/icons";
import { Fonts } from "../fonts";
@@ -187,7 +196,7 @@ export const getFormValue = function <T extends Primitive>(
elements: readonly ExcalidrawElement[],
app: AppClassProperties,
getAttribute: (element: ExcalidrawElement) => T,
isRelevantElement: true | ((element: ExcalidrawElement) => boolean),
elementPredicate: true | ((element: ExcalidrawElement) => boolean),
defaultValue: T | ((isSomeElementSelected: boolean) => T),
): T {
const editingTextElement = app.state.editingTextElement;
@@ -205,9 +214,9 @@ export const getFormValue = function <T extends Primitive>(
if (hasSelection) {
const selectedElements = app.scene.getSelectedElements(app.state);
const targetElements =
isRelevantElement === true
elementPredicate === true
? selectedElements
: selectedElements.filter((el) => isRelevantElement(el));
: selectedElements.filter((el) => elementPredicate(el));
ret =
reduceToCommonValue(targetElements, getAttribute) ??
@@ -726,9 +735,28 @@ export const actionChangeOpacity = register<ExcalidrawElement["opacity"]>({
captureUpdate: CaptureUpdateAction.IMMEDIATELY,
};
},
PanelComponent: ({ app, updateData }) => (
<Range updateData={updateData} app={app} testId="opacity" />
),
PanelComponent: ({ elements, appState, app, updateData }) => {
const opacity = getFormValue(
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"]>(
@@ -1550,80 +1578,117 @@ export const actionChangeRoundness = register<"sharp" | "round">({
});
const getArrowheadOptions = (flip: boolean) => {
return [
{
value: null,
text: t("labels.arrowhead_none"),
keyBinding: "q",
icon: <ArrowheadNoneIcon flip={flip} />,
},
{
value: "arrow",
text: t("labels.arrowhead_arrow"),
keyBinding: "w",
icon: <ArrowheadArrowIcon flip={flip} />,
},
{
value: "triangle",
text: t("labels.arrowhead_triangle"),
icon: <ArrowheadTriangleIcon flip={flip} />,
keyBinding: "e",
},
{
value: "triangle_outline",
text: t("labels.arrowhead_triangle_outline"),
icon: <ArrowheadTriangleOutlineIcon flip={flip} />,
keyBinding: "r",
},
{
value: "circle",
text: t("labels.arrowhead_circle"),
keyBinding: "a",
icon: <ArrowheadCircleIcon flip={flip} />,
},
{
value: "circle_outline",
text: t("labels.arrowhead_circle_outline"),
keyBinding: "s",
icon: <ArrowheadCircleOutlineIcon flip={flip} />,
},
{
value: "diamond",
text: t("labels.arrowhead_diamond"),
icon: <ArrowheadDiamondIcon flip={flip} />,
keyBinding: "d",
},
{
value: "diamond_outline",
text: t("labels.arrowhead_diamond_outline"),
icon: <ArrowheadDiamondOutlineIcon flip={flip} />,
keyBinding: "f",
},
{
value: "bar",
text: t("labels.arrowhead_bar"),
keyBinding: "z",
icon: <ArrowheadBarIcon flip={flip} />,
},
{
value: "crowfoot_one",
text: t("labels.arrowhead_crowfoot_one"),
icon: <ArrowheadCrowfootOneIcon flip={flip} />,
keyBinding: "x",
},
{
value: "crowfoot_many",
text: t("labels.arrowhead_crowfoot_many"),
icon: <ArrowheadCrowfootIcon flip={flip} />,
keyBinding: "c",
},
{
value: "crowfoot_one_or_many",
text: t("labels.arrowhead_crowfoot_one_or_many"),
icon: <ArrowheadCrowfootOneOrManyIcon flip={flip} />,
keyBinding: "v",
},
] as const;
return {
visibleSections: [
{
name: "default",
options: [
{
value: null,
text: t("labels.arrowhead_none"),
keyBinding: "q",
icon: <ArrowheadNoneIcon flip={flip} />,
},
{
value: "arrow",
text: t("labels.arrowhead_arrow"),
keyBinding: "w",
icon: <ArrowheadArrowIcon flip={flip} />,
},
{
value: "triangle",
text: t("labels.arrowhead_triangle"),
icon: <ArrowheadTriangleIcon flip={flip} />,
keyBinding: "e",
},
{
value: "triangle_outline",
text: t("labels.arrowhead_triangle_outline"),
icon: <ArrowheadTriangleOutlineIcon flip={flip} />,
keyBinding: "r",
},
],
},
],
hiddenSections: [
{
name: "default",
options: [
{
value: "circle",
text: t("labels.arrowhead_circle"),
keyBinding: "a",
icon: <ArrowheadCircleIcon flip={flip} />,
},
{
value: "circle_outline",
text: t("labels.arrowhead_circle_outline"),
keyBinding: "s",
icon: <ArrowheadCircleOutlineIcon flip={flip} />,
},
{
value: "diamond",
text: t("labels.arrowhead_diamond"),
icon: <ArrowheadDiamondIcon flip={flip} />,
keyBinding: "d",
},
{
value: "diamond_outline",
text: t("labels.arrowhead_diamond_outline"),
icon: <ArrowheadDiamondOutlineIcon flip={flip} />,
keyBinding: "f",
},
{
value: "bar",
text: t("labels.arrowhead_bar"),
keyBinding: "z",
icon: <ArrowheadBarIcon flip={flip} />,
},
],
},
{
name: t("labels.cardinality"),
options: [
{
value: "cardinality_one",
text: t("labels.arrowhead_cardinality_one"),
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<{
@@ -1667,45 +1732,52 @@ export const actionChangeArrowhead = register<{
},
PanelComponent: ({ elements, appState, updateData, app }) => {
const isRTL = getLanguage().rtl;
const startArrowheadOptions = useMemo(
() => getArrowheadOptions(!isRTL),
[isRTL],
);
const endArrowheadOptions = useMemo(
() => getArrowheadOptions(!!isRTL),
[isRTL],
);
return (
<fieldset>
<legend>{t("labels.arrowheads")}</legend>
<div className="iconSelectList buttonList">
<IconPicker
visibleSections={startArrowheadOptions.visibleSections}
hiddenSections={startArrowheadOptions.hiddenSections}
label="arrowhead_start"
options={getArrowheadOptions(!isRTL)}
value={getFormValue<Arrowhead | null>(
elements,
app,
(element) =>
isLinearElement(element) && canHaveArrowheads(element.type)
? element.startArrowhead
? getArrowheadForPicker(element.startArrowhead)
: appState.currentItemStartArrowhead,
true,
(hasSelection) =>
hasSelection ? null : appState.currentItemStartArrowhead,
)}
onChange={(value) => updateData({ position: "start", type: value })}
numberOfOptionsToAlwaysShow={4}
/>
<IconPicker
visibleSections={endArrowheadOptions.visibleSections}
hiddenSections={endArrowheadOptions.hiddenSections}
label="arrowhead_end"
group="arrowheads"
options={getArrowheadOptions(!!isRTL)}
value={getFormValue<Arrowhead | null>(
elements,
app,
(element) =>
isLinearElement(element) && canHaveArrowheads(element.type)
? element.endArrowhead
? getArrowheadForPicker(element.endArrowhead)
: appState.currentItemEndArrowhead,
true,
(hasSelection) =>
hasSelection ? null : appState.currentItemEndArrowhead,
)}
onChange={(value) => updateData({ position: "end", type: value })}
numberOfOptionsToAlwaysShow={4}
/>
</div>
</fieldset>
@@ -1974,3 +2046,89 @@ export const actionChangeArrowType = register<keyof typeof ARROW_TYPE>({
);
},
});
export const actionChangeStrokeShape = register<boolean>({
name: "changeStrokeShape",
label: "labels.strokeShape",
trackEvent: false,
perform: (elements, appState, value) => {
return {
elements: changeProperty(elements, appState, (el) => {
if (isFreeDrawElement(el)) {
return newElementWith(el, {
simulatePressure: value,
});
}
return el;
}),
appState: { ...appState, currentItemFreedrawConstantPressure: value },
captureUpdate: CaptureUpdateAction.IMMEDIATELY,
};
},
PanelComponent: ({ elements, appState, updateData, app }) => {
const { isCompact } = getStylesPanelInfo(app);
const currentValue = getFormValue(
elements,
app,
(element) => {
if (isFreeDrawElement(element)) {
return element.simulatePressure;
}
return null;
},
(element) => isFreeDrawElement(element),
(hasSelection) =>
hasSelection ? null : appState.currentItemFreedrawConstantPressure,
);
if (isCompact) {
const isConstantPressure = currentValue !== false;
return (
<button
type="button"
className={clsx("compact-action-button", {
active: !isConstantPressure,
})}
title={
isConstantPressure
? t("labels.strokeShape_constant")
: t("labels.strokeShape_pressure")
}
onClick={() => updateData(!isConstantPressure)}
>
{isConstantPressure
? FreedrawPressureConstantIcon
: FreedrawPressureSensitiveIcon}
</button>
);
}
return (
<fieldset>
<legend>{t("labels.strokeShape")}</legend>
<div className="buttonList">
<RadioSelection
group="stroke-shape"
options={[
{
value: true,
text: t("labels.strokeShape_constant"),
icon: FreedrawPressureConstantIcon,
testId: "strokeShape-constant",
},
{
value: false,
text: t("labels.strokeShape_pressure"),
icon: FreedrawPressureSensitiveIcon,
testId: "strokeShape-pressure",
},
]}
value={currentValue}
onChange={(value) => updateData(value)}
/>
</div>
</fieldset>
);
},
});
@@ -1,24 +1,24 @@
import { getFontString } from "@excalidraw/common";
import { newElementWith } from "@excalidraw/element";
import { isExcalidrawElement, newElementWith } from "@excalidraw/element";
import { measureText } from "@excalidraw/element";
import { isTextElement } from "@excalidraw/element";
import { CaptureUpdateAction } from "@excalidraw/element";
import type { ExcalidrawElement } from "@excalidraw/element/types";
import { getSelectedElements } from "../scene";
import { register } from "./register";
import type { AppClassProperties } from "../types";
export const actionTextAutoResize = register({
name: "autoResize",
label: "labels.autoResize",
icon: null,
trackEvent: { category: "element" },
predicate: (elements, appState, _: unknown, app: AppClassProperties) => {
predicate: (elements, appState, _: unknown) => {
const selectedElements = getSelectedElements(elements, appState);
return (
selectedElements.length === 1 &&
@@ -26,13 +26,18 @@ export const actionTextAutoResize = register({
!selectedElements[0].autoResize
);
},
perform: (elements, appState, _, app) => {
perform: (elements, appState, targetElement) => {
const selectedElements = getSelectedElements(elements, appState);
const targetTextElement =
isExcalidrawElement(targetElement) && isTextElement(targetElement)
? targetElement
: (selectedElements[0] as ExcalidrawElement | undefined);
return {
appState,
elements: elements.map((element) => {
if (element.id === selectedElements[0].id && isTextElement(element)) {
if (element.id === targetTextElement?.id && isTextElement(element)) {
const metrics = measureText(
element.originalText,
getFontString(element),
+2
View File
@@ -19,6 +19,7 @@ export {
actionChangeTextAlign,
actionChangeVerticalAlign,
actionChangeArrowProperties,
actionChangeStrokeShape,
} from "./actionProperties";
export {
@@ -34,6 +35,7 @@ export {
export { actionSetEmbeddableAsActiveTool } from "./actionEmbeddable";
export { actionFinalize } from "./actionFinalize";
export { actionDeselect } from "./actionDeselect";
export {
actionChangeProjectName,
+1
View File
@@ -114,6 +114,7 @@ export type ActionName =
| "distributeVertically"
| "flipHorizontal"
| "flipVertical"
| "deselect"
| "viewMode"
| "exportWithDarkMode"
| "toggleTheme"
@@ -1,5 +1,4 @@
import { LaserPointer } from "@excalidraw/laser-pointer";
import {
SVG_NS,
getSvgPathFromStroke,
@@ -8,7 +7,8 @@ import {
import type { LaserPointerOptions } from "@excalidraw/laser-pointer";
import type { AnimationFrameHandler } from "./animation-frame-handler";
import { AnimationController } from "./renderer/animation";
import type App from "./components/App";
import type { AppState } from "./types";
@@ -34,15 +34,16 @@ export class AnimatedTrail implements Trail {
private container?: SVGSVGElement;
private trailElement: SVGPathElement;
private trailAnimation?: SVGAnimateElement;
private key: string;
private static counter = 0;
constructor(
private animationFrameHandler: AnimationFrameHandler,
protected app: App,
private options: Partial<LaserPointerOptions> &
Partial<AnimatedTrailOptions>,
) {
this.animationFrameHandler.register(this, this.onFrame.bind(this));
this.key = `animated-trail-${AnimatedTrail.counter++}`;
this.trailElement = document.createElementNS(SVG_NS, "path");
if (this.options.animateTrail) {
this.trailAnimation = document.createElementNS(SVG_NS, "animate");
@@ -73,6 +74,15 @@ export class AnimatedTrail implements Trail {
return false;
}
private cleanup() {
this.pastTrails = [];
this.currentTrail = undefined;
if (this.trailElement.parentNode === this.container) {
this.container?.removeChild(this.trailElement);
}
}
start(container?: SVGSVGElement) {
if (container) {
this.container = container;
@@ -82,15 +92,23 @@ export class AnimatedTrail implements Trail {
this.container.appendChild(this.trailElement);
}
this.animationFrameHandler.start(this);
if (!AnimationController.running(this.key)) {
AnimationController.start(this.key, () => {
const needsNext = this.onFrame();
if (needsNext) {
return { keep: true };
}
this.cleanup();
return null;
});
}
}
stop() {
this.animationFrameHandler.stop(this);
if (this.trailElement.parentNode === this.container) {
this.container?.removeChild(this.trailElement);
}
AnimationController.cancel(this.key);
this.cleanup();
}
startPath(x: number, y: number) {
@@ -145,21 +163,25 @@ export class AnimatedTrail implements Trail {
if (this.currentTrail) {
const currentPath = this.drawTrail(this.currentTrail, this.app.state);
paths.push(currentPath);
}
this.pastTrails = this.pastTrails.filter((trail) => {
return trail.getStrokeOutline().length !== 0;
});
this.pastTrails = this.pastTrails.filter(
(t) =>
t.getStrokeOutline(t.options.size / this.app.state.zoom.value)
.length !== 0,
);
if (paths.length === 0) {
this.stop();
// Clean up the SVG path if there are no trails to render
this.trailElement.setAttribute("d", "");
return false;
}
const svgPaths = paths.join(" ").trim();
this.trailElement.setAttribute("d", svgPaths);
if (this.trailAnimation) {
this.trailElement.setAttribute(
"fill",
@@ -175,6 +197,8 @@ export class AnimatedTrail implements Trail {
(this.options.fill ?? (() => "black"))(this),
);
}
return true;
}
private drawTrail(trail: LaserPointer, state: AppState): string {
@@ -1,79 +0,0 @@
export type AnimationCallback = (timestamp: number) => void | boolean;
export type AnimationTarget = {
callback: AnimationCallback;
stopped: boolean;
};
export class AnimationFrameHandler {
private targets = new WeakMap<object, AnimationTarget>();
private rafIds = new WeakMap<object, number>();
register(key: object, callback: AnimationCallback) {
this.targets.set(key, { callback, stopped: true });
}
start(key: object) {
const target = this.targets.get(key);
if (!target) {
return;
}
if (this.rafIds.has(key)) {
return;
}
this.targets.set(key, { ...target, stopped: false });
this.scheduleFrame(key);
}
stop(key: object) {
const target = this.targets.get(key);
if (target && !target.stopped) {
this.targets.set(key, { ...target, stopped: true });
}
this.cancelFrame(key);
}
private constructFrame(key: object): FrameRequestCallback {
return (timestamp: number) => {
const target = this.targets.get(key);
if (!target) {
return;
}
const shouldAbort = this.onFrame(target, timestamp);
if (!target.stopped && !shouldAbort) {
this.scheduleFrame(key);
} else {
this.cancelFrame(key);
}
};
}
private scheduleFrame(key: object) {
const rafId = requestAnimationFrame(this.constructFrame(key));
this.rafIds.set(key, rafId);
}
private cancelFrame(key: object) {
if (this.rafIds.has(key)) {
const rafId = this.rafIds.get(key)!;
cancelAnimationFrame(rafId);
}
this.rafIds.delete(key);
}
private onFrame(target: AnimationTarget, timestamp: number): boolean {
const shouldAbort = target.callback(timestamp);
return shouldAbort ?? false;
}
}
+8 -2
View File
@@ -40,6 +40,7 @@ export const getDefaultAppState = (): Omit<
currentItemArrowType: ARROW_TYPE.round,
currentItemStrokeStyle: DEFAULT_ELEMENT_PROPS.strokeStyle,
currentItemStrokeWidth: DEFAULT_ELEMENT_PROPS.strokeWidth,
currentItemFreedrawConstantPressure: true,
currentItemTextAlign: DEFAULT_TEXT_ALIGN,
currentHoveredFontFamily: null,
cursorButton: "up",
@@ -99,7 +100,6 @@ export const getDefaultAppState = (): Omit<
open: false,
panels: STATS_PANELS.generalStats | STATS_PANELS.elementProperties,
},
startBoundElement: null,
suggestedBinding: null,
frameRendering: { enabled: true, clip: true, name: true, outline: true },
frameToHighlight: null,
@@ -128,6 +128,7 @@ export const getDefaultAppState = (): Omit<
lockedMultiSelections: {},
activeLockedId: null,
bindMode: "orbit",
boxSelectionMode: "contain",
};
};
@@ -171,6 +172,11 @@ const APP_STATE_STORAGE_CONF = (<
currentItemStrokeColor: { browser: true, export: false, server: false },
currentItemStrokeStyle: { browser: true, export: false, server: false },
currentItemStrokeWidth: { browser: true, export: false, server: false },
currentItemFreedrawConstantPressure: {
browser: true,
export: false,
server: false,
},
currentItemTextAlign: { browser: true, export: false, server: false },
currentHoveredFontFamily: { browser: false, export: false, server: false },
cursorButton: { browser: true, export: false, server: false },
@@ -193,6 +199,7 @@ const APP_STATE_STORAGE_CONF = (<
gridModeEnabled: { browser: true, export: true, server: true },
height: { browser: false, export: false, server: false },
isBindingEnabled: { browser: true, export: false, server: false },
boxSelectionMode: { browser: true, export: false, server: false },
bindingPreference: { browser: true, export: false, server: false },
isMidpointSnappingEnabled: { browser: true, export: false, server: false },
defaultSidebarDockedPreference: {
@@ -229,7 +236,6 @@ const APP_STATE_STORAGE_CONF = (<
selectionElement: { browser: false, export: false, server: false },
shouldCacheIgnoreZoom: { browser: true, export: false, server: false },
stats: { browser: true, export: false, server: false },
startBoundElement: { browser: false, export: false, server: false },
suggestedBinding: { browser: false, export: false, server: false },
frameRendering: { browser: false, export: false, server: false },
frameToHighlight: { browser: false, export: false, server: false },
@@ -834,6 +834,14 @@ export const CompactShapeActions = ({
container={container}
/>
{/* Stroke Shape Toggle (freedraw only) */}
{(appState.activeTool.type === "freedraw" ||
targetElements.some((element) => element.type === "freedraw")) && (
<div className="compact-action-item">
{renderAction("changeStrokeShape")}
</div>
)}
<CombinedArrowProperties
appState={appState}
renderAction={renderAction}
@@ -969,6 +977,13 @@ export const MobileShapeActions = ({
targetElements={targetElements}
container={container}
/>
{/* Stroke Shape Toggle (freedraw only) */}
{(appState.activeTool.type === "freedraw" ||
targetElements.some((element) => element.type === "freedraw")) && (
<div className="compact-action-item">
{renderAction("changeStrokeShape")}
</div>
)}
{/* Combined Arrow Properties */}
<CombinedArrowProperties
appState={appState}
File diff suppressed because it is too large Load Diff
@@ -46,6 +46,7 @@ import {
import { fontPickerKeyHandler } from "./keyboardNavHandlers";
import type { JSX } from "react";
import type { ExcalidrawFontFace } from "../../fonts/ExcalidrawFontFace";
export interface FontDescriptor {
value: number;
@@ -86,6 +87,15 @@ const getFontFamilyIcon = (fontFamily: FontFamilyValues): JSX.Element => {
}
};
const getFontFamilyLabel = (
fontFamily: FontFamilyValues,
fontFaces: ExcalidrawFontFace[],
) =>
// prefer our config as the browser resolved names may be wrapped in quotes and such
Object.entries(FONT_FAMILY).find(([, id]) => id === fontFamily)?.[0] ??
fontFaces[0]?.fontFace?.family ??
"Unknown";
export const FontPickerList = React.memo(
({
selectedFontFamily,
@@ -114,7 +124,7 @@ export const FontPickerList = React.memo(
const fontDescriptor = {
value: familyId,
icon: getFontFamilyIcon(familyId),
text: fontFaces[0]?.fontFace?.family ?? "Unknown",
text: getFontFamilyLabel(familyId, fontFaces),
};
if (metadata.deprecated) {
+15 -3
View File
@@ -6,14 +6,20 @@
padding: 0.5rem;
background: var(--popup-bg-color);
border: 0 solid color.adjust(#fff, $alpha: -0.75);
box-shadow: var(--shadow-island);
box-shadow: var(--shadow-island-stronger);
border-radius: 4px;
position: absolute;
:root[dir="rtl"] & {
padding: 0.4rem;
}
}
.picker-sections,
.picker-section {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.picker-container button,
.picker button {
position: relative;
@@ -62,7 +68,13 @@
.picker-collapsible {
font-size: 0.75rem;
padding: 0.5rem 0;
padding: 0;
color: var(--text-primary-color);
}
.picker-section-label {
font-size: 0.75rem;
color: var(--text-primary-color);
}
.picker-keybinding {
+157 -67
View File
@@ -1,6 +1,6 @@
import { Popover } from "radix-ui";
import clsx from "clsx";
import React, { useEffect } from "react";
import React, { useEffect, useMemo } from "react";
import { isArrowKey, KEYS } from "@excalidraw/common";
@@ -8,13 +8,15 @@ import { atom, useAtom } from "../editor-jotai";
import { getLanguage, t } from "../i18n";
import Collapsible from "./Stats/Collapsible";
import { useEditorInterface, useExcalidrawContainer } from "./App";
import { useExcalidrawContainer } from "./App";
import "./IconPicker.scss";
import type { JSX } from "react";
const moreOptionsAtom = atom(false);
const PICKER_COLUMNS = 4;
const DEFAULT_SECTION_NAME = "default";
type Option<T> = {
value: T;
@@ -23,28 +25,73 @@ type Option<T> = {
keyBinding: string | null;
};
type PickerSection<T> = {
name: string;
options: readonly Option<T>[];
};
const flattenOptions = <T,>(sections: readonly PickerSection<T>[]) =>
sections.flatMap((section) => section.options);
const findOption = <T,>(
sections: readonly PickerSection<T>[],
predicate: (option: Option<T>) => boolean,
) => {
for (const section of sections) {
const option = section.options.find(predicate);
if (option) {
return option;
}
}
return null;
};
const hasOption = <T,>(
sections: readonly PickerSection<T>[],
predicate: (option: Option<T>) => boolean,
) => sections.some((section) => section.options.some(predicate));
const getNavigationRows = <T,>(sections: readonly PickerSection<T>[]) =>
sections.flatMap((section) =>
Array.from(
{ length: Math.ceil(section.options.length / PICKER_COLUMNS) },
(_, index) =>
section.options.slice(
index * PICKER_COLUMNS,
index * PICKER_COLUMNS + PICKER_COLUMNS,
),
),
);
function Picker<T>({
options,
visibleSections,
hiddenSections = [],
value,
label,
onChange,
onClose,
numberOfOptionsToAlwaysShow = options.length,
}: {
label: string;
value: T;
options: readonly Option<T>[];
visibleSections: readonly PickerSection<T>[];
hiddenSections?: readonly PickerSection<T>[];
onChange: (value: T) => void;
onClose: () => void;
numberOfOptionsToAlwaysShow?: number;
}) {
const editorInterface = useEditorInterface();
const { container } = useExcalidrawContainer();
const [showMoreOptions, setShowMoreOptions] = useAtom(moreOptionsAtom);
const allSections = [...visibleSections, ...hiddenSections];
const allOptions = flattenOptions(allSections);
const navigationRows = getNavigationRows([
...visibleSections,
...(showMoreOptions ? hiddenSections : []),
]);
const handleKeyDown = (event: React.KeyboardEvent) => {
const pressedOption = options.find(
const pressedOption = allOptions.find(
(option) => option.keyBinding === event.key.toLowerCase(),
)!;
);
if (!(event.metaKey || event.altKey || event.ctrlKey) && pressedOption) {
// Keybinding navigation
@@ -52,17 +99,17 @@ function Picker<T>({
event.preventDefault();
} else if (event.key === KEYS.TAB) {
const index = options.findIndex((option) => option.value === value);
const index = allOptions.findIndex((option) => option.value === value);
const nextIndex = event.shiftKey
? (options.length + index - 1) % options.length
: (index + 1) % options.length;
onChange(options[nextIndex].value);
? (allOptions.length + index - 1) % allOptions.length
: (index + 1) % allOptions.length;
onChange(allOptions[nextIndex].value);
} else if (isArrowKey(event.key)) {
// Arrow navigation
const isRTL = getLanguage().rtl;
const index = options.findIndex((option) => option.value === value);
const index = allOptions.findIndex((option) => option.value === value);
if (index !== -1) {
const length = options.length;
const length = allOptions.length;
let nextIndex = index;
switch (event.key) {
@@ -76,18 +123,60 @@ function Picker<T>({
break;
// Go the next row
case KEYS.ARROW_DOWN: {
nextIndex = (index + (numberOfOptionsToAlwaysShow ?? 1)) % length;
const currentRowIndex = navigationRows.findIndex((row) =>
row.some((option) => option.value === value),
);
const currentRow = navigationRows[currentRowIndex];
if (currentRowIndex !== -1 && currentRow) {
const column = currentRow.findIndex(
(option) => option.value === value,
);
const nextRow =
navigationRows[(currentRowIndex + 1) % navigationRows.length];
const nextOption =
nextRow[Math.min(column, nextRow.length - 1)] ??
allOptions[index];
onChange(nextOption.value);
event.preventDefault();
event.nativeEvent.stopImmediatePropagation();
event.stopPropagation();
return;
}
break;
}
// Go the previous row
case KEYS.ARROW_UP: {
nextIndex =
(length + index - (numberOfOptionsToAlwaysShow ?? 1)) % length;
const currentRowIndex = navigationRows.findIndex((row) =>
row.some((option) => option.value === value),
);
const currentRow = navigationRows[currentRowIndex];
if (currentRowIndex !== -1 && currentRow) {
const column = currentRow.findIndex(
(option) => option.value === value,
);
const previousRow =
navigationRows[
(navigationRows.length + currentRowIndex - 1) %
navigationRows.length
];
const previousOption =
previousRow[Math.min(column, previousRow.length - 1)] ??
allOptions[index];
onChange(previousOption.value);
event.preventDefault();
event.nativeEvent.stopImmediatePropagation();
event.stopPropagation();
return;
}
break;
}
}
onChange(options[nextIndex].value);
onChange(allOptions[nextIndex].value);
}
event.preventDefault();
} else if (event.key === KEYS.ESCAPE || event.key === KEYS.ENTER) {
@@ -99,38 +188,29 @@ function Picker<T>({
event.stopPropagation();
};
const [showMoreOptions, setShowMoreOptions] = useAtom(moreOptionsAtom);
const alwaysVisibleOptions = React.useMemo(
() => options.slice(0, numberOfOptionsToAlwaysShow),
[options, numberOfOptionsToAlwaysShow],
);
const moreOptions = React.useMemo(
() => options.slice(numberOfOptionsToAlwaysShow),
[options, numberOfOptionsToAlwaysShow],
);
useEffect(() => {
if (!alwaysVisibleOptions.some((option) => option.value === value)) {
if (hasOption(hiddenSections, (option) => option.value === value)) {
setShowMoreOptions(true);
}
}, [value, alwaysVisibleOptions, setShowMoreOptions]);
}, [value, hiddenSections, setShowMoreOptions]);
const renderOptions = (options: Option<T>[]) => {
const renderOptions = (options: readonly Option<T>[]) => {
return (
<div className="picker-content">
{options.map((option, i) => (
{options.map((option) => (
<button
type="button"
className={clsx("picker-option", {
active: value === option.value,
})}
onClick={(event) => {
onClick={() => {
onChange(option.value);
}}
title={`${option.text} ${
option.keyBinding && `${option.keyBinding.toUpperCase()}`
}`}
title={
option.keyBinding
? `${option.text}${option.keyBinding.toUpperCase()}`
: option.text
}
aria-label={option.text || "none"}
aria-keyshortcuts={option.keyBinding || undefined}
key={option.text}
@@ -153,26 +233,38 @@ function Picker<T>({
);
};
const isMobile = editorInterface.formFactor === "phone";
const renderSections = (sections: readonly PickerSection<T>[]) =>
sections.map((section, index) =>
section.name === DEFAULT_SECTION_NAME ? (
<React.Fragment key={`${section.name}-${index}`}>
{renderOptions(section.options)}
</React.Fragment>
) : (
<div className="picker-section" key={`${section.name}-${index}`}>
<div className="picker-section-label">{section.name}</div>
{renderOptions(section.options)}
</div>
),
);
return (
<Popover.Content
side={isMobile ? "right" : "bottom"}
className="picker"
role="dialog"
aria-modal="true"
aria-label={label}
side={"bottom"}
align="start"
sideOffset={isMobile ? 8 : 12}
sideOffset={12}
alignOffset={12}
style={{ zIndex: "var(--zIndex-ui-styles-popup)" }}
onKeyDown={handleKeyDown}
collisionBoundary={container ?? undefined}
>
<div
className={`picker`}
role="dialog"
aria-modal="true"
aria-label={label}
>
{renderOptions(alwaysVisibleOptions)}
<div className="picker-sections">
{renderSections(visibleSections)}
{moreOptions.length > 0 && (
{hiddenSections.length > 0 && (
<Collapsible
label={t("labels.more_options")}
open={showMoreOptions}
@@ -181,7 +273,9 @@ function Picker<T>({
}}
className="picker-collapsible"
>
{renderOptions(moreOptions)}
<div className="picker-sections">
{renderSections(hiddenSections)}
</div>
</Collapsible>
)}
</div>
@@ -192,49 +286,45 @@ function Picker<T>({
export function IconPicker<T>({
value,
label,
options,
visibleSections,
hiddenSections,
onChange,
group = "",
numberOfOptionsToAlwaysShow,
}: {
label: string;
value: T;
options: readonly {
value: T;
text: string;
icon: JSX.Element;
keyBinding: string | null;
}[];
visibleSections: readonly PickerSection<T>[];
hiddenSections?: readonly PickerSection<T>[];
onChange: (value: T) => void;
numberOfOptionsToAlwaysShow?: number;
group?: string;
}) {
const [isActive, setActive] = React.useState(false);
const rPickerButton = React.useRef<any>(null);
const selectedOption = useMemo(
() =>
findOption(visibleSections, (option) => option.value === value) ??
findOption(hiddenSections ?? [], (option) => option.value === value),
[visibleSections, hiddenSections, value],
);
return (
<div>
<Popover.Root open={isActive} onOpenChange={(open) => setActive(open)}>
<Popover.Trigger
name={group}
type="button"
aria-label={label}
onClick={() => setActive(!isActive)}
ref={rPickerButton}
className={isActive ? "active" : ""}
>
{options.find((option) => option.value === value)?.icon}
{selectedOption?.icon}
</Popover.Trigger>
{isActive && (
<Picker
options={options}
visibleSections={visibleSections}
hiddenSections={hiddenSections}
value={value}
label={label}
onChange={onChange}
onClose={() => {
setActive(false);
}}
numberOfOptionsToAlwaysShow={numberOfOptionsToAlwaysShow}
/>
)}
</Popover.Root>
+1 -2
View File
@@ -650,8 +650,7 @@ const LayerUI = ({
};
const stripIrrelevantAppStateProps = (appState: AppState): UIAppState => {
const { startBoundElement, cursorButton, scrollX, scrollY, ...ret } =
appState;
const { cursorButton, scrollX, scrollY, ...ret } = appState;
return ret;
};
@@ -199,6 +199,7 @@ export default function LibraryMenuItems({
type: "everything",
elements: item.elements,
randomizeSeed: true,
preserveFrameChildrenOrder: true,
}).duplicatedElements,
};
});
@@ -26,13 +26,16 @@
background: var(--RadioGroup-background);
border: 1px solid var(--RadioGroup-border);
gap: 2px;
&__choice {
position: relative;
display: flex;
align-items: center;
justify-content: center;
width: 32px;
min-width: 20px;
height: 24px;
padding: 0 0.375rem;
color: var(--RadioGroup-choice-color-off);
background: var(--RadioGroup-choice-background-off);
+37 -33
View File
@@ -1,74 +1,78 @@
import React, { useEffect } from "react";
import { t } from "../i18n";
import "./Range.scss";
import type { AppClassProperties } from "../types";
export type RangeProps = {
updateData: (value: number) => void;
app: AppClassProperties;
label: React.ReactNode;
value: number;
onChange: (value: number) => void;
min?: number;
max?: number;
step?: number;
minLabel?: React.ReactNode;
hasCommonValue?: boolean;
testId?: string;
};
export const Range = ({ updateData, app, testId }: RangeProps) => {
export const Range = ({
label,
value,
onChange,
min = 0,
max = 100,
step = 10,
minLabel = min,
hasCommonValue = true,
testId,
}: RangeProps) => {
const rangeRef = React.useRef<HTMLInputElement>(null);
const valueRef = React.useRef<HTMLDivElement>(null);
const selectedElements = app.scene.getSelectedElements(app.state);
let hasCommonOpacity = true;
const firstElement = selectedElements.at(0);
const leastCommonOpacity = selectedElements.reduce((acc, element) => {
if (acc != null && acc !== element.opacity) {
hasCommonOpacity = false;
}
if (acc == null || acc > element.opacity) {
return element.opacity;
}
return acc;
}, firstElement?.opacity ?? null);
const value = leastCommonOpacity ?? app.state.currentItemOpacity;
useEffect(() => {
if (rangeRef.current && valueRef.current) {
const rangeElement = rangeRef.current;
const valueElement = valueRef.current;
const inputWidth = rangeElement.offsetWidth;
const thumbWidth = 15; // 15 is the width of the thumb
const thumbWidth =
parseFloat(
getComputedStyle(rangeElement).getPropertyValue(
"--slider-thumb-size",
),
) || 16;
const progress = ((value - min) / (max - min || 1)) * 100;
const position =
(value / 100) * (inputWidth - thumbWidth) + thumbWidth / 2;
(progress / 100) * (inputWidth - thumbWidth) + thumbWidth / 2;
valueElement.style.left = `${position}px`;
rangeElement.style.background = `linear-gradient(to right, var(--color-slider-track) 0%, var(--color-slider-track) ${value}%, var(--button-bg) ${value}%, var(--button-bg) 100%)`;
rangeElement.style.background = `linear-gradient(to right, var(--color-slider-track) 0%, var(--color-slider-track) ${progress}%, var(--button-bg) ${progress}%, var(--button-bg) 100%)`;
}
}, [value]);
}, [max, min, value]);
return (
<label className="control-label">
{t("labels.opacity")}
{label}
<div className="range-wrapper">
<input
style={{
["--color-slider-track" as string]: hasCommonOpacity
["--color-slider-track" as string]: hasCommonValue
? undefined
: "var(--button-bg)",
}}
ref={rangeRef}
type="range"
min="0"
max="100"
step="10"
min={min}
max={max}
step={step}
onChange={(event) => {
updateData(+event.target.value);
onChange(+event.target.value);
}}
value={value}
className="range-input"
data-testid={testId}
/>
<div className="value-bubble" ref={valueRef}>
{value !== 0 ? value : null}
{value !== min ? value : null}
</div>
<div className="zero-label">0</div>
<div className="zero-label">{minLabel}</div>
</div>
</label>
);
+1 -1
View File
@@ -2,7 +2,7 @@ import { useEffect, useRef } from "react";
import "./SVGLayer.scss";
import type { Trail } from "../animated-trail";
import type { Trail } from "../animatedTrail";
type SVGLayerProps = {
trails: Trail[];
@@ -206,7 +206,6 @@ const handleDimensionChange: DragInputCallbackType<
scene.getElementsIncludingDeleted(),
nextElementsInFrame,
latestElement,
app,
);
scene.replaceAllElements(updatedElements);
@@ -302,7 +301,6 @@ const handleDragFinished: DragFinishedCallbackType = ({
app.scene.getElementsIncludingDeleted(),
nextElementsInFrame,
latestElement,
app,
);
app.scene.replaceAllElements(updatedElements);
@@ -261,7 +261,6 @@ const handleDimensionChange: DragInputCallbackType<
scene.getElementsIncludingDeleted(),
nextElementsInFrame,
latestElement,
app,
);
scene.replaceAllElements(updatedElements);
@@ -416,7 +415,6 @@ const handleDragFinished: DragFinishedCallbackType = ({
app.scene.getElementsIncludingDeleted(),
nextElementsInFrame,
latestElement,
app,
);
app.scene.replaceAllElements(updatedElements);
@@ -361,12 +361,10 @@ describe("stats for a non-generic element", () => {
mouse.clickAt(20, 30);
const editor = await getTextEditor();
updateTextEditor(editor, "Hello!");
act(() => {
editor.blur();
});
Keyboard.exitTextEditor(editor);
const text = h.elements[0] as ExcalidrawTextElement;
mouse.clickOn(text);
API.setSelectedElements([text]);
elementStats = stats?.querySelector("#elementStats");
@@ -752,7 +750,7 @@ describe("frame resizing behavior", () => {
x: 0,
y: 0,
width: 100,
height: 100,
height: 103,
});
// Create a rectangle outside the frame
@@ -236,15 +236,40 @@ const MermaidToExcalidraw = ({
<Trans
i18nKey="mermaid.description"
flowchartLink={(el) => (
<a href="https://mermaid.js.org/syntax/flowchart.html">{el}</a>
<a
href="https://mermaid.js.org/syntax/flowchart.html"
target="_blank"
rel="noreferrer"
>
{el}
</a>
)}
sequenceLink={(el) => (
<a href="https://mermaid.js.org/syntax/sequenceDiagram.html">
<a
href="https://mermaid.js.org/syntax/sequenceDiagram.html"
target="_blank"
rel="noreferrer"
>
{el}
</a>
)}
classLink={(el) => (
<a href="https://mermaid.js.org/syntax/classDiagram.html">{el}</a>
<a
href="https://mermaid.js.org/syntax/classDiagram.html"
target="_blank"
rel="noreferrer"
>
{el}
</a>
)}
erdLink={(el) => (
<a
href="https://mermaid.js.org/syntax/entityRelationshipDiagram.html"
target="_blank"
rel="noreferrer"
>
{el}
</a>
)}
/>
</div>
@@ -94,7 +94,7 @@ export const convertMermaidToExcalidraw = async ({
}
}
const { elements, files } = ret;
const { elements, files = {} } = ret;
setError(null);
data.current = {
@@ -39,7 +39,7 @@ type InteractiveCanvasProps = {
visibleElements: readonly NonDeletedExcalidrawElement[];
selectedElements: readonly NonDeletedExcalidrawElement[];
allElementsMap: NonDeletedSceneElementsMap;
sceneNonce: number | undefined;
canvasNonce: string;
selectionNonce: number | undefined;
scale: number;
appState: InteractiveCanvasAppState;
@@ -54,6 +54,7 @@ type InteractiveCanvasProps = {
DOMAttributes<HTMLCanvasElement | HTMLDivElement>["onContextMenu"],
undefined
>;
onClick: Exclude<DOMAttributes<HTMLCanvasElement>["onClick"], undefined>;
onPointerMove: Exclude<
DOMAttributes<HTMLCanvasElement>["onPointerMove"],
undefined
@@ -213,6 +214,7 @@ const InteractiveCanvas = (props: InteractiveCanvasProps) => {
height={props.appState.height * props.scale}
ref={props.handleCanvasRef}
onContextMenu={props.onContextMenu}
onClick={props.onClick}
onPointerMove={props.onPointerMove}
onPointerUp={props.onPointerUp}
onPointerCancel={props.onPointerCancel}
@@ -277,10 +279,10 @@ const areEqual = (
// This could be further optimised if needed, as we don't have to render interactive canvas on each scene mutation
if (
prevProps.selectionNonce !== nextProps.selectionNonce ||
prevProps.sceneNonce !== nextProps.sceneNonce ||
prevProps.canvasNonce !== nextProps.canvasNonce ||
prevProps.scale !== nextProps.scale ||
// we need to memoize on elementsMap because they may have renewed
// even if sceneNonce didn't change (e.g. we filter elements out based
// even if canvasNonce didn't change (e.g. we filter elements out based
// on appState)
prevProps.elementsMap !== nextProps.elementsMap ||
prevProps.visibleElements !== nextProps.visibleElements ||
@@ -14,6 +14,7 @@ import type { RoughCanvas } from "roughjs/bin/canvas";
interface NewElementCanvasProps {
appState: AppState;
newElement: NonNullable<AppState["newElement"]>;
elementsMap: RenderableElementsMap;
allElementsMap: NonDeletedSceneElementsMap;
scale: number;
@@ -31,7 +32,7 @@ const NewElementCanvas = (props: NewElementCanvasProps) => {
{
canvas: canvasRef.current,
scale: props.scale,
newElement: props.appState.newElement,
newElement: props.newElement,
elementsMap: props.elementsMap,
allElementsMap: props.allElementsMap,
rc: props.rc,
@@ -23,7 +23,7 @@ type StaticCanvasProps = {
elementsMap: RenderableElementsMap;
allElementsMap: NonDeletedSceneElementsMap;
visibleElements: readonly NonDeletedExcalidrawElement[];
sceneNonce: number | undefined;
canvasNonce: string;
selectionNonce: number | undefined;
scale: number;
appState: StaticCanvasAppState;
@@ -110,10 +110,10 @@ const areEqual = (
nextProps: StaticCanvasProps,
) => {
if (
prevProps.sceneNonce !== nextProps.sceneNonce ||
prevProps.canvasNonce !== nextProps.canvasNonce ||
prevProps.scale !== nextProps.scale ||
// we need to memoize on elementsMap because they may have renewed
// even if sceneNonce didn't change (e.g. we filter elements out based
// even if canvasNonce didn't change (e.g. we filter elements out based
// on appState)
prevProps.elementsMap !== nextProps.elementsMap ||
prevProps.visibleElements !== nextProps.visibleElements
@@ -2,7 +2,7 @@
.excalidraw {
.dropdown-menu {
max-width: 16rem;
max-width: 20rem;
z-index: 1;
&--placement-top {
@@ -1,4 +1,5 @@
import { useEditorInterface } from "../App";
import { Ellipsify } from "../Ellipsify";
import { RadioGroup } from "../RadioGroup";
type Props<T> = {
@@ -12,6 +13,7 @@ type Props<T> = {
onChange: (value: T) => void;
children: React.ReactNode;
name: string;
icon?: React.ReactNode;
};
const DropdownMenuItemContentRadio = <T,>({
@@ -21,13 +23,17 @@ const DropdownMenuItemContentRadio = <T,>({
choices,
children,
name,
icon,
}: Props<T>) => {
const editorInterface = useEditorInterface();
return (
<>
<div className="dropdown-menu-item-base dropdown-menu-item-bare">
<label className="dropdown-menu-item__text">{children}</label>
{icon && <div className="dropdown-menu-item__icon">{icon}</div>}
<label className="dropdown-menu-item__text">
<Ellipsify>{children}</Ellipsify>
</label>
<RadioGroup
name={name}
value={value}
+155 -76
View File
@@ -69,6 +69,11 @@ const modifiedTablerIconProps: Opts = {
strokeLinejoin: "round",
} as const;
const arrowheadPreviewIconProps: Opts = {
width: 40,
height: 20,
} as const;
// -----------------------------------------------------------------------------
// tabler-icons: present
@@ -1181,6 +1186,26 @@ export const StrokeWidthExtraBoldIcon = createIcon(
modifiedTablerIconProps,
);
export const FreedrawPressureConstantIcon = createIcon(
<path
d="M4 10h12"
stroke="currentColor"
strokeWidth="3"
strokeLinecap="round"
strokeLinejoin="round"
/>,
modifiedTablerIconProps,
);
export const FreedrawPressureSensitiveIcon = createIcon(
<path
d="M4 10C6 9.5 10 8 16 7L16 13C10 12 6 10.5 4 10Z"
fill="currentColor"
stroke="none"
/>,
modifiedTablerIconProps,
);
export const StrokeStyleSolidIcon = React.memo(({ theme }: { theme: Theme }) =>
createIcon(
<path
@@ -1291,16 +1316,17 @@ export const ArrowheadNoneIcon = React.memo(
({ flip = false }: { flip?: boolean }) =>
createIcon(
<g
transform={flip ? "translate(24, 0) scale(-1, 1)" : ""}
transform={flip ? "translate(40, 0) scale(-1, 1)" : ""}
stroke="currentColor"
opacity={0.3}
strokeWidth={2}
fill="none"
strokeLinecap="round"
>
<path d="M12 12l-9 0" />
<path d="M21 9l-6 6" />
<path d="M21 15l-6 -6" />
<path d="M7,11 H19" />
<path d="M25,6 L33,16 M33,6 L25,16" />
</g>,
tablerIconProps,
arrowheadPreviewIconProps,
),
);
@@ -1312,57 +1338,12 @@ export const ArrowheadArrowIcon = React.memo(
stroke="currentColor"
strokeWidth={2}
fill="none"
strokeLinecap="round"
strokeLinejoin="round"
>
<path d="M34 10H6M34 10L27 5M34 10L27 15" />
<path d="M27.5 5L34.5 10L27.5 15" />
<path d="M7,11 H33 M23,5 L33,11 L23,17" />
</g>,
{ width: 40, height: 20 },
),
);
export const ArrowheadCircleIcon = React.memo(
({ flip = false }: { flip?: boolean }) =>
createIcon(
<g
stroke="currentColor"
fill="currentColor"
transform={flip ? "translate(40, 0) scale(-1, 1)" : ""}
>
<path d="M32 10L6 10" strokeWidth={2} />
<circle r="4" transform="matrix(-1 0 0 1 30 10)" />
</g>,
{ width: 40, height: 20 },
),
);
export const ArrowheadCircleOutlineIcon = React.memo(
({ flip = false }: { flip?: boolean }) =>
createIcon(
<g
stroke="currentColor"
fill="none"
transform={flip ? "translate(40, 0) scale(-1, 1)" : ""}
strokeWidth={2}
>
<path d="M26 10L6 10" />
<circle r="4" transform="matrix(-1 0 0 1 30 10)" />
</g>,
{ width: 40, height: 20 },
),
);
export const ArrowheadBarIcon = React.memo(
({ flip = false }: { flip?: boolean }) =>
createIcon(
<g transform={flip ? "translate(40, 0) scale(-1, 1)" : ""}>
<path
d="M34 10H5.99996M34 10L34 5M34 10L34 15"
stroke="currentColor"
strokeWidth={2}
fill="none"
/>
</g>,
{ width: 40, height: 20 },
arrowheadPreviewIconProps,
),
);
@@ -1373,11 +1354,12 @@ export const ArrowheadTriangleIcon = React.memo(
stroke="currentColor"
fill="currentColor"
transform={flip ? "translate(40, 0) scale(-1, 1)" : ""}
strokeLinejoin="round"
>
<path d="M32 10L6 10" strokeWidth={2} />
<path d="M27.5 5.5L34.5 10L27.5 14.5L27.5 5.5" />
<path d="M7,11 H23" strokeWidth={2} strokeLinecap="round" />
<path d="M23,5 L35,11 L23,17 Z" />
</g>,
{ width: 40, height: 20 },
arrowheadPreviewIconProps,
),
);
@@ -1390,12 +1372,43 @@ export const ArrowheadTriangleOutlineIcon = React.memo(
transform={flip ? "translate(40, 0) scale(-1, 1)" : ""}
strokeWidth={2}
strokeLinejoin="round"
strokeLinecap="round"
>
<path d="M6,9.5H27" />
<path d="M27,5L34,10L27,14Z" fill="none" />
<path d="M7,11 H23" />
<path d="M23,5 L35,11 L23,17 Z" />
</g>,
arrowheadPreviewIconProps,
),
);
{ width: 40, height: 20 },
export const ArrowheadCircleIcon = React.memo(
({ flip = false }: { flip?: boolean }) =>
createIcon(
<g
stroke="currentColor"
fill="currentColor"
transform={flip ? "translate(40, 0) scale(-1, 1)" : ""}
>
<path d="M7,11 H25" strokeWidth={2} strokeLinecap="round" />
<circle cx="29" cy="11" r="4" />
</g>,
arrowheadPreviewIconProps,
),
);
export const ArrowheadCircleOutlineIcon = React.memo(
({ flip = false }: { flip?: boolean }) =>
createIcon(
<g
stroke="currentColor"
fill="none"
transform={flip ? "translate(40, 0) scale(-1, 1)" : ""}
strokeWidth={2}
>
<path d="M7,11 H25" strokeLinecap="round" />
<circle cx="29" cy="11" r="4" />
</g>,
arrowheadPreviewIconProps,
),
);
@@ -1407,12 +1420,11 @@ export const ArrowheadDiamondIcon = React.memo(
fill="currentColor"
transform={flip ? "translate(40, 0) scale(-1, 1)" : ""}
strokeLinejoin="round"
strokeWidth={2}
>
<path d="M6,9.5H20" />
<path d="M27,5L34,10L27,14L20,9.5Z" />
<path d="M7,11 H21" strokeWidth={2} strokeLinecap="round" />
<path d="M21,11 L28,5 L35,11 L28,17 Z" />
</g>,
{ width: 40, height: 20 },
arrowheadPreviewIconProps,
),
);
@@ -1425,15 +1437,32 @@ export const ArrowheadDiamondOutlineIcon = React.memo(
transform={flip ? "translate(40, 0) scale(-1, 1)" : ""}
strokeLinejoin="round"
strokeWidth={2}
strokeLinecap="round"
>
<path d="M6,9.5H20" />
<path d="M27,5L34,10L27,14L20,9.5Z" />
<path d="M7,11 H21" />
<path d="M21,11 L28,5 L35,11 L28,17 Z" />
</g>,
{ width: 40, height: 20 },
arrowheadPreviewIconProps,
),
);
export const ArrowheadCrowfootIcon = React.memo(
export const ArrowheadBarIcon = React.memo(
({ flip = false }: { flip?: boolean }) =>
createIcon(
<g
transform={flip ? "translate(40, 0) scale(-1, 1)" : ""}
stroke="currentColor"
strokeWidth={2}
fill="none"
strokeLinecap="round"
>
<path d="M11,11 H31 M31,5 V17" />
</g>,
arrowheadPreviewIconProps,
),
);
export const ArrowheadCardinalityOneIcon = React.memo(
({ flip = false }: { flip?: boolean }) =>
createIcon(
<g
@@ -1443,13 +1472,13 @@ export const ArrowheadCrowfootIcon = React.memo(
strokeLinejoin="round"
strokeWidth={2}
>
<path d="M34,10 H6 M15,10 L7,5 M15,10 L7,15" />
<path d="M35,11 H7 M15,5 V17" />
</g>,
{ width: 40, height: 20 },
arrowheadPreviewIconProps,
),
);
export const ArrowheadCrowfootOneIcon = React.memo(
export const ArrowheadCardinalityManyIcon = React.memo(
({ flip = false }: { flip?: boolean }) =>
createIcon(
<g
@@ -1459,13 +1488,13 @@ export const ArrowheadCrowfootOneIcon = React.memo(
strokeLinejoin="round"
strokeWidth={2}
>
<path d="M34,10 H6 M15,10 L15,15 L15,5" />
<path d="M35,11 H7 M15,11 L7,5 M15,11 L7,17" />
</g>,
{ width: 40, height: 20 },
arrowheadPreviewIconProps,
),
);
export const ArrowheadCrowfootOneOrManyIcon = React.memo(
export const ArrowheadCardinalityOneOrManyIcon = React.memo(
({ flip = false }: { flip?: boolean }) =>
createIcon(
<g
@@ -1475,9 +1504,59 @@ export const ArrowheadCrowfootOneOrManyIcon = React.memo(
strokeLinejoin="round"
strokeWidth={2}
>
<path d="M34,10 H6 M15,10 L15,16 L15,4 M15,10 L7,5 M15,10 L7,15" />
<path d="M35,11 H7 M23,5 V17 M15,11 L7,5 M15,11 L7,17" />
</g>,
{ width: 40, height: 20 },
arrowheadPreviewIconProps,
),
);
export const ArrowheadCardinalityExactlyOneIcon = React.memo(
({ flip = false }: { flip?: boolean }) =>
createIcon(
<g
stroke="currentColor"
fill="none"
transform={flip ? "" : "translate(40, 0) scale(-1, 1)"}
strokeLinejoin="round"
strokeWidth={2}
>
<path d="M35,11 H7 M15,5 V17 M7,5 V17" />
</g>,
arrowheadPreviewIconProps,
),
);
export const ArrowheadCardinalityZeroOrOneIcon = React.memo(
({ flip = false }: { flip?: boolean }) =>
createIcon(
<g
stroke="currentColor"
fill="none"
transform={flip ? "" : "translate(40, 0) scale(-1, 1)"}
strokeLinejoin="round"
strokeWidth={2}
>
<path d="M35,11 H19 M11,11 H7 M7,5 V17" />
<circle cx="15" cy="11" r="4" />
</g>,
arrowheadPreviewIconProps,
),
);
export const ArrowheadCardinalityZeroOrManyIcon = React.memo(
({ flip = false }: { flip?: boolean }) =>
createIcon(
<g
stroke="currentColor"
fill="none"
transform={flip ? "" : "translate(40, 0) scale(-1, 1)"}
strokeLinejoin="round"
strokeWidth={2}
>
<path d="M35,11 H27 M19,11 H7 M15,11 L7,5 M15,11 L7,17" />
<circle cx="23" cy="11" r="4" />
</g>,
arrowheadPreviewIconProps,
),
);
@@ -39,7 +39,13 @@ import DropdownMenuItemCheckbox from "../dropdownMenu/DropdownMenuItemCheckbox";
import DropdownMenuItemContentRadio from "../dropdownMenu/DropdownMenuItemContentRadio";
import DropdownMenuItemLink from "../dropdownMenu/DropdownMenuItemLink";
import DropdownMenuSub from "../dropdownMenu/DropdownMenuSub";
import { GithubIcon, DiscordIcon, XBrandIcon, settingsIcon } from "../icons";
import {
GithubIcon,
DiscordIcon,
XBrandIcon,
settingsIcon,
emptyIcon,
} from "../icons";
import {
boltIcon,
DeviceDesktopIcon,
@@ -427,6 +433,39 @@ const PreferencesToggleToolLockItem = () => {
);
};
const PreferencesBoxSelectionModeItem = () => {
const { t } = useI18n();
const appState = useUIAppState();
const setAppState = useExcalidrawSetAppState();
return (
<DropdownMenuItemContentRadio<"contain" | "overlap">
name="boxSelectionMode"
icon={emptyIcon}
value={appState.boxSelectionMode}
onChange={(value) => {
setAppState({
boxSelectionMode: value,
});
}}
choices={[
{
value: "contain",
label: t("labels.boxSelectionContain"),
ariaLabel: t("labels.boxSelectionContain"),
},
{
value: "overlap",
label: t("labels.boxSelectionOverlap"),
ariaLabel: t("labels.boxSelectionOverlap"),
},
]}
>
{t("labels.boxSelectionMode")}
</DropdownMenuItemContentRadio>
);
};
const PreferencesToggleSnapModeItem = () => {
const { t } = useI18n();
const actionManager = useExcalidrawActionManager();
@@ -568,6 +607,7 @@ export const Preferences = ({
<DropdownMenuSub.Content className="excalidraw-main-menu-preferences-submenu">
{children || (
<>
<PreferencesBoxSelectionModeItem />
<PreferencesToggleToolLockItem />
<PreferencesToggleSnapModeItem />
<PreferencesToggleGridModeItem />
@@ -585,6 +625,7 @@ export const Preferences = ({
};
Preferences.ToggleToolLock = PreferencesToggleToolLockItem;
Preferences.BoxSelectionMode = PreferencesBoxSelectionModeItem;
Preferences.ToggleSnapMode = PreferencesToggleSnapModeItem;
Preferences.ToggleArrowBinding = PreferencesToggleArrowBindingItem;
Preferences.ToggleMidpointSnapping = PreferencesToggleMidpointSnappingItem;
+3 -6
View File
@@ -119,15 +119,12 @@ export const SHAPES = [
export const getToolbarTools = (app: AppClassProperties) => {
return app.state.preferredSelectionTool.type === "lasso"
? ([
SHAPES[0],
{
...SHAPES[1],
value: "lasso",
icon: SelectionIcon,
key: KEYS.V,
numericKey: KEYS["1"],
fillable: true,
toolbar: true,
},
...SHAPES.slice(1),
...SHAPES.slice(2),
] as const)
: SHAPES;
};
+8
View File
@@ -814,6 +814,14 @@ body.excalidraw-cursor-resize * {
.excalidraw__embeddable__outer {
width: 100%;
height: 100%;
}
.excalidraw__embeddable__content {
width: 100%;
height: 100%;
transform-origin: top left;
&,
& > * {
border-radius: var(--embeddable-radius);
}
+4 -6
View File
@@ -34,9 +34,10 @@
--popup-text-color: #000;
--popup-text-inverted-color: #fff;
--select-highlight-color: #{$color-blue-5};
--shadow-island: 0px 0px 0.9310142993927002px 0px rgba(0, 0, 0, 0.17),
0px 0px 3.1270833015441895px 0px rgba(0, 0, 0, 0.08),
0px 7px 14px 0px rgba(0, 0, 0, 0.05);
--shadow-island: 0px 0px 1px 0px rgba(0, 0, 0, 0.17),
0px 0px 3px 0px rgba(0, 0, 0, 0.08), 0px 7px 14px 0px rgba(0, 0, 0, 0.05);
--shadow-island-stronger: 0px 0px 1px 0px rgba(0, 0, 0, 0.17),
0px 0px 3px 0px rgba(0, 0, 0, 0.08), 0px 7px 14px 0px rgb(0 0 0 / 18%);
--button-hover-bg: var(--color-surface-high);
--button-active-bg: var(--color-surface-high);
@@ -210,9 +211,6 @@
--popup-text-color: #{$color-gray-4};
--popup-text-inverted-color: #2c2c2c;
--select-highlight-color: #{$color-blue-4};
--shadow-island: 0px 0px 0.9310142993927002px 0px rgba(0, 0, 0, 0.17),
0px 0px 3.1270833015441895px 0px rgba(0, 0, 0, 0.08),
0px 7px 14px 0px rgba(0, 0, 0, 0.05);
--modal-shadow: 0px 100px 80px rgba(0, 0, 0, 0.07),
0px 41.7776px 33.4221px rgba(0, 0, 0, 0.0503198),
+56 -4
View File
@@ -311,6 +311,48 @@ export const dataURLToString = (dataURL: DataURL) => {
return base64ToString(dataURL.slice(dataURL.indexOf(",") + 1));
};
const getImageFileDimensions = async (file: File) => {
const browserURL = typeof window !== "undefined" ? window.URL : undefined;
let objectURL: string | null = null;
let imageSource: string;
try {
imageSource = browserURL?.createObjectURL
? (objectURL = browserURL.createObjectURL(file))
: await getDataURL(file);
} catch {
objectURL = null;
imageSource = await getDataURL(file);
}
return new Promise<{ width: number; height: number }>((resolve, reject) => {
const image = new Image();
const cleanup = () => {
image.onload = null;
image.onerror = null;
if (objectURL && browserURL?.revokeObjectURL) {
browserURL.revokeObjectURL(objectURL);
}
};
image.onload = () => {
cleanup();
resolve({
width: image.naturalWidth || image.width,
height: image.naturalHeight || image.height,
});
};
image.onerror = (error) => {
cleanup();
reject(error);
};
image.src = imageSource;
});
};
export const resizeImageFile = async (
file: File,
opts: {
@@ -324,6 +366,20 @@ export const resizeImageFile = async (
return file;
}
if (!isSupportedImageFile(file)) {
throw new Error("Error: unsupported file type", { cause: "UNSUPPORTED" });
}
if (!opts.outputType || opts.outputType === file.type) {
const dimensions = await getImageFileDimensions(file);
if (
Math.max(dimensions.width, dimensions.height) <= opts.maxWidthOrHeight
) {
return file;
}
}
const [pica, imageBlobReduce] = await Promise.all([
import("pica").then((res) => res.default),
// a wrapper for pica for better API
@@ -347,10 +403,6 @@ export const resizeImageFile = async (
};
}
if (!isSupportedImageFile(file)) {
throw new Error("Error: unsupported file type", { cause: "UNSUPPORTED" });
}
return new File(
[await reduce.toBlob(file, { max: opts.maxWidthOrHeight, alpha: true })],
file.name,
+7 -1
View File
@@ -6,6 +6,7 @@ import {
MIME_TYPES,
cloneJSON,
SVG_DOCUMENT_PREAMBLE,
arrayToMap,
} from "@excalidraw/common";
import { getNonDeletedElements } from "@excalidraw/element";
@@ -49,6 +50,7 @@ export const prepareElementsForExport = (
exportSelectionOnly: boolean,
) => {
elements = getNonDeletedElements(elements);
const elementsMap = arrayToMap(elements);
const isExportingSelection =
exportSelectionOnly &&
@@ -71,7 +73,11 @@ export const prepareElementsForExport = (
isFrameLikeElement(exportedElements[0])
) {
exportingFrame = exportedElements[0];
exportedElements = getElementsOverlappingFrame(elements, exportingFrame);
exportedElements = getElementsOverlappingFrame(
elements,
exportingFrame,
elementsMap,
);
} else if (exportedElements.length > 1) {
exportedElements = getSelectedElements(
elements,
-4
View File
@@ -20,10 +20,6 @@ export const resaveAsImageWithScene = async (
) => {
const fileHandleType = getFileHandleType(fileHandle);
if (Math.random() < 1) {
throw new Error("OLALALALA");
}
if (!isImageFileHandleType(fileHandleType)) {
throw new Error(
"fileHandle should exist and should be of type svg or png when resaving",
+152 -24
View File
@@ -1,4 +1,4 @@
import { isFiniteNumber, pointFrom } from "@excalidraw/math";
import { isFiniteNumber, isValidPoint, pointFrom } from "@excalidraw/math";
import {
type CombineBrandsIfNeeded,
@@ -22,6 +22,7 @@ import {
import {
calculateFixedPointForNonElbowArrowBinding,
getNonDeletedElements,
normalizeArrowhead,
isPointInElement,
isValidPolygon,
projectFixedPointOntoDiagonal,
@@ -95,6 +96,69 @@ type RestoredAppState = Omit<
"offsetTop" | "offsetLeft" | "width" | "height"
>;
const MAX_ARROW_PX = 75_000;
const restoreLinearElementPoints = (
points: unknown,
width: unknown,
height: unknown,
): LocalPoint[] => {
const restoredPoints = Array.isArray(points)
? points.reduce<LocalPoint[]>((acc, point) => {
if (isValidPoint(point)) {
acc.push(pointFrom<LocalPoint>(point[0], point[1]));
}
return acc;
}, [])
: [];
return restoredPoints.length < 2
? [
pointFrom<LocalPoint>(0, 0),
pointFrom<LocalPoint>(
isFiniteNumber(width) ? width : 0,
isFiniteNumber(height) ? height : 0,
),
]
: restoredPoints;
};
const restoreFreedrawPoints = (
points: unknown,
pressures: unknown,
): {
points: LocalPoint[];
pressures: number[];
} => {
if (!Array.isArray(points)) {
return {
points: [],
pressures: [],
};
}
const pressureValues: readonly unknown[] = Array.isArray(pressures)
? pressures
: [];
const restoredPoints: LocalPoint[] = [];
const restoredPressures: number[] = [];
points.forEach((point, index) => {
if (isValidPoint(point)) {
restoredPoints.push(pointFrom<LocalPoint>(point[0], point[1]));
if (index in pressureValues) {
const pressure = pressureValues[index];
restoredPressures.push(isFiniteNumber(pressure) ? pressure : 0.5);
}
}
});
return {
points: restoredPoints,
pressures: restoredPressures,
};
};
export const AllowedExcalidrawActiveTools: Record<
AppState["activeTool"]["type"],
boolean
@@ -250,7 +314,9 @@ const repairBinding = <T extends ExcalidrawArrowElement>(
};
}
console.error(`could not repair binding for element`);
console.error(
`Could not repair binding for element "${boundElement?.id}" out of (${elementsMap?.size}) elements`,
);
} catch (error) {
console.error("Error repairing binding:", error);
}
@@ -409,10 +475,15 @@ export const restoreElement = (
return element;
case "freedraw": {
const { points, pressures } = restoreFreedrawPoints(
element.points,
element.pressures,
);
return restoreElementWithProperties(element, {
points: element.points,
points,
simulatePressure: element.simulatePressure,
pressures: element.pressures,
pressures,
});
}
case "image":
@@ -426,17 +497,24 @@ export const restoreElement = (
// @ts-ignore LEGACY type
// eslint-disable-next-line no-fallthrough
case "draw":
const { startArrowhead = null, endArrowhead = null } = element;
const startArrowhead = normalizeArrowhead(element.startArrowhead);
const endArrowhead = normalizeArrowhead(element.endArrowhead);
let x = element.x;
let y = element.y;
let points = // migrate old arrow model to new one
!Array.isArray(element.points) || element.points.length < 2
? [pointFrom(0, 0), pointFrom(element.width, element.height)]
: element.points;
let points = restoreLinearElementPoints(
element.points,
element.width,
element.height,
);
if (points[0][0] !== 0 || points[0][1] !== 0) {
({ points, x, y } =
LinearElementEditor.getNormalizeElementPointsAndCoords(element));
LinearElementEditor.getNormalizeElementPointsAndCoords({
...element,
points,
x: x ?? 0,
y: y ?? 0,
} as ExcalidrawLinearElement));
}
return restoreElementWithProperties(element, {
@@ -450,7 +528,7 @@ export const restoreElement = (
y,
...(isLineElement(element)
? {
polygon: isValidPolygon(element.points)
polygon: isValidPolygon(points)
? element.polygon ?? false
: false,
}
@@ -458,25 +536,36 @@ export const restoreElement = (
...getSizeFromPoints(points),
});
case "arrow": {
const { startArrowhead = null, endArrowhead = "arrow" } = element;
const x: number | undefined = element.x;
const y: number | undefined = element.y;
const points: readonly LocalPoint[] | undefined = // migrate old arrow model to new one
!Array.isArray(element.points) || element.points.length < 2
? [pointFrom(0, 0), pointFrom(element.width, element.height)]
: element.points;
const startArrowhead = normalizeArrowhead(element.startArrowhead);
const endArrowhead =
element.endArrowhead === undefined
? "arrow"
: normalizeArrowhead(element.endArrowhead);
const x = element.x as number | undefined;
const y = element.y as number | undefined;
const points = restoreLinearElementPoints(
element.points,
element.width,
element.height,
);
const elementWithRestoredPoints = {
...element,
points,
x: x ?? 0,
y: y ?? 0,
} as ExcalidrawArrowElement;
const base = {
type: element.type,
startBinding: repairBinding(
element as ExcalidrawArrowElement,
elementWithRestoredPoints,
element.startBinding,
targetElementsMap,
existingElementsMap,
"start",
),
endBinding: repairBinding(
element as ExcalidrawArrowElement,
elementWithRestoredPoints,
element.endBinding,
targetElementsMap,
existingElementsMap,
@@ -485,8 +574,8 @@ export const restoreElement = (
startArrowhead,
endArrowhead,
points,
x,
y,
x: x ?? 0,
y: y ?? 0,
elbowed: (element as ExcalidrawArrowElement).elbowed,
...getSizeFromPoints(points),
};
@@ -505,12 +594,44 @@ export const restoreElement = (
})
: restoreElementWithProperties(element as ExcalidrawArrowElement, base);
return {
const normalizedRestoredElement = {
...restoredElement,
...LinearElementEditor.getNormalizeElementPointsAndCoords(
restoredElement,
),
};
// Last resort fix for extremely large arrows
if (
normalizedRestoredElement.width > MAX_ARROW_PX ||
normalizedRestoredElement.height > MAX_ARROW_PX
) {
console.error(
`Removing extremely large arrow ${
normalizedRestoredElement.id
} (type: ${
isElbowArrow(normalizedRestoredElement) ? "elbow" : "simple"
}, width: ${normalizedRestoredElement.width}, height: ${
normalizedRestoredElement.height
}, x: ${normalizedRestoredElement.x}, y: ${
normalizedRestoredElement.y
})`,
);
return {
...normalizedRestoredElement,
x: 0,
y: 0,
width: 100,
height: 100,
points: [
pointFrom<LocalPoint>(0, 0),
pointFrom<LocalPoint>(100, 100),
],
isDeleted: true,
};
}
return normalizedRestoredElement;
}
// generic elements
@@ -658,6 +779,7 @@ export const restoreElements = <T extends ExcalidrawElement>(
const existingElementsMap = existingElements
? arrayToMap(existingElements)
: null;
const restoredElements = syncInvalidIndices(
(targetElements || []).reduce((elements, element) => {
// filtering out selection, which is legacy, no longer kept in elements,
@@ -754,7 +876,7 @@ export const restoreElements = <T extends ExcalidrawElement>(
}
}
// NOTE (mtolmacs): Temporary fix for extremely large arrows
// NOTE (mtolmacs): Temporary fix for invalid/self-bound elbow arrows
// Need to iterate again so we have attached text nodes in elementsMap
return restoredElements.map((element) => {
if (
@@ -928,6 +1050,12 @@ export const restoreAppState = (
: defaultValue;
}
const boxSelectionMode =
appState.boxSelectionMode ?? localAppState?.boxSelectionMode;
if (boxSelectionMode !== undefined) {
nextAppState.boxSelectionMode = boxSelectionMode;
}
return {
...nextAppState,
cursorButton: localAppState?.cursorButton || "up",
+3 -5
View File
@@ -33,9 +33,7 @@ import type { Bounds } from "@excalidraw/common";
import type { GlobalPoint, LineSegment } from "@excalidraw/math/types";
import type { ElementsMap, ExcalidrawElement } from "@excalidraw/element/types";
import { AnimatedTrail } from "../animated-trail";
import type { AnimationFrameHandler } from "../animation-frame-handler";
import { AnimatedTrail } from "../animatedTrail";
import type App from "../components/App";
@@ -43,8 +41,8 @@ export class EraserTrail extends AnimatedTrail {
private elementsToErase: Set<ExcalidrawElement["id"]> = new Set();
private groupsToErase: Set<ExcalidrawElement["id"]> = new Set();
constructor(animationFrameHandler: AnimationFrameHandler, app: App) {
super(animationFrameHandler, app, {
constructor(app: App) {
super(app, {
streamline: 0.2,
size: 5,
keepHead: true,

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