Compare commits

...

55 Commits

Author SHA1 Message Date
dwelle 77edb39a7e seed 2025-06-10 23:52:11 +02:00
dwelle 56c9ea9259 stuff 2025-06-10 23:30:37 +02:00
dwelle 0d34177761 liquid glass 2025-06-10 23:10:10 +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
David Luzar fc3e062074 feat: do not break polygon on point delete inside line editor (#9580)
* feat: do not break polygon on point delete inside line editor

* fix: polygon point highlighting when selected point == 0
2025-05-26 16:51:47 +02:00
zsviczian 87c87a9fb1 feat: line polygons (#9477)
* Loop Lock/Unlock

* fixed condition. 4 line points are required for the action to be available

* extracted updateLoopLock to improve readability. Removed unnecessary SVG attributes

* lint + added loopLock to restore.ts

* added  loopLock to newElement, updated test snapshots

* lint

* dislocate enpoint when breaking the loop.

* change icon & turn into a state style button

* POC: auto-transform to polygon on bg set

* keep polygon icon constant

* do not split points on de-polygonizing & highlight overlapping points

* rewrite color picker to support no (mixed) colors & fix focus handling

* refactor

* tweak point rendering inside line editor

* do not disable polygon when creating new points via alt

* auto-enable polygon when aligning start/end points

* TBD: remove bg color when disabling polygon

* TBD: only show polygon button for enabled polygons

* fix polygon behavior when adding/removing/moving points within line editor

* convert to polygon when creating line

* labels tweak

* add to command palette

* loopLock -> polygon

* restore `polygon` state on type conversions

* update snapshots

* naming

* break polygon on restore/finalize if invalid & prevent creation

* snapshots

* fix: merge issue and forgotten debug

* snaps

* do not merge points for 3-point lines

---------

Co-authored-by: dwelle <5153846+dwelle@users.noreply.github.com>
2025-05-26 11:14:55 +02:00
Márk Tolmács 4dc205537c feat: Call actionFinalize at the end of arrow creation and drag (#9453)
* First iter

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

* Restore binding

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

* More actionFinalize

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

* Additional fixes

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

* New elbow arrow is removed if  too small

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

* Remove very small arrows

* Still allow loops

* Restore tests

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

* Update history snapshot

* More history snapshot updates

* keep invisible 2-point lines/freedraw elements

---------

Signed-off-by: Mark Tolmacs <mark@lazycat.hu>
Co-authored-by: dwelle <5153846+dwelle@users.noreply.github.com>
2025-05-25 22:28:24 +02:00
David Luzar cc571c4681 chore: init CLAUDE.md (#9563)
* chore: init CLAUDE.md

* Add Copilot instructions

* update gitignore

* simplify

---------

Co-authored-by: Mark Tolmacs <mark@lazycat.hu>
2025-05-25 21:23:40 +02:00
Marcel Mraz 14d512f321 Fix import.meta.env.MODE being undefined in host apps 2025-05-22 15:25:48 +02:00
Marcel Mraz 41c036e1a5 chore: Add DeepWiki badge (#9559) 2025-05-22 13:05:56 +02:00
Márk Tolmács 91d36e9b81 fix: Linear to elbow conversion crash (#9556)
* Fix linear to elbow conversion

* Add invariant check

* Add dev invariant fix

* Add arrowhead
2025-05-22 12:34:15 +02:00
Kamil Wąż 27522110df fix: fix keybindings for arrowheads (#9557) 2025-05-22 09:47:41 +02:00
Ryan Di 712f267519 feat: better unlock (#9546)
* change lock label

* feat: add unlock logic for single units on pointer up

* feat: add unlock popup

* fix: linting errors

* style: padding tweaks

* style: remove redundant line

* feat: lock multiple units together

* style: tweak color & position

* feat: add highlight for locked elements

* feat: select groups correctly after unlocking

* test: update snapshots

* fix: lasso from selecting locked elements

* fix: should prevent selecting unlocked elements and setting locked id at the same time

* fix: reset hit locked id immediately when appropriate

* feat: capture locked units in delta

* test: update locking test

* feat: show lock highlight when locking (including undo/redo)

* feat: make locked highlighting consistent

* feat: show correct cursor type when moving over locked elements

* fix history

* remove `lockedUnits.singleUnits`

* tweak button

* do not render UnlockPopup if not locked element selected

* tweak actions

* refactor: simplify type

* refactor: rename type

* refactor: simplify hit element setting & checking

* fix: prefer locked over link

* rename to `activeLockedId`

* refactor: getElementAtPosition takes an optional hitelments array

* fix: avoid setting active locked id after resizing

---------

Co-authored-by: dwelle <5153846+dwelle@users.noreply.github.com>
2025-05-21 21:57:12 +10:00
Márk Tolmács 41a7613dff fix: Elbow arrow conversion labels mixed up (#9547) 2025-05-19 20:35:48 +02:00
David Luzar 95d89a751a refactor: decouple radio button selection from .buttonList wrapper (#9528)
* refactor: decouple radio button selection from `.buttonList`

* fix
2025-05-15 13:22:26 +02:00
Marcel Mraz 6b5fb30d69 fix: unify line height across default fonts (#9513) 2025-05-14 16:02:01 +02:00
Marcel Mraz d92a849038 fix: issues when importing package outside of browser (#9525) 2025-05-14 16:01:43 +02:00
David Luzar 0a534f1bc6 fix: never show snap lines when lasso tool active (#9523) 2025-05-14 22:04:40 +10:00
Ryan Di 4ca5f53b1f fix: alt + ctrl lasso selected elements not always kept (#9522)
* fix: alt + ctrl lasso selected elements not always kept

* Update packages/excalidraw/components/App.tsx

---------

Co-authored-by: David Luzar <5153846+dwelle@users.noreply.github.com>
2025-05-14 22:04:03 +10:00
zsviczian f7dcc893ea feat: transparent link background, scale link icon when zooming to below 100% (#9520)
* Do not set link background color, dynamically scale down link icon size with zoom.

* removed unnecessary change

* use canvas bg color & reduce size and stroke width

---------

Co-authored-by: dwelle <5153846+dwelle@users.noreply.github.com>
2025-05-14 13:38:18 +02:00
zsviczian 4dfb8a3f8e feat: allow forms.microsoft.com domain for embeddables (#9519)
* Update embeddable.ts

* no need for same origin

* The form does not load without allow same origin

* automatically add embed=true to link if not present

* fix link check
2025-05-13 19:48:26 +02:00
David Luzar 298812e1d0 fix: improve ctrl+alt lasso selecting (#9514) 2025-05-12 18:09:37 +02:00
Ryan Di 35bb449a4b fix: update cached segments when visible area changes (#9512) 2025-05-12 15:55:36 +02:00
David Luzar c4c064982f feat: show empty active color if no common color (#9506) 2025-05-11 15:07:57 +02:00
David Luzar 51dbd4831b refactor: make element type conversion more generic (#9504)
* feat: add `reduceToCommonValue()` & improve opacity slider

* feat: generalize and simplify type conversion cache

* refactor: change cache from atoms to Map

* feat: always attempt to reuse original fontSize when converting generic types
2025-05-10 20:06:16 +02:00
Marcel Mraz 7e41026812 refactor: export everything from @excalidraw/element, don't import from subpaths (#9466)
* Don't import from subpaths

* Fix tests, move related tests to element
2025-05-09 23:01:33 +02:00
shindi-renuo a8ebe514da Replace tongue emoji with globe emoji (#9489) 2025-05-09 16:59:06 +00:00
Ryan Di a30e1b25c6 feat: include frame names in canvas searches (#9484)
* fix frame name clipping on zooming

* include assistant font

* default frame name

* extend search to frame names

* add a simple test

* collpase search match items

* id check out of loop

* fix frame name check

* include focusedId for small perf improvement

* optionally show and hide collapse icon

* update section title

* fix tests

* rename `serverSide` -> `private`

* revert: do not reset zoom on zoom change

* feat: do not close menu on repeated ctrl+f

* remove collapsible

* tweak results CSS

* remove redundant check

* set `appState.searchMatches` to null if empty

---------

Co-authored-by: dwelle <5153846+dwelle@users.noreply.github.com>
2025-05-09 18:32:16 +02:00
David Luzar ff2ed5d26a refactor: change movePoints pointUpdates type (#9499) 2025-05-08 16:47:13 +02:00
Narek Malkhasyan e058a08b33 fix: use rimraf instead of rm -rf (#9460) 2025-05-07 14:13:27 +02:00
Narek Malkhasyan a306a909a0 fix: don't scroll page when TTDDialog is opened (#9455) 2025-05-07 13:33:18 +02:00
Marcel Mraz 3dc54a724a feat: add onIncrement API (#9450) 2025-05-06 19:23:02 +02:00
David Luzar a7c61319dd fix: do not translate bound elements twice (#9486) 2025-05-06 13:09:00 +02:00
Narek Malkhasyan cec5232a7a fix: when resizing element, update bound elements after final size of element is determined (#9475) 2025-05-05 12:15:42 +02:00
Márk Tolmács d4f70e9f31 feat: Quarter snap points for diamonds (#9387) 2025-05-05 11:34:40 +02:00
Márk Tolmács e19fd1332a feat: Precise highlights for bindings (#9472) 2025-05-05 09:51:20 +02:00
Hazem Krimi 6e655cdb24 fix: When moving a frame through the stats inputs or drags move along its children (#9433)
Co-authored-by: Mark Tolmacs <mark@lazycat.hu>
2025-05-02 17:07:17 +02:00
Gowtham Selvaraj 192c4e7658 docs: added shape cycling shortcut in helper dialog (#9465)
* docs: added shape cycling shortcut in helper dialog

- Document Tab and Shift+Tab usage for shape cycling

* docs: added shape cycling shortcut in helper dialog

* Update packages/excalidraw/components/HelpDialog.tsx

* Update packages/excalidraw/locales/en.json

---------

Co-authored-by: David Luzar <5153846+dwelle@users.noreply.github.com>
2025-05-01 12:12:45 +02:00
Ryan Di 195a743874 feat: switch between basic shapes (#9270)
* feat: switch between basic shapes

* add tab for testing

* style tweaks

* only show hint when a new node is created

* fix panel state

* refactor

* combine captures into one

* keep original font size

* switch multi

* switch different types altogether

* use tab only

* fix font size atom

* do not switch from active tool change

* prefer generic when mixed

* provide an optional direction when shape switching

* adjust panel bg & shadow

* redraw to correctly position text

* remove redundant code

* only tab to switch if focusing on app container

* limit which linear elements can be switched

* add shape switch to command palette

* remove hint

* cache initial panel position

* bend line to elbow if needed

* remove debug logic

* clean switch of arrows using app state

* safe conversion between line, sharp, curved, and elbow

* cache linear when panel shows up

* type safe element conversion

* rename type

* respect initial type when switching between linears

* fix elbow segment indexing

* use latest linear

* merge converted elbow points if too close

* focus on panel after click

* set roudness to null to fix drag points offset for elbows

* remove Mutable

* add arrowBoundToElement check

* make it dependent on one signle state

* unmount when not showing

* simpler types, tidy up code

* can change linear when it's linear + non-generic

* fix popup component lifecycle

* move constant to CLASSES

* DRY out type detection

* file & variable renaming

* refactor

* throw in not-prod instead

* simplify

* semi-fix bindings on `generic` type conversion

---------

Co-authored-by: dwelle <5153846+dwelle@users.noreply.github.com>
2025-04-30 18:07:31 +02:00
David Luzar 4a60fe3d22 fix: remove noreferrer on internal links (#9452)
* fix: remove `noreferrer` on internal links

* fix snaps

* fix lint
2025-04-29 18:45:17 +02:00
Narek Malkhasyan 2a0d15799c fix: when dragging arrow endpoint, update binding only on the dragged side (#9367) 2025-04-25 10:46:58 +02:00
CharitSinghChauhan a18b139a60 fix: laser pointer trail disappearing on pointerup (#9413) (#9427)
* Fix laser pointer trail disappearing on pointerup (#9413)

Previously, the laser pointer trail would disappear as soon as the pointerup event was triggered. This fix delays the trail removal to ensure it persists for a smoother visual experience.

Fixes #9413.

* Remove extra blank lines

Minor formatting cleanup. No functional changes.
2025-04-24 10:05:08 +10:00
255 changed files with 30079 additions and 24488 deletions
+2
View File
@@ -1,3 +1,5 @@
MODE="development"
VITE_APP_BACKEND_V2_GET_URL=https://json-dev.excalidraw.com/api/v2/ VITE_APP_BACKEND_V2_GET_URL=https://json-dev.excalidraw.com/api/v2/
VITE_APP_BACKEND_V2_POST_URL=https://json-dev.excalidraw.com/api/v2/post/ VITE_APP_BACKEND_V2_POST_URL=https://json-dev.excalidraw.com/api/v2/post/
+2
View File
@@ -1,3 +1,5 @@
MODE="production"
VITE_APP_BACKEND_V2_GET_URL=https://json.excalidraw.com/api/v2/ VITE_APP_BACKEND_V2_GET_URL=https://json.excalidraw.com/api/v2/
VITE_APP_BACKEND_V2_POST_URL=https://json.excalidraw.com/api/v2/post/ VITE_APP_BACKEND_V2_POST_URL=https://json.excalidraw.com/api/v2/post/
+6
View File
@@ -32,6 +32,12 @@
"name": "jotai", "name": "jotai",
"message": "Do not import from \"jotai\" directly. Use our app-specific modules (\"editor-jotai\" or \"app-jotai\")." "message": "Do not import from \"jotai\" directly. Use our app-specific modules (\"editor-jotai\" or \"app-jotai\")."
} }
],
"react/jsx-no-target-blank": [
"error",
{
"allowReferrer": true
}
] ]
} }
} }
+45
View File
@@ -0,0 +1,45 @@
# Project coding standards
## Generic Communication Guidelines
- Be succint and be aware that expansive generative AI answers are costly and slow
- Avoid providing explanations, trying to teach unless asked for, your chat partner is an expert
- Stop apologising if corrected, just provide the correct information or code
- Prefer code unless asked for explanation
- Stop summarizing what you've changed after modifications unless asked for
## TypeScript Guidelines
- Use TypeScript for all new code
- Where possible, prefer implementations without allocation
- When there is an option, opt for more performant solutions and trade RAM usage for less CPU cycles
- Prefer immutable data (const, readonly)
- Use optional chaining (?.) and nullish coalescing (??) operators
## React Guidelines
- Use functional components with hooks
- Follow the React hooks rules (no conditional hooks)
- Keep components small and focused
- Use CSS modules for component styling
## Naming Conventions
- Use PascalCase for component names, interfaces, and type aliases
- Use camelCase for variables, functions, and methods
- Use ALL_CAPS for constants
## Error Handling
- Use try/catch blocks for async operations
- Implement proper error boundaries in React components
- Always log errors with contextual information
## Testing
- Always attempt to fix #problems
- Always offer to run `yarn test:app` in the project root after modifications are complete and attempt fixing the issues reported
## Types
- Always include `packages/math/src/types.ts` in the context when your write math related code and always use the Point type instead of { x, y}
+2 -1
View File
@@ -25,4 +25,5 @@ packages/excalidraw/types
coverage coverage
dev-dist dev-dist
html html
meta*.json meta*.json
.claude
+34
View File
@@ -0,0 +1,34 @@
# CLAUDE.md
## Project Structure
Excalidraw is a **monorepo** with a clear separation between the core library and the application:
- **`packages/excalidraw/`** - Main React component library published to npm as `@excalidraw/excalidraw`
- **`excalidraw-app/`** - Full-featured web application (excalidraw.com) that uses the library
- **`packages/`** - Core packages: `@excalidraw/common`, `@excalidraw/element`, `@excalidraw/math`, `@excalidraw/utils`
- **`examples/`** - Integration examples (NextJS, browser script)
## Development Workflow
1. **Package Development**: Work in `packages/*` for editor features
2. **App Development**: Work in `excalidraw-app/` for app-specific features
3. **Testing**: Always run `yarn test:update` before committing
4. **Type Safety**: Use `yarn test:typecheck` to verify TypeScript
## Development Commands
```bash
yarn test:typecheck # TypeScript type checking
yarn test:update # Run all tests (with snapshot updates)
yarn fix # Auto-fix formatting and linting issues
```
## Architecture Notes
### Package System
- Uses Yarn workspaces for monorepo management
- Internal packages use path aliases (see `vitest.config.mts`)
- Build system uses esbuild for packages, Vite for the app
- TypeScript throughout with strict configuration
+4 -1
View File
@@ -34,6 +34,9 @@
<a href="https://discord.gg/UexuTaE"> <a href="https://discord.gg/UexuTaE">
<img alt="Chat on Discord" src="https://img.shields.io/discord/723672430744174682?color=738ad6&label=Chat%20on%20Discord&logo=discord&logoColor=ffffff&widge=false"/> <img alt="Chat on Discord" src="https://img.shields.io/discord/723672430744174682?color=738ad6&label=Chat%20on%20Discord&logo=discord&logoColor=ffffff&widge=false"/>
</a> </a>
<a href="https://deepwiki.com/excalidraw/excalidraw">
<img alt="Ask DeepWiki" src="https://deepwiki.com/badge.svg" />
</a>
<a href="https://twitter.com/excalidraw"> <a href="https://twitter.com/excalidraw">
<img alt="Follow Excalidraw on Twitter" src="https://img.shields.io/twitter/follow/excalidraw.svg?label=follow+@excalidraw&style=social&logo=twitter"/> <img alt="Follow Excalidraw on Twitter" src="https://img.shields.io/twitter/follow/excalidraw.svg?label=follow+@excalidraw&style=social&logo=twitter"/>
</a> </a>
@@ -63,7 +66,7 @@ The Excalidraw editor (npm package) supports:
- 🏗️&nbsp;Customizable. - 🏗️&nbsp;Customizable.
- 📷&nbsp;Image support. - 📷&nbsp;Image support.
- 😀&nbsp;Shape libraries support. - 😀&nbsp;Shape libraries support.
- 👅&nbsp;Localization (i18n) support. - 🌐&nbsp;Localization (i18n) support.
- 🖼️&nbsp;Export to PNG, SVG & clipboard. - 🖼️&nbsp;Export to PNG, SVG & clipboard.
- 💾&nbsp;Open format - export drawings as an `.excalidraw` json file. - 💾&nbsp;Open format - export drawings as an `.excalidraw` json file.
- ⚒️&nbsp;Wide range of tools - rectangle, circle, diamond, arrow, line, free-draw, eraser... - ⚒️&nbsp;Wide range of tools - rectangle, circle, diamond, arrow, line, free-draw, eraser...
@@ -363,13 +363,7 @@ This API has the below signature. It sets the `tool` passed in param as the acti
```ts ```ts
( (
tool: ( tool: (
| ( | { type: ToolType }
| { type: Exclude<ToolType, "image"> }
| {
type: Extract<ToolType, "image">;
insertOnCanvasDirectly?: boolean;
}
)
| { type: "custom"; customType: string } | { type: "custom"; customType: string }
) & { locked?: boolean }, ) & { 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 | | 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 | | `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 ## setCursor
@@ -38,6 +38,8 @@ If you want to only import `Excalidraw` component you can do :point_down:
```jsx showLineNumbers ```jsx showLineNumbers
import dynamic from "next/dynamic"; import dynamic from "next/dynamic";
import "@excalidraw/excalidraw/index.css";
const Excalidraw = dynamic( const Excalidraw = dynamic(
async () => (await import("@excalidraw/excalidraw")).Excalidraw, async () => (await import("@excalidraw/excalidraw")).Excalidraw,
{ {
@@ -52,7 +52,7 @@
transform: none; transform: none;
} }
.excalidraw .panelColumn { .excalidraw .selected-shape-actions {
text-align: left; text-align: left;
} }
+274 -3
View File
@@ -4,6 +4,7 @@ import {
TTDDialogTrigger, TTDDialogTrigger,
CaptureUpdateAction, CaptureUpdateAction,
reconcileElements, reconcileElements,
convertToExcalidrawElements,
} from "@excalidraw/excalidraw"; } from "@excalidraw/excalidraw";
import { trackEvent } from "@excalidraw/excalidraw/analytics"; import { trackEvent } from "@excalidraw/excalidraw/analytics";
import { getDefaultAppState } from "@excalidraw/excalidraw/appState"; import { getDefaultAppState } from "@excalidraw/excalidraw/appState";
@@ -47,10 +48,10 @@ import {
share, share,
youtubeIcon, youtubeIcon,
} from "@excalidraw/excalidraw/components/icons"; } from "@excalidraw/excalidraw/components/icons";
import { isElementLink } from "@excalidraw/element/elementLink"; import { isElementLink } from "@excalidraw/element";
import { restore, restoreAppState } from "@excalidraw/excalidraw/data/restore"; import { restore, restoreAppState } from "@excalidraw/excalidraw/data/restore";
import { newElementWith } from "@excalidraw/element/mutateElement"; import { newElementWith } from "@excalidraw/element";
import { isInitializedImageElement } from "@excalidraw/element/typeChecks"; import { isInitializedImageElement } from "@excalidraw/element";
import clsx from "clsx"; import clsx from "clsx";
import { import {
parseLibraryTokensFromUrl, parseLibraryTokensFromUrl,
@@ -471,6 +472,276 @@ const ExcalidrawWrapper = () => {
initializeScene({ collabAPI, excalidrawAPI }).then(async (data) => { initializeScene({ collabAPI, excalidrawAPI }).then(async (data) => {
loadImages(data, /* isInitialLoad */ true); loadImages(data, /* isInitialLoad */ true);
if (data.scene) {
data.scene.elements = data.scene.elements?.length
? data.scene.elements
: convertToExcalidrawElements([
{
id: "MyWwgGwbjyZoxKSa2pfBI",
type: "rectangle",
x: 43.5,
y: 52.5,
width: 357,
height: 280,
strokeColor: "transparent",
backgroundColor: "#ff8787",
fillStyle: "solid",
strokeWidth: 2,
strokeStyle: "solid",
roughness: 1,
opacity: 100,
groupIds: [],
frameId: null,
roundness: { type: 3 },
seed: 246249428,
version: 427,
versionNonce: 2079096276,
boundElements: [],
link: null,
locked: false,
},
{
id: "ABor4pClpJJs6adQb4iqs",
type: "rectangle",
x: 357.5,
y: 481.5,
width: 357,
height: 280,
strokeColor: "transparent",
backgroundColor: "#b2f2bb",
fillStyle: "solid",
strokeWidth: 2,
strokeStyle: "solid",
roughness: 1,
opacity: 100,
groupIds: [],
frameId: null,
roundness: { type: 3 },
seed: 1800887636,
version: 445,
versionNonce: 650486612,
boundElements: [],
link: null,
locked: false,
},
{
id: "-FGknlZxdFGYZNoUr2lG5",
type: "rectangle",
x: 1280.5,
y: 47.5,
width: 357,
height: 280,
strokeColor: "transparent",
backgroundColor: "#69db7c",
fillStyle: "solid",
strokeWidth: 2,
strokeStyle: "solid",
roughness: 1,
opacity: 100,
groupIds: [],
frameId: null,
roundness: { type: 3 },
seed: 883462868,
version: 532,
versionNonce: 912136404,
boundElements: [],
link: null,
locked: false,
},
{
id: "XQ3bRtQnS_ydtq13c_p99",
type: "rectangle",
x: 863.5,
y: 334.5,
width: 357,
height: 280,
strokeColor: "transparent",
backgroundColor: "#a5d8ff",
fillStyle: "solid",
strokeWidth: 2,
strokeStyle: "solid",
roughness: 1,
opacity: 100,
groupIds: [],
frameId: null,
roundness: { type: 3 },
seed: 2033653844,
version: 459,
versionNonce: 1984683604,
boundElements: [],
link: null,
locked: false,
},
{
id: "GmC3zgvVq3xUd2Qn0cNX3",
type: "rectangle",
x: 897.5,
y: 697.5,
width: 357,
height: 280,
strokeColor: "transparent",
backgroundColor: "#ffec99",
fillStyle: "solid",
strokeWidth: 2,
strokeStyle: "solid",
roughness: 1,
opacity: 100,
groupIds: [],
frameId: null,
roundness: { type: 3 },
seed: 81325524,
version: 488,
versionNonce: 1332016084,
boundElements: [],
link: null,
locked: false,
},
{
id: "8nvmvwbmFmNAcLAmwZryR",
type: "rectangle",
x: 456.5,
y: 229.5,
width: 357,
height: 280,
strokeColor: "transparent",
backgroundColor: "#eebefa",
fillStyle: "solid",
strokeWidth: 2,
strokeStyle: "solid",
roughness: 1,
opacity: 100,
groupIds: [],
frameId: null,
roundness: { type: 3 },
seed: 950276948,
version: 649,
versionNonce: 532633940,
boundElements: [],
link: null,
locked: false,
},
{
id: "7dyXEvhPDLA0-e3XCaM2H",
type: "rectangle",
x: 1446.5,
y: 609.5,
width: 357,
height: 280,
strokeColor: "transparent",
backgroundColor: "#d0bfff",
fillStyle: "solid",
strokeWidth: 2,
strokeStyle: "solid",
roughness: 1,
opacity: 100,
groupIds: [],
frameId: null,
roundness: { type: 3 },
seed: 1659799764,
version: 570,
versionNonce: 313593556,
boundElements: [],
link: null,
locked: false,
},
{
id: "gJubXeAjkEAr6yywtiZ0T",
type: "rectangle",
x: 1182.5,
y: 800.5,
width: 357,
height: 280,
strokeColor: "transparent",
backgroundColor: "#96f2d7",
fillStyle: "solid",
strokeWidth: 2,
strokeStyle: "solid",
roughness: 1,
opacity: 100,
groupIds: [],
frameId: null,
roundness: { type: 3 },
seed: 391007828,
version: 563,
versionNonce: 2026902612,
boundElements: [],
link: null,
locked: false,
},
{
id: "pSB2Izj9osR_XxVCzyVnD",
type: "rectangle",
x: 1477.5,
y: 295.5,
width: 357,
height: 280,
strokeColor: "transparent",
backgroundColor: "#9775fa",
fillStyle: "solid",
strokeWidth: 2,
strokeStyle: "solid",
roughness: 1,
opacity: 100,
groupIds: [],
frameId: null,
roundness: { type: 3 },
seed: 110715860,
version: 590,
versionNonce: 535980500,
boundElements: [],
link: null,
locked: false,
},
{
id: "yc7IKdtbnjLRaxJI5_q7m",
type: "rectangle",
x: 696.5,
y: 21.5,
width: 357,
height: 280,
strokeColor: "transparent",
backgroundColor: "#4dabf7",
fillStyle: "solid",
strokeWidth: 2,
strokeStyle: "solid",
roughness: 1,
opacity: 100,
groupIds: [],
frameId: null,
roundness: { type: 3 },
seed: 1457899860,
version: 587,
versionNonce: 724438868,
boundElements: [],
link: null,
locked: false,
},
{
id: "7KjY1vcMQm_i_i05wowTC",
type: "rectangle",
x: 447.5,
y: 771.5,
width: 357,
height: 280,
strokeColor: "transparent",
backgroundColor: "#ffc9c9",
fillStyle: "solid",
strokeWidth: 2,
strokeStyle: "solid",
roughness: 1,
opacity: 100,
groupIds: [],
frameId: null,
roundness: { type: 3 },
seed: 2072608468,
version: 610,
versionNonce: 1792607444,
boundElements: [],
link: null,
locked: false,
},
]);
}
initialStatePromiseRef.current.promise.resolve(data.scene); initialStatePromiseRef.current.promise.resolve(data.scene);
}); });
+3 -6
View File
@@ -19,12 +19,9 @@ import {
throttleRAF, throttleRAF,
} from "@excalidraw/common"; } from "@excalidraw/common";
import { decryptData } from "@excalidraw/excalidraw/data/encryption"; import { decryptData } from "@excalidraw/excalidraw/data/encryption";
import { getVisibleSceneBounds } from "@excalidraw/element/bounds"; import { getVisibleSceneBounds } from "@excalidraw/element";
import { newElementWith } from "@excalidraw/element/mutateElement"; import { newElementWith } from "@excalidraw/element";
import { import { isImageElement, isInitializedImageElement } from "@excalidraw/element";
isImageElement,
isInitializedImageElement,
} from "@excalidraw/element/typeChecks";
import { AbortError } from "@excalidraw/excalidraw/errors"; import { AbortError } from "@excalidraw/excalidraw/errors";
import { t } from "@excalidraw/excalidraw/i18n"; import { t } from "@excalidraw/excalidraw/i18n";
import { withBatchedUpdates } from "@excalidraw/excalidraw/reactUtils"; import { withBatchedUpdates } from "@excalidraw/excalidraw/reactUtils";
+1 -1
View File
@@ -1,7 +1,7 @@
import { CaptureUpdateAction } from "@excalidraw/excalidraw"; import { CaptureUpdateAction } from "@excalidraw/excalidraw";
import { trackEvent } from "@excalidraw/excalidraw/analytics"; import { trackEvent } from "@excalidraw/excalidraw/analytics";
import { encryptData } from "@excalidraw/excalidraw/data/encryption"; import { encryptData } from "@excalidraw/excalidraw/data/encryption";
import { newElementWith } from "@excalidraw/element/mutateElement"; import { newElementWith } from "@excalidraw/element";
import throttle from "lodash.throttle"; import throttle from "lodash.throttle";
import type { UserIdleState } from "@excalidraw/common"; import type { UserIdleState } from "@excalidraw/common";
+1 -1
View File
@@ -73,7 +73,7 @@ export const AIComponents = ({
</br> </br>
<div>You can also try <a href="${ <div>You can also try <a href="${
import.meta.env.VITE_APP_PLUS_LP import.meta.env.VITE_APP_PLUS_LP
}/plus?utm_source=excalidraw&utm_medium=app&utm_content=d2c" target="_blank" rel="noreferrer noopener">Excalidraw+</a> to get more requests.</div> }/plus?utm_source=excalidraw&utm_medium=app&utm_content=d2c" target="_blank" rel="noopener">Excalidraw+</a> to get more requests.</div>
</div> </div>
</body> </body>
</html>`, </html>`,
+1 -1
View File
@@ -10,7 +10,7 @@ export const EncryptedIcon = () => {
className="encrypted-icon tooltip" className="encrypted-icon tooltip"
href="https://plus.excalidraw.com/blog/end-to-end-encryption" href="https://plus.excalidraw.com/blog/end-to-end-encryption"
target="_blank" target="_blank"
rel="noopener noreferrer" rel="noopener"
aria-label={t("encrypted.link")} aria-label={t("encrypted.link")}
> >
<Tooltip label={t("encrypted.tooltip")} long={true}> <Tooltip label={t("encrypted.tooltip")} long={true}>
@@ -10,7 +10,7 @@ export const ExcalidrawPlusAppLink = () => {
import.meta.env.VITE_APP_PLUS_APP import.meta.env.VITE_APP_PLUS_APP
}?utm_source=excalidraw&utm_medium=app&utm_content=signedInUserRedirectButton#excalidraw-redirect`} }?utm_source=excalidraw&utm_medium=app&utm_content=signedInUserRedirectButton#excalidraw-redirect`}
target="_blank" target="_blank"
rel="noreferrer" rel="noopener"
className="plus-button" className="plus-button"
> >
Go to Excalidraw+ Go to Excalidraw+
@@ -12,7 +12,7 @@ import {
generateEncryptionKey, generateEncryptionKey,
} from "@excalidraw/excalidraw/data/encryption"; } from "@excalidraw/excalidraw/data/encryption";
import { serializeAsJSON } from "@excalidraw/excalidraw/data/json"; import { serializeAsJSON } from "@excalidraw/excalidraw/data/json";
import { isInitializedImageElement } from "@excalidraw/element/typeChecks"; import { isInitializedImageElement } from "@excalidraw/element";
import { useI18n } from "@excalidraw/excalidraw/i18n"; import { useI18n } from "@excalidraw/excalidraw/i18n";
import type { import type {
+2 -2
View File
@@ -1,7 +1,7 @@
import { CaptureUpdateAction } from "@excalidraw/excalidraw"; import { CaptureUpdateAction } from "@excalidraw/excalidraw";
import { compressData } from "@excalidraw/excalidraw/data/encode"; import { compressData } from "@excalidraw/excalidraw/data/encode";
import { newElementWith } from "@excalidraw/element/mutateElement"; import { newElementWith } from "@excalidraw/element";
import { isInitializedImageElement } from "@excalidraw/element/typeChecks"; import { isInitializedImageElement } from "@excalidraw/element";
import { t } from "@excalidraw/excalidraw/i18n"; import { t } from "@excalidraw/excalidraw/i18n";
import type { import type {
+3 -3
View File
@@ -9,14 +9,14 @@ import {
} from "@excalidraw/excalidraw/data/encryption"; } from "@excalidraw/excalidraw/data/encryption";
import { serializeAsJSON } from "@excalidraw/excalidraw/data/json"; import { serializeAsJSON } from "@excalidraw/excalidraw/data/json";
import { restore } from "@excalidraw/excalidraw/data/restore"; import { restore } from "@excalidraw/excalidraw/data/restore";
import { isInvisiblySmallElement } from "@excalidraw/element/sizeHelpers"; import { isInvisiblySmallElement } from "@excalidraw/element";
import { isInitializedImageElement } from "@excalidraw/element/typeChecks"; import { isInitializedImageElement } from "@excalidraw/element";
import { t } from "@excalidraw/excalidraw/i18n"; import { t } from "@excalidraw/excalidraw/i18n";
import { bytesToHexString } from "@excalidraw/common"; import { bytesToHexString } from "@excalidraw/common";
import type { UserIdleState } from "@excalidraw/common"; import type { UserIdleState } from "@excalidraw/common";
import type { ImportedDataState } from "@excalidraw/excalidraw/data/types"; import type { ImportedDataState } from "@excalidraw/excalidraw/data/types";
import type { SceneBounds } from "@excalidraw/element/bounds"; import type { SceneBounds } from "@excalidraw/element";
import type { import type {
ExcalidrawElement, ExcalidrawElement,
FileId, FileId,
@@ -198,7 +198,7 @@ exports[`Test MobileMenu > should initialize with welcome screen and hide once u
<a <a
class="welcome-screen-menu-item " class="welcome-screen-menu-item "
href="undefined/plus?utm_source=excalidraw&utm_medium=app&utm_content=welcomeScreenGuest" href="undefined/plus?utm_source=excalidraw&utm_medium=app&utm_content=welcomeScreenGuest"
rel="noreferrer" rel="noopener"
target="_blank" target="_blank"
> >
<div <div
+81 -77
View File
@@ -3,11 +3,15 @@ import {
createRedoAction, createRedoAction,
createUndoAction, createUndoAction,
} from "@excalidraw/excalidraw/actions/actionHistory"; } from "@excalidraw/excalidraw/actions/actionHistory";
import { syncInvalidIndices } from "@excalidraw/element/fractionalIndex"; import { syncInvalidIndices } from "@excalidraw/element";
import { API } from "@excalidraw/excalidraw/tests/helpers/api"; import { API } from "@excalidraw/excalidraw/tests/helpers/api";
import { act, render, waitFor } from "@excalidraw/excalidraw/tests/test-utils"; import { act, render, waitFor } from "@excalidraw/excalidraw/tests/test-utils";
import { vi } from "vitest"; import { vi } from "vitest";
import { StoreIncrement } from "@excalidraw/element";
import type { DurableIncrement, EphemeralIncrement } from "@excalidraw/element";
import ExcalidrawApp from "../App"; import ExcalidrawApp from "../App";
const { h } = window; const { h } = window;
@@ -65,6 +69,79 @@ vi.mock("socket.io-client", () => {
* i.e. multiplayer history tests could be a good first candidate, as we could test both history stacks simultaneously. * i.e. multiplayer history tests could be a good first candidate, as we could test both history stacks simultaneously.
*/ */
describe("collaboration", () => { describe("collaboration", () => {
it("should emit two ephemeral increments even though updates get batched", async () => {
const durableIncrements: DurableIncrement[] = [];
const ephemeralIncrements: EphemeralIncrement[] = [];
await render(<ExcalidrawApp />);
h.store.onStoreIncrementEmitter.on((increment) => {
if (StoreIncrement.isDurable(increment)) {
durableIncrements.push(increment);
} else {
ephemeralIncrements.push(increment);
}
});
// eslint-disable-next-line dot-notation
expect(h.store["scheduledMicroActions"].length).toBe(0);
expect(durableIncrements.length).toBe(0);
expect(ephemeralIncrements.length).toBe(0);
const rectProps = {
type: "rectangle",
id: "A",
height: 200,
width: 100,
x: 0,
y: 0,
} as const;
const rect = API.createElement({ ...rectProps });
API.updateScene({
elements: [rect],
captureUpdate: CaptureUpdateAction.IMMEDIATELY,
});
await waitFor(() => {
// expect(commitSpy).toHaveBeenCalledTimes(1);
expect(durableIncrements.length).toBe(1);
});
// simulate two batched remote updates
act(() => {
h.app.updateScene({
elements: [newElementWith(h.elements[0], { x: 100 })],
captureUpdate: CaptureUpdateAction.NEVER,
});
h.app.updateScene({
elements: [newElementWith(h.elements[0], { x: 200 })],
captureUpdate: CaptureUpdateAction.NEVER,
});
// we scheduled two micro actions,
// which confirms they are going to be executed as part of one batched component update
// eslint-disable-next-line dot-notation
expect(h.store["scheduledMicroActions"].length).toBe(2);
});
await waitFor(() => {
// altough the updates get batched,
// we expect two ephemeral increments for each update,
// and each such update should have the expected change
expect(ephemeralIncrements.length).toBe(2);
expect(ephemeralIncrements[0].change.elements.A).toEqual(
expect.objectContaining({ x: 100 }),
);
expect(ephemeralIncrements[1].change.elements.A).toEqual(
expect.objectContaining({ x: 200 }),
);
// eslint-disable-next-line dot-notation
expect(h.store["scheduledMicroActions"].length).toBe(0);
});
});
it("should allow to undo / redo even on force-deleted elements", async () => { it("should allow to undo / redo even on force-deleted elements", async () => {
await render(<ExcalidrawApp />); await render(<ExcalidrawApp />);
const rect1Props = { const rect1Props = {
@@ -122,12 +199,13 @@ describe("collaboration", () => {
expect(h.elements).toEqual([expect.objectContaining(rect1Props)]); expect(h.elements).toEqual([expect.objectContaining(rect1Props)]);
}); });
const undoAction = createUndoAction(h.history, h.store); const undoAction = createUndoAction(h.history);
act(() => h.app.actionManager.executeAction(undoAction)); act(() => h.app.actionManager.executeAction(undoAction));
// with explicit undo (as addition) we expect our item to be restored from the snapshot! // with explicit undo (as addition) we expect our item to be restored from the snapshot!
await waitFor(() => { await waitFor(() => {
expect(API.getUndoStack().length).toBe(1); expect(API.getUndoStack().length).toBe(1);
expect(API.getRedoStack().length).toBe(1);
expect(API.getSnapshot()).toEqual([ expect(API.getSnapshot()).toEqual([
expect.objectContaining(rect1Props), expect.objectContaining(rect1Props),
expect.objectContaining({ ...rect2Props, isDeleted: false }), expect.objectContaining({ ...rect2Props, isDeleted: false }),
@@ -154,7 +232,7 @@ describe("collaboration", () => {
expect(h.elements).toEqual([expect.objectContaining(rect1Props)]); expect(h.elements).toEqual([expect.objectContaining(rect1Props)]);
}); });
const redoAction = createRedoAction(h.history, h.store); const redoAction = createRedoAction(h.history);
act(() => h.app.actionManager.executeAction(redoAction)); act(() => h.app.actionManager.executeAction(redoAction));
// with explicit redo (as removal) we again restore the element from the snapshot! // with explicit redo (as removal) we again restore the element from the snapshot!
@@ -170,79 +248,5 @@ describe("collaboration", () => {
expect.objectContaining({ ...rect2Props, isDeleted: true }), 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 }),
]);
});
}); });
}); });
+3 -2
View File
@@ -33,6 +33,7 @@
"pepjs": "0.5.3", "pepjs": "0.5.3",
"prettier": "2.6.2", "prettier": "2.6.2",
"rewire": "6.0.0", "rewire": "6.0.0",
"rimraf": "^5.0.0",
"typescript": "4.9.4", "typescript": "4.9.4",
"vite": "5.0.12", "vite": "5.0.12",
"vite-plugin-checker": "0.7.2", "vite-plugin-checker": "0.7.2",
@@ -78,8 +79,8 @@
"autorelease": "node scripts/autorelease.js", "autorelease": "node scripts/autorelease.js",
"prerelease:excalidraw": "node scripts/prerelease.js", "prerelease:excalidraw": "node scripts/prerelease.js",
"release:excalidraw": "node scripts/release.js", "release:excalidraw": "node scripts/release.js",
"rm:build": "rm -rf excalidraw-app/{build,dist,dev-dist} && rm -rf packages/*/{dist,build} && rm -rf examples/*/{build,dist}", "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": "rm -rf node_modules && rm -rf excalidraw-app/node_modules && rm -rf packages/*/node_modules", "rm:node_modules": "rimraf --glob node_modules excalidraw-app/node_modules packages/*/node_modules",
"clean-install": "yarn rm:node_modules && yarn install" "clean-install": "yarn rm:node_modules && yarn install"
}, },
"resolutions": { "resolutions": {
+3 -3
View File
@@ -13,7 +13,7 @@
"default": "./dist/prod/index.js" "default": "./dist/prod/index.js"
}, },
"./*": { "./*": {
"types": "./../common/dist/types/common/src/*.d.ts" "types": "./dist/types/common/src/*.d.ts"
} }
}, },
"files": [ "files": [
@@ -50,7 +50,7 @@
"bugs": "https://github.com/excalidraw/excalidraw/issues", "bugs": "https://github.com/excalidraw/excalidraw/issues",
"repository": "https://github.com/excalidraw/excalidraw", "repository": "https://github.com/excalidraw/excalidraw",
"scripts": { "scripts": {
"gen:types": "rm -rf types && tsc", "gen:types": "rimraf types && tsc",
"build:esm": "rm -rf dist && node ../../scripts/buildBase.js && yarn gen:types" "build:esm": "rimraf dist && node ../../scripts/buildBase.js && yarn gen:types"
} }
} }
+43 -3
View File
@@ -10,6 +10,7 @@ export const isDarwin = /Mac|iPod|iPhone|iPad/.test(navigator.platform);
export const isWindows = /^Win/.test(navigator.platform); export const isWindows = /^Win/.test(navigator.platform);
export const isAndroid = /\b(android)\b/i.test(navigator.userAgent); export const isAndroid = /\b(android)\b/i.test(navigator.userAgent);
export const isFirefox = export const isFirefox =
typeof window !== "undefined" &&
"netscape" in window && "netscape" in window &&
navigator.userAgent.indexOf("rv:") > 1 && navigator.userAgent.indexOf("rv:") > 1 &&
navigator.userAgent.indexOf("Gecko") > 1; navigator.userAgent.indexOf("Gecko") > 1;
@@ -119,6 +120,7 @@ export const CLASSES = {
SHAPE_ACTIONS_MENU: "App-menu__left", SHAPE_ACTIONS_MENU: "App-menu__left",
ZOOM_ACTIONS: "zoom-actions", ZOOM_ACTIONS: "zoom-actions",
SEARCH_MENU_INPUT_WRAPPER: "layer-ui__search-inputWrapper", SEARCH_MENU_INPUT_WRAPPER: "layer-ui__search-inputWrapper",
CONVERT_ELEMENT_TYPE_POPUP: "ConvertElementTypePopup",
}; };
export const CJK_HAND_DRAWN_FALLBACK_FONT = "Xiaolai"; export const CJK_HAND_DRAWN_FALLBACK_FONT = "Xiaolai";
@@ -142,21 +144,52 @@ export const FONT_FAMILY = {
"Lilita One": 7, "Lilita One": 7,
"Comic Shanns": 8, "Comic Shanns": 8,
"Liberation Sans": 9, "Liberation Sans": 9,
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 = { export const FONT_FAMILY_FALLBACKS = {
[CJK_HAND_DRAWN_FALLBACK_FONT]: 100, [CJK_HAND_DRAWN_FALLBACK_FONT]: 100,
...FONT_FAMILY_GENERIC_FALLBACKS,
[WINDOWS_EMOJI_FALLBACK_FONT]: 1000, [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 = ( export const getFontFamilyFallbacks = (
fontFamily: number, fontFamily: number,
): Array<keyof typeof FONT_FAMILY_FALLBACKS> => { ): Array<keyof typeof FONT_FAMILY_FALLBACKS> => {
const genericFallbackFont = getGenericFontFamilyFallback(fontFamily);
switch (fontFamily) { switch (fontFamily) {
case FONT_FAMILY.Excalifont: 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: default:
return [WINDOWS_EMOJI_FALLBACK_FONT]; return [genericFallbackFont, WINDOWS_EMOJI_FALLBACK_FONT];
} }
}; };
@@ -253,7 +286,7 @@ export const EXPORT_DATA_TYPES = {
excalidrawClipboardWithAPI: "excalidraw-api/clipboard", excalidrawClipboardWithAPI: "excalidraw-api/clipboard",
} as const; } as const;
export const EXPORT_SOURCE = export const getExportSource = () =>
window.EXCALIDRAW_EXPORT_SOURCE || window.location.origin; window.EXCALIDRAW_EXPORT_SOURCE || window.location.origin;
// time in milliseconds // time in milliseconds
@@ -474,3 +507,10 @@ export enum UserIdleState {
AWAY = "away", AWAY = "away",
IDLE = "idle", IDLE = "idle",
} }
/**
* distance at which we merge points instead of adding a new merge-point
* when converting a line to a polygon (merge currently means overlaping
* the start and end points)
*/
export const LINE_POLYGON_POINT_MERGE_DISTANCE = 20;
@@ -1,4 +1,4 @@
import type { UnsubscribeCallback } from "./types"; import type { UnsubscribeCallback } from "@excalidraw/excalidraw/types";
type Subscriber<T extends any[]> = (...payload: T) => void; type Subscriber<T extends any[]> = (...payload: T) => void;
+16 -5
View File
@@ -22,8 +22,10 @@ export interface FontMetadata {
}; };
/** flag to indicate a deprecated font */ /** flag to indicate a deprecated font */
deprecated?: true; deprecated?: true;
/** flag to indicate a server-side only font */ /**
serverSide?: true; * whether this is a font that users can use (= shown in font picker)
*/
private?: true;
/** flag to indiccate a local-only font */ /** flag to indiccate a local-only font */
local?: true; local?: true;
/** flag to indicate a fallback font */ /** flag to indicate a fallback font */
@@ -44,7 +46,7 @@ export const FONT_METADATA: Record<number, FontMetadata> = {
unitsPerEm: 1000, unitsPerEm: 1000,
ascender: 1011, ascender: 1011,
descender: -353, descender: -353,
lineHeight: 1.35, lineHeight: 1.25,
}, },
}, },
[FONT_FAMILY["Lilita One"]]: { [FONT_FAMILY["Lilita One"]]: {
@@ -98,14 +100,23 @@ export const FONT_METADATA: Record<number, FontMetadata> = {
descender: -434, descender: -434,
lineHeight: 1.15, lineHeight: 1.15,
}, },
serverSide: true, private: true,
},
[FONT_FAMILY.Assistant]: {
metrics: {
unitsPerEm: 2048,
ascender: 1021,
descender: -287,
lineHeight: 1.25,
},
private: true,
}, },
[FONT_FAMILY_FALLBACKS.Xiaolai]: { [FONT_FAMILY_FALLBACKS.Xiaolai]: {
metrics: { metrics: {
unitsPerEm: 1000, unitsPerEm: 1000,
ascender: 880, ascender: 880,
descender: -144, descender: -144,
lineHeight: 1.15, lineHeight: 1.25,
}, },
fallback: true, fallback: true,
}, },
+1
View File
@@ -9,3 +9,4 @@ export * from "./promise-pool";
export * from "./random"; export * from "./random";
export * from "./url"; export * from "./url";
export * from "./utils"; export * from "./utils";
export * from "./emitter";
+9
View File
@@ -68,3 +68,12 @@ export type MaybePromise<T> = T | Promise<T>;
// get union of all keys from the union of types // get union of all keys from the union of types
export type AllPossibleKeys<T> = T extends any ? keyof T : never; export type AllPossibleKeys<T> = T extends any ? keyof T : never;
/** Strip all the methods or functions from a type */
export type DTO<T> = {
[K in keyof T as T[K] extends Function ? never : K]: T[K];
};
export type MapEntry<M extends Map<any, any>> = M extends Map<infer K, infer V>
? [K, V]
: never;
+82
View File
@@ -0,0 +1,82 @@
import {
isTransparent,
mapFind,
reduceToCommonValue,
} from "@excalidraw/common";
describe("@excalidraw/common/utils", () => {
describe("isTransparent()", () => {
it("should return true when color is rgb transparent", () => {
expect(isTransparent("#ff00")).toEqual(true);
expect(isTransparent("#fff00000")).toEqual(true);
expect(isTransparent("transparent")).toEqual(true);
});
it("should return false when color is not transparent", () => {
expect(isTransparent("#ced4da")).toEqual(false);
});
});
describe("reduceToCommonValue()", () => {
it("should return the common value when all values are the same", () => {
expect(reduceToCommonValue([1, 1])).toEqual(1);
expect(reduceToCommonValue([0, 0])).toEqual(0);
expect(reduceToCommonValue(["a", "a"])).toEqual("a");
expect(reduceToCommonValue(new Set([1]))).toEqual(1);
expect(reduceToCommonValue([""])).toEqual("");
expect(reduceToCommonValue([0])).toEqual(0);
const o = {};
expect(reduceToCommonValue([o, o])).toEqual(o);
expect(
reduceToCommonValue([{ a: 1 }, { a: 1, b: 2 }], (o) => o.a),
).toEqual(1);
expect(
reduceToCommonValue(new Set([{ a: 1 }, { a: 1, b: 2 }]), (o) => o.a),
).toEqual(1);
});
it("should return `null` when values are different", () => {
expect(reduceToCommonValue([1, 2, 3])).toEqual(null);
expect(reduceToCommonValue(new Set([1, 2]))).toEqual(null);
expect(reduceToCommonValue([{ a: 1 }, { a: 2 }], (o) => o.a)).toEqual(
null,
);
});
it("should return `null` when some values are nullable", () => {
expect(reduceToCommonValue([1, null, 1])).toEqual(null);
expect(reduceToCommonValue([null, 1])).toEqual(null);
expect(reduceToCommonValue([1, undefined])).toEqual(null);
expect(reduceToCommonValue([undefined, 1])).toEqual(null);
expect(reduceToCommonValue([null])).toEqual(null);
expect(reduceToCommonValue([undefined])).toEqual(null);
expect(reduceToCommonValue([])).toEqual(null);
});
});
describe("mapFind()", () => {
it("should return the first mapped non-null element", () => {
{
let counter = 0;
const result = mapFind(["a", "b", "c"], (value) => {
counter++;
return value === "b" ? 42 : null;
});
expect(result).toEqual(42);
expect(counter).toBe(2);
}
expect(mapFind([1, 2], (value) => value * 0)).toBe(0);
expect(mapFind([1, 2], () => false)).toBe(false);
expect(mapFind([1, 2], () => "")).toBe("");
});
it("should return undefined if no mapped element is found", () => {
expect(mapFind([1, 2], () => undefined)).toBe(undefined);
expect(mapFind([1, 2], () => null)).toBe(undefined);
});
});
});
+70 -11
View File
@@ -1,10 +1,12 @@
import { average, pointFrom, type GlobalPoint } from "@excalidraw/math"; import { average, pointFrom, type GlobalPoint } from "@excalidraw/math";
import { getCenterForBounds, getElementBounds } from "@excalidraw/element";
import type { import type {
ExcalidrawBindableElement, ExcalidrawBindableElement,
FontFamilyValues, FontFamilyValues,
FontString, FontString,
ExcalidrawElement, ExcalidrawElement,
ElementsMap,
} from "@excalidraw/element/types"; } from "@excalidraw/element/types";
import type { import type {
@@ -101,7 +103,6 @@ export const getFontFamilyString = ({
}) => { }) => {
for (const [fontFamilyString, id] of Object.entries(FONT_FAMILY)) { for (const [fontFamilyString, id] of Object.entries(FONT_FAMILY)) {
if (id === fontFamily) { if (id === fontFamily) {
// TODO: we should fallback first to generic family names first
return `${fontFamilyString}${getFontFamilyFallbacks(id) return `${fontFamilyString}${getFontFamilyFallbacks(id)
.map((x) => `, ${x}`) .map((x) => `, ${x}`)
.join("")}`; .join("")}`;
@@ -544,6 +545,20 @@ export const findLastIndex = <T>(
return -1; return -1;
}; };
/** returns the first non-null mapped value */
export const mapFind = <T, K>(
collection: readonly T[],
iteratee: (value: T, index: number) => K | undefined | null,
): K | undefined => {
for (let idx = 0; idx < collection.length; idx++) {
const result = iteratee(collection[idx], idx);
if (result != null) {
return result;
}
}
return undefined;
};
export const isTransparent = (color: string) => { export const isTransparent = (color: string) => {
const isRGBTransparent = color.length === 5 && color.substr(4, 1) === "0"; const isRGBTransparent = color.length === 5 && color.substr(4, 1) === "0";
const isRRGGBBTransparent = color.length === 9 && color.substr(7, 2) === "00"; const isRRGGBBTransparent = color.length === 9 && color.substr(7, 2) === "00";
@@ -698,8 +713,8 @@ export const arrayToObject = <T>(
array: readonly T[], array: readonly T[],
groupBy?: (value: T) => string | number, groupBy?: (value: T) => string | number,
) => ) =>
array.reduce((acc, value) => { array.reduce((acc, value, idx) => {
acc[groupBy ? groupBy(value) : String(value)] = value; acc[groupBy ? groupBy(value) : idx] = value;
return acc; return acc;
}, {} as { [key: string]: T }); }, {} as { [key: string]: T });
@@ -735,6 +750,25 @@ export const arrayToList = <T>(array: readonly T[]): Node<T>[] =>
return acc; return acc;
}, [] as Node<T>[]); }, [] as Node<T>[]);
/**
* Converts a readonly array or map into an iterable.
* Useful for avoiding entry allocations when iterating object / map on each iteration.
*/
export const toIterable = <T>(
values: readonly T[] | ReadonlyMap<string, T>,
): Iterable<T> => {
return Array.isArray(values) ? values : values.values();
};
/**
* Converts a readonly array or map into an array.
*/
export const toArray = <T>(
values: readonly T[] | ReadonlyMap<string, T>,
): T[] => {
return Array.isArray(values) ? values : Array.from(toIterable(values));
};
export const isTestEnv = () => import.meta.env.MODE === ENV.TEST; export const isTestEnv = () => import.meta.env.MODE === ENV.TEST;
export const isDevEnv = () => import.meta.env.MODE === ENV.DEVELOPMENT; export const isDevEnv = () => import.meta.env.MODE === ENV.DEVELOPMENT;
@@ -1207,16 +1241,13 @@ export const castArray = <T>(value: T | T[]): T[] =>
export const elementCenterPoint = ( export const elementCenterPoint = (
element: ExcalidrawElement, element: ExcalidrawElement,
elementsMap: ElementsMap,
xOffset: number = 0, xOffset: number = 0,
yOffset: number = 0, yOffset: number = 0,
) => { ) => {
const { x, y, width, height } = element; const [x, y] = getCenterForBounds(getElementBounds(element, elementsMap));
const centerXPoint = x + width / 2 + xOffset; return pointFrom<GlobalPoint>(x + xOffset, y + yOffset);
const centerYPoint = y + height / 2 + yOffset;
return pointFrom<GlobalPoint>(centerXPoint, centerYPoint);
}; };
/** hack for Array.isArray type guard not working with readonly value[] */ /** hack for Array.isArray type guard not working with readonly value[] */
@@ -1225,11 +1256,39 @@ export const isReadonlyArray = (value?: any): value is readonly any[] => {
}; };
export const sizeOf = ( export const sizeOf = (
value: readonly number[] | Readonly<Map<any, any>> | Record<any, any>, value:
| readonly unknown[]
| Readonly<Map<string, unknown>>
| Readonly<Record<string, unknown>>
| ReadonlySet<unknown>,
): number => { ): number => {
return isReadonlyArray(value) return isReadonlyArray(value)
? value.length ? value.length
: value instanceof Map : value instanceof Map || value instanceof Set
? value.size ? value.size
: Object.keys(value).length; : Object.keys(value).length;
}; };
export const reduceToCommonValue = <T, R = T>(
collection: readonly T[] | ReadonlySet<T>,
getValue?: (item: T) => R,
): R | null => {
if (sizeOf(collection) === 0) {
return null;
}
const valueExtractor = getValue || ((item: T) => item as unknown as R);
let commonValue: R | null = null;
for (const item of collection) {
const value = valueExtractor(item);
if ((commonValue === null || commonValue === value) && value != null) {
commonValue = value;
} else {
return null;
}
}
return commonValue;
};
+3 -3
View File
@@ -13,7 +13,7 @@
"default": "./dist/prod/index.js" "default": "./dist/prod/index.js"
}, },
"./*": { "./*": {
"types": "./../element/dist/types/element/src/*.d.ts" "types": "./dist/types/element/src/*.d.ts"
} }
}, },
"files": [ "files": [
@@ -50,7 +50,7 @@
"bugs": "https://github.com/excalidraw/excalidraw/issues", "bugs": "https://github.com/excalidraw/excalidraw/issues",
"repository": "https://github.com/excalidraw/excalidraw", "repository": "https://github.com/excalidraw/excalidraw",
"scripts": { "scripts": {
"gen:types": "rm -rf types && tsc", "gen:types": "rimraf types && tsc",
"build:esm": "rm -rf dist && node ../../scripts/buildBase.js && yarn gen:types" "build:esm": "rimraf dist && node ../../scripts/buildBase.js && yarn gen:types"
} }
} }
+11 -23
View File
@@ -6,25 +6,21 @@ import {
toBrandedType, toBrandedType,
isDevEnv, isDevEnv,
isTestEnv, isTestEnv,
isReadonlyArray, toArray,
} from "@excalidraw/common"; } from "@excalidraw/common";
import { isNonDeletedElement } from "@excalidraw/element"; import { isNonDeletedElement } from "@excalidraw/element";
import { isFrameLikeElement } from "@excalidraw/element/typeChecks"; import { isFrameLikeElement } from "@excalidraw/element";
import { getElementsInGroup } from "@excalidraw/element/groups"; import { getElementsInGroup } from "@excalidraw/element";
import { import {
orderByFractionalIndex,
syncInvalidIndices, syncInvalidIndices,
syncMovedIndices, syncMovedIndices,
validateFractionalIndices, validateFractionalIndices,
} from "@excalidraw/element/fractionalIndex"; } from "@excalidraw/element";
import { getSelectedElements } from "@excalidraw/element/selection"; import { getSelectedElements } from "@excalidraw/element";
import { import { mutateElement, type ElementUpdate } from "@excalidraw/element";
mutateElement,
type ElementUpdate,
} from "@excalidraw/element/mutateElement";
import type { import type {
ExcalidrawElement, ExcalidrawElement,
@@ -109,7 +105,7 @@ const hashSelectionOpts = (
// in our codebase // in our codebase
export type ExcalidrawElementsIncludingDeleted = readonly ExcalidrawElement[]; export type ExcalidrawElementsIncludingDeleted = readonly ExcalidrawElement[];
class Scene { export class Scene {
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// instance methods/props // instance methods/props
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@@ -268,19 +264,13 @@ class Scene {
} }
replaceAllElements(nextElements: ElementsMapOrArray) { replaceAllElements(nextElements: ElementsMapOrArray) {
// ts doesn't like `Array.isArray` of `instanceof Map` // we do trust the insertion order on the map, though maybe we shouldn't and should prefer order defined by fractional indices
if (!isReadonlyArray(nextElements)) { const _nextElements = toArray(nextElements);
// need to order by fractional indices to get the correct order
nextElements = orderByFractionalIndex(
Array.from(nextElements.values()) as OrderedExcalidrawElement[],
);
}
const nextFrameLikes: ExcalidrawFrameLikeElement[] = []; const nextFrameLikes: ExcalidrawFrameLikeElement[] = [];
validateIndicesThrottled(nextElements); validateIndicesThrottled(_nextElements);
this.elements = syncInvalidIndices(nextElements); this.elements = syncInvalidIndices(_nextElements);
this.elementsMap.clear(); this.elementsMap.clear();
this.elements.forEach((element) => { this.elements.forEach((element) => {
if (isFrameLikeElement(element)) { if (isFrameLikeElement(element)) {
@@ -464,5 +454,3 @@ class Scene {
return element; return element;
} }
} }
export default Scene;
+184 -3
View File
@@ -1,8 +1,17 @@
import { simplify } from "points-on-curve"; import { simplify } from "points-on-curve";
import { pointFrom, pointDistance, type LocalPoint } from "@excalidraw/math"; import {
pointFrom,
pointDistance,
type LocalPoint,
pointRotateRads,
} from "@excalidraw/math";
import { ROUGHNESS, isTransparent, assertNever } from "@excalidraw/common"; import { ROUGHNESS, isTransparent, assertNever } 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 { Mutable } from "@excalidraw/common/utility-types";
import type { EmbedsValidationStatus } from "@excalidraw/excalidraw/types"; import type { EmbedsValidationStatus } from "@excalidraw/excalidraw/types";
@@ -20,7 +29,12 @@ import { headingForPointIsHorizontal } from "./heading";
import { canChangeRoundness } from "./comparisons"; import { canChangeRoundness } from "./comparisons";
import { generateFreeDrawShape } from "./renderElement"; import { generateFreeDrawShape } from "./renderElement";
import { getArrowheadPoints, getDiamondPoints } from "./bounds"; import {
getArrowheadPoints,
getCenterForBounds,
getDiamondPoints,
getElementBounds,
} from "./bounds";
import type { import type {
ExcalidrawElement, ExcalidrawElement,
@@ -28,10 +42,11 @@ import type {
ExcalidrawSelectionElement, ExcalidrawSelectionElement,
ExcalidrawLinearElement, ExcalidrawLinearElement,
Arrowhead, Arrowhead,
ExcalidrawFreeDrawElement,
ElementsMap,
} from "./types"; } from "./types";
import type { Drawable, Options } from "roughjs/bin/core"; import type { Drawable, Options } from "roughjs/bin/core";
import type { RoughGenerator } from "roughjs/bin/generator";
import type { Point as RoughPoint } from "roughjs/bin/geometry"; import type { Point as RoughPoint } from "roughjs/bin/geometry";
const getDashArrayDashed = (strokeWidth: number) => [8, 8 + strokeWidth]; const getDashArrayDashed = (strokeWidth: number) => [8, 8 + strokeWidth];
@@ -303,6 +318,172 @@ const getArrowheadShapes = (
} }
}; };
export const generateLinearCollisionShape = (
element: ExcalidrawLinearElement | ExcalidrawFreeDrawElement,
elementsMap: ElementsMap,
) => {
const generator = new RoughGenerator();
const options: Options = {
seed: element.seed,
disableMultiStroke: true,
disableMultiStrokeFill: true,
roughness: 0,
preserveVertices: true,
};
const center = getCenterForBounds(
getElementBounds(element, elementsMap, true),
);
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. * Generates the roughjs shape for given element.
* *
+1 -1
View File
@@ -2,7 +2,7 @@ import { updateBoundElements } from "./binding";
import { getCommonBoundingBox } from "./bounds"; import { getCommonBoundingBox } from "./bounds";
import { getMaximumGroups } from "./groups"; import { getMaximumGroups } from "./groups";
import type Scene from "./Scene"; import type { Scene } from "./Scene";
import type { BoundingBox } from "./bounds"; import type { BoundingBox } from "./bounds";
import type { ExcalidrawElement } from "./types"; import type { ExcalidrawElement } from "./types";
+219 -164
View File
@@ -27,13 +27,11 @@ import {
PRECISION, PRECISION,
} from "@excalidraw/math"; } from "@excalidraw/math";
import { isPointOnShape } from "@excalidraw/utils/collision";
import type { LocalPoint, Radians } from "@excalidraw/math"; import type { LocalPoint, Radians } from "@excalidraw/math";
import type { AppState } from "@excalidraw/excalidraw/types"; import type { AppState } from "@excalidraw/excalidraw/types";
import type { Mutable } from "@excalidraw/common/utility-types"; import type { MapEntry, Mutable } from "@excalidraw/common/utility-types";
import { import {
getCenterForBounds, getCenterForBounds,
@@ -41,7 +39,7 @@ import {
doBoundsIntersect, doBoundsIntersect,
} from "./bounds"; } from "./bounds";
import { intersectElementWithLineSegment } from "./collision"; import { intersectElementWithLineSegment } from "./collision";
import { distanceToBindableElement } from "./distance"; import { distanceToElement } from "./distance";
import { import {
headingForPointFromElement, headingForPointFromElement,
headingIsHorizontal, headingIsHorizontal,
@@ -63,10 +61,10 @@ import {
isTextElement, isTextElement,
} from "./typeChecks"; } from "./typeChecks";
import { aabbForElement, getElementShape, pointInsideBounds } from "./shapes"; import { aabbForElement } from "./shapes";
import { updateElbowArrowPoints } from "./elbowArrow"; import { updateElbowArrowPoints } from "./elbowArrow";
import type Scene from "./Scene"; import type { Scene } from "./Scene";
import type { Bounds } from "./bounds"; import type { Bounds } from "./bounds";
import type { ElementUpdate } from "./mutateElement"; import type { ElementUpdate } from "./mutateElement";
@@ -81,10 +79,10 @@ import type {
NonDeletedSceneElementsMap, NonDeletedSceneElementsMap,
ExcalidrawTextElement, ExcalidrawTextElement,
ExcalidrawArrowElement, ExcalidrawArrowElement,
OrderedExcalidrawElement,
ExcalidrawElbowArrowElement, ExcalidrawElbowArrowElement,
FixedPoint, FixedPoint,
FixedPointBinding, FixedPointBinding,
PointsPositionUpdates,
} from "./types"; } from "./types";
export type SuggestedBinding = export type SuggestedBinding =
@@ -109,7 +107,6 @@ export const isBindingEnabled = (appState: AppState): boolean => {
export const FIXED_BINDING_DISTANCE = 5; export const FIXED_BINDING_DISTANCE = 5;
export const BINDING_HIGHLIGHT_THICKNESS = 10; export const BINDING_HIGHLIGHT_THICKNESS = 10;
export const BINDING_HIGHLIGHT_OFFSET = 4;
const getNonDeletedElements = ( const getNonDeletedElements = (
scene: Scene, scene: Scene,
@@ -131,6 +128,7 @@ export const bindOrUnbindLinearElement = (
endBindingElement: ExcalidrawBindableElement | null | "keep", endBindingElement: ExcalidrawBindableElement | null | "keep",
scene: Scene, scene: Scene,
): void => { ): void => {
const elementsMap = scene.getNonDeletedElementsMap();
const boundToElementIds: Set<ExcalidrawBindableElement["id"]> = new Set(); const boundToElementIds: Set<ExcalidrawBindableElement["id"]> = new Set();
const unboundFromElementIds: Set<ExcalidrawBindableElement["id"]> = new Set(); const unboundFromElementIds: Set<ExcalidrawBindableElement["id"]> = new Set();
bindOrUnbindLinearElementEdge( bindOrUnbindLinearElementEdge(
@@ -141,6 +139,7 @@ export const bindOrUnbindLinearElement = (
boundToElementIds, boundToElementIds,
unboundFromElementIds, unboundFromElementIds,
scene, scene,
elementsMap,
); );
bindOrUnbindLinearElementEdge( bindOrUnbindLinearElementEdge(
linearElement, linearElement,
@@ -150,6 +149,7 @@ export const bindOrUnbindLinearElement = (
boundToElementIds, boundToElementIds,
unboundFromElementIds, unboundFromElementIds,
scene, scene,
elementsMap,
); );
const onlyUnbound = Array.from(unboundFromElementIds).filter( const onlyUnbound = Array.from(unboundFromElementIds).filter(
@@ -176,6 +176,7 @@ const bindOrUnbindLinearElementEdge = (
// Is mutated // Is mutated
unboundFromElementIds: Set<ExcalidrawBindableElement["id"]>, unboundFromElementIds: Set<ExcalidrawBindableElement["id"]>,
scene: Scene, scene: Scene,
elementsMap: ElementsMap,
): void => { ): void => {
// "keep" is for method chaining convenience, a "no-op", so just bail out // "keep" is for method chaining convenience, a "no-op", so just bail out
if (bindableElement === "keep") { if (bindableElement === "keep") {
@@ -216,43 +217,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 = ( const getOriginalBindingsIfStillCloseToArrowEnds = (
linearElement: NonDeleted<ExcalidrawLinearElement>, linearElement: NonDeleted<ExcalidrawLinearElement>,
elementsMap: NonDeletedSceneElementsMap, elementsMap: NonDeletedSceneElementsMap,
zoom?: AppState["zoom"], zoom?: AppState["zoom"],
): (NonDeleted<ExcalidrawElement> | null)[] => ): (NonDeleted<ExcalidrawElement> | null)[] =>
["start", "end"].map((edge) => (["start", "end"] as const).map((edge) => {
getOriginalBindingIfStillCloseOfLinearElementEdge( const coors = getLinearElementEdgeCoors(linearElement, edge, elementsMap);
linearElement, const elementId =
edge as "start" | "end", edge === "start"
elementsMap, ? linearElement.startBinding?.elementId
zoom, : linearElement.endBinding?.elementId;
), if (elementId) {
); const element = elementsMap.get(elementId);
if (
isBindableElement(element) &&
bindingBorderTest(element, coors, elementsMap, zoom)
) {
return element;
}
}
return null;
});
const getBindingStrategyForDraggingArrowEndpoints = ( const getBindingStrategyForDraggingArrowEndpoints = (
selectedElement: NonDeleted<ExcalidrawLinearElement>, selectedElement: NonDeleted<ExcalidrawLinearElement>,
@@ -268,7 +255,7 @@ const getBindingStrategyForDraggingArrowEndpoints = (
const endDragged = draggingPoints.findIndex((i) => i === endIdx) > -1; const endDragged = draggingPoints.findIndex((i) => i === endIdx) > -1;
const start = startDragged const start = startDragged
? isBindingEnabled ? isBindingEnabled
? getElligibleElementForBindingElement( ? getEligibleElementForBindingElement(
selectedElement, selectedElement,
"start", "start",
elementsMap, elementsMap,
@@ -276,19 +263,10 @@ const getBindingStrategyForDraggingArrowEndpoints = (
zoom, zoom,
) )
: null // If binding is disabled and start is dragged, break all binds : null // If binding is disabled and start is dragged, break all binds
: !isElbowArrow(selectedElement)
? // We have to update the focus and gap of the binding, so let's rebind
getElligibleElementForBindingElement(
selectedElement,
"start",
elementsMap,
elements,
zoom,
)
: "keep"; : "keep";
const end = endDragged const end = endDragged
? isBindingEnabled ? isBindingEnabled
? getElligibleElementForBindingElement( ? getEligibleElementForBindingElement(
selectedElement, selectedElement,
"end", "end",
elementsMap, elementsMap,
@@ -296,15 +274,6 @@ const getBindingStrategyForDraggingArrowEndpoints = (
zoom, zoom,
) )
: null // If binding is disabled and end is dragged, break all binds : null // If binding is disabled and end is dragged, break all binds
: !isElbowArrow(selectedElement)
? // We have to update the focus and gap of the binding, so let's rebind
getElligibleElementForBindingElement(
selectedElement,
"end",
elementsMap,
elements,
zoom,
)
: "keep"; : "keep";
return [start, end]; return [start, end];
@@ -329,7 +298,7 @@ const getBindingStrategyForDraggingArrowOrJoints = (
); );
const start = startIsClose const start = startIsClose
? isBindingEnabled ? isBindingEnabled
? getElligibleElementForBindingElement( ? getEligibleElementForBindingElement(
selectedElement, selectedElement,
"start", "start",
elementsMap, elementsMap,
@@ -340,7 +309,7 @@ const getBindingStrategyForDraggingArrowOrJoints = (
: null; : null;
const end = endIsClose const end = endIsClose
? isBindingEnabled ? isBindingEnabled
? getElligibleElementForBindingElement( ? getEligibleElementForBindingElement(
selectedElement, selectedElement,
"end", "end",
elementsMap, elementsMap,
@@ -459,22 +428,13 @@ export const maybeBindLinearElement = (
const normalizePointBinding = ( const normalizePointBinding = (
binding: { focus: number; gap: number }, binding: { focus: number; gap: number },
hoveredElement: ExcalidrawBindableElement, hoveredElement: ExcalidrawBindableElement,
) => { ) => ({
let gap = binding.gap; ...binding,
const maxGap = maxBindingGap( gap: Math.min(
hoveredElement, binding.gap,
hoveredElement.width, maxBindingGap(hoveredElement, hoveredElement.width, hoveredElement.height),
hoveredElement.height, ),
); });
if (gap > maxGap) {
gap = BINDING_HIGHLIGHT_THICKNESS + BINDING_HIGHLIGHT_OFFSET;
}
return {
...binding,
gap,
};
};
export const bindLinearElement = ( export const bindLinearElement = (
linearElement: NonDeleted<ExcalidrawLinearElement>, linearElement: NonDeleted<ExcalidrawLinearElement>,
@@ -506,6 +466,7 @@ export const bindLinearElement = (
linearElement, linearElement,
hoveredElement, hoveredElement,
startOrEnd, startOrEnd,
scene.getNonDeletedElementsMap(),
), ),
}; };
} }
@@ -721,36 +682,44 @@ const calculateFocusAndGap = (
); );
return { return {
focus: determineFocusDistance(hoveredElement, adjacentPoint, edgePoint), focus: determineFocusDistance(
gap: Math.max(1, distanceToBindableElement(hoveredElement, edgePoint)), hoveredElement,
elementsMap,
adjacentPoint,
edgePoint,
),
gap: Math.max(1, distanceToElement(hoveredElement, elementsMap, edgePoint)),
}; };
}; };
// Supports translating, rotating and scaling `changedElement` with bound // Supports translating, rotating and scaling `changedElement` with bound
// linear elements. // linear elements.
// Because scaling involves moving the focus points as well, it is
// done before the `changedElement` is updated, and the `newSize` is passed
// in explicitly.
export const updateBoundElements = ( export const updateBoundElements = (
changedElement: NonDeletedExcalidrawElement, changedElement: NonDeletedExcalidrawElement,
scene: Scene, scene: Scene,
options?: { options?: {
simultaneouslyUpdated?: readonly ExcalidrawElement[]; simultaneouslyUpdated?: readonly ExcalidrawElement[];
newSize?: { width: number; height: number }; newSize?: { width: number; height: number };
changedElements?: Map<string, OrderedExcalidrawElement>; changedElements?: Map<string, ExcalidrawElement>;
}, },
) => { ) => {
if (!isBindableElement(changedElement)) {
return;
}
const { newSize, simultaneouslyUpdated } = options ?? {}; const { newSize, simultaneouslyUpdated } = options ?? {};
const simultaneouslyUpdatedElementIds = getSimultaneouslyUpdatedElementIds( const simultaneouslyUpdatedElementIds = getSimultaneouslyUpdatedElementIds(
simultaneouslyUpdated, simultaneouslyUpdated,
); );
if (!isBindableElement(changedElement)) { let elementsMap: ElementsMap = scene.getNonDeletedElementsMap();
return; if (options?.changedElements) {
elementsMap = new Map(elementsMap) as typeof elementsMap;
options.changedElements.forEach((element) => {
elementsMap.set(element.id, element);
});
} }
const elementsMap = scene.getNonDeletedElementsMap();
boundElementsVisitor(elementsMap, changedElement, (element) => { boundElementsVisitor(elementsMap, changedElement, (element) => {
if (!isLinearElement(element) || element.isDeleted) { if (!isLinearElement(element) || element.isDeleted) {
return; return;
@@ -817,28 +786,22 @@ export const updateBoundElements = (
bindableElement, bindableElement,
elementsMap, elementsMap,
); );
if (point) { if (point) {
return { return [
index: bindingProp === "startBinding" ? 0 : element.points.length - 1,
bindingProp === "startBinding" ? 0 : element.points.length - 1, { point },
point, ] as MapEntry<PointsPositionUpdates>;
};
} }
} }
return null; return null;
}, },
).filter( ).filter(
( (update): update is MapEntry<PointsPositionUpdates> => update !== null,
update,
): update is NonNullable<{
index: number;
point: LocalPoint;
isDragging?: boolean;
}> => update !== null,
); );
LinearElementEditor.movePoints(element, scene, updates, { LinearElementEditor.movePoints(element, scene, new Map(updates), {
...(changedElement.id === element.startBinding?.elementId ...(changedElement.id === element.startBinding?.elementId
? { startBinding: bindings.startBinding } ? { startBinding: bindings.startBinding }
: {}), : {}),
@@ -854,6 +817,25 @@ export const updateBoundElements = (
}); });
}; };
export const updateBindings = (
latestElement: ExcalidrawElement,
scene: Scene,
options?: {
simultaneouslyUpdated?: readonly ExcalidrawElement[];
newSize?: { width: number; height: number };
zoom?: AppState["zoom"];
},
) => {
if (isLinearElement(latestElement)) {
bindOrUnbindLinearElements([latestElement], true, [], scene, options?.zoom);
} else {
updateBoundElements(latestElement, scene, {
...options,
changedElements: new Map([[latestElement.id, latestElement]]),
});
}
};
const doesNeedUpdate = ( const doesNeedUpdate = (
boundElement: NonDeleted<ExcalidrawLinearElement>, boundElement: NonDeleted<ExcalidrawLinearElement>,
changedElement: ExcalidrawBindableElement, changedElement: ExcalidrawBindableElement,
@@ -876,6 +858,7 @@ export const getHeadingForElbowArrowSnap = (
bindableElement: ExcalidrawBindableElement | undefined | null, bindableElement: ExcalidrawBindableElement | undefined | null,
aabb: Bounds | undefined | null, aabb: Bounds | undefined | null,
origPoint: GlobalPoint, origPoint: GlobalPoint,
elementsMap: ElementsMap,
zoom?: AppState["zoom"], zoom?: AppState["zoom"],
): Heading => { ): Heading => {
const otherPointHeading = vectorToHeading(vectorFromPoint(otherPoint, p)); const otherPointHeading = vectorToHeading(vectorFromPoint(otherPoint, p));
@@ -884,11 +867,16 @@ export const getHeadingForElbowArrowSnap = (
return otherPointHeading; return otherPointHeading;
} }
const distance = getDistanceForBinding(origPoint, bindableElement, zoom); const distance = getDistanceForBinding(
origPoint,
bindableElement,
elementsMap,
zoom,
);
if (!distance) { if (!distance) {
return vectorToHeading( return vectorToHeading(
vectorFromPoint(p, elementCenterPoint(bindableElement)), vectorFromPoint(p, elementCenterPoint(bindableElement, elementsMap)),
); );
} }
@@ -898,9 +886,10 @@ export const getHeadingForElbowArrowSnap = (
const getDistanceForBinding = ( const getDistanceForBinding = (
point: Readonly<GlobalPoint>, point: Readonly<GlobalPoint>,
bindableElement: ExcalidrawBindableElement, bindableElement: ExcalidrawBindableElement,
elementsMap: ElementsMap,
zoom?: AppState["zoom"], zoom?: AppState["zoom"],
) => { ) => {
const distance = distanceToBindableElement(bindableElement, point); const distance = distanceToElement(bindableElement, elementsMap, point);
const bindDistance = maxBindingGap( const bindDistance = maxBindingGap(
bindableElement, bindableElement,
bindableElement.width, bindableElement.width,
@@ -915,12 +904,13 @@ export const bindPointToSnapToElementOutline = (
arrow: ExcalidrawElbowArrowElement, arrow: ExcalidrawElbowArrowElement,
bindableElement: ExcalidrawBindableElement, bindableElement: ExcalidrawBindableElement,
startOrEnd: "start" | "end", startOrEnd: "start" | "end",
elementsMap: ElementsMap,
): GlobalPoint => { ): GlobalPoint => {
if (isDevEnv() || isTestEnv()) { if (isDevEnv() || isTestEnv()) {
invariant(arrow.points.length > 1, "Arrow should have at least 2 points"); invariant(arrow.points.length > 1, "Arrow should have at least 2 points");
} }
const aabb = aabbForElement(bindableElement); const aabb = aabbForElement(bindableElement, elementsMap);
const localP = const localP =
arrow.points[startOrEnd === "start" ? 0 : arrow.points.length - 1]; arrow.points[startOrEnd === "start" ? 0 : arrow.points.length - 1];
const globalP = pointFrom<GlobalPoint>( const globalP = pointFrom<GlobalPoint>(
@@ -928,7 +918,7 @@ export const bindPointToSnapToElementOutline = (
arrow.y + localP[1], arrow.y + localP[1],
); );
const edgePoint = isRectanguloidElement(bindableElement) const edgePoint = isRectanguloidElement(bindableElement)
? avoidRectangularCorner(bindableElement, globalP) ? avoidRectangularCorner(bindableElement, elementsMap, globalP)
: globalP; : globalP;
const elbowed = isElbowArrow(arrow); const elbowed = isElbowArrow(arrow);
const center = getCenterForBounds(aabb); const center = getCenterForBounds(aabb);
@@ -947,26 +937,31 @@ export const bindPointToSnapToElementOutline = (
const isHorizontal = headingIsHorizontal( const isHorizontal = headingIsHorizontal(
headingForPointFromElement(bindableElement, aabb, globalP), headingForPointFromElement(bindableElement, aabb, globalP),
); );
const snapPoint = snapToMid(bindableElement, elementsMap, edgePoint);
const otherPoint = pointFrom<GlobalPoint>( const otherPoint = pointFrom<GlobalPoint>(
isHorizontal ? center[0] : edgePoint[0], isHorizontal ? center[0] : snapPoint[0],
!isHorizontal ? center[1] : edgePoint[1], !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( intersection = intersectElementWithLineSegment(
bindableElement, bindableElement,
lineSegment( elementsMap,
otherPoint, intersector,
pointFromVector( FIXED_BINDING_DISTANCE,
vectorScale( ).sort(pointDistanceSq)[0];
vectorNormalize(vectorFromPoint(edgePoint, otherPoint)),
Math.max(bindableElement.width, bindableElement.height) * 2,
),
otherPoint,
),
),
)[0];
} else { } else {
intersection = intersectElementWithLineSegment( intersection = intersectElementWithLineSegment(
bindableElement, bindableElement,
elementsMap,
lineSegment( lineSegment(
adjacentPoint, adjacentPoint,
pointFromVector( pointFromVector(
@@ -993,31 +988,15 @@ export const bindPointToSnapToElementOutline = (
return edgePoint; return edgePoint;
} }
if (elbowed) { return elbowed ? intersection : edgePoint;
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;
}; };
export const avoidRectangularCorner = ( export const avoidRectangularCorner = (
element: ExcalidrawBindableElement, element: ExcalidrawBindableElement,
elementsMap: ElementsMap,
p: GlobalPoint, p: GlobalPoint,
): GlobalPoint => { ): GlobalPoint => {
const center = elementCenterPoint(element); const center = elementCenterPoint(element, elementsMap);
const nonRotatedPoint = pointRotateRads(p, center, -element.angle as Radians); const nonRotatedPoint = pointRotateRads(p, center, -element.angle as Radians);
if (nonRotatedPoint[0] < element.x && nonRotatedPoint[1] < element.y) { if (nonRotatedPoint[0] < element.x && nonRotatedPoint[1] < element.y) {
@@ -1110,35 +1089,34 @@ export const avoidRectangularCorner = (
export const snapToMid = ( export const snapToMid = (
element: ExcalidrawBindableElement, element: ExcalidrawBindableElement,
elementsMap: ElementsMap,
p: GlobalPoint, p: GlobalPoint,
tolerance: number = 0.05, tolerance: number = 0.05,
): GlobalPoint => { ): GlobalPoint => {
const { x, y, width, height, angle } = element; const { x, y, width, height, angle } = element;
const center = elementCenterPoint(element, elementsMap, -0.1, -0.1);
const center = elementCenterPoint(element, -0.1, -0.1);
const nonRotated = pointRotateRads(p, center, -angle as Radians); const nonRotated = pointRotateRads(p, center, -angle as Radians);
// snap-to-center point is adaptive to element size, but we don't want to go // snap-to-center point is adaptive to element size, but we don't want to go
// above and below certain px distance // above and below certain px distance
const verticalThrehsold = clamp(tolerance * height, 5, 80); const verticalThreshold = clamp(tolerance * height, 5, 80);
const horizontalThrehsold = clamp(tolerance * width, 5, 80); const horizontalThreshold = clamp(tolerance * width, 5, 80);
if ( if (
nonRotated[0] <= x + width / 2 && nonRotated[0] <= x + width / 2 &&
nonRotated[1] > center[1] - verticalThrehsold && nonRotated[1] > center[1] - verticalThreshold &&
nonRotated[1] < center[1] + verticalThrehsold nonRotated[1] < center[1] + verticalThreshold
) { ) {
// LEFT // LEFT
return pointRotateRads( return pointRotateRads<GlobalPoint>(
pointFrom(x - FIXED_BINDING_DISTANCE, center[1]), pointFrom(x - FIXED_BINDING_DISTANCE, center[1]),
center, center,
angle, angle,
); );
} else if ( } else if (
nonRotated[1] <= y + height / 2 && nonRotated[1] <= y + height / 2 &&
nonRotated[0] > center[0] - horizontalThrehsold && nonRotated[0] > center[0] - horizontalThreshold &&
nonRotated[0] < center[0] + horizontalThrehsold nonRotated[0] < center[0] + horizontalThreshold
) { ) {
// TOP // TOP
return pointRotateRads( return pointRotateRads(
@@ -1148,8 +1126,8 @@ export const snapToMid = (
); );
} else if ( } else if (
nonRotated[0] >= x + width / 2 && nonRotated[0] >= x + width / 2 &&
nonRotated[1] > center[1] - verticalThrehsold && nonRotated[1] > center[1] - verticalThreshold &&
nonRotated[1] < center[1] + verticalThrehsold nonRotated[1] < center[1] + verticalThreshold
) { ) {
// RIGHT // RIGHT
return pointRotateRads( return pointRotateRads(
@@ -1159,8 +1137,8 @@ export const snapToMid = (
); );
} else if ( } else if (
nonRotated[1] >= y + height / 2 && nonRotated[1] >= y + height / 2 &&
nonRotated[0] > center[0] - horizontalThrehsold && nonRotated[0] > center[0] - horizontalThreshold &&
nonRotated[0] < center[0] + horizontalThrehsold nonRotated[0] < center[0] + horizontalThreshold
) { ) {
// DOWN // DOWN
return pointRotateRads( return pointRotateRads(
@@ -1168,6 +1146,49 @@ export const snapToMid = (
center, center,
angle, angle,
); );
} else if (element.type === "diamond") {
const distance = FIXED_BINDING_DISTANCE;
const topLeft = pointFrom<GlobalPoint>(
x + width / 4 - distance,
y + height / 4 - distance,
);
const topRight = pointFrom<GlobalPoint>(
x + (3 * width) / 4 + distance,
y + height / 4 - distance,
);
const bottomLeft = pointFrom<GlobalPoint>(
x + width / 4 - distance,
y + (3 * height) / 4 + distance,
);
const bottomRight = pointFrom<GlobalPoint>(
x + (3 * width) / 4 + distance,
y + (3 * height) / 4 + distance,
);
if (
pointDistance(topLeft, nonRotated) <
Math.max(horizontalThreshold, verticalThreshold)
) {
return pointRotateRads(topLeft, center, angle);
}
if (
pointDistance(topRight, nonRotated) <
Math.max(horizontalThreshold, verticalThreshold)
) {
return pointRotateRads(topRight, center, angle);
}
if (
pointDistance(bottomLeft, nonRotated) <
Math.max(horizontalThreshold, verticalThreshold)
) {
return pointRotateRads(bottomLeft, center, angle);
}
if (
pointDistance(bottomRight, nonRotated) <
Math.max(horizontalThreshold, verticalThreshold)
) {
return pointRotateRads(bottomRight, center, angle);
}
} }
return p; return p;
@@ -1199,8 +1220,9 @@ const updateBoundPoint = (
linearElement, linearElement,
bindableElement, bindableElement,
startOrEnd === "startBinding" ? "start" : "end", startOrEnd === "startBinding" ? "start" : "end",
elementsMap,
).fixedPoint; ).fixedPoint;
const globalMidPoint = elementCenterPoint(bindableElement); const globalMidPoint = elementCenterPoint(bindableElement, elementsMap);
const global = pointFrom<GlobalPoint>( const global = pointFrom<GlobalPoint>(
bindableElement.x + fixedPoint[0] * bindableElement.width, bindableElement.x + fixedPoint[0] * bindableElement.width,
bindableElement.y + fixedPoint[1] * bindableElement.height, bindableElement.y + fixedPoint[1] * bindableElement.height,
@@ -1226,6 +1248,7 @@ const updateBoundPoint = (
); );
const focusPointAbsolute = determineFocusPoint( const focusPointAbsolute = determineFocusPoint(
bindableElement, bindableElement,
elementsMap,
binding.focus, binding.focus,
adjacentPoint, adjacentPoint,
); );
@@ -1244,7 +1267,7 @@ const updateBoundPoint = (
elementsMap, elementsMap,
); );
const center = elementCenterPoint(bindableElement); const center = elementCenterPoint(bindableElement, elementsMap);
const interceptorLength = const interceptorLength =
pointDistance(adjacentPoint, edgePointAbsolute) + pointDistance(adjacentPoint, edgePointAbsolute) +
pointDistance(adjacentPoint, center) + pointDistance(adjacentPoint, center) +
@@ -1252,6 +1275,7 @@ const updateBoundPoint = (
const intersections = [ const intersections = [
...intersectElementWithLineSegment( ...intersectElementWithLineSegment(
bindableElement, bindableElement,
elementsMap,
lineSegment<GlobalPoint>( lineSegment<GlobalPoint>(
adjacentPoint, adjacentPoint,
pointFromVector( pointFromVector(
@@ -1302,6 +1326,7 @@ export const calculateFixedPointForElbowArrowBinding = (
linearElement: NonDeleted<ExcalidrawElbowArrowElement>, linearElement: NonDeleted<ExcalidrawElbowArrowElement>,
hoveredElement: ExcalidrawBindableElement, hoveredElement: ExcalidrawBindableElement,
startOrEnd: "start" | "end", startOrEnd: "start" | "end",
elementsMap: ElementsMap,
): { fixedPoint: FixedPoint } => { ): { fixedPoint: FixedPoint } => {
const bounds = [ const bounds = [
hoveredElement.x, hoveredElement.x,
@@ -1313,6 +1338,7 @@ export const calculateFixedPointForElbowArrowBinding = (
linearElement, linearElement,
hoveredElement, hoveredElement,
startOrEnd, startOrEnd,
elementsMap,
); );
const globalMidPoint = pointFrom( const globalMidPoint = pointFrom(
bounds[0] + (bounds[2] - bounds[0]) / 2, bounds[0] + (bounds[2] - bounds[0]) / 2,
@@ -1356,7 +1382,7 @@ const maybeCalculateNewGapWhenScaling = (
return { ...currentBinding, gap: newGap }; return { ...currentBinding, gap: newGap };
}; };
const getElligibleElementForBindingElement = ( const getEligibleElementForBindingElement = (
linearElement: NonDeleted<ExcalidrawLinearElement>, linearElement: NonDeleted<ExcalidrawLinearElement>,
startOrEnd: "start" | "end", startOrEnd: "start" | "end",
elementsMap: NonDeletedSceneElementsMap, elementsMap: NonDeletedSceneElementsMap,
@@ -1508,14 +1534,38 @@ export const bindingBorderTest = (
zoom?: AppState["zoom"], zoom?: AppState["zoom"],
fullShape?: boolean, fullShape?: boolean,
): boolean => { ): boolean => {
const p = pointFrom<GlobalPoint>(x, y);
const threshold = maxBindingGap(element, element.width, element.height, zoom); 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); // PERF: Run a cheap test to see if the binding element
return ( // is even close to the element
isPointOnShape(pointFrom(x, y), shape, threshold) || const bounds = [
(fullShape === true && x - threshold,
pointInsideBounds(pointFrom(x, y), aabbForElement(element))) 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 = ( export const maxBindingGap = (
@@ -1535,7 +1585,7 @@ export const maxBindingGap = (
// bigger bindable boundary for bigger elements // bigger bindable boundary for bigger elements
Math.min(0.25 * smallerDimension, 32), Math.min(0.25 * smallerDimension, 32),
// keep in sync with the zoomed highlight // keep in sync with the zoomed highlight
BINDING_HIGHLIGHT_THICKNESS / zoomValue + BINDING_HIGHLIGHT_OFFSET, BINDING_HIGHLIGHT_THICKNESS / zoomValue + FIXED_BINDING_DISTANCE,
); );
}; };
@@ -1546,12 +1596,13 @@ export const maxBindingGap = (
// of the element. // of the element.
const determineFocusDistance = ( const determineFocusDistance = (
element: ExcalidrawBindableElement, element: ExcalidrawBindableElement,
elementsMap: ElementsMap,
// Point on the line, in absolute coordinates // Point on the line, in absolute coordinates
a: GlobalPoint, a: GlobalPoint,
// Another point on the line, in absolute coordinates (closer to element) // Another point on the line, in absolute coordinates (closer to element)
b: GlobalPoint, b: GlobalPoint,
): number => { ): number => {
const center = elementCenterPoint(element); const center = elementCenterPoint(element, elementsMap);
if (pointsEqual(a, b)) { if (pointsEqual(a, b)) {
return 0; return 0;
@@ -1676,12 +1727,13 @@ const determineFocusDistance = (
const determineFocusPoint = ( const determineFocusPoint = (
element: ExcalidrawBindableElement, element: ExcalidrawBindableElement,
elementsMap: ElementsMap,
// The oriented, relative distance from the center of `element` of the // The oriented, relative distance from the center of `element` of the
// returned focusPoint // returned focusPoint
focus: number, focus: number,
adjacentPoint: GlobalPoint, adjacentPoint: GlobalPoint,
): GlobalPoint => { ): GlobalPoint => {
const center = elementCenterPoint(element); const center = elementCenterPoint(element, elementsMap);
if (focus === 0) { if (focus === 0) {
return center; return center;
@@ -2104,6 +2156,7 @@ export class BindableElement {
export const getGlobalFixedPointForBindableElement = ( export const getGlobalFixedPointForBindableElement = (
fixedPointRatio: [number, number], fixedPointRatio: [number, number],
element: ExcalidrawBindableElement, element: ExcalidrawBindableElement,
elementsMap: ElementsMap,
): GlobalPoint => { ): GlobalPoint => {
const [fixedX, fixedY] = normalizeFixedPoint(fixedPointRatio); const [fixedX, fixedY] = normalizeFixedPoint(fixedPointRatio);
@@ -2112,7 +2165,7 @@ export const getGlobalFixedPointForBindableElement = (
element.x + element.width * fixedX, element.x + element.width * fixedX,
element.y + element.height * fixedY, element.y + element.height * fixedY,
), ),
elementCenterPoint(element), elementCenterPoint(element, elementsMap),
element.angle, element.angle,
); );
}; };
@@ -2136,6 +2189,7 @@ export const getGlobalFixedPoints = (
? getGlobalFixedPointForBindableElement( ? getGlobalFixedPointForBindableElement(
arrow.startBinding.fixedPoint, arrow.startBinding.fixedPoint,
startElement as ExcalidrawBindableElement, startElement as ExcalidrawBindableElement,
elementsMap,
) )
: pointFrom<GlobalPoint>( : pointFrom<GlobalPoint>(
arrow.x + arrow.points[0][0], arrow.x + arrow.points[0][0],
@@ -2146,6 +2200,7 @@ export const getGlobalFixedPoints = (
? getGlobalFixedPointForBindableElement( ? getGlobalFixedPointForBindableElement(
arrow.endBinding.fixedPoint, arrow.endBinding.fixedPoint,
endElement as ExcalidrawBindableElement, endElement as ExcalidrawBindableElement,
elementsMap,
) )
: pointFrom<GlobalPoint>( : pointFrom<GlobalPoint>(
arrow.x + arrow.points[arrow.points.length - 1][0], arrow.x + arrow.points[arrow.points.length - 1][0],
+50 -18
View File
@@ -1,17 +1,17 @@
import rough from "roughjs/bin/rough"; import rough from "roughjs/bin/rough";
import { import {
rescalePoints,
arrayToMap, arrayToMap,
invariant, invariant,
rescalePoints,
sizeOf, sizeOf,
} from "@excalidraw/common"; } from "@excalidraw/common";
import { import {
degreesToRadians, degreesToRadians,
lineSegment, lineSegment,
pointFrom,
pointDistance, pointDistance,
pointFrom,
pointFromArray, pointFromArray,
pointRotateRads, pointRotateRads,
} from "@excalidraw/math"; } from "@excalidraw/math";
@@ -33,8 +33,8 @@ import type { AppState } from "@excalidraw/excalidraw/types";
import type { Mutable } from "@excalidraw/common/utility-types"; import type { Mutable } from "@excalidraw/common/utility-types";
import { ShapeCache } from "./ShapeCache";
import { generateRoughOptions } from "./Shape"; import { generateRoughOptions } from "./Shape";
import { ShapeCache } from "./ShapeCache";
import { LinearElementEditor } from "./linearElementEditor"; import { LinearElementEditor } from "./linearElementEditor";
import { getBoundTextElement, getContainerElement } from "./textElement"; import { getBoundTextElement, getContainerElement } from "./textElement";
import { import {
@@ -52,20 +52,20 @@ import {
deconstructRectanguloidElement, deconstructRectanguloidElement,
} from "./utils"; } from "./utils";
import type {
ExcalidrawElement,
ExcalidrawLinearElement,
Arrowhead,
ExcalidrawFreeDrawElement,
NonDeleted,
ExcalidrawTextElementWithContainer,
ElementsMap,
ExcalidrawRectanguloidElement,
ExcalidrawEllipseElement,
ElementsMapOrArray,
} from "./types";
import type { Drawable, Op } from "roughjs/bin/core"; import type { Drawable, Op } from "roughjs/bin/core";
import type { Point as RoughPoint } from "roughjs/bin/geometry"; import type { Point as RoughPoint } from "roughjs/bin/geometry";
import type {
Arrowhead,
ElementsMap,
ElementsMapOrArray,
ExcalidrawElement,
ExcalidrawEllipseElement,
ExcalidrawFreeDrawElement,
ExcalidrawLinearElement,
ExcalidrawRectanguloidElement,
ExcalidrawTextElementWithContainer,
NonDeleted,
} from "./types";
export type RectangleBox = { export type RectangleBox = {
x: number; x: number;
@@ -102,9 +102,23 @@ export class ElementBounds {
version: ExcalidrawElement["version"]; version: ExcalidrawElement["version"];
} }
>(); >();
private static nonRotatedBoundsCache = new WeakMap<
ExcalidrawElement,
{
bounds: Bounds;
version: ExcalidrawElement["version"];
}
>();
static getBounds(element: ExcalidrawElement, elementsMap: ElementsMap) { static getBounds(
const cachedBounds = ElementBounds.boundsCache.get(element); element: ExcalidrawElement,
elementsMap: ElementsMap,
nonRotated: boolean = false,
) {
const cachedBounds =
nonRotated && element.angle !== 0
? ElementBounds.nonRotatedBoundsCache.get(element)
: ElementBounds.boundsCache.get(element);
if ( if (
cachedBounds?.version && cachedBounds?.version &&
@@ -115,6 +129,23 @@ export class ElementBounds {
) { ) {
return cachedBounds.bounds; 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); const bounds = ElementBounds.calculateBounds(element, elementsMap);
ElementBounds.boundsCache.set(element, { ElementBounds.boundsCache.set(element, {
@@ -939,8 +970,9 @@ const getLinearElementRotatedBounds = (
export const getElementBounds = ( export const getElementBounds = (
element: ExcalidrawElement, element: ExcalidrawElement,
elementsMap: ElementsMap, elementsMap: ElementsMap,
nonRotated: boolean = false,
): Bounds => { ): Bounds => {
return ElementBounds.getBounds(element, elementsMap); return ElementBounds.getBounds(element, elementsMap, nonRotated);
}; };
export const getCommonBounds = ( export const getCommonBounds = (
+318 -132
View File
@@ -2,51 +2,60 @@ import { isTransparent, elementCenterPoint } from "@excalidraw/common";
import { import {
curveIntersectLineSegment, curveIntersectLineSegment,
isPointWithinBounds, isPointWithinBounds,
line,
lineSegment, lineSegment,
lineSegmentIntersectionPoints, lineSegmentIntersectionPoints,
pointFrom, pointFrom,
pointFromVector,
pointRotateRads, pointRotateRads,
pointsEqual, pointsEqual,
vectorFromPoint,
vectorNormalize,
vectorScale,
} from "@excalidraw/math"; } from "@excalidraw/math";
import { import {
ellipse, ellipse,
ellipseLineIntersectionPoints, ellipseSegmentInterceptPoints,
} from "@excalidraw/math/ellipse"; } from "@excalidraw/math/ellipse";
import { isPointInShape, isPointOnShape } from "@excalidraw/utils/collision"; import type { GlobalPoint, LineSegment, Radians } from "@excalidraw/math";
import { type GeometricShape, getPolygonShape } from "@excalidraw/utils/shape";
import type {
GlobalPoint,
LineSegment,
LocalPoint,
Polygon,
Radians,
} from "@excalidraw/math";
import type { FrameNameBounds } from "@excalidraw/excalidraw/types"; import type { FrameNameBounds } from "@excalidraw/excalidraw/types";
import { getBoundTextShape, isPathALoop } from "./shapes"; import { isPathALoop } from "./shapes";
import { getElementBounds } from "./bounds"; import {
type Bounds,
doBoundsIntersect,
getCenterForBounds,
getElementBounds,
} from "./bounds";
import { import {
hasBoundTextElement, hasBoundTextElement,
isFreeDrawElement,
isIframeLikeElement, isIframeLikeElement,
isImageElement, isImageElement,
isLinearElement,
isTextElement, isTextElement,
} from "./typeChecks"; } from "./typeChecks";
import { import {
deconstructDiamondElement, deconstructDiamondElement,
deconstructLinearOrFreeDrawElement,
deconstructRectanguloidElement, deconstructRectanguloidElement,
} from "./utils"; } from "./utils";
import { getBoundTextElement } from "./textElement";
import { LinearElementEditor } from "./linearElementEditor";
import { distanceToElement } from "./distance";
import type { import type {
ElementsMap, ElementsMap,
ExcalidrawDiamondElement, ExcalidrawDiamondElement,
ExcalidrawElement, ExcalidrawElement,
ExcalidrawEllipseElement, ExcalidrawEllipseElement,
ExcalidrawRectangleElement, ExcalidrawFreeDrawElement,
ExcalidrawLinearElement,
ExcalidrawRectanguloidElement, ExcalidrawRectanguloidElement,
} from "./types"; } from "./types";
@@ -72,45 +81,64 @@ export const shouldTestInside = (element: ExcalidrawElement) => {
return isDraggableFromInside || isImageElement(element); return isDraggableFromInside || isImageElement(element);
}; };
export type HitTestArgs<Point extends GlobalPoint | LocalPoint> = { export type HitTestArgs = {
x: number; point: GlobalPoint;
y: number;
element: ExcalidrawElement; element: ExcalidrawElement;
shape: GeometricShape<Point>; threshold: number;
threshold?: number; elementsMap: ElementsMap;
frameNameBound?: FrameNameBounds | null; frameNameBound?: FrameNameBounds | null;
}; };
export const hitElementItself = <Point extends GlobalPoint | LocalPoint>({ export const hitElementItself = ({
x, point,
y,
element, element,
shape, threshold,
threshold = 10, elementsMap,
frameNameBound = null, frameNameBound = null,
}: HitTestArgs<Point>) => { }: HitTestArgs) => {
let hit = shouldTestInside(element) // Hit test against a frame's name
? // Since `inShape` tests STRICTLY againt the insides of a shape const hitFrameName = frameNameBound
// we would need `onShape` as well to include the "borders" ? isPointWithinBounds(
isPointInShape(pointFrom(x, y), shape) || pointFrom(frameNameBound.x - threshold, frameNameBound.y - threshold),
isPointOnShape(pointFrom(x, y), shape, threshold) point,
: isPointOnShape(pointFrom(x, y), shape, threshold); pointFrom(
frameNameBound.x + frameNameBound.width + threshold,
frameNameBound.y + frameNameBound.height + threshold,
),
)
: false;
// hit test against a frame's name // Hit test against the extended, rotated bounding box of the element first
if (!hit && frameNameBound) { const bounds = getElementBounds(element, elementsMap, true);
hit = isPointInShape(pointFrom(x, y), { const hitBounds = isPointWithinBounds(
type: "polygon", pointFrom(bounds[0] - threshold, bounds[1] - threshold),
data: getPolygonShape(frameNameBound as ExcalidrawRectangleElement) pointRotateRads(
.data as Polygon<Point>, 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 = ( export const hitElementBoundingBox = (
x: number, point: GlobalPoint,
y: number,
element: ExcalidrawElement, element: ExcalidrawElement,
elementsMap: ElementsMap, elementsMap: ElementsMap,
tolerance = 0, tolerance = 0,
@@ -120,37 +148,42 @@ export const hitElementBoundingBox = (
y1 -= tolerance; y1 -= tolerance;
x2 += tolerance; x2 += tolerance;
y2 += tolerance; y2 += tolerance;
return isPointWithinBounds( return isPointWithinBounds(pointFrom(x1, y1), point, pointFrom(x2, y2));
pointFrom(x1, y1),
pointFrom(x, y),
pointFrom(x2, y2),
);
}; };
export const hitElementBoundingBoxOnly = < export const hitElementBoundingBoxOnly = (
Point extends GlobalPoint | LocalPoint, hitArgs: HitTestArgs,
>(
hitArgs: HitTestArgs<Point>,
elementsMap: ElementsMap, elementsMap: ElementsMap,
) => { ) =>
return ( !hitElementItself(hitArgs) &&
!hitElementItself(hitArgs) && // bound text is considered part of the element (even if it's outside the bounding box)
// bound text is considered part of the element (even if it's outside the bounding box) !hitElementBoundText(hitArgs.point, hitArgs.element, elementsMap) &&
!hitElementBoundText( hitElementBoundingBox(hitArgs.point, hitArgs.element, elementsMap);
hitArgs.x,
hitArgs.y,
getBoundTextShape(hitArgs.element, elementsMap),
) &&
hitElementBoundingBox(hitArgs.x, hitArgs.y, hitArgs.element, elementsMap)
);
};
export const hitElementBoundText = <Point extends GlobalPoint | LocalPoint>( export const hitElementBoundText = (
x: number, point: GlobalPoint,
y: number, element: ExcalidrawElement,
textShape: GeometricShape<Point> | null, elementsMap: ElementsMap,
): boolean => { ): 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 +196,26 @@ export const hitElementBoundText = <Point extends GlobalPoint | LocalPoint>(
*/ */
export const intersectElementWithLineSegment = ( export const intersectElementWithLineSegment = (
element: ExcalidrawElement, element: ExcalidrawElement,
elementsMap: ElementsMap,
line: LineSegment<GlobalPoint>, line: LineSegment<GlobalPoint>,
offset: number = 0, offset: number = 0,
onlyFirst = false,
): GlobalPoint[] => { ): 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) { switch (element.type) {
case "rectangle": case "rectangle":
case "image": case "image":
@@ -173,23 +223,88 @@ export const intersectElementWithLineSegment = (
case "iframe": case "iframe":
case "embeddable": case "embeddable":
case "frame": case "frame":
case "selection":
case "magicframe": case "magicframe":
return intersectRectanguloidWithLineSegment(element, line, offset); return intersectRectanguloidWithLineSegment(
element,
elementsMap,
line,
offset,
onlyFirst,
);
case "diamond": case "diamond":
return intersectDiamondWithLineSegment(element, line, offset); return intersectDiamondWithLineSegment(
element,
elementsMap,
line,
offset,
onlyFirst,
);
case "ellipse": case "ellipse":
return intersectEllipseWithLineSegment(element, line, offset); return intersectEllipseWithLineSegment(
default: element,
throw new Error(`Unimplemented element type '${element.type}'`); elementsMap,
line,
offset,
);
case "line":
case "freedraw":
case "arrow":
return intersectLinearOrFreeDrawWithLineSegment(
element,
elementsMap,
line,
onlyFirst,
);
} }
}; };
const intersectLinearOrFreeDrawWithLineSegment = (
element: ExcalidrawLinearElement | ExcalidrawFreeDrawElement,
elementsMap: ElementsMap,
segment: LineSegment<GlobalPoint>,
onlyFirst = false,
): GlobalPoint[] => {
const [lines, curves] = deconstructLinearOrFreeDrawElement(
element,
elementsMap,
);
const intersections = [];
for (const l of lines) {
const intersection = lineSegmentIntersectionPoints(l, segment);
if (intersection) {
intersections.push(intersection);
if (onlyFirst) {
return intersections;
}
}
}
for (const c of curves) {
const hits = curveIntersectLineSegment(c, segment);
if (hits.length > 0) {
intersections.push(...hits);
if (onlyFirst) {
return intersections;
}
}
}
return intersections;
};
const intersectRectanguloidWithLineSegment = ( const intersectRectanguloidWithLineSegment = (
element: ExcalidrawRectanguloidElement, element: ExcalidrawRectanguloidElement,
elementsMap: ElementsMap,
l: LineSegment<GlobalPoint>, l: LineSegment<GlobalPoint>,
offset: number = 0, offset: number = 0,
onlyFirst = false,
): GlobalPoint[] => { ): GlobalPoint[] => {
const center = elementCenterPoint(element); const center = elementCenterPoint(element, elementsMap);
// To emulate a rotated rectangle we rotate the point in the inverse angle // To emulate a rotated rectangle we rotate the point in the inverse angle
// instead. It's all the same distance-wise. // instead. It's all the same distance-wise.
const rotatedA = pointRotateRads<GlobalPoint>( const rotatedA = pointRotateRads<GlobalPoint>(
@@ -206,34 +321,37 @@ const intersectRectanguloidWithLineSegment = (
// Get the element's building components we can test against // Get the element's building components we can test against
const [sides, corners] = deconstructRectanguloidElement(element, offset); const [sides, corners] = deconstructRectanguloidElement(element, offset);
return ( const intersections: GlobalPoint[] = [];
// Test intersection against the sides, keep only the valid
// intersection points and rotate them back to scene space for (const s of sides) {
sides const intersection = lineSegmentIntersectionPoints(
.map((s) => lineSegment(rotatedA, rotatedB),
lineSegmentIntersectionPoints( s,
lineSegment<GlobalPoint>(rotatedA, rotatedB), );
s, if (intersection) {
), intersections.push(pointRotateRads(intersection, center, element.angle));
)
.filter((x) => x != null) if (onlyFirst) {
.map((j) => pointRotateRads<GlobalPoint>(j!, center, element.angle)) return intersections;
// 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 for (const t of corners) {
.flatMap((t) => const hits = curveIntersectLineSegment(t, lineSegment(rotatedA, rotatedB));
curveIntersectLineSegment(t, lineSegment(rotatedA, rotatedB)),
) if (hits.length > 0) {
.filter((i) => i != null) for (const j of hits) {
.map((j) => pointRotateRads(j, center, element.angle)), intersections.push(pointRotateRads(j, center, element.angle));
) }
// Remove duplicates
.filter( if (onlyFirst) {
(p, idx, points) => points.findIndex((d) => pointsEqual(p, d)) === idx, return intersections;
) }
); }
}
return intersections;
}; };
/** /**
@@ -245,43 +363,51 @@ const intersectRectanguloidWithLineSegment = (
*/ */
const intersectDiamondWithLineSegment = ( const intersectDiamondWithLineSegment = (
element: ExcalidrawDiamondElement, element: ExcalidrawDiamondElement,
elementsMap: ElementsMap,
l: LineSegment<GlobalPoint>, l: LineSegment<GlobalPoint>,
offset: number = 0, offset: number = 0,
onlyFirst = false,
): GlobalPoint[] => { ): GlobalPoint[] => {
const center = elementCenterPoint(element); const center = elementCenterPoint(element, elementsMap);
// Rotate the point to the inverse direction to simulate the rotated diamond // Rotate the point to the inverse direction to simulate the rotated diamond
// points. It's all the same distance-wise. // points. It's all the same distance-wise.
const rotatedA = pointRotateRads(l[0], center, -element.angle as Radians); const rotatedA = pointRotateRads(l[0], center, -element.angle as Radians);
const rotatedB = pointRotateRads(l[1], center, -element.angle as Radians); const rotatedB = pointRotateRads(l[1], center, -element.angle as Radians);
const [sides, curves] = deconstructDiamondElement(element, offset); const [sides, corners] = deconstructDiamondElement(element, offset);
return ( const intersections: GlobalPoint[] = [];
sides
.map((s) => for (const s of sides) {
lineSegmentIntersectionPoints( const intersection = lineSegmentIntersectionPoints(
lineSegment<GlobalPoint>(rotatedA, rotatedB), lineSegment(rotatedA, rotatedB),
s, s,
), );
) if (intersection) {
.filter((p): p is GlobalPoint => p != null) intersections.push(pointRotateRads(intersection, center, element.angle));
// Rotate back intersection points
.map((p) => pointRotateRads<GlobalPoint>(p!, center, element.angle)) if (onlyFirst) {
.concat( return intersections;
curves }
.flatMap((p) => }
curveIntersectLineSegment(p, lineSegment(rotatedA, rotatedB)), }
)
.filter((p) => p != null) for (const t of corners) {
// Rotate back intersection points const hits = curveIntersectLineSegment(t, lineSegment(rotatedA, rotatedB));
.map((p) => pointRotateRads(p, center, element.angle)),
) if (hits.length > 0) {
// Remove duplicates for (const j of hits) {
.filter( intersections.push(pointRotateRads(j, center, element.angle));
(p, idx, points) => points.findIndex((d) => pointsEqual(p, d)) === idx, }
)
); if (onlyFirst) {
return intersections;
}
}
}
return intersections;
}; };
/** /**
@@ -293,16 +419,76 @@ const intersectDiamondWithLineSegment = (
*/ */
const intersectEllipseWithLineSegment = ( const intersectEllipseWithLineSegment = (
element: ExcalidrawEllipseElement, element: ExcalidrawEllipseElement,
elementsMap: ElementsMap,
l: LineSegment<GlobalPoint>, l: LineSegment<GlobalPoint>,
offset: number = 0, offset: number = 0,
): GlobalPoint[] => { ): GlobalPoint[] => {
const center = elementCenterPoint(element); const center = elementCenterPoint(element, elementsMap);
const rotatedA = pointRotateRads(l[0], center, -element.angle as Radians); const rotatedA = pointRotateRads(l[0], center, -element.angle as Radians);
const rotatedB = pointRotateRads(l[1], 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), ellipse(center, element.width / 2 + offset, element.height / 2 + offset),
line(rotatedA, rotatedB), lineSegment(rotatedA, rotatedB),
).map((p) => pointRotateRads(p, center, element.angle)); ).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;
};
+2 -1
View File
@@ -34,6 +34,7 @@ export const MINIMAL_CROP_SIZE = 10;
export const cropElement = ( export const cropElement = (
element: ExcalidrawImageElement, element: ExcalidrawImageElement,
elementsMap: ElementsMap,
transformHandle: TransformHandleType, transformHandle: TransformHandleType,
naturalWidth: number, naturalWidth: number,
naturalHeight: number, naturalHeight: number,
@@ -63,7 +64,7 @@ export const cropElement = (
const rotatedPointer = pointRotateRads( const rotatedPointer = pointRotateRads(
pointFrom(pointerX, pointerY), pointFrom(pointerX, pointerY),
elementCenterPoint(element), elementCenterPoint(element, elementsMap),
-element.angle as Radians, -element.angle as Radians,
); );
File diff suppressed because it is too large Load Diff
+37 -9
View File
@@ -12,21 +12,27 @@ import type { GlobalPoint, Radians } from "@excalidraw/math";
import { import {
deconstructDiamondElement, deconstructDiamondElement,
deconstructLinearOrFreeDrawElement,
deconstructRectanguloidElement, deconstructRectanguloidElement,
} from "./utils"; } from "./utils";
import type { import type {
ExcalidrawBindableElement, ElementsMap,
ExcalidrawDiamondElement, ExcalidrawDiamondElement,
ExcalidrawElement,
ExcalidrawEllipseElement, ExcalidrawEllipseElement,
ExcalidrawFreeDrawElement,
ExcalidrawLinearElement,
ExcalidrawRectanguloidElement, ExcalidrawRectanguloidElement,
} from "./types"; } from "./types";
export const distanceToBindableElement = ( export const distanceToElement = (
element: ExcalidrawBindableElement, element: ExcalidrawElement,
elementsMap: ElementsMap,
p: GlobalPoint, p: GlobalPoint,
): number => { ): number => {
switch (element.type) { switch (element.type) {
case "selection":
case "rectangle": case "rectangle":
case "image": case "image":
case "text": case "text":
@@ -34,11 +40,15 @@ export const distanceToBindableElement = (
case "embeddable": case "embeddable":
case "frame": case "frame":
case "magicframe": case "magicframe":
return distanceToRectanguloidElement(element, p); return distanceToRectanguloidElement(element, elementsMap, p);
case "diamond": case "diamond":
return distanceToDiamondElement(element, p); return distanceToDiamondElement(element, elementsMap, p);
case "ellipse": case "ellipse":
return distanceToEllipseElement(element, p); return distanceToEllipseElement(element, elementsMap, p);
case "line":
case "arrow":
case "freedraw":
return distanceToLinearOrFreeDraElement(element, elementsMap, p);
} }
}; };
@@ -52,9 +62,10 @@ export const distanceToBindableElement = (
*/ */
const distanceToRectanguloidElement = ( const distanceToRectanguloidElement = (
element: ExcalidrawRectanguloidElement, element: ExcalidrawRectanguloidElement,
elementsMap: ElementsMap,
p: GlobalPoint, p: GlobalPoint,
) => { ) => {
const center = elementCenterPoint(element); const center = elementCenterPoint(element, elementsMap);
// To emulate a rotated rectangle we rotate the point in the inverse angle // To emulate a rotated rectangle we rotate the point in the inverse angle
// instead. It's all the same distance-wise. // instead. It's all the same distance-wise.
const rotatedPoint = pointRotateRads(p, center, -element.angle as Radians); const rotatedPoint = pointRotateRads(p, center, -element.angle as Radians);
@@ -80,9 +91,10 @@ const distanceToRectanguloidElement = (
*/ */
const distanceToDiamondElement = ( const distanceToDiamondElement = (
element: ExcalidrawDiamondElement, element: ExcalidrawDiamondElement,
elementsMap: ElementsMap,
p: GlobalPoint, p: GlobalPoint,
): number => { ): number => {
const center = elementCenterPoint(element); const center = elementCenterPoint(element, elementsMap);
// Rotate the point to the inverse direction to simulate the rotated diamond // Rotate the point to the inverse direction to simulate the rotated diamond
// points. It's all the same distance-wise. // points. It's all the same distance-wise.
@@ -108,12 +120,28 @@ const distanceToDiamondElement = (
*/ */
const distanceToEllipseElement = ( const distanceToEllipseElement = (
element: ExcalidrawEllipseElement, element: ExcalidrawEllipseElement,
elementsMap: ElementsMap,
p: GlobalPoint, p: GlobalPoint,
): number => { ): number => {
const center = elementCenterPoint(element); const center = elementCenterPoint(element, elementsMap);
return ellipseDistanceFromPoint( return ellipseDistanceFromPoint(
// Instead of rotating the ellipse, rotate the point to the inverse angle // Instead of rotating the ellipse, rotate the point to the inverse angle
pointRotateRads(p, center, -element.angle as Radians), pointRotateRads(p, center, -element.angle as Radians),
ellipse(center, element.width / 2, element.height / 2), ellipse(center, element.width / 2, element.height / 2),
); );
}; };
const distanceToLinearOrFreeDraElement = (
element: ExcalidrawLinearElement | ExcalidrawFreeDrawElement,
elementsMap: ElementsMap,
p: GlobalPoint,
) => {
const [lines, curves] = deconstructLinearOrFreeDrawElement(
element,
elementsMap,
);
return Math.min(
...lines.map((s) => distanceToLineSegment(p, s)),
...curves.map((a) => curvePointDistance(a, p)),
);
};
+1 -1
View File
@@ -26,7 +26,7 @@ import {
isTextElement, isTextElement,
} from "./typeChecks"; } from "./typeChecks";
import type Scene from "./Scene"; import type { Scene } from "./Scene";
import type { Bounds } from "./bounds"; import type { Bounds } from "./bounds";
import type { ExcalidrawElement } from "./types"; import type { ExcalidrawElement } from "./types";
+42 -56
View File
@@ -29,10 +29,9 @@ import {
FIXED_BINDING_DISTANCE, FIXED_BINDING_DISTANCE,
getHeadingForElbowArrowSnap, getHeadingForElbowArrowSnap,
getGlobalFixedPointForBindableElement, getGlobalFixedPointForBindableElement,
snapToMid,
getHoveredElementForBinding, getHoveredElementForBinding,
} from "./binding"; } from "./binding";
import { distanceToBindableElement } from "./distance"; import { distanceToElement } from "./distance";
import { import {
compareHeading, compareHeading,
flipHeading, flipHeading,
@@ -898,50 +897,6 @@ export const updateElbowArrowPoints = (
return { points: updates.points ?? arrow.points }; 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) { if (!import.meta.env.PROD) {
invariant( invariant(
!updates.points || updates.points.length >= 2, !updates.points || updates.points.length >= 2,
@@ -974,6 +929,25 @@ export const updateElbowArrowPoints = (
), ),
"Elbow arrow segments must be either horizontal or vertical", "Elbow arrow segments must be either horizontal or vertical",
); );
invariant(
updates.fixedSegments?.find(
(segment) =>
segment.index === 1 &&
pointsEqual(segment.start, (updates.points ?? arrow.points)[0]),
) == null &&
updates.fixedSegments?.find(
(segment) =>
segment.index === (updates.points ?? arrow.points).length - 1 &&
pointsEqual(
segment.end,
(updates.points ?? arrow.points)[
(updates.points ?? arrow.points).length - 1
],
),
) == null,
"The first and last segments cannot be fixed",
);
} }
const fixedSegments = updates.fixedSegments ?? arrow.fixedSegments ?? []; const fixedSegments = updates.fixedSegments ?? arrow.fixedSegments ?? [];
@@ -1254,6 +1228,7 @@ const getElbowArrowData = (
arrow.startBinding?.fixedPoint, arrow.startBinding?.fixedPoint,
origStartGlobalPoint, origStartGlobalPoint,
hoveredStartElement, hoveredStartElement,
elementsMap,
options?.isDragging, options?.isDragging,
); );
const endGlobalPoint = getGlobalPoint( const endGlobalPoint = getGlobalPoint(
@@ -1267,6 +1242,7 @@ const getElbowArrowData = (
arrow.endBinding?.fixedPoint, arrow.endBinding?.fixedPoint,
origEndGlobalPoint, origEndGlobalPoint,
hoveredEndElement, hoveredEndElement,
elementsMap,
options?.isDragging, options?.isDragging,
); );
const startHeading = getBindPointHeading( const startHeading = getBindPointHeading(
@@ -1274,12 +1250,14 @@ const getElbowArrowData = (
endGlobalPoint, endGlobalPoint,
hoveredStartElement, hoveredStartElement,
origStartGlobalPoint, origStartGlobalPoint,
elementsMap,
); );
const endHeading = getBindPointHeading( const endHeading = getBindPointHeading(
endGlobalPoint, endGlobalPoint,
startGlobalPoint, startGlobalPoint,
hoveredEndElement, hoveredEndElement,
origEndGlobalPoint, origEndGlobalPoint,
elementsMap,
); );
const startPointBounds = [ const startPointBounds = [
startGlobalPoint[0] - 2, startGlobalPoint[0] - 2,
@@ -1296,6 +1274,7 @@ const getElbowArrowData = (
const startElementBounds = hoveredStartElement const startElementBounds = hoveredStartElement
? aabbForElement( ? aabbForElement(
hoveredStartElement, hoveredStartElement,
elementsMap,
offsetFromHeading( offsetFromHeading(
startHeading, startHeading,
arrow.startArrowhead arrow.startArrowhead
@@ -1308,6 +1287,7 @@ const getElbowArrowData = (
const endElementBounds = hoveredEndElement const endElementBounds = hoveredEndElement
? aabbForElement( ? aabbForElement(
hoveredEndElement, hoveredEndElement,
elementsMap,
offsetFromHeading( offsetFromHeading(
endHeading, endHeading,
arrow.endArrowhead arrow.endArrowhead
@@ -1323,6 +1303,7 @@ const getElbowArrowData = (
hoveredEndElement hoveredEndElement
? aabbForElement( ? aabbForElement(
hoveredEndElement, hoveredEndElement,
elementsMap,
offsetFromHeading(endHeading, BASE_PADDING, BASE_PADDING), offsetFromHeading(endHeading, BASE_PADDING, BASE_PADDING),
) )
: endPointBounds, : endPointBounds,
@@ -1332,6 +1313,7 @@ const getElbowArrowData = (
hoveredStartElement hoveredStartElement
? aabbForElement( ? aabbForElement(
hoveredStartElement, hoveredStartElement,
elementsMap,
offsetFromHeading(startHeading, BASE_PADDING, BASE_PADDING), offsetFromHeading(startHeading, BASE_PADDING, BASE_PADDING),
) )
: startPointBounds, : startPointBounds,
@@ -1378,8 +1360,8 @@ const getElbowArrowData = (
BASE_PADDING, BASE_PADDING,
), ),
boundsOverlap, boundsOverlap,
hoveredStartElement && aabbForElement(hoveredStartElement), hoveredStartElement && aabbForElement(hoveredStartElement, elementsMap),
hoveredEndElement && aabbForElement(hoveredEndElement), hoveredEndElement && aabbForElement(hoveredEndElement, elementsMap),
); );
const startDonglePosition = getDonglePosition( const startDonglePosition = getDonglePosition(
dynamicAABBs[0], dynamicAABBs[0],
@@ -2210,34 +2192,35 @@ const getGlobalPoint = (
fixedPointRatio: [number, number] | undefined | null, fixedPointRatio: [number, number] | undefined | null,
initialPoint: GlobalPoint, initialPoint: GlobalPoint,
element?: ExcalidrawBindableElement | null, element?: ExcalidrawBindableElement | null,
elementsMap?: ElementsMap,
isDragging?: boolean, isDragging?: boolean,
): GlobalPoint => { ): GlobalPoint => {
if (isDragging) { if (isDragging) {
if (element) { if (element && elementsMap) {
const snapPoint = bindPointToSnapToElementOutline( return bindPointToSnapToElementOutline(
arrow, arrow,
element, element,
startOrEnd, startOrEnd,
elementsMap,
); );
return snapToMid(element, snapPoint);
} }
return initialPoint; return initialPoint;
} }
if (element) { if (element && elementsMap) {
const fixedGlobalPoint = getGlobalFixedPointForBindableElement( const fixedGlobalPoint = getGlobalFixedPointForBindableElement(
fixedPointRatio || [0, 0], fixedPointRatio || [0, 0],
element, element,
elementsMap,
); );
// NOTE: Resize scales the binding position point too, so we need to update it // NOTE: Resize scales the binding position point too, so we need to update it
return Math.abs( return Math.abs(
distanceToBindableElement(element, fixedGlobalPoint) - distanceToElement(element, elementsMap, fixedGlobalPoint) -
FIXED_BINDING_DISTANCE, FIXED_BINDING_DISTANCE,
) > 0.01 ) > 0.01
? bindPointToSnapToElementOutline(arrow, element, startOrEnd) ? bindPointToSnapToElementOutline(arrow, element, startOrEnd, elementsMap)
: fixedGlobalPoint; : fixedGlobalPoint;
} }
@@ -2249,6 +2232,7 @@ const getBindPointHeading = (
otherPoint: GlobalPoint, otherPoint: GlobalPoint,
hoveredElement: ExcalidrawBindableElement | null | undefined, hoveredElement: ExcalidrawBindableElement | null | undefined,
origPoint: GlobalPoint, origPoint: GlobalPoint,
elementsMap: ElementsMap,
): Heading => ): Heading =>
getHeadingForElbowArrowSnap( getHeadingForElbowArrowSnap(
p, p,
@@ -2257,7 +2241,8 @@ const getBindPointHeading = (
hoveredElement && hoveredElement &&
aabbForElement( aabbForElement(
hoveredElement, hoveredElement,
Array(4).fill(distanceToBindableElement(hoveredElement, p)) as [ elementsMap,
Array(4).fill(distanceToElement(hoveredElement, elementsMap, p)) as [
number, number,
number, number,
number, number,
@@ -2265,6 +2250,7 @@ const getBindPointHeading = (
], ],
), ),
origPoint, origPoint,
elementsMap,
); );
const getHoveredElement = ( const getHoveredElement = (
+8
View File
@@ -33,6 +33,8 @@ const RE_GH_GIST = /^https:\/\/gist\.github\.com\/([\w_-]+)\/([\w_-]+)/;
const RE_GH_GIST_EMBED = const RE_GH_GIST_EMBED =
/^<script[\s\S]*?\ssrc=["'](https:\/\/gist\.github\.com\/.*?)\.js["']/i; /^<script[\s\S]*?\ssrc=["'](https:\/\/gist\.github\.com\/.*?)\.js["']/i;
const RE_MSFORMS = /^(?:https?:\/\/)?forms\.microsoft\.com\//;
// not anchored to start to allow <blockquote> twitter embeds // not anchored to start to allow <blockquote> twitter embeds
const RE_TWITTER = const RE_TWITTER =
/(?:https?:\/\/)?(?:(?:w){3}\.)?(?:twitter|x)\.com\/[^/]+\/status\/(\d+)/; /(?:https?:\/\/)?(?:(?:w){3}\.)?(?:twitter|x)\.com\/[^/]+\/status\/(\d+)/;
@@ -69,6 +71,7 @@ const ALLOWED_DOMAINS = new Set([
"val.town", "val.town",
"giphy.com", "giphy.com",
"reddit.com", "reddit.com",
"forms.microsoft.com",
]); ]);
const ALLOW_SAME_ORIGIN = new Set([ const ALLOW_SAME_ORIGIN = new Set([
@@ -82,6 +85,7 @@ const ALLOW_SAME_ORIGIN = new Set([
"*.simplepdf.eu", "*.simplepdf.eu",
"stackblitz.com", "stackblitz.com",
"reddit.com", "reddit.com",
"forms.microsoft.com",
]); ]);
export const createSrcDoc = (body: string) => { export const createSrcDoc = (body: string) => {
@@ -206,6 +210,10 @@ export const getEmbedLink = (
}; };
} }
if (RE_MSFORMS.test(link) && !link.includes("embed=true")) {
link += link.includes("?") ? "&embed=true" : "?embed=true";
}
if (RE_TWITTER.test(link)) { if (RE_TWITTER.test(link)) {
const postId = link.match(RE_TWITTER)![1]; const postId = link.match(RE_TWITTER)![1];
// the embed srcdoc still supports twitter.com domain only. // the embed srcdoc still supports twitter.com domain only.
+18 -11
View File
@@ -39,7 +39,7 @@ import {
type OrderedExcalidrawElement, type OrderedExcalidrawElement,
} from "./types"; } from "./types";
import type Scene from "./Scene"; import type { Scene } from "./Scene";
type LinkDirection = "up" | "right" | "down" | "left"; type LinkDirection = "up" | "right" | "down" | "left";
@@ -95,10 +95,11 @@ const getNodeRelatives = (
type === "predecessors" ? el.points[el.points.length - 1] : [0, 0] type === "predecessors" ? el.points[el.points.length - 1] : [0, 0]
) as Readonly<LocalPoint>; ) as Readonly<LocalPoint>;
const heading = headingForPointFromElement(node, aabbForElement(node), [ const heading = headingForPointFromElement(
edgePoint[0] + el.x, node,
edgePoint[1] + el.y, aabbForElement(node, elementsMap),
] as Readonly<GlobalPoint>); [edgePoint[0] + el.x, edgePoint[1] + el.y] as Readonly<GlobalPoint>,
);
acc.push({ acc.push({
relative, relative,
@@ -462,12 +463,18 @@ const createBindingArrow = (
bindingArrow as OrderedExcalidrawElement, bindingArrow as OrderedExcalidrawElement,
); );
LinearElementEditor.movePoints(bindingArrow, scene, [ LinearElementEditor.movePoints(
{ bindingArrow,
index: 1, scene,
point: bindingArrow.points[1], new Map([
}, [
]); 1,
{
point: bindingArrow.points[1],
},
],
]),
);
const update = updateElbowArrowPoints( const update = updateElbowArrowPoints(
bindingArrow, bindingArrow,
+35 -9
View File
@@ -2,7 +2,7 @@ import { generateNKeysBetween } from "fractional-indexing";
import { arrayToMap } from "@excalidraw/common"; import { arrayToMap } from "@excalidraw/common";
import { mutateElement } from "./mutateElement"; import { mutateElement, newElementWith } from "./mutateElement";
import { getBoundTextElement } from "./textElement"; import { getBoundTextElement } from "./textElement";
import { hasBoundTextElement } from "./typeChecks"; import { hasBoundTextElement } from "./typeChecks";
@@ -11,6 +11,7 @@ import type {
ExcalidrawElement, ExcalidrawElement,
FractionalIndex, FractionalIndex,
OrderedExcalidrawElement, OrderedExcalidrawElement,
SceneElementsMap,
} from "./types"; } from "./types";
export class InvalidFractionalIndexError extends Error { export class InvalidFractionalIndexError extends Error {
@@ -161,9 +162,15 @@ export const syncMovedIndices = (
// try generatating indices, throws on invalid movedElements // try generatating indices, throws on invalid movedElements
const elementsUpdates = generateIndices(elements, indicesGroups); const elementsUpdates = generateIndices(elements, indicesGroups);
const elementsCandidates = elements.map((x) => const elementsCandidates = elements.map((x) => {
elementsUpdates.has(x) ? { ...x, ...elementsUpdates.get(x) } : 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 // ensure next indices are valid before mutation, throws on invalid ones
validateFractionalIndices( validateFractionalIndices(
@@ -177,8 +184,8 @@ export const syncMovedIndices = (
); );
// split mutation so we don't end up in an incosistent state // split mutation so we don't end up in an incosistent state
for (const [element, update] of elementsUpdates) { for (const [element, { index }] of elementsUpdates) {
mutateElement(element, elementsMap, update); mutateElement(element, elementsMap, { index });
} }
} catch (e) { } catch (e) {
// fallback to default sync // 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. * 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 indicesGroups = getInvalidIndicesGroups(elements);
const elementsUpdates = generateIndices(elements, indicesGroups); const elementsUpdates = generateIndices(elements, indicesGroups);
for (const [element, update] of elementsUpdates) { for (const [element, { index }] of elementsUpdates) {
mutateElement(element, elementsMap, update); mutateElement(element, elementsMap, { index });
} }
return elements as OrderedExcalidrawElement[]; 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. * Get contiguous groups of indices of passed moved elements.
* *
+9 -6
View File
@@ -905,13 +905,16 @@ export const shouldApplyFrameClip = (
return false; return false;
}; };
export const getFrameLikeTitle = (element: ExcalidrawFrameLikeElement) => { const DEFAULT_FRAME_NAME = "Frame";
const DEFAULT_AI_FRAME_NAME = "AI Frame";
export const getDefaultFrameName = (element: ExcalidrawFrameLikeElement) => {
// TODO name frames "AI" only if specific to AI frames // TODO name frames "AI" only if specific to AI frames
return element.name === null return isFrameElement(element) ? DEFAULT_FRAME_NAME : DEFAULT_AI_FRAME_NAME;
? isFrameElement(element) };
? "Frame"
: "AI Frame" export const getFrameLikeTitle = (element: ExcalidrawFrameLikeElement) => {
: element.name; return element.name === null ? getDefaultFrameName(element) : element.name;
}; };
export const getElementsOverlappingFrame = ( export const getElementsOverlappingFrame = (
+50 -5
View File
@@ -1,3 +1,5 @@
import { toIterable } from "@excalidraw/common";
import { isInvisiblySmallElement } from "./sizeHelpers"; import { isInvisiblySmallElement } from "./sizeHelpers";
import { isLinearElementType } from "./typeChecks"; import { isLinearElementType } from "./typeChecks";
@@ -5,6 +7,7 @@ import type {
ExcalidrawElement, ExcalidrawElement,
NonDeletedExcalidrawElement, NonDeletedExcalidrawElement,
NonDeleted, NonDeleted,
ElementsMapOrArray,
} from "./types"; } from "./types";
/** /**
@@ -16,12 +19,10 @@ export const getSceneVersion = (elements: readonly ExcalidrawElement[]) =>
/** /**
* Hashes elements' versionNonce (using djb2 algo). Order of elements matters. * Hashes elements' versionNonce (using djb2 algo). Order of elements matters.
*/ */
export const hashElementsVersion = ( export const hashElementsVersion = (elements: ElementsMapOrArray): number => {
elements: readonly ExcalidrawElement[],
): number => {
let hash = 5381; let hash = 5381;
for (let i = 0; i < elements.length; i++) { for (const element of toIterable(elements)) {
hash = (hash << 5) + hash + elements[i].versionNonce; hash = (hash << 5) + hash + element.versionNonce;
} }
return hash >>> 0; // Ensure unsigned 32-bit integer return hash >>> 0; // Ensure unsigned 32-bit integer
}; };
@@ -71,3 +72,47 @@ export const clearElementsForExport = (
export const clearElementsForLocalStorage = ( export const clearElementsForLocalStorage = (
elements: readonly ExcalidrawElement[], elements: readonly ExcalidrawElement[],
) => _clearElements(elements); ) => _clearElements(elements);
export * from "./align";
export * from "./binding";
export * from "./bounds";
export * from "./collision";
export * from "./comparisons";
export * from "./containerCache";
export * from "./cropElement";
export * from "./delta";
export * from "./distance";
export * from "./distribute";
export * from "./dragElements";
export * from "./duplicate";
export * from "./elbowArrow";
export * from "./elementLink";
export * from "./embeddable";
export * from "./flowchart";
export * from "./fractionalIndex";
export * from "./frame";
export * from "./groups";
export * from "./heading";
export * from "./image";
export * from "./linearElementEditor";
export * from "./mutateElement";
export * from "./newElement";
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 "./showSelectedShapeActions";
export * from "./sizeHelpers";
export * from "./sortElements";
export * from "./store";
export * from "./textElement";
export * from "./textMeasurements";
export * from "./textWrapping";
export * from "./transformHandles";
export * from "./typeChecks";
export * from "./utils";
export * from "./zindex";
+251 -179
View File
@@ -20,7 +20,7 @@ import {
tupleToCoors, tupleToCoors,
} from "@excalidraw/common"; } from "@excalidraw/common";
import type { Store } from "@excalidraw/excalidraw/store"; import type { Store } from "@excalidraw/element";
import type { Radians } from "@excalidraw/math"; import type { Radians } from "@excalidraw/math";
@@ -63,11 +63,14 @@ import {
getControlPointsForBezierCurve, getControlPointsForBezierCurve,
mapIntervalToBezierT, mapIntervalToBezierT,
getBezierXY, getBezierXY,
toggleLinePolygonState,
} from "./shapes"; } from "./shapes";
import { getLockedLinearCursorAlignSize } from "./sizeHelpers"; import { getLockedLinearCursorAlignSize } from "./sizeHelpers";
import type Scene from "./Scene"; import { isLineElement } from "./typeChecks";
import type { Scene } from "./Scene";
import type { Bounds } from "./bounds"; import type { Bounds } from "./bounds";
import type { import type {
@@ -82,13 +85,38 @@ import type {
FixedPointBinding, FixedPointBinding,
FixedSegment, FixedSegment,
ExcalidrawElbowArrowElement, ExcalidrawElbowArrowElement,
PointsPositionUpdates,
} from "./types"; } from "./types";
const editorMidPointsCache: { /**
version: number | null; * Normalizes line points so that the start point is at [0,0]. This is
points: (GlobalPoint | null)[]; * expected in various parts of the codebase.
zoom: number | null; *
} = { version: null, points: [], zoom: null }; * Also returns the offsets - [0,0] if no normalization needed.
*
* @private
*/
const getNormalizedPoints = ({
points,
}: {
points: ExcalidrawLinearElement["points"];
}): {
points: LocalPoint[];
offsetX: number;
offsetY: number;
} => {
const offsetX = points[0][0];
const offsetY = points[0][1];
return {
points: points.map((p) => {
return pointFrom(p[0] - offsetX, p[1] - offsetY);
}),
offsetX,
offsetY,
};
};
export class LinearElementEditor { export class LinearElementEditor {
public readonly elementId: ExcalidrawElement["id"] & { public readonly elementId: ExcalidrawElement["id"] & {
_brand: "excalidrawLinearElementId"; _brand: "excalidrawLinearElementId";
@@ -121,6 +149,7 @@ export class LinearElementEditor {
public readonly hoverPointIndex: number; public readonly hoverPointIndex: number;
public readonly segmentMidPointHoveredCoords: GlobalPoint | null; public readonly segmentMidPointHoveredCoords: GlobalPoint | null;
public readonly elbowed: boolean; public readonly elbowed: boolean;
public readonly customLineAngle: number | null;
constructor( constructor(
element: NonDeleted<ExcalidrawLinearElement>, element: NonDeleted<ExcalidrawLinearElement>,
@@ -131,7 +160,11 @@ export class LinearElementEditor {
}; };
if (!pointsEqual(element.points[0], pointFrom(0, 0))) { if (!pointsEqual(element.points[0], pointFrom(0, 0))) {
console.error("Linear element is not normalized", Error().stack); console.error("Linear element is not normalized", Error().stack);
LinearElementEditor.normalizePoints(element, elementsMap); mutateElement(
element,
elementsMap,
LinearElementEditor.getNormalizeElementPointsAndCoords(element),
);
} }
this.selectedPointsIndices = null; this.selectedPointsIndices = null;
this.lastUncommittedPoint = null; this.lastUncommittedPoint = null;
@@ -154,6 +187,7 @@ export class LinearElementEditor {
this.hoverPointIndex = -1; this.hoverPointIndex = -1;
this.segmentMidPointHoveredCoords = null; this.segmentMidPointHoveredCoords = null;
this.elbowed = isElbowArrow(element) && element.elbowed; this.elbowed = isElbowArrow(element) && element.elbowed;
this.customLineAngle = null;
} }
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@@ -257,6 +291,7 @@ export class LinearElementEditor {
const { elementId } = linearElementEditor; const { elementId } = linearElementEditor;
const elementsMap = scene.getNonDeletedElementsMap(); const elementsMap = scene.getNonDeletedElementsMap();
const element = LinearElementEditor.getElement(elementId, elementsMap); const element = LinearElementEditor.getElement(elementId, elementsMap);
let customLineAngle = linearElementEditor.customLineAngle;
if (!element) { if (!element) {
return null; return null;
} }
@@ -297,6 +332,12 @@ export class LinearElementEditor {
const selectedIndex = selectedPointsIndices[0]; const selectedIndex = selectedPointsIndices[0];
const referencePoint = const referencePoint =
element.points[selectedIndex === 0 ? 1 : selectedIndex - 1]; 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( const [width, height] = LinearElementEditor._getShiftLockedDelta(
element, element,
@@ -304,18 +345,25 @@ export class LinearElementEditor {
referencePoint, referencePoint,
pointFrom(scenePointerX, scenePointerY), pointFrom(scenePointerX, scenePointerY),
event[KEYS.CTRL_OR_CMD] ? null : app.getEffectiveGridSize(), event[KEYS.CTRL_OR_CMD] ? null : app.getEffectiveGridSize(),
customLineAngle,
); );
LinearElementEditor.movePoints(element, scene, [ LinearElementEditor.movePoints(
{ element,
index: selectedIndex, scene,
point: pointFrom( new Map([
width + referencePoint[0], [
height + referencePoint[1], selectedIndex,
), {
isDragging: selectedIndex === lastClickedPoint, point: pointFrom(
}, width + referencePoint[0],
]); height + referencePoint[1],
),
isDragging: selectedIndex === lastClickedPoint,
},
],
]),
);
} else { } else {
const newDraggingPointPosition = LinearElementEditor.createPointAt( const newDraggingPointPosition = LinearElementEditor.createPointAt(
element, element,
@@ -331,26 +379,32 @@ export class LinearElementEditor {
LinearElementEditor.movePoints( LinearElementEditor.movePoints(
element, element,
scene, scene,
selectedPointsIndices.map((pointIndex) => { new Map(
const newPointPosition: LocalPoint = selectedPointsIndices.map((pointIndex) => {
pointIndex === lastClickedPoint const newPointPosition: LocalPoint =
? LinearElementEditor.createPointAt( pointIndex === lastClickedPoint
element, ? LinearElementEditor.createPointAt(
elementsMap, element,
scenePointerX - linearElementEditor.pointerOffset.x, elementsMap,
scenePointerY - linearElementEditor.pointerOffset.y, scenePointerX - linearElementEditor.pointerOffset.x,
event[KEYS.CTRL_OR_CMD] ? null : app.getEffectiveGridSize(), scenePointerY - linearElementEditor.pointerOffset.y,
) event[KEYS.CTRL_OR_CMD]
: pointFrom( ? null
element.points[pointIndex][0] + deltaX, : app.getEffectiveGridSize(),
element.points[pointIndex][1] + deltaY, )
); : pointFrom(
return { element.points[pointIndex][0] + deltaX,
index: pointIndex, element.points[pointIndex][1] + deltaY,
point: newPointPosition, );
isDragging: pointIndex === lastClickedPoint, return [
}; pointIndex,
}), {
point: newPointPosition,
isDragging: pointIndex === lastClickedPoint,
},
];
}),
),
); );
} }
@@ -413,6 +467,7 @@ export class LinearElementEditor {
? lastClickedPoint ? lastClickedPoint
: -1, : -1,
isDragging: true, isDragging: true,
customLineAngle,
}; };
} }
@@ -451,15 +506,33 @@ export class LinearElementEditor {
selectedPoint === element.points.length - 1 selectedPoint === element.points.length - 1
) { ) {
if (isPathALoop(element.points, appState.zoom.value)) { if (isPathALoop(element.points, appState.zoom.value)) {
LinearElementEditor.movePoints(element, scene, [ if (isLineElement(element)) {
{ scene.mutateElement(
index: selectedPoint, element,
point: {
selectedPoint === 0 ...toggleLinePolygonState(element, true),
? element.points[element.points.length - 1] },
: element.points[0], {
}, informMutation: false,
]); isDragging: false,
},
);
}
LinearElementEditor.movePoints(
element,
scene,
new Map([
[
selectedPoint,
{
point:
selectedPoint === 0
? element.points[element.points.length - 1]
: element.points[0],
},
],
]),
);
} }
const bindingElement = isBindingEnabled(appState) const bindingElement = isBindingEnabled(appState)
@@ -489,6 +562,8 @@ export class LinearElementEditor {
return { return {
...editingLinearElement, ...editingLinearElement,
...bindings, ...bindings,
segmentMidPointHoveredCoords: null,
hoverPointIndex: -1,
// if clicking without previously dragging a point(s), and not holding // if clicking without previously dragging a point(s), and not holding
// shift, deselect all points except the one clicked. If holding shift, // shift, deselect all points except the one clicked. If holding shift,
// toggle the point. // toggle the point.
@@ -510,6 +585,7 @@ export class LinearElementEditor {
: selectedPointsIndices, : selectedPointsIndices,
isDragging: false, isDragging: false,
pointerOffset: { x: 0, y: 0 }, pointerOffset: { x: 0, y: 0 },
customLineAngle: null,
}; };
} }
@@ -517,7 +593,7 @@ export class LinearElementEditor {
element: NonDeleted<ExcalidrawLinearElement>, element: NonDeleted<ExcalidrawLinearElement>,
elementsMap: ElementsMap, elementsMap: ElementsMap,
appState: InteractiveCanvasAppState, appState: InteractiveCanvasAppState,
): typeof editorMidPointsCache["points"] => { ): (GlobalPoint | null)[] => {
const boundText = getBoundTextElement(element, elementsMap); const boundText = getBoundTextElement(element, elementsMap);
// Since its not needed outside editor unless 2 pointer lines or bound text // Since its not needed outside editor unless 2 pointer lines or bound text
@@ -529,25 +605,7 @@ export class LinearElementEditor {
) { ) {
return []; return [];
} }
if (
editorMidPointsCache.version === element.version &&
editorMidPointsCache.zoom === appState.zoom.value
) {
return editorMidPointsCache.points;
}
LinearElementEditor.updateEditorMidPointsCache(
element,
elementsMap,
appState,
);
return editorMidPointsCache.points!;
};
static updateEditorMidPointsCache = (
element: NonDeleted<ExcalidrawLinearElement>,
elementsMap: ElementsMap,
appState: InteractiveCanvasAppState,
) => {
const points = LinearElementEditor.getPointsGlobalCoordinates( const points = LinearElementEditor.getPointsGlobalCoordinates(
element, element,
elementsMap, elementsMap,
@@ -579,9 +637,8 @@ export class LinearElementEditor {
midpoints.push(segmentMidPoint); midpoints.push(segmentMidPoint);
index++; index++;
} }
editorMidPointsCache.points = midpoints;
editorMidPointsCache.version = element.version; return midpoints;
editorMidPointsCache.zoom = appState.zoom.value;
}; };
static getSegmentMidpointHitCoords = ( static getSegmentMidpointHitCoords = (
@@ -635,8 +692,11 @@ export class LinearElementEditor {
} }
} }
let index = 0; let index = 0;
const midPoints: typeof editorMidPointsCache["points"] = const midPoints = LinearElementEditor.getEditorMidPoints(
LinearElementEditor.getEditorMidPoints(element, elementsMap, appState); element,
elementsMap,
appState,
);
while (index < midPoints.length) { while (index < midPoints.length) {
if (midPoints[index] !== null) { if (midPoints[index] !== null) {
@@ -807,7 +867,7 @@ export class LinearElementEditor {
}); });
ret.didAddPoint = true; ret.didAddPoint = true;
} }
store.shouldCaptureIncrement(); store.scheduleCapture();
ret.linearElementEditor = { ret.linearElementEditor = {
...linearElementEditor, ...linearElementEditor,
pointerDownState: { pointerDownState: {
@@ -948,9 +1008,7 @@ export class LinearElementEditor {
if (!event.altKey) { if (!event.altKey) {
if (lastPoint === lastUncommittedPoint) { if (lastPoint === lastUncommittedPoint) {
LinearElementEditor.deletePoints(element, app.scene, [ LinearElementEditor.deletePoints(element, app, [points.length - 1]);
points.length - 1,
]);
} }
return { return {
...appState.editingLinearElement, ...appState.editingLinearElement,
@@ -988,14 +1046,20 @@ export class LinearElementEditor {
} }
if (lastPoint === lastUncommittedPoint) { if (lastPoint === lastUncommittedPoint) {
LinearElementEditor.movePoints(element, app.scene, [ LinearElementEditor.movePoints(
{ element,
index: element.points.length - 1, app.scene,
point: newPoint, new Map([
}, [
]); element.points.length - 1,
{
point: newPoint,
},
],
]),
);
} else { } else {
LinearElementEditor.addPoints(element, app.scene, [{ point: newPoint }]); LinearElementEditor.addPoints(element, app.scene, [newPoint]);
} }
return { return {
...appState.editingLinearElement, ...appState.editingLinearElement,
@@ -1138,40 +1202,23 @@ export class LinearElementEditor {
/** /**
* Normalizes line points so that the start point is at [0,0]. This is * Normalizes line points so that the start point is at [0,0]. This is
* expected in various parts of the codebase. Also returns new x/y to account * expected in various parts of the codebase.
* for the potential normalization. *
* Also returns normalized x and y coords to account for the normalization
* of the points.
*/ */
static getNormalizedPoints(element: ExcalidrawLinearElement): { static getNormalizeElementPointsAndCoords(element: ExcalidrawLinearElement) {
points: LocalPoint[]; const { points, offsetX, offsetY } = getNormalizedPoints(element);
x: number;
y: number;
} {
const { points } = element;
const offsetX = points[0][0];
const offsetY = points[0][1];
return { return {
points: points.map((p) => { points,
return pointFrom(p[0] - offsetX, p[1] - offsetY);
}),
x: element.x + offsetX, x: element.x + offsetX,
y: element.y + offsetY, y: element.y + offsetY,
}; };
} }
// element-mutating methods // element-mutating methods
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
static normalizePoints(
element: NonDeleted<ExcalidrawLinearElement>,
elementsMap: ElementsMap,
) {
mutateElement(
element,
elementsMap,
LinearElementEditor.getNormalizedPoints(element),
);
}
static duplicateSelectedPoints(appState: AppState, scene: Scene): AppState { static duplicateSelectedPoints(appState: AppState, scene: Scene): AppState {
invariant( invariant(
appState.editingLinearElement, appState.editingLinearElement,
@@ -1227,12 +1274,16 @@ export class LinearElementEditor {
// potentially expanding the bounding box // potentially expanding the bounding box
if (pointAddedToEnd) { if (pointAddedToEnd) {
const lastPoint = element.points[element.points.length - 1]; const lastPoint = element.points[element.points.length - 1];
LinearElementEditor.movePoints(element, scene, [ LinearElementEditor.movePoints(
{ element,
index: element.points.length - 1, scene,
point: pointFrom(lastPoint[0] + 30, lastPoint[1] + 30), new Map([
}, [
]); element.points.length - 1,
{ point: pointFrom(lastPoint[0] + 30, lastPoint[1] + 30) },
],
]),
);
} }
return { return {
@@ -1246,41 +1297,42 @@ export class LinearElementEditor {
static deletePoints( static deletePoints(
element: NonDeleted<ExcalidrawLinearElement>, element: NonDeleted<ExcalidrawLinearElement>,
scene: Scene, app: AppClassProperties,
pointIndices: readonly number[], pointIndices: readonly number[],
) { ) {
let offsetX = 0; const isUncommittedPoint =
let offsetY = 0; app.state.editingLinearElement?.lastUncommittedPoint ===
element.points[element.points.length - 1];
const isDeletingOriginPoint = pointIndices.includes(0); const nextPoints = element.points.filter((_, idx) => {
return !pointIndices.includes(idx);
});
// if deleting first point, make the next to be [0,0] and recalculate const isPolygon = isLineElement(element) && element.polygon;
// positions of the rest with respect to it
if (isDeletingOriginPoint) { // keep polygon intact if deleting start/end point or uncommitted point
const firstNonDeletedPoint = element.points.find((point, idx) => { if (
return !pointIndices.includes(idx); isPolygon &&
}); (isUncommittedPoint ||
if (firstNonDeletedPoint) { pointIndices.includes(0) ||
offsetX = firstNonDeletedPoint[0]; pointIndices.includes(element.points.length - 1))
offsetY = firstNonDeletedPoint[1]; ) {
} nextPoints[0] = pointFrom(
nextPoints[nextPoints.length - 1][0],
nextPoints[nextPoints.length - 1][1],
);
} }
const nextPoints = element.points.reduce((acc: LocalPoint[], p, idx) => { const {
if (!pointIndices.includes(idx)) { points: normalizedPoints,
acc.push( offsetX,
!acc.length offsetY,
? pointFrom(0, 0) } = getNormalizedPoints({ points: nextPoints });
: pointFrom(p[0] - offsetX, p[1] - offsetY),
);
}
return acc;
}, []);
LinearElementEditor._updatePoints( LinearElementEditor._updatePoints(
element, element,
scene, app.scene,
nextPoints, normalizedPoints,
offsetX, offsetX,
offsetY, offsetY,
); );
@@ -1289,16 +1341,27 @@ export class LinearElementEditor {
static addPoints( static addPoints(
element: NonDeleted<ExcalidrawLinearElement>, element: NonDeleted<ExcalidrawLinearElement>,
scene: Scene, scene: Scene,
targetPoints: { point: LocalPoint }[], addedPoints: LocalPoint[],
) { ) {
const offsetX = 0; const nextPoints = [...element.points, ...addedPoints];
const offsetY = 0;
if (isLineElement(element) && element.polygon) {
nextPoints[0] = pointFrom(
nextPoints[nextPoints.length - 1][0],
nextPoints[nextPoints.length - 1][1],
);
}
const {
points: normalizedPoints,
offsetX,
offsetY,
} = getNormalizedPoints({ points: nextPoints });
const nextPoints = [...element.points, ...targetPoints.map((x) => x.point)];
LinearElementEditor._updatePoints( LinearElementEditor._updatePoints(
element, element,
scene, scene,
nextPoints, normalizedPoints,
offsetX, offsetX,
offsetY, offsetY,
); );
@@ -1307,7 +1370,7 @@ export class LinearElementEditor {
static movePoints( static movePoints(
element: NonDeleted<ExcalidrawLinearElement>, element: NonDeleted<ExcalidrawLinearElement>,
scene: Scene, scene: Scene,
targetPoints: { index: number; point: LocalPoint; isDragging?: boolean }[], pointUpdates: PointsPositionUpdates,
otherUpdates?: { otherUpdates?: {
startBinding?: PointBinding | null; startBinding?: PointBinding | null;
endBinding?: PointBinding | null; endBinding?: PointBinding | null;
@@ -1315,27 +1378,46 @@ export class LinearElementEditor {
) { ) {
const { points } = element; const { points } = element;
// if polygon, move start and end points together
if (isLineElement(element) && element.polygon) {
const firstPointUpdate = pointUpdates.get(0);
const lastPointUpdate = pointUpdates.get(points.length - 1);
if (firstPointUpdate) {
pointUpdates.set(points.length - 1, {
point: pointFrom(
firstPointUpdate.point[0],
firstPointUpdate.point[1],
),
isDragging: firstPointUpdate.isDragging,
});
} else if (lastPointUpdate) {
pointUpdates.set(0, {
point: pointFrom(lastPointUpdate.point[0], lastPointUpdate.point[1]),
isDragging: lastPointUpdate.isDragging,
});
}
}
// in case we're moving start point, instead of modifying its position // in case we're moving start point, instead of modifying its position
// which would break the invariant of it being at [0,0], we move // which would break the invariant of it being at [0,0], we move
// all the other points in the opposite direction by delta to // all the other points in the opposite direction by delta to
// offset it. We do the same with actual element.x/y position, so // offset it. We do the same with actual element.x/y position, so
// this hacks are completely transparent to the user. // this hacks are completely transparent to the user.
const [deltaX, deltaY] =
targetPoints.find(({ index }) => index === 0)?.point ?? const updatedOriginPoint =
pointFrom<LocalPoint>(0, 0); pointUpdates.get(0)?.point ?? pointFrom<LocalPoint>(0, 0);
const [offsetX, offsetY] = pointFrom<LocalPoint>(
deltaX - points[0][0], const [offsetX, offsetY] = updatedOriginPoint;
deltaY - points[0][1],
);
const nextPoints = isElbowArrow(element) const nextPoints = isElbowArrow(element)
? [ ? [
targetPoints.find((t) => t.index === 0)?.point ?? points[0], pointUpdates.get(0)?.point ?? points[0],
targetPoints.find((t) => t.index === points.length - 1)?.point ?? pointUpdates.get(points.length - 1)?.point ??
points[points.length - 1], points[points.length - 1],
] ]
: points.map((p, idx) => { : points.map((p, idx) => {
const current = targetPoints.find((t) => t.index === idx)?.point ?? p; const current = pointUpdates.get(idx)?.point ?? p;
return pointFrom<LocalPoint>( return pointFrom<LocalPoint>(
current[0] - offsetX, current[0] - offsetX,
@@ -1351,11 +1433,7 @@ export class LinearElementEditor {
offsetY, offsetY,
otherUpdates, otherUpdates,
{ {
isDragging: targetPoints.reduce( isDragging: Array.from(pointUpdates.values()).some((t) => t.isDragging),
(dragging, targetPoint): boolean =>
dragging || targetPoint.isDragging === true,
false,
),
}, },
); );
} }
@@ -1500,6 +1578,7 @@ export class LinearElementEditor {
isDragging: options?.isDragging ?? false, isDragging: options?.isDragging ?? false,
}); });
} else { } else {
// TODO do we need to get precise coords here just to calc centers?
const nextCoords = getElementPointsCoords(element, nextPoints); const nextCoords = getElementPointsCoords(element, nextPoints);
const prevCoords = getElementPointsCoords(element, element.points); const prevCoords = getElementPointsCoords(element, element.points);
const nextCenterX = (nextCoords[0] + nextCoords[2]) / 2; const nextCenterX = (nextCoords[0] + nextCoords[2]) / 2;
@@ -1508,7 +1587,7 @@ export class LinearElementEditor {
const prevCenterY = (prevCoords[1] + prevCoords[3]) / 2; const prevCenterY = (prevCoords[1] + prevCoords[3]) / 2;
const dX = prevCenterX - nextCenterX; const dX = prevCenterX - nextCenterX;
const dY = prevCenterY - nextCenterY; const dY = prevCenterY - nextCenterY;
const rotated = pointRotateRads( const rotatedOffset = pointRotateRads(
pointFrom(offsetX, offsetY), pointFrom(offsetX, offsetY),
pointFrom(dX, dY), pointFrom(dX, dY),
element.angle, element.angle,
@@ -1516,8 +1595,8 @@ export class LinearElementEditor {
scene.mutateElement(element, { scene.mutateElement(element, {
...otherUpdates, ...otherUpdates,
points: nextPoints, points: nextPoints,
x: element.x + rotated[0], x: element.x + rotatedOffset[0],
y: element.y + rotated[1], y: element.y + rotatedOffset[1],
}); });
} }
} }
@@ -1528,6 +1607,7 @@ export class LinearElementEditor {
referencePoint: LocalPoint, referencePoint: LocalPoint,
scenePointer: GlobalPoint, scenePointer: GlobalPoint,
gridSize: NullableGridSize, gridSize: NullableGridSize,
customLineAngle?: number,
) { ) {
const referencePointCoords = LinearElementEditor.getPointGlobalCoordinates( const referencePointCoords = LinearElementEditor.getPointGlobalCoordinates(
element, element,
@@ -1553,6 +1633,7 @@ export class LinearElementEditor {
referencePointCoords[1], referencePointCoords[1],
gridX, gridX,
gridY, gridY,
customLineAngle,
); );
return pointRotateRads( return pointRotateRads(
@@ -1587,23 +1668,14 @@ export class LinearElementEditor {
y = midPoint[1] - boundTextElement.height / 2; y = midPoint[1] - boundTextElement.height / 2;
} else { } else {
const index = element.points.length / 2 - 1; const index = element.points.length / 2 - 1;
const midSegmentMidpoint = LinearElementEditor.getSegmentMidPoint(
element,
points[index],
points[index + 1],
index + 1,
elementsMap,
);
let midSegmentMidpoint = editorMidPointsCache.points[index];
if (element.points.length === 2) {
midSegmentMidpoint = pointCenter(points[0], points[1]);
}
if (
!midSegmentMidpoint ||
editorMidPointsCache.version !== element.version
) {
midSegmentMidpoint = LinearElementEditor.getSegmentMidPoint(
element,
points[index],
points[index + 1],
index + 1,
elementsMap,
);
}
x = midSegmentMidpoint[0] - boundTextElement.width / 2; x = midSegmentMidpoint[0] - boundTextElement.width / 2;
y = midSegmentMidpoint[1] - boundTextElement.height / 2; y = midSegmentMidpoint[1] - boundTextElement.height / 2;
} }
+5 -5
View File
@@ -23,7 +23,7 @@ import type {
export type ElementUpdate<TElement extends ExcalidrawElement> = Omit< export type ElementUpdate<TElement extends ExcalidrawElement> = Omit<
Partial<TElement>, Partial<TElement>,
"id" | "version" | "versionNonce" | "updated" "id" | "updated"
>; >;
/** /**
@@ -137,8 +137,8 @@ export const mutateElement = <TElement extends Mutable<ExcalidrawElement>>(
ShapeCache.delete(element); ShapeCache.delete(element);
} }
element.version++; element.version = updates.version ?? element.version + 1;
element.versionNonce = randomInteger(); element.versionNonce = updates.versionNonce ?? randomInteger();
element.updated = getUpdatedTimestamp(); element.updated = getUpdatedTimestamp();
return element; return element;
@@ -172,9 +172,9 @@ export const newElementWith = <TElement extends ExcalidrawElement>(
return { return {
...element, ...element,
...updates, ...updates,
version: updates.version ?? element.version + 1,
versionNonce: updates.versionNonce ?? randomInteger(),
updated: getUpdatedTimestamp(), updated: getUpdatedTimestamp(),
version: element.version + 1,
versionNonce: randomInteger(),
}; };
}; };
+17 -3
View File
@@ -25,6 +25,8 @@ import { getBoundTextMaxWidth } from "./textElement";
import { normalizeText, measureText } from "./textMeasurements"; import { normalizeText, measureText } from "./textMeasurements";
import { wrapText } from "./textWrapping"; import { wrapText } from "./textWrapping";
import { isLineElement } from "./typeChecks";
import type { import type {
ExcalidrawElement, ExcalidrawElement,
ExcalidrawImageElement, ExcalidrawImageElement,
@@ -44,8 +46,8 @@ import type {
ExcalidrawIframeElement, ExcalidrawIframeElement,
ElementsMap, ElementsMap,
ExcalidrawArrowElement, ExcalidrawArrowElement,
FixedSegment,
ExcalidrawElbowArrowElement, ExcalidrawElbowArrowElement,
ExcalidrawLineElement,
} from "./types"; } from "./types";
export type ElementConstructorOpts = MarkOptional< export type ElementConstructorOpts = MarkOptional<
@@ -458,9 +460,10 @@ export const newLinearElement = (
opts: { opts: {
type: ExcalidrawLinearElement["type"]; type: ExcalidrawLinearElement["type"];
points?: ExcalidrawLinearElement["points"]; points?: ExcalidrawLinearElement["points"];
polygon?: ExcalidrawLineElement["polygon"];
} & ElementConstructorOpts, } & ElementConstructorOpts,
): NonDeleted<ExcalidrawLinearElement> => { ): NonDeleted<ExcalidrawLinearElement> => {
return { const element = {
..._newElementBase<ExcalidrawLinearElement>(opts.type, opts), ..._newElementBase<ExcalidrawLinearElement>(opts.type, opts),
points: opts.points || [], points: opts.points || [],
lastCommittedPoint: null, lastCommittedPoint: null,
@@ -469,6 +472,17 @@ export const newLinearElement = (
startArrowhead: null, startArrowhead: null,
endArrowhead: null, endArrowhead: null,
}; };
if (isLineElement(element)) {
const lineElement: NonDeleted<ExcalidrawLineElement> = {
...element,
polygon: opts.polygon ?? false,
};
return lineElement;
}
return element;
}; };
export const newArrowElement = <T extends boolean>( export const newArrowElement = <T extends boolean>(
@@ -478,7 +492,7 @@ export const newArrowElement = <T extends boolean>(
endArrowhead?: Arrowhead | null; endArrowhead?: Arrowhead | null;
points?: ExcalidrawArrowElement["points"]; points?: ExcalidrawArrowElement["points"];
elbowed?: T; elbowed?: T;
fixedSegments?: FixedSegment[] | null; fixedSegments?: ExcalidrawElbowArrowElement["fixedSegments"] | null;
} & ElementConstructorOpts, } & ElementConstructorOpts,
): T extends true ): T extends true
? NonDeleted<ExcalidrawElbowArrowElement> ? NonDeleted<ExcalidrawElbowArrowElement>
+10 -2
View File
@@ -351,12 +351,20 @@ const generateElementCanvas = (
export const DEFAULT_LINK_SIZE = 14; export const DEFAULT_LINK_SIZE = 14;
const IMAGE_PLACEHOLDER_IMG = document.createElement("img"); const IMAGE_PLACEHOLDER_IMG =
typeof document !== "undefined"
? document.createElement("img")
: ({ src: "" } as HTMLImageElement); // mock image element outside of browser
IMAGE_PLACEHOLDER_IMG.src = `data:${MIME_TYPES.svg},${encodeURIComponent( IMAGE_PLACEHOLDER_IMG.src = `data:${MIME_TYPES.svg},${encodeURIComponent(
`<svg aria-hidden="true" focusable="false" data-prefix="fas" data-icon="image" class="svg-inline--fa fa-image fa-w-16" role="img" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512"><path fill="#888" d="M464 448H48c-26.51 0-48-21.49-48-48V112c0-26.51 21.49-48 48-48h416c26.51 0 48 21.49 48 48v288c0 26.51-21.49 48-48 48zM112 120c-30.928 0-56 25.072-56 56s25.072 56 56 56 56-25.072 56-56-25.072-56-56-56zM64 384h384V272l-87.515-87.515c-4.686-4.686-12.284-4.686-16.971 0L208 320l-55.515-55.515c-4.686-4.686-12.284-4.686-16.971 0L64 336v48z"></path></svg>`, `<svg aria-hidden="true" focusable="false" data-prefix="fas" data-icon="image" class="svg-inline--fa fa-image fa-w-16" role="img" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512"><path fill="#888" d="M464 448H48c-26.51 0-48-21.49-48-48V112c0-26.51 21.49-48 48-48h416c26.51 0 48 21.49 48 48v288c0 26.51-21.49 48-48 48zM112 120c-30.928 0-56 25.072-56 56s25.072 56 56 56 56-25.072 56-56-25.072-56-56-56zM64 384h384V272l-87.515-87.515c-4.686-4.686-12.284-4.686-16.971 0L208 320l-55.515-55.515c-4.686-4.686-12.284-4.686-16.971 0L64 336v48z"></path></svg>`,
)}`; )}`;
const IMAGE_ERROR_PLACEHOLDER_IMG = document.createElement("img"); const IMAGE_ERROR_PLACEHOLDER_IMG =
typeof document !== "undefined"
? document.createElement("img")
: ({ src: "" } as HTMLImageElement); // mock image element outside of browser
IMAGE_ERROR_PLACEHOLDER_IMG.src = `data:${MIME_TYPES.svg},${encodeURIComponent( IMAGE_ERROR_PLACEHOLDER_IMG.src = `data:${MIME_TYPES.svg},${encodeURIComponent(
`<svg viewBox="0 0 668 668" xmlns="http://www.w3.org/2000/svg" xml:space="preserve" style="fill-rule:evenodd;clip-rule:evenodd;stroke-linejoin:round;stroke-miterlimit:2"><path d="M464 448H48c-26.51 0-48-21.49-48-48V112c0-26.51 21.49-48 48-48h416c26.51 0 48 21.49 48 48v288c0 26.51-21.49 48-48 48ZM112 120c-30.928 0-56 25.072-56 56s25.072 56 56 56 56-25.072 56-56-25.072-56-56-56ZM64 384h384V272l-87.515-87.515c-4.686-4.686-12.284-4.686-16.971 0L208 320l-55.515-55.515c-4.686-4.686-12.284-4.686-16.971 0L64 336v48Z" style="fill:#888;fill-rule:nonzero" transform="matrix(.81709 0 0 .81709 124.825 145.825)"/><path d="M256 8C119.034 8 8 119.033 8 256c0 136.967 111.034 248 248 248s248-111.034 248-248S392.967 8 256 8Zm130.108 117.892c65.448 65.448 70 165.481 20.677 235.637L150.47 105.216c70.204-49.356 170.226-44.735 235.638 20.676ZM125.892 386.108c-65.448-65.448-70-165.481-20.677-235.637L361.53 406.784c-70.203 49.356-170.226 44.736-235.638-20.676Z" style="fill:#888;fill-rule:nonzero" transform="matrix(.30366 0 0 .30366 506.822 60.065)"/></svg>`, `<svg viewBox="0 0 668 668" xmlns="http://www.w3.org/2000/svg" xml:space="preserve" style="fill-rule:evenodd;clip-rule:evenodd;stroke-linejoin:round;stroke-miterlimit:2"><path d="M464 448H48c-26.51 0-48-21.49-48-48V112c0-26.51 21.49-48 48-48h416c26.51 0 48 21.49 48 48v288c0 26.51-21.49 48-48 48ZM112 120c-30.928 0-56 25.072-56 56s25.072 56 56 56 56-25.072 56-56-25.072-56-56-56ZM64 384h384V272l-87.515-87.515c-4.686-4.686-12.284-4.686-16.971 0L208 320l-55.515-55.515c-4.686-4.686-12.284-4.686-16.971 0L64 336v48Z" style="fill:#888;fill-rule:nonzero" transform="matrix(.81709 0 0 .81709 124.825 145.825)"/><path d="M256 8C119.034 8 8 119.033 8 256c0 136.967 111.034 248 248 248s248-111.034 248-248S392.967 8 256 8Zm130.108 117.892c65.448 65.448 70 165.481 20.677 235.637L150.47 105.216c70.204-49.356 170.226-44.735 235.638 20.676ZM125.892 386.108c-65.448-65.448-70-165.481-20.677-235.637L361.53 406.784c-70.203 49.356-170.226 44.736-235.638-20.676Z" style="fill:#888;fill-rule:nonzero" transform="matrix(.30366 0 0 .30366 506.822 60.065)"/></svg>`,
)}`; )}`;
+6 -6
View File
@@ -57,7 +57,7 @@ import {
import { isInGroup } from "./groups"; import { isInGroup } from "./groups";
import type Scene from "./Scene"; import type { Scene } from "./Scene";
import type { BoundingBox } from "./bounds"; import type { BoundingBox } from "./bounds";
import type { import type {
@@ -962,11 +962,6 @@ export const resizeSingleElement = (
isDragging: false, isDragging: false,
}); });
updateBoundElements(latestElement, scene, {
// TODO: confirm with MARK if this actually makes sense
newSize: { width: nextWidth, height: nextHeight },
});
if (boundTextElement && boundTextFont != null) { if (boundTextElement && boundTextFont != null) {
scene.mutateElement(boundTextElement, { scene.mutateElement(boundTextElement, {
fontSize: boundTextFont.fontSize, fontSize: boundTextFont.fontSize,
@@ -978,6 +973,11 @@ export const resizeSingleElement = (
handleDirection, handleDirection,
shouldMaintainAspectRatio, shouldMaintainAspectRatio,
); );
updateBoundElements(latestElement, scene, {
// TODO: confirm with MARK if this actually makes sense
newSize: { width: nextWidth, height: nextHeight },
});
} }
}; };
-19
View File
@@ -169,25 +169,6 @@ export const isSomeElementSelected = (function () {
return ret; return ret;
})(); })();
/**
* Returns common attribute (picked by `getAttribute` callback) of selected
* elements. If elements don't share the same value, returns `null`.
*/
export const getCommonAttributeOfSelectedElements = <T>(
elements: readonly NonDeletedExcalidrawElement[],
appState: Pick<AppState, "selectedElementIds">,
getAttribute: (element: ExcalidrawElement) => T,
): T | null => {
const attributes = Array.from(
new Set(
getSelectedElements(elements, appState).map((element) =>
getAttribute(element),
),
),
);
return attributes.length === 1 ? attributes[0] : null;
};
export const getSelectedElements = ( export const getSelectedElements = (
elements: ElementsMapOrArray, elements: ElementsMapOrArray,
appState: Pick<InteractiveCanvasAppState, "selectedElementIds">, appState: Pick<InteractiveCanvasAppState, "selectedElementIds">,
+50 -1
View File
@@ -5,6 +5,7 @@ import {
ROUNDNESS, ROUNDNESS,
invariant, invariant,
elementCenterPoint, elementCenterPoint,
LINE_POLYGON_POINT_MERGE_DISTANCE,
} from "@excalidraw/common"; } from "@excalidraw/common";
import { import {
isPoint, isPoint,
@@ -35,10 +36,13 @@ import { ShapeCache } from "./ShapeCache";
import { getElementAbsoluteCoords, type Bounds } from "./bounds"; import { getElementAbsoluteCoords, type Bounds } from "./bounds";
import { canBecomePolygon } from "./typeChecks";
import type { import type {
ElementsMap, ElementsMap,
ExcalidrawElement, ExcalidrawElement,
ExcalidrawLinearElement, ExcalidrawLinearElement,
ExcalidrawLineElement,
NonDeleted, NonDeleted,
} from "./types"; } from "./types";
@@ -287,6 +291,7 @@ export const mapIntervalToBezierT = <P extends GlobalPoint | LocalPoint>(
*/ */
export const aabbForElement = ( export const aabbForElement = (
element: Readonly<ExcalidrawElement>, element: Readonly<ExcalidrawElement>,
elementsMap: ElementsMap,
offset?: [number, number, number, number], offset?: [number, number, number, number],
) => { ) => {
const bbox = { const bbox = {
@@ -298,7 +303,7 @@ export const aabbForElement = (
midY: element.y + element.height / 2, midY: element.y + element.height / 2,
}; };
const center = elementCenterPoint(element); const center = elementCenterPoint(element, elementsMap);
const [topLeftX, topLeftY] = pointRotateRads( const [topLeftX, topLeftY] = pointRotateRads(
pointFrom(bbox.minX, bbox.minY), pointFrom(bbox.minX, bbox.minY),
center, center,
@@ -396,3 +401,47 @@ export const isPathALoop = (
} }
return false; 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;
};
+58 -5
View File
@@ -2,14 +2,28 @@ import {
SHIFT_LOCKING_ANGLE, SHIFT_LOCKING_ANGLE,
viewportCoordsToSceneCoords, viewportCoordsToSceneCoords,
} from "@excalidraw/common"; } from "@excalidraw/common";
import {
normalizeRadians,
radiansBetweenAngles,
radiansDifference,
type Radians,
} from "@excalidraw/math";
import { pointsEqual } from "@excalidraw/math";
import type { AppState, Offsets, Zoom } from "@excalidraw/excalidraw/types"; import type { AppState, Offsets, Zoom } from "@excalidraw/excalidraw/types";
import { getCommonBounds, getElementBounds } from "./bounds"; import { getCommonBounds, getElementBounds } from "./bounds";
import { isFreeDrawElement, isLinearElement } from "./typeChecks"; import {
isArrowElement,
isFreeDrawElement,
isLinearElement,
} from "./typeChecks";
import type { ElementsMap, ExcalidrawElement } from "./types"; import type { ElementsMap, ExcalidrawElement } from "./types";
export const INVISIBLY_SMALL_ELEMENT_SIZE = 0.1;
// TODO: remove invisible elements consistently actions, so that invisible elements are not recorded by the store, exported, broadcasted or persisted // TODO: remove invisible elements consistently actions, so that invisible elements are not recorded by the store, exported, broadcasted or persisted
// - perhaps could be as part of a standalone 'cleanup' action, in addition to 'finalize' // - perhaps could be as part of a standalone 'cleanup' action, in addition to 'finalize'
// - could also be part of `_clearElements` // - could also be part of `_clearElements`
@@ -17,8 +31,18 @@ export const isInvisiblySmallElement = (
element: ExcalidrawElement, element: ExcalidrawElement,
): boolean => { ): boolean => {
if (isLinearElement(element) || isFreeDrawElement(element)) { if (isLinearElement(element) || isFreeDrawElement(element)) {
return element.points.length < 2; return (
element.points.length < 2 ||
(element.points.length === 2 &&
isArrowElement(element) &&
pointsEqual(
element.points[0],
element.points[element.points.length - 1],
INVISIBLY_SMALL_ELEMENT_SIZE,
))
);
} }
return element.width === 0 && element.height === 0; return element.width === 0 && element.height === 0;
}; };
@@ -134,13 +158,42 @@ export const getLockedLinearCursorAlignSize = (
originY: number, originY: number,
x: number, x: number,
y: number, y: number,
customAngle?: number,
) => { ) => {
let width = x - originX; let width = x - originX;
let height = y - originY; let height = y - originY;
const lockedAngle = const angle = Math.atan2(height, width) as Radians;
Math.round(Math.atan(height / width) / SHIFT_LOCKING_ANGLE) * let lockedAngle = (Math.round(angle / SHIFT_LOCKING_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) { if (lockedAngle === 0) {
height = 0; height = 0;
File diff suppressed because it is too large Load Diff
+1 -1
View File
@@ -30,7 +30,7 @@ import {
isTextElement, isTextElement,
} from "./typeChecks"; } from "./typeChecks";
import type Scene from "./Scene"; import type { Scene } from "./Scene";
import type { MaybeTransformHandleType } from "./transformHandles"; import type { MaybeTransformHandleType } from "./transformHandles";
import type { import type {
+75
View File
@@ -1,5 +1,7 @@
import { ROUNDNESS, assertNever } from "@excalidraw/common"; import { ROUNDNESS, assertNever } from "@excalidraw/common";
import { pointsEqual } from "@excalidraw/math";
import type { ElementOrToolType } from "@excalidraw/excalidraw/types"; import type { ElementOrToolType } from "@excalidraw/excalidraw/types";
import type { MarkNonNullable } from "@excalidraw/common/utility-types"; import type { MarkNonNullable } from "@excalidraw/common/utility-types";
@@ -25,9 +27,11 @@ import type {
ExcalidrawMagicFrameElement, ExcalidrawMagicFrameElement,
ExcalidrawArrowElement, ExcalidrawArrowElement,
ExcalidrawElbowArrowElement, ExcalidrawElbowArrowElement,
ExcalidrawLineElement,
PointBinding, PointBinding,
FixedPointBinding, FixedPointBinding,
ExcalidrawFlowchartNodeElement, ExcalidrawFlowchartNodeElement,
ExcalidrawLinearElementSubType,
} from "./types"; } from "./types";
export const isInitializedImageElement = ( export const isInitializedImageElement = (
@@ -107,6 +111,12 @@ export const isLinearElement = (
return element != null && isLinearElementType(element.type); return element != null && isLinearElementType(element.type);
}; };
export const isLineElement = (
element?: ExcalidrawElement | null,
): element is ExcalidrawLineElement => {
return element != null && element.type === "line";
};
export const isArrowElement = ( export const isArrowElement = (
element?: ExcalidrawElement | null, element?: ExcalidrawElement | null,
): element is ExcalidrawArrowElement => { ): element is ExcalidrawArrowElement => {
@@ -119,6 +129,29 @@ export const isElbowArrow = (
return isArrowElement(element) && element.elbowed; 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 => {
return isArrowElement(element) && !element.elbowed && !element.roundness;
};
export const isCurvedArrow = (
element?: ExcalidrawElement,
): element is ExcalidrawArrowElement => {
return (
isArrowElement(element) && !element.elbowed && element.roundness !== null
);
};
export const isLinearElementType = ( export const isLinearElementType = (
elementType: ElementOrToolType, elementType: ElementOrToolType,
): boolean => { ): boolean => {
@@ -271,6 +304,10 @@ export const isBoundToContainer = (
); );
}; };
export const isArrowBoundToElement = (element: ExcalidrawArrowElement) => {
return !!element.startBinding || !!element.endBinding;
};
export const isUsingAdaptiveRadius = (type: string) => export const isUsingAdaptiveRadius = (type: string) =>
type === "rectangle" || type === "rectangle" ||
type === "embeddable" || type === "embeddable" ||
@@ -338,3 +375,41 @@ export const isBounds = (box: unknown): box is Bounds =>
typeof box[1] === "number" && typeof box[1] === "number" &&
typeof box[2] === "number" && typeof box[2] === "number" &&
typeof box[3] === "number"; typeof box[3] === "number";
export const getLinearElementSubType = (
element: ExcalidrawLinearElement,
): ExcalidrawLinearElementSubType => {
if (isSharpArrow(element)) {
return "sharpArrow";
}
if (isCurvedArrow(element)) {
return "curvedArrow";
}
if (isElbowArrow(element)) {
return "elbowArrow";
}
return "line";
};
/**
* Checks if current element points meet all the conditions for polygon=true
* (this isn't a element type check, for that use isLineElement).
*
* If you want to check if points *can* be turned into a polygon, use
* canBecomePolygon(points).
*/
export const isValidPolygon = (
points: ExcalidrawLineElement["points"],
): boolean => {
return points.length > 3 && pointsEqual(points[0], points[points.length - 1]);
};
export const canBecomePolygon = (
points: ExcalidrawLineElement["points"],
): boolean => {
return (
points.length > 3 ||
// 3-point polygons can't have all points in a single line
(points.length === 3 && !pointsEqual(points[0], points[points.length - 1]))
);
};
+26 -2
View File
@@ -195,7 +195,8 @@ export type ExcalidrawRectanguloidElement =
| ExcalidrawFreeDrawElement | ExcalidrawFreeDrawElement
| ExcalidrawIframeLikeElement | ExcalidrawIframeLikeElement
| ExcalidrawFrameLikeElement | ExcalidrawFrameLikeElement
| ExcalidrawEmbeddableElement; | ExcalidrawEmbeddableElement
| ExcalidrawSelectionElement;
/** /**
* ExcalidrawElement should be JSON serializable and (eventually) contain * ExcalidrawElement should be JSON serializable and (eventually) contain
@@ -296,6 +297,13 @@ export type FixedPointBinding = Merge<
} }
>; >;
type Index = number;
export type PointsPositionUpdates = Map<
Index,
{ point: LocalPoint; isDragging?: boolean }
>;
export type Arrowhead = export type Arrowhead =
| "arrow" | "arrow"
| "bar" | "bar"
@@ -321,10 +329,16 @@ export type ExcalidrawLinearElement = _ExcalidrawElementBase &
endArrowhead: Arrowhead | null; endArrowhead: Arrowhead | null;
}>; }>;
export type ExcalidrawLineElement = ExcalidrawLinearElement &
Readonly<{
type: "line";
polygon: boolean;
}>;
export type FixedSegment = { export type FixedSegment = {
start: LocalPoint; start: LocalPoint;
end: LocalPoint; end: LocalPoint;
index: number; index: Index;
}; };
export type ExcalidrawArrowElement = ExcalidrawLinearElement & export type ExcalidrawArrowElement = ExcalidrawLinearElement &
@@ -412,3 +426,13 @@ export type NonDeletedSceneElementsMap = Map<
export type ElementsMapOrArray = export type ElementsMapOrArray =
| readonly ExcalidrawElement[] | readonly ExcalidrawElement[]
| Readonly<ElementsMap>; | Readonly<ElementsMap>;
export type ExcalidrawLinearElementSubType =
| "line"
| "sharpArrow"
| "curvedArrow"
| "elbowArrow";
export type ConvertibleGenericTypes = "rectangle" | "diamond" | "ellipse";
export type ConvertibleLinearTypes = ExcalidrawLinearElementSubType;
export type ConvertibleTypes = ConvertibleGenericTypes | ConvertibleLinearTypes;
+314 -239
View File
@@ -1,28 +1,168 @@
import { import {
curve, curve,
curveCatmullRomCubicApproxPoints,
curveOffsetPoints,
lineSegment, lineSegment,
pointFrom, pointFrom,
pointFromVector, pointFromArray,
rectangle, rectangle,
vectorFromPoint,
vectorNormalize,
vectorScale,
type GlobalPoint, type GlobalPoint,
} from "@excalidraw/math"; } 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 { getCornerRadius } from "./shapes";
import { getDiamondPoints } from "./bounds"; import { getDiamondPoints } from "./bounds";
import { generateLinearCollisionShape } from "./Shape";
import type { import type {
ElementsMap,
ExcalidrawDiamondElement, ExcalidrawDiamondElement,
ExcalidrawElement,
ExcalidrawFreeDrawElement,
ExcalidrawLinearElement,
ExcalidrawRectanguloidElement, ExcalidrawRectanguloidElement,
} from "./types"; } 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);
};
export function deconstructLinearOrFreeDrawElement(
element: ExcalidrawLinearElement | ExcalidrawFreeDrawElement,
elementsMap: ElementsMap,
): [LineSegment<GlobalPoint>[], Curve<GlobalPoint>[]] {
const cachedShape = getElementShapesCacheEntry(element, 0);
if (cachedShape) {
return cachedShape;
}
const ops = generateLinearCollisionShape(element, elementsMap) 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 * Get the building components of a rectanguloid element in the form of
* line segments and curves. * line segments and curves.
@@ -35,175 +175,132 @@ export function deconstructRectanguloidElement(
element: ExcalidrawRectanguloidElement, element: ExcalidrawRectanguloidElement,
offset: number = 0, offset: number = 0,
): [LineSegment<GlobalPoint>[], Curve<GlobalPoint>[]] { ): [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), Math.min(element.width, element.height),
element, element,
); );
if (roundness <= 0) { if (radius === 0) {
const r = rectangle( radius = 0.01;
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, []];
} }
const center = elementCenterPoint(element);
const r = rectangle( const r = rectangle(
pointFrom(element.x, element.y), pointFrom(element.x, element.y),
pointFrom(element.x + element.width, element.y + element.height), pointFrom(element.x + element.width, element.y + element.height),
); );
const top = lineSegment<GlobalPoint>( const top = lineSegment<GlobalPoint>(
pointFrom<GlobalPoint>(r[0][0] + roundness, r[0][1]), pointFrom<GlobalPoint>(r[0][0] + radius, r[0][1]),
pointFrom<GlobalPoint>(r[1][0] - roundness, r[0][1]), pointFrom<GlobalPoint>(r[1][0] - radius, r[0][1]),
); );
const right = lineSegment<GlobalPoint>( const right = lineSegment<GlobalPoint>(
pointFrom<GlobalPoint>(r[1][0], r[0][1] + roundness), pointFrom<GlobalPoint>(r[1][0], r[0][1] + radius),
pointFrom<GlobalPoint>(r[1][0], r[1][1] - roundness), pointFrom<GlobalPoint>(r[1][0], r[1][1] - radius),
); );
const bottom = lineSegment<GlobalPoint>( const bottom = lineSegment<GlobalPoint>(
pointFrom<GlobalPoint>(r[0][0] + roundness, r[1][1]), pointFrom<GlobalPoint>(r[0][0] + radius, r[1][1]),
pointFrom<GlobalPoint>(r[1][0] - roundness, r[1][1]), pointFrom<GlobalPoint>(r[1][0] - radius, r[1][1]),
); );
const left = lineSegment<GlobalPoint>( const left = lineSegment<GlobalPoint>(
pointFrom<GlobalPoint>(r[0][0], r[1][1] - roundness), pointFrom<GlobalPoint>(r[0][0], r[1][1] - radius),
pointFrom<GlobalPoint>(r[0][0], r[0][1] + roundness), pointFrom<GlobalPoint>(r[0][0], r[0][1] + radius),
); );
const offsets = [ const baseCorners = [
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 = [
curve( curve(
pointFromVector(offsets[0], left[1]), left[1],
pointFromVector( pointFrom<GlobalPoint>(
offsets[0], left[1][0] + (2 / 3) * (r[0][0] - left[1][0]),
pointFrom<GlobalPoint>( left[1][1] + (2 / 3) * (r[0][1] - left[1][1]),
left[1][0] + (2 / 3) * (r[0][0] - left[1][0]),
left[1][1] + (2 / 3) * (r[0][1] - left[1][1]),
),
), ),
pointFromVector( pointFrom<GlobalPoint>(
offsets[0], top[0][0] + (2 / 3) * (r[0][0] - top[0][0]),
pointFrom<GlobalPoint>( top[0][1] + (2 / 3) * (r[0][1] - top[0][1]),
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 ), // TOP LEFT
curve( curve(
pointFromVector(offsets[1], top[1]), top[1],
pointFromVector( pointFrom<GlobalPoint>(
offsets[1], top[1][0] + (2 / 3) * (r[1][0] - top[1][0]),
pointFrom<GlobalPoint>( top[1][1] + (2 / 3) * (r[0][1] - top[1][1]),
top[1][0] + (2 / 3) * (r[1][0] - top[1][0]),
top[1][1] + (2 / 3) * (r[0][1] - top[1][1]),
),
), ),
pointFromVector( pointFrom<GlobalPoint>(
offsets[1], right[0][0] + (2 / 3) * (r[1][0] - right[0][0]),
pointFrom<GlobalPoint>( right[0][1] + (2 / 3) * (r[0][1] - right[0][1]),
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 ), // TOP RIGHT
curve( curve(
pointFromVector(offsets[2], right[1]), right[1],
pointFromVector( pointFrom<GlobalPoint>(
offsets[2], right[1][0] + (2 / 3) * (r[1][0] - right[1][0]),
pointFrom<GlobalPoint>( right[1][1] + (2 / 3) * (r[1][1] - right[1][1]),
right[1][0] + (2 / 3) * (r[1][0] - right[1][0]),
right[1][1] + (2 / 3) * (r[1][1] - right[1][1]),
),
), ),
pointFromVector( pointFrom<GlobalPoint>(
offsets[2], bottom[1][0] + (2 / 3) * (r[1][0] - bottom[1][0]),
pointFrom<GlobalPoint>( bottom[1][1] + (2 / 3) * (r[1][1] - bottom[1][1]),
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 ), // BOTTOM RIGHT
curve( curve(
pointFromVector(offsets[3], bottom[0]), bottom[0],
pointFromVector( pointFrom<GlobalPoint>(
offsets[3], bottom[0][0] + (2 / 3) * (r[0][0] - bottom[0][0]),
pointFrom<GlobalPoint>( bottom[0][1] + (2 / 3) * (r[1][1] - bottom[0][1]),
bottom[0][0] + (2 / 3) * (r[0][0] - bottom[0][0]),
bottom[0][1] + (2 / 3) * (r[1][1] - bottom[0][1]),
),
), ),
pointFromVector( pointFrom<GlobalPoint>(
offsets[3], left[0][0] + (2 / 3) * (r[0][0] - left[0][0]),
pointFrom<GlobalPoint>( left[0][1] + (2 / 3) * (r[1][1] - left[0][1]),
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 ), // BOTTOM LEFT
]; ];
const sides = [ const corners =
lineSegment<GlobalPoint>(corners[0][3], corners[1][0]), offset > 0
lineSegment<GlobalPoint>(corners[1][3], corners[2][0]), ? baseCorners.map(
lineSegment<GlobalPoint>(corners[2][3], corners[3][0]), (corner) =>
lineSegment<GlobalPoint>(corners[3][3], corners[0][0]), 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;
} }
/** /**
@@ -218,42 +315,20 @@ export function deconstructDiamondElement(
element: ExcalidrawDiamondElement, element: ExcalidrawDiamondElement,
offset: number = 0, offset: number = 0,
): [LineSegment<GlobalPoint>[], Curve<GlobalPoint>[]] { ): [LineSegment<GlobalPoint>[], Curve<GlobalPoint>[]] {
const [topX, topY, rightX, rightY, bottomX, bottomY, leftX, leftY] = const cachedShape = getElementShapesCacheEntry(element, offset);
getDiamondPoints(element);
const verticalRadius = getCornerRadius(Math.abs(topX - leftX), element);
const horizontalRadius = getCornerRadius(Math.abs(rightY - topY), element);
if (element.roundness?.type == null) { if (cachedShape) {
const [top, right, bottom, left]: GlobalPoint[] = [ return cachedShape;
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], []];
} }
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[] = [ const [top, right, bottom, left]: GlobalPoint[] = [
pointFrom(element.x + topX, element.y + topY), pointFrom(element.x + topX, element.y + topY),
@@ -262,94 +337,94 @@ export function deconstructDiamondElement(
pointFrom(element.x + leftX, element.y + leftY), pointFrom(element.x + leftX, element.y + leftY),
]; ];
const offsets = [ const baseCorners = [
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 = [
curve( curve(
pointFromVector( pointFrom<GlobalPoint>(
offsets[0], right[0] - verticalRadius,
pointFrom<GlobalPoint>( right[1] - horizontalRadius,
right[0] - verticalRadius,
right[1] - horizontalRadius,
),
), ),
pointFromVector(offsets[0], right), right,
pointFromVector(offsets[0], right), right,
pointFromVector( pointFrom<GlobalPoint>(
offsets[0], right[0] - verticalRadius,
pointFrom<GlobalPoint>( right[1] + horizontalRadius,
right[0] - verticalRadius,
right[1] + horizontalRadius,
),
), ),
), // RIGHT ), // RIGHT
curve( curve(
pointFromVector( pointFrom<GlobalPoint>(
offsets[1], bottom[0] + verticalRadius,
pointFrom<GlobalPoint>( bottom[1] - horizontalRadius,
bottom[0] + verticalRadius,
bottom[1] - horizontalRadius,
),
), ),
pointFromVector(offsets[1], bottom), bottom,
pointFromVector(offsets[1], bottom), bottom,
pointFromVector( pointFrom<GlobalPoint>(
offsets[1], bottom[0] - verticalRadius,
pointFrom<GlobalPoint>( bottom[1] - horizontalRadius,
bottom[0] - verticalRadius,
bottom[1] - horizontalRadius,
),
), ),
), // BOTTOM ), // BOTTOM
curve( curve(
pointFromVector( pointFrom<GlobalPoint>(
offsets[2], left[0] + verticalRadius,
pointFrom<GlobalPoint>( left[1] + horizontalRadius,
left[0] + verticalRadius,
left[1] + horizontalRadius,
),
), ),
pointFromVector(offsets[2], left), left,
pointFromVector(offsets[2], left), left,
pointFromVector( pointFrom<GlobalPoint>(
offsets[2], left[0] + verticalRadius,
pointFrom<GlobalPoint>( left[1] - horizontalRadius,
left[0] + verticalRadius,
left[1] - horizontalRadius,
),
), ),
), // LEFT ), // LEFT
curve( curve(
pointFromVector( pointFrom<GlobalPoint>(
offsets[3], top[0] - verticalRadius,
pointFrom<GlobalPoint>( top[1] + horizontalRadius,
top[0] - verticalRadius,
top[1] + horizontalRadius,
),
), ),
pointFromVector(offsets[3], top), top,
pointFromVector(offsets[3], top), top,
pointFromVector( pointFrom<GlobalPoint>(
offsets[3], top[0] + verticalRadius,
pointFrom<GlobalPoint>( top[1] + horizontalRadius,
top[0] + verticalRadius,
top[1] + horizontalRadius,
),
), ),
), // TOP ), // TOP
]; ];
const corners =
offset > 0
? baseCorners.map(
(corner) =>
curveCatmullRomCubicApproxPoints(
curveOffsetPoints(corner, offset),
)!,
)
: [
[baseCorners[0]],
[baseCorners[1]],
[baseCorners[2]],
[baseCorners[3]],
];
const sides = [ const sides = [
lineSegment<GlobalPoint>(corners[0][3], corners[1][0]), lineSegment<GlobalPoint>(
lineSegment<GlobalPoint>(corners[1][3], corners[2][0]), corners[0][corners[0].length - 1][3],
lineSegment<GlobalPoint>(corners[2][3], corners[3][0]), corners[1][0][0],
lineSegment<GlobalPoint>(corners[3][3], corners[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;
} }
+1 -1
View File
@@ -10,7 +10,7 @@ import { syncMovedIndices } from "./fractionalIndex";
import { getSelectedElements } from "./selection"; import { getSelectedElements } from "./selection";
import type Scene from "./Scene"; import type { Scene } from "./Scene";
import type { ExcalidrawElement, ExcalidrawFrameLikeElement } from "./types"; import type { ExcalidrawElement, ExcalidrawFrameLikeElement } from "./types";
@@ -17,7 +17,7 @@ exports[`Test Linear Elements > Test bound text element > should match styles fo
class="excalidraw-wysiwyg" class="excalidraw-wysiwyg"
data-type="wysiwyg" data-type="wysiwyg"
dir="auto" 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" tabindex="0"
wrap="off" wrap="off"
/> />
+10 -2
View File
@@ -35,6 +35,7 @@ const createAndSelectTwoRectangles = () => {
// The second rectangle is already reselected because it was the last element created // The second rectangle is already reselected because it was the last element created
mouse.reset(); mouse.reset();
Keyboard.withModifierKeys({ shift: true }, () => { Keyboard.withModifierKeys({ shift: true }, () => {
mouse.moveTo(10, 0);
mouse.click(); mouse.click();
}); });
}; };
@@ -52,6 +53,7 @@ const createAndSelectTwoRectanglesWithDifferentSizes = () => {
// The second rectangle is already reselected because it was the last element created // The second rectangle is already reselected because it was the last element created
mouse.reset(); mouse.reset();
Keyboard.withModifierKeys({ shift: true }, () => { Keyboard.withModifierKeys({ shift: true }, () => {
mouse.moveTo(10, 0);
mouse.click(); mouse.click();
}); });
}; };
@@ -202,6 +204,7 @@ describe("aligning", () => {
// The second rectangle is already reselected because it was the last element created // The second rectangle is already reselected because it was the last element created
mouse.reset(); mouse.reset();
Keyboard.withModifierKeys({ shift: true }, () => { Keyboard.withModifierKeys({ shift: true }, () => {
mouse.moveTo(10, 0);
mouse.click(); mouse.click();
}); });
@@ -215,6 +218,7 @@ describe("aligning", () => {
// Add the created group to the current selection // Add the created group to the current selection
mouse.restorePosition(0, 0); mouse.restorePosition(0, 0);
Keyboard.withModifierKeys({ shift: true }, () => { Keyboard.withModifierKeys({ shift: true }, () => {
mouse.moveTo(10, 0);
mouse.click(); mouse.click();
}); });
}; };
@@ -316,6 +320,7 @@ describe("aligning", () => {
// The second rectangle is already selected because it was the last element created // The second rectangle is already selected because it was the last element created
mouse.reset(); mouse.reset();
Keyboard.withModifierKeys({ shift: true }, () => { Keyboard.withModifierKeys({ shift: true }, () => {
mouse.moveTo(10, 0);
mouse.click(); mouse.click();
}); });
@@ -330,7 +335,7 @@ describe("aligning", () => {
mouse.down(); mouse.down();
mouse.up(100, 100); mouse.up(100, 100);
mouse.restorePosition(200, 200); mouse.restorePosition(210, 200);
Keyboard.withModifierKeys({ shift: true }, () => { Keyboard.withModifierKeys({ shift: true }, () => {
mouse.click(); mouse.click();
}); });
@@ -341,6 +346,7 @@ describe("aligning", () => {
// The second group is already selected because it was the last group created // The second group is already selected because it was the last group created
mouse.reset(); mouse.reset();
Keyboard.withModifierKeys({ shift: true }, () => { Keyboard.withModifierKeys({ shift: true }, () => {
mouse.moveTo(10, 0);
mouse.click(); mouse.click();
}); });
}; };
@@ -454,6 +460,7 @@ describe("aligning", () => {
// The second rectangle is already reselected because it was the last element created // The second rectangle is already reselected because it was the last element created
mouse.reset(); mouse.reset();
Keyboard.withModifierKeys({ shift: true }, () => { Keyboard.withModifierKeys({ shift: true }, () => {
mouse.moveTo(10, 0);
mouse.click(); mouse.click();
}); });
@@ -466,7 +473,7 @@ describe("aligning", () => {
mouse.up(100, 100); mouse.up(100, 100);
// Add group to current selection // Add group to current selection
mouse.restorePosition(0, 0); mouse.restorePosition(10, 0);
Keyboard.withModifierKeys({ shift: true }, () => { Keyboard.withModifierKeys({ shift: true }, () => {
mouse.click(); mouse.click();
}); });
@@ -482,6 +489,7 @@ describe("aligning", () => {
// Select the nested group, the rectangle is already selected // Select the nested group, the rectangle is already selected
mouse.reset(); mouse.reset();
Keyboard.withModifierKeys({ shift: true }, () => { Keyboard.withModifierKeys({ shift: true }, () => {
mouse.moveTo(10, 0);
mouse.click(); mouse.click();
}); });
}; };
+10 -16
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 { fireEvent, render } from "@excalidraw/excalidraw/tests/test-utils";
import { getTransformHandles } from "../src/transformHandles"; import { getTransformHandles } from "../src/transformHandles";
import {
getTextEditor,
TEXT_EDITOR_SELECTOR,
} from "../../excalidraw/tests/queries/dom";
const { h } = window; const { h } = window;
@@ -172,12 +176,12 @@ describe("element binding", () => {
const arrow = UI.createElement("arrow", { const arrow = UI.createElement("arrow", {
x: 0, x: 0,
y: 0, y: 0,
size: 50, size: 49,
}); });
expect(arrow.endBinding).toBe(null); expect(arrow.endBinding).toBe(null);
mouse.downAt(50, 50); mouse.downAt(49, 49);
mouse.moveTo(51, 0); mouse.moveTo(51, 0);
mouse.up(0, 0); mouse.up(0, 0);
@@ -244,18 +248,12 @@ describe("element binding", () => {
mouse.clickAt(text.x + 50, text.y + 50); mouse.clickAt(text.x + 50, text.y + 50);
const editor = document.querySelector( const editor = await getTextEditor();
".excalidraw-textEditorContainer > textarea",
) as HTMLTextAreaElement;
expect(editor).not.toBe(null);
fireEvent.change(editor, { target: { value: "" } }); fireEvent.change(editor, { target: { value: "" } });
fireEvent.keyDown(editor, { key: KEYS.ESCAPE }); fireEvent.keyDown(editor, { key: KEYS.ESCAPE });
expect( expect(document.querySelector(TEXT_EDITOR_SELECTOR)).toBe(null);
document.querySelector(".excalidraw-textEditorContainer > textarea"),
).toBe(null);
expect(arrow.endBinding).toBe(null); expect(arrow.endBinding).toBe(null);
}); });
@@ -285,18 +283,14 @@ describe("element binding", () => {
UI.clickTool("text"); UI.clickTool("text");
mouse.clickAt(text.x + 50, text.y + 50); mouse.clickAt(text.x + 50, text.y + 50);
const editor = document.querySelector( const editor = await getTextEditor();
".excalidraw-textEditorContainer > textarea",
) as HTMLTextAreaElement;
expect(editor).not.toBe(null); expect(editor).not.toBe(null);
fireEvent.change(editor, { target: { value: "asdasdasdasdas" } }); fireEvent.change(editor, { target: { value: "asdasdasdasdas" } });
fireEvent.keyDown(editor, { key: KEYS.ESCAPE }); fireEvent.keyDown(editor, { key: KEYS.ESCAPE });
expect( expect(document.querySelector(TEXT_EDITOR_SELECTOR)).toBe(null);
document.querySelector(".excalidraw-textEditorContainer > textarea"),
).toBe(null);
expect(arrow.endBinding?.elementId).toBe(text.id); expect(arrow.endBinding?.elementId).toBe(text.id);
}); });
+38
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>(87, -68),
element: window.h.elements[0],
threshold: 10,
elementsMap: window.h.scene.getNonDeletedElementsMap(),
});
expect(hit).toBe(true);
});
});
@@ -3,21 +3,30 @@ import { vi } from "vitest";
import { KEYS, cloneJSON } from "@excalidraw/common"; import { KEYS, cloneJSON } from "@excalidraw/common";
import { duplicateElement } from "@excalidraw/element/duplicate"; import {
Excalidraw,
exportToCanvas,
exportToSvg,
} from "@excalidraw/excalidraw";
import {
actionFlipHorizontal,
actionFlipVertical,
} from "@excalidraw/excalidraw/actions";
import type { import { API } from "@excalidraw/excalidraw/tests/helpers/api";
ExcalidrawImageElement, import { Keyboard, Pointer, UI } from "@excalidraw/excalidraw/tests/helpers/ui";
ImageCrop, import {
} from "@excalidraw/element/types"; act,
GlobalTestState,
render,
unmountComponent,
} from "@excalidraw/excalidraw/tests/test-utils";
import { Excalidraw, exportToCanvas, exportToSvg } from ".."; import type { NormalizedZoomValue } from "@excalidraw/excalidraw/types";
import { actionFlipHorizontal, actionFlipVertical } from "../actions";
import { API } from "./helpers/api"; import { duplicateElement } from "../src/duplicate";
import { Keyboard, Pointer, UI } from "./helpers/ui";
import { act, GlobalTestState, render, unmountComponent } from "./test-utils";
import type { NormalizedZoomValue } from "../types"; import type { ExcalidrawImageElement, ImageCrop } from "../src/types";
const { h } = window; const { h } = window;
const mouse = new Pointer("mouse"); const mouse = new Pointer("mouse");
+149
View File
@@ -0,0 +1,149 @@
import type { ObservedAppState } from "@excalidraw/excalidraw/types";
import type { LinearElementEditor } from "@excalidraw/element";
import { AppStateDelta } from "../src/delta";
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 commonAppState = {
viewBackgroundColor: "#ffffff",
selectedElementIds: {},
selectedGroupIds: {},
editingGroupId: null,
croppingElementId: null,
editingLinearElementId: null,
lockedMultiSelections: {},
activeLockedId: null,
};
const prevAppState1: ObservedAppState = {
...commonAppState,
name: "",
selectedLinearElementId: null,
};
const nextAppState1: ObservedAppState = {
...commonAppState,
name,
selectedLinearElementId,
};
const prevAppState2: ObservedAppState = {
selectedLinearElementId: null,
name: "",
...commonAppState,
};
const nextAppState2: ObservedAppState = {
selectedLinearElementId,
name,
...commonAppState,
};
const delta1 = AppStateDelta.calculate(prevAppState1, nextAppState1);
const delta2 = AppStateDelta.calculate(prevAppState2, nextAppState2);
expect(JSON.stringify(delta1)).toBe(JSON.stringify(delta2));
});
it("should maintain stable order for selectedElementIds", () => {
const commonAppState = {
name: "",
viewBackgroundColor: "#ffffff",
selectedGroupIds: {},
editingGroupId: null,
croppingElementId: null,
selectedLinearElementId: null,
editingLinearElementId: null,
activeLockedId: null,
lockedMultiSelections: {},
};
const prevAppState1: ObservedAppState = {
...commonAppState,
selectedElementIds: { id5: true, id2: true, id4: true },
};
const nextAppState1: ObservedAppState = {
...commonAppState,
selectedElementIds: {
id1: true,
id2: true,
id3: true,
},
};
const prevAppState2: ObservedAppState = {
...commonAppState,
selectedElementIds: { id4: true, id2: true, id5: true },
};
const nextAppState2: ObservedAppState = {
...commonAppState,
selectedElementIds: {
id3: true,
id2: true,
id1: true,
},
};
const delta1 = AppStateDelta.calculate(prevAppState1, nextAppState1);
const delta2 = AppStateDelta.calculate(prevAppState2, nextAppState2);
expect(JSON.stringify(delta1)).toBe(JSON.stringify(delta2));
});
it("should maintain stable order for selectedGroupIds", () => {
const commonAppState = {
name: "",
viewBackgroundColor: "#ffffff",
selectedElementIds: {},
editingGroupId: null,
croppingElementId: null,
selectedLinearElementId: null,
editingLinearElementId: null,
activeLockedId: null,
lockedMultiSelections: {},
};
const prevAppState1: ObservedAppState = {
...commonAppState,
selectedGroupIds: { id5: false, id2: true, id4: true, id0: true },
};
const nextAppState1: ObservedAppState = {
...commonAppState,
selectedGroupIds: {
id0: true,
id1: true,
id2: false,
id3: true,
},
};
const prevAppState2: ObservedAppState = {
...commonAppState,
selectedGroupIds: { id0: true, id4: true, id2: true, id5: false },
};
const nextAppState2: ObservedAppState = {
...commonAppState,
selectedGroupIds: {
id3: true,
id2: false,
id1: true,
id0: true,
},
};
const delta1 = AppStateDelta.calculate(prevAppState1, nextAppState1);
const delta2 = AppStateDelta.calculate(prevAppState2, nextAppState2);
expect(JSON.stringify(delta1)).toBe(JSON.stringify(delta2));
});
});
});
@@ -505,8 +505,6 @@ describe("group-related duplication", () => {
mouse.up(frame.x + frame.width + 50, frame.y + frame.height + 50); mouse.up(frame.x + frame.width + 50, frame.y + frame.height + 50);
}); });
// console.log(h.elements);
assertElements(h.elements, [ assertElements(h.elements, [
{ id: frame.id }, { id: frame.id },
{ id: rectangle1.id, frameId: frame.id }, { id: rectangle1.id, frameId: frame.id },
+1 -1
View File
@@ -22,7 +22,7 @@ import type { LocalPoint } from "@excalidraw/math";
import { bindLinearElement } from "../src/binding"; import { bindLinearElement } from "../src/binding";
import Scene from "../src/Scene"; import { Scene } from "../src/Scene";
import type { import type {
ExcalidrawArrowElement, ExcalidrawArrowElement,
@@ -7,9 +7,9 @@ import {
syncInvalidIndices, syncInvalidIndices,
syncMovedIndices, syncMovedIndices,
validateFractionalIndices, validateFractionalIndices,
} from "@excalidraw/element/fractionalIndex"; } from "@excalidraw/element";
import { deepCopyElement } from "@excalidraw/element/duplicate"; import { deepCopyElement } from "@excalidraw/element";
import { API } from "@excalidraw/excalidraw/tests/helpers/api"; import { API } from "@excalidraw/excalidraw/tests/helpers/api";
@@ -1,8 +1,5 @@
import { newArrowElement } from "@excalidraw/element/newElement";
import { pointCenter, pointFrom } from "@excalidraw/math"; import { pointCenter, pointFrom } from "@excalidraw/math";
import { act, queryByTestId, queryByText } from "@testing-library/react"; import { act, queryByTestId, queryByText } from "@testing-library/react";
import React from "react";
import { vi } from "vitest"; import { vi } from "vitest";
import { import {
@@ -13,36 +10,39 @@ import {
arrayToMap, arrayToMap,
} from "@excalidraw/common"; } from "@excalidraw/common";
import { LinearElementEditor } from "@excalidraw/element/linearElementEditor"; import { Excalidraw } from "@excalidraw/excalidraw";
import { import * as InteractiveCanvas from "@excalidraw/excalidraw/renderer/interactiveScene";
getBoundTextElementPosition, import * as StaticScene from "@excalidraw/excalidraw/renderer/staticScene";
getBoundTextMaxWidth, import { API } from "@excalidraw/excalidraw/tests/helpers/api";
} from "@excalidraw/element/textElement";
import * as textElementUtils from "@excalidraw/element/textElement";
import { wrapText } from "@excalidraw/element/textWrapping";
import type { GlobalPoint, LocalPoint } from "@excalidraw/math"; import { Keyboard, Pointer, UI } from "@excalidraw/excalidraw/tests/helpers/ui";
import type {
ExcalidrawElement,
ExcalidrawLinearElement,
ExcalidrawTextElementWithContainer,
FontString,
} from "@excalidraw/element/types";
import { Excalidraw } from "../index";
import * as InteractiveCanvas from "../renderer/interactiveScene";
import * as StaticScene from "../renderer/staticScene";
import { API } from "../tests/helpers/api";
import { Keyboard, Pointer, UI } from "./helpers/ui";
import { import {
screen, screen,
render, render,
fireEvent, fireEvent,
GlobalTestState, GlobalTestState,
unmountComponent, unmountComponent,
} from "./test-utils"; } from "@excalidraw/excalidraw/tests/test-utils";
import type { GlobalPoint, LocalPoint } from "@excalidraw/math";
import { wrapText } from "../src";
import * as textElementUtils from "../src/textElement";
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,
ExcalidrawTextElementWithContainer,
FontString,
} from "../src/types";
const renderInteractiveScene = vi.spyOn( const renderInteractiveScene = vi.spyOn(
InteractiveCanvas, InteractiveCanvas,
@@ -256,7 +256,49 @@ describe("Test Linear Elements", () => {
expect(h.state.editingLinearElement?.elementId).toEqual(h.elements[0].id); expect(h.state.editingLinearElement?.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();
mouse.clickAt(midpoint[0], midpoint[1]);
Keyboard.keyPress(KEYS.ENTER);
expect(h.state.editingLinearElement?.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.editingLinearElement?.elementId).toBeUndefined();
mouse.clickAt(midpoint[0], midpoint[1]);
Keyboard.withModifierKeys({ ctrl: true }, () => {
Keyboard.keyPress(KEYS.ENTER);
});
expect(h.state.editingLinearElement?.elementId).toEqual(h.elements[0].id);
});
it("should enter line editor via ctrl+enter (arrow)", () => {
createTwoPointerLinearElement("arrow");
expect(h.state.editingLinearElement?.elementId).toBeUndefined();
mouse.clickAt(midpoint[0], midpoint[1]);
Keyboard.withModifierKeys({ ctrl: true }, () => {
Keyboard.keyPress(KEYS.ENTER);
});
expect(h.state.editingLinearElement?.elementId).toEqual(h.elements[0].id);
});
it("should enter line editor on ctrl+dblclick (simple arrow)", () => {
createTwoPointerLinearElement("arrow");
expect(h.state.editingLinearElement?.elementId).toBeUndefined();
Keyboard.withModifierKeys({ ctrl: true }, () => {
mouse.doubleClick();
});
expect(h.state.editingLinearElement?.elementId).toEqual(h.elements[0].id);
});
it("should enter line editor on ctrl+dblclick (line)", () => {
createTwoPointerLinearElement("line"); createTwoPointerLinearElement("line");
expect(h.state.editingLinearElement?.elementId).toBeUndefined(); expect(h.state.editingLinearElement?.elementId).toBeUndefined();
@@ -266,6 +308,37 @@ describe("Test Linear Elements", () => {
expect(h.state.editingLinearElement?.elementId).toEqual(h.elements[0].id); expect(h.state.editingLinearElement?.elementId).toEqual(h.elements[0].id);
}); });
it("should enter line editor on dblclick (line)", () => {
createTwoPointerLinearElement("line");
expect(h.state.editingLinearElement?.elementId).toBeUndefined();
mouse.doubleClick();
expect(h.state.editingLinearElement?.elementId).toEqual(h.elements[0].id);
});
it("should not enter line editor on dblclick (arrow)", async () => {
createTwoPointerLinearElement("arrow");
expect(h.state.editingLinearElement?.elementId).toBeUndefined();
mouse.doubleClick();
expect(h.state.editingLinearElement).toEqual(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.editingLinearElement?.elementId).toEqual(arrow.id);
mouse.doubleClick();
expect(h.state.editingLinearElement?.elementId).toEqual(arrow.id);
expect(h.elements.length).toEqual(1);
expect(document.querySelector(TEXT_EDITOR_SELECTOR)).toBe(null);
});
describe("Inside editor", () => { describe("Inside editor", () => {
it("should not drag line and add midpoint when dragged irrespective of threshold", () => { it("should not drag line and add midpoint when dragged irrespective of threshold", () => {
createTwoPointerLinearElement("line"); createTwoPointerLinearElement("line");
@@ -994,12 +1067,10 @@ describe("Test Linear Elements", () => {
}); });
}); });
it("should match styles for text editor", () => { it("should match styles for text editor", async () => {
createTwoPointerLinearElement("arrow"); createTwoPointerLinearElement("arrow");
Keyboard.keyPress(KEYS.ENTER); Keyboard.keyPress(KEYS.ENTER);
const editor = document.querySelector( const editor = await getTextEditor();
".excalidraw-textEditorContainer > textarea",
) as HTMLTextAreaElement;
expect(editor).toMatchSnapshot(); expect(editor).toMatchSnapshot();
}); });
@@ -1016,9 +1087,7 @@ describe("Test Linear Elements", () => {
expect(text.type).toBe("text"); expect(text.type).toBe("text");
expect(text.containerId).toBe(arrow.id); expect(text.containerId).toBe(arrow.id);
mouse.down(); mouse.down();
const editor = document.querySelector( const editor = await getTextEditor();
".excalidraw-textEditorContainer > textarea",
) as HTMLTextAreaElement;
fireEvent.change(editor, { fireEvent.change(editor, {
target: { value: DEFAULT_TEXT }, target: { value: DEFAULT_TEXT },
@@ -1046,9 +1115,7 @@ describe("Test Linear Elements", () => {
const textElement = h.elements[1] as ExcalidrawTextElementWithContainer; const textElement = h.elements[1] as ExcalidrawTextElementWithContainer;
expect(textElement.type).toBe("text"); expect(textElement.type).toBe("text");
expect(textElement.containerId).toBe(arrow.id); expect(textElement.containerId).toBe(arrow.id);
const editor = document.querySelector( const editor = await getTextEditor();
".excalidraw-textEditorContainer > textarea",
) as HTMLTextAreaElement;
fireEvent.change(editor, { fireEvent.change(editor, {
target: { value: DEFAULT_TEXT }, target: { value: DEFAULT_TEXT },
@@ -1067,13 +1134,7 @@ describe("Test Linear Elements", () => {
expect(h.elements.length).toBe(1); expect(h.elements.length).toBe(1);
mouse.doubleClickAt(line.x, line.y); mouse.doubleClickAt(line.x, line.y);
expect(h.elements.length).toBe(1);
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();
}); });
// TODO fix #7029 and rewrite this test // TODO fix #7029 and rewrite this test
@@ -1238,9 +1299,7 @@ describe("Test Linear Elements", () => {
mouse.select(arrow); mouse.select(arrow);
Keyboard.keyPress(KEYS.ENTER); Keyboard.keyPress(KEYS.ENTER);
const editor = document.querySelector( const editor = await getTextEditor();
".excalidraw-textEditorContainer > textarea",
) as HTMLTextAreaElement;
fireEvent.change(editor, { target: { value: DEFAULT_TEXT } }); fireEvent.change(editor, { target: { value: DEFAULT_TEXT } });
Keyboard.exitTextEditor(editor); Keyboard.exitTextEditor(editor);
@@ -1266,7 +1325,7 @@ describe("Test Linear Elements", () => {
mouse.downAt(rect.x, rect.y); mouse.downAt(rect.x, rect.y);
mouse.moveTo(200, 0); mouse.moveTo(200, 0);
mouse.upAt(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.x).toBe(200);
expect(rect.y).toBe(0); expect(rect.y).toBe(0);
expect(handleBindTextResizeSpy).toHaveBeenCalledWith( expect(handleBindTextResizeSpy).toHaveBeenCalledWith(
@@ -1384,19 +1443,30 @@ describe("Test Linear Elements", () => {
const [origStartX, origStartY] = [line.x, line.y]; const [origStartX, origStartY] = [line.x, line.y];
act(() => { act(() => {
LinearElementEditor.movePoints(line, h.app.scene, [ LinearElementEditor.movePoints(
{ line,
index: 0, h.app.scene,
point: pointFrom(line.points[0][0] + 10, line.points[0][1] + 10), new Map([
}, [
{ 0,
index: line.points.length - 1, {
point: pointFrom( point: pointFrom(
line.points[line.points.length - 1][0] - 10, line.points[0][0] + 10,
line.points[line.points.length - 1][1] - 10, line.points[0][1] + 10,
), ),
}, },
]); ],
[
line.points.length - 1,
{
point: pointFrom(
line.points[line.points.length - 1][0] - 10,
line.points[line.points.length - 1][1] - 10,
),
},
],
]),
);
}); });
expect(line.x).toBe(origStartX + 10); expect(line.x).toBe(origStartX + 10);
expect(line.y).toBe(origStartY + 10); expect(line.y).toBe(origStartY + 10);
@@ -1404,5 +1474,55 @@ describe("Test Linear Elements", () => {
expect(line.points[line.points.length - 1][0]).toBe(20); expect(line.points[line.points.length - 1][0]).toBe(20);
expect(line.points[line.points.length - 1][1]).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);
});
}); });
}); });
+6 -6
View File
@@ -510,12 +510,12 @@ describe("arrow element", () => {
h.state, h.state,
)[0] as ExcalidrawElbowArrowElement; )[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); expect(arrow.startBinding?.fixedPoint?.[1]).toBeCloseTo(0.75);
UI.resize(rectangle, "se", [-200, -150]); 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); expect(arrow.startBinding?.fixedPoint?.[1]).toBeCloseTo(0.75);
}); });
@@ -538,11 +538,11 @@ describe("arrow element", () => {
h.state, h.state,
)[0] as ExcalidrawElbowArrowElement; )[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); expect(arrow.startBinding?.fixedPoint?.[1]).toBeCloseTo(0.75);
UI.resize([rectangle, arrow], "nw", [300, 350]); 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); expect(arrow.startBinding?.fixedPoint?.[1]).toBeCloseTo(0.25);
}); });
}); });
@@ -819,7 +819,7 @@ describe("image element", () => {
UI.resize(image, "ne", [40, 0]); 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 imageWidth = image.width;
const scale = 20 / image.height; const scale = 20 / image.height;
@@ -1033,7 +1033,7 @@ describe("multiple selection", () => {
expect(leftBoundArrow.x).toBeCloseTo(-110); expect(leftBoundArrow.x).toBeCloseTo(-110);
expect(leftBoundArrow.y).toBeCloseTo(50); 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.height).toBeCloseTo(7, 0);
expect(leftBoundArrow.angle).toEqual(0); expect(leftBoundArrow.angle).toEqual(0);
expect(leftBoundArrow.startBinding).toBeNull(); expect(leftBoundArrow.startBinding).toBeNull();
@@ -1,7 +1,5 @@
import { vi } from "vitest"; import { vi } from "vitest";
import * as constants from "@excalidraw/common";
import { getPerfectElementSize } from "../src/sizeHelpers"; import { getPerfectElementSize } from "../src/sizeHelpers";
const EPSILON_DIGITS = 3; const EPSILON_DIGITS = 3;
@@ -57,13 +55,4 @@ describe("getPerfectElementSize", () => {
expect(width).toBeCloseTo(0, EPSILON_DIGITS); expect(width).toBeCloseTo(0, EPSILON_DIGITS);
expect(height).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);
});
});
}); });
+1 -1
View File
@@ -1,6 +1,6 @@
import { API } from "@excalidraw/excalidraw/tests/helpers/api"; import { API } from "@excalidraw/excalidraw/tests/helpers/api";
import { mutateElement } from "@excalidraw/element/mutateElement"; import { mutateElement } from "@excalidraw/element";
import { normalizeElementOrder } from "../src/sortElements"; import { normalizeElementOrder } from "../src/sortElements";
@@ -1,8 +1,9 @@
import { LIBRARY_DISABLED_TYPES, randomId } from "@excalidraw/common"; import { LIBRARY_DISABLED_TYPES, randomId } from "@excalidraw/common";
import { deepCopyElement } from "@excalidraw/element/duplicate"; import { deepCopyElement } from "@excalidraw/element";
import { CaptureUpdateAction } from "@excalidraw/element";
import { t } from "../i18n"; import { t } from "../i18n";
import { CaptureUpdateAction } from "../store";
import { register } from "./register"; import { register } from "./register";
+6 -5
View File
@@ -1,16 +1,18 @@
import { getNonDeletedElements } from "@excalidraw/element"; import { getNonDeletedElements } from "@excalidraw/element";
import { isFrameLikeElement } from "@excalidraw/element/typeChecks"; import { isFrameLikeElement } from "@excalidraw/element";
import { updateFrameMembershipOfSelectedElements } from "@excalidraw/element/frame"; import { updateFrameMembershipOfSelectedElements } from "@excalidraw/element";
import { KEYS, arrayToMap, getShortcutKey } from "@excalidraw/common"; import { KEYS, arrayToMap, getShortcutKey } from "@excalidraw/common";
import { alignElements } from "@excalidraw/element/align"; import { alignElements } from "@excalidraw/element";
import { CaptureUpdateAction } from "@excalidraw/element";
import type { ExcalidrawElement } from "@excalidraw/element/types"; import type { ExcalidrawElement } from "@excalidraw/element/types";
import type { Alignment } from "@excalidraw/element/align"; import type { Alignment } from "@excalidraw/element";
import { ToolButton } from "../components/ToolButton"; import { ToolButton } from "../components/ToolButton";
import { import {
@@ -25,7 +27,6 @@ import {
import { t } from "../i18n"; import { t } from "../i18n";
import { isSomeElementSelected } from "../scene"; import { isSomeElementSelected } from "../scene";
import { CaptureUpdateAction } from "../store";
import { register } from "./register"; import { register } from "./register";
@@ -10,14 +10,14 @@ import {
getOriginalContainerHeightFromCache, getOriginalContainerHeightFromCache,
resetOriginalContainerCache, resetOriginalContainerCache,
updateOriginalContainerCache, updateOriginalContainerCache,
} from "@excalidraw/element/containerCache"; } from "@excalidraw/element";
import { import {
computeBoundTextPosition, computeBoundTextPosition,
computeContainerDimensionForBoundText, computeContainerDimensionForBoundText,
getBoundTextElement, getBoundTextElement,
redrawTextBoundingBox, redrawTextBoundingBox,
} from "@excalidraw/element/textElement"; } from "@excalidraw/element";
import { import {
hasBoundTextElement, hasBoundTextElement,
@@ -25,13 +25,15 @@ import {
isTextBindableContainer, isTextBindableContainer,
isTextElement, isTextElement,
isUsingAdaptiveRadius, isUsingAdaptiveRadius,
} from "@excalidraw/element/typeChecks"; } from "@excalidraw/element";
import { measureText } from "@excalidraw/element/textMeasurements"; import { measureText } from "@excalidraw/element";
import { syncMovedIndices } from "@excalidraw/element/fractionalIndex"; import { syncMovedIndices } from "@excalidraw/element";
import { newElement } from "@excalidraw/element/newElement"; import { newElement } from "@excalidraw/element";
import { CaptureUpdateAction } from "@excalidraw/element";
import type { import type {
ExcalidrawElement, ExcalidrawElement,
@@ -44,8 +46,6 @@ import type { Mutable } from "@excalidraw/common/utility-types";
import type { Radians } from "@excalidraw/math"; import type { Radians } from "@excalidraw/math";
import { CaptureUpdateAction } from "../store";
import { register } from "./register"; import { register } from "./register";
import type { AppState } from "../types"; import type { AppState } from "../types";
+4 -3
View File
@@ -14,8 +14,10 @@ import {
} from "@excalidraw/common"; } from "@excalidraw/common";
import { getNonDeletedElements } from "@excalidraw/element"; import { getNonDeletedElements } from "@excalidraw/element";
import { newElementWith } from "@excalidraw/element/mutateElement"; import { newElementWith } from "@excalidraw/element";
import { getCommonBounds, type SceneBounds } from "@excalidraw/element/bounds"; import { getCommonBounds, type SceneBounds } from "@excalidraw/element";
import { CaptureUpdateAction } from "@excalidraw/element";
import type { ExcalidrawElement } from "@excalidraw/element/types"; import type { ExcalidrawElement } from "@excalidraw/element/types";
@@ -44,7 +46,6 @@ import { t } from "../i18n";
import { getNormalizedZoom } from "../scene"; import { getNormalizedZoom } from "../scene";
import { centerScrollOn } from "../scene/scroll"; import { centerScrollOn } from "../scene/scroll";
import { getStateForZoom } from "../scene/zoom"; import { getStateForZoom } from "../scene/zoom";
import { CaptureUpdateAction } from "../store";
import { register } from "./register"; import { register } from "./register";
@@ -1,8 +1,10 @@
import { isTextElement } from "@excalidraw/element/typeChecks"; import { isTextElement } from "@excalidraw/element";
import { getTextFromElements } from "@excalidraw/element/textElement"; import { getTextFromElements } from "@excalidraw/element";
import { CODES, KEYS, isFirefox } from "@excalidraw/common"; import { CODES, KEYS, isFirefox } from "@excalidraw/common";
import { CaptureUpdateAction } from "@excalidraw/element";
import { import {
copyTextToSystemClipboard, copyTextToSystemClipboard,
copyToClipboard, copyToClipboard,
@@ -15,8 +17,6 @@ import { DuplicateIcon, cutIcon, pngIcon, svgIcon } from "../components/icons";
import { exportCanvas, prepareElementsForExport } from "../data/index"; import { exportCanvas, prepareElementsForExport } from "../data/index";
import { t } from "../i18n"; import { t } from "../i18n";
import { CaptureUpdateAction } from "../store";
import { actionDeleteSelected } from "./actionDeleteSelected"; import { actionDeleteSelected } from "./actionDeleteSelected";
import { register } from "./register"; import { register } from "./register";
@@ -1,11 +1,12 @@
import { isImageElement } from "@excalidraw/element/typeChecks"; import { isImageElement } from "@excalidraw/element";
import { CaptureUpdateAction } from "@excalidraw/element";
import type { ExcalidrawImageElement } from "@excalidraw/element/types"; import type { ExcalidrawImageElement } from "@excalidraw/element/types";
import { ToolButton } from "../components/ToolButton"; import { ToolButton } from "../components/ToolButton";
import { cropIcon } from "../components/icons"; import { cropIcon } from "../components/icons";
import { t } from "../i18n"; import { t } from "../i18n";
import { CaptureUpdateAction } from "../store";
import { register } from "./register"; import { register } from "./register";
@@ -1,27 +1,28 @@
import { KEYS, updateActiveTool } from "@excalidraw/common"; import { KEYS, updateActiveTool } from "@excalidraw/common";
import { getNonDeletedElements } from "@excalidraw/element"; import { getNonDeletedElements } from "@excalidraw/element";
import { fixBindingsAfterDeletion } from "@excalidraw/element/binding"; import { fixBindingsAfterDeletion } from "@excalidraw/element";
import { LinearElementEditor } from "@excalidraw/element/linearElementEditor"; import { LinearElementEditor } from "@excalidraw/element";
import { newElementWith } from "@excalidraw/element/mutateElement"; import { newElementWith } from "@excalidraw/element";
import { getContainerElement } from "@excalidraw/element/textElement"; import { getContainerElement } from "@excalidraw/element";
import { import {
isBoundToContainer, isBoundToContainer,
isElbowArrow, isElbowArrow,
isFrameLikeElement, isFrameLikeElement,
} from "@excalidraw/element/typeChecks"; } from "@excalidraw/element";
import { getFrameChildren } from "@excalidraw/element/frame"; import { getFrameChildren } from "@excalidraw/element";
import { import {
getElementsInGroup, getElementsInGroup,
selectGroupsForSelectedElements, selectGroupsForSelectedElements,
} from "@excalidraw/element/groups"; } from "@excalidraw/element";
import { CaptureUpdateAction } from "@excalidraw/element";
import type { ExcalidrawElement } from "@excalidraw/element/types"; import type { ExcalidrawElement } from "@excalidraw/element/types";
import { t } from "../i18n"; import { t } from "../i18n";
import { getSelectedElements, isSomeElementSelected } from "../scene"; import { getSelectedElements, isSomeElementSelected } from "../scene";
import { CaptureUpdateAction } from "../store";
import { TrashIcon } from "../components/icons"; import { TrashIcon } from "../components/icons";
import { ToolButton } from "../components/ToolButton"; import { ToolButton } from "../components/ToolButton";
@@ -257,11 +258,7 @@ export const actionDeleteSelected = register({
: endBindingElement, : endBindingElement,
}; };
LinearElementEditor.deletePoints( LinearElementEditor.deletePoints(element, app, selectedPointsIndices);
element,
app.scene,
selectedPointsIndices,
);
return { return {
elements, elements,
@@ -1,16 +1,18 @@
import { getNonDeletedElements } from "@excalidraw/element"; import { getNonDeletedElements } from "@excalidraw/element";
import { isFrameLikeElement } from "@excalidraw/element/typeChecks"; import { isFrameLikeElement } from "@excalidraw/element";
import { CODES, KEYS, arrayToMap, getShortcutKey } from "@excalidraw/common"; import { CODES, KEYS, arrayToMap, getShortcutKey } from "@excalidraw/common";
import { updateFrameMembershipOfSelectedElements } from "@excalidraw/element/frame"; import { updateFrameMembershipOfSelectedElements } from "@excalidraw/element";
import { distributeElements } from "@excalidraw/element/distribute"; import { distributeElements } from "@excalidraw/element";
import { CaptureUpdateAction } from "@excalidraw/element";
import type { ExcalidrawElement } from "@excalidraw/element/types"; import type { ExcalidrawElement } from "@excalidraw/element/types";
import type { Distribution } from "@excalidraw/element/distribute"; import type { Distribution } from "@excalidraw/element";
import { ToolButton } from "../components/ToolButton"; import { ToolButton } from "../components/ToolButton";
import { import {
@@ -21,7 +23,6 @@ import {
import { t } from "../i18n"; import { t } from "../i18n";
import { isSomeElementSelected } from "../scene"; import { isSomeElementSelected } from "../scene";
import { CaptureUpdateAction } from "../store";
import { register } from "./register"; import { register } from "./register";
@@ -7,23 +7,24 @@ import {
import { getNonDeletedElements } from "@excalidraw/element"; import { getNonDeletedElements } from "@excalidraw/element";
import { LinearElementEditor } from "@excalidraw/element/linearElementEditor"; import { LinearElementEditor } from "@excalidraw/element";
import { import {
getSelectedElements, getSelectedElements,
getSelectionStateForElements, getSelectionStateForElements,
} from "@excalidraw/element/selection"; } from "@excalidraw/element";
import { syncMovedIndices } from "@excalidraw/element/fractionalIndex"; import { syncMovedIndices } from "@excalidraw/element";
import { duplicateElements } from "@excalidraw/element/duplicate"; import { duplicateElements } from "@excalidraw/element";
import { CaptureUpdateAction } from "@excalidraw/element";
import { ToolButton } from "../components/ToolButton"; import { ToolButton } from "../components/ToolButton";
import { DuplicateIcon } from "../components/icons"; import { DuplicateIcon } from "../components/icons";
import { t } from "../i18n"; import { t } from "../i18n";
import { isSomeElementSelected } from "../scene"; import { isSomeElementSelected } from "../scene";
import { CaptureUpdateAction } from "../store";
import { register } from "./register"; import { register } from "./register";
@@ -2,13 +2,14 @@ import {
canCreateLinkFromElements, canCreateLinkFromElements,
defaultGetElementLinkFromSelection, defaultGetElementLinkFromSelection,
getLinkIdAndTypeFromSelection, getLinkIdAndTypeFromSelection,
} from "@excalidraw/element/elementLink"; } from "@excalidraw/element";
import { CaptureUpdateAction } from "@excalidraw/element";
import { copyTextToSystemClipboard } from "../clipboard"; import { copyTextToSystemClipboard } from "../clipboard";
import { copyIcon, elementLinkIcon } from "../components/icons"; import { copyIcon, elementLinkIcon } from "../components/icons";
import { t } from "../i18n"; import { t } from "../i18n";
import { getSelectedElements } from "../scene"; import { getSelectedElements } from "../scene";
import { CaptureUpdateAction } from "../store";
import { register } from "./register"; import { register } from "./register";
+115 -24
View File
@@ -1,18 +1,23 @@
import { KEYS, arrayToMap } from "@excalidraw/common"; import { KEYS, arrayToMap, randomId } from "@excalidraw/common";
import { newElementWith } from "@excalidraw/element/mutateElement"; import {
elementsAreInSameGroup,
newElementWith,
selectGroupsFromGivenElements,
} from "@excalidraw/element";
import { isFrameLikeElement } from "@excalidraw/element/typeChecks"; import { CaptureUpdateAction } from "@excalidraw/element";
import type { ExcalidrawElement } from "@excalidraw/element/types"; import type { ExcalidrawElement } from "@excalidraw/element/types";
import { LockedIcon, UnlockedIcon } from "../components/icons"; import { LockedIcon, UnlockedIcon } from "../components/icons";
import { getSelectedElements } from "../scene"; import { getSelectedElements } from "../scene";
import { CaptureUpdateAction } from "../store";
import { register } from "./register"; import { register } from "./register";
import type { AppState } from "../types";
const shouldLock = (elements: readonly ExcalidrawElement[]) => const shouldLock = (elements: readonly ExcalidrawElement[]) =>
elements.every((el) => !el.locked); elements.every((el) => !el.locked);
@@ -23,15 +28,10 @@ export const actionToggleElementLock = register({
selectedElementIds: appState.selectedElementIds, selectedElementIds: appState.selectedElementIds,
includeBoundTextElement: false, includeBoundTextElement: false,
}); });
if (selected.length === 1 && !isFrameLikeElement(selected[0])) {
return selected[0].locked
? "labels.elementLock.unlock"
: "labels.elementLock.lock";
}
return shouldLock(selected) return shouldLock(selected)
? "labels.elementLock.lockAll" ? "labels.elementLock.lock"
: "labels.elementLock.unlockAll"; : "labels.elementLock.unlock";
}, },
icon: (appState, elements) => { icon: (appState, elements) => {
const selectedElements = getSelectedElements(elements, appState); const selectedElements = getSelectedElements(elements, appState);
@@ -58,19 +58,84 @@ export const actionToggleElementLock = register({
const nextLockState = shouldLock(selectedElements); const nextLockState = shouldLock(selectedElements);
const selectedElementsMap = arrayToMap(selectedElements); const selectedElementsMap = arrayToMap(selectedElements);
return {
elements: elements.map((element) => {
if (!selectedElementsMap.has(element.id)) {
return element;
}
return newElementWith(element, { locked: nextLockState }); const isAGroup =
}), selectedElements.length > 1 && elementsAreInSameGroup(selectedElements);
const isASingleUnit = selectedElements.length === 1 || isAGroup;
const newGroupId = isASingleUnit ? null : randomId();
let nextLockedMultiSelections = { ...appState.lockedMultiSelections };
if (nextLockState) {
nextLockedMultiSelections = {
...appState.lockedMultiSelections,
...(newGroupId ? { [newGroupId]: true } : {}),
};
} else if (isAGroup) {
const groupId = selectedElements[0].groupIds.at(-1)!;
delete nextLockedMultiSelections[groupId];
}
const nextElements = elements.map((element) => {
if (!selectedElementsMap.has(element.id)) {
return element;
}
let nextGroupIds = element.groupIds;
// if locking together, add to group
// if unlocking, remove the temporary group
if (nextLockState) {
if (newGroupId) {
nextGroupIds = [...nextGroupIds, newGroupId];
}
} else {
nextGroupIds = nextGroupIds.filter(
(groupId) => !appState.lockedMultiSelections[groupId],
);
}
return newElementWith(element, {
locked: nextLockState,
// do not recreate the array unncessarily
groupIds:
nextGroupIds.length !== element.groupIds.length
? nextGroupIds
: element.groupIds,
});
});
const nextElementsMap = arrayToMap(nextElements);
const nextSelectedElementIds: AppState["selectedElementIds"] = nextLockState
? {}
: Object.fromEntries(selectedElements.map((el) => [el.id, true]));
const unlockedSelectedElements = selectedElements.map(
(el) => nextElementsMap.get(el.id) || el,
);
const nextSelectedGroupIds = nextLockState
? {}
: selectGroupsFromGivenElements(unlockedSelectedElements, appState);
const activeLockedId = nextLockState
? newGroupId
? newGroupId
: isAGroup
? selectedElements[0].groupIds.at(-1)!
: selectedElements[0].id
: null;
return {
elements: nextElements,
appState: { appState: {
...appState, ...appState,
selectedElementIds: nextSelectedElementIds,
selectedGroupIds: nextSelectedGroupIds,
selectedLinearElement: nextLockState selectedLinearElement: nextLockState
? null ? null
: appState.selectedLinearElement, : appState.selectedLinearElement,
lockedMultiSelections: nextLockedMultiSelections,
activeLockedId,
}, },
captureUpdate: CaptureUpdateAction.IMMEDIATELY, captureUpdate: CaptureUpdateAction.IMMEDIATELY,
}; };
@@ -103,18 +168,44 @@ export const actionUnlockAllElements = register({
perform: (elements, appState) => { perform: (elements, appState) => {
const lockedElements = elements.filter((el) => el.locked); const lockedElements = elements.filter((el) => el.locked);
const nextElements = elements.map((element) => {
if (element.locked) {
// remove the temporary groupId if it exists
const nextGroupIds = element.groupIds.filter(
(gid) => !appState.lockedMultiSelections[gid],
);
return newElementWith(element, {
locked: false,
groupIds:
// do not recreate the array unncessarily
element.groupIds.length !== nextGroupIds.length
? nextGroupIds
: element.groupIds,
});
}
return element;
});
const nextElementsMap = arrayToMap(nextElements);
const unlockedElements = lockedElements.map(
(el) => nextElementsMap.get(el.id) || el,
);
return { return {
elements: elements.map((element) => { elements: nextElements,
if (element.locked) {
return newElementWith(element, { locked: false });
}
return element;
}),
appState: { appState: {
...appState, ...appState,
selectedElementIds: Object.fromEntries( selectedElementIds: Object.fromEntries(
lockedElements.map((el) => [el.id, true]), lockedElements.map((el) => [el.id, true]),
), ),
selectedGroupIds: selectGroupsFromGivenElements(
unlockedElements,
appState,
),
lockedMultiSelections: {},
activeLockedId: null,
}, },
captureUpdate: CaptureUpdateAction.IMMEDIATELY, captureUpdate: CaptureUpdateAction.IMMEDIATELY,
}; };
@@ -1,7 +1,8 @@
import { updateActiveTool } from "@excalidraw/common"; import { updateActiveTool } from "@excalidraw/common";
import { CaptureUpdateAction } from "@excalidraw/element";
import { setCursorForShape } from "../cursor"; import { setCursorForShape } from "../cursor";
import { CaptureUpdateAction } from "../store";
import { register } from "./register"; import { register } from "./register";
+2 -1
View File
@@ -7,6 +7,8 @@ import {
import { getNonDeletedElements } from "@excalidraw/element"; import { getNonDeletedElements } from "@excalidraw/element";
import { CaptureUpdateAction } from "@excalidraw/element";
import type { Theme } from "@excalidraw/element/types"; import type { Theme } from "@excalidraw/element/types";
import { useDevice } from "../components/App"; import { useDevice } from "../components/App";
@@ -24,7 +26,6 @@ import { resaveAsImageWithScene } from "../data/resave";
import { t } from "../i18n"; import { t } from "../i18n";
import { getSelectedElements, isSomeElementSelected } from "../scene"; import { getSelectedElements, isSomeElementSelected } from "../scene";
import { getExportSize } from "../scene/export"; import { getExportSize } from "../scene/export";
import { CaptureUpdateAction } from "../store";
import "../components/ToolIcon.scss"; import "../components/ToolIcon.scss";
+133 -70
View File
@@ -3,24 +3,35 @@ import { pointFrom } from "@excalidraw/math";
import { import {
maybeBindLinearElement, maybeBindLinearElement,
bindOrUnbindLinearElement, bindOrUnbindLinearElement,
isBindingEnabled,
} from "@excalidraw/element/binding"; } from "@excalidraw/element/binding";
import { LinearElementEditor } from "@excalidraw/element/linearElementEditor"; import { isValidPolygon, LinearElementEditor } from "@excalidraw/element";
import { import {
isBindingElement, isBindingElement,
isFreeDrawElement,
isLinearElement, isLinearElement,
} from "@excalidraw/element/typeChecks"; isLineElement,
} from "@excalidraw/element";
import { KEYS, arrayToMap, updateActiveTool } from "@excalidraw/common"; import { KEYS, arrayToMap, updateActiveTool } from "@excalidraw/common";
import { isPathALoop } from "@excalidraw/element/shapes"; import { isPathALoop } from "@excalidraw/element";
import { isInvisiblySmallElement } from "@excalidraw/element/sizeHelpers"; import { isInvisiblySmallElement } from "@excalidraw/element";
import { CaptureUpdateAction } from "@excalidraw/element";
import type { LocalPoint } from "@excalidraw/math";
import type {
ExcalidrawElement,
ExcalidrawLinearElement,
NonDeleted,
} from "@excalidraw/element/types";
import { t } from "../i18n"; import { t } from "../i18n";
import { resetCursor } from "../cursor"; import { resetCursor } from "../cursor";
import { done } from "../components/icons"; import { done } from "../components/icons";
import { ToolButton } from "../components/ToolButton"; import { ToolButton } from "../components/ToolButton";
import { CaptureUpdateAction } from "../store";
import { register } from "./register"; import { register } from "./register";
@@ -30,11 +41,50 @@ export const actionFinalize = register({
name: "finalize", name: "finalize",
label: "", label: "",
trackEvent: false, trackEvent: false,
perform: (elements, appState, _, app) => { perform: (elements, appState, data, app) => {
const { interactiveCanvas, focusContainer, scene } = app; const { interactiveCanvas, focusContainer, scene } = app;
const elementsMap = scene.getNonDeletedElementsMap(); const elementsMap = scene.getNonDeletedElementsMap();
if (data?.event && appState.selectedLinearElement) {
const linearElementEditor = LinearElementEditor.handlePointerUp(
data.event,
appState.selectedLinearElement,
appState,
app.scene,
);
const { startBindingElement, endBindingElement } = linearElementEditor;
const element = app.scene.getElement(linearElementEditor.elementId);
if (isBindingElement(element)) {
bindOrUnbindLinearElement(
element,
startBindingElement,
endBindingElement,
app.scene,
);
}
if (linearElementEditor !== appState.selectedLinearElement) {
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);
}
return {
elements: newElements,
appState: {
selectedLinearElement: {
...linearElementEditor,
selectedPointsIndices: null,
},
suggestedBindings: [],
},
captureUpdate: CaptureUpdateAction.IMMEDIATELY,
};
}
}
if (appState.editingLinearElement) { if (appState.editingLinearElement) {
const { elementId, startBindingElement, endBindingElement } = const { elementId, startBindingElement, endBindingElement } =
appState.editingLinearElement; appState.editingLinearElement;
@@ -49,6 +99,12 @@ export const actionFinalize = register({
scene, scene,
); );
} }
if (isLineElement(element) && !isValidPolygon(element.points)) {
scene.mutateElement(element, {
polygon: false,
});
}
return { return {
elements: elements:
element.points.length < 2 || isInvisiblySmallElement(element) element.points.length < 2 || isInvisiblySmallElement(element)
@@ -66,91 +122,102 @@ export const actionFinalize = register({
let newElements = elements; 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) { if (window.document.activeElement instanceof HTMLElement) {
focusContainer(); focusContainer();
} }
const multiPointElement = appState.multiElement let element: NonDeleted<ExcalidrawElement> | null = null;
? appState.multiElement if (appState.multiElement) {
: appState.newElement?.type === "freedraw" element = appState.multiElement;
? appState.newElement } else if (
: null; appState.newElement?.type === "freedraw" ||
isBindingElement(appState.newElement)
) {
element = appState.newElement;
} else if (Object.keys(appState.selectedElementIds).length === 1) {
const candidate = elementsMap.get(
Object.keys(appState.selectedElementIds)[0],
) as NonDeleted<ExcalidrawLinearElement> | undefined;
if (candidate) {
element = candidate;
}
}
if (multiPointElement) { if (element) {
// pen and mouse have hover // pen and mouse have hover
if ( if (
multiPointElement.type !== "freedraw" && appState.multiElement &&
element.type !== "freedraw" &&
appState.lastPointerDownWith !== "touch" appState.lastPointerDownWith !== "touch"
) { ) {
const { points, lastCommittedPoint } = multiPointElement; const { points, lastCommittedPoint } = element;
if ( if (
!lastCommittedPoint || !lastCommittedPoint ||
points[points.length - 1] !== lastCommittedPoint points[points.length - 1] !== lastCommittedPoint
) { ) {
scene.mutateElement(multiPointElement, { scene.mutateElement(element, {
points: multiPointElement.points.slice(0, -1), points: element.points.slice(0, -1),
}); });
} }
} }
if (isInvisiblySmallElement(multiPointElement)) { 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 // 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( newElements = newElements.filter((el) => el.id !== element!.id);
(el) => el.id !== multiPointElement.id,
);
} }
// If the multi point line closes the loop, if (isLinearElement(element) || isFreeDrawElement(element)) {
// set the last point to first point. // If the multi point line closes the loop,
// This ensures that loop remains closed at different scales. // set the last point to first point.
const isLoop = isPathALoop(multiPointElement.points, appState.zoom.value); // This ensures that loop remains closed at different scales.
if ( const isLoop = isPathALoop(element.points, appState.zoom.value);
multiPointElement.type === "line" ||
multiPointElement.type === "freedraw" if (isLoop && (isLineElement(element) || isFreeDrawElement(element))) {
) { const linePoints = element.points;
if (isLoop) {
const linePoints = multiPointElement.points;
const firstPoint = linePoints[0]; const firstPoint = linePoints[0];
scene.mutateElement(multiPointElement, { const points: LocalPoint[] = linePoints.map((p, index) =>
points: linePoints.map((p, index) => index === linePoints.length - 1
index === linePoints.length - 1 ? pointFrom(firstPoint[0], firstPoint[1])
? pointFrom(firstPoint[0], firstPoint[1]) : p,
: p, );
), if (isLineElement(element)) {
scene.mutateElement(element, {
points,
polygon: true,
});
} else {
scene.mutateElement(element, {
points,
});
}
}
if (isLineElement(element) && !isValidPolygon(element.points)) {
scene.mutateElement(element, {
polygon: false,
}); });
} }
}
if ( if (
isBindingElement(multiPointElement) && isBindingElement(element) &&
!isLoop && !isLoop &&
multiPointElement.points.length > 1 element.points.length > 1 &&
) { isBindingEnabled(appState)
const [x, y] = LinearElementEditor.getPointAtIndexGlobalCoordinates( ) {
multiPointElement, const [x, y] = LinearElementEditor.getPointAtIndexGlobalCoordinates(
-1, element,
arrayToMap(elements), -1,
); arrayToMap(elements),
maybeBindLinearElement(multiPointElement, appState, { x, y }, scene); );
maybeBindLinearElement(element, appState, { x, y }, scene);
}
} }
} }
if ( if (
(!appState.activeTool.locked && (!appState.activeTool.locked &&
appState.activeTool.type !== "freedraw") || appState.activeTool.type !== "freedraw") ||
!multiPointElement !element
) { ) {
resetCursor(interactiveCanvas); resetCursor(interactiveCanvas);
} }
@@ -177,7 +244,7 @@ export const actionFinalize = register({
activeTool: activeTool:
(appState.activeTool.locked || (appState.activeTool.locked ||
appState.activeTool.type === "freedraw") && appState.activeTool.type === "freedraw") &&
multiPointElement element
? appState.activeTool ? appState.activeTool
: activeTool, : activeTool,
activeEmbeddable: null, activeEmbeddable: null,
@@ -188,23 +255,19 @@ export const actionFinalize = register({
startBoundElement: null, startBoundElement: null,
suggestedBindings: [], suggestedBindings: [],
selectedElementIds: selectedElementIds:
multiPointElement && element &&
!appState.activeTool.locked && !appState.activeTool.locked &&
appState.activeTool.type !== "freedraw" appState.activeTool.type !== "freedraw"
? { ? {
...appState.selectedElementIds, ...appState.selectedElementIds,
[multiPointElement.id]: true, [element.id]: true,
} }
: appState.selectedElementIds, : appState.selectedElementIds,
// To select the linear element when user has finished mutipoint editing // To select the linear element when user has finished mutipoint editing
selectedLinearElement: selectedLinearElement:
multiPointElement && isLinearElement(multiPointElement) element && isLinearElement(element)
? new LinearElementEditor( ? new LinearElementEditor(element, arrayToMap(newElements))
multiPointElement,
arrayToMap(newElements),
)
: appState.selectedLinearElement, : appState.selectedLinearElement,
pendingImageElementId: null,
}, },
// TODO: #7348 we should not capture everything, but if we don't, it leads to incosistencies -> revisit // TODO: #7348 we should not capture everything, but if we don't, it leads to incosistencies -> revisit
captureUpdate: CaptureUpdateAction.IMMEDIATELY, captureUpdate: CaptureUpdateAction.IMMEDIATELY,
+9 -8
View File
@@ -2,19 +2,21 @@ import { getNonDeletedElements } from "@excalidraw/element";
import { import {
bindOrUnbindLinearElements, bindOrUnbindLinearElements,
isBindingEnabled, isBindingEnabled,
} from "@excalidraw/element/binding"; } from "@excalidraw/element";
import { getCommonBoundingBox } from "@excalidraw/element/bounds"; import { getCommonBoundingBox } from "@excalidraw/element";
import { newElementWith } from "@excalidraw/element/mutateElement"; import { newElementWith } from "@excalidraw/element";
import { deepCopyElement } from "@excalidraw/element/duplicate"; import { deepCopyElement } from "@excalidraw/element";
import { resizeMultipleElements } from "@excalidraw/element/resizeElements"; import { resizeMultipleElements } from "@excalidraw/element";
import { import {
isArrowElement, isArrowElement,
isElbowArrow, isElbowArrow,
isLinearElement, isLinearElement,
} from "@excalidraw/element/typeChecks"; } from "@excalidraw/element";
import { updateFrameMembershipOfSelectedElements } from "@excalidraw/element/frame"; import { updateFrameMembershipOfSelectedElements } from "@excalidraw/element";
import { CODES, KEYS, arrayToMap } from "@excalidraw/common"; import { CODES, KEYS, arrayToMap } from "@excalidraw/common";
import { CaptureUpdateAction } from "@excalidraw/element";
import type { import type {
ExcalidrawArrowElement, ExcalidrawArrowElement,
ExcalidrawElbowArrowElement, ExcalidrawElbowArrowElement,
@@ -24,7 +26,6 @@ import type {
} from "@excalidraw/element/types"; } from "@excalidraw/element/types";
import { getSelectedElements } from "../scene"; import { getSelectedElements } from "../scene";
import { CaptureUpdateAction } from "../store";
import { flipHorizontal, flipVertical } from "../components/icons"; import { flipHorizontal, flipVertical } from "../components/icons";
+9 -8
View File
@@ -1,25 +1,26 @@
import { getNonDeletedElements } from "@excalidraw/element"; import { getNonDeletedElements } from "@excalidraw/element";
import { mutateElement } from "@excalidraw/element/mutateElement"; import { mutateElement } from "@excalidraw/element";
import { newFrameElement } from "@excalidraw/element/newElement"; import { newFrameElement } from "@excalidraw/element";
import { isFrameLikeElement } from "@excalidraw/element/typeChecks"; import { isFrameLikeElement } from "@excalidraw/element";
import { import {
addElementsToFrame, addElementsToFrame,
removeAllElementsFromFrame, removeAllElementsFromFrame,
} from "@excalidraw/element/frame"; } from "@excalidraw/element";
import { getFrameChildren } from "@excalidraw/element/frame"; import { getFrameChildren } from "@excalidraw/element";
import { KEYS, updateActiveTool } from "@excalidraw/common"; import { KEYS, updateActiveTool } from "@excalidraw/common";
import { getElementsInGroup } from "@excalidraw/element/groups"; import { getElementsInGroup } from "@excalidraw/element";
import { getCommonBounds } from "@excalidraw/element/bounds"; import { getCommonBounds } from "@excalidraw/element";
import { CaptureUpdateAction } from "@excalidraw/element";
import type { ExcalidrawElement } from "@excalidraw/element/types"; import type { ExcalidrawElement } from "@excalidraw/element/types";
import { setCursorForShape } from "../cursor"; import { setCursorForShape } from "../cursor";
import { frameToolIcon } from "../components/icons"; import { frameToolIcon } from "../components/icons";
import { getSelectedElements } from "../scene"; import { getSelectedElements } from "../scene";
import { CaptureUpdateAction } from "../store";
import { register } from "./register"; import { register } from "./register";
+7 -6
View File
@@ -1,8 +1,8 @@
import { getNonDeletedElements } from "@excalidraw/element"; import { getNonDeletedElements } from "@excalidraw/element";
import { newElementWith } from "@excalidraw/element/mutateElement"; import { newElementWith } from "@excalidraw/element";
import { isBoundToContainer } from "@excalidraw/element/typeChecks"; import { isBoundToContainer } from "@excalidraw/element";
import { import {
frameAndChildrenSelectedTogether, frameAndChildrenSelectedTogether,
@@ -12,7 +12,7 @@ import {
groupByFrameLikes, groupByFrameLikes,
removeElementsFromFrame, removeElementsFromFrame,
replaceAllElementsInFrame, replaceAllElementsInFrame,
} from "@excalidraw/element/frame"; } from "@excalidraw/element";
import { KEYS, randomId, arrayToMap, getShortcutKey } from "@excalidraw/common"; import { KEYS, randomId, arrayToMap, getShortcutKey } from "@excalidraw/common";
@@ -24,9 +24,11 @@ import {
addToGroup, addToGroup,
removeFromSelectedGroups, removeFromSelectedGroups,
isElementInGroup, isElementInGroup,
} from "@excalidraw/element/groups"; } from "@excalidraw/element";
import { syncMovedIndices } from "@excalidraw/element/fractionalIndex"; import { syncMovedIndices } from "@excalidraw/element";
import { CaptureUpdateAction } from "@excalidraw/element";
import type { import type {
ExcalidrawElement, ExcalidrawElement,
@@ -40,7 +42,6 @@ import { UngroupIcon, GroupIcon } from "../components/icons";
import { t } from "../i18n"; import { t } from "../i18n";
import { isSomeElementSelected } from "../scene"; import { isSomeElementSelected } from "../scene";
import { CaptureUpdateAction } from "../store";
import { register } from "./register"; import { register } from "./register";
+15 -17
View File
@@ -1,5 +1,9 @@
import { isWindows, KEYS, matchKey, arrayToMap } from "@excalidraw/common"; import { isWindows, KEYS, matchKey, arrayToMap } from "@excalidraw/common";
import { CaptureUpdateAction } from "@excalidraw/element";
import { orderByFractionalIndex } from "@excalidraw/element";
import type { SceneElementsMap } from "@excalidraw/element/types"; import type { SceneElementsMap } from "@excalidraw/element/types";
import { ToolButton } from "../components/ToolButton"; import { ToolButton } from "../components/ToolButton";
@@ -7,10 +11,8 @@ import { UndoIcon, RedoIcon } from "../components/icons";
import { HistoryChangedEvent } from "../history"; import { HistoryChangedEvent } from "../history";
import { useEmitter } from "../hooks/useEmitter"; import { useEmitter } from "../hooks/useEmitter";
import { t } from "../i18n"; import { t } from "../i18n";
import { CaptureUpdateAction } from "../store";
import type { History } from "../history"; import type { History } from "../history";
import type { Store } from "../store";
import type { AppClassProperties, AppState } from "../types"; import type { AppClassProperties, AppState } from "../types";
import type { Action, ActionResult } from "./types"; import type { Action, ActionResult } from "./types";
@@ -35,7 +37,11 @@ const executeHistoryAction = (
} }
const [nextElementsMap, nextAppState] = result; const [nextElementsMap, nextAppState] = result;
const nextElements = Array.from(nextElementsMap.values());
// order by fractional indices in case the map was accidently modified in the meantime
const nextElements = orderByFractionalIndex(
Array.from(nextElementsMap.values()),
);
return { return {
appState: nextAppState, appState: nextAppState,
@@ -47,9 +53,9 @@ const executeHistoryAction = (
return { captureUpdate: CaptureUpdateAction.EVENTUALLY }; return { captureUpdate: CaptureUpdateAction.EVENTUALLY };
}; };
type ActionCreator = (history: History, store: Store) => Action; type ActionCreator = (history: History) => Action;
export const createUndoAction: ActionCreator = (history, store) => ({ export const createUndoAction: ActionCreator = (history) => ({
name: "undo", name: "undo",
label: "buttons.undo", label: "buttons.undo",
icon: UndoIcon, icon: UndoIcon,
@@ -57,11 +63,7 @@ export const createUndoAction: ActionCreator = (history, store) => ({
viewMode: false, viewMode: false,
perform: (elements, appState, value, app) => perform: (elements, appState, value, app) =>
executeHistoryAction(app, appState, () => executeHistoryAction(app, appState, () =>
history.undo( history.undo(arrayToMap(elements) as SceneElementsMap, appState),
arrayToMap(elements) as SceneElementsMap, // TODO: #7348 refactor action manager to already include `SceneElementsMap`
appState,
store.snapshot,
),
), ),
keyTest: (event) => keyTest: (event) =>
event[KEYS.CTRL_OR_CMD] && matchKey(event, KEYS.Z) && !event.shiftKey, event[KEYS.CTRL_OR_CMD] && matchKey(event, KEYS.Z) && !event.shiftKey,
@@ -88,19 +90,15 @@ export const createUndoAction: ActionCreator = (history, store) => ({
}, },
}); });
export const createRedoAction: ActionCreator = (history, store) => ({ export const createRedoAction: ActionCreator = (history) => ({
name: "redo", name: "redo",
label: "buttons.redo", label: "buttons.redo",
icon: RedoIcon, icon: RedoIcon,
trackEvent: { category: "history" }, trackEvent: { category: "history" },
viewMode: false, viewMode: false,
perform: (elements, appState, _, app) => perform: (elements, appState, __, app) =>
executeHistoryAction(app, appState, () => executeHistoryAction(app, appState, () =>
history.redo( history.redo(arrayToMap(elements) as SceneElementsMap, appState),
arrayToMap(elements) as SceneElementsMap, // TODO: #7348 refactor action manager to already include `SceneElementsMap`
appState,
store.snapshot,
),
), ),
keyTest: (event) => keyTest: (event) =>
(event[KEYS.CTRL_OR_CMD] && event.shiftKey && matchKey(event, KEYS.Z)) || (event[KEYS.CTRL_OR_CMD] && event.shiftKey && matchKey(event, KEYS.Z)) ||
@@ -1,17 +1,28 @@
import { LinearElementEditor } from "@excalidraw/element/linearElementEditor"; import { LinearElementEditor } from "@excalidraw/element";
import {
import { isElbowArrow, isLinearElement } from "@excalidraw/element/typeChecks"; isElbowArrow,
isLinearElement,
isLineElement,
} from "@excalidraw/element";
import { arrayToMap } from "@excalidraw/common"; import { arrayToMap } from "@excalidraw/common";
import type { ExcalidrawLinearElement } from "@excalidraw/element/types"; import { CaptureUpdateAction } from "@excalidraw/element";
import type {
ExcalidrawLinearElement,
ExcalidrawLineElement,
} from "@excalidraw/element/types";
import { DEFAULT_CATEGORIES } from "../components/CommandPalette/CommandPalette"; import { DEFAULT_CATEGORIES } from "../components/CommandPalette/CommandPalette";
import { ToolButton } from "../components/ToolButton"; import { ToolButton } from "../components/ToolButton";
import { lineEditorIcon } from "../components/icons"; import { lineEditorIcon, polygonIcon } from "../components/icons";
import { t } from "../i18n"; import { t } from "../i18n";
import { CaptureUpdateAction } from "../store";
import { ButtonIcon } from "../components/ButtonIcon";
import { newElementWith } from "../../element/src/mutateElement";
import { toggleLinePolygonState } from "../../element/src/shapes";
import { register } from "./register"; import { register } from "./register";
@@ -82,3 +93,110 @@ export const actionToggleLinearEditor = register({
); );
}, },
}); });
export const actionTogglePolygon = register({
name: "togglePolygon",
category: DEFAULT_CATEGORIES.elements,
icon: polygonIcon,
keywords: ["loop"],
label: (elements, appState, app) => {
const selectedElements = app.scene.getSelectedElements({
selectedElementIds: appState.selectedElementIds,
});
const allPolygons = !selectedElements.some(
(element) => !isLineElement(element) || !element.polygon,
);
return allPolygons
? "labels.polygon.breakPolygon"
: "labels.polygon.convertToPolygon";
},
trackEvent: {
category: "element",
},
predicate: (elements, appState, _, app) => {
const selectedElements = app.scene.getSelectedElements({
selectedElementIds: appState.selectedElementIds,
});
return (
selectedElements.length > 0 &&
selectedElements.every(
(element) => isLineElement(element) && element.points.length >= 4,
)
);
},
perform(elements, appState, _, app) {
const selectedElements = app.scene.getSelectedElements(appState);
if (selectedElements.some((element) => !isLineElement(element))) {
return false;
}
const targetElements = selectedElements as ExcalidrawLineElement[];
// if one element not a polygon, convert all to polygon
const nextPolygonState = targetElements.some((element) => !element.polygon);
const targetElementsMap = arrayToMap(targetElements);
return {
elements: elements.map((element) => {
if (!targetElementsMap.has(element.id) || !isLineElement(element)) {
return element;
}
return newElementWith(element, {
backgroundColor: nextPolygonState
? element.backgroundColor
: "transparent",
...toggleLinePolygonState(element, nextPolygonState),
});
}),
appState,
captureUpdate: CaptureUpdateAction.IMMEDIATELY,
};
},
PanelComponent: ({ appState, updateData, app }) => {
const selectedElements = app.scene.getSelectedElements({
selectedElementIds: appState.selectedElementIds,
});
if (
selectedElements.length === 0 ||
selectedElements.some(
(element) =>
!isLineElement(element) ||
// only show polygon button if every selected element is already
// a polygon, effectively showing this button only to allow for
// disabling the polygon state
!element.polygon ||
element.points.length < 3,
)
) {
return null;
}
const allPolygon = selectedElements.every(
(element) => isLineElement(element) && element.polygon,
);
const label = t(
allPolygon
? "labels.polygon.breakPolygon"
: "labels.polygon.convertToPolygon",
);
return (
<ButtonIcon
icon={polygonIcon}
title={label}
aria-label={label}
active={allPolygon}
onClick={() => updateData(null)}
style={{ marginLeft: "auto" }}
/>
);
},
});
+3 -2
View File
@@ -1,14 +1,15 @@
import { isEmbeddableElement } from "@excalidraw/element/typeChecks"; import { isEmbeddableElement } from "@excalidraw/element";
import { KEYS, getShortcutKey } from "@excalidraw/common"; import { KEYS, getShortcutKey } from "@excalidraw/common";
import { CaptureUpdateAction } from "@excalidraw/element";
import { ToolButton } from "../components/ToolButton"; import { ToolButton } from "../components/ToolButton";
import { getContextMenuLabel } from "../components/hyperlink/Hyperlink"; import { getContextMenuLabel } from "../components/hyperlink/Hyperlink";
import { LinkIcon } from "../components/icons"; import { LinkIcon } from "../components/icons";
import { t } from "../i18n"; import { t } from "../i18n";
import { getSelectedElements } from "../scene"; import { getSelectedElements } from "../scene";
import { CaptureUpdateAction } from "../store";
import { register } from "./register"; import { register } from "./register";
+3 -3
View File
@@ -2,14 +2,14 @@ import { KEYS } from "@excalidraw/common";
import { getNonDeletedElements } from "@excalidraw/element"; import { getNonDeletedElements } from "@excalidraw/element";
import { showSelectedShapeActions } from "@excalidraw/element/showSelectedShapeActions"; import { showSelectedShapeActions } from "@excalidraw/element";
import { CaptureUpdateAction } from "@excalidraw/element";
import { ToolButton } from "../components/ToolButton"; import { ToolButton } from "../components/ToolButton";
import { HamburgerMenuIcon, HelpIconThin, palette } from "../components/icons"; import { HamburgerMenuIcon, HelpIconThin, palette } from "../components/icons";
import { t } from "../i18n"; import { t } from "../i18n";
import { CaptureUpdateAction } from "../store";
import { register } from "./register"; import { register } from "./register";
export const actionToggleCanvasMenu = register({ export const actionToggleCanvasMenu = register({
@@ -1,5 +1,7 @@
import clsx from "clsx"; import clsx from "clsx";
import { CaptureUpdateAction } from "@excalidraw/element";
import { getClientColor } from "../clients"; import { getClientColor } from "../clients";
import { Avatar } from "../components/Avatar"; import { Avatar } from "../components/Avatar";
import { import {
@@ -8,7 +10,6 @@ import {
microphoneMutedIcon, microphoneMutedIcon,
} from "../components/icons"; } from "../components/icons";
import { t } from "../i18n"; import { t } from "../i18n";
import { CaptureUpdateAction } from "../store";
import { register } from "./register"; import { register } from "./register";
File diff suppressed because it is too large Load Diff
@@ -1,15 +1,15 @@
import { getNonDeletedElements } from "@excalidraw/element"; import { getNonDeletedElements } from "@excalidraw/element";
import { LinearElementEditor } from "@excalidraw/element/linearElementEditor"; import { LinearElementEditor } from "@excalidraw/element";
import { isLinearElement, isTextElement } from "@excalidraw/element/typeChecks"; import { isLinearElement, isTextElement } from "@excalidraw/element";
import { arrayToMap, KEYS } from "@excalidraw/common"; import { arrayToMap, KEYS } from "@excalidraw/common";
import { selectGroupsForSelectedElements } from "@excalidraw/element/groups"; import { selectGroupsForSelectedElements } from "@excalidraw/element";
import { CaptureUpdateAction } from "@excalidraw/element";
import type { ExcalidrawElement } from "@excalidraw/element/types"; import type { ExcalidrawElement } from "@excalidraw/element/types";
import { CaptureUpdateAction } from "../store";
import { selectAllIcon } from "../components/icons"; import { selectAllIcon } from "../components/icons";
import { register } from "./register"; import { register } from "./register";
+5 -4
View File
@@ -7,7 +7,7 @@ import {
getLineHeight, getLineHeight,
} from "@excalidraw/common"; } from "@excalidraw/common";
import { newElementWith } from "@excalidraw/element/mutateElement"; import { newElementWith } from "@excalidraw/element";
import { import {
hasBoundTextElement, hasBoundTextElement,
@@ -17,12 +17,14 @@ import {
isArrowElement, isArrowElement,
isExcalidrawElement, isExcalidrawElement,
isTextElement, isTextElement,
} from "@excalidraw/element/typeChecks"; } from "@excalidraw/element";
import { import {
getBoundTextElement, getBoundTextElement,
redrawTextBoundingBox, redrawTextBoundingBox,
} from "@excalidraw/element/textElement"; } from "@excalidraw/element";
import { CaptureUpdateAction } from "@excalidraw/element";
import type { ExcalidrawTextElement } from "@excalidraw/element/types"; import type { ExcalidrawTextElement } from "@excalidraw/element/types";
@@ -30,7 +32,6 @@ import { paintIcon } from "../components/icons";
import { t } from "../i18n"; import { t } from "../i18n";
import { getSelectedElements } from "../scene"; import { getSelectedElements } from "../scene";
import { CaptureUpdateAction } from "../store";
import { register } from "./register"; import { register } from "./register";
@@ -1,12 +1,13 @@
import { getFontString } from "@excalidraw/common"; import { getFontString } from "@excalidraw/common";
import { newElementWith } from "@excalidraw/element/mutateElement"; import { newElementWith } from "@excalidraw/element";
import { measureText } from "@excalidraw/element/textMeasurements"; import { measureText } from "@excalidraw/element";
import { isTextElement } from "@excalidraw/element/typeChecks"; import { isTextElement } from "@excalidraw/element";
import { CaptureUpdateAction } from "@excalidraw/element";
import { getSelectedElements } from "../scene"; import { getSelectedElements } from "../scene";
import { CaptureUpdateAction } from "../store";
import { register } from "./register"; import { register } from "./register";

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