Compare commits

..

203 Commits

Author SHA1 Message Date
Mark Tolmacs
9551f2f8fb Merge branch 'master' into mtolmacs/feat/fixed-point-simple-arrow-binding 2025-10-01 10:46:38 +02:00
Emil
835eb8d2fd fix: display error message when local storage quota is exceeded (#9961)
* fix: display error message when local storage quota is exceeded

* add danger alert instead of toast

* tweak text

---------

Co-authored-by: dwelle <5153846+dwelle@users.noreply.github.com>
2025-09-30 23:54:43 +02:00
zsviczian
fde796a7a0 feat: Make naming of library items discoverable (#10041)
* updated library relevant strings

* fix: detect name changes

* clarify hashing function

---------

Co-authored-by: dwelle <5153846+dwelle@users.noreply.github.com>
2025-09-30 18:38:10 +00:00
Yago Dórea
7c41944856 fix: small improvement on binary heap implementation (#9992) 2025-09-30 17:09:20 +02:00
Omar Eltomy
f1b097ad06 fix: support bidirectional shift+click selection in library items (#10034)
* fix: support bidirectional shift+click selection in library items

- Enable bottom-up multi-selection (previously only top-down worked)
- Use Math.min/max to handle selection range in both directions
- Maintains existing behavior for preserving non-contiguous selections
- Fixes issue where shift+clicking items above last selected item failed

* improve deselection behavior

---------

Co-authored-by: dwelle <5153846+dwelle@users.noreply.github.com>
2025-09-29 11:46:42 +00:00
David Luzar
9fcbbe0d27 fix: library search UI fixes/tweaks (#10032)
* fix library icon height in command palette

* add clear button when no results
2025-09-29 12:06:17 +02:00
Mark Tolmacs
6c56972c96 Fix ghost start binding
Signed-off-by: Mark Tolmacs <mark@lazycat.hu>
2025-09-29 11:57:09 +02:00
Mark Tolmacs
1744c7aa56 feat: Center binding on SHIFT key
Signed-off-by: Mark Tolmacs <mark@lazycat.hu>
2025-09-29 10:55:48 +02:00
Mark Tolmacs
0232917443 feat: No angle lock over bindable elements
Signed-off-by: Mark Tolmacs <mark@lazycat.hu>
2025-09-29 10:55:26 +02:00
Mark Tolmacs
2a4f7e94b1 fix: Allow already inside bound arrows to continue inside binding
Signed-off-by: Mark Tolmacs <mark@lazycat.hu>
2025-09-29 09:51:47 +02:00
Archie Sengupta
dbb0a39b22 feat: library search (#9903)
* feat(utils): add support for search input type in isWritableElement

* feat(i18n): add search text

* feat(cmdp+lib): add search functionality for command pallete and lib menu items

* chore: fix formats, and whitespaces

* fix: opt to optimal code changes

* chore: fix for linting

* focus input on mount

* tweak placeholder

* design and UX changes

* tweak item hover/active/seletected states

* unrelated: move publish button above delete/clear to keep it more stable

* esc to clear search input / close sidebar

* refactor command pallete library stuff

* make library commands bigger

---------

Co-authored-by: dwelle <5153846+dwelle@users.noreply.github.com>
2025-09-29 09:20:54 +02:00
Davide Wietlisbach
1ce81e7347 fix: increase rejection delay for opening files with legacy api (#8961)
* Increased input change interval to 1000 ms to fix IOS 18 file opening issue

* increase more

---------

Co-authored-by: dwelle <5153846+dwelle@users.noreply.github.com>
2025-09-29 09:20:54 +02:00
David Luzar
2fbfca5067 feat: better file normalization (#10024)
* feat: better file normalization

* fix lint

* fix png detection

* optimize

* fix type
2025-09-29 09:20:54 +02:00
Archie Sengupta
ec070911b8 feat: library search (#9903)
* feat(utils): add support for search input type in isWritableElement

* feat(i18n): add search text

* feat(cmdp+lib): add search functionality for command pallete and lib menu items

* chore: fix formats, and whitespaces

* fix: opt to optimal code changes

* chore: fix for linting

* focus input on mount

* tweak placeholder

* design and UX changes

* tweak item hover/active/seletected states

* unrelated: move publish button above delete/clear to keep it more stable

* esc to clear search input / close sidebar

* refactor command pallete library stuff

* make library commands bigger

---------

Co-authored-by: dwelle <5153846+dwelle@users.noreply.github.com>
2025-09-28 22:16:28 +02:00
Davide Wietlisbach
dcdeb2be57 fix: increase rejection delay for opening files with legacy api (#8961)
* Increased input change interval to 1000 ms to fix IOS 18 file opening issue

* increase more

---------

Co-authored-by: dwelle <5153846+dwelle@users.noreply.github.com>
2025-09-26 16:30:23 +02:00
David Luzar
a8acc8212d feat: better file normalization (#10024)
* feat: better file normalization

* fix lint

* fix png detection

* optimize

* fix type
2025-09-25 22:26:58 +02:00
Mark Tolmacs
af4e1befef Merge branch 'master' into mtolmacs/feat/fixed-point-simple-arrow-binding 2025-09-24 20:29:27 +02:00
Márk Tolmács
a89a03c66c fix: Arrow eraser precision arrow selection (#10006) 2025-09-24 20:28:41 +02:00
Mark Tolmacs
f85a4b6998 Merge branch 'master' into mtolmacs/feat/fixed-point-simple-arrow-binding 2025-09-24 19:38:23 +02:00
Márk Tolmács
e32836f799 fix: Use analytical Jacobian for curve intersection testing (#10007) 2025-09-24 19:33:20 +02:00
Márk Tolmács
06c40006db fix: Elbow arrow routing issue with diamonds and ellipses (#10021) 2025-09-24 19:22:32 +02:00
Mossberg
91c7748c3d fix: added normalization to images added with the image tool to prevent MIME-mismatches (#10018)
* fix: fixed a bug where a MIME-mismatch in an image would cause an error to update cache

* fix: fixed a bug where a MIME-mismatch in an image would cause an error to update cache

* normalize inside insertImages()

---------

Co-authored-by: Mårten Mossberg <marmo607@student.liu.se>
Co-authored-by: dwelle <5153846+dwelle@users.noreply.github.com>
2025-09-24 16:30:50 +00:00
David Luzar
f738b74791 fix: reintroduce height-based mobile query detection (#10020) 2025-09-24 18:17:39 +02:00
Omar Brikaa
00ae455873 fix: Remove local elements when there is room data during startCollaboration (#9786)
* Remove local elements when there is room data

* Update excalidraw-app/collab/Collab.tsx

---------

Co-authored-by: David Luzar <5153846+dwelle@users.noreply.github.com>
2025-09-23 22:18:41 +00:00
ericvannunen
06c5ea94d3 fix: Race conditions when adding many library items (#10013)
* Fix for race condition when adding many library items

* Remove unused import

* Replace any with LibraryItem type

* Fix comments on pr

* Fix build errors

* Fix hoisted variable

* new mime type

* duplicate before passing down to be sure

* lint

* fix tests

* Remove unused import

---------

Co-authored-by: dwelle <5153846+dwelle@users.noreply.github.com>
2025-09-23 23:47:03 +02:00
Mark Tolmacs
95f1c719c4 fix: False bind timeout indicator
Signed-off-by: Mark Tolmacs <mark@lazycat.hu>
2025-09-22 18:22:35 +02:00
Mark Tolmacs
83c7841bf1 Merge branch 'master' into mtolmacs/feat/fixed-point-simple-arrow-binding 2025-09-21 09:56:59 +02:00
dwelle
dba41c4116 tweak animation some more + remove countdown 2025-09-19 19:58:01 +02:00
Márk Tolmács
f55ecb96cc fix: Mobile arrow point drag broken (#9998)
* fix: Mobile bound arrow point drag broken

* fix:Check real point
2025-09-19 19:41:03 +02:00
dwelle
83fa9099f6 alt anims + increase timeout to 700 2025-09-18 20:25:32 +02:00
Mark Tolmacs
97fa922060 fix:Animated binding highlight 2025-09-18 19:30:54 +02:00
Mark Tolmacs
b8d1b8a5bd fix:Overlap inside binding 2025-09-18 16:16:01 +02:00
Mark Tolmacs
50e58abfd3 fix:Tool lock binding behavior restored 2025-09-17 20:52:52 +02:00
Mark Tolmacs
43816eb62d fix Arrow edit mode selection 2025-09-17 20:41:04 +02:00
Mark Tolmacs
d6e3839d31 fix:Poisoned arrow 2025-09-17 18:56:41 +02:00
Mark Tolmacs
e0dd29aa36 fix:Refactored and fixed highlight animation 2025-09-17 18:37:56 +02:00
Mark Tolmacs
f0494ced4c feat:Highlight animations
Signed-off-by: Mark Tolmacs <mark@lazycat.hu>
2025-09-17 10:46:36 +02:00
David Luzar
a6a32b9b29 fix: align MQ breakpoints and always use editor dimensions (#9991)
* fix: align MQ breakpoints and always use editor dimensions

* naming

* update snapshots
2025-09-17 07:57:10 +00:00
Mark Tolmacs
345e3f68f1 chore:Basic interactive canvas animation re-render trigger for highlights
Signed-off-by: Mark Tolmacs <mark@lazycat.hu>
2025-09-16 20:35:17 +02:00
Mark Tolmacs
5b77409eff fix: Jump arrow inside it gets visially too short
Signed-off-by: Mark Tolmacs <mark@lazycat.hu>
2025-09-16 19:14:29 +02:00
Mark Tolmacs
6398d14f3f feat: Remove center binding 2025-09-16 17:08:07 +02:00
Mark Tolmacs
32526c4d4a fix: Locked tool + arrow 2025-09-16 17:05:57 +02:00
Mark Tolmacs
f2f5168355 chore: Logic for measurement 2025-09-16 17:05:30 +02:00
Mark Tolmacs
55115d2ee4 feat: Allow inside binding for new arrows in nested cases 2025-09-16 17:05:03 +02:00
Mark Tolmacs
acc1241015 fix: Update history snapshot 2025-09-15 12:38:44 +02:00
Mark Tolmacs
e67338bff0 fix: New arrow start orbit when nested binds on the end 2025-09-15 11:01:30 +02:00
Mark Tolmacs
5a350a17c0 fix:Add intersection padding 2025-09-15 10:55:00 +02:00
Márk Tolmács
ac0d3059dc fix: Use the right polygon enclosure test (#9979) 2025-09-15 10:07:37 +02:00
dwelle
073f47d253 tweak highlight stroke width some more 2025-09-14 22:48:56 +02:00
dwelle
6c2f5dbd81 handle overlap when both elements the same size 2025-09-14 19:04:31 +02:00
dwelle
4e7b399927 reduce timeout 2025-09-14 14:48:28 +02:00
dwelle
7172006d1b update hints 2025-09-14 14:48:28 +02:00
Mark Tolmacs
b1006e2bfd fix: Locked elbow arrow creation 2025-09-14 14:05:55 +02:00
dwelle
4d8a1b29f6 render highlight on the outside 2025-09-14 13:41:32 +02:00
dwelle
f6978ae162 tweak binding highlight width 2025-09-14 13:39:44 +02:00
dwelle
b789308798 POC: highlight center on hover 2025-09-14 13:39:40 +02:00
Christopher Tangonan
1161f1b8ba fix: eraser can handle dots without regressing prior performance improvements (#9946)
Co-authored-by: Márk Tolmács <mark@lazycat.hu>
2025-09-14 11:33:43 +00:00
Mark Tolmacs
ef2bde0d03 fix:More stroke width for highlight 2025-09-13 14:46:24 +02:00
Mark Tolmacs
80706f733b fix:Arrow tool hover stuck highlight 2025-09-13 14:44:24 +02:00
Mark Tolmacs
737f6e08c1 feat: Center point with more precise highlight outlines 2025-09-13 14:41:47 +02:00
dwelle
c4874e9dd9 ignore invisible elements when binding 2025-09-13 12:06:48 +02:00
Mark Tolmacs
45b7cfc14b fix:Change center binding to circular 2025-09-13 11:54:11 +02:00
Mark Tolmacs
65a105e30a fix: Reverse 2025-09-13 11:22:45 +02:00
Mark Tolmacs
ee6f4d9ce5 Merge branch 'master' into mtolmacs/feat/fixed-point-simple-arrow-binding 2025-09-13 11:17:02 +02:00
Mark Tolmacs
7ae4d3aab5 feat:Add tolerance to shape nesting detection 2025-09-13 10:59:36 +02:00
Mark Tolmacs
017b36aeae fix:More precise element nesting check 2025-09-12 19:19:49 +02:00
Ryan Di
204e06b77b feat: compact layout for tablets (#9910)
* feat: allow the hiding of top picks

* feat: allow the hiding of default fonts

* refactor: rename to compactMode

* feat: introduce layout (incomplete)

* tweak icons

* do not show border

* lint

* add isTouchMobile to device

* add isTouchMobile to device

* refactor to use showCompactSidebar instead

* hide library label in compact

* fix icon color in dark theme

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

* update tests

* use a smaller gap between shapes

* proper fix of range

* quicker switching between different popovers

* to not show properties panel at all when editing text

* fix switching between different popovers for texts

* fix popover not closable and font search auto focus

* change properties for a new or editing text

* change icon for more style settings

* use bolt icon for extra actions

* fix breakpoints

* use rem for icon sizes

* fix tests

* improve switching between triggers (incomplete)

* improve trigger switching (complete)

* clean up code

* put compact into app state

* fix button size

* remove redundant PanelComponentProps["compactMode"]

* move fontSize UI on top

* mobile detection (breakpoints incomplete)

* tweak compact mode detection

* rename appState prop & values

* update snapshots

---------

Co-authored-by: dwelle <5153846+dwelle@users.noreply.github.com>
2025-09-12 10:18:31 +10:00
Mark Tolmacs
3ab8f67bc6 fix: history 2025-09-11 22:04:51 +02:00
Mark Tolmacs
fb3fe09226 fix: Nested shape binding 2025-09-11 21:58:19 +02:00
Mark Tolmacs
e5c7a6304e fix: Binding highlight stroke on sharp bindables 2025-09-11 21:00:11 +02:00
Mark Tolmacs
434ed03f1e fix: Size-based orbit jump-in 2025-09-11 18:00:51 +02:00
Mark Tolmacs
d73e273e63 fix: Tune nested shape binding 2025-09-11 17:48:56 +02:00
Mark Tolmacs
8d7af92719 fix: Fully nested shapes 2025-09-11 16:39:41 +02:00
David Luzar
414182f599 fix: normalize file on paste/drop (#9959) 2025-09-10 17:59:02 +02:00
Mark Tolmacs
9ac0f8231c fix:Highlight flicker
Signed-off-by: Mark Tolmacs <mark@lazycat.hu>
2025-09-10 17:34:19 +02:00
Mark Tolmacs
d4680df3d9 Trigger Rebuild 2025-09-10 14:44:31 +02:00
Mark Tolmacs
5830d518d4 fix: Image binding rule changed 2025-09-10 14:31:16 +02:00
Mark Tolmacs
b23768719d fix: New highlight overdraws arrow
Signed-off-by: Mark Tolmacs <mark@lazycat.hu>
2025-09-09 13:50:05 +02:00
Mark Tolmacs
fce13ccefd chore: Laxing on invariants
Signed-off-by: Mark Tolmacs <mark@lazycat.hu>
2025-09-09 11:20:43 +02:00
Mark Tolmacs
35c986cbef fix: Alt immediate update
Signed-off-by: Mark Tolmacs <mark@lazycat.hu>
2025-09-09 11:10:54 +02:00
Mark Tolmacs
a06b828ed2 fix: snapshots
Signed-off-by: Mark Tolmacs <mark@lazycat.hu>
2025-09-07 18:29:26 +02:00
Mark Tolmacs
7703cc2597 fix: Circular dep
Signed-off-by: Mark Tolmacs <mark@lazycat.hu>
2025-09-07 18:27:12 +02:00
Mark Tolmacs
433774e892 fix: Binding suggestions
Signed-off-by: Mark Tolmacs <mark@lazycat.hu>
2025-09-07 18:22:33 +02:00
Mark Tolmacs
be56e84596 fix: Existing arrow nested bindable
Signed-off-by: Mark Tolmacs <mark@lazycat.hu>
2025-09-07 17:59:35 +02:00
Mark Tolmacs
eb9efc261a Alt unbind fix
Signed-off-by: Mark Tolmacs <mark@lazycat.hu>
2025-09-07 16:31:18 +02:00
David Luzar
b9d27d308e fix: pasting not working in firefox (#9947) 2025-09-06 22:51:23 +02:00
Mark Tolmacs
b01eea9eb4 fix: Overlap behavior 2025-09-05 16:03:24 +02:00
Mark Tolmacs
109ff756f5 feat: Nested shapes handling
Signed-off-by: Mark Tolmacs <mark@lazycat.hu>
2025-09-04 17:03:19 +02:00
Mark Tolmacs
6ea0102b0a fix: Lint
Signed-off-by: Mark Tolmacs <mark@lazycat.hu>
2025-09-04 15:50:00 +02:00
Mark Tolmacs
8a3ba853ab fix: Multi-point arrows and linears
Signed-off-by: Mark Tolmacs <mark@lazycat.hu>
2025-09-04 15:03:22 +02:00
Mark Tolmacs
bcf3127fe5 chore: Small refactor
Signed-off-by: Mark Tolmacs <mark@lazycat.hu>
2025-09-04 10:23:13 +02:00
Mark Tolmacs
5a62499e95 fix: New arrow never binds inside
Signed-off-by: Mark Tolmacs <mark@lazycat.hu>
2025-09-03 21:34:22 +02:00
Mark Tolmacs
f8b8c0e95c fix: Arrow start inside binding switch
Signed-off-by: Mark Tolmacs <mark@lazycat.hu>
2025-09-03 20:58:20 +02:00
Mark Tolmacs
364f0be815 Fix multipoint arrow orbit
Signed-off-by: Mark Tolmacs <mark@lazycat.hu>
2025-09-03 18:29:03 +02:00
Mark Tolmacs
10d38a8539 feat: Alt inside start binding mode change 2025-09-02 17:00:45 +02:00
Mark Tolmacs
67fff43b92 fix: Ctrl+Alt precedence 2025-09-02 16:49:44 +02:00
Mark Tolmacs
62d7740c94 fix: Multipoint arrow opposite point movement 2025-09-02 16:39:38 +02:00
Mark Tolmacs
4f43399951 fix: Disable drag drag when arrow is bound 2025-09-02 16:15:57 +02:00
Mark Tolmacs
8dae900bbb fix: Dragging fix 2 2025-09-02 15:50:32 +02:00
Mark Tolmacs
9a49c8e448 fix: Dragging issues 2025-09-02 15:45:32 +02:00
Mark Tolmacs
8d77f1daf5 fix: Deleted arrow causes problems 2025-09-02 15:23:52 +02:00
Mark Tolmacs
405d37e158 fix: Delete invariant violation with arrows 2025-09-02 15:14:33 +02:00
dwelle
cba5d01460 fix: allow inside binding via timeout if arrow has no startBinding 2025-09-02 14:35:30 +02:00
Mark Tolmacs
aa7351f649 CHange new arrow creation 2025-09-02 13:57:27 +02:00
Mark Tolmacs
4438137a57 Fixed point binding for simple arrows
Tests added

Fix binding

Remove unneeded params

Unfinished simple arrow avoidance

Fix newly created jumping arrow when gets outside

Do not apply the jumping logic to elbow arrows for new elements

Existing arrows now jump out

Type updates to support fixed binding for simple arrows

Fix crash for elbow arrws in mutateElement()

Refactored simple arrow creation

Updating tests

No confirm threshold when inside biding range

Fix multi-point arrow grid off

Make elbow arrows respect grids

Unbind arrow if bound and moved at shaft of arrow key

Fix binding test

Fix drag unbind when the bound element is in the selection

Do not move mid point for simple arrows bound on both ends

Add test for mobing mid points for simple arrows when bound on the same element on both ends

Fix linear editor bug when both midpoint and endpoint is moved

Fix all point multipoint arrow highlight and binding

Arrow dragging gets a little drag to avoid accidental unbinding

Fixed point binding for simple arrows when the arrow doesn't point to the element

Fix binding disabled use-case triggering arrow editor

Timed binding mode change for simple arrows

Apply fixes

Remove code to unbind on drag

Update simple arrow fixed point when arrow is dragged or moved by arrow keys

Binding highlight fixes

Change bind mode timeout logic

Fix tests

Add Alt bindMode switch

 No dragging of arrows when bound, similar to elbow

Fix timeout not taking effect immediately

Bumop z-index for arrows when dragged

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

Only transparent bindables allow binding fallthrough

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

Fix lint

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

Fix point click array creation interaction with fixed point binding

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

Restrict new behavior to arrows only

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

Allow binding inside images

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

Fix already existing fixed binding retention

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

Refactor and implement fixed point binding for unfilled elements

Restore drag

Removed point binding

Binding code refactor

Added centered focus point

Binding & focus point debug

Add invariants to check binding integrity in elements

Binding fixes

Small refactors

Completely rewritten binding

Include point updates after binding update

Fix point updates when endpoint dragged and opposite endpoint orbits

centered focus point only for new arrows

Make z-index arrow reorder on bind

Turn off inside binding mode after leaving a shape

Remove invariants from debug

feat: expose `applyTo` options, don't commit empty text element (#9744)

* Expose applyTo options, skip re-draw for empty text

* Don't commit empty text elements

test: added test file for distribute (#9754)

z-index update

Bind mode on precise binding

Fix binding to inside element

Fix initial arrow not following cursor (white dot)

Fix elbow arrow

Fix z-index so it works on hover

Fix fixed angle orbiting

Move point click arrow creation over to common strategy

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

Add binding strategy for drag arrow creation

Fix elbow arrow

Fix point handles

Snap to center

Fix transparent shape binding

Internal arrow creation fix

Fix point binding

Fix selection bug

Fix new arrow focus point

Images now always bind inside

Flashing arrow creation on binding band

Add watchState debug method to window.h

Fix debug canvas crash

Remove non-needed bind mode

Fix restore

No keyboard movement when bound

Add actionFinalize when arrow in edit mode

Add drag to the Stats panel when bound arrow is moved

Further simplify curve tracking

Add typing to action register()

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

Fix point at finalize

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

Fix type errors

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

New arrow binding rules

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

Fix cyclical dep

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

Fix jiggly arrows

Fix jiggly arrow x2

Long inside-other binding

Click-click binding

Fix arrows

Performance

[PERF] Replace in-place Jacobian derivation with analytical version

Different approach to inside binding

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

Fixes

Fix inconsistent arrow start jump out

Change how images are bound to on new arrow creation

Lower timeout

Small insurance fix

Fix curve test

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

No center focus point

90% inside center binding

Fixing tests

fix: Elbow arrow fixes

fix: More arrow fixes

Do not trigger arrow binding for linear elements

fix: Linear elements

fix: Refactor actionFinalize for linear

Binding tests updated

fix: Jump when cursor not moved

fix: history tests

Fix history snapshot

Fix undo issue

fix(eraser): Remove binding from the other element

fix(tests): Update tests

chore: Attempt filtering new set state

Fix excessive history recording

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

Fix all tests

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

fix(transform): Fix group resize and rotate

fix(binding): Harmonize binding param usage

fix: Center focus point

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

chore: Trigger build

Remove binding gap

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

Binding highlight refactor

fix: Refactored timeout bind mode handling

fix: Center when orbiting

feat: Color change on highlight

Fix orbit binding highlight

fix: hiding arrow

Fix arrow binding

Fix arrow drag selection logic

Binding highlight is now hot pink

Change inside binding logic for start point

Render focus point in debug mode

Fix snap to center

Fix actionFinalize for new arrow creation

fix: snapToCenter()

80% by length

fix: attempt at fixing the dancing arrows

feat: No center snap when start is not bound

Fix centering for existing arrows

tweak binding highlight color

change `appState.suggestedBindings` -> `suggestedBinding` & remove unused code

Refactor delayed bind mode change

Binding highlight rotation support + image support

fix(highlight): Overdraw fixes

feat: Do not allow drag bound arrow closer to the shape than dragging distance

feat: Stroke width adaptive fixed binding distance

chore: More point dragging centralization

New element behavior

Refactor dragging

Fix incorrect highlight sizing

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

Fix delayed bind mode for multiElement arrows

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

Fix multi-point

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

Fix elbow arrows

Simplify state

Small positional fixes

Fix jiggly arrows

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

Fixes for arrow dragging

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

Elbow arrow fixes

Highlight fixes

Fix elbow arrow binding

Frame highlight

Fix elbow mid-point binding

Fix binding suggestion for disabled binding state

Implement Alt

Remove debug
2025-09-01 21:56:20 +02:00
Omar Brikaa
3bdaafe4b5 feat: [cont.] support inserting multiple images (#9875)
* feat: support inserting multiple images

* Initial

* handleAppOnDrop, onImageToolbarButtonClick, pasteFromClipboard

* Initial get history working

* insertMultipleImages -> insertImages

* Bug fixes, improvements

* Remove redundant branch

* Refactor addElementsFromMixedContentPaste

* History, drag & drop bug fixes

* Update snapshots

* Remove redundant try-catch

* Refactor pasteFromClipboard

* Plain paste check in mermaid paste

* Move comment

* processClipboardData -> insertClipboardContent

* Redundant variable

* Redundant variable

* Refactor insertImages

* createImagePlaceholder -> newImagePlaceholder

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

* Trigger CI

* Position placeholders before initializing

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

* Comment

* Move positionOnGrid out of file

* Rename file

* Get rid of generic

* Initial tests

* More asserts, test paste

* Test image tool

* De-duplicate

* Stricter assert, move rest of logic outside of waitFor

* Modify history tests

* De-duplicate update snapshots

* Trigger CI

* Fix package build

* Make setupImageTest more explicit

* Re-introduce generic to use latest placeholder versions

* newElementWith instead of mutateElement to delete failed placeholder

* Insert failed images separately with CaptureUpdateAction.NEVER

* Refactor

* Don't re-order elements

* WIP

* Get rid of 'never' for failed

* refactor type check

* align max file size constant

* make grid padding scale to zoom

---------

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

* fix: Type updates and throw for invalid point states

---------

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

* alternative ux: drag with lasso right away

* fix: lasso dragging should snap too

* fix: alt+cmd getting stuck

* test: snapshots

* alternatvie: keep lasso drag to only mobile

* alternative: drag after selection on PCs

* improve mobile dection

* add mobile lasso icon

* add default selection tool

* render according to default selection tool

* return to default selection tool after deletion

* reset to default tool after clearing out the canvas

* return to default tool after eraser toggle

* if default lasso, close lasso toggle

* finalize to default selection tool

* toggle between laser and default selection

* return to default selection tool after creation

* double click to add text when using default selection tool

* set to default selection tool after unlocking tool

* paste to center on touch screen

* switch to default selection tool after pasting

* lint

* fix tests

* show welcome screen when using default selection tool

* fix tests

* fix snapshots

* fix context menu not opening

* prevent potential displacement issue

* prevent element jumping during lasso selection

* fix dragging on mobile

* use same selection icon

* fix alt+cmd lasso getting cut off

* fix: shortcut handling

* lint

---------

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

* lint

* lint

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

* update meta tag

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

* cleanup save/restore

---------

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

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

* Remove selectedLinearElement flushSync

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

* feat: move finalize to next to last point

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

* fix: make default adaptive to zoom

* fix: increase padding to avoid cutoffs

* refactor: simplify

* fix: only use bigger padding when needed

* center arrow horizontally on pointer

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

* do not render 0-width arrow while creating

* dead code

* fix tests

* fix: remove redundant code

* do not enter line editor on creation

---------

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

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

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

* fix: lint

---------

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

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

* Simplify hover test for cursor

* Add back comment

* Fix test for hit testing

---------

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

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

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

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

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

* clean up test

* simplify check

* add tests

* prevent text editing on dblclick when inside arrow editor

---------

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

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

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

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

* extracted updateLoopLock to improve readability. Removed unnecessary SVG attributes

* lint + added loopLock to restore.ts

* added  loopLock to newElement, updated test snapshots

* lint

* dislocate enpoint when breaking the loop.

* change icon & turn into a state style button

* POC: auto-transform to polygon on bg set

* keep polygon icon constant

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

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

* refactor

* tweak point rendering inside line editor

* do not disable polygon when creating new points via alt

* auto-enable polygon when aligning start/end points

* TBD: remove bg color when disabling polygon

* TBD: only show polygon button for enabled polygons

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

* convert to polygon when creating line

* labels tweak

* add to command palette

* loopLock -> polygon

* restore `polygon` state on type conversions

* update snapshots

* naming

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

* snapshots

* fix: merge issue and forgotten debug

* snaps

* do not merge points for 3-point lines

---------

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

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

* Restore binding

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

* More actionFinalize

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

* Additional fixes

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

* New elbow arrow is removed if  too small

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

* Remove very small arrows

* Still allow loops

* Restore tests

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

* Update history snapshot

* More history snapshot updates

* keep invisible 2-point lines/freedraw elements

---------

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

* Add Copilot instructions

* update gitignore

* simplify

---------

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

* Add invariant check

* Add dev invariant fix

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

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

* feat: add unlock popup

* fix: linting errors

* style: padding tweaks

* style: remove redundant line

* feat: lock multiple units together

* style: tweak color & position

* feat: add highlight for locked elements

* feat: select groups correctly after unlocking

* test: update snapshots

* fix: lasso from selecting locked elements

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

* fix: reset hit locked id immediately when appropriate

* feat: capture locked units in delta

* test: update locking test

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

* feat: make locked highlighting consistent

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

* fix history

* remove `lockedUnits.singleUnits`

* tweak button

* do not render UnlockPopup if not locked element selected

* tweak actions

* refactor: simplify type

* refactor: rename type

* refactor: simplify hit element setting & checking

* fix: prefer locked over link

* rename to `activeLockedId`

* refactor: getElementAtPosition takes an optional hitelments array

* fix: avoid setting active locked id after resizing

---------

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

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

* Update packages/excalidraw/components/App.tsx

---------

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

* removed unnecessary change

* use canvas bg color & reduce size and stroke width

---------

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

* no need for same origin

* The form does not load without allow same origin

* automatically add embed=true to link if not present

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

* feat: generalize and simplify type conversion cache

* refactor: change cache from atoms to Map

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

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

* include assistant font

* default frame name

* extend search to frame names

* add a simple test

* collpase search match items

* id check out of loop

* fix frame name check

* include focusedId for small perf improvement

* optionally show and hide collapse icon

* update section title

* fix tests

* rename `serverSide` -> `private`

* revert: do not reset zoom on zoom change

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

* remove collapsible

* tweak results CSS

* remove redundant check

* set `appState.searchMatches` to null if empty

---------

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

- Document Tab and Shift+Tab usage for shape cycling

* docs: added shape cycling shortcut in helper dialog

* Update packages/excalidraw/components/HelpDialog.tsx

* Update packages/excalidraw/locales/en.json

---------

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

* add tab for testing

* style tweaks

* only show hint when a new node is created

* fix panel state

* refactor

* combine captures into one

* keep original font size

* switch multi

* switch different types altogether

* use tab only

* fix font size atom

* do not switch from active tool change

* prefer generic when mixed

* provide an optional direction when shape switching

* adjust panel bg & shadow

* redraw to correctly position text

* remove redundant code

* only tab to switch if focusing on app container

* limit which linear elements can be switched

* add shape switch to command palette

* remove hint

* cache initial panel position

* bend line to elbow if needed

* remove debug logic

* clean switch of arrows using app state

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

* cache linear when panel shows up

* type safe element conversion

* rename type

* respect initial type when switching between linears

* fix elbow segment indexing

* use latest linear

* merge converted elbow points if too close

* focus on panel after click

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

* remove Mutable

* add arrowBoundToElement check

* make it dependent on one signle state

* unmount when not showing

* simpler types, tidy up code

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

* fix popup component lifecycle

* move constant to CLASSES

* DRY out type detection

* file & variable renaming

* refactor

* throw in not-prod instead

* simplify

* semi-fix bindings on `generic` type conversion

---------

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

* fix snaps

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

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

Fixes #9413.

* Remove extra blank lines

Minor formatting cleanup. No functional changes.
2025-04-24 10:05:08 +10:00
349 changed files with 39046 additions and 34364 deletions

View File

@@ -1,3 +1,5 @@
MODE="development"
VITE_APP_BACKEND_V2_GET_URL=https://json-dev.excalidraw.com/api/v2/
VITE_APP_BACKEND_V2_POST_URL=https://json-dev.excalidraw.com/api/v2/post/

View File

@@ -1,3 +1,5 @@
MODE="production"
VITE_APP_BACKEND_V2_GET_URL=https://json.excalidraw.com/api/v2/
VITE_APP_BACKEND_V2_POST_URL=https://json.excalidraw.com/api/v2/post/

View File

@@ -32,6 +32,12 @@
"name": "jotai",
"message": "Do not import from \"jotai\" directly. Use our app-specific modules (\"editor-jotai\" or \"app-jotai\")."
}
],
"react/jsx-no-target-blank": [
"error",
{
"allowReferrer": true
}
]
}
}

45
.github/copilot-instructions.md vendored Normal file
View File

@@ -0,0 +1,45 @@
# Project coding standards
## Generic Communication Guidelines
- Be succint and be aware that expansive generative AI answers are costly and slow
- Avoid providing explanations, trying to teach unless asked for, your chat partner is an expert
- Stop apologising if corrected, just provide the correct information or code
- Prefer code unless asked for explanation
- Stop summarizing what you've changed after modifications unless asked for
## TypeScript Guidelines
- Use TypeScript for all new code
- Where possible, prefer implementations without allocation
- When there is an option, opt for more performant solutions and trade RAM usage for less CPU cycles
- Prefer immutable data (const, readonly)
- Use optional chaining (?.) and nullish coalescing (??) operators
## React Guidelines
- Use functional components with hooks
- Follow the React hooks rules (no conditional hooks)
- Keep components small and focused
- Use CSS modules for component styling
## Naming Conventions
- Use PascalCase for component names, interfaces, and type aliases
- Use camelCase for variables, functions, and methods
- Use ALL_CAPS for constants
## Error Handling
- Use try/catch blocks for async operations
- Implement proper error boundaries in React components
- Always log errors with contextual information
## Testing
- Always attempt to fix #problems
- Always offer to run `yarn test:app` in the project root after modifications are complete and attempt fixing the issues reported
## Types
- Always include `packages/math/src/types.ts` in the context when your write math related code and always use the Point type instead of { x, y}

View File

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

View File

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

View File

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

3
.gitignore vendored
View File

@@ -25,4 +25,5 @@ packages/excalidraw/types
coverage
dev-dist
html
meta*.json
meta*.json
.claude

34
CLAUDE.md Normal file
View File

@@ -0,0 +1,34 @@
# CLAUDE.md
## Project Structure
Excalidraw is a **monorepo** with a clear separation between the core library and the application:
- **`packages/excalidraw/`** - Main React component library published to npm as `@excalidraw/excalidraw`
- **`excalidraw-app/`** - Full-featured web application (excalidraw.com) that uses the library
- **`packages/`** - Core packages: `@excalidraw/common`, `@excalidraw/element`, `@excalidraw/math`, `@excalidraw/utils`
- **`examples/`** - Integration examples (NextJS, browser script)
## Development Workflow
1. **Package Development**: Work in `packages/*` for editor features
2. **App Development**: Work in `excalidraw-app/` for app-specific features
3. **Testing**: Always run `yarn test:update` before committing
4. **Type Safety**: Use `yarn test:typecheck` to verify TypeScript
## Development Commands
```bash
yarn test:typecheck # TypeScript type checking
yarn test:update # Run all tests (with snapshot updates)
yarn fix # Auto-fix formatting and linting issues
```
## Architecture Notes
### Package System
- Uses Yarn workspaces for monorepo management
- Internal packages use path aliases (see `vitest.config.mts`)
- Build system uses esbuild for packages, Vite for the app
- TypeScript throughout with strict configuration

View File

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

View File

@@ -34,6 +34,9 @@
<a href="https://discord.gg/UexuTaE">
<img alt="Chat on Discord" src="https://img.shields.io/discord/723672430744174682?color=738ad6&label=Chat%20on%20Discord&logo=discord&logoColor=ffffff&widge=false"/>
</a>
<a href="https://deepwiki.com/excalidraw/excalidraw">
<img alt="Ask DeepWiki" src="https://deepwiki.com/badge.svg" />
</a>
<a href="https://twitter.com/excalidraw">
<img alt="Follow Excalidraw on Twitter" src="https://img.shields.io/twitter/follow/excalidraw.svg?label=follow+@excalidraw&style=social&logo=twitter"/>
</a>
@@ -63,7 +66,7 @@ The Excalidraw editor (npm package) supports:
- 🏗️&nbsp;Customizable.
- 📷&nbsp;Image support.
- 😀&nbsp;Shape libraries support.
- 👅&nbsp;Localization (i18n) support.
- 🌐&nbsp;Localization (i18n) support.
- 🖼️&nbsp;Export to PNG, SVG & clipboard.
- 💾&nbsp;Open format - export drawings as an `.excalidraw` json file.
- ⚒️&nbsp;Wide range of tools - rectangle, circle, diamond, arrow, line, free-draw, eraser...

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,7 +1,3 @@
import Slider from "rc-slider";
import "rc-slider/assets/index.css";
import {
Excalidraw,
LiveCollaborationTrigger,
@@ -24,7 +20,6 @@ import {
APP_NAME,
EVENT,
THEME,
TITLE_TIMEOUT,
VERSION_TIMEOUT,
debounce,
getVersion,
@@ -34,7 +29,6 @@ import {
resolvablePromise,
isRunningInIframe,
isDevEnv,
assertNever,
} from "@excalidraw/common";
import polyfill from "@excalidraw/excalidraw/polyfill";
import { useCallback, useEffect, useRef, useState } from "react";
@@ -52,16 +46,15 @@ import {
share,
youtubeIcon,
} from "@excalidraw/excalidraw/components/icons";
import { isElementLink } from "@excalidraw/element/elementLink";
import { isElementLink } from "@excalidraw/element";
import { restore, restoreAppState } from "@excalidraw/excalidraw/data/restore";
import { newElementWith } from "@excalidraw/element/mutateElement";
import { isInitializedImageElement } from "@excalidraw/element/typeChecks";
import { newElementWith } from "@excalidraw/element";
import { isInitializedImageElement } from "@excalidraw/element";
import clsx from "clsx";
import {
parseLibraryTokensFromUrl,
useHandleLibrary,
} from "@excalidraw/excalidraw/data/library";
import { StoreDelta, DurableStoreIncrement, EphemeralStoreIncrement, StoreIncrement } from "@excalidraw/excalidraw/store";
import type { RemoteExcalidrawElement } from "@excalidraw/excalidraw/data/reconcile";
import type { RestoredDataState } from "@excalidraw/excalidraw/data/restore";
@@ -69,7 +62,6 @@ import type {
FileId,
NonDeletedExcalidrawElement,
OrderedExcalidrawElement,
SceneElementsMap,
} from "@excalidraw/element/types";
import type {
AppState,
@@ -99,7 +91,6 @@ import Collab, {
collabAPIAtom,
isCollaboratingAtom,
isOfflineAtom,
syncApiAtom,
} from "./collab/Collab";
import { AppFooter } from "./components/AppFooter";
import { AppMainMenu } from "./components/AppMainMenu";
@@ -128,6 +119,7 @@ import {
LibraryIndexedDBAdapter,
LibraryLocalStorageMigrationAdapter,
LocalData,
localStorageQuotaExceededAtom,
} from "./data/LocalData";
import { isBrowserStorageStateNewer } from "./data/tabSync";
import { ShareDialog, shareDialogStateAtom } from "./share/ShareDialog";
@@ -376,40 +368,11 @@ const ExcalidrawWrapper = () => {
const [, setShareDialogState] = useAtom(shareDialogStateAtom);
const [collabAPI] = useAtom(collabAPIAtom);
const [syncAPI] = useAtom(syncApiAtom);
const [sliderVersion, setSliderVersion] = useState(0);
const [acknowledgedDeltas, setAcknowledgedDeltas] = useState<StoreDelta[]>(
[],
);
const acknowledgedDeltasRef = useRef<StoreDelta[]>(acknowledgedDeltas);
const [isCollaborating] = useAtomWithInitialValue(isCollaboratingAtom, () => {
return isCollaborationLink(window.location.href);
});
const collabError = useAtomValue(collabErrorIndicatorAtom);
useEffect(() => {
acknowledgedDeltasRef.current = acknowledgedDeltas;
}, [acknowledgedDeltas]);
useEffect(() => {
const interval = setInterval(() => {
const deltas = syncAPI?.acknowledgedDeltas ?? [];
// CFDO: buffer local deltas as well, not only acknowledged ones
if (deltas.length > acknowledgedDeltasRef.current.length) {
setAcknowledgedDeltas([...deltas]);
setSliderVersion(deltas.length);
}
}, 1000);
syncAPI?.connect();
return () => {
syncAPI?.disconnect();
clearInterval(interval);
};
}, [syncAPI]);
useHandleLibrary({
excalidrawAPI,
adapter: LibraryIndexedDBAdapter,
@@ -536,11 +499,6 @@ const ExcalidrawWrapper = () => {
}
};
const titleTimeout = setTimeout(
() => (document.title = APP_NAME),
TITLE_TIMEOUT,
);
const syncData = debounce(() => {
if (isTestEnv()) {
return;
@@ -631,7 +589,6 @@ const ExcalidrawWrapper = () => {
visibilityChange,
false,
);
clearTimeout(titleTimeout);
};
}, [isCollabDisabled, collabAPI, excalidrawAPI, setLangCode]);
@@ -706,40 +663,12 @@ const ExcalidrawWrapper = () => {
debugRenderer(
debugCanvasRef.current,
appState,
elements,
window.devicePixelRatio,
() => forceRefresh((prev) => !prev),
);
}
};
const onIncrement = (
increment: DurableStoreIncrement | EphemeralStoreIncrement,
) => {
try {
if (!syncAPI) {
return;
}
if (StoreIncrement.isDurable(increment)) {
// push only if there are element changes
if (!increment.delta.elements.isEmpty()) {
syncAPI.push(increment.delta);
}
return;
}
if (StoreIncrement.isEphemeral(increment)) {
syncAPI.relay(increment.change);
return;
}
assertNever(increment, `Unknown increment type`);
} catch (e) {
console.error("Error during onIncrement handler", e);
}
};
const [latestShareableLink, setLatestShareableLink] = useState<string | null>(
null,
);
@@ -799,6 +728,8 @@ const ExcalidrawWrapper = () => {
const isOffline = useAtomValue(isOfflineAtom);
const localStorageQuotaExceeded = useAtomValue(localStorageQuotaExceededAtom);
const onCollabDialogOpen = useCallback(
() => setShareDialogState({ isOpen: true, type: "collaborationOnly" }),
[setShareDialogState],
@@ -862,57 +793,6 @@ const ExcalidrawWrapper = () => {
},
};
const debouncedTimeTravel = debounce(
(value: number, direction: "forward" | "backward") => {
if (!excalidrawAPI) {
return;
}
let nextAppState = excalidrawAPI.getAppState();
// CFDO: retrieve the scene map already
let nextElements = new Map(
excalidrawAPI.getSceneElements().map((x) => [x.id, x]),
) as SceneElementsMap;
let deltas: StoreDelta[] = [];
// CFDO I: going both in collaborative setting means the (acknowledge) deltas need to have applied latest changes
switch (direction) {
case "forward": {
deltas = acknowledgedDeltas.slice(sliderVersion, value) ?? [];
break;
}
case "backward": {
deltas = acknowledgedDeltas
.slice(value)
.reverse()
.map((x) => StoreDelta.inverse(x));
break;
}
default:
assertNever(direction, `Unknown direction: ${direction}`);
}
for (const delta of deltas) {
[nextElements, nextAppState] = excalidrawAPI.store.applyDeltaTo(
delta,
nextElements,
nextAppState,
);
}
excalidrawAPI?.updateScene({
appState: {
...nextAppState,
viewModeEnabled: value !== acknowledgedDeltas.length,
},
elements: Array.from(nextElements.values()),
captureUpdate: CaptureUpdateAction.NEVER,
});
},
0,
);
return (
<div
style={{ height: "100%" }}
@@ -920,45 +800,9 @@ const ExcalidrawWrapper = () => {
"is-collaborating": isCollaborating,
})}
>
<Slider
style={{
position: "fixed",
bottom: "25px",
zIndex: 999,
width: "60%",
left: "25%",
}}
step={1}
min={0}
max={acknowledgedDeltas.length}
value={sliderVersion}
onChange={(value) => {
const nextSliderVersion = value as number;
// CFDO: in safari the whole canvas gets selected when dragging
if (nextSliderVersion !== acknowledgedDeltas.length) {
// don't listen to updates in the detached mode
syncAPI?.disconnect();
} else {
// reconnect once we're back to the latest version
syncAPI?.connect();
}
if (nextSliderVersion === sliderVersion) {
return;
}
debouncedTimeTravel(
nextSliderVersion,
nextSliderVersion < sliderVersion ? "backward" : "forward",
);
setSliderVersion(nextSliderVersion);
}}
/>
<Excalidraw
excalidrawAPI={excalidrawRefCallback}
onChange={onChange}
onIncrement={onIncrement}
initialData={initialStatePromiseRef.current.promise}
isCollaborating={isCollaborating}
onPointerUpdate={collabAPI?.onPointerUpdate}
@@ -1037,6 +881,7 @@ const ExcalidrawWrapper = () => {
/>
<OverwriteConfirmDialog>
<OverwriteConfirmDialog.Actions.ExportToImage />
<OverwriteConfirmDialog.Actions.SaveToDisk />
{excalidrawAPI && (
<OverwriteConfirmDialog.Action
title={t("overwriteConfirm.action.excalidrawPlus.title")}
@@ -1059,10 +904,15 @@ const ExcalidrawWrapper = () => {
<TTDDialogTrigger />
{isCollaborating && isOffline && (
<div className="collab-offline-warning">
<div className="alertalert--warning">
{t("alerts.collabOfflineWarning")}
</div>
)}
{localStorageQuotaExceeded && (
<div className="alert alert--danger">
{t("alerts.localStorageQuotaExceeded")}
</div>
)}
{latestShareableLink && (
<ShareableLinkDialog
link={latestShareableLink}

View File

@@ -8,7 +8,8 @@ export const SYNC_BROWSER_TABS_TIMEOUT = 50;
export const CURSOR_SYNC_TIMEOUT = 33; // ~30fps
export const DELETED_ELEMENT_TIMEOUT = 24 * 60 * 60 * 1000; // 1 day
export const FILE_UPLOAD_MAX_BYTES = 3 * 1024 * 1024; // 3 MiB
// should be aligned with MAX_ALLOWED_FILE_BYTES
export const FILE_UPLOAD_MAX_BYTES = 4 * 1024 * 1024; // 4 MiB
// 1 year (https://stackoverflow.com/a/25201898/927631)
export const FILE_CACHE_MAX_AGE_SEC = 31536000;
@@ -45,7 +46,6 @@ export const STORAGE_KEYS = {
VERSION_FILES: "version-files",
IDB_LIBRARY: "excalidraw-library",
IDB_SYNC: "excalidraw-sync",
// do not use apart from migrations
__LEGACY_LOCAL_STORAGE_LIBRARY: "excalidraw-library",

View File

@@ -19,12 +19,9 @@ import {
throttleRAF,
} from "@excalidraw/common";
import { decryptData } from "@excalidraw/excalidraw/data/encryption";
import { getVisibleSceneBounds } from "@excalidraw/element/bounds";
import { newElementWith } from "@excalidraw/element/mutateElement";
import {
isImageElement,
isInitializedImageElement,
} from "@excalidraw/element/typeChecks";
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";
@@ -73,7 +70,7 @@ import {
FileManager,
updateStaleImageStatuses,
} from "../data/FileManager";
import { LocalData, SyncIndexedDBAdapter } from "../data/LocalData";
import { LocalData } from "../data/LocalData";
import {
isSavedToFirebase,
loadFilesFromFirebase,
@@ -95,7 +92,6 @@ import type {
SyncableExcalidrawElement,
} from "../data";
export const syncApiAtom = atom<SyncClient | null>(null);
export const collabAPIAtom = atom<CollabAPI | null>(null);
export const isCollaboratingAtom = atom(false);
export const isOfflineAtom = atom(false);
@@ -242,12 +238,6 @@ class Collab extends PureComponent<CollabProps, CollabState> {
appJotaiStore.set(collabAPIAtom, collabAPI);
SyncClient.create(this.excalidrawAPI, SyncIndexedDBAdapter).then(
(syncAPI) => {
appJotaiStore.set(syncApiAtom, syncAPI);
},
);
if (isTestEnv() || isDevEnv()) {
window.collab = window.collab || ({} as Window["collab"]);
Object.defineProperties(window, {
@@ -281,8 +271,6 @@ class Collab extends PureComponent<CollabProps, CollabState> {
window.clearTimeout(this.idleTimeoutId);
this.idleTimeoutId = null;
}
appJotaiStore.get(syncApiAtom)?.disconnect();
this.onUmmount?.();
}
@@ -542,7 +530,10 @@ class Collab extends PureComponent<CollabProps, CollabState> {
return null;
}
if (!existingRoomLinkData) {
if (existingRoomLinkData) {
// when joining existing room, don't merge it with current scene data
this.excalidrawAPI.resetScene();
} else {
const elements = this.excalidrawAPI.getSceneElements().map((element) => {
if (isImageElement(element) && element.status === "saved") {
return newElementWith(element, { status: "pending" });

View File

@@ -1,7 +1,7 @@
import { CaptureUpdateAction } from "@excalidraw/excalidraw";
import { trackEvent } from "@excalidraw/excalidraw/analytics";
import { encryptData } from "@excalidraw/excalidraw/data/encryption";
import { newElementWith } from "@excalidraw/element/mutateElement";
import { newElementWith } from "@excalidraw/element";
import throttle from "lodash.throttle";
import type { UserIdleState } from "@excalidraw/common";

View File

@@ -73,7 +73,7 @@ export const AIComponents = ({
</br>
<div>You can also try <a href="${
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>
</body>
</html>`,

View File

@@ -8,8 +8,14 @@ import {
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 { arrayToMap, throttleRAF } from "@excalidraw/common";
import { useCallback } from "react";
import {
getGlobalFixedPointForBindableElement,
isArrowElement,
isBindableElement,
} from "@excalidraw/element";
import {
isLineSegment,
@@ -18,9 +24,17 @@ import {
} from "@excalidraw/math";
import { isCurve } from "@excalidraw/math/curve";
import type { DebugElement } from "@excalidraw/excalidraw/visualdebug";
import React from "react";
import type { Curve } from "@excalidraw/math";
import type { DebugElement } from "@excalidraw/common";
import type {
ElementsMap,
ExcalidrawArrowElement,
ExcalidrawBindableElement,
FixedPointBinding,
OrderedExcalidrawElement,
} from "@excalidraw/element/types";
import { STORAGE_KEYS } from "../app_constants";
@@ -73,6 +87,176 @@ const renderOrigin = (context: CanvasRenderingContext2D, zoom: number) => {
context.save();
};
const _renderBinding = (
context: CanvasRenderingContext2D,
binding: FixedPointBinding,
elementsMap: ElementsMap,
zoom: number,
width: number,
height: number,
color: string,
) => {
if (!binding.fixedPoint) {
console.warn("Binding must have a fixedPoint");
return;
}
const bindable = elementsMap.get(
binding.elementId,
) as ExcalidrawBindableElement;
const [x, y] = getGlobalFixedPointForBindableElement(
binding.fixedPoint,
bindable,
elementsMap,
);
context.save();
context.strokeStyle = color;
context.lineWidth = 1;
context.beginPath();
context.moveTo(x * zoom, y * zoom);
context.bezierCurveTo(
x * zoom - width,
y * zoom - height,
x * zoom - width,
y * zoom + height,
x * zoom,
y * zoom,
);
context.stroke();
context.restore();
};
const _renderBindableBinding = (
binding: FixedPointBinding,
context: CanvasRenderingContext2D,
elementsMap: ElementsMap,
zoom: number,
width: number,
height: number,
color: string,
) => {
const bindable = elementsMap.get(
binding.elementId,
) as ExcalidrawBindableElement;
if (!binding.fixedPoint) {
console.warn("Binding must have a fixedPoint");
return;
}
const [x, y] = getGlobalFixedPointForBindableElement(
binding.fixedPoint,
bindable,
elementsMap,
);
context.save();
context.strokeStyle = color;
context.lineWidth = 1;
context.beginPath();
context.moveTo(x * zoom, y * zoom);
context.bezierCurveTo(
x * zoom + width,
y * zoom + height,
x * zoom + width,
y * zoom - height,
x * zoom,
y * zoom,
);
context.stroke();
context.restore();
};
const renderBindings = (
context: CanvasRenderingContext2D,
elements: readonly OrderedExcalidrawElement[],
zoom: number,
) => {
const elementsMap = arrayToMap(elements);
const dim = 16;
elements.forEach((element) => {
if (element.isDeleted) {
return;
}
if (isArrowElement(element)) {
if (element.startBinding) {
if (
!elementsMap
.get(element.startBinding.elementId)
?.boundElements?.find((e) => e.id === element.id)
) {
return;
}
_renderBinding(
context,
element.startBinding,
elementsMap,
zoom,
dim,
dim,
"red",
);
}
if (element.endBinding) {
if (
!elementsMap
.get(element.endBinding.elementId)
?.boundElements?.find((e) => e.id === element.id)
) {
return;
}
_renderBinding(
context,
element.endBinding,
elementsMap,
zoom,
dim,
dim,
"red",
);
}
}
if (isBindableElement(element) && element.boundElements?.length) {
element.boundElements.forEach((boundElement) => {
if (boundElement.type !== "arrow") {
return;
}
const arrow = elementsMap.get(
boundElement.id,
) as ExcalidrawArrowElement;
if (arrow && arrow.startBinding?.elementId === element.id) {
_renderBindableBinding(
arrow.startBinding,
context,
elementsMap,
zoom,
dim,
dim,
"green",
);
}
if (arrow && arrow.endBinding?.elementId === element.id) {
_renderBindableBinding(
arrow.endBinding,
context,
elementsMap,
zoom,
dim,
dim,
"green",
);
}
});
}
});
};
const render = (
frame: DebugElement[],
context: CanvasRenderingContext2D,
@@ -105,18 +289,14 @@ const render = (
const _debugRenderer = (
canvas: HTMLCanvasElement,
appState: AppState,
elements: readonly OrderedExcalidrawElement[],
scale: number,
refresh: () => void,
) => {
const [normalizedWidth, normalizedHeight] = getNormalizedCanvasDimensions(
canvas,
scale,
);
if (appState.height !== canvas.height || appState.width !== canvas.width) {
refresh();
}
const context = bootstrapCanvas({
canvas,
scale,
@@ -133,6 +313,7 @@ const _debugRenderer = (
);
renderOrigin(context, appState.zoom.value);
renderBindings(context, elements, appState.zoom.value);
if (
window.visualDebug?.currentFrame &&
@@ -184,10 +365,10 @@ export const debugRenderer = throttleRAF(
(
canvas: HTMLCanvasElement,
appState: AppState,
elements: readonly OrderedExcalidrawElement[],
scale: number,
refresh: () => void,
) => {
_debugRenderer(canvas, appState, scale, refresh);
_debugRenderer(canvas, appState, elements, scale);
},
{ trailing: true },
);
@@ -314,35 +495,29 @@ export const DebugFooter = ({ onChange }: { onChange: () => void }) => {
interface DebugCanvasProps {
appState: AppState;
scale: number;
ref?: React.Ref<HTMLCanvasElement>;
}
const DebugCanvas = ({ appState, scale, ref }: DebugCanvasProps) => {
const { width, height } = appState;
const DebugCanvas = React.forwardRef<HTMLCanvasElement, DebugCanvasProps>(
({ appState, scale }, ref) => {
const { width, height } = appState;
const canvasRef = useRef<HTMLCanvasElement>(null);
useImperativeHandle<HTMLCanvasElement | null, HTMLCanvasElement | null>(
ref,
() => canvasRef.current,
[canvasRef],
);
return (
<canvas
style={{
width,
height,
position: "absolute",
zIndex: 2,
pointerEvents: "none",
}}
width={width * scale}
height={height * scale}
ref={canvasRef}
>
Debug Canvas
</canvas>
);
};
return (
<canvas
style={{
width,
height,
position: "absolute",
zIndex: 2,
pointerEvents: "none",
}}
width={width * scale}
height={height * scale}
ref={ref}
>
Debug Canvas
</canvas>
);
},
);
export default DebugCanvas;

View File

@@ -10,7 +10,7 @@ export const EncryptedIcon = () => {
className="encrypted-icon tooltip"
href="https://plus.excalidraw.com/blog/end-to-end-encryption"
target="_blank"
rel="noopener noreferrer"
rel="noopener"
aria-label={t("encrypted.link")}
>
<Tooltip label={t("encrypted.tooltip")} long={true}>

View File

@@ -10,7 +10,7 @@ export const ExcalidrawPlusAppLink = () => {
import.meta.env.VITE_APP_PLUS_APP
}?utm_source=excalidraw&utm_medium=app&utm_content=signedInUserRedirectButton#excalidraw-redirect`}
target="_blank"
rel="noreferrer"
rel="noopener"
className="plus-button"
>
Go to Excalidraw+

View File

@@ -12,7 +12,7 @@ import {
generateEncryptionKey,
} from "@excalidraw/excalidraw/data/encryption";
import { serializeAsJSON } from "@excalidraw/excalidraw/data/json";
import { isInitializedImageElement } from "@excalidraw/element/typeChecks";
import { isInitializedImageElement } from "@excalidraw/element";
import { useI18n } from "@excalidraw/excalidraw/i18n";
import type {

View File

@@ -1,7 +1,7 @@
import { CaptureUpdateAction } from "@excalidraw/excalidraw";
import { compressData } from "@excalidraw/excalidraw/data/encode";
import { newElementWith } from "@excalidraw/element/mutateElement";
import { isInitializedImageElement } from "@excalidraw/element/typeChecks";
import { newElementWith } from "@excalidraw/element";
import { isInitializedImageElement } from "@excalidraw/element";
import { t } from "@excalidraw/excalidraw/i18n";
import type {

View File

@@ -16,7 +16,6 @@ import {
DEFAULT_SIDEBAR,
debounce,
} from "@excalidraw/common";
import { clearElementsForLocalStorage } from "@excalidraw/element";
import {
createStore,
entries,
@@ -27,7 +26,7 @@ import {
get,
} from "idb-keyval";
import { StoreDelta } from "@excalidraw/excalidraw/store";
import { appJotaiStore, atom } from "excalidraw-app/app-jotai";
import type { LibraryPersistedData } from "@excalidraw/excalidraw/data/library";
import type { ImportedDataState } from "@excalidraw/excalidraw/data/types";
@@ -37,7 +36,7 @@ import type {
BinaryFileData,
BinaryFiles,
} from "@excalidraw/excalidraw/types";
import type { DTO, MaybePromise } from "@excalidraw/common/utility-types";
import type { MaybePromise } from "@excalidraw/common/utility-types";
import { SAVE_TO_LOCAL_STORAGE_TIMEOUT, STORAGE_KEYS } from "../app_constants";
@@ -47,6 +46,8 @@ import { updateBrowserStateVersion } from "./tabSync";
const filesStore = createStore("files-db", "files-store");
export const localStorageQuotaExceededAtom = atom(false);
class LocalFileManager extends FileManager {
clearObsoleteFiles = async (opts: { currentFileIds: FileId[] }) => {
await entries(filesStore).then((entries) => {
@@ -71,6 +72,9 @@ const saveDataStateToLocalStorage = (
elements: readonly ExcalidrawElement[],
appState: AppState,
) => {
const localStorageQuotaExceeded = appJotaiStore.get(
localStorageQuotaExceededAtom,
);
try {
const _appState = clearAppStateForLocalStorage(appState);
@@ -83,19 +87,29 @@ const saveDataStateToLocalStorage = (
localStorage.setItem(
STORAGE_KEYS.LOCAL_STORAGE_ELEMENTS,
JSON.stringify(clearElementsForLocalStorage(elements)),
JSON.stringify(elements),
);
localStorage.setItem(
STORAGE_KEYS.LOCAL_STORAGE_APP_STATE,
JSON.stringify(_appState),
);
updateBrowserStateVersion(STORAGE_KEYS.VERSION_DATA_STATE);
if (localStorageQuotaExceeded) {
appJotaiStore.set(localStorageQuotaExceededAtom, false);
}
} catch (error: any) {
// Unable to access window.localStorage
console.error(error);
if (isQuotaExceededError(error) && !localStorageQuotaExceeded) {
appJotaiStore.set(localStorageQuotaExceededAtom, true);
}
}
};
const isQuotaExceededError = (error: any) => {
return error instanceof DOMException && error.name === "QuotaExceededError";
};
type SavingLockTypes = "collaboration";
export class LocalData {
@@ -106,12 +120,13 @@ export class LocalData {
files: BinaryFiles,
onFilesSaved: () => void,
) => {
// saveDataStateToLocalStorage(elements, appState);
// await this.fileStorage.saveFiles({
// elements,
// files,
// });
// onFilesSaved();
saveDataStateToLocalStorage(elements, appState);
await this.fileStorage.saveFiles({
elements,
files,
});
onFilesSaved();
},
SAVE_TO_LOCAL_STORAGE_TIMEOUT,
);
@@ -257,60 +272,3 @@ export class LibraryLocalStorageMigrationAdapter {
localStorage.removeItem(STORAGE_KEYS.__LEGACY_LOCAL_STORAGE_LIBRARY);
}
}
type SyncDeltaPersistedData = DTO<StoreDelta>[];
type SyncMetaPersistedData = {
lastAcknowledgedVersion: number;
};
export class SyncIndexedDBAdapter {
/** IndexedDB database and store name */
private static idb_name = STORAGE_KEYS.IDB_SYNC;
/** library data store keys */
private static deltasKey = "deltas";
private static metadataKey = "metadata";
private static store = createStore(
`${SyncIndexedDBAdapter.idb_name}-db`,
`${SyncIndexedDBAdapter.idb_name}-store`,
);
static async loadDeltas() {
const deltas = await get<SyncDeltaPersistedData>(
SyncIndexedDBAdapter.deltasKey,
SyncIndexedDBAdapter.store,
);
if (deltas?.length) {
return deltas.map((storeDeltaDTO) => StoreDelta.restore(storeDeltaDTO));
}
return null;
}
static async saveDeltas(data: SyncDeltaPersistedData): Promise<void> {
return set(
SyncIndexedDBAdapter.deltasKey,
data,
SyncIndexedDBAdapter.store,
);
}
static async loadMetadata() {
const metadata = await get<SyncMetaPersistedData>(
SyncIndexedDBAdapter.metadataKey,
SyncIndexedDBAdapter.store,
);
return metadata || null;
}
static async saveMetadata(data: SyncMetaPersistedData): Promise<void> {
return set(
SyncIndexedDBAdapter.metadataKey,
data,
SyncIndexedDBAdapter.store,
);
}
}

View File

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

View File

@@ -9,14 +9,14 @@ import {
} from "@excalidraw/excalidraw/data/encryption";
import { serializeAsJSON } from "@excalidraw/excalidraw/data/json";
import { restore } from "@excalidraw/excalidraw/data/restore";
import { isInvisiblySmallElement } from "@excalidraw/element/sizeHelpers";
import { isInitializedImageElement } from "@excalidraw/element/typeChecks";
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 { SceneBounds } from "@excalidraw/element/bounds";
import type { SceneBounds } from "@excalidraw/element";
import type {
ExcalidrawElement,
FileId,
@@ -258,11 +258,16 @@ export const loadScene = async (
await importFromBackend(id, privateKey),
localDataState?.appState,
localDataState?.elements,
{ repairBindings: true, refreshDimensions: false },
{
repairBindings: true,
refreshDimensions: false,
deleteInvisibleElements: true,
},
);
} else {
data = restore(localDataState || null, null, null, {
repairBindings: true,
deleteInvisibleElements: true,
});
}

View File

@@ -2,7 +2,6 @@ import {
clearAppStateForLocalStorage,
getDefaultAppState,
} from "@excalidraw/excalidraw/appState";
import { clearElementsForLocalStorage } from "@excalidraw/element";
import type { ExcalidrawElement } from "@excalidraw/element/types";
import type { AppState } from "@excalidraw/excalidraw/types";
@@ -50,7 +49,7 @@ export const importFromLocalStorage = () => {
let elements: ExcalidrawElement[] = [];
if (savedElements) {
try {
elements = clearElementsForLocalStorage(JSON.parse(savedElements));
elements = JSON.parse(savedElements);
} catch (error: any) {
console.error(error);
// Do nothing because elements array is already empty

View File

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

View File

@@ -58,7 +58,7 @@
}
}
.collab-offline-warning {
.alert {
pointer-events: none;
position: absolute;
top: 6.5rem;
@@ -69,10 +69,18 @@
text-align: center;
line-height: 1.5;
border-radius: var(--border-radius-md);
background-color: var(--color-warning);
color: var(--color-text-warning);
z-index: 6;
white-space: pre;
&--warning {
background-color: var(--color-warning);
color: var(--color-text-warning);
}
&--danger {
background-color: var(--color-danger-dark);
color: var(--color-danger-text);
}
}
}

View File

@@ -33,7 +33,6 @@
"i18next-browser-languagedetector": "6.1.4",
"idb-keyval": "6.0.3",
"jotai": "2.11.0",
"rc-slider": "11.1.7",
"react": "19.0.0",
"react-dom": "19.0.0",
"socket.io-client": "4.7.2",

View File

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

View File

@@ -198,7 +198,7 @@ exports[`Test MobileMenu > should initialize with welcome screen and hide once u
<a
class="welcome-screen-menu-item "
href="undefined/plus?utm_source=excalidraw&utm_medium=app&utm_content=welcomeScreenGuest"
rel="noreferrer"
rel="noopener"
target="_blank"
>
<div

View File

@@ -3,11 +3,15 @@ import {
createRedoAction,
createUndoAction,
} from "@excalidraw/excalidraw/actions/actionHistory";
import { syncInvalidIndices } from "@excalidraw/element/fractionalIndex";
import { syncInvalidIndices } from "@excalidraw/element";
import { API } from "@excalidraw/excalidraw/tests/helpers/api";
import { 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;
@@ -65,6 +69,79 @@ vi.mock("socket.io-client", () => {
* i.e. multiplayer history tests could be a good first candidate, as we could test both history stacks simultaneously.
*/
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 () => {
await render(<ExcalidrawApp />);
const rect1Props = {
@@ -128,6 +205,7 @@ describe("collaboration", () => {
// with explicit undo (as addition) we expect our item to be restored from the snapshot!
await waitFor(() => {
expect(API.getUndoStack().length).toBe(1);
expect(API.getRedoStack().length).toBe(1);
expect(API.getSnapshot()).toEqual([
expect.objectContaining(rect1Props),
expect.objectContaining({ ...rect2Props, isDeleted: false }),
@@ -170,79 +248,5 @@ describe("collaboration", () => {
expect.objectContaining({ ...rect2Props, isDeleted: true }),
]);
});
act(() => h.app.actionManager.executeAction(undoAction));
// simulate local update
API.updateScene({
elements: syncInvalidIndices([
h.elements[0],
newElementWith(h.elements[1], { x: 100 }),
]),
captureUpdate: CaptureUpdateAction.IMMEDIATELY,
});
await waitFor(() => {
expect(API.getUndoStack().length).toBe(2);
expect(API.getRedoStack().length).toBe(0);
expect(API.getSnapshot()).toEqual([
expect.objectContaining(rect1Props),
expect.objectContaining({ ...rect2Props, isDeleted: false, x: 100 }),
]);
expect(h.elements).toEqual([
expect.objectContaining(rect1Props),
expect.objectContaining({ ...rect2Props, isDeleted: false, x: 100 }),
]);
});
act(() => h.app.actionManager.executeAction(undoAction));
// we expect to iterate the stack to the first visible change
await waitFor(() => {
expect(API.getUndoStack().length).toBe(1);
expect(API.getRedoStack().length).toBe(1);
expect(API.getSnapshot()).toEqual([
expect.objectContaining(rect1Props),
expect.objectContaining({ ...rect2Props, isDeleted: false, x: 0 }),
]);
expect(h.elements).toEqual([
expect.objectContaining(rect1Props),
expect.objectContaining({ ...rect2Props, isDeleted: false, x: 0 }),
]);
});
// simulate force deleting the element remotely
API.updateScene({
elements: syncInvalidIndices([rect1]),
captureUpdate: CaptureUpdateAction.NEVER,
});
// snapshot was correctly updated and marked the element as deleted
await waitFor(() => {
expect(API.getUndoStack().length).toBe(1);
expect(API.getRedoStack().length).toBe(1);
expect(API.getSnapshot()).toEqual([
expect.objectContaining(rect1Props),
expect.objectContaining({ ...rect2Props, isDeleted: true, x: 0 }),
]);
expect(h.elements).toEqual([expect.objectContaining(rect1Props)]);
});
act(() => h.app.actionManager.executeAction(redoAction));
// with explicit redo (as update) we again restored the element from the snapshot!
await waitFor(() => {
expect(API.getUndoStack().length).toBe(2);
expect(API.getRedoStack().length).toBe(0);
expect(API.getSnapshot()).toEqual([
expect.objectContaining({ id: "A", isDeleted: false }),
expect.objectContaining({ id: "B", isDeleted: true, x: 100 }),
]);
expect(h.history.isRedoStackEmpty).toBeTruthy();
expect(h.elements).toEqual([
expect.objectContaining({ id: "A", isDeleted: false }),
expect.objectContaining({ id: "B", isDeleted: true, x: 100 }),
]);
});
});
});

View File

@@ -33,6 +33,7 @@
"pepjs": "0.5.3",
"prettier": "2.6.2",
"rewire": "6.0.0",
"rimraf": "^5.0.0",
"typescript": "4.9.4",
"vite": "5.0.12",
"vite-plugin-checker": "0.7.2",
@@ -51,13 +52,17 @@
"build-node": "node ./scripts/build-node.js",
"build:app:docker": "yarn --cwd ./excalidraw-app build:app:docker",
"build:app": "yarn --cwd ./excalidraw-app build:app",
"build:package": "yarn --cwd ./packages/excalidraw build:esm",
"build:common": "yarn --cwd ./packages/common build:esm",
"build:element": "yarn --cwd ./packages/element build:esm",
"build:excalidraw": "yarn --cwd ./packages/excalidraw build:esm",
"build:math": "yarn --cwd ./packages/math build:esm",
"build:packages": "yarn build:common && yarn build:math && yarn build:element && yarn build:excalidraw",
"build:version": "yarn --cwd ./excalidraw-app build:version",
"build": "yarn --cwd ./excalidraw-app build",
"build:preview": "yarn --cwd ./excalidraw-app build:preview",
"start": "yarn --cwd ./excalidraw-app start",
"start:production": "yarn --cwd ./excalidraw-app start:production",
"start:example": "yarn build:package && yarn --cwd ./examples/with-script-in-browser start",
"start:example": "yarn build:packages && yarn --cwd ./examples/with-script-in-browser start",
"test:all": "yarn test:typecheck && yarn test:code && yarn test:other && yarn test:app --watch=false",
"test:app": "vitest",
"test:code": "eslint --max-warnings=0 --ext .js,.ts,.tsx .",
@@ -75,11 +80,12 @@
"locales-coverage:description": "node scripts/locales-coverage-description.js",
"prepare": "husky install",
"prettier": "prettier \"**/*.{css,scss,json,md,html,yml}\" --ignore-path=.eslintignore",
"autorelease": "node scripts/autorelease.js",
"prerelease:excalidraw": "node scripts/prerelease.js",
"release:excalidraw": "node scripts/release.js",
"rm:build": "rm -rf excalidraw-app/{build,dist,dev-dist} && rm -rf packages/*/{dist,build} && rm -rf examples/*/{build,dist}",
"rm:node_modules": "rm -rf node_modules && rm -rf excalidraw-app/node_modules && rm -rf packages/*/node_modules",
"release": "node scripts/release.js",
"release:test": "node scripts/release.js --tag=test",
"release:next": "node scripts/release.js --tag=next",
"release:latest": "node scripts/release.js --tag=latest",
"rm:build": "rimraf --glob excalidraw-app/build excalidraw-app/dist excalidraw-app/dev-dist packages/*/dist packages/*/build examples/*/build examples/*/dist",
"rm:node_modules": "rimraf --glob node_modules excalidraw-app/node_modules packages/*/node_modules",
"clean-install": "yarn rm:node_modules && yarn install"
},
"resolutions": {

View File

@@ -1,6 +1,6 @@
{
"name": "@excalidraw/common",
"version": "0.1.0",
"version": "0.18.0",
"type": "module",
"types": "./dist/types/common/src/index.d.ts",
"main": "./dist/prod/index.js",
@@ -13,7 +13,10 @@
"default": "./dist/prod/index.js"
},
"./*": {
"types": "./../common/dist/types/common/src/*.d.ts"
"types": "./dist/types/common/src/*.d.ts",
"development": "./dist/dev/index.js",
"production": "./dist/prod/index.js",
"default": "./dist/prod/index.js"
}
},
"files": [
@@ -50,7 +53,7 @@
"bugs": "https://github.com/excalidraw/excalidraw/issues",
"repository": "https://github.com/excalidraw/excalidraw",
"scripts": {
"gen:types": "rm -rf types && tsc",
"build:esm": "rm -rf dist && node ../../scripts/buildBase.js && yarn gen:types"
"gen:types": "rimraf types && tsc",
"build:esm": "rimraf dist && node ../../scripts/buildBase.js && yarn gen:types"
}
}

View File

@@ -5,17 +5,18 @@ export class BinaryHeap<T> {
sinkDown(idx: number) {
const node = this.content[idx];
const nodeScore = this.scoreFunction(node);
while (idx > 0) {
const parentN = ((idx + 1) >> 1) - 1;
const parent = this.content[parentN];
if (this.scoreFunction(node) < this.scoreFunction(parent)) {
this.content[parentN] = node;
if (nodeScore < this.scoreFunction(parent)) {
this.content[idx] = parent;
idx = parentN; // TODO: Optimize
} else {
break;
}
}
this.content[idx] = node;
}
bubbleUp(idx: number) {
@@ -24,35 +25,39 @@ export class BinaryHeap<T> {
const score = this.scoreFunction(node);
while (true) {
const child2N = (idx + 1) << 1;
const child1N = child2N - 1;
let swap = null;
let child1Score = 0;
const child1N = ((idx + 1) << 1) - 1;
const child2N = child1N + 1;
let smallestIdx = idx;
let smallestScore = score;
// Check left child
if (child1N < length) {
const child1 = this.content[child1N];
child1Score = this.scoreFunction(child1);
if (child1Score < score) {
swap = child1N;
const child1Score = this.scoreFunction(this.content[child1N]);
if (child1Score < smallestScore) {
smallestIdx = child1N;
smallestScore = child1Score;
}
}
// Check right child
if (child2N < length) {
const child2 = this.content[child2N];
const child2Score = this.scoreFunction(child2);
if (child2Score < (swap === null ? score : child1Score)) {
swap = child2N;
const child2Score = this.scoreFunction(this.content[child2N]);
if (child2Score < smallestScore) {
smallestIdx = child2N;
}
}
if (swap !== null) {
this.content[idx] = this.content[swap];
this.content[swap] = node;
idx = swap; // TODO: Optimize
} else {
if (smallestIdx === idx) {
break;
}
// Move the smaller child up, continue finding position for node
this.content[idx] = this.content[smallestIdx];
idx = smallestIdx;
}
// Place node in its final position
this.content[idx] = node;
}
push(node: T) {

View File

@@ -10,6 +10,7 @@ export const isDarwin = /Mac|iPod|iPhone|iPad/.test(navigator.platform);
export const isWindows = /^Win/.test(navigator.platform);
export const isAndroid = /\b(android)\b/i.test(navigator.userAgent);
export const isFirefox =
typeof window !== "undefined" &&
"netscape" in window &&
navigator.userAgent.indexOf("rv:") > 1 &&
navigator.userAgent.indexOf("Gecko") > 1;
@@ -17,13 +18,20 @@ export const isChrome = navigator.userAgent.indexOf("Chrome") !== -1;
export const isSafari =
!isChrome && navigator.userAgent.indexOf("Safari") !== -1;
export const isIOS =
/iPad|iPhone/.test(navigator.platform) ||
/iPad|iPhone/i.test(navigator.platform) ||
// iPadOS 13+
(navigator.userAgent.includes("Mac") && "ontouchend" in document);
// keeping function so it can be mocked in test
export const isBrave = () =>
(navigator as any).brave?.isBrave?.name === "isBrave";
export const isMobile =
isIOS ||
/android|webos|ipod|blackberry|iemobile|opera mini/i.test(
navigator.userAgent,
) ||
/android|ios|ipod|blackberry|windows phone/i.test(navigator.platform);
export const supportsResizeObserver =
typeof window !== "undefined" && "ResizeObserver" in window;
@@ -35,6 +43,7 @@ export const APP_NAME = "Excalidraw";
// (happens a lot with fast clicks with the text tool)
export const TEXT_AUTOWRAP_THRESHOLD = 36; // px
export const DRAGGING_THRESHOLD = 10; // px
export const MINIMUM_ARROW_SIZE = 20; // px
export const LINE_CONFIRM_THRESHOLD = 8; // px
export const ELEMENT_SHIFT_TRANSLATE_AMOUNT = 5;
export const ELEMENT_TRANSLATE_AMOUNT = 1;
@@ -116,9 +125,12 @@ export const ENV = {
};
export const CLASSES = {
SIDEBAR: "sidebar",
SHAPE_ACTIONS_MENU: "App-menu__left",
ZOOM_ACTIONS: "zoom-actions",
SEARCH_MENU_INPUT_WRAPPER: "layer-ui__search-inputWrapper",
CONVERT_ELEMENT_TYPE_POPUP: "ConvertElementTypePopup",
SHAPE_ACTIONS_THEME_SCOPE: "shape-actions-theme-scope",
};
export const CJK_HAND_DRAWN_FALLBACK_FONT = "Xiaolai";
@@ -142,21 +154,52 @@ export const FONT_FAMILY = {
"Lilita One": 7,
"Comic Shanns": 8,
"Liberation Sans": 9,
Assistant: 10,
};
// Segoe UI Emoji fails to properly fallback for some glyphs: ∞, ∫, ≠
// so we need to have generic font fallback before it
export const SANS_SERIF_GENERIC_FONT = "sans-serif";
export const MONOSPACE_GENERIC_FONT = "monospace";
export const FONT_FAMILY_GENERIC_FALLBACKS = {
[SANS_SERIF_GENERIC_FONT]: 998,
[MONOSPACE_GENERIC_FONT]: 999,
};
export const FONT_FAMILY_FALLBACKS = {
[CJK_HAND_DRAWN_FALLBACK_FONT]: 100,
...FONT_FAMILY_GENERIC_FALLBACKS,
[WINDOWS_EMOJI_FALLBACK_FONT]: 1000,
};
export function getGenericFontFamilyFallback(
fontFamily: number,
): keyof typeof FONT_FAMILY_GENERIC_FALLBACKS {
switch (fontFamily) {
case FONT_FAMILY.Cascadia:
case FONT_FAMILY["Comic Shanns"]:
return MONOSPACE_GENERIC_FONT;
default:
return SANS_SERIF_GENERIC_FONT;
}
}
export const getFontFamilyFallbacks = (
fontFamily: number,
): Array<keyof typeof FONT_FAMILY_FALLBACKS> => {
const genericFallbackFont = getGenericFontFamilyFallback(fontFamily);
switch (fontFamily) {
case FONT_FAMILY.Excalifont:
return [CJK_HAND_DRAWN_FALLBACK_FONT, WINDOWS_EMOJI_FALLBACK_FONT];
return [
CJK_HAND_DRAWN_FALLBACK_FONT,
genericFallbackFont,
WINDOWS_EMOJI_FALLBACK_FONT,
];
default:
return [WINDOWS_EMOJI_FALLBACK_FONT];
return [genericFallbackFont, WINDOWS_EMOJI_FALLBACK_FONT];
}
};
@@ -218,13 +261,20 @@ export const IMAGE_MIME_TYPES = {
jfif: "image/jfif",
} as const;
export const MIME_TYPES = {
export const STRING_MIME_TYPES = {
text: "text/plain",
html: "text/html",
json: "application/json",
// excalidraw data
excalidraw: "application/vnd.excalidraw+json",
// LEGACY: fully-qualified library JSON data
excalidrawlib: "application/vnd.excalidrawlib+json",
// list of excalidraw library item ids
excalidrawlibIds: "application/vnd.excalidrawlib.ids+json",
} as const;
export const MIME_TYPES = {
...STRING_MIME_TYPES,
// image-encoded excalidraw data
"excalidraw.svg": "image/svg+xml",
"excalidraw.png": "image/png",
@@ -253,7 +303,7 @@ export const EXPORT_DATA_TYPES = {
excalidrawClipboardWithAPI: "excalidraw-api/clipboard",
} as const;
export const EXPORT_SOURCE =
export const getExportSource = () =>
window.EXCALIDRAW_EXPORT_SOURCE || window.location.origin;
// time in milliseconds
@@ -301,10 +351,20 @@ export const DEFAULT_UI_OPTIONS: AppProps["UIOptions"] = {
// breakpoints
// -----------------------------------------------------------------------------
// md screen
export const MQ_MAX_WIDTH_PORTRAIT = 730;
// mobile: up to 699px
export const MQ_MAX_MOBILE = 599;
export const MQ_MAX_WIDTH_LANDSCAPE = 1000;
export const MQ_MAX_HEIGHT_LANDSCAPE = 500;
// tablets
export const MQ_MIN_TABLET = MQ_MAX_MOBILE + 1; // lower bound (excludes phones)
export const MQ_MAX_TABLET = 1400; // upper bound (excludes laptops/desktops)
// desktop/laptop
export const MQ_MIN_WIDTH_DESKTOP = 1440;
// sidebar
export const MQ_RIGHT_SIDEBAR_MIN_WIDTH = 1229;
// -----------------------------------------------------------------------------
@@ -474,3 +534,14 @@ export enum UserIdleState {
AWAY = "away",
IDLE = "idle",
}
/**
* distance at which we merge points instead of adding a new merge-point
* when converting a line to a polygon (merge currently means overlaping
* the start and end points)
*/
export const LINE_POLYGON_POINT_MERGE_DISTANCE = 20;
export const DOUBLE_TAP_POSITION_THRESHOLD = 35;
export const BIND_MODE_TIMEOUT = 700; // ms

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;

View File

@@ -22,8 +22,10 @@ export interface FontMetadata {
};
/** flag to indicate a deprecated font */
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 */
local?: true;
/** flag to indicate a fallback font */
@@ -44,7 +46,7 @@ export const FONT_METADATA: Record<number, FontMetadata> = {
unitsPerEm: 1000,
ascender: 1011,
descender: -353,
lineHeight: 1.35,
lineHeight: 1.25,
},
},
[FONT_FAMILY["Lilita One"]]: {
@@ -98,14 +100,23 @@ export const FONT_METADATA: Record<number, FontMetadata> = {
descender: -434,
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]: {
metrics: {
unitsPerEm: 1000,
ascender: 880,
descender: -144,
lineHeight: 1.15,
lineHeight: 1.25,
},
fallback: true,
},

View File

@@ -9,3 +9,5 @@ export * from "./promise-pool";
export * from "./random";
export * from "./url";
export * from "./utils";
export * from "./emitter";
export * from "./visualdebug";

View File

@@ -66,10 +66,14 @@ export type MakeBrand<T extends string> = {
/** Maybe just promise or already fulfilled one! */
export type MaybePromise<T> = T | Promise<T>;
// get union of all keys from the union of types
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];
};
// get union of all keys from the union of types
export type AllPossibleKeys<T> = T extends any ? keyof T : never;
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,11 +1,6 @@
import { average, pointFrom, type GlobalPoint } from "@excalidraw/math";
import { average } from "@excalidraw/math";
import type {
ExcalidrawBindableElement,
FontFamilyValues,
FontString,
ExcalidrawElement,
} from "@excalidraw/element/types";
import type { FontFamilyValues, FontString } from "@excalidraw/element/types";
import type {
ActiveTool,
@@ -22,6 +17,8 @@ import {
FONT_FAMILY,
getFontFamilyFallbacks,
isDarwin,
isAndroid,
isIOS,
WINDOWS_EMOJI_FALLBACK_FONT,
} from "./constants";
@@ -92,7 +89,8 @@ export const isWritableElement = (
(target instanceof HTMLInputElement &&
(target.type === "text" ||
target.type === "number" ||
target.type === "password"));
target.type === "password" ||
target.type === "search"));
export const getFontFamilyString = ({
fontFamily,
@@ -101,7 +99,6 @@ export const getFontFamilyString = ({
}) => {
for (const [fontFamilyString, id] of Object.entries(FONT_FAMILY)) {
if (id === fontFamily) {
// TODO: we should fallback first to generic family names first
return `${fontFamilyString}${getFontFamilyFallbacks(id)
.map((x) => `, ${x}`)
.join("")}`;
@@ -121,6 +118,11 @@ export const getFontString = ({
return `${fontSize}px ${getFontFamilyString({ fontFamily })}` as FontString;
};
/** executes callback in the frame that's after the current one */
export const nextAnimationFrame = async (cb: () => any) => {
requestAnimationFrame(() => requestAnimationFrame(cb));
};
export const debounce = <T extends any[]>(
fn: (...args: T) => void,
timeout: number,
@@ -544,6 +546,20 @@ export const findLastIndex = <T>(
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) => {
const isRGBTransparent = color.length === 5 && color.substr(4, 1) === "0";
const isRRGGBBTransparent = color.length === 9 && color.substr(7, 2) === "00";
@@ -554,9 +570,6 @@ export const isTransparent = (color: string) => {
);
};
export const isBindingFallthroughEnabled = (el: ExcalidrawBindableElement) =>
el.fillStyle !== "solid" || isTransparent(el.backgroundColor);
export type ResolvablePromise<T> = Promise<T> & {
resolve: [T] extends [undefined]
? (value?: MaybePromise<Awaited<T>>) => void
@@ -698,8 +711,8 @@ export const arrayToObject = <T>(
array: readonly T[],
groupBy?: (value: T) => string | number,
) =>
array.reduce((acc, value) => {
acc[groupBy ? groupBy(value) : String(value)] = value;
array.reduce((acc, value, idx) => {
acc[groupBy ? groupBy(value) : idx] = value;
return acc;
}, {} as { [key: string]: T });
@@ -735,6 +748,25 @@ export const arrayToList = <T>(array: readonly T[]): Node<T>[] =>
return acc;
}, [] as Node<T>[]);
/**
* Converts a readonly array or map into an iterable.
* Useful for avoiding entry allocations when iterating object / map on each iteration.
*/
export const toIterable = <T>(
values: readonly T[] | ReadonlyMap<string, T>,
): Iterable<T> => {
return Array.isArray(values) ? values : values.values();
};
/**
* Converts a readonly array or map into an array.
*/
export const toArray = <T>(
values: readonly T[] | ReadonlyMap<string, T>,
): T[] => {
return Array.isArray(values) ? values : Array.from(toIterable(values));
};
export const isTestEnv = () => import.meta.env.MODE === ENV.TEST;
export const isDevEnv = () => import.meta.env.MODE === ENV.DEVELOPMENT;
@@ -1205,31 +1237,101 @@ export const escapeDoubleQuotes = (str: string) => {
export const castArray = <T>(value: T | T[]): T[] =>
Array.isArray(value) ? value : [value];
export const elementCenterPoint = (
element: ExcalidrawElement,
xOffset: number = 0,
yOffset: number = 0,
) => {
const { x, y, width, height } = element;
const centerXPoint = x + width / 2 + xOffset;
const centerYPoint = y + height / 2 + yOffset;
return pointFrom<GlobalPoint>(centerXPoint, centerYPoint);
};
/** hack for Array.isArray type guard not working with readonly value[] */
export const isReadonlyArray = (value?: any): value is readonly any[] => {
return Array.isArray(value);
};
export const sizeOf = (
value: readonly number[] | Readonly<Map<any, any>> | Record<any, any>,
value:
| readonly unknown[]
| Readonly<Map<string, unknown>>
| Readonly<Record<string, unknown>>
| ReadonlySet<unknown>,
): number => {
return isReadonlyArray(value)
? value.length
: value instanceof Map
: 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;
};
export const isMobileOrTablet = (): boolean => {
const ua = navigator.userAgent || "";
const platform = navigator.platform || "";
const uaData = (navigator as any).userAgentData as
| { mobile?: boolean; platform?: string }
| undefined;
// --- 1) chromium: prefer ua client hints -------------------------------
if (uaData) {
const plat = (uaData.platform || "").toLowerCase();
const isDesktopOS =
plat === "windows" ||
plat === "macos" ||
plat === "linux" ||
plat === "chrome os";
if (uaData.mobile === true) {
return true;
}
if (uaData.mobile === false && plat === "android") {
const looksTouchTablet =
matchMedia?.("(hover: none)").matches &&
matchMedia?.("(pointer: coarse)").matches;
return looksTouchTablet;
}
if (isDesktopOS) {
return false;
}
}
// --- 2) ios (includes ipad) --------------------------------------------
if (isIOS) {
return true;
}
// --- 3) android legacy ua fallback -------------------------------------
if (isAndroid) {
const isAndroidPhone = /Mobile/i.test(ua);
const isAndroidTablet = !isAndroidPhone;
if (isAndroidPhone || isAndroidTablet) {
const looksTouchTablet =
matchMedia?.("(hover: none)").matches &&
matchMedia?.("(pointer: coarse)").matches;
return looksTouchTablet;
}
}
// --- 4) last resort desktop exclusion ----------------------------------
const looksDesktopPlatform =
/Win|Linux|CrOS|Mac/.test(platform) ||
/Windows NT|X11|CrOS|Macintosh/.test(ua);
if (looksDesktopPlatform) {
return false;
}
return false;
};

View File

@@ -6,12 +6,12 @@ import {
type LocalPoint,
} from "@excalidraw/math";
import { isBounds } from "@excalidraw/element/typeChecks";
import { isBounds } from "@excalidraw/element";
import type { Curve } from "@excalidraw/math";
import type { LineSegment } from "@excalidraw/utils";
import type { Bounds } from "@excalidraw/element/bounds";
import type { Bounds } from "@excalidraw/element";
// The global data holder to collect the debug operations
declare global {
@@ -63,6 +63,8 @@ export const debugDrawLine = (
);
};
export const testDebug = () => {};
export const debugDrawPoint = (
p: GlobalPoint,
opts?: {

View File

@@ -1,38 +0,0 @@
{
"name": "@excalidraw/deltas",
"version": "0.0.1",
"main": "./dist/prod/index.js",
"type": "module",
"module": "./dist/prod/index.js",
"exports": {
".": {
"development": "./dist/dev/index.js",
"default": "./dist/prod/index.js"
}
},
"types": "./dist/types/index.d.ts",
"files": [
"dist/*"
],
"description": "Excalidraw utilities for handling deltas",
"publishConfig": {
"access": "public"
},
"license": "MIT",
"keywords": [
"excalidraw",
"excalidraw-deltas"
],
"dependencies": {
"nanoid": "5.0.9",
"roughjs": "4.6.6"
},
"devDependencies": {},
"bugs": "https://github.com/excalidraw/excalidraw/issues",
"repository": "https://github.com/excalidraw/excalidraw",
"scripts": {
"gen:types": "rm -rf types && tsc",
"build:esm": "rm -rf dist && node ../../scripts/buildShared.js && yarn gen:types",
"pack": "yarn build:umd && yarn pack"
}
}

View File

@@ -1,357 +0,0 @@
import { arrayToObject, assertNever } from "./utils";
/**
* Represents the difference between two objects of the same type.
*
* Both `deleted` and `inserted` partials represent the same set of added, removed or updated properties, where:
* - `deleted` is a set of all the deleted values
* - `inserted` is a set of all the inserted (added, updated) values
*
* Keeping it as pure object (without transient state, side-effects, etc.), so we won't have to instantiate it on load.
*/
export class Delta<T> {
private constructor(
public readonly deleted: Partial<T>,
public readonly inserted: Partial<T>,
) {}
public static create<T>(
deleted: Partial<T>,
inserted: Partial<T>,
modifier?: (delta: Partial<T>) => Partial<T>,
modifierOptions?: "deleted" | "inserted",
) {
const modifiedDeleted =
modifier && modifierOptions !== "inserted" ? modifier(deleted) : deleted;
const modifiedInserted =
modifier && modifierOptions !== "deleted" ? modifier(inserted) : inserted;
return new Delta(modifiedDeleted, modifiedInserted);
}
/**
* Calculates the delta between two objects.
*
* @param prevObject - The previous state of the object.
* @param nextObject - The next state of the object.
*
* @returns new delta instance.
*/
public static calculate<T extends { [key: string]: any }>(
prevObject: T,
nextObject: T,
modifier?: (partial: Partial<T>) => Partial<T>,
postProcess?: (
deleted: Partial<T>,
inserted: Partial<T>,
) => [Partial<T>, Partial<T>],
): Delta<T> {
if (prevObject === nextObject) {
return Delta.empty();
}
const deleted = {} as Partial<T>;
const inserted = {} as Partial<T>;
// O(n^3) here for elements, but it's not as bad as it looks:
// - we do this only on store recordings, not on every frame (not for ephemerals)
// - we do this only on previously detected changed elements
// - we do shallow compare only on the first level of properties (not going any deeper)
// - # of properties is reasonably small
for (const key of this.distinctKeysIterator(
"full",
prevObject,
nextObject,
)) {
deleted[key as keyof T] = prevObject[key];
inserted[key as keyof T] = nextObject[key];
}
const [processedDeleted, processedInserted] = postProcess
? postProcess(deleted, inserted)
: [deleted, inserted];
return Delta.create(processedDeleted, processedInserted, modifier);
}
public static empty() {
return new Delta({}, {});
}
public static isEmpty<T>(delta: Delta<T>): boolean {
return (
!Object.keys(delta.deleted).length && !Object.keys(delta.inserted).length
);
}
/**
* Merges deleted and inserted object partials.
*/
public static mergeObjects<T extends { [key: string]: unknown }>(
prev: T,
added: T,
removed: T,
) {
const cloned = { ...prev };
for (const key of Object.keys(removed)) {
delete cloned[key];
}
return { ...cloned, ...added };
}
/**
* Merges deleted and inserted array partials.
*/
public static mergeArrays<T>(
prev: readonly T[] | null,
added: readonly T[] | null | undefined,
removed: readonly T[] | null | undefined,
predicate?: (value: T) => string,
) {
return Object.values(
Delta.mergeObjects(
arrayToObject(prev ?? [], predicate),
arrayToObject(added ?? [], predicate),
arrayToObject(removed ?? [], predicate),
),
);
}
/**
* Diff object partials as part of the `postProcess`.
*/
public static diffObjects<T, K extends keyof T, V extends T[K][keyof T[K]]>(
deleted: Partial<T>,
inserted: Partial<T>,
property: K,
setValue: (prevValue: V | undefined) => V,
) {
if (!deleted[property] && !inserted[property]) {
return;
}
if (
typeof deleted[property] === "object" ||
typeof inserted[property] === "object"
) {
type RecordLike = Record<string, V | undefined>;
const deletedObject: RecordLike = deleted[property] ?? {};
const insertedObject: RecordLike = inserted[property] ?? {};
const deletedDifferences = Delta.getLeftDifferences(
deletedObject,
insertedObject,
).reduce((acc, curr) => {
acc[curr] = setValue(deletedObject[curr]);
return acc;
}, {} as RecordLike);
const insertedDifferences = Delta.getRightDifferences(
deletedObject,
insertedObject,
).reduce((acc, curr) => {
acc[curr] = setValue(insertedObject[curr]);
return acc;
}, {} as RecordLike);
if (
Object.keys(deletedDifferences).length ||
Object.keys(insertedDifferences).length
) {
Reflect.set(deleted, property, deletedDifferences);
Reflect.set(inserted, property, insertedDifferences);
} else {
Reflect.deleteProperty(deleted, property);
Reflect.deleteProperty(inserted, property);
}
}
}
/**
* Diff array partials as part of the `postProcess`.
*/
public static diffArrays<T, K extends keyof T, V extends T[K]>(
deleted: Partial<T>,
inserted: Partial<T>,
property: K,
groupBy: (value: V extends ArrayLike<infer T> ? T : never) => string,
) {
if (!deleted[property] && !inserted[property]) {
return;
}
if (Array.isArray(deleted[property]) || Array.isArray(inserted[property])) {
const deletedArray = (
Array.isArray(deleted[property]) ? deleted[property] : []
) as [];
const insertedArray = (
Array.isArray(inserted[property]) ? inserted[property] : []
) as [];
const deletedDifferences = arrayToObject(
Delta.getLeftDifferences(
arrayToObject(deletedArray, groupBy),
arrayToObject(insertedArray, groupBy),
),
);
const insertedDifferences = arrayToObject(
Delta.getRightDifferences(
arrayToObject(deletedArray, groupBy),
arrayToObject(insertedArray, groupBy),
),
);
if (
Object.keys(deletedDifferences).length ||
Object.keys(insertedDifferences).length
) {
const deletedValue = deletedArray.filter(
(x) => deletedDifferences[groupBy ? groupBy(x) : String(x)],
);
const insertedValue = insertedArray.filter(
(x) => insertedDifferences[groupBy ? groupBy(x) : String(x)],
);
Reflect.set(deleted, property, deletedValue);
Reflect.set(inserted, property, insertedValue);
} else {
Reflect.deleteProperty(deleted, property);
Reflect.deleteProperty(inserted, property);
}
}
}
/**
* Compares if object1 contains any different value compared to the object2.
*/
public static isLeftDifferent<T extends {}>(
object1: T,
object2: T,
skipShallowCompare = false,
): boolean {
const anyDistinctKey = this.distinctKeysIterator(
"left",
object1,
object2,
skipShallowCompare,
).next().value;
return !!anyDistinctKey;
}
/**
* Compares if object2 contains any different value compared to the object1.
*/
public static isRightDifferent<T extends {}>(
object1: T,
object2: T,
skipShallowCompare = false,
): boolean {
const anyDistinctKey = this.distinctKeysIterator(
"right",
object1,
object2,
skipShallowCompare,
).next().value;
return !!anyDistinctKey;
}
/**
* Returns all the object1 keys that have distinct values.
*/
public static getLeftDifferences<T extends {}>(
object1: T,
object2: T,
skipShallowCompare = false,
) {
return Array.from(
this.distinctKeysIterator("left", object1, object2, skipShallowCompare),
);
}
/**
* Returns all the object2 keys that have distinct values.
*/
public static getRightDifferences<T extends {}>(
object1: T,
object2: T,
skipShallowCompare = false,
) {
return Array.from(
this.distinctKeysIterator("right", object1, object2, skipShallowCompare),
);
}
/**
* Iterator comparing values of object properties based on the passed joining strategy.
*
* @yields keys of properties with different values
*
* WARN: it's based on shallow compare performed only on the first level and doesn't go deeper than that.
*/
private static *distinctKeysIterator<T extends {}>(
join: "left" | "right" | "full",
object1: T,
object2: T,
skipShallowCompare = false,
) {
if (object1 === object2) {
return;
}
let keys: string[] = [];
if (join === "left") {
keys = Object.keys(object1);
} else if (join === "right") {
keys = Object.keys(object2);
} else if (join === "full") {
keys = Array.from(
new Set([...Object.keys(object1), ...Object.keys(object2)]),
);
} else {
assertNever(join, "Unknown distinctKeysIterator's join param");
}
for (const key of keys) {
const object1Value = object1[key as keyof T];
const object2Value = object2[key as keyof T];
if (object1Value !== object2Value) {
if (
!skipShallowCompare &&
typeof object1Value === "object" &&
typeof object2Value === "object" &&
object1Value !== null &&
object2Value !== null &&
this.isShallowEqual(object1Value, object2Value)
) {
continue;
}
yield key;
}
}
}
private static isShallowEqual(object1: any, object2: any) {
const keys1 = Object.keys(object1);
const keys2 = Object.keys(object1);
if (keys1.length !== keys2.length) {
return false;
}
for (const key of keys1) {
if (object1[key] !== object2[key]) {
return false;
}
}
return true;
}
}

View File

@@ -1,21 +0,0 @@
/**
* Encapsulates a set of application-level `Delta`s.
*/
export interface DeltaContainer<T> {
/**
* Inverses the `Delta`s while creating a new `DeltaContainer` instance.
*/
inverse(): DeltaContainer<T>;
/**
* Applies the `Delta`s to the previous object.
*
* @returns a tuple of the next object `T` with applied `Delta`s, and `boolean`, indicating whether the applied deltas resulted in a visible change.
*/
applyTo(previous: T, ...options: unknown[]): [T, boolean];
/**
* Checks whether all `Delta`s are empty.
*/
isEmpty(): boolean;
}

View File

@@ -1,149 +0,0 @@
import { Random } from "roughjs/bin/math";
import { nanoid } from "nanoid";
import type {
AppState,
ObservedAppState,
ElementsMap,
ExcalidrawElement,
ElementUpdate,
} from "../excalidraw-types";
/**
* Transform array into an object, use only when array order is irrelevant.
*/
export const arrayToObject = <T>(
array: readonly T[],
groupBy?: (value: T) => string | number,
) =>
array.reduce((acc, value) => {
acc[groupBy ? groupBy(value) : String(value)] = value;
return acc;
}, {} as { [key: string]: T });
/**
* Transforms array of elements with `id` property into into a Map grouped by `id`.
*/
export const elementsToMap = <T extends { id: string }>(
items: readonly T[],
) => {
return items.reduce((acc: Map<string, T>, element) => {
acc.set(element.id, element);
return acc;
}, new Map());
};
// --
// hidden non-enumerable property for runtime checks
const hiddenObservedAppStateProp = "__observedAppState";
export const getObservedAppState = (appState: AppState): ObservedAppState => {
const observedAppState = {
name: appState.name,
editingGroupId: appState.editingGroupId,
viewBackgroundColor: appState.viewBackgroundColor,
selectedElementIds: appState.selectedElementIds,
selectedGroupIds: appState.selectedGroupIds,
editingLinearElementId: appState.editingLinearElement?.elementId || null,
selectedLinearElementId: appState.selectedLinearElement?.elementId || null,
croppingElementId: appState.croppingElementId,
};
Reflect.defineProperty(observedAppState, hiddenObservedAppStateProp, {
value: true,
enumerable: false,
});
return observedAppState;
};
// ------------------------------------------------------------
export const assertNever = (value: never, message: string): never => {
throw new Error(`${message}: "${value}".`);
};
// ------------------------------------------------------------
export const getNonDeletedGroupIds = (elements: ElementsMap) => {
const nonDeletedGroupIds = new Set<string>();
for (const [, element] of elements) {
// defensive check
if (element.isDeleted) {
continue;
}
// defensive fallback
for (const groupId of element.groupIds ?? []) {
nonDeletedGroupIds.add(groupId);
}
}
return nonDeletedGroupIds;
};
// ------------------------------------------------------------
export const isTestEnv = () => import.meta.env.MODE === "test";
export const isDevEnv = () => import.meta.env.MODE === "development";
export const isServerEnv = () => import.meta.env.MODE === "server";
export const shouldThrow = () => isDevEnv() || isTestEnv() || isServerEnv();
// ------------------------------------------------------------
let random = new Random(Date.now());
let testIdBase = 0;
export const randomInteger = () => Math.floor(random.next() * 2 ** 31);
export const reseed = (seed: number) => {
random = new Random(seed);
testIdBase = 0;
};
export const randomId = () => (isTestEnv() ? `id${testIdBase++}` : nanoid());
// ------------------------------------------------------------
export const getUpdatedTimestamp = () => (isTestEnv() ? 1 : Date.now());
// ------------------------------------------------------------
export const newElementWith = <TElement extends ExcalidrawElement>(
element: TElement,
updates: ElementUpdate<TElement>,
/** pass `true` to always regenerate */
force = false,
): TElement => {
let didChange = false;
for (const key in updates) {
const value = (updates as any)[key];
if (typeof value !== "undefined") {
if (
(element as any)[key] === value &&
// if object, always update because its attrs could have changed
(typeof value !== "object" || value === null)
) {
continue;
}
didChange = true;
}
}
if (!didChange && !force) {
return element;
}
return {
...element,
...updates,
updated: getUpdatedTimestamp(),
version: element.version + 1,
versionNonce: randomInteger(),
};
};

View File

@@ -1,404 +0,0 @@
import { Delta } from "../common/delta";
import {
assertNever,
getNonDeletedGroupIds,
getObservedAppState,
isDevEnv,
isTestEnv,
shouldThrow,
} from "../common/utils";
import type { DeltaContainer } from "../common/interfaces";
import type {
AppState,
ObservedAppState,
DTO,
SceneElementsMap,
ValueOf,
ObservedElementsAppState,
ObservedStandaloneAppState,
SubtypeOf,
} from "../excalidraw-types";
export class AppStateDelta implements DeltaContainer<AppState> {
private constructor(public readonly delta: Delta<ObservedAppState>) {}
public static calculate<T extends ObservedAppState>(
prevAppState: T,
nextAppState: T,
): AppStateDelta {
const delta = Delta.calculate(
prevAppState,
nextAppState,
undefined,
AppStateDelta.postProcess,
);
return new AppStateDelta(delta);
}
public static restore(appStateDeltaDTO: DTO<AppStateDelta>): AppStateDelta {
const { delta } = appStateDeltaDTO;
return new AppStateDelta(delta);
}
public static empty() {
return new AppStateDelta(Delta.create({}, {}));
}
public inverse(): AppStateDelta {
const inversedDelta = Delta.create(this.delta.inserted, this.delta.deleted);
return new AppStateDelta(inversedDelta);
}
public applyTo(
appState: AppState,
nextElements: SceneElementsMap,
): [AppState, boolean] {
try {
const {
selectedElementIds: removedSelectedElementIds = {},
selectedGroupIds: removedSelectedGroupIds = {},
} = this.delta.deleted;
const {
selectedElementIds: addedSelectedElementIds = {},
selectedGroupIds: addedSelectedGroupIds = {},
selectedLinearElementId,
editingLinearElementId,
...directlyApplicablePartial
} = this.delta.inserted;
const mergedSelectedElementIds = Delta.mergeObjects(
appState.selectedElementIds,
addedSelectedElementIds,
removedSelectedElementIds,
);
const mergedSelectedGroupIds = Delta.mergeObjects(
appState.selectedGroupIds,
addedSelectedGroupIds,
removedSelectedGroupIds,
);
// const selectedLinearElement =
// selectedLinearElementId && nextElements.has(selectedLinearElementId)
// ? new LinearElementEditor(
// nextElements.get(
// selectedLinearElementId,
// ) as NonDeleted<ExcalidrawLinearElement>,
// )
// : null;
// const editingLinearElement =
// editingLinearElementId && nextElements.has(editingLinearElementId)
// ? new LinearElementEditor(
// nextElements.get(
// editingLinearElementId,
// ) as NonDeleted<ExcalidrawLinearElement>,
// )
// : null;
const nextAppState = {
...appState,
...directlyApplicablePartial,
selectedElementIds: mergedSelectedElementIds,
selectedGroupIds: mergedSelectedGroupIds,
// selectedLinearElement:
// typeof selectedLinearElementId !== "undefined"
// ? selectedLinearElement // element was either inserted or deleted
// : appState.selectedLinearElement, // otherwise assign what we had before
// editingLinearElement:
// typeof editingLinearElementId !== "undefined"
// ? editingLinearElement // element was either inserted or deleted
// : appState.editingLinearElement, // otherwise assign what we had before
};
const constainsVisibleChanges = this.filterInvisibleChanges(
appState,
nextAppState,
nextElements,
);
return [nextAppState, constainsVisibleChanges];
} catch (e) {
// shouldn't really happen, but just in case
console.error(`Couldn't apply appstate delta`, e);
if (shouldThrow()) {
throw e;
}
return [appState, false];
}
}
public isEmpty(): boolean {
return Delta.isEmpty(this.delta);
}
/**
* It is necessary to post process the partials in case of reference values,
* for which we need to calculate the real diff between `deleted` and `inserted`.
*/
private static postProcess<T extends ObservedAppState>(
deleted: Partial<T>,
inserted: Partial<T>,
): [Partial<T>, Partial<T>] {
try {
Delta.diffObjects(
deleted,
inserted,
"selectedElementIds",
// ts language server has a bit trouble resolving this, so we are giving it a little push
(_) => true as ValueOf<T["selectedElementIds"]>,
);
Delta.diffObjects(
deleted,
inserted,
"selectedGroupIds",
(prevValue) => (prevValue ?? false) as ValueOf<T["selectedGroupIds"]>,
);
} catch (e) {
// if postprocessing fails it does not make sense to bubble up, but let's make sure we know about it
console.error(`Couldn't postprocess appstate change deltas.`);
if (isDevEnv() || isTestEnv()) {
throw e;
}
} finally {
return [deleted, inserted];
}
}
/**
* Mutates `nextAppState` be filtering out state related to deleted elements.
*
* @returns `true` if a visible change is found, `false` otherwise.
*/
private filterInvisibleChanges(
prevAppState: AppState,
nextAppState: AppState,
nextElements: SceneElementsMap,
): boolean {
// TODO: #7348 we could still get an empty undo/redo, as we assume that previous appstate does not contain references to deleted elements
// which is not always true - i.e. now we do cleanup appstate during history, but we do not do it during remote updates
const prevObservedAppState = getObservedAppState(prevAppState);
const nextObservedAppState = getObservedAppState(nextAppState);
const containsStandaloneDifference = Delta.isRightDifferent(
AppStateDelta.stripElementsProps(prevObservedAppState),
AppStateDelta.stripElementsProps(nextObservedAppState),
);
const containsElementsDifference = Delta.isRightDifferent(
AppStateDelta.stripStandaloneProps(prevObservedAppState),
AppStateDelta.stripStandaloneProps(nextObservedAppState),
);
if (!containsStandaloneDifference && !containsElementsDifference) {
// no change in appstate was detected
return false;
}
const visibleDifferenceFlag = {
value: containsStandaloneDifference,
};
if (containsElementsDifference) {
// filter invisible changes on each iteration
const changedElementsProps = Delta.getRightDifferences(
AppStateDelta.stripStandaloneProps(prevObservedAppState),
AppStateDelta.stripStandaloneProps(nextObservedAppState),
) as Array<keyof ObservedElementsAppState>;
let nonDeletedGroupIds = new Set<string>();
if (
changedElementsProps.includes("editingGroupId") ||
changedElementsProps.includes("selectedGroupIds")
) {
// this one iterates through all the non deleted elements, so make sure it's not done twice
nonDeletedGroupIds = getNonDeletedGroupIds(nextElements);
}
// check whether delta properties are related to the existing non-deleted elements
for (const key of changedElementsProps) {
switch (key) {
case "selectedElementIds":
nextAppState[key] = AppStateDelta.filterSelectedElements(
nextAppState[key],
nextElements,
visibleDifferenceFlag,
);
break;
case "selectedGroupIds":
nextAppState[key] = AppStateDelta.filterSelectedGroups(
nextAppState[key],
nonDeletedGroupIds,
visibleDifferenceFlag,
);
break;
case "croppingElementId": {
const croppingElementId = nextAppState[key];
const element =
croppingElementId && nextElements.get(croppingElementId);
if (element && !element.isDeleted) {
visibleDifferenceFlag.value = true;
} else {
nextAppState[key] = null;
}
break;
}
case "editingGroupId":
const editingGroupId = nextAppState[key];
if (!editingGroupId) {
// previously there was an editingGroup (assuming visible), now there is none
visibleDifferenceFlag.value = true;
} else if (nonDeletedGroupIds.has(editingGroupId)) {
// previously there wasn't an editingGroup, now there is one which is visible
visibleDifferenceFlag.value = true;
} else {
// there was assigned an editingGroup now, but it's related to deleted element
nextAppState[key] = null;
}
break;
case "selectedLinearElementId":
case "editingLinearElementId":
const appStateKey = AppStateDelta.convertToAppStateKey(key);
const linearElement = nextAppState[appStateKey];
if (!linearElement) {
// previously there was a linear element (assuming visible), now there is none
visibleDifferenceFlag.value = true;
} else {
const element = nextElements.get(linearElement.elementId);
if (element && !element.isDeleted) {
// previously there wasn't a linear element, now there is one which is visible
visibleDifferenceFlag.value = true;
} else {
// there was assigned a linear element now, but it's deleted
nextAppState[appStateKey] = null;
}
}
break;
default: {
assertNever(key, `Unknown ObservedElementsAppState's key "${key}"`);
}
}
}
}
return visibleDifferenceFlag.value;
}
private static convertToAppStateKey(
key: keyof Pick<
ObservedElementsAppState,
"selectedLinearElementId" | "editingLinearElementId"
>,
): keyof Pick<AppState, "selectedLinearElement" | "editingLinearElement"> {
switch (key) {
case "selectedLinearElementId":
return "selectedLinearElement";
case "editingLinearElementId":
return "editingLinearElement";
}
}
private static filterSelectedElements(
selectedElementIds: AppState["selectedElementIds"],
elements: SceneElementsMap,
visibleDifferenceFlag: { value: boolean },
) {
const ids = Object.keys(selectedElementIds);
if (!ids.length) {
// previously there were ids (assuming related to visible elements), now there are none
visibleDifferenceFlag.value = true;
return selectedElementIds;
}
const nextSelectedElementIds = { ...selectedElementIds };
for (const id of ids) {
const element = elements.get(id);
if (element && !element.isDeleted) {
// there is a selected element id related to a visible element
visibleDifferenceFlag.value = true;
} else {
delete nextSelectedElementIds[id];
}
}
return nextSelectedElementIds;
}
private static filterSelectedGroups(
selectedGroupIds: AppState["selectedGroupIds"],
nonDeletedGroupIds: Set<string>,
visibleDifferenceFlag: { value: boolean },
) {
const ids = Object.keys(selectedGroupIds);
if (!ids.length) {
// previously there were ids (assuming related to visible groups), now there are none
visibleDifferenceFlag.value = true;
return selectedGroupIds;
}
const nextSelectedGroupIds = { ...selectedGroupIds };
for (const id of Object.keys(nextSelectedGroupIds)) {
if (nonDeletedGroupIds.has(id)) {
// there is a selected group id related to a visible group
visibleDifferenceFlag.value = true;
} else {
delete nextSelectedGroupIds[id];
}
}
return nextSelectedGroupIds;
}
private static stripElementsProps(
delta: Partial<ObservedAppState>,
): Partial<ObservedStandaloneAppState> {
// WARN: Do not remove the type-casts as they here to ensure proper type checks
const {
editingGroupId,
selectedGroupIds,
selectedElementIds,
editingLinearElementId,
selectedLinearElementId,
croppingElementId,
...standaloneProps
} = delta as ObservedAppState;
return standaloneProps as SubtypeOf<
typeof standaloneProps,
ObservedStandaloneAppState
>;
}
private static stripStandaloneProps(
delta: Partial<ObservedAppState>,
): Partial<ObservedElementsAppState> {
// WARN: Do not remove the type-casts as they here to ensure proper type checks
const { name, viewBackgroundColor, ...elementsProps } =
delta as ObservedAppState;
return elementsProps as SubtypeOf<
typeof elementsProps,
ObservedElementsAppState
>;
}
}

View File

@@ -1,825 +0,0 @@
import { Delta } from "../common/delta";
import { newElementWith, shouldThrow } from "../common/utils";
import type { DeltaContainer } from "../common/interfaces";
import type {
ExcalidrawElement,
ElementUpdate,
Ordered,
SceneElementsMap,
DTO,
OrderedExcalidrawElement,
ExcalidrawImageElement,
} from "../excalidraw-types";
// CFDO: consider adding here (nonnullable) version & versionNonce & updated (so that we have correct versions when recunstructing from remote)
type ElementPartial<T extends ExcalidrawElement = ExcalidrawElement> =
ElementUpdate<Ordered<T>>;
/**
* Elements delta is a low level primitive to encapsulate property changes between two sets of elements.
* It does so by encapsulating forward and backward `Delta`s, allowing to time-travel in both directions.
*/
export class ElementsDelta implements DeltaContainer<SceneElementsMap> {
private constructor(
public readonly added: Record<string, Delta<ElementPartial>>,
public readonly removed: Record<string, Delta<ElementPartial>>,
public readonly updated: Record<string, Delta<ElementPartial>>,
) {}
public static create(
added: Record<string, Delta<ElementPartial>>,
removed: Record<string, Delta<ElementPartial>>,
updated: Record<string, Delta<ElementPartial>>,
options: {
shouldRedistribute: boolean;
} = {
shouldRedistribute: false,
// CFDO: don't forget to re-enable
},
) {
const { shouldRedistribute } = options;
let delta: ElementsDelta;
if (shouldRedistribute) {
const nextAdded: Record<string, Delta<ElementPartial>> = {};
const nextRemoved: Record<string, Delta<ElementPartial>> = {};
const nextUpdated: Record<string, Delta<ElementPartial>> = {};
const deltas = [
...Object.entries(added),
...Object.entries(removed),
...Object.entries(updated),
];
for (const [id, delta] of deltas) {
if (this.satisfiesAddition(delta)) {
nextAdded[id] = delta;
} else if (this.satisfiesRemoval(delta)) {
nextRemoved[id] = delta;
} else {
nextUpdated[id] = delta;
}
}
delta = new ElementsDelta(nextAdded, nextRemoved, nextUpdated);
} else {
delta = new ElementsDelta(added, removed, updated);
}
if (shouldThrow()) {
ElementsDelta.validate(delta, "added", this.satisfiesAddition);
ElementsDelta.validate(delta, "removed", this.satisfiesRemoval);
ElementsDelta.validate(delta, "updated", this.satisfiesUpdate);
}
return delta;
}
public static restore(elementsDeltaDTO: DTO<ElementsDelta>): ElementsDelta {
const { added, removed, updated } = elementsDeltaDTO;
return ElementsDelta.create(added, removed, updated);
}
private static satisfiesAddition = ({
deleted,
inserted,
}: Delta<ElementPartial>) =>
// dissallowing added as "deleted", which could cause issues when resolving conflicts
deleted.isDeleted === true && !inserted.isDeleted;
private static satisfiesRemoval = ({
deleted,
inserted,
}: Delta<ElementPartial>) =>
!deleted.isDeleted && inserted.isDeleted === true;
private static satisfiesUpdate = ({
deleted,
inserted,
}: Delta<ElementPartial>) => !!deleted.isDeleted === !!inserted.isDeleted;
private static validate(
elementsDelta: ElementsDelta,
type: "added" | "removed" | "updated",
satifies: (delta: Delta<ElementPartial>) => boolean,
) {
for (const [id, delta] of Object.entries(elementsDelta[type])) {
if (!satifies(delta)) {
console.error(
`Broken invariant for "${type}" delta, element "${id}", delta:`,
delta,
);
throw new Error(`ElementsDelta invariant broken for element "${id}".`);
}
}
}
/**
* Calculates the `Delta`s between the previous and next set of elements.
*
* @param prevElements - Map representing the previous state of elements.
* @param nextElements - Map representing the next state of elements.
*
* @returns `ElementsDelta` instance representing the `Delta` changes between the two sets of elements.
*/
public static calculate<T extends OrderedExcalidrawElement>(
prevElements: Map<string, T>,
nextElements: Map<string, T>,
): ElementsDelta {
if (prevElements === nextElements) {
return ElementsDelta.empty();
}
const added: Record<string, Delta<ElementPartial>> = {};
const removed: Record<string, Delta<ElementPartial>> = {};
const updated: Record<string, Delta<ElementPartial>> = {};
// this might be needed only in same edge cases, like during collab, when `isDeleted` elements get removed or when we (un)intentionally remove the elements
for (const prevElement of prevElements.values()) {
const nextElement = nextElements.get(prevElement.id);
if (!nextElement) {
const deleted = { ...prevElement, isDeleted: false } as ElementPartial;
const inserted = { isDeleted: true } as ElementPartial;
const delta = Delta.create(
deleted,
inserted,
ElementsDelta.stripIrrelevantProps,
);
removed[prevElement.id] = delta;
}
}
for (const nextElement of nextElements.values()) {
const prevElement = prevElements.get(nextElement.id);
if (!prevElement) {
const deleted = { isDeleted: true } as ElementPartial;
const inserted = {
...nextElement,
isDeleted: false,
} as ElementPartial;
const delta = Delta.create(
deleted,
inserted,
ElementsDelta.stripIrrelevantProps,
);
added[nextElement.id] = delta;
continue;
}
if (prevElement.versionNonce !== nextElement.versionNonce) {
const delta = Delta.calculate<ElementPartial>(
prevElement,
nextElement,
ElementsDelta.stripIrrelevantProps,
ElementsDelta.postProcess,
);
if (
// making sure we don't get here some non-boolean values (i.e. undefined, null, etc.)
typeof prevElement.isDeleted === "boolean" &&
typeof nextElement.isDeleted === "boolean" &&
prevElement.isDeleted !== nextElement.isDeleted
) {
// notice that other props could have been updated as well
if (prevElement.isDeleted && !nextElement.isDeleted) {
added[nextElement.id] = delta;
} else {
removed[nextElement.id] = delta;
}
continue;
}
// making sure there are at least some changes
if (!Delta.isEmpty(delta)) {
updated[nextElement.id] = delta;
}
}
}
return ElementsDelta.create(added, removed, updated);
}
public static empty() {
return ElementsDelta.create({}, {}, {});
}
public inverse(): ElementsDelta {
const inverseInternal = (deltas: Record<string, Delta<ElementPartial>>) => {
const inversedDeltas: Record<string, Delta<ElementPartial>> = {};
for (const [id, delta] of Object.entries(deltas)) {
inversedDeltas[id] = Delta.create(delta.inserted, delta.deleted);
}
return inversedDeltas;
};
const added = inverseInternal(this.added);
const removed = inverseInternal(this.removed);
const updated = inverseInternal(this.updated);
// notice we inverse removed with added not to break the invariants
// notice we force generate a new id
return ElementsDelta.create(removed, added, updated);
}
public isEmpty(): boolean {
return (
Object.keys(this.added).length === 0 &&
Object.keys(this.removed).length === 0 &&
Object.keys(this.updated).length === 0
);
}
/**
* Update delta/s based on the existing elements.
*
* @param elements current elements
* @param modifierOptions defines which of the delta (`deleted` or `inserted`) will be updated
* @returns new instance with modified delta/s
*/
public applyLatestChanges(
elements: SceneElementsMap,
modifierOptions: "deleted" | "inserted",
): ElementsDelta {
const modifier =
(element: OrderedExcalidrawElement) => (partial: ElementPartial) => {
const latestPartial: { [key: string]: unknown } = {};
for (const key of Object.keys(partial) as Array<keyof typeof partial>) {
// do not update following props:
// - `boundElements`, as it is a reference value which is postprocessed to contain only deleted/inserted keys
switch (key) {
case "boundElements":
latestPartial[key] = partial[key];
break;
default:
latestPartial[key] = element[key];
}
}
return latestPartial;
};
const applyLatestChangesInternal = (
deltas: Record<string, Delta<ElementPartial>>,
) => {
const modifiedDeltas: Record<string, Delta<ElementPartial>> = {};
for (const [id, delta] of Object.entries(deltas)) {
const existingElement = elements.get(id);
if (existingElement) {
const modifiedDelta = Delta.create(
delta.deleted,
delta.inserted,
modifier(existingElement),
modifierOptions,
);
modifiedDeltas[id] = modifiedDelta;
} else {
modifiedDeltas[id] = delta;
}
}
return modifiedDeltas;
};
const added = applyLatestChangesInternal(this.added);
const removed = applyLatestChangesInternal(this.removed);
const updated = applyLatestChangesInternal(this.updated);
return ElementsDelta.create(added, removed, updated, {
shouldRedistribute: true, // redistribute the deltas as `isDeleted` could have been updated
});
}
// CFDO: does it make sense having a separate snapshot?
public applyTo(
elements: SceneElementsMap,
elementsSnapshot: Map<string, OrderedExcalidrawElement>,
): [SceneElementsMap, boolean] {
const nextElements = new Map(elements) as SceneElementsMap;
let changedElements: Map<string, OrderedExcalidrawElement>;
const flags = {
containsVisibleDifference: false,
containsZindexDifference: false,
};
// mimic a transaction by applying deltas into `nextElements` (always new instance, no mutation)
try {
const applyDeltas = ElementsDelta.createApplier(
nextElements,
elementsSnapshot,
flags,
);
const addedElements = applyDeltas("added", this.added);
const removedElements = applyDeltas("removed", this.removed);
const updatedElements = applyDeltas("updated", this.updated);
// CFDO I: don't forget to fix this part
// const affectedElements = this.resolveConflicts(elements, nextElements);
// TODO: #7348 validate elements semantically and syntactically the changed elements, in case they would result data integrity issues
changedElements = new Map([
...addedElements,
...removedElements,
...updatedElements,
// ...affectedElements,
]);
} catch (e) {
console.error(`Couldn't apply elements delta`, e);
if (shouldThrow()) {
throw e;
}
// should not really happen, but just in case we cannot apply deltas, let's return the previous elements with visible change set to `true`
// even though there is obviously no visible change, returning `false` could be dangerous, as i.e.:
// in the worst case, it could lead into iterating through the whole stack with no possibility to redo
// instead, the worst case when returning `true` is an empty undo / redo
return [elements, true];
}
try {
// CFDO I: don't forget to fix this part
// // TODO: #7348 refactor away mutations below, so that we couldn't end up in an incosistent state
// ElementsDelta.redrawTextBoundingBoxes(nextElements, changedElements);
// // the following reorder performs also mutations, but only on new instances of changed elements
// // (unless something goes really bad and it fallbacks to fixing all invalid indices)
// nextElements = ElementsDelta.reorderElements(
// nextElements,
// changedElements,
// flags,
// );
// // Need ordered nextElements to avoid z-index binding issues
// ElementsDelta.redrawBoundArrows(nextElements, changedElements);
} catch (e) {
console.error(
`Couldn't mutate elements after applying elements change`,
e,
);
if (shouldThrow()) {
throw e;
}
} finally {
return [nextElements, flags.containsVisibleDifference];
}
}
private static createApplier =
(
nextElements: SceneElementsMap,
snapshot: Map<string, OrderedExcalidrawElement>,
flags: {
containsVisibleDifference: boolean;
containsZindexDifference: boolean;
},
) =>
(
type: "added" | "removed" | "updated",
deltas: Record<string, Delta<ElementPartial>>,
) => {
const getElement = ElementsDelta.createGetter(
type,
nextElements,
snapshot,
flags,
);
return Object.entries(deltas).reduce((acc, [id, delta]) => {
const element = getElement(id, delta.inserted);
if (element) {
const newElement = ElementsDelta.applyDelta(element, delta, flags);
nextElements.set(newElement.id, newElement);
acc.set(newElement.id, newElement);
}
return acc;
}, new Map<string, OrderedExcalidrawElement>());
};
private static createGetter =
(
type: "added" | "removed" | "updated",
elements: SceneElementsMap,
snapshot: Map<string, OrderedExcalidrawElement>,
flags: {
containsVisibleDifference: boolean;
containsZindexDifference: boolean;
},
) =>
(id: string, partial: ElementPartial) => {
let element = elements.get(id);
if (!element) {
// always fallback to the local snapshot, in cases when we cannot find the element in the elements array
element = snapshot.get(id);
if (element) {
// as the element was brought from the snapshot, it automatically results in a possible zindex difference
flags.containsZindexDifference = true;
// as the element was force deleted, we need to check if adding it back results in a visible change
if (
partial.isDeleted === false ||
(partial.isDeleted !== true && element.isDeleted === false)
) {
flags.containsVisibleDifference = true;
}
} else if (type === "added") {
// for additions the element does not have to exist (i.e. remote update)
// CFDO II: the version itself might be different!
element = newElementWith(
{ id, version: 1 } as OrderedExcalidrawElement,
{
...partial,
},
);
}
}
return element;
};
private static applyDelta(
element: OrderedExcalidrawElement,
delta: Delta<ElementPartial>,
flags: {
containsVisibleDifference: boolean;
containsZindexDifference: boolean;
} = {
// by default we don't care about about the flags
containsVisibleDifference: true,
containsZindexDifference: true,
},
) {
const { boundElements, ...directlyApplicablePartial } = delta.inserted;
if (
delta.deleted.boundElements?.length ||
delta.inserted.boundElements?.length
) {
const mergedBoundElements = Delta.mergeArrays(
element.boundElements,
delta.inserted.boundElements,
delta.deleted.boundElements,
(x) => x.id,
);
Object.assign(directlyApplicablePartial, {
boundElements: mergedBoundElements,
});
}
// CFDO: this looks wrong
if (element.type === "image") {
const _delta = delta as Delta<ElementPartial<ExcalidrawImageElement>>;
// we want to override `crop` only if modified so that we don't reset
// when undoing/redoing unrelated change
if (_delta.deleted.crop || _delta.inserted.crop) {
Object.assign(directlyApplicablePartial, {
// apply change verbatim
crop: _delta.inserted.crop ?? null,
});
}
}
if (!flags.containsVisibleDifference) {
// strip away fractional index, as even if it would be different, it doesn't have to result in visible change
const { index, ...rest } = directlyApplicablePartial;
const containsVisibleDifference = ElementsDelta.checkForVisibleDifference(
element,
rest,
);
flags.containsVisibleDifference = containsVisibleDifference;
}
if (!flags.containsZindexDifference) {
flags.containsZindexDifference =
delta.deleted.index !== delta.inserted.index;
}
return newElementWith(element, directlyApplicablePartial);
}
/**
* Check for visible changes regardless of whether they were removed, added or updated.
*/
private static checkForVisibleDifference(
element: OrderedExcalidrawElement,
partial: ElementPartial,
) {
if (element.isDeleted && partial.isDeleted !== false) {
// when it's deleted and partial is not false, it cannot end up with a visible change
return false;
}
if (element.isDeleted && partial.isDeleted === false) {
// when we add an element, it results in a visible change
return true;
}
if (element.isDeleted === false && partial.isDeleted) {
// when we remove an element, it results in a visible change
return true;
}
// check for any difference on a visible element
return Delta.isRightDifferent(element, partial);
}
// /**
// * Resolves conflicts for all previously added, removed and updated elements.
// * Updates the previous deltas with all the changes after conflict resolution.
// *
// * // CFDO: revisit since arrow seem often redrawn incorrectly
// *
// * @returns all elements affected by the conflict resolution
// */
// private resolveConflicts(
// prevElements: SceneElementsMap,
// nextElements: SceneElementsMap,
// ) {
// const nextAffectedElements = new Map<string, OrderedExcalidrawElement>();
// const updater = (
// element: ExcalidrawElement,
// updates: ElementUpdate<ExcalidrawElement>,
// ) => {
// const nextElement = nextElements.get(element.id); // only ever modify next element!
// if (!nextElement) {
// return;
// }
// let affectedElement: OrderedExcalidrawElement;
// if (prevElements.get(element.id) === nextElement) {
// // create the new element instance in case we didn't modify the element yet
// // so that we won't end up in an incosistent state in case we would fail in the middle of mutations
// affectedElement = newElementWith(
// nextElement,
// updates as ElementUpdate<OrderedExcalidrawElement>,
// );
// } else {
// affectedElement = mutateElement(
// nextElement,
// updates as ElementUpdate<OrderedExcalidrawElement>,
// );
// }
// nextAffectedElements.set(affectedElement.id, affectedElement);
// nextElements.set(affectedElement.id, affectedElement);
// };
// // removed delta is affecting the bindings always, as all the affected elements of the removed elements need to be unbound
// for (const id of Object.keys(this.removed)) {
// ElementsDelta.unbindAffected(prevElements, nextElements, id, updater);
// }
// // added delta is affecting the bindings always, all the affected elements of the added elements need to be rebound
// for (const id of Object.keys(this.added)) {
// ElementsDelta.rebindAffected(prevElements, nextElements, id, updater);
// }
// // updated delta is affecting the binding only in case it contains changed binding or bindable property
// for (const [id] of Array.from(Object.entries(this.updated)).filter(
// ([_, delta]) =>
// Object.keys({ ...delta.deleted, ...delta.inserted }).find((prop) =>
// bindingProperties.has(prop as BindingProp | BindableProp),
// ),
// )) {
// const updatedElement = nextElements.get(id);
// if (!updatedElement || updatedElement.isDeleted) {
// // skip fixing bindings for updates on deleted elements
// continue;
// }
// ElementsDelta.rebindAffected(prevElements, nextElements, id, updater);
// }
// // filter only previous elements, which were now affected
// const prevAffectedElements = new Map(
// Array.from(prevElements).filter(([id]) => nextAffectedElements.has(id)),
// );
// // calculate complete deltas for affected elements, and assign them back to all the deltas
// // technically we could do better here if perf. would become an issue
// const { added, removed, updated } = ElementsDelta.calculate(
// prevAffectedElements,
// nextAffectedElements,
// );
// for (const [id, delta] of Object.entries(added)) {
// this.added[id] = delta;
// }
// for (const [id, delta] of Object.entries(removed)) {
// this.removed[id] = delta;
// }
// for (const [id, delta] of Object.entries(updated)) {
// this.updated[id] = delta;
// }
// return nextAffectedElements;
// }
// /**
// * Non deleted affected elements of removed elements (before and after applying delta),
// * should be unbound ~ bindings should not point from non deleted into the deleted element/s.
// */
// private static unbindAffected(
// prevElements: SceneElementsMap,
// nextElements: SceneElementsMap,
// id: string,
// updater: (
// element: ExcalidrawElement,
// updates: ElementUpdate<ExcalidrawElement>,
// ) => void,
// ) {
// // the instance could have been updated, so make sure we are passing the latest element to each function below
// const prevElement = () => prevElements.get(id); // element before removal
// const nextElement = () => nextElements.get(id); // element after removal
// BoundElement.unbindAffected(nextElements, prevElement(), updater);
// BoundElement.unbindAffected(nextElements, nextElement(), updater);
// BindableElement.unbindAffected(nextElements, prevElement(), updater);
// BindableElement.unbindAffected(nextElements, nextElement(), updater);
// }
// /**
// * Non deleted affected elements of added or updated element/s (before and after applying delta),
// * should be rebound (if possible) with the current element ~ bindings should be bidirectional.
// */
// private static rebindAffected(
// prevElements: SceneElementsMap,
// nextElements: SceneElementsMap,
// id: string,
// updater: (
// element: ExcalidrawElement,
// updates: ElementUpdate<ExcalidrawElement>,
// ) => void,
// ) {
// // the instance could have been updated, so make sure we are passing the latest element to each function below
// const prevElement = () => prevElements.get(id); // element before addition / update
// const nextElement = () => nextElements.get(id); // element after addition / update
// BoundElement.unbindAffected(nextElements, prevElement(), updater);
// BoundElement.rebindAffected(nextElements, nextElement(), updater);
// BindableElement.unbindAffected(
// nextElements,
// prevElement(),
// (element, updates) => {
// // we cannot rebind arrows with bindable element so we don't unbind them at all during rebind (we still need to unbind them on removal)
// // TODO: #7348 add startBinding / endBinding to the `BoundElement` context so that we could rebind arrows and remove this condition
// if (isTextElement(element)) {
// updater(element, updates);
// }
// },
// );
// BindableElement.rebindAffected(nextElements, nextElement(), updater);
// }
// private static redrawTextBoundingBoxes(
// elements: SceneElementsMap,
// changed: Map<string, OrderedExcalidrawElement>,
// ) {
// const boxesToRedraw = new Map<
// string,
// { container: OrderedExcalidrawElement; boundText: ExcalidrawTextElement }
// >();
// for (const element of changed.values()) {
// if (isBoundToContainer(element)) {
// const { containerId } = element as ExcalidrawTextElement;
// const container = containerId ? elements.get(containerId) : undefined;
// if (container) {
// boxesToRedraw.set(container.id, {
// container,
// boundText: element as ExcalidrawTextElement,
// });
// }
// }
// if (hasBoundTextElement(element)) {
// const boundTextElementId = getBoundTextElementId(element);
// const boundText = boundTextElementId
// ? elements.get(boundTextElementId)
// : undefined;
// if (boundText) {
// boxesToRedraw.set(element.id, {
// container: element,
// boundText: boundText as ExcalidrawTextElement,
// });
// }
// }
// }
// for (const { container, boundText } of boxesToRedraw.values()) {
// if (container.isDeleted || boundText.isDeleted) {
// // skip redraw if one of them is deleted, as it would not result in a meaningful redraw
// continue;
// }
// redrawTextBoundingBox(boundText, container, elements, false);
// }
// }
// private static redrawBoundArrows(
// elements: SceneElementsMap,
// changed: Map<string, OrderedExcalidrawElement>,
// ) {
// for (const element of changed.values()) {
// if (!element.isDeleted && isBindableElement(element)) {
// updateBoundElements(element, elements, {
// changedElements: changed,
// });
// }
// }
// }
// private static reorderElements(
// elements: SceneElementsMap,
// changed: Map<string, OrderedExcalidrawElement>,
// flags: {
// containsVisibleDifference: boolean;
// containsZindexDifference: boolean;
// },
// ) {
// if (!flags.containsZindexDifference) {
// return elements;
// }
// const unordered = Array.from(elements.values());
// const ordered = orderByFractionalIndex([...unordered]);
// const moved = Delta.getRightDifferences(unordered, ordered, true).reduce(
// (acc, arrayIndex) => {
// const candidate = unordered[Number(arrayIndex)];
// if (candidate && changed.has(candidate.id)) {
// acc.set(candidate.id, candidate);
// }
// return acc;
// },
// new Map(),
// );
// if (!flags.containsVisibleDifference && moved.size) {
// // we found a difference in order!
// flags.containsVisibleDifference = true;
// }
// // synchronize all elements that were actually moved
// // could fallback to synchronizing all invalid indices
// return elementsToMap(syncMovedIndices(ordered, moved)) as typeof elements;
// }
/**
* It is necessary to post process the partials in case of reference values,
* for which we need to calculate the real diff between `deleted` and `inserted`.
*/
private static postProcess(
deleted: ElementPartial,
inserted: ElementPartial,
): [ElementPartial, ElementPartial] {
try {
Delta.diffArrays(deleted, inserted, "boundElements", (x) => x.id);
} catch (e) {
// if postprocessing fails, it does not make sense to bubble up, but let's make sure we know about it
console.error(`Couldn't postprocess elements delta.`);
if (shouldThrow()) {
throw e;
}
} finally {
return [deleted, inserted];
}
}
private static stripIrrelevantProps(
partial: Partial<OrderedExcalidrawElement>,
): ElementPartial {
const { id, updated, version, versionNonce, ...strippedPartial } = partial;
return strippedPartial;
}
}

View File

@@ -1,26 +0,0 @@
export type {
AppState,
ObservedElementsAppState,
ObservedStandaloneAppState,
ObservedAppState,
} from "@excalidraw/excalidraw/dist/excalidraw/types";
export type {
DTO,
SubtypeOf,
ValueOf,
} from "@excalidraw/excalidraw/dist/excalidraw/utility-types";
export type {
ExcalidrawElement,
ExcalidrawImageElement,
ExcalidrawTextElement,
Ordered,
OrderedExcalidrawElement,
SceneElementsMap,
ElementsMap,
} from "@excalidraw/excalidraw/dist/excalidraw/element/types";
export type { ElementUpdate } from "@excalidraw/excalidraw/dist/excalidraw/element/mutateElement";
export type {
BindableProp,
BindingProp,
} from "@excalidraw/excalidraw/dist/excalidraw/element/binding";

View File

@@ -1,5 +0,0 @@
export type { DeltaContainer } from "./common/interfaces";
export { Delta } from "./common/delta";
export { ElementsDelta } from "./containers/elements";
export { AppStateDelta } from "./containers/appstate";

View File

@@ -1,19 +0,0 @@
{
"compilerOptions": {
"target": "ESNext",
"strict": true,
"outDir": "dist/types",
"skipLibCheck": true,
"declaration": true,
"emitDeclarationOnly": true,
"allowSyntheticDefaultImports": true,
"module": "ESNext",
"moduleResolution": "Node",
},
"exclude": [
"**/*.test.*",
"**/tests/*",
"types",
"dist",
],
}

View File

@@ -1,6 +1,6 @@
{
"name": "@excalidraw/element",
"version": "0.1.0",
"version": "0.18.0",
"type": "module",
"types": "./dist/types/element/src/index.d.ts",
"main": "./dist/prod/index.js",
@@ -13,7 +13,10 @@
"default": "./dist/prod/index.js"
},
"./*": {
"types": "./../element/dist/types/element/src/*.d.ts"
"types": "./dist/types/element/src/*.d.ts",
"development": "./dist/dev/index.js",
"production": "./dist/prod/index.js",
"default": "./dist/prod/index.js"
}
},
"files": [
@@ -50,7 +53,11 @@
"bugs": "https://github.com/excalidraw/excalidraw/issues",
"repository": "https://github.com/excalidraw/excalidraw",
"scripts": {
"gen:types": "rm -rf types && tsc",
"build:esm": "rm -rf dist && node ../../scripts/buildBase.js && yarn gen:types"
"gen:types": "rimraf types && tsc",
"build:esm": "rimraf dist && node ../../scripts/buildBase.js && yarn gen:types"
},
"dependencies": {
"@excalidraw/common": "0.18.0",
"@excalidraw/math": "0.18.0"
}
}

View File

@@ -6,25 +6,21 @@ import {
toBrandedType,
isDevEnv,
isTestEnv,
isReadonlyArray,
toArray,
} from "@excalidraw/common";
import { isNonDeletedElement } from "@excalidraw/element";
import { isFrameLikeElement } from "@excalidraw/element/typeChecks";
import { getElementsInGroup } from "@excalidraw/element/groups";
import { isFrameLikeElement } from "@excalidraw/element";
import { getElementsInGroup } from "@excalidraw/element";
import {
orderByFractionalIndex,
syncInvalidIndices,
syncMovedIndices,
validateFractionalIndices,
} from "@excalidraw/element/fractionalIndex";
} from "@excalidraw/element";
import { getSelectedElements } from "@excalidraw/element/selection";
import { getSelectedElements } from "@excalidraw/element";
import {
mutateElement,
type ElementUpdate,
} from "@excalidraw/element/mutateElement";
import { mutateElement, type ElementUpdate } from "@excalidraw/element";
import type {
ExcalidrawElement,
@@ -109,7 +105,7 @@ const hashSelectionOpts = (
// in our codebase
export type ExcalidrawElementsIncludingDeleted = readonly ExcalidrawElement[];
class Scene {
export class Scene {
// ---------------------------------------------------------------------------
// instance methods/props
// ---------------------------------------------------------------------------
@@ -168,9 +164,14 @@ class Scene {
return this.frames;
}
constructor(elements: ElementsMapOrArray | null = null) {
constructor(
elements: ElementsMapOrArray | null = null,
options?: {
skipValidation?: true;
},
) {
if (elements) {
this.replaceAllElements(elements);
this.replaceAllElements(elements, options);
}
}
@@ -267,20 +268,21 @@ class Scene {
return didChange;
}
replaceAllElements(nextElements: ElementsMapOrArray) {
// ts doesn't like `Array.isArray` of `instanceof Map`
if (!isReadonlyArray(nextElements)) {
// need to order by fractional indices to get the correct order
nextElements = orderByFractionalIndex(
Array.from(nextElements.values()) as OrderedExcalidrawElement[],
);
}
replaceAllElements(
nextElements: ElementsMapOrArray,
options?: {
skipValidation?: true;
},
) {
// we do trust the insertion order on the map, though maybe we shouldn't and should prefer order defined by fractional indices
const _nextElements = toArray(nextElements);
const nextFrameLikes: ExcalidrawFrameLikeElement[] = [];
validateIndicesThrottled(nextElements);
if (!options?.skipValidation) {
validateIndicesThrottled(_nextElements);
}
this.elements = syncInvalidIndices(nextElements);
this.elements = syncInvalidIndices(_nextElements);
this.elementsMap.clear();
this.elements.forEach((element) => {
if (isFrameLikeElement(element)) {
@@ -464,5 +466,3 @@ class Scene {
return element;
}
}
export default Scene;

View File

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

View File

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

File diff suppressed because it is too large Load Diff

View File

@@ -1,17 +1,17 @@
import rough from "roughjs/bin/rough";
import {
rescalePoints,
arrayToMap,
invariant,
rescalePoints,
sizeOf,
} from "@excalidraw/common";
import {
degreesToRadians,
lineSegment,
pointFrom,
pointDistance,
pointFrom,
pointFromArray,
pointRotateRads,
} from "@excalidraw/math";
@@ -33,8 +33,8 @@ import type { AppState } from "@excalidraw/excalidraw/types";
import type { Mutable } from "@excalidraw/common/utility-types";
import { ShapeCache } from "./ShapeCache";
import { generateRoughOptions } from "./Shape";
import { generateRoughOptions } from "./shape";
import { ShapeCache } from "./shape";
import { LinearElementEditor } from "./linearElementEditor";
import { getBoundTextElement, getContainerElement } from "./textElement";
import {
@@ -42,30 +42,31 @@ import {
isBoundToContainer,
isFreeDrawElement,
isLinearElement,
isLineElement,
isTextElement,
} from "./typeChecks";
import { getElementShape } from "./shapes";
import { getElementShape } from "./shape";
import {
deconstructDiamondElement,
deconstructRectanguloidElement,
} from "./utils";
import type {
ExcalidrawElement,
ExcalidrawLinearElement,
Arrowhead,
ExcalidrawFreeDrawElement,
NonDeleted,
ExcalidrawTextElementWithContainer,
ElementsMap,
ExcalidrawRectanguloidElement,
ExcalidrawEllipseElement,
ElementsMapOrArray,
} from "./types";
import type { Drawable, Op } from "roughjs/bin/core";
import type { Point as RoughPoint } from "roughjs/bin/geometry";
import type {
Arrowhead,
ElementsMap,
ElementsMapOrArray,
ExcalidrawElement,
ExcalidrawEllipseElement,
ExcalidrawFreeDrawElement,
ExcalidrawLinearElement,
ExcalidrawRectanguloidElement,
ExcalidrawTextElementWithContainer,
NonDeleted,
} from "./types";
export type RectangleBox = {
x: number;
@@ -102,9 +103,23 @@ export class ElementBounds {
version: ExcalidrawElement["version"];
}
>();
private static nonRotatedBoundsCache = new WeakMap<
ExcalidrawElement,
{
bounds: Bounds;
version: ExcalidrawElement["version"];
}
>();
static getBounds(element: ExcalidrawElement, elementsMap: ElementsMap) {
const cachedBounds = ElementBounds.boundsCache.get(element);
static getBounds(
element: ExcalidrawElement,
elementsMap: ElementsMap,
nonRotated: boolean = false,
) {
const cachedBounds =
nonRotated && element.angle !== 0
? ElementBounds.nonRotatedBoundsCache.get(element)
: ElementBounds.boundsCache.get(element);
if (
cachedBounds?.version &&
@@ -115,6 +130,23 @@ export class ElementBounds {
) {
return cachedBounds.bounds;
}
if (nonRotated && element.angle !== 0) {
const nonRotatedBounds = ElementBounds.calculateBounds(
{
...element,
angle: 0 as Radians,
},
elementsMap,
);
ElementBounds.nonRotatedBoundsCache.set(element, {
version: element.version,
bounds: nonRotatedBounds,
});
return nonRotatedBounds;
}
const bounds = ElementBounds.calculateBounds(element, elementsMap);
ElementBounds.boundsCache.set(element, {
@@ -290,19 +322,42 @@ export const getElementLineSegments = (
if (shape.type === "polycurve") {
const curves = shape.data;
const points = curves
.map((curve) => pointsOnBezierCurves(curve, 10))
.flat();
let i = 0;
const pointsOnCurves = curves.map((curve) =>
pointsOnBezierCurves(curve, 10),
);
const segments: LineSegment<GlobalPoint>[] = [];
while (i < points.length - 1) {
segments.push(
lineSegment(
pointFrom(points[i][0], points[i][1]),
pointFrom(points[i + 1][0], points[i + 1][1]),
),
);
i++;
if (
(isLineElement(element) && !element.polygon) ||
isArrowElement(element)
) {
for (const points of pointsOnCurves) {
let i = 0;
while (i < points.length - 1) {
segments.push(
lineSegment(
pointFrom(points[i][0], points[i][1]),
pointFrom(points[i + 1][0], points[i + 1][1]),
),
);
i++;
}
}
} else {
const points = pointsOnCurves.flat();
let i = 0;
while (i < points.length - 1) {
segments.push(
lineSegment(
pointFrom(points[i][0], points[i][1]),
pointFrom(points[i + 1][0], points[i + 1][1]),
),
);
i++;
}
}
return segments;
@@ -553,7 +608,7 @@ const solveQuadratic = (
return [s1, s2];
};
const getCubicBezierCurveBound = (
export const getCubicBezierCurveBound = (
p0: GlobalPoint,
p1: GlobalPoint,
p2: GlobalPoint,
@@ -939,8 +994,9 @@ const getLinearElementRotatedBounds = (
export const getElementBounds = (
element: ExcalidrawElement,
elementsMap: ElementsMap,
nonRotated: boolean = false,
): Bounds => {
return ElementBounds.getBounds(element, elementsMap);
return ElementBounds.getBounds(element, elementsMap, nonRotated);
};
export const getCommonBounds = (
@@ -1094,7 +1150,9 @@ export interface BoundingBox {
}
export const getCommonBoundingBox = (
elements: ExcalidrawElement[] | readonly NonDeleted<ExcalidrawElement>[],
elements:
| readonly ExcalidrawElement[]
| readonly NonDeleted<ExcalidrawElement>[],
): BoundingBox => {
const [minX, minY, maxX, maxY] = getCommonBounds(elements);
return {
@@ -1133,6 +1191,71 @@ export const getCenterForBounds = (bounds: Bounds): GlobalPoint =>
bounds[1] + (bounds[3] - bounds[1]) / 2,
);
/**
* Get the axis-aligned bounding box for a given element
*/
export const aabbForElement = (
element: Readonly<ExcalidrawElement>,
elementsMap: ElementsMap,
offset?: [number, number, number, number],
) => {
const bbox = {
minX: element.x,
minY: element.y,
maxX: element.x + element.width,
maxY: element.y + element.height,
midX: element.x + element.width / 2,
midY: element.y + element.height / 2,
};
const center = elementCenterPoint(element, elementsMap);
const [topLeftX, topLeftY] = pointRotateRads(
pointFrom(bbox.minX, bbox.minY),
center,
element.angle,
);
const [topRightX, topRightY] = pointRotateRads(
pointFrom(bbox.maxX, bbox.minY),
center,
element.angle,
);
const [bottomRightX, bottomRightY] = pointRotateRads(
pointFrom(bbox.maxX, bbox.maxY),
center,
element.angle,
);
const [bottomLeftX, bottomLeftY] = pointRotateRads(
pointFrom(bbox.minX, bbox.maxY),
center,
element.angle,
);
const bounds = [
Math.min(topLeftX, topRightX, bottomRightX, bottomLeftX),
Math.min(topLeftY, topRightY, bottomRightY, bottomLeftY),
Math.max(topLeftX, topRightX, bottomRightX, bottomLeftX),
Math.max(topLeftY, topRightY, bottomRightY, bottomLeftY),
] as Bounds;
if (offset) {
const [topOffset, rightOffset, downOffset, leftOffset] = offset;
return [
bounds[0] - leftOffset,
bounds[1] - topOffset,
bounds[2] + rightOffset,
bounds[3] + downOffset,
] as Bounds;
}
return bounds;
};
export const pointInsideBounds = <P extends GlobalPoint | LocalPoint>(
p: P,
bounds: Bounds,
): boolean =>
p[0] > bounds[0] && p[0] < bounds[2] && p[1] > bounds[1] && p[1] < bounds[3];
export const doBoundsIntersect = (
bounds1: Bounds | null,
bounds2: Bounds | null,
@@ -1146,3 +1269,14 @@ export const doBoundsIntersect = (
return minX1 < maxX2 && maxX1 > minX2 && minY1 < maxY2 && maxY1 > minY2;
};
export const elementCenterPoint = (
element: ExcalidrawElement,
elementsMap: ElementsMap,
xOffset: number = 0,
yOffset: number = 0,
) => {
const [x, y] = getCenterForBounds(getElementBounds(element, elementsMap));
return pointFrom<GlobalPoint>(x + xOffset, y + yOffset);
};

View File

@@ -1,53 +1,77 @@
import { isTransparent, elementCenterPoint } from "@excalidraw/common";
import { invariant, isTransparent } from "@excalidraw/common";
import {
curveIntersectLineSegment,
isPointWithinBounds,
line,
lineSegment,
lineSegmentIntersectionPoints,
pointFrom,
pointFromVector,
pointRotateRads,
pointsEqual,
vectorFromPoint,
vectorNormalize,
vectorScale,
} from "@excalidraw/math";
import {
ellipse,
ellipseLineIntersectionPoints,
ellipseSegmentInterceptPoints,
} from "@excalidraw/math/ellipse";
import { isPointInShape, isPointOnShape } from "@excalidraw/utils/collision";
import { type GeometricShape, getPolygonShape } from "@excalidraw/utils/shape";
import type {
Curve,
GlobalPoint,
LineSegment,
LocalPoint,
Polygon,
Radians,
} from "@excalidraw/math";
import type { FrameNameBounds } from "@excalidraw/excalidraw/types";
import { getBoundTextShape, isPathALoop } from "./shapes";
import { getElementBounds } from "./bounds";
import { isPathALoop } from "./utils";
import {
type Bounds,
doBoundsIntersect,
elementCenterPoint,
getCenterForBounds,
getCubicBezierCurveBound,
getDiamondPoints,
getElementBounds,
} from "./bounds";
import {
hasBoundTextElement,
isBindableElement,
isFrameLikeElement,
isFreeDrawElement,
isIframeLikeElement,
isImageElement,
isLinearElement,
isTextElement,
} from "./typeChecks";
import {
deconstructDiamondElement,
deconstructLinearOrFreeDrawElement,
deconstructRectanguloidElement,
} from "./utils";
import { getBoundTextElement } from "./textElement";
import { LinearElementEditor } from "./linearElementEditor";
import { distanceToElement } from "./distance";
import type {
ElementsMap,
ExcalidrawBindableElement,
ExcalidrawDiamondElement,
ExcalidrawElement,
ExcalidrawEllipseElement,
ExcalidrawRectangleElement,
ExcalidrawFreeDrawElement,
ExcalidrawLinearElement,
ExcalidrawRectanguloidElement,
NonDeleted,
NonDeletedExcalidrawElement,
NonDeletedSceneElementsMap,
Ordered,
} from "./types";
export const shouldTestInside = (element: ExcalidrawElement) => {
@@ -72,45 +96,68 @@ export const shouldTestInside = (element: ExcalidrawElement) => {
return isDraggableFromInside || isImageElement(element);
};
export type HitTestArgs<Point extends GlobalPoint | LocalPoint> = {
x: number;
y: number;
export type HitTestArgs = {
point: GlobalPoint;
element: ExcalidrawElement;
shape: GeometricShape<Point>;
threshold?: number;
threshold: number;
elementsMap: ElementsMap;
frameNameBound?: FrameNameBounds | null;
overrideShouldTestInside?: boolean;
};
export const hitElementItself = <Point extends GlobalPoint | LocalPoint>({
x,
y,
export const hitElementItself = ({
point,
element,
shape,
threshold = 10,
threshold,
elementsMap,
frameNameBound = null,
}: HitTestArgs<Point>) => {
let hit = shouldTestInside(element)
? // Since `inShape` tests STRICTLY againt the insides of a shape
// we would need `onShape` as well to include the "borders"
isPointInShape(pointFrom(x, y), shape) ||
isPointOnShape(pointFrom(x, y), shape, threshold)
: isPointOnShape(pointFrom(x, y), shape, threshold);
overrideShouldTestInside = false,
}: HitTestArgs) => {
// Hit test against a frame's name
const hitFrameName = frameNameBound
? isPointWithinBounds(
pointFrom(frameNameBound.x - threshold, frameNameBound.y - threshold),
point,
pointFrom(
frameNameBound.x + frameNameBound.width + threshold,
frameNameBound.y + frameNameBound.height + threshold,
),
)
: false;
// hit test against a frame's name
if (!hit && frameNameBound) {
hit = isPointInShape(pointFrom(x, y), {
type: "polygon",
data: getPolygonShape(frameNameBound as ExcalidrawRectangleElement)
.data as Polygon<Point>,
});
// Hit test against the extended, rotated bounding box of the element first
const bounds = getElementBounds(element, elementsMap, true);
const hitBounds = isPointWithinBounds(
pointFrom(bounds[0] - threshold, bounds[1] - threshold),
pointRotateRads(
point,
getCenterForBounds(bounds),
-element.angle as Radians,
),
pointFrom(bounds[2] + threshold, bounds[3] + threshold),
);
// PERF: Bail out early if the point is not even in the
// rotated bounding box or not hitting the frame name (saves 99%)
if (!hitBounds && !hitFrameName) {
return false;
}
return hit;
// Do the precise (and relatively costly) hit test
const hitElement = (
overrideShouldTestInside ? true : shouldTestInside(element)
)
? // Since `inShape` tests STRICTLY againt the insides of a shape
// we would need `onShape` as well to include the "borders"
isPointInElement(point, element, elementsMap) ||
isPointOnElementOutline(point, element, elementsMap, threshold)
: isPointOnElementOutline(point, element, elementsMap, threshold);
return hitElement || hitFrameName;
};
export const hitElementBoundingBox = (
x: number,
y: number,
point: GlobalPoint,
element: ExcalidrawElement,
elementsMap: ElementsMap,
tolerance = 0,
@@ -120,37 +167,138 @@ export const hitElementBoundingBox = (
y1 -= tolerance;
x2 += tolerance;
y2 += tolerance;
return isPointWithinBounds(
pointFrom(x1, y1),
pointFrom(x, y),
pointFrom(x2, y2),
);
return isPointWithinBounds(pointFrom(x1, y1), point, pointFrom(x2, y2));
};
export const hitElementBoundingBoxOnly = <
Point extends GlobalPoint | LocalPoint,
>(
hitArgs: HitTestArgs<Point>,
export const hitElementBoundingBoxOnly = (
hitArgs: HitTestArgs,
elementsMap: ElementsMap,
) => {
return (
!hitElementItself(hitArgs) &&
// bound text is considered part of the element (even if it's outside the bounding box)
!hitElementBoundText(
hitArgs.x,
hitArgs.y,
getBoundTextShape(hitArgs.element, elementsMap),
) &&
hitElementBoundingBox(hitArgs.x, hitArgs.y, hitArgs.element, elementsMap)
);
) =>
!hitElementItself(hitArgs) &&
// bound text is considered part of the element (even if it's outside the bounding box)
!hitElementBoundText(hitArgs.point, hitArgs.element, elementsMap) &&
hitElementBoundingBox(hitArgs.point, hitArgs.element, elementsMap);
export const hitElementBoundText = (
point: GlobalPoint,
element: ExcalidrawElement,
elementsMap: ElementsMap,
): boolean => {
const boundTextElementCandidate = getBoundTextElement(element, elementsMap);
if (!boundTextElementCandidate) {
return false;
}
const boundTextElement = isLinearElement(element)
? {
...boundTextElementCandidate,
// arrow's bound text accurate position is not stored in the element's property
// but rather calculated and returned from the following static method
...LinearElementEditor.getBoundTextElementPosition(
element,
boundTextElementCandidate,
elementsMap,
),
}
: boundTextElementCandidate;
return isPointInElement(point, boundTextElement, elementsMap);
};
export const hitElementBoundText = <Point extends GlobalPoint | LocalPoint>(
x: number,
y: number,
textShape: GeometricShape<Point> | null,
const bindingBorderTest = (
element: NonDeleted<ExcalidrawBindableElement>,
[x, y]: Readonly<GlobalPoint>,
elementsMap: NonDeletedSceneElementsMap,
tolerance: number = 0,
): boolean => {
return !!textShape && isPointInShape(pointFrom(x, y), textShape);
const p = pointFrom<GlobalPoint>(x, y);
const shouldTestInside =
// disable fullshape snapping for frame elements so we
// can bind to frame children
!isFrameLikeElement(element);
// PERF: Run a cheap test to see if the binding element
// is even close to the element
const t = Math.max(1, tolerance);
const bounds = [x - t, y - t, x + t, y + t] as Bounds;
const elementBounds = getElementBounds(element, elementsMap);
if (!doBoundsIntersect(bounds, elementBounds)) {
return false;
}
// Do the intersection test against the element since it's close enough
const intersections = intersectElementWithLineSegment(
element,
elementsMap,
lineSegment(elementCenterPoint(element, elementsMap), p),
);
const distance = distanceToElement(element, elementsMap, p);
return shouldTestInside
? intersections.length === 0 || distance <= tolerance
: intersections.length > 0 && distance <= t;
};
export const getAllHoveredElementAtPoint = (
point: Readonly<GlobalPoint>,
elements: readonly Ordered<NonDeletedExcalidrawElement>[],
elementsMap: NonDeletedSceneElementsMap,
toleranceFn?: (element: ExcalidrawBindableElement) => number,
): NonDeleted<ExcalidrawBindableElement>[] => {
const candidateElements: NonDeleted<ExcalidrawBindableElement>[] = [];
// We need to to hit testing from front (end of the array) to back (beginning of the array)
// because array is ordered from lower z-index to highest and we want element z-index
// with higher z-index
for (let index = elements.length - 1; index >= 0; --index) {
const element = elements[index];
invariant(
!element.isDeleted,
"Elements in the function parameter for getAllElementsAtPositionForBinding() should not contain deleted elements",
);
if (
isBindableElement(element, false) &&
bindingBorderTest(element, point, elementsMap, toleranceFn?.(element))
) {
candidateElements.push(element);
if (!isTransparent(element.backgroundColor)) {
break;
}
}
}
return candidateElements;
};
export const getHoveredElementForBinding = (
point: Readonly<GlobalPoint>,
elements: readonly Ordered<NonDeletedExcalidrawElement>[],
elementsMap: NonDeletedSceneElementsMap,
toleranceFn?: (element: ExcalidrawBindableElement) => number,
): NonDeleted<ExcalidrawBindableElement> | null => {
const candidateElements = getAllHoveredElementAtPoint(
point,
elements,
elementsMap,
toleranceFn,
);
if (!candidateElements || candidateElements.length === 0) {
return null;
}
if (candidateElements.length === 1) {
return candidateElements[0];
}
// Prefer smaller shapes
return candidateElements
.sort(
(a, b) => b.width ** 2 + b.height ** 2 - (a.width ** 2 + a.height ** 2),
)
.pop() as NonDeleted<ExcalidrawBindableElement>;
};
/**
@@ -163,9 +311,26 @@ export const hitElementBoundText = <Point extends GlobalPoint | LocalPoint>(
*/
export const intersectElementWithLineSegment = (
element: ExcalidrawElement,
elementsMap: ElementsMap,
line: LineSegment<GlobalPoint>,
offset: number = 0,
onlyFirst = false,
): GlobalPoint[] => {
// First check if the line intersects the element's axis-aligned bounding box
// as it is much faster than checking intersection against the element's shape
const intersectorBounds = [
Math.min(line[0][0] - offset, line[1][0] - offset),
Math.min(line[0][1] - offset, line[1][1] - offset),
Math.max(line[0][0] + offset, line[1][0] + offset),
Math.max(line[0][1] + offset, line[1][1] + offset),
] as Bounds;
const elementBounds = getElementBounds(element, elementsMap);
if (!doBoundsIntersect(intersectorBounds, elementBounds)) {
return [];
}
// Do the actual intersection test against the element's shape
switch (element.type) {
case "rectangle":
case "image":
@@ -173,67 +338,196 @@ export const intersectElementWithLineSegment = (
case "iframe":
case "embeddable":
case "frame":
case "selection":
case "magicframe":
return intersectRectanguloidWithLineSegment(element, line, offset);
return intersectRectanguloidWithLineSegment(
element,
elementsMap,
line,
offset,
onlyFirst,
);
case "diamond":
return intersectDiamondWithLineSegment(element, line, offset);
return intersectDiamondWithLineSegment(
element,
elementsMap,
line,
offset,
onlyFirst,
);
case "ellipse":
return intersectEllipseWithLineSegment(element, line, offset);
default:
throw new Error(`Unimplemented element type '${element.type}'`);
return intersectEllipseWithLineSegment(
element,
elementsMap,
line,
offset,
);
case "line":
case "freedraw":
case "arrow":
return intersectLinearOrFreeDrawWithLineSegment(element, line, onlyFirst);
}
};
const curveIntersections = (
curves: Curve<GlobalPoint>[],
segment: LineSegment<GlobalPoint>,
intersections: GlobalPoint[],
center: GlobalPoint,
angle: Radians,
onlyFirst = false,
) => {
for (const c of curves) {
// Optimize by doing a cheap bounding box check first
const b1 = getCubicBezierCurveBound(c[0], c[1], c[2], c[3]);
const b2 = [
Math.min(segment[0][0], segment[1][0]),
Math.min(segment[0][1], segment[1][1]),
Math.max(segment[0][0], segment[1][0]),
Math.max(segment[0][1], segment[1][1]),
] as Bounds;
if (!doBoundsIntersect(b1, b2)) {
continue;
}
const hits = curveIntersectLineSegment(c, segment);
if (hits.length > 0) {
for (const j of hits) {
intersections.push(pointRotateRads(j, center, angle));
}
if (onlyFirst) {
return intersections;
}
}
}
return intersections;
};
const lineIntersections = (
lines: LineSegment<GlobalPoint>[],
segment: LineSegment<GlobalPoint>,
intersections: GlobalPoint[],
center: GlobalPoint,
angle: Radians,
onlyFirst = false,
) => {
for (const l of lines) {
const intersection = lineSegmentIntersectionPoints(l, segment);
if (intersection) {
intersections.push(pointRotateRads(intersection, center, angle));
if (onlyFirst) {
return intersections;
}
}
}
return intersections;
};
const intersectLinearOrFreeDrawWithLineSegment = (
element: ExcalidrawLinearElement | ExcalidrawFreeDrawElement,
segment: LineSegment<GlobalPoint>,
onlyFirst = false,
): GlobalPoint[] => {
// NOTE: This is the only one which return the decomposed elements
// rotated! This is due to taking advantage of roughjs definitions.
const [lines, curves] = deconstructLinearOrFreeDrawElement(element);
const intersections: GlobalPoint[] = [];
for (const l of lines) {
const intersection = lineSegmentIntersectionPoints(l, segment);
if (intersection) {
intersections.push(intersection);
if (onlyFirst) {
return intersections;
}
}
}
for (const c of curves) {
// Optimize by doing a cheap bounding box check first
const b1 = getCubicBezierCurveBound(c[0], c[1], c[2], c[3]);
const b2 = [
Math.min(segment[0][0], segment[1][0]),
Math.min(segment[0][1], segment[1][1]),
Math.max(segment[0][0], segment[1][0]),
Math.max(segment[0][1], segment[1][1]),
] as Bounds;
if (!doBoundsIntersect(b1, b2)) {
continue;
}
const hits = curveIntersectLineSegment(c, segment);
if (hits.length > 0) {
intersections.push(...hits);
if (onlyFirst) {
return intersections;
}
}
}
return intersections;
};
const intersectRectanguloidWithLineSegment = (
element: ExcalidrawRectanguloidElement,
l: LineSegment<GlobalPoint>,
elementsMap: ElementsMap,
segment: LineSegment<GlobalPoint>,
offset: number = 0,
onlyFirst = false,
): GlobalPoint[] => {
const center = elementCenterPoint(element);
const center = elementCenterPoint(element, elementsMap);
// To emulate a rotated rectangle we rotate the point in the inverse angle
// instead. It's all the same distance-wise.
const rotatedA = pointRotateRads<GlobalPoint>(
l[0],
segment[0],
center,
-element.angle as Radians,
);
const rotatedB = pointRotateRads<GlobalPoint>(
l[1],
segment[1],
center,
-element.angle as Radians,
);
const rotatedIntersector = lineSegment(rotatedA, rotatedB);
// Get the element's building components we can test against
const [sides, corners] = deconstructRectanguloidElement(element, offset);
return (
// Test intersection against the sides, keep only the valid
// intersection points and rotate them back to scene space
sides
.map((s) =>
lineSegmentIntersectionPoints(
lineSegment<GlobalPoint>(rotatedA, rotatedB),
s,
),
)
.filter((x) => x != null)
.map((j) => pointRotateRads<GlobalPoint>(j!, center, element.angle))
// Test intersection against the corners which are cubic bezier curves,
// keep only the valid intersection points and rotate them back to scene
// space
.concat(
corners
.flatMap((t) =>
curveIntersectLineSegment(t, lineSegment(rotatedA, rotatedB)),
)
.filter((i) => i != null)
.map((j) => pointRotateRads(j, center, element.angle)),
)
// Remove duplicates
.filter(
(p, idx, points) => points.findIndex((d) => pointsEqual(p, d)) === idx,
)
const intersections: GlobalPoint[] = [];
lineIntersections(
sides,
rotatedIntersector,
intersections,
center,
element.angle,
onlyFirst,
);
if (onlyFirst && intersections.length > 0) {
return intersections;
}
curveIntersections(
corners,
rotatedIntersector,
intersections,
center,
element.angle,
onlyFirst,
);
return intersections;
};
/**
@@ -245,43 +539,45 @@ const intersectRectanguloidWithLineSegment = (
*/
const intersectDiamondWithLineSegment = (
element: ExcalidrawDiamondElement,
elementsMap: ElementsMap,
l: LineSegment<GlobalPoint>,
offset: number = 0,
onlyFirst = false,
): GlobalPoint[] => {
const center = elementCenterPoint(element);
const center = elementCenterPoint(element, elementsMap);
// Rotate the point to the inverse direction to simulate the rotated diamond
// points. It's all the same distance-wise.
const rotatedA = pointRotateRads(l[0], center, -element.angle as Radians);
const rotatedB = pointRotateRads(l[1], center, -element.angle as Radians);
const rotatedIntersector = lineSegment(rotatedA, rotatedB);
const [sides, curves] = deconstructDiamondElement(element, offset);
const [sides, corners] = deconstructDiamondElement(element, offset);
const intersections: GlobalPoint[] = [];
return (
sides
.map((s) =>
lineSegmentIntersectionPoints(
lineSegment<GlobalPoint>(rotatedA, rotatedB),
s,
),
)
.filter((p): p is GlobalPoint => p != null)
// Rotate back intersection points
.map((p) => pointRotateRads<GlobalPoint>(p!, center, element.angle))
.concat(
curves
.flatMap((p) =>
curveIntersectLineSegment(p, lineSegment(rotatedA, rotatedB)),
)
.filter((p) => p != null)
// Rotate back intersection points
.map((p) => pointRotateRads(p, center, element.angle)),
)
// Remove duplicates
.filter(
(p, idx, points) => points.findIndex((d) => pointsEqual(p, d)) === idx,
)
lineIntersections(
sides,
rotatedIntersector,
intersections,
center,
element.angle,
onlyFirst,
);
if (onlyFirst && intersections.length > 0) {
return intersections;
}
curveIntersections(
corners,
rotatedIntersector,
intersections,
center,
element.angle,
onlyFirst,
);
return intersections;
};
/**
@@ -293,16 +589,134 @@ const intersectDiamondWithLineSegment = (
*/
const intersectEllipseWithLineSegment = (
element: ExcalidrawEllipseElement,
elementsMap: ElementsMap,
l: LineSegment<GlobalPoint>,
offset: number = 0,
): GlobalPoint[] => {
const center = elementCenterPoint(element);
const center = elementCenterPoint(element, elementsMap);
const rotatedA = pointRotateRads(l[0], center, -element.angle as Radians);
const rotatedB = pointRotateRads(l[1], center, -element.angle as Radians);
return ellipseLineIntersectionPoints(
return ellipseSegmentInterceptPoints(
ellipse(center, element.width / 2 + offset, element.height / 2 + offset),
line(rotatedA, rotatedB),
lineSegment(rotatedA, rotatedB),
).map((p) => pointRotateRads(p, center, element.angle));
};
/**
* Check if the given point is considered on the given shape's border
*
* @param point
* @param element
* @param tolerance
* @returns
*/
const isPointOnElementOutline = (
point: GlobalPoint,
element: ExcalidrawElement,
elementsMap: ElementsMap,
tolerance = 1,
) => distanceToElement(element, elementsMap, point) <= tolerance;
/**
* Check if the given point is considered inside the element's border
*
* @param point
* @param element
* @returns
*/
export const isPointInElement = (
point: GlobalPoint,
element: ExcalidrawElement,
elementsMap: ElementsMap,
) => {
if (
(isLinearElement(element) || isFreeDrawElement(element)) &&
!isPathALoop(element.points)
) {
// There isn't any "inside" for a non-looping path
return false;
}
const [x1, y1, x2, y2] = getElementBounds(element, elementsMap);
if (!isPointWithinBounds(pointFrom(x1, y1), point, pointFrom(x2, y2))) {
return false;
}
const center = pointFrom<GlobalPoint>((x1 + x2) / 2, (y1 + y2) / 2);
const otherPoint = pointFromVector(
vectorScale(
vectorNormalize(vectorFromPoint(point, center, 0.1)),
Math.max(element.width, element.height) * 2,
),
center,
);
const intersector = lineSegment(point, otherPoint);
const intersections = intersectElementWithLineSegment(
element,
elementsMap,
intersector,
).filter((p, pos, arr) => arr.findIndex((q) => pointsEqual(q, p)) === pos);
return intersections.length % 2 === 1;
};
export const isBindableElementInsideOtherBindable = (
innerElement: ExcalidrawBindableElement,
outerElement: ExcalidrawBindableElement,
elementsMap: ElementsMap,
): boolean => {
// Get corner points of the inner element based on its type
const getCornerPoints = (
element: ExcalidrawElement,
offset: number,
): GlobalPoint[] => {
const { x, y, width, height, angle } = element;
const center = elementCenterPoint(element, elementsMap);
if (element.type === "diamond") {
// Diamond has 4 corner points at the middle of each side
const [topX, topY, rightX, rightY, bottomX, bottomY, leftX, leftY] =
getDiamondPoints(element);
const corners: GlobalPoint[] = [
pointFrom(x + topX, y + topY - offset), // top
pointFrom(x + rightX + offset, y + rightY), // right
pointFrom(x + bottomX, y + bottomY + offset), // bottom
pointFrom(x + leftX - offset, y + leftY), // left
];
return corners.map((corner) => pointRotateRads(corner, center, angle));
}
if (element.type === "ellipse") {
// For ellipse, test points at the extremes (top, right, bottom, left)
const cx = x + width / 2;
const cy = y + height / 2;
const rx = width / 2;
const ry = height / 2;
const corners: GlobalPoint[] = [
pointFrom(cx, cy - ry - offset), // top
pointFrom(cx + rx + offset, cy), // right
pointFrom(cx, cy + ry + offset), // bottom
pointFrom(cx - rx - offset, cy), // left
];
return corners.map((corner) => pointRotateRads(corner, center, angle));
}
// Rectangle and other rectangular shapes (image, text, etc.)
const corners: GlobalPoint[] = [
pointFrom(x - offset, y - offset), // top-left
pointFrom(x + width + offset, y - offset), // top-right
pointFrom(x + width + offset, y + height + offset), // bottom-right
pointFrom(x - offset, y + height + offset), // bottom-left
];
return corners.map((corner) => pointRotateRads(corner, center, angle));
};
const offset = (-1 * Math.max(innerElement.width, innerElement.height)) / 20; // 5% offset
const innerCorners = getCornerPoints(innerElement, offset);
// Check if all corner points of the inner element are inside the outer element
return innerCorners.every((corner) =>
isPointInElement(corner, outerElement, elementsMap),
);
};

View File

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

File diff suppressed because it is too large Load Diff

View File

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

View File

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

View File

@@ -2,6 +2,7 @@ import {
TEXT_AUTOWRAP_THRESHOLD,
getGridPoint,
getFontString,
DRAGGING_THRESHOLD,
} from "@excalidraw/common";
import type {
@@ -13,7 +14,7 @@ import type {
import type { NonDeletedExcalidrawElement } from "@excalidraw/element/types";
import { updateBoundElements } from "./binding";
import { unbindBindingElement, updateBoundElements } from "./binding";
import { getCommonBounds } from "./bounds";
import { getPerfectElementSize } from "./sizeHelpers";
import { getBoundTextElement } from "./textElement";
@@ -26,7 +27,7 @@ import {
isTextElement,
} from "./typeChecks";
import type Scene from "./Scene";
import type { Scene } from "./Scene";
import type { Bounds } from "./bounds";
import type { ExcalidrawElement } from "./types";
@@ -102,9 +103,26 @@ export const dragSelectedElements = (
gridSize,
);
const elementsToUpdateIds = new Set(
Array.from(elementsToUpdate, (el) => el.id),
);
elementsToUpdate.forEach((element) => {
updateElementCoords(pointerDownState, element, scene, adjustedOffset);
const isArrow = !isArrowElement(element);
const isStartBoundElementSelected =
isArrow ||
(element.startBinding
? elementsToUpdateIds.has(element.startBinding.elementId)
: false);
const isEndBoundElementSelected =
isArrow ||
(element.endBinding
? elementsToUpdateIds.has(element.endBinding.elementId)
: false);
if (!isArrowElement(element)) {
updateElementCoords(pointerDownState, element, scene, adjustedOffset);
// skip arrow labels since we calculate its position during render
const textElement = getBoundTextElement(
element,
@@ -121,6 +139,33 @@ export const dragSelectedElements = (
updateBoundElements(element, scene, {
simultaneouslyUpdated: Array.from(elementsToUpdate),
});
} else if (
// NOTE: Add a little initial drag to the arrow dragging when the arrow
// is the single element being dragged to avoid accidentally unbinding
// the arrow when the user just wants to select it.
elementsToUpdate.size > 1 ||
Math.max(Math.abs(adjustedOffset.x), Math.abs(adjustedOffset.y)) >
DRAGGING_THRESHOLD ||
(!element.startBinding && !element.endBinding)
) {
updateElementCoords(pointerDownState, element, scene, adjustedOffset);
const shouldUnbindStart =
element.startBinding && !isStartBoundElementSelected;
const shouldUnbindEnd = element.endBinding && !isEndBoundElementSelected;
if (shouldUnbindStart || shouldUnbindEnd) {
// NOTE: Moving the bound arrow should unbind it, otherwise we would
// have weird situations, like 0 lenght arrow when the user moves
// the arrow outside a filled shape suddenly forcing the arrow start
// and end point to jump "outside" the shape.
if (shouldUnbindStart) {
unbindBindingElement(element, "start", scene);
}
if (shouldUnbindEnd) {
unbindBindingElement(element, "end", scene);
}
}
}
});
};

View File

@@ -17,9 +17,9 @@ import {
BinaryHeap,
invariant,
isAnyTrue,
tupleToCoors,
getSizeFromPoints,
isDevEnv,
arrayToMap,
} from "@excalidraw/common";
import type { AppState } from "@excalidraw/excalidraw/types";
@@ -29,10 +29,9 @@ import {
FIXED_BINDING_DISTANCE,
getHeadingForElbowArrowSnap,
getGlobalFixedPointForBindableElement,
snapToMid,
getHoveredElementForBinding,
getFixedBindingDistance,
} from "./binding";
import { distanceToBindableElement } from "./distance";
import { distanceToElement } from "./distance";
import {
compareHeading,
flipHeading,
@@ -51,8 +50,8 @@ import {
type ExcalidrawElbowArrowElement,
type NonDeletedSceneElementsMap,
} from "./types";
import { aabbForElement, pointInsideBounds } from "./shapes";
import { aabbForElement, pointInsideBounds } from "./bounds";
import { getHoveredElementForBinding } from "./collision";
import type { Bounds } from "./bounds";
import type { Heading } from "./heading";
@@ -63,6 +62,7 @@ import type {
FixedPointBinding,
FixedSegment,
NonDeletedExcalidrawElement,
Ordered,
} from "./types";
type GridAddress = [number, number] & { _brand: "gridaddress" };
@@ -359,6 +359,12 @@ const handleSegmentRelease = (
null,
);
if (!restoredPoints || restoredPoints.length < 2) {
throw new Error(
"Property 'points' is required in the update returned by normalizeArrowElementUpdate()",
);
}
const nextPoints: GlobalPoint[] = [];
// First part of the arrow are the old points
@@ -706,7 +712,7 @@ const handleEndpointDrag = (
endGlobalPoint: GlobalPoint,
hoveredStartElement: ExcalidrawBindableElement | null,
hoveredEndElement: ExcalidrawBindableElement | null,
) => {
): ElementUpdate<ExcalidrawElbowArrowElement> => {
let startIsSpecial = arrow.startIsSpecial ?? null;
let endIsSpecial = arrow.endIsSpecial ?? null;
const globalUpdatedPoints = updatedPoints.map((p, i) =>
@@ -741,8 +747,15 @@ const handleEndpointDrag = (
// Calculate the moving second point connection and add the start point
{
const secondPoint = globalUpdatedPoints[startIsSpecial ? 2 : 1];
const thirdPoint = globalUpdatedPoints[startIsSpecial ? 3 : 2];
const secondPoint = globalUpdatedPoints.at(startIsSpecial ? 2 : 1);
const thirdPoint = globalUpdatedPoints.at(startIsSpecial ? 3 : 2);
if (!secondPoint || !thirdPoint) {
throw new Error(
`Second and third points must exist when handling endpoint drag (${startIsSpecial})`,
);
}
const startIsHorizontal = headingIsHorizontal(startHeading);
const secondIsHorizontal = headingIsHorizontal(
vectorToHeading(vectorFromPoint(secondPoint, thirdPoint)),
@@ -801,10 +814,19 @@ const handleEndpointDrag = (
// Calculate the moving second to last point connection
{
const secondToLastPoint =
globalUpdatedPoints[globalUpdatedPoints.length - (endIsSpecial ? 3 : 2)];
const thirdToLastPoint =
globalUpdatedPoints[globalUpdatedPoints.length - (endIsSpecial ? 4 : 3)];
const secondToLastPoint = globalUpdatedPoints.at(
globalUpdatedPoints.length - (endIsSpecial ? 3 : 2),
);
const thirdToLastPoint = globalUpdatedPoints.at(
globalUpdatedPoints.length - (endIsSpecial ? 4 : 3),
);
if (!secondToLastPoint || !thirdToLastPoint) {
throw new Error(
`Second and third to last points must exist when handling endpoint drag (${endIsSpecial})`,
);
}
const endIsHorizontal = headingIsHorizontal(endHeading);
const secondIsHorizontal = headingForPointIsHorizontal(
thirdToLastPoint,
@@ -898,50 +920,6 @@ export const updateElbowArrowPoints = (
return { points: updates.points ?? arrow.points };
}
// NOTE (mtolmacs): This is a temporary check to ensure that the incoming elbow
// arrow size is valid. This check will be removed once the issue is identified
if (
arrow.x < -MAX_POS ||
arrow.x > MAX_POS ||
arrow.y < -MAX_POS ||
arrow.y > MAX_POS ||
arrow.x + (updates?.points?.[updates?.points?.length - 1]?.[0] ?? 0) <
-MAX_POS ||
arrow.x + (updates?.points?.[updates?.points?.length - 1]?.[0] ?? 0) >
MAX_POS ||
arrow.y + (updates?.points?.[updates?.points?.length - 1]?.[1] ?? 0) <
-MAX_POS ||
arrow.y + (updates?.points?.[updates?.points?.length - 1]?.[1] ?? 0) >
MAX_POS ||
arrow.x + (arrow?.points?.[arrow?.points?.length - 1]?.[0] ?? 0) <
-MAX_POS ||
arrow.x + (arrow?.points?.[arrow?.points?.length - 1]?.[0] ?? 0) >
MAX_POS ||
arrow.y + (arrow?.points?.[arrow?.points?.length - 1]?.[1] ?? 0) <
-MAX_POS ||
arrow.y + (arrow?.points?.[arrow?.points?.length - 1]?.[1] ?? 0) > MAX_POS
) {
console.error(
"Elbow arrow (or update) is outside reasonable bounds (> 1e6)",
{
arrow,
updates,
},
);
}
// @ts-ignore See above note
arrow.x = clamp(arrow.x, -MAX_POS, MAX_POS);
// @ts-ignore See above note
arrow.y = clamp(arrow.y, -MAX_POS, MAX_POS);
if (updates.points) {
updates.points = updates.points.map(([x, y]) =>
pointFrom<LocalPoint>(
clamp(x, -MAX_POS, MAX_POS),
clamp(y, -MAX_POS, MAX_POS),
),
);
}
if (!import.meta.env.PROD) {
invariant(
!updates.points || updates.points.length >= 2,
@@ -974,6 +952,25 @@ export const updateElbowArrowPoints = (
),
"Elbow arrow segments must be either horizontal or vertical",
);
invariant(
updates.fixedSegments?.find(
(segment) =>
segment.index === 1 &&
pointsEqual(segment.start, (updates.points ?? arrow.points)[0]),
) == null &&
updates.fixedSegments?.find(
(segment) =>
segment.index === (updates.points ?? arrow.points).length - 1 &&
pointsEqual(
segment.end,
(updates.points ?? arrow.points)[
(updates.points ?? arrow.points).length - 1
],
),
) == null,
"The first and last segments cannot be fixed",
);
}
const fixedSegments = updates.fixedSegments ?? arrow.fixedSegments ?? [];
@@ -1220,19 +1217,9 @@ const getElbowArrowData = (
if (options?.isDragging) {
const elements = Array.from(elementsMap.values());
hoveredStartElement =
getHoveredElement(
origStartGlobalPoint,
elementsMap,
elements,
options?.zoom,
) || null;
getHoveredElement(origStartGlobalPoint, elementsMap, elements) || null;
hoveredEndElement =
getHoveredElement(
origEndGlobalPoint,
elementsMap,
elements,
options?.zoom,
) || null;
getHoveredElement(origEndGlobalPoint, elementsMap, elements) || null;
} else {
hoveredStartElement = arrow.startBinding
? getBindableElementForId(arrow.startBinding.elementId, elementsMap) ||
@@ -1254,6 +1241,7 @@ const getElbowArrowData = (
arrow.startBinding?.fixedPoint,
origStartGlobalPoint,
hoveredStartElement,
elementsMap,
options?.isDragging,
);
const endGlobalPoint = getGlobalPoint(
@@ -1267,6 +1255,7 @@ const getElbowArrowData = (
arrow.endBinding?.fixedPoint,
origEndGlobalPoint,
hoveredEndElement,
elementsMap,
options?.isDragging,
);
const startHeading = getBindPointHeading(
@@ -1274,12 +1263,14 @@ const getElbowArrowData = (
endGlobalPoint,
hoveredStartElement,
origStartGlobalPoint,
elementsMap,
);
const endHeading = getBindPointHeading(
endGlobalPoint,
startGlobalPoint,
hoveredEndElement,
origEndGlobalPoint,
elementsMap,
);
const startPointBounds = [
startGlobalPoint[0] - 2,
@@ -1296,11 +1287,12 @@ const getElbowArrowData = (
const startElementBounds = hoveredStartElement
? aabbForElement(
hoveredStartElement,
elementsMap,
offsetFromHeading(
startHeading,
arrow.startArrowhead
? FIXED_BINDING_DISTANCE * 6
: FIXED_BINDING_DISTANCE * 2,
? getFixedBindingDistance(hoveredStartElement) * 6
: getFixedBindingDistance(hoveredStartElement) * 2,
1,
),
)
@@ -1308,11 +1300,12 @@ const getElbowArrowData = (
const endElementBounds = hoveredEndElement
? aabbForElement(
hoveredEndElement,
elementsMap,
offsetFromHeading(
endHeading,
arrow.endArrowhead
? FIXED_BINDING_DISTANCE * 6
: FIXED_BINDING_DISTANCE * 2,
? getFixedBindingDistance(hoveredEndElement) * 6
: getFixedBindingDistance(hoveredEndElement) * 2,
1,
),
)
@@ -1323,6 +1316,7 @@ const getElbowArrowData = (
hoveredEndElement
? aabbForElement(
hoveredEndElement,
elementsMap,
offsetFromHeading(endHeading, BASE_PADDING, BASE_PADDING),
)
: endPointBounds,
@@ -1332,6 +1326,7 @@ const getElbowArrowData = (
hoveredStartElement
? aabbForElement(
hoveredStartElement,
elementsMap,
offsetFromHeading(startHeading, BASE_PADDING, BASE_PADDING),
)
: startPointBounds,
@@ -1378,8 +1373,8 @@ const getElbowArrowData = (
BASE_PADDING,
),
boundsOverlap,
hoveredStartElement && aabbForElement(hoveredStartElement),
hoveredEndElement && aabbForElement(hoveredEndElement),
hoveredStartElement && aabbForElement(hoveredStartElement, elementsMap),
hoveredEndElement && aabbForElement(hoveredEndElement, elementsMap),
);
const startDonglePosition = getDonglePosition(
dynamicAABBs[0],
@@ -2088,16 +2083,7 @@ const normalizeArrowElementUpdate = (
nextFixedSegments: readonly FixedSegment[] | null,
startIsSpecial?: ExcalidrawElbowArrowElement["startIsSpecial"],
endIsSpecial?: ExcalidrawElbowArrowElement["startIsSpecial"],
): {
points: LocalPoint[];
x: number;
y: number;
width: number;
height: number;
fixedSegments: readonly FixedSegment[] | null;
startIsSpecial?: ExcalidrawElbowArrowElement["startIsSpecial"];
endIsSpecial?: ExcalidrawElbowArrowElement["startIsSpecial"];
} => {
): ElementUpdate<ExcalidrawElbowArrowElement> => {
const offsetX = global[0][0];
const offsetY = global[0][1];
let points = global.map((p) =>
@@ -2210,35 +2196,28 @@ const getGlobalPoint = (
fixedPointRatio: [number, number] | undefined | null,
initialPoint: GlobalPoint,
element?: ExcalidrawBindableElement | null,
elementsMap?: ElementsMap,
isDragging?: boolean,
): GlobalPoint => {
if (isDragging) {
if (element) {
const snapPoint = bindPointToSnapToElementOutline(
if (element && elementsMap) {
return bindPointToSnapToElementOutline(
arrow,
element,
startOrEnd,
elementsMap,
);
return snapToMid(element, snapPoint);
}
return initialPoint;
}
if (element) {
const fixedGlobalPoint = getGlobalFixedPointForBindableElement(
return getGlobalFixedPointForBindableElement(
fixedPointRatio || [0, 0],
element,
elementsMap ?? arrayToMap([element]),
);
// NOTE: Resize scales the binding position point too, so we need to update it
return Math.abs(
distanceToBindableElement(element, fixedGlobalPoint) -
FIXED_BINDING_DISTANCE,
) > 0.01
? bindPointToSnapToElementOutline(arrow, element, startOrEnd)
: fixedGlobalPoint;
}
return initialPoint;
@@ -2249,6 +2228,7 @@ const getBindPointHeading = (
otherPoint: GlobalPoint,
hoveredElement: ExcalidrawBindableElement | null | undefined,
origPoint: GlobalPoint,
elementsMap: ElementsMap,
): Heading =>
getHeadingForElbowArrowSnap(
p,
@@ -2257,7 +2237,8 @@ const getBindPointHeading = (
hoveredElement &&
aabbForElement(
hoveredElement,
Array(4).fill(distanceToBindableElement(hoveredElement, p)) as [
elementsMap,
Array(4).fill(distanceToElement(hoveredElement, elementsMap, p)) as [
number,
number,
number,
@@ -2265,21 +2246,19 @@ const getBindPointHeading = (
],
),
origPoint,
elementsMap,
);
const getHoveredElement = (
origPoint: GlobalPoint,
elementsMap: NonDeletedSceneElementsMap,
elements: readonly NonDeletedExcalidrawElement[],
zoom?: AppState["zoom"],
elements: readonly Ordered<NonDeletedExcalidrawElement>[],
) => {
return getHoveredElementForBinding(
tupleToCoors(origPoint),
origPoint,
elements,
elementsMap,
zoom,
true,
true,
(element) => getFixedBindingDistance(element) + 1,
);
};

View File

@@ -23,7 +23,7 @@ type IframeDataWithSandbox = MarkRequired<IframeData, "sandbox">;
const embeddedLinkCache = new Map<string, IframeDataWithSandbox>();
const RE_YOUTUBE =
/^(?:http(?:s)?:\/\/)?(?:www\.)?youtu(?:be\.com|\.be)\/(embed\/|watch\?v=|shorts\/|playlist\?list=|embed\/videoseries\?list=)?([a-zA-Z0-9_-]+)(?:\?t=|&t=|\?start=|&start=)?([a-zA-Z0-9_-]+)?[^\s]*$/;
/^(?:http(?:s)?:\/\/)?(?:www\.)?youtu(?:be\.com|\.be)\/(embed\/|watch\?v=|shorts\/|playlist\?list=|embed\/videoseries\?list=)?([a-zA-Z0-9_-]+)/;
const RE_VIMEO =
/^(?:http(?:s)?:\/\/)?(?:(?:w){3}\.)?(?:player\.)?vimeo\.com\/(?:video\/)?([^?\s]+)(?:\?.*)?$/;
@@ -33,6 +33,8 @@ const RE_GH_GIST = /^https:\/\/gist\.github\.com\/([\w_-]+)\/([\w_-]+)/;
const RE_GH_GIST_EMBED =
/^<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
const RE_TWITTER =
/(?:https?:\/\/)?(?:(?:w){3}\.)?(?:twitter|x)\.com\/[^/]+\/status\/(\d+)/;
@@ -54,6 +56,35 @@ const RE_REDDIT =
const RE_REDDIT_EMBED =
/^<blockquote[\s\S]*?\shref=["'](https?:\/\/(?:www\.)?reddit\.com\/[^"']*)/i;
const parseYouTubeTimestamp = (url: string): number => {
let timeParam: string | null | undefined;
try {
const urlObj = new URL(url.startsWith("http") ? url : `https://${url}`);
timeParam =
urlObj.searchParams.get("t") || urlObj.searchParams.get("start");
} catch (error) {
const timeMatch = url.match(/[?&#](?:t|start)=([^&#\s]+)/);
timeParam = timeMatch?.[1];
}
if (!timeParam) {
return 0;
}
if (/^\d+$/.test(timeParam)) {
return parseInt(timeParam, 10);
}
const timeMatch = timeParam.match(/^(?:(\d+)h)?(?:(\d+)m)?(?:(\d+)s)?$/);
if (!timeMatch) {
return 0;
}
const [, hours = "0", minutes = "0", seconds = "0"] = timeMatch;
return parseInt(hours) * 3600 + parseInt(minutes) * 60 + parseInt(seconds);
};
const ALLOWED_DOMAINS = new Set([
"youtube.com",
"youtu.be",
@@ -69,6 +100,7 @@ const ALLOWED_DOMAINS = new Set([
"val.town",
"giphy.com",
"reddit.com",
"forms.microsoft.com",
]);
const ALLOW_SAME_ORIGIN = new Set([
@@ -82,6 +114,7 @@ const ALLOW_SAME_ORIGIN = new Set([
"*.simplepdf.eu",
"stackblitz.com",
"reddit.com",
"forms.microsoft.com",
]);
export const createSrcDoc = (body: string) => {
@@ -109,7 +142,8 @@ export const getEmbedLink = (
let aspectRatio = { w: 560, h: 840 };
const ytLink = link.match(RE_YOUTUBE);
if (ytLink?.[2]) {
const time = ytLink[3] ? `&start=${ytLink[3]}` : ``;
const startTime = parseYouTubeTimestamp(originalLink);
const time = startTime > 0 ? `&start=${startTime}` : ``;
const isPortrait = link.includes("shorts");
type = "video";
switch (ytLink[1]) {
@@ -206,6 +240,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)) {
const postId = link.match(RE_TWITTER)![1];
// the embed srcdoc still supports twitter.com domain only.

View File

@@ -7,7 +7,7 @@ import type {
PendingExcalidrawElements,
} from "@excalidraw/excalidraw/types";
import { bindLinearElement } from "./binding";
import { bindBindingElement } from "./binding";
import { updateElbowArrowPoints } from "./elbowArrow";
import {
HEADING_DOWN,
@@ -21,7 +21,7 @@ import {
import { LinearElementEditor } from "./linearElementEditor";
import { mutateElement } from "./mutateElement";
import { newArrowElement, newElement } from "./newElement";
import { aabbForElement } from "./shapes";
import { aabbForElement } from "./bounds";
import { elementsAreInFrameBounds, elementOverlapsWithFrame } from "./frame";
import {
isBindableElement,
@@ -39,7 +39,7 @@ import {
type OrderedExcalidrawElement,
} from "./types";
import type Scene from "./Scene";
import type { Scene } from "./Scene";
type LinkDirection = "up" | "right" | "down" | "left";
@@ -95,10 +95,11 @@ const getNodeRelatives = (
type === "predecessors" ? el.points[el.points.length - 1] : [0, 0]
) as Readonly<LocalPoint>;
const heading = headingForPointFromElement(node, aabbForElement(node), [
edgePoint[0] + el.x,
edgePoint[1] + el.y,
] as Readonly<GlobalPoint>);
const heading = headingForPointFromElement(
node,
aabbForElement(node, elementsMap),
[edgePoint[0] + el.x, edgePoint[1] + el.y] as Readonly<GlobalPoint>,
);
acc.push({
relative,
@@ -445,8 +446,14 @@ const createBindingArrow = (
const elementsMap = scene.getNonDeletedElementsMap();
bindLinearElement(bindingArrow, startBindingElement, "start", scene);
bindLinearElement(bindingArrow, endBindingElement, "end", scene);
bindBindingElement(
bindingArrow,
startBindingElement,
"orbit",
"start",
scene,
);
bindBindingElement(bindingArrow, endBindingElement, "orbit", "end", scene);
const changedElements = new Map<string, OrderedExcalidrawElement>();
changedElements.set(
@@ -462,12 +469,18 @@ const createBindingArrow = (
bindingArrow as OrderedExcalidrawElement,
);
LinearElementEditor.movePoints(bindingArrow, scene, [
{
index: 1,
point: bindingArrow.points[1],
},
]);
LinearElementEditor.movePoints(
bindingArrow,
scene,
new Map([
[
1,
{
point: bindingArrow.points[1],
},
],
]),
);
const update = updateElbowArrowPoints(
bindingArrow,

View File

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

View File

@@ -905,13 +905,16 @@ export const shouldApplyFrameClip = (
return false;
};
export const getFrameLikeTitle = (element: ExcalidrawFrameLikeElement) => {
const DEFAULT_FRAME_NAME = "Frame";
const DEFAULT_AI_FRAME_NAME = "AI Frame";
export const getDefaultFrameName = (element: ExcalidrawFrameLikeElement) => {
// TODO name frames "AI" only if specific to AI frames
return element.name === null
? isFrameElement(element)
? "Frame"
: "AI Frame"
: element.name;
return isFrameElement(element) ? DEFAULT_FRAME_NAME : DEFAULT_AI_FRAME_NAME;
};
export const getFrameLikeTitle = (element: ExcalidrawFrameLikeElement) => {
return element.name === null ? getDefaultFrameName(element) : element.name;
};
export const getElementsOverlappingFrame = (

View File

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

View File

@@ -1,10 +1,12 @@
import { toIterable } from "@excalidraw/common";
import { isInvisiblySmallElement } from "./sizeHelpers";
import { isLinearElementType } from "./typeChecks";
import type {
ExcalidrawElement,
NonDeletedExcalidrawElement,
NonDeleted,
ElementsMapOrArray,
} from "./types";
/**
@@ -16,18 +18,19 @@ export const getSceneVersion = (elements: readonly ExcalidrawElement[]) =>
/**
* Hashes elements' versionNonce (using djb2 algo). Order of elements matters.
*/
export const hashElementsVersion = (
elements: readonly ExcalidrawElement[],
): number => {
export const hashElementsVersion = (elements: ElementsMapOrArray): number => {
let hash = 5381;
for (let i = 0; i < elements.length; i++) {
hash = (hash << 5) + hash + elements[i].versionNonce;
for (const element of toIterable(elements)) {
hash = (hash << 5) + hash + element.versionNonce;
}
return hash >>> 0; // Ensure unsigned 32-bit integer
};
// string hash function (using djb2). Not cryptographically secure, use only
// for versioning and such.
// note: hashes individual code units (not code points),
// but for hashing purposes this is fine as it iterates through every code unit
// (as such, no need to encode to byte string first)
export const hashString = (s: string): number => {
let hash: number = 5381;
for (let i = 0; i < s.length; i++) {
@@ -51,23 +54,45 @@ export const isNonDeletedElement = <T extends ExcalidrawElement>(
element: T,
): element is NonDeleted<T> => !element.isDeleted;
const _clearElements = (
elements: readonly ExcalidrawElement[],
): ExcalidrawElement[] =>
getNonDeletedElements(elements).map((element) =>
isLinearElementType(element.type)
? { ...element, lastCommittedPoint: null }
: element,
);
export const clearElementsForDatabase = (
elements: readonly ExcalidrawElement[],
) => _clearElements(elements);
export const clearElementsForExport = (
elements: readonly ExcalidrawElement[],
) => _clearElements(elements);
export const clearElementsForLocalStorage = (
elements: readonly ExcalidrawElement[],
) => _clearElements(elements);
export * from "./align";
export * from "./binding";
export * from "./bounds";
export * from "./collision";
export * from "./comparisons";
export * from "./containerCache";
export * from "./cropElement";
export * from "./delta";
export * from "./distance";
export * from "./distribute";
export * from "./dragElements";
export * from "./duplicate";
export * from "./elbowArrow";
export * from "./elementLink";
export * from "./embeddable";
export * from "./flowchart";
export * from "./fractionalIndex";
export * from "./frame";
export * from "./groups";
export * from "./heading";
export * from "./image";
export * from "./linearElementEditor";
export * from "./mutateElement";
export * from "./newElement";
export * from "./positionElementsOnGrid";
export * from "./renderElement";
export * from "./resizeElements";
export * from "./resizeTest";
export * from "./Scene";
export * from "./selection";
export * from "./shape";
export * from "./showSelectedShapeActions";
export * from "./sizeHelpers";
export * from "./sortElements";
export * from "./store";
export * from "./textElement";
export * from "./textMeasurements";
export * from "./textWrapping";
export * from "./transformHandles";
export * from "./typeChecks";
export * from "./utils";
export * from "./zindex";

File diff suppressed because it is too large Load Diff

View File

@@ -8,7 +8,7 @@ import type { Radians } from "@excalidraw/math";
import type { Mutable } from "@excalidraw/common/utility-types";
import { ShapeCache } from "./ShapeCache";
import { ShapeCache } from "./shape";
import { updateElbowArrowPoints } from "./elbowArrow";
@@ -23,7 +23,7 @@ import type {
export type ElementUpdate<TElement extends ExcalidrawElement> = Omit<
Partial<TElement>,
"id" | "version" | "versionNonce" | "updated"
"id" | "updated"
>;
/**
@@ -46,16 +46,13 @@ export const mutateElement = <TElement extends Mutable<ExcalidrawElement>>(
// casting to any because can't use `in` operator
// (see https://github.com/microsoft/TypeScript/issues/21732)
const { points, fixedSegments, startBinding, endBinding, fileId } =
updates as any;
const { points, fixedSegments, fileId } = updates as any;
if (
isElbowArrow(element) &&
(Object.keys(updates).length === 0 || // normalization case
typeof points !== "undefined" || // repositioning
typeof fixedSegments !== "undefined" || // segment fixing
typeof startBinding !== "undefined" ||
typeof endBinding !== "undefined") // manual binding to element
typeof fixedSegments !== "undefined") // segment fixing
) {
updates = {
...updates,
@@ -137,8 +134,8 @@ export const mutateElement = <TElement extends Mutable<ExcalidrawElement>>(
ShapeCache.delete(element);
}
element.version++;
element.versionNonce = randomInteger();
element.version = updates.version ?? element.version + 1;
element.versionNonce = updates.versionNonce ?? randomInteger();
element.updated = getUpdatedTimestamp();
return element;
@@ -172,9 +169,9 @@ export const newElementWith = <TElement extends ExcalidrawElement>(
return {
...element,
...updates,
version: updates.version ?? element.version + 1,
versionNonce: updates.versionNonce ?? randomInteger(),
updated: getUpdatedTimestamp(),
version: element.version + 1,
versionNonce: randomInteger(),
};
};

View File

@@ -25,6 +25,8 @@ import { getBoundTextMaxWidth } from "./textElement";
import { normalizeText, measureText } from "./textMeasurements";
import { wrapText } from "./textWrapping";
import { isLineElement } from "./typeChecks";
import type {
ExcalidrawElement,
ExcalidrawImageElement,
@@ -44,8 +46,8 @@ import type {
ExcalidrawIframeElement,
ElementsMap,
ExcalidrawArrowElement,
FixedSegment,
ExcalidrawElbowArrowElement,
ExcalidrawLineElement,
} from "./types";
export type ElementConstructorOpts = MarkOptional<
@@ -450,7 +452,6 @@ export const newFreeDrawElement = (
points: opts.points || [],
pressures: opts.pressures || [],
simulatePressure: opts.simulatePressure,
lastCommittedPoint: null,
};
};
@@ -458,17 +459,29 @@ export const newLinearElement = (
opts: {
type: ExcalidrawLinearElement["type"];
points?: ExcalidrawLinearElement["points"];
polygon?: ExcalidrawLineElement["polygon"];
} & ElementConstructorOpts,
): NonDeleted<ExcalidrawLinearElement> => {
return {
const element = {
..._newElementBase<ExcalidrawLinearElement>(opts.type, opts),
points: opts.points || [],
lastCommittedPoint: null,
startBinding: null,
endBinding: null,
startArrowhead: null,
endArrowhead: null,
};
if (isLineElement(element)) {
const lineElement: NonDeleted<ExcalidrawLineElement> = {
...element,
polygon: opts.polygon ?? false,
};
return lineElement;
}
return element;
};
export const newArrowElement = <T extends boolean>(
@@ -478,7 +491,7 @@ export const newArrowElement = <T extends boolean>(
endArrowhead?: Arrowhead | null;
points?: ExcalidrawArrowElement["points"];
elbowed?: T;
fixedSegments?: FixedSegment[] | null;
fixedSegments?: ExcalidrawElbowArrowElement["fixedSegments"] | null;
} & ElementConstructorOpts,
): T extends true
? NonDeleted<ExcalidrawElbowArrowElement>
@@ -487,7 +500,6 @@ export const newArrowElement = <T extends boolean>(
return {
..._newElementBase<ExcalidrawElbowArrowElement>(opts.type, opts),
points: opts.points || [],
lastCommittedPoint: null,
startBinding: null,
endBinding: null,
startArrowhead: opts.startArrowhead || null,
@@ -502,7 +514,6 @@ export const newArrowElement = <T extends boolean>(
return {
..._newElementBase<ExcalidrawArrowElement>(opts.type, opts),
points: opts.points || [],
lastCommittedPoint: null,
startBinding: null,
endBinding: null,
startArrowhead: opts.startArrowhead || null,

View File

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

View File

@@ -1,7 +1,14 @@
import rough from "roughjs/bin/rough";
import { getStroke } from "perfect-freehand";
import { isRightAngleRads } from "@excalidraw/math";
import {
type GlobalPoint,
isRightAngleRads,
lineSegment,
pointFrom,
pointRotateRads,
type Radians,
} from "@excalidraw/math";
import {
BOUND_TEXT_PADDING,
@@ -14,6 +21,7 @@ import {
getFontString,
isRTL,
getVerticalOffset,
invariant,
} from "@excalidraw/common";
import type {
@@ -32,7 +40,7 @@ import type {
InteractiveCanvasRenderConfig,
} from "@excalidraw/excalidraw/scene/types";
import { getElementAbsoluteCoords } from "./bounds";
import { getElementAbsoluteCoords, getElementBounds } from "./bounds";
import { getUncroppedImageElement } from "./cropElement";
import { LinearElementEditor } from "./linearElementEditor";
import {
@@ -54,9 +62,9 @@ import {
isImageElement,
} from "./typeChecks";
import { getContainingFrame } from "./frame";
import { getCornerRadius } from "./shapes";
import { getCornerRadius } from "./utils";
import { ShapeCache } from "./ShapeCache";
import { ShapeCache } from "./shape";
import type {
ExcalidrawElement,
@@ -90,7 +98,7 @@ const isPendingImageElement = (
const shouldResetImageFilter = (
element: ExcalidrawElement,
renderConfig: StaticCanvasRenderConfig,
appState: StaticCanvasAppState,
appState: StaticCanvasAppState | InteractiveCanvasAppState,
) => {
return (
appState.theme === THEME.DARK &&
@@ -106,6 +114,11 @@ const getCanvasPadding = (element: ExcalidrawElement) => {
return element.strokeWidth * 12;
case "text":
return element.fontSize / 2;
case "arrow":
if (element.endArrowhead || element.endArrowhead) {
return 40;
}
return 20;
default:
return 20;
}
@@ -212,7 +225,7 @@ const generateElementCanvas = (
elementsMap: NonDeletedSceneElementsMap,
zoom: Zoom,
renderConfig: StaticCanvasRenderConfig,
appState: StaticCanvasAppState,
appState: StaticCanvasAppState | InteractiveCanvasAppState,
): ExcalidrawElementWithCanvas | null => {
const canvas = document.createElement("canvas");
const context = canvas.getContext("2d")!;
@@ -264,7 +277,7 @@ const generateElementCanvas = (
context.filter = IMAGE_INVERT_FILTER;
}
drawElementOnCanvas(element, rc, context, renderConfig, appState);
drawElementOnCanvas(element, rc, context, renderConfig);
context.restore();
@@ -351,12 +364,20 @@ const generateElementCanvas = (
export const DEFAULT_LINK_SIZE = 14;
const IMAGE_PLACEHOLDER_IMG = document.createElement("img");
const IMAGE_PLACEHOLDER_IMG =
typeof document !== "undefined"
? document.createElement("img")
: ({ src: "" } as HTMLImageElement); // mock image element outside of browser
IMAGE_PLACEHOLDER_IMG.src = `data:${MIME_TYPES.svg},${encodeURIComponent(
`<svg aria-hidden="true" focusable="false" data-prefix="fas" data-icon="image" class="svg-inline--fa fa-image fa-w-16" role="img" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512"><path fill="#888" d="M464 448H48c-26.51 0-48-21.49-48-48V112c0-26.51 21.49-48 48-48h416c26.51 0 48 21.49 48 48v288c0 26.51-21.49 48-48 48zM112 120c-30.928 0-56 25.072-56 56s25.072 56 56 56 56-25.072 56-56-25.072-56-56-56zM64 384h384V272l-87.515-87.515c-4.686-4.686-12.284-4.686-16.971 0L208 320l-55.515-55.515c-4.686-4.686-12.284-4.686-16.971 0L64 336v48z"></path></svg>`,
)}`;
const IMAGE_ERROR_PLACEHOLDER_IMG = document.createElement("img");
const IMAGE_ERROR_PLACEHOLDER_IMG =
typeof document !== "undefined"
? document.createElement("img")
: ({ src: "" } as HTMLImageElement); // mock image element outside of browser
IMAGE_ERROR_PLACEHOLDER_IMG.src = `data:${MIME_TYPES.svg},${encodeURIComponent(
`<svg viewBox="0 0 668 668" xmlns="http://www.w3.org/2000/svg" xml:space="preserve" style="fill-rule:evenodd;clip-rule:evenodd;stroke-linejoin:round;stroke-miterlimit:2"><path d="M464 448H48c-26.51 0-48-21.49-48-48V112c0-26.51 21.49-48 48-48h416c26.51 0 48 21.49 48 48v288c0 26.51-21.49 48-48 48ZM112 120c-30.928 0-56 25.072-56 56s25.072 56 56 56 56-25.072 56-56-25.072-56-56-56ZM64 384h384V272l-87.515-87.515c-4.686-4.686-12.284-4.686-16.971 0L208 320l-55.515-55.515c-4.686-4.686-12.284-4.686-16.971 0L64 336v48Z" style="fill:#888;fill-rule:nonzero" transform="matrix(.81709 0 0 .81709 124.825 145.825)"/><path d="M256 8C119.034 8 8 119.033 8 256c0 136.967 111.034 248 248 248s248-111.034 248-248S392.967 8 256 8Zm130.108 117.892c65.448 65.448 70 165.481 20.677 235.637L150.47 105.216c70.204-49.356 170.226-44.735 235.638 20.676ZM125.892 386.108c-65.448-65.448-70-165.481-20.677-235.637L361.53 406.784c-70.203 49.356-170.226 44.736-235.638-20.676Z" style="fill:#888;fill-rule:nonzero" transform="matrix(.30366 0 0 .30366 506.822 60.065)"/></svg>`,
)}`;
@@ -391,7 +412,6 @@ const drawElementOnCanvas = (
rc: RoughCanvas,
context: CanvasRenderingContext2D,
renderConfig: StaticCanvasRenderConfig,
appState: StaticCanvasAppState,
) => {
switch (element.type) {
case "rectangle":
@@ -537,7 +557,7 @@ const generateElementWithCanvas = (
element: NonDeletedExcalidrawElement,
elementsMap: NonDeletedSceneElementsMap,
renderConfig: StaticCanvasRenderConfig,
appState: StaticCanvasAppState,
appState: StaticCanvasAppState | InteractiveCanvasAppState,
) => {
const zoom: Zoom = renderConfig
? appState.zoom
@@ -594,7 +614,7 @@ const drawElementFromCanvas = (
elementWithCanvas: ExcalidrawElementWithCanvas,
context: CanvasRenderingContext2D,
renderConfig: StaticCanvasRenderConfig,
appState: StaticCanvasAppState,
appState: StaticCanvasAppState | InteractiveCanvasAppState,
allElementsMap: NonDeletedSceneElementsMap,
) => {
const element = elementWithCanvas.element;
@@ -712,7 +732,7 @@ export const renderElement = (
rc: RoughCanvas,
context: CanvasRenderingContext2D,
renderConfig: StaticCanvasRenderConfig,
appState: StaticCanvasAppState,
appState: StaticCanvasAppState | InteractiveCanvasAppState,
) => {
const reduceAlphaForSelection =
appState.openDialog?.name === "elementLinkSelector" &&
@@ -782,7 +802,7 @@ export const renderElement = (
context.translate(cx, cy);
context.rotate(element.angle);
context.translate(-shiftX, -shiftY);
drawElementOnCanvas(element, rc, context, renderConfig, appState);
drawElementOnCanvas(element, rc, context, renderConfig);
context.restore();
} else {
const elementWithCanvas = generateElementWithCanvas(
@@ -875,13 +895,7 @@ export const renderElement = (
tempCanvasContext.translate(-shiftX, -shiftY);
drawElementOnCanvas(
element,
tempRc,
tempCanvasContext,
renderConfig,
appState,
);
drawElementOnCanvas(element, tempRc, tempCanvasContext, renderConfig);
tempCanvasContext.translate(shiftX, shiftY);
@@ -920,7 +934,7 @@ export const renderElement = (
}
context.translate(-shiftX, -shiftY);
drawElementOnCanvas(element, rc, context, renderConfig, appState);
drawElementOnCanvas(element, rc, context, renderConfig);
}
context.restore();
@@ -1026,6 +1040,66 @@ export function getFreeDrawPath2D(element: ExcalidrawFreeDrawElement) {
}
export function getFreeDrawSvgPath(element: ExcalidrawFreeDrawElement) {
return getSvgPathFromStroke(getFreedrawOutlinePoints(element));
}
export function getFreedrawOutlineAsSegments(
element: ExcalidrawFreeDrawElement,
points: [number, number][],
elementsMap: ElementsMap,
) {
const bounds = getElementBounds(
{
...element,
angle: 0 as Radians,
},
elementsMap,
);
const center = pointFrom<GlobalPoint>(
(bounds[0] + bounds[2]) / 2,
(bounds[1] + bounds[3]) / 2,
);
invariant(points.length >= 2, "Freepath outline must have at least 2 points");
return points.slice(2).reduce(
(acc, curr) => {
acc.push(
lineSegment<GlobalPoint>(
acc[acc.length - 1][1],
pointRotateRads(
pointFrom<GlobalPoint>(curr[0] + element.x, curr[1] + element.y),
center,
element.angle,
),
),
);
return acc;
},
[
lineSegment<GlobalPoint>(
pointRotateRads(
pointFrom<GlobalPoint>(
points[0][0] + element.x,
points[0][1] + element.y,
),
center,
element.angle,
),
pointRotateRads(
pointFrom<GlobalPoint>(
points[1][0] + element.x,
points[1][1] + element.y,
),
center,
element.angle,
),
),
],
);
}
export function getFreedrawOutlinePoints(element: ExcalidrawFreeDrawElement) {
// If input points are empty (should they ever be?) return a dot
const inputPoints = element.simulatePressure
? element.points
@@ -1041,10 +1115,10 @@ export function getFreeDrawSvgPath(element: ExcalidrawFreeDrawElement) {
smoothing: 0.5,
streamline: 0.5,
easing: (t) => Math.sin((t * Math.PI) / 2), // https://easings.net/#easeOutSine
last: !!element.lastCommittedPoint, // LastCommittedPoint is added on pointerup
last: true,
};
return getSvgPathFromStroke(getStroke(inputPoints as number[][], options));
return getStroke(inputPoints as number[][], options) as [number, number][];
}
function med(A: number[], B: number[]) {

View File

@@ -2,7 +2,6 @@ import {
pointCenter,
normalizeRadians,
pointFrom,
pointFromPair,
pointRotateRads,
type Radians,
type LocalPoint,
@@ -21,7 +20,11 @@ import type { PointerDownState } from "@excalidraw/excalidraw/types";
import type { Mutable } from "@excalidraw/common/utility-types";
import { getArrowLocalFixedPoints, updateBoundElements } from "./binding";
import {
getArrowLocalFixedPoints,
unbindBindingElement,
updateBoundElements,
} from "./binding";
import {
getElementAbsoluteCoords,
getCommonBounds,
@@ -36,6 +39,7 @@ import {
getContainerElement,
handleBindTextResize,
getBoundTextMaxWidth,
computeBoundTextPosition,
} from "./textElement";
import {
getMinTextElementWidth,
@@ -46,6 +50,7 @@ import {
import { wrapText } from "./textWrapping";
import {
isArrowElement,
isBindingElement,
isBoundToContainer,
isElbowArrow,
isFrameLikeElement,
@@ -57,7 +62,7 @@ import {
import { isInGroup } from "./groups";
import type Scene from "./Scene";
import type { Scene } from "./Scene";
import type { BoundingBox } from "./bounds";
import type {
@@ -74,7 +79,9 @@ import type {
ExcalidrawImageElement,
ElementsMap,
ExcalidrawElbowArrowElement,
ExcalidrawArrowElement,
} from "./types";
import type { ElementUpdate } from "./mutateElement";
// Returns true when transform (resizing/rotation) happened
export const transformElements = (
@@ -104,18 +111,6 @@ export const transformElements = (
);
updateBoundElements(element, scene);
}
} else if (isTextElement(element) && transformHandleType) {
resizeSingleTextElement(
originalElements,
element,
scene,
transformHandleType,
shouldResizeFromCenter,
pointerX,
pointerY,
);
updateBoundElements(element, scene);
return true;
} else if (transformHandleType) {
const elementId = selectedElements[0].id;
const latestElement = elementsMap.get(elementId);
@@ -150,6 +145,9 @@ export const transformElements = (
);
}
}
if (isTextElement(element)) {
updateBoundElements(element, scene);
}
return true;
} else if (selectedElements.length > 1) {
if (transformHandleType === "rotation") {
@@ -229,13 +227,40 @@ const rotateSingleElement = (
}
const boundTextElementId = getBoundTextElementId(element);
scene.mutateElement(element, { angle });
let update: ElementUpdate<NonDeletedExcalidrawElement> = {
angle,
};
if (isBindingElement(element)) {
update = {
...update,
} as ElementUpdate<ExcalidrawArrowElement>;
if (element.startBinding) {
unbindBindingElement(element, "start", scene);
}
if (element.endBinding) {
unbindBindingElement(element, "end", scene);
}
}
scene.mutateElement(element, update);
if (boundTextElementId) {
const textElement =
scene.getElement<ExcalidrawTextElementWithContainer>(boundTextElementId);
if (textElement && !isArrowElement(element)) {
scene.mutateElement(textElement, { angle });
const { x, y } = computeBoundTextPosition(
element,
textElement,
scene.getNonDeletedElementsMap(),
);
scene.mutateElement(textElement, {
angle,
x,
y,
});
}
}
};
@@ -282,151 +307,50 @@ export const measureFontSizeFromWidth = (
};
};
const resizeSingleTextElement = (
originalElements: PointerDownState["originalElements"],
export const resizeSingleTextElement = (
origElement: NonDeleted<ExcalidrawTextElement>,
element: NonDeleted<ExcalidrawTextElement>,
scene: Scene,
transformHandleType: TransformHandleDirection,
shouldResizeFromCenter: boolean,
pointerX: number,
pointerY: number,
nextWidth: number,
nextHeight: number,
) => {
const elementsMap = scene.getNonDeletedElementsMap();
const [x1, y1, x2, y2, cx, cy] = getElementAbsoluteCoords(
element,
elementsMap,
);
// rotation pointer with reverse angle
const [rotatedX, rotatedY] = pointRotateRads(
pointFrom(pointerX, pointerY),
pointFrom(cx, cy),
-element.angle as Radians,
);
let scaleX = 0;
let scaleY = 0;
if (transformHandleType !== "e" && transformHandleType !== "w") {
if (transformHandleType.includes("e")) {
scaleX = (rotatedX - x1) / (x2 - x1);
}
if (transformHandleType.includes("w")) {
scaleX = (x2 - rotatedX) / (x2 - x1);
}
if (transformHandleType.includes("n")) {
scaleY = (y2 - rotatedY) / (y2 - y1);
}
if (transformHandleType.includes("s")) {
scaleY = (rotatedY - y1) / (y2 - y1);
}
const metricsWidth = element.width * (nextHeight / element.height);
const metrics = measureFontSizeFromWidth(element, elementsMap, metricsWidth);
if (metrics === null) {
return;
}
const scale = Math.max(scaleX, scaleY);
if (transformHandleType.includes("n") || transformHandleType.includes("s")) {
const previousOrigin = pointFrom<GlobalPoint>(origElement.x, origElement.y);
if (scale > 0) {
const nextWidth = element.width * scale;
const nextHeight = element.height * scale;
const metrics = measureFontSizeFromWidth(element, elementsMap, nextWidth);
if (metrics === null) {
return;
}
const startTopLeft = [x1, y1];
const startBottomRight = [x2, y2];
const startCenter = [cx, cy];
let newTopLeft = pointFrom<GlobalPoint>(x1, y1);
if (["n", "w", "nw"].includes(transformHandleType)) {
newTopLeft = pointFrom<GlobalPoint>(
startBottomRight[0] - Math.abs(nextWidth),
startBottomRight[1] - Math.abs(nextHeight),
);
}
if (transformHandleType === "ne") {
const bottomLeft = [startTopLeft[0], startBottomRight[1]];
newTopLeft = pointFrom<GlobalPoint>(
bottomLeft[0],
bottomLeft[1] - Math.abs(nextHeight),
);
}
if (transformHandleType === "sw") {
const topRight = [startBottomRight[0], startTopLeft[1]];
newTopLeft = pointFrom<GlobalPoint>(
topRight[0] - Math.abs(nextWidth),
topRight[1],
);
}
if (["s", "n"].includes(transformHandleType)) {
newTopLeft[0] = startCenter[0] - nextWidth / 2;
}
if (["e", "w"].includes(transformHandleType)) {
newTopLeft[1] = startCenter[1] - nextHeight / 2;
}
if (shouldResizeFromCenter) {
newTopLeft[0] = startCenter[0] - Math.abs(nextWidth) / 2;
newTopLeft[1] = startCenter[1] - Math.abs(nextHeight) / 2;
}
const angle = element.angle;
const rotatedTopLeft = pointRotateRads(
newTopLeft,
pointFrom(cx, cy),
angle,
const newOrigin = getResizedOrigin(
previousOrigin,
origElement.width,
origElement.height,
metricsWidth,
nextHeight,
origElement.angle,
transformHandleType,
false,
shouldResizeFromCenter,
);
const newCenter = pointFrom<GlobalPoint>(
newTopLeft[0] + Math.abs(nextWidth) / 2,
newTopLeft[1] + Math.abs(nextHeight) / 2,
);
const rotatedNewCenter = pointRotateRads(
newCenter,
pointFrom(cx, cy),
angle,
);
newTopLeft = pointRotateRads(
rotatedTopLeft,
rotatedNewCenter,
-angle as Radians,
);
const [nextX, nextY] = newTopLeft;
scene.mutateElement(element, {
fontSize: metrics.size,
width: nextWidth,
width: metricsWidth,
height: nextHeight,
x: nextX,
y: nextY,
x: newOrigin.x,
y: newOrigin.y,
});
return;
}
if (transformHandleType === "e" || transformHandleType === "w") {
const stateAtResizeStart = originalElements.get(element.id)!;
const [x1, y1, x2, y2] = getResizedElementAbsoluteCoords(
stateAtResizeStart,
stateAtResizeStart.width,
stateAtResizeStart.height,
true,
);
const startTopLeft = pointFrom<GlobalPoint>(x1, y1);
const startBottomRight = pointFrom<GlobalPoint>(x2, y2);
const startCenter = pointCenter(startTopLeft, startBottomRight);
const rotatedPointer = pointRotateRads(
pointFrom(pointerX, pointerY),
startCenter,
-stateAtResizeStart.angle as Radians,
);
const [esx1, , esx2] = getResizedElementAbsoluteCoords(
element,
element.width,
element.height,
true,
);
const boundsCurrentWidth = esx2 - esx1;
const atStartBoundsWidth = startBottomRight[0] - startTopLeft[0];
const minWidth = getMinTextElementWidth(
getFontString({
fontSize: element.fontSize,
@@ -435,17 +359,7 @@ const resizeSingleTextElement = (
element.lineHeight,
);
let scaleX = atStartBoundsWidth / boundsCurrentWidth;
if (transformHandleType.includes("e")) {
scaleX = (rotatedPointer[0] - startTopLeft[0]) / boundsCurrentWidth;
}
if (transformHandleType.includes("w")) {
scaleX = (startBottomRight[0] - rotatedPointer[0]) / boundsCurrentWidth;
}
const newWidth =
element.width * scaleX < minWidth ? minWidth : element.width * scaleX;
const newWidth = Math.max(minWidth, nextWidth);
const text = wrapText(
element.originalText,
@@ -458,49 +372,27 @@ const resizeSingleTextElement = (
element.lineHeight,
);
const eleNewHeight = metrics.height;
const newHeight = metrics.height;
const [newBoundsX1, newBoundsY1, newBoundsX2, newBoundsY2] =
getResizedElementAbsoluteCoords(
stateAtResizeStart,
newWidth,
eleNewHeight,
true,
);
const newBoundsWidth = newBoundsX2 - newBoundsX1;
const newBoundsHeight = newBoundsY2 - newBoundsY1;
const previousOrigin = pointFrom<GlobalPoint>(origElement.x, origElement.y);
let newTopLeft = [...startTopLeft] as [number, number];
if (["n", "w", "nw"].includes(transformHandleType)) {
newTopLeft = [
startBottomRight[0] - Math.abs(newBoundsWidth),
startTopLeft[1],
];
}
// adjust topLeft to new rotation point
const angle = stateAtResizeStart.angle;
const rotatedTopLeft = pointRotateRads(
pointFromPair(newTopLeft),
startCenter,
angle,
);
const newCenter = pointFrom(
newTopLeft[0] + Math.abs(newBoundsWidth) / 2,
newTopLeft[1] + Math.abs(newBoundsHeight) / 2,
);
const rotatedNewCenter = pointRotateRads(newCenter, startCenter, angle);
newTopLeft = pointRotateRads(
rotatedTopLeft,
rotatedNewCenter,
-angle as Radians,
const newOrigin = getResizedOrigin(
previousOrigin,
origElement.width,
origElement.height,
newWidth,
newHeight,
element.angle,
transformHandleType,
false,
shouldResizeFromCenter,
);
const resizedElement: Partial<ExcalidrawTextElement> = {
width: Math.abs(newWidth),
height: Math.abs(metrics.height),
x: newTopLeft[0],
y: newTopLeft[1],
x: newOrigin.x,
y: newOrigin.y,
text,
autoResize: false,
};
@@ -527,6 +419,11 @@ const rotateMultipleElements = (
centerAngle -= centerAngle % SHIFT_LOCKING_ANGLE;
}
const rotatedElementsMap = new Map<
ExcalidrawElement["id"],
NonDeletedExcalidrawElement
>(elements.map((element) => [element.id, element]));
for (const element of elements) {
if (!isFrameLikeElement(element)) {
const [x1, y1, x2, y2] = getElementAbsoluteCoords(element, elementsMap);
@@ -557,11 +454,30 @@ const rotateMultipleElements = (
simultaneouslyUpdated: elements,
});
if (isBindingElement(element)) {
if (element.startBinding) {
if (!rotatedElementsMap.has(element.startBinding.elementId)) {
unbindBindingElement(element, "start", scene);
}
}
if (element.endBinding) {
if (!rotatedElementsMap.has(element.endBinding.elementId)) {
unbindBindingElement(element, "end", scene);
}
}
}
const boundText = getBoundTextElement(element, elementsMap);
if (boundText && !isArrowElement(element)) {
const { x, y } = computeBoundTextPosition(
element,
boundText,
elementsMap,
);
scene.mutateElement(boundText, {
x: boundText.x + (rotatedCX - cx),
y: boundText.y + (rotatedCY - cy),
x,
y,
angle: normalizeRadians((centerAngle + origAngle) as Radians),
});
}
@@ -821,6 +737,18 @@ export const resizeSingleElement = (
shouldInformMutation?: boolean;
} = {},
) => {
if (isTextElement(latestElement) && isTextElement(origElement)) {
return resizeSingleTextElement(
origElement,
latestElement,
scene,
handleDirection,
shouldResizeFromCenter,
nextWidth,
nextHeight,
);
}
let boundTextFont: { fontSize?: number } = {};
const elementsMap = scene.getNonDeletedElementsMap();
const boundTextElement = getBoundTextElement(latestElement, elementsMap);
@@ -950,23 +878,37 @@ export const resizeSingleElement = (
Number.isFinite(newOrigin.x) &&
Number.isFinite(newOrigin.y)
) {
const updates = {
let updates: ElementUpdate<ExcalidrawElement> = {
...newOrigin,
width: Math.abs(nextWidth),
height: Math.abs(nextHeight),
...rescaledPoints,
};
if (isBindingElement(latestElement)) {
if (latestElement.startBinding) {
updates = {
...updates,
} as ElementUpdate<ExcalidrawArrowElement>;
if (latestElement.startBinding) {
unbindBindingElement(latestElement, "start", scene);
}
}
if (latestElement.endBinding) {
updates = {
...updates,
endBinding: null,
} as ElementUpdate<ExcalidrawArrowElement>;
}
}
scene.mutateElement(latestElement, updates, {
informMutation: shouldInformMutation,
isDragging: false,
});
updateBoundElements(latestElement, scene, {
// TODO: confirm with MARK if this actually makes sense
newSize: { width: nextWidth, height: nextHeight },
});
if (boundTextElement && boundTextFont != null) {
scene.mutateElement(boundTextElement, {
fontSize: boundTextFont.fontSize,
@@ -978,6 +920,8 @@ export const resizeSingleElement = (
handleDirection,
shouldMaintainAspectRatio,
);
updateBoundElements(latestElement, scene);
}
};
@@ -1511,24 +1455,36 @@ export const resizeMultipleElements = (
}
const elementsToUpdate = elementsAndUpdates.map(({ element }) => element);
const resizedElementsMap = new Map<
ExcalidrawElement["id"],
NonDeletedExcalidrawElement
>(elementsAndUpdates.map(({ element }) => [element.id, element]));
for (const {
element,
update: { boundTextFontSize, ...update },
} of elementsAndUpdates) {
const { width, height, angle } = update;
const { angle } = update;
scene.mutateElement(element, update, {
informMutation: true,
// needed for the fixed binding point udpate to take effect
isDragging: true,
});
scene.mutateElement(element, update);
updateBoundElements(element, scene, {
simultaneouslyUpdated: elementsToUpdate,
newSize: { width, height },
});
if (isBindingElement(element)) {
if (element.startBinding) {
if (!resizedElementsMap.has(element.startBinding.elementId)) {
unbindBindingElement(element, "start", scene);
}
}
if (element.endBinding) {
if (!resizedElementsMap.has(element.endBinding.elementId)) {
unbindBindingElement(element, "end", scene);
}
}
}
const boundTextElement = getBoundTextElement(element, elementsMap);
if (boundTextElement && boundTextFontSize) {
scene.mutateElement(boundTextElement, {

View File

@@ -169,25 +169,6 @@ export const isSomeElementSelected = (function () {
return ret;
})();
/**
* Returns common attribute (picked by `getAttribute` callback) of selected
* elements. If elements don't share the same value, returns `null`.
*/
export const getCommonAttributeOfSelectedElements = <T>(
elements: readonly NonDeletedExcalidrawElement[],
appState: Pick<AppState, "selectedElementIds">,
getAttribute: (element: ExcalidrawElement) => T,
): T | null => {
const attributes = Array.from(
new Set(
getSelectedElements(elements, appState).map((element) =>
getAttribute(element),
),
),
);
return attributes.length === 1 ? attributes[0] : null;
};
export const getSelectedElements = (
elements: ElementsMapOrArray,
appState: Pick<InteractiveCanvasAppState, "selectedElementIds">,

View File

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

View File

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

View File

@@ -2,14 +2,28 @@ import {
SHIFT_LOCKING_ANGLE,
viewportCoordsToSceneCoords,
} from "@excalidraw/common";
import {
normalizeRadians,
radiansBetweenAngles,
radiansDifference,
type Radians,
} from "@excalidraw/math";
import { pointsEqual } from "@excalidraw/math";
import type { AppState, Offsets, Zoom } from "@excalidraw/excalidraw/types";
import { getCommonBounds, getElementBounds } from "./bounds";
import { isFreeDrawElement, isLinearElement } from "./typeChecks";
import {
isArrowElement,
isFreeDrawElement,
isLinearElement,
} from "./typeChecks";
import type { ElementsMap, ExcalidrawElement } from "./types";
export const INVISIBLY_SMALL_ELEMENT_SIZE = 0.1;
// TODO: remove invisible elements consistently actions, so that invisible elements are not recorded by the store, exported, broadcasted or persisted
// - perhaps could be as part of a standalone 'cleanup' action, in addition to 'finalize'
// - could also be part of `_clearElements`
@@ -17,8 +31,18 @@ export const isInvisiblySmallElement = (
element: ExcalidrawElement,
): boolean => {
if (isLinearElement(element) || isFreeDrawElement(element)) {
return element.points.length < 2;
return (
element.points.length < 2 ||
(element.points.length === 2 &&
isArrowElement(element) &&
pointsEqual(
element.points[0],
element.points[element.points.length - 1],
INVISIBLY_SMALL_ELEMENT_SIZE,
))
);
}
return element.width === 0 && element.height === 0;
};
@@ -134,13 +158,42 @@ export const getLockedLinearCursorAlignSize = (
originY: number,
x: number,
y: number,
customAngle?: number,
) => {
let width = x - originX;
let height = y - originY;
const lockedAngle =
Math.round(Math.atan(height / width) / SHIFT_LOCKING_ANGLE) *
SHIFT_LOCKING_ANGLE;
const angle = Math.atan2(height, width) as Radians;
let lockedAngle = (Math.round(angle / SHIFT_LOCKING_ANGLE) *
SHIFT_LOCKING_ANGLE) as Radians;
if (customAngle) {
// If custom angle is provided, we check if the angle is close to the
// custom angle, snap to that if close engough, otherwise snap to the
// higher or lower angle depending on the current angle vs custom angle.
const lower = (Math.floor(customAngle / SHIFT_LOCKING_ANGLE) *
SHIFT_LOCKING_ANGLE) as Radians;
if (
radiansBetweenAngles(
angle,
lower,
(lower + SHIFT_LOCKING_ANGLE) as Radians,
)
) {
if (
radiansDifference(angle, customAngle as Radians) <
SHIFT_LOCKING_ANGLE / 6
) {
lockedAngle = customAngle as Radians;
} else if (
normalizeRadians(angle) > normalizeRadians(customAngle as Radians)
) {
lockedAngle = (lower + SHIFT_LOCKING_ANGLE) as Radians;
} else {
lockedAngle = lower;
}
}
}
if (lockedAngle === 0) {
height = 0;

File diff suppressed because it is too large Load Diff

View File

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

View File

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

View File

@@ -1,5 +1,7 @@
import { ROUNDNESS, assertNever } from "@excalidraw/common";
import { pointsEqual } from "@excalidraw/math";
import type { ElementOrToolType } from "@excalidraw/excalidraw/types";
import type { MarkNonNullable } from "@excalidraw/common/utility-types";
@@ -25,9 +27,9 @@ import type {
ExcalidrawMagicFrameElement,
ExcalidrawArrowElement,
ExcalidrawElbowArrowElement,
PointBinding,
FixedPointBinding,
ExcalidrawLineElement,
ExcalidrawFlowchartNodeElement,
ExcalidrawLinearElementSubType,
} from "./types";
export const isInitializedImageElement = (
@@ -107,6 +109,12 @@ export const isLinearElement = (
return element != null && isLinearElementType(element.type);
};
export const isLineElement = (
element?: ExcalidrawElement | null,
): element is ExcalidrawLineElement => {
return element != null && element.type === "line";
};
export const isArrowElement = (
element?: ExcalidrawElement | null,
): element is ExcalidrawArrowElement => {
@@ -119,6 +127,29 @@ export const isElbowArrow = (
return isArrowElement(element) && element.elbowed;
};
/**
* sharp or curved arrow, but not elbow
*/
export const isSimpleArrow = (
element?: ExcalidrawElement,
): element is ExcalidrawArrowElement => {
return isArrowElement(element) && !element.elbowed;
};
export const isSharpArrow = (
element?: ExcalidrawElement,
): element is ExcalidrawArrowElement => {
return isArrowElement(element) && !element.elbowed && !element.roundness;
};
export const isCurvedArrow = (
element?: ExcalidrawElement,
): element is ExcalidrawArrowElement => {
return (
isArrowElement(element) && !element.elbowed && element.roundness !== null
);
};
export const isLinearElementType = (
elementType: ElementOrToolType,
): boolean => {
@@ -130,7 +161,7 @@ export const isLinearElementType = (
export const isBindingElement = (
element?: ExcalidrawElement | null,
includeLocked = true,
): element is ExcalidrawLinearElement => {
): element is ExcalidrawArrowElement => {
return (
element != null &&
(!element.locked || includeLocked === true) &&
@@ -271,6 +302,10 @@ export const isBoundToContainer = (
);
};
export const isArrowBoundToElement = (element: ExcalidrawArrowElement) => {
return !!element.startBinding || !!element.endBinding;
};
export const isUsingAdaptiveRadius = (type: string) =>
type === "rectangle" ||
type === "embeddable" ||
@@ -321,15 +356,6 @@ export const getDefaultRoundnessTypeForElement = (
return null;
};
export const isFixedPointBinding = (
binding: PointBinding | FixedPointBinding,
): binding is FixedPointBinding => {
return (
Object.hasOwn(binding, "fixedPoint") &&
(binding as FixedPointBinding).fixedPoint != null
);
};
// TODO: Move this to @excalidraw/math
export const isBounds = (box: unknown): box is Bounds =>
Array.isArray(box) &&
@@ -338,3 +364,41 @@ export const isBounds = (box: unknown): box is Bounds =>
typeof box[1] === "number" &&
typeof box[2] === "number" &&
typeof box[3] === "number";
export const getLinearElementSubType = (
element: ExcalidrawLinearElement,
): ExcalidrawLinearElementSubType => {
if (isSharpArrow(element)) {
return "sharpArrow";
}
if (isCurvedArrow(element)) {
return "curvedArrow";
}
if (isElbowArrow(element)) {
return "elbowArrow";
}
return "line";
};
/**
* Checks if current element points meet all the conditions for polygon=true
* (this isn't a element type check, for that use isLineElement).
*
* If you want to check if points *can* be turned into a polygon, use
* canBecomePolygon(points).
*/
export const isValidPolygon = (
points: ExcalidrawLineElement["points"],
): boolean => {
return points.length > 3 && pointsEqual(points[0], points[points.length - 1]);
};
export const canBecomePolygon = (
points: ExcalidrawLineElement["points"],
): boolean => {
return (
points.length > 3 ||
// 3-point polygons can't have all points in a single line
(points.length === 3 && !pointsEqual(points[0], points[points.length - 1]))
);
};

View File

@@ -195,7 +195,8 @@ export type ExcalidrawRectanguloidElement =
| ExcalidrawFreeDrawElement
| ExcalidrawIframeLikeElement
| ExcalidrawFrameLikeElement
| ExcalidrawEmbeddableElement;
| ExcalidrawEmbeddableElement
| ExcalidrawSelectionElement;
/**
* ExcalidrawElement should be JSON serializable and (eventually) contain
@@ -278,22 +279,28 @@ export type ExcalidrawTextElementWithContainer = {
export type FixedPoint = [number, number];
export type PointBinding = {
export type BindMode = "inside" | "orbit" | "skip";
export type FixedPointBinding = {
elementId: ExcalidrawBindableElement["id"];
focus: number;
gap: number;
// Represents the fixed point binding information in form of a vertical and
// horizontal ratio (i.e. a percentage value in the 0.0-1.0 range). This ratio
// gives the user selected fixed point by multiplying the bound element width
// with fixedPoint[0] and the bound element height with fixedPoint[1] to get the
// bound element-local point coordinate.
fixedPoint: FixedPoint;
// Determines whether the arrow remains outside the shape or is allowed to
// go all the way inside the shape up to the exact fixed point.
mode: BindMode;
};
export type FixedPointBinding = Merge<
PointBinding,
{
// Represents the fixed point binding information in form of a vertical and
// horizontal ratio (i.e. a percentage value in the 0.0-1.0 range). This ratio
// gives the user selected fixed point by multiplying the bound element width
// with fixedPoint[0] and the bound element height with fixedPoint[1] to get the
// bound element-local point coordinate.
fixedPoint: FixedPoint;
}
type Index = number;
export type PointsPositionUpdates = Map<
Index,
{ point: LocalPoint; isDragging?: boolean }
>;
export type Arrowhead =
@@ -314,17 +321,22 @@ export type ExcalidrawLinearElement = _ExcalidrawElementBase &
Readonly<{
type: "line" | "arrow";
points: readonly LocalPoint[];
lastCommittedPoint: LocalPoint | null;
startBinding: PointBinding | null;
endBinding: PointBinding | null;
startBinding: FixedPointBinding | null;
endBinding: FixedPointBinding | null;
startArrowhead: Arrowhead | null;
endArrowhead: Arrowhead | null;
}>;
export type ExcalidrawLineElement = ExcalidrawLinearElement &
Readonly<{
type: "line";
polygon: boolean;
}>;
export type FixedSegment = {
start: LocalPoint;
end: LocalPoint;
index: number;
index: Index;
};
export type ExcalidrawArrowElement = ExcalidrawLinearElement &
@@ -337,9 +349,9 @@ export type ExcalidrawElbowArrowElement = Merge<
ExcalidrawArrowElement,
{
elbowed: true;
fixedSegments: readonly FixedSegment[] | null;
startBinding: FixedPointBinding | null;
endBinding: FixedPointBinding | null;
fixedSegments: readonly FixedSegment[] | null;
/**
* Marks that the 3rd point should be used as the 2nd point of the arrow in
* order to temporarily hide the first segment of the arrow without losing
@@ -365,7 +377,6 @@ export type ExcalidrawFreeDrawElement = _ExcalidrawElementBase &
points: readonly LocalPoint[];
pressures: readonly number[];
simulatePressure: boolean;
lastCommittedPoint: LocalPoint | null;
}>;
export type FileId = string & { _brand: "FileId" };
@@ -412,3 +423,13 @@ export type NonDeletedSceneElementsMap = Map<
export type ElementsMapOrArray =
| readonly ExcalidrawElement[]
| Readonly<ElementsMap>;
export type ExcalidrawLinearElementSubType =
| "line"
| "sharpArrow"
| "curvedArrow"
| "elbowArrow";
export type ConvertibleGenericTypes = "rectangle" | "diamond" | "ellipse";
export type ConvertibleLinearTypes = ExcalidrawLinearElementSubType;
export type ConvertibleTypes = ConvertibleGenericTypes | ConvertibleLinearTypes;

View File

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

View File

@@ -1,18 +1,25 @@
import { arrayToMap, findIndex, findLastIndex } from "@excalidraw/common";
import type { AppState } from "@excalidraw/excalidraw/types";
import type { GlobalPoint } from "@excalidraw/math";
import { isFrameLikeElement } from "./typeChecks";
import { isFrameLikeElement, isTextElement } from "./typeChecks";
import { getElementsInGroup } from "./groups";
import { syncMovedIndices } from "./fractionalIndex";
import { getSelectedElements } from "./selection";
import { getBoundTextElement, getContainerElement } from "./textElement";
import { getHoveredElementForBinding } from "./collision";
import type Scene from "./Scene";
import type { ExcalidrawElement, ExcalidrawFrameLikeElement } from "./types";
import type { Scene } from "./Scene";
import type {
ExcalidrawArrowElement,
ExcalidrawElement,
ExcalidrawFrameLikeElement,
NonDeletedExcalidrawElement,
NonDeletedSceneElementsMap,
Ordered,
OrderedExcalidrawElement,
} from "./types";
const isOfTargetFrame = (element: ExcalidrawElement, frameId: string) => {
return element.frameId === frameId || element.id === frameId;
@@ -139,6 +146,51 @@ const getContiguousFrameRangeElements = (
return allElements.slice(rangeStart, rangeEnd + 1);
};
/**
* Moves the arrow element above any bindable elements it intersects with or
* hovers over.
*/
export const moveArrowAboveBindable = (
point: GlobalPoint,
arrow: ExcalidrawArrowElement,
elements: readonly Ordered<NonDeletedExcalidrawElement>[],
elementsMap: NonDeletedSceneElementsMap,
scene: Scene,
): readonly OrderedExcalidrawElement[] => {
const hoveredElement = getHoveredElementForBinding(
point,
elements,
elementsMap,
);
if (!hoveredElement) {
return elements;
}
const boundTextElement = getBoundTextElement(hoveredElement, elementsMap);
const containerElement = isTextElement(hoveredElement)
? getContainerElement(hoveredElement, elementsMap)
: null;
const bindableIds = [
hoveredElement.id,
boundTextElement?.id,
containerElement?.id,
].filter((id): id is NonDeletedExcalidrawElement["id"] => !!id);
const bindableIdx = elements.findIndex((el) => bindableIds.includes(el.id));
const arrowIdx = elements.findIndex((el) => el.id === arrow.id);
if (arrowIdx !== -1 && bindableIdx !== -1 && arrowIdx < bindableIdx) {
const updatedElements = Array.from(elements);
const arrow = updatedElements.splice(arrowIdx, 1)[0];
updatedElements.splice(bindableIdx, 0, arrow);
scene.replaceAllElements(updatedElements);
}
return elements;
};
/**
* Returns next candidate index that's available to be moved to. Currently that
* is a non-deleted element, and not inside a group (unless we're editing it).

View File

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

View File

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

File diff suppressed because it is too large Load Diff

View File

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

View File

@@ -3,21 +3,30 @@ import { vi } from "vitest";
import { KEYS, cloneJSON } from "@excalidraw/common";
import { duplicateElement } from "@excalidraw/element/duplicate";
import {
Excalidraw,
exportToCanvas,
exportToSvg,
} from "@excalidraw/excalidraw";
import {
actionFlipHorizontal,
actionFlipVertical,
} from "@excalidraw/excalidraw/actions";
import type {
ExcalidrawImageElement,
ImageCrop,
} from "@excalidraw/element/types";
import { API } from "@excalidraw/excalidraw/tests/helpers/api";
import { Keyboard, Pointer, UI } from "@excalidraw/excalidraw/tests/helpers/ui";
import {
act,
GlobalTestState,
render,
unmountComponent,
} from "@excalidraw/excalidraw/tests/test-utils";
import { Excalidraw, exportToCanvas, exportToSvg } from "..";
import { actionFlipHorizontal, actionFlipVertical } from "../actions";
import type { NormalizedZoomValue } from "@excalidraw/excalidraw/types";
import { API } from "./helpers/api";
import { Keyboard, Pointer, UI } from "./helpers/ui";
import { act, GlobalTestState, render, unmountComponent } from "./test-utils";
import { duplicateElement } from "../src/duplicate";
import type { NormalizedZoomValue } from "../types";
import type { ExcalidrawImageElement, ImageCrop } from "../src/types";
const { h } = window;
const mouse = new Pointer("mouse");

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