Compare commits

...

74 Commits

Author SHA1 Message Date
dwelle fb4a7bdf45 Merge branch 'master' into dwelle/bump-socket.io
# Conflicts:
#	src/locales/en.json
2023-01-05 18:54:26 +01:00
EternalWill43 0f11f7da15 fix: HelpDialog (#6072) 2023-01-05 17:43:19 +01:00
Aakansha Doshi 8420aecb34 feat: new Menu Component API (#6034)
* feat: new Menu Component API

* allow valid children types

* introduce menu group to group items

* Add lang footer

* use display name

* displayName

* define types inside

* fix default menu

* add json export to menu

* fix

* simplify expression

* put open menu into own compo to optimize perf

So that we don't rerun `useOutsideClickHook` (and rebind event listeners
all the time)

* naming tweaks

* rename MenuComponents->MenuDefaultItems and export default items from Menu.Items

* import Menu.scss in Menu.tsx

* move menu scss to excal app

* Don't filter children inside menu group

* move E+ out of socials

* support style prop for MenuItem and MenuGroup

* Support header in menu group and add Excalidraw links header for default items in social section

* rename header to title

* fix padding for lang

* render menu in mobile

* review fixes

* tweaks

* Export collaborators and show in mobile menu

* revert .env

* lint :p

* again lint

* show correct actions in view mode for mobile

* Whitelist Collaborators Comp

* mobile styling

* padding

* don't show nerds when menu open in mobile

* lint :(

* hide shortcuts

* refactor userlist to support mobile and keep a wrapper comp for excal app

* use only UserList

* render only on mobile for default items

* remove unused hooks

* Show collab button in menu when onCollabButtonClick present and hide export when UIOptions.canvasActions.export is false

* fix tests

* lint

* inject userlist inside menu on mobile

* revert userlist

* move menu socials to default menu

* fix collab

* use meny in library

* Make Menu generic and create hamburgemenu for public excal menu and use menu in library as well

* use appState.openMenu for mobile

* fix tests

* styling fixes and support style and class name in menu content

* fix test

* rename MenuDefaultItems->DefaultItems

* move footer css to its own comp

* rename HamburgerMenu -> MainMenu

* rename menu -> dropdownMenu and update classes, onClick->onToggle

* close main menu when dialog closes

* by bye filtering

* update docs

* fix lint

* update example, docs for useDevice and footer in mobile, rename menu ->DropDownMenu everywhere

* spec

* remove isMenuOpenAtom and set openMenu as canvas for main menu, render decreases in specs :)

* [temp] remove cyclic depenedency to fix build

* hack- update appstate to sync lang change

* Add more specs

* wip: rewrite MainMenu footer

* fix margin

* fix snaps

* not needed as lang list no more imported

* simplify custom footer rendering

* Add DropdownMenuItemLink and DropdownMenuItemCustom and update API, docs

* fix `MainMenu.ItemCustom`

* naming

* use onSelect and base class for custom items

* fix lint

* fix snap

* use custom item for lang

* update docs

* fix

* properly use `MainMenu.ItemCustom` for `LanguageList`

* add margin top to custom items

* flex

Co-authored-by: dwelle <luzar.david@gmail.com>
2023-01-05 22:04:23 +05:30
David Luzar 08afb857c3 fix: show error message on collab save failure (#6063)
* fix: show error message on collab save failure

* comment
2023-01-02 23:53:51 +01:00
David Luzar 9230c8f4d2 fix: remove ga from docker build (#6059)
* fix: remove ga from docker build

* lint

* fix debug
2023-01-02 21:21:57 +01:00
Excalidraw Bot dba8f812f1 chore: Update translations from Crowdin (#5807)
Co-authored-by: David Luzar <luzar.david@gmail.com>
2023-01-01 17:01:46 +00:00
dwelle 05df951770 fix Modal z-index 2023-01-01 17:35:35 +01:00
dwelle dffce96995 modify room init 2023-01-01 17:35:27 +01:00
DanielJGeiger fdd8552637 feat: Scroll using PageUp and PageDown (#6038)
* feat: Scroll using PageUp and PageDown

* support x-axis via `shift` & enable in viewMode

* tweak test

Co-authored-by: dwelle <luzar.david@gmail.com>
2022-12-31 15:54:37 -06:00
dwelle 1c84872b31 tweak message 2022-12-31 22:40:31 +01:00
dwelle 529eba9676 unrelated type tweak 2022-12-31 22:37:10 +01:00
dwelle 480db3f5b6 chore: bump socket.io-client to 4.5.4 2022-12-31 22:35:20 +01:00
Aakansha Doshi c8370b394c fix: use displayName since name gets stripped off when uglifying/minifiyng in production (#6036)
fix: use displayName since name gets stripped off when uglifying/minifiy in production
2022-12-27 15:17:13 +05:30
David Luzar 5fcf6a4845 fix: remove background from wysiwyg when editing arrow label (#6033)
Co-authored-by: Aakansha Doshi <aakansha1216@gmail.com>
2022-12-23 19:40:52 +01:00
Aakansha Doshi af3b93c410 fix: use canvas measureText to calculate width in measureText (#6030)
* fix: use canvas measureText to calculate width in measureText

* calculate multiline width correctly using canvas measure text and rename functions

* set correct width when pasting in bound container

* take existing value + new pasted

* remove debugger :p

* fix snaps
2022-12-23 21:45:49 +05:30
David Luzar 2595e0de82 fix: restoring deleted bindings (#6029)
* fix: restoring deleted bindings

* add tests

* add one more test

* merge restore tests files
2022-12-23 11:48:14 +01:00
Aakansha Doshi 8ec5f7b982 feat: support shrinking text containers to original height when text removed (#6025)
* fix:cache bind text containers height so that it could autoshrink to original height when text deleted

* revert

* rename

* reset cache when resized

* safe check

* restore original containr height when text is unbind

* update cache when redrawing bounding box

* reset cache when unbind

* make type-safe

* add specs

* skip one test

* remoe mock

* fix

Co-authored-by: dwelle <luzar.david@gmail.com>
2022-12-23 11:57:48 +05:30
David Luzar 9086674b27 chore: bump typescript @ 4.9.4 (#6024) 2022-12-22 19:32:21 +01:00
zsviczian 6273d56524 fix: ColorPicker getColor (#5949)
Co-authored-by: dwelle <luzar.david@gmail.com>
2022-12-22 12:53:49 +00:00
David Luzar 7e135c4e22 feat: move contextMenu into the component tree and control via appState (#6021) 2022-12-21 12:47:09 +01:00
Aakansha Doshi b704705ed8 feat: render footer as a component instead of render prop (#5970)
* feat: render footer as a component instead of render prop

* Export FooterCenter as footer

* remove useDevice export

* revert some changes

* remove

* add spec

* update specs

* parse children into a dictionary

* factor app footer components into a single file

* Add docs

* split app footer components

Co-authored-by: dwelle <luzar.david@gmail.com>
2022-12-21 14:29:06 +05:30
Aakansha Doshi d2e371cdf0 fix: don't push whitespace to next line when exceeding max width during wrapping and make sure to use same width of text editor on DOM when measuring dimensions (#5996)
* fix: don't push whitespace to next line when exceeding max width during wrapping

* add a helper function and never push empty line

* use width same as in text area so dimensions are same

* add tests

* make sure dom element has exact same width as text editor
2022-12-21 12:32:43 +05:30
David Luzar 6ab3f0eb74 fix: showing grabbing cursor when holding spacebar (#6015) 2022-12-20 13:22:20 +01:00
David Luzar 539505affd fix: resize sometimes throwing on missing null-checks (#6013) 2022-12-18 23:06:01 +01:00
David Luzar 95d669390f fix: PWA not working after CRA@5 update (#6012)
* fix: PWA not working after CRA@5 update

* fix: fallback to default locale when fetch fails
2022-12-18 22:23:30 +01:00
David Luzar 73a45e1988 fix: not properly restoring element stroke and bg colors (#6002) 2022-12-16 18:19:26 +01:00
David Luzar 88c2812949 fix: Avatar outline on safari & center (#5997) 2022-12-16 18:18:34 +01:00
David Luzar bdb14723b3 fix: chart pasting not working due to removing tab characters (#5987) 2022-12-16 18:18:27 +01:00
David Luzar cc9e764585 feat: allow readonly actions to be used in viewMode (#5982) 2022-12-11 22:57:03 +01:00
Ryan Di 8466eb0eef fix: apply the right type of roundness when pasting styles (#5979)
* fix: only paste roundness when target and source elements are of the same type

* apply roundness when pasting across different types

* simplify

Co-authored-by: dwelle <luzar.david@gmail.com>
2022-12-10 20:12:09 +08:00
Aakansha Doshi 0ebe6292a3 chore: add display name to context providers (#5974)
* chore: add display name to context providers

* fix typo
2022-12-08 16:19:44 +00:00
Ryan Di 5854ac3eed feat: better default radius sizes for rectangles (#5553)
Co-authored-by: Ryan <diweihao@bytedance.com>
Co-authored-by: dwelle <luzar.david@gmail.com>
2022-12-08 16:48:49 +01:00
Aakansha Doshi 65d84a5d5a fix: remove editor onpaste handler (#5971) 2022-12-07 23:05:57 +05:30
fennghuang 808366d112 fix: remove blank space (#5950) 2022-12-06 17:40:53 +05:30
Fer 9311c99d3c fix: Galego and Kurdî missing in languages plus two locale typos (#5954) 2022-12-06 17:34:22 +05:30
DanielJGeiger d131b31084 fix: ExcalidrawArrowElement rather than ExcalidrawArrowEleement (#5955) 2022-12-06 17:30:03 +05:30
Aakansha Doshi 0111ca2050 fix: renderFooter styling (#5962) 2022-12-06 16:42:54 +05:30
Aakansha Doshi a1dcd6d984 build: move release scripts to use release branch (#5958) 2022-12-06 16:33:02 +05:30
David Luzar fffd4957db fix: repair element bindings on restore (#5956)
* fix: repair element bindings on restore

* fix dropping non-text bound elements

* be more conservative
2022-12-06 00:23:47 +01:00
Aakansha Doshi 760fd7b3a6 feat: Support labels for arrow 🔥 (#5723)
* feat: support arrow with text

* render arrow -> clear rect-> render text

* move bound text when linear elements move

* fix centering cursor when linear element rotated

* fix y coord when new line added and container has 3 points

* update text position when 2nd point moved

* support adding label on top of 2nd point when 3 points are present

* change linear element editor shortcut to cmd+enter and fix tests

* scale bound text points when resizing via bounding box

* ohh yeah rotation works :)

* fix coords when updating text properties

* calculate new position after rotation always from original position

* rotate the bound text by same angle as parent

* don't rotate text and make sure dimensions and coords are always calculated from original point

* hardcoding the text width for now

* Move the linear element when bound text hit

* Rotation working yaay

* consider text element angle when editing

* refactor

* update x2 coords if needed when text updated

* simplify

* consider bound text to be part of bounding box when hit

* show bounding box correctly when multiple element selected

* fix typo

* support rotating multiple elements

* support multiple element resizing

* shift bound text to mid point when odd points

* Always render linear element handles inside editor after element rendered so point is visible for bound text

* Delete bound text when point attached to it deleted

* move bound to mid segement mid point when points are even

* shift bound text when points nearby deleted and handle segment deletion

* Resize working :)

* more resize fixes

* don't update cache-its breaking delete points, look for better soln

* update mid point cache for bound elements when updated

* introduce wrapping when resizing

* wrap when resize for 2 pointer linear elements

* support adding text for linear elements with more than 3 points

* export to svg  working :)

* clip from nearest enclosing element with non transparent color if present when exporting and fill with correct color in canvas

* fix snap

* use visible elements

* Make export to svg work with Mask :)

* remove id

* mask canvas linear element area where label is added

* decide the position of bound text during render

* fix coords when editing

* fix multiple resize

* update cache when bound text version changes

* fix masking when rotated

* render text in correct position in preview

* remove unnecessary code

* fix masking when rotating linear element

* fix masking with zoom

* fix mask in preview for export

* fix offsets in export view

* fix coords on svg export

* fix mask when element rotated in svg

* enable double-click to enter text

* fix hint

* Position cursor correctly and text dimensiosn when height of element is negative

* don't allow 2 pointer linear element with bound text width to go beyond min width

* code cleanup

* fix freedraw

* Add padding

* don't show vertical align action for linear element containers

* Add specs for getBoundTextElementPosition

* more specs

* move some utils to linearElementEditor.ts

* remove only :p

* check absoulte coods in test

* Add test to hide vertical align for linear eleemnt with bound text

* improve export preview

* support labels only for arrows

* spec

* fix large texts

* fix tests

* fix zooming

* enter line editor with cmd+double click

* Allow points to move beyond min width/height for 2 pointer arrow with bound text

* fix hint for line editing

* attempt to fix arrow getting deselected

* fix hint and shortcut

* Add padding of 5px when creating bound text and add spec

* Wrap bound text when arrow binding containers moved

* Add spec

* remove

* set boundTextElementVersion to null if not present

* dont use cache when version mismatch

* Add a padding of 5px vertically when creating text

* Add box sizing content box

* Set bound elements when text element created to fix the padding

* fix zooming in editor

* fix zoom in export

* remove globalCompositeOperation and use clearRect instead of fillRect
2022-12-05 21:03:13 +05:30
Aakansha Doshi 1933116261 fix: don't allow whitespaces for bound text (#5939)
* fix: don't allow whitespaces for bound text

* fix

* remove

* remove empty else

* fix

* fix

* fix
2022-12-02 16:47:50 +05:30
David Luzar 8b33ca3a1a fix: bindings do not survive history serialization (#5942) 2022-12-02 10:36:18 +00:00
Aakansha Doshi a86224c797 fix: Dedupe boundElement ids when container duplicated with alt+drag (#5938)
* Dedupe boundElement ids when container duplicated with alt+drag and add spec

* set to null by default
2022-12-01 20:44:33 +05:30
Aakansha Doshi 66bbfda460 fix: scale font correctly when using shift (#5935)
* fix: scale font correctly when using shift

* fix

* Empty-Commit

* Add spec

* fix
2022-11-30 15:55:01 +05:30
Aakansha Doshi 88b2f4707d refactor: remove unnecessary code (#5933) 2022-11-29 16:41:02 +05:30
Aakansha Doshi 25c6056b03 feat: Don't add midpoint until dragged beyond a threshold (#5927)
* Don't add midpoint until dragged beyond a threshold

* remove unnecessary code

* fix tests

* fix

* add spec

* remove isMidpoint

* cleanup

* fix threshold for zoom

* split into shouldAddMidpoint and addMidpoint

* wrap in flushSync for synchronous updates

* remove threshold for line editor and add spec

* [unrelated] fix stack overflow state update

* fix tests

* don't drag arrow when dragging to add mid point

* add specs

Co-authored-by: dwelle <luzar.david@gmail.com>
2022-11-29 00:01:53 +05:30
Antonio Della Fortuna baf9651d34 feat: changed text copy/paste behaviour (#5786)
Co-authored-by: dwelle <luzar.david@gmail.com>
Co-authored-by: Antonio Della Fortuna <a.dellafortuna00@gmail.com>
2022-11-26 23:44:26 +01:00
Aakansha Doshi d2181847be fix: Always bind to container selected by user (#5880)
* fix: Always bind to container selected by user

* Don't bind to container when using text tool

* adjust z-index for bound text

* fix

* Add spec

* Add test

* Allow double click on transparent container and add spec

* fix spec

* adjust z-index only when binding

* update index

* fix

* add index check

* Update src/scene/Scene.ts

Co-authored-by: dwelle <luzar.david@gmail.com>
2022-11-25 15:45:34 +05:30
David Luzar 1f117995d9 fix: fonts not rendered on init if loadingdone not fired (#5923)
* fix: fonts not rendered on init if `loadingdone` not fired

* remove unnecessary check
2022-11-23 21:15:32 +01:00
David Luzar 52c96a6870 chore: bump create-react-app to 5.0.1 (from 4.0.3) (#5904) 2022-11-19 18:28:21 +01:00
David Luzar 81fd2350a9 fix: stop replacing del word with Delete (#5897) 2022-11-19 18:28:08 +01:00
David Luzar 8ed0fc2c87 fix: remove legacy React.render() from the editor (#5893) 2022-11-19 18:27:54 +01:00
Aakansha Doshi 96a5d6548b fix: allow adding text via enter only for text containers (#5891)
* fix: allow adding text via enter only for text containers

* fix

* fix

* fix

* move check isTextElement outside
2022-11-17 15:26:18 +05:30
dependabot[bot] 4709b953e7 build(deps): bump loader-utils from 2.0.3 to 2.0.4 in /dev-docs (#5885)
Bumps [loader-utils](https://github.com/webpack/loader-utils) from 2.0.3 to 2.0.4.
- [Release notes](https://github.com/webpack/loader-utils/releases)
- [Changelog](https://github.com/webpack/loader-utils/blob/v2.0.4/CHANGELOG.md)
- [Commits](https://github.com/webpack/loader-utils/compare/v2.0.3...v2.0.4)

---
updated-dependencies:
- dependency-name: loader-utils
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2022-11-17 15:14:29 +05:30
David Luzar bbe0c35f66 fix: stop font loadingdone loop when rendering element SVGs (#5883)
* fix: stop font `loadingdone` loop when rendering element SVGs

* update snaps

* stop updating scene elements array if no change was made

* always re-render if invalidating element shape
2022-11-15 21:02:57 +01:00
David Luzar d273acb7e4 fix: refresh text dimensions only after font load done (#5878)
* fix: refresh text dimensions only after font load done

* fix snaps
2022-11-15 00:15:02 +01:00
dependabot[bot] 3c0b29d85f build(deps): bump loader-utils from 2.0.0 to 2.0.4 in /src/packages/utils (#5874)
build(deps): bump loader-utils in /src/packages/utils

Bumps [loader-utils](https://github.com/webpack/loader-utils) from 2.0.0 to 2.0.4.
- [Release notes](https://github.com/webpack/loader-utils/releases)
- [Changelog](https://github.com/webpack/loader-utils/blob/v2.0.4/CHANGELOG.md)
- [Commits](https://github.com/webpack/loader-utils/compare/v2.0.0...v2.0.4)

---
updated-dependencies:
- dependency-name: loader-utils
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2022-11-14 15:49:05 +05:30
DanielJGeiger bfbaeae67f fix: Correctly paste contents parsed by JSON.parse() as text. (#5868)
* Fix #5867

* Add test.

* Add tests to clipboard.test.ts

Co-authored-by: Aakansha Doshi <aakansha1216@gmail.com>
2022-11-14 14:02:54 +05:30
dependabot[bot] 74b9885955 build(deps): bump minimatch from 3.0.4 to 3.1.2 in /src/packages/excalidraw (#5861)
build(deps): bump minimatch in /src/packages/excalidraw

Bumps [minimatch](https://github.com/isaacs/minimatch) from 3.0.4 to 3.1.2.
- [Release notes](https://github.com/isaacs/minimatch/releases)
- [Commits](https://github.com/isaacs/minimatch/compare/v3.0.4...v3.1.2)

---
updated-dependencies:
- dependency-name: minimatch
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2022-11-14 13:51:10 +05:30
dependabot[bot] 2cbe869a13 build(deps): bump socket.io-parser from 3.3.2 to 3.3.3 (#5862)
Bumps [socket.io-parser](https://github.com/socketio/socket.io-parser) from 3.3.2 to 3.3.3.
- [Release notes](https://github.com/socketio/socket.io-parser/releases)
- [Changelog](https://github.com/socketio/socket.io-parser/blob/main/CHANGELOG.md)
- [Commits](https://github.com/socketio/socket.io-parser/compare/3.3.2...3.3.3)

---
updated-dependencies:
- dependency-name: socket.io-parser
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2022-11-14 13:50:15 +05:30
dependabot[bot] a48607eb25 build(deps): bump loader-utils from 2.0.2 to 2.0.3 in /src/packages/excalidraw (#5851)
build(deps): bump loader-utils in /src/packages/excalidraw

Bumps [loader-utils](https://github.com/webpack/loader-utils) from 2.0.2 to 2.0.3.
- [Release notes](https://github.com/webpack/loader-utils/releases)
- [Changelog](https://github.com/webpack/loader-utils/blob/v2.0.3/CHANGELOG.md)
- [Commits](https://github.com/webpack/loader-utils/compare/v2.0.2...v2.0.3)

---
updated-dependencies:
- dependency-name: loader-utils
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2022-11-14 13:49:39 +05:30
zsviczian 7831b6e74b fix: SVG element attributes in icons.tsx (#5871)
Update icons.tsx
2022-11-14 11:42:28 +05:30
dependabot[bot] 640affe7c0 build(deps): bump loader-utils from 2.0.2 to 2.0.3 in /dev-docs (#5853)
Bumps [loader-utils](https://github.com/webpack/loader-utils) from 2.0.2 to 2.0.3.
- [Release notes](https://github.com/webpack/loader-utils/releases)
- [Changelog](https://github.com/webpack/loader-utils/blob/v2.0.3/CHANGELOG.md)
- [Commits](https://github.com/webpack/loader-utils/compare/v2.0.2...v2.0.3)

---
updated-dependencies:
- dependency-name: loader-utils
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2022-11-10 15:02:18 +05:30
DanielJGeiger 335aff8838 fix: merge existing text with new when pasted (#5856)
* Fix #5855.

* fix test

* tweak

* Add specs

* Add more snaps

Co-authored-by: Aakansha Doshi <aakansha1216@gmail.com>
2022-11-09 23:39:53 +05:30
Aakansha Doshi dc97dc30bf fix: disable FAST_REFRESH to fix live reload (#5852) 2022-11-09 17:13:20 +05:30
DanielJGeiger a0ecfed4cd fix: Paste clipboard contents into unbound text elements (#5849)
* Fix #5848.

* Add test.

* some tweaks

Co-authored-by: Aakansha Doshi <aakansha1216@gmail.com>
2022-11-09 11:30:22 +05:30
Aakansha Doshi e201e79cd0 fix: compute dimensions of container correctly when text pasted on container (#5845)
* fix: compute dimensions of container correctly when text pasted on container

* add test

* remove only
2022-11-08 19:50:41 +05:30
Pritam Sangani e1c5c706c6 build: stops ignoring .env files from docker context so env variables get set during react app build. (#5809)
build: stops ignoring .env.development and .env.production files from docker context so env variables get set during react app build.
* this fixes the issue where Browse Libraries button link was broken in
  docker/self-hosted versions of excalidraw
2022-11-07 16:48:38 +05:30
David Luzar bdc56090d7 feat: reintroduce x shortcut for freedraw (#5840) 2022-11-06 23:07:15 +01:00
David Luzar 58accc9310 feat: tweak toolbar shortcuts & remove library shortcut (#5832) 2022-11-06 20:14:53 +01:00
David Luzar b91158198e feat: clean unused images only after 24hrs (local-only) (#5839)
* feat: clean unused images only after 24hrs (local-only)

* fix test

* make optional for now
2022-11-06 19:41:14 +01:00
David Luzar 938ce241ff feat: refetch errored/pending images on collab room init load (#5833) 2022-11-05 15:55:14 +01:00
David Luzar 0228646507 fix: line editor points rendering below elements (#5781)
* fix: line editor points rendering below elements

* add test
2022-11-05 11:35:53 +01:00
Aakansha Doshi 25ea97d0f9 test: fix failing tests and API (#5823)
* tests: fix failing tests

* fix selection.test.tsx

* fix excalidraw.test.tsx and don't show image export when SaveAsImage is false in UIOptions.canvasActions

* more fixes

* require fake index db in setUp test to fix the tests

* fix regression
2022-11-04 18:22:21 +05:30
223 changed files with 15478 additions and 10107 deletions
+2 -1
View File
@@ -1,5 +1,6 @@
* *
!.env !.env.development
!.env.production
!.eslintrc.json !.eslintrc.json
!.npmrc !.npmrc
!.prettierrc !.prettierrc
+2
View File
@@ -20,3 +20,5 @@ REACT_APP_DEV_ENABLE_SW=
# whether to disable live reload / HMR. Usuaully what you want to do when # whether to disable live reload / HMR. Usuaully what you want to do when
# debugging Service Workers. # debugging Service Workers.
REACT_APP_DEV_DISABLE_LIVE_RELOAD= REACT_APP_DEV_DISABLE_LIVE_RELOAD=
FAST_REFRESH=false
+1 -1
View File
@@ -2,7 +2,7 @@ name: Auto release excalidraw next
on: on:
push: push:
branches: branches:
- master - release
jobs: jobs:
Auto-release-excalidraw-next: Auto-release-excalidraw-next:
+1 -1
View File
@@ -3,7 +3,7 @@ name: Build Docker image
on: on:
push: push:
branches: branches:
- master - release
jobs: jobs:
build-docker: build-docker:
+1 -1
View File
@@ -3,7 +3,7 @@ name: Cancel previous runs
on: on:
push: push:
branches: branches:
- master - release
pull_request: pull_request:
jobs: jobs:
+1 -1
View File
@@ -3,7 +3,7 @@ name: Publish Docker
on: on:
push: push:
branches: branches:
- master - release
jobs: jobs:
publish-docker: publish-docker:
+1 -1
View File
@@ -3,7 +3,7 @@ name: New Sentry production release
on: on:
push: push:
branches: branches:
- master - release
jobs: jobs:
sentry: sentry:
+3 -3
View File
@@ -4755,9 +4755,9 @@ loader-runner@^4.2.0:
integrity sha512-3R/1M+yS3j5ou80Me59j7F9IMs4PXs3VqRrm0TU3AbKPxlmpoY1TNscJV/oGJXo8qCatFGTfDbY6W6ipGOYXfg== integrity sha512-3R/1M+yS3j5ou80Me59j7F9IMs4PXs3VqRrm0TU3AbKPxlmpoY1TNscJV/oGJXo8qCatFGTfDbY6W6ipGOYXfg==
loader-utils@^2.0.0: loader-utils@^2.0.0:
version "2.0.2" version "2.0.4"
resolved "https://registry.yarnpkg.com/loader-utils/-/loader-utils-2.0.2.tgz#d6e3b4fb81870721ae4e0868ab11dd638368c129" resolved "https://registry.yarnpkg.com/loader-utils/-/loader-utils-2.0.4.tgz#8b5cb38b5c34a9a018ee1fc0e6a066d1dfcc528c"
integrity sha512-TM57VeHptv569d/GKh6TAYdzKblwDNiumOdkFnejjD0XwTH87K90w3O7AiJRqdQoXygvi1VQTJTLGhJl7WqA7A== integrity sha512-xXqpXoINfFhgua9xiqD8fPFHgkoq1mmmpE92WlDbm9rNRd/EbRb+Gqf908T2DMfuHjjJlksiK2RbHVOdD/MqSw==
dependencies: dependencies:
big.js "^5.2.2" big.js "^5.2.2"
emojis-list "^3.0.0" emojis-list "^3.0.0"
+21 -8
View File
@@ -28,9 +28,9 @@
"@types/pica": "5.1.3", "@types/pica": "5.1.3",
"@types/react": "18.0.15", "@types/react": "18.0.15",
"@types/react-dom": "18.0.6", "@types/react-dom": "18.0.6",
"@types/socket.io-client": "1.4.36",
"browser-fs-access": "0.29.1", "browser-fs-access": "0.29.1",
"clsx": "1.1.1", "clsx": "1.1.1",
"cross-env": "7.0.3",
"fake-indexeddb": "3.1.7", "fake-indexeddb": "3.1.7",
"firebase": "8.3.3", "firebase": "8.3.3",
"i18next-browser-languagedetector": "6.1.4", "i18next-browser-languagedetector": "6.1.4",
@@ -50,11 +50,23 @@
"pwacompat": "2.0.17", "pwacompat": "2.0.17",
"react": "18.2.0", "react": "18.2.0",
"react-dom": "18.2.0", "react-dom": "18.2.0",
"react-scripts": "4.0.3", "react-scripts": "5.0.1",
"roughjs": "4.5.2", "roughjs": "4.5.2",
"sass": "1.51.0", "sass": "1.51.0",
"socket.io-client": "2.3.1", "socket.io-client": "4.5.4",
"typescript": "4.5.5" "typescript": "4.9.4",
"workbox-background-sync": "^6.5.4",
"workbox-broadcast-update": "^6.5.4",
"workbox-cacheable-response": "^6.5.4",
"workbox-core": "^6.5.4",
"workbox-expiration": "^6.5.4",
"workbox-google-analytics": "^6.5.4",
"workbox-navigation-preload": "^6.5.4",
"workbox-precaching": "^6.5.4",
"workbox-range-requests": "^6.5.4",
"workbox-routing": "^6.5.4",
"workbox-strategies": "^6.5.4",
"workbox-streams": "^6.5.4"
}, },
"devDependencies": { "devDependencies": {
"@excalidraw/eslint-config": "1.0.0", "@excalidraw/eslint-config": "1.0.0",
@@ -67,6 +79,7 @@
"dotenv": "16.0.1", "dotenv": "16.0.1",
"eslint-config-prettier": "8.5.0", "eslint-config-prettier": "8.5.0",
"eslint-plugin-prettier": "3.3.1", "eslint-plugin-prettier": "3.3.1",
"http-server": "14.1.1",
"husky": "7.0.4", "husky": "7.0.4",
"jest-canvas-mock": "2.4.0", "jest-canvas-mock": "2.4.0",
"lint-staged": "12.3.7", "lint-staged": "12.3.7",
@@ -89,11 +102,10 @@
"private": true, "private": true,
"scripts": { "scripts": {
"build-node": "node ./scripts/build-node.js", "build-node": "node ./scripts/build-node.js",
"build:app:docker": "REACT_APP_DISABLE_SENTRY=true react-scripts build", "build:app:docker": "cross-env REACT_APP_DISABLE_SENTRY=true REACT_APP_DISABLE_TRACKING=true react-scripts build",
"build:app": "REACT_APP_GIT_SHA=$VERCEL_GIT_COMMIT_SHA react-scripts build", "build:app": "cross-env REACT_APP_GIT_SHA=$VERCEL_GIT_COMMIT_SHA react-scripts build",
"build:version": "node ./scripts/build-version.js", "build:version": "node ./scripts/build-version.js",
"build:prebuild": "node ./scripts/prebuild.js", "build": "yarn build:app && yarn build:version",
"build": "yarn build:prebuild && yarn build:app && yarn build:version",
"eject": "react-scripts eject", "eject": "react-scripts eject",
"fix:code": "yarn test:code --fix", "fix:code": "yarn test:code --fix",
"fix:other": "yarn prettier --write", "fix:other": "yarn prettier --write",
@@ -103,6 +115,7 @@
"prepare": "husky install", "prepare": "husky install",
"prettier": "prettier \"**/*.{css,scss,json,md,html,yml}\" --ignore-path=.eslintignore", "prettier": "prettier \"**/*.{css,scss,json,md,html,yml}\" --ignore-path=.eslintignore",
"start": "react-scripts start", "start": "react-scripts start",
"start:production": "npm run build && npx http-server build -a localhost -p 5001 -o",
"test:all": "yarn test:typecheck && yarn test:code && yarn test:other && yarn test:app --watchAll=false", "test:all": "yarn test:typecheck && yarn test:code && yarn test:other && yarn test:app --watchAll=false",
"test:app": "react-scripts test --passWithNoTests", "test:app": "react-scripts test --passWithNoTests",
"test:code": "eslint --max-warnings=0 --ext .js,.ts,.tsx .", "test:code": "eslint --max-warnings=0 --ext .js,.ts,.tsx .",
+2 -1
View File
@@ -146,7 +146,8 @@
// setting this so that libraries installation reuses this window tab. // setting this so that libraries installation reuses this window tab.
window.name = "_excalidraw"; window.name = "_excalidraw";
</script> </script>
<% if (process.env.REACT_APP_GOOGLE_ANALYTICS_ID) { %> <% if (process.env.REACT_APP_DISABLE_TRACKING !== 'true' &&
process.env.REACT_APP_GOOGLE_ANALYTICS_ID) { %>
<script <script
async async
src="https://www.googletagmanager.com/gtag/js?id=%REACT_APP_GOOGLE_ANALYTICS_ID%" src="https://www.googletagmanager.com/gtag/js?id=%REACT_APP_GOOGLE_ANALYTICS_ID%"
-81
View File
@@ -1,81 +0,0 @@
// eslint-disable-next-line no-restricted-globals
// eslint-disable-next-line no-unused-expressions
/* eslint-disable no-restricted-globals */
/* global importScripts, workbox */
/**
* Welcome to your Workbox-powered service worker!
*
* You'll need to register this file in your web app and you should
* disable HTTP caching for this file too.
* See https://goo.gl/nhQhGp
*
* The rest of the code is auto-generated. Please don't update this file
* directly; instead, make changes to your Workbox build configuration
* and re-run your build process.
* See https://goo.gl/2aRDsh
*/
// in dev, `process` is undefined because this file is not compiled until build
const IS_DEVELOPMENT =
typeof process === "undefined" || process.env.NODE_ENV !== "production";
if (IS_DEVELOPMENT) {
importScripts(
"https://storage.googleapis.com/workbox-cdn/releases/4.3.1/workbox-sw.js",
);
workbox.setConfig({
debug: true,
});
} else {
importScripts("/workbox/workbox-sw.js");
workbox.setConfig({
modulePathPrefix: "/workbox/",
});
}
self.addEventListener("message", (event) => {
if (event.data && event.data.type === "SKIP_WAITING") {
self.skipWaiting();
}
});
workbox.core.clientsClaim();
if (!IS_DEVELOPMENT) {
workbox.precaching.precacheAndRoute(self.__WB_MANIFEST);
workbox.routing.registerNavigationRoute(
workbox.precaching.getCacheKeyForURL("./index.html"),
{
blacklist: [/^\/_/, /\/[^/?]+\.[^/]+$/],
},
);
}
// Cache relevant font files
workbox.routing.registerRoute(
new RegExp("/(fonts.css|.+.(ttf|woff2|otf))"),
new workbox.strategies.StaleWhileRevalidate({
cacheName: "fonts",
plugins: [new workbox.expiration.Plugin({ maxEntries: 10 })],
}),
);
self.addEventListener("fetch", (event) => {
if (
event.request.method === "POST" &&
event.request.url.endsWith("/web-share-target")
) {
return event.respondWith(
(async () => {
const formData = await event.request.formData();
const file = formData.get("file");
const webShareTargetCache = await caches.open("web-share-target");
await webShareTargetCache.put("shared-file", new Response(file));
return Response.redirect("/?web-share-target", 303);
})(),
);
}
});
+4 -2
View File
@@ -50,8 +50,8 @@ const crowdinMap = {
"lv-LV": "en-lv", "lv-LV": "en-lv",
"cs-CZ": "en-cs", "cs-CZ": "en-cs",
"kk-KZ": "en-kk", "kk-KZ": "en-kk",
"vi-vn": "en-vi", "vi-VN": "en-vi",
"mr-in": "en-mr", "mr-IN": "en-mr",
}; };
const flags = { const flags = {
@@ -120,6 +120,7 @@ const languages = {
"fa-IR": "فارسی", "fa-IR": "فارسی",
"fi-FI": "Suomi", "fi-FI": "Suomi",
"fr-FR": "Français", "fr-FR": "Français",
"gl-ES": "Galego",
"he-IL": "עברית", "he-IL": "עברית",
"hi-IN": "हिन्दी", "hi-IN": "हिन्दी",
"hu-HU": "Magyar", "hu-HU": "Magyar",
@@ -129,6 +130,7 @@ const languages = {
"kab-KAB": "Taqbaylit", "kab-KAB": "Taqbaylit",
"kk-KZ": "Қазақ тілі", "kk-KZ": "Қазақ тілі",
"ko-KR": "한국어", "ko-KR": "한국어",
"ku-TR": "Kurdî",
"lt-LT": "Lietuvių", "lt-LT": "Lietuvių",
"lv-LV": "Latviešu", "lv-LV": "Latviešu",
"my-MM": "Burmese", "my-MM": "Burmese",
-21
View File
@@ -1,21 +0,0 @@
const fs = require("fs");
const path = require("path");
// for development purposes we want to have the service-worker.js file
// accessible from the public folder. On build though, we need to compile it
// and CRA expects that file to be in src/ folder.
const moveServiceWorkerScript = () => {
const oldPath = path.resolve(__dirname, "../public/service-worker.js");
const newPath = path.resolve(__dirname, "../src/service-worker.js");
fs.rename(oldPath, newPath, (error) => {
if (error) {
throw error;
}
console.info("public/service-worker.js moved to src/");
});
};
// -----------------------------------------------------------------------------
moveServiceWorkerScript();
+12
View File
@@ -6,6 +6,10 @@ import {
measureText, measureText,
redrawTextBoundingBox, redrawTextBoundingBox,
} from "../element/textElement"; } from "../element/textElement";
import {
getOriginalContainerHeightFromCache,
resetOriginalContainerCache,
} from "../element/textWysiwyg";
import { import {
hasBoundTextElement, hasBoundTextElement,
isTextBindableContainer, isTextBindableContainer,
@@ -38,6 +42,11 @@ export const actionUnbindText = register({
boundTextElement.originalText, boundTextElement.originalText,
getFontString(boundTextElement), getFontString(boundTextElement),
); );
const originalContainerHeight = getOriginalContainerHeightFromCache(
element.id,
);
resetOriginalContainerCache(element.id);
mutateElement(boundTextElement as ExcalidrawTextElement, { mutateElement(boundTextElement as ExcalidrawTextElement, {
containerId: null, containerId: null,
width, width,
@@ -49,6 +58,9 @@ export const actionUnbindText = register({
boundElements: element.boundElements?.filter( boundElements: element.boundElements?.filter(
(ele) => ele.id !== boundTextElement.id, (ele) => ele.id !== boundTextElement.id,
), ),
height: originalContainerHeight
? originalContainerHeight
: element.height,
}); });
} }
}); });
+18 -9
View File
@@ -23,7 +23,7 @@ import { newElementWith } from "../element/mutateElement";
import { getDefaultAppState, isEraserActive } from "../appState"; import { getDefaultAppState, isEraserActive } from "../appState";
import ClearCanvas from "../components/ClearCanvas"; import ClearCanvas from "../components/ClearCanvas";
import clsx from "clsx"; import clsx from "clsx";
import MenuItem from "../components/MenuItem"; import DropdownMenuItem from "../components/dropdownMenu/DropdownMenuItem";
import { getShortcutFromShortcutName } from "./shortcuts"; import { getShortcutFromShortcutName } from "./shortcuts";
export const actionChangeViewBackgroundColor = register({ export const actionChangeViewBackgroundColor = register({
@@ -90,6 +90,7 @@ export const actionClearCanvas = register({
export const actionZoomIn = register({ export const actionZoomIn = register({
name: "zoomIn", name: "zoomIn",
viewMode: true,
trackEvent: { category: "canvas" }, trackEvent: { category: "canvas" },
perform: (_elements, appState, _, app) => { perform: (_elements, appState, _, app) => {
return { return {
@@ -126,6 +127,7 @@ export const actionZoomIn = register({
export const actionZoomOut = register({ export const actionZoomOut = register({
name: "zoomOut", name: "zoomOut",
viewMode: true,
trackEvent: { category: "canvas" }, trackEvent: { category: "canvas" },
perform: (_elements, appState, _, app) => { perform: (_elements, appState, _, app) => {
return { return {
@@ -162,6 +164,7 @@ export const actionZoomOut = register({
export const actionResetZoom = register({ export const actionResetZoom = register({
name: "resetZoom", name: "resetZoom",
viewMode: true,
trackEvent: { category: "canvas" }, trackEvent: { category: "canvas" },
perform: (_elements, appState, _, app) => { perform: (_elements, appState, _, app) => {
return { return {
@@ -271,6 +274,7 @@ export const actionZoomToSelected = register({
export const actionZoomToFit = register({ export const actionZoomToFit = register({
name: "zoomToFit", name: "zoomToFit",
viewMode: true,
trackEvent: { category: "canvas" }, trackEvent: { category: "canvas" },
perform: (elements, appState) => zoomToFitElements(elements, appState, false), perform: (elements, appState) => zoomToFitElements(elements, appState, false),
keyTest: (event) => keyTest: (event) =>
@@ -282,6 +286,7 @@ export const actionZoomToFit = register({
export const actionToggleTheme = register({ export const actionToggleTheme = register({
name: "toggleTheme", name: "toggleTheme",
viewMode: true,
trackEvent: { category: "canvas" }, trackEvent: { category: "canvas" },
perform: (_, appState, value) => { perform: (_, appState, value) => {
return { return {
@@ -294,19 +299,23 @@ export const actionToggleTheme = register({
}; };
}, },
PanelComponent: ({ appState, updateData }) => ( PanelComponent: ({ appState, updateData }) => (
<MenuItem <DropdownMenuItem
label={ onSelect={() => {
appState.theme === "dark"
? t("buttons.lightMode")
: t("buttons.darkMode")
}
onClick={() => {
updateData(appState.theme === THEME.LIGHT ? THEME.DARK : THEME.LIGHT); updateData(appState.theme === THEME.LIGHT ? THEME.DARK : THEME.LIGHT);
}} }}
icon={appState.theme === "dark" ? SunIcon : MoonIcon} icon={appState.theme === "dark" ? SunIcon : MoonIcon}
dataTestId="toggle-dark-mode" dataTestId="toggle-dark-mode"
shortcut={getShortcutFromShortcutName("toggleTheme")} shortcut={getShortcutFromShortcutName("toggleTheme")}
/> ariaLabel={
appState.theme === "dark"
? t("buttons.lightMode")
: t("buttons.darkMode")
}
>
{appState.theme === "dark"
? t("buttons.lightMode")
: t("buttons.darkMode")}
</DropdownMenuItem>
), ),
keyTest: (event) => event.altKey && event.shiftKey && event.code === CODES.D, keyTest: (event) => event.altKey && event.shiftKey && event.code === CODES.D,
}); });
+30
View File
@@ -3,6 +3,7 @@ import { register } from "./register";
import { import {
copyTextToSystemClipboard, copyTextToSystemClipboard,
copyToClipboard, copyToClipboard,
probablySupportsClipboardBlob,
probablySupportsClipboardWriteText, probablySupportsClipboardWriteText,
} from "../clipboard"; } from "../clipboard";
import { actionDeleteSelected } from "./actionDeleteSelected"; import { actionDeleteSelected } from "./actionDeleteSelected";
@@ -23,11 +24,31 @@ export const actionCopy = register({
commitToHistory: false, commitToHistory: false,
}; };
}, },
contextItemPredicate: (elements, appState, appProps, app) => {
return app.device.isMobile && !!navigator.clipboard;
},
contextItemLabel: "labels.copy", contextItemLabel: "labels.copy",
// don't supply a shortcut since we handle this conditionally via onCopy event // don't supply a shortcut since we handle this conditionally via onCopy event
keyTest: undefined, keyTest: undefined,
}); });
export const actionPaste = register({
name: "paste",
trackEvent: { category: "element" },
perform: (elements: any, appStates: any, data, app) => {
app.pasteFromClipboard(null);
return {
commitToHistory: false,
};
},
contextItemPredicate: (elements, appState, appProps, app) => {
return app.device.isMobile && !!navigator.clipboard;
},
contextItemLabel: "labels.paste",
// don't supply a shortcut since we handle this conditionally via onCopy event
keyTest: undefined,
});
export const actionCut = register({ export const actionCut = register({
name: "cut", name: "cut",
trackEvent: { category: "element" }, trackEvent: { category: "element" },
@@ -35,6 +56,9 @@ export const actionCut = register({
actionCopy.perform(elements, appState, data, app); actionCopy.perform(elements, appState, data, app);
return actionDeleteSelected.perform(elements, appState); return actionDeleteSelected.perform(elements, appState);
}, },
contextItemPredicate: (elements, appState, appProps, app) => {
return app.device.isMobile && !!navigator.clipboard;
},
contextItemLabel: "labels.cut", contextItemLabel: "labels.cut",
keyTest: (event) => event[KEYS.CTRL_OR_CMD] && event.key === KEYS.X, keyTest: (event) => event[KEYS.CTRL_OR_CMD] && event.key === KEYS.X,
}); });
@@ -77,6 +101,9 @@ export const actionCopyAsSvg = register({
}; };
} }
}, },
contextItemPredicate: (elements) => {
return probablySupportsClipboardWriteText && elements.length > 0;
},
contextItemLabel: "labels.copyAsSvg", contextItemLabel: "labels.copyAsSvg",
}); });
@@ -131,6 +158,9 @@ export const actionCopyAsPng = register({
}; };
} }
}, },
contextItemPredicate: (elements) => {
return probablySupportsClipboardBlob && elements.length > 0;
},
contextItemLabel: "labels.copyAsPng", contextItemLabel: "labels.copyAsPng",
keyTest: (event) => event.code === CODES.C && event.altKey && event.shiftKey, keyTest: (event) => event.code === CODES.C && event.altKey && event.shiftKey,
}); });
+15 -10
View File
@@ -19,7 +19,7 @@ import { ActiveFile } from "../components/ActiveFile";
import { isImageFileHandle } from "../data/blob"; import { isImageFileHandle } from "../data/blob";
import { nativeFileSystemSupported } from "../data/filesystem"; import { nativeFileSystemSupported } from "../data/filesystem";
import { Theme } from "../element/types"; import { Theme } from "../element/types";
import MenuItem from "../components/MenuItem"; import DropdownMenuItem from "../components/dropdownMenu/DropdownMenuItem";
import { getShortcutFromShortcutName } from "./shortcuts"; import { getShortcutFromShortcutName } from "./shortcuts";
export const actionChangeProjectName = register({ export const actionChangeProjectName = register({
@@ -179,6 +179,7 @@ export const actionSaveToActiveFile = register({
export const actionSaveFileToDisk = register({ export const actionSaveFileToDisk = register({
name: "saveFileToDisk", name: "saveFileToDisk",
viewMode: true,
trackEvent: { category: "export" }, trackEvent: { category: "export" },
perform: async (elements, appState, value, app) => { perform: async (elements, appState, value, app) => {
try { try {
@@ -246,15 +247,19 @@ export const actionLoadScene = register({
} }
}, },
keyTest: (event) => event[KEYS.CTRL_OR_CMD] && event.key === KEYS.O, keyTest: (event) => event[KEYS.CTRL_OR_CMD] && event.key === KEYS.O,
PanelComponent: ({ updateData }) => ( PanelComponent: ({ updateData }) => {
<MenuItem return (
label={t("buttons.load")} <DropdownMenuItem
icon={LoadIcon} icon={LoadIcon}
onClick={updateData} onSelect={updateData}
dataTestId="load-button" dataTestId="load-button"
shortcut={getShortcutFromShortcutName("loadScene")} shortcut={getShortcutFromShortcutName("loadScene")}
/> ariaLabel={t("buttons.load")}
), >
{t("buttons.load")}
</DropdownMenuItem>
);
},
}); });
export const actionExportWithDarkMode = register({ export const actionExportWithDarkMode = register({
+5 -11
View File
@@ -14,6 +14,7 @@ import {
} from "../element/bounds"; } from "../element/bounds";
import { isLinearElement } from "../element/typeChecks"; import { isLinearElement } from "../element/typeChecks";
import { LinearElementEditor } from "../element/linearElementEditor"; import { LinearElementEditor } from "../element/linearElementEditor";
import { KEYS } from "../keys";
const enableActionFlipHorizontal = ( const enableActionFlipHorizontal = (
elements: readonly ExcalidrawElement[], elements: readonly ExcalidrawElement[],
@@ -63,7 +64,8 @@ export const actionFlipVertical = register({
commitToHistory: true, commitToHistory: true,
}; };
}, },
keyTest: (event) => event.shiftKey && event.code === "KeyV", keyTest: (event) =>
event.shiftKey && event.code === "KeyV" && !event[KEYS.CTRL_OR_CMD],
contextItemLabel: "labels.flipVertical", contextItemLabel: "labels.flipVertical",
contextItemPredicate: (elements, appState) => contextItemPredicate: (elements, appState) =>
enableActionFlipVertical(elements, appState), enableActionFlipVertical(elements, appState),
@@ -151,11 +153,7 @@ const flipElement = (
let initialPointsCoords; let initialPointsCoords;
if (isLinearElement(element)) { if (isLinearElement(element)) {
initialPointsCoords = getElementPointsCoords( initialPointsCoords = getElementPointsCoords(element, element.points);
element,
element.points,
element.strokeSharpness,
);
} }
const initialElementAbsoluteCoords = getElementAbsoluteCoords(element); const initialElementAbsoluteCoords = getElementAbsoluteCoords(element);
@@ -213,11 +211,7 @@ const flipElement = (
// Adjusting origin because when a beizer curve path exceeds min/max points it offsets the origin. // Adjusting origin because when a beizer curve path exceeds min/max points it offsets the origin.
// There's still room for improvement since when the line roughness is > 1 // There's still room for improvement since when the line roughness is > 1
// we still have a small offset of the origin when fliipping the element. // we still have a small offset of the origin when fliipping the element.
const finalPointsCoords = getElementPointsCoords( const finalPointsCoords = getElementPointsCoords(element, element.points);
element,
element.points,
element.strokeSharpness,
);
const topLeftCoordsDiff = initialPointsCoords[0] - finalPointsCoords[0]; const topLeftCoordsDiff = initialPointsCoords[0] - finalPointsCoords[0];
const topRightCoordDiff = initialPointsCoords[2] - finalPointsCoords[2]; const topRightCoordDiff = initialPointsCoords[2] - finalPointsCoords[2];
+9 -5
View File
@@ -6,7 +6,7 @@ import { register } from "./register";
import { allowFullScreen, exitFullScreen, isFullScreen } from "../utils"; import { allowFullScreen, exitFullScreen, isFullScreen } from "../utils";
import { KEYS } from "../keys"; import { KEYS } from "../keys";
import { HelpButton } from "../components/HelpButton"; import { HelpButton } from "../components/HelpButton";
import MenuItem from "../components/MenuItem"; import DropdownMenuItem from "../components/dropdownMenu/DropdownMenuItem";
export const actionToggleCanvasMenu = register({ export const actionToggleCanvasMenu = register({
name: "toggleCanvasMenu", name: "toggleCanvasMenu",
@@ -56,6 +56,7 @@ export const actionToggleEditMenu = register({
export const actionFullScreen = register({ export const actionFullScreen = register({
name: "toggleFullScreen", name: "toggleFullScreen",
viewMode: true,
trackEvent: { category: "canvas", predicate: (appState) => !isFullScreen() }, trackEvent: { category: "canvas", predicate: (appState) => !isFullScreen() },
perform: () => { perform: () => {
if (!isFullScreen()) { if (!isFullScreen()) {
@@ -73,6 +74,7 @@ export const actionFullScreen = register({
export const actionShortcuts = register({ export const actionShortcuts = register({
name: "toggleShortcuts", name: "toggleShortcuts",
viewMode: true,
trackEvent: { category: "menu", action: "toggleHelpDialog" }, trackEvent: { category: "menu", action: "toggleHelpDialog" },
perform: (_elements, appState, _, { focusContainer }) => { perform: (_elements, appState, _, { focusContainer }) => {
if (appState.openDialog === "help") { if (appState.openDialog === "help") {
@@ -88,13 +90,15 @@ export const actionShortcuts = register({
}, },
PanelComponent: ({ updateData, isInHamburgerMenu }) => PanelComponent: ({ updateData, isInHamburgerMenu }) =>
isInHamburgerMenu ? ( isInHamburgerMenu ? (
<MenuItem <DropdownMenuItem
label={t("helpDialog.title")}
dataTestId="help-menu-item" dataTestId="help-menu-item"
icon={HelpIcon} icon={HelpIcon}
onClick={updateData} onSelect={updateData}
shortcut="?" shortcut="?"
/> ariaLabel={t("helpDialog.title")}
>
{t("helpDialog.title")}
</DropdownMenuItem>
) : ( ) : (
<HelpButton title={t("helpDialog.title")} onClick={updateData} /> <HelpButton title={t("helpDialog.title")} onClick={updateData} />
), ),
+1
View File
@@ -6,6 +6,7 @@ import { register } from "./register";
export const actionGoToCollaborator = register({ export const actionGoToCollaborator = register({
name: "goToCollaborator", name: "goToCollaborator",
viewMode: true,
trackEvent: { category: "collab" }, trackEvent: { category: "collab" },
perform: (_elements, appState, value) => { perform: (_elements, appState, value) => {
const point = value as Collaborator["pointer"]; const point = value as Collaborator["pointer"];
+58 -52
View File
@@ -42,6 +42,7 @@ import {
DEFAULT_FONT_FAMILY, DEFAULT_FONT_FAMILY,
DEFAULT_FONT_SIZE, DEFAULT_FONT_SIZE,
FONT_FAMILY, FONT_FAMILY,
ROUNDNESS,
VERTICAL_ALIGN, VERTICAL_ALIGN,
} from "../constants"; } from "../constants";
import { import {
@@ -57,7 +58,7 @@ import {
import { import {
isBoundToContainer, isBoundToContainer,
isLinearElement, isLinearElement,
isLinearElementType, isUsingAdaptiveRadius,
} from "../element/typeChecks"; } from "../element/typeChecks";
import { import {
Arrowhead, Arrowhead,
@@ -72,7 +73,7 @@ import { getLanguage, t } from "../i18n";
import { KEYS } from "../keys"; import { KEYS } from "../keys";
import { randomInteger } from "../random"; import { randomInteger } from "../random";
import { import {
canChangeSharpness, canChangeRoundness,
canHaveArrowheads, canHaveArrowheads,
getCommonAttributeOfSelectedElements, getCommonAttributeOfSelectedElements,
getSelectedElements, getSelectedElements,
@@ -816,16 +817,19 @@ export const actionChangeVerticalAlign = register({
value: VERTICAL_ALIGN.TOP, value: VERTICAL_ALIGN.TOP,
text: t("labels.alignTop"), text: t("labels.alignTop"),
icon: <TextAlignTopIcon theme={appState.theme} />, icon: <TextAlignTopIcon theme={appState.theme} />,
testId: "align-top",
}, },
{ {
value: VERTICAL_ALIGN.MIDDLE, value: VERTICAL_ALIGN.MIDDLE,
text: t("labels.centerVertically"), text: t("labels.centerVertically"),
icon: <TextAlignMiddleIcon theme={appState.theme} />, icon: <TextAlignMiddleIcon theme={appState.theme} />,
testId: "align-middle",
}, },
{ {
value: VERTICAL_ALIGN.BOTTOM, value: VERTICAL_ALIGN.BOTTOM,
text: t("labels.alignBottom"), text: t("labels.alignBottom"),
icon: <TextAlignBottomIcon theme={appState.theme} />, icon: <TextAlignBottomIcon theme={appState.theme} />,
testId: "align-bottom",
}, },
]} ]}
value={getFormValue(elements, appState, (element) => { value={getFormValue(elements, appState, (element) => {
@@ -845,69 +849,71 @@ export const actionChangeVerticalAlign = register({
}, },
}); });
export const actionChangeSharpness = register({ export const actionChangeRoundness = register({
name: "changeSharpness", name: "changeRoundness",
trackEvent: false, trackEvent: false,
perform: (elements, appState, value) => { perform: (elements, appState, value) => {
const targetElements = getTargetElements(
getNonDeletedElements(elements),
appState,
);
const shouldUpdateForNonLinearElements = targetElements.length
? targetElements.every((el) => !isLinearElement(el))
: !isLinearElementType(appState.activeTool.type);
const shouldUpdateForLinearElements = targetElements.length
? targetElements.every(isLinearElement)
: isLinearElementType(appState.activeTool.type);
return { return {
elements: changeProperty(elements, appState, (el) => elements: changeProperty(elements, appState, (el) =>
newElementWith(el, { newElementWith(el, {
strokeSharpness: value, roundness:
value === "round"
? {
type: isUsingAdaptiveRadius(el.type)
? ROUNDNESS.ADAPTIVE_RADIUS
: ROUNDNESS.PROPORTIONAL_RADIUS,
}
: null,
}), }),
), ),
appState: { appState: {
...appState, ...appState,
currentItemStrokeSharpness: shouldUpdateForNonLinearElements currentItemRoundness: value,
? value
: appState.currentItemStrokeSharpness,
currentItemLinearStrokeSharpness: shouldUpdateForLinearElements
? value
: appState.currentItemLinearStrokeSharpness,
}, },
commitToHistory: true, commitToHistory: true,
}; };
}, },
PanelComponent: ({ elements, appState, updateData }) => ( PanelComponent: ({ elements, appState, updateData }) => {
<fieldset> const targetElements = getTargetElements(
<legend>{t("labels.edges")}</legend> getNonDeletedElements(elements),
<ButtonIconSelect appState,
group="edges" );
options={[
{ const hasLegacyRoundness = targetElements.some(
value: "sharp", (el) => el.roundness?.type === ROUNDNESS.LEGACY,
text: t("labels.sharp"), );
icon: EdgeSharpIcon,
}, return (
{ <fieldset>
value: "round", <legend>{t("labels.edges")}</legend>
text: t("labels.round"), <ButtonIconSelect
icon: EdgeRoundIcon, group="edges"
}, options={[
]} {
value={getFormValue( value: "sharp",
elements, text: t("labels.sharp"),
appState, icon: EdgeSharpIcon,
(element) => element.strokeSharpness, },
(canChangeSharpness(appState.activeTool.type) && {
(isLinearElementType(appState.activeTool.type) value: "round",
? appState.currentItemLinearStrokeSharpness text: t("labels.round"),
: appState.currentItemStrokeSharpness)) || icon: EdgeRoundIcon,
null, },
)} ]}
onChange={(value) => updateData(value)} value={getFormValue(
/> elements,
</fieldset> appState,
), (element) =>
hasLegacyRoundness ? null : element.roundness ? "round" : "sharp",
(canChangeRoundness(appState.activeTool.type) &&
appState.currentItemRoundness) ||
null,
)}
onChange={(value) => updateData(value)}
/>
</fieldset>
);
},
}); });
export const actionChangeArrowhead = register({ export const actionChangeArrowhead = register({
+13 -1
View File
@@ -13,7 +13,11 @@ import {
DEFAULT_TEXT_ALIGN, DEFAULT_TEXT_ALIGN,
} from "../constants"; } from "../constants";
import { getBoundTextElement } from "../element/textElement"; import { getBoundTextElement } from "../element/textElement";
import { hasBoundTextElement } from "../element/typeChecks"; import {
hasBoundTextElement,
canApplyRoundnessTypeToElement,
getDefaultRoundnessTypeForElement,
} from "../element/typeChecks";
import { getSelectedElements } from "../scene"; import { getSelectedElements } from "../scene";
// `copiedStyles` is exported only for tests. // `copiedStyles` is exported only for tests.
@@ -77,6 +81,14 @@ export const actionPasteStyles = register({
fillStyle: elementStylesToCopyFrom?.fillStyle, fillStyle: elementStylesToCopyFrom?.fillStyle,
opacity: elementStylesToCopyFrom?.opacity, opacity: elementStylesToCopyFrom?.opacity,
roughness: elementStylesToCopyFrom?.roughness, roughness: elementStylesToCopyFrom?.roughness,
roundness: elementStylesToCopyFrom.roundness
? canApplyRoundnessTypeToElement(
elementStylesToCopyFrom.roundness.type,
element,
)
? elementStylesToCopyFrom.roundness
: getDefaultRoundnessTypeForElement(element)
: null,
}); });
if (isTextElement(newElement)) { if (isTextElement(newElement)) {
+4
View File
@@ -5,6 +5,7 @@ import { AppState } from "../types";
export const actionToggleGridMode = register({ export const actionToggleGridMode = register({
name: "gridMode", name: "gridMode",
viewMode: true,
trackEvent: { trackEvent: {
category: "canvas", category: "canvas",
predicate: (appState) => !appState.gridSize, predicate: (appState) => !appState.gridSize,
@@ -19,6 +20,9 @@ export const actionToggleGridMode = register({
}; };
}, },
checked: (appState: AppState) => appState.gridSize !== null, checked: (appState: AppState) => appState.gridSize !== null,
contextItemPredicate: (element, appState, props) => {
return typeof props.gridModeEnabled === "undefined";
},
contextItemLabel: "labels.showGrid", contextItemLabel: "labels.showGrid",
keyTest: (event) => event[KEYS.CTRL_OR_CMD] && event.code === CODES.QUOTE, keyTest: (event) => event[KEYS.CTRL_OR_CMD] && event.code === CODES.QUOTE,
}); });
+3 -9
View File
@@ -41,15 +41,9 @@ export const actionToggleLock = register({
: "labels.elementLock.lock"; : "labels.elementLock.lock";
} }
if (selected.length > 1) { return getOperation(selected) === "lock"
return getOperation(selected) === "lock" ? "labels.elementLock.lockAll"
? "labels.elementLock.lockAll" : "labels.elementLock.unlockAll";
: "labels.elementLock.unlockAll";
}
throw new Error(
"Unexpected zero elements to lock/unlock. This should never happen.",
);
}, },
keyTest: (event, appState, elements) => { keyTest: (event, appState, elements) => {
return ( return (
+1
View File
@@ -3,6 +3,7 @@ import { CODES, KEYS } from "../keys";
export const actionToggleStats = register({ export const actionToggleStats = register({
name: "stats", name: "stats",
viewMode: true,
trackEvent: { category: "menu" }, trackEvent: { category: "menu" },
perform(elements, appState) { perform(elements, appState) {
return { return {
+4
View File
@@ -3,6 +3,7 @@ import { register } from "./register";
export const actionToggleViewMode = register({ export const actionToggleViewMode = register({
name: "viewMode", name: "viewMode",
viewMode: true,
trackEvent: { trackEvent: {
category: "canvas", category: "canvas",
predicate: (appState) => !appState.viewModeEnabled, predicate: (appState) => !appState.viewModeEnabled,
@@ -17,6 +18,9 @@ export const actionToggleViewMode = register({
}; };
}, },
checked: (appState) => appState.viewModeEnabled, checked: (appState) => appState.viewModeEnabled,
contextItemPredicate: (elements, appState, appProps) => {
return typeof appProps.viewModeEnabled === "undefined";
},
contextItemLabel: "labels.viewMode", contextItemLabel: "labels.viewMode",
keyTest: (event) => keyTest: (event) =>
!event[KEYS.CTRL_OR_CMD] && event.altKey && event.code === CODES.R, !event[KEYS.CTRL_OR_CMD] && event.altKey && event.code === CODES.R,
+4
View File
@@ -3,6 +3,7 @@ import { register } from "./register";
export const actionToggleZenMode = register({ export const actionToggleZenMode = register({
name: "zenMode", name: "zenMode",
viewMode: true,
trackEvent: { trackEvent: {
category: "canvas", category: "canvas",
predicate: (appState) => !appState.zenModeEnabled, predicate: (appState) => !appState.zenModeEnabled,
@@ -17,6 +18,9 @@ export const actionToggleZenMode = register({
}; };
}, },
checked: (appState) => appState.zenModeEnabled, checked: (appState) => appState.zenModeEnabled,
contextItemPredicate: (elements, appState, appProps) => {
return typeof appProps.zenModeEnabled === "undefined";
},
contextItemLabel: "buttons.zenMode", contextItemLabel: "buttons.zenMode",
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 -6
View File
@@ -9,7 +9,6 @@ import {
} from "./types"; } from "./types";
import { ExcalidrawElement } from "../element/types"; import { ExcalidrawElement } from "../element/types";
import { AppClassProperties, AppState } from "../types"; import { AppClassProperties, AppState } from "../types";
import { MODES } from "../constants";
import { trackEvent } from "../analytics"; import { trackEvent } from "../analytics";
const trackAction = ( const trackAction = (
@@ -103,11 +102,8 @@ export class ActionManager {
const action = data[0]; const action = data[0];
const { viewModeEnabled } = this.getAppState(); if (this.getAppState().viewModeEnabled && action.viewMode !== true) {
if (viewModeEnabled) { return false;
if (!Object.values(MODES).includes(data[0].name)) {
return false;
}
} }
const elements = this.getElementsIncludingDeleted(); const elements = this.getElementsIncludingDeleted();
+1 -1
View File
@@ -48,7 +48,7 @@ const shortcutMap: Record<ShortcutName, string[]> = {
copyStyles: [getShortcutKey("CtrlOrCmd+Alt+C")], copyStyles: [getShortcutKey("CtrlOrCmd+Alt+C")],
pasteStyles: [getShortcutKey("CtrlOrCmd+Alt+V")], pasteStyles: [getShortcutKey("CtrlOrCmd+Alt+V")],
selectAll: [getShortcutKey("CtrlOrCmd+A")], selectAll: [getShortcutKey("CtrlOrCmd+A")],
deleteSelectedElements: [getShortcutKey("Del")], deleteSelectedElements: [getShortcutKey("Delete")],
duplicateSelection: [ duplicateSelection: [
getShortcutKey("CtrlOrCmd+D"), getShortcutKey("CtrlOrCmd+D"),
getShortcutKey(`Alt+${t("helpDialog.drag")}`), getShortcutKey(`Alt+${t("helpDialog.drag")}`),
+6 -1
View File
@@ -91,7 +91,7 @@ export type ActionName =
| "ungroup" | "ungroup"
| "goToCollaborator" | "goToCollaborator"
| "addToLibrary" | "addToLibrary"
| "changeSharpness" | "changeRoundness"
| "alignTop" | "alignTop"
| "alignBottom" | "alignBottom"
| "alignLeft" | "alignLeft"
@@ -143,6 +143,8 @@ export interface Action {
contextItemPredicate?: ( contextItemPredicate?: (
elements: readonly ExcalidrawElement[], elements: readonly ExcalidrawElement[],
appState: AppState, appState: AppState,
appProps: ExcalidrawProps,
app: AppClassProperties,
) => boolean; ) => boolean;
checked?: (appState: Readonly<AppState>) => boolean; checked?: (appState: Readonly<AppState>) => boolean;
trackEvent: trackEvent:
@@ -164,4 +166,7 @@ export interface Action {
value: any, value: any,
) => boolean; ) => boolean;
}; };
/** if set to `true`, allow action to be performed in viewMode.
* Defaults to `false` */
viewMode?: boolean;
} }
+4 -4
View File
@@ -28,12 +28,11 @@ export const getDefaultAppState = (): Omit<
currentItemFillStyle: "hachure", currentItemFillStyle: "hachure",
currentItemFontFamily: DEFAULT_FONT_FAMILY, currentItemFontFamily: DEFAULT_FONT_FAMILY,
currentItemFontSize: DEFAULT_FONT_SIZE, currentItemFontSize: DEFAULT_FONT_SIZE,
currentItemLinearStrokeSharpness: "round",
currentItemOpacity: 100, currentItemOpacity: 100,
currentItemRoughness: 1, currentItemRoughness: 1,
currentItemStartArrowhead: null, currentItemStartArrowhead: null,
currentItemStrokeColor: oc.black, currentItemStrokeColor: oc.black,
currentItemStrokeSharpness: "sharp", currentItemRoundness: "round",
currentItemStrokeStyle: "solid", currentItemStrokeStyle: "solid",
currentItemStrokeWidth: 1, currentItemStrokeWidth: 1,
currentItemTextAlign: DEFAULT_TEXT_ALIGN, currentItemTextAlign: DEFAULT_TEXT_ALIGN,
@@ -65,6 +64,7 @@ export const getDefaultAppState = (): Omit<
lastPointerDownWith: "mouse", lastPointerDownWith: "mouse",
multiElement: null, multiElement: null,
name: `${t("labels.untitled")}-${getDateTime()}`, name: `${t("labels.untitled")}-${getDateTime()}`,
contextMenu: null,
openMenu: null, openMenu: null,
openPopup: null, openPopup: null,
openSidebar: null, openSidebar: null,
@@ -120,7 +120,7 @@ const APP_STATE_STORAGE_CONF = (<
currentItemFillStyle: { browser: true, export: false, server: false }, currentItemFillStyle: { browser: true, export: false, server: false },
currentItemFontFamily: { browser: true, export: false, server: false }, currentItemFontFamily: { browser: true, export: false, server: false },
currentItemFontSize: { browser: true, export: false, server: false }, currentItemFontSize: { browser: true, export: false, server: false },
currentItemLinearStrokeSharpness: { currentItemRoundness: {
browser: true, browser: true,
export: false, export: false,
server: false, server: false,
@@ -129,7 +129,6 @@ const APP_STATE_STORAGE_CONF = (<
currentItemRoughness: { browser: true, export: false, server: false }, currentItemRoughness: { browser: true, export: false, server: false },
currentItemStartArrowhead: { browser: true, export: false, server: false }, currentItemStartArrowhead: { browser: true, export: false, server: false },
currentItemStrokeColor: { browser: true, export: false, server: false }, currentItemStrokeColor: { browser: true, export: false, server: false },
currentItemStrokeSharpness: { browser: true, export: false, server: false },
currentItemStrokeStyle: { browser: true, export: false, server: false }, currentItemStrokeStyle: { browser: true, export: false, server: false },
currentItemStrokeWidth: { browser: true, export: false, server: false }, currentItemStrokeWidth: { browser: true, export: false, server: false },
currentItemTextAlign: { browser: true, export: false, server: false }, currentItemTextAlign: { browser: true, export: false, server: false },
@@ -159,6 +158,7 @@ const APP_STATE_STORAGE_CONF = (<
name: { browser: true, export: false, server: false }, name: { browser: true, export: false, server: false },
offsetLeft: { browser: false, export: false, server: false }, offsetLeft: { browser: false, export: false, server: false },
offsetTop: { browser: false, export: false, server: false }, offsetTop: { browser: false, export: false, server: false },
contextMenu: { browser: false, export: false, server: false },
openMenu: { browser: true, export: false, server: false }, openMenu: { browser: true, export: false, server: false },
openPopup: { browser: false, export: false, server: false }, openPopup: { browser: false, export: false, server: false },
openSidebar: { browser: true, export: false, server: false }, openSidebar: { browser: true, export: false, server: false },
+2 -2
View File
@@ -172,7 +172,7 @@ const commonProps = {
opacity: 100, opacity: 100,
roughness: 1, roughness: 1,
strokeColor: colors.elementStroke[0], strokeColor: colors.elementStroke[0],
strokeSharpness: "sharp", roundness: null,
strokeStyle: "solid", strokeStyle: "solid",
strokeWidth: 1, strokeWidth: 1,
verticalAlign: VERTICAL_ALIGN.MIDDLE, verticalAlign: VERTICAL_ALIGN.MIDDLE,
@@ -322,7 +322,7 @@ const chartBaseElements = (
text: spreadsheet.title, text: spreadsheet.title,
x: x + chartWidth / 2, x: x + chartWidth / 2,
y: y - BAR_HEIGHT - BAR_GAP * 2 - DEFAULT_FONT_SIZE, y: y - BAR_HEIGHT - BAR_GAP * 2 - DEFAULT_FONT_SIZE,
strokeSharpness: "sharp", roundness: null,
strokeStyle: "solid", strokeStyle: "solid",
textAlign: "center", textAlign: "center",
}) })
+27
View File
@@ -0,0 +1,27 @@
import { parseClipboard } from "./clipboard";
describe("Test parseClipboard", () => {
it("should parse valid json correctly", async () => {
let text = "123";
let clipboardData = await parseClipboard({
//@ts-ignore
clipboardData: {
getData: () => text,
},
});
expect(clipboardData.text).toBe(text);
text = "[123]";
clipboardData = await parseClipboard({
//@ts-ignore
clipboardData: {
getData: () => text,
},
});
expect(clipboardData.text).toBe(text);
});
});
+26 -14
View File
@@ -109,16 +109,16 @@ const parsePotentialSpreadsheet = (
* Retrieves content from system clipboard (either from ClipboardEvent or * Retrieves content from system clipboard (either from ClipboardEvent or
* via async clipboard API if supported) * via async clipboard API if supported)
*/ */
const getSystemClipboard = async ( export const getSystemClipboard = async (
event: ClipboardEvent | null, event: ClipboardEvent | null,
): Promise<string> => { ): Promise<string> => {
try { try {
const text = event const text = event
? event.clipboardData?.getData("text/plain").trim() ? event.clipboardData?.getData("text/plain")
: probablySupportsClipboardReadText && : probablySupportsClipboardReadText &&
(await navigator.clipboard.readText()); (await navigator.clipboard.readText());
return text || ""; return (text || "").trim();
} catch { } catch {
return ""; return "";
} }
@@ -129,19 +129,25 @@ const getSystemClipboard = async (
*/ */
export const parseClipboard = async ( export const parseClipboard = async (
event: ClipboardEvent | null, event: ClipboardEvent | null,
isPlainPaste = false,
): Promise<ClipboardData> => { ): Promise<ClipboardData> => {
const systemClipboard = await getSystemClipboard(event); const systemClipboard = await getSystemClipboard(event);
// if system clipboard empty, couldn't be resolved, or contains previously // if system clipboard empty, couldn't be resolved, or contains previously
// copied excalidraw scene as SVG, fall back to previously copied excalidraw // copied excalidraw scene as SVG, fall back to previously copied excalidraw
// elements // elements
if (!systemClipboard || systemClipboard.includes(SVG_EXPORT_TAG)) { if (
!systemClipboard ||
(!isPlainPaste && systemClipboard.includes(SVG_EXPORT_TAG))
) {
return getAppClipboard(); return getAppClipboard();
} }
// if system clipboard contains spreadsheet, use it even though it's // if system clipboard contains spreadsheet, use it even though it's
// technically possible it's staler than in-app clipboard // technically possible it's staler than in-app clipboard
const spreadsheetResult = parsePotentialSpreadsheet(systemClipboard); const spreadsheetResult =
!isPlainPaste && parsePotentialSpreadsheet(systemClipboard);
if (spreadsheetResult) { if (spreadsheetResult) {
return spreadsheetResult; return spreadsheetResult;
} }
@@ -154,17 +160,23 @@ export const parseClipboard = async (
return { return {
elements: systemClipboardData.elements, elements: systemClipboardData.elements,
files: systemClipboardData.files, files: systemClipboardData.files,
text: isPlainPaste
? JSON.stringify(systemClipboardData.elements, null, 2)
: undefined,
}; };
} }
return appClipboardData; } catch (e) {}
} catch { // system clipboard doesn't contain excalidraw elements → return plaintext
// system clipboard doesn't contain excalidraw elements → return plaintext // unless we set a flag to prefer in-app clipboard because browser didn't
// unless we set a flag to prefer in-app clipboard because browser didn't // support storing to system clipboard on copy
// support storing to system clipboard on copy return PREFER_APP_CLIPBOARD && appClipboardData.elements
return PREFER_APP_CLIPBOARD && appClipboardData.elements ? {
? appClipboardData ...appClipboardData,
: { text: systemClipboard }; text: isPlainPaste
} ? JSON.stringify(appClipboardData.elements, null, 2)
: undefined,
}
: { text: systemClipboard };
}; };
export const copyBlobToClipboardAsPng = async (blob: Blob | Promise<Blob>) => { export const copyBlobToClipboardAsPng = async (blob: Blob | Promise<Blob>) => {
+12 -14
View File
@@ -5,7 +5,7 @@ import { ExcalidrawElement, PointerType } from "../element/types";
import { t } from "../i18n"; import { t } from "../i18n";
import { useDevice } from "../components/App"; import { useDevice } from "../components/App";
import { import {
canChangeSharpness, canChangeRoundness,
canHaveArrowheads, canHaveArrowheads,
getTargetElements, getTargetElements,
hasBackground, hasBackground,
@@ -25,11 +25,12 @@ import Stack from "./Stack";
import { ToolButton } from "./ToolButton"; import { ToolButton } from "./ToolButton";
import { hasStrokeColor } from "../scene/comparisons"; import { hasStrokeColor } from "../scene/comparisons";
import { trackEvent } from "../analytics"; import { trackEvent } from "../analytics";
import { hasBoundTextElement, isBoundToContainer } from "../element/typeChecks"; import { hasBoundTextElement } from "../element/typeChecks";
import clsx from "clsx"; import clsx from "clsx";
import { actionToggleZenMode } from "../actions"; import { actionToggleZenMode } from "../actions";
import "./Actions.scss"; import "./Actions.scss";
import { Tooltip } from "./Tooltip"; import { Tooltip } from "./Tooltip";
import { shouldAllowVerticalAlign } from "../element/textElement";
export const SelectedShapeActions = ({ export const SelectedShapeActions = ({
appState, appState,
@@ -109,9 +110,9 @@ export const SelectedShapeActions = ({
</> </>
)} )}
{(canChangeSharpness(appState.activeTool.type) || {(canChangeRoundness(appState.activeTool.type) ||
targetElements.some((element) => canChangeSharpness(element.type))) && ( targetElements.some((element) => canChangeRoundness(element.type))) && (
<>{renderAction("changeSharpness")}</> <>{renderAction("changeRoundness")}</>
)} )}
{(hasText(appState.activeTool.type) || {(hasText(appState.activeTool.type) ||
@@ -125,10 +126,8 @@ export const SelectedShapeActions = ({
</> </>
)} )}
{targetElements.some( {shouldAllowVerticalAlign(targetElements) &&
(element) => renderAction("changeVerticalAlign")}
hasBoundTextElement(element) || isBoundToContainer(element),
) && renderAction("changeVerticalAlign")}
{(canHaveArrowheads(appState.activeTool.type) || {(canHaveArrowheads(appState.activeTool.type) ||
targetElements.some((element) => canHaveArrowheads(element.type))) && ( targetElements.some((element) => canHaveArrowheads(element.type))) && (
<>{renderAction("changeArrowhead")}</> <>{renderAction("changeArrowhead")}</>
@@ -218,13 +217,12 @@ export const ShapesSwitcher = ({
appState: AppState; appState: AppState;
}) => ( }) => (
<> <>
{SHAPES.map(({ value, icon, key, fillable }, index) => { {SHAPES.map(({ value, icon, key, numericKey, fillable }, index) => {
const numberKey = value === "eraser" ? 0 : index + 1;
const label = t(`toolBar.${value}`); const label = t(`toolBar.${value}`);
const letter = key && (typeof key === "string" ? key : key[0]); const letter = key && (typeof key === "string" ? key : key[0]);
const shortcut = letter const shortcut = letter
? `${capitalizeString(letter)} ${t("helpDialog.or")} ${numberKey}` ? `${capitalizeString(letter)} ${t("helpDialog.or")} ${numericKey}`
: `${numberKey}`; : `${numericKey}`;
return ( return (
<ToolButton <ToolButton
className={clsx("Shape", { fillable })} className={clsx("Shape", { fillable })}
@@ -234,7 +232,7 @@ export const ShapesSwitcher = ({
checked={activeTool.type === value} checked={activeTool.type === value}
name="editor-current-shape" name="editor-current-shape"
title={`${capitalizeString(label)}${shortcut}`} title={`${capitalizeString(label)}${shortcut}`}
keyBindingLabel={`${numberKey}`} keyBindingLabel={numericKey}
aria-label={capitalizeString(label)} aria-label={capitalizeString(label)}
aria-keyshortcuts={shortcut} aria-keyshortcuts={shortcut}
data-testid={`toolbar-${value}`} data-testid={`toolbar-${value}`}
+5 -5
View File
@@ -5,7 +5,7 @@ import { save } from "../components/icons";
import { t } from "../i18n"; import { t } from "../i18n";
import "./ActiveFile.scss"; import "./ActiveFile.scss";
import MenuItem from "./MenuItem"; import DropdownMenuItem from "./dropdownMenu/DropdownMenuItem";
type ActiveFileProps = { type ActiveFileProps = {
fileName?: string; fileName?: string;
@@ -13,11 +13,11 @@ type ActiveFileProps = {
}; };
export const ActiveFile = ({ fileName, onSave }: ActiveFileProps) => ( export const ActiveFile = ({ fileName, onSave }: ActiveFileProps) => (
<MenuItem <DropdownMenuItem
label={`${t("buttons.save")}`}
shortcut={getShortcutFromShortcutName("saveScene")} shortcut={getShortcutFromShortcutName("saveScene")}
dataTestId="save-button" dataTestId="save-button"
onClick={onSave} onSelect={onSave}
icon={save} icon={save}
/> ariaLabel={`${t("buttons.save")}`}
>{`${t("buttons.save")}`}</DropdownMenuItem>
); );
+550 -449
View File
File diff suppressed because it is too large Load Diff
+12 -1
View File
@@ -4,8 +4,8 @@
.Avatar { .Avatar {
width: 1.25rem; width: 1.25rem;
height: 1.25rem; height: 1.25rem;
position: relative;
border-radius: 100%; border-radius: 100%;
outline: 2px solid var(--avatar-border-color);
outline-offset: 2px; outline-offset: 2px;
display: flex; display: flex;
justify-content: center; justify-content: center;
@@ -21,5 +21,16 @@
height: 100%; height: 100%;
border-radius: 100%; border-radius: 100%;
} }
&::before {
content: "";
position: absolute;
top: -3px;
right: -3px;
bottom: -3px;
left: -3px;
border: 1px solid var(--avatar-border-color);
border-radius: 100%;
}
} }
} }
+7 -5
View File
@@ -3,7 +3,7 @@ import { t } from "../i18n";
import { TrashIcon } from "./icons"; import { TrashIcon } from "./icons";
import ConfirmDialog from "./ConfirmDialog"; import ConfirmDialog from "./ConfirmDialog";
import MenuItem from "./MenuItem"; import DropdownMenuItem from "./dropdownMenu/DropdownMenuItem";
const ClearCanvas = ({ onConfirm }: { onConfirm: () => void }) => { const ClearCanvas = ({ onConfirm }: { onConfirm: () => void }) => {
const [showDialog, setShowDialog] = useState(false); const [showDialog, setShowDialog] = useState(false);
@@ -13,12 +13,14 @@ const ClearCanvas = ({ onConfirm }: { onConfirm: () => void }) => {
return ( return (
<> <>
<MenuItem <DropdownMenuItem
label={t("buttons.clearReset")}
icon={TrashIcon} icon={TrashIcon}
onClick={toggleDialog} onSelect={toggleDialog}
dataTestId="clear-canvas-button" dataTestId="clear-canvas-button"
/> ariaLabel={t("buttons.clearReset")}
>
{t("buttons.clearReset")}
</DropdownMenuItem>
{showDialog && ( {showDialog && (
<ConfirmDialog <ConfirmDialog
+7 -6
View File
@@ -2,7 +2,7 @@ import { t } from "../i18n";
import { UsersIcon } from "./icons"; import { UsersIcon } from "./icons";
import "./CollabButton.scss"; import "./CollabButton.scss";
import MenuItem from "./MenuItem"; import DropdownMenuItem from "./dropdownMenu/DropdownMenuItem";
import clsx from "clsx"; import clsx from "clsx";
const CollabButton = ({ const CollabButton = ({
@@ -19,13 +19,14 @@ const CollabButton = ({
return ( return (
<> <>
{isInHamburgerMenu ? ( {isInHamburgerMenu ? (
<MenuItem <DropdownMenuItem
label={t("labels.liveCollaboration")}
dataTestId="collab-button" dataTestId="collab-button"
icon={UsersIcon} icon={UsersIcon}
onClick={onClick} onSelect={onClick}
isCollaborating={isCollaborating} ariaLabel={t("labels.liveCollaboration")}
/> >
{t("labels.liveCollaboration")}
</DropdownMenuItem>
) : ( ) : (
<button <button
className={clsx("collab-button", { active: isCollaborating })} className={clsx("collab-button", { active: isCollaborating })}
+6 -3
View File
@@ -66,10 +66,13 @@ const getColor = (color: string): string | null => {
return color; return color;
} }
return isValidColor(color) // testing for `#` first fixes a bug on Electron (more specfically, an
? color // Obsidian popout window), where a hex color without `#` is (incorrectly)
: isValidColor(`#${color}`) // considered valid
return isValidColor(`#${color}`)
? `#${color}` ? `#${color}`
: isValidColor(color)
? color
: null; : null;
}; };
+8 -9
View File
@@ -3,9 +3,9 @@ import { Dialog, DialogProps } from "./Dialog";
import "./ConfirmDialog.scss"; import "./ConfirmDialog.scss";
import DialogActionButton from "./DialogActionButton"; import DialogActionButton from "./DialogActionButton";
import { isMenuOpenAtom } from "./App";
import { isDropdownOpenAtom } from "./App";
import { useSetAtom } from "jotai"; import { useSetAtom } from "jotai";
import { isLibraryMenuOpenAtom } from "./LibraryMenuHeaderContent";
import { useExcalidrawSetAppState } from "./App";
interface Props extends Omit<DialogProps, "onCloseRequest"> { interface Props extends Omit<DialogProps, "onCloseRequest"> {
onConfirm: () => void; onConfirm: () => void;
@@ -23,9 +23,8 @@ const ConfirmDialog = (props: Props) => {
className = "", className = "",
...rest ...rest
} = props; } = props;
const setAppState = useExcalidrawSetAppState();
const setIsMenuOpen = useSetAtom(isMenuOpenAtom); const setIsLibraryMenuOpen = useSetAtom(isLibraryMenuOpenAtom);
const setIsDropdownOpen = useSetAtom(isDropdownOpenAtom);
return ( return (
<Dialog <Dialog
@@ -39,16 +38,16 @@ const ConfirmDialog = (props: Props) => {
<DialogActionButton <DialogActionButton
label={cancelText} label={cancelText}
onClick={() => { onClick={() => {
setIsMenuOpen(false); setAppState({ openMenu: null });
setIsDropdownOpen(false); setIsLibraryMenuOpen(false);
onCancel(); onCancel();
}} }}
/> />
<DialogActionButton <DialogActionButton
label={confirmText} label={confirmText}
onClick={() => { onClick={() => {
setIsMenuOpen(false); setAppState({ openMenu: null });
setIsDropdownOpen(false); setIsLibraryMenuOpen(false);
onConfirm(); onConfirm();
}} }}
actionType="danger" actionType="danger"
+11 -11
View File
@@ -19,7 +19,7 @@
color: var(--popup-text-color); color: var(--popup-text-color);
} }
.context-menu-option { .context-menu-item {
position: relative; position: relative;
width: 100%; width: 100%;
min-width: 9.5rem; min-width: 9.5rem;
@@ -43,16 +43,16 @@
} }
&.dangerous { &.dangerous {
.context-menu-option__label { .context-menu-item__label {
color: $oc-red-7; color: $oc-red-7;
} }
} }
.context-menu-option__label { .context-menu-item__label {
justify-self: start; justify-self: start;
margin-inline-end: 20px; margin-inline-end: 20px;
} }
.context-menu-option__shortcut { .context-menu-item__shortcut {
justify-self: end; justify-self: end;
opacity: 0.6; opacity: 0.6;
font-family: inherit; font-family: inherit;
@@ -60,37 +60,37 @@
} }
} }
.context-menu-option:hover { .context-menu-item:hover {
color: var(--popup-bg-color); color: var(--popup-bg-color);
background-color: var(--select-highlight-color); background-color: var(--select-highlight-color);
&.dangerous { &.dangerous {
.context-menu-option__label { .context-menu-item__label {
color: var(--popup-bg-color); color: var(--popup-bg-color);
} }
background-color: $oc-red-6; background-color: $oc-red-6;
} }
} }
.context-menu-option:focus { .context-menu-item:focus {
z-index: 1; z-index: 1;
} }
@include isMobile { @include isMobile {
.context-menu-option { .context-menu-item {
display: block; display: block;
.context-menu-option__label { .context-menu-item__label {
margin-inline-end: 0; margin-inline-end: 0;
} }
.context-menu-option__shortcut { .context-menu-item__shortcut {
display: none; display: none;
} }
} }
} }
.context-menu-option-separator { .context-menu-item-separator {
border: none; border: none;
border-top: 1px solid $oc-gray-5; border-top: 1px solid $oc-gray-5;
} }
+102 -127
View File
@@ -1,4 +1,3 @@
import { render, unmountComponentAtNode } from "react-dom";
import clsx from "clsx"; import clsx from "clsx";
import { Popover } from "./Popover"; import { Popover } from "./Popover";
import { t } from "../i18n"; import { t } from "../i18n";
@@ -10,140 +9,116 @@ import {
} from "../actions/shortcuts"; } from "../actions/shortcuts";
import { Action } from "../actions/types"; import { Action } from "../actions/types";
import { ActionManager } from "../actions/manager"; import { ActionManager } from "../actions/manager";
import { AppState } from "../types"; import {
import { NonDeletedExcalidrawElement } from "../element/types"; useExcalidrawAppState,
useExcalidrawElements,
useExcalidrawSetAppState,
} from "./App";
import React from "react";
export type ContextMenuOption = "separator" | Action; export type ContextMenuItem = typeof CONTEXT_MENU_SEPARATOR | Action;
export type ContextMenuItems = (ContextMenuItem | false | null | undefined)[];
type ContextMenuProps = { type ContextMenuProps = {
options: ContextMenuOption[]; actionManager: ActionManager;
onCloseRequest?(): void; items: ContextMenuItems;
top: number; top: number;
left: number; left: number;
actionManager: ActionManager;
appState: Readonly<AppState>;
elements: readonly NonDeletedExcalidrawElement[];
}; };
const ContextMenu = ({ export const CONTEXT_MENU_SEPARATOR = "separator";
options,
onCloseRequest,
top,
left,
actionManager,
appState,
elements,
}: ContextMenuProps) => {
return (
<Popover
onCloseRequest={onCloseRequest}
top={top}
left={left}
fitInViewport={true}
offsetLeft={appState.offsetLeft}
offsetTop={appState.offsetTop}
viewportWidth={appState.width}
viewportHeight={appState.height}
>
<ul
className="context-menu"
onContextMenu={(event) => event.preventDefault()}
>
{options.map((option, idx) => {
if (option === "separator") {
return <hr key={idx} className="context-menu-option-separator" />;
}
const actionName = option.name; export const ContextMenu = React.memo(
let label = ""; ({ actionManager, items, top, left }: ContextMenuProps) => {
if (option.contextItemLabel) { const appState = useExcalidrawAppState();
if (typeof option.contextItemLabel === "function") { const setAppState = useExcalidrawSetAppState();
label = t(option.contextItemLabel(elements, appState)); const elements = useExcalidrawElements();
} else {
label = t(option.contextItemLabel);
}
}
return (
<li key={idx} data-testid={actionName} onClick={onCloseRequest}>
<button
className={clsx("context-menu-option", {
dangerous: actionName === "deleteSelectedElements",
checkmark: option.checked?.(appState),
})}
onClick={() =>
actionManager.executeAction(option, "contextMenu")
}
>
<div className="context-menu-option__label">{label}</div>
<kbd className="context-menu-option__shortcut">
{actionName
? getShortcutFromShortcutName(actionName as ShortcutName)
: ""}
</kbd>
</button>
</li>
);
})}
</ul>
</Popover>
);
};
const contextMenuNodeByContainer = new WeakMap<HTMLElement, HTMLDivElement>(); const filteredItems = items.reduce((acc: ContextMenuItem[], item) => {
if (
const getContextMenuNode = (container: HTMLElement): HTMLDivElement => { item &&
let contextMenuNode = contextMenuNodeByContainer.get(container); (item === CONTEXT_MENU_SEPARATOR ||
if (contextMenuNode) { !item.contextItemPredicate ||
return contextMenuNode; item.contextItemPredicate(
} elements,
contextMenuNode = document.createElement("div"); appState,
container actionManager.app.props,
.querySelector(".excalidraw-contextMenuContainer")! actionManager.app,
.appendChild(contextMenuNode); ))
contextMenuNodeByContainer.set(container, contextMenuNode); ) {
return contextMenuNode; acc.push(item);
};
type ContextMenuParams = {
options: (ContextMenuOption | false | null | undefined)[];
top: ContextMenuProps["top"];
left: ContextMenuProps["left"];
actionManager: ContextMenuProps["actionManager"];
appState: Readonly<AppState>;
container: HTMLElement;
elements: readonly NonDeletedExcalidrawElement[];
};
const handleClose = (container: HTMLElement) => {
const contextMenuNode = contextMenuNodeByContainer.get(container);
if (contextMenuNode) {
unmountComponentAtNode(contextMenuNode);
contextMenuNode.remove();
contextMenuNodeByContainer.delete(container);
}
};
export default {
push(params: ContextMenuParams) {
const options = Array.of<ContextMenuOption>();
params.options.forEach((option) => {
if (option) {
options.push(option);
} }
}); return acc;
if (options.length) { }, []);
render(
<ContextMenu return (
top={params.top} <Popover
left={params.left} onCloseRequest={() => setAppState({ contextMenu: null })}
options={options} top={top}
onCloseRequest={() => handleClose(params.container)} left={left}
actionManager={params.actionManager} fitInViewport={true}
appState={params.appState} offsetLeft={appState.offsetLeft}
elements={params.elements} offsetTop={appState.offsetTop}
/>, viewportWidth={appState.width}
getContextMenuNode(params.container), viewportHeight={appState.height}
); >
} <ul
className="context-menu"
onContextMenu={(event) => event.preventDefault()}
>
{filteredItems.map((item, idx) => {
if (item === CONTEXT_MENU_SEPARATOR) {
if (
!filteredItems[idx - 1] ||
filteredItems[idx - 1] === CONTEXT_MENU_SEPARATOR
) {
return null;
}
return <hr key={idx} className="context-menu-item-separator" />;
}
const actionName = item.name;
let label = "";
if (item.contextItemLabel) {
if (typeof item.contextItemLabel === "function") {
label = t(item.contextItemLabel(elements, appState));
} else {
label = t(item.contextItemLabel);
}
}
return (
<li
key={idx}
data-testid={actionName}
onClick={() => {
// we need update state before executing the action in case
// the action uses the appState it's being passed (that still
// contains a defined contextMenu) to return the next state.
setAppState({ contextMenu: null }, () => {
actionManager.executeAction(item, "contextMenu");
});
}}
>
<button
className={clsx("context-menu-item", {
dangerous: actionName === "deleteSelectedElements",
checkmark: item.checked?.(appState),
})}
>
<div className="context-menu-item__label">{label}</div>
<kbd className="context-menu-item__shortcut">
{actionName
? getShortcutFromShortcutName(actionName as ShortcutName)
: ""}
</kbd>
</button>
</li>
);
})}
</ul>
</Popover>
);
}, },
}; );
+10 -6
View File
@@ -2,7 +2,11 @@ import clsx from "clsx";
import React, { useEffect, useState } from "react"; import React, { useEffect, useState } from "react";
import { useCallbackRefState } from "../hooks/useCallbackRefState"; import { useCallbackRefState } from "../hooks/useCallbackRefState";
import { t } from "../i18n"; import { t } from "../i18n";
import { useExcalidrawContainer, useDevice } from "../components/App"; import {
useExcalidrawContainer,
useDevice,
useExcalidrawSetAppState,
} from "../components/App";
import { KEYS } from "../keys"; import { KEYS } from "../keys";
import "./Dialog.scss"; import "./Dialog.scss";
import { back, CloseIcon } from "./icons"; import { back, CloseIcon } from "./icons";
@@ -10,8 +14,8 @@ import { Island } from "./Island";
import { Modal } from "./Modal"; import { Modal } from "./Modal";
import { AppState } from "../types"; import { AppState } from "../types";
import { queryFocusableElements } from "../utils"; import { queryFocusableElements } from "../utils";
import { isMenuOpenAtom, isDropdownOpenAtom } from "./App";
import { useSetAtom } from "jotai"; import { useSetAtom } from "jotai";
import { isLibraryMenuOpenAtom } from "./LibraryMenuHeaderContent";
export interface DialogProps { export interface DialogProps {
children: React.ReactNode; children: React.ReactNode;
@@ -67,12 +71,12 @@ export const Dialog = (props: DialogProps) => {
return () => islandNode.removeEventListener("keydown", handleKeyDown); return () => islandNode.removeEventListener("keydown", handleKeyDown);
}, [islandNode, props.autofocus]); }, [islandNode, props.autofocus]);
const setIsMenuOpen = useSetAtom(isMenuOpenAtom); const setAppState = useExcalidrawSetAppState();
const setIsDropdownOpen = useSetAtom(isDropdownOpenAtom); const setIsLibraryMenuOpen = useSetAtom(isLibraryMenuOpenAtom);
const onClose = () => { const onClose = () => {
setIsMenuOpen(false); setAppState({ openMenu: null });
setIsDropdownOpen(false); setIsLibraryMenuOpen(false);
(lastActiveElement as HTMLElement).focus(); (lastActiveElement as HTMLElement).focus();
props.onCloseRequest(); props.onCloseRequest();
}; };
+50 -15
View File
@@ -1,6 +1,6 @@
import React from "react"; import React from "react";
import { t } from "../i18n"; import { t } from "../i18n";
import { isDarwin, isWindows } from "../keys"; import { isDarwin, isWindows, KEYS } from "../keys";
import { Dialog } from "./Dialog"; import { Dialog } from "./Dialog";
import { getShortcutKey } from "../utils"; import { getShortcutKey } from "../utils";
import "./HelpDialog.scss"; import "./HelpDialog.scss";
@@ -118,26 +118,49 @@ export const HelpDialog = ({ onClose }: { onClose?: () => void }) => {
className="HelpDialog__island--tools" className="HelpDialog__island--tools"
caption={t("helpDialog.tools")} caption={t("helpDialog.tools")}
> >
<Shortcut label={t("toolBar.selection")} shortcuts={["V", "1"]} /> <Shortcut
<Shortcut label={t("toolBar.rectangle")} shortcuts={["R", "2"]} /> label={t("toolBar.selection")}
<Shortcut label={t("toolBar.diamond")} shortcuts={["D", "3"]} /> shortcuts={[KEYS.V, KEYS["1"]]}
<Shortcut label={t("toolBar.ellipse")} shortcuts={["O", "4"]} /> />
<Shortcut label={t("toolBar.arrow")} shortcuts={["A", "5"]} /> <Shortcut
<Shortcut label={t("toolBar.line")} shortcuts={["P", "6"]} /> label={t("toolBar.rectangle")}
shortcuts={[KEYS.R, KEYS["2"]]}
/>
<Shortcut
label={t("toolBar.diamond")}
shortcuts={[KEYS.D, KEYS["3"]]}
/>
<Shortcut
label={t("toolBar.ellipse")}
shortcuts={[KEYS.O, KEYS["4"]]}
/>
<Shortcut
label={t("toolBar.arrow")}
shortcuts={[KEYS.A, KEYS["5"]]}
/>
<Shortcut
label={t("toolBar.line")}
shortcuts={[KEYS.L, KEYS["6"]]}
/>
<Shortcut <Shortcut
label={t("toolBar.freedraw")} label={t("toolBar.freedraw")}
shortcuts={["Shift + P", "X", "7"]} shortcuts={[KEYS.P, KEYS["7"]]}
/> />
<Shortcut label={t("toolBar.text")} shortcuts={["T", "8"]} /> <Shortcut
<Shortcut label={t("toolBar.image")} shortcuts={["9"]} /> label={t("toolBar.text")}
<Shortcut label={t("toolBar.library")} shortcuts={["0"]} /> shortcuts={[KEYS.T, KEYS["8"]]}
/>
<Shortcut label={t("toolBar.image")} shortcuts={[KEYS["9"]]} />
<Shortcut <Shortcut
label={t("toolBar.eraser")} label={t("toolBar.eraser")}
shortcuts={[getShortcutKey("E")]} shortcuts={[KEYS.E, KEYS["0"]]}
/> />
<Shortcut <Shortcut
label={t("helpDialog.editSelectedShape")} label={t("helpDialog.editSelectedShape")}
shortcuts={[getShortcutKey("Enter"), t("helpDialog.doubleClick")]} shortcuts={[
getShortcutKey("CtrlOrCmd+Enter"),
getShortcutKey(`CtrlOrCmd + ${t("helpDialog.doubleClick")}`),
]}
/> />
<Shortcut <Shortcut
label={t("helpDialog.textNewLine")} label={t("helpDialog.textNewLine")}
@@ -173,7 +196,7 @@ export const HelpDialog = ({ onClose }: { onClose?: () => void }) => {
]} ]}
isOr={false} isOr={false}
/> />
<Shortcut label={t("toolBar.lock")} shortcuts={["Q"]} /> <Shortcut label={t("toolBar.lock")} shortcuts={[KEYS.Q]} />
<Shortcut <Shortcut
label={t("helpDialog.preventBinding")} label={t("helpDialog.preventBinding")}
shortcuts={[getShortcutKey("CtrlOrCmd")]} shortcuts={[getShortcutKey("CtrlOrCmd")]}
@@ -207,6 +230,14 @@ export const HelpDialog = ({ onClose }: { onClose?: () => void }) => {
label={t("helpDialog.zoomToSelection")} label={t("helpDialog.zoomToSelection")}
shortcuts={["Shift+2"]} shortcuts={["Shift+2"]}
/> />
<Shortcut
label={t("helpDialog.movePageUpDown")}
shortcuts={["PgUp/PgDn"]}
/>
<Shortcut
label={t("helpDialog.movePageLeftRight")}
shortcuts={["Shift+PgUp/PgDn"]}
/>
<Shortcut label={t("buttons.fullScreen")} shortcuts={["F"]} /> <Shortcut label={t("buttons.fullScreen")} shortcuts={["F"]} />
<Shortcut <Shortcut
label={t("buttons.zenMode")} label={t("buttons.zenMode")}
@@ -269,6 +300,10 @@ export const HelpDialog = ({ onClose }: { onClose?: () => void }) => {
label={t("labels.paste")} label={t("labels.paste")}
shortcuts={[getShortcutKey("CtrlOrCmd+V")]} shortcuts={[getShortcutKey("CtrlOrCmd+V")]}
/> />
<Shortcut
label={t("labels.pasteAsPlaintext")}
shortcuts={[getShortcutKey("CtrlOrCmd+Shift+V")]}
/>
<Shortcut <Shortcut
label={t("labels.copyAsPng")} label={t("labels.copyAsPng")}
shortcuts={[getShortcutKey("Shift+Alt+C")]} shortcuts={[getShortcutKey("Shift+Alt+C")]}
@@ -283,7 +318,7 @@ export const HelpDialog = ({ onClose }: { onClose?: () => void }) => {
/> />
<Shortcut <Shortcut
label={t("labels.delete")} label={t("labels.delete")}
shortcuts={[getShortcutKey("Del")]} shortcuts={[getShortcutKey("Delete")]}
/> />
<Shortcut <Shortcut
label={t("labels.sendToBack")} label={t("labels.sendToBack")}
+7 -18
View File
@@ -1,9 +1,7 @@
import React, { useEffect, useRef, useState } from "react"; import React, { useEffect, useRef, useState } from "react";
import { render, unmountComponentAtNode } from "react-dom";
import { probablySupportsClipboardBlob } from "../clipboard"; import { probablySupportsClipboardBlob } from "../clipboard";
import { canvasToBlob } from "../data/blob"; import { canvasToBlob } from "../data/blob";
import { NonDeletedExcalidrawElement } from "../element/types"; import { NonDeletedExcalidrawElement } from "../element/types";
import { CanvasError } from "../errors";
import { t } from "../i18n"; import { t } from "../i18n";
import { getSelectedElements, isSomeElementSelected } from "../scene"; import { getSelectedElements, isSomeElementSelected } from "../scene";
import { exportToCanvas } from "../scene/export"; import { exportToCanvas } from "../scene/export";
@@ -33,19 +31,6 @@ export const ErrorCanvasPreview = () => {
); );
}; };
const renderPreview = (
content: HTMLCanvasElement | Error,
previewNode: HTMLDivElement,
) => {
unmountComponentAtNode(previewNode);
previewNode.innerHTML = "";
if (content instanceof HTMLCanvasElement) {
previewNode.appendChild(content);
} else {
render(<ErrorCanvasPreview />, previewNode);
}
};
export type ExportCB = ( export type ExportCB = (
elements: readonly NonDeletedExcalidrawElement[], elements: readonly NonDeletedExcalidrawElement[],
scale?: number, scale?: number,
@@ -99,6 +84,7 @@ const ImageExportModal = ({
const [exportSelected, setExportSelected] = useState(someElementIsSelected); const [exportSelected, setExportSelected] = useState(someElementIsSelected);
const previewRef = useRef<HTMLDivElement>(null); const previewRef = useRef<HTMLDivElement>(null);
const { exportBackground, viewBackgroundColor } = appState; const { exportBackground, viewBackgroundColor } = appState;
const [renderError, setRenderError] = useState<Error | null>(null);
const exportedElements = exportSelected const exportedElements = exportSelected
? getSelectedElements(elements, appState, true) ? getSelectedElements(elements, appState, true)
@@ -119,15 +105,16 @@ const ImageExportModal = ({
exportPadding, exportPadding,
}) })
.then((canvas) => { .then((canvas) => {
setRenderError(null);
// if converting to blob fails, there's some problem that will // if converting to blob fails, there's some problem that will
// likely prevent preview and export (e.g. canvas too big) // likely prevent preview and export (e.g. canvas too big)
return canvasToBlob(canvas).then(() => { return canvasToBlob(canvas).then(() => {
renderPreview(canvas, previewNode); previewNode.replaceChildren(canvas);
}); });
}) })
.catch((error) => { .catch((error) => {
console.error(error); console.error(error);
renderPreview(new CanvasError(), previewNode); setRenderError(error);
}); });
}, [ }, [
appState, appState,
@@ -140,7 +127,9 @@ const ImageExportModal = ({
return ( return (
<div className="ExportDialog"> <div className="ExportDialog">
<div className="ExportDialog__preview" ref={previewRef} /> <div className="ExportDialog__preview" ref={previewRef}>
{renderError && <ErrorCanvasPreview />}
</div>
{supportsContextFilters && {supportsContextFilters &&
actionManager.renderAction("exportWithDarkMode")} actionManager.renderAction("exportWithDarkMode")}
<div style={{ display: "grid", gridTemplateColumns: "1fr" }}> <div style={{ display: "grid", gridTemplateColumns: "1fr" }}>
+7 -16
View File
@@ -1,10 +1,10 @@
import React, { useState } from "react"; import React from "react";
import { NonDeletedExcalidrawElement } from "../element/types"; import { NonDeletedExcalidrawElement } from "../element/types";
import { t } from "../i18n"; import { t } from "../i18n";
import { AppState, ExportOpts, BinaryFiles } from "../types"; import { AppState, ExportOpts, BinaryFiles } from "../types";
import { Dialog } from "./Dialog"; import { Dialog } from "./Dialog";
import { ExportIcon, exportToFileIcon, LinkIcon } from "./icons"; import { exportToFileIcon, LinkIcon } from "./icons";
import { ToolButton } from "./ToolButton"; import { ToolButton } from "./ToolButton";
import { actionSaveFileToDisk } from "../actions/actionExport"; import { actionSaveFileToDisk } from "../actions/actionExport";
import { Card } from "./Card"; import { Card } from "./Card";
@@ -14,7 +14,6 @@ import { nativeFileSystemSupported } from "../data/filesystem";
import { trackEvent } from "../analytics"; import { trackEvent } from "../analytics";
import { ActionManager } from "../actions/manager"; import { ActionManager } from "../actions/manager";
import { getFrame } from "../utils"; import { getFrame } from "../utils";
import MenuItem from "./MenuItem";
export type ExportCB = ( export type ExportCB = (
elements: readonly NonDeletedExcalidrawElement[], elements: readonly NonDeletedExcalidrawElement[],
@@ -94,6 +93,7 @@ export const JSONExportDialog = ({
actionManager, actionManager,
exportOpts, exportOpts,
canvas, canvas,
setAppState,
}: { }: {
elements: readonly NonDeletedExcalidrawElement[]; elements: readonly NonDeletedExcalidrawElement[];
appState: AppState; appState: AppState;
@@ -101,24 +101,15 @@ export const JSONExportDialog = ({
actionManager: ActionManager; actionManager: ActionManager;
exportOpts: ExportOpts; exportOpts: ExportOpts;
canvas: HTMLCanvasElement | null; canvas: HTMLCanvasElement | null;
setAppState: React.Component<any, AppState>["setState"];
}) => { }) => {
const [modalIsShown, setModalIsShown] = useState(false);
const handleClose = React.useCallback(() => { const handleClose = React.useCallback(() => {
setModalIsShown(false); setAppState({ openDialog: null });
}, []); }, [setAppState]);
return ( return (
<> <>
<MenuItem {appState.openDialog === "jsonExport" && (
icon={ExportIcon}
label={t("buttons.export")}
onClick={() => {
setModalIsShown(true);
}}
dataTestId="json-export-button"
/>
{modalIsShown && (
<Dialog onCloseRequest={handleClose} title={t("buttons.export")}> <Dialog onCloseRequest={handleClose} title={t("buttons.export")}>
<JSONExportModal <JSONExportModal
elements={elements} elements={elements}
-6
View File
@@ -80,12 +80,6 @@
} }
} }
.layer-ui__wrapper__footer-center {
pointer-events: none;
& > * {
pointer-events: all;
}
}
.layer-ui__wrapper__footer-left, .layer-ui__wrapper__footer-left,
.layer-ui__wrapper__footer-right, .layer-ui__wrapper__footer-right,
.disable-zen-mode--visible { .disable-zen-mode--visible {
+54 -105
View File
@@ -8,8 +8,14 @@ import { NonDeletedExcalidrawElement } from "../element/types";
import { Language, t } from "../i18n"; import { Language, t } from "../i18n";
import { calculateScrollCenter } from "../scene"; import { calculateScrollCenter } from "../scene";
import { ExportType } from "../scene/types"; import { ExportType } from "../scene/types";
import { AppProps, AppState, ExcalidrawProps, BinaryFiles } from "../types"; import {
import { muteFSAbortError } from "../utils"; AppProps,
AppState,
ExcalidrawProps,
BinaryFiles,
UIChildrenComponents,
} from "../types";
import { muteFSAbortError, ReactChildrenToObject } from "../utils";
import { SelectedShapeActions, ShapesSwitcher } from "./Actions"; import { SelectedShapeActions, ShapesSwitcher } from "./Actions";
import CollabButton from "./CollabButton"; import CollabButton from "./CollabButton";
import { ErrorDialog } from "./ErrorDialog"; import { ErrorDialog } from "./ErrorDialog";
@@ -35,26 +41,17 @@ import "./LayerUI.scss";
import "./Toolbar.scss"; import "./Toolbar.scss";
import { PenModeButton } from "./PenModeButton"; import { PenModeButton } from "./PenModeButton";
import { trackEvent } from "../analytics"; import { trackEvent } from "../analytics";
import { isMenuOpenAtom, useDevice } from "../components/App"; import { useDevice } from "../components/App";
import { Stats } from "./Stats"; import { Stats } from "./Stats";
import { actionToggleStats } from "../actions/actionToggleStats"; import { actionToggleStats } from "../actions/actionToggleStats";
import Footer from "./Footer"; import Footer from "./footer/Footer";
import { import { WelcomeScreenMenuArrow, WelcomeScreenTopToolbarArrow } from "./icons";
ExportImageIcon,
HamburgerMenuIcon,
WelcomeScreenMenuArrow,
WelcomeScreenTopToolbarArrow,
} from "./icons";
import { MenuLinks, Separator } from "./MenuUtils";
import { useOutsideClickHook } from "../hooks/useOutsideClick";
import WelcomeScreen from "./WelcomeScreen"; import WelcomeScreen from "./WelcomeScreen";
import { hostSidebarCountersAtom } from "./Sidebar/Sidebar"; import { hostSidebarCountersAtom } from "./Sidebar/Sidebar";
import { jotaiScope } from "../jotai"; import { jotaiScope } from "../jotai";
import { useAtom } from "jotai"; import { useAtom } from "jotai";
import { LanguageList } from "../excalidraw-app/components/LanguageList";
import WelcomeScreenDecor from "./WelcomeScreenDecor"; import WelcomeScreenDecor from "./WelcomeScreenDecor";
import { getShortcutFromShortcutName } from "../actions/shortcuts"; import MainMenu from "./mainMenu/MainMenu";
import MenuItem from "./MenuItem";
interface LayerUIProps { interface LayerUIProps {
actionManager: ActionManager; actionManager: ActionManager;
@@ -71,7 +68,6 @@ interface LayerUIProps {
langCode: Language["code"]; langCode: Language["code"];
isCollaborating: boolean; isCollaborating: boolean;
renderTopRightUI?: ExcalidrawProps["renderTopRightUI"]; renderTopRightUI?: ExcalidrawProps["renderTopRightUI"];
renderCustomFooter?: ExcalidrawProps["renderFooter"];
renderCustomStats?: ExcalidrawProps["renderCustomStats"]; renderCustomStats?: ExcalidrawProps["renderCustomStats"];
renderCustomSidebar?: ExcalidrawProps["renderSidebar"]; renderCustomSidebar?: ExcalidrawProps["renderSidebar"];
libraryReturnUrl: ExcalidrawProps["libraryReturnUrl"]; libraryReturnUrl: ExcalidrawProps["libraryReturnUrl"];
@@ -81,7 +77,9 @@ interface LayerUIProps {
id: string; id: string;
onImageAction: (data: { insertOnCanvasDirectly: boolean }) => void; onImageAction: (data: { insertOnCanvasDirectly: boolean }) => void;
renderWelcomeScreen: boolean; renderWelcomeScreen: boolean;
children?: React.ReactNode;
} }
const LayerUI = ({ const LayerUI = ({
actionManager, actionManager,
appState, appState,
@@ -96,7 +94,6 @@ const LayerUI = ({
showExitZenModeBtn, showExitZenModeBtn,
isCollaborating, isCollaborating,
renderTopRightUI, renderTopRightUI,
renderCustomFooter,
renderCustomStats, renderCustomStats,
renderCustomSidebar, renderCustomSidebar,
libraryReturnUrl, libraryReturnUrl,
@@ -106,9 +103,13 @@ const LayerUI = ({
id, id,
onImageAction, onImageAction,
renderWelcomeScreen, renderWelcomeScreen,
children,
}: LayerUIProps) => { }: LayerUIProps) => {
const device = useDevice(); const device = useDevice();
const childrenComponents =
ReactChildrenToObject<UIChildrenComponents>(children);
const renderJSONExportDialog = () => { const renderJSONExportDialog = () => {
if (!UIOptions.canvasActions.export) { if (!UIOptions.canvasActions.export) {
return null; return null;
@@ -122,6 +123,7 @@ const LayerUI = ({
actionManager={actionManager} actionManager={actionManager}
exportOpts={UIOptions.canvasActions.export} exportOpts={UIOptions.canvasActions.export}
canvas={canvas} canvas={canvas}
setAppState={setAppState}
/> />
); );
}; };
@@ -175,9 +177,35 @@ const LayerUI = ({
); );
}; };
const [isMenuOpen, setIsMenuOpen] = useAtom(isMenuOpenAtom); const renderMenu = () => {
const menuRef = useOutsideClickHook(() => setIsMenuOpen(false)); return (
childrenComponents.Menu || (
<MainMenu>
<MainMenu.DefaultItems.LoadScene />
<MainMenu.DefaultItems.SaveToActiveFile />
{UIOptions.canvasActions.export && <MainMenu.DefaultItems.Export />}
{UIOptions.canvasActions.saveAsImage && (
<MainMenu.DefaultItems.SaveAsImage />
)}
{onCollabButtonClick && (
<MainMenu.DefaultItems.LiveCollaboration
onSelect={onCollabButtonClick}
isCollaborating={isCollaborating}
/>
)}
<MainMenu.DefaultItems.Help />
<MainMenu.DefaultItems.ClearCanvas />
<MainMenu.Separator />
<MainMenu.Group title="Excalidraw links">
<MainMenu.DefaultItems.Socials />
</MainMenu.Group>
<MainMenu.Separator />
<MainMenu.DefaultItems.ToggleTheme />
<MainMenu.DefaultItems.ChangeCanvasBackground />
</MainMenu>
)
);
};
const renderCanvasActions = () => ( const renderCanvasActions = () => (
<div style={{ position: "relative" }}> <div style={{ position: "relative" }}>
<WelcomeScreenDecor <WelcomeScreenDecor
@@ -188,84 +216,7 @@ const LayerUI = ({
<div>{t("welcomeScreen.menuHints")}</div> <div>{t("welcomeScreen.menuHints")}</div>
</div> </div>
</WelcomeScreenDecor> </WelcomeScreenDecor>
{renderMenu()}
<button
data-prevent-outside-click
className={clsx("menu-button", "zen-mode-transition", {
"transition-left": appState.zenModeEnabled,
})}
onClick={() => setIsMenuOpen(!isMenuOpen)}
type="button"
>
{HamburgerMenuIcon}
</button>
{isMenuOpen && (
<div
ref={menuRef}
style={{ position: "absolute", top: "100%", marginTop: ".25rem" }}
>
<Section heading="canvasActions">
{/* the zIndex ensures this menu has higher stacking order,
see https://github.com/excalidraw/excalidraw/pull/1445 */}
<Island
className="menu-container"
padding={2}
style={{ zIndex: 1 }}
>
{!appState.viewModeEnabled &&
actionManager.renderAction("loadScene")}
{/* // TODO barnabasmolnar/editor-redesign */}
{/* is this fine here? */}
{appState.fileHandle &&
actionManager.renderAction("saveToActiveFile")}
{renderJSONExportDialog()}
<MenuItem
label={t("buttons.exportImage")}
icon={ExportImageIcon}
dataTestId="image-export-button"
onClick={() => setAppState({ openDialog: "imageExport" })}
shortcut={getShortcutFromShortcutName("imageExport")}
/>
{onCollabButtonClick && (
<CollabButton
isCollaborating={isCollaborating}
collaboratorCount={appState.collaborators.size}
onClick={onCollabButtonClick}
/>
)}
{actionManager.renderAction("toggleShortcuts", undefined, true)}
{!appState.viewModeEnabled &&
actionManager.renderAction("clearCanvas")}
<Separator />
<MenuLinks />
<Separator />
<div
style={{
display: "flex",
flexDirection: "column",
rowGap: ".5rem",
}}
>
<div>{actionManager.renderAction("toggleTheme")}</div>
<div style={{ padding: "0 0.625rem" }}>
<LanguageList style={{ width: "100%" }} />
</div>
{!appState.viewModeEnabled && (
<div>
<div style={{ fontSize: ".75rem", marginBottom: ".5rem" }}>
{t("labels.canvasBackground")}
</div>
<div style={{ padding: "0 0.625rem" }}>
{actionManager.renderAction("changeViewBackgroundColor")}
</div>
</div>
)}
</div>
</Island>
</Section>
</div>
)}
</div> </div>
); );
@@ -396,10 +347,7 @@ const LayerUI = ({
}, },
)} )}
> >
<UserList <UserList collaborators={appState.collaborators} />
collaborators={appState.collaborators}
actionManager={actionManager}
/>
{onCollabButtonClick && ( {onCollabButtonClick && (
<CollabButton <CollabButton
isInHamburgerMenu={false} isInHamburgerMenu={false}
@@ -452,6 +400,7 @@ const LayerUI = ({
/> />
)} )}
{renderImageExportDialog()} {renderImageExportDialog()}
{renderJSONExportDialog()}
{appState.pasteDialog.shown && ( {appState.pasteDialog.shown && (
<PasteChartDialog <PasteChartDialog
setAppState={setAppState} setAppState={setAppState}
@@ -478,12 +427,12 @@ const LayerUI = ({
onPenModeToggle={onPenModeToggle} onPenModeToggle={onPenModeToggle}
canvas={canvas} canvas={canvas}
isCollaborating={isCollaborating} isCollaborating={isCollaborating}
renderCustomFooter={renderCustomFooter}
onImageAction={onImageAction} onImageAction={onImageAction}
renderTopRightUI={renderTopRightUI} renderTopRightUI={renderTopRightUI}
renderCustomStats={renderCustomStats} renderCustomStats={renderCustomStats}
renderSidebars={renderSidebars} renderSidebars={renderSidebars}
device={device} device={device}
renderMenu={renderMenu}
/> />
)} )}
@@ -511,9 +460,10 @@ const LayerUI = ({
renderWelcomeScreen={renderWelcomeScreen} renderWelcomeScreen={renderWelcomeScreen}
appState={appState} appState={appState}
actionManager={actionManager} actionManager={actionManager}
renderCustomFooter={renderCustomFooter}
showExitZenModeBtn={showExitZenModeBtn} showExitZenModeBtn={showExitZenModeBtn}
footerCenter={childrenComponents.FooterCenter}
/> />
{appState.showStats && ( {appState.showStats && (
<Stats <Stats
appState={appState} appState={appState}
@@ -560,7 +510,6 @@ const areEqual = (prev: LayerUIProps, next: LayerUIProps) => {
const keys = Object.keys(prevAppState) as (keyof Partial<AppState>)[]; const keys = Object.keys(prevAppState) as (keyof Partial<AppState>)[];
return ( return (
prev.renderCustomFooter === next.renderCustomFooter &&
prev.renderTopRightUI === next.renderTopRightUI && prev.renderTopRightUI === next.renderTopRightUI &&
prev.renderCustomStats === next.renderCustomStats && prev.renderCustomStats === next.renderCustomStats &&
prev.renderCustomSidebar === next.renderCustomSidebar && prev.renderCustomSidebar === next.renderCustomSidebar &&
+1 -1
View File
@@ -22,7 +22,7 @@ export const LibraryButton: React.FC<{
} }
return ( return (
<label title={`${capitalizeString(t("toolBar.library"))} — 0`}> <label title={`${capitalizeString(t("toolBar.library"))}`}>
<input <input
className="ToolIcon_type_checkbox" className="ToolIcon_type_checkbox"
type="checkbox" type="checkbox"
+23
View File
@@ -129,4 +129,27 @@
padding-right: 0; padding-right: 0;
} }
} }
.layer-ui__sidebar__header .dropdown-menu {
&.dropdown-menu--mobile {
top: 100%;
}
.dropdown-menu-container {
--gap: 0;
z-index: 1;
position: absolute;
top: 100%;
left: 0;
:root[dir="rtl"] & {
right: 0;
left: auto;
}
width: 196px;
box-shadow: var(--library-dropdown-shadow);
border-radius: var(--border-radius-lg);
padding: 0.25rem 0.5rem;
}
}
} }
+81 -77
View File
@@ -13,14 +13,15 @@ import {
import { ToolButton } from "./ToolButton"; import { ToolButton } from "./ToolButton";
import { fileOpen } from "../data/filesystem"; import { fileOpen } from "../data/filesystem";
import { muteFSAbortError } from "../utils"; import { muteFSAbortError } from "../utils";
import { useAtom } from "jotai"; import { atom, useAtom } from "jotai";
import { jotaiScope } from "../jotai"; import { jotaiScope } from "../jotai";
import ConfirmDialog from "./ConfirmDialog"; import ConfirmDialog from "./ConfirmDialog";
import PublishLibrary from "./PublishLibrary"; import PublishLibrary from "./PublishLibrary";
import { Dialog } from "./Dialog"; import { Dialog } from "./Dialog";
import { useOutsideClickHook } from "../hooks/useOutsideClick";
import MenuItem from "./MenuItem"; import DropdownMenu from "./dropdownMenu/DropdownMenu";
import { isDropdownOpenAtom } from "./App";
export const isLibraryMenuOpenAtom = atom(false);
const getSelectedItems = ( const getSelectedItems = (
libraryItems: LibraryItems, libraryItems: LibraryItems,
@@ -45,7 +46,9 @@ export const LibraryMenuHeader: React.FC<{
appState, appState,
}) => { }) => {
const [libraryItemsData] = useAtom(libraryItemsAtom, jotaiScope); const [libraryItemsData] = useAtom(libraryItemsAtom, jotaiScope);
const [isLibraryMenuOpen, setIsLibraryMenuOpen] = useAtom(
isLibraryMenuOpenAtom,
);
const renderRemoveLibAlert = useCallback(() => { const renderRemoveLibAlert = useCallback(() => {
const content = selectedItems.length const content = selectedItems.length
? t("alerts.removeItemsFromsLibrary", { count: selectedItems.length }) ? t("alerts.removeItemsFromsLibrary", { count: selectedItems.length })
@@ -173,85 +176,86 @@ export const LibraryMenuHeader: React.FC<{
}); });
}; };
const [isDropdownOpen, setIsDropdownOpen] = useAtom(isDropdownOpenAtom); const renderLibraryMenu = () => {
const dropdownRef = useOutsideClickHook(() => setIsDropdownOpen(false)); return (
<DropdownMenu open={isLibraryMenuOpen}>
<DropdownMenu.Trigger
className="Sidebar__dropdown-btn"
onToggle={() => setIsLibraryMenuOpen(!isLibraryMenuOpen)}
>
{DotsIcon}
</DropdownMenu.Trigger>
<DropdownMenu.Content
onClickOutside={() => setIsLibraryMenuOpen(false)}
className="library-menu"
>
{!itemsSelected && (
<DropdownMenu.Item
onSelect={onLibraryImport}
icon={LoadIcon}
dataTestId="lib-dropdown--load"
>
{t("buttons.load")}
</DropdownMenu.Item>
)}
{!!items.length && (
<DropdownMenu.Item
onSelect={onLibraryExport}
icon={ExportIcon}
dataTestId="lib-dropdown--export"
>
{t("buttons.export")}
</DropdownMenu.Item>
)}
{!!items.length && (
<DropdownMenu.Item
onSelect={() => setShowRemoveLibAlert(true)}
icon={TrashIcon}
>
{resetLabel}
</DropdownMenu.Item>
)}
{itemsSelected && (
<DropdownMenu.Item
icon={publishIcon}
onSelect={() => setShowPublishLibraryDialog(true)}
dataTestId="lib-dropdown--remove"
>
{t("buttons.publishLibrary")}
</DropdownMenu.Item>
)}
</DropdownMenu.Content>
</DropdownMenu>
);
};
return ( return (
<div style={{ position: "relative" }}> <div style={{ position: "relative" }}>
<button {renderLibraryMenu()}
type="button"
className="Sidebar__dropdown-btn"
data-prevent-outside-click
onClick={() => setIsDropdownOpen(!isDropdownOpen)}
>
{DotsIcon}
</button>
{selectedItems.length > 0 && ( {selectedItems.length > 0 && (
<div className="library-actions-counter">{selectedItems.length}</div> <div className="library-actions-counter">{selectedItems.length}</div>
)} )}
{showRemoveLibAlert && renderRemoveLibAlert()}
{isDropdownOpen && ( {showPublishLibraryDialog && (
<div <PublishLibrary
className="Sidebar__dropdown-content menu-container" onClose={() => setShowPublishLibraryDialog(false)}
ref={dropdownRef} libraryItems={getSelectedItems(
> libraryItemsData.libraryItems,
{!itemsSelected && ( selectedItems,
<MenuItem
label={t("buttons.load")}
icon={LoadIcon}
dataTestId="lib-dropdown--load"
onClick={onLibraryImport}
/>
)} )}
{showRemoveLibAlert && renderRemoveLibAlert()} appState={appState}
{showPublishLibraryDialog && ( onSuccess={(data) =>
<PublishLibrary onPublishLibSuccess(data, libraryItemsData.libraryItems)
onClose={() => setShowPublishLibraryDialog(false)} }
libraryItems={getSelectedItems( onError={(error) => window.alert(error)}
libraryItemsData.libraryItems, updateItemsInStorage={() =>
selectedItems, library.setLibrary(libraryItemsData.libraryItems)
)} }
appState={appState} onRemove={(id: string) =>
onSuccess={(data) => onSelectItems(selectedItems.filter((_id) => _id !== id))
onPublishLibSuccess(data, libraryItemsData.libraryItems) }
} />
onError={(error) => window.alert(error)}
updateItemsInStorage={() =>
library.setLibrary(libraryItemsData.libraryItems)
}
onRemove={(id: string) =>
onSelectItems(selectedItems.filter((_id) => _id !== id))
}
/>
)}
{publishLibSuccess && renderPublishSuccess()}
{!!items.length && (
<>
<MenuItem
label={t("buttons.export")}
icon={ExportIcon}
onClick={onLibraryExport}
dataTestId="lib-dropdown--export"
/>
<MenuItem
label={resetLabel}
icon={TrashIcon}
onClick={() => setShowRemoveLibAlert(true)}
dataTestId="lib-dropdown--remove"
/>
</>
)}
{itemsSelected && (
<MenuItem
label={t("buttons.publishLibrary")}
icon={publishIcon}
dataTestId="lib-dropdown--publish"
onClick={() => setShowPublishLibraryDialog(true)}
/>
)}
</div>
)} )}
{publishLibSuccess && renderPublishSuccess()}
</div> </div>
); );
}; };
+1
View File
@@ -44,6 +44,7 @@ export const LibraryUnit = ({
}, },
null, null,
); );
svg.querySelector(".style-fonts")?.remove();
node.innerHTML = svg.outerHTML; node.innerHTML = svg.outerHTML;
})(); })();
-85
View File
@@ -1,85 +0,0 @@
@import "../css/variables.module";
.excalidraw {
.menu-container {
background-color: #fff !important;
max-height: calc(100vh - 150px);
overflow-y: auto;
}
.menu-button {
@include outlineButtonStyles;
background-color: var(--island-bg-color);
width: var(--lg-button-size);
height: var(--lg-button-size);
svg {
width: var(--lg-icon-size);
height: var(--lg-icon-size);
}
}
.menu-item {
display: flex;
background-color: transparent;
border: 0;
align-items: center;
padding: 0 0.625rem;
height: 2rem;
column-gap: 0.625rem;
font-size: 0.875rem;
color: var(--color-gray-100);
cursor: pointer;
border-radius: var(--border-radius-md);
width: 100%;
box-sizing: border-box;
font-weight: normal;
font-family: inherit;
@media screen and (min-width: 1921px) {
height: 2.25rem;
}
&__text {
text-overflow: ellipsis;
overflow: hidden;
white-space: nowrap;
}
&__shortcut {
margin-inline-start: auto;
opacity: 0.5;
}
&:hover {
background-color: var(--button-hover);
text-decoration: none;
}
svg {
width: 1rem;
height: 1rem;
display: block;
}
&.active-collab {
background-color: #ecfdf5;
color: #064e3c;
}
}
&.theme--dark {
.menu-item {
color: var(--color-gray-40);
&.active-collab {
background-color: #064e3c;
color: #ecfdf5;
}
}
.menu-container {
background-color: var(--color-gray-90) !important;
}
}
}
-37
View File
@@ -1,37 +0,0 @@
import clsx from "clsx";
import "./Menu.scss";
interface MenuProps {
icon: JSX.Element;
onClick: () => void;
label: string;
dataTestId: string;
shortcut?: string;
isCollaborating?: boolean;
}
const MenuItem = ({
icon,
onClick,
label,
dataTestId,
shortcut,
isCollaborating,
}: MenuProps) => {
return (
<button
className={clsx("menu-item", { "active-collab": isCollaborating })}
aria-label={label}
onClick={onClick}
data-testid={dataTestId}
title={label}
type="button"
>
<div className="menu-item__icon">{icon}</div>
<div className="menu-item__text">{label}</div>
{shortcut && <div className="menu-item__shortcut">{shortcut}</div>}
</button>
);
};
export default MenuItem;
-53
View File
@@ -1,53 +0,0 @@
import { GithubIcon, DiscordIcon, PlusPromoIcon, TwitterIcon } from "./icons";
export const MenuLinks = () => (
<>
<a
href="https://plus.excalidraw.com/plus?utm_source=excalidraw&utm_medium=app&utm_content=hamburger"
target="_blank"
rel="noreferrer"
className="menu-item"
style={{ color: "var(--color-promo)" }}
>
<div className="menu-item__icon">{PlusPromoIcon}</div>
<div className="menu-item__text">Excalidraw+</div>
</a>
<a
className="menu-item"
href="https://github.com/excalidraw/excalidraw"
target="_blank"
rel="noopener noreferrer"
>
<div className="menu-item__icon">{GithubIcon}</div>
<div className="menu-item__text">GitHub</div>
</a>
<a
className="menu-item"
target="_blank"
href="https://discord.gg/UexuTaE"
rel="noopener noreferrer"
>
<div className="menu-item__icon">{DiscordIcon}</div>
<div className="menu-item__text">Discord</div>
</a>
<a
className="menu-item"
target="_blank"
href="https://twitter.com/excalidraw"
rel="noopener noreferrer"
>
<div className="menu-item__icon">{TwitterIcon}</div>
<div className="menu-item__text">Twitter</div>
</a>
</>
);
export const Separator = () => (
<div
style={{
height: "1px",
backgroundColor: "var(--default-border-color)",
margin: ".5rem 0",
}}
/>
);
+8 -93
View File
@@ -11,18 +11,13 @@ import { HintViewer } from "./HintViewer";
import { calculateScrollCenter } from "../scene"; import { calculateScrollCenter } from "../scene";
import { SelectedShapeActions, ShapesSwitcher } from "./Actions"; import { SelectedShapeActions, ShapesSwitcher } from "./Actions";
import { Section } from "./Section"; import { Section } from "./Section";
import CollabButton from "./CollabButton";
import { SCROLLBAR_WIDTH, SCROLLBAR_MARGIN } from "../scene/scrollbars"; import { SCROLLBAR_WIDTH, SCROLLBAR_MARGIN } from "../scene/scrollbars";
import { LockButton } from "./LockButton"; import { LockButton } from "./LockButton";
import { UserList } from "./UserList";
import { LibraryButton } from "./LibraryButton"; import { LibraryButton } from "./LibraryButton";
import { PenModeButton } from "./PenModeButton"; import { PenModeButton } from "./PenModeButton";
import { Stats } from "./Stats"; import { Stats } from "./Stats";
import { actionToggleStats } from "../actions"; import { actionToggleStats } from "../actions";
import { MenuLinks, Separator } from "./MenuUtils";
import WelcomeScreen from "./WelcomeScreen"; import WelcomeScreen from "./WelcomeScreen";
import MenuItem from "./MenuItem";
import { ExportImageIcon } from "./icons";
type MobileMenuProps = { type MobileMenuProps = {
appState: AppState; appState: AppState;
@@ -36,10 +31,7 @@ type MobileMenuProps = {
onPenModeToggle: () => void; onPenModeToggle: () => void;
canvas: HTMLCanvasElement | null; canvas: HTMLCanvasElement | null;
isCollaborating: boolean; isCollaborating: boolean;
renderCustomFooter?: (
isMobile: boolean,
appState: AppState,
) => JSX.Element | null;
onImageAction: (data: { insertOnCanvasDirectly: boolean }) => void; onImageAction: (data: { insertOnCanvasDirectly: boolean }) => void;
renderTopRightUI?: ( renderTopRightUI?: (
isMobile: boolean, isMobile: boolean,
@@ -49,27 +41,25 @@ type MobileMenuProps = {
renderSidebars: () => JSX.Element | null; renderSidebars: () => JSX.Element | null;
device: Device; device: Device;
renderWelcomeScreen?: boolean; renderWelcomeScreen?: boolean;
renderMenu: () => React.ReactNode;
}; };
export const MobileMenu = ({ export const MobileMenu = ({
appState, appState,
elements, elements,
actionManager, actionManager,
renderJSONExportDialog,
renderImageExportDialog,
setAppState, setAppState,
onCollabButtonClick,
onLockToggle, onLockToggle,
onPenModeToggle, onPenModeToggle,
canvas, canvas,
isCollaborating, isCollaborating,
renderCustomFooter,
onImageAction, onImageAction,
renderTopRightUI, renderTopRightUI,
renderCustomStats, renderCustomStats,
renderSidebars, renderSidebars,
device, device,
renderWelcomeScreen, renderWelcomeScreen,
renderMenu,
}: MobileMenuProps) => { }: MobileMenuProps) => {
const renderToolbar = () => { const renderToolbar = () => {
return ( return (
@@ -151,16 +141,12 @@ export const MobileMenu = ({
const renderAppToolbar = () => { const renderAppToolbar = () => {
if (appState.viewModeEnabled) { if (appState.viewModeEnabled) {
return ( return <div className="App-toolbar-content">{renderMenu()}</div>;
<div className="App-toolbar-content">
{actionManager.renderAction("toggleCanvasMenu")}
</div>
);
} }
return ( return (
<div className="App-toolbar-content"> <div className="App-toolbar-content">
{actionManager.renderAction("toggleCanvasMenu")} {renderMenu()}
{actionManager.renderAction("toggleEditMenu")} {actionManager.renderAction("toggleEditMenu")}
{actionManager.renderAction("undo")} {actionManager.renderAction("undo")}
{actionManager.renderAction("redo")} {actionManager.renderAction("redo")}
@@ -172,58 +158,6 @@ export const MobileMenu = ({
); );
}; };
const renderCanvasActions = () => {
if (appState.viewModeEnabled) {
return (
<>
{renderJSONExportDialog()}
<MenuItem
label={t("buttons.exportImage")}
icon={ExportImageIcon}
dataTestId="image-export-button"
onClick={() => setAppState({ openDialog: "imageExport" })}
/>
{renderImageExportDialog()}
</>
);
}
return (
<>
{!appState.viewModeEnabled && actionManager.renderAction("loadScene")}
{renderJSONExportDialog()}
{renderImageExportDialog()}
<MenuItem
label={t("buttons.exportImage")}
icon={ExportImageIcon}
dataTestId="image-export-button"
onClick={() => setAppState({ openDialog: "imageExport" })}
/>
{onCollabButtonClick && (
<CollabButton
isCollaborating={isCollaborating}
collaboratorCount={appState.collaborators.size}
onClick={onCollabButtonClick}
/>
)}
{actionManager.renderAction("toggleShortcuts", undefined, true)}
{!appState.viewModeEnabled && actionManager.renderAction("clearCanvas")}
<Separator />
<MenuLinks />
<Separator />
{!appState.viewModeEnabled && (
<div style={{ marginBottom: ".5rem" }}>
<div style={{ fontSize: ".75rem", marginBottom: ".5rem" }}>
{t("labels.canvasBackground")}
</div>
<div style={{ padding: "0 0.625rem" }}>
{actionManager.renderAction("changeViewBackgroundColor")}
</div>
</div>
)}
{actionManager.renderAction("toggleTheme")}
</>
);
};
return ( return (
<> <>
{renderSidebars()} {renderSidebars()}
@@ -248,28 +182,9 @@ export const MobileMenu = ({
}} }}
> >
<Island padding={0}> <Island padding={0}>
{appState.openMenu === "canvas" ? ( {appState.openMenu === "shape" &&
<Section className="App-mobile-menu" heading="canvasActions"> !appState.viewModeEnabled &&
<div className="panelColumn"> showSelectedShapeActions(appState, elements) ? (
<Stack.Col gap={2}>
{renderCanvasActions()}
{renderCustomFooter?.(true, appState)}
{appState.collaborators.size > 0 && (
<fieldset>
<legend>{t("labels.collaborators")}</legend>
<UserList
mobile
collaborators={appState.collaborators}
actionManager={actionManager}
/>
</fieldset>
)}
</Stack.Col>
</div>
</Section>
) : appState.openMenu === "shape" &&
!appState.viewModeEnabled &&
showSelectedShapeActions(appState, elements) ? (
<Section className="App-mobile-menu" heading="selectedShapeActions"> <Section className="App-mobile-menu" heading="selectedShapeActions">
<SelectedShapeActions <SelectedShapeActions
appState={appState} appState={appState}
+1 -1
View File
@@ -3,7 +3,7 @@
.excalidraw { .excalidraw {
&.excalidraw-modal-container { &.excalidraw-modal-container {
position: absolute; position: absolute;
z-index: 10; z-index: 9999;
} }
.Modal { .Modal {
+1
View File
@@ -46,6 +46,7 @@ const ChartPreviewBtn = (props: {
}, },
null, // files null, // files
); );
svg.querySelector(".style-fonts")?.remove();
previewNode.replaceChildren(); previewNode.replaceChildren();
previewNode.appendChild(svg); previewNode.appendChild(svg);
-18
View File
@@ -3,24 +3,6 @@
.excalidraw { .excalidraw {
.Sidebar { .Sidebar {
&__dropdown-content {
z-index: 1;
position: absolute;
top: 100%;
left: 0;
:root[dir="rtl"] & {
right: 0;
left: auto;
}
margin-top: 0.25rem;
width: 180px;
box-shadow: var(--library-dropdown-shadow);
border-radius: var(--border-radius-lg);
padding: 0.25rem 0.5rem;
}
&__close-btn, &__close-btn,
&__pin-btn, &__pin-btn,
&__dropdown-btn { &__dropdown-btn {
+2 -2
View File
@@ -90,10 +90,10 @@ describe("Sidebar", () => {
const sidebar = container.querySelector<HTMLElement>(".test-sidebar"); const sidebar = container.querySelector<HTMLElement>(".test-sidebar");
expect(sidebar).not.toBe(null); expect(sidebar).not.toBe(null);
const closeButton = queryByTestId(sidebar!, "sidebar-close"); const closeButton = queryByTestId(sidebar!, "sidebar-close")!;
expect(closeButton).not.toBe(null); expect(closeButton).not.toBe(null);
fireEvent.click(closeButton!.querySelector("button")!); fireEvent.click(closeButton);
await waitFor(() => { await waitFor(() => {
expect(container.querySelector<HTMLElement>(".test-sidebar")).toBe(null); expect(container.querySelector<HTMLElement>(".test-sidebar")).toBe(null);
expect(onClose).toHaveBeenCalled(); expect(onClose).toHaveBeenCalled();
+1
View File
@@ -7,6 +7,7 @@
display: flex; display: flex;
flex-wrap: wrap; flex-wrap: wrap;
justify-content: flex-end; justify-content: flex-end;
align-items: center;
gap: 0.625rem; gap: 0.625rem;
&:empty { &:empty {
+4 -24
View File
@@ -4,16 +4,16 @@ import React from "react";
import clsx from "clsx"; import clsx from "clsx";
import { AppState, Collaborator } from "../types"; import { AppState, Collaborator } from "../types";
import { Tooltip } from "./Tooltip"; import { Tooltip } from "./Tooltip";
import { ActionManager } from "../actions/manager"; import { useExcalidrawActionManager } from "./App";
export const UserList: React.FC<{ export const UserList: React.FC<{
className?: string; className?: string;
mobile?: boolean; mobile?: boolean;
collaborators: AppState["collaborators"]; collaborators: AppState["collaborators"];
actionManager: ActionManager; }> = ({ className, mobile, collaborators }) => {
}> = ({ className, mobile, collaborators, actionManager }) => { const actionManager = useExcalidrawActionManager();
const uniqueCollaborators = new Map<string, Collaborator>();
const uniqueCollaborators = new Map<string, Collaborator>();
collaborators.forEach((collaborator, socketId) => { collaborators.forEach((collaborator, socketId) => {
uniqueCollaborators.set( uniqueCollaborators.set(
// filter on user id, else fall back on unique socketId // filter on user id, else fall back on unique socketId
@@ -44,26 +44,6 @@ export const UserList: React.FC<{
); );
}); });
// TODO barnabasmolnar/editor-redesign
// probably remove before shipping :)
// 20 fake collaborators; for easy, convenient debug purposes ˇˇ
// const avatars = Array.from({ length: 20 }).map((_, index) => {
// const avatarJSX = actionManager.renderAction("goToCollaborator", [
// index.toString(),
// {
// username: `User ${index}`,
// },
// ]);
// return mobile ? (
// <Tooltip label={`User ${index}`} key={index}>
// {avatarJSX}
// </Tooltip>
// ) : (
// <React.Fragment key={index}>{avatarJSX}</React.Fragment>
// );
// });
return ( return (
<div className={clsx("UserList", className, { UserList_mobile: mobile })}> <div className={clsx("UserList", className, { UserList_mobile: mobile })}>
{avatars} {avatars}
+2 -22
View File
@@ -1,24 +1,12 @@
import { useAtom } from "jotai";
import { actionLoadScene, actionShortcuts } from "../actions"; import { actionLoadScene, actionShortcuts } from "../actions";
import { ActionManager } from "../actions/manager"; import { ActionManager } from "../actions/manager";
import { getShortcutFromShortcutName } from "../actions/shortcuts"; import { getShortcutFromShortcutName } from "../actions/shortcuts";
import { COOKIES } from "../constants"; import { isExcalidrawPlusSignedUser } from "../constants";
import { collabDialogShownAtom } from "../excalidraw-app/collab/Collab";
import { t } from "../i18n"; import { t } from "../i18n";
import { AppState } from "../types"; import { AppState } from "../types";
import { import { ExcalLogo, HelpIcon, LoadIcon, PlusPromoIcon } from "./icons";
ExcalLogo,
HelpIcon,
LoadIcon,
PlusPromoIcon,
UsersIcon,
} from "./icons";
import "./WelcomeScreen.scss"; import "./WelcomeScreen.scss";
const isExcalidrawPlusSignedUser = document.cookie.includes(
COOKIES.AUTH_STATE_COOKIE,
);
const WelcomeScreenItem = ({ const WelcomeScreenItem = ({
label, label,
shortcut, shortcut,
@@ -68,8 +56,6 @@ const WelcomeScreen = ({
appState: AppState; appState: AppState;
actionManager: ActionManager; actionManager: ActionManager;
}) => { }) => {
const [, setCollabDialogShown] = useAtom(collabDialogShownAtom);
let subheadingJSX; let subheadingJSX;
if (isExcalidrawPlusSignedUser) { if (isExcalidrawPlusSignedUser) {
@@ -113,12 +99,6 @@ const WelcomeScreen = ({
icon={LoadIcon} icon={LoadIcon}
/> />
)} )}
<WelcomeScreenItem
label={t("labels.liveCollaboration")}
shortcut={null}
onClick={() => setCollabDialogShown(true)}
icon={UsersIcon}
/>
<WelcomeScreenItem <WelcomeScreenItem
onClick={() => actionManager.executeAction(actionShortcuts)} onClick={() => actionManager.executeAction(actionShortcuts)}
label={t("helpDialog.title")} label={t("helpDialog.title")}
@@ -0,0 +1,127 @@
@import "../../css/variables.module";
.excalidraw {
.dropdown-menu {
position: absolute;
top: 100%;
margin-top: 0.25rem;
&--mobile {
bottom: 55px;
top: auto;
left: 0;
width: 100%;
display: flex;
flex-direction: column;
row-gap: 0.75rem;
.dropdown-menu-container {
padding: 8px 8px;
box-sizing: border-box;
background-color: var(--island-bg-color);
box-shadow: var(--shadow-island);
border-radius: var(--border-radius-lg);
position: relative;
transition: box-shadow 0.5s ease-in-out;
&.zen-mode {
box-shadow: none;
}
}
}
.dropdown-menu-container {
background-color: #fff !important;
max-height: calc(100vh - 150px);
overflow-y: auto;
--gap: 2;
}
.dropdown-menu-item-base {
display: flex;
padding: 0 0.625rem;
column-gap: 0.625rem;
font-size: 0.875rem;
color: var(--color-gray-100);
width: 100%;
box-sizing: border-box;
font-weight: normal;
font-family: inherit;
}
.dropdown-menu-item {
background-color: transparent;
border: 0;
align-items: center;
height: 2rem;
cursor: pointer;
border-radius: var(--border-radius-md);
@media screen and (min-width: 1921px) {
height: 2.25rem;
}
&__text {
text-overflow: ellipsis;
overflow: hidden;
white-space: nowrap;
}
&__shortcut {
margin-inline-start: auto;
opacity: 0.5;
}
&:hover {
background-color: var(--button-hover);
text-decoration: none;
}
svg {
width: 1rem;
height: 1rem;
display: block;
}
}
.dropdown-menu-item-custom {
margin-top: 0.5rem;
}
.dropdown-menu-group-title {
font-size: 14px;
text-align: left;
margin: 10px 0;
font-weight: 500;
}
}
&.theme--dark {
.dropdown-menu-item {
color: var(--color-gray-40);
}
.dropdown-menu-container {
background-color: var(--color-gray-90) !important;
}
}
.dropdown-menu-button {
@include outlineButtonStyles;
background-color: var(--island-bg-color);
width: var(--lg-button-size);
height: var(--lg-button-size);
svg {
width: var(--lg-icon-size);
height: var(--lg-icon-size);
}
&--mobile {
border: none;
margin: 0;
padding: 0;
width: var(--default-button-size);
height: var(--default-button-size);
}
}
}
@@ -0,0 +1,43 @@
import React from "react";
import DropdownMenuTrigger from "./DropdownMenuTrigger";
import DropdownMenuItem from "./DropdownMenuItem";
import MenuSeparator from "./DropdownMenuSeparator";
import DropdownMenuGroup from "./DropdownMenuGroup";
import DropdownMenuContent from "./DropdownMenuContent";
import DropdownMenuItemLink from "./DropdownMenuItemLink";
import DropdownMenuItemCustom from "./DropdownMenuItemCustom";
import {
getMenuContentComponent,
getMenuTriggerComponent,
} from "./dropdownMenuUtils";
import "./DropdownMenu.scss";
const DropdownMenu = ({
children,
open,
}: {
children?: React.ReactNode;
open: boolean;
}) => {
const MenuTriggerComp = getMenuTriggerComponent(children);
const MenuContentComp = getMenuContentComponent(children);
return (
<>
{MenuTriggerComp}
{open && MenuContentComp}
</>
);
};
DropdownMenu.Trigger = DropdownMenuTrigger;
DropdownMenu.Content = DropdownMenuContent;
DropdownMenu.Item = DropdownMenuItem;
DropdownMenu.ItemLink = DropdownMenuItemLink;
DropdownMenu.ItemCustom = DropdownMenuItemCustom;
DropdownMenu.Group = DropdownMenuGroup;
DropdownMenu.Separator = MenuSeparator;
export default DropdownMenu;
DropdownMenu.displayName = "DropdownMenu";
@@ -0,0 +1,51 @@
import { useOutsideClickHook } from "../../hooks/useOutsideClick";
import { Island } from "../Island";
import { useDevice } from "../App";
import clsx from "clsx";
import Stack from "../Stack";
const MenuContent = ({
children,
onClickOutside,
className = "",
style,
}: {
children?: React.ReactNode;
onClickOutside?: () => void;
className?: string;
style?: React.CSSProperties;
}) => {
const device = useDevice();
const menuRef = useOutsideClickHook(() => {
onClickOutside?.();
});
const classNames = clsx(`dropdown-menu ${className}`, {
"dropdown-menu--mobile": device.isMobile,
}).trim();
return (
<div
ref={menuRef}
className={classNames}
style={style}
data-testid="dropdown-menu"
>
{/* the zIndex ensures this menu has higher stacking order,
see https://github.com/excalidraw/excalidraw/pull/1445 */}
{device.isMobile ? (
<Stack.Col className="dropdown-menu-container">{children}</Stack.Col>
) : (
<Island
className="dropdown-menu-container"
padding={2}
style={{ zIndex: 1 }}
>
{children}
</Island>
)}
</div>
);
};
export default MenuContent;
MenuContent.displayName = "DropdownMenuContent";
@@ -0,0 +1,23 @@
import React from "react";
const MenuGroup = ({
children,
className = "",
style,
title,
}: {
children: React.ReactNode;
className?: string;
style?: React.CSSProperties;
title?: string;
}) => {
return (
<div className={`dropdown-menu-group ${className}`} style={style}>
{title && <p className="dropdown-menu-group-title">{title}</p>}
{children}
</div>
);
};
export default MenuGroup;
MenuGroup.displayName = "DropdownMenuGroup";
@@ -0,0 +1,45 @@
import React from "react";
import MenuItemContent from "./DropdownMenuItemContent";
export const getDrodownMenuItemClassName = (className = "") => {
return `dropdown-menu-item dropdown-menu-item-base ${className}`.trim();
};
const DropdownMenuItem = ({
icon,
onSelect,
children,
dataTestId,
shortcut,
className,
style,
ariaLabel,
}: {
icon?: JSX.Element;
onSelect: () => void;
children: React.ReactNode;
dataTestId?: string;
shortcut?: string;
className?: string;
style?: React.CSSProperties;
ariaLabel?: string;
}) => {
return (
<button
aria-label={ariaLabel}
onClick={onSelect}
data-testid={dataTestId}
title={ariaLabel}
type="button"
className={getDrodownMenuItemClassName(className)}
style={style}
>
<MenuItemContent icon={icon} shortcut={shortcut}>
{children}
</MenuItemContent>
</button>
);
};
export default DropdownMenuItem;
DropdownMenuItem.displayName = "DropdownMenuItem";
@@ -0,0 +1,23 @@
import { useDevice } from "../App";
const MenuItemContent = ({
icon,
shortcut,
children,
}: {
icon?: JSX.Element;
shortcut?: string;
children: React.ReactNode;
}) => {
const device = useDevice();
return (
<>
<div className="dropdown-menu-item__icon">{icon}</div>
<div className="dropdown-menu-item__text">{children}</div>
{shortcut && !device.isMobile && (
<div className="dropdown-menu-item__shortcut">{shortcut}</div>
)}
</>
);
};
export default MenuItemContent;
@@ -0,0 +1,23 @@
const DropdownMenuItemCustom = ({
children,
className = "",
style,
dataTestId,
}: {
children: React.ReactNode;
className?: string;
style?: React.CSSProperties;
dataTestId?: string;
}) => {
return (
<div
className={`dropdown-menu-item-base dropdown-menu-item-custom ${className}`.trim()}
style={style}
data-testid={dataTestId}
>
{children}
</div>
);
};
export default DropdownMenuItemCustom;
@@ -0,0 +1,42 @@
import MenuItemContent from "./DropdownMenuItemContent";
import React from "react";
import { getDrodownMenuItemClassName } from "./DropdownMenuItem";
const DropdownMenuItemLink = ({
icon,
dataTestId,
shortcut,
href,
children,
className = "",
style,
ariaLabel,
}: {
icon?: JSX.Element;
children: React.ReactNode;
dataTestId?: string;
shortcut?: string;
className?: string;
href: string;
style?: React.CSSProperties;
ariaLabel?: string;
}) => {
return (
<a
href={href}
target="_blank"
rel="noreferrer"
className={getDrodownMenuItemClassName(className)}
style={style}
data-testid={dataTestId}
title={ariaLabel}
aria-label={ariaLabel}
>
<MenuItemContent icon={icon} shortcut={shortcut}>
{children}
</MenuItemContent>
</a>
);
};
export default DropdownMenuItemLink;
DropdownMenuItemLink.displayName = "DropdownMenuItemLink";
@@ -0,0 +1,14 @@
import React from "react";
const MenuSeparator = () => (
<div
style={{
height: "1px",
backgroundColor: "var(--default-border-color)",
margin: ".5rem 0",
}}
/>
);
export default MenuSeparator;
MenuSeparator.displayName = "DropdownMenuSeparator";
@@ -0,0 +1,37 @@
import clsx from "clsx";
import { useDevice, useExcalidrawAppState } from "../App";
const MenuTrigger = ({
className = "",
children,
onToggle,
}: {
className?: string;
children: React.ReactNode;
onToggle: () => void;
}) => {
const appState = useExcalidrawAppState();
const device = useDevice();
const classNames = clsx(
`dropdown-menu-button ${className}`,
"zen-mode-transition",
{
"transition-left": appState.zenModeEnabled,
"dropdown-menu-button--mobile": device.isMobile,
},
).trim();
return (
<button
data-prevent-outside-click
className={classNames}
onClick={onToggle}
type="button"
data-testid="dropdown-menu-button"
>
{children}
</button>
);
};
export default MenuTrigger;
MenuTrigger.displayName = "DropdownMenuTrigger";
@@ -0,0 +1,35 @@
import React from "react";
export const getMenuTriggerComponent = (children: React.ReactNode) => {
const comp = React.Children.toArray(children).find(
(child) =>
React.isValidElement(child) &&
typeof child.type !== "string" &&
//@ts-ignore
child?.type.displayName &&
//@ts-ignore
child.type.displayName === "DropdownMenuTrigger",
);
if (!comp) {
return null;
}
//@ts-ignore
return comp;
};
export const getMenuContentComponent = (children: React.ReactNode) => {
const comp = React.Children.toArray(children).find(
(child) =>
React.isValidElement(child) &&
typeof child.type !== "string" &&
//@ts-ignore
child?.type.displayName &&
//@ts-ignore
child.type.displayName === "DropdownMenuContent",
);
if (!comp) {
return null;
}
//@ts-ignore
return comp;
};
@@ -1,35 +1,36 @@
import clsx from "clsx"; import clsx from "clsx";
import { ActionManager } from "../actions/manager"; import { ActionManager } from "../../actions/manager";
import { t } from "../i18n"; import { t } from "../../i18n";
import { AppState, ExcalidrawProps } from "../types"; import { AppState, UIChildrenComponents } from "../../types";
import { import {
ExitZenModeAction, ExitZenModeAction,
FinalizeAction, FinalizeAction,
UndoRedoActions, UndoRedoActions,
ZoomActions, ZoomActions,
} from "./Actions"; } from "../Actions";
import { useDevice } from "./App"; import { useDevice } from "../App";
import { WelcomeScreenHelpArrow } from "./icons"; import { WelcomeScreenHelpArrow } from "../icons";
import { Section } from "./Section"; import { Section } from "../Section";
import Stack from "./Stack"; import Stack from "../Stack";
import WelcomeScreenDecor from "./WelcomeScreenDecor"; import WelcomeScreenDecor from "../WelcomeScreenDecor";
const Footer = ({ const Footer = ({
appState, appState,
actionManager, actionManager,
renderCustomFooter,
showExitZenModeBtn, showExitZenModeBtn,
renderWelcomeScreen, renderWelcomeScreen,
footerCenter,
}: { }: {
appState: AppState; appState: AppState;
actionManager: ActionManager; actionManager: ActionManager;
renderCustomFooter?: ExcalidrawProps["renderFooter"];
showExitZenModeBtn: boolean; showExitZenModeBtn: boolean;
renderWelcomeScreen: boolean; renderWelcomeScreen: boolean;
footerCenter: UIChildrenComponents["FooterCenter"];
}) => { }) => {
const device = useDevice(); const device = useDevice();
const showFinalize = const showFinalize =
!appState.viewModeEnabled && appState.multiElement && device.isTouchScreen; !appState.viewModeEnabled && appState.multiElement && device.isTouchScreen;
return ( return (
<footer <footer
role="contentinfo" role="contentinfo"
@@ -69,17 +70,7 @@ const Footer = ({
</Section> </Section>
</Stack.Col> </Stack.Col>
</div> </div>
<div {footerCenter}
className={clsx(
"layer-ui__wrapper__footer-center zen-mode-transition",
{
"layer-ui__wrapper__footer-left--transition-bottom":
appState.zenModeEnabled,
},
)}
>
{renderCustomFooter?.(false, appState)}
</div>
<div <div
className={clsx("layer-ui__wrapper__footer-right zen-mode-transition", { className={clsx("layer-ui__wrapper__footer-right zen-mode-transition", {
"transition-right disable-pointerEvents": appState.zenModeEnabled, "transition-right disable-pointerEvents": appState.zenModeEnabled,
@@ -107,3 +98,4 @@ const Footer = ({
}; };
export default Footer; export default Footer;
Footer.displayName = "Footer";
+10
View File
@@ -0,0 +1,10 @@
.footer-center {
pointer-events: none;
& > * {
pointer-events: all;
}
display: flex;
width: 100%;
justify-content: flex-start;
}
+20
View File
@@ -0,0 +1,20 @@
import clsx from "clsx";
import { useExcalidrawAppState } from "../App";
import "./FooterCenter.scss";
const FooterCenter = ({ children }: { children?: React.ReactNode }) => {
const appState = useExcalidrawAppState();
return (
<div
className={clsx("footer-center zen-mode-transition", {
"layer-ui__wrapper__footer-left--transition-bottom":
appState.zenModeEnabled,
})}
>
{children}
</div>
);
};
export default FooterCenter;
FooterCenter.displayName = "FooterCenter";
+9 -9
View File
@@ -1470,11 +1470,11 @@ export const TextAlignRightIcon = createIcon(
export const TextAlignTopIcon = React.memo(({ theme }: { theme: Theme }) => export const TextAlignTopIcon = React.memo(({ theme }: { theme: Theme }) =>
createIcon( createIcon(
<g <g
stroke-width="1.5" strokeWidth="1.5"
stroke="currentColor" stroke="currentColor"
fill="none" fill="none"
stroke-linecap="round" strokeLinecap="round"
stroke-linejoin="round" strokeLinejoin="round"
> >
<path stroke="none" d="M0 0h24v24H0z" fill="none" /> <path stroke="none" d="M0 0h24v24H0z" fill="none" />
<line x1="4" y1="4" x2="20" y2="4" /> <line x1="4" y1="4" x2="20" y2="4" />
@@ -1488,11 +1488,11 @@ export const TextAlignTopIcon = React.memo(({ theme }: { theme: Theme }) =>
export const TextAlignBottomIcon = React.memo(({ theme }: { theme: Theme }) => export const TextAlignBottomIcon = React.memo(({ theme }: { theme: Theme }) =>
createIcon( createIcon(
<g <g
stroke-width="2" strokeWidth="2"
stroke="currentColor" stroke="currentColor"
fill="none" fill="none"
stroke-linecap="round" strokeLinecap="round"
stroke-linejoin="round" strokeLinejoin="round"
> >
<path stroke="none" d="M0 0h24v24H0z" fill="none" /> <path stroke="none" d="M0 0h24v24H0z" fill="none" />
<line x1="4" y1="20" x2="20" y2="20" /> <line x1="4" y1="20" x2="20" y2="20" />
@@ -1506,11 +1506,11 @@ export const TextAlignBottomIcon = React.memo(({ theme }: { theme: Theme }) =>
export const TextAlignMiddleIcon = React.memo(({ theme }: { theme: Theme }) => export const TextAlignMiddleIcon = React.memo(({ theme }: { theme: Theme }) =>
createIcon( createIcon(
<g <g
stroke-width="1.5" strokeWidth="1.5"
stroke="currentColor" stroke="currentColor"
fill="none" fill="none"
stroke-linecap="round" strokeLinecap="round"
stroke-linejoin="round" strokeLinejoin="round"
> >
<path stroke="none" d="M0 0h24v24H0z" fill="none" /> <path stroke="none" d="M0 0h24v24H0z" fill="none" />
<line x1="4" y1="12" x2="9" y2="12" /> <line x1="4" y1="12" x2="9" y2="12" />
+174
View File
@@ -0,0 +1,174 @@
import clsx from "clsx";
import { getShortcutFromShortcutName } from "../../actions/shortcuts";
import { t } from "../../i18n";
import {
useExcalidrawAppState,
useExcalidrawSetAppState,
useExcalidrawActionManager,
} from "../App";
import { ExportIcon, ExportImageIcon, UsersIcon } from "../icons";
import { GithubIcon, DiscordIcon, TwitterIcon } from "../icons";
import DropdownMenuItem from "../dropdownMenu/DropdownMenuItem";
import DropdownMenuItemLink from "../dropdownMenu/DropdownMenuItemLink";
export const LoadScene = () => {
const appState = useExcalidrawAppState();
const actionManager = useExcalidrawActionManager();
if (appState.viewModeEnabled) {
return null;
}
return actionManager.renderAction("loadScene");
};
LoadScene.displayName = "LoadScene";
export const SaveToActiveFile = () => {
const appState = useExcalidrawAppState();
const actionManager = useExcalidrawActionManager();
if (!appState.fileHandle) {
return null;
}
return actionManager.renderAction("saveToActiveFile");
};
SaveToActiveFile.displayName = "SaveToActiveFile";
export const SaveAsImage = () => {
const setAppState = useExcalidrawSetAppState();
// Hack until we tie "t" to lang state
// eslint-disable-next-line
const appState = useExcalidrawAppState();
return (
<DropdownMenuItem
icon={ExportImageIcon}
dataTestId="image-export-button"
onSelect={() => setAppState({ openDialog: "imageExport" })}
shortcut={getShortcutFromShortcutName("imageExport")}
ariaLabel={t("buttons.exportImage")}
>
{t("buttons.exportImage")}
</DropdownMenuItem>
);
};
SaveAsImage.displayName = "SaveAsImage";
export const Help = () => {
// Hack until we tie "t" to lang state
// eslint-disable-next-line
const appState = useExcalidrawAppState();
const actionManager = useExcalidrawActionManager();
return actionManager.renderAction("toggleShortcuts", undefined, true);
};
Help.displayName = "Help";
export const ClearCanvas = () => {
const appState = useExcalidrawAppState();
const actionManager = useExcalidrawActionManager();
if (appState.viewModeEnabled) {
return null;
}
return actionManager.renderAction("clearCanvas");
};
ClearCanvas.displayName = "ClearCanvas";
export const ToggleTheme = () => {
// Hack until we tie "t" to lang state
// eslint-disable-next-line
const appState = useExcalidrawAppState();
const actionManager = useExcalidrawActionManager();
return actionManager.renderAction("toggleTheme");
};
ToggleTheme.displayName = "ToggleTheme";
export const ChangeCanvasBackground = () => {
const appState = useExcalidrawAppState();
const actionManager = useExcalidrawActionManager();
if (appState.viewModeEnabled) {
return null;
}
return (
<div style={{ marginTop: "0.5rem" }}>
<div style={{ fontSize: ".75rem", marginBottom: ".5rem" }}>
{t("labels.canvasBackground")}
</div>
<div style={{ padding: "0 0.625rem" }}>
{actionManager.renderAction("changeViewBackgroundColor")}
</div>
</div>
);
};
ChangeCanvasBackground.displayName = "ChangeCanvasBackground";
export const Export = () => {
// Hack until we tie "t" to lang state
// eslint-disable-next-line
const appState = useExcalidrawAppState();
const setAppState = useExcalidrawSetAppState();
return (
<DropdownMenuItem
icon={ExportIcon}
onSelect={() => {
setAppState({ openDialog: "jsonExport" });
}}
dataTestId="json-export-button"
ariaLabel={t("buttons.export")}
>
{t("buttons.export")}
</DropdownMenuItem>
);
};
Export.displayName = "Export";
export const Socials = () => (
<>
<DropdownMenuItemLink
icon={GithubIcon}
href="https://github.com/excalidraw/excalidraw"
ariaLabel="GitHub"
>
GitHub
</DropdownMenuItemLink>
<DropdownMenuItemLink
icon={DiscordIcon}
href="https://discord.gg/UexuTaE"
ariaLabel="Discord"
>
Discord
</DropdownMenuItemLink>
<DropdownMenuItemLink
icon={TwitterIcon}
href="https://twitter.com/excalidraw"
ariaLabel="Twitter"
>
Twitter
</DropdownMenuItemLink>
</>
);
Socials.displayName = "Socials";
export const LiveCollaboration = ({
onSelect,
isCollaborating,
}: {
onSelect: () => void;
isCollaborating: boolean;
}) => {
// Hack until we tie "t" to lang state
// eslint-disable-next-line
const appState = useExcalidrawAppState();
return (
<DropdownMenuItem
dataTestId="collab-button"
icon={UsersIcon}
className={clsx({
"active-collab": isCollaborating,
})}
onSelect={onSelect}
>
{t("labels.liveCollaboration")}
</DropdownMenuItem>
);
};
LiveCollaboration.displayName = "LiveCollaboration";
+56
View File
@@ -0,0 +1,56 @@
import React from "react";
import {
useDevice,
useExcalidrawAppState,
useExcalidrawSetAppState,
} from "../App";
import DropdownMenu from "../dropdownMenu/DropdownMenu";
import * as DefaultItems from "./DefaultItems";
import { UserList } from "../UserList";
import { t } from "../../i18n";
import { HamburgerMenuIcon } from "../icons";
const MainMenu = ({ children }: { children?: React.ReactNode }) => {
const device = useDevice();
const appState = useExcalidrawAppState();
const setAppState = useExcalidrawSetAppState();
const onClickOutside = device.isMobile
? undefined
: () => setAppState({ openMenu: null });
return (
<DropdownMenu open={appState.openMenu === "canvas"}>
<DropdownMenu.Trigger
onToggle={() => {
setAppState({
openMenu: appState.openMenu === "canvas" ? null : "canvas",
});
}}
>
{HamburgerMenuIcon}
</DropdownMenu.Trigger>
<DropdownMenu.Content onClickOutside={onClickOutside}>
{children}
{device.isMobile && appState.collaborators.size > 0 && (
<fieldset className="UserList-Wrapper">
<legend>{t("labels.collaborators")}</legend>
<UserList mobile={true} collaborators={appState.collaborators} />
</fieldset>
)}
</DropdownMenu.Content>
</DropdownMenu>
);
};
MainMenu.Trigger = DropdownMenu.Trigger;
MainMenu.Item = DropdownMenu.Item;
MainMenu.ItemLink = DropdownMenu.ItemLink;
MainMenu.ItemCustom = DropdownMenu.ItemCustom;
MainMenu.Group = DropdownMenu.Group;
MainMenu.Separator = DropdownMenu.Separator;
MainMenu.DefaultItems = DefaultItems;
export default MainMenu;
MainMenu.displayName = "Menu";
+30 -6
View File
@@ -130,12 +130,6 @@ export const IDLE_THRESHOLD = 60_000;
// Report a user active each ACTIVE_THRESHOLD milliseconds // Report a user active each ACTIVE_THRESHOLD milliseconds
export const ACTIVE_THRESHOLD = 3_000; export const ACTIVE_THRESHOLD = 3_000;
export const MODES = {
VIEW: "viewMode",
ZEN: "zenMode",
GRID: "gridMode",
};
export const THEME_FILTER = cssVariables.themeFilter; export const THEME_FILTER = cssVariables.themeFilter;
export const URL_QUERY_KEYS = { export const URL_QUERY_KEYS = {
@@ -216,6 +210,32 @@ export const TEXT_ALIGN = {
export const ELEMENT_READY_TO_ERASE_OPACITY = 20; export const ELEMENT_READY_TO_ERASE_OPACITY = 20;
// Radius represented as 25% of element's largest side (width/height).
// Used for LEGACY and PROPORTIONAL_RADIUS algorithms, or when the element is
// below the cutoff size.
export const DEFAULT_PROPORTIONAL_RADIUS = 0.25;
// Fixed radius for the ADAPTIVE_RADIUS algorithm. In pixels.
export const DEFAULT_ADAPTIVE_RADIUS = 32;
// roundness type (algorithm)
export const ROUNDNESS = {
// Used for legacy rounding (rectangles), which currently works the same
// as PROPORTIONAL_RADIUS, but we need to differentiate for UI purposes and
// forwards-compat.
LEGACY: 1,
// Used for linear elements & diamonds
PROPORTIONAL_RADIUS: 2,
// Current default algorithm for rectangles, using fixed pixel radius.
// It's working similarly to a regular border-radius, but attemps to make
// radius visually similar across differnt element sizes, especially
// very large and very small elements.
//
// NOTE right now we don't allow configuration and use a constant radius
// (see DEFAULT_ADAPTIVE_RADIUS constant)
ADAPTIVE_RADIUS: 3,
} as const;
export const COOKIES = { export const COOKIES = {
AUTH_STATE_COOKIE: "excplus-auth", AUTH_STATE_COOKIE: "excplus-auth",
} as const; } as const;
@@ -223,3 +243,7 @@ export const COOKIES = {
/** key containt id of precedeing elemnt id we use in reconciliation during /** key containt id of precedeing elemnt id we use in reconciliation during
* collaboration */ * collaboration */
export const PRECEDING_ELEMENT_KEY = "__precedingElement__"; export const PRECEDING_ELEMENT_KEY = "__precedingElement__";
export const isExcalidrawPlusSignedUser = document.cookie.includes(
COOKIES.AUTH_STATE_COOKIE,
);
+14
View File
@@ -569,6 +569,20 @@
display: none; display: none;
} }
} }
.UserList-Wrapper {
margin: 0;
padding: 0;
border: none;
text-align: left;
legend {
display: block;
font-size: 0.75rem;
font-weight: 400;
margin: 0 0 0.25rem;
padding: 0;
}
}
} }
.ErrorSplash.excalidraw { .ErrorSplash.excalidraw {
+2 -1
View File
@@ -154,7 +154,8 @@ class Library {
return this.setLibrary(() => { return this.setLibrary(() => {
return new Promise<LibraryItems>(async (resolve, reject) => { return new Promise<LibraryItems>(async (resolve, reject) => {
try { try {
const source = await (typeof libraryItems === "function" const source = await (typeof libraryItems === "function" &&
!(libraryItems instanceof Blob)
? libraryItems(this.lastLibraryItems) ? libraryItems(this.lastLibraryItems)
: libraryItems); : libraryItems);
+121 -10
View File
@@ -1,7 +1,9 @@
import { import {
ExcalidrawElement, ExcalidrawElement,
ExcalidrawSelectionElement, ExcalidrawSelectionElement,
ExcalidrawTextElement,
FontFamilyValues, FontFamilyValues,
StrokeRoundness,
} from "../element/types"; } from "../element/types";
import { import {
AppState, AppState,
@@ -16,7 +18,7 @@ import {
isInvisiblySmallElement, isInvisiblySmallElement,
refreshTextDimensions, refreshTextDimensions,
} from "../element"; } from "../element";
import { isLinearElementType } from "../element/typeChecks"; import { isTextElement, isUsingAdaptiveRadius } from "../element/typeChecks";
import { randomId } from "../random"; import { randomId } from "../random";
import { import {
DEFAULT_FONT_FAMILY, DEFAULT_FONT_FAMILY,
@@ -24,12 +26,14 @@ import {
DEFAULT_VERTICAL_ALIGN, DEFAULT_VERTICAL_ALIGN,
PRECEDING_ELEMENT_KEY, PRECEDING_ELEMENT_KEY,
FONT_FAMILY, FONT_FAMILY,
ROUNDNESS,
} from "../constants"; } from "../constants";
import { getDefaultAppState } from "../appState"; import { getDefaultAppState } from "../appState";
import { LinearElementEditor } from "../element/linearElementEditor"; import { LinearElementEditor } from "../element/linearElementEditor";
import { bumpVersion } from "../element/mutateElement"; import { bumpVersion } from "../element/mutateElement";
import { getUpdatedTimestamp, updateActiveTool } from "../utils"; import { getUpdatedTimestamp, updateActiveTool } from "../utils";
import { arrayToMap } from "../utils"; import { arrayToMap } from "../utils";
import oc from "open-color";
type RestoredAppState = Omit< type RestoredAppState = Omit<
AppState, AppState,
@@ -73,6 +77,8 @@ const restoreElementWithProperties = <
customData?: ExcalidrawElement["customData"]; customData?: ExcalidrawElement["customData"];
/** @deprecated */ /** @deprecated */
boundElementIds?: readonly ExcalidrawElement["id"][]; boundElementIds?: readonly ExcalidrawElement["id"][];
/** @deprecated */
strokeSharpness?: StrokeRoundness;
/** metadata that may be present in elements during collaboration */ /** metadata that may be present in elements during collaboration */
[PRECEDING_ELEMENT_KEY]?: string; [PRECEDING_ELEMENT_KEY]?: string;
}, },
@@ -105,15 +111,23 @@ const restoreElementWithProperties = <
angle: element.angle || 0, angle: element.angle || 0,
x: extra.x ?? element.x ?? 0, x: extra.x ?? element.x ?? 0,
y: extra.y ?? element.y ?? 0, y: extra.y ?? element.y ?? 0,
strokeColor: element.strokeColor, strokeColor: element.strokeColor || oc.black,
backgroundColor: element.backgroundColor, backgroundColor: element.backgroundColor || "transparent",
width: element.width || 0, width: element.width || 0,
height: element.height || 0, height: element.height || 0,
seed: element.seed ?? 1, seed: element.seed ?? 1,
groupIds: element.groupIds ?? [], groupIds: element.groupIds ?? [],
strokeSharpness: roundness: element.roundness
element.strokeSharpness ?? ? element.roundness
(isLinearElementType(element.type) ? "round" : "sharp"), : element.strokeSharpness === "round"
? {
// for old elements that would now use adaptive radius algo,
// use legacy algo instead
type: isUsingAdaptiveRadius(element.type)
? ROUNDNESS.LEGACY
: ROUNDNESS.PROPORTIONAL_RADIUS,
}
: null,
boundElements: element.boundElementIds boundElements: element.boundElementIds
? element.boundElementIds.map((id) => ({ type: "arrow", id })) ? element.boundElementIds.map((id) => ({ type: "arrow", id }))
: element.boundElements ?? [], : element.boundElements ?? [],
@@ -139,7 +153,7 @@ const restoreElementWithProperties = <
const restoreElement = ( const restoreElement = (
element: Exclude<ExcalidrawElement, ExcalidrawSelectionElement>, element: Exclude<ExcalidrawElement, ExcalidrawSelectionElement>,
refreshDimensions = true, refreshDimensions = false,
): typeof element | null => { ): typeof element | null => {
switch (element.type) { switch (element.type) {
case "text": case "text":
@@ -235,14 +249,99 @@ const restoreElement = (
} }
}; };
/**
* Repairs contaienr element's boundElements array by removing duplicates and
* fixing containerId of bound elements if not present. Also removes any
* bound elements that do not exist in the elements array.
*
* NOTE mutates elements.
*/
const repairContainerElement = (
container: Mutable<ExcalidrawElement>,
elementsMap: Map<string, Mutable<ExcalidrawElement>>,
) => {
if (container.boundElements) {
// copy because we're not cloning on restore, and we don't want to mutate upstream
const boundElements = container.boundElements.slice();
// dedupe bindings & fix boundElement.containerId if not set already
const boundIds = new Set<ExcalidrawElement["id"]>();
container.boundElements = boundElements.reduce(
(
acc: Mutable<NonNullable<ExcalidrawElement["boundElements"]>>,
binding,
) => {
const boundElement = elementsMap.get(binding.id);
if (boundElement && !boundIds.has(binding.id)) {
boundIds.add(binding.id);
if (boundElement.isDeleted) {
return acc;
}
acc.push(binding);
if (
isTextElement(boundElement) &&
// being slightly conservative here, preserving existing containerId
// if defined, lest boundElements is stale
!boundElement.containerId
) {
(boundElement as Mutable<ExcalidrawTextElement>).containerId =
container.id;
}
}
return acc;
},
[],
);
}
};
/**
* Repairs target bound element's container's boundElements array,
* or removes contaienrId if container does not exist.
*
* NOTE mutates elements.
*/
const repairBoundElement = (
boundElement: Mutable<ExcalidrawTextElement>,
elementsMap: Map<string, Mutable<ExcalidrawElement>>,
) => {
const container = boundElement.containerId
? elementsMap.get(boundElement.containerId)
: null;
if (!container) {
boundElement.containerId = null;
return;
}
if (boundElement.isDeleted) {
return;
}
if (
container.boundElements &&
!container.boundElements.find((binding) => binding.id === boundElement.id)
) {
// copy because we're not cloning on restore, and we don't want to mutate upstream
const boundElements = (
container.boundElements || (container.boundElements = [])
).slice();
boundElements.push({ type: "text", id: boundElement.id });
container.boundElements = boundElements;
}
};
export const restoreElements = ( export const restoreElements = (
elements: ImportedDataState["elements"], elements: ImportedDataState["elements"],
/** NOTE doesn't serve for reconciliation */ /** NOTE doesn't serve for reconciliation */
localElements: readonly ExcalidrawElement[] | null | undefined, localElements: readonly ExcalidrawElement[] | null | undefined,
refreshDimensions = true, refreshDimensions = false,
): ExcalidrawElement[] => { ): ExcalidrawElement[] => {
const localElementsMap = localElements ? arrayToMap(localElements) : null; const localElementsMap = localElements ? arrayToMap(localElements) : null;
return (elements || []).reduce((elements, element) => { const restoredElements = (elements || []).reduce((elements, element) => {
// filtering out selection, which is legacy, no longer kept in elements, // filtering out selection, which is legacy, no longer kept in elements,
// and causing issues if retained // and causing issues if retained
if (element.type !== "selection" && !isInvisiblySmallElement(element)) { if (element.type !== "selection" && !isInvisiblySmallElement(element)) {
@@ -260,6 +359,18 @@ export const restoreElements = (
} }
return elements; return elements;
}, [] as ExcalidrawElement[]); }, [] as ExcalidrawElement[]);
// repair binding. Mutates elements.
const restoredElementsMap = arrayToMap(restoredElements);
for (const element of restoredElements) {
if (isTextElement(element) && element.containerId) {
repairBoundElement(element, restoredElementsMap);
} else if (element.boundElements) {
repairContainerElement(element, restoredElementsMap);
}
}
return restoredElements;
}; };
const coalesceAppStateValue = < const coalesceAppStateValue = <
@@ -387,7 +498,7 @@ export const restore = (
localElements: readonly ExcalidrawElement[] | null | undefined, localElements: readonly ExcalidrawElement[] | null | undefined,
): RestoredDataState => { ): RestoredDataState => {
return { return {
elements: restoreElements(data?.elements, localElements, true), elements: restoreElements(data?.elements, localElements),
appState: restoreAppState(data?.appState, localAppState || null), appState: restoreAppState(data?.appState, localAppState || null),
files: data?.files || {}, files: data?.files || {},
}; };
+5
View File
@@ -26,6 +26,7 @@ import Scene from "../scene/Scene";
import { LinearElementEditor } from "./linearElementEditor"; import { LinearElementEditor } from "./linearElementEditor";
import { arrayToMap, tupleToCoors } from "../utils"; import { arrayToMap, tupleToCoors } from "../utils";
import { KEYS } from "../keys"; import { KEYS } from "../keys";
import { getBoundTextElement, handleBindTextResize } from "./textElement";
export type SuggestedBinding = export type SuggestedBinding =
| NonDeleted<ExcalidrawBindableElement> | NonDeleted<ExcalidrawBindableElement>
@@ -361,6 +362,10 @@ export const updateBoundElements = (
endBinding, endBinding,
changedElement as ExcalidrawBindableElement, changedElement as ExcalidrawBindableElement,
); );
const boundText = getBoundTextElement(element);
if (boundText) {
handleBindTextResize(element, false);
}
}); });
}; };
+2
View File
@@ -1,3 +1,4 @@
import { ROUNDNESS } from "../constants";
import { getElementAbsoluteCoords, getElementBounds } from "./bounds"; import { getElementAbsoluteCoords, getElementBounds } from "./bounds";
import { ExcalidrawElement, ExcalidrawLinearElement } from "./types"; import { ExcalidrawElement, ExcalidrawLinearElement } from "./types";
@@ -22,6 +23,7 @@ const _ce = ({
backgroundColor: "#000", backgroundColor: "#000",
fillStyle: "solid", fillStyle: "solid",
strokeWidth: 1, strokeWidth: 1,
roundness: { type: ROUNDNESS.PROPORTIONAL_RADIUS },
roughness: 0, roughness: 0,
opacity: 1, opacity: 1,
x, x,
+88 -75
View File
@@ -4,6 +4,7 @@ import {
Arrowhead, Arrowhead,
ExcalidrawFreeDrawElement, ExcalidrawFreeDrawElement,
NonDeleted, NonDeleted,
ExcalidrawTextElementWithContainer,
} from "./types"; } from "./types";
import { distance2d, rotate } from "../math"; import { distance2d, rotate } from "../math";
import rough from "roughjs/bin/rough"; import rough from "roughjs/bin/rough";
@@ -13,8 +14,15 @@ import {
getShapeForElement, getShapeForElement,
generateRoughOptions, generateRoughOptions,
} from "../renderer/renderElement"; } from "../renderer/renderElement";
import { isFreeDrawElement, isLinearElement } from "./typeChecks"; import {
isArrowElement,
isFreeDrawElement,
isLinearElement,
isTextElement,
} from "./typeChecks";
import { rescalePoints } from "../points"; import { rescalePoints } from "../points";
import { getBoundTextElement, getContainerElement } from "./textElement";
import { LinearElementEditor } from "./linearElementEditor";
// x and y position of top left corner, x and y position of bottom right corner // x and y position of top left corner, x and y position of bottom right corner
export type Bounds = readonly [number, number, number, number]; export type Bounds = readonly [number, number, number, number];
@@ -24,17 +32,39 @@ type MaybeQuadraticSolution = [number | null, number | null] | false;
// This set of functions retrieves the absolute position of the 4 points. // This set of functions retrieves the absolute position of the 4 points.
export const getElementAbsoluteCoords = ( export const getElementAbsoluteCoords = (
element: ExcalidrawElement, element: ExcalidrawElement,
): Bounds => { includeBoundText: boolean = false,
): [number, number, number, number, number, number] => {
if (isFreeDrawElement(element)) { if (isFreeDrawElement(element)) {
return getFreeDrawElementAbsoluteCoords(element); return getFreeDrawElementAbsoluteCoords(element);
} else if (isLinearElement(element)) { } else if (isLinearElement(element)) {
return getLinearElementAbsoluteCoords(element); return LinearElementEditor.getElementAbsoluteCoords(
element,
includeBoundText,
);
} else if (isTextElement(element)) {
const container = getContainerElement(element);
if (isArrowElement(container)) {
const coords = LinearElementEditor.getBoundTextElementPosition(
container,
element as ExcalidrawTextElementWithContainer,
);
return [
coords.x,
coords.y,
coords.x + element.width,
coords.y + element.height,
coords.x + element.width / 2,
coords.y + element.height / 2,
];
}
} }
return [ return [
element.x, element.x,
element.y, element.y,
element.x + element.width, element.x + element.width,
element.y + element.height, element.y + element.height,
element.x + element.width / 2,
element.y + element.height / 2,
]; ];
}; };
@@ -159,7 +189,7 @@ const getCubicBezierCurveBound = (
return [minX, minY, maxX, maxY]; return [minX, minY, maxX, maxY];
}; };
const getMinMaxXYFromCurvePathOps = ( export const getMinMaxXYFromCurvePathOps = (
ops: Op[], ops: Op[],
transformXY?: (x: number, y: number) => [number, number], transformXY?: (x: number, y: number) => [number, number],
): [number, number, number, number] => { ): [number, number, number, number] => {
@@ -230,59 +260,13 @@ const getBoundsFromPoints = (
const getFreeDrawElementAbsoluteCoords = ( const getFreeDrawElementAbsoluteCoords = (
element: ExcalidrawFreeDrawElement, element: ExcalidrawFreeDrawElement,
): [number, number, number, number] => { ): [number, number, number, number, number, number] => {
const [minX, minY, maxX, maxY] = getBoundsFromPoints(element.points); const [minX, minY, maxX, maxY] = getBoundsFromPoints(element.points);
const x1 = minX + element.x;
return [ const y1 = minY + element.y;
minX + element.x, const x2 = maxX + element.x;
minY + element.y, const y2 = maxY + element.y;
maxX + element.x, return [x1, y1, x2, y2, (x1 + x2) / 2, (y1 + y2) / 2];
maxY + element.y,
];
};
const getLinearElementAbsoluteCoords = (
element: ExcalidrawLinearElement,
): [number, number, number, number] => {
let coords: [number, number, number, number];
if (element.points.length < 2 || !getShapeForElement(element)) {
// XXX this is just a poor estimate and not very useful
const { minX, minY, maxX, maxY } = element.points.reduce(
(limits, [x, y]) => {
limits.minY = Math.min(limits.minY, y);
limits.minX = Math.min(limits.minX, x);
limits.maxX = Math.max(limits.maxX, x);
limits.maxY = Math.max(limits.maxY, y);
return limits;
},
{ minX: Infinity, minY: Infinity, maxX: -Infinity, maxY: -Infinity },
);
coords = [
minX + element.x,
minY + element.y,
maxX + element.x,
maxY + element.y,
];
} else {
const shape = getShapeForElement(element)!;
// first element is always the curve
const ops = getCurvePathOps(shape[0]);
const [minX, minY, maxX, maxY] = getMinMaxXYFromCurvePathOps(ops);
coords = [
minX + element.x,
minY + element.y,
maxX + element.x,
maxY + element.y,
];
}
return coords;
}; };
export const getArrowheadPoints = ( export const getArrowheadPoints = (
@@ -394,7 +378,7 @@ const generateLinearElementShape = (
const options = generateRoughOptions(element); const options = generateRoughOptions(element);
const method = (() => { const method = (() => {
if (element.strokeSharpness !== "sharp") { if (element.roundness) {
return "curve"; return "curve";
} }
if (options.fill) { if (options.fill) {
@@ -420,7 +404,23 @@ const getLinearElementRotatedBounds = (
cy, cy,
element.angle, element.angle,
); );
return [x, y, x, y];
let coords: [number, number, number, number] = [x, y, x, y];
const boundTextElement = getBoundTextElement(element);
if (boundTextElement) {
const coordsWithBoundText = LinearElementEditor.getMinMaxXYWithBoundText(
element,
[x, y, x, y],
boundTextElement,
);
coords = [
coordsWithBoundText[0],
coordsWithBoundText[1],
coordsWithBoundText[2],
coordsWithBoundText[3],
];
}
return coords;
} }
// first element is always the curve // first element is always the curve
@@ -429,8 +429,28 @@ const getLinearElementRotatedBounds = (
const ops = getCurvePathOps(shape); const ops = getCurvePathOps(shape);
const transformXY = (x: number, y: number) => const transformXY = (x: number, y: number) =>
rotate(element.x + x, element.y + y, cx, cy, element.angle); rotate(element.x + x, element.y + y, cx, cy, element.angle);
const res = getMinMaxXYFromCurvePathOps(ops, transformXY);
return getMinMaxXYFromCurvePathOps(ops, transformXY); let coords: [number, number, number, number] = [
res[0],
res[1],
res[2],
res[3],
];
const boundTextElement = getBoundTextElement(element);
if (boundTextElement) {
const coordsWithBoundText = LinearElementEditor.getMinMaxXYWithBoundText(
element,
coords,
boundTextElement,
);
coords = [
coordsWithBoundText[0],
coordsWithBoundText[1],
coordsWithBoundText[2],
coordsWithBoundText[3],
];
}
return coords;
}; };
// We could cache this stuff // We could cache this stuff
@@ -439,9 +459,7 @@ export const getElementBounds = (
): [number, number, number, number] => { ): [number, number, number, number] => {
let bounds: [number, number, number, number]; let bounds: [number, number, number, number];
const [x1, y1, x2, y2] = getElementAbsoluteCoords(element); const [x1, y1, x2, y2, cx, cy] = getElementAbsoluteCoords(element);
const cx = (x1 + x2) / 2;
const cy = (y1 + y2) / 2;
if (isFreeDrawElement(element)) { if (isFreeDrawElement(element)) {
const [minX, minY, maxX, maxY] = getBoundsFromPoints( const [minX, minY, maxX, maxY] = getBoundsFromPoints(
element.points.map(([x, y]) => element.points.map(([x, y]) =>
@@ -543,16 +561,12 @@ export const getResizedElementAbsoluteCoords = (
} else { } else {
// Line // Line
const gen = rough.generator(); const gen = rough.generator();
const curve = const curve = !element.roundness
element.strokeSharpness === "sharp" ? gen.linearPath(
? gen.linearPath( points as [number, number][],
points as [number, number][], generateRoughOptions(element),
generateRoughOptions(element), )
) : gen.curve(points as [number, number][], generateRoughOptions(element));
: gen.curve(
points as [number, number][],
generateRoughOptions(element),
);
const ops = getCurvePathOps(curve); const ops = getCurvePathOps(curve);
bounds = getMinMaxXYFromCurvePathOps(ops); bounds = getMinMaxXYFromCurvePathOps(ops);
@@ -570,12 +584,11 @@ export const getResizedElementAbsoluteCoords = (
export const getElementPointsCoords = ( export const getElementPointsCoords = (
element: ExcalidrawLinearElement, element: ExcalidrawLinearElement,
points: readonly (readonly [number, number])[], points: readonly (readonly [number, number])[],
sharpness: ExcalidrawElement["strokeSharpness"],
): [number, number, number, number] => { ): [number, number, number, number] => {
// This might be computationally heavey // This might be computationally heavey
const gen = rough.generator(); const gen = rough.generator();
const curve = const curve =
sharpness === "sharp" element.roundness == null
? gen.linearPath( ? gen.linearPath(
points as [number, number][], points as [number, number][],
generateRoughOptions(element), generateRoughOptions(element),
+31 -9
View File
@@ -25,6 +25,7 @@ import {
ExcalidrawFreeDrawElement, ExcalidrawFreeDrawElement,
ExcalidrawImageElement, ExcalidrawImageElement,
ExcalidrawLinearElement, ExcalidrawLinearElement,
StrokeRoundness,
} from "./types"; } from "./types";
import { getElementAbsoluteCoords, getCurvePathOps, Bounds } from "./bounds"; import { getElementAbsoluteCoords, getCurvePathOps, Bounds } from "./bounds";
@@ -36,6 +37,7 @@ import { hasBoundTextElement, isImageElement } from "./typeChecks";
import { isTextElement } from "."; import { isTextElement } from ".";
import { isTransparent } from "../utils"; import { isTransparent } from "../utils";
import { shouldShowBoundingBox } from "./transformHandles"; import { shouldShowBoundingBox } from "./transformHandles";
import { getBoundTextElement } from "./textElement";
const isElementDraggableFromInside = ( const isElementDraggableFromInside = (
element: NonDeletedExcalidrawElement, element: NonDeletedExcalidrawElement,
@@ -72,6 +74,13 @@ export const hitTest = (
return isPointHittingElementBoundingBox(element, point, threshold); return isPointHittingElementBoundingBox(element, point, threshold);
} }
const boundTextElement = getBoundTextElement(element);
if (boundTextElement) {
const isHittingBoundTextElement = hitTest(boundTextElement, appState, x, y);
if (isHittingBoundTextElement) {
return true;
}
}
return isHittingElementNotConsideringBoundingBox(element, appState, point); return isHittingElementNotConsideringBoundingBox(element, appState, point);
}; };
@@ -83,6 +92,13 @@ export const isHittingElementBoundingBoxWithoutHittingElement = (
): boolean => { ): boolean => {
const threshold = 10 / appState.zoom.value; const threshold = 10 / appState.zoom.value;
// So that bound text element hit is considered within bounding box of container even if its outside actual bounding box of element
// eg for linear elements text can be outside the element bounding box
const boundTextElement = getBoundTextElement(element);
if (boundTextElement && hitTest(boundTextElement, appState, x, y)) {
return false;
}
return ( return (
!isHittingElementNotConsideringBoundingBox(element, appState, [x, y]) && !isHittingElementNotConsideringBoundingBox(element, appState, [x, y]) &&
isPointHittingElementBoundingBox(element, [x, y], threshold) isPointHittingElementBoundingBox(element, [x, y], threshold)
@@ -95,7 +111,6 @@ export const isHittingElementNotConsideringBoundingBox = (
point: Point, point: Point,
): boolean => { ): boolean => {
const threshold = 10 / appState.zoom.value; const threshold = 10 / appState.zoom.value;
const check = isTextElement(element) const check = isTextElement(element)
? isStrictlyInside ? isStrictlyInside
: isElementDraggableFromInside(element) : isElementDraggableFromInside(element)
@@ -382,6 +397,7 @@ const hitTestLinear = (args: HitTestArgs): boolean => {
if (!getShapeForElement(element)) { if (!getShapeForElement(element)) {
return false; return false;
} }
const [point, pointAbs, hwidth, hheight] = pointRelativeToElement( const [point, pointAbs, hwidth, hheight] = pointRelativeToElement(
args.element, args.element,
args.point, args.point,
@@ -404,7 +420,12 @@ const hitTestLinear = (args: HitTestArgs): boolean => {
if (args.check === isInsideCheck) { if (args.check === isInsideCheck) {
const hit = shape.some((subshape) => const hit = shape.some((subshape) =>
hitTestCurveInside(subshape, relX, relY, element.strokeSharpness), hitTestCurveInside(
subshape,
relX,
relY,
element.roundness ? "round" : "sharp",
),
); );
if (hit) { if (hit) {
return true; return true;
@@ -434,8 +455,9 @@ const pointRelativeToElement = (
pointTuple: Point, pointTuple: Point,
): [GA.Point, GA.Point, number, number] => { ): [GA.Point, GA.Point, number, number] => {
const point = GAPoint.from(pointTuple); const point = GAPoint.from(pointTuple);
const [x1, y1, x2, y2] = getElementAbsoluteCoords(element);
const elementCoords = getElementAbsoluteCoords(element); const elementCoords = getElementAbsoluteCoords(element);
const center = coordsCenter(elementCoords); const center = coordsCenter([x1, y1, x2, y2]);
// GA has angle orientation opposite to `rotate` // GA has angle orientation opposite to `rotate`
const rotate = GATransform.rotation(center, element.angle); const rotate = GATransform.rotation(center, element.angle);
const pointRotated = GATransform.apply(rotate, point); const pointRotated = GATransform.apply(rotate, point);
@@ -466,8 +488,8 @@ export const pointInAbsoluteCoords = (
const relativizationToElementCenter = ( const relativizationToElementCenter = (
element: ExcalidrawElement, element: ExcalidrawElement,
): GA.Transform => { ): GA.Transform => {
const elementCoords = getElementAbsoluteCoords(element); const [x1, y1, x2, y2] = getElementAbsoluteCoords(element);
const center = coordsCenter(elementCoords); const center = coordsCenter([x1, y1, x2, y2]);
// GA has angle orientation opposite to `rotate` // GA has angle orientation opposite to `rotate`
const rotate = GATransform.rotation(center, element.angle); const rotate = GATransform.rotation(center, element.angle);
const translate = GA.reverse( const translate = GA.reverse(
@@ -524,8 +546,8 @@ export const determineFocusPoint = (
adjecentPoint: Point, adjecentPoint: Point,
): Point => { ): Point => {
if (focus === 0) { if (focus === 0) {
const elementCoords = getElementAbsoluteCoords(element); const [x1, y1, x2, y2] = getElementAbsoluteCoords(element);
const center = coordsCenter(elementCoords); const center = coordsCenter([x1, y1, x2, y2]);
return GAPoint.toTuple(center); return GAPoint.toTuple(center);
} }
const relateToCenter = relativizationToElementCenter(element); const relateToCenter = relativizationToElementCenter(element);
@@ -835,7 +857,7 @@ const hitTestCurveInside = (
drawable: Drawable, drawable: Drawable,
x: number, x: number,
y: number, y: number,
sharpness: ExcalidrawElement["strokeSharpness"], roundness: StrokeRoundness,
) => { ) => {
const ops = getCurvePathOps(drawable); const ops = getCurvePathOps(drawable);
const points: Mutable<Point>[] = []; const points: Mutable<Point>[] = [];
@@ -859,7 +881,7 @@ const hitTestCurveInside = (
} }
} }
if (points.length >= 4) { if (points.length >= 4) {
if (sharpness === "sharp") { if (roundness === "sharp") {
return isPointInPolygon(points, x, y); return isPointInPolygon(points, x, y);
} }
const polygonPoints = pointsOnBezierCurves(points, 10, 5); const polygonPoints = pointsOnBezierCurves(points, 10, 5);
+349 -54
View File
@@ -4,6 +4,7 @@ import {
ExcalidrawElement, ExcalidrawElement,
PointBinding, PointBinding,
ExcalidrawBindableElement, ExcalidrawBindableElement,
ExcalidrawTextElementWithContainer,
} from "./types"; } from "./types";
import { import {
distance2d, distance2d,
@@ -19,8 +20,12 @@ import {
arePointsEqual, arePointsEqual,
} from "../math"; } from "../math";
import { getElementAbsoluteCoords, getLockedLinearCursorAlignSize } from "."; import { getElementAbsoluteCoords, getLockedLinearCursorAlignSize } from ".";
import { getElementPointsCoords } from "./bounds"; import {
import { Point, AppState } from "../types"; getCurvePathOps,
getElementPointsCoords,
getMinMaxXYFromCurvePathOps,
} from "./bounds";
import { Point, AppState, PointerCoords } from "../types";
import { mutateElement } from "./mutateElement"; import { mutateElement } from "./mutateElement";
import History from "../history"; import History from "../history";
@@ -33,13 +38,15 @@ import {
import { tupleToCoors } from "../utils"; import { tupleToCoors } from "../utils";
import { isBindingElement } from "./typeChecks"; import { isBindingElement } from "./typeChecks";
import { shouldRotateWithDiscreteAngle } from "../keys"; import { shouldRotateWithDiscreteAngle } from "../keys";
import { getBoundTextElement, handleBindTextResize } from "./textElement";
import { getShapeForElement } from "../renderer/renderElement";
import { DRAGGING_THRESHOLD } from "../constants";
const editorMidPointsCache: { const editorMidPointsCache: {
version: number | null; version: number | null;
points: (Point | null)[]; points: (Point | null)[];
zoom: number | null; zoom: number | null;
} = { version: null, points: [], zoom: null }; } = { version: null, points: [], zoom: null };
export class LinearElementEditor { export class LinearElementEditor {
public readonly elementId: ExcalidrawElement["id"] & { public readonly elementId: ExcalidrawElement["id"] & {
_brand: "excalidrawLinearElementId"; _brand: "excalidrawLinearElementId";
@@ -51,6 +58,12 @@ export class LinearElementEditor {
prevSelectedPointsIndices: readonly number[] | null; prevSelectedPointsIndices: readonly number[] | null;
/** index */ /** index */
lastClickedPoint: number; lastClickedPoint: number;
origin: Readonly<{ x: number; y: number }> | null;
segmentMidpoint: {
value: Point | null;
index: number | null;
added: boolean;
};
}>; }>;
/** whether you're dragging a point */ /** whether you're dragging a point */
@@ -81,6 +94,13 @@ export class LinearElementEditor {
this.pointerDownState = { this.pointerDownState = {
prevSelectedPointsIndices: null, prevSelectedPointsIndices: null,
lastClickedPoint: -1, lastClickedPoint: -1,
origin: null,
segmentMidpoint: {
value: null,
index: null,
added: false,
},
}; };
this.hoverPointIndex = -1; this.hoverPointIndex = -1;
this.segmentMidPointHoveredCoords = null; this.segmentMidPointHoveredCoords = null;
@@ -180,6 +200,7 @@ export class LinearElementEditor {
const draggingPoint = element.points[ const draggingPoint = element.points[
linearElementEditor.pointerDownState.lastClickedPoint linearElementEditor.pointerDownState.lastClickedPoint
] as [number, number] | undefined; ] as [number, number] | undefined;
if (selectedPointsIndices && draggingPoint) { if (selectedPointsIndices && draggingPoint) {
if ( if (
shouldRotateWithDiscreteAngle(event) && shouldRotateWithDiscreteAngle(event) &&
@@ -242,6 +263,11 @@ export class LinearElementEditor {
}; };
}), }),
); );
const boundTextElement = getBoundTextElement(element);
if (boundTextElement) {
handleBindTextResize(element, false);
}
} }
// suggest bindings for first and last point if selected // suggest bindings for first and last point if selected
@@ -373,8 +399,14 @@ export class LinearElementEditor {
element: NonDeleted<ExcalidrawLinearElement>, element: NonDeleted<ExcalidrawLinearElement>,
appState: AppState, appState: AppState,
): typeof editorMidPointsCache["points"] => { ): typeof editorMidPointsCache["points"] => {
// Since its not needed outside editor unless 2 pointer lines const boundText = getBoundTextElement(element);
if (!appState.editingLinearElement && element.points.length > 2) {
// Since its not needed outside editor unless 2 pointer lines or bound text
if (
!appState.editingLinearElement &&
element.points.length > 2 &&
!boundText
) {
return []; return [];
} }
if ( if (
@@ -495,7 +527,7 @@ export class LinearElementEditor {
endPoint[0], endPoint[0],
endPoint[1], endPoint[1],
); );
if (element.points.length > 2 && element.strokeSharpness === "round") { if (element.points.length > 2 && element.roundness) {
distance = getBezierCurveLength(element, endPoint); distance = getBezierCurveLength(element, endPoint);
} }
@@ -509,7 +541,7 @@ export class LinearElementEditor {
endPointIndex: number, endPointIndex: number,
) { ) {
let segmentMidPoint = centerPoint(startPoint, endPoint); let segmentMidPoint = centerPoint(startPoint, endPoint);
if (element.points.length > 2 && element.strokeSharpness === "round") { if (element.points.length > 2 && element.roundness) {
const controlPoints = getControlPointsForBezierCurve( const controlPoints = getControlPointsForBezierCurve(
element, element,
element.points[endPointIndex], element.points[endPointIndex],
@@ -551,7 +583,7 @@ export class LinearElementEditor {
} }
const midPoints = LinearElementEditor.getEditorMidPoints(element, appState); const midPoints = LinearElementEditor.getEditorMidPoints(element, appState);
let index = 0; let index = 0;
while (index < midPoints.length - 1) { while (index < midPoints.length) {
if (LinearElementEditor.arePointsEqual(midPoint, midPoints[index])) { if (LinearElementEditor.arePointsEqual(midPoint, midPoints[index])) {
return index + 1; return index + 1;
} }
@@ -570,13 +602,11 @@ export class LinearElementEditor {
didAddPoint: boolean; didAddPoint: boolean;
hitElement: NonDeleted<ExcalidrawElement> | null; hitElement: NonDeleted<ExcalidrawElement> | null;
linearElementEditor: LinearElementEditor | null; linearElementEditor: LinearElementEditor | null;
isMidPoint: boolean;
} { } {
const ret: ReturnType<typeof LinearElementEditor["handlePointerDown"]> = { const ret: ReturnType<typeof LinearElementEditor["handlePointerDown"]> = {
didAddPoint: false, didAddPoint: false,
hitElement: null, hitElement: null,
linearElementEditor: null, linearElementEditor: null,
isMidPoint: false,
}; };
if (!linearElementEditor) { if (!linearElementEditor) {
@@ -589,43 +619,18 @@ export class LinearElementEditor {
if (!element) { if (!element) {
return ret; return ret;
} }
const segmentMidPoint = LinearElementEditor.getSegmentMidpointHitCoords( const segmentMidpoint = LinearElementEditor.getSegmentMidpointHitCoords(
linearElementEditor, linearElementEditor,
scenePointer, scenePointer,
appState, appState,
); );
if (segmentMidPoint) { let segmentMidpointIndex = null;
const index = LinearElementEditor.getSegmentMidPointIndex( if (segmentMidpoint) {
segmentMidpointIndex = LinearElementEditor.getSegmentMidPointIndex(
linearElementEditor, linearElementEditor,
appState, appState,
segmentMidPoint, segmentMidpoint,
); );
const newMidPoint = LinearElementEditor.createPointAt(
element,
segmentMidPoint[0],
segmentMidPoint[1],
appState.gridSize,
);
const points = [
...element.points.slice(0, index),
newMidPoint,
...element.points.slice(index),
];
mutateElement(element, {
points,
});
ret.didAddPoint = true;
ret.isMidPoint = true;
ret.linearElementEditor = {
...linearElementEditor,
selectedPointsIndices: element.points[1],
pointerDownState: {
prevSelectedPointsIndices: linearElementEditor.selectedPointsIndices,
lastClickedPoint: -1,
},
lastUncommittedPoint: null,
};
} }
if (event.altKey && appState.editingLinearElement) { if (event.altKey && appState.editingLinearElement) {
if (linearElementEditor.lastUncommittedPoint == null) { if (linearElementEditor.lastUncommittedPoint == null) {
@@ -648,6 +653,12 @@ export class LinearElementEditor {
pointerDownState: { pointerDownState: {
prevSelectedPointsIndices: linearElementEditor.selectedPointsIndices, prevSelectedPointsIndices: linearElementEditor.selectedPointsIndices,
lastClickedPoint: -1, lastClickedPoint: -1,
origin: { x: scenePointer.x, y: scenePointer.y },
segmentMidpoint: {
value: segmentMidpoint,
index: segmentMidpointIndex,
added: false,
},
}, },
selectedPointsIndices: [element.points.length - 1], selectedPointsIndices: [element.points.length - 1],
lastUncommittedPoint: null, lastUncommittedPoint: null,
@@ -667,10 +678,9 @@ export class LinearElementEditor {
scenePointer.x, scenePointer.x,
scenePointer.y, scenePointer.y,
); );
// if we clicked on a point, set the element as hitElement otherwise // if we clicked on a point, set the element as hitElement otherwise
// it would get deselected if the point is outside the hitbox area // it would get deselected if the point is outside the hitbox area
if (clickedPointIndex >= 0 || segmentMidPoint) { if (clickedPointIndex >= 0 || segmentMidpoint) {
ret.hitElement = element; ret.hitElement = element;
} else { } else {
// You might be wandering why we are storing the binding elements on // You might be wandering why we are storing the binding elements on
@@ -716,6 +726,12 @@ export class LinearElementEditor {
pointerDownState: { pointerDownState: {
prevSelectedPointsIndices: linearElementEditor.selectedPointsIndices, prevSelectedPointsIndices: linearElementEditor.selectedPointsIndices,
lastClickedPoint: clickedPointIndex, lastClickedPoint: clickedPointIndex,
origin: { x: scenePointer.x, y: scenePointer.y },
segmentMidpoint: {
value: segmentMidpoint,
index: segmentMidpointIndex,
added: false,
},
}, },
selectedPointsIndices: nextSelectedPointsIndices, selectedPointsIndices: nextSelectedPointsIndices,
pointerOffset: targetPoint pointerOffset: targetPoint
@@ -1055,7 +1071,6 @@ export class LinearElementEditor {
const offsetY = 0; const offsetY = 0;
const nextPoints = [...element.points, ...targetPoints.map((x) => x.point)]; const nextPoints = [...element.points, ...targetPoints.map((x) => x.point)];
LinearElementEditor._updatePoints(element, nextPoints, offsetX, offsetY); LinearElementEditor._updatePoints(element, nextPoints, offsetX, offsetY);
} }
@@ -1111,6 +1126,94 @@ export class LinearElementEditor {
); );
} }
static shouldAddMidpoint(
linearElementEditor: LinearElementEditor,
pointerCoords: PointerCoords,
appState: AppState,
) {
const element = LinearElementEditor.getElement(
linearElementEditor.elementId,
);
if (!element) {
return false;
}
const { segmentMidpoint } = linearElementEditor.pointerDownState;
if (
segmentMidpoint.added ||
segmentMidpoint.value === null ||
segmentMidpoint.index === null ||
linearElementEditor.pointerDownState.origin === null
) {
return false;
}
const origin = linearElementEditor.pointerDownState.origin!;
const dist = distance2d(
origin.x,
origin.y,
pointerCoords.x,
pointerCoords.y,
);
if (
!appState.editingLinearElement &&
dist < DRAGGING_THRESHOLD / appState.zoom.value
) {
return false;
}
return true;
}
static addMidpoint(
linearElementEditor: LinearElementEditor,
pointerCoords: PointerCoords,
appState: AppState,
) {
const element = LinearElementEditor.getElement(
linearElementEditor.elementId,
);
if (!element) {
return;
}
const { segmentMidpoint } = linearElementEditor.pointerDownState;
const ret: {
pointerDownState: LinearElementEditor["pointerDownState"];
selectedPointsIndices: LinearElementEditor["selectedPointsIndices"];
} = {
pointerDownState: linearElementEditor.pointerDownState,
selectedPointsIndices: linearElementEditor.selectedPointsIndices,
};
const midpoint = LinearElementEditor.createPointAt(
element,
pointerCoords.x,
pointerCoords.y,
appState.gridSize,
);
const points = [
...element.points.slice(0, segmentMidpoint.index!),
midpoint,
...element.points.slice(segmentMidpoint.index!),
];
mutateElement(element, {
points,
});
ret.pointerDownState = {
...linearElementEditor.pointerDownState,
segmentMidpoint: {
...linearElementEditor.pointerDownState.segmentMidpoint,
added: true,
},
lastClickedPoint: segmentMidpoint.index!,
};
ret.selectedPointsIndices = [segmentMidpoint.index!];
return ret;
}
private static _updatePoints( private static _updatePoints(
element: NonDeleted<ExcalidrawLinearElement>, element: NonDeleted<ExcalidrawLinearElement>,
nextPoints: readonly Point[], nextPoints: readonly Point[],
@@ -1118,16 +1221,8 @@ export class LinearElementEditor {
offsetY: number, offsetY: number,
otherUpdates?: { startBinding?: PointBinding; endBinding?: PointBinding }, otherUpdates?: { startBinding?: PointBinding; endBinding?: PointBinding },
) { ) {
const nextCoords = getElementPointsCoords( const nextCoords = getElementPointsCoords(element, nextPoints);
element, const prevCoords = getElementPointsCoords(element, element.points);
nextPoints,
element.strokeSharpness || "round",
);
const prevCoords = getElementPointsCoords(
element,
element.points,
element.strokeSharpness || "round",
);
const nextCenterX = (nextCoords[0] + nextCoords[2]) / 2; const nextCenterX = (nextCoords[0] + nextCoords[2]) / 2;
const nextCenterY = (nextCoords[1] + nextCoords[3]) / 2; const nextCenterY = (nextCoords[1] + nextCoords[3]) / 2;
const prevCenterX = (prevCoords[0] + prevCoords[2]) / 2; const prevCenterX = (prevCoords[0] + prevCoords[2]) / 2;
@@ -1135,7 +1230,6 @@ export class LinearElementEditor {
const dX = prevCenterX - nextCenterX; const dX = prevCenterX - nextCenterX;
const dY = prevCenterY - nextCenterY; const dY = prevCenterY - nextCenterY;
const rotated = rotate(offsetX, offsetY, dX, dY, element.angle); const rotated = rotate(offsetX, offsetY, dX, dY, element.angle);
mutateElement(element, { mutateElement(element, {
...otherUpdates, ...otherUpdates,
points: nextPoints, points: nextPoints,
@@ -1170,6 +1264,207 @@ export class LinearElementEditor {
return rotatePoint([width, height], [0, 0], -element.angle); return rotatePoint([width, height], [0, 0], -element.angle);
} }
static getBoundTextElementPosition = (
element: ExcalidrawLinearElement,
boundTextElement: ExcalidrawTextElementWithContainer,
): { x: number; y: number } => {
const points = LinearElementEditor.getPointsGlobalCoordinates(element);
if (points.length < 2) {
mutateElement(boundTextElement, { isDeleted: true });
}
let x = 0;
let y = 0;
if (element.points.length % 2 === 1) {
const index = Math.floor(element.points.length / 2);
const midPoint = LinearElementEditor.getPointGlobalCoordinates(
element,
element.points[index],
);
x = midPoint[0] - boundTextElement.width / 2;
y = midPoint[1] - boundTextElement.height / 2;
} else {
const index = element.points.length / 2 - 1;
let midSegmentMidpoint = editorMidPointsCache.points[index];
if (element.points.length === 2) {
midSegmentMidpoint = centerPoint(points[0], points[1]);
}
if (
!midSegmentMidpoint ||
editorMidPointsCache.version !== element.version
) {
midSegmentMidpoint = LinearElementEditor.getSegmentMidPoint(
element,
points[index],
points[index + 1],
index + 1,
);
}
x = midSegmentMidpoint[0] - boundTextElement.width / 2;
y = midSegmentMidpoint[1] - boundTextElement.height / 2;
}
return { x, y };
};
static getMinMaxXYWithBoundText = (
element: ExcalidrawLinearElement,
elementBounds: [number, number, number, number],
boundTextElement: ExcalidrawTextElementWithContainer,
): [number, number, number, number, number, number] => {
let [x1, y1, x2, y2] = elementBounds;
const cx = (x1 + x2) / 2;
const cy = (y1 + y2) / 2;
const { x: boundTextX1, y: boundTextY1 } =
LinearElementEditor.getBoundTextElementPosition(
element,
boundTextElement,
);
const boundTextX2 = boundTextX1 + boundTextElement.width;
const boundTextY2 = boundTextY1 + boundTextElement.height;
const topLeftRotatedPoint = rotatePoint([x1, y1], [cx, cy], element.angle);
const topRightRotatedPoint = rotatePoint([x2, y1], [cx, cy], element.angle);
const counterRotateBoundTextTopLeft = rotatePoint(
[boundTextX1, boundTextY1],
[cx, cy],
-element.angle,
);
const counterRotateBoundTextTopRight = rotatePoint(
[boundTextX2, boundTextY1],
[cx, cy],
-element.angle,
);
const counterRotateBoundTextBottomLeft = rotatePoint(
[boundTextX1, boundTextY2],
[cx, cy],
-element.angle,
);
const counterRotateBoundTextBottomRight = rotatePoint(
[boundTextX2, boundTextY2],
[cx, cy],
-element.angle,
);
if (
topLeftRotatedPoint[0] < topRightRotatedPoint[0] &&
topLeftRotatedPoint[1] >= topRightRotatedPoint[1]
) {
x1 = Math.min(x1, counterRotateBoundTextBottomLeft[0]);
x2 = Math.max(
x2,
Math.max(
counterRotateBoundTextTopRight[0],
counterRotateBoundTextBottomRight[0],
),
);
y1 = Math.min(y1, counterRotateBoundTextTopLeft[1]);
y2 = Math.max(y2, counterRotateBoundTextBottomRight[1]);
} else if (
topLeftRotatedPoint[0] >= topRightRotatedPoint[0] &&
topLeftRotatedPoint[1] > topRightRotatedPoint[1]
) {
x1 = Math.min(x1, counterRotateBoundTextBottomRight[0]);
x2 = Math.max(
x2,
Math.max(
counterRotateBoundTextTopLeft[0],
counterRotateBoundTextTopRight[0],
),
);
y1 = Math.min(y1, counterRotateBoundTextBottomLeft[1]);
y2 = Math.max(y2, counterRotateBoundTextTopRight[1]);
} else if (topLeftRotatedPoint[0] >= topRightRotatedPoint[0]) {
x1 = Math.min(x1, counterRotateBoundTextTopRight[0]);
x2 = Math.max(x2, counterRotateBoundTextBottomLeft[0]);
y1 = Math.min(y1, counterRotateBoundTextBottomRight[1]);
y2 = Math.max(y2, counterRotateBoundTextTopLeft[1]);
} else if (topLeftRotatedPoint[1] <= topRightRotatedPoint[1]) {
x1 = Math.min(
x1,
Math.min(
counterRotateBoundTextTopRight[0],
counterRotateBoundTextTopLeft[0],
),
);
x2 = Math.max(x2, counterRotateBoundTextBottomRight[0]);
y1 = Math.min(y1, counterRotateBoundTextTopRight[1]);
y2 = Math.max(y2, counterRotateBoundTextBottomLeft[1]);
}
return [x1, y1, x2, y2, cx, cy];
};
static getElementAbsoluteCoords = (
element: ExcalidrawLinearElement,
includeBoundText: boolean = false,
): [number, number, number, number, number, number] => {
let coords: [number, number, number, number, number, number];
let x1;
let y1;
let x2;
let y2;
if (element.points.length < 2 || !getShapeForElement(element)) {
// XXX this is just a poor estimate and not very useful
const { minX, minY, maxX, maxY } = element.points.reduce(
(limits, [x, y]) => {
limits.minY = Math.min(limits.minY, y);
limits.minX = Math.min(limits.minX, x);
limits.maxX = Math.max(limits.maxX, x);
limits.maxY = Math.max(limits.maxY, y);
return limits;
},
{ minX: Infinity, minY: Infinity, maxX: -Infinity, maxY: -Infinity },
);
x1 = minX + element.x;
y1 = minY + element.y;
x2 = maxX + element.x;
y2 = maxY + element.y;
} else {
const shape = getShapeForElement(element)!;
// first element is always the curve
const ops = getCurvePathOps(shape[0]);
const [minX, minY, maxX, maxY] = getMinMaxXYFromCurvePathOps(ops);
x1 = minX + element.x;
y1 = minY + element.y;
x2 = maxX + element.x;
y2 = maxY + element.y;
}
const cx = (x1 + x2) / 2;
const cy = (y1 + y2) / 2;
coords = [x1, y1, x2, y2, cx, cy];
if (!includeBoundText) {
return coords;
}
const boundTextElement = getBoundTextElement(element);
if (boundTextElement) {
coords = LinearElementEditor.getMinMaxXYWithBoundText(
element,
[x1, y1, x2, y2],
boundTextElement,
);
}
return coords;
};
} }
const normalizeSelectedPoints = ( const normalizeSelectedPoints = (
+3 -3
View File
@@ -1,7 +1,7 @@
import { duplicateElement } from "./newElement"; import { duplicateElement } from "./newElement";
import { mutateElement } from "./mutateElement"; import { mutateElement } from "./mutateElement";
import { API } from "../tests/helpers/api"; import { API } from "../tests/helpers/api";
import { FONT_FAMILY } from "../constants"; import { FONT_FAMILY, ROUNDNESS } from "../constants";
import { isPrimitive } from "../utils"; import { isPrimitive } from "../utils";
const assertCloneObjects = (source: any, clone: any) => { const assertCloneObjects = (source: any, clone: any) => {
@@ -25,7 +25,7 @@ it("clones arrow element", () => {
fillStyle: "hachure", fillStyle: "hachure",
strokeWidth: 1, strokeWidth: 1,
strokeStyle: "solid", strokeStyle: "solid",
strokeSharpness: "round", roundness: { type: ROUNDNESS.PROPORTIONAL_RADIUS },
roughness: 1, roughness: 1,
opacity: 100, opacity: 100,
}); });
@@ -71,7 +71,7 @@ it("clones text element", () => {
fillStyle: "hachure", fillStyle: "hachure",
strokeWidth: 1, strokeWidth: 1,
strokeStyle: "solid", strokeStyle: "solid",
strokeSharpness: "round", roundness: null,
roughness: 1, roughness: 1,
opacity: 100, opacity: 100,
text: "hello", text: "hello",
+55 -19
View File
@@ -11,7 +11,7 @@ import {
Arrowhead, Arrowhead,
ExcalidrawFreeDrawElement, ExcalidrawFreeDrawElement,
FontFamilyValues, FontFamilyValues,
ExcalidrawRectangleElement, ExcalidrawTextContainer,
} from "../element/types"; } from "../element/types";
import { getFontString, getUpdatedTimestamp, isTestEnv } from "../utils"; import { getFontString, getUpdatedTimestamp, isTestEnv } from "../utils";
import { randomInteger, randomId } from "../random"; import { randomInteger, randomId } from "../random";
@@ -22,12 +22,16 @@ import { getElementAbsoluteCoords } from ".";
import { adjustXYWithRotation } from "../math"; import { adjustXYWithRotation } from "../math";
import { getResizedElementAbsoluteCoords } from "./bounds"; import { getResizedElementAbsoluteCoords } from "./bounds";
import { import {
getBoundTextElement,
getBoundTextElementOffset,
getContainerDims, getContainerDims,
getContainerElement, getContainerElement,
measureText, measureText,
normalizeText,
wrapText, wrapText,
} from "./textElement"; } from "./textElement";
import { BOUND_TEXT_PADDING, VERTICAL_ALIGN } from "../constants"; import { BOUND_TEXT_PADDING, VERTICAL_ALIGN } from "../constants";
import { isArrowElement } from "./typeChecks";
type ElementConstructorOpts = MarkOptional< type ElementConstructorOpts = MarkOptional<
Omit<ExcalidrawGenericElement, "id" | "type" | "isDeleted" | "updated">, Omit<ExcalidrawGenericElement, "id" | "type" | "isDeleted" | "updated">,
@@ -58,14 +62,15 @@ const _newElementBase = <T extends ExcalidrawElement>(
height = 0, height = 0,
angle = 0, angle = 0,
groupIds = [], groupIds = [],
strokeSharpness, roundness = null,
boundElements = null, boundElements = null,
link = null, link = null,
locked, locked,
...rest ...rest
}: ElementConstructorOpts & Omit<Partial<ExcalidrawGenericElement>, "type">, }: ElementConstructorOpts & Omit<Partial<ExcalidrawGenericElement>, "type">,
) => { ) => {
const element = { // assign type to guard against excess properties
const element: Merge<ExcalidrawGenericElement, { type: T["type"] }> = {
id: rest.id || randomId(), id: rest.id || randomId(),
type, type,
x, x,
@@ -81,7 +86,7 @@ const _newElementBase = <T extends ExcalidrawElement>(
roughness, roughness,
opacity, opacity,
groupIds, groupIds,
strokeSharpness, roundness,
seed: rest.seed ?? randomInteger(), seed: rest.seed ?? randomInteger(),
version: rest.version || 1, version: rest.version || 1,
versionNonce: rest.versionNonce ?? 0, versionNonce: rest.versionNonce ?? 0,
@@ -130,15 +135,16 @@ export const newTextElement = (
fontFamily: FontFamilyValues; fontFamily: FontFamilyValues;
textAlign: TextAlign; textAlign: TextAlign;
verticalAlign: VerticalAlign; verticalAlign: VerticalAlign;
containerId?: ExcalidrawRectangleElement["id"]; containerId?: ExcalidrawTextContainer["id"];
} & ElementConstructorOpts, } & ElementConstructorOpts,
): NonDeleted<ExcalidrawTextElement> => { ): NonDeleted<ExcalidrawTextElement> => {
const metrics = measureText(opts.text, getFontString(opts)); const text = normalizeText(opts.text);
const metrics = measureText(text, getFontString(opts));
const offsets = getTextElementPositionOffsets(opts, metrics); const offsets = getTextElementPositionOffsets(opts, metrics);
const textElement = newElementWith( const textElement = newElementWith(
{ {
..._newElementBase<ExcalidrawTextElement>("text", opts), ..._newElementBase<ExcalidrawTextElement>("text", opts),
text: opts.text, text,
fontSize: opts.fontSize, fontSize: opts.fontSize,
fontFamily: opts.fontFamily, fontFamily: opts.fontFamily,
textAlign: opts.textAlign, textAlign: opts.textAlign,
@@ -149,7 +155,7 @@ export const newTextElement = (
height: metrics.height, height: metrics.height,
baseline: metrics.baseline, baseline: metrics.baseline,
containerId: opts.containerId || null, containerId: opts.containerId || null,
originalText: opts.text, originalText: text,
}, },
{}, {},
); );
@@ -169,8 +175,7 @@ const getAdjustedDimensions = (
let maxWidth = null; let maxWidth = null;
const container = getContainerElement(element); const container = getContainerElement(element);
if (container) { if (container) {
const containerDims = getContainerDims(container); maxWidth = getMaxContainerWidth(container);
maxWidth = containerDims.width - BOUND_TEXT_PADDING * 2;
} }
const { const {
width: nextWidth, width: nextWidth,
@@ -230,16 +235,21 @@ const getAdjustedDimensions = (
// make sure container dimensions are set properly when // make sure container dimensions are set properly when
// text editor overflows beyond viewport dimensions // text editor overflows beyond viewport dimensions
if (container) { if (container) {
const boundTextElementPadding = getBoundTextElementOffset(element);
const containerDims = getContainerDims(container); const containerDims = getContainerDims(container);
let height = containerDims.height; let height = containerDims.height;
let width = containerDims.width; let width = containerDims.width;
if (nextHeight > height - BOUND_TEXT_PADDING * 2) { if (nextHeight > height - boundTextElementPadding * 2) {
height = nextHeight + BOUND_TEXT_PADDING * 2; height = nextHeight + boundTextElementPadding * 2;
} }
if (nextWidth > width - BOUND_TEXT_PADDING * 2) { if (nextWidth > width - boundTextElementPadding * 2) {
width = nextWidth + BOUND_TEXT_PADDING * 2; width = nextWidth + boundTextElementPadding * 2;
} }
if (height !== containerDims.height || width !== containerDims.width) { if (
!isArrowElement(container) &&
(height !== containerDims.height || width !== containerDims.width)
) {
mutateElement(container, { height, width }); mutateElement(container, { height, width });
} }
} }
@@ -258,7 +268,6 @@ export const refreshTextDimensions = (
) => { ) => {
const container = getContainerElement(textElement); const container = getContainerElement(textElement);
if (container) { if (container) {
// text = wrapText(text, getFontString(textElement), container.width);
text = wrapText( text = wrapText(
text, text,
getFontString(textElement), getFontString(textElement),
@@ -270,11 +279,35 @@ export const refreshTextDimensions = (
}; };
export const getMaxContainerWidth = (container: ExcalidrawElement) => { export const getMaxContainerWidth = (container: ExcalidrawElement) => {
return getContainerDims(container).width - BOUND_TEXT_PADDING * 2; const width = getContainerDims(container).width;
if (isArrowElement(container)) {
const containerWidth = width - BOUND_TEXT_PADDING * 8 * 2;
if (containerWidth <= 0) {
const boundText = getBoundTextElement(container);
if (boundText) {
return boundText.width;
}
return BOUND_TEXT_PADDING * 8 * 2;
}
return containerWidth;
}
return width - BOUND_TEXT_PADDING * 2;
}; };
export const getMaxContainerHeight = (container: ExcalidrawElement) => { export const getMaxContainerHeight = (container: ExcalidrawElement) => {
return getContainerDims(container).height - BOUND_TEXT_PADDING * 2; const height = getContainerDims(container).height;
if (isArrowElement(container)) {
const containerHeight = height - BOUND_TEXT_PADDING * 8 * 2;
if (containerHeight <= 0) {
const boundText = getBoundTextElement(container);
if (boundText) {
return boundText.height;
}
return BOUND_TEXT_PADDING * 8 * 2;
}
return height;
}
return height - BOUND_TEXT_PADDING * 2;
}; };
export const updateTextElement = ( export const updateTextElement = (
@@ -366,7 +399,8 @@ export const deepCopyElement = (val: any, depth: number = 0) => {
: {}; : {};
for (const key in val) { for (const key in val) {
if (val.hasOwnProperty(key)) { if (val.hasOwnProperty(key)) {
// don't copy top-level shape property, which we want to regenerate // don't copy non-serializable objects like these caches. They'll be
// populated when the element is rendered.
if (depth === 0 && (key === "shape" || key === "canvas")) { if (depth === 0 && (key === "shape" || key === "canvas")) {
continue; continue;
} }
@@ -409,6 +443,7 @@ export const duplicateElement = <TElement extends Mutable<ExcalidrawElement>>(
overrides?: Partial<TElement>, overrides?: Partial<TElement>,
): TElement => { ): TElement => {
let copy: TElement = deepCopyElement(element); let copy: TElement = deepCopyElement(element);
if (isTestEnv()) { if (isTestEnv()) {
copy.id = `${copy.id}_copy`; copy.id = `${copy.id}_copy`;
// `window.h` may not be defined in some unit tests // `window.h` may not be defined in some unit tests
@@ -422,6 +457,7 @@ export const duplicateElement = <TElement extends Mutable<ExcalidrawElement>>(
} else { } else {
copy.id = randomId(); copy.id = randomId();
} }
copy.boundElements = null;
copy.updated = getUpdatedTimestamp(); copy.updated = getUpdatedTimestamp();
copy.seed = randomInteger(); copy.seed = randomInteger();
copy.groupIds = getNewGroupIdsForDuplication( copy.groupIds = getNewGroupIdsForDuplication(
+67 -125
View File
@@ -1,4 +1,4 @@
import { BOUND_TEXT_PADDING, SHIFT_LOCKING_ANGLE } from "../constants"; import { SHIFT_LOCKING_ANGLE } from "../constants";
import { rescalePoints } from "../points"; import { rescalePoints } from "../points";
import { import {
@@ -13,6 +13,7 @@ import {
NonDeletedExcalidrawElement, NonDeletedExcalidrawElement,
NonDeleted, NonDeleted,
ExcalidrawElement, ExcalidrawElement,
ExcalidrawTextElementWithContainer,
} from "./types"; } from "./types";
import { import {
getElementAbsoluteCoords, getElementAbsoluteCoords,
@@ -21,12 +22,13 @@ import {
getCommonBoundingBox, getCommonBoundingBox,
} from "./bounds"; } from "./bounds";
import { import {
isArrowElement,
isBoundToContainer,
isFreeDrawElement, isFreeDrawElement,
isLinearElement, isLinearElement,
isTextElement, isTextElement,
} from "./typeChecks"; } from "./typeChecks";
import { mutateElement } from "./mutateElement"; import { mutateElement } from "./mutateElement";
import { getPerfectElementSize } from "./sizeHelpers";
import { getFontString } from "../utils"; import { getFontString } from "../utils";
import { updateBoundElements } from "./binding"; import { updateBoundElements } from "./binding";
import { import {
@@ -41,9 +43,12 @@ import {
getApproxMinLineWidth, getApproxMinLineWidth,
getBoundTextElement, getBoundTextElement,
getBoundTextElementId, getBoundTextElementId,
getBoundTextElementOffset,
getContainerElement,
handleBindTextResize, handleBindTextResize,
measureText, measureText,
} from "./textElement"; } from "./textElement";
import { getMaxContainerWidth } from "./newElement";
export const normalizeAngle = (angle: number): number => { export const normalizeAngle = (angle: number): number => {
if (angle >= 2 * Math.PI) { if (angle >= 2 * Math.PI) {
@@ -74,23 +79,9 @@ export const transformElements = (
pointerX, pointerX,
pointerY, pointerY,
shouldRotateWithDiscreteAngle, shouldRotateWithDiscreteAngle,
pointerDownState.originalElements,
); );
updateBoundElements(element); updateBoundElements(element);
} else if (
isLinearElement(element) &&
element.points.length === 2 &&
(transformHandleType === "nw" ||
transformHandleType === "ne" ||
transformHandleType === "sw" ||
transformHandleType === "se")
) {
reshapeSingleTwoPointElement(
element,
resizeArrowDirection,
shouldRotateWithDiscreteAngle,
pointerX,
pointerY,
);
} else if ( } else if (
isTextElement(element) && isTextElement(element) &&
(transformHandleType === "nw" || (transformHandleType === "nw" ||
@@ -156,6 +147,7 @@ const rotateSingleElement = (
pointerX: number, pointerX: number,
pointerY: number, pointerY: number,
shouldRotateWithDiscreteAngle: boolean, shouldRotateWithDiscreteAngle: boolean,
originalElements: Map<string, NonDeleted<ExcalidrawElement>>,
) => { ) => {
const [x1, y1, x2, y2] = getElementAbsoluteCoords(element); const [x1, y1, x2, y2] = getElementAbsoluteCoords(element);
const cx = (x1 + x2) / 2; const cx = (x1 + x2) / 2;
@@ -166,100 +158,21 @@ const rotateSingleElement = (
angle -= angle % SHIFT_LOCKING_ANGLE; angle -= angle % SHIFT_LOCKING_ANGLE;
} }
angle = normalizeAngle(angle); angle = normalizeAngle(angle);
mutateElement(element, { angle });
const boundTextElementId = getBoundTextElementId(element); const boundTextElementId = getBoundTextElementId(element);
mutateElement(element, { angle });
if (boundTextElementId) { if (boundTextElementId) {
const textElement = Scene.getScene(element)!.getElement(boundTextElementId); const textElement =
mutateElement(textElement!, { angle }); Scene.getScene(element)?.getElement<ExcalidrawTextElementWithContainer>(
} boundTextElementId,
}; );
// used in DEV only if (textElement && !isArrowElement(element)) {
const validateTwoPointElementNormalized = ( mutateElement(textElement, { angle });
element: NonDeleted<ExcalidrawLinearElement>, }
) => {
if (
element.points.length !== 2 ||
element.points[0][0] !== 0 ||
element.points[0][1] !== 0 ||
Math.abs(element.points[1][0]) !== element.width ||
Math.abs(element.points[1][1]) !== element.height
) {
throw new Error("Two-point element is not normalized");
} }
}; };
const getPerfectElementSizeWithRotation = (
elementType: ExcalidrawElement["type"],
width: number,
height: number,
angle: number,
): [number, number] => {
const size = getPerfectElementSize(
elementType,
...rotate(width, height, 0, 0, angle),
);
return rotate(size.width, size.height, 0, 0, -angle);
};
export const reshapeSingleTwoPointElement = (
element: NonDeleted<ExcalidrawLinearElement>,
resizeArrowDirection: "origin" | "end",
shouldRotateWithDiscreteAngle: boolean,
pointerX: number,
pointerY: number,
) => {
if (process.env.NODE_ENV !== "production") {
validateTwoPointElementNormalized(element);
}
const [x1, y1, x2, y2] = getElementAbsoluteCoords(element);
const cx = (x1 + x2) / 2;
const cy = (y1 + y2) / 2;
// rotation pointer with reverse angle
const [rotatedX, rotatedY] = rotate(
pointerX,
pointerY,
cx,
cy,
-element.angle,
);
let [width, height] =
resizeArrowDirection === "end"
? [rotatedX - element.x, rotatedY - element.y]
: [
element.x + element.points[1][0] - rotatedX,
element.y + element.points[1][1] - rotatedY,
];
if (shouldRotateWithDiscreteAngle) {
[width, height] = getPerfectElementSizeWithRotation(
element.type,
width,
height,
element.angle,
);
}
const [nextElementX, nextElementY] = adjustXYWithRotation(
resizeArrowDirection === "end"
? { s: true, e: true }
: { n: true, w: true },
element.x,
element.y,
element.angle,
0,
0,
(element.points[1][0] - width) / 2,
(element.points[1][1] - height) / 2,
);
mutateElement(element, {
x: nextElementX,
y: nextElementY,
points: [
[0, 0],
[width, height],
],
});
};
const rescalePointsInElement = ( const rescalePointsInElement = (
element: NonDeletedExcalidrawElement, element: NonDeletedExcalidrawElement,
width: number, width: number,
@@ -285,14 +198,23 @@ const measureFontSizeFromWH = (
nextHeight: number, nextHeight: number,
): { size: number; baseline: number } | null => { ): { size: number; baseline: number } | null => {
// We only use width to scale font on resize // We only use width to scale font on resize
const nextFontSize = element.fontSize * (nextWidth / element.width); let width = element.width;
const hasContainer = isBoundToContainer(element);
if (hasContainer) {
const container = getContainerElement(element);
if (container) {
width = getMaxContainerWidth(container);
}
}
const nextFontSize = element.fontSize * (nextWidth / width);
if (nextFontSize < MIN_FONT_SIZE) { if (nextFontSize < MIN_FONT_SIZE) {
return null; return null;
} }
const metrics = measureText( const metrics = measureText(
element.text, element.text,
getFontString({ fontSize: nextFontSize, fontFamily: element.fontFamily }), getFontString({ fontSize: nextFontSize, fontFamily: element.fontFamily }),
element.containerId ? element.width : null, element.containerId ? width : null,
); );
return { return {
size: nextFontSize, size: nextFontSize,
@@ -505,10 +427,12 @@ export const resizeSingleElement = (
}; };
} }
if (shouldMaintainAspectRatio) { if (shouldMaintainAspectRatio) {
const boundTextElementPadding =
getBoundTextElementOffset(boundTextElement);
const nextFont = measureFontSizeFromWH( const nextFont = measureFontSizeFromWH(
boundTextElement, boundTextElement,
eleNewWidth - BOUND_TEXT_PADDING * 2, eleNewWidth - boundTextElementPadding * 2,
eleNewHeight - BOUND_TEXT_PADDING * 2, eleNewHeight - boundTextElementPadding * 2,
); );
if (nextFont === null) { if (nextFont === null) {
return; return;
@@ -597,24 +521,36 @@ export const resizeSingleElement = (
newTopLeft = rotatePoint(rotatedTopLeft, rotatedNewCenter, -angle); newTopLeft = rotatePoint(rotatedTopLeft, rotatedNewCenter, -angle);
// Readjust points for linear elements // Readjust points for linear elements
const rescaledPoints = rescalePointsInElement( let rescaledElementPointsY;
stateAtResizeStart, let rescaledPoints;
eleNewWidth,
eleNewHeight, if (isLinearElement(element) || isFreeDrawElement(element)) {
true, rescaledElementPointsY = rescalePoints(
); 1,
eleNewHeight,
(stateAtResizeStart as ExcalidrawLinearElement).points,
true,
);
rescaledPoints = rescalePoints(
0,
eleNewWidth,
rescaledElementPointsY,
true,
);
}
// For linear elements (x,y) are the coordinates of the first drawn point not the top-left corner // For linear elements (x,y) are the coordinates of the first drawn point not the top-left corner
// So we need to readjust (x,y) to be where the first point should be // So we need to readjust (x,y) to be where the first point should be
const newOrigin = [...newTopLeft]; const newOrigin = [...newTopLeft];
newOrigin[0] += stateAtResizeStart.x - newBoundsX1; newOrigin[0] += stateAtResizeStart.x - newBoundsX1;
newOrigin[1] += stateAtResizeStart.y - newBoundsY1; newOrigin[1] += stateAtResizeStart.y - newBoundsY1;
const resizedElement = { const resizedElement = {
width: Math.abs(eleNewWidth), width: Math.abs(eleNewWidth),
height: Math.abs(eleNewHeight), height: Math.abs(eleNewHeight),
x: newOrigin[0], x: newOrigin[0],
y: newOrigin[1], y: newOrigin[1],
...rescaledPoints, points: rescaledPoints,
}; };
if ("scale" in element && "scale" in stateAtResizeStart) { if ("scale" in element && "scale" in stateAtResizeStart) {
@@ -638,6 +574,7 @@ export const resizeSingleElement = (
updateBoundElements(element, { updateBoundElements(element, {
newSize: { width: resizedElement.width, height: resizedElement.height }, newSize: { width: resizedElement.width, height: resizedElement.height },
}); });
mutateElement(element, resizedElement); mutateElement(element, resizedElement);
if (boundTextElement && boundTextFont) { if (boundTextElement && boundTextFont) {
mutateElement(boundTextElement, { fontSize: boundTextFont.fontSize }); mutateElement(boundTextElement, { fontSize: boundTextFont.fontSize });
@@ -760,7 +697,7 @@ const resizeMultipleElements = (
const boundTextElement = getBoundTextElement(element.latest); const boundTextElement = getBoundTextElement(element.latest);
if (boundTextElement || isTextElement(element.orig)) { if (boundTextElement || isTextElement(element.orig)) {
const optionalPadding = boundTextElement ? BOUND_TEXT_PADDING * 2 : 0; const optionalPadding = getBoundTextElementOffset(boundTextElement) * 2;
const textMeasurements = measureFontSizeFromWH( const textMeasurements = measureFontSizeFromWH(
boundTextElement ?? (element.orig as ExcalidrawTextElement), boundTextElement ?? (element.orig as ExcalidrawTextElement),
width - optionalPadding, width - optionalPadding,
@@ -790,6 +727,7 @@ const resizeMultipleElements = (
if (boundTextElement && boundTextUpdates) { if (boundTextElement && boundTextUpdates) {
mutateElement(boundTextElement, boundTextUpdates); mutateElement(boundTextElement, boundTextUpdates);
handleBindTextResize(element.latest, transformHandleType); handleBindTextResize(element.latest, transformHandleType);
} }
}); });
@@ -810,7 +748,7 @@ const rotateMultipleElements = (
centerAngle += SHIFT_LOCKING_ANGLE / 2; centerAngle += SHIFT_LOCKING_ANGLE / 2;
centerAngle -= centerAngle % SHIFT_LOCKING_ANGLE; centerAngle -= centerAngle % SHIFT_LOCKING_ANGLE;
} }
elements.forEach((element, index) => { elements.forEach((element) => {
const [x1, y1, x2, y2] = getElementAbsoluteCoords(element); const [x1, y1, x2, y2] = getElementAbsoluteCoords(element);
const cx = (x1 + x2) / 2; const cx = (x1 + x2) / 2;
const cy = (y1 + y2) / 2; const cy = (y1 + y2) / 2;
@@ -831,12 +769,16 @@ const rotateMultipleElements = (
const boundTextElementId = getBoundTextElementId(element); const boundTextElementId = getBoundTextElementId(element);
if (boundTextElementId) { if (boundTextElementId) {
const textElement = const textElement =
Scene.getScene(element)!.getElement(boundTextElementId)!; Scene.getScene(element)?.getElement<ExcalidrawTextElementWithContainer>(
mutateElement(textElement, { boundTextElementId,
x: textElement.x + (rotatedCX - cx), );
y: textElement.y + (rotatedCY - cy), if (textElement && !isArrowElement(element)) {
angle: normalizeAngle(centerAngle + origAngle), mutateElement(textElement, {
}); x: textElement.x + (rotatedCX - cx),
y: textElement.y + (rotatedCY - cy),
angle: normalizeAngle(centerAngle + origAngle),
});
}
} }
}); });
}; };
+1 -1
View File
@@ -94,7 +94,7 @@ export const getTransformHandleTypeFromCoords = (
pointerType: PointerType, pointerType: PointerType,
): MaybeTransformHandleType => { ): MaybeTransformHandleType => {
const transformHandles = getTransformHandlesFromCoords( const transformHandles = getTransformHandlesFromCoords(
[x1, y1, x2, y2], [x1, y1, x2, y2, (x1 + x2) / 2, (y1 + y2) / 2],
0, 0,
zoom, zoom,
pointerType, pointerType,
+42 -1
View File
@@ -1,10 +1,17 @@
import { BOUND_TEXT_PADDING } from "../constants"; import { BOUND_TEXT_PADDING } from "../constants";
import { wrapText } from "./textElement"; import { measureText, wrapText } from "./textElement";
import { FontString } from "./types"; import { FontString } from "./types";
describe("Test wrapText", () => { describe("Test wrapText", () => {
const font = "20px Cascadia, width: Segoe UI Emoji" as FontString; const font = "20px Cascadia, width: Segoe UI Emoji" as FontString;
it("shouldn't add new lines for trailing spaces", () => {
const text = "Hello whats up ";
const maxWidth = 200 - BOUND_TEXT_PADDING * 2;
const res = wrapText(text, font, maxWidth);
expect(res).toBe("Hello whats up ");
});
describe("When text doesn't contain new lines", () => { describe("When text doesn't contain new lines", () => {
const text = "Hello whats up"; const text = "Hello whats up";
[ [
@@ -139,3 +146,37 @@ break it now`,
}); });
}); });
}); });
describe("Test measureText", () => {
const font = "20px Cascadia, width: Segoe UI Emoji" as FontString;
const text = "Hello World";
it("should add correct attributes when maxWidth is passed", () => {
const maxWidth = 200 - BOUND_TEXT_PADDING * 2;
const res = measureText(text, font, maxWidth);
expect(res.container).toMatchInlineSnapshot(`
<div
style="position: absolute; white-space: pre-wrap; font: Emoji 20px 20px; min-height: 1em; width: 111px; overflow: hidden; word-break: break-word; line-height: 0px;"
>
<span
style="display: inline-block; overflow: hidden; width: 1px; height: 1px;"
/>
</div>
`);
});
it("should add correct attributes when maxWidth is not passed", () => {
const res = measureText(text, font);
expect(res.container).toMatchInlineSnapshot(`
<div
style="position: absolute; white-space: pre; font: Emoji 20px 20px; min-height: 1em;"
>
<span
style="display: inline-block; overflow: hidden; width: 1px; height: 1px;"
/>
</div>
`);
});
});
+347 -118
View File
@@ -1,6 +1,7 @@
import { getFontString, arrayToMap, isTestEnv } from "../utils"; import { getFontString, arrayToMap, isTestEnv } from "../utils";
import { import {
ExcalidrawElement, ExcalidrawElement,
ExcalidrawTextContainer,
ExcalidrawTextElement, ExcalidrawTextElement,
ExcalidrawTextElementWithContainer, ExcalidrawTextElementWithContainer,
FontString, FontString,
@@ -12,6 +13,31 @@ import { MaybeTransformHandleType } from "./transformHandles";
import Scene from "../scene/Scene"; import Scene from "../scene/Scene";
import { isTextElement } from "."; import { isTextElement } from ".";
import { getMaxContainerHeight, getMaxContainerWidth } from "./newElement"; import { getMaxContainerHeight, getMaxContainerWidth } from "./newElement";
import {
isBoundToContainer,
isImageElement,
isArrowElement,
} from "./typeChecks";
import { LinearElementEditor } from "./linearElementEditor";
import { AppState } from "../types";
import { isTextBindableContainer } from "./typeChecks";
import { getElementAbsoluteCoords } from "../element";
import { getSelectedElements } from "../scene";
import { isHittingElementNotConsideringBoundingBox } from "./collision";
import {
resetOriginalContainerCache,
updateOriginalContainerCache,
} from "./textWysiwyg";
export const normalizeText = (text: string) => {
return (
text
// replace tabs with spaces so they render and measure correctly
.replace(/\t/g, " ")
// normalize newlines
.replace(/\r?\n|\r/g, "\n")
);
};
export const redrawTextBoundingBox = ( export const redrawTextBoundingBox = (
textElement: ExcalidrawTextElement, textElement: ExcalidrawTextElement,
@@ -19,54 +45,60 @@ export const redrawTextBoundingBox = (
) => { ) => {
let maxWidth = undefined; let maxWidth = undefined;
let text = textElement.text; let text = textElement.text;
if (container) { if (container) {
maxWidth = getMaxContainerWidth(container); maxWidth = getMaxContainerWidth(container);
text = wrapText( text = wrapText(
textElement.originalText, textElement.originalText,
getFontString(textElement), getFontString(textElement),
getMaxContainerWidth(container), maxWidth,
); );
} }
const metrics = measureText( const metrics = measureText(text, getFontString(textElement), maxWidth);
textElement.originalText,
getFontString(textElement),
maxWidth,
);
let coordY = textElement.y; let coordY = textElement.y;
let coordX = textElement.x; let coordX = textElement.x;
// Resize container and vertically center align the text // Resize container and vertically center align the text
if (container) { if (container) {
const containerDims = getContainerDims(container); if (!isArrowElement(container)) {
let nextHeight = containerDims.height; const containerDims = getContainerDims(container);
if (textElement.verticalAlign === VERTICAL_ALIGN.TOP) { let nextHeight = containerDims.height;
coordY = container.y + BOUND_TEXT_PADDING; const boundTextElementPadding = getBoundTextElementOffset(textElement);
} else if (textElement.verticalAlign === VERTICAL_ALIGN.BOTTOM) { if (textElement.verticalAlign === VERTICAL_ALIGN.TOP) {
coordY = coordY = container.y + boundTextElementPadding;
container.y + } else if (textElement.verticalAlign === VERTICAL_ALIGN.BOTTOM) {
containerDims.height - coordY =
metrics.height - container.y +
BOUND_TEXT_PADDING; containerDims.height -
} else { metrics.height -
coordY = container.y + containerDims.height / 2 - metrics.height / 2; boundTextElementPadding;
if (metrics.height > getMaxContainerHeight(container)) { } else {
nextHeight = metrics.height + BOUND_TEXT_PADDING * 2; coordY = container.y + containerDims.height / 2 - metrics.height / 2;
coordY = container.y + nextHeight / 2 - metrics.height / 2; if (metrics.height > getMaxContainerHeight(container)) {
nextHeight = metrics.height + boundTextElementPadding * 2;
coordY = container.y + nextHeight / 2 - metrics.height / 2;
}
} }
} if (textElement.textAlign === TEXT_ALIGN.LEFT) {
coordX = container.x + boundTextElementPadding;
if (textElement.textAlign === TEXT_ALIGN.LEFT) { } else if (textElement.textAlign === TEXT_ALIGN.RIGHT) {
coordX = container.x + BOUND_TEXT_PADDING; coordX =
} else if (textElement.textAlign === TEXT_ALIGN.RIGHT) { container.x +
coordX = containerDims.width -
container.x + containerDims.width - metrics.width - BOUND_TEXT_PADDING; metrics.width -
boundTextElementPadding;
} else {
coordX = container.x + containerDims.width / 2 - metrics.width / 2;
}
updateOriginalContainerCache(container.id, nextHeight);
mutateElement(container, { height: nextHeight });
} else { } else {
coordX = container.x + container.width / 2 - metrics.width / 2; const centerX = textElement.x + textElement.width / 2;
const centerY = textElement.y + textElement.height / 2;
const diffWidth = metrics.width - textElement.width;
const diffHeight = metrics.height - textElement.height;
coordY = centerY - (textElement.height + diffHeight) / 2;
coordX = centerX - (textElement.width + diffWidth) / 2;
} }
mutateElement(container, { height: nextHeight });
} }
mutateElement(textElement, { mutateElement(textElement, {
width: metrics.width, width: metrics.width,
height: metrics.height, height: metrics.height,
@@ -96,7 +128,7 @@ export const bindTextToShapeAfterDuplication = (
const newContainer = sceneElementMap.get(newElementId); const newContainer = sceneElementMap.get(newElementId);
if (newContainer) { if (newContainer) {
mutateElement(newContainer, { mutateElement(newContainer, {
boundElements: element.boundElements?.concat({ boundElements: (newContainer.boundElements || []).concat({
type: "text", type: "text",
id: newTextElementId, id: newTextElementId,
}), }),
@@ -114,84 +146,114 @@ export const bindTextToShapeAfterDuplication = (
}; };
export const handleBindTextResize = ( export const handleBindTextResize = (
element: NonDeletedExcalidrawElement, container: NonDeletedExcalidrawElement,
transformHandleType: MaybeTransformHandleType, transformHandleType: MaybeTransformHandleType,
) => { ) => {
const boundTextElementId = getBoundTextElementId(element); const boundTextElementId = getBoundTextElementId(container);
if (boundTextElementId) { if (!boundTextElementId) {
const textElement = Scene.getScene(element)!.getElement( return;
}
resetOriginalContainerCache(container.id);
let textElement = Scene.getScene(container)!.getElement(
boundTextElementId,
) as ExcalidrawTextElement;
if (textElement && textElement.text) {
if (!container) {
return;
}
textElement = Scene.getScene(container)!.getElement(
boundTextElementId, boundTextElementId,
) as ExcalidrawTextElement; ) as ExcalidrawTextElement;
if (textElement && textElement.text) { let text = textElement.text;
if (!element) { let nextHeight = textElement.height;
return; let nextWidth = textElement.width;
} const containerDims = getContainerDims(container);
let text = textElement.text; const maxWidth = getMaxContainerWidth(container);
let nextHeight = textElement.height; const maxHeight = getMaxContainerHeight(container);
let nextWidth = textElement.width; let containerHeight = containerDims.height;
let containerHeight = element.height; let nextBaseLine = textElement.baseline;
let nextBaseLine = textElement.baseline; if (transformHandleType !== "n" && transformHandleType !== "s") {
if (transformHandleType !== "n" && transformHandleType !== "s") { if (text) {
if (text) { text = wrapText(
text = wrapText( textElement.originalText,
textElement.originalText,
getFontString(textElement),
getMaxContainerWidth(element),
);
}
const dimensions = measureText(
text,
getFontString(textElement), getFontString(textElement),
element.width, maxWidth,
); );
nextHeight = dimensions.height;
nextWidth = dimensions.width;
nextBaseLine = dimensions.baseline;
} }
// increase height in case text element height exceeds const dimensions = measureText(
if (nextHeight > element.height - BOUND_TEXT_PADDING * 2) {
containerHeight = nextHeight + BOUND_TEXT_PADDING * 2;
const diff = containerHeight - element.height;
// fix the y coord when resizing from ne/nw/n
const updatedY =
transformHandleType === "ne" ||
transformHandleType === "nw" ||
transformHandleType === "n"
? element.y - diff
: element.y;
mutateElement(element, {
height: containerHeight,
y: updatedY,
});
}
let updatedY;
if (textElement.verticalAlign === VERTICAL_ALIGN.TOP) {
updatedY = element.y + BOUND_TEXT_PADDING;
} else if (textElement.verticalAlign === VERTICAL_ALIGN.BOTTOM) {
updatedY = element.y + element.height - nextHeight - BOUND_TEXT_PADDING;
} else {
updatedY = element.y + element.height / 2 - nextHeight / 2;
}
const updatedX =
textElement.textAlign === TEXT_ALIGN.LEFT
? element.x + BOUND_TEXT_PADDING
: textElement.textAlign === TEXT_ALIGN.RIGHT
? element.x + element.width - nextWidth - BOUND_TEXT_PADDING
: element.x + element.width / 2 - nextWidth / 2;
mutateElement(textElement, {
text, text,
width: nextWidth, getFontString(textElement),
height: nextHeight, maxWidth,
x: updatedX, );
nextHeight = dimensions.height;
nextWidth = dimensions.width;
nextBaseLine = dimensions.baseline;
}
// increase height in case text element height exceeds
if (nextHeight > maxHeight) {
containerHeight = nextHeight + getBoundTextElementOffset(textElement) * 2;
const diff = containerHeight - containerDims.height;
// fix the y coord when resizing from ne/nw/n
const updatedY =
!isArrowElement(container) &&
(transformHandleType === "ne" ||
transformHandleType === "nw" ||
transformHandleType === "n")
? container.y - diff
: container.y;
mutateElement(container, {
height: containerHeight,
y: updatedY, y: updatedY,
baseline: nextBaseLine,
}); });
} }
mutateElement(textElement, {
text,
width: nextWidth,
height: nextHeight,
baseline: nextBaseLine,
});
if (!isArrowElement(container)) {
updateBoundTextPosition(
container,
textElement as ExcalidrawTextElementWithContainer,
);
}
} }
}; };
const updateBoundTextPosition = (
container: ExcalidrawElement,
boundTextElement: ExcalidrawTextElementWithContainer,
) => {
const containerDims = getContainerDims(container);
const boundTextElementPadding = getBoundTextElementOffset(boundTextElement);
let y;
if (boundTextElement.verticalAlign === VERTICAL_ALIGN.TOP) {
y = container.y + boundTextElementPadding;
} else if (boundTextElement.verticalAlign === VERTICAL_ALIGN.BOTTOM) {
y =
container.y +
containerDims.height -
boundTextElement.height -
boundTextElementPadding;
} else {
y = container.y + containerDims.height / 2 - boundTextElement.height / 2;
}
const x =
boundTextElement.textAlign === TEXT_ALIGN.LEFT
? container.x + boundTextElementPadding
: boundTextElement.textAlign === TEXT_ALIGN.RIGHT
? container.x +
containerDims.width -
boundTextElement.width -
boundTextElementPadding
: container.x + containerDims.width / 2 - boundTextElement.width / 2;
mutateElement(boundTextElement, { x, y });
};
// https://github.com/grassator/canvas-text-editor/blob/master/lib/FontMetrics.js // https://github.com/grassator/canvas-text-editor/blob/master/lib/FontMetrics.js
export const measureText = ( export const measureText = (
text: string, text: string,
@@ -209,9 +271,12 @@ export const measureText = (
container.style.whiteSpace = "pre"; container.style.whiteSpace = "pre";
container.style.font = font; container.style.font = font;
container.style.minHeight = "1em"; container.style.minHeight = "1em";
const textWidth = getTextWidth(text, font);
if (maxWidth) { if (maxWidth) {
const lineHeight = getApproxLineHeight(font); const lineHeight = getApproxLineHeight(font);
container.style.maxWidth = `${String(maxWidth)}px`; container.style.width = `${String(Math.min(textWidth, maxWidth) + 1)}px`;
container.style.overflow = "hidden"; container.style.overflow = "hidden";
container.style.wordBreak = "break-word"; container.style.wordBreak = "break-word";
container.style.lineHeight = `${String(lineHeight)}px`; container.style.lineHeight = `${String(lineHeight)}px`;
@@ -229,11 +294,15 @@ export const measureText = (
// Baseline is important for positioning text on canvas // Baseline is important for positioning text on canvas
const baseline = span.offsetTop + span.offsetHeight; const baseline = span.offsetTop + span.offsetHeight;
// Since span adds 1px extra width to the container // Since span adds 1px extra width to the container
const width = container.offsetWidth + 1; let width = container.offsetWidth;
if (maxWidth && textWidth > maxWidth) {
width = width - 1;
}
const height = container.offsetHeight; const height = container.offsetHeight;
document.body.removeChild(container); document.body.removeChild(container);
if (isTestEnv()) {
return { width, height, baseline, container };
}
return { width, height, baseline }; return { width, height, baseline };
}; };
@@ -249,7 +318,7 @@ export const getApproxLineHeight = (font: FontString) => {
}; };
let canvas: HTMLCanvasElement | undefined; let canvas: HTMLCanvasElement | undefined;
const getTextWidth = (text: string, font: FontString) => { const getLineWidth = (text: string, font: FontString) => {
if (!canvas) { if (!canvas) {
canvas = document.createElement("canvas"); canvas = document.createElement("canvas");
} }
@@ -267,10 +336,24 @@ const getTextWidth = (text: string, font: FontString) => {
return metrics.width; return metrics.width;
}; };
export const getTextWidth = (text: string, font: FontString) => {
const lines = text.split("\n");
let width = 0;
lines.forEach((line) => {
width = Math.max(width, getLineWidth(line, font));
});
return width;
};
export const wrapText = (text: string, font: FontString, maxWidth: number) => { export const wrapText = (text: string, font: FontString, maxWidth: number) => {
const lines: Array<string> = []; const lines: Array<string> = [];
const originalLines = text.split("\n"); const originalLines = text.split("\n");
const spaceWidth = getTextWidth(" ", font); const spaceWidth = getLineWidth(" ", font);
const push = (str: string) => {
if (str.trim()) {
lines.push(str);
}
};
originalLines.forEach((originalLine) => { originalLines.forEach((originalLine) => {
const words = originalLine.split(" "); const words = originalLine.split(" ");
// This means its newline so push it // This means its newline so push it
@@ -282,15 +365,13 @@ export const wrapText = (text: string, font: FontString, maxWidth: number) => {
let index = 0; let index = 0;
while (index < words.length) { while (index < words.length) {
const currentWordWidth = getTextWidth(words[index], font); const currentWordWidth = getLineWidth(words[index], font);
// Start breaking longer words exceeding max width // Start breaking longer words exceeding max width
if (currentWordWidth >= maxWidth) { if (currentWordWidth >= maxWidth) {
// push current line since the current word exceeds the max width // push current line since the current word exceeds the max width
// so will be appended in next line // so will be appended in next line
if (currentLine) { push(currentLine);
lines.push(currentLine);
}
currentLine = ""; currentLine = "";
currentLineWidthTillNow = 0; currentLineWidthTillNow = 0;
while (words[index].length > 0) { while (words[index].length > 0) {
@@ -304,7 +385,7 @@ export const wrapText = (text: string, font: FontString, maxWidth: number) => {
if (currentLine.slice(-1) === " ") { if (currentLine.slice(-1) === " ") {
currentLine = currentLine.slice(0, -1); currentLine = currentLine.slice(0, -1);
} }
lines.push(currentLine); push(currentLine);
currentLine = currentChar; currentLine = currentChar;
currentLineWidthTillNow = width; currentLineWidthTillNow = width;
if (currentLineWidthTillNow === maxWidth) { if (currentLineWidthTillNow === maxWidth) {
@@ -317,7 +398,7 @@ export const wrapText = (text: string, font: FontString, maxWidth: number) => {
} }
// push current line if appending space exceeds max width // push current line if appending space exceeds max width
if (currentLineWidthTillNow + spaceWidth >= maxWidth) { if (currentLineWidthTillNow + spaceWidth >= maxWidth) {
lines.push(currentLine); push(currentLine);
currentLine = ""; currentLine = "";
currentLineWidthTillNow = 0; currentLineWidthTillNow = 0;
} else { } else {
@@ -333,10 +414,10 @@ export const wrapText = (text: string, font: FontString, maxWidth: number) => {
// Start appending words in a line till max width reached // Start appending words in a line till max width reached
while (currentLineWidthTillNow < maxWidth && index < words.length) { while (currentLineWidthTillNow < maxWidth && index < words.length) {
const word = words[index]; const word = words[index];
currentLineWidthTillNow = getTextWidth(currentLine + word, font); currentLineWidthTillNow = getLineWidth(currentLine + word, font);
if (currentLineWidthTillNow >= maxWidth) { if (currentLineWidthTillNow >= maxWidth) {
lines.push(currentLine); push(currentLine);
currentLineWidthTillNow = 0; currentLineWidthTillNow = 0;
currentLine = ""; currentLine = "";
@@ -347,7 +428,8 @@ export const wrapText = (text: string, font: FontString, maxWidth: number) => {
// Push the word if appending space exceeds max width // Push the word if appending space exceeds max width
if (currentLineWidthTillNow + spaceWidth >= maxWidth) { if (currentLineWidthTillNow + spaceWidth >= maxWidth) {
lines.push(currentLine.slice(0, -1)); const word = currentLine.slice(0, -1);
push(word);
currentLine = ""; currentLine = "";
currentLineWidthTillNow = 0; currentLineWidthTillNow = 0;
break; break;
@@ -364,7 +446,7 @@ export const wrapText = (text: string, font: FontString, maxWidth: number) => {
if (currentLine.slice(-1) === " ") { if (currentLine.slice(-1) === " ") {
currentLine = currentLine.slice(0, -1); currentLine = currentLine.slice(0, -1);
} }
lines.push(currentLine); push(currentLine);
} }
} }
}); });
@@ -380,7 +462,7 @@ export const charWidth = (() => {
cachedCharWidth[font] = []; cachedCharWidth[font] = [];
} }
if (!cachedCharWidth[font][ascii]) { if (!cachedCharWidth[font][ascii]) {
const width = getTextWidth(char, font); const width = getLineWidth(char, font);
cachedCharWidth[font][ascii] = width; cachedCharWidth[font][ascii] = width;
} }
@@ -397,6 +479,7 @@ export const charWidth = (() => {
})(); })();
export const getApproxMinLineWidth = (font: FontString) => { export const getApproxMinLineWidth = (font: FontString) => {
const maxCharWidth = getMaxCharWidth(font); const maxCharWidth = getMaxCharWidth(font);
if (maxCharWidth === 0) { if (maxCharWidth === 0) {
return ( return (
measureText(DUMMY_TEXT.split("").join("\n"), font).width + measureText(DUMMY_TEXT.split("").join("\n"), font).width +
@@ -439,7 +522,7 @@ export const getApproxCharsToFitInWidth = (font: FontString, width: number) => {
while (widthTillNow <= width) { while (widthTillNow <= width) {
const batch = dummyText.substr(index, index + batchLength); const batch = dummyText.substr(index, index + batchLength);
str += batch; str += batch;
widthTillNow += getTextWidth(str, font); widthTillNow += getLineWidth(str, font);
if (index === dummyText.length - 1) { if (index === dummyText.length - 1) {
index = 0; index = 0;
} }
@@ -448,7 +531,7 @@ export const getApproxCharsToFitInWidth = (font: FontString, width: number) => {
while (widthTillNow > width) { while (widthTillNow > width) {
str = str.substr(0, str.length - 1); str = str.substr(0, str.length - 1);
widthTillNow = getTextWidth(str, font); widthTillNow = getLineWidth(str, font);
} }
return str.length; return str.length;
}; };
@@ -477,7 +560,9 @@ export const getBoundTextElement = (element: ExcalidrawElement | null) => {
export const getContainerElement = ( export const getContainerElement = (
element: element:
| (ExcalidrawElement & { containerId: ExcalidrawElement["id"] | null }) | (ExcalidrawElement & {
containerId: ExcalidrawElement["id"] | null;
})
| null, | null,
) => { ) => {
if (!element) { if (!element) {
@@ -490,5 +575,149 @@ export const getContainerElement = (
}; };
export const getContainerDims = (element: ExcalidrawElement) => { export const getContainerDims = (element: ExcalidrawElement) => {
const MIN_WIDTH = 300;
if (isArrowElement(element)) {
const width = Math.max(element.width, MIN_WIDTH);
const height = element.height;
return { width, height };
}
return { width: element.width, height: element.height }; return { width: element.width, height: element.height };
}; };
export const getContainerCenter = (
container: ExcalidrawElement,
appState: AppState,
) => {
if (!isArrowElement(container)) {
return {
x: container.x + container.width / 2,
y: container.y + container.height / 2,
};
}
const points = LinearElementEditor.getPointsGlobalCoordinates(container);
if (points.length % 2 === 1) {
const index = Math.floor(container.points.length / 2);
const midPoint = LinearElementEditor.getPointGlobalCoordinates(
container,
container.points[index],
);
return { x: midPoint[0], y: midPoint[1] };
}
const index = container.points.length / 2 - 1;
let midSegmentMidpoint = LinearElementEditor.getEditorMidPoints(
container,
appState,
)[index];
if (!midSegmentMidpoint) {
midSegmentMidpoint = LinearElementEditor.getSegmentMidPoint(
container,
points[index],
points[index + 1],
index + 1,
);
}
return { x: midSegmentMidpoint[0], y: midSegmentMidpoint[1] };
};
export const getTextElementAngle = (textElement: ExcalidrawTextElement) => {
const container = getContainerElement(textElement);
if (!container || isArrowElement(container)) {
return textElement.angle;
}
return container.angle;
};
export const getBoundTextElementOffset = (
boundTextElement: ExcalidrawTextElement | null,
) => {
const container = getContainerElement(boundTextElement);
if (!container) {
return 0;
}
if (isArrowElement(container)) {
return BOUND_TEXT_PADDING * 8;
}
return BOUND_TEXT_PADDING;
};
export const getBoundTextElementPosition = (
container: ExcalidrawElement,
boundTextElement: ExcalidrawTextElementWithContainer,
) => {
if (isArrowElement(container)) {
return LinearElementEditor.getBoundTextElementPosition(
container,
boundTextElement,
);
}
};
export const shouldAllowVerticalAlign = (
selectedElements: NonDeletedExcalidrawElement[],
) => {
return selectedElements.some((element) => {
const hasBoundContainer = isBoundToContainer(element);
if (hasBoundContainer) {
const container = getContainerElement(element);
if (isTextElement(element) && isArrowElement(container)) {
return false;
}
return true;
}
const boundTextElement = getBoundTextElement(element);
if (boundTextElement) {
if (isArrowElement(element)) {
return false;
}
return true;
}
return false;
});
};
export const getTextBindableContainerAtPosition = (
elements: readonly ExcalidrawElement[],
appState: AppState,
x: number,
y: number,
): ExcalidrawTextContainer | null => {
const selectedElements = getSelectedElements(elements, appState);
if (selectedElements.length === 1) {
return isTextBindableContainer(selectedElements[0], false)
? selectedElements[0]
: null;
}
let hitElement = null;
// We need to to hit testing from front (end of the array) to back (beginning of the array)
for (let index = elements.length - 1; index >= 0; --index) {
if (elements[index].isDeleted) {
continue;
}
const [x1, y1, x2, y2] = getElementAbsoluteCoords(elements[index]);
if (
isArrowElement(elements[index]) &&
isHittingElementNotConsideringBoundingBox(elements[index], appState, [
x,
y,
])
) {
hitElement = elements[index];
break;
} else if (x1 < x && x < x2 && y1 < y && y < y2) {
hitElement = elements[index];
break;
}
}
return isTextBindableContainer(hitElement, false) ? hitElement : null;
};
export const isValidTextContainer = (element: ExcalidrawElement) => {
return (
element.type === "rectangle" ||
element.type === "ellipse" ||
element.type === "diamond" ||
isImageElement(element) ||
isArrowElement(element)
);
};
+319 -30
View File
@@ -15,6 +15,7 @@ import * as textElementUtils from "./textElement";
import { API } from "../tests/helpers/api"; import { API } from "../tests/helpers/api";
import { mutateElement } from "./mutateElement"; import { mutateElement } from "./mutateElement";
import { resize } from "../tests/utils"; import { resize } from "../tests/utils";
import { getOriginalContainerHeightFromCache } from "./textWysiwyg";
// Unmount ReactDOM from root // Unmount ReactDOM from root
ReactDOM.unmountComponentAtNode(document.getElementById("root")!); ReactDOM.unmountComponentAtNode(document.getElementById("root")!);
@@ -462,7 +463,7 @@ describe("textWysiwyg", () => {
}); });
}); });
it("should bind text to container when double clicked on center", async () => { it("should bind text to container when double clicked on center of filled container", async () => {
expect(h.elements.length).toBe(1); expect(h.elements.length).toBe(1);
expect(h.elements[0].id).toBe(rectangle.id); expect(h.elements[0].id).toBe(rectangle.id);
@@ -472,6 +473,43 @@ describe("textWysiwyg", () => {
); );
expect(h.elements.length).toBe(2); expect(h.elements.length).toBe(2);
const text = h.elements[1] as ExcalidrawTextElementWithContainer;
expect(text.type).toBe("text");
expect(text.containerId).toBe(rectangle.id);
expect(rectangle.boundElements).toStrictEqual([
{ id: text.id, type: "text" },
]);
mouse.down();
const editor = document.querySelector(
".excalidraw-textEditorContainer > textarea",
) as HTMLTextAreaElement;
fireEvent.change(editor, { target: { value: "Hello World!" } });
await new Promise((r) => setTimeout(r, 0));
editor.blur();
expect(rectangle.boundElements).toStrictEqual([
{ id: text.id, type: "text" },
]);
});
it("should bind text to container when double clicked on center of transparent container", async () => {
const rectangle = API.createElement({
type: "rectangle",
x: 10,
y: 20,
width: 90,
height: 75,
backgroundColor: "transparent",
});
h.elements = [rectangle];
mouse.doubleClickAt(
rectangle.x + rectangle.width / 2,
rectangle.y + rectangle.height / 2,
);
expect(h.elements.length).toBe(2);
const text = h.elements[1] as ExcalidrawTextElementWithContainer; const text = h.elements[1] as ExcalidrawTextElementWithContainer;
expect(text.type).toBe("text"); expect(text.type).toBe("text");
expect(text.containerId).toBe(rectangle.id); expect(text.containerId).toBe(rectangle.id);
@@ -514,20 +552,19 @@ describe("textWysiwyg", () => {
}); });
it("shouldn't bind to non-text-bindable containers", async () => { it("shouldn't bind to non-text-bindable containers", async () => {
const line = API.createElement({ const freedraw = API.createElement({
type: "line", type: "freedraw",
width: 100, width: 100,
height: 0, height: 0,
points: [
[0, 0],
[100, 0],
],
}); });
h.elements = [line]; h.elements = [freedraw];
UI.clickTool("text"); UI.clickTool("text");
mouse.clickAt(line.x + line.width / 2, line.y + line.height / 2); mouse.clickAt(
freedraw.x + freedraw.width / 2,
freedraw.y + freedraw.height / 2,
);
const editor = document.querySelector( const editor = document.querySelector(
".excalidraw-textEditorContainer > textarea", ".excalidraw-textEditorContainer > textarea",
@@ -541,11 +578,24 @@ describe("textWysiwyg", () => {
fireEvent.keyDown(editor, { key: KEYS.ESCAPE }); fireEvent.keyDown(editor, { key: KEYS.ESCAPE });
editor.dispatchEvent(new Event("input")); editor.dispatchEvent(new Event("input"));
expect(line.boundElements).toBe(null); expect(freedraw.boundElements).toBe(null);
expect(h.elements[1].type).toBe("text"); expect(h.elements[1].type).toBe("text");
expect((h.elements[1] as ExcalidrawTextElement).containerId).toBe(null); expect((h.elements[1] as ExcalidrawTextElement).containerId).toBe(null);
}); });
["freedraw", "line"].forEach((type: any) => {
it(`shouldn't create text element when pressing 'Enter' key on ${type} `, async () => {
h.elements = [];
const elemnet = UI.createElement(type, {
width: 100,
height: 50,
});
API.setSelectedElements([elemnet]);
Keyboard.keyPress(KEYS.ENTER);
expect(h.elements.length).toBe(1);
});
});
it("should'nt bind text to container when not double clicked on center", async () => { it("should'nt bind text to container when not double clicked on center", async () => {
expect(h.elements.length).toBe(1); expect(h.elements.length).toBe(1);
expect(h.elements[0].id).toBe(rectangle.id); expect(h.elements[0].id).toBe(rectangle.id);
@@ -776,9 +826,9 @@ describe("textWysiwyg", () => {
expect(h.elements.length).toBe(2); expect(h.elements.length).toBe(2);
// Bind first text // Bind first text
let text = h.elements[1] as ExcalidrawTextElementWithContainer; const text = h.elements[1] as ExcalidrawTextElementWithContainer;
expect(text.containerId).toBe(rectangle.id); expect(text.containerId).toBe(rectangle.id);
let editor = document.querySelector( const editor = document.querySelector(
".excalidraw-textEditorContainer > textarea", ".excalidraw-textEditorContainer > textarea",
) as HTMLTextAreaElement; ) as HTMLTextAreaElement;
await new Promise((r) => setTimeout(r, 0)); await new Promise((r) => setTimeout(r, 0));
@@ -788,25 +838,14 @@ describe("textWysiwyg", () => {
{ id: text.id, type: "text" }, { id: text.id, type: "text" },
]); ]);
// Attempt to bind another text mouse.select(rectangle);
UI.clickTool("text"); Keyboard.keyPress(KEYS.ENTER);
mouse.clickAt( expect(h.elements.length).toBe(2);
rectangle.x + rectangle.width / 2,
rectangle.y + rectangle.height / 2,
);
mouse.down();
expect(h.elements.length).toBe(3);
text = h.elements[2] as ExcalidrawTextElementWithContainer;
editor = document.querySelector(
".excalidraw-textEditorContainer > textarea",
) as HTMLTextAreaElement;
await new Promise((r) => setTimeout(r, 0));
fireEvent.change(editor, { target: { value: "Whats up?" } });
editor.blur();
expect(rectangle.boundElements).toStrictEqual([ expect(rectangle.boundElements).toStrictEqual([
{ id: h.elements[1].id, type: "text" }, { id: h.elements[1].id, type: "text" },
]); ]);
expect(text.containerId).toBe(null); expect(text.containerId).toBe(rectangle.id);
}); });
it("should respect text alignment when resizing", async () => { it("should respect text alignment when resizing", async () => {
@@ -823,7 +862,7 @@ describe("textWysiwyg", () => {
resize(rectangle, "ne", [rectangle.x + 100, rectangle.y - 100]); resize(rectangle, "ne", [rectangle.x + 100, rectangle.y - 100]);
expect([h.elements[1].x, h.elements[1].y]).toMatchInlineSnapshot(` expect([h.elements[1].x, h.elements[1].y]).toMatchInlineSnapshot(`
Array [ Array [
109.5, 110,
17, 17,
] ]
`); `);
@@ -871,10 +910,260 @@ describe("textWysiwyg", () => {
resize(rectangle, "ne", [rectangle.x + 100, rectangle.y - 100]); resize(rectangle, "ne", [rectangle.x + 100, rectangle.y - 100]);
expect([h.elements[1].x, h.elements[1].y]).toMatchInlineSnapshot(` expect([h.elements[1].x, h.elements[1].y]).toMatchInlineSnapshot(`
Array [ Array [
424, 425,
-539, -539,
] ]
`); `);
}); });
it("should always bind to selected container and insert it in correct position", async () => {
const rectangle2 = UI.createElement("rectangle", {
x: 5,
y: 10,
width: 120,
height: 100,
});
API.setSelectedElements([rectangle]);
Keyboard.keyPress(KEYS.ENTER);
expect(h.elements.length).toBe(3);
expect(h.elements[1].type).toBe("text");
const text = h.elements[1] as ExcalidrawTextElementWithContainer;
expect(text.type).toBe("text");
expect(text.containerId).toBe(rectangle.id);
mouse.down();
const editor = document.querySelector(
".excalidraw-textEditorContainer > textarea",
) as HTMLTextAreaElement;
fireEvent.change(editor, { target: { value: "Hello World!" } });
await new Promise((r) => setTimeout(r, 0));
editor.blur();
expect(rectangle2.boundElements).toBeNull();
expect(rectangle.boundElements).toStrictEqual([
{ id: text.id, type: "text" },
]);
});
it("should scale font size correctly when resizing using shift", async () => {
Keyboard.keyPress(KEYS.ENTER);
const editor = document.querySelector(
".excalidraw-textEditorContainer > textarea",
) as HTMLTextAreaElement;
await new Promise((r) => setTimeout(r, 0));
fireEvent.change(editor, { target: { value: "Hello" } });
editor.blur();
const textElement = h.elements[1] as ExcalidrawTextElement;
expect(rectangle.width).toBe(90);
expect(rectangle.height).toBe(75);
expect(textElement.fontSize).toBe(20);
resize(rectangle, "ne", [rectangle.x + 100, rectangle.y - 50], {
shift: true,
});
expect(rectangle.width).toBe(200);
expect(rectangle.height).toBe(166.66666666666669);
expect(textElement.fontSize).toBe(47.5);
});
it("should bind text correctly when container duplicated with alt-drag", async () => {
Keyboard.keyPress(KEYS.ENTER);
const editor = document.querySelector(
".excalidraw-textEditorContainer > textarea",
) as HTMLTextAreaElement;
await new Promise((r) => setTimeout(r, 0));
fireEvent.change(editor, { target: { value: "Hello" } });
editor.blur();
expect(h.elements.length).toBe(2);
mouse.select(rectangle);
Keyboard.withModifierKeys({ alt: true }, () => {
mouse.down(rectangle.x + 10, rectangle.y + 10);
mouse.up(rectangle.x + 10, rectangle.y + 10);
});
expect(h.elements.length).toBe(4);
const duplicatedRectangle = h.elements[0];
const duplicatedText = h
.elements[1] as ExcalidrawTextElementWithContainer;
const originalRect = h.elements[2];
const originalText = h.elements[3] as ExcalidrawTextElementWithContainer;
expect(originalRect.boundElements).toStrictEqual([
{ id: originalText.id, type: "text" },
]);
expect(originalText.containerId).toBe(originalRect.id);
expect(duplicatedRectangle.boundElements).toStrictEqual([
{ id: duplicatedText.id, type: "text" },
]);
expect(duplicatedText.containerId).toBe(duplicatedRectangle.id);
});
it("undo should work", async () => {
Keyboard.keyPress(KEYS.ENTER);
const editor = document.querySelector(
".excalidraw-textEditorContainer > textarea",
) as HTMLTextAreaElement;
await new Promise((r) => setTimeout(r, 0));
fireEvent.change(editor, { target: { value: "Hello" } });
editor.blur();
expect(rectangle.boundElements).toStrictEqual([
{ id: h.elements[1].id, type: "text" },
]);
let text = h.elements[1] as ExcalidrawTextElementWithContainer;
const originalRectX = rectangle.x;
const originalRectY = rectangle.y;
const originalTextX = text.x;
const originalTextY = text.y;
mouse.select(rectangle);
mouse.downAt(rectangle.x, rectangle.y);
mouse.moveTo(rectangle.x + 100, rectangle.y + 50);
mouse.up(rectangle.x + 100, rectangle.y + 50);
expect(rectangle.x).toBe(80);
expect(rectangle.y).toBe(85);
expect(text.x).toBe(90);
expect(text.y).toBe(90);
Keyboard.withModifierKeys({ ctrl: true }, () => {
Keyboard.keyPress(KEYS.Z);
});
expect(rectangle.x).toBe(originalRectX);
expect(rectangle.y).toBe(originalRectY);
text = h.elements[1] as ExcalidrawTextElementWithContainer;
expect(text.x).toBe(originalTextX);
expect(text.y).toBe(originalTextY);
expect(rectangle.boundElements).toStrictEqual([
{ id: text.id, type: "text" },
]);
expect(text.containerId).toBe(rectangle.id);
});
it("should not allow bound text with only whitespaces", async () => {
Keyboard.keyPress(KEYS.ENTER);
const editor = document.querySelector(
".excalidraw-textEditorContainer > textarea",
) as HTMLTextAreaElement;
await new Promise((r) => setTimeout(r, 0));
fireEvent.change(editor, { target: { value: " " } });
editor.blur();
expect(rectangle.boundElements).toStrictEqual([]);
expect(h.elements[1].isDeleted).toBe(true);
});
it("should restore original container height and clear cache once text is unbind", async () => {
jest
.spyOn(textElementUtils, "measureText")
.mockImplementation((text, font, maxWidth) => {
let width = INITIAL_WIDTH;
let height = APPROX_LINE_HEIGHT;
let baseline = 10;
if (!text) {
return {
width,
height,
baseline,
};
}
baseline = 30;
width = DUMMY_WIDTH;
height = APPROX_LINE_HEIGHT * 5;
return {
width,
height,
baseline,
};
});
const originalRectHeight = rectangle.height;
expect(rectangle.height).toBe(originalRectHeight);
Keyboard.keyPress(KEYS.ENTER);
const editor = document.querySelector(
".excalidraw-textEditorContainer > textarea",
) as HTMLTextAreaElement;
await new Promise((r) => setTimeout(r, 0));
fireEvent.change(editor, {
target: { value: "Online whiteboard collaboration made easy" },
});
editor.blur();
expect(rectangle.height).toBe(135);
mouse.select(rectangle);
fireEvent.contextMenu(GlobalTestState.canvas, {
button: 2,
clientX: 20,
clientY: 30,
});
const contextMenu = document.querySelector(".context-menu");
fireEvent.click(queryByText(contextMenu as HTMLElement, "Unbind text")!);
expect(h.elements[0].boundElements).toEqual([]);
expect(getOriginalContainerHeightFromCache(rectangle.id)).toBe(null);
expect(rectangle.height).toBe(originalRectHeight);
});
it("should reset the container height cache when resizing", async () => {
Keyboard.keyPress(KEYS.ENTER);
expect(getOriginalContainerHeightFromCache(rectangle.id)).toBe(75);
let editor = document.querySelector(
".excalidraw-textEditorContainer > textarea",
) as HTMLTextAreaElement;
await new Promise((r) => setTimeout(r, 0));
fireEvent.change(editor, { target: { value: "Hello" } });
editor.blur();
resize(rectangle, "ne", [rectangle.x + 100, rectangle.y - 100]);
expect(rectangle.height).toBe(215);
expect(getOriginalContainerHeightFromCache(rectangle.id)).toBe(null);
mouse.select(rectangle);
Keyboard.keyPress(KEYS.ENTER);
editor = document.querySelector(
".excalidraw-textEditorContainer > textarea",
) as HTMLTextAreaElement;
await new Promise((r) => setTimeout(r, 0));
editor.blur();
expect(rectangle.height).toBe(215);
// cache updated again
expect(getOriginalContainerHeightFromCache(rectangle.id)).toBe(215);
});
//@todo fix this test later once measureText is mocked correctly
it.skip("should reset the container height cache when font properties updated", async () => {
Keyboard.keyPress(KEYS.ENTER);
expect(getOriginalContainerHeightFromCache(rectangle.id)).toBe(75);
const editor = document.querySelector(
".excalidraw-textEditorContainer > textarea",
) as HTMLTextAreaElement;
await new Promise((r) => setTimeout(r, 0));
fireEvent.change(editor, { target: { value: "Hello World!" } });
editor.blur();
mouse.select(rectangle);
Keyboard.keyPress(KEYS.ENTER);
fireEvent.click(screen.getByTitle(/code/i));
expect(
(h.elements[1] as ExcalidrawTextElementWithContainer).fontFamily,
).toEqual(FONT_FAMILY.Cascadia);
expect(getOriginalContainerHeightFromCache(rectangle.id)).toBe(75);
fireEvent.click(screen.getByTitle(/Very large/i));
expect(
(h.elements[1] as ExcalidrawTextElementWithContainer).fontSize,
).toEqual(36);
expect(getOriginalContainerHeightFromCache(rectangle.id)).toBe(75);
});
}); });
}); });
+136 -37
View File
@@ -6,20 +6,30 @@ import {
isTestEnv, isTestEnv,
} from "../utils"; } from "../utils";
import Scene from "../scene/Scene"; import Scene from "../scene/Scene";
import { isBoundToContainer, isTextElement } from "./typeChecks"; import {
import { CLASSES, BOUND_TEXT_PADDING, VERTICAL_ALIGN } from "../constants"; isArrowElement,
isBoundToContainer,
isTextElement,
} from "./typeChecks";
import { CLASSES, VERTICAL_ALIGN } from "../constants";
import { import {
ExcalidrawElement, ExcalidrawElement,
ExcalidrawTextElement,
ExcalidrawLinearElement, ExcalidrawLinearElement,
ExcalidrawTextElementWithContainer,
ExcalidrawTextElement,
ExcalidrawTextContainer,
} from "./types"; } from "./types";
import { AppState } from "../types"; import { AppState } from "../types";
import { mutateElement } from "./mutateElement"; import { mutateElement } from "./mutateElement";
import { import {
getApproxLineHeight, getApproxLineHeight,
getBoundTextElementId, getBoundTextElementId,
getBoundTextElementOffset,
getContainerDims, getContainerDims,
getContainerElement, getContainerElement,
getTextElementAngle,
getTextWidth,
normalizeText,
wrapText, wrapText,
} from "./textElement"; } from "./textElement";
import { import {
@@ -28,17 +38,9 @@ import {
} from "../actions/actionProperties"; } from "../actions/actionProperties";
import { actionZoomIn, actionZoomOut } from "../actions/actionCanvas"; import { actionZoomIn, actionZoomOut } from "../actions/actionCanvas";
import App from "../components/App"; import App from "../components/App";
import { getMaxContainerWidth } from "./newElement"; import { getMaxContainerHeight, getMaxContainerWidth } from "./newElement";
import { LinearElementEditor } from "./linearElementEditor";
const normalizeText = (text: string) => { import { parseClipboard } from "../clipboard";
return (
text
// replace tabs with spaces so they render and measure correctly
.replace(/\t/g, " ")
// normalize newlines
.replace(/\r?\n|\r/g, "\n")
);
};
const getTransform = ( const getTransform = (
width: number, width: number,
@@ -61,6 +63,38 @@ const getTransform = (
return `translate(${translateX}px, ${translateY}px) scale(${zoom.value}) rotate(${degree}deg)`; return `translate(${translateX}px, ${translateY}px) scale(${zoom.value}) rotate(${degree}deg)`;
}; };
const originalContainerCache: {
[id: ExcalidrawTextContainer["id"]]:
| {
height: ExcalidrawTextContainer["height"];
}
| undefined;
} = {};
export const updateOriginalContainerCache = (
id: ExcalidrawTextContainer["id"],
height: ExcalidrawTextContainer["height"],
) => {
const data =
originalContainerCache[id] || (originalContainerCache[id] = { height });
data.height = height;
return data;
};
export const resetOriginalContainerCache = (
id: ExcalidrawTextContainer["id"],
) => {
if (originalContainerCache[id]) {
delete originalContainerCache[id];
}
};
export const getOriginalContainerHeightFromCache = (
id: ExcalidrawTextContainer["id"],
) => {
return originalContainerCache[id]?.height ?? null;
};
export const textWysiwyg = ({ export const textWysiwyg = ({
id, id,
onChange, onChange,
@@ -88,6 +122,9 @@ export const textWysiwyg = ({
updatedTextElement: ExcalidrawTextElement, updatedTextElement: ExcalidrawTextElement,
editable: HTMLTextAreaElement, editable: HTMLTextAreaElement,
) => { ) => {
if (!editable.style.fontFamily || !editable.style.fontSize) {
return false;
}
const currentFont = editable.style.fontFamily.replace(/"/g, ""); const currentFont = editable.style.fontFamily.replace(/"/g, "");
if ( if (
getFontFamilyString({ fontFamily: updatedTextElement.fontFamily }) !== getFontFamilyString({ fontFamily: updatedTextElement.fontFamily }) !==
@@ -100,7 +137,6 @@ export const textWysiwyg = ({
} }
return false; return false;
}; };
let originalContainerHeight: number;
const updateWysiwygStyle = () => { const updateWysiwygStyle = () => {
const appState = app.state; const appState = app.state;
@@ -115,7 +151,7 @@ export const textWysiwyg = ({
getFontString(updatedTextElement), getFontString(updatedTextElement),
); );
if (updatedTextElement && isTextElement(updatedTextElement)) { if (updatedTextElement && isTextElement(updatedTextElement)) {
const coordX = updatedTextElement.x; let coordX = updatedTextElement.x;
let coordY = updatedTextElement.y; let coordY = updatedTextElement.y;
const container = getContainerElement(updatedTextElement); const container = getContainerElement(updatedTextElement);
let maxWidth = updatedTextElement.width; let maxWidth = updatedTextElement.width;
@@ -124,8 +160,17 @@ export const textWysiwyg = ({
const width = updatedTextElement.width; const width = updatedTextElement.width;
// Set to element height by default since that's // Set to element height by default since that's
// what is going to be used for unbounded text // what is going to be used for unbounded text
let height = updatedTextElement.height; let textElementHeight = updatedTextElement.height;
if (container && updatedTextElement.containerId) { if (container && updatedTextElement.containerId) {
if (isArrowElement(container)) {
const boundTextCoords =
LinearElementEditor.getBoundTextElementPosition(
container,
updatedTextElement as ExcalidrawTextElementWithContainer,
);
coordX = boundTextCoords.x;
coordY = boundTextCoords.y;
}
const propertiesUpdated = textPropertiesUpdated( const propertiesUpdated = textPropertiesUpdated(
updatedTextElement, updatedTextElement,
editable, editable,
@@ -134,31 +179,52 @@ export const textWysiwyg = ({
// using editor.style.height to get the accurate height of text editor // using editor.style.height to get the accurate height of text editor
const editorHeight = Number(editable.style.height.slice(0, -2)); const editorHeight = Number(editable.style.height.slice(0, -2));
if (editorHeight > 0) { if (editorHeight > 0) {
height = editorHeight; textElementHeight = editorHeight;
} }
if (propertiesUpdated) { if (propertiesUpdated) {
originalContainerHeight = containerDims.height;
// update height of the editor after properties updated // update height of the editor after properties updated
height = updatedTextElement.height; textElementHeight = updatedTextElement.height;
} }
if (!originalContainerHeight) {
originalContainerHeight = containerDims.height; let originalContainerData;
if (propertiesUpdated) {
originalContainerData = updateOriginalContainerCache(
container.id,
containerDims.height,
);
} else {
originalContainerData = originalContainerCache[container.id];
if (!originalContainerData) {
originalContainerData = updateOriginalContainerCache(
container.id,
containerDims.height,
);
}
} }
maxWidth = containerDims.width - BOUND_TEXT_PADDING * 2;
maxHeight = containerDims.height - BOUND_TEXT_PADDING * 2; maxWidth = getMaxContainerWidth(container);
maxHeight = getMaxContainerHeight(container);
// autogrow container height if text exceeds // autogrow container height if text exceeds
if (height > maxHeight) {
const diff = Math.min(height - maxHeight, approxLineHeight); if (!isArrowElement(container) && textElementHeight > maxHeight) {
const diff = Math.min(
textElementHeight - maxHeight,
approxLineHeight,
);
mutateElement(container, { height: containerDims.height + diff }); mutateElement(container, { height: containerDims.height + diff });
return; return;
} else if ( } else if (
// autoshrink container height until original container height // autoshrink container height until original container height
// is reached when text is removed // is reached when text is removed
containerDims.height > originalContainerHeight && !isArrowElement(container) &&
height < maxHeight containerDims.height > originalContainerData.height &&
textElementHeight < maxHeight
) { ) {
const diff = Math.min(maxHeight - height, approxLineHeight); const diff = Math.min(
maxHeight - textElementHeight,
approxLineHeight,
);
mutateElement(container, { height: containerDims.height - diff }); mutateElement(container, { height: containerDims.height - diff });
} }
// Start pushing text upward until a diff of 30px (padding) // Start pushing text upward until a diff of 30px (padding)
@@ -166,11 +232,17 @@ export const textWysiwyg = ({
else { else {
// vertically center align the text // vertically center align the text
if (verticalAlign === VERTICAL_ALIGN.MIDDLE) { if (verticalAlign === VERTICAL_ALIGN.MIDDLE) {
coordY = container.y + containerDims.height / 2 - height / 2; if (!isArrowElement(container)) {
coordY =
container.y + containerDims.height / 2 - textElementHeight / 2;
}
} }
if (verticalAlign === VERTICAL_ALIGN.BOTTOM) { if (verticalAlign === VERTICAL_ALIGN.BOTTOM) {
coordY = coordY =
container.y + containerDims.height - height - BOUND_TEXT_PADDING; container.y +
containerDims.height -
textElementHeight -
getBoundTextElementOffset(updatedTextElement);
} }
} }
} }
@@ -204,19 +276,19 @@ export const textWysiwyg = ({
// Make sure text editor height doesn't go beyond viewport // Make sure text editor height doesn't go beyond viewport
const editorMaxHeight = const editorMaxHeight =
(appState.height - viewportY) / appState.zoom.value; (appState.height - viewportY) / appState.zoom.value;
const angle = container ? container.angle : updatedTextElement.angle;
Object.assign(editable.style, { Object.assign(editable.style, {
font: getFontString(updatedTextElement), font: getFontString(updatedTextElement),
// must be defined *after* font ¯\_(ツ)_/¯ // must be defined *after* font ¯\_(ツ)_/¯
lineHeight: `${lineHeight}px`, lineHeight: `${lineHeight}px`,
width: `${Math.min(width, maxWidth)}px`, width: `${Math.min(width, maxWidth)}px`,
height: `${height}px`, height: `${textElementHeight}px`,
left: `${viewportX}px`, left: `${viewportX}px`,
top: `${viewportY}px`, top: `${viewportY}px`,
transform: getTransform( transform: getTransform(
width, width,
height, textElementHeight,
angle, getTextElementAngle(updatedTextElement),
appState, appState,
maxWidth, maxWidth,
editorMaxHeight, editorMaxHeight,
@@ -271,10 +343,37 @@ export const textWysiwyg = ({
// prevent line wrapping (`whitespace: nowrap` doesn't work on FF) // prevent line wrapping (`whitespace: nowrap` doesn't work on FF)
whiteSpace, whiteSpace,
overflowWrap: "break-word", overflowWrap: "break-word",
boxSizing: "content-box",
}); });
updateWysiwygStyle(); updateWysiwygStyle();
if (onChange) { if (onChange) {
editable.onpaste = async (event) => {
const clipboardData = await parseClipboard(event, true);
if (!clipboardData.text) {
return;
}
const data = normalizeText(clipboardData.text);
if (!data) {
return;
}
const container = getContainerElement(element);
const font = getFontString({
fontSize: app.state.currentItemFontSize,
fontFamily: app.state.currentItemFontFamily,
});
if (container) {
const wrappedText = wrapText(
`${editable.value}${data}`,
font,
getMaxContainerWidth(container),
);
const width = getTextWidth(wrappedText, font);
editable.style.width = `${width}px`;
}
};
editable.oninput = () => { editable.oninput = () => {
const updatedTextElement = Scene.getScene(element)?.getElement( const updatedTextElement = Scene.getScene(element)?.getElement(
id, id,
@@ -485,7 +584,7 @@ export const textWysiwyg = ({
if (container) { if (container) {
text = updateElement.text; text = updateElement.text;
if (editable.value) { if (editable.value.trim()) {
const boundTextElementId = getBoundTextElementId(container); const boundTextElementId = getBoundTextElementId(container);
if (!boundTextElementId || boundTextElementId !== element.id) { if (!boundTextElementId || boundTextElementId !== element.id) {
mutateElement(container, { mutateElement(container, {
+3 -5
View File
@@ -4,7 +4,7 @@ import {
PointerType, PointerType,
} from "./types"; } from "./types";
import { getElementAbsoluteCoords, Bounds } from "./bounds"; import { getElementAbsoluteCoords } from "./bounds";
import { rotate } from "../math"; import { rotate } from "../math";
import { AppState, Zoom } from "../types"; import { AppState, Zoom } from "../types";
import { isTextElement } from "."; import { isTextElement } from ".";
@@ -81,7 +81,7 @@ const generateTransformHandle = (
}; };
export const getTransformHandlesFromCoords = ( export const getTransformHandlesFromCoords = (
[x1, y1, x2, y2]: Bounds, [x1, y1, x2, y2, cx, cy]: [number, number, number, number, number, number],
angle: number, angle: number,
zoom: Zoom, zoom: Zoom,
pointerType: PointerType, pointerType: PointerType,
@@ -97,8 +97,6 @@ export const getTransformHandlesFromCoords = (
const width = x2 - x1; const width = x2 - x1;
const height = y2 - y1; const height = y2 - y1;
const cx = (x1 + x2) / 2;
const cy = (y1 + y2) / 2;
const dashedLineMargin = margin / zoom.value; const dashedLineMargin = margin / zoom.value;
const centeringOffset = (size - DEFAULT_SPACING * 2) / (2 * zoom.value); const centeringOffset = (size - DEFAULT_SPACING * 2) / (2 * zoom.value);
@@ -256,7 +254,7 @@ export const getTransformHandles = (
? DEFAULT_SPACING + 8 ? DEFAULT_SPACING + 8
: DEFAULT_SPACING; : DEFAULT_SPACING;
return getTransformHandlesFromCoords( return getTransformHandlesFromCoords(
getElementAbsoluteCoords(element), getElementAbsoluteCoords(element, true),
element.angle, element.angle,
zoom, zoom,
pointerType, pointerType,

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