Compare commits

...

65 Commits

Author SHA1 Message Date
Ryan Di cbf4d5a591 fix other imports as well 2025-11-19 14:40:16 +08:00
Ryan Di 38d0966227 fix: use index path instead of ellipse 2025-11-19 14:37:05 +08:00
gothamsidd 37882c66cb fix: canvas panning stops when hovering over frame title (#10340) (#10351)
* fix: canvas panning stops when hovering over frame title (#10340)

* improve

---------

Co-authored-by: dwelle <5153846+dwelle@users.noreply.github.com>
2025-11-18 09:24:02 +00:00
David Luzar 7f66e1fe89 fix: banner url (#10315) 2025-11-11 11:29:44 +01:00
Márk Tolmács 2b4540225d feat: Animation support (#10042) 2025-11-10 22:31:23 +01:00
Márk Tolmács dc2f25c14a fix: Alt-duplication copied elements placement (#10152) 2025-11-10 22:31:08 +01:00
Márk Tolmács 8fb16669ab feat: Add binding visual debug (#10222) 2025-11-10 12:08:57 +01:00
David Luzar f2600fe3e8 fix: close floating sidebar on main menu open (#10295) 2025-11-06 22:39:39 +01:00
zsviczian 95ddc66339 fix: add toggle pen-mode to MobileMenu (#10293)
* add toggle pen mode to MobileMenu

* swap buttons

---------

Co-authored-by: dwelle <5153846+dwelle@users.noreply.github.com>
2025-11-06 21:33:11 +00:00
David Luzar 5bcd8280c9 feat: add comments/presi eplus promos for discoveribility (#10294) 2025-11-06 21:35:14 +01:00
Ryan Di c99e81678b fix: mobile view ui issues (#10284)
* hide zen mode when formFactor = phone

* tool bar fixes: icon and width

* view mode

* fix lint

* add exit-view-mode button

---------

Co-authored-by: dwelle <5153846+dwelle@users.noreply.github.com>
2025-11-04 11:20:55 +00:00
Excalidraw Bot d1f39823f1 chore: Update translations from Crowdin (#7429)
* New translations en.json (Punjabi)

* New translations en.json (Polish)

* New translations en.json (Russian)

* New translations en.json (Turkish)

* New translations en.json (Ukrainian)

* New translations en.json (Chinese Traditional)

* New translations en.json (Vietnamese)

* New translations en.json (Galician)

* New translations en.json (Portuguese, Brazilian)

* New translations en.json (Persian)

* New translations en.json (Khmer)

* New translations en.json (Tamil)

* New translations en.json (Bengali)

* New translations en.json (Marathi)

* New translations en.json (Thai)

* New translations en.json (Norwegian Nynorsk)

* New translations en.json (Kazakh)

* New translations en.json (Latvian)

* New translations en.json (Azerbaijani)

* New translations en.json (Hindi)

* New translations en.json (Burmese)

* New translations en.json (Chinese Traditional, Hong Kong)

* New translations en.json (Sinhala)

* New translations en.json (Uzbek)

* New translations en.json (Norwegian Bokmal)

* New translations en.json (Occitan)

* New translations en.json (German, Switzerland)

* New translations en.json (Bengali, India)

* New translations en.json (Kabyle)

* New translations en.json (Karakalpak)

* New translations en.json (Portuguese)

* New translations en.json (Russian)

* New translations en.json (Marathi)

* New translations en.json (Hindi)

* New translations en.json (German)

* New translations en.json (Chinese Simplified)

* New translations en.json (Polish)

* New translations en.json (Romanian)

* New translations en.json (Korean)

* New translations en.json (Chinese Traditional)

* New translations en.json (Hebrew)

* New translations en.json (Hebrew)

* New translations en.json (Slovak)

* New translations en.json (Slovak)

* New translations en.json (Hungarian)

* New translations en.json (Hungarian)

* New translations en.json (Slovak)

* New translations en.json (Hebrew)

* New translations en.json (Hungarian)

* New translations en.json (Korean)

* New translations en.json (Chinese Traditional)

* New translations en.json (Romanian)

* New translations en.json (French)

* New translations en.json (Spanish)

* New translations en.json (Arabic)

* New translations en.json (Bulgarian)

* New translations en.json (Catalan)

* New translations en.json (Czech)

* New translations en.json (Danish)

* New translations en.json (German)

* New translations en.json (Greek)

* New translations en.json (Basque)

* New translations en.json (Finnish)

* New translations en.json (Italian)

* New translations en.json (Japanese)

* New translations en.json (Kurdish)

* New translations en.json (Lithuanian)

* New translations en.json (Dutch)

* New translations en.json (Punjabi)

* New translations en.json (Polish)

* New translations en.json (Portuguese)

* New translations en.json (Russian)

* New translations en.json (Slovenian)

* New translations en.json (Swedish)

* New translations en.json (Turkish)

* New translations en.json (Ukrainian)

* New translations en.json (Chinese Simplified)

* New translations en.json (Vietnamese)

* New translations en.json (Galician)

* New translations en.json (Portuguese, Brazilian)

* New translations en.json (Indonesian)

* New translations en.json (Persian)

* New translations en.json (Khmer)

* New translations en.json (Tamil)

* New translations en.json (Bengali)

* New translations en.json (Marathi)

* New translations en.json (Thai)

* New translations en.json (Norwegian Nynorsk)

* New translations en.json (Kazakh)

* New translations en.json (Latvian)

* New translations en.json (Azerbaijani)

* New translations en.json (Hindi)

* New translations en.json (Burmese)

* New translations en.json (Chinese Traditional, Hong Kong)

* New translations en.json (Sinhala)

* New translations en.json (Uzbek)

* New translations en.json (Norwegian Bokmal)

* New translations en.json (Occitan)

* New translations en.json (German, Switzerland)

* New translations en.json (Bengali, India)

* New translations en.json (Kabyle)

* New translations en.json (Karakalpak)

* New translations en.json (Romanian)

* New translations en.json (German)

* New translations en.json (Slovenian)

* New translations en.json (Chinese Simplified)

* New translations en.json (Spanish)

* New translations en.json (Russian)

* New translations en.json (Chinese Traditional)

* New translations en.json (Turkish)

* New translations en.json (Slovak)

* New translations en.json (Slovak)

* New translations en.json (Hebrew)

* New translations en.json (Hungarian)

* New translations en.json (Korean)

* New translations en.json (Chinese Traditional)

* New translations en.json (Romanian)

* New translations en.json (French)

* New translations en.json (Spanish)

* New translations en.json (Arabic)

* New translations en.json (Bulgarian)

* New translations en.json (Catalan)

* New translations en.json (Czech)

* New translations en.json (Danish)

* New translations en.json (German)

* New translations en.json (Greek)

* New translations en.json (Basque)

* New translations en.json (Finnish)

* New translations en.json (Italian)

* New translations en.json (Japanese)

* New translations en.json (Kurdish)

* New translations en.json (Lithuanian)

* New translations en.json (Dutch)

* New translations en.json (Punjabi)

* New translations en.json (Polish)

* New translations en.json (Portuguese)

* New translations en.json (Russian)

* New translations en.json (Slovenian)

* New translations en.json (Swedish)

* New translations en.json (Turkish)

* New translations en.json (Ukrainian)

* New translations en.json (Chinese Simplified)

* New translations en.json (Vietnamese)

* New translations en.json (Galician)

* New translations en.json (Portuguese, Brazilian)

* New translations en.json (Indonesian)

* New translations en.json (Persian)

* New translations en.json (Khmer)

* New translations en.json (Tamil)

* New translations en.json (Bengali)

* New translations en.json (Marathi)

* New translations en.json (Thai)

* New translations en.json (Norwegian Nynorsk)

* New translations en.json (Kazakh)

* New translations en.json (Latvian)

* New translations en.json (Azerbaijani)

* New translations en.json (Hindi)

* New translations en.json (Burmese)

* New translations en.json (Chinese Traditional, Hong Kong)

* New translations en.json (Sinhala)

* New translations en.json (Uzbek)

* New translations en.json (Norwegian Bokmal)

* New translations en.json (Occitan)

* New translations en.json (German, Switzerland)

* New translations en.json (Bengali, India)

* New translations en.json (Kabyle)

* New translations en.json (Karakalpak)

* New translations en.json (German)

* New translations en.json (Russian)

* New translations en.json (Romanian)

* New translations en.json (Spanish)

* New translations en.json (Chinese Simplified)

* New translations en.json (Marathi)

* New translations en.json (Hindi)

* New translations en.json (Slovak)

* New translations en.json (German)

* New translations en.json (Portuguese)

* New translations en.json (Romanian)

* New translations en.json (French)

* New translations en.json (Spanish)

* New translations en.json (Arabic)

* New translations en.json (Bulgarian)

* New translations en.json (Catalan)

* New translations en.json (Czech)

* New translations en.json (Danish)

* New translations en.json (German)

* New translations en.json (Greek)

* New translations en.json (Basque)

* New translations en.json (Finnish)

* New translations en.json (Hebrew)

* New translations en.json (Hungarian)

* New translations en.json (Italian)

* New translations en.json (Japanese)

* New translations en.json (Korean)

* New translations en.json (Kurdish)

* New translations en.json (Lithuanian)

* New translations en.json (Dutch)

* New translations en.json (Punjabi)

* New translations en.json (Polish)

* New translations en.json (Portuguese)

* New translations en.json (Russian)

* New translations en.json (Slovak)

* New translations en.json (Slovenian)

* New translations en.json (Swedish)

* New translations en.json (Turkish)

* New translations en.json (Ukrainian)

* New translations en.json (Chinese Simplified)

* New translations en.json (Chinese Traditional)

* New translations en.json (Vietnamese)

* New translations en.json (Galician)

* New translations en.json (Portuguese, Brazilian)

* New translations en.json (Indonesian)

* New translations en.json (Persian)

* New translations en.json (Khmer)

* New translations en.json (Tamil)

* New translations en.json (Bengali)

* New translations en.json (Marathi)

* New translations en.json (Thai)

* New translations en.json (Norwegian Nynorsk)

* New translations en.json (Kazakh)

* New translations en.json (Latvian)

* New translations en.json (Azerbaijani)

* New translations en.json (Hindi)

* New translations en.json (Burmese)

* New translations en.json (Chinese Traditional, Hong Kong)

* New translations en.json (Sinhala)

* New translations en.json (Uzbek)

* New translations en.json (Norwegian Bokmal)

* New translations en.json (Occitan)

* New translations en.json (German, Switzerland)

* New translations en.json (Bengali, India)

* New translations en.json (Kabyle)

* New translations en.json (Karakalpak)

* Auto commit: Calculate translation coverage

* New translations en.json (Chinese Simplified)

* Auto commit: Calculate translation coverage

* New translations en.json (Romanian)

* Auto commit: Calculate translation coverage

* New translations en.json (Romanian)

* Auto commit: Calculate translation coverage

* New translations en.json (French)

* Auto commit: Calculate translation coverage

* New translations en.json (Polish)

* Auto commit: Calculate translation coverage

* New translations en.json (Polish)

* Auto commit: Calculate translation coverage

* New translations en.json (Turkish)

* Auto commit: Calculate translation coverage

* New translations en.json (Turkish)

---------

Co-authored-by: dwelle <5153846+dwelle@users.noreply.github.com>
2025-11-03 23:36:08 +01:00
Ryan Di 47cbb5b6fb refactor: single source of truths with editor interface (#10178)
* refactor device to editor interface and derive styles panel

* allow host app to control form factor and ui mode

* add editor interface event listener

* put new props inside UIOptions

* refactor: move related apis into one file

* expose getFormFactor

* privatize the setting of desktop mode and fix snapshots

* refactor and fix test

* remove unimplemented code

* export getFormFactor()

* replace `getFormFactor` with `getEditorInterface`

* remove dead & useless

* comment

* fix ts

---------

Co-authored-by: dwelle <5153846+dwelle@users.noreply.github.com>
2025-11-03 23:34:17 +01:00
Márk Tolmács 8fd970320e chore: Uncap the nodejs version requirement (#10238)
Co-authored-by: dwelle <5153846+dwelle@users.noreply.github.com>
2025-11-03 17:30:35 +01:00
Christopher Tangonan 8d8f696628 fix: prevent wrap text in a container to only text that are not bound to a container (#10250)
* fix: only enable wrap text in a container when at least one text element selected is unbound

* Trigger Rebuild

---------

Co-authored-by: Mark Tolmacs <mark@lazycat.hu>
2025-10-26 23:00:17 +01:00
zsviczian 19b3dc658a fix: set radix PropertiesPopover collision boundary (#10221)
* Set collision boundary

* Calculate collisionPadding dynamically based on container

* Add appState offsetTop and offsetLeft to padding calculation.

Refactor collisionPadding calculation to use app state offsets.

* Update PropertiesPopover.tsx

* popover positioning relative to container
2025-10-22 23:29:39 +02:00
David Luzar 4e0441eeb4 fix: small tweaks to shortcut hints (#10214) 2025-10-20 16:57:40 +02:00
Omar Brikaa 8013eb5e16 feat: More prominent keyboard shortcuts in hints (#10057)
* Initial

* Memoize

* Styling

* Use double angle brackets for keyboard shortcuts

* Use rem in gap

* Use an existing function for substituting tags in a string

* Revert styling

* Avoid unique key warnings

* Styling

* getTransChildren -> nodesFromTextWithTags

* Use height and padding instead of padding only

* Initial new idea

* WIP shortcut substitutions

* Use simple regex for parsing shortcuts

* Use single shortcut for combos

* Use kbd instead of span

* shortcutFromKeyString -> getTaggedShortcutKey

* Bug fix

* FlowChart -> Flowchart

* memo is useless here

* Trigger CI

* Translate in getShortcutKey

* More normalized shortcuts

* improve shortcut normalization and replacement & support multi-key tagged shortcuts

* fix regex

* tweak css

---------

Co-authored-by: dwelle <5153846+dwelle@users.noreply.github.com>
2025-10-20 16:09:20 +02:00
Ryan Di 725412ebd3 fix: context menu getting covered (#10199)
* do not show z-index actions on mobile or tablet

* fix: context menu getting covered

* fix lint

* fix style popup getting covered

* put contextmenu z-index above sidebar

---------

Co-authored-by: dwelle <5153846+dwelle@users.noreply.github.com>
2025-10-20 11:56:55 +02:00
Márk Tolmács 7da176ff7d fix: Increase transform handle offset (#10180)
* fix: Increase transform handle offset

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

* fix: Temporarily disable transform handles for linear elements on mobile and tablets

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

* fix: Linear hidden resize

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

* disable mobielOrTablet linear element bbox completely

* fix: Test

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

* fix: Lint

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

---------

Signed-off-by: Mark Tolmacs <mark@lazycat.hu>
Co-authored-by: dwelle <5153846+dwelle@users.noreply.github.com>
2025-10-15 21:16:20 +02:00
David Luzar 5fffc4743f fix: mobile UI and other fixes (#10177)
* remove legacy openMenu=shape state and unused actions

* close menus/popups in applicable cases when opening a different one

* split ui z-indexes to account prefer different overlap

* make top canvas area clickable on mobile

* make mobile main menu closable by clicking outside and reduce width

* offset picker popups from viewport border on mobile

* reduce items gap in mobile main menu

* show top picks for canvas bg colors in all ui modes

* fix menu separator visibility on mobile

* fix command palette items not being filtered
2025-10-14 16:34:49 +02:00
David Luzar 8608d7b2e0 fix: revert preferred selection to box once you switch to full UI (#10160) 2025-10-12 23:33:02 +02:00
Omar Brikaa 19b03b4ca9 fix: remove redundant selectionStart/End resetting that causes scroll-reset bug on firefox (#8263)
Remove redundant selectionStart/End resetting that causes scroll-reset bug on firefox
2025-10-10 18:12:08 +02:00
Ryan Di 416e8b3e42 feat: new mobile layout (#9996)
* compact bottom toolbar

* put menu trigger to top left

* add popup to switch between grouped tool types

* add a dedicated mobile toolbar

* update position for mobile

* fix active tool type

* add mobile mode as well

* mobile actions

* remove refactored popups

* excali logo mobile

* include mobile

* update mobile menu layout

* move selection and deletion back to right

* do not fill eraser

* fix styling

* fix active styling

* bigger buttons, smaller gaps

* fix other tools not opened

* fix: Style panel persistence and restore

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

* move hidden action btns to extra popover

* fix dropdown overlapping with welcome screen

* replace custom popup with popover

* improve button styles

* swapping redo and delete

* always show undo & redo and improve styling

* change background

* toolbar styles

* no any

* persist perferred selection tool and align tablet as well

* add a renderTopLeftUI to props

* tweak border and bg

* show combined properties only when using suitable tools

* fix preferred tool

* new stroke icon

* hide color picker hot keys

* init preferred tool based on device

* fix main menu sizing

* fix welcome screen offset

* put text before image

* disable call highlight on buttons

* fix renderTopLeftUI

---------

Signed-off-by: Mark Tolmacs <mark@lazycat.hu>
Co-authored-by: Mark Tolmacs <mark@lazycat.hu>
Co-authored-by: dwelle <5153846+dwelle@users.noreply.github.com>
2025-10-09 23:48:31 +02:00
David Espinoza 98e0cd9078 build: Docker compose version removed (#10074) 2025-10-05 14:48:54 +02:00
Akibur Rahman f3c16a600d fix: text to diagram translation update issue on language update (#10016) 2025-10-02 16:47:26 +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
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
Márk Tolmács a89a03c66c fix: Arrow eraser precision arrow selection (#10006) 2025-09-24 20:28:41 +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
Márk Tolmács f55ecb96cc fix: Mobile arrow point drag broken (#9998)
* fix: Mobile bound arrow point drag broken

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

* naming

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

* feat: allow the hiding of default fonts

* refactor: rename to compactMode

* feat: introduce layout (incomplete)

* tweak icons

* do not show border

* lint

* add isTouchMobile to device

* add isTouchMobile to device

* refactor to use showCompactSidebar instead

* hide library label in compact

* fix icon color in dark theme

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

* update tests

* use a smaller gap between shapes

* proper fix of range

* quicker switching between different popovers

* to not show properties panel at all when editing text

* fix switching between different popovers for texts

* fix popover not closable and font search auto focus

* change properties for a new or editing text

* change icon for more style settings

* use bolt icon for extra actions

* fix breakpoints

* use rem for icon sizes

* fix tests

* improve switching between triggers (incomplete)

* improve trigger switching (complete)

* clean up code

* put compact into app state

* fix button size

* remove redundant PanelComponentProps["compactMode"]

* move fontSize UI on top

* mobile detection (breakpoints incomplete)

* tweak compact mode detection

* rename appState prop & values

* update snapshots

---------

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

* Initial

* handleAppOnDrop, onImageToolbarButtonClick, pasteFromClipboard

* Initial get history working

* insertMultipleImages -> insertImages

* Bug fixes, improvements

* Remove redundant branch

* Refactor addElementsFromMixedContentPaste

* History, drag & drop bug fixes

* Update snapshots

* Remove redundant try-catch

* Refactor pasteFromClipboard

* Plain paste check in mermaid paste

* Move comment

* processClipboardData -> insertClipboardContent

* Redundant variable

* Redundant variable

* Refactor insertImages

* createImagePlaceholder -> newImagePlaceholder

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

* Trigger CI

* Position placeholders before initializing

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

* Comment

* Move positionOnGrid out of file

* Rename file

* Get rid of generic

* Initial tests

* More asserts, test paste

* Test image tool

* De-duplicate

* Stricter assert, move rest of logic outside of waitFor

* Modify history tests

* De-duplicate update snapshots

* Trigger CI

* Fix package build

* Make setupImageTest more explicit

* Re-introduce generic to use latest placeholder versions

* newElementWith instead of mutateElement to delete failed placeholder

* Insert failed images separately with CaptureUpdateAction.NEVER

* Refactor

* Don't re-order elements

* WIP

* Get rid of 'never' for failed

* refactor type check

* align max file size constant

* make grid padding scale to zoom

---------

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

* fix: Type updates and throw for invalid point states

---------

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

* alternative ux: drag with lasso right away

* fix: lasso dragging should snap too

* fix: alt+cmd getting stuck

* test: snapshots

* alternatvie: keep lasso drag to only mobile

* alternative: drag after selection on PCs

* improve mobile dection

* add mobile lasso icon

* add default selection tool

* render according to default selection tool

* return to default selection tool after deletion

* reset to default tool after clearing out the canvas

* return to default tool after eraser toggle

* if default lasso, close lasso toggle

* finalize to default selection tool

* toggle between laser and default selection

* return to default selection tool after creation

* double click to add text when using default selection tool

* set to default selection tool after unlocking tool

* paste to center on touch screen

* switch to default selection tool after pasting

* lint

* fix tests

* show welcome screen when using default selection tool

* fix tests

* fix snapshots

* fix context menu not opening

* prevent potential displacement issue

* prevent element jumping during lasso selection

* fix dragging on mobile

* use same selection icon

* fix alt+cmd lasso getting cut off

* fix: shortcut handling

* lint

---------

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

* lint

* lint

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

* update meta tag

* lint
2025-08-01 17:17:42 +02:00
258 changed files with 23287 additions and 7132 deletions
-1
View File
@@ -1 +0,0 @@
18
@@ -9,7 +9,7 @@ You will need to import the `Footer` component from the package and wrap your co
```jsx live ```jsx live
function App() { function App() {
return ( return (
<div style={{ height: "500px"}}> <div style={{ height: "500px" }}>
<Excalidraw> <Excalidraw>
<Footer> <Footer>
<button <button
@@ -27,19 +27,19 @@ function App() {
This will only work for `Desktop` devices. This will only work for `Desktop` devices.
For `mobile` you will need to render it inside the [MainMenu](#mainmenu). You can use the [`useDevice`](#useDevice) hook to check the type of device, this will be available only inside the `children` of `Excalidraw` component. For `mobile` you will need to render it inside the [MainMenu](#mainmenu). You can use the [`useEditorInterface`](#useEditorInterface) hook to check the type of device, this will be available only inside the `children` of `Excalidraw` component.
Open the `Menu` in the below playground and you will see the `custom footer` rendered. Open the `Menu` in the below playground and you will see the `custom footer` rendered.
```jsx live noInline ```jsx live noInline
const MobileFooter = ({}) => { const MobileFooter = ({}) => {
const device = useDevice(); const editorInterface = useEditorInterface();
if (device.editor.isMobile) { if (editorInterface.formFactor === "phone") {
return ( return (
<Footer> <Footer>
<button <button
className="custom-footer" className="custom-footer"
style= {{ marginLeft: '20px', height: '2rem'}} style={{ marginLeft: "20px", height: "2rem" }}
onClick={() => alert("This is custom footer in mobile menu")} onClick={() => alert("This is custom footer in mobile menu")}
> >
custom footer custom footer
@@ -292,7 +292,7 @@ viewportCoordsToSceneCoords(&#123; clientX: number, clientY: number },<br/>&nbsp
appState: <a href="https://github.com/excalidraw/excalidraw/blob/master/packages/excalidraw/types.ts#L95">AppState</a><br/>): &#123;x: number, y: number} appState: <a href="https://github.com/excalidraw/excalidraw/blob/master/packages/excalidraw/types.ts#L95">AppState</a><br/>): &#123;x: number, y: number}
</pre> </pre>
### useDevice ### useEditorInterface
This hook can be used to check the type of device which is being used. It can only be used inside the `children` of `Excalidraw` component. This hook can be used to check the type of device which is being used. It can only be used inside the `children` of `Excalidraw` component.
@@ -300,8 +300,8 @@ Open the `main menu` in the below example to view the footer.
```jsx live noInline ```jsx live noInline
const MobileFooter = ({}) => { const MobileFooter = ({}) => {
const device = useDevice(); const editorInterface = useEditorInterface();
if (device.editor.isMobile) { if (editorInterface.formFactor === "phone") {
return ( return (
<Footer> <Footer>
<button <button
@@ -336,12 +336,20 @@ render(<App />);
The `device` has the following `attributes`, some grouped into `viewport` and `editor` objects, per context. The `device` has the following `attributes`, some grouped into `viewport` and `editor` objects, per context.
| Name | Type | Description | | Name | Type | Description |
| --- | --- | --- | | ---- | ---- | ----------- |
| `viewport.isMobile` | `boolean` | Set to `true` when viewport is in `mobile` breakpoint |
| `viewport.isLandscape` | `boolean` | Set to `true` when the viewport is in `landscape` mode | The `EditorInterface` object has the following properties:
| `editor.canFitSidebar` | `boolean` | Set to `true` if there's enough space to fit the `sidebar` |
| `editor.isMobile` | `boolean` | Set to `true` when editor container is in `mobile` breakpoint | | Name | Type | Description |
| `isTouchScreen` | `boolean` | Set to `true` for `touch` when touch event detected | | --- | --- | --- | --- | --- | --- |
| `formFactor` | `'phone' | 'tablet' | 'desktop'` | Indicates the device type based on screen size |
| `desktopUIMode` | `'compact' | 'full'` | UI mode for desktop form factor |
| `userAgent.raw` | `string` | Raw user agent string |
| `userAgent.isMobileDevice` | `boolean` | True if device is mobile |
| `userAgent.platform` | `'ios' | 'android' | 'other' | 'unknown'` | Device platform |
| `isTouchScreen` | `boolean` | True if touch events are detected |
| `canFitSidebar` | `boolean` | True if sidebar can fit in the viewport |
| `isLandscape` | `boolean` | True if viewport is in landscape mode |
### i18n ### i18n
-2
View File
@@ -1,5 +1,3 @@
version: "3.8"
services: services:
excalidraw: excalidraw:
build: build:
@@ -12,10 +12,10 @@ const MobileFooter = ({
excalidrawAPI: ExcalidrawImperativeAPI; excalidrawAPI: ExcalidrawImperativeAPI;
excalidrawLib: typeof TExcalidraw; excalidrawLib: typeof TExcalidraw;
}) => { }) => {
const { useDevice, Footer } = excalidrawLib; const { useEditorInterface, Footer } = excalidrawLib;
const device = useDevice(); const editorInterface = useEditorInterface();
if (device.editor.isMobile) { if (editorInterface.formFactor === "phone") {
return ( return (
<Footer> <Footer>
<CustomFooter <CustomFooter
+27 -10
View File
@@ -4,6 +4,7 @@ import {
TTDDialogTrigger, TTDDialogTrigger,
CaptureUpdateAction, CaptureUpdateAction,
reconcileElements, reconcileElements,
useEditorInterface,
} from "@excalidraw/excalidraw"; } from "@excalidraw/excalidraw";
import { trackEvent } from "@excalidraw/excalidraw/analytics"; import { trackEvent } from "@excalidraw/excalidraw/analytics";
import { getDefaultAppState } from "@excalidraw/excalidraw/appState"; import { getDefaultAppState } from "@excalidraw/excalidraw/appState";
@@ -20,7 +21,6 @@ import {
APP_NAME, APP_NAME,
EVENT, EVENT,
THEME, THEME,
TITLE_TIMEOUT,
VERSION_TIMEOUT, VERSION_TIMEOUT,
debounce, debounce,
getVersion, getVersion,
@@ -120,6 +120,7 @@ import {
LibraryIndexedDBAdapter, LibraryIndexedDBAdapter,
LibraryLocalStorageMigrationAdapter, LibraryLocalStorageMigrationAdapter,
LocalData, LocalData,
localStorageQuotaExceededAtom,
} from "./data/LocalData"; } from "./data/LocalData";
import { isBrowserStorageStateNewer } from "./data/tabSync"; import { isBrowserStorageStateNewer } from "./data/tabSync";
import { ShareDialog, shareDialogStateAtom } from "./share/ShareDialog"; import { ShareDialog, shareDialogStateAtom } from "./share/ShareDialog";
@@ -137,6 +138,9 @@ import { ExcalidrawPlusIframeExport } from "./ExcalidrawPlusIframeExport";
import "./index.scss"; import "./index.scss";
import { ExcalidrawPlusPromoBanner } from "./components/ExcalidrawPlusPromoBanner";
import { AppSidebar } from "./components/AppSidebar";
import type { CollabAPI } from "./collab/Collab"; import type { CollabAPI } from "./collab/Collab";
polyfill(); polyfill();
@@ -342,6 +346,8 @@ const ExcalidrawWrapper = () => {
const [langCode, setLangCode] = useAppLangCode(); const [langCode, setLangCode] = useAppLangCode();
const editorInterface = useEditorInterface();
// initial state // initial state
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@@ -499,11 +505,6 @@ const ExcalidrawWrapper = () => {
} }
}; };
const titleTimeout = setTimeout(
() => (document.title = APP_NAME),
TITLE_TIMEOUT,
);
const syncData = debounce(() => { const syncData = debounce(() => {
if (isTestEnv()) { if (isTestEnv()) {
return; return;
@@ -594,7 +595,6 @@ const ExcalidrawWrapper = () => {
visibilityChange, visibilityChange,
false, false,
); );
clearTimeout(titleTimeout);
}; };
}, [isCollabDisabled, collabAPI, excalidrawAPI, setLangCode]); }, [isCollabDisabled, collabAPI, excalidrawAPI, setLangCode]);
@@ -669,8 +669,8 @@ const ExcalidrawWrapper = () => {
debugRenderer( debugRenderer(
debugCanvasRef.current, debugCanvasRef.current,
appState, appState,
elements,
window.devicePixelRatio, window.devicePixelRatio,
() => forceRefresh((prev) => !prev),
); );
} }
}; };
@@ -734,6 +734,8 @@ const ExcalidrawWrapper = () => {
const isOffline = useAtomValue(isOfflineAtom); const isOffline = useAtomValue(isOfflineAtom);
const localStorageQuotaExceeded = useAtomValue(localStorageQuotaExceededAtom);
const onCollabDialogOpen = useCallback( const onCollabDialogOpen = useCallback(
() => setShareDialogState({ isOpen: true, type: "collaborationOnly" }), () => setShareDialogState({ isOpen: true, type: "collaborationOnly" }),
[setShareDialogState], [setShareDialogState],
@@ -852,14 +854,22 @@ const ExcalidrawWrapper = () => {
if (isMobile || !collabAPI || isCollabDisabled) { if (isMobile || !collabAPI || isCollabDisabled) {
return null; return null;
} }
return ( return (
<div className="top-right-ui"> <div className="excalidraw-ui-top-right">
{excalidrawAPI?.getEditorInterface().formFactor === "desktop" && (
<ExcalidrawPlusPromoBanner
isSignedIn={isExcalidrawPlusSignedUser}
/>
)}
{collabError.message && <CollabError collabError={collabError} />} {collabError.message && <CollabError collabError={collabError} />}
<LiveCollaborationTrigger <LiveCollaborationTrigger
isCollaborating={isCollaborating} isCollaborating={isCollaborating}
onSelect={() => onSelect={() =>
setShareDialogState({ isOpen: true, type: "share" }) setShareDialogState({ isOpen: true, type: "share" })
} }
editorInterface={editorInterface}
/> />
</div> </div>
); );
@@ -908,10 +918,15 @@ const ExcalidrawWrapper = () => {
<TTDDialogTrigger /> <TTDDialogTrigger />
{isCollaborating && isOffline && ( {isCollaborating && isOffline && (
<div className="collab-offline-warning"> <div className="alertalert--warning">
{t("alerts.collabOfflineWarning")} {t("alerts.collabOfflineWarning")}
</div> </div>
)} )}
{localStorageQuotaExceeded && (
<div className="alert alert--danger">
{t("alerts.localStorageQuotaExceeded")}
</div>
)}
{latestShareableLink && ( {latestShareableLink && (
<ShareableLinkDialog <ShareableLinkDialog
link={latestShareableLink} link={latestShareableLink}
@@ -940,6 +955,8 @@ const ExcalidrawWrapper = () => {
}} }}
/> />
<AppSidebar />
{errorMessage && ( {errorMessage && (
<ErrorDialog onClose={() => setErrorMessage("")}> <ErrorDialog onClose={() => setErrorMessage("")}>
{errorMessage} {errorMessage}
+2 -1
View File
@@ -8,7 +8,8 @@ export const SYNC_BROWSER_TABS_TIMEOUT = 50;
export const CURSOR_SYNC_TIMEOUT = 33; // ~30fps export const CURSOR_SYNC_TIMEOUT = 33; // ~30fps
export const DELETED_ELEMENT_TIMEOUT = 24 * 60 * 60 * 1000; // 1 day 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) // 1 year (https://stackoverflow.com/a/25201898/927631)
export const FILE_CACHE_MAX_AGE_SEC = 31536000; export const FILE_CACHE_MAX_AGE_SEC = 31536000;
+4 -1
View File
@@ -530,7 +530,10 @@ class Collab extends PureComponent<CollabProps, CollabState> {
return null; 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) => { const elements = this.excalidrawAPI.getSceneElements().map((element) => {
if (isImageElement(element) && element.status === "saved") { if (isImageElement(element) && element.status === "saved") {
return newElementWith(element, { status: "pending" }); return newElementWith(element, { status: "pending" });
+1 -6
View File
@@ -5,7 +5,6 @@ import { isExcalidrawPlusSignedUser } from "../app_constants";
import { DebugFooter, isVisualDebuggerEnabled } from "./DebugCanvas"; import { DebugFooter, isVisualDebuggerEnabled } from "./DebugCanvas";
import { EncryptedIcon } from "./EncryptedIcon"; import { EncryptedIcon } from "./EncryptedIcon";
import { ExcalidrawPlusAppLink } from "./ExcalidrawPlusAppLink";
export const AppFooter = React.memo( export const AppFooter = React.memo(
({ onChange }: { onChange: () => void }) => { ({ onChange }: { onChange: () => void }) => {
@@ -19,11 +18,7 @@ export const AppFooter = React.memo(
}} }}
> >
{isVisualDebuggerEnabled() && <DebugFooter onChange={onChange} />} {isVisualDebuggerEnabled() && <DebugFooter onChange={onChange} />}
{isExcalidrawPlusSignedUser ? ( {!isExcalidrawPlusSignedUser && <EncryptedIcon />}
<ExcalidrawPlusAppLink />
) : (
<EncryptedIcon />
)}
</div> </div>
</Footer> </Footer>
); );
+36
View File
@@ -0,0 +1,36 @@
.excalidraw {
.app-sidebar-promo-container {
padding: 0.75rem;
display: flex;
flex-direction: column;
text-align: center;
gap: 1rem;
flex: 1 1 auto;
}
.app-sidebar-promo-image {
margin: 1rem 0;
height: 16.25rem;
background-size: contain;
background-position: center;
background-repeat: no-repeat;
background-image: radial-gradient(
circle,
transparent 60%,
var(--sidebar-bg-color) 100%
),
var(--image-source);
display: flex;
}
.app-sidebar-promo-text {
padding: 0 2rem;
}
.link-button {
margin: 0 auto;
}
}
+79
View File
@@ -0,0 +1,79 @@
import { DefaultSidebar, Sidebar, THEME } from "@excalidraw/excalidraw";
import {
messageCircleIcon,
presentationIcon,
} from "@excalidraw/excalidraw/components/icons";
import { LinkButton } from "@excalidraw/excalidraw/components/LinkButton";
import { useUIAppState } from "@excalidraw/excalidraw/context/ui-appState";
import "./AppSidebar.scss";
export const AppSidebar = () => {
const { theme, openSidebar } = useUIAppState();
return (
<DefaultSidebar>
<DefaultSidebar.TabTriggers>
<Sidebar.TabTrigger
tab="comments"
style={{ opacity: openSidebar?.tab === "comments" ? 1 : 0.4 }}
>
{messageCircleIcon}
</Sidebar.TabTrigger>
<Sidebar.TabTrigger
tab="presentation"
style={{ opacity: openSidebar?.tab === "presentation" ? 1 : 0.4 }}
>
{presentationIcon}
</Sidebar.TabTrigger>
</DefaultSidebar.TabTriggers>
<Sidebar.Tab tab="comments">
<div className="app-sidebar-promo-container">
<div
className="app-sidebar-promo-image"
style={{
["--image-source" as any]: `url(/oss_promo_comments_${
theme === THEME.DARK ? "dark" : "light"
}.jpg)`,
opacity: 0.7,
}}
/>
<div className="app-sidebar-promo-text">
Make comments with Excalidraw+
</div>
<LinkButton
href={`${
import.meta.env.VITE_APP_PLUS_LP
}/plus?utm_source=excalidraw&utm_medium=app&utm_content=comments_promo#excalidraw-redirect`}
>
Sign up now
</LinkButton>
</div>
</Sidebar.Tab>
<Sidebar.Tab tab="presentation" className="px-3">
<div className="app-sidebar-promo-container">
<div
className="app-sidebar-promo-image"
style={{
["--image-source" as any]: `url(/oss_promo_presentations_${
theme === THEME.DARK ? "dark" : "light"
}.svg)`,
backgroundSize: "60%",
opacity: 0.4,
}}
/>
<div className="app-sidebar-promo-text">
Create presentations with Excalidraw+
</div>
<LinkButton
href={`${
import.meta.env.VITE_APP_PLUS_LP
}/plus?utm_source=excalidraw&utm_medium=app&utm_content=presentations_promo#excalidraw-redirect`}
>
Sign up now
</LinkButton>
</div>
</Sidebar.Tab>
</DefaultSidebar>
);
};
+196 -7
View File
@@ -8,21 +8,35 @@ import {
getNormalizedCanvasDimensions, getNormalizedCanvasDimensions,
} from "@excalidraw/excalidraw/renderer/helpers"; } from "@excalidraw/excalidraw/renderer/helpers";
import { type AppState } from "@excalidraw/excalidraw/types"; import { type AppState } from "@excalidraw/excalidraw/types";
import { throttleRAF } from "@excalidraw/common"; import { arrayToMap, throttleRAF } from "@excalidraw/common";
import { useCallback } from "react"; import { useCallback } from "react";
import {
getGlobalFixedPointForBindableElement,
isArrowElement,
isBindableElement,
isFixedPointBinding,
} from "@excalidraw/element";
import { import {
isLineSegment, isLineSegment,
type GlobalPoint, type GlobalPoint,
type LineSegment, type LineSegment,
isCurve,
} from "@excalidraw/math"; } from "@excalidraw/math";
import { isCurve } from "@excalidraw/math/curve";
import React from "react"; import React from "react";
import type { Curve } from "@excalidraw/math"; import type { Curve } from "@excalidraw/math";
import type { DebugElement } from "@excalidraw/common";
import type { DebugElement } from "@excalidraw/utils/visualdebug"; import type {
ElementsMap,
ExcalidrawArrowElement,
ExcalidrawBindableElement,
FixedPointBinding,
OrderedExcalidrawElement,
PointBinding,
} from "@excalidraw/element/types";
import { STORAGE_KEYS } from "../app_constants"; import { STORAGE_KEYS } from "../app_constants";
@@ -75,6 +89,180 @@ const renderOrigin = (context: CanvasRenderingContext2D, zoom: number) => {
context.save(); context.save();
}; };
const _renderBinding = (
context: CanvasRenderingContext2D,
binding: FixedPointBinding | PointBinding,
elementsMap: ElementsMap,
zoom: number,
width: number,
height: number,
color: string,
) => {
if (isFixedPointBinding(binding)) {
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 | PointBinding,
context: CanvasRenderingContext2D,
elementsMap: ElementsMap,
zoom: number,
width: number,
height: number,
color: string,
) => {
if (isFixedPointBinding(binding)) {
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 as FixedPointBinding,
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 = ( const render = (
frame: DebugElement[], frame: DebugElement[],
context: CanvasRenderingContext2D, context: CanvasRenderingContext2D,
@@ -107,8 +295,8 @@ const render = (
const _debugRenderer = ( const _debugRenderer = (
canvas: HTMLCanvasElement, canvas: HTMLCanvasElement,
appState: AppState, appState: AppState,
elements: readonly OrderedExcalidrawElement[],
scale: number, scale: number,
refresh: () => void,
) => { ) => {
const [normalizedWidth, normalizedHeight] = getNormalizedCanvasDimensions( const [normalizedWidth, normalizedHeight] = getNormalizedCanvasDimensions(
canvas, canvas,
@@ -131,6 +319,7 @@ const _debugRenderer = (
); );
renderOrigin(context, appState.zoom.value); renderOrigin(context, appState.zoom.value);
renderBindings(context, elements, appState.zoom.value);
if ( if (
window.visualDebug?.currentFrame && window.visualDebug?.currentFrame &&
@@ -182,10 +371,10 @@ export const debugRenderer = throttleRAF(
( (
canvas: HTMLCanvasElement, canvas: HTMLCanvasElement,
appState: AppState, appState: AppState,
elements: readonly OrderedExcalidrawElement[],
scale: number, scale: number,
refresh: () => void,
) => { ) => {
_debugRenderer(canvas, appState, scale, refresh); _debugRenderer(canvas, appState, elements, scale);
}, },
{ trailing: true }, { trailing: true },
); );
@@ -1,19 +0,0 @@
import { isExcalidrawPlusSignedUser } from "../app_constants";
export const ExcalidrawPlusAppLink = () => {
if (!isExcalidrawPlusSignedUser) {
return null;
}
return (
<a
href={`${
import.meta.env.VITE_APP_PLUS_APP
}?utm_source=excalidraw&utm_medium=app&utm_content=signedInUserRedirectButton#excalidraw-redirect`}
target="_blank"
rel="noopener"
className="plus-button"
>
Go to Excalidraw+
</a>
);
};
@@ -0,0 +1,22 @@
export const ExcalidrawPlusPromoBanner = ({
isSignedIn,
}: {
isSignedIn: boolean;
}) => {
return (
<a
href={
isSignedIn
? import.meta.env.VITE_APP_PLUS_APP
: `${
import.meta.env.VITE_APP_PLUS_LP
}/plus?utm_source=excalidraw&utm_medium=app&utm_content=guestBanner#excalidraw-redirect`
}
target="_blank"
rel="noopener"
className="plus-banner"
>
Excalidraw+
</a>
);
};
+17
View File
@@ -27,6 +27,8 @@ import {
get, get,
} from "idb-keyval"; } from "idb-keyval";
import { appJotaiStore, atom } from "excalidraw-app/app-jotai";
import type { LibraryPersistedData } from "@excalidraw/excalidraw/data/library"; import type { LibraryPersistedData } from "@excalidraw/excalidraw/data/library";
import type { ImportedDataState } from "@excalidraw/excalidraw/data/types"; import type { ImportedDataState } from "@excalidraw/excalidraw/data/types";
import type { ExcalidrawElement, FileId } from "@excalidraw/element/types"; import type { ExcalidrawElement, FileId } from "@excalidraw/element/types";
@@ -45,6 +47,8 @@ import { updateBrowserStateVersion } from "./tabSync";
const filesStore = createStore("files-db", "files-store"); const filesStore = createStore("files-db", "files-store");
export const localStorageQuotaExceededAtom = atom(false);
class LocalFileManager extends FileManager { class LocalFileManager extends FileManager {
clearObsoleteFiles = async (opts: { currentFileIds: FileId[] }) => { clearObsoleteFiles = async (opts: { currentFileIds: FileId[] }) => {
await entries(filesStore).then((entries) => { await entries(filesStore).then((entries) => {
@@ -69,6 +73,9 @@ const saveDataStateToLocalStorage = (
elements: readonly ExcalidrawElement[], elements: readonly ExcalidrawElement[],
appState: AppState, appState: AppState,
) => { ) => {
const localStorageQuotaExceeded = appJotaiStore.get(
localStorageQuotaExceededAtom,
);
try { try {
const _appState = clearAppStateForLocalStorage(appState); const _appState = clearAppStateForLocalStorage(appState);
@@ -88,12 +95,22 @@ const saveDataStateToLocalStorage = (
JSON.stringify(_appState), JSON.stringify(_appState),
); );
updateBrowserStateVersion(STORAGE_KEYS.VERSION_DATA_STATE); updateBrowserStateVersion(STORAGE_KEYS.VERSION_DATA_STATE);
if (localStorageQuotaExceeded) {
appJotaiStore.set(localStorageQuotaExceededAtom, false);
}
} catch (error: any) { } catch (error: any) {
// Unable to access window.localStorage // Unable to access window.localStorage
console.error(error); 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"; type SavingLockTypes = "collaboration";
export class LocalData { export class LocalData {
+3 -1
View File
@@ -259,7 +259,9 @@ export const loadFromFirebase = async (
} }
const storedScene = docSnap.data() as FirebaseStoredScene; const storedScene = docSnap.data() as FirebaseStoredScene;
const elements = getSyncableElements( const elements = getSyncableElements(
restoreElements(await decryptElements(storedScene, roomKey), null), restoreElements(await decryptElements(storedScene, roomKey), null, {
deleteInvisibleElements: true,
}),
); );
if (socket) { if (socket) {
+6 -1
View File
@@ -258,11 +258,16 @@ export const loadScene = async (
await importFromBackend(id, privateKey), await importFromBackend(id, privateKey),
localDataState?.appState, localDataState?.appState,
localDataState?.elements, localDataState?.elements,
{ repairBindings: true, refreshDimensions: false }, {
repairBindings: true,
refreshDimensions: false,
deleteInvisibleElements: true,
},
); );
} else { } else {
data = restore(localDataState || null, null, null, { data = restore(localDataState || null, null, null, {
repairBindings: true, repairBindings: true,
deleteInvisibleElements: true,
}); });
} }
+2 -2
View File
@@ -2,7 +2,7 @@
<html lang="en"> <html lang="en">
<head> <head>
<meta charset="utf-8" /> <meta charset="utf-8" />
<title>Excalidraw | Hand-drawn look & feel • Collaborative • Secure</title> <title>Excalidraw Whiteboard</title>
<meta <meta
name="viewport" name="viewport"
content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no, viewport-fit=cover, shrink-to-fit=no" 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 --> <!-- Primary Meta Tags -->
<meta <meta
name="title" name="title"
content="Excalidraw — Collaborative whiteboarding made easy" content="Free, collaborative whiteboard • Hand-drawn look & feel | Excalidraw"
/> />
<meta <meta
name="description" name="description"
+27 -14
View File
@@ -1,3 +1,5 @@
@import "../packages/excalidraw/css/variables.module.scss";
.excalidraw { .excalidraw {
--color-primary-contrast-offset: #625ee0; // to offset Chubb illusion --color-primary-contrast-offset: #625ee0; // to offset Chubb illusion
@@ -5,12 +7,6 @@
--color-primary-contrast-offset: #726dff; // to offset Chubb illusion --color-primary-contrast-offset: #726dff; // to offset Chubb illusion
} }
.top-right-ui {
display: flex;
justify-content: center;
align-items: flex-start;
}
.footer-center { .footer-center {
justify-content: flex-end; justify-content: flex-end;
margin-top: auto; margin-top: auto;
@@ -58,7 +54,7 @@
} }
} }
.collab-offline-warning { .alert {
pointer-events: none; pointer-events: none;
position: absolute; position: absolute;
top: 6.5rem; top: 6.5rem;
@@ -69,10 +65,18 @@
text-align: center; text-align: center;
line-height: 1.5; line-height: 1.5;
border-radius: var(--border-radius-md); border-radius: var(--border-radius-md);
background-color: var(--color-warning);
color: var(--color-text-warning);
z-index: 6; z-index: 6;
white-space: pre; 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);
}
} }
} }
@@ -82,22 +86,31 @@
} }
} }
.plus-button { .plus-banner {
display: flex; display: flex;
justify-content: center; justify-content: center;
cursor: pointer; cursor: pointer;
align-items: center; align-items: center;
border: 1px solid var(--color-primary); border: 1px solid var(--color-primary);
padding: 0.5rem 0.75rem; padding: 0.5rem 0.875rem;
border-radius: var(--border-radius-lg); border-radius: var(--border-radius-lg);
background-color: var(--island-bg-color); background-color: var(--island-bg-color);
color: var(--color-primary) !important;
text-decoration: none !important; text-decoration: none !important;
font-size: 0.75rem; font-family: var(--ui-font);
font-size: 0.8333rem;
box-sizing: border-box; box-sizing: border-box;
height: var(--lg-button-size); height: var(--lg-button-size);
border: none;
box-shadow: 0 0 0 1px var(--color-surface-lowest);
background-color: var(--color-surface-low);
color: var(--button-color, var(--color-on-surface)) !important;
&:active {
box-shadow: 0 0 0 1px var(--color-brand-active);
}
&:hover { &:hover {
background-color: var(--color-primary); background-color: var(--color-primary);
color: white !important; color: white !important;
@@ -109,7 +122,7 @@
} }
.theme--dark { .theme--dark {
.plus-button { .plus-banner {
&:hover { &:hover {
color: black !important; color: black !important;
} }
+1 -1
View File
@@ -23,7 +23,7 @@
] ]
}, },
"engines": { "engines": {
"node": "18.0.0 - 22.x.x" "node": ">=18.0.0"
}, },
"dependencies": { "dependencies": {
"@excalidraw/random-username": "1.0.0", "@excalidraw/random-username": "1.0.0",
+3 -18
View File
@@ -17,30 +17,15 @@ describe("Test MobileMenu", () => {
beforeEach(async () => { beforeEach(async () => {
await render(<ExcalidrawApp />); await render(<ExcalidrawApp />);
// @ts-ignore h.app.refreshEditorInterface();
h.app.refreshViewportBreakpoints();
// @ts-ignore
h.app.refreshEditorBreakpoints();
}); });
afterAll(() => { afterAll(() => {
restoreOriginalGetBoundingClientRect(); restoreOriginalGetBoundingClientRect();
}); });
it("should set device correctly", () => { it("should set editor interface correctly", () => {
expect(h.app.device).toMatchInlineSnapshot(` expect(h.app.editorInterface.formFactor).toBe("phone");
{
"editor": {
"canFitSidebar": false,
"isMobile": true,
},
"isTouchScreen": false,
"viewport": {
"isLandscape": false,
"isMobile": true,
},
}
`);
}); });
it("should initialize with welcome screen and hide once user interacts", async () => { it("should initialize with welcome screen and hide once user interacts", async () => {
+1 -1
View File
@@ -44,7 +44,7 @@
"vitest-canvas-mock": "0.3.3" "vitest-canvas-mock": "0.3.3"
}, },
"engines": { "engines": {
"node": "18.0.0 - 22.x.x" "node": ">=18.0.0"
}, },
"homepage": ".", "homepage": ".",
"prettier": "@excalidraw/prettier-config", "prettier": "@excalidraw/prettier-config",
+24 -19
View File
@@ -5,17 +5,18 @@ export class BinaryHeap<T> {
sinkDown(idx: number) { sinkDown(idx: number) {
const node = this.content[idx]; const node = this.content[idx];
const nodeScore = this.scoreFunction(node);
while (idx > 0) { while (idx > 0) {
const parentN = ((idx + 1) >> 1) - 1; const parentN = ((idx + 1) >> 1) - 1;
const parent = this.content[parentN]; const parent = this.content[parentN];
if (this.scoreFunction(node) < this.scoreFunction(parent)) { if (nodeScore < this.scoreFunction(parent)) {
this.content[parentN] = node;
this.content[idx] = parent; this.content[idx] = parent;
idx = parentN; // TODO: Optimize idx = parentN; // TODO: Optimize
} else { } else {
break; break;
} }
} }
this.content[idx] = node;
} }
bubbleUp(idx: number) { bubbleUp(idx: number) {
@@ -24,35 +25,39 @@ export class BinaryHeap<T> {
const score = this.scoreFunction(node); const score = this.scoreFunction(node);
while (true) { while (true) {
const child2N = (idx + 1) << 1; const child1N = ((idx + 1) << 1) - 1;
const child1N = child2N - 1; const child2N = child1N + 1;
let swap = null; let smallestIdx = idx;
let child1Score = 0; let smallestScore = score;
// Check left child
if (child1N < length) { if (child1N < length) {
const child1 = this.content[child1N]; const child1Score = this.scoreFunction(this.content[child1N]);
child1Score = this.scoreFunction(child1); if (child1Score < smallestScore) {
if (child1Score < score) { smallestIdx = child1N;
swap = child1N; smallestScore = child1Score;
} }
} }
// Check right child
if (child2N < length) { if (child2N < length) {
const child2 = this.content[child2N]; const child2Score = this.scoreFunction(this.content[child2N]);
const child2Score = this.scoreFunction(child2); if (child2Score < smallestScore) {
if (child2Score < (swap === null ? score : child1Score)) { smallestIdx = child2N;
swap = child2N;
} }
} }
if (swap !== null) { if (smallestIdx === idx) {
this.content[idx] = this.content[swap];
this.content[swap] = node;
idx = swap; // TODO: Optimize
} else {
break; 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) { push(node: T) {
+18 -30
View File
@@ -6,25 +6,6 @@ import type { AppProps, AppState } from "@excalidraw/excalidraw/types";
import { COLOR_PALETTE } from "./colors"; import { COLOR_PALETTE } from "./colors";
export const isDarwin = /Mac|iPod|iPhone|iPad/.test(navigator.platform);
export const 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;
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) ||
// 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 supportsResizeObserver = export const supportsResizeObserver =
typeof window !== "undefined" && "ResizeObserver" in window; typeof window !== "undefined" && "ResizeObserver" in window;
@@ -118,10 +99,13 @@ export const ENV = {
}; };
export const CLASSES = { export const CLASSES = {
SIDEBAR: "sidebar",
SHAPE_ACTIONS_MENU: "App-menu__left", SHAPE_ACTIONS_MENU: "App-menu__left",
ZOOM_ACTIONS: "zoom-actions", ZOOM_ACTIONS: "zoom-actions",
SEARCH_MENU_INPUT_WRAPPER: "layer-ui__search-inputWrapper", SEARCH_MENU_INPUT_WRAPPER: "layer-ui__search-inputWrapper",
CONVERT_ELEMENT_TYPE_POPUP: "ConvertElementTypePopup", CONVERT_ELEMENT_TYPE_POPUP: "ConvertElementTypePopup",
SHAPE_ACTIONS_THEME_SCOPE: "shape-actions-theme-scope",
FRAME_NAME: "frame-name",
}; };
export const CJK_HAND_DRAWN_FALLBACK_FONT = "Xiaolai"; export const CJK_HAND_DRAWN_FALLBACK_FONT = "Xiaolai";
@@ -252,13 +236,20 @@ export const IMAGE_MIME_TYPES = {
jfif: "image/jfif", jfif: "image/jfif",
} as const; } as const;
export const MIME_TYPES = { export const STRING_MIME_TYPES = {
text: "text/plain", text: "text/plain",
html: "text/html", html: "text/html",
json: "application/json", json: "application/json",
// excalidraw data // excalidraw data
excalidraw: "application/vnd.excalidraw+json", excalidraw: "application/vnd.excalidraw+json",
// LEGACY: fully-qualified library JSON data
excalidrawlib: "application/vnd.excalidrawlib+json", 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 // image-encoded excalidraw data
"excalidraw.svg": "image/svg+xml", "excalidraw.svg": "image/svg+xml",
"excalidraw.png": "image/png", "excalidraw.png": "image/png",
@@ -333,16 +324,6 @@ export const DEFAULT_UI_OPTIONS: AppProps["UIOptions"] = {
}, },
}; };
// breakpoints
// -----------------------------------------------------------------------------
// md screen
export const MQ_MAX_WIDTH_PORTRAIT = 730;
export const MQ_MAX_WIDTH_LANDSCAPE = 1000;
export const MQ_MAX_HEIGHT_LANDSCAPE = 500;
// sidebar
export const MQ_RIGHT_SIDEBAR_MIN_WIDTH = 1229;
// -----------------------------------------------------------------------------
export const MAX_DECIMALS_FOR_SVG_EXPORT = 2; export const MAX_DECIMALS_FOR_SVG_EXPORT = 2;
export const EXPORT_SCALES = [1, 2, 3]; export const EXPORT_SCALES = [1, 2, 3];
@@ -515,3 +496,10 @@ export enum UserIdleState {
* the start and end points) * the start and end points)
*/ */
export const LINE_POLYGON_POINT_MERGE_DISTANCE = 20; export const LINE_POLYGON_POINT_MERGE_DISTANCE = 20;
export const DOUBLE_TAP_POSITION_THRESHOLD = 35;
// glass background for mobile action buttons
export const MOBILE_ACTION_BUTTON_BG = {
background: "var(--mobile-action-button-bg)",
} as const;
+223
View File
@@ -0,0 +1,223 @@
export type StylesPanelMode = "compact" | "full" | "mobile";
export type EditorInterface = Readonly<{
formFactor: "phone" | "tablet" | "desktop";
desktopUIMode: "compact" | "full";
userAgent: Readonly<{
isMobileDevice: boolean;
platform: "ios" | "android" | "other" | "unknown";
}>;
isTouchScreen: boolean;
canFitSidebar: boolean;
isLandscape: boolean;
}>;
// storage key
const DESKTOP_UI_MODE_STORAGE_KEY = "excalidraw.desktopUIMode";
// breakpoints
// 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;
// -----------------------------------------------------------------------------
// user agent detections
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;
export const isChrome = navigator.userAgent.indexOf("Chrome") !== -1;
export const isSafari =
!isChrome && navigator.userAgent.indexOf("Safari") !== -1;
export const isIOS =
/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);
// utilities
export const isMobileBreakpoint = (width: number, height: number) => {
return (
width <= MQ_MAX_MOBILE ||
(height < MQ_MAX_HEIGHT_LANDSCAPE && width < MQ_MAX_WIDTH_LANDSCAPE)
);
};
export const isTabletBreakpoint = (
editorWidth: number,
editorHeight: number,
) => {
const minSide = Math.min(editorWidth, editorHeight);
const maxSide = Math.max(editorWidth, editorHeight);
return minSide >= MQ_MIN_TABLET && maxSide <= MQ_MAX_TABLET;
};
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;
};
export const getFormFactor = (
editorWidth: number,
editorHeight: number,
): EditorInterface["formFactor"] => {
if (isMobileBreakpoint(editorWidth, editorHeight)) {
return "phone";
}
if (isTabletBreakpoint(editorWidth, editorHeight)) {
return "tablet";
}
return "desktop";
};
export const deriveStylesPanelMode = (
editorInterface: EditorInterface,
): StylesPanelMode => {
if (editorInterface.formFactor === "phone") {
return "mobile";
}
if (editorInterface.formFactor === "tablet") {
return "compact";
}
return editorInterface.desktopUIMode;
};
export const createUserAgentDescriptor = (
userAgentString: string,
): EditorInterface["userAgent"] => {
const normalizedUA = userAgentString ?? "";
let platform: EditorInterface["userAgent"]["platform"] = "unknown";
if (isIOS) {
platform = "ios";
} else if (isAndroid) {
platform = "android";
} else if (normalizedUA) {
platform = "other";
}
return {
isMobileDevice: isMobileOrTablet(),
platform,
} as const;
};
export const loadDesktopUIModePreference = () => {
if (typeof window === "undefined") {
return null;
}
try {
const stored = window.localStorage.getItem(DESKTOP_UI_MODE_STORAGE_KEY);
if (stored === "compact" || stored === "full") {
return stored as EditorInterface["desktopUIMode"];
}
} catch (error) {
// ignore storage access issues (e.g., Safari private mode)
}
return null;
};
const persistDesktopUIMode = (mode: EditorInterface["desktopUIMode"]) => {
if (typeof window === "undefined") {
return;
}
try {
window.localStorage.setItem(DESKTOP_UI_MODE_STORAGE_KEY, mode);
} catch (error) {
// ignore storage access issues (e.g., Safari private mode)
}
};
export const setDesktopUIMode = (mode: EditorInterface["desktopUIMode"]) => {
if (mode !== "compact" && mode !== "full") {
return;
}
persistDesktopUIMode(mode);
return mode;
};
+2
View File
@@ -10,3 +10,5 @@ export * from "./random";
export * from "./url"; export * from "./url";
export * from "./utils"; export * from "./utils";
export * from "./emitter"; export * from "./emitter";
export * from "./visualdebug";
export * from "./editorInterface";
+1 -1
View File
@@ -1,4 +1,4 @@
import { isDarwin } from "./constants"; import { isDarwin } from "./editorInterface";
import type { ValueOf } from "./utility-types"; import type { ValueOf } from "./utility-types";
+7 -15
View File
@@ -20,7 +20,6 @@ import {
ENV, ENV,
FONT_FAMILY, FONT_FAMILY,
getFontFamilyFallbacks, getFontFamilyFallbacks,
isDarwin,
WINDOWS_EMOJI_FALLBACK_FONT, WINDOWS_EMOJI_FALLBACK_FONT,
} from "./constants"; } from "./constants";
@@ -91,7 +90,8 @@ export const isWritableElement = (
(target instanceof HTMLInputElement && (target instanceof HTMLInputElement &&
(target.type === "text" || (target.type === "text" ||
target.type === "number" || target.type === "number" ||
target.type === "password")); target.type === "password" ||
target.type === "search"));
export const getFontFamilyString = ({ export const getFontFamilyString = ({
fontFamily, fontFamily,
@@ -119,6 +119,11 @@ export const getFontString = ({
return `${fontSize}px ${getFontFamilyString({ fontFamily })}` as FontString; 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[]>( export const debounce = <T extends any[]>(
fn: (...args: T) => void, fn: (...args: T) => void,
timeout: number, timeout: number,
@@ -418,19 +423,6 @@ export const allowFullScreen = () =>
export const exitFullScreen = () => document.exitFullscreen(); export const exitFullScreen = () => document.exitFullscreen();
export const getShortcutKey = (shortcut: string): string => {
shortcut = shortcut
.replace(/\bAlt\b/i, "Alt")
.replace(/\bShift\b/i, "Shift")
.replace(/\b(Enter|Return)\b/i, "Enter");
if (isDarwin) {
return shortcut
.replace(/\bCtrlOrCmd\b/gi, "Cmd")
.replace(/\bAlt\b/i, "Option");
}
return shortcut.replace(/\bCtrlOrCmd\b/gi, "Ctrl");
};
export const viewportCoordsToSceneCoords = ( export const viewportCoordsToSceneCoords = (
{ clientX, clientY }: { clientX: number; clientY: number }, { clientX, clientY }: { clientX: number; clientY: number },
{ {
+16 -4
View File
@@ -164,9 +164,14 @@ export class Scene {
return this.frames; return this.frames;
} }
constructor(elements: ElementsMapOrArray | null = null) { constructor(
elements: ElementsMapOrArray | null = null,
options?: {
skipValidation?: true;
},
) {
if (elements) { if (elements) {
this.replaceAllElements(elements); this.replaceAllElements(elements, options);
} }
} }
@@ -263,12 +268,19 @@ export class Scene {
return didChange; return didChange;
} }
replaceAllElements(nextElements: ElementsMapOrArray) { replaceAllElements(
nextElements: ElementsMapOrArray,
options?: {
skipValidation?: true;
},
) {
// we do trust the insertion order on the map, though maybe we shouldn't and should prefer order defined by fractional indices // 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 _nextElements = toArray(nextElements);
const nextFrameLikes: ExcalidrawFrameLikeElement[] = []; const nextFrameLikes: ExcalidrawFrameLikeElement[] = [];
validateIndicesThrottled(_nextElements); if (!options?.skipValidation) {
validateIndicesThrottled(_nextElements);
}
this.elements = syncInvalidIndices(_nextElements); this.elements = syncInvalidIndices(_nextElements);
this.elementsMap.clear(); this.elementsMap.clear();
+23
View File
@@ -999,6 +999,29 @@ export const bindPointToSnapToElementOutline = (
intersector, intersector,
FIXED_BINDING_DISTANCE, FIXED_BINDING_DISTANCE,
).sort(pointDistanceSq)[0]; ).sort(pointDistanceSq)[0];
if (!intersection) {
const anotherPoint = pointFrom<GlobalPoint>(
!isHorizontal ? center[0] : snapPoint[0],
isHorizontal ? center[1] : snapPoint[1],
);
const anotherIntersector = lineSegment(
anotherPoint,
pointFromVector(
vectorScale(
vectorNormalize(vectorFromPoint(snapPoint, anotherPoint)),
Math.max(bindableElement.width, bindableElement.height) * 2,
),
anotherPoint,
),
);
intersection = intersectElementWithLineSegment(
bindableElement,
elementsMap,
anotherIntersector,
FIXED_BINDING_DISTANCE,
).sort(pointDistanceSq)[0];
}
} else { } else {
intersection = intersectElementWithLineSegment( intersection = intersectElementWithLineSegment(
bindableElement, bindableElement,
+39 -13
View File
@@ -42,6 +42,7 @@ import {
isBoundToContainer, isBoundToContainer,
isFreeDrawElement, isFreeDrawElement,
isLinearElement, isLinearElement,
isLineElement,
isTextElement, isTextElement,
} from "./typeChecks"; } from "./typeChecks";
@@ -321,19 +322,42 @@ export const getElementLineSegments = (
if (shape.type === "polycurve") { if (shape.type === "polycurve") {
const curves = shape.data; const curves = shape.data;
const points = curves const pointsOnCurves = curves.map((curve) =>
.map((curve) => pointsOnBezierCurves(curve, 10)) pointsOnBezierCurves(curve, 10),
.flat(); );
let i = 0;
const segments: LineSegment<GlobalPoint>[] = []; const segments: LineSegment<GlobalPoint>[] = [];
while (i < points.length - 1) {
segments.push( if (
lineSegment( (isLineElement(element) && !element.polygon) ||
pointFrom(points[i][0], points[i][1]), isArrowElement(element)
pointFrom(points[i + 1][0], points[i + 1][1]), ) {
), for (const points of pointsOnCurves) {
); let i = 0;
i++;
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; return segments;
@@ -1126,7 +1150,9 @@ export interface BoundingBox {
} }
export const getCommonBoundingBox = ( export const getCommonBoundingBox = (
elements: ExcalidrawElement[] | readonly NonDeleted<ExcalidrawElement>[], elements:
| readonly ExcalidrawElement[]
| readonly NonDeleted<ExcalidrawElement>[],
): BoundingBox => { ): BoundingBox => {
const [minX, minY, maxX, maxY] = getCommonBounds(elements); const [minX, minY, maxX, maxY] = getCommonBounds(elements);
return { return {
+1 -4
View File
@@ -11,12 +11,9 @@ import {
vectorFromPoint, vectorFromPoint,
vectorNormalize, vectorNormalize,
vectorScale, vectorScale,
} from "@excalidraw/math";
import {
ellipse, ellipse,
ellipseSegmentInterceptPoints, ellipseSegmentInterceptPoints,
} from "@excalidraw/math/ellipse"; } from "@excalidraw/math";
import type { import type {
Curve, Curve,
+7 -1
View File
@@ -10,7 +10,13 @@ export const hasBackground = (type: ElementOrToolType) =>
type === "freedraw"; type === "freedraw";
export const hasStrokeColor = (type: ElementOrToolType) => export const hasStrokeColor = (type: ElementOrToolType) =>
type !== "image" && type !== "frame" && type !== "magicframe"; type === "rectangle" ||
type === "ellipse" ||
type === "diamond" ||
type === "freedraw" ||
type === "arrow" ||
type === "line" ||
type === "text";
export const hasStrokeWidth = (type: ElementOrToolType) => export const hasStrokeWidth = (type: ElementOrToolType) =>
type === "rectangle" || type === "rectangle" ||
+367 -157
View File
@@ -2,7 +2,6 @@ import {
arrayToMap, arrayToMap,
arrayToObject, arrayToObject,
assertNever, assertNever,
invariant,
isDevEnv, isDevEnv,
isShallowEqual, isShallowEqual,
isTestEnv, isTestEnv,
@@ -56,10 +55,10 @@ import { getNonDeletedGroupIds } from "./groups";
import { orderByFractionalIndex, syncMovedIndices } from "./fractionalIndex"; import { orderByFractionalIndex, syncMovedIndices } from "./fractionalIndex";
import { Scene } from "./Scene";
import { StoreSnapshot } from "./store"; import { StoreSnapshot } from "./store";
import { Scene } from "./Scene";
import type { BindableProp, BindingProp } from "./binding"; import type { BindableProp, BindingProp } from "./binding";
import type { ElementUpdate } from "./mutateElement"; import type { ElementUpdate } from "./mutateElement";
@@ -151,13 +150,27 @@ export class Delta<T> {
); );
} }
/**
* Merges two deltas into a new one.
*/
public static merge<T>(
delta1: Delta<T>,
delta2: Delta<T>,
delta3: Delta<T> = Delta.empty(),
) {
return Delta.create(
{ ...delta1.deleted, ...delta2.deleted, ...delta3.deleted },
{ ...delta1.inserted, ...delta2.inserted, ...delta3.inserted },
);
}
/** /**
* Merges deleted and inserted object partials. * Merges deleted and inserted object partials.
*/ */
public static mergeObjects<T extends { [key: string]: unknown }>( public static mergeObjects<T extends { [key: string]: unknown }>(
prev: T, prev: T,
added: T, added: T,
removed: T, removed: T = {} as T,
) { ) {
const cloned = { ...prev }; const cloned = { ...prev };
@@ -497,6 +510,11 @@ export interface DeltaContainer<T> {
*/ */
applyTo(previous: T, ...options: unknown[]): [T, boolean]; applyTo(previous: T, ...options: unknown[]): [T, boolean];
/**
* Squashes the current delta with the given one.
*/
squash(delta: DeltaContainer<T>): this;
/** /**
* Checks whether all `Delta`s are empty. * Checks whether all `Delta`s are empty.
*/ */
@@ -504,7 +522,11 @@ export interface DeltaContainer<T> {
} }
export class AppStateDelta implements DeltaContainer<AppState> { export class AppStateDelta implements DeltaContainer<AppState> {
private constructor(public readonly delta: Delta<ObservedAppState>) {} private constructor(public delta: Delta<ObservedAppState>) {}
public static create(delta: Delta<ObservedAppState>): AppStateDelta {
return new AppStateDelta(delta);
}
public static calculate<T extends ObservedAppState>( public static calculate<T extends ObservedAppState>(
prevAppState: T, prevAppState: T,
@@ -535,76 +557,137 @@ export class AppStateDelta implements DeltaContainer<AppState> {
return new AppStateDelta(inversedDelta); return new AppStateDelta(inversedDelta);
} }
public squash(delta: AppStateDelta): this {
if (delta.isEmpty()) {
return this;
}
const mergedDeletedSelectedElementIds = Delta.mergeObjects(
this.delta.deleted.selectedElementIds ?? {},
delta.delta.deleted.selectedElementIds ?? {},
);
const mergedInsertedSelectedElementIds = Delta.mergeObjects(
this.delta.inserted.selectedElementIds ?? {},
delta.delta.inserted.selectedElementIds ?? {},
);
const mergedDeletedSelectedGroupIds = Delta.mergeObjects(
this.delta.deleted.selectedGroupIds ?? {},
delta.delta.deleted.selectedGroupIds ?? {},
);
const mergedInsertedSelectedGroupIds = Delta.mergeObjects(
this.delta.inserted.selectedGroupIds ?? {},
delta.delta.inserted.selectedGroupIds ?? {},
);
const mergedDeletedLockedMultiSelections = Delta.mergeObjects(
this.delta.deleted.lockedMultiSelections ?? {},
delta.delta.deleted.lockedMultiSelections ?? {},
);
const mergedInsertedLockedMultiSelections = Delta.mergeObjects(
this.delta.inserted.lockedMultiSelections ?? {},
delta.delta.inserted.lockedMultiSelections ?? {},
);
const mergedInserted: Partial<ObservedAppState> = {};
const mergedDeleted: Partial<ObservedAppState> = {};
if (
Object.keys(mergedDeletedSelectedElementIds).length ||
Object.keys(mergedInsertedSelectedElementIds).length
) {
mergedDeleted.selectedElementIds = mergedDeletedSelectedElementIds;
mergedInserted.selectedElementIds = mergedInsertedSelectedElementIds;
}
if (
Object.keys(mergedDeletedSelectedGroupIds).length ||
Object.keys(mergedInsertedSelectedGroupIds).length
) {
mergedDeleted.selectedGroupIds = mergedDeletedSelectedGroupIds;
mergedInserted.selectedGroupIds = mergedInsertedSelectedGroupIds;
}
if (
Object.keys(mergedDeletedLockedMultiSelections).length ||
Object.keys(mergedInsertedLockedMultiSelections).length
) {
mergedDeleted.lockedMultiSelections = mergedDeletedLockedMultiSelections;
mergedInserted.lockedMultiSelections =
mergedInsertedLockedMultiSelections;
}
this.delta = Delta.merge(
this.delta,
delta.delta,
Delta.create(mergedDeleted, mergedInserted),
);
return this;
}
public applyTo( public applyTo(
appState: AppState, appState: AppState,
nextElements: SceneElementsMap, nextElements: SceneElementsMap,
): [AppState, boolean] { ): [AppState, boolean] {
try { try {
const { const {
selectedElementIds: removedSelectedElementIds = {}, selectedElementIds: deletedSelectedElementIds = {},
selectedGroupIds: removedSelectedGroupIds = {}, selectedGroupIds: deletedSelectedGroupIds = {},
lockedMultiSelections: deletedLockedMultiSelections = {},
} = this.delta.deleted; } = this.delta.deleted;
const { const {
selectedElementIds: addedSelectedElementIds = {}, selectedElementIds: insertedSelectedElementIds = {},
selectedGroupIds: addedSelectedGroupIds = {}, selectedGroupIds: insertedSelectedGroupIds = {},
selectedLinearElementId, lockedMultiSelections: insertedLockedMultiSelections = {},
selectedLinearElementIsEditing, selectedLinearElement: insertedSelectedLinearElement,
...directlyApplicablePartial ...directlyApplicablePartial
} = this.delta.inserted; } = this.delta.inserted;
const mergedSelectedElementIds = Delta.mergeObjects( const mergedSelectedElementIds = Delta.mergeObjects(
appState.selectedElementIds, appState.selectedElementIds,
addedSelectedElementIds, insertedSelectedElementIds,
removedSelectedElementIds, deletedSelectedElementIds,
); );
const mergedSelectedGroupIds = Delta.mergeObjects( const mergedSelectedGroupIds = Delta.mergeObjects(
appState.selectedGroupIds, appState.selectedGroupIds,
addedSelectedGroupIds, insertedSelectedGroupIds,
removedSelectedGroupIds, deletedSelectedGroupIds,
); );
let selectedLinearElement = appState.selectedLinearElement; const mergedLockedMultiSelections = Delta.mergeObjects(
appState.lockedMultiSelections,
insertedLockedMultiSelections,
deletedLockedMultiSelections,
);
if (selectedLinearElementId === null) { const selectedLinearElement =
// Unselect linear element (visible change) insertedSelectedLinearElement &&
selectedLinearElement = null; nextElements.has(insertedSelectedLinearElement.elementId)
} else if ( ? new LinearElementEditor(
selectedLinearElementId && nextElements.get(
nextElements.has(selectedLinearElementId) insertedSelectedLinearElement.elementId,
) { ) as NonDeleted<ExcalidrawLinearElement>,
selectedLinearElement = new LinearElementEditor( nextElements,
nextElements.get( insertedSelectedLinearElement.isEditing,
selectedLinearElementId, )
) as NonDeleted<ExcalidrawLinearElement>, : null;
nextElements,
selectedLinearElementIsEditing === true, // Can be unknown which is defaulted to false
);
}
if (
// Value being 'null' is equivaluent to unknown in this case because it only gets set
// to null when 'selectedLinearElementId' is set to null
selectedLinearElementIsEditing != null
) {
invariant(
selectedLinearElement,
`selectedLinearElement is null when selectedLinearElementIsEditing is set to ${selectedLinearElementIsEditing}`,
);
selectedLinearElement = {
...selectedLinearElement,
isEditing: selectedLinearElementIsEditing,
};
}
const nextAppState = { const nextAppState = {
...appState, ...appState,
...directlyApplicablePartial, ...directlyApplicablePartial,
selectedElementIds: mergedSelectedElementIds, selectedElementIds: mergedSelectedElementIds,
selectedGroupIds: mergedSelectedGroupIds, selectedGroupIds: mergedSelectedGroupIds,
selectedLinearElement, lockedMultiSelections: mergedLockedMultiSelections,
selectedLinearElement:
typeof insertedSelectedLinearElement !== "undefined"
? selectedLinearElement
: appState.selectedLinearElement,
}; };
const constainsVisibleChanges = this.filterInvisibleChanges( const constainsVisibleChanges = this.filterInvisibleChanges(
@@ -733,64 +816,53 @@ export class AppStateDelta implements DeltaContainer<AppState> {
} }
break; break;
case "selectedLinearElementId": { case "selectedLinearElement":
const appStateKey = AppStateDelta.convertToAppStateKey(key); const nextLinearElement = nextAppState[key];
const linearElement = nextAppState[appStateKey];
if (!linearElement) { if (!nextLinearElement) {
// previously there was a linear element (assuming visible), now there is none // previously there was a linear element (assuming visible), now there is none
visibleDifferenceFlag.value = true; visibleDifferenceFlag.value = true;
} else { } else {
const element = nextElements.get(linearElement.elementId); const element = nextElements.get(nextLinearElement.elementId);
if (element && !element.isDeleted) { if (element && !element.isDeleted) {
// previously there wasn't a linear element, now there is one which is visible // previously there wasn't a linear element, now there is one which is visible
visibleDifferenceFlag.value = true; visibleDifferenceFlag.value = true;
} else { } else {
// there was assigned a linear element now, but it's deleted // there was assigned a linear element now, but it's deleted
nextAppState[appStateKey] = null; nextAppState[key] = null;
} }
} }
break; break;
} case "lockedMultiSelections":
case "selectedLinearElementIsEditing": {
// Changes in editing state are always visible
const prevIsEditing =
prevAppState.selectedLinearElement?.isEditing ?? false;
const nextIsEditing =
nextAppState.selectedLinearElement?.isEditing ?? false;
if (prevIsEditing !== nextIsEditing) {
visibleDifferenceFlag.value = true;
}
break;
}
case "lockedMultiSelections": {
const prevLockedUnits = prevAppState[key] || {}; const prevLockedUnits = prevAppState[key] || {};
const nextLockedUnits = nextAppState[key] || {}; const nextLockedUnits = nextAppState[key] || {};
// TODO: this seems wrong, we are already doing this comparison generically above,
// hence instead we should check whether elements are actually visible,
// so that once these changes are applied they actually result in a visible change to the user
if (!isShallowEqual(prevLockedUnits, nextLockedUnits)) { if (!isShallowEqual(prevLockedUnits, nextLockedUnits)) {
visibleDifferenceFlag.value = true; visibleDifferenceFlag.value = true;
} }
break; break;
} case "activeLockedId":
case "activeLockedId": {
const prevHitLockedId = prevAppState[key] || null; const prevHitLockedId = prevAppState[key] || null;
const nextHitLockedId = nextAppState[key] || null; const nextHitLockedId = nextAppState[key] || null;
// TODO: this seems wrong, we are already doing this comparison generically above,
// hence instead we should check whether elements are actually visible,
// so that once these changes are applied they actually result in a visible change to the user
if (prevHitLockedId !== nextHitLockedId) { if (prevHitLockedId !== nextHitLockedId) {
visibleDifferenceFlag.value = true; visibleDifferenceFlag.value = true;
} }
break; break;
} default:
default: {
assertNever( assertNever(
key, key,
`Unknown ObservedElementsAppState's key "${key}"`, `Unknown ObservedElementsAppState's key "${key}"`,
true, true,
); );
}
} }
} }
} }
@@ -798,15 +870,6 @@ export class AppStateDelta implements DeltaContainer<AppState> {
return visibleDifferenceFlag.value; return visibleDifferenceFlag.value;
} }
private static convertToAppStateKey(
key: keyof Pick<ObservedElementsAppState, "selectedLinearElementId">,
): keyof Pick<AppState, "selectedLinearElement"> {
switch (key) {
case "selectedLinearElementId":
return "selectedLinearElement";
}
}
private static filterSelectedElements( private static filterSelectedElements(
selectedElementIds: AppState["selectedElementIds"], selectedElementIds: AppState["selectedElementIds"],
elements: SceneElementsMap, elements: SceneElementsMap,
@@ -871,8 +934,7 @@ export class AppStateDelta implements DeltaContainer<AppState> {
editingGroupId, editingGroupId,
selectedGroupIds, selectedGroupIds,
selectedElementIds, selectedElementIds,
selectedLinearElementId, selectedLinearElement,
selectedLinearElementIsEditing,
croppingElementId, croppingElementId,
lockedMultiSelections, lockedMultiSelections,
activeLockedId, activeLockedId,
@@ -926,12 +988,6 @@ export class AppStateDelta implements DeltaContainer<AppState> {
"lockedMultiSelections", "lockedMultiSelections",
(prevValue) => (prevValue ?? {}) as ValueOf<T["lockedMultiSelections"]>, (prevValue) => (prevValue ?? {}) as ValueOf<T["lockedMultiSelections"]>,
); );
Delta.diffObjects(
deleted,
inserted,
"activeLockedId",
(prevValue) => (prevValue ?? null) as ValueOf<T["activeLockedId"]>,
);
} catch (e) { } catch (e) {
// if postprocessing fails it does not make sense to bubble up, but let's make sure we know about it // 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.`); console.error(`Couldn't postprocess appstate change deltas.`);
@@ -960,12 +1016,13 @@ type ElementPartial<TElement extends ExcalidrawElement = ExcalidrawElement> =
Omit<Partial<Ordered<TElement>>, "id" | "updated" | "seed">; Omit<Partial<Ordered<TElement>>, "id" | "updated" | "seed">;
export type ApplyToOptions = { export type ApplyToOptions = {
excludedProperties: Set<keyof ElementPartial>; excludedProperties?: Set<keyof ElementPartial>;
}; };
type ApplyToFlags = { type ApplyToFlags = {
containsVisibleDifference: boolean; containsVisibleDifference: boolean;
containsZindexDifference: boolean; containsZindexDifference: boolean;
applyDirection: "forward" | "backward" | undefined;
}; };
/** /**
@@ -1054,18 +1111,27 @@ export class ElementsDelta implements DeltaContainer<SceneElementsMap> {
inserted, inserted,
}: Delta<ElementPartial>) => }: Delta<ElementPartial>) =>
!!( !!(
deleted.version &&
inserted.version &&
// versions are required integers // versions are required integers
Number.isInteger(deleted.version) && (
Number.isInteger(inserted.version) && Number.isInteger(deleted.version) &&
// versions should be positive, zero included Number.isInteger(inserted.version) &&
deleted.version >= 0 && // versions should be positive, zero included
inserted.version >= 0 && deleted.version! >= 0 &&
// versions should never be the same inserted.version! >= 0 &&
deleted.version !== inserted.version // versions should never be the same
deleted.version !== inserted.version
)
); );
private static satisfiesUniqueInvariants = (
elementsDelta: ElementsDelta,
id: string,
) => {
const { added, removed, updated } = elementsDelta;
// it's required that there is only one unique delta type per element
return [added[id], removed[id], updated[id]].filter(Boolean).length === 1;
};
private static validate( private static validate(
elementsDelta: ElementsDelta, elementsDelta: ElementsDelta,
type: "added" | "removed" | "updated", type: "added" | "removed" | "updated",
@@ -1074,6 +1140,7 @@ export class ElementsDelta implements DeltaContainer<SceneElementsMap> {
for (const [id, delta] of Object.entries(elementsDelta[type])) { for (const [id, delta] of Object.entries(elementsDelta[type])) {
if ( if (
!this.satisfiesCommmonInvariants(delta) || !this.satisfiesCommmonInvariants(delta) ||
!this.satisfiesUniqueInvariants(elementsDelta, id) ||
!satifiesSpecialInvariants(delta) !satifiesSpecialInvariants(delta)
) { ) {
console.error( console.error(
@@ -1110,7 +1177,7 @@ export class ElementsDelta implements DeltaContainer<SceneElementsMap> {
const nextElement = nextElements.get(prevElement.id); const nextElement = nextElements.get(prevElement.id);
if (!nextElement) { if (!nextElement) {
const deleted = { ...prevElement, isDeleted: false } as ElementPartial; const deleted = { ...prevElement } as ElementPartial;
const inserted = { const inserted = {
isDeleted: true, isDeleted: true,
@@ -1124,7 +1191,11 @@ export class ElementsDelta implements DeltaContainer<SceneElementsMap> {
ElementsDelta.stripIrrelevantProps, ElementsDelta.stripIrrelevantProps,
); );
removed[prevElement.id] = delta; if (!prevElement.isDeleted) {
removed[prevElement.id] = delta;
} else {
updated[prevElement.id] = delta;
}
} }
} }
@@ -1140,7 +1211,6 @@ export class ElementsDelta implements DeltaContainer<SceneElementsMap> {
const inserted = { const inserted = {
...nextElement, ...nextElement,
isDeleted: false,
} as ElementPartial; } as ElementPartial;
const delta = Delta.create( const delta = Delta.create(
@@ -1149,7 +1219,12 @@ export class ElementsDelta implements DeltaContainer<SceneElementsMap> {
ElementsDelta.stripIrrelevantProps, ElementsDelta.stripIrrelevantProps,
); );
added[nextElement.id] = delta; // ignore updates which would "delete" already deleted element
if (!nextElement.isDeleted) {
added[nextElement.id] = delta;
} else {
updated[nextElement.id] = delta;
}
continue; continue;
} }
@@ -1178,10 +1253,7 @@ export class ElementsDelta implements DeltaContainer<SceneElementsMap> {
continue; continue;
} }
// making sure there are at least some changes updated[nextElement.id] = delta;
if (!Delta.isEmpty(delta)) {
updated[nextElement.id] = delta;
}
} }
} }
@@ -1196,8 +1268,8 @@ export class ElementsDelta implements DeltaContainer<SceneElementsMap> {
const inverseInternal = (deltas: Record<string, Delta<ElementPartial>>) => { const inverseInternal = (deltas: Record<string, Delta<ElementPartial>>) => {
const inversedDeltas: Record<string, Delta<ElementPartial>> = {}; const inversedDeltas: Record<string, Delta<ElementPartial>> = {};
for (const [id, delta] of Object.entries(deltas)) { for (const [id, { inserted, deleted }] of Object.entries(deltas)) {
inversedDeltas[id] = Delta.create(delta.inserted, delta.deleted); inversedDeltas[id] = Delta.create({ ...inserted }, { ...deleted });
} }
return inversedDeltas; return inversedDeltas;
@@ -1316,9 +1388,7 @@ export class ElementsDelta implements DeltaContainer<SceneElementsMap> {
public applyTo( public applyTo(
elements: SceneElementsMap, elements: SceneElementsMap,
snapshot: StoreSnapshot["elements"] = StoreSnapshot.empty().elements, snapshot: StoreSnapshot["elements"] = StoreSnapshot.empty().elements,
options: ApplyToOptions = { options?: ApplyToOptions,
excludedProperties: new Set(),
},
): [SceneElementsMap, boolean] { ): [SceneElementsMap, boolean] {
let nextElements = new Map(elements) as SceneElementsMap; let nextElements = new Map(elements) as SceneElementsMap;
let changedElements: Map<string, OrderedExcalidrawElement>; let changedElements: Map<string, OrderedExcalidrawElement>;
@@ -1326,22 +1396,28 @@ export class ElementsDelta implements DeltaContainer<SceneElementsMap> {
const flags: ApplyToFlags = { const flags: ApplyToFlags = {
containsVisibleDifference: false, containsVisibleDifference: false,
containsZindexDifference: false, containsZindexDifference: false,
applyDirection: undefined,
}; };
// mimic a transaction by applying deltas into `nextElements` (always new instance, no mutation) // mimic a transaction by applying deltas into `nextElements` (always new instance, no mutation)
try { try {
const applyDeltas = ElementsDelta.createApplier( const applyDeltas = ElementsDelta.createApplier(
elements,
nextElements, nextElements,
snapshot, snapshot,
options,
flags, flags,
options,
); );
const addedElements = applyDeltas(this.added); const addedElements = applyDeltas(this.added);
const removedElements = applyDeltas(this.removed); const removedElements = applyDeltas(this.removed);
const updatedElements = applyDeltas(this.updated); const updatedElements = applyDeltas(this.updated);
const affectedElements = this.resolveConflicts(elements, nextElements); const affectedElements = this.resolveConflicts(
elements,
nextElements,
flags.applyDirection,
);
// TODO: #7348 validate elements semantically and syntactically the changed elements, in case they would result data integrity issues // TODO: #7348 validate elements semantically and syntactically the changed elements, in case they would result data integrity issues
changedElements = new Map([ changedElements = new Map([
@@ -1365,22 +1441,15 @@ export class ElementsDelta implements DeltaContainer<SceneElementsMap> {
} }
try { try {
// the following reorder performs also mutations, but only on new instances of changed elements // the following reorder performs mutations, but only on new instances of changed elements,
// (unless something goes really bad and it fallbacks to fixing all invalid indices) // unless something goes really bad and it fallbacks to fixing all invalid indices
nextElements = ElementsDelta.reorderElements( nextElements = ElementsDelta.reorderElements(
nextElements, nextElements,
changedElements, changedElements,
flags, flags,
); );
// we don't have an up-to-date scene, as we can be just in the middle of applying history entry ElementsDelta.redrawElements(nextElements, changedElements);
// we also don't have a scene on the server
// so we are creating a temp scene just to query and mutate elements
const tempScene = new Scene(nextElements);
ElementsDelta.redrawTextBoundingBoxes(tempScene, changedElements);
// Need ordered nextElements to avoid z-index binding issues
ElementsDelta.redrawBoundArrows(tempScene, changedElements);
} catch (e) { } catch (e) {
console.error( console.error(
`Couldn't mutate elements after applying elements change`, `Couldn't mutate elements after applying elements change`,
@@ -1395,12 +1464,113 @@ export class ElementsDelta implements DeltaContainer<SceneElementsMap> {
} }
} }
public squash(delta: ElementsDelta): this {
if (delta.isEmpty()) {
return this;
}
const { added, removed, updated } = delta;
const mergeBoundElements = (
prevDelta: Delta<ElementPartial>,
nextDelta: Delta<ElementPartial>,
) => {
const mergedDeletedBoundElements =
Delta.mergeArrays(
prevDelta.deleted.boundElements ?? [],
nextDelta.deleted.boundElements ?? [],
undefined,
(x) => x.id,
) ?? [];
const mergedInsertedBoundElements =
Delta.mergeArrays(
prevDelta.inserted.boundElements ?? [],
nextDelta.inserted.boundElements ?? [],
undefined,
(x) => x.id,
) ?? [];
if (
!mergedDeletedBoundElements.length &&
!mergedInsertedBoundElements.length
) {
return;
}
return Delta.create(
{
boundElements: mergedDeletedBoundElements,
},
{
boundElements: mergedInsertedBoundElements,
},
);
};
for (const [id, nextDelta] of Object.entries(added)) {
const prevDelta = this.added[id] ?? this.removed[id] ?? this.updated[id];
if (!prevDelta) {
this.added[id] = nextDelta;
} else {
const mergedDelta = mergeBoundElements(prevDelta, nextDelta);
delete this.removed[id];
delete this.updated[id];
this.added[id] = Delta.merge(prevDelta, nextDelta, mergedDelta);
}
}
for (const [id, nextDelta] of Object.entries(removed)) {
const prevDelta = this.added[id] ?? this.removed[id] ?? this.updated[id];
if (!prevDelta) {
this.removed[id] = nextDelta;
} else {
const mergedDelta = mergeBoundElements(prevDelta, nextDelta);
delete this.added[id];
delete this.updated[id];
this.removed[id] = Delta.merge(prevDelta, nextDelta, mergedDelta);
}
}
for (const [id, nextDelta] of Object.entries(updated)) {
const prevDelta = this.added[id] ?? this.removed[id] ?? this.updated[id];
if (!prevDelta) {
this.updated[id] = nextDelta;
} else {
const mergedDelta = mergeBoundElements(prevDelta, nextDelta);
const updatedDelta = Delta.merge(prevDelta, nextDelta, mergedDelta);
if (prevDelta === this.added[id]) {
this.added[id] = updatedDelta;
} else if (prevDelta === this.removed[id]) {
this.removed[id] = updatedDelta;
} else {
this.updated[id] = updatedDelta;
}
}
}
if (isTestEnv() || isDevEnv()) {
ElementsDelta.validate(this, "added", ElementsDelta.satisfiesAddition);
ElementsDelta.validate(this, "removed", ElementsDelta.satisfiesRemoval);
ElementsDelta.validate(this, "updated", ElementsDelta.satisfiesUpdate);
}
return this;
}
private static createApplier = private static createApplier =
( (
prevElements: SceneElementsMap,
nextElements: SceneElementsMap, nextElements: SceneElementsMap,
snapshot: StoreSnapshot["elements"], snapshot: StoreSnapshot["elements"],
options: ApplyToOptions,
flags: ApplyToFlags, flags: ApplyToFlags,
options?: ApplyToOptions,
) => ) =>
(deltas: Record<string, Delta<ElementPartial>>) => { (deltas: Record<string, Delta<ElementPartial>>) => {
const getElement = ElementsDelta.createGetter( const getElement = ElementsDelta.createGetter(
@@ -1413,15 +1583,26 @@ export class ElementsDelta implements DeltaContainer<SceneElementsMap> {
const element = getElement(id, delta.inserted); const element = getElement(id, delta.inserted);
if (element) { if (element) {
const newElement = ElementsDelta.applyDelta( const nextElement = ElementsDelta.applyDelta(
element, element,
delta, delta,
options,
flags, flags,
options,
); );
nextElements.set(newElement.id, newElement); nextElements.set(nextElement.id, nextElement);
acc.set(newElement.id, newElement); acc.set(nextElement.id, nextElement);
if (!flags.applyDirection) {
const prevElement = prevElements.get(id);
if (prevElement) {
flags.applyDirection =
prevElement.version > nextElement.version
? "backward"
: "forward";
}
}
} }
return acc; return acc;
@@ -1466,8 +1647,8 @@ export class ElementsDelta implements DeltaContainer<SceneElementsMap> {
private static applyDelta( private static applyDelta(
element: OrderedExcalidrawElement, element: OrderedExcalidrawElement,
delta: Delta<ElementPartial>, delta: Delta<ElementPartial>,
options: ApplyToOptions,
flags: ApplyToFlags, flags: ApplyToFlags,
options?: ApplyToOptions,
) { ) {
const directlyApplicablePartial: Mutable<ElementPartial> = {}; const directlyApplicablePartial: Mutable<ElementPartial> = {};
@@ -1481,7 +1662,7 @@ export class ElementsDelta implements DeltaContainer<SceneElementsMap> {
continue; continue;
} }
if (options.excludedProperties.has(key)) { if (options?.excludedProperties?.has(key)) {
continue; continue;
} }
@@ -1521,7 +1702,7 @@ export class ElementsDelta implements DeltaContainer<SceneElementsMap> {
delta.deleted.index !== delta.inserted.index; delta.deleted.index !== delta.inserted.index;
} }
return newElementWith(element, directlyApplicablePartial); return newElementWith(element, directlyApplicablePartial, true);
} }
/** /**
@@ -1561,6 +1742,7 @@ export class ElementsDelta implements DeltaContainer<SceneElementsMap> {
private resolveConflicts( private resolveConflicts(
prevElements: SceneElementsMap, prevElements: SceneElementsMap,
nextElements: SceneElementsMap, nextElements: SceneElementsMap,
applyDirection: "forward" | "backward" = "forward",
) { ) {
const nextAffectedElements = new Map<string, OrderedExcalidrawElement>(); const nextAffectedElements = new Map<string, OrderedExcalidrawElement>();
const updater = ( const updater = (
@@ -1572,21 +1754,36 @@ export class ElementsDelta implements DeltaContainer<SceneElementsMap> {
return; return;
} }
const prevElement = prevElements.get(element.id);
const nextVersion =
applyDirection === "forward"
? nextElement.version + 1
: nextElement.version - 1;
const elementUpdates = updates as ElementUpdate<OrderedExcalidrawElement>;
let affectedElement: OrderedExcalidrawElement; let affectedElement: OrderedExcalidrawElement;
if (prevElements.get(element.id) === nextElement) { if (prevElement === nextElement) {
// create the new element instance in case we didn't modify the element yet // 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 // so that we won't end up in an incosistent state in case we would fail in the middle of mutations
affectedElement = newElementWith( affectedElement = newElementWith(
nextElement, nextElement,
updates as ElementUpdate<OrderedExcalidrawElement>, {
...elementUpdates,
version: nextVersion,
},
true,
); );
} else { } else {
affectedElement = mutateElement( affectedElement = mutateElement(nextElement, nextElements, {
nextElement, ...elementUpdates,
nextElements, // don't modify the version further, if it's already different
updates as ElementUpdate<OrderedExcalidrawElement>, version:
); prevElement?.version !== nextElement.version
? nextElement.version
: nextVersion,
});
} }
nextAffectedElements.set(affectedElement.id, affectedElement); nextAffectedElements.set(affectedElement.id, affectedElement);
@@ -1624,25 +1821,12 @@ export class ElementsDelta implements DeltaContainer<SceneElementsMap> {
Array.from(prevElements).filter(([id]) => nextAffectedElements.has(id)), Array.from(prevElements).filter(([id]) => nextAffectedElements.has(id)),
); );
// calculate complete deltas for affected elements, and assign them back to all the deltas // calculate complete deltas for affected elements, and squash them back to the current deltas
// technically we could do better here if perf. would become an issue this.squash(
const { added, removed, updated } = ElementsDelta.calculate( // technically we could do better here if perf. would become an issue
prevAffectedElements, ElementsDelta.calculate(prevAffectedElements, nextAffectedElements),
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; return nextAffectedElements;
} }
@@ -1704,6 +1888,31 @@ export class ElementsDelta implements DeltaContainer<SceneElementsMap> {
BindableElement.rebindAffected(nextElements, nextElement(), updater); BindableElement.rebindAffected(nextElements, nextElement(), updater);
} }
public static redrawElements(
nextElements: SceneElementsMap,
changedElements: Map<string, OrderedExcalidrawElement>,
) {
try {
// we don't have an up-to-date scene, as we can be just in the middle of applying history entry
// we also don't have a scene on the server
// so we are creating a temp scene just to query and mutate elements
const tempScene = new Scene(nextElements, { skipValidation: true });
ElementsDelta.redrawTextBoundingBoxes(tempScene, changedElements);
// needs ordered nextElements to avoid z-index binding issues
ElementsDelta.redrawBoundArrows(tempScene, changedElements);
} catch (e) {
console.error(`Couldn't redraw elements`, e);
if (isTestEnv() || isDevEnv()) {
throw e;
}
} finally {
return nextElements;
}
}
private static redrawTextBoundingBoxes( private static redrawTextBoundingBoxes(
scene: Scene, scene: Scene,
changed: Map<string, OrderedExcalidrawElement>, changed: Map<string, OrderedExcalidrawElement>,
@@ -1758,6 +1967,7 @@ export class ElementsDelta implements DeltaContainer<SceneElementsMap> {
) { ) {
for (const element of changed.values()) { for (const element of changed.values()) {
if (!element.isDeleted && isBindableElement(element)) { if (!element.isDeleted && isBindableElement(element)) {
// TODO: with precise bindings this is quite expensive, so consider optimisation so it's only triggered when the arrow does not intersect (imprecise) element bounds
updateBoundElements(element, scene, { updateBoundElements(element, scene, {
changedElements: changed, changedElements: changed,
}); });
+2 -2
View File
@@ -2,10 +2,10 @@ import {
curvePointDistance, curvePointDistance,
distanceToLineSegment, distanceToLineSegment,
pointRotateRads, pointRotateRads,
ellipse,
ellipseDistanceFromPoint,
} from "@excalidraw/math"; } from "@excalidraw/math";
import { ellipse, ellipseDistanceFromPoint } from "@excalidraw/math/ellipse";
import type { GlobalPoint, Radians } from "@excalidraw/math"; import type { GlobalPoint, Radians } from "@excalidraw/math";
import { import {
+30 -17
View File
@@ -359,6 +359,12 @@ const handleSegmentRelease = (
null, null,
); );
if (!restoredPoints || restoredPoints.length < 2) {
throw new Error(
"Property 'points' is required in the update returned by normalizeArrowElementUpdate()",
);
}
const nextPoints: GlobalPoint[] = []; const nextPoints: GlobalPoint[] = [];
// First part of the arrow are the old points // First part of the arrow are the old points
@@ -706,7 +712,7 @@ const handleEndpointDrag = (
endGlobalPoint: GlobalPoint, endGlobalPoint: GlobalPoint,
hoveredStartElement: ExcalidrawBindableElement | null, hoveredStartElement: ExcalidrawBindableElement | null,
hoveredEndElement: ExcalidrawBindableElement | null, hoveredEndElement: ExcalidrawBindableElement | null,
) => { ): ElementUpdate<ExcalidrawElbowArrowElement> => {
let startIsSpecial = arrow.startIsSpecial ?? null; let startIsSpecial = arrow.startIsSpecial ?? null;
let endIsSpecial = arrow.endIsSpecial ?? null; let endIsSpecial = arrow.endIsSpecial ?? null;
const globalUpdatedPoints = updatedPoints.map((p, i) => const globalUpdatedPoints = updatedPoints.map((p, i) =>
@@ -741,8 +747,15 @@ const handleEndpointDrag = (
// Calculate the moving second point connection and add the start point // Calculate the moving second point connection and add the start point
{ {
const secondPoint = globalUpdatedPoints[startIsSpecial ? 2 : 1]; const secondPoint = globalUpdatedPoints.at(startIsSpecial ? 2 : 1);
const thirdPoint = globalUpdatedPoints[startIsSpecial ? 3 : 2]; 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 startIsHorizontal = headingIsHorizontal(startHeading);
const secondIsHorizontal = headingIsHorizontal( const secondIsHorizontal = headingIsHorizontal(
vectorToHeading(vectorFromPoint(secondPoint, thirdPoint)), vectorToHeading(vectorFromPoint(secondPoint, thirdPoint)),
@@ -801,10 +814,19 @@ const handleEndpointDrag = (
// Calculate the moving second to last point connection // Calculate the moving second to last point connection
{ {
const secondToLastPoint = const secondToLastPoint = globalUpdatedPoints.at(
globalUpdatedPoints[globalUpdatedPoints.length - (endIsSpecial ? 3 : 2)]; globalUpdatedPoints.length - (endIsSpecial ? 3 : 2),
const thirdToLastPoint = );
globalUpdatedPoints[globalUpdatedPoints.length - (endIsSpecial ? 4 : 3)]; 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 endIsHorizontal = headingIsHorizontal(endHeading);
const secondIsHorizontal = headingForPointIsHorizontal( const secondIsHorizontal = headingForPointIsHorizontal(
thirdToLastPoint, thirdToLastPoint,
@@ -2071,16 +2093,7 @@ const normalizeArrowElementUpdate = (
nextFixedSegments: readonly FixedSegment[] | null, nextFixedSegments: readonly FixedSegment[] | null,
startIsSpecial?: ExcalidrawElbowArrowElement["startIsSpecial"], startIsSpecial?: ExcalidrawElbowArrowElement["startIsSpecial"],
endIsSpecial?: ExcalidrawElbowArrowElement["startIsSpecial"], endIsSpecial?: ExcalidrawElbowArrowElement["startIsSpecial"],
): { ): ElementUpdate<ExcalidrawElbowArrowElement> => {
points: LocalPoint[];
x: number;
y: number;
width: number;
height: number;
fixedSegments: readonly FixedSegment[] | null;
startIsSpecial?: ExcalidrawElbowArrowElement["startIsSpecial"];
endIsSpecial?: ExcalidrawElbowArrowElement["startIsSpecial"];
} => {
const offsetX = global[0][0]; const offsetX = global[0][0];
const offsetY = global[0][1]; const offsetY = global[0][1];
let points = global.map((p) => let points = global.map((p) =>
+4
View File
@@ -29,6 +29,9 @@ export const hashElementsVersion = (elements: ElementsMapOrArray): number => {
// string hash function (using djb2). Not cryptographically secure, use only // string hash function (using djb2). Not cryptographically secure, use only
// for versioning and such. // 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 => { export const hashString = (s: string): number => {
let hash: number = 5381; let hash: number = 5381;
for (let i = 0; i < s.length; i++) { for (let i = 0; i < s.length; i++) {
@@ -97,6 +100,7 @@ export * from "./image";
export * from "./linearElementEditor"; export * from "./linearElementEditor";
export * from "./mutateElement"; export * from "./mutateElement";
export * from "./newElement"; export * from "./newElement";
export * from "./positionElementsOnGrid";
export * from "./renderElement"; export * from "./renderElement";
export * from "./resizeElements"; export * from "./resizeElements";
export * from "./resizeTest"; export * from "./resizeTest";
@@ -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;
};
+71 -3
View File
@@ -1,7 +1,14 @@
import rough from "roughjs/bin/rough"; import rough from "roughjs/bin/rough";
import { getStroke } from "perfect-freehand"; import { getStroke } from "perfect-freehand";
import { isRightAngleRads } from "@excalidraw/math"; import {
type GlobalPoint,
isRightAngleRads,
lineSegment,
pointFrom,
pointRotateRads,
type Radians,
} from "@excalidraw/math";
import { import {
BOUND_TEXT_PADDING, BOUND_TEXT_PADDING,
@@ -14,6 +21,7 @@ import {
getFontString, getFontString,
isRTL, isRTL,
getVerticalOffset, getVerticalOffset,
invariant,
} from "@excalidraw/common"; } from "@excalidraw/common";
import type { import type {
@@ -32,7 +40,7 @@ import type {
InteractiveCanvasRenderConfig, InteractiveCanvasRenderConfig,
} from "@excalidraw/excalidraw/scene/types"; } from "@excalidraw/excalidraw/scene/types";
import { getElementAbsoluteCoords } from "./bounds"; import { getElementAbsoluteCoords, getElementBounds } from "./bounds";
import { getUncroppedImageElement } from "./cropElement"; import { getUncroppedImageElement } from "./cropElement";
import { LinearElementEditor } from "./linearElementEditor"; import { LinearElementEditor } from "./linearElementEditor";
import { import {
@@ -1039,6 +1047,66 @@ export function getFreeDrawPath2D(element: ExcalidrawFreeDrawElement) {
} }
export function getFreeDrawSvgPath(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 // If input points are empty (should they ever be?) return a dot
const inputPoints = element.simulatePressure const inputPoints = element.simulatePressure
? element.points ? element.points
@@ -1057,7 +1125,7 @@ export function getFreeDrawSvgPath(element: ExcalidrawFreeDrawElement) {
last: !!element.lastCommittedPoint, // LastCommittedPoint is added on pointerup last: !!element.lastCommittedPoint, // LastCommittedPoint is added on pointerup
}; };
return getSvgPathFromStroke(getStroke(inputPoints as number[][], options)); return getStroke(inputPoints as number[][], options) as [number, number][];
} }
function med(A: number[], B: number[]) { function med(A: number[], B: number[]) {
+19 -3
View File
@@ -35,6 +35,7 @@ import {
getContainerElement, getContainerElement,
handleBindTextResize, handleBindTextResize,
getBoundTextMaxWidth, getBoundTextMaxWidth,
computeBoundTextPosition,
} from "./textElement"; } from "./textElement";
import { import {
getMinTextElementWidth, getMinTextElementWidth,
@@ -225,7 +226,16 @@ const rotateSingleElement = (
scene.getElement<ExcalidrawTextElementWithContainer>(boundTextElementId); scene.getElement<ExcalidrawTextElementWithContainer>(boundTextElementId);
if (textElement && !isArrowElement(element)) { if (textElement && !isArrowElement(element)) {
scene.mutateElement(textElement, { angle }); const { x, y } = computeBoundTextPosition(
element,
textElement,
scene.getNonDeletedElementsMap(),
);
scene.mutateElement(textElement, {
angle,
x,
y,
});
} }
} }
}; };
@@ -416,9 +426,15 @@ const rotateMultipleElements = (
const boundText = getBoundTextElement(element, elementsMap); const boundText = getBoundTextElement(element, elementsMap);
if (boundText && !isArrowElement(element)) { if (boundText && !isArrowElement(element)) {
const { x, y } = computeBoundTextPosition(
element,
boundText,
elementsMap,
);
scene.mutateElement(boundText, { scene.mutateElement(boundText, {
x: boundText.x + (rotatedCX - cx), x,
y: boundText.y + (rotatedCY - cy), y,
angle: normalizeRadians((centerAngle + origAngle) as Radians), angle: normalizeRadians((centerAngle + origAngle) as Radians),
}); });
} }
+14 -11
View File
@@ -5,17 +5,20 @@ import {
type Radians, type Radians,
} from "@excalidraw/math"; } from "@excalidraw/math";
import { SIDE_RESIZING_THRESHOLD } from "@excalidraw/common"; import {
SIDE_RESIZING_THRESHOLD,
type EditorInterface,
} from "@excalidraw/common";
import type { GlobalPoint, LineSegment, LocalPoint } from "@excalidraw/math"; import type { GlobalPoint, LineSegment, LocalPoint } from "@excalidraw/math";
import type { AppState, Device, Zoom } from "@excalidraw/excalidraw/types"; import type { AppState, Zoom } from "@excalidraw/excalidraw/types";
import { getElementAbsoluteCoords } from "./bounds"; import { getElementAbsoluteCoords } from "./bounds";
import { import {
getTransformHandlesFromCoords, getTransformHandlesFromCoords,
getTransformHandles, getTransformHandles,
getOmitSidesForDevice, getOmitSidesForEditorInterface,
canResizeFromSides, canResizeFromSides,
} from "./transformHandles"; } from "./transformHandles";
import { isImageElement, isLinearElement } from "./typeChecks"; import { isImageElement, isLinearElement } from "./typeChecks";
@@ -51,7 +54,7 @@ export const resizeTest = <Point extends GlobalPoint | LocalPoint>(
y: number, y: number,
zoom: Zoom, zoom: Zoom,
pointerType: PointerType, pointerType: PointerType,
device: Device, editorInterface: EditorInterface,
): MaybeTransformHandleType => { ): MaybeTransformHandleType => {
if (!appState.selectedElementIds[element.id]) { if (!appState.selectedElementIds[element.id]) {
return false; return false;
@@ -63,7 +66,7 @@ export const resizeTest = <Point extends GlobalPoint | LocalPoint>(
zoom, zoom,
elementsMap, elementsMap,
pointerType, pointerType,
getOmitSidesForDevice(device), getOmitSidesForEditorInterface(editorInterface),
); );
if ( if (
@@ -86,7 +89,7 @@ export const resizeTest = <Point extends GlobalPoint | LocalPoint>(
return filter[0] as TransformHandleType; return filter[0] as TransformHandleType;
} }
if (canResizeFromSides(device)) { if (canResizeFromSides(editorInterface)) {
const [x1, y1, x2, y2, cx, cy] = getElementAbsoluteCoords( const [x1, y1, x2, y2, cx, cy] = getElementAbsoluteCoords(
element, element,
elementsMap, elementsMap,
@@ -132,7 +135,7 @@ export const getElementWithTransformHandleType = (
zoom: Zoom, zoom: Zoom,
pointerType: PointerType, pointerType: PointerType,
elementsMap: ElementsMap, elementsMap: ElementsMap,
device: Device, editorInterface: EditorInterface,
) => { ) => {
return elements.reduce((result, element) => { return elements.reduce((result, element) => {
if (result) { if (result) {
@@ -146,7 +149,7 @@ export const getElementWithTransformHandleType = (
scenePointerY, scenePointerY,
zoom, zoom,
pointerType, pointerType,
device, editorInterface,
); );
return transformHandleType ? { element, transformHandleType } : null; return transformHandleType ? { element, transformHandleType } : null;
}, null as { element: NonDeletedExcalidrawElement; transformHandleType: MaybeTransformHandleType } | null); }, null as { element: NonDeletedExcalidrawElement; transformHandleType: MaybeTransformHandleType } | null);
@@ -160,14 +163,14 @@ export const getTransformHandleTypeFromCoords = <
scenePointerY: number, scenePointerY: number,
zoom: Zoom, zoom: Zoom,
pointerType: PointerType, pointerType: PointerType,
device: Device, editorInterface: EditorInterface,
): MaybeTransformHandleType => { ): MaybeTransformHandleType => {
const transformHandles = getTransformHandlesFromCoords( const transformHandles = getTransformHandlesFromCoords(
[x1, y1, x2, y2, (x1 + x2) / 2, (y1 + y2) / 2], [x1, y1, x2, y2, (x1 + x2) / 2, (y1 + y2) / 2],
0 as Radians, 0 as Radians,
zoom, zoom,
pointerType, pointerType,
getOmitSidesForDevice(device), getOmitSidesForEditorInterface(editorInterface),
); );
const found = Object.keys(transformHandles).find((key) => { const found = Object.keys(transformHandles).find((key) => {
@@ -183,7 +186,7 @@ export const getTransformHandleTypeFromCoords = <
return found as MaybeTransformHandleType; return found as MaybeTransformHandleType;
} }
if (canResizeFromSides(device)) { if (canResizeFromSides(editorInterface)) {
const cx = (x1 + x2) / 2; const cx = (x1 + x2) / 2;
const cy = (y1 + y2) / 2; const cy = (y1 + y2) / 2;
+31 -16
View File
@@ -76,8 +76,9 @@ type MicroActionsQueue = (() => void)[];
* Store which captures the observed changes and emits them as `StoreIncrement` events. * Store which captures the observed changes and emits them as `StoreIncrement` events.
*/ */
export class Store { export class Store {
// internally used by history // for internal use by history
public readonly onDurableIncrementEmitter = new Emitter<[DurableIncrement]>(); public readonly onDurableIncrementEmitter = new Emitter<[DurableIncrement]>();
// for public use as part of onIncrement API
public readonly onStoreIncrementEmitter = new Emitter< public readonly onStoreIncrementEmitter = new Emitter<
[DurableIncrement | EphemeralIncrement] [DurableIncrement | EphemeralIncrement]
>(); >();
@@ -239,7 +240,6 @@ export class Store {
if (!storeDelta.isEmpty()) { if (!storeDelta.isEmpty()) {
const increment = new DurableIncrement(storeChange, storeDelta); const increment = new DurableIncrement(storeChange, storeDelta);
// Notify listeners with the increment
this.onDurableIncrementEmitter.trigger(increment); this.onDurableIncrementEmitter.trigger(increment);
this.onStoreIncrementEmitter.trigger(increment); this.onStoreIncrementEmitter.trigger(increment);
} }
@@ -552,10 +552,26 @@ export class StoreDelta {
public static load({ public static load({
id, id,
elements: { added, removed, updated }, elements: { added, removed, updated },
appState: { delta: appStateDelta },
}: DTO<StoreDelta>) { }: DTO<StoreDelta>) {
const elements = ElementsDelta.create(added, removed, updated); const elements = ElementsDelta.create(added, removed, updated);
const appState = AppStateDelta.create(appStateDelta);
return new this(id, elements, AppStateDelta.empty()); return new this(id, elements, appState);
}
/**
* Squash the passed deltas into the aggregated delta instance.
*/
public static squash(...deltas: StoreDelta[]) {
const aggregatedDelta = StoreDelta.empty();
for (const delta of deltas) {
aggregatedDelta.elements.squash(delta.elements);
aggregatedDelta.appState.squash(delta.appState);
}
return aggregatedDelta;
} }
/** /**
@@ -572,9 +588,7 @@ export class StoreDelta {
delta: StoreDelta, delta: StoreDelta,
elements: SceneElementsMap, elements: SceneElementsMap,
appState: AppState, appState: AppState,
options: ApplyToOptions = { options?: ApplyToOptions,
excludedProperties: new Set(),
},
): [SceneElementsMap, AppState, boolean] { ): [SceneElementsMap, AppState, boolean] {
const [nextElements, elementsContainVisibleChange] = delta.elements.applyTo( const [nextElements, elementsContainVisibleChange] = delta.elements.applyTo(
elements, elements,
@@ -613,6 +627,10 @@ export class StoreDelta {
); );
} }
public static empty() {
return StoreDelta.create(ElementsDelta.empty(), AppStateDelta.empty());
}
public isEmpty() { public isEmpty() {
return this.elements.isEmpty() && this.appState.isEmpty(); return this.elements.isEmpty() && this.appState.isEmpty();
} }
@@ -978,8 +996,7 @@ const getDefaultObservedAppState = (): ObservedAppState => {
viewBackgroundColor: COLOR_PALETTE.white, viewBackgroundColor: COLOR_PALETTE.white,
selectedElementIds: {}, selectedElementIds: {},
selectedGroupIds: {}, selectedGroupIds: {},
selectedLinearElementId: null, selectedLinearElement: null,
selectedLinearElementIsEditing: null,
croppingElementId: null, croppingElementId: null,
activeLockedId: null, activeLockedId: null,
lockedMultiSelections: {}, lockedMultiSelections: {},
@@ -998,14 +1015,12 @@ export const getObservedAppState = (
croppingElementId: appState.croppingElementId, croppingElementId: appState.croppingElementId,
activeLockedId: appState.activeLockedId, activeLockedId: appState.activeLockedId,
lockedMultiSelections: appState.lockedMultiSelections, lockedMultiSelections: appState.lockedMultiSelections,
selectedLinearElementId: selectedLinearElement: appState.selectedLinearElement
(appState as AppState).selectedLinearElement?.elementId ?? ? {
(appState as ObservedAppState).selectedLinearElementId ?? elementId: appState.selectedLinearElement.elementId,
null, isEditing: !!appState.selectedLinearElement.isEditing,
selectedLinearElementIsEditing: }
(appState as AppState).selectedLinearElement?.isEditing ?? : null,
(appState as ObservedAppState).selectedLinearElementIsEditing ??
null,
}; };
Reflect.defineProperty(observedAppState, hiddenObservedAppStateProp, { Reflect.defineProperty(observedAppState, hiddenObservedAppStateProp, {
+22 -2
View File
@@ -10,12 +10,12 @@ import {
invariant, invariant,
} from "@excalidraw/common"; } from "@excalidraw/common";
import { pointFrom, pointRotateRads, type Radians } from "@excalidraw/math";
import type { AppState } from "@excalidraw/excalidraw/types"; import type { AppState } from "@excalidraw/excalidraw/types";
import type { ExtractSetType } from "@excalidraw/common/utility-types"; import type { ExtractSetType } from "@excalidraw/common/utility-types";
import type { Radians } from "@excalidraw/math";
import { import {
resetOriginalContainerCache, resetOriginalContainerCache,
updateOriginalContainerCache, updateOriginalContainerCache,
@@ -254,6 +254,26 @@ export const computeBoundTextPosition = (
x = x =
containerCoords.x + (maxContainerWidth / 2 - boundTextElement.width / 2); 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 }; return { x, y };
}; };
+15 -13
View File
@@ -1,7 +1,6 @@
import { import {
DEFAULT_TRANSFORM_HANDLE_SPACING, DEFAULT_TRANSFORM_HANDLE_SPACING,
isAndroid, type EditorInterface,
isIOS,
} from "@excalidraw/common"; } from "@excalidraw/common";
import { pointFrom, pointRotateRads } from "@excalidraw/math"; import { pointFrom, pointRotateRads } from "@excalidraw/math";
@@ -9,7 +8,6 @@ import { pointFrom, pointRotateRads } from "@excalidraw/math";
import type { Radians } from "@excalidraw/math"; import type { Radians } from "@excalidraw/math";
import type { import type {
Device,
InteractiveCanvasAppState, InteractiveCanvasAppState,
Zoom, Zoom,
} from "@excalidraw/excalidraw/types"; } from "@excalidraw/excalidraw/types";
@@ -111,20 +109,21 @@ const generateTransformHandle = (
return [xx - width / 2, yy - height / 2, width, height]; return [xx - width / 2, yy - height / 2, width, height];
}; };
export const canResizeFromSides = (device: Device) => { export const canResizeFromSides = (editorInterface: EditorInterface) => {
if (device.viewport.isMobile) { if (
return false; editorInterface.formFactor === "phone" &&
} editorInterface.userAgent.isMobileDevice
) {
if (device.isTouchScreen && (isAndroid || isIOS)) {
return false; return false;
} }
return true; return true;
}; };
export const getOmitSidesForDevice = (device: Device) => { export const getOmitSidesForEditorInterface = (
if (canResizeFromSides(device)) { editorInterface: EditorInterface,
) => {
if (canResizeFromSides(editorInterface)) {
return DEFAULT_OMIT_SIDES; return DEFAULT_OMIT_SIDES;
} }
@@ -326,9 +325,10 @@ export const getTransformHandles = (
); );
}; };
export const shouldShowBoundingBox = ( export const hasBoundingBox = (
elements: readonly NonDeletedExcalidrawElement[], elements: readonly NonDeletedExcalidrawElement[],
appState: InteractiveCanvasAppState, appState: InteractiveCanvasAppState,
editorInterface: EditorInterface,
) => { ) => {
if (appState.selectedLinearElement?.isEditing) { if (appState.selectedLinearElement?.isEditing) {
return false; return false;
@@ -345,5 +345,7 @@ export const shouldShowBoundingBox = (
return true; return true;
} }
return element.points.length > 2; // on mobile/tablet we currently don't show bbox because of resize issues
// (also prob best for simplicity's sake)
return element.points.length > 2 && !editorInterface.userAgent.isMobileDevice;
}; };
+6 -8
View File
@@ -10,6 +10,8 @@ import { API } from "@excalidraw/excalidraw/tests/helpers/api";
import { UI, Pointer, Keyboard } from "@excalidraw/excalidraw/tests/helpers/ui"; import { UI, Pointer, Keyboard } from "@excalidraw/excalidraw/tests/helpers/ui";
import { fireEvent, render } from "@excalidraw/excalidraw/tests/test-utils"; import { fireEvent, render } from "@excalidraw/excalidraw/tests/test-utils";
import { LinearElementEditor } from "@excalidraw/element";
import { getTransformHandles } from "../src/transformHandles"; import { getTransformHandles } from "../src/transformHandles";
import { import {
getTextEditor, getTextEditor,
@@ -413,16 +415,12 @@ describe("element binding", () => {
expect(arrow.endBinding?.elementId).toBe(rectRight.id); expect(arrow.endBinding?.elementId).toBe(rectRight.id);
// Drag arrow off of bound rectangle range // Drag arrow off of bound rectangle range
const handles = getTransformHandles( const [elX, elY] = LinearElementEditor.getPointAtIndexGlobalCoordinates(
arrow, arrow,
h.state.zoom, -1,
arrayToMap(h.elements), h.scene.getNonDeletedElementsMap(),
"mouse", );
).se!;
Keyboard.keyDown(KEYS.CTRL_OR_CMD); Keyboard.keyDown(KEYS.CTRL_OR_CMD);
const elX = handles[0] + handles[2] / 2;
const elY = handles[1] + handles[3] / 2;
mouse.downAt(elX, elY); mouse.downAt(elX, elY);
mouse.moveTo(300, 400); mouse.moveTo(300, 400);
mouse.up(); mouse.up();
+433 -12
View File
@@ -1,13 +1,345 @@
import { API } from "@excalidraw/excalidraw/tests/helpers/api";
import type { ObservedAppState } from "@excalidraw/excalidraw/types"; import type { ObservedAppState } from "@excalidraw/excalidraw/types";
import type { LinearElementEditor } from "@excalidraw/element"; import type { LinearElementEditor } from "@excalidraw/element";
import type { SceneElementsMap } from "@excalidraw/element/types";
import { AppStateDelta } from "../src/delta"; import { AppStateDelta, Delta, ElementsDelta } from "../src/delta";
describe("ElementsDelta", () => {
describe("elements delta calculation", () => {
it("should not throw when element gets removed but was already deleted", () => {
const element = API.createElement({
type: "rectangle",
x: 100,
y: 100,
isDeleted: true,
});
const prevElements = new Map([[element.id, element]]);
const nextElements = new Map();
expect(() =>
ElementsDelta.calculate(prevElements, nextElements),
).not.toThrow();
});
it("should not throw when adding element as already deleted", () => {
const element = API.createElement({
type: "rectangle",
x: 100,
y: 100,
isDeleted: true,
});
const prevElements = new Map();
const nextElements = new Map([[element.id, element]]);
expect(() =>
ElementsDelta.calculate(prevElements, nextElements),
).not.toThrow();
});
it("should create updated delta even when there is only version and versionNonce change", () => {
const baseElement = API.createElement({
type: "rectangle",
x: 100,
y: 100,
strokeColor: "#000000",
backgroundColor: "#ffffff",
});
const modifiedElement = {
...baseElement,
version: baseElement.version + 1,
versionNonce: baseElement.versionNonce + 1,
};
// Create maps for the delta calculation
const prevElements = new Map([[baseElement.id, baseElement]]);
const nextElements = new Map([[modifiedElement.id, modifiedElement]]);
// Calculate the delta
const delta = ElementsDelta.calculate(
prevElements as SceneElementsMap,
nextElements as SceneElementsMap,
);
expect(delta).toEqual(
ElementsDelta.create(
{},
{},
{
[baseElement.id]: Delta.create(
{
version: baseElement.version,
versionNonce: baseElement.versionNonce,
},
{
version: baseElement.version + 1,
versionNonce: baseElement.versionNonce + 1,
},
),
},
),
);
});
});
describe("squash", () => {
it("should not squash when second delta is empty", () => {
const updatedDelta = Delta.create(
{ x: 100, version: 1, versionNonce: 1 },
{ x: 200, version: 2, versionNonce: 2 },
);
const elementsDelta1 = ElementsDelta.create(
{},
{},
{ id1: updatedDelta },
);
const elementsDelta2 = ElementsDelta.empty();
const elementsDelta = elementsDelta1.squash(elementsDelta2);
expect(elementsDelta.isEmpty()).toBeFalsy();
expect(elementsDelta).toBe(elementsDelta1);
expect(elementsDelta.updated.id1).toBe(updatedDelta);
});
it("should squash mutually exclusive delta types", () => {
const addedDelta = Delta.create(
{ x: 100, version: 1, versionNonce: 1, isDeleted: true },
{ x: 200, version: 2, versionNonce: 2, isDeleted: false },
);
const removedDelta = Delta.create(
{ x: 100, version: 1, versionNonce: 1, isDeleted: false },
{ x: 200, version: 2, versionNonce: 2, isDeleted: true },
);
const updatedDelta = Delta.create(
{ x: 100, version: 1, versionNonce: 1 },
{ x: 200, version: 2, versionNonce: 2 },
);
const elementsDelta1 = ElementsDelta.create(
{ id1: addedDelta },
{ id2: removedDelta },
{},
);
const elementsDelta2 = ElementsDelta.create(
{},
{},
{ id3: updatedDelta },
);
const elementsDelta = elementsDelta1.squash(elementsDelta2);
expect(elementsDelta.isEmpty()).toBeFalsy();
expect(elementsDelta).toBe(elementsDelta1);
expect(elementsDelta.added.id1).toBe(addedDelta);
expect(elementsDelta.removed.id2).toBe(removedDelta);
expect(elementsDelta.updated.id3).toBe(updatedDelta);
});
it("should squash the same delta types", () => {
const elementsDelta1 = ElementsDelta.create(
{
id1: Delta.create(
{ x: 100, version: 1, versionNonce: 1, isDeleted: true },
{ x: 200, version: 2, versionNonce: 2, isDeleted: false },
),
},
{
id2: Delta.create(
{ x: 100, version: 1, versionNonce: 1, isDeleted: false },
{ x: 200, version: 2, versionNonce: 2, isDeleted: true },
),
},
{
id3: Delta.create(
{ x: 100, version: 1, versionNonce: 1 },
{ x: 200, version: 2, versionNonce: 2 },
),
},
);
const elementsDelta2 = ElementsDelta.create(
{
id1: Delta.create(
{ y: 100, version: 2, versionNonce: 2, isDeleted: true },
{ y: 200, version: 3, versionNonce: 3, isDeleted: false },
),
},
{
id2: Delta.create(
{ y: 100, version: 2, versionNonce: 2, isDeleted: false },
{ y: 200, version: 3, versionNonce: 3, isDeleted: true },
),
},
{
id3: Delta.create(
{ y: 100, version: 2, versionNonce: 2 },
{ y: 200, version: 3, versionNonce: 3 },
),
},
);
const elementsDelta = elementsDelta1.squash(elementsDelta2);
expect(elementsDelta.isEmpty()).toBeFalsy();
expect(elementsDelta).toBe(elementsDelta1);
expect(elementsDelta.added.id1).toEqual(
Delta.create(
{ x: 100, y: 100, version: 2, versionNonce: 2, isDeleted: true },
{ x: 200, y: 200, version: 3, versionNonce: 3, isDeleted: false },
),
);
expect(elementsDelta.removed.id2).toEqual(
Delta.create(
{ x: 100, y: 100, version: 2, versionNonce: 2, isDeleted: false },
{ x: 200, y: 200, version: 3, versionNonce: 3, isDeleted: true },
),
);
expect(elementsDelta.updated.id3).toEqual(
Delta.create(
{ x: 100, y: 100, version: 2, versionNonce: 2 },
{ x: 200, y: 200, version: 3, versionNonce: 3 },
),
);
});
it("should squash different delta types ", () => {
// id1: added -> updated => added
// id2: removed -> added => added
// id3: updated -> removed => removed
const elementsDelta1 = ElementsDelta.create(
{
id1: Delta.create(
{ x: 100, version: 1, versionNonce: 1, isDeleted: true },
{ x: 101, version: 2, versionNonce: 2, isDeleted: false },
),
},
{
id2: Delta.create(
{ x: 200, version: 1, versionNonce: 1, isDeleted: false },
{ x: 201, version: 2, versionNonce: 2, isDeleted: true },
),
},
{
id3: Delta.create(
{ x: 300, version: 1, versionNonce: 1 },
{ x: 301, version: 2, versionNonce: 2 },
),
},
);
const elementsDelta2 = ElementsDelta.create(
{
id2: Delta.create(
{ y: 200, version: 2, versionNonce: 2, isDeleted: true },
{ y: 201, version: 3, versionNonce: 3, isDeleted: false },
),
},
{
id3: Delta.create(
{ y: 300, version: 2, versionNonce: 2, isDeleted: false },
{ y: 301, version: 3, versionNonce: 3, isDeleted: true },
),
},
{
id1: Delta.create(
{ y: 100, version: 2, versionNonce: 2 },
{ y: 101, version: 3, versionNonce: 3 },
),
},
);
const elementsDelta = elementsDelta1.squash(elementsDelta2);
expect(elementsDelta.isEmpty()).toBeFalsy();
expect(elementsDelta).toBe(elementsDelta1);
expect(elementsDelta.added).toEqual({
id1: Delta.create(
{ x: 100, y: 100, version: 2, versionNonce: 2, isDeleted: true },
{ x: 101, y: 101, version: 3, versionNonce: 3, isDeleted: false },
),
id2: Delta.create(
{ x: 200, y: 200, version: 2, versionNonce: 2, isDeleted: true },
{ x: 201, y: 201, version: 3, versionNonce: 3, isDeleted: false },
),
});
expect(elementsDelta.removed).toEqual({
id3: Delta.create(
{ x: 300, y: 300, version: 2, versionNonce: 2, isDeleted: false },
{ x: 301, y: 301, version: 3, versionNonce: 3, isDeleted: true },
),
});
expect(elementsDelta.updated).toEqual({});
});
it("should squash bound elements", () => {
const elementsDelta1 = ElementsDelta.create(
{},
{},
{
id1: Delta.create(
{
version: 1,
versionNonce: 1,
boundElements: [{ id: "t1", type: "text" }],
},
{
version: 2,
versionNonce: 2,
boundElements: [{ id: "t2", type: "text" }],
},
),
},
);
const elementsDelta2 = ElementsDelta.create(
{},
{},
{
id1: Delta.create(
{
version: 2,
versionNonce: 2,
boundElements: [{ id: "a1", type: "arrow" }],
},
{
version: 3,
versionNonce: 3,
boundElements: [{ id: "a2", type: "arrow" }],
},
),
},
);
const elementsDelta = elementsDelta1.squash(elementsDelta2);
expect(elementsDelta.updated.id1.deleted.boundElements).toEqual([
{ id: "t1", type: "text" },
{ id: "a1", type: "arrow" },
]);
expect(elementsDelta.updated.id1.inserted.boundElements).toEqual([
{ id: "t2", type: "text" },
{ id: "a2", type: "arrow" },
]);
});
});
});
describe("AppStateDelta", () => { describe("AppStateDelta", () => {
describe("ensure stable delta properties order", () => { describe("ensure stable delta properties order", () => {
it("should maintain stable order for root properties", () => { it("should maintain stable order for root properties", () => {
const name = "untitled scene"; const name = "untitled scene";
const selectedLinearElementId = "id1" as LinearElementEditor["elementId"]; const selectedLinearElement = {
elementId: "id1" as LinearElementEditor["elementId"],
isEditing: false,
};
const commonAppState = { const commonAppState = {
viewBackgroundColor: "#ffffff", viewBackgroundColor: "#ffffff",
@@ -24,23 +356,23 @@ describe("AppStateDelta", () => {
const prevAppState1: ObservedAppState = { const prevAppState1: ObservedAppState = {
...commonAppState, ...commonAppState,
name: "", name: "",
selectedLinearElementId: null, selectedLinearElement: null,
}; };
const nextAppState1: ObservedAppState = { const nextAppState1: ObservedAppState = {
...commonAppState, ...commonAppState,
name, name,
selectedLinearElementId, selectedLinearElement,
}; };
const prevAppState2: ObservedAppState = { const prevAppState2: ObservedAppState = {
selectedLinearElementId: null, selectedLinearElement: null,
name: "", name: "",
...commonAppState, ...commonAppState,
}; };
const nextAppState2: ObservedAppState = { const nextAppState2: ObservedAppState = {
selectedLinearElementId, selectedLinearElement,
name, name,
...commonAppState, ...commonAppState,
}; };
@@ -58,9 +390,7 @@ describe("AppStateDelta", () => {
selectedGroupIds: {}, selectedGroupIds: {},
editingGroupId: null, editingGroupId: null,
croppingElementId: null, croppingElementId: null,
selectedLinearElementId: null, selectedLinearElement: null,
selectedLinearElementIsEditing: null,
editingLinearElementId: null,
activeLockedId: null, activeLockedId: null,
lockedMultiSelections: {}, lockedMultiSelections: {},
}; };
@@ -106,9 +436,7 @@ describe("AppStateDelta", () => {
selectedElementIds: {}, selectedElementIds: {},
editingGroupId: null, editingGroupId: null,
croppingElementId: null, croppingElementId: null,
selectedLinearElementId: null, selectedLinearElement: null,
selectedLinearElementIsEditing: null,
editingLinearElementId: null,
activeLockedId: null, activeLockedId: null,
lockedMultiSelections: {}, lockedMultiSelections: {},
}; };
@@ -149,4 +477,97 @@ describe("AppStateDelta", () => {
expect(JSON.stringify(delta1)).toBe(JSON.stringify(delta2)); expect(JSON.stringify(delta1)).toBe(JSON.stringify(delta2));
}); });
}); });
describe("squash", () => {
it("should not squash when second delta is empty", () => {
const delta = Delta.create(
{ name: "untitled scene" },
{ name: "titled scene" },
);
const appStateDelta1 = AppStateDelta.create(delta);
const appStateDelta2 = AppStateDelta.empty();
const appStateDelta = appStateDelta1.squash(appStateDelta2);
expect(appStateDelta.isEmpty()).toBeFalsy();
expect(appStateDelta).toBe(appStateDelta1);
expect(appStateDelta.delta).toBe(delta);
});
it("should squash exclusive properties", () => {
const delta1 = Delta.create(
{ name: "untitled scene" },
{ name: "titled scene" },
);
const delta2 = Delta.create(
{ viewBackgroundColor: "#ffffff" },
{ viewBackgroundColor: "#000000" },
);
const appStateDelta1 = AppStateDelta.create(delta1);
const appStateDelta2 = AppStateDelta.create(delta2);
const appStateDelta = appStateDelta1.squash(appStateDelta2);
expect(appStateDelta.isEmpty()).toBeFalsy();
expect(appStateDelta).toBe(appStateDelta1);
expect(appStateDelta.delta).toEqual(
Delta.create(
{ name: "untitled scene", viewBackgroundColor: "#ffffff" },
{ name: "titled scene", viewBackgroundColor: "#000000" },
),
);
});
it("should squash selectedElementIds, selectedGroupIds and lockedMultiSelections", () => {
const delta1 = Delta.create<Partial<ObservedAppState>>(
{
name: "untitled scene",
selectedElementIds: { id1: true },
selectedGroupIds: {},
lockedMultiSelections: { g1: true },
},
{
name: "titled scene",
selectedElementIds: { id2: true },
selectedGroupIds: { g1: true },
lockedMultiSelections: {},
},
);
const delta2 = Delta.create<Partial<ObservedAppState>>(
{
selectedElementIds: { id3: true },
selectedGroupIds: { g1: true },
lockedMultiSelections: {},
},
{
selectedElementIds: { id2: true },
selectedGroupIds: { g2: true, g3: true },
lockedMultiSelections: { g3: true },
},
);
const appStateDelta1 = AppStateDelta.create(delta1);
const appStateDelta2 = AppStateDelta.create(delta2);
const appStateDelta = appStateDelta1.squash(appStateDelta2);
expect(appStateDelta.isEmpty()).toBeFalsy();
expect(appStateDelta).toBe(appStateDelta1);
expect(appStateDelta.delta).toEqual(
Delta.create<Partial<ObservedAppState>>(
{
name: "untitled scene",
selectedElementIds: { id1: true, id3: true },
selectedGroupIds: { g1: true },
lockedMultiSelections: { g1: true },
},
{
name: "titled scene",
selectedElementIds: { id2: true },
selectedGroupIds: { g1: true, g2: true, g3: true },
lockedMultiSelections: { g3: true },
},
),
);
});
});
}); });
+171 -1
View File
@@ -1,13 +1,14 @@
import { getLineHeight } from "@excalidraw/common"; import { getLineHeight } from "@excalidraw/common";
import { API } from "@excalidraw/excalidraw/tests/helpers/api"; import { API } from "@excalidraw/excalidraw/tests/helpers/api";
import { FONT_FAMILY } from "@excalidraw/common"; import { FONT_FAMILY, TEXT_ALIGN, VERTICAL_ALIGN } from "@excalidraw/common";
import { import {
computeContainerDimensionForBoundText, computeContainerDimensionForBoundText,
getContainerCoords, getContainerCoords,
getBoundTextMaxWidth, getBoundTextMaxWidth,
getBoundTextMaxHeight, getBoundTextMaxHeight,
computeBoundTextPosition,
} from "../src/textElement"; } from "../src/textElement";
import { detectLineHeight, getLineHeightInPx } from "../src/textMeasurements"; import { detectLineHeight, getLineHeightInPx } from "../src/textMeasurements";
@@ -207,3 +208,172 @@ describe("Test getDefaultLineHeight", () => {
expect(getLineHeight(FONT_FAMILY.Cascadia)).toBe(1.2); expect(getLineHeight(FONT_FAMILY.Cascadia)).toBe(1.2);
}); });
}); });
describe("Test computeBoundTextPosition", () => {
const createMockElementsMap = () => new Map();
// Helper function to create rectangle test case with 90-degree rotation
const createRotatedRectangleTestCase = (
textAlign: string,
verticalAlign: string,
) => {
const container = API.createElement({
type: "rectangle",
x: 100,
y: 100,
width: 200,
height: 100,
angle: (Math.PI / 2) as any, // 90 degrees
});
const boundTextElement = API.createElement({
type: "text",
width: 80,
height: 40,
text: "hello darkness my old friend",
textAlign: textAlign as any,
verticalAlign: verticalAlign as any,
containerId: container.id,
}) as ExcalidrawTextElementWithContainer;
const elementsMap = createMockElementsMap();
return { container, boundTextElement, elementsMap };
};
describe("90-degree rotation with all alignment combinations", () => {
// Test all 9 combinations of horizontal (left, center, right) and vertical (top, middle, bottom) alignment
it("should position text with LEFT + TOP alignment at 90-degree rotation", () => {
const { container, boundTextElement, elementsMap } =
createRotatedRectangleTestCase(TEXT_ALIGN.LEFT, VERTICAL_ALIGN.TOP);
const result = computeBoundTextPosition(
container,
boundTextElement,
elementsMap,
);
expect(result.x).toBeCloseTo(185, 1);
expect(result.y).toBeCloseTo(75, 1);
});
it("should position text with LEFT + MIDDLE alignment at 90-degree rotation", () => {
const { container, boundTextElement, elementsMap } =
createRotatedRectangleTestCase(TEXT_ALIGN.LEFT, VERTICAL_ALIGN.MIDDLE);
const result = computeBoundTextPosition(
container,
boundTextElement,
elementsMap,
);
expect(result.x).toBeCloseTo(160, 1);
expect(result.y).toBeCloseTo(75, 1);
});
it("should position text with LEFT + BOTTOM alignment at 90-degree rotation", () => {
const { container, boundTextElement, elementsMap } =
createRotatedRectangleTestCase(TEXT_ALIGN.LEFT, VERTICAL_ALIGN.BOTTOM);
const result = computeBoundTextPosition(
container,
boundTextElement,
elementsMap,
);
expect(result.x).toBeCloseTo(135, 1);
expect(result.y).toBeCloseTo(75, 1);
});
it("should position text with CENTER + TOP alignment at 90-degree rotation", () => {
const { container, boundTextElement, elementsMap } =
createRotatedRectangleTestCase(TEXT_ALIGN.CENTER, VERTICAL_ALIGN.TOP);
const result = computeBoundTextPosition(
container,
boundTextElement,
elementsMap,
);
expect(result.x).toBeCloseTo(185, 1);
expect(result.y).toBeCloseTo(130, 1);
});
it("should position text with CENTER + MIDDLE alignment at 90-degree rotation", () => {
const { container, boundTextElement, elementsMap } =
createRotatedRectangleTestCase(
TEXT_ALIGN.CENTER,
VERTICAL_ALIGN.MIDDLE,
);
const result = computeBoundTextPosition(
container,
boundTextElement,
elementsMap,
);
expect(result.x).toBeCloseTo(160, 1);
expect(result.y).toBeCloseTo(130, 1);
});
it("should position text with CENTER + BOTTOM alignment at 90-degree rotation", () => {
const { container, boundTextElement, elementsMap } =
createRotatedRectangleTestCase(
TEXT_ALIGN.CENTER,
VERTICAL_ALIGN.BOTTOM,
);
const result = computeBoundTextPosition(
container,
boundTextElement,
elementsMap,
);
expect(result.x).toBeCloseTo(135, 1);
expect(result.y).toBeCloseTo(130, 1);
});
it("should position text with RIGHT + TOP alignment at 90-degree rotation", () => {
const { container, boundTextElement, elementsMap } =
createRotatedRectangleTestCase(TEXT_ALIGN.RIGHT, VERTICAL_ALIGN.TOP);
const result = computeBoundTextPosition(
container,
boundTextElement,
elementsMap,
);
expect(result.x).toBeCloseTo(185, 1);
expect(result.y).toBeCloseTo(185, 1);
});
it("should position text with RIGHT + MIDDLE alignment at 90-degree rotation", () => {
const { container, boundTextElement, elementsMap } =
createRotatedRectangleTestCase(TEXT_ALIGN.RIGHT, VERTICAL_ALIGN.MIDDLE);
const result = computeBoundTextPosition(
container,
boundTextElement,
elementsMap,
);
expect(result.x).toBeCloseTo(160, 1);
expect(result.y).toBeCloseTo(185, 1);
});
it("should position text with RIGHT + BOTTOM alignment at 90-degree rotation", () => {
const { container, boundTextElement, elementsMap } =
createRotatedRectangleTestCase(TEXT_ALIGN.RIGHT, VERTICAL_ALIGN.BOTTOM);
const result = computeBoundTextPosition(
container,
boundTextElement,
elementsMap,
);
expect(result.x).toBeCloseTo(135, 1);
expect(result.y).toBeCloseTo(185, 1);
});
});
});
+3 -1
View File
@@ -4,7 +4,7 @@ import { isFrameLikeElement } from "@excalidraw/element";
import { updateFrameMembershipOfSelectedElements } from "@excalidraw/element"; import { updateFrameMembershipOfSelectedElements } from "@excalidraw/element";
import { KEYS, arrayToMap, getShortcutKey } from "@excalidraw/common"; import { KEYS, arrayToMap } from "@excalidraw/common";
import { alignElements } from "@excalidraw/element"; import { alignElements } from "@excalidraw/element";
@@ -30,6 +30,8 @@ import { t } from "../i18n";
import { isSomeElementSelected } from "../scene"; import { isSomeElementSelected } from "../scene";
import { getShortcutKey } from "../shortcut";
import { register } from "./register"; import { register } from "./register";
import type { AppClassProperties, AppState, UIAppState } from "../types"; import type { AppClassProperties, AppState, UIAppState } from "../types";
@@ -8,6 +8,7 @@ import {
} from "@excalidraw/common"; } from "@excalidraw/common";
import { import {
getOriginalContainerHeightFromCache, getOriginalContainerHeightFromCache,
isBoundToContainer,
resetOriginalContainerCache, resetOriginalContainerCache,
updateOriginalContainerCache, updateOriginalContainerCache,
} from "@excalidraw/element"; } from "@excalidraw/element";
@@ -225,7 +226,9 @@ export const actionWrapTextInContainer = register({
trackEvent: { category: "element" }, trackEvent: { category: "element" },
predicate: (elements, appState, _, app) => { predicate: (elements, appState, _, app) => {
const selectedElements = app.scene.getSelectedElements(appState); const selectedElements = app.scene.getSelectedElements(appState);
const someTextElements = selectedElements.some((el) => isTextElement(el)); const someTextElements = selectedElements.some(
(el) => isTextElement(el) && !isBoundToContainer(el),
);
return selectedElements.length > 0 && someTextElements; return selectedElements.length > 0 && someTextElements;
}, },
perform: (elements, appState, _, app) => { perform: (elements, appState, _, app) => {
@@ -234,7 +237,7 @@ export const actionWrapTextInContainer = register({
const containerIds: Mutable<AppState["selectedElementIds"]> = {}; const containerIds: Mutable<AppState["selectedElementIds"]> = {};
for (const textElement of selectedElements) { for (const textElement of selectedElements) {
if (isTextElement(textElement)) { if (isTextElement(textElement) && !isBoundToContainer(textElement)) {
const container = newElement({ const container = newElement({
type: "rectangle", type: "rectangle",
backgroundColor: appState.currentItemBackgroundColor, backgroundColor: appState.currentItemBackgroundColor,
+11 -5
View File
@@ -7,7 +7,6 @@ import {
MIN_ZOOM, MIN_ZOOM,
THEME, THEME,
ZOOM_STEP, ZOOM_STEP,
getShortcutKey,
updateActiveTool, updateActiveTool,
CODES, CODES,
KEYS, KEYS,
@@ -46,6 +45,7 @@ import { t } from "../i18n";
import { getNormalizedZoom } from "../scene"; import { getNormalizedZoom } from "../scene";
import { centerScrollOn } from "../scene/scroll"; import { centerScrollOn } from "../scene/scroll";
import { getStateForZoom } from "../scene/zoom"; import { getStateForZoom } from "../scene/zoom";
import { getShortcutKey } from "../shortcut";
import { register } from "./register"; import { register } from "./register";
@@ -69,7 +69,7 @@ export const actionChangeViewBackgroundColor = register({
: CaptureUpdateAction.EVENTUALLY, : CaptureUpdateAction.EVENTUALLY,
}; };
}, },
PanelComponent: ({ elements, appState, updateData, appProps }) => { PanelComponent: ({ elements, appState, updateData, appProps, data }) => {
// FIXME move me to src/components/mainMenu/DefaultItems.tsx // FIXME move me to src/components/mainMenu/DefaultItems.tsx
return ( return (
<ColorPicker <ColorPicker
@@ -121,7 +121,10 @@ export const actionClearCanvas = register({
pasteDialog: appState.pasteDialog, pasteDialog: appState.pasteDialog,
activeTool: activeTool:
appState.activeTool.type === "image" appState.activeTool.type === "image"
? { ...appState.activeTool, type: "selection" } ? {
...appState.activeTool,
type: app.state.preferredSelectionTool.type,
}
: appState.activeTool, : appState.activeTool,
}, },
captureUpdate: CaptureUpdateAction.IMMEDIATELY, captureUpdate: CaptureUpdateAction.IMMEDIATELY,
@@ -494,13 +497,13 @@ export const actionToggleEraserTool = register({
name: "toggleEraserTool", name: "toggleEraserTool",
label: "toolBar.eraser", label: "toolBar.eraser",
trackEvent: { category: "toolbar" }, trackEvent: { category: "toolbar" },
perform: (elements, appState) => { perform: (elements, appState, _, app) => {
let activeTool: AppState["activeTool"]; let activeTool: AppState["activeTool"];
if (isEraserActive(appState)) { if (isEraserActive(appState)) {
activeTool = updateActiveTool(appState, { activeTool = updateActiveTool(appState, {
...(appState.activeTool.lastActiveTool || { ...(appState.activeTool.lastActiveTool || {
type: "selection", type: app.state.preferredSelectionTool.type,
}), }),
lastActiveToolBeforeEraser: null, lastActiveToolBeforeEraser: null,
}); });
@@ -530,6 +533,9 @@ export const actionToggleLassoTool = register({
label: "toolBar.lasso", label: "toolBar.lasso",
icon: LassoIcon, icon: LassoIcon,
trackEvent: { category: "toolbar" }, trackEvent: { category: "toolbar" },
predicate: (elements, appState, props, app) => {
return app.state.preferredSelectionTool.type !== "lasso";
},
perform: (elements, appState, _, app) => { perform: (elements, appState, _, app) => {
let activeTool: AppState["activeTool"]; let activeTool: AppState["activeTool"];
@@ -1,4 +1,8 @@
import { KEYS, updateActiveTool } from "@excalidraw/common"; import {
KEYS,
MOBILE_ACTION_BUTTON_BG,
updateActiveTool,
} from "@excalidraw/common";
import { getNonDeletedElements } from "@excalidraw/element"; import { getNonDeletedElements } from "@excalidraw/element";
import { fixBindingsAfterDeletion } from "@excalidraw/element"; import { fixBindingsAfterDeletion } from "@excalidraw/element";
@@ -26,6 +30,8 @@ import { getSelectedElements, isSomeElementSelected } from "../scene";
import { TrashIcon } from "../components/icons"; import { TrashIcon } from "../components/icons";
import { ToolButton } from "../components/ToolButton"; import { ToolButton } from "../components/ToolButton";
import { useStylesPanelMode } from "..";
import { register } from "./register"; import { register } from "./register";
import type { AppClassProperties, AppState } from "../types"; import type { AppClassProperties, AppState } from "../types";
@@ -298,7 +304,9 @@ export const actionDeleteSelected = register({
elements: nextElements, elements: nextElements,
appState: { appState: {
...nextAppState, ...nextAppState,
activeTool: updateActiveTool(appState, { type: "selection" }), activeTool: updateActiveTool(appState, {
type: app.state.preferredSelectionTool.type,
}),
multiElement: null, multiElement: null,
activeEmbeddable: null, activeEmbeddable: null,
selectedLinearElement: null, selectedLinearElement: null,
@@ -314,14 +322,25 @@ export const actionDeleteSelected = register({
keyTest: (event, appState, elements) => keyTest: (event, appState, elements) =>
(event.key === KEYS.BACKSPACE || event.key === KEYS.DELETE) && (event.key === KEYS.BACKSPACE || event.key === KEYS.DELETE) &&
!event[KEYS.CTRL_OR_CMD], !event[KEYS.CTRL_OR_CMD],
PanelComponent: ({ elements, appState, updateData }) => ( PanelComponent: ({ elements, appState, updateData, app }) => {
<ToolButton const isMobile = useStylesPanelMode() === "mobile";
type="button"
icon={TrashIcon} return (
title={t("labels.delete")} <ToolButton
aria-label={t("labels.delete")} type="button"
onClick={() => updateData(null)} icon={TrashIcon}
visible={isSomeElementSelected(getNonDeletedElements(elements), appState)} title={t("labels.delete")}
/> aria-label={t("labels.delete")}
), onClick={() => updateData(null)}
disabled={
!isSomeElementSelected(getNonDeletedElements(elements), appState)
}
style={{
...(isMobile && appState.openPopup !== "compactOtherProperties"
? MOBILE_ACTION_BUTTON_BG
: {}),
}}
/>
);
},
}); });
@@ -2,7 +2,7 @@ import { getNonDeletedElements } from "@excalidraw/element";
import { isFrameLikeElement } from "@excalidraw/element"; import { isFrameLikeElement } from "@excalidraw/element";
import { CODES, KEYS, arrayToMap, getShortcutKey } from "@excalidraw/common"; import { CODES, KEYS, arrayToMap } from "@excalidraw/common";
import { updateFrameMembershipOfSelectedElements } from "@excalidraw/element"; import { updateFrameMembershipOfSelectedElements } from "@excalidraw/element";
@@ -26,6 +26,8 @@ import { t } from "../i18n";
import { isSomeElementSelected } from "../scene"; import { isSomeElementSelected } from "../scene";
import { getShortcutKey } from "../shortcut";
import { register } from "./register"; import { register } from "./register";
import type { AppClassProperties, AppState } from "../types"; import type { AppClassProperties, AppState } from "../types";
@@ -1,8 +1,8 @@
import { import {
DEFAULT_GRID_SIZE, DEFAULT_GRID_SIZE,
KEYS, KEYS,
MOBILE_ACTION_BUTTON_BG,
arrayToMap, arrayToMap,
getShortcutKey,
} from "@excalidraw/common"; } from "@excalidraw/common";
import { getNonDeletedElements } from "@excalidraw/element"; import { getNonDeletedElements } from "@excalidraw/element";
@@ -25,6 +25,9 @@ import { DuplicateIcon } from "../components/icons";
import { t } from "../i18n"; import { t } from "../i18n";
import { isSomeElementSelected } from "../scene"; import { isSomeElementSelected } from "../scene";
import { getShortcutKey } from "../shortcut";
import { useStylesPanelMode } from "..";
import { register } from "./register"; import { register } from "./register";
@@ -106,16 +109,27 @@ export const actionDuplicateSelection = register({
}; };
}, },
keyTest: (event) => event[KEYS.CTRL_OR_CMD] && event.key === KEYS.D, keyTest: (event) => event[KEYS.CTRL_OR_CMD] && event.key === KEYS.D,
PanelComponent: ({ elements, appState, updateData }) => ( PanelComponent: ({ elements, appState, updateData, app }) => {
<ToolButton const isMobile = useStylesPanelMode() === "mobile";
type="button"
icon={DuplicateIcon} return (
title={`${t("labels.duplicateSelection")}${getShortcutKey( <ToolButton
"CtrlOrCmd+D", type="button"
)}`} icon={DuplicateIcon}
aria-label={t("labels.duplicateSelection")} title={`${t("labels.duplicateSelection")}${getShortcutKey(
onClick={() => updateData(null)} "CtrlOrCmd+D",
visible={isSomeElementSelected(getNonDeletedElements(elements), appState)} )}`}
/> aria-label={t("labels.duplicateSelection")}
), onClick={() => updateData(null)}
disabled={
!isSomeElementSelected(getNonDeletedElements(elements), appState)
}
style={{
...(isMobile && appState.openPopup !== "compactOtherProperties"
? MOBILE_ACTION_BUTTON_BG
: {}),
}}
/>
);
},
}); });
+2 -2
View File
@@ -11,7 +11,7 @@ import { CaptureUpdateAction } from "@excalidraw/element";
import type { Theme } from "@excalidraw/element/types"; import type { Theme } from "@excalidraw/element/types";
import { useDevice } from "../components/App"; import { useEditorInterface } from "../components/App";
import { CheckboxItem } from "../components/CheckboxItem"; import { CheckboxItem } from "../components/CheckboxItem";
import { DarkModeToggle } from "../components/DarkModeToggle"; import { DarkModeToggle } from "../components/DarkModeToggle";
import { ProjectName } from "../components/ProjectName"; import { ProjectName } from "../components/ProjectName";
@@ -242,7 +242,7 @@ export const actionSaveFileToDisk = register({
icon={saveAs} icon={saveAs}
title={t("buttons.saveAs")} title={t("buttons.saveAs")}
aria-label={t("buttons.saveAs")} aria-label={t("buttons.saveAs")}
showAriaLabel={useDevice().editor.isMobile} showAriaLabel={useEditorInterface().formFactor === "phone"}
hidden={!nativeFileSystemSupported} hidden={!nativeFileSystemSupported}
onClick={() => updateData(null)} onClick={() => updateData(null)}
data-testid="save-as-button" data-testid="save-as-button"
+27 -6
View File
@@ -5,7 +5,11 @@ import {
bindOrUnbindLinearElement, bindOrUnbindLinearElement,
isBindingEnabled, isBindingEnabled,
} from "@excalidraw/element/binding"; } from "@excalidraw/element/binding";
import { isValidPolygon, LinearElementEditor } from "@excalidraw/element"; import {
isValidPolygon,
LinearElementEditor,
newElementWith,
} from "@excalidraw/element";
import { import {
isBindingElement, isBindingElement,
@@ -78,7 +82,14 @@ export const actionFinalize = register({
let newElements = elements; let newElements = elements;
if (element && isInvisiblySmallElement(element)) { if (element && isInvisiblySmallElement(element)) {
// TODO: #7348 in theory this gets recorded by the store, so the invisible elements could be restored by the undo/redo, which might be not what we would want // TODO: #7348 in theory this gets recorded by the store, so the invisible elements could be restored by the undo/redo, which might be not what we would want
newElements = newElements.filter((el) => el.id !== element!.id); newElements = newElements.map((el) => {
if (el.id === element.id) {
return newElementWith(el, {
isDeleted: true,
});
}
return el;
});
} }
return { return {
elements: newElements, elements: newElements,
@@ -117,7 +128,12 @@ export const actionFinalize = register({
return { return {
elements: elements:
element.points.length < 2 || isInvisiblySmallElement(element) element.points.length < 2 || isInvisiblySmallElement(element)
? elements.filter((el) => el.id !== element.id) ? elements.map((el) => {
if (el.id === element.id) {
return newElementWith(el, { isDeleted: true });
}
return el;
})
: undefined, : undefined,
appState: { appState: {
...appState, ...appState,
@@ -172,7 +188,12 @@ export const actionFinalize = register({
if (element && isInvisiblySmallElement(element)) { if (element && isInvisiblySmallElement(element)) {
// TODO: #7348 in theory this gets recorded by the store, so the invisible elements could be restored by the undo/redo, which might be not what we would want // TODO: #7348 in theory this gets recorded by the store, so the invisible elements could be restored by the undo/redo, which might be not what we would want
newElements = newElements.filter((el) => el.id !== element!.id); newElements = newElements.map((el) => {
if (el.id === element?.id) {
return newElementWith(el, { isDeleted: true });
}
return el;
});
} }
if (isLinearElement(element) || isFreeDrawElement(element)) { if (isLinearElement(element) || isFreeDrawElement(element)) {
@@ -240,13 +261,13 @@ export const actionFinalize = register({
if (appState.activeTool.type === "eraser") { if (appState.activeTool.type === "eraser") {
activeTool = updateActiveTool(appState, { activeTool = updateActiveTool(appState, {
...(appState.activeTool.lastActiveTool || { ...(appState.activeTool.lastActiveTool || {
type: "selection", type: app.state.preferredSelectionTool.type,
}), }),
lastActiveToolBeforeEraser: null, lastActiveToolBeforeEraser: null,
}); });
} else { } else {
activeTool = updateActiveTool(appState, { activeTool = updateActiveTool(appState, {
type: "selection", type: app.state.preferredSelectionTool.type,
}); });
} }
+3 -1
View File
@@ -14,7 +14,7 @@ import {
replaceAllElementsInFrame, replaceAllElementsInFrame,
} from "@excalidraw/element"; } from "@excalidraw/element";
import { KEYS, randomId, arrayToMap, getShortcutKey } from "@excalidraw/common"; import { KEYS, randomId, arrayToMap } from "@excalidraw/common";
import { import {
getSelectedGroupIds, getSelectedGroupIds,
@@ -43,6 +43,8 @@ import { t } from "../i18n";
import { isSomeElementSelected } from "../scene"; import { isSomeElementSelected } from "../scene";
import { getShortcutKey } from "../shortcut";
import { register } from "./register"; import { register } from "./register";
import type { AppClassProperties, AppState } from "../types"; import type { AppClassProperties, AppState } from "../types";
+19 -3
View File
@@ -1,4 +1,10 @@
import { isWindows, KEYS, matchKey, arrayToMap } from "@excalidraw/common"; import {
isWindows,
KEYS,
matchKey,
arrayToMap,
MOBILE_ACTION_BUTTON_BG,
} from "@excalidraw/common";
import { CaptureUpdateAction } from "@excalidraw/element"; import { CaptureUpdateAction } from "@excalidraw/element";
@@ -12,6 +18,8 @@ import { HistoryChangedEvent } from "../history";
import { useEmitter } from "../hooks/useEmitter"; import { useEmitter } from "../hooks/useEmitter";
import { t } from "../i18n"; import { t } from "../i18n";
import { useStylesPanelMode } from "..";
import type { History } from "../history"; import type { History } from "../history";
import type { AppClassProperties, AppState } from "../types"; import type { AppClassProperties, AppState } from "../types";
import type { Action, ActionResult } from "./types"; import type { Action, ActionResult } from "./types";
@@ -67,7 +75,7 @@ export const createUndoAction: ActionCreator = (history) => ({
), ),
keyTest: (event) => keyTest: (event) =>
event[KEYS.CTRL_OR_CMD] && matchKey(event, KEYS.Z) && !event.shiftKey, event[KEYS.CTRL_OR_CMD] && matchKey(event, KEYS.Z) && !event.shiftKey,
PanelComponent: ({ updateData, data }) => { PanelComponent: ({ appState, updateData, data, app }) => {
const { isUndoStackEmpty } = useEmitter<HistoryChangedEvent>( const { isUndoStackEmpty } = useEmitter<HistoryChangedEvent>(
history.onHistoryChangedEmitter, history.onHistoryChangedEmitter,
new HistoryChangedEvent( new HistoryChangedEvent(
@@ -75,6 +83,7 @@ export const createUndoAction: ActionCreator = (history) => ({
history.isRedoStackEmpty, history.isRedoStackEmpty,
), ),
); );
const isMobile = useStylesPanelMode() === "mobile";
return ( return (
<ToolButton <ToolButton
@@ -85,6 +94,9 @@ export const createUndoAction: ActionCreator = (history) => ({
size={data?.size || "medium"} size={data?.size || "medium"}
disabled={isUndoStackEmpty} disabled={isUndoStackEmpty}
data-testid="button-undo" data-testid="button-undo"
style={{
...(isMobile ? MOBILE_ACTION_BUTTON_BG : {}),
}}
/> />
); );
}, },
@@ -103,7 +115,7 @@ export const createRedoAction: ActionCreator = (history) => ({
keyTest: (event) => keyTest: (event) =>
(event[KEYS.CTRL_OR_CMD] && event.shiftKey && matchKey(event, KEYS.Z)) || (event[KEYS.CTRL_OR_CMD] && event.shiftKey && matchKey(event, KEYS.Z)) ||
(isWindows && event.ctrlKey && !event.shiftKey && matchKey(event, KEYS.Y)), (isWindows && event.ctrlKey && !event.shiftKey && matchKey(event, KEYS.Y)),
PanelComponent: ({ updateData, data }) => { PanelComponent: ({ appState, updateData, data, app }) => {
const { isRedoStackEmpty } = useEmitter( const { isRedoStackEmpty } = useEmitter(
history.onHistoryChangedEmitter, history.onHistoryChangedEmitter,
new HistoryChangedEvent( new HistoryChangedEvent(
@@ -111,6 +123,7 @@ export const createRedoAction: ActionCreator = (history) => ({
history.isRedoStackEmpty, history.isRedoStackEmpty,
), ),
); );
const isMobile = useStylesPanelMode() === "mobile";
return ( return (
<ToolButton <ToolButton
@@ -121,6 +134,9 @@ export const createRedoAction: ActionCreator = (history) => ({
size={data?.size || "medium"} size={data?.size || "medium"}
disabled={isRedoStackEmpty} disabled={isRedoStackEmpty}
data-testid="button-redo" data-testid="button-redo"
style={{
...(isMobile ? MOBILE_ACTION_BUTTON_BG : {}),
}}
/> />
); );
}, },
@@ -88,6 +88,10 @@ export const actionToggleLinearEditor = register({
selectedElementIds: appState.selectedElementIds, selectedElementIds: appState.selectedElementIds,
})[0] as ExcalidrawLinearElement; })[0] as ExcalidrawLinearElement;
if (!selectedElement) {
return null;
}
const label = t( const label = t(
selectedElement.type === "arrow" selectedElement.type === "arrow"
? "labels.lineEditor.editArrow" ? "labels.lineEditor.editArrow"
+2 -2
View File
@@ -1,6 +1,6 @@
import { isEmbeddableElement } from "@excalidraw/element"; import { isEmbeddableElement } from "@excalidraw/element";
import { KEYS, getShortcutKey } from "@excalidraw/common"; import { KEYS } from "@excalidraw/common";
import { CaptureUpdateAction } from "@excalidraw/element"; import { CaptureUpdateAction } from "@excalidraw/element";
@@ -8,8 +8,8 @@ import { ToolButton } from "../components/ToolButton";
import { getContextMenuLabel } from "../components/hyperlink/Hyperlink"; import { getContextMenuLabel } from "../components/hyperlink/Hyperlink";
import { LinkIcon } from "../components/icons"; import { LinkIcon } from "../components/icons";
import { t } from "../i18n"; import { t } from "../i18n";
import { getSelectedElements } from "../scene"; import { getSelectedElements } from "../scene";
import { getShortcutKey } from "../shortcut";
import { register } from "./register"; import { register } from "./register";
+3 -55
View File
@@ -1,65 +1,11 @@
import { KEYS } from "@excalidraw/common"; import { KEYS } from "@excalidraw/common";
import { getNonDeletedElements } from "@excalidraw/element";
import { showSelectedShapeActions } from "@excalidraw/element";
import { CaptureUpdateAction } from "@excalidraw/element"; import { CaptureUpdateAction } from "@excalidraw/element";
import { ToolButton } from "../components/ToolButton"; import { HelpIconThin } from "../components/icons";
import { HamburgerMenuIcon, HelpIconThin, palette } from "../components/icons";
import { t } from "../i18n";
import { register } from "./register"; import { register } from "./register";
export const actionToggleCanvasMenu = register({
name: "toggleCanvasMenu",
label: "buttons.menu",
trackEvent: { category: "menu" },
perform: (_, appState) => ({
appState: {
...appState,
openMenu: appState.openMenu === "canvas" ? null : "canvas",
},
captureUpdate: CaptureUpdateAction.EVENTUALLY,
}),
PanelComponent: ({ appState, updateData }) => (
<ToolButton
type="button"
icon={HamburgerMenuIcon}
aria-label={t("buttons.menu")}
onClick={updateData}
selected={appState.openMenu === "canvas"}
/>
),
});
export const actionToggleEditMenu = register({
name: "toggleEditMenu",
label: "buttons.edit",
trackEvent: { category: "menu" },
perform: (_elements, appState) => ({
appState: {
...appState,
openMenu: appState.openMenu === "shape" ? null : "shape",
},
captureUpdate: CaptureUpdateAction.EVENTUALLY,
}),
PanelComponent: ({ elements, appState, updateData }) => (
<ToolButton
visible={showSelectedShapeActions(
appState,
getNonDeletedElements(elements),
)}
type="button"
icon={palette}
aria-label={t("buttons.edit")}
onClick={updateData}
selected={appState.openMenu === "shape"}
/>
),
});
export const actionShortcuts = register({ export const actionShortcuts = register({
name: "toggleShortcuts", name: "toggleShortcuts",
label: "welcomeScreen.defaults.helpHint", label: "welcomeScreen.defaults.helpHint",
@@ -79,6 +25,8 @@ export const actionShortcuts = register({
: { : {
name: "help", name: "help",
}, },
openMenu: null,
openPopup: null,
}, },
captureUpdate: CaptureUpdateAction.EVENTUALLY, captureUpdate: CaptureUpdateAction.EVENTUALLY,
}; };
+226 -140
View File
@@ -17,7 +17,6 @@ import {
randomInteger, randomInteger,
arrayToMap, arrayToMap,
getFontFamilyString, getFontFamilyString,
getShortcutKey,
getLineHeight, getLineHeight,
isTransparent, isTransparent,
reduceToCommonValue, reduceToCommonValue,
@@ -58,6 +57,8 @@ import {
toggleLinePolygonState, toggleLinePolygonState,
} from "@excalidraw/element"; } from "@excalidraw/element";
import { deriveStylesPanelMode } from "@excalidraw/common";
import type { LocalPoint } from "@excalidraw/math"; import type { LocalPoint } from "@excalidraw/math";
import type { import type {
@@ -81,9 +82,6 @@ import { RadioSelection } from "../components/RadioSelection";
import { ColorPicker } from "../components/ColorPicker/ColorPicker"; import { ColorPicker } from "../components/ColorPicker/ColorPicker";
import { FontPicker } from "../components/FontPicker/FontPicker"; import { FontPicker } from "../components/FontPicker/FontPicker";
import { IconPicker } from "../components/IconPicker"; import { IconPicker } from "../components/IconPicker";
// TODO barnabasmolnar/editor-redesign
// TextAlignTopIcon, TextAlignBottomIcon,TextAlignMiddleIcon,
// ArrowHead icons
import { Range } from "../components/Range"; import { Range } from "../components/Range";
import { import {
ArrowheadArrowIcon, ArrowheadArrowIcon,
@@ -137,12 +135,28 @@ import {
isSomeElementSelected, isSomeElementSelected,
} from "../scene"; } from "../scene";
import {
withCaretPositionPreservation,
restoreCaretPosition,
} from "../hooks/useTextEditorFocus";
import { getShortcutKey } from "../shortcut";
import { register } from "./register"; import { register } from "./register";
import type { AppClassProperties, AppState, Primitive } from "../types"; import type { AppClassProperties, AppState, Primitive } from "../types";
const FONT_SIZE_RELATIVE_INCREASE_STEP = 0.1; const FONT_SIZE_RELATIVE_INCREASE_STEP = 0.1;
const getStylesPanelInfo = (app: AppClassProperties) => {
const stylesPanelMode = deriveStylesPanelMode(app.editorInterface);
return {
stylesPanelMode,
isCompact: stylesPanelMode !== "full",
isMobile: stylesPanelMode === "mobile",
} as const;
};
export const changeProperty = ( export const changeProperty = (
elements: readonly ExcalidrawElement[], elements: readonly ExcalidrawElement[],
appState: AppState, appState: AppState,
@@ -321,29 +335,35 @@ export const actionChangeStrokeColor = register({
: CaptureUpdateAction.EVENTUALLY, : CaptureUpdateAction.EVENTUALLY,
}; };
}, },
PanelComponent: ({ elements, appState, updateData, app }) => ( PanelComponent: ({ elements, appState, updateData, app, data }) => {
<> const { stylesPanelMode } = getStylesPanelInfo(app);
<h3 aria-hidden="true">{t("labels.stroke")}</h3>
<ColorPicker return (
topPicks={DEFAULT_ELEMENT_STROKE_PICKS} <>
palette={DEFAULT_ELEMENT_STROKE_COLOR_PALETTE} {stylesPanelMode === "full" && (
type="elementStroke" <h3 aria-hidden="true">{t("labels.stroke")}</h3>
label={t("labels.stroke")}
color={getFormValue(
elements,
app,
(element) => element.strokeColor,
true,
(hasSelection) =>
!hasSelection ? appState.currentItemStrokeColor : null,
)} )}
onChange={(color) => updateData({ currentItemStrokeColor: color })} <ColorPicker
elements={elements} topPicks={DEFAULT_ELEMENT_STROKE_PICKS}
appState={appState} palette={DEFAULT_ELEMENT_STROKE_COLOR_PALETTE}
updateData={updateData} type="elementStroke"
/> label={t("labels.stroke")}
</> color={getFormValue(
), elements,
app,
(element) => element.strokeColor,
true,
(hasSelection) =>
!hasSelection ? appState.currentItemStrokeColor : null,
)}
onChange={(color) => updateData({ currentItemStrokeColor: color })}
elements={elements}
appState={appState}
updateData={updateData}
/>
</>
);
},
}); });
export const actionChangeBackgroundColor = register({ export const actionChangeBackgroundColor = register({
@@ -398,29 +418,37 @@ export const actionChangeBackgroundColor = register({
captureUpdate: CaptureUpdateAction.IMMEDIATELY, captureUpdate: CaptureUpdateAction.IMMEDIATELY,
}; };
}, },
PanelComponent: ({ elements, appState, updateData, app }) => ( PanelComponent: ({ elements, appState, updateData, app, data }) => {
<> const { stylesPanelMode } = getStylesPanelInfo(app);
<h3 aria-hidden="true">{t("labels.background")}</h3>
<ColorPicker return (
topPicks={DEFAULT_ELEMENT_BACKGROUND_PICKS} <>
palette={DEFAULT_ELEMENT_BACKGROUND_COLOR_PALETTE} {stylesPanelMode === "full" && (
type="elementBackground" <h3 aria-hidden="true">{t("labels.background")}</h3>
label={t("labels.background")}
color={getFormValue(
elements,
app,
(element) => element.backgroundColor,
true,
(hasSelection) =>
!hasSelection ? appState.currentItemBackgroundColor : null,
)} )}
onChange={(color) => updateData({ currentItemBackgroundColor: color })} <ColorPicker
elements={elements} topPicks={DEFAULT_ELEMENT_BACKGROUND_PICKS}
appState={appState} palette={DEFAULT_ELEMENT_BACKGROUND_COLOR_PALETTE}
updateData={updateData} type="elementBackground"
/> label={t("labels.background")}
</> color={getFormValue(
), elements,
app,
(element) => element.backgroundColor,
true,
(hasSelection) =>
!hasSelection ? appState.currentItemBackgroundColor : null,
)}
onChange={(color) =>
updateData({ currentItemBackgroundColor: color })
}
elements={elements}
appState={appState}
updateData={updateData}
/>
</>
);
},
}); });
export const actionChangeFillStyle = register({ export const actionChangeFillStyle = register({
@@ -431,7 +459,9 @@ export const actionChangeFillStyle = register({
trackEvent( trackEvent(
"element", "element",
"changeFillStyle", "changeFillStyle",
`${value} (${app.device.editor.isMobile ? "mobile" : "desktop"})`, `${value} (${
app.editorInterface.formFactor === "phone" ? "mobile" : "desktop"
})`,
); );
return { return {
elements: changeProperty(elements, appState, (el) => elements: changeProperty(elements, appState, (el) =>
@@ -518,7 +548,7 @@ export const actionChangeStrokeWidth = register({
captureUpdate: CaptureUpdateAction.IMMEDIATELY, captureUpdate: CaptureUpdateAction.IMMEDIATELY,
}; };
}, },
PanelComponent: ({ elements, appState, updateData, app }) => ( PanelComponent: ({ elements, appState, updateData, app, data }) => (
<fieldset> <fieldset>
<legend>{t("labels.strokeWidth")}</legend> <legend>{t("labels.strokeWidth")}</legend>
<div className="buttonList"> <div className="buttonList">
@@ -575,7 +605,7 @@ export const actionChangeSloppiness = register({
captureUpdate: CaptureUpdateAction.IMMEDIATELY, captureUpdate: CaptureUpdateAction.IMMEDIATELY,
}; };
}, },
PanelComponent: ({ elements, appState, updateData, app }) => ( PanelComponent: ({ elements, appState, updateData, app, data }) => (
<fieldset> <fieldset>
<legend>{t("labels.sloppiness")}</legend> <legend>{t("labels.sloppiness")}</legend>
<div className="buttonList"> <div className="buttonList">
@@ -628,7 +658,7 @@ export const actionChangeStrokeStyle = register({
captureUpdate: CaptureUpdateAction.IMMEDIATELY, captureUpdate: CaptureUpdateAction.IMMEDIATELY,
}; };
}, },
PanelComponent: ({ elements, appState, updateData, app }) => ( PanelComponent: ({ elements, appState, updateData, app, data }) => (
<fieldset> <fieldset>
<legend>{t("labels.strokeStyle")}</legend> <legend>{t("labels.strokeStyle")}</legend>
<div className="buttonList"> <div className="buttonList">
@@ -697,70 +727,81 @@ export const actionChangeFontSize = register({
perform: (elements, appState, value, app) => { perform: (elements, appState, value, app) => {
return changeFontSize(elements, appState, app, () => value, value); return changeFontSize(elements, appState, app, () => value, value);
}, },
PanelComponent: ({ elements, appState, updateData, app }) => ( PanelComponent: ({ elements, appState, updateData, app, data }) => {
<fieldset> const { isCompact } = getStylesPanelInfo(app);
<legend>{t("labels.fontSize")}</legend>
<div className="buttonList"> return (
<RadioSelection <fieldset>
group="font-size" <legend>{t("labels.fontSize")}</legend>
options={[ <div className="buttonList">
{ <RadioSelection
value: 16, group="font-size"
text: t("labels.small"), options={[
icon: FontSizeSmallIcon, {
testId: "fontSize-small", value: 16,
}, text: t("labels.small"),
{ icon: FontSizeSmallIcon,
value: 20, testId: "fontSize-small",
text: t("labels.medium"), },
icon: FontSizeMediumIcon, {
testId: "fontSize-medium", value: 20,
}, text: t("labels.medium"),
{ icon: FontSizeMediumIcon,
value: 28, testId: "fontSize-medium",
text: t("labels.large"), },
icon: FontSizeLargeIcon, {
testId: "fontSize-large", value: 28,
}, text: t("labels.large"),
{ icon: FontSizeLargeIcon,
value: 36, testId: "fontSize-large",
text: t("labels.veryLarge"), },
icon: FontSizeExtraLargeIcon, {
testId: "fontSize-veryLarge", value: 36,
}, text: t("labels.veryLarge"),
]} icon: FontSizeExtraLargeIcon,
value={getFormValue( testId: "fontSize-veryLarge",
elements, },
app, ]}
(element) => { value={getFormValue(
if (isTextElement(element)) { elements,
return element.fontSize; app,
} (element) => {
const boundTextElement = getBoundTextElement( if (isTextElement(element)) {
element, return element.fontSize;
app.scene.getNonDeletedElementsMap(), }
const boundTextElement = getBoundTextElement(
element,
app.scene.getNonDeletedElementsMap(),
);
if (boundTextElement) {
return boundTextElement.fontSize;
}
return null;
},
(element) =>
isTextElement(element) ||
getBoundTextElement(
element,
app.scene.getNonDeletedElementsMap(),
) !== null,
(hasSelection) =>
hasSelection
? null
: appState.currentItemFontSize || DEFAULT_FONT_SIZE,
)}
onChange={(value) => {
withCaretPositionPreservation(
() => updateData(value),
isCompact,
!!appState.editingTextElement,
data?.onPreventClose,
); );
if (boundTextElement) { }}
return boundTextElement.fontSize; />
} </div>
return null; </fieldset>
}, );
(element) => },
isTextElement(element) ||
getBoundTextElement(
element,
app.scene.getNonDeletedElementsMap(),
) !== null,
(hasSelection) =>
hasSelection
? null
: appState.currentItemFontSize || DEFAULT_FONT_SIZE,
)}
onChange={(value) => updateData(value)}
/>
</div>
</fieldset>
),
}); });
export const actionDecreaseFontSize = register({ export const actionDecreaseFontSize = register({
@@ -1022,6 +1063,7 @@ export const actionChangeFontFamily = register({
// relying on state batching as multiple `FontPicker` handlers could be called in rapid succession and we want to combine them // relying on state batching as multiple `FontPicker` handlers could be called in rapid succession and we want to combine them
const [batchedData, setBatchedData] = useState<ChangeFontFamilyData>({}); const [batchedData, setBatchedData] = useState<ChangeFontFamilyData>({});
const isUnmounted = useRef(true); const isUnmounted = useRef(true);
const { stylesPanelMode, isCompact } = getStylesPanelInfo(app);
const selectedFontFamily = useMemo(() => { const selectedFontFamily = useMemo(() => {
const getFontFamily = ( const getFontFamily = (
@@ -1093,21 +1135,29 @@ export const actionChangeFontFamily = register({
}, []); }, []);
return ( return (
<fieldset> <>
<legend>{t("labels.fontFamily")}</legend> {stylesPanelMode === "full" && (
<legend>{t("labels.fontFamily")}</legend>
)}
<FontPicker <FontPicker
isOpened={appState.openPopup === "fontFamily"} isOpened={appState.openPopup === "fontFamily"}
selectedFontFamily={selectedFontFamily} selectedFontFamily={selectedFontFamily}
hoveredFontFamily={appState.currentHoveredFontFamily} hoveredFontFamily={appState.currentHoveredFontFamily}
compactMode={stylesPanelMode !== "full"}
onSelect={(fontFamily) => { onSelect={(fontFamily) => {
setBatchedData({ withCaretPositionPreservation(
openPopup: null, () => {
currentHoveredFontFamily: null, setBatchedData({
currentItemFontFamily: fontFamily, openPopup: null,
}); currentHoveredFontFamily: null,
currentItemFontFamily: fontFamily,
// defensive clear so immediate close won't abuse the cached elements });
cachedElementsRef.current.clear(); // defensive clear so immediate close won't abuse the cached elements
cachedElementsRef.current.clear();
},
isCompact,
!!appState.editingTextElement,
);
}} }}
onHover={(fontFamily) => { onHover={(fontFamily) => {
setBatchedData({ setBatchedData({
@@ -1164,29 +1214,29 @@ export const actionChangeFontFamily = register({
} }
setBatchedData({ setBatchedData({
...batchedData,
openPopup: "fontFamily", openPopup: "fontFamily",
}); });
} else { } else {
// close, use the cache and clear it afterwards const fontFamilyData = {
const data = {
openPopup: null,
currentHoveredFontFamily: null, currentHoveredFontFamily: null,
cachedElements: new Map(cachedElementsRef.current), cachedElements: new Map(cachedElementsRef.current),
resetAll: true, resetAll: true,
} as ChangeFontFamilyData; } as ChangeFontFamilyData;
if (isUnmounted.current) { setBatchedData({
// in case the component was unmounted by the parent, trigger the update directly ...fontFamilyData,
updateData({ ...batchedData, ...data }); });
} else {
setBatchedData(data);
}
cachedElementsRef.current.clear(); cachedElementsRef.current.clear();
// Refocus text editor when font picker closes if we were editing text
if (isCompact && appState.editingTextElement) {
restoreCaretPosition(null); // Just refocus without saved position
}
} }
}} }}
/> />
</fieldset> </>
); );
}, },
}); });
@@ -1225,8 +1275,10 @@ export const actionChangeTextAlign = register({
captureUpdate: CaptureUpdateAction.IMMEDIATELY, captureUpdate: CaptureUpdateAction.IMMEDIATELY,
}; };
}, },
PanelComponent: ({ elements, appState, updateData, app }) => { PanelComponent: ({ elements, appState, updateData, app, data }) => {
const elementsMap = app.scene.getNonDeletedElementsMap(); const elementsMap = app.scene.getNonDeletedElementsMap();
const { isCompact } = getStylesPanelInfo(app);
return ( return (
<fieldset> <fieldset>
<legend>{t("labels.textAlign")}</legend> <legend>{t("labels.textAlign")}</legend>
@@ -1275,7 +1327,14 @@ export const actionChangeTextAlign = register({
(hasSelection) => (hasSelection) =>
hasSelection ? null : appState.currentItemTextAlign, hasSelection ? null : appState.currentItemTextAlign,
)} )}
onChange={(value) => updateData(value)} onChange={(value) => {
withCaretPositionPreservation(
() => updateData(value),
isCompact,
!!appState.editingTextElement,
data?.onPreventClose,
);
}}
/> />
</div> </div>
</fieldset> </fieldset>
@@ -1317,7 +1376,8 @@ export const actionChangeVerticalAlign = register({
captureUpdate: CaptureUpdateAction.IMMEDIATELY, captureUpdate: CaptureUpdateAction.IMMEDIATELY,
}; };
}, },
PanelComponent: ({ elements, appState, updateData, app }) => { PanelComponent: ({ elements, appState, updateData, app, data }) => {
const { isCompact } = getStylesPanelInfo(app);
return ( return (
<fieldset> <fieldset>
<div className="buttonList"> <div className="buttonList">
@@ -1367,7 +1427,14 @@ export const actionChangeVerticalAlign = register({
) !== null, ) !== null,
(hasSelection) => (hasSelection ? null : VERTICAL_ALIGN.MIDDLE), (hasSelection) => (hasSelection ? null : VERTICAL_ALIGN.MIDDLE),
)} )}
onChange={(value) => updateData(value)} onChange={(value) => {
withCaretPositionPreservation(
() => updateData(value),
isCompact,
!!appState.editingTextElement,
data?.onPreventClose,
);
}}
/> />
</div> </div>
</fieldset> </fieldset>
@@ -1616,6 +1683,25 @@ export const actionChangeArrowhead = register({
}, },
}); });
export const actionChangeArrowProperties = register({
name: "changeArrowProperties",
label: "Change arrow properties",
trackEvent: false,
perform: (elements, appState, value, app) => {
// This action doesn't perform any changes directly
// It's just a container for the arrow type and arrowhead actions
return false;
},
PanelComponent: ({ elements, appState, updateData, app, renderAction }) => {
return (
<div className="selected-shape-actions">
{renderAction("changeArrowhead")}
{renderAction("changeArrowType")}
</div>
);
},
});
export const actionChangeArrowType = register({ export const actionChangeArrowType = register({
name: "changeArrowType", name: "changeArrowType",
label: "Change arrow types", label: "Change arrow types",
@@ -25,8 +25,11 @@ export const actionToggleZenMode = register({
}; };
}, },
checked: (appState) => appState.zenModeEnabled, checked: (appState) => appState.zenModeEnabled,
predicate: (elements, appState, appProps) => { predicate: (elements, appState, appProps, app) => {
return typeof appProps.zenModeEnabled === "undefined"; return (
app.editorInterface.formFactor !== "phone" &&
typeof appProps.zenModeEnabled === "undefined"
);
}, },
keyTest: (event) => keyTest: (event) =>
!event[KEYS.CTRL_OR_CMD] && event.altKey && event.code === CODES.Z, !event[KEYS.CTRL_OR_CMD] && event.altKey && event.code === CODES.Z,
+2 -1
View File
@@ -1,4 +1,4 @@
import { KEYS, CODES, getShortcutKey, isDarwin } from "@excalidraw/common"; import { KEYS, CODES, isDarwin } from "@excalidraw/common";
import { import {
moveOneLeft, moveOneLeft,
@@ -16,6 +16,7 @@ import {
SendToBackIcon, SendToBackIcon,
} from "../components/icons"; } from "../components/icons";
import { t } from "../i18n"; import { t } from "../i18n";
import { getShortcutKey } from "../shortcut";
import { register } from "./register"; import { register } from "./register";
+2 -5
View File
@@ -18,6 +18,7 @@ export {
actionChangeFontFamily, actionChangeFontFamily,
actionChangeTextAlign, actionChangeTextAlign,
actionChangeVerticalAlign, actionChangeVerticalAlign,
actionChangeArrowProperties,
} from "./actionProperties"; } from "./actionProperties";
export { export {
@@ -43,11 +44,7 @@ export {
} from "./actionExport"; } from "./actionExport";
export { actionCopyStyles, actionPasteStyles } from "./actionStyles"; export { actionCopyStyles, actionPasteStyles } from "./actionStyles";
export { export { actionShortcuts } from "./actionMenu";
actionToggleCanvasMenu,
actionToggleEditMenu,
actionShortcuts,
} from "./actionMenu";
export { actionGroup, actionUngroup } from "./actionGroup"; export { actionGroup, actionUngroup } from "./actionGroup";
+3 -1
View File
@@ -37,7 +37,9 @@ const trackAction = (
trackEvent( trackEvent(
action.trackEvent.category, action.trackEvent.category,
action.trackEvent.action || action.name, action.trackEvent.action || action.name,
`${source} (${app.device.editor.isMobile ? "mobile" : "desktop"})`, `${source} (${
app.editorInterface.formFactor === "phone" ? "mobile" : "desktop"
})`,
); );
} }
} }
+2 -1
View File
@@ -1,8 +1,9 @@
import { isDarwin, getShortcutKey } from "@excalidraw/common"; import { isDarwin } from "@excalidraw/common";
import type { SubtypeOf } from "@excalidraw/common/utility-types"; import type { SubtypeOf } from "@excalidraw/common/utility-types";
import { t } from "../i18n"; import { t } from "../i18n";
import { getShortcutKey } from "../shortcut";
import type { ActionName } from "./types"; import type { ActionName } from "./types";
+1 -2
View File
@@ -69,10 +69,9 @@ export type ActionName =
| "changeStrokeStyle" | "changeStrokeStyle"
| "changeArrowhead" | "changeArrowhead"
| "changeArrowType" | "changeArrowType"
| "changeArrowProperties"
| "changeOpacity" | "changeOpacity"
| "changeFontSize" | "changeFontSize"
| "toggleCanvasMenu"
| "toggleEditMenu"
| "undo" | "undo"
| "redo" | "redo"
| "finalize" | "finalize"
+5
View File
@@ -55,6 +55,10 @@ export const getDefaultAppState = (): Omit<
fromSelection: false, fromSelection: false,
lastActiveTool: null, lastActiveTool: null,
}, },
preferredSelectionTool: {
type: "selection",
initialized: false,
},
penMode: false, penMode: false,
penDetected: false, penDetected: false,
errorMessage: null, errorMessage: null,
@@ -175,6 +179,7 @@ const APP_STATE_STORAGE_CONF = (<
editingTextElement: { browser: false, export: false, server: false }, editingTextElement: { browser: false, export: false, server: false },
editingGroupId: { browser: true, export: false, server: false }, editingGroupId: { browser: true, export: false, server: false },
activeTool: { browser: true, export: false, server: false }, activeTool: { browser: true, export: false, server: false },
preferredSelectionTool: { browser: true, export: false, server: false },
penMode: { browser: true, export: false, server: false }, penMode: { browser: true, export: false, server: false },
penDetected: { browser: true, export: false, server: false }, penDetected: { browser: true, export: false, server: false },
errorMessage: { browser: false, export: false, server: false }, errorMessage: { browser: false, export: false, server: false },
+87 -62
View File
@@ -1,6 +1,7 @@
import { import {
createPasteEvent, createPasteEvent,
parseClipboard, parseClipboard,
parseDataTransferEvent,
serializeAsClipboardJSON, serializeAsClipboardJSON,
} from "./clipboard"; } from "./clipboard";
import { API } from "./tests/helpers/api"; import { API } from "./tests/helpers/api";
@@ -13,7 +14,9 @@ describe("parseClipboard()", () => {
text = "123"; text = "123";
clipboardData = await parseClipboard( clipboardData = await parseClipboard(
createPasteEvent({ types: { "text/plain": text } }), await parseDataTransferEvent(
createPasteEvent({ types: { "text/plain": text } }),
),
); );
expect(clipboardData.text).toBe(text); expect(clipboardData.text).toBe(text);
@@ -21,7 +24,9 @@ describe("parseClipboard()", () => {
text = "[123]"; text = "[123]";
clipboardData = await parseClipboard( clipboardData = await parseClipboard(
createPasteEvent({ types: { "text/plain": text } }), await parseDataTransferEvent(
createPasteEvent({ types: { "text/plain": text } }),
),
); );
expect(clipboardData.text).toBe(text); expect(clipboardData.text).toBe(text);
@@ -29,7 +34,9 @@ describe("parseClipboard()", () => {
text = JSON.stringify({ val: 42 }); text = JSON.stringify({ val: 42 });
clipboardData = await parseClipboard( clipboardData = await parseClipboard(
createPasteEvent({ types: { "text/plain": text } }), await parseDataTransferEvent(
createPasteEvent({ types: { "text/plain": text } }),
),
); );
expect(clipboardData.text).toBe(text); expect(clipboardData.text).toBe(text);
}); });
@@ -39,11 +46,13 @@ describe("parseClipboard()", () => {
const json = serializeAsClipboardJSON({ elements: [rect], files: null }); const json = serializeAsClipboardJSON({ elements: [rect], files: null });
const clipboardData = await parseClipboard( const clipboardData = await parseClipboard(
createPasteEvent({ await parseDataTransferEvent(
types: { createPasteEvent({
"text/plain": json, types: {
}, "text/plain": json,
}), },
}),
),
); );
expect(clipboardData.elements).toEqual([rect]); expect(clipboardData.elements).toEqual([rect]);
}); });
@@ -56,21 +65,25 @@ describe("parseClipboard()", () => {
// ------------------------------------------------------------------------- // -------------------------------------------------------------------------
json = serializeAsClipboardJSON({ elements: [rect], files: null }); json = serializeAsClipboardJSON({ elements: [rect], files: null });
clipboardData = await parseClipboard( clipboardData = await parseClipboard(
createPasteEvent({ await parseDataTransferEvent(
types: { createPasteEvent({
"text/html": json, types: {
}, "text/html": json,
}), },
}),
),
); );
expect(clipboardData.elements).toEqual([rect]); expect(clipboardData.elements).toEqual([rect]);
// ------------------------------------------------------------------------- // -------------------------------------------------------------------------
json = serializeAsClipboardJSON({ elements: [rect], files: null }); json = serializeAsClipboardJSON({ elements: [rect], files: null });
clipboardData = await parseClipboard( clipboardData = await parseClipboard(
createPasteEvent({ await parseDataTransferEvent(
types: { createPasteEvent({
"text/html": `<div> ${json}</div>`, types: {
}, "text/html": `<div> ${json}</div>`,
}), },
}),
),
); );
expect(clipboardData.elements).toEqual([rect]); expect(clipboardData.elements).toEqual([rect]);
// ------------------------------------------------------------------------- // -------------------------------------------------------------------------
@@ -80,11 +93,13 @@ describe("parseClipboard()", () => {
let clipboardData; let clipboardData;
// ------------------------------------------------------------------------- // -------------------------------------------------------------------------
clipboardData = await parseClipboard( clipboardData = await parseClipboard(
createPasteEvent({ await parseDataTransferEvent(
types: { createPasteEvent({
"text/html": `<img src="https://example.com/image.png" />`, types: {
}, "text/html": `<img src="https://example.com/image.png" />`,
}), },
}),
),
); );
expect(clipboardData.mixedContent).toEqual([ expect(clipboardData.mixedContent).toEqual([
{ {
@@ -94,11 +109,13 @@ describe("parseClipboard()", () => {
]); ]);
// ------------------------------------------------------------------------- // -------------------------------------------------------------------------
clipboardData = await parseClipboard( clipboardData = await parseClipboard(
createPasteEvent({ await parseDataTransferEvent(
types: { createPasteEvent({
"text/html": `<div><img src="https://example.com/image.png" /></div><a><img src="https://example.com/image2.png" /></a>`, types: {
}, "text/html": `<div><img src="https://example.com/image.png" /></div><a><img src="https://example.com/image2.png" /></a>`,
}), },
}),
),
); );
expect(clipboardData.mixedContent).toEqual([ expect(clipboardData.mixedContent).toEqual([
{ {
@@ -114,11 +131,13 @@ describe("parseClipboard()", () => {
it("should parse text content alongside <image> `src` urls out of text/html", async () => { it("should parse text content alongside <image> `src` urls out of text/html", async () => {
const clipboardData = await parseClipboard( const clipboardData = await parseClipboard(
createPasteEvent({ await parseDataTransferEvent(
types: { createPasteEvent({
"text/html": `<a href="https://example.com">hello </a><div><img src="https://example.com/image.png" /></div><b>my friend!</b>`, types: {
}, "text/html": `<a href="https://example.com">hello </a><div><img src="https://example.com/image.png" /></div><b>my friend!</b>`,
}), },
}),
),
); );
expect(clipboardData.mixedContent).toEqual([ expect(clipboardData.mixedContent).toEqual([
{ {
@@ -141,14 +160,16 @@ describe("parseClipboard()", () => {
let clipboardData; let clipboardData;
// ------------------------------------------------------------------------- // -------------------------------------------------------------------------
clipboardData = await parseClipboard( clipboardData = await parseClipboard(
createPasteEvent({ await parseDataTransferEvent(
types: { createPasteEvent({
"text/plain": `a b types: {
1 2 "text/plain": `a b
4 5 1 2
7 10`, 4 5
}, 7 10`,
}), },
}),
),
); );
expect(clipboardData.spreadsheet).toEqual({ expect(clipboardData.spreadsheet).toEqual({
title: "b", title: "b",
@@ -157,14 +178,16 @@ describe("parseClipboard()", () => {
}); });
// ------------------------------------------------------------------------- // -------------------------------------------------------------------------
clipboardData = await parseClipboard( clipboardData = await parseClipboard(
createPasteEvent({ await parseDataTransferEvent(
types: { createPasteEvent({
"text/html": `a b types: {
1 2 "text/html": `a b
4 5 1 2
7 10`, 4 5
}, 7 10`,
}), },
}),
),
); );
expect(clipboardData.spreadsheet).toEqual({ expect(clipboardData.spreadsheet).toEqual({
title: "b", title: "b",
@@ -173,19 +196,21 @@ describe("parseClipboard()", () => {
}); });
// ------------------------------------------------------------------------- // -------------------------------------------------------------------------
clipboardData = await parseClipboard( clipboardData = await parseClipboard(
createPasteEvent({ await parseDataTransferEvent(
types: { createPasteEvent({
"text/html": `<html> types: {
<body> "text/html": `<html>
<!--StartFragment--><google-sheets-html-origin><style type="text/css"><!--td {border: 1px solid #cccccc;}br {mso-data-placement:same-cell;}--></style><table xmlns="http://www.w3.org/1999/xhtml" cellspacing="0" cellpadding="0" dir="ltr" border="1" style="table-layout:fixed;font-size:10pt;font-family:Arial;width:0px;border-collapse:collapse;border:none"><colgroup><col width="100"/><col width="100"/></colgroup><tbody><tr style="height:21px;"><td style="overflow:hidden;padding:2px 3px 2px 3px;vertical-align:bottom;" data-sheets-value="{&quot;1&quot;:2,&quot;2&quot;:&quot;a&quot;}">a</td><td style="overflow:hidden;padding:2px 3px 2px 3px;vertical-align:bottom;" data-sheets-value="{&quot;1&quot;:2,&quot;2&quot;:&quot;b&quot;}">b</td></tr><tr style="height:21px;"><td style="overflow:hidden;padding:2px 3px 2px 3px;vertical-align:bottom;text-align:right;" data-sheets-value="{&quot;1&quot;:3,&quot;3&quot;:1}">1</td><td style="overflow:hidden;padding:2px 3px 2px 3px;vertical-align:bottom;text-align:right;" data-sheets-value="{&quot;1&quot;:3,&quot;3&quot;:2}">2</td></tr><tr style="height:21px;"><td style="overflow:hidden;padding:2px 3px 2px 3px;vertical-align:bottom;text-align:right;" data-sheets-value="{&quot;1&quot;:3,&quot;3&quot;:4}">4</td><td style="overflow:hidden;padding:2px 3px 2px 3px;vertical-align:bottom;text-align:right;" data-sheets-value="{&quot;1&quot;:3,&quot;3&quot;:5}">5</td></tr><tr style="height:21px;"><td style="overflow:hidden;padding:2px 3px 2px 3px;vertical-align:bottom;text-align:right;" data-sheets-value="{&quot;1&quot;:3,&quot;3&quot;:7}">7</td><td style="overflow:hidden;padding:2px 3px 2px 3px;vertical-align:bottom;text-align:right;" data-sheets-value="{&quot;1&quot;:3,&quot;3&quot;:10}">10</td></tr></tbody></table><!--EndFragment--> <body>
</body> <!--StartFragment--><google-sheets-html-origin><style type="text/css"><!--td {border: 1px solid #cccccc;}br {mso-data-placement:same-cell;}--></style><table xmlns="http://www.w3.org/1999/xhtml" cellspacing="0" cellpadding="0" dir="ltr" border="1" style="table-layout:fixed;font-size:10pt;font-family:Arial;width:0px;border-collapse:collapse;border:none"><colgroup><col width="100"/><col width="100"/></colgroup><tbody><tr style="height:21px;"><td style="overflow:hidden;padding:2px 3px 2px 3px;vertical-align:bottom;" data-sheets-value="{&quot;1&quot;:2,&quot;2&quot;:&quot;a&quot;}">a</td><td style="overflow:hidden;padding:2px 3px 2px 3px;vertical-align:bottom;" data-sheets-value="{&quot;1&quot;:2,&quot;2&quot;:&quot;b&quot;}">b</td></tr><tr style="height:21px;"><td style="overflow:hidden;padding:2px 3px 2px 3px;vertical-align:bottom;text-align:right;" data-sheets-value="{&quot;1&quot;:3,&quot;3&quot;:1}">1</td><td style="overflow:hidden;padding:2px 3px 2px 3px;vertical-align:bottom;text-align:right;" data-sheets-value="{&quot;1&quot;:3,&quot;3&quot;:2}">2</td></tr><tr style="height:21px;"><td style="overflow:hidden;padding:2px 3px 2px 3px;vertical-align:bottom;text-align:right;" data-sheets-value="{&quot;1&quot;:3,&quot;3&quot;:4}">4</td><td style="overflow:hidden;padding:2px 3px 2px 3px;vertical-align:bottom;text-align:right;" data-sheets-value="{&quot;1&quot;:3,&quot;3&quot;:5}">5</td></tr><tr style="height:21px;"><td style="overflow:hidden;padding:2px 3px 2px 3px;vertical-align:bottom;text-align:right;" data-sheets-value="{&quot;1&quot;:3,&quot;3&quot;:7}">7</td><td style="overflow:hidden;padding:2px 3px 2px 3px;vertical-align:bottom;text-align:right;" data-sheets-value="{&quot;1&quot;:3,&quot;3&quot;:10}">10</td></tr></tbody></table><!--EndFragment-->
</html>`, </body>
"text/plain": `a b </html>`,
1 2 "text/plain": `a b
4 5 1 2
7 10`, 4 5
}, 7 10`,
}), },
}),
),
); );
expect(clipboardData.spreadsheet).toEqual({ expect(clipboardData.spreadsheet).toEqual({
title: "b", title: "b",
+174 -18
View File
@@ -5,6 +5,7 @@ import {
arrayToMap, arrayToMap,
isMemberOf, isMemberOf,
isPromiseLike, isPromiseLike,
EVENT,
} from "@excalidraw/common"; } from "@excalidraw/common";
import { mutateElement } from "@excalidraw/element"; import { mutateElement } from "@excalidraw/element";
@@ -16,15 +17,26 @@ import {
import { getContainingFrame } from "@excalidraw/element"; import { getContainingFrame } from "@excalidraw/element";
import type { ValueOf } from "@excalidraw/common/utility-types";
import type { IMAGE_MIME_TYPES, STRING_MIME_TYPES } from "@excalidraw/common";
import type { import type {
ExcalidrawElement, ExcalidrawElement,
NonDeletedExcalidrawElement, NonDeletedExcalidrawElement,
} from "@excalidraw/element/types"; } from "@excalidraw/element/types";
import { ExcalidrawError } from "./errors"; import { ExcalidrawError } from "./errors";
import { createFile, isSupportedImageFileType } from "./data/blob"; import {
createFile,
getFileHandle,
isSupportedImageFileType,
normalizeFile,
} from "./data/blob";
import { tryParseSpreadsheet, VALID_SPREADSHEET } from "./charts"; import { tryParseSpreadsheet, VALID_SPREADSHEET } from "./charts";
import type { FileSystemHandle } from "./data/filesystem";
import type { Spreadsheet } from "./charts"; import type { Spreadsheet } from "./charts";
import type { BinaryFiles } from "./types"; import type { BinaryFiles } from "./types";
@@ -92,7 +104,7 @@ export const createPasteEvent = ({
console.warn("createPasteEvent: no types or files provided"); console.warn("createPasteEvent: no types or files provided");
} }
const event = new ClipboardEvent("paste", { const event = new ClipboardEvent(EVENT.PASTE, {
clipboardData: new DataTransfer(), clipboardData: new DataTransfer(),
}); });
@@ -101,10 +113,11 @@ export const createPasteEvent = ({
if (typeof value !== "string") { if (typeof value !== "string") {
files = files || []; files = files || [];
files.push(value); files.push(value);
event.clipboardData?.items.add(value);
continue; continue;
} }
try { try {
event.clipboardData?.setData(type, value); event.clipboardData?.items.add(value, type);
if (event.clipboardData?.getData(type) !== value) { if (event.clipboardData?.getData(type) !== value) {
throw new Error(`Failed to set "${type}" as clipboardData item`); throw new Error(`Failed to set "${type}" as clipboardData item`);
} }
@@ -229,14 +242,10 @@ function parseHTMLTree(el: ChildNode) {
return result; return result;
} }
const maybeParseHTMLPaste = ( const maybeParseHTMLDataItem = (
event: ClipboardEvent, dataItem: ParsedDataTransferItemType<typeof MIME_TYPES["html"]>,
): { type: "mixedContent"; value: PastedMixedContent } | null => { ): { type: "mixedContent"; value: PastedMixedContent } | null => {
const html = event.clipboardData?.getData(MIME_TYPES.html); const html = dataItem.value;
if (!html) {
return null;
}
try { try {
const doc = new DOMParser().parseFromString(html, MIME_TYPES.html); const doc = new DOMParser().parseFromString(html, MIME_TYPES.html);
@@ -332,18 +341,21 @@ export const readSystemClipboard = async () => {
* Parses "paste" ClipboardEvent. * Parses "paste" ClipboardEvent.
*/ */
const parseClipboardEventTextData = async ( const parseClipboardEventTextData = async (
event: ClipboardEvent, dataList: ParsedDataTranferList,
isPlainPaste = false, isPlainPaste = false,
): Promise<ParsedClipboardEventTextData> => { ): Promise<ParsedClipboardEventTextData> => {
try { try {
const mixedContent = !isPlainPaste && event && maybeParseHTMLPaste(event); const htmlItem = dataList.findByType(MIME_TYPES.html);
const mixedContent =
!isPlainPaste && htmlItem && maybeParseHTMLDataItem(htmlItem);
if (mixedContent) { if (mixedContent) {
if (mixedContent.value.every((item) => item.type === "text")) { if (mixedContent.value.every((item) => item.type === "text")) {
return { return {
type: "text", type: "text",
value: value:
event.clipboardData?.getData(MIME_TYPES.text) || dataList.getData(MIME_TYPES.text) ??
mixedContent.value mixedContent.value
.map((item) => item.value) .map((item) => item.value)
.join("\n") .join("\n")
@@ -354,23 +366,156 @@ const parseClipboardEventTextData = async (
return mixedContent; return mixedContent;
} }
const text = event.clipboardData?.getData(MIME_TYPES.text); return {
type: "text",
return { type: "text", value: (text || "").trim() }; value: (dataList.getData(MIME_TYPES.text) || "").trim(),
};
} catch { } catch {
return { type: "text", value: "" }; return { type: "text", value: "" };
} }
}; };
type AllowedParsedDataTransferItem =
| {
type: ValueOf<typeof IMAGE_MIME_TYPES>;
kind: "file";
file: File;
fileHandle: FileSystemHandle | null;
}
| { type: ValueOf<typeof STRING_MIME_TYPES>; kind: "string"; value: string };
type ParsedDataTransferItem =
| {
type: string;
kind: "file";
file: File;
fileHandle: FileSystemHandle | null;
}
| { type: string; kind: "string"; value: string };
type ParsedDataTransferItemType<
T extends AllowedParsedDataTransferItem["type"],
> = AllowedParsedDataTransferItem & { type: T };
export type ParsedDataTransferFile = Extract<
AllowedParsedDataTransferItem,
{ kind: "file" }
>;
type ParsedDataTranferList = ParsedDataTransferItem[] & {
/**
* Only allows filtering by known `string` data types, since `file`
* types can have multiple items of the same type (e.g. multiple image files)
* unlike `string` data transfer items.
*/
findByType: typeof findDataTransferItemType;
/**
* Only allows filtering by known `string` data types, since `file`
* types can have multiple items of the same type (e.g. multiple image files)
* unlike `string` data transfer items.
*/
getData: typeof getDataTransferItemData;
getFiles: typeof getDataTransferFiles;
};
const findDataTransferItemType = function <
T extends ValueOf<typeof STRING_MIME_TYPES>,
>(this: ParsedDataTranferList, type: T): ParsedDataTransferItemType<T> | null {
return (
this.find(
(item): item is ParsedDataTransferItemType<T> => item.type === type,
) || null
);
};
const getDataTransferItemData = function <
T extends ValueOf<typeof STRING_MIME_TYPES>,
>(
this: ParsedDataTranferList,
type: T,
):
| ParsedDataTransferItemType<ValueOf<typeof STRING_MIME_TYPES>>["value"]
| null {
const item = this.find(
(
item,
): item is ParsedDataTransferItemType<ValueOf<typeof STRING_MIME_TYPES>> =>
item.type === type,
);
return item?.value ?? null;
};
const getDataTransferFiles = function (
this: ParsedDataTranferList,
): ParsedDataTransferFile[] {
return this.filter(
(item): item is ParsedDataTransferFile => item.kind === "file",
);
};
export const parseDataTransferEvent = async (
event: ClipboardEvent | DragEvent | React.DragEvent<HTMLDivElement>,
): Promise<ParsedDataTranferList> => {
let items: DataTransferItemList | undefined = undefined;
if (isClipboardEvent(event)) {
items = event.clipboardData?.items;
} else {
const dragEvent = event;
items = dragEvent.dataTransfer?.items;
}
const dataItems = (
await Promise.all(
Array.from(items || []).map(
async (item): Promise<ParsedDataTransferItem | null> => {
if (item.kind === "file") {
let file = item.getAsFile();
if (file) {
const fileHandle = await getFileHandle(item);
file = await normalizeFile(file);
return {
type: file.type,
kind: "file",
file,
fileHandle,
};
}
} else if (item.kind === "string") {
const { type } = item;
let value: string;
if ("clipboardData" in event && event.clipboardData) {
value = event.clipboardData?.getData(type);
} else {
value = await new Promise<string>((resolve) => {
item.getAsString((str) => resolve(str));
});
}
return { type, kind: "string", value };
}
return null;
},
),
)
).filter((data): data is ParsedDataTransferItem => data != null);
return Object.assign(dataItems, {
findByType: findDataTransferItemType,
getData: getDataTransferItemData,
getFiles: getDataTransferFiles,
});
};
/** /**
* Attempts to parse clipboard event. * Attempts to parse clipboard event.
*/ */
export const parseClipboard = async ( export const parseClipboard = async (
event: ClipboardEvent, dataList: ParsedDataTranferList,
isPlainPaste = false, isPlainPaste = false,
): Promise<ClipboardData> => { ): Promise<ClipboardData> => {
const parsedEventData = await parseClipboardEventTextData( const parsedEventData = await parseClipboardEventTextData(
event, dataList,
isPlainPaste, isPlainPaste,
); );
@@ -519,3 +664,14 @@ const copyTextViaExecCommand = (text: string | null) => {
return success; return success;
}; };
export const isClipboardEvent = (
event: React.SyntheticEvent | Event,
): event is ClipboardEvent => {
/** not using instanceof ClipboardEvent due to tests (jsdom) */
return (
event.type === EVENT.PASTE ||
event.type === EVENT.COPY ||
event.type === EVENT.CUT
);
};
+115
View File
@@ -91,3 +91,118 @@
} }
} }
} }
.compact-shape-actions {
display: flex;
flex-direction: column;
gap: 0.5rem;
max-height: calc(100vh - 200px);
overflow-y: auto;
padding: 0.5rem;
.compact-action-item {
position: relative;
display: flex;
justify-content: center;
align-items: center;
min-height: 2.5rem;
pointer-events: auto;
--default-button-size: 2rem;
.compact-action-button {
width: var(--mobile-action-button-size);
height: var(--mobile-action-button-size);
border: none;
border-radius: var(--border-radius-lg);
color: var(--color-on-surface);
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
transition: all 0.2s ease;
background: var(--mobile-action-button-bg);
svg {
width: 1rem;
height: 1rem;
flex: 0 0 auto;
}
&.active {
background: var(
--color-surface-primary-container,
var(--mobile-action-button-bg)
);
}
}
.compact-popover-content {
.popover-section {
margin-bottom: 1rem;
&:last-child {
margin-bottom: 0;
}
.popover-section-title {
font-size: 0.75rem;
font-weight: 600;
color: var(--color-text-secondary);
margin-bottom: 0.5rem;
text-transform: uppercase;
letter-spacing: 0.5px;
}
.buttonList {
display: flex;
flex-wrap: wrap;
gap: 0.25rem;
}
}
}
}
.ToolIcon {
.ToolIcon__icon {
width: var(--mobile-action-button-size);
height: var(--mobile-action-button-size);
background: var(--mobile-action-button-bg);
&:hover {
background-color: transparent;
}
}
}
}
.compact-shape-actions-island {
width: fit-content;
overflow-x: hidden;
}
.mobile-shape-actions {
z-index: 999;
display: flex;
flex-direction: row;
justify-content: space-between;
width: 100%;
background: transparent;
border-radius: var(--border-radius-lg);
box-shadow: none;
overflow: none;
scrollbar-width: none;
-ms-overflow-style: none;
}
.shape-actions-theme-scope {
--button-border: transparent;
--button-bg: var(--color-surface-mid);
}
:root.theme--dark .shape-actions-theme-scope {
--button-hover-bg: #363541;
--button-bg: var(--color-surface-high);
}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -1,11 +1,12 @@
import clsx from "clsx"; import clsx from "clsx";
import { useCallback, useEffect, useRef, useState } from "react"; import { useCallback, useEffect, useRef, useState } from "react";
import { KEYS, getShortcutKey } from "@excalidraw/common"; import { KEYS } from "@excalidraw/common";
import { getShortcutKey } from "../..//shortcut";
import { useAtom } from "../../editor-jotai"; import { useAtom } from "../../editor-jotai";
import { t } from "../../i18n"; import { t } from "../../i18n";
import { useDevice } from "../App"; import { useEditorInterface } from "../App";
import { activeEyeDropperAtom } from "../EyeDropper"; import { activeEyeDropperAtom } from "../EyeDropper";
import { eyeDropperIcon } from "../icons"; import { eyeDropperIcon } from "../icons";
@@ -29,7 +30,7 @@ export const ColorInput = ({
colorPickerType, colorPickerType,
placeholder, placeholder,
}: ColorInputProps) => { }: ColorInputProps) => {
const device = useDevice(); const editorInterface = useEditorInterface();
const [innerValue, setInnerValue] = useState(color); const [innerValue, setInnerValue] = useState(color);
const [activeSection, setActiveColorPickerSection] = useAtom( const [activeSection, setActiveColorPickerSection] = useAtom(
activeColorPickerSectionAtom, activeColorPickerSectionAtom,
@@ -98,7 +99,7 @@ export const ColorInput = ({
placeholder={placeholder} placeholder={placeholder}
/> />
{/* TODO reenable on mobile with a better UX */} {/* TODO reenable on mobile with a better UX */}
{!device.editor.isMobile && ( {editorInterface.formFactor !== "phone" && (
<> <>
<div <div
style={{ style={{
@@ -7,6 +7,12 @@
} }
} }
.color-picker__title {
padding: 0 0.5rem;
font-size: 0.875rem;
text-align: left;
}
.color-picker__heading { .color-picker__heading {
padding: 0 0.5rem; padding: 0 0.5rem;
font-size: 0.75rem; font-size: 0.75rem;
@@ -22,6 +28,12 @@
@include isMobile { @include isMobile {
max-width: 11rem; max-width: 11rem;
} }
&.color-picker-container--no-top-picks {
display: flex;
justify-content: center;
grid-template-columns: unset;
}
} }
.color-picker__top-picks { .color-picker__top-picks {
@@ -80,6 +92,16 @@
} }
} }
.color-picker__button-background {
display: flex;
align-items: center;
justify-content: center;
svg {
width: 100%;
height: 100%;
}
}
&.active { &.active {
.color-picker__button-outline { .color-picker__button-outline {
position: absolute; position: absolute;
@@ -141,6 +163,15 @@
width: 1.625rem; width: 1.625rem;
height: 1.625rem; height: 1.625rem;
} }
&.compact-sizing {
width: var(--mobile-action-button-size);
height: var(--mobile-action-button-size);
}
&.mobile-border {
border: 1px solid var(--mobile-color-border);
}
} }
.color-picker__button__hotkey-label { .color-picker__button__hotkey-label {
@@ -1,11 +1,12 @@
import * as Popover from "@radix-ui/react-popover"; import * as Popover from "@radix-ui/react-popover";
import clsx from "clsx"; import clsx from "clsx";
import { useRef } from "react"; import { useRef, useEffect } from "react";
import { import {
COLOR_OUTLINE_CONTRAST_THRESHOLD, COLOR_OUTLINE_CONTRAST_THRESHOLD,
COLOR_PALETTE, COLOR_PALETTE,
isTransparent, isTransparent,
isWritableElement,
} from "@excalidraw/common"; } from "@excalidraw/common";
import type { ColorTuple, ColorPaletteCustom } from "@excalidraw/common"; import type { ColorTuple, ColorPaletteCustom } from "@excalidraw/common";
@@ -14,11 +15,16 @@ import type { ExcalidrawElement } from "@excalidraw/element/types";
import { useAtom } from "../../editor-jotai"; import { useAtom } from "../../editor-jotai";
import { t } from "../../i18n"; import { t } from "../../i18n";
import { useExcalidrawContainer } from "../App"; import { useExcalidrawContainer, useStylesPanelMode } from "../App";
import { ButtonSeparator } from "../ButtonSeparator"; import { ButtonSeparator } from "../ButtonSeparator";
import { activeEyeDropperAtom } from "../EyeDropper"; import { activeEyeDropperAtom } from "../EyeDropper";
import { PropertiesPopover } from "../PropertiesPopover"; import { PropertiesPopover } from "../PropertiesPopover";
import { slashIcon } from "../icons"; import { slashIcon, strokeIcon } from "../icons";
import {
saveCaretPosition,
restoreCaretPosition,
temporarilyDisableTextEditorBlur,
} from "../../hooks/useTextEditorFocus";
import { ColorInput } from "./ColorInput"; import { ColorInput } from "./ColorInput";
import { Picker } from "./Picker"; import { Picker } from "./Picker";
@@ -77,6 +83,8 @@ const ColorPickerPopupContent = ({
elements, elements,
palette = COLOR_PALETTE, palette = COLOR_PALETTE,
updateData, updateData,
getOpenPopup,
appState,
}: Pick< }: Pick<
ColorPickerProps, ColorPickerProps,
| "type" | "type"
@@ -86,8 +94,14 @@ const ColorPickerPopupContent = ({
| "elements" | "elements"
| "palette" | "palette"
| "updateData" | "updateData"
>) => { | "appState"
> & {
getOpenPopup: () => AppState["openPopup"];
}) => {
const { container } = useExcalidrawContainer(); const { container } = useExcalidrawContainer();
const stylesPanelMode = useStylesPanelMode();
const isCompactMode = stylesPanelMode !== "full";
const isMobileMode = stylesPanelMode === "mobile";
const [, setActiveColorPickerSection] = useAtom(activeColorPickerSectionAtom); const [, setActiveColorPickerSection] = useAtom(activeColorPickerSectionAtom);
const [eyeDropperState, setEyeDropperState] = useAtom(activeEyeDropperAtom); const [eyeDropperState, setEyeDropperState] = useAtom(activeEyeDropperAtom);
@@ -117,9 +131,13 @@ const ColorPickerPopupContent = ({
<PropertiesPopover <PropertiesPopover
container={container} container={container}
style={{ maxWidth: "13rem" }} style={{ maxWidth: "13rem" }}
// Improve focus handling for text editing scenarios
preventAutoFocusOnTouch={!!appState.editingTextElement}
onFocusOutside={(event) => { onFocusOutside={(event) => {
// refocus due to eye dropper // refocus due to eye dropper
focusPickerContent(); if (!isWritableElement(event.target)) {
focusPickerContent();
}
event.preventDefault(); event.preventDefault();
}} }}
onPointerDownOutside={(event) => { onPointerDownOutside={(event) => {
@@ -131,8 +149,23 @@ const ColorPickerPopupContent = ({
} }
}} }}
onClose={() => { onClose={() => {
updateData({ openPopup: null }); // only clear if we're still the active popup (avoid racing with switch)
if (getOpenPopup() === type) {
updateData({ openPopup: null });
}
setActiveColorPickerSection(null); setActiveColorPickerSection(null);
// Refocus text editor when popover closes if we were editing text
if (appState.editingTextElement) {
setTimeout(() => {
const textEditor = document.querySelector(
".excalidraw-wysiwyg",
) as HTMLTextAreaElement;
if (textEditor) {
textEditor.focus();
}
}, 0);
}
}} }}
> >
{palette ? ( {palette ? (
@@ -141,7 +174,17 @@ const ColorPickerPopupContent = ({
palette={palette} palette={palette}
color={color} color={color}
onChange={(changedColor) => { onChange={(changedColor) => {
// Save caret position before color change if editing text
const savedSelection = appState.editingTextElement
? saveCaretPosition()
: null;
onChange(changedColor); onChange(changedColor);
// Restore caret position after color change if editing text
if (appState.editingTextElement && savedSelection) {
restoreCaretPosition(savedSelection);
}
}} }}
onEyeDropperToggle={(force) => { onEyeDropperToggle={(force) => {
setEyeDropperState((state) => { setEyeDropperState((state) => {
@@ -168,12 +211,15 @@ const ColorPickerPopupContent = ({
if (eyeDropperState) { if (eyeDropperState) {
setEyeDropperState(null); setEyeDropperState(null);
} else { } else {
// close explicitly on Escape
updateData({ openPopup: null }); updateData({ openPopup: null });
} }
}} }}
type={type} type={type}
elements={elements} elements={elements}
updateData={updateData} updateData={updateData}
showTitle={isCompactMode}
showHotKey={!isMobileMode}
> >
{colorInputJSX} {colorInputJSX}
</Picker> </Picker>
@@ -188,11 +234,33 @@ const ColorPickerTrigger = ({
label, label,
color, color,
type, type,
mode = "background",
onToggle,
editingTextElement,
}: { }: {
color: string | null; color: string | null;
label: string; label: string;
type: ColorPickerType; type: ColorPickerType;
mode?: "background" | "stroke";
onToggle: () => void;
editingTextElement?: boolean;
}) => { }) => {
const stylesPanelMode = useStylesPanelMode();
const isCompactMode = stylesPanelMode !== "full";
const isMobileMode = stylesPanelMode === "mobile";
const handleClick = (e: React.MouseEvent) => {
// use pointerdown so we run before outside-close logic
e.preventDefault();
e.stopPropagation();
// If editing text, temporarily disable the wysiwyg blur event
if (editingTextElement) {
temporarilyDisableTextEditorBlur();
}
onToggle();
};
return ( return (
<Popover.Trigger <Popover.Trigger
type="button" type="button"
@@ -200,6 +268,8 @@ const ColorPickerTrigger = ({
"is-transparent": !color || color === "transparent", "is-transparent": !color || color === "transparent",
"has-outline": "has-outline":
!color || !isColorDark(color, COLOR_OUTLINE_CONTRAST_THRESHOLD), !color || !isColorDark(color, COLOR_OUTLINE_CONTRAST_THRESHOLD),
"compact-sizing": isCompactMode,
"mobile-border": isMobileMode,
})} })}
aria-label={label} aria-label={label}
style={color ? { "--swatch-color": color } : undefined} style={color ? { "--swatch-color": color } : undefined}
@@ -208,8 +278,24 @@ const ColorPickerTrigger = ({
? t("labels.showStroke") ? t("labels.showStroke")
: t("labels.showBackground") : t("labels.showBackground")
} }
data-openpopup={type}
onClick={handleClick}
> >
<div className="color-picker__button-outline">{!color && slashIcon}</div> <div className="color-picker__button-outline">{!color && slashIcon}</div>
{isCompactMode && color && mode === "stroke" && (
<div className="color-picker__button-background">
<span
style={{
color:
color && isColorDark(color, COLOR_OUTLINE_CONTRAST_THRESHOLD)
? "#fff"
: "#111",
}}
>
{strokeIcon}
</span>
</div>
)}
</Popover.Trigger> </Popover.Trigger>
); );
}; };
@@ -225,24 +311,59 @@ export const ColorPicker = ({
updateData, updateData,
appState, appState,
}: ColorPickerProps) => { }: ColorPickerProps) => {
const openRef = useRef(appState.openPopup);
useEffect(() => {
openRef.current = appState.openPopup;
}, [appState.openPopup]);
const stylesPanelMode = useStylesPanelMode();
const isCompactMode = stylesPanelMode !== "full";
return ( return (
<div> <div>
<div role="dialog" aria-modal="true" className="color-picker-container"> <div
<TopPicks role="dialog"
activeColor={color} aria-modal="true"
onChange={onChange} className={clsx("color-picker-container", {
type={type} "color-picker-container--no-top-picks": isCompactMode,
topPicks={topPicks} })}
/> >
<ButtonSeparator /> {!isCompactMode && (
<TopPicks
activeColor={color}
onChange={onChange}
type={type}
topPicks={topPicks}
/>
)}
{!isCompactMode && <ButtonSeparator />}
<Popover.Root <Popover.Root
open={appState.openPopup === type} open={appState.openPopup === type}
onOpenChange={(open) => { onOpenChange={(open) => {
updateData({ openPopup: open ? type : null }); if (open) {
updateData({ openPopup: type });
}
}} }}
> >
{/* serves as an active color indicator as well */} {/* serves as an active color indicator as well */}
<ColorPickerTrigger color={color} label={label} type={type} /> <ColorPickerTrigger
color={color}
label={label}
type={type}
mode={type === "elementStroke" ? "stroke" : "background"}
editingTextElement={!!appState.editingTextElement}
onToggle={() => {
// atomic switch: if another popup is open, close it first, then open this one next tick
if (appState.openPopup === type) {
// toggle off on same trigger
updateData({ openPopup: null });
} else if (appState.openPopup) {
updateData({ openPopup: type });
} else {
// open this one
updateData({ openPopup: type });
}
}}
/>
{/* popup content */} {/* popup content */}
{appState.openPopup === type && ( {appState.openPopup === type && (
<ColorPickerPopupContent <ColorPickerPopupContent
@@ -253,6 +374,8 @@ export const ColorPicker = ({
elements={elements} elements={elements}
palette={palette} palette={palette}
updateData={updateData} updateData={updateData}
getOpenPopup={() => openRef.current}
appState={appState}
/> />
)} )}
</Popover.Root> </Popover.Root>
@@ -37,8 +37,10 @@ interface PickerProps {
palette: ColorPaletteCustom; palette: ColorPaletteCustom;
updateData: (formData?: any) => void; updateData: (formData?: any) => void;
children?: React.ReactNode; children?: React.ReactNode;
showTitle?: boolean;
onEyeDropperToggle: (force?: boolean) => void; onEyeDropperToggle: (force?: boolean) => void;
onEscape: (event: React.KeyboardEvent | KeyboardEvent) => void; onEscape: (event: React.KeyboardEvent | KeyboardEvent) => void;
showHotKey?: boolean;
} }
export const Picker = React.forwardRef( export const Picker = React.forwardRef(
@@ -51,11 +53,21 @@ export const Picker = React.forwardRef(
palette, palette,
updateData, updateData,
children, children,
showTitle,
onEyeDropperToggle, onEyeDropperToggle,
onEscape, onEscape,
showHotKey = true,
}: PickerProps, }: PickerProps,
ref, ref,
) => { ) => {
const title = showTitle
? type === "elementStroke"
? t("labels.stroke")
: type === "elementBackground"
? t("labels.background")
: null
: null;
const [customColors] = React.useState(() => { const [customColors] = React.useState(() => {
if (type === "canvasBackground") { if (type === "canvasBackground") {
return []; return [];
@@ -154,6 +166,8 @@ export const Picker = React.forwardRef(
// to allow focusing by clicking but not by tabbing // to allow focusing by clicking but not by tabbing
tabIndex={-1} tabIndex={-1}
> >
{title && <div className="color-picker__title">{title}</div>}
{!!customColors.length && ( {!!customColors.length && (
<div> <div>
<PickerHeading> <PickerHeading>
@@ -175,12 +189,18 @@ export const Picker = React.forwardRef(
palette={palette} palette={palette}
onChange={onChange} onChange={onChange}
activeShade={activeShade} activeShade={activeShade}
showHotKey={showHotKey}
/> />
</div> </div>
<div> <div>
<PickerHeading>{t("colorPicker.shades")}</PickerHeading> <PickerHeading>{t("colorPicker.shades")}</PickerHeading>
<ShadeList color={color} onChange={onChange} palette={palette} /> <ShadeList
color={color}
onChange={onChange}
palette={palette}
showHotKey={showHotKey}
/>
</div> </div>
{children} {children}
</div> </div>
@@ -20,6 +20,7 @@ interface PickerColorListProps {
color: string | null; color: string | null;
onChange: (color: string) => void; onChange: (color: string) => void;
activeShade: number; activeShade: number;
showHotKey?: boolean;
} }
const PickerColorList = ({ const PickerColorList = ({
@@ -27,6 +28,7 @@ const PickerColorList = ({
color, color,
onChange, onChange,
activeShade, activeShade,
showHotKey = true,
}: PickerColorListProps) => { }: PickerColorListProps) => {
const colorObj = getColorNameAndShadeFromColor({ const colorObj = getColorNameAndShadeFromColor({
color, color,
@@ -82,7 +84,7 @@ const PickerColorList = ({
key={key} key={key}
> >
<div className="color-picker__button-outline" /> <div className="color-picker__button-outline" />
<HotkeyLabel color={color} keyLabel={keybinding} /> {showHotKey && <HotkeyLabel color={color} keyLabel={keybinding} />}
</button> </button>
); );
})} })}
@@ -16,9 +16,15 @@ interface ShadeListProps {
color: string | null; color: string | null;
onChange: (color: string) => void; onChange: (color: string) => void;
palette: ColorPaletteCustom; palette: ColorPaletteCustom;
showHotKey?: boolean;
} }
export const ShadeList = ({ color, onChange, palette }: ShadeListProps) => { export const ShadeList = ({
color,
onChange,
palette,
showHotKey,
}: ShadeListProps) => {
const colorObj = getColorNameAndShadeFromColor({ const colorObj = getColorNameAndShadeFromColor({
color: color || "transparent", color: color || "transparent",
palette, palette,
@@ -67,7 +73,9 @@ export const ShadeList = ({ color, onChange, palette }: ShadeListProps) => {
}} }}
> >
<div className="color-picker__button-outline" /> <div className="color-picker__button-outline" />
<HotkeyLabel color={color} keyLabel={i + 1} isShade /> {showHotKey && (
<HotkeyLabel color={color} keyLabel={i + 1} isShade />
)}
</button> </button>
))} ))}
</div> </div>
@@ -100,6 +100,19 @@ $verticalBreakpoint: 861px;
border-radius: var(--border-radius-lg); border-radius: var(--border-radius-lg);
cursor: pointer; cursor: pointer;
--icon-size: 1rem;
&.command-item-large {
height: 2.75rem;
--icon-size: 1.5rem;
.icon {
width: var(--icon-size);
height: var(--icon-size);
margin-right: 0.625rem;
}
}
&:active { &:active {
background-color: var(--color-surface-low); background-color: var(--color-surface-low);
} }
@@ -130,9 +143,17 @@ $verticalBreakpoint: 861px;
} }
.icon { .icon {
width: 16px; width: var(--icon-size, 1rem);
height: 16px; height: var(--icon-size, 1rem);
margin-right: 6px; margin-right: 0.375rem;
.library-item-icon {
display: flex;
align-items: center;
justify-content: center;
height: 100%;
width: 100%;
}
} }
} }
} }
@@ -1,18 +1,19 @@
import clsx from "clsx"; import clsx from "clsx";
import fuzzy from "fuzzy"; import fuzzy from "fuzzy";
import { useEffect, useRef, useState } from "react"; import { useEffect, useRef, useMemo, useState } from "react";
import { import {
DEFAULT_SIDEBAR, DEFAULT_SIDEBAR,
EVENT, EVENT,
KEYS, KEYS,
capitalizeString, capitalizeString,
getShortcutKey,
isWritableElement, isWritableElement,
} from "@excalidraw/common"; } from "@excalidraw/common";
import { actionToggleShapeSwitch } from "@excalidraw/excalidraw/actions/actionToggleShapeSwitch"; import { actionToggleShapeSwitch } from "@excalidraw/excalidraw/actions/actionToggleShapeSwitch";
import { getShortcutKey } from "@excalidraw/excalidraw/shortcut";
import type { MarkRequired } from "@excalidraw/common/utility-types"; import type { MarkRequired } from "@excalidraw/common/utility-types";
import { import {
@@ -61,12 +62,21 @@ import { useStable } from "../../hooks/useStable";
import { Ellipsify } from "../Ellipsify"; import { Ellipsify } from "../Ellipsify";
import * as defaultItems from "./defaultCommandPaletteItems"; import {
distributeLibraryItemsOnSquareGrid,
libraryItemsAtom,
} from "../../data/library";
import {
useLibraryCache,
useLibraryItemSvg,
} from "../../hooks/useLibraryItemSvg";
import * as defaultItems from "./defaultCommandPaletteItems";
import "./CommandPalette.scss"; import "./CommandPalette.scss";
import type { CommandPaletteItem } from "./types"; import type { CommandPaletteItem } from "./types";
import type { AppProps, AppState, UIAppState } from "../../types"; import type { AppProps, AppState, LibraryItem, UIAppState } from "../../types";
import type { ShortcutName } from "../../actions/shortcuts"; import type { ShortcutName } from "../../actions/shortcuts";
import type { TranslationKeys } from "../../i18n"; import type { TranslationKeys } from "../../i18n";
import type { Action } from "../../actions/types"; import type { Action } from "../../actions/types";
@@ -80,6 +90,7 @@ export const DEFAULT_CATEGORIES = {
editor: "Editor", editor: "Editor",
elements: "Elements", elements: "Elements",
links: "Links", links: "Links",
library: "Library",
}; };
const getCategoryOrder = (category: string) => { const getCategoryOrder = (category: string) => {
@@ -207,6 +218,34 @@ function CommandPaletteInner({
appProps, appProps,
}); });
const [libraryItemsData] = useAtom(libraryItemsAtom);
const libraryCommands: CommandPaletteItem[] = useMemo(() => {
return (
libraryItemsData.libraryItems
?.filter(
(libraryItem): libraryItem is MarkRequired<LibraryItem, "name"> =>
!!libraryItem.name,
)
.map((libraryItem) => ({
label: libraryItem.name,
icon: (
<LibraryItemIcon
id={libraryItem.id}
elements={libraryItem.elements}
/>
),
category: "Library",
order: getCategoryOrder("Library"),
haystack: deburr(libraryItem.name),
perform: () => {
app.onInsertElements(
distributeLibraryItemsOnSquareGrid([libraryItem]),
);
},
})) || []
);
}, [app, libraryItemsData.libraryItems]);
useEffect(() => { useEffect(() => {
// these props change often and we don't want them to re-run the effect // these props change often and we don't want them to re-run the effect
// which would renew `allCommands`, cascading down and resetting state. // which would renew `allCommands`, cascading down and resetting state.
@@ -438,7 +477,6 @@ function CommandPaletteInner({
}, },
perform: () => { perform: () => {
setAppState((prevState) => ({ setAppState((prevState) => ({
openMenu: prevState.openMenu === "shape" ? null : "shape",
openPopup: "elementStroke", openPopup: "elementStroke",
})); }));
}, },
@@ -458,7 +496,6 @@ function CommandPaletteInner({
}, },
perform: () => { perform: () => {
setAppState((prevState) => ({ setAppState((prevState) => ({
openMenu: prevState.openMenu === "shape" ? null : "shape",
openPopup: "elementBackground", openPopup: "elementBackground",
})); }));
}, },
@@ -588,8 +625,9 @@ function CommandPaletteInner({
setAllCommands(allCommands); setAllCommands(allCommands);
setLastUsed( setLastUsed(
allCommands.find((command) => command.label === lastUsed?.label) ?? [...allCommands, ...libraryCommands].find(
null, (command) => command.label === lastUsed?.label,
) ?? null,
); );
} }
}, [ }, [
@@ -600,6 +638,7 @@ function CommandPaletteInner({
lastUsed?.label, lastUsed?.label,
setLastUsed, setLastUsed,
setAppState, setAppState,
libraryCommands,
]); ]);
const [commandSearch, setCommandSearch] = useState(""); const [commandSearch, setCommandSearch] = useState("");
@@ -796,9 +835,17 @@ function CommandPaletteInner({
return nextCommandsByCategory; return nextCommandsByCategory;
}; };
let matchingCommands = allCommands let matchingCommands =
.filter(isCommandAvailable) commandSearch?.length > 1
.sort((a, b) => a.order - b.order); ? [
...allCommands
.filter(isCommandAvailable)
.sort((a, b) => a.order - b.order),
...libraryCommands,
]
: allCommands
.filter(isCommandAvailable)
.sort((a, b) => a.order - b.order);
const showLastUsed = const showLastUsed =
!commandSearch && lastUsed && isCommandAvailable(lastUsed); !commandSearch && lastUsed && isCommandAvailable(lastUsed);
@@ -822,14 +869,20 @@ function CommandPaletteInner({
); );
matchingCommands = fuzzy matchingCommands = fuzzy
.filter(_query, matchingCommands, { .filter(_query, matchingCommands, {
extract: (command) => command.haystack, extract: (command) => command.haystack ?? "",
}) })
.sort((a, b) => b.score - a.score) .sort((a, b) => b.score - a.score)
.map((item) => item.original); .map((item) => item.original);
setCommandsByCategory(getNextCommandsByCategory(matchingCommands)); setCommandsByCategory(getNextCommandsByCategory(matchingCommands));
setCurrentCommand(matchingCommands[0] ?? null); setCurrentCommand(matchingCommands[0] ?? null);
}, [commandSearch, allCommands, isCommandAvailable, lastUsed]); }, [
commandSearch,
allCommands,
isCommandAvailable,
lastUsed,
libraryCommands,
]);
return ( return (
<Dialog <Dialog
@@ -850,7 +903,7 @@ function CommandPaletteInner({
ref={inputRef} ref={inputRef}
/> />
{!app.device.viewport.isMobile && ( {app.editorInterface.formFactor !== "phone" && (
<div className="shortcuts-wrapper"> <div className="shortcuts-wrapper">
<CommandShortcutHint shortcut="↑↓"> <CommandShortcutHint shortcut="↑↓">
{t("commandPalette.shortcuts.select")} {t("commandPalette.shortcuts.select")}
@@ -884,7 +937,7 @@ function CommandPaletteInner({
onClick={(event) => executeCommand(lastUsed, event)} onClick={(event) => executeCommand(lastUsed, event)}
disabled={!isCommandAvailable(lastUsed)} disabled={!isCommandAvailable(lastUsed)}
onMouseMove={() => setCurrentCommand(lastUsed)} onMouseMove={() => setCurrentCommand(lastUsed)}
showShortcut={!app.device.viewport.isMobile} showShortcut={app.editorInterface.formFactor !== "phone"}
appState={uiAppState} appState={uiAppState}
/> />
</div> </div>
@@ -902,8 +955,9 @@ function CommandPaletteInner({
isSelected={command.label === currentCommand?.label} isSelected={command.label === currentCommand?.label}
onClick={(event) => executeCommand(command, event)} onClick={(event) => executeCommand(command, event)}
onMouseMove={() => setCurrentCommand(command)} onMouseMove={() => setCurrentCommand(command)}
showShortcut={!app.device.viewport.isMobile} showShortcut={app.editorInterface.formFactor !== "phone"}
appState={uiAppState} appState={uiAppState}
size={category === "Library" ? "large" : "small"}
/> />
))} ))}
</div> </div>
@@ -919,6 +973,20 @@ function CommandPaletteInner({
</Dialog> </Dialog>
); );
} }
const LibraryItemIcon = ({
id,
elements,
}: {
id: LibraryItem["id"] | null;
elements: LibraryItem["elements"] | undefined;
}) => {
const ref = useRef<HTMLDivElement | null>(null);
const { svgCache } = useLibraryCache();
useLibraryItemSvg(id, elements, svgCache, ref);
return <div className="library-item-icon" ref={ref} />;
};
const CommandItem = ({ const CommandItem = ({
command, command,
@@ -928,6 +996,7 @@ const CommandItem = ({
onClick, onClick,
showShortcut, showShortcut,
appState, appState,
size = "small",
}: { }: {
command: CommandPaletteItem; command: CommandPaletteItem;
isSelected: boolean; isSelected: boolean;
@@ -936,6 +1005,7 @@ const CommandItem = ({
onClick: (event: React.MouseEvent) => void; onClick: (event: React.MouseEvent) => void;
showShortcut: boolean; showShortcut: boolean;
appState: UIAppState; appState: UIAppState;
size?: "small" | "large";
}) => { }) => {
const noop = () => {}; const noop = () => {};
@@ -944,6 +1014,7 @@ const CommandItem = ({
className={clsx("command-item", { className={clsx("command-item", {
"item-selected": isSelected, "item-selected": isSelected,
"item-disabled": disabled, "item-disabled": disabled,
"command-item-large": size === "large",
})} })}
ref={(ref) => { ref={(ref) => {
if (isSelected && !disabled) { if (isSelected && !disabled) {
@@ -959,6 +1030,8 @@ const CommandItem = ({
<div className="name"> <div className="name">
{command.icon && ( {command.icon && (
<InlineIcon <InlineIcon
className="icon"
size="var(--icon-size, 1rem)"
icon={ icon={
typeof command.icon === "function" typeof command.icon === "function"
? command.icon(appState) ? command.icon(appState)
@@ -1,6 +1,10 @@
@import "../css/variables.module.scss"; @import "../css/variables.module.scss";
.excalidraw { .excalidraw {
.context-menu-popover {
z-index: var(--zIndex-ui-context-menu);
}
.context-menu { .context-menu {
position: relative; position: relative;
border-radius: 4px; border-radius: 4px;
@@ -64,6 +64,7 @@ export const ContextMenu = React.memo(
offsetTop={appState.offsetTop} offsetTop={appState.offsetTop}
viewportWidth={appState.width} viewportWidth={appState.width}
viewportHeight={appState.height} viewportHeight={appState.height}
className="context-menu-popover"
> >
<ul <ul
className="context-menu" className="context-menu"
+2 -2
View File
@@ -9,7 +9,7 @@ import { t } from "../i18n";
import { import {
useExcalidrawContainer, useExcalidrawContainer,
useDevice, useEditorInterface,
useExcalidrawSetAppState, useExcalidrawSetAppState,
} from "./App"; } from "./App";
import { Island } from "./Island"; import { Island } from "./Island";
@@ -51,7 +51,7 @@ export const Dialog = (props: DialogProps) => {
const [islandNode, setIslandNode] = useCallbackRefState<HTMLDivElement>(); const [islandNode, setIslandNode] = useCallbackRefState<HTMLDivElement>();
const [lastActiveElement] = useState(document.activeElement); const [lastActiveElement] = useState(document.activeElement);
const { id } = useExcalidrawContainer(); const { id } = useExcalidrawContainer();
const isFullscreen = useDevice().viewport.isMobile; const isFullscreen = useEditorInterface().formFactor === "phone";
useEffect(() => { useEffect(() => {
if (!islandNode) { if (!islandNode) {
@@ -1,5 +1,8 @@
.excalidraw { .excalidraw {
.ExcalidrawLogo { .ExcalidrawLogo {
--logo-icon--mobile: 1rem;
--logo-text--mobile: 0.75rem;
--logo-icon--xs: 2rem; --logo-icon--xs: 2rem;
--logo-text--xs: 1.5rem; --logo-text--xs: 1.5rem;
@@ -30,6 +33,17 @@
color: var(--color-logo-text); color: var(--color-logo-text);
} }
&.is-mobile {
.ExcalidrawLogo-icon {
height: var(--logo-icon--mobile);
}
.ExcalidrawLogo-text {
height: var(--logo-text--mobile);
margin-left: 0.5rem;
}
}
&.is-xs { &.is-xs {
.ExcalidrawLogo-icon { .ExcalidrawLogo-icon {
height: var(--logo-icon--xs); height: var(--logo-icon--xs);
@@ -41,7 +41,7 @@ const LogoText = () => (
</svg> </svg>
); );
type LogoSize = "xs" | "small" | "normal" | "large" | "custom"; type LogoSize = "xs" | "small" | "normal" | "large" | "custom" | "mobile";
interface LogoProps { interface LogoProps {
size?: LogoSize; size?: LogoSize;
@@ -20,7 +20,7 @@ export type ButtonColor =
export type ButtonSize = "medium" | "large"; export type ButtonSize = "medium" | "large";
export type FilledButtonProps = { export type FilledButtonProps = {
label: string; label?: string;
children?: React.ReactNode; children?: React.ReactNode;
onClick?: (event: React.MouseEvent) => void; onClick?: (event: React.MouseEvent) => void;
@@ -11,5 +11,10 @@
2rem + 4 * var(--default-button-size) 2rem + 4 * var(--default-button-size)
); // 4 gaps + 4 buttons ); // 4 gaps + 4 buttons
} }
&--compact {
display: block;
grid-template-columns: none;
}
} }
} }
@@ -1,4 +1,5 @@
import * as Popover from "@radix-ui/react-popover"; import * as Popover from "@radix-ui/react-popover";
import clsx from "clsx";
import React, { useCallback, useMemo } from "react"; import React, { useCallback, useMemo } from "react";
import { FONT_FAMILY } from "@excalidraw/common"; import { FONT_FAMILY } from "@excalidraw/common";
@@ -58,6 +59,7 @@ interface FontPickerProps {
onHover: (fontFamily: FontFamilyValues) => void; onHover: (fontFamily: FontFamilyValues) => void;
onLeave: () => void; onLeave: () => void;
onPopupChange: (open: boolean) => void; onPopupChange: (open: boolean) => void;
compactMode?: boolean;
} }
export const FontPicker = React.memo( export const FontPicker = React.memo(
@@ -69,6 +71,7 @@ export const FontPicker = React.memo(
onHover, onHover,
onLeave, onLeave,
onPopupChange, onPopupChange,
compactMode = false,
}: FontPickerProps) => { }: FontPickerProps) => {
const defaultFonts = useMemo(() => DEFAULT_FONTS, []); const defaultFonts = useMemo(() => DEFAULT_FONTS, []);
const onSelectCallback = useCallback( const onSelectCallback = useCallback(
@@ -81,18 +84,30 @@ export const FontPicker = React.memo(
); );
return ( return (
<div role="dialog" aria-modal="true" className="FontPicker__container"> <div
<div className="buttonList"> role="dialog"
<RadioSelection<FontFamilyValues | false> aria-modal="true"
type="button" className={clsx("FontPicker__container", {
options={defaultFonts} "FontPicker__container--compact": compactMode,
value={selectedFontFamily} })}
onClick={onSelectCallback} >
/> {!compactMode && (
</div> <div className="buttonList">
<ButtonSeparator /> <RadioSelection<FontFamilyValues | false>
type="button"
options={defaultFonts}
value={selectedFontFamily}
onClick={onSelectCallback}
/>
</div>
)}
{!compactMode && <ButtonSeparator />}
<Popover.Root open={isOpened} onOpenChange={onPopupChange}> <Popover.Root open={isOpened} onOpenChange={onPopupChange}>
<FontPickerTrigger selectedFontFamily={selectedFontFamily} /> <FontPickerTrigger
selectedFontFamily={selectedFontFamily}
isOpened={isOpened}
compactMode={compactMode}
/>
{isOpened && ( {isOpened && (
<FontPickerList <FontPickerList
selectedFontFamily={selectedFontFamily} selectedFontFamily={selectedFontFamily}
@@ -20,7 +20,12 @@ import type { ValueOf } from "@excalidraw/common/utility-types";
import { Fonts } from "../../fonts"; import { Fonts } from "../../fonts";
import { t } from "../../i18n"; import { t } from "../../i18n";
import { useApp, useAppProps, useExcalidrawContainer } from "../App"; import {
useApp,
useAppProps,
useExcalidrawContainer,
useStylesPanelMode,
} from "../App";
import { PropertiesPopover } from "../PropertiesPopover"; import { PropertiesPopover } from "../PropertiesPopover";
import { QuickSearch } from "../QuickSearch"; import { QuickSearch } from "../QuickSearch";
import { ScrollableList } from "../ScrollableList"; import { ScrollableList } from "../ScrollableList";
@@ -90,8 +95,10 @@ export const FontPickerList = React.memo(
onClose, onClose,
}: FontPickerListProps) => { }: FontPickerListProps) => {
const { container } = useExcalidrawContainer(); const { container } = useExcalidrawContainer();
const { fonts } = useApp(); const app = useApp();
const { fonts } = app;
const { showDeprecatedFonts } = useAppProps(); const { showDeprecatedFonts } = useAppProps();
const stylesPanelMode = useStylesPanelMode();
const [searchTerm, setSearchTerm] = useState(""); const [searchTerm, setSearchTerm] = useState("");
const inputRef = useRef<HTMLInputElement>(null); const inputRef = useRef<HTMLInputElement>(null);
@@ -187,6 +194,42 @@ export const FontPickerList = React.memo(
onLeave, onLeave,
]); ]);
// Create a wrapped onSelect function that preserves caret position
const wrappedOnSelect = useCallback(
(fontFamily: FontFamilyValues) => {
// Save caret position before font selection if editing text
let savedSelection: { start: number; end: number } | null = null;
if (app.state.editingTextElement) {
const textEditor = document.querySelector(
".excalidraw-wysiwyg",
) as HTMLTextAreaElement;
if (textEditor) {
savedSelection = {
start: textEditor.selectionStart,
end: textEditor.selectionEnd,
};
}
}
onSelect(fontFamily);
// Restore caret position after font selection if editing text
if (app.state.editingTextElement && savedSelection) {
setTimeout(() => {
const textEditor = document.querySelector(
".excalidraw-wysiwyg",
) as HTMLTextAreaElement;
if (textEditor && savedSelection) {
textEditor.focus();
textEditor.selectionStart = savedSelection.start;
textEditor.selectionEnd = savedSelection.end;
}
}, 0);
}
},
[onSelect, app.state.editingTextElement],
);
const onKeyDown = useCallback<KeyboardEventHandler<HTMLDivElement>>( const onKeyDown = useCallback<KeyboardEventHandler<HTMLDivElement>>(
(event) => { (event) => {
const handled = fontPickerKeyHandler({ const handled = fontPickerKeyHandler({
@@ -194,7 +237,7 @@ export const FontPickerList = React.memo(
inputRef, inputRef,
hoveredFont, hoveredFont,
filteredFonts, filteredFonts,
onSelect, onSelect: wrappedOnSelect,
onHover, onHover,
onClose, onClose,
}); });
@@ -204,7 +247,7 @@ export const FontPickerList = React.memo(
event.stopPropagation(); event.stopPropagation();
} }
}, },
[hoveredFont, filteredFonts, onSelect, onHover, onClose], [hoveredFont, filteredFonts, wrappedOnSelect, onHover, onClose],
); );
useEffect(() => { useEffect(() => {
@@ -240,7 +283,7 @@ export const FontPickerList = React.memo(
// allow to tab between search and selected font // allow to tab between search and selected font
tabIndex={font.value === selectedFontFamily ? 0 : -1} tabIndex={font.value === selectedFontFamily ? 0 : -1}
onClick={(e) => { onClick={(e) => {
onSelect(Number(e.currentTarget.value)); wrappedOnSelect(Number(e.currentTarget.value));
}} }}
onMouseMove={() => { onMouseMove={() => {
if (hoveredFont?.value !== font.value) { if (hoveredFont?.value !== font.value) {
@@ -282,15 +325,32 @@ export const FontPickerList = React.memo(
className="properties-content" className="properties-content"
container={container} container={container}
style={{ width: "15rem" }} style={{ width: "15rem" }}
onClose={onClose} onClose={() => {
onClose();
// Refocus text editor when font picker closes if we were editing text
if (app.state.editingTextElement) {
setTimeout(() => {
const textEditor = document.querySelector(
".excalidraw-wysiwyg",
) as HTMLTextAreaElement;
if (textEditor) {
textEditor.focus();
}
}, 0);
}
}}
onPointerLeave={onLeave} onPointerLeave={onLeave}
onKeyDown={onKeyDown} onKeyDown={onKeyDown}
preventAutoFocusOnTouch={!!app.state.editingTextElement}
> >
<QuickSearch {stylesPanelMode === "full" && (
ref={inputRef} <QuickSearch
placeholder={t("quickSearch.placeholder")} ref={inputRef}
onChange={debounce(setSearchTerm, 20)} placeholder={t("quickSearch.placeholder")}
/> onChange={debounce(setSearchTerm, 20)}
/>
)}
<ScrollableList <ScrollableList
className="dropdown-menu fonts manual-hover" className="dropdown-menu fonts manual-hover"
placeholder={t("fontList.empty")} placeholder={t("fontList.empty")}
@@ -1,5 +1,6 @@
import * as Popover from "@radix-ui/react-popover"; import * as Popover from "@radix-ui/react-popover";
import { useMemo } from "react";
import { MOBILE_ACTION_BUTTON_BG } from "@excalidraw/common";
import type { FontFamilyValues } from "@excalidraw/element/types"; import type { FontFamilyValues } from "@excalidraw/element/types";
@@ -7,33 +8,49 @@ import { t } from "../../i18n";
import { ButtonIcon } from "../ButtonIcon"; import { ButtonIcon } from "../ButtonIcon";
import { TextIcon } from "../icons"; import { TextIcon } from "../icons";
import { isDefaultFont } from "./FontPicker"; import { useExcalidrawSetAppState } from "../App";
interface FontPickerTriggerProps { interface FontPickerTriggerProps {
selectedFontFamily: FontFamilyValues | null; selectedFontFamily: FontFamilyValues | null;
isOpened?: boolean;
compactMode?: boolean;
} }
export const FontPickerTrigger = ({ export const FontPickerTrigger = ({
selectedFontFamily, selectedFontFamily,
isOpened = false,
compactMode = false,
}: FontPickerTriggerProps) => { }: FontPickerTriggerProps) => {
const isTriggerActive = useMemo( const setAppState = useExcalidrawSetAppState();
() => Boolean(selectedFontFamily && !isDefaultFont(selectedFontFamily)),
[selectedFontFamily], const compactStyle = compactMode
); ? {
...MOBILE_ACTION_BUTTON_BG,
width: "2rem",
height: "2rem",
}
: {};
return ( return (
<Popover.Trigger asChild> <Popover.Trigger asChild>
{/* Empty div as trigger so it's stretched 100% due to different button sizes */} <div data-openpopup="fontFamily" className="properties-trigger">
<div>
<ButtonIcon <ButtonIcon
standalone standalone
icon={TextIcon} icon={TextIcon}
title={t("labels.showFonts")} title={t("labels.showFonts")}
className="properties-trigger" className="properties-trigger"
testId={"font-family-show-fonts"} testId={"font-family-show-fonts"}
active={isTriggerActive} active={isOpened}
// no-op onClick={() => {
onClick={() => {}} setAppState((appState) => ({
openPopup:
appState.openPopup === "fontFamily" ? null : appState.openPopup,
}));
}}
style={{
border: "none",
...compactStyle,
}}
/> />
</div> </div>
</Popover.Trigger> </Popover.Trigger>
@@ -18,7 +18,7 @@ type LockIconProps = {
export const HandButton = (props: LockIconProps) => { export const HandButton = (props: LockIconProps) => {
return ( return (
<ToolButton <ToolButton
className={clsx("Shape", { fillable: false })} className={clsx("Shape", { fillable: false, active: props.checked })}
type="radio" type="radio"
icon={handIcon} icon={handIcon}
name="editor-current-shape" name="editor-current-shape"
@@ -2,11 +2,12 @@ import React from "react";
import { isDarwin, isFirefox, isWindows } from "@excalidraw/common"; import { isDarwin, isFirefox, isWindows } from "@excalidraw/common";
import { KEYS, getShortcutKey } from "@excalidraw/common"; import { KEYS } from "@excalidraw/common";
import { getShortcutFromShortcutName } from "../actions/shortcuts"; import { getShortcutFromShortcutName } from "../actions/shortcuts";
import { probablySupportsClipboardBlob } from "../clipboard"; import { probablySupportsClipboardBlob } from "../clipboard";
import { t } from "../i18n"; import { t } from "../i18n";
import { getShortcutKey } from "../shortcut";
import { Dialog } from "./Dialog"; import { Dialog } from "./Dialog";
import { ExternalLinkIcon, GithubIcon, youtubeIcon } from "./icons"; import { ExternalLinkIcon, GithubIcon, youtubeIcon } from "./icons";
@@ -28,11 +28,24 @@ $wide-viewport-width: 1000px;
> span { > span {
padding: 0.25rem; padding: 0.25rem;
} }
kbd {
display: inline-block;
margin: 0 1px;
font-family: monospace;
border: 1px solid var(--color-gray-40);
border-radius: 4px;
padding: 1px 3px;
font-size: 10px;
}
} }
&.theme--dark { &.theme--dark {
.HintViewer { .HintViewer {
color: var(--color-gray-60); color: var(--color-gray-60);
kbd {
border-color: var(--color-gray-60);
}
} }
} }
} }
+101 -37
View File
@@ -9,29 +9,35 @@ import {
isTextElement, isTextElement,
} from "@excalidraw/element"; } from "@excalidraw/element";
import { getShortcutKey } from "@excalidraw/common";
import { isNodeInFlowchart } from "@excalidraw/element"; import { isNodeInFlowchart } from "@excalidraw/element";
import type { EditorInterface } from "@excalidraw/common";
import { t } from "../i18n"; import { t } from "../i18n";
import { getShortcutKey } from "../shortcut";
import { isEraserActive } from "../appState"; import { isEraserActive } from "../appState";
import { isGridModeEnabled } from "../snapping"; import { isGridModeEnabled } from "../snapping";
import "./HintViewer.scss"; import "./HintViewer.scss";
import type { AppClassProperties, Device, UIAppState } from "../types"; import type { AppClassProperties, UIAppState } from "../types";
interface HintViewerProps { interface HintViewerProps {
appState: UIAppState; appState: UIAppState;
isMobile: boolean; isMobile: boolean;
device: Device; editorInterface: EditorInterface;
app: AppClassProperties; app: AppClassProperties;
} }
const getTaggedShortcutKey = (key: string | string[]) =>
Array.isArray(key)
? `<kbd>${key.map(getShortcutKey).join(" + ")}</kbd>`
: `<kbd>${getShortcutKey(key)}</kbd>`;
const getHints = ({ const getHints = ({
appState, appState,
isMobile, isMobile,
device, editorInterface,
app, app,
}: HintViewerProps): null | string | string[] => { }: HintViewerProps): null | string | string[] => {
const { activeTool, isResizing, isRotating, lastPointerDownWith } = appState; const { activeTool, isResizing, isRotating, lastPointerDownWith } = appState;
@@ -42,22 +48,31 @@ const getHints = ({
appState.openSidebar.tab === CANVAS_SEARCH_TAB && appState.openSidebar.tab === CANVAS_SEARCH_TAB &&
appState.searchMatches?.matches.length appState.searchMatches?.matches.length
) { ) {
return t("hints.dismissSearch"); return t("hints.dismissSearch", {
shortcut: getTaggedShortcutKey("Escape"),
});
} }
if (appState.openSidebar && !device.editor.canFitSidebar) { if (appState.openSidebar && !editorInterface.canFitSidebar) {
return null; return null;
} }
if (isEraserActive(appState)) { if (isEraserActive(appState)) {
return t("hints.eraserRevert"); return t("hints.eraserRevert", {
shortcut: getTaggedShortcutKey("Alt"),
});
} }
if (activeTool.type === "arrow" || activeTool.type === "line") { if (activeTool.type === "arrow" || activeTool.type === "line") {
if (multiMode) { if (multiMode) {
return t("hints.linearElementMulti"); return t("hints.linearElementMulti", {
shortcut_1: getTaggedShortcutKey("Escape"),
shortcut_2: getTaggedShortcutKey("Enter"),
});
} }
if (activeTool.type === "arrow") { if (activeTool.type === "arrow") {
return t("hints.arrowTool", { arrowShortcut: getShortcutKey("A") }); return t("hints.arrowTool", {
shortcut: getTaggedShortcutKey("A"),
});
} }
return t("hints.linearElement"); return t("hints.linearElement");
} }
@@ -83,31 +98,51 @@ const getHints = ({
) { ) {
const targetElement = selectedElements[0]; const targetElement = selectedElements[0];
if (isLinearElement(targetElement) && targetElement.points.length === 2) { if (isLinearElement(targetElement) && targetElement.points.length === 2) {
return t("hints.lockAngle"); return t("hints.lockAngle", {
shortcut: getTaggedShortcutKey("Shift"),
});
} }
return isImageElement(targetElement) return isImageElement(targetElement)
? t("hints.resizeImage") ? t("hints.resizeImage", {
: t("hints.resize"); shortcut_1: getTaggedShortcutKey("Shift"),
shortcut_2: getTaggedShortcutKey("Alt"),
})
: t("hints.resize", {
shortcut_1: getTaggedShortcutKey("Shift"),
shortcut_2: getTaggedShortcutKey("Alt"),
});
} }
if (isRotating && lastPointerDownWith === "mouse") { if (isRotating && lastPointerDownWith === "mouse") {
return t("hints.rotate"); return t("hints.rotate", {
shortcut: getTaggedShortcutKey("Shift"),
});
} }
if (selectedElements.length === 1 && isTextElement(selectedElements[0])) { if (selectedElements.length === 1 && isTextElement(selectedElements[0])) {
return t("hints.text_selected"); return t("hints.text_selected", {
shortcut: getTaggedShortcutKey("Enter"),
});
} }
if (appState.editingTextElement) { if (appState.editingTextElement) {
return t("hints.text_editing"); return t("hints.text_editing", {
shortcut_1: getTaggedShortcutKey("Escape"),
shortcut_2: getTaggedShortcutKey(["CtrlOrCmd", "Enter"]),
});
} }
if (appState.croppingElementId) { if (appState.croppingElementId) {
return t("hints.leaveCropEditor"); return t("hints.leaveCropEditor", {
shortcut_1: getTaggedShortcutKey("Enter"),
shortcut_2: getTaggedShortcutKey("Escape"),
});
} }
if (selectedElements.length === 1 && isImageElement(selectedElements[0])) { if (selectedElements.length === 1 && isImageElement(selectedElements[0])) {
return t("hints.enterCropEditor"); return t("hints.enterCropEditor", {
shortcut: getTaggedShortcutKey("Enter"),
});
} }
if (activeTool.type === "selection") { if (activeTool.type === "selection") {
@@ -117,33 +152,57 @@ const getHints = ({
!appState.editingTextElement && !appState.editingTextElement &&
!appState.selectedLinearElement?.isEditing !appState.selectedLinearElement?.isEditing
) { ) {
return [t("hints.deepBoxSelect")]; return t("hints.deepBoxSelect", {
shortcut: getTaggedShortcutKey("CtrlOrCmd"),
});
} }
if (isGridModeEnabled(app) && appState.selectedElementsAreBeingDragged) { if (isGridModeEnabled(app) && appState.selectedElementsAreBeingDragged) {
return t("hints.disableSnapping"); return t("hints.disableSnapping", {
shortcut: getTaggedShortcutKey("CtrlOrCmd"),
});
} }
if (!selectedElements.length && !isMobile) { if (!selectedElements.length && !isMobile) {
return [t("hints.canvasPanning")]; return t("hints.canvasPanning", {
shortcut_1: getTaggedShortcutKey(t("keys.mmb")),
shortcut_2: getTaggedShortcutKey("Space"),
});
} }
if (selectedElements.length === 1) { if (selectedElements.length === 1) {
if (isLinearElement(selectedElements[0])) { if (isLinearElement(selectedElements[0])) {
if (appState.selectedLinearElement?.isEditing) { if (appState.selectedLinearElement?.isEditing) {
return appState.selectedLinearElement.selectedPointsIndices return appState.selectedLinearElement.selectedPointsIndices
? t("hints.lineEditor_pointSelected") ? t("hints.lineEditor_pointSelected", {
: t("hints.lineEditor_nothingSelected"); shortcut_1: getTaggedShortcutKey("Delete"),
shortcut_2: getTaggedShortcutKey(["CtrlOrCmd", "D"]),
})
: t("hints.lineEditor_nothingSelected", {
shortcut_1: getTaggedShortcutKey("Shift"),
shortcut_2: getTaggedShortcutKey("Alt"),
});
} }
return isLineElement(selectedElements[0]) return isLineElement(selectedElements[0])
? t("hints.lineEditor_line_info") ? t("hints.lineEditor_line_info", {
: t("hints.lineEditor_info"); shortcut: getTaggedShortcutKey("Enter"),
})
: t("hints.lineEditor_info", {
shortcut_1: getTaggedShortcutKey("CtrlOrCmd"),
shortcut_2: getTaggedShortcutKey(["CtrlOrCmd", "Enter"]),
});
} }
if ( if (
!appState.newElement && !appState.newElement &&
!appState.selectedElementsAreBeingDragged && !appState.selectedElementsAreBeingDragged &&
isTextBindableContainer(selectedElements[0]) isTextBindableContainer(selectedElements[0])
) { ) {
const bindTextToElement = t("hints.bindTextToElement", {
shortcut: getTaggedShortcutKey("Enter"),
});
const createFlowchart = t("hints.createFlowchart", {
shortcut: getTaggedShortcutKey(["CtrlOrCmd", "↑↓"]),
});
if (isFlowchartNodeElement(selectedElements[0])) { if (isFlowchartNodeElement(selectedElements[0])) {
if ( if (
isNodeInFlowchart( isNodeInFlowchart(
@@ -151,13 +210,13 @@ const getHints = ({
app.scene.getNonDeletedElementsMap(), app.scene.getNonDeletedElementsMap(),
) )
) { ) {
return [t("hints.bindTextToElement"), t("hints.createFlowchart")]; return [bindTextToElement, createFlowchart];
} }
return [t("hints.bindTextToElement"), t("hints.createFlowchart")]; return [bindTextToElement, createFlowchart];
} }
return t("hints.bindTextToElement"); return bindTextToElement;
} }
} }
} }
@@ -168,13 +227,13 @@ const getHints = ({
export const HintViewer = ({ export const HintViewer = ({
appState, appState,
isMobile, isMobile,
device, editorInterface,
app, app,
}: HintViewerProps) => { }: HintViewerProps) => {
const hints = getHints({ const hints = getHints({
appState, appState,
isMobile, isMobile,
device, editorInterface,
app, app,
}); });
@@ -183,16 +242,21 @@ export const HintViewer = ({
} }
const hint = Array.isArray(hints) const hint = Array.isArray(hints)
? hints ? hints.map((hint) => hint.replace(/\. ?$/, "")).join(", ")
.map((hint) => { : hints;
return getShortcutKey(hint).replace(/\. ?$/, "");
}) const hintJSX = hint.split(/(<kbd>[^<]+<\/kbd>)/g).map((part, index) => {
.join(". ") if (index % 2 === 1) {
: getShortcutKey(hints); const shortcutMatch =
part[0] === "<" && part.match(/^<kbd>([^<]+)<\/kbd>$/);
return <kbd key={index}>{shortcutMatch ? shortcutMatch[1] : part}</kbd>;
}
return part;
});
return ( return (
<div className="HintViewer"> <div className="HintViewer">
<span>{hint}</span> <span>{hintJSX}</span>
</div> </div>
); );
}; };
@@ -8,7 +8,7 @@ import { atom, useAtom } from "../editor-jotai";
import { getLanguage, t } from "../i18n"; import { getLanguage, t } from "../i18n";
import Collapsible from "./Stats/Collapsible"; import Collapsible from "./Stats/Collapsible";
import { useDevice } from "./App"; import { useEditorInterface, useExcalidrawContainer } from "./App";
import "./IconPicker.scss"; import "./IconPicker.scss";
@@ -38,7 +38,8 @@ function Picker<T>({
onClose: () => void; onClose: () => void;
numberOfOptionsToAlwaysShow?: number; numberOfOptionsToAlwaysShow?: number;
}) { }) {
const device = useDevice(); const editorInterface = useEditorInterface();
const { container } = useExcalidrawContainer();
const handleKeyDown = (event: React.KeyboardEvent) => { const handleKeyDown = (event: React.KeyboardEvent) => {
const pressedOption = options.find( const pressedOption = options.find(
@@ -152,17 +153,16 @@ function Picker<T>({
); );
}; };
const isMobile = editorInterface.formFactor === "phone";
return ( return (
<Popover.Content <Popover.Content
side={ side={isMobile ? "right" : "bottom"}
device.editor.isMobile && !device.viewport.isLandscape
? "top"
: "bottom"
}
align="start" align="start"
sideOffset={12} sideOffset={isMobile ? 8 : 12}
style={{ zIndex: "var(--zIndex-popup)" }} style={{ zIndex: "var(--zIndex-ui-styles-popup)" }}
onKeyDown={handleKeyDown} onKeyDown={handleKeyDown}
collisionBoundary={container ?? undefined}
> >
<div <div
className={`picker`} className={`picker`}
+13 -3
View File
@@ -1,10 +1,20 @@
export const InlineIcon = ({ icon }: { icon: React.ReactNode }) => { export const InlineIcon = ({
className,
icon,
size = "1em",
}: {
className?: string;
icon: React.ReactNode;
size?: string;
}) => {
return ( return (
<span <span
className={className}
style={{ style={{
width: "1em", width: size,
height: "100%",
margin: "0 0.5ex 0 0.5ex", margin: "0 0.5ex 0 0.5ex",
display: "inline-block", display: "inline-flex",
lineHeight: 0, lineHeight: 0,
verticalAlign: "middle", verticalAlign: "middle",
flex: "0 0 auto", flex: "0 0 auto",
@@ -24,6 +24,10 @@
gap: 0.75rem; gap: 0.75rem;
pointer-events: none !important; pointer-events: none !important;
&--compact {
gap: 0.5rem;
}
& > * { & > * {
pointer-events: var(--ui-pointerEvents); pointer-events: var(--ui-pointerEvents);
} }
@@ -116,4 +120,53 @@
margin-bottom: auto; margin-bottom: auto;
} }
} }
.disable-view-mode {
display: flex;
justify-content: center;
cursor: pointer;
align-items: center;
border: 1px solid var(--color-primary);
padding: 0.5rem;
border-radius: var(--border-radius-lg);
background-color: var(--island-bg-color);
text-decoration: none !important;
font-family: var(--ui-font);
font-size: 0.8333rem;
box-sizing: border-box;
width: var(--mobile-action-button-size, var(--default-button-size));
height: var(--mobile-action-button-size, var(--default-button-size));
border: none;
box-shadow: 0 0 0 1px var(--color-surface-lowest);
background-color: var(--color-surface-low);
color: var(--button-color, var(--color-on-surface)) !important;
&:active {
box-shadow: 0 0 0 1px var(--color-brand-active);
}
&:hover {
background-color: var(--color-primary);
color: white !important;
}
&:active {
background-color: var(--color-primary-darker);
}
svg {
width: 20px;
height: 20px;
}
}
.theme--dark {
.plus-banner {
&:hover {
color: black !important;
}
}
}
} }
+117 -52
View File
@@ -28,7 +28,11 @@ import { useAtom, useAtomValue } from "../editor-jotai";
import { t } from "../i18n"; import { t } from "../i18n";
import { calculateScrollCenter } from "../scene"; import { calculateScrollCenter } from "../scene";
import { SelectedShapeActions, ShapesSwitcher } from "./Actions"; import {
SelectedShapeActions,
ShapesSwitcher,
CompactShapeActions,
} from "./Actions";
import { LoadingMessage } from "./LoadingMessage"; import { LoadingMessage } from "./LoadingMessage";
import { LockButton } from "./LockButton"; import { LockButton } from "./LockButton";
import { MobileMenu } from "./MobileMenu"; import { MobileMenu } from "./MobileMenu";
@@ -41,9 +45,9 @@ import Footer from "./footer/Footer";
import { isSidebarDockedAtom } from "./Sidebar/Sidebar"; import { isSidebarDockedAtom } from "./Sidebar/Sidebar";
import MainMenu from "./main-menu/MainMenu"; import MainMenu from "./main-menu/MainMenu";
import { ActiveConfirmDialog } from "./ActiveConfirmDialog"; import { ActiveConfirmDialog } from "./ActiveConfirmDialog";
import { useDevice } from "./App"; import { useEditorInterface, useStylesPanelMode } from "./App";
import { OverwriteConfirmDialog } from "./OverwriteConfirm/OverwriteConfirm"; import { OverwriteConfirmDialog } from "./OverwriteConfirm/OverwriteConfirm";
import { LibraryIcon } from "./icons"; import { sidebarRightIcon } from "./icons";
import { DefaultSidebar } from "./DefaultSidebar"; import { DefaultSidebar } from "./DefaultSidebar";
import { TTDDialog } from "./TTDDialog/TTDDialog"; import { TTDDialog } from "./TTDDialog/TTDDialog";
import { Stats } from "./Stats"; import { Stats } from "./Stats";
@@ -86,6 +90,7 @@ interface LayerUIProps {
onPenModeToggle: AppClassProperties["togglePenMode"]; onPenModeToggle: AppClassProperties["togglePenMode"];
showExitZenModeBtn: boolean; showExitZenModeBtn: boolean;
langCode: Language["code"]; langCode: Language["code"];
renderTopLeftUI?: ExcalidrawProps["renderTopLeftUI"];
renderTopRightUI?: ExcalidrawProps["renderTopRightUI"]; renderTopRightUI?: ExcalidrawProps["renderTopRightUI"];
renderCustomStats?: ExcalidrawProps["renderCustomStats"]; renderCustomStats?: ExcalidrawProps["renderCustomStats"];
UIOptions: AppProps["UIOptions"]; UIOptions: AppProps["UIOptions"];
@@ -144,6 +149,7 @@ const LayerUI = ({
onHandToolToggle, onHandToolToggle,
onPenModeToggle, onPenModeToggle,
showExitZenModeBtn, showExitZenModeBtn,
renderTopLeftUI,
renderTopRightUI, renderTopRightUI,
renderCustomStats, renderCustomStats,
UIOptions, UIOptions,
@@ -154,9 +160,29 @@ const LayerUI = ({
isCollaborating, isCollaborating,
generateLinkForSelection, generateLinkForSelection,
}: LayerUIProps) => { }: LayerUIProps) => {
const device = useDevice(); const editorInterface = useEditorInterface();
const stylesPanelMode = useStylesPanelMode();
const isCompactStylesPanel = stylesPanelMode === "compact";
const tunnels = useInitializeTunnels(); const tunnels = useInitializeTunnels();
const spacing = isCompactStylesPanel
? {
menuTopGap: 4,
toolbarColGap: 4,
toolbarRowGap: 1,
toolbarInnerRowGap: 0.5,
islandPadding: 1,
collabMarginLeft: 8,
}
: {
menuTopGap: 6,
toolbarColGap: 4,
toolbarRowGap: 1,
toolbarInnerRowGap: 1,
islandPadding: 1,
collabMarginLeft: 8,
};
const TunnelsJotaiProvider = tunnels.tunnelsJotai.Provider; const TunnelsJotaiProvider = tunnels.tunnelsJotai.Provider;
const [eyeDropperState, setEyeDropperState] = useAtom(activeEyeDropperAtom); const [eyeDropperState, setEyeDropperState] = useAtom(activeEyeDropperAtom);
@@ -209,31 +235,55 @@ const LayerUI = ({
</div> </div>
); );
const renderSelectedShapeActions = () => ( const renderSelectedShapeActions = () => {
<Section const isCompactMode = isCompactStylesPanel;
heading="selectedShapeActions"
className={clsx("selected-shape-actions zen-mode-transition", { return (
"transition-left": appState.zenModeEnabled, <Section
})} heading="selectedShapeActions"
> className={clsx("selected-shape-actions zen-mode-transition", {
<Island "transition-left": appState.zenModeEnabled,
className={CLASSES.SHAPE_ACTIONS_MENU} })}
padding={2}
style={{
// we want to make sure this doesn't overflow so subtracting the
// approximate height of hamburgerMenu + footer
maxHeight: `${appState.height - 166}px`,
}}
> >
<SelectedShapeActions {isCompactMode ? (
appState={appState} <Island
elementsMap={app.scene.getNonDeletedElementsMap()} className={clsx("compact-shape-actions-island")}
renderAction={actionManager.renderAction} padding={0}
app={app} style={{
/> // we want to make sure this doesn't overflow so subtracting the
</Island> // approximate height of hamburgerMenu + footer
</Section> maxHeight: `${appState.height - 166}px`,
); }}
>
<CompactShapeActions
appState={appState}
elementsMap={app.scene.getNonDeletedElementsMap()}
renderAction={actionManager.renderAction}
app={app}
setAppState={setAppState}
/>
</Island>
) : (
<Island
className={CLASSES.SHAPE_ACTIONS_MENU}
padding={2}
style={{
// we want to make sure this doesn't overflow so subtracting the
// approximate height of hamburgerMenu + footer
maxHeight: `${appState.height - 166}px`,
}}
>
<SelectedShapeActions
appState={appState}
elementsMap={app.scene.getNonDeletedElementsMap()}
renderAction={actionManager.renderAction}
app={app}
/>
</Island>
)}
</Section>
);
};
const renderFixedSideContainer = () => { const renderFixedSideContainer = () => {
const shouldRenderSelectedShapeActions = showSelectedShapeActions( const shouldRenderSelectedShapeActions = showSelectedShapeActions(
@@ -250,9 +300,19 @@ const LayerUI = ({
return ( return (
<FixedSideContainer side="top"> <FixedSideContainer side="top">
<div className="App-menu App-menu_top"> <div className="App-menu App-menu_top">
<Stack.Col gap={6} className={clsx("App-menu_top__left")}> <Stack.Col
gap={spacing.menuTopGap}
className={clsx("App-menu_top__left")}
>
{renderCanvasActions()} {renderCanvasActions()}
{shouldRenderSelectedShapeActions && renderSelectedShapeActions()} <div
className={clsx("selected-shape-actions-container", {
"selected-shape-actions-container--compact":
isCompactStylesPanel,
})}
>
{shouldRenderSelectedShapeActions && renderSelectedShapeActions()}
</div>
</Stack.Col> </Stack.Col>
{!appState.viewModeEnabled && {!appState.viewModeEnabled &&
appState.openDialog?.name !== "elementLinkSelector" && ( appState.openDialog?.name !== "elementLinkSelector" && (
@@ -262,27 +322,28 @@ const LayerUI = ({
{renderWelcomeScreen && ( {renderWelcomeScreen && (
<tunnels.WelcomeScreenToolbarHintTunnel.Out /> <tunnels.WelcomeScreenToolbarHintTunnel.Out />
)} )}
<Stack.Col gap={4} align="start"> <Stack.Col gap={spacing.toolbarColGap} align="start">
<Stack.Row <Stack.Row
gap={1} gap={spacing.toolbarRowGap}
className={clsx("App-toolbar-container", { className={clsx("App-toolbar-container", {
"zen-mode": appState.zenModeEnabled, "zen-mode": appState.zenModeEnabled,
})} })}
> >
<Island <Island
padding={1} padding={spacing.islandPadding}
className={clsx("App-toolbar", { className={clsx("App-toolbar", {
"zen-mode": appState.zenModeEnabled, "zen-mode": appState.zenModeEnabled,
"App-toolbar--compact": isCompactStylesPanel,
})} })}
> >
<HintViewer <HintViewer
appState={appState} appState={appState}
isMobile={device.editor.isMobile} isMobile={editorInterface.formFactor === "phone"}
device={device} editorInterface={editorInterface}
app={app} app={app}
/> />
{heading} {heading}
<Stack.Row gap={1}> <Stack.Row gap={spacing.toolbarInnerRowGap}>
<PenModeButton <PenModeButton
zenModeEnabled={appState.zenModeEnabled} zenModeEnabled={appState.zenModeEnabled}
checked={appState.penMode} checked={appState.penMode}
@@ -306,7 +367,7 @@ const LayerUI = ({
/> />
<ShapesSwitcher <ShapesSwitcher
appState={appState} setAppState={setAppState}
activeTool={appState.activeTool} activeTool={appState.activeTool}
UIOptions={UIOptions} UIOptions={UIOptions}
app={app} app={app}
@@ -316,7 +377,7 @@ const LayerUI = ({
{isCollaborating && ( {isCollaborating && (
<Island <Island
style={{ style={{
marginLeft: 8, marginLeft: spacing.collabMarginLeft,
alignSelf: "center", alignSelf: "center",
height: "fit-content", height: "fit-content",
}} }}
@@ -344,6 +405,7 @@ const LayerUI = ({
"layer-ui__wrapper__top-right zen-mode-transition", "layer-ui__wrapper__top-right zen-mode-transition",
{ {
"transition-right": appState.zenModeEnabled, "transition-right": appState.zenModeEnabled,
"layer-ui__wrapper__top-right--compact": isCompactStylesPanel,
}, },
)} )}
> >
@@ -353,7 +415,10 @@ const LayerUI = ({
userToFollow={appState.userToFollow?.socketId || null} userToFollow={appState.userToFollow?.socketId || null}
/> />
)} )}
{renderTopRightUI?.(device.editor.isMobile, appState)} {renderTopRightUI?.(
editorInterface.formFactor === "phone",
appState,
)}
{!appState.viewModeEnabled && {!appState.viewModeEnabled &&
appState.openDialog?.name !== "elementLinkSelector" && appState.openDialog?.name !== "elementLinkSelector" &&
// hide button when sidebar docked // hide button when sidebar docked
@@ -384,7 +449,9 @@ const LayerUI = ({
trackEvent( trackEvent(
"sidebar", "sidebar",
`toggleDock (${docked ? "dock" : "undock"})`, `toggleDock (${docked ? "dock" : "undock"})`,
`(${device.editor.isMobile ? "mobile" : "desktop"})`, `(${
editorInterface.formFactor === "phone" ? "mobile" : "desktop"
})`,
); );
}} }}
/> />
@@ -405,21 +472,21 @@ const LayerUI = ({
<DefaultMainMenu UIOptions={UIOptions} /> <DefaultMainMenu UIOptions={UIOptions} />
<DefaultSidebar.Trigger <DefaultSidebar.Trigger
__fallback __fallback
icon={LibraryIcon} icon={sidebarRightIcon}
title={capitalizeString(t("toolBar.library"))} title={capitalizeString(t("toolBar.library"))}
onToggle={(open) => { onToggle={(open) => {
if (open) { if (open) {
trackEvent( trackEvent(
"sidebar", "sidebar",
`${DEFAULT_SIDEBAR.name} (open)`, `${DEFAULT_SIDEBAR.name} (open)`,
`button (${device.editor.isMobile ? "mobile" : "desktop"})`, `button (${
editorInterface.formFactor === "phone" ? "mobile" : "desktop"
})`,
); );
} }
}} }}
tab={DEFAULT_SIDEBAR.defaultTab} tab={DEFAULT_SIDEBAR.defaultTab}
> />
{t("toolBar.library")}
</DefaultSidebar.Trigger>
<DefaultOverwriteConfirmDialog /> <DefaultOverwriteConfirmDialog />
{appState.openDialog?.name === "ttd" && <TTDDialog __fallback />} {appState.openDialog?.name === "ttd" && <TTDDialog __fallback />}
{/* ------------------------------------------------------------------ */} {/* ------------------------------------------------------------------ */}
@@ -430,7 +497,7 @@ const LayerUI = ({
{appState.errorMessage} {appState.errorMessage}
</ErrorDialog> </ErrorDialog>
)} )}
{eyeDropperState && !device.editor.isMobile && ( {eyeDropperState && editorInterface.formFactor !== "phone" && (
<EyeDropper <EyeDropper
colorPickerType={eyeDropperState.colorPickerType} colorPickerType={eyeDropperState.colorPickerType}
onCancel={() => { onCancel={() => {
@@ -509,7 +576,7 @@ const LayerUI = ({
} }
/> />
)} )}
{device.editor.isMobile && ( {editorInterface.formFactor === "phone" && (
<MobileMenu <MobileMenu
app={app} app={app}
appState={appState} appState={appState}
@@ -518,25 +585,23 @@ const LayerUI = ({
renderJSONExportDialog={renderJSONExportDialog} renderJSONExportDialog={renderJSONExportDialog}
renderImageExportDialog={renderImageExportDialog} renderImageExportDialog={renderImageExportDialog}
setAppState={setAppState} setAppState={setAppState}
onLockToggle={onLockToggle}
onHandToolToggle={onHandToolToggle} onHandToolToggle={onHandToolToggle}
onPenModeToggle={onPenModeToggle} onPenModeToggle={onPenModeToggle}
renderTopLeftUI={renderTopLeftUI}
renderTopRightUI={renderTopRightUI} renderTopRightUI={renderTopRightUI}
renderCustomStats={renderCustomStats}
renderSidebars={renderSidebars} renderSidebars={renderSidebars}
device={device}
renderWelcomeScreen={renderWelcomeScreen} renderWelcomeScreen={renderWelcomeScreen}
UIOptions={UIOptions} UIOptions={UIOptions}
/> />
)} )}
{!device.editor.isMobile && ( {editorInterface.formFactor !== "phone" && (
<> <>
<div <div
className="layer-ui__wrapper" className="layer-ui__wrapper"
style={ style={
appState.openSidebar && appState.openSidebar &&
isSidebarDocked && isSidebarDocked &&
device.editor.canFitSidebar editorInterface.canFitSidebar
? { width: `calc(100% - var(--right-sidebar-width))` } ? { width: `calc(100% - var(--right-sidebar-width))` }
: {} : {}
} }

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