Compare commits

...

106 Commits

Author SHA1 Message Date
ad1992 c4b951a0c5 convert customElementsConfig into an object 2022-05-04 14:26:54 +05:30
ad1992 c93d8f4bd0 don't use addCallback for triggering onCreate 2022-05-04 13:30:42 +05:30
ad1992 645f9a5dc0 Merge remote-tracking branch 'origin/master' into aakansha-custom-elements 2022-05-04 13:20:08 +05:30
Aakansha Doshi b30066ca19 fix: jumping of text when typing single line in bound text (#5139)
* fix: jumping of text when typing single line in bound text

* add comment
2022-05-04 11:21:27 +05:30
Aakansha Doshi aae8e2fa5d feat: export MIME_TYPES supported by excalidraw (#5135)
* feat: export MIME_TYPES supported by excalidraw

* Update src/packages/excalidraw/CHANGELOG.md
2022-05-02 15:47:03 +05:30
Aakansha Doshi 9e6d5fdbcb feat: support src collaborators (#5114)
* feat: support avatarURLfor collaborators

* fix

* better avatars :)

* use position fixed for tooltips so it renders correctly when offsets updated

* update docs

* Update src/excalidraw-app/collab/CollabWrapper.tsx

* rename avatarUrl to src
2022-05-02 15:15:24 +05:30
dependabot[bot] 22b2e10ddb chore(deps-dev): bump @babel/plugin-transform-runtime (#5125)
Bumps [@babel/plugin-transform-runtime](https://github.com/babel/babel/tree/HEAD/packages/babel-plugin-transform-runtime) from 7.16.8 to 7.17.10.
- [Release notes](https://github.com/babel/babel/releases)
- [Changelog](https://github.com/babel/babel/blob/main/CHANGELOG.md)
- [Commits](https://github.com/babel/babel/commits/v7.17.10/packages/babel-plugin-transform-runtime)

---
updated-dependencies:
- dependency-name: "@babel/plugin-transform-runtime"
  dependency-type: direct:development
  update-type: version-update:semver-minor
...

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

Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2022-05-02 14:47:37 +05:30
David Luzar d53ac2a61e fix: library init/import race conditions (#5101) 2022-04-29 16:45:02 +02:00
Aakansha Doshi 6a0f800716 feat: export exportToClipboard util from package (#5103)
* feat: export copyToClipboard from package

* use promise constructor for better browser supprt

* add type to exportToClipboard

* update docs

* use json instead of text and use selected element in actionCopy

* pass `files` in example `exportToClipboard`

* fix bad access

Co-authored-by: dwelle <luzar.david@gmail.com>
2022-04-29 18:58:44 +05:30
Johannes aee1e2451e fix: remove opacity scroll wheel interaction (#5111) 2022-04-28 19:10:08 +02:00
Aakansha Doshi da94eb1284 fix: use excalidraw asset path in fonts when exporting to svg (#5065)
* fix: use excalidraw asset path in fonts when exporting

* fix

* fix

* introduce env variables and determine asset path correctly

* fix snaps

* use env vars to determine pkg name and version

* update docs

* quotes
2022-04-28 20:19:41 +05:30
Achille Lacoin ea51251fe6 fix: propagate keydown events from excalidraw-wysiwyg inputs (#5099)
* fix: allow propagation for keydown events originating from `excalidraw-wysiwyg`

* revert check on the handleKeyDown function
2022-04-27 20:28:41 +02:00
Aakansha Doshi 399ce1e01a fix: don't bind text to container if double clicked else instead of center (#5105) 2022-04-27 17:04:21 +05:30
dependabot[bot] 7df8302ba2 chore(deps-dev): bump babel-loader in /src/packages/utils (#5088)
Bumps [babel-loader](https://github.com/babel/babel-loader) from 8.2.3 to 8.2.5.
- [Release notes](https://github.com/babel/babel-loader/releases)
- [Changelog](https://github.com/babel/babel-loader/blob/main/CHANGELOG.md)
- [Commits](https://github.com/babel/babel-loader/compare/v8.2.3...v8.2.5)

---
updated-dependencies:
- dependency-name: babel-loader
  dependency-type: direct:development
  update-type: version-update:semver-patch
...

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

Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2022-04-27 10:03:17 +00:00
dependabot[bot] af8c59b5bb chore(deps): bump minimist in /src/packages/excalidraw (#5039)
Bumps [minimist](https://github.com/substack/minimist) from 1.2.5 to 1.2.6.
- [Release notes](https://github.com/substack/minimist/releases)
- [Commits](https://github.com/substack/minimist/compare/1.2.5...1.2.6)

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

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

Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2022-04-27 15:27:56 +05:30
dependabot[bot] cf0f00285b chore(deps): bump minimist from 1.2.5 to 1.2.6 in /src/packages/utils (#5040)
Bumps [minimist](https://github.com/substack/minimist) from 1.2.5 to 1.2.6.
- [Release notes](https://github.com/substack/minimist/releases)
- [Commits](https://github.com/substack/minimist/compare/1.2.5...1.2.6)

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

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

Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2022-04-27 15:27:43 +05:30
dependabot[bot] b5c67a384c chore(deps): bump minimist from 1.2.5 to 1.2.6 (#5042)
Bumps [minimist](https://github.com/substack/minimist) from 1.2.5 to 1.2.6.
- [Release notes](https://github.com/substack/minimist/releases)
- [Commits](https://github.com/substack/minimist/compare/1.2.5...1.2.6)

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

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

Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2022-04-27 15:27:20 +05:30
Achille Lacoin af93cedc08 EXCALIDRAW_EXPORT_SOURCE_PATH -> EXCALIDRAW_EXPORT_SOURCE (#5102) 2022-04-27 10:49:02 +02:00
ad1992 128b7741c1 update config to use displayData 2022-04-27 14:03:42 +05:30
Achille Lacoin b6a6f2d465 feat: Expose window.EXCALIDRAW_EXPORT_SOURCE which host can use to overwrite the source field in exports (#5095)
* Expose `window.EXCALIDRAW_EXPORT_SOURCE` which host can use to overwrite the source field in exports

* Update src/packages/excalidraw/CHANGELOG.md

Co-authored-by: David Luzar <luzar.david@gmail.com>

* address review comments

* Update src/packages/excalidraw/README_NEXT.md

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

* Update src/packages/excalidraw/README_NEXT.md

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

* EXCALIDRAW_EXPORT_SOURCE -> EXCALIDRAW_EXPORT_SOURCE_PATH

Co-authored-by: David Luzar <luzar.david@gmail.com>
Co-authored-by: Aakansha Doshi <aakansha1216@gmail.com>
2022-04-26 13:28:39 +02:00
dependabot[bot] 6bcbf8b50a chore(deps): bump node-forge in /src/packages/excalidraw (#5071)
Bumps [node-forge](https://github.com/digitalbazaar/forge) from 1.2.1 to 1.3.1.
- [Release notes](https://github.com/digitalbazaar/forge/releases)
- [Changelog](https://github.com/digitalbazaar/forge/blob/main/CHANGELOG.md)
- [Commits](https://github.com/digitalbazaar/forge/compare/v1.2.1...v1.3.1)

---
updated-dependencies:
- dependency-name: node-forge
  dependency-type: indirect
...

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

Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2022-04-26 07:40:12 +00:00
dependabot[bot] 666516d7e9 chore(deps): bump async from 2.6.3 to 2.6.4 in /src/packages/excalidraw (#5069)
Bumps [async](https://github.com/caolan/async) from 2.6.3 to 2.6.4.
- [Release notes](https://github.com/caolan/async/releases)
- [Changelog](https://github.com/caolan/async/blob/v2.6.4/CHANGELOG.md)
- [Commits](https://github.com/caolan/async/compare/v2.6.3...v2.6.4)

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

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

Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2022-04-26 07:32:29 +00:00
dependabot[bot] b941c5b661 chore(deps-dev): bump webpack in /src/packages/excalidraw (#5027)
Bumps [webpack](https://github.com/webpack/webpack) from 5.65.0 to 5.72.0.
- [Release notes](https://github.com/webpack/webpack/releases)
- [Commits](https://github.com/webpack/webpack/compare/v5.65.0...v5.72.0)

---
updated-dependencies:
- dependency-name: webpack
  dependency-type: direct:development
  update-type: version-update:semver-minor
...

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

Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2022-04-26 13:01:35 +05:30
dependabot[bot] 8f8c85c64e chore(deps): bump async from 2.6.3 to 2.6.4 (#5070)
Bumps [async](https://github.com/caolan/async) from 2.6.3 to 2.6.4.
- [Release notes](https://github.com/caolan/async/releases)
- [Changelog](https://github.com/caolan/async/blob/v2.6.4/CHANGELOG.md)
- [Commits](https://github.com/caolan/async/compare/v2.6.3...v2.6.4)

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

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

Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2022-04-26 12:59:37 +05:30
dependabot[bot] 116b0c48da chore(deps): bump nanoid from 3.1.32 to 3.3.3 (#5073)
Bumps [nanoid](https://github.com/ai/nanoid) from 3.1.32 to 3.3.3.
- [Release notes](https://github.com/ai/nanoid/releases)
- [Changelog](https://github.com/ai/nanoid/blob/main/CHANGELOG.md)
- [Commits](https://github.com/ai/nanoid/compare/3.1.32...3.3.3)

---
updated-dependencies:
- dependency-name: nanoid
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

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

Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2022-04-26 12:59:11 +05:30
dependabot[bot] aa2971e8c5 chore(deps-dev): bump autoprefixer in /src/packages/excalidraw (#5087)
Bumps [autoprefixer](https://github.com/postcss/autoprefixer) from 10.4.2 to 10.4.5.
- [Release notes](https://github.com/postcss/autoprefixer/releases)
- [Changelog](https://github.com/postcss/autoprefixer/blob/main/CHANGELOG.md)
- [Commits](https://github.com/postcss/autoprefixer/compare/10.4.2...10.4.5)

---
updated-dependencies:
- dependency-name: autoprefixer
  dependency-type: direct:development
  update-type: version-update:semver-patch
...

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

Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2022-04-26 12:58:53 +05:30
dependabot[bot] 5656ac1e3e chore(deps): bump browser-fs-access from 0.24.1 to 0.29.1 (#5090)
Bumps [browser-fs-access](https://github.com/GoogleChromeLabs/browser-fs-access) from 0.24.1 to 0.29.1.
- [Release notes](https://github.com/GoogleChromeLabs/browser-fs-access/releases)
- [Commits](https://github.com/GoogleChromeLabs/browser-fs-access/commits)

---
updated-dependencies:
- dependency-name: browser-fs-access
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

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

Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2022-04-26 12:58:10 +05:30
ad1992 1edde7291c fix specs 2022-04-26 12:02:50 +05:30
ad1992 1ca56204b1 don't draw if image not present 2022-04-25 20:25:14 +05:30
ad1992 f3ae7a8506 don't show move cursor if transform handles disabled 2022-04-25 14:45:17 +05:30
ad1992 5f57daa132 fix: selection not working sometimes when transformHandles disabled 2022-04-25 14:23:28 +05:30
David Luzar e6a9ff1b96 fix: toolIcon height not using rem (#5092) 2022-04-24 19:21:41 +02:00
David Luzar 832b88249c feat: stop event propagation when key handled (#5091)
* feat: stop event propagation when key handled

* don't handle s/g shortcuts if cmd/ctrl/alt pressed
2022-04-24 18:29:38 +02:00
Aakansha Doshi 9902092fd1 fix setting webpack public path (#5081) 2022-04-23 13:48:57 +05:30
Achille Lacoin 8f0863d335 add a prefix to the extension for image embbedding sceneData (#5079) 2022-04-22 18:31:31 +02:00
David Luzar 9423ac3263 fix: Excalidraw named export type (#5078) 2022-04-22 15:49:36 +02:00
ad1992 db9c9eb3d2 suppport disabling context menu in custom elements 2022-04-22 00:56:17 +05:30
ad1992 2e8c4d25f2 fix package example 2022-04-21 23:55:38 +05:30
ad1992 4953828d86 Merge remote-tracking branch 'origin/master' into aakansha-custom-elements 2022-04-21 19:50:46 +05:30
Aakansha Doshi a66cfe2627 fix: boundElementIds when arrows bound to elements are deleted (#5077)
* fix: boundElementIds when arrows bound to elements are deleted

* fix type checks and updating unrelated elements

Co-authored-by: dwelle <luzar.david@gmail.com>
2022-04-21 18:57:06 +05:30
Achille Lacoin 86cf28f2b4 fix: don't merge libraryItems on updateScene (#5076) 2022-04-21 10:54:02 +02:00
Achille Lacoin b5a46dd671 fix: SVG metadata extraction regex on multiline elements (#5074)
* Fix SVG metadata extraction regex on multiline elements

* remove multiline flag
2022-04-20 17:07:58 +02:00
David Luzar cd942c3e3b feat: rewrite library state management & related refactor (#5067)
* support libraryItems promise for `updateScene()` and use `importLibrary`

* fix typing for `getLibraryItemsFromStorage()`

* remove `libraryItemsFromStorage` hack

if there was a point to it then I'm missing it, but this part will be rewritten anyway

* rewrite state handling

(temporarily removed loading states)

* add async support

* refactor and deduplicate library importing logic

* hide hints when library open

* fix snaps

* support promise in `initialData.libraryItems`

* add default to params instead
2022-04-20 14:40:03 +02:00
ad1992 6eb0cf6a10 unbind onCreate once executed 2022-04-20 11:48:22 +05:30
David Luzar 55ccd5b79b feat: delay initial loading message & tweak design (#5049) 2022-04-19 19:08:13 +02:00
ad1992 ba48aa24a0 Add onCreate in customElementConfig 2022-04-19 21:58:38 +05:30
ad1992 4e75f10b2c cache svg with element id 2022-04-18 21:30:38 +05:30
ad1992 d2d3599661 Support svg as a async function returing promise/string 2022-04-18 16:08:40 +05:30
Excalidraw Bot 4348c55c31 chore: Update translations from Crowdin (#4894) 2022-04-18 00:16:43 +02:00
David Luzar a3fbe40b26 fix: eraser cursor showing on theme change when not using eraser (#4990) 2022-04-17 22:47:36 +02:00
David Luzar 7431ca81d1 fix: update storage.rules (#5020) 2022-04-17 22:47:00 +02:00
David Luzar 4d13dbf625 feat: reconcile when saving to firebase (#4991)
* naming tweaks

* do not mark local element as duplicate when there's no remote counterpart

* merge instead of overwrite elements when saving to firebase & reconcile local state

* decouple syncing from persistence

* fix ts

* clarify doc

* fix reconciliation not removing duplicates
2022-04-17 22:40:39 +02:00
Achille Lacoin 3840e2f4e6 feat: embed scene support for png export in npm package (#5047)
* feat: embed scene support for png export in npm package

* move logic to the callback function

* add exportEmbedScene checkbox in package example

* update readme and changelog

* add PR link in changelog

* reverse sort changelog items
2022-04-16 16:30:11 +02:00
Aakansha Doshi 52d10bb41e feat: hide trash button during collaboration (#5037)
* feat: hide trash button during collaboration

* visually hide the trash icon

* pointer events none

* tweak env docs

* fix typo

Co-authored-by: dwelle <luzar.david@gmail.com>
2022-04-15 09:21:41 -07:00
Aakansha Doshi 96c87f920a build: export only named exports from the package (#5045)
* build: export only named exports from the package

* update docs

* Update src/packages/excalidraw/CHANGELOG.md

* fix lint
2022-04-15 18:12:57 +05:30
zsviczian 7d4189c624 fix: Add image button not working on iPad (#5038) 2022-04-15 12:20:51 +02:00
David Luzar f3e17c90d3 fix: ensure svg image dimensions are always set (#5044) 2022-04-15 12:05:10 +02:00
David Luzar 70b3a9de49 feat: library restoring changes (#4995)
* restore library items in all cases & refactor

* export `restoreLibraryItems` from package

* feat: rerender library menu when updating via API

* update readme & changelog

* fix changelog
2022-04-14 16:20:35 +02:00
zsviczian bf6d0eeef7 fix: Pinch zoom in view mode (#5001) 2022-04-12 12:39:28 +02:00
David Luzar 5359e4fec9 feat: refactor local persistence & fix race condition on SW reload (#5032) 2022-04-11 22:15:49 +02:00
David Luzar 58fe639b8d fix: select whole group on righclick & few lock-related fixes (#5022) 2022-04-07 17:53:55 +02:00
Tom Sherman 327ed0e2d1 feat: Element locking (#4964)
Co-authored-by: dwelle <luzar.david@gmail.com>
Co-authored-by: Zsolt Viczian <viczian.zsolt@gmail.com>
2022-04-07 13:43:29 +02:00
Achille Lacoin c2fce6d8c4 fix: export serializeLibraryAsJSON from the package (#5017) 2022-04-07 12:35:44 +05:30
David Luzar cb6b7559b4 fix: support copying PNG to clipboard on Safari (#3746) 2022-04-06 14:05:09 +02:00
David Luzar 77d789ed8e fix: more copyText fixes (#5016) 2022-04-05 23:11:00 +02:00
Faustino Kialungila 89471094ce fix: Copy to clipboard all text nodes as text (#5014)
* fix: Copy to clipboard all text nodes as text

* fix: support copying text even if there are selected elements that are no text

* patch: makes paragraphs betwen texts of each element

* patch: allow copying text for bound text
2022-04-05 21:48:59 +02:00
Faustino Kialungila 670ceafc84 feat: Copy to clipboard all text nodes as text (#5013)
* Copy to clipboard all text nodes as text

* fix: only show the option for text elements
2022-04-05 15:31:19 +02:00
Achille Lacoin 873afdacd3 feat: create and expose serializeLibraryAsJSON (#5009)
Co-authored-by: David Luzar <luzar.david@gmail.com>
2022-04-05 14:35:38 +02:00
Valerii Gusev 880e4feede fix: update cursorButton once freedraw is released (#4996)
Co-authored-by: dwelle <luzar.david@gmail.com>
2022-04-01 18:25:21 +02:00
David Luzar 9ba7ca3845 feat: hide penMode button on reload if not enabled (#4992) 2022-03-30 10:53:22 +02:00
ad1992 ed3eda3401 restore custom elements with correct type 2022-03-30 14:20:29 +05:30
zsviczian 734bb4d2ed fix: decouple actionFinalize and actionErase (#4984)
* Update actionCanvas.tsx

* Update actionFinalize.tsx

* lint

* remove Escape trigger from actionErase

* revert to lastActiveTool only if coming from eraser tool

* unrelated: fix restoring `appState.activeTool`

* one more restoring fix

* fix tests

Co-authored-by: dwelle <luzar.david@gmail.com>
2022-03-29 21:37:09 +02:00
David Luzar f2d2f97546 fix: using stale state when switching tools (#4989) 2022-03-29 17:00:19 +02:00
ad1992 d27b32dd2c Merge remote-tracking branch 'origin/master' into aakansha-custom-elements 2022-03-29 17:26:46 +05:30
Aakansha Doshi 2fa69ddc32 refactor: move elementLocked to activeTool.locked (#4983)
* refactor: move elementLocked to activeTool.locked

* fix

* fix snap

* update docs

* Update src/packages/excalidraw/CHANGELOG.md

* revert

* make lastActiveToolBeforeEraser required and nullable

* fix snap
2022-03-29 17:10:19 +05:30
ad1992 2337842f57 fix typescript 2022-03-29 16:07:45 +05:30
ad1992 5b78f50fe3 Merge remote-tracking branch 'origin/master' into aakansha-custom-elements 2022-03-29 15:34:48 +05:30
zsviczian 1331cffe93 feat: Eraser toggle to switch back to the previous tool (#4981)
* add typeBeforeEraser

* ESC to switch to lastActiveToolBeforeEraser
2022-03-28 21:33:32 +02:00
David Luzar f242721f3b chore: add ga for most actions (#4829) 2022-03-28 14:46:40 +02:00
ad1992 a4a95a591a Add stackedOnTop to make sure the custom element is always rendered on top of all when stackedOnTop is true 2022-03-28 15:03:29 +05:30
zsviczian e940aeb1a3 fix: updateWysiwygStyle updatedElement is undefined TypeError (#4980)
Co-authored-by: dwelle <luzar.david@gmail.com>
2022-03-28 00:14:04 +02:00
connorhanafee 580e719580 fix: adding check for link length to prevent early return (#4982)
Co-authored-by: Connor Hanafee <connorp@Connors-MacBook-Pro.local>
Co-authored-by: dwelle <luzar.david@gmail.com>
2022-03-27 23:50:41 +02:00
ad1992 3d459076fb Merge remote-tracking branch 'origin/master' into aakansha-custom-elements
Update customType
2022-03-25 22:32:28 +05:30
Aakansha Doshi 127af9db23 refactor: rename elementType to activeTool and make it an object (#4968)
* refactor: rename elementType to activeTool

* update docs

* fix snap

* update activeToll to be an object and review fixes

* fix tests

* fix
2022-03-25 20:46:01 +05:30
ad1992 14a23c6c50 make onElementClick optional 2022-03-24 17:28:40 +05:30
ad1992 5f4a5b1789 Add onElementClick and export sceneCoordsToViewportCoords 2022-03-24 17:24:54 +05:30
ad1992 47498796e0 fix hit testing for custom elements 2022-03-24 15:06:22 +05:30
ad1992 8706277d14 rename name to customType 2022-03-24 14:04:31 +05:30
ad1992 3d0a1106ff support making transform handles optional 2022-03-23 23:24:25 +05:30
ad1992 61699ff3c2 cache the custom image element and improve jittering experience 2022-03-23 19:42:39 +05:30
ad1992 39d0084a5e feat: Support custom elements in @excalidraw/excalidraw 2022-03-23 19:04:00 +05:30
Aakansha Doshi 2209e2c1e8 fix: show link icon for bound text containers (#4960) 2022-03-23 00:45:08 +05:30
zsviczian ed31980f84 feat: Save penDetected and penMode, and detect pen already on ToolButton click (#4955)
* save penMode and penDetected to browser cache

* added on pointer down

* added onPointerDown

* factor out and merge handlers

Co-authored-by: dwelle <luzar.david@gmail.com>
2022-03-22 13:29:27 +01:00
Aakansha Doshi db28595302 fix: cancel erase elements on pointer up if eraser is not active on pointer up (#4956)
* fix: erase elements on pointer up if present

* cancel erase on pointer up if eraser is not active
2022-03-22 17:14:07 +05:30
Aakansha Doshi cded1cd63d fix: restore original opacities when alt pressed while erasing (#4954) 2022-03-22 16:40:28 +05:30
Aakansha Doshi 8e447b4c32 fix: don't bind text to container if already present (#4946)
* fix: don't bind text to container if already present

* Add specs and update condition
2022-03-22 15:32:49 +05:30
dependabot[bot] e29d3fc5e6 chore(deps-dev): bump mini-css-extract-plugin (#4872)
Bumps [mini-css-extract-plugin](https://github.com/webpack-contrib/mini-css-extract-plugin) from 2.4.6 to 2.6.0.
- [Release notes](https://github.com/webpack-contrib/mini-css-extract-plugin/releases)
- [Changelog](https://github.com/webpack-contrib/mini-css-extract-plugin/blob/master/CHANGELOG.md)
- [Commits](https://github.com/webpack-contrib/mini-css-extract-plugin/compare/v2.4.6...v2.6.0)

---
updated-dependencies:
- dependency-name: mini-css-extract-plugin
  dependency-type: direct:development
  update-type: version-update:semver-minor
...

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

Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2022-03-22 13:07:14 +05:30
dependabot[bot] 9da56e46f0 chore(deps-dev): bump lint-staged from 12.3.3 to 12.3.7 (#4941)
Bumps [lint-staged](https://github.com/okonet/lint-staged) from 12.3.3 to 12.3.7.
- [Release notes](https://github.com/okonet/lint-staged/releases)
- [Commits](https://github.com/okonet/lint-staged/compare/v12.3.3...v12.3.7)

---
updated-dependencies:
- dependency-name: lint-staged
  dependency-type: direct:development
  update-type: version-update:semver-patch
...

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

Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2022-03-22 13:06:48 +05:30
Aakansha Doshi 625ecc64ed feat: Support binding text to container via context menu (#4935)
* feat: Support binding text to closest container

* Bind text to selected container

* show bind action in canvas and selected container after binding

* allow binding if container has no bound text

* fix

* move logic to show/hide bind actions to contextMenuPredicate

* don't show bind action when clicking on bounding box and not elemnts
2022-03-21 17:54:54 +05:30
Aakansha Doshi ceb43ed8fb fix: erase all elements which are hit with single point click (#4934) 2022-03-17 21:03:59 +05:30
Ives van Hoorne 8c0a0415de update README.md to show commands in code blocks (#4932) 2022-03-16 18:49:28 +01:00
zsviczian 192debd829 fix: Add multiElement-edit finalize action to Desktop (currently only visible in Mobile view) (#4764)
* add finalize action to Desktop UI

* Update LayerUI.tsx

* add size to panel component

* finzalize button style

* add finalize button

* changed isMobile to DeviceInfo, added isTouchScreen

* cleanup

* rename deviceInfo to deviceType

* rename deviceInfo to deviceType

* added updateObject

* Update App.tsx
2022-03-16 15:59:30 +01:00
Aakansha Doshi 1cfb4dfd8b feat: Map shortcut O to ellipse and Add eraser shortcut E (#4930)
* feat: Add erase shortcut Shift+E

* map o to ellipse and E to Eraser

* fix tests

* use key

* move eraser to tools and rename shape to tools
2022-03-16 18:31:20 +05:30
dependabot[bot] fb32886355 chore(deps-dev): bump ts-loader in /src/packages/excalidraw (#4912)
Bumps [ts-loader](https://github.com/TypeStrong/ts-loader) from 9.2.6 to 9.2.8.
- [Release notes](https://github.com/TypeStrong/ts-loader/releases)
- [Changelog](https://github.com/TypeStrong/ts-loader/blob/main/CHANGELOG.md)
- [Commits](https://github.com/TypeStrong/ts-loader/compare/v9.2.6...v9.2.8)

---
updated-dependencies:
- dependency-name: ts-loader
  dependency-type: direct:development
  update-type: version-update:semver-patch
...

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

Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2022-03-16 13:25:36 +05:30
Aakansha Doshi 065df495ba fix: Hide eraser in view mode in desktop (#4929) 2022-03-16 13:09:34 +05:30
Aakansha Doshi 558227f744 feat: update eraser cursor (#4922)
* feat: update eraser cursor

* fix dark theme

* check before adding active class

* use custom cursor instead of DOM manipulation

* cache canvas and redraw only when theme changes

* use oc colors

* remove

* cache preview data url

* increase linwidth

* update coords for cursor

* add white 2px outline

* improvements

* use 1px line width 6px radius for outer

* improve
2022-03-15 20:56:39 +05:30
201 changed files with 6972 additions and 2623 deletions
+5 -4
View File
@@ -4,9 +4,10 @@ REACT_APP_BACKEND_V2_POST_URL=https://json-dev.excalidraw.com/api/v2/post/
REACT_APP_LIBRARY_URL=https://libraries.excalidraw.com REACT_APP_LIBRARY_URL=https://libraries.excalidraw.com
REACT_APP_LIBRARY_BACKEND=https://us-central1-excalidraw-room-persistence.cloudfunctions.net/libraries REACT_APP_LIBRARY_BACKEND=https://us-central1-excalidraw-room-persistence.cloudfunctions.net/libraries
REACT_APP_PORTAL_URL=http://localhost:3002 # collaboration WebSocket server (https://github.com/excalidraw/excalidraw-room)
# Fill to set socket server URL used for collaboration. REACT_APP_WS_SERVER_URL=http://localhost:3002
# Meant for forks only: excalidraw.com uses custom REACT_APP_PORTAL_URL flow
REACT_APP_WS_SERVER_URL= # set this only if using the collaboration workflow we use on excalidraw.com
REACT_APP_PORTAL_URL=
REACT_APP_FIREBASE_CONFIG='{"apiKey":"AIzaSyCMkxA60XIW8KbqMYL7edC4qT5l4qHX2h8","authDomain":"excalidraw-oss-dev.firebaseapp.com","projectId":"excalidraw-oss-dev","storageBucket":"excalidraw-oss-dev.appspot.com","messagingSenderId":"664559512677","appId":"1:664559512677:web:a385181f2928d328a7aa8c"}' REACT_APP_FIREBASE_CONFIG='{"apiKey":"AIzaSyCMkxA60XIW8KbqMYL7edC4qT5l4qHX2h8","authDomain":"excalidraw-oss-dev.firebaseapp.com","projectId":"excalidraw-oss-dev","storageBucket":"excalidraw-oss-dev.appspot.com","messagingSenderId":"664559512677","appId":"1:664559512677:web:a385181f2928d328a7aa8c"}'
+35 -8
View File
@@ -128,14 +128,41 @@ For collaboration, you will need to set up [collab server](https://github.com/ex
#### Commands #### Commands
| Command | Description | ##### Install the dependencies
| ------------------ | --------------------------------- |
| `yarn` | Install the dependencies | ```
| `yarn start` | Run the project | yarn
| `yarn fix` | Reformat all files with Prettier | ```
| `yarn test` | Run tests |
| `yarn test:update` | Update test snapshots | ##### Run the project
| `yarn test:code` | Test for formatting with Prettier |
```
yarn start
```
##### Reformat all files with Prettier
```
yarn fix
```
##### Run tests
```
yarn test
```
##### Update test snapshots
```
yarn test:update
```
##### Test for formatting with Prettier
```
yarn test:code
```
#### Docker Compose #### Docker Compose
+5 -6
View File
@@ -1,12 +1,11 @@
rules_version = '2'; rules_version = '2';
service firebase.storage { service firebase.storage {
match /b/{bucket}/o { match /b/{bucket}/o {
match /{migrations} { match /{files}/rooms/{room}/{file} {
match /{scenes}/{scene} { allow get, write: if true;
allow get, write: if true; }
// redundant, but let's be explicit' match /{files}/shareLinks/{shareLink}/{file} {
allow list: if false; allow get, write: if true;
}
} }
} }
} }
+4 -3
View File
@@ -29,15 +29,16 @@
"@types/react": "17.0.39", "@types/react": "17.0.39",
"@types/react-dom": "17.0.11", "@types/react-dom": "17.0.11",
"@types/socket.io-client": "1.4.36", "@types/socket.io-client": "1.4.36",
"browser-fs-access": "0.24.1", "browser-fs-access": "0.29.1",
"clsx": "1.1.1", "clsx": "1.1.1",
"fake-indexeddb": "3.1.7", "fake-indexeddb": "3.1.7",
"firebase": "8.3.3", "firebase": "8.3.3",
"i18next-browser-languagedetector": "6.1.2", "i18next-browser-languagedetector": "6.1.2",
"idb-keyval": "6.0.3", "idb-keyval": "6.0.3",
"image-blob-reduce": "3.0.1", "image-blob-reduce": "3.0.1",
"jotai": "1.6.4",
"lodash.throttle": "4.1.1", "lodash.throttle": "4.1.1",
"nanoid": "3.1.32", "nanoid": "3.3.3",
"open-color": "1.9.1", "open-color": "1.9.1",
"pako": "1.0.11", "pako": "1.0.11",
"perfect-freehand": "1.0.16", "perfect-freehand": "1.0.16",
@@ -67,7 +68,7 @@
"eslint-plugin-prettier": "3.3.1", "eslint-plugin-prettier": "3.3.1",
"husky": "7.0.4", "husky": "7.0.4",
"jest-canvas-mock": "2.3.1", "jest-canvas-mock": "2.3.1",
"lint-staged": "12.3.3", "lint-staged": "12.3.7",
"pepjs": "0.5.3", "pepjs": "0.5.3",
"prettier": "2.5.1", "prettier": "2.5.1",
"rewire": "5.0.0" "rewire": "5.0.0"
+4 -26
View File
@@ -124,26 +124,6 @@
user-select: none; user-select: none;
} }
.LoadingMessage {
position: absolute;
top: 0;
right: 0;
bottom: 0;
left: 0;
z-index: 999;
display: flex;
align-items: center;
justify-content: center;
pointer-events: none;
}
.LoadingMessage span {
background-color: var(--button-gray-1);
border-radius: 5px;
padding: 0.8em 1.2em;
color: var(--popup-text-color);
font-size: 1.3em;
}
#root { #root {
height: 100%; height: 100%;
-webkit-touch-callout: none; -webkit-touch-callout: none;
@@ -152,8 +132,10 @@
-moz-user-select: none; -moz-user-select: none;
-ms-user-select: none; -ms-user-select: none;
user-select: none; user-select: none;
}
@media screen and (min-width: 1200px) { @media screen and (min-width: 1200px) {
#root {
-webkit-touch-callout: default; -webkit-touch-callout: default;
-webkit-user-select: auto; -webkit-user-select: auto;
-khtml-user-select: auto; -khtml-user-select: auto;
@@ -170,10 +152,6 @@
<header> <header>
<h1 class="visually-hidden">Excalidraw</h1> <h1 class="visually-hidden">Excalidraw</h1>
</header> </header>
<div id="root"> <div id="root"></div>
<div class="LoadingMessage">
<span>Loading scene...</span>
</div>
</div>
</body> </body>
</html> </html>
+3 -2
View File
@@ -7,6 +7,7 @@ import { t } from "../i18n";
export const actionAddToLibrary = register({ export const actionAddToLibrary = register({
name: "addToLibrary", name: "addToLibrary",
trackEvent: { category: "element" },
perform: (elements, appState, _, app) => { perform: (elements, appState, _, app) => {
const selectedElements = getSelectedElements( const selectedElements = getSelectedElements(
getNonDeletedElements(elements), getNonDeletedElements(elements),
@@ -24,9 +25,9 @@ export const actionAddToLibrary = register({
} }
return app.library return app.library
.loadLibrary() .getLatestLibrary()
.then((items) => { .then((items) => {
return app.library.saveLibrary([ return app.library.setLibrary([
{ {
id: randomId(), id: randomId(),
status: "unpublished", status: "unpublished",
+8
View File
@@ -43,6 +43,7 @@ const alignSelectedElements = (
export const actionAlignTop = register({ export const actionAlignTop = register({
name: "alignTop", name: "alignTop",
trackEvent: { category: "element" },
perform: (elements, appState) => { perform: (elements, appState) => {
return { return {
appState, appState,
@@ -72,6 +73,7 @@ export const actionAlignTop = register({
export const actionAlignBottom = register({ export const actionAlignBottom = register({
name: "alignBottom", name: "alignBottom",
trackEvent: { category: "element" },
perform: (elements, appState) => { perform: (elements, appState) => {
return { return {
appState, appState,
@@ -101,6 +103,7 @@ export const actionAlignBottom = register({
export const actionAlignLeft = register({ export const actionAlignLeft = register({
name: "alignLeft", name: "alignLeft",
trackEvent: { category: "element" },
perform: (elements, appState) => { perform: (elements, appState) => {
return { return {
appState, appState,
@@ -130,6 +133,8 @@ export const actionAlignLeft = register({
export const actionAlignRight = register({ export const actionAlignRight = register({
name: "alignRight", name: "alignRight",
trackEvent: { category: "element" },
perform: (elements, appState) => { perform: (elements, appState) => {
return { return {
appState, appState,
@@ -159,6 +164,8 @@ export const actionAlignRight = register({
export const actionAlignVerticallyCentered = register({ export const actionAlignVerticallyCentered = register({
name: "alignVerticallyCentered", name: "alignVerticallyCentered",
trackEvent: { category: "element" },
perform: (elements, appState) => { perform: (elements, appState) => {
return { return {
appState, appState,
@@ -184,6 +191,7 @@ export const actionAlignVerticallyCentered = register({
export const actionAlignHorizontallyCentered = register({ export const actionAlignHorizontallyCentered = register({
name: "alignHorizontallyCentered", name: "alignHorizontallyCentered",
trackEvent: { category: "element" },
perform: (elements, appState) => { perform: (elements, appState) => {
return { return {
appState, appState,
+136
View File
@@ -0,0 +1,136 @@
import { VERTICAL_ALIGN } from "../constants";
import { getNonDeletedElements, isTextElement } from "../element";
import { mutateElement } from "../element/mutateElement";
import {
getBoundTextElement,
measureText,
redrawTextBoundingBox,
} from "../element/textElement";
import {
hasBoundTextElement,
isTextBindableContainer,
} from "../element/typeChecks";
import {
ExcalidrawTextContainer,
ExcalidrawTextElement,
} from "../element/types";
import { getSelectedElements } from "../scene";
import { getFontString } from "../utils";
import { register } from "./register";
export const actionUnbindText = register({
name: "unbindText",
contextItemLabel: "labels.unbindText",
trackEvent: { category: "element" },
contextItemPredicate: (elements, appState) => {
const selectedElements = getSelectedElements(elements, appState);
return selectedElements.some((element) => hasBoundTextElement(element));
},
perform: (elements, appState) => {
const selectedElements = getSelectedElements(
getNonDeletedElements(elements),
appState,
);
selectedElements.forEach((element) => {
const boundTextElement = getBoundTextElement(element);
if (boundTextElement) {
const { width, height, baseline } = measureText(
boundTextElement.originalText,
getFontString(boundTextElement),
);
mutateElement(boundTextElement as ExcalidrawTextElement, {
containerId: null,
width,
height,
baseline,
text: boundTextElement.originalText,
});
mutateElement(element, {
boundElements: element.boundElements?.filter(
(ele) => ele.id !== boundTextElement.id,
),
});
}
});
return {
elements,
appState,
commitToHistory: true,
};
},
});
export const actionBindText = register({
name: "bindText",
contextItemLabel: "labels.bindText",
trackEvent: { category: "element" },
contextItemPredicate: (elements, appState) => {
const selectedElements = getSelectedElements(elements, appState);
if (selectedElements.length === 2) {
const textElement =
isTextElement(selectedElements[0]) ||
isTextElement(selectedElements[1]);
let bindingContainer;
if (isTextBindableContainer(selectedElements[0])) {
bindingContainer = selectedElements[0];
} else if (isTextBindableContainer(selectedElements[1])) {
bindingContainer = selectedElements[1];
}
if (
textElement &&
bindingContainer &&
getBoundTextElement(bindingContainer) === null
) {
return true;
}
}
return false;
},
perform: (elements, appState) => {
const selectedElements = getSelectedElements(
getNonDeletedElements(elements),
appState,
);
let textElement: ExcalidrawTextElement;
let container: ExcalidrawTextContainer;
if (
isTextElement(selectedElements[0]) &&
isTextBindableContainer(selectedElements[1])
) {
textElement = selectedElements[0];
container = selectedElements[1];
} else {
textElement = selectedElements[1] as ExcalidrawTextElement;
container = selectedElements[0] as ExcalidrawTextContainer;
}
mutateElement(textElement, {
containerId: container.id,
verticalAlign: VERTICAL_ALIGN.MIDDLE,
});
mutateElement(container, {
boundElements: (container.boundElements || []).concat({
type: "text",
id: textElement.id,
}),
});
redrawTextBoundingBox(textElement, container);
const updatedElements = elements.slice();
const textElementIndex = updatedElements.findIndex(
(ele) => ele.id === textElement.id,
);
updatedElements.splice(textElementIndex, 1);
const containerIndex = updatedElements.findIndex(
(ele) => ele.id === container.id,
);
updatedElements.splice(containerIndex + 1, 0, textElement);
return {
elements: updatedElements,
appState: { ...appState, selectedElementIds: { [container.id]: true } },
commitToHistory: true,
};
},
});
+46 -5
View File
@@ -21,6 +21,7 @@ import clsx from "clsx";
export const actionChangeViewBackgroundColor = register({ export const actionChangeViewBackgroundColor = register({
name: "changeViewBackgroundColor", name: "changeViewBackgroundColor",
trackEvent: false,
perform: (_, appState, value) => { perform: (_, appState, value) => {
return { return {
appState: { ...appState, ...value }, appState: { ...appState, ...value },
@@ -50,6 +51,7 @@ export const actionChangeViewBackgroundColor = register({
export const actionClearCanvas = register({ export const actionClearCanvas = register({
name: "clearCanvas", name: "clearCanvas",
trackEvent: { category: "canvas" },
perform: (elements, appState, _, app) => { perform: (elements, appState, _, app) => {
app.imageCache.clear(); app.imageCache.clear();
return { return {
@@ -60,7 +62,6 @@ export const actionClearCanvas = register({
...getDefaultAppState(), ...getDefaultAppState(),
files: {}, files: {},
theme: appState.theme, theme: appState.theme,
elementLocked: appState.elementLocked,
penMode: appState.penMode, penMode: appState.penMode,
penDetected: appState.penDetected, penDetected: appState.penDetected,
exportBackground: appState.exportBackground, exportBackground: appState.exportBackground,
@@ -68,8 +69,10 @@ export const actionClearCanvas = register({
gridSize: appState.gridSize, gridSize: appState.gridSize,
showStats: appState.showStats, showStats: appState.showStats,
pasteDialog: appState.pasteDialog, pasteDialog: appState.pasteDialog,
elementType: activeTool:
appState.elementType === "image" ? "selection" : appState.elementType, appState.activeTool.type === "image"
? { ...appState.activeTool, type: "selection" }
: appState.activeTool,
}, },
commitToHistory: true, commitToHistory: true,
}; };
@@ -80,6 +83,7 @@ export const actionClearCanvas = register({
export const actionZoomIn = register({ export const actionZoomIn = register({
name: "zoomIn", name: "zoomIn",
trackEvent: { category: "canvas" },
perform: (_elements, appState, _, app) => { perform: (_elements, appState, _, app) => {
return { return {
appState: { appState: {
@@ -115,6 +119,7 @@ export const actionZoomIn = register({
export const actionZoomOut = register({ export const actionZoomOut = register({
name: "zoomOut", name: "zoomOut",
trackEvent: { category: "canvas" },
perform: (_elements, appState, _, app) => { perform: (_elements, appState, _, app) => {
return { return {
appState: { appState: {
@@ -150,6 +155,7 @@ export const actionZoomOut = register({
export const actionResetZoom = register({ export const actionResetZoom = register({
name: "resetZoom", name: "resetZoom",
trackEvent: { category: "canvas" },
perform: (_elements, appState, _, app) => { perform: (_elements, appState, _, app) => {
return { return {
appState: { appState: {
@@ -248,6 +254,7 @@ const zoomToFitElements = (
export const actionZoomToSelected = register({ export const actionZoomToSelected = register({
name: "zoomToSelection", name: "zoomToSelection",
trackEvent: { category: "canvas" },
perform: (elements, appState) => zoomToFitElements(elements, appState, true), perform: (elements, appState) => zoomToFitElements(elements, appState, true),
keyTest: (event) => keyTest: (event) =>
event.code === CODES.TWO && event.code === CODES.TWO &&
@@ -258,6 +265,7 @@ export const actionZoomToSelected = register({
export const actionZoomToFit = register({ export const actionZoomToFit = register({
name: "zoomToFit", name: "zoomToFit",
trackEvent: { category: "canvas" },
perform: (elements, appState) => zoomToFitElements(elements, appState, false), perform: (elements, appState) => zoomToFitElements(elements, appState, false),
keyTest: (event) => keyTest: (event) =>
event.code === CODES.ONE && event.code === CODES.ONE &&
@@ -268,6 +276,7 @@ export const actionZoomToFit = register({
export const actionToggleTheme = register({ export const actionToggleTheme = register({
name: "toggleTheme", name: "toggleTheme",
trackEvent: { category: "canvas" },
perform: (_, appState, value) => { perform: (_, appState, value) => {
return { return {
appState: { appState: {
@@ -293,23 +302,55 @@ export const actionToggleTheme = register({
export const actionErase = register({ export const actionErase = register({
name: "eraser", name: "eraser",
trackEvent: { category: "toolbar" },
perform: (elements, appState) => { perform: (elements, appState) => {
const activeTool: any = { ...appState.activeTool };
if (appState.activeTool.type !== "eraser") {
if (appState.activeTool.type === "custom") {
activeTool.lastActiveToolBeforeEraser = {
type: "custom",
customType: appState.activeTool.customType,
};
} else {
activeTool.lastActiveToolBeforeEraser = appState.activeTool.type;
}
}
if (isEraserActive(appState)) {
if (appState.activeTool.lastActiveToolBeforeEraser) {
if (
typeof appState.activeTool.lastActiveToolBeforeEraser === "object" &&
appState.activeTool.lastActiveToolBeforeEraser?.type === "custom"
) {
activeTool.type = "custom";
activeTool.customType =
appState.activeTool.lastActiveToolBeforeEraser.customType;
} else {
activeTool.type = appState.activeTool.lastActiveToolBeforeEraser;
}
} else {
activeTool.type = "selection";
}
} else {
activeTool.type = "eraser";
}
return { return {
appState: { appState: {
...appState, ...appState,
selectedElementIds: {}, selectedElementIds: {},
selectedGroupIds: {}, selectedGroupIds: {},
elementType: isEraserActive(appState) ? "selection" : "eraser", activeTool,
}, },
commitToHistory: true, commitToHistory: true,
}; };
}, },
keyTest: (event) => event.key === KEYS.E,
PanelComponent: ({ elements, appState, updateData, data }) => ( PanelComponent: ({ elements, appState, updateData, data }) => (
<ToolButton <ToolButton
type="button" type="button"
icon={eraser} icon={eraser}
className={clsx("eraser", { active: isEraserActive(appState) })} className={clsx("eraser", { active: isEraserActive(appState) })}
title={t("toolBar.eraser")} title={`${t("toolBar.eraser")}-${getShortcutKey("E")}`}
aria-label={t("toolBar.eraser")} aria-label={t("toolBar.eraser")}
onClick={() => { onClick={() => {
updateData(null); updateData(null);
+45 -3
View File
@@ -1,16 +1,23 @@
import { CODES, KEYS } from "../keys"; import { CODES, KEYS } from "../keys";
import { register } from "./register"; import { register } from "./register";
import { copyToClipboard } from "../clipboard"; import {
copyTextToSystemClipboard,
copyToClipboard,
probablySupportsClipboardWriteText,
} from "../clipboard";
import { actionDeleteSelected } from "./actionDeleteSelected"; import { actionDeleteSelected } from "./actionDeleteSelected";
import { getSelectedElements } from "../scene/selection"; import { getSelectedElements } from "../scene/selection";
import { exportCanvas } from "../data/index"; import { exportCanvas } from "../data/index";
import { getNonDeletedElements } from "../element"; import { getNonDeletedElements, isTextElement } from "../element";
import { t } from "../i18n"; import { t } from "../i18n";
export const actionCopy = register({ export const actionCopy = register({
name: "copy", name: "copy",
trackEvent: { category: "element" },
perform: (elements, appState, _, app) => { perform: (elements, appState, _, app) => {
copyToClipboard(getNonDeletedElements(elements), appState, app.files); const selectedElements = getSelectedElements(elements, appState, true);
copyToClipboard(selectedElements, appState, app.files);
return { return {
commitToHistory: false, commitToHistory: false,
@@ -23,6 +30,7 @@ export const actionCopy = register({
export const actionCut = register({ export const actionCut = register({
name: "cut", name: "cut",
trackEvent: { category: "element" },
perform: (elements, appState, data, app) => { perform: (elements, appState, data, app) => {
actionCopy.perform(elements, appState, data, app); actionCopy.perform(elements, appState, data, app);
return actionDeleteSelected.perform(elements, appState); return actionDeleteSelected.perform(elements, appState);
@@ -33,6 +41,7 @@ export const actionCut = register({
export const actionCopyAsSvg = register({ export const actionCopyAsSvg = register({
name: "copyAsSvg", name: "copyAsSvg",
trackEvent: { category: "element" },
perform: async (elements, appState, _data, app) => { perform: async (elements, appState, _data, app) => {
if (!app.canvas) { if (!app.canvas) {
return { return {
@@ -73,6 +82,7 @@ export const actionCopyAsSvg = register({
export const actionCopyAsPng = register({ export const actionCopyAsPng = register({
name: "copyAsPng", name: "copyAsPng",
trackEvent: { category: "element" },
perform: async (elements, appState, _data, app) => { perform: async (elements, appState, _data, app) => {
if (!app.canvas) { if (!app.canvas) {
return { return {
@@ -122,3 +132,35 @@ export const actionCopyAsPng = register({
contextItemLabel: "labels.copyAsPng", contextItemLabel: "labels.copyAsPng",
keyTest: (event) => event.code === CODES.C && event.altKey && event.shiftKey, keyTest: (event) => event.code === CODES.C && event.altKey && event.shiftKey,
}); });
export const copyText = register({
name: "copyText",
trackEvent: { category: "element" },
perform: (elements, appState) => {
const selectedElements = getSelectedElements(
getNonDeletedElements(elements),
appState,
true,
);
const text = selectedElements
.reduce((acc: string[], element) => {
if (isTextElement(element)) {
acc.push(element.text);
}
return acc;
}, [])
.join("\n\n");
copyTextToSystemClipboard(text);
return {
commitToHistory: false,
};
},
contextItemPredicate: (elements, appState) => {
return (
probablySupportsClipboardWriteText &&
getSelectedElements(elements, appState, true).some(isTextElement)
);
},
contextItemLabel: "labels.copyText",
});
+2 -1
View File
@@ -58,6 +58,7 @@ const handleGroupEditingState = (
export const actionDeleteSelected = register({ export const actionDeleteSelected = register({
name: "deleteSelectedElements", name: "deleteSelectedElements",
trackEvent: { category: "element", action: "delete" },
perform: (elements, appState) => { perform: (elements, appState) => {
if (appState.editingLinearElement) { if (appState.editingLinearElement) {
const { const {
@@ -133,7 +134,7 @@ export const actionDeleteSelected = register({
elements: nextElements, elements: nextElements,
appState: { appState: {
...nextAppState, ...nextAppState,
elementType: "selection", activeTool: { ...appState.activeTool, type: "selection" },
multiElement: null, multiElement: null,
}, },
commitToHistory: isSomeElementSelected( commitToHistory: isSomeElementSelected(
+2
View File
@@ -39,6 +39,7 @@ const distributeSelectedElements = (
export const distributeHorizontally = register({ export const distributeHorizontally = register({
name: "distributeHorizontally", name: "distributeHorizontally",
trackEvent: { category: "element" },
perform: (elements, appState) => { perform: (elements, appState) => {
return { return {
appState, appState,
@@ -68,6 +69,7 @@ export const distributeHorizontally = register({
export const distributeVertically = register({ export const distributeVertically = register({
name: "distributeVertically", name: "distributeVertically",
trackEvent: { category: "element" },
perform: (elements, appState) => { perform: (elements, appState) => {
return { return {
appState, appState,
+1
View File
@@ -22,6 +22,7 @@ import { isBoundToContainer } from "../element/typeChecks";
export const actionDuplicateSelection = register({ export const actionDuplicateSelection = register({
name: "duplicateSelection", name: "duplicateSelection",
trackEvent: { category: "element" },
perform: (elements, appState) => { perform: (elements, appState) => {
// duplicate selected point(s) if editing a line // duplicate selected point(s) if editing a line
if (appState.editingLinearElement) { if (appState.editingLinearElement) {
+11 -5
View File
@@ -1,4 +1,3 @@
import { trackEvent } from "../analytics";
import { load, questionCircle, saveAs } from "../components/icons"; import { load, questionCircle, saveAs } from "../components/icons";
import { ProjectName } from "../components/ProjectName"; import { ProjectName } from "../components/ProjectName";
import { ToolButton } from "../components/ToolButton"; import { ToolButton } from "../components/ToolButton";
@@ -8,7 +7,7 @@ import { DarkModeToggle } from "../components/DarkModeToggle";
import { loadFromJSON, saveAsJSON } from "../data"; import { loadFromJSON, saveAsJSON } from "../data";
import { resaveAsImageWithScene } from "../data/resave"; import { resaveAsImageWithScene } from "../data/resave";
import { t } from "../i18n"; import { t } from "../i18n";
import { useIsMobile } from "../components/App"; import { useDeviceType } from "../components/App";
import { KEYS } from "../keys"; import { KEYS } from "../keys";
import { register } from "./register"; import { register } from "./register";
import { CheckboxItem } from "../components/CheckboxItem"; import { CheckboxItem } from "../components/CheckboxItem";
@@ -23,8 +22,8 @@ import { Theme } from "../element/types";
export const actionChangeProjectName = register({ export const actionChangeProjectName = register({
name: "changeProjectName", name: "changeProjectName",
trackEvent: false,
perform: (_elements, appState, value) => { perform: (_elements, appState, value) => {
trackEvent("change", "title");
return { appState: { ...appState, name: value }, commitToHistory: false }; return { appState: { ...appState, name: value }, commitToHistory: false };
}, },
PanelComponent: ({ appState, updateData, appProps }) => ( PanelComponent: ({ appState, updateData, appProps }) => (
@@ -41,6 +40,7 @@ export const actionChangeProjectName = register({
export const actionChangeExportScale = register({ export const actionChangeExportScale = register({
name: "changeExportScale", name: "changeExportScale",
trackEvent: { category: "export", action: "scale" },
perform: (_elements, appState, value) => { perform: (_elements, appState, value) => {
return { return {
appState: { ...appState, exportScale: value }, appState: { ...appState, exportScale: value },
@@ -89,6 +89,7 @@ export const actionChangeExportScale = register({
export const actionChangeExportBackground = register({ export const actionChangeExportBackground = register({
name: "changeExportBackground", name: "changeExportBackground",
trackEvent: { category: "export", action: "toggleBackground" },
perform: (_elements, appState, value) => { perform: (_elements, appState, value) => {
return { return {
appState: { ...appState, exportBackground: value }, appState: { ...appState, exportBackground: value },
@@ -107,6 +108,7 @@ export const actionChangeExportBackground = register({
export const actionChangeExportEmbedScene = register({ export const actionChangeExportEmbedScene = register({
name: "changeExportEmbedScene", name: "changeExportEmbedScene",
trackEvent: { category: "export", action: "embedScene" },
perform: (_elements, appState, value) => { perform: (_elements, appState, value) => {
return { return {
appState: { ...appState, exportEmbedScene: value }, appState: { ...appState, exportEmbedScene: value },
@@ -128,6 +130,7 @@ export const actionChangeExportEmbedScene = register({
export const actionSaveToActiveFile = register({ export const actionSaveToActiveFile = register({
name: "saveToActiveFile", name: "saveToActiveFile",
trackEvent: { category: "export" },
perform: async (elements, appState, value, app) => { perform: async (elements, appState, value, app) => {
const fileHandleExists = !!appState.fileHandle; const fileHandleExists = !!appState.fileHandle;
@@ -172,6 +175,7 @@ export const actionSaveToActiveFile = register({
export const actionSaveFileToDisk = register({ export const actionSaveFileToDisk = register({
name: "saveFileToDisk", name: "saveFileToDisk",
trackEvent: { category: "export" },
perform: async (elements, appState, value, app) => { perform: async (elements, appState, value, app) => {
try { try {
const { fileHandle } = await saveAsJSON( const { fileHandle } = await saveAsJSON(
@@ -200,7 +204,7 @@ export const actionSaveFileToDisk = register({
icon={saveAs} icon={saveAs}
title={t("buttons.saveAs")} title={t("buttons.saveAs")}
aria-label={t("buttons.saveAs")} aria-label={t("buttons.saveAs")}
showAriaLabel={useIsMobile()} showAriaLabel={useDeviceType().isMobile}
hidden={!nativeFileSystemSupported} hidden={!nativeFileSystemSupported}
onClick={() => updateData(null)} onClick={() => updateData(null)}
data-testid="save-as-button" data-testid="save-as-button"
@@ -210,6 +214,7 @@ export const actionSaveFileToDisk = register({
export const actionLoadScene = register({ export const actionLoadScene = register({
name: "loadScene", name: "loadScene",
trackEvent: { category: "export" },
perform: async (elements, appState, _, app) => { perform: async (elements, appState, _, app) => {
try { try {
const { const {
@@ -243,7 +248,7 @@ export const actionLoadScene = register({
icon={load} icon={load}
title={t("buttons.load")} title={t("buttons.load")}
aria-label={t("buttons.load")} aria-label={t("buttons.load")}
showAriaLabel={useIsMobile()} showAriaLabel={useDeviceType().isMobile}
onClick={updateData} onClick={updateData}
data-testid="load-button" data-testid="load-button"
/> />
@@ -252,6 +257,7 @@ export const actionLoadScene = register({
export const actionExportWithDarkMode = register({ export const actionExportWithDarkMode = register({
name: "exportWithDarkMode", name: "exportWithDarkMode",
trackEvent: { category: "export", action: "toggleTheme" },
perform: (_elements, appState, value) => { perform: (_elements, appState, value) => {
return { return {
appState: { ...appState, exportWithDarkMode: value }, appState: { ...appState, exportWithDarkMode: value },
+34 -11
View File
@@ -17,6 +17,7 @@ import { isBindingElement } from "../element/typeChecks";
export const actionFinalize = register({ export const actionFinalize = register({
name: "finalize", name: "finalize",
trackEvent: false,
perform: (elements, appState, _, { canvas, focusContainer }) => { perform: (elements, appState, _, { canvas, focusContainer }) => {
if (appState.editingLinearElement) { if (appState.editingLinearElement) {
const { elementId, startBindingElement, endBindingElement } = const { elementId, startBindingElement, endBindingElement } =
@@ -38,6 +39,7 @@ export const actionFinalize = register({
: undefined, : undefined,
appState: { appState: {
...appState, ...appState,
cursorButton: "up",
editingLinearElement: null, editingLinearElement: null,
}, },
commitToHistory: true, commitToHistory: true,
@@ -119,27 +121,47 @@ export const actionFinalize = register({
); );
} }
if (!appState.elementLocked && appState.elementType !== "freedraw") { if (
!appState.activeTool.locked &&
appState.activeTool.type !== "freedraw"
) {
appState.selectedElementIds[multiPointElement.id] = true; appState.selectedElementIds[multiPointElement.id] = true;
} }
} }
if ( if (
(!appState.elementLocked && appState.elementType !== "freedraw") || (!appState.activeTool.locked &&
appState.activeTool.type !== "freedraw") ||
!multiPointElement !multiPointElement
) { ) {
resetCursor(canvas); resetCursor(canvas);
} }
const activeTool: any = { ...appState.activeTool };
if (appState.activeTool.lastActiveToolBeforeEraser) {
if (
typeof appState.activeTool.lastActiveToolBeforeEraser === "object" &&
appState.activeTool.lastActiveToolBeforeEraser.type === "custom"
) {
activeTool.type = appState.activeTool.lastActiveToolBeforeEraser.type;
activeTool.customType =
appState.activeTool.lastActiveToolBeforeEraser.customType;
} else {
activeTool.type = appState.activeTool.lastActiveToolBeforeEraser;
}
} else {
activeTool.type = "selection";
}
return { return {
elements: newElements, elements: newElements,
appState: { appState: {
...appState, ...appState,
elementType: cursorButton: "up",
(appState.elementLocked || appState.elementType === "freedraw") && activeTool:
(appState.activeTool.locked ||
appState.activeTool.type === "freedraw") &&
multiPointElement multiPointElement
? appState.elementType ? appState.activeTool
: "selection", : activeTool,
draggingElement: null, draggingElement: null,
multiElement: null, multiElement: null,
editingElement: null, editingElement: null,
@@ -147,8 +169,8 @@ export const actionFinalize = register({
suggestedBindings: [], suggestedBindings: [],
selectedElementIds: selectedElementIds:
multiPointElement && multiPointElement &&
!appState.elementLocked && !appState.activeTool.locked &&
appState.elementType !== "freedraw" appState.activeTool.type !== "freedraw"
? { ? {
...appState.selectedElementIds, ...appState.selectedElementIds,
[multiPointElement.id]: true, [multiPointElement.id]: true,
@@ -156,7 +178,7 @@ export const actionFinalize = register({
: appState.selectedElementIds, : appState.selectedElementIds,
pendingImageElement: null, pendingImageElement: null,
}, },
commitToHistory: appState.elementType === "freedraw", commitToHistory: appState.activeTool.type === "freedraw",
}; };
}, },
keyTest: (event, appState) => keyTest: (event, appState) =>
@@ -165,7 +187,7 @@ export const actionFinalize = register({
(!appState.draggingElement && appState.multiElement === null))) || (!appState.draggingElement && appState.multiElement === null))) ||
((event.key === KEYS.ESCAPE || event.key === KEYS.ENTER) && ((event.key === KEYS.ESCAPE || event.key === KEYS.ENTER) &&
appState.multiElement !== null), appState.multiElement !== null),
PanelComponent: ({ appState, updateData }) => ( PanelComponent: ({ appState, updateData, data }) => (
<ToolButton <ToolButton
type="button" type="button"
icon={done} icon={done}
@@ -173,6 +195,7 @@ export const actionFinalize = register({
aria-label={t("buttons.done")} aria-label={t("buttons.done")}
onClick={updateData} onClick={updateData}
visible={appState.multiElement != null} visible={appState.multiElement != null}
size={data?.size || "medium"}
/> />
), ),
}); });
+2
View File
@@ -35,6 +35,7 @@ const enableActionFlipVertical = (
export const actionFlipHorizontal = register({ export const actionFlipHorizontal = register({
name: "flipHorizontal", name: "flipHorizontal",
trackEvent: { category: "element" },
perform: (elements, appState) => { perform: (elements, appState) => {
return { return {
elements: flipSelectedElements(elements, appState, "horizontal"), elements: flipSelectedElements(elements, appState, "horizontal"),
@@ -50,6 +51,7 @@ export const actionFlipHorizontal = register({
export const actionFlipVertical = register({ export const actionFlipVertical = register({
name: "flipVertical", name: "flipVertical",
trackEvent: { category: "element" },
perform: (elements, appState) => { perform: (elements, appState) => {
return { return {
elements: flipSelectedElements(elements, appState, "vertical"), elements: flipSelectedElements(elements, appState, "vertical"),
+2
View File
@@ -54,6 +54,7 @@ const enableActionGroup = (
export const actionGroup = register({ export const actionGroup = register({
name: "group", name: "group",
trackEvent: { category: "element" },
perform: (elements, appState) => { perform: (elements, appState) => {
const selectedElements = getSelectedElements( const selectedElements = getSelectedElements(
getNonDeletedElements(elements), getNonDeletedElements(elements),
@@ -147,6 +148,7 @@ export const actionGroup = register({
export const actionUngroup = register({ export const actionUngroup = register({
name: "ungroup", name: "ungroup",
trackEvent: { category: "element" },
perform: (elements, appState) => { perform: (elements, appState) => {
const groupIds = getSelectedGroupIds(appState); const groupIds = getSelectedGroupIds(appState);
if (groupIds.length === 0) { if (groupIds.length === 0) {
+2
View File
@@ -62,6 +62,7 @@ type ActionCreator = (history: History) => Action;
export const createUndoAction: ActionCreator = (history) => ({ export const createUndoAction: ActionCreator = (history) => ({
name: "undo", name: "undo",
trackEvent: { category: "history" },
perform: (elements, appState) => perform: (elements, appState) =>
writeData(elements, appState, () => history.undoOnce()), writeData(elements, appState, () => history.undoOnce()),
keyTest: (event) => keyTest: (event) =>
@@ -82,6 +83,7 @@ export const createUndoAction: ActionCreator = (history) => ({
export const createRedoAction: ActionCreator = (history) => ({ export const createRedoAction: ActionCreator = (history) => ({
name: "redo", name: "redo",
trackEvent: { category: "history" },
perform: (elements, appState) => perform: (elements, appState) =>
writeData(elements, appState, () => history.redoOnce()), writeData(elements, appState, () => history.redoOnce()),
keyTest: (event) => keyTest: (event) =>
+4
View File
@@ -9,6 +9,7 @@ import { HelpIcon } from "../components/HelpIcon";
export const actionToggleCanvasMenu = register({ export const actionToggleCanvasMenu = register({
name: "toggleCanvasMenu", name: "toggleCanvasMenu",
trackEvent: { category: "menu" },
perform: (_, appState) => ({ perform: (_, appState) => ({
appState: { appState: {
...appState, ...appState,
@@ -29,6 +30,7 @@ export const actionToggleCanvasMenu = register({
export const actionToggleEditMenu = register({ export const actionToggleEditMenu = register({
name: "toggleEditMenu", name: "toggleEditMenu",
trackEvent: { category: "menu" },
perform: (_elements, appState) => ({ perform: (_elements, appState) => ({
appState: { appState: {
...appState, ...appState,
@@ -53,6 +55,7 @@ export const actionToggleEditMenu = register({
export const actionFullScreen = register({ export const actionFullScreen = register({
name: "toggleFullScreen", name: "toggleFullScreen",
trackEvent: { category: "canvas", predicate: (appState) => !isFullScreen() },
perform: () => { perform: () => {
if (!isFullScreen()) { if (!isFullScreen()) {
allowFullScreen(); allowFullScreen();
@@ -69,6 +72,7 @@ export const actionFullScreen = register({
export const actionShortcuts = register({ export const actionShortcuts = register({
name: "toggleShortcuts", name: "toggleShortcuts",
trackEvent: { category: "menu", action: "toggleHelpDialog" },
perform: (_elements, appState, _, { focusContainer }) => { perform: (_elements, appState, _, { focusContainer }) => {
if (appState.showHelpDialog) { if (appState.showHelpDialog) {
focusContainer(); focusContainer();
+5 -5
View File
@@ -1,4 +1,4 @@
import { getClientColors, getClientInitials } from "../clients"; import { getClientColors } from "../clients";
import { Avatar } from "../components/Avatar"; import { Avatar } from "../components/Avatar";
import { centerScrollOn } from "../scene/scroll"; import { centerScrollOn } from "../scene/scroll";
import { Collaborator } from "../types"; import { Collaborator } from "../types";
@@ -6,6 +6,7 @@ import { register } from "./register";
export const actionGoToCollaborator = register({ export const actionGoToCollaborator = register({
name: "goToCollaborator", name: "goToCollaborator",
trackEvent: { category: "collab" },
perform: (_elements, appState, value) => { perform: (_elements, appState, value) => {
const point = value as Collaborator["pointer"]; const point = value as Collaborator["pointer"];
if (!point) { if (!point) {
@@ -42,16 +43,15 @@ export const actionGoToCollaborator = register({
} }
const { background, stroke } = getClientColors(clientId, appState); const { background, stroke } = getClientColors(clientId, appState);
const shortName = getClientInitials(collaborator.username);
return ( return (
<Avatar <Avatar
color={background} color={background}
border={stroke} border={stroke}
onClick={() => updateData(collaborator.pointer)} onClick={() => updateData(collaborator.pointer)}
> name={collaborator.username || ""}
{shortName} src={collaborator.src}
</Avatar> />
); );
}, },
}); });
+23 -38
View File
@@ -166,11 +166,7 @@ const changeFontSize = (
let newElement: ExcalidrawTextElement = newElementWith(oldElement, { let newElement: ExcalidrawTextElement = newElementWith(oldElement, {
fontSize: newFontSize, fontSize: newFontSize,
}); });
redrawTextBoundingBox( redrawTextBoundingBox(newElement, getContainerElement(oldElement));
newElement,
getContainerElement(oldElement),
appState,
);
newElement = offsetElementAfterFontResize(oldElement, newElement); newElement = offsetElementAfterFontResize(oldElement, newElement);
@@ -198,6 +194,7 @@ const changeFontSize = (
export const actionChangeStrokeColor = register({ export const actionChangeStrokeColor = register({
name: "changeStrokeColor", name: "changeStrokeColor",
trackEvent: false,
perform: (elements, appState, value) => { perform: (elements, appState, value) => {
return { return {
...(value.currentItemStrokeColor && { ...(value.currentItemStrokeColor && {
@@ -247,6 +244,7 @@ export const actionChangeStrokeColor = register({
export const actionChangeBackgroundColor = register({ export const actionChangeBackgroundColor = register({
name: "changeBackgroundColor", name: "changeBackgroundColor",
trackEvent: false,
perform: (elements, appState, value) => { perform: (elements, appState, value) => {
return { return {
...(value.currentItemBackgroundColor && { ...(value.currentItemBackgroundColor && {
@@ -289,6 +287,7 @@ export const actionChangeBackgroundColor = register({
export const actionChangeFillStyle = register({ export const actionChangeFillStyle = register({
name: "changeFillStyle", name: "changeFillStyle",
trackEvent: false,
perform: (elements, appState, value) => { perform: (elements, appState, value) => {
return { return {
elements: changeProperty(elements, appState, (el) => elements: changeProperty(elements, appState, (el) =>
@@ -338,6 +337,7 @@ export const actionChangeFillStyle = register({
export const actionChangeStrokeWidth = register({ export const actionChangeStrokeWidth = register({
name: "changeStrokeWidth", name: "changeStrokeWidth",
trackEvent: false,
perform: (elements, appState, value) => { perform: (elements, appState, value) => {
return { return {
elements: changeProperty(elements, appState, (el) => elements: changeProperty(elements, appState, (el) =>
@@ -385,6 +385,7 @@ export const actionChangeStrokeWidth = register({
export const actionChangeSloppiness = register({ export const actionChangeSloppiness = register({
name: "changeSloppiness", name: "changeSloppiness",
trackEvent: false,
perform: (elements, appState, value) => { perform: (elements, appState, value) => {
return { return {
elements: changeProperty(elements, appState, (el) => elements: changeProperty(elements, appState, (el) =>
@@ -433,6 +434,7 @@ export const actionChangeSloppiness = register({
export const actionChangeStrokeStyle = register({ export const actionChangeStrokeStyle = register({
name: "changeStrokeStyle", name: "changeStrokeStyle",
trackEvent: false,
perform: (elements, appState, value) => { perform: (elements, appState, value) => {
return { return {
elements: changeProperty(elements, appState, (el) => elements: changeProperty(elements, appState, (el) =>
@@ -480,6 +482,7 @@ export const actionChangeStrokeStyle = register({
export const actionChangeOpacity = register({ export const actionChangeOpacity = register({
name: "changeOpacity", name: "changeOpacity",
trackEvent: false,
perform: (elements, appState, value) => { perform: (elements, appState, value) => {
return { return {
elements: changeProperty(elements, appState, (el) => elements: changeProperty(elements, appState, (el) =>
@@ -500,20 +503,6 @@ export const actionChangeOpacity = register({
max="100" max="100"
step="10" step="10"
onChange={(event) => updateData(+event.target.value)} onChange={(event) => updateData(+event.target.value)}
onWheel={(event) => {
event.stopPropagation();
const target = event.target as HTMLInputElement;
const STEP = 10;
const MAX = 100;
const MIN = 0;
const value = +target.value;
if (event.deltaY < 0 && value < MAX) {
updateData(value + STEP);
} else if (event.deltaY > 0 && value > MIN) {
updateData(value - STEP);
}
}}
value={ value={
getFormValue( getFormValue(
elements, elements,
@@ -529,6 +518,7 @@ export const actionChangeOpacity = register({
export const actionChangeFontSize = register({ export const actionChangeFontSize = register({
name: "changeFontSize", name: "changeFontSize",
trackEvent: false,
perform: (elements, appState, value) => { perform: (elements, appState, value) => {
return changeFontSize(elements, appState, () => value, value); return changeFontSize(elements, appState, () => value, value);
}, },
@@ -586,6 +576,7 @@ export const actionChangeFontSize = register({
export const actionDecreaseFontSize = register({ export const actionDecreaseFontSize = register({
name: "decreaseFontSize", name: "decreaseFontSize",
trackEvent: false,
perform: (elements, appState, value) => { perform: (elements, appState, value) => {
return changeFontSize(elements, appState, (element) => return changeFontSize(elements, appState, (element) =>
Math.round( Math.round(
@@ -607,6 +598,7 @@ export const actionDecreaseFontSize = register({
export const actionIncreaseFontSize = register({ export const actionIncreaseFontSize = register({
name: "increaseFontSize", name: "increaseFontSize",
trackEvent: false,
perform: (elements, appState, value) => { perform: (elements, appState, value) => {
return changeFontSize(elements, appState, (element) => return changeFontSize(elements, appState, (element) =>
Math.round(element.fontSize * (1 + FONT_SIZE_RELATIVE_INCREASE_STEP)), Math.round(element.fontSize * (1 + FONT_SIZE_RELATIVE_INCREASE_STEP)),
@@ -624,6 +616,7 @@ export const actionIncreaseFontSize = register({
export const actionChangeFontFamily = register({ export const actionChangeFontFamily = register({
name: "changeFontFamily", name: "changeFontFamily",
trackEvent: false,
perform: (elements, appState, value) => { perform: (elements, appState, value) => {
return { return {
elements: changeProperty( elements: changeProperty(
@@ -637,11 +630,7 @@ export const actionChangeFontFamily = register({
fontFamily: value, fontFamily: value,
}, },
); );
redrawTextBoundingBox( redrawTextBoundingBox(newElement, getContainerElement(oldElement));
newElement,
getContainerElement(oldElement),
appState,
);
return newElement; return newElement;
} }
@@ -709,6 +698,7 @@ export const actionChangeFontFamily = register({
export const actionChangeTextAlign = register({ export const actionChangeTextAlign = register({
name: "changeTextAlign", name: "changeTextAlign",
trackEvent: false,
perform: (elements, appState, value) => { perform: (elements, appState, value) => {
return { return {
elements: changeProperty( elements: changeProperty(
@@ -720,11 +710,7 @@ export const actionChangeTextAlign = register({
oldElement, oldElement,
{ textAlign: value }, { textAlign: value },
); );
redrawTextBoundingBox( redrawTextBoundingBox(newElement, getContainerElement(oldElement));
newElement,
getContainerElement(oldElement),
appState,
);
return newElement; return newElement;
} }
@@ -785,6 +771,7 @@ export const actionChangeTextAlign = register({
}); });
export const actionChangeVerticalAlign = register({ export const actionChangeVerticalAlign = register({
name: "changeVerticalAlign", name: "changeVerticalAlign",
trackEvent: { category: "element" },
perform: (elements, appState, value) => { perform: (elements, appState, value) => {
return { return {
elements: changeProperty( elements: changeProperty(
@@ -797,11 +784,7 @@ export const actionChangeVerticalAlign = register({
{ verticalAlign: value }, { verticalAlign: value },
); );
redrawTextBoundingBox( redrawTextBoundingBox(newElement, getContainerElement(oldElement));
newElement,
getContainerElement(oldElement),
appState,
);
return newElement; return newElement;
} }
@@ -856,6 +839,7 @@ export const actionChangeVerticalAlign = register({
export const actionChangeSharpness = register({ export const actionChangeSharpness = register({
name: "changeSharpness", name: "changeSharpness",
trackEvent: false,
perform: (elements, appState, value) => { perform: (elements, appState, value) => {
const targetElements = getTargetElements( const targetElements = getTargetElements(
getNonDeletedElements(elements), getNonDeletedElements(elements),
@@ -863,10 +847,10 @@ export const actionChangeSharpness = register({
); );
const shouldUpdateForNonLinearElements = targetElements.length const shouldUpdateForNonLinearElements = targetElements.length
? targetElements.every((el) => !isLinearElement(el)) ? targetElements.every((el) => !isLinearElement(el))
: !isLinearElementType(appState.elementType); : !isLinearElementType(appState.activeTool.type);
const shouldUpdateForLinearElements = targetElements.length const shouldUpdateForLinearElements = targetElements.length
? targetElements.every(isLinearElement) ? targetElements.every(isLinearElement)
: isLinearElementType(appState.elementType); : isLinearElementType(appState.activeTool.type);
return { return {
elements: changeProperty(elements, appState, (el) => elements: changeProperty(elements, appState, (el) =>
newElementWith(el, { newElementWith(el, {
@@ -906,8 +890,8 @@ export const actionChangeSharpness = register({
elements, elements,
appState, appState,
(element) => element.strokeSharpness, (element) => element.strokeSharpness,
(canChangeSharpness(appState.elementType) && (canChangeSharpness(appState.activeTool.type) &&
(isLinearElementType(appState.elementType) (isLinearElementType(appState.activeTool.type)
? appState.currentItemLinearStrokeSharpness ? appState.currentItemLinearStrokeSharpness
: appState.currentItemStrokeSharpness)) || : appState.currentItemStrokeSharpness)) ||
null, null,
@@ -920,6 +904,7 @@ export const actionChangeSharpness = register({
export const actionChangeArrowhead = register({ export const actionChangeArrowhead = register({
name: "changeArrowhead", name: "changeArrowhead",
trackEvent: false,
perform: ( perform: (
elements, elements,
appState, appState,
+3 -1
View File
@@ -5,6 +5,7 @@ import { getNonDeletedElements, isTextElement } from "../element";
export const actionSelectAll = register({ export const actionSelectAll = register({
name: "selectAll", name: "selectAll",
trackEvent: { category: "canvas" },
perform: (elements, appState) => { perform: (elements, appState) => {
if (appState.editingLinearElement) { if (appState.editingLinearElement) {
return false; return false;
@@ -17,7 +18,8 @@ export const actionSelectAll = register({
selectedElementIds: elements.reduce((map, element) => { selectedElementIds: elements.reduce((map, element) => {
if ( if (
!element.isDeleted && !element.isDeleted &&
!(isTextElement(element) && element.containerId) !(isTextElement(element) && element.containerId) &&
element.locked === false
) { ) {
map[element.id] = true; map[element.id] = true;
} }
+3 -5
View File
@@ -19,6 +19,7 @@ export let copiedStyles: string = "{}";
export const actionCopyStyles = register({ export const actionCopyStyles = register({
name: "copyStyles", name: "copyStyles",
trackEvent: { category: "element" },
perform: (elements, appState) => { perform: (elements, appState) => {
const element = elements.find((el) => appState.selectedElementIds[el.id]); const element = elements.find((el) => appState.selectedElementIds[el.id]);
if (element) { if (element) {
@@ -39,6 +40,7 @@ export const actionCopyStyles = register({
export const actionPasteStyles = register({ export const actionPasteStyles = register({
name: "pasteStyles", name: "pasteStyles",
trackEvent: { category: "element" },
perform: (elements, appState) => { perform: (elements, appState) => {
const pastedElement = JSON.parse(copiedStyles); const pastedElement = JSON.parse(copiedStyles);
if (!isExcalidrawElement(pastedElement)) { if (!isExcalidrawElement(pastedElement)) {
@@ -63,11 +65,7 @@ export const actionPasteStyles = register({
textAlign: pastedElement?.textAlign || DEFAULT_TEXT_ALIGN, textAlign: pastedElement?.textAlign || DEFAULT_TEXT_ALIGN,
}); });
redrawTextBoundingBox( redrawTextBoundingBox(newElement, getContainerElement(newElement));
newElement,
getContainerElement(newElement),
appState,
);
} }
return newElement; return newElement;
} }
+4 -2
View File
@@ -2,12 +2,14 @@ import { CODES, KEYS } from "../keys";
import { register } from "./register"; import { register } from "./register";
import { GRID_SIZE } from "../constants"; import { GRID_SIZE } from "../constants";
import { AppState } from "../types"; import { AppState } from "../types";
import { trackEvent } from "../analytics";
export const actionToggleGridMode = register({ export const actionToggleGridMode = register({
name: "gridMode", name: "gridMode",
trackEvent: {
category: "canvas",
predicate: (appState) => !appState.gridSize,
},
perform(elements, appState) { perform(elements, appState) {
trackEvent("view", "mode", "grid");
return { return {
appState: { appState: {
...appState, ...appState,
+63
View File
@@ -0,0 +1,63 @@
import { newElementWith } from "../element/mutateElement";
import { ExcalidrawElement } from "../element/types";
import { KEYS } from "../keys";
import { getSelectedElements } from "../scene";
import { arrayToMap } from "../utils";
import { register } from "./register";
export const actionToggleLock = register({
name: "toggleLock",
trackEvent: { category: "element" },
perform: (elements, appState) => {
const selectedElements = getSelectedElements(elements, appState, true);
if (!selectedElements.length) {
return false;
}
const operation = getOperation(selectedElements);
const selectedElementsMap = arrayToMap(selectedElements);
return {
elements: elements.map((element) => {
if (!selectedElementsMap.has(element.id)) {
return element;
}
return newElementWith(element, { locked: operation === "lock" });
}),
appState,
commitToHistory: true,
};
},
contextItemLabel: (elements, appState) => {
const selected = getSelectedElements(elements, appState, false);
if (selected.length === 1) {
return selected[0].locked
? "labels.elementLock.unlock"
: "labels.elementLock.lock";
}
if (selected.length > 1) {
return getOperation(selected) === "lock"
? "labels.elementLock.lockAll"
: "labels.elementLock.unlockAll";
}
throw new Error(
"Unexpected zero elements to lock/unlock. This should never happen.",
);
},
keyTest: (event, appState, elements) => {
return (
event.key.toLocaleLowerCase() === KEYS.L &&
event[KEYS.CTRL_OR_CMD] &&
event.shiftKey &&
getSelectedElements(elements, appState, false).length > 0
);
},
});
const getOperation = (
elements: readonly ExcalidrawElement[],
): "lock" | "unlock" => (elements.some((el) => !el.locked) ? "lock" : "unlock");
+1
View File
@@ -3,6 +3,7 @@ import { CODES, KEYS } from "../keys";
export const actionToggleStats = register({ export const actionToggleStats = register({
name: "stats", name: "stats",
trackEvent: { category: "menu" },
perform(elements, appState) { perform(elements, appState) {
return { return {
appState: { appState: {
+4 -2
View File
@@ -1,11 +1,13 @@
import { CODES, KEYS } from "../keys"; import { CODES, KEYS } from "../keys";
import { register } from "./register"; import { register } from "./register";
import { trackEvent } from "../analytics";
export const actionToggleViewMode = register({ export const actionToggleViewMode = register({
name: "viewMode", name: "viewMode",
trackEvent: {
category: "canvas",
predicate: (appState) => !appState.viewModeEnabled,
},
perform(elements, appState) { perform(elements, appState) {
trackEvent("view", "mode", "view");
return { return {
appState: { appState: {
...appState, ...appState,
+4 -3
View File
@@ -1,12 +1,13 @@
import { CODES, KEYS } from "../keys"; import { CODES, KEYS } from "../keys";
import { register } from "./register"; import { register } from "./register";
import { trackEvent } from "../analytics";
export const actionToggleZenMode = register({ export const actionToggleZenMode = register({
name: "zenMode", name: "zenMode",
trackEvent: {
category: "canvas",
predicate: (appState) => !appState.zenModeEnabled,
},
perform(elements, appState) { perform(elements, appState) {
trackEvent("view", "mode", "zen");
return { return {
appState: { appState: {
...appState, ...appState,
-44
View File
@@ -1,44 +0,0 @@
import { getNonDeletedElements } from "../element";
import { mutateElement } from "../element/mutateElement";
import { getBoundTextElement, measureText } from "../element/textElement";
import { ExcalidrawTextElement } from "../element/types";
import { getSelectedElements } from "../scene";
import { getFontString } from "../utils";
import { register } from "./register";
export const actionUnbindText = register({
name: "unbindText",
contextItemLabel: "labels.unbindText",
perform: (elements, appState) => {
const selectedElements = getSelectedElements(
getNonDeletedElements(elements),
appState,
);
selectedElements.forEach((element) => {
const boundTextElement = getBoundTextElement(element);
if (boundTextElement) {
const { width, height, baseline } = measureText(
boundTextElement.originalText,
getFontString(boundTextElement),
);
mutateElement(boundTextElement as ExcalidrawTextElement, {
containerId: null,
width,
height,
baseline,
text: boundTextElement.originalText,
});
mutateElement(element, {
boundElements: element.boundElements?.filter(
(ele) => ele.id !== boundTextElement.id,
),
});
}
});
return {
elements,
appState,
commitToHistory: true,
};
},
});
+5
View File
@@ -18,6 +18,7 @@ import {
export const actionSendBackward = register({ export const actionSendBackward = register({
name: "sendBackward", name: "sendBackward",
trackEvent: { category: "element" },
perform: (elements, appState) => { perform: (elements, appState) => {
return { return {
elements: moveOneLeft(elements, appState), elements: moveOneLeft(elements, appState),
@@ -45,6 +46,7 @@ export const actionSendBackward = register({
export const actionBringForward = register({ export const actionBringForward = register({
name: "bringForward", name: "bringForward",
trackEvent: { category: "element" },
perform: (elements, appState) => { perform: (elements, appState) => {
return { return {
elements: moveOneRight(elements, appState), elements: moveOneRight(elements, appState),
@@ -72,6 +74,7 @@ export const actionBringForward = register({
export const actionSendToBack = register({ export const actionSendToBack = register({
name: "sendToBack", name: "sendToBack",
trackEvent: { category: "element" },
perform: (elements, appState) => { perform: (elements, appState) => {
return { return {
elements: moveAllLeft(elements, appState), elements: moveAllLeft(elements, appState),
@@ -106,6 +109,8 @@ export const actionSendToBack = register({
export const actionBringToFront = register({ export const actionBringToFront = register({
name: "bringToFront", name: "bringToFront",
trackEvent: { category: "element" },
perform: (elements, appState) => { perform: (elements, appState) => {
return { return {
elements: moveAllRight(elements, appState), elements: moveAllRight(elements, appState),
+3 -1
View File
@@ -75,11 +75,13 @@ export {
actionCut, actionCut,
actionCopyAsPng, actionCopyAsPng,
actionCopyAsSvg, actionCopyAsSvg,
copyText,
} from "./actionClipboard"; } from "./actionClipboard";
export { actionToggleGridMode } from "./actionToggleGridMode"; export { actionToggleGridMode } from "./actionToggleGridMode";
export { actionToggleZenMode } from "./actionToggleZenMode"; export { actionToggleZenMode } from "./actionToggleZenMode";
export { actionToggleStats } from "./actionToggleStats"; export { actionToggleStats } from "./actionToggleStats";
export { actionUnbindText } from "./actionUnbindText"; export { actionUnbindText, actionBindText } from "./actionBoundText";
export { actionLink } from "../element/Hyperlink"; export { actionLink } from "../element/Hyperlink";
export { actionToggleLock } from "./actionToggleLock";
+38 -36
View File
@@ -1,11 +1,11 @@
import React from "react"; import React from "react";
import { import {
Action, Action,
ActionsManagerInterface,
UpdaterFn, UpdaterFn,
ActionName, ActionName,
ActionResult, ActionResult,
PanelComponentProps, PanelComponentProps,
ActionSource,
} from "./types"; } from "./types";
import { ExcalidrawElement } from "../element/types"; import { ExcalidrawElement } from "../element/types";
import { AppClassProperties, AppState } from "../types"; import { AppClassProperties, AppState } from "../types";
@@ -14,21 +14,25 @@ import { trackEvent } from "../analytics";
const trackAction = ( const trackAction = (
action: Action, action: Action,
source: "ui" | "keyboard" | "api", source: ActionSource,
appState: Readonly<AppState>,
elements: readonly ExcalidrawElement[],
app: AppClassProperties,
value: any, value: any,
) => { ) => {
if (action.trackEvent !== false) { if (action.trackEvent) {
try { try {
if (action.trackEvent === true) { if (typeof action.trackEvent === "object") {
trackEvent( const shouldTrack = action.trackEvent.predicate
action.name, ? action.trackEvent.predicate(appState, elements, value)
source, : true;
typeof value === "number" || typeof value === "string" if (shouldTrack) {
? String(value) trackEvent(
: undefined, action.trackEvent.category,
); action.trackEvent.action || action.name,
} else { `${source} (${app.deviceType.isMobile ? "mobile" : "desktop"})`,
action.trackEvent?.(action, source, value); );
}
} }
} catch (error) { } catch (error) {
console.error("error while logging action:", error); console.error("error while logging action:", error);
@@ -36,8 +40,8 @@ const trackAction = (
} }
}; };
export class ActionManager implements ActionsManagerInterface { export class ActionManager {
actions = {} as ActionsManagerInterface["actions"]; actions = {} as Record<ActionName, Action>;
updater: (actionResult: ActionResult | Promise<ActionResult>) => void; updater: (actionResult: ActionResult | Promise<ActionResult>) => void;
@@ -106,30 +110,26 @@ export class ActionManager implements ActionsManagerInterface {
} }
} }
trackAction(action, "keyboard", null); const elements = this.getElementsIncludingDeleted();
const appState = this.getAppState();
const value = null;
trackAction(action, "keyboard", appState, elements, this.app, null);
event.preventDefault(); event.preventDefault();
this.updater( event.stopPropagation();
data[0].perform( this.updater(data[0].perform(elements, appState, value, this.app));
this.getElementsIncludingDeleted(),
this.getAppState(),
null,
this.app,
),
);
return true; return true;
} }
executeAction(action: Action) { executeAction(action: Action, source: ActionSource = "api") {
this.updater( const elements = this.getElementsIncludingDeleted();
action.perform( const appState = this.getAppState();
this.getElementsIncludingDeleted(), const value = null;
this.getAppState(),
null, trackAction(action, source, appState, elements, this.app, value);
this.app,
), this.updater(action.perform(elements, appState, value, this.app));
);
trackAction(action, "api", null);
} }
/** /**
@@ -147,7 +147,11 @@ export class ActionManager implements ActionsManagerInterface {
) { ) {
const action = this.actions[name]; const action = this.actions[name];
const PanelComponent = action.PanelComponent!; const PanelComponent = action.PanelComponent!;
const elements = this.getElementsIncludingDeleted();
const appState = this.getAppState();
const updateData = (formState?: any) => { const updateData = (formState?: any) => {
trackAction(action, "ui", appState, elements, this.app, formState);
this.updater( this.updater(
action.perform( action.perform(
this.getElementsIncludingDeleted(), this.getElementsIncludingDeleted(),
@@ -156,8 +160,6 @@ export class ActionManager implements ActionsManagerInterface {
this.app, this.app,
), ),
); );
trackAction(action, "ui", formState);
}; };
return ( return (
+2
View File
@@ -29,6 +29,7 @@ export type ShortcutName = SubtypeOf<
| "flipHorizontal" | "flipHorizontal"
| "flipVertical" | "flipVertical"
| "hyperlink" | "hyperlink"
| "toggleLock"
>; >;
const shortcutMap: Record<ShortcutName, string[]> = { const shortcutMap: Record<ShortcutName, string[]> = {
@@ -67,6 +68,7 @@ const shortcutMap: Record<ShortcutName, string[]> = {
flipVertical: [getShortcutKey("Shift+V")], flipVertical: [getShortcutKey("Shift+V")],
viewMode: [getShortcutKey("Alt+R")], viewMode: [getShortcutKey("Alt+R")],
hyperlink: [getShortcutKey("CtrlOrCmd+K")], hyperlink: [getShortcutKey("CtrlOrCmd+K")],
toggleLock: [getShortcutKey("CtrlOrCmd+Shift+L")],
}; };
export const getShortcutFromShortcutName = (name: ShortcutName) => { export const getShortcutFromShortcutName = (name: ShortcutName) => {
+25 -12
View File
@@ -8,6 +8,8 @@ import {
} from "../types"; } from "../types";
import { ToolButtonSize } from "../components/ToolButton"; import { ToolButtonSize } from "../components/ToolButton";
export type ActionSource = "ui" | "keyboard" | "contextMenu" | "api";
/** if false, the action should be prevented */ /** if false, the action should be prevented */
export type ActionResult = export type ActionResult =
| { | {
@@ -39,6 +41,7 @@ export type ActionName =
| "paste" | "paste"
| "copyAsPng" | "copyAsPng"
| "copyAsSvg" | "copyAsSvg"
| "copyText"
| "sendBackward" | "sendBackward"
| "bringForward" | "bringForward"
| "sendToBack" | "sendToBack"
@@ -107,7 +110,9 @@ export type ActionName =
| "decreaseFontSize" | "decreaseFontSize"
| "unbindText" | "unbindText"
| "hyperlink" | "hyperlink"
| "eraser"; | "eraser"
| "bindText"
| "toggleLock";
export type PanelComponentProps = { export type PanelComponentProps = {
elements: readonly ExcalidrawElement[]; elements: readonly ExcalidrawElement[];
@@ -138,15 +143,23 @@ export interface Action {
appState: AppState, appState: AppState,
) => boolean; ) => boolean;
checked?: (appState: Readonly<AppState>) => boolean; checked?: (appState: Readonly<AppState>) => boolean;
trackEvent?: trackEvent:
| boolean | false
| ((action: Action, type: "ui" | "keyboard" | "api", value: any) => void); | {
} category:
| "toolbar"
export interface ActionsManagerInterface { | "element"
actions: Record<ActionName, Action>; | "canvas"
registerAction: (action: Action) => void; | "export"
handleKeyDown: (event: React.KeyboardEvent | KeyboardEvent) => boolean; | "history"
renderAction: (name: ActionName) => React.ReactElement | null; | "menu"
executeAction: (action: Action) => void; | "collab"
| "hyperlink";
action?: string;
predicate?: (
appState: Readonly<AppState>,
elements: readonly ExcalidrawElement[],
value: any,
) => boolean;
};
} }
+10 -6
View File
@@ -4,15 +4,19 @@ export const trackEvent =
typeof window !== "undefined" && typeof window !== "undefined" &&
window.gtag window.gtag
? (category: string, action: string, label?: string, value?: number) => { ? (category: string, action: string, label?: string, value?: number) => {
window.gtag("event", action, { try {
event_category: category, window.gtag("event", action, {
event_label: label, event_category: category,
value, event_label: label,
}); value,
});
} catch (error) {
console.error("error logging to ga", error);
}
} }
: typeof process !== "undefined" && process.env?.JEST_WORKER_ID : typeof process !== "undefined" && process.env?.JEST_WORKER_ID
? (category: string, action: string, label?: string, value?: number) => {} ? (category: string, action: string, label?: string, value?: number) => {}
: (category: string, action: string, label?: string, value?: number) => { : (category: string, action: string, label?: string, value?: number) => {
// Uncomment the next line to track locally // Uncomment the next line to track locally
// console.info("Track Event", category, action, label, value); // console.log("Track Event", { category, action, label, value });
}; };
+11 -9
View File
@@ -41,8 +41,11 @@ export const getDefaultAppState = (): Omit<
editingElement: null, editingElement: null,
editingGroupId: null, editingGroupId: null,
editingLinearElement: null, editingLinearElement: null,
elementLocked: false, activeTool: {
elementType: "selection", type: "selection",
locked: false,
lastActiveToolBeforeEraser: null,
},
penMode: false, penMode: false,
penDetected: false, penDetected: false,
errorMessage: null, errorMessage: null,
@@ -130,10 +133,9 @@ const APP_STATE_STORAGE_CONF = (<
editingElement: { browser: false, export: false, server: false }, editingElement: { browser: false, export: false, server: false },
editingGroupId: { browser: true, export: false, server: false }, editingGroupId: { browser: true, export: false, server: false },
editingLinearElement: { browser: false, export: false, server: false }, editingLinearElement: { browser: false, export: false, server: false },
elementLocked: { browser: true, export: false, server: false }, activeTool: { browser: true, export: false, server: false },
elementType: { browser: true, export: false, server: false }, penMode: { browser: true, export: false, server: false },
penMode: { browser: false, export: false, server: false }, penDetected: { browser: true, export: false, server: false },
penDetected: { browser: false, export: false, server: false },
errorMessage: { browser: false, export: false, server: false }, errorMessage: { browser: false, export: false, server: false },
exportBackground: { browser: true, export: false, server: false }, exportBackground: { browser: true, export: false, server: false },
exportEmbedScene: { browser: true, export: false, server: false }, exportEmbedScene: { browser: true, export: false, server: false },
@@ -215,7 +217,7 @@ export const clearAppStateForDatabase = (appState: Partial<AppState>) => {
}; };
export const isEraserActive = ({ export const isEraserActive = ({
elementType, activeTool,
}: { }: {
elementType: AppState["elementType"]; activeTool: AppState["activeTool"];
}) => elementType === "eraser"; }) => activeTool.type === "eraser";
+1
View File
@@ -167,6 +167,7 @@ const commonProps = {
strokeStyle: "solid", strokeStyle: "solid",
strokeWidth: 1, strokeWidth: 1,
verticalAlign: VERTICAL_ALIGN.MIDDLE, verticalAlign: VERTICAL_ALIGN.MIDDLE,
locked: false,
} as const; } as const;
const getChartDimentions = (spreadsheet: Spreadsheet) => { const getChartDimentions = (spreadsheet: Spreadsheet) => {
+41 -15
View File
@@ -2,16 +2,16 @@ import {
ExcalidrawElement, ExcalidrawElement,
NonDeletedExcalidrawElement, NonDeletedExcalidrawElement,
} from "./element/types"; } from "./element/types";
import { getSelectedElements } from "./scene";
import { AppState, BinaryFiles } from "./types"; import { AppState, 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";
type ElementsClipboard = { type ElementsClipboard = {
type: typeof EXPORT_DATA_TYPES.excalidrawClipboard; type: typeof EXPORT_DATA_TYPES.excalidrawClipboard;
elements: ExcalidrawElement[]; elements: readonly NonDeletedExcalidrawElement[];
files: BinaryFiles | undefined; files: BinaryFiles | undefined;
}; };
@@ -56,19 +56,20 @@ const clipboardContainsElements = (
export const copyToClipboard = async ( export const copyToClipboard = async (
elements: readonly NonDeletedExcalidrawElement[], elements: readonly NonDeletedExcalidrawElement[],
appState: AppState, appState: AppState,
files: BinaryFiles, files: BinaryFiles | null,
) => { ) => {
// select binded text elements when copying // select binded text elements when copying
const selectedElements = getSelectedElements(elements, appState, true);
const contents: ElementsClipboard = { const contents: ElementsClipboard = {
type: EXPORT_DATA_TYPES.excalidrawClipboard, type: EXPORT_DATA_TYPES.excalidrawClipboard,
elements: selectedElements, elements,
files: selectedElements.reduce((acc, element) => { files: files
if (isInitializedImageElement(element) && files[element.fileId]) { ? elements.reduce((acc, element) => {
acc[element.fileId] = files[element.fileId]; if (isInitializedImageElement(element) && files[element.fileId]) {
} acc[element.fileId] = files[element.fileId];
return acc; }
}, {} as BinaryFiles), return acc;
}, {} as BinaryFiles)
: undefined,
}; };
const json = JSON.stringify(contents); const json = JSON.stringify(contents);
CLIPBOARD = json; CLIPBOARD = json;
@@ -166,10 +167,35 @@ export const parseClipboard = async (
} }
}; };
export const copyBlobToClipboardAsPng = async (blob: Blob) => { export const copyBlobToClipboardAsPng = async (blob: Blob | Promise<Blob>) => {
await navigator.clipboard.write([ let promise;
new window.ClipboardItem({ [MIME_TYPES.png]: blob }), try {
]); // in Safari so far we need to construct the ClipboardItem synchronously
// (i.e. in the same tick) otherwise browser will complain for lack of
// user intent. Using a Promise ClipboardItem constructor solves this.
// https://bugs.webkit.org/show_bug.cgi?id=222262
//
// not await so that we can detect whether the thrown error likely relates
// to a lack of support for the Promise ClipboardItem constructor
promise = navigator.clipboard.write([
new window.ClipboardItem({
[MIME_TYPES.png]: blob,
}),
]);
} catch (error: any) {
// if we're using a Promise ClipboardItem, let's try constructing
// with resolution value instead
if (isPromiseLike(blob)) {
await navigator.clipboard.write([
new window.ClipboardItem({
[MIME_TYPES.png]: await blob,
}),
]);
} else {
throw error;
}
}
await promise;
}; };
export const copyTextToSystemClipboard = async (text: string | null) => { export const copyTextToSystemClipboard = async (text: string | null) => {
+43 -22
View File
@@ -3,7 +3,7 @@ import { ActionManager } from "../actions/manager";
import { getNonDeletedElements } from "../element"; import { getNonDeletedElements } from "../element";
import { ExcalidrawElement, PointerType } from "../element/types"; import { ExcalidrawElement, PointerType } from "../element/types";
import { t } from "../i18n"; import { t } from "../i18n";
import { useIsMobile } from "../components/App"; import { useDeviceType } from "../components/App";
import { import {
canChangeSharpness, canChangeSharpness,
canHaveArrowheads, canHaveArrowheads,
@@ -19,18 +19,19 @@ import { capitalizeString, isTransparent, setCursorForShape } from "../utils";
import Stack from "./Stack"; import Stack from "./Stack";
import { ToolButton } from "./ToolButton"; import { ToolButton } from "./ToolButton";
import { hasStrokeColor } from "../scene/comparisons"; import { hasStrokeColor } from "../scene/comparisons";
import { trackEvent } from "../analytics";
import { hasBoundTextElement, isBoundToContainer } from "../element/typeChecks"; import { hasBoundTextElement, isBoundToContainer } from "../element/typeChecks";
export const SelectedShapeActions = ({ export const SelectedShapeActions = ({
appState, appState,
elements, elements,
renderAction, renderAction,
elementType, activeTool,
}: { }: {
appState: AppState; appState: AppState;
elements: readonly ExcalidrawElement[]; elements: readonly ExcalidrawElement[];
renderAction: ActionManager["renderAction"]; renderAction: ActionManager["renderAction"];
elementType: AppState["elementType"]; activeTool: AppState["activeTool"]["type"];
}) => { }) => {
const targetElements = getTargetElements( const targetElements = getTargetElements(
getNonDeletedElements(elements), getNonDeletedElements(elements),
@@ -46,19 +47,22 @@ export const SelectedShapeActions = ({
isSingleElementBoundContainer = true; isSingleElementBoundContainer = true;
} }
const isEditing = Boolean(appState.editingElement); const isEditing = Boolean(appState.editingElement);
const isMobile = useIsMobile(); const deviceType = useDeviceType();
const isRTL = document.documentElement.getAttribute("dir") === "rtl"; const isRTL = document.documentElement.getAttribute("dir") === "rtl";
const showFillIcons = const showFillIcons =
hasBackground(elementType) || hasBackground(activeTool) ||
targetElements.some( targetElements.some(
(element) => (element) =>
hasBackground(element.type) && !isTransparent(element.backgroundColor), hasBackground(element.type) && !isTransparent(element.backgroundColor),
); );
const showChangeBackgroundIcons = const showChangeBackgroundIcons =
hasBackground(elementType) || hasBackground(activeTool) ||
targetElements.some((element) => hasBackground(element.type)); targetElements.some((element) => hasBackground(element.type));
const showLinkIcon =
targetElements.length === 1 || isSingleElementBoundContainer;
let commonSelectedType: string | null = targetElements[0]?.type || null; let commonSelectedType: string | null = targetElements[0]?.type || null;
for (const element of targetElements) { for (const element of targetElements) {
@@ -70,23 +74,23 @@ export const SelectedShapeActions = ({
return ( return (
<div className="panelColumn"> <div className="panelColumn">
{((hasStrokeColor(elementType) && {((hasStrokeColor(activeTool) &&
elementType !== "image" && activeTool !== "image" &&
commonSelectedType !== "image") || commonSelectedType !== "image") ||
targetElements.some((element) => hasStrokeColor(element.type))) && targetElements.some((element) => hasStrokeColor(element.type))) &&
renderAction("changeStrokeColor")} renderAction("changeStrokeColor")}
{showChangeBackgroundIcons && renderAction("changeBackgroundColor")} {showChangeBackgroundIcons && renderAction("changeBackgroundColor")}
{showFillIcons && renderAction("changeFillStyle")} {showFillIcons && renderAction("changeFillStyle")}
{(hasStrokeWidth(elementType) || {(hasStrokeWidth(activeTool) ||
targetElements.some((element) => hasStrokeWidth(element.type))) && targetElements.some((element) => hasStrokeWidth(element.type))) &&
renderAction("changeStrokeWidth")} renderAction("changeStrokeWidth")}
{(elementType === "freedraw" || {(activeTool === "freedraw" ||
targetElements.some((element) => element.type === "freedraw")) && targetElements.some((element) => element.type === "freedraw")) &&
renderAction("changeStrokeShape")} renderAction("changeStrokeShape")}
{(hasStrokeStyle(elementType) || {(hasStrokeStyle(activeTool) ||
targetElements.some((element) => hasStrokeStyle(element.type))) && ( targetElements.some((element) => hasStrokeStyle(element.type))) && (
<> <>
{renderAction("changeStrokeStyle")} {renderAction("changeStrokeStyle")}
@@ -94,12 +98,12 @@ export const SelectedShapeActions = ({
</> </>
)} )}
{(canChangeSharpness(elementType) || {(canChangeSharpness(activeTool) ||
targetElements.some((element) => canChangeSharpness(element.type))) && ( targetElements.some((element) => canChangeSharpness(element.type))) && (
<>{renderAction("changeSharpness")}</> <>{renderAction("changeSharpness")}</>
)} )}
{(hasText(elementType) || {(hasText(activeTool) ||
targetElements.some((element) => hasText(element.type))) && ( targetElements.some((element) => hasText(element.type))) && (
<> <>
{renderAction("changeFontSize")} {renderAction("changeFontSize")}
@@ -114,7 +118,7 @@ export const SelectedShapeActions = ({
(element) => (element) =>
hasBoundTextElement(element) || isBoundToContainer(element), hasBoundTextElement(element) || isBoundToContainer(element),
) && renderAction("changeVerticalAlign")} ) && renderAction("changeVerticalAlign")}
{(canHaveArrowheads(elementType) || {(canHaveArrowheads(activeTool) ||
targetElements.some((element) => canHaveArrowheads(element.type))) && ( targetElements.some((element) => canHaveArrowheads(element.type))) && (
<>{renderAction("changeArrowhead")}</> <>{renderAction("changeArrowhead")}</>
)} )}
@@ -168,11 +172,11 @@ export const SelectedShapeActions = ({
<fieldset> <fieldset>
<legend>{t("labels.actions")}</legend> <legend>{t("labels.actions")}</legend>
<div className="buttonList"> <div className="buttonList">
{!isMobile && renderAction("duplicateSelection")} {!deviceType.isMobile && renderAction("duplicateSelection")}
{!isMobile && renderAction("deleteSelectedElements")} {!deviceType.isMobile && renderAction("deleteSelectedElements")}
{renderAction("group")} {renderAction("group")}
{renderAction("ungroup")} {renderAction("ungroup")}
{targetElements.length === 1 && renderAction("hyperlink")} {showLinkIcon && renderAction("hyperlink")}
</div> </div>
</fieldset> </fieldset>
)} )}
@@ -182,14 +186,16 @@ export const SelectedShapeActions = ({
export const ShapesSwitcher = ({ export const ShapesSwitcher = ({
canvas, canvas,
elementType, activeTool,
setAppState, setAppState,
onImageAction, onImageAction,
appState,
}: { }: {
canvas: HTMLCanvasElement | null; canvas: HTMLCanvasElement | null;
elementType: AppState["elementType"]; activeTool: AppState["activeTool"];
setAppState: React.Component<any, AppState>["setState"]; setAppState: React.Component<any, AppState>["setState"];
onImageAction: (data: { pointerType: PointerType | null }) => void; onImageAction: (data: { pointerType: PointerType | null }) => void;
appState: AppState;
}) => ( }) => (
<> <>
{SHAPES.map(({ value, icon, key }, index) => { {SHAPES.map(({ value, icon, key }, index) => {
@@ -204,20 +210,35 @@ export const ShapesSwitcher = ({
key={value} key={value}
type="radio" type="radio"
icon={icon} icon={icon}
checked={elementType === value} checked={activeTool.type === value}
name="editor-current-shape" name="editor-current-shape"
title={`${capitalizeString(label)}${shortcut}`} title={`${capitalizeString(label)}${shortcut}`}
keyBindingLabel={`${index + 1}`} keyBindingLabel={`${index + 1}`}
aria-label={capitalizeString(label)} aria-label={capitalizeString(label)}
aria-keyshortcuts={shortcut} aria-keyshortcuts={shortcut}
data-testid={value} data-testid={value}
onPointerDown={({ pointerType }) => {
if (!appState.penDetected && pointerType === "pen") {
setAppState({
penDetected: true,
penMode: true,
});
}
}}
onChange={({ pointerType }) => { onChange={({ pointerType }) => {
if (appState.activeTool.type !== value) {
trackEvent("toolbar", value, "ui");
}
const nextActiveTool = { ...activeTool, type: value };
setAppState({ setAppState({
elementType: value, activeTool: nextActiveTool,
multiElement: null, multiElement: null,
selectedElementIds: {}, selectedElementIds: {},
}); });
setCursorForShape(canvas, value); setCursorForShape(canvas, {
...appState,
activeTool: nextActiveTool,
});
if (value === "image") { if (value === "image") {
onImageAction({ pointerType }); onImageAction({ pointerType });
} }
+509 -216
View File
File diff suppressed because it is too large Load Diff
+6
View File
@@ -12,5 +12,11 @@
cursor: pointer; cursor: pointer;
font-size: 0.8rem; font-size: 0.8rem;
font-weight: 500; font-weight: 500;
&-img {
width: 100%;
height: 100%;
border-radius: 100%;
}
} }
} }
+18 -10
View File
@@ -1,20 +1,28 @@
import "./Avatar.scss"; import "./Avatar.scss";
import React from "react"; import React from "react";
import { getClientInitials } from "../clients";
type AvatarProps = { type AvatarProps = {
children: string;
onClick: (e: React.MouseEvent<HTMLDivElement, MouseEvent>) => void; onClick: (e: React.MouseEvent<HTMLDivElement, MouseEvent>) => void;
color: string; color: string;
border: string; border: string;
name: string;
src?: string;
}; };
export const Avatar = ({ children, color, border, onClick }: AvatarProps) => ( export const Avatar = ({ color, border, onClick, name, src }: AvatarProps) => {
<div const shortName = getClientInitials(name);
className="Avatar" const style = src
style={{ background: color, border: `1px solid ${border}` }} ? undefined
onClick={onClick} : { background: color, border: `1px solid ${border}` };
> return (
{children} <div className="Avatar" style={style} onClick={onClick}>
</div> {src ? (
); <img className="Avatar-img" src={src} alt={shortName} />
) : (
shortName
)}
</div>
);
};
+2 -2
View File
@@ -1,6 +1,6 @@
import { useState } from "react"; import { useState } from "react";
import { t } from "../i18n"; import { t } from "../i18n";
import { useIsMobile } from "./App"; import { useDeviceType } from "./App";
import { trash } from "./icons"; import { trash } from "./icons";
import { ToolButton } from "./ToolButton"; import { ToolButton } from "./ToolButton";
@@ -19,7 +19,7 @@ const ClearCanvas = ({ onConfirm }: { onConfirm: () => void }) => {
icon={trash} icon={trash}
title={t("buttons.clearReset")} title={t("buttons.clearReset")}
aria-label={t("buttons.clearReset")} aria-label={t("buttons.clearReset")}
showAriaLabel={useIsMobile()} showAriaLabel={useDeviceType().isMobile}
onClick={toggleDialog} onClick={toggleDialog}
data-testid="clear-canvas-button" data-testid="clear-canvas-button"
/> />
+2 -2
View File
@@ -1,7 +1,7 @@
import clsx from "clsx"; import clsx from "clsx";
import { ToolButton } from "./ToolButton"; import { ToolButton } from "./ToolButton";
import { t } from "../i18n"; import { t } from "../i18n";
import { useIsMobile } from "../components/App"; import { useDeviceType } from "../components/App";
import { users } from "./icons"; import { users } from "./icons";
import "./CollabButton.scss"; import "./CollabButton.scss";
@@ -26,7 +26,7 @@ const CollabButton = ({
type="button" type="button"
title={t("labels.liveCollaboration")} title={t("labels.liveCollaboration")}
aria-label={t("labels.liveCollaboration")} aria-label={t("labels.liveCollaboration")}
showAriaLabel={useIsMobile()} showAriaLabel={useDeviceType().isMobile}
> >
{collaboratorCount > 0 && ( {collaboratorCount > 0 && (
<div className="CollabButton-collaborators">{collaboratorCount}</div> <div className="CollabButton-collaborators">{collaboratorCount}</div>
+3 -1
View File
@@ -70,7 +70,9 @@ const ContextMenu = ({
dangerous: actionName === "deleteSelectedElements", dangerous: actionName === "deleteSelectedElements",
checkmark: option.checked?.(appState), checkmark: option.checked?.(appState),
})} })}
onClick={() => actionManager.executeAction(option)} onClick={() =>
actionManager.executeAction(option, "contextMenu")
}
> >
<div className="context-menu-option__label">{label}</div> <div className="context-menu-option__label">{label}</div>
<kbd className="context-menu-option__shortcut"> <kbd className="context-menu-option__shortcut">
+2 -2
View File
@@ -2,7 +2,7 @@ import clsx from "clsx";
import React, { useEffect, useState } from "react"; import React, { useEffect, useState } from "react";
import { useCallbackRefState } from "../hooks/useCallbackRefState"; import { useCallbackRefState } from "../hooks/useCallbackRefState";
import { t } from "../i18n"; import { t } from "../i18n";
import { useExcalidrawContainer, useIsMobile } from "../components/App"; import { useExcalidrawContainer, useDeviceType } from "../components/App";
import { KEYS } from "../keys"; import { KEYS } from "../keys";
import "./Dialog.scss"; import "./Dialog.scss";
import { back, close } from "./icons"; import { back, close } from "./icons";
@@ -94,7 +94,7 @@ export const Dialog = (props: DialogProps) => {
onClick={onClose} onClick={onClose}
aria-label={t("buttons.close")} aria-label={t("buttons.close")}
> >
{useIsMobile() ? back : close} {useDeviceType().isMobile ? back : close}
</button> </button>
</h2> </h2>
<div className="Dialog__content">{props.children}</div> <div className="Dialog__content">{props.children}</div>
+10 -2
View File
@@ -139,7 +139,7 @@ export const HelpDialog = ({ onClose }: { onClose?: () => void }) => {
<Section title={t("helpDialog.shortcuts")}> <Section title={t("helpDialog.shortcuts")}>
<Columns> <Columns>
<Column> <Column>
<ShortcutIsland caption={t("helpDialog.shapes")}> <ShortcutIsland caption={t("helpDialog.tools")}>
<Shortcut <Shortcut
label={t("toolBar.selection")} label={t("toolBar.selection")}
shortcuts={["V", "1"]} shortcuts={["V", "1"]}
@@ -149,7 +149,7 @@ export const HelpDialog = ({ onClose }: { onClose?: () => void }) => {
shortcuts={["R", "2"]} shortcuts={["R", "2"]}
/> />
<Shortcut label={t("toolBar.diamond")} shortcuts={["D", "3"]} /> <Shortcut label={t("toolBar.diamond")} shortcuts={["D", "3"]} />
<Shortcut label={t("toolBar.ellipse")} shortcuts={["E", "4"]} /> <Shortcut label={t("toolBar.ellipse")} shortcuts={["O", "4"]} />
<Shortcut label={t("toolBar.arrow")} shortcuts={["A", "5"]} /> <Shortcut label={t("toolBar.arrow")} shortcuts={["A", "5"]} />
<Shortcut label={t("toolBar.line")} shortcuts={["P", "6"]} /> <Shortcut label={t("toolBar.line")} shortcuts={["P", "6"]} />
<Shortcut <Shortcut
@@ -159,6 +159,10 @@ export const HelpDialog = ({ onClose }: { onClose?: () => void }) => {
<Shortcut label={t("toolBar.text")} shortcuts={["T", "8"]} /> <Shortcut label={t("toolBar.text")} shortcuts={["T", "8"]} />
<Shortcut label={t("toolBar.image")} shortcuts={["9"]} /> <Shortcut label={t("toolBar.image")} shortcuts={["9"]} />
<Shortcut label={t("toolBar.library")} shortcuts={["0"]} /> <Shortcut label={t("toolBar.library")} shortcuts={["0"]} />
<Shortcut
label={t("toolBar.eraser")}
shortcuts={[getShortcutKey("E")]}
/>
<Shortcut <Shortcut
label={t("helpDialog.editSelectedShape")} label={t("helpDialog.editSelectedShape")}
shortcuts={[ shortcuts={[
@@ -359,6 +363,10 @@ export const HelpDialog = ({ onClose }: { onClose?: () => void }) => {
getShortcutKey(`Alt+${t("helpDialog.drag")}`), getShortcutKey(`Alt+${t("helpDialog.drag")}`),
]} ]}
/> />
<Shortcut
label={t("helpDialog.toggleElementLock")}
shortcuts={[getShortcutKey("CtrlOrCmd+Shift+L")]}
/>
<Shortcut <Shortcut
label={t("buttons.undo")} label={t("buttons.undo")}
shortcuts={[getShortcutKey("CtrlOrCmd+Z")]} shortcuts={[getShortcutKey("CtrlOrCmd+Z")]}
+10 -6
View File
@@ -20,28 +20,32 @@ interface HintViewerProps {
} }
const getHints = ({ appState, elements, isMobile }: HintViewerProps) => { const getHints = ({ appState, elements, isMobile }: HintViewerProps) => {
const { elementType, isResizing, isRotating, lastPointerDownWith } = appState; const { activeTool, isResizing, isRotating, lastPointerDownWith } = appState;
const multiMode = appState.multiElement !== null; const multiMode = appState.multiElement !== null;
if (appState.isLibraryOpen) {
return null;
}
if (isEraserActive(appState)) { if (isEraserActive(appState)) {
return t("hints.eraserRevert"); return t("hints.eraserRevert");
} }
if (elementType === "arrow" || elementType === "line") { if (activeTool.type === "arrow" || activeTool.type === "line") {
if (!multiMode) { if (!multiMode) {
return t("hints.linearElement"); return t("hints.linearElement");
} }
return t("hints.linearElementMulti"); return t("hints.linearElementMulti");
} }
if (elementType === "freedraw") { if (activeTool.type === "freedraw") {
return t("hints.freeDraw"); return t("hints.freeDraw");
} }
if (elementType === "text") { if (activeTool.type === "text") {
return t("hints.text"); return t("hints.text");
} }
if (appState.elementType === "image" && appState.pendingImageElement) { if (appState.activeTool.type === "image" && appState.pendingImageElement) {
return t("hints.placeImage"); return t("hints.placeImage");
} }
@@ -73,7 +77,7 @@ const getHints = ({ appState, elements, isMobile }: HintViewerProps) => {
return t("hints.text_editing"); return t("hints.text_editing");
} }
if (elementType === "selection") { if (activeTool.type === "selection") {
if ( if (
appState.draggingElement?.type === "selection" && appState.draggingElement?.type === "selection" &&
!appState.editingElement && !appState.editingElement &&
+5 -5
View File
@@ -1,12 +1,11 @@
import React, { useEffect, useRef, useState } from "react"; import React, { useEffect, useRef, useState } from "react";
import { render, unmountComponentAtNode } from "react-dom"; import { render, unmountComponentAtNode } from "react-dom";
import { ActionsManagerInterface } from "../actions/types";
import { probablySupportsClipboardBlob } from "../clipboard"; import { probablySupportsClipboardBlob } from "../clipboard";
import { canvasToBlob } from "../data/blob"; import { canvasToBlob } from "../data/blob";
import { NonDeletedExcalidrawElement } from "../element/types"; import { NonDeletedExcalidrawElement } from "../element/types";
import { CanvasError } from "../errors"; import { CanvasError } from "../errors";
import { t } from "../i18n"; import { t } from "../i18n";
import { useIsMobile } from "./App"; import { useDeviceType } from "./App";
import { getSelectedElements, isSomeElementSelected } from "../scene"; import { getSelectedElements, isSomeElementSelected } from "../scene";
import { exportToCanvas } from "../scene/export"; import { exportToCanvas } from "../scene/export";
import { AppState, BinaryFiles } from "../types"; import { AppState, BinaryFiles } from "../types";
@@ -19,6 +18,7 @@ import OpenColor from "open-color";
import { CheckboxItem } from "./CheckboxItem"; import { CheckboxItem } from "./CheckboxItem";
import { DEFAULT_EXPORT_PADDING } from "../constants"; import { DEFAULT_EXPORT_PADDING } from "../constants";
import { nativeFileSystemSupported } from "../data/filesystem"; import { nativeFileSystemSupported } from "../data/filesystem";
import { ActionManager } from "../actions/manager";
const supportsContextFilters = const supportsContextFilters =
"filter" in document.createElement("canvas").getContext("2d")!; "filter" in document.createElement("canvas").getContext("2d")!;
@@ -90,7 +90,7 @@ const ImageExportModal = ({
elements: readonly NonDeletedExcalidrawElement[]; elements: readonly NonDeletedExcalidrawElement[];
files: BinaryFiles; files: BinaryFiles;
exportPadding?: number; exportPadding?: number;
actionManager: ActionsManagerInterface; actionManager: ActionManager;
onExportToPng: ExportCB; onExportToPng: ExportCB;
onExportToSvg: ExportCB; onExportToSvg: ExportCB;
onExportToClipboard: ExportCB; onExportToClipboard: ExportCB;
@@ -229,7 +229,7 @@ export const ImageExportDialog = ({
elements: readonly NonDeletedExcalidrawElement[]; elements: readonly NonDeletedExcalidrawElement[];
files: BinaryFiles; files: BinaryFiles;
exportPadding?: number; exportPadding?: number;
actionManager: ActionsManagerInterface; actionManager: ActionManager;
onExportToPng: ExportCB; onExportToPng: ExportCB;
onExportToSvg: ExportCB; onExportToSvg: ExportCB;
onExportToClipboard: ExportCB; onExportToClipboard: ExportCB;
@@ -250,7 +250,7 @@ export const ImageExportDialog = ({
icon={exportImage} icon={exportImage}
type="button" type="button"
aria-label={t("buttons.exportImage")} aria-label={t("buttons.exportImage")}
showAriaLabel={useIsMobile()} showAriaLabel={useDeviceType().isMobile}
title={t("buttons.exportImage")} title={t("buttons.exportImage")}
/> />
{modalIsShown && ( {modalIsShown && (
+12 -9
View File
@@ -1,8 +1,7 @@
import React, { useState } from "react"; import React, { useState } from "react";
import { ActionsManagerInterface } from "../actions/types";
import { NonDeletedExcalidrawElement } from "../element/types"; import { NonDeletedExcalidrawElement } from "../element/types";
import { t } from "../i18n"; import { t } from "../i18n";
import { useIsMobile } from "./App"; import { useDeviceType } from "./App";
import { AppState, ExportOpts, BinaryFiles } from "../types"; import { AppState, ExportOpts, BinaryFiles } from "../types";
import { Dialog } from "./Dialog"; import { Dialog } from "./Dialog";
import { exportFile, exportToFileIcon, link } from "./icons"; import { exportFile, exportToFileIcon, link } from "./icons";
@@ -12,6 +11,9 @@ import { Card } from "./Card";
import "./ExportDialog.scss"; import "./ExportDialog.scss";
import { nativeFileSystemSupported } from "../data/filesystem"; import { nativeFileSystemSupported } from "../data/filesystem";
import { trackEvent } from "../analytics";
import { ActionManager } from "../actions/manager";
import { getFrame } from "../utils";
export type ExportCB = ( export type ExportCB = (
elements: readonly NonDeletedExcalidrawElement[], elements: readonly NonDeletedExcalidrawElement[],
@@ -29,7 +31,7 @@ const JSONExportModal = ({
appState: AppState; appState: AppState;
files: BinaryFiles; files: BinaryFiles;
elements: readonly NonDeletedExcalidrawElement[]; elements: readonly NonDeletedExcalidrawElement[];
actionManager: ActionsManagerInterface; actionManager: ActionManager;
onCloseRequest: () => void; onCloseRequest: () => void;
exportOpts: ExportOpts; exportOpts: ExportOpts;
canvas: HTMLCanvasElement | null; canvas: HTMLCanvasElement | null;
@@ -54,7 +56,7 @@ const JSONExportModal = ({
aria-label={t("exportDialog.disk_button")} aria-label={t("exportDialog.disk_button")}
showAriaLabel={true} showAriaLabel={true}
onClick={() => { onClick={() => {
actionManager.executeAction(actionSaveFileToDisk); actionManager.executeAction(actionSaveFileToDisk, "ui");
}} }}
/> />
</Card> </Card>
@@ -70,9 +72,10 @@ const JSONExportModal = ({
title={t("exportDialog.link_button")} title={t("exportDialog.link_button")}
aria-label={t("exportDialog.link_button")} aria-label={t("exportDialog.link_button")}
showAriaLabel={true} showAriaLabel={true}
onClick={() => onClick={() => {
onExportToBackend(elements, appState, files, canvas) onExportToBackend(elements, appState, files, canvas);
} trackEvent("export", "link", `ui (${getFrame()})`);
}}
/> />
</Card> </Card>
)} )}
@@ -94,7 +97,7 @@ export const JSONExportDialog = ({
elements: readonly NonDeletedExcalidrawElement[]; elements: readonly NonDeletedExcalidrawElement[];
appState: AppState; appState: AppState;
files: BinaryFiles; files: BinaryFiles;
actionManager: ActionsManagerInterface; actionManager: ActionManager;
exportOpts: ExportOpts; exportOpts: ExportOpts;
canvas: HTMLCanvasElement | null; canvas: HTMLCanvasElement | null;
}) => { }) => {
@@ -114,7 +117,7 @@ export const JSONExportDialog = ({
icon={exportFile} icon={exportFile}
type="button" type="button"
aria-label={t("buttons.export")} aria-label={t("buttons.export")}
showAriaLabel={useIsMobile()} showAriaLabel={useDeviceType().isMobile}
title={t("buttons.export")} title={t("buttons.export")}
/> />
{modalIsShown && ( {modalIsShown && (
+50 -28
View File
@@ -6,7 +6,6 @@ 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 { useIsMobile } from "../components/App";
import { calculateScrollCenter, getSelectedElements } from "../scene"; import { calculateScrollCenter, getSelectedElements } from "../scene";
import { ExportType } from "../scene/types"; import { ExportType } from "../scene/types";
import { AppProps, AppState, ExcalidrawProps, BinaryFiles } from "../types"; import { AppProps, AppState, ExcalidrawProps, BinaryFiles } from "../types";
@@ -37,6 +36,8 @@ import { LibraryMenu } from "./LibraryMenu";
import "./LayerUI.scss"; import "./LayerUI.scss";
import "./Toolbar.scss"; import "./Toolbar.scss";
import { PenModeButton } from "./PenModeButton"; import { PenModeButton } from "./PenModeButton";
import { trackEvent } from "../analytics";
import { useDeviceType } from "../components/App";
interface LayerUIProps { interface LayerUIProps {
actionManager: ActionManager; actionManager: ActionManager;
@@ -67,6 +68,7 @@ interface LayerUIProps {
library: Library; library: Library;
id: string; id: string;
onImageAction: (data: { insertOnCanvasDirectly: boolean }) => void; onImageAction: (data: { insertOnCanvasDirectly: boolean }) => void;
renderCustomElementWidget?: (appState: AppState) => void;
} }
const LayerUI = ({ const LayerUI = ({
@@ -94,8 +96,9 @@ const LayerUI = ({
library, library,
id, id,
onImageAction, onImageAction,
renderCustomElementWidget,
}: LayerUIProps) => { }: LayerUIProps) => {
const isMobile = useIsMobile(); const deviceType = useDeviceType();
const renderJSONExportDialog = () => { const renderJSONExportDialog = () => {
if (!UIOptions.canvasActions.export) { if (!UIOptions.canvasActions.export) {
@@ -122,6 +125,7 @@ const LayerUI = ({
const createExporter = const createExporter =
(type: ExportType): ExportCB => (type: ExportType): ExportCB =>
async (exportedElements) => { async (exportedElements) => {
trackEvent("export", type, "ui");
const fileHandle = await exportCanvas( const fileHandle = await exportCanvas(
type, type,
exportedElements, exportedElements,
@@ -248,7 +252,7 @@ const LayerUI = ({
appState={appState} appState={appState}
elements={elements} elements={elements}
renderAction={actionManager.renderAction} renderAction={actionManager.renderAction}
elementType={appState.elementType} activeTool={appState.activeTool.type}
/> />
</Island> </Island>
</Section> </Section>
@@ -325,8 +329,8 @@ const LayerUI = ({
/> />
<LockButton <LockButton
zenModeEnabled={zenModeEnabled} zenModeEnabled={zenModeEnabled}
checked={appState.elementLocked} checked={appState.activeTool.locked}
onChange={onLockToggle} onChange={() => onLockToggle()}
title={t("toolBar.lock")} title={t("toolBar.lock")}
/> />
<Island <Island
@@ -338,13 +342,14 @@ const LayerUI = ({
<HintViewer <HintViewer
appState={appState} appState={appState}
elements={elements} elements={elements}
isMobile={isMobile} isMobile={deviceType.isMobile}
/> />
{heading} {heading}
<Stack.Row gap={1}> <Stack.Row gap={1}>
<ShapesSwitcher <ShapesSwitcher
appState={appState}
canvas={canvas} canvas={canvas}
elementType={appState.elementType} activeTool={appState.activeTool}
setAppState={setAppState} setAppState={setAppState}
onImageAction={({ pointerType }) => { onImageAction={({ pointerType }) => {
onImageAction({ onImageAction({
@@ -388,7 +393,7 @@ const LayerUI = ({
</Tooltip> </Tooltip>
))} ))}
</UserList> </UserList>
{renderTopRightUI?.(isMobile, appState)} {renderTopRightUI?.(deviceType.isMobile, appState)}
</div> </div>
</div> </div>
</FixedSideContainer> </FixedSideContainer>
@@ -418,24 +423,41 @@ const LayerUI = ({
/> />
</Island> </Island>
{!viewModeEnabled && ( {!viewModeEnabled && (
<div <>
className={clsx("undo-redo-buttons zen-mode-transition", { <div
"layer-ui__wrapper__footer-left--transition-bottom": className={clsx("undo-redo-buttons zen-mode-transition", {
zenModeEnabled, "layer-ui__wrapper__footer-left--transition-bottom":
})} zenModeEnabled,
> })}
{actionManager.renderAction("undo", { size: "small" })} >
{actionManager.renderAction("redo", { size: "small" })} {actionManager.renderAction("undo", { size: "small" })}
</div> {actionManager.renderAction("redo", { size: "small" })}
</div>
<div
className={clsx("eraser-buttons zen-mode-transition", {
"layer-ui__wrapper__footer-left--transition-left":
zenModeEnabled,
})}
>
{actionManager.renderAction("eraser", { size: "small" })}
{renderCustomElementWidget &&
renderCustomElementWidget(appState)}
</div>
</>
)} )}
<div {!viewModeEnabled &&
className={clsx("eraser-buttons zen-mode-transition", { appState.multiElement &&
"layer-ui__wrapper__footer-left--transition-left": deviceType.isTouchScreen && (
zenModeEnabled, <div
})} className={clsx("finalize-button zen-mode-transition", {
> "layer-ui__wrapper__footer-left--transition-left":
{actionManager.renderAction("eraser", { size: "small" })} zenModeEnabled,
</div> })}
>
{actionManager.renderAction("finalize", { size: "small" })}
</div>
)}
</Section> </Section>
</Stack.Col> </Stack.Col>
</div> </div>
@@ -474,7 +496,7 @@ const LayerUI = ({
const dialogs = ( const dialogs = (
<> <>
{appState.isLoading && <LoadingMessage />} {appState.isLoading && <LoadingMessage delay={250} />}
{appState.errorMessage && ( {appState.errorMessage && (
<ErrorDialog <ErrorDialog
message={appState.errorMessage} message={appState.errorMessage}
@@ -503,7 +525,7 @@ const LayerUI = ({
</> </>
); );
return isMobile ? ( return deviceType.isMobile ? (
<> <>
{dialogs} {dialogs}
<MobileMenu <MobileMenu
@@ -515,7 +537,7 @@ const LayerUI = ({
renderImageExportDialog={renderImageExportDialog} renderImageExportDialog={renderImageExportDialog}
setAppState={setAppState} setAppState={setAppState}
onCollabButtonClick={onCollabButtonClick} onCollabButtonClick={onCollabButtonClick}
onLockToggle={onLockToggle} onLockToggle={() => onLockToggle()}
onPenModeToggle={onPenModeToggle} onPenModeToggle={onPenModeToggle}
canvas={canvas} canvas={canvas}
isCollaborating={isCollaborating} isCollaborating={isCollaborating}
+15 -2
View File
@@ -13,6 +13,10 @@
width: 100%; width: 100%;
margin: 2px 0; margin: 2px 0;
.Spinner {
margin-right: 1rem;
}
button { button {
// 2px from the left to account for focus border of left-most button // 2px from the left to account for focus border of left-most button
margin: 0 2px; margin: 0 2px;
@@ -28,8 +32,17 @@
} }
.layer-ui__library-message { .layer-ui__library-message {
padding: 10px 20px; padding: 2em 4em;
max-width: 200px; min-width: 200px;
display: flex;
flex-direction: column;
align-items: center;
.Spinner {
margin-bottom: 1em;
}
span {
font-size: 0.8em;
}
} }
.publish-library-success { .publish-library-success {
+127 -120
View File
@@ -1,5 +1,12 @@
import { useRef, useState, useEffect, useCallback, RefObject } from "react"; import {
import Library from "../data/library"; useRef,
useState,
useEffect,
useCallback,
RefObject,
forwardRef,
} from "react";
import Library, { libraryItemsAtom } from "../data/library";
import { t } from "../i18n"; import { t } from "../i18n";
import { randomId } from "../random"; import { randomId } from "../random";
import { import {
@@ -19,6 +26,10 @@ import LibraryMenuItems from "./LibraryMenuItems";
import { EVENT } from "../constants"; import { EVENT } from "../constants";
import { KEYS } from "../keys"; import { KEYS } from "../keys";
import { arrayToMap } from "../utils"; import { arrayToMap } from "../utils";
import { trackEvent } from "../analytics";
import { useAtom } from "jotai";
import { jotaiScope } from "../jotai";
import Spinner from "./Spinner";
const useOnClickOutside = ( const useOnClickOutside = (
ref: RefObject<HTMLElement>, ref: RefObject<HTMLElement>,
@@ -53,6 +64,17 @@ const getSelectedItems = (
selectedItems: LibraryItem["id"][], selectedItems: LibraryItem["id"][],
) => libraryItems.filter((item) => selectedItems.includes(item.id)); ) => libraryItems.filter((item) => selectedItems.includes(item.id));
const LibraryMenuWrapper = forwardRef<
HTMLDivElement,
{ children: React.ReactNode }
>(({ children }, ref) => {
return (
<Island padding={1} ref={ref} className="layer-ui__library">
{children}
</Island>
);
});
export const LibraryMenu = ({ export const LibraryMenu = ({
onClose, onClose,
onInsertShape, onInsertShape,
@@ -102,11 +124,6 @@ export const LibraryMenu = ({
}; };
}, [onClose]); }, [onClose]);
const [libraryItems, setLibraryItems] = useState<LibraryItems>([]);
const [loadingState, setIsLoading] = useState<
"preloading" | "loading" | "ready"
>("preloading");
const [selectedItems, setSelectedItems] = useState<LibraryItem["id"][]>([]); const [selectedItems, setSelectedItems] = useState<LibraryItem["id"][]>([]);
const [showPublishLibraryDialog, setShowPublishLibraryDialog] = const [showPublishLibraryDialog, setShowPublishLibraryDialog] =
useState(false); useState(false);
@@ -114,55 +131,35 @@ export const LibraryMenu = ({
url: string; url: string;
authorName: string; authorName: string;
}>(null); }>(null);
const loadingTimerRef = useRef<number | null>(null);
useEffect(() => { const [libraryItemsData] = useAtom(libraryItemsAtom, jotaiScope);
Promise.race([
new Promise((resolve) => {
loadingTimerRef.current = window.setTimeout(() => {
resolve("loading");
}, 100);
}),
library.loadLibrary().then((items) => {
setLibraryItems(items);
setIsLoading("ready");
}),
]).then((data) => {
if (data === "loading") {
setIsLoading("loading");
}
});
return () => {
clearTimeout(loadingTimerRef.current!);
};
}, [library]);
const removeFromLibrary = useCallback(async () => { const removeFromLibrary = useCallback(
const items = await library.loadLibrary(); async (libraryItems: LibraryItems) => {
const nextItems = libraryItems.filter(
const nextItems = items.filter((item) => !selectedItems.includes(item.id)); (item) => !selectedItems.includes(item.id),
library.saveLibrary(nextItems).catch((error) => { );
setLibraryItems(items); library.setLibrary(nextItems).catch(() => {
setAppState({ errorMessage: t("alerts.errorRemovingFromLibrary") }); setAppState({ errorMessage: t("alerts.errorRemovingFromLibrary") });
}); });
setSelectedItems([]); setSelectedItems([]);
setLibraryItems(nextItems); },
}, [library, setAppState, selectedItems, setSelectedItems]); [library, setAppState, selectedItems, setSelectedItems],
);
const resetLibrary = useCallback(() => { const resetLibrary = useCallback(() => {
library.resetLibrary(); library.resetLibrary();
setLibraryItems([]);
focusContainer(); focusContainer();
}, [library, focusContainer]); }, [library, focusContainer]);
const addToLibrary = useCallback( const addToLibrary = useCallback(
async (elements: LibraryItem["elements"]) => { async (elements: LibraryItem["elements"], libraryItems: LibraryItems) => {
trackEvent("element", "addToLibrary", "ui");
if (elements.some((element) => element.type === "image")) { if (elements.some((element) => element.type === "image")) {
return setAppState({ return setAppState({
errorMessage: "Support for adding images to the library coming soon!", errorMessage: "Support for adding images to the library coming soon!",
}); });
} }
const items = await library.loadLibrary();
const nextItems: LibraryItems = [ const nextItems: LibraryItems = [
{ {
status: "unpublished", status: "unpublished",
@@ -170,14 +167,12 @@ export const LibraryMenu = ({
id: randomId(), id: randomId(),
created: Date.now(), created: Date.now(),
}, },
...items, ...libraryItems,
]; ];
onAddToLibrary(); onAddToLibrary();
library.saveLibrary(nextItems).catch((error) => { library.setLibrary(nextItems).catch(() => {
setLibraryItems(items);
setAppState({ errorMessage: t("alerts.errorAddingToLibrary") }); setAppState({ errorMessage: t("alerts.errorAddingToLibrary") });
}); });
setLibraryItems(nextItems);
}, },
[onAddToLibrary, library, setAppState], [onAddToLibrary, library, setAppState],
); );
@@ -216,7 +211,7 @@ export const LibraryMenu = ({
}, [setPublishLibSuccess, publishLibSuccess]); }, [setPublishLibSuccess, publishLibSuccess]);
const onPublishLibSuccess = useCallback( const onPublishLibSuccess = useCallback(
(data) => { (data, libraryItems: LibraryItems) => {
setShowPublishLibraryDialog(false); setShowPublishLibraryDialog(false);
setPublishLibSuccess({ url: data.url, authorName: data.authorName }); setPublishLibSuccess({ url: data.url, authorName: data.authorName });
const nextLibItems = libraryItems.slice(); const nextLibItems = libraryItems.slice();
@@ -225,102 +220,114 @@ export const LibraryMenu = ({
libItem.status = "published"; libItem.status = "published";
} }
}); });
library.saveLibrary(nextLibItems); library.setLibrary(nextLibItems);
setLibraryItems(nextLibItems);
}, },
[ [setShowPublishLibraryDialog, setPublishLibSuccess, selectedItems, library],
setShowPublishLibraryDialog,
setPublishLibSuccess,
libraryItems,
selectedItems,
library,
],
); );
const [lastSelectedItem, setLastSelectedItem] = useState< const [lastSelectedItem, setLastSelectedItem] = useState<
LibraryItem["id"] | null LibraryItem["id"] | null
>(null); >(null);
return loadingState === "preloading" ? null : ( if (
<Island padding={1} ref={ref} className="layer-ui__library"> libraryItemsData.status === "loading" &&
!libraryItemsData.isInitialized
) {
return (
<LibraryMenuWrapper ref={ref}>
<div className="layer-ui__library-message">
<Spinner size="2em" />
<span>{t("labels.libraryLoadingMessage")}</span>
</div>
</LibraryMenuWrapper>
);
}
return (
<LibraryMenuWrapper ref={ref}>
{showPublishLibraryDialog && ( {showPublishLibraryDialog && (
<PublishLibrary <PublishLibrary
onClose={() => setShowPublishLibraryDialog(false)} onClose={() => setShowPublishLibraryDialog(false)}
libraryItems={getSelectedItems(libraryItems, selectedItems)} libraryItems={getSelectedItems(
libraryItemsData.libraryItems,
selectedItems,
)}
appState={appState} appState={appState}
onSuccess={onPublishLibSuccess} onSuccess={(data) =>
onPublishLibSuccess(data, libraryItemsData.libraryItems)
}
onError={(error) => window.alert(error)} onError={(error) => window.alert(error)}
updateItemsInStorage={() => library.saveLibrary(libraryItems)} updateItemsInStorage={() =>
library.setLibrary(libraryItemsData.libraryItems)
}
onRemove={(id: string) => onRemove={(id: string) =>
setSelectedItems(selectedItems.filter((_id) => _id !== id)) setSelectedItems(selectedItems.filter((_id) => _id !== id))
} }
/> />
)} )}
{publishLibSuccess && renderPublishSuccess()} {publishLibSuccess && renderPublishSuccess()}
<LibraryMenuItems
isLoading={libraryItemsData.status === "loading"}
libraryItems={libraryItemsData.libraryItems}
onRemoveFromLibrary={() =>
removeFromLibrary(libraryItemsData.libraryItems)
}
onAddToLibrary={(elements) =>
addToLibrary(elements, libraryItemsData.libraryItems)
}
onInsertShape={onInsertShape}
pendingElements={pendingElements}
setAppState={setAppState}
libraryReturnUrl={libraryReturnUrl}
library={library}
theme={theme}
files={files}
id={id}
selectedItems={selectedItems}
onToggle={(id, event) => {
const shouldSelect = !selectedItems.includes(id);
{loadingState === "loading" ? ( if (shouldSelect) {
<div className="layer-ui__library-message"> if (event.shiftKey && lastSelectedItem) {
{t("labels.libraryLoadingMessage")} const rangeStart = libraryItemsData.libraryItems.findIndex(
</div> (item) => item.id === lastSelectedItem,
) : ( );
<LibraryMenuItems const rangeEnd = libraryItemsData.libraryItems.findIndex(
libraryItems={libraryItems} (item) => item.id === id,
onRemoveFromLibrary={removeFromLibrary} );
onAddToLibrary={addToLibrary}
onInsertShape={onInsertShape}
pendingElements={pendingElements}
setAppState={setAppState}
libraryReturnUrl={libraryReturnUrl}
library={library}
theme={theme}
files={files}
id={id}
selectedItems={selectedItems}
onToggle={(id, event) => {
const shouldSelect = !selectedItems.includes(id);
if (shouldSelect) { if (rangeStart === -1 || rangeEnd === -1) {
if (event.shiftKey && lastSelectedItem) {
const rangeStart = libraryItems.findIndex(
(item) => item.id === lastSelectedItem,
);
const rangeEnd = libraryItems.findIndex(
(item) => item.id === id,
);
if (rangeStart === -1 || rangeEnd === -1) {
setSelectedItems([...selectedItems, id]);
return;
}
const selectedItemsMap = arrayToMap(selectedItems);
const nextSelectedIds = libraryItems.reduce(
(acc: LibraryItem["id"][], item, idx) => {
if (
(idx >= rangeStart && idx <= rangeEnd) ||
selectedItemsMap.has(item.id)
) {
acc.push(item.id);
}
return acc;
},
[],
);
setSelectedItems(nextSelectedIds);
} else {
setSelectedItems([...selectedItems, id]); setSelectedItems([...selectedItems, id]);
return;
} }
setLastSelectedItem(id);
const selectedItemsMap = arrayToMap(selectedItems);
const nextSelectedIds = libraryItemsData.libraryItems.reduce(
(acc: LibraryItem["id"][], item, idx) => {
if (
(idx >= rangeStart && idx <= rangeEnd) ||
selectedItemsMap.has(item.id)
) {
acc.push(item.id);
}
return acc;
},
[],
);
setSelectedItems(nextSelectedIds);
} else { } else {
setLastSelectedItem(null); setSelectedItems([...selectedItems, id]);
setSelectedItems(selectedItems.filter((_id) => _id !== id));
} }
}} setLastSelectedItem(id);
onPublish={() => setShowPublishLibraryDialog(true)} } else {
resetLibrary={resetLibrary} setLastSelectedItem(null);
/> setSelectedItems(selectedItems.filter((_id) => _id !== id));
)} }
</Island> }}
onPublish={() => setShowPublishLibraryDialog(true)}
resetLibrary={resetLibrary}
/>
</LibraryMenuWrapper>
); );
}; };
+22 -19
View File
@@ -12,7 +12,7 @@ import {
LibraryItems, LibraryItems,
} from "../types"; } from "../types";
import { muteFSAbortError } from "../utils"; import { muteFSAbortError } from "../utils";
import { useIsMobile } from "./App"; import { useDeviceType } from "./App";
import ConfirmDialog from "./ConfirmDialog"; import ConfirmDialog from "./ConfirmDialog";
import { exportToFileIcon, load, publishIcon, trash } from "./icons"; import { exportToFileIcon, load, publishIcon, trash } from "./icons";
import { LibraryUnit } from "./LibraryUnit"; import { LibraryUnit } from "./LibraryUnit";
@@ -22,8 +22,10 @@ import { Tooltip } from "./Tooltip";
import "./LibraryMenuItems.scss"; import "./LibraryMenuItems.scss";
import { VERSIONS } from "../constants"; import { VERSIONS } from "../constants";
import Spinner from "./Spinner";
const LibraryMenuItems = ({ const LibraryMenuItems = ({
isLoading,
libraryItems, libraryItems,
onRemoveFromLibrary, onRemoveFromLibrary,
onAddToLibrary, onAddToLibrary,
@@ -40,6 +42,7 @@ const LibraryMenuItems = ({
onPublish, onPublish,
resetLibrary, resetLibrary,
}: { }: {
isLoading: boolean;
libraryItems: LibraryItems; libraryItems: LibraryItems;
pendingElements: LibraryItem["elements"]; pendingElements: LibraryItem["elements"];
onRemoveFromLibrary: () => void; onRemoveFromLibrary: () => void;
@@ -85,7 +88,7 @@ const LibraryMenuItems = ({
const [showRemoveLibAlert, setShowRemoveLibAlert] = useState(false); const [showRemoveLibAlert, setShowRemoveLibAlert] = useState(false);
const isMobile = useIsMobile(); const isMobile = useDeviceType().isMobile;
const renderLibraryActions = () => { const renderLibraryActions = () => {
const itemsSelected = !!selectedItems.length; const itemsSelected = !!selectedItems.length;
@@ -106,14 +109,10 @@ const LibraryMenuItems = ({
icon={load} icon={load}
onClick={() => { onClick={() => {
importLibraryFromJSON(library) importLibraryFromJSON(library)
.then(() => {
// Close and then open to get the libraries updated
setAppState({ isLibraryOpen: false });
setAppState({ isLibraryOpen: true });
})
.catch(muteFSAbortError) .catch(muteFSAbortError)
.catch((error) => { .catch((error) => {
setAppState({ errorMessage: error.message }); console.error(error);
setAppState({ errorMessage: t("errors.importLibraryError") });
}); });
}} }}
className="library-actions--load" className="library-actions--load"
@@ -130,7 +129,7 @@ const LibraryMenuItems = ({
onClick={async () => { onClick={async () => {
const libraryItems = itemsSelected const libraryItems = itemsSelected
? items ? items
: await library.loadLibrary(); : await library.getLatestLibrary();
saveLibraryAsJSON(libraryItems) saveLibraryAsJSON(libraryItems)
.catch(muteFSAbortError) .catch(muteFSAbortError)
.catch((error) => { .catch((error) => {
@@ -289,16 +288,20 @@ const LibraryMenuItems = ({
{showRemoveLibAlert && renderRemoveLibAlert()} {showRemoveLibAlert && renderRemoveLibAlert()}
<div className="layer-ui__library-header" key="library-header"> <div className="layer-ui__library-header" key="library-header">
{renderLibraryActions()} {renderLibraryActions()}
<a {isLoading ? (
href={`${process.env.REACT_APP_LIBRARY_URL}?target=${ <Spinner />
window.name || "_blank" ) : (
}&referrer=${referrer}&useHash=true&token=${id}&theme=${theme}&version=${ <a
VERSIONS.excalidrawLibrary href={`${process.env.REACT_APP_LIBRARY_URL}?target=${
}`} window.name || "_blank"
target="_excalidraw_libraries" }&referrer=${referrer}&useHash=true&token=${id}&theme=${theme}&version=${
> VERSIONS.excalidrawLibrary
{t("labels.libraries")} }`}
</a> target="_excalidraw_libraries"
>
{t("labels.libraries")}
</a>
)}
</div> </div>
<Stack.Col <Stack.Col
className="library-menu-items-container__items" className="library-menu-items-container__items"
+2 -2
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 { MIME_TYPES } from "../constants"; import { MIME_TYPES } from "../constants";
import { useIsMobile } from "../components/App"; import { useDeviceType } from "../components/App";
import { exportToSvg } from "../scene/export"; import { exportToSvg } from "../scene/export";
import { BinaryFiles, LibraryItem } from "../types"; import { BinaryFiles, LibraryItem } from "../types";
import "./LibraryUnit.scss"; import "./LibraryUnit.scss";
@@ -66,7 +66,7 @@ export const LibraryUnit = ({
}, [elements, files]); }, [elements, files]);
const [isHovered, setIsHovered] = useState(false); const [isHovered, setIsHovered] = useState(false);
const isMobile = useIsMobile(); const isMobile = useDeviceType().isMobile;
const adder = isPending && ( const adder = isPending && (
<div className="library-unit__adder">{PLUS_ICON}</div> <div className="library-unit__adder">{PLUS_ICON}</div>
); );
+23 -3
View File
@@ -1,10 +1,30 @@
import { t } from "../i18n"; import { t } from "../i18n";
import { useState, useEffect } from "react";
import Spinner from "./Spinner";
export const LoadingMessage: React.FC<{ delay?: number }> = ({ delay }) => {
const [isWaiting, setIsWaiting] = useState(!!delay);
useEffect(() => {
if (!delay) {
return;
}
const timer = setTimeout(() => {
setIsWaiting(false);
}, delay);
return () => clearTimeout(timer);
}, [delay]);
if (isWaiting) {
return null;
}
export const LoadingMessage = () => {
// !! KEEP THIS IN SYNC WITH index.html !!
return ( return (
<div className="LoadingMessage"> <div className="LoadingMessage">
<span>{t("labels.loadingScene")}</span> <div>
<Spinner />
</div>
<div className="LoadingMessage-text">{t("labels.loadingScene")}</div>
</div> </div>
); );
}; };
+4 -3
View File
@@ -72,8 +72,9 @@ export const MobileMenu = ({
{heading} {heading}
<Stack.Row gap={1}> <Stack.Row gap={1}>
<ShapesSwitcher <ShapesSwitcher
appState={appState}
canvas={canvas} canvas={canvas}
elementType={appState.elementType} activeTool={appState.activeTool}
setAppState={setAppState} setAppState={setAppState}
onImageAction={({ pointerType }) => { onImageAction={({ pointerType }) => {
onImageAction({ onImageAction({
@@ -85,7 +86,7 @@ export const MobileMenu = ({
</Island> </Island>
{renderTopRightUI && renderTopRightUI(true, appState)} {renderTopRightUI && renderTopRightUI(true, appState)}
<LockButton <LockButton
checked={appState.elementLocked} checked={appState.activeTool.locked}
onChange={onLockToggle} onChange={onLockToggle}
title={t("toolBar.lock")} title={t("toolBar.lock")}
isMobile isMobile
@@ -225,7 +226,7 @@ export const MobileMenu = ({
appState={appState} appState={appState}
elements={elements} elements={elements}
renderAction={actionManager.renderAction} renderAction={actionManager.renderAction}
elementType={appState.elementType} activeTool={appState.activeTool.type}
/> />
</Section> </Section>
) : null} ) : null}
+6 -6
View File
@@ -4,7 +4,7 @@ import React, { useState, useLayoutEffect, useRef } from "react";
import { createPortal } from "react-dom"; import { createPortal } from "react-dom";
import clsx from "clsx"; import clsx from "clsx";
import { KEYS } from "../keys"; import { KEYS } from "../keys";
import { useExcalidrawContainer, useIsMobile } from "./App"; import { useExcalidrawContainer, useDeviceType } from "./App";
import { AppState } from "../types"; import { AppState } from "../types";
import { THEME } from "../constants"; import { THEME } from "../constants";
@@ -59,17 +59,17 @@ export const Modal = (props: {
const useBodyRoot = (theme: AppState["theme"]) => { const useBodyRoot = (theme: AppState["theme"]) => {
const [div, setDiv] = useState<HTMLDivElement | null>(null); const [div, setDiv] = useState<HTMLDivElement | null>(null);
const isMobile = useIsMobile(); const deviceType = useDeviceType();
const isMobileRef = useRef(isMobile); const isMobileRef = useRef(deviceType.isMobile);
isMobileRef.current = isMobile; isMobileRef.current = deviceType.isMobile;
const { container: excalidrawContainer } = useExcalidrawContainer(); const { container: excalidrawContainer } = useExcalidrawContainer();
useLayoutEffect(() => { useLayoutEffect(() => {
if (div) { if (div) {
div.classList.toggle("excalidraw--mobile", isMobile); div.classList.toggle("excalidraw--mobile", deviceType.isMobile);
} }
}, [div, isMobile]); }, [div, deviceType.isMobile]);
useLayoutEffect(() => { useLayoutEffect(() => {
const isDarkTheme = const isDarkTheme =
+3 -3
View File
@@ -2,7 +2,7 @@ import React from "react";
import { getCommonBounds } from "../element/bounds"; import { getCommonBounds } from "../element/bounds";
import { NonDeletedExcalidrawElement } from "../element/types"; import { NonDeletedExcalidrawElement } from "../element/types";
import { t } from "../i18n"; import { t } from "../i18n";
import { useIsMobile } from "../components/App"; import { useDeviceType } from "../components/App";
import { getTargetElements } from "../scene"; import { getTargetElements } from "../scene";
import { AppState, ExcalidrawProps } from "../types"; import { AppState, ExcalidrawProps } from "../types";
import { close } from "./icons"; import { close } from "./icons";
@@ -16,13 +16,13 @@ export const Stats = (props: {
onClose: () => void; onClose: () => void;
renderCustomStats: ExcalidrawProps["renderCustomStats"]; renderCustomStats: ExcalidrawProps["renderCustomStats"];
}) => { }) => {
const isMobile = useIsMobile(); const deviceType = useDeviceType();
const boundingBox = getCommonBounds(props.elements); const boundingBox = getCommonBounds(props.elements);
const selectedElements = getTargetElements(props.elements, props.appState); const selectedElements = getTargetElements(props.elements, props.appState);
const selectedBoundingBox = getCommonBounds(selectedElements); const selectedBoundingBox = getCommonBounds(selectedElements);
if (isMobile && props.appState.openMenu) { if (deviceType.isMobile && props.appState.openMenu) {
return null; return null;
} }
+2
View File
@@ -48,6 +48,7 @@ type ToolButtonProps =
type: "radio"; type: "radio";
checked: boolean; checked: boolean;
onChange?(data: { pointerType: PointerType | null }): void; onChange?(data: { pointerType: PointerType | null }): void;
onPointerDown?(data: { pointerType: PointerType }): void;
}); });
export const ToolButton = React.forwardRef((props: ToolButtonProps, ref) => { export const ToolButton = React.forwardRef((props: ToolButtonProps, ref) => {
@@ -149,6 +150,7 @@ export const ToolButton = React.forwardRef((props: ToolButtonProps, ref) => {
title={props.title} title={props.title}
onPointerDown={(event) => { onPointerDown={(event) => {
lastPointerTypeRef.current = event.pointerType || null; lastPointerTypeRef.current = event.pointerType || null;
props.onPointerDown?.({ pointerType: event.pointerType || null });
}} }}
onPointerUp={() => { onPointerUp={() => {
requestAnimationFrame(() => { requestAnimationFrame(() => {
+1 -1
View File
@@ -155,7 +155,7 @@
} }
width: 2rem; width: 2rem;
height: 2em; height: 2rem;
} }
} }
+1 -1
View File
@@ -2,7 +2,7 @@
// container in body where the actual tooltip is appended to // container in body where the actual tooltip is appended to
.excalidraw-tooltip { .excalidraw-tooltip {
position: absolute; position: fixed;
z-index: 1000; z-index: 1000;
padding: 8px; padding: 8px;
+18 -2
View File
@@ -1,5 +1,5 @@
import cssVariables from "./css/variables.module.scss"; import cssVariables from "./css/variables.module.scss";
import { AppProps } from "./types"; import { AppProps, CustomElementConfig } from "./types";
import { FontFamilyValues } from "./element/types"; import { FontFamilyValues } from "./element/types";
export const APP_NAME = "Excalidraw"; export const APP_NAME = "Excalidraw";
@@ -94,7 +94,9 @@ export const MIME_TYPES = {
excalidrawlib: "application/vnd.excalidrawlib+json", excalidrawlib: "application/vnd.excalidrawlib+json",
json: "application/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",
binary: "application/octet-stream", binary: "application/octet-stream",
@@ -106,7 +108,8 @@ export const EXPORT_DATA_TYPES = {
excalidrawLibrary: "excalidrawlib", excalidrawLibrary: "excalidrawlib",
} as const; } as const;
export const EXPORT_SOURCE = window.location.origin; export const EXPORT_SOURCE =
window.EXCALIDRAW_EXPORT_SOURCE || window.location.origin;
// time in milliseconds // time in milliseconds
export const IMAGE_RENDER_TIMEOUT = 500; export const IMAGE_RENDER_TIMEOUT = 500;
@@ -152,6 +155,17 @@ export const DEFAULT_UI_OPTIONS: AppProps["UIOptions"] = {
}, },
}; };
export const DEFAULT_CUSTOM_ELEMENT_CONFIG: Required<CustomElementConfig> = {
type: "custom",
customType: "custom",
transformHandles: true,
displayData: { content: "", type: "svg" },
width: 40,
height: 40,
stackedOnTop: false,
onCreate: () => {},
disableContextMenu: false,
};
export const MQ_MAX_WIDTH_PORTRAIT = 730; export const MQ_MAX_WIDTH_PORTRAIT = 730;
export const MQ_MAX_WIDTH_LANDSCAPE = 1000; export const MQ_MAX_WIDTH_LANDSCAPE = 1000;
export const MQ_MAX_HEIGHT_LANDSCAPE = 500; export const MQ_MAX_HEIGHT_LANDSCAPE = 500;
@@ -188,3 +202,5 @@ export const VERTICAL_ALIGN = {
MIDDLE: "middle", MIDDLE: "middle",
BOTTOM: "bottom", BOTTOM: "bottom",
}; };
export const ELEMENT_READY_TO_ERASE_OPACITY = 20;
+9 -7
View File
@@ -16,15 +16,17 @@
left: 0; left: 0;
z-index: 999; z-index: 999;
display: flex; display: flex;
flex-direction: column;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
pointer-events: none; pointer-events: none;
}
.LoadingMessage span { .Spinner {
background-color: var(--button-gray-1); font-size: 2.8em;
border-radius: 5px; }
padding: 0.8em 1.2em;
color: var(--popup-text-color); .LoadingMessage-text {
font-size: 1.3em; margin-top: 1em;
font-size: 0.8em;
}
} }
+9
View File
@@ -477,6 +477,15 @@
font-family: var(--ui-font); font-family: var(--ui-font);
} }
.finalize-button {
display: grid;
grid-auto-flow: column;
gap: 0.4em;
margin-top: auto;
margin-bottom: auto;
margin-inline-start: 0.6em;
}
.undo-redo-buttons, .undo-redo-buttons,
.eraser-buttons { .eraser-buttons {
display: grid; display: grid;
+13 -13
View File
@@ -1,20 +1,16 @@
import { nanoid } from "nanoid"; import { nanoid } from "nanoid";
import { cleanAppStateForExport } from "../appState"; import { cleanAppStateForExport } from "../appState";
import { import { ALLOWED_IMAGE_MIME_TYPES, MIME_TYPES } from "../constants";
ALLOWED_IMAGE_MIME_TYPES,
EXPORT_DATA_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";
import { t } from "../i18n"; import { t } from "../i18n";
import { calculateScrollCenter } from "../scene"; import { calculateScrollCenter } from "../scene";
import { AppState, DataURL } from "../types"; import { AppState, DataURL, LibraryItem } from "../types";
import { bytesToHexString } from "../utils"; import { bytesToHexString } from "../utils";
import { FileSystemHandle } from "./filesystem"; import { FileSystemHandle } from "./filesystem";
import { isValidExcalidrawData } from "./json"; import { isValidExcalidrawData, isValidLibrary } from "./json";
import { restore } from "./restore"; import { restore, restoreLibraryItems } from "./restore";
import { ImportedLibraryData } from "./types"; import { ImportedLibraryData } from "./types";
const parseFileContents = async (blob: Blob | File) => { const parseFileContents = async (blob: Blob | File) => {
@@ -163,13 +159,17 @@ export const loadFromBlob = async (
} }
}; };
export const loadLibraryFromBlob = async (blob: Blob) => { export const loadLibraryFromBlob = async (
blob: Blob,
defaultStatus: LibraryItem["status"] = "unpublished",
) => {
const contents = await parseFileContents(blob); const contents = await parseFileContents(blob);
const data: ImportedLibraryData = JSON.parse(contents); const data: ImportedLibraryData | undefined = JSON.parse(contents);
if (data.type !== EXPORT_DATA_TYPES.excalidrawLibrary) { if (!isValidLibrary(data)) {
throw new Error(t("alerts.couldNotLoadInvalidFile")); throw new Error("Invalid library");
} }
return data; const libraryItems = data.libraryItems || data.library;
return restoreLibraryItems(libraryItems, defaultStatus);
}; };
export const canvasToBlob = async ( export const canvasToBlob = async (
+2
View File
@@ -13,7 +13,9 @@ type FILE_EXTENSION =
| "gif" | "gif"
| "jpg" | "jpg"
| "png" | "png"
| "excalidraw.png"
| "svg" | "svg"
| "excalidraw.svg"
| "json" | "json"
| "excalidraw" | "excalidraw"
| "excalidrawlib"; | "excalidrawlib";
+3 -1
View File
@@ -105,7 +105,9 @@ export const encodeSvgMetadata = async ({ text }: { text: string }) => {
export const decodeSvgMetadata = async ({ svg }: { svg: string }) => { export const decodeSvgMetadata = async ({ svg }: { svg: string }) => {
if (svg.includes(`payload-type:${MIME_TYPES.excalidraw}`)) { if (svg.includes(`payload-type:${MIME_TYPES.excalidraw}`)) {
const match = svg.match(/<!-- payload-start -->(.+?)<!-- payload-end -->/); const match = svg.match(
/<!-- payload-start -->\s*(.+?)\s*<!-- payload-end -->/,
);
if (!match) { if (!match) {
throw new Error("INVALID"); throw new Error("INVALID");
} }
+12 -5
View File
@@ -16,7 +16,7 @@ export { loadFromBlob } from "./blob";
export { loadFromJSON, saveAsJSON } from "./json"; export { loadFromJSON, saveAsJSON } from "./json";
export const exportCanvas = async ( export const exportCanvas = async (
type: ExportType, type: Omit<ExportType, "backend">,
elements: readonly NonDeletedExcalidrawElement[], elements: readonly NonDeletedExcalidrawElement[],
appState: AppState, appState: AppState,
files: BinaryFiles, files: BinaryFiles,
@@ -56,7 +56,7 @@ export const exportCanvas = async (
{ {
description: "Export to SVG", description: "Export to SVG",
name, name,
extension: "svg", extension: appState.exportEmbedScene ? "excalidraw.svg" : "svg",
fileHandle, fileHandle,
}, },
); );
@@ -73,10 +73,10 @@ export const exportCanvas = async (
}); });
tempCanvas.style.display = "none"; tempCanvas.style.display = "none";
document.body.appendChild(tempCanvas); document.body.appendChild(tempCanvas);
let blob = await canvasToBlob(tempCanvas);
tempCanvas.remove();
if (type === "png") { if (type === "png") {
let blob = await canvasToBlob(tempCanvas);
tempCanvas.remove();
if (appState.exportEmbedScene) { if (appState.exportEmbedScene) {
blob = await ( blob = await (
await import(/* webpackChunkName: "image" */ "./image") await import(/* webpackChunkName: "image" */ "./image")
@@ -89,17 +89,24 @@ export const exportCanvas = async (
return await fileSave(blob, { return await fileSave(blob, {
description: "Export to PNG", description: "Export to PNG",
name, name,
extension: "png", extension: appState.exportEmbedScene ? "excalidraw.png" : "png",
fileHandle, fileHandle,
}); });
} else if (type === "clipboard") { } else if (type === "clipboard") {
try { try {
const blob = canvasToBlob(tempCanvas);
await copyBlobToClipboardAsPng(blob); await copyBlobToClipboardAsPng(blob);
} catch (error: any) { } catch (error: any) {
if (error.name === "CANVAS_POSSIBLY_TOO_BIG") { if (error.name === "CANVAS_POSSIBLY_TOO_BIG") {
throw error; throw error;
} }
throw new Error(t("alerts.couldNotCopyToClipboard")); throw new Error(t("alerts.couldNotCopyToClipboard"));
} finally {
tempCanvas.remove();
} }
} else {
tempCanvas.remove();
// shouldn't happen
throw new Error("Unsupported export type");
} }
}; };
+8 -3
View File
@@ -15,6 +15,7 @@ import {
ExportedDataState, ExportedDataState,
ImportedDataState, ImportedDataState,
ExportedLibraryData, ExportedLibraryData,
ImportedLibraryData,
} from "./types"; } from "./types";
import Library from "./library"; import Library from "./library";
@@ -114,7 +115,7 @@ export const isValidExcalidrawData = (data?: {
); );
}; };
export const isValidLibrary = (json: any) => { export const isValidLibrary = (json: any): json is ImportedLibraryData => {
return ( return (
typeof json === "object" && typeof json === "object" &&
json && json &&
@@ -123,14 +124,18 @@ export const isValidLibrary = (json: any) => {
); );
}; };
export const saveLibraryAsJSON = async (libraryItems: LibraryItems) => { export const serializeLibraryAsJSON = (libraryItems: LibraryItems) => {
const data: ExportedLibraryData = { const data: ExportedLibraryData = {
type: EXPORT_DATA_TYPES.excalidrawLibrary, type: EXPORT_DATA_TYPES.excalidrawLibrary,
version: VERSIONS.excalidrawLibrary, version: VERSIONS.excalidrawLibrary,
source: EXPORT_SOURCE, source: EXPORT_SOURCE,
libraryItems, libraryItems,
}; };
const serialized = JSON.stringify(data, null, 2); return JSON.stringify(data, null, 2);
};
export const saveLibraryAsJSON = async (libraryItems: LibraryItems) => {
const serialized = serializeLibraryAsJSON(libraryItems);
await fileSave( await fileSave(
new Blob([serialized], { new Blob([serialized], {
type: MIME_TYPES.excalidrawlib, type: MIME_TYPES.excalidrawlib,
+176 -92
View File
@@ -1,121 +1,205 @@
import { loadLibraryFromBlob } from "./blob"; import { loadLibraryFromBlob } from "./blob";
import { LibraryItems, LibraryItem } from "../types"; import { LibraryItems, LibraryItem } from "../types";
import { restoreElements, restoreLibraryItems } from "./restore"; import { restoreLibraryItems } from "./restore";
import { getNonDeletedElements } from "../element";
import type App from "../components/App"; import type App from "../components/App";
import { ImportedDataState } from "./types";
import { atom } from "jotai";
import { jotaiStore } from "../jotai";
export const libraryItemsAtom = atom<{
status: "loading" | "loaded";
isInitialized: boolean;
libraryItems: LibraryItems;
}>({ status: "loaded", isInitialized: true, libraryItems: [] });
const cloneLibraryItems = (libraryItems: LibraryItems): LibraryItems =>
JSON.parse(JSON.stringify(libraryItems));
/**
* checks if library item does not exist already in current library
*/
const isUniqueItem = (
existingLibraryItems: LibraryItems,
targetLibraryItem: LibraryItem,
) => {
return !existingLibraryItems.find((libraryItem) => {
if (libraryItem.elements.length !== targetLibraryItem.elements.length) {
return false;
}
// detect z-index difference by checking the excalidraw elements
// are in order
return libraryItem.elements.every((libItemExcalidrawItem, idx) => {
return (
libItemExcalidrawItem.id === targetLibraryItem.elements[idx].id &&
libItemExcalidrawItem.versionNonce ===
targetLibraryItem.elements[idx].versionNonce
);
});
});
};
/** Merges otherItems into localItems. Unique items in otherItems array are
sorted first. */
export const mergeLibraryItems = (
localItems: LibraryItems,
otherItems: LibraryItems,
): LibraryItems => {
const newItems = [];
for (const item of otherItems) {
if (isUniqueItem(localItems, item)) {
newItems.push(item);
}
}
return [...newItems, ...localItems];
};
class Library { class Library {
private libraryCache: LibraryItems | null = null; /** latest libraryItems */
private lastLibraryItems: LibraryItems = [];
/** indicates whether library is initialized with library items (has gone
* though at least one update) */
private isInitialized = false;
private app: App; private app: App;
constructor(app: App) { constructor(app: App) {
this.app = app; this.app = app;
} }
resetLibrary = async () => { private updateQueue: Promise<LibraryItems>[] = [];
await this.app.props.onLibraryChange?.([]);
this.libraryCache = []; private getLastUpdateTask = (): Promise<LibraryItems> | undefined => {
return this.updateQueue[this.updateQueue.length - 1];
}; };
restoreLibraryItem = (libraryItem: LibraryItem): LibraryItem | null => { private notifyListeners = () => {
const elements = getNonDeletedElements( if (this.updateQueue.length > 0) {
restoreElements(libraryItem.elements, null), jotaiStore.set(libraryItemsAtom, {
); status: "loading",
return elements.length ? { ...libraryItem, elements } : null; libraryItems: this.lastLibraryItems,
}; isInitialized: this.isInitialized,
/** imports library (currently merges, removing duplicates) */
async importLibrary(blob: Blob, defaultStatus = "unpublished") {
const libraryFile = await loadLibraryFromBlob(blob);
if (!libraryFile || !(libraryFile.libraryItems || libraryFile.library)) {
return;
}
/**
* checks if library item does not exist already in current library
*/
const isUniqueitem = (
existingLibraryItems: LibraryItems,
targetLibraryItem: LibraryItem,
) => {
return !existingLibraryItems.find((libraryItem) => {
if (libraryItem.elements.length !== targetLibraryItem.elements.length) {
return false;
}
// detect z-index difference by checking the excalidraw elements
// are in order
return libraryItem.elements.every((libItemExcalidrawItem, idx) => {
return (
libItemExcalidrawItem.id === targetLibraryItem.elements[idx].id &&
libItemExcalidrawItem.versionNonce ===
targetLibraryItem.elements[idx].versionNonce
);
});
}); });
}; } else {
this.isInitialized = true;
const existingLibraryItems = await this.loadLibrary(); jotaiStore.set(libraryItemsAtom, {
status: "loaded",
const library = libraryFile.libraryItems || libraryFile.library || []; libraryItems: this.lastLibraryItems,
const restoredLibItems = restoreLibraryItems( isInitialized: this.isInitialized,
library, });
defaultStatus as "published" | "unpublished", try {
); this.app.props.onLibraryChange?.(
const filteredItems = []; cloneLibraryItems(this.lastLibraryItems),
for (const item of restoredLibItems) { );
const restoredItem = this.restoreLibraryItem(item as LibraryItem); } catch (error) {
if (restoredItem && isUniqueitem(existingLibraryItems, restoredItem)) { console.error(error);
filteredItems.push(restoredItem);
} }
} }
};
await this.saveLibrary([...filteredItems, ...existingLibraryItems]); resetLibrary = () => {
return this.setLibrary([]);
};
/**
* imports library (from blob or libraryItems), merging with current library
* (attempting to remove duplicates)
*/
importLibrary(
library:
| Blob
| Required<ImportedDataState>["libraryItems"]
| Promise<Required<ImportedDataState>["libraryItems"]>,
defaultStatus: LibraryItem["status"] = "unpublished",
): Promise<LibraryItems> {
return this.setLibrary(
() =>
new Promise<LibraryItems>(async (resolve, reject) => {
try {
let libraryItems: LibraryItems;
if (library instanceof Blob) {
libraryItems = await loadLibraryFromBlob(library, defaultStatus);
} else {
libraryItems = restoreLibraryItems(await library, defaultStatus);
}
resolve(mergeLibraryItems(this.lastLibraryItems, libraryItems));
} catch (error) {
reject(error);
}
}),
);
} }
loadLibrary = (): Promise<LibraryItems> => { /**
* @returns latest cloned libraryItems. Awaits all in-progress updates first.
*/
getLatestLibrary = (): Promise<LibraryItems> => {
return new Promise(async (resolve) => { return new Promise(async (resolve) => {
if (this.libraryCache) {
return resolve(JSON.parse(JSON.stringify(this.libraryCache)));
}
try { try {
const libraryItems = this.app.libraryItemsFromStorage; const libraryItems = await (this.getLastUpdateTask() ||
if (!libraryItems) { this.lastLibraryItems);
return resolve([]); if (this.updateQueue.length > 0) {
resolve(this.getLatestLibrary());
} else {
resolve(cloneLibraryItems(libraryItems));
} }
} catch (error) {
const items = libraryItems.reduce((acc, item) => { return resolve(this.lastLibraryItems);
const restoredItem = this.restoreLibraryItem(item);
if (restoredItem) {
acc.push(item);
}
return acc;
}, [] as Mutable<LibraryItems>);
// clone to ensure we don't mutate the cached library elements in the app
this.libraryCache = JSON.parse(JSON.stringify(items));
resolve(items);
} catch (error: any) {
console.error(error);
resolve([]);
} }
}); });
}; };
saveLibrary = async (items: LibraryItems) => { setLibrary = (
const prevLibraryItems = this.libraryCache; /**
try { * LibraryItems that will replace current items. Can be a function which
const serializedItems = JSON.stringify(items); * will be invoked after all previous tasks are resolved
// cache optimistically so that the app has access to the latest * (this is the prefered way to update the library to avoid race conditions,
// immediately * but you'll want to manually merge the library items in the callback
this.libraryCache = JSON.parse(serializedItems); * - which is what we're doing in Library.importLibrary()).
await this.app.props.onLibraryChange?.(items); *
} catch (error: any) { * If supplied promise is rejected with AbortError, we swallow it and
this.libraryCache = prevLibraryItems; * do not update the library.
throw error; */
} libraryItems:
| LibraryItems
| Promise<LibraryItems>
| ((
latestLibraryItems: LibraryItems,
) => LibraryItems | Promise<LibraryItems>),
): Promise<LibraryItems> => {
const task = new Promise<LibraryItems>(async (resolve, reject) => {
try {
await this.getLastUpdateTask();
if (typeof libraryItems === "function") {
libraryItems = libraryItems(this.lastLibraryItems);
}
this.lastLibraryItems = cloneLibraryItems(await libraryItems);
resolve(this.lastLibraryItems);
} catch (error: any) {
reject(error);
}
})
.catch((error) => {
if (error.name === "AbortError") {
console.warn("Library update aborted by user");
return this.lastLibraryItems;
}
throw error;
})
.finally(() => {
this.updateQueue = this.updateQueue.filter((_task) => _task !== task);
this.notifyListeners();
});
this.updateQueue.push(task);
this.notifyListeners();
return task;
}; };
} }
+54 -14
View File
@@ -10,7 +10,11 @@ import {
NormalizedZoomValue, NormalizedZoomValue,
} from "../types"; } from "../types";
import { ImportedDataState } from "./types"; import { ImportedDataState } from "./types";
import { getNormalizedDimensions, isInvisiblySmallElement } from "../element"; import {
getNonDeletedElements,
getNormalizedDimensions,
isInvisiblySmallElement,
} from "../element";
import { isLinearElementType } from "../element/typeChecks"; import { isLinearElementType } from "../element/typeChecks";
import { randomId } from "../random"; import { randomId } from "../random";
import { import {
@@ -30,8 +34,8 @@ type RestoredAppState = Omit<
"offsetTop" | "offsetLeft" | "width" | "height" "offsetTop" | "offsetLeft" | "width" | "height"
>; >;
export const AllowedExcalidrawElementTypes: Record< export const AllowedExcalidrawActiveTools: Record<
AppState["elementType"], AppState["activeTool"]["type"],
boolean boolean
> = { > = {
selection: true, selection: true,
@@ -44,6 +48,7 @@ export const AllowedExcalidrawElementTypes: Record<
arrow: true, arrow: true,
freedraw: true, freedraw: true,
eraser: false, eraser: false,
custom: true,
}; };
export type RestoredDataState = { export type RestoredDataState = {
@@ -107,6 +112,7 @@ const restoreElementWithProperties = <
: element.boundElements ?? [], : element.boundElements ?? [],
updated: element.updated ?? getUpdatedTimestamp(), updated: element.updated ?? getUpdatedTimestamp(),
link: element.link ?? null, link: element.link ?? null,
locked: element.locked ?? false,
}; };
return { return {
@@ -193,6 +199,10 @@ const restoreElement = (
y, y,
}); });
} }
case "custom":
return restoreElementWithProperties(element, {
customType: element.customType || "custom",
});
// generic elements // generic elements
case "ellipse": case "ellipse":
return restoreElementWithProperties(element, {}); return restoreElementWithProperties(element, {});
@@ -235,10 +245,8 @@ export const restoreAppState = (
localAppState: Partial<AppState> | null | undefined, localAppState: Partial<AppState> | null | undefined,
): RestoredAppState => { ): RestoredAppState => {
appState = appState || {}; appState = appState || {};
const defaultAppState = getDefaultAppState(); const defaultAppState = getDefaultAppState();
const nextAppState = {} as typeof defaultAppState; const nextAppState = {} as typeof defaultAppState;
for (const [key, defaultValue] of Object.entries(defaultAppState) as [ for (const [key, defaultValue] of Object.entries(defaultAppState) as [
keyof typeof defaultAppState, keyof typeof defaultAppState,
any, any,
@@ -252,12 +260,27 @@ export const restoreAppState = (
? localValue ? localValue
: defaultValue; : defaultValue;
} }
const activeTool: any = {
lastActiveToolBeforeEraser: null,
locked: nextAppState.activeTool.locked ?? false,
type: "selection",
};
if (AllowedExcalidrawActiveTools[nextAppState.activeTool.type]) {
if (nextAppState.activeTool.type === "custom") {
activeTool.type = "custom";
activeTool.customType = nextAppState.activeTool.customType ?? "custom";
} else {
activeTool.type = nextAppState.activeTool.type;
}
}
return { return {
...nextAppState, ...nextAppState,
elementType: AllowedExcalidrawElementTypes[nextAppState.elementType] cursorButton: localAppState?.cursorButton || "up",
? nextAppState.elementType // reset on fresh restore so as to hide the UI button if penMode not active
: "selection", penDetected:
localAppState?.penDetected ??
(appState.penMode ? appState.penDetected ?? false : false),
activeTool,
// Migrates from previous version where appState.zoom was a number // Migrates from previous version where appState.zoom was a number
zoom: zoom:
typeof appState.zoom === "number" typeof appState.zoom === "number"
@@ -269,7 +292,7 @@ export const restoreAppState = (
}; };
export const restore = ( export const restore = (
data: ImportedDataState | null, data: Pick<ImportedDataState, "appState" | "elements" | "files"> | null,
/** /**
* Local AppState (`this.state` or initial state from localStorage) so that we * Local AppState (`this.state` or initial state from localStorage) so that we
* don't overwrite local state with default values (when values not * don't overwrite local state with default values (when values not
@@ -286,28 +309,45 @@ export const restore = (
}; };
}; };
const restoreLibraryItem = (libraryItem: LibraryItem) => {
const elements = restoreElements(
getNonDeletedElements(libraryItem.elements),
null,
);
return elements.length ? { ...libraryItem, elements } : null;
};
export const restoreLibraryItems = ( export const restoreLibraryItems = (
libraryItems: NonOptional<ImportedDataState["libraryItems"]>, libraryItems: ImportedDataState["libraryItems"] = [],
defaultStatus: LibraryItem["status"], defaultStatus: LibraryItem["status"],
) => { ) => {
const restoredItems: LibraryItem[] = []; const restoredItems: LibraryItem[] = [];
for (const item of libraryItems) { for (const item of libraryItems) {
// migrate older libraries // migrate older libraries
if (Array.isArray(item)) { if (Array.isArray(item)) {
restoredItems.push({ const restoredItem = restoreLibraryItem({
status: defaultStatus, status: defaultStatus,
elements: item, elements: item,
id: randomId(), id: randomId(),
created: Date.now(), created: Date.now(),
}); });
if (restoredItem) {
restoredItems.push(restoredItem);
}
} else { } else {
const _item = item as MarkOptional<LibraryItem, "id" | "status">; const _item = item as MarkOptional<
restoredItems.push({ LibraryItem,
"id" | "status" | "created"
>;
const restoredItem = restoreLibraryItem({
..._item, ..._item,
id: _item.id || randomId(), id: _item.id || randomId(),
status: _item.status || defaultStatus, status: _item.status || defaultStatus,
created: _item.created || Date.now(), created: _item.created || Date.now(),
}); });
if (restoredItem) {
restoredItems.push(restoredItem);
}
} }
} }
return restoredItems; return restoredItems;
+4 -3
View File
@@ -262,9 +262,7 @@ export const actionLink = register({
commitToHistory: true, commitToHistory: true,
}; };
}, },
trackEvent: (action, source) => { trackEvent: { category: "hyperlink", action: "click" },
trackEvent("hyperlink", "edit", source);
},
keyTest: (event) => event[KEYS.CTRL_OR_CMD] && event.key === KEYS.K, keyTest: (event) => event[KEYS.CTRL_OR_CMD] && event.key === KEYS.K,
contextItemLabel: (elements, appState) => contextItemLabel: (elements, appState) =>
getContextMenuLabel(elements, appState), getContextMenuLabel(elements, appState),
@@ -337,6 +335,9 @@ export const isPointHittingLinkIcon = (
[x, y]: Point, [x, y]: Point,
isMobile: boolean, isMobile: boolean,
) => { ) => {
if (!element.link || appState.selectedElementIds[element.id]) {
return false;
}
const threshold = 4 / appState.zoom.value; const threshold = 4 / appState.zoom.value;
if ( if (
!isMobile && !isMobile &&
+47 -17
View File
@@ -255,7 +255,8 @@ export const getHoveredElementForBinding = (
const hoveredElement = getElementAtPosition( const hoveredElement = getElementAtPosition(
scene.getElements(), scene.getElements(),
(element) => (element) =>
isBindableElement(element) && bindingBorderTest(element, pointerCoords), isBindableElement(element, false) &&
bindingBorderTest(element, pointerCoords),
); );
return hoveredElement as NonDeleted<ExcalidrawBindableElement> | null; return hoveredElement as NonDeleted<ExcalidrawBindableElement> | null;
}; };
@@ -456,13 +457,13 @@ export const getEligibleElementsForBinding = (
): SuggestedBinding[] => { ): SuggestedBinding[] => {
const includedElementIds = new Set(elements.map(({ id }) => id)); const includedElementIds = new Set(elements.map(({ id }) => id));
return elements.flatMap((element) => return elements.flatMap((element) =>
isBindingElement(element) isBindingElement(element, false)
? (getElligibleElementsForBindingElement( ? (getElligibleElementsForBindingElement(
element as NonDeleted<ExcalidrawLinearElement>, element as NonDeleted<ExcalidrawLinearElement>,
).filter( ).filter(
(element) => !includedElementIds.has(element.id), (element) => !includedElementIds.has(element.id),
) as SuggestedBinding[]) ) as SuggestedBinding[])
: isBindableElement(element) : isBindableElement(element, false)
? getElligibleElementsForBindableElementAndWhere(element).filter( ? getElligibleElementsForBindableElementAndWhere(element).filter(
(binding) => !includedElementIds.has(binding[0].id), (binding) => !includedElementIds.has(binding[0].id),
) )
@@ -508,7 +509,7 @@ const getElligibleElementsForBindableElementAndWhere = (
return Scene.getScene(bindableElement)! return Scene.getScene(bindableElement)!
.getElements() .getElements()
.map((element) => { .map((element) => {
if (!isBindingElement(element)) { if (!isBindingElement(element, false)) {
return null; return null;
} }
const canBindStart = isLinearElementEligibleForNewBindingByBindable( const canBindStart = isLinearElementEligibleForNewBindingByBindable(
@@ -659,28 +660,47 @@ export const fixBindingsAfterDeletion = (
const deletedElementIds = new Set( const deletedElementIds = new Set(
deletedElements.map((element) => element.id), deletedElements.map((element) => element.id),
); );
// Non deleted and need an update // non-deleted which bindings need to be updated
const boundElementIds: Set<ExcalidrawElement["id"]> = new Set(); const affectedElements: Set<ExcalidrawElement["id"]> = new Set();
deletedElements.forEach((deletedElement) => { deletedElements.forEach((deletedElement) => {
if (isBindableElement(deletedElement)) { if (isBindableElement(deletedElement)) {
deletedElement.boundElements?.forEach((element) => { deletedElement.boundElements?.forEach((element) => {
if (!deletedElementIds.has(element.id)) { if (!deletedElementIds.has(element.id)) {
boundElementIds.add(element.id); affectedElements.add(element.id);
} }
}); });
} else if (isBindingElement(deletedElement)) {
if (deletedElement.startBinding) {
affectedElements.add(deletedElement.startBinding.elementId);
}
if (deletedElement.endBinding) {
affectedElements.add(deletedElement.endBinding.elementId);
}
} }
}); });
( sceneElements
sceneElements.filter(({ id }) => .filter(({ id }) => affectedElements.has(id))
boundElementIds.has(id), .forEach((element) => {
) as ExcalidrawLinearElement[] if (isBindableElement(element)) {
).forEach((element: ExcalidrawLinearElement) => { mutateElement(element, {
const { startBinding, endBinding } = element; boundElements: newBoundElementsAfterDeletion(
mutateElement(element, { element.boundElements,
startBinding: newBindingAfterDeletion(startBinding, deletedElementIds), deletedElementIds,
endBinding: newBindingAfterDeletion(endBinding, deletedElementIds), ),
});
} else if (isBindingElement(element)) {
mutateElement(element, {
startBinding: newBindingAfterDeletion(
element.startBinding,
deletedElementIds,
),
endBinding: newBindingAfterDeletion(
element.endBinding,
deletedElementIds,
),
});
}
}); });
});
}; };
const newBindingAfterDeletion = ( const newBindingAfterDeletion = (
@@ -692,3 +712,13 @@ const newBindingAfterDeletion = (
} }
return binding; return binding;
}; };
const newBoundElementsAfterDeletion = (
boundElements: ExcalidrawElement["boundElements"],
deletedElementIds: Set<ExcalidrawElement["id"]>,
) => {
if (!boundElements) {
return null;
}
return boundElements.filter((ele) => !deletedElementIds.has(ele.id));
};
+21 -4
View File
@@ -25,6 +25,7 @@ import {
ExcalidrawFreeDrawElement, ExcalidrawFreeDrawElement,
ExcalidrawImageElement, ExcalidrawImageElement,
ExcalidrawLinearElement, ExcalidrawLinearElement,
ExcalidrawCustomElement,
} from "./types"; } from "./types";
import { getElementAbsoluteCoords, getCurvePathOps, Bounds } from "./bounds"; import { getElementAbsoluteCoords, getCurvePathOps, Bounds } from "./bounds";
@@ -32,13 +33,20 @@ import { Point } from "../types";
import { Drawable } from "roughjs/bin/core"; import { Drawable } from "roughjs/bin/core";
import { AppState } from "../types"; import { AppState } from "../types";
import { getShapeForElement } from "../renderer/renderElement"; import { getShapeForElement } from "../renderer/renderElement";
import { hasBoundTextElement, isImageElement } from "./typeChecks"; import {
hasBoundTextElement,
isCustomElement,
isImageElement,
} from "./typeChecks";
import { isTextElement } from "."; import { isTextElement } from ".";
import { isTransparent } from "../utils"; import { isTransparent } from "../utils";
const isElementDraggableFromInside = ( const isElementDraggableFromInside = (
element: NonDeletedExcalidrawElement, element: NonDeletedExcalidrawElement,
): boolean => { ): boolean => {
if (isCustomElement(element)) {
return true;
}
if (element.type === "arrow") { if (element.type === "arrow") {
return false; return false;
} }
@@ -166,6 +174,7 @@ const hitTestPointAgainstElement = (args: HitTestArgs): boolean => {
case "text": case "text":
case "diamond": case "diamond":
case "ellipse": case "ellipse":
case "custom":
const distance = distanceToBindableElement(args.element, args.point); const distance = distanceToBindableElement(args.element, args.point);
return args.check(distance, args.threshold); return args.check(distance, args.threshold);
case "freedraw": { case "freedraw": {
@@ -199,6 +208,7 @@ export const distanceToBindableElement = (
case "rectangle": case "rectangle":
case "image": case "image":
case "text": case "text":
case "custom":
return distanceToRectangle(element, point); return distanceToRectangle(element, point);
case "diamond": case "diamond":
return distanceToDiamond(element, point); return distanceToDiamond(element, point);
@@ -228,7 +238,8 @@ const distanceToRectangle = (
| ExcalidrawRectangleElement | ExcalidrawRectangleElement
| ExcalidrawTextElement | ExcalidrawTextElement
| ExcalidrawFreeDrawElement | ExcalidrawFreeDrawElement
| ExcalidrawImageElement, | ExcalidrawImageElement
| ExcalidrawCustomElement,
point: Point, point: Point,
): number => { ): number => {
const [, pointRel, hwidth, hheight] = pointRelativeToElement(element, point); const [, pointRel, hwidth, hheight] = pointRelativeToElement(element, point);
@@ -504,6 +515,7 @@ export const determineFocusDistance = (
case "rectangle": case "rectangle":
case "image": case "image":
case "text": case "text":
case "custom":
return c / (hwidth * (nabs + q * mabs)); return c / (hwidth * (nabs + q * mabs));
case "diamond": case "diamond":
return mabs < nabs ? c / (nabs * hwidth) : c / (mabs * hheight); return mabs < nabs ? c / (nabs * hwidth) : c / (mabs * hheight);
@@ -536,6 +548,7 @@ export const determineFocusPoint = (
case "image": case "image":
case "text": case "text":
case "diamond": case "diamond":
case "custom":
point = findFocusPointForRectangulars(element, focus, adjecentPointRel); point = findFocusPointForRectangulars(element, focus, adjecentPointRel);
break; break;
case "ellipse": case "ellipse":
@@ -586,6 +599,7 @@ const getSortedElementLineIntersections = (
case "image": case "image":
case "text": case "text":
case "diamond": case "diamond":
case "custom":
const corners = getCorners(element); const corners = getCorners(element);
intersections = corners intersections = corners
.flatMap((point, i) => { .flatMap((point, i) => {
@@ -619,7 +633,8 @@ const getCorners = (
| ExcalidrawRectangleElement | ExcalidrawRectangleElement
| ExcalidrawImageElement | ExcalidrawImageElement
| ExcalidrawDiamondElement | ExcalidrawDiamondElement
| ExcalidrawTextElement, | ExcalidrawTextElement
| ExcalidrawCustomElement,
scale: number = 1, scale: number = 1,
): GA.Point[] => { ): GA.Point[] => {
const hx = (scale * element.width) / 2; const hx = (scale * element.width) / 2;
@@ -628,6 +643,7 @@ const getCorners = (
case "rectangle": case "rectangle":
case "image": case "image":
case "text": case "text":
case "custom":
return [ return [
GA.point(hx, hy), GA.point(hx, hy),
GA.point(hx, -hy), GA.point(hx, -hy),
@@ -770,7 +786,8 @@ export const findFocusPointForRectangulars = (
| ExcalidrawRectangleElement | ExcalidrawRectangleElement
| ExcalidrawImageElement | ExcalidrawImageElement
| ExcalidrawDiamondElement | ExcalidrawDiamondElement
| ExcalidrawTextElement, | ExcalidrawTextElement
| ExcalidrawCustomElement,
// Between -1 and 1 for how far away should the focus point be relative // Between -1 and 1 for how far away should the focus point be relative
// to the size of the element. Sign determines orientation. // to the size of the element. Sign determines orientation.
relativeDistance: number, relativeDistance: number,
+1 -1
View File
@@ -92,7 +92,7 @@ export const getDragOffsetXY = (
export const dragNewElement = ( export const dragNewElement = (
draggingElement: NonDeletedExcalidrawElement, draggingElement: NonDeletedExcalidrawElement,
elementType: AppState["elementType"], elementType: AppState["activeTool"]["type"],
originX: number, originX: number,
originY: number, originY: number,
x: number, x: number,
+14
View File
@@ -106,6 +106,20 @@ export const normalizeSVG = async (SVGString: string) => {
svg.setAttribute("xmlns", SVG_NS); svg.setAttribute("xmlns", SVG_NS);
} }
if (!svg.hasAttribute("width") || !svg.hasAttribute("height")) {
const viewBox = svg.getAttribute("viewBox");
let width = svg.getAttribute("width") || "50";
let height = svg.getAttribute("height") || "50";
if (viewBox) {
const match = viewBox.match(/\d+ +\d+ +(\d+) +(\d+)/);
if (match) {
[, width, height] = match;
}
}
svg.setAttribute("width", width);
svg.setAttribute("height", height);
}
return svg.outerHTML; return svg.outerHTML;
} }
}; };
+1 -1
View File
@@ -205,7 +205,7 @@ export class LinearElementEditor {
); );
// suggest bindings for first and last point if selected // suggest bindings for first and last point if selected
if (isBindingElement(element)) { if (isBindingElement(element, false)) {
const coords: { x: number; y: number }[] = []; const coords: { x: number; y: number }[] = [];
const firstSelectedIndex = selectedPointsIndices[0]; const firstSelectedIndex = selectedPointsIndices[0];
+1 -5
View File
@@ -2,11 +2,7 @@ import { duplicateElement } from "./newElement";
import { mutateElement } from "./mutateElement"; import { mutateElement } from "./mutateElement";
import { API } from "../tests/helpers/api"; import { API } from "../tests/helpers/api";
import { FONT_FAMILY } from "../constants"; import { FONT_FAMILY } from "../constants";
import { isPrimitive } from "../utils";
const isPrimitive = (val: any) => {
const type = typeof val;
return val == null || (type !== "object" && type !== "function");
};
const assertCloneObjects = (source: any, clone: any) => { const assertCloneObjects = (source: any, clone: any) => {
for (const key in clone) { for (const key in clone) {
+14
View File
@@ -12,6 +12,7 @@ import {
ExcalidrawFreeDrawElement, ExcalidrawFreeDrawElement,
FontFamilyValues, FontFamilyValues,
ExcalidrawRectangleElement, ExcalidrawRectangleElement,
ExcalidrawCustomElement,
} from "../element/types"; } from "../element/types";
import { getFontString, getUpdatedTimestamp, isTestEnv } from "../utils"; import { getFontString, getUpdatedTimestamp, isTestEnv } from "../utils";
import { randomInteger, randomId } from "../random"; import { randomInteger, randomId } from "../random";
@@ -56,6 +57,7 @@ const _newElementBase = <T extends ExcalidrawElement>(
strokeSharpness, strokeSharpness,
boundElements = null, boundElements = null,
link = null, link = null,
locked,
...rest ...rest
}: ElementConstructorOpts & Omit<Partial<ExcalidrawGenericElement>, "type">, }: ElementConstructorOpts & Omit<Partial<ExcalidrawGenericElement>, "type">,
) => { ) => {
@@ -83,6 +85,7 @@ const _newElementBase = <T extends ExcalidrawElement>(
boundElements, boundElements,
updated: getUpdatedTimestamp(), updated: getUpdatedTimestamp(),
link, link,
locked,
}; };
return element; return element;
}; };
@@ -318,6 +321,17 @@ export const newImageElement = (
}; };
}; };
export const newCustomElement = (
customType: string,
opts: {
type: ExcalidrawCustomElement["type"];
} & ElementConstructorOpts,
): NonDeleted<ExcalidrawCustomElement> => {
return {
..._newElementBase<ExcalidrawCustomElement>("custom", opts),
customType,
};
};
// Simplified deep clone for the purpose of cloning ExcalidrawElement only // Simplified deep clone for the purpose of cloning ExcalidrawElement only
// (doesn't clone Date, RegExp, Map, Set, Typed arrays etc.) // (doesn't clone Date, RegExp, Map, Set, Typed arrays etc.)
// //
+2 -1
View File
@@ -12,6 +12,7 @@ import {
ExcalidrawTextElement, ExcalidrawTextElement,
NonDeletedExcalidrawElement, NonDeletedExcalidrawElement,
NonDeleted, NonDeleted,
ExcalidrawElement,
} from "./types"; } from "./types";
import { import {
getElementAbsoluteCoords, getElementAbsoluteCoords,
@@ -186,7 +187,7 @@ const validateTwoPointElementNormalized = (
}; };
const getPerfectElementSizeWithRotation = ( const getPerfectElementSizeWithRotation = (
elementType: string, elementType: ExcalidrawElement["type"],
width: number, width: number,
height: number, height: number,
angle: number, angle: number,
+2 -2
View File
@@ -10,6 +10,6 @@ export const showSelectedShapeActions = (
!appState.viewModeEnabled && !appState.viewModeEnabled &&
(appState.editingElement || (appState.editingElement ||
getSelectedElements(elements, appState).length || getSelectedElements(elements, appState).length ||
(appState.elementType !== "selection" && (appState.activeTool.type !== "selection" &&
appState.elementType !== "eraser")), appState.activeTool.type !== "eraser")),
); );
+2 -1
View File
@@ -2,6 +2,7 @@ import { ExcalidrawElement } from "./types";
import { mutateElement } from "./mutateElement"; import { mutateElement } from "./mutateElement";
import { isFreeDrawElement, isLinearElement } from "./typeChecks"; import { isFreeDrawElement, isLinearElement } from "./typeChecks";
import { SHIFT_LOCKING_ANGLE } from "../constants"; import { SHIFT_LOCKING_ANGLE } from "../constants";
import { AppState } from "../types";
export const isInvisiblySmallElement = ( export const isInvisiblySmallElement = (
element: ExcalidrawElement, element: ExcalidrawElement,
@@ -16,7 +17,7 @@ export const isInvisiblySmallElement = (
* Makes a perfect shape or diagonal/horizontal/vertical line * Makes a perfect shape or diagonal/horizontal/vertical line
*/ */
export const getPerfectElementSize = ( export const getPerfectElementSize = (
elementType: string, elementType: AppState["activeTool"]["type"],
width: number, width: number,
height: number, height: number,
): { width: number; height: number } => { ): { width: number; height: number } => {
+3 -5
View File
@@ -10,13 +10,11 @@ import { mutateElement } from "./mutateElement";
import { BOUND_TEXT_PADDING, VERTICAL_ALIGN } from "../constants"; import { BOUND_TEXT_PADDING, VERTICAL_ALIGN } from "../constants";
import { MaybeTransformHandleType } from "./transformHandles"; import { MaybeTransformHandleType } from "./transformHandles";
import Scene from "../scene/Scene"; import Scene from "../scene/Scene";
import { AppState } from "../types";
import { isTextElement } from "."; import { isTextElement } from ".";
export const redrawTextBoundingBox = ( export const redrawTextBoundingBox = (
element: ExcalidrawTextElement, element: ExcalidrawTextElement,
container: ExcalidrawElement | null, container: ExcalidrawElement | null,
appState: AppState,
) => { ) => {
const maxWidth = container const maxWidth = container
? container.width - BOUND_TEXT_PADDING * 2 ? container.width - BOUND_TEXT_PADDING * 2
@@ -35,12 +33,12 @@ export const redrawTextBoundingBox = (
getFontString(element), getFontString(element),
maxWidth, maxWidth,
); );
let coordY = element.y; let coordY = element.y;
let coordX = element.x;
// Resize container and vertically center align the text // Resize container and vertically center align the text
if (container) { if (container) {
let nextHeight = container.height; let nextHeight = container.height;
coordX = container.x + BOUND_TEXT_PADDING;
if (element.verticalAlign === VERTICAL_ALIGN.TOP) { if (element.verticalAlign === VERTICAL_ALIGN.TOP) {
coordY = container.y + BOUND_TEXT_PADDING; coordY = container.y + BOUND_TEXT_PADDING;
} else if (element.verticalAlign === VERTICAL_ALIGN.BOTTOM) { } else if (element.verticalAlign === VERTICAL_ALIGN.BOTTOM) {
@@ -55,12 +53,12 @@ export const redrawTextBoundingBox = (
} }
mutateElement(container, { height: nextHeight }); mutateElement(container, { height: nextHeight });
} }
mutateElement(element, { mutateElement(element, {
width: metrics.width, width: metrics.width,
height: metrics.height, height: metrics.height,
baseline: metrics.baseline, baseline: metrics.baseline,
y: coordY, y: coordY,
x: coordX,
text, text,
}); });
}; };
+66 -1
View File
@@ -544,6 +544,29 @@ describe("textWysiwyg", () => {
expect((h.elements[1] as ExcalidrawTextElement).containerId).toBe(null); expect((h.elements[1] as ExcalidrawTextElement).containerId).toBe(null);
}); });
it("should'nt bind text to container when not double clicked on center", async () => {
expect(h.elements.length).toBe(1);
expect(h.elements[0].id).toBe(rectangle.id);
// clicking somewhere on top left
mouse.doubleClickAt(rectangle.x + 20, rectangle.y + 20);
expect(h.elements.length).toBe(2);
const text = h.elements[1] as ExcalidrawTextElementWithContainer;
expect(text.type).toBe("text");
expect(text.containerId).toBe(null);
mouse.down();
const editor = document.querySelector(
".excalidraw-textEditorContainer > textarea",
) as HTMLTextAreaElement;
fireEvent.change(editor, { target: { value: "Hello World!" } });
await new Promise((r) => setTimeout(r, 0));
editor.blur();
expect(rectangle.boundElements).toBe(null);
});
it("should update font family correctly on undo/redo by selecting bounded text when font family was updated", async () => { it("should update font family correctly on undo/redo by selecting bounded text when font family was updated", async () => {
expect(h.elements.length).toBe(1); expect(h.elements.length).toBe(1);
@@ -704,7 +727,7 @@ describe("textWysiwyg", () => {
expect(text.width).toBe(rectangle.width - BOUND_TEXT_PADDING * 2); expect(text.width).toBe(rectangle.width - BOUND_TEXT_PADDING * 2);
}); });
it("should unbind bound text when unbind action from context menu is triggred", async () => { it("should unbind bound text when unbind action from context menu is triggered", async () => {
expect(h.elements.length).toBe(1); expect(h.elements.length).toBe(1);
expect(h.elements[0].id).toBe(rectangle.id); expect(h.elements[0].id).toBe(rectangle.id);
@@ -745,5 +768,47 @@ describe("textWysiwyg", () => {
null, null,
); );
}); });
it("shouldn't bind to container if container has bound text", async () => {
expect(h.elements.length).toBe(1);
Keyboard.withModifierKeys({}, () => {
Keyboard.keyPress(KEYS.ENTER);
});
expect(h.elements.length).toBe(2);
// Bind first text
let text = h.elements[1] as ExcalidrawTextElementWithContainer;
expect(text.containerId).toBe(rectangle.id);
let editor = document.querySelector(
".excalidraw-textEditorContainer > textarea",
) as HTMLTextAreaElement;
await new Promise((r) => setTimeout(r, 0));
fireEvent.change(editor, { target: { value: "Hello World!" } });
editor.blur();
expect(rectangle.boundElements).toStrictEqual([
{ id: text.id, type: "text" },
]);
// Attempt to bind another text
UI.clickTool("text");
mouse.clickAt(
rectangle.x + rectangle.width / 2,
rectangle.y + rectangle.height / 2,
);
mouse.down();
expect(h.elements.length).toBe(3);
text = h.elements[2] as ExcalidrawTextElementWithContainer;
editor = document.querySelector(
".excalidraw-textEditorContainer > textarea",
) as HTMLTextAreaElement;
await new Promise((r) => setTimeout(r, 0));
fireEvent.change(editor, { target: { value: "Whats up?" } });
editor.blur();
expect(rectangle.boundElements).toStrictEqual([
{ id: h.elements[1].id, type: "text" },
]);
expect(text.containerId).toBe(null);
});
}); });
}); });
+13 -7
View File
@@ -102,9 +102,11 @@ export const textWysiwyg = ({
const updateWysiwygStyle = () => { const updateWysiwygStyle = () => {
const appState = app.state; const appState = app.state;
const updatedElement = Scene.getScene(element)?.getElement( const updatedElement =
id, Scene.getScene(element)?.getElement<ExcalidrawTextElement>(id);
) as ExcalidrawTextElement; if (!updatedElement) {
return;
}
const { textAlign, verticalAlign } = updatedElement; const { textAlign, verticalAlign } = updatedElement;
const approxLineHeight = getApproxLineHeight(getFontString(updatedElement)); const approxLineHeight = getApproxLineHeight(getFontString(updatedElement));
@@ -281,7 +283,14 @@ export const textWysiwyg = ({
// using scrollHeight here since we need to calculate // using scrollHeight here since we need to calculate
// number of lines so cannot use editable.style.height // number of lines so cannot use editable.style.height
// as that gets updated below // as that gets updated below
const lines = editable.scrollHeight / getApproxLineHeight(font); // Rounding here so that the lines calculated is more accurate in all browsers.
// The scrollHeight and approxLineHeight differs in diff browsers
// eg it gives 1.05 in firefox for handewritten small font due to which
// height gets updated as lines > 1 and leads to jumping text for first line in bound container
// hence rounding here to avoid that
const lines = Math.round(
editable.scrollHeight / getApproxLineHeight(font),
);
// auto increase height only when lines > 1 so its // auto increase height only when lines > 1 so its
// measured correctly and vertically aligns for // measured correctly and vertically aligns for
// first line as well as setting height to "auto" // first line as well as setting height to "auto"
@@ -296,7 +305,6 @@ export const textWysiwyg = ({
font, font,
container!.width, container!.width,
).split("\n").length; ).split("\n").length;
// This is browser behaviour when setting height to "auto" // This is browser behaviour when setting height to "auto"
// It sets the height needed for 2 lines even if actual // It sets the height needed for 2 lines even if actual
// line count is 1 as mentioned above as well // line count is 1 as mentioned above as well
@@ -314,8 +322,6 @@ export const textWysiwyg = ({
} }
editable.onkeydown = (event) => { editable.onkeydown = (event) => {
event.stopPropagation();
if (!event.shiftKey && actionZoomIn.keyTest(event)) { if (!event.shiftKey && actionZoomIn.keyTest(event)) {
event.preventDefault(); event.preventDefault();
app.actionManager.executeAction(actionZoomIn); app.actionManager.executeAction(actionZoomIn);
+7
View File
@@ -222,6 +222,13 @@ export const getTransformHandles = (
zoom: Zoom, zoom: Zoom,
pointerType: PointerType = "mouse", pointerType: PointerType = "mouse",
): TransformHandles => { ): TransformHandles => {
// so that when locked element is selected (especially when you toggle lock
// via keyboard) the locked element is visually distinct, indicating
// you can't move/resize
if (element.locked) {
return {};
}
let omitSides: { [T in TransformHandleType]?: boolean } = {}; let omitSides: { [T in TransformHandleType]?: boolean } = {};
if ( if (
element.type === "arrow" || element.type === "arrow" ||
+17 -3
View File
@@ -10,6 +10,7 @@ import {
ExcalidrawImageElement, ExcalidrawImageElement,
ExcalidrawTextElementWithContainer, ExcalidrawTextElementWithContainer,
ExcalidrawTextContainer, ExcalidrawTextContainer,
ExcalidrawCustomElement,
} from "./types"; } from "./types";
export const isGenericElement = ( export const isGenericElement = (
@@ -61,7 +62,7 @@ export const isLinearElement = (
}; };
export const isLinearElementType = ( export const isLinearElementType = (
elementType: AppState["elementType"], elementType: AppState["activeTool"]["type"],
): boolean => { ): boolean => {
return ( return (
elementType === "arrow" || elementType === "line" // || elementType === "freedraw" elementType === "arrow" || elementType === "line" // || elementType === "freedraw"
@@ -70,21 +71,28 @@ export const isLinearElementType = (
export const isBindingElement = ( export const isBindingElement = (
element?: ExcalidrawElement | null, element?: ExcalidrawElement | null,
includeLocked = true,
): element is ExcalidrawLinearElement => { ): element is ExcalidrawLinearElement => {
return element != null && isBindingElementType(element.type); return (
element != null &&
(!element.locked || includeLocked === true) &&
isBindingElementType(element.type)
);
}; };
export const isBindingElementType = ( export const isBindingElementType = (
elementType: AppState["elementType"], elementType: AppState["activeTool"]["type"],
): boolean => { ): boolean => {
return elementType === "arrow"; return elementType === "arrow";
}; };
export const isBindableElement = ( export const isBindableElement = (
element: ExcalidrawElement | null, element: ExcalidrawElement | null,
includeLocked = true,
): element is ExcalidrawBindableElement => { ): element is ExcalidrawBindableElement => {
return ( return (
element != null && element != null &&
(!element.locked || includeLocked === true) &&
(element.type === "rectangle" || (element.type === "rectangle" ||
element.type === "diamond" || element.type === "diamond" ||
element.type === "ellipse" || element.type === "ellipse" ||
@@ -95,9 +103,11 @@ export const isBindableElement = (
export const isTextBindableContainer = ( export const isTextBindableContainer = (
element: ExcalidrawElement | null, element: ExcalidrawElement | null,
includeLocked = true,
): element is ExcalidrawTextContainer => { ): element is ExcalidrawTextContainer => {
return ( return (
element != null && element != null &&
(!element.locked || includeLocked === true) &&
(element.type === "rectangle" || (element.type === "rectangle" ||
element.type === "diamond" || element.type === "diamond" ||
element.type === "ellipse" || element.type === "ellipse" ||
@@ -133,3 +143,7 @@ export const isBoundToContainer = (
element !== null && isTextElement(element) && element.containerId !== null element !== null && isTextElement(element) && element.containerId !== null
); );
}; };
export const isCustomElement = (
element: ExcalidrawElement,
): element is ExcalidrawCustomElement => element && element.type === "custom";
+8 -2
View File
@@ -55,6 +55,7 @@ type _ExcalidrawElementBase = Readonly<{
/** epoch (ms) timestamp of last element update */ /** epoch (ms) timestamp of last element update */
updated: number; updated: number;
link: string | null; link: string | null;
locked: boolean;
}>; }>;
export type ExcalidrawSelectionElement = _ExcalidrawElementBase & { export type ExcalidrawSelectionElement = _ExcalidrawElementBase & {
@@ -83,6 +84,9 @@ export type ExcalidrawImageElement = _ExcalidrawElementBase &
scale: [number, number]; scale: [number, number];
}>; }>;
export type ExcalidrawCustomElement = _ExcalidrawElementBase &
Readonly<{ type: "custom"; customType: string }>;
export type InitializedExcalidrawImageElement = MarkNonNullable< export type InitializedExcalidrawImageElement = MarkNonNullable<
ExcalidrawImageElement, ExcalidrawImageElement,
"fileId" "fileId"
@@ -107,7 +111,8 @@ export type ExcalidrawElement =
| ExcalidrawTextElement | ExcalidrawTextElement
| ExcalidrawLinearElement | ExcalidrawLinearElement
| ExcalidrawFreeDrawElement | ExcalidrawFreeDrawElement
| ExcalidrawImageElement; | ExcalidrawImageElement
| ExcalidrawCustomElement;
export type NonDeleted<TElement extends ExcalidrawElement> = TElement & { export type NonDeleted<TElement extends ExcalidrawElement> = TElement & {
isDeleted: boolean; isDeleted: boolean;
@@ -133,7 +138,8 @@ export type ExcalidrawBindableElement =
| ExcalidrawDiamondElement | ExcalidrawDiamondElement
| ExcalidrawEllipseElement | ExcalidrawEllipseElement
| ExcalidrawTextElement | ExcalidrawTextElement
| ExcalidrawImageElement; | ExcalidrawImageElement
| ExcalidrawCustomElement;
export type ExcalidrawTextContainer = export type ExcalidrawTextContainer =
| ExcalidrawRectangleElement | ExcalidrawRectangleElement
+2 -2
View File
@@ -11,12 +11,12 @@ export const FILE_UPLOAD_MAX_BYTES = 3 * 1024 * 1024; // 3 MiB
// 1 year (https://stackoverflow.com/a/25201898/927631) // 1 year (https://stackoverflow.com/a/25201898/927631)
export const FILE_CACHE_MAX_AGE_SEC = 31536000; export const FILE_CACHE_MAX_AGE_SEC = 31536000;
export const BROADCAST = { export const WS_EVENTS = {
SERVER_VOLATILE: "server-volatile-broadcast", SERVER_VOLATILE: "server-volatile-broadcast",
SERVER: "server-broadcast", SERVER: "server-broadcast",
}; };
export enum SCENE { export enum WS_SCENE_EVENT_TYPES {
INIT = "SCENE_INIT", INIT = "SCENE_INIT",
UPDATE = "SCENE_UPDATE", UPDATE = "SCENE_UPDATE",
} }
+54 -22
View File
@@ -11,6 +11,7 @@ import {
import { getSceneVersion } from "../../packages/excalidraw/index"; import { getSceneVersion } from "../../packages/excalidraw/index";
import { Collaborator, Gesture } from "../../types"; import { Collaborator, Gesture } from "../../types";
import { import {
getFrame,
preventUnload, preventUnload,
resolvablePromise, resolvablePromise,
withBatchedUpdates, withBatchedUpdates,
@@ -21,7 +22,7 @@ import {
FIREBASE_STORAGE_PREFIXES, FIREBASE_STORAGE_PREFIXES,
INITIAL_SCENE_UPDATE_TIMEOUT, INITIAL_SCENE_UPDATE_TIMEOUT,
LOAD_IMAGES_TIMEOUT, LOAD_IMAGES_TIMEOUT,
SCENE, WS_SCENE_EVENT_TYPES,
STORAGE_KEYS, STORAGE_KEYS,
SYNC_FULL_SCENE_INTERVAL_MS, SYNC_FULL_SCENE_INTERVAL_MS,
} from "../app_constants"; } from "../app_constants";
@@ -67,6 +68,7 @@ import {
} from "./reconciliation"; } from "./reconciliation";
import { decryptData } from "../../data/encryption"; import { decryptData } from "../../data/encryption";
import { resetBrowserStateVersions } from "../data/tabSync"; import { resetBrowserStateVersions } from "../data/tabSync";
import { LocalData } from "../data/LocalData";
interface CollabState { interface CollabState {
modalIsShown: boolean; modalIsShown: boolean;
@@ -86,7 +88,7 @@ export interface CollabAPI {
onPointerUpdate: CollabInstance["onPointerUpdate"]; onPointerUpdate: CollabInstance["onPointerUpdate"];
initializeSocketClient: CollabInstance["initializeSocketClient"]; initializeSocketClient: CollabInstance["initializeSocketClient"];
onCollabButtonClick: CollabInstance["onCollabButtonClick"]; onCollabButtonClick: CollabInstance["onCollabButtonClick"];
broadcastElements: CollabInstance["broadcastElements"]; syncElements: CollabInstance["syncElements"];
fetchImageFilesFromFirebase: CollabInstance["fetchImageFilesFromFirebase"]; fetchImageFilesFromFirebase: CollabInstance["fetchImageFilesFromFirebase"];
setUsername: (username: string) => void; setUsername: (username: string) => void;
} }
@@ -108,10 +110,11 @@ class CollabWrapper extends PureComponent<Props, CollabState> {
portal: Portal; portal: Portal;
fileManager: FileManager; fileManager: FileManager;
excalidrawAPI: Props["excalidrawAPI"]; excalidrawAPI: Props["excalidrawAPI"];
isCollaborating: boolean = false;
activeIntervalId: number | null; activeIntervalId: number | null;
idleTimeoutId: number | null; idleTimeoutId: number | null;
// marked as private to ensure we don't change it outside this class
private _isCollaborating: boolean = false;
private socketInitializationTimer?: number; private socketInitializationTimer?: number;
private lastBroadcastedOrReceivedSceneVersion: number = -1; private lastBroadcastedOrReceivedSceneVersion: number = -1;
private collaborators = new Map<string, Collaborator>(); private collaborators = new Map<string, Collaborator>();
@@ -192,6 +195,8 @@ class CollabWrapper extends PureComponent<Props, CollabState> {
} }
} }
isCollaborating = () => this._isCollaborating;
private onUnload = () => { private onUnload = () => {
this.destroySocketClient({ isUnload: true }); this.destroySocketClient({ isUnload: true });
}; };
@@ -202,7 +207,7 @@ class CollabWrapper extends PureComponent<Props, CollabState> {
); );
if ( if (
this.isCollaborating && this._isCollaborating &&
(this.fileManager.shouldPreventUnload(syncableElements) || (this.fileManager.shouldPreventUnload(syncableElements) ||
!isSavedToFirebase(this.portal, syncableElements)) !isSavedToFirebase(this.portal, syncableElements))
) { ) {
@@ -227,27 +232,40 @@ class CollabWrapper extends PureComponent<Props, CollabState> {
}); });
saveCollabRoomToFirebase = async ( saveCollabRoomToFirebase = async (
syncableElements: readonly ExcalidrawElement[] = this.getSyncableElements( syncableElements: readonly ExcalidrawElement[],
this.excalidrawAPI.getSceneElementsIncludingDeleted(),
),
) => { ) => {
try { try {
await saveToFirebase(this.portal, syncableElements); const savedData = await saveToFirebase(
this.portal,
syncableElements,
this.excalidrawAPI.getAppState(),
);
if (this.isCollaborating() && savedData && savedData.reconciledElements) {
this.handleRemoteSceneUpdate(
this.reconcileElements(savedData.reconciledElements),
);
}
} catch (error: any) { } catch (error: any) {
console.error(error); console.error(error);
} }
}; };
openPortal = async () => { openPortal = async () => {
trackEvent("share", "room creation"); trackEvent("share", "room creation", `ui (${getFrame()})`);
return this.initializeSocketClient(null); return this.initializeSocketClient(null);
}; };
closePortal = () => { closePortal = () => {
this.queueBroadcastAllElements.cancel(); this.queueBroadcastAllElements.cancel();
this.queueSaveToFirebase.cancel();
this.loadImageFiles.cancel(); this.loadImageFiles.cancel();
this.saveCollabRoomToFirebase(); this.saveCollabRoomToFirebase(
this.getSyncableElements(
this.excalidrawAPI.getSceneElementsIncludingDeleted(),
),
);
if (window.confirm(t("alerts.collabStopOverridePrompt"))) { if (window.confirm(t("alerts.collabStopOverridePrompt"))) {
// hack to ensure that we prefer we disregard any new browser state // hack to ensure that we prefer we disregard any new browser state
// that could have been saved in other tabs while we were collaborating // that could have been saved in other tabs while we were collaborating
@@ -284,7 +302,8 @@ class CollabWrapper extends PureComponent<Props, CollabState> {
this.setState({ this.setState({
activeRoomLink: "", activeRoomLink: "",
}); });
this.isCollaborating = false; this._isCollaborating = false;
LocalData.resumeSave("collaboration");
} }
this.lastBroadcastedOrReceivedSceneVersion = -1; this.lastBroadcastedOrReceivedSceneVersion = -1;
this.portal.close(); this.portal.close();
@@ -352,7 +371,8 @@ class CollabWrapper extends PureComponent<Props, CollabState> {
const scenePromise = resolvablePromise<ImportedDataState | null>(); const scenePromise = resolvablePromise<ImportedDataState | null>();
this.isCollaborating = true; this._isCollaborating = true;
LocalData.pauseSave("collaboration");
const { default: socketIOClient } = await import( const { default: socketIOClient } = await import(
/* webpackChunkName: "socketIoClient" */ "socket.io-client" /* webpackChunkName: "socketIoClient" */ "socket.io-client"
@@ -393,10 +413,7 @@ class CollabWrapper extends PureComponent<Props, CollabState> {
commitToHistory: true, commitToHistory: true,
}); });
this.broadcastElements(elements); this.saveCollabRoomToFirebase(this.getSyncableElements(elements));
const syncableElements = this.getSyncableElements(elements);
this.saveCollabRoomToFirebase(syncableElements);
} }
// fallback in case you're not alone in the room but still don't receive // fallback in case you're not alone in the room but still don't receive
@@ -426,7 +443,7 @@ class CollabWrapper extends PureComponent<Props, CollabState> {
switch (decryptedData.type) { switch (decryptedData.type) {
case "INVALID_RESPONSE": case "INVALID_RESPONSE":
return; return;
case SCENE.INIT: { case WS_SCENE_EVENT_TYPES.INIT: {
if (!this.portal.socketInitialized) { if (!this.portal.socketInitialized) {
this.initializeRoom({ fetchScene: false }); this.initializeRoom({ fetchScene: false });
const remoteElements = decryptedData.payload.elements; const remoteElements = decryptedData.payload.elements;
@@ -442,7 +459,7 @@ class CollabWrapper extends PureComponent<Props, CollabState> {
} }
break; break;
} }
case SCENE.UPDATE: case WS_SCENE_EVENT_TYPES.UPDATE:
this.handleRemoteSceneUpdate( this.handleRemoteSceneUpdate(
this.reconcileElements(decryptedData.payload.elements), this.reconcileElements(decryptedData.payload.elements),
); );
@@ -704,15 +721,20 @@ class CollabWrapper extends PureComponent<Props, CollabState> {
getSceneVersion(elements) > getSceneVersion(elements) >
this.getLastBroadcastedOrReceivedSceneVersion() this.getLastBroadcastedOrReceivedSceneVersion()
) { ) {
this.portal.broadcastScene(SCENE.UPDATE, elements, false); this.portal.broadcastScene(WS_SCENE_EVENT_TYPES.UPDATE, elements, false);
this.lastBroadcastedOrReceivedSceneVersion = getSceneVersion(elements); this.lastBroadcastedOrReceivedSceneVersion = getSceneVersion(elements);
this.queueBroadcastAllElements(); this.queueBroadcastAllElements();
} }
}; };
syncElements = (elements: readonly ExcalidrawElement[]) => {
this.broadcastElements(elements);
this.queueSaveToFirebase();
};
queueBroadcastAllElements = throttle(() => { queueBroadcastAllElements = throttle(() => {
this.portal.broadcastScene( this.portal.broadcastScene(
SCENE.UPDATE, WS_SCENE_EVENT_TYPES.UPDATE,
this.excalidrawAPI.getSceneElementsIncludingDeleted(), this.excalidrawAPI.getSceneElementsIncludingDeleted(),
true, true,
); );
@@ -724,6 +746,16 @@ class CollabWrapper extends PureComponent<Props, CollabState> {
this.setLastBroadcastedOrReceivedSceneVersion(newVersion); this.setLastBroadcastedOrReceivedSceneVersion(newVersion);
}, SYNC_FULL_SCENE_INTERVAL_MS); }, SYNC_FULL_SCENE_INTERVAL_MS);
queueSaveToFirebase = throttle(() => {
if (this.portal.socketInitialized) {
this.saveCollabRoomToFirebase(
this.getSyncableElements(
this.excalidrawAPI.getSceneElementsIncludingDeleted(),
),
);
}
}, SYNC_FULL_SCENE_INTERVAL_MS);
handleClose = () => { handleClose = () => {
this.setState({ modalIsShown: false }); this.setState({ modalIsShown: false });
}; };
@@ -759,12 +791,12 @@ class CollabWrapper extends PureComponent<Props, CollabState> {
this.contextValue = {} as CollabAPI; this.contextValue = {} as CollabAPI;
} }
this.contextValue.isCollaborating = () => this.isCollaborating; this.contextValue.isCollaborating = this.isCollaborating;
this.contextValue.username = this.state.username; this.contextValue.username = this.state.username;
this.contextValue.onPointerUpdate = this.onPointerUpdate; this.contextValue.onPointerUpdate = this.onPointerUpdate;
this.contextValue.initializeSocketClient = this.initializeSocketClient; this.contextValue.initializeSocketClient = this.initializeSocketClient;
this.contextValue.onCollabButtonClick = this.onCollabButtonClick; this.contextValue.onCollabButtonClick = this.onCollabButtonClick;
this.contextValue.broadcastElements = this.broadcastElements; this.contextValue.syncElements = this.syncElements;
this.contextValue.fetchImageFilesFromFirebase = this.contextValue.fetchImageFilesFromFirebase =
this.fetchImageFilesFromFirebase; this.fetchImageFilesFromFirebase;
this.contextValue.setUsername = this.setUsername; this.contextValue.setUsername = this.setUsername;
+12 -19
View File
@@ -3,7 +3,11 @@ import { SocketUpdateData, SocketUpdateDataSource } from "../data";
import CollabWrapper from "./CollabWrapper"; import CollabWrapper from "./CollabWrapper";
import { ExcalidrawElement } from "../../element/types"; import { ExcalidrawElement } from "../../element/types";
import { BROADCAST, FILE_UPLOAD_TIMEOUT, SCENE } from "../app_constants"; import {
WS_EVENTS,
FILE_UPLOAD_TIMEOUT,
WS_SCENE_EVENT_TYPES,
} from "../app_constants";
import { UserIdleState } from "../../types"; import { UserIdleState } from "../../types";
import { trackEvent } from "../../analytics"; import { trackEvent } from "../../analytics";
import { throttle } from "lodash"; import { throttle } from "lodash";
@@ -37,7 +41,7 @@ class Portal {
}); });
this.socket.on("new-user", async (_socketId: string) => { this.socket.on("new-user", async (_socketId: string) => {
this.broadcastScene( this.broadcastScene(
SCENE.INIT, WS_SCENE_EVENT_TYPES.INIT,
this.collab.getSceneElementsIncludingDeleted(), this.collab.getSceneElementsIncludingDeleted(),
/* syncAll */ true, /* syncAll */ true,
); );
@@ -81,7 +85,7 @@ class Portal {
const { encryptedBuffer, iv } = await encryptData(this.roomKey!, encoded); const { encryptedBuffer, iv } = await encryptData(this.roomKey!, encoded);
this.socket?.emit( this.socket?.emit(
volatile ? BROADCAST.SERVER_VOLATILE : BROADCAST.SERVER, volatile ? WS_EVENTS.SERVER_VOLATILE : WS_EVENTS.SERVER,
this.roomId, this.roomId,
encryptedBuffer, encryptedBuffer,
iv, iv,
@@ -121,11 +125,11 @@ class Portal {
}, FILE_UPLOAD_TIMEOUT); }, FILE_UPLOAD_TIMEOUT);
broadcastScene = async ( broadcastScene = async (
sceneType: SCENE.INIT | SCENE.UPDATE, updateType: WS_SCENE_EVENT_TYPES.INIT | WS_SCENE_EVENT_TYPES.UPDATE,
allElements: readonly ExcalidrawElement[], allElements: readonly ExcalidrawElement[],
syncAll: boolean, syncAll: boolean,
) => { ) => {
if (sceneType === SCENE.INIT && !syncAll) { if (updateType === WS_SCENE_EVENT_TYPES.INIT && !syncAll) {
throw new Error("syncAll must be true when sending SCENE.INIT"); throw new Error("syncAll must be true when sending SCENE.INIT");
} }
@@ -152,8 +156,8 @@ class Portal {
[] as BroadcastedExcalidrawElement[], [] as BroadcastedExcalidrawElement[],
); );
const data: SocketUpdateDataSource[typeof sceneType] = { const data: SocketUpdateDataSource[typeof updateType] = {
type: sceneType, type: updateType,
payload: { payload: {
elements: syncableElements, elements: syncableElements,
}, },
@@ -166,20 +170,9 @@ class Portal {
); );
} }
const broadcastPromise = this._broadcastSocketData(
data as SocketUpdateData,
);
this.queueFileUpload(); this.queueFileUpload();
if (syncAll && this.collab.isCollaborating) { await this._broadcastSocketData(data as SocketUpdateData);
await Promise.all([
broadcastPromise,
this.collab.saveCollabRoomToFirebase(syncableElements),
]);
} else {
await broadcastPromise;
}
}; };
broadcastIdleChange = (userState: UserIdleState) => { broadcastIdleChange = (userState: UserIdleState) => {
+7 -1
View File
@@ -78,8 +78,14 @@ export const reconcileElements = (
continue; continue;
} }
// Mark duplicate for removal as it'll be replaced with the remote element
if (local) { if (local) {
// mark for removal since it'll be replaced with the remote element // Unless the ramote and local elements are the same element in which case
// we need to keep it as we'd otherwise discard it from the resulting
// array.
if (local[0] === remoteElement) {
continue;
}
duplicates.set(local[0], true); duplicates.set(local[0], true);
} }
@@ -13,6 +13,8 @@ import { isInitializedImageElement } from "../../element/typeChecks";
import { FILE_UPLOAD_MAX_BYTES } from "../app_constants"; import { FILE_UPLOAD_MAX_BYTES } from "../app_constants";
import { encodeFilesForUpload } from "../data/FileManager"; import { encodeFilesForUpload } from "../data/FileManager";
import { MIME_TYPES } from "../../constants"; import { MIME_TYPES } from "../../constants";
import { trackEvent } from "../../analytics";
import { getFrame } from "../../utils";
const exportToExcalidrawPlus = async ( const exportToExcalidrawPlus = async (
elements: readonly NonDeletedExcalidrawElement[], elements: readonly NonDeletedExcalidrawElement[],
@@ -92,6 +94,7 @@ export const ExportToExcalidrawPlus: React.FC<{
showAriaLabel={true} showAriaLabel={true}
onClick={async () => { onClick={async () => {
try { try {
trackEvent("export", "eplus", `ui (${getFrame()})`);
await exportToExcalidrawPlus(elements, appState, files); await exportToExcalidrawPlus(elements, appState, files);
} catch (error: any) { } catch (error: any) {
console.error(error); console.error(error);
+154
View File
@@ -0,0 +1,154 @@
/**
* This file deals with saving data state (appState, elements, images, ...)
* locally to the browser.
*
* Notes:
*
* - DataState refers to full state of the app: appState, elements, images,
* though some state is saved separately (collab username, library) for one
* reason or another. We also save different data to different sotrage
* (localStorage, indexedDB).
*/
import { createStore, keys, del, getMany, set } from "idb-keyval";
import { clearAppStateForLocalStorage } from "../../appState";
import { clearElementsForLocalStorage } from "../../element";
import { ExcalidrawElement, FileId } from "../../element/types";
import { AppState, BinaryFileData, BinaryFiles } from "../../types";
import { debounce } from "../../utils";
import { SAVE_TO_LOCAL_STORAGE_TIMEOUT, STORAGE_KEYS } from "../app_constants";
import { FileManager } from "./FileManager";
import { Locker } from "./Locker";
import { updateBrowserStateVersion } from "./tabSync";
const filesStore = createStore("files-db", "files-store");
class LocalFileManager extends FileManager {
clearObsoleteFiles = async (opts: { currentFileIds: FileId[] }) => {
const allIds = await keys(filesStore);
for (const id of allIds) {
if (!opts.currentFileIds.includes(id as FileId)) {
del(id, filesStore);
}
}
};
}
const saveDataStateToLocalStorage = (
elements: readonly ExcalidrawElement[],
appState: AppState,
) => {
try {
localStorage.setItem(
STORAGE_KEYS.LOCAL_STORAGE_ELEMENTS,
JSON.stringify(clearElementsForLocalStorage(elements)),
);
localStorage.setItem(
STORAGE_KEYS.LOCAL_STORAGE_APP_STATE,
JSON.stringify(clearAppStateForLocalStorage(appState)),
);
updateBrowserStateVersion(STORAGE_KEYS.VERSION_DATA_STATE);
} catch (error: any) {
// Unable to access window.localStorage
console.error(error);
}
};
type SavingLockTypes = "collaboration";
export class LocalData {
private static _save = debounce(
async (
elements: readonly ExcalidrawElement[],
appState: AppState,
files: BinaryFiles,
onFilesSaved: () => void,
) => {
saveDataStateToLocalStorage(elements, appState);
await this.fileStorage.saveFiles({
elements,
files,
});
onFilesSaved();
},
SAVE_TO_LOCAL_STORAGE_TIMEOUT,
);
/** Saves DataState, including files. Bails if saving is paused */
static save = (
elements: readonly ExcalidrawElement[],
appState: AppState,
files: BinaryFiles,
onFilesSaved: () => void,
) => {
// we need to make the `isSavePaused` check synchronously (undebounced)
if (!this.isSavePaused()) {
this._save(elements, appState, files, onFilesSaved);
}
};
static flushSave = () => {
this._save.flush();
};
private static locker = new Locker<SavingLockTypes>();
static pauseSave = (lockType: SavingLockTypes) => {
this.locker.lock(lockType);
};
static resumeSave = (lockType: SavingLockTypes) => {
this.locker.unlock(lockType);
};
static isSavePaused = () => {
return document.hidden || this.locker.isLocked();
};
// ---------------------------------------------------------------------------
static fileStorage = new LocalFileManager({
getFiles(ids) {
return getMany(ids, filesStore).then(
(filesData: (BinaryFileData | undefined)[]) => {
const loadedFiles: BinaryFileData[] = [];
const erroredFiles = new Map<FileId, true>();
filesData.forEach((data, index) => {
const id = ids[index];
if (data) {
loadedFiles.push(data);
} else {
erroredFiles.set(id, true);
}
});
return { loadedFiles, erroredFiles };
},
);
},
async saveFiles({ addedFiles }) {
const savedFiles = new Map<FileId, true>();
const erroredFiles = new Map<FileId, true>();
// before we use `storage` event synchronization, let's update the flag
// optimistically. Hopefully nothing fails, and an IDB read executed
// before an IDB write finishes will read the latest value.
updateBrowserStateVersion(STORAGE_KEYS.VERSION_FILES);
await Promise.all(
[...addedFiles].map(async ([id, fileData]) => {
try {
await set(id, fileData, filesStore);
savedFiles.set(id, true);
} catch (error: any) {
console.error(error);
erroredFiles.set(id, true);
}
}),
);
return { savedFiles, erroredFiles };
},
});
}
+18
View File
@@ -0,0 +1,18 @@
export class Locker<T extends string> {
private locks = new Map<T, true>();
lock = (lockType: T) => {
this.locks.set(lockType, true);
};
/** @returns whether no locks remaining */
unlock = (lockType: T) => {
this.locks.delete(lockType);
return !this.isLocked();
};
/** @returns whether some (or specific) locks are present */
isLocked(lockType?: T) {
return lockType ? this.locks.has(lockType) : !!this.locks.size;
}
}
+81 -47
View File
@@ -2,11 +2,17 @@ import { ExcalidrawElement, FileId } from "../../element/types";
import { getSceneVersion } from "../../element"; import { getSceneVersion } from "../../element";
import Portal from "../collab/Portal"; import Portal from "../collab/Portal";
import { restoreElements } from "../../data/restore"; import { restoreElements } from "../../data/restore";
import { BinaryFileData, BinaryFileMetadata, DataURL } from "../../types"; import {
AppState,
BinaryFileData,
BinaryFileMetadata,
DataURL,
} from "../../types";
import { FILE_CACHE_MAX_AGE_SEC } from "../app_constants"; import { FILE_CACHE_MAX_AGE_SEC } from "../app_constants";
import { decompressData } from "../../data/encode"; import { decompressData } from "../../data/encode";
import { encryptData, decryptData } from "../../data/encryption"; import { encryptData, decryptData } from "../../data/encryption";
import { MIME_TYPES } from "../../constants"; import { MIME_TYPES } from "../../constants";
import { reconcileElements } from "../collab/reconciliation";
// private // private
// ----------------------------------------------------------------------------- // -----------------------------------------------------------------------------
@@ -108,11 +114,13 @@ const encryptElements = async (
}; };
const decryptElements = async ( const decryptElements = async (
key: string, data: FirebaseStoredScene,
iv: Uint8Array, roomKey: string,
ciphertext: ArrayBuffer | Uint8Array,
): Promise<readonly ExcalidrawElement[]> => { ): Promise<readonly ExcalidrawElement[]> => {
const decrypted = await decryptData(iv, ciphertext, key); const ciphertext = data.ciphertext.toUint8Array();
const iv = data.iv.toUint8Array();
const decrypted = await decryptData(iv, ciphertext, roomKey);
const decodedData = new TextDecoder("utf-8").decode( const decodedData = new TextDecoder("utf-8").decode(
new Uint8Array(decrypted), new Uint8Array(decrypted),
); );
@@ -171,57 +179,86 @@ export const saveFilesToFirebase = async ({
return { savedFiles, erroredFiles }; return { savedFiles, erroredFiles };
}; };
export const saveToFirebase = async ( const createFirebaseSceneDocument = async (
portal: Portal, firebase: ResolutionType<typeof loadFirestore>,
elements: readonly ExcalidrawElement[], elements: readonly ExcalidrawElement[],
roomKey: string,
) => { ) => {
const { roomId, roomKey, socket } = portal;
if (
// if no room exists, consider the room saved because there's nothing we can
// do at this point
!roomId ||
!roomKey ||
!socket ||
isSavedToFirebase(portal, elements)
) {
return true;
}
const firebase = await loadFirestore();
const sceneVersion = getSceneVersion(elements); const sceneVersion = getSceneVersion(elements);
const { ciphertext, iv } = await encryptElements(roomKey, elements); const { ciphertext, iv } = await encryptElements(roomKey, elements);
return {
const nextDocData = {
sceneVersion, sceneVersion,
ciphertext: firebase.firestore.Blob.fromUint8Array( ciphertext: firebase.firestore.Blob.fromUint8Array(
new Uint8Array(ciphertext), new Uint8Array(ciphertext),
), ),
iv: firebase.firestore.Blob.fromUint8Array(iv), iv: firebase.firestore.Blob.fromUint8Array(iv),
} as FirebaseStoredScene; } as FirebaseStoredScene;
};
const db = firebase.firestore(); export const saveToFirebase = async (
const docRef = db.collection("scenes").doc(roomId); portal: Portal,
const didUpdate = await db.runTransaction(async (transaction) => { elements: readonly ExcalidrawElement[],
const doc = await transaction.get(docRef); appState: AppState,
if (!doc.exists) { ) => {
transaction.set(docRef, nextDocData); const { roomId, roomKey, socket } = portal;
return true; if (
} // bail if no room exists as there's nothing we can do at this point
!roomId ||
const prevDocData = doc.data() as FirebaseStoredScene; !roomKey ||
if (prevDocData.sceneVersion >= nextDocData.sceneVersion) { !socket ||
return false; isSavedToFirebase(portal, elements)
} ) {
return false;
transaction.update(docRef, nextDocData);
return true;
});
if (didUpdate) {
firebaseSceneVersionCache.set(socket, sceneVersion);
} }
return didUpdate; const firebase = await loadFirestore();
const firestore = firebase.firestore();
const docRef = firestore.collection("scenes").doc(roomId);
const savedData = await firestore.runTransaction(async (transaction) => {
const snapshot = await transaction.get(docRef);
if (!snapshot.exists) {
const sceneDocument = await createFirebaseSceneDocument(
firebase,
elements,
roomKey,
);
transaction.set(docRef, sceneDocument);
return {
sceneVersion: sceneDocument.sceneVersion,
reconciledElements: null,
};
}
const prevDocData = snapshot.data() as FirebaseStoredScene;
const prevElements = await decryptElements(prevDocData, roomKey);
const reconciledElements = reconcileElements(
elements,
prevElements,
appState,
);
const sceneDocument = await createFirebaseSceneDocument(
firebase,
reconciledElements,
roomKey,
);
transaction.update(docRef, sceneDocument);
return {
reconciledElements,
sceneVersion: sceneDocument.sceneVersion,
};
});
firebaseSceneVersionCache.set(socket, savedData.sceneVersion);
return savedData;
}; };
export const loadFromFirebase = async ( export const loadFromFirebase = async (
@@ -238,10 +275,7 @@ export const loadFromFirebase = async (
return null; return null;
} }
const storedScene = doc.data() as FirebaseStoredScene; const storedScene = doc.data() as FirebaseStoredScene;
const ciphertext = storedScene.ciphertext.toUint8Array(); const elements = await decryptElements(storedScene, roomKey);
const iv = storedScene.iv.toUint8Array();
const elements = await decryptElements(roomKey, iv, ciphertext);
if (socket) { if (socket) {
firebaseSceneVersionCache.set(socket, getSceneVersion(elements)); firebaseSceneVersionCache.set(socket, getSceneVersion(elements));
+7 -28
View File
@@ -5,8 +5,8 @@ import {
getDefaultAppState, getDefaultAppState,
} from "../../appState"; } from "../../appState";
import { clearElementsForLocalStorage } from "../../element"; import { clearElementsForLocalStorage } from "../../element";
import { updateBrowserStateVersion } from "./tabSync";
import { STORAGE_KEYS } from "../app_constants"; import { STORAGE_KEYS } from "../app_constants";
import { ImportedDataState } from "../../data/types";
export const saveUsernameToLocalStorage = (username: string) => { export const saveUsernameToLocalStorage = (username: string) => {
try { try {
@@ -34,26 +34,6 @@ export const importUsernameFromLocalStorage = (): string | null => {
return null; return null;
}; };
export const saveToLocalStorage = (
elements: readonly ExcalidrawElement[],
appState: AppState,
) => {
try {
localStorage.setItem(
STORAGE_KEYS.LOCAL_STORAGE_ELEMENTS,
JSON.stringify(clearElementsForLocalStorage(elements)),
);
localStorage.setItem(
STORAGE_KEYS.LOCAL_STORAGE_APP_STATE,
JSON.stringify(clearAppStateForLocalStorage(appState)),
);
updateBrowserStateVersion(STORAGE_KEYS.VERSION_DATA_STATE);
} catch (error: any) {
// Unable to access window.localStorage
console.error(error);
}
};
export const importFromLocalStorage = () => { export const importFromLocalStorage = () => {
let savedElements = null; let savedElements = null;
let savedState = null; let savedState = null;
@@ -123,14 +103,13 @@ export const getTotalStorageSize = () => {
export const getLibraryItemsFromStorage = () => { export const getLibraryItemsFromStorage = () => {
try { try {
const libraryItems = const libraryItems: ImportedDataState["libraryItems"] = JSON.parse(
JSON.parse( localStorage.getItem(STORAGE_KEYS.LOCAL_STORAGE_LIBRARY) as string,
localStorage.getItem(STORAGE_KEYS.LOCAL_STORAGE_LIBRARY) as string, );
) || [];
return libraryItems; return libraryItems || [];
} catch (e) { } catch (error) {
console.error(e); console.error(error);
return []; return [];
} }
}; };
+6
View File
@@ -26,3 +26,9 @@
} }
} }
} }
.excalidraw-app.is-collaborating {
[data-testid="clear-canvas-button"] {
visibility: hidden;
pointer-events: none;
}
}
+65 -113
View File
@@ -12,7 +12,6 @@ import {
VERSION_TIMEOUT, VERSION_TIMEOUT,
} from "../constants"; } from "../constants";
import { loadFromBlob } from "../data/blob"; import { loadFromBlob } from "../data/blob";
import { ImportedDataState } from "../data/types";
import { import {
ExcalidrawElement, ExcalidrawElement,
FileId, FileId,
@@ -20,7 +19,8 @@ import {
} from "../element/types"; } from "../element/types";
import { useCallbackRefState } from "../hooks/useCallbackRefState"; import { useCallbackRefState } from "../hooks/useCallbackRefState";
import { Language, t } from "../i18n"; import { Language, t } from "../i18n";
import Excalidraw, { import {
Excalidraw,
defaultLang, defaultLang,
languages, languages,
} from "../packages/excalidraw/index"; } from "../packages/excalidraw/index";
@@ -28,12 +28,13 @@ import {
AppState, AppState,
LibraryItems, LibraryItems,
ExcalidrawImperativeAPI, ExcalidrawImperativeAPI,
BinaryFileData,
BinaryFiles, BinaryFiles,
ExcalidrawInitialDataState,
} from "../types"; } from "../types";
import { import {
debounce, debounce,
getVersion, getVersion,
getFrame,
isTestEnv, isTestEnv,
preventUnload, preventUnload,
ResolvablePromise, ResolvablePromise,
@@ -41,7 +42,6 @@ import {
} from "../utils"; } from "../utils";
import { import {
FIREBASE_STORAGE_PREFIXES, FIREBASE_STORAGE_PREFIXES,
SAVE_TO_LOCAL_STORAGE_TIMEOUT,
STORAGE_KEYS, STORAGE_KEYS,
SYNC_BROWSER_TABS_TIMEOUT, SYNC_BROWSER_TABS_TIMEOUT,
} from "./app_constants"; } from "./app_constants";
@@ -56,7 +56,6 @@ import {
getLibraryItemsFromStorage, getLibraryItemsFromStorage,
importFromLocalStorage, importFromLocalStorage,
importUsernameFromLocalStorage, importUsernameFromLocalStorage,
saveToLocalStorage,
} from "./data/localStorage"; } from "./data/localStorage";
import CustomStats from "./CustomStats"; import CustomStats from "./CustomStats";
import { restoreAppState, RestoredDataState } from "../data/restore"; import { restoreAppState, RestoredDataState } from "../data/restore";
@@ -66,72 +65,13 @@ import { shield } from "../components/icons";
import "./index.scss"; import "./index.scss";
import { ExportToExcalidrawPlus } from "./components/ExportToExcalidrawPlus"; import { ExportToExcalidrawPlus } from "./components/ExportToExcalidrawPlus";
import { getMany, set, del, keys, createStore } from "idb-keyval"; import { updateStaleImageStatuses } from "./data/FileManager";
import { FileManager, updateStaleImageStatuses } from "./data/FileManager";
import { newElementWith } from "../element/mutateElement"; import { newElementWith } from "../element/mutateElement";
import { isInitializedImageElement } from "../element/typeChecks"; import { isInitializedImageElement } from "../element/typeChecks";
import { loadFilesFromFirebase } from "./data/firebase"; import { loadFilesFromFirebase } from "./data/firebase";
import { import { LocalData } from "./data/LocalData";
isBrowserStorageStateNewer, import { isBrowserStorageStateNewer } from "./data/tabSync";
updateBrowserStateVersion, import clsx from "clsx";
} from "./data/tabSync";
const filesStore = createStore("files-db", "files-store");
const clearObsoleteFilesFromIndexedDB = async (opts: {
currentFileIds: FileId[];
}) => {
const allIds = await keys(filesStore);
for (const id of allIds) {
if (!opts.currentFileIds.includes(id as FileId)) {
del(id, filesStore);
}
}
};
const localFileStorage = new FileManager({
getFiles(ids) {
return getMany(ids, filesStore).then(
(filesData: (BinaryFileData | undefined)[]) => {
const loadedFiles: BinaryFileData[] = [];
const erroredFiles = new Map<FileId, true>();
filesData.forEach((data, index) => {
const id = ids[index];
if (data) {
loadedFiles.push(data);
} else {
erroredFiles.set(id, true);
}
});
return { loadedFiles, erroredFiles };
},
);
},
async saveFiles({ addedFiles }) {
const savedFiles = new Map<FileId, true>();
const erroredFiles = new Map<FileId, true>();
// before we use `storage` event synchronization, let's update the flag
// optimistically. Hopefully nothing fails, and an IDB read executed
// before an IDB write finishes will read the latest value.
updateBrowserStateVersion(STORAGE_KEYS.VERSION_FILES);
await Promise.all(
[...addedFiles].map(async ([id, fileData]) => {
try {
await set(id, fileData, filesStore);
savedFiles.set(id, true);
} catch (error: any) {
console.error(error);
erroredFiles.set(id, true);
}
}),
);
return { savedFiles, erroredFiles };
},
});
const languageDetector = new LanguageDetector(); const languageDetector = new LanguageDetector();
languageDetector.init({ languageDetector.init({
@@ -142,32 +82,10 @@ languageDetector.init({
checkWhitelist: false, checkWhitelist: false,
}); });
const saveDebounced = debounce(
async (
elements: readonly ExcalidrawElement[],
appState: AppState,
files: BinaryFiles,
onFilesSaved: () => void,
) => {
saveToLocalStorage(elements, appState);
await localFileStorage.saveFiles({
elements,
files,
});
onFilesSaved();
},
SAVE_TO_LOCAL_STORAGE_TIMEOUT,
);
const onBlur = () => {
saveDebounced.flush();
};
const initializeScene = async (opts: { const initializeScene = async (opts: {
collabAPI: CollabAPI; collabAPI: CollabAPI;
}): Promise< }): Promise<
{ scene: ImportedDataState | null } & ( { scene: ExcalidrawInitialDataState | null } & (
| { isExternalScene: true; id: string; key: string } | { isExternalScene: true; id: string; key: string }
| { isExternalScene: false; id?: null; key?: null } | { isExternalScene: false; id?: null; key?: null }
) )
@@ -294,14 +212,15 @@ const ExcalidrawWrapper = () => {
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
const initialStatePromiseRef = useRef<{ const initialStatePromiseRef = useRef<{
promise: ResolvablePromise<ImportedDataState | null>; promise: ResolvablePromise<ExcalidrawInitialDataState | null>;
}>({ promise: null! }); }>({ promise: null! });
if (!initialStatePromiseRef.current.promise) { if (!initialStatePromiseRef.current.promise) {
initialStatePromiseRef.current.promise = initialStatePromiseRef.current.promise =
resolvablePromise<ImportedDataState | null>(); resolvablePromise<ExcalidrawInitialDataState | null>();
} }
useEffect(() => { useEffect(() => {
trackEvent("load", "frame", getFrame());
// Delayed so that the app has a time to load the latest SW // Delayed so that the app has a time to load the latest SW
setTimeout(() => { setTimeout(() => {
trackEvent("load", "version", getVersion()); trackEvent("load", "version", getVersion());
@@ -364,7 +283,7 @@ const ExcalidrawWrapper = () => {
}); });
} else if (isInitialLoad) { } else if (isInitialLoad) {
if (fileIds.length) { if (fileIds.length) {
localFileStorage LocalData.fileStorage
.getFiles(fileIds) .getFiles(fileIds)
.then(({ loadedFiles, erroredFiles }) => { .then(({ loadedFiles, erroredFiles }) => {
if (loadedFiles.length) { if (loadedFiles.length) {
@@ -379,7 +298,7 @@ const ExcalidrawWrapper = () => {
} }
// on fresh load, clear unused files from IDB (from previous // on fresh load, clear unused files from IDB (from previous
// session) // session)
clearObsoleteFilesFromIndexedDB({ currentFileIds: fileIds }); LocalData.fileStorage.clearObsoleteFiles({ currentFileIds: fileIds });
} }
} }
@@ -456,7 +375,7 @@ const ExcalidrawWrapper = () => {
return acc; return acc;
}, [] as FileId[]) || []; }, [] as FileId[]) || [];
if (fileIds.length) { if (fileIds.length) {
localFileStorage LocalData.fileStorage
.getFiles(fileIds) .getFiles(fileIds)
.then(({ loadedFiles, erroredFiles }) => { .then(({ loadedFiles, erroredFiles }) => {
if (loadedFiles.length) { if (loadedFiles.length) {
@@ -473,28 +392,50 @@ const ExcalidrawWrapper = () => {
} }
}, SYNC_BROWSER_TABS_TIMEOUT); }, SYNC_BROWSER_TABS_TIMEOUT);
const onUnload = () => {
LocalData.flushSave();
};
const visibilityChange = (event: FocusEvent | Event) => {
if (event.type === EVENT.BLUR || document.hidden) {
LocalData.flushSave();
}
if (
event.type === EVENT.VISIBILITY_CHANGE ||
event.type === EVENT.FOCUS
) {
syncData();
}
};
window.addEventListener(EVENT.HASHCHANGE, onHashChange, false); window.addEventListener(EVENT.HASHCHANGE, onHashChange, false);
window.addEventListener(EVENT.UNLOAD, onBlur, false); window.addEventListener(EVENT.UNLOAD, onUnload, false);
window.addEventListener(EVENT.BLUR, onBlur, false); window.addEventListener(EVENT.BLUR, visibilityChange, false);
document.addEventListener(EVENT.VISIBILITY_CHANGE, syncData, false); document.addEventListener(EVENT.VISIBILITY_CHANGE, visibilityChange, false);
window.addEventListener(EVENT.FOCUS, syncData, false); window.addEventListener(EVENT.FOCUS, visibilityChange, false);
return () => { return () => {
window.removeEventListener(EVENT.HASHCHANGE, onHashChange, false); window.removeEventListener(EVENT.HASHCHANGE, onHashChange, false);
window.removeEventListener(EVENT.UNLOAD, onBlur, false); window.removeEventListener(EVENT.UNLOAD, onUnload, false);
window.removeEventListener(EVENT.BLUR, onBlur, false); window.removeEventListener(EVENT.BLUR, visibilityChange, false);
window.removeEventListener(EVENT.FOCUS, syncData, false); window.removeEventListener(EVENT.FOCUS, visibilityChange, false);
document.removeEventListener(EVENT.VISIBILITY_CHANGE, syncData, false); document.removeEventListener(
EVENT.VISIBILITY_CHANGE,
visibilityChange,
false,
);
clearTimeout(titleTimeout); clearTimeout(titleTimeout);
}; };
}, [collabAPI, excalidrawAPI]); }, [collabAPI, excalidrawAPI]);
useEffect(() => { useEffect(() => {
const unloadHandler = (event: BeforeUnloadEvent) => { const unloadHandler = (event: BeforeUnloadEvent) => {
saveDebounced.flush(); LocalData.flushSave();
if ( if (
excalidrawAPI && excalidrawAPI &&
localFileStorage.shouldPreventUnload(excalidrawAPI.getSceneElements()) LocalData.fileStorage.shouldPreventUnload(
excalidrawAPI.getSceneElements(),
)
) { ) {
preventUnload(event); preventUnload(event);
} }
@@ -515,9 +456,13 @@ const ExcalidrawWrapper = () => {
files: BinaryFiles, files: BinaryFiles,
) => { ) => {
if (collabAPI?.isCollaborating()) { if (collabAPI?.isCollaborating()) {
collabAPI.broadcastElements(elements); collabAPI.syncElements(elements);
} else { }
saveDebounced(elements, appState, files, () => {
// this check is redundant, but since this is a hot path, it's best
// not to evaludate the nested expression every time
if (!LocalData.isSavePaused()) {
LocalData.save(elements, appState, files, () => {
if (excalidrawAPI) { if (excalidrawAPI) {
let didChange = false; let didChange = false;
@@ -525,7 +470,9 @@ const ExcalidrawWrapper = () => {
const elements = excalidrawAPI const elements = excalidrawAPI
.getSceneElementsIncludingDeleted() .getSceneElementsIncludingDeleted()
.map((element) => { .map((element) => {
if (localFileStorage.shouldUpdateImageElementStatus(element)) { if (
LocalData.fileStorage.shouldUpdateImageElementStatus(element)
) {
didChange = true; didChange = true;
const newEl = newElementWith(element, { status: "saved" }); const newEl = newElementWith(element, { status: "saved" });
if (pendingImageElement === element) { if (pendingImageElement === element) {
@@ -685,11 +632,16 @@ const ExcalidrawWrapper = () => {
}; };
const onRoomClose = useCallback(() => { const onRoomClose = useCallback(() => {
localFileStorage.reset(); LocalData.fileStorage.reset();
}, []); }, []);
return ( return (
<> <div
style={{ height: "100%" }}
className={clsx("excalidraw-app", {
"is-collaborating": collabAPI?.isCollaborating(),
})}
>
<Excalidraw <Excalidraw
ref={excalidrawRefCallback} ref={excalidrawRefCallback}
onChange={onChange} onChange={onChange}
@@ -741,7 +693,7 @@ const ExcalidrawWrapper = () => {
onClose={() => setErrorMessage("")} onClose={() => setErrorMessage("")}
/> />
)} )}
</> </div>
); );
}; };

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