Compare commits

..

66 Commits

Author SHA1 Message Date
5639bb8e87 Updates to point to excalidraw-storage-backend 2025-05-25 22:22:50 -04: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
Marcel Mraz
1913599594
refactor: remove dependency on the (static) Scene (#9389) 2025-04-23 13:45:08 +02:00
Vedant Mishra
debf2ad608
docs: Fix missing verb in Footer component documentation (#9393) 2025-04-20 12:35:38 +02:00
David Luzar
8fb2f70414
fix: scrollbar rendering and improve dragging (#9417)
* fix: scrollbar rendering and improve dragging

* tweak offsets
2025-04-20 12:28:41 +02:00
Jack Walsh
5fc13e4309
feat: add props.renderScrollbars (#9399)
* Expose renderScrollbars to AppState

* fix: scrollbar rendering should use al renderable elements

* remove `appState.renderScrollbars`

* clean unused

---------

Co-authored-by: dwelle <5153846+dwelle@users.noreply.github.com>
2025-04-19 21:50:44 +00:00
David Luzar
b5d60973b7
fix: duplication tests pointer state leaking between tests (#9414)
* fix: duplication tests pointer state leaking between tests

* fix snapshots
2025-04-18 11:11:12 +02:00
David Luzar
a5d6939826
fix: keep orig elem in place on alt-duplication (#9403)
* fix: keep orig elem in place on alt-duplication

* clarify comment

* fix: incorrect selection on duplicating labeled containers

* fix: duplicating within group outside frame should remove from group
2025-04-17 16:08:07 +02:00
David Luzar
0cf36d6b30
fix: erasing locked elements (#9400)
* fix: erasing locked elements

* signature tweaks
2025-04-16 10:28:56 +02:00
Ryan Di
58f7d33d80
perf: make eraser great again (#9352)
* perf: make eraser great again

* lint

* refactor and improve perf

* lint
2025-04-15 16:58:45 +02:00
Rubén Norte
6fe7de8020
fix: Add DOCTYPE and XML preamble in exported SVG documents (#9386)
* Add DOCTYPE and XML preamble in exported SVG documents

* Update packages/excalidraw/data/index.ts

---------

Co-authored-by: David Luzar <5153846+dwelle@users.noreply.github.com>
2025-04-14 21:25:18 +02:00
Márk Tolmács
01304aac49
feat: Keep text label horizontal (#9364)
Co-authored-by: dwelle <5153846+dwelle@users.noreply.github.com>
2025-04-13 21:21:49 +02:00
jhanma17dev
dff69e9191
chore: Element center point util (#9298) 2025-04-09 17:04:51 +02:00
Ryan Di
6fc85022ae
fix: lasso selection issues (#9353)
* revert stroke slicing hack for knot

* fix incorrect closing of path

* nonzero enclosure

* lint
2025-04-08 00:50:52 +10:00
Márk Tolmács
e48b63a0ae
fix: Rounded diamond edge elbow arrow U route (#9349) 2025-04-07 10:43:07 +02:00
David Luzar
c2caf78e95
fix: deselected hit element being duplicated + incorrect re-seeding (#9333)
* fix: deselected hit element being duplicated + incorrect re-seeding

* snapshots

* Fix alt-drag binding

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

* Add alt-drag bound arrow test

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

---------

Signed-off-by: Mark Tolmacs <mark@lazycat.hu>
Co-authored-by: Mark Tolmacs <mark@lazycat.hu>
2025-04-07 10:41:31 +02:00
Ryan Di
ce267aa0d3
feat: lasso selection (#9169)
* lasso without 'real' shape detection

* select a single linear el

* improve ux

* feed segments to worker

* simplify path threshold adaptive to zoom

* add a tiny threshold for checks

* refactor code

* lasso tests

* fix: ts

* do not capture lasso tool

* try worker-loader in next config

* update config

* refactor

* lint

* feat: show active tool when using "more tools"

* keep lasso if selected from toolbar

* fix incorrect checks for resetting to selection

* shift for additive selection

* bound text related fixes

* lint

* keep alt toggled lasso selection if shift pressed

* fix regression

* fix 'dead' lassos

* lint

* use workerpool and polyfill

* fix worker bundled with window related code

* refactor

* add file extension for worker constructor error

* another attempt at constructor error

* attempt at build issue

* attempt with dynamic import

* test not importing from math

* narrow down imports

* Reusing existing workers infrastructure (fallback to the main thread, type-safety)

* Points on curve inside the shared chunk

* Give up on experimental code splitting

* Remove potentially unnecessary optimisation

* Removing workers as the complexit is much worse, while perf. does not seem to be much better

* fix selecting text containers and containing frames together

* render fill directly from animated trail

* do not re-render static when setting selected element ids in lasso

* remove unnecessary property

* tweak trail animation

* slice points to remove notch

* always start alt-lasso from initial point

* revert build & worker changes (unused)

* remove `lasso` from `hasStrokeColor`

* label change

* remove unused props

* remove unsafe optimization

* snaps

---------

Co-authored-by: dwelle <5153846+dwelle@users.noreply.github.com>
Co-authored-by: Marcel Mraz <marcel@excalidraw.com>
2025-04-07 16:44:25 +10:00
Narek Malkhasyan
6e47fadb59
feat: add container to multiple text elements (#9348) 2025-04-07 00:57:27 +02:00
Márk Tolmács
b3d5ba0567
fix: Linear element is not normalized (#9347)
* Fix #9292
2025-04-06 13:41:11 +02:00
Panagiotis Papadopoulos
c79e892e55
chore: bump @radix-ui/react-tabs version to 1.1.3 (#9329)
* chore: bump @radix-ui/react-tabs version to 1.1.3

bumped the version to latest stable that includes
react ^19 as peerDepenecy.
This fixes peerDependency issues, as reported in #9253

* redeploy

---------

Co-authored-by: dwelle <5153846+dwelle@users.noreply.github.com>
2025-04-02 16:23:15 +02:00
David Luzar
57a9e301d4
feat: tweak color swatch, and button bgs (#9330)
* feat: tweak color swatch, and button bgs

* snapshots
2025-04-02 14:36:13 +02:00
David Luzar
7c58477382
feat: tweak properties panel styling (#9322) 2025-03-30 19:20:13 +02:00
David Luzar
83fac6d0db
feat: tweak stats panel input styles (#9321) 2025-03-30 19:00:31 +02:00
David Luzar
f2e8404c7b
feat: allow to disable preventUnload in dev (#9319)
* feat: allow to disable preventUnload in dev

* add template
2025-03-29 19:42:33 +01:00
David Luzar
d797c2e210
fix: strip legacy attrs on element restore (#9318) 2025-03-29 19:31:16 +01:00
Marcel Mraz
0cd5a259ae
fix: incorrect type imports (#9308) 2025-03-27 12:00:12 +01:00
Marcel Mraz
432a46ef9e
refactor: separate elements logic into a standalone package (#9285) 2025-03-26 15:24:59 +01:00
Márk Tolmács
a18f059188
fix: Reduce allocations in collision detection (#9299)
Reduce allocations
2025-03-26 15:10:43 +01:00
KODIFY
ab89d4c16f
feat: add keyboard shortcut to save file in text (#9295)
Co-authored-by: Aviral Sharma <aviralsharma954@gmail.com>
2025-03-25 22:18:55 +01:00
Mubaraq Wahab
6c3a434f2a
docs: Fix table rendering and broken links in Props page (#9293)
* Fix table rendering and broken links

---------

Co-authored-by: Marcel Mraz <marcel@excalidraw.com>
2025-03-25 14:32:15 +01:00
Mursaleen Nisar
e1bb59fb8f
chore: Use isDevEnv() and isTestEnv() (#9264)
Signed-off-by: Mark Tolmacs <mark@lazycat.hu>
Co-authored-by: Mark Tolmacs <mark@lazycat.hu>
2025-03-24 19:44:00 +01:00
Márk Tolmács
77aca48c84
fix: Refactor and merge duplication and binding (#9246) 2025-03-23 18:39:33 +01:00
WalterMitty
58990b41ae
fix: 'Rotate' spell error (#9288) 2025-03-22 09:06:23 +00:00
David Luzar
99d8bff175
fix: elements offset incorrectly when action-duplicated during drag (#9275)
* fix: elements offset incorrectly when action-duplicated during drag

* prevent duplicate action during drag altogether
2025-03-15 20:05:42 +01:00
Márk Tolmács
30983d801a
fix: Arrow conversion regression (#9241)
Signed-off-by: Mark Tolmacs <mark@lazycat.hu>
2025-03-15 12:31:25 +01:00
Marcel Mraz
21ffaf4d76
refactor: auto ordered imports (#9163) 2025-03-12 15:23:31 +01:00
Marcel Mraz
82b9a6b464
docs: CHANGELOG typos 🙏 (#9250) 2025-03-11 23:18:15 +01:00
530 changed files with 36536 additions and 28119 deletions

View File

@ -48,3 +48,6 @@ UNWEjuqNMi/lwAErS9fFa2oJlWyT8U7zzv/5kQREkxZI6y9v0AF3qcbsy2731FnD
s9ChJvOUW9toIab2gsIdrKW8ZNpu084ZFVKb6LNjvIXI1Se4oMTHeszXzNptzlot s9ChJvOUW9toIab2gsIdrKW8ZNpu084ZFVKb6LNjvIXI1Se4oMTHeszXzNptzlot
kdxxjOoaQMAyfljFSot1F1FlU6MQlag7UnFGvFjRHN1JI5q4K+n3a67DX+TMyRqS kdxxjOoaQMAyfljFSot1F1FlU6MQlag7UnFGvFjRHN1JI5q4K+n3a67DX+TMyRqS
HQIDAQAB' HQIDAQAB'
# set to true in .env.development.local to disable the prevent unload dialog
VITE_APP_DISABLE_PREVENT_UNLOAD=

View File

@ -1,5 +1,5 @@
VITE_APP_BACKEND_V2_GET_URL=https://json.excalidraw.com/api/v2/ VITE_APP_BACKEND_V2_GET_URL=https://ex.dylanbanta.com/api/v2/scenes/
VITE_APP_BACKEND_V2_POST_URL=https://json.excalidraw.com/api/v2/post/ VITE_APP_BACKEND_V2_POST_URL=https://ex.dylanbanta.com/api/v2/scenes/
VITE_APP_LIBRARY_URL=https://libraries.excalidraw.com VITE_APP_LIBRARY_URL=https://libraries.excalidraw.com
VITE_APP_LIBRARY_BACKEND=https://us-central1-excalidraw-room-persistence.cloudfunctions.net/libraries VITE_APP_LIBRARY_BACKEND=https://us-central1-excalidraw-room-persistence.cloudfunctions.net/libraries

View File

@ -1,6 +1,21 @@
{ {
"extends": ["@excalidraw/eslint-config", "react-app"], "extends": ["@excalidraw/eslint-config", "react-app"],
"rules": { "rules": {
"import/order": [
"warn",
{
"groups": ["builtin", "external", "internal", "parent", "sibling", "index", "object", "type"],
"pathGroups": [
{
"pattern": "@excalidraw/**",
"group": "external",
"position": "after"
}
],
"newlines-between": "always-and-inside-groups",
"warnOnUnassignedImports": true
}
],
"import/no-anonymous-default-export": "off", "import/no-anonymous-default-export": "off",
"no-restricted-globals": "off", "no-restricted-globals": "off",
"@typescript-eslint/consistent-type-imports": [ "@typescript-eslint/consistent-type-imports": [
@ -17,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
}
] ]
} }
} }

View File

@ -63,7 +63,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...

View File

@ -2,7 +2,7 @@
Earlier we were using `renderFooter` prop to render custom footer which was removed in [#5970](https://github.com/excalidraw/excalidraw/pull/5970). Now you can pass a `Footer` component instead to render the custom UI for footer. Earlier we were using `renderFooter` prop to render custom footer which was removed in [#5970](https://github.com/excalidraw/excalidraw/pull/5970). Now you can pass a `Footer` component instead to render the custom UI for footer.
You will need to import the `Footer` component from the package and wrap your component with the Footer component. The `Footer` should a valid React Node. You will need to import the `Footer` component from the package and wrap your component with the Footer component. The `Footer` should be a valid React Node.
**Usage** **Usage**
@ -25,7 +25,7 @@ function App() {
} }
``` ```
This will only for `Desktop` devices. This will only work for `Desktop` devices.
For `mobile` you will need to render it inside the [MainMenu](#mainmenu). You can use the [`useDevice`](#useDevice) hook to check the type of device, this will be available only inside the `children` of `Excalidraw` component. For `mobile` you will need to render it inside the [MainMenu](#mainmenu). You can use the [`useDevice`](#useDevice) hook to check the type of device, this will be available only inside the `children` of `Excalidraw` component.
@ -65,4 +65,4 @@ const App = () => (
// Need to render when code is span across multiple components // Need to render when code is span across multiple components
// in Live Code blocks editor // in Live Code blocks editor
render(<App />); render(<App />);
``` ```

View File

@ -3,7 +3,7 @@
All `props` are _optional_. All `props` are _optional_.
| Name | Type | Default | Description | | Name | Type | Default | Description |
| --- | --- | --- | --- | --- | --- | --- | --- | --- | | --- | --- | --- | --- |
| [`initialData`](/docs/@excalidraw/excalidraw/api/props/initialdata) | `object` &#124; `null` &#124; <code>Promise<object &#124; null></code> | `null` | The initial data with which app loads. | | [`initialData`](/docs/@excalidraw/excalidraw/api/props/initialdata) | `object` &#124; `null` &#124; <code>Promise<object &#124; null></code> | `null` | The initial data with which app loads. |
| [`excalidrawAPI`](/docs/@excalidraw/excalidraw/api/props/excalidraw-api) | `function` | \_ | Callback triggered with the excalidraw api once rendered | | [`excalidrawAPI`](/docs/@excalidraw/excalidraw/api/props/excalidraw-api) | `function` | \_ | Callback triggered with the excalidraw api once rendered |
| [`isCollaborating`](#iscollaborating) | `boolean` | \_ | This indicates if the app is in `collaboration` mode | | [`isCollaborating`](#iscollaborating) | `boolean` | \_ | This indicates if the app is in `collaboration` mode |
@ -13,7 +13,7 @@ All `props` are _optional_.
| [`onScrollChange`](#onscrollchange) | `function` | \_ | This prop if passed gets triggered when scrolling the canvas. | | [`onScrollChange`](#onscrollchange) | `function` | \_ | This prop if passed gets triggered when scrolling the canvas. |
| [`onPaste`](#onpaste) | `function` | \_ | Callback to be triggered if passed when something is pasted into the scene | | [`onPaste`](#onpaste) | `function` | \_ | Callback to be triggered if passed when something is pasted into the scene |
| [`onLibraryChange`](#onlibrarychange) | `function` | \_ | The callback if supplied is triggered when the library is updated and receives the library items. | | [`onLibraryChange`](#onlibrarychange) | `function` | \_ | The callback if supplied is triggered when the library is updated and receives the library items. |
| [`generateLinkForSelection`](#generateLinkForSelection) | `function` | \_ | Allows you to override `url` generation when linking to Excalidraw elements. | | [`generateLinkForSelection`](#generatelinkforselection) | `function` | \_ | Allows you to override `url` generation when linking to Excalidraw elements. |
| [`onLinkOpen`](#onlinkopen) | `function` | \_ | The callback if supplied is triggered when any link is opened. | | [`onLinkOpen`](#onlinkopen) | `function` | \_ | The callback if supplied is triggered when any link is opened. |
| [`langCode`](#langcode) | `string` | `en` | Language code string to be used in Excalidraw | | [`langCode`](#langcode) | `string` | `en` | Language code string to be used in Excalidraw |
| [`renderTopRightUI`](/docs/@excalidraw/excalidraw/api/props/render-props#rendertoprightui) | `function` | \_ | Render function that renders custom UI in top right corner | | [`renderTopRightUI`](/docs/@excalidraw/excalidraw/api/props/render-props#rendertoprightui) | `function` | \_ | Render function that renders custom UI in top right corner |
@ -29,8 +29,9 @@ All `props` are _optional_.
| [`handleKeyboardGlobally`](#handlekeyboardglobally) | `boolean` | `false` | Indicates whether to bind the keyboard events to document. | | [`handleKeyboardGlobally`](#handlekeyboardglobally) | `boolean` | `false` | Indicates whether to bind the keyboard events to document. |
| [`autoFocus`](#autofocus) | `boolean` | `false` | Indicates whether to focus the Excalidraw component on page load | | [`autoFocus`](#autofocus) | `boolean` | `false` | Indicates whether to focus the Excalidraw component on page load |
| [`generateIdForFile`](#generateidforfile) | `function` | \_ | Allows you to override `id` generation for files added on canvas | | [`generateIdForFile`](#generateidforfile) | `function` | \_ | Allows you to override `id` generation for files added on canvas |
| [`validateEmbeddable`](#validateEmbeddable) | string[] | `boolean | RegExp | RegExp[] | ((link: string) => boolean | undefined)` | \_ | use for custom src url validation | | [`validateEmbeddable`](#validateembeddable) | `string[]` \| `boolean` \| `RegExp` \| `RegExp[]` \| <code>((link: string) => boolean &#124; undefined)</code> | \_ | use for custom src url validation |
| [`renderEmbeddable`](/docs/@excalidraw/excalidraw/api/props/render-props#renderEmbeddable) | `function` | \_ | Render function that can override the built-in `<iframe>` | | [`renderEmbeddable`](/docs/@excalidraw/excalidraw/api/props/render-props#renderEmbeddable) | `function` | \_ | Render function that can override the built-in `<iframe>` |
| [`renderScrollbars`] | `boolean`| | `false` | Indicates whether scrollbars will be shown
### Storing custom data on Excalidraw elements ### Storing custom data on Excalidraw elements

View File

@ -1,5 +1,6 @@
import React from "react";
import clsx from "clsx"; import clsx from "clsx";
import React from "react";
import styles from "./styles.module.css"; import styles from "./styles.module.css";
const FeatureList = [ const FeatureList = [

View File

@ -1,5 +1,6 @@
import React from "react";
import clsx from "clsx"; import clsx from "clsx";
import React from "react";
import styles from "./styles.module.css"; import styles from "./styles.module.css";
type FeatureItem = { type FeatureItem = {

View File

@ -1,10 +1,11 @@
import React from "react";
import clsx from "clsx";
import Layout from "@theme/Layout";
import Link from "@docusaurus/Link"; import Link from "@docusaurus/Link";
import useDocusaurusContext from "@docusaurus/useDocusaurusContext"; import useDocusaurusContext from "@docusaurus/useDocusaurusContext";
import styles from "./index.module.css";
import HomepageFeatures from "@site/src/components/Homepage"; import HomepageFeatures from "@site/src/components/Homepage";
import Layout from "@theme/Layout";
import clsx from "clsx";
import React from "react";
import styles from "./index.module.css";
function HomepageHeader() { function HomepageHeader() {
const { siteConfig } = useDocusaurusContext(); const { siteConfig } = useDocusaurusContext();

View File

@ -1,6 +1,6 @@
// Import the original mapper // Import the original mapper
import MDXComponents from "@theme-original/MDXComponents";
import Highlight from "@site/src/components/Highlight"; import Highlight from "@site/src/components/Highlight";
import MDXComponents from "@theme-original/MDXComponents";
export default { export default {
// Re-use the default mapping // Re-use the default mapping

View File

@ -19,7 +19,7 @@ services:
- ./:/opt/node_app/app:delegated - ./:/opt/node_app/app:delegated
- ./package.json:/opt/node_app/package.json - ./package.json:/opt/node_app/package.json
- ./yarn.lock:/opt/node_app/yarn.lock - ./yarn.lock:/opt/node_app/yarn.lock
- notused:/opt/node_app/app/node_modules # - notused:/opt/node_app/app/node_modules
volumes: # volumes:
notused: # notused:

View File

@ -1,5 +1,6 @@
import dynamic from "next/dynamic"; import dynamic from "next/dynamic";
import Script from "next/script"; import Script from "next/script";
import "../common.scss"; import "../common.scss";
// Since client components get prerenderd on server as well hence importing the excalidraw stuff dynamically // Since client components get prerenderd on server as well hence importing the excalidraw stuff dynamically

View File

@ -1,10 +1,11 @@
"use client"; "use client";
import * as excalidrawLib from "@excalidraw/excalidraw"; import * as excalidrawLib from "@excalidraw/excalidraw";
import { Excalidraw } from "@excalidraw/excalidraw"; import { Excalidraw } from "@excalidraw/excalidraw";
import App from "../../with-script-in-browser/components/ExampleApp";
import "@excalidraw/excalidraw/index.css"; import "@excalidraw/excalidraw/index.css";
import App from "../../with-script-in-browser/components/ExampleApp";
const ExcalidrawWrapper: React.FC = () => { const ExcalidrawWrapper: React.FC = () => {
return ( return (
<> <>

View File

@ -1,4 +1,5 @@
import dynamic from "next/dynamic"; import dynamic from "next/dynamic";
import "../common.scss"; import "../common.scss";
// Since client components get prerenderd on server as well hence importing the excalidraw stuff dynamically // Since client components get prerenderd on server as well hence importing the excalidraw stuff dynamically

View File

@ -1,4 +1,5 @@
import React from "react"; import React from "react";
import type * as TExcalidraw from "@excalidraw/excalidraw"; import type * as TExcalidraw from "@excalidraw/excalidraw";
import type { ExcalidrawImperativeAPI } from "@excalidraw/excalidraw/types"; import type { ExcalidrawImperativeAPI } from "@excalidraw/excalidraw/types";

View File

@ -52,7 +52,7 @@
transform: none; transform: none;
} }
.excalidraw .panelColumn { .excalidraw .selected-shape-actions {
text-align: left; text-align: left;
} }

View File

@ -1,3 +1,4 @@
import { nanoid } from "nanoid";
import React, { import React, {
useEffect, useEffect,
useState, useState,
@ -6,13 +7,24 @@ import React, {
Children, Children,
cloneElement, cloneElement,
} from "react"; } from "react";
import ExampleSidebar from "./sidebar/ExampleSidebar";
import type * as TExcalidraw from "@excalidraw/excalidraw"; import type * as TExcalidraw from "@excalidraw/excalidraw";
import type { ImportedLibraryData } from "@excalidraw/excalidraw/data/types";
import type {
NonDeletedExcalidrawElement,
Theme,
} from "@excalidraw/excalidraw/element/types";
import type {
AppState,
BinaryFileData,
ExcalidrawImperativeAPI,
ExcalidrawInitialDataState,
Gesture,
LibraryItems,
PointerDownState as ExcalidrawPointerDownState,
} from "@excalidraw/excalidraw/types";
import { nanoid } from "nanoid"; import initialData from "../initialData";
import type { ResolvablePromise } from "../utils";
import { import {
resolvablePromise, resolvablePromise,
distance2d, distance2d,
@ -23,25 +35,12 @@ import {
import CustomFooter from "./CustomFooter"; import CustomFooter from "./CustomFooter";
import MobileFooter from "./MobileFooter"; import MobileFooter from "./MobileFooter";
import initialData from "../initialData"; import ExampleSidebar from "./sidebar/ExampleSidebar";
import type {
AppState,
BinaryFileData,
ExcalidrawImperativeAPI,
ExcalidrawInitialDataState,
Gesture,
LibraryItems,
PointerDownState as ExcalidrawPointerDownState,
} from "@excalidraw/excalidraw/types";
import type {
NonDeletedExcalidrawElement,
Theme,
} from "@excalidraw/excalidraw/element/types";
import type { ImportedLibraryData } from "@excalidraw/excalidraw/data/types";
import "./ExampleApp.scss"; import "./ExampleApp.scss";
import type { ResolvablePromise } from "../utils";
type Comment = { type Comment = {
x: number; x: number;
y: number; y: number;
@ -105,6 +104,7 @@ export default function ExampleApp({
const [viewModeEnabled, setViewModeEnabled] = useState(false); const [viewModeEnabled, setViewModeEnabled] = useState(false);
const [zenModeEnabled, setZenModeEnabled] = useState(false); const [zenModeEnabled, setZenModeEnabled] = useState(false);
const [gridModeEnabled, setGridModeEnabled] = useState(false); const [gridModeEnabled, setGridModeEnabled] = useState(false);
const [renderScrollbars, setRenderScrollbars] = useState(false);
const [blobUrl, setBlobUrl] = useState<string>(""); const [blobUrl, setBlobUrl] = useState<string>("");
const [canvasUrl, setCanvasUrl] = useState<string>(""); const [canvasUrl, setCanvasUrl] = useState<string>("");
const [exportWithDarkMode, setExportWithDarkMode] = useState(false); const [exportWithDarkMode, setExportWithDarkMode] = useState(false);
@ -193,6 +193,7 @@ export default function ExampleApp({
}) => setPointerData(payload), }) => setPointerData(payload),
viewModeEnabled, viewModeEnabled,
zenModeEnabled, zenModeEnabled,
renderScrollbars,
gridModeEnabled, gridModeEnabled,
theme, theme,
name: "Custom name of drawing", name: "Custom name of drawing",
@ -711,6 +712,14 @@ export default function ExampleApp({
/> />
Grid mode Grid mode
</label> </label>
<label>
<input
type="checkbox"
checked={renderScrollbars}
onChange={() => setRenderScrollbars(!renderScrollbars)}
/>
Render scrollbars
</label>
<label> <label>
<input <input
type="checkbox" type="checkbox"

View File

@ -1,7 +1,9 @@
import React from "react"; import React from "react";
import type { ExcalidrawImperativeAPI } from "@excalidraw/excalidraw/types";
import CustomFooter from "./CustomFooter";
import type * as TExcalidraw from "@excalidraw/excalidraw"; import type * as TExcalidraw from "@excalidraw/excalidraw";
import type { ExcalidrawImperativeAPI } from "@excalidraw/excalidraw/types";
import CustomFooter from "./CustomFooter";
const MobileFooter = ({ const MobileFooter = ({
excalidrawAPI, excalidrawAPI,

View File

@ -1,4 +1,5 @@
import React, { useState } from "react"; import React, { useState } from "react";
import "./ExampleSidebar.scss"; import "./ExampleSidebar.scss";
export default function Sidebar({ children }: { children: React.ReactNode }) { export default function Sidebar({ children }: { children: React.ReactNode }) {

View File

@ -1,10 +1,11 @@
import App from "./components/ExampleApp";
import React, { StrictMode } from "react"; import React, { StrictMode } from "react";
import { createRoot } from "react-dom/client"; import { createRoot } from "react-dom/client";
import "@excalidraw/excalidraw/index.css";
import type * as TExcalidraw from "@excalidraw/excalidraw"; import type * as TExcalidraw from "@excalidraw/excalidraw";
import "@excalidraw/excalidraw/index.css"; import App from "./components/ExampleApp";
declare global { declare global {
interface Window { interface Window {

View File

@ -15,7 +15,8 @@
"scripts": { "scripts": {
"start": "vite", "start": "vite",
"build": "vite build", "build": "vite build",
"build:preview": "yarn build && vite preview --port 5002", "preview": "vite preview --port 5002",
"build:preview": "yarn build && yarn preview",
"build:package": "yarn workspace @excalidraw/excalidraw run build:esm" "build:package": "yarn workspace @excalidraw/excalidraw run build:esm"
} }
} }

View File

@ -1,6 +1,6 @@
import { unstable_batchedUpdates } from "react-dom";
import { fileOpen as _fileOpen } from "browser-fs-access";
import { MIME_TYPES } from "@excalidraw/excalidraw"; import { MIME_TYPES } from "@excalidraw/excalidraw";
import { fileOpen as _fileOpen } from "browser-fs-access";
import { unstable_batchedUpdates } from "react-dom";
type FILE_EXTENSION = Exclude<keyof typeof MIME_TYPES, "binary">; type FILE_EXTENSION = Exclude<keyof typeof MIME_TYPES, "binary">;

View File

@ -1,24 +1,3 @@
import polyfill from "@excalidraw/excalidraw/polyfill";
import { useCallback, useEffect, useRef, useState } from "react";
import { trackEvent } from "@excalidraw/excalidraw/analytics";
import { getDefaultAppState } from "@excalidraw/excalidraw/appState";
import { ErrorDialog } from "@excalidraw/excalidraw/components/ErrorDialog";
import { TopErrorBoundary } from "./components/TopErrorBoundary";
import {
APP_NAME,
EVENT,
THEME,
TITLE_TIMEOUT,
VERSION_TIMEOUT,
} from "@excalidraw/excalidraw/constants";
import { loadFromBlob } from "@excalidraw/excalidraw/data/blob";
import type {
FileId,
NonDeletedExcalidrawElement,
OrderedExcalidrawElement,
} from "@excalidraw/excalidraw/element/types";
import { useCallbackRefState } from "@excalidraw/excalidraw/hooks/useCallbackRefState";
import { t } from "@excalidraw/excalidraw/i18n";
import { import {
Excalidraw, Excalidraw,
LiveCollaborationTrigger, LiveCollaborationTrigger,
@ -26,15 +5,23 @@ import {
CaptureUpdateAction, CaptureUpdateAction,
reconcileElements, reconcileElements,
} from "@excalidraw/excalidraw"; } from "@excalidraw/excalidraw";
import type { import { trackEvent } from "@excalidraw/excalidraw/analytics";
AppState, import { getDefaultAppState } from "@excalidraw/excalidraw/appState";
ExcalidrawImperativeAPI,
BinaryFiles,
ExcalidrawInitialDataState,
UIAppState,
} from "@excalidraw/excalidraw/types";
import type { ResolvablePromise } from "@excalidraw/excalidraw/utils";
import { import {
CommandPalette,
DEFAULT_CATEGORIES,
} from "@excalidraw/excalidraw/components/CommandPalette/CommandPalette";
import { ErrorDialog } from "@excalidraw/excalidraw/components/ErrorDialog";
import { OverwriteConfirmDialog } from "@excalidraw/excalidraw/components/OverwriteConfirm/OverwriteConfirm";
import { openConfirmModal } from "@excalidraw/excalidraw/components/OverwriteConfirm/OverwriteConfirmState";
import { ShareableLinkDialog } from "@excalidraw/excalidraw/components/ShareableLinkDialog";
import Trans from "@excalidraw/excalidraw/components/Trans";
import {
APP_NAME,
EVENT,
THEME,
TITLE_TIMEOUT,
VERSION_TIMEOUT,
debounce, debounce,
getVersion, getVersion,
getFrame, getFrame,
@ -42,75 +29,14 @@ import {
preventUnload, preventUnload,
resolvablePromise, resolvablePromise,
isRunningInIframe, isRunningInIframe,
} from "@excalidraw/excalidraw/utils"; isDevEnv,
import { } from "@excalidraw/common";
FIREBASE_STORAGE_PREFIXES, import polyfill from "@excalidraw/excalidraw/polyfill";
isExcalidrawPlusSignedUser, import { useCallback, useEffect, useRef, useState } from "react";
STORAGE_KEYS, import { loadFromBlob } from "@excalidraw/excalidraw/data/blob";
SYNC_BROWSER_TABS_TIMEOUT, import { useCallbackRefState } from "@excalidraw/excalidraw/hooks/useCallbackRefState";
} from "./app_constants"; import { t } from "@excalidraw/excalidraw/i18n";
import type { CollabAPI } from "./collab/Collab";
import Collab, {
collabAPIAtom,
isCollaboratingAtom,
isOfflineAtom,
} from "./collab/Collab";
import {
exportToBackend,
getCollaborationLinkData,
isCollaborationLink,
loadScene,
} from "./data";
import {
importFromLocalStorage,
importUsernameFromLocalStorage,
} from "./data/localStorage";
import CustomStats from "./CustomStats";
import type { RestoredDataState } from "@excalidraw/excalidraw/data/restore";
import { restore, restoreAppState } from "@excalidraw/excalidraw/data/restore";
import {
ExportToExcalidrawPlus,
exportToExcalidrawPlus,
} from "./components/ExportToExcalidrawPlus";
import { updateStaleImageStatuses } from "./data/FileManager";
import { newElementWith } from "@excalidraw/excalidraw/element/mutateElement";
import { isInitializedImageElement } from "@excalidraw/excalidraw/element/typeChecks";
import { loadFilesFromFirebase } from "./data/firebase";
import {
LibraryIndexedDBAdapter,
LibraryLocalStorageMigrationAdapter,
LocalData,
} from "./data/LocalData";
import { isBrowserStorageStateNewer } from "./data/tabSync";
import clsx from "clsx";
import {
parseLibraryTokensFromUrl,
useHandleLibrary,
} from "@excalidraw/excalidraw/data/library";
import { AppMainMenu } from "./components/AppMainMenu";
import { AppWelcomeScreen } from "./components/AppWelcomeScreen";
import { AppFooter } from "./components/AppFooter";
import {
Provider,
useAtom,
useAtomValue,
useAtomWithInitialValue,
appJotaiStore,
} from "./app-jotai";
import "./index.scss";
import type { ResolutionType } from "@excalidraw/excalidraw/utility-types";
import { ShareableLinkDialog } from "@excalidraw/excalidraw/components/ShareableLinkDialog";
import { openConfirmModal } from "@excalidraw/excalidraw/components/OverwriteConfirm/OverwriteConfirmState";
import { OverwriteConfirmDialog } from "@excalidraw/excalidraw/components/OverwriteConfirm/OverwriteConfirm";
import Trans from "@excalidraw/excalidraw/components/Trans";
import { ShareDialog, shareDialogStateAtom } from "./share/ShareDialog";
import CollabError, { collabErrorIndicatorAtom } from "./collab/CollabError";
import type { RemoteExcalidrawElement } from "@excalidraw/excalidraw/data/reconcile";
import {
CommandPalette,
DEFAULT_CATEGORIES,
} from "@excalidraw/excalidraw/components/CommandPalette/CommandPalette";
import { import {
GithubIcon, GithubIcon,
XBrandIcon, XBrandIcon,
@ -121,6 +47,83 @@ import {
share, share,
youtubeIcon, youtubeIcon,
} from "@excalidraw/excalidraw/components/icons"; } from "@excalidraw/excalidraw/components/icons";
import { isElementLink } from "@excalidraw/element";
import { restore, restoreAppState } from "@excalidraw/excalidraw/data/restore";
import { newElementWith } from "@excalidraw/element";
import { isInitializedImageElement } from "@excalidraw/element";
import clsx from "clsx";
import {
parseLibraryTokensFromUrl,
useHandleLibrary,
} from "@excalidraw/excalidraw/data/library";
import type { RemoteExcalidrawElement } from "@excalidraw/excalidraw/data/reconcile";
import type { RestoredDataState } from "@excalidraw/excalidraw/data/restore";
import type {
FileId,
NonDeletedExcalidrawElement,
OrderedExcalidrawElement,
} from "@excalidraw/element/types";
import type {
AppState,
ExcalidrawImperativeAPI,
BinaryFiles,
ExcalidrawInitialDataState,
UIAppState,
} from "@excalidraw/excalidraw/types";
import type { ResolutionType } from "@excalidraw/common/utility-types";
import type { ResolvablePromise } from "@excalidraw/common/utils";
import CustomStats from "./CustomStats";
import {
Provider,
useAtom,
useAtomValue,
useAtomWithInitialValue,
appJotaiStore,
} from "./app-jotai";
import {
FIREBASE_STORAGE_PREFIXES,
isExcalidrawPlusSignedUser,
STORAGE_KEYS,
SYNC_BROWSER_TABS_TIMEOUT,
} from "./app_constants";
import Collab, {
collabAPIAtom,
isCollaboratingAtom,
isOfflineAtom,
} from "./collab/Collab";
import { AppFooter } from "./components/AppFooter";
import { AppMainMenu } from "./components/AppMainMenu";
import { AppWelcomeScreen } from "./components/AppWelcomeScreen";
import {
ExportToExcalidrawPlus,
exportToExcalidrawPlus,
} from "./components/ExportToExcalidrawPlus";
import { TopErrorBoundary } from "./components/TopErrorBoundary";
import {
exportToBackend,
getCollaborationLinkData,
isCollaborationLink,
loadScene,
} from "./data";
import { updateStaleImageStatuses } from "./data/FileManager";
import {
importFromLocalStorage,
importUsernameFromLocalStorage,
} from "./data/localStorage";
import { loadFilesFromFirebase } from "./data/firebase";
import {
LibraryIndexedDBAdapter,
LibraryLocalStorageMigrationAdapter,
LocalData,
} from "./data/LocalData";
import { isBrowserStorageStateNewer } from "./data/tabSync";
import { ShareDialog, shareDialogStateAtom } from "./share/ShareDialog";
import CollabError, { collabErrorIndicatorAtom } from "./collab/CollabError";
import { useHandleAppTheme } from "./useHandleAppTheme"; import { useHandleAppTheme } from "./useHandleAppTheme";
import { getPreferredLanguage } from "./app-language/language-detector"; import { getPreferredLanguage } from "./app-language/language-detector";
import { useAppLangCode } from "./app-language/language-state"; import { useAppLangCode } from "./app-language/language-state";
@ -131,7 +134,10 @@ import DebugCanvas, {
} from "./components/DebugCanvas"; } from "./components/DebugCanvas";
import { AIComponents } from "./components/AI"; import { AIComponents } from "./components/AI";
import { ExcalidrawPlusIframeExport } from "./ExcalidrawPlusIframeExport"; import { ExcalidrawPlusIframeExport } from "./ExcalidrawPlusIframeExport";
import { isElementLink } from "@excalidraw/excalidraw/element/elementLink";
import "./index.scss";
import type { CollabAPI } from "./collab/Collab";
polyfill(); polyfill();
@ -377,7 +383,7 @@ const ExcalidrawWrapper = () => {
const [, forceRefresh] = useState(false); const [, forceRefresh] = useState(false);
useEffect(() => { useEffect(() => {
if (import.meta.env.DEV) { if (isDevEnv()) {
const debugState = loadSavedDebugState(); const debugState = loadSavedDebugState();
if (debugState.enabled && !window.visualDebug) { if (debugState.enabled && !window.visualDebug) {
@ -602,7 +608,13 @@ const ExcalidrawWrapper = () => {
excalidrawAPI.getSceneElements(), excalidrawAPI.getSceneElements(),
) )
) { ) {
preventUnload(event); if (import.meta.env.VITE_APP_DISABLE_PREVENT_UNLOAD !== "true") {
preventUnload(event);
} else {
console.warn(
"preventing unload disabled (VITE_APP_DISABLE_PREVENT_UNLOAD)",
);
}
} }
}; };
window.addEventListener(EVENT.BEFORE_UNLOAD, unloadHandler); window.addEventListener(EVENT.BEFORE_UNLOAD, unloadHandler);
@ -914,16 +926,21 @@ const ExcalidrawWrapper = () => {
<ShareDialog <ShareDialog
collabAPI={collabAPI} collabAPI={collabAPI}
onExportToBackend={async () => { onExportToBackend={async () => {
if (excalidrawAPI) { if (!excalidrawAPI) {
try { return;
await onExportToBackend( }
excalidrawAPI.getSceneElements(), try {
excalidrawAPI.getAppState(), const { url, errorMessage } = await exportToBackend(
excalidrawAPI.getFiles(), excalidrawAPI.getSceneElements(),
); excalidrawAPI.getAppState(),
} catch (error: any) { excalidrawAPI.getFiles(),
setErrorMessage(error.message); );
if (errorMessage) {
throw new Error(errorMessage);
} }
setLatestShareableLink(url);
} catch (error: any) {
setErrorMessage(error.message);
} }
}} }}
/> />

View File

@ -1,15 +1,21 @@
import { Stats } from "@excalidraw/excalidraw";
import { copyTextToSystemClipboard } from "@excalidraw/excalidraw/clipboard";
import {
DEFAULT_VERSION,
debounce,
getVersion,
nFormatter,
} from "@excalidraw/common";
import { t } from "@excalidraw/excalidraw/i18n";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { debounce, getVersion, nFormatter } from "@excalidraw/excalidraw/utils";
import type { NonDeletedExcalidrawElement } from "@excalidraw/element/types";
import type { UIAppState } from "@excalidraw/excalidraw/types";
import { import {
getElementsStorageSize, getElementsStorageSize,
getTotalStorageSize, getTotalStorageSize,
} from "./data/localStorage"; } from "./data/localStorage";
import { DEFAULT_VERSION } from "@excalidraw/excalidraw/constants";
import { t } from "@excalidraw/excalidraw/i18n";
import { copyTextToSystemClipboard } from "@excalidraw/excalidraw/clipboard";
import type { NonDeletedExcalidrawElement } from "@excalidraw/excalidraw/element/types";
import type { UIAppState } from "@excalidraw/excalidraw/types";
import { Stats } from "@excalidraw/excalidraw";
type StorageSizes = { scene: number; total: number }; type StorageSizes = { scene: number; total: number };

View File

@ -1,13 +1,15 @@
import { base64urlToString } from "@excalidraw/excalidraw/data/encode";
import { ExcalidrawError } from "@excalidraw/excalidraw/errors";
import { useLayoutEffect, useRef } from "react"; import { useLayoutEffect, useRef } from "react";
import { STORAGE_KEYS } from "./app_constants";
import { LocalData } from "./data/LocalData";
import type { import type {
FileId, FileId,
OrderedExcalidrawElement, OrderedExcalidrawElement,
} from "@excalidraw/excalidraw/element/types"; } from "@excalidraw/element/types";
import type { AppState, BinaryFileData } from "@excalidraw/excalidraw/types"; import type { AppState, BinaryFileData } from "@excalidraw/excalidraw/types";
import { ExcalidrawError } from "@excalidraw/excalidraw/errors";
import { base64urlToString } from "@excalidraw/excalidraw/data/encode"; import { STORAGE_KEYS } from "./app_constants";
import { LocalData } from "./data/LocalData";
const EVENT_REQUEST_SCENE = "REQUEST_SCENE"; const EVENT_REQUEST_SCENE = "REQUEST_SCENE";

View File

@ -1,6 +1,8 @@
import React from "react";
import { useI18n, languages } from "@excalidraw/excalidraw/i18n"; import { useI18n, languages } from "@excalidraw/excalidraw/i18n";
import React from "react";
import { useSetAtom } from "../app-jotai"; import { useSetAtom } from "../app-jotai";
import { appLangCodeAtom } from "./language-state"; import { appLangCodeAtom } from "./language-state";
export const LanguageList = ({ style }: { style?: React.CSSProperties }) => { export const LanguageList = ({ style }: { style?: React.CSSProperties }) => {

View File

@ -1,5 +1,5 @@
import LanguageDetector from "i18next-browser-languagedetector";
import { defaultLang, languages } from "@excalidraw/excalidraw"; import { defaultLang, languages } from "@excalidraw/excalidraw";
import LanguageDetector from "i18next-browser-languagedetector";
export const languageDetector = new LanguageDetector(); export const languageDetector = new LanguageDetector();

View File

@ -1,5 +1,7 @@
import { useEffect } from "react"; import { useEffect } from "react";
import { atom, useAtom } from "../app-jotai"; import { atom, useAtom } from "../app-jotai";
import { getPreferredLanguage, languageDetector } from "./language-detector"; import { getPreferredLanguage, languageDetector } from "./language-detector";
export const appLangCodeAtom = atom(getPreferredLanguage()); export const appLangCodeAtom = atom(getPreferredLanguage());

View File

@ -1,21 +1,3 @@
import throttle from "lodash.throttle";
import { PureComponent } from "react";
import type {
BinaryFileData,
ExcalidrawImperativeAPI,
SocketId,
Collaborator,
Gesture,
} from "@excalidraw/excalidraw/types";
import { ErrorDialog } from "@excalidraw/excalidraw/components/ErrorDialog";
import { APP_NAME, ENV, EVENT } from "@excalidraw/excalidraw/constants";
import type { ImportedDataState } from "@excalidraw/excalidraw/data/types";
import type {
ExcalidrawElement,
FileId,
InitializedExcalidrawImageElement,
OrderedExcalidrawElement,
} from "@excalidraw/excalidraw/element/types";
import { import {
CaptureUpdateAction, CaptureUpdateAction,
getSceneVersion, getSceneVersion,
@ -23,12 +5,51 @@ import {
zoomToFitBounds, zoomToFitBounds,
reconcileElements, reconcileElements,
} from "@excalidraw/excalidraw"; } from "@excalidraw/excalidraw";
import { ErrorDialog } from "@excalidraw/excalidraw/components/ErrorDialog";
import { APP_NAME, EVENT } from "@excalidraw/common";
import { import {
IDLE_THRESHOLD,
ACTIVE_THRESHOLD,
UserIdleState,
assertNever, assertNever,
isDevEnv,
isTestEnv,
preventUnload, preventUnload,
resolvablePromise, resolvablePromise,
throttleRAF, throttleRAF,
} from "@excalidraw/excalidraw/utils"; } from "@excalidraw/common";
import { decryptData } from "@excalidraw/excalidraw/data/encryption";
import { getVisibleSceneBounds } from "@excalidraw/element";
import { newElementWith } from "@excalidraw/element";
import { isImageElement, isInitializedImageElement } from "@excalidraw/element";
import { AbortError } from "@excalidraw/excalidraw/errors";
import { t } from "@excalidraw/excalidraw/i18n";
import { withBatchedUpdates } from "@excalidraw/excalidraw/reactUtils";
import throttle from "lodash.throttle";
import { PureComponent } from "react";
import type {
ReconciledExcalidrawElement,
RemoteExcalidrawElement,
} from "@excalidraw/excalidraw/data/reconcile";
import type { ImportedDataState } from "@excalidraw/excalidraw/data/types";
import type {
ExcalidrawElement,
FileId,
InitializedExcalidrawImageElement,
OrderedExcalidrawElement,
} from "@excalidraw/element/types";
import type {
BinaryFileData,
ExcalidrawImperativeAPI,
SocketId,
Collaborator,
Gesture,
} from "@excalidraw/excalidraw/types";
import type { Mutable, ValueOf } from "@excalidraw/common/utility-types";
import { appJotaiStore, atom } from "../app-jotai";
import { import {
CURSOR_SYNC_TIMEOUT, CURSOR_SYNC_TIMEOUT,
FILE_UPLOAD_MAX_BYTES, FILE_UPLOAD_MAX_BYTES,
@ -39,15 +60,17 @@ import {
SYNC_FULL_SCENE_INTERVAL_MS, SYNC_FULL_SCENE_INTERVAL_MS,
WS_EVENTS, WS_EVENTS,
} from "../app_constants"; } from "../app_constants";
import type {
SocketUpdateDataSource,
SyncableExcalidrawElement,
} from "../data";
import { import {
generateCollaborationLinkData, generateCollaborationLinkData,
getCollaborationLink, getCollaborationLink,
getSyncableElements, getSyncableElements,
} from "../data"; } from "../data";
import {
encodeFilesForUpload,
FileManager,
updateStaleImageStatuses,
} from "../data/FileManager";
import { LocalData } from "../data/LocalData";
import { import {
isSavedToFirebase, isSavedToFirebase,
loadFilesFromFirebase, loadFilesFromFirebase,
@ -59,36 +82,15 @@ import {
importUsernameFromLocalStorage, importUsernameFromLocalStorage,
saveUsernameToLocalStorage, saveUsernameToLocalStorage,
} from "../data/localStorage"; } from "../data/localStorage";
import Portal from "./Portal";
import { t } from "@excalidraw/excalidraw/i18n";
import {
IDLE_THRESHOLD,
ACTIVE_THRESHOLD,
UserIdleState,
} from "@excalidraw/excalidraw/constants";
import {
encodeFilesForUpload,
FileManager,
updateStaleImageStatuses,
} from "../data/FileManager";
import { AbortError } from "@excalidraw/excalidraw/errors";
import {
isImageElement,
isInitializedImageElement,
} from "@excalidraw/excalidraw/element/typeChecks";
import { newElementWith } from "@excalidraw/excalidraw/element/mutateElement";
import { decryptData } from "@excalidraw/excalidraw/data/encryption";
import { resetBrowserStateVersions } from "../data/tabSync"; import { resetBrowserStateVersions } from "../data/tabSync";
import { LocalData } from "../data/LocalData";
import { appJotaiStore, atom } from "../app-jotai";
import type { Mutable, ValueOf } from "@excalidraw/excalidraw/utility-types";
import { getVisibleSceneBounds } from "@excalidraw/excalidraw/element/bounds";
import { withBatchedUpdates } from "@excalidraw/excalidraw/reactUtils";
import { collabErrorIndicatorAtom } from "./CollabError"; import { collabErrorIndicatorAtom } from "./CollabError";
import Portal from "./Portal";
import type { import type {
ReconciledExcalidrawElement, SocketUpdateDataSource,
RemoteExcalidrawElement, SyncableExcalidrawElement,
} from "@excalidraw/excalidraw/data/reconcile"; } from "../data";
export const collabAPIAtom = atom<CollabAPI | null>(null); export const collabAPIAtom = atom<CollabAPI | null>(null);
export const isCollaboratingAtom = atom(false); export const isCollaboratingAtom = atom(false);
@ -236,7 +238,7 @@ class Collab extends PureComponent<CollabProps, CollabState> {
appJotaiStore.set(collabAPIAtom, collabAPI); appJotaiStore.set(collabAPIAtom, collabAPI);
if (import.meta.env.MODE === ENV.TEST || import.meta.env.DEV) { if (isTestEnv() || isDevEnv()) {
window.collab = window.collab || ({} as Window["collab"]); window.collab = window.collab || ({} as Window["collab"]);
Object.defineProperties(window, { Object.defineProperties(window, {
collab: { collab: {
@ -296,7 +298,13 @@ class Collab extends PureComponent<CollabProps, CollabState> {
// the purpose is to run in immediately after user decides to stay // the purpose is to run in immediately after user decides to stay
this.saveCollabRoomToFirebase(syncableElements); this.saveCollabRoomToFirebase(syncableElements);
preventUnload(event); if (import.meta.env.VITE_APP_DISABLE_PREVENT_UNLOAD !== "true") {
preventUnload(event);
} else {
console.warn(
"preventing unload disabled (VITE_APP_DISABLE_PREVENT_UNLOAD)",
);
}
} }
}); });
@ -1009,7 +1017,7 @@ declare global {
} }
} }
if (import.meta.env.MODE === ENV.TEST || import.meta.env.DEV) { if (isTestEnv() || isDevEnv()) {
window.collab = window.collab || ({} as Window["collab"]); window.collab = window.collab || ({} as Window["collab"]);
} }

View File

@ -2,6 +2,7 @@ import { Tooltip } from "@excalidraw/excalidraw/components/Tooltip";
import { warning } from "@excalidraw/excalidraw/components/icons"; import { warning } from "@excalidraw/excalidraw/components/icons";
import clsx from "clsx"; import clsx from "clsx";
import { useEffect, useRef, useState } from "react"; import { useEffect, useRef, useState } from "react";
import { atom } from "../app-jotai"; import { atom } from "../app-jotai";
import "./CollabError.scss"; import "./CollabError.scss";

View File

@ -1,25 +1,26 @@
import { CaptureUpdateAction } from "@excalidraw/excalidraw";
import { trackEvent } from "@excalidraw/excalidraw/analytics";
import { encryptData } from "@excalidraw/excalidraw/data/encryption";
import { newElementWith } from "@excalidraw/element";
import throttle from "lodash.throttle";
import type { UserIdleState } from "@excalidraw/common";
import type { OrderedExcalidrawElement } from "@excalidraw/element/types";
import type {
OnUserFollowedPayload,
SocketId,
} from "@excalidraw/excalidraw/types";
import { WS_EVENTS, FILE_UPLOAD_TIMEOUT, WS_SUBTYPES } from "../app_constants";
import { isSyncableElement } from "../data";
import type { import type {
SocketUpdateData, SocketUpdateData,
SocketUpdateDataSource, SocketUpdateDataSource,
SyncableExcalidrawElement, SyncableExcalidrawElement,
} from "../data"; } from "../data";
import { isSyncableElement } from "../data";
import type { TCollabClass } from "./Collab"; import type { TCollabClass } from "./Collab";
import type { OrderedExcalidrawElement } from "@excalidraw/excalidraw/element/types";
import { WS_EVENTS, FILE_UPLOAD_TIMEOUT, WS_SUBTYPES } from "../app_constants";
import type {
OnUserFollowedPayload,
SocketId,
} from "@excalidraw/excalidraw/types";
import type { UserIdleState } from "@excalidraw/excalidraw/constants";
import { trackEvent } from "@excalidraw/excalidraw/analytics";
import throttle from "lodash.throttle";
import { newElementWith } from "@excalidraw/excalidraw/element/mutateElement";
import { encryptData } from "@excalidraw/excalidraw/data/encryption";
import type { Socket } from "socket.io-client"; import type { Socket } from "socket.io-client";
import { CaptureUpdateAction } from "@excalidraw/excalidraw";
class Portal { class Portal {
collab: TCollabClass; collab: TCollabClass;

View File

@ -1,4 +1,3 @@
import type { ExcalidrawImperativeAPI } from "@excalidraw/excalidraw/types";
import { import {
DiagramToCodePlugin, DiagramToCodePlugin,
exportToBlob, exportToBlob,
@ -7,7 +6,9 @@ import {
TTDDialog, TTDDialog,
} from "@excalidraw/excalidraw"; } from "@excalidraw/excalidraw";
import { getDataURL } from "@excalidraw/excalidraw/data/blob"; import { getDataURL } from "@excalidraw/excalidraw/data/blob";
import { safelyParseJSON } from "@excalidraw/excalidraw/utils"; import { safelyParseJSON } from "@excalidraw/common";
import type { ExcalidrawImperativeAPI } from "@excalidraw/excalidraw/types";
export const AIComponents = ({ export const AIComponents = ({
excalidrawAPI, excalidrawAPI,
@ -72,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>`,

View File

@ -1,9 +1,11 @@
import React from "react";
import { Footer } from "@excalidraw/excalidraw/index"; import { Footer } from "@excalidraw/excalidraw/index";
import React from "react";
import { isExcalidrawPlusSignedUser } from "../app_constants";
import { DebugFooter, isVisualDebuggerEnabled } from "./DebugCanvas";
import { EncryptedIcon } from "./EncryptedIcon"; import { EncryptedIcon } from "./EncryptedIcon";
import { ExcalidrawPlusAppLink } from "./ExcalidrawPlusAppLink"; import { ExcalidrawPlusAppLink } from "./ExcalidrawPlusAppLink";
import { isExcalidrawPlusSignedUser } from "../app_constants";
import { DebugFooter, isVisualDebuggerEnabled } from "./DebugCanvas";
export const AppFooter = React.memo( export const AppFooter = React.memo(
({ onChange }: { onChange: () => void }) => { ({ onChange }: { onChange: () => void }) => {

View File

@ -1,13 +1,18 @@
import React from "react";
import { import {
loginIcon, loginIcon,
ExcalLogo, ExcalLogo,
eyeIcon, eyeIcon,
} from "@excalidraw/excalidraw/components/icons"; } from "@excalidraw/excalidraw/components/icons";
import type { Theme } from "@excalidraw/excalidraw/element/types";
import { MainMenu } from "@excalidraw/excalidraw/index"; import { MainMenu } from "@excalidraw/excalidraw/index";
import { isExcalidrawPlusSignedUser } from "../app_constants"; import React from "react";
import { isDevEnv } from "@excalidraw/common";
import type { Theme } from "@excalidraw/element/types";
import { LanguageList } from "../app-language/LanguageList"; import { LanguageList } from "../app-language/LanguageList";
import { isExcalidrawPlusSignedUser } from "../app_constants";
import { saveDebugState } from "./DebugCanvas"; import { saveDebugState } from "./DebugCanvas";
export const AppMainMenu: React.FC<{ export const AppMainMenu: React.FC<{
@ -54,7 +59,7 @@ export const AppMainMenu: React.FC<{
> >
{isExcalidrawPlusSignedUser ? "Sign in" : "Sign up"} {isExcalidrawPlusSignedUser ? "Sign in" : "Sign up"}
</MainMenu.ItemLink> </MainMenu.ItemLink>
{import.meta.env.DEV && ( {isDevEnv() && (
<MainMenu.Item <MainMenu.Item
icon={eyeIcon} icon={eyeIcon}
onClick={() => { onClick={() => {

View File

@ -1,9 +1,10 @@
import React from "react";
import { loginIcon } from "@excalidraw/excalidraw/components/icons"; import { loginIcon } from "@excalidraw/excalidraw/components/icons";
import { POINTER_EVENTS } from "@excalidraw/common";
import { useI18n } from "@excalidraw/excalidraw/i18n"; import { useI18n } from "@excalidraw/excalidraw/i18n";
import { WelcomeScreen } from "@excalidraw/excalidraw/index"; import { WelcomeScreen } from "@excalidraw/excalidraw/index";
import React from "react";
import { isExcalidrawPlusSignedUser } from "../app_constants"; import { isExcalidrawPlusSignedUser } from "../app_constants";
import { POINTER_EVENTS } from "@excalidraw/excalidraw/constants";
export const AppWelcomeScreen: React.FC<{ export const AppWelcomeScreen: React.FC<{
onCollabDialogOpen: () => any; onCollabDialogOpen: () => any;

View File

@ -1,24 +1,28 @@
import { useCallback, useImperativeHandle, useRef } from "react";
import { type AppState } from "@excalidraw/excalidraw/types";
import { throttleRAF } from "@excalidraw/excalidraw/utils";
import {
bootstrapCanvas,
getNormalizedCanvasDimensions,
} from "@excalidraw/excalidraw/renderer/helpers";
import type { DebugElement } from "@excalidraw/excalidraw/visualdebug";
import { import {
ArrowheadArrowIcon, ArrowheadArrowIcon,
CloseIcon, CloseIcon,
TrashIcon, TrashIcon,
} from "@excalidraw/excalidraw/components/icons"; } from "@excalidraw/excalidraw/components/icons";
import { STORAGE_KEYS } from "../app_constants"; import {
import type { Curve } from "../../packages/math"; bootstrapCanvas,
getNormalizedCanvasDimensions,
} from "@excalidraw/excalidraw/renderer/helpers";
import { type AppState } from "@excalidraw/excalidraw/types";
import { throttleRAF } from "@excalidraw/common";
import { useCallback, useImperativeHandle, useRef } from "react";
import { import {
isLineSegment, isLineSegment,
type GlobalPoint, type GlobalPoint,
type LineSegment, type LineSegment,
} from "../../packages/math"; } from "@excalidraw/math";
import { isCurve } from "../../packages/math/curve"; import { isCurve } from "@excalidraw/math/curve";
import type { DebugElement } from "@excalidraw/excalidraw/visualdebug";
import type { Curve } from "@excalidraw/math";
import { STORAGE_KEYS } from "../app_constants";
const renderLine = ( const renderLine = (
context: CanvasRenderingContext2D, context: CanvasRenderingContext2D,

View File

@ -1,5 +1,5 @@
import { shield } from "@excalidraw/excalidraw/components/icons";
import { Tooltip } from "@excalidraw/excalidraw/components/Tooltip"; import { Tooltip } from "@excalidraw/excalidraw/components/Tooltip";
import { shield } from "@excalidraw/excalidraw/components/icons";
import { useI18n } from "@excalidraw/excalidraw/i18n"; import { useI18n } from "@excalidraw/excalidraw/i18n";
export const EncryptedIcon = () => { export const EncryptedIcon = () => {
@ -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}>

View File

@ -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+

View File

@ -1,31 +1,33 @@
import React from "react"; import React from "react";
import { uploadBytes, ref } from "firebase/storage";
import { nanoid } from "nanoid";
import { trackEvent } from "@excalidraw/excalidraw/analytics";
import { Card } from "@excalidraw/excalidraw/components/Card"; import { Card } from "@excalidraw/excalidraw/components/Card";
import { ExcalidrawLogo } from "@excalidraw/excalidraw/components/ExcalidrawLogo";
import { ToolButton } from "@excalidraw/excalidraw/components/ToolButton"; import { ToolButton } from "@excalidraw/excalidraw/components/ToolButton";
import { MIME_TYPES, getFrame } from "@excalidraw/common";
import {
encryptData,
generateEncryptionKey,
} from "@excalidraw/excalidraw/data/encryption";
import { serializeAsJSON } from "@excalidraw/excalidraw/data/json"; import { serializeAsJSON } from "@excalidraw/excalidraw/data/json";
import { loadFirebaseStorage, saveFilesToFirebase } from "../data/firebase"; import { isInitializedImageElement } from "@excalidraw/element";
import { useI18n } from "@excalidraw/excalidraw/i18n";
import type { import type {
FileId, FileId,
NonDeletedExcalidrawElement, NonDeletedExcalidrawElement,
} from "@excalidraw/excalidraw/element/types"; } from "@excalidraw/element/types";
import type { import type {
AppState, AppState,
BinaryFileData, BinaryFileData,
BinaryFiles, BinaryFiles,
} from "@excalidraw/excalidraw/types"; } from "@excalidraw/excalidraw/types";
import { nanoid } from "nanoid";
import { useI18n } from "@excalidraw/excalidraw/i18n";
import {
encryptData,
generateEncryptionKey,
} from "@excalidraw/excalidraw/data/encryption";
import { isInitializedImageElement } from "@excalidraw/excalidraw/element/typeChecks";
import { FILE_UPLOAD_MAX_BYTES } from "../app_constants"; import { FILE_UPLOAD_MAX_BYTES } from "../app_constants";
import { encodeFilesForUpload } from "../data/FileManager"; import { encodeFilesForUpload } from "../data/FileManager";
import { uploadBytes, ref } from "firebase/storage"; import { loadFirebaseStorage, saveFilesToFirebase } from "../data/firebase";
import { MIME_TYPES } from "@excalidraw/excalidraw/constants";
import { trackEvent } from "@excalidraw/excalidraw/analytics";
import { getFrame } from "@excalidraw/excalidraw/utils";
import { ExcalidrawLogo } from "@excalidraw/excalidraw/components/ExcalidrawLogo";
export const exportToExcalidrawPlus = async ( export const exportToExcalidrawPlus = async (
elements: readonly NonDeletedExcalidrawElement[], elements: readonly NonDeletedExcalidrawElement[],

View File

@ -1,7 +1,8 @@
import { THEME } from "@excalidraw/common";
import oc from "open-color"; import oc from "open-color";
import React from "react"; import React from "react";
import { THEME } from "@excalidraw/excalidraw/constants";
import type { Theme } from "@excalidraw/excalidraw/element/types"; import type { Theme } from "@excalidraw/element/types";
// https://github.com/tholman/github-corners // https://github.com/tholman/github-corners
export const GitHubCorner = React.memo( export const GitHubCorner = React.memo(

View File

@ -1,7 +1,7 @@
import React from "react";
import * as Sentry from "@sentry/browser";
import { t } from "@excalidraw/excalidraw/i18n";
import Trans from "@excalidraw/excalidraw/components/Trans"; import Trans from "@excalidraw/excalidraw/components/Trans";
import { t } from "@excalidraw/excalidraw/i18n";
import * as Sentry from "@sentry/browser";
import React from "react";
interface TopErrorBoundaryState { interface TopErrorBoundaryState {
hasError: boolean; hasError: boolean;

View File

@ -1,14 +1,15 @@
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/excalidraw/element/mutateElement"; import { newElementWith } from "@excalidraw/element";
import { isInitializedImageElement } from "@excalidraw/excalidraw/element/typeChecks"; import { isInitializedImageElement } from "@excalidraw/element";
import { t } from "@excalidraw/excalidraw/i18n";
import type { import type {
ExcalidrawElement, ExcalidrawElement,
ExcalidrawImageElement, ExcalidrawImageElement,
FileId, FileId,
InitializedExcalidrawImageElement, InitializedExcalidrawImageElement,
} from "@excalidraw/excalidraw/element/types"; } from "@excalidraw/element/types";
import { t } from "@excalidraw/excalidraw/i18n";
import type { import type {
BinaryFileData, BinaryFileData,
BinaryFileMetadata, BinaryFileMetadata,

View File

@ -10,6 +10,13 @@
* (localStorage, indexedDB). * (localStorage, indexedDB).
*/ */
import { clearAppStateForLocalStorage } from "@excalidraw/excalidraw/appState";
import {
CANVAS_SEARCH_TAB,
DEFAULT_SIDEBAR,
debounce,
} from "@excalidraw/common";
import { clearElementsForLocalStorage } from "@excalidraw/element";
import { import {
createStore, createStore,
entries, entries,
@ -19,26 +26,19 @@ import {
setMany, setMany,
get, get,
} from "idb-keyval"; } from "idb-keyval";
import { clearAppStateForLocalStorage } from "@excalidraw/excalidraw/appState";
import {
CANVAS_SEARCH_TAB,
DEFAULT_SIDEBAR,
} from "@excalidraw/excalidraw/constants";
import type { LibraryPersistedData } from "@excalidraw/excalidraw/data/library"; import type { LibraryPersistedData } from "@excalidraw/excalidraw/data/library";
import type { ImportedDataState } from "@excalidraw/excalidraw/data/types"; import type { ImportedDataState } from "@excalidraw/excalidraw/data/types";
import { clearElementsForLocalStorage } from "@excalidraw/excalidraw/element"; import type { ExcalidrawElement, FileId } from "@excalidraw/element/types";
import type {
ExcalidrawElement,
FileId,
} from "@excalidraw/excalidraw/element/types";
import type { import type {
AppState, AppState,
BinaryFileData, BinaryFileData,
BinaryFiles, BinaryFiles,
} from "@excalidraw/excalidraw/types"; } from "@excalidraw/excalidraw/types";
import type { MaybePromise } from "@excalidraw/excalidraw/utility-types"; import type { MaybePromise } from "@excalidraw/common/utility-types";
import { debounce } from "@excalidraw/excalidraw/utils";
import { SAVE_TO_LOCAL_STORAGE_TIMEOUT, STORAGE_KEYS } from "../app_constants"; import { SAVE_TO_LOCAL_STORAGE_TIMEOUT, STORAGE_KEYS } from "../app_constants";
import { FileManager } from "./FileManager"; import { FileManager } from "./FileManager";
import { Locker } from "./Locker"; import { Locker } from "./Locker";
import { updateBrowserStateVersion } from "./tabSync"; import { updateBrowserStateVersion } from "./tabSync";

View File

@ -1,27 +1,12 @@
import { reconcileElements } from "@excalidraw/excalidraw"; import { reconcileElements } from "@excalidraw/excalidraw";
import type { import { MIME_TYPES } from "@excalidraw/common";
ExcalidrawElement,
FileId,
OrderedExcalidrawElement,
} from "@excalidraw/excalidraw/element/types";
import { getSceneVersion } from "@excalidraw/excalidraw/element";
import type Portal from "../collab/Portal";
import { restoreElements } from "@excalidraw/excalidraw/data/restore";
import type {
AppState,
BinaryFileData,
BinaryFileMetadata,
DataURL,
} from "@excalidraw/excalidraw/types";
import { FILE_CACHE_MAX_AGE_SEC } from "../app_constants";
import { decompressData } from "@excalidraw/excalidraw/data/encode"; import { decompressData } from "@excalidraw/excalidraw/data/encode";
import { import {
encryptData, encryptData,
decryptData, decryptData,
} from "@excalidraw/excalidraw/data/encryption"; } from "@excalidraw/excalidraw/data/encryption";
import { MIME_TYPES } from "@excalidraw/excalidraw/constants"; import { restoreElements } from "@excalidraw/excalidraw/data/restore";
import type { SyncableExcalidrawElement } from "."; import { getSceneVersion } from "@excalidraw/element";
import { getSyncableElements } from ".";
import { initializeApp } from "firebase/app"; import { initializeApp } from "firebase/app";
import { import {
getFirestore, getFirestore,
@ -31,8 +16,27 @@ import {
Bytes, Bytes,
} from "firebase/firestore"; } from "firebase/firestore";
import { getStorage, ref, uploadBytes } from "firebase/storage"; import { getStorage, ref, uploadBytes } from "firebase/storage";
import type { Socket } from "socket.io-client";
import type { RemoteExcalidrawElement } from "@excalidraw/excalidraw/data/reconcile"; import type { RemoteExcalidrawElement } from "@excalidraw/excalidraw/data/reconcile";
import type {
ExcalidrawElement,
FileId,
OrderedExcalidrawElement,
} from "@excalidraw/element/types";
import type {
AppState,
BinaryFileData,
BinaryFileMetadata,
DataURL,
} from "@excalidraw/excalidraw/types";
import { FILE_CACHE_MAX_AGE_SEC } from "../app_constants";
import { getSyncableElements } from ".";
import type { SyncableExcalidrawElement } from ".";
import type Portal from "../collab/Portal";
import type { Socket } from "socket.io-client";
// private // private
// ----------------------------------------------------------------------------- // -----------------------------------------------------------------------------

View File

@ -9,34 +9,38 @@ 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";
import { isInitializedImageElement } from "@excalidraw/element";
import { t } from "@excalidraw/excalidraw/i18n";
import { bytesToHexString } 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/excalidraw/element/bounds"; import type { SceneBounds } from "@excalidraw/element";
import { isInvisiblySmallElement } from "@excalidraw/excalidraw/element/sizeHelpers";
import { isInitializedImageElement } from "@excalidraw/excalidraw/element/typeChecks";
import type { import type {
ExcalidrawElement, ExcalidrawElement,
FileId, FileId,
OrderedExcalidrawElement, OrderedExcalidrawElement,
} from "@excalidraw/excalidraw/element/types"; } from "@excalidraw/element/types";
import { t } from "@excalidraw/excalidraw/i18n";
import type { import type {
AppState, AppState,
BinaryFileData, BinaryFileData,
BinaryFiles, BinaryFiles,
SocketId, SocketId,
} from "@excalidraw/excalidraw/types"; } from "@excalidraw/excalidraw/types";
import type { UserIdleState } from "@excalidraw/excalidraw/constants"; import type { MakeBrand } from "@excalidraw/common/utility-types";
import type { MakeBrand } from "@excalidraw/excalidraw/utility-types";
import { bytesToHexString } from "@excalidraw/excalidraw/utils";
import type { WS_SUBTYPES } from "../app_constants";
import { import {
DELETED_ELEMENT_TIMEOUT, DELETED_ELEMENT_TIMEOUT,
FILE_UPLOAD_MAX_BYTES, FILE_UPLOAD_MAX_BYTES,
ROOM_ID_BYTES, ROOM_ID_BYTES,
} from "../app_constants"; } from "../app_constants";
import { encodeFilesForUpload } from "./FileManager"; import { encodeFilesForUpload } from "./FileManager";
import { saveFilesToFirebase } from "./firebase"; import { saveFilesToFirebase } from "./firebase";
import type { WS_SUBTYPES } from "../app_constants";
export type SyncableExcalidrawElement = OrderedExcalidrawElement & export type SyncableExcalidrawElement = OrderedExcalidrawElement &
MakeBrand<"SyncableExcalidrawElement">; MakeBrand<"SyncableExcalidrawElement">;

View File

@ -1,10 +1,12 @@
import type { ExcalidrawElement } from "@excalidraw/excalidraw/element/types";
import type { AppState } from "@excalidraw/excalidraw/types";
import { import {
clearAppStateForLocalStorage, clearAppStateForLocalStorage,
getDefaultAppState, getDefaultAppState,
} from "@excalidraw/excalidraw/appState"; } from "@excalidraw/excalidraw/appState";
import { clearElementsForLocalStorage } from "@excalidraw/excalidraw/element"; import { clearElementsForLocalStorage } from "@excalidraw/element";
import type { ExcalidrawElement } from "@excalidraw/element/types";
import type { AppState } from "@excalidraw/excalidraw/types";
import { STORAGE_KEYS } from "../app_constants"; import { STORAGE_KEYS } from "../app_constants";
export const saveUsernameToLocalStorage = (username: string) => { export const saveUsernameToLocalStorage = (username: string) => {

View File

@ -1,9 +1,11 @@
import { StrictMode } from "react"; import { StrictMode } from "react";
import { createRoot } from "react-dom/client"; import { createRoot } from "react-dom/client";
import ExcalidrawApp from "./App";
import { registerSW } from "virtual:pwa-register"; import { registerSW } from "virtual:pwa-register";
import "../excalidraw-app/sentry"; import "../excalidraw-app/sentry";
import ExcalidrawApp from "./App";
window.__EXCALIDRAW_SHA__ = import.meta.env.VITE_APP_GIT_SHA; window.__EXCALIDRAW_SHA__ = import.meta.env.VITE_APP_GIT_SHA;
const rootElement = document.getElementById("root")!; const rootElement = document.getElementById("root")!;
const root = createRoot(rootElement); const root = createRoot(rootElement);

View File

@ -41,8 +41,8 @@
"prettier": "@excalidraw/prettier-config", "prettier": "@excalidraw/prettier-config",
"scripts": { "scripts": {
"build-node": "node ./scripts/build-node.js", "build-node": "node ./scripts/build-node.js",
"build:app:docker": "cross-env VITE_APP_DISABLE_SENTRY=true vite build", "build:app:docker": "vite build",
"build:app": "cross-env VITE_APP_GIT_SHA=$VERCEL_GIT_COMMIT_SHA cross-env VITE_APP_ENABLE_TRACKING=true vite build", "build:app": "vite build",
"build:version": "node ../scripts/build-version.js", "build:version": "node ../scripts/build-version.js",
"build": "yarn build:app && yarn build:version", "build": "yarn build:app && yarn build:version",
"start": "yarn && vite", "start": "yarn && vite",

View File

@ -1,10 +1,8 @@
import { useEffect, useRef, useState } from "react";
import { copyTextToSystemClipboard } from "@excalidraw/excalidraw/clipboard";
import { trackEvent } from "@excalidraw/excalidraw/analytics"; import { trackEvent } from "@excalidraw/excalidraw/analytics";
import { getFrame } from "@excalidraw/excalidraw/utils"; import { copyTextToSystemClipboard } from "@excalidraw/excalidraw/clipboard";
import { useI18n } from "@excalidraw/excalidraw/i18n";
import { KEYS } from "@excalidraw/excalidraw/keys";
import { Dialog } from "@excalidraw/excalidraw/components/Dialog"; import { Dialog } from "@excalidraw/excalidraw/components/Dialog";
import { FilledButton } from "@excalidraw/excalidraw/components/FilledButton";
import { TextField } from "@excalidraw/excalidraw/components/TextField";
import { import {
copyIcon, copyIcon,
LinkIcon, LinkIcon,
@ -14,16 +12,19 @@ import {
shareIOS, shareIOS,
shareWindows, shareWindows,
} from "@excalidraw/excalidraw/components/icons"; } from "@excalidraw/excalidraw/components/icons";
import { TextField } from "@excalidraw/excalidraw/components/TextField";
import { FilledButton } from "@excalidraw/excalidraw/components/FilledButton";
import type { CollabAPI } from "../collab/Collab";
import { activeRoomLinkAtom } from "../collab/Collab";
import { useUIAppState } from "@excalidraw/excalidraw/context/ui-appState"; import { useUIAppState } from "@excalidraw/excalidraw/context/ui-appState";
import { useCopyStatus } from "@excalidraw/excalidraw/hooks/useCopiedIndicator"; import { useCopyStatus } from "@excalidraw/excalidraw/hooks/useCopiedIndicator";
import { useI18n } from "@excalidraw/excalidraw/i18n";
import { KEYS, getFrame } from "@excalidraw/common";
import { useEffect, useRef, useState } from "react";
import { atom, useAtom, useAtomValue } from "../app-jotai"; import { atom, useAtom, useAtomValue } from "../app-jotai";
import { activeRoomLinkAtom } from "../collab/Collab";
import "./ShareDialog.scss"; import "./ShareDialog.scss";
import type { CollabAPI } from "../collab/Collab";
type OnExportToBackend = () => void; type OnExportToBackend = () => void;
type ShareDialogType = "share" | "collaborationOnly"; type ShareDialogType = "share" | "collaborationOnly";

View File

@ -1,11 +1,11 @@
import ExcalidrawApp from "../App"; import { UI } from "@excalidraw/excalidraw/tests/helpers/ui";
import { import {
mockBoundingClientRect, mockBoundingClientRect,
render, render,
restoreOriginalGetBoundingClientRect, restoreOriginalGetBoundingClientRect,
} from "@excalidraw/excalidraw/tests/test-utils"; } from "@excalidraw/excalidraw/tests/test-utils";
import { UI } from "@excalidraw/excalidraw/tests/helpers/ui"; import ExcalidrawApp from "../App";
describe("Test MobileMenu", () => { describe("Test MobileMenu", () => {
const { h } = window; const { h } = window;

View File

@ -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

View File

@ -1,13 +1,18 @@
import { vi } from "vitest"; import { CaptureUpdateAction, newElementWith } from "@excalidraw/excalidraw";
import { act, render, waitFor } from "@excalidraw/excalidraw/tests/test-utils";
import ExcalidrawApp from "../App";
import { API } from "@excalidraw/excalidraw/tests/helpers/api";
import { syncInvalidIndices } from "@excalidraw/excalidraw/fractionalIndex";
import { import {
createRedoAction, createRedoAction,
createUndoAction, createUndoAction,
} from "@excalidraw/excalidraw/actions/actionHistory"; } from "@excalidraw/excalidraw/actions/actionHistory";
import { CaptureUpdateAction, newElementWith } from "@excalidraw/excalidraw"; import { syncInvalidIndices } from "@excalidraw/element";
import { API } from "@excalidraw/excalidraw/tests/helpers/api";
import { act, render, waitFor } from "@excalidraw/excalidraw/tests/test-utils";
import { vi } from "vitest";
import { StoreIncrement } from "@excalidraw/element";
import type { DurableIncrement, EphemeralIncrement } from "@excalidraw/element";
import ExcalidrawApp from "../App";
const { h } = window; const { h } = window;
@ -64,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 = {
@ -121,7 +199,7 @@ 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!
@ -153,7 +231,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!

View File

@ -1,8 +1,9 @@
import { useEffect, useLayoutEffect, useState } from "react";
import { THEME } from "@excalidraw/excalidraw"; import { THEME } from "@excalidraw/excalidraw";
import { EVENT } from "@excalidraw/excalidraw/constants"; import { EVENT, CODES, KEYS } from "@excalidraw/common";
import type { Theme } from "@excalidraw/excalidraw/element/types"; import { useEffect, useLayoutEffect, useState } from "react";
import { CODES, KEYS } from "@excalidraw/excalidraw/keys";
import type { Theme } from "@excalidraw/element/types";
import { STORAGE_KEYS } from "./app_constants"; import { STORAGE_KEYS } from "./app_constants";
const getDarkThemeMediaQuery = (): MediaQueryList | undefined => const getDarkThemeMediaQuery = (): MediaQueryList | undefined =>

View File

@ -23,29 +23,57 @@ export default defineConfig(({ mode }) => {
envDir: "../", envDir: "../",
resolve: { resolve: {
alias: [ alias: [
{
find: /^@excalidraw\/common$/,
replacement: path.resolve(
__dirname,
"../packages/common/src/index.ts",
),
},
{
find: /^@excalidraw\/common\/(.*?)/,
replacement: path.resolve(__dirname, "../packages/common/src/$1"),
},
{
find: /^@excalidraw\/element$/,
replacement: path.resolve(
__dirname,
"../packages/element/src/index.ts",
),
},
{
find: /^@excalidraw\/element\/(.*?)/,
replacement: path.resolve(__dirname, "../packages/element/src/$1"),
},
{ {
find: /^@excalidraw\/excalidraw$/, find: /^@excalidraw\/excalidraw$/,
replacement: path.resolve(__dirname, "../packages/excalidraw/index.tsx"), replacement: path.resolve(
__dirname,
"../packages/excalidraw/index.tsx",
),
}, },
{ {
find: /^@excalidraw\/excalidraw\/(.*?)/, find: /^@excalidraw\/excalidraw\/(.*?)/,
replacement: path.resolve(__dirname, "../packages/excalidraw/$1"), replacement: path.resolve(__dirname, "../packages/excalidraw/$1"),
}, },
{
find: /^@excalidraw\/utils$/,
replacement: path.resolve(__dirname, "../packages/utils/index.ts"),
},
{
find: /^@excalidraw\/utils\/(.*?)/,
replacement: path.resolve(__dirname, "../packages/utils/$1"),
},
{ {
find: /^@excalidraw\/math$/, find: /^@excalidraw\/math$/,
replacement: path.resolve(__dirname, "../packages/math/index.ts"), replacement: path.resolve(__dirname, "../packages/math/src/index.ts"),
}, },
{ {
find: /^@excalidraw\/math\/(.*?)/, find: /^@excalidraw\/math\/(.*?)/,
replacement: path.resolve(__dirname, "../packages/math/$1"), replacement: path.resolve(__dirname, "../packages/math/src/$1"),
},
{
find: /^@excalidraw\/utils$/,
replacement: path.resolve(
__dirname,
"../packages/utils/src/index.ts",
),
},
{
find: /^@excalidraw\/utils\/(.*?)/,
replacement: path.resolve(__dirname, "../packages/utils/src/$1"),
}, },
], ],
}, },
@ -197,7 +225,7 @@ export default defineConfig(({ mode }) => {
}, },
], ],
start_url: "/", start_url: "/",
id:"excalidraw", id: "excalidraw",
display: "standalone", display: "standalone",
theme_color: "#121212", theme_color: "#121212",
background_color: "#ffffff", background_color: "#ffffff",

View File

@ -4,9 +4,7 @@
"packageManager": "yarn@1.22.22", "packageManager": "yarn@1.22.22",
"workspaces": [ "workspaces": [
"excalidraw-app", "excalidraw-app",
"packages/excalidraw", "packages/*",
"packages/utils",
"packages/math",
"examples/*" "examples/*"
], ],
"devDependencies": { "devDependencies": {
@ -26,6 +24,7 @@
"dotenv": "16.0.1", "dotenv": "16.0.1",
"eslint-config-prettier": "8.5.0", "eslint-config-prettier": "8.5.0",
"eslint-config-react-app": "7.0.1", "eslint-config-react-app": "7.0.1",
"eslint-plugin-import": "2.31.0",
"eslint-plugin-prettier": "3.3.1", "eslint-plugin-prettier": "3.3.1",
"http-server": "14.1.1", "http-server": "14.1.1",
"husky": "7.0.4", "husky": "7.0.4",
@ -34,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",
@ -79,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": {

View File

@ -0,0 +1,3 @@
{
"extends": ["../eslintrc.base.json"]
}

19
packages/common/README.md Normal file
View File

@ -0,0 +1,19 @@
# @excalidraw/common
## Install
```bash
npm install @excalidraw/common
```
If you prefer Yarn over npm, use this command to install the Excalidraw utils package:
```bash
yarn add @excalidraw/common
```
With PNPM, similarly install the package with this command:
```bash
pnpm add @excalidraw/common
```

3
packages/common/global.d.ts vendored Normal file
View File

@ -0,0 +1,3 @@
/// <reference types="vite/client" />
import "@excalidraw/excalidraw/global";
import "@excalidraw/excalidraw/css";

View File

@ -0,0 +1,56 @@
{
"name": "@excalidraw/common",
"version": "0.1.0",
"type": "module",
"types": "./dist/types/common/src/index.d.ts",
"main": "./dist/prod/index.js",
"module": "./dist/prod/index.js",
"exports": {
".": {
"types": "./dist/types/common/src/index.d.ts",
"development": "./dist/dev/index.js",
"production": "./dist/prod/index.js",
"default": "./dist/prod/index.js"
},
"./*": {
"types": "./dist/types/common/src/*.d.ts"
}
},
"files": [
"dist/*"
],
"description": "Excalidraw common functions, constants, etc.",
"publishConfig": {
"access": "public"
},
"license": "MIT",
"keywords": [
"excalidraw",
"excalidraw-utils"
],
"browserslist": {
"production": [
">0.2%",
"not dead",
"not ie <= 11",
"not op_mini all",
"not safari < 12",
"not kaios <= 2.5",
"not edge < 79",
"not chrome < 70",
"not and_uc < 13",
"not samsung < 10"
],
"development": [
"last 1 chrome version",
"last 1 firefox version",
"last 1 safari version"
]
},
"bugs": "https://github.com/excalidraw/excalidraw/issues",
"repository": "https://github.com/excalidraw/excalidraw",
"scripts": {
"gen:types": "rimraf types && tsc",
"build:esm": "rimraf dist && node ../../scripts/buildBase.js && yarn gen:types"
}
}

View File

@ -1,4 +1,4 @@
export default class BinaryHeap<T> { export class BinaryHeap<T> {
private content: T[] = []; private content: T[] = [];
constructor(private scoreFunction: (node: T) => number) {} constructor(private scoreFunction: (node: T) => number) {}

View File

@ -1,6 +1,9 @@
import oc from "open-color"; import oc from "open-color";
import type { Merge } from "./utility-types"; import type { Merge } from "./utility-types";
export const COLOR_OUTLINE_CONTRAST_THRESHOLD = 240;
// FIXME can't put to utils.ts rn because of circular dependency // FIXME can't put to utils.ts rn because of circular dependency
const pick = <R extends Record<string, any>, K extends readonly (keyof R)[]>( const pick = <R extends Record<string, any>, K extends readonly (keyof R)[]>(
source: R, source: R,

View File

@ -1,11 +1,16 @@
import type { AppProps, AppState } from "./types"; import type {
import type { ExcalidrawElement, FontFamilyValues } from "./element/types"; ExcalidrawElement,
FontFamilyValues,
} from "@excalidraw/element/types";
import type { AppProps, AppState } from "@excalidraw/excalidraw/types";
import { COLOR_PALETTE } from "./colors"; import { COLOR_PALETTE } from "./colors";
export const isDarwin = /Mac|iPod|iPhone|iPad/.test(navigator.platform); 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;
@ -108,12 +113,14 @@ export const YOUTUBE_STATES = {
export const ENV = { export const ENV = {
TEST: "test", TEST: "test",
DEVELOPMENT: "development", DEVELOPMENT: "development",
PRODUCTION: "production",
}; };
export const CLASSES = { 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";
@ -137,6 +144,7 @@ export const FONT_FAMILY = {
"Lilita One": 7, "Lilita One": 7,
"Comic Shanns": 8, "Comic Shanns": 8,
"Liberation Sans": 9, "Liberation Sans": 9,
Assistant: 10,
}; };
export const FONT_FAMILY_FALLBACKS = { export const FONT_FAMILY_FALLBACKS = {
@ -248,7 +256,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
@ -314,6 +322,9 @@ export const DEFAULT_MAX_IMAGE_WIDTH_OR_HEIGHT = 1440;
export const MAX_ALLOWED_FILE_BYTES = 4 * 1024 * 1024; export const MAX_ALLOWED_FILE_BYTES = 4 * 1024 * 1024;
export const SVG_NS = "http://www.w3.org/2000/svg"; export const SVG_NS = "http://www.w3.org/2000/svg";
export const SVG_DOCUMENT_PREAMBLE = `<?xml version="1.0" standalone="no"?>
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
`;
export const ENCRYPTION_KEY_BITS = 128; export const ENCRYPTION_KEY_BITS = 128;
@ -415,6 +426,7 @@ export const LIBRARY_DISABLED_TYPES = new Set([
// use these constants to easily identify reference sites // use these constants to easily identify reference sites
export const TOOL_TYPE = { export const TOOL_TYPE = {
selection: "selection", selection: "selection",
lasso: "lasso",
rectangle: "rectangle", rectangle: "rectangle",
diamond: "diamond", diamond: "diamond",
ellipse: "ellipse", ellipse: "ellipse",

View File

@ -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;

View File

@ -1,11 +1,9 @@
import type { JSX } from "react"; import type {
import { ExcalidrawTextElement,
FreedrawIcon, FontFamilyValues,
FontFamilyNormalIcon, } from "@excalidraw/element/types";
FontFamilyHeadingIcon,
FontFamilyCodeIcon, import { FONT_FAMILY, FONT_FAMILY_FALLBACKS } from "./constants";
} from "../components/icons";
import { FONT_FAMILY, FONT_FAMILY_FALLBACKS } from "../constants";
/** /**
* Encapsulates font metrics with additional font metadata. * Encapsulates font metrics with additional font metadata.
@ -22,12 +20,12 @@ export interface FontMetadata {
/** harcoded unitless line-height, https://github.com/excalidraw/excalidraw/pull/6360#issuecomment-1477635971 */ /** harcoded unitless line-height, https://github.com/excalidraw/excalidraw/pull/6360#issuecomment-1477635971 */
lineHeight: number; lineHeight: number;
}; };
/** element to be displayed as an icon */
icon?: JSX.Element;
/** 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 */
@ -42,16 +40,14 @@ export const FONT_METADATA: Record<number, FontMetadata> = {
descender: -374, descender: -374,
lineHeight: 1.25, lineHeight: 1.25,
}, },
icon: FreedrawIcon,
}, },
[FONT_FAMILY.Nunito]: { [FONT_FAMILY.Nunito]: {
metrics: { metrics: {
unitsPerEm: 1000, unitsPerEm: 1000,
ascender: 1011, ascender: 1011,
descender: -353, descender: -353,
lineHeight: 1.35, lineHeight: 1.25,
}, },
icon: FontFamilyNormalIcon,
}, },
[FONT_FAMILY["Lilita One"]]: { [FONT_FAMILY["Lilita One"]]: {
metrics: { metrics: {
@ -60,7 +56,6 @@ export const FONT_METADATA: Record<number, FontMetadata> = {
descender: -220, descender: -220,
lineHeight: 1.15, lineHeight: 1.15,
}, },
icon: FontFamilyHeadingIcon,
}, },
[FONT_FAMILY["Comic Shanns"]]: { [FONT_FAMILY["Comic Shanns"]]: {
metrics: { metrics: {
@ -69,7 +64,6 @@ export const FONT_METADATA: Record<number, FontMetadata> = {
descender: -250, descender: -250,
lineHeight: 1.25, lineHeight: 1.25,
}, },
icon: FontFamilyCodeIcon,
}, },
[FONT_FAMILY.Virgil]: { [FONT_FAMILY.Virgil]: {
metrics: { metrics: {
@ -78,7 +72,6 @@ export const FONT_METADATA: Record<number, FontMetadata> = {
descender: -374, descender: -374,
lineHeight: 1.25, lineHeight: 1.25,
}, },
icon: FreedrawIcon,
deprecated: true, deprecated: true,
}, },
[FONT_FAMILY.Helvetica]: { [FONT_FAMILY.Helvetica]: {
@ -88,7 +81,6 @@ export const FONT_METADATA: Record<number, FontMetadata> = {
descender: -471, descender: -471,
lineHeight: 1.15, lineHeight: 1.15,
}, },
icon: FontFamilyNormalIcon,
deprecated: true, deprecated: true,
local: true, local: true,
}, },
@ -99,7 +91,6 @@ export const FONT_METADATA: Record<number, FontMetadata> = {
descender: -480, descender: -480,
lineHeight: 1.2, lineHeight: 1.2,
}, },
icon: FontFamilyCodeIcon,
deprecated: true, deprecated: true,
}, },
[FONT_FAMILY["Liberation Sans"]]: { [FONT_FAMILY["Liberation Sans"]]: {
@ -109,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,
}, },
@ -148,3 +148,34 @@ export const GOOGLE_FONTS_RANGES = {
/** local protocol to skip the local font from registering or inlining */ /** local protocol to skip the local font from registering or inlining */
export const LOCAL_FONT_PROTOCOL = "local:"; export const LOCAL_FONT_PROTOCOL = "local:";
/**
* Calculates vertical offset for a text with alphabetic baseline.
*/
export const getVerticalOffset = (
fontFamily: ExcalidrawTextElement["fontFamily"],
fontSize: ExcalidrawTextElement["fontSize"],
lineHeightPx: number,
) => {
const { unitsPerEm, ascender, descender } =
FONT_METADATA[fontFamily]?.metrics ||
FONT_METADATA[FONT_FAMILY.Excalifont].metrics;
const fontSizeEm = fontSize / unitsPerEm;
const lineGap =
(lineHeightPx - fontSizeEm * ascender + fontSizeEm * descender) / 2;
const verticalOffset = fontSizeEm * ascender + lineGap;
return verticalOffset;
};
/**
* Gets line height for a selected family.
*/
export const getLineHeight = (fontFamily: FontFamilyValues) => {
const { lineHeight } =
FONT_METADATA[fontFamily]?.metrics ||
FONT_METADATA[FONT_FAMILY.Excalifont].metrics;
return lineHeight as ExcalidrawTextElement["lineHeight"];
};

View File

@ -0,0 +1,12 @@
export * from "./binary-heap";
export * from "./colors";
export * from "./constants";
export * from "./font-metadata";
export * from "./queue";
export * from "./keys";
export * from "./points";
export * from "./promise-pool";
export * from "./random";
export * from "./url";
export * from "./utils";
export * from "./emitter";

View File

@ -1,4 +1,5 @@
import { isDarwin } from "./constants"; import { isDarwin } from "./constants";
import type { ValueOf } from "./utility-types"; import type { ValueOf } from "./utility-types";
export const CODES = { export const CODES = {

View File

@ -4,6 +4,8 @@ import {
type LocalPoint, type LocalPoint,
} from "@excalidraw/math"; } from "@excalidraw/math";
import type { NullableGridSize } from "@excalidraw/excalidraw/types";
export const getSizeFromPoints = ( export const getSizeFromPoints = (
points: readonly (GlobalPoint | LocalPoint)[], points: readonly (GlobalPoint | LocalPoint)[],
) => { ) => {
@ -61,3 +63,18 @@ export const rescalePoints = <Point extends GlobalPoint | LocalPoint>(
return nextPoints; return nextPoints;
}; };
// TODO: Rounding this point causes some shake when free drawing
export const getGridPoint = (
x: number,
y: number,
gridSize: NullableGridSize,
): [number, number] => {
if (gridSize) {
return [
Math.round(x / gridSize) * gridSize,
Math.round(y / gridSize) * gridSize,
];
}
return [x, y];
};

View File

@ -0,0 +1,50 @@
import Pool from "es6-promise-pool";
// extending the missing types
// relying on the [Index, T] to keep a correct order
type TPromisePool<T, Index = number> = Pool<[Index, T][]> & {
addEventListener: (
type: "fulfilled",
listener: (event: { data: { result: [Index, T] } }) => void,
) => (event: { data: { result: [Index, T] } }) => void;
removeEventListener: (
type: "fulfilled",
listener: (event: { data: { result: [Index, T] } }) => void,
) => void;
};
export class PromisePool<T> {
private readonly pool: TPromisePool<T>;
private readonly entries: Record<number, T> = {};
constructor(
source: IterableIterator<Promise<void | readonly [number, T]>>,
concurrency: number,
) {
this.pool = new Pool(
source as unknown as () => void | PromiseLike<[number, T][]>,
concurrency,
) as TPromisePool<T>;
}
public all() {
const listener = (event: { data: { result: void | [number, T] } }) => {
if (event.data.result) {
// by default pool does not return the results, so we are gathering them manually
// with the correct call order (represented by the index in the tuple)
const [index, value] = event.data.result;
this.entries[index] = value;
}
};
this.pool.addEventListener("fulfilled", listener);
return this.pool.start().then(() => {
setTimeout(() => {
this.pool.removeEventListener("fulfilled", listener);
});
return Object.values(this.entries);
});
}
}

View File

@ -1,6 +1,8 @@
import { promiseTry, resolvablePromise } from ".";
import type { ResolvablePromise } from ".";
import type { MaybePromise } from "./utility-types"; import type { MaybePromise } from "./utility-types";
import type { ResolvablePromise } from "./utils";
import { promiseTry, resolvablePromise } from "./utils";
type Job<T, TArgs extends unknown[]> = (...args: TArgs) => MaybePromise<T>; type Job<T, TArgs extends unknown[]> = (...args: TArgs) => MaybePromise<T>;

View File

@ -1,5 +1,6 @@
import { Random } from "roughjs/bin/math";
import { nanoid } from "nanoid"; import { nanoid } from "nanoid";
import { Random } from "roughjs/bin/math";
import { isTestEnv } from "./utils"; import { isTestEnv } from "./utils";
let random = new Random(Date.now()); let random = new Random(Date.now());

View File

@ -1,5 +1,6 @@
import { sanitizeUrl } from "@braintree/sanitize-url"; import { sanitizeUrl } from "@braintree/sanitize-url";
import { escapeDoubleQuotes } from "../utils";
import { escapeDoubleQuotes } from "./utils";
export const normalizeLink = (link: string) => { export const normalizeLink = (link: string) => {
link = link.trim(); link = link.trim();

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;

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);
});
});
});

View File

@ -1,28 +1,34 @@
import Pool from "es6-promise-pool"; import { average, pointFrom, type GlobalPoint } from "@excalidraw/math";
import { average } from "@excalidraw/math";
import { COLOR_PALETTE } from "./colors";
import type { EVENT } from "./constants";
import {
DEFAULT_VERSION,
FONT_FAMILY,
getFontFamilyFallbacks,
isDarwin,
WINDOWS_EMOJI_FALLBACK_FONT,
} from "./constants";
import type { import type {
ExcalidrawBindableElement, ExcalidrawBindableElement,
FontFamilyValues, FontFamilyValues,
FontString, FontString,
} from "./element/types"; ExcalidrawElement,
} from "@excalidraw/element/types";
import type { import type {
ActiveTool, ActiveTool,
AppState, AppState,
ToolType, ToolType,
UnsubscribeCallback, UnsubscribeCallback,
Zoom, Zoom,
} from "./types"; } from "@excalidraw/excalidraw/types";
import { COLOR_PALETTE } from "./colors";
import {
DEFAULT_VERSION,
ENV,
FONT_FAMILY,
getFontFamilyFallbacks,
isDarwin,
WINDOWS_EMOJI_FALLBACK_FONT,
} from "./constants";
import type { MaybePromise, ResolutionType } from "./utility-types"; import type { MaybePromise, ResolutionType } from "./utility-types";
import type { EVENT } from "./constants";
let mockDateTime: string | null = null; let mockDateTime: string | null = null;
export const setDateTimeForTests = (dateTime: string) => { export const setDateTimeForTests = (dateTime: string) => {
@ -167,7 +173,7 @@ export const throttleRAF = <T extends any[]>(
}; };
const ret = (...args: T) => { const ret = (...args: T) => {
if (import.meta.env.MODE === "test") { if (isTestEnv()) {
fn(...args); fn(...args);
return; return;
} }
@ -380,7 +386,7 @@ export const updateActiveTool = (
type: ToolType; type: ToolType;
} }
| { type: "custom"; customType: string } | { type: "custom"; customType: string }
) & { locked?: boolean }) & { ) & { locked?: boolean; fromSelection?: boolean }) & {
lastActiveToolBeforeEraser?: ActiveTool | null; lastActiveToolBeforeEraser?: ActiveTool | null;
}, },
): AppState["activeTool"] => { ): AppState["activeTool"] => {
@ -402,6 +408,7 @@ export const updateActiveTool = (
type: data.type, type: data.type,
customType: null, customType: null,
locked: data.locked ?? appState.activeTool.locked, locked: data.locked ?? appState.activeTool.locked,
fromSelection: data.fromSelection ?? false,
}; };
}; };
@ -537,6 +544,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";
@ -673,7 +694,7 @@ export const arrayToMap = <T extends { id: string } | string>(
return items.reduce((acc: Map<string, T>, element) => { return items.reduce((acc: Map<string, T>, element) => {
acc.set(typeof element === "string" ? element : element.id, element); acc.set(typeof element === "string" ? element : element.id, element);
return acc; return acc;
}, new Map()); }, new Map() as Map<string, T>);
}; };
export const arrayToMapWithIndex = <T extends { id: string }>( export const arrayToMapWithIndex = <T extends { id: string }>(
@ -728,9 +749,30 @@ export const arrayToList = <T>(array: readonly T[]): Node<T>[] =>
return acc; return acc;
}, [] as Node<T>[]); }, [] as Node<T>[]);
export const isTestEnv = () => import.meta.env.MODE === "test"; /**
* 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();
};
export const isDevEnv = () => import.meta.env.MODE === "development"; /**
* 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 isDevEnv = () => import.meta.env.MODE === ENV.DEVELOPMENT;
export const isProdEnv = () => import.meta.env.MODE === ENV.PRODUCTION;
export const isServerEnv = () => export const isServerEnv = () =>
typeof process !== "undefined" && !!process?.env?.NODE_ENV; typeof process !== "undefined" && !!process?.env?.NODE_ENV;
@ -1184,54 +1226,6 @@ export const safelyParseJSON = (json: string): Record<string, any> | null => {
return null; return null;
} }
}; };
// extending the missing types
// relying on the [Index, T] to keep a correct order
type TPromisePool<T, Index = number> = Pool<[Index, T][]> & {
addEventListener: (
type: "fulfilled",
listener: (event: { data: { result: [Index, T] } }) => void,
) => (event: { data: { result: [Index, T] } }) => void;
removeEventListener: (
type: "fulfilled",
listener: (event: { data: { result: [Index, T] } }) => void,
) => void;
};
export class PromisePool<T> {
private readonly pool: TPromisePool<T>;
private readonly entries: Record<number, T> = {};
constructor(
source: IterableIterator<Promise<void | readonly [number, T]>>,
concurrency: number,
) {
this.pool = new Pool(
source as unknown as () => void | PromiseLike<[number, T][]>,
concurrency,
) as TPromisePool<T>;
}
public all() {
const listener = (event: { data: { result: void | [number, T] } }) => {
if (event.data.result) {
// by default pool does not return the results, so we are gathering them manually
// with the correct call order (represented by the index in the tuple)
const [index, value] = event.data.result;
this.entries[index] = value;
}
};
this.pool.addEventListener("fulfilled", listener);
return this.pool.start().then(() => {
setTimeout(() => {
this.pool.removeEventListener("fulfilled", listener);
});
return Object.values(this.entries);
});
}
}
/** /**
* use when you need to render unsafe string as HTML attribute, but MAKE SURE * use when you need to render unsafe string as HTML attribute, but MAKE SURE
@ -1243,3 +1237,60 @@ export const escapeDoubleQuotes = (str: string) => {
export const castArray = <T>(value: T | T[]): T[] => export const castArray = <T>(value: T | T[]): T[] =>
Array.isArray(value) ? value : [value]; Array.isArray(value) ? value : [value];
export const elementCenterPoint = (
element: ExcalidrawElement,
xOffset: number = 0,
yOffset: number = 0,
) => {
const { x, y, width, height } = element;
const centerXPoint = x + width / 2 + xOffset;
const centerYPoint = y + height / 2 + yOffset;
return pointFrom<GlobalPoint>(centerXPoint, centerYPoint);
};
/** hack for Array.isArray type guard not working with readonly value[] */
export const isReadonlyArray = (value?: any): value is readonly any[] => {
return Array.isArray(value);
};
export const sizeOf = (
value:
| readonly unknown[]
| Readonly<Map<string, unknown>>
| Readonly<Record<string, unknown>>
| ReadonlySet<unknown>,
): number => {
return isReadonlyArray(value)
? value.length
: value instanceof Map || value instanceof Set
? value.size
: 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;
};

View File

@ -1,4 +1,4 @@
import { KEYS, matchKey } from "./keys"; import { KEYS, matchKey } from "../src/keys";
describe("key matcher", async () => { describe("key matcher", async () => {
it("should not match unexpected key", async () => { it("should not match unexpected key", async () => {

View File

@ -1,4 +1,4 @@
import { Queue } from "./queue"; import { Queue } from "../src/queue";
describe("Queue", () => { describe("Queue", () => {
const calls: any[] = []; const calls: any[] = [];

View File

@ -1,4 +1,4 @@
import { normalizeLink } from "./url"; import { normalizeLink } from "../src/url";
describe("normalizeLink", () => { describe("normalizeLink", () => {
// NOTE not an extensive XSS test suite, just to check if we're not // NOTE not an extensive XSS test suite, just to check if we're not

View File

@ -0,0 +1,8 @@
{
"extends": "../tsconfig.base.json",
"compilerOptions": {
"outDir": "./dist/types"
},
"include": ["src/**/*", "global.d.ts"],
"exclude": ["**/*.test.*", "tests", "types", "examples", "dist"]
}

View File

@ -0,0 +1,3 @@
{
"extends": ["../eslintrc.base.json"]
}

View File

@ -0,0 +1,19 @@
# @excalidraw/element
## Install
```bash
npm install @excalidraw/element
```
If you prefer Yarn over npm, use this command to install the Excalidraw utils package:
```bash
yarn add @excalidraw/element
```
With PNPM, similarly install the package with this command:
```bash
pnpm add @excalidraw/element
```

3
packages/element/global.d.ts vendored Normal file
View File

@ -0,0 +1,3 @@
/// <reference types="vite/client" />
import "@excalidraw/excalidraw/global";
import "@excalidraw/excalidraw/css";

View File

@ -0,0 +1,56 @@
{
"name": "@excalidraw/element",
"version": "0.1.0",
"type": "module",
"types": "./dist/types/element/src/index.d.ts",
"main": "./dist/prod/index.js",
"module": "./dist/prod/index.js",
"exports": {
".": {
"types": "./dist/types/element/src/index.d.ts",
"development": "./dist/dev/index.js",
"production": "./dist/prod/index.js",
"default": "./dist/prod/index.js"
},
"./*": {
"types": "./dist/types/element/src/*.d.ts"
}
},
"files": [
"dist/*"
],
"description": "Excalidraw elements-related logic",
"publishConfig": {
"access": "public"
},
"license": "MIT",
"keywords": [
"excalidraw",
"excalidraw-utils"
],
"browserslist": {
"production": [
">0.2%",
"not dead",
"not ie <= 11",
"not op_mini all",
"not safari < 12",
"not kaios <= 2.5",
"not edge < 79",
"not chrome < 70",
"not and_uc < 13",
"not samsung < 10"
],
"development": [
"last 1 chrome version",
"last 1 firefox version",
"last 1 safari version"
]
},
"bugs": "https://github.com/excalidraw/excalidraw/issues",
"repository": "https://github.com/excalidraw/excalidraw",
"scripts": {
"gen:types": "rimraf types && tsc",
"build:esm": "rimraf dist && node ../../scripts/buildBase.js && yarn gen:types"
}
}

View File

@ -1,4 +1,27 @@
import throttle from "lodash.throttle"; import throttle from "lodash.throttle";
import {
randomInteger,
arrayToMap,
toBrandedType,
isDevEnv,
isTestEnv,
toArray,
} from "@excalidraw/common";
import { isNonDeletedElement } from "@excalidraw/element";
import { isFrameLikeElement } from "@excalidraw/element";
import { getElementsInGroup } from "@excalidraw/element";
import {
syncInvalidIndices,
syncMovedIndices,
validateFractionalIndices,
} from "@excalidraw/element";
import { getSelectedElements } from "@excalidraw/element";
import { mutateElement, type ElementUpdate } from "@excalidraw/element";
import type { import type {
ExcalidrawElement, ExcalidrawElement,
NonDeletedExcalidrawElement, NonDeletedExcalidrawElement,
@ -9,26 +32,15 @@ import type {
NonDeletedSceneElementsMap, NonDeletedSceneElementsMap,
OrderedExcalidrawElement, OrderedExcalidrawElement,
Ordered, Ordered,
} from "../element/types"; } from "@excalidraw/element/types";
import { isNonDeletedElement } from "../element";
import type { LinearElementEditor } from "../element/linearElementEditor";
import { isFrameLikeElement } from "../element/typeChecks";
import { getSelectedElements } from "./selection";
import type { AppState } from "../types";
import type { Assert, SameType } from "../utility-types";
import { randomInteger } from "../random";
import {
syncInvalidIndices,
syncMovedIndices,
validateFractionalIndices,
} from "../fractionalIndex";
import { arrayToMap } from "../utils";
import { toBrandedType } from "../utils";
import { ENV } from "../constants";
import { getElementsInGroup } from "../groups";
type ElementIdKey = InstanceType<typeof LinearElementEditor>["elementId"]; import type {
type ElementKey = ExcalidrawElement | ElementIdKey; Assert,
Mutable,
SameType,
} from "@excalidraw/common/utility-types";
import type { AppState } from "../../excalidraw/types";
type SceneStateCallback = () => void; type SceneStateCallback = () => void;
type SceneStateCallbackRemover = () => void; type SceneStateCallbackRemover = () => void;
@ -54,14 +66,10 @@ const getNonDeletedElements = <T extends ExcalidrawElement>(
const validateIndicesThrottled = throttle( const validateIndicesThrottled = throttle(
(elements: readonly ExcalidrawElement[]) => { (elements: readonly ExcalidrawElement[]) => {
if ( if (isDevEnv() || isTestEnv() || window?.DEBUG_FRACTIONAL_INDICES) {
import.meta.env.DEV ||
import.meta.env.MODE === ENV.TEST ||
window?.DEBUG_FRACTIONAL_INDICES
) {
validateFractionalIndices(elements, { validateFractionalIndices(elements, {
// throw only in dev & test, to remain functional on `DEBUG_FRACTIONAL_INDICES` // throw only in dev & test, to remain functional on `DEBUG_FRACTIONAL_INDICES`
shouldThrow: import.meta.env.DEV || import.meta.env.MODE === ENV.TEST, shouldThrow: isDevEnv() || isTestEnv(),
includeBoundTextValidation: true, includeBoundTextValidation: true,
}); });
} }
@ -97,44 +105,7 @@ const hashSelectionOpts = (
// in our codebase // in our codebase
export type ExcalidrawElementsIncludingDeleted = readonly ExcalidrawElement[]; export type ExcalidrawElementsIncludingDeleted = readonly ExcalidrawElement[];
const isIdKey = (elementKey: ElementKey): elementKey is ElementIdKey => { export class Scene {
if (typeof elementKey === "string") {
return true;
}
return false;
};
class Scene {
// ---------------------------------------------------------------------------
// static methods/props
// ---------------------------------------------------------------------------
private static sceneMapByElement = new WeakMap<ExcalidrawElement, Scene>();
private static sceneMapById = new Map<string, Scene>();
static mapElementToScene(elementKey: ElementKey, scene: Scene) {
if (isIdKey(elementKey)) {
// for cases where we don't have access to the element object
// (e.g. restore serialized appState with id references)
this.sceneMapById.set(elementKey, scene);
} else {
this.sceneMapByElement.set(elementKey, scene);
// if mapping element objects, also cache the id string when later
// looking up by id alone
this.sceneMapById.set(elementKey.id, scene);
}
}
/**
* @deprecated pass down `app.scene` and use it directly
*/
static getScene(elementKey: ElementKey): Scene | null {
if (isIdKey(elementKey)) {
return this.sceneMapById.get(elementKey) || null;
}
return this.sceneMapByElement.get(elementKey) || null;
}
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// instance methods/props // instance methods/props
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@ -193,6 +164,12 @@ class Scene {
return this.frames; return this.frames;
} }
constructor(elements: ElementsMapOrArray | null = null) {
if (elements) {
this.replaceAllElements(elements);
}
}
getSelectedElements(opts: { getSelectedElements(opts: {
// NOTE can be ommitted by making Scene constructor require App instance // NOTE can be ommitted by making Scene constructor require App instance
selectedElementIds: AppState["selectedElementIds"]; selectedElementIds: AppState["selectedElementIds"];
@ -287,11 +264,8 @@ class Scene {
} }
replaceAllElements(nextElements: ElementsMapOrArray) { replaceAllElements(nextElements: ElementsMapOrArray) {
const _nextElements = // we do trust the insertion order on the map, though maybe we shouldn't and should prefer order defined by fractional indices
// ts doesn't like `Array.isArray` of `instanceof Map` const _nextElements = toArray(nextElements);
nextElements instanceof Array
? nextElements
: Array.from(nextElements.values());
const nextFrameLikes: ExcalidrawFrameLikeElement[] = []; const nextFrameLikes: ExcalidrawFrameLikeElement[] = [];
validateIndicesThrottled(_nextElements); validateIndicesThrottled(_nextElements);
@ -303,7 +277,6 @@ class Scene {
nextFrameLikes.push(element); nextFrameLikes.push(element);
} }
this.elementsMap.set(element.id, element); this.elementsMap.set(element.id, element);
Scene.mapElementToScene(element, this);
}); });
const nonDeletedElements = getNonDeletedElements(this.elements); const nonDeletedElements = getNonDeletedElements(this.elements);
this.nonDeletedElements = nonDeletedElements.elements; this.nonDeletedElements = nonDeletedElements.elements;
@ -348,12 +321,6 @@ class Scene {
this.selectedElementsCache.elements = null; this.selectedElementsCache.elements = null;
this.selectedElementsCache.cache.clear(); this.selectedElementsCache.cache.clear();
Scene.sceneMapById.forEach((scene, elementKey) => {
if (scene === this) {
Scene.sceneMapById.delete(elementKey);
}
});
// done not for memory leaks, but to guard against possible late fires // done not for memory leaks, but to guard against possible late fires
// (I guess?) // (I guess?)
this.callbacks.clear(); this.callbacks.clear();
@ -450,6 +417,40 @@ class Scene {
// then, check if the id is a group // then, check if the id is a group
return getElementsInGroup(elementsMap, id); return getElementsInGroup(elementsMap, id);
}; };
}
export default Scene; // Mutate an element with passed updates and trigger the component to update. Make sure you
// are calling it either from a React event handler or within unstable_batchedUpdates().
mutateElement<TElement extends Mutable<ExcalidrawElement>>(
element: TElement,
updates: ElementUpdate<TElement>,
options: {
informMutation: boolean;
isDragging: boolean;
} = {
informMutation: true,
isDragging: false,
},
) {
const elementsMap = this.getNonDeletedElementsMap();
const { version: prevVersion } = element;
const { version: nextVersion } = mutateElement(
element,
elementsMap,
updates,
options,
);
if (
// skip if the element is not in the scene (i.e. selection)
this.elementsMap.has(element.id) &&
// skip if the element's version hasn't changed, as mutateElement returned the same element
prevVersion !== nextVersion &&
options.informMutation
) {
this.triggerUpdate();
}
return element;
}
}

View File

@ -1,31 +1,38 @@
import type { Point as RoughPoint } from "roughjs/bin/geometry";
import type { Drawable, Options } from "roughjs/bin/core";
import type { RoughGenerator } from "roughjs/bin/generator";
import { getDiamondPoints, getArrowheadPoints } from "../element";
import type { ElementShapes } from "./types";
import type {
ExcalidrawElement,
NonDeletedExcalidrawElement,
ExcalidrawSelectionElement,
ExcalidrawLinearElement,
Arrowhead,
} from "../element/types";
import { generateFreeDrawShape } from "../renderer/renderElement";
import { isTransparent, assertNever } from "../utils";
import { simplify } from "points-on-curve"; import { simplify } from "points-on-curve";
import { ROUGHNESS } from "../constants";
import { pointFrom, pointDistance, type LocalPoint } from "@excalidraw/math";
import { ROUGHNESS, isTransparent, assertNever } from "@excalidraw/common";
import type { Mutable } from "@excalidraw/common/utility-types";
import type { EmbedsValidationStatus } from "@excalidraw/excalidraw/types";
import type { ElementShapes } from "@excalidraw/excalidraw/scene/types";
import { import {
isElbowArrow, isElbowArrow,
isEmbeddableElement, isEmbeddableElement,
isIframeElement, isIframeElement,
isIframeLikeElement, isIframeLikeElement,
isLinearElement, isLinearElement,
} from "../element/typeChecks"; } from "./typeChecks";
import { getCornerRadius, isPathALoop } from "./shapes";
import { headingForPointIsHorizontal } from "./heading";
import { canChangeRoundness } from "./comparisons"; import { canChangeRoundness } from "./comparisons";
import type { EmbedsValidationStatus } from "../types"; import { generateFreeDrawShape } from "./renderElement";
import { pointFrom, pointDistance, type LocalPoint } from "@excalidraw/math"; import { getArrowheadPoints, getDiamondPoints } from "./bounds";
import { getCornerRadius, isPathALoop } from "../shapes";
import { headingForPointIsHorizontal } from "../element/heading"; import type {
ExcalidrawElement,
NonDeletedExcalidrawElement,
ExcalidrawSelectionElement,
ExcalidrawLinearElement,
Arrowhead,
} from "./types";
import type { Drawable, Options } from "roughjs/bin/core";
import type { RoughGenerator } from "roughjs/bin/generator";
import type { Point as RoughPoint } from "roughjs/bin/geometry";
const getDashArrayDashed = (strokeWidth: number) => [8, 8 + strokeWidth]; const getDashArrayDashed = (strokeWidth: number) => [8, 8 + strokeWidth];
@ -508,7 +515,10 @@ export const _generateElementShape = (
if (isPathALoop(element.points)) { if (isPathALoop(element.points)) {
// generate rough polygon to fill freedraw shape // generate rough polygon to fill freedraw shape
const simplifiedPoints = simplify(element.points, 0.75); const simplifiedPoints = simplify(
element.points as Mutable<LocalPoint[]>,
0.75,
);
shape = generator.curve(simplifiedPoints as [number, number][], { shape = generator.curve(simplifiedPoints as [number, number][], {
...generateRoughOptions(element), ...generateRoughOptions(element),
stroke: "none", stroke: "none",

View File

@ -1,14 +1,23 @@
import type { Drawable } from "roughjs/bin/core";
import { RoughGenerator } from "roughjs/bin/generator"; import { RoughGenerator } from "roughjs/bin/generator";
import { COLOR_PALETTE } from "@excalidraw/common";
import type { import type {
ExcalidrawElement, AppState,
ExcalidrawSelectionElement, EmbedsValidationStatus,
} from "../element/types"; } from "@excalidraw/excalidraw/types";
import { elementWithCanvasCache } from "../renderer/renderElement"; import type {
ElementShape,
ElementShapes,
} from "@excalidraw/excalidraw/scene/types";
import { _generateElementShape } from "./Shape"; import { _generateElementShape } from "./Shape";
import type { ElementShape, ElementShapes } from "./types";
import { COLOR_PALETTE } from "../colors"; import { elementWithCanvasCache } from "./renderElement";
import type { AppState, EmbedsValidationStatus } from "../types";
import type { ExcalidrawElement, ExcalidrawSelectionElement } from "./types";
import type { Drawable } from "roughjs/bin/core";
export class ShapeCache { export class ShapeCache {
private static rg = new RoughGenerator(); private static rg = new RoughGenerator();

View File

@ -1,10 +1,11 @@
import type { ElementsMap, ExcalidrawElement } from "./element/types"; import { updateBoundElements } from "./binding";
import { mutateElement } from "./element/mutateElement"; import { getCommonBoundingBox } from "./bounds";
import type { BoundingBox } from "./element/bounds";
import { getCommonBoundingBox } from "./element/bounds";
import { getMaximumGroups } from "./groups"; import { getMaximumGroups } from "./groups";
import { updateBoundElements } from "./element/binding";
import type Scene from "./scene/Scene"; import type { Scene } from "./Scene";
import type { BoundingBox } from "./bounds";
import type { ExcalidrawElement } from "./types";
export interface Alignment { export interface Alignment {
position: "start" | "center" | "end"; position: "start" | "center" | "end";
@ -13,10 +14,10 @@ export interface Alignment {
export const alignElements = ( export const alignElements = (
selectedElements: ExcalidrawElement[], selectedElements: ExcalidrawElement[],
elementsMap: ElementsMap,
alignment: Alignment, alignment: Alignment,
scene: Scene, scene: Scene,
): ExcalidrawElement[] => { ): ExcalidrawElement[] => {
const elementsMap = scene.getNonDeletedElementsMap();
const groups: ExcalidrawElement[][] = getMaximumGroups( const groups: ExcalidrawElement[][] = getMaximumGroups(
selectedElements, selectedElements,
elementsMap, elementsMap,
@ -31,12 +32,13 @@ export const alignElements = (
); );
return group.map((element) => { return group.map((element) => {
// update element // update element
const updatedEle = mutateElement(element, { const updatedEle = scene.mutateElement(element, {
x: element.x + translation.x, x: element.x + translation.x,
y: element.y + translation.y, y: element.y + translation.y,
}); });
// update bound elements // update bound elements
updateBoundElements(element, scene.getNonDeletedElementsMap(), { updateBoundElements(element, scene, {
simultaneouslyUpdated: group, simultaneouslyUpdated: group,
}); });
return updatedEle; return updatedEle;

View File

@ -1,17 +1,42 @@
import type {
ExcalidrawElement,
ExcalidrawLinearElement,
Arrowhead,
ExcalidrawFreeDrawElement,
NonDeleted,
ExcalidrawTextElementWithContainer,
ElementsMap,
} from "./types";
import rough from "roughjs/bin/rough"; import rough from "roughjs/bin/rough";
import type { Point as RoughPoint } from "roughjs/bin/geometry";
import type { Drawable, Op } from "roughjs/bin/core"; import {
import type { AppState } from "../types"; arrayToMap,
import { generateRoughOptions } from "../scene/Shape"; invariant,
rescalePoints,
sizeOf,
} from "@excalidraw/common";
import {
degreesToRadians,
lineSegment,
pointDistance,
pointFrom,
pointFromArray,
pointRotateRads,
} from "@excalidraw/math";
import { getCurvePathOps } from "@excalidraw/utils/shape";
import { pointsOnBezierCurves } from "points-on-curve";
import type {
Curve,
Degrees,
GlobalPoint,
LineSegment,
LocalPoint,
Radians,
} from "@excalidraw/math";
import type { AppState } from "@excalidraw/excalidraw/types";
import type { Mutable } from "@excalidraw/common/utility-types";
import { generateRoughOptions } from "./Shape";
import { ShapeCache } from "./ShapeCache";
import { LinearElementEditor } from "./linearElementEditor";
import { getBoundTextElement, getContainerElement } from "./textElement";
import { import {
isArrowElement, isArrowElement,
isBoundToContainer, isBoundToContainer,
@ -19,28 +44,28 @@ import {
isLinearElement, isLinearElement,
isTextElement, isTextElement,
} from "./typeChecks"; } from "./typeChecks";
import { rescalePoints } from "../points";
import { getBoundTextElement, getContainerElement } from "./textElement"; import { getElementShape } from "./shapes";
import { LinearElementEditor } from "./linearElementEditor";
import { ShapeCache } from "../scene/ShapeCache";
import { arrayToMap, invariant } from "../utils";
import type {
Degrees,
GlobalPoint,
LineSegment,
LocalPoint,
Radians,
} from "@excalidraw/math";
import { import {
degreesToRadians, deconstructDiamondElement,
lineSegment, deconstructRectanguloidElement,
pointFrom, } from "./utils";
pointDistance,
pointFromArray, import type { Drawable, Op } from "roughjs/bin/core";
pointRotateRads, import type { Point as RoughPoint } from "roughjs/bin/geometry";
} from "@excalidraw/math"; import type {
import type { Mutable } from "../utility-types"; Arrowhead,
import { getCurvePathOps } from "@excalidraw/utils/geometry/shape"; ElementsMap,
ElementsMapOrArray,
ExcalidrawElement,
ExcalidrawEllipseElement,
ExcalidrawFreeDrawElement,
ExcalidrawLinearElement,
ExcalidrawRectanguloidElement,
ExcalidrawTextElementWithContainer,
NonDeleted,
} from "./types";
export type RectangleBox = { export type RectangleBox = {
x: number; x: number;
@ -247,50 +272,82 @@ export const getElementAbsoluteCoords = (
* that can be used for visual collision detection (useful for frames) * that can be used for visual collision detection (useful for frames)
* as opposed to bounding box collision detection * as opposed to bounding box collision detection
*/ */
/**
* Given an element, return the line segments that make up the element.
*
* Uses helpers from /math
*/
export const getElementLineSegments = ( export const getElementLineSegments = (
element: ExcalidrawElement, element: ExcalidrawElement,
elementsMap: ElementsMap, elementsMap: ElementsMap,
): LineSegment<GlobalPoint>[] => { ): LineSegment<GlobalPoint>[] => {
const shape = getElementShape(element, elementsMap);
const [x1, y1, x2, y2, cx, cy] = getElementAbsoluteCoords( const [x1, y1, x2, y2, cx, cy] = getElementAbsoluteCoords(
element, element,
elementsMap, elementsMap,
); );
const center = pointFrom<GlobalPoint>(cx, cy);
const center: GlobalPoint = pointFrom(cx, cy); if (shape.type === "polycurve") {
const curves = shape.data;
if (isLinearElement(element) || isFreeDrawElement(element)) { const points = curves
const segments: LineSegment<GlobalPoint>[] = []; .map((curve) => pointsOnBezierCurves(curve, 10))
.flat();
let i = 0; let i = 0;
const segments: LineSegment<GlobalPoint>[] = [];
while (i < element.points.length - 1) { while (i < points.length - 1) {
segments.push( segments.push(
lineSegment( lineSegment(
pointRotateRads( pointFrom(points[i][0], points[i][1]),
pointFrom( pointFrom(points[i + 1][0], points[i + 1][1]),
element.points[i][0] + element.x,
element.points[i][1] + element.y,
),
center,
element.angle,
),
pointRotateRads(
pointFrom(
element.points[i + 1][0] + element.x,
element.points[i + 1][1] + element.y,
),
center,
element.angle,
),
), ),
); );
i++; i++;
} }
return segments; return segments;
} else if (shape.type === "polyline") {
return shape.data as LineSegment<GlobalPoint>[];
} else if (_isRectanguloidElement(element)) {
const [sides, corners] = deconstructRectanguloidElement(element);
const cornerSegments: LineSegment<GlobalPoint>[] = corners
.map((corner) => getSegmentsOnCurve(corner, center, element.angle))
.flat();
const rotatedSides = getRotatedSides(sides, center, element.angle);
return [...rotatedSides, ...cornerSegments];
} else if (element.type === "diamond") {
const [sides, corners] = deconstructDiamondElement(element);
const cornerSegments = corners
.map((corner) => getSegmentsOnCurve(corner, center, element.angle))
.flat();
const rotatedSides = getRotatedSides(sides, center, element.angle);
return [...rotatedSides, ...cornerSegments];
} else if (shape.type === "polygon") {
if (isTextElement(element)) {
const container = getContainerElement(element, elementsMap);
if (container && isLinearElement(container)) {
const segments: LineSegment<GlobalPoint>[] = [
lineSegment(pointFrom(x1, y1), pointFrom(x2, y1)),
lineSegment(pointFrom(x2, y1), pointFrom(x2, y2)),
lineSegment(pointFrom(x2, y2), pointFrom(x1, y2)),
lineSegment(pointFrom(x1, y2), pointFrom(x1, y1)),
];
return segments;
}
}
const points = shape.data as GlobalPoint[];
const segments: LineSegment<GlobalPoint>[] = [];
for (let i = 0; i < points.length - 1; i++) {
segments.push(lineSegment(points[i], points[i + 1]));
}
return segments;
} else if (shape.type === "ellipse") {
return getSegmentsOnEllipse(element as ExcalidrawEllipseElement);
} }
const [nw, ne, sw, se, n, s, w, e] = ( const [nw, ne, sw, se, , , w, e] = (
[ [
[x1, y1], [x1, y1],
[x2, y1], [x2, y1],
@ -303,28 +360,6 @@ export const getElementLineSegments = (
] as GlobalPoint[] ] as GlobalPoint[]
).map((point) => pointRotateRads(point, center, element.angle)); ).map((point) => pointRotateRads(point, center, element.angle));
if (element.type === "diamond") {
return [
lineSegment(n, w),
lineSegment(n, e),
lineSegment(s, w),
lineSegment(s, e),
];
}
if (element.type === "ellipse") {
return [
lineSegment(n, w),
lineSegment(n, e),
lineSegment(s, w),
lineSegment(s, e),
lineSegment(n, w),
lineSegment(n, e),
lineSegment(s, w),
lineSegment(s, e),
];
}
return [ return [
lineSegment(nw, ne), lineSegment(nw, ne),
lineSegment(sw, se), lineSegment(sw, se),
@ -337,6 +372,94 @@ export const getElementLineSegments = (
]; ];
}; };
const _isRectanguloidElement = (
element: ExcalidrawElement,
): element is ExcalidrawRectanguloidElement => {
return (
element != null &&
(element.type === "rectangle" ||
element.type === "image" ||
element.type === "iframe" ||
element.type === "embeddable" ||
element.type === "frame" ||
element.type === "magicframe" ||
(element.type === "text" && !element.containerId))
);
};
const getRotatedSides = (
sides: LineSegment<GlobalPoint>[],
center: GlobalPoint,
angle: Radians,
) => {
return sides.map((side) => {
return lineSegment(
pointRotateRads<GlobalPoint>(side[0], center, angle),
pointRotateRads<GlobalPoint>(side[1], center, angle),
);
});
};
const getSegmentsOnCurve = (
curve: Curve<GlobalPoint>,
center: GlobalPoint,
angle: Radians,
): LineSegment<GlobalPoint>[] => {
const points = pointsOnBezierCurves(curve, 10);
let i = 0;
const segments: LineSegment<GlobalPoint>[] = [];
while (i < points.length - 1) {
segments.push(
lineSegment(
pointRotateRads<GlobalPoint>(
pointFrom(points[i][0], points[i][1]),
center,
angle,
),
pointRotateRads<GlobalPoint>(
pointFrom(points[i + 1][0], points[i + 1][1]),
center,
angle,
),
),
);
i++;
}
return segments;
};
const getSegmentsOnEllipse = (
ellipse: ExcalidrawEllipseElement,
): LineSegment<GlobalPoint>[] => {
const center = pointFrom<GlobalPoint>(
ellipse.x + ellipse.width / 2,
ellipse.y + ellipse.height / 2,
);
const a = ellipse.width / 2;
const b = ellipse.height / 2;
const segments: LineSegment<GlobalPoint>[] = [];
const points: GlobalPoint[] = [];
const n = 90;
const deltaT = (Math.PI * 2) / n;
for (let i = 0; i < n; i++) {
const t = i * deltaT;
const x = center[0] + a * Math.cos(t);
const y = center[1] + b * Math.sin(t);
points.push(pointRotateRads(pointFrom(x, y), center, ellipse.angle));
}
for (let i = 0; i < points.length - 1; i++) {
segments.push(lineSegment(points[i], points[i + 1]));
}
segments.push(lineSegment(points[points.length - 1], points[0]));
return segments;
};
/** /**
* Scene -> Scene coords, but in x1,x2,y1,y2 format. * Scene -> Scene coords, but in x1,x2,y1,y2 format.
* *
@ -821,10 +944,10 @@ export const getElementBounds = (
}; };
export const getCommonBounds = ( export const getCommonBounds = (
elements: readonly ExcalidrawElement[], elements: ElementsMapOrArray,
elementsMap?: ElementsMap, elementsMap?: ElementsMap,
): Bounds => { ): Bounds => {
if (!elements.length) { if (!sizeOf(elements)) {
return [0, 0, 0, 0]; return [0, 0, 0, 0];
} }

View File

@ -1,31 +1,4 @@
import type { import { isTransparent, elementCenterPoint } from "@excalidraw/common";
ElementsMap,
ExcalidrawDiamondElement,
ExcalidrawElement,
ExcalidrawEllipseElement,
ExcalidrawRectangleElement,
ExcalidrawRectanguloidElement,
} from "./types";
import { getElementBounds } from "./bounds";
import type { FrameNameBounds } from "../types";
import type { GeometricShape } from "@excalidraw/utils/geometry/shape";
import { getPolygonShape } from "@excalidraw/utils/geometry/shape";
import { isPointInShape, isPointOnShape } from "@excalidraw/utils/collision";
import { isTransparent } from "../utils";
import {
hasBoundTextElement,
isIframeLikeElement,
isImageElement,
isTextElement,
} from "./typeChecks";
import { getBoundTextShape, isPathALoop } from "../shapes";
import type {
GlobalPoint,
LineSegment,
LocalPoint,
Polygon,
Radians,
} from "@excalidraw/math";
import { import {
curveIntersectLineSegment, curveIntersectLineSegment,
isPointWithinBounds, isPointWithinBounds,
@ -36,15 +9,47 @@ import {
pointRotateRads, pointRotateRads,
pointsEqual, pointsEqual,
} from "@excalidraw/math"; } from "@excalidraw/math";
import { import {
ellipse, ellipse,
ellipseLineIntersectionPoints, ellipseLineIntersectionPoints,
} from "@excalidraw/math/ellipse"; } from "@excalidraw/math/ellipse";
import { isPointInShape, isPointOnShape } from "@excalidraw/utils/collision";
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 { getBoundTextShape, isPathALoop } from "./shapes";
import { getElementBounds } from "./bounds";
import {
hasBoundTextElement,
isIframeLikeElement,
isImageElement,
isTextElement,
} from "./typeChecks";
import { import {
deconstructDiamondElement, deconstructDiamondElement,
deconstructRectanguloidElement, deconstructRectanguloidElement,
} from "./utils"; } from "./utils";
import type {
ElementsMap,
ExcalidrawDiamondElement,
ExcalidrawElement,
ExcalidrawEllipseElement,
ExcalidrawRectangleElement,
ExcalidrawRectanguloidElement,
} from "./types";
export const shouldTestInside = (element: ExcalidrawElement) => { export const shouldTestInside = (element: ExcalidrawElement) => {
if (element.type === "arrow") { if (element.type === "arrow") {
return false; return false;
@ -184,10 +189,7 @@ const intersectRectanguloidWithLineSegment = (
l: LineSegment<GlobalPoint>, l: LineSegment<GlobalPoint>,
offset: number = 0, offset: number = 0,
): GlobalPoint[] => { ): GlobalPoint[] => {
const center = pointFrom<GlobalPoint>( const center = elementCenterPoint(element);
element.x + element.width / 2,
element.y + element.height / 2,
);
// 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>(
@ -205,28 +207,28 @@ const intersectRectanguloidWithLineSegment = (
const [sides, corners] = deconstructRectanguloidElement(element, offset); const [sides, corners] = deconstructRectanguloidElement(element, offset);
return ( return (
[ // Test intersection against the sides, keep only the valid
// Test intersection against the sides, keep only the valid // intersection points and rotate them back to scene space
// intersection points and rotate them back to scene space sides
...sides .map((s) =>
.map((s) => lineSegmentIntersectionPoints(
lineSegmentIntersectionPoints( lineSegment<GlobalPoint>(rotatedA, rotatedB),
lineSegment<GlobalPoint>(rotatedA, rotatedB), s,
s, ),
), )
) .filter((x) => x != null)
.filter((x) => x != null) .map((j) => pointRotateRads<GlobalPoint>(j!, center, element.angle))
.map((j) => pointRotateRads<GlobalPoint>(j!, center, element.angle)),
// Test intersection against the corners which are cubic bezier curves, // Test intersection against the corners which are cubic bezier curves,
// keep only the valid intersection points and rotate them back to scene // keep only the valid intersection points and rotate them back to scene
// space // space
...corners .concat(
.flatMap((t) => corners
curveIntersectLineSegment(t, lineSegment(rotatedA, rotatedB)), .flatMap((t) =>
) curveIntersectLineSegment(t, lineSegment(rotatedA, rotatedB)),
.filter((i) => i != null) )
.map((j) => pointRotateRads(j, center, element.angle)), .filter((i) => i != null)
] .map((j) => pointRotateRads(j, center, element.angle)),
)
// Remove duplicates // Remove duplicates
.filter( .filter(
(p, idx, points) => points.findIndex((d) => pointsEqual(p, d)) === idx, (p, idx, points) => points.findIndex((d) => pointsEqual(p, d)) === idx,
@ -246,10 +248,7 @@ const intersectDiamondWithLineSegment = (
l: LineSegment<GlobalPoint>, l: LineSegment<GlobalPoint>,
offset: number = 0, offset: number = 0,
): GlobalPoint[] => { ): GlobalPoint[] => {
const center = pointFrom<GlobalPoint>( const center = elementCenterPoint(element);
element.x + element.width / 2,
element.y + element.height / 2,
);
// 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.
@ -259,25 +258,25 @@ const intersectDiamondWithLineSegment = (
const [sides, curves] = deconstructDiamondElement(element, offset); const [sides, curves] = deconstructDiamondElement(element, offset);
return ( return (
[ sides
...sides .map((s) =>
.map((s) => lineSegmentIntersectionPoints(
lineSegmentIntersectionPoints( lineSegment<GlobalPoint>(rotatedA, rotatedB),
lineSegment<GlobalPoint>(rotatedA, rotatedB), s,
s, ),
), )
) .filter((p): p is GlobalPoint => p != null)
.filter((p): p is GlobalPoint => p != null) // Rotate back intersection points
// Rotate back intersection points .map((p) => pointRotateRads<GlobalPoint>(p!, center, element.angle))
.map((p) => pointRotateRads<GlobalPoint>(p!, center, element.angle)), .concat(
...curves curves
.flatMap((p) => .flatMap((p) =>
curveIntersectLineSegment(p, lineSegment(rotatedA, rotatedB)), curveIntersectLineSegment(p, lineSegment(rotatedA, rotatedB)),
) )
.filter((p) => p != null) .filter((p) => p != null)
// Rotate back intersection points // Rotate back intersection points
.map((p) => pointRotateRads(p, center, element.angle)), .map((p) => pointRotateRads(p, center, element.angle)),
] )
// Remove duplicates // Remove duplicates
.filter( .filter(
(p, idx, points) => points.findIndex((d) => pointsEqual(p, d)) === idx, (p, idx, points) => points.findIndex((d) => pointsEqual(p, d)) === idx,
@ -297,10 +296,7 @@ const intersectEllipseWithLineSegment = (
l: LineSegment<GlobalPoint>, l: LineSegment<GlobalPoint>,
offset: number = 0, offset: number = 0,
): GlobalPoint[] => { ): GlobalPoint[] => {
const center = pointFrom<GlobalPoint>( const center = elementCenterPoint(element);
element.x + element.width / 2,
element.y + element.height / 2,
);
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);

View File

@ -1,4 +1,4 @@
import type { ElementOrToolType } from "../types"; import type { ElementOrToolType } from "@excalidraw/excalidraw/types";
export const hasBackground = (type: ElementOrToolType) => export const hasBackground = (type: ElementOrToolType) =>
type === "rectangle" || type === "rectangle" ||

View File

@ -1,4 +1,3 @@
import { type Point } from "points-on-curve";
import { import {
type Radians, type Radians,
pointFrom, pointFrom,
@ -13,6 +12,15 @@ import {
clamp, clamp,
isCloseTo, isCloseTo,
} from "@excalidraw/math"; } from "@excalidraw/math";
import { type Point } from "points-on-curve";
import { elementCenterPoint } from "@excalidraw/common";
import {
getElementAbsoluteCoords,
getResizedElementAbsoluteCoords,
} from "./bounds";
import type { TransformHandleType } from "./transformHandles"; import type { TransformHandleType } from "./transformHandles";
import type { import type {
ElementsMap, ElementsMap,
@ -21,10 +29,6 @@ import type {
ImageCrop, ImageCrop,
NonDeleted, NonDeleted,
} from "./types"; } from "./types";
import {
getElementAbsoluteCoords,
getResizedElementAbsoluteCoords,
} from "./bounds";
export const MINIMAL_CROP_SIZE = 10; export const MINIMAL_CROP_SIZE = 10;
@ -59,7 +63,7 @@ export const cropElement = (
const rotatedPointer = pointRotateRads( const rotatedPointer = pointRotateRads(
pointFrom(pointerX, pointerY), pointFrom(pointerX, pointerY),
pointFrom(element.x + element.width / 2, element.y + element.height / 2), elementCenterPoint(element),
-element.angle as Radians, -element.angle as Radians,
); );

View File

@ -1,21 +1,26 @@
import type { GlobalPoint, Radians } from "@excalidraw/math";
import { import {
curvePointDistance, curvePointDistance,
distanceToLineSegment, distanceToLineSegment,
pointFrom,
pointRotateRads, pointRotateRads,
} from "@excalidraw/math"; } from "@excalidraw/math";
import { ellipse, ellipseDistanceFromPoint } from "@excalidraw/math/ellipse"; import { ellipse, ellipseDistanceFromPoint } from "@excalidraw/math/ellipse";
import { elementCenterPoint } from "@excalidraw/common";
import type { GlobalPoint, Radians } from "@excalidraw/math";
import {
deconstructDiamondElement,
deconstructRectanguloidElement,
} from "./utils";
import type { import type {
ExcalidrawBindableElement, ExcalidrawBindableElement,
ExcalidrawDiamondElement, ExcalidrawDiamondElement,
ExcalidrawEllipseElement, ExcalidrawEllipseElement,
ExcalidrawRectanguloidElement, ExcalidrawRectanguloidElement,
} from "./types"; } from "./types";
import {
deconstructDiamondElement,
deconstructRectanguloidElement,
} from "./utils";
export const distanceToBindableElement = ( export const distanceToBindableElement = (
element: ExcalidrawBindableElement, element: ExcalidrawBindableElement,
@ -49,10 +54,7 @@ const distanceToRectanguloidElement = (
element: ExcalidrawRectanguloidElement, element: ExcalidrawRectanguloidElement,
p: GlobalPoint, p: GlobalPoint,
) => { ) => {
const center = pointFrom<GlobalPoint>( const center = elementCenterPoint(element);
element.x + element.width / 2,
element.y + element.height / 2,
);
// 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,10 +82,7 @@ const distanceToDiamondElement = (
element: ExcalidrawDiamondElement, element: ExcalidrawDiamondElement,
p: GlobalPoint, p: GlobalPoint,
): number => { ): number => {
const center = pointFrom<GlobalPoint>( const center = elementCenterPoint(element);
element.x + element.width / 2,
element.y + element.height / 2,
);
// 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.
@ -111,10 +110,7 @@ const distanceToEllipseElement = (
element: ExcalidrawEllipseElement, element: ExcalidrawEllipseElement,
p: GlobalPoint, p: GlobalPoint,
): number => { ): number => {
const center = pointFrom( const center = elementCenterPoint(element);
element.x + element.width / 2,
element.y + element.height / 2,
);
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),

View File

@ -1,7 +1,9 @@
import { newElementWith } from "./element/mutateElement"; import { getCommonBoundingBox } from "./bounds";
import { newElementWith } from "./mutateElement";
import { getMaximumGroups } from "./groups"; import { getMaximumGroups } from "./groups";
import { getCommonBoundingBox } from "./element/bounds";
import type { ElementsMap, ExcalidrawElement } from "./element/types"; import type { ElementsMap, ExcalidrawElement } from "./types";
export interface Distribution { export interface Distribution {
space: "between"; space: "between";

View File

@ -1,17 +1,23 @@
import { updateBoundElements } from "./binding"; import {
import type { Bounds } from "./bounds"; TEXT_AUTOWRAP_THRESHOLD,
import { getCommonBounds } from "./bounds"; getGridPoint,
import { mutateElement } from "./mutateElement"; getFontString,
import { getPerfectElementSize } from "./sizeHelpers"; } from "@excalidraw/common";
import type { NonDeletedExcalidrawElement } from "./types";
import type { import type {
AppState, AppState,
NormalizedZoomValue, NormalizedZoomValue,
NullableGridSize, NullableGridSize,
PointerDownState, PointerDownState,
} from "../types"; } from "@excalidraw/excalidraw/types";
import type { NonDeletedExcalidrawElement } from "@excalidraw/element/types";
import { updateBoundElements } from "./binding";
import { getCommonBounds } from "./bounds";
import { getPerfectElementSize } from "./sizeHelpers";
import { getBoundTextElement } from "./textElement"; import { getBoundTextElement } from "./textElement";
import type Scene from "../scene/Scene"; import { getMinTextElementWidth } from "./textMeasurements";
import { import {
isArrowElement, isArrowElement,
isElbowArrow, isElbowArrow,
@ -19,10 +25,11 @@ import {
isImageElement, isImageElement,
isTextElement, isTextElement,
} from "./typeChecks"; } from "./typeChecks";
import { getFontString } from "../utils";
import { TEXT_AUTOWRAP_THRESHOLD } from "../constants"; import type { Scene } from "./Scene";
import { getGridPoint } from "../snapping";
import { getMinTextElementWidth } from "./textMeasurements"; import type { Bounds } from "./bounds";
import type { ExcalidrawElement } from "./types";
export const dragSelectedElements = ( export const dragSelectedElements = (
pointerDownState: PointerDownState, pointerDownState: PointerDownState,
@ -76,20 +83,27 @@ export const dragSelectedElements = (
} }
} }
const commonBounds = getCommonBounds( const origElements: ExcalidrawElement[] = [];
Array.from(elementsToUpdate).map(
(el) => pointerDownState.originalElements.get(el.id) ?? el, for (const element of elementsToUpdate) {
), const origElement = pointerDownState.originalElements.get(element.id);
); // if original element is not set (e.g. when you duplicate during a drag
// operation), exit to avoid undefined behavior
if (!origElement) {
return;
}
origElements.push(origElement);
}
const adjustedOffset = calculateOffset( const adjustedOffset = calculateOffset(
commonBounds, getCommonBounds(origElements),
offset, offset,
snapOffset, snapOffset,
gridSize, gridSize,
); );
elementsToUpdate.forEach((element) => { elementsToUpdate.forEach((element) => {
updateElementCoords(pointerDownState, element, adjustedOffset); updateElementCoords(pointerDownState, element, scene, adjustedOffset);
if (!isArrowElement(element)) { if (!isArrowElement(element)) {
// skip arrow labels since we calculate its position during render // skip arrow labels since we calculate its position during render
const textElement = getBoundTextElement( const textElement = getBoundTextElement(
@ -97,9 +111,14 @@ export const dragSelectedElements = (
scene.getNonDeletedElementsMap(), scene.getNonDeletedElementsMap(),
); );
if (textElement) { if (textElement) {
updateElementCoords(pointerDownState, textElement, adjustedOffset); updateElementCoords(
pointerDownState,
textElement,
scene,
adjustedOffset,
);
} }
updateBoundElements(element, scene.getElementsMapIncludingDeleted(), { updateBoundElements(element, scene, {
simultaneouslyUpdated: Array.from(elementsToUpdate), simultaneouslyUpdated: Array.from(elementsToUpdate),
}); });
} }
@ -140,6 +159,7 @@ const calculateOffset = (
const updateElementCoords = ( const updateElementCoords = (
pointerDownState: PointerDownState, pointerDownState: PointerDownState,
element: NonDeletedExcalidrawElement, element: NonDeletedExcalidrawElement,
scene: Scene,
dragOffset: { x: number; y: number }, dragOffset: { x: number; y: number },
) => { ) => {
const originalElement = const originalElement =
@ -148,7 +168,7 @@ const updateElementCoords = (
const nextX = originalElement.x + dragOffset.x; const nextX = originalElement.x + dragOffset.x;
const nextY = originalElement.y + dragOffset.y; const nextY = originalElement.y + dragOffset.y;
mutateElement(element, { scene.mutateElement(element, {
x: nextX, x: nextX,
y: nextY, y: nextY,
}); });
@ -175,6 +195,7 @@ export const dragNewElement = ({
shouldMaintainAspectRatio, shouldMaintainAspectRatio,
shouldResizeFromCenter, shouldResizeFromCenter,
zoom, zoom,
scene,
widthAspectRatio = null, widthAspectRatio = null,
originOffset = null, originOffset = null,
informMutation = true, informMutation = true,
@ -190,6 +211,7 @@ export const dragNewElement = ({
shouldMaintainAspectRatio: boolean; shouldMaintainAspectRatio: boolean;
shouldResizeFromCenter: boolean; shouldResizeFromCenter: boolean;
zoom: NormalizedZoomValue; zoom: NormalizedZoomValue;
scene: Scene;
/** whether to keep given aspect ratio when `isResizeWithSidesSameLength` is /** whether to keep given aspect ratio when `isResizeWithSidesSameLength` is
true */ true */
widthAspectRatio?: number | null; widthAspectRatio?: number | null;
@ -270,7 +292,7 @@ export const dragNewElement = ({
}; };
} }
mutateElement( scene.mutateElement(
newElement, newElement,
{ {
x: newX + (originOffset?.x ?? 0), x: newX + (originOffset?.x ?? 0),
@ -280,7 +302,7 @@ export const dragNewElement = ({
...textAutoResize, ...textAutoResize,
...imageInitialDimension, ...imageInitialDimension,
}, },
informMutation, { informMutation, isDragging: false },
); );
} }
}; };

View File

@ -0,0 +1,487 @@
import {
ORIG_ID,
randomId,
randomInteger,
arrayToMap,
castArray,
findLastIndex,
getUpdatedTimestamp,
isTestEnv,
} from "@excalidraw/common";
import type { Mutable } from "@excalidraw/common/utility-types";
import type { AppState } from "@excalidraw/excalidraw/types";
import {
getElementsInGroup,
getNewGroupIdsForDuplication,
getSelectedGroupForElement,
} from "./groups";
import {
bindElementsToFramesAfterDuplication,
getFrameChildren,
} from "./frame";
import { normalizeElementOrder } from "./sortElements";
import { bumpVersion } from "./mutateElement";
import {
hasBoundTextElement,
isBoundToContainer,
isFrameLikeElement,
} from "./typeChecks";
import { getBoundTextElement, getContainerElement } from "./textElement";
import { fixDuplicatedBindingsAfterDuplication } from "./binding";
import type {
ElementsMap,
ExcalidrawElement,
GroupId,
NonDeletedSceneElementsMap,
} from "./types";
/**
* Duplicate an element, often used in the alt-drag operation.
* Note that this method has gotten a bit complicated since the
* introduction of gruoping/ungrouping elements.
* @param editingGroupId The current group being edited. The new
* element will inherit this group and its
* parents.
* @param groupIdMapForOperation A Map that maps old group IDs to
* duplicated ones. If you are duplicating
* multiple elements at once, share this map
* amongst all of them
* @param element Element to duplicate
*/
export const duplicateElement = <TElement extends ExcalidrawElement>(
editingGroupId: AppState["editingGroupId"],
groupIdMapForOperation: Map<GroupId, GroupId>,
element: TElement,
randomizeSeed?: boolean,
): Readonly<TElement> => {
const copy = deepCopyElement(element);
if (isTestEnv()) {
__test__defineOrigId(copy, element.id);
}
copy.id = randomId();
copy.updated = getUpdatedTimestamp();
if (randomizeSeed) {
copy.seed = randomInteger();
bumpVersion(copy);
}
copy.groupIds = getNewGroupIdsForDuplication(
copy.groupIds,
editingGroupId,
(groupId) => {
if (!groupIdMapForOperation.has(groupId)) {
groupIdMapForOperation.set(groupId, randomId());
}
return groupIdMapForOperation.get(groupId)!;
},
);
return copy;
};
export const duplicateElements = (
opts: {
elements: readonly ExcalidrawElement[];
randomizeSeed?: boolean;
overrides?: (data: {
duplicateElement: ExcalidrawElement;
origElement: ExcalidrawElement;
origIdToDuplicateId: Map<
ExcalidrawElement["id"],
ExcalidrawElement["id"]
>;
}) => Partial<ExcalidrawElement>;
} & (
| {
/**
* Duplicates all elements in array.
*
* Use this when programmaticaly duplicating elements, without direct
* user interaction.
*/
type: "everything";
}
| {
/**
* Duplicates specified elements and inserts them back into the array
* in specified order.
*
* Use this when duplicating Scene elements, during user interaction
* such as alt-drag or on duplicate action.
*/
type: "in-place";
idsOfElementsToDuplicate: Map<
ExcalidrawElement["id"],
ExcalidrawElement
>;
appState: {
editingGroupId: AppState["editingGroupId"];
selectedGroupIds: AppState["selectedGroupIds"];
};
}
),
) => {
let { elements } = opts;
const appState =
"appState" in opts
? opts.appState
: ({
editingGroupId: null,
selectedGroupIds: {},
} as const);
// Ids of elements that have already been processed so we don't push them
// into the array twice if we end up backtracking when retrieving
// discontiguous group of elements (can happen due to a bug, or in edge
// cases such as a group containing deleted elements which were not selected).
//
// This is not enough to prevent duplicates, so we do a second loop afterwards
// to remove them.
//
// For convenience we mark even the newly created ones even though we don't
// loop over them.
const processedIds = new Map<ExcalidrawElement["id"], true>();
const groupIdMap = new Map();
const duplicatedElements: ExcalidrawElement[] = [];
const origElements: ExcalidrawElement[] = [];
const origIdToDuplicateId = new Map<
ExcalidrawElement["id"],
ExcalidrawElement["id"]
>();
const duplicateIdToOrigElement = new Map<
ExcalidrawElement["id"],
ExcalidrawElement
>();
const duplicateElementsMap = new Map<string, ExcalidrawElement>();
const elementsMap = arrayToMap(elements) as ElementsMap;
const _idsOfElementsToDuplicate =
opts.type === "in-place"
? opts.idsOfElementsToDuplicate
: new Map(elements.map((el) => [el.id, el]));
// For sanity
if (opts.type === "in-place") {
for (const groupId of Object.keys(opts.appState.selectedGroupIds)) {
elements
.filter((el) => el.groupIds?.includes(groupId))
.forEach((el) => _idsOfElementsToDuplicate.set(el.id, el));
}
}
elements = normalizeElementOrder(elements);
const elementsWithDuplicates: ExcalidrawElement[] = elements.slice();
// helper functions
// -------------------------------------------------------------------------
// Used for the heavy lifing of copying a single element, a group of elements
// an element with bound text etc.
const copyElements = <T extends ExcalidrawElement | ExcalidrawElement[]>(
element: T,
): T extends ExcalidrawElement[]
? ExcalidrawElement[]
: ExcalidrawElement | null => {
const elements = castArray(element);
const _newElements = elements.reduce(
(acc: ExcalidrawElement[], element) => {
if (processedIds.has(element.id)) {
return acc;
}
processedIds.set(element.id, true);
const newElement = duplicateElement(
appState.editingGroupId,
groupIdMap,
element,
opts.randomizeSeed,
);
processedIds.set(newElement.id, true);
duplicateElementsMap.set(newElement.id, newElement);
origIdToDuplicateId.set(element.id, newElement.id);
duplicateIdToOrigElement.set(newElement.id, element);
origElements.push(element);
duplicatedElements.push(newElement);
acc.push(newElement);
return acc;
},
[],
);
return (
Array.isArray(element) ? _newElements : _newElements[0] || null
) as T extends ExcalidrawElement[]
? ExcalidrawElement[]
: ExcalidrawElement | null;
};
// Helper to position cloned elements in the Z-order the product needs it
const insertBeforeOrAfterIndex = (
index: number,
elements: ExcalidrawElement | null | ExcalidrawElement[],
) => {
if (!elements) {
return;
}
if (index > elementsWithDuplicates.length - 1) {
elementsWithDuplicates.push(...castArray(elements));
return;
}
elementsWithDuplicates.splice(index + 1, 0, ...castArray(elements));
};
const frameIdsToDuplicate = new Set(
elements
.filter(
(el) => _idsOfElementsToDuplicate.has(el.id) && isFrameLikeElement(el),
)
.map((el) => el.id),
);
for (const element of elements) {
if (processedIds.has(element.id)) {
continue;
}
if (!_idsOfElementsToDuplicate.has(element.id)) {
continue;
}
// groups
// -------------------------------------------------------------------------
const groupId = getSelectedGroupForElement(appState, element);
if (groupId) {
const groupElements = getElementsInGroup(elements, groupId).flatMap(
(element) =>
isFrameLikeElement(element)
? [...getFrameChildren(elements, element.id), element]
: [element],
);
const targetIndex = findLastIndex(elementsWithDuplicates, (el) => {
return el.groupIds?.includes(groupId);
});
insertBeforeOrAfterIndex(targetIndex, copyElements(groupElements));
continue;
}
// frame duplication
// -------------------------------------------------------------------------
if (element.frameId && frameIdsToDuplicate.has(element.frameId)) {
continue;
}
if (isFrameLikeElement(element)) {
const frameId = element.id;
const frameChildren = getFrameChildren(elements, frameId);
const targetIndex = findLastIndex(elementsWithDuplicates, (el) => {
return el.frameId === frameId || el.id === frameId;
});
insertBeforeOrAfterIndex(
targetIndex,
copyElements([...frameChildren, element]),
);
continue;
}
// text container
// -------------------------------------------------------------------------
if (hasBoundTextElement(element)) {
const boundTextElement = getBoundTextElement(element, elementsMap);
const targetIndex = findLastIndex(elementsWithDuplicates, (el) => {
return (
el.id === element.id ||
("containerId" in el && el.containerId === element.id)
);
});
if (boundTextElement) {
insertBeforeOrAfterIndex(
targetIndex,
copyElements([element, boundTextElement]),
);
} else {
insertBeforeOrAfterIndex(targetIndex, copyElements(element));
}
continue;
}
if (isBoundToContainer(element)) {
const container = getContainerElement(element, elementsMap);
const targetIndex = findLastIndex(elementsWithDuplicates, (el) => {
return el.id === element.id || el.id === container?.id;
});
if (container) {
insertBeforeOrAfterIndex(
targetIndex,
copyElements([container, element]),
);
} else {
insertBeforeOrAfterIndex(targetIndex, copyElements(element));
}
continue;
}
// default duplication (regular elements)
// -------------------------------------------------------------------------
insertBeforeOrAfterIndex(
findLastIndex(elementsWithDuplicates, (el) => el.id === element.id),
copyElements(element),
);
}
// ---------------------------------------------------------------------------
fixDuplicatedBindingsAfterDuplication(
duplicatedElements,
origIdToDuplicateId,
duplicateElementsMap as NonDeletedSceneElementsMap,
);
bindElementsToFramesAfterDuplication(
elementsWithDuplicates,
origElements,
origIdToDuplicateId,
);
if (opts.overrides) {
for (const duplicateElement of duplicatedElements) {
const origElement = duplicateIdToOrigElement.get(duplicateElement.id);
if (origElement) {
Object.assign(
duplicateElement,
opts.overrides({
duplicateElement,
origElement,
origIdToDuplicateId,
}),
);
}
}
}
return {
duplicatedElements,
duplicateElementsMap,
elementsWithDuplicates,
origIdToDuplicateId,
};
};
// Simplified deep clone for the purpose of cloning ExcalidrawElement.
//
// Only clones plain objects and arrays. Doesn't clone Date, RegExp, Map, Set,
// Typed arrays and other non-null objects.
//
// Adapted from https://github.com/lukeed/klona
//
// The reason for `deepCopyElement()` wrapper is type safety (only allow
// passing ExcalidrawElement as the top-level argument).
const _deepCopyElement = (val: any, depth: number = 0) => {
// only clone non-primitives
if (val == null || typeof val !== "object") {
return val;
}
const objectType = Object.prototype.toString.call(val);
if (objectType === "[object Object]") {
const tmp =
typeof val.constructor === "function"
? Object.create(Object.getPrototypeOf(val))
: {};
for (const key in val) {
if (val.hasOwnProperty(key)) {
// don't copy non-serializable objects like these caches. They'll be
// populated when the element is rendered.
if (depth === 0 && (key === "shape" || key === "canvas")) {
continue;
}
tmp[key] = _deepCopyElement(val[key], depth + 1);
}
}
return tmp;
}
if (Array.isArray(val)) {
let k = val.length;
const arr = new Array(k);
while (k--) {
arr[k] = _deepCopyElement(val[k], depth + 1);
}
return arr;
}
// we're not cloning non-array & non-plain-object objects because we
// don't support them on excalidraw elements yet. If we do, we need to make
// sure we start cloning them, so let's warn about it.
if (import.meta.env.DEV) {
if (
objectType !== "[object Object]" &&
objectType !== "[object Array]" &&
objectType.startsWith("[object ")
) {
console.warn(
`_deepCloneElement: unexpected object type ${objectType}. This value will not be cloned!`,
);
}
}
return val;
};
/**
* Clones ExcalidrawElement data structure. Does not regenerate id, nonce, or
* any value. The purpose is to to break object references for immutability
* reasons, whenever we want to keep the original element, but ensure it's not
* mutated.
*
* Only clones plain objects and arrays. Doesn't clone Date, RegExp, Map, Set,
* Typed arrays and other non-null objects.
*/
export const deepCopyElement = <T extends ExcalidrawElement>(
val: T,
): Mutable<T> => {
return _deepCopyElement(val);
};
const __test__defineOrigId = (clonedObj: object, origId: string) => {
Object.defineProperty(clonedObj, ORIG_ID, {
value: origId,
writable: false,
enumerable: false,
});
};

View File

@ -12,11 +12,18 @@ import {
type GlobalPoint, type GlobalPoint,
type LocalPoint, type LocalPoint,
} from "@excalidraw/math"; } from "@excalidraw/math";
import BinaryHeap from "../binaryheap";
import { getSizeFromPoints } from "../points"; import {
import { aabbForElement, pointInsideBounds } from "../shapes"; BinaryHeap,
import { invariant, isAnyTrue, tupleToCoors } from "../utils"; invariant,
import type { AppState } from "../types"; isAnyTrue,
tupleToCoors,
getSizeFromPoints,
isDevEnv,
} from "@excalidraw/common";
import type { AppState } from "@excalidraw/excalidraw/types";
import { import {
bindPointToSnapToElementOutline, bindPointToSnapToElementOutline,
FIXED_BINDING_DISTANCE, FIXED_BINDING_DISTANCE,
@ -25,8 +32,7 @@ import {
snapToMid, snapToMid,
getHoveredElementForBinding, getHoveredElementForBinding,
} from "./binding"; } from "./binding";
import type { Bounds } from "./bounds"; import { distanceToBindableElement } from "./distance";
import type { Heading } from "./heading";
import { import {
compareHeading, compareHeading,
flipHeading, flipHeading,
@ -44,8 +50,12 @@ import { isBindableElement } from "./typeChecks";
import { import {
type ExcalidrawElbowArrowElement, type ExcalidrawElbowArrowElement,
type NonDeletedSceneElementsMap, type NonDeletedSceneElementsMap,
type SceneElementsMap,
} from "./types"; } from "./types";
import { aabbForElement, pointInsideBounds } from "./shapes";
import type { Bounds } from "./bounds";
import type { Heading } from "./heading";
import type { import type {
Arrowhead, Arrowhead,
ElementsMap, ElementsMap,
@ -54,7 +64,6 @@ import type {
FixedSegment, FixedSegment,
NonDeletedExcalidrawElement, NonDeletedExcalidrawElement,
} from "./types"; } from "./types";
import { distanceToBindableElement } from "./distance";
type GridAddress = [number, number] & { _brand: "gridaddress" }; type GridAddress = [number, number] & { _brand: "gridaddress" };
@ -235,16 +244,6 @@ const handleSegmentRenormalization = (
nextPoints.map((p) => nextPoints.map((p) =>
pointFrom<LocalPoint>(p[0] - arrow.x, p[1] - arrow.y), pointFrom<LocalPoint>(p[0] - arrow.x, p[1] - arrow.y),
), ),
arrow.startBinding &&
getBindableElementForId(
arrow.startBinding.elementId,
elementsMap,
),
arrow.endBinding &&
getBindableElementForId(
arrow.endBinding.elementId,
elementsMap,
),
), ),
) ?? [], ) ?? [],
), ),
@ -255,7 +254,7 @@ const handleSegmentRenormalization = (
); );
} }
import.meta.env.DEV && isDevEnv() &&
invariant( invariant(
validateElbowPoints(nextPoints), validateElbowPoints(nextPoints),
"Invalid elbow points with fixed segments", "Invalid elbow points with fixed segments",
@ -338,9 +337,6 @@ const handleSegmentRelease = (
y, y,
), ),
], ],
startBinding &&
getBindableElementForId(startBinding.elementId, elementsMap),
endBinding && getBindableElementForId(endBinding.elementId, elementsMap),
{ isDragging: false }, { isDragging: false },
); );
@ -890,7 +886,7 @@ export const updateElbowArrowPoints = (
elementsMap: NonDeletedSceneElementsMap, elementsMap: NonDeletedSceneElementsMap,
updates: { updates: {
points?: readonly LocalPoint[]; points?: readonly LocalPoint[];
fixedSegments?: FixedSegment[] | null; fixedSegments?: readonly FixedSegment[] | null;
startBinding?: FixedPointBinding | null; startBinding?: FixedPointBinding | null;
endBinding?: FixedPointBinding | null; endBinding?: FixedPointBinding | null;
}, },
@ -980,6 +976,8 @@ export const updateElbowArrowPoints = (
); );
} }
const fixedSegments = updates.fixedSegments ?? arrow.fixedSegments ?? [];
const updatedPoints: readonly LocalPoint[] = updates.points const updatedPoints: readonly LocalPoint[] = updates.points
? updates.points && updates.points.length === 2 ? updates.points && updates.points.length === 2
? arrow.points.map((p, idx) => ? arrow.points.map((p, idx) =>
@ -992,26 +990,36 @@ export const updateElbowArrowPoints = (
: updates.points.slice() : updates.points.slice()
: arrow.points.slice(); : arrow.points.slice();
// 0. During all element replacement in the scene, we just need to renormalize // During all element replacement in the scene, we just need to renormalize
// the arrow // the arrow
// TODO (dwelle,mtolmacs): Remove this once Scene.getScene() is removed // TODO (dwelle,mtolmacs): Remove this once Scene.getScene() is removed
const {
startBinding: updatedStartBinding,
endBinding: updatedEndBinding,
...restOfTheUpdates
} = updates;
const startBinding = const startBinding =
typeof updates.startBinding !== "undefined" typeof updatedStartBinding !== "undefined"
? updates.startBinding ? updatedStartBinding
: arrow.startBinding; : arrow.startBinding;
const endBinding = const endBinding =
typeof updates.endBinding !== "undefined" typeof updatedEndBinding !== "undefined"
? updates.endBinding ? updatedEndBinding
: arrow.endBinding; : arrow.endBinding;
const startElement = const startElement =
startBinding && startBinding &&
getBindableElementForId(startBinding.elementId, elementsMap); getBindableElementForId(startBinding.elementId, elementsMap);
const endElement = const endElement =
endBinding && getBindableElementForId(endBinding.elementId, elementsMap); endBinding && getBindableElementForId(endBinding.elementId, elementsMap);
const areUpdatedPointsValid = validateElbowPoints(updatedPoints);
if ( if (
(elementsMap.size === 0 && validateElbowPoints(updatedPoints)) || (startBinding && !startElement && areUpdatedPointsValid) ||
startElement?.id !== startBinding?.elementId || (endBinding && !endElement && areUpdatedPointsValid) ||
endElement?.id !== endBinding?.elementId (elementsMap.size === 0 && areUpdatedPointsValid) ||
(Object.keys(restOfTheUpdates).length === 0 &&
(startElement?.id !== startBinding?.elementId ||
endElement?.id !== endBinding?.elementId))
) { ) {
return normalizeArrowElementUpdate( return normalizeArrowElementUpdate(
updatedPoints.map((p) => updatedPoints.map((p) =>
@ -1043,12 +1051,22 @@ export const updateElbowArrowPoints = (
}, },
elementsMap, elementsMap,
updatedPoints, updatedPoints,
startElement,
endElement,
options, options,
); );
const fixedSegments = updates.fixedSegments ?? arrow.fixedSegments ?? []; // 0. During all element replacement in the scene, we just need to renormalize
// the arrow
// TODO (dwelle,mtolmacs): Remove this once Scene.getScene() is removed
if (elementsMap.size === 0 && areUpdatedPointsValid) {
return normalizeArrowElementUpdate(
updatedPoints.map((p) =>
pointFrom<GlobalPoint>(arrow.x + p[0], arrow.y + p[1]),
),
arrow.fixedSegments,
arrow.startIsSpecial,
arrow.endIsSpecial,
);
}
//// ////
// 1. Renormalize the arrow // 1. Renormalize the arrow
@ -1071,7 +1089,8 @@ export const updateElbowArrowPoints = (
p, p,
arrow.points[i] ?? pointFrom<LocalPoint>(Infinity, Infinity), arrow.points[i] ?? pointFrom<LocalPoint>(Infinity, Infinity),
), ),
) ) &&
areUpdatedPointsValid
) { ) {
return {}; return {};
} }
@ -1182,8 +1201,6 @@ const getElbowArrowData = (
}, },
elementsMap: NonDeletedSceneElementsMap, elementsMap: NonDeletedSceneElementsMap,
nextPoints: readonly LocalPoint[], nextPoints: readonly LocalPoint[],
startElement: ExcalidrawBindableElement | null,
endElement: ExcalidrawBindableElement | null,
options?: { options?: {
isDragging?: boolean; isDragging?: boolean;
zoom?: AppState["zoom"]; zoom?: AppState["zoom"];
@ -1198,8 +1215,8 @@ const getElbowArrowData = (
GlobalPoint GlobalPoint
>(nextPoints[nextPoints.length - 1], vector(arrow.x, arrow.y)); >(nextPoints[nextPoints.length - 1], vector(arrow.x, arrow.y));
let hoveredStartElement = startElement; let hoveredStartElement = null;
let hoveredEndElement = endElement; let hoveredEndElement = null;
if (options?.isDragging) { if (options?.isDragging) {
const elements = Array.from(elementsMap.values()); const elements = Array.from(elementsMap.values());
hoveredStartElement = hoveredStartElement =
@ -1208,53 +1225,59 @@ const getElbowArrowData = (
elementsMap, elementsMap,
elements, elements,
options?.zoom, options?.zoom,
) || startElement; ) || null;
hoveredEndElement = hoveredEndElement =
getHoveredElement( getHoveredElement(
origEndGlobalPoint, origEndGlobalPoint,
elementsMap, elementsMap,
elements, elements,
options?.zoom, options?.zoom,
) || endElement; ) || null;
} else {
hoveredStartElement = arrow.startBinding
? getBindableElementForId(arrow.startBinding.elementId, elementsMap) ||
null
: null;
hoveredEndElement = arrow.endBinding
? getBindableElementForId(arrow.endBinding.elementId, elementsMap) || null
: null;
} }
const startGlobalPoint = getGlobalPoint( const startGlobalPoint = getGlobalPoint(
{ {
...arrow, ...arrow,
type: "arrow",
elbowed: true, elbowed: true,
points: nextPoints, points: nextPoints,
} as ExcalidrawElbowArrowElement, } as ExcalidrawElbowArrowElement,
"start", "start",
arrow.startBinding?.fixedPoint, arrow.startBinding?.fixedPoint,
origStartGlobalPoint, origStartGlobalPoint,
startElement,
hoveredStartElement, hoveredStartElement,
options?.isDragging, options?.isDragging,
); );
const endGlobalPoint = getGlobalPoint( const endGlobalPoint = getGlobalPoint(
{ {
...arrow, ...arrow,
type: "arrow",
elbowed: true, elbowed: true,
points: nextPoints, points: nextPoints,
} as ExcalidrawElbowArrowElement, } as ExcalidrawElbowArrowElement,
"end", "end",
arrow.endBinding?.fixedPoint, arrow.endBinding?.fixedPoint,
origEndGlobalPoint, origEndGlobalPoint,
endElement,
hoveredEndElement, hoveredEndElement,
options?.isDragging, options?.isDragging,
); );
const startHeading = getBindPointHeading( const startHeading = getBindPointHeading(
startGlobalPoint, startGlobalPoint,
endGlobalPoint, endGlobalPoint,
elementsMap,
hoveredStartElement, hoveredStartElement,
origStartGlobalPoint, origStartGlobalPoint,
); );
const endHeading = getBindPointHeading( const endHeading = getBindPointHeading(
endGlobalPoint, endGlobalPoint,
startGlobalPoint, startGlobalPoint,
elementsMap,
hoveredEndElement, hoveredEndElement,
origEndGlobalPoint, origEndGlobalPoint,
); );
@ -2186,36 +2209,35 @@ const getGlobalPoint = (
startOrEnd: "start" | "end", startOrEnd: "start" | "end",
fixedPointRatio: [number, number] | undefined | null, fixedPointRatio: [number, number] | undefined | null,
initialPoint: GlobalPoint, initialPoint: GlobalPoint,
boundElement?: ExcalidrawBindableElement | null, element?: ExcalidrawBindableElement | null,
hoveredElement?: ExcalidrawBindableElement | null,
isDragging?: boolean, isDragging?: boolean,
): GlobalPoint => { ): GlobalPoint => {
if (isDragging) { if (isDragging) {
if (hoveredElement) { if (element) {
const snapPoint = bindPointToSnapToElementOutline( const snapPoint = bindPointToSnapToElementOutline(
arrow, arrow,
hoveredElement, element,
startOrEnd, startOrEnd,
); );
return snapToMid(hoveredElement, snapPoint); return snapToMid(element, snapPoint);
} }
return initialPoint; return initialPoint;
} }
if (boundElement) { if (element) {
const fixedGlobalPoint = getGlobalFixedPointForBindableElement( const fixedGlobalPoint = getGlobalFixedPointForBindableElement(
fixedPointRatio || [0, 0], fixedPointRatio || [0, 0],
boundElement, element,
); );
// 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(boundElement, fixedGlobalPoint) - distanceToBindableElement(element, fixedGlobalPoint) -
FIXED_BINDING_DISTANCE, FIXED_BINDING_DISTANCE,
) > 0.01 ) > 0.01
? bindPointToSnapToElementOutline(arrow, boundElement, startOrEnd) ? bindPointToSnapToElementOutline(arrow, element, startOrEnd)
: fixedGlobalPoint; : fixedGlobalPoint;
} }
@ -2225,7 +2247,6 @@ const getGlobalPoint = (
const getBindPointHeading = ( const getBindPointHeading = (
p: GlobalPoint, p: GlobalPoint,
otherPoint: GlobalPoint, otherPoint: GlobalPoint,
elementsMap: NonDeletedSceneElementsMap | SceneElementsMap,
hoveredElement: ExcalidrawBindableElement | null | undefined, hoveredElement: ExcalidrawBindableElement | null | undefined,
origPoint: GlobalPoint, origPoint: GlobalPoint,
): Heading => ): Heading =>
@ -2243,7 +2264,6 @@ const getBindPointHeading = (
number, number,
], ],
), ),
elementsMap,
origPoint, origPoint,
); );

View File

@ -2,10 +2,12 @@
* Create and link between shapes. * Create and link between shapes.
*/ */
import { ELEMENT_LINK_KEY } from "../constants"; import { ELEMENT_LINK_KEY, normalizeLink } from "@excalidraw/common";
import { normalizeLink } from "../data/url";
import { elementsAreInSameGroup } from "../groups"; import type { AppProps, AppState } from "@excalidraw/excalidraw/types";
import type { AppProps, AppState } from "../types";
import { elementsAreInSameGroup } from "./groups";
import type { ExcalidrawElement } from "./types"; import type { ExcalidrawElement } from "./types";
export const defaultGetElementLinkFromSelection: Exclude< export const defaultGetElementLinkFromSelection: Exclude<

View File

@ -1,18 +1,22 @@
import { register } from "../actions/register"; import {
import { FONT_FAMILY, VERTICAL_ALIGN } from "../constants"; FONT_FAMILY,
import type { ExcalidrawProps } from "../types"; VERTICAL_ALIGN,
import { escapeDoubleQuotes, getFontString, updateActiveTool } from "../utils"; escapeDoubleQuotes,
import { setCursorForShape } from "../cursor"; getFontString,
} from "@excalidraw/common";
import type { ExcalidrawProps } from "@excalidraw/excalidraw/types";
import type { MarkRequired } from "@excalidraw/common/utility-types";
import { newTextElement } from "./newElement"; import { newTextElement } from "./newElement";
import { wrapText } from "./textWrapping"; import { wrapText } from "./textWrapping";
import { isIframeElement } from "./typeChecks"; import { isIframeElement } from "./typeChecks";
import type { import type {
ExcalidrawElement, ExcalidrawElement,
ExcalidrawIframeLikeElement, ExcalidrawIframeLikeElement,
IframeData, IframeData,
} from "./types"; } from "./types";
import type { MarkRequired } from "../utility-types";
import { CaptureUpdateAction } from "../store";
type IframeDataWithSandbox = MarkRequired<IframeData, "sandbox">; type IframeDataWithSandbox = MarkRequired<IframeData, "sandbox">;
@ -29,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+)/;
@ -65,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([
@ -78,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) => {
@ -202,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.
@ -317,34 +329,6 @@ export const createPlaceholderEmbeddableLabel = (
}); });
}; };
export const actionSetEmbeddableAsActiveTool = register({
name: "setEmbeddableAsActiveTool",
trackEvent: { category: "toolbar" },
target: "Tool",
label: "toolBar.embeddable",
perform: (elements, appState, _, app) => {
const nextActiveTool = updateActiveTool(appState, {
type: "embeddable",
});
setCursorForShape(app.canvas, {
...appState,
activeTool: nextActiveTool,
});
return {
elements,
appState: {
...appState,
activeTool: updateActiveTool(appState, {
type: "embeddable",
}),
},
captureUpdate: CaptureUpdateAction.EVENTUALLY,
};
},
});
const matchHostname = ( const matchHostname = (
url: string, url: string,
/** using a Set assumes it already contains normalized bare domains */ /** using a Set assumes it already contains normalized bare domains */

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