Compare commits

...

73 Commits

Author SHA1 Message Date
Maielo 2351af1854 updated socket io package to latest 2023-05-15 19:39:06 +02:00
Maielo 5e5faf4e67 set hidden connection to 5 mins 2023-05-15 19:38:46 +02:00
Maielo 4c70f471cb increased hidden disconnect timeout 2023-05-14 17:51:47 +02:00
dwelle d61a87f132 Merge branch 'master' into HEAD
# Conflicts:
#	src/excalidraw-app/collab/Collab.tsx
#	src/excalidraw-app/index.tsx
#	src/locales/en.json
2023-05-11 16:35:27 +02:00
Contextualist e619e06055 fix: i18n: Apply Trans component to publish library dialogue (#6564) 2023-05-10 10:39:21 +02:00
Excalidraw Bot d8965ee823 chore: Update translations from Crowdin (#6530)
* New translations en.json (Bengali)

* New translations en.json (Bengali)

* Auto commit: Calculate translation coverage

* New translations en.json (Khmer)

* Auto commit: Calculate translation coverage

* New translations en.json (Slovak)

* Auto commit: Calculate translation coverage

* New translations en.json (Karakalpak)

* Auto commit: Calculate translation coverage

* New translations en.json (Karakalpak)

* Auto commit: Calculate translation coverage

* New translations en.json (Chinese Simplified)

* New translations en.json (Spanish)

* New translations en.json (Italian)

* New translations en.json (Slovak)

* New translations en.json (Bengali)

* New translations en.json (Khmer)

* New translations en.json (Karakalpak)

* New translations en.json (Romanian)

* New translations en.json (French)

* New translations en.json (Arabic)

* New translations en.json (Bulgarian)

* New translations en.json (Catalan)

* New translations en.json (Czech)

* New translations en.json (Danish)

* New translations en.json (German)

* New translations en.json (Greek)

* New translations en.json (Basque)

* New translations en.json (Finnish)

* New translations en.json (Hebrew)

* New translations en.json (Hungarian)

* New translations en.json (Japanese)

* New translations en.json (Korean)

* New translations en.json (Kurdish)

* New translations en.json (Lithuanian)

* New translations en.json (Dutch)

* New translations en.json (Punjabi)

* New translations en.json (Polish)

* New translations en.json (Portuguese)

* New translations en.json (Russian)

* New translations en.json (Slovenian)

* New translations en.json (Swedish)

* New translations en.json (Turkish)

* New translations en.json (Ukrainian)

* New translations en.json (Chinese Traditional)

* New translations en.json (Vietnamese)

* New translations en.json (Galician)

* New translations en.json (Portuguese, Brazilian)

* New translations en.json (Indonesian)

* New translations en.json (Persian)

* New translations en.json (Tamil)

* New translations en.json (Marathi)

* New translations en.json (Thai)

* New translations en.json (Norwegian Nynorsk)

* New translations en.json (Kazakh)

* New translations en.json (Latvian)

* New translations en.json (Hindi)

* New translations en.json (Burmese)

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

* New translations en.json (Sinhala)

* New translations en.json (Norwegian Bokmal)

* New translations en.json (Occitan)

* New translations en.json (Kabyle)

* Auto commit: Calculate translation coverage

* New translations en.json (Romanian)

* New translations en.json (Korean)

* New translations en.json (Russian)

* New translations en.json (Slovenian)

* New translations en.json (Norwegian Bokmal)

* Auto commit: Calculate translation coverage

* New translations en.json (Chinese Simplified)

* New translations en.json (German)

* New translations en.json (Chinese Traditional)

* Auto commit: Calculate translation coverage

* New translations en.json (Karakalpak)

* Auto commit: Calculate translation coverage

* New translations en.json (Karakalpak)

* Auto commit: Calculate translation coverage

* New translations en.json (Vietnamese)

* Auto commit: Calculate translation coverage

* New translations en.json (Chinese Simplified)

* New translations en.json (Spanish)

* New translations en.json (Italian)

* New translations en.json (Slovak)

* New translations en.json (Bengali)

* New translations en.json (Khmer)

* New translations en.json (Karakalpak)

* New translations en.json (Romanian)

* New translations en.json (French)

* New translations en.json (Arabic)

* New translations en.json (Bulgarian)

* New translations en.json (Catalan)

* New translations en.json (Czech)

* New translations en.json (Danish)

* New translations en.json (German)

* New translations en.json (Greek)

* New translations en.json (Basque)

* New translations en.json (Finnish)

* New translations en.json (Hebrew)

* New translations en.json (Hungarian)

* New translations en.json (Japanese)

* New translations en.json (Korean)

* New translations en.json (Kurdish)

* New translations en.json (Lithuanian)

* New translations en.json (Dutch)

* New translations en.json (Punjabi)

* New translations en.json (Polish)

* New translations en.json (Portuguese)

* New translations en.json (Russian)

* New translations en.json (Slovenian)

* New translations en.json (Swedish)

* New translations en.json (Turkish)

* New translations en.json (Ukrainian)

* New translations en.json (Chinese Traditional)

* New translations en.json (Vietnamese)

* New translations en.json (Galician)

* New translations en.json (Portuguese, Brazilian)

* New translations en.json (Indonesian)

* New translations en.json (Persian)

* New translations en.json (Tamil)

* New translations en.json (Marathi)

* New translations en.json (Thai)

* New translations en.json (Norwegian Nynorsk)

* New translations en.json (Kazakh)

* New translations en.json (Latvian)

* New translations en.json (Hindi)

* New translations en.json (Burmese)

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

* New translations en.json (Sinhala)

* New translations en.json (Norwegian Bokmal)

* New translations en.json (Occitan)

* New translations en.json (Kabyle)

* Auto commit: Calculate translation coverage

* New translations en.json (Kurdish)

* New translations en.json (Slovenian)

* New translations en.json (Norwegian Bokmal)

* Auto commit: Calculate translation coverage

* New translations en.json (German)

* Auto commit: Calculate translation coverage

* New translations en.json (Chinese Simplified)

* New translations en.json (Chinese Traditional)

* Auto commit: Calculate translation coverage

* New translations en.json (Russian)

* Auto commit: Calculate translation coverage

* New translations en.json (Korean)

* Auto commit: Calculate translation coverage

* New translations en.json (Romanian)

* Auto commit: Calculate translation coverage

* New translations en.json (Swedish)

* Auto commit: Calculate translation coverage

* New translations en.json (Slovak)

* New translations en.json (Persian)

* Auto commit: Calculate translation coverage
2023-05-09 18:15:27 +05:30
David Luzar 560231d365 perf: use UIAppState where possible to reduce UI rerenders (#6560) 2023-05-08 10:14:02 +02:00
David Luzar 026949204d fix: fix brave error i18n string and remove unused (#6561) 2023-05-06 10:36:42 +02:00
Luka Zakrajšek 1184a8c0e9 feat: Add Trans component for interpolating JSX in translations (#6534)
* feat: add Trans component

* Add comments

* tweak

* Move brave to trans component

* fix test and tweaks

* remove any

* fix

* fix

* comment

* replace render function type

* Use tags for Trans

* Fix a typo

Co-authored-by: Aakansha Doshi <aakansha1216@gmail.com>

* Cleanup, add comments, add support for kebab case

* tweaks

---------

Co-authored-by: Aakansha Doshi <aakansha1216@gmail.com>
Co-authored-by: dwelle <luzar.david@gmail.com>
2023-05-05 21:35:18 +05:30
David Luzar e9cae918a7 feat: sidebar tabs support (#6213)
* feat: Sidebar tabs support [wip]

* tab trigger styling tweaks

* add `:hover` & `:active` states

* replace `@dwelle/tunnel-rat` with `tunnel-rat`

* make stuff more explicit

- remove `Sidebar.Header` fallback (host apps need to render manually), and stop tunneling it (render in place)
- make `docked` state explicit
- stop tunneling `Sidebar.TabTriggers` (render in place)

* redesign sidebar / library as per latest spec

* support no label on `Sidebar.Trigger`

* add Sidebar `props.onStateChange`

* style fixes

* make `appState.isSidebarDocked` into a soft user preference

* px -> rem & refactor

* remove `props.renderSidebar`

* update tests

* remove

* refactor

* rename constants

* tab triggers styling fixes

* factor out library-related logic from generic sidebar trigger

* change `props.onClose` to `onToggle`

* rename `props.value` -> `props.tab`

* add displayNames

* allow HTMLAttributes on applicable compos

* fix example App

* more styling tweaks and fixes

* fix not setting `dockable`

* more style fixes

* fix and align sidebar header button styling

* make DefaultSidebar dockable on if host apps supplies `onDock`

* stop `Sidebar.Trigger` hiding label on mobile

this should be only the default sidebar trigger behavior, and for that we don't need to use `device` hook as we handle in CSS

* fix `dockable` prop of defaultSidebar

* remove extra `typescript` dep

* remove `defaultTab` prop

in favor of explicit `tab` value in `<Sidebar.Trigger/>` and `toggleSidebar()`, to reduce API surface area and solve inconsistency of `appState.openSidebar.tab` not reflecting actual UI value if `defaultTab` was supported (without additional syncing logic which feels like the wrong solution).

* remove `onToggle` in favor of `onStateChange`

reducing API surface area

* fix restore

* comment no longer applies

* reuse `Button` component in sidebar buttons

* fix tests

* split Sidebar sub-components into files

* remove `props.dockable` in favor of `props.onDock` only

* split tests

* fix sidebar showing dock button if no `props.docked` supplied & add more tests

* reorder and group sidebar tests

* clarify

* rename classes & dedupe css

* refactor tests

* update changelog

* update changelog

---------

Co-authored-by: barnabasmolnar <barnabas@excalidraw.com>
2023-05-04 17:33:31 +00:00
Aakansha Doshi b1311a407a fix: Revert add version tags to Docker build (#6540)
Revert "build: Add version tags to Docker build (#6508)"

This reverts commit 1815cf3213.
2023-05-02 12:49:11 +05:30
Excalidraw Bot 2a39d0b9a7 chore: Update translations from Crowdin (#6471) 2023-04-27 19:27:36 +02:00
Milos Vetesnik 6b0218b012 feat: testing simple analytics and fathom analytics for better privacy of the users (#6529)
Co-authored-by: dwelle <luzar.david@gmail.com>
2023-04-27 19:11:42 +02:00
Aakansha Doshi 45a57d70de fix: don't refresh dimensions for text containers on font load (#6523) 2023-04-26 21:35:06 +05:30
Aakansha Doshi da8dd389a9 fix: cleanup getMaxContainerHeight and getMaxContainerWidth (#6519)
* fix: cleanup getMaxContainerHeight and getMaxContainerWidth

* rename getMaxContainerWidth -> getBoundTextMaxMaxWidth and getMaxContainerHeight -> getBoundTextMaxHeight

* add specs
2023-04-25 18:06:23 +05:30
Aakansha Doshi dae81c0a2c fix: cleanup redrawTextBoundingBox (#6518)
* chore: cleanup redrawTextBoundingBox

* fix
2023-04-25 17:57:53 +05:30
suwalkanishka 1e9943323a style: fix font family inconsistencies (#6501)
style: font fix for four components

The browser default font was showing up in various locations. Fixed them to show the desired ui font.
2023-04-25 17:20:19 +05:30
Nainterceptor 1815cf3213 build: Add version tags to Docker build (#6508)
ci: Add version tags
2023-04-25 16:51:25 +05:30
David Luzar d35386755f feat: retain seed on shift-paste (#6509)
thanks for the review 👍
2023-04-24 10:26:21 +02:00
zsviczian 9d5cfbbfb7 fix: text jumps when editing on Android Chrome (#6503)
* debug logging

* debug

* debugging

* Update textWysiwyg.tsx

* Update textWysiwyg.tsx

* extended debug information

* debug

* debug

* trace

* further debug

* don't drag while editing

* removing all console.logs

* revert all changes to textWysiwyt.tsx

* updated comment
2023-04-22 14:17:13 +02:00
David Luzar fee760d38c feat: allow avif, jfif, webp, bmp, ico image types (#6500
* feat: allow `avif`, `jfif`, `webp`, `bmp`, `ico` image types

* dedupe for SSOT

* more SSOT
2023-04-21 22:53:49 +02:00
Aakansha Doshi 2a4799d8c8 chore: upgrade excalidraw version to 0.15.2 (#6496)
chore: updragde excalidraw version to 0.15.2
2023-04-20 17:40:08 +05:30
Aakansha Doshi c4445c181b docs: release @excalidraw/excalidraw@0.15.2 🎉 (#6495) 2023-04-20 17:34:06 +05:30
dependabot[bot] d12a9fdd40 build(deps): bump ua-parser-js from 0.7.31 to 0.7.33 in /dev-docs (#6164)
Bumps [ua-parser-js](https://github.com/faisalman/ua-parser-js) from 0.7.31 to 0.7.33.
- [Release notes](https://github.com/faisalman/ua-parser-js/releases)
- [Changelog](https://github.com/faisalman/ua-parser-js/blob/master/changelog.md)
- [Commits](https://github.com/faisalman/ua-parser-js/compare/0.7.31...0.7.33)

---
updated-dependencies:
- dependency-name: ua-parser-js
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2023-04-20 17:07:56 +05:30
dependabot[bot] 9368a9ce3e build(deps): bump webpack from 5.75.0 to 5.76.1 (#6357)
Bumps [webpack](https://github.com/webpack/webpack) from 5.75.0 to 5.76.1.
- [Release notes](https://github.com/webpack/webpack/releases)
- [Commits](https://github.com/webpack/webpack/compare/v5.75.0...v5.76.1)

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

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2023-04-20 17:05:03 +05:30
siddhant 851b9b7aec fix: rotate the text element when binding to a rotated container (#6477)
* Updated logic to update the bound child angle from the parent

* update angle when generating text element

* add test

* remove

* fix

---------

Co-authored-by: Aakansha Doshi <aakansha1216@gmail.com>
2023-04-20 16:34:39 +05:30
Aakansha Doshi 5ddb28d378 fix: support breaking words containing hyphen - (#6014)
* fix: support breaking words containing hyphen -

* fix

* add spec

* fix

* fix

* fix

* fix and add spec

* improve code and add more tests
2023-04-20 11:10:46 +05:30
Max Kovalenko 404a79e241 chore: typo (collab) - reconciliation.ts (#6447) 2023-04-19 19:18:03 +02:00
David Luzar eea30da05a fix: incorrect background fill button active state (#6491) 2023-04-19 16:23:24 +02:00
Aakansha Doshi 98a77d7426 chore: show bounding box only when flag is true (#6490) 2023-04-19 17:02:20 +05:30
Aakansha Doshi ff3c2e5a16 docs: fix docs link in readme (#6486)
* docs: fix docs link in readme

* update changelog
2023-04-18 21:52:08 +05:30
Aakansha Doshi b64beaf5ba docs: release @excalidraw/excalidraw@0.15.1 🎉 (#6485) 2023-04-18 21:32:33 +05:30
Aakansha Doshi 89304c9f66 fix: add readme back to npm package which was mistakenly removed (#6484)
* fix: remove update readme script from release

* update docs

* remove

* fix
2023-04-18 21:23:47 +05:30
Aakansha Doshi 1d0653ce50 docs: update docs for next version (#6251)
* update docs for opts param inr estore utils

* docs: add docs for `useI18n` hook (#6269)

Co-authored-by: Aakansha Doshi <aakansha1216@gmail.com>

* upgrade excal

---------

Co-authored-by: David Luzar <luzar.david@gmail.com>
2023-04-18 21:03:17 +05:30
Aakansha Doshi c9c79646c5 docs: release @excalidraw/excalidraw@0.15.0 🎉 (#6481) 2023-04-18 20:48:30 +05:30
Aakansha Doshi 979312f779 fix: center align text when wrapped in container via context menu (#6480)
* rename action to wrapTextInContainer

* fix: center align text when wrapped in container via context menu

* revert translation key

* fix tests
2023-04-18 19:44:14 +05:30
David Luzar 4d0d844e39 feat: constrain export dialog preview size (#6475) 2023-04-18 15:27:51 +02:00
Aakansha Doshi 801412bf6b fix: restore original container height when unbinding text which was binded via context menu (#6444)
* fix: restore original container height when unbinding text which was binded via context menu

* remove flag

* comment
2023-04-18 18:50:25 +05:30
Aakansha Doshi 21726e22cc fix: mark more props as optional for element (#6448)
* fix: mark more props as optional for element

* fix
2023-04-18 18:42:48 +05:30
David Luzar c3e8ddaf58 fix: improperly cache-busting on canvas scale instead of zoom (#6473) 2023-04-17 11:41:27 +02:00
David Luzar f640ddc2aa fix: incorrectly duplicating items on paste/library insert (#6467
* fix: incorrectly duplicating items on paste/library insert

* fix: deduplicate element ids on restore

* tests
2023-04-16 17:22:16 +02:00
Excalidraw Bot e7e54814e7 chore: Update translations from Crowdin (#6290)
* New translations en.json (Occitan)

* New translations en.json (Kabyle)

* Auto commit: Calculate translation coverage

* New translations en.json (Romanian)

* New translations en.json (French)

* New translations en.json (Slovenian)

* New translations en.json (Norwegian Bokmal)

* Auto commit: Calculate translation coverage

* New translations en.json (Dutch)

* Auto commit: Calculate translation coverage

* New translations en.json (Thai)

* Auto commit: Calculate translation coverage

* New translations en.json (Thai)

* Auto commit: Calculate translation coverage

* New translations en.json (Chinese Traditional)

* New translations en.json (Thai)

* Auto commit: Calculate translation coverage

* New translations en.json (Galician)

* Auto commit: Calculate translation coverage

* New translations en.json (German)

* Auto commit: Calculate translation coverage

* New translations en.json (Marathi)

* New translations en.json (Hindi)

* Auto commit: Calculate translation coverage

* New translations en.json (Thai)

* Auto commit: Calculate translation coverage

* New translations en.json (Thai)

* Auto commit: Calculate translation coverage

* New translations en.json (Thai)

* Auto commit: Calculate translation coverage

* New translations en.json (Thai)

* Auto commit: Calculate translation coverage

* New translations en.json (Basque)

* Auto commit: Calculate translation coverage

* New translations en.json (Swedish)

* Auto commit: Calculate translation coverage

* New translations en.json (Spanish)

* Auto commit: Calculate translation coverage

* New translations en.json (Hebrew)

* Auto commit: Calculate translation coverage

* New translations en.json (Hebrew)

* Auto commit: Calculate translation coverage

* New translations en.json (Hebrew)

* New translations en.json (Chinese Simplified)

* Auto commit: Calculate translation coverage

* New translations en.json (Slovak)

* Auto commit: Calculate translation coverage

* New translations en.json (Thai)

* Auto commit: Calculate translation coverage

* New translations en.json (Thai)

* Auto commit: Calculate translation coverage

* New translations en.json (Turkish)

* Auto commit: Calculate translation coverage

* New translations en.json (Romanian)

* New translations en.json (Spanish)

* New translations en.json (Catalan)

* New translations en.json (Basque)

* New translations en.json (Finnish)

* New translations en.json (Portuguese)

* New translations en.json (Slovak)

* New translations en.json (Chinese Simplified)

* New translations en.json (Portuguese, Brazilian)

* New translations en.json (Tamil)

* New translations en.json (Marathi)

* New translations en.json (Hindi)

* New translations en.json (French)

* New translations en.json (Arabic)

* New translations en.json (Bulgarian)

* New translations en.json (Czech)

* New translations en.json (Danish)

* New translations en.json (German)

* New translations en.json (Greek)

* New translations en.json (Hebrew)

* New translations en.json (Hungarian)

* New translations en.json (Italian)

* New translations en.json (Japanese)

* New translations en.json (Korean)

* New translations en.json (Kurdish)

* New translations en.json (Lithuanian)

* New translations en.json (Dutch)

* New translations en.json (Punjabi)

* New translations en.json (Polish)

* New translations en.json (Russian)

* New translations en.json (Slovenian)

* New translations en.json (Swedish)

* New translations en.json (Turkish)

* New translations en.json (Ukrainian)

* New translations en.json (Chinese Traditional)

* New translations en.json (Vietnamese)

* New translations en.json (Galician)

* New translations en.json (Indonesian)

* New translations en.json (Persian)

* New translations en.json (Bengali)

* New translations en.json (Norwegian Nynorsk)

* New translations en.json (Kazakh)

* New translations en.json (Latvian)

* New translations en.json (Burmese)

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

* New translations en.json (Sinhala)

* New translations en.json (Norwegian Bokmal)

* New translations en.json (Occitan)

* New translations en.json (Kabyle)

* New translations en.json (Thai)

* Auto commit: Calculate translation coverage

* New translations en.json (Chinese Simplified)

* New translations en.json (Greek)

* New translations en.json (Slovenian)

* New translations en.json (Swedish)

* New translations en.json (Norwegian Bokmal)

* Auto commit: Calculate translation coverage

* New translations en.json (Romanian)

* Auto commit: Calculate translation coverage

* New translations en.json (Chinese Traditional)

* Auto commit: Calculate translation coverage

* New translations en.json (Portuguese)

* Auto commit: Calculate translation coverage

* New translations en.json (Portuguese)

* New translations en.json (German)

* Auto commit: Calculate translation coverage

* New translations en.json (Galician)

* Auto commit: Calculate translation coverage

* New translations en.json (Galician)

* Auto commit: Calculate translation coverage

* New translations en.json (Hebrew)

* Auto commit: Calculate translation coverage

* New translations en.json (Hebrew)

* New translations en.json (French)

* Auto commit: Calculate translation coverage

* New translations en.json (Indonesian)

* Auto commit: Calculate translation coverage

* New translations en.json (Indonesian)

* Auto commit: Calculate translation coverage

* New translations en.json (Italian)

* New translations en.json (Chinese Simplified)

* New translations en.json (Chinese Simplified)

* New translations en.json (Kabyle)

* Auto commit: Calculate translation coverage

* New translations en.json (Kabyle)

* Auto commit: Calculate translation coverage

* New translations en.json (Marathi)

* New translations en.json (Hindi)

* Auto commit: Calculate translation coverage

* New translations en.json (Hindi)

* Auto commit: Calculate translation coverage

* New translations en.json (Slovak)

* Auto commit: Calculate translation coverage

* New translations en.json (Spanish)

* Auto commit: Calculate translation coverage

* New translations en.json (Thai)

* Auto commit: Calculate translation coverage

* New translations en.json (Thai)

* Auto commit: Calculate translation coverage

* New translations en.json (Japanese)

* Auto commit: Calculate translation coverage

* New translations en.json (Dutch)

* Auto commit: Calculate translation coverage

* New translations en.json (Dutch)

* Auto commit: Calculate translation coverage

* New translations en.json (Basque)

* Auto commit: Calculate translation coverage

* New translations en.json (Basque)

* Auto commit: Calculate translation coverage

* New translations en.json (Marathi)

* Auto commit: Calculate translation coverage

* New translations en.json (Marathi)

* Auto commit: Calculate translation coverage

* New translations en.json (Marathi)

* New translations en.json (Hindi)

* Auto commit: Calculate translation coverage

* New translations en.json (Polish)

* Auto commit: Calculate translation coverage

* New translations en.json (Polish)

* New translations en.json (Chinese Simplified)

* New translations en.json (Vietnamese)

* Auto commit: Calculate translation coverage

* New translations en.json (Vietnamese)

* Auto commit: Calculate translation coverage

* New translations en.json (Polish)

* Auto commit: Calculate translation coverage

* New translations en.json (Korean)

* Auto commit: Calculate translation coverage

* New translations en.json (Dutch)

* New translations en.json (Basque)

* New translations en.json (Marathi)

* New translations en.json (Hindi)

* New translations en.json (Polish)

* New translations en.json (Chinese Simplified)

* New translations en.json (Vietnamese)

* New translations en.json (Korean)

* New translations en.json (Romanian)

* New translations en.json (French)

* New translations en.json (Spanish)

* New translations en.json (Arabic)

* New translations en.json (Bulgarian)

* New translations en.json (Catalan)

* New translations en.json (Czech)

* New translations en.json (Danish)

* New translations en.json (German)

* New translations en.json (Greek)

* New translations en.json (Finnish)

* New translations en.json (Hebrew)

* New translations en.json (Hungarian)

* New translations en.json (Italian)

* New translations en.json (Japanese)

* New translations en.json (Kurdish)

* New translations en.json (Lithuanian)

* New translations en.json (Punjabi)

* New translations en.json (Portuguese)

* New translations en.json (Russian)

* New translations en.json (Slovak)

* New translations en.json (Slovenian)

* New translations en.json (Swedish)

* New translations en.json (Turkish)

* New translations en.json (Ukrainian)

* New translations en.json (Chinese Traditional)

* New translations en.json (Galician)

* New translations en.json (Portuguese, Brazilian)

* New translations en.json (Indonesian)

* New translations en.json (Persian)

* New translations en.json (Tamil)

* New translations en.json (Bengali)

* New translations en.json (Thai)

* New translations en.json (Norwegian Nynorsk)

* New translations en.json (Kazakh)

* New translations en.json (Latvian)

* New translations en.json (Burmese)

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

* New translations en.json (Sinhala)

* New translations en.json (Norwegian Bokmal)

* New translations en.json (Occitan)

* New translations en.json (Kabyle)

* Auto commit: Calculate translation coverage

* New translations en.json (Slovenian)

* New translations en.json (Norwegian Bokmal)

* Auto commit: Calculate translation coverage

* New translations en.json (Japanese)

* Auto commit: Calculate translation coverage

* New translations en.json (German)

* Auto commit: Calculate translation coverage

* New translations en.json (Chinese Traditional)

* Auto commit: Calculate translation coverage

* New translations en.json (Marathi)

* New translations en.json (Hindi)

* Auto commit: Calculate translation coverage

* New translations en.json (Romanian)

* Auto commit: Calculate translation coverage

* New translations en.json (Italian)

* Auto commit: Calculate translation coverage

* New translations en.json (Turkish)

* Auto commit: Calculate translation coverage
2023-04-16 17:12:37 +02:00
David Luzar e9064a4a87 fix: library ids cross-contamination on multiple insert (#6466) 2023-04-16 17:09:51 +02:00
David Luzar 034113772d fix: color picker keyboard handling not working (#6464) 2023-04-16 15:33:30 +02:00
zsviczian d34cd3072f fix: abort freedraw line if second touch is detected (#6440) 2023-04-16 15:33:16 +02:00
David Luzar e31230f78c refactor: inline SingleLibraryItem into PublishLibrary (#6462
refactor: inline `SingleLibraryItem` into `PublishLibrary` to reduce api surface area
2023-04-16 11:57:13 +02:00
David Luzar 399c92d882 fix: utils leaking Scene state (#6461
* fix: utils leaking Scene state

* remove debug

* doc

* add tests for group duplicating

* tweaks
2023-04-16 11:56:25 +02:00
Aurélie b0b23353cf fix: split "Edit selected shape" shortcut (#6457)
Co-authored-by: David Luzar <luzar.david@gmail.com>
2023-04-14 21:34:26 +00:00
Bear 6164b5273c fix: center align text when bind to container via context menu (#6451) 2023-04-14 15:22:39 +02:00
Aakansha Doshi ca3be2c678 fix: exporting labelled arrows via export utils (#6443)
* fix: exporting labelled arrows via export utils

* add comments

* lint

* update changelog

* fix lint

* initialize scene in the utils so it can be availabe in the helper functions

* fix library rendering

* add comments
2023-04-13 17:19:46 +05:30
Aakansha Doshi 13b27afe0f fix: update coords when text unbinded from its container (#6445)
* fix: update coords when text unbinded from its container

* Add specs
2023-04-13 11:45:58 +05:30
David Luzar 372743f59f fix: autoredirect to plus in prod only (#6446) 2023-04-12 10:57:00 +02:00
Nishant fc601347cf fix: fixing popover overflow on small screen (#6433)
Co-authored-by: dwelle <luzar.david@gmail.com>
2023-04-11 23:23:36 +02:00
David Luzar e4d8ba226f feat: zigzag fill easter egg (#6439) 2023-04-10 15:38:50 +02:00
Aakansha Doshi ec215362a1 fix: introduce baseline to fix the layout shift when switching to text editor (#6397)
* fix: introduce baseline to fix the layout shift when switching to text editor

* uncomment

* change offset to 8pixels

* [debug]

* introduce DOM baseline in canvas rendering instead

* introduce baseline in element making it backward compat

* fix

* lint

* fix

* update baseline when resizing text element

* fix safari backward compat

* fix for safari

* lint

* reduce safari LS

* floor line height and height when dom height increases than canvas height

* Revert "floor line height and height when dom height increases than canvas height"

This reverts commit 8de6516823.

* cleanup

* use DOM height only for safari to fix LS

* Revert "use DOM height only for safari to fix LS"

This reverts commit d75889238d.

* fix lint and test

* fix

* calculate line height by rounding off instead of DOM

* cleanup

---------

Co-authored-by: dwelle <luzar.david@gmail.com>
2023-04-10 18:52:46 +05:30
Aakansha Doshi 0b8fc4f4b6 fix: don't refresh dimensions for deleted text elements (#6438) 2023-04-10 16:31:58 +05:30
Manuel Brandstetter c170403b13 docs: fix closing tag in readme (#6424
Update README.md
2023-04-07 17:43:50 +02:00
zsviczian 705ac9c1ab fix: element vanishes when zoomed in (#6417)
Co-authored-by: David Luzar <luzar.david@gmail.com>
2023-04-07 15:30:44 +00:00
Chinmay Mhatre 68692b9d4c feat: add container to multiple text elements (#6428)
Co-authored-by: dwelle <luzar.david@gmail.com>
2023-04-07 13:50:36 +00:00
Aakansha Doshi d61b3cf83d fix: don't jump text to end when out of viewport in safari (#6416) 2023-04-05 12:17:18 +05:30
Coyote d2b8f4d2f8 fix: getDefaultLineHeight should return default font family line height for unknown font (#6399)
* fix(getDefaultLineHeight): make getDefaultLineHeight always has a default value

* test: add getDefaultLineHeight test case when using unknown font

* test: add getDefaultLineHeight test case when using unknown font

* Revert "test: add getDefaultLineHeight test case when using unknown font"

This reverts commit d41da5493b6edab9e599a13a23c387d38345bf03.

* test: add getDefaultLineHeight test case when using unknown font

* newline

* newline

* tweaks

* trigger action

* trigger action

* fix

---------

Co-authored-by: Aakansha Doshi <aakansha1216@gmail.com>
2023-03-30 00:46:23 +05:30
Aakansha Doshi f8e65bb77e fix: Revert use ideographic textBaseline to improve layout shift when editing text" (#6400)
Revert "fix: use `ideographic` textBaseline to improve layout shift when editing text (#6384)"

This reverts commit 9e52c30ce8.
2023-03-29 18:53:03 +05:30
Milos Vetesnik 3030e96d62 feat: starting migration from GA to Matomo for better privacy (#6398)
Co-authored-by: dwelle <luzar.david@gmail.com>
2023-03-29 11:13:06 +02:00
dongfang 44453b725d fix: call stack size exceeded when paste large text (#6373) (#6396)
* fix: call stack size exceeded when paste large text (#6373)

* fix: add test case for paste multi-line text

* fix

* tweak

* add missing assertion

* add comments

* lint

---------

Co-authored-by: Aakansha Doshi <aakansha1216@gmail.com>
2023-03-29 13:45:30 +05:30
Type Horror 25bb6738ea feat: Add fitToContent and animate to scrollToContent (#6319)
Co-authored-by: Brice Leroy <brice@brigalabs.com>
Co-authored-by: dwelle <luzar.david@gmail.com>
2023-03-25 23:26:58 +01:00
David Luzar 9e52c30ce8 fix: use ideographic textBaseline to improve layout shift when editing text (#6384) 2023-03-22 18:33:37 +01:00
Ryan Di 4d3dec612c remove redudant setToast to null 2022-09-01 20:46:04 +08:00
Ryan Di 8b84322627 remove exhaustive-deps that's no longer needed 2022-09-01 19:41:33 +08:00
Ryan Di df5a5f7d86 use excalidrawAPI.setToast for reconnection toast message 2022-09-01 19:33:46 +08:00
Ryan Di a1b1a2f9a3 fix >5s blank scene for first one in the room when reconnecting 2022-08-30 22:36:24 +08:00
Ryan Di c71495b140 add toast message when reconnecting to server 2022-08-29 16:23:52 +08:00
Ryan Di f8c9f63650 fix: disconnect from and reconnect to socket server when visibilitychange 2022-08-26 20:11:16 +08:00
Ryan Di 2816c72c62 fix: fix function boolean typo 2022-08-26 20:00:39 +08:00
195 changed files with 8285 additions and 4530 deletions
+5
View File
@@ -23,6 +23,11 @@ REACT_APP_DEV_DISABLE_LIVE_RELOAD=
FAST_REFRESH=false FAST_REFRESH=false
# MATOMO
REACT_APP_MATOMO_URL=
REACT_APP_CDN_MATOMO_TRACKER_URL=
REACT_APP_MATOMO_SITE_ID=
#Debug flags #Debug flags
# To enable bounding box for text containers # To enable bounding box for text containers
+7
View File
@@ -12,6 +12,13 @@ REACT_APP_WS_SERVER_URL=
REACT_APP_FIREBASE_CONFIG='{"apiKey":"AIzaSyAd15pYlMci_xIp9ko6wkEsDzAAA0Dn0RU","authDomain":"excalidraw-room-persistence.firebaseapp.com","databaseURL":"https://excalidraw-room-persistence.firebaseio.com","projectId":"excalidraw-room-persistence","storageBucket":"excalidraw-room-persistence.appspot.com","messagingSenderId":"654800341332","appId":"1:654800341332:web:4a692de832b55bd57ce0c1"}' REACT_APP_FIREBASE_CONFIG='{"apiKey":"AIzaSyAd15pYlMci_xIp9ko6wkEsDzAAA0Dn0RU","authDomain":"excalidraw-room-persistence.firebaseapp.com","databaseURL":"https://excalidraw-room-persistence.firebaseio.com","projectId":"excalidraw-room-persistence","storageBucket":"excalidraw-room-persistence.appspot.com","messagingSenderId":"654800341332","appId":"1:654800341332:web:4a692de832b55bd57ce0c1"}'
# production-only vars # production-only vars
# GOOGLE ANALYTICS
REACT_APP_GOOGLE_ANALYTICS_ID=UA-387204-13 REACT_APP_GOOGLE_ANALYTICS_ID=UA-387204-13
# MATOMO
REACT_APP_MATOMO_URL=https://excalidraw.matomo.cloud/
REACT_APP_CDN_MATOMO_TRACKER_URL=//cdn.matomo.cloud/excalidraw.matomo.cloud/matomo.js
REACT_APP_MATOMO_SITE_ID=1
REACT_APP_PLUS_APP=https://app.excalidraw.com REACT_APP_PLUS_APP=https://app.excalidraw.com
+1 -1
View File
@@ -17,7 +17,7 @@
An open source virtual hand-drawn style whiteboard. </br> An open source virtual hand-drawn style whiteboard. </br>
Collaborative and end-to-end encrypted. </br> Collaborative and end-to-end encrypted. </br>
<br /> <br />
</h3> </h2>
</div> </div>
<br /> <br />
@@ -1,6 +1,19 @@
# ref # ref
<pre> <pre>
<a href="https://reactjs.org/docs/refs-and-the-dom.html#creating-refs">createRef</a> &#124; <a href="https://reactjs.org/docs/hooks-reference.html#useref">useRef</a> &#124; <a href="https://reactjs.org/docs/refs-and-the-dom.html#callback-refs">callbackRef</a> &#124; <br/>&#123; current: &#123; readyPromise: <a href="https://github.com/excalidraw/excalidraw/blob/master/src/utils.ts#L460">resolvablePromise</a> } } <a href="https://reactjs.org/docs/refs-and-the-dom.html#creating-refs">
createRef
</a>{" "}
&#124;{" "}
<a href="https://reactjs.org/docs/hooks-reference.html#useref">useRef</a>{" "}
&#124;{" "}
<a href="https://reactjs.org/docs/refs-and-the-dom.html#callback-refs">
callbackRef
</a>{" "}
&#124; <br />
&#123; current: &#123; readyPromise: <a href="https://github.com/excalidraw/excalidraw/blob/master/src/utils.ts#L460">
resolvablePromise
</a> } }
</pre> </pre>
You can pass a `ref` when you want to access some excalidraw APIs. We expose the below APIs: You can pass a `ref` when you want to access some excalidraw APIs. We expose the below APIs:
@@ -139,7 +152,9 @@ function App() {
return ( return (
<div style={{ height: "500px" }}> <div style={{ height: "500px" }}>
<p style={{ fontSize: "16px" }}> Click to update the scene</p> <p style={{ fontSize: "16px" }}> Click to update the scene</p>
<button className="custom-button" onClick={updateScene}>Update Scene</button> <button className="custom-button" onClick={updateScene}>
Update Scene
</button>
<Excalidraw ref={(api) => setExcalidrawAPI(api)} /> <Excalidraw ref={(api) => setExcalidrawAPI(api)} />
</div> </div>
); );
@@ -187,7 +202,8 @@ function App() {
return ( return (
<div style={{ height: "500px" }}> <div style={{ height: "500px" }}>
<p style={{ fontSize: "16px" }}> Click to update the library items</p> <p style={{ fontSize: "16px" }}> Click to update the library items</p>
<button className="custom-button" <button
className="custom-button"
onClick={() => { onClick={() => {
const libraryItems = [ const libraryItems = [
{ {
@@ -205,10 +221,8 @@ function App() {
]; ];
excalidrawAPI.updateLibrary({ excalidrawAPI.updateLibrary({
libraryItems, libraryItems,
openLibraryMenu: true openLibraryMenu: true,
}); });
}} }}
> >
Update Library Update Library
@@ -250,7 +264,7 @@ Resets the scene. If `resetLoadingState` is passed as true then it will also for
<pre> <pre>
() =>{" "} () =>{" "}
<a href="https://github.com/excalidraw/excalidraw/blob/master/src/element/types.ts#L114"> <a href="https://github.com/excalidraw/excalidraw/blob/master/src/element/types.ts#L115">
ExcalidrawElement[] ExcalidrawElement[]
</a> </a>
</pre> </pre>
@@ -261,7 +275,7 @@ Returns all the elements including the deleted in the scene.
<pre> <pre>
() => NonDeleted&#60; () => NonDeleted&#60;
<a href="https://github.com/excalidraw/excalidraw/blob/master/src/element/types.ts#L114"> <a href="https://github.com/excalidraw/excalidraw/blob/master/src/element/types.ts#L115">
ExcalidrawElement ExcalidrawElement
</a> </a>
[]&#62; []&#62;
@@ -293,18 +307,31 @@ This is the history API. history.clear() will clear the history.
## scrollToContent ## scrollToContent
<pre> <pre>
(target?:{" "} (<br />
<a href="https://github.com/excalidraw/excalidraw/blob/master/src/element/types.ts#L114"> {" "}
target?:{" "}
<a href="https://github.com/excalidraw/excalidraw/blob/master/src/element/types.ts#L115">
ExcalidrawElement ExcalidrawElement
</a>{" "} </a>{" "}
&#124;{" "} &#124;{" "}
<a href="https://github.com/excalidraw/excalidraw/blob/master/src/element/types.ts#L114"> <a href="https://github.com/excalidraw/excalidraw/blob/master/src/element/types.ts#L115">
ExcalidrawElement ExcalidrawElement
</a> </a>
[]) => void [],
<br />
{" "}opts?: &#123; fitToContent?: boolean; animate?: boolean; duration?: number
&#125;
<br />) => void
</pre> </pre>
Scroll the nearest element out of the elements supplied to the center. Defaults to the elements on the scene. Scroll the nearest element out of the elements supplied to the center of the viewport. Defaults to the elements on the scene.
| Attribute | type | default | Description |
| --- | --- | --- | --- |
| target | <code>ExcalidrawElement &#124; ExcalidrawElement[]</code> | All scene elements | The element(s) to scroll to. |
| opts.fitToContent | boolean | false | Whether to fit the elements to viewport by automatically changing zoom as needed. |
| opts.animate | boolean | false | Whether to animate between starting and ending position. Note that for larger scenes the animation may not be smooth due to performance issues. |
| opts.duration | number | 500 | Duration of the animation if `opts.animate` is `true`. |
## refresh ## refresh
@@ -358,15 +385,18 @@ This API can be used to get the files present in the scene. It may contain files
This API has the below signature. It sets the `tool` passed in param as the active tool. This API has the below signature. It sets the `tool` passed in param as the active tool.
<pre> <pre>
(tool: <br/> &#123; type: <a href="https://github.com/excalidraw/excalidraw/blob/master/src/shapes.tsx#L15">SHAPES</a>[number]["value"]&#124; "eraser" &#125; &#124;<br/> &#123; type: "custom"; customType: string &#125;) => void (tool: <br /> &#123; type:{" "}
<a href="https://github.com/excalidraw/excalidraw/blob/master/src/shapes.tsx#L15">
SHAPES
</a>
[number]["value"]&#124; "eraser" &#125; &#124;
<br /> &#123; type: "custom"; customType: string &#125;) => void
</pre> </pre>
## setCursor ## setCursor
This API can be used to customise the mouse cursor on the canvas and has the below signature. This API can be used to customise the mouse cursor on the canvas and has the below signature. It sets the mouse cursor to the cursor passed in param.
It sets the mouse cursor to the cursor passed in param.
```tsx ```tsx
(cursor: string) => void (cursor: string) => void
@@ -31,10 +31,29 @@ You can pass `null` / `undefined` if not applicable.
restoreElements( restoreElements(
elements: <a href="https://github.com/excalidraw/excalidraw/blob/master/src/element/types.ts#L114">ImportedDataState["elements"]</a>,<br/>&nbsp; elements: <a href="https://github.com/excalidraw/excalidraw/blob/master/src/element/types.ts#L114">ImportedDataState["elements"]</a>,<br/>&nbsp;
localElements: <a href="https://github.com/excalidraw/excalidraw/blob/master/src/element/types.ts#L114">ExcalidrawElement[]</a> | null | undefined): <a href="https://github.com/excalidraw/excalidraw/blob/master/src/element/types.ts#L114">ExcalidrawElement[]</a>,<br/>&nbsp; localElements: <a href="https://github.com/excalidraw/excalidraw/blob/master/src/element/types.ts#L114">ExcalidrawElement[]</a> | null | undefined): <a href="https://github.com/excalidraw/excalidraw/blob/master/src/element/types.ts#L114">ExcalidrawElement[]</a>,<br/>&nbsp;
refreshDimensions?: boolean<br/> opts: &#123; refreshDimensions?: boolean, repairBindings?: boolean }<br/>
) )
</pre> </pre>
| Prop | Type | Description |
| ---- | ---- | ---- |
| `elements` | <a href="https://github.com/excalidraw/excalidraw/blob/master/src/element/types.ts#L114">ImportedDataState["elements"]</a> | The `elements` to be restored |
| [`localElements`](#localelements) | <a href="https://github.com/excalidraw/excalidraw/blob/master/src/element/types.ts#L114">ExcalidrawElement[]</a> &#124; null &#124; undefined | When `localElements` are supplied, they are used to ensure that existing restored elements reuse `version` (and increment it), and regenerate `versionNonce`. |
| [`opts`](#opts) | `Object` | The extra optional parameter to configure restored elements
#### localElements
When `localElements` are supplied, they are used to ensure that existing restored elements reuse `version` (and increment it), and regenerate `versionNonce`.
Use this when you `import` elements which may already be present in the scene to ensure that you do not disregard the newly imported elements if you're using element version to detect the update
#### opts
The extra optional parameter to configure restored elements. It has the following attributes
| Prop | Type | Description|
| --- | --- | ------|
| `refreshDimensions` | `boolean` | Indicates whether we should also `recalculate` text element dimensions. Since this is a potentially costly operation, you may want to disable it if you restore elements in tight loops, such as during collaboration. |
| `repairBindings` |`boolean` | Indicates whether the `bindings` for the elements should be repaired. This is to make sure there are no containers with non existent bound text element id and no bound text elements with non existent container id. |
**_How to use_** **_How to use_**
```js ```js
@@ -43,9 +62,6 @@ import { restoreElements } from "@excalidraw/excalidraw";
This function will make sure all properties of element is correctly set and if any attribute is missing, it will be set to its default value. This function will make sure all properties of element is correctly set and if any attribute is missing, it will be set to its default value.
When `localElements` are supplied, they are used to ensure that existing restored elements reuse `version` (and increment it), and regenerate `versionNonce`.
Use this when you import elements which may already be present in the scene to ensure that you do not disregard the newly imported elements if you're using element version to detect the updates.
Parameter `refreshDimensions` indicates whether we should also `recalculate` text element dimensions. Defaults to `false`. Since this is a potentially costly operation, you may want to disable it if you restore elements in tight loops, such as during collaboration. Parameter `refreshDimensions` indicates whether we should also `recalculate` text element dimensions. Defaults to `false`. Since this is a potentially costly operation, you may want to disable it if you restore elements in tight loops, such as during collaboration.
### restore ### restore
@@ -56,7 +72,9 @@ Parameter `refreshDimensions` indicates whether we should also `recalculate` tex
restore( restore(
data: <a href="https://github.com/excalidraw/excalidraw/blob/master/src/data/types.ts#L34">ImportedDataState</a>,<br/>&nbsp; data: <a href="https://github.com/excalidraw/excalidraw/blob/master/src/data/types.ts#L34">ImportedDataState</a>,<br/>&nbsp;
localAppState: Partial&lt;<a href="https://github.com/excalidraw/excalidraw/blob/master/src/types.ts#L95">AppState</a>> | null | undefined,<br/>&nbsp; localAppState: Partial&lt;<a href="https://github.com/excalidraw/excalidraw/blob/master/src/types.ts#L95">AppState</a>> | null | undefined,<br/>&nbsp;
localElements: <a href="https://github.com/excalidraw/excalidraw/blob/master/src/element/types.ts#L114">ExcalidrawElement[]</a> | null | undefined<br/>): <a href="https://github.com/excalidraw/excalidraw/blob/master/src/data/types.ts#L4">DataState</a> localElements: <a href="https://github.com/excalidraw/excalidraw/blob/master/src/element/types.ts#L114">ExcalidrawElement[]</a> | null | undefined<br/>): <a href="https://github.com/excalidraw/excalidraw/blob/master/src/data/types.ts#L4">DataState</a><br/>
opts: &#123; refreshDimensions?: boolean, repairBindings?: boolean }<br/>
) )
</pre> </pre>
@@ -339,3 +339,47 @@ The `device` has the following `attributes`
| `isMobile` | `boolean` | Set to `true` when the device is `mobile` | | `isMobile` | `boolean` | Set to `true` when the device is `mobile` |
| `isTouchScreen` | `boolean` | Set to `true` for `touch` devices | | `isTouchScreen` | `boolean` | Set to `true` for `touch` devices |
| `canDeviceFitSidebar` | `boolean` | Implies whether there is enough space to fit the `sidebar` | | `canDeviceFitSidebar` | `boolean` | Implies whether there is enough space to fit the `sidebar` |
### i18n
To help with localization, we export the following.
| name | type |
| --- | --- |
| `defaultLang` | `string` |
| `languages` | [`Language[]`](https://github.com/excalidraw/excalidraw/blob/master/src/i18n.ts#L15) |
| `useI18n` | [`() => { langCode, t }`](https://github.com/excalidraw/excalidraw/blob/master/src/i18n.ts#L15) |
```js
import { defaultLang, languages, useI18n } from "@excalidraw/excalidraw";
```
#### defaultLang
Default language code, `en`.
#### languages
List of supported language codes. You can pass any of these to `Excalidraw`'s [`langCode` prop](/docs/@excalidraw/excalidraw/api/props/#langcode).
#### useI18n
A hook that returns the current language code and translation helper function. You can use this to translate strings in the components you render as children of `<Excalidraw>`.
```jsx live
function App() {
const { t } = useI18n();
return (
<div style={{ height: "500px" }}>
<Excalidraw>
<button
style={{ position: "absolute", zIndex: 10, height: "2rem" }}
onClick={() => window.alert(t("labels.madeWithExcalidraw"))}
>
{t("buttons.confirm")}
</button>
</Excalidraw>
</div>
);
}
```
+1 -1
View File
@@ -18,7 +18,7 @@
"@docusaurus/core": "2.2.0", "@docusaurus/core": "2.2.0",
"@docusaurus/preset-classic": "2.2.0", "@docusaurus/preset-classic": "2.2.0",
"@docusaurus/theme-live-codeblock": "2.2.0", "@docusaurus/theme-live-codeblock": "2.2.0",
"@excalidraw/excalidraw": "0.14.2", "@excalidraw/excalidraw": "0.15.2",
"@mdx-js/react": "^1.6.22", "@mdx-js/react": "^1.6.22",
"clsx": "^1.2.1", "clsx": "^1.2.1",
"docusaurus-plugin-sass": "0.2.3", "docusaurus-plugin-sass": "0.2.3",
@@ -24,6 +24,7 @@ const ExcalidrawScope = {
Sidebar: ExcalidrawComp.Sidebar, Sidebar: ExcalidrawComp.Sidebar,
exportToCanvas: ExcalidrawComp.exportToCanvas, exportToCanvas: ExcalidrawComp.exportToCanvas,
initialData, initialData,
useI18n: ExcalidrawComp.useI18n,
}; };
export default ExcalidrawScope; export default ExcalidrawScope;
+7 -7
View File
@@ -1631,10 +1631,10 @@
url-loader "^4.1.1" url-loader "^4.1.1"
webpack "^5.73.0" webpack "^5.73.0"
"@excalidraw/excalidraw@0.14.2": "@excalidraw/excalidraw@0.15.2":
version "0.14.2" version "0.15.2"
resolved "https://registry.yarnpkg.com/@excalidraw/excalidraw/-/excalidraw-0.14.2.tgz#150cb4b7a1bf0d11cd64295936c930e7e0db8375" resolved "https://registry.yarnpkg.com/@excalidraw/excalidraw/-/excalidraw-0.15.2.tgz#7dba4f6e10c52015a007efb75a9fc1afe598574c"
integrity sha512-8LdjpTBWEK5waDWB7Bt/G9YBI4j0OxkstUhvaDGz7dwQGfzF6FW5CXBoYHNEoX0qmb+Fg/NPOlZ7FrKsrSVCqg== integrity sha512-rTI02kgWSTXiUdIkBxt9u/581F3eXcqQgJdIxmz54TFtG3ughoxO5fr4t7Fr2LZIturBPqfocQHGKZ0t2KLKgw==
"@hapi/hoek@^9.0.0": "@hapi/hoek@^9.0.0":
version "9.3.0" version "9.3.0"
@@ -7159,9 +7159,9 @@ typescript@^4.7.4:
integrity sha512-C0WQT0gezHuw6AdY1M2jxUO83Rjf0HP7Sk1DtXj6j1EwkQNZrHAg2XPWlq62oqEhYvONq5pkC2Y9oPljWToLmQ== integrity sha512-C0WQT0gezHuw6AdY1M2jxUO83Rjf0HP7Sk1DtXj6j1EwkQNZrHAg2XPWlq62oqEhYvONq5pkC2Y9oPljWToLmQ==
ua-parser-js@^0.7.30: ua-parser-js@^0.7.30:
version "0.7.31" version "0.7.33"
resolved "https://registry.yarnpkg.com/ua-parser-js/-/ua-parser-js-0.7.31.tgz#649a656b191dffab4f21d5e053e27ca17cbff5c6" resolved "https://registry.yarnpkg.com/ua-parser-js/-/ua-parser-js-0.7.33.tgz#1d04acb4ccef9293df6f70f2c3d22f3030d8b532"
integrity sha512-qLK/Xe9E2uzmYI3qLeOmI0tEOt+TBBQyUIAh4aAgU05FVYzeZrKUdkAZfBNVGRaHVgV0TDkdEngJSw/SyQchkQ== integrity sha512-s8ax/CeZdK9R/56Sui0WM6y9OFREJarMRHqLB2EwkovemBxNQ+Bqu8GAsUnVcXKgphb++ghr/B2BZx4mahujPw==
unescape@^1.0.1: unescape@^1.0.1:
version "1.0.1" version "1.0.1"
+3 -3
View File
@@ -19,7 +19,7 @@
] ]
}, },
"dependencies": { "dependencies": {
"@dwelle/tunnel-rat": "0.1.1", "@radix-ui/react-tabs": "1.0.2",
"@sentry/browser": "6.2.5", "@sentry/browser": "6.2.5",
"@sentry/integrations": "6.2.5", "@sentry/integrations": "6.2.5",
"@testing-library/jest-dom": "5.16.2", "@testing-library/jest-dom": "5.16.2",
@@ -50,8 +50,8 @@
"react-scripts": "5.0.1", "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.6.1",
"tunnel-rat": "0.1.0", "tunnel-rat": "0.1.2",
"workbox-background-sync": "^6.5.4", "workbox-background-sync": "^6.5.4",
"workbox-broadcast-update": "^6.5.4", "workbox-broadcast-update": "^6.5.4",
"workbox-cacheable-response": "^6.5.4", "workbox-cacheable-response": "^6.5.4",
+28 -2
View File
@@ -79,6 +79,7 @@
</style> </style>
<!-------------------------------------------------------------------------> <!------------------------------------------------------------------------->
<% if (process.env.NODE_ENV === "production") { %>
<script> <script>
// Redirect Excalidraw+ users which have auto-redirect enabled. // Redirect Excalidraw+ users which have auto-redirect enabled.
// //
@@ -97,6 +98,7 @@
window.location.href = "https://app.excalidraw.com"; window.location.href = "https://app.excalidraw.com";
} }
</script> </script>
<% } %>
<link rel="shortcut icon" href="favicon.ico" type="image/x-icon" /> <link rel="shortcut icon" href="favicon.ico" type="image/x-icon" />
@@ -146,8 +148,18 @@
// 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_DISABLE_TRACKING !== 'true' && <% if (process.env.REACT_APP_DISABLE_TRACKING !== 'true') { %>
process.env.REACT_APP_GOOGLE_ANALYTICS_ID) { %>
<!-- Fathom - privacy-friendly analytics -->
<script
src="https://cdn.usefathom.com/script.js"
data-site="VMSBUEYA"
defer
></script>
<!-- / Fathom -->
<!-- LEGACY GOOGLE ANALYTICS -->
<% if (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%"
@@ -161,6 +173,8 @@
gtag("config", "%REACT_APP_GOOGLE_ANALYTICS_ID%"); gtag("config", "%REACT_APP_GOOGLE_ANALYTICS_ID%");
</script> </script>
<% } %> <% } %>
<!-- end LEGACY GOOGLE ANALYTICS -->
<% } %>
<!-- FIXME: remove this when we update CRA (fix SW caching) --> <!-- FIXME: remove this when we update CRA (fix SW caching) -->
<style> <style>
@@ -213,5 +227,17 @@
<h1 class="visually-hidden">Excalidraw</h1> <h1 class="visually-hidden">Excalidraw</h1>
</header> </header>
<div id="root"></div> <div id="root"></div>
<!-- 100% privacy friendly analytics -->
<script
async
defer
src="https://scripts.simpleanalyticscdn.com/latest.js"
></script>
<noscript
><img
src="https://queue.simpleanalyticscdn.com/noscript.gif"
alt=""
referrerpolicy="no-referrer-when-downgrade"
/></noscript>
</body> </body>
</html> </html>
-20
View File
@@ -1,22 +1,9 @@
const fs = require("fs");
const { execSync } = require("child_process"); const { execSync } = require("child_process");
const excalidrawDir = `${__dirname}/../src/packages/excalidraw`; const excalidrawDir = `${__dirname}/../src/packages/excalidraw`;
const excalidrawPackage = `${excalidrawDir}/package.json`; const excalidrawPackage = `${excalidrawDir}/package.json`;
const pkg = require(excalidrawPackage); const pkg = require(excalidrawPackage);
const originalReadMe = fs.readFileSync(`${excalidrawDir}/README.md`, "utf8");
const updateReadme = () => {
const excalidrawIndex = originalReadMe.indexOf("### Excalidraw");
// remove note for stable readme
const data = originalReadMe.slice(excalidrawIndex);
// update readme
fs.writeFileSync(`${excalidrawDir}/README.md`, data, "utf8");
};
const publish = () => { const publish = () => {
try { try {
execSync(`yarn --frozen-lockfile`); execSync(`yarn --frozen-lockfile`);
@@ -30,15 +17,8 @@ const publish = () => {
}; };
const release = () => { const release = () => {
updateReadme();
console.info("Note for stable readme removed");
publish(); publish();
console.info(`Published ${pkg.version}!`); console.info(`Published ${pkg.version}!`);
// revert readme after release
fs.writeFileSync(`${excalidrawDir}/README.md`, originalReadMe, "utf8");
console.info("Readme reverted");
}; };
release(); release();
+112 -91
View File
@@ -1,7 +1,13 @@
import { BOUND_TEXT_PADDING, ROUNDNESS, VERTICAL_ALIGN } from "../constants"; import {
BOUND_TEXT_PADDING,
ROUNDNESS,
VERTICAL_ALIGN,
TEXT_ALIGN,
} from "../constants";
import { getNonDeletedElements, isTextElement, newElement } from "../element"; import { getNonDeletedElements, isTextElement, newElement } from "../element";
import { mutateElement } from "../element/mutateElement"; import { mutateElement } from "../element/mutateElement";
import { import {
computeBoundTextPosition,
computeContainerDimensionForBoundText, computeContainerDimensionForBoundText,
getBoundTextElement, getBoundTextElement,
measureText, measureText,
@@ -10,6 +16,7 @@ import {
import { import {
getOriginalContainerHeightFromCache, getOriginalContainerHeightFromCache,
resetOriginalContainerCache, resetOriginalContainerCache,
updateOriginalContainerCache,
} from "../element/textWysiwyg"; } from "../element/textWysiwyg";
import { import {
hasBoundTextElement, hasBoundTextElement,
@@ -23,6 +30,7 @@ import {
ExcalidrawTextElement, ExcalidrawTextElement,
} from "../element/types"; } from "../element/types";
import { getSelectedElements } from "../scene"; import { getSelectedElements } from "../scene";
import { AppState } from "../types";
import { getFontString } from "../utils"; import { getFontString } from "../utils";
import { register } from "./register"; import { register } from "./register";
@@ -32,6 +40,7 @@ export const actionUnbindText = register({
trackEvent: { category: "element" }, trackEvent: { category: "element" },
predicate: (elements, appState) => { predicate: (elements, appState) => {
const selectedElements = getSelectedElements(elements, appState); const selectedElements = getSelectedElements(elements, appState);
return selectedElements.some((element) => hasBoundTextElement(element)); return selectedElements.some((element) => hasBoundTextElement(element));
}, },
perform: (elements, appState) => { perform: (elements, appState) => {
@@ -42,7 +51,7 @@ export const actionUnbindText = register({
selectedElements.forEach((element) => { selectedElements.forEach((element) => {
const boundTextElement = getBoundTextElement(element); const boundTextElement = getBoundTextElement(element);
if (boundTextElement) { if (boundTextElement) {
const { width, height } = measureText( const { width, height, baseline } = measureText(
boundTextElement.originalText, boundTextElement.originalText,
getFontString(boundTextElement), getFontString(boundTextElement),
boundTextElement.lineHeight, boundTextElement.lineHeight,
@@ -51,12 +60,15 @@ export const actionUnbindText = register({
element.id, element.id,
); );
resetOriginalContainerCache(element.id); resetOriginalContainerCache(element.id);
const { x, y } = computeBoundTextPosition(element, boundTextElement);
mutateElement(boundTextElement as ExcalidrawTextElement, { mutateElement(boundTextElement as ExcalidrawTextElement, {
containerId: null, containerId: null,
width, width,
height, height,
baseline,
text: boundTextElement.originalText, text: boundTextElement.originalText,
x,
y,
}); });
mutateElement(element, { mutateElement(element, {
boundElements: element.boundElements?.filter( boundElements: element.boundElements?.filter(
@@ -126,6 +138,7 @@ export const actionBindText = register({
mutateElement(textElement, { mutateElement(textElement, {
containerId: container.id, containerId: container.id,
verticalAlign: VERTICAL_ALIGN.MIDDLE, verticalAlign: VERTICAL_ALIGN.MIDDLE,
textAlign: TEXT_ALIGN.CENTER,
}); });
mutateElement(container, { mutateElement(container, {
boundElements: (container.boundElements || []).concat({ boundElements: (container.boundElements || []).concat({
@@ -133,7 +146,11 @@ export const actionBindText = register({
id: textElement.id, id: textElement.id,
}), }),
}); });
const originalContainerHeight = container.height;
redrawTextBoundingBox(textElement, container); redrawTextBoundingBox(textElement, container);
// overwritting the cache with original container height so
// it can be restored when unbind
updateOriginalContainerCache(container.id, originalContainerHeight);
return { return {
elements: pushTextAboveContainer(elements, container, textElement), elements: pushTextAboveContainer(elements, container, textElement),
@@ -179,113 +196,117 @@ const pushContainerBelowText = (
return updatedElements; return updatedElements;
}; };
export const actionCreateContainerFromText = register({ export const actionWrapTextInContainer = register({
name: "createContainerFromText", name: "wrapTextInContainer",
contextItemLabel: "labels.createContainerFromText", contextItemLabel: "labels.createContainerFromText",
trackEvent: { category: "element" }, trackEvent: { category: "element" },
predicate: (elements, appState) => { predicate: (elements, appState) => {
const selectedElements = getSelectedElements(elements, appState); const selectedElements = getSelectedElements(elements, appState);
return selectedElements.length === 1 && isTextElement(selectedElements[0]); const areTextElements = selectedElements.every((el) => isTextElement(el));
return selectedElements.length > 0 && areTextElements;
}, },
perform: (elements, appState) => { perform: (elements, appState) => {
const selectedElements = getSelectedElements( const selectedElements = getSelectedElements(
getNonDeletedElements(elements), getNonDeletedElements(elements),
appState, appState,
); );
const updatedElements = elements.slice(); let updatedElements: readonly ExcalidrawElement[] = elements.slice();
if (selectedElements.length === 1 && isTextElement(selectedElements[0])) { const containerIds: AppState["selectedElementIds"] = {};
const textElement = selectedElements[0];
const container = newElement({
type: "rectangle",
backgroundColor: appState.currentItemBackgroundColor,
boundElements: [
...(textElement.boundElements || []),
{ id: textElement.id, type: "text" },
],
angle: textElement.angle,
fillStyle: appState.currentItemFillStyle,
strokeColor: appState.currentItemStrokeColor,
roughness: appState.currentItemRoughness,
strokeWidth: appState.currentItemStrokeWidth,
strokeStyle: appState.currentItemStrokeStyle,
roundness:
appState.currentItemRoundness === "round"
? {
type: isUsingAdaptiveRadius("rectangle")
? ROUNDNESS.ADAPTIVE_RADIUS
: ROUNDNESS.PROPORTIONAL_RADIUS,
}
: null,
opacity: 100,
locked: false,
x: textElement.x - BOUND_TEXT_PADDING,
y: textElement.y - BOUND_TEXT_PADDING,
width: computeContainerDimensionForBoundText(
textElement.width,
"rectangle",
),
height: computeContainerDimensionForBoundText(
textElement.height,
"rectangle",
),
groupIds: textElement.groupIds,
});
// update bindings for (const textElement of selectedElements) {
if (textElement.boundElements?.length) { if (isTextElement(textElement)) {
const linearElementIds = textElement.boundElements const container = newElement({
.filter((ele) => ele.type === "arrow") type: "rectangle",
.map((el) => el.id); backgroundColor: appState.currentItemBackgroundColor,
const linearElements = updatedElements.filter((ele) => boundElements: [
linearElementIds.includes(ele.id), ...(textElement.boundElements || []),
) as ExcalidrawLinearElement[]; { id: textElement.id, type: "text" },
linearElements.forEach((ele) => { ],
let startBinding = ele.startBinding; angle: textElement.angle,
let endBinding = ele.endBinding; fillStyle: appState.currentItemFillStyle,
strokeColor: appState.currentItemStrokeColor,
if (startBinding?.elementId === textElement.id) { roughness: appState.currentItemRoughness,
startBinding = { strokeWidth: appState.currentItemStrokeWidth,
...startBinding, strokeStyle: appState.currentItemStrokeStyle,
elementId: container.id, roundness:
}; appState.currentItemRoundness === "round"
} ? {
type: isUsingAdaptiveRadius("rectangle")
if (endBinding?.elementId === textElement.id) { ? ROUNDNESS.ADAPTIVE_RADIUS
endBinding = { ...endBinding, elementId: container.id }; : ROUNDNESS.PROPORTIONAL_RADIUS,
} }
: null,
if (startBinding || endBinding) { opacity: 100,
mutateElement(ele, { startBinding, endBinding }); locked: false,
} x: textElement.x - BOUND_TEXT_PADDING,
y: textElement.y - BOUND_TEXT_PADDING,
width: computeContainerDimensionForBoundText(
textElement.width,
"rectangle",
),
height: computeContainerDimensionForBoundText(
textElement.height,
"rectangle",
),
groupIds: textElement.groupIds,
}); });
}
mutateElement(textElement, { // update bindings
containerId: container.id, if (textElement.boundElements?.length) {
verticalAlign: VERTICAL_ALIGN.MIDDLE, const linearElementIds = textElement.boundElements
boundElements: null, .filter((ele) => ele.type === "arrow")
}); .map((el) => el.id);
redrawTextBoundingBox(textElement, container); const linearElements = updatedElements.filter((ele) =>
linearElementIds.includes(ele.id),
) as ExcalidrawLinearElement[];
linearElements.forEach((ele) => {
let startBinding = ele.startBinding;
let endBinding = ele.endBinding;
return { if (startBinding?.elementId === textElement.id) {
elements: pushContainerBelowText( startBinding = {
[...elements, container], ...startBinding,
elementId: container.id,
};
}
if (endBinding?.elementId === textElement.id) {
endBinding = { ...endBinding, elementId: container.id };
}
if (startBinding || endBinding) {
mutateElement(ele, { startBinding, endBinding }, false);
}
});
}
mutateElement(
textElement,
{
containerId: container.id,
verticalAlign: VERTICAL_ALIGN.MIDDLE,
boundElements: null,
textAlign: TEXT_ALIGN.CENTER,
},
false,
);
redrawTextBoundingBox(textElement, container);
updatedElements = pushContainerBelowText(
[...updatedElements, container],
container, container,
textElement, textElement,
), );
appState: { containerIds[container.id] = true;
...appState, }
selectedElementIds: {
[container.id]: true,
[textElement.id]: false,
},
},
commitToHistory: true,
};
} }
return { return {
elements: updatedElements, elements: updatedElements,
appState, appState: {
...appState,
selectedElementIds: containerIds,
},
commitToHistory: true, commitToHistory: true,
}; };
}, },
+1 -1
View File
@@ -226,7 +226,7 @@ const zoomValueToFitBoundsOnViewport = (
return clampedZoomValueToFitElements as NormalizedZoomValue; return clampedZoomValueToFitElements as NormalizedZoomValue;
}; };
const zoomToFitElements = ( export const zoomToFitElements = (
elements: readonly ExcalidrawElement[], elements: readonly ExcalidrawElement[],
appState: Readonly<AppState>, appState: Readonly<AppState>,
zoomToSelection: boolean, zoomToSelection: boolean,
+1 -1
View File
@@ -18,7 +18,7 @@ export const actionCopy = register({
perform: (elements, appState, _, app) => { perform: (elements, appState, _, app) => {
const selectedElements = getSelectedElements(elements, appState, true); const selectedElements = getSelectedElements(elements, appState, true);
copyToClipboard(selectedElements, appState, app.files); copyToClipboard(selectedElements, app.files);
return { return {
commitToHistory: false, commitToHistory: false,
+60 -36
View File
@@ -1,4 +1,5 @@
import { AppState } from "../../src/types"; import { AppState } from "../../src/types";
import { trackEvent } from "../analytics";
import { ButtonIconSelect } from "../components/ButtonIconSelect"; import { ButtonIconSelect } from "../components/ButtonIconSelect";
import { ColorPicker } from "../components/ColorPicker"; import { ColorPicker } from "../components/ColorPicker";
import { IconPicker } from "../components/IconPicker"; import { IconPicker } from "../components/IconPicker";
@@ -37,6 +38,7 @@ import {
TextAlignLeftIcon, TextAlignLeftIcon,
TextAlignCenterIcon, TextAlignCenterIcon,
TextAlignRightIcon, TextAlignRightIcon,
FillZigZagIcon,
} from "../components/icons"; } from "../components/icons";
import { import {
DEFAULT_FONT_FAMILY, DEFAULT_FONT_FAMILY,
@@ -82,7 +84,7 @@ import {
isSomeElementSelected, isSomeElementSelected,
} from "../scene"; } from "../scene";
import { hasStrokeColor } from "../scene/comparisons"; import { hasStrokeColor } from "../scene/comparisons";
import { arrayToMap } from "../utils"; import { arrayToMap, getShortcutKey } from "../utils";
import { register } from "./register"; import { register } from "./register";
const FONT_SIZE_RELATIVE_INCREASE_STEP = 0.1; const FONT_SIZE_RELATIVE_INCREASE_STEP = 0.1;
@@ -294,7 +296,12 @@ export const actionChangeBackgroundColor = register({
export const actionChangeFillStyle = register({ export const actionChangeFillStyle = register({
name: "changeFillStyle", name: "changeFillStyle",
trackEvent: false, trackEvent: false,
perform: (elements, appState, value) => { perform: (elements, appState, value, app) => {
trackEvent(
"element",
"changeFillStyle",
`${value} (${app.device.isMobile ? "mobile" : "desktop"})`,
);
return { return {
elements: changeProperty(elements, appState, (el) => elements: changeProperty(elements, appState, (el) =>
newElementWith(el, { newElementWith(el, {
@@ -305,40 +312,57 @@ export const actionChangeFillStyle = register({
commitToHistory: true, commitToHistory: true,
}; };
}, },
PanelComponent: ({ elements, appState, updateData }) => ( PanelComponent: ({ elements, appState, updateData }) => {
<fieldset> const selectedElements = getSelectedElements(elements, appState);
<legend>{t("labels.fill")}</legend> const allElementsZigZag =
<ButtonIconSelect selectedElements.length > 0 &&
options={[ selectedElements.every((el) => el.fillStyle === "zigzag");
{
value: "hachure", return (
text: t("labels.hachure"), <fieldset>
icon: FillHachureIcon, <legend>{t("labels.fill")}</legend>
}, <ButtonIconSelect
{ type="button"
value: "cross-hatch", options={[
text: t("labels.crossHatch"), {
icon: FillCrossHatchIcon, value: "hachure",
}, text: `${
{ allElementsZigZag ? t("labels.zigzag") : t("labels.hachure")
value: "solid", } (${getShortcutKey("Alt-Click")})`,
text: t("labels.solid"), icon: allElementsZigZag ? FillZigZagIcon : FillHachureIcon,
icon: FillSolidIcon, active: allElementsZigZag ? true : undefined,
}, },
]} {
group="fill" value: "cross-hatch",
value={getFormValue( text: t("labels.crossHatch"),
elements, icon: FillCrossHatchIcon,
appState, },
(element) => element.fillStyle, {
appState.currentItemFillStyle, value: "solid",
)} text: t("labels.solid"),
onChange={(value) => { icon: FillSolidIcon,
updateData(value); },
}} ]}
/> value={getFormValue(
</fieldset> elements,
), appState,
(element) => element.fillStyle,
appState.currentItemFillStyle,
)}
onClick={(value, event) => {
const nextValue =
event.altKey &&
value === "hachure" &&
selectedElements.every((el) => el.fillStyle === "hachure")
? "zigzag"
: value;
updateData(nextValue);
}}
/>
</fieldset>
);
},
}); });
export const actionChangeStrokeWidth = register({ export const actionChangeStrokeWidth = register({
+1 -1
View File
@@ -115,7 +115,7 @@ export type ActionName =
| "toggleLinearEditor" | "toggleLinearEditor"
| "toggleEraserTool" | "toggleEraserTool"
| "toggleHandTool" | "toggleHandTool"
| "createContainerFromText"; | "wrapTextInContainer";
export type PanelComponentProps = { export type PanelComponentProps = {
elements: readonly ExcalidrawElement[]; elements: readonly ExcalidrawElement[];
+41 -22
View File
@@ -1,22 +1,41 @@
export const trackEvent = export const trackEvent = (
typeof process !== "undefined" && category: string,
process.env?.REACT_APP_GOOGLE_ANALYTICS_ID && action: string,
typeof window !== "undefined" && label?: string,
window.gtag value?: number,
? (category: string, action: string, label?: string, value?: number) => { ) => {
try { try {
window.gtag("event", action, { // Uncomment the next line to track locally
event_category: category, // console.log("Track Event", { category, action, label, value });
event_label: label,
value, if (typeof window === "undefined" || process.env.JEST_WORKER_ID) {
}); return;
} catch (error) { }
console.error("error logging to ga", error);
} if (process.env.REACT_APP_GOOGLE_ANALYTICS_ID && window.gtag) {
} window.gtag("event", action, {
: typeof process !== "undefined" && process.env?.JEST_WORKER_ID event_category: category,
? (category: string, action: string, label?: string, value?: number) => {} event_label: label,
: (category: string, action: string, label?: string, value?: number) => { value,
// Uncomment the next line to track locally });
// console.log("Track Event", { category, action, label, value }); }
};
if (window.sa_event) {
window.sa_event(action, {
category,
label,
value,
});
}
if (window.fathom) {
window.fathom.trackEvent(action, {
category,
label,
value,
});
}
} catch (error) {
console.error("error during analytics", error);
}
};
+15 -10
View File
@@ -1,5 +1,6 @@
import oc from "open-color"; import oc from "open-color";
import { import {
DEFAULT_ELEMENT_PROPS,
DEFAULT_FONT_FAMILY, DEFAULT_FONT_FAMILY,
DEFAULT_FONT_SIZE, DEFAULT_FONT_SIZE,
DEFAULT_TEXT_ALIGN, DEFAULT_TEXT_ALIGN,
@@ -23,18 +24,18 @@ export const getDefaultAppState = (): Omit<
theme: THEME.LIGHT, theme: THEME.LIGHT,
collaborators: new Map(), collaborators: new Map(),
currentChartType: "bar", currentChartType: "bar",
currentItemBackgroundColor: "transparent", currentItemBackgroundColor: DEFAULT_ELEMENT_PROPS.backgroundColor,
currentItemEndArrowhead: "arrow", currentItemEndArrowhead: "arrow",
currentItemFillStyle: "hachure", currentItemFillStyle: DEFAULT_ELEMENT_PROPS.fillStyle,
currentItemFontFamily: DEFAULT_FONT_FAMILY, currentItemFontFamily: DEFAULT_FONT_FAMILY,
currentItemFontSize: DEFAULT_FONT_SIZE, currentItemFontSize: DEFAULT_FONT_SIZE,
currentItemOpacity: 100, currentItemOpacity: DEFAULT_ELEMENT_PROPS.opacity,
currentItemRoughness: 1, currentItemRoughness: DEFAULT_ELEMENT_PROPS.roughness,
currentItemStartArrowhead: null, currentItemStartArrowhead: null,
currentItemStrokeColor: oc.black, currentItemStrokeColor: DEFAULT_ELEMENT_PROPS.strokeColor,
currentItemRoundness: "round", currentItemRoundness: "round",
currentItemStrokeStyle: "solid", currentItemStrokeStyle: DEFAULT_ELEMENT_PROPS.strokeStyle,
currentItemStrokeWidth: 1, currentItemStrokeWidth: DEFAULT_ELEMENT_PROPS.strokeWidth,
currentItemTextAlign: DEFAULT_TEXT_ALIGN, currentItemTextAlign: DEFAULT_TEXT_ALIGN,
cursorButton: "up", cursorButton: "up",
draggingElement: null, draggingElement: null,
@@ -44,7 +45,7 @@ export const getDefaultAppState = (): Omit<
activeTool: { activeTool: {
type: "selection", type: "selection",
customType: null, customType: null,
locked: false, locked: DEFAULT_ELEMENT_PROPS.locked,
lastActiveTool: null, lastActiveTool: null,
}, },
penMode: false, penMode: false,
@@ -57,7 +58,7 @@ export const getDefaultAppState = (): Omit<
fileHandle: null, fileHandle: null,
gridSize: null, gridSize: null,
isBindingEnabled: true, isBindingEnabled: true,
isSidebarDocked: false, defaultSidebarDockedPreference: false,
isLoading: false, isLoading: false,
isResizing: false, isResizing: false,
isRotating: false, isRotating: false,
@@ -149,7 +150,11 @@ const APP_STATE_STORAGE_CONF = (<
gridSize: { browser: true, export: true, server: true }, gridSize: { browser: true, export: true, server: true },
height: { browser: false, export: false, server: false }, height: { browser: false, export: false, server: false },
isBindingEnabled: { browser: false, export: false, server: false }, isBindingEnabled: { browser: false, export: false, server: false },
isSidebarDocked: { browser: true, export: false, server: false }, defaultSidebarDockedPreference: {
browser: true,
export: false,
server: false,
},
isLoading: { browser: false, export: false, server: false }, isLoading: { browser: false, export: false, server: false },
isResizing: { browser: false, export: false, server: false }, isResizing: { browser: false, export: false, server: false },
isRotating: { browser: false, export: false, server: false }, isRotating: { browser: false, export: false, server: false },
+1 -17
View File
@@ -1,10 +1,5 @@
import colors from "./colors"; import colors from "./colors";
import { import { DEFAULT_FONT_SIZE, ENV } from "./constants";
DEFAULT_FONT_FAMILY,
DEFAULT_FONT_SIZE,
ENV,
VERTICAL_ALIGN,
} from "./constants";
import { newElement, newLinearElement, newTextElement } from "./element"; import { newElement, newLinearElement, newTextElement } from "./element";
import { NonDeletedExcalidrawElement } from "./element/types"; import { NonDeletedExcalidrawElement } from "./element/types";
import { randomId } from "./random"; import { randomId } from "./random";
@@ -166,17 +161,7 @@ const bgColors = colors.elementBackground.slice(
// Put all the common properties here so when the whole chart is selected // Put all the common properties here so when the whole chart is selected
// the properties dialog shows the correct selected values // the properties dialog shows the correct selected values
const commonProps = { const commonProps = {
fillStyle: "hachure",
fontFamily: DEFAULT_FONT_FAMILY,
fontSize: DEFAULT_FONT_SIZE,
opacity: 100,
roughness: 1,
strokeColor: colors.elementStroke[0], strokeColor: colors.elementStroke[0],
roundness: null,
strokeStyle: "solid",
strokeWidth: 1,
verticalAlign: VERTICAL_ALIGN.MIDDLE,
locked: false,
} as const; } as const;
const getChartDimentions = (spreadsheet: Spreadsheet) => { const getChartDimentions = (spreadsheet: Spreadsheet) => {
@@ -323,7 +308,6 @@ const chartBaseElements = (
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,
roundness: null, roundness: null,
strokeStyle: "solid",
textAlign: "center", textAlign: "center",
}) })
: null; : null;
+27 -11
View File
@@ -2,12 +2,12 @@ import {
ExcalidrawElement, ExcalidrawElement,
NonDeletedExcalidrawElement, NonDeletedExcalidrawElement,
} from "./element/types"; } from "./element/types";
import { AppState, BinaryFiles } from "./types"; import { BinaryFiles } from "./types";
import { SVG_EXPORT_TAG } from "./scene/export"; import { SVG_EXPORT_TAG } from "./scene/export";
import { tryParseSpreadsheet, Spreadsheet, VALID_SPREADSHEET } from "./charts"; import { tryParseSpreadsheet, Spreadsheet, VALID_SPREADSHEET } from "./charts";
import { EXPORT_DATA_TYPES, MIME_TYPES } from "./constants"; import { EXPORT_DATA_TYPES, MIME_TYPES } from "./constants";
import { isInitializedImageElement } from "./element/typeChecks"; import { isInitializedImageElement } from "./element/typeChecks";
import { isPromiseLike } from "./utils"; import { isPromiseLike, isTestEnv } from "./utils";
type ElementsClipboard = { type ElementsClipboard = {
type: typeof EXPORT_DATA_TYPES.excalidrawClipboard; type: typeof EXPORT_DATA_TYPES.excalidrawClipboard;
@@ -55,24 +55,40 @@ const clipboardContainsElements = (
export const copyToClipboard = async ( export const copyToClipboard = async (
elements: readonly NonDeletedExcalidrawElement[], elements: readonly NonDeletedExcalidrawElement[],
appState: AppState,
files: BinaryFiles | null, files: BinaryFiles | null,
) => { ) => {
let foundFile = false;
const _files = elements.reduce((acc, element) => {
if (isInitializedImageElement(element)) {
foundFile = true;
if (files && files[element.fileId]) {
acc[element.fileId] = files[element.fileId];
}
}
return acc;
}, {} as BinaryFiles);
if (foundFile && !files) {
console.warn(
"copyToClipboard: attempting to file element(s) without providing associated `files` object.",
);
}
// select binded text elements when copying // select binded text elements when copying
const contents: ElementsClipboard = { const contents: ElementsClipboard = {
type: EXPORT_DATA_TYPES.excalidrawClipboard, type: EXPORT_DATA_TYPES.excalidrawClipboard,
elements, elements,
files: files files: files ? _files : undefined,
? elements.reduce((acc, element) => {
if (isInitializedImageElement(element) && files[element.fileId]) {
acc[element.fileId] = files[element.fileId];
}
return acc;
}, {} as BinaryFiles)
: undefined,
}; };
const json = JSON.stringify(contents); const json = JSON.stringify(contents);
if (isTestEnv()) {
return json;
}
CLIPBOARD = json; CLIPBOARD = json;
try { try {
PREFER_APP_CLIPBOARD = false; PREFER_APP_CLIPBOARD = false;
await copyTextToSystemClipboard(json); await copyTextToSystemClipboard(json);
+7 -6
View File
@@ -14,7 +14,7 @@ import {
hasText, hasText,
} from "../scene"; } from "../scene";
import { SHAPES } from "../shapes"; import { SHAPES } from "../shapes";
import { AppState, Zoom } from "../types"; import { UIAppState, Zoom } from "../types";
import { import {
capitalizeString, capitalizeString,
isTransparent, isTransparent,
@@ -28,19 +28,20 @@ import { trackEvent } from "../analytics";
import { hasBoundTextElement } 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 { Tooltip } from "./Tooltip"; import { Tooltip } from "./Tooltip";
import { import {
shouldAllowVerticalAlign, shouldAllowVerticalAlign,
suppportsHorizontalAlign, suppportsHorizontalAlign,
} from "../element/textElement"; } from "../element/textElement";
import "./Actions.scss";
export const SelectedShapeActions = ({ export const SelectedShapeActions = ({
appState, appState,
elements, elements,
renderAction, renderAction,
}: { }: {
appState: AppState; appState: UIAppState;
elements: readonly ExcalidrawElement[]; elements: readonly ExcalidrawElement[];
renderAction: ActionManager["renderAction"]; renderAction: ActionManager["renderAction"];
}) => { }) => {
@@ -215,10 +216,10 @@ export const ShapesSwitcher = ({
appState, appState,
}: { }: {
canvas: HTMLCanvasElement | null; canvas: HTMLCanvasElement | null;
activeTool: AppState["activeTool"]; activeTool: UIAppState["activeTool"];
setAppState: React.Component<any, AppState>["setState"]; setAppState: React.Component<any, UIAppState>["setState"];
onImageAction: (data: { pointerType: PointerType | null }) => void; onImageAction: (data: { pointerType: PointerType | null }) => void;
appState: AppState; appState: UIAppState;
}) => ( }) => (
<> <>
{SHAPES.map(({ value, icon, key, numericKey, fillable }, index) => { {SHAPES.map(({ value, icon, key, numericKey, fillable }, index) => {
+281 -161
View File
@@ -60,6 +60,7 @@ import {
ENV, ENV,
EVENT, EVENT,
GRID_SIZE, GRID_SIZE,
IMAGE_MIME_TYPES,
IMAGE_RENDER_TIMEOUT, IMAGE_RENDER_TIMEOUT,
isAndroid, isAndroid,
isBrave, isBrave,
@@ -127,7 +128,11 @@ import {
} from "../element/binding"; } from "../element/binding";
import { LinearElementEditor } from "../element/linearElementEditor"; import { LinearElementEditor } from "../element/linearElementEditor";
import { mutateElement, newElementWith } from "../element/mutateElement"; import { mutateElement, newElementWith } from "../element/mutateElement";
import { deepCopyElement, newFreeDrawElement } from "../element/newElement"; import {
deepCopyElement,
duplicateElements,
newFreeDrawElement,
} from "../element/newElement";
import { import {
hasBoundTextElement, hasBoundTextElement,
isArrowElement, isArrowElement,
@@ -205,6 +210,8 @@ import {
PointerDownState, PointerDownState,
SceneData, SceneData,
Device, Device,
SidebarName,
SidebarTabName,
} from "../types"; } from "../types";
import { import {
debounce, debounce,
@@ -229,6 +236,7 @@ import {
updateActiveTool, updateActiveTool,
getShortcutKey, getShortcutKey,
isTransparent, isTransparent,
easeToValuesRAF,
} from "../utils"; } from "../utils";
import { import {
ContextMenu, ContextMenu,
@@ -284,12 +292,18 @@ import {
import { shouldShowBoundingBox } from "../element/transformHandles"; import { shouldShowBoundingBox } from "../element/transformHandles";
import { Fonts } from "../scene/Fonts"; import { Fonts } from "../scene/Fonts";
import { actionPaste } from "../actions/actionClipboard"; import { actionPaste } from "../actions/actionClipboard";
import { actionToggleHandTool } from "../actions/actionCanvas"; import {
actionToggleHandTool,
zoomToFitElements,
} from "../actions/actionCanvas";
import { jotaiStore } from "../jotai"; import { jotaiStore } from "../jotai";
import { activeConfirmDialogAtom } from "./ActiveConfirmDialog"; import { activeConfirmDialogAtom } from "./ActiveConfirmDialog";
import { actionCreateContainerFromText } from "../actions/actionBoundText"; import { actionWrapTextInContainer } from "../actions/actionBoundText";
import BraveMeasureTextError from "./BraveMeasureTextError"; import BraveMeasureTextError from "./BraveMeasureTextError";
const AppContext = React.createContext<AppClassProperties>(null!);
const AppPropsContext = React.createContext<AppProps>(null!);
const deviceContextInitialValue = { const deviceContextInitialValue = {
isSmScreen: false, isSmScreen: false,
isMobile: false, isMobile: false,
@@ -331,6 +345,8 @@ const ExcalidrawActionManagerContext = React.createContext<ActionManager>(
); );
ExcalidrawActionManagerContext.displayName = "ExcalidrawActionManagerContext"; ExcalidrawActionManagerContext.displayName = "ExcalidrawActionManagerContext";
export const useApp = () => useContext(AppContext);
export const useAppProps = () => useContext(AppPropsContext);
export const useDevice = () => useContext<Device>(DeviceContext); export const useDevice = () => useContext<Device>(DeviceContext);
export const useExcalidrawContainer = () => export const useExcalidrawContainer = () =>
useContext(ExcalidrawContainerContext); useContext(ExcalidrawContainerContext);
@@ -391,7 +407,7 @@ class App extends React.Component<AppProps, AppState> {
private nearestScrollableContainer: HTMLElement | Document | undefined; private nearestScrollableContainer: HTMLElement | Document | undefined;
public library: AppClassProperties["library"]; public library: AppClassProperties["library"];
public libraryItemsFromStorage: LibraryItems | undefined; public libraryItemsFromStorage: LibraryItems | undefined;
private id: string; public id: string;
private history: History; private history: History;
private excalidrawContainerValue: { private excalidrawContainerValue: {
container: HTMLDivElement | null; container: HTMLDivElement | null;
@@ -429,7 +445,7 @@ class App extends React.Component<AppProps, AppState> {
width: window.innerWidth, width: window.innerWidth,
height: window.innerHeight, height: window.innerHeight,
showHyperlinkPopup: false, showHyperlinkPopup: false,
isSidebarDocked: false, defaultSidebarDockedPreference: false,
}; };
this.id = nanoid(); this.id = nanoid();
@@ -460,7 +476,7 @@ class App extends React.Component<AppProps, AppState> {
setActiveTool: this.setActiveTool, setActiveTool: this.setActiveTool,
setCursor: this.setCursor, setCursor: this.setCursor,
resetCursor: this.resetCursor, resetCursor: this.resetCursor,
toggleMenu: this.toggleMenu, toggleSidebar: this.toggleSidebar,
} as const; } as const;
if (typeof excalidrawRef === "function") { if (typeof excalidrawRef === "function") {
excalidrawRef(api); excalidrawRef(api);
@@ -568,101 +584,91 @@ class App extends React.Component<AppProps, AppState> {
this.props.handleKeyboardGlobally ? undefined : this.onKeyDown this.props.handleKeyboardGlobally ? undefined : this.onKeyDown
} }
> >
<ExcalidrawContainerContext.Provider <AppContext.Provider value={this}>
value={this.excalidrawContainerValue} <AppPropsContext.Provider value={this.props}>
> <ExcalidrawContainerContext.Provider
<DeviceContext.Provider value={this.device}> value={this.excalidrawContainerValue}
<ExcalidrawSetAppStateContext.Provider value={this.setAppState}> >
<ExcalidrawAppStateContext.Provider value={this.state}> <DeviceContext.Provider value={this.device}>
<ExcalidrawElementsContext.Provider <ExcalidrawSetAppStateContext.Provider value={this.setAppState}>
value={this.scene.getNonDeletedElements()} <ExcalidrawAppStateContext.Provider value={this.state}>
> <ExcalidrawElementsContext.Provider
<ExcalidrawActionManagerContext.Provider value={this.scene.getNonDeletedElements()}
value={this.actionManager}
>
<LayerUI
canvas={this.canvas}
appState={this.state}
files={this.files}
setAppState={this.setAppState}
actionManager={this.actionManager}
elements={this.scene.getNonDeletedElements()}
onLockToggle={this.toggleLock}
onPenModeToggle={this.togglePenMode}
onHandToolToggle={this.onHandToolToggle}
onInsertElements={(elements) =>
this.addElementsFromPasteOrLibrary({
elements,
position: "center",
files: null,
})
}
langCode={getLanguage().code}
renderTopRightUI={renderTopRightUI}
renderCustomStats={renderCustomStats}
renderCustomSidebar={this.props.renderSidebar}
showExitZenModeBtn={
typeof this.props?.zenModeEnabled === "undefined" &&
this.state.zenModeEnabled
}
libraryReturnUrl={this.props.libraryReturnUrl}
UIOptions={this.props.UIOptions}
focusContainer={this.focusContainer}
library={this.library}
id={this.id}
onImageAction={this.onImageAction}
renderWelcomeScreen={
!this.state.isLoading &&
this.state.showWelcomeScreen &&
this.state.activeTool.type === "selection" &&
!this.scene.getElementsIncludingDeleted().length
}
> >
{this.props.children} <ExcalidrawActionManagerContext.Provider
</LayerUI> value={this.actionManager}
<div className="excalidraw-textEditorContainer" /> >
<div className="excalidraw-contextMenuContainer" /> <LayerUI
{selectedElement.length === 1 && canvas={this.canvas}
!this.state.contextMenu && appState={this.state}
this.state.showHyperlinkPopup && ( files={this.files}
<Hyperlink
key={selectedElement[0].id}
element={selectedElement[0]}
setAppState={this.setAppState} setAppState={this.setAppState}
onLinkOpen={this.props.onLinkOpen} actionManager={this.actionManager}
/> elements={this.scene.getNonDeletedElements()}
)} onLockToggle={this.toggleLock}
{this.state.toast !== null && ( onPenModeToggle={this.togglePenMode}
<Toast onHandToolToggle={this.onHandToolToggle}
message={this.state.toast.message} langCode={getLanguage().code}
onClose={() => this.setToast(null)} renderTopRightUI={renderTopRightUI}
duration={this.state.toast.duration} renderCustomStats={renderCustomStats}
closable={this.state.toast.closable} showExitZenModeBtn={
/> typeof this.props?.zenModeEnabled === "undefined" &&
)} this.state.zenModeEnabled
{this.state.contextMenu && ( }
<ContextMenu UIOptions={this.props.UIOptions}
items={this.state.contextMenu.items} onImageAction={this.onImageAction}
top={this.state.contextMenu.top} renderWelcomeScreen={
left={this.state.contextMenu.left} !this.state.isLoading &&
actionManager={this.actionManager} this.state.showWelcomeScreen &&
/> this.state.activeTool.type === "selection" &&
)} !this.scene.getElementsIncludingDeleted().length
<main>{this.renderCanvas()}</main> }
</ExcalidrawActionManagerContext.Provider> >
</ExcalidrawElementsContext.Provider>{" "} {this.props.children}
</ExcalidrawAppStateContext.Provider> </LayerUI>
</ExcalidrawSetAppStateContext.Provider> <div className="excalidraw-textEditorContainer" />
</DeviceContext.Provider> <div className="excalidraw-contextMenuContainer" />
</ExcalidrawContainerContext.Provider> {selectedElement.length === 1 &&
!this.state.contextMenu &&
this.state.showHyperlinkPopup && (
<Hyperlink
key={selectedElement[0].id}
element={selectedElement[0]}
setAppState={this.setAppState}
onLinkOpen={this.props.onLinkOpen}
/>
)}
{this.state.toast !== null && (
<Toast
message={this.state.toast.message}
onClose={() => this.setToast(null)}
duration={this.state.toast.duration}
closable={this.state.toast.closable}
/>
)}
{this.state.contextMenu && (
<ContextMenu
items={this.state.contextMenu.items}
top={this.state.contextMenu.top}
left={this.state.contextMenu.left}
actionManager={this.actionManager}
/>
)}
<main>{this.renderCanvas()}</main>
</ExcalidrawActionManagerContext.Provider>
</ExcalidrawElementsContext.Provider>{" "}
</ExcalidrawAppStateContext.Provider>
</ExcalidrawSetAppStateContext.Provider>
</DeviceContext.Provider>
</ExcalidrawContainerContext.Provider>
</AppPropsContext.Provider>
</AppContext.Provider>
</div> </div>
); );
} }
public focusContainer: AppClassProperties["focusContainer"] = () => { public focusContainer: AppClassProperties["focusContainer"] = () => {
if (this.props.autoFocus) { this.excalidrawContainerRef.current?.focus();
this.excalidrawContainerRef.current?.focus();
}
}; };
public getSceneElementsIncludingDeleted = () => { public getSceneElementsIncludingDeleted = () => {
@@ -673,6 +679,14 @@ class App extends React.Component<AppProps, AppState> {
return this.scene.getNonDeletedElements(); return this.scene.getNonDeletedElements();
}; };
public onInsertElements = (elements: readonly ExcalidrawElement[]) => {
this.addElementsFromPasteOrLibrary({
elements,
position: "center",
files: null,
});
};
private syncActionResult = withBatchedUpdates( private syncActionResult = withBatchedUpdates(
(actionResult: ActionResult) => { (actionResult: ActionResult) => {
if (this.unmounted || actionResult === false) { if (this.unmounted || actionResult === false) {
@@ -942,7 +956,7 @@ class App extends React.Component<AppProps, AppState> {
this.scene.addCallback(this.onSceneUpdated); this.scene.addCallback(this.onSceneUpdated);
this.addEventListeners(); this.addEventListeners();
if (this.excalidrawContainerRef.current) { if (this.props.autoFocus && this.excalidrawContainerRef.current) {
this.focusContainer(); this.focusContainer();
} }
@@ -1581,6 +1595,7 @@ class App extends React.Component<AppProps, AppState> {
elements: data.elements, elements: data.elements,
files: data.files || null, files: data.files || null,
position: "cursor", position: "cursor",
retainSeed: isPlainPaste,
}); });
} else if (data.text) { } else if (data.text) {
this.addTextFromPaste(data.text, isPlainPaste); this.addTextFromPaste(data.text, isPlainPaste);
@@ -1594,6 +1609,7 @@ class App extends React.Component<AppProps, AppState> {
elements: readonly ExcalidrawElement[]; elements: readonly ExcalidrawElement[];
files: BinaryFiles | null; files: BinaryFiles | null;
position: { clientX: number; clientY: number } | "cursor" | "center"; position: { clientX: number; clientY: number } | "cursor" | "center";
retainSeed?: boolean;
}) => { }) => {
const elements = restoreElements(opts.elements, null); const elements = restoreElements(opts.elements, null);
const [minX, minY, maxX, maxY] = getCommonBounds(elements); const [minX, minY, maxX, maxY] = getCommonBounds(elements);
@@ -1621,35 +1637,25 @@ class App extends React.Component<AppProps, AppState> {
const dx = x - elementsCenterX; const dx = x - elementsCenterX;
const dy = y - elementsCenterY; const dy = y - elementsCenterY;
const groupIdMap = new Map();
const [gridX, gridY] = getGridPoint(dx, dy, this.state.gridSize); const [gridX, gridY] = getGridPoint(dx, dy, this.state.gridSize);
const oldIdToDuplicatedId = new Map(); const newElements = duplicateElements(
const newElements = elements.map((element) => { elements.map((element) => {
const newElement = duplicateElement( return newElementWith(element, {
this.state.editingGroupId,
groupIdMap,
element,
{
x: element.x + gridX - minX, x: element.x + gridX - minX,
y: element.y + gridY - minY, y: element.y + gridY - minY,
}, });
); }),
oldIdToDuplicatedId.set(element.id, newElement.id); {
return newElement; randomizeSeed: !opts.retainSeed,
}); },
);
bindTextToShapeAfterDuplication(newElements, elements, oldIdToDuplicatedId);
const nextElements = [ const nextElements = [
...this.scene.getElementsIncludingDeleted(), ...this.scene.getElementsIncludingDeleted(),
...newElements, ...newElements,
]; ];
fixBindingsAfterDuplication(nextElements, elements, oldIdToDuplicatedId);
if (opts.files) {
this.files = { ...this.files, ...opts.files };
}
this.scene.replaceAllElements(nextElements); this.scene.replaceAllElements(nextElements);
@@ -1660,6 +1666,10 @@ class App extends React.Component<AppProps, AppState> {
} }
}); });
if (opts.files) {
this.files = { ...this.files, ...opts.files };
}
this.history.resumeRecording(); this.history.resumeRecording();
this.setState( this.setState(
@@ -1674,7 +1684,7 @@ class App extends React.Component<AppProps, AppState> {
openSidebar: openSidebar:
this.state.openSidebar && this.state.openSidebar &&
this.device.canDeviceFitSidebar && this.device.canDeviceFitSidebar &&
this.state.isSidebarDocked this.state.defaultSidebarDockedPreference
? this.state.openSidebar ? this.state.openSidebar
: null, : null,
selectedElementIds: newElements.reduce( selectedElementIds: newElements.reduce(
@@ -1843,18 +1853,89 @@ class App extends React.Component<AppProps, AppState> {
this.actionManager.executeAction(actionToggleHandTool); this.actionManager.executeAction(actionToggleHandTool);
}; };
/**
* Zooms on canvas viewport center
*/
zoomCanvas = (
/** decimal fraction between 0.1 (10% zoom) and 30 (3000% zoom) */
value: number,
) => {
this.setState({
...getStateForZoom(
{
viewportX: this.state.width / 2 + this.state.offsetLeft,
viewportY: this.state.height / 2 + this.state.offsetTop,
nextZoom: getNormalizedZoom(value),
},
this.state,
),
});
};
private cancelInProgresAnimation: (() => void) | null = null;
scrollToContent = ( scrollToContent = (
target: target:
| ExcalidrawElement | ExcalidrawElement
| readonly ExcalidrawElement[] = this.scene.getNonDeletedElements(), | readonly ExcalidrawElement[] = this.scene.getNonDeletedElements(),
opts?: { fitToContent?: boolean; animate?: boolean; duration?: number },
) => { ) => {
this.setState({ this.cancelInProgresAnimation?.();
...calculateScrollCenter(
Array.isArray(target) ? target : [target], // convert provided target into ExcalidrawElement[] if necessary
this.state, const targets = Array.isArray(target) ? target : [target];
this.canvas,
), let zoom = this.state.zoom;
}); let scrollX = this.state.scrollX;
let scrollY = this.state.scrollY;
if (opts?.fitToContent) {
// compute an appropriate viewport location (scroll X, Y) and zoom level
// that fit the target elements on the scene
const { appState } = zoomToFitElements(targets, this.state, false);
zoom = appState.zoom;
scrollX = appState.scrollX;
scrollY = appState.scrollY;
} else {
// compute only the viewport location, without any zoom adjustment
const scroll = calculateScrollCenter(targets, this.state, this.canvas);
scrollX = scroll.scrollX;
scrollY = scroll.scrollY;
}
// when animating, we use RequestAnimationFrame to prevent the animation
// from slowing down other processes
if (opts?.animate) {
const origScrollX = this.state.scrollX;
const origScrollY = this.state.scrollY;
// zoom animation could become problematic on scenes with large number
// of elements, setting it to its final value to improve user experience.
//
// using zoomCanvas() to zoom on current viewport center
this.zoomCanvas(zoom.value);
const cancel = easeToValuesRAF(
[origScrollX, origScrollY],
[scrollX, scrollY],
(scrollX, scrollY) => this.setState({ scrollX, scrollY }),
{ duration: opts?.duration ?? 500 },
);
this.cancelInProgresAnimation = () => {
cancel();
this.cancelInProgresAnimation = null;
};
} else {
this.setState({ scrollX, scrollY, zoom });
}
};
/** use when changing scrollX/scrollY/zoom based on user interaction */
private translateCanvas: React.Component<any, AppState>["setState"] = (
state,
) => {
this.cancelInProgresAnimation?.();
this.setState(state);
}; };
setToast = ( setToast = (
@@ -1941,30 +2022,24 @@ class App extends React.Component<AppProps, AppState> {
/** /**
* @returns whether the menu was toggled on or off * @returns whether the menu was toggled on or off
*/ */
public toggleMenu = ( public toggleSidebar = ({
type: "library" | "customSidebar", name,
force?: boolean, tab,
): boolean => { force,
if (type === "customSidebar" && !this.props.renderSidebar) { }: {
console.warn( name: SidebarName;
`attempting to toggle "customSidebar", but no "props.renderSidebar" is defined`, tab?: SidebarTabName;
); force?: boolean;
return false; }): boolean => {
let nextName;
if (force === undefined) {
nextName = this.state.openSidebar?.name === name ? null : name;
} else {
nextName = force ? name : null;
} }
this.setState({ openSidebar: nextName ? { name: nextName, tab } : null });
if (type === "library" || type === "customSidebar") { return !!nextName;
let nextValue;
if (force === undefined) {
nextValue = this.state.openSidebar === type ? null : type;
} else {
nextValue = force ? type : null;
}
this.setState({ openSidebar: nextValue });
return !!nextValue;
}
return false;
}; };
private updateCurrentCursorPosition = withBatchedUpdates( private updateCurrentCursorPosition = withBatchedUpdates(
@@ -2055,9 +2130,13 @@ class App extends React.Component<AppProps, AppState> {
offset = -offset; offset = -offset;
} }
if (event.shiftKey) { if (event.shiftKey) {
this.setState((state) => ({ scrollX: state.scrollX + offset })); this.translateCanvas((state) => ({
scrollX: state.scrollX + offset,
}));
} else { } else {
this.setState((state) => ({ scrollY: state.scrollY + offset })); this.translateCanvas((state) => ({
scrollY: state.scrollY + offset,
}));
} }
} }
@@ -2658,7 +2737,6 @@ class App extends React.Component<AppProps, AppState> {
strokeStyle: this.state.currentItemStrokeStyle, strokeStyle: this.state.currentItemStrokeStyle,
roughness: this.state.currentItemRoughness, roughness: this.state.currentItemRoughness,
opacity: this.state.currentItemOpacity, opacity: this.state.currentItemOpacity,
roundness: null,
text: "", text: "",
fontSize, fontSize,
fontFamily, fontFamily,
@@ -2670,8 +2748,8 @@ class App extends React.Component<AppProps, AppState> {
: DEFAULT_VERTICAL_ALIGN, : DEFAULT_VERTICAL_ALIGN,
containerId: shouldBindToContainer ? container?.id : undefined, containerId: shouldBindToContainer ? container?.id : undefined,
groupIds: container?.groupIds ?? [], groupIds: container?.groupIds ?? [],
locked: false,
lineHeight, lineHeight,
angle: container?.angle ?? 0,
}); });
if (!existingTextElement && shouldBindToContainer && container) { if (!existingTextElement && shouldBindToContainer && container) {
@@ -2938,12 +3016,12 @@ class App extends React.Component<AppProps, AppState> {
state, state,
); );
return { this.translateCanvas({
zoom: zoomState.zoom, zoom: zoomState.zoom,
scrollX: zoomState.scrollX + deltaX / nextZoom, scrollX: zoomState.scrollX + deltaX / nextZoom,
scrollY: zoomState.scrollY + deltaY / nextZoom, scrollY: zoomState.scrollY + deltaY / nextZoom,
shouldCacheIgnoreZoom: true, shouldCacheIgnoreZoom: true,
}; });
}); });
this.resetShouldCacheIgnoreZoomDebounced(); this.resetShouldCacheIgnoreZoomDebounced();
} else { } else {
@@ -3421,6 +3499,43 @@ class App extends React.Component<AppProps, AppState> {
this.setState({ contextMenu: null }); this.setState({ contextMenu: null });
} }
this.updateGestureOnPointerDown(event);
// if dragging element is freedraw and another pointerdown event occurs
// a second finger is on the screen
// discard the freedraw element if it is very short because it is likely
// just a spike, otherwise finalize the freedraw element when the second
// finger is lifted
if (
event.pointerType === "touch" &&
this.state.draggingElement &&
this.state.draggingElement.type === "freedraw"
) {
const element = this.state.draggingElement as ExcalidrawFreeDrawElement;
this.updateScene({
...(element.points.length < 10
? {
elements: this.scene
.getElementsIncludingDeleted()
.filter((el) => el.id !== element.id),
}
: {}),
appState: {
draggingElement: null,
editingElement: null,
startBoundElement: null,
suggestedBindings: [],
selectedElementIds: Object.keys(this.state.selectedElementIds)
.filter((key) => key !== element.id)
.reduce((obj: { [id: string]: boolean }, key) => {
obj[key] = this.state.selectedElementIds[key];
return obj;
}, {}),
},
});
return;
}
// remove any active selection when we start to interact with canvas // remove any active selection when we start to interact with canvas
// (mainly, we care about removing selection outside the component which // (mainly, we care about removing selection outside the component which
// would prevent our copy handling otherwise) // would prevent our copy handling otherwise)
@@ -3460,8 +3575,6 @@ class App extends React.Component<AppProps, AppState> {
}); });
this.savePointer(event.clientX, event.clientY, "down"); this.savePointer(event.clientX, event.clientY, "down");
this.updateGestureOnPointerDown(event);
if (this.handleCanvasPanUsingWheelOrSpaceDrag(event)) { if (this.handleCanvasPanUsingWheelOrSpaceDrag(event)) {
return; return;
} }
@@ -3719,7 +3832,7 @@ class App extends React.Component<AppProps, AppState> {
window.addEventListener(EVENT.POINTER_UP, enableNextPaste); window.addEventListener(EVENT.POINTER_UP, enableNextPaste);
} }
this.setState({ this.translateCanvas({
scrollX: this.state.scrollX - deltaX / this.state.zoom.value, scrollX: this.state.scrollX - deltaX / this.state.zoom.value,
scrollY: this.state.scrollY - deltaY / this.state.zoom.value, scrollY: this.state.scrollY - deltaY / this.state.zoom.value,
}); });
@@ -4612,7 +4725,12 @@ class App extends React.Component<AppProps, AppState> {
pointerDownState.drag.hasOccurred = true; pointerDownState.drag.hasOccurred = true;
// prevent dragging even if we're no longer holding cmd/ctrl otherwise // prevent dragging even if we're no longer holding cmd/ctrl otherwise
// it would have weird results (stuff jumping all over the screen) // it would have weird results (stuff jumping all over the screen)
if (selectedElements.length > 0 && !pointerDownState.withCmdOrCtrl) { // Checking for editingElement to avoid jump while editing on mobile #6503
if (
selectedElements.length > 0 &&
!pointerDownState.withCmdOrCtrl &&
!this.state.editingElement
) {
const [dragX, dragY] = getGridPoint( const [dragX, dragY] = getGridPoint(
pointerCoords.x - pointerDownState.drag.offset.x, pointerCoords.x - pointerDownState.drag.offset.x,
pointerCoords.y - pointerDownState.drag.offset.y, pointerCoords.y - pointerDownState.drag.offset.y,
@@ -4865,7 +4983,7 @@ class App extends React.Component<AppProps, AppState> {
if (pointerDownState.scrollbars.isOverHorizontal) { if (pointerDownState.scrollbars.isOverHorizontal) {
const x = event.clientX; const x = event.clientX;
const dx = x - pointerDownState.lastCoords.x; const dx = x - pointerDownState.lastCoords.x;
this.setState({ this.translateCanvas({
scrollX: this.state.scrollX - dx / this.state.zoom.value, scrollX: this.state.scrollX - dx / this.state.zoom.value,
}); });
pointerDownState.lastCoords.x = x; pointerDownState.lastCoords.x = x;
@@ -4875,7 +4993,7 @@ class App extends React.Component<AppProps, AppState> {
if (pointerDownState.scrollbars.isOverVertical) { if (pointerDownState.scrollbars.isOverVertical) {
const y = event.clientY; const y = event.clientY;
const dy = y - pointerDownState.lastCoords.y; const dy = y - pointerDownState.lastCoords.y;
this.setState({ this.translateCanvas({
scrollY: this.state.scrollY - dy / this.state.zoom.value, scrollY: this.state.scrollY - dy / this.state.zoom.value,
}); });
pointerDownState.lastCoords.y = y; pointerDownState.lastCoords.y = y;
@@ -5635,7 +5753,9 @@ class App extends React.Component<AppProps, AppState> {
const imageFile = await fileOpen({ const imageFile = await fileOpen({
description: "Image", description: "Image",
extensions: ["jpg", "png", "svg", "gif"], extensions: Object.keys(
IMAGE_MIME_TYPES,
) as (keyof typeof IMAGE_MIME_TYPES)[],
}); });
const imageElement = this.createImageElement({ const imageElement = this.createImageElement({
@@ -6257,7 +6377,7 @@ class App extends React.Component<AppProps, AppState> {
actionGroup, actionGroup,
actionUnbindText, actionUnbindText,
actionBindText, actionBindText,
actionCreateContainerFromText, actionWrapTextInContainer,
actionUngroup, actionUngroup,
CONTEXT_MENU_SEPARATOR, CONTEXT_MENU_SEPARATOR,
actionAddToLibrary, actionAddToLibrary,
@@ -6304,7 +6424,7 @@ class App extends React.Component<AppProps, AppState> {
// reduced amplification for small deltas (small movements on a trackpad) // reduced amplification for small deltas (small movements on a trackpad)
Math.min(1, absDelta / 20); Math.min(1, absDelta / 20);
this.setState((state) => ({ this.translateCanvas((state) => ({
...getStateForZoom( ...getStateForZoom(
{ {
viewportX: cursorX, viewportX: cursorX,
@@ -6321,14 +6441,14 @@ class App extends React.Component<AppProps, AppState> {
// scroll horizontally when shift pressed // scroll horizontally when shift pressed
if (event.shiftKey) { if (event.shiftKey) {
this.setState(({ zoom, scrollX }) => ({ this.translateCanvas(({ zoom, scrollX }) => ({
// on Mac, shift+wheel tends to result in deltaX // on Mac, shift+wheel tends to result in deltaX
scrollX: scrollX - (deltaY || deltaX) / zoom.value, scrollX: scrollX - (deltaY || deltaX) / zoom.value,
})); }));
return; return;
} }
this.setState(({ zoom, scrollX, scrollY }) => ({ this.translateCanvas(({ zoom, scrollX, scrollY }) => ({
scrollX: scrollX - deltaX / zoom.value, scrollX: scrollX - deltaX / zoom.value,
scrollY: scrollY - deltaY / zoom.value, scrollY: scrollY - deltaY / zoom.value,
})); }));
+29 -28
View File
@@ -1,39 +1,40 @@
import { t } from "../i18n"; import Trans from "./Trans";
const BraveMeasureTextError = () => { const BraveMeasureTextError = () => {
return ( return (
<div data-testid="brave-measure-text-error"> <div data-testid="brave-measure-text-error">
<p> <p>
{t("errors.brave_measure_text_error.start")} &nbsp; <Trans
<span style={{ fontWeight: 600 }}> i18nKey="errors.brave_measure_text_error.line1"
{t("errors.brave_measure_text_error.aggressive_block_fingerprint")} bold={(el) => <span style={{ fontWeight: 600 }}>{el}</span>}
</span>{" "} />
{t("errors.brave_measure_text_error.setting_enabled")}.
<br />
<br />
{t("errors.brave_measure_text_error.break")}{" "}
<span style={{ fontWeight: 600 }}>
{t("errors.brave_measure_text_error.text_elements")}
</span>{" "}
{t("errors.brave_measure_text_error.in_your_drawings")}.
</p> </p>
<p> <p>
{t("errors.brave_measure_text_error.strongly_recommend")}{" "} <Trans
<a href="http://docs.excalidraw.com/docs/@excalidraw/excalidraw/faq#turning-off-aggresive-block-fingerprinting-in-brave-browser"> i18nKey="errors.brave_measure_text_error.line2"
{" "} bold={(el) => <span style={{ fontWeight: 600 }}>{el}</span>}
{t("errors.brave_measure_text_error.steps")} />
</a>{" "}
{t("errors.brave_measure_text_error.how")}.
</p> </p>
<p> <p>
{t("errors.brave_measure_text_error.disable_setting")}{" "} <Trans
<a href="https://github.com/excalidraw/excalidraw/issues/new"> i18nKey="errors.brave_measure_text_error.line3"
{t("errors.brave_measure_text_error.issue")} link={(el) => (
</a>{" "} <a href="http://docs.excalidraw.com/docs/@excalidraw/excalidraw/faq#turning-off-aggresive-block-fingerprinting-in-brave-browser">
{t("errors.brave_measure_text_error.write")}{" "} {el}
<a href="https://discord.gg/UexuTaE"> </a>
{t("errors.brave_measure_text_error.discord")} )}
</a> />
. </p>
<p>
<Trans
i18nKey="errors.brave_measure_text_error.line4"
issueLink={(el) => (
<a href="https://github.com/excalidraw/excalidraw/issues/new">
{el}
</a>
)}
discordLink={(el) => <a href="https://discord.gg/UexuTaE">{el}.</a>}
/>
</p> </p>
</div> </div>
); );
+8 -4
View File
@@ -1,8 +1,12 @@
import clsx from "clsx";
import { composeEventHandlers } from "../utils";
import "./Button.scss"; import "./Button.scss";
interface ButtonProps extends React.HTMLAttributes<HTMLButtonElement> { interface ButtonProps extends React.HTMLAttributes<HTMLButtonElement> {
type?: "button" | "submit" | "reset"; type?: "button" | "submit" | "reset";
onSelect: () => any; onSelect: () => any;
/** whether button is in active state */
selected?: boolean;
children: React.ReactNode; children: React.ReactNode;
className?: string; className?: string;
} }
@@ -15,18 +19,18 @@ interface ButtonProps extends React.HTMLAttributes<HTMLButtonElement> {
export const Button = ({ export const Button = ({
type = "button", type = "button",
onSelect, onSelect,
selected,
children, children,
className = "", className = "",
...rest ...rest
}: ButtonProps) => { }: ButtonProps) => {
return ( return (
<button <button
onClick={(event) => { onClick={composeEventHandlers(rest.onClick, (event) => {
onSelect(); onSelect();
rest.onClick?.(event); })}
}}
type={type} type={type}
className={`excalidraw-button ${className}`} className={clsx("excalidraw-button", className, { selected })}
{...rest} {...rest}
> >
{children} {children}
+52 -26
View File
@@ -1,33 +1,59 @@
import clsx from "clsx"; import clsx from "clsx";
// TODO: It might be "clever" to add option.icon to the existing component <ButtonSelect /> // TODO: It might be "clever" to add option.icon to the existing component <ButtonSelect />
export const ButtonIconSelect = <T extends Object>({ export const ButtonIconSelect = <T extends Object>(
options, props: {
value, options: {
onChange, value: T;
group, text: string;
}: { icon: JSX.Element;
options: { value: T; text: string; icon: JSX.Element; testId?: string }[]; testId?: string;
value: T | null; /** if not supplied, defaults to value identity check */
onChange: (value: T) => void; active?: boolean;
group: string; }[];
}) => ( value: T | null;
type?: "radio" | "button";
} & (
| { type?: "radio"; group: string; onChange: (value: T) => void }
| {
type: "button";
onClick: (
value: T,
event: React.MouseEvent<HTMLButtonElement, MouseEvent>,
) => void;
}
),
) => (
<div className="buttonList buttonListIcon"> <div className="buttonList buttonListIcon">
{options.map((option) => ( {props.options.map((option) =>
<label props.type === "button" ? (
key={option.text} <button
className={clsx({ active: value === option.value })} key={option.text}
title={option.text} onClick={(event) => props.onClick(option.value, event)}
> className={clsx({
<input active: option.active ?? props.value === option.value,
type="radio" })}
name={group}
onChange={() => onChange(option.value)}
checked={value === option.value}
data-testid={option.testId} data-testid={option.testId}
/> title={option.text}
{option.icon} >
</label> {option.icon}
))} </button>
) : (
<label
key={option.text}
className={clsx({ active: props.value === option.value })}
title={option.text}
>
<input
type="radio"
name={props.group}
onChange={() => props.onChange(option.value)}
checked={props.value === option.value}
data-testid={option.testId}
/>
{option.icon}
</label>
),
)}
</div> </div>
); );
+1
View File
@@ -183,6 +183,7 @@
width: 100%; width: 100%;
margin: 0; margin: 0;
font-size: 0.875rem; font-size: 0.875rem;
font-family: inherit;
background-color: transparent; background-color: transparent;
color: var(--text-primary-color); color: var(--text-primary-color);
border: 0; border: 0;
+5 -2
View File
@@ -4,8 +4,8 @@ import { Dialog, DialogProps } from "./Dialog";
import "./ConfirmDialog.scss"; import "./ConfirmDialog.scss";
import DialogActionButton from "./DialogActionButton"; import DialogActionButton from "./DialogActionButton";
import { useSetAtom } from "jotai"; import { useSetAtom } from "jotai";
import { isLibraryMenuOpenAtom } from "./LibraryMenuHeaderContent"; import { isLibraryMenuOpenAtom } from "./LibraryMenu";
import { useExcalidrawSetAppState } from "./App"; import { useExcalidrawContainer, useExcalidrawSetAppState } from "./App";
import { jotaiScope } from "../jotai"; import { jotaiScope } from "../jotai";
interface Props extends Omit<DialogProps, "onCloseRequest"> { interface Props extends Omit<DialogProps, "onCloseRequest"> {
@@ -26,6 +26,7 @@ const ConfirmDialog = (props: Props) => {
} = props; } = props;
const setAppState = useExcalidrawSetAppState(); const setAppState = useExcalidrawSetAppState();
const setIsLibraryMenuOpen = useSetAtom(isLibraryMenuOpenAtom, jotaiScope); const setIsLibraryMenuOpen = useSetAtom(isLibraryMenuOpenAtom, jotaiScope);
const { container } = useExcalidrawContainer();
return ( return (
<Dialog <Dialog
@@ -42,6 +43,7 @@ const ConfirmDialog = (props: Props) => {
setAppState({ openMenu: null }); setAppState({ openMenu: null });
setIsLibraryMenuOpen(false); setIsLibraryMenuOpen(false);
onCancel(); onCancel();
container?.focus();
}} }}
/> />
<DialogActionButton <DialogActionButton
@@ -50,6 +52,7 @@ const ConfirmDialog = (props: Props) => {
setAppState({ openMenu: null }); setAppState({ openMenu: null });
setIsLibraryMenuOpen(false); setIsLibraryMenuOpen(false);
onConfirm(); onConfirm();
container?.focus();
}} }}
actionType="danger" actionType="danger"
/> />
+1
View File
@@ -30,6 +30,7 @@
background-color: transparent; background-color: transparent;
border: none; border: none;
white-space: nowrap; white-space: nowrap;
font-family: inherit;
display: grid; display: grid;
grid-template-columns: 1fr 0.2fr; grid-template-columns: 1fr 0.2fr;
+144
View File
@@ -0,0 +1,144 @@
import React from "react";
import { DEFAULT_SIDEBAR } from "../constants";
import { DefaultSidebar } from "../packages/excalidraw/index";
import {
fireEvent,
waitFor,
withExcalidrawDimensions,
} from "../tests/test-utils";
import {
assertExcalidrawWithSidebar,
assertSidebarDockButton,
} from "./Sidebar/Sidebar.test";
const { h } = window;
describe("DefaultSidebar", () => {
it("when `docked={undefined}` & `onDock={undefined}`, should allow docking", async () => {
await assertExcalidrawWithSidebar(
<DefaultSidebar />,
DEFAULT_SIDEBAR.name,
async () => {
expect(h.state.defaultSidebarDockedPreference).toBe(false);
const { dockButton } = await assertSidebarDockButton(true);
fireEvent.click(dockButton);
await waitFor(() => {
expect(h.state.defaultSidebarDockedPreference).toBe(true);
expect(dockButton).toHaveClass("selected");
});
fireEvent.click(dockButton);
await waitFor(() => {
expect(h.state.defaultSidebarDockedPreference).toBe(false);
expect(dockButton).not.toHaveClass("selected");
});
},
);
});
it("when `docked={undefined}` & `onDock`, should allow docking", async () => {
await assertExcalidrawWithSidebar(
<DefaultSidebar onDock={() => {}} />,
DEFAULT_SIDEBAR.name,
async () => {
expect(h.state.defaultSidebarDockedPreference).toBe(false);
const { dockButton } = await assertSidebarDockButton(true);
fireEvent.click(dockButton);
await waitFor(() => {
expect(h.state.defaultSidebarDockedPreference).toBe(true);
expect(dockButton).toHaveClass("selected");
});
fireEvent.click(dockButton);
await waitFor(() => {
expect(h.state.defaultSidebarDockedPreference).toBe(false);
expect(dockButton).not.toHaveClass("selected");
});
},
);
});
it("when `docked={true}` & `onDock`, should allow docking", async () => {
await assertExcalidrawWithSidebar(
<DefaultSidebar onDock={() => {}} />,
DEFAULT_SIDEBAR.name,
async () => {
expect(h.state.defaultSidebarDockedPreference).toBe(false);
const { dockButton } = await assertSidebarDockButton(true);
fireEvent.click(dockButton);
await waitFor(() => {
expect(h.state.defaultSidebarDockedPreference).toBe(true);
expect(dockButton).toHaveClass("selected");
});
fireEvent.click(dockButton);
await waitFor(() => {
expect(h.state.defaultSidebarDockedPreference).toBe(false);
expect(dockButton).not.toHaveClass("selected");
});
},
);
});
it("when `onDock={false}`, should disable docking", async () => {
await assertExcalidrawWithSidebar(
<DefaultSidebar onDock={false} />,
DEFAULT_SIDEBAR.name,
async () => {
await withExcalidrawDimensions(
{ width: 1920, height: 1080 },
async () => {
expect(h.state.defaultSidebarDockedPreference).toBe(false);
await assertSidebarDockButton(false);
},
);
},
);
});
it("when `docked={true}` & `onDock={false}`, should force-dock sidebar", async () => {
await assertExcalidrawWithSidebar(
<DefaultSidebar docked onDock={false} />,
DEFAULT_SIDEBAR.name,
async () => {
expect(h.state.defaultSidebarDockedPreference).toBe(false);
const { sidebar } = await assertSidebarDockButton(false);
expect(sidebar).toHaveClass("sidebar--docked");
},
);
});
it("when `docked={true}` & `onDock={undefined}`, should force-dock sidebar", async () => {
await assertExcalidrawWithSidebar(
<DefaultSidebar docked />,
DEFAULT_SIDEBAR.name,
async () => {
expect(h.state.defaultSidebarDockedPreference).toBe(false);
const { sidebar } = await assertSidebarDockButton(false);
expect(sidebar).toHaveClass("sidebar--docked");
},
);
});
it("when `docked={false}` & `onDock={undefined}`, should force-undock sidebar", async () => {
await assertExcalidrawWithSidebar(
<DefaultSidebar docked={false} />,
DEFAULT_SIDEBAR.name,
async () => {
expect(h.state.defaultSidebarDockedPreference).toBe(false);
const { sidebar } = await assertSidebarDockButton(false);
expect(sidebar).not.toHaveClass("sidebar--docked");
},
);
});
});
+118
View File
@@ -0,0 +1,118 @@
import clsx from "clsx";
import { DEFAULT_SIDEBAR, LIBRARY_SIDEBAR_TAB } from "../constants";
import { useTunnels } from "../context/tunnels";
import { useUIAppState } from "../context/ui-appState";
import { t } from "../i18n";
import { MarkOptional, Merge } from "../utility-types";
import { composeEventHandlers } from "../utils";
import { useExcalidrawSetAppState } from "./App";
import { withInternalFallback } from "./hoc/withInternalFallback";
import { LibraryMenu } from "./LibraryMenu";
import { SidebarProps, SidebarTriggerProps } from "./Sidebar/common";
import { Sidebar } from "./Sidebar/Sidebar";
const DefaultSidebarTrigger = withInternalFallback(
"DefaultSidebarTrigger",
(
props: Omit<SidebarTriggerProps, "name"> &
React.HTMLAttributes<HTMLDivElement>,
) => {
const { DefaultSidebarTriggerTunnel } = useTunnels();
return (
<DefaultSidebarTriggerTunnel.In>
<Sidebar.Trigger
{...props}
className="default-sidebar-trigger"
name={DEFAULT_SIDEBAR.name}
/>
</DefaultSidebarTriggerTunnel.In>
);
},
);
DefaultSidebarTrigger.displayName = "DefaultSidebarTrigger";
const DefaultTabTriggers = ({
children,
...rest
}: { children: React.ReactNode } & React.HTMLAttributes<HTMLDivElement>) => {
const { DefaultSidebarTabTriggersTunnel } = useTunnels();
return (
<DefaultSidebarTabTriggersTunnel.In>
<Sidebar.TabTriggers {...rest}>{children}</Sidebar.TabTriggers>
</DefaultSidebarTabTriggersTunnel.In>
);
};
DefaultTabTriggers.displayName = "DefaultTabTriggers";
export const DefaultSidebar = Object.assign(
withInternalFallback(
"DefaultSidebar",
({
children,
className,
onDock,
docked,
...rest
}: Merge<
MarkOptional<Omit<SidebarProps, "name">, "children">,
{
/** pass `false` to disable docking */
onDock?: SidebarProps["onDock"] | false;
}
>) => {
const appState = useUIAppState();
const setAppState = useExcalidrawSetAppState();
const { DefaultSidebarTabTriggersTunnel } = useTunnels();
return (
<Sidebar
{...rest}
name="default"
key="default"
className={clsx("default-sidebar", className)}
docked={docked ?? appState.defaultSidebarDockedPreference}
onDock={
// `onDock=false` disables docking.
// if `docked` passed, but no onDock passed, disable manual docking.
onDock === false || (!onDock && docked != null)
? undefined
: // compose to allow the host app to listen on default behavior
composeEventHandlers(onDock, (docked) => {
setAppState({ defaultSidebarDockedPreference: docked });
})
}
>
<Sidebar.Tabs>
<Sidebar.Header>
{rest.__fallback && (
<div
style={{
color: "var(--color-primary)",
fontSize: "1.2em",
fontWeight: "bold",
textOverflow: "ellipsis",
overflow: "hidden",
whiteSpace: "nowrap",
paddingRight: "1em",
}}
>
{t("toolBar.library")}
</div>
)}
<DefaultSidebarTabTriggersTunnel.Out />
</Sidebar.Header>
<Sidebar.Tab tab={LIBRARY_SIDEBAR_TAB}>
<LibraryMenu />
</Sidebar.Tab>
{children}
</Sidebar.Tabs>
</Sidebar>
);
},
),
{
Trigger: DefaultSidebarTrigger,
TabTriggers: DefaultTabTriggers,
},
);
+1 -1
View File
@@ -15,7 +15,7 @@ import { Modal } from "./Modal";
import { AppState } from "../types"; import { AppState } from "../types";
import { queryFocusableElements } from "../utils"; import { queryFocusableElements } from "../utils";
import { useSetAtom } from "jotai"; import { useSetAtom } from "jotai";
import { isLibraryMenuOpenAtom } from "./LibraryMenuHeaderContent"; import { isLibraryMenuOpenAtom } from "./LibraryMenu";
import { jotaiScope } from "../jotai"; import { jotaiScope } from "../jotai";
export interface DialogProps { export interface DialogProps {
+6 -5
View File
@@ -165,11 +165,12 @@ export const HelpDialog = ({ onClose }: { onClose?: () => void }) => {
shortcuts={[KEYS.E, KEYS["0"]]} shortcuts={[KEYS.E, KEYS["0"]]}
/> />
<Shortcut <Shortcut
label={t("helpDialog.editSelectedShape")} label={t("helpDialog.editLineArrowPoints")}
shortcuts={[ shortcuts={[getShortcutKey("CtrlOrCmd+Enter")]}
getShortcutKey("CtrlOrCmd+Enter"), />
getShortcutKey(`CtrlOrCmd + ${t("helpDialog.doubleClick")}`), <Shortcut
]} label={t("helpDialog.editText")}
shortcuts={[getShortcutKey("Enter")]}
/> />
<Shortcut <Shortcut
label={t("helpDialog.textNewLine")} label={t("helpDialog.textNewLine")}
+5 -5
View File
@@ -1,9 +1,7 @@
import { t } from "../i18n"; import { t } from "../i18n";
import { NonDeletedExcalidrawElement } from "../element/types"; import { NonDeletedExcalidrawElement } from "../element/types";
import { getSelectedElements } from "../scene"; import { getSelectedElements } from "../scene";
import { Device, UIAppState } from "../types";
import "./HintViewer.scss";
import { AppState, Device } from "../types";
import { import {
isImageElement, isImageElement,
isLinearElement, isLinearElement,
@@ -13,8 +11,10 @@ import {
import { getShortcutKey } from "../utils"; import { getShortcutKey } from "../utils";
import { isEraserActive } from "../appState"; import { isEraserActive } from "../appState";
import "./HintViewer.scss";
interface HintViewerProps { interface HintViewerProps {
appState: AppState; appState: UIAppState;
elements: readonly NonDeletedExcalidrawElement[]; elements: readonly NonDeletedExcalidrawElement[];
isMobile: boolean; isMobile: boolean;
device: Device; device: Device;
@@ -29,7 +29,7 @@ const getHints = ({
const { activeTool, isResizing, isRotating, lastPointerDownWith } = appState; const { activeTool, isResizing, isRotating, lastPointerDownWith } = appState;
const multiMode = appState.multiElement !== null; const multiMode = appState.multiElement !== null;
if (appState.openSidebar === "library" && !device.canDeviceFitSidebar) { if (appState.openSidebar && !device.canDeviceFitSidebar) {
return null; return null;
} }
+17 -18
View File
@@ -4,17 +4,18 @@ import { canvasToBlob } from "../data/blob";
import { NonDeletedExcalidrawElement } from "../element/types"; import { NonDeletedExcalidrawElement } from "../element/types";
import { t } from "../i18n"; import { t } from "../i18n";
import { getSelectedElements, isSomeElementSelected } from "../scene"; import { getSelectedElements, isSomeElementSelected } from "../scene";
import { exportToCanvas } from "../scene/export"; import { BinaryFiles, UIAppState } from "../types";
import { AppState, BinaryFiles } from "../types";
import { Dialog } from "./Dialog"; import { Dialog } from "./Dialog";
import { clipboard } from "./icons"; import { clipboard } from "./icons";
import Stack from "./Stack"; import Stack from "./Stack";
import "./ExportDialog.scss";
import OpenColor from "open-color"; import OpenColor from "open-color";
import { CheckboxItem } from "./CheckboxItem"; import { CheckboxItem } from "./CheckboxItem";
import { DEFAULT_EXPORT_PADDING, isFirefox } from "../constants"; import { DEFAULT_EXPORT_PADDING, isFirefox } from "../constants";
import { nativeFileSystemSupported } from "../data/filesystem"; import { nativeFileSystemSupported } from "../data/filesystem";
import { ActionManager } from "../actions/manager"; import { ActionManager } from "../actions/manager";
import { exportToCanvas } from "../packages/utils";
import "./ExportDialog.scss";
const supportsContextFilters = const supportsContextFilters =
"filter" in document.createElement("canvas").getContext("2d")!; "filter" in document.createElement("canvas").getContext("2d")!;
@@ -70,7 +71,7 @@ const ImageExportModal = ({
onExportToSvg, onExportToSvg,
onExportToClipboard, onExportToClipboard,
}: { }: {
appState: AppState; appState: UIAppState;
elements: readonly NonDeletedExcalidrawElement[]; elements: readonly NonDeletedExcalidrawElement[];
files: BinaryFiles; files: BinaryFiles;
exportPadding?: number; exportPadding?: number;
@@ -83,7 +84,6 @@ const ImageExportModal = ({
const someElementIsSelected = isSomeElementSelected(elements, appState); const someElementIsSelected = isSomeElementSelected(elements, appState);
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 [renderError, setRenderError] = useState<Error | null>(null); const [renderError, setRenderError] = useState<Error | null>(null);
const exportedElements = exportSelected const exportedElements = exportSelected
@@ -99,10 +99,16 @@ const ImageExportModal = ({
if (!previewNode) { if (!previewNode) {
return; return;
} }
exportToCanvas(exportedElements, appState, files, { const maxWidth = previewNode.offsetWidth;
exportBackground, if (!maxWidth) {
viewBackgroundColor, return;
}
exportToCanvas({
elements: exportedElements,
appState,
files,
exportPadding, exportPadding,
maxWidthOrHeight: maxWidth,
}) })
.then((canvas) => { .then((canvas) => {
setRenderError(null); setRenderError(null);
@@ -116,14 +122,7 @@ const ImageExportModal = ({
console.error(error); console.error(error);
setRenderError(error); setRenderError(error);
}); });
}, [ }, [appState, files, exportedElements, exportPadding]);
appState,
files,
exportedElements,
exportBackground,
exportPadding,
viewBackgroundColor,
]);
return ( return (
<div className="ExportDialog"> <div className="ExportDialog">
@@ -218,8 +217,8 @@ export const ImageExportDialog = ({
onExportToSvg, onExportToSvg,
onExportToClipboard, onExportToClipboard,
}: { }: {
appState: AppState; appState: UIAppState;
setAppState: React.Component<any, AppState>["setState"]; setAppState: React.Component<any, UIAppState>["setState"];
elements: readonly NonDeletedExcalidrawElement[]; elements: readonly NonDeletedExcalidrawElement[];
files: BinaryFiles; files: BinaryFiles;
exportPadding?: number; exportPadding?: number;
+4 -4
View File
@@ -2,7 +2,7 @@ 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 { ExportOpts, BinaryFiles, UIAppState } from "../types";
import { Dialog } from "./Dialog"; import { Dialog } from "./Dialog";
import { exportToFileIcon, LinkIcon } from "./icons"; import { exportToFileIcon, LinkIcon } from "./icons";
import { ToolButton } from "./ToolButton"; import { ToolButton } from "./ToolButton";
@@ -28,7 +28,7 @@ const JSONExportModal = ({
exportOpts, exportOpts,
canvas, canvas,
}: { }: {
appState: AppState; appState: UIAppState;
files: BinaryFiles; files: BinaryFiles;
elements: readonly NonDeletedExcalidrawElement[]; elements: readonly NonDeletedExcalidrawElement[];
actionManager: ActionManager; actionManager: ActionManager;
@@ -96,12 +96,12 @@ export const JSONExportDialog = ({
setAppState, setAppState,
}: { }: {
elements: readonly NonDeletedExcalidrawElement[]; elements: readonly NonDeletedExcalidrawElement[];
appState: AppState; appState: UIAppState;
files: BinaryFiles; files: BinaryFiles;
actionManager: ActionManager; actionManager: ActionManager;
exportOpts: ExportOpts; exportOpts: ExportOpts;
canvas: HTMLCanvasElement | null; canvas: HTMLCanvasElement | null;
setAppState: React.Component<any, AppState>["setState"]; setAppState: React.Component<any, UIAppState>["setState"];
}) => { }) => {
const handleClose = React.useCallback(() => { const handleClose = React.useCallback(() => {
setAppState({ openDialog: null }); setAppState({ openDialog: null });
+91 -78
View File
@@ -1,15 +1,21 @@
import clsx from "clsx"; import clsx from "clsx";
import React from "react"; import React from "react";
import { ActionManager } from "../actions/manager"; import { ActionManager } from "../actions/manager";
import { CLASSES, LIBRARY_SIDEBAR_WIDTH } from "../constants"; import { CLASSES, DEFAULT_SIDEBAR, LIBRARY_SIDEBAR_WIDTH } from "../constants";
import { exportCanvas } from "../data"; import { exportCanvas } from "../data";
import { isTextElement, showSelectedShapeActions } from "../element"; import { isTextElement, showSelectedShapeActions } from "../element";
import { NonDeletedExcalidrawElement } from "../element/types"; 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 { isShallowEqual, muteFSAbortError } from "../utils"; AppProps,
AppState,
ExcalidrawProps,
BinaryFiles,
UIAppState,
} from "../types";
import { capitalizeString, isShallowEqual, muteFSAbortError } from "../utils";
import { SelectedShapeActions, ShapesSwitcher } from "./Actions"; import { SelectedShapeActions, ShapesSwitcher } from "./Actions";
import { ErrorDialog } from "./ErrorDialog"; import { ErrorDialog } from "./ErrorDialog";
import { ExportCB, ImageExportDialog } from "./ImageExportDialog"; import { ExportCB, ImageExportDialog } from "./ImageExportDialog";
@@ -24,32 +30,32 @@ import { Section } from "./Section";
import { HelpDialog } from "./HelpDialog"; import { HelpDialog } from "./HelpDialog";
import Stack from "./Stack"; import Stack from "./Stack";
import { UserList } from "./UserList"; import { UserList } from "./UserList";
import Library from "../data/library";
import { JSONExportDialog } from "./JSONExportDialog"; import { JSONExportDialog } from "./JSONExportDialog";
import { LibraryButton } from "./LibraryButton";
import { isImageFileHandle } from "../data/blob"; import { isImageFileHandle } from "../data/blob";
import { LibraryMenu } from "./LibraryMenu";
import "./LayerUI.scss";
import "./Toolbar.scss";
import { PenModeButton } from "./PenModeButton"; import { PenModeButton } from "./PenModeButton";
import { trackEvent } from "../analytics"; import { trackEvent } from "../analytics";
import { 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/Footer"; import Footer from "./footer/Footer";
import { hostSidebarCountersAtom } from "./Sidebar/Sidebar"; import { isSidebarDockedAtom } from "./Sidebar/Sidebar";
import { jotaiScope } from "../jotai"; import { jotaiScope } from "../jotai";
import { Provider, useAtom } from "jotai"; import { Provider, useAtomValue } from "jotai";
import MainMenu from "./main-menu/MainMenu"; import MainMenu from "./main-menu/MainMenu";
import { ActiveConfirmDialog } from "./ActiveConfirmDialog"; import { ActiveConfirmDialog } from "./ActiveConfirmDialog";
import { HandButton } from "./HandButton"; import { HandButton } from "./HandButton";
import { isHandToolActive } from "../appState"; import { isHandToolActive } from "../appState";
import { TunnelsContext, useInitializeTunnels } from "./context/tunnels"; import { TunnelsContext, useInitializeTunnels } from "../context/tunnels";
import { LibraryIcon } from "./icons";
import { UIAppStateContext } from "../context/ui-appState";
import { DefaultSidebar } from "./DefaultSidebar";
import "./LayerUI.scss";
import "./Toolbar.scss";
interface LayerUIProps { interface LayerUIProps {
actionManager: ActionManager; actionManager: ActionManager;
appState: AppState; appState: UIAppState;
files: BinaryFiles; files: BinaryFiles;
canvas: HTMLCanvasElement | null; canvas: HTMLCanvasElement | null;
setAppState: React.Component<any, AppState>["setState"]; setAppState: React.Component<any, AppState>["setState"];
@@ -57,17 +63,11 @@ interface LayerUIProps {
onLockToggle: () => void; onLockToggle: () => void;
onHandToolToggle: () => void; onHandToolToggle: () => void;
onPenModeToggle: () => void; onPenModeToggle: () => void;
onInsertElements: (elements: readonly NonDeletedExcalidrawElement[]) => void;
showExitZenModeBtn: boolean; showExitZenModeBtn: boolean;
langCode: Language["code"]; langCode: Language["code"];
renderTopRightUI?: ExcalidrawProps["renderTopRightUI"]; renderTopRightUI?: ExcalidrawProps["renderTopRightUI"];
renderCustomStats?: ExcalidrawProps["renderCustomStats"]; renderCustomStats?: ExcalidrawProps["renderCustomStats"];
renderCustomSidebar?: ExcalidrawProps["renderSidebar"];
libraryReturnUrl: ExcalidrawProps["libraryReturnUrl"];
UIOptions: AppProps["UIOptions"]; UIOptions: AppProps["UIOptions"];
focusContainer: () => void;
library: Library;
id: string;
onImageAction: (data: { insertOnCanvasDirectly: boolean }) => void; onImageAction: (data: { insertOnCanvasDirectly: boolean }) => void;
renderWelcomeScreen: boolean; renderWelcomeScreen: boolean;
children?: React.ReactNode; children?: React.ReactNode;
@@ -109,16 +109,10 @@ const LayerUI = ({
onLockToggle, onLockToggle,
onHandToolToggle, onHandToolToggle,
onPenModeToggle, onPenModeToggle,
onInsertElements,
showExitZenModeBtn, showExitZenModeBtn,
renderTopRightUI, renderTopRightUI,
renderCustomStats, renderCustomStats,
renderCustomSidebar,
libraryReturnUrl,
UIOptions, UIOptions,
focusContainer,
library,
id,
onImageAction, onImageAction,
renderWelcomeScreen, renderWelcomeScreen,
children, children,
@@ -156,7 +150,8 @@ const LayerUI = ({
const fileHandle = await exportCanvas( const fileHandle = await exportCanvas(
type, type,
exportedElements, exportedElements,
appState, // FIXME once we split UI canvas from element canvas
appState as AppState,
files, files,
{ {
exportBackground: appState.exportBackground, exportBackground: appState.exportBackground,
@@ -197,8 +192,8 @@ const LayerUI = ({
<div style={{ position: "relative" }}> <div style={{ position: "relative" }}>
{/* wrapping to Fragment stops React from occasionally complaining {/* wrapping to Fragment stops React from occasionally complaining
about identical Keys */} about identical Keys */}
<tunnels.mainMenuTunnel.Out /> <tunnels.MainMenuTunnel.Out />
{renderWelcomeScreen && <tunnels.welcomeScreenMenuHintTunnel.Out />} {renderWelcomeScreen && <tunnels.WelcomeScreenMenuHintTunnel.Out />}
</div> </div>
); );
@@ -250,7 +245,7 @@ const LayerUI = ({
{(heading: React.ReactNode) => ( {(heading: React.ReactNode) => (
<div style={{ position: "relative" }}> <div style={{ position: "relative" }}>
{renderWelcomeScreen && ( {renderWelcomeScreen && (
<tunnels.welcomeScreenToolbarHintTunnel.Out /> <tunnels.WelcomeScreenToolbarHintTunnel.Out />
)} )}
<Stack.Col gap={4} align="start"> <Stack.Col gap={4} align="start">
<Stack.Row <Stack.Row
@@ -324,9 +319,12 @@ const LayerUI = ({
> >
<UserList collaborators={appState.collaborators} /> <UserList collaborators={appState.collaborators} />
{renderTopRightUI?.(device.isMobile, appState)} {renderTopRightUI?.(device.isMobile, appState)}
{!appState.viewModeEnabled && ( {!appState.viewModeEnabled &&
<LibraryButton appState={appState} setAppState={setAppState} /> // hide button when sidebar docked
)} (!isSidebarDocked ||
appState.openSidebar?.name !== DEFAULT_SIDEBAR.name) && (
<tunnels.DefaultSidebarTriggerTunnel.Out />
)}
</div> </div>
</div> </div>
</FixedSideContainer> </FixedSideContainer>
@@ -334,21 +332,21 @@ const LayerUI = ({
}; };
const renderSidebars = () => { const renderSidebars = () => {
return appState.openSidebar === "customSidebar" ? ( return (
renderCustomSidebar?.() || null <DefaultSidebar
) : appState.openSidebar === "library" ? ( __fallback
<LibraryMenu onDock={(docked) => {
appState={appState} trackEvent(
onInsertElements={onInsertElements} "sidebar",
libraryReturnUrl={libraryReturnUrl} `toggleDock (${docked ? "dock" : "undock"})`,
focusContainer={focusContainer} `(${device.isMobile ? "mobile" : "desktop"})`,
library={library} );
id={id} }}
/> />
) : null; );
}; };
const [hostSidebarCounters] = useAtom(hostSidebarCountersAtom, jotaiScope); const isSidebarDocked = useAtomValue(isSidebarDockedAtom, jotaiScope);
const layerUIJSX = ( const layerUIJSX = (
<> <>
@@ -358,8 +356,25 @@ const LayerUI = ({
{children} {children}
{/* render component fallbacks. Can be rendered anywhere as they'll be {/* render component fallbacks. Can be rendered anywhere as they'll be
tunneled away. We only render tunneled components that actually tunneled away. We only render tunneled components that actually
have defaults when host do not render anything. */} have defaults when host do not render anything. */}
<DefaultMainMenu UIOptions={UIOptions} /> <DefaultMainMenu UIOptions={UIOptions} />
<DefaultSidebar.Trigger
__fallback
icon={LibraryIcon}
title={capitalizeString(t("toolBar.library"))}
onToggle={(open) => {
if (open) {
trackEvent(
"sidebar",
`${DEFAULT_SIDEBAR.name} (open)`,
`button (${device.isMobile ? "mobile" : "desktop"})`,
);
}
}}
tab={DEFAULT_SIDEBAR.defaultTab}
>
{t("toolBar.library")}
</DefaultSidebar.Trigger>
{/* ------------------------------------------------------------------ */} {/* ------------------------------------------------------------------ */}
{appState.isLoading && <LoadingMessage delay={250} />} {appState.isLoading && <LoadingMessage delay={250} />}
@@ -382,7 +397,6 @@ const LayerUI = ({
<PasteChartDialog <PasteChartDialog
setAppState={setAppState} setAppState={setAppState}
appState={appState} appState={appState}
onInsertChart={onInsertElements}
onClose={() => onClose={() =>
setAppState({ setAppState({
pasteDialog: { shown: false, data: null }, pasteDialog: { shown: false, data: null },
@@ -410,7 +424,6 @@ const LayerUI = ({
renderWelcomeScreen={renderWelcomeScreen} renderWelcomeScreen={renderWelcomeScreen}
/> />
)} )}
{!device.isMobile && ( {!device.isMobile && (
<> <>
<div <div
@@ -422,15 +435,14 @@ const LayerUI = ({
!isTextElement(appState.editingElement)), !isTextElement(appState.editingElement)),
})} })}
style={ style={
((appState.openSidebar === "library" && appState.openSidebar &&
appState.isSidebarDocked) || isSidebarDocked &&
hostSidebarCounters.docked) &&
device.canDeviceFitSidebar device.canDeviceFitSidebar
? { width: `calc(100% - ${LIBRARY_SIDEBAR_WIDTH}px)` } ? { width: `calc(100% - ${LIBRARY_SIDEBAR_WIDTH}px)` }
: {} : {}
} }
> >
{renderWelcomeScreen && <tunnels.welcomeScreenCenterTunnel.Out />} {renderWelcomeScreen && <tunnels.WelcomeScreenCenterTunnel.Out />}
{renderFixedSideContainer()} {renderFixedSideContainer()}
<Footer <Footer
appState={appState} appState={appState}
@@ -453,9 +465,9 @@ const LayerUI = ({
<button <button
className="scroll-back-to-content" className="scroll-back-to-content"
onClick={() => { onClick={() => {
setAppState({ setAppState((appState) => ({
...calculateScrollCenter(elements, appState, canvas), ...calculateScrollCenter(elements, appState, canvas),
}); }));
}} }}
> >
{t("buttons.scrollBackToContent")} {t("buttons.scrollBackToContent")}
@@ -469,19 +481,25 @@ const LayerUI = ({
); );
return ( return (
<Provider scope={tunnels.jotaiScope}> <UIAppStateContext.Provider value={appState}>
<TunnelsContext.Provider value={tunnels}> <Provider scope={tunnels.jotaiScope}>
{layerUIJSX} <TunnelsContext.Provider value={tunnels}>
</TunnelsContext.Provider> {layerUIJSX}
</Provider> </TunnelsContext.Provider>
</Provider>
</UIAppStateContext.Provider>
); );
}; };
const stripIrrelevantAppStateProps = ( const stripIrrelevantAppStateProps = (appState: AppState): UIAppState => {
appState: AppState, const {
): Partial<AppState> => { suggestedBindings,
const { suggestedBindings, startBoundElement, cursorButton, ...ret } = startBoundElement,
appState; cursorButton,
scrollX,
scrollY,
...ret
} = appState;
return ret; return ret;
}; };
@@ -491,24 +509,19 @@ const areEqual = (prevProps: LayerUIProps, nextProps: LayerUIProps) => {
return false; return false;
} }
const { const { canvas: _prevCanvas, appState: prevAppState, ...prev } = prevProps;
canvas: _prevCanvas, const { canvas: _nextCanvas, appState: nextAppState, ...next } = nextProps;
// not stable, but shouldn't matter in our case
onInsertElements: _prevOnInsertElements,
appState: prevAppState,
...prev
} = prevProps;
const {
canvas: _nextCanvas,
onInsertElements: _nextOnInsertElements,
appState: nextAppState,
...next
} = nextProps;
return ( return (
isShallowEqual( isShallowEqual(
stripIrrelevantAppStateProps(prevAppState), // asserting AppState because we're being passed the whole AppState
stripIrrelevantAppStateProps(nextAppState), // but resolve to only the UI-relevant props
stripIrrelevantAppStateProps(prevAppState as AppState),
stripIrrelevantAppStateProps(nextAppState as AppState),
{
selectedElementIds: isShallowEqual,
selectedGroupIds: isShallowEqual,
},
) && isShallowEqual(prev, next) ) && isShallowEqual(prev, next)
); );
}; };
-32
View File
@@ -1,32 +0,0 @@
@import "../css/variables.module";
.library-button {
@include outlineButtonStyles;
background-color: var(--island-bg-color);
width: auto;
height: var(--lg-button-size);
display: flex;
align-items: center;
gap: 0.5rem;
line-height: 0;
font-size: 0.75rem;
letter-spacing: 0.4px;
svg {
width: var(--lg-icon-size);
height: var(--lg-icon-size);
}
&__label {
display: none;
@media screen and (min-width: 1024px) {
display: block;
}
}
}
-57
View File
@@ -1,57 +0,0 @@
import React from "react";
import { t } from "../i18n";
import { AppState } from "../types";
import { capitalizeString } from "../utils";
import { trackEvent } from "../analytics";
import { useDevice } from "./App";
import "./LibraryButton.scss";
import { LibraryIcon } from "./icons";
export const LibraryButton: React.FC<{
appState: AppState;
setAppState: React.Component<any, AppState>["setState"];
isMobile?: boolean;
}> = ({ appState, setAppState, isMobile }) => {
const device = useDevice();
const showLabel = !isMobile;
// TODO barnabasmolnar/redesign
// not great, toolbar jumps in a jarring manner
if (appState.isSidebarDocked && appState.openSidebar === "library") {
return null;
}
return (
<label title={`${capitalizeString(t("toolBar.library"))}`}>
<input
className="ToolIcon_type_checkbox"
type="checkbox"
name="editor-library"
onChange={(event) => {
document
.querySelector(".layer-ui__wrapper")
?.classList.remove("animate");
const isOpen = event.target.checked;
setAppState({ openSidebar: isOpen ? "library" : null });
// track only openings
if (isOpen) {
trackEvent(
"library",
"toggleLibrary (open)",
`toolbar (${device.isMobile ? "mobile" : "desktop"})`,
);
}
}}
checked={appState.openSidebar === "library"}
aria-label={capitalizeString(t("toolBar.library"))}
aria-keyshortcuts="0"
/>
<div className="library-button">
<div>{LibraryIcon}</div>
{showLabel && (
<div className="library-button__label">{t("toolBar.library")}</div>
)}
</div>
</label>
);
};
+23 -49
View File
@@ -1,9 +1,9 @@
@import "open-color/open-color"; @import "open-color/open-color";
.excalidraw { .excalidraw {
.layer-ui__library-sidebar { .library-menu-items-container {
display: flex; height: 100%;
flex-direction: column; width: 100%;
} }
.layer-ui__library { .layer-ui__library {
@@ -11,28 +11,6 @@
flex-direction: column; flex-direction: column;
flex: 1 1 auto; flex: 1 1 auto;
.layer-ui__library-header {
display: flex;
align-items: center;
width: 100%;
margin: 2px 0 15px 0;
.Spinner {
margin-right: 1rem;
}
button {
// 2px from the left to account for focus border of left-most button
margin: 0 2px;
}
}
}
.layer-ui__sidebar {
.library-menu-items-container {
height: 100%;
width: 100%;
}
} }
.library-actions-counter { .library-actions-counter {
@@ -87,10 +65,17 @@
} }
} }
.library-menu-browse-button { .library-menu-control-buttons {
margin: 1rem auto; display: flex;
align-items: center;
justify-content: center;
gap: 0.625rem;
}
padding: 0.875rem 1rem; .library-menu-browse-button {
flex: 1;
height: var(--lg-button-size);
display: flex; display: flex;
align-items: center; align-items: center;
@@ -122,30 +107,19 @@
} }
} }
.library-menu-browse-button--mobile { &.excalidraw--mobile .library-menu-browse-button {
min-height: 22px; height: var(--default-button-size);
margin-left: auto;
a {
padding-right: 0;
}
} }
.layer-ui__sidebar__header .dropdown-menu { .layer-ui__library .dropdown-menu {
&.dropdown-menu--mobile { width: auto;
top: 100%; top: initial;
} right: 0;
left: initial;
bottom: 100%;
margin-bottom: 0.625rem;
.dropdown-menu-container { .dropdown-menu-container {
--gap: 0;
z-index: 1;
position: absolute;
top: 100%;
left: 0;
:root[dir="rtl"] & {
right: 0;
left: auto;
}
width: 196px; width: 196px;
box-shadow: var(--library-dropdown-shadow); box-shadow: var(--library-dropdown-shadow);
border-radius: var(--border-radius-lg); border-radius: var(--border-radius-lg);
+43 -180
View File
@@ -1,77 +1,39 @@
import { import React, { useState, useCallback } from "react";
useRef,
useState,
useEffect,
useCallback,
RefObject,
forwardRef,
} from "react";
import Library, { import Library, {
distributeLibraryItemsOnSquareGrid, distributeLibraryItemsOnSquareGrid,
libraryItemsAtom, libraryItemsAtom,
} from "../data/library"; } from "../data/library";
import { t } from "../i18n"; import { t } from "../i18n";
import { randomId } from "../random"; import { randomId } from "../random";
import { LibraryItems, LibraryItem, AppState, ExcalidrawProps } from "../types"; import {
LibraryItems,
import "./LibraryMenu.scss"; LibraryItem,
ExcalidrawProps,
UIAppState,
} from "../types";
import LibraryMenuItems from "./LibraryMenuItems"; import LibraryMenuItems from "./LibraryMenuItems";
import { EVENT } from "../constants";
import { KEYS } from "../keys";
import { trackEvent } from "../analytics"; import { trackEvent } from "../analytics";
import { useAtom } from "jotai"; import { atom, useAtom } from "jotai";
import { jotaiScope } from "../jotai"; import { jotaiScope } from "../jotai";
import Spinner from "./Spinner"; import Spinner from "./Spinner";
import { import {
useDevice, useApp,
useAppProps,
useExcalidrawElements, useExcalidrawElements,
useExcalidrawSetAppState, useExcalidrawSetAppState,
} from "./App"; } from "./App";
import { Sidebar } from "./Sidebar/Sidebar";
import { getSelectedElements } from "../scene"; import { getSelectedElements } from "../scene";
import { NonDeletedExcalidrawElement } from "../element/types"; import { useUIAppState } from "../context/ui-appState";
import { LibraryMenuHeader } from "./LibraryMenuHeaderContent";
import LibraryMenuBrowseButton from "./LibraryMenuBrowseButton";
const useOnClickOutside = ( import "./LibraryMenu.scss";
ref: RefObject<HTMLElement>, import { LibraryMenuControlButtons } from "./LibraryMenuControlButtons";
cb: (event: MouseEvent) => void,
) => {
useEffect(() => {
const listener = (event: MouseEvent) => {
if (!ref.current) {
return;
}
if ( export const isLibraryMenuOpenAtom = atom(false);
event.target instanceof Element &&
(ref.current.contains(event.target) ||
!document.body.contains(event.target))
) {
return;
}
cb(event); const LibraryMenuWrapper = ({ children }: { children: React.ReactNode }) => {
}; return <div className="layer-ui__library">{children}</div>;
document.addEventListener("pointerdown", listener, false);
return () => {
document.removeEventListener("pointerdown", listener);
};
}, [ref, cb]);
}; };
const LibraryMenuWrapper = forwardRef<
HTMLDivElement,
{ children: React.ReactNode }
>(({ children }, ref) => {
return (
<div ref={ref} className="layer-ui__library">
{children}
</div>
);
});
export const LibraryMenuContent = ({ export const LibraryMenuContent = ({
onInsertLibraryItems, onInsertLibraryItems,
pendingElements, pendingElements,
@@ -87,11 +49,11 @@ export const LibraryMenuContent = ({
pendingElements: LibraryItem["elements"]; pendingElements: LibraryItem["elements"];
onInsertLibraryItems: (libraryItems: LibraryItems) => void; onInsertLibraryItems: (libraryItems: LibraryItems) => void;
onAddToLibrary: () => void; onAddToLibrary: () => void;
setAppState: React.Component<any, AppState>["setState"]; setAppState: React.Component<any, UIAppState>["setState"];
libraryReturnUrl: ExcalidrawProps["libraryReturnUrl"]; libraryReturnUrl: ExcalidrawProps["libraryReturnUrl"];
library: Library; library: Library;
id: string; id: string;
appState: AppState; appState: UIAppState;
selectedItems: LibraryItem["id"][]; selectedItems: LibraryItem["id"][];
onSelectItems: (id: LibraryItem["id"][]) => void; onSelectItems: (id: LibraryItem["id"][]) => void;
}) => { }) => {
@@ -158,81 +120,31 @@ export const LibraryMenuContent = ({
theme={appState.theme} theme={appState.theme}
/> />
{showBtn && ( {showBtn && (
<LibraryMenuBrowseButton <LibraryMenuControlButtons
style={{ padding: "16px 12px 0 12px" }}
id={id} id={id}
libraryReturnUrl={libraryReturnUrl} libraryReturnUrl={libraryReturnUrl}
theme={appState.theme} theme={appState.theme}
selectedItems={selectedItems}
onSelectItems={onSelectItems}
/> />
)} )}
</LibraryMenuWrapper> </LibraryMenuWrapper>
); );
}; };
export const LibraryMenu: React.FC<{ /**
appState: AppState; * This component is meant to be rendered inside <Sidebar.Tab/> inside our
onInsertElements: (elements: readonly NonDeletedExcalidrawElement[]) => void; * <DefaultSidebar/> or host apps Sidebar components.
libraryReturnUrl: ExcalidrawProps["libraryReturnUrl"]; */
focusContainer: () => void; export const LibraryMenu = () => {
library: Library; const { library, id, onInsertElements } = useApp();
id: string; const appProps = useAppProps();
}> = ({ const appState = useUIAppState();
appState,
onInsertElements,
libraryReturnUrl,
focusContainer,
library,
id,
}) => {
const setAppState = useExcalidrawSetAppState(); const setAppState = useExcalidrawSetAppState();
const elements = useExcalidrawElements(); const elements = useExcalidrawElements();
const device = useDevice();
const [selectedItems, setSelectedItems] = useState<LibraryItem["id"][]>([]); const [selectedItems, setSelectedItems] = useState<LibraryItem["id"][]>([]);
const [libraryItemsData] = useAtom(libraryItemsAtom, jotaiScope);
const ref = useRef<HTMLDivElement | null>(null);
const closeLibrary = useCallback(() => {
const isDialogOpen = !!document.querySelector(".Dialog");
// Prevent closing if any dialog is open
if (isDialogOpen) {
return;
}
setAppState({ openSidebar: null });
}, [setAppState]);
useOnClickOutside(
ref,
useCallback(
(event) => {
// If click on the library icon, do nothing so that LibraryButton
// can toggle library menu
if ((event.target as Element).closest(".ToolIcon__library")) {
return;
}
if (!appState.isSidebarDocked || !device.canDeviceFitSidebar) {
closeLibrary();
}
},
[closeLibrary, appState.isSidebarDocked, device.canDeviceFitSidebar],
),
);
useEffect(() => {
const handleKeyDown = (event: KeyboardEvent) => {
if (
event.key === KEYS.ESCAPE &&
(!appState.isSidebarDocked || !device.canDeviceFitSidebar)
) {
closeLibrary();
}
};
document.addEventListener(EVENT.KEYDOWN, handleKeyDown);
return () => {
document.removeEventListener(EVENT.KEYDOWN, handleKeyDown);
};
}, [closeLibrary, appState.isSidebarDocked, device.canDeviceFitSidebar]);
const deselectItems = useCallback(() => { const deselectItems = useCallback(() => {
setAppState({ setAppState({
@@ -241,69 +153,20 @@ export const LibraryMenu: React.FC<{
}); });
}, [setAppState]); }, [setAppState]);
const removeFromLibrary = useCallback(
async (libraryItems: LibraryItems) => {
const nextItems = libraryItems.filter(
(item) => !selectedItems.includes(item.id),
);
library.setLibrary(nextItems).catch(() => {
setAppState({ errorMessage: t("alerts.errorRemovingFromLibrary") });
});
setSelectedItems([]);
},
[library, setAppState, selectedItems, setSelectedItems],
);
const resetLibrary = useCallback(() => {
library.resetLibrary();
focusContainer();
}, [library, focusContainer]);
return ( return (
<Sidebar <LibraryMenuContent
__isInternal pendingElements={getSelectedElements(elements, appState, true)}
// necessary to remount when switching between internal onInsertLibraryItems={(libraryItems) => {
// and custom (host app) sidebar, so that the `props.onClose` onInsertElements(distributeLibraryItemsOnSquareGrid(libraryItems));
// is colled correctly
key="library"
className="layer-ui__library-sidebar"
initialDockedState={appState.isSidebarDocked}
onDock={(docked) => {
trackEvent(
"library",
`toggleLibraryDock (${docked ? "dock" : "undock"})`,
`sidebar (${device.isMobile ? "mobile" : "desktop"})`,
);
}} }}
ref={ref} onAddToLibrary={deselectItems}
> setAppState={setAppState}
<Sidebar.Header className="layer-ui__library-header"> libraryReturnUrl={appProps.libraryReturnUrl}
<LibraryMenuHeader library={library}
appState={appState} id={id}
setAppState={setAppState} appState={appState}
selectedItems={selectedItems} selectedItems={selectedItems}
onSelectItems={setSelectedItems} onSelectItems={setSelectedItems}
library={library} />
onRemoveFromLibrary={() =>
removeFromLibrary(libraryItemsData.libraryItems)
}
resetLibrary={resetLibrary}
/>
</Sidebar.Header>
<LibraryMenuContent
pendingElements={getSelectedElements(elements, appState, true)}
onInsertLibraryItems={(libraryItems) => {
onInsertElements(distributeLibraryItemsOnSquareGrid(libraryItems));
}}
onAddToLibrary={deselectItems}
setAppState={setAppState}
libraryReturnUrl={libraryReturnUrl}
library={library}
id={id}
appState={appState}
selectedItems={selectedItems}
onSelectItems={setSelectedItems}
/>
</Sidebar>
); );
}; };
+2 -2
View File
@@ -1,6 +1,6 @@
import { VERSIONS } from "../constants"; import { VERSIONS } from "../constants";
import { t } from "../i18n"; import { t } from "../i18n";
import { AppState, ExcalidrawProps } from "../types"; import { ExcalidrawProps, UIAppState } from "../types";
const LibraryMenuBrowseButton = ({ const LibraryMenuBrowseButton = ({
theme, theme,
@@ -8,7 +8,7 @@ const LibraryMenuBrowseButton = ({
libraryReturnUrl, libraryReturnUrl,
}: { }: {
libraryReturnUrl: ExcalidrawProps["libraryReturnUrl"]; libraryReturnUrl: ExcalidrawProps["libraryReturnUrl"];
theme: AppState["theme"]; theme: UIAppState["theme"];
id: string; id: string;
}) => { }) => {
const referrer = const referrer =
@@ -0,0 +1,33 @@
import { LibraryItem, ExcalidrawProps, UIAppState } from "../types";
import LibraryMenuBrowseButton from "./LibraryMenuBrowseButton";
import { LibraryDropdownMenu } from "./LibraryMenuHeaderContent";
export const LibraryMenuControlButtons = ({
selectedItems,
onSelectItems,
libraryReturnUrl,
theme,
id,
style,
}: {
selectedItems: LibraryItem["id"][];
onSelectItems: (id: LibraryItem["id"][]) => void;
libraryReturnUrl: ExcalidrawProps["libraryReturnUrl"];
theme: UIAppState["theme"];
id: string;
style: React.CSSProperties;
}) => {
return (
<div className="library-menu-control-buttons" style={style}>
<LibraryMenuBrowseButton
id={id}
libraryReturnUrl={libraryReturnUrl}
theme={theme}
/>
<LibraryDropdownMenu
selectedItems={selectedItems}
onSelectItems={onSelectItems}
/>
</div>
);
};
+72 -22
View File
@@ -1,8 +1,11 @@
import React, { useCallback, useState } from "react"; import { useCallback, useState } from "react";
import { t } from "../i18n";
import Trans from "./Trans";
import { jotaiScope } from "../jotai";
import { LibraryItem, LibraryItems, UIAppState } from "../types";
import { useApp, useExcalidrawSetAppState } from "./App";
import { saveLibraryAsJSON } from "../data/json"; import { saveLibraryAsJSON } from "../data/json";
import Library, { libraryItemsAtom } from "../data/library"; import Library, { libraryItemsAtom } from "../data/library";
import { t } from "../i18n";
import { AppState, LibraryItem, LibraryItems } from "../types";
import { import {
DotsIcon, DotsIcon,
ExportIcon, ExportIcon,
@@ -13,29 +16,27 @@ 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 { atom, useAtom } from "jotai"; import { useAtom } 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 DropdownMenu from "./dropdownMenu/DropdownMenu"; import DropdownMenu from "./dropdownMenu/DropdownMenu";
import { isLibraryMenuOpenAtom } from "./LibraryMenu";
export const isLibraryMenuOpenAtom = atom(false); import { useUIAppState } from "../context/ui-appState";
const getSelectedItems = ( const getSelectedItems = (
libraryItems: LibraryItems, libraryItems: LibraryItems,
selectedItems: LibraryItem["id"][], selectedItems: LibraryItem["id"][],
) => libraryItems.filter((item) => selectedItems.includes(item.id)); ) => libraryItems.filter((item) => selectedItems.includes(item.id));
export const LibraryMenuHeader: React.FC<{ export const LibraryDropdownMenuButton: React.FC<{
setAppState: React.Component<any, AppState>["setState"]; setAppState: React.Component<any, UIAppState>["setState"];
selectedItems: LibraryItem["id"][]; selectedItems: LibraryItem["id"][];
library: Library; library: Library;
onRemoveFromLibrary: () => void; onRemoveFromLibrary: () => void;
resetLibrary: () => void; resetLibrary: () => void;
onSelectItems: (items: LibraryItem["id"][]) => void; onSelectItems: (items: LibraryItem["id"][]) => void;
appState: AppState; appState: UIAppState;
}> = ({ }> = ({
setAppState, setAppState,
selectedItems, selectedItems,
@@ -50,6 +51,7 @@ export const LibraryMenuHeader: React.FC<{
isLibraryMenuOpenAtom, isLibraryMenuOpenAtom,
jotaiScope, jotaiScope,
); );
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 })
@@ -104,16 +106,19 @@ export const LibraryMenuHeader: React.FC<{
small={true} small={true}
> >
<p> <p>
{t("publishSuccessDialog.content", { <Trans
authorName: publishLibSuccess!.authorName, i18nKey="publishSuccessDialog.content"
})}{" "} authorName={publishLibSuccess!.authorName}
<a link={(el) => (
href={publishLibSuccess?.url} <a
target="_blank" href={publishLibSuccess?.url}
rel="noopener noreferrer" target="_blank"
> rel="noopener noreferrer"
{t("publishSuccessDialog.link")} >
</a> {el}
</a>
)}
/>
</p> </p>
<ToolButton <ToolButton
type="button" type="button"
@@ -181,7 +186,6 @@ export const LibraryMenuHeader: React.FC<{
return ( return (
<DropdownMenu open={isLibraryMenuOpen}> <DropdownMenu open={isLibraryMenuOpen}>
<DropdownMenu.Trigger <DropdownMenu.Trigger
className="Sidebar__dropdown-btn"
onToggle={() => setIsLibraryMenuOpen(!isLibraryMenuOpen)} onToggle={() => setIsLibraryMenuOpen(!isLibraryMenuOpen)}
> >
{DotsIcon} {DotsIcon}
@@ -230,6 +234,7 @@ export const LibraryMenuHeader: React.FC<{
</DropdownMenu> </DropdownMenu>
); );
}; };
return ( return (
<div style={{ position: "relative" }}> <div style={{ position: "relative" }}>
{renderLibraryMenu()} {renderLibraryMenu()}
@@ -261,3 +266,48 @@ export const LibraryMenuHeader: React.FC<{
</div> </div>
); );
}; };
export const LibraryDropdownMenu = ({
selectedItems,
onSelectItems,
}: {
selectedItems: LibraryItem["id"][];
onSelectItems: (id: LibraryItem["id"][]) => void;
}) => {
const { library } = useApp();
const appState = useUIAppState();
const setAppState = useExcalidrawSetAppState();
const [libraryItemsData] = useAtom(libraryItemsAtom, jotaiScope);
const removeFromLibrary = useCallback(
async (libraryItems: LibraryItems) => {
const nextItems = libraryItems.filter(
(item) => !selectedItems.includes(item.id),
);
library.setLibrary(nextItems).catch(() => {
setAppState({ errorMessage: t("alerts.errorRemovingFromLibrary") });
});
onSelectItems([]);
},
[library, setAppState, selectedItems, onSelectItems],
);
const resetLibrary = useCallback(() => {
library.resetLibrary();
}, [library]);
return (
<LibraryDropdownMenuButton
appState={appState}
setAppState={setAppState}
selectedItems={selectedItems}
onSelectItems={onSelectItems}
library={library}
onRemoveFromLibrary={() =>
removeFromLibrary(libraryItemsData.libraryItems)
}
resetLibrary={resetLibrary}
/>
);
};
+2 -2
View File
@@ -47,7 +47,7 @@
&__items { &__items {
row-gap: 0.5rem; row-gap: 0.5rem;
padding: var(--container-padding-y) var(--container-padding-x); padding: var(--container-padding-y) 0;
flex: 1; flex: 1;
overflow-y: auto; overflow-y: auto;
overflow-x: hidden; overflow-x: hidden;
@@ -61,7 +61,7 @@
margin-bottom: 0.75rem; margin-bottom: 0.75rem;
&--excal { &--excal {
margin-top: 2.5rem; margin-top: 2rem;
} }
} }
+26 -19
View File
@@ -2,16 +2,21 @@ import React, { useState } from "react";
import { serializeLibraryAsJSON } from "../data/json"; import { serializeLibraryAsJSON } from "../data/json";
import { ExcalidrawElement, NonDeleted } from "../element/types"; import { ExcalidrawElement, NonDeleted } from "../element/types";
import { t } from "../i18n"; import { t } from "../i18n";
import { AppState, ExcalidrawProps, LibraryItem, LibraryItems } from "../types"; import {
ExcalidrawProps,
LibraryItem,
LibraryItems,
UIAppState,
} from "../types";
import { arrayToMap, chunk } from "../utils"; import { arrayToMap, chunk } from "../utils";
import { LibraryUnit } from "./LibraryUnit"; import { LibraryUnit } from "./LibraryUnit";
import Stack from "./Stack"; import Stack from "./Stack";
import "./LibraryMenuItems.scss";
import { MIME_TYPES } from "../constants"; import { MIME_TYPES } from "../constants";
import Spinner from "./Spinner"; import Spinner from "./Spinner";
import LibraryMenuBrowseButton from "./LibraryMenuBrowseButton"; import { duplicateElements } from "../element/newElement";
import clsx from "clsx"; import { LibraryMenuControlButtons } from "./LibraryMenuControlButtons";
import "./LibraryMenuItems.scss";
const CELLS_PER_ROW = 4; const CELLS_PER_ROW = 4;
@@ -35,7 +40,7 @@ const LibraryMenuItems = ({
selectedItems: LibraryItem["id"][]; selectedItems: LibraryItem["id"][];
onSelectItems: (id: LibraryItem["id"][]) => void; onSelectItems: (id: LibraryItem["id"][]) => void;
libraryReturnUrl: ExcalidrawProps["libraryReturnUrl"]; libraryReturnUrl: ExcalidrawProps["libraryReturnUrl"];
theme: AppState["theme"]; theme: UIAppState["theme"];
id: string; id: string;
}) => { }) => {
const [lastSelectedItem, setLastSelectedItem] = useState< const [lastSelectedItem, setLastSelectedItem] = useState<
@@ -96,7 +101,14 @@ const LibraryMenuItems = ({
} else { } else {
targetElements = libraryItems.filter((item) => item.id === id); targetElements = libraryItems.filter((item) => item.id === id);
} }
return targetElements; return targetElements.map((item) => {
return {
...item,
// duplicate each library item before inserting on canvas to confine
// ids and bindings to each library item. See #6465
elements: duplicateElements(item.elements, { randomizeSeed: true }),
};
});
}; };
const createLibraryItemCompo = (params: { const createLibraryItemCompo = (params: {
@@ -193,11 +205,7 @@ const LibraryMenuItems = ({
(item) => item.status === "published", (item) => item.status === "published",
); );
const showBtn = const showBtn = !libraryItems.length && !pendingElements.length;
!libraryItems.length &&
!unpublishedItems.length &&
!publishedItems.length &&
!pendingElements.length;
return ( return (
<div <div
@@ -207,7 +215,7 @@ const LibraryMenuItems = ({
unpublishedItems.length || unpublishedItems.length ||
publishedItems.length publishedItems.length
? { justifyContent: "flex-start" } ? { justifyContent: "flex-start" }
: {} : { borderBottom: 0 }
} }
> >
<Stack.Col <Stack.Col
@@ -243,11 +251,7 @@ const LibraryMenuItems = ({
</div> </div>
{!pendingElements.length && !unpublishedItems.length ? ( {!pendingElements.length && !unpublishedItems.length ? (
<div className="library-menu-items__no-items"> <div className="library-menu-items__no-items">
<div <div className="library-menu-items__no-items__label">
className={clsx({
"library-menu-items__no-items__label": showBtn,
})}
>
{t("library.noItems")} {t("library.noItems")}
</div> </div>
<div className="library-menu-items__no-items__hint"> <div className="library-menu-items__no-items__hint">
@@ -295,10 +299,13 @@ const LibraryMenuItems = ({
</> </>
{showBtn && ( {showBtn && (
<LibraryMenuBrowseButton <LibraryMenuControlButtons
style={{ padding: "16px 0", width: "100%" }}
id={id} id={id}
libraryReturnUrl={libraryReturnUrl} libraryReturnUrl={libraryReturnUrl}
theme={theme} theme={theme}
selectedItems={selectedItems}
onSelectItems={onSelectItems}
/> />
)} )}
</Stack.Col> </Stack.Col>
+5 -5
View File
@@ -2,7 +2,7 @@ import clsx from "clsx";
import oc from "open-color"; import oc from "open-color";
import { useEffect, useRef, useState } from "react"; import { useEffect, useRef, useState } from "react";
import { useDevice } from "../components/App"; import { useDevice } from "../components/App";
import { exportToSvg } from "../scene/export"; import { exportToSvg } from "../packages/utils";
import { LibraryItem } from "../types"; import { LibraryItem } from "../types";
import "./LibraryUnit.scss"; import "./LibraryUnit.scss";
import { CheckboxItem } from "./CheckboxItem"; import { CheckboxItem } from "./CheckboxItem";
@@ -36,14 +36,14 @@ export const LibraryUnit = ({
if (!elements) { if (!elements) {
return; return;
} }
const svg = await exportToSvg( const svg = await exportToSvg({
elements, elements,
{ appState: {
exportBackground: false, exportBackground: false,
viewBackgroundColor: oc.white, viewBackgroundColor: oc.white,
}, },
null, files: null,
); });
svg.querySelector(".style-fonts")?.remove(); svg.querySelector(".style-fonts")?.remove();
node.innerHTML = svg.outerHTML; node.innerHTML = svg.outerHTML;
})(); })();
+16 -17
View File
@@ -1,5 +1,5 @@
import React from "react"; import React from "react";
import { AppState, Device, ExcalidrawProps } from "../types"; import { AppState, Device, ExcalidrawProps, UIAppState } from "../types";
import { ActionManager } from "../actions/manager"; import { ActionManager } from "../actions/manager";
import { t } from "../i18n"; import { t } from "../i18n";
import Stack from "./Stack"; import Stack from "./Stack";
@@ -13,16 +13,15 @@ import { SelectedShapeActions, ShapesSwitcher } from "./Actions";
import { Section } from "./Section"; import { Section } from "./Section";
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 { 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 { HandButton } from "./HandButton"; import { HandButton } from "./HandButton";
import { isHandToolActive } from "../appState"; import { isHandToolActive } from "../appState";
import { useTunnels } from "./context/tunnels"; import { useTunnels } from "../context/tunnels";
type MobileMenuProps = { type MobileMenuProps = {
appState: AppState; appState: UIAppState;
actionManager: ActionManager; actionManager: ActionManager;
renderJSONExportDialog: () => React.ReactNode; renderJSONExportDialog: () => React.ReactNode;
renderImageExportDialog: () => React.ReactNode; renderImageExportDialog: () => React.ReactNode;
@@ -36,7 +35,7 @@ type MobileMenuProps = {
onImageAction: (data: { insertOnCanvasDirectly: boolean }) => void; onImageAction: (data: { insertOnCanvasDirectly: boolean }) => void;
renderTopRightUI?: ( renderTopRightUI?: (
isMobile: boolean, isMobile: boolean,
appState: AppState, appState: UIAppState,
) => JSX.Element | null; ) => JSX.Element | null;
renderCustomStats?: ExcalidrawProps["renderCustomStats"]; renderCustomStats?: ExcalidrawProps["renderCustomStats"];
renderSidebars: () => JSX.Element | null; renderSidebars: () => JSX.Element | null;
@@ -60,11 +59,15 @@ export const MobileMenu = ({
device, device,
renderWelcomeScreen, renderWelcomeScreen,
}: MobileMenuProps) => { }: MobileMenuProps) => {
const { welcomeScreenCenterTunnel, mainMenuTunnel } = useTunnels(); const {
WelcomeScreenCenterTunnel,
MainMenuTunnel,
DefaultSidebarTriggerTunnel,
} = useTunnels();
const renderToolbar = () => { const renderToolbar = () => {
return ( return (
<FixedSideContainer side="top" className="App-top-bar"> <FixedSideContainer side="top" className="App-top-bar">
{renderWelcomeScreen && <welcomeScreenCenterTunnel.Out />} {renderWelcomeScreen && <WelcomeScreenCenterTunnel.Out />}
<Section heading="shapes"> <Section heading="shapes">
{(heading: React.ReactNode) => ( {(heading: React.ReactNode) => (
<Stack.Col gap={4} align="center"> <Stack.Col gap={4} align="center">
@@ -88,11 +91,7 @@ export const MobileMenu = ({
{renderTopRightUI && renderTopRightUI(true, appState)} {renderTopRightUI && renderTopRightUI(true, appState)}
<div className="mobile-misc-tools-container"> <div className="mobile-misc-tools-container">
{!appState.viewModeEnabled && ( {!appState.viewModeEnabled && (
<LibraryButton <DefaultSidebarTriggerTunnel.Out />
appState={appState}
setAppState={setAppState}
isMobile
/>
)} )}
<PenModeButton <PenModeButton
checked={appState.penMode} checked={appState.penMode}
@@ -132,14 +131,14 @@ export const MobileMenu = ({
if (appState.viewModeEnabled) { if (appState.viewModeEnabled) {
return ( return (
<div className="App-toolbar-content"> <div className="App-toolbar-content">
<mainMenuTunnel.Out /> <MainMenuTunnel.Out />
</div> </div>
); );
} }
return ( return (
<div className="App-toolbar-content"> <div className="App-toolbar-content">
<mainMenuTunnel.Out /> <MainMenuTunnel.Out />
{actionManager.renderAction("toggleEditMenu")} {actionManager.renderAction("toggleEditMenu")}
{actionManager.renderAction("undo")} {actionManager.renderAction("undo")}
{actionManager.renderAction("redo")} {actionManager.renderAction("redo")}
@@ -190,13 +189,13 @@ export const MobileMenu = ({
{renderAppToolbar()} {renderAppToolbar()}
{appState.scrolledOutside && {appState.scrolledOutside &&
!appState.openMenu && !appState.openMenu &&
appState.openSidebar !== "library" && ( !appState.openSidebar && (
<button <button
className="scroll-back-to-content" className="scroll-back-to-content"
onClick={() => { onClick={() => {
setAppState({ setAppState((appState) => ({
...calculateScrollCenter(elements, appState, canvas), ...calculateScrollCenter(elements, appState, canvas),
}); }));
}} }}
> >
{t("buttons.scrollBackToContent")} {t("buttons.scrollBackToContent")}
+7 -6
View File
@@ -5,8 +5,10 @@ import { ChartElements, renderSpreadsheet, Spreadsheet } from "../charts";
import { ChartType } from "../element/types"; import { ChartType } from "../element/types";
import { t } from "../i18n"; import { t } from "../i18n";
import { exportToSvg } from "../scene/export"; import { exportToSvg } from "../scene/export";
import { AppState, LibraryItem } from "../types"; import { UIAppState } from "../types";
import { useApp } from "./App";
import { Dialog } from "./Dialog"; import { Dialog } from "./Dialog";
import "./PasteChartDialog.scss"; import "./PasteChartDialog.scss";
type OnInsertChart = (chartType: ChartType, elements: ChartElements) => void; type OnInsertChart = (chartType: ChartType, elements: ChartElements) => void;
@@ -78,13 +80,12 @@ export const PasteChartDialog = ({
setAppState, setAppState,
appState, appState,
onClose, onClose,
onInsertChart,
}: { }: {
appState: AppState; appState: UIAppState;
onClose: () => void; onClose: () => void;
setAppState: React.Component<any, AppState>["setState"]; setAppState: React.Component<any, UIAppState>["setState"];
onInsertChart: (elements: LibraryItem["elements"]) => void;
}) => { }) => {
const { onInsertElements } = useApp();
const handleClose = React.useCallback(() => { const handleClose = React.useCallback(() => {
if (onClose) { if (onClose) {
onClose(); onClose();
@@ -92,7 +93,7 @@ export const PasteChartDialog = ({
}, [onClose]); }, [onClose]);
const handleChartClick = (chartType: ChartType, elements: ChartElements) => { const handleChartClick = (chartType: ChartType, elements: ChartElements) => {
onInsertChart(elements); onInsertElements(elements);
trackEvent("magic", "chart", chartType); trackEvent("magic", "chart", chartType);
setAppState({ setAppState({
currentChartType: chartType, currentChartType: chartType,
+1
View File
@@ -3,5 +3,6 @@
position: absolute; position: absolute;
z-index: 10; z-index: 10;
padding: 5px 0 5px; padding: 5px 0 5px;
outline: none;
} }
} }
+66 -26
View File
@@ -29,13 +29,21 @@ export const Popover = ({
}: Props) => { }: Props) => {
const popoverRef = useRef<HTMLDivElement>(null); const popoverRef = useRef<HTMLDivElement>(null);
const container = popoverRef.current;
useEffect(() => { useEffect(() => {
const container = popoverRef.current;
if (!container) { if (!container) {
return; return;
} }
// focus popover only if the caller didn't focus on something else nested
// within the popover, which should take precedence. Fixes cases
// like color picker listening to keydown events on containers nested
// in the popover.
if (!container.contains(document.activeElement)) {
container.focus();
}
const handleKeyDown = (event: KeyboardEvent) => { const handleKeyDown = (event: KeyboardEvent) => {
if (event.key === KEYS.TAB) { if (event.key === KEYS.TAB) {
const focusableElements = queryFocusableElements(container); const focusableElements = queryFocusableElements(container);
@@ -44,15 +52,23 @@ export const Popover = ({
(element) => element === activeElement, (element) => element === activeElement,
); );
if (currentIndex === 0 && event.shiftKey) { if (activeElement === container) {
focusableElements[focusableElements.length - 1].focus(); if (event.shiftKey) {
focusableElements[focusableElements.length - 1]?.focus();
} else {
focusableElements[0].focus();
}
event.preventDefault();
event.stopImmediatePropagation();
} else if (currentIndex === 0 && event.shiftKey) {
focusableElements[focusableElements.length - 1]?.focus();
event.preventDefault(); event.preventDefault();
event.stopImmediatePropagation(); event.stopImmediatePropagation();
} else if ( } else if (
currentIndex === focusableElements.length - 1 && currentIndex === focusableElements.length - 1 &&
!event.shiftKey !event.shiftKey
) { ) {
focusableElements[0].focus(); focusableElements[0]?.focus();
event.preventDefault(); event.preventDefault();
event.stopImmediatePropagation(); event.stopImmediatePropagation();
} }
@@ -62,35 +78,59 @@ export const Popover = ({
container.addEventListener("keydown", handleKeyDown); container.addEventListener("keydown", handleKeyDown);
return () => container.removeEventListener("keydown", handleKeyDown); return () => container.removeEventListener("keydown", handleKeyDown);
}, [container]); }, []);
const lastInitializedPosRef = useRef<{ top: number; left: number } | null>(
null,
);
// ensure the popover doesn't overflow the viewport // ensure the popover doesn't overflow the viewport
useLayoutEffect(() => { useLayoutEffect(() => {
if (fitInViewport && popoverRef.current) { if (fitInViewport && popoverRef.current && top != null && left != null) {
const element = popoverRef.current; const container = popoverRef.current;
const { x, y, width, height } = element.getBoundingClientRect(); const { width, height } = container.getBoundingClientRect();
//Position correctly when clicked on rightmost part or the bottom part of viewport // hack for StrictMode so this effect only runs once for
if (x + width - offsetLeft > viewportWidth) { // the same top/left position, otherwise
element.style.left = `${viewportWidth - width - 10}px`; // we'd potentically reposition twice (once for viewport overflow)
} // and once for top/left position afterwards
if (y + height - offsetTop > viewportHeight) { if (
element.style.top = `${viewportHeight - height}px`; lastInitializedPosRef.current?.top === top &&
lastInitializedPosRef.current?.left === left
) {
return;
} }
lastInitializedPosRef.current = { top, left };
//Resize to fit viewport on smaller screens
if (height >= viewportHeight) {
element.style.height = `${viewportHeight - 20}px`;
element.style.top = "10px";
element.style.overflowY = "scroll";
}
if (width >= viewportWidth) { if (width >= viewportWidth) {
element.style.width = `${viewportWidth}px`; container.style.width = `${viewportWidth}px`;
element.style.left = "0px"; container.style.left = "0px";
element.style.overflowX = "scroll"; container.style.overflowX = "scroll";
} else if (left + width - offsetLeft > viewportWidth) {
container.style.left = `${viewportWidth - width - 10}px`;
} else {
container.style.left = `${left}px`;
}
if (height >= viewportHeight) {
container.style.height = `${viewportHeight - 20}px`;
container.style.top = "10px";
container.style.overflowY = "scroll";
} else if (top + height - offsetTop > viewportHeight) {
container.style.top = `${viewportHeight - height}px`;
} else {
container.style.top = `${top}px`;
} }
} }
}, [fitInViewport, viewportWidth, viewportHeight, offsetLeft, offsetTop]); }, [
top,
left,
fitInViewport,
viewportWidth,
viewportHeight,
offsetLeft,
offsetTop,
]);
useEffect(() => { useEffect(() => {
if (onCloseRequest) { if (onCloseRequest) {
@@ -105,7 +145,7 @@ export const Popover = ({
}, [onCloseRequest]); }, [onCloseRequest]);
return ( return (
<div className="popover" style={{ top, left }} ref={popoverRef}> <div className="popover" ref={popoverRef} tabIndex={-1}>
{children} {children}
</div> </div>
); );
+76
View File
@@ -93,4 +93,80 @@
display: block; display: block;
} }
} }
.single-library-item {
position: relative;
&-status {
position: absolute;
top: 0.3rem;
left: 0.3rem;
font-size: 0.7rem;
color: $oc-red-7;
background: rgba(255, 255, 255, 0.9);
padding: 0.1rem 0.2rem;
border-radius: 0.2rem;
}
&__svg {
background-color: $oc-white;
padding: 0.3rem;
width: 7.5rem;
height: 7.5rem;
border: 1px solid var(--button-gray-2);
svg {
width: 100%;
height: 100%;
}
}
.ToolIcon__icon {
background-color: $oc-white;
width: auto;
height: auto;
margin: 0 0.5rem;
}
.ToolIcon,
.ToolIcon_type_button:hover {
background-color: white;
}
.required,
.error {
color: $oc-red-8;
font-weight: bold;
font-size: 1rem;
margin: 0.2rem;
}
.error {
font-weight: 500;
margin: 0;
padding: 0.3em 0;
}
&--remove {
position: absolute;
top: 0.2rem;
right: 1rem;
.ToolIcon__icon {
margin: 0;
}
.ToolIcon__icon {
background-color: $oc-red-6;
&:hover {
background-color: $oc-red-7;
}
&:active {
background-color: $oc-red-8;
}
}
svg {
color: $oc-white;
padding: 0.26rem;
border-radius: 0.3em;
width: 1rem;
height: 1rem;
}
}
}
} }
+138 -34
View File
@@ -1,11 +1,12 @@
import { ReactNode, useCallback, useEffect, useState } from "react"; import { ReactNode, useCallback, useEffect, useRef, useState } from "react";
import OpenColor from "open-color"; import OpenColor from "open-color";
import { Dialog } from "./Dialog"; import { Dialog } from "./Dialog";
import { t } from "../i18n"; import { t } from "../i18n";
import Trans from "./Trans";
import { AppState, LibraryItems, LibraryItem } from "../types"; import { LibraryItems, LibraryItem, UIAppState } from "../types";
import { exportToCanvas } from "../packages/utils"; import { exportToCanvas, exportToSvg } from "../packages/utils";
import { import {
EXPORT_DATA_TYPES, EXPORT_DATA_TYPES,
EXPORT_SOURCE, EXPORT_SOURCE,
@@ -13,12 +14,13 @@ import {
VERSIONS, VERSIONS,
} from "../constants"; } from "../constants";
import { ExportedLibraryData } from "../data/types"; import { ExportedLibraryData } from "../data/types";
import "./PublishLibrary.scss";
import SingleLibraryItem from "./SingleLibraryItem";
import { canvasToBlob, resizeImageFile } from "../data/blob"; import { canvasToBlob, resizeImageFile } from "../data/blob";
import { chunk } from "../utils"; import { chunk } from "../utils";
import DialogActionButton from "./DialogActionButton"; import DialogActionButton from "./DialogActionButton";
import { CloseIcon } from "./icons";
import { ToolButton } from "./ToolButton";
import "./PublishLibrary.scss";
interface PublishLibraryDataParams { interface PublishLibraryDataParams {
authorName: string; authorName: string;
@@ -126,6 +128,99 @@ const generatePreviewImage = async (libraryItems: LibraryItems) => {
); );
}; };
const SingleLibraryItem = ({
libItem,
appState,
index,
onChange,
onRemove,
}: {
libItem: LibraryItem;
appState: UIAppState;
index: number;
onChange: (val: string, index: number) => void;
onRemove: (id: string) => void;
}) => {
const svgRef = useRef<HTMLDivElement | null>(null);
const inputRef = useRef<HTMLInputElement | null>(null);
useEffect(() => {
const node = svgRef.current;
if (!node) {
return;
}
(async () => {
const svg = await exportToSvg({
elements: libItem.elements,
appState: {
...appState,
viewBackgroundColor: OpenColor.white,
exportBackground: true,
},
files: null,
});
node.innerHTML = svg.outerHTML;
})();
}, [libItem.elements, appState]);
return (
<div className="single-library-item">
{libItem.status === "published" && (
<span className="single-library-item-status">
{t("labels.statusPublished")}
</span>
)}
<div ref={svgRef} className="single-library-item__svg" />
<ToolButton
aria-label={t("buttons.remove")}
type="button"
icon={CloseIcon}
className="single-library-item--remove"
onClick={onRemove.bind(null, libItem.id)}
title={t("buttons.remove")}
/>
<div
style={{
display: "flex",
margin: "0.8rem 0",
width: "100%",
fontSize: "14px",
fontWeight: 500,
flexDirection: "column",
}}
>
<label
style={{
display: "flex",
justifyContent: "space-between",
flexDirection: "column",
}}
>
<div style={{ padding: "0.5em 0" }}>
<span style={{ fontWeight: 500, color: OpenColor.gray[6] }}>
{t("publishDialog.itemName")}
</span>
<span aria-hidden="true" className="required">
*
</span>
</div>
<input
type="text"
ref={inputRef}
style={{ width: "80%", padding: "0.2rem" }}
defaultValue={libItem.name}
placeholder="Item name"
onChange={(event) => {
onChange(event.target.value, index);
}}
/>
</label>
<span className="error">{libItem.error}</span>
</div>
</div>
);
};
const PublishLibrary = ({ const PublishLibrary = ({
onClose, onClose,
libraryItems, libraryItems,
@@ -137,7 +232,7 @@ const PublishLibrary = ({
}: { }: {
onClose: () => void; onClose: () => void;
libraryItems: LibraryItems; libraryItems: LibraryItems;
appState: AppState; appState: UIAppState;
onSuccess: (data: { onSuccess: (data: {
url: string; url: string;
authorName: string; authorName: string;
@@ -308,26 +403,32 @@ const PublishLibrary = ({
{shouldRenderForm ? ( {shouldRenderForm ? (
<form onSubmit={onSubmit}> <form onSubmit={onSubmit}>
<div className="publish-library-note"> <div className="publish-library-note">
{t("publishDialog.noteDescription.pre")} <Trans
<a i18nKey="publishDialog.noteDescription"
href="https://libraries.excalidraw.com" link={(el) => (
target="_blank" <a
rel="noopener noreferrer" href="https://libraries.excalidraw.com"
> target="_blank"
{t("publishDialog.noteDescription.link")} rel="noopener noreferrer"
</a>{" "} >
{t("publishDialog.noteDescription.post")} {el}
</a>
)}
/>
</div> </div>
<span className="publish-library-note"> <span className="publish-library-note">
{t("publishDialog.noteGuidelines.pre")} <Trans
<a i18nKey="publishDialog.noteGuidelines"
href="https://github.com/excalidraw/excalidraw-libraries#guidelines" link={(el) => (
target="_blank" <a
rel="noopener noreferrer" href="https://github.com/excalidraw/excalidraw-libraries#guidelines"
> target="_blank"
{t("publishDialog.noteGuidelines.link")} rel="noopener noreferrer"
</a> >
{t("publishDialog.noteGuidelines.post")} {el}
</a>
)}
/>
</span> </span>
<div className="publish-library-note"> <div className="publish-library-note">
@@ -421,15 +522,18 @@ const PublishLibrary = ({
/> />
</label> </label>
<span className="publish-library-note"> <span className="publish-library-note">
{t("publishDialog.noteLicense.pre")} <Trans
<a i18nKey="publishDialog.noteLicense"
href="https://github.com/excalidraw/excalidraw-libraries/blob/main/LICENSE" link={(el) => (
target="_blank" <a
rel="noopener noreferrer" href="https://github.com/excalidraw/excalidraw-libraries/blob/main/LICENSE"
> target="_blank"
{t("publishDialog.noteLicense.link")} rel="noopener noreferrer"
</a> >
{t("publishDialog.noteLicense.post")} {el}
</a>
)}
/>
</span> </span>
</div> </div>
<div className="publish-library__buttons"> <div className="publish-library__buttons">
+126 -85
View File
@@ -2,67 +2,26 @@
@import "../../css/variables.module"; @import "../../css/variables.module";
.excalidraw { .excalidraw {
.Sidebar { .sidebar {
&__close-btn, display: flex;
&__pin-btn, flex-direction: column;
&__dropdown-btn {
@include outlineButtonStyles;
width: var(--lg-button-size);
height: var(--lg-button-size);
padding: 0;
svg {
width: var(--lg-icon-size);
height: var(--lg-icon-size);
}
}
&__pin-btn {
&--pinned {
background-color: var(--color-primary);
border-color: var(--color-primary);
svg {
color: #fff;
}
&:hover,
&:active {
background-color: var(--color-primary-darker);
}
}
}
}
&.theme--dark {
.Sidebar {
&__pin-btn {
&--pinned {
svg {
color: var(--color-gray-90);
}
}
}
}
}
.layer-ui__sidebar {
position: absolute; position: absolute;
top: 0; top: 0;
bottom: 0; bottom: 0;
right: 0; right: 0;
z-index: 5; z-index: 5;
margin: 0; margin: 0;
padding: 0;
box-sizing: border-box;
background-color: var(--sidebar-bg-color);
box-shadow: var(--sidebar-shadow);
:root[dir="rtl"] & { :root[dir="rtl"] & {
left: 0; left: 0;
right: auto; right: auto;
} }
background-color: var(--sidebar-bg-color);
box-shadow: var(--sidebar-shadow);
&--docked { &--docked {
box-shadow: none; box-shadow: none;
} }
@@ -77,52 +36,134 @@
border-right: 1px solid var(--sidebar-border-color); border-right: 1px solid var(--sidebar-border-color);
border-left: 0; border-left: 0;
} }
padding: 0;
box-sizing: border-box;
.Island {
box-shadow: none;
}
.ToolIcon__icon {
border-radius: var(--border-radius-md);
}
.ToolIcon__icon__close {
.Modal__close {
width: calc(var(--space-factor) * 7);
height: calc(var(--space-factor) * 7);
display: flex;
justify-content: center;
align-items: center;
color: var(--color-text);
}
}
.Island {
--padding: 0;
background-color: var(--island-bg-color);
border-radius: var(--border-radius-lg);
padding: calc(var(--padding) * var(--space-factor));
position: relative;
transition: box-shadow 0.5s ease-in-out;
}
} }
.layer-ui__sidebar__header { // ---------------------------- sidebar header ------------------------------
.sidebar__header {
box-sizing: border-box; box-sizing: border-box;
display: flex; display: flex;
justify-content: space-between; justify-content: space-between;
align-items: center; align-items: center;
width: 100%; width: 100%;
padding: 1rem; padding-top: 1rem;
border-bottom: 1px solid var(--sidebar-border-color); padding-bottom: 1rem;
} }
.layer-ui__sidebar__header__buttons { .sidebar__header__buttons {
gap: 0;
display: flex; display: flex;
align-items: center; align-items: center;
gap: 0.625rem; margin-left: auto;
button {
@include outlineButtonStyles;
--button-bg: transparent;
border: 0 !important;
width: var(--lg-button-size);
height: var(--lg-button-size);
padding: 0;
svg {
width: var(--lg-icon-size);
height: var(--lg-icon-size);
}
&:hover {
background: var(--button-hover-bg, var(--island-bg-color));
}
}
.sidebar__dock.selected {
svg {
stroke: var(--color-primary);
fill: var(--color-primary);
}
}
}
// ---------------------------- sidebar tabs ------------------------------
.sidebar-tabs-root {
display: flex;
flex-direction: column;
flex: 1 1 auto;
padding: 1rem 0.75rem;
[role="tabpanel"] {
flex: 1;
outline: none;
flex: 1 1 auto;
display: flex;
flex-direction: column;
outline: none;
}
[role="tabpanel"][data-state="inactive"] {
display: none !important;
}
[role="tablist"] {
display: grid;
gap: 1rem;
grid-template-columns: repeat(auto-fit, minmax(0, 1fr));
}
}
.sidebar-tabs-root > .sidebar__header {
padding-top: 0;
padding-bottom: 1rem;
}
.sidebar-tab-trigger {
--button-width: auto;
--button-bg: transparent;
--button-hover-bg: transparent;
--button-active-bg: var(--color-primary);
--button-hover-color: var(--color-primary);
--button-hover-border: var(--color-primary);
&[data-state="active"] {
--button-bg: var(--color-primary);
--button-hover-bg: var(--color-primary-darker);
--button-hover-color: var(--color-icon-white);
--button-border: var(--color-primary);
color: var(--color-icon-white);
}
}
// ---------------------------- default sidebar ------------------------------
.default-sidebar {
display: flex;
flex-direction: column;
.sidebar-triggers {
$padding: 2px;
$border: 1px;
display: flex;
gap: 0;
padding: $padding;
// offset by padding + border to vertically center the list with sibling
// buttons (both from top and bototm, due to flex layout)
margin-top: -#{$padding + $border};
margin-bottom: -#{$padding + $border};
border: $border solid var(--sidebar-border-color);
background: var(--default-bg-color);
border-radius: 0.625rem;
.sidebar-tab-trigger {
height: var(--lg-button-size);
width: var(--lg-button-size);
border: none;
}
}
.sidebar__header {
border-bottom: 1px solid var(--sidebar-border-color);
}
} }
} }
+274 -298
View File
@@ -1,8 +1,9 @@
import React from "react"; import React from "react";
import { DEFAULT_SIDEBAR } from "../../constants";
import { Excalidraw, Sidebar } from "../../packages/excalidraw/index"; import { Excalidraw, Sidebar } from "../../packages/excalidraw/index";
import { import {
act,
fireEvent, fireEvent,
GlobalTestState,
queryAllByTestId, queryAllByTestId,
queryByTestId, queryByTestId,
render, render,
@@ -10,346 +11,321 @@ import {
withExcalidrawDimensions, withExcalidrawDimensions,
} from "../../tests/test-utils"; } from "../../tests/test-utils";
export const assertSidebarDockButton = async <T extends boolean>(
hasDockButton: T,
): Promise<
T extends false
? { dockButton: null; sidebar: HTMLElement }
: { dockButton: HTMLElement; sidebar: HTMLElement }
> => {
const sidebar =
GlobalTestState.renderResult.container.querySelector<HTMLElement>(
".sidebar",
);
expect(sidebar).not.toBe(null);
const dockButton = queryByTestId(sidebar!, "sidebar-dock");
if (hasDockButton) {
expect(dockButton).not.toBe(null);
return { dockButton: dockButton!, sidebar: sidebar! } as any;
}
expect(dockButton).toBe(null);
return { dockButton: null, sidebar: sidebar! } as any;
};
export const assertExcalidrawWithSidebar = async (
sidebar: React.ReactNode,
name: string,
test: () => void,
) => {
await render(
<Excalidraw initialData={{ appState: { openSidebar: { name } } }}>
{sidebar}
</Excalidraw>,
);
await withExcalidrawDimensions({ width: 1920, height: 1080 }, test);
};
describe("Sidebar", () => { describe("Sidebar", () => {
it("should render custom sidebar", async () => { describe("General behavior", () => {
const { container } = await render( it("should render custom sidebar", async () => {
<Excalidraw const { container } = await render(
initialData={{ appState: { openSidebar: "customSidebar" } }} <Excalidraw
renderSidebar={() => ( initialData={{ appState: { openSidebar: { name: "customSidebar" } } }}
<Sidebar> >
<Sidebar name="customSidebar">
<div id="test-sidebar-content">42</div> <div id="test-sidebar-content">42</div>
</Sidebar> </Sidebar>
)} </Excalidraw>,
/>, );
);
const node = container.querySelector("#test-sidebar-content"); const node = container.querySelector("#test-sidebar-content");
expect(node).not.toBe(null); expect(node).not.toBe(null);
});
it("should render only one sidebar and prefer the custom one", async () => {
const { container } = await render(
<Excalidraw
initialData={{ appState: { openSidebar: { name: "customSidebar" } } }}
>
<Sidebar name="customSidebar">
<div id="test-sidebar-content">42</div>
</Sidebar>
</Excalidraw>,
);
await waitFor(() => {
// make sure the custom sidebar is rendered
const node = container.querySelector("#test-sidebar-content");
expect(node).not.toBe(null);
// make sure only one sidebar is rendered
const sidebars = container.querySelectorAll(".sidebar");
expect(sidebars.length).toBe(1);
});
});
it("should toggle sidebar using props.toggleMenu()", async () => {
const { container } = await render(
<Excalidraw>
<Sidebar name="customSidebar">
<div id="test-sidebar-content">42</div>
</Sidebar>
</Excalidraw>,
);
// sidebar isn't rendered initially
// -------------------------------------------------------------------------
await waitFor(() => {
const node = container.querySelector("#test-sidebar-content");
expect(node).toBe(null);
});
// toggle sidebar on
// -------------------------------------------------------------------------
expect(window.h.app.toggleSidebar({ name: "customSidebar" })).toBe(true);
await waitFor(() => {
const node = container.querySelector("#test-sidebar-content");
expect(node).not.toBe(null);
});
// toggle sidebar off
// -------------------------------------------------------------------------
expect(window.h.app.toggleSidebar({ name: "customSidebar" })).toBe(false);
await waitFor(() => {
const node = container.querySelector("#test-sidebar-content");
expect(node).toBe(null);
});
// force-toggle sidebar off (=> still hidden)
// -------------------------------------------------------------------------
expect(
window.h.app.toggleSidebar({ name: "customSidebar", force: false }),
).toBe(false);
await waitFor(() => {
const node = container.querySelector("#test-sidebar-content");
expect(node).toBe(null);
});
// force-toggle sidebar on
// -------------------------------------------------------------------------
expect(
window.h.app.toggleSidebar({ name: "customSidebar", force: true }),
).toBe(true);
expect(
window.h.app.toggleSidebar({ name: "customSidebar", force: true }),
).toBe(true);
await waitFor(() => {
const node = container.querySelector("#test-sidebar-content");
expect(node).not.toBe(null);
});
// toggle library (= hide custom sidebar)
// -------------------------------------------------------------------------
expect(window.h.app.toggleSidebar({ name: DEFAULT_SIDEBAR.name })).toBe(
true,
);
await waitFor(() => {
const node = container.querySelector("#test-sidebar-content");
expect(node).toBe(null);
// make sure only one sidebar is rendered
const sidebars = container.querySelectorAll(".sidebar");
expect(sidebars.length).toBe(1);
});
});
}); });
it("should render custom sidebar header", async () => { describe("<Sidebar.Header/>", () => {
const { container } = await render( it("should render custom sidebar header", async () => {
<Excalidraw const { container } = await render(
initialData={{ appState: { openSidebar: "customSidebar" } }} <Excalidraw
renderSidebar={() => ( initialData={{ appState: { openSidebar: { name: "customSidebar" } } }}
<Sidebar> >
<Sidebar name="customSidebar">
<Sidebar.Header> <Sidebar.Header>
<div id="test-sidebar-header-content">42</div> <div id="test-sidebar-header-content">42</div>
</Sidebar.Header> </Sidebar.Header>
</Sidebar> </Sidebar>
)} </Excalidraw>,
/>, );
);
const node = container.querySelector("#test-sidebar-header-content"); const node = container.querySelector("#test-sidebar-header-content");
expect(node).not.toBe(null);
// make sure we don't render the default fallback header,
// just the custom one
expect(queryAllByTestId(container, "sidebar-header").length).toBe(1);
});
it("should render only one sidebar and prefer the custom one", async () => {
const { container } = await render(
<Excalidraw
initialData={{ appState: { openSidebar: "customSidebar" } }}
renderSidebar={() => (
<Sidebar>
<div id="test-sidebar-content">42</div>
</Sidebar>
)}
/>,
);
await waitFor(() => {
// make sure the custom sidebar is rendered
const node = container.querySelector("#test-sidebar-content");
expect(node).not.toBe(null); expect(node).not.toBe(null);
// make sure we don't render the default fallback header,
// make sure only one sidebar is rendered // just the custom one
const sidebars = container.querySelectorAll(".layer-ui__sidebar"); expect(queryAllByTestId(container, "sidebar-header").length).toBe(1);
expect(sidebars.length).toBe(1);
}); });
});
it("should always render custom sidebar with close button & close on click", async () => { it("should not render <Sidebar.Header> for custom sidebars by default", async () => {
const onClose = jest.fn(); const CustomExcalidraw = () => {
const CustomExcalidraw = () => { return (
return ( <Excalidraw
<Excalidraw initialData={{
initialData={{ appState: { openSidebar: "customSidebar" } }} appState: { openSidebar: { name: "customSidebar" } },
renderSidebar={() => ( }}
<Sidebar className="test-sidebar" onClose={onClose}> >
<Sidebar name="customSidebar" className="test-sidebar">
hello hello
</Sidebar> </Sidebar>
)} </Excalidraw>
/> );
); };
};
const { container } = await render(<CustomExcalidraw />); const { container } = await render(<CustomExcalidraw />);
const sidebar = container.querySelector<HTMLElement>(".test-sidebar");
expect(sidebar).not.toBe(null);
const closeButton = queryByTestId(sidebar!, "sidebar-close")!;
expect(closeButton).not.toBe(null);
fireEvent.click(closeButton);
await waitFor(() => {
expect(container.querySelector<HTMLElement>(".test-sidebar")).toBe(null);
expect(onClose).toHaveBeenCalled();
});
});
it("should render custom sidebar with dock (irrespective of onDock prop)", async () => {
const CustomExcalidraw = () => {
return (
<Excalidraw
initialData={{ appState: { openSidebar: "customSidebar" } }}
renderSidebar={() => (
<Sidebar className="test-sidebar">hello</Sidebar>
)}
/>
);
};
const { container } = await render(<CustomExcalidraw />);
// should show dock button when the sidebar fits to be docked
// -------------------------------------------------------------------------
await withExcalidrawDimensions({ width: 1920, height: 1080 }, () => {
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-dock"); const closeButton = queryByTestId(sidebar!, "sidebar-close");
expect(closeButton).not.toBe(null);
});
// should not show dock button when the sidebar does not fit to be docked
// -------------------------------------------------------------------------
await withExcalidrawDimensions({ width: 400, height: 1080 }, () => {
const sidebar = container.querySelector<HTMLElement>(".test-sidebar");
expect(sidebar).not.toBe(null);
const closeButton = queryByTestId(sidebar!, "sidebar-dock");
expect(closeButton).toBe(null); expect(closeButton).toBe(null);
}); });
});
it("should support controlled docking", async () => { it("<Sidebar.Header> should render close button", async () => {
let _setDockable: (dockable: boolean) => void = null!; const onStateChange = jest.fn();
const CustomExcalidraw = () => {
const CustomExcalidraw = () => { return (
const [dockable, setDockable] = React.useState(false); <Excalidraw
_setDockable = setDockable; initialData={{
return ( appState: { openSidebar: { name: "customSidebar" } },
<Excalidraw }}
initialData={{ appState: { openSidebar: "customSidebar" } }} >
renderSidebar={() => (
<Sidebar <Sidebar
name="customSidebar"
className="test-sidebar" className="test-sidebar"
docked={false} onStateChange={onStateChange}
dockable={dockable}
> >
hello <Sidebar.Header />
</Sidebar> </Sidebar>
)} </Excalidraw>
/> );
); };
};
const { container } = await render(<CustomExcalidraw />); const { container } = await render(<CustomExcalidraw />);
await withExcalidrawDimensions({ width: 1920, height: 1080 }, async () => { // initial open
// should not show dock button when `dockable` is `false` expect(onStateChange).toHaveBeenCalledWith({ name: "customSidebar" });
// -------------------------------------------------------------------------
act(() => { const sidebar = container.querySelector<HTMLElement>(".test-sidebar");
_setDockable(false); expect(sidebar).not.toBe(null);
}); const closeButton = queryByTestId(sidebar!, "sidebar-close")!;
expect(closeButton).not.toBe(null);
fireEvent.click(closeButton);
await waitFor(() => { await waitFor(() => {
const sidebar = container.querySelector<HTMLElement>(".test-sidebar"); expect(container.querySelector<HTMLElement>(".test-sidebar")).toBe(
expect(sidebar).not.toBe(null); null,
const closeButton = queryByTestId(sidebar!, "sidebar-dock"); );
expect(closeButton).toBe(null); expect(onStateChange).toHaveBeenCalledWith(null);
});
// should show dock button when `dockable` is `true`, even if `docked`
// prop is set
// -------------------------------------------------------------------------
act(() => {
_setDockable(true);
});
await waitFor(() => {
const sidebar = container.querySelector<HTMLElement>(".test-sidebar");
expect(sidebar).not.toBe(null);
const closeButton = queryByTestId(sidebar!, "sidebar-dock");
expect(closeButton).not.toBe(null);
}); });
}); });
}); });
it("should support controlled docking", async () => { describe("Docking behavior", () => {
let _setDocked: (docked?: boolean) => void = null!; it("shouldn't be user-dockable if `onDock` not supplied", async () => {
await assertExcalidrawWithSidebar(
<Sidebar name="customSidebar">
<Sidebar.Header />
</Sidebar>,
"customSidebar",
async () => {
await assertSidebarDockButton(false);
},
);
});
const CustomExcalidraw = () => { it("shouldn't be user-dockable if `onDock` not supplied & `docked={true}`", async () => {
const [docked, setDocked] = React.useState<boolean | undefined>(); await assertExcalidrawWithSidebar(
_setDocked = setDocked; <Sidebar name="customSidebar" docked={true}>
return ( <Sidebar.Header />
</Sidebar>,
"customSidebar",
async () => {
await assertSidebarDockButton(false);
},
);
});
it("shouldn't be user-dockable if `onDock` not supplied & docked={false}`", async () => {
await assertExcalidrawWithSidebar(
<Sidebar name="customSidebar" docked={false}>
<Sidebar.Header />
</Sidebar>,
"customSidebar",
async () => {
await assertSidebarDockButton(false);
},
);
});
it("should be user-dockable when both `onDock` and `docked` supplied", async () => {
await render(
<Excalidraw <Excalidraw
initialData={{ appState: { openSidebar: "customSidebar" } }} initialData={{ appState: { openSidebar: { name: "customSidebar" } } }}
renderSidebar={() => ( >
<Sidebar className="test-sidebar" docked={docked}> <Sidebar
hello name="customSidebar"
</Sidebar> className="test-sidebar"
)} onDock={() => {}}
/> docked
); >
}; <Sidebar.Header />
const { container } = await render(<CustomExcalidraw />);
const { h } = window;
await withExcalidrawDimensions({ width: 1920, height: 1080 }, async () => {
const dockButton = await waitFor(() => {
const sidebar = container.querySelector<HTMLElement>(".test-sidebar");
expect(sidebar).not.toBe(null);
const dockBotton = queryByTestId(sidebar!, "sidebar-dock");
expect(dockBotton).not.toBe(null);
return dockBotton!;
});
const dockButtonInput = dockButton.querySelector("input")!;
// should not show dock button when `dockable` is `false`
// -------------------------------------------------------------------------
expect(h.state.isSidebarDocked).toBe(false);
fireEvent.click(dockButtonInput);
await waitFor(() => {
expect(h.state.isSidebarDocked).toBe(true);
expect(dockButtonInput).toBeChecked();
});
fireEvent.click(dockButtonInput);
await waitFor(() => {
expect(h.state.isSidebarDocked).toBe(false);
expect(dockButtonInput).not.toBeChecked();
});
// shouldn't update `appState.isSidebarDocked` when the sidebar
// is controlled (`docked` prop is set), as host apps should handle
// the state themselves
// -------------------------------------------------------------------------
act(() => {
_setDocked(true);
});
await waitFor(() => {
expect(dockButtonInput).toBeChecked();
expect(h.state.isSidebarDocked).toBe(false);
expect(dockButtonInput).toBeChecked();
});
fireEvent.click(dockButtonInput);
await waitFor(() => {
expect(h.state.isSidebarDocked).toBe(false);
expect(dockButtonInput).toBeChecked();
});
// the `appState.isSidebarDocked` should remain untouched when
// `props.docked` is set to `false`, and user toggles
// -------------------------------------------------------------------------
act(() => {
_setDocked(false);
h.setState({ isSidebarDocked: true });
});
await waitFor(() => {
expect(h.state.isSidebarDocked).toBe(true);
expect(dockButtonInput).not.toBeChecked();
});
fireEvent.click(dockButtonInput);
await waitFor(() => {
expect(dockButtonInput).not.toBeChecked();
expect(h.state.isSidebarDocked).toBe(true);
});
});
});
it("should toggle sidebar using props.toggleMenu()", async () => {
const { container } = await render(
<Excalidraw
renderSidebar={() => (
<Sidebar>
<div id="test-sidebar-content">42</div>
</Sidebar> </Sidebar>
)} </Excalidraw>,
/>, );
);
// sidebar isn't rendered initially await withExcalidrawDimensions(
// ------------------------------------------------------------------------- { width: 1920, height: 1080 },
await waitFor(() => { async () => {
const node = container.querySelector("#test-sidebar-content"); await assertSidebarDockButton(true);
expect(node).toBe(null); },
);
}); });
// toggle sidebar on it("shouldn't be user-dockable when only `onDock` supplied w/o `docked`", async () => {
// ------------------------------------------------------------------------- await render(
expect(window.h.app.toggleMenu("customSidebar")).toBe(true); <Excalidraw
initialData={{ appState: { openSidebar: { name: "customSidebar" } } }}
>
<Sidebar
name="customSidebar"
className="test-sidebar"
onDock={() => {}}
>
<Sidebar.Header />
</Sidebar>
</Excalidraw>,
);
await waitFor(() => { await withExcalidrawDimensions(
const node = container.querySelector("#test-sidebar-content"); { width: 1920, height: 1080 },
expect(node).not.toBe(null); async () => {
}); await assertSidebarDockButton(false);
},
// toggle sidebar off );
// -------------------------------------------------------------------------
expect(window.h.app.toggleMenu("customSidebar")).toBe(false);
await waitFor(() => {
const node = container.querySelector("#test-sidebar-content");
expect(node).toBe(null);
});
// force-toggle sidebar off (=> still hidden)
// -------------------------------------------------------------------------
expect(window.h.app.toggleMenu("customSidebar", false)).toBe(false);
await waitFor(() => {
const node = container.querySelector("#test-sidebar-content");
expect(node).toBe(null);
});
// force-toggle sidebar on
// -------------------------------------------------------------------------
expect(window.h.app.toggleMenu("customSidebar", true)).toBe(true);
expect(window.h.app.toggleMenu("customSidebar", true)).toBe(true);
await waitFor(() => {
const node = container.querySelector("#test-sidebar-content");
expect(node).not.toBe(null);
});
// toggle library (= hide custom sidebar)
// -------------------------------------------------------------------------
expect(window.h.app.toggleMenu("library")).toBe(true);
await waitFor(() => {
const node = container.querySelector("#test-sidebar-content");
expect(node).toBe(null);
// make sure only one sidebar is rendered
const sidebars = container.querySelectorAll(".layer-ui__sidebar");
expect(sidebars.length).toBe(1);
}); });
}); });
}); });
+225 -130
View File
@@ -1,151 +1,246 @@
import { import React, {
useEffect, useEffect,
useLayoutEffect, useLayoutEffect,
useRef, useRef,
useState, useState,
forwardRef, forwardRef,
useImperativeHandle,
useCallback,
RefObject,
} from "react"; } from "react";
import { Island } from ".././Island"; import { Island } from ".././Island";
import { atom, useAtom } from "jotai"; import { atom, useSetAtom } from "jotai";
import { jotaiScope } from "../../jotai"; import { jotaiScope } from "../../jotai";
import { import {
SidebarPropsContext, SidebarPropsContext,
SidebarProps, SidebarProps,
SidebarPropsContextValue, SidebarPropsContextValue,
} from "./common"; } from "./common";
import { SidebarHeader } from "./SidebarHeader";
import { SidebarHeaderComponents } from "./SidebarHeader"; import clsx from "clsx";
import { useDevice, useExcalidrawSetAppState } from "../App";
import { updateObject } from "../../utils";
import { KEYS } from "../../keys";
import { EVENT } from "../../constants";
import { SidebarTrigger } from "./SidebarTrigger";
import { SidebarTabTriggers } from "./SidebarTabTriggers";
import { SidebarTabTrigger } from "./SidebarTabTrigger";
import { SidebarTabs } from "./SidebarTabs";
import { SidebarTab } from "./SidebarTab";
import "./Sidebar.scss"; import "./Sidebar.scss";
import clsx from "clsx"; import { useUIAppState } from "../../context/ui-appState";
import { useExcalidrawSetAppState } from "../App";
import { updateObject } from "../../utils";
/** using a counter instead of boolean to handle race conditions where // FIXME replace this with the implem from ColorPicker once it's merged
* the host app may render (mount/unmount) multiple different sidebar */ const useOnClickOutside = (
export const hostSidebarCountersAtom = atom({ rendered: 0, docked: 0 }); ref: RefObject<HTMLElement>,
cb: (event: MouseEvent) => void,
export const Sidebar = Object.assign( ) => {
forwardRef( useEffect(() => {
( const listener = (event: MouseEvent) => {
{ if (!ref.current) {
children, return;
onClose,
onDock,
docked,
/** Undocumented, may be removed later. Generally should either be
* `props.docked` or `appState.isSidebarDocked`. Currently serves to
* prevent unwanted animation of the shadow if initially docked. */
//
// NOTE we'll want to remove this after we sort out how to subscribe to
// individual appState properties
initialDockedState = docked,
dockable = true,
className,
__isInternal,
}: SidebarProps<{
// NOTE sidebars we use internally inside the editor must have this flag set.
// It indicates that this sidebar should have lower precedence over host
// sidebars, if both are open.
/** @private internal */
__isInternal?: boolean;
}>,
ref: React.ForwardedRef<HTMLDivElement>,
) => {
const [hostSidebarCounters, setHostSidebarCounters] = useAtom(
hostSidebarCountersAtom,
jotaiScope,
);
const setAppState = useExcalidrawSetAppState();
const [isDockedFallback, setIsDockedFallback] = useState(
docked ?? initialDockedState ?? false,
);
useLayoutEffect(() => {
if (docked === undefined) {
// ugly hack to get initial state out of AppState without subscribing
// to it as a whole (once we have granular subscriptions, we'll move
// to that)
//
// NOTE this means that is updated `state.isSidebarDocked` changes outside
// of this compoent, it won't be reflected here. Currently doesn't happen.
setAppState((state) => {
setIsDockedFallback(state.isSidebarDocked);
// bail from update
return null;
});
}
}, [setAppState, docked]);
useLayoutEffect(() => {
if (!__isInternal) {
setHostSidebarCounters((s) => ({
rendered: s.rendered + 1,
docked: isDockedFallback ? s.docked + 1 : s.docked,
}));
return () => {
setHostSidebarCounters((s) => ({
rendered: s.rendered - 1,
docked: isDockedFallback ? s.docked - 1 : s.docked,
}));
};
}
}, [__isInternal, setHostSidebarCounters, isDockedFallback]);
const onCloseRef = useRef(onClose);
onCloseRef.current = onClose;
useEffect(() => {
return () => {
onCloseRef.current?.();
};
}, []);
const headerPropsRef = useRef<SidebarPropsContextValue>({});
headerPropsRef.current.onClose = () => {
setAppState({ openSidebar: null });
};
headerPropsRef.current.onDock = (isDocked) => {
if (docked === undefined) {
setAppState({ isSidebarDocked: isDocked });
setIsDockedFallback(isDocked);
}
onDock?.(isDocked);
};
// renew the ref object if the following props change since we want to
// rerender. We can't pass down as component props manually because
// the <Sidebar.Header/> can be rendered upsream.
headerPropsRef.current = updateObject(headerPropsRef.current, {
docked: docked ?? isDockedFallback,
dockable,
});
if (hostSidebarCounters.rendered > 0 && __isInternal) {
return null;
} }
return ( if (
<Island event.target instanceof Element &&
className={clsx( (ref.current.contains(event.target) ||
"layer-ui__sidebar", !document.body.contains(event.target))
{ "layer-ui__sidebar--docked": isDockedFallback }, ) {
className, return;
)} }
ref={ref}
> cb(event);
<SidebarPropsContext.Provider value={headerPropsRef.current}> };
<SidebarHeaderComponents.Context> document.addEventListener("pointerdown", listener, false);
<SidebarHeaderComponents.Component __isFallback />
{children} return () => {
</SidebarHeaderComponents.Context> document.removeEventListener("pointerdown", listener);
</SidebarPropsContext.Provider> };
</Island> }, [ref, cb]);
};
/**
* Flags whether the currently rendered Sidebar is docked or not, for use
* in upstream components that need to act on this (e.g. LayerUI to shift the
* UI). We use an atom because of potential host app sidebars (for the default
* sidebar we could just read from appState.defaultSidebarDockedPreference).
*
* Since we can only render one Sidebar at a time, we can use a simple flag.
*/
export const isSidebarDockedAtom = atom(false);
export const SidebarInner = forwardRef(
(
{
name,
children,
onDock,
docked,
className,
...rest
}: SidebarProps & Omit<React.RefAttributes<HTMLDivElement>, "onSelect">,
ref: React.ForwardedRef<HTMLDivElement>,
) => {
if (process.env.NODE_ENV === "development" && onDock && docked == null) {
console.warn(
"Sidebar: `docked` must be set when `onDock` is supplied for the sidebar to be user-dockable. To hide this message, either pass `docked` or remove `onDock`",
); );
}, }
),
{ const setAppState = useExcalidrawSetAppState();
Header: SidebarHeaderComponents.Component,
const setIsSidebarDockedAtom = useSetAtom(isSidebarDockedAtom, jotaiScope);
useLayoutEffect(() => {
setIsSidebarDockedAtom(!!docked);
return () => {
setIsSidebarDockedAtom(false);
};
}, [setIsSidebarDockedAtom, docked]);
const headerPropsRef = useRef<SidebarPropsContextValue>(
{} as SidebarPropsContextValue,
);
headerPropsRef.current.onCloseRequest = () => {
setAppState({ openSidebar: null });
};
headerPropsRef.current.onDock = (isDocked) => onDock?.(isDocked);
// renew the ref object if the following props change since we want to
// rerender. We can't pass down as component props manually because
// the <Sidebar.Header/> can be rendered upstream.
headerPropsRef.current = updateObject(headerPropsRef.current, {
docked,
// explicit prop to rerender on update
shouldRenderDockButton: !!onDock && docked != null,
});
const islandRef = useRef<HTMLDivElement>(null);
useImperativeHandle(ref, () => {
return islandRef.current!;
});
const device = useDevice();
const closeLibrary = useCallback(() => {
const isDialogOpen = !!document.querySelector(".Dialog");
// Prevent closing if any dialog is open
if (isDialogOpen) {
return;
}
setAppState({ openSidebar: null });
}, [setAppState]);
useOnClickOutside(
islandRef,
useCallback(
(event) => {
// If click on the library icon, do nothing so that LibraryButton
// can toggle library menu
if ((event.target as Element).closest(".sidebar-trigger")) {
return;
}
if (!docked || !device.canDeviceFitSidebar) {
closeLibrary();
}
},
[closeLibrary, docked, device.canDeviceFitSidebar],
),
);
useEffect(() => {
const handleKeyDown = (event: KeyboardEvent) => {
if (
event.key === KEYS.ESCAPE &&
(!docked || !device.canDeviceFitSidebar)
) {
closeLibrary();
}
};
document.addEventListener(EVENT.KEYDOWN, handleKeyDown);
return () => {
document.removeEventListener(EVENT.KEYDOWN, handleKeyDown);
};
}, [closeLibrary, docked, device.canDeviceFitSidebar]);
return (
<Island
{...rest}
className={clsx("sidebar", { "sidebar--docked": docked }, className)}
ref={islandRef}
>
<SidebarPropsContext.Provider value={headerPropsRef.current}>
{children}
</SidebarPropsContext.Provider>
</Island>
);
}, },
); );
SidebarInner.displayName = "SidebarInner";
export const Sidebar = Object.assign(
forwardRef((props: SidebarProps, ref: React.ForwardedRef<HTMLDivElement>) => {
const appState = useUIAppState();
const { onStateChange } = props;
const refPrevOpenSidebar = useRef(appState.openSidebar);
useEffect(() => {
if (
// closing sidebar
((!appState.openSidebar &&
refPrevOpenSidebar?.current?.name === props.name) ||
// opening current sidebar
(appState.openSidebar?.name === props.name &&
refPrevOpenSidebar?.current?.name !== props.name) ||
// switching tabs or switching to a different sidebar
refPrevOpenSidebar.current?.name === props.name) &&
appState.openSidebar !== refPrevOpenSidebar.current
) {
onStateChange?.(
appState.openSidebar?.name !== props.name
? null
: appState.openSidebar,
);
}
refPrevOpenSidebar.current = appState.openSidebar;
}, [appState.openSidebar, onStateChange, props.name]);
const [mounted, setMounted] = useState(false);
useLayoutEffect(() => {
setMounted(true);
return () => setMounted(false);
}, []);
// We want to render in the next tick (hence `mounted` flag) so that it's
// guaranteed to happen after unmount of the previous sidebar (in case the
// previous sidebar is mounted after the next one). This is necessary to
// prevent flicker of subcomponents that support fallbacks
// (e.g. SidebarHeader). This is because we're using flags to determine
// whether prefer the fallback component or not (otherwise both will render
// initially), and the flag won't be reset in time if the unmount order
// it not correct.
//
// Alternative, and more general solution would be to namespace the fallback
// HoC so that state is not shared between subcomponents when the wrapping
// component is of the same type (e.g. Sidebar -> SidebarHeader).
const shouldRender = mounted && appState.openSidebar?.name === props.name;
if (!shouldRender) {
return null;
}
return <SidebarInner {...props} ref={ref} key={props.name} />;
}),
{
Header: SidebarHeader,
TabTriggers: SidebarTabTriggers,
TabTrigger: SidebarTabTrigger,
Tabs: SidebarTabs,
Tab: SidebarTab,
Trigger: SidebarTrigger,
},
);
Sidebar.displayName = "Sidebar";
+33 -65
View File
@@ -4,86 +4,54 @@ import { t } from "../../i18n";
import { useDevice } from "../App"; import { useDevice } from "../App";
import { SidebarPropsContext } from "./common"; import { SidebarPropsContext } from "./common";
import { CloseIcon, PinIcon } from "../icons"; import { CloseIcon, PinIcon } from "../icons";
import { withUpstreamOverride } from "../hoc/withUpstreamOverride";
import { Tooltip } from "../Tooltip"; import { Tooltip } from "../Tooltip";
import { Button } from "../Button";
export const SidebarDockButton = (props: { export const SidebarHeader = ({
checked: boolean; children,
onChange?(): void; className,
}) => { }: {
return (
<div className="layer-ui__sidebar-dock-button" data-testid="sidebar-dock">
<Tooltip label={t("labels.sidebarLock")}>
<label
className={clsx(
"ToolIcon ToolIcon__lock ToolIcon_type_floating",
`ToolIcon_size_medium`,
)}
>
<input
className="ToolIcon_type_checkbox"
type="checkbox"
onChange={props.onChange}
checked={props.checked}
aria-label={t("labels.sidebarLock")}
/>{" "}
<div
className={clsx("Sidebar__pin-btn", {
"Sidebar__pin-btn--pinned": props.checked,
})}
tabIndex={0}
>
{PinIcon}
</div>{" "}
</label>{" "}
</Tooltip>
</div>
);
};
const _SidebarHeader: React.FC<{
children?: React.ReactNode; children?: React.ReactNode;
className?: string; className?: string;
}> = ({ children, className }) => { }) => {
const device = useDevice(); const device = useDevice();
const props = useContext(SidebarPropsContext); const props = useContext(SidebarPropsContext);
const renderDockButton = !!(device.canDeviceFitSidebar && props.dockable); const renderDockButton = !!(
const renderCloseButton = !!props.onClose; device.canDeviceFitSidebar && props.shouldRenderDockButton
);
return ( return (
<div <div
className={clsx("layer-ui__sidebar__header", className)} className={clsx("sidebar__header", className)}
data-testid="sidebar-header" data-testid="sidebar-header"
> >
{children} {children}
{(renderDockButton || renderCloseButton) && ( <div className="sidebar__header__buttons">
<div className="layer-ui__sidebar__header__buttons"> {renderDockButton && (
{renderDockButton && ( <Tooltip label={t("labels.sidebarLock")}>
<SidebarDockButton <Button
checked={!!props.docked} onSelect={() => props.onDock?.(!props.docked)}
onChange={() => { selected={!!props.docked}
props.onDock?.(!props.docked); className="sidebar__dock"
}} data-testid="sidebar-dock"
/> aria-label={t("labels.sidebarLock")}
)}
{renderCloseButton && (
<button
data-testid="sidebar-close"
className="Sidebar__close-btn"
onClick={props.onClose}
aria-label={t("buttons.close")}
> >
{CloseIcon} {PinIcon}
</button> </Button>
)} </Tooltip>
</div> )}
)} <Button
data-testid="sidebar-close"
className="sidebar__close"
onSelect={props.onCloseRequest}
aria-label={t("buttons.close")}
>
{CloseIcon}
</Button>
</div>
</div> </div>
); );
}; };
const [Context, Component] = withUpstreamOverride(_SidebarHeader); SidebarHeader.displayName = "SidebarHeader";
/** @private */
export const SidebarHeaderComponents = { Context, Component };
+18
View File
@@ -0,0 +1,18 @@
import * as RadixTabs from "@radix-ui/react-tabs";
import { SidebarTabName } from "../../types";
export const SidebarTab = ({
tab,
children,
...rest
}: {
tab: SidebarTabName;
children: React.ReactNode;
} & React.HTMLAttributes<HTMLDivElement>) => {
return (
<RadixTabs.Content {...rest} value={tab}>
{children}
</RadixTabs.Content>
);
};
SidebarTab.displayName = "SidebarTab";
@@ -0,0 +1,26 @@
import * as RadixTabs from "@radix-ui/react-tabs";
import { SidebarTabName } from "../../types";
export const SidebarTabTrigger = ({
children,
tab,
onSelect,
...rest
}: {
children: React.ReactNode;
tab: SidebarTabName;
onSelect?: React.ReactEventHandler<HTMLButtonElement> | undefined;
} & Omit<React.HTMLAttributes<HTMLButtonElement>, "onSelect">) => {
return (
<RadixTabs.Trigger value={tab} asChild onSelect={onSelect}>
<button
type={"button"}
className={`excalidraw-button sidebar-tab-trigger`}
{...rest}
>
{children}
</button>
</RadixTabs.Trigger>
);
};
SidebarTabTrigger.displayName = "SidebarTabTrigger";
@@ -0,0 +1,16 @@
import * as RadixTabs from "@radix-ui/react-tabs";
export const SidebarTabTriggers = ({
children,
...rest
}: { children: React.ReactNode } & Omit<
React.RefAttributes<HTMLDivElement>,
"onSelect"
>) => {
return (
<RadixTabs.List className="sidebar-triggers" {...rest}>
{children}
</RadixTabs.List>
);
};
SidebarTabTriggers.displayName = "SidebarTabTriggers";
+36
View File
@@ -0,0 +1,36 @@
import * as RadixTabs from "@radix-ui/react-tabs";
import { useUIAppState } from "../../context/ui-appState";
import { useExcalidrawSetAppState } from "../App";
export const SidebarTabs = ({
children,
...rest
}: {
children: React.ReactNode;
} & Omit<React.RefAttributes<HTMLDivElement>, "onSelect">) => {
const appState = useUIAppState();
const setAppState = useExcalidrawSetAppState();
if (!appState.openSidebar) {
return null;
}
const { name } = appState.openSidebar;
return (
<RadixTabs.Root
className="sidebar-tabs-root"
value={appState.openSidebar.tab}
onValueChange={(tab) =>
setAppState((state) => ({
...state,
openSidebar: { ...state.openSidebar, name, tab },
}))
}
{...rest}
>
{children}
</RadixTabs.Root>
);
};
SidebarTabs.displayName = "SidebarTabs";
@@ -0,0 +1,34 @@
@import "../../css/variables.module";
.excalidraw {
.sidebar-trigger {
@include outlineButtonStyles;
background-color: var(--island-bg-color);
width: auto;
height: var(--lg-button-size);
display: flex;
align-items: center;
gap: 0.5rem;
line-height: 0;
font-size: 0.75rem;
letter-spacing: 0.4px;
svg {
width: var(--lg-icon-size);
height: var(--lg-icon-size);
}
}
.default-sidebar-trigger .sidebar-trigger__label {
display: none;
@media screen and (min-width: 1024px) {
display: block;
}
}
}
+45
View File
@@ -0,0 +1,45 @@
import { useExcalidrawSetAppState } from "../App";
import { SidebarTriggerProps } from "./common";
import { useUIAppState } from "../../context/ui-appState";
import clsx from "clsx";
import "./SidebarTrigger.scss";
export const SidebarTrigger = ({
name,
tab,
icon,
title,
children,
onToggle,
className,
style,
}: SidebarTriggerProps) => {
const setAppState = useExcalidrawSetAppState();
const appState = useUIAppState();
return (
<label title={title}>
<input
className="ToolIcon_type_checkbox"
type="checkbox"
onChange={(event) => {
document
.querySelector(".layer-ui__wrapper")
?.classList.remove("animate");
const isOpen = event.target.checked;
setAppState({ openSidebar: isOpen ? { name, tab } : null });
onToggle?.(isOpen);
}}
checked={appState.openSidebar?.name === name}
aria-label={title}
aria-keyshortcuts="0"
/>
<div className={clsx("sidebar-trigger", className)} style={style}>
{icon && <div>{icon}</div>}
{children && <div className="sidebar-trigger__label">{children}</div>}
</div>
</label>
);
};
SidebarTrigger.displayName = "SidebarTrigger";
+26 -8
View File
@@ -1,23 +1,41 @@
import React from "react"; import React from "react";
import { AppState, SidebarName, SidebarTabName } from "../../types";
export type SidebarTriggerProps = {
name: SidebarName;
tab?: SidebarTabName;
icon?: JSX.Element;
children?: React.ReactNode;
title?: string;
className?: string;
onToggle?: (open: boolean) => void;
style?: React.CSSProperties;
};
export type SidebarProps<P = {}> = { export type SidebarProps<P = {}> = {
name: SidebarName;
children: React.ReactNode; children: React.ReactNode;
/** /**
* Called on sidebar close (either by user action or by the editor). * Called on sidebar open/close or tab change.
*/
onStateChange?: (state: AppState["openSidebar"]) => void;
/**
* supply alongside `docked` prop in order to make the Sidebar user-dockable
*/ */
onClose?: () => void | boolean;
/** if not supplied, sidebar won't be dockable */
onDock?: (docked: boolean) => void; onDock?: (docked: boolean) => void;
docked?: boolean; docked?: boolean;
initialDockedState?: boolean;
dockable?: boolean;
className?: string; className?: string;
// NOTE sidebars we use internally inside the editor must have this flag set.
// It indicates that this sidebar should have lower precedence over host
// sidebars, if both are open.
/** @private internal */
__fallback?: boolean;
} & P; } & P;
export type SidebarPropsContextValue = Pick< export type SidebarPropsContextValue = Pick<
SidebarProps, SidebarProps,
"onClose" | "onDock" | "docked" | "dockable" "onDock" | "docked"
>; > & { onCloseRequest: () => void; shouldRenderDockButton: boolean };
export const SidebarPropsContext = export const SidebarPropsContext =
React.createContext<SidebarPropsContextValue>({}); React.createContext<SidebarPropsContextValue>({} as SidebarPropsContextValue);
-79
View File
@@ -1,79 +0,0 @@
@import "../css/variables.module";
.excalidraw {
.single-library-item {
position: relative;
&-status {
position: absolute;
top: 0.3rem;
left: 0.3rem;
font-size: 0.7rem;
color: $oc-red-7;
background: rgba(255, 255, 255, 0.9);
padding: 0.1rem 0.2rem;
border-radius: 0.2rem;
}
&__svg {
background-color: $oc-white;
padding: 0.3rem;
width: 7.5rem;
height: 7.5rem;
border: 1px solid var(--button-gray-2);
svg {
width: 100%;
height: 100%;
}
}
.ToolIcon__icon {
background-color: $oc-white;
width: auto;
height: auto;
margin: 0 0.5rem;
}
.ToolIcon,
.ToolIcon_type_button:hover {
background-color: white;
}
.required,
.error {
color: $oc-red-8;
font-weight: bold;
font-size: 1rem;
margin: 0.2rem;
}
.error {
font-weight: 500;
margin: 0;
padding: 0.3em 0;
}
&--remove {
position: absolute;
top: 0.2rem;
right: 1rem;
.ToolIcon__icon {
margin: 0;
}
.ToolIcon__icon {
background-color: $oc-red-6;
&:hover {
background-color: $oc-red-7;
}
&:active {
background-color: $oc-red-8;
}
}
svg {
color: $oc-white;
padding: 0.26rem;
border-radius: 0.3em;
width: 1rem;
height: 1rem;
}
}
}
}
-104
View File
@@ -1,104 +0,0 @@
import oc from "open-color";
import { useEffect, useRef } from "react";
import { t } from "../i18n";
import { exportToSvg } from "../packages/utils";
import { AppState, LibraryItem } from "../types";
import { CloseIcon } from "./icons";
import "./SingleLibraryItem.scss";
import { ToolButton } from "./ToolButton";
const SingleLibraryItem = ({
libItem,
appState,
index,
onChange,
onRemove,
}: {
libItem: LibraryItem;
appState: AppState;
index: number;
onChange: (val: string, index: number) => void;
onRemove: (id: string) => void;
}) => {
const svgRef = useRef<HTMLDivElement | null>(null);
const inputRef = useRef<HTMLInputElement | null>(null);
useEffect(() => {
const node = svgRef.current;
if (!node) {
return;
}
(async () => {
const svg = await exportToSvg({
elements: libItem.elements,
appState: {
...appState,
viewBackgroundColor: oc.white,
exportBackground: true,
},
files: null,
});
node.innerHTML = svg.outerHTML;
})();
}, [libItem.elements, appState]);
return (
<div className="single-library-item">
{libItem.status === "published" && (
<span className="single-library-item-status">
{t("labels.statusPublished")}
</span>
)}
<div ref={svgRef} className="single-library-item__svg" />
<ToolButton
aria-label={t("buttons.remove")}
type="button"
icon={CloseIcon}
className="single-library-item--remove"
onClick={onRemove.bind(null, libItem.id)}
title={t("buttons.remove")}
/>
<div
style={{
display: "flex",
margin: "0.8rem 0",
width: "100%",
fontSize: "14px",
fontWeight: 500,
flexDirection: "column",
}}
>
<label
style={{
display: "flex",
justifyContent: "space-between",
flexDirection: "column",
}}
>
<div style={{ padding: "0.5em 0" }}>
<span style={{ fontWeight: 500, color: oc.gray[6] }}>
{t("publishDialog.itemName")}
</span>
<span aria-hidden="true" className="required">
*
</span>
</div>
<input
type="text"
ref={inputRef}
style={{ width: "80%", padding: "0.2rem" }}
defaultValue={libItem.name}
placeholder="Item name"
onChange={(event) => {
onChange(event.target.value, index);
}}
/>
</label>
<span className="error">{libItem.error}</span>
</div>
</div>
);
};
export default SingleLibraryItem;
+3 -3
View File
@@ -3,14 +3,14 @@ import { getCommonBounds } from "../element/bounds";
import { NonDeletedExcalidrawElement } from "../element/types"; import { NonDeletedExcalidrawElement } from "../element/types";
import { t } from "../i18n"; import { t } from "../i18n";
import { getTargetElements } from "../scene"; import { getTargetElements } from "../scene";
import { AppState, ExcalidrawProps } from "../types"; import { ExcalidrawProps, UIAppState } from "../types";
import { CloseIcon } from "./icons"; import { CloseIcon } from "./icons";
import { Island } from "./Island"; import { Island } from "./Island";
import "./Stats.scss"; import "./Stats.scss";
export const Stats = (props: { export const Stats = (props: {
appState: AppState; appState: UIAppState;
setAppState: React.Component<any, AppState>["setState"]; setAppState: React.Component<any, UIAppState>["setState"];
elements: readonly NonDeletedExcalidrawElement[]; elements: readonly NonDeletedExcalidrawElement[];
onClose: () => void; onClose: () => void;
renderCustomStats: ExcalidrawProps["renderCustomStats"]; renderCustomStats: ExcalidrawProps["renderCustomStats"];
+3
View File
@@ -2,6 +2,9 @@
// container in body where the actual tooltip is appended to // container in body where the actual tooltip is appended to
.excalidraw-tooltip { .excalidraw-tooltip {
--ui-font: Assistant, system-ui, BlinkMacSystemFont, -apple-system, Segoe UI,
Roboto, Helvetica, Arial, sans-serif;
font-family: var(--ui-font);
position: fixed; position: fixed;
z-index: 1000; z-index: 1000;
+33 -25
View File
@@ -1,6 +1,7 @@
import React from "react"; import React from "react";
import * as Sentry from "@sentry/browser"; import * as Sentry from "@sentry/browser";
import { t } from "../i18n"; import { t } from "../i18n";
import Trans from "./Trans";
interface TopErrorBoundaryState { interface TopErrorBoundaryState {
hasError: boolean; hasError: boolean;
@@ -74,25 +75,31 @@ export class TopErrorBoundary extends React.Component<
<div className="ErrorSplash excalidraw"> <div className="ErrorSplash excalidraw">
<div className="ErrorSplash-messageContainer"> <div className="ErrorSplash-messageContainer">
<div className="ErrorSplash-paragraph bigger align-center"> <div className="ErrorSplash-paragraph bigger align-center">
{t("errorSplash.headingMain_pre")} <Trans
<button onClick={() => window.location.reload()}> i18nKey="errorSplash.headingMain"
{t("errorSplash.headingMain_button")} button={(el) => (
</button> <button onClick={() => window.location.reload()}>{el}</button>
)}
/>
</div> </div>
<div className="ErrorSplash-paragraph align-center"> <div className="ErrorSplash-paragraph align-center">
{t("errorSplash.clearCanvasMessage")} <Trans
<button i18nKey="errorSplash.clearCanvasMessage"
onClick={() => { button={(el) => (
try { <button
localStorage.clear(); onClick={() => {
window.location.reload(); try {
} catch (error: any) { localStorage.clear();
console.error(error); window.location.reload();
} } catch (error: any) {
}} console.error(error);
> }
{t("errorSplash.clearCanvasMessage_button")} }}
</button> >
{el}
</button>
)}
/>
<br /> <br />
<div className="smaller"> <div className="smaller">
<span role="img" aria-label="warning"> <span role="img" aria-label="warning">
@@ -106,16 +113,17 @@ export class TopErrorBoundary extends React.Component<
</div> </div>
<div> <div>
<div className="ErrorSplash-paragraph"> <div className="ErrorSplash-paragraph">
{t("errorSplash.trackedToSentry_pre")} {t("errorSplash.trackedToSentry", {
{this.state.sentryEventId} eventId: this.state.sentryEventId,
{t("errorSplash.trackedToSentry_post")} })}
</div> </div>
<div className="ErrorSplash-paragraph"> <div className="ErrorSplash-paragraph">
{t("errorSplash.openIssueMessage_pre")} <Trans
<button onClick={() => this.createGithubIssue()}> i18nKey="errorSplash.openIssueMessage"
{t("errorSplash.openIssueMessage_button")} button={(el) => (
</button> <button onClick={() => this.createGithubIssue()}>{el}</button>
{t("errorSplash.openIssueMessage_post")} )}
/>
</div> </div>
<div className="ErrorSplash-paragraph"> <div className="ErrorSplash-paragraph">
<div className="ErrorSplash-details"> <div className="ErrorSplash-details">
+67
View File
@@ -0,0 +1,67 @@
import { render } from "@testing-library/react";
import fallbackLangData from "../locales/en.json";
import Trans from "./Trans";
describe("Test <Trans/>", () => {
it("should translate the the strings correctly", () => {
//@ts-ignore
fallbackLangData.transTest = {
key1: "Hello {{audience}}",
key2: "Please <link>click the button</link> to continue.",
key3: "Please <link>click {{location}}</link> to continue.",
key4: "Please <link>click <bold>{{location}}</bold></link> to continue.",
key5: "Please <connect-link>click the button</connect-link> to continue.",
};
const { getByTestId } = render(
<>
<div data-testid="test1">
<Trans i18nKey="transTest.key1" audience="world" />
</div>
<div data-testid="test2">
<Trans
i18nKey="transTest.key2"
link={(el) => <a href="https://example.com">{el}</a>}
/>
</div>
<div data-testid="test3">
<Trans
i18nKey="transTest.key3"
link={(el) => <a href="https://example.com">{el}</a>}
location="the button"
/>
</div>
<div data-testid="test4">
<Trans
i18nKey="transTest.key4"
link={(el) => <a href="https://example.com">{el}</a>}
location="the button"
bold={(el) => <strong>{el}</strong>}
/>
</div>
<div data-testid="test5">
<Trans
i18nKey="transTest.key5"
connect-link={(el) => <a href="https://example.com">{el}</a>}
/>
</div>
</>,
);
expect(getByTestId("test1").innerHTML).toEqual("Hello world");
expect(getByTestId("test2").innerHTML).toEqual(
`Please <a href="https://example.com">click the button</a> to continue.`,
);
expect(getByTestId("test3").innerHTML).toEqual(
`Please <a href="https://example.com">click the button</a> to continue.`,
);
expect(getByTestId("test4").innerHTML).toEqual(
`Please <a href="https://example.com">click <strong>the button</strong></a> to continue.`,
);
expect(getByTestId("test5").innerHTML).toEqual(
`Please <a href="https://example.com">click the button</a> to continue.`,
);
});
});
+169
View File
@@ -0,0 +1,169 @@
import React from "react";
import { useI18n } from "../i18n";
// Used for splitting i18nKey into tokens in Trans component
// Example:
// "Please <link>click {{location}}</link> to continue.".split(SPLIT_REGEX).filter(Boolean)
// produces
// ["Please ", "<link>", "click ", "{{location}}", "</link>", " to continue."]
const SPLIT_REGEX = /({{[\w-]+}})|(<[\w-]+>)|(<\/[\w-]+>)/g;
// Used for extracting "location" from "{{location}}"
const KEY_REGEXP = /{{([\w-]+)}}/;
// Used for extracting "link" from "<link>"
const TAG_START_REGEXP = /<([\w-]+)>/;
// Used for extracting "link" from "</link>"
const TAG_END_REGEXP = /<\/([\w-]+)>/;
const getTransChildren = (
format: string,
props: {
[key: string]: React.ReactNode | ((el: React.ReactNode) => React.ReactNode);
},
): React.ReactNode[] => {
const stack: { name: string; children: React.ReactNode[] }[] = [
{
name: "",
children: [],
},
];
format
.split(SPLIT_REGEX)
.filter(Boolean)
.forEach((match) => {
const tagStartMatch = match.match(TAG_START_REGEXP);
const tagEndMatch = match.match(TAG_END_REGEXP);
const keyMatch = match.match(KEY_REGEXP);
if (tagStartMatch !== null) {
// The match is <tag>. Set the tag name as the name if it's one of the
// props, e.g. for "Please <link>click the button</link> to continue"
// tagStartMatch[1] = "link" and props contain "link" then it will be
// pushed to stack.
const name = tagStartMatch[1];
if (props.hasOwnProperty(name)) {
stack.push({
name,
children: [],
});
} else {
console.warn(
`Trans: missed to pass in prop ${name} for interpolating ${format}`,
);
}
} else if (tagEndMatch !== null) {
// If tag end match is found, this means we need to replace the content with
// its actual value in prop e.g. format = "Please <link>click the
// button</link> to continue", tagEndMatch is for "</link>", stack last item name =
// "link" and props.link = (el) => <a
// href="https://example.com">{el}</a> then its prop value will be
// pushed to "link"'s children so on DOM when rendering it's rendered as
// <a href="https://example.com">click the button</a>
const name = tagEndMatch[1];
if (name === stack[stack.length - 1].name) {
const item = stack.pop()!;
const itemChildren = React.createElement(
React.Fragment,
{},
...item.children,
);
const fn = props[item.name];
if (typeof fn === "function") {
stack[stack.length - 1].children.push(fn(itemChildren));
}
} else {
console.warn(
`Trans: unexpected end tag ${match} for interpolating ${format}`,
);
}
} else if (keyMatch !== null) {
// The match is for {{key}}. Check if the key is present in props and set
// the prop value as children of last stack item e.g. format = "Hello
// {{name}}", key = "name" and props.name = "Excalidraw" then its prop
// value will be pushed to "name"'s children so it's rendered on DOM as
// "Hello Excalidraw"
const name = keyMatch[1];
if (props.hasOwnProperty(name)) {
stack[stack.length - 1].children.push(props[name] as React.ReactNode);
} else {
console.warn(
`Trans: key ${name} not in props for interpolating ${format}`,
);
}
} else {
// If none of cases match means we just need to push the string
// to stack eg - "Hello {{name}} Whats up?" "Hello", "Whats up" will be pushed
stack[stack.length - 1].children.push(match);
}
});
if (stack.length !== 1) {
console.warn(`Trans: stack not empty for interpolating ${format}`);
}
return stack[0].children;
};
/*
Trans component is used for translating JSX.
```json
{
"example1": "Hello {{audience}}",
"example2": "Please <link>click the button</link> to continue.",
"example3": "Please <link>click {{location}}</link> to continue.",
"example4": "Please <link>click <bold>{{location}}</bold></link> to continue.",
}
```
```jsx
<Trans i18nKey="example1" audience="world" />
<Trans
i18nKey="example2"
connectLink={(el) => <a href="https://example.com">{el}</a>}
/>
<Trans
i18nKey="example3"
connectLink={(el) => <a href="https://example.com">{el}</a>}
location="the button"
/>
<Trans
i18nKey="example4"
connectLink={(el) => <a href="https://example.com">{el}</a>}
location="the button"
bold={(el) => <strong>{el}</strong>}
/>
```
Output:
```html
Hello world
Please <a href="https://example.com">click the button</a> to continue.
Please <a href="https://example.com">click the button</a> to continue.
Please <a href="https://example.com">click <strong>the button</strong></a> to continue.
```
*/
const Trans = ({
i18nKey,
children,
...props
}: {
i18nKey: string;
[key: string]: React.ReactNode | ((el: React.ReactNode) => React.ReactNode);
}) => {
const { t } = useI18n();
// This is needed to avoid unique key error in list which gets rendered from getTransChildren
return React.createElement(
React.Fragment,
{},
...getTransChildren(t(i18nKey), props),
);
};
export default Trans;
+8 -21
View File
@@ -6,58 +6,45 @@ exports[`Test <App/> should show error modal when using brave and measureText AP
> >
<p> <p>
Looks like you are using Brave browser with the Looks like you are using Brave browser with the
 
<span <span
style="font-weight: 600;" style="font-weight: 600;"
> >
Aggressively Block Fingerprinting Aggressively Block Fingerprinting
</span> </span>
setting enabled.
setting enabled </p>
. <p>
<br />
<br />
This could result in breaking the This could result in breaking the
<span <span
style="font-weight: 600;" style="font-weight: 600;"
> >
Text Elements Text Elements
</span> </span>
in your drawings.
in your drawings
.
</p> </p>
<p> <p>
We strongly recommend disabling this setting. You can follow We strongly recommend disabling this setting. You can follow
<a <a
href="http://docs.excalidraw.com/docs/@excalidraw/excalidraw/faq#turning-off-aggresive-block-fingerprinting-in-brave-browser" href="http://docs.excalidraw.com/docs/@excalidraw/excalidraw/faq#turning-off-aggresive-block-fingerprinting-in-brave-browser"
> >
these steps these steps
</a> </a>
on how to do so.
on how to do so
.
</p> </p>
<p> <p>
If disabling this setting doesn't fix the display of text elements, please open an If disabling this setting doesn't fix the display of text elements, please open an
<a <a
href="https://github.com/excalidraw/excalidraw/issues/new" href="https://github.com/excalidraw/excalidraw/issues/new"
> >
issue issue
</a> </a>
on our GitHub, or write us on
on our GitHub, or write us on
<a <a
href="https://discord.gg/UexuTaE" href="https://discord.gg/UexuTaE"
> >
Discord Discord
.
</a> </a>
.
</p> </p>
</div> </div>
`; `;
-32
View File
@@ -1,32 +0,0 @@
import React from "react";
import tunnel from "@dwelle/tunnel-rat";
type Tunnel = ReturnType<typeof tunnel>;
type TunnelsContextValue = {
mainMenuTunnel: Tunnel;
welcomeScreenMenuHintTunnel: Tunnel;
welcomeScreenToolbarHintTunnel: Tunnel;
welcomeScreenHelpHintTunnel: Tunnel;
welcomeScreenCenterTunnel: Tunnel;
footerCenterTunnel: Tunnel;
jotaiScope: symbol;
};
export const TunnelsContext = React.createContext<TunnelsContextValue>(null!);
export const useTunnels = () => React.useContext(TunnelsContext);
export const useInitializeTunnels = () => {
return React.useMemo((): TunnelsContextValue => {
return {
mainMenuTunnel: tunnel(),
welcomeScreenMenuHintTunnel: tunnel(),
welcomeScreenToolbarHintTunnel: tunnel(),
welcomeScreenHelpHintTunnel: tunnel(),
welcomeScreenCenterTunnel: tunnel(),
footerCenterTunnel: tunnel(),
jotaiScope: Symbol(),
};
}, []);
};
@@ -1,4 +1,4 @@
import { useOutsideClickHook } from "../../hooks/useOutsideClick"; import { useOutsideClick } from "../../hooks/useOutsideClick";
import { Island } from "../Island"; import { Island } from "../Island";
import { useDevice } from "../App"; import { useDevice } from "../App";
@@ -24,7 +24,7 @@ const MenuContent = ({
style?: React.CSSProperties; style?: React.CSSProperties;
}) => { }) => {
const device = useDevice(); const device = useDevice();
const menuRef = useOutsideClickHook(() => { const menuRef = useOutsideClick(() => {
onClickOutside?.(); onClickOutside?.();
}); });
@@ -1,5 +1,6 @@
import clsx from "clsx"; import clsx from "clsx";
import { useDevice, useExcalidrawAppState } from "../App"; import { useUIAppState } from "../../context/ui-appState";
import { useDevice } from "../App";
const MenuTrigger = ({ const MenuTrigger = ({
className = "", className = "",
@@ -10,7 +11,7 @@ const MenuTrigger = ({
children: React.ReactNode; children: React.ReactNode;
onToggle: () => void; onToggle: () => void;
}) => { }) => {
const appState = useExcalidrawAppState(); const appState = useUIAppState();
const device = useDevice(); const device = useDevice();
const classNames = clsx( const classNames = clsx(
`dropdown-menu-button ${className}`, `dropdown-menu-button ${className}`,
+6 -6
View File
@@ -1,7 +1,6 @@
import clsx from "clsx"; import clsx from "clsx";
import { actionShortcuts } from "../../actions"; import { actionShortcuts } from "../../actions";
import { ActionManager } from "../../actions/manager"; import { ActionManager } from "../../actions/manager";
import { AppState } from "../../types";
import { import {
ExitZenModeAction, ExitZenModeAction,
FinalizeAction, FinalizeAction,
@@ -9,10 +8,11 @@ import {
ZoomActions, ZoomActions,
} from "../Actions"; } from "../Actions";
import { useDevice } from "../App"; import { useDevice } from "../App";
import { useTunnels } from "../context/tunnels"; import { useTunnels } from "../../context/tunnels";
import { HelpButton } from "../HelpButton"; import { HelpButton } from "../HelpButton";
import { Section } from "../Section"; import { Section } from "../Section";
import Stack from "../Stack"; import Stack from "../Stack";
import { UIAppState } from "../../types";
const Footer = ({ const Footer = ({
appState, appState,
@@ -20,12 +20,12 @@ const Footer = ({
showExitZenModeBtn, showExitZenModeBtn,
renderWelcomeScreen, renderWelcomeScreen,
}: { }: {
appState: AppState; appState: UIAppState;
actionManager: ActionManager; actionManager: ActionManager;
showExitZenModeBtn: boolean; showExitZenModeBtn: boolean;
renderWelcomeScreen: boolean; renderWelcomeScreen: boolean;
}) => { }) => {
const { footerCenterTunnel, welcomeScreenHelpHintTunnel } = useTunnels(); const { FooterCenterTunnel, WelcomeScreenHelpHintTunnel } = useTunnels();
const device = useDevice(); const device = useDevice();
const showFinalize = const showFinalize =
@@ -70,14 +70,14 @@ const Footer = ({
</Section> </Section>
</Stack.Col> </Stack.Col>
</div> </div>
<footerCenterTunnel.Out /> <FooterCenterTunnel.Out />
<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,
})} })}
> >
<div style={{ position: "relative" }}> <div style={{ position: "relative" }}>
{renderWelcomeScreen && <welcomeScreenHelpHintTunnel.Out />} {renderWelcomeScreen && <WelcomeScreenHelpHintTunnel.Out />}
<HelpButton <HelpButton
onClick={() => actionManager.executeAction(actionShortcuts)} onClick={() => actionManager.executeAction(actionShortcuts)}
/> />
+6 -6
View File
@@ -1,13 +1,13 @@
import clsx from "clsx"; import clsx from "clsx";
import { useExcalidrawAppState } from "../App"; import { useTunnels } from "../../context/tunnels";
import { useTunnels } from "../context/tunnels";
import "./FooterCenter.scss"; import "./FooterCenter.scss";
import { useUIAppState } from "../../context/ui-appState";
const FooterCenter = ({ children }: { children?: React.ReactNode }) => { const FooterCenter = ({ children }: { children?: React.ReactNode }) => {
const { footerCenterTunnel } = useTunnels(); const { FooterCenterTunnel } = useTunnels();
const appState = useExcalidrawAppState(); const appState = useUIAppState();
return ( return (
<footerCenterTunnel.In> <FooterCenterTunnel.In>
<div <div
className={clsx("footer-center zen-mode-transition", { className={clsx("footer-center zen-mode-transition", {
"layer-ui__wrapper__footer-left--transition-bottom": "layer-ui__wrapper__footer-left--transition-bottom":
@@ -16,7 +16,7 @@ const FooterCenter = ({ children }: { children?: React.ReactNode }) => {
> >
{children} {children}
</div> </div>
</footerCenterTunnel.In> </FooterCenterTunnel.In>
); );
}; };
+20 -6
View File
@@ -1,32 +1,46 @@
import { atom, useAtom } from "jotai"; import { atom, useAtom } from "jotai";
import React, { useLayoutEffect } from "react"; import React, { useLayoutEffect } from "react";
import { useTunnels } from "../context/tunnels"; import { useTunnels } from "../../context/tunnels";
export const withInternalFallback = <P,>( export const withInternalFallback = <P,>(
componentName: string, componentName: string,
Component: React.FC<P>, Component: React.FC<P>,
) => { ) => {
const counterAtom = atom(0); const renderAtom = atom(0);
// flag set on initial render to tell the fallback component to skip the // flag set on initial render to tell the fallback component to skip the
// render until mount counter are initialized. This is because the counter // render until mount counter are initialized. This is because the counter
// is initialized in an effect, and thus we could end rendering both // is initialized in an effect, and thus we could end rendering both
// components at the same time until counter is initialized. // components at the same time until counter is initialized.
let preferHost = false; let preferHost = false;
let counter = 0;
const WrapperComponent: React.FC< const WrapperComponent: React.FC<
P & { P & {
__fallback?: boolean; __fallback?: boolean;
} }
> = (props) => { > = (props) => {
const { jotaiScope } = useTunnels(); const { jotaiScope } = useTunnels();
const [counter, setCounter] = useAtom(counterAtom, jotaiScope); const [, setRender] = useAtom(renderAtom, jotaiScope);
useLayoutEffect(() => { useLayoutEffect(() => {
setCounter((counter) => counter + 1); setRender((c) => {
const next = c + 1;
counter = next;
return next;
});
return () => { return () => {
setCounter((counter) => counter - 1); setRender((c) => {
const next = c - 1;
counter = next;
if (!next) {
preferHost = false;
}
return next;
});
}; };
}, [setCounter]); }, [setRender]);
if (!props.__fallback) { if (!props.__fallback) {
preferHost = true; preferHost = true;
@@ -1,63 +0,0 @@
import React, {
useMemo,
useContext,
useLayoutEffect,
useState,
createContext,
} from "react";
export const withUpstreamOverride = <P,>(Component: React.ComponentType<P>) => {
type ContextValue = [boolean, React.Dispatch<React.SetStateAction<boolean>>];
const DefaultComponentContext = createContext<ContextValue>([
false,
() => {},
]);
const ComponentContext: React.FC<{ children: React.ReactNode }> = ({
children,
}) => {
const [isRenderedUpstream, setIsRenderedUpstream] = useState(false);
const contextValue: ContextValue = useMemo(
() => [isRenderedUpstream, setIsRenderedUpstream],
[isRenderedUpstream],
);
return (
<DefaultComponentContext.Provider value={contextValue}>
{children}
</DefaultComponentContext.Provider>
);
};
const DefaultComponent = (
props: P & {
// indicates whether component should render when not rendered upstream
/** @private internal */
__isFallback?: boolean;
},
) => {
const [isRenderedUpstream, setIsRenderedUpstream] = useContext(
DefaultComponentContext,
);
useLayoutEffect(() => {
if (!props.__isFallback) {
setIsRenderedUpstream(true);
return () => setIsRenderedUpstream(false);
}
}, [props.__isFallback, setIsRenderedUpstream]);
if (props.__isFallback && isRenderedUpstream) {
return null;
}
return <Component {...props} />;
};
if (Component.name) {
DefaultComponent.displayName = `${Component.name}_upstreamOverrideWrapper`;
ComponentContext.displayName = `${Component.name}_upstreamOverrideContextWrapper`;
}
return [ComponentContext, DefaultComponent] as const;
};
+7
View File
@@ -1008,6 +1008,13 @@ export const UngroupIcon = React.memo(({ theme }: { theme: Theme }) =>
), ),
); );
export const FillZigZagIcon = createIcon(
<g strokeWidth={1.25}>
<path d="M5.879 2.625h8.242a3.27 3.27 0 0 1 3.254 3.254v8.242a3.27 3.27 0 0 1-3.254 3.254H5.88a3.27 3.27 0 0 1-3.254-3.254V5.88A3.27 3.27 0 0 1 5.88 2.626l-.001-.001ZM4.518 16.118l7.608-12.83m.198 13.934 5.051-9.897M2.778 9.675l9.348-6.387m-7.608 12.83 12.857-8.793" />
</g>,
modifiedTablerIconProps,
);
export const FillHachureIcon = createIcon( export const FillHachureIcon = createIcon(
<> <>
<path <path
@@ -3,9 +3,9 @@ import { usersIcon } from "../icons";
import { Button } from "../Button"; import { Button } from "../Button";
import clsx from "clsx"; import clsx from "clsx";
import { useExcalidrawAppState } from "../App";
import "./LiveCollaborationTrigger.scss"; import "./LiveCollaborationTrigger.scss";
import { useUIAppState } from "../../context/ui-appState";
const LiveCollaborationTrigger = ({ const LiveCollaborationTrigger = ({
isCollaborating, isCollaborating,
@@ -15,7 +15,7 @@ const LiveCollaborationTrigger = ({
isCollaborating: boolean; isCollaborating: boolean;
onSelect: () => void; onSelect: () => void;
} & React.ButtonHTMLAttributes<HTMLButtonElement>) => { } & React.ButtonHTMLAttributes<HTMLButtonElement>) => {
const appState = useExcalidrawAppState(); const appState = useUIAppState();
return ( return (
<Button <Button
+4 -7
View File
@@ -1,10 +1,6 @@
import { getShortcutFromShortcutName } from "../../actions/shortcuts"; import { getShortcutFromShortcutName } from "../../actions/shortcuts";
import { useI18n } from "../../i18n"; import { useI18n } from "../../i18n";
import { import { useExcalidrawSetAppState, useExcalidrawActionManager } from "../App";
useExcalidrawAppState,
useExcalidrawSetAppState,
useExcalidrawActionManager,
} from "../App";
import { import {
ExportIcon, ExportIcon,
ExportImageIcon, ExportImageIcon,
@@ -32,6 +28,7 @@ import clsx from "clsx";
import { useSetAtom } from "jotai"; import { useSetAtom } from "jotai";
import { activeConfirmDialogAtom } from "../ActiveConfirmDialog"; import { activeConfirmDialogAtom } from "../ActiveConfirmDialog";
import { jotaiScope } from "../../jotai"; import { jotaiScope } from "../../jotai";
import { useUIAppState } from "../../context/ui-appState";
export const LoadScene = () => { export const LoadScene = () => {
const { t } = useI18n(); const { t } = useI18n();
@@ -139,7 +136,7 @@ ClearCanvas.displayName = "ClearCanvas";
export const ToggleTheme = () => { export const ToggleTheme = () => {
const { t } = useI18n(); const { t } = useI18n();
const appState = useExcalidrawAppState(); const appState = useUIAppState();
const actionManager = useExcalidrawActionManager(); const actionManager = useExcalidrawActionManager();
if (!actionManager.isActionEnabled(actionToggleTheme)) { if (!actionManager.isActionEnabled(actionToggleTheme)) {
@@ -172,7 +169,7 @@ ToggleTheme.displayName = "ToggleTheme";
export const ChangeCanvasBackground = () => { export const ChangeCanvasBackground = () => {
const { t } = useI18n(); const { t } = useI18n();
const appState = useExcalidrawAppState(); const appState = useUIAppState();
const actionManager = useExcalidrawActionManager(); const actionManager = useExcalidrawActionManager();
if (appState.viewModeEnabled) { if (appState.viewModeEnabled) {
+7 -10
View File
@@ -1,9 +1,5 @@
import React from "react"; import React from "react";
import { import { useDevice, useExcalidrawSetAppState } from "../App";
useDevice,
useExcalidrawAppState,
useExcalidrawSetAppState,
} from "../App";
import DropdownMenu from "../dropdownMenu/DropdownMenu"; import DropdownMenu from "../dropdownMenu/DropdownMenu";
import * as DefaultItems from "./DefaultItems"; import * as DefaultItems from "./DefaultItems";
@@ -13,7 +9,8 @@ import { t } from "../../i18n";
import { HamburgerMenuIcon } from "../icons"; import { HamburgerMenuIcon } from "../icons";
import { withInternalFallback } from "../hoc/withInternalFallback"; import { withInternalFallback } from "../hoc/withInternalFallback";
import { composeEventHandlers } from "../../utils"; import { composeEventHandlers } from "../../utils";
import { useTunnels } from "../context/tunnels"; import { useTunnels } from "../../context/tunnels";
import { useUIAppState } from "../../context/ui-appState";
const MainMenu = Object.assign( const MainMenu = Object.assign(
withInternalFallback( withInternalFallback(
@@ -28,16 +25,16 @@ const MainMenu = Object.assign(
*/ */
onSelect?: (event: Event) => void; onSelect?: (event: Event) => void;
}) => { }) => {
const { mainMenuTunnel } = useTunnels(); const { MainMenuTunnel } = useTunnels();
const device = useDevice(); const device = useDevice();
const appState = useExcalidrawAppState(); const appState = useUIAppState();
const setAppState = useExcalidrawSetAppState(); const setAppState = useExcalidrawSetAppState();
const onClickOutside = device.isMobile const onClickOutside = device.isMobile
? undefined ? undefined
: () => setAppState({ openMenu: null }); : () => setAppState({ openMenu: null });
return ( return (
<mainMenuTunnel.In> <MainMenuTunnel.In>
<DropdownMenu open={appState.openMenu === "canvas"}> <DropdownMenu open={appState.openMenu === "canvas"}>
<DropdownMenu.Trigger <DropdownMenu.Trigger
onToggle={() => { onToggle={() => {
@@ -66,7 +63,7 @@ const MainMenu = Object.assign(
)} )}
</DropdownMenu.Content> </DropdownMenu.Content>
</DropdownMenu> </DropdownMenu>
</mainMenuTunnel.In> </MainMenuTunnel.In>
); );
}, },
), ),
@@ -1,13 +1,10 @@
import { actionLoadScene, actionShortcuts } from "../../actions"; import { actionLoadScene, actionShortcuts } from "../../actions";
import { getShortcutFromShortcutName } from "../../actions/shortcuts"; import { getShortcutFromShortcutName } from "../../actions/shortcuts";
import { t, useI18n } from "../../i18n"; import { t, useI18n } from "../../i18n";
import { import { useDevice, useExcalidrawActionManager } from "../App";
useDevice, import { useTunnels } from "../../context/tunnels";
useExcalidrawActionManager,
useExcalidrawAppState,
} from "../App";
import { useTunnels } from "../context/tunnels";
import { ExcalLogo, HelpIcon, LoadIcon, usersIcon } from "../icons"; import { ExcalLogo, HelpIcon, LoadIcon, usersIcon } from "../icons";
import { useUIAppState } from "../../context/ui-appState";
const WelcomeScreenMenuItemContent = ({ const WelcomeScreenMenuItemContent = ({
icon, icon,
@@ -89,9 +86,9 @@ const WelcomeScreenMenuItemLink = ({
WelcomeScreenMenuItemLink.displayName = "WelcomeScreenMenuItemLink"; WelcomeScreenMenuItemLink.displayName = "WelcomeScreenMenuItemLink";
const Center = ({ children }: { children?: React.ReactNode }) => { const Center = ({ children }: { children?: React.ReactNode }) => {
const { welcomeScreenCenterTunnel } = useTunnels(); const { WelcomeScreenCenterTunnel } = useTunnels();
return ( return (
<welcomeScreenCenterTunnel.In> <WelcomeScreenCenterTunnel.In>
<div className="welcome-screen-center"> <div className="welcome-screen-center">
{children || ( {children || (
<> <>
@@ -104,7 +101,7 @@ const Center = ({ children }: { children?: React.ReactNode }) => {
</> </>
)} )}
</div> </div>
</welcomeScreenCenterTunnel.In> </WelcomeScreenCenterTunnel.In>
); );
}; };
Center.displayName = "Center"; Center.displayName = "Center";
@@ -148,7 +145,7 @@ const MenuItemHelp = () => {
MenuItemHelp.displayName = "MenuItemHelp"; MenuItemHelp.displayName = "MenuItemHelp";
const MenuItemLoadScene = () => { const MenuItemLoadScene = () => {
const appState = useExcalidrawAppState(); const appState = useUIAppState();
const actionManager = useExcalidrawActionManager(); const actionManager = useExcalidrawActionManager();
if (appState.viewModeEnabled) { if (appState.viewModeEnabled) {
@@ -1,5 +1,5 @@
import { t } from "../../i18n"; import { t } from "../../i18n";
import { useTunnels } from "../context/tunnels"; import { useTunnels } from "../../context/tunnels";
import { import {
WelcomeScreenHelpArrow, WelcomeScreenHelpArrow,
WelcomeScreenMenuArrow, WelcomeScreenMenuArrow,
@@ -7,44 +7,44 @@ import {
} from "../icons"; } from "../icons";
const MenuHint = ({ children }: { children?: React.ReactNode }) => { const MenuHint = ({ children }: { children?: React.ReactNode }) => {
const { welcomeScreenMenuHintTunnel } = useTunnels(); const { WelcomeScreenMenuHintTunnel } = useTunnels();
return ( return (
<welcomeScreenMenuHintTunnel.In> <WelcomeScreenMenuHintTunnel.In>
<div className="virgil welcome-screen-decor welcome-screen-decor-hint welcome-screen-decor-hint--menu"> <div className="virgil welcome-screen-decor welcome-screen-decor-hint welcome-screen-decor-hint--menu">
{WelcomeScreenMenuArrow} {WelcomeScreenMenuArrow}
<div className="welcome-screen-decor-hint__label"> <div className="welcome-screen-decor-hint__label">
{children || t("welcomeScreen.defaults.menuHint")} {children || t("welcomeScreen.defaults.menuHint")}
</div> </div>
</div> </div>
</welcomeScreenMenuHintTunnel.In> </WelcomeScreenMenuHintTunnel.In>
); );
}; };
MenuHint.displayName = "MenuHint"; MenuHint.displayName = "MenuHint";
const ToolbarHint = ({ children }: { children?: React.ReactNode }) => { const ToolbarHint = ({ children }: { children?: React.ReactNode }) => {
const { welcomeScreenToolbarHintTunnel } = useTunnels(); const { WelcomeScreenToolbarHintTunnel } = useTunnels();
return ( return (
<welcomeScreenToolbarHintTunnel.In> <WelcomeScreenToolbarHintTunnel.In>
<div className="virgil welcome-screen-decor welcome-screen-decor-hint welcome-screen-decor-hint--toolbar"> <div className="virgil welcome-screen-decor welcome-screen-decor-hint welcome-screen-decor-hint--toolbar">
<div className="welcome-screen-decor-hint__label"> <div className="welcome-screen-decor-hint__label">
{children || t("welcomeScreen.defaults.toolbarHint")} {children || t("welcomeScreen.defaults.toolbarHint")}
</div> </div>
{WelcomeScreenTopToolbarArrow} {WelcomeScreenTopToolbarArrow}
</div> </div>
</welcomeScreenToolbarHintTunnel.In> </WelcomeScreenToolbarHintTunnel.In>
); );
}; };
ToolbarHint.displayName = "ToolbarHint"; ToolbarHint.displayName = "ToolbarHint";
const HelpHint = ({ children }: { children?: React.ReactNode }) => { const HelpHint = ({ children }: { children?: React.ReactNode }) => {
const { welcomeScreenHelpHintTunnel } = useTunnels(); const { WelcomeScreenHelpHintTunnel } = useTunnels();
return ( return (
<welcomeScreenHelpHintTunnel.In> <WelcomeScreenHelpHintTunnel.In>
<div className="virgil welcome-screen-decor welcome-screen-decor-hint welcome-screen-decor-hint--help"> <div className="virgil welcome-screen-decor welcome-screen-decor-hint welcome-screen-decor-hint--help">
<div>{children || t("welcomeScreen.defaults.helpHint")}</div> <div>{children || t("welcomeScreen.defaults.helpHint")}</div>
{WelcomeScreenHelpArrow} {WelcomeScreenHelpArrow}
</div> </div>
</welcomeScreenHelpHintTunnel.In> </WelcomeScreenHelpHintTunnel.In>
); );
}; };
HelpHint.displayName = "HelpHint"; HelpHint.displayName = "HelpHint";
+45 -17
View File
@@ -1,6 +1,7 @@
import cssVariables from "./css/variables.module.scss"; import cssVariables from "./css/variables.module.scss";
import { AppProps } from "./types"; import { AppProps } from "./types";
import { FontFamilyValues } from "./element/types"; import { ExcalidrawElement, FontFamilyValues } from "./element/types";
import oc from "open-color";
export const isDarwin = /Mac|iPod|iPhone|iPad/.test(navigator.platform); export const isDarwin = /Mac|iPod|iPhone|iPad/.test(navigator.platform);
export const isWindows = /^Win/.test(navigator.platform); export const isWindows = /^Win/.test(navigator.platform);
@@ -104,20 +105,30 @@ export const CANVAS_ONLY_ACTIONS = ["selectAll"];
export const GRID_SIZE = 20; // TODO make it configurable? export const GRID_SIZE = 20; // TODO make it configurable?
export const MIME_TYPES = { export const IMAGE_MIME_TYPES = {
excalidraw: "application/vnd.excalidraw+json",
excalidrawlib: "application/vnd.excalidrawlib+json",
json: "application/json",
svg: "image/svg+xml", svg: "image/svg+xml",
"excalidraw.svg": "image/svg+xml",
png: "image/png", png: "image/png",
"excalidraw.png": "image/png",
jpg: "image/jpeg", jpg: "image/jpeg",
gif: "image/gif", gif: "image/gif",
webp: "image/webp", webp: "image/webp",
bmp: "image/bmp", bmp: "image/bmp",
ico: "image/x-icon", ico: "image/x-icon",
avif: "image/avif",
jfif: "image/jfif",
} as const;
export const MIME_TYPES = {
json: "application/json",
// excalidraw data
excalidraw: "application/vnd.excalidraw+json",
excalidrawlib: "application/vnd.excalidrawlib+json",
// image-encoded excalidraw data
"excalidraw.svg": "image/svg+xml",
"excalidraw.png": "image/png",
// binary
binary: "application/octet-stream", binary: "application/octet-stream",
// image
...IMAGE_MIME_TYPES,
} as const; } as const;
export const EXPORT_DATA_TYPES = { export const EXPORT_DATA_TYPES = {
@@ -188,16 +199,6 @@ export const DEFAULT_EXPORT_PADDING = 10; // px
export const DEFAULT_MAX_IMAGE_WIDTH_OR_HEIGHT = 1440; export const DEFAULT_MAX_IMAGE_WIDTH_OR_HEIGHT = 1440;
export const ALLOWED_IMAGE_MIME_TYPES = [
MIME_TYPES.png,
MIME_TYPES.jpg,
MIME_TYPES.svg,
MIME_TYPES.gif,
MIME_TYPES.webp,
MIME_TYPES.bmp,
MIME_TYPES.ico,
] as const;
export const MAX_ALLOWED_FILE_BYTES = 2 * 1024 * 1024; export const MAX_ALLOWED_FILE_BYTES = 2 * 1024 * 1024;
export const SVG_NS = "http://www.w3.org/2000/svg"; export const SVG_NS = "http://www.w3.org/2000/svg";
@@ -254,3 +255,30 @@ export const ROUNDNESS = {
/** 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 DEFAULT_ELEMENT_PROPS: {
strokeColor: ExcalidrawElement["strokeColor"];
backgroundColor: ExcalidrawElement["backgroundColor"];
fillStyle: ExcalidrawElement["fillStyle"];
strokeWidth: ExcalidrawElement["strokeWidth"];
strokeStyle: ExcalidrawElement["strokeStyle"];
roughness: ExcalidrawElement["roughness"];
opacity: ExcalidrawElement["opacity"];
locked: ExcalidrawElement["locked"];
} = {
strokeColor: oc.black,
backgroundColor: "transparent",
fillStyle: "hachure",
strokeWidth: 1,
strokeStyle: "solid",
roughness: 1,
opacity: 100,
locked: false,
};
export const LIBRARY_SIDEBAR_TAB = "library";
export const DEFAULT_SIDEBAR = {
name: "default",
defaultTab: LIBRARY_SIDEBAR_TAB,
} as const;
+36
View File
@@ -0,0 +1,36 @@
import React from "react";
import tunnel from "tunnel-rat";
export type Tunnel = ReturnType<typeof tunnel>;
type TunnelsContextValue = {
MainMenuTunnel: Tunnel;
WelcomeScreenMenuHintTunnel: Tunnel;
WelcomeScreenToolbarHintTunnel: Tunnel;
WelcomeScreenHelpHintTunnel: Tunnel;
WelcomeScreenCenterTunnel: Tunnel;
FooterCenterTunnel: Tunnel;
DefaultSidebarTriggerTunnel: Tunnel;
DefaultSidebarTabTriggersTunnel: Tunnel;
jotaiScope: symbol;
};
export const TunnelsContext = React.createContext<TunnelsContextValue>(null!);
export const useTunnels = () => React.useContext(TunnelsContext);
export const useInitializeTunnels = () => {
return React.useMemo((): TunnelsContextValue => {
return {
MainMenuTunnel: tunnel(),
WelcomeScreenMenuHintTunnel: tunnel(),
WelcomeScreenToolbarHintTunnel: tunnel(),
WelcomeScreenHelpHintTunnel: tunnel(),
WelcomeScreenCenterTunnel: tunnel(),
FooterCenterTunnel: tunnel(),
DefaultSidebarTriggerTunnel: tunnel(),
DefaultSidebarTabTriggersTunnel: tunnel(),
jotaiScope: Symbol(),
};
}, []);
};
+5
View File
@@ -0,0 +1,5 @@
import React from "react";
import { UIAppState } from "../types";
export const UIAppStateContext = React.createContext<UIAppState>(null!);
export const useUIAppState = () => React.useContext(UIAppStateContext);
+6 -1
View File
@@ -155,6 +155,9 @@
margin: 1px; margin: 1px;
} }
.welcome-screen-menu-item:focus-visible,
.dropdown-menu-item:focus-visible,
button:focus-visible,
.buttonList label:focus-within, .buttonList label:focus-within,
input:focus-visible { input:focus-visible {
outline: transparent; outline: transparent;
@@ -351,6 +354,7 @@
border-radius: var(--space-factor); border-radius: var(--space-factor);
border: 1px solid var(--button-gray-2); border: 1px solid var(--button-gray-2);
font-size: 0.8rem; font-size: 0.8rem;
font-family: inherit;
outline: none; outline: none;
appearance: none; appearance: none;
background-image: var(--dropdown-icon); background-image: var(--dropdown-icon);
@@ -410,6 +414,7 @@
bottom: 30px; bottom: 30px;
transform: translateX(-50%); transform: translateX(-50%);
pointer-events: all; pointer-events: all;
font-family: inherit;
&:hover { &:hover {
background-color: var(--button-hover-bg); background-color: var(--button-hover-bg);
@@ -562,7 +567,7 @@
border-radius: 0; border-radius: 0;
} }
.library-button { .default-sidebar-trigger {
border: 0; border: 0;
} }
} }
+6
View File
@@ -78,10 +78,13 @@
--color-selection: #6965db; --color-selection: #6965db;
--color-icon-white: #{$oc-white};
--color-primary: #6965db; --color-primary: #6965db;
--color-primary-darker: #5b57d1; --color-primary-darker: #5b57d1;
--color-primary-darkest: #4a47b1; --color-primary-darkest: #4a47b1;
--color-primary-light: #e3e2fe; --color-primary-light: #e3e2fe;
--color-primary-light-darker: #d7d5ff;
--color-gray-10: #f5f5f5; --color-gray-10: #f5f5f5;
--color-gray-20: #ebebeb; --color-gray-20: #ebebeb;
@@ -161,10 +164,13 @@
// will be inverted to a lighter color. // will be inverted to a lighter color.
--color-selection: #3530c4; --color-selection: #3530c4;
--color-icon-white: var(--color-gray-90);
--color-primary: #a8a5ff; --color-primary: #a8a5ff;
--color-primary-darker: #b2aeff; --color-primary-darker: #b2aeff;
--color-primary-darkest: #beb9ff; --color-primary-darkest: #beb9ff;
--color-primary-light: #4f4d6f; --color-primary-light: #4f4d6f;
--color-primary-light-darker: #43415e;
--color-text-warning: var(--color-gray-80); --color-text-warning: var(--color-gray-80);
+14 -4
View File
@@ -72,7 +72,14 @@
&:hover { &:hover {
background-color: var(--button-hover-bg, var(--island-bg-color)); background-color: var(--button-hover-bg, var(--island-bg-color));
border-color: var(--button-hover-border, var(--default-border-color)); border-color: var(
--button-hover-border,
var(--button-border, var(--default-border-color))
);
color: var(
--button-hover-color,
var(--button-color, var(--text-primary-color, inherit))
);
} }
&:active { &:active {
@@ -81,11 +88,14 @@
} }
&.active { &.active {
background-color: var(--color-primary-light); background-color: var(--button-selected-bg, var(--color-primary-light));
border-color: var(--color-primary-light); border-color: var(--button-selected-border, var(--color-primary-light));
&:hover { &:hover {
background-color: var(--color-primary-light); background-color: var(
--button-selected-hover-bg,
var(--color-primary-light)
);
} }
svg { svg {
+4 -6
View File
@@ -1,6 +1,6 @@
import { nanoid } from "nanoid"; import { nanoid } from "nanoid";
import { cleanAppStateForExport } from "../appState"; import { cleanAppStateForExport } from "../appState";
import { ALLOWED_IMAGE_MIME_TYPES, MIME_TYPES } from "../constants"; import { IMAGE_MIME_TYPES, MIME_TYPES } from "../constants";
import { clearElementsForExport } from "../element"; import { clearElementsForExport } from "../element";
import { ExcalidrawElement, FileId } from "../element/types"; import { ExcalidrawElement, FileId } from "../element/types";
import { CanvasError } from "../errors"; import { CanvasError } from "../errors";
@@ -117,11 +117,9 @@ export const isImageFileHandle = (handle: FileSystemHandle | null) => {
export const isSupportedImageFile = ( export const isSupportedImageFile = (
blob: Blob | null | undefined, blob: Blob | null | undefined,
): blob is Blob & { type: typeof ALLOWED_IMAGE_MIME_TYPES[number] } => { ): blob is Blob & { type: ValueOf<typeof IMAGE_MIME_TYPES> } => {
const { type } = blob || {}; const { type } = blob || {};
return ( return !!type && (Object.values(IMAGE_MIME_TYPES) as string[]).includes(type);
!!type && (ALLOWED_IMAGE_MIME_TYPES as readonly string[]).includes(type)
);
}; };
export const loadSceneOrLibraryFromBlob = async ( export const loadSceneOrLibraryFromBlob = async (
@@ -157,7 +155,7 @@ export const loadSceneOrLibraryFromBlob = async (
}, },
localAppState, localAppState,
localElements, localElements,
{ repairBindings: true, refreshDimensions: true }, { repairBindings: true, refreshDimensions: false },
), ),
}; };
} else if (isValidLibrary(data)) { } else if (isValidLibrary(data)) {
+1 -10
View File
@@ -8,16 +8,7 @@ import { EVENT, MIME_TYPES } from "../constants";
import { AbortError } from "../errors"; import { AbortError } from "../errors";
import { debounce } from "../utils"; import { debounce } from "../utils";
type FILE_EXTENSION = type FILE_EXTENSION = Exclude<keyof typeof MIME_TYPES, "binary">;
| "gif"
| "jpg"
| "png"
| "excalidraw.png"
| "svg"
| "excalidraw.svg"
| "json"
| "excalidraw"
| "excalidrawlib";
const INPUT_CHANGE_INTERVAL_MS = 500; const INPUT_CHANGE_INTERVAL_MS = 500;
+18 -4
View File
@@ -14,7 +14,14 @@ import { getCommonBoundingBox } from "../element/bounds";
import { AbortError } from "../errors"; import { AbortError } from "../errors";
import { t } from "../i18n"; import { t } from "../i18n";
import { useEffect, useRef } from "react"; import { useEffect, useRef } from "react";
import { URL_HASH_KEYS, URL_QUERY_KEYS, APP_NAME, EVENT } from "../constants"; import {
URL_HASH_KEYS,
URL_QUERY_KEYS,
APP_NAME,
EVENT,
DEFAULT_SIDEBAR,
LIBRARY_SIDEBAR_TAB,
} from "../constants";
export const libraryItemsAtom = atom<{ export const libraryItemsAtom = atom<{
status: "loading" | "loaded"; status: "loading" | "loaded";
@@ -148,7 +155,9 @@ class Library {
defaultStatus?: "unpublished" | "published"; defaultStatus?: "unpublished" | "published";
}): Promise<LibraryItems> => { }): Promise<LibraryItems> => {
if (openLibraryMenu) { if (openLibraryMenu) {
this.app.setState({ openSidebar: "library" }); this.app.setState({
openSidebar: { name: DEFAULT_SIDEBAR.name, tab: LIBRARY_SIDEBAR_TAB },
});
} }
return this.setLibrary(() => { return this.setLibrary(() => {
@@ -174,6 +183,13 @@ class Library {
}), }),
) )
) { ) {
if (prompt) {
// focus container if we've prompted. We focus conditionally
// lest `props.autoFocus` is disabled (in which case we should
// focus only on user action such as prompt confirm)
this.app.focusContainer();
}
if (merge) { if (merge) {
resolve(mergeLibraryItems(this.lastLibraryItems, nextItems)); resolve(mergeLibraryItems(this.lastLibraryItems, nextItems));
} else { } else {
@@ -186,8 +202,6 @@ class Library {
reject(error); reject(error);
} }
}); });
}).finally(() => {
this.app.focusContainer();
}); });
}; };
+49 -36
View File
@@ -27,15 +27,20 @@ import {
PRECEDING_ELEMENT_KEY, PRECEDING_ELEMENT_KEY,
FONT_FAMILY, FONT_FAMILY,
ROUNDNESS, ROUNDNESS,
DEFAULT_SIDEBAR,
} 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 { getFontString, getUpdatedTimestamp, updateActiveTool } from "../utils";
import { arrayToMap } from "../utils"; import { arrayToMap } from "../utils";
import oc from "open-color"; import oc from "open-color";
import { MarkOptional, Mutable } from "../utility-types"; import { MarkOptional, Mutable } from "../utility-types";
import { detectLineHeight, getDefaultLineHeight } from "../element/textElement"; import {
detectLineHeight,
getDefaultLineHeight,
measureBaseline,
} from "../element/textElement";
type RestoredAppState = Omit< type RestoredAppState = Omit<
AppState, AppState,
@@ -171,6 +176,24 @@ const restoreElement = (
} }
const text = element.text ?? ""; const text = element.text ?? "";
// line-height might not be specified either when creating elements
// programmatically, or when importing old diagrams.
// For the latter we want to detect the original line height which
// will likely differ from our per-font fixed line height we now use,
// to maintain backward compatibility.
const lineHeight =
element.lineHeight ||
(element.height
? // detect line-height from current element height and font-size
detectLineHeight(element)
: // no element height likely means programmatic use, so default
// to a fixed line height
getDefaultLineHeight(element.fontFamily));
const baseline = measureBaseline(
element.text,
getFontString(element),
lineHeight,
);
element = restoreElementWithProperties(element, { element = restoreElementWithProperties(element, {
fontSize, fontSize,
fontFamily, fontFamily,
@@ -179,19 +202,9 @@ const restoreElement = (
verticalAlign: element.verticalAlign || DEFAULT_VERTICAL_ALIGN, verticalAlign: element.verticalAlign || DEFAULT_VERTICAL_ALIGN,
containerId: element.containerId ?? null, containerId: element.containerId ?? null,
originalText: element.originalText || text, originalText: element.originalText || text,
// line-height might not be specified either when creating elements
// programmatically, or when importing old diagrams. lineHeight,
// For the latter we want to detect the original line height which baseline,
// will likely differ from our per-font fixed line height we now use,
// to maintain backward compatibility.
lineHeight:
element.lineHeight ||
(element.height
? // detect line-height from current element height and font-size
detectLineHeight(element)
: // no element height likely means programmatic use, so default
// to a fixed line height
getDefaultLineHeight(element.fontFamily)),
}); });
if (refreshDimensions) { if (refreshDimensions) {
@@ -357,6 +370,9 @@ export const restoreElements = (
localElements: readonly ExcalidrawElement[] | null | undefined, localElements: readonly ExcalidrawElement[] | null | undefined,
opts?: { refreshDimensions?: boolean; repairBindings?: boolean } | undefined, opts?: { refreshDimensions?: boolean; repairBindings?: boolean } | undefined,
): ExcalidrawElement[] => { ): ExcalidrawElement[] => {
// used to detect duplicate top-level element ids
const existingIds = new Set<string>();
const localElementsMap = localElements ? arrayToMap(localElements) : null; const localElementsMap = localElements ? arrayToMap(localElements) : null;
const restoredElements = (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,
@@ -371,6 +387,10 @@ export const restoreElements = (
if (localElement && localElement.version > migratedElement.version) { if (localElement && localElement.version > migratedElement.version) {
migratedElement = bumpVersion(migratedElement, localElement.version); migratedElement = bumpVersion(migratedElement, localElement.version);
} }
if (existingIds.has(migratedElement.id)) {
migratedElement = { ...migratedElement, id: randomId() };
}
existingIds.add(migratedElement.id);
elements.push(migratedElement); elements.push(migratedElement);
} }
} }
@@ -412,21 +432,15 @@ const LegacyAppStateMigrations: {
defaultAppState: ReturnType<typeof getDefaultAppState>, defaultAppState: ReturnType<typeof getDefaultAppState>,
) => [LegacyAppState[K][1], AppState[LegacyAppState[K][1]]]; ) => [LegacyAppState[K][1], AppState[LegacyAppState[K][1]]];
} = { } = {
isLibraryOpen: (appState, defaultAppState) => { isSidebarDocked: (appState, defaultAppState) => {
return [ return [
"openSidebar", "defaultSidebarDockedPreference",
"isLibraryOpen" in appState appState.isSidebarDocked ??
? appState.isLibraryOpen coalesceAppStateValue(
? "library" "defaultSidebarDockedPreference",
: null appState,
: coalesceAppStateValue("openSidebar", appState, defaultAppState), defaultAppState,
]; ),
},
isLibraryMenuDocked: (appState, defaultAppState) => {
return [
"isSidebarDocked",
appState.isLibraryMenuDocked ??
coalesceAppStateValue("isSidebarDocked", appState, defaultAppState),
]; ];
}, },
}; };
@@ -495,14 +509,13 @@ export const restoreAppState = (
? { ? {
value: appState.zoom as NormalizedZoomValue, value: appState.zoom as NormalizedZoomValue,
} }
: appState.zoom || defaultAppState.zoom, : appState.zoom?.value
// when sidebar docked and user left it open in last session, ? appState.zoom
// keep it open. If not docked, keep it closed irrespective of last state. : defaultAppState.zoom,
openSidebar: openSidebar:
nextAppState.openSidebar === "library" // string (legacy)
? nextAppState.isSidebarDocked typeof (appState.openSidebar as any as string) === "string"
? "library" ? { name: DEFAULT_SIDEBAR.name }
: null
: nextAppState.openSidebar, : nextAppState.openSidebar,
}; };
}; };
+2 -4
View File
@@ -25,10 +25,8 @@ export interface ExportedDataState {
* Don't consume on its own. * Don't consume on its own.
*/ */
export type LegacyAppState = { export type LegacyAppState = {
/** @deprecated #5663 TODO remove 22-12-15 */ /** @deprecated #6213 TODO remove 23-06-01 */
isLibraryOpen: [boolean, "openSidebar"]; isSidebarDocked: [boolean, "defaultSidebarDockedPreference"];
/** @deprecated #5663 TODO remove 22-12-15 */
isLibraryMenuDocked: [boolean, "isSidebarDocked"];
}; };
export interface ImportedDataState { export interface ImportedDataState {
+3 -2
View File
@@ -1,4 +1,4 @@
import { AppState, ExcalidrawProps, Point } from "../types"; import { AppState, ExcalidrawProps, Point, UIAppState } from "../types";
import { import {
getShortcutKey, getShortcutKey,
sceneCoordsToViewportCoords, sceneCoordsToViewportCoords,
@@ -297,10 +297,11 @@ export const getContextMenuLabel = (
: "labels.link.create"; : "labels.link.create";
return label; return label;
}; };
export const getLinkHandleFromCoords = ( export const getLinkHandleFromCoords = (
[x1, y1, x2, y2]: Bounds, [x1, y1, x2, y2]: Bounds,
angle: number, angle: number,
appState: AppState, appState: UIAppState,
): [x: number, y: number, width: number, height: number] => { ): [x: number, y: number, width: number, height: number] => {
const size = DEFAULT_LINK_SIZE; const size = DEFAULT_LINK_SIZE;
const linkWidth = size / appState.zoom.value; const linkWidth = size / appState.zoom.value;
+339 -64
View File
@@ -1,8 +1,9 @@
import { duplicateElement } from "./newElement"; import { duplicateElement, duplicateElements } 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, ROUNDNESS } from "../constants"; import { FONT_FAMILY, ROUNDNESS } from "../constants";
import { isPrimitive } from "../utils"; import { isPrimitive } from "../utils";
import { ExcalidrawLinearElement } from "./types";
const assertCloneObjects = (source: any, clone: any) => { const assertCloneObjects = (source: any, clone: any) => {
for (const key in clone) { for (const key in clone) {
@@ -15,79 +16,353 @@ const assertCloneObjects = (source: any, clone: any) => {
} }
}; };
it("clones arrow element", () => { describe("duplicating single elements", () => {
const element = API.createElement({ it("clones arrow element", () => {
type: "arrow", const element = API.createElement({
x: 0, type: "arrow",
y: 0, x: 0,
strokeColor: "#000000", y: 0,
backgroundColor: "transparent", strokeColor: "#000000",
fillStyle: "hachure", backgroundColor: "transparent",
strokeWidth: 1, fillStyle: "hachure",
strokeStyle: "solid", strokeWidth: 1,
roundness: { type: ROUNDNESS.PROPORTIONAL_RADIUS }, strokeStyle: "solid",
roughness: 1, roundness: { type: ROUNDNESS.PROPORTIONAL_RADIUS },
opacity: 100, roughness: 1,
opacity: 100,
});
// @ts-ignore
element.__proto__ = { hello: "world" };
mutateElement(element, {
points: [
[1, 2],
[3, 4],
],
});
const copy = duplicateElement(null, new Map(), element);
assertCloneObjects(element, copy);
// assert we clone the object's prototype
// @ts-ignore
expect(copy.__proto__).toEqual({ hello: "world" });
expect(copy.hasOwnProperty("hello")).toBe(false);
expect(copy.points).not.toBe(element.points);
expect(copy).not.toHaveProperty("shape");
expect(copy.id).not.toBe(element.id);
expect(typeof copy.id).toBe("string");
expect(copy.seed).not.toBe(element.seed);
expect(typeof copy.seed).toBe("number");
expect(copy).toEqual({
...element,
id: copy.id,
seed: copy.seed,
});
}); });
// @ts-ignore it("clones text element", () => {
element.__proto__ = { hello: "world" }; const element = API.createElement({
type: "text",
x: 0,
y: 0,
strokeColor: "#000000",
backgroundColor: "transparent",
fillStyle: "hachure",
strokeWidth: 1,
strokeStyle: "solid",
roundness: null,
roughness: 1,
opacity: 100,
text: "hello",
fontSize: 20,
fontFamily: FONT_FAMILY.Virgil,
textAlign: "left",
verticalAlign: "top",
});
mutateElement(element, { const copy = duplicateElement(null, new Map(), element);
points: [
[1, 2],
[3, 4],
],
});
const copy = duplicateElement(null, new Map(), element); assertCloneObjects(element, copy);
assertCloneObjects(element, copy); expect(copy).not.toHaveProperty("points");
expect(copy).not.toHaveProperty("shape");
// @ts-ignore expect(copy.id).not.toBe(element.id);
expect(copy.__proto__).toEqual({ hello: "world" }); expect(typeof copy.id).toBe("string");
expect(copy.hasOwnProperty("hello")).toBe(false); expect(typeof copy.seed).toBe("number");
expect(copy.points).not.toBe(element.points);
expect(copy).not.toHaveProperty("shape");
expect(copy.id).not.toBe(element.id);
expect(typeof copy.id).toBe("string");
expect(copy.seed).not.toBe(element.seed);
expect(typeof copy.seed).toBe("number");
expect(copy).toEqual({
...element,
id: copy.id,
seed: copy.seed,
}); });
}); });
it("clones text element", () => { describe("duplicating multiple elements", () => {
const element = API.createElement({ it("duplicateElements should clone bindings", () => {
type: "text", const rectangle1 = API.createElement({
x: 0, type: "rectangle",
y: 0, id: "rectangle1",
strokeColor: "#000000", boundElements: [
backgroundColor: "transparent", { id: "arrow1", type: "arrow" },
fillStyle: "hachure", { id: "arrow2", type: "arrow" },
strokeWidth: 1, { id: "text1", type: "text" },
strokeStyle: "solid", ],
roundness: null, });
roughness: 1,
opacity: 100, const text1 = API.createElement({
text: "hello", type: "text",
fontSize: 20, id: "text1",
fontFamily: FONT_FAMILY.Virgil, containerId: "rectangle1",
textAlign: "left", });
verticalAlign: "top",
const arrow1 = API.createElement({
type: "arrow",
id: "arrow1",
startBinding: {
elementId: "rectangle1",
focus: 0.2,
gap: 7,
},
});
const arrow2 = API.createElement({
type: "arrow",
id: "arrow2",
endBinding: {
elementId: "rectangle1",
focus: 0.2,
gap: 7,
},
boundElements: [{ id: "text2", type: "text" }],
});
const text2 = API.createElement({
type: "text",
id: "text2",
containerId: "arrow2",
});
// -------------------------------------------------------------------------
const origElements = [rectangle1, text1, arrow1, arrow2, text2] as const;
const clonedElements = duplicateElements(origElements);
// generic id in-equality checks
// --------------------------------------------------------------------------
expect(origElements.map((e) => e.type)).toEqual(
clonedElements.map((e) => e.type),
);
origElements.forEach((origElement, idx) => {
const clonedElement = clonedElements[idx];
expect(origElement).toEqual(
expect.objectContaining({
id: expect.not.stringMatching(clonedElement.id),
type: clonedElement.type,
}),
);
if ("containerId" in origElement) {
expect(origElement.containerId).not.toBe(
(clonedElement as any).containerId,
);
}
if ("endBinding" in origElement) {
if (origElement.endBinding) {
expect(origElement.endBinding.elementId).not.toBe(
(clonedElement as any).endBinding?.elementId,
);
} else {
expect((clonedElement as any).endBinding).toBeNull();
}
}
if ("startBinding" in origElement) {
if (origElement.startBinding) {
expect(origElement.startBinding.elementId).not.toBe(
(clonedElement as any).startBinding?.elementId,
);
} else {
expect((clonedElement as any).startBinding).toBeNull();
}
}
});
// --------------------------------------------------------------------------
const clonedArrows = clonedElements.filter(
(e) => e.type === "arrow",
) as ExcalidrawLinearElement[];
const [clonedRectangle, clonedText1, , clonedArrow2, clonedArrowLabel] =
clonedElements as any as typeof origElements;
expect(clonedText1.containerId).toBe(clonedRectangle.id);
expect(
clonedRectangle.boundElements!.find((e) => e.id === clonedText1.id),
).toEqual(
expect.objectContaining({
id: clonedText1.id,
type: clonedText1.type,
}),
);
clonedArrows.forEach((arrow) => {
// console.log(arrow);
expect(
clonedRectangle.boundElements!.find((e) => e.id === arrow.id),
).toEqual(
expect.objectContaining({
id: arrow.id,
type: arrow.type,
}),
);
if (arrow.endBinding) {
expect(arrow.endBinding.elementId).toBe(clonedRectangle.id);
}
if (arrow.startBinding) {
expect(arrow.startBinding.elementId).toBe(clonedRectangle.id);
}
});
expect(clonedArrow2.boundElements).toEqual([
{ type: "text", id: clonedArrowLabel.id },
]);
expect(clonedArrowLabel.containerId).toBe(clonedArrow2.id);
}); });
const copy = duplicateElement(null, new Map(), element); it("should remove id references of elements that aren't found", () => {
const rectangle1 = API.createElement({
type: "rectangle",
id: "rectangle1",
boundElements: [
// should keep
{ id: "arrow1", type: "arrow" },
// should drop
{ id: "arrow-not-exists", type: "arrow" },
// should drop
{ id: "text-not-exists", type: "text" },
],
});
assertCloneObjects(element, copy); const arrow1 = API.createElement({
type: "arrow",
id: "arrow1",
startBinding: {
elementId: "rectangle1",
focus: 0.2,
gap: 7,
},
});
expect(copy).not.toHaveProperty("points"); const text1 = API.createElement({
expect(copy).not.toHaveProperty("shape"); type: "text",
expect(copy.id).not.toBe(element.id); id: "text1",
expect(typeof copy.id).toBe("string"); containerId: "rectangle-not-exists",
expect(typeof copy.seed).toBe("number"); });
const arrow2 = API.createElement({
type: "arrow",
id: "arrow2",
startBinding: {
elementId: "rectangle1",
focus: 0.2,
gap: 7,
},
endBinding: {
elementId: "rectangle-not-exists",
focus: 0.2,
gap: 7,
},
});
const arrow3 = API.createElement({
type: "arrow",
id: "arrow2",
startBinding: {
elementId: "rectangle-not-exists",
focus: 0.2,
gap: 7,
},
endBinding: {
elementId: "rectangle1",
focus: 0.2,
gap: 7,
},
});
// -------------------------------------------------------------------------
const origElements = [rectangle1, text1, arrow1, arrow2, arrow3] as const;
const clonedElements = duplicateElements(
origElements,
) as any as typeof origElements;
const [
clonedRectangle,
clonedText1,
clonedArrow1,
clonedArrow2,
clonedArrow3,
] = clonedElements;
expect(clonedRectangle.boundElements).toEqual([
{ id: clonedArrow1.id, type: "arrow" },
]);
expect(clonedText1.containerId).toBe(null);
expect(clonedArrow2.startBinding).toEqual({
...arrow2.startBinding,
elementId: clonedRectangle.id,
});
expect(clonedArrow2.endBinding).toBe(null);
expect(clonedArrow3.startBinding).toBe(null);
expect(clonedArrow3.endBinding).toEqual({
...arrow3.endBinding,
elementId: clonedRectangle.id,
});
});
describe("should duplicate all group ids", () => {
it("should regenerate all group ids and keep them consistent across elements", () => {
const rectangle1 = API.createElement({
type: "rectangle",
groupIds: ["g1"],
});
const rectangle2 = API.createElement({
type: "rectangle",
groupIds: ["g2", "g1"],
});
const rectangle3 = API.createElement({
type: "rectangle",
groupIds: ["g2", "g1"],
});
const origElements = [rectangle1, rectangle2, rectangle3] as const;
const clonedElements = duplicateElements(
origElements,
) as any as typeof origElements;
const [clonedRectangle1, clonedRectangle2, clonedRectangle3] =
clonedElements;
expect(rectangle1.groupIds[0]).not.toBe(clonedRectangle1.groupIds[0]);
expect(rectangle2.groupIds[0]).not.toBe(clonedRectangle2.groupIds[0]);
expect(rectangle2.groupIds[1]).not.toBe(clonedRectangle2.groupIds[1]);
expect(clonedRectangle1.groupIds[0]).toBe(clonedRectangle2.groupIds[1]);
expect(clonedRectangle2.groupIds[0]).toBe(clonedRectangle3.groupIds[0]);
expect(clonedRectangle2.groupIds[1]).toBe(clonedRectangle3.groupIds[1]);
});
it("should keep and regenerate ids of groups even if invalid", () => {
// lone element shouldn't be able to be grouped with itself,
// but hard to check against in a performant way so we ignore it
const rectangle1 = API.createElement({
type: "rectangle",
groupIds: ["g1"],
});
const [clonedRectangle1] = duplicateElements([rectangle1]);
expect(typeof clonedRectangle1.groupIds[0]).toBe("string");
expect(rectangle1.groupIds[0]).not.toBe(clonedRectangle1.groupIds[0]);
});
});
}); });
+253 -52
View File
@@ -13,9 +13,14 @@ import {
FontFamilyValues, FontFamilyValues,
ExcalidrawTextContainer, ExcalidrawTextContainer,
} from "../element/types"; } from "../element/types";
import { getFontString, getUpdatedTimestamp, isTestEnv } from "../utils"; import {
arrayToMap,
getFontString,
getUpdatedTimestamp,
isTestEnv,
} from "../utils";
import { randomInteger, randomId } from "../random"; import { randomInteger, randomId } from "../random";
import { mutateElement, newElementWith } from "./mutateElement"; import { bumpVersion, mutateElement, newElementWith } from "./mutateElement";
import { getNewGroupIdsForDuplication } from "../groups"; import { getNewGroupIdsForDuplication } from "../groups";
import { AppState } from "../types"; import { AppState } from "../types";
import { getElementAbsoluteCoords } from "."; import { getElementAbsoluteCoords } from ".";
@@ -28,10 +33,17 @@ import {
measureText, measureText,
normalizeText, normalizeText,
wrapText, wrapText,
getMaxContainerWidth, getBoundTextMaxWidth,
getDefaultLineHeight, getDefaultLineHeight,
} from "./textElement"; } from "./textElement";
import { VERTICAL_ALIGN } from "../constants"; import {
DEFAULT_ELEMENT_PROPS,
DEFAULT_FONT_FAMILY,
DEFAULT_FONT_SIZE,
DEFAULT_TEXT_ALIGN,
DEFAULT_VERTICAL_ALIGN,
VERTICAL_ALIGN,
} from "../constants";
import { isArrowElement } from "./typeChecks"; import { isArrowElement } from "./typeChecks";
import { MarkOptional, Merge, Mutable } from "../utility-types"; import { MarkOptional, Merge, Mutable } from "../utility-types";
@@ -46,6 +58,15 @@ type ElementConstructorOpts = MarkOptional<
| "version" | "version"
| "versionNonce" | "versionNonce"
| "link" | "link"
| "strokeStyle"
| "fillStyle"
| "strokeColor"
| "backgroundColor"
| "roughness"
| "strokeWidth"
| "roundness"
| "locked"
| "opacity"
>; >;
const _newElementBase = <T extends ExcalidrawElement>( const _newElementBase = <T extends ExcalidrawElement>(
@@ -53,13 +74,13 @@ const _newElementBase = <T extends ExcalidrawElement>(
{ {
x, x,
y, y,
strokeColor, strokeColor = DEFAULT_ELEMENT_PROPS.strokeColor,
backgroundColor, backgroundColor = DEFAULT_ELEMENT_PROPS.backgroundColor,
fillStyle, fillStyle = DEFAULT_ELEMENT_PROPS.fillStyle,
strokeWidth, strokeWidth = DEFAULT_ELEMENT_PROPS.strokeWidth,
strokeStyle, strokeStyle = DEFAULT_ELEMENT_PROPS.strokeStyle,
roughness, roughness = DEFAULT_ELEMENT_PROPS.roughness,
opacity, opacity = DEFAULT_ELEMENT_PROPS.opacity,
width = 0, width = 0,
height = 0, height = 0,
angle = 0, angle = 0,
@@ -67,7 +88,7 @@ const _newElementBase = <T extends ExcalidrawElement>(
roundness = null, roundness = null,
boundElements = null, boundElements = null,
link = null, link = null,
locked, locked = DEFAULT_ELEMENT_PROPS.locked,
...rest ...rest
}: ElementConstructorOpts & Omit<Partial<ExcalidrawGenericElement>, "type">, }: ElementConstructorOpts & Omit<Partial<ExcalidrawGenericElement>, "type">,
) => { ) => {
@@ -133,30 +154,44 @@ const getTextElementPositionOffsets = (
export const newTextElement = ( export const newTextElement = (
opts: { opts: {
text: string; text: string;
fontSize: number; fontSize?: number;
fontFamily: FontFamilyValues; fontFamily?: FontFamilyValues;
textAlign: TextAlign; textAlign?: TextAlign;
verticalAlign: VerticalAlign; verticalAlign?: VerticalAlign;
containerId?: ExcalidrawTextContainer["id"]; containerId?: ExcalidrawTextContainer["id"];
lineHeight?: ExcalidrawTextElement["lineHeight"]; lineHeight?: ExcalidrawTextElement["lineHeight"];
strokeWidth?: ExcalidrawTextElement["strokeWidth"];
} & ElementConstructorOpts, } & ElementConstructorOpts,
): NonDeleted<ExcalidrawTextElement> => { ): NonDeleted<ExcalidrawTextElement> => {
const lineHeight = opts.lineHeight || getDefaultLineHeight(opts.fontFamily); const fontFamily = opts.fontFamily || DEFAULT_FONT_FAMILY;
const fontSize = opts.fontSize || DEFAULT_FONT_SIZE;
const lineHeight = opts.lineHeight || getDefaultLineHeight(fontFamily);
const text = normalizeText(opts.text); const text = normalizeText(opts.text);
const metrics = measureText(text, getFontString(opts), lineHeight); const metrics = measureText(
const offsets = getTextElementPositionOffsets(opts, metrics); text,
getFontString({ fontFamily, fontSize }),
lineHeight,
);
const textAlign = opts.textAlign || DEFAULT_TEXT_ALIGN;
const verticalAlign = opts.verticalAlign || DEFAULT_VERTICAL_ALIGN;
const offsets = getTextElementPositionOffsets(
{ textAlign, verticalAlign },
metrics,
);
const textElement = newElementWith( const textElement = newElementWith(
{ {
..._newElementBase<ExcalidrawTextElement>("text", opts), ..._newElementBase<ExcalidrawTextElement>("text", opts),
text, text,
fontSize: opts.fontSize, fontSize,
fontFamily: opts.fontFamily, fontFamily,
textAlign: opts.textAlign, textAlign,
verticalAlign: opts.verticalAlign, verticalAlign,
x: opts.x - offsets.x, x: opts.x - offsets.x,
y: opts.y - offsets.y, y: opts.y - offsets.y,
width: metrics.width, width: metrics.width,
height: metrics.height, height: metrics.height,
baseline: metrics.baseline,
containerId: opts.containerId || null, containerId: opts.containerId || null,
originalText: text, originalText: text,
lineHeight, lineHeight,
@@ -174,14 +209,15 @@ const getAdjustedDimensions = (
y: number; y: number;
width: number; width: number;
height: number; height: number;
baseline: number;
} => { } => {
const container = getContainerElement(element); const container = getContainerElement(element);
const { width: nextWidth, height: nextHeight } = measureText( const {
nextText, width: nextWidth,
getFontString(element), height: nextHeight,
element.lineHeight, baseline: nextBaseline,
); } = measureText(nextText, getFontString(element), element.lineHeight);
const { textAlign, verticalAlign } = element; const { textAlign, verticalAlign } = element;
let x: number; let x: number;
let y: number; let y: number;
@@ -256,6 +292,7 @@ const getAdjustedDimensions = (
return { return {
width: nextWidth, width: nextWidth,
height: nextHeight, height: nextHeight,
baseline: nextBaseline,
x: Number.isFinite(x) ? x : element.x, x: Number.isFinite(x) ? x : element.x,
y: Number.isFinite(y) ? y : element.y, y: Number.isFinite(y) ? y : element.y,
}; };
@@ -265,12 +302,15 @@ export const refreshTextDimensions = (
textElement: ExcalidrawTextElement, textElement: ExcalidrawTextElement,
text = textElement.text, text = textElement.text,
) => { ) => {
if (textElement.isDeleted) {
return;
}
const container = getContainerElement(textElement); const container = getContainerElement(textElement);
if (container) { if (container) {
text = wrapText( text = wrapText(
text, text,
getFontString(textElement), getFontString(textElement),
getMaxContainerWidth(container), getBoundTextMaxWidth(container),
); );
} }
const dimensions = getAdjustedDimensions(textElement, text); const dimensions = getAdjustedDimensions(textElement, text);
@@ -350,16 +390,24 @@ export const newImageElement = (
}; };
}; };
// Simplified deep clone for the purpose of cloning ExcalidrawElement only // Simplified deep clone for the purpose of cloning ExcalidrawElement.
// (doesn't clone Date, RegExp, Map, Set, Typed arrays etc.) //
// Only clones plain objects and arrays. Doesn't clone Date, RegExp, Map, Set,
// Typed arrays and other non-null objects.
// //
// Adapted from https://github.com/lukeed/klona // Adapted from https://github.com/lukeed/klona
export const deepCopyElement = (val: any, depth: number = 0) => { //
// The reason for `deepCopyElement()` wrapper is type safety (only allow
// passing ExcalidrawElement as the top-level argument).
const _deepCopyElement = (val: any, depth: number = 0) => {
// only clone non-primitives
if (val == null || typeof val !== "object") { if (val == null || typeof val !== "object") {
return val; return val;
} }
if (Object.prototype.toString.call(val) === "[object Object]") { const objectType = Object.prototype.toString.call(val);
if (objectType === "[object Object]") {
const tmp = const tmp =
typeof val.constructor === "function" typeof val.constructor === "function"
? Object.create(Object.getPrototypeOf(val)) ? Object.create(Object.getPrototypeOf(val))
@@ -371,7 +419,7 @@ export const deepCopyElement = (val: any, depth: number = 0) => {
if (depth === 0 && (key === "shape" || key === "canvas")) { if (depth === 0 && (key === "shape" || key === "canvas")) {
continue; continue;
} }
tmp[key] = deepCopyElement(val[key], depth + 1); tmp[key] = _deepCopyElement(val[key], depth + 1);
} }
} }
return tmp; return tmp;
@@ -381,14 +429,67 @@ export const deepCopyElement = (val: any, depth: number = 0) => {
let k = val.length; let k = val.length;
const arr = new Array(k); const arr = new Array(k);
while (k--) { while (k--) {
arr[k] = deepCopyElement(val[k], depth + 1); arr[k] = _deepCopyElement(val[k], depth + 1);
} }
return arr; return arr;
} }
// we're not cloning non-array & non-plain-object objects because we
// don't support them on excalidraw elements yet. If we do, we need to make
// sure we start cloning them, so let's warn about it.
if (process.env.NODE_ENV === "development") {
if (
objectType !== "[object Object]" &&
objectType !== "[object Array]" &&
objectType.startsWith("[object ")
) {
console.warn(
`_deepCloneElement: unexpected object type ${objectType}. This value will not be cloned!`,
);
}
}
return val; return val;
}; };
/**
* Clones ExcalidrawElement data structure. Does not regenerate id, nonce, or
* any value. The purpose is to to break object references for immutability
* reasons, whenever we want to keep the original element, but ensure it's not
* mutated.
*
* Only clones plain objects and arrays. Doesn't clone Date, RegExp, Map, Set,
* Typed arrays and other non-null objects.
*/
export const deepCopyElement = <T extends ExcalidrawElement>(
val: T,
): Mutable<T> => {
return _deepCopyElement(val);
};
/**
* utility wrapper to generate new id. In test env it reuses the old + postfix
* for test assertions.
*/
const regenerateId = (
/** supply null if no previous id exists */
previousId: string | null,
) => {
if (isTestEnv() && previousId) {
let nextId = `${previousId}_copy`;
// `window.h` may not be defined in some unit tests
if (
window.h?.app
?.getSceneElementsIncludingDeleted()
.find((el) => el.id === nextId)
) {
nextId += "_copy";
}
return nextId;
}
return randomId();
};
/** /**
* Duplicate an element, often used in the alt-drag operation. * Duplicate an element, often used in the alt-drag operation.
* Note that this method has gotten a bit complicated since the * Note that this method has gotten a bit complicated since the
@@ -403,27 +504,15 @@ export const deepCopyElement = (val: any, depth: number = 0) => {
* @param element Element to duplicate * @param element Element to duplicate
* @param overrides Any element properties to override * @param overrides Any element properties to override
*/ */
export const duplicateElement = <TElement extends Mutable<ExcalidrawElement>>( export const duplicateElement = <TElement extends ExcalidrawElement>(
editingGroupId: AppState["editingGroupId"], editingGroupId: AppState["editingGroupId"],
groupIdMapForOperation: Map<GroupId, GroupId>, groupIdMapForOperation: Map<GroupId, GroupId>,
element: TElement, element: TElement,
overrides?: Partial<TElement>, overrides?: Partial<TElement>,
): TElement => { ): Readonly<TElement> => {
let copy: TElement = deepCopyElement(element); let copy = deepCopyElement(element);
if (isTestEnv()) { copy.id = regenerateId(copy.id);
copy.id = `${copy.id}_copy`;
// `window.h` may not be defined in some unit tests
if (
window.h?.app
?.getSceneElementsIncludingDeleted()
.find((el) => el.id === copy.id)
) {
copy.id += "_copy";
}
} else {
copy.id = randomId();
}
copy.boundElements = null; copy.boundElements = null;
copy.updated = getUpdatedTimestamp(); copy.updated = getUpdatedTimestamp();
copy.seed = randomInteger(); copy.seed = randomInteger();
@@ -432,7 +521,7 @@ export const duplicateElement = <TElement extends Mutable<ExcalidrawElement>>(
editingGroupId, editingGroupId,
(groupId) => { (groupId) => {
if (!groupIdMapForOperation.has(groupId)) { if (!groupIdMapForOperation.has(groupId)) {
groupIdMapForOperation.set(groupId, randomId()); groupIdMapForOperation.set(groupId, regenerateId(groupId));
} }
return groupIdMapForOperation.get(groupId)!; return groupIdMapForOperation.get(groupId)!;
}, },
@@ -442,3 +531,115 @@ export const duplicateElement = <TElement extends Mutable<ExcalidrawElement>>(
} }
return copy; return copy;
}; };
/**
* Clones elements, regenerating their ids (including bindings) and group ids.
*
* If bindings don't exist in the elements array, they are removed. Therefore,
* it's advised to supply the whole elements array, or sets of elements that
* are encapsulated (such as library items), if the purpose is to retain
* bindings to the cloned elements intact.
*
* NOTE by default does not randomize or regenerate anything except the id.
*/
export const duplicateElements = (
elements: readonly ExcalidrawElement[],
opts?: {
/** NOTE also updates version flags and `updated` */
randomizeSeed: boolean;
},
) => {
const clonedElements: ExcalidrawElement[] = [];
const origElementsMap = arrayToMap(elements);
// used for for migrating old ids to new ids
const elementNewIdsMap = new Map<
/* orig */ ExcalidrawElement["id"],
/* new */ ExcalidrawElement["id"]
>();
const maybeGetNewId = (id: ExcalidrawElement["id"]) => {
// if we've already migrated the element id, return the new one directly
if (elementNewIdsMap.has(id)) {
return elementNewIdsMap.get(id)!;
}
// if we haven't migrated the element id, but an old element with the same
// id exists, generate a new id for it and return it
if (origElementsMap.has(id)) {
const newId = regenerateId(id);
elementNewIdsMap.set(id, newId);
return newId;
}
// if old element doesn't exist, return null to mark it for removal
return null;
};
const groupNewIdsMap = new Map</* orig */ GroupId, /* new */ GroupId>();
for (const element of elements) {
const clonedElement: Mutable<ExcalidrawElement> = _deepCopyElement(element);
clonedElement.id = maybeGetNewId(element.id)!;
if (opts?.randomizeSeed) {
clonedElement.seed = randomInteger();
bumpVersion(clonedElement);
}
if (clonedElement.groupIds) {
clonedElement.groupIds = clonedElement.groupIds.map((groupId) => {
if (!groupNewIdsMap.has(groupId)) {
groupNewIdsMap.set(groupId, regenerateId(groupId));
}
return groupNewIdsMap.get(groupId)!;
});
}
if ("containerId" in clonedElement && clonedElement.containerId) {
const newContainerId = maybeGetNewId(clonedElement.containerId);
clonedElement.containerId = newContainerId;
}
if ("boundElements" in clonedElement && clonedElement.boundElements) {
clonedElement.boundElements = clonedElement.boundElements.reduce(
(
acc: Mutable<NonNullable<ExcalidrawElement["boundElements"]>>,
binding,
) => {
const newBindingId = maybeGetNewId(binding.id);
if (newBindingId) {
acc.push({ ...binding, id: newBindingId });
}
return acc;
},
[],
);
}
if ("endBinding" in clonedElement && clonedElement.endBinding) {
const newEndBindingId = maybeGetNewId(clonedElement.endBinding.elementId);
clonedElement.endBinding = newEndBindingId
? {
...clonedElement.endBinding,
elementId: newEndBindingId,
}
: null;
}
if ("startBinding" in clonedElement && clonedElement.startBinding) {
const newEndBindingId = maybeGetNewId(
clonedElement.startBinding.elementId,
);
clonedElement.startBinding = newEndBindingId
? {
...clonedElement.startBinding,
elementId: newEndBindingId,
}
: null;
}
clonedElements.push(clonedElement);
}
return clonedElements;
};
+47 -22
View File
@@ -44,8 +44,10 @@ import {
getBoundTextElementId, getBoundTextElementId,
getContainerElement, getContainerElement,
handleBindTextResize, handleBindTextResize,
getMaxContainerWidth, getBoundTextMaxWidth,
getApproxMinLineHeight, getApproxMinLineHeight,
measureText,
getBoundTextMaxHeight,
} from "./textElement"; } from "./textElement";
export const normalizeAngle = (angle: number): number => { export const normalizeAngle = (angle: number): number => {
@@ -193,7 +195,8 @@ const MIN_FONT_SIZE = 1;
const measureFontSizeFromWidth = ( const measureFontSizeFromWidth = (
element: NonDeleted<ExcalidrawTextElement>, element: NonDeleted<ExcalidrawTextElement>,
nextWidth: number, nextWidth: number,
): number | null => { nextHeight: number,
): { size: number; baseline: number } | null => {
// We only use width to scale font on resize // We only use width to scale font on resize
let width = element.width; let width = element.width;
@@ -201,15 +204,22 @@ const measureFontSizeFromWidth = (
if (hasContainer) { if (hasContainer) {
const container = getContainerElement(element); const container = getContainerElement(element);
if (container) { if (container) {
width = getMaxContainerWidth(container); width = getBoundTextMaxWidth(container);
} }
} }
const nextFontSize = element.fontSize * (nextWidth / width); const nextFontSize = element.fontSize * (nextWidth / width);
if (nextFontSize < MIN_FONT_SIZE) { if (nextFontSize < MIN_FONT_SIZE) {
return null; return null;
} }
const metrics = measureText(
return nextFontSize; element.text,
getFontString({ fontSize: nextFontSize, fontFamily: element.fontFamily }),
element.lineHeight,
);
return {
size: nextFontSize,
baseline: metrics.baseline + (nextHeight - metrics.height),
};
}; };
const getSidesForTransformHandle = ( const getSidesForTransformHandle = (
@@ -280,8 +290,8 @@ const resizeSingleTextElement = (
if (scale > 0) { if (scale > 0) {
const nextWidth = element.width * scale; const nextWidth = element.width * scale;
const nextHeight = element.height * scale; const nextHeight = element.height * scale;
const nextFontSize = measureFontSizeFromWidth(element, nextWidth); const metrics = measureFontSizeFromWidth(element, nextWidth, nextHeight);
if (nextFontSize === null) { if (metrics === null) {
return; return;
} }
const [nextX1, nextY1, nextX2, nextY2] = getResizedElementAbsoluteCoords( const [nextX1, nextY1, nextX2, nextY2] = getResizedElementAbsoluteCoords(
@@ -305,9 +315,10 @@ const resizeSingleTextElement = (
deltaY2, deltaY2,
); );
mutateElement(element, { mutateElement(element, {
fontSize: nextFontSize, fontSize: metrics.size,
width: nextWidth, width: nextWidth,
height: nextHeight, height: nextHeight,
baseline: metrics.baseline,
x: nextElementX, x: nextElementX,
y: nextElementY, y: nextElementY,
}); });
@@ -360,7 +371,7 @@ export const resizeSingleElement = (
let scaleX = atStartBoundsWidth / boundsCurrentWidth; let scaleX = atStartBoundsWidth / boundsCurrentWidth;
let scaleY = atStartBoundsHeight / boundsCurrentHeight; let scaleY = atStartBoundsHeight / boundsCurrentHeight;
let boundTextFontSize: number | null = null; let boundTextFont: { fontSize?: number; baseline?: number } = {};
const boundTextElement = getBoundTextElement(element); const boundTextElement = getBoundTextElement(element);
if (transformHandleDirection.includes("e")) { if (transformHandleDirection.includes("e")) {
@@ -410,7 +421,10 @@ export const resizeSingleElement = (
boundTextElement.id, boundTextElement.id,
) as typeof boundTextElement | undefined; ) as typeof boundTextElement | undefined;
if (stateOfBoundTextElementAtResize) { if (stateOfBoundTextElementAtResize) {
boundTextFontSize = stateOfBoundTextElementAtResize.fontSize; boundTextFont = {
fontSize: stateOfBoundTextElementAtResize.fontSize,
baseline: stateOfBoundTextElementAtResize.baseline,
};
} }
if (shouldMaintainAspectRatio) { if (shouldMaintainAspectRatio) {
const updatedElement = { const updatedElement = {
@@ -419,14 +433,18 @@ export const resizeSingleElement = (
height: eleNewHeight, height: eleNewHeight,
}; };
const nextFontSize = measureFontSizeFromWidth( const nextFont = measureFontSizeFromWidth(
boundTextElement, boundTextElement,
getMaxContainerWidth(updatedElement), getBoundTextMaxWidth(updatedElement),
getBoundTextMaxHeight(updatedElement, boundTextElement),
); );
if (nextFontSize === null) { if (nextFont === null) {
return; return;
} }
boundTextFontSize = nextFontSize; boundTextFont = {
fontSize: nextFont.size,
baseline: nextFont.baseline,
};
} else { } else {
const minWidth = getApproxMinLineWidth( const minWidth = getApproxMinLineWidth(
getFontString(boundTextElement), getFontString(boundTextElement),
@@ -568,9 +586,10 @@ export const resizeSingleElement = (
}); });
mutateElement(element, resizedElement); mutateElement(element, resizedElement);
if (boundTextElement && boundTextFontSize != null) { if (boundTextElement && boundTextFont != null) {
mutateElement(boundTextElement, { mutateElement(boundTextElement, {
fontSize: boundTextFontSize, fontSize: boundTextFont.fontSize,
baseline: boundTextFont.baseline,
}); });
} }
handleBindTextResize(element, transformHandleDirection); handleBindTextResize(element, transformHandleDirection);
@@ -677,6 +696,7 @@ const resizeMultipleElements = (
y: number; y: number;
points?: Point[]; points?: Point[];
fontSize?: number; fontSize?: number;
baseline?: number;
} = { } = {
width, width,
height, height,
@@ -685,7 +705,7 @@ const resizeMultipleElements = (
...rescaledPoints, ...rescaledPoints,
}; };
let boundTextUpdates: { fontSize: number } | null = null; let boundTextUpdates: { fontSize: number; baseline: number } | null = null;
const boundTextElement = getBoundTextElement(element.latest); const boundTextElement = getBoundTextElement(element.latest);
@@ -695,24 +715,29 @@ const resizeMultipleElements = (
width, width,
height, height,
}; };
const fontSize = measureFontSizeFromWidth( const metrics = measureFontSizeFromWidth(
boundTextElement ?? (element.orig as ExcalidrawTextElement), boundTextElement ?? (element.orig as ExcalidrawTextElement),
boundTextElement boundTextElement
? getMaxContainerWidth(updatedElement) ? getBoundTextMaxWidth(updatedElement)
: updatedElement.width, : updatedElement.width,
boundTextElement
? getBoundTextMaxHeight(updatedElement, boundTextElement)
: updatedElement.height,
); );
if (!fontSize) { if (!metrics) {
return; return;
} }
if (isTextElement(element.orig)) { if (isTextElement(element.orig)) {
update.fontSize = fontSize; update.fontSize = metrics.size;
update.baseline = metrics.baseline;
} }
if (boundTextElement) { if (boundTextElement) {
boundTextUpdates = { boundTextUpdates = {
fontSize, fontSize: metrics.size,
baseline: metrics.baseline,
}; };
} }
} }

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