Compare commits

...

67 Commits

Author SHA1 Message Date
Márk Tolmács
f55ecb96cc
fix: Mobile arrow point drag broken (#9998)
* fix: Mobile bound arrow point drag broken

* fix:Check real point
2025-09-19 19:41:03 +02:00
David Luzar
a6a32b9b29
fix: align MQ breakpoints and always use editor dimensions (#9991)
* fix: align MQ breakpoints and always use editor dimensions

* naming

* update snapshots
2025-09-17 07:57:10 +00:00
Márk Tolmács
ac0d3059dc
fix: Use the right polygon enclosure test (#9979) 2025-09-15 10:07:37 +02:00
Christopher Tangonan
1161f1b8ba
fix: eraser can handle dots without regressing prior performance improvements (#9946)
Co-authored-by: Márk Tolmács <mark@lazycat.hu>
2025-09-14 11:33:43 +00:00
Ryan Di
204e06b77b
feat: compact layout for tablets (#9910)
* feat: allow the hiding of top picks

* feat: allow the hiding of default fonts

* refactor: rename to compactMode

* feat: introduce layout (incomplete)

* tweak icons

* do not show border

* lint

* add isTouchMobile to device

* add isTouchMobile to device

* refactor to use showCompactSidebar instead

* hide library label in compact

* fix icon color in dark theme

* fix library and share btns getting hidden in smaller tablet widths

* update tests

* use a smaller gap between shapes

* proper fix of range

* quicker switching between different popovers

* to not show properties panel at all when editing text

* fix switching between different popovers for texts

* fix popover not closable and font search auto focus

* change properties for a new or editing text

* change icon for more style settings

* use bolt icon for extra actions

* fix breakpoints

* use rem for icon sizes

* fix tests

* improve switching between triggers (incomplete)

* improve trigger switching (complete)

* clean up code

* put compact into app state

* fix button size

* remove redundant PanelComponentProps["compactMode"]

* move fontSize UI on top

* mobile detection (breakpoints incomplete)

* tweak compact mode detection

* rename appState prop & values

* update snapshots

---------

Co-authored-by: dwelle <5153846+dwelle@users.noreply.github.com>
2025-09-12 10:18:31 +10:00
David Luzar
414182f599
fix: normalize file on paste/drop (#9959) 2025-09-10 17:59:02 +02:00
David Luzar
b9d27d308e
fix: pasting not working in firefox (#9947) 2025-09-06 22:51:23 +02:00
Omar Brikaa
3bdaafe4b5
feat: [cont.] support inserting multiple images (#9875)
* feat: support inserting multiple images

* Initial

* handleAppOnDrop, onImageToolbarButtonClick, pasteFromClipboard

* Initial get history working

* insertMultipleImages -> insertImages

* Bug fixes, improvements

* Remove redundant branch

* Refactor addElementsFromMixedContentPaste

* History, drag & drop bug fixes

* Update snapshots

* Remove redundant try-catch

* Refactor pasteFromClipboard

* Plain paste check in mermaid paste

* Move comment

* processClipboardData -> insertClipboardContent

* Redundant variable

* Redundant variable

* Refactor insertImages

* createImagePlaceholder -> newImagePlaceholder

* Get rid of unneeded NEVER schedule, filter out failed images

* Trigger CI

* Position placeholders before initializing

* Don't mutate scene with positionElementsOnGrid, captureUpdate: CaptureUpdateAction.IMMEDIATELY

* Comment

* Move positionOnGrid out of file

* Rename file

* Get rid of generic

* Initial tests

* More asserts, test paste

* Test image tool

* De-duplicate

* Stricter assert, move rest of logic outside of waitFor

* Modify history tests

* De-duplicate update snapshots

* Trigger CI

* Fix package build

* Make setupImageTest more explicit

* Re-introduce generic to use latest placeholder versions

* newElementWith instead of mutateElement to delete failed placeholder

* Insert failed images separately with CaptureUpdateAction.NEVER

* Refactor

* Don't re-order elements

* WIP

* Get rid of 'never' for failed

* refactor type check

* align max file size constant

* make grid padding scale to zoom

---------

Co-authored-by: dwelle <5153846+dwelle@users.noreply.github.com>
2025-09-01 17:31:24 +02:00
Christopher Tangonan
ae89608985
fix: bound text rotation across alignments (#9914)
Co-authored-by: A-Mundanilkunathil <aaronchackom2002@gmail.com>
2025-08-29 12:31:23 +02:00
Ryan Di
3085f4af81
fix: tighten distance for double tap text creation (#9889) 2025-08-22 18:12:51 +02:00
David Luzar
531f3e5524
fix: restore from invalid fixedSegments & type-safer point updates (#9899)
* fix: restore from invalid fixedSegments & type-safer point updates

* fix: Type updates and throw for invalid point states

---------

Co-authored-by: Mark Tolmacs <mark@lazycat.hu>
2025-08-22 15:45:58 +00:00
David Luzar
90ec2739ae
fix: calling toLowerCase on potentially undefined navigator.* values (#9901) 2025-08-22 17:37:16 +02:00
David Luzar
f29e9df72d
chore: bump mermaid-to-excalidraw to 1.1.3 (#9898) 2025-08-21 20:58:04 +02:00
Marcel Mraz
b5ad7ae4e3
fix: even deltas with version & version nonce are valid (#9897) 2025-08-21 16:09:19 +02:00
David Luzar
c78e4aab7f
chore: tweak title & remove timeout (#9883) 2025-08-20 14:09:20 +02:00
Ryan Di
b4903a7eab
feat: drag, resize, and rotate after selecting in lasso (#9732)
* feat: drag, resize, and rotate after selecting in lasso

* alternative ux: drag with lasso right away

* fix: lasso dragging should snap too

* fix: alt+cmd getting stuck

* test: snapshots

* alternatvie: keep lasso drag to only mobile

* alternative: drag after selection on PCs

* improve mobile dection

* add mobile lasso icon

* add default selection tool

* render according to default selection tool

* return to default selection tool after deletion

* reset to default tool after clearing out the canvas

* return to default tool after eraser toggle

* if default lasso, close lasso toggle

* finalize to default selection tool

* toggle between laser and default selection

* return to default selection tool after creation

* double click to add text when using default selection tool

* set to default selection tool after unlocking tool

* paste to center on touch screen

* switch to default selection tool after pasting

* lint

* fix tests

* show welcome screen when using default selection tool

* fix tests

* fix snapshots

* fix context menu not opening

* prevent potential displacement issue

* prevent element jumping during lasso selection

* fix dragging on mobile

* use same selection icon

* fix alt+cmd lasso getting cut off

* fix: shortcut handling

* lint

---------

Co-authored-by: dwelle <5153846+dwelle@users.noreply.github.com>
2025-08-20 00:03:02 +02:00
zsviczian
c6f8ef9ad2
fix: Scene deleted after pica image resize failure (#9879)
Revert change in private updateImageCache
2025-08-18 11:45:05 +02:00
Marcel Mraz
2535d73054
feat: apply deltas API (#9869) 2025-08-15 15:25:56 +02:00
David Luzar
dda3affcb0
fix: do not strip invisible elements from array (#9844) 2025-08-12 11:56:11 +02:00
Marcel Mraz
54c148f390
fix: text restore & deletion issues (#9853) 2025-08-12 09:27:04 +02:00
zsviczian
cc8e490c75
fix: do not auto-add elements to locked frame (#9851)
* Do not return locked frames when filtering for top level frame

* lint

* lint

* lint
2025-08-11 11:52:44 +02:00
Marcel Mraz
9036812b6d
fix: editing linear element (#9839) 2025-08-08 09:30:11 +02:00
Marcel Mraz
df25de7e68
feat: fix delta apply to issues (#9830) 2025-08-07 15:38:58 +02:00
David Luzar
a3763648fe
chore: update title (#9814)
* chore: update title

* update meta tag

* lint
2025-08-01 17:17:42 +02:00
Ryan Di
178eca5828
fix: add frame clipping to new element canvas (#9794)
* fix: add frame clipping to new element canvas

* cleanup save/restore

---------

Co-authored-by: dwelle <5153846+dwelle@users.noreply.github.com>
2025-07-31 12:10:59 +00:00
Ryan Di
cb33de25f4
feat: allow a frame to snap to its children (#9795) 2025-07-31 13:58:29 +02:00
Omar Brikaa
37ad85cbaf
fix: Fix the root cause of flushSync flickering (#9791)
* More reliable width and height change detection

* Put the dimensions useEffect before the scene render one, just in case
2025-07-27 23:52:07 +02:00
Márk Tolmács
d6a934ed19
chore: Remove editingLinearElement (#9771)
Co-authored-by: dwelle <5153846+dwelle@users.noreply.github.com>
2025-07-24 17:02:21 +02:00
Omar Brikaa
416da62138
fix: multiple line editor bugs (#9760)
Co-authored-by: Mark Tolmacs <mark@lazycat.hu>
2025-07-24 09:11:04 +02:00
Omar Brikaa
f38f381989
fix: Remove flushSync from alt-lasso and elbow dragging (#9734)
* Remove lasso flushSync

* Remove selectedLinearElement flushSync

* Early return
2025-07-23 23:39:16 +02:00
Ryan Di
e5e07260c6
fix: improve line creation ux on touch screens (#9740)
* fix: awkward point adding and removing on touch device

* feat: move finalize to next to last point

* feat: on touch screen, click would create a default line/arrow

* fix: make default adaptive to zoom

* fix: increase padding to avoid cutoffs

* refactor: simplify

* fix: only use bigger padding when needed

* center arrow horizontally on pointer

* increase min drag distance before we start 2-point-arrow-drag-creating

* do not render 0-width arrow while creating

* dead code

* fix tests

* fix: remove redundant code

* do not enter line editor on creation

---------

Co-authored-by: dwelle <5153846+dwelle@users.noreply.github.com>
2025-07-23 18:49:56 +10:00
Christopher Tangonan
8492b144b0
test: added test file for distribute (#9754) 2025-07-17 19:52:16 +02:00
Marcel Mraz
e46f038132
feat: expose applyTo options, don't commit empty text element (#9744)
* Expose applyTo options, skip re-draw for empty text

* Don't commit empty text elements
2025-07-17 15:22:32 +02:00
David Luzar
678dff25ed
fix: ellipsify MainMenu and CommandPalette items (#9743)
* fix: ellipsify MainMenu and CommandPalette items

* fix lint
2025-07-15 12:59:55 +02:00
Christopher Tangonan
0cfa53b764
fix: aligning and distributing elements and nested groups while editing a group (#9721) 2025-07-15 12:43:42 +02:00
David Luzar
cde46793f8
feat: support timestamps for youtube video emebds (#9737) 2025-07-13 19:19:10 +02:00
Aakansha Doshi
2d127f8c22
docs: fix broken update scene button example in docs (#9726)
fix: update scene example in docs
2025-07-08 19:29:44 +05:30
Soham Kulkarni
4eadb891f8
fix(toast): prevent toast from re-rendering and resetting timeout Fixes #9714 (#9715)
* Update App.tsx

* fix: lint

---------

Co-authored-by: Ryan Di <ryan.weihao.di@gmail.com>
2025-07-03 17:07:26 +10:00
Marcel Mraz
258605d1d5
chore: release multiple packages (#9698) 2025-06-30 12:19:15 +02:00
Márk Tolmács
c141500400
chore: Relocate visualdebug so ESLint doesn't complain (#9668) 2025-06-18 14:45:51 +02:00
Márk Tolmács
8e27de2cdc
fix: Frame dimensions change by stats don't include new elements (#9568) 2025-06-16 14:07:03 +02:00
Márk Tolmács
0a19c93509
fix: Bindings at partially overlapping binding areas (#9536) 2025-06-16 12:30:59 +02:00
Márk Tolmács
958597dfaa
chore: Refactor doBoundsIntersect (#9657) 2025-06-16 12:30:42 +02:00
Marcel Mraz
058918f8e5
feat: capture images after they initialize (#9643)
Co-authored-by: dwelle <5153846+dwelle@users.noreply.github.com>
2025-06-15 23:43:14 +02:00
Spawn
3f194918e6
feat: add mulitplatform Docker image support (#9594) 2025-06-15 20:11:37 +02:00
Ryan Di
93c92d13e9
feat: wrap texts from stats panel (#9552) 2025-06-14 13:05:24 +02:00
zsviczian
84e96e9393
fix: move doBoundsIntersect from element/src/bounds.ts to common/math/src/utils.ts (#9650)
move doBoundsIntersect to math/utils

Co-authored-by: dwelle <5153846+dwelle@users.noreply.github.com>
2025-06-14 11:01:30 +00:00
zsviczian
320af405e9
fix: move elementCenterPoint from common/src/utils.ts to element/src/bounds.ts (#9647)
move elementCenterPoint from utils to bounds.ts
2025-06-14 12:49:22 +02:00
Marcel Mraz
60512f13d5 Fix broken history when eleemnt in update scene are optional 2025-06-14 12:29:58 +02:00
Márk Tolmács
f0458cc216
fix: Mid-point for rounded linears are not precisely centered (#9544) 2025-06-12 21:08:37 +02:00
Márk Tolmács
9f3fdf5505
fix: Test hook usage in production code (#9645) 2025-06-12 10:39:50 +02:00
Márk Tolmács
f42e1ab64e
perf: Improve elbow arrow indirect binding logic (#9624) 2025-06-11 19:15:48 +02:00
Ashwin Temkar
18808481fd
fix: set cursor to auto when not hovering a point on linear element (#9642)
* fix: set cursor to auto when not hovering a point on linear element #9628

* Simplify hover test for cursor

* Add back comment

* Fix test for hit testing

---------

Co-authored-by: Mark Tolmacs <mark@lazycat.hu>
2025-06-11 16:52:02 +02:00
Marcel Mraz
a7b64f02b3
fix: remove image preview on image insertion (#9626)
Co-authored-by: dwelle <5153846+dwelle@users.noreply.github.com>
2025-06-10 21:31:11 +02:00
Marcel Mraz
0d4abd1ddc
fix: add history capture for paste and drop of images and embeds (#9605) 2025-06-10 14:28:16 +02:00
Sachintha Lakmin
9e77373c81
fix: add generic font family fallbacks before Segoe UI Emoji to fix glyph rendering on windows (#9425) 2025-06-10 13:43:39 +02:00
Marcel Mraz
d108053351
feat: various delta improvements (#9571) 2025-06-09 09:55:35 +02:00
David Luzar
d4e85a9480
feat: use enter to edit line points & update hints (#9630)
feat: use enter to edit line points & update hints
2025-06-07 18:05:20 +02:00
David Luzar
08cd4c4f9a
test: improve getTextEditor test helper (#9629)
* test: improve getTextEditor test helper

* fix test
2025-06-07 17:45:37 +02:00
cheapster
469caadb87
fix: prevent double-click to edit/create text scenarios on line (#9597)
* fix : double click on line enables line editor

* fix : prevent double-click to edit/create text
when inside line editor

* refactor: use lineCheck instead of arrowCheck in
doubleClick handler to align with updated logic

* fix: replace negative arrowCheck with lineCheck in
dbl click handler and fix double-click bind text
test in linearElementEditor tests

* clean up test

* simplify check

* add tests

* prevent text editing on dblclick when inside arrow editor

---------

Co-authored-by: dwelle <5153846+dwelle@users.noreply.github.com>
2025-06-07 17:08:35 +02:00
Márk Tolmács
ca1a4f25e7
feat: Precise hit testing (#9488) 2025-06-07 12:56:32 +02:00
Sujal Gupta
56c05b3099
fix: prevent search menu from opening when dialog is open (#9279) 2025-06-03 15:53:00 +02:00
Aarav Dayal
6c0ff7fc5c
docs: added the correct CSS import for nextjs dynamic first import integration example (#9584)
Added the correct CSS import for nextjs dynamic first import integration example

This is with reference to [this](https://github.com/excalidraw/excalidraw/issues/9562)
2025-05-29 22:03:20 +02:00
Muhammad Khuzaima Umair
7cad3645a0
perf: Simplify normalizeRadians function (#9572)
Co-authored-by: Mark Tolmacs <mark@lazycat.hu>
2025-05-28 15:58:42 +02:00
Márk Tolmács
5921ebc416
fix: Regression in long press context menu closure (#9588) 2025-05-28 13:38:47 +02:00
Márk Tolmács
864353be5f
feat: Try to preserve line angle on SHIFT+drag (#9570) 2025-05-27 12:39:45 +02:00
cheapster
db2911c6c4
fix: ghost point issue when moving a shape after dragging a point in the line editor (#9530)
fix: ghost point issue when moving a shape after
dragging a point in the line editor
2025-05-26 21:34:41 +02:00
193 changed files with 14125 additions and 7476 deletions

View File

@ -24,4 +24,4 @@ jobs:
- name: Auto release
run: |
yarn add @actions/core -W
yarn autorelease
yarn release --tag=next --non-interactive

View File

@ -1,55 +0,0 @@
name: Auto release excalidraw preview
on:
issue_comment:
types: [created, edited]
jobs:
Auto-release-excalidraw-preview:
name: Auto release preview
if: github.event.comment.body == '@excalibot trigger release' && github.event.issue.pull_request
runs-on: ubuntu-latest
steps:
- name: React to release comment
uses: peter-evans/create-or-update-comment@v1
with:
token: ${{ secrets.PUSH_TRANSLATIONS_COVERAGE_PAT }}
comment-id: ${{ github.event.comment.id }}
reactions: "+1"
- name: Get PR SHA
id: sha
uses: actions/github-script@v4
with:
result-encoding: string
script: |
const { owner, repo, number } = context.issue;
const pr = await github.pulls.get({
owner,
repo,
pull_number: number,
});
return pr.data.head.sha
- uses: actions/checkout@v2
with:
ref: ${{ steps.sha.outputs.result }}
fetch-depth: 2
- name: Setup Node.js 18.x
uses: actions/setup-node@v2
with:
node-version: 18.x
- name: Set up publish access
run: |
npm config set //registry.npmjs.org/:_authToken ${NPM_TOKEN}
env:
NPM_TOKEN: ${{ secrets.NPM_TOKEN }}
- name: Auto release preview
id: "autorelease"
run: |
yarn add @actions/core -W
yarn autorelease preview ${{ github.event.issue.number }}
- name: Post comment post release
if: always()
uses: peter-evans/create-or-update-comment@v1
with:
token: ${{ secrets.PUSH_TRANSLATIONS_COVERAGE_PAT }}
issue-number: ${{ github.event.issue.number }}
body: "@${{ github.event.comment.user.login }} ${{ steps.autorelease.outputs.result }}"

View File

@ -17,9 +17,14 @@ jobs:
with:
username: ${{ secrets.DOCKER_USERNAME }}
password: ${{ secrets.DOCKER_PASSWORD }}
- name: Set up QEMU
uses: docker/setup-qemu-action@v3
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Build and push
uses: docker/build-push-action@v3
uses: docker/build-push-action@v5
with:
context: .
push: true
tags: excalidraw/excalidraw:latest
platforms: linux/amd64, linux/arm64, linux/arm/v7

View File

@ -1,4 +1,4 @@
FROM node:18 AS build
FROM --platform=${BUILDPLATFORM} node:18 AS build
WORKDIR /opt/node_app
@ -6,13 +6,14 @@ COPY . .
# do not ignore optional dependencies:
# Error: Cannot find module @rollup/rollup-linux-x64-gnu
RUN yarn --network-timeout 600000
RUN --mount=type=cache,target=/root/.cache/yarn \
npm_config_target_arch=${TARGETARCH} yarn --network-timeout 600000
ARG NODE_ENV=production
RUN yarn build:app:docker
RUN npm_config_target_arch=${TARGETARCH} yarn build:app:docker
FROM nginx:1.27-alpine
FROM --platform=${TARGETPLATFORM} nginx:1.27-alpine
COPY --from=build /opt/node_app/excalidraw-app/build /usr/share/nginx/html

View File

@ -363,13 +363,7 @@ This API has the below signature. It sets the `tool` passed in param as the acti
```ts
(
tool: (
| (
| { type: Exclude<ToolType, "image"> }
| {
type: Extract<ToolType, "image">;
insertOnCanvasDirectly?: boolean;
}
)
| { type: ToolType }
| { type: "custom"; customType: string }
) & { locked?: boolean },
) => {};
@ -377,7 +371,7 @@ This API has the below signature. It sets the `tool` passed in param as the acti
| Name | Type | Default | Description |
| --- | --- | --- | --- |
| `type` | [ToolType](https://github.com/excalidraw/excalidraw/blob/master/packages/excalidraw/types.ts#L91) | `selection` | The tool type which should be set as active tool. When setting `image` as active tool, the insertion onto canvas when using image tool is disabled by default, so you can enable it by setting `insertOnCanvasDirectly` to `true` |
| `type` | [ToolType](https://github.com/excalidraw/excalidraw/blob/master/packages/excalidraw/types.ts#L91) | `selection` | The tool type which should be set as active tool |
| `locked` | `boolean` | `false` | Indicates whether the the active tool should be locked. It behaves the same way when using the `lock` tool in the editor interface |
## setCursor

View File

@ -28,32 +28,12 @@ To start the example app using the `@excalidraw/excalidraw` package, follow the
## Releasing
### Create a test release
You can create a test release by posting the below comment in your pull request:
```bash
@excalibot trigger release
```
Once the version is released `@excalibot` will post a comment with the release version.
### Creating a production release
To release the next stable version follow the below steps:
```bash
yarn prerelease:excalidraw
yarn release --tag=latest --version=0.19.0
```
You need to pass the `version` for which you want to create the release. This will make the changes needed before making the release like updating `package.json`, `changelog` and more.
The next step is to run the `release` script:
```bash
yarn release:excalidraw
```
This will publish the package.
Right now there are two steps to create a production release but once this works fine these scripts will be combined and more automation will be done.
You will need to pass the `latest` tag with `version` for which you want to create the release. This will make the changes needed before publishing the packages into NPM, like updating dependencies of all `@excalidraw/*` packages, generating new entries in `CHANGELOG.md` and more.

View File

@ -38,6 +38,8 @@ If you want to only import `Excalidraw` component you can do :point_down:
```jsx showLineNumbers
import dynamic from "next/dynamic";
import "@excalidraw/excalidraw/index.css";
const Excalidraw = dynamic(
async () => (await import("@excalidraw/excalidraw")).Excalidraw,
{

View File

@ -33,6 +33,7 @@ const ExcalidrawScope = {
initialData,
useI18n: ExcalidrawComp.useI18n,
convertToExcalidrawElements: ExcalidrawComp.convertToExcalidrawElements,
CaptureUpdateAction: ExcalidrawComp.CaptureUpdateAction,
};
export default ExcalidrawScope;

View File

@ -3,7 +3,8 @@
"version": "0.1.0",
"private": true,
"scripts": {
"build:workspace": "yarn workspace @excalidraw/excalidraw run build:esm && yarn copy:assets",
"build:packages": "yarn --cwd ../../ build:packages",
"build:workspace": "yarn build:packages && yarn copy:assets",
"copy:assets": "cp -r ../../packages/excalidraw/dist/prod/fonts ./public",
"dev": "yarn build:workspace && next dev -p 3005",
"build": "yarn build:workspace && next build",

View File

@ -17,6 +17,6 @@
"build": "vite build",
"preview": "vite preview --port 5002",
"build:preview": "yarn build && yarn preview",
"build:package": "yarn workspace @excalidraw/excalidraw run build:esm"
"build:packages": "yarn --cwd ../../ build:packages"
}
}

View File

@ -1,5 +1,5 @@
{
"outputDirectory": "dist",
"installCommand": "yarn install",
"buildCommand": "yarn build:package && yarn build"
"buildCommand": "yarn build:packages && yarn build"
}

View File

@ -20,7 +20,6 @@ import {
APP_NAME,
EVENT,
THEME,
TITLE_TIMEOUT,
VERSION_TIMEOUT,
debounce,
getVersion,
@ -499,11 +498,6 @@ const ExcalidrawWrapper = () => {
}
};
const titleTimeout = setTimeout(
() => (document.title = APP_NAME),
TITLE_TIMEOUT,
);
const syncData = debounce(() => {
if (isTestEnv()) {
return;
@ -594,7 +588,6 @@ const ExcalidrawWrapper = () => {
visibilityChange,
false,
);
clearTimeout(titleTimeout);
};
}, [isCollabDisabled, collabAPI, excalidrawAPI, setLangCode]);

View File

@ -8,7 +8,8 @@ export const SYNC_BROWSER_TABS_TIMEOUT = 50;
export const CURSOR_SYNC_TIMEOUT = 33; // ~30fps
export const DELETED_ELEMENT_TIMEOUT = 24 * 60 * 60 * 1000; // 1 day
export const FILE_UPLOAD_MAX_BYTES = 3 * 1024 * 1024; // 3 MiB
// should be aligned with MAX_ALLOWED_FILE_BYTES
export const FILE_UPLOAD_MAX_BYTES = 4 * 1024 * 1024; // 4 MiB
// 1 year (https://stackoverflow.com/a/25201898/927631)
export const FILE_CACHE_MAX_AGE_SEC = 31536000;

View File

@ -9,7 +9,7 @@ import {
} from "@excalidraw/excalidraw/renderer/helpers";
import { type AppState } from "@excalidraw/excalidraw/types";
import { throttleRAF } from "@excalidraw/common";
import { useCallback, useImperativeHandle, useRef } from "react";
import { useCallback } from "react";
import {
isLineSegment,
@ -18,10 +18,12 @@ import {
} from "@excalidraw/math";
import { isCurve } from "@excalidraw/math/curve";
import type { DebugElement } from "@excalidraw/excalidraw/visualdebug";
import React from "react";
import type { Curve } from "@excalidraw/math";
import type { DebugElement } from "@excalidraw/utils/visualdebug";
import { STORAGE_KEYS } from "../app_constants";
const renderLine = (
@ -113,10 +115,6 @@ const _debugRenderer = (
scale,
);
if (appState.height !== canvas.height || appState.width !== canvas.width) {
refresh();
}
const context = bootstrapCanvas({
canvas,
scale,
@ -314,35 +312,29 @@ export const DebugFooter = ({ onChange }: { onChange: () => void }) => {
interface DebugCanvasProps {
appState: AppState;
scale: number;
ref?: React.Ref<HTMLCanvasElement>;
}
const DebugCanvas = ({ appState, scale, ref }: DebugCanvasProps) => {
const { width, height } = appState;
const DebugCanvas = React.forwardRef<HTMLCanvasElement, DebugCanvasProps>(
({ appState, scale }, ref) => {
const { width, height } = appState;
const canvasRef = useRef<HTMLCanvasElement>(null);
useImperativeHandle<HTMLCanvasElement | null, HTMLCanvasElement | null>(
ref,
() => canvasRef.current,
[canvasRef],
);
return (
<canvas
style={{
width,
height,
position: "absolute",
zIndex: 2,
pointerEvents: "none",
}}
width={width * scale}
height={height * scale}
ref={canvasRef}
>
Debug Canvas
</canvas>
);
};
return (
<canvas
style={{
width,
height,
position: "absolute",
zIndex: 2,
pointerEvents: "none",
}}
width={width * scale}
height={height * scale}
ref={ref}
>
Debug Canvas
</canvas>
);
},
);
export default DebugCanvas;

View File

@ -259,7 +259,9 @@ export const loadFromFirebase = async (
}
const storedScene = docSnap.data() as FirebaseStoredScene;
const elements = getSyncableElements(
restoreElements(await decryptElements(storedScene, roomKey), null),
restoreElements(await decryptElements(storedScene, roomKey), null, {
deleteInvisibleElements: true,
}),
);
if (socket) {

View File

@ -258,11 +258,16 @@ export const loadScene = async (
await importFromBackend(id, privateKey),
localDataState?.appState,
localDataState?.elements,
{ repairBindings: true, refreshDimensions: false },
{
repairBindings: true,
refreshDimensions: false,
deleteInvisibleElements: true,
},
);
} else {
data = restore(localDataState || null, null, null, {
repairBindings: true,
deleteInvisibleElements: true,
});
}

View File

@ -2,7 +2,7 @@
<html lang="en">
<head>
<meta charset="utf-8" />
<title>Excalidraw | Hand-drawn look & feel • Collaborative • Secure</title>
<title>Excalidraw Whiteboard</title>
<meta
name="viewport"
content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no, viewport-fit=cover, shrink-to-fit=no"
@ -14,7 +14,7 @@
<!-- Primary Meta Tags -->
<meta
name="title"
content="Excalidraw — Collaborative whiteboarding made easy"
content="Free, collaborative whiteboard • Hand-drawn look & feel | Excalidraw"
/>
<meta
name="description"

View File

@ -36,7 +36,7 @@ describe("Test MobileMenu", () => {
},
"isTouchScreen": false,
"viewport": {
"isLandscape": false,
"isLandscape": true,
"isMobile": true,
},
}

View File

@ -205,6 +205,7 @@ describe("collaboration", () => {
// with explicit undo (as addition) we expect our item to be restored from the snapshot!
await waitFor(() => {
expect(API.getUndoStack().length).toBe(1);
expect(API.getRedoStack().length).toBe(1);
expect(API.getSnapshot()).toEqual([
expect.objectContaining(rect1Props),
expect.objectContaining({ ...rect2Props, isDeleted: false }),
@ -247,79 +248,5 @@ describe("collaboration", () => {
expect.objectContaining({ ...rect2Props, isDeleted: true }),
]);
});
act(() => h.app.actionManager.executeAction(undoAction));
// simulate local update
API.updateScene({
elements: syncInvalidIndices([
h.elements[0],
newElementWith(h.elements[1], { x: 100 }),
]),
captureUpdate: CaptureUpdateAction.IMMEDIATELY,
});
await waitFor(() => {
expect(API.getUndoStack().length).toBe(2);
expect(API.getRedoStack().length).toBe(0);
expect(API.getSnapshot()).toEqual([
expect.objectContaining(rect1Props),
expect.objectContaining({ ...rect2Props, isDeleted: false, x: 100 }),
]);
expect(h.elements).toEqual([
expect.objectContaining(rect1Props),
expect.objectContaining({ ...rect2Props, isDeleted: false, x: 100 }),
]);
});
act(() => h.app.actionManager.executeAction(undoAction));
// we expect to iterate the stack to the first visible change
await waitFor(() => {
expect(API.getUndoStack().length).toBe(1);
expect(API.getRedoStack().length).toBe(1);
expect(API.getSnapshot()).toEqual([
expect.objectContaining(rect1Props),
expect.objectContaining({ ...rect2Props, isDeleted: false, x: 0 }),
]);
expect(h.elements).toEqual([
expect.objectContaining(rect1Props),
expect.objectContaining({ ...rect2Props, isDeleted: false, x: 0 }),
]);
});
// simulate force deleting the element remotely
API.updateScene({
elements: syncInvalidIndices([rect1]),
captureUpdate: CaptureUpdateAction.NEVER,
});
// snapshot was correctly updated and marked the element as deleted
await waitFor(() => {
expect(API.getUndoStack().length).toBe(1);
expect(API.getRedoStack().length).toBe(1);
expect(API.getSnapshot()).toEqual([
expect.objectContaining(rect1Props),
expect.objectContaining({ ...rect2Props, isDeleted: true, x: 0 }),
]);
expect(h.elements).toEqual([expect.objectContaining(rect1Props)]);
});
act(() => h.app.actionManager.executeAction(redoAction));
// with explicit redo (as update) we again restored the element from the snapshot!
await waitFor(() => {
expect(API.getUndoStack().length).toBe(2);
expect(API.getRedoStack().length).toBe(0);
expect(API.getSnapshot()).toEqual([
expect.objectContaining({ id: "A", isDeleted: false }),
expect.objectContaining({ id: "B", isDeleted: true, x: 100 }),
]);
expect(h.history.isRedoStackEmpty).toBeTruthy();
expect(h.elements).toEqual([
expect.objectContaining({ id: "A", isDeleted: false }),
expect.objectContaining({ id: "B", isDeleted: true, x: 100 }),
]);
});
});
});

View File

@ -52,13 +52,17 @@
"build-node": "node ./scripts/build-node.js",
"build:app:docker": "yarn --cwd ./excalidraw-app build:app:docker",
"build:app": "yarn --cwd ./excalidraw-app build:app",
"build:package": "yarn --cwd ./packages/excalidraw build:esm",
"build:common": "yarn --cwd ./packages/common build:esm",
"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:version": "yarn --cwd ./excalidraw-app build:version",
"build": "yarn --cwd ./excalidraw-app build",
"build:preview": "yarn --cwd ./excalidraw-app build:preview",
"start": "yarn --cwd ./excalidraw-app start",
"start:production": "yarn --cwd ./excalidraw-app start:production",
"start:example": "yarn build:package && yarn --cwd ./examples/with-script-in-browser start",
"start:example": "yarn build:packages && yarn --cwd ./examples/with-script-in-browser start",
"test:all": "yarn test:typecheck && yarn test:code && yarn test:other && yarn test:app --watch=false",
"test:app": "vitest",
"test:code": "eslint --max-warnings=0 --ext .js,.ts,.tsx .",
@ -76,9 +80,10 @@
"locales-coverage:description": "node scripts/locales-coverage-description.js",
"prepare": "husky install",
"prettier": "prettier \"**/*.{css,scss,json,md,html,yml}\" --ignore-path=.eslintignore",
"autorelease": "node scripts/autorelease.js",
"prerelease:excalidraw": "node scripts/prerelease.js",
"release:excalidraw": "node scripts/release.js",
"release": "node scripts/release.js",
"release:test": "node scripts/release.js --tag=test",
"release:next": "node scripts/release.js --tag=next",
"release:latest": "node scripts/release.js --tag=latest",
"rm:build": "rimraf --glob excalidraw-app/build excalidraw-app/dist excalidraw-app/dev-dist packages/*/dist packages/*/build examples/*/build examples/*/dist",
"rm:node_modules": "rimraf --glob node_modules excalidraw-app/node_modules packages/*/node_modules",
"clean-install": "yarn rm:node_modules && yarn install"

View File

@ -1,6 +1,6 @@
{
"name": "@excalidraw/common",
"version": "0.1.0",
"version": "0.18.0",
"type": "module",
"types": "./dist/types/common/src/index.d.ts",
"main": "./dist/prod/index.js",
@ -13,7 +13,10 @@
"default": "./dist/prod/index.js"
},
"./*": {
"types": "./dist/types/common/src/*.d.ts"
"types": "./dist/types/common/src/*.d.ts",
"development": "./dist/dev/index.js",
"production": "./dist/prod/index.js",
"default": "./dist/prod/index.js"
}
},
"files": [

View File

@ -18,13 +18,20 @@ export const isChrome = navigator.userAgent.indexOf("Chrome") !== -1;
export const isSafari =
!isChrome && navigator.userAgent.indexOf("Safari") !== -1;
export const isIOS =
/iPad|iPhone/.test(navigator.platform) ||
/iPad|iPhone/i.test(navigator.platform) ||
// iPadOS 13+
(navigator.userAgent.includes("Mac") && "ontouchend" in document);
// keeping function so it can be mocked in test
export const isBrave = () =>
(navigator as any).brave?.isBrave?.name === "isBrave";
export const isMobile =
isIOS ||
/android|webos|ipod|blackberry|iemobile|opera mini/i.test(
navigator.userAgent,
) ||
/android|ios|ipod|blackberry|windows phone/i.test(navigator.platform);
export const supportsResizeObserver =
typeof window !== "undefined" && "ResizeObserver" in window;
@ -36,6 +43,7 @@ export const APP_NAME = "Excalidraw";
// (happens a lot with fast clicks with the text tool)
export const TEXT_AUTOWRAP_THRESHOLD = 36; // px
export const DRAGGING_THRESHOLD = 10; // px
export const MINIMUM_ARROW_SIZE = 20; // px
export const LINE_CONFIRM_THRESHOLD = 8; // px
export const ELEMENT_SHIFT_TRANSLATE_AMOUNT = 5;
export const ELEMENT_TRANSLATE_AMOUNT = 1;
@ -121,6 +129,7 @@ export const CLASSES = {
ZOOM_ACTIONS: "zoom-actions",
SEARCH_MENU_INPUT_WRAPPER: "layer-ui__search-inputWrapper",
CONVERT_ELEMENT_TYPE_POPUP: "ConvertElementTypePopup",
SHAPE_ACTIONS_THEME_SCOPE: "shape-actions-theme-scope",
};
export const CJK_HAND_DRAWN_FALLBACK_FONT = "Xiaolai";
@ -147,19 +156,49 @@ export const FONT_FAMILY = {
Assistant: 10,
};
// Segoe UI Emoji fails to properly fallback for some glyphs: ∞, ∫, ≠
// so we need to have generic font fallback before it
export const SANS_SERIF_GENERIC_FONT = "sans-serif";
export const MONOSPACE_GENERIC_FONT = "monospace";
export const FONT_FAMILY_GENERIC_FALLBACKS = {
[SANS_SERIF_GENERIC_FONT]: 998,
[MONOSPACE_GENERIC_FONT]: 999,
};
export const FONT_FAMILY_FALLBACKS = {
[CJK_HAND_DRAWN_FALLBACK_FONT]: 100,
...FONT_FAMILY_GENERIC_FALLBACKS,
[WINDOWS_EMOJI_FALLBACK_FONT]: 1000,
};
export function getGenericFontFamilyFallback(
fontFamily: number,
): keyof typeof FONT_FAMILY_GENERIC_FALLBACKS {
switch (fontFamily) {
case FONT_FAMILY.Cascadia:
case FONT_FAMILY["Comic Shanns"]:
return MONOSPACE_GENERIC_FONT;
default:
return SANS_SERIF_GENERIC_FONT;
}
}
export const getFontFamilyFallbacks = (
fontFamily: number,
): Array<keyof typeof FONT_FAMILY_FALLBACKS> => {
const genericFallbackFont = getGenericFontFamilyFallback(fontFamily);
switch (fontFamily) {
case FONT_FAMILY.Excalifont:
return [CJK_HAND_DRAWN_FALLBACK_FONT, WINDOWS_EMOJI_FALLBACK_FONT];
return [
CJK_HAND_DRAWN_FALLBACK_FONT,
genericFallbackFont,
WINDOWS_EMOJI_FALLBACK_FONT,
];
default:
return [WINDOWS_EMOJI_FALLBACK_FONT];
return [genericFallbackFont, WINDOWS_EMOJI_FALLBACK_FONT];
}
};
@ -221,13 +260,17 @@ export const IMAGE_MIME_TYPES = {
jfif: "image/jfif",
} as const;
export const MIME_TYPES = {
export const STRING_MIME_TYPES = {
text: "text/plain",
html: "text/html",
json: "application/json",
// excalidraw data
excalidraw: "application/vnd.excalidraw+json",
excalidrawlib: "application/vnd.excalidrawlib+json",
} as const;
export const MIME_TYPES = {
...STRING_MIME_TYPES,
// image-encoded excalidraw data
"excalidraw.svg": "image/svg+xml",
"excalidraw.png": "image/png",
@ -304,10 +347,17 @@ export const DEFAULT_UI_OPTIONS: AppProps["UIOptions"] = {
// breakpoints
// -----------------------------------------------------------------------------
// md screen
export const MQ_MAX_WIDTH_PORTRAIT = 730;
export const MQ_MAX_WIDTH_LANDSCAPE = 1000;
export const MQ_MAX_HEIGHT_LANDSCAPE = 500;
// mobile: up to 699px
export const MQ_MAX_MOBILE = 599;
// tablets
export const MQ_MIN_TABLET = MQ_MAX_MOBILE + 1; // lower bound (excludes phones)
export const MQ_MAX_TABLET = 1400; // upper bound (excludes laptops/desktops)
// desktop/laptop
export const MQ_MIN_WIDTH_DESKTOP = 1440;
// sidebar
export const MQ_RIGHT_SIDEBAR_MIN_WIDTH = 1229;
// -----------------------------------------------------------------------------
@ -484,3 +534,5 @@ export enum UserIdleState {
* the start and end points)
*/
export const LINE_POLYGON_POINT_MERGE_DISTANCE = 20;
export const DOUBLE_TAP_POSITION_THRESHOLD = 35;

View File

@ -1,10 +1,9 @@
import { average, pointFrom, type GlobalPoint } from "@excalidraw/math";
import { average } from "@excalidraw/math";
import type {
ExcalidrawBindableElement,
FontFamilyValues,
FontString,
ExcalidrawElement,
} from "@excalidraw/element/types";
import type {
@ -22,6 +21,8 @@ import {
FONT_FAMILY,
getFontFamilyFallbacks,
isDarwin,
isAndroid,
isIOS,
WINDOWS_EMOJI_FALLBACK_FONT,
} from "./constants";
@ -101,7 +102,6 @@ export const getFontFamilyString = ({
}) => {
for (const [fontFamilyString, id] of Object.entries(FONT_FAMILY)) {
if (id === fontFamily) {
// TODO: we should fallback first to generic family names first
return `${fontFamilyString}${getFontFamilyFallbacks(id)
.map((x) => `, ${x}`)
.join("")}`;
@ -712,8 +712,8 @@ export const arrayToObject = <T>(
array: readonly T[],
groupBy?: (value: T) => string | number,
) =>
array.reduce((acc, value) => {
acc[groupBy ? groupBy(value) : String(value)] = value;
array.reduce((acc, value, idx) => {
acc[groupBy ? groupBy(value) : idx] = value;
return acc;
}, {} as { [key: string]: T });
@ -1238,20 +1238,6 @@ export const escapeDoubleQuotes = (str: string) => {
export const castArray = <T>(value: T | T[]): T[] =>
Array.isArray(value) ? value : [value];
export const elementCenterPoint = (
element: ExcalidrawElement,
xOffset: number = 0,
yOffset: number = 0,
) => {
const { x, y, width, height } = element;
const centerXPoint = x + width / 2 + xOffset;
const centerYPoint = y + height / 2 + yOffset;
return pointFrom<GlobalPoint>(centerXPoint, centerYPoint);
};
/** hack for Array.isArray type guard not working with readonly value[] */
export const isReadonlyArray = (value?: any): value is readonly any[] => {
return Array.isArray(value);
@ -1294,3 +1280,59 @@ export const reduceToCommonValue = <T, R = T>(
return commonValue;
};
export const isMobileOrTablet = (): boolean => {
const ua = navigator.userAgent || "";
const platform = navigator.platform || "";
const uaData = (navigator as any).userAgentData as
| { mobile?: boolean; platform?: string }
| undefined;
// --- 1) chromium: prefer ua client hints -------------------------------
if (uaData) {
const plat = (uaData.platform || "").toLowerCase();
const isDesktopOS =
plat === "windows" ||
plat === "macos" ||
plat === "linux" ||
plat === "chrome os";
if (uaData.mobile === true) {
return true;
}
if (uaData.mobile === false && plat === "android") {
const looksTouchTablet =
matchMedia?.("(hover: none)").matches &&
matchMedia?.("(pointer: coarse)").matches;
return looksTouchTablet;
}
if (isDesktopOS) {
return false;
}
}
// --- 2) ios (includes ipad) --------------------------------------------
if (isIOS) {
return true;
}
// --- 3) android legacy ua fallback -------------------------------------
if (isAndroid) {
const isAndroidPhone = /Mobile/i.test(ua);
const isAndroidTablet = !isAndroidPhone;
if (isAndroidPhone || isAndroidTablet) {
const looksTouchTablet =
matchMedia?.("(hover: none)").matches &&
matchMedia?.("(pointer: coarse)").matches;
return looksTouchTablet;
}
}
// --- 4) last resort desktop exclusion ----------------------------------
const looksDesktopPlatform =
/Win|Linux|CrOS|Mac/.test(platform) ||
/Windows NT|X11|CrOS|Macintosh/.test(ua);
if (looksDesktopPlatform) {
return false;
}
return false;
};

View File

@ -1,6 +1,6 @@
{
"name": "@excalidraw/element",
"version": "0.1.0",
"version": "0.18.0",
"type": "module",
"types": "./dist/types/element/src/index.d.ts",
"main": "./dist/prod/index.js",
@ -13,7 +13,10 @@
"default": "./dist/prod/index.js"
},
"./*": {
"types": "./dist/types/element/src/*.d.ts"
"types": "./dist/types/element/src/*.d.ts",
"development": "./dist/dev/index.js",
"production": "./dist/prod/index.js",
"default": "./dist/prod/index.js"
}
},
"files": [
@ -52,5 +55,9 @@
"scripts": {
"gen:types": "rimraf types && tsc",
"build:esm": "rimraf dist && node ../../scripts/buildBase.js && yarn gen:types"
},
"dependencies": {
"@excalidraw/common": "0.18.0",
"@excalidraw/math": "0.18.0"
}
}

View File

@ -164,9 +164,14 @@ export class Scene {
return this.frames;
}
constructor(elements: ElementsMapOrArray | null = null) {
constructor(
elements: ElementsMapOrArray | null = null,
options?: {
skipValidation?: true;
},
) {
if (elements) {
this.replaceAllElements(elements);
this.replaceAllElements(elements, options);
}
}
@ -263,12 +268,19 @@ export class Scene {
return didChange;
}
replaceAllElements(nextElements: ElementsMapOrArray) {
replaceAllElements(
nextElements: ElementsMapOrArray,
options?: {
skipValidation?: true;
},
) {
// we do trust the insertion order on the map, though maybe we shouldn't and should prefer order defined by fractional indices
const _nextElements = toArray(nextElements);
const nextFrameLikes: ExcalidrawFrameLikeElement[] = [];
validateIndicesThrottled(_nextElements);
if (!options?.skipValidation) {
validateIndicesThrottled(_nextElements);
}
this.elements = syncInvalidIndices(_nextElements);
this.elementsMap.clear();

View File

@ -1,95 +0,0 @@
import { RoughGenerator } from "roughjs/bin/generator";
import { COLOR_PALETTE } from "@excalidraw/common";
import type {
AppState,
EmbedsValidationStatus,
} from "@excalidraw/excalidraw/types";
import type {
ElementShape,
ElementShapes,
} from "@excalidraw/excalidraw/scene/types";
import { _generateElementShape } from "./Shape";
import { elementWithCanvasCache } from "./renderElement";
import type { ExcalidrawElement, ExcalidrawSelectionElement } from "./types";
import type { Drawable } from "roughjs/bin/core";
export class ShapeCache {
private static rg = new RoughGenerator();
private static cache = new WeakMap<ExcalidrawElement, ElementShape>();
/**
* Retrieves shape from cache if available. Use this only if shape
* is optional and you have a fallback in case it's not cached.
*/
public static get = <T extends ExcalidrawElement>(element: T) => {
return ShapeCache.cache.get(
element,
) as T["type"] extends keyof ElementShapes
? ElementShapes[T["type"]] | undefined
: ElementShape | undefined;
};
public static set = <T extends ExcalidrawElement>(
element: T,
shape: T["type"] extends keyof ElementShapes
? ElementShapes[T["type"]]
: Drawable,
) => ShapeCache.cache.set(element, shape);
public static delete = (element: ExcalidrawElement) =>
ShapeCache.cache.delete(element);
public static destroy = () => {
ShapeCache.cache = new WeakMap();
};
/**
* Generates & caches shape for element if not already cached, otherwise
* returns cached shape.
*/
public static generateElementShape = <
T extends Exclude<ExcalidrawElement, ExcalidrawSelectionElement>,
>(
element: T,
renderConfig: {
isExporting: boolean;
canvasBackgroundColor: AppState["viewBackgroundColor"];
embedsValidationStatus: EmbedsValidationStatus;
} | null,
) => {
// when exporting, always regenerated to guarantee the latest shape
const cachedShape = renderConfig?.isExporting
? undefined
: ShapeCache.get(element);
// `null` indicates no rc shape applicable for this element type,
// but it's considered a valid cache value (= do not regenerate)
if (cachedShape !== undefined) {
return cachedShape;
}
elementWithCanvasCache.delete(element);
const shape = _generateElementShape(
element,
ShapeCache.rg,
renderConfig || {
isExporting: false,
canvasBackgroundColor: COLOR_PALETTE.white,
embedsValidationStatus: null,
},
) as T["type"] extends keyof ElementShapes
? ElementShapes[T["type"]]
: Drawable | null;
ShapeCache.cache.set(element, shape);
return shape;
};
}

View File

@ -1,6 +1,8 @@
import type { AppState } from "@excalidraw/excalidraw/types";
import { updateBoundElements } from "./binding";
import { getCommonBoundingBox } from "./bounds";
import { getMaximumGroups } from "./groups";
import { getSelectedElementsByGroup } from "./groups";
import type { Scene } from "./Scene";
@ -16,11 +18,12 @@ export const alignElements = (
selectedElements: ExcalidrawElement[],
alignment: Alignment,
scene: Scene,
appState: Readonly<AppState>,
): ExcalidrawElement[] => {
const elementsMap = scene.getNonDeletedElementsMap();
const groups: ExcalidrawElement[][] = getMaximumGroups(
const groups: ExcalidrawElement[][] = getSelectedElementsByGroup(
selectedElements,
elementsMap,
scene.getNonDeletedElementsMap(),
appState,
);
const selectionBoundingBox = getCommonBoundingBox(selectedElements);

View File

@ -6,7 +6,6 @@ import {
invariant,
isDevEnv,
isTestEnv,
elementCenterPoint,
} from "@excalidraw/common";
import {
@ -27,8 +26,6 @@ import {
PRECISION,
} from "@excalidraw/math";
import { isPointOnShape } from "@excalidraw/utils/collision";
import type { LocalPoint, Radians } from "@excalidraw/math";
import type { AppState } from "@excalidraw/excalidraw/types";
@ -36,12 +33,12 @@ import type { AppState } from "@excalidraw/excalidraw/types";
import type { MapEntry, Mutable } from "@excalidraw/common/utility-types";
import {
doBoundsIntersect,
getCenterForBounds,
getElementBounds,
doBoundsIntersect,
} from "./bounds";
import { intersectElementWithLineSegment } from "./collision";
import { distanceToBindableElement } from "./distance";
import { distanceToElement } from "./distance";
import {
headingForPointFromElement,
headingIsHorizontal,
@ -63,7 +60,7 @@ import {
isTextElement,
} from "./typeChecks";
import { aabbForElement, getElementShape, pointInsideBounds } from "./shapes";
import { aabbForElement, elementCenterPoint } from "./bounds";
import { updateElbowArrowPoints } from "./elbowArrow";
import type { Scene } from "./Scene";
@ -109,7 +106,6 @@ export const isBindingEnabled = (appState: AppState): boolean => {
export const FIXED_BINDING_DISTANCE = 5;
export const BINDING_HIGHLIGHT_THICKNESS = 10;
export const BINDING_HIGHLIGHT_OFFSET = 4;
const getNonDeletedElements = (
scene: Scene,
@ -131,6 +127,7 @@ export const bindOrUnbindLinearElement = (
endBindingElement: ExcalidrawBindableElement | null | "keep",
scene: Scene,
): void => {
const elementsMap = scene.getNonDeletedElementsMap();
const boundToElementIds: Set<ExcalidrawBindableElement["id"]> = new Set();
const unboundFromElementIds: Set<ExcalidrawBindableElement["id"]> = new Set();
bindOrUnbindLinearElementEdge(
@ -141,6 +138,7 @@ export const bindOrUnbindLinearElement = (
boundToElementIds,
unboundFromElementIds,
scene,
elementsMap,
);
bindOrUnbindLinearElementEdge(
linearElement,
@ -150,6 +148,7 @@ export const bindOrUnbindLinearElement = (
boundToElementIds,
unboundFromElementIds,
scene,
elementsMap,
);
const onlyUnbound = Array.from(unboundFromElementIds).filter(
@ -176,6 +175,7 @@ const bindOrUnbindLinearElementEdge = (
// Is mutated
unboundFromElementIds: Set<ExcalidrawBindableElement["id"]>,
scene: Scene,
elementsMap: ElementsMap,
): void => {
// "keep" is for method chaining convenience, a "no-op", so just bail out
if (bindableElement === "keep") {
@ -216,43 +216,29 @@ const bindOrUnbindLinearElementEdge = (
}
};
const getOriginalBindingIfStillCloseOfLinearElementEdge = (
linearElement: NonDeleted<ExcalidrawLinearElement>,
edge: "start" | "end",
elementsMap: NonDeletedSceneElementsMap,
zoom?: AppState["zoom"],
): NonDeleted<ExcalidrawElement> | null => {
const coors = getLinearElementEdgeCoors(linearElement, edge, elementsMap);
const elementId =
edge === "start"
? linearElement.startBinding?.elementId
: linearElement.endBinding?.elementId;
if (elementId) {
const element = elementsMap.get(elementId);
if (
isBindableElement(element) &&
bindingBorderTest(element, coors, elementsMap, zoom)
) {
return element;
}
}
return null;
};
const getOriginalBindingsIfStillCloseToArrowEnds = (
linearElement: NonDeleted<ExcalidrawLinearElement>,
elementsMap: NonDeletedSceneElementsMap,
zoom?: AppState["zoom"],
): (NonDeleted<ExcalidrawElement> | null)[] =>
["start", "end"].map((edge) =>
getOriginalBindingIfStillCloseOfLinearElementEdge(
linearElement,
edge as "start" | "end",
elementsMap,
zoom,
),
);
(["start", "end"] as const).map((edge) => {
const coors = getLinearElementEdgeCoors(linearElement, edge, elementsMap);
const elementId =
edge === "start"
? linearElement.startBinding?.elementId
: linearElement.endBinding?.elementId;
if (elementId) {
const element = elementsMap.get(elementId);
if (
isBindableElement(element) &&
bindingBorderTest(element, coors, elementsMap, zoom)
) {
return element;
}
}
return null;
});
const getBindingStrategyForDraggingArrowEndpoints = (
selectedElement: NonDeleted<ExcalidrawLinearElement>,
@ -268,7 +254,7 @@ const getBindingStrategyForDraggingArrowEndpoints = (
const endDragged = draggingPoints.findIndex((i) => i === endIdx) > -1;
const start = startDragged
? isBindingEnabled
? getElligibleElementForBindingElement(
? getEligibleElementForBindingElement(
selectedElement,
"start",
elementsMap,
@ -279,7 +265,7 @@ const getBindingStrategyForDraggingArrowEndpoints = (
: "keep";
const end = endDragged
? isBindingEnabled
? getElligibleElementForBindingElement(
? getEligibleElementForBindingElement(
selectedElement,
"end",
elementsMap,
@ -311,7 +297,7 @@ const getBindingStrategyForDraggingArrowOrJoints = (
);
const start = startIsClose
? isBindingEnabled
? getElligibleElementForBindingElement(
? getEligibleElementForBindingElement(
selectedElement,
"start",
elementsMap,
@ -322,7 +308,7 @@ const getBindingStrategyForDraggingArrowOrJoints = (
: null;
const end = endIsClose
? isBindingEnabled
? getElligibleElementForBindingElement(
? getEligibleElementForBindingElement(
selectedElement,
"end",
elementsMap,
@ -398,6 +384,48 @@ export const getSuggestedBindingsForArrows = (
);
};
export const maybeSuggestBindingsForLinearElementAtCoords = (
linearElement: NonDeleted<ExcalidrawLinearElement>,
/** scene coords */
pointerCoords: {
x: number;
y: number;
}[],
scene: Scene,
zoom: AppState["zoom"],
// During line creation the start binding hasn't been written yet
// into `linearElement`
oppositeBindingBoundElement?: ExcalidrawBindableElement | null,
): ExcalidrawBindableElement[] =>
Array.from(
pointerCoords.reduce(
(acc: Set<NonDeleted<ExcalidrawBindableElement>>, coords) => {
const hoveredBindableElement = getHoveredElementForBinding(
coords,
scene.getNonDeletedElements(),
scene.getNonDeletedElementsMap(),
zoom,
isElbowArrow(linearElement),
isElbowArrow(linearElement),
);
if (
hoveredBindableElement != null &&
!isLinearElementSimpleAndAlreadyBound(
linearElement,
oppositeBindingBoundElement?.id,
hoveredBindableElement,
)
) {
acc.add(hoveredBindableElement);
}
return acc;
},
new Set() as Set<NonDeleted<ExcalidrawBindableElement>>,
),
);
export const maybeBindLinearElement = (
linearElement: NonDeleted<ExcalidrawLinearElement>,
appState: AppState,
@ -441,22 +469,13 @@ export const maybeBindLinearElement = (
const normalizePointBinding = (
binding: { focus: number; gap: number },
hoveredElement: ExcalidrawBindableElement,
) => {
let gap = binding.gap;
const maxGap = maxBindingGap(
hoveredElement,
hoveredElement.width,
hoveredElement.height,
);
if (gap > maxGap) {
gap = BINDING_HIGHLIGHT_THICKNESS + BINDING_HIGHLIGHT_OFFSET;
}
return {
...binding,
gap,
};
};
) => ({
...binding,
gap: Math.min(
binding.gap,
maxBindingGap(hoveredElement, hoveredElement.width, hoveredElement.height),
),
});
export const bindLinearElement = (
linearElement: NonDeleted<ExcalidrawLinearElement>,
@ -488,6 +507,7 @@ export const bindLinearElement = (
linearElement,
hoveredElement,
startOrEnd,
scene.getNonDeletedElementsMap(),
),
};
}
@ -535,7 +555,7 @@ export const isLinearElementSimpleAndAlreadyBound = (
const isLinearElementSimple = (
linearElement: NonDeleted<ExcalidrawLinearElement>,
): boolean => linearElement.points.length < 3;
): boolean => linearElement.points.length < 3 && !isElbowArrow(linearElement);
const unbindLinearElement = (
linearElement: NonDeleted<ExcalidrawLinearElement>,
@ -703,8 +723,13 @@ const calculateFocusAndGap = (
);
return {
focus: determineFocusDistance(hoveredElement, adjacentPoint, edgePoint),
gap: Math.max(1, distanceToBindableElement(hoveredElement, edgePoint)),
focus: determineFocusDistance(
hoveredElement,
elementsMap,
adjacentPoint,
edgePoint,
),
gap: Math.max(1, distanceToElement(hoveredElement, elementsMap, edgePoint)),
};
};
@ -874,6 +899,7 @@ export const getHeadingForElbowArrowSnap = (
bindableElement: ExcalidrawBindableElement | undefined | null,
aabb: Bounds | undefined | null,
origPoint: GlobalPoint,
elementsMap: ElementsMap,
zoom?: AppState["zoom"],
): Heading => {
const otherPointHeading = vectorToHeading(vectorFromPoint(otherPoint, p));
@ -882,11 +908,16 @@ export const getHeadingForElbowArrowSnap = (
return otherPointHeading;
}
const distance = getDistanceForBinding(origPoint, bindableElement, zoom);
const distance = getDistanceForBinding(
origPoint,
bindableElement,
elementsMap,
zoom,
);
if (!distance) {
return vectorToHeading(
vectorFromPoint(p, elementCenterPoint(bindableElement)),
vectorFromPoint(p, elementCenterPoint(bindableElement, elementsMap)),
);
}
@ -896,9 +927,10 @@ export const getHeadingForElbowArrowSnap = (
const getDistanceForBinding = (
point: Readonly<GlobalPoint>,
bindableElement: ExcalidrawBindableElement,
elementsMap: ElementsMap,
zoom?: AppState["zoom"],
) => {
const distance = distanceToBindableElement(bindableElement, point);
const distance = distanceToElement(bindableElement, elementsMap, point);
const bindDistance = maxBindingGap(
bindableElement,
bindableElement.width,
@ -913,12 +945,13 @@ export const bindPointToSnapToElementOutline = (
arrow: ExcalidrawElbowArrowElement,
bindableElement: ExcalidrawBindableElement,
startOrEnd: "start" | "end",
elementsMap: ElementsMap,
): GlobalPoint => {
if (isDevEnv() || isTestEnv()) {
invariant(arrow.points.length > 1, "Arrow should have at least 2 points");
}
const aabb = aabbForElement(bindableElement);
const aabb = aabbForElement(bindableElement, elementsMap);
const localP =
arrow.points[startOrEnd === "start" ? 0 : arrow.points.length - 1];
const globalP = pointFrom<GlobalPoint>(
@ -926,7 +959,7 @@ export const bindPointToSnapToElementOutline = (
arrow.y + localP[1],
);
const edgePoint = isRectanguloidElement(bindableElement)
? avoidRectangularCorner(bindableElement, globalP)
? avoidRectangularCorner(bindableElement, elementsMap, globalP)
: globalP;
const elbowed = isElbowArrow(arrow);
const center = getCenterForBounds(aabb);
@ -945,26 +978,31 @@ export const bindPointToSnapToElementOutline = (
const isHorizontal = headingIsHorizontal(
headingForPointFromElement(bindableElement, aabb, globalP),
);
const snapPoint = snapToMid(bindableElement, elementsMap, edgePoint);
const otherPoint = pointFrom<GlobalPoint>(
isHorizontal ? center[0] : edgePoint[0],
!isHorizontal ? center[1] : edgePoint[1],
isHorizontal ? center[0] : snapPoint[0],
!isHorizontal ? center[1] : snapPoint[1],
);
const intersector = lineSegment(
otherPoint,
pointFromVector(
vectorScale(
vectorNormalize(vectorFromPoint(snapPoint, otherPoint)),
Math.max(bindableElement.width, bindableElement.height) * 2,
),
otherPoint,
),
);
intersection = intersectElementWithLineSegment(
bindableElement,
lineSegment(
otherPoint,
pointFromVector(
vectorScale(
vectorNormalize(vectorFromPoint(edgePoint, otherPoint)),
Math.max(bindableElement.width, bindableElement.height) * 2,
),
otherPoint,
),
),
)[0];
elementsMap,
intersector,
FIXED_BINDING_DISTANCE,
).sort(pointDistanceSq)[0];
} else {
intersection = intersectElementWithLineSegment(
bindableElement,
elementsMap,
lineSegment(
adjacentPoint,
pointFromVector(
@ -991,31 +1029,15 @@ export const bindPointToSnapToElementOutline = (
return edgePoint;
}
if (elbowed) {
const scalar =
pointDistanceSq(edgePoint, center) -
pointDistanceSq(intersection, center) >
0
? FIXED_BINDING_DISTANCE
: -FIXED_BINDING_DISTANCE;
return pointFromVector(
vectorScale(
vectorNormalize(vectorFromPoint(edgePoint, intersection)),
scalar,
),
intersection,
);
}
return edgePoint;
return elbowed ? intersection : edgePoint;
};
export const avoidRectangularCorner = (
element: ExcalidrawBindableElement,
elementsMap: ElementsMap,
p: GlobalPoint,
): GlobalPoint => {
const center = elementCenterPoint(element);
const center = elementCenterPoint(element, elementsMap);
const nonRotatedPoint = pointRotateRads(p, center, -element.angle as Radians);
if (nonRotatedPoint[0] < element.x && nonRotatedPoint[1] < element.y) {
@ -1108,35 +1130,34 @@ export const avoidRectangularCorner = (
export const snapToMid = (
element: ExcalidrawBindableElement,
elementsMap: ElementsMap,
p: GlobalPoint,
tolerance: number = 0.05,
): GlobalPoint => {
const { x, y, width, height, angle } = element;
const center = elementCenterPoint(element, -0.1, -0.1);
const center = elementCenterPoint(element, elementsMap, -0.1, -0.1);
const nonRotated = pointRotateRads(p, center, -angle as Radians);
// snap-to-center point is adaptive to element size, but we don't want to go
// above and below certain px distance
const verticalThrehsold = clamp(tolerance * height, 5, 80);
const horizontalThrehsold = clamp(tolerance * width, 5, 80);
const verticalThreshold = clamp(tolerance * height, 5, 80);
const horizontalThreshold = clamp(tolerance * width, 5, 80);
if (
nonRotated[0] <= x + width / 2 &&
nonRotated[1] > center[1] - verticalThrehsold &&
nonRotated[1] < center[1] + verticalThrehsold
nonRotated[1] > center[1] - verticalThreshold &&
nonRotated[1] < center[1] + verticalThreshold
) {
// LEFT
return pointRotateRads(
return pointRotateRads<GlobalPoint>(
pointFrom(x - FIXED_BINDING_DISTANCE, center[1]),
center,
angle,
);
} else if (
nonRotated[1] <= y + height / 2 &&
nonRotated[0] > center[0] - horizontalThrehsold &&
nonRotated[0] < center[0] + horizontalThrehsold
nonRotated[0] > center[0] - horizontalThreshold &&
nonRotated[0] < center[0] + horizontalThreshold
) {
// TOP
return pointRotateRads(
@ -1146,8 +1167,8 @@ export const snapToMid = (
);
} else if (
nonRotated[0] >= x + width / 2 &&
nonRotated[1] > center[1] - verticalThrehsold &&
nonRotated[1] < center[1] + verticalThrehsold
nonRotated[1] > center[1] - verticalThreshold &&
nonRotated[1] < center[1] + verticalThreshold
) {
// RIGHT
return pointRotateRads(
@ -1157,8 +1178,8 @@ export const snapToMid = (
);
} else if (
nonRotated[1] >= y + height / 2 &&
nonRotated[0] > center[0] - horizontalThrehsold &&
nonRotated[0] < center[0] + horizontalThrehsold
nonRotated[0] > center[0] - horizontalThreshold &&
nonRotated[0] < center[0] + horizontalThreshold
) {
// DOWN
return pointRotateRads(
@ -1167,7 +1188,7 @@ export const snapToMid = (
angle,
);
} else if (element.type === "diamond") {
const distance = FIXED_BINDING_DISTANCE - 1;
const distance = FIXED_BINDING_DISTANCE;
const topLeft = pointFrom<GlobalPoint>(
x + width / 4 - distance,
y + height / 4 - distance,
@ -1184,27 +1205,28 @@ export const snapToMid = (
x + (3 * width) / 4 + distance,
y + (3 * height) / 4 + distance,
);
if (
pointDistance(topLeft, nonRotated) <
Math.max(horizontalThrehsold, verticalThrehsold)
Math.max(horizontalThreshold, verticalThreshold)
) {
return pointRotateRads(topLeft, center, angle);
}
if (
pointDistance(topRight, nonRotated) <
Math.max(horizontalThrehsold, verticalThrehsold)
Math.max(horizontalThreshold, verticalThreshold)
) {
return pointRotateRads(topRight, center, angle);
}
if (
pointDistance(bottomLeft, nonRotated) <
Math.max(horizontalThrehsold, verticalThrehsold)
Math.max(horizontalThreshold, verticalThreshold)
) {
return pointRotateRads(bottomLeft, center, angle);
}
if (
pointDistance(bottomRight, nonRotated) <
Math.max(horizontalThrehsold, verticalThrehsold)
Math.max(horizontalThreshold, verticalThreshold)
) {
return pointRotateRads(bottomRight, center, angle);
}
@ -1239,8 +1261,9 @@ const updateBoundPoint = (
linearElement,
bindableElement,
startOrEnd === "startBinding" ? "start" : "end",
elementsMap,
).fixedPoint;
const globalMidPoint = elementCenterPoint(bindableElement);
const globalMidPoint = elementCenterPoint(bindableElement, elementsMap);
const global = pointFrom<GlobalPoint>(
bindableElement.x + fixedPoint[0] * bindableElement.width,
bindableElement.y + fixedPoint[1] * bindableElement.height,
@ -1266,6 +1289,7 @@ const updateBoundPoint = (
);
const focusPointAbsolute = determineFocusPoint(
bindableElement,
elementsMap,
binding.focus,
adjacentPoint,
);
@ -1284,7 +1308,7 @@ const updateBoundPoint = (
elementsMap,
);
const center = elementCenterPoint(bindableElement);
const center = elementCenterPoint(bindableElement, elementsMap);
const interceptorLength =
pointDistance(adjacentPoint, edgePointAbsolute) +
pointDistance(adjacentPoint, center) +
@ -1292,6 +1316,7 @@ const updateBoundPoint = (
const intersections = [
...intersectElementWithLineSegment(
bindableElement,
elementsMap,
lineSegment<GlobalPoint>(
adjacentPoint,
pointFromVector(
@ -1342,6 +1367,7 @@ export const calculateFixedPointForElbowArrowBinding = (
linearElement: NonDeleted<ExcalidrawElbowArrowElement>,
hoveredElement: ExcalidrawBindableElement,
startOrEnd: "start" | "end",
elementsMap: ElementsMap,
): { fixedPoint: FixedPoint } => {
const bounds = [
hoveredElement.x,
@ -1353,6 +1379,7 @@ export const calculateFixedPointForElbowArrowBinding = (
linearElement,
hoveredElement,
startOrEnd,
elementsMap,
);
const globalMidPoint = pointFrom(
bounds[0] + (bounds[2] - bounds[0]) / 2,
@ -1396,7 +1423,7 @@ const maybeCalculateNewGapWhenScaling = (
return { ...currentBinding, gap: newGap };
};
const getElligibleElementForBindingElement = (
const getEligibleElementForBindingElement = (
linearElement: NonDeleted<ExcalidrawLinearElement>,
startOrEnd: "start" | "end",
elementsMap: NonDeletedSceneElementsMap,
@ -1548,14 +1575,38 @@ export const bindingBorderTest = (
zoom?: AppState["zoom"],
fullShape?: boolean,
): boolean => {
const p = pointFrom<GlobalPoint>(x, y);
const threshold = maxBindingGap(element, element.width, element.height, zoom);
const shouldTestInside =
// disable fullshape snapping for frame elements so we
// can bind to frame children
(fullShape || !isBindingFallthroughEnabled(element)) &&
!isFrameLikeElement(element);
const shape = getElementShape(element, elementsMap);
return (
isPointOnShape(pointFrom(x, y), shape, threshold) ||
(fullShape === true &&
pointInsideBounds(pointFrom(x, y), aabbForElement(element)))
// PERF: Run a cheap test to see if the binding element
// is even close to the element
const bounds = [
x - threshold,
y - threshold,
x + threshold,
y + threshold,
] as Bounds;
const elementBounds = getElementBounds(element, elementsMap);
if (!doBoundsIntersect(bounds, elementBounds)) {
return false;
}
// Do the intersection test against the element since it's close enough
const intersections = intersectElementWithLineSegment(
element,
elementsMap,
lineSegment(elementCenterPoint(element, elementsMap), p),
);
const distance = distanceToElement(element, elementsMap, p);
return shouldTestInside
? intersections.length === 0 || distance <= threshold
: intersections.length > 0 && distance <= threshold;
};
export const maxBindingGap = (
@ -1575,7 +1626,7 @@ export const maxBindingGap = (
// bigger bindable boundary for bigger elements
Math.min(0.25 * smallerDimension, 32),
// keep in sync with the zoomed highlight
BINDING_HIGHLIGHT_THICKNESS / zoomValue + BINDING_HIGHLIGHT_OFFSET,
BINDING_HIGHLIGHT_THICKNESS / zoomValue + FIXED_BINDING_DISTANCE,
);
};
@ -1586,12 +1637,13 @@ export const maxBindingGap = (
// of the element.
const determineFocusDistance = (
element: ExcalidrawBindableElement,
elementsMap: ElementsMap,
// Point on the line, in absolute coordinates
a: GlobalPoint,
// Another point on the line, in absolute coordinates (closer to element)
b: GlobalPoint,
): number => {
const center = elementCenterPoint(element);
const center = elementCenterPoint(element, elementsMap);
if (pointsEqual(a, b)) {
return 0;
@ -1716,12 +1768,13 @@ const determineFocusDistance = (
const determineFocusPoint = (
element: ExcalidrawBindableElement,
elementsMap: ElementsMap,
// The oriented, relative distance from the center of `element` of the
// returned focusPoint
focus: number,
adjacentPoint: GlobalPoint,
): GlobalPoint => {
const center = elementCenterPoint(element);
const center = elementCenterPoint(element, elementsMap);
if (focus === 0) {
return center;
@ -2144,6 +2197,7 @@ export class BindableElement {
export const getGlobalFixedPointForBindableElement = (
fixedPointRatio: [number, number],
element: ExcalidrawBindableElement,
elementsMap: ElementsMap,
): GlobalPoint => {
const [fixedX, fixedY] = normalizeFixedPoint(fixedPointRatio);
@ -2152,7 +2206,7 @@ export const getGlobalFixedPointForBindableElement = (
element.x + element.width * fixedX,
element.y + element.height * fixedY,
),
elementCenterPoint(element),
elementCenterPoint(element, elementsMap),
element.angle,
);
};
@ -2176,6 +2230,7 @@ export const getGlobalFixedPoints = (
? getGlobalFixedPointForBindableElement(
arrow.startBinding.fixedPoint,
startElement as ExcalidrawBindableElement,
elementsMap,
)
: pointFrom<GlobalPoint>(
arrow.x + arrow.points[0][0],
@ -2186,6 +2241,7 @@ export const getGlobalFixedPoints = (
? getGlobalFixedPointForBindableElement(
arrow.endBinding.fixedPoint,
endElement as ExcalidrawBindableElement,
elementsMap,
)
: pointFrom<GlobalPoint>(
arrow.x + arrow.points[arrow.points.length - 1][0],

View File

@ -33,8 +33,8 @@ import type { AppState } from "@excalidraw/excalidraw/types";
import type { Mutable } from "@excalidraw/common/utility-types";
import { generateRoughOptions } from "./Shape";
import { ShapeCache } from "./ShapeCache";
import { generateRoughOptions } from "./shape";
import { ShapeCache } from "./shape";
import { LinearElementEditor } from "./linearElementEditor";
import { getBoundTextElement, getContainerElement } from "./textElement";
import {
@ -45,7 +45,7 @@ import {
isTextElement,
} from "./typeChecks";
import { getElementShape } from "./shapes";
import { getElementShape } from "./shape";
import {
deconstructDiamondElement,
@ -102,9 +102,23 @@ export class ElementBounds {
version: ExcalidrawElement["version"];
}
>();
private static nonRotatedBoundsCache = new WeakMap<
ExcalidrawElement,
{
bounds: Bounds;
version: ExcalidrawElement["version"];
}
>();
static getBounds(element: ExcalidrawElement, elementsMap: ElementsMap) {
const cachedBounds = ElementBounds.boundsCache.get(element);
static getBounds(
element: ExcalidrawElement,
elementsMap: ElementsMap,
nonRotated: boolean = false,
) {
const cachedBounds =
nonRotated && element.angle !== 0
? ElementBounds.nonRotatedBoundsCache.get(element)
: ElementBounds.boundsCache.get(element);
if (
cachedBounds?.version &&
@ -115,6 +129,23 @@ export class ElementBounds {
) {
return cachedBounds.bounds;
}
if (nonRotated && element.angle !== 0) {
const nonRotatedBounds = ElementBounds.calculateBounds(
{
...element,
angle: 0 as Radians,
},
elementsMap,
);
ElementBounds.nonRotatedBoundsCache.set(element, {
version: element.version,
bounds: nonRotatedBounds,
});
return nonRotatedBounds;
}
const bounds = ElementBounds.calculateBounds(element, elementsMap);
ElementBounds.boundsCache.set(element, {
@ -553,7 +584,7 @@ const solveQuadratic = (
return [s1, s2];
};
const getCubicBezierCurveBound = (
export const getCubicBezierCurveBound = (
p0: GlobalPoint,
p1: GlobalPoint,
p2: GlobalPoint,
@ -939,8 +970,9 @@ const getLinearElementRotatedBounds = (
export const getElementBounds = (
element: ExcalidrawElement,
elementsMap: ElementsMap,
nonRotated: boolean = false,
): Bounds => {
return ElementBounds.getBounds(element, elementsMap);
return ElementBounds.getBounds(element, elementsMap, nonRotated);
};
export const getCommonBounds = (
@ -1094,7 +1126,9 @@ export interface BoundingBox {
}
export const getCommonBoundingBox = (
elements: ExcalidrawElement[] | readonly NonDeleted<ExcalidrawElement>[],
elements:
| readonly ExcalidrawElement[]
| readonly NonDeleted<ExcalidrawElement>[],
): BoundingBox => {
const [minX, minY, maxX, maxY] = getCommonBounds(elements);
return {
@ -1133,6 +1167,71 @@ export const getCenterForBounds = (bounds: Bounds): GlobalPoint =>
bounds[1] + (bounds[3] - bounds[1]) / 2,
);
/**
* Get the axis-aligned bounding box for a given element
*/
export const aabbForElement = (
element: Readonly<ExcalidrawElement>,
elementsMap: ElementsMap,
offset?: [number, number, number, number],
) => {
const bbox = {
minX: element.x,
minY: element.y,
maxX: element.x + element.width,
maxY: element.y + element.height,
midX: element.x + element.width / 2,
midY: element.y + element.height / 2,
};
const center = elementCenterPoint(element, elementsMap);
const [topLeftX, topLeftY] = pointRotateRads(
pointFrom(bbox.minX, bbox.minY),
center,
element.angle,
);
const [topRightX, topRightY] = pointRotateRads(
pointFrom(bbox.maxX, bbox.minY),
center,
element.angle,
);
const [bottomRightX, bottomRightY] = pointRotateRads(
pointFrom(bbox.maxX, bbox.maxY),
center,
element.angle,
);
const [bottomLeftX, bottomLeftY] = pointRotateRads(
pointFrom(bbox.minX, bbox.maxY),
center,
element.angle,
);
const bounds = [
Math.min(topLeftX, topRightX, bottomRightX, bottomLeftX),
Math.min(topLeftY, topRightY, bottomRightY, bottomLeftY),
Math.max(topLeftX, topRightX, bottomRightX, bottomLeftX),
Math.max(topLeftY, topRightY, bottomRightY, bottomLeftY),
] as Bounds;
if (offset) {
const [topOffset, rightOffset, downOffset, leftOffset] = offset;
return [
bounds[0] - leftOffset,
bounds[1] - topOffset,
bounds[2] + rightOffset,
bounds[3] + downOffset,
] as Bounds;
}
return bounds;
};
export const pointInsideBounds = <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,
@ -1146,3 +1245,14 @@ export const doBoundsIntersect = (
return minX1 < maxX2 && maxX1 > minX2 && minY1 < maxY2 && maxY1 > minY2;
};
export const elementCenterPoint = (
element: ExcalidrawElement,
elementsMap: ElementsMap,
xOffset: number = 0,
yOffset: number = 0,
) => {
const [x, y] = getCenterForBounds(getElementBounds(element, elementsMap));
return pointFrom<GlobalPoint>(x + xOffset, y + yOffset);
};

View File

@ -1,52 +1,68 @@
import { isTransparent, elementCenterPoint } from "@excalidraw/common";
import { isTransparent } from "@excalidraw/common";
import {
curveIntersectLineSegment,
isPointWithinBounds,
line,
lineSegment,
lineSegmentIntersectionPoints,
pointFrom,
pointFromVector,
pointRotateRads,
pointsEqual,
vectorFromPoint,
vectorNormalize,
vectorScale,
} from "@excalidraw/math";
import {
ellipse,
ellipseLineIntersectionPoints,
ellipseSegmentInterceptPoints,
} from "@excalidraw/math/ellipse";
import { isPointInShape, isPointOnShape } from "@excalidraw/utils/collision";
import { type GeometricShape, getPolygonShape } from "@excalidraw/utils/shape";
import type {
Curve,
GlobalPoint,
LineSegment,
LocalPoint,
Polygon,
Radians,
} from "@excalidraw/math";
import type { FrameNameBounds } from "@excalidraw/excalidraw/types";
import { getBoundTextShape, isPathALoop } from "./shapes";
import { getElementBounds } from "./bounds";
import { isPathALoop } from "./utils";
import {
type Bounds,
doBoundsIntersect,
elementCenterPoint,
getCenterForBounds,
getCubicBezierCurveBound,
getElementBounds,
} from "./bounds";
import {
hasBoundTextElement,
isFreeDrawElement,
isIframeLikeElement,
isImageElement,
isLinearElement,
isTextElement,
} from "./typeChecks";
import {
deconstructDiamondElement,
deconstructLinearOrFreeDrawElement,
deconstructRectanguloidElement,
} from "./utils";
import { getBoundTextElement } from "./textElement";
import { LinearElementEditor } from "./linearElementEditor";
import { distanceToElement } from "./distance";
import type {
ElementsMap,
ExcalidrawDiamondElement,
ExcalidrawElement,
ExcalidrawEllipseElement,
ExcalidrawRectangleElement,
ExcalidrawFreeDrawElement,
ExcalidrawLinearElement,
ExcalidrawRectanguloidElement,
} from "./types";
@ -72,45 +88,64 @@ export const shouldTestInside = (element: ExcalidrawElement) => {
return isDraggableFromInside || isImageElement(element);
};
export type HitTestArgs<Point extends GlobalPoint | LocalPoint> = {
x: number;
y: number;
export type HitTestArgs = {
point: GlobalPoint;
element: ExcalidrawElement;
shape: GeometricShape<Point>;
threshold?: number;
threshold: number;
elementsMap: ElementsMap;
frameNameBound?: FrameNameBounds | null;
};
export const hitElementItself = <Point extends GlobalPoint | LocalPoint>({
x,
y,
export const hitElementItself = ({
point,
element,
shape,
threshold = 10,
threshold,
elementsMap,
frameNameBound = null,
}: HitTestArgs<Point>) => {
let hit = shouldTestInside(element)
? // Since `inShape` tests STRICTLY againt the insides of a shape
// we would need `onShape` as well to include the "borders"
isPointInShape(pointFrom(x, y), shape) ||
isPointOnShape(pointFrom(x, y), shape, threshold)
: isPointOnShape(pointFrom(x, y), shape, threshold);
}: HitTestArgs) => {
// Hit test against a frame's name
const hitFrameName = frameNameBound
? isPointWithinBounds(
pointFrom(frameNameBound.x - threshold, frameNameBound.y - threshold),
point,
pointFrom(
frameNameBound.x + frameNameBound.width + threshold,
frameNameBound.y + frameNameBound.height + threshold,
),
)
: false;
// hit test against a frame's name
if (!hit && frameNameBound) {
hit = isPointInShape(pointFrom(x, y), {
type: "polygon",
data: getPolygonShape(frameNameBound as ExcalidrawRectangleElement)
.data as Polygon<Point>,
});
// 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),
);
// PERF: Bail out early if the point is not even in the
// rotated bounding box or not hitting the frame name (saves 99%)
if (!hitBounds && !hitFrameName) {
return false;
}
return hit;
// Do the precise (and relatively costly) hit test
const hitElement = shouldTestInside(element)
? // Since `inShape` tests STRICTLY againt the insides of a shape
// we would need `onShape` as well to include the "borders"
isPointInElement(point, element, elementsMap) ||
isPointOnElementOutline(point, element, elementsMap, threshold)
: isPointOnElementOutline(point, element, elementsMap, threshold);
return hitElement || hitFrameName;
};
export const hitElementBoundingBox = (
x: number,
y: number,
point: GlobalPoint,
element: ExcalidrawElement,
elementsMap: ElementsMap,
tolerance = 0,
@ -120,37 +155,42 @@ export const hitElementBoundingBox = (
y1 -= tolerance;
x2 += tolerance;
y2 += tolerance;
return isPointWithinBounds(
pointFrom(x1, y1),
pointFrom(x, y),
pointFrom(x2, y2),
);
return isPointWithinBounds(pointFrom(x1, y1), point, pointFrom(x2, y2));
};
export const hitElementBoundingBoxOnly = <
Point extends GlobalPoint | LocalPoint,
>(
hitArgs: HitTestArgs<Point>,
export const hitElementBoundingBoxOnly = (
hitArgs: HitTestArgs,
elementsMap: ElementsMap,
) => {
return (
!hitElementItself(hitArgs) &&
// bound text is considered part of the element (even if it's outside the bounding box)
!hitElementBoundText(
hitArgs.x,
hitArgs.y,
getBoundTextShape(hitArgs.element, elementsMap),
) &&
hitElementBoundingBox(hitArgs.x, hitArgs.y, hitArgs.element, elementsMap)
);
};
) =>
!hitElementItself(hitArgs) &&
// bound text is considered part of the element (even if it's outside the bounding box)
!hitElementBoundText(hitArgs.point, hitArgs.element, elementsMap) &&
hitElementBoundingBox(hitArgs.point, hitArgs.element, elementsMap);
export const hitElementBoundText = <Point extends GlobalPoint | LocalPoint>(
x: number,
y: number,
textShape: GeometricShape<Point> | null,
export const hitElementBoundText = (
point: GlobalPoint,
element: ExcalidrawElement,
elementsMap: ElementsMap,
): boolean => {
return !!textShape && isPointInShape(pointFrom(x, y), textShape);
const boundTextElementCandidate = getBoundTextElement(element, elementsMap);
if (!boundTextElementCandidate) {
return false;
}
const boundTextElement = isLinearElement(element)
? {
...boundTextElementCandidate,
// arrow's bound text accurate position is not stored in the element's property
// but rather calculated and returned from the following static method
...LinearElementEditor.getBoundTextElementPosition(
element,
boundTextElementCandidate,
elementsMap,
),
}
: boundTextElementCandidate;
return isPointInElement(point, boundTextElement, elementsMap);
};
/**
@ -163,9 +203,26 @@ export const hitElementBoundText = <Point extends GlobalPoint | LocalPoint>(
*/
export const intersectElementWithLineSegment = (
element: ExcalidrawElement,
elementsMap: ElementsMap,
line: LineSegment<GlobalPoint>,
offset: number = 0,
onlyFirst = false,
): GlobalPoint[] => {
// First check if the line intersects the element's axis-aligned bounding box
// as it is much faster than checking intersection against the element's shape
const intersectorBounds = [
Math.min(line[0][0] - offset, line[1][0] - offset),
Math.min(line[0][1] - offset, line[1][1] - offset),
Math.max(line[0][0] + offset, line[1][0] + offset),
Math.max(line[0][1] + offset, line[1][1] + offset),
] as Bounds;
const elementBounds = getElementBounds(element, elementsMap);
if (!doBoundsIntersect(intersectorBounds, elementBounds)) {
return [];
}
// Do the actual intersection test against the element's shape
switch (element.type) {
case "rectangle":
case "image":
@ -173,67 +230,196 @@ export const intersectElementWithLineSegment = (
case "iframe":
case "embeddable":
case "frame":
case "selection":
case "magicframe":
return intersectRectanguloidWithLineSegment(element, line, offset);
return intersectRectanguloidWithLineSegment(
element,
elementsMap,
line,
offset,
onlyFirst,
);
case "diamond":
return intersectDiamondWithLineSegment(element, line, offset);
return intersectDiamondWithLineSegment(
element,
elementsMap,
line,
offset,
onlyFirst,
);
case "ellipse":
return intersectEllipseWithLineSegment(element, line, offset);
default:
throw new Error(`Unimplemented element type '${element.type}'`);
return intersectEllipseWithLineSegment(
element,
elementsMap,
line,
offset,
);
case "line":
case "freedraw":
case "arrow":
return intersectLinearOrFreeDrawWithLineSegment(element, line, onlyFirst);
}
};
const curveIntersections = (
curves: Curve<GlobalPoint>[],
segment: LineSegment<GlobalPoint>,
intersections: GlobalPoint[],
center: GlobalPoint,
angle: Radians,
onlyFirst = false,
) => {
for (const c of curves) {
// Optimize by doing a cheap bounding box check first
const b1 = getCubicBezierCurveBound(c[0], c[1], c[2], c[3]);
const b2 = [
Math.min(segment[0][0], segment[1][0]),
Math.min(segment[0][1], segment[1][1]),
Math.max(segment[0][0], segment[1][0]),
Math.max(segment[0][1], segment[1][1]),
] as Bounds;
if (!doBoundsIntersect(b1, b2)) {
continue;
}
const hits = curveIntersectLineSegment(c, segment);
if (hits.length > 0) {
for (const j of hits) {
intersections.push(pointRotateRads(j, center, angle));
}
if (onlyFirst) {
return intersections;
}
}
}
return intersections;
};
const lineIntersections = (
lines: LineSegment<GlobalPoint>[],
segment: LineSegment<GlobalPoint>,
intersections: GlobalPoint[],
center: GlobalPoint,
angle: Radians,
onlyFirst = false,
) => {
for (const l of lines) {
const intersection = lineSegmentIntersectionPoints(l, segment);
if (intersection) {
intersections.push(pointRotateRads(intersection, center, angle));
if (onlyFirst) {
return intersections;
}
}
}
return intersections;
};
const intersectLinearOrFreeDrawWithLineSegment = (
element: ExcalidrawLinearElement | ExcalidrawFreeDrawElement,
segment: LineSegment<GlobalPoint>,
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 intersections: GlobalPoint[] = [];
for (const l of lines) {
const intersection = lineSegmentIntersectionPoints(l, segment);
if (intersection) {
intersections.push(intersection);
if (onlyFirst) {
return intersections;
}
}
}
for (const c of curves) {
// Optimize by doing a cheap bounding box check first
const b1 = getCubicBezierCurveBound(c[0], c[1], c[2], c[3]);
const b2 = [
Math.min(segment[0][0], segment[1][0]),
Math.min(segment[0][1], segment[1][1]),
Math.max(segment[0][0], segment[1][0]),
Math.max(segment[0][1], segment[1][1]),
] as Bounds;
if (!doBoundsIntersect(b1, b2)) {
continue;
}
const hits = curveIntersectLineSegment(c, segment);
if (hits.length > 0) {
intersections.push(...hits);
if (onlyFirst) {
return intersections;
}
}
}
return intersections;
};
const intersectRectanguloidWithLineSegment = (
element: ExcalidrawRectanguloidElement,
l: LineSegment<GlobalPoint>,
elementsMap: ElementsMap,
segment: LineSegment<GlobalPoint>,
offset: number = 0,
onlyFirst = false,
): GlobalPoint[] => {
const center = elementCenterPoint(element);
const center = elementCenterPoint(element, elementsMap);
// To emulate a rotated rectangle we rotate the point in the inverse angle
// instead. It's all the same distance-wise.
const rotatedA = pointRotateRads<GlobalPoint>(
l[0],
segment[0],
center,
-element.angle as Radians,
);
const rotatedB = pointRotateRads<GlobalPoint>(
l[1],
segment[1],
center,
-element.angle as Radians,
);
const rotatedIntersector = lineSegment(rotatedA, rotatedB);
// Get the element's building components we can test against
const [sides, corners] = deconstructRectanguloidElement(element, offset);
return (
// Test intersection against the sides, keep only the valid
// intersection points and rotate them back to scene space
sides
.map((s) =>
lineSegmentIntersectionPoints(
lineSegment<GlobalPoint>(rotatedA, rotatedB),
s,
),
)
.filter((x) => x != null)
.map((j) => pointRotateRads<GlobalPoint>(j!, center, element.angle))
// Test intersection against the corners which are cubic bezier curves,
// keep only the valid intersection points and rotate them back to scene
// space
.concat(
corners
.flatMap((t) =>
curveIntersectLineSegment(t, lineSegment(rotatedA, rotatedB)),
)
.filter((i) => i != null)
.map((j) => pointRotateRads(j, center, element.angle)),
)
// Remove duplicates
.filter(
(p, idx, points) => points.findIndex((d) => pointsEqual(p, d)) === idx,
)
const intersections: GlobalPoint[] = [];
lineIntersections(
sides,
rotatedIntersector,
intersections,
center,
element.angle,
onlyFirst,
);
if (onlyFirst && intersections.length > 0) {
return intersections;
}
curveIntersections(
corners,
rotatedIntersector,
intersections,
center,
element.angle,
onlyFirst,
);
return intersections;
};
/**
@ -245,43 +431,45 @@ const intersectRectanguloidWithLineSegment = (
*/
const intersectDiamondWithLineSegment = (
element: ExcalidrawDiamondElement,
elementsMap: ElementsMap,
l: LineSegment<GlobalPoint>,
offset: number = 0,
onlyFirst = false,
): GlobalPoint[] => {
const center = elementCenterPoint(element);
const center = elementCenterPoint(element, elementsMap);
// Rotate the point to the inverse direction to simulate the rotated diamond
// points. It's all the same distance-wise.
const rotatedA = pointRotateRads(l[0], center, -element.angle as Radians);
const rotatedB = pointRotateRads(l[1], center, -element.angle as Radians);
const rotatedIntersector = lineSegment(rotatedA, rotatedB);
const [sides, curves] = deconstructDiamondElement(element, offset);
const [sides, corners] = deconstructDiamondElement(element, offset);
const intersections: GlobalPoint[] = [];
return (
sides
.map((s) =>
lineSegmentIntersectionPoints(
lineSegment<GlobalPoint>(rotatedA, rotatedB),
s,
),
)
.filter((p): p is GlobalPoint => p != null)
// Rotate back intersection points
.map((p) => pointRotateRads<GlobalPoint>(p!, center, element.angle))
.concat(
curves
.flatMap((p) =>
curveIntersectLineSegment(p, lineSegment(rotatedA, rotatedB)),
)
.filter((p) => p != null)
// Rotate back intersection points
.map((p) => pointRotateRads(p, center, element.angle)),
)
// Remove duplicates
.filter(
(p, idx, points) => points.findIndex((d) => pointsEqual(p, d)) === idx,
)
lineIntersections(
sides,
rotatedIntersector,
intersections,
center,
element.angle,
onlyFirst,
);
if (onlyFirst && intersections.length > 0) {
return intersections;
}
curveIntersections(
corners,
rotatedIntersector,
intersections,
center,
element.angle,
onlyFirst,
);
return intersections;
};
/**
@ -293,16 +481,76 @@ const intersectDiamondWithLineSegment = (
*/
const intersectEllipseWithLineSegment = (
element: ExcalidrawEllipseElement,
elementsMap: ElementsMap,
l: LineSegment<GlobalPoint>,
offset: number = 0,
): GlobalPoint[] => {
const center = elementCenterPoint(element);
const center = elementCenterPoint(element, elementsMap);
const rotatedA = pointRotateRads(l[0], center, -element.angle as Radians);
const rotatedB = pointRotateRads(l[1], center, -element.angle as Radians);
return ellipseLineIntersectionPoints(
return ellipseSegmentInterceptPoints(
ellipse(center, element.width / 2 + offset, element.height / 2 + offset),
line(rotatedA, rotatedB),
lineSegment(rotatedA, rotatedB),
).map((p) => pointRotateRads(p, center, element.angle));
};
/**
* Check if the given point is considered on the given shape's border
*
* @param point
* @param element
* @param tolerance
* @returns
*/
const isPointOnElementOutline = (
point: GlobalPoint,
element: ExcalidrawElement,
elementsMap: ElementsMap,
tolerance = 1,
) => distanceToElement(element, elementsMap, point) <= tolerance;
/**
* Check if the given point is considered inside the element's border
*
* @param point
* @param element
* @returns
*/
export const isPointInElement = (
point: GlobalPoint,
element: ExcalidrawElement,
elementsMap: ElementsMap,
) => {
if (
(isLinearElement(element) || isFreeDrawElement(element)) &&
!isPathALoop(element.points)
) {
// There isn't any "inside" for a non-looping path
return false;
}
const [x1, y1, x2, y2] = getElementBounds(element, elementsMap);
if (!isPointWithinBounds(pointFrom(x1, y1), point, pointFrom(x2, y2))) {
return false;
}
const center = pointFrom<GlobalPoint>((x1 + x2) / 2, (y1 + y2) / 2);
const otherPoint = pointFromVector(
vectorScale(
vectorNormalize(vectorFromPoint(point, center, 0.1)),
Math.max(element.width, element.height) * 2,
),
center,
);
const intersector = lineSegment(point, otherPoint);
const intersections = intersectElementWithLineSegment(
element,
elementsMap,
intersector,
).filter((p, pos, arr) => arr.findIndex((q) => pointsEqual(q, p)) === pos);
return intersections.length % 2 === 1;
};

View File

@ -14,9 +14,8 @@ import {
} from "@excalidraw/math";
import { type Point } from "points-on-curve";
import { elementCenterPoint } from "@excalidraw/common";
import {
elementCenterPoint,
getElementAbsoluteCoords,
getResizedElementAbsoluteCoords,
} from "./bounds";
@ -34,6 +33,7 @@ export const MINIMAL_CROP_SIZE = 10;
export const cropElement = (
element: ExcalidrawImageElement,
elementsMap: ElementsMap,
transformHandle: TransformHandleType,
naturalWidth: number,
naturalHeight: number,
@ -63,7 +63,7 @@ export const cropElement = (
const rotatedPointer = pointRotateRads(
pointFrom(pointerX, pointerY),
elementCenterPoint(element),
elementCenterPoint(element, elementsMap),
-element.angle as Radians,
);

File diff suppressed because it is too large Load Diff

View File

@ -6,27 +6,33 @@ import {
import { ellipse, ellipseDistanceFromPoint } from "@excalidraw/math/ellipse";
import { elementCenterPoint } from "@excalidraw/common";
import type { GlobalPoint, Radians } from "@excalidraw/math";
import {
deconstructDiamondElement,
deconstructLinearOrFreeDrawElement,
deconstructRectanguloidElement,
} from "./utils";
import { elementCenterPoint } from "./bounds";
import type {
ExcalidrawBindableElement,
ElementsMap,
ExcalidrawDiamondElement,
ExcalidrawElement,
ExcalidrawEllipseElement,
ExcalidrawFreeDrawElement,
ExcalidrawLinearElement,
ExcalidrawRectanguloidElement,
} from "./types";
export const distanceToBindableElement = (
element: ExcalidrawBindableElement,
export const distanceToElement = (
element: ExcalidrawElement,
elementsMap: ElementsMap,
p: GlobalPoint,
): number => {
switch (element.type) {
case "selection":
case "rectangle":
case "image":
case "text":
@ -34,11 +40,15 @@ export const distanceToBindableElement = (
case "embeddable":
case "frame":
case "magicframe":
return distanceToRectanguloidElement(element, p);
return distanceToRectanguloidElement(element, elementsMap, p);
case "diamond":
return distanceToDiamondElement(element, p);
return distanceToDiamondElement(element, elementsMap, p);
case "ellipse":
return distanceToEllipseElement(element, p);
return distanceToEllipseElement(element, elementsMap, p);
case "line":
case "arrow":
case "freedraw":
return distanceToLinearOrFreeDraElement(element, p);
}
};
@ -52,9 +62,10 @@ export const distanceToBindableElement = (
*/
const distanceToRectanguloidElement = (
element: ExcalidrawRectanguloidElement,
elementsMap: ElementsMap,
p: GlobalPoint,
) => {
const center = elementCenterPoint(element);
const center = elementCenterPoint(element, elementsMap);
// To emulate a rotated rectangle we rotate the point in the inverse angle
// instead. It's all the same distance-wise.
const rotatedPoint = pointRotateRads(p, center, -element.angle as Radians);
@ -80,9 +91,10 @@ const distanceToRectanguloidElement = (
*/
const distanceToDiamondElement = (
element: ExcalidrawDiamondElement,
elementsMap: ElementsMap,
p: GlobalPoint,
): number => {
const center = elementCenterPoint(element);
const center = elementCenterPoint(element, elementsMap);
// Rotate the point to the inverse direction to simulate the rotated diamond
// points. It's all the same distance-wise.
@ -108,12 +120,24 @@ const distanceToDiamondElement = (
*/
const distanceToEllipseElement = (
element: ExcalidrawEllipseElement,
elementsMap: ElementsMap,
p: GlobalPoint,
): number => {
const center = elementCenterPoint(element);
const center = elementCenterPoint(element, elementsMap);
return ellipseDistanceFromPoint(
// Instead of rotating the ellipse, rotate the point to the inverse angle
pointRotateRads(p, center, -element.angle as Radians),
ellipse(center, element.width / 2, element.height / 2),
);
};
const distanceToLinearOrFreeDraElement = (
element: ExcalidrawLinearElement | ExcalidrawFreeDrawElement,
p: GlobalPoint,
) => {
const [lines, curves] = deconstructLinearOrFreeDrawElement(element);
return Math.min(
...lines.map((s) => distanceToLineSegment(p, s)),
...curves.map((a) => curvePointDistance(a, p)),
);
};

View File

@ -1,7 +1,9 @@
import type { AppState } from "@excalidraw/excalidraw/types";
import { getCommonBoundingBox } from "./bounds";
import { newElementWith } from "./mutateElement";
import { getMaximumGroups } from "./groups";
import { getSelectedElementsByGroup } from "./groups";
import type { ElementsMap, ExcalidrawElement } from "./types";
@ -14,6 +16,7 @@ export const distributeElements = (
selectedElements: ExcalidrawElement[],
elementsMap: ElementsMap,
distribution: Distribution,
appState: Readonly<AppState>,
): ExcalidrawElement[] => {
const [start, mid, end, extent] =
distribution.axis === "x"
@ -21,7 +24,11 @@ export const distributeElements = (
: (["minY", "midY", "maxY", "height"] as const);
const bounds = getCommonBoundingBox(selectedElements);
const groups = getMaximumGroups(selectedElements, elementsMap)
const groups = getSelectedElementsByGroup(
selectedElements,
elementsMap,
appState,
)
.map((group) => [group, getCommonBoundingBox(group)] as const)
.sort((a, b) => a[1][mid] - b[1][mid]);

View File

@ -20,6 +20,7 @@ import {
tupleToCoors,
getSizeFromPoints,
isDevEnv,
arrayToMap,
} from "@excalidraw/common";
import type { AppState } from "@excalidraw/excalidraw/types";
@ -29,10 +30,9 @@ import {
FIXED_BINDING_DISTANCE,
getHeadingForElbowArrowSnap,
getGlobalFixedPointForBindableElement,
snapToMid,
getHoveredElementForBinding,
} from "./binding";
import { distanceToBindableElement } from "./distance";
import { distanceToElement } from "./distance";
import {
compareHeading,
flipHeading,
@ -52,7 +52,7 @@ import {
type NonDeletedSceneElementsMap,
} from "./types";
import { aabbForElement, pointInsideBounds } from "./shapes";
import { aabbForElement, pointInsideBounds } from "./bounds";
import type { Bounds } from "./bounds";
import type { Heading } from "./heading";
@ -359,6 +359,12 @@ const handleSegmentRelease = (
null,
);
if (!restoredPoints || restoredPoints.length < 2) {
throw new Error(
"Property 'points' is required in the update returned by normalizeArrowElementUpdate()",
);
}
const nextPoints: GlobalPoint[] = [];
// First part of the arrow are the old points
@ -706,7 +712,7 @@ const handleEndpointDrag = (
endGlobalPoint: GlobalPoint,
hoveredStartElement: ExcalidrawBindableElement | null,
hoveredEndElement: ExcalidrawBindableElement | null,
) => {
): ElementUpdate<ExcalidrawElbowArrowElement> => {
let startIsSpecial = arrow.startIsSpecial ?? null;
let endIsSpecial = arrow.endIsSpecial ?? null;
const globalUpdatedPoints = updatedPoints.map((p, i) =>
@ -741,8 +747,15 @@ const handleEndpointDrag = (
// Calculate the moving second point connection and add the start point
{
const secondPoint = globalUpdatedPoints[startIsSpecial ? 2 : 1];
const thirdPoint = globalUpdatedPoints[startIsSpecial ? 3 : 2];
const secondPoint = globalUpdatedPoints.at(startIsSpecial ? 2 : 1);
const thirdPoint = globalUpdatedPoints.at(startIsSpecial ? 3 : 2);
if (!secondPoint || !thirdPoint) {
throw new Error(
`Second and third points must exist when handling endpoint drag (${startIsSpecial})`,
);
}
const startIsHorizontal = headingIsHorizontal(startHeading);
const secondIsHorizontal = headingIsHorizontal(
vectorToHeading(vectorFromPoint(secondPoint, thirdPoint)),
@ -801,10 +814,19 @@ const handleEndpointDrag = (
// Calculate the moving second to last point connection
{
const secondToLastPoint =
globalUpdatedPoints[globalUpdatedPoints.length - (endIsSpecial ? 3 : 2)];
const thirdToLastPoint =
globalUpdatedPoints[globalUpdatedPoints.length - (endIsSpecial ? 4 : 3)];
const secondToLastPoint = globalUpdatedPoints.at(
globalUpdatedPoints.length - (endIsSpecial ? 3 : 2),
);
const thirdToLastPoint = globalUpdatedPoints.at(
globalUpdatedPoints.length - (endIsSpecial ? 4 : 3),
);
if (!secondToLastPoint || !thirdToLastPoint) {
throw new Error(
`Second and third to last points must exist when handling endpoint drag (${endIsSpecial})`,
);
}
const endIsHorizontal = headingIsHorizontal(endHeading);
const secondIsHorizontal = headingForPointIsHorizontal(
thirdToLastPoint,
@ -898,50 +920,6 @@ export const updateElbowArrowPoints = (
return { points: updates.points ?? arrow.points };
}
// NOTE (mtolmacs): This is a temporary check to ensure that the incoming elbow
// arrow size is valid. This check will be removed once the issue is identified
if (
arrow.x < -MAX_POS ||
arrow.x > MAX_POS ||
arrow.y < -MAX_POS ||
arrow.y > MAX_POS ||
arrow.x + (updates?.points?.[updates?.points?.length - 1]?.[0] ?? 0) <
-MAX_POS ||
arrow.x + (updates?.points?.[updates?.points?.length - 1]?.[0] ?? 0) >
MAX_POS ||
arrow.y + (updates?.points?.[updates?.points?.length - 1]?.[1] ?? 0) <
-MAX_POS ||
arrow.y + (updates?.points?.[updates?.points?.length - 1]?.[1] ?? 0) >
MAX_POS ||
arrow.x + (arrow?.points?.[arrow?.points?.length - 1]?.[0] ?? 0) <
-MAX_POS ||
arrow.x + (arrow?.points?.[arrow?.points?.length - 1]?.[0] ?? 0) >
MAX_POS ||
arrow.y + (arrow?.points?.[arrow?.points?.length - 1]?.[1] ?? 0) <
-MAX_POS ||
arrow.y + (arrow?.points?.[arrow?.points?.length - 1]?.[1] ?? 0) > MAX_POS
) {
console.error(
"Elbow arrow (or update) is outside reasonable bounds (> 1e6)",
{
arrow,
updates,
},
);
}
// @ts-ignore See above note
arrow.x = clamp(arrow.x, -MAX_POS, MAX_POS);
// @ts-ignore See above note
arrow.y = clamp(arrow.y, -MAX_POS, MAX_POS);
if (updates.points) {
updates.points = updates.points.map(([x, y]) =>
pointFrom<LocalPoint>(
clamp(x, -MAX_POS, MAX_POS),
clamp(y, -MAX_POS, MAX_POS),
),
);
}
if (!import.meta.env.PROD) {
invariant(
!updates.points || updates.points.length >= 2,
@ -1273,6 +1251,7 @@ const getElbowArrowData = (
arrow.startBinding?.fixedPoint,
origStartGlobalPoint,
hoveredStartElement,
elementsMap,
options?.isDragging,
);
const endGlobalPoint = getGlobalPoint(
@ -1286,6 +1265,7 @@ const getElbowArrowData = (
arrow.endBinding?.fixedPoint,
origEndGlobalPoint,
hoveredEndElement,
elementsMap,
options?.isDragging,
);
const startHeading = getBindPointHeading(
@ -1293,12 +1273,14 @@ const getElbowArrowData = (
endGlobalPoint,
hoveredStartElement,
origStartGlobalPoint,
elementsMap,
);
const endHeading = getBindPointHeading(
endGlobalPoint,
startGlobalPoint,
hoveredEndElement,
origEndGlobalPoint,
elementsMap,
);
const startPointBounds = [
startGlobalPoint[0] - 2,
@ -1315,6 +1297,7 @@ const getElbowArrowData = (
const startElementBounds = hoveredStartElement
? aabbForElement(
hoveredStartElement,
elementsMap,
offsetFromHeading(
startHeading,
arrow.startArrowhead
@ -1327,6 +1310,7 @@ const getElbowArrowData = (
const endElementBounds = hoveredEndElement
? aabbForElement(
hoveredEndElement,
elementsMap,
offsetFromHeading(
endHeading,
arrow.endArrowhead
@ -1342,6 +1326,7 @@ const getElbowArrowData = (
hoveredEndElement
? aabbForElement(
hoveredEndElement,
elementsMap,
offsetFromHeading(endHeading, BASE_PADDING, BASE_PADDING),
)
: endPointBounds,
@ -1351,6 +1336,7 @@ const getElbowArrowData = (
hoveredStartElement
? aabbForElement(
hoveredStartElement,
elementsMap,
offsetFromHeading(startHeading, BASE_PADDING, BASE_PADDING),
)
: startPointBounds,
@ -1397,8 +1383,8 @@ const getElbowArrowData = (
BASE_PADDING,
),
boundsOverlap,
hoveredStartElement && aabbForElement(hoveredStartElement),
hoveredEndElement && aabbForElement(hoveredEndElement),
hoveredStartElement && aabbForElement(hoveredStartElement, elementsMap),
hoveredEndElement && aabbForElement(hoveredEndElement, elementsMap),
);
const startDonglePosition = getDonglePosition(
dynamicAABBs[0],
@ -2107,16 +2093,7 @@ const normalizeArrowElementUpdate = (
nextFixedSegments: readonly FixedSegment[] | null,
startIsSpecial?: ExcalidrawElbowArrowElement["startIsSpecial"],
endIsSpecial?: ExcalidrawElbowArrowElement["startIsSpecial"],
): {
points: LocalPoint[];
x: number;
y: number;
width: number;
height: number;
fixedSegments: readonly FixedSegment[] | null;
startIsSpecial?: ExcalidrawElbowArrowElement["startIsSpecial"];
endIsSpecial?: ExcalidrawElbowArrowElement["startIsSpecial"];
} => {
): ElementUpdate<ExcalidrawElbowArrowElement> => {
const offsetX = global[0][0];
const offsetY = global[0][1];
let points = global.map((p) =>
@ -2229,35 +2206,28 @@ const getGlobalPoint = (
fixedPointRatio: [number, number] | undefined | null,
initialPoint: GlobalPoint,
element?: ExcalidrawBindableElement | null,
elementsMap?: ElementsMap,
isDragging?: boolean,
): GlobalPoint => {
if (isDragging) {
if (element) {
const snapPoint = bindPointToSnapToElementOutline(
if (element && elementsMap) {
return bindPointToSnapToElementOutline(
arrow,
element,
startOrEnd,
elementsMap,
);
return snapToMid(element, snapPoint);
}
return initialPoint;
}
if (element) {
const fixedGlobalPoint = getGlobalFixedPointForBindableElement(
return getGlobalFixedPointForBindableElement(
fixedPointRatio || [0, 0],
element,
elementsMap ?? arrayToMap([element]),
);
// NOTE: Resize scales the binding position point too, so we need to update it
return Math.abs(
distanceToBindableElement(element, fixedGlobalPoint) -
FIXED_BINDING_DISTANCE,
) > 0.01
? bindPointToSnapToElementOutline(arrow, element, startOrEnd)
: fixedGlobalPoint;
}
return initialPoint;
@ -2268,6 +2238,7 @@ const getBindPointHeading = (
otherPoint: GlobalPoint,
hoveredElement: ExcalidrawBindableElement | null | undefined,
origPoint: GlobalPoint,
elementsMap: ElementsMap,
): Heading =>
getHeadingForElbowArrowSnap(
p,
@ -2276,7 +2247,8 @@ const getBindPointHeading = (
hoveredElement &&
aabbForElement(
hoveredElement,
Array(4).fill(distanceToBindableElement(hoveredElement, p)) as [
elementsMap,
Array(4).fill(distanceToElement(hoveredElement, elementsMap, p)) as [
number,
number,
number,
@ -2284,6 +2256,7 @@ const getBindPointHeading = (
],
),
origPoint,
elementsMap,
);
const getHoveredElement = (

View File

@ -23,7 +23,7 @@ type IframeDataWithSandbox = MarkRequired<IframeData, "sandbox">;
const embeddedLinkCache = new Map<string, IframeDataWithSandbox>();
const RE_YOUTUBE =
/^(?:http(?:s)?:\/\/)?(?:www\.)?youtu(?:be\.com|\.be)\/(embed\/|watch\?v=|shorts\/|playlist\?list=|embed\/videoseries\?list=)?([a-zA-Z0-9_-]+)(?:\?t=|&t=|\?start=|&start=)?([a-zA-Z0-9_-]+)?[^\s]*$/;
/^(?:http(?:s)?:\/\/)?(?:www\.)?youtu(?:be\.com|\.be)\/(embed\/|watch\?v=|shorts\/|playlist\?list=|embed\/videoseries\?list=)?([a-zA-Z0-9_-]+)/;
const RE_VIMEO =
/^(?:http(?:s)?:\/\/)?(?:(?:w){3}\.)?(?:player\.)?vimeo\.com\/(?:video\/)?([^?\s]+)(?:\?.*)?$/;
@ -56,6 +56,35 @@ const RE_REDDIT =
const RE_REDDIT_EMBED =
/^<blockquote[\s\S]*?\shref=["'](https?:\/\/(?:www\.)?reddit\.com\/[^"']*)/i;
const parseYouTubeTimestamp = (url: string): number => {
let timeParam: string | null | undefined;
try {
const urlObj = new URL(url.startsWith("http") ? url : `https://${url}`);
timeParam =
urlObj.searchParams.get("t") || urlObj.searchParams.get("start");
} catch (error) {
const timeMatch = url.match(/[?&#](?:t|start)=([^&#\s]+)/);
timeParam = timeMatch?.[1];
}
if (!timeParam) {
return 0;
}
if (/^\d+$/.test(timeParam)) {
return parseInt(timeParam, 10);
}
const timeMatch = timeParam.match(/^(?:(\d+)h)?(?:(\d+)m)?(?:(\d+)s)?$/);
if (!timeMatch) {
return 0;
}
const [, hours = "0", minutes = "0", seconds = "0"] = timeMatch;
return parseInt(hours) * 3600 + parseInt(minutes) * 60 + parseInt(seconds);
};
const ALLOWED_DOMAINS = new Set([
"youtube.com",
"youtu.be",
@ -113,7 +142,8 @@ export const getEmbedLink = (
let aspectRatio = { w: 560, h: 840 };
const ytLink = link.match(RE_YOUTUBE);
if (ytLink?.[2]) {
const time = ytLink[3] ? `&start=${ytLink[3]}` : ``;
const startTime = parseYouTubeTimestamp(originalLink);
const time = startTime > 0 ? `&start=${startTime}` : ``;
const isPortrait = link.includes("shorts");
type = "video";
switch (ytLink[1]) {

View File

@ -21,7 +21,7 @@ import {
import { LinearElementEditor } from "./linearElementEditor";
import { mutateElement } from "./mutateElement";
import { newArrowElement, newElement } from "./newElement";
import { aabbForElement } from "./shapes";
import { aabbForElement } from "./bounds";
import { elementsAreInFrameBounds, elementOverlapsWithFrame } from "./frame";
import {
isBindableElement,
@ -95,10 +95,11 @@ const getNodeRelatives = (
type === "predecessors" ? el.points[el.points.length - 1] : [0, 0]
) as Readonly<LocalPoint>;
const heading = headingForPointFromElement(node, aabbForElement(node), [
edgePoint[0] + el.x,
edgePoint[1] + el.y,
] as Readonly<GlobalPoint>);
const heading = headingForPointFromElement(
node,
aabbForElement(node, elementsMap),
[edgePoint[0] + el.x, edgePoint[1] + el.y] as Readonly<GlobalPoint>,
);
acc.push({
relative,

View File

@ -2,7 +2,7 @@ import { generateNKeysBetween } from "fractional-indexing";
import { arrayToMap } from "@excalidraw/common";
import { mutateElement } from "./mutateElement";
import { mutateElement, newElementWith } from "./mutateElement";
import { getBoundTextElement } from "./textElement";
import { hasBoundTextElement } from "./typeChecks";
@ -11,6 +11,7 @@ import type {
ExcalidrawElement,
FractionalIndex,
OrderedExcalidrawElement,
SceneElementsMap,
} from "./types";
export class InvalidFractionalIndexError extends Error {
@ -161,9 +162,15 @@ export const syncMovedIndices = (
// try generatating indices, throws on invalid movedElements
const elementsUpdates = generateIndices(elements, indicesGroups);
const elementsCandidates = elements.map((x) =>
elementsUpdates.has(x) ? { ...x, ...elementsUpdates.get(x) } : x,
);
const elementsCandidates = elements.map((x) => {
const elementUpdates = elementsUpdates.get(x);
if (elementUpdates) {
return { ...x, index: elementUpdates.index };
}
return x;
});
// ensure next indices are valid before mutation, throws on invalid ones
validateFractionalIndices(
@ -177,8 +184,8 @@ export const syncMovedIndices = (
);
// split mutation so we don't end up in an incosistent state
for (const [element, update] of elementsUpdates) {
mutateElement(element, elementsMap, update);
for (const [element, { index }] of elementsUpdates) {
mutateElement(element, elementsMap, { index });
}
} catch (e) {
// fallback to default sync
@ -189,7 +196,7 @@ export const syncMovedIndices = (
};
/**
* Synchronizes all invalid fractional indices with the array order by mutating passed elements.
* Synchronizes all invalid fractional indices within the array order by mutating elements in the passed array.
*
* WARN: in edge cases it could modify the elements which were not moved, as it's impossible to guess the actually moved elements from the elements array itself.
*/
@ -200,13 +207,32 @@ export const syncInvalidIndices = (
const indicesGroups = getInvalidIndicesGroups(elements);
const elementsUpdates = generateIndices(elements, indicesGroups);
for (const [element, update] of elementsUpdates) {
mutateElement(element, elementsMap, update);
for (const [element, { index }] of elementsUpdates) {
mutateElement(element, elementsMap, { index });
}
return elements as OrderedExcalidrawElement[];
};
/**
* Synchronizes all invalid fractional indices within the array order by creating new instances of elements with corrected indices.
*
* WARN: in edge cases it could modify the elements which were not moved, as it's impossible to guess the actually moved elements from the elements array itself.
*/
export const syncInvalidIndicesImmutable = (
elements: readonly ExcalidrawElement[],
): SceneElementsMap | undefined => {
const syncedElements = arrayToMap(elements);
const indicesGroups = getInvalidIndicesGroups(elements);
const elementsUpdates = generateIndices(elements, indicesGroups);
for (const [element, { index }] of elementsUpdates) {
syncedElements.set(element.id, newElementWith(element, { index }));
}
return syncedElements as SceneElementsMap;
};
/**
* Get contiguous groups of indices of passed moved elements.
*

View File

@ -7,6 +7,8 @@ import type { Mutable } from "@excalidraw/common/utility-types";
import { getBoundTextElement } from "./textElement";
import { isBoundToContainer } from "./typeChecks";
import { makeNextSelectedElementIds, getSelectedElements } from "./selection";
import type {
@ -402,3 +404,78 @@ export const getNewGroupIdsForDuplication = (
return copy;
};
// given a list of selected elements, return the element grouped by their immediate group selected state
// in the case if only one group is selected and all elements selected are within the group, it will respect group hierarchy in accordance to their nested grouping order
export const getSelectedElementsByGroup = (
selectedElements: ExcalidrawElement[],
elementsMap: ElementsMap,
appState: Readonly<AppState>,
): ExcalidrawElement[][] => {
const selectedGroupIds = getSelectedGroupIds(appState);
const unboundElements = selectedElements.filter(
(element) => !isBoundToContainer(element),
);
const groups: Map<string, ExcalidrawElement[]> = new Map();
const elements: Map<string, ExcalidrawElement[]> = new Map();
// helper function to add an element to the elements map
const addToElementsMap = (element: ExcalidrawElement) => {
// elements
const currentElementMembers = elements.get(element.id) || [];
const boundTextElement = getBoundTextElement(element, elementsMap);
if (boundTextElement) {
currentElementMembers.push(boundTextElement);
}
elements.set(element.id, [...currentElementMembers, element]);
};
// helper function to add an element to the groups map
const addToGroupsMap = (element: ExcalidrawElement, groupId: string) => {
// groups
const currentGroupMembers = groups.get(groupId) || [];
const boundTextElement = getBoundTextElement(element, elementsMap);
if (boundTextElement) {
currentGroupMembers.push(boundTextElement);
}
groups.set(groupId, [...currentGroupMembers, element]);
};
// helper function to handle the case where a single group is selected
// and all elements selected are within the group, it will respect group hierarchy in accordance to
// their nested grouping order
const handleSingleSelectedGroupCase = (
element: ExcalidrawElement,
selectedGroupId: GroupId,
) => {
const indexOfSelectedGroupId = element.groupIds.indexOf(selectedGroupId, 0);
const nestedGroupCount = element.groupIds.slice(
0,
indexOfSelectedGroupId,
).length;
return nestedGroupCount > 0
? addToGroupsMap(element, element.groupIds[indexOfSelectedGroupId - 1])
: addToElementsMap(element);
};
const isAllInSameGroup = selectedElements.every((element) =>
isSelectedViaGroup(appState, element),
);
unboundElements.forEach((element) => {
const selectedGroupId = getSelectedGroupIdForElement(
element,
appState.selectedGroupIds,
);
if (!selectedGroupId) {
addToElementsMap(element);
} else if (selectedGroupIds.length === 1 && isAllInSameGroup) {
handleSingleSelectedGroupCase(element, selectedGroupId);
} else {
addToGroupsMap(element, selectedGroupId);
}
});
return Array.from(groups.values()).concat(Array.from(elements.values()));
};

View File

@ -97,14 +97,13 @@ export * from "./image";
export * from "./linearElementEditor";
export * from "./mutateElement";
export * from "./newElement";
export * from "./positionElementsOnGrid";
export * from "./renderElement";
export * from "./resizeElements";
export * from "./resizeTest";
export * from "./Scene";
export * from "./selection";
export * from "./Shape";
export * from "./ShapeCache";
export * from "./shapes";
export * from "./shape";
export * from "./showSelectedShapeActions";
export * from "./sizeHelpers";
export * from "./sortElements";

View File

@ -7,6 +7,8 @@ import {
type LocalPoint,
pointDistance,
vectorFromPoint,
curveLength,
curvePointAtLength,
} from "@excalidraw/math";
import { getCurvePathOps } from "@excalidraw/utils/shape";
@ -18,9 +20,14 @@ import {
getGridPoint,
invariant,
tupleToCoors,
viewportCoordsToSceneCoords,
} from "@excalidraw/common";
import type { Store } from "@excalidraw/element";
import {
deconstructLinearOrFreeDrawElement,
isPathALoop,
type Store,
} from "@excalidraw/element";
import type { Radians } from "@excalidraw/math";
@ -39,6 +46,7 @@ import {
bindOrUnbindLinearElement,
getHoveredElementForBinding,
isBindingEnabled,
maybeSuggestBindingsForLinearElementAtCoords,
} from "./binding";
import {
getElementAbsoluteCoords,
@ -55,16 +63,7 @@ import {
isFixedPointBinding,
} from "./typeChecks";
import { ShapeCache } from "./ShapeCache";
import {
isPathALoop,
getBezierCurveLength,
getControlPointsForBezierCurve,
mapIntervalToBezierT,
getBezierXY,
toggleLinePolygonState,
} from "./shapes";
import { ShapeCache, toggleLinePolygonState } from "./shape";
import { getLockedLinearCursorAlignSize } from "./sizeHelpers";
@ -149,10 +148,13 @@ export class LinearElementEditor {
public readonly hoverPointIndex: number;
public readonly segmentMidPointHoveredCoords: GlobalPoint | null;
public readonly elbowed: boolean;
public readonly customLineAngle: number | null;
public readonly isEditing: boolean;
constructor(
element: NonDeleted<ExcalidrawLinearElement>,
elementsMap: ElementsMap,
isEditing: boolean = false,
) {
this.elementId = element.id as string & {
_brand: "excalidrawLinearElementId";
@ -186,6 +188,8 @@ export class LinearElementEditor {
this.hoverPointIndex = -1;
this.segmentMidPointHoveredCoords = null;
this.elbowed = isElbowArrow(element) && element.elbowed;
this.customLineAngle = null;
this.isEditing = isEditing;
}
// ---------------------------------------------------------------------------
@ -193,6 +197,7 @@ export class LinearElementEditor {
// ---------------------------------------------------------------------------
static POINT_HANDLE_SIZE = 10;
/**
* @param id the `elementId` from the instance of this class (so that we can
* statically guarantee this method returns an ExcalidrawLinearElement)
@ -214,11 +219,14 @@ export class LinearElementEditor {
setState: React.Component<any, AppState>["setState"],
elementsMap: NonDeletedSceneElementsMap,
) {
if (!appState.editingLinearElement || !appState.selectionElement) {
if (
!appState.selectedLinearElement?.isEditing ||
!appState.selectionElement
) {
return false;
}
const { editingLinearElement } = appState;
const { selectedPointsIndices, elementId } = editingLinearElement;
const { selectedLinearElement } = appState;
const { selectedPointsIndices, elementId } = selectedLinearElement;
const element = LinearElementEditor.getElement(elementId, elementsMap);
if (!element) {
@ -259,8 +267,8 @@ export class LinearElementEditor {
});
setState({
editingLinearElement: {
...editingLinearElement,
selectedLinearElement: {
...selectedLinearElement,
selectedPointsIndices: nextSelectedPoints.length
? nextSelectedPoints
: null,
@ -276,19 +284,15 @@ export class LinearElementEditor {
app: AppClassProperties,
scenePointerX: number,
scenePointerY: number,
maybeSuggestBinding: (
element: NonDeleted<ExcalidrawLinearElement>,
pointSceneCoords: { x: number; y: number }[],
) => void,
linearElementEditor: LinearElementEditor,
scene: Scene,
): LinearElementEditor | null {
): Pick<AppState, keyof AppState> | null {
if (!linearElementEditor) {
return null;
}
const { elementId } = linearElementEditor;
const elementsMap = scene.getNonDeletedElementsMap();
const elementsMap = app.scene.getNonDeletedElementsMap();
const element = LinearElementEditor.getElement(elementId, elementsMap);
let customLineAngle = linearElementEditor.customLineAngle;
if (!element) {
return null;
}
@ -329,6 +333,12 @@ export class LinearElementEditor {
const selectedIndex = selectedPointsIndices[0];
const referencePoint =
element.points[selectedIndex === 0 ? 1 : selectedIndex - 1];
customLineAngle =
linearElementEditor.customLineAngle ??
Math.atan2(
element.points[selectedIndex][1] - referencePoint[1],
element.points[selectedIndex][0] - referencePoint[0],
);
const [width, height] = LinearElementEditor._getShiftLockedDelta(
element,
@ -336,11 +346,12 @@ export class LinearElementEditor {
referencePoint,
pointFrom(scenePointerX, scenePointerY),
event[KEYS.CTRL_OR_CMD] ? null : app.getEffectiveGridSize(),
customLineAngle,
);
LinearElementEditor.movePoints(
element,
scene,
app.scene,
new Map([
[
selectedIndex,
@ -368,7 +379,7 @@ export class LinearElementEditor {
LinearElementEditor.movePoints(
element,
scene,
app.scene,
new Map(
selectedPointsIndices.map((pointIndex) => {
const newPointPosition: LocalPoint =
@ -400,46 +411,59 @@ export class LinearElementEditor {
const boundTextElement = getBoundTextElement(element, elementsMap);
if (boundTextElement) {
handleBindTextResize(element, scene, false);
handleBindTextResize(element, app.scene, false);
}
// suggest bindings for first and last point if selected
let suggestedBindings: ExcalidrawBindableElement[] = [];
if (isBindingElement(element, false)) {
const firstSelectedIndex = selectedPointsIndices[0] === 0;
const lastSelectedIndex =
selectedPointsIndices[selectedPointsIndices.length - 1] ===
element.points.length - 1;
const coords: { x: number; y: number }[] = [];
const firstSelectedIndex = selectedPointsIndices[0];
if (firstSelectedIndex === 0) {
coords.push(
tupleToCoors(
LinearElementEditor.getPointGlobalCoordinates(
element,
element.points[0],
elementsMap,
if (!firstSelectedIndex !== !lastSelectedIndex) {
coords.push({ x: scenePointerX, y: scenePointerY });
} else {
if (firstSelectedIndex) {
coords.push(
tupleToCoors(
LinearElementEditor.getPointGlobalCoordinates(
element,
element.points[0],
elementsMap,
),
),
),
);
}
);
}
const lastSelectedIndex =
selectedPointsIndices[selectedPointsIndices.length - 1];
if (lastSelectedIndex === element.points.length - 1) {
coords.push(
tupleToCoors(
LinearElementEditor.getPointGlobalCoordinates(
element,
element.points[lastSelectedIndex],
elementsMap,
if (lastSelectedIndex) {
coords.push(
tupleToCoors(
LinearElementEditor.getPointGlobalCoordinates(
element,
element.points[
selectedPointsIndices[selectedPointsIndices.length - 1]
],
elementsMap,
),
),
),
);
);
}
}
if (coords.length) {
maybeSuggestBinding(element, coords);
suggestedBindings = maybeSuggestBindingsForLinearElementAtCoords(
element,
coords,
app.scene,
app.state.zoom,
);
}
}
return {
const newLinearElementEditor = {
...linearElementEditor,
selectedPointsIndices,
segmentMidPointHoveredCoords:
@ -457,6 +481,13 @@ export class LinearElementEditor {
? lastClickedPoint
: -1,
isDragging: true,
customLineAngle,
};
return {
...app.state,
selectedLinearElement: newLinearElementEditor,
suggestedBindings,
};
}
@ -471,6 +502,7 @@ export class LinearElementEditor {
): LinearElementEditor {
const elementsMap = scene.getNonDeletedElementsMap();
const elements = scene.getNonDeletedElements();
const pointerCoords = viewportCoordsToSceneCoords(event, appState);
const { elementId, selectedPointsIndices, isDragging, pointerDownState } =
editingLinearElement;
@ -526,13 +558,15 @@ export class LinearElementEditor {
const bindingElement = isBindingEnabled(appState)
? getHoveredElementForBinding(
tupleToCoors(
LinearElementEditor.getPointAtIndexGlobalCoordinates(
element,
selectedPoint!,
elementsMap,
),
),
(selectedPointsIndices?.length ?? 0) > 1
? tupleToCoors(
LinearElementEditor.getPointAtIndexGlobalCoordinates(
element,
selectedPoint!,
elementsMap,
),
)
: pointerCoords,
elements,
elementsMap,
appState.zoom,
@ -551,6 +585,8 @@ export class LinearElementEditor {
return {
...editingLinearElement,
...bindings,
segmentMidPointHoveredCoords: null,
hoverPointIndex: -1,
// if clicking without previously dragging a point(s), and not holding
// shift, deselect all points except the one clicked. If holding shift,
// toggle the point.
@ -572,6 +608,7 @@ export class LinearElementEditor {
: selectedPointsIndices,
isDragging: false,
pointerOffset: { x: 0, y: 0 },
customLineAngle: null,
};
}
@ -585,7 +622,7 @@ export class LinearElementEditor {
// Since its not needed outside editor unless 2 pointer lines or bound text
if (
!isElbowArrow(element) &&
!appState.editingLinearElement &&
!appState.selectedLinearElement?.isEditing &&
element.points.length > 2 &&
!boundText
) {
@ -615,10 +652,7 @@ export class LinearElementEditor {
}
const segmentMidPoint = LinearElementEditor.getSegmentMidPoint(
element,
points[index],
points[index + 1],
index + 1,
elementsMap,
);
midpoints.push(segmentMidPoint);
index++;
@ -654,7 +688,7 @@ export class LinearElementEditor {
);
if (
points.length >= 3 &&
!appState.editingLinearElement &&
!appState.selectedLinearElement?.isEditing &&
!isElbowArrow(element)
) {
return null;
@ -720,7 +754,18 @@ export class LinearElementEditor {
let distance = pointDistance(startPoint, endPoint);
if (element.points.length > 2 && element.roundness) {
distance = getBezierCurveLength(element, endPoint);
const [lines, curves] = deconstructLinearOrFreeDrawElement(element);
invariant(
lines.length === 0 && curves.length > 0,
"Only linears built out of curves are supported",
);
invariant(
lines.length + curves.length >= index,
"Invalid segment index while calculating mid point",
);
distance = curveLength<GlobalPoint>(curves[index]);
}
return distance * zoom.value < LinearElementEditor.POINT_HANDLE_SIZE * 4;
@ -728,39 +773,42 @@ export class LinearElementEditor {
static getSegmentMidPoint(
element: NonDeleted<ExcalidrawLinearElement>,
startPoint: GlobalPoint,
endPoint: GlobalPoint,
endPointIndex: number,
elementsMap: ElementsMap,
index: number,
): GlobalPoint {
let segmentMidPoint = pointCenter(startPoint, endPoint);
if (element.points.length > 2 && element.roundness) {
const controlPoints = getControlPointsForBezierCurve(
element,
element.points[endPointIndex],
if (isElbowArrow(element)) {
invariant(
element.points.length >= index,
"Invalid segment index while calculating elbow arrow mid point",
);
if (controlPoints) {
const t = mapIntervalToBezierT(
element,
element.points[endPointIndex],
0.5,
);
segmentMidPoint = LinearElementEditor.getPointGlobalCoordinates(
element,
getBezierXY(
controlPoints[0],
controlPoints[1],
controlPoints[2],
controlPoints[3],
t,
),
elementsMap,
);
}
const p = pointCenter(element.points[index - 1], element.points[index]);
return pointFrom<GlobalPoint>(element.x + p[0], element.y + p[1]);
}
return segmentMidPoint;
const [lines, curves] = deconstructLinearOrFreeDrawElement(element);
invariant(
(lines.length === 0 && curves.length > 0) ||
(lines.length > 0 && curves.length === 0),
"Only linears built out of either segments or curves are supported",
);
invariant(
lines.length + curves.length >= index,
"Invalid segment index while calculating mid point",
);
if (lines.length) {
const segment = lines[index - 1];
return pointCenter(segment[0], segment[1]);
}
if (curves.length) {
const segment = curves[index - 1];
return curvePointAtLength(segment, 0.5);
}
invariant(false, "Invalid segment type while calculating mid point");
}
static getSegmentMidPointIndex(
@ -837,7 +885,7 @@ export class LinearElementEditor {
segmentMidpoint,
elementsMap,
);
} else if (event.altKey && appState.editingLinearElement) {
} else if (event.altKey && appState.selectedLinearElement?.isEditing) {
if (linearElementEditor.lastUncommittedPoint == null) {
scene.mutateElement(element, {
points: [
@ -979,14 +1027,14 @@ export class LinearElementEditor {
app: AppClassProperties,
): LinearElementEditor | null {
const appState = app.state;
if (!appState.editingLinearElement) {
if (!appState.selectedLinearElement?.isEditing) {
return null;
}
const { elementId, lastUncommittedPoint } = appState.editingLinearElement;
const { elementId, lastUncommittedPoint } = appState.selectedLinearElement;
const elementsMap = app.scene.getNonDeletedElementsMap();
const element = LinearElementEditor.getElement(elementId, elementsMap);
if (!element) {
return appState.editingLinearElement;
return appState.selectedLinearElement;
}
const { points } = element;
@ -996,10 +1044,12 @@ export class LinearElementEditor {
if (lastPoint === lastUncommittedPoint) {
LinearElementEditor.deletePoints(element, app, [points.length - 1]);
}
return {
...appState.editingLinearElement,
lastUncommittedPoint: null,
};
return appState.selectedLinearElement?.lastUncommittedPoint
? {
...appState.selectedLinearElement,
lastUncommittedPoint: null,
}
: appState.selectedLinearElement;
}
let newPoint: LocalPoint;
@ -1023,8 +1073,8 @@ export class LinearElementEditor {
newPoint = LinearElementEditor.createPointAt(
element,
elementsMap,
scenePointerX - appState.editingLinearElement.pointerOffset.x,
scenePointerY - appState.editingLinearElement.pointerOffset.y,
scenePointerX - appState.selectedLinearElement.pointerOffset.x,
scenePointerY - appState.selectedLinearElement.pointerOffset.y,
event[KEYS.CTRL_OR_CMD] || isElbowArrow(element)
? null
: app.getEffectiveGridSize(),
@ -1048,7 +1098,7 @@ export class LinearElementEditor {
LinearElementEditor.addPoints(element, app.scene, [newPoint]);
}
return {
...appState.editingLinearElement,
...appState.selectedLinearElement,
lastUncommittedPoint: element.points[element.points.length - 1],
};
}
@ -1207,12 +1257,12 @@ export class LinearElementEditor {
// ---------------------------------------------------------------------------
static duplicateSelectedPoints(appState: AppState, scene: Scene): AppState {
invariant(
appState.editingLinearElement,
appState.selectedLinearElement?.isEditing,
"Not currently editing a linear element",
);
const elementsMap = scene.getNonDeletedElementsMap();
const { selectedPointsIndices, elementId } = appState.editingLinearElement;
const { selectedPointsIndices, elementId } = appState.selectedLinearElement;
const element = LinearElementEditor.getElement(elementId, elementsMap);
invariant(
@ -1274,8 +1324,8 @@ export class LinearElementEditor {
return {
...appState,
editingLinearElement: {
...appState.editingLinearElement,
selectedLinearElement: {
...appState.selectedLinearElement,
selectedPointsIndices: nextSelectedIndices,
},
};
@ -1287,8 +1337,9 @@ export class LinearElementEditor {
pointIndices: readonly number[],
) {
const isUncommittedPoint =
app.state.editingLinearElement?.lastUncommittedPoint ===
element.points[element.points.length - 1];
app.state.selectedLinearElement?.isEditing &&
app.state.selectedLinearElement?.lastUncommittedPoint ===
element.points[element.points.length - 1];
const nextPoints = element.points.filter((_, idx) => {
return !pointIndices.includes(idx);
@ -1461,7 +1512,7 @@ export class LinearElementEditor {
pointFrom(pointerCoords.x, pointerCoords.y),
);
if (
!appState.editingLinearElement &&
!appState.selectedLinearElement?.isEditing &&
dist < DRAGGING_THRESHOLD / appState.zoom.value
) {
return false;
@ -1593,6 +1644,7 @@ export class LinearElementEditor {
referencePoint: LocalPoint,
scenePointer: GlobalPoint,
gridSize: NullableGridSize,
customLineAngle?: number,
) {
const referencePointCoords = LinearElementEditor.getPointGlobalCoordinates(
element,
@ -1618,6 +1670,7 @@ export class LinearElementEditor {
referencePointCoords[1],
gridX,
gridY,
customLineAngle,
);
return pointRotateRads(
@ -1654,10 +1707,7 @@ export class LinearElementEditor {
const index = element.points.length / 2 - 1;
const midSegmentMidpoint = LinearElementEditor.getSegmentMidPoint(
element,
points[index],
points[index + 1],
index + 1,
elementsMap,
);
x = midSegmentMidpoint[0] - boundTextElement.width / 2;

View File

@ -8,7 +8,7 @@ import type { Radians } from "@excalidraw/math";
import type { Mutable } from "@excalidraw/common/utility-types";
import { ShapeCache } from "./ShapeCache";
import { ShapeCache } from "./shape";
import { updateElbowArrowPoints } from "./elbowArrow";
@ -23,7 +23,7 @@ import type {
export type ElementUpdate<TElement extends ExcalidrawElement> = Omit<
Partial<TElement>,
"id" | "version" | "versionNonce" | "updated"
"id" | "updated"
>;
/**
@ -137,8 +137,8 @@ export const mutateElement = <TElement extends Mutable<ExcalidrawElement>>(
ShapeCache.delete(element);
}
element.version++;
element.versionNonce = randomInteger();
element.version = updates.version ?? element.version + 1;
element.versionNonce = updates.versionNonce ?? randomInteger();
element.updated = getUpdatedTimestamp();
return element;
@ -172,9 +172,9 @@ export const newElementWith = <TElement extends ExcalidrawElement>(
return {
...element,
...updates,
version: updates.version ?? element.version + 1,
versionNonce: updates.versionNonce ?? randomInteger(),
updated: getUpdatedTimestamp(),
version: element.version + 1,
versionNonce: randomInteger(),
};
};

View File

@ -0,0 +1,112 @@
import { getCommonBounds } from "./bounds";
import { type ElementUpdate, newElementWith } from "./mutateElement";
import type { ExcalidrawElement } from "./types";
// TODO rewrite (mostly vibe-coded)
export const positionElementsOnGrid = <TElement extends ExcalidrawElement>(
elements: TElement[] | TElement[][],
centerX: number,
centerY: number,
padding = 50,
): TElement[] => {
// Ensure there are elements to position
if (!elements || elements.length === 0) {
return [];
}
const res: TElement[] = [];
// Normalize input to work with atomic units (groups of elements)
// If elements is a flat array, treat each element as its own atomic unit
const atomicUnits: TElement[][] = Array.isArray(elements[0])
? (elements as TElement[][])
: (elements as TElement[]).map((element) => [element]);
// Determine the number of columns for atomic units
// A common approach for a "grid-like" layout without specific column constraints
// is to aim for a roughly square arrangement.
const numUnits = atomicUnits.length;
const numColumns = Math.max(1, Math.ceil(Math.sqrt(numUnits)));
// Group atomic units into rows based on the calculated number of columns
const rows: TElement[][][] = [];
for (let i = 0; i < numUnits; i += numColumns) {
rows.push(atomicUnits.slice(i, i + numColumns));
}
// Calculate properties for each row (total width, max height)
// and the total actual height of all row content.
let totalGridActualHeight = 0; // Sum of max heights of rows, without inter-row padding
const rowProperties = rows.map((rowUnits) => {
let rowWidth = 0;
let maxUnitHeightInRow = 0;
const unitBounds = rowUnits.map((unit) => {
const [minX, minY, maxX, maxY] = getCommonBounds(unit);
return {
elements: unit,
bounds: [minX, minY, maxX, maxY] as const,
width: maxX - minX,
height: maxY - minY,
};
});
unitBounds.forEach((unitBound, index) => {
rowWidth += unitBound.width;
// Add padding between units in the same row, but not after the last one
if (index < unitBounds.length - 1) {
rowWidth += padding;
}
if (unitBound.height > maxUnitHeightInRow) {
maxUnitHeightInRow = unitBound.height;
}
});
totalGridActualHeight += maxUnitHeightInRow;
return {
unitBounds,
width: rowWidth,
maxHeight: maxUnitHeightInRow,
};
});
// Calculate the total height of the grid including padding between rows
const totalGridHeightWithPadding =
totalGridActualHeight + Math.max(0, rows.length - 1) * padding;
// Calculate the starting Y position to center the entire grid vertically around centerY
let currentY = centerY - totalGridHeightWithPadding / 2;
// Position atomic units row by row
rowProperties.forEach((rowProp) => {
const { unitBounds, width: rowWidth, maxHeight: rowMaxHeight } = rowProp;
// Calculate the starting X for the current row to center it horizontally around centerX
let currentX = centerX - rowWidth / 2;
unitBounds.forEach((unitBound) => {
// Calculate the offset needed to position this atomic unit
const [originalMinX, originalMinY] = unitBound.bounds;
const offsetX = currentX - originalMinX;
const offsetY = currentY - originalMinY;
// Apply the offset to all elements in this atomic unit
unitBound.elements.forEach((element) => {
res.push(
newElementWith(element, {
x: element.x + offsetX,
y: element.y + offsetY,
} as ElementUpdate<TElement>),
);
});
// Move X for the next unit in the row
currentX += unitBound.width + padding;
});
// Move Y to the starting position for the next row
// This accounts for the tallest unit in the current row and the inter-row padding
currentY += rowMaxHeight + padding;
});
return res;
};

View File

@ -1,7 +1,14 @@
import rough from "roughjs/bin/rough";
import { getStroke } from "perfect-freehand";
import { isRightAngleRads } from "@excalidraw/math";
import {
type GlobalPoint,
isRightAngleRads,
lineSegment,
pointFrom,
pointRotateRads,
type Radians,
} from "@excalidraw/math";
import {
BOUND_TEXT_PADDING,
@ -14,6 +21,7 @@ import {
getFontString,
isRTL,
getVerticalOffset,
invariant,
} from "@excalidraw/common";
import type {
@ -32,7 +40,7 @@ import type {
InteractiveCanvasRenderConfig,
} from "@excalidraw/excalidraw/scene/types";
import { getElementAbsoluteCoords } from "./bounds";
import { getElementAbsoluteCoords, getElementBounds } from "./bounds";
import { getUncroppedImageElement } from "./cropElement";
import { LinearElementEditor } from "./linearElementEditor";
import {
@ -54,9 +62,9 @@ import {
isImageElement,
} from "./typeChecks";
import { getContainingFrame } from "./frame";
import { getCornerRadius } from "./shapes";
import { getCornerRadius } from "./utils";
import { ShapeCache } from "./ShapeCache";
import { ShapeCache } from "./shape";
import type {
ExcalidrawElement,
@ -106,6 +114,11 @@ const getCanvasPadding = (element: ExcalidrawElement) => {
return element.strokeWidth * 12;
case "text":
return element.fontSize / 2;
case "arrow":
if (element.endArrowhead || element.endArrowhead) {
return 40;
}
return 20;
default:
return 20;
}
@ -1034,6 +1047,66 @@ export function getFreeDrawPath2D(element: ExcalidrawFreeDrawElement) {
}
export function getFreeDrawSvgPath(element: ExcalidrawFreeDrawElement) {
return getSvgPathFromStroke(getFreedrawOutlinePoints(element));
}
export function getFreedrawOutlineAsSegments(
element: ExcalidrawFreeDrawElement,
points: [number, number][],
elementsMap: ElementsMap,
) {
const bounds = getElementBounds(
{
...element,
angle: 0 as Radians,
},
elementsMap,
);
const center = pointFrom<GlobalPoint>(
(bounds[0] + bounds[2]) / 2,
(bounds[1] + bounds[3]) / 2,
);
invariant(points.length >= 2, "Freepath outline must have at least 2 points");
return points.slice(2).reduce(
(acc, curr) => {
acc.push(
lineSegment<GlobalPoint>(
acc[acc.length - 1][1],
pointRotateRads(
pointFrom<GlobalPoint>(curr[0] + element.x, curr[1] + element.y),
center,
element.angle,
),
),
);
return acc;
},
[
lineSegment<GlobalPoint>(
pointRotateRads(
pointFrom<GlobalPoint>(
points[0][0] + element.x,
points[0][1] + element.y,
),
center,
element.angle,
),
pointRotateRads(
pointFrom<GlobalPoint>(
points[1][0] + element.x,
points[1][1] + element.y,
),
center,
element.angle,
),
),
],
);
}
export function getFreedrawOutlinePoints(element: ExcalidrawFreeDrawElement) {
// If input points are empty (should they ever be?) return a dot
const inputPoints = element.simulatePressure
? element.points
@ -1052,7 +1125,7 @@ export function getFreeDrawSvgPath(element: ExcalidrawFreeDrawElement) {
last: !!element.lastCommittedPoint, // LastCommittedPoint is added on pointerup
};
return getSvgPathFromStroke(getStroke(inputPoints as number[][], options));
return getStroke(inputPoints as number[][], options) as [number, number][];
}
function med(A: number[], B: number[]) {

View File

@ -2,7 +2,6 @@ import {
pointCenter,
normalizeRadians,
pointFrom,
pointFromPair,
pointRotateRads,
type Radians,
type LocalPoint,
@ -36,6 +35,7 @@ import {
getContainerElement,
handleBindTextResize,
getBoundTextMaxWidth,
computeBoundTextPosition,
} from "./textElement";
import {
getMinTextElementWidth,
@ -104,18 +104,6 @@ export const transformElements = (
);
updateBoundElements(element, scene);
}
} else if (isTextElement(element) && transformHandleType) {
resizeSingleTextElement(
originalElements,
element,
scene,
transformHandleType,
shouldResizeFromCenter,
pointerX,
pointerY,
);
updateBoundElements(element, scene);
return true;
} else if (transformHandleType) {
const elementId = selectedElements[0].id;
const latestElement = elementsMap.get(elementId);
@ -150,6 +138,9 @@ export const transformElements = (
);
}
}
if (isTextElement(element)) {
updateBoundElements(element, scene);
}
return true;
} else if (selectedElements.length > 1) {
if (transformHandleType === "rotation") {
@ -235,7 +226,16 @@ const rotateSingleElement = (
scene.getElement<ExcalidrawTextElementWithContainer>(boundTextElementId);
if (textElement && !isArrowElement(element)) {
scene.mutateElement(textElement, { angle });
const { x, y } = computeBoundTextPosition(
element,
textElement,
scene.getNonDeletedElementsMap(),
);
scene.mutateElement(textElement, {
angle,
x,
y,
});
}
}
};
@ -282,151 +282,50 @@ export const measureFontSizeFromWidth = (
};
};
const resizeSingleTextElement = (
originalElements: PointerDownState["originalElements"],
export const resizeSingleTextElement = (
origElement: NonDeleted<ExcalidrawTextElement>,
element: NonDeleted<ExcalidrawTextElement>,
scene: Scene,
transformHandleType: TransformHandleDirection,
shouldResizeFromCenter: boolean,
pointerX: number,
pointerY: number,
nextWidth: number,
nextHeight: number,
) => {
const elementsMap = scene.getNonDeletedElementsMap();
const [x1, y1, x2, y2, cx, cy] = getElementAbsoluteCoords(
element,
elementsMap,
);
// rotation pointer with reverse angle
const [rotatedX, rotatedY] = pointRotateRads(
pointFrom(pointerX, pointerY),
pointFrom(cx, cy),
-element.angle as Radians,
);
let scaleX = 0;
let scaleY = 0;
if (transformHandleType !== "e" && transformHandleType !== "w") {
if (transformHandleType.includes("e")) {
scaleX = (rotatedX - x1) / (x2 - x1);
}
if (transformHandleType.includes("w")) {
scaleX = (x2 - rotatedX) / (x2 - x1);
}
if (transformHandleType.includes("n")) {
scaleY = (y2 - rotatedY) / (y2 - y1);
}
if (transformHandleType.includes("s")) {
scaleY = (rotatedY - y1) / (y2 - y1);
}
const metricsWidth = element.width * (nextHeight / element.height);
const metrics = measureFontSizeFromWidth(element, elementsMap, metricsWidth);
if (metrics === null) {
return;
}
const scale = Math.max(scaleX, scaleY);
if (transformHandleType.includes("n") || transformHandleType.includes("s")) {
const previousOrigin = pointFrom<GlobalPoint>(origElement.x, origElement.y);
if (scale > 0) {
const nextWidth = element.width * scale;
const nextHeight = element.height * scale;
const metrics = measureFontSizeFromWidth(element, elementsMap, nextWidth);
if (metrics === null) {
return;
}
const startTopLeft = [x1, y1];
const startBottomRight = [x2, y2];
const startCenter = [cx, cy];
let newTopLeft = pointFrom<GlobalPoint>(x1, y1);
if (["n", "w", "nw"].includes(transformHandleType)) {
newTopLeft = pointFrom<GlobalPoint>(
startBottomRight[0] - Math.abs(nextWidth),
startBottomRight[1] - Math.abs(nextHeight),
);
}
if (transformHandleType === "ne") {
const bottomLeft = [startTopLeft[0], startBottomRight[1]];
newTopLeft = pointFrom<GlobalPoint>(
bottomLeft[0],
bottomLeft[1] - Math.abs(nextHeight),
);
}
if (transformHandleType === "sw") {
const topRight = [startBottomRight[0], startTopLeft[1]];
newTopLeft = pointFrom<GlobalPoint>(
topRight[0] - Math.abs(nextWidth),
topRight[1],
);
}
if (["s", "n"].includes(transformHandleType)) {
newTopLeft[0] = startCenter[0] - nextWidth / 2;
}
if (["e", "w"].includes(transformHandleType)) {
newTopLeft[1] = startCenter[1] - nextHeight / 2;
}
if (shouldResizeFromCenter) {
newTopLeft[0] = startCenter[0] - Math.abs(nextWidth) / 2;
newTopLeft[1] = startCenter[1] - Math.abs(nextHeight) / 2;
}
const angle = element.angle;
const rotatedTopLeft = pointRotateRads(
newTopLeft,
pointFrom(cx, cy),
angle,
const newOrigin = getResizedOrigin(
previousOrigin,
origElement.width,
origElement.height,
metricsWidth,
nextHeight,
origElement.angle,
transformHandleType,
false,
shouldResizeFromCenter,
);
const newCenter = pointFrom<GlobalPoint>(
newTopLeft[0] + Math.abs(nextWidth) / 2,
newTopLeft[1] + Math.abs(nextHeight) / 2,
);
const rotatedNewCenter = pointRotateRads(
newCenter,
pointFrom(cx, cy),
angle,
);
newTopLeft = pointRotateRads(
rotatedTopLeft,
rotatedNewCenter,
-angle as Radians,
);
const [nextX, nextY] = newTopLeft;
scene.mutateElement(element, {
fontSize: metrics.size,
width: nextWidth,
width: metricsWidth,
height: nextHeight,
x: nextX,
y: nextY,
x: newOrigin.x,
y: newOrigin.y,
});
return;
}
if (transformHandleType === "e" || transformHandleType === "w") {
const stateAtResizeStart = originalElements.get(element.id)!;
const [x1, y1, x2, y2] = getResizedElementAbsoluteCoords(
stateAtResizeStart,
stateAtResizeStart.width,
stateAtResizeStart.height,
true,
);
const startTopLeft = pointFrom<GlobalPoint>(x1, y1);
const startBottomRight = pointFrom<GlobalPoint>(x2, y2);
const startCenter = pointCenter(startTopLeft, startBottomRight);
const rotatedPointer = pointRotateRads(
pointFrom(pointerX, pointerY),
startCenter,
-stateAtResizeStart.angle as Radians,
);
const [esx1, , esx2] = getResizedElementAbsoluteCoords(
element,
element.width,
element.height,
true,
);
const boundsCurrentWidth = esx2 - esx1;
const atStartBoundsWidth = startBottomRight[0] - startTopLeft[0];
const minWidth = getMinTextElementWidth(
getFontString({
fontSize: element.fontSize,
@ -435,17 +334,7 @@ const resizeSingleTextElement = (
element.lineHeight,
);
let scaleX = atStartBoundsWidth / boundsCurrentWidth;
if (transformHandleType.includes("e")) {
scaleX = (rotatedPointer[0] - startTopLeft[0]) / boundsCurrentWidth;
}
if (transformHandleType.includes("w")) {
scaleX = (startBottomRight[0] - rotatedPointer[0]) / boundsCurrentWidth;
}
const newWidth =
element.width * scaleX < minWidth ? minWidth : element.width * scaleX;
const newWidth = Math.max(minWidth, nextWidth);
const text = wrapText(
element.originalText,
@ -458,49 +347,27 @@ const resizeSingleTextElement = (
element.lineHeight,
);
const eleNewHeight = metrics.height;
const newHeight = metrics.height;
const [newBoundsX1, newBoundsY1, newBoundsX2, newBoundsY2] =
getResizedElementAbsoluteCoords(
stateAtResizeStart,
newWidth,
eleNewHeight,
true,
);
const newBoundsWidth = newBoundsX2 - newBoundsX1;
const newBoundsHeight = newBoundsY2 - newBoundsY1;
const previousOrigin = pointFrom<GlobalPoint>(origElement.x, origElement.y);
let newTopLeft = [...startTopLeft] as [number, number];
if (["n", "w", "nw"].includes(transformHandleType)) {
newTopLeft = [
startBottomRight[0] - Math.abs(newBoundsWidth),
startTopLeft[1],
];
}
// adjust topLeft to new rotation point
const angle = stateAtResizeStart.angle;
const rotatedTopLeft = pointRotateRads(
pointFromPair(newTopLeft),
startCenter,
angle,
);
const newCenter = pointFrom(
newTopLeft[0] + Math.abs(newBoundsWidth) / 2,
newTopLeft[1] + Math.abs(newBoundsHeight) / 2,
);
const rotatedNewCenter = pointRotateRads(newCenter, startCenter, angle);
newTopLeft = pointRotateRads(
rotatedTopLeft,
rotatedNewCenter,
-angle as Radians,
const newOrigin = getResizedOrigin(
previousOrigin,
origElement.width,
origElement.height,
newWidth,
newHeight,
element.angle,
transformHandleType,
false,
shouldResizeFromCenter,
);
const resizedElement: Partial<ExcalidrawTextElement> = {
width: Math.abs(newWidth),
height: Math.abs(metrics.height),
x: newTopLeft[0],
y: newTopLeft[1],
x: newOrigin.x,
y: newOrigin.y,
text,
autoResize: false,
};
@ -559,9 +426,15 @@ const rotateMultipleElements = (
const boundText = getBoundTextElement(element, elementsMap);
if (boundText && !isArrowElement(element)) {
const { x, y } = computeBoundTextPosition(
element,
boundText,
elementsMap,
);
scene.mutateElement(boundText, {
x: boundText.x + (rotatedCX - cx),
y: boundText.y + (rotatedCY - cy),
x,
y,
angle: normalizeRadians((centerAngle + origAngle) as Radians),
});
}
@ -821,6 +694,18 @@ export const resizeSingleElement = (
shouldInformMutation?: boolean;
} = {},
) => {
if (isTextElement(latestElement) && isTextElement(origElement)) {
return resizeSingleTextElement(
origElement,
latestElement,
scene,
handleDirection,
shouldResizeFromCenter,
nextWidth,
nextHeight,
);
}
let boundTextFont: { fontSize?: number } = {};
const elementsMap = scene.getNonDeletedElementsMap();
const boundTextElement = getBoundTextElement(latestElement, elementsMap);
@ -1518,11 +1403,7 @@ export const resizeMultipleElements = (
} of elementsAndUpdates) {
const { width, height, angle } = update;
scene.mutateElement(element, update, {
informMutation: true,
// needed for the fixed binding point udpate to take effect
isDragging: true,
});
scene.mutateElement(element, update);
updateBoundElements(element, scene, {
simultaneouslyUpdated: elementsToUpdate,

View File

@ -1,26 +1,65 @@
import { simplify } from "points-on-curve";
import { pointFrom, pointDistance, type LocalPoint } from "@excalidraw/math";
import { ROUGHNESS, isTransparent, assertNever } from "@excalidraw/common";
import {
type GeometricShape,
getClosedCurveShape,
getCurveShape,
getEllipseShape,
getFreedrawShape,
getPolygonShape,
} from "@excalidraw/utils/shape";
import {
pointFrom,
pointDistance,
type LocalPoint,
pointRotateRads,
} from "@excalidraw/math";
import {
ROUGHNESS,
isTransparent,
assertNever,
COLOR_PALETTE,
LINE_POLYGON_POINT_MERGE_DISTANCE,
} from "@excalidraw/common";
import { RoughGenerator } from "roughjs/bin/generator";
import type { GlobalPoint } from "@excalidraw/math";
import type { Mutable } from "@excalidraw/common/utility-types";
import type { EmbedsValidationStatus } from "@excalidraw/excalidraw/types";
import type { ElementShapes } from "@excalidraw/excalidraw/scene/types";
import type {
AppState,
EmbedsValidationStatus,
} from "@excalidraw/excalidraw/types";
import type {
ElementShape,
ElementShapes,
} from "@excalidraw/excalidraw/scene/types";
import { elementWithCanvasCache } from "./renderElement";
import {
canBecomePolygon,
isElbowArrow,
isEmbeddableElement,
isIframeElement,
isIframeLikeElement,
isLinearElement,
} from "./typeChecks";
import { getCornerRadius, isPathALoop } from "./shapes";
import { getCornerRadius, isPathALoop } from "./utils";
import { headingForPointIsHorizontal } from "./heading";
import { canChangeRoundness } from "./comparisons";
import { generateFreeDrawShape } from "./renderElement";
import { getArrowheadPoints, getDiamondPoints } from "./bounds";
import {
getArrowheadPoints,
getCenterForBounds,
getDiamondPoints,
getElementAbsoluteCoords,
} from "./bounds";
import { shouldTestInside } from "./collision";
import type {
ExcalidrawElement,
@ -28,12 +67,89 @@ import type {
ExcalidrawSelectionElement,
ExcalidrawLinearElement,
Arrowhead,
ExcalidrawFreeDrawElement,
ElementsMap,
ExcalidrawLineElement,
} from "./types";
import type { Drawable, Options } from "roughjs/bin/core";
import type { RoughGenerator } from "roughjs/bin/generator";
import type { Point as RoughPoint } from "roughjs/bin/geometry";
export class ShapeCache {
private static rg = new RoughGenerator();
private static cache = new WeakMap<ExcalidrawElement, ElementShape>();
/**
* Retrieves shape from cache if available. Use this only if shape
* is optional and you have a fallback in case it's not cached.
*/
public static get = <T extends ExcalidrawElement>(element: T) => {
return ShapeCache.cache.get(
element,
) as T["type"] extends keyof ElementShapes
? ElementShapes[T["type"]] | undefined
: ElementShape | undefined;
};
public static set = <T extends ExcalidrawElement>(
element: T,
shape: T["type"] extends keyof ElementShapes
? ElementShapes[T["type"]]
: Drawable,
) => ShapeCache.cache.set(element, shape);
public static delete = (element: ExcalidrawElement) =>
ShapeCache.cache.delete(element);
public static destroy = () => {
ShapeCache.cache = new WeakMap();
};
/**
* Generates & caches shape for element if not already cached, otherwise
* returns cached shape.
*/
public static generateElementShape = <
T extends Exclude<ExcalidrawElement, ExcalidrawSelectionElement>,
>(
element: T,
renderConfig: {
isExporting: boolean;
canvasBackgroundColor: AppState["viewBackgroundColor"];
embedsValidationStatus: EmbedsValidationStatus;
} | null,
) => {
// when exporting, always regenerated to guarantee the latest shape
const cachedShape = renderConfig?.isExporting
? undefined
: ShapeCache.get(element);
// `null` indicates no rc shape applicable for this element type,
// but it's considered a valid cache value (= do not regenerate)
if (cachedShape !== undefined) {
return cachedShape;
}
elementWithCanvasCache.delete(element);
const shape = generateElementShape(
element,
ShapeCache.rg,
renderConfig || {
isExporting: false,
canvasBackgroundColor: COLOR_PALETTE.white,
embedsValidationStatus: null,
},
) as T["type"] extends keyof ElementShapes
? ElementShapes[T["type"]]
: Drawable | null;
ShapeCache.cache.set(element, shape);
return shape;
};
}
const getDashArrayDashed = (strokeWidth: number) => [8, 8 + strokeWidth];
const getDashArrayDotted = (strokeWidth: number) => [1.5, 6 + strokeWidth];
@ -303,6 +419,182 @@ const getArrowheadShapes = (
}
};
export const generateLinearCollisionShape = (
element: ExcalidrawLinearElement | ExcalidrawFreeDrawElement,
) => {
const generator = new RoughGenerator();
const options: Options = {
seed: element.seed,
disableMultiStroke: true,
disableMultiStrokeFill: true,
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],
),
);
switch (element.type) {
case "line":
case "arrow": {
// points array can be empty in the beginning, so it is important to add
// initial position to it
const points = element.points.length
? element.points
: [pointFrom<LocalPoint>(0, 0)];
if (isElbowArrow(element)) {
return generator.path(generateElbowArrowShape(points, 16), options)
.sets[0].ops;
} else if (!element.roundness) {
return points.map((point, idx) => {
const p = pointRotateRads(
pointFrom<GlobalPoint>(element.x + point[0], element.y + point[1]),
center,
element.angle,
);
return {
op: idx === 0 ? "move" : "lineTo",
data: pointFrom<LocalPoint>(p[0] - element.x, p[1] - element.y),
};
});
}
return generator
.curve(points as unknown as RoughPoint[], options)
.sets[0].ops.slice(0, element.points.length)
.map((op, i) => {
if (i === 0) {
const p = pointRotateRads<GlobalPoint>(
pointFrom<GlobalPoint>(
element.x + op.data[0],
element.y + op.data[1],
),
center,
element.angle,
);
return {
op: "move",
data: pointFrom<LocalPoint>(p[0] - element.x, p[1] - element.y),
};
}
return {
op: "bcurveTo",
data: [
pointRotateRads(
pointFrom<GlobalPoint>(
element.x + op.data[0],
element.y + op.data[1],
),
center,
element.angle,
),
pointRotateRads(
pointFrom<GlobalPoint>(
element.x + op.data[2],
element.y + op.data[3],
),
center,
element.angle,
),
pointRotateRads(
pointFrom<GlobalPoint>(
element.x + op.data[4],
element.y + op.data[5],
),
center,
element.angle,
),
]
.map((p) =>
pointFrom<LocalPoint>(p[0] - element.x, p[1] - element.y),
)
.flat(),
};
});
}
case "freedraw": {
if (element.points.length < 2) {
return [];
}
const simplifiedPoints = simplify(
element.points as Mutable<LocalPoint[]>,
0.75,
);
return generator
.curve(simplifiedPoints as [number, number][], options)
.sets[0].ops.slice(0, element.points.length)
.map((op, i) => {
if (i === 0) {
const p = pointRotateRads<GlobalPoint>(
pointFrom<GlobalPoint>(
element.x + op.data[0],
element.y + op.data[1],
),
center,
element.angle,
);
return {
op: "move",
data: pointFrom<LocalPoint>(p[0] - element.x, p[1] - element.y),
};
}
return {
op: "bcurveTo",
data: [
pointRotateRads(
pointFrom<GlobalPoint>(
element.x + op.data[0],
element.y + op.data[1],
),
center,
element.angle,
),
pointRotateRads(
pointFrom<GlobalPoint>(
element.x + op.data[2],
element.y + op.data[3],
),
center,
element.angle,
),
pointRotateRads(
pointFrom<GlobalPoint>(
element.x + op.data[4],
element.y + op.data[5],
),
center,
element.angle,
),
]
.map((p) =>
pointFrom<LocalPoint>(p[0] - element.x, p[1] - element.y),
)
.flat(),
};
});
}
}
};
/**
* Generates the roughjs shape for given element.
*
@ -310,7 +602,7 @@ const getArrowheadShapes = (
*
* @private
*/
export const _generateElementShape = (
const generateElementShape = (
element: Exclude<NonDeletedExcalidrawElement, ExcalidrawSelectionElement>,
generator: RoughGenerator,
{
@ -611,3 +903,103 @@ const generateElbowArrowShape = (
return d.join(" ");
};
/**
* get the pure geometric shape of an excalidraw elementw
* which is then used for hit detection
*/
export const getElementShape = <Point extends GlobalPoint | LocalPoint>(
element: ExcalidrawElement,
elementsMap: ElementsMap,
): GeometricShape<Point> => {
switch (element.type) {
case "rectangle":
case "diamond":
case "frame":
case "magicframe":
case "embeddable":
case "image":
case "iframe":
case "text":
case "selection":
return getPolygonShape(element);
case "arrow":
case "line": {
const roughShape =
ShapeCache.get(element)?.[0] ??
ShapeCache.generateElementShape(element, null)[0];
const [, , , , cx, cy] = getElementAbsoluteCoords(element, elementsMap);
return shouldTestInside(element)
? getClosedCurveShape<Point>(
element,
roughShape,
pointFrom<Point>(element.x, element.y),
element.angle,
pointFrom(cx, cy),
)
: getCurveShape<Point>(
roughShape,
pointFrom<Point>(element.x, element.y),
element.angle,
pointFrom(cx, cy),
);
}
case "ellipse":
return getEllipseShape(element);
case "freedraw": {
const [, , , , cx, cy] = getElementAbsoluteCoords(element, elementsMap);
return getFreedrawShape(
element,
pointFrom(cx, cy),
shouldTestInside(element),
);
}
}
};
export const toggleLinePolygonState = (
element: ExcalidrawLineElement,
nextPolygonState: boolean,
): {
polygon: ExcalidrawLineElement["polygon"];
points: ExcalidrawLineElement["points"];
} | null => {
const updatedPoints = [...element.points];
if (nextPolygonState) {
if (!canBecomePolygon(element.points)) {
return null;
}
const firstPoint = updatedPoints[0];
const lastPoint = updatedPoints[updatedPoints.length - 1];
const distance = Math.hypot(
firstPoint[0] - lastPoint[0],
firstPoint[1] - lastPoint[1],
);
if (
distance > LINE_POLYGON_POINT_MERGE_DISTANCE ||
updatedPoints.length < 4
) {
updatedPoints.push(pointFrom(firstPoint[0], firstPoint[1]));
} else {
updatedPoints[updatedPoints.length - 1] = pointFrom(
firstPoint[0],
firstPoint[1],
);
}
}
// TODO: satisfies ElementUpdate<ExcalidrawLineElement>
const ret = {
polygon: nextPolygonState,
points: updatedPoints,
};
return ret;
};

View File

@ -1,446 +0,0 @@
import {
DEFAULT_ADAPTIVE_RADIUS,
DEFAULT_PROPORTIONAL_RADIUS,
LINE_CONFIRM_THRESHOLD,
ROUNDNESS,
invariant,
elementCenterPoint,
LINE_POLYGON_POINT_MERGE_DISTANCE,
} from "@excalidraw/common";
import {
isPoint,
pointFrom,
pointDistance,
pointFromPair,
pointRotateRads,
pointsEqual,
type GlobalPoint,
type LocalPoint,
} from "@excalidraw/math";
import {
getClosedCurveShape,
getCurvePathOps,
getCurveShape,
getEllipseShape,
getFreedrawShape,
getPolygonShape,
type GeometricShape,
} from "@excalidraw/utils/shape";
import type { NormalizedZoomValue, Zoom } from "@excalidraw/excalidraw/types";
import { shouldTestInside } from "./collision";
import { LinearElementEditor } from "./linearElementEditor";
import { getBoundTextElement } from "./textElement";
import { ShapeCache } from "./ShapeCache";
import { getElementAbsoluteCoords, type Bounds } from "./bounds";
import { canBecomePolygon } from "./typeChecks";
import type {
ElementsMap,
ExcalidrawElement,
ExcalidrawLinearElement,
ExcalidrawLineElement,
NonDeleted,
} from "./types";
/**
* get the pure geometric shape of an excalidraw elementw
* which is then used for hit detection
*/
export const getElementShape = <Point extends GlobalPoint | LocalPoint>(
element: ExcalidrawElement,
elementsMap: ElementsMap,
): GeometricShape<Point> => {
switch (element.type) {
case "rectangle":
case "diamond":
case "frame":
case "magicframe":
case "embeddable":
case "image":
case "iframe":
case "text":
case "selection":
return getPolygonShape(element);
case "arrow":
case "line": {
const roughShape =
ShapeCache.get(element)?.[0] ??
ShapeCache.generateElementShape(element, null)[0];
const [, , , , cx, cy] = getElementAbsoluteCoords(element, elementsMap);
return shouldTestInside(element)
? getClosedCurveShape<Point>(
element,
roughShape,
pointFrom<Point>(element.x, element.y),
element.angle,
pointFrom(cx, cy),
)
: getCurveShape<Point>(
roughShape,
pointFrom<Point>(element.x, element.y),
element.angle,
pointFrom(cx, cy),
);
}
case "ellipse":
return getEllipseShape(element);
case "freedraw": {
const [, , , , cx, cy] = getElementAbsoluteCoords(element, elementsMap);
return getFreedrawShape(
element,
pointFrom(cx, cy),
shouldTestInside(element),
);
}
}
};
export const getBoundTextShape = <Point extends GlobalPoint | LocalPoint>(
element: ExcalidrawElement,
elementsMap: ElementsMap,
): GeometricShape<Point> | null => {
const boundTextElement = getBoundTextElement(element, elementsMap);
if (boundTextElement) {
if (element.type === "arrow") {
return getElementShape(
{
...boundTextElement,
// arrow's bound text accurate position is not stored in the element's property
// but rather calculated and returned from the following static method
...LinearElementEditor.getBoundTextElementPosition(
element,
boundTextElement,
elementsMap,
),
},
elementsMap,
);
}
return getElementShape(boundTextElement, elementsMap);
}
return null;
};
export const getControlPointsForBezierCurve = <
P extends GlobalPoint | LocalPoint,
>(
element: NonDeleted<ExcalidrawLinearElement>,
endPoint: P,
) => {
const shape = ShapeCache.generateElementShape(element, null);
if (!shape) {
return null;
}
const ops = getCurvePathOps(shape[0]);
let currentP = pointFrom<P>(0, 0);
let index = 0;
let minDistance = Infinity;
let controlPoints: P[] | null = null;
while (index < ops.length) {
const { op, data } = ops[index];
if (op === "move") {
invariant(
isPoint(data),
"The returned ops is not compatible with a point",
);
currentP = pointFromPair(data);
}
if (op === "bcurveTo") {
const p0 = currentP;
const p1 = pointFrom<P>(data[0], data[1]);
const p2 = pointFrom<P>(data[2], data[3]);
const p3 = pointFrom<P>(data[4], data[5]);
const distance = pointDistance(p3, endPoint);
if (distance < minDistance) {
minDistance = distance;
controlPoints = [p0, p1, p2, p3];
}
currentP = p3;
}
index++;
}
return controlPoints;
};
export const getBezierXY = <P extends GlobalPoint | LocalPoint>(
p0: P,
p1: P,
p2: P,
p3: P,
t: number,
): P => {
const equation = (t: number, idx: number) =>
Math.pow(1 - t, 3) * p3[idx] +
3 * t * Math.pow(1 - t, 2) * p2[idx] +
3 * Math.pow(t, 2) * (1 - t) * p1[idx] +
p0[idx] * Math.pow(t, 3);
const tx = equation(t, 0);
const ty = equation(t, 1);
return pointFrom(tx, ty);
};
const getPointsInBezierCurve = <P extends GlobalPoint | LocalPoint>(
element: NonDeleted<ExcalidrawLinearElement>,
endPoint: P,
) => {
const controlPoints: P[] = getControlPointsForBezierCurve(element, endPoint)!;
if (!controlPoints) {
return [];
}
const pointsOnCurve: P[] = [];
let t = 1;
// Take 20 points on curve for better accuracy
while (t > 0) {
const p = getBezierXY(
controlPoints[0],
controlPoints[1],
controlPoints[2],
controlPoints[3],
t,
);
pointsOnCurve.push(pointFrom(p[0], p[1]));
t -= 0.05;
}
if (pointsOnCurve.length) {
if (pointsEqual(pointsOnCurve.at(-1)!, endPoint)) {
pointsOnCurve.push(pointFrom(endPoint[0], endPoint[1]));
}
}
return pointsOnCurve;
};
const getBezierCurveArcLengths = <P extends GlobalPoint | LocalPoint>(
element: NonDeleted<ExcalidrawLinearElement>,
endPoint: P,
) => {
const arcLengths: number[] = [];
arcLengths[0] = 0;
const points = getPointsInBezierCurve(element, endPoint);
let index = 0;
let distance = 0;
while (index < points.length - 1) {
const segmentDistance = pointDistance(points[index], points[index + 1]);
distance += segmentDistance;
arcLengths.push(distance);
index++;
}
return arcLengths;
};
export const getBezierCurveLength = <P extends GlobalPoint | LocalPoint>(
element: NonDeleted<ExcalidrawLinearElement>,
endPoint: P,
) => {
const arcLengths = getBezierCurveArcLengths(element, endPoint);
return arcLengths.at(-1) as number;
};
// This maps interval to actual interval t on the curve so that when t = 0.5, its actually the point at 50% of the length
export const mapIntervalToBezierT = <P extends GlobalPoint | LocalPoint>(
element: NonDeleted<ExcalidrawLinearElement>,
endPoint: P,
interval: number, // The interval between 0 to 1 for which you want to find the point on the curve,
) => {
const arcLengths = getBezierCurveArcLengths(element, endPoint);
const pointsCount = arcLengths.length - 1;
const curveLength = arcLengths.at(-1) as number;
const targetLength = interval * curveLength;
let low = 0;
let high = pointsCount;
let index = 0;
// Doing a binary search to find the largest length that is less than the target length
while (low < high) {
index = Math.floor(low + (high - low) / 2);
if (arcLengths[index] < targetLength) {
low = index + 1;
} else {
high = index;
}
}
if (arcLengths[index] > targetLength) {
index--;
}
if (arcLengths[index] === targetLength) {
return index / pointsCount;
}
return (
1 -
(index +
(targetLength - arcLengths[index]) /
(arcLengths[index + 1] - arcLengths[index])) /
pointsCount
);
};
/**
* Get the axis-aligned bounding box for a given element
*/
export const aabbForElement = (
element: Readonly<ExcalidrawElement>,
offset?: [number, number, number, number],
) => {
const bbox = {
minX: element.x,
minY: element.y,
maxX: element.x + element.width,
maxY: element.y + element.height,
midX: element.x + element.width / 2,
midY: element.y + element.height / 2,
};
const center = elementCenterPoint(element);
const [topLeftX, topLeftY] = pointRotateRads(
pointFrom(bbox.minX, bbox.minY),
center,
element.angle,
);
const [topRightX, topRightY] = pointRotateRads(
pointFrom(bbox.maxX, bbox.minY),
center,
element.angle,
);
const [bottomRightX, bottomRightY] = pointRotateRads(
pointFrom(bbox.maxX, bbox.maxY),
center,
element.angle,
);
const [bottomLeftX, bottomLeftY] = pointRotateRads(
pointFrom(bbox.minX, bbox.maxY),
center,
element.angle,
);
const bounds = [
Math.min(topLeftX, topRightX, bottomRightX, bottomLeftX),
Math.min(topLeftY, topRightY, bottomRightY, bottomLeftY),
Math.max(topLeftX, topRightX, bottomRightX, bottomLeftX),
Math.max(topLeftY, topRightY, bottomRightY, bottomLeftY),
] as Bounds;
if (offset) {
const [topOffset, rightOffset, downOffset, leftOffset] = offset;
return [
bounds[0] - leftOffset,
bounds[1] - topOffset,
bounds[2] + rightOffset,
bounds[3] + downOffset,
] as Bounds;
}
return bounds;
};
export const pointInsideBounds = <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 aabbsOverlapping = (a: Bounds, b: Bounds) =>
pointInsideBounds(pointFrom(a[0], a[1]), b) ||
pointInsideBounds(pointFrom(a[2], a[1]), b) ||
pointInsideBounds(pointFrom(a[2], a[3]), b) ||
pointInsideBounds(pointFrom(a[0], a[3]), b) ||
pointInsideBounds(pointFrom(b[0], b[1]), a) ||
pointInsideBounds(pointFrom(b[2], b[1]), a) ||
pointInsideBounds(pointFrom(b[2], b[3]), a) ||
pointInsideBounds(pointFrom(b[0], b[3]), a);
export const getCornerRadius = (x: number, element: ExcalidrawElement) => {
if (
element.roundness?.type === ROUNDNESS.PROPORTIONAL_RADIUS ||
element.roundness?.type === ROUNDNESS.LEGACY
) {
return x * DEFAULT_PROPORTIONAL_RADIUS;
}
if (element.roundness?.type === ROUNDNESS.ADAPTIVE_RADIUS) {
const fixedRadiusSize = element.roundness?.value ?? DEFAULT_ADAPTIVE_RADIUS;
const CUTOFF_SIZE = fixedRadiusSize / DEFAULT_PROPORTIONAL_RADIUS;
if (x <= CUTOFF_SIZE) {
return x * DEFAULT_PROPORTIONAL_RADIUS;
}
return fixedRadiusSize;
}
return 0;
};
// Checks if the first and last point are close enough
// to be considered a loop
export const isPathALoop = (
points: ExcalidrawLinearElement["points"],
/** supply if you want the loop detection to account for current zoom */
zoomValue: Zoom["value"] = 1 as NormalizedZoomValue,
): boolean => {
if (points.length >= 3) {
const [first, last] = [points[0], points[points.length - 1]];
const distance = pointDistance(first, last);
// Adjusting LINE_CONFIRM_THRESHOLD to current zoom so that when zoomed in
// really close we make the threshold smaller, and vice versa.
return distance <= LINE_CONFIRM_THRESHOLD / zoomValue;
}
return false;
};
export const toggleLinePolygonState = (
element: ExcalidrawLineElement,
nextPolygonState: boolean,
): {
polygon: ExcalidrawLineElement["polygon"];
points: ExcalidrawLineElement["points"];
} | null => {
const updatedPoints = [...element.points];
if (nextPolygonState) {
if (!canBecomePolygon(element.points)) {
return null;
}
const firstPoint = updatedPoints[0];
const lastPoint = updatedPoints[updatedPoints.length - 1];
const distance = Math.hypot(
firstPoint[0] - lastPoint[0],
firstPoint[1] - lastPoint[1],
);
if (
distance > LINE_POLYGON_POINT_MERGE_DISTANCE ||
updatedPoints.length < 4
) {
updatedPoints.push(pointFrom(firstPoint[0], firstPoint[1]));
} else {
updatedPoints[updatedPoints.length - 1] = pointFrom(
firstPoint[0],
firstPoint[1],
);
}
}
// TODO: satisfies ElementUpdate<ExcalidrawLineElement>
const ret = {
polygon: nextPolygonState,
points: updatedPoints,
};
return ret;
};

View File

@ -2,6 +2,12 @@ import {
SHIFT_LOCKING_ANGLE,
viewportCoordsToSceneCoords,
} from "@excalidraw/common";
import {
normalizeRadians,
radiansBetweenAngles,
radiansDifference,
type Radians,
} from "@excalidraw/math";
import { pointsEqual } from "@excalidraw/math";
@ -152,13 +158,42 @@ export const getLockedLinearCursorAlignSize = (
originY: number,
x: number,
y: number,
customAngle?: number,
) => {
let width = x - originX;
let height = y - originY;
const lockedAngle =
Math.round(Math.atan(height / width) / SHIFT_LOCKING_ANGLE) *
SHIFT_LOCKING_ANGLE;
const angle = Math.atan2(height, width) as Radians;
let lockedAngle = (Math.round(angle / SHIFT_LOCKING_ANGLE) *
SHIFT_LOCKING_ANGLE) as Radians;
if (customAngle) {
// If custom angle is provided, we check if the angle is close to the
// custom angle, snap to that if close engough, otherwise snap to the
// higher or lower angle depending on the current angle vs custom angle.
const lower = (Math.floor(customAngle / SHIFT_LOCKING_ANGLE) *
SHIFT_LOCKING_ANGLE) as Radians;
if (
radiansBetweenAngles(
angle,
lower,
(lower + SHIFT_LOCKING_ANGLE) as Radians,
)
) {
if (
radiansDifference(angle, customAngle as Radians) <
SHIFT_LOCKING_ANGLE / 6
) {
lockedAngle = customAngle as Radians;
} else if (
normalizeRadians(angle) > normalizeRadians(customAngle as Radians)
) {
lockedAngle = (lower + SHIFT_LOCKING_ANGLE) as Radians;
} else {
lockedAngle = lower;
}
}
}
if (lockedAngle === 0) {
height = 0;

View File

@ -19,9 +19,21 @@ import { newElementWith } from "./mutateElement";
import { ElementsDelta, AppStateDelta, Delta } from "./delta";
import { hashElementsVersion, hashString } from "./index";
import {
syncInvalidIndicesImmutable,
hashElementsVersion,
hashString,
isInitializedImageElement,
isImageElement,
} from "./index";
import type { OrderedExcalidrawElement, SceneElementsMap } from "./types";
import type { ApplyToOptions } from "./delta";
import type {
ExcalidrawElement,
OrderedExcalidrawElement,
SceneElementsMap,
} from "./types";
export const CaptureUpdateAction = {
/**
@ -64,8 +76,9 @@ type MicroActionsQueue = (() => void)[];
* Store which captures the observed changes and emits them as `StoreIncrement` events.
*/
export class Store {
// internally used by history
// for internal use by history
public readonly onDurableIncrementEmitter = new Emitter<[DurableIncrement]>();
// for public use as part of onIncrement API
public readonly onStoreIncrementEmitter = new Emitter<
[DurableIncrement | EphemeralIncrement]
>();
@ -105,7 +118,7 @@ export class Store {
params:
| {
action: CaptureUpdateActionType;
elements: SceneElementsMap | undefined;
elements: readonly ExcalidrawElement[] | undefined;
appState: AppState | ObservedAppState | undefined;
}
| {
@ -129,13 +142,21 @@ export class Store {
} else {
// immediately create an immutable change of the scheduled updates,
// compared to the current state, so that they won't mutate later on during batching
// also, we have to compare against the current state,
// as comparing against the snapshot might include yet uncomitted changes (i.e. async freedraw / text / image, etc.)
const currentSnapshot = StoreSnapshot.create(
this.app.scene.getElementsMapIncludingDeleted(),
this.app.state,
);
const scheduledSnapshot = currentSnapshot.maybeClone(
action,
params.elements,
// let's sync invalid indices first, so that we could detect this change
// also have the synced elements immutable, so that we don't mutate elements,
// that are already in the scene, otherwise we wouldn't see any change
params.elements
? syncInvalidIndicesImmutable(params.elements)
: undefined,
params.appState,
);
@ -213,22 +234,12 @@ export class Store {
// using the same instance, since in history we have a check against `HistoryEntry`, so that we don't re-record the same delta again
storeDelta = delta;
} else {
// calculate the deltas based on the previous and next snapshot
const elementsDelta = snapshot.metadata.didElementsChange
? ElementsDelta.calculate(prevSnapshot.elements, snapshot.elements)
: ElementsDelta.empty();
const appStateDelta = snapshot.metadata.didAppStateChange
? AppStateDelta.calculate(prevSnapshot.appState, snapshot.appState)
: AppStateDelta.empty();
storeDelta = StoreDelta.create(elementsDelta, appStateDelta);
storeDelta = StoreDelta.calculate(prevSnapshot, snapshot);
}
if (!storeDelta.isEmpty()) {
const increment = new DurableIncrement(storeChange, storeDelta);
// Notify listeners with the increment
this.onDurableIncrementEmitter.trigger(increment);
this.onStoreIncrementEmitter.trigger(increment);
}
@ -505,6 +516,24 @@ export class StoreDelta {
return new this(opts.id, elements, appState);
}
/**
* Calculate the delta between the previous and next snapshot.
*/
public static calculate(
prevSnapshot: StoreSnapshot,
nextSnapshot: StoreSnapshot,
) {
const elementsDelta = nextSnapshot.metadata.didElementsChange
? ElementsDelta.calculate(prevSnapshot.elements, nextSnapshot.elements)
: ElementsDelta.empty();
const appStateDelta = nextSnapshot.metadata.didAppStateChange
? AppStateDelta.calculate(prevSnapshot.appState, nextSnapshot.appState)
: AppStateDelta.empty();
return this.create(elementsDelta, appStateDelta);
}
/**
* Restore a store delta instance from a DTO.
*/
@ -523,38 +552,35 @@ export class StoreDelta {
public static load({
id,
elements: { added, removed, updated },
appState: { delta: appStateDelta },
}: DTO<StoreDelta>) {
const elements = ElementsDelta.create(added, removed, updated, {
shouldRedistribute: false,
});
const elements = ElementsDelta.create(added, removed, updated);
const appState = AppStateDelta.create(appStateDelta);
return new this(id, elements, AppStateDelta.empty());
return new this(id, elements, appState);
}
/**
* Squash the passed deltas into the aggregated delta instance.
*/
public static squash(...deltas: StoreDelta[]) {
const aggregatedDelta = StoreDelta.empty();
for (const delta of deltas) {
aggregatedDelta.elements.squash(delta.elements);
aggregatedDelta.appState.squash(delta.appState);
}
return aggregatedDelta;
}
/**
* Inverse store delta, creates new instance of `StoreDelta`.
*/
public static inverse(delta: StoreDelta): StoreDelta {
public static inverse(delta: StoreDelta) {
return this.create(delta.elements.inverse(), delta.appState.inverse());
}
/**
* Apply latest (remote) changes to the delta, creates new instance of `StoreDelta`.
*/
public static applyLatestChanges(
delta: StoreDelta,
elements: SceneElementsMap,
modifierOptions: "deleted" | "inserted",
): StoreDelta {
return this.create(
delta.elements.applyLatestChanges(elements, modifierOptions),
delta.appState,
{
id: delta.id,
},
);
}
/**
* Apply the delta to the passed elements and appState, does not modify the snapshot.
*/
@ -562,11 +588,12 @@ export class StoreDelta {
delta: StoreDelta,
elements: SceneElementsMap,
appState: AppState,
prevSnapshot: StoreSnapshot = StoreSnapshot.empty(),
options?: ApplyToOptions,
): [SceneElementsMap, AppState, boolean] {
const [nextElements, elementsContainVisibleChange] = delta.elements.applyTo(
elements,
prevSnapshot.elements,
StoreSnapshot.empty().elements,
options,
);
const [nextAppState, appStateContainsVisibleChange] =
@ -578,6 +605,32 @@ export class StoreDelta {
return [nextElements, nextAppState, appliedVisibleChanges];
}
/**
* Apply latest (remote) changes to the delta, creates new instance of `StoreDelta`.
*/
public static applyLatestChanges(
delta: StoreDelta,
prevElements: SceneElementsMap,
nextElements: SceneElementsMap,
modifierOptions?: "deleted" | "inserted",
): StoreDelta {
return this.create(
delta.elements.applyLatestChanges(
prevElements,
nextElements,
modifierOptions,
),
delta.appState,
{
id: delta.id,
},
);
}
public static empty() {
return StoreDelta.create(ElementsDelta.empty(), AppStateDelta.empty());
}
public isEmpty() {
return this.elements.isEmpty() && this.appState.isEmpty();
}
@ -687,11 +740,10 @@ export class StoreSnapshot {
nextElements.set(id, changedElement);
}
const nextAppState = Object.assign(
{},
this.appState,
change.appState,
) as ObservedAppState;
const nextAppState = getObservedAppState({
...this.appState,
...change.appState,
});
return StoreSnapshot.create(nextElements, nextAppState, {
// by default we assume that change is different from what we have in the snapshot
@ -847,7 +899,7 @@ export class StoreSnapshot {
}
/**
* Detect if there any changed elements.
* Detect if there are any changed elements.
*/
private detectChangedElements(
nextElements: SceneElementsMap,
@ -882,6 +934,14 @@ export class StoreSnapshot {
!prevElement || // element was added
prevElement.version < nextElement.version // element was updated
) {
if (
isImageElement(nextElement) &&
!isInitializedImageElement(nextElement)
) {
// ignore any updates on uninitialized image elements
continue;
}
changedElements.set(nextElement.id, nextElement);
}
}
@ -936,26 +996,31 @@ const getDefaultObservedAppState = (): ObservedAppState => {
viewBackgroundColor: COLOR_PALETTE.white,
selectedElementIds: {},
selectedGroupIds: {},
editingLinearElementId: null,
selectedLinearElementId: null,
selectedLinearElement: null,
croppingElementId: null,
activeLockedId: null,
lockedMultiSelections: {},
};
};
export const getObservedAppState = (appState: AppState): ObservedAppState => {
export const getObservedAppState = (
appState: AppState | ObservedAppState,
): ObservedAppState => {
const observedAppState = {
name: appState.name,
editingGroupId: appState.editingGroupId,
viewBackgroundColor: appState.viewBackgroundColor,
selectedElementIds: appState.selectedElementIds,
selectedGroupIds: appState.selectedGroupIds,
editingLinearElementId: appState.editingLinearElement?.elementId || null,
selectedLinearElementId: appState.selectedLinearElement?.elementId || null,
croppingElementId: appState.croppingElementId,
activeLockedId: appState.activeLockedId,
lockedMultiSelections: appState.lockedMultiSelections,
selectedLinearElement: appState.selectedLinearElement
? {
elementId: appState.selectedLinearElement.elementId,
isEditing: !!appState.selectedLinearElement.isEditing,
}
: null,
};
Reflect.defineProperty(observedAppState, hiddenObservedAppStateProp, {

View File

@ -10,12 +10,12 @@ import {
invariant,
} from "@excalidraw/common";
import { pointFrom, pointRotateRads, type Radians } from "@excalidraw/math";
import type { AppState } from "@excalidraw/excalidraw/types";
import type { ExtractSetType } from "@excalidraw/common/utility-types";
import type { Radians } from "@excalidraw/math";
import {
resetOriginalContainerCache,
updateOriginalContainerCache,
@ -254,6 +254,26 @@ export const computeBoundTextPosition = (
x =
containerCoords.x + (maxContainerWidth / 2 - boundTextElement.width / 2);
}
const angle = (container.angle ?? 0) as Radians;
if (angle !== 0) {
const contentCenter = pointFrom(
containerCoords.x + maxContainerWidth / 2,
containerCoords.y + maxContainerHeight / 2,
);
const textCenter = pointFrom(
x + boundTextElement.width / 2,
y + boundTextElement.height / 2,
);
const [rx, ry] = pointRotateRads(textCenter, contentCenter, angle);
return {
x: rx - boundTextElement.width / 2,
y: ry - boundTextElement.height / 2,
};
}
return { x, y };
};
@ -326,10 +346,7 @@ export const getContainerCenter = (
if (!midSegmentMidpoint) {
midSegmentMidpoint = LinearElementEditor.getSegmentMidPoint(
container,
points[index],
points[index + 1],
index + 1,
elementsMap,
);
}
return { x: midSegmentMidpoint[0], y: midSegmentMidpoint[1] };

View File

@ -330,7 +330,7 @@ export const shouldShowBoundingBox = (
elements: readonly NonDeletedExcalidrawElement[],
appState: InteractiveCanvasAppState,
) => {
if (appState.editingLinearElement) {
if (appState.selectedLinearElement?.isEditing) {
return false;
}
if (elements.length > 1) {

View File

@ -129,6 +129,15 @@ export const isElbowArrow = (
return isArrowElement(element) && element.elbowed;
};
/**
* sharp or curved arrow, but not elbow
*/
export const isSimpleArrow = (
element?: ExcalidrawElement,
): element is ExcalidrawArrowElement => {
return isArrowElement(element) && !element.elbowed;
};
export const isSharpArrow = (
element?: ExcalidrawElement,
): element is ExcalidrawArrowElement => {

View File

@ -195,7 +195,8 @@ export type ExcalidrawRectanguloidElement =
| ExcalidrawFreeDrawElement
| ExcalidrawIframeLikeElement
| ExcalidrawFrameLikeElement
| ExcalidrawEmbeddableElement;
| ExcalidrawEmbeddableElement
| ExcalidrawSelectionElement;
/**
* ExcalidrawElement should be JSON serializable and (eventually) contain

View File

@ -1,259 +1,346 @@
import {
DEFAULT_ADAPTIVE_RADIUS,
DEFAULT_PROPORTIONAL_RADIUS,
LINE_CONFIRM_THRESHOLD,
ROUNDNESS,
} from "@excalidraw/common";
import {
curve,
curveCatmullRomCubicApproxPoints,
curveOffsetPoints,
lineSegment,
pointDistance,
pointFrom,
pointFromVector,
pointFromArray,
rectangle,
vectorFromPoint,
vectorNormalize,
vectorScale,
type GlobalPoint,
} from "@excalidraw/math";
import { elementCenterPoint } from "@excalidraw/common";
import type { Curve, LineSegment, LocalPoint } from "@excalidraw/math";
import type { Curve, LineSegment } from "@excalidraw/math";
import { getCornerRadius } from "./shapes";
import type { NormalizedZoomValue, Zoom } from "@excalidraw/excalidraw/types";
import { getDiamondPoints } from "./bounds";
import { generateLinearCollisionShape } from "./shape";
import type {
ExcalidrawDiamondElement,
ExcalidrawElement,
ExcalidrawFreeDrawElement,
ExcalidrawLinearElement,
ExcalidrawRectanguloidElement,
} from "./types";
type ElementShape = [LineSegment<GlobalPoint>[], Curve<GlobalPoint>[]];
const ElementShapesCache = new WeakMap<
ExcalidrawElement,
{ version: ExcalidrawElement["version"]; shapes: Map<number, ElementShape> }
>();
const getElementShapesCacheEntry = <T extends ExcalidrawElement>(
element: T,
offset: number,
): ElementShape | undefined => {
const record = ElementShapesCache.get(element);
if (!record) {
return undefined;
}
const { version, shapes } = record;
if (version !== element.version) {
ElementShapesCache.delete(element);
return undefined;
}
return shapes.get(offset);
};
const setElementShapesCacheEntry = <T extends ExcalidrawElement>(
element: T,
shape: ElementShape,
offset: number,
) => {
const record = ElementShapesCache.get(element);
if (!record) {
ElementShapesCache.set(element, {
version: element.version,
shapes: new Map([[offset, shape]]),
});
return;
}
const { version, shapes } = record;
if (version !== element.version) {
ElementShapesCache.set(element, {
version: element.version,
shapes: new Map([[offset, shape]]),
});
return;
}
shapes.set(offset, shape);
};
/**
* Returns the **rotated** components of freedraw, line or arrow elements.
*
* @param element The linear element to deconstruct
* @returns The rotated in components.
*/
export function deconstructLinearOrFreeDrawElement(
element: ExcalidrawLinearElement | ExcalidrawFreeDrawElement,
): [LineSegment<GlobalPoint>[], Curve<GlobalPoint>[]] {
const cachedShape = getElementShapesCacheEntry(element, 0);
if (cachedShape) {
return cachedShape;
}
const ops = generateLinearCollisionShape(element) as {
op: string;
data: number[];
}[];
const lines = [];
const curves = [];
for (let idx = 0; idx < ops.length; idx += 1) {
const op = ops[idx];
const prevPoint =
ops[idx - 1] && pointFromArray<LocalPoint>(ops[idx - 1].data.slice(-2));
switch (op.op) {
case "move":
continue;
case "lineTo":
if (!prevPoint) {
throw new Error("prevPoint is undefined");
}
lines.push(
lineSegment<GlobalPoint>(
pointFrom<GlobalPoint>(
element.x + prevPoint[0],
element.y + prevPoint[1],
),
pointFrom<GlobalPoint>(
element.x + op.data[0],
element.y + op.data[1],
),
),
);
continue;
case "bcurveTo":
if (!prevPoint) {
throw new Error("prevPoint is undefined");
}
curves.push(
curve<GlobalPoint>(
pointFrom<GlobalPoint>(
element.x + prevPoint[0],
element.y + prevPoint[1],
),
pointFrom<GlobalPoint>(
element.x + op.data[0],
element.y + op.data[1],
),
pointFrom<GlobalPoint>(
element.x + op.data[2],
element.y + op.data[3],
),
pointFrom<GlobalPoint>(
element.x + op.data[4],
element.y + op.data[5],
),
),
);
continue;
default: {
console.error("Unknown op type", op.op);
}
}
}
const shape = [lines, curves] as ElementShape;
setElementShapesCacheEntry(element, shape, 0);
return shape;
}
/**
* Get the building components of a rectanguloid element in the form of
* line segments and curves.
* line segments and curves **unrotated**.
*
* @param element Target rectanguloid element
* @param offset Optional offset to expand the rectanguloid shape
* @returns Tuple of line segments (0) and curves (1)
* @returns Tuple of **unrotated** line segments (0) and curves (1)
*/
export function deconstructRectanguloidElement(
element: ExcalidrawRectanguloidElement,
offset: number = 0,
): [LineSegment<GlobalPoint>[], Curve<GlobalPoint>[]] {
const roundness = getCornerRadius(
const cachedShape = getElementShapesCacheEntry(element, offset);
if (cachedShape) {
return cachedShape;
}
let radius = getCornerRadius(
Math.min(element.width, element.height),
element,
);
if (roundness <= 0) {
const r = rectangle(
pointFrom(element.x - offset, element.y - offset),
pointFrom(
element.x + element.width + offset,
element.y + element.height + offset,
),
);
const top = lineSegment<GlobalPoint>(
pointFrom<GlobalPoint>(r[0][0] + roundness, r[0][1]),
pointFrom<GlobalPoint>(r[1][0] - roundness, r[0][1]),
);
const right = lineSegment<GlobalPoint>(
pointFrom<GlobalPoint>(r[1][0], r[0][1] + roundness),
pointFrom<GlobalPoint>(r[1][0], r[1][1] - roundness),
);
const bottom = lineSegment<GlobalPoint>(
pointFrom<GlobalPoint>(r[0][0] + roundness, r[1][1]),
pointFrom<GlobalPoint>(r[1][0] - roundness, r[1][1]),
);
const left = lineSegment<GlobalPoint>(
pointFrom<GlobalPoint>(r[0][0], r[1][1] - roundness),
pointFrom<GlobalPoint>(r[0][0], r[0][1] + roundness),
);
const sides = [top, right, bottom, left];
return [sides, []];
if (radius === 0) {
radius = 0.01;
}
const center = elementCenterPoint(element);
const r = rectangle(
pointFrom(element.x, element.y),
pointFrom(element.x + element.width, element.y + element.height),
);
const top = lineSegment<GlobalPoint>(
pointFrom<GlobalPoint>(r[0][0] + roundness, r[0][1]),
pointFrom<GlobalPoint>(r[1][0] - roundness, r[0][1]),
pointFrom<GlobalPoint>(r[0][0] + radius, r[0][1]),
pointFrom<GlobalPoint>(r[1][0] - radius, r[0][1]),
);
const right = lineSegment<GlobalPoint>(
pointFrom<GlobalPoint>(r[1][0], r[0][1] + roundness),
pointFrom<GlobalPoint>(r[1][0], r[1][1] - roundness),
pointFrom<GlobalPoint>(r[1][0], r[0][1] + radius),
pointFrom<GlobalPoint>(r[1][0], r[1][1] - radius),
);
const bottom = lineSegment<GlobalPoint>(
pointFrom<GlobalPoint>(r[0][0] + roundness, r[1][1]),
pointFrom<GlobalPoint>(r[1][0] - roundness, r[1][1]),
pointFrom<GlobalPoint>(r[0][0] + radius, r[1][1]),
pointFrom<GlobalPoint>(r[1][0] - radius, r[1][1]),
);
const left = lineSegment<GlobalPoint>(
pointFrom<GlobalPoint>(r[0][0], r[1][1] - roundness),
pointFrom<GlobalPoint>(r[0][0], r[0][1] + roundness),
pointFrom<GlobalPoint>(r[0][0], r[1][1] - radius),
pointFrom<GlobalPoint>(r[0][0], r[0][1] + radius),
);
const offsets = [
vectorScale(
vectorNormalize(
vectorFromPoint(pointFrom(r[0][0] - offset, r[0][1] - offset), center),
),
offset,
), // TOP LEFT
vectorScale(
vectorNormalize(
vectorFromPoint(pointFrom(r[1][0] + offset, r[0][1] - offset), center),
),
offset,
), //TOP RIGHT
vectorScale(
vectorNormalize(
vectorFromPoint(pointFrom(r[1][0] + offset, r[1][1] + offset), center),
),
offset,
), // BOTTOM RIGHT
vectorScale(
vectorNormalize(
vectorFromPoint(pointFrom(r[0][0] - offset, r[1][1] + offset), center),
),
offset,
), // BOTTOM LEFT
];
const corners = [
const baseCorners = [
curve(
pointFromVector(offsets[0], left[1]),
pointFromVector(
offsets[0],
pointFrom<GlobalPoint>(
left[1][0] + (2 / 3) * (r[0][0] - left[1][0]),
left[1][1] + (2 / 3) * (r[0][1] - left[1][1]),
),
left[1],
pointFrom<GlobalPoint>(
left[1][0] + (2 / 3) * (r[0][0] - left[1][0]),
left[1][1] + (2 / 3) * (r[0][1] - left[1][1]),
),
pointFromVector(
offsets[0],
pointFrom<GlobalPoint>(
top[0][0] + (2 / 3) * (r[0][0] - top[0][0]),
top[0][1] + (2 / 3) * (r[0][1] - top[0][1]),
),
pointFrom<GlobalPoint>(
top[0][0] + (2 / 3) * (r[0][0] - top[0][0]),
top[0][1] + (2 / 3) * (r[0][1] - top[0][1]),
),
pointFromVector(offsets[0], top[0]),
top[0],
), // TOP LEFT
curve(
pointFromVector(offsets[1], top[1]),
pointFromVector(
offsets[1],
pointFrom<GlobalPoint>(
top[1][0] + (2 / 3) * (r[1][0] - top[1][0]),
top[1][1] + (2 / 3) * (r[0][1] - top[1][1]),
),
top[1],
pointFrom<GlobalPoint>(
top[1][0] + (2 / 3) * (r[1][0] - top[1][0]),
top[1][1] + (2 / 3) * (r[0][1] - top[1][1]),
),
pointFromVector(
offsets[1],
pointFrom<GlobalPoint>(
right[0][0] + (2 / 3) * (r[1][0] - right[0][0]),
right[0][1] + (2 / 3) * (r[0][1] - right[0][1]),
),
pointFrom<GlobalPoint>(
right[0][0] + (2 / 3) * (r[1][0] - right[0][0]),
right[0][1] + (2 / 3) * (r[0][1] - right[0][1]),
),
pointFromVector(offsets[1], right[0]),
right[0],
), // TOP RIGHT
curve(
pointFromVector(offsets[2], right[1]),
pointFromVector(
offsets[2],
pointFrom<GlobalPoint>(
right[1][0] + (2 / 3) * (r[1][0] - right[1][0]),
right[1][1] + (2 / 3) * (r[1][1] - right[1][1]),
),
right[1],
pointFrom<GlobalPoint>(
right[1][0] + (2 / 3) * (r[1][0] - right[1][0]),
right[1][1] + (2 / 3) * (r[1][1] - right[1][1]),
),
pointFromVector(
offsets[2],
pointFrom<GlobalPoint>(
bottom[1][0] + (2 / 3) * (r[1][0] - bottom[1][0]),
bottom[1][1] + (2 / 3) * (r[1][1] - bottom[1][1]),
),
pointFrom<GlobalPoint>(
bottom[1][0] + (2 / 3) * (r[1][0] - bottom[1][0]),
bottom[1][1] + (2 / 3) * (r[1][1] - bottom[1][1]),
),
pointFromVector(offsets[2], bottom[1]),
bottom[1],
), // BOTTOM RIGHT
curve(
pointFromVector(offsets[3], bottom[0]),
pointFromVector(
offsets[3],
pointFrom<GlobalPoint>(
bottom[0][0] + (2 / 3) * (r[0][0] - bottom[0][0]),
bottom[0][1] + (2 / 3) * (r[1][1] - bottom[0][1]),
),
bottom[0],
pointFrom<GlobalPoint>(
bottom[0][0] + (2 / 3) * (r[0][0] - bottom[0][0]),
bottom[0][1] + (2 / 3) * (r[1][1] - bottom[0][1]),
),
pointFromVector(
offsets[3],
pointFrom<GlobalPoint>(
left[0][0] + (2 / 3) * (r[0][0] - left[0][0]),
left[0][1] + (2 / 3) * (r[1][1] - left[0][1]),
),
pointFrom<GlobalPoint>(
left[0][0] + (2 / 3) * (r[0][0] - left[0][0]),
left[0][1] + (2 / 3) * (r[1][1] - left[0][1]),
),
pointFromVector(offsets[3], left[0]),
left[0],
), // BOTTOM LEFT
];
const sides = [
lineSegment<GlobalPoint>(corners[0][3], corners[1][0]),
lineSegment<GlobalPoint>(corners[1][3], corners[2][0]),
lineSegment<GlobalPoint>(corners[2][3], corners[3][0]),
lineSegment<GlobalPoint>(corners[3][3], corners[0][0]),
];
const corners =
offset > 0
? baseCorners.map(
(corner) =>
curveCatmullRomCubicApproxPoints(
curveOffsetPoints(corner, offset),
)!,
)
: [
[baseCorners[0]],
[baseCorners[1]],
[baseCorners[2]],
[baseCorners[3]],
];
return [sides, corners];
const sides = [
lineSegment<GlobalPoint>(
corners[0][corners[0].length - 1][3],
corners[1][0][0],
),
lineSegment<GlobalPoint>(
corners[1][corners[1].length - 1][3],
corners[2][0][0],
),
lineSegment<GlobalPoint>(
corners[2][corners[2].length - 1][3],
corners[3][0][0],
),
lineSegment<GlobalPoint>(
corners[3][corners[3].length - 1][3],
corners[0][0][0],
),
];
const shape = [sides, corners.flat()] as ElementShape;
setElementShapesCacheEntry(element, shape, offset);
return shape;
}
/**
* Get the building components of a diamond element in the form of
* line segments and curves as a tuple, in this order.
* Get the **unrotated** building components of a diamond element
* in the form of line segments and curves as a tuple, in this order.
*
* @param element The element to deconstruct
* @param offset An optional offset
* @returns Tuple of line segments (0) and curves (1)
* @returns Tuple of line **unrotated** segments (0) and curves (1)
*/
export function deconstructDiamondElement(
element: ExcalidrawDiamondElement,
offset: number = 0,
): [LineSegment<GlobalPoint>[], Curve<GlobalPoint>[]] {
const [topX, topY, rightX, rightY, bottomX, bottomY, leftX, leftY] =
getDiamondPoints(element);
const verticalRadius = getCornerRadius(Math.abs(topX - leftX), element);
const horizontalRadius = getCornerRadius(Math.abs(rightY - topY), element);
const cachedShape = getElementShapesCacheEntry(element, offset);
if (element.roundness?.type == null) {
const [top, right, bottom, left]: GlobalPoint[] = [
pointFrom(element.x + topX, element.y + topY - offset),
pointFrom(element.x + rightX + offset, element.y + rightY),
pointFrom(element.x + bottomX, element.y + bottomY + offset),
pointFrom(element.x + leftX - offset, element.y + leftY),
];
// Create the line segment parts of the diamond
// NOTE: Horizontal and vertical seems to be flipped here
const topRight = lineSegment<GlobalPoint>(
pointFrom(top[0] + verticalRadius, top[1] + horizontalRadius),
pointFrom(right[0] - verticalRadius, right[1] - horizontalRadius),
);
const bottomRight = lineSegment<GlobalPoint>(
pointFrom(right[0] - verticalRadius, right[1] + horizontalRadius),
pointFrom(bottom[0] + verticalRadius, bottom[1] - horizontalRadius),
);
const bottomLeft = lineSegment<GlobalPoint>(
pointFrom(bottom[0] - verticalRadius, bottom[1] - horizontalRadius),
pointFrom(left[0] + verticalRadius, left[1] + horizontalRadius),
);
const topLeft = lineSegment<GlobalPoint>(
pointFrom(left[0] + verticalRadius, left[1] - horizontalRadius),
pointFrom(top[0] - verticalRadius, top[1] + horizontalRadius),
);
return [[topRight, bottomRight, bottomLeft, topLeft], []];
if (cachedShape) {
return cachedShape;
}
const center = elementCenterPoint(element);
const [topX, topY, rightX, rightY, bottomX, bottomY, leftX, leftY] =
getDiamondPoints(element);
const verticalRadius = element.roundness
? getCornerRadius(Math.abs(topX - leftX), element)
: (topX - leftX) * 0.01;
const horizontalRadius = element.roundness
? getCornerRadius(Math.abs(rightY - topY), element)
: (rightY - topY) * 0.01;
const [top, right, bottom, left]: GlobalPoint[] = [
pointFrom(element.x + topX, element.y + topY),
@ -262,94 +349,135 @@ export function deconstructDiamondElement(
pointFrom(element.x + leftX, element.y + leftY),
];
const offsets = [
vectorScale(vectorNormalize(vectorFromPoint(right, center)), offset), // RIGHT
vectorScale(vectorNormalize(vectorFromPoint(bottom, center)), offset), // BOTTOM
vectorScale(vectorNormalize(vectorFromPoint(left, center)), offset), // LEFT
vectorScale(vectorNormalize(vectorFromPoint(top, center)), offset), // TOP
];
const corners = [
const baseCorners = [
curve(
pointFromVector(
offsets[0],
pointFrom<GlobalPoint>(
right[0] - verticalRadius,
right[1] - horizontalRadius,
),
pointFrom<GlobalPoint>(
right[0] - verticalRadius,
right[1] - horizontalRadius,
),
pointFromVector(offsets[0], right),
pointFromVector(offsets[0], right),
pointFromVector(
offsets[0],
pointFrom<GlobalPoint>(
right[0] - verticalRadius,
right[1] + horizontalRadius,
),
right,
right,
pointFrom<GlobalPoint>(
right[0] - verticalRadius,
right[1] + horizontalRadius,
),
), // RIGHT
curve(
pointFromVector(
offsets[1],
pointFrom<GlobalPoint>(
bottom[0] + verticalRadius,
bottom[1] - horizontalRadius,
),
pointFrom<GlobalPoint>(
bottom[0] + verticalRadius,
bottom[1] - horizontalRadius,
),
pointFromVector(offsets[1], bottom),
pointFromVector(offsets[1], bottom),
pointFromVector(
offsets[1],
pointFrom<GlobalPoint>(
bottom[0] - verticalRadius,
bottom[1] - horizontalRadius,
),
bottom,
bottom,
pointFrom<GlobalPoint>(
bottom[0] - verticalRadius,
bottom[1] - horizontalRadius,
),
), // BOTTOM
curve(
pointFromVector(
offsets[2],
pointFrom<GlobalPoint>(
left[0] + verticalRadius,
left[1] + horizontalRadius,
),
pointFrom<GlobalPoint>(
left[0] + verticalRadius,
left[1] + horizontalRadius,
),
pointFromVector(offsets[2], left),
pointFromVector(offsets[2], left),
pointFromVector(
offsets[2],
pointFrom<GlobalPoint>(
left[0] + verticalRadius,
left[1] - horizontalRadius,
),
left,
left,
pointFrom<GlobalPoint>(
left[0] + verticalRadius,
left[1] - horizontalRadius,
),
), // LEFT
curve(
pointFromVector(
offsets[3],
pointFrom<GlobalPoint>(
top[0] - verticalRadius,
top[1] + horizontalRadius,
),
pointFrom<GlobalPoint>(
top[0] - verticalRadius,
top[1] + horizontalRadius,
),
pointFromVector(offsets[3], top),
pointFromVector(offsets[3], top),
pointFromVector(
offsets[3],
pointFrom<GlobalPoint>(
top[0] + verticalRadius,
top[1] + horizontalRadius,
),
top,
top,
pointFrom<GlobalPoint>(
top[0] + verticalRadius,
top[1] + horizontalRadius,
),
), // TOP
];
const corners =
offset > 0
? baseCorners.map(
(corner) =>
curveCatmullRomCubicApproxPoints(
curveOffsetPoints(corner, offset),
)!,
)
: [
[baseCorners[0]],
[baseCorners[1]],
[baseCorners[2]],
[baseCorners[3]],
];
const sides = [
lineSegment<GlobalPoint>(corners[0][3], corners[1][0]),
lineSegment<GlobalPoint>(corners[1][3], corners[2][0]),
lineSegment<GlobalPoint>(corners[2][3], corners[3][0]),
lineSegment<GlobalPoint>(corners[3][3], corners[0][0]),
lineSegment<GlobalPoint>(
corners[0][corners[0].length - 1][3],
corners[1][0][0],
),
lineSegment<GlobalPoint>(
corners[1][corners[1].length - 1][3],
corners[2][0][0],
),
lineSegment<GlobalPoint>(
corners[2][corners[2].length - 1][3],
corners[3][0][0],
),
lineSegment<GlobalPoint>(
corners[3][corners[3].length - 1][3],
corners[0][0][0],
),
];
return [sides, corners];
const shape = [sides, corners.flat()] as ElementShape;
setElementShapesCacheEntry(element, shape, offset);
return shape;
}
// Checks if the first and last point are close enough
// to be considered a loop
export const isPathALoop = (
points: ExcalidrawLinearElement["points"],
/** supply if you want the loop detection to account for current zoom */
zoomValue: Zoom["value"] = 1 as NormalizedZoomValue,
): boolean => {
if (points.length >= 3) {
const [first, last] = [points[0], points[points.length - 1]];
const distance = pointDistance(first, last);
// Adjusting LINE_CONFIRM_THRESHOLD to current zoom so that when zoomed in
// really close we make the threshold smaller, and vice versa.
return distance <= LINE_CONFIRM_THRESHOLD / zoomValue;
}
return false;
};
export const getCornerRadius = (x: number, element: ExcalidrawElement) => {
if (
element.roundness?.type === ROUNDNESS.PROPORTIONAL_RADIUS ||
element.roundness?.type === ROUNDNESS.LEGACY
) {
return x * DEFAULT_PROPORTIONAL_RADIUS;
}
if (element.roundness?.type === ROUNDNESS.ADAPTIVE_RADIUS) {
const fixedRadiusSize = element.roundness?.value ?? DEFAULT_ADAPTIVE_RADIUS;
const CUTOFF_SIZE = fixedRadiusSize / DEFAULT_PROPORTIONAL_RADIUS;
if (x <= CUTOFF_SIZE) {
return x * DEFAULT_PROPORTIONAL_RADIUS;
}
return fixedRadiusSize;
}
return 0;
};

View File

@ -17,7 +17,7 @@ exports[`Test Linear Elements > Test bound text element > should match styles fo
class="excalidraw-wysiwyg"
data-type="wysiwyg"
dir="auto"
style="position: absolute; display: inline-block; min-height: 1em; backface-visibility: hidden; margin: 0px; padding: 0px; border: 0px; outline: 0; resize: none; background: transparent; overflow: hidden; z-index: var(--zIndex-wysiwyg); word-break: break-word; white-space: pre-wrap; overflow-wrap: break-word; box-sizing: content-box; width: 10.5px; height: 26.25px; left: 35px; top: 7.5px; transform: translate(0px, 0px) scale(1) rotate(0deg); text-align: center; vertical-align: middle; color: rgb(30, 30, 30); opacity: 1; filter: var(--theme-filter); max-height: 992.5px; font: Emoji 20px 20px; line-height: 1.25; font-family: Excalifont, Xiaolai, Segoe UI Emoji;"
style="position: absolute; display: inline-block; min-height: 1em; backface-visibility: hidden; margin: 0px; padding: 0px; border: 0px; outline: 0; resize: none; background: transparent; overflow: hidden; z-index: var(--zIndex-wysiwyg); word-break: break-word; white-space: pre-wrap; overflow-wrap: break-word; box-sizing: content-box; width: 10.5px; height: 26.25px; left: 35px; top: 7.5px; transform: translate(0px, 0px) scale(1) rotate(0deg); text-align: center; vertical-align: middle; color: rgb(30, 30, 30); opacity: 1; filter: var(--theme-filter); max-height: 992.5px; font: Emoji 20px 20px; line-height: 1.25; font-family: Excalifont, Xiaolai, sans-serif, Segoe UI Emoji;"
tabindex="0"
wrap="off"
/>

View File

@ -35,6 +35,7 @@ const createAndSelectTwoRectangles = () => {
// The second rectangle is already reselected because it was the last element created
mouse.reset();
Keyboard.withModifierKeys({ shift: true }, () => {
mouse.moveTo(10, 0);
mouse.click();
});
};
@ -52,6 +53,7 @@ const createAndSelectTwoRectanglesWithDifferentSizes = () => {
// The second rectangle is already reselected because it was the last element created
mouse.reset();
Keyboard.withModifierKeys({ shift: true }, () => {
mouse.moveTo(10, 0);
mouse.click();
});
};
@ -202,6 +204,7 @@ describe("aligning", () => {
// The second rectangle is already reselected because it was the last element created
mouse.reset();
Keyboard.withModifierKeys({ shift: true }, () => {
mouse.moveTo(10, 0);
mouse.click();
});
@ -215,6 +218,7 @@ describe("aligning", () => {
// Add the created group to the current selection
mouse.restorePosition(0, 0);
Keyboard.withModifierKeys({ shift: true }, () => {
mouse.moveTo(10, 0);
mouse.click();
});
};
@ -316,6 +320,7 @@ describe("aligning", () => {
// The second rectangle is already selected because it was the last element created
mouse.reset();
Keyboard.withModifierKeys({ shift: true }, () => {
mouse.moveTo(10, 0);
mouse.click();
});
@ -330,7 +335,7 @@ describe("aligning", () => {
mouse.down();
mouse.up(100, 100);
mouse.restorePosition(200, 200);
mouse.restorePosition(210, 200);
Keyboard.withModifierKeys({ shift: true }, () => {
mouse.click();
});
@ -341,6 +346,7 @@ describe("aligning", () => {
// The second group is already selected because it was the last group created
mouse.reset();
Keyboard.withModifierKeys({ shift: true }, () => {
mouse.moveTo(10, 0);
mouse.click();
});
};
@ -454,6 +460,7 @@ describe("aligning", () => {
// The second rectangle is already reselected because it was the last element created
mouse.reset();
Keyboard.withModifierKeys({ shift: true }, () => {
mouse.moveTo(10, 0);
mouse.click();
});
@ -466,7 +473,7 @@ describe("aligning", () => {
mouse.up(100, 100);
// Add group to current selection
mouse.restorePosition(0, 0);
mouse.restorePosition(10, 0);
Keyboard.withModifierKeys({ shift: true }, () => {
mouse.click();
});
@ -482,6 +489,7 @@ describe("aligning", () => {
// Select the nested group, the rectangle is already selected
mouse.reset();
Keyboard.withModifierKeys({ shift: true }, () => {
mouse.moveTo(10, 0);
mouse.click();
});
};
@ -581,4 +589,424 @@ describe("aligning", () => {
expect(API.getSelectedElements()[2].x).toEqual(250);
expect(API.getSelectedElements()[3].x).toEqual(150);
});
const createGroupAndSelectInEditGroupMode = () => {
UI.clickTool("rectangle");
mouse.down();
mouse.up(100, 100);
UI.clickTool("rectangle");
mouse.down(0, 0);
mouse.up(100, 100);
// select the first element.
// The second rectangle is already reselected because it was the last element created
mouse.reset();
Keyboard.withModifierKeys({ shift: true }, () => {
mouse.moveTo(10, 0);
mouse.click();
});
API.executeAction(actionGroup);
mouse.reset();
mouse.moveTo(10, 0);
mouse.doubleClick();
Keyboard.withModifierKeys({ shift: true }, () => {
mouse.click();
mouse.moveTo(100, 100);
mouse.click();
});
};
it("aligns elements within a group while in group edit mode correctly to the top", () => {
createGroupAndSelectInEditGroupMode();
expect(API.getSelectedElements()[0].y).toEqual(0);
expect(API.getSelectedElements()[1].y).toEqual(100);
API.executeAction(actionAlignTop);
expect(API.getSelectedElements()[0].y).toEqual(0);
expect(API.getSelectedElements()[1].y).toEqual(0);
});
it("aligns elements within a group while in group edit mode correctly to the bottom", () => {
createGroupAndSelectInEditGroupMode();
expect(API.getSelectedElements()[0].y).toEqual(0);
expect(API.getSelectedElements()[1].y).toEqual(100);
API.executeAction(actionAlignBottom);
expect(API.getSelectedElements()[0].y).toEqual(100);
expect(API.getSelectedElements()[1].y).toEqual(100);
});
it("aligns elements within a group while in group edit mode correctly to the left", () => {
createGroupAndSelectInEditGroupMode();
expect(API.getSelectedElements()[0].x).toEqual(0);
expect(API.getSelectedElements()[1].x).toEqual(100);
API.executeAction(actionAlignLeft);
expect(API.getSelectedElements()[0].x).toEqual(0);
expect(API.getSelectedElements()[1].x).toEqual(0);
});
it("aligns elements within a group while in group edit mode correctly to the right", () => {
createGroupAndSelectInEditGroupMode();
expect(API.getSelectedElements()[0].x).toEqual(0);
expect(API.getSelectedElements()[1].x).toEqual(100);
API.executeAction(actionAlignRight);
expect(API.getSelectedElements()[0].x).toEqual(100);
expect(API.getSelectedElements()[1].x).toEqual(100);
});
it("aligns elements within a group while in group edit mode correctly to the vertical center", () => {
createGroupAndSelectInEditGroupMode();
expect(API.getSelectedElements()[0].y).toEqual(0);
expect(API.getSelectedElements()[1].y).toEqual(100);
API.executeAction(actionAlignVerticallyCentered);
expect(API.getSelectedElements()[0].y).toEqual(50);
expect(API.getSelectedElements()[1].y).toEqual(50);
});
it("aligns elements within a group while in group edit mode correctly to the horizontal center", () => {
createGroupAndSelectInEditGroupMode();
expect(API.getSelectedElements()[0].x).toEqual(0);
expect(API.getSelectedElements()[1].x).toEqual(100);
API.executeAction(actionAlignHorizontallyCentered);
expect(API.getSelectedElements()[0].x).toEqual(50);
expect(API.getSelectedElements()[1].x).toEqual(50);
});
const createNestedGroupAndSelectInEditGroupMode = () => {
UI.clickTool("rectangle");
mouse.down();
mouse.up(100, 100);
UI.clickTool("rectangle");
mouse.down(0, 0);
mouse.up(100, 100);
// Select the first element.
// The second rectangle is already reselected because it was the last element created
mouse.reset();
Keyboard.withModifierKeys({ shift: true }, () => {
mouse.moveTo(10, 0);
mouse.click();
});
API.executeAction(actionGroup);
mouse.reset();
mouse.moveTo(200, 200);
// create third element
UI.clickTool("rectangle");
mouse.down(0, 0);
mouse.up(100, 100);
// third element is already selected, select the initial group and group together
mouse.reset();
Keyboard.withModifierKeys({ shift: true }, () => {
mouse.moveTo(10, 0);
mouse.click();
});
API.executeAction(actionGroup);
// double click to enter edit mode
mouse.doubleClick();
// select nested group and other element within the group
Keyboard.withModifierKeys({ shift: true }, () => {
mouse.moveTo(200, 200);
mouse.click();
});
};
it("aligns element and nested group while in group edit mode correctly to the top", () => {
createNestedGroupAndSelectInEditGroupMode();
expect(API.getSelectedElements()[0].y).toEqual(0);
expect(API.getSelectedElements()[1].y).toEqual(100);
expect(API.getSelectedElements()[2].y).toEqual(200);
API.executeAction(actionAlignTop);
expect(API.getSelectedElements()[0].y).toEqual(0);
expect(API.getSelectedElements()[1].y).toEqual(100);
expect(API.getSelectedElements()[2].y).toEqual(0);
});
it("aligns element and nested group while in group edit mode correctly to the bottom", () => {
createNestedGroupAndSelectInEditGroupMode();
expect(API.getSelectedElements()[0].y).toEqual(0);
expect(API.getSelectedElements()[1].y).toEqual(100);
expect(API.getSelectedElements()[2].y).toEqual(200);
API.executeAction(actionAlignBottom);
expect(API.getSelectedElements()[0].y).toEqual(100);
expect(API.getSelectedElements()[1].y).toEqual(200);
expect(API.getSelectedElements()[2].y).toEqual(200);
});
it("aligns element and nested group while in group edit mode correctly to the left", () => {
createNestedGroupAndSelectInEditGroupMode();
expect(API.getSelectedElements()[0].x).toEqual(0);
expect(API.getSelectedElements()[1].x).toEqual(100);
expect(API.getSelectedElements()[2].x).toEqual(200);
API.executeAction(actionAlignLeft);
expect(API.getSelectedElements()[0].x).toEqual(0);
expect(API.getSelectedElements()[1].x).toEqual(100);
expect(API.getSelectedElements()[2].x).toEqual(0);
});
it("aligns element and nested group while in group edit mode correctly to the right", () => {
createNestedGroupAndSelectInEditGroupMode();
expect(API.getSelectedElements()[0].x).toEqual(0);
expect(API.getSelectedElements()[1].x).toEqual(100);
expect(API.getSelectedElements()[2].x).toEqual(200);
API.executeAction(actionAlignRight);
expect(API.getSelectedElements()[0].x).toEqual(100);
expect(API.getSelectedElements()[1].x).toEqual(200);
expect(API.getSelectedElements()[2].x).toEqual(200);
});
it("aligns element and nested group while in group edit mode correctly to the vertical center", () => {
createNestedGroupAndSelectInEditGroupMode();
expect(API.getSelectedElements()[0].y).toEqual(0);
expect(API.getSelectedElements()[1].y).toEqual(100);
expect(API.getSelectedElements()[2].y).toEqual(200);
API.executeAction(actionAlignVerticallyCentered);
expect(API.getSelectedElements()[0].y).toEqual(50);
expect(API.getSelectedElements()[1].y).toEqual(150);
expect(API.getSelectedElements()[2].y).toEqual(100);
});
it("aligns elements and nested group within a group while in group edit mode correctly to the horizontal center", () => {
createNestedGroupAndSelectInEditGroupMode();
expect(API.getSelectedElements()[0].x).toEqual(0);
expect(API.getSelectedElements()[1].x).toEqual(100);
expect(API.getSelectedElements()[2].x).toEqual(200);
API.executeAction(actionAlignHorizontallyCentered);
expect(API.getSelectedElements()[0].x).toEqual(50);
expect(API.getSelectedElements()[1].x).toEqual(150);
expect(API.getSelectedElements()[2].x).toEqual(100);
});
const createAndSelectSingleGroup = () => {
UI.clickTool("rectangle");
mouse.down();
mouse.up(100, 100);
UI.clickTool("rectangle");
mouse.down(0, 0);
mouse.up(100, 100);
// Select the first element.
// The second rectangle is already reselected because it was the last element created
mouse.reset();
Keyboard.withModifierKeys({ shift: true }, () => {
mouse.moveTo(10, 0);
mouse.click();
});
API.executeAction(actionGroup);
};
it("aligns elements within a single-selected group correctly to the top", () => {
createAndSelectSingleGroup();
expect(API.getSelectedElements()[0].y).toEqual(0);
expect(API.getSelectedElements()[1].y).toEqual(100);
API.executeAction(actionAlignTop);
expect(API.getSelectedElements()[0].y).toEqual(0);
expect(API.getSelectedElements()[1].y).toEqual(0);
});
it("aligns elements within a single-selected group correctly to the bottom", () => {
createAndSelectSingleGroup();
expect(API.getSelectedElements()[0].y).toEqual(0);
expect(API.getSelectedElements()[1].y).toEqual(100);
API.executeAction(actionAlignBottom);
expect(API.getSelectedElements()[0].y).toEqual(100);
expect(API.getSelectedElements()[1].y).toEqual(100);
});
it("aligns elements within a single-selected group correctly to the left", () => {
createAndSelectSingleGroup();
expect(API.getSelectedElements()[0].x).toEqual(0);
expect(API.getSelectedElements()[1].x).toEqual(100);
API.executeAction(actionAlignLeft);
expect(API.getSelectedElements()[0].x).toEqual(0);
expect(API.getSelectedElements()[1].x).toEqual(0);
});
it("aligns elements within a single-selected group correctly to the right", () => {
createAndSelectSingleGroup();
expect(API.getSelectedElements()[0].x).toEqual(0);
expect(API.getSelectedElements()[1].x).toEqual(100);
API.executeAction(actionAlignRight);
expect(API.getSelectedElements()[0].x).toEqual(100);
expect(API.getSelectedElements()[1].x).toEqual(100);
});
it("aligns elements within a single-selected group correctly to the vertical center", () => {
createAndSelectSingleGroup();
expect(API.getSelectedElements()[0].y).toEqual(0);
expect(API.getSelectedElements()[1].y).toEqual(100);
API.executeAction(actionAlignVerticallyCentered);
expect(API.getSelectedElements()[0].y).toEqual(50);
expect(API.getSelectedElements()[1].y).toEqual(50);
});
it("aligns elements within a single-selected group correctly to the horizontal center", () => {
createAndSelectSingleGroup();
expect(API.getSelectedElements()[0].x).toEqual(0);
expect(API.getSelectedElements()[1].x).toEqual(100);
API.executeAction(actionAlignHorizontallyCentered);
expect(API.getSelectedElements()[0].x).toEqual(50);
expect(API.getSelectedElements()[1].x).toEqual(50);
});
const createAndSelectSingleGroupWithNestedGroup = () => {
UI.clickTool("rectangle");
mouse.down();
mouse.up(100, 100);
UI.clickTool("rectangle");
mouse.down(0, 0);
mouse.up(100, 100);
// Select the first element.
// The second rectangle is already reselected because it was the last element created
mouse.reset();
Keyboard.withModifierKeys({ shift: true }, () => {
mouse.moveTo(10, 0);
mouse.click();
});
API.executeAction(actionGroup);
mouse.reset();
UI.clickTool("rectangle");
mouse.down(200, 200);
mouse.up(100, 100);
// Add group to current selection
mouse.restorePosition(10, 0);
Keyboard.withModifierKeys({ shift: true }, () => {
mouse.click();
});
// Create the nested group
API.executeAction(actionGroup);
};
it("aligns elements within a single-selected group containing a nested group correctly to the top", () => {
createAndSelectSingleGroupWithNestedGroup();
expect(API.getSelectedElements()[0].y).toEqual(0);
expect(API.getSelectedElements()[1].y).toEqual(100);
expect(API.getSelectedElements()[2].y).toEqual(200);
API.executeAction(actionAlignTop);
expect(API.getSelectedElements()[0].y).toEqual(0);
expect(API.getSelectedElements()[1].y).toEqual(100);
expect(API.getSelectedElements()[2].y).toEqual(0);
});
it("aligns elements within a single-selected group containing a nested group correctly to the bottom", () => {
createAndSelectSingleGroupWithNestedGroup();
expect(API.getSelectedElements()[0].y).toEqual(0);
expect(API.getSelectedElements()[1].y).toEqual(100);
expect(API.getSelectedElements()[2].y).toEqual(200);
API.executeAction(actionAlignBottom);
expect(API.getSelectedElements()[0].y).toEqual(100);
expect(API.getSelectedElements()[1].y).toEqual(200);
expect(API.getSelectedElements()[2].y).toEqual(200);
});
it("aligns elements within a single-selected group containing a nested group correctly to the left", () => {
createAndSelectSingleGroupWithNestedGroup();
expect(API.getSelectedElements()[0].x).toEqual(0);
expect(API.getSelectedElements()[1].x).toEqual(100);
expect(API.getSelectedElements()[2].x).toEqual(200);
API.executeAction(actionAlignLeft);
expect(API.getSelectedElements()[0].x).toEqual(0);
expect(API.getSelectedElements()[1].x).toEqual(100);
expect(API.getSelectedElements()[2].x).toEqual(0);
});
it("aligns elements within a single-selected group containing a nested group correctly to the right", () => {
createAndSelectSingleGroupWithNestedGroup();
expect(API.getSelectedElements()[0].x).toEqual(0);
expect(API.getSelectedElements()[1].x).toEqual(100);
expect(API.getSelectedElements()[2].x).toEqual(200);
API.executeAction(actionAlignRight);
expect(API.getSelectedElements()[0].x).toEqual(100);
expect(API.getSelectedElements()[1].x).toEqual(200);
expect(API.getSelectedElements()[2].x).toEqual(200);
});
it("aligns elements within a single-selected group containing a nested group correctly to the vertical center", () => {
createAndSelectSingleGroupWithNestedGroup();
expect(API.getSelectedElements()[0].y).toEqual(0);
expect(API.getSelectedElements()[1].y).toEqual(100);
expect(API.getSelectedElements()[2].y).toEqual(200);
API.executeAction(actionAlignVerticallyCentered);
expect(API.getSelectedElements()[0].y).toEqual(50);
expect(API.getSelectedElements()[1].y).toEqual(150);
expect(API.getSelectedElements()[2].y).toEqual(100);
});
it("aligns elements within a single-selected group containing a nested group correctly to the horizontal center", () => {
createAndSelectSingleGroupWithNestedGroup();
expect(API.getSelectedElements()[0].x).toEqual(0);
expect(API.getSelectedElements()[1].x).toEqual(100);
expect(API.getSelectedElements()[2].x).toEqual(200);
API.executeAction(actionAlignHorizontallyCentered);
expect(API.getSelectedElements()[0].x).toEqual(50);
expect(API.getSelectedElements()[1].x).toEqual(150);
expect(API.getSelectedElements()[2].x).toEqual(100);
});
});

View File

@ -11,6 +11,10 @@ import { UI, Pointer, Keyboard } from "@excalidraw/excalidraw/tests/helpers/ui";
import { fireEvent, render } from "@excalidraw/excalidraw/tests/test-utils";
import { getTransformHandles } from "../src/transformHandles";
import {
getTextEditor,
TEXT_EDITOR_SELECTOR,
} from "../../excalidraw/tests/queries/dom";
const { h } = window;
@ -151,10 +155,10 @@ describe("element binding", () => {
// NOTE this mouse down/up + await needs to be done in order to repro
// the issue, due to https://github.com/excalidraw/excalidraw/blob/46bff3daceb602accf60c40a84610797260fca94/src/components/App.tsx#L740
mouse.reset();
expect(h.state.editingLinearElement).not.toBe(null);
expect(h.state.selectedLinearElement?.isEditing).toBe(true);
mouse.down(0, 0);
await new Promise((r) => setTimeout(r, 100));
expect(h.state.editingLinearElement).toBe(null);
expect(h.state.selectedLinearElement?.isEditing).toBe(false);
expect(API.getSelectedElement().type).toBe("rectangle");
mouse.up();
expect(API.getSelectedElement().type).toBe("rectangle");
@ -172,12 +176,12 @@ describe("element binding", () => {
const arrow = UI.createElement("arrow", {
x: 0,
y: 0,
size: 50,
size: 49,
});
expect(arrow.endBinding).toBe(null);
mouse.downAt(50, 50);
mouse.downAt(49, 49);
mouse.moveTo(51, 0);
mouse.up(0, 0);
@ -244,18 +248,12 @@ describe("element binding", () => {
mouse.clickAt(text.x + 50, text.y + 50);
const editor = document.querySelector(
".excalidraw-textEditorContainer > textarea",
) as HTMLTextAreaElement;
expect(editor).not.toBe(null);
const editor = await getTextEditor();
fireEvent.change(editor, { target: { value: "" } });
fireEvent.keyDown(editor, { key: KEYS.ESCAPE });
expect(
document.querySelector(".excalidraw-textEditorContainer > textarea"),
).toBe(null);
expect(document.querySelector(TEXT_EDITOR_SELECTOR)).toBe(null);
expect(arrow.endBinding).toBe(null);
});
@ -285,18 +283,14 @@ describe("element binding", () => {
UI.clickTool("text");
mouse.clickAt(text.x + 50, text.y + 50);
const editor = document.querySelector(
".excalidraw-textEditorContainer > textarea",
) as HTMLTextAreaElement;
const editor = await getTextEditor();
expect(editor).not.toBe(null);
fireEvent.change(editor, { target: { value: "asdasdasdasdas" } });
fireEvent.keyDown(editor, { key: KEYS.ESCAPE });
expect(
document.querySelector(".excalidraw-textEditorContainer > textarea"),
).toBe(null);
expect(document.querySelector(TEXT_EDITOR_SELECTOR)).toBe(null);
expect(arrow.endBinding?.elementId).toBe(text.id);
});

View File

@ -0,0 +1,38 @@
import { type GlobalPoint, type LocalPoint, pointFrom } from "@excalidraw/math";
import { Excalidraw } from "@excalidraw/excalidraw";
import { UI } from "@excalidraw/excalidraw/tests/helpers/ui";
import "@excalidraw/utils/test-utils";
import { render } from "@excalidraw/excalidraw/tests/test-utils";
import { hitElementItself } from "../src/collision";
describe("check rotated elements can be hit:", () => {
beforeEach(async () => {
localStorage.clear();
await render(<Excalidraw handleKeyboardGlobally={true} />);
});
it("arrow", () => {
UI.createElement("arrow", {
x: 0,
y: 0,
width: 124,
height: 302,
angle: 1.8700426423973724,
points: [
[0, 0],
[120, -198],
[-4, -302],
] as LocalPoint[],
});
//const p = [120, -211];
//const p = [0, 13];
const hit = hitElementItself({
point: pointFrom<GlobalPoint>(88, -68),
element: window.h.elements[0],
threshold: 10,
elementsMap: window.h.scene.getNonDeletedElementsMap(),
});
expect(hit).toBe(true);
});
});

View File

@ -1,13 +1,345 @@
import { API } from "@excalidraw/excalidraw/tests/helpers/api";
import type { ObservedAppState } from "@excalidraw/excalidraw/types";
import type { LinearElementEditor } from "@excalidraw/element";
import type { SceneElementsMap } from "@excalidraw/element/types";
import { AppStateDelta } from "../src/delta";
import { AppStateDelta, Delta, ElementsDelta } from "../src/delta";
describe("ElementsDelta", () => {
describe("elements delta calculation", () => {
it("should not throw when element gets removed but was already deleted", () => {
const element = API.createElement({
type: "rectangle",
x: 100,
y: 100,
isDeleted: true,
});
const prevElements = new Map([[element.id, element]]);
const nextElements = new Map();
expect(() =>
ElementsDelta.calculate(prevElements, nextElements),
).not.toThrow();
});
it("should not throw when adding element as already deleted", () => {
const element = API.createElement({
type: "rectangle",
x: 100,
y: 100,
isDeleted: true,
});
const prevElements = new Map();
const nextElements = new Map([[element.id, element]]);
expect(() =>
ElementsDelta.calculate(prevElements, nextElements),
).not.toThrow();
});
it("should create updated delta even when there is only version and versionNonce change", () => {
const baseElement = API.createElement({
type: "rectangle",
x: 100,
y: 100,
strokeColor: "#000000",
backgroundColor: "#ffffff",
});
const modifiedElement = {
...baseElement,
version: baseElement.version + 1,
versionNonce: baseElement.versionNonce + 1,
};
// Create maps for the delta calculation
const prevElements = new Map([[baseElement.id, baseElement]]);
const nextElements = new Map([[modifiedElement.id, modifiedElement]]);
// Calculate the delta
const delta = ElementsDelta.calculate(
prevElements as SceneElementsMap,
nextElements as SceneElementsMap,
);
expect(delta).toEqual(
ElementsDelta.create(
{},
{},
{
[baseElement.id]: Delta.create(
{
version: baseElement.version,
versionNonce: baseElement.versionNonce,
},
{
version: baseElement.version + 1,
versionNonce: baseElement.versionNonce + 1,
},
),
},
),
);
});
});
describe("squash", () => {
it("should not squash when second delta is empty", () => {
const updatedDelta = Delta.create(
{ x: 100, version: 1, versionNonce: 1 },
{ x: 200, version: 2, versionNonce: 2 },
);
const elementsDelta1 = ElementsDelta.create(
{},
{},
{ id1: updatedDelta },
);
const elementsDelta2 = ElementsDelta.empty();
const elementsDelta = elementsDelta1.squash(elementsDelta2);
expect(elementsDelta.isEmpty()).toBeFalsy();
expect(elementsDelta).toBe(elementsDelta1);
expect(elementsDelta.updated.id1).toBe(updatedDelta);
});
it("should squash mutually exclusive delta types", () => {
const addedDelta = Delta.create(
{ x: 100, version: 1, versionNonce: 1, isDeleted: true },
{ x: 200, version: 2, versionNonce: 2, isDeleted: false },
);
const removedDelta = Delta.create(
{ x: 100, version: 1, versionNonce: 1, isDeleted: false },
{ x: 200, version: 2, versionNonce: 2, isDeleted: true },
);
const updatedDelta = Delta.create(
{ x: 100, version: 1, versionNonce: 1 },
{ x: 200, version: 2, versionNonce: 2 },
);
const elementsDelta1 = ElementsDelta.create(
{ id1: addedDelta },
{ id2: removedDelta },
{},
);
const elementsDelta2 = ElementsDelta.create(
{},
{},
{ id3: updatedDelta },
);
const elementsDelta = elementsDelta1.squash(elementsDelta2);
expect(elementsDelta.isEmpty()).toBeFalsy();
expect(elementsDelta).toBe(elementsDelta1);
expect(elementsDelta.added.id1).toBe(addedDelta);
expect(elementsDelta.removed.id2).toBe(removedDelta);
expect(elementsDelta.updated.id3).toBe(updatedDelta);
});
it("should squash the same delta types", () => {
const elementsDelta1 = ElementsDelta.create(
{
id1: Delta.create(
{ x: 100, version: 1, versionNonce: 1, isDeleted: true },
{ x: 200, version: 2, versionNonce: 2, isDeleted: false },
),
},
{
id2: Delta.create(
{ x: 100, version: 1, versionNonce: 1, isDeleted: false },
{ x: 200, version: 2, versionNonce: 2, isDeleted: true },
),
},
{
id3: Delta.create(
{ x: 100, version: 1, versionNonce: 1 },
{ x: 200, version: 2, versionNonce: 2 },
),
},
);
const elementsDelta2 = ElementsDelta.create(
{
id1: Delta.create(
{ y: 100, version: 2, versionNonce: 2, isDeleted: true },
{ y: 200, version: 3, versionNonce: 3, isDeleted: false },
),
},
{
id2: Delta.create(
{ y: 100, version: 2, versionNonce: 2, isDeleted: false },
{ y: 200, version: 3, versionNonce: 3, isDeleted: true },
),
},
{
id3: Delta.create(
{ y: 100, version: 2, versionNonce: 2 },
{ y: 200, version: 3, versionNonce: 3 },
),
},
);
const elementsDelta = elementsDelta1.squash(elementsDelta2);
expect(elementsDelta.isEmpty()).toBeFalsy();
expect(elementsDelta).toBe(elementsDelta1);
expect(elementsDelta.added.id1).toEqual(
Delta.create(
{ x: 100, y: 100, version: 2, versionNonce: 2, isDeleted: true },
{ x: 200, y: 200, version: 3, versionNonce: 3, isDeleted: false },
),
);
expect(elementsDelta.removed.id2).toEqual(
Delta.create(
{ x: 100, y: 100, version: 2, versionNonce: 2, isDeleted: false },
{ x: 200, y: 200, version: 3, versionNonce: 3, isDeleted: true },
),
);
expect(elementsDelta.updated.id3).toEqual(
Delta.create(
{ x: 100, y: 100, version: 2, versionNonce: 2 },
{ x: 200, y: 200, version: 3, versionNonce: 3 },
),
);
});
it("should squash different delta types ", () => {
// id1: added -> updated => added
// id2: removed -> added => added
// id3: updated -> removed => removed
const elementsDelta1 = ElementsDelta.create(
{
id1: Delta.create(
{ x: 100, version: 1, versionNonce: 1, isDeleted: true },
{ x: 101, version: 2, versionNonce: 2, isDeleted: false },
),
},
{
id2: Delta.create(
{ x: 200, version: 1, versionNonce: 1, isDeleted: false },
{ x: 201, version: 2, versionNonce: 2, isDeleted: true },
),
},
{
id3: Delta.create(
{ x: 300, version: 1, versionNonce: 1 },
{ x: 301, version: 2, versionNonce: 2 },
),
},
);
const elementsDelta2 = ElementsDelta.create(
{
id2: Delta.create(
{ y: 200, version: 2, versionNonce: 2, isDeleted: true },
{ y: 201, version: 3, versionNonce: 3, isDeleted: false },
),
},
{
id3: Delta.create(
{ y: 300, version: 2, versionNonce: 2, isDeleted: false },
{ y: 301, version: 3, versionNonce: 3, isDeleted: true },
),
},
{
id1: Delta.create(
{ y: 100, version: 2, versionNonce: 2 },
{ y: 101, version: 3, versionNonce: 3 },
),
},
);
const elementsDelta = elementsDelta1.squash(elementsDelta2);
expect(elementsDelta.isEmpty()).toBeFalsy();
expect(elementsDelta).toBe(elementsDelta1);
expect(elementsDelta.added).toEqual({
id1: Delta.create(
{ x: 100, y: 100, version: 2, versionNonce: 2, isDeleted: true },
{ x: 101, y: 101, version: 3, versionNonce: 3, isDeleted: false },
),
id2: Delta.create(
{ x: 200, y: 200, version: 2, versionNonce: 2, isDeleted: true },
{ x: 201, y: 201, version: 3, versionNonce: 3, isDeleted: false },
),
});
expect(elementsDelta.removed).toEqual({
id3: Delta.create(
{ x: 300, y: 300, version: 2, versionNonce: 2, isDeleted: false },
{ x: 301, y: 301, version: 3, versionNonce: 3, isDeleted: true },
),
});
expect(elementsDelta.updated).toEqual({});
});
it("should squash bound elements", () => {
const elementsDelta1 = ElementsDelta.create(
{},
{},
{
id1: Delta.create(
{
version: 1,
versionNonce: 1,
boundElements: [{ id: "t1", type: "text" }],
},
{
version: 2,
versionNonce: 2,
boundElements: [{ id: "t2", type: "text" }],
},
),
},
);
const elementsDelta2 = ElementsDelta.create(
{},
{},
{
id1: Delta.create(
{
version: 2,
versionNonce: 2,
boundElements: [{ id: "a1", type: "arrow" }],
},
{
version: 3,
versionNonce: 3,
boundElements: [{ id: "a2", type: "arrow" }],
},
),
},
);
const elementsDelta = elementsDelta1.squash(elementsDelta2);
expect(elementsDelta.updated.id1.deleted.boundElements).toEqual([
{ id: "t1", type: "text" },
{ id: "a1", type: "arrow" },
]);
expect(elementsDelta.updated.id1.inserted.boundElements).toEqual([
{ id: "t2", type: "text" },
{ id: "a2", type: "arrow" },
]);
});
});
});
describe("AppStateDelta", () => {
describe("ensure stable delta properties order", () => {
it("should maintain stable order for root properties", () => {
const name = "untitled scene";
const selectedLinearElementId = "id1" as LinearElementEditor["elementId"];
const selectedLinearElement = {
elementId: "id1" as LinearElementEditor["elementId"],
isEditing: false,
};
const commonAppState = {
viewBackgroundColor: "#ffffff",
@ -16,6 +348,7 @@ describe("AppStateDelta", () => {
editingGroupId: null,
croppingElementId: null,
editingLinearElementId: null,
selectedLinearElementIsEditing: null,
lockedMultiSelections: {},
activeLockedId: null,
};
@ -23,23 +356,23 @@ describe("AppStateDelta", () => {
const prevAppState1: ObservedAppState = {
...commonAppState,
name: "",
selectedLinearElementId: null,
selectedLinearElement: null,
};
const nextAppState1: ObservedAppState = {
...commonAppState,
name,
selectedLinearElementId,
selectedLinearElement,
};
const prevAppState2: ObservedAppState = {
selectedLinearElementId: null,
selectedLinearElement: null,
name: "",
...commonAppState,
};
const nextAppState2: ObservedAppState = {
selectedLinearElementId,
selectedLinearElement,
name,
...commonAppState,
};
@ -57,8 +390,7 @@ describe("AppStateDelta", () => {
selectedGroupIds: {},
editingGroupId: null,
croppingElementId: null,
selectedLinearElementId: null,
editingLinearElementId: null,
selectedLinearElement: null,
activeLockedId: null,
lockedMultiSelections: {},
};
@ -104,8 +436,7 @@ describe("AppStateDelta", () => {
selectedElementIds: {},
editingGroupId: null,
croppingElementId: null,
selectedLinearElementId: null,
editingLinearElementId: null,
selectedLinearElement: null,
activeLockedId: null,
lockedMultiSelections: {},
};
@ -146,4 +477,97 @@ describe("AppStateDelta", () => {
expect(JSON.stringify(delta1)).toBe(JSON.stringify(delta2));
});
});
describe("squash", () => {
it("should not squash when second delta is empty", () => {
const delta = Delta.create(
{ name: "untitled scene" },
{ name: "titled scene" },
);
const appStateDelta1 = AppStateDelta.create(delta);
const appStateDelta2 = AppStateDelta.empty();
const appStateDelta = appStateDelta1.squash(appStateDelta2);
expect(appStateDelta.isEmpty()).toBeFalsy();
expect(appStateDelta).toBe(appStateDelta1);
expect(appStateDelta.delta).toBe(delta);
});
it("should squash exclusive properties", () => {
const delta1 = Delta.create(
{ name: "untitled scene" },
{ name: "titled scene" },
);
const delta2 = Delta.create(
{ viewBackgroundColor: "#ffffff" },
{ viewBackgroundColor: "#000000" },
);
const appStateDelta1 = AppStateDelta.create(delta1);
const appStateDelta2 = AppStateDelta.create(delta2);
const appStateDelta = appStateDelta1.squash(appStateDelta2);
expect(appStateDelta.isEmpty()).toBeFalsy();
expect(appStateDelta).toBe(appStateDelta1);
expect(appStateDelta.delta).toEqual(
Delta.create(
{ name: "untitled scene", viewBackgroundColor: "#ffffff" },
{ name: "titled scene", viewBackgroundColor: "#000000" },
),
);
});
it("should squash selectedElementIds, selectedGroupIds and lockedMultiSelections", () => {
const delta1 = Delta.create<Partial<ObservedAppState>>(
{
name: "untitled scene",
selectedElementIds: { id1: true },
selectedGroupIds: {},
lockedMultiSelections: { g1: true },
},
{
name: "titled scene",
selectedElementIds: { id2: true },
selectedGroupIds: { g1: true },
lockedMultiSelections: {},
},
);
const delta2 = Delta.create<Partial<ObservedAppState>>(
{
selectedElementIds: { id3: true },
selectedGroupIds: { g1: true },
lockedMultiSelections: {},
},
{
selectedElementIds: { id2: true },
selectedGroupIds: { g2: true, g3: true },
lockedMultiSelections: { g3: true },
},
);
const appStateDelta1 = AppStateDelta.create(delta1);
const appStateDelta2 = AppStateDelta.create(delta2);
const appStateDelta = appStateDelta1.squash(appStateDelta2);
expect(appStateDelta.isEmpty()).toBeFalsy();
expect(appStateDelta).toBe(appStateDelta1);
expect(appStateDelta.delta).toEqual(
Delta.create<Partial<ObservedAppState>>(
{
name: "untitled scene",
selectedElementIds: { id1: true, id3: true },
selectedGroupIds: { g1: true },
lockedMultiSelections: { g1: true },
},
{
name: "titled scene",
selectedElementIds: { id2: true },
selectedGroupIds: { g1: true, g2: true, g3: true },
lockedMultiSelections: { g3: true },
},
),
);
});
});
});

View File

@ -0,0 +1,128 @@
import {
distributeHorizontally,
distributeVertically,
} from "@excalidraw/excalidraw/actions";
import { defaultLang, setLanguage } from "@excalidraw/excalidraw/i18n";
import { Excalidraw } from "@excalidraw/excalidraw";
import { API } from "@excalidraw/excalidraw/tests/helpers/api";
import { UI, Pointer, Keyboard } from "@excalidraw/excalidraw/tests/helpers/ui";
import {
act,
unmountComponent,
render,
} from "@excalidraw/excalidraw/tests/test-utils";
const mouse = new Pointer("mouse");
// Scenario: three rectangles that will be distributed with gaps
const createAndSelectThreeRectanglesWithGap = () => {
UI.clickTool("rectangle");
mouse.down();
mouse.up(100, 100);
mouse.reset();
UI.clickTool("rectangle");
mouse.down(10, 10);
mouse.up(100, 100);
mouse.reset();
UI.clickTool("rectangle");
mouse.down(300, 300);
mouse.up(100, 100);
mouse.reset();
// Last rectangle is selected by default
Keyboard.withModifierKeys({ shift: true }, () => {
mouse.click(0, 10);
mouse.click(10, 0);
});
};
// Scenario: three rectangles that will be distributed by their centers
const createAndSelectThreeRectanglesWithoutGap = () => {
UI.clickTool("rectangle");
mouse.down();
mouse.up(100, 100);
mouse.reset();
UI.clickTool("rectangle");
mouse.down(10, 10);
mouse.up(200, 200);
mouse.reset();
UI.clickTool("rectangle");
mouse.down(200, 200);
mouse.up(100, 100);
mouse.reset();
// Last rectangle is selected by default
Keyboard.withModifierKeys({ shift: true }, () => {
mouse.click(0, 10);
mouse.click(10, 0);
});
};
describe("distributing", () => {
beforeEach(async () => {
unmountComponent();
mouse.reset();
await act(() => {
return setLanguage(defaultLang);
});
await render(<Excalidraw handleKeyboardGlobally={true} />);
});
it("should distribute selected elements horizontally", async () => {
createAndSelectThreeRectanglesWithGap();
expect(API.getSelectedElements()[0].x).toEqual(0);
expect(API.getSelectedElements()[1].x).toEqual(10);
expect(API.getSelectedElements()[2].x).toEqual(300);
API.executeAction(distributeHorizontally);
expect(API.getSelectedElements()[0].x).toEqual(0);
expect(API.getSelectedElements()[1].x).toEqual(150);
expect(API.getSelectedElements()[2].x).toEqual(300);
});
it("should distribute selected elements vertically", async () => {
createAndSelectThreeRectanglesWithGap();
expect(API.getSelectedElements()[0].y).toEqual(0);
expect(API.getSelectedElements()[1].y).toEqual(10);
expect(API.getSelectedElements()[2].y).toEqual(300);
API.executeAction(distributeVertically);
expect(API.getSelectedElements()[0].y).toEqual(0);
expect(API.getSelectedElements()[1].y).toEqual(150);
expect(API.getSelectedElements()[2].y).toEqual(300);
});
it("should distribute selected elements horizontally based on their centers", async () => {
createAndSelectThreeRectanglesWithoutGap();
expect(API.getSelectedElements()[0].x).toEqual(0);
expect(API.getSelectedElements()[1].x).toEqual(10);
expect(API.getSelectedElements()[2].x).toEqual(200);
API.executeAction(distributeHorizontally);
expect(API.getSelectedElements()[0].x).toEqual(0);
expect(API.getSelectedElements()[1].x).toEqual(50);
expect(API.getSelectedElements()[2].x).toEqual(200);
});
it("should distribute selected elements vertically with based on their centers", async () => {
createAndSelectThreeRectanglesWithoutGap();
expect(API.getSelectedElements()[0].y).toEqual(0);
expect(API.getSelectedElements()[1].y).toEqual(10);
expect(API.getSelectedElements()[2].y).toEqual(200);
API.executeAction(distributeVertically);
expect(API.getSelectedElements()[0].y).toEqual(0);
expect(API.getSelectedElements()[1].y).toEqual(50);
expect(API.getSelectedElements()[2].y).toEqual(200);
});
});

View File

@ -505,8 +505,6 @@ describe("group-related duplication", () => {
mouse.up(frame.x + frame.width + 50, frame.y + frame.height + 50);
});
// console.log(h.elements);
assertElements(h.elements, [
{ id: frame.id },
{ id: rectangle1.id, frameId: frame.id },

View File

@ -0,0 +1,153 @@
import { getEmbedLink } from "../src/embeddable";
describe("YouTube timestamp parsing", () => {
it("should parse YouTube URLs with timestamp in seconds", () => {
const testCases = [
{
url: "https://www.youtube.com/watch?v=dQw4w9WgXcQ&t=90",
expectedStart: 90,
},
{
url: "https://youtu.be/dQw4w9WgXcQ?t=120",
expectedStart: 120,
},
{
url: "https://www.youtube.com/watch?v=dQw4w9WgXcQ&start=150",
expectedStart: 150,
},
];
testCases.forEach(({ url, expectedStart }) => {
const result = getEmbedLink(url);
expect(result).toBeTruthy();
expect(result?.type).toBe("video");
if (result?.type === "video" || result?.type === "generic") {
expect(result.link).toContain(`start=${expectedStart}`);
}
});
});
it("should parse YouTube URLs with timestamp in time format", () => {
const testCases = [
{
url: "https://www.youtube.com/watch?v=dQw4w9WgXcQ&t=1m30s",
expectedStart: 90, // 1*60 + 30
},
{
url: "https://youtu.be/dQw4w9WgXcQ?t=2m45s",
expectedStart: 165, // 2*60 + 45
},
{
url: "https://www.youtube.com/watch?v=dQw4w9WgXcQ&t=1h2m3s",
expectedStart: 3723, // 1*3600 + 2*60 + 3
},
{
url: "https://www.youtube.com/watch?v=dQw4w9WgXcQ&t=45s",
expectedStart: 45,
},
{
url: "https://www.youtube.com/watch?v=dQw4w9WgXcQ&t=5m",
expectedStart: 300, // 5*60
},
{
url: "https://www.youtube.com/watch?v=dQw4w9WgXcQ&t=2h",
expectedStart: 7200, // 2*3600
},
];
testCases.forEach(({ url, expectedStart }) => {
const result = getEmbedLink(url);
expect(result).toBeTruthy();
expect(result?.type).toBe("video");
if (result?.type === "video" || result?.type === "generic") {
expect(result.link).toContain(`start=${expectedStart}`);
}
});
});
it("should handle YouTube URLs without timestamps", () => {
const testCases = [
"https://www.youtube.com/watch?v=dQw4w9WgXcQ",
"https://youtu.be/dQw4w9WgXcQ",
"https://www.youtube.com/embed/dQw4w9WgXcQ",
];
testCases.forEach((url) => {
const result = getEmbedLink(url);
expect(result).toBeTruthy();
expect(result?.type).toBe("video");
if (result?.type === "video" || result?.type === "generic") {
expect(result.link).not.toContain("start=");
}
});
});
it("should handle YouTube shorts URLs with timestamps", () => {
const url = "https://www.youtube.com/shorts/dQw4w9WgXcQ?t=30";
const result = getEmbedLink(url);
expect(result).toBeTruthy();
expect(result?.type).toBe("video");
if (result?.type === "video" || result?.type === "generic") {
expect(result.link).toContain("start=30");
}
// Shorts should have portrait aspect ratio
expect(result?.intrinsicSize).toEqual({ w: 315, h: 560 });
});
it("should handle playlist URLs with timestamps", () => {
const url =
"https://www.youtube.com/playlist?list=PLrAXtmRdnEQy1KbG5lbfgQ0-PKQY6FKYZ&t=60";
const result = getEmbedLink(url);
expect(result).toBeTruthy();
expect(result?.type).toBe("video");
if (result?.type === "video" || result?.type === "generic") {
expect(result.link).toContain("start=60");
expect(result.link).toContain("list=PLrAXtmRdnEQy1KbG5lbfgQ0-PKQY6FKYZ");
}
});
it("should handle malformed or edge case timestamps", () => {
const testCases = [
{
url: "https://www.youtube.com/watch?v=dQw4w9WgXcQ&t=abc",
expectedStart: 0, // Invalid timestamp should default to 0
},
{
url: "https://www.youtube.com/watch?v=dQw4w9WgXcQ&t=",
expectedStart: 0, // Empty timestamp should default to 0
},
{
url: "https://www.youtube.com/watch?v=dQw4w9WgXcQ&t=0",
expectedStart: 0, // Zero timestamp should be handled
},
];
testCases.forEach(({ url, expectedStart }) => {
const result = getEmbedLink(url);
expect(result).toBeTruthy();
expect(result?.type).toBe("video");
if (result?.type === "video" || result?.type === "generic") {
if (expectedStart === 0) {
expect(result.link).not.toContain("start=");
} else {
expect(result.link).toContain(`start=${expectedStart}`);
}
}
});
});
it("should preserve other URL parameters", () => {
const url =
"https://www.youtube.com/watch?v=dQw4w9WgXcQ&t=90&feature=youtu.be&list=PLtest";
const result = getEmbedLink(url);
expect(result).toBeTruthy();
expect(result?.type).toBe("video");
if (result?.type === "video" || result?.type === "generic") {
expect(result.link).toContain("start=90");
expect(result.link).toContain("enablejsapi=1");
}
});
});

View File

@ -1,6 +1,5 @@
import { pointCenter, pointFrom } from "@excalidraw/math";
import { act, queryByTestId, queryByText } from "@testing-library/react";
import React from "react";
import { vi } from "vitest";
import {
@ -33,6 +32,11 @@ import { getBoundTextElementPosition, getBoundTextMaxWidth } from "../src";
import { LinearElementEditor } from "../src";
import { newArrowElement } from "../src";
import {
getTextEditor,
TEXT_EDITOR_SELECTOR,
} from "../../excalidraw/tests/queries/dom";
import type {
ExcalidrawElement,
ExcalidrawLinearElement,
@ -132,7 +136,8 @@ describe("Test Linear Elements", () => {
Keyboard.withModifierKeys({ ctrl: true }, () => {
Keyboard.keyPress(KEYS.ENTER);
});
expect(h.state.editingLinearElement?.elementId).toEqual(line.id);
expect(h.state.selectedLinearElement?.isEditing).toBe(true);
expect(h.state.selectedLinearElement?.elementId).toEqual(line.id);
};
const drag = (startPoint: GlobalPoint, endPoint: GlobalPoint) => {
@ -249,17 +254,99 @@ describe("Test Linear Elements", () => {
});
fireEvent.click(queryByText(contextMenu as HTMLElement, "Edit line")!);
expect(h.state.editingLinearElement?.elementId).toEqual(h.elements[0].id);
expect(h.state.selectedLinearElement?.isEditing).toBe(true);
expect(h.state.selectedLinearElement?.elementId).toEqual(h.elements[0].id);
});
it("should enter line editor when using double clicked with ctrl key", () => {
it("should enter line editor via enter (line)", () => {
createTwoPointerLinearElement("line");
expect(h.state.editingLinearElement?.elementId).toBeUndefined();
expect(h.state.selectedLinearElement?.isEditing).toBe(false);
mouse.clickAt(midpoint[0], midpoint[1]);
Keyboard.keyPress(KEYS.ENTER);
expect(h.state.selectedLinearElement?.isEditing).toBe(true);
expect(h.state.selectedLinearElement?.elementId).toEqual(h.elements[0].id);
});
// ctrl+enter alias (to align with arrows)
it("should enter line editor via ctrl+enter (line)", () => {
createTwoPointerLinearElement("line");
expect(h.state.selectedLinearElement?.isEditing).toBe(false);
mouse.clickAt(midpoint[0], midpoint[1]);
Keyboard.withModifierKeys({ ctrl: true }, () => {
Keyboard.keyPress(KEYS.ENTER);
});
expect(h.state.selectedLinearElement?.isEditing).toBe(true);
expect(h.state.selectedLinearElement?.elementId).toEqual(h.elements[0].id);
});
it("should enter line editor via ctrl+enter (arrow)", () => {
createTwoPointerLinearElement("arrow");
expect(h.state.selectedLinearElement?.isEditing).toBe(false);
mouse.clickAt(midpoint[0], midpoint[1]);
Keyboard.withModifierKeys({ ctrl: true }, () => {
Keyboard.keyPress(KEYS.ENTER);
});
expect(h.state.selectedLinearElement?.isEditing).toBe(true);
expect(h.state.selectedLinearElement?.elementId).toEqual(h.elements[0].id);
});
it("should enter line editor on ctrl+dblclick (simple arrow)", () => {
createTwoPointerLinearElement("arrow");
expect(h.state.selectedLinearElement?.isEditing).toBe(false);
Keyboard.withModifierKeys({ ctrl: true }, () => {
mouse.doubleClick();
});
expect(h.state.editingLinearElement?.elementId).toEqual(h.elements[0].id);
expect(h.state.selectedLinearElement?.isEditing).toBe(true);
expect(h.state.selectedLinearElement?.elementId).toEqual(h.elements[0].id);
});
it("should enter line editor on ctrl+dblclick (line)", () => {
createTwoPointerLinearElement("line");
expect(h.state.selectedLinearElement?.isEditing).toBe(false);
Keyboard.withModifierKeys({ ctrl: true }, () => {
mouse.doubleClick();
});
expect(h.state.selectedLinearElement?.isEditing).toBe(true);
expect(h.state.selectedLinearElement?.elementId).toEqual(h.elements[0].id);
});
it("should enter line editor on dblclick (line)", () => {
createTwoPointerLinearElement("line");
expect(h.state.selectedLinearElement?.isEditing).toBe(false);
mouse.doubleClick();
expect(h.state.selectedLinearElement?.isEditing).toBe(true);
expect(h.state.selectedLinearElement?.elementId).toEqual(h.elements[0].id);
});
it("should not enter line editor on dblclick (arrow)", async () => {
createTwoPointerLinearElement("arrow");
expect(h.state.selectedLinearElement?.isEditing).toBe(false);
mouse.doubleClick();
expect(h.state.selectedLinearElement).toBe(null);
await getTextEditor();
});
it("shouldn't create text element on double click in line editor (arrow)", async () => {
createTwoPointerLinearElement("arrow");
const arrow = h.elements[0] as ExcalidrawLinearElement;
enterLineEditingMode(arrow);
expect(h.state.selectedLinearElement?.isEditing).toBe(true);
expect(h.state.selectedLinearElement?.elementId).toEqual(arrow.id);
mouse.doubleClick();
expect(h.state.selectedLinearElement?.isEditing).toBe(true);
expect(h.state.selectedLinearElement?.elementId).toEqual(arrow.id);
expect(h.elements.length).toEqual(1);
expect(document.querySelector(TEXT_EDITOR_SELECTOR)).toBe(null);
});
describe("Inside editor", () => {
@ -290,9 +377,9 @@ describe("Test Linear Elements", () => {
// drag line from midpoint
drag(midpoint, pointFrom(midpoint[0] + delta, midpoint[1] + delta));
expect(renderInteractiveScene.mock.calls.length).toMatchInlineSnapshot(
`12`,
`11`,
);
expect(renderStaticScene.mock.calls.length).toMatchInlineSnapshot(`7`);
expect(renderStaticScene.mock.calls.length).toMatchInlineSnapshot(`6`);
expect(line.points.length).toEqual(3);
expect(line.points).toMatchInlineSnapshot(`
@ -333,7 +420,7 @@ describe("Test Linear Elements", () => {
expect(renderInteractiveScene.mock.calls.length).toMatchInlineSnapshot(
`9`,
);
expect(renderStaticScene.mock.calls.length).toMatchInlineSnapshot(`7`);
expect(renderStaticScene.mock.calls.length).toMatchInlineSnapshot(`6`);
const midPointsWithRoundEdge = LinearElementEditor.getEditorMidPoints(
h.elements[0] as ExcalidrawLinearElement,
@ -346,12 +433,12 @@ describe("Test Linear Elements", () => {
expect(midPointsWithRoundEdge).toMatchInlineSnapshot(`
[
[
"55.96978",
"47.44233",
"54.27552",
"46.16120",
],
[
"76.08587",
"43.29417",
"76.95494",
"44.56052",
],
]
`);
@ -392,9 +479,9 @@ describe("Test Linear Elements", () => {
drag(startPoint, endPoint);
expect(renderInteractiveScene.mock.calls.length).toMatchInlineSnapshot(
`12`,
`11`,
);
expect(renderStaticScene.mock.calls.length).toMatchInlineSnapshot(`8`);
expect(renderStaticScene.mock.calls.length).toMatchInlineSnapshot(`7`);
expect([line.x, line.y]).toEqual([
points[0][0] + deltaX,
@ -411,12 +498,12 @@ describe("Test Linear Elements", () => {
expect(newMidPoints).toMatchInlineSnapshot(`
[
[
"105.96978",
"67.44233",
"104.27552",
"66.16120",
],
[
"126.08587",
"63.29417",
"126.95494",
"64.56052",
],
]
`);
@ -460,9 +547,9 @@ describe("Test Linear Elements", () => {
);
expect(renderInteractiveScene.mock.calls.length).toMatchInlineSnapshot(
`16`,
`14`,
);
expect(renderStaticScene.mock.calls.length).toMatchInlineSnapshot(`8`);
expect(renderStaticScene.mock.calls.length).toMatchInlineSnapshot(`7`);
expect(line.points.length).toEqual(5);
@ -511,9 +598,9 @@ describe("Test Linear Elements", () => {
drag(hitCoords, pointFrom(hitCoords[0] - delta, hitCoords[1] - delta));
expect(renderInteractiveScene.mock.calls.length).toMatchInlineSnapshot(
`12`,
`11`,
);
expect(renderStaticScene.mock.calls.length).toMatchInlineSnapshot(`7`);
expect(renderStaticScene.mock.calls.length).toMatchInlineSnapshot(`6`);
const newPoints = LinearElementEditor.getPointsGlobalCoordinates(
line,
@ -552,9 +639,9 @@ describe("Test Linear Elements", () => {
drag(hitCoords, pointFrom(hitCoords[0] + delta, hitCoords[1] + delta));
expect(renderInteractiveScene.mock.calls.length).toMatchInlineSnapshot(
`12`,
`11`,
);
expect(renderStaticScene.mock.calls.length).toMatchInlineSnapshot(`7`);
expect(renderStaticScene.mock.calls.length).toMatchInlineSnapshot(`6`);
const newPoints = LinearElementEditor.getPointsGlobalCoordinates(
line,
@ -600,9 +687,9 @@ describe("Test Linear Elements", () => {
deletePoint(points[2]);
expect(line.points.length).toEqual(3);
expect(renderInteractiveScene.mock.calls.length).toMatchInlineSnapshot(
`18`,
`17`,
);
expect(renderStaticScene.mock.calls.length).toMatchInlineSnapshot(`8`);
expect(renderStaticScene.mock.calls.length).toMatchInlineSnapshot(`7`);
const newMidPoints = LinearElementEditor.getEditorMidPoints(
line,
@ -658,9 +745,9 @@ describe("Test Linear Elements", () => {
),
);
expect(renderInteractiveScene.mock.calls.length).toMatchInlineSnapshot(
`16`,
`14`,
);
expect(renderStaticScene.mock.calls.length).toMatchInlineSnapshot(`8`);
expect(renderStaticScene.mock.calls.length).toMatchInlineSnapshot(`7`);
expect(line.points.length).toEqual(5);
expect((h.elements[0] as ExcalidrawLinearElement).points)
@ -727,12 +814,12 @@ describe("Test Linear Elements", () => {
expect(newMidPoints).toMatchInlineSnapshot(`
[
[
"31.88408",
"23.13276",
"29.28349",
"20.91105",
],
[
"77.74793",
"44.57841",
"78.86048",
"46.12277",
],
]
`);
@ -756,9 +843,9 @@ describe("Test Linear Elements", () => {
drag(hitCoords, pointFrom(hitCoords[0] + delta, hitCoords[1] + delta));
expect(renderInteractiveScene.mock.calls.length).toMatchInlineSnapshot(
`12`,
`11`,
);
expect(renderStaticScene.mock.calls.length).toMatchInlineSnapshot(`7`);
expect(renderStaticScene.mock.calls.length).toMatchInlineSnapshot(`6`);
const newPoints = LinearElementEditor.getPointsGlobalCoordinates(
line,
@ -816,12 +903,12 @@ describe("Test Linear Elements", () => {
expect(newMidPoints).toMatchInlineSnapshot(`
[
[
"55.96978",
"47.44233",
"54.27552",
"46.16120",
],
[
"76.08587",
"43.29417",
"76.95494",
"44.56052",
],
]
`);
@ -983,19 +1070,17 @@ describe("Test Linear Elements", () => {
);
expect(position).toMatchInlineSnapshot(`
{
"x": "85.82202",
"y": "75.63461",
"x": "86.17305",
"y": "76.11251",
}
`);
});
});
it("should match styles for text editor", () => {
it("should match styles for text editor", async () => {
createTwoPointerLinearElement("arrow");
Keyboard.keyPress(KEYS.ENTER);
const editor = document.querySelector(
".excalidraw-textEditorContainer > textarea",
) as HTMLTextAreaElement;
const editor = await getTextEditor();
expect(editor).toMatchSnapshot();
});
@ -1012,9 +1097,7 @@ describe("Test Linear Elements", () => {
expect(text.type).toBe("text");
expect(text.containerId).toBe(arrow.id);
mouse.down();
const editor = document.querySelector(
".excalidraw-textEditorContainer > textarea",
) as HTMLTextAreaElement;
const editor = await getTextEditor();
fireEvent.change(editor, {
target: { value: DEFAULT_TEXT },
@ -1042,9 +1125,7 @@ describe("Test Linear Elements", () => {
const textElement = h.elements[1] as ExcalidrawTextElementWithContainer;
expect(textElement.type).toBe("text");
expect(textElement.containerId).toBe(arrow.id);
const editor = document.querySelector(
".excalidraw-textEditorContainer > textarea",
) as HTMLTextAreaElement;
const editor = await getTextEditor();
fireEvent.change(editor, {
target: { value: DEFAULT_TEXT },
@ -1063,13 +1144,7 @@ describe("Test Linear Elements", () => {
expect(h.elements.length).toBe(1);
mouse.doubleClickAt(line.x, line.y);
expect(h.elements.length).toBe(2);
const text = h.elements[1] as ExcalidrawTextElementWithContainer;
expect(text.type).toBe("text");
expect(text.containerId).toBeNull();
expect(line.boundElements).toBeNull();
expect(h.elements.length).toBe(1);
});
// TODO fix #7029 and rewrite this test
@ -1234,9 +1309,7 @@ describe("Test Linear Elements", () => {
mouse.select(arrow);
Keyboard.keyPress(KEYS.ENTER);
const editor = document.querySelector(
".excalidraw-textEditorContainer > textarea",
) as HTMLTextAreaElement;
const editor = await getTextEditor();
fireEvent.change(editor, { target: { value: DEFAULT_TEXT } });
Keyboard.exitTextEditor(editor);
@ -1262,7 +1335,7 @@ describe("Test Linear Elements", () => {
mouse.downAt(rect.x, rect.y);
mouse.moveTo(200, 0);
mouse.upAt(200, 0);
expect(arrow.width).toBeCloseTo(204, 0);
expect(arrow.width).toBeCloseTo(200, 0);
expect(rect.x).toBe(200);
expect(rect.y).toBe(0);
expect(handleBindTextResizeSpy).toHaveBeenCalledWith(
@ -1411,5 +1484,55 @@ describe("Test Linear Elements", () => {
expect(line.points[line.points.length - 1][0]).toBe(20);
expect(line.points[line.points.length - 1][1]).toBe(-20);
});
it("should preserve original angle when dragging endpoint with SHIFT key", () => {
createTwoPointerLinearElement("line");
const line = h.elements[0] as ExcalidrawLinearElement;
enterLineEditingMode(line);
const elementsMap = arrayToMap(h.elements);
const points = LinearElementEditor.getPointsGlobalCoordinates(
line,
elementsMap,
);
// Calculate original angle between first and last point
const originalAngle = Math.atan2(
points[1][1] - points[0][1],
points[1][0] - points[0][0],
);
// Drag the second point (endpoint) with SHIFT key pressed
const startPoint = pointFrom<GlobalPoint>(points[1][0], points[1][1]);
const endPoint = pointFrom<GlobalPoint>(
startPoint[0] + 4,
startPoint[1] + 4,
);
// Perform drag with SHIFT key modifier
Keyboard.withModifierKeys({ shift: true }, () => {
mouse.downAt(startPoint[0], startPoint[1]);
mouse.moveTo(endPoint[0], endPoint[1]);
mouse.upAt(endPoint[0], endPoint[1]);
});
// Get updated points after drag
const updatedPoints = LinearElementEditor.getPointsGlobalCoordinates(
line,
elementsMap,
);
// Calculate new angle
const newAngle = Math.atan2(
updatedPoints[1][1] - updatedPoints[0][1],
updatedPoints[1][0] - updatedPoints[0][0],
);
// The angle should be preserved (within a small tolerance for floating point precision)
const angleDifference = Math.abs(newAngle - originalAngle);
const tolerance = 0.01; // Small tolerance for floating point precision
expect(angleDifference).toBeLessThan(tolerance);
});
});
});

View File

@ -510,12 +510,12 @@ describe("arrow element", () => {
h.state,
)[0] as ExcalidrawElbowArrowElement;
expect(arrow.startBinding?.fixedPoint?.[0]).toBeCloseTo(1);
expect(arrow.startBinding?.fixedPoint?.[0]).toBeCloseTo(1.05);
expect(arrow.startBinding?.fixedPoint?.[1]).toBeCloseTo(0.75);
UI.resize(rectangle, "se", [-200, -150]);
expect(arrow.startBinding?.fixedPoint?.[0]).toBeCloseTo(1);
expect(arrow.startBinding?.fixedPoint?.[0]).toBeCloseTo(1.05);
expect(arrow.startBinding?.fixedPoint?.[1]).toBeCloseTo(0.75);
});
@ -538,11 +538,11 @@ describe("arrow element", () => {
h.state,
)[0] as ExcalidrawElbowArrowElement;
expect(arrow.startBinding?.fixedPoint?.[0]).toBeCloseTo(1);
expect(arrow.startBinding?.fixedPoint?.[0]).toBeCloseTo(1.05);
expect(arrow.startBinding?.fixedPoint?.[1]).toBeCloseTo(0.75);
UI.resize([rectangle, arrow], "nw", [300, 350]);
expect(arrow.startBinding?.fixedPoint?.[0]).toBeCloseTo(0);
expect(arrow.startBinding?.fixedPoint?.[0]).toBeCloseTo(-0.05);
expect(arrow.startBinding?.fixedPoint?.[1]).toBeCloseTo(0.25);
});
});
@ -819,7 +819,7 @@ describe("image element", () => {
UI.resize(image, "ne", [40, 0]);
expect(arrow.width + arrow.endBinding!.gap).toBeCloseTo(31, 0);
expect(arrow.width + arrow.endBinding!.gap).toBeCloseTo(30, 0);
const imageWidth = image.width;
const scale = 20 / image.height;
@ -1033,7 +1033,7 @@ describe("multiple selection", () => {
expect(leftBoundArrow.x).toBeCloseTo(-110);
expect(leftBoundArrow.y).toBeCloseTo(50);
expect(leftBoundArrow.width).toBeCloseTo(143, 0);
expect(leftBoundArrow.width).toBeCloseTo(140, 0);
expect(leftBoundArrow.height).toBeCloseTo(7, 0);
expect(leftBoundArrow.angle).toEqual(0);
expect(leftBoundArrow.startBinding).toBeNull();

View File

@ -1,7 +1,5 @@
import { vi } from "vitest";
import * as constants from "@excalidraw/common";
import { getPerfectElementSize } from "../src/sizeHelpers";
const EPSILON_DIGITS = 3;
@ -57,13 +55,4 @@ describe("getPerfectElementSize", () => {
expect(width).toBeCloseTo(0, EPSILON_DIGITS);
expect(height).toBeCloseTo(0, EPSILON_DIGITS);
});
describe("should respond to SHIFT_LOCKING_ANGLE constant", () => {
it("should have only 2 locking angles per section if SHIFT_LOCKING_ANGLE = 45 deg (Math.PI/4)", () => {
(constants as any).SHIFT_LOCKING_ANGLE = Math.PI / 4;
const { height, width } = getPerfectElementSize("arrow", 120, 185);
expect(width).toBeCloseTo(120, EPSILON_DIGITS);
expect(height).toBeCloseTo(120, EPSILON_DIGITS);
});
});
});

View File

@ -1,13 +1,14 @@
import { getLineHeight } from "@excalidraw/common";
import { API } from "@excalidraw/excalidraw/tests/helpers/api";
import { FONT_FAMILY } from "@excalidraw/common";
import { FONT_FAMILY, TEXT_ALIGN, VERTICAL_ALIGN } from "@excalidraw/common";
import {
computeContainerDimensionForBoundText,
getContainerCoords,
getBoundTextMaxWidth,
getBoundTextMaxHeight,
computeBoundTextPosition,
} from "../src/textElement";
import { detectLineHeight, getLineHeightInPx } from "../src/textMeasurements";
@ -207,3 +208,172 @@ describe("Test getDefaultLineHeight", () => {
expect(getLineHeight(FONT_FAMILY.Cascadia)).toBe(1.2);
});
});
describe("Test computeBoundTextPosition", () => {
const createMockElementsMap = () => new Map();
// Helper function to create rectangle test case with 90-degree rotation
const createRotatedRectangleTestCase = (
textAlign: string,
verticalAlign: string,
) => {
const container = API.createElement({
type: "rectangle",
x: 100,
y: 100,
width: 200,
height: 100,
angle: (Math.PI / 2) as any, // 90 degrees
});
const boundTextElement = API.createElement({
type: "text",
width: 80,
height: 40,
text: "hello darkness my old friend",
textAlign: textAlign as any,
verticalAlign: verticalAlign as any,
containerId: container.id,
}) as ExcalidrawTextElementWithContainer;
const elementsMap = createMockElementsMap();
return { container, boundTextElement, elementsMap };
};
describe("90-degree rotation with all alignment combinations", () => {
// Test all 9 combinations of horizontal (left, center, right) and vertical (top, middle, bottom) alignment
it("should position text with LEFT + TOP alignment at 90-degree rotation", () => {
const { container, boundTextElement, elementsMap } =
createRotatedRectangleTestCase(TEXT_ALIGN.LEFT, VERTICAL_ALIGN.TOP);
const result = computeBoundTextPosition(
container,
boundTextElement,
elementsMap,
);
expect(result.x).toBeCloseTo(185, 1);
expect(result.y).toBeCloseTo(75, 1);
});
it("should position text with LEFT + MIDDLE alignment at 90-degree rotation", () => {
const { container, boundTextElement, elementsMap } =
createRotatedRectangleTestCase(TEXT_ALIGN.LEFT, VERTICAL_ALIGN.MIDDLE);
const result = computeBoundTextPosition(
container,
boundTextElement,
elementsMap,
);
expect(result.x).toBeCloseTo(160, 1);
expect(result.y).toBeCloseTo(75, 1);
});
it("should position text with LEFT + BOTTOM alignment at 90-degree rotation", () => {
const { container, boundTextElement, elementsMap } =
createRotatedRectangleTestCase(TEXT_ALIGN.LEFT, VERTICAL_ALIGN.BOTTOM);
const result = computeBoundTextPosition(
container,
boundTextElement,
elementsMap,
);
expect(result.x).toBeCloseTo(135, 1);
expect(result.y).toBeCloseTo(75, 1);
});
it("should position text with CENTER + TOP alignment at 90-degree rotation", () => {
const { container, boundTextElement, elementsMap } =
createRotatedRectangleTestCase(TEXT_ALIGN.CENTER, VERTICAL_ALIGN.TOP);
const result = computeBoundTextPosition(
container,
boundTextElement,
elementsMap,
);
expect(result.x).toBeCloseTo(185, 1);
expect(result.y).toBeCloseTo(130, 1);
});
it("should position text with CENTER + MIDDLE alignment at 90-degree rotation", () => {
const { container, boundTextElement, elementsMap } =
createRotatedRectangleTestCase(
TEXT_ALIGN.CENTER,
VERTICAL_ALIGN.MIDDLE,
);
const result = computeBoundTextPosition(
container,
boundTextElement,
elementsMap,
);
expect(result.x).toBeCloseTo(160, 1);
expect(result.y).toBeCloseTo(130, 1);
});
it("should position text with CENTER + BOTTOM alignment at 90-degree rotation", () => {
const { container, boundTextElement, elementsMap } =
createRotatedRectangleTestCase(
TEXT_ALIGN.CENTER,
VERTICAL_ALIGN.BOTTOM,
);
const result = computeBoundTextPosition(
container,
boundTextElement,
elementsMap,
);
expect(result.x).toBeCloseTo(135, 1);
expect(result.y).toBeCloseTo(130, 1);
});
it("should position text with RIGHT + TOP alignment at 90-degree rotation", () => {
const { container, boundTextElement, elementsMap } =
createRotatedRectangleTestCase(TEXT_ALIGN.RIGHT, VERTICAL_ALIGN.TOP);
const result = computeBoundTextPosition(
container,
boundTextElement,
elementsMap,
);
expect(result.x).toBeCloseTo(185, 1);
expect(result.y).toBeCloseTo(185, 1);
});
it("should position text with RIGHT + MIDDLE alignment at 90-degree rotation", () => {
const { container, boundTextElement, elementsMap } =
createRotatedRectangleTestCase(TEXT_ALIGN.RIGHT, VERTICAL_ALIGN.MIDDLE);
const result = computeBoundTextPosition(
container,
boundTextElement,
elementsMap,
);
expect(result.x).toBeCloseTo(160, 1);
expect(result.y).toBeCloseTo(185, 1);
});
it("should position text with RIGHT + BOTTOM alignment at 90-degree rotation", () => {
const { container, boundTextElement, elementsMap } =
createRotatedRectangleTestCase(TEXT_ALIGN.RIGHT, VERTICAL_ALIGN.BOTTOM);
const result = computeBoundTextPosition(
container,
boundTextElement,
elementsMap,
);
expect(result.x).toBeCloseTo(135, 1);
expect(result.y).toBeCloseTo(185, 1);
});
});
});

View File

@ -10,6 +10,8 @@ import { alignElements } from "@excalidraw/element";
import { CaptureUpdateAction } from "@excalidraw/element";
import { getSelectedElementsByGroup } from "@excalidraw/element";
import type { ExcalidrawElement } from "@excalidraw/element/types";
import type { Alignment } from "@excalidraw/element";
@ -38,7 +40,11 @@ export const alignActionsPredicate = (
) => {
const selectedElements = app.scene.getSelectedElements(appState);
return (
selectedElements.length > 1 &&
getSelectedElementsByGroup(
selectedElements,
app.scene.getNonDeletedElementsMap(),
appState as Readonly<AppState>,
).length > 1 &&
// TODO enable aligning frames when implemented properly
!selectedElements.some((el) => isFrameLikeElement(el))
);
@ -52,7 +58,12 @@ const alignSelectedElements = (
) => {
const selectedElements = app.scene.getSelectedElements(appState);
const updatedElements = alignElements(selectedElements, alignment, app.scene);
const updatedElements = alignElements(
selectedElements,
alignment,
app.scene,
appState,
);
const updatedElementsMap = arrayToMap(updatedElements);

View File

@ -69,7 +69,7 @@ export const actionChangeViewBackgroundColor = register({
: CaptureUpdateAction.EVENTUALLY,
};
},
PanelComponent: ({ elements, appState, updateData, appProps }) => {
PanelComponent: ({ elements, appState, updateData, appProps, data }) => {
// FIXME move me to src/components/mainMenu/DefaultItems.tsx
return (
<ColorPicker
@ -83,6 +83,7 @@ export const actionChangeViewBackgroundColor = register({
elements={elements}
appState={appState}
updateData={updateData}
compactMode={appState.stylesPanelMode === "compact"}
/>
);
},
@ -121,7 +122,7 @@ export const actionClearCanvas = register({
pasteDialog: appState.pasteDialog,
activeTool:
appState.activeTool.type === "image"
? { ...appState.activeTool, type: "selection" }
? { ...appState.activeTool, type: app.defaultSelectionTool }
: appState.activeTool,
},
captureUpdate: CaptureUpdateAction.IMMEDIATELY,
@ -494,13 +495,13 @@ export const actionToggleEraserTool = register({
name: "toggleEraserTool",
label: "toolBar.eraser",
trackEvent: { category: "toolbar" },
perform: (elements, appState) => {
perform: (elements, appState, _, app) => {
let activeTool: AppState["activeTool"];
if (isEraserActive(appState)) {
activeTool = updateActiveTool(appState, {
...(appState.activeTool.lastActiveTool || {
type: "selection",
type: app.defaultSelectionTool,
}),
lastActiveToolBeforeEraser: null,
});
@ -530,6 +531,9 @@ export const actionToggleLassoTool = register({
label: "toolBar.lasso",
icon: LassoIcon,
trackEvent: { category: "toolbar" },
predicate: (elements, appState, props, app) => {
return app.defaultSelectionTool !== "lasso";
},
perform: (elements, appState, _, app) => {
let activeTool: AppState["activeTool"];

View File

@ -205,16 +205,19 @@ export const actionDeleteSelected = register({
icon: TrashIcon,
trackEvent: { category: "element", action: "delete" },
perform: (elements, appState, formData, app) => {
if (appState.editingLinearElement) {
if (appState.selectedLinearElement?.isEditing) {
const {
elementId,
selectedPointsIndices,
startBindingElement,
endBindingElement,
} = appState.editingLinearElement;
} = appState.selectedLinearElement;
const elementsMap = app.scene.getNonDeletedElementsMap();
const element = LinearElementEditor.getElement(elementId, elementsMap);
if (!element) {
const linearElement = LinearElementEditor.getElement(
elementId,
elementsMap,
);
if (!linearElement) {
return false;
}
// case: no point selected → do nothing, as deleting the whole element
@ -225,10 +228,10 @@ export const actionDeleteSelected = register({
return false;
}
// case: deleting last remaining point
if (element.points.length < 2) {
// case: deleting all points
if (selectedPointsIndices.length >= linearElement.points.length) {
const nextElements = elements.map((el) => {
if (el.id === element.id) {
if (el.id === linearElement.id) {
return newElementWith(el, { isDeleted: true });
}
return el;
@ -239,7 +242,7 @@ export const actionDeleteSelected = register({
elements: nextElements,
appState: {
...nextAppState,
editingLinearElement: null,
selectedLinearElement: null,
},
captureUpdate: CaptureUpdateAction.IMMEDIATELY,
};
@ -252,20 +255,24 @@ export const actionDeleteSelected = register({
? null
: startBindingElement,
endBindingElement: selectedPointsIndices?.includes(
element.points.length - 1,
linearElement.points.length - 1,
)
? null
: endBindingElement,
};
LinearElementEditor.deletePoints(element, app, selectedPointsIndices);
LinearElementEditor.deletePoints(
linearElement,
app,
selectedPointsIndices,
);
return {
elements,
appState: {
...appState,
editingLinearElement: {
...appState.editingLinearElement,
selectedLinearElement: {
...appState.selectedLinearElement,
...binding,
selectedPointsIndices:
selectedPointsIndices?.[0] > 0
@ -291,7 +298,9 @@ export const actionDeleteSelected = register({
elements: nextElements,
appState: {
...nextAppState,
activeTool: updateActiveTool(appState, { type: "selection" }),
activeTool: updateActiveTool(appState, {
type: app.defaultSelectionTool,
}),
multiElement: null,
activeEmbeddable: null,
selectedLinearElement: null,

View File

@ -10,6 +10,8 @@ import { distributeElements } from "@excalidraw/element";
import { CaptureUpdateAction } from "@excalidraw/element";
import { getSelectedElementsByGroup } from "@excalidraw/element";
import type { ExcalidrawElement } from "@excalidraw/element/types";
import type { Distribution } from "@excalidraw/element";
@ -31,7 +33,11 @@ import type { AppClassProperties, AppState } from "../types";
const enableActionGroup = (appState: AppState, app: AppClassProperties) => {
const selectedElements = app.scene.getSelectedElements(appState);
return (
selectedElements.length > 1 &&
getSelectedElementsByGroup(
selectedElements,
app.scene.getNonDeletedElementsMap(),
appState as Readonly<AppState>,
).length > 2 &&
// TODO enable distributing frames when implemented properly
!selectedElements.some((el) => isFrameLikeElement(el))
);
@ -49,6 +55,7 @@ const distributeSelectedElements = (
selectedElements,
app.scene.getNonDeletedElementsMap(),
distribution,
appState,
);
const updatedElementsMap = arrayToMap(updatedElements);

View File

@ -39,7 +39,7 @@ export const actionDuplicateSelection = register({
}
// duplicate selected point(s) if editing a line
if (appState.editingLinearElement) {
if (appState.selectedLinearElement?.isEditing) {
// TODO: Invariants should be checked here instead of duplicateSelectedPoints()
try {
const newAppState = LinearElementEditor.duplicateSelectedPoints(

View File

@ -5,7 +5,11 @@ import {
bindOrUnbindLinearElement,
isBindingEnabled,
} from "@excalidraw/element/binding";
import { isValidPolygon, LinearElementEditor } from "@excalidraw/element";
import {
isValidPolygon,
LinearElementEditor,
newElementWith,
} from "@excalidraw/element";
import {
isBindingElement,
@ -14,7 +18,12 @@ import {
isLineElement,
} from "@excalidraw/element";
import { KEYS, arrayToMap, updateActiveTool } from "@excalidraw/common";
import {
KEYS,
arrayToMap,
tupleToCoors,
updateActiveTool,
} from "@excalidraw/common";
import { isPathALoop } from "@excalidraw/element";
import { isInvisiblySmallElement } from "@excalidraw/element";
@ -43,12 +52,16 @@ export const actionFinalize = register({
trackEvent: false,
perform: (elements, appState, data, app) => {
const { interactiveCanvas, focusContainer, scene } = app;
const { event, sceneCoords } =
(data as {
event?: PointerEvent;
sceneCoords?: { x: number; y: number };
}) ?? {};
const elementsMap = scene.getNonDeletedElementsMap();
if (data?.event && appState.selectedLinearElement) {
if (event && appState.selectedLinearElement) {
const linearElementEditor = LinearElementEditor.handlePointerUp(
data.event,
event,
appState.selectedLinearElement,
appState,
app.scene,
@ -69,7 +82,14 @@ export const actionFinalize = register({
let newElements = elements;
if (element && isInvisiblySmallElement(element)) {
// TODO: #7348 in theory this gets recorded by the store, so the invisible elements could be restored by the undo/redo, which might be not what we would want
newElements = newElements.filter((el) => el.id !== element!.id);
newElements = newElements.map((el) => {
if (el.id === element.id) {
return newElementWith(el, {
isDeleted: true,
});
}
return el;
});
}
return {
elements: newElements,
@ -85,9 +105,9 @@ export const actionFinalize = register({
}
}
if (appState.editingLinearElement) {
if (appState.selectedLinearElement?.isEditing) {
const { elementId, startBindingElement, endBindingElement } =
appState.editingLinearElement;
appState.selectedLinearElement;
const element = LinearElementEditor.getElement(elementId, elementsMap);
if (element) {
@ -108,12 +128,21 @@ export const actionFinalize = register({
return {
elements:
element.points.length < 2 || isInvisiblySmallElement(element)
? elements.filter((el) => el.id !== element.id)
? elements.map((el) => {
if (el.id === element.id) {
return newElementWith(el, { isDeleted: true });
}
return el;
})
: undefined,
appState: {
...appState,
cursorButton: "up",
editingLinearElement: null,
selectedLinearElement: new LinearElementEditor(
element,
arrayToMap(elementsMap),
false, // exit editing mode
),
},
captureUpdate: CaptureUpdateAction.IMMEDIATELY,
};
@ -122,18 +151,6 @@ export const actionFinalize = register({
let newElements = elements;
const pendingImageElement =
appState.pendingImageElementId &&
scene.getElement(appState.pendingImageElementId);
if (pendingImageElement) {
scene.mutateElement(
pendingImageElement,
{ isDeleted: true },
{ informMutation: false, isDragging: false },
);
}
if (window.document.activeElement instanceof HTMLElement) {
focusContainer();
}
@ -157,11 +174,7 @@ export const actionFinalize = register({
if (element) {
// pen and mouse have hover
if (
appState.multiElement &&
element.type !== "freedraw" &&
appState.lastPointerDownWith !== "touch"
) {
if (appState.multiElement && element.type !== "freedraw") {
const { points, lastCommittedPoint } = element;
if (
!lastCommittedPoint ||
@ -175,7 +188,12 @@ export const actionFinalize = register({
if (element && isInvisiblySmallElement(element)) {
// TODO: #7348 in theory this gets recorded by the store, so the invisible elements could be restored by the undo/redo, which might be not what we would want
newElements = newElements.filter((el) => el.id !== element!.id);
newElements = newElements.map((el) => {
if (el.id === element?.id) {
return newElementWith(el, { isDeleted: true });
}
return el;
});
}
if (isLinearElement(element) || isFreeDrawElement(element)) {
@ -216,12 +234,17 @@ export const actionFinalize = register({
element.points.length > 1 &&
isBindingEnabled(appState)
) {
const [x, y] = LinearElementEditor.getPointAtIndexGlobalCoordinates(
element,
-1,
arrayToMap(elements),
);
maybeBindLinearElement(element, appState, { x, y }, scene);
const coords =
sceneCoords ??
tupleToCoors(
LinearElementEditor.getPointAtIndexGlobalCoordinates(
element,
-1,
arrayToMap(elements),
),
);
maybeBindLinearElement(element, appState, coords, scene);
}
}
}
@ -238,13 +261,13 @@ export const actionFinalize = register({
if (appState.activeTool.type === "eraser") {
activeTool = updateActiveTool(appState, {
...(appState.activeTool.lastActiveTool || {
type: "selection",
type: app.defaultSelectionTool,
}),
lastActiveToolBeforeEraser: null,
});
} else {
activeTool = updateActiveTool(appState, {
type: "selection",
type: app.defaultSelectionTool,
});
}
@ -280,7 +303,6 @@ export const actionFinalize = register({
element && isLinearElement(element)
? new LinearElementEditor(element, arrayToMap(newElements))
: appState.selectedLinearElement,
pendingImageElementId: null,
},
// TODO: #7348 we should not capture everything, but if we don't, it leads to incosistencies -> revisit
captureUpdate: CaptureUpdateAction.IMMEDIATELY,
@ -288,7 +310,7 @@ export const actionFinalize = register({
},
keyTest: (event, appState) =>
(event.key === KEYS.ESCAPE &&
(appState.editingLinearElement !== null ||
(appState.selectedLinearElement?.isEditing ||
(!appState.newElement && appState.multiElement === null))) ||
((event.key === KEYS.ESCAPE || event.key === KEYS.ENTER) &&
appState.multiElement !== null),

View File

@ -1,12 +1,14 @@
import { LinearElementEditor } from "@excalidraw/element";
import {
isElbowArrow,
isLinearElement,
isLineElement,
} from "@excalidraw/element";
import { arrayToMap } from "@excalidraw/common";
import { arrayToMap, invariant } from "@excalidraw/common";
import { CaptureUpdateAction } from "@excalidraw/element";
import {
toggleLinePolygonState,
CaptureUpdateAction,
} from "@excalidraw/element";
import type {
ExcalidrawLinearElement,
@ -22,8 +24,6 @@ import { ButtonIcon } from "../components/ButtonIcon";
import { newElementWith } from "../../element/src/mutateElement";
import { toggleLinePolygonState } from "../../element/src/shapes";
import { register } from "./register";
export const actionToggleLinearEditor = register({
@ -45,7 +45,7 @@ export const actionToggleLinearEditor = register({
predicate: (elements, appState, _, app) => {
const selectedElements = app.scene.getSelectedElements(appState);
if (
!appState.editingLinearElement &&
!appState.selectedLinearElement?.isEditing &&
selectedElements.length === 1 &&
isLinearElement(selectedElements[0]) &&
!isElbowArrow(selectedElements[0])
@ -60,14 +60,25 @@ export const actionToggleLinearEditor = register({
includeBoundTextElement: true,
})[0] as ExcalidrawLinearElement;
const editingLinearElement =
appState.editingLinearElement?.elementId === selectedElement.id
? null
: new LinearElementEditor(selectedElement, arrayToMap(elements));
invariant(selectedElement, "No selected element found");
invariant(
appState.selectedLinearElement,
"No selected linear element found",
);
invariant(
selectedElement.id === appState.selectedLinearElement.elementId,
"Selected element ID and linear editor elementId does not match",
);
const selectedLinearElement = {
...appState.selectedLinearElement,
isEditing: !appState.selectedLinearElement.isEditing,
};
return {
appState: {
...appState,
editingLinearElement,
selectedLinearElement,
},
captureUpdate: CaptureUpdateAction.IMMEDIATELY,
};
@ -77,6 +88,10 @@ export const actionToggleLinearEditor = register({
selectedElementIds: appState.selectedElementIds,
})[0] as ExcalidrawLinearElement;
if (!selectedElement) {
return null;
}
const label = t(
selectedElement.type === "arrow"
? "labels.lineEditor.editArrow"

View File

@ -18,7 +18,6 @@ import {
arrayToMap,
getFontFamilyString,
getShortcutKey,
tupleToCoors,
getLineHeight,
isTransparent,
reduceToCommonValue,
@ -28,9 +27,7 @@ import { canBecomePolygon, getNonDeletedElements } from "@excalidraw/element";
import {
bindLinearElement,
bindPointToSnapToElementOutline,
calculateFixedPointForElbowArrowBinding,
getHoveredElementForBinding,
updateBoundElements,
} from "@excalidraw/element";
@ -55,9 +52,11 @@ import {
import { hasStrokeColor } from "@excalidraw/element";
import { updateElbowArrowPoints } from "@excalidraw/element";
import { CaptureUpdateAction } from "@excalidraw/element";
import {
updateElbowArrowPoints,
CaptureUpdateAction,
toggleLinePolygonState,
} from "@excalidraw/element";
import type { LocalPoint } from "@excalidraw/math";
@ -138,7 +137,10 @@ import {
isSomeElementSelected,
} from "../scene";
import { toggleLinePolygonState } from "../../element/src/shapes";
import {
withCaretPositionPreservation,
restoreCaretPosition,
} from "../hooks/useTextEditorFocus";
import { register } from "./register";
@ -324,9 +326,11 @@ export const actionChangeStrokeColor = register({
: CaptureUpdateAction.EVENTUALLY,
};
},
PanelComponent: ({ elements, appState, updateData, app }) => (
PanelComponent: ({ elements, appState, updateData, app, data }) => (
<>
<h3 aria-hidden="true">{t("labels.stroke")}</h3>
{appState.stylesPanelMode === "full" && (
<h3 aria-hidden="true">{t("labels.stroke")}</h3>
)}
<ColorPicker
topPicks={DEFAULT_ELEMENT_STROKE_PICKS}
palette={DEFAULT_ELEMENT_STROKE_COLOR_PALETTE}
@ -344,6 +348,7 @@ export const actionChangeStrokeColor = register({
elements={elements}
appState={appState}
updateData={updateData}
compactMode={appState.stylesPanelMode === "compact"}
/>
</>
),
@ -401,9 +406,11 @@ export const actionChangeBackgroundColor = register({
captureUpdate: CaptureUpdateAction.IMMEDIATELY,
};
},
PanelComponent: ({ elements, appState, updateData, app }) => (
PanelComponent: ({ elements, appState, updateData, app, data }) => (
<>
<h3 aria-hidden="true">{t("labels.background")}</h3>
{appState.stylesPanelMode === "full" && (
<h3 aria-hidden="true">{t("labels.background")}</h3>
)}
<ColorPicker
topPicks={DEFAULT_ELEMENT_BACKGROUND_PICKS}
palette={DEFAULT_ELEMENT_BACKGROUND_COLOR_PALETTE}
@ -421,6 +428,7 @@ export const actionChangeBackgroundColor = register({
elements={elements}
appState={appState}
updateData={updateData}
compactMode={appState.stylesPanelMode === "compact"}
/>
</>
),
@ -521,9 +529,11 @@ export const actionChangeStrokeWidth = register({
captureUpdate: CaptureUpdateAction.IMMEDIATELY,
};
},
PanelComponent: ({ elements, appState, updateData, app }) => (
PanelComponent: ({ elements, appState, updateData, app, data }) => (
<fieldset>
<legend>{t("labels.strokeWidth")}</legend>
{appState.stylesPanelMode === "full" && (
<legend>{t("labels.strokeWidth")}</legend>
)}
<div className="buttonList">
<RadioSelection
group="stroke-width"
@ -578,9 +588,11 @@ export const actionChangeSloppiness = register({
captureUpdate: CaptureUpdateAction.IMMEDIATELY,
};
},
PanelComponent: ({ elements, appState, updateData, app }) => (
PanelComponent: ({ elements, appState, updateData, app, data }) => (
<fieldset>
<legend>{t("labels.sloppiness")}</legend>
{appState.stylesPanelMode === "full" && (
<legend>{t("labels.sloppiness")}</legend>
)}
<div className="buttonList">
<RadioSelection
group="sloppiness"
@ -631,9 +643,11 @@ export const actionChangeStrokeStyle = register({
captureUpdate: CaptureUpdateAction.IMMEDIATELY,
};
},
PanelComponent: ({ elements, appState, updateData, app }) => (
PanelComponent: ({ elements, appState, updateData, app, data }) => (
<fieldset>
<legend>{t("labels.strokeStyle")}</legend>
{appState.stylesPanelMode === "full" && (
<legend>{t("labels.strokeStyle")}</legend>
)}
<div className="buttonList">
<RadioSelection
group="strokeStyle"
@ -700,7 +714,7 @@ export const actionChangeFontSize = register({
perform: (elements, appState, value, app) => {
return changeFontSize(elements, appState, app, () => value, value);
},
PanelComponent: ({ elements, appState, updateData, app }) => (
PanelComponent: ({ elements, appState, updateData, app, data }) => (
<fieldset>
<legend>{t("labels.fontSize")}</legend>
<div className="buttonList">
@ -759,7 +773,14 @@ export const actionChangeFontSize = register({
? null
: appState.currentItemFontSize || DEFAULT_FONT_SIZE,
)}
onChange={(value) => updateData(value)}
onChange={(value) => {
withCaretPositionPreservation(
() => updateData(value),
appState.stylesPanelMode === "compact",
!!appState.editingTextElement,
data?.onPreventClose,
);
}}
/>
</div>
</fieldset>
@ -1019,7 +1040,7 @@ export const actionChangeFontFamily = register({
return result;
},
PanelComponent: ({ elements, appState, app, updateData }) => {
PanelComponent: ({ elements, appState, app, updateData, data }) => {
const cachedElementsRef = useRef<ElementsMap>(new Map());
const prevSelectedFontFamilyRef = useRef<number | null>(null);
// relying on state batching as multiple `FontPicker` handlers could be called in rapid succession and we want to combine them
@ -1097,20 +1118,28 @@ export const actionChangeFontFamily = register({
return (
<fieldset>
<legend>{t("labels.fontFamily")}</legend>
{appState.stylesPanelMode === "full" && (
<legend>{t("labels.fontFamily")}</legend>
)}
<FontPicker
isOpened={appState.openPopup === "fontFamily"}
selectedFontFamily={selectedFontFamily}
hoveredFontFamily={appState.currentHoveredFontFamily}
compactMode={appState.stylesPanelMode === "compact"}
onSelect={(fontFamily) => {
setBatchedData({
openPopup: null,
currentHoveredFontFamily: null,
currentItemFontFamily: fontFamily,
});
// defensive clear so immediate close won't abuse the cached elements
cachedElementsRef.current.clear();
withCaretPositionPreservation(
() => {
setBatchedData({
openPopup: null,
currentHoveredFontFamily: null,
currentItemFontFamily: fontFamily,
});
// defensive clear so immediate close won't abuse the cached elements
cachedElementsRef.current.clear();
},
appState.stylesPanelMode === "compact",
!!appState.editingTextElement,
);
}}
onHover={(fontFamily) => {
setBatchedData({
@ -1167,25 +1196,28 @@ export const actionChangeFontFamily = register({
}
setBatchedData({
...batchedData,
openPopup: "fontFamily",
});
} else {
// close, use the cache and clear it afterwards
const data = {
openPopup: null,
const fontFamilyData = {
currentHoveredFontFamily: null,
cachedElements: new Map(cachedElementsRef.current),
resetAll: true,
} as ChangeFontFamilyData;
if (isUnmounted.current) {
// in case the component was unmounted by the parent, trigger the update directly
updateData({ ...batchedData, ...data });
} else {
setBatchedData(data);
}
setBatchedData({
...fontFamilyData,
});
cachedElementsRef.current.clear();
// Refocus text editor when font picker closes if we were editing text
if (
appState.stylesPanelMode === "compact" &&
appState.editingTextElement
) {
restoreCaretPosition(null); // Just refocus without saved position
}
}
}}
/>
@ -1228,8 +1260,9 @@ export const actionChangeTextAlign = register({
captureUpdate: CaptureUpdateAction.IMMEDIATELY,
};
},
PanelComponent: ({ elements, appState, updateData, app }) => {
PanelComponent: ({ elements, appState, updateData, app, data }) => {
const elementsMap = app.scene.getNonDeletedElementsMap();
return (
<fieldset>
<legend>{t("labels.textAlign")}</legend>
@ -1278,7 +1311,14 @@ export const actionChangeTextAlign = register({
(hasSelection) =>
hasSelection ? null : appState.currentItemTextAlign,
)}
onChange={(value) => updateData(value)}
onChange={(value) => {
withCaretPositionPreservation(
() => updateData(value),
appState.stylesPanelMode === "compact",
!!appState.editingTextElement,
data?.onPreventClose,
);
}}
/>
</div>
</fieldset>
@ -1320,7 +1360,7 @@ export const actionChangeVerticalAlign = register({
captureUpdate: CaptureUpdateAction.IMMEDIATELY,
};
},
PanelComponent: ({ elements, appState, updateData, app }) => {
PanelComponent: ({ elements, appState, updateData, app, data }) => {
return (
<fieldset>
<div className="buttonList">
@ -1370,7 +1410,14 @@ export const actionChangeVerticalAlign = register({
) !== null,
(hasSelection) => (hasSelection ? null : VERTICAL_ALIGN.MIDDLE),
)}
onChange={(value) => updateData(value)}
onChange={(value) => {
withCaretPositionPreservation(
() => updateData(value),
appState.stylesPanelMode === "compact",
!!appState.editingTextElement,
data?.onPreventClose,
);
}}
/>
</div>
</fieldset>
@ -1619,6 +1666,25 @@ export const actionChangeArrowhead = register({
},
});
export const actionChangeArrowProperties = register({
name: "changeArrowProperties",
label: "Change arrow properties",
trackEvent: false,
perform: (elements, appState, value, app) => {
// This action doesn't perform any changes directly
// It's just a container for the arrow type and arrowhead actions
return false;
},
PanelComponent: ({ elements, appState, updateData, app, renderAction }) => {
return (
<div className="selected-shape-actions">
{renderAction("changeArrowType")}
{renderAction("changeArrowhead")}
</div>
);
},
});
export const actionChangeArrowType = register({
name: "changeArrowType",
label: "Change arrow types",
@ -1661,63 +1727,16 @@ export const actionChangeArrowType = register({
-1,
elementsMap,
);
const startHoveredElement =
!newElement.startBinding &&
getHoveredElementForBinding(
tupleToCoors(startGlobalPoint),
elements,
elementsMap,
appState.zoom,
false,
true,
);
const endHoveredElement =
!newElement.endBinding &&
getHoveredElementForBinding(
tupleToCoors(endGlobalPoint),
elements,
elementsMap,
appState.zoom,
false,
true,
);
const startElement = startHoveredElement
? startHoveredElement
: newElement.startBinding &&
(elementsMap.get(
newElement.startBinding.elementId,
) as ExcalidrawBindableElement);
const endElement = endHoveredElement
? endHoveredElement
: newElement.endBinding &&
(elementsMap.get(
newElement.endBinding.elementId,
) as ExcalidrawBindableElement);
const finalStartPoint = startHoveredElement
? bindPointToSnapToElementOutline(
newElement,
startHoveredElement,
"start",
)
: startGlobalPoint;
const finalEndPoint = endHoveredElement
? bindPointToSnapToElementOutline(
newElement,
endHoveredElement,
"end",
)
: endGlobalPoint;
startHoveredElement &&
bindLinearElement(
newElement,
startHoveredElement,
"start",
app.scene,
);
endHoveredElement &&
bindLinearElement(newElement, endHoveredElement, "end", app.scene);
const startElement =
newElement.startBinding &&
(elementsMap.get(
newElement.startBinding.elementId,
) as ExcalidrawBindableElement);
const endElement =
newElement.endBinding &&
(elementsMap.get(
newElement.endBinding.elementId,
) as ExcalidrawBindableElement);
const startBinding =
startElement && newElement.startBinding
@ -1728,6 +1747,7 @@ export const actionChangeArrowType = register({
newElement,
startElement,
"start",
elementsMap,
),
}
: null;
@ -1740,6 +1760,7 @@ export const actionChangeArrowType = register({
newElement,
endElement,
"end",
elementsMap,
),
}
: null;
@ -1749,7 +1770,7 @@ export const actionChangeArrowType = register({
startBinding,
endBinding,
...updateElbowArrowPoints(newElement, elementsMap, {
points: [finalStartPoint, finalEndPoint].map(
points: [startGlobalPoint, endGlobalPoint].map(
(p): LocalPoint =>
pointFrom(p[0] - newElement.x, p[1] - newElement.y),
),

View File

@ -21,7 +21,7 @@ export const actionSelectAll = register({
trackEvent: { category: "canvas" },
viewMode: false,
perform: (elements, appState, value, app) => {
if (appState.editingLinearElement) {
if (appState.selectedLinearElement?.isEditing) {
return false;
}

View File

@ -25,6 +25,10 @@ export const actionToggleSearchMenu = register({
predicate: (appState) => appState.gridModeEnabled,
},
perform(elements, appState, _, app) {
if (appState.openDialog) {
return false;
}
if (
appState.openSidebar?.name === DEFAULT_SIDEBAR.name &&
appState.openSidebar.tab === CANVAS_SEARCH_TAB

View File

@ -18,6 +18,7 @@ export {
actionChangeFontFamily,
actionChangeTextAlign,
actionChangeVerticalAlign,
actionChangeArrowProperties,
} from "./actionProperties";
export {

View File

@ -69,6 +69,7 @@ export type ActionName =
| "changeStrokeStyle"
| "changeArrowhead"
| "changeArrowType"
| "changeArrowProperties"
| "changeOpacity"
| "changeFontSize"
| "toggleCanvasMenu"

View File

@ -10,6 +10,7 @@ import {
STATS_PANELS,
THEME,
DEFAULT_GRID_STEP,
isTestEnv,
} from "@excalidraw/common";
import type { AppState, NormalizedZoomValue } from "./types";
@ -36,7 +37,7 @@ export const getDefaultAppState = (): Omit<
currentItemRoughness: DEFAULT_ELEMENT_PROPS.roughness,
currentItemStartArrowhead: null,
currentItemStrokeColor: DEFAULT_ELEMENT_PROPS.strokeColor,
currentItemRoundness: "round",
currentItemRoundness: isTestEnv() ? "sharp" : "round",
currentItemArrowType: ARROW_TYPE.round,
currentItemStrokeStyle: DEFAULT_ELEMENT_PROPS.strokeStyle,
currentItemStrokeWidth: DEFAULT_ELEMENT_PROPS.strokeWidth,
@ -47,7 +48,6 @@ export const getDefaultAppState = (): Omit<
newElement: null,
editingTextElement: null,
editingGroupId: null,
editingLinearElement: null,
activeTool: {
type: "selection",
customType: null,
@ -108,7 +108,6 @@ export const getDefaultAppState = (): Omit<
value: 1 as NormalizedZoomValue,
},
viewModeEnabled: false,
pendingImageElementId: null,
showHyperlinkPopup: false,
selectedLinearElement: null,
snapLines: [],
@ -124,6 +123,7 @@ export const getDefaultAppState = (): Omit<
searchMatches: null,
lockedMultiSelections: {},
activeLockedId: null,
stylesPanelMode: "full",
};
};
@ -175,7 +175,6 @@ const APP_STATE_STORAGE_CONF = (<
newElement: { browser: false, export: false, server: false },
editingTextElement: { browser: false, export: false, server: false },
editingGroupId: { browser: true, export: false, server: false },
editingLinearElement: { browser: false, export: false, server: false },
activeTool: { browser: true, export: false, server: false },
penMode: { browser: true, export: false, server: false },
penDetected: { browser: true, export: false, server: false },
@ -237,7 +236,6 @@ const APP_STATE_STORAGE_CONF = (<
zenModeEnabled: { browser: true, export: false, server: false },
zoom: { browser: true, export: false, server: false },
viewModeEnabled: { browser: false, export: false, server: false },
pendingImageElementId: { browser: false, export: false, server: false },
showHyperlinkPopup: { browser: false, export: false, server: false },
selectedLinearElement: { browser: true, export: false, server: false },
snapLines: { browser: false, export: false, server: false },
@ -250,6 +248,7 @@ const APP_STATE_STORAGE_CONF = (<
searchMatches: { browser: false, export: false, server: false },
lockedMultiSelections: { browser: true, export: true, server: true },
activeLockedId: { browser: false, export: false, server: false },
stylesPanelMode: { browser: true, export: false, server: false },
});
const _clearAppStateForStorage = <

View File

@ -1,6 +1,7 @@
import {
createPasteEvent,
parseClipboard,
parseDataTransferEvent,
serializeAsClipboardJSON,
} from "./clipboard";
import { API } from "./tests/helpers/api";
@ -13,7 +14,9 @@ describe("parseClipboard()", () => {
text = "123";
clipboardData = await parseClipboard(
createPasteEvent({ types: { "text/plain": text } }),
await parseDataTransferEvent(
createPasteEvent({ types: { "text/plain": text } }),
),
);
expect(clipboardData.text).toBe(text);
@ -21,7 +24,9 @@ describe("parseClipboard()", () => {
text = "[123]";
clipboardData = await parseClipboard(
createPasteEvent({ types: { "text/plain": text } }),
await parseDataTransferEvent(
createPasteEvent({ types: { "text/plain": text } }),
),
);
expect(clipboardData.text).toBe(text);
@ -29,7 +34,9 @@ describe("parseClipboard()", () => {
text = JSON.stringify({ val: 42 });
clipboardData = await parseClipboard(
createPasteEvent({ types: { "text/plain": text } }),
await parseDataTransferEvent(
createPasteEvent({ types: { "text/plain": text } }),
),
);
expect(clipboardData.text).toBe(text);
});
@ -39,11 +46,13 @@ describe("parseClipboard()", () => {
const json = serializeAsClipboardJSON({ elements: [rect], files: null });
const clipboardData = await parseClipboard(
createPasteEvent({
types: {
"text/plain": json,
},
}),
await parseDataTransferEvent(
createPasteEvent({
types: {
"text/plain": json,
},
}),
),
);
expect(clipboardData.elements).toEqual([rect]);
});
@ -56,21 +65,25 @@ describe("parseClipboard()", () => {
// -------------------------------------------------------------------------
json = serializeAsClipboardJSON({ elements: [rect], files: null });
clipboardData = await parseClipboard(
createPasteEvent({
types: {
"text/html": json,
},
}),
await parseDataTransferEvent(
createPasteEvent({
types: {
"text/html": json,
},
}),
),
);
expect(clipboardData.elements).toEqual([rect]);
// -------------------------------------------------------------------------
json = serializeAsClipboardJSON({ elements: [rect], files: null });
clipboardData = await parseClipboard(
createPasteEvent({
types: {
"text/html": `<div> ${json}</div>`,
},
}),
await parseDataTransferEvent(
createPasteEvent({
types: {
"text/html": `<div> ${json}</div>`,
},
}),
),
);
expect(clipboardData.elements).toEqual([rect]);
// -------------------------------------------------------------------------
@ -80,11 +93,13 @@ describe("parseClipboard()", () => {
let clipboardData;
// -------------------------------------------------------------------------
clipboardData = await parseClipboard(
createPasteEvent({
types: {
"text/html": `<img src="https://example.com/image.png" />`,
},
}),
await parseDataTransferEvent(
createPasteEvent({
types: {
"text/html": `<img src="https://example.com/image.png" />`,
},
}),
),
);
expect(clipboardData.mixedContent).toEqual([
{
@ -94,11 +109,13 @@ describe("parseClipboard()", () => {
]);
// -------------------------------------------------------------------------
clipboardData = await parseClipboard(
createPasteEvent({
types: {
"text/html": `<div><img src="https://example.com/image.png" /></div><a><img src="https://example.com/image2.png" /></a>`,
},
}),
await parseDataTransferEvent(
createPasteEvent({
types: {
"text/html": `<div><img src="https://example.com/image.png" /></div><a><img src="https://example.com/image2.png" /></a>`,
},
}),
),
);
expect(clipboardData.mixedContent).toEqual([
{
@ -114,11 +131,13 @@ describe("parseClipboard()", () => {
it("should parse text content alongside <image> `src` urls out of text/html", async () => {
const clipboardData = await parseClipboard(
createPasteEvent({
types: {
"text/html": `<a href="https://example.com">hello </a><div><img src="https://example.com/image.png" /></div><b>my friend!</b>`,
},
}),
await parseDataTransferEvent(
createPasteEvent({
types: {
"text/html": `<a href="https://example.com">hello </a><div><img src="https://example.com/image.png" /></div><b>my friend!</b>`,
},
}),
),
);
expect(clipboardData.mixedContent).toEqual([
{
@ -141,14 +160,16 @@ describe("parseClipboard()", () => {
let clipboardData;
// -------------------------------------------------------------------------
clipboardData = await parseClipboard(
createPasteEvent({
types: {
"text/plain": `a b
1 2
4 5
7 10`,
},
}),
await parseDataTransferEvent(
createPasteEvent({
types: {
"text/plain": `a b
1 2
4 5
7 10`,
},
}),
),
);
expect(clipboardData.spreadsheet).toEqual({
title: "b",
@ -157,14 +178,16 @@ describe("parseClipboard()", () => {
});
// -------------------------------------------------------------------------
clipboardData = await parseClipboard(
createPasteEvent({
types: {
"text/html": `a b
1 2
4 5
7 10`,
},
}),
await parseDataTransferEvent(
createPasteEvent({
types: {
"text/html": `a b
1 2
4 5
7 10`,
},
}),
),
);
expect(clipboardData.spreadsheet).toEqual({
title: "b",
@ -173,19 +196,21 @@ describe("parseClipboard()", () => {
});
// -------------------------------------------------------------------------
clipboardData = await parseClipboard(
createPasteEvent({
types: {
"text/html": `<html>
<body>
<!--StartFragment--><google-sheets-html-origin><style type="text/css"><!--td {border: 1px solid #cccccc;}br {mso-data-placement:same-cell;}--></style><table xmlns="http://www.w3.org/1999/xhtml" cellspacing="0" cellpadding="0" dir="ltr" border="1" style="table-layout:fixed;font-size:10pt;font-family:Arial;width:0px;border-collapse:collapse;border:none"><colgroup><col width="100"/><col width="100"/></colgroup><tbody><tr style="height:21px;"><td style="overflow:hidden;padding:2px 3px 2px 3px;vertical-align:bottom;" data-sheets-value="{&quot;1&quot;:2,&quot;2&quot;:&quot;a&quot;}">a</td><td style="overflow:hidden;padding:2px 3px 2px 3px;vertical-align:bottom;" data-sheets-value="{&quot;1&quot;:2,&quot;2&quot;:&quot;b&quot;}">b</td></tr><tr style="height:21px;"><td style="overflow:hidden;padding:2px 3px 2px 3px;vertical-align:bottom;text-align:right;" data-sheets-value="{&quot;1&quot;:3,&quot;3&quot;:1}">1</td><td style="overflow:hidden;padding:2px 3px 2px 3px;vertical-align:bottom;text-align:right;" data-sheets-value="{&quot;1&quot;:3,&quot;3&quot;:2}">2</td></tr><tr style="height:21px;"><td style="overflow:hidden;padding:2px 3px 2px 3px;vertical-align:bottom;text-align:right;" data-sheets-value="{&quot;1&quot;:3,&quot;3&quot;:4}">4</td><td style="overflow:hidden;padding:2px 3px 2px 3px;vertical-align:bottom;text-align:right;" data-sheets-value="{&quot;1&quot;:3,&quot;3&quot;:5}">5</td></tr><tr style="height:21px;"><td style="overflow:hidden;padding:2px 3px 2px 3px;vertical-align:bottom;text-align:right;" data-sheets-value="{&quot;1&quot;:3,&quot;3&quot;:7}">7</td><td style="overflow:hidden;padding:2px 3px 2px 3px;vertical-align:bottom;text-align:right;" data-sheets-value="{&quot;1&quot;:3,&quot;3&quot;:10}">10</td></tr></tbody></table><!--EndFragment-->
</body>
</html>`,
"text/plain": `a b
1 2
4 5
7 10`,
},
}),
await parseDataTransferEvent(
createPasteEvent({
types: {
"text/html": `<html>
<body>
<!--StartFragment--><google-sheets-html-origin><style type="text/css"><!--td {border: 1px solid #cccccc;}br {mso-data-placement:same-cell;}--></style><table xmlns="http://www.w3.org/1999/xhtml" cellspacing="0" cellpadding="0" dir="ltr" border="1" style="table-layout:fixed;font-size:10pt;font-family:Arial;width:0px;border-collapse:collapse;border:none"><colgroup><col width="100"/><col width="100"/></colgroup><tbody><tr style="height:21px;"><td style="overflow:hidden;padding:2px 3px 2px 3px;vertical-align:bottom;" data-sheets-value="{&quot;1&quot;:2,&quot;2&quot;:&quot;a&quot;}">a</td><td style="overflow:hidden;padding:2px 3px 2px 3px;vertical-align:bottom;" data-sheets-value="{&quot;1&quot;:2,&quot;2&quot;:&quot;b&quot;}">b</td></tr><tr style="height:21px;"><td style="overflow:hidden;padding:2px 3px 2px 3px;vertical-align:bottom;text-align:right;" data-sheets-value="{&quot;1&quot;:3,&quot;3&quot;:1}">1</td><td style="overflow:hidden;padding:2px 3px 2px 3px;vertical-align:bottom;text-align:right;" data-sheets-value="{&quot;1&quot;:3,&quot;3&quot;:2}">2</td></tr><tr style="height:21px;"><td style="overflow:hidden;padding:2px 3px 2px 3px;vertical-align:bottom;text-align:right;" data-sheets-value="{&quot;1&quot;:3,&quot;3&quot;:4}">4</td><td style="overflow:hidden;padding:2px 3px 2px 3px;vertical-align:bottom;text-align:right;" data-sheets-value="{&quot;1&quot;:3,&quot;3&quot;:5}">5</td></tr><tr style="height:21px;"><td style="overflow:hidden;padding:2px 3px 2px 3px;vertical-align:bottom;text-align:right;" data-sheets-value="{&quot;1&quot;:3,&quot;3&quot;:7}">7</td><td style="overflow:hidden;padding:2px 3px 2px 3px;vertical-align:bottom;text-align:right;" data-sheets-value="{&quot;1&quot;:3,&quot;3&quot;:10}">10</td></tr></tbody></table><!--EndFragment-->
</body>
</html>`,
"text/plain": `a b
1 2
4 5
7 10`,
},
}),
),
);
expect(clipboardData.spreadsheet).toEqual({
title: "b",

View File

@ -5,6 +5,7 @@ import {
arrayToMap,
isMemberOf,
isPromiseLike,
EVENT,
} from "@excalidraw/common";
import { mutateElement } from "@excalidraw/element";
@ -16,15 +17,26 @@ import {
import { getContainingFrame } from "@excalidraw/element";
import type { ValueOf } from "@excalidraw/common/utility-types";
import type { IMAGE_MIME_TYPES, STRING_MIME_TYPES } from "@excalidraw/common";
import type {
ExcalidrawElement,
NonDeletedExcalidrawElement,
} from "@excalidraw/element/types";
import { ExcalidrawError } from "./errors";
import { createFile, isSupportedImageFileType } from "./data/blob";
import {
createFile,
getFileHandle,
isSupportedImageFileType,
normalizeFile,
} from "./data/blob";
import { tryParseSpreadsheet, VALID_SPREADSHEET } from "./charts";
import type { FileSystemHandle } from "./data/filesystem";
import type { Spreadsheet } from "./charts";
import type { BinaryFiles } from "./types";
@ -92,7 +104,7 @@ export const createPasteEvent = ({
console.warn("createPasteEvent: no types or files provided");
}
const event = new ClipboardEvent("paste", {
const event = new ClipboardEvent(EVENT.PASTE, {
clipboardData: new DataTransfer(),
});
@ -101,10 +113,11 @@ export const createPasteEvent = ({
if (typeof value !== "string") {
files = files || [];
files.push(value);
event.clipboardData?.items.add(value);
continue;
}
try {
event.clipboardData?.setData(type, value);
event.clipboardData?.items.add(value, type);
if (event.clipboardData?.getData(type) !== value) {
throw new Error(`Failed to set "${type}" as clipboardData item`);
}
@ -229,14 +242,10 @@ function parseHTMLTree(el: ChildNode) {
return result;
}
const maybeParseHTMLPaste = (
event: ClipboardEvent,
const maybeParseHTMLDataItem = (
dataItem: ParsedDataTransferItemType<typeof MIME_TYPES["html"]>,
): { type: "mixedContent"; value: PastedMixedContent } | null => {
const html = event.clipboardData?.getData(MIME_TYPES.html);
if (!html) {
return null;
}
const html = dataItem.value;
try {
const doc = new DOMParser().parseFromString(html, MIME_TYPES.html);
@ -332,18 +341,21 @@ export const readSystemClipboard = async () => {
* Parses "paste" ClipboardEvent.
*/
const parseClipboardEventTextData = async (
event: ClipboardEvent,
dataList: ParsedDataTranferList,
isPlainPaste = false,
): Promise<ParsedClipboardEventTextData> => {
try {
const mixedContent = !isPlainPaste && event && maybeParseHTMLPaste(event);
const htmlItem = dataList.findByType(MIME_TYPES.html);
const mixedContent =
!isPlainPaste && htmlItem && maybeParseHTMLDataItem(htmlItem);
if (mixedContent) {
if (mixedContent.value.every((item) => item.type === "text")) {
return {
type: "text",
value:
event.clipboardData?.getData(MIME_TYPES.text) ||
dataList.getData(MIME_TYPES.text) ??
mixedContent.value
.map((item) => item.value)
.join("\n")
@ -354,23 +366,155 @@ const parseClipboardEventTextData = async (
return mixedContent;
}
const text = event.clipboardData?.getData(MIME_TYPES.text);
return { type: "text", value: (text || "").trim() };
return {
type: "text",
value: (dataList.getData(MIME_TYPES.text) || "").trim(),
};
} catch {
return { type: "text", value: "" };
}
};
type AllowedParsedDataTransferItem =
| {
type: ValueOf<typeof IMAGE_MIME_TYPES>;
kind: "file";
file: File;
fileHandle: FileSystemHandle | null;
}
| { type: ValueOf<typeof STRING_MIME_TYPES>; kind: "string"; value: string };
type ParsedDataTransferItem =
| {
type: string;
kind: "file";
file: File;
fileHandle: FileSystemHandle | null;
}
| { type: string; kind: "string"; value: string };
type ParsedDataTransferItemType<
T extends AllowedParsedDataTransferItem["type"],
> = AllowedParsedDataTransferItem & { type: T };
export type ParsedDataTransferFile = Extract<
AllowedParsedDataTransferItem,
{ kind: "file" }
>;
type ParsedDataTranferList = ParsedDataTransferItem[] & {
/**
* Only allows filtering by known `string` data types, since `file`
* types can have multiple items of the same type (e.g. multiple image files)
* unlike `string` data transfer items.
*/
findByType: typeof findDataTransferItemType;
/**
* Only allows filtering by known `string` data types, since `file`
* types can have multiple items of the same type (e.g. multiple image files)
* unlike `string` data transfer items.
*/
getData: typeof getDataTransferItemData;
getFiles: typeof getDataTransferFiles;
};
const findDataTransferItemType = function <
T extends ValueOf<typeof STRING_MIME_TYPES>,
>(this: ParsedDataTranferList, type: T): ParsedDataTransferItemType<T> | null {
return (
this.find(
(item): item is ParsedDataTransferItemType<T> => item.type === type,
) || null
);
};
const getDataTransferItemData = function <
T extends ValueOf<typeof STRING_MIME_TYPES>,
>(
this: ParsedDataTranferList,
type: T,
):
| ParsedDataTransferItemType<ValueOf<typeof STRING_MIME_TYPES>>["value"]
| null {
const item = this.find(
(
item,
): item is ParsedDataTransferItemType<ValueOf<typeof STRING_MIME_TYPES>> =>
item.type === type,
);
return item?.value ?? null;
};
const getDataTransferFiles = function (
this: ParsedDataTranferList,
): ParsedDataTransferFile[] {
return this.filter(
(item): item is ParsedDataTransferFile => item.kind === "file",
);
};
export const parseDataTransferEvent = async (
event: ClipboardEvent | DragEvent | React.DragEvent<HTMLDivElement>,
): Promise<ParsedDataTranferList> => {
let items: DataTransferItemList | undefined = undefined;
if (isClipboardEvent(event)) {
items = event.clipboardData?.items;
} else {
const dragEvent = event;
items = dragEvent.dataTransfer?.items;
}
const dataItems = (
await Promise.all(
Array.from(items || []).map(
async (item): Promise<ParsedDataTransferItem | null> => {
if (item.kind === "file") {
const file = item.getAsFile();
if (file) {
const fileHandle = await getFileHandle(item);
return {
type: file.type,
kind: "file",
file: await normalizeFile(file),
fileHandle,
};
}
} else if (item.kind === "string") {
const { type } = item;
let value: string;
if ("clipboardData" in event && event.clipboardData) {
value = event.clipboardData?.getData(type);
} else {
value = await new Promise<string>((resolve) => {
item.getAsString((str) => resolve(str));
});
}
return { type, kind: "string", value };
}
return null;
},
),
)
).filter((data): data is ParsedDataTransferItem => data != null);
return Object.assign(dataItems, {
findByType: findDataTransferItemType,
getData: getDataTransferItemData,
getFiles: getDataTransferFiles,
});
};
/**
* Attempts to parse clipboard event.
*/
export const parseClipboard = async (
event: ClipboardEvent,
dataList: ParsedDataTranferList,
isPlainPaste = false,
): Promise<ClipboardData> => {
const parsedEventData = await parseClipboardEventTextData(
event,
dataList,
isPlainPaste,
);
@ -519,3 +663,14 @@ const copyTextViaExecCommand = (text: string | null) => {
return success;
};
export const isClipboardEvent = (
event: React.SyntheticEvent | Event,
): event is ClipboardEvent => {
/** not using instanceof ClipboardEvent due to tests (jsdom) */
return (
event.type === EVENT.PASTE ||
event.type === EVENT.COPY ||
event.type === EVENT.CUT
);
};

View File

@ -91,3 +91,120 @@
}
}
}
.compact-shape-actions {
display: flex;
flex-direction: column;
gap: 0.5rem;
max-height: calc(100vh - 200px);
overflow-y: auto;
padding: 0.5rem;
.compact-action-item {
position: relative;
display: flex;
justify-content: center;
align-items: center;
min-height: 2.5rem;
--default-button-size: 2rem;
.compact-action-button {
width: 2rem;
height: 2rem;
border: none;
border-radius: var(--border-radius-lg);
background: transparent;
color: var(--color-on-surface);
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
transition: all 0.2s ease;
svg {
width: 1rem;
height: 1rem;
flex: 0 0 auto;
}
&:hover {
background: var(--button-hover-bg, var(--island-bg-color));
border-color: var(
--button-hover-border,
var(--button-border, var(--default-border-color))
);
}
&:active {
background: var(--button-active-bg, var(--island-bg-color));
border-color: var(--button-active-border, var(--color-primary-darkest));
}
}
.compact-popover-content {
.popover-section {
margin-bottom: 1rem;
&:last-child {
margin-bottom: 0;
}
.popover-section-title {
font-size: 0.75rem;
font-weight: 600;
color: var(--color-text-secondary);
margin-bottom: 0.5rem;
text-transform: uppercase;
letter-spacing: 0.5px;
}
.buttonList {
display: flex;
flex-wrap: wrap;
gap: 0.25rem;
}
}
}
}
}
.compact-shape-actions-island {
width: fit-content;
overflow-x: hidden;
}
.compact-popover-content {
.popover-section {
margin-bottom: 1rem;
&:last-child {
margin-bottom: 0;
}
.popover-section-title {
font-size: 0.75rem;
font-weight: 600;
color: var(--color-text-secondary);
margin-bottom: 0.5rem;
text-transform: uppercase;
letter-spacing: 0.5px;
}
.buttonList {
display: flex;
flex-wrap: wrap;
gap: 0.25rem;
}
}
}
.shape-actions-theme-scope {
--button-border: transparent;
--button-bg: var(--color-surface-mid);
}
:root.theme--dark .shape-actions-theme-scope {
--button-hover-bg: #363541;
--button-bg: var(--color-surface-high);
}

View File

@ -1,5 +1,6 @@
import clsx from "clsx";
import { useState } from "react";
import * as Popover from "@radix-ui/react-popover";
import {
CLASSES,
@ -19,6 +20,7 @@ import {
isImageElement,
isLinearElement,
isTextElement,
isArrowElement,
} from "@excalidraw/element";
import { hasStrokeColor, toolIsArrow } from "@excalidraw/element";
@ -46,15 +48,20 @@ import {
hasStrokeWidth,
} from "../scene";
import { SHAPES } from "./shapes";
import { getFormValue } from "../actions/actionProperties";
import { useTextEditorFocus } from "../hooks/useTextEditorFocus";
import { getToolbarTools } from "./shapes";
import "./Actions.scss";
import { useDevice } from "./App";
import { useDevice, useExcalidrawContainer } from "./App";
import Stack from "./Stack";
import { ToolButton } from "./ToolButton";
import { Tooltip } from "./Tooltip";
import DropdownMenu from "./dropdownMenu/DropdownMenu";
import { PropertiesPopover } from "./PropertiesPopover";
import {
EmbedIcon,
extraToolsIcon,
@ -63,11 +70,29 @@ import {
laserPointerToolIcon,
MagicIcon,
LassoIcon,
sharpArrowIcon,
roundArrowIcon,
elbowArrowIcon,
TextSizeIcon,
adjustmentsIcon,
DotsHorizontalIcon,
} from "./icons";
import type { AppClassProperties, AppProps, UIAppState, Zoom } from "../types";
import type {
AppClassProperties,
AppProps,
UIAppState,
Zoom,
AppState,
} from "../types";
import type { ActionManager } from "../actions/manager";
// Common CSS class combinations
const PROPERTIES_CLASSES = clsx([
CLASSES.SHAPE_ACTIONS_THEME_SCOPE,
"properties-content",
]);
export const canChangeStrokeColor = (
appState: UIAppState,
targetElements: ExcalidrawElement[],
@ -140,7 +165,7 @@ export const SelectedShapeActions = ({
targetElements.length === 1 || isSingleElementBoundContainer;
const showLineEditorAction =
!appState.editingLinearElement &&
!appState.selectedLinearElement?.isEditing &&
targetElements.length === 1 &&
isLinearElement(targetElements[0]) &&
!isElbowArrow(targetElements[0]);
@ -280,6 +305,437 @@ export const SelectedShapeActions = ({
);
};
export const CompactShapeActions = ({
appState,
elementsMap,
renderAction,
app,
setAppState,
}: {
appState: UIAppState;
elementsMap: NonDeletedElementsMap | NonDeletedSceneElementsMap;
renderAction: ActionManager["renderAction"];
app: AppClassProperties;
setAppState: React.Component<any, AppState>["setState"];
}) => {
const targetElements = getTargetElements(elementsMap, appState);
const { saveCaretPosition, restoreCaretPosition } = useTextEditorFocus();
const { container } = useExcalidrawContainer();
const isEditingTextOrNewElement = Boolean(
appState.editingTextElement || appState.newElement,
);
const showFillIcons =
(hasBackground(appState.activeTool.type) &&
!isTransparent(appState.currentItemBackgroundColor)) ||
targetElements.some(
(element) =>
hasBackground(element.type) && !isTransparent(element.backgroundColor),
);
const showLinkIcon = targetElements.length === 1;
const showLineEditorAction =
!appState.selectedLinearElement?.isEditing &&
targetElements.length === 1 &&
isLinearElement(targetElements[0]) &&
!isElbowArrow(targetElements[0]);
const showCropEditorAction =
!appState.croppingElementId &&
targetElements.length === 1 &&
isImageElement(targetElements[0]);
const showAlignActions = alignActionsPredicate(appState, app);
let isSingleElementBoundContainer = false;
if (
targetElements.length === 2 &&
(hasBoundTextElement(targetElements[0]) ||
hasBoundTextElement(targetElements[1]))
) {
isSingleElementBoundContainer = true;
}
const isRTL = document.documentElement.getAttribute("dir") === "rtl";
return (
<div className="compact-shape-actions">
{/* Stroke Color */}
{canChangeStrokeColor(appState, targetElements) && (
<div className={clsx("compact-action-item")}>
{renderAction("changeStrokeColor")}
</div>
)}
{/* Background Color */}
{canChangeBackgroundColor(appState, targetElements) && (
<div className="compact-action-item">
{renderAction("changeBackgroundColor")}
</div>
)}
{/* Combined Properties (Fill, Stroke, Opacity) */}
{(showFillIcons ||
hasStrokeWidth(appState.activeTool.type) ||
targetElements.some((element) => hasStrokeWidth(element.type)) ||
hasStrokeStyle(appState.activeTool.type) ||
targetElements.some((element) => hasStrokeStyle(element.type)) ||
canChangeRoundness(appState.activeTool.type) ||
targetElements.some((element) => canChangeRoundness(element.type))) && (
<div className="compact-action-item">
<Popover.Root
open={appState.openPopup === "compactStrokeStyles"}
onOpenChange={(open) => {
if (open) {
setAppState({ openPopup: "compactStrokeStyles" });
} else {
setAppState({ openPopup: null });
}
}}
>
<Popover.Trigger asChild>
<button
type="button"
className="compact-action-button properties-trigger"
title={t("labels.stroke")}
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
setAppState({
openPopup:
appState.openPopup === "compactStrokeStyles"
? null
: "compactStrokeStyles",
});
}}
>
{adjustmentsIcon}
</button>
</Popover.Trigger>
{appState.openPopup === "compactStrokeStyles" && (
<PropertiesPopover
className={PROPERTIES_CLASSES}
container={container}
style={{ maxWidth: "13rem" }}
onClose={() => {}}
>
<div className="selected-shape-actions">
{showFillIcons && renderAction("changeFillStyle")}
{(hasStrokeWidth(appState.activeTool.type) ||
targetElements.some((element) =>
hasStrokeWidth(element.type),
)) &&
renderAction("changeStrokeWidth")}
{(hasStrokeStyle(appState.activeTool.type) ||
targetElements.some((element) =>
hasStrokeStyle(element.type),
)) && (
<>
{renderAction("changeStrokeStyle")}
{renderAction("changeSloppiness")}
</>
)}
{(canChangeRoundness(appState.activeTool.type) ||
targetElements.some((element) =>
canChangeRoundness(element.type),
)) &&
renderAction("changeRoundness")}
{renderAction("changeOpacity")}
</div>
</PropertiesPopover>
)}
</Popover.Root>
</div>
)}
{/* Combined Arrow Properties */}
{(toolIsArrow(appState.activeTool.type) ||
targetElements.some((element) => toolIsArrow(element.type))) && (
<div className="compact-action-item">
<Popover.Root
open={appState.openPopup === "compactArrowProperties"}
onOpenChange={(open) => {
if (open) {
setAppState({ openPopup: "compactArrowProperties" });
} else {
setAppState({ openPopup: null });
}
}}
>
<Popover.Trigger asChild>
<button
type="button"
className="compact-action-button properties-trigger"
title={t("labels.arrowtypes")}
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
setAppState({
openPopup:
appState.openPopup === "compactArrowProperties"
? null
: "compactArrowProperties",
});
}}
>
{(() => {
// Show an icon based on the current arrow type
const arrowType = getFormValue(
targetElements,
app,
(element) => {
if (isArrowElement(element)) {
return element.elbowed
? "elbow"
: element.roundness
? "round"
: "sharp";
}
return null;
},
(element) => isArrowElement(element),
(hasSelection) =>
hasSelection ? null : appState.currentItemArrowType,
);
if (arrowType === "elbow") {
return elbowArrowIcon;
}
if (arrowType === "round") {
return roundArrowIcon;
}
return sharpArrowIcon;
})()}
</button>
</Popover.Trigger>
{appState.openPopup === "compactArrowProperties" && (
<PropertiesPopover
container={container}
className="properties-content"
style={{ maxWidth: "13rem" }}
onClose={() => {}}
>
{renderAction("changeArrowProperties")}
</PropertiesPopover>
)}
</Popover.Root>
</div>
)}
{/* Linear Editor */}
{showLineEditorAction && (
<div className="compact-action-item">
{renderAction("toggleLinearEditor")}
</div>
)}
{/* Text Properties */}
{(appState.activeTool.type === "text" ||
targetElements.some(isTextElement)) && (
<>
<div className="compact-action-item">
{renderAction("changeFontFamily")}
</div>
<div className="compact-action-item">
<Popover.Root
open={appState.openPopup === "compactTextProperties"}
onOpenChange={(open) => {
if (open) {
if (appState.editingTextElement) {
saveCaretPosition();
}
setAppState({ openPopup: "compactTextProperties" });
} else {
setAppState({ openPopup: null });
if (appState.editingTextElement) {
restoreCaretPosition();
}
}
}}
>
<Popover.Trigger asChild>
<button
type="button"
className="compact-action-button properties-trigger"
title={t("labels.textAlign")}
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
if (appState.openPopup === "compactTextProperties") {
setAppState({ openPopup: null });
} else {
if (appState.editingTextElement) {
saveCaretPosition();
}
setAppState({ openPopup: "compactTextProperties" });
}
}}
>
{TextSizeIcon}
</button>
</Popover.Trigger>
{appState.openPopup === "compactTextProperties" && (
<PropertiesPopover
className={PROPERTIES_CLASSES}
container={container}
style={{ maxWidth: "13rem" }}
// Improve focus handling for text editing scenarios
preventAutoFocusOnTouch={!!appState.editingTextElement}
onClose={() => {
// Refocus text editor when popover closes with caret restoration
if (appState.editingTextElement) {
restoreCaretPosition();
}
}}
>
<div className="selected-shape-actions">
{(appState.activeTool.type === "text" ||
targetElements.some(isTextElement)) &&
renderAction("changeFontSize")}
{(appState.activeTool.type === "text" ||
suppportsHorizontalAlign(targetElements, elementsMap)) &&
renderAction("changeTextAlign")}
{shouldAllowVerticalAlign(targetElements, elementsMap) &&
renderAction("changeVerticalAlign")}
</div>
</PropertiesPopover>
)}
</Popover.Root>
</div>
</>
)}
{/* Dedicated Copy Button */}
{!isEditingTextOrNewElement && targetElements.length > 0 && (
<div className="compact-action-item">
{renderAction("duplicateSelection")}
</div>
)}
{/* Dedicated Delete Button */}
{!isEditingTextOrNewElement && targetElements.length > 0 && (
<div className="compact-action-item">
{renderAction("deleteSelectedElements")}
</div>
)}
{/* Combined Other Actions */}
{!isEditingTextOrNewElement && targetElements.length > 0 && (
<div className="compact-action-item">
<Popover.Root
open={appState.openPopup === "compactOtherProperties"}
onOpenChange={(open) => {
if (open) {
setAppState({ openPopup: "compactOtherProperties" });
} else {
setAppState({ openPopup: null });
}
}}
>
<Popover.Trigger asChild>
<button
type="button"
className="compact-action-button properties-trigger"
title={t("labels.actions")}
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
setAppState({
openPopup:
appState.openPopup === "compactOtherProperties"
? null
: "compactOtherProperties",
});
}}
>
{DotsHorizontalIcon}
</button>
</Popover.Trigger>
{appState.openPopup === "compactOtherProperties" && (
<PropertiesPopover
className={PROPERTIES_CLASSES}
container={container}
style={{
maxWidth: "12rem",
// center the popover content
justifyContent: "center",
alignItems: "center",
}}
onClose={() => {}}
>
<div className="selected-shape-actions">
<fieldset>
<legend>{t("labels.layers")}</legend>
<div className="buttonList">
{renderAction("sendToBack")}
{renderAction("sendBackward")}
{renderAction("bringForward")}
{renderAction("bringToFront")}
</div>
</fieldset>
{showAlignActions && !isSingleElementBoundContainer && (
<fieldset>
<legend>{t("labels.align")}</legend>
<div className="buttonList">
{isRTL ? (
<>
{renderAction("alignRight")}
{renderAction("alignHorizontallyCentered")}
{renderAction("alignLeft")}
</>
) : (
<>
{renderAction("alignLeft")}
{renderAction("alignHorizontallyCentered")}
{renderAction("alignRight")}
</>
)}
{targetElements.length > 2 &&
renderAction("distributeHorizontally")}
{/* breaks the row ˇˇ */}
<div style={{ flexBasis: "100%", height: 0 }} />
<div
style={{
display: "flex",
flexWrap: "wrap",
gap: ".5rem",
marginTop: "-0.5rem",
}}
>
{renderAction("alignTop")}
{renderAction("alignVerticallyCentered")}
{renderAction("alignBottom")}
{targetElements.length > 2 &&
renderAction("distributeVertically")}
</div>
</div>
</fieldset>
)}
<fieldset>
<legend>{t("labels.actions")}</legend>
<div className="buttonList">
{renderAction("group")}
{renderAction("ungroup")}
{showLinkIcon && renderAction("hyperlink")}
{showCropEditorAction && renderAction("cropEditor")}
</div>
</fieldset>
</div>
</PropertiesPopover>
)}
</Popover.Root>
</div>
)}
</div>
);
};
export const ShapesSwitcher = ({
activeTool,
appState,
@ -295,7 +751,8 @@ export const ShapesSwitcher = ({
const frameToolSelected = activeTool.type === "frame";
const laserToolSelected = activeTool.type === "laser";
const lassoToolSelected = activeTool.type === "lasso";
const lassoToolSelected =
activeTool.type === "lasso" && app.defaultSelectionTool !== "lasso";
const embeddableToolSelected = activeTool.type === "embeddable";
@ -303,64 +760,68 @@ export const ShapesSwitcher = ({
return (
<>
{SHAPES.map(({ value, icon, key, numericKey, fillable }, index) => {
if (
UIOptions.tools?.[
value as Extract<typeof value, keyof AppProps["UIOptions"]["tools"]>
] === false
) {
return null;
}
{getToolbarTools(app).map(
({ value, icon, key, numericKey, fillable }, index) => {
if (
UIOptions.tools?.[
value as Extract<
typeof value,
keyof AppProps["UIOptions"]["tools"]
>
] === false
) {
return null;
}
const label = t(`toolBar.${value}`);
const letter =
key && capitalizeString(typeof key === "string" ? key : key[0]);
const shortcut = letter
? `${letter} ${t("helpDialog.or")} ${numericKey}`
: `${numericKey}`;
const label = t(`toolBar.${value}`);
const letter =
key && capitalizeString(typeof key === "string" ? key : key[0]);
const shortcut = letter
? `${letter} ${t("helpDialog.or")} ${numericKey}`
: `${numericKey}`;
return (
<ToolButton
className={clsx("Shape", { fillable })}
key={value}
type="radio"
icon={icon}
checked={activeTool.type === value}
name="editor-current-shape"
title={`${capitalizeString(label)}${shortcut}`}
keyBindingLabel={numericKey || letter}
aria-label={capitalizeString(label)}
aria-keyshortcuts={shortcut}
data-testid={`toolbar-${value}`}
onPointerDown={({ pointerType }) => {
if (!appState.penDetected && pointerType === "pen") {
app.togglePenMode(true);
}
if (value === "selection") {
if (appState.activeTool.type === "selection") {
app.setActiveTool({ type: "lasso" });
} else {
app.setActiveTool({ type: "selection" });
return (
<ToolButton
className={clsx("Shape", { fillable })}
key={value}
type="radio"
icon={icon}
checked={activeTool.type === value}
name="editor-current-shape"
title={`${capitalizeString(label)}${shortcut}`}
keyBindingLabel={numericKey || letter}
aria-label={capitalizeString(label)}
aria-keyshortcuts={shortcut}
data-testid={`toolbar-${value}`}
onPointerDown={({ pointerType }) => {
if (!appState.penDetected && pointerType === "pen") {
app.togglePenMode(true);
}
}
}}
onChange={({ pointerType }) => {
if (appState.activeTool.type !== value) {
trackEvent("toolbar", value, "ui");
}
if (value === "image") {
app.setActiveTool({
type: value,
insertOnCanvasDirectly: pointerType !== "mouse",
});
} else {
app.setActiveTool({ type: value });
}
}}
/>
);
})}
if (value === "selection") {
if (appState.activeTool.type === "selection") {
app.setActiveTool({ type: "lasso" });
} else {
app.setActiveTool({ type: "selection" });
}
}
}}
onChange={({ pointerType }) => {
if (appState.activeTool.type !== value) {
trackEvent("toolbar", value, "ui");
}
if (value === "image") {
app.setActiveTool({
type: value,
});
} else {
app.setActiveTool({ type: value });
}
}}
/>
);
},
)}
<div className="App-toolbar__divider" />
<DropdownMenu open={isExtraToolsMenuOpen}>
@ -419,14 +880,16 @@ export const ShapesSwitcher = ({
>
{t("toolBar.laser")}
</DropdownMenu.Item>
<DropdownMenu.Item
onSelect={() => app.setActiveTool({ type: "lasso" })}
icon={LassoIcon}
data-testid="toolbar-lasso"
selected={lassoToolSelected}
>
{t("toolBar.lasso")}
</DropdownMenu.Item>
{app.defaultSelectionTool !== "lasso" && (
<DropdownMenu.Item
onSelect={() => app.setActiveTool({ type: "lasso" })}
icon={LassoIcon}
data-testid="toolbar-lasso"
selected={lassoToolSelected}
>
{t("toolBar.lasso")}
</DropdownMenu.Item>
)}
<div style={{ margin: "6px 0", fontSize: 14, fontWeight: 600 }}>
Generate
</div>
@ -506,15 +969,3 @@ export const ExitZenModeAction = ({
{t("buttons.exitZenMode")}
</button>
);
export const FinalizeAction = ({
renderAction,
className,
}: {
renderAction: ActionManager["renderAction"];
className?: string;
}) => (
<div className={`finalize-button ${className}`}>
{renderAction("finalize", { size: "small" })}
</div>
);

File diff suppressed because it is too large Load Diff

View File

@ -22,6 +22,12 @@
@include isMobile {
max-width: 11rem;
}
&.color-picker-container--no-top-picks {
display: flex;
justify-content: center;
grid-template-columns: unset;
}
}
.color-picker__top-picks {
@ -80,6 +86,16 @@
}
}
.color-picker__button-background {
display: flex;
align-items: center;
justify-content: center;
svg {
width: 100%;
height: 100%;
}
}
&.active {
.color-picker__button-outline {
position: absolute;

View File

@ -1,6 +1,6 @@
import * as Popover from "@radix-ui/react-popover";
import clsx from "clsx";
import { useRef } from "react";
import { useRef, useEffect } from "react";
import {
COLOR_OUTLINE_CONTRAST_THRESHOLD,
@ -18,7 +18,12 @@ import { useExcalidrawContainer } from "../App";
import { ButtonSeparator } from "../ButtonSeparator";
import { activeEyeDropperAtom } from "../EyeDropper";
import { PropertiesPopover } from "../PropertiesPopover";
import { slashIcon } from "../icons";
import { backgroundIcon, slashIcon, strokeIcon } from "../icons";
import {
saveCaretPosition,
restoreCaretPosition,
temporarilyDisableTextEditorBlur,
} from "../../hooks/useTextEditorFocus";
import { ColorInput } from "./ColorInput";
import { Picker } from "./Picker";
@ -67,6 +72,7 @@ interface ColorPickerProps {
palette?: ColorPaletteCustom | null;
topPicks?: ColorTuple;
updateData: (formData?: any) => void;
compactMode?: boolean;
}
const ColorPickerPopupContent = ({
@ -77,6 +83,8 @@ const ColorPickerPopupContent = ({
elements,
palette = COLOR_PALETTE,
updateData,
getOpenPopup,
appState,
}: Pick<
ColorPickerProps,
| "type"
@ -86,7 +94,10 @@ const ColorPickerPopupContent = ({
| "elements"
| "palette"
| "updateData"
>) => {
| "appState"
> & {
getOpenPopup: () => AppState["openPopup"];
}) => {
const { container } = useExcalidrawContainer();
const [, setActiveColorPickerSection] = useAtom(activeColorPickerSectionAtom);
@ -117,6 +128,8 @@ const ColorPickerPopupContent = ({
<PropertiesPopover
container={container}
style={{ maxWidth: "13rem" }}
// Improve focus handling for text editing scenarios
preventAutoFocusOnTouch={!!appState.editingTextElement}
onFocusOutside={(event) => {
// refocus due to eye dropper
focusPickerContent();
@ -131,8 +144,23 @@ const ColorPickerPopupContent = ({
}
}}
onClose={() => {
updateData({ openPopup: null });
// only clear if we're still the active popup (avoid racing with switch)
if (getOpenPopup() === type) {
updateData({ openPopup: null });
}
setActiveColorPickerSection(null);
// Refocus text editor when popover closes if we were editing text
if (appState.editingTextElement) {
setTimeout(() => {
const textEditor = document.querySelector(
".excalidraw-wysiwyg",
) as HTMLTextAreaElement;
if (textEditor) {
textEditor.focus();
}
}, 0);
}
}}
>
{palette ? (
@ -141,7 +169,17 @@ const ColorPickerPopupContent = ({
palette={palette}
color={color}
onChange={(changedColor) => {
// Save caret position before color change if editing text
const savedSelection = appState.editingTextElement
? saveCaretPosition()
: null;
onChange(changedColor);
// Restore caret position after color change if editing text
if (appState.editingTextElement && savedSelection) {
restoreCaretPosition(savedSelection);
}
}}
onEyeDropperToggle={(force) => {
setEyeDropperState((state) => {
@ -168,6 +206,7 @@ const ColorPickerPopupContent = ({
if (eyeDropperState) {
setEyeDropperState(null);
} else {
// close explicitly on Escape
updateData({ openPopup: null });
}
}}
@ -188,11 +227,32 @@ const ColorPickerTrigger = ({
label,
color,
type,
compactMode = false,
mode = "background",
onToggle,
editingTextElement,
}: {
color: string | null;
label: string;
type: ColorPickerType;
compactMode?: boolean;
mode?: "background" | "stroke";
onToggle: () => void;
editingTextElement?: boolean;
}) => {
const handleClick = (e: React.MouseEvent) => {
// use pointerdown so we run before outside-close logic
e.preventDefault();
e.stopPropagation();
// If editing text, temporarily disable the wysiwyg blur event
if (editingTextElement) {
temporarilyDisableTextEditorBlur();
}
onToggle();
};
return (
<Popover.Trigger
type="button"
@ -208,8 +268,37 @@ const ColorPickerTrigger = ({
? t("labels.showStroke")
: t("labels.showBackground")
}
data-openpopup={type}
onClick={handleClick}
>
<div className="color-picker__button-outline">{!color && slashIcon}</div>
{compactMode && color && (
<div className="color-picker__button-background">
{mode === "background" ? (
<span
style={{
color:
color && isColorDark(color, COLOR_OUTLINE_CONTRAST_THRESHOLD)
? "#fff"
: "#111",
}}
>
{backgroundIcon}
</span>
) : (
<span
style={{
color:
color && isColorDark(color, COLOR_OUTLINE_CONTRAST_THRESHOLD)
? "#fff"
: "#111",
}}
>
{strokeIcon}
</span>
)}
</div>
)}
</Popover.Trigger>
);
};
@ -224,25 +313,59 @@ export const ColorPicker = ({
topPicks,
updateData,
appState,
compactMode = false,
}: ColorPickerProps) => {
const openRef = useRef(appState.openPopup);
useEffect(() => {
openRef.current = appState.openPopup;
}, [appState.openPopup]);
return (
<div>
<div role="dialog" aria-modal="true" className="color-picker-container">
<TopPicks
activeColor={color}
onChange={onChange}
type={type}
topPicks={topPicks}
/>
<ButtonSeparator />
<div
role="dialog"
aria-modal="true"
className={clsx("color-picker-container", {
"color-picker-container--no-top-picks": compactMode,
})}
>
{!compactMode && (
<TopPicks
activeColor={color}
onChange={onChange}
type={type}
topPicks={topPicks}
/>
)}
{!compactMode && <ButtonSeparator />}
<Popover.Root
open={appState.openPopup === type}
onOpenChange={(open) => {
updateData({ openPopup: open ? type : null });
if (open) {
updateData({ openPopup: type });
}
}}
>
{/* serves as an active color indicator as well */}
<ColorPickerTrigger color={color} label={label} type={type} />
<ColorPickerTrigger
color={color}
label={label}
type={type}
compactMode={compactMode}
mode={type === "elementStroke" ? "stroke" : "background"}
editingTextElement={!!appState.editingTextElement}
onToggle={() => {
// atomic switch: if another popup is open, close it first, then open this one next tick
if (appState.openPopup === type) {
// toggle off on same trigger
updateData({ openPopup: null });
} else if (appState.openPopup) {
updateData({ openPopup: type });
} else {
// open this one
updateData({ openPopup: type });
}
}}
/>
{/* popup content */}
{appState.openPopup === type && (
<ColorPickerPopupContent
@ -253,6 +376,8 @@ export const ColorPicker = ({
elements={elements}
palette={palette}
updateData={updateData}
getOpenPopup={() => openRef.current}
appState={appState}
/>
)}
</Popover.Root>

View File

@ -108,6 +108,7 @@ $verticalBreakpoint: 861px;
display: flex;
align-items: center;
gap: 0.25rem;
overflow: hidden;
}
}

View File

@ -59,6 +59,8 @@ import { useStableCallback } from "../../hooks/useStableCallback";
import { activeConfirmDialogAtom } from "../ActiveConfirmDialog";
import { useStable } from "../../hooks/useStable";
import { Ellipsify } from "../Ellipsify";
import * as defaultItems from "./defaultCommandPaletteItems";
import "./CommandPalette.scss";
@ -503,7 +505,6 @@ function CommandPaletteInner({
if (value === "image") {
app.setActiveTool({
type: value,
insertOnCanvasDirectly: event.type === EVENT.KEYDOWN,
});
} else {
app.setActiveTool({ type: value });
@ -965,7 +966,7 @@ const CommandItem = ({
}
/>
)}
{command.label}
<Ellipsify>{command.label}</Ellipsify>
</div>
{showShortcut && command.shortcut && (
<CommandShortcutHint shortcut={command.shortcut} />

View File

@ -0,0 +1,18 @@
export const Ellipsify = ({
children,
...rest
}: { children: React.ReactNode } & React.HTMLAttributes<HTMLSpanElement>) => {
return (
<span
{...rest}
style={{
textOverflow: "ellipsis",
overflow: "hidden",
whiteSpace: "nowrap",
...rest.style,
}}
>
{children}
</span>
);
};

View File

@ -11,5 +11,10 @@
2rem + 4 * var(--default-button-size)
); // 4 gaps + 4 buttons
}
&--compact {
display: block;
grid-template-columns: none;
}
}
}

View File

@ -1,4 +1,5 @@
import * as Popover from "@radix-ui/react-popover";
import clsx from "clsx";
import React, { useCallback, useMemo } from "react";
import { FONT_FAMILY } from "@excalidraw/common";
@ -58,6 +59,7 @@ interface FontPickerProps {
onHover: (fontFamily: FontFamilyValues) => void;
onLeave: () => void;
onPopupChange: (open: boolean) => void;
compactMode?: boolean;
}
export const FontPicker = React.memo(
@ -69,6 +71,7 @@ export const FontPicker = React.memo(
onHover,
onLeave,
onPopupChange,
compactMode = false,
}: FontPickerProps) => {
const defaultFonts = useMemo(() => DEFAULT_FONTS, []);
const onSelectCallback = useCallback(
@ -81,18 +84,29 @@ export const FontPicker = React.memo(
);
return (
<div role="dialog" aria-modal="true" className="FontPicker__container">
<div className="buttonList">
<RadioSelection<FontFamilyValues | false>
type="button"
options={defaultFonts}
value={selectedFontFamily}
onClick={onSelectCallback}
/>
</div>
<ButtonSeparator />
<div
role="dialog"
aria-modal="true"
className={clsx("FontPicker__container", {
"FontPicker__container--compact": compactMode,
})}
>
{!compactMode && (
<div className="buttonList">
<RadioSelection<FontFamilyValues | false>
type="button"
options={defaultFonts}
value={selectedFontFamily}
onClick={onSelectCallback}
/>
</div>
)}
{!compactMode && <ButtonSeparator />}
<Popover.Root open={isOpened} onOpenChange={onPopupChange}>
<FontPickerTrigger selectedFontFamily={selectedFontFamily} />
<FontPickerTrigger
selectedFontFamily={selectedFontFamily}
isOpened={isOpened}
/>
{isOpened && (
<FontPickerList
selectedFontFamily={selectedFontFamily}

View File

@ -90,7 +90,8 @@ export const FontPickerList = React.memo(
onClose,
}: FontPickerListProps) => {
const { container } = useExcalidrawContainer();
const { fonts } = useApp();
const app = useApp();
const { fonts } = app;
const { showDeprecatedFonts } = useAppProps();
const [searchTerm, setSearchTerm] = useState("");
@ -187,6 +188,42 @@ export const FontPickerList = React.memo(
onLeave,
]);
// Create a wrapped onSelect function that preserves caret position
const wrappedOnSelect = useCallback(
(fontFamily: FontFamilyValues) => {
// Save caret position before font selection if editing text
let savedSelection: { start: number; end: number } | null = null;
if (app.state.editingTextElement) {
const textEditor = document.querySelector(
".excalidraw-wysiwyg",
) as HTMLTextAreaElement;
if (textEditor) {
savedSelection = {
start: textEditor.selectionStart,
end: textEditor.selectionEnd,
};
}
}
onSelect(fontFamily);
// Restore caret position after font selection if editing text
if (app.state.editingTextElement && savedSelection) {
setTimeout(() => {
const textEditor = document.querySelector(
".excalidraw-wysiwyg",
) as HTMLTextAreaElement;
if (textEditor && savedSelection) {
textEditor.focus();
textEditor.selectionStart = savedSelection.start;
textEditor.selectionEnd = savedSelection.end;
}
}, 0);
}
},
[onSelect, app.state.editingTextElement],
);
const onKeyDown = useCallback<KeyboardEventHandler<HTMLDivElement>>(
(event) => {
const handled = fontPickerKeyHandler({
@ -194,7 +231,7 @@ export const FontPickerList = React.memo(
inputRef,
hoveredFont,
filteredFonts,
onSelect,
onSelect: wrappedOnSelect,
onHover,
onClose,
});
@ -204,7 +241,7 @@ export const FontPickerList = React.memo(
event.stopPropagation();
}
},
[hoveredFont, filteredFonts, onSelect, onHover, onClose],
[hoveredFont, filteredFonts, wrappedOnSelect, onHover, onClose],
);
useEffect(() => {
@ -240,7 +277,7 @@ export const FontPickerList = React.memo(
// allow to tab between search and selected font
tabIndex={font.value === selectedFontFamily ? 0 : -1}
onClick={(e) => {
onSelect(Number(e.currentTarget.value));
wrappedOnSelect(Number(e.currentTarget.value));
}}
onMouseMove={() => {
if (hoveredFont?.value !== font.value) {
@ -282,9 +319,24 @@ export const FontPickerList = React.memo(
className="properties-content"
container={container}
style={{ width: "15rem" }}
onClose={onClose}
onClose={() => {
onClose();
// Refocus text editor when font picker closes if we were editing text
if (app.state.editingTextElement) {
setTimeout(() => {
const textEditor = document.querySelector(
".excalidraw-wysiwyg",
) as HTMLTextAreaElement;
if (textEditor) {
textEditor.focus();
}
}, 0);
}
}}
onPointerLeave={onLeave}
onKeyDown={onKeyDown}
preventAutoFocusOnTouch={!!app.state.editingTextElement}
>
<QuickSearch
ref={inputRef}

View File

@ -1,5 +1,4 @@
import * as Popover from "@radix-ui/react-popover";
import { useMemo } from "react";
import type { FontFamilyValues } from "@excalidraw/element/types";
@ -7,33 +6,38 @@ import { t } from "../../i18n";
import { ButtonIcon } from "../ButtonIcon";
import { TextIcon } from "../icons";
import { isDefaultFont } from "./FontPicker";
import { useExcalidrawSetAppState } from "../App";
interface FontPickerTriggerProps {
selectedFontFamily: FontFamilyValues | null;
isOpened?: boolean;
}
export const FontPickerTrigger = ({
selectedFontFamily,
isOpened = false,
}: FontPickerTriggerProps) => {
const isTriggerActive = useMemo(
() => Boolean(selectedFontFamily && !isDefaultFont(selectedFontFamily)),
[selectedFontFamily],
);
const setAppState = useExcalidrawSetAppState();
return (
<Popover.Trigger asChild>
{/* Empty div as trigger so it's stretched 100% due to different button sizes */}
<div>
<div data-openpopup="fontFamily" className="properties-trigger">
<ButtonIcon
standalone
icon={TextIcon}
title={t("labels.showFonts")}
className="properties-trigger"
testId={"font-family-show-fonts"}
active={isTriggerActive}
// no-op
onClick={() => {}}
active={isOpened}
onClick={() => {
setAppState((appState) => ({
openPopup:
appState.openPopup === "fontFamily" ? null : appState.openPopup,
}));
}}
style={{
border: "none",
}}
/>
</div>
</Popover.Trigger>

View File

@ -4,6 +4,7 @@ import {
isFlowchartNodeElement,
isImageElement,
isLinearElement,
isLineElement,
isTextBindableContainer,
isTextElement,
} from "@excalidraw/element";
@ -73,10 +74,6 @@ const getHints = ({
return t("hints.embeddable");
}
if (appState.activeTool.type === "image" && appState.pendingImageElementId) {
return t("hints.placeImage");
}
const selectedElements = app.scene.getSelectedElements(appState);
if (
@ -118,7 +115,7 @@ const getHints = ({
appState.selectionElement &&
!selectedElements.length &&
!appState.editingTextElement &&
!appState.editingLinearElement
!appState.selectedLinearElement?.isEditing
) {
return [t("hints.deepBoxSelect")];
}
@ -133,12 +130,14 @@ const getHints = ({
if (selectedElements.length === 1) {
if (isLinearElement(selectedElements[0])) {
if (appState.editingLinearElement) {
return appState.editingLinearElement.selectedPointsIndices
if (appState.selectedLinearElement?.isEditing) {
return appState.selectedLinearElement.selectedPointsIndices
? t("hints.lineEditor_pointSelected")
: t("hints.lineEditor_nothingSelected");
}
return t("hints.lineEditor_info");
return isLineElement(selectedElements[0])
? t("hints.lineEditor_line_info")
: t("hints.lineEditor_info");
}
if (
!appState.newElement &&

View File

@ -7,6 +7,7 @@ export const InlineIcon = ({ icon }: { icon: React.ReactNode }) => {
display: "inline-block",
lineHeight: 0,
verticalAlign: "middle",
flex: "0 0 auto",
}}
>
{icon}

View File

@ -24,6 +24,10 @@
gap: 0.75rem;
pointer-events: none !important;
&--compact {
gap: 0.5rem;
}
& > * {
pointer-events: var(--ui-pointerEvents);
}

View File

@ -4,6 +4,7 @@ import React from "react";
import {
CLASSES,
DEFAULT_SIDEBAR,
MQ_MIN_WIDTH_DESKTOP,
TOOL_TYPE,
arrayToMap,
capitalizeString,
@ -28,7 +29,11 @@ import { useAtom, useAtomValue } from "../editor-jotai";
import { t } from "../i18n";
import { calculateScrollCenter } from "../scene";
import { SelectedShapeActions, ShapesSwitcher } from "./Actions";
import {
SelectedShapeActions,
ShapesSwitcher,
CompactShapeActions,
} from "./Actions";
import { LoadingMessage } from "./LoadingMessage";
import { LockButton } from "./LockButton";
import { MobileMenu } from "./MobileMenu";
@ -157,6 +162,25 @@ const LayerUI = ({
const device = useDevice();
const tunnels = useInitializeTunnels();
const spacing =
appState.stylesPanelMode === "compact"
? {
menuTopGap: 4,
toolbarColGap: 4,
toolbarRowGap: 1,
toolbarInnerRowGap: 0.5,
islandPadding: 1,
collabMarginLeft: 8,
}
: {
menuTopGap: 6,
toolbarColGap: 4,
toolbarRowGap: 1,
toolbarInnerRowGap: 1,
islandPadding: 1,
collabMarginLeft: 8,
};
const TunnelsJotaiProvider = tunnels.tunnelsJotai.Provider;
const [eyeDropperState, setEyeDropperState] = useAtom(activeEyeDropperAtom);
@ -209,31 +233,55 @@ const LayerUI = ({
</div>
);
const renderSelectedShapeActions = () => (
<Section
heading="selectedShapeActions"
className={clsx("selected-shape-actions zen-mode-transition", {
"transition-left": appState.zenModeEnabled,
})}
>
<Island
className={CLASSES.SHAPE_ACTIONS_MENU}
padding={2}
style={{
// we want to make sure this doesn't overflow so subtracting the
// approximate height of hamburgerMenu + footer
maxHeight: `${appState.height - 166}px`,
}}
const renderSelectedShapeActions = () => {
const isCompactMode = appState.stylesPanelMode === "compact";
return (
<Section
heading="selectedShapeActions"
className={clsx("selected-shape-actions zen-mode-transition", {
"transition-left": appState.zenModeEnabled,
})}
>
<SelectedShapeActions
appState={appState}
elementsMap={app.scene.getNonDeletedElementsMap()}
renderAction={actionManager.renderAction}
app={app}
/>
</Island>
</Section>
);
{isCompactMode ? (
<Island
className={clsx("compact-shape-actions-island")}
padding={0}
style={{
// we want to make sure this doesn't overflow so subtracting the
// approximate height of hamburgerMenu + footer
maxHeight: `${appState.height - 166}px`,
}}
>
<CompactShapeActions
appState={appState}
elementsMap={app.scene.getNonDeletedElementsMap()}
renderAction={actionManager.renderAction}
app={app}
setAppState={setAppState}
/>
</Island>
) : (
<Island
className={CLASSES.SHAPE_ACTIONS_MENU}
padding={2}
style={{
// we want to make sure this doesn't overflow so subtracting the
// approximate height of hamburgerMenu + footer
maxHeight: `${appState.height - 166}px`,
}}
>
<SelectedShapeActions
appState={appState}
elementsMap={app.scene.getNonDeletedElementsMap()}
renderAction={actionManager.renderAction}
app={app}
/>
</Island>
)}
</Section>
);
};
const renderFixedSideContainer = () => {
const shouldRenderSelectedShapeActions = showSelectedShapeActions(
@ -250,9 +298,19 @@ const LayerUI = ({
return (
<FixedSideContainer side="top">
<div className="App-menu App-menu_top">
<Stack.Col gap={6} className={clsx("App-menu_top__left")}>
<Stack.Col
gap={spacing.menuTopGap}
className={clsx("App-menu_top__left")}
>
{renderCanvasActions()}
{shouldRenderSelectedShapeActions && renderSelectedShapeActions()}
<div
className={clsx("selected-shape-actions-container", {
"selected-shape-actions-container--compact":
appState.stylesPanelMode === "compact",
})}
>
{shouldRenderSelectedShapeActions && renderSelectedShapeActions()}
</div>
</Stack.Col>
{!appState.viewModeEnabled &&
appState.openDialog?.name !== "elementLinkSelector" && (
@ -262,17 +320,19 @@ const LayerUI = ({
{renderWelcomeScreen && (
<tunnels.WelcomeScreenToolbarHintTunnel.Out />
)}
<Stack.Col gap={4} align="start">
<Stack.Col gap={spacing.toolbarColGap} align="start">
<Stack.Row
gap={1}
gap={spacing.toolbarRowGap}
className={clsx("App-toolbar-container", {
"zen-mode": appState.zenModeEnabled,
})}
>
<Island
padding={1}
padding={spacing.islandPadding}
className={clsx("App-toolbar", {
"zen-mode": appState.zenModeEnabled,
"App-toolbar--compact":
appState.stylesPanelMode === "compact",
})}
>
<HintViewer
@ -282,7 +342,7 @@ const LayerUI = ({
app={app}
/>
{heading}
<Stack.Row gap={1}>
<Stack.Row gap={spacing.toolbarInnerRowGap}>
<PenModeButton
zenModeEnabled={appState.zenModeEnabled}
checked={appState.penMode}
@ -316,7 +376,7 @@ const LayerUI = ({
{isCollaborating && (
<Island
style={{
marginLeft: 8,
marginLeft: spacing.collabMarginLeft,
alignSelf: "center",
height: "fit-content",
}}
@ -344,6 +404,8 @@ const LayerUI = ({
"layer-ui__wrapper__top-right zen-mode-transition",
{
"transition-right": appState.zenModeEnabled,
"layer-ui__wrapper__top-right--compact":
appState.stylesPanelMode === "compact",
},
)}
>
@ -418,7 +480,9 @@ const LayerUI = ({
}}
tab={DEFAULT_SIDEBAR.defaultTab}
>
{t("toolBar.library")}
{appState.stylesPanelMode === "full" &&
appState.width >= MQ_MIN_WIDTH_DESKTOP &&
t("toolBar.library")}
</DefaultSidebar.Trigger>
<DefaultOverwriteConfirmDialog />
{appState.openDialog?.name === "ttd" && <TTDDialog __fallback />}

View File

@ -17,6 +17,7 @@ interface PropertiesPopoverProps {
onPointerLeave?: React.PointerEventHandler<HTMLDivElement>;
onFocusOutside?: Popover.PopoverContentProps["onFocusOutside"];
onPointerDownOutside?: Popover.PopoverContentProps["onPointerDownOutside"];
preventAutoFocusOnTouch?: boolean;
}
export const PropertiesPopover = React.forwardRef<
@ -34,6 +35,7 @@ export const PropertiesPopover = React.forwardRef<
onFocusOutside,
onPointerLeave,
onPointerDownOutside,
preventAutoFocusOnTouch = false,
},
ref,
) => {
@ -64,6 +66,12 @@ export const PropertiesPopover = React.forwardRef<
onKeyDown={onKeyDown}
onFocusOutside={onFocusOutside}
onPointerDownOutside={onPointerDownOutside}
onOpenAutoFocus={(e) => {
// prevent auto-focus on touch devices to avoid keyboard popup
if (preventAutoFocusOnTouch && device.isTouchScreen) {
e.preventDefault();
}
}}
onCloseAutoFocus={(e) => {
e.stopPropagation();
// prevents focusing the trigger

View File

@ -297,6 +297,10 @@ export const SearchMenu = () => {
event.preventDefault();
event.stopPropagation();
if (app.state.openDialog) {
return;
}
if (!searchInputRef.current?.matches(":focus")) {
if (app.state.openDialog) {
setAppState({

View File

@ -7,6 +7,9 @@ import {
} from "@excalidraw/element";
import { resizeSingleElement } from "@excalidraw/element";
import { isImageElement } from "@excalidraw/element";
import { isFrameLikeElement } from "@excalidraw/element";
import { getElementsInResizingFrame } from "@excalidraw/element";
import { replaceAllElementsInFrame } from "@excalidraw/element";
import type { ExcalidrawElement } from "@excalidraw/element/types";
@ -15,7 +18,10 @@ import type { Scene } from "@excalidraw/element";
import DragInput from "./DragInput";
import { getStepSizedValue, isPropertyEditable } from "./utils";
import type { DragInputCallbackType } from "./DragInput";
import type {
DragFinishedCallbackType,
DragInputCallbackType,
} from "./DragInput";
import type { AppState } from "../../types";
interface DimensionDragInputProps {
@ -43,6 +49,8 @@ const handleDimensionChange: DragInputCallbackType<
originalAppState,
instantChange,
scene,
app,
setAppState,
}) => {
const elementsMap = scene.getNonDeletedElementsMap();
const origElement = originalElements[0];
@ -153,6 +161,7 @@ const handleDimensionChange: DragInputCallbackType<
return;
}
// User types in a value to stats then presses Enter
if (nextValue !== undefined) {
const nextWidth = Math.max(
property === "width"
@ -184,52 +193,123 @@ const handleDimensionChange: DragInputCallbackType<
},
);
// Handle frame membership update for resized frames
if (isFrameLikeElement(latestElement)) {
const nextElementsInFrame = getElementsInResizingFrame(
scene.getElementsIncludingDeleted(),
latestElement,
originalAppState,
scene.getNonDeletedElementsMap(),
);
const updatedElements = replaceAllElementsInFrame(
scene.getElementsIncludingDeleted(),
nextElementsInFrame,
latestElement,
app,
);
scene.replaceAllElements(updatedElements);
}
return;
}
const changeInWidth = property === "width" ? accumulatedChange : 0;
const changeInHeight = property === "height" ? accumulatedChange : 0;
let nextWidth = Math.max(0, origElement.width + changeInWidth);
if (property === "width") {
if (shouldChangeByStepSize) {
nextWidth = getStepSizedValue(nextWidth, STEP_SIZE);
} else {
nextWidth = Math.round(nextWidth);
}
}
// Stats slider is dragged
{
const changeInWidth = property === "width" ? accumulatedChange : 0;
const changeInHeight = property === "height" ? accumulatedChange : 0;
let nextHeight = Math.max(0, origElement.height + changeInHeight);
if (property === "height") {
if (shouldChangeByStepSize) {
nextHeight = getStepSizedValue(nextHeight, STEP_SIZE);
} else {
nextHeight = Math.round(nextHeight);
}
}
if (keepAspectRatio) {
let nextWidth = Math.max(0, origElement.width + changeInWidth);
if (property === "width") {
nextHeight = Math.round((nextWidth / aspectRatio) * 100) / 100;
} else {
nextWidth = Math.round(nextHeight * aspectRatio * 100) / 100;
if (shouldChangeByStepSize) {
nextWidth = getStepSizedValue(nextWidth, STEP_SIZE);
} else {
nextWidth = Math.round(nextWidth);
}
}
let nextHeight = Math.max(0, origElement.height + changeInHeight);
if (property === "height") {
if (shouldChangeByStepSize) {
nextHeight = getStepSizedValue(nextHeight, STEP_SIZE);
} else {
nextHeight = Math.round(nextHeight);
}
}
if (keepAspectRatio) {
if (property === "width") {
nextHeight = Math.round((nextWidth / aspectRatio) * 100) / 100;
} else {
nextWidth = Math.round(nextHeight * aspectRatio * 100) / 100;
}
}
nextHeight = Math.max(MIN_WIDTH_OR_HEIGHT, nextHeight);
nextWidth = Math.max(MIN_WIDTH_OR_HEIGHT, nextWidth);
resizeSingleElement(
nextWidth,
nextHeight,
latestElement,
origElement,
originalElementsMap,
scene,
property === "width" ? "e" : "s",
{
shouldMaintainAspectRatio: keepAspectRatio,
},
);
// Handle highlighting frame element candidates
if (isFrameLikeElement(latestElement)) {
const nextElementsInFrame = getElementsInResizingFrame(
scene.getElementsIncludingDeleted(),
latestElement,
originalAppState,
scene.getNonDeletedElementsMap(),
);
setAppState({
elementsToHighlight: nextElementsInFrame,
});
}
}
}
};
nextHeight = Math.max(MIN_WIDTH_OR_HEIGHT, nextHeight);
nextWidth = Math.max(MIN_WIDTH_OR_HEIGHT, nextWidth);
const handleDragFinished: DragFinishedCallbackType = ({
setAppState,
app,
originalElements,
originalAppState,
}) => {
const elementsMap = app.scene.getNonDeletedElementsMap();
const origElement = originalElements?.[0];
const latestElement = origElement && elementsMap.get(origElement.id);
resizeSingleElement(
nextWidth,
nextHeight,
// Handle frame membership update for resized frames
if (latestElement && isFrameLikeElement(latestElement)) {
const nextElementsInFrame = getElementsInResizingFrame(
app.scene.getElementsIncludingDeleted(),
latestElement,
origElement,
originalElementsMap,
scene,
property === "width" ? "e" : "s",
{
shouldMaintainAspectRatio: keepAspectRatio,
},
originalAppState,
app.scene.getNonDeletedElementsMap(),
);
const updatedElements = replaceAllElementsInFrame(
app.scene.getElementsIncludingDeleted(),
nextElementsInFrame,
latestElement,
app,
);
app.scene.replaceAllElements(updatedElements);
setAppState({
elementsToHighlight: null,
});
}
};
@ -269,6 +349,7 @@ const DimensionDragInput = ({
scene={scene}
appState={appState}
property={property}
dragFinishedCallback={handleDragFinished}
/>
);
};

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