Compare commits

...

144 Commits

Author SHA1 Message Date
pomdtr 8984d8f19a don't clean export options on load 2022-06-29 10:23:48 +00:00
Achille Lacoin b80706cd4a Update appState.ts 2022-06-28 17:53:01 +02:00
pomdtr cf34cbdd30 fix export.test.tsx 2022-06-28 08:16:14 +00:00
pomdtr 6ead3ff839 fix export snapshot 2022-06-28 08:12:38 +00:00
pomdtr d7f0d4ee21 [feat] serialize export options when embedding scene in an image 2022-06-27 20:31:05 +00:00
David Luzar 120c8f373c fix: library not scrollable when no published items installed (#5352)
* fix: library not scrollable when no published items installed

* show empty lib message in one case & fix i18n
2022-06-25 20:10:53 +02:00
David Luzar 9135ebf2e2 feat: redirect vscode.excalidraw.com to vscode marketplace (#5285) 2022-06-23 17:42:50 +02:00
David Luzar af31e9dcc2 fix: focus traps inside popovers (#5317) 2022-06-23 17:33:50 +02:00
David Luzar 50bc7e099a fix: unable to use cmd/ctrl-delete/backspace in inputs (#5348) 2022-06-23 17:27:15 +02:00
Aakansha Doshi 39d17c4a3c fix: delay loading until language imported (#5344) 2022-06-22 22:06:29 +05:30
Aakansha Doshi d34c2a75db fix: command to trigger release (#5347) 2022-06-22 18:10:57 +05:30
Aakansha Doshi de95c68d75 fix: remove unnecessary options passed to language detector (#5336) 2022-06-22 01:33:08 +05:30
Ishtiaq Bhatti cdf352d4c3 feat: add sidebar for libraries panel (#5274)
Co-authored-by: dwelle <luzar.david@gmail.com>
Co-authored-by: Aakansha Doshi <aakansha1216@gmail.com>
2022-06-21 17:03:23 +02:00
David Luzar 4712393b62 fix: stale appState.pendingImageElement (#5322)
* fix: stale `appState.pendingImageElement`

* unrelated fix for devTools race conditions

* snap fix
2022-06-19 14:13:43 +02:00
David Luzar fd48c2cf79 fix: non-letter shortcuts being swallowed by color picker (#5316) 2022-06-17 12:37:11 +02:00
David Luzar 5feacd9a3b feat: deduplicate collab avatars based on id (#5309) 2022-06-15 15:35:57 +02:00
Aakansha Doshi ec35d5db51 fix: bind text to correct container when nested (#5307)
* fix: bind text to correct container when nested

* fix tests
2022-06-15 16:09:12 +05:30
dependabot[bot] ddf088e428 chore(deps): bump eventsource from 1.1.0 to 1.1.1 (#5262)
Bumps [eventsource](https://github.com/EventSource/eventsource) from 1.1.0 to 1.1.1.
- [Release notes](https://github.com/EventSource/eventsource/releases)
- [Changelog](https://github.com/EventSource/eventsource/blob/master/HISTORY.md)
- [Commits](https://github.com/EventSource/eventsource/compare/v1.1.0...v1.1.1)

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

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

Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2022-06-14 22:04:35 +05:30
dependabot[bot] adc1e585ff chore(deps): bump protobufjs from 6.10.2 to 6.11.3 (#5266)
Bumps [protobufjs](https://github.com/protobufjs/protobuf.js) from 6.10.2 to 6.11.3.
- [Release notes](https://github.com/protobufjs/protobuf.js/releases)
- [Changelog](https://github.com/protobufjs/protobuf.js/blob/v6.11.3/CHANGELOG.md)
- [Commits](https://github.com/protobufjs/protobuf.js/compare/v6.10.2...v6.11.3)

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

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

Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2022-06-14 22:04:01 +05:30
Aakansha Doshi 84b47a2ed5 fix: copy bound text style when copying element having bound text (#5305)
* fix: copy bound text style when copying element having bound text

* fix

* fix tests
2022-06-14 19:42:49 +05:30
Aakansha Doshi 6196fba286 docs: migrate example to typescript (#5243)
* docs: migrate example to typescript

* fix

* fix sidebar

* fix
2022-06-14 17:56:05 +05:30
Aakansha Doshi 5daff2d3cd fix: copy arrow head when using copy styles (#5303)
* fix: copy arrow head when using copy styles

* remove mutations & check against `arrow` type

* fix lint

Co-authored-by: dwelle <luzar.david@gmail.com>
2022-06-14 16:27:41 +05:30
Aakansha Doshi f1bc90e08a fix: Allow null in renderFooter prop (#5282)
* fix: Allow null in render props

* update docs
2022-06-06 20:45:06 +05:30
Aakansha Doshi aabcdc20fd docs: prevent touch so that pointermove works in touch devices (#5241) 2022-05-25 11:39:16 +05:30
Aakansha Doshi 269fbcc2f3 docs: remove dragging threshold when interacting with custom elements (#5240) 2022-05-24 20:56:01 +05:30
jasonphillips d08179c215 fix: transpile browser-fs-access in package build (#5041) 2022-05-24 15:07:05 +02:00
Aakansha Doshi 90e739d444 docs: fix offsets in the example when dragging custom elements (#5238) 2022-05-24 16:27:23 +05:30
David Luzar 4a9fac2d1e fix: unsafely accessing draggingElement (#5216) 2022-05-20 17:48:26 +02:00
Aakansha Doshi 07ebd7c68c feat: support setting/resetting cursor from host (#5215)
* Support setting/resetting cursor type from host

* add docs

* minor
2022-05-20 18:43:38 +05:30
David Luzar 92f30f7ed6 test: add tests for loading library from file picker (#5206) 2022-05-19 00:37:36 +02:00
zsviczian 605aa554d0 fix: Library load button does not work (#5205)
Co-authored-by: dwelle <luzar.david@gmail.com>
2022-05-18 19:46:08 +00:00
Milos Vetesnik bed9fca4a5 feat: go-to-excalidrawplus button (#5202)
Co-authored-by: dwelle <luzar.david@gmail.com>
2022-05-18 18:30:34 +02:00
Milos Vetesnik b9968e2e72 feat: Autoredirect to Excalidraw+ if special cookie is present (#5183)
Co-authored-by: David Luzar <luzar.david@gmail.com>
Co-authored-by: dwelle <luzar.david@gmail.com>
2022-05-18 12:04:26 +02:00
Aakansha Doshi ab1a30073c feat: expose utilitis to convert scene coords to viewport coords and vice versa (#5187)
* feat: expose utilitis to convert scene coords to viewport coords and vice versa

* add return value
2022-05-12 17:14:30 +05:30
Excalidraw Bot 31049d06e8 chore: Update translations from Crowdin (#5061) 2022-05-11 23:44:02 +02:00
Johannes ef8559d060 fix: do not deselect when not zooming using touchscreen pinch (#5181)
* Do not deselect when zooming

* do not deselect when trackpad zooming on Safari

Co-authored-by: dwelle <luzar.david@gmail.com>
2022-05-11 23:30:50 +02:00
Johannes 33bb23d2f3 fix: wheel zoom normalization (#5165)
Co-authored-by: David Luzar <luzar.david@gmail.com>
2022-05-11 22:01:20 +02:00
David Luzar b27ac257e7 feat: support resubmitting published library items (#5174) 2022-05-11 15:56:11 +02:00
David Luzar d2cc76e52e feat: support adding multiple library items on canvas (#5116) 2022-05-11 15:51:02 +02:00
David Luzar cad6097d60 feat: factor out url library init & switch to updateLibrary API (#5115)
Co-authored-by: Aakansha Doshi <aakansha1216@gmail.com>
2022-05-11 15:08:54 +02:00
David Luzar 2537b225ac fix: hide sidebar when custom tool active (#5179) 2022-05-11 12:42:52 +00:00
Aakansha Doshi 4ee48d2729 fix: rename src to avatarUrl in collaborator (#5177)
* fix: rename  to  in collaborator

* update pr link

* fallback to intials on image error
2022-05-11 15:54:10 +05:30
Aakansha Doshi 68f23d652f feat: support custom elements in @excalidraw/excalidraw (#5164)
* feat: support custom elements in @excalidraw/excalidraw

* revert

* fix css

* fix offsets

* fix overflow of custom elements in example

* fix overflow in comments input

* make sure comment input never overflows the viewport

* remove offsetschange

* expose setActiveTool

* rename to onPointerDown

* update docs

* fix
2022-05-11 13:30:15 +05:30
dependabot[bot] a078508c05 chore(deps-dev): bump sass-loader in /src/packages/utils (#4813)
Bumps [sass-loader](https://github.com/webpack-contrib/sass-loader) from 12.4.0 to 12.6.0.
- [Release notes](https://github.com/webpack-contrib/sass-loader/releases)
- [Changelog](https://github.com/webpack-contrib/sass-loader/blob/master/CHANGELOG.md)
- [Commits](https://github.com/webpack-contrib/sass-loader/compare/v12.4.0...v12.6.0)

---
updated-dependencies:
- dependency-name: sass-loader
  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-10 13:02:20 +00:00
dependabot[bot] abf4dc9256 chore(deps-dev): bump @babel/preset-env in /src/packages/excalidraw (#5121)
Bumps [@babel/preset-env](https://github.com/babel/babel/tree/HEAD/packages/babel-preset-env) from 7.16.7 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-preset-env)

---
updated-dependencies:
- dependency-name: "@babel/preset-env"
  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-10 12:51:53 +00:00
dependabot[bot] ba8f12d588 chore(deps-dev): bump webpack-dev-server in /src/packages/excalidraw (#5158)
Bumps [webpack-dev-server](https://github.com/webpack/webpack-dev-server) from 4.7.4 to 4.9.0.
- [Release notes](https://github.com/webpack/webpack-dev-server/releases)
- [Changelog](https://github.com/webpack/webpack-dev-server/blob/master/CHANGELOG.md)
- [Commits](https://github.com/webpack/webpack-dev-server/compare/v4.7.4...v4.9.0)

---
updated-dependencies:
- dependency-name: webpack-dev-server
  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-10 12:49:37 +00:00
dependabot[bot] d57560db06 chore(deps-dev): bump typescript in /src/packages/excalidraw (#5119)
Bumps [typescript](https://github.com/Microsoft/TypeScript) from 4.5.4 to 4.6.4.
- [Release notes](https://github.com/Microsoft/TypeScript/releases)
- [Commits](https://github.com/Microsoft/TypeScript/compare/v4.5.4...v4.6.4)

---
updated-dependencies:
- dependency-name: typescript
  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-10 12:48:59 +00:00
dependabot[bot] 0d26049b4e chore(deps-dev): bump babel-loader in /src/packages/excalidraw (#5086)
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-05-10 12:48:27 +00:00
dependabot[bot] f72e9b6ea5 chore(deps-dev): bump ts-loader in /src/packages/utils (#5123)
Bumps [ts-loader](https://github.com/TypeStrong/ts-loader) from 9.2.8 to 9.3.0.
- [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.8...v9.3.0)

---
updated-dependencies:
- dependency-name: ts-loader
  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-10 12:42:13 +00:00
dependabot[bot] 029cfb31b0 chore(deps-dev): bump prettier from 2.5.1 to 2.6.2 (#5168)
Bumps [prettier](https://github.com/prettier/prettier) from 2.5.1 to 2.6.2.
- [Release notes](https://github.com/prettier/prettier/releases)
- [Changelog](https://github.com/prettier/prettier/blob/main/CHANGELOG.md)
- [Commits](https://github.com/prettier/prettier/compare/2.5.1...2.6.2)

---
updated-dependencies:
- dependency-name: prettier
  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-10 12:23:34 +00:00
dependabot[bot] 3a288eb09c chore(deps-dev): bump eslint-config-prettier from 8.3.0 to 8.5.0 (#5098)
Bumps [eslint-config-prettier](https://github.com/prettier/eslint-config-prettier) from 8.3.0 to 8.5.0.
- [Release notes](https://github.com/prettier/eslint-config-prettier/releases)
- [Changelog](https://github.com/prettier/eslint-config-prettier/blob/main/CHANGELOG.md)
- [Commits](https://github.com/prettier/eslint-config-prettier/compare/v8.3.0...v8.5.0)

---
updated-dependencies:
- dependency-name: eslint-config-prettier
  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-10 12:22:58 +00:00
dependabot[bot] 803909abb6 chore(deps-dev): bump webpack in /src/packages/utils (#5167)
Bumps [webpack](https://github.com/webpack/webpack) from 5.66.0 to 5.72.1.
- [Release notes](https://github.com/webpack/webpack/releases)
- [Commits](https://github.com/webpack/webpack/compare/v5.66.0...v5.72.1)

---
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-05-10 12:19:48 +00:00
dependabot[bot] 56c75b769c chore(deps): bump sass from 1.49.7 to 1.51.0 (#5169)
Bumps [sass](https://github.com/sass/dart-sass) from 1.49.7 to 1.51.0.
- [Release notes](https://github.com/sass/dart-sass/releases)
- [Changelog](https://github.com/sass/dart-sass/blob/main/CHANGELOG.md)
- [Commits](https://github.com/sass/dart-sass/compare/1.49.7...1.51.0)

---
updated-dependencies:
- dependency-name: sass
  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-05-10 17:49:02 +05:30
dependabot[bot] eea48d94d3 chore(deps): bump @testing-library/react from 12.1.2 to 12.1.5 (#5055)
Bumps [@testing-library/react](https://github.com/testing-library/react-testing-library) from 12.1.2 to 12.1.5.
- [Release notes](https://github.com/testing-library/react-testing-library/releases)
- [Changelog](https://github.com/testing-library/react-testing-library/blob/main/CHANGELOG.md)
- [Commits](https://github.com/testing-library/react-testing-library/compare/v12.1.2...v12.1.5)

---
updated-dependencies:
- dependency-name: "@testing-library/react"
  dependency-type: direct:production
  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-05-10 17:42:29 +05:30
dependabot[bot] e29152ab30 chore(deps-dev): bump @babel/plugin-transform-runtime (#5118)
Bumps [@babel/plugin-transform-runtime](https://github.com/babel/babel/tree/HEAD/packages/babel-plugin-transform-runtime) from 7.17.0 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-patch
...

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

Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2022-05-10 17:42:13 +05:30
dependabot[bot] f4aa36b35d chore(deps-dev): bump ts-loader in /src/packages/excalidraw (#5117)
Bumps [ts-loader](https://github.com/TypeStrong/ts-loader) from 9.2.8 to 9.3.0.
- [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.8...v9.3.0)

---
updated-dependencies:
- dependency-name: ts-loader
  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-10 17:40:30 +05:30
dependabot[bot] 2903a763a7 chore(deps-dev): bump jest-canvas-mock from 2.3.1 to 2.4.0 (#5127)
Bumps [jest-canvas-mock](https://github.com/hustcc/jest-canvas-mock) from 2.3.1 to 2.4.0.
- [Release notes](https://github.com/hustcc/jest-canvas-mock/releases)
- [Changelog](https://github.com/hustcc/jest-canvas-mock/blob/master/CHANGELOG.md)
- [Commits](https://github.com/hustcc/jest-canvas-mock/commits/v2.4.0)

---
updated-dependencies:
- dependency-name: jest-canvas-mock
  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-10 17:39:46 +05:30
dependabot[bot] 4a980ed5db chore(deps-dev): bump autoprefixer in /src/packages/excalidraw (#5159)
Bumps [autoprefixer](https://github.com/postcss/autoprefixer) from 10.4.5 to 10.4.7.
- [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.5...10.4.7)

---
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-05-10 17:39:09 +05:30
David Luzar d2e687ed0a feat: make file handling more robust (#5057) 2022-05-09 15:53:04 +02:00
DanielJGeiger 0d70690ec8 fix: Don't save deleted ExcalidrawElements to Firebase (#5108)
Co-authored-by: dwelle <luzar.david@gmail.com>
2022-05-09 15:38:44 +02:00
David Luzar a524eeb66e fix: eraser removed deleted elements (#5155)
* fix: eraser removed deleted elements

* rename `getElements` API

* fix one more case of not including deleted elements
2022-05-07 21:01:37 +02:00
Tom Sherman 3d56ceb794 fix: Handle ColorPicker parentSelector being undefined (#5152)
Co-authored-by: dwelle <luzar.david@gmail.com>
2022-05-07 18:16:14 +00:00
David Luzar 65c32b3319 fix: library multiselect not accounting for published state (#5132) 2022-05-07 19:13:10 +02:00
Nicholas Fitton 9e8e047aae fix: Chart display fix (#5154) 2022-05-07 19:12:31 +02:00
Aakansha Doshi 64d330a332 feat: support customType in activeTool (#5144)
* feat: support customType in activeTool

* fix

* rewrite types and handling

* tweaks

* fix

Co-authored-by: dwelle <luzar.david@gmail.com>
2022-05-06 18:21:22 +05:30
Aakansha Doshi 1ed1529f96 fix: update opacity of bound text when opacity of container updated (#5142) 2022-05-04 14:29:05 +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
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
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
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
David Luzar 55ccd5b79b feat: delay initial loading message & tweak design (#5049) 2022-04-19 19:08:13 +02:00
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
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
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
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
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
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
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
229 changed files with 11110 additions and 4888 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_BACKEND=https://us-central1-excalidraw-room-persistence.cloudfunctions.net/libraries
REACT_APP_PORTAL_URL=http://localhost:3002
# Fill to set socket server URL used for collaboration.
# Meant for forks only: excalidraw.com uses custom REACT_APP_PORTAL_URL flow
REACT_APP_WS_SERVER_URL=
# collaboration WebSocket server (https://github.com/excalidraw/excalidraw-room)
REACT_APP_WS_SERVER_URL=http://localhost:3002
# 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"}'
+2
View File
@@ -13,3 +13,5 @@ REACT_APP_FIREBASE_CONFIG='{"apiKey":"AIzaSyAd15pYlMci_xIp9ko6wkEsDzAAA0Dn0RU","
# production-only vars
REACT_APP_GOOGLE_ANALYTICS_ID=UA-387204-13
REACT_APP_PLUS_APP=https://app.excalidraw.com
+1 -1
View File
@@ -6,7 +6,7 @@ on:
jobs:
Auto-release-excalidraw-preview:
name: Auto release preview
if: github.event.comment.body == '@excalibot release package' && github.event.issue.pull_request
if: github.event.comment.body == '@excalibot trigger release' && github.event.issue.pull_request
runs-on: ubuntu-latest
steps:
- name: React to release comment
+35 -8
View File
@@ -128,14 +128,41 @@ For collaboration, you will need to set up [collab server](https://github.com/ex
#### Commands
| Command | Description |
| ------------------ | --------------------------------- |
| `yarn` | Install the dependencies |
| `yarn start` | Run the project |
| `yarn fix` | Reformat all files with Prettier |
| `yarn test` | Run tests |
| `yarn test:update` | Update test snapshots |
| `yarn test:code` | Test for formatting with Prettier |
##### Install the dependencies
```
yarn
```
##### Run the project
```
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
+5 -6
View File
@@ -1,12 +1,11 @@
rules_version = '2';
service firebase.storage {
match /b/{bucket}/o {
match /{migrations} {
match /{scenes}/{scene} {
allow get, write: if true;
// redundant, but let's be explicit'
allow list: if false;
}
match /{files}/rooms/{room}/{file} {
allow get, write: if true;
}
match /{files}/shareLinks/{shareLink}/{file} {
allow get, write: if true;
}
}
}
+9 -8
View File
@@ -22,22 +22,23 @@
"@sentry/browser": "6.2.5",
"@sentry/integrations": "6.2.5",
"@testing-library/jest-dom": "5.16.2",
"@testing-library/react": "12.1.2",
"@testing-library/react": "12.1.5",
"@tldraw/vec": "1.4.3",
"@types/jest": "27.4.0",
"@types/pica": "5.1.3",
"@types/react": "17.0.39",
"@types/react-dom": "17.0.11",
"@types/socket.io-client": "1.4.36",
"browser-fs-access": "0.24.1",
"browser-fs-access": "0.29.1",
"clsx": "1.1.1",
"fake-indexeddb": "3.1.7",
"firebase": "8.3.3",
"i18next-browser-languagedetector": "6.1.2",
"idb-keyval": "6.0.3",
"image-blob-reduce": "3.0.1",
"jotai": "1.6.4",
"lodash.throttle": "4.1.1",
"nanoid": "3.1.32",
"nanoid": "3.3.3",
"open-color": "1.9.1",
"pako": "1.0.11",
"perfect-freehand": "1.0.16",
@@ -50,7 +51,7 @@
"react-dom": "17.0.2",
"react-scripts": "4.0.3",
"roughjs": "4.5.2",
"sass": "1.49.7",
"sass": "1.51.0",
"socket.io-client": "2.3.1",
"typescript": "4.5.5"
},
@@ -63,13 +64,13 @@
"@types/resize-observer-browser": "0.1.6",
"chai": "4.3.6",
"dotenv": "10.0.0",
"eslint-config-prettier": "8.3.0",
"eslint-config-prettier": "8.5.0",
"eslint-plugin-prettier": "3.3.1",
"husky": "7.0.4",
"jest-canvas-mock": "2.3.1",
"lint-staged": "12.3.3",
"jest-canvas-mock": "2.4.0",
"lint-staged": "12.3.7",
"pepjs": "0.5.3",
"prettier": "2.5.1",
"prettier": "2.6.2",
"rewire": "5.0.0"
},
"resolutions": {
+23 -26
View File
@@ -52,6 +52,25 @@
content="Excalidraw is a whiteboard tool that lets you easily sketch diagrams that have a hand-drawn feel to them."
/>
<script>
// Redirect Excalidraw+ users which have auto-redirect enabled.
//
// Redirect only the bare root path, so link/room/library urls are not
// redirected.
//
// Putting into index.html for best performance (can't redirect on server
// due to location.hash checks).
if (
window.location.pathname === "/" &&
!window.location.hash &&
!window.location.search &&
// if its present redirect
document.cookie.includes("excplus-autoredirect=true")
) {
window.location.href = "https://app.excalidraw.com";
}
</script>
<link rel="shortcut icon" href="favicon.ico" type="image/x-icon" />
<!-- Excalidraw version -->
@@ -124,26 +143,6 @@
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 {
height: 100%;
-webkit-touch-callout: none;
@@ -152,8 +151,10 @@
-moz-user-select: none;
-ms-user-select: none;
user-select: none;
}
@media screen and (min-width: 1200px) {
@media screen and (min-width: 1200px) {
#root {
-webkit-touch-callout: default;
-webkit-user-select: auto;
-khtml-user-select: auto;
@@ -170,10 +171,6 @@
<header>
<h1 class="visually-hidden">Excalidraw</h1>
</header>
<div id="root">
<div class="LoadingMessage">
<span>Loading scene...</span>
</div>
</div>
<div id="root"></div>
</body>
</html>
+3 -2
View File
@@ -7,6 +7,7 @@ import { t } from "../i18n";
export const actionAddToLibrary = register({
name: "addToLibrary",
trackEvent: { category: "element" },
perform: (elements, appState, _, app) => {
const selectedElements = getSelectedElements(
getNonDeletedElements(elements),
@@ -24,9 +25,9 @@ export const actionAddToLibrary = register({
}
return app.library
.loadLibrary()
.getLatestLibrary()
.then((items) => {
return app.library.saveLibrary([
return app.library.setLibrary([
{
id: randomId(),
status: "unpublished",
+8
View File
@@ -43,6 +43,7 @@ const alignSelectedElements = (
export const actionAlignTop = register({
name: "alignTop",
trackEvent: { category: "element" },
perform: (elements, appState) => {
return {
appState,
@@ -72,6 +73,7 @@ export const actionAlignTop = register({
export const actionAlignBottom = register({
name: "alignBottom",
trackEvent: { category: "element" },
perform: (elements, appState) => {
return {
appState,
@@ -101,6 +103,7 @@ export const actionAlignBottom = register({
export const actionAlignLeft = register({
name: "alignLeft",
trackEvent: { category: "element" },
perform: (elements, appState) => {
return {
appState,
@@ -130,6 +133,8 @@ export const actionAlignLeft = register({
export const actionAlignRight = register({
name: "alignRight",
trackEvent: { category: "element" },
perform: (elements, appState) => {
return {
appState,
@@ -159,6 +164,8 @@ export const actionAlignRight = register({
export const actionAlignVerticallyCentered = register({
name: "alignVerticallyCentered",
trackEvent: { category: "element" },
perform: (elements, appState) => {
return {
appState,
@@ -184,6 +191,7 @@ export const actionAlignVerticallyCentered = register({
export const actionAlignHorizontallyCentered = register({
name: "alignHorizontallyCentered",
trackEvent: { category: "element" },
perform: (elements, appState) => {
return {
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,
};
},
});
+33 -6
View File
@@ -11,7 +11,7 @@ import { getNormalizedZoom, getSelectedElements } from "../scene";
import { centerScrollOn } from "../scene/scroll";
import { getStateForZoom } from "../scene/zoom";
import { AppState, NormalizedZoomValue } from "../types";
import { getShortcutKey } from "../utils";
import { getShortcutKey, updateActiveTool } from "../utils";
import { register } from "./register";
import { Tooltip } from "../components/Tooltip";
import { newElementWith } from "../element/mutateElement";
@@ -21,6 +21,7 @@ import clsx from "clsx";
export const actionChangeViewBackgroundColor = register({
name: "changeViewBackgroundColor",
trackEvent: false,
perform: (_, appState, value) => {
return {
appState: { ...appState, ...value },
@@ -50,6 +51,7 @@ export const actionChangeViewBackgroundColor = register({
export const actionClearCanvas = register({
name: "clearCanvas",
trackEvent: { category: "canvas" },
perform: (elements, appState, _, app) => {
app.imageCache.clear();
return {
@@ -60,7 +62,6 @@ export const actionClearCanvas = register({
...getDefaultAppState(),
files: {},
theme: appState.theme,
elementLocked: appState.elementLocked,
penMode: appState.penMode,
penDetected: appState.penDetected,
exportBackground: appState.exportBackground,
@@ -68,8 +69,10 @@ export const actionClearCanvas = register({
gridSize: appState.gridSize,
showStats: appState.showStats,
pasteDialog: appState.pasteDialog,
elementType:
appState.elementType === "image" ? "selection" : appState.elementType,
activeTool:
appState.activeTool.type === "image"
? { ...appState.activeTool, type: "selection" }
: appState.activeTool,
},
commitToHistory: true,
};
@@ -80,6 +83,7 @@ export const actionClearCanvas = register({
export const actionZoomIn = register({
name: "zoomIn",
trackEvent: { category: "canvas" },
perform: (_elements, appState, _, app) => {
return {
appState: {
@@ -115,6 +119,7 @@ export const actionZoomIn = register({
export const actionZoomOut = register({
name: "zoomOut",
trackEvent: { category: "canvas" },
perform: (_elements, appState, _, app) => {
return {
appState: {
@@ -150,6 +155,7 @@ export const actionZoomOut = register({
export const actionResetZoom = register({
name: "resetZoom",
trackEvent: { category: "canvas" },
perform: (_elements, appState, _, app) => {
return {
appState: {
@@ -248,6 +254,7 @@ const zoomToFitElements = (
export const actionZoomToSelected = register({
name: "zoomToSelection",
trackEvent: { category: "canvas" },
perform: (elements, appState) => zoomToFitElements(elements, appState, true),
keyTest: (event) =>
event.code === CODES.TWO &&
@@ -258,6 +265,7 @@ export const actionZoomToSelected = register({
export const actionZoomToFit = register({
name: "zoomToFit",
trackEvent: { category: "canvas" },
perform: (elements, appState) => zoomToFitElements(elements, appState, false),
keyTest: (event) =>
event.code === CODES.ONE &&
@@ -268,6 +276,7 @@ export const actionZoomToFit = register({
export const actionToggleTheme = register({
name: "toggleTheme",
trackEvent: { category: "canvas" },
perform: (_, appState, value) => {
return {
appState: {
@@ -293,23 +302,41 @@ export const actionToggleTheme = register({
export const actionErase = register({
name: "eraser",
trackEvent: { category: "toolbar" },
perform: (elements, appState) => {
let activeTool: AppState["activeTool"];
if (isEraserActive(appState)) {
activeTool = updateActiveTool(appState, {
...(appState.activeTool.lastActiveToolBeforeEraser || {
type: "selection",
}),
lastActiveToolBeforeEraser: null,
});
} else {
activeTool = updateActiveTool(appState, {
type: "eraser",
lastActiveToolBeforeEraser: appState.activeTool,
});
}
return {
appState: {
...appState,
selectedElementIds: {},
selectedGroupIds: {},
elementType: isEraserActive(appState) ? "selection" : "eraser",
activeTool,
},
commitToHistory: true,
};
},
keyTest: (event) => event.key === KEYS.E,
PanelComponent: ({ elements, appState, updateData, data }) => (
<ToolButton
type="button"
icon={eraser}
className={clsx("eraser", { active: isEraserActive(appState) })}
title={t("toolBar.eraser")}
title={`${t("toolBar.eraser")}-${getShortcutKey("E")}`}
aria-label={t("toolBar.eraser")}
onClick={() => {
updateData(null);
+45 -3
View File
@@ -1,16 +1,23 @@
import { CODES, KEYS } from "../keys";
import { register } from "./register";
import { copyToClipboard } from "../clipboard";
import {
copyTextToSystemClipboard,
copyToClipboard,
probablySupportsClipboardWriteText,
} from "../clipboard";
import { actionDeleteSelected } from "./actionDeleteSelected";
import { getSelectedElements } from "../scene/selection";
import { exportCanvas } from "../data/index";
import { getNonDeletedElements } from "../element";
import { getNonDeletedElements, isTextElement } from "../element";
import { t } from "../i18n";
export const actionCopy = register({
name: "copy",
trackEvent: { category: "element" },
perform: (elements, appState, _, app) => {
copyToClipboard(getNonDeletedElements(elements), appState, app.files);
const selectedElements = getSelectedElements(elements, appState, true);
copyToClipboard(selectedElements, appState, app.files);
return {
commitToHistory: false,
@@ -23,6 +30,7 @@ export const actionCopy = register({
export const actionCut = register({
name: "cut",
trackEvent: { category: "element" },
perform: (elements, appState, data, app) => {
actionCopy.perform(elements, appState, data, app);
return actionDeleteSelected.perform(elements, appState);
@@ -33,6 +41,7 @@ export const actionCut = register({
export const actionCopyAsSvg = register({
name: "copyAsSvg",
trackEvent: { category: "element" },
perform: async (elements, appState, _data, app) => {
if (!app.canvas) {
return {
@@ -73,6 +82,7 @@ export const actionCopyAsSvg = register({
export const actionCopyAsPng = register({
name: "copyAsPng",
trackEvent: { category: "element" },
perform: async (elements, appState, _data, app) => {
if (!app.canvas) {
return {
@@ -122,3 +132,35 @@ export const actionCopyAsPng = register({
contextItemLabel: "labels.copyAsPng",
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",
});
+3 -1
View File
@@ -12,6 +12,7 @@ import { getElementsInGroup } from "../groups";
import { LinearElementEditor } from "../element/linearElementEditor";
import { fixBindingsAfterDeletion } from "../element/binding";
import { isBoundToContainer } from "../element/typeChecks";
import { updateActiveTool } from "../utils";
const deleteSelectedElements = (
elements: readonly ExcalidrawElement[],
@@ -58,6 +59,7 @@ const handleGroupEditingState = (
export const actionDeleteSelected = register({
name: "deleteSelectedElements",
trackEvent: { category: "element", action: "delete" },
perform: (elements, appState) => {
if (appState.editingLinearElement) {
const {
@@ -133,7 +135,7 @@ export const actionDeleteSelected = register({
elements: nextElements,
appState: {
...nextAppState,
elementType: "selection",
activeTool: updateActiveTool(appState, { type: "selection" }),
multiElement: null,
},
commitToHistory: isSomeElementSelected(
+3 -1
View File
@@ -3,7 +3,7 @@ import {
DistributeVerticallyIcon,
} from "../components/icons";
import { ToolButton } from "../components/ToolButton";
import { distributeElements, Distribution } from "../disitrubte";
import { distributeElements, Distribution } from "../distribute";
import { getNonDeletedElements } from "../element";
import { ExcalidrawElement } from "../element/types";
import { t } from "../i18n";
@@ -39,6 +39,7 @@ const distributeSelectedElements = (
export const distributeHorizontally = register({
name: "distributeHorizontally",
trackEvent: { category: "element" },
perform: (elements, appState) => {
return {
appState,
@@ -68,6 +69,7 @@ export const distributeHorizontally = register({
export const distributeVertically = register({
name: "distributeVertically",
trackEvent: { category: "element" },
perform: (elements, appState) => {
return {
appState,
+1
View File
@@ -22,6 +22,7 @@ import { isBoundToContainer } from "../element/typeChecks";
export const actionDuplicateSelection = register({
name: "duplicateSelection",
trackEvent: { category: "element" },
perform: (elements, appState) => {
// duplicate selected point(s) if editing a line
if (appState.editingLinearElement) {
+11 -5
View File
@@ -1,4 +1,3 @@
import { trackEvent } from "../analytics";
import { load, questionCircle, saveAs } from "../components/icons";
import { ProjectName } from "../components/ProjectName";
import { ToolButton } from "../components/ToolButton";
@@ -8,7 +7,7 @@ import { DarkModeToggle } from "../components/DarkModeToggle";
import { loadFromJSON, saveAsJSON } from "../data";
import { resaveAsImageWithScene } from "../data/resave";
import { t } from "../i18n";
import { useIsMobile } from "../components/App";
import { useDevice } from "../components/App";
import { KEYS } from "../keys";
import { register } from "./register";
import { CheckboxItem } from "../components/CheckboxItem";
@@ -23,8 +22,8 @@ import { Theme } from "../element/types";
export const actionChangeProjectName = register({
name: "changeProjectName",
trackEvent: false,
perform: (_elements, appState, value) => {
trackEvent("change", "title");
return { appState: { ...appState, name: value }, commitToHistory: false };
},
PanelComponent: ({ appState, updateData, appProps }) => (
@@ -41,6 +40,7 @@ export const actionChangeProjectName = register({
export const actionChangeExportScale = register({
name: "changeExportScale",
trackEvent: { category: "export", action: "scale" },
perform: (_elements, appState, value) => {
return {
appState: { ...appState, exportScale: value },
@@ -89,6 +89,7 @@ export const actionChangeExportScale = register({
export const actionChangeExportBackground = register({
name: "changeExportBackground",
trackEvent: { category: "export", action: "toggleBackground" },
perform: (_elements, appState, value) => {
return {
appState: { ...appState, exportBackground: value },
@@ -107,6 +108,7 @@ export const actionChangeExportBackground = register({
export const actionChangeExportEmbedScene = register({
name: "changeExportEmbedScene",
trackEvent: { category: "export", action: "embedScene" },
perform: (_elements, appState, value) => {
return {
appState: { ...appState, exportEmbedScene: value },
@@ -128,6 +130,7 @@ export const actionChangeExportEmbedScene = register({
export const actionSaveToActiveFile = register({
name: "saveToActiveFile",
trackEvent: { category: "export" },
perform: async (elements, appState, value, app) => {
const fileHandleExists = !!appState.fileHandle;
@@ -172,6 +175,7 @@ export const actionSaveToActiveFile = register({
export const actionSaveFileToDisk = register({
name: "saveFileToDisk",
trackEvent: { category: "export" },
perform: async (elements, appState, value, app) => {
try {
const { fileHandle } = await saveAsJSON(
@@ -200,7 +204,7 @@ export const actionSaveFileToDisk = register({
icon={saveAs}
title={t("buttons.saveAs")}
aria-label={t("buttons.saveAs")}
showAriaLabel={useIsMobile()}
showAriaLabel={useDevice().isMobile}
hidden={!nativeFileSystemSupported}
onClick={() => updateData(null)}
data-testid="save-as-button"
@@ -210,6 +214,7 @@ export const actionSaveFileToDisk = register({
export const actionLoadScene = register({
name: "loadScene",
trackEvent: { category: "export" },
perform: async (elements, appState, _, app) => {
try {
const {
@@ -243,7 +248,7 @@ export const actionLoadScene = register({
icon={load}
title={t("buttons.load")}
aria-label={t("buttons.load")}
showAriaLabel={useIsMobile()}
showAriaLabel={useDevice().isMobile}
onClick={updateData}
data-testid="load-button"
/>
@@ -252,6 +257,7 @@ export const actionLoadScene = register({
export const actionExportWithDarkMode = register({
name: "exportWithDarkMode",
trackEvent: { category: "export", action: "toggleTheme" },
perform: (_elements, appState, value) => {
return {
appState: { ...appState, exportWithDarkMode: value },
+43 -15
View File
@@ -1,6 +1,6 @@
import { KEYS } from "../keys";
import { isInvisiblySmallElement } from "../element";
import { resetCursor } from "../utils";
import { updateActiveTool, resetCursor } from "../utils";
import { ToolButton } from "../components/ToolButton";
import { done } from "../components/icons";
import { t } from "../i18n";
@@ -14,10 +14,12 @@ import {
bindOrUnbindLinearElement,
} from "../element/binding";
import { isBindingElement } from "../element/typeChecks";
import { AppState } from "../types";
export const actionFinalize = register({
name: "finalize",
perform: (elements, appState, _, { canvas, focusContainer }) => {
trackEvent: false,
perform: (elements, appState, _, { canvas, focusContainer, scene }) => {
if (appState.editingLinearElement) {
const { elementId, startBindingElement, endBindingElement } =
appState.editingLinearElement;
@@ -38,6 +40,7 @@ export const actionFinalize = register({
: undefined,
appState: {
...appState,
cursorButton: "up",
editingLinearElement: null,
},
commitToHistory: true,
@@ -47,8 +50,12 @@ export const actionFinalize = register({
let newElements = elements;
if (appState.pendingImageElement) {
mutateElement(appState.pendingImageElement, { isDeleted: true }, false);
const pendingImageElement =
appState.pendingImageElementId &&
scene.getElement(appState.pendingImageElementId);
if (pendingImageElement) {
mutateElement(pendingImageElement, { isDeleted: true }, false);
}
if (window.document.activeElement instanceof HTMLElement) {
@@ -119,27 +126,47 @@ export const actionFinalize = register({
);
}
if (!appState.elementLocked && appState.elementType !== "freedraw") {
if (
!appState.activeTool.locked &&
appState.activeTool.type !== "freedraw"
) {
appState.selectedElementIds[multiPointElement.id] = true;
}
}
if (
(!appState.elementLocked && appState.elementType !== "freedraw") ||
(!appState.activeTool.locked &&
appState.activeTool.type !== "freedraw") ||
!multiPointElement
) {
resetCursor(canvas);
}
let activeTool: AppState["activeTool"];
if (appState.activeTool.type === "eraser") {
activeTool = updateActiveTool(appState, {
...(appState.activeTool.lastActiveToolBeforeEraser || {
type: "selection",
}),
lastActiveToolBeforeEraser: null,
});
} else {
activeTool = updateActiveTool(appState, {
type: "selection",
});
}
return {
elements: newElements,
appState: {
...appState,
elementType:
(appState.elementLocked || appState.elementType === "freedraw") &&
cursorButton: "up",
activeTool:
(appState.activeTool.locked ||
appState.activeTool.type === "freedraw") &&
multiPointElement
? appState.elementType
: "selection",
? appState.activeTool
: activeTool,
draggingElement: null,
multiElement: null,
editingElement: null,
@@ -147,16 +174,16 @@ export const actionFinalize = register({
suggestedBindings: [],
selectedElementIds:
multiPointElement &&
!appState.elementLocked &&
appState.elementType !== "freedraw"
!appState.activeTool.locked &&
appState.activeTool.type !== "freedraw"
? {
...appState.selectedElementIds,
[multiPointElement.id]: true,
}
: appState.selectedElementIds,
pendingImageElement: null,
pendingImageElementId: null,
},
commitToHistory: appState.elementType === "freedraw",
commitToHistory: appState.activeTool.type === "freedraw",
};
},
keyTest: (event, appState) =>
@@ -165,7 +192,7 @@ export const actionFinalize = register({
(!appState.draggingElement && appState.multiElement === null))) ||
((event.key === KEYS.ESCAPE || event.key === KEYS.ENTER) &&
appState.multiElement !== null),
PanelComponent: ({ appState, updateData }) => (
PanelComponent: ({ appState, updateData, data }) => (
<ToolButton
type="button"
icon={done}
@@ -173,6 +200,7 @@ export const actionFinalize = register({
aria-label={t("buttons.done")}
onClick={updateData}
visible={appState.multiElement != null}
size={data?.size || "medium"}
/>
),
});
+2
View File
@@ -35,6 +35,7 @@ const enableActionFlipVertical = (
export const actionFlipHorizontal = register({
name: "flipHorizontal",
trackEvent: { category: "element" },
perform: (elements, appState) => {
return {
elements: flipSelectedElements(elements, appState, "horizontal"),
@@ -50,6 +51,7 @@ export const actionFlipHorizontal = register({
export const actionFlipVertical = register({
name: "flipVertical",
trackEvent: { category: "element" },
perform: (elements, appState) => {
return {
elements: flipSelectedElements(elements, appState, "vertical"),
+2
View File
@@ -54,6 +54,7 @@ const enableActionGroup = (
export const actionGroup = register({
name: "group",
trackEvent: { category: "element" },
perform: (elements, appState) => {
const selectedElements = getSelectedElements(
getNonDeletedElements(elements),
@@ -147,6 +148,7 @@ export const actionGroup = register({
export const actionUngroup = register({
name: "ungroup",
trackEvent: { category: "element" },
perform: (elements, appState) => {
const groupIds = getSelectedGroupIds(appState);
if (groupIds.length === 0) {
+2
View File
@@ -62,6 +62,7 @@ type ActionCreator = (history: History) => Action;
export const createUndoAction: ActionCreator = (history) => ({
name: "undo",
trackEvent: { category: "history" },
perform: (elements, appState) =>
writeData(elements, appState, () => history.undoOnce()),
keyTest: (event) =>
@@ -82,6 +83,7 @@ export const createUndoAction: ActionCreator = (history) => ({
export const createRedoAction: ActionCreator = (history) => ({
name: "redo",
trackEvent: { category: "history" },
perform: (elements, appState) =>
writeData(elements, appState, () => history.redoOnce()),
keyTest: (event) =>
+4
View File
@@ -9,6 +9,7 @@ import { HelpIcon } from "../components/HelpIcon";
export const actionToggleCanvasMenu = register({
name: "toggleCanvasMenu",
trackEvent: { category: "menu" },
perform: (_, appState) => ({
appState: {
...appState,
@@ -29,6 +30,7 @@ export const actionToggleCanvasMenu = register({
export const actionToggleEditMenu = register({
name: "toggleEditMenu",
trackEvent: { category: "menu" },
perform: (_elements, appState) => ({
appState: {
...appState,
@@ -53,6 +55,7 @@ export const actionToggleEditMenu = register({
export const actionFullScreen = register({
name: "toggleFullScreen",
trackEvent: { category: "canvas", predicate: (appState) => !isFullScreen() },
perform: () => {
if (!isFullScreen()) {
allowFullScreen();
@@ -69,6 +72,7 @@ export const actionFullScreen = register({
export const actionShortcuts = register({
name: "toggleShortcuts",
trackEvent: { category: "menu", action: "toggleHelpDialog" },
perform: (_elements, appState, _, { focusContainer }) => {
if (appState.showHelpDialog) {
focusContainer();
+6 -15
View File
@@ -1,4 +1,4 @@
import { getClientColors, getClientInitials } from "../clients";
import { getClientColors } from "../clients";
import { Avatar } from "../components/Avatar";
import { centerScrollOn } from "../scene/scroll";
import { Collaborator } from "../types";
@@ -6,6 +6,7 @@ import { register } from "./register";
export const actionGoToCollaborator = register({
name: "goToCollaborator",
trackEvent: { category: "collab" },
perform: (_elements, appState, value) => {
const point = value as Collaborator["pointer"];
if (!point) {
@@ -30,28 +31,18 @@ export const actionGoToCollaborator = register({
};
},
PanelComponent: ({ appState, updateData, data }) => {
const clientId: string | undefined = data?.id;
if (!clientId) {
return null;
}
const collaborator = appState.collaborators.get(clientId);
if (!collaborator) {
return null;
}
const [clientId, collaborator] = data as [string, Collaborator];
const { background, stroke } = getClientColors(clientId, appState);
const shortName = getClientInitials(collaborator.username);
return (
<Avatar
color={background}
border={stroke}
onClick={() => updateData(collaborator.pointer)}
>
{shortName}
</Avatar>
name={collaborator.username || ""}
src={collaborator.avatarUrl}
/>
);
},
});
+31 -42
View File
@@ -166,11 +166,7 @@ const changeFontSize = (
let newElement: ExcalidrawTextElement = newElementWith(oldElement, {
fontSize: newFontSize,
});
redrawTextBoundingBox(
newElement,
getContainerElement(oldElement),
appState,
);
redrawTextBoundingBox(newElement, getContainerElement(oldElement));
newElement = offsetElementAfterFontResize(oldElement, newElement);
@@ -198,6 +194,7 @@ const changeFontSize = (
export const actionChangeStrokeColor = register({
name: "changeStrokeColor",
trackEvent: false,
perform: (elements, appState, value) => {
return {
...(value.currentItemStrokeColor && {
@@ -247,6 +244,7 @@ export const actionChangeStrokeColor = register({
export const actionChangeBackgroundColor = register({
name: "changeBackgroundColor",
trackEvent: false,
perform: (elements, appState, value) => {
return {
...(value.currentItemBackgroundColor && {
@@ -289,6 +287,7 @@ export const actionChangeBackgroundColor = register({
export const actionChangeFillStyle = register({
name: "changeFillStyle",
trackEvent: false,
perform: (elements, appState, value) => {
return {
elements: changeProperty(elements, appState, (el) =>
@@ -338,6 +337,7 @@ export const actionChangeFillStyle = register({
export const actionChangeStrokeWidth = register({
name: "changeStrokeWidth",
trackEvent: false,
perform: (elements, appState, value) => {
return {
elements: changeProperty(elements, appState, (el) =>
@@ -385,6 +385,7 @@ export const actionChangeStrokeWidth = register({
export const actionChangeSloppiness = register({
name: "changeSloppiness",
trackEvent: false,
perform: (elements, appState, value) => {
return {
elements: changeProperty(elements, appState, (el) =>
@@ -433,6 +434,7 @@ export const actionChangeSloppiness = register({
export const actionChangeStrokeStyle = register({
name: "changeStrokeStyle",
trackEvent: false,
perform: (elements, appState, value) => {
return {
elements: changeProperty(elements, appState, (el) =>
@@ -480,12 +482,17 @@ export const actionChangeStrokeStyle = register({
export const actionChangeOpacity = register({
name: "changeOpacity",
trackEvent: false,
perform: (elements, appState, value) => {
return {
elements: changeProperty(elements, appState, (el) =>
newElementWith(el, {
opacity: value,
}),
elements: changeProperty(
elements,
appState,
(el) =>
newElementWith(el, {
opacity: value,
}),
true,
),
appState: { ...appState, currentItemOpacity: value },
commitToHistory: true,
@@ -500,20 +507,6 @@ export const actionChangeOpacity = register({
max="100"
step="10"
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={
getFormValue(
elements,
@@ -529,6 +522,7 @@ export const actionChangeOpacity = register({
export const actionChangeFontSize = register({
name: "changeFontSize",
trackEvent: false,
perform: (elements, appState, value) => {
return changeFontSize(elements, appState, () => value, value);
},
@@ -586,6 +580,7 @@ export const actionChangeFontSize = register({
export const actionDecreaseFontSize = register({
name: "decreaseFontSize",
trackEvent: false,
perform: (elements, appState, value) => {
return changeFontSize(elements, appState, (element) =>
Math.round(
@@ -607,6 +602,7 @@ export const actionDecreaseFontSize = register({
export const actionIncreaseFontSize = register({
name: "increaseFontSize",
trackEvent: false,
perform: (elements, appState, value) => {
return changeFontSize(elements, appState, (element) =>
Math.round(element.fontSize * (1 + FONT_SIZE_RELATIVE_INCREASE_STEP)),
@@ -624,6 +620,7 @@ export const actionIncreaseFontSize = register({
export const actionChangeFontFamily = register({
name: "changeFontFamily",
trackEvent: false,
perform: (elements, appState, value) => {
return {
elements: changeProperty(
@@ -637,11 +634,7 @@ export const actionChangeFontFamily = register({
fontFamily: value,
},
);
redrawTextBoundingBox(
newElement,
getContainerElement(oldElement),
appState,
);
redrawTextBoundingBox(newElement, getContainerElement(oldElement));
return newElement;
}
@@ -709,6 +702,7 @@ export const actionChangeFontFamily = register({
export const actionChangeTextAlign = register({
name: "changeTextAlign",
trackEvent: false,
perform: (elements, appState, value) => {
return {
elements: changeProperty(
@@ -720,11 +714,7 @@ export const actionChangeTextAlign = register({
oldElement,
{ textAlign: value },
);
redrawTextBoundingBox(
newElement,
getContainerElement(oldElement),
appState,
);
redrawTextBoundingBox(newElement, getContainerElement(oldElement));
return newElement;
}
@@ -785,6 +775,7 @@ export const actionChangeTextAlign = register({
});
export const actionChangeVerticalAlign = register({
name: "changeVerticalAlign",
trackEvent: { category: "element" },
perform: (elements, appState, value) => {
return {
elements: changeProperty(
@@ -797,11 +788,7 @@ export const actionChangeVerticalAlign = register({
{ verticalAlign: value },
);
redrawTextBoundingBox(
newElement,
getContainerElement(oldElement),
appState,
);
redrawTextBoundingBox(newElement, getContainerElement(oldElement));
return newElement;
}
@@ -856,6 +843,7 @@ export const actionChangeVerticalAlign = register({
export const actionChangeSharpness = register({
name: "changeSharpness",
trackEvent: false,
perform: (elements, appState, value) => {
const targetElements = getTargetElements(
getNonDeletedElements(elements),
@@ -863,10 +851,10 @@ export const actionChangeSharpness = register({
);
const shouldUpdateForNonLinearElements = targetElements.length
? targetElements.every((el) => !isLinearElement(el))
: !isLinearElementType(appState.elementType);
: !isLinearElementType(appState.activeTool.type);
const shouldUpdateForLinearElements = targetElements.length
? targetElements.every(isLinearElement)
: isLinearElementType(appState.elementType);
: isLinearElementType(appState.activeTool.type);
return {
elements: changeProperty(elements, appState, (el) =>
newElementWith(el, {
@@ -906,8 +894,8 @@ export const actionChangeSharpness = register({
elements,
appState,
(element) => element.strokeSharpness,
(canChangeSharpness(appState.elementType) &&
(isLinearElementType(appState.elementType)
(canChangeSharpness(appState.activeTool.type) &&
(isLinearElementType(appState.activeTool.type)
? appState.currentItemLinearStrokeSharpness
: appState.currentItemStrokeSharpness)) ||
null,
@@ -920,6 +908,7 @@ export const actionChangeSharpness = register({
export const actionChangeArrowhead = register({
name: "changeArrowhead",
trackEvent: false,
perform: (
elements,
appState,
+3 -1
View File
@@ -5,6 +5,7 @@ import { getNonDeletedElements, isTextElement } from "../element";
export const actionSelectAll = register({
name: "selectAll",
trackEvent: { category: "canvas" },
perform: (elements, appState) => {
if (appState.editingLinearElement) {
return false;
@@ -17,7 +18,8 @@ export const actionSelectAll = register({
selectedElementIds: elements.reduce((map, element) => {
if (
!element.isDeleted &&
!(isTextElement(element) && element.containerId)
!(isTextElement(element) && element.containerId) &&
element.locked === false
) {
map[element.id] = true;
}
+1 -1
View File
@@ -48,7 +48,7 @@ describe("actionStyles", () => {
Keyboard.withModifierKeys({ ctrl: true, alt: true }, () => {
Keyboard.codeDown(CODES.C);
});
const secondRect = JSON.parse(copiedStyles);
const secondRect = JSON.parse(copiedStyles)[0];
expect(secondRect.id).toBe(h.elements[1].id);
mouse.reset();
+63 -26
View File
@@ -6,23 +6,32 @@ import {
import { CODES, KEYS } from "../keys";
import { t } from "../i18n";
import { register } from "./register";
import { mutateElement, newElementWith } from "../element/mutateElement";
import { newElementWith } from "../element/mutateElement";
import {
DEFAULT_FONT_SIZE,
DEFAULT_FONT_FAMILY,
DEFAULT_TEXT_ALIGN,
} from "../constants";
import { getContainerElement } from "../element/textElement";
import { getBoundTextElement } from "../element/textElement";
import { hasBoundTextElement } from "../element/typeChecks";
import { getSelectedElements } from "../scene";
// `copiedStyles` is exported only for tests.
export let copiedStyles: string = "{}";
export const actionCopyStyles = register({
name: "copyStyles",
trackEvent: { category: "element" },
perform: (elements, appState) => {
const elementsCopied = [];
const element = elements.find((el) => appState.selectedElementIds[el.id]);
elementsCopied.push(element);
if (element && hasBoundTextElement(element)) {
const boundTextElement = getBoundTextElement(element);
elementsCopied.push(boundTextElement);
}
if (element) {
copiedStyles = JSON.stringify(element);
copiedStyles = JSON.stringify(elementsCopied);
}
return {
appState: {
@@ -39,36 +48,64 @@ export const actionCopyStyles = register({
export const actionPasteStyles = register({
name: "pasteStyles",
trackEvent: { category: "element" },
perform: (elements, appState) => {
const pastedElement = JSON.parse(copiedStyles);
const elementsCopied = JSON.parse(copiedStyles);
const pastedElement = elementsCopied[0];
const boundTextElement = elementsCopied[1];
if (!isExcalidrawElement(pastedElement)) {
return { elements, commitToHistory: false };
}
const selectedElements = getSelectedElements(elements, appState, true);
const selectedElementIds = selectedElements.map((element) => element.id);
return {
elements: elements.map((element) => {
if (appState.selectedElementIds[element.id]) {
const newElement = newElementWith(element, {
backgroundColor: pastedElement?.backgroundColor,
strokeWidth: pastedElement?.strokeWidth,
strokeColor: pastedElement?.strokeColor,
strokeStyle: pastedElement?.strokeStyle,
fillStyle: pastedElement?.fillStyle,
opacity: pastedElement?.opacity,
roughness: pastedElement?.roughness,
});
if (isTextElement(newElement) && isTextElement(element)) {
mutateElement(newElement, {
fontSize: pastedElement?.fontSize || DEFAULT_FONT_SIZE,
fontFamily: pastedElement?.fontFamily || DEFAULT_FONT_FAMILY,
textAlign: pastedElement?.textAlign || DEFAULT_TEXT_ALIGN,
});
redrawTextBoundingBox(
newElement,
getContainerElement(newElement),
appState,
);
if (selectedElementIds.includes(element.id)) {
let elementStylesToCopyFrom = pastedElement;
if (isTextElement(element) && element.containerId) {
elementStylesToCopyFrom = boundTextElement;
}
if (!elementStylesToCopyFrom) {
return element;
}
let newElement = newElementWith(element, {
backgroundColor: elementStylesToCopyFrom?.backgroundColor,
strokeWidth: elementStylesToCopyFrom?.strokeWidth,
strokeColor: elementStylesToCopyFrom?.strokeColor,
strokeStyle: elementStylesToCopyFrom?.strokeStyle,
fillStyle: elementStylesToCopyFrom?.fillStyle,
opacity: elementStylesToCopyFrom?.opacity,
roughness: elementStylesToCopyFrom?.roughness,
});
if (isTextElement(newElement)) {
newElement = newElementWith(newElement, {
fontSize: elementStylesToCopyFrom?.fontSize || DEFAULT_FONT_SIZE,
fontFamily:
elementStylesToCopyFrom?.fontFamily || DEFAULT_FONT_FAMILY,
textAlign:
elementStylesToCopyFrom?.textAlign || DEFAULT_TEXT_ALIGN,
});
let container = null;
if (newElement.containerId) {
container =
selectedElements.find(
(element) =>
isTextElement(newElement) &&
element.id === newElement.containerId,
) || null;
}
redrawTextBoundingBox(newElement, container);
}
if (newElement.type === "arrow") {
newElement = newElementWith(newElement, {
startArrowhead: elementStylesToCopyFrom.startArrowhead,
endArrowhead: elementStylesToCopyFrom.endArrowhead,
});
}
return newElement;
}
return element;
+4 -2
View File
@@ -2,12 +2,14 @@ import { CODES, KEYS } from "../keys";
import { register } from "./register";
import { GRID_SIZE } from "../constants";
import { AppState } from "../types";
import { trackEvent } from "../analytics";
export const actionToggleGridMode = register({
name: "gridMode",
trackEvent: {
category: "canvas",
predicate: (appState) => !appState.gridSize,
},
perform(elements, appState) {
trackEvent("view", "mode", "grid");
return {
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({
name: "stats",
trackEvent: { category: "menu" },
perform(elements, appState) {
return {
appState: {
+4 -2
View File
@@ -1,11 +1,13 @@
import { CODES, KEYS } from "../keys";
import { register } from "./register";
import { trackEvent } from "../analytics";
export const actionToggleViewMode = register({
name: "viewMode",
trackEvent: {
category: "canvas",
predicate: (appState) => !appState.viewModeEnabled,
},
perform(elements, appState) {
trackEvent("view", "mode", "view");
return {
appState: {
...appState,
+4 -3
View File
@@ -1,12 +1,13 @@
import { CODES, KEYS } from "../keys";
import { register } from "./register";
import { trackEvent } from "../analytics";
export const actionToggleZenMode = register({
name: "zenMode",
trackEvent: {
category: "canvas",
predicate: (appState) => !appState.zenModeEnabled,
},
perform(elements, appState) {
trackEvent("view", "mode", "zen");
return {
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({
name: "sendBackward",
trackEvent: { category: "element" },
perform: (elements, appState) => {
return {
elements: moveOneLeft(elements, appState),
@@ -45,6 +46,7 @@ export const actionSendBackward = register({
export const actionBringForward = register({
name: "bringForward",
trackEvent: { category: "element" },
perform: (elements, appState) => {
return {
elements: moveOneRight(elements, appState),
@@ -72,6 +74,7 @@ export const actionBringForward = register({
export const actionSendToBack = register({
name: "sendToBack",
trackEvent: { category: "element" },
perform: (elements, appState) => {
return {
elements: moveAllLeft(elements, appState),
@@ -106,6 +109,8 @@ export const actionSendToBack = register({
export const actionBringToFront = register({
name: "bringToFront",
trackEvent: { category: "element" },
perform: (elements, appState) => {
return {
elements: moveAllRight(elements, appState),
+3 -1
View File
@@ -75,11 +75,13 @@ export {
actionCut,
actionCopyAsPng,
actionCopyAsSvg,
copyText,
} from "./actionClipboard";
export { actionToggleGridMode } from "./actionToggleGridMode";
export { actionToggleZenMode } from "./actionToggleZenMode";
export { actionToggleStats } from "./actionToggleStats";
export { actionUnbindText } from "./actionUnbindText";
export { actionUnbindText, actionBindText } from "./actionBoundText";
export { actionLink } from "../element/Hyperlink";
export { actionToggleLock } from "./actionToggleLock";
+38 -36
View File
@@ -1,11 +1,11 @@
import React from "react";
import {
Action,
ActionsManagerInterface,
UpdaterFn,
ActionName,
ActionResult,
PanelComponentProps,
ActionSource,
} from "./types";
import { ExcalidrawElement } from "../element/types";
import { AppClassProperties, AppState } from "../types";
@@ -14,21 +14,25 @@ import { trackEvent } from "../analytics";
const trackAction = (
action: Action,
source: "ui" | "keyboard" | "api",
source: ActionSource,
appState: Readonly<AppState>,
elements: readonly ExcalidrawElement[],
app: AppClassProperties,
value: any,
) => {
if (action.trackEvent !== false) {
if (action.trackEvent) {
try {
if (action.trackEvent === true) {
trackEvent(
action.name,
source,
typeof value === "number" || typeof value === "string"
? String(value)
: undefined,
);
} else {
action.trackEvent?.(action, source, value);
if (typeof action.trackEvent === "object") {
const shouldTrack = action.trackEvent.predicate
? action.trackEvent.predicate(appState, elements, value)
: true;
if (shouldTrack) {
trackEvent(
action.trackEvent.category,
action.trackEvent.action || action.name,
`${source} (${app.device.isMobile ? "mobile" : "desktop"})`,
);
}
}
} catch (error) {
console.error("error while logging action:", error);
@@ -36,8 +40,8 @@ const trackAction = (
}
};
export class ActionManager implements ActionsManagerInterface {
actions = {} as ActionsManagerInterface["actions"];
export class ActionManager {
actions = {} as Record<ActionName, Action>;
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();
this.updater(
data[0].perform(
this.getElementsIncludingDeleted(),
this.getAppState(),
null,
this.app,
),
);
event.stopPropagation();
this.updater(data[0].perform(elements, appState, value, this.app));
return true;
}
executeAction(action: Action) {
this.updater(
action.perform(
this.getElementsIncludingDeleted(),
this.getAppState(),
null,
this.app,
),
);
trackAction(action, "api", null);
executeAction(action: Action, source: ActionSource = "api") {
const elements = this.getElementsIncludingDeleted();
const appState = this.getAppState();
const value = null;
trackAction(action, source, appState, elements, this.app, value);
this.updater(action.perform(elements, appState, value, this.app));
}
/**
@@ -147,7 +147,11 @@ export class ActionManager implements ActionsManagerInterface {
) {
const action = this.actions[name];
const PanelComponent = action.PanelComponent!;
const elements = this.getElementsIncludingDeleted();
const appState = this.getAppState();
const updateData = (formState?: any) => {
trackAction(action, "ui", appState, elements, this.app, formState);
this.updater(
action.perform(
this.getElementsIncludingDeleted(),
@@ -156,8 +160,6 @@ export class ActionManager implements ActionsManagerInterface {
this.app,
),
);
trackAction(action, "ui", formState);
};
return (
+2
View File
@@ -29,6 +29,7 @@ export type ShortcutName = SubtypeOf<
| "flipHorizontal"
| "flipVertical"
| "hyperlink"
| "toggleLock"
>;
const shortcutMap: Record<ShortcutName, string[]> = {
@@ -67,6 +68,7 @@ const shortcutMap: Record<ShortcutName, string[]> = {
flipVertical: [getShortcutKey("Shift+V")],
viewMode: [getShortcutKey("Alt+R")],
hyperlink: [getShortcutKey("CtrlOrCmd+K")],
toggleLock: [getShortcutKey("CtrlOrCmd+Shift+L")],
};
export const getShortcutFromShortcutName = (name: ShortcutName) => {
+26 -14
View File
@@ -6,7 +6,8 @@ import {
ExcalidrawProps,
BinaryFiles,
} from "../types";
import { ToolButtonSize } from "../components/ToolButton";
export type ActionSource = "ui" | "keyboard" | "contextMenu" | "api";
/** if false, the action should be prevented */
export type ActionResult =
@@ -39,6 +40,7 @@ export type ActionName =
| "paste"
| "copyAsPng"
| "copyAsSvg"
| "copyText"
| "sendBackward"
| "bringForward"
| "sendToBack"
@@ -107,14 +109,16 @@ export type ActionName =
| "decreaseFontSize"
| "unbindText"
| "hyperlink"
| "eraser";
| "eraser"
| "bindText"
| "toggleLock";
export type PanelComponentProps = {
elements: readonly ExcalidrawElement[];
appState: AppState;
updateData: (formData?: any) => void;
appProps: ExcalidrawProps;
data?: Partial<{ id: string; size: ToolButtonSize }>;
data?: Record<string, any>;
};
export interface Action {
@@ -138,15 +142,23 @@ export interface Action {
appState: AppState,
) => boolean;
checked?: (appState: Readonly<AppState>) => boolean;
trackEvent?:
| boolean
| ((action: Action, type: "ui" | "keyboard" | "api", value: any) => void);
}
export interface ActionsManagerInterface {
actions: Record<ActionName, Action>;
registerAction: (action: Action) => void;
handleKeyDown: (event: React.KeyboardEvent | KeyboardEvent) => boolean;
renderAction: (name: ActionName) => React.ReactElement | null;
executeAction: (action: Action) => void;
trackEvent:
| false
| {
category:
| "toolbar"
| "element"
| "canvas"
| "export"
| "history"
| "menu"
| "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" &&
window.gtag
? (category: string, action: string, label?: string, value?: number) => {
window.gtag("event", action, {
event_category: category,
event_label: label,
value,
});
try {
window.gtag("event", action, {
event_category: category,
event_label: label,
value,
});
} catch (error) {
console.error("error logging to ga", error);
}
}
: typeof process !== "undefined" && process.env?.JEST_WORKER_ID
? (category: string, action: string, label?: string, value?: number) => {}
: (category: string, action: string, label?: string, value?: number) => {
// Uncomment the next line to track locally
// console.info("Track Event", category, action, label, value);
// console.log("Track Event", { category, action, label, value });
};
+227 -80
View File
@@ -41,8 +41,12 @@ export const getDefaultAppState = (): Omit<
editingElement: null,
editingGroupId: null,
editingLinearElement: null,
elementLocked: false,
elementType: "selection",
activeTool: {
type: "selection",
customType: null,
locked: false,
lastActiveToolBeforeEraser: null,
},
penMode: false,
penDetected: false,
errorMessage: null,
@@ -54,6 +58,7 @@ export const getDefaultAppState = (): Omit<
gridSize: null,
isBindingEnabled: true,
isLibraryOpen: false,
isLibraryMenuDocked: false,
isLoading: false,
isResizing: false,
isRotating: false,
@@ -83,7 +88,7 @@ export const getDefaultAppState = (): Omit<
value: 1 as NormalizedZoomValue,
},
viewModeEnabled: false,
pendingImageElement: null,
pendingImageElementId: null,
showHyperlinkPopup: false,
};
};
@@ -96,90 +101,228 @@ const APP_STATE_STORAGE_CONF = (<
Values extends {
/** whether to keep when storing to browser storage (localStorage/IDB) */
browser: boolean;
/** whether to keep when exporting to file/database */
export: boolean;
/** whether to keep when exporting to a text file */
text: boolean;
/** whether to keep when exporting to an image file */
image: boolean;
/** server (shareLink/collab/...) */
server: boolean;
},
T extends Record<keyof AppState, Values>,
>(config: { [K in keyof T]: K extends keyof AppState ? T[K] : never }) =>
config)({
theme: { browser: true, export: false, server: false },
collaborators: { browser: false, export: false, server: false },
currentChartType: { browser: true, export: false, server: false },
currentItemBackgroundColor: { browser: true, export: false, server: false },
currentItemEndArrowhead: { browser: true, export: false, server: false },
currentItemFillStyle: { browser: true, export: false, server: false },
currentItemFontFamily: { browser: true, export: false, server: false },
currentItemFontSize: { browser: true, export: false, server: false },
currentItemLinearStrokeSharpness: {
theme: { browser: true, text: false, image: false, server: false },
collaborators: { browser: false, text: false, image: false, server: false },
currentChartType: { browser: true, text: false, image: false, server: false },
currentItemBackgroundColor: {
browser: true,
export: false,
text: false,
image: false,
server: false,
},
currentItemEndArrowhead: {
browser: true,
text: false,
image: false,
server: false,
},
currentItemFillStyle: {
browser: true,
text: false,
image: false,
server: false,
},
currentItemFontFamily: {
browser: true,
text: false,
image: false,
server: false,
},
currentItemFontSize: {
browser: true,
text: false,
image: false,
server: false,
},
currentItemLinearStrokeSharpness: {
browser: true,
text: false,
image: false,
server: false,
},
currentItemOpacity: {
browser: true,
text: false,
image: false,
server: false,
},
currentItemRoughness: {
browser: true,
text: false,
image: false,
server: false,
},
currentItemStartArrowhead: {
browser: true,
text: false,
image: false,
server: false,
},
currentItemStrokeColor: {
browser: true,
text: false,
image: false,
server: false,
},
currentItemStrokeSharpness: {
browser: true,
text: false,
image: false,
server: false,
},
currentItemStrokeStyle: {
browser: true,
text: false,
image: false,
server: false,
},
currentItemStrokeWidth: {
browser: true,
text: false,
image: false,
server: false,
},
currentItemTextAlign: {
browser: true,
text: false,
image: false,
server: false,
},
cursorButton: { browser: true, text: false, image: false, server: false },
draggingElement: { browser: false, text: false, image: false, server: false },
editingElement: { browser: false, text: false, image: false, server: false },
editingGroupId: { browser: true, text: false, image: false, server: false },
editingLinearElement: {
browser: false,
text: false,
image: false,
server: false,
},
activeTool: { browser: true, text: false, image: false, server: false },
penMode: { browser: true, text: false, image: false, server: false },
penDetected: { browser: true, text: false, image: false, server: false },
errorMessage: { browser: false, text: false, image: false, server: false },
exportBackground: { browser: true, text: false, image: true, server: false },
exportEmbedScene: { browser: true, text: false, image: true, server: false },
exportScale: { browser: true, text: false, image: true, server: false },
exportWithDarkMode: {
browser: true,
text: false,
image: true,
server: false,
},
fileHandle: { browser: false, text: false, image: false, server: false },
gridSize: { browser: true, text: true, image: true, server: true },
height: { browser: false, text: false, image: false, server: false },
isBindingEnabled: {
browser: false,
text: false,
image: false,
server: false,
},
isLibraryOpen: { browser: true, text: false, image: false, server: false },
isLibraryMenuDocked: {
browser: true,
text: false,
image: false,
server: false,
},
isLoading: { browser: false, text: false, image: false, server: false },
isResizing: { browser: false, text: false, image: false, server: false },
isRotating: { browser: false, text: false, image: false, server: false },
lastPointerDownWith: {
browser: true,
text: false,
image: false,
server: false,
},
multiElement: { browser: false, text: false, image: false, server: false },
name: { browser: true, text: false, image: false, server: false },
offsetLeft: { browser: false, text: false, image: false, server: false },
offsetTop: { browser: false, text: false, image: false, server: false },
openMenu: { browser: true, text: false, image: false, server: false },
openPopup: { browser: false, text: false, image: false, server: false },
pasteDialog: { browser: false, text: false, image: false, server: false },
previousSelectedElementIds: {
browser: true,
text: false,
image: false,
server: false,
},
resizingElement: { browser: false, text: false, image: false, server: false },
scrolledOutside: { browser: true, text: false, image: false, server: false },
scrollX: { browser: true, text: false, image: false, server: false },
scrollY: { browser: true, text: false, image: false, server: false },
selectedElementIds: {
browser: true,
text: false,
image: false,
server: false,
},
selectedGroupIds: { browser: true, text: false, image: false, server: false },
selectionElement: {
browser: false,
text: false,
image: false,
server: false,
},
shouldCacheIgnoreZoom: {
browser: true,
text: false,
image: false,
server: false,
},
showHelpDialog: { browser: false, text: false, image: false, server: false },
showStats: { browser: true, text: false, image: false, server: false },
startBoundElement: {
browser: false,
text: false,
image: false,
server: false,
},
suggestedBindings: {
browser: false,
text: false,
image: false,
server: false,
},
toastMessage: { browser: false, text: false, image: false, server: false },
viewBackgroundColor: {
browser: true,
text: true,
image: true,
server: true,
},
width: { browser: false, text: false, image: false, server: false },
zenModeEnabled: { browser: true, text: false, image: false, server: false },
zoom: { browser: true, text: false, image: false, server: false },
viewModeEnabled: { browser: false, text: false, image: false, server: false },
pendingImageElementId: {
browser: false,
text: false,
image: false,
server: false,
},
showHyperlinkPopup: {
browser: false,
text: false,
image: false,
server: false,
},
currentItemOpacity: { browser: true, export: false, server: false },
currentItemRoughness: { browser: true, export: false, server: false },
currentItemStartArrowhead: { browser: true, export: false, server: false },
currentItemStrokeColor: { browser: true, export: false, server: false },
currentItemStrokeSharpness: { browser: true, export: false, server: false },
currentItemStrokeStyle: { browser: true, export: false, server: false },
currentItemStrokeWidth: { browser: true, export: false, server: false },
currentItemTextAlign: { browser: true, export: false, server: false },
cursorButton: { browser: true, export: false, server: false },
draggingElement: { browser: false, export: false, server: false },
editingElement: { browser: false, export: false, server: false },
editingGroupId: { browser: true, export: false, server: false },
editingLinearElement: { browser: false, export: false, server: false },
elementLocked: { browser: true, export: false, server: false },
elementType: { browser: true, export: false, server: false },
penMode: { browser: false, export: false, server: false },
penDetected: { browser: false, export: false, server: false },
errorMessage: { browser: false, export: false, server: false },
exportBackground: { browser: true, export: false, server: false },
exportEmbedScene: { browser: true, export: false, server: false },
exportScale: { browser: true, export: false, server: false },
exportWithDarkMode: { browser: true, export: false, server: false },
fileHandle: { browser: false, export: false, server: false },
gridSize: { browser: true, export: true, server: true },
height: { browser: false, export: false, server: false },
isBindingEnabled: { browser: false, export: false, server: false },
isLibraryOpen: { browser: false, export: false, server: false },
isLoading: { browser: false, export: false, server: false },
isResizing: { browser: false, export: false, server: false },
isRotating: { browser: false, export: false, server: false },
lastPointerDownWith: { browser: true, export: false, server: false },
multiElement: { browser: false, export: false, server: false },
name: { browser: true, export: false, server: false },
offsetLeft: { browser: false, export: false, server: false },
offsetTop: { browser: false, export: false, server: false },
openMenu: { browser: true, export: false, server: false },
openPopup: { browser: false, export: false, server: false },
pasteDialog: { browser: false, export: false, server: false },
previousSelectedElementIds: { browser: true, export: false, server: false },
resizingElement: { browser: false, export: false, server: false },
scrolledOutside: { browser: true, export: false, server: false },
scrollX: { browser: true, export: false, server: false },
scrollY: { browser: true, export: false, server: false },
selectedElementIds: { browser: true, export: false, server: false },
selectedGroupIds: { browser: true, export: false, server: false },
selectionElement: { browser: false, export: false, server: false },
shouldCacheIgnoreZoom: { browser: true, export: false, server: false },
showHelpDialog: { browser: false, export: false, server: false },
showStats: { browser: true, export: false, server: false },
startBoundElement: { browser: false, export: false, server: false },
suggestedBindings: { browser: false, export: false, server: false },
toastMessage: { browser: false, export: false, server: false },
viewBackgroundColor: { browser: true, export: true, server: true },
width: { browser: false, export: false, server: false },
zenModeEnabled: { browser: true, export: false, server: false },
zoom: { browser: true, export: false, server: false },
viewModeEnabled: { browser: false, export: false, server: false },
pendingImageElement: { browser: false, export: false, server: false },
showHyperlinkPopup: { browser: false, export: false, server: false },
});
const _clearAppStateForStorage = <
ExportType extends "export" | "browser" | "server",
ExportType extends "image" | "text" | "browser" | "server",
>(
appState: Partial<AppState>,
exportType: ExportType,
@@ -206,8 +349,12 @@ export const clearAppStateForLocalStorage = (appState: Partial<AppState>) => {
return _clearAppStateForStorage(appState, "browser");
};
export const cleanAppStateForExport = (appState: Partial<AppState>) => {
return _clearAppStateForStorage(appState, "export");
export const cleanAppStateForTextExport = (appState: Partial<AppState>) => {
return _clearAppStateForStorage(appState, "text");
};
export const cleanAppStateForImageExport = (appState: Partial<AppState>) => {
return _clearAppStateForStorage(appState, "image");
};
export const clearAppStateForDatabase = (appState: Partial<AppState>) => {
@@ -215,7 +362,7 @@ export const clearAppStateForDatabase = (appState: Partial<AppState>) => {
};
export const isEraserActive = ({
elementType,
activeTool,
}: {
elementType: AppState["elementType"];
}) => elementType === "eraser";
activeTool: AppState["activeTool"];
}) => activeTool.type === "eraser";
+121
View File
@@ -0,0 +1,121 @@
import {
Spreadsheet,
tryParseCells,
tryParseNumber,
VALID_SPREADSHEET,
} from "./charts";
describe("charts", () => {
describe("tryParseNumber", () => {
it.each<[string, number]>([
["1", 1],
["0", 0],
["-1", -1],
["0.1", 0.1],
[".1", 0.1],
["1.", 1],
["424.", 424],
["$1", 1],
["-.1", -0.1],
["-$1", -1],
["$-1", -1],
])("should correctly identify %s as numbers", (given, expected) => {
expect(tryParseNumber(given)).toEqual(expected);
});
it.each<[string]>([["a"], ["$"], ["$a"], ["-$a"]])(
"should correctly identify %s as not a number",
(given) => {
expect(tryParseNumber(given)).toBeNull();
},
);
});
describe("tryParseCells", () => {
it("Successfully parses a spreadsheet", () => {
const spreadsheet = [
["time", "value"],
["01:00", "61"],
["02:00", "-60"],
["03:00", "85"],
["04:00", "-67"],
["05:00", "54"],
["06:00", "95"],
];
const result = tryParseCells(spreadsheet);
expect(result.type).toBe(VALID_SPREADSHEET);
const { title, labels, values } = (
result as { type: typeof VALID_SPREADSHEET; spreadsheet: Spreadsheet }
).spreadsheet;
expect(title).toEqual("value");
expect(labels).toEqual([
"01:00",
"02:00",
"03:00",
"04:00",
"05:00",
"06:00",
]);
expect(values).toEqual([61, -60, 85, -67, 54, 95]);
});
it("Uses the second column as the label if it is not a number", () => {
const spreadsheet = [
["time", "value"],
["01:00", "61"],
["02:00", "-60"],
["03:00", "85"],
["04:00", "-67"],
["05:00", "54"],
["06:00", "95"],
];
const result = tryParseCells(spreadsheet);
expect(result.type).toBe(VALID_SPREADSHEET);
const { title, labels, values } = (
result as { type: typeof VALID_SPREADSHEET; spreadsheet: Spreadsheet }
).spreadsheet;
expect(title).toEqual("value");
expect(labels).toEqual([
"01:00",
"02:00",
"03:00",
"04:00",
"05:00",
"06:00",
]);
expect(values).toEqual([61, -60, 85, -67, 54, 95]);
});
it("treats the first column as labels if both columns are numbers", () => {
const spreadsheet = [
["time", "value"],
["01", "61"],
["02", "-60"],
["03", "85"],
["04", "-67"],
["05", "54"],
["06", "95"],
];
const result = tryParseCells(spreadsheet);
expect(result.type).toBe(VALID_SPREADSHEET);
const { title, labels, values } = (
result as { type: typeof VALID_SPREADSHEET; spreadsheet: Spreadsheet }
).spreadsheet;
expect(title).toEqual("value");
expect(labels).toEqual(["01", "02", "03", "04", "05", "06"]);
expect(values).toEqual([61, -60, 85, -67, 54, 95]);
});
});
});
+17 -7
View File
@@ -29,18 +29,24 @@ type ParseSpreadsheetResult =
| { type: typeof NOT_SPREADSHEET; reason: string }
| { type: typeof VALID_SPREADSHEET; spreadsheet: Spreadsheet };
const tryParseNumber = (s: string): number | null => {
const match = /^[$€£¥₩]?([0-9,]+(\.[0-9]+)?)$/.exec(s);
/**
* @private exported for testing
*/
export const tryParseNumber = (s: string): number | null => {
const match = /^([-+]?)[$€£¥₩]?([-+]?)([\d.,]+)[%]?$/.exec(s);
if (!match) {
return null;
}
return parseFloat(match[1].replace(/,/g, ""));
return parseFloat(`${(match[1] || match[2]) + match[3]}`.replace(/,/g, ""));
};
const isNumericColumn = (lines: string[][], columnIndex: number) =>
lines.slice(1).every((line) => tryParseNumber(line[columnIndex]) !== null);
const tryParseCells = (cells: string[][]): ParseSpreadsheetResult => {
/**
* @private exported for testing
*/
export const tryParseCells = (cells: string[][]): ParseSpreadsheetResult => {
const numCols = cells[0].length;
if (numCols > 2) {
@@ -71,13 +77,16 @@ const tryParseCells = (cells: string[][]): ParseSpreadsheetResult => {
};
}
const valueColumnIndex = isNumericColumn(cells, 0) ? 0 : 1;
const labelColumnNumeric = isNumericColumn(cells, 0);
const valueColumnNumeric = isNumericColumn(cells, 1);
if (!isNumericColumn(cells, valueColumnIndex)) {
if (!labelColumnNumeric && !valueColumnNumeric) {
return { type: NOT_SPREADSHEET, reason: "Value is not numeric" };
}
const labelColumnIndex = (valueColumnIndex + 1) % 2;
const [labelColumnIndex, valueColumnIndex] = valueColumnNumeric
? [0, 1]
: [1, 0];
const hasHeader = tryParseNumber(cells[0][valueColumnIndex]) === null;
const rows = hasHeader ? cells.slice(1) : cells;
@@ -167,6 +176,7 @@ const commonProps = {
strokeStyle: "solid",
strokeWidth: 1,
verticalAlign: VERTICAL_ALIGN.MIDDLE,
locked: false,
} as const;
const getChartDimentions = (spreadsheet: Spreadsheet) => {
+41 -15
View File
@@ -2,16 +2,16 @@ import {
ExcalidrawElement,
NonDeletedExcalidrawElement,
} from "./element/types";
import { getSelectedElements } from "./scene";
import { AppState, BinaryFiles } from "./types";
import { SVG_EXPORT_TAG } from "./scene/export";
import { tryParseSpreadsheet, Spreadsheet, VALID_SPREADSHEET } from "./charts";
import { EXPORT_DATA_TYPES, MIME_TYPES } from "./constants";
import { isInitializedImageElement } from "./element/typeChecks";
import { isPromiseLike } from "./utils";
type ElementsClipboard = {
type: typeof EXPORT_DATA_TYPES.excalidrawClipboard;
elements: ExcalidrawElement[];
elements: readonly NonDeletedExcalidrawElement[];
files: BinaryFiles | undefined;
};
@@ -56,19 +56,20 @@ const clipboardContainsElements = (
export const copyToClipboard = async (
elements: readonly NonDeletedExcalidrawElement[],
appState: AppState,
files: BinaryFiles,
files: BinaryFiles | null,
) => {
// select binded text elements when copying
const selectedElements = getSelectedElements(elements, appState, true);
const contents: ElementsClipboard = {
type: EXPORT_DATA_TYPES.excalidrawClipboard,
elements: selectedElements,
files: selectedElements.reduce((acc, element) => {
if (isInitializedImageElement(element) && files[element.fileId]) {
acc[element.fileId] = files[element.fileId];
}
return acc;
}, {} as BinaryFiles),
elements,
files: files
? elements.reduce((acc, element) => {
if (isInitializedImageElement(element) && files[element.fileId]) {
acc[element.fileId] = files[element.fileId];
}
return acc;
}, {} as BinaryFiles)
: undefined,
};
const json = JSON.stringify(contents);
CLIPBOARD = json;
@@ -166,10 +167,35 @@ export const parseClipboard = async (
}
};
export const copyBlobToClipboardAsPng = async (blob: Blob) => {
await navigator.clipboard.write([
new window.ClipboardItem({ [MIME_TYPES.png]: blob }),
]);
export const copyBlobToClipboardAsPng = async (blob: Blob | Promise<Blob>) => {
let promise;
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) => {
+51 -23
View File
@@ -3,7 +3,7 @@ import { ActionManager } from "../actions/manager";
import { getNonDeletedElements } from "../element";
import { ExcalidrawElement, PointerType } from "../element/types";
import { t } from "../i18n";
import { useIsMobile } from "../components/App";
import { useDevice } from "../components/App";
import {
canChangeSharpness,
canHaveArrowheads,
@@ -15,22 +15,28 @@ import {
} from "../scene";
import { SHAPES } from "../shapes";
import { AppState, Zoom } from "../types";
import { capitalizeString, isTransparent, setCursorForShape } from "../utils";
import {
capitalizeString,
isTransparent,
updateActiveTool,
setCursorForShape,
} from "../utils";
import Stack from "./Stack";
import { ToolButton } from "./ToolButton";
import { hasStrokeColor } from "../scene/comparisons";
import { trackEvent } from "../analytics";
import { hasBoundTextElement, isBoundToContainer } from "../element/typeChecks";
export const SelectedShapeActions = ({
appState,
elements,
renderAction,
elementType,
activeTool,
}: {
appState: AppState;
elements: readonly ExcalidrawElement[];
renderAction: ActionManager["renderAction"];
elementType: AppState["elementType"];
activeTool: AppState["activeTool"]["type"];
}) => {
const targetElements = getTargetElements(
getNonDeletedElements(elements),
@@ -46,19 +52,22 @@ export const SelectedShapeActions = ({
isSingleElementBoundContainer = true;
}
const isEditing = Boolean(appState.editingElement);
const isMobile = useIsMobile();
const device = useDevice();
const isRTL = document.documentElement.getAttribute("dir") === "rtl";
const showFillIcons =
hasBackground(elementType) ||
hasBackground(activeTool) ||
targetElements.some(
(element) =>
hasBackground(element.type) && !isTransparent(element.backgroundColor),
);
const showChangeBackgroundIcons =
hasBackground(elementType) ||
hasBackground(activeTool) ||
targetElements.some((element) => hasBackground(element.type));
const showLinkIcon =
targetElements.length === 1 || isSingleElementBoundContainer;
let commonSelectedType: string | null = targetElements[0]?.type || null;
for (const element of targetElements) {
@@ -70,23 +79,23 @@ export const SelectedShapeActions = ({
return (
<div className="panelColumn">
{((hasStrokeColor(elementType) &&
elementType !== "image" &&
{((hasStrokeColor(activeTool) &&
activeTool !== "image" &&
commonSelectedType !== "image") ||
targetElements.some((element) => hasStrokeColor(element.type))) &&
renderAction("changeStrokeColor")}
{showChangeBackgroundIcons && renderAction("changeBackgroundColor")}
{showFillIcons && renderAction("changeFillStyle")}
{(hasStrokeWidth(elementType) ||
{(hasStrokeWidth(activeTool) ||
targetElements.some((element) => hasStrokeWidth(element.type))) &&
renderAction("changeStrokeWidth")}
{(elementType === "freedraw" ||
{(activeTool === "freedraw" ||
targetElements.some((element) => element.type === "freedraw")) &&
renderAction("changeStrokeShape")}
{(hasStrokeStyle(elementType) ||
{(hasStrokeStyle(activeTool) ||
targetElements.some((element) => hasStrokeStyle(element.type))) && (
<>
{renderAction("changeStrokeStyle")}
@@ -94,12 +103,12 @@ export const SelectedShapeActions = ({
</>
)}
{(canChangeSharpness(elementType) ||
{(canChangeSharpness(activeTool) ||
targetElements.some((element) => canChangeSharpness(element.type))) && (
<>{renderAction("changeSharpness")}</>
)}
{(hasText(elementType) ||
{(hasText(activeTool) ||
targetElements.some((element) => hasText(element.type))) && (
<>
{renderAction("changeFontSize")}
@@ -114,7 +123,7 @@ export const SelectedShapeActions = ({
(element) =>
hasBoundTextElement(element) || isBoundToContainer(element),
) && renderAction("changeVerticalAlign")}
{(canHaveArrowheads(elementType) ||
{(canHaveArrowheads(activeTool) ||
targetElements.some((element) => canHaveArrowheads(element.type))) && (
<>{renderAction("changeArrowhead")}</>
)}
@@ -168,11 +177,11 @@ export const SelectedShapeActions = ({
<fieldset>
<legend>{t("labels.actions")}</legend>
<div className="buttonList">
{!isMobile && renderAction("duplicateSelection")}
{!isMobile && renderAction("deleteSelectedElements")}
{!device.isMobile && renderAction("duplicateSelection")}
{!device.isMobile && renderAction("deleteSelectedElements")}
{renderAction("group")}
{renderAction("ungroup")}
{targetElements.length === 1 && renderAction("hyperlink")}
{showLinkIcon && renderAction("hyperlink")}
</div>
</fieldset>
)}
@@ -182,14 +191,16 @@ export const SelectedShapeActions = ({
export const ShapesSwitcher = ({
canvas,
elementType,
activeTool,
setAppState,
onImageAction,
appState,
}: {
canvas: HTMLCanvasElement | null;
elementType: AppState["elementType"];
activeTool: AppState["activeTool"];
setAppState: React.Component<any, AppState>["setState"];
onImageAction: (data: { pointerType: PointerType | null }) => void;
appState: AppState;
}) => (
<>
{SHAPES.map(({ value, icon, key }, index) => {
@@ -204,20 +215,37 @@ export const ShapesSwitcher = ({
key={value}
type="radio"
icon={icon}
checked={elementType === value}
checked={activeTool.type === value}
name="editor-current-shape"
title={`${capitalizeString(label)}${shortcut}`}
keyBindingLabel={`${index + 1}`}
aria-label={capitalizeString(label)}
aria-keyshortcuts={shortcut}
data-testid={value}
onPointerDown={({ pointerType }) => {
if (!appState.penDetected && pointerType === "pen") {
setAppState({
penDetected: true,
penMode: true,
});
}
}}
onChange={({ pointerType }) => {
if (appState.activeTool.type !== value) {
trackEvent("toolbar", value, "ui");
}
const nextActiveTool = updateActiveTool(appState, {
type: value,
});
setAppState({
elementType: value,
activeTool: nextActiveTool,
multiElement: null,
selectedElementIds: {},
});
setCursorForShape(canvas, value);
setCursorForShape(canvas, {
...appState,
activeTool: nextActiveTool,
});
if (value === "image") {
onImageAction({ pointerType });
}
+656 -456
View File
File diff suppressed because it is too large Load Diff
+6
View File
@@ -12,5 +12,11 @@
cursor: pointer;
font-size: 0.8rem;
font-weight: 500;
&-img {
width: 100%;
height: 100%;
border-radius: 100%;
}
}
}
+27 -11
View File
@@ -1,20 +1,36 @@
import "./Avatar.scss";
import React from "react";
import React, { useState } from "react";
import { getClientInitials } from "../clients";
type AvatarProps = {
children: string;
onClick: (e: React.MouseEvent<HTMLDivElement, MouseEvent>) => void;
color: string;
border: string;
name: string;
src?: string;
};
export const Avatar = ({ children, color, border, onClick }: AvatarProps) => (
<div
className="Avatar"
style={{ background: color, border: `1px solid ${border}` }}
onClick={onClick}
>
{children}
</div>
);
export const Avatar = ({ color, border, onClick, name, src }: AvatarProps) => {
const shortName = getClientInitials(name);
const [error, setError] = useState(false);
const loadImg = !error && src;
const style = loadImg
? undefined
: { background: color, border: `1px solid ${border}` };
return (
<div className="Avatar" style={style} onClick={onClick}>
{loadImg ? (
<img
className="Avatar-img"
src={src}
alt={shortName}
referrerPolicy="no-referrer"
onError={() => setError(true)}
/>
) : (
shortName
)}
</div>
);
};
+2 -2
View File
@@ -1,6 +1,6 @@
import { useState } from "react";
import { t } from "../i18n";
import { useIsMobile } from "./App";
import { useDevice } from "./App";
import { trash } from "./icons";
import { ToolButton } from "./ToolButton";
@@ -19,7 +19,7 @@ const ClearCanvas = ({ onConfirm }: { onConfirm: () => void }) => {
icon={trash}
title={t("buttons.clearReset")}
aria-label={t("buttons.clearReset")}
showAriaLabel={useIsMobile()}
showAriaLabel={useDevice().isMobile}
onClick={toggleDialog}
data-testid="clear-canvas-button"
/>
+2 -2
View File
@@ -1,7 +1,7 @@
import clsx from "clsx";
import { ToolButton } from "./ToolButton";
import { t } from "../i18n";
import { useIsMobile } from "../components/App";
import { useDevice } from "../components/App";
import { users } from "./icons";
import "./CollabButton.scss";
@@ -26,7 +26,7 @@ const CollabButton = ({
type="button"
title={t("labels.liveCollaboration")}
aria-label={t("labels.liveCollaboration")}
showAriaLabel={useIsMobile()}
showAriaLabel={useDevice().isMobile}
>
{collaboratorCount > 0 && (
<div className="CollabButton-collaborators">{collaboratorCount}</div>
+30 -33
View File
@@ -128,45 +128,33 @@ const Picker = ({
}, []);
const handleKeyDown = (event: React.KeyboardEvent) => {
if (event.key === KEYS.TAB) {
const { activeElement } = document;
if (event.shiftKey) {
if (activeElement === firstItem.current) {
colorInput.current?.focus();
event.preventDefault();
}
} else if (activeElement === colorInput.current) {
firstItem.current?.focus();
event.preventDefault();
}
} else if (isArrowKey(event.key)) {
let handled = false;
if (isArrowKey(event.key)) {
handled = true;
const { activeElement } = document;
const isRTL = getLanguage().rtl;
let isCustom = false;
let index = Array.prototype.indexOf.call(
gallery!.current!.querySelector(".color-picker-content--default")!
.children,
gallery.current!.querySelector(".color-picker-content--default")
?.children,
activeElement,
);
if (index === -1) {
index = Array.prototype.indexOf.call(
gallery!.current!.querySelector(
".color-picker-content--canvas-colors",
)!.children,
gallery.current!.querySelector(".color-picker-content--canvas-colors")
?.children,
activeElement,
);
if (index !== -1) {
isCustom = true;
}
}
const parentSelector = isCustom
? gallery!.current!.querySelector(
".color-picker-content--canvas-colors",
)!
: gallery!.current!.querySelector(".color-picker-content--default")!;
const parentElement = isCustom
? gallery.current?.querySelector(".color-picker-content--canvas-colors")
: gallery.current?.querySelector(".color-picker-content--default");
if (index !== -1) {
const length = parentSelector!.children.length - (showInput ? 1 : 0);
if (parentElement && index !== -1) {
const length = parentElement.children.length - (showInput ? 1 : 0);
const nextIndex =
event.key === (isRTL ? KEYS.ARROW_LEFT : KEYS.ARROW_RIGHT)
? (index + 1) % length
@@ -177,30 +165,38 @@ const Picker = ({
: !isCustom && event.key === KEYS.ARROW_UP
? (length + index - 5) % length
: index;
(parentSelector!.children![nextIndex] as HTMLElement)?.focus();
(parentElement.children[nextIndex] as HTMLElement | undefined)?.focus();
}
event.preventDefault();
} else if (
keyBindings.includes(event.key.toLowerCase()) &&
!event[KEYS.CTRL_OR_CMD] &&
!event.altKey &&
!isWritableElement(event.target)
) {
handled = true;
const index = keyBindings.indexOf(event.key.toLowerCase());
const isCustom = index >= MAX_DEFAULT_COLORS;
const parentSelector = isCustom
? gallery!.current!.querySelector(
const parentElement = isCustom
? gallery?.current?.querySelector(
".color-picker-content--canvas-colors",
)!
: gallery!.current!.querySelector(".color-picker-content--default")!;
)
: gallery?.current?.querySelector(".color-picker-content--default");
const actualIndex = isCustom ? index - MAX_DEFAULT_COLORS : index;
(parentSelector!.children![actualIndex] as HTMLElement)?.focus();
(
parentElement?.children[actualIndex] as HTMLElement | undefined
)?.focus();
event.preventDefault();
} else if (event.key === KEYS.ESCAPE || event.key === KEYS.ENTER) {
handled = true;
event.preventDefault();
onClose();
}
event.nativeEvent.stopImmediatePropagation();
event.stopPropagation();
if (handled) {
event.nativeEvent.stopImmediatePropagation();
event.stopPropagation();
}
};
const renderColors = (colors: Array<string>, custom: boolean = false) => {
@@ -264,7 +260,8 @@ const Picker = ({
gallery.current = el;
}
}}
tabIndex={0}
// to allow focusing by clicking but not by tabbing
tabIndex={-1}
>
<div className="color-picker-content--default">
{renderColors(colors)}
+3 -1
View File
@@ -70,7 +70,9 @@ const ContextMenu = ({
dangerous: actionName === "deleteSelectedElements",
checkmark: option.checked?.(appState),
})}
onClick={() => actionManager.executeAction(option)}
onClick={() =>
actionManager.executeAction(option, "contextMenu")
}
>
<div className="context-menu-option__label">{label}</div>
<kbd className="context-menu-option__shortcut">
+3 -10
View File
@@ -2,13 +2,14 @@ import clsx from "clsx";
import React, { useEffect, useState } from "react";
import { useCallbackRefState } from "../hooks/useCallbackRefState";
import { t } from "../i18n";
import { useExcalidrawContainer, useIsMobile } from "../components/App";
import { useExcalidrawContainer, useDevice } from "../components/App";
import { KEYS } from "../keys";
import "./Dialog.scss";
import { back, close } from "./icons";
import { Island } from "./Island";
import { Modal } from "./Modal";
import { AppState } from "../types";
import { queryFocusableElements } from "../utils";
export interface DialogProps {
children: React.ReactNode;
@@ -64,14 +65,6 @@ export const Dialog = (props: DialogProps) => {
return () => islandNode.removeEventListener("keydown", handleKeyDown);
}, [islandNode, props.autofocus]);
const queryFocusableElements = (node: HTMLElement) => {
const focusableElements = node.querySelectorAll<HTMLElement>(
"button, a, input, select, textarea, div[tabindex]",
);
return focusableElements ? Array.from(focusableElements) : [];
};
const onClose = () => {
(lastActiveElement as HTMLElement).focus();
props.onCloseRequest();
@@ -94,7 +87,7 @@ export const Dialog = (props: DialogProps) => {
onClick={onClose}
aria-label={t("buttons.close")}
>
{useIsMobile() ? back : close}
{useDevice().isMobile ? back : close}
</button>
</h2>
<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")}>
<Columns>
<Column>
<ShortcutIsland caption={t("helpDialog.shapes")}>
<ShortcutIsland caption={t("helpDialog.tools")}>
<Shortcut
label={t("toolBar.selection")}
shortcuts={["V", "1"]}
@@ -149,7 +149,7 @@ export const HelpDialog = ({ onClose }: { onClose?: () => void }) => {
shortcuts={["R", "2"]}
/>
<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.line")} shortcuts={["P", "6"]} />
<Shortcut
@@ -159,6 +159,10 @@ export const HelpDialog = ({ onClose }: { onClose?: () => void }) => {
<Shortcut label={t("toolBar.text")} shortcuts={["T", "8"]} />
<Shortcut label={t("toolBar.image")} shortcuts={["9"]} />
<Shortcut label={t("toolBar.library")} shortcuts={["0"]} />
<Shortcut
label={t("toolBar.eraser")}
shortcuts={[getShortcutKey("E")]}
/>
<Shortcut
label={t("helpDialog.editSelectedShape")}
shortcuts={[
@@ -359,6 +363,10 @@ export const HelpDialog = ({ onClose }: { onClose?: () => void }) => {
getShortcutKey(`Alt+${t("helpDialog.drag")}`),
]}
/>
<Shortcut
label={t("helpDialog.toggleElementLock")}
shortcuts={[getShortcutKey("CtrlOrCmd+Shift+L")]}
/>
<Shortcut
label={t("buttons.undo")}
shortcuts={[getShortcutKey("CtrlOrCmd+Z")]}
+10 -6
View File
@@ -20,28 +20,32 @@ interface HintViewerProps {
}
const getHints = ({ appState, elements, isMobile }: HintViewerProps) => {
const { elementType, isResizing, isRotating, lastPointerDownWith } = appState;
const { activeTool, isResizing, isRotating, lastPointerDownWith } = appState;
const multiMode = appState.multiElement !== null;
if (appState.isLibraryOpen) {
return null;
}
if (isEraserActive(appState)) {
return t("hints.eraserRevert");
}
if (elementType === "arrow" || elementType === "line") {
if (activeTool.type === "arrow" || activeTool.type === "line") {
if (!multiMode) {
return t("hints.linearElement");
}
return t("hints.linearElementMulti");
}
if (elementType === "freedraw") {
if (activeTool.type === "freedraw") {
return t("hints.freeDraw");
}
if (elementType === "text") {
if (activeTool.type === "text") {
return t("hints.text");
}
if (appState.elementType === "image" && appState.pendingImageElement) {
if (appState.activeTool.type === "image" && appState.pendingImageElementId) {
return t("hints.placeImage");
}
@@ -73,7 +77,7 @@ const getHints = ({ appState, elements, isMobile }: HintViewerProps) => {
return t("hints.text_editing");
}
if (elementType === "selection") {
if (activeTool.type === "selection") {
if (
appState.draggingElement?.type === "selection" &&
!appState.editingElement &&
+5 -5
View File
@@ -1,12 +1,11 @@
import React, { useEffect, useRef, useState } from "react";
import { render, unmountComponentAtNode } from "react-dom";
import { ActionsManagerInterface } from "../actions/types";
import { probablySupportsClipboardBlob } from "../clipboard";
import { canvasToBlob } from "../data/blob";
import { NonDeletedExcalidrawElement } from "../element/types";
import { CanvasError } from "../errors";
import { t } from "../i18n";
import { useIsMobile } from "./App";
import { useDevice } from "./App";
import { getSelectedElements, isSomeElementSelected } from "../scene";
import { exportToCanvas } from "../scene/export";
import { AppState, BinaryFiles } from "../types";
@@ -19,6 +18,7 @@ import OpenColor from "open-color";
import { CheckboxItem } from "./CheckboxItem";
import { DEFAULT_EXPORT_PADDING } from "../constants";
import { nativeFileSystemSupported } from "../data/filesystem";
import { ActionManager } from "../actions/manager";
const supportsContextFilters =
"filter" in document.createElement("canvas").getContext("2d")!;
@@ -90,7 +90,7 @@ const ImageExportModal = ({
elements: readonly NonDeletedExcalidrawElement[];
files: BinaryFiles;
exportPadding?: number;
actionManager: ActionsManagerInterface;
actionManager: ActionManager;
onExportToPng: ExportCB;
onExportToSvg: ExportCB;
onExportToClipboard: ExportCB;
@@ -229,7 +229,7 @@ export const ImageExportDialog = ({
elements: readonly NonDeletedExcalidrawElement[];
files: BinaryFiles;
exportPadding?: number;
actionManager: ActionsManagerInterface;
actionManager: ActionManager;
onExportToPng: ExportCB;
onExportToSvg: ExportCB;
onExportToClipboard: ExportCB;
@@ -250,7 +250,7 @@ export const ImageExportDialog = ({
icon={exportImage}
type="button"
aria-label={t("buttons.exportImage")}
showAriaLabel={useIsMobile()}
showAriaLabel={useDevice().isMobile}
title={t("buttons.exportImage")}
/>
{modalIsShown && (
+1 -1
View File
@@ -14,11 +14,11 @@ export const InitializeApp = (props: Props) => {
useEffect(() => {
const updateLang = async () => {
await setLanguage(currentLang);
setLoading(false);
};
const currentLang =
languages.find((lang) => lang.code === props.langCode) || defaultLang;
updateLang();
setLoading(false);
}, [props.langCode]);
return loading ? <LoadingMessage /> : props.children;
+12 -9
View File
@@ -1,8 +1,7 @@
import React, { useState } from "react";
import { ActionsManagerInterface } from "../actions/types";
import { NonDeletedExcalidrawElement } from "../element/types";
import { t } from "../i18n";
import { useIsMobile } from "./App";
import { useDevice } from "./App";
import { AppState, ExportOpts, BinaryFiles } from "../types";
import { Dialog } from "./Dialog";
import { exportFile, exportToFileIcon, link } from "./icons";
@@ -12,6 +11,9 @@ import { Card } from "./Card";
import "./ExportDialog.scss";
import { nativeFileSystemSupported } from "../data/filesystem";
import { trackEvent } from "../analytics";
import { ActionManager } from "../actions/manager";
import { getFrame } from "../utils";
export type ExportCB = (
elements: readonly NonDeletedExcalidrawElement[],
@@ -29,7 +31,7 @@ const JSONExportModal = ({
appState: AppState;
files: BinaryFiles;
elements: readonly NonDeletedExcalidrawElement[];
actionManager: ActionsManagerInterface;
actionManager: ActionManager;
onCloseRequest: () => void;
exportOpts: ExportOpts;
canvas: HTMLCanvasElement | null;
@@ -54,7 +56,7 @@ const JSONExportModal = ({
aria-label={t("exportDialog.disk_button")}
showAriaLabel={true}
onClick={() => {
actionManager.executeAction(actionSaveFileToDisk);
actionManager.executeAction(actionSaveFileToDisk, "ui");
}}
/>
</Card>
@@ -70,9 +72,10 @@ const JSONExportModal = ({
title={t("exportDialog.link_button")}
aria-label={t("exportDialog.link_button")}
showAriaLabel={true}
onClick={() =>
onExportToBackend(elements, appState, files, canvas)
}
onClick={() => {
onExportToBackend(elements, appState, files, canvas);
trackEvent("export", "link", `ui (${getFrame()})`);
}}
/>
</Card>
)}
@@ -94,7 +97,7 @@ export const JSONExportDialog = ({
elements: readonly NonDeletedExcalidrawElement[];
appState: AppState;
files: BinaryFiles;
actionManager: ActionsManagerInterface;
actionManager: ActionManager;
exportOpts: ExportOpts;
canvas: HTMLCanvasElement | null;
}) => {
@@ -114,7 +117,7 @@ export const JSONExportDialog = ({
icon={exportFile}
type="button"
aria-label={t("buttons.export")}
showAriaLabel={useIsMobile()}
showAriaLabel={useDevice().isMobile}
title={t("buttons.export")}
/>
{modalIsShown && (
+55 -1
View File
@@ -1,9 +1,63 @@
@import "open-color/open-color";
@import "../css/variables.module";
.layer-ui__sidebar {
position: absolute;
top: var(--sat);
bottom: var(--sab);
right: var(--sar);
z-index: 5;
box-shadow: var(--shadow-island);
overflow: hidden;
border-radius: var(--border-radius-lg);
margin: var(--space-factor);
width: calc(#{$right-sidebar-width} - var(--space-factor) * 2);
.Island {
box-shadow: none;
}
.ToolIcon__icon {
border-radius: var(--border-radius-md);
}
.ToolIcon__icon__close {
.Modal__close {
width: calc(var(--space-factor) * 7);
height: calc(var(--space-factor) * 7);
display: flex;
justify-content: center;
align-items: center;
color: var(--color-text);
}
}
.Island {
--padding: 0;
background-color: var(--island-bg-color);
border-radius: var(--border-radius-lg);
padding: calc(var(--padding) * var(--space-factor));
position: relative;
transition: box-shadow 0.5s ease-in-out;
}
}
.excalidraw {
.layer-ui__wrapper.animate {
transition: width 0.1s ease-in-out;
}
.layer-ui__wrapper {
// when the rightside sidebar is docked, we need to resize the UI by its
// width, making the nested UI content shift to the left. To do this,
// we need the UI container to actually have dimensions set, but
// then we also need to disable pointer events else the canvas below
// wouldn't be interactive.
position: absolute;
width: 100%;
height: 100%;
pointer-events: none;
z-index: var(--zIndex-layerUI);
&__top-right {
display: flex;
}
+116 -78
View File
@@ -1,12 +1,11 @@
import clsx from "clsx";
import React, { useCallback } from "react";
import { ActionManager } from "../actions/manager";
import { CLASSES } from "../constants";
import { CLASSES, LIBRARY_SIDEBAR_WIDTH } from "../constants";
import { exportCanvas } from "../data";
import { isTextElement, showSelectedShapeActions } from "../element";
import { NonDeletedExcalidrawElement } from "../element/types";
import { Language, t } from "../i18n";
import { useIsMobile } from "../components/App";
import { calculateScrollCenter, getSelectedElements } from "../scene";
import { ExportType } from "../scene/types";
import { AppProps, AppState, ExcalidrawProps, BinaryFiles } from "../types";
@@ -26,9 +25,8 @@ import { PasteChartDialog } from "./PasteChartDialog";
import { Section } from "./Section";
import { HelpDialog } from "./HelpDialog";
import Stack from "./Stack";
import { Tooltip } from "./Tooltip";
import { UserList } from "./UserList";
import Library from "../data/library";
import Library, { distributeLibraryItemsOnSquareGrid } from "../data/library";
import { JSONExportDialog } from "./JSONExportDialog";
import { LibraryButton } from "./LibraryButton";
import { isImageFileHandle } from "../data/blob";
@@ -37,6 +35,10 @@ import { LibraryMenu } from "./LibraryMenu";
import "./LayerUI.scss";
import "./Toolbar.scss";
import { PenModeButton } from "./PenModeButton";
import { trackEvent } from "../analytics";
import { useDevice } from "../components/App";
import { Stats } from "./Stats";
import { actionToggleStats } from "../actions/actionToggleStats";
interface LayerUIProps {
actionManager: ActionManager;
@@ -55,11 +57,9 @@ interface LayerUIProps {
toggleZenMode: () => void;
langCode: Language["code"];
isCollaborating: boolean;
renderTopRightUI?: (
isMobile: boolean,
appState: AppState,
) => JSX.Element | null;
renderCustomFooter?: (isMobile: boolean, appState: AppState) => JSX.Element;
renderTopRightUI?: ExcalidrawProps["renderTopRightUI"];
renderCustomFooter?: ExcalidrawProps["renderFooter"];
renderCustomStats?: ExcalidrawProps["renderCustomStats"];
viewModeEnabled: boolean;
libraryReturnUrl: ExcalidrawProps["libraryReturnUrl"];
UIOptions: AppProps["UIOptions"];
@@ -68,7 +68,6 @@ interface LayerUIProps {
id: string;
onImageAction: (data: { insertOnCanvasDirectly: boolean }) => void;
}
const LayerUI = ({
actionManager,
appState,
@@ -87,6 +86,7 @@ const LayerUI = ({
isCollaborating,
renderTopRightUI,
renderCustomFooter,
renderCustomStats,
viewModeEnabled,
libraryReturnUrl,
UIOptions,
@@ -95,7 +95,7 @@ const LayerUI = ({
id,
onImageAction,
}: LayerUIProps) => {
const isMobile = useIsMobile();
const device = useDevice();
const renderJSONExportDialog = () => {
if (!UIOptions.canvasActions.export) {
@@ -122,6 +122,7 @@ const LayerUI = ({
const createExporter =
(type: ExportType): ExportCB =>
async (exportedElements) => {
trackEvent("export", type, "ui");
const fileHandle = await exportCanvas(
type,
exportedElements,
@@ -248,7 +249,7 @@ const LayerUI = ({
appState={appState}
elements={elements}
renderAction={actionManager.renderAction}
elementType={appState.elementType}
activeTool={appState.activeTool.type}
/>
</Island>
</Section>
@@ -275,7 +276,9 @@ const LayerUI = ({
<LibraryMenu
pendingElements={getSelectedElements(elements, appState, true)}
onClose={closeLibrary}
onInsertShape={onInsertElements}
onInsertLibraryItems={(libraryItems) => {
onInsertElements(distributeLibraryItemsOnSquareGrid(libraryItems));
}}
onAddToLibrary={deselectItems}
setAppState={setAppState}
libraryReturnUrl={libraryReturnUrl}
@@ -325,8 +328,8 @@ const LayerUI = ({
/>
<LockButton
zenModeEnabled={zenModeEnabled}
checked={appState.elementLocked}
onChange={onLockToggle}
checked={appState.activeTool.locked}
onChange={() => onLockToggle()}
title={t("toolBar.lock")}
/>
<Island
@@ -338,13 +341,14 @@ const LayerUI = ({
<HintViewer
appState={appState}
elements={elements}
isMobile={isMobile}
isMobile={device.isMobile}
/>
{heading}
<Stack.Row gap={1}>
<ShapesSwitcher
appState={appState}
canvas={canvas}
elementType={appState.elementType}
activeTool={appState.activeTool}
setAppState={setAppState}
onImageAction={({ pointerType }) => {
onImageAction({
@@ -359,7 +363,6 @@ const LayerUI = ({
setAppState={setAppState}
/>
</Stack.Row>
{libraryMenu}
</Stack.Col>
)}
</Section>
@@ -372,23 +375,11 @@ const LayerUI = ({
},
)}
>
<UserList>
{appState.collaborators.size > 0 &&
Array.from(appState.collaborators)
// Collaborator is either not initialized or is actually the current user.
.filter(([_, client]) => Object.keys(client).length !== 0)
.map(([clientId, client]) => (
<Tooltip
label={client.username || "Unknown user"}
key={clientId}
>
{actionManager.renderAction("goToCollaborator", {
id: clientId,
})}
</Tooltip>
))}
</UserList>
{renderTopRightUI?.(isMobile, appState)}
<UserList
collaborators={appState.collaborators}
actionManager={actionManager}
/>
{renderTopRightUI?.(device.isMobile, appState)}
</div>
</div>
</FixedSideContainer>
@@ -418,24 +409,39 @@ const LayerUI = ({
/>
</Island>
{!viewModeEnabled && (
<div
className={clsx("undo-redo-buttons zen-mode-transition", {
"layer-ui__wrapper__footer-left--transition-bottom":
zenModeEnabled,
})}
>
{actionManager.renderAction("undo", { size: "small" })}
{actionManager.renderAction("redo", { size: "small" })}
</div>
<>
<div
className={clsx("undo-redo-buttons zen-mode-transition", {
"layer-ui__wrapper__footer-left--transition-bottom":
zenModeEnabled,
})}
>
{actionManager.renderAction("undo", { size: "small" })}
{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" })}
</div>
</>
)}
<div
className={clsx("eraser-buttons zen-mode-transition", {
"layer-ui__wrapper__footer-left--transition-left":
zenModeEnabled,
})}
>
{actionManager.renderAction("eraser", { size: "small" })}
</div>
{!viewModeEnabled &&
appState.multiElement &&
device.isTouchScreen && (
<div
className={clsx("finalize-button zen-mode-transition", {
"layer-ui__wrapper__footer-left--transition-left":
zenModeEnabled,
})}
>
{actionManager.renderAction("finalize", { size: "small" })}
</div>
)}
</Section>
</Stack.Col>
</div>
@@ -474,7 +480,7 @@ const LayerUI = ({
const dialogs = (
<>
{appState.isLoading && <LoadingMessage />}
{appState.isLoading && <LoadingMessage delay={250} />}
{appState.errorMessage && (
<ErrorDialog
message={appState.errorMessage}
@@ -503,7 +509,24 @@ const LayerUI = ({
</>
);
return isMobile ? (
const renderStats = () => {
if (!appState.showStats) {
return null;
}
return (
<Stats
appState={appState}
setAppState={setAppState}
elements={elements}
onClose={() => {
actionManager.executeAction(actionToggleStats);
}}
renderCustomStats={renderCustomStats}
/>
);
};
return device.isMobile ? (
<>
{dialogs}
<MobileMenu
@@ -515,7 +538,7 @@ const LayerUI = ({
renderImageExportDialog={renderImageExportDialog}
setAppState={setAppState}
onCollabButtonClick={onCollabButtonClick}
onLockToggle={onLockToggle}
onLockToggle={() => onLockToggle()}
onPenModeToggle={onPenModeToggle}
canvas={canvas}
isCollaborating={isCollaborating}
@@ -524,33 +547,48 @@ const LayerUI = ({
showThemeBtn={showThemeBtn}
onImageAction={onImageAction}
renderTopRightUI={renderTopRightUI}
renderStats={renderStats}
/>
</>
) : (
<div
className={clsx("layer-ui__wrapper", {
"disable-pointerEvents":
appState.draggingElement ||
appState.resizingElement ||
(appState.editingElement && !isTextElement(appState.editingElement)),
})}
>
{dialogs}
{renderFixedSideContainer()}
{renderBottomAppMenu()}
{appState.scrolledOutside && (
<button
className="scroll-back-to-content"
onClick={() => {
setAppState({
...calculateScrollCenter(elements, appState, canvas),
});
}}
>
{t("buttons.scrollBackToContent")}
</button>
<>
<div
className={clsx("layer-ui__wrapper", {
"disable-pointerEvents":
appState.draggingElement ||
appState.resizingElement ||
(appState.editingElement &&
!isTextElement(appState.editingElement)),
})}
style={
appState.isLibraryOpen &&
appState.isLibraryMenuDocked &&
device.canDeviceFitSidebar
? { width: `calc(100% - ${LIBRARY_SIDEBAR_WIDTH}px)` }
: {}
}
>
{dialogs}
{renderFixedSideContainer()}
{renderBottomAppMenu()}
{renderStats()}
{appState.scrolledOutside && (
<button
className="scroll-back-to-content"
onClick={() => {
setAppState({
...calculateScrollCenter(elements, appState, canvas),
});
}}
>
{t("buttons.scrollBackToContent")}
</button>
)}
</div>
{appState.isLibraryOpen && (
<div className="layer-ui__sidebar">{libraryMenu}</div>
)}
</div>
</>
);
};
+16 -1
View File
@@ -3,6 +3,8 @@ import clsx from "clsx";
import { t } from "../i18n";
import { AppState } from "../types";
import { capitalizeString } from "../utils";
import { trackEvent } from "../analytics";
import { useDevice } from "./App";
const LIBRARY_ICON = (
<svg viewBox="0 0 576 512">
@@ -18,6 +20,7 @@ export const LibraryButton: React.FC<{
setAppState: React.Component<any, AppState>["setState"];
isMobile?: boolean;
}> = ({ appState, setAppState, isMobile }) => {
const device = useDevice();
return (
<label
className={clsx(
@@ -34,7 +37,19 @@ export const LibraryButton: React.FC<{
type="checkbox"
name="editor-library"
onChange={(event) => {
setAppState({ isLibraryOpen: event.target.checked });
document
.querySelector(".layer-ui__wrapper")
?.classList.remove("animate");
const nextState = event.target.checked;
setAppState({ isLibraryOpen: nextState });
// track only openings
if (nextState) {
trackEvent(
"library",
"toggleLibrary (open)",
`toolbar (${device.isMobile ? "mobile" : "desktop"})`,
);
}
}}
checked={appState.isLibraryOpen}
aria-label={capitalizeString(t("toolBar.library"))}
+59 -10
View File
@@ -2,7 +2,6 @@
.excalidraw {
.layer-ui__library {
margin: auto;
display: flex;
align-items: center;
justify-content: center;
@@ -11,25 +10,41 @@
display: flex;
align-items: center;
width: 100%;
margin: 2px 0;
margin: 2px 0 15px 0;
.Spinner {
margin-right: 1rem;
}
button {
// 2px from the left to account for focus border of left-most button
margin: 0 2px;
}
}
}
a {
margin-inline-start: auto;
// 17px for scrollbar (needed for overlay scrollbars on Big Sur?) + 1px extra
padding-inline-end: 18px;
white-space: nowrap;
}
.layer-ui__sidebar {
.layer-ui__library {
padding: 0;
height: 100%;
}
.library-menu-items-container {
height: 100%;
width: 100%;
}
}
.layer-ui__library-message {
padding: 10px 20px;
max-width: 200px;
padding: 2em 4em;
min-width: 200px;
display: flex;
flex-direction: column;
align-items: center;
.Spinner {
margin-bottom: 1em;
}
span {
font-size: 0.8em;
}
}
.publish-library-success {
@@ -52,4 +67,38 @@
}
}
}
.library-menu-browse-button {
width: 80%;
min-height: 22px;
margin: 0 auto;
margin-top: 1rem;
padding: 10px;
display: flex;
align-items: center;
justify-content: center;
overflow: hidden;
position: relative;
border-radius: var(--border-radius-lg);
background-color: var(--color-primary);
color: $oc-white;
text-align: center;
white-space: nowrap;
text-decoration: none !important;
&:hover {
background-color: var(--color-primary-darker);
}
&:active {
background-color: var(--color-primary-darkest);
}
}
.library-menu-browse-button--mobile {
min-height: 22px;
margin-left: auto;
a {
padding-right: 0;
}
}
}
+118 -141
View File
@@ -1,5 +1,12 @@
import { useRef, useState, useEffect, useCallback, RefObject } from "react";
import Library from "../data/library";
import {
useRef,
useState,
useEffect,
useCallback,
RefObject,
forwardRef,
} from "react";
import Library, { libraryItemsAtom } from "../data/library";
import { t } from "../i18n";
import { randomId } from "../random";
import {
@@ -18,7 +25,11 @@ import "./LibraryMenu.scss";
import LibraryMenuItems from "./LibraryMenuItems";
import { EVENT } from "../constants";
import { KEYS } from "../keys";
import { arrayToMap } from "../utils";
import { trackEvent } from "../analytics";
import { useAtom } from "jotai";
import { jotaiScope } from "../jotai";
import Spinner from "./Spinner";
import { useDevice } from "./App";
const useOnClickOutside = (
ref: RefObject<HTMLElement>,
@@ -53,9 +64,20 @@ const getSelectedItems = (
selectedItems: LibraryItem["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 = ({
onClose,
onInsertShape,
onInsertLibraryItems,
pendingElements,
onAddToLibrary,
theme,
@@ -69,7 +91,7 @@ export const LibraryMenu = ({
}: {
pendingElements: LibraryItem["elements"];
onClose: () => void;
onInsertShape: (elements: LibraryItem["elements"]) => void;
onInsertLibraryItems: (libraryItems: LibraryItems) => void;
onAddToLibrary: () => void;
theme: AppState["theme"];
files: BinaryFiles;
@@ -82,17 +104,30 @@ export const LibraryMenu = ({
}) => {
const ref = useRef<HTMLDivElement | null>(null);
useOnClickOutside(ref, (event) => {
// If click on the library icon, do nothing.
if ((event.target as Element).closest(".ToolIcon__library")) {
return;
}
onClose();
});
const device = useDevice();
useOnClickOutside(
ref,
useCallback(
(event) => {
// If click on the library icon, do nothing.
if ((event.target as Element).closest(".ToolIcon__library")) {
return;
}
if (!appState.isLibraryMenuDocked || !device.canDeviceFitSidebar) {
onClose();
}
},
[onClose, appState.isLibraryMenuDocked, device.canDeviceFitSidebar],
),
);
useEffect(() => {
const handleKeyDown = (event: KeyboardEvent) => {
if (event.key === KEYS.ESCAPE) {
if (
event.key === KEYS.ESCAPE &&
(!appState.isLibraryMenuDocked || !device.canDeviceFitSidebar)
) {
onClose();
}
};
@@ -100,13 +135,8 @@ export const LibraryMenu = ({
return () => {
document.removeEventListener(EVENT.KEYDOWN, handleKeyDown);
};
}, [onClose]);
}, [onClose, appState.isLibraryMenuDocked, device.canDeviceFitSidebar]);
const [libraryItems, setLibraryItems] = useState<LibraryItems>([]);
const [loadingState, setIsLoading] = useState<
"preloading" | "loading" | "ready"
>("preloading");
const [selectedItems, setSelectedItems] = useState<LibraryItem["id"][]>([]);
const [showPublishLibraryDialog, setShowPublishLibraryDialog] =
useState(false);
@@ -114,55 +144,35 @@ export const LibraryMenu = ({
url: string;
authorName: string;
}>(null);
const loadingTimerRef = useRef<number | null>(null);
useEffect(() => {
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 [libraryItemsData] = useAtom(libraryItemsAtom, jotaiScope);
const removeFromLibrary = useCallback(async () => {
const items = await library.loadLibrary();
const nextItems = items.filter((item) => !selectedItems.includes(item.id));
library.saveLibrary(nextItems).catch((error) => {
setLibraryItems(items);
setAppState({ errorMessage: t("alerts.errorRemovingFromLibrary") });
});
setSelectedItems([]);
setLibraryItems(nextItems);
}, [library, setAppState, selectedItems, setSelectedItems]);
const removeFromLibrary = useCallback(
async (libraryItems: LibraryItems) => {
const nextItems = libraryItems.filter(
(item) => !selectedItems.includes(item.id),
);
library.setLibrary(nextItems).catch(() => {
setAppState({ errorMessage: t("alerts.errorRemovingFromLibrary") });
});
setSelectedItems([]);
},
[library, setAppState, selectedItems, setSelectedItems],
);
const resetLibrary = useCallback(() => {
library.resetLibrary();
setLibraryItems([]);
focusContainer();
}, [library, focusContainer]);
const addToLibrary = useCallback(
async (elements: LibraryItem["elements"]) => {
async (elements: LibraryItem["elements"], libraryItems: LibraryItems) => {
trackEvent("element", "addToLibrary", "ui");
if (elements.some((element) => element.type === "image")) {
return setAppState({
errorMessage: "Support for adding images to the library coming soon!",
});
}
const items = await library.loadLibrary();
const nextItems: LibraryItems = [
{
status: "unpublished",
@@ -170,14 +180,12 @@ export const LibraryMenu = ({
id: randomId(),
created: Date.now(),
},
...items,
...libraryItems,
];
onAddToLibrary();
library.saveLibrary(nextItems).catch((error) => {
setLibraryItems(items);
library.setLibrary(nextItems).catch(() => {
setAppState({ errorMessage: t("alerts.errorAddingToLibrary") });
});
setLibraryItems(nextItems);
},
[onAddToLibrary, library, setAppState],
);
@@ -216,7 +224,7 @@ export const LibraryMenu = ({
}, [setPublishLibSuccess, publishLibSuccess]);
const onPublishLibSuccess = useCallback(
(data) => {
(data, libraryItems: LibraryItems) => {
setShowPublishLibraryDialog(false);
setPublishLibSuccess({ url: data.url, authorName: data.authorName });
const nextLibItems = libraryItems.slice();
@@ -225,102 +233,71 @@ export const LibraryMenu = ({
libItem.status = "published";
}
});
library.saveLibrary(nextLibItems);
setLibraryItems(nextLibItems);
library.setLibrary(nextLibItems);
},
[
setShowPublishLibraryDialog,
setPublishLibSuccess,
libraryItems,
selectedItems,
library,
],
[setShowPublishLibraryDialog, setPublishLibSuccess, selectedItems, library],
);
const [lastSelectedItem, setLastSelectedItem] = useState<
LibraryItem["id"] | null
>(null);
if (
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 loadingState === "preloading" ? null : (
<Island padding={1} ref={ref} className="layer-ui__library">
return (
<LibraryMenuWrapper ref={ref}>
{showPublishLibraryDialog && (
<PublishLibrary
onClose={() => setShowPublishLibraryDialog(false)}
libraryItems={getSelectedItems(libraryItems, selectedItems)}
libraryItems={getSelectedItems(
libraryItemsData.libraryItems,
selectedItems,
)}
appState={appState}
onSuccess={onPublishLibSuccess}
onSuccess={(data) =>
onPublishLibSuccess(data, libraryItemsData.libraryItems)
}
onError={(error) => window.alert(error)}
updateItemsInStorage={() => library.saveLibrary(libraryItems)}
updateItemsInStorage={() =>
library.setLibrary(libraryItemsData.libraryItems)
}
onRemove={(id: string) =>
setSelectedItems(selectedItems.filter((_id) => _id !== id))
}
/>
)}
{publishLibSuccess && renderPublishSuccess()}
{loadingState === "loading" ? (
<div className="layer-ui__library-message">
{t("labels.libraryLoadingMessage")}
</div>
) : (
<LibraryMenuItems
libraryItems={libraryItems}
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 (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]);
}
setLastSelectedItem(id);
} else {
setLastSelectedItem(null);
setSelectedItems(selectedItems.filter((_id) => _id !== id));
}
}}
onPublish={() => setShowPublishLibraryDialog(true)}
resetLibrary={resetLibrary}
/>
)}
</Island>
<LibraryMenuItems
isLoading={libraryItemsData.status === "loading"}
libraryItems={libraryItemsData.libraryItems}
onRemoveFromLibrary={() =>
removeFromLibrary(libraryItemsData.libraryItems)
}
onAddToLibrary={(elements) =>
addToLibrary(elements, libraryItemsData.libraryItems)
}
onInsertLibraryItems={onInsertLibraryItems}
pendingElements={pendingElements}
setAppState={setAppState}
appState={appState}
libraryReturnUrl={libraryReturnUrl}
library={library}
theme={theme}
files={files}
id={id}
selectedItems={selectedItems}
onSelectItems={(ids) => setSelectedItems(ids)}
onPublish={() => setShowPublishLibraryDialog(true)}
resetLibrary={resetLibrary}
/>
</LibraryMenuWrapper>
);
};
+16 -3
View File
@@ -2,8 +2,17 @@
.excalidraw {
.library-menu-items-container {
display: flex;
flex-direction: column;
height: 100%;
padding: 0.5rem;
box-sizing: border-box;
.library-actions {
width: 100%;
display: flex;
margin-right: auto;
align-items: center;
button .library-actions-counter {
position: absolute;
@@ -87,12 +96,16 @@
}
}
&__items {
max-height: 50vh;
overflow: auto;
margin-top: 0.5rem;
flex: 1;
overflow-y: auto;
overflow-x: hidden;
margin-bottom: 1rem;
}
.separator {
width: 100%;
display: flex;
align-items: center;
font-weight: 500;
font-size: 0.9rem;
margin: 0.6em 0.2em;
+298 -62
View File
@@ -1,6 +1,6 @@
import { chunk } from "lodash";
import { useCallback, useState } from "react";
import { importLibraryFromJSON, saveLibraryAsJSON } from "../data/json";
import React, { useCallback, useState } from "react";
import { saveLibraryAsJSON, serializeLibraryAsJSON } from "../data/json";
import Library from "../data/library";
import { ExcalidrawElement, NonDeleted } from "../element/types";
import { t } from "../i18n";
@@ -11,48 +11,57 @@ import {
LibraryItem,
LibraryItems,
} from "../types";
import { muteFSAbortError } from "../utils";
import { useIsMobile } from "./App";
import { arrayToMap, muteFSAbortError } from "../utils";
import { useDevice } from "./App";
import ConfirmDialog from "./ConfirmDialog";
import { exportToFileIcon, load, publishIcon, trash } from "./icons";
import { close, exportToFileIcon, load, publishIcon, trash } from "./icons";
import { LibraryUnit } from "./LibraryUnit";
import Stack from "./Stack";
import { ToolButton } from "./ToolButton";
import { Tooltip } from "./Tooltip";
import "./LibraryMenuItems.scss";
import { VERSIONS } from "../constants";
import { MIME_TYPES, VERSIONS } from "../constants";
import Spinner from "./Spinner";
import { fileOpen } from "../data/filesystem";
import { SidebarLockButton } from "./SidebarLockButton";
import { trackEvent } from "../analytics";
const LibraryMenuItems = ({
isLoading,
libraryItems,
onRemoveFromLibrary,
onAddToLibrary,
onInsertShape,
onInsertLibraryItems,
pendingElements,
theme,
setAppState,
appState,
libraryReturnUrl,
library,
files,
id,
selectedItems,
onToggle,
onSelectItems,
onPublish,
resetLibrary,
}: {
isLoading: boolean;
libraryItems: LibraryItems;
pendingElements: LibraryItem["elements"];
onRemoveFromLibrary: () => void;
onInsertShape: (elements: LibraryItem["elements"]) => void;
onInsertLibraryItems: (libraryItems: LibraryItems) => void;
onAddToLibrary: (elements: LibraryItem["elements"]) => void;
theme: AppState["theme"];
files: BinaryFiles;
setAppState: React.Component<any, AppState>["setState"];
appState: AppState;
libraryReturnUrl: ExcalidrawProps["libraryReturnUrl"];
library: Library;
id: string;
selectedItems: LibraryItem["id"][];
onToggle: (id: LibraryItem["id"], event: React.MouseEvent) => void;
onSelectItems: (id: LibraryItem["id"][]) => void;
onPublish: () => void;
resetLibrary: () => void;
}) => {
@@ -84,9 +93,7 @@ const LibraryMenuItems = ({
}, [selectedItems, onRemoveFromLibrary, resetLibrary]);
const [showRemoveLibAlert, setShowRemoveLibAlert] = useState(false);
const isMobile = useIsMobile();
const device = useDevice();
const renderLibraryActions = () => {
const itemsSelected = !!selectedItems.length;
const items = itemsSelected
@@ -97,24 +104,34 @@ const LibraryMenuItems = ({
: t("buttons.resetLibrary");
return (
<div className="library-actions">
{(!itemsSelected || !isMobile) && (
{!itemsSelected && (
<ToolButton
key="import"
type="button"
title={t("buttons.load")}
aria-label={t("buttons.load")}
icon={load}
onClick={() => {
importLibraryFromJSON(library)
.then(() => {
// Close and then open to get the libraries updated
setAppState({ isLibraryOpen: false });
setAppState({ isLibraryOpen: true });
})
.catch(muteFSAbortError)
.catch((error) => {
setAppState({ errorMessage: error.message });
onClick={async () => {
try {
await library.updateLibrary({
libraryItems: fileOpen({
description: "Excalidraw library files",
// ToDo: Be over-permissive until https://bugs.webkit.org/show_bug.cgi?id=34442
// gets resolved. Else, iOS users cannot open `.excalidraw` files.
/*
extensions: [".json", ".excalidrawlib"],
*/
}),
merge: true,
openLibraryMenu: true,
});
} catch (error: any) {
if (error?.name === "AbortError") {
console.warn(error);
return;
}
setAppState({ errorMessage: t("errors.importLibraryError") });
}
}}
className="library-actions--load"
/>
@@ -130,7 +147,7 @@ const LibraryMenuItems = ({
onClick={async () => {
const libraryItems = itemsSelected
? items
: await library.loadLibrary();
: await library.getLatestLibrary();
saveLibraryAsJSON(libraryItems)
.catch(muteFSAbortError)
.catch((error) => {
@@ -162,7 +179,7 @@ const LibraryMenuItems = ({
</ToolButton>
</>
)}
{itemsSelected && !isPublished && (
{itemsSelected && (
<Tooltip label={t("hints.publishLibrary")}>
<ToolButton
type="button"
@@ -172,7 +189,7 @@ const LibraryMenuItems = ({
className="library-actions--publish"
onClick={onPublish}
>
{!isMobile && <label>{t("buttons.publishLibrary")}</label>}
{!device.isMobile && <label>{t("buttons.publishLibrary")}</label>}
{selectedItems.length > 0 && (
<span className="library-actions-counter">
{selectedItems.length}
@@ -181,17 +198,89 @@ const LibraryMenuItems = ({
</ToolButton>
</Tooltip>
)}
{device.isMobile && (
<div className="library-menu-browse-button--mobile">
<a
href={`${process.env.REACT_APP_LIBRARY_URL}?target=${
window.name || "_blank"
}&referrer=${referrer}&useHash=true&token=${id}&theme=${theme}&version=${
VERSIONS.excalidrawLibrary
}`}
target="_excalidraw_libraries"
>
{t("labels.libraries")}
</a>
</div>
)}
</div>
);
};
const CELLS_PER_ROW = isMobile ? 4 : 6;
const CELLS_PER_ROW = device.isMobile && !device.isSmScreen ? 6 : 4;
const referrer =
libraryReturnUrl || window.location.origin + window.location.pathname;
const isPublished = selectedItems.some(
(id) => libraryItems.find((item) => item.id === id)?.status === "published",
);
const [lastSelectedItem, setLastSelectedItem] = useState<
LibraryItem["id"] | null
>(null);
const onItemSelectToggle = (
id: LibraryItem["id"],
event: React.MouseEvent,
) => {
const shouldSelect = !selectedItems.includes(id);
const orderedItems = [...unpublishedItems, ...publishedItems];
if (shouldSelect) {
if (event.shiftKey && lastSelectedItem) {
const rangeStart = orderedItems.findIndex(
(item) => item.id === lastSelectedItem,
);
const rangeEnd = orderedItems.findIndex((item) => item.id === id);
if (rangeStart === -1 || rangeEnd === -1) {
onSelectItems([...selectedItems, id]);
return;
}
const selectedItemsMap = arrayToMap(selectedItems);
const nextSelectedIds = orderedItems.reduce(
(acc: LibraryItem["id"][], item, idx) => {
if (
(idx >= rangeStart && idx <= rangeEnd) ||
selectedItemsMap.has(item.id)
) {
acc.push(item.id);
}
return acc;
},
[],
);
onSelectItems(nextSelectedIds);
} else {
onSelectItems([...selectedItems, id]);
}
setLastSelectedItem(id);
} else {
setLastSelectedItem(null);
onSelectItems(selectedItems.filter((_id) => _id !== id));
}
};
const getInsertedElements = (id: string) => {
let targetElements;
if (selectedItems.includes(id)) {
targetElements = libraryItems.filter((item) =>
selectedItems.includes(item.id),
);
} else {
targetElements = libraryItems.filter((item) => item.id === id);
}
return targetElements;
};
const createLibraryItemCompo = (params: {
item:
@@ -213,8 +302,12 @@ const LibraryMenuItems = ({
onClick={params.onClick || (() => {})}
id={params.item?.id || null}
selected={!!params.item?.id && selectedItems.includes(params.item.id)}
onToggle={(id, event) => {
onToggle(id, event);
onToggle={onItemSelectToggle}
onDrag={(id, event) => {
event.dataTransfer.setData(
MIME_TYPES.excalidrawlib,
serializeLibraryAsJSON(getInsertedElements(id)),
);
}}
/>
</Stack.Col>
@@ -234,7 +327,7 @@ const LibraryMenuItems = ({
if (item.id) {
return createLibraryItemCompo({
item,
onClick: () => onInsertShape(item.elements),
onClick: () => onInsertLibraryItems(getInsertedElements(item.id)),
key: item.id,
});
}
@@ -273,49 +366,192 @@ const LibraryMenuItems = ({
});
};
const unpublishedItems = libraryItems.filter(
(item) => item.status !== "published",
);
const publishedItems = libraryItems.filter(
(item) => item.status === "published",
);
const unpublishedItems = [
// append pending library item
...(pendingElements.length
? [{ id: null, elements: pendingElements }]
: []),
...libraryItems.filter((item) => item.status !== "published"),
];
return (
<div className="library-menu-items-container">
{showRemoveLibAlert && renderRemoveLibAlert()}
<div className="layer-ui__library-header" key="library-header">
{renderLibraryActions()}
<a
href={`${process.env.REACT_APP_LIBRARY_URL}?target=${
window.name || "_blank"
}&referrer=${referrer}&useHash=true&token=${id}&theme=${theme}&version=${
VERSIONS.excalidrawLibrary
}`}
target="_excalidraw_libraries"
>
{t("labels.libraries")}
</a>
</div>
const renderLibraryHeader = () => {
return (
<>
<div className="layer-ui__library-header" key="library-header">
{renderLibraryActions()}
{device.canDeviceFitSidebar && (
<>
<div className="layer-ui__sidebar-lock-button">
<SidebarLockButton
checked={appState.isLibraryMenuDocked}
onChange={() => {
document
.querySelector(".layer-ui__wrapper")
?.classList.add("animate");
const nextState = !appState.isLibraryMenuDocked;
setAppState({
isLibraryMenuDocked: nextState,
});
trackEvent(
"library",
`toggleLibraryDock (${nextState ? "dock" : "undock"})`,
`sidebar (${device.isMobile ? "mobile" : "desktop"})`,
);
}}
/>
</div>
</>
)}
{!device.isMobile && (
<div className="ToolIcon__icon__close">
<button
className="Modal__close"
onClick={() =>
setAppState({
isLibraryOpen: false,
})
}
aria-label={t("buttons.close")}
>
{close}
</button>
</div>
)}
</div>
</>
);
};
const renderLibraryMenuItems = () => {
return (
<Stack.Col
className="library-menu-items-container__items"
align="start"
gap={1}
style={{
flex: publishedItems.length > 0 ? 1 : "0 1 auto",
marginBottom: 0,
}}
>
<>
<div className="separator">{t("labels.personalLib")}</div>
{renderLibrarySection(unpublishedItems)}
<div className="separator">
{(pendingElements.length > 0 ||
unpublishedItems.length > 0 ||
publishedItems.length > 0) && (
<div>{t("labels.personalLib")}</div>
)}
{isLoading && (
<div
style={{
marginLeft: "auto",
marginRight: "1rem",
display: "flex",
alignItems: "center",
fontWeight: "normal",
}}
>
<div style={{ transform: "translateY(2px)" }}>
<Spinner />
</div>
</div>
)}
</div>
{!pendingElements.length && !unpublishedItems.length ? (
<div
style={{
height: 65,
display: "flex",
flexDirection: "column",
alignItems: "center",
justifyContent: "center",
width: "100%",
fontSize: ".9rem",
}}
>
{t("library.noItems")}
<div
style={{
margin: ".6rem 0",
fontSize: ".8em",
width: "70%",
textAlign: "center",
}}
>
{publishedItems.length > 0
? t("library.hint_emptyPrivateLibrary")
: t("library.hint_emptyLibrary")}
</div>
</div>
) : (
renderLibrarySection([
// append pending library item
...(pendingElements.length
? [{ id: null, elements: pendingElements }]
: []),
...unpublishedItems,
])
)}
</>
<>
<div className="separator">{t("labels.excalidrawLib")} </div>
{renderLibrarySection(publishedItems)}
{(publishedItems.length > 0 ||
(!device.isMobile &&
(pendingElements.length > 0 || unpublishedItems.length > 0))) && (
<div className="separator">{t("labels.excalidrawLib")}</div>
)}
{publishedItems.length > 0 ? (
renderLibrarySection(publishedItems)
) : unpublishedItems.length > 0 ? (
<div
style={{
margin: "1rem 0",
display: "flex",
flexDirection: "column",
alignItems: "center",
justifyContent: "center",
width: "100%",
fontSize: ".9rem",
}}
>
{t("library.noItems")}
</div>
) : null}
</>
</Stack.Col>
);
};
const renderLibraryFooter = () => {
return (
<a
className="library-menu-browse-button"
href={`${process.env.REACT_APP_LIBRARY_URL}?target=${
window.name || "_blank"
}&referrer=${referrer}&useHash=true&token=${id}&theme=${theme}&version=${
VERSIONS.excalidrawLibrary
}`}
target="_excalidraw_libraries"
>
{t("labels.libraries")}
</a>
);
};
return (
<div
className="library-menu-items-container"
style={
device.isMobile
? {
minHeight: "200px",
maxHeight: "70vh",
}
: undefined
}
>
{showRemoveLibAlert && renderRemoveLibAlert()}
{renderLibraryHeader()}
{renderLibraryMenuItems()}
{!device.isMobile && renderLibraryFooter()}
</div>
);
};
+1 -5
View File
@@ -3,7 +3,7 @@
.excalidraw {
.library-unit {
align-items: center;
border: 1px solid var(--button-gray-2);
border: 1px solid transparent;
display: flex;
justify-content: center;
position: relative;
@@ -21,10 +21,6 @@
}
}
&.theme--dark .library-unit {
border-color: rgb(48, 48, 48);
}
.library-unit__dragger {
display: flex;
align-items: center;
+9 -7
View File
@@ -1,8 +1,7 @@
import clsx from "clsx";
import oc from "open-color";
import { useEffect, useRef, useState } from "react";
import { MIME_TYPES } from "../constants";
import { useIsMobile } from "../components/App";
import { useDevice } from "../components/App";
import { exportToSvg } from "../scene/export";
import { BinaryFiles, LibraryItem } from "../types";
import "./LibraryUnit.scss";
@@ -29,6 +28,7 @@ export const LibraryUnit = ({
onClick,
selected,
onToggle,
onDrag,
}: {
id: LibraryItem["id"] | /** for pending item */ null;
elements?: LibraryItem["elements"];
@@ -37,6 +37,7 @@ export const LibraryUnit = ({
onClick: () => void;
selected: boolean;
onToggle: (id: string, event: React.MouseEvent) => void;
onDrag: (id: string, event: React.DragEvent) => void;
}) => {
const ref = useRef<HTMLDivElement | null>(null);
useEffect(() => {
@@ -66,7 +67,7 @@ export const LibraryUnit = ({
}, [elements, files]);
const [isHovered, setIsHovered] = useState(false);
const isMobile = useIsMobile();
const isMobile = useDevice().isMobile;
const adder = isPending && (
<div className="library-unit__adder">{PLUS_ICON}</div>
);
@@ -99,11 +100,12 @@ export const LibraryUnit = ({
: undefined
}
onDragStart={(event) => {
if (!id) {
event.preventDefault();
return;
}
setIsHovered(false);
event.dataTransfer.setData(
MIME_TYPES.excalidrawlib,
JSON.stringify(elements),
);
onDrag(id, event);
}}
/>
{adder}
+23 -3
View File
@@ -1,10 +1,30 @@
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 (
<div className="LoadingMessage">
<span>{t("labels.loadingScene")}</span>
<div>
<Spinner />
</div>
<div className="LoadingMessage-text">{t("labels.loadingScene")}</div>
</div>
);
};
+16 -18
View File
@@ -32,7 +32,10 @@ type MobileMenuProps = {
onPenModeToggle: () => void;
canvas: HTMLCanvasElement | null;
isCollaborating: boolean;
renderCustomFooter?: (isMobile: boolean, appState: AppState) => JSX.Element;
renderCustomFooter?: (
isMobile: boolean,
appState: AppState,
) => JSX.Element | null;
viewModeEnabled: boolean;
showThemeBtn: boolean;
onImageAction: (data: { insertOnCanvasDirectly: boolean }) => void;
@@ -40,6 +43,7 @@ type MobileMenuProps = {
isMobile: boolean,
appState: AppState,
) => JSX.Element | null;
renderStats: () => JSX.Element | null;
};
export const MobileMenu = ({
@@ -60,6 +64,7 @@ export const MobileMenu = ({
showThemeBtn,
onImageAction,
renderTopRightUI,
renderStats,
}: MobileMenuProps) => {
const renderToolbar = () => {
return (
@@ -72,8 +77,9 @@ export const MobileMenu = ({
{heading}
<Stack.Row gap={1}>
<ShapesSwitcher
appState={appState}
canvas={canvas}
elementType={appState.elementType}
activeTool={appState.activeTool}
setAppState={setAppState}
onImageAction={({ pointerType }) => {
onImageAction({
@@ -85,7 +91,7 @@ export const MobileMenu = ({
</Island>
{renderTopRightUI && renderTopRightUI(true, appState)}
<LockButton
checked={appState.elementLocked}
checked={appState.activeTool.locked}
onChange={onLockToggle}
title={t("toolBar.lock")}
isMobile
@@ -180,6 +186,7 @@ export const MobileMenu = ({
return (
<>
{!viewModeEnabled && renderToolbar()}
{renderStats()}
<div
className="App-bottom-bar"
style={{
@@ -198,20 +205,11 @@ export const MobileMenu = ({
{appState.collaborators.size > 0 && (
<fieldset>
<legend>{t("labels.collaborators")}</legend>
<UserList mobile>
{Array.from(appState.collaborators)
// Collaborator is either not initialized or is actually the current user.
.filter(
([_, client]) => Object.keys(client).length !== 0,
)
.map(([clientId, client]) => (
<React.Fragment key={clientId}>
{actionManager.renderAction("goToCollaborator", {
id: clientId,
})}
</React.Fragment>
))}
</UserList>
<UserList
mobile
collaborators={appState.collaborators}
actionManager={actionManager}
/>
</fieldset>
)}
</Stack.Col>
@@ -225,7 +223,7 @@ export const MobileMenu = ({
appState={appState}
elements={elements}
renderAction={actionManager.renderAction}
elementType={appState.elementType}
activeTool={appState.activeTool.type}
/>
</Section>
) : null}
+6 -6
View File
@@ -4,7 +4,7 @@ import React, { useState, useLayoutEffect, useRef } from "react";
import { createPortal } from "react-dom";
import clsx from "clsx";
import { KEYS } from "../keys";
import { useExcalidrawContainer, useIsMobile } from "./App";
import { useExcalidrawContainer, useDevice } from "./App";
import { AppState } from "../types";
import { THEME } from "../constants";
@@ -59,17 +59,17 @@ export const Modal = (props: {
const useBodyRoot = (theme: AppState["theme"]) => {
const [div, setDiv] = useState<HTMLDivElement | null>(null);
const isMobile = useIsMobile();
const isMobileRef = useRef(isMobile);
isMobileRef.current = isMobile;
const device = useDevice();
const isMobileRef = useRef(device.isMobile);
isMobileRef.current = device.isMobile;
const { container: excalidrawContainer } = useExcalidrawContainer();
useLayoutEffect(() => {
if (div) {
div.classList.toggle("excalidraw--mobile", isMobile);
div.classList.toggle("excalidraw--mobile", device.isMobile);
}
}, [div, isMobile]);
}, [div, device.isMobile]);
useLayoutEffect(() => {
const isDarkTheme =
+37
View File
@@ -1,6 +1,8 @@
import React, { useLayoutEffect, useRef, useEffect } from "react";
import "./Popover.scss";
import { unstable_batchedUpdates } from "react-dom";
import { queryFocusableElements } from "../utils";
import { KEYS } from "../keys";
type Props = {
top?: number;
@@ -27,6 +29,41 @@ export const Popover = ({
}: Props) => {
const popoverRef = useRef<HTMLDivElement>(null);
const container = popoverRef.current;
useEffect(() => {
if (!container) {
return;
}
const handleKeyDown = (event: KeyboardEvent) => {
if (event.key === KEYS.TAB) {
const focusableElements = queryFocusableElements(container);
const { activeElement } = document;
const currentIndex = focusableElements.findIndex(
(element) => element === activeElement,
);
if (currentIndex === 0 && event.shiftKey) {
focusableElements[focusableElements.length - 1].focus();
event.preventDefault();
event.stopImmediatePropagation();
} else if (
currentIndex === focusableElements.length - 1 &&
!event.shiftKey
) {
focusableElements[0].focus();
event.preventDefault();
event.stopImmediatePropagation();
}
}
};
container.addEventListener("keydown", handleKeyDown);
return () => container.removeEventListener("keydown", handleKeyDown);
}, [container]);
// ensure the popover doesn't overflow the viewport
useLayoutEffect(() => {
if (fitInViewport && popoverRef.current) {
+4
View File
@@ -82,6 +82,10 @@
}
}
&-warning {
color: $oc-red-6;
}
&-note {
padding: 1em;
font-style: italic;
+10
View File
@@ -295,6 +295,11 @@ const PublishLibrary = ({
}, [clonedLibItems, onClose, updateItemsInStorage, libraryData]);
const shouldRenderForm = !!libraryItems.length;
const containsPublishedItems = libraryItems.some(
(item) => item.status === "published",
);
return (
<Dialog
onCloseRequest={onDialogClose}
@@ -329,6 +334,11 @@ const PublishLibrary = ({
<div className="publish-library-note">
{t("publishDialog.noteItems")}
</div>
{containsPublishedItems && (
<span className="publish-library-note publish-library-warning">
{t("publishDialog.republishWarning")}
</span>
)}
{renderLibraryItems()}
<div className="publish-library__fields">
<label>
+22
View File
@@ -0,0 +1,22 @@
@import "../css/variables.module";
.excalidraw {
.layer-ui__sidebar-lock-button {
@include toolbarButtonColorStates;
margin-right: 0.2rem;
}
.ToolIcon_type_floating .side_lock_icon {
width: calc(var(--space-factor) * 7);
height: calc(var(--space-factor) * 7);
svg {
// mirror
transform: scale(-1, 1);
}
}
.ToolIcon_type_checkbox {
&:not(.ToolIcon_toggle_opaque):checked + .side_lock_icon {
background-color: var(--color-primary);
}
}
}
+46
View File
@@ -0,0 +1,46 @@
import "./ToolIcon.scss";
import React from "react";
import clsx from "clsx";
import { ToolButtonSize } from "./ToolButton";
import { t } from "../i18n";
import { Tooltip } from "./Tooltip";
import "./SidebarLockButton.scss";
type SidebarLockIconProps = {
checked: boolean;
onChange?(): void;
};
const DEFAULT_SIZE: ToolButtonSize = "medium";
const SIDE_LIBRARY_TOGGLE_ICON = (
<svg viewBox="0 0 24 24" fill="#ffffff">
<path d="M19 22H5a3 3 0 01-3-3V5a3 3 0 013-3h14a3 3 0 013 3v14a3 3 0 01-3 3zm0-18h-9v16h9a1.01 1.01 0 001-1V5a1.01 1.01 0 00-1-1z"></path>
</svg>
);
export const SidebarLockButton = (props: SidebarLockIconProps) => {
return (
<Tooltip label={t("labels.sidebarLock")}>
<label
className={clsx(
"ToolIcon ToolIcon__lock ToolIcon_type_floating",
`ToolIcon_size_${DEFAULT_SIZE}`,
)}
>
<input
className="ToolIcon_type_checkbox"
type="checkbox"
onChange={props.onChange}
checked={props.checked}
aria-label={t("labels.sidebarLock")}
/>{" "}
<div className="ToolIcon__icon side_lock_icon" tabIndex={0}>
{SIDE_LIBRARY_TOGGLE_ICON}
</div>{" "}
</label>{" "}
</Tooltip>
);
};
+15 -2
View File
@@ -3,11 +3,24 @@
.excalidraw {
.single-library-item {
position: relative;
&-status {
position: absolute;
top: 0.3rem;
left: 0.3rem;
font-size: 0.7rem;
color: $oc-red-7;
background: rgba(255, 255, 255, 0.9);
padding: 0.1rem 0.2rem;
border-radius: 0.2rem;
}
&__svg {
background-color: $oc-white;
padding: 0.3rem;
width: 7.5rem;
height: 7.5rem;
border: 1px solid var(--button-gray-2);
margin: 0.3rem;
svg {
width: 100%;
height: 100%;
@@ -40,7 +53,7 @@
&--remove {
position: absolute;
top: 0.2rem;
right: 1.3rem;
right: 1rem;
.ToolIcon__icon {
margin: 0;
+5
View File
@@ -45,6 +45,11 @@ const SingleLibraryItem = ({
return (
<div className="single-library-item">
{libItem.status === "published" && (
<span className="single-library-item-status">
{t("labels.statusPublished")}
</span>
)}
<div ref={svgRef} className="single-library-item__svg" />
<ToolButton
aria-label={t("buttons.remove")}
+2
View File
@@ -41,6 +41,7 @@ const ColStack = ({
align,
justifyContent,
className,
style,
}: StackProps) => {
return (
<div
@@ -49,6 +50,7 @@ const ColStack = ({
"--gap": gap,
justifyItems: align,
justifyContent,
...style,
}}
>
{children}
+1
View File
@@ -7,6 +7,7 @@
right: 12px;
font-size: 12px;
z-index: 10;
pointer-events: all;
h3 {
margin: 0 24px 8px 0;
+3 -6
View File
@@ -2,7 +2,7 @@ import React from "react";
import { getCommonBounds } from "../element/bounds";
import { NonDeletedExcalidrawElement } from "../element/types";
import { t } from "../i18n";
import { useIsMobile } from "../components/App";
import { useDevice } from "../components/App";
import { getTargetElements } from "../scene";
import { AppState, ExcalidrawProps } from "../types";
import { close } from "./icons";
@@ -16,16 +16,13 @@ export const Stats = (props: {
onClose: () => void;
renderCustomStats: ExcalidrawProps["renderCustomStats"];
}) => {
const isMobile = useIsMobile();
const device = useDevice();
const boundingBox = getCommonBounds(props.elements);
const selectedElements = getTargetElements(props.elements, props.appState);
const selectedBoundingBox = getCommonBounds(selectedElements);
if (isMobile && props.appState.openMenu) {
if (device.isMobile && props.appState.openMenu) {
return null;
}
return (
<div className="Stats">
<Island padding={2}>
+2
View File
@@ -48,6 +48,7 @@ type ToolButtonProps =
type: "radio";
checked: boolean;
onChange?(data: { pointerType: PointerType | null }): void;
onPointerDown?(data: { pointerType: PointerType }): void;
});
export const ToolButton = React.forwardRef((props: ToolButtonProps, ref) => {
@@ -149,6 +150,7 @@ export const ToolButton = React.forwardRef((props: ToolButtonProps, ref) => {
title={props.title}
onPointerDown={(event) => {
lastPointerTypeRef.current = event.pointerType || null;
props.onPointerDown?.({ pointerType: event.pointerType || null });
}}
onPointerUp={() => {
requestAnimationFrame(() => {
+1 -1
View File
@@ -155,7 +155,7 @@
}
width: 2rem;
height: 2em;
height: 2rem;
}
}
+1 -22
View File
@@ -1,26 +1,5 @@
@import "open-color/open-color.scss";
@mixin toolbarButtonColorStates {
.ToolIcon_type_radio,
.ToolIcon_type_checkbox {
& + .ToolIcon__icon:active {
background: var(--color-primary-light);
}
&:checked + .ToolIcon__icon {
background: var(--color-primary);
--icon-fill-color: #{$oc-white};
--keybinding-color: #{$oc-white};
}
&:checked + .ToolIcon__icon:active {
background: var(--color-primary-darker);
}
}
.ToolIcon__keybinding {
bottom: 4px;
right: 4px;
}
}
@import "../css/variables.module";
.excalidraw {
.App-toolbar-container {
+1 -1
View File
@@ -2,7 +2,7 @@
// container in body where the actual tooltip is appended to
.excalidraw-tooltip {
position: absolute;
position: fixed;
z-index: 1000;
padding: 8px;
+39 -5
View File
@@ -2,17 +2,51 @@ import "./UserList.scss";
import React from "react";
import clsx from "clsx";
import { AppState, Collaborator } from "../types";
import { Tooltip } from "./Tooltip";
import { ActionManager } from "../actions/manager";
type UserListProps = {
children: React.ReactNode;
export const UserList: React.FC<{
className?: string;
mobile?: boolean;
};
collaborators: AppState["collaborators"];
actionManager: ActionManager;
}> = ({ className, mobile, collaborators, actionManager }) => {
const uniqueCollaborators = new Map<string, Collaborator>();
collaborators.forEach((collaborator, socketId) => {
uniqueCollaborators.set(
// filter on user id, else fall back on unique socketId
collaborator.id || socketId,
collaborator,
);
});
const avatars =
uniqueCollaborators.size > 0 &&
Array.from(uniqueCollaborators)
.filter(([_, client]) => Object.keys(client).length !== 0)
.map(([clientId, collaborator]) => {
const avatarJSX = actionManager.renderAction("goToCollaborator", [
clientId,
collaborator,
]);
return mobile ? (
<Tooltip
label={collaborator.username || "Unknown user"}
key={clientId}
>
{avatarJSX}
</Tooltip>
) : (
<React.Fragment key={clientId}>{avatarJSX}</React.Fragment>
);
});
export const UserList = ({ children, className, mobile }: UserListProps) => {
return (
<div className={clsx("UserList", className, { UserList_mobile: mobile })}>
{children}
{avatars}
</div>
);
};
+20 -1
View File
@@ -94,7 +94,9 @@ export const MIME_TYPES = {
excalidrawlib: "application/vnd.excalidrawlib+json",
json: "application/json",
svg: "image/svg+xml",
"excalidraw.svg": "image/svg+xml",
png: "image/png",
"excalidraw.png": "image/png",
jpg: "image/jpeg",
gif: "image/gif",
binary: "application/octet-stream",
@@ -106,7 +108,8 @@ export const EXPORT_DATA_TYPES = {
excalidrawLibrary: "excalidrawlib",
} as const;
export const EXPORT_SOURCE = window.location.origin;
export const EXPORT_SOURCE =
window.EXCALIDRAW_EXPORT_SOURCE || window.location.origin;
// time in milliseconds
export const IMAGE_RENDER_TIMEOUT = 500;
@@ -152,9 +155,19 @@ export const DEFAULT_UI_OPTIONS: AppProps["UIOptions"] = {
},
};
// breakpoints
// -----------------------------------------------------------------------------
// sm screen
export const MQ_SM_MAX_WIDTH = 640;
// md screen
export const MQ_MAX_WIDTH_PORTRAIT = 730;
export const MQ_MAX_WIDTH_LANDSCAPE = 1000;
export const MQ_MAX_HEIGHT_LANDSCAPE = 500;
// sidebar
export const MQ_RIGHT_SIDEBAR_MIN_WIDTH = 1229;
// -----------------------------------------------------------------------------
export const LIBRARY_SIDEBAR_WIDTH = parseInt(cssVariables.rightSidebarWidth);
export const MAX_DECIMALS_FOR_SVG_EXPORT = 2;
@@ -188,3 +201,9 @@ export const VERTICAL_ALIGN = {
MIDDLE: "middle",
BOTTOM: "bottom",
};
export const ELEMENT_READY_TO_ERASE_OPACITY = 20;
export const COOKIES = {
AUTH_STATE_COOKIE: "excplus-auth",
} as const;
+9 -7
View File
@@ -16,15 +16,17 @@
left: 0;
z-index: 999;
display: flex;
flex-direction: column;
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;
.Spinner {
font-size: 2.8em;
}
.LoadingMessage-text {
margin-top: 1em;
font-size: 0.8em;
}
}
+27 -1
View File
@@ -350,7 +350,6 @@
align-items: flex-start;
cursor: default;
pointer-events: none !important;
z-index: 100;
:root[dir="ltr"] & {
left: 0.25rem;
@@ -391,6 +390,7 @@
.App-menu__left {
overflow-y: auto;
box-shadow: var(--shadow-island);
}
.dropdown-select {
@@ -449,6 +449,7 @@
bottom: 30px;
transform: translateX(-50%);
padding: 10px 20px;
pointer-events: all;
}
.help-icon {
@@ -477,6 +478,15 @@
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,
.eraser-buttons {
display: grid;
@@ -558,6 +568,22 @@
display: none;
}
}
// use custom, minimalistic scrollbar
// (doesn't work in Firefox)
::-webkit-scrollbar {
width: 5px;
}
::-webkit-scrollbar-thumb {
background: var(--button-gray-2);
border-radius: 10px;
}
::-webkit-scrollbar-thumb:hover {
background: var(--button-gray-3);
}
::-webkit-scrollbar-thumb:active {
background: var(--button-gray-2);
}
}
.ErrorSplash.excalidraw {
+24
View File
@@ -6,8 +6,32 @@
}
}
@mixin toolbarButtonColorStates {
.ToolIcon_type_radio,
.ToolIcon_type_checkbox {
& + .ToolIcon__icon:active {
background: var(--color-primary-light);
}
&:checked + .ToolIcon__icon {
background: var(--color-primary);
--icon-fill-color: #{$oc-white};
--keybinding-color: #{$oc-white};
}
&:checked + .ToolIcon__icon:active {
background: var(--color-primary-darker);
}
}
.ToolIcon__keybinding {
bottom: 4px;
right: 4px;
}
}
$theme-filter: "invert(93%) hue-rotate(180deg)";
$right-sidebar-width: "302px";
:export {
themeFilter: unquote($theme-filter);
rightSidebarWidth: unquote($right-sidebar-width);
}
+198 -38
View File
@@ -1,20 +1,16 @@
import { nanoid } from "nanoid";
import { cleanAppStateForExport } from "../appState";
import {
ALLOWED_IMAGE_MIME_TYPES,
EXPORT_DATA_TYPES,
MIME_TYPES,
} from "../constants";
import { cleanAppStateForImageExport } from "../appState";
import { ALLOWED_IMAGE_MIME_TYPES, MIME_TYPES } from "../constants";
import { clearElementsForExport } from "../element";
import { ExcalidrawElement, FileId } from "../element/types";
import { CanvasError } from "../errors";
import { t } from "../i18n";
import { calculateScrollCenter } from "../scene";
import { AppState, DataURL } from "../types";
import { AppState, DataURL, LibraryItem } from "../types";
import { bytesToHexString } from "../utils";
import { FileSystemHandle } from "./filesystem";
import { isValidExcalidrawData } from "./json";
import { restore } from "./restore";
import { FileSystemHandle, nativeFileSystemSupported } from "./filesystem";
import { isValidExcalidrawData, isValidLibrary } from "./json";
import { restore, restoreLibraryItems } from "./restore";
import { ImportedLibraryData } from "./types";
const parseFileContents = async (blob: Blob | File) => {
@@ -127,49 +123,91 @@ export const isSupportedImageFile = (
);
};
export const loadFromBlob = async (
blob: Blob,
export const loadSceneOrLibraryFromBlob = async (
blob: Blob | File,
/** @see restore.localAppState */
localAppState: AppState | null,
localElements: readonly ExcalidrawElement[] | null,
/** FileSystemHandle. Defaults to `blob.handle` if defined, otherwise null. */
fileHandle?: FileSystemHandle | null,
) => {
const contents = await parseFileContents(blob);
try {
const data = JSON.parse(contents);
if (!isValidExcalidrawData(data)) {
throw new Error(t("alerts.couldNotLoadInvalidFile"));
if (isValidExcalidrawData(data)) {
return {
type: MIME_TYPES.excalidraw,
data: restore(
{
elements: clearElementsForExport(data.elements || []),
appState: {
theme: localAppState?.theme,
fileHandle: fileHandle || blob.handle || null,
...cleanAppStateForImageExport(data.appState || {}),
...(localAppState
? calculateScrollCenter(
data.elements || [],
localAppState,
null,
)
: {}),
},
files: data.files,
},
localAppState,
localElements,
),
};
} else if (isValidLibrary(data)) {
return {
type: MIME_TYPES.excalidrawlib,
data,
};
}
const result = restore(
{
elements: clearElementsForExport(data.elements || []),
appState: {
theme: localAppState?.theme,
fileHandle: blob.handle || null,
...cleanAppStateForExport(data.appState || {}),
...(localAppState
? calculateScrollCenter(data.elements || [], localAppState, null)
: {}),
},
files: data.files,
},
localAppState,
localElements,
);
return result;
throw new Error(t("alerts.couldNotLoadInvalidFile"));
} catch (error: any) {
console.error(error.message);
throw new Error(t("alerts.couldNotLoadInvalidFile"));
}
};
export const loadLibraryFromBlob = async (blob: Blob) => {
const contents = await parseFileContents(blob);
const data: ImportedLibraryData = JSON.parse(contents);
if (data.type !== EXPORT_DATA_TYPES.excalidrawLibrary) {
export const loadFromBlob = async (
blob: Blob,
/** @see restore.localAppState */
localAppState: AppState | null,
localElements: readonly ExcalidrawElement[] | null,
/** FileSystemHandle. Defaults to `blob.handle` if defined, otherwise null. */
fileHandle?: FileSystemHandle | null,
) => {
const ret = await loadSceneOrLibraryFromBlob(
blob,
localAppState,
localElements,
fileHandle,
);
if (ret.type !== MIME_TYPES.excalidraw) {
throw new Error(t("alerts.couldNotLoadInvalidFile"));
}
return data;
return ret.data;
};
export const parseLibraryJSON = (
json: string,
defaultStatus: LibraryItem["status"] = "unpublished",
) => {
const data: ImportedLibraryData | undefined = JSON.parse(json);
if (!isValidLibrary(data)) {
throw new Error("Invalid library");
}
const libraryItems = data.libraryItems || data.library;
return restoreLibraryItems(libraryItems, defaultStatus);
};
export const loadLibraryFromBlob = async (
blob: Blob,
defaultStatus: LibraryItem["status"] = "unpublished",
) => {
return parseLibraryJSON(await parseFileContents(blob), defaultStatus);
};
export const canvasToBlob = async (
@@ -200,7 +238,7 @@ export const generateIdFromFile = async (file: File): Promise<FileId> => {
try {
const hashBuffer = await window.crypto.subtle.digest(
"SHA-1",
await file.arrayBuffer(),
await blobToArrayBuffer(file),
);
return bytesToHexString(new Uint8Array(hashBuffer)) as FileId;
} catch (error: any) {
@@ -289,3 +327,125 @@ export const SVGStringToFile = (SVGString: string, filename: string = "") => {
type: MIME_TYPES.svg,
}) as File & { type: typeof MIME_TYPES.svg };
};
export const getFileFromEvent = async (
event: React.DragEvent<HTMLDivElement>,
) => {
const file = event.dataTransfer.files.item(0);
const fileHandle = await getFileHandle(event);
return { file: file ? await normalizeFile(file) : null, fileHandle };
};
export const getFileHandle = async (
event: React.DragEvent<HTMLDivElement>,
): Promise<FileSystemHandle | null> => {
if (nativeFileSystemSupported) {
try {
const item = event.dataTransfer.items[0];
const handle: FileSystemHandle | null =
(await (item as any).getAsFileSystemHandle()) || null;
return handle;
} catch (error: any) {
console.warn(error.name, error.message);
return null;
}
}
return null;
};
/**
* attemps to detect if a buffer is a valid image by checking its leading bytes
*/
const getActualMimeTypeFromImage = (buffer: ArrayBuffer) => {
let mimeType: ValueOf<Pick<typeof MIME_TYPES, "png" | "jpg" | "gif">> | null =
null;
const first8Bytes = `${[...new Uint8Array(buffer).slice(0, 8)].join(" ")} `;
// uint8 leading bytes
const headerBytes = {
// https://en.wikipedia.org/wiki/Portable_Network_Graphics#File_header
png: "137 80 78 71 13 10 26 10 ",
// https://en.wikipedia.org/wiki/JPEG#Syntax_and_structure
// jpg is a bit wonky. Checking the first three bytes should be enough,
// but may yield false positives. (https://stackoverflow.com/a/23360709/927631)
jpg: "255 216 255 ",
// https://en.wikipedia.org/wiki/GIF#Example_GIF_file
gif: "71 73 70 56 57 97 ",
};
if (first8Bytes === headerBytes.png) {
mimeType = MIME_TYPES.png;
} else if (first8Bytes.startsWith(headerBytes.jpg)) {
mimeType = MIME_TYPES.jpg;
} else if (first8Bytes.startsWith(headerBytes.gif)) {
mimeType = MIME_TYPES.gif;
}
return mimeType;
};
export const createFile = (
blob: File | Blob | ArrayBuffer,
mimeType: ValueOf<typeof MIME_TYPES>,
name: string | undefined,
) => {
return new File([blob], name || "", {
type: mimeType,
});
};
/** attemps to detect correct mimeType if none is set, or if an image
* has an incorrect extension.
* Note: doesn't handle missing .excalidraw/.excalidrawlib extension */
export const normalizeFile = async (file: File) => {
if (!file.type) {
if (file?.name?.endsWith(".excalidrawlib")) {
file = createFile(
await blobToArrayBuffer(file),
MIME_TYPES.excalidrawlib,
file.name,
);
} else if (file?.name?.endsWith(".excalidraw")) {
file = createFile(
await blobToArrayBuffer(file),
MIME_TYPES.excalidraw,
file.name,
);
} else {
const buffer = await blobToArrayBuffer(file);
const mimeType = getActualMimeTypeFromImage(buffer);
if (mimeType) {
file = createFile(buffer, mimeType, file.name);
}
}
// when the file is an image, make sure the extension corresponds to the
// actual mimeType (this is an edge case, but happens sometime)
} else if (isSupportedImageFile(file)) {
const buffer = await blobToArrayBuffer(file);
const mimeType = getActualMimeTypeFromImage(buffer);
if (mimeType && mimeType !== file.type) {
file = createFile(buffer, mimeType, file.name);
}
}
return file;
};
export const blobToArrayBuffer = (blob: Blob): Promise<ArrayBuffer> => {
if ("arrayBuffer" in blob) {
return blob.arrayBuffer();
}
// Safari
return new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onload = (event) => {
if (!event.target?.result) {
return reject(new Error("Couldn't convert blob to ArrayBuffer"));
}
resolve(event.target.result as ArrayBuffer);
};
reader.readAsArrayBuffer(blob);
});
};
+2 -1
View File
@@ -1,4 +1,5 @@
import { ENCRYPTION_KEY_BITS } from "../constants";
import { blobToArrayBuffer } from "./blob";
export const IV_LENGTH_BYTES = 12;
@@ -58,7 +59,7 @@ export const encryptData = async (
: data instanceof Uint8Array
? data
: data instanceof Blob
? await data.arrayBuffer()
? await blobToArrayBuffer(data)
: data;
// We use symmetric encryption. AES-GCM is the recommended algorithm and
+2
View File
@@ -13,7 +13,9 @@ type FILE_EXTENSION =
| "gif"
| "jpg"
| "png"
| "excalidraw.png"
| "svg"
| "excalidraw.svg"
| "json"
| "excalidraw"
| "excalidrawlib";
+4 -18
View File
@@ -3,28 +3,12 @@ import tEXt from "png-chunk-text";
import encodePng from "png-chunks-encode";
import { stringToBase64, encode, decode, base64ToString } from "./encode";
import { EXPORT_DATA_TYPES, MIME_TYPES } from "../constants";
import { blobToArrayBuffer } from "./blob";
// -----------------------------------------------------------------------------
// PNG
// -----------------------------------------------------------------------------
const blobToArrayBuffer = (blob: Blob): Promise<ArrayBuffer> => {
if ("arrayBuffer" in blob) {
return blob.arrayBuffer();
}
// Safari
return new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onload = (event) => {
if (!event.target?.result) {
return reject(new Error("couldn't convert blob to ArrayBuffer"));
}
resolve(event.target.result as ArrayBuffer);
};
reader.readAsArrayBuffer(blob);
});
};
export const getTEXtChunk = async (
blob: Blob,
): Promise<{ keyword: string; text: string } | null> => {
@@ -105,7 +89,9 @@ export const encodeSvgMetadata = async ({ text }: { text: string }) => {
export const decodeSvgMetadata = async ({ svg }: { svg: string }) => {
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) {
throw new Error("INVALID");
}
+13 -6
View File
@@ -16,7 +16,7 @@ export { loadFromBlob } from "./blob";
export { loadFromJSON, saveAsJSON } from "./json";
export const exportCanvas = async (
type: ExportType,
type: Omit<ExportType, "backend">,
elements: readonly NonDeletedExcalidrawElement[],
appState: AppState,
files: BinaryFiles,
@@ -56,7 +56,7 @@ export const exportCanvas = async (
{
description: "Export to SVG",
name,
extension: "svg",
extension: appState.exportEmbedScene ? "excalidraw.svg" : "svg",
fileHandle,
},
);
@@ -73,33 +73,40 @@ export const exportCanvas = async (
});
tempCanvas.style.display = "none";
document.body.appendChild(tempCanvas);
let blob = await canvasToBlob(tempCanvas);
tempCanvas.remove();
if (type === "png") {
let blob = await canvasToBlob(tempCanvas);
tempCanvas.remove();
if (appState.exportEmbedScene) {
blob = await (
await import(/* webpackChunkName: "image" */ "./image")
).encodePngMetadata({
blob,
metadata: serializeAsJSON(elements, appState, files, "local"),
metadata: serializeAsJSON(elements, appState, files, "image"),
});
}
return await fileSave(blob, {
description: "Export to PNG",
name,
extension: "png",
extension: appState.exportEmbedScene ? "excalidraw.png" : "png",
fileHandle,
});
} else if (type === "clipboard") {
try {
const blob = canvasToBlob(tempCanvas);
await copyBlobToClipboardAsPng(blob);
} catch (error: any) {
if (error.name === "CANVAS_POSSIBLY_TOO_BIG") {
throw error;
}
throw new Error(t("alerts.couldNotCopyToClipboard"));
} finally {
tempCanvas.remove();
}
} else {
tempCanvas.remove();
// shouldn't happen
throw new Error("Unsupported export type");
}
};
+36 -33
View File
@@ -1,5 +1,9 @@
import { fileOpen, fileSave } from "./filesystem";
import { cleanAppStateForExport, clearAppStateForDatabase } from "../appState";
import {
cleanAppStateForImageExport,
cleanAppStateForTextExport,
clearAppStateForDatabase,
} from "../appState";
import {
EXPORT_DATA_TYPES,
EXPORT_SOURCE,
@@ -9,14 +13,14 @@ import {
import { clearElementsForDatabase, clearElementsForExport } from "../element";
import { ExcalidrawElement } from "../element/types";
import { AppState, BinaryFiles, LibraryItems } from "../types";
import { isImageFileHandle, loadFromBlob } from "./blob";
import { isImageFileHandle, loadFromBlob, normalizeFile } from "./blob";
import {
ExportedDataState,
ImportedDataState,
ExportedLibraryData,
ImportedLibraryData,
} from "./types";
import Library from "./library";
/**
* Strips out files which are only referenced by deleted elements
@@ -43,25 +47,32 @@ export const serializeAsJSON = (
elements: readonly ExcalidrawElement[],
appState: Partial<AppState>,
files: BinaryFiles,
type: "local" | "database",
destination: "text" | "image" | "database",
): string => {
const cleanAppState = () => {
switch (destination) {
case "database":
return clearAppStateForDatabase(appState);
case "text":
return cleanAppStateForTextExport(appState);
case "image":
return cleanAppStateForImageExport(appState);
}
};
const data: ExportedDataState = {
type: EXPORT_DATA_TYPES.excalidraw,
version: VERSIONS.excalidraw,
source: EXPORT_SOURCE,
elements:
type === "local"
? clearElementsForExport(elements)
: clearElementsForDatabase(elements),
appState:
type === "local"
? cleanAppStateForExport(appState)
: clearAppStateForDatabase(appState),
destination === "database"
? clearElementsForDatabase(elements)
: clearElementsForExport(elements),
appState: cleanAppState(),
files:
type === "local"
? filterOutDeletedFiles(elements, files)
: // will be stripped from JSON
undefined,
destination === "database"
? // will be stripped from JSON
undefined
: filterOutDeletedFiles(elements, files),
};
return JSON.stringify(data, null, 2);
@@ -72,7 +83,7 @@ export const saveAsJSON = async (
appState: AppState,
files: BinaryFiles,
) => {
const serialized = serializeAsJSON(elements, appState, files, "local");
const serialized = serializeAsJSON(elements, appState, files, "text");
const blob = new Blob([serialized], {
type: MIME_TYPES.excalidraw,
});
@@ -92,13 +103,13 @@ export const loadFromJSON = async (
localAppState: AppState,
localElements: readonly ExcalidrawElement[] | null,
) => {
const blob = await fileOpen({
const file = await fileOpen({
description: "Excalidraw files",
// ToDo: Be over-permissive until https://bugs.webkit.org/show_bug.cgi?id=34442
// gets resolved. Else, iOS users cannot open `.excalidraw` files.
// extensions: ["json", "excalidraw", "png", "svg"],
});
return loadFromBlob(blob, localAppState, localElements);
return loadFromBlob(await normalizeFile(file), localAppState, localElements);
};
export const isValidExcalidrawData = (data?: {
@@ -114,7 +125,7 @@ export const isValidExcalidrawData = (data?: {
);
};
export const isValidLibrary = (json: any) => {
export const isValidLibrary = (json: any): json is ImportedLibraryData => {
return (
typeof json === "object" &&
json &&
@@ -123,14 +134,18 @@ export const isValidLibrary = (json: any) => {
);
};
export const saveLibraryAsJSON = async (libraryItems: LibraryItems) => {
export const serializeLibraryAsJSON = (libraryItems: LibraryItems) => {
const data: ExportedLibraryData = {
type: EXPORT_DATA_TYPES.excalidrawLibrary,
version: VERSIONS.excalidrawLibrary,
source: EXPORT_SOURCE,
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(
new Blob([serialized], {
type: MIME_TYPES.excalidrawlib,
@@ -142,15 +157,3 @@ export const saveLibraryAsJSON = async (libraryItems: LibraryItems) => {
},
);
};
export const importLibraryFromJSON = async (library: Library) => {
const blob = await fileOpen({
description: "Excalidraw library files",
// ToDo: Be over-permissive until https://bugs.webkit.org/show_bug.cgi?id=34442
// gets resolved. Else, iOS users cannot open `.excalidraw` files.
/*
extensions: [".json", ".excalidrawlib"],
*/
});
await library.importLibrary(blob);
};
+407 -93
View File
@@ -1,122 +1,436 @@
import { loadLibraryFromBlob } from "./blob";
import { LibraryItems, LibraryItem } from "../types";
import { restoreElements, restoreLibraryItems } from "./restore";
import { getNonDeletedElements } from "../element";
import {
LibraryItems,
LibraryItem,
ExcalidrawImperativeAPI,
LibraryItemsSource,
} from "../types";
import { restoreLibraryItems } from "./restore";
import type App from "../components/App";
import { atom } from "jotai";
import { jotaiStore } from "../jotai";
import { ExcalidrawElement } from "../element/types";
import { getCommonBoundingBox } from "../element/bounds";
import { AbortError } from "../errors";
import { t } from "../i18n";
import { useEffect, useRef } from "react";
import { URL_HASH_KEYS, URL_QUERY_KEYS, APP_NAME, EVENT } from "../constants";
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 {
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;
constructor(app: App) {
this.app = app;
}
resetLibrary = async () => {
await this.app.props.onLibraryChange?.([]);
this.libraryCache = [];
private updateQueue: Promise<LibraryItems>[] = [];
private getLastUpdateTask = (): Promise<LibraryItems> | undefined => {
return this.updateQueue[this.updateQueue.length - 1];
};
restoreLibraryItem = (libraryItem: LibraryItem): LibraryItem | null => {
const elements = getNonDeletedElements(
restoreElements(libraryItem.elements, null),
);
return elements.length ? { ...libraryItem, elements } : null;
};
/** 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
);
});
private notifyListeners = () => {
if (this.updateQueue.length > 0) {
jotaiStore.set(libraryItemsAtom, {
status: "loading",
libraryItems: this.lastLibraryItems,
isInitialized: this.isInitialized,
});
};
const existingLibraryItems = await this.loadLibrary();
const library = libraryFile.libraryItems || libraryFile.library || [];
const restoredLibItems = restoreLibraryItems(
library,
defaultStatus as "published" | "unpublished",
);
const filteredItems = [];
for (const item of restoredLibItems) {
const restoredItem = this.restoreLibraryItem(item as LibraryItem);
if (restoredItem && isUniqueitem(existingLibraryItems, restoredItem)) {
filteredItems.push(restoredItem);
} else {
this.isInitialized = true;
jotaiStore.set(libraryItemsAtom, {
status: "loaded",
libraryItems: this.lastLibraryItems,
isInitialized: this.isInitialized,
});
try {
this.app.props.onLibraryChange?.(
cloneLibraryItems(this.lastLibraryItems),
);
} catch (error) {
console.error(error);
}
}
};
await this.saveLibrary([...filteredItems, ...existingLibraryItems]);
}
resetLibrary = () => {
return this.setLibrary([]);
};
loadLibrary = (): Promise<LibraryItems> => {
/**
* @returns latest cloned libraryItems. Awaits all in-progress updates first.
*/
getLatestLibrary = (): Promise<LibraryItems> => {
return new Promise(async (resolve) => {
if (this.libraryCache) {
return resolve(JSON.parse(JSON.stringify(this.libraryCache)));
}
try {
const libraryItems = this.app.libraryItemsFromStorage;
if (!libraryItems) {
return resolve([]);
const libraryItems = await (this.getLastUpdateTask() ||
this.lastLibraryItems);
if (this.updateQueue.length > 0) {
resolve(this.getLatestLibrary());
} else {
resolve(cloneLibraryItems(libraryItems));
}
const items = libraryItems.reduce((acc, item) => {
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([]);
} catch (error) {
return resolve(this.lastLibraryItems);
}
});
};
saveLibrary = async (items: LibraryItems) => {
const prevLibraryItems = this.libraryCache;
try {
const serializedItems = JSON.stringify(items);
// cache optimistically so that the app has access to the latest
// immediately
this.libraryCache = JSON.parse(serializedItems);
await this.app.props.onLibraryChange?.(items);
} catch (error: any) {
this.libraryCache = prevLibraryItems;
throw error;
// NOTE this is a high-level public API (exposed on ExcalidrawAPI) with
// a slight overhead (always restoring library items). For internal use
// where merging isn't needed, use `library.setLibrary()` directly.
updateLibrary = async ({
libraryItems,
prompt = false,
merge = false,
openLibraryMenu = false,
defaultStatus = "unpublished",
}: {
libraryItems: LibraryItemsSource;
merge?: boolean;
prompt?: boolean;
openLibraryMenu?: boolean;
defaultStatus?: "unpublished" | "published";
}): Promise<LibraryItems> => {
if (openLibraryMenu) {
this.app.setState({ isLibraryOpen: true });
}
return this.setLibrary(() => {
return new Promise<LibraryItems>(async (resolve, reject) => {
try {
const source = await (typeof libraryItems === "function"
? libraryItems(this.lastLibraryItems)
: libraryItems);
let nextItems;
if (source instanceof Blob) {
nextItems = await loadLibraryFromBlob(source, defaultStatus);
} else {
nextItems = restoreLibraryItems(source, defaultStatus);
}
if (
!prompt ||
window.confirm(
t("alerts.confirmAddLibrary", {
numShapes: nextItems.length,
}),
)
) {
if (merge) {
resolve(mergeLibraryItems(this.lastLibraryItems, nextItems));
} else {
resolve(nextItems);
}
} else {
reject(new AbortError());
}
} catch (error: any) {
reject(error);
}
});
}).finally(() => {
this.app.focusContainer();
});
};
setLibrary = (
/**
* LibraryItems that will replace current items. Can be a function which
* will be invoked after all previous tasks are resolved
* (this is the prefered way to update the library to avoid race conditions,
* but you'll want to manually merge the library items in the callback
* - which is what we're doing in Library.importLibrary()).
*
* If supplied promise is rejected with AbortError, we swallow it and
* do not update the library.
*/
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;
};
}
export default Library;
export const distributeLibraryItemsOnSquareGrid = (
libraryItems: LibraryItems,
) => {
const PADDING = 50;
const ITEMS_PER_ROW = Math.ceil(Math.sqrt(libraryItems.length));
const resElements: ExcalidrawElement[] = [];
const getMaxHeightPerRow = (row: number) => {
const maxHeight = libraryItems
.slice(row * ITEMS_PER_ROW, row * ITEMS_PER_ROW + ITEMS_PER_ROW)
.reduce((acc, item) => {
const { height } = getCommonBoundingBox(item.elements);
return Math.max(acc, height);
}, 0);
return maxHeight;
};
const getMaxWidthPerCol = (targetCol: number) => {
let index = 0;
let currCol = 0;
let maxWidth = 0;
for (const item of libraryItems) {
if (index % ITEMS_PER_ROW === 0) {
currCol = 0;
}
if (currCol === targetCol) {
const { width } = getCommonBoundingBox(item.elements);
maxWidth = Math.max(maxWidth, width);
}
index++;
currCol++;
}
return maxWidth;
};
let colOffsetX = 0;
let rowOffsetY = 0;
let maxHeightCurrRow = 0;
let maxWidthCurrCol = 0;
let index = 0;
let col = 0;
let row = 0;
for (const item of libraryItems) {
if (index && index % ITEMS_PER_ROW === 0) {
rowOffsetY += maxHeightCurrRow + PADDING;
colOffsetX = 0;
col = 0;
row++;
}
if (col === 0) {
maxHeightCurrRow = getMaxHeightPerRow(row);
}
maxWidthCurrCol = getMaxWidthPerCol(col);
const { minX, minY, width, height } = getCommonBoundingBox(item.elements);
const offsetCenterX = (maxWidthCurrCol - width) / 2;
const offsetCenterY = (maxHeightCurrRow - height) / 2;
resElements.push(
// eslint-disable-next-line no-loop-func
...item.elements.map((element) => ({
...element,
x:
element.x +
// offset for column
colOffsetX +
// offset to center in given square grid
offsetCenterX -
// subtract minX so that given item starts at 0 coord
minX,
y:
element.y +
// offset for row
rowOffsetY +
// offset to center in given square grid
offsetCenterY -
// subtract minY so that given item starts at 0 coord
minY,
})),
);
colOffsetX += maxWidthCurrCol + PADDING;
index++;
col++;
}
return resElements;
};
export const parseLibraryTokensFromUrl = () => {
const libraryUrl =
// current
new URLSearchParams(window.location.hash.slice(1)).get(
URL_HASH_KEYS.addLibrary,
) ||
// legacy, kept for compat reasons
new URLSearchParams(window.location.search).get(URL_QUERY_KEYS.addLibrary);
const idToken = libraryUrl
? new URLSearchParams(window.location.hash.slice(1)).get("token")
: null;
return libraryUrl ? { libraryUrl, idToken } : null;
};
export const useHandleLibrary = ({
excalidrawAPI,
getInitialLibraryItems,
}: {
excalidrawAPI: ExcalidrawImperativeAPI | null;
getInitialLibraryItems?: () => LibraryItemsSource;
}) => {
const getInitialLibraryRef = useRef(getInitialLibraryItems);
useEffect(() => {
if (!excalidrawAPI) {
return;
}
const importLibraryFromURL = ({
libraryUrl,
idToken,
}: {
libraryUrl: string;
idToken: string | null;
}) => {
if (window.location.hash.includes(URL_HASH_KEYS.addLibrary)) {
const hash = new URLSearchParams(window.location.hash.slice(1));
hash.delete(URL_HASH_KEYS.addLibrary);
window.history.replaceState({}, APP_NAME, `#${hash.toString()}`);
} else if (window.location.search.includes(URL_QUERY_KEYS.addLibrary)) {
const query = new URLSearchParams(window.location.search);
query.delete(URL_QUERY_KEYS.addLibrary);
window.history.replaceState({}, APP_NAME, `?${query.toString()}`);
}
excalidrawAPI.updateLibrary({
libraryItems: new Promise<Blob>(async (resolve, reject) => {
try {
const request = await fetch(decodeURIComponent(libraryUrl));
const blob = await request.blob();
resolve(blob);
} catch (error: any) {
reject(error);
}
}),
prompt: idToken !== excalidrawAPI.id,
merge: true,
defaultStatus: "published",
openLibraryMenu: true,
});
};
const onHashChange = (event: HashChangeEvent) => {
event.preventDefault();
const libraryUrlTokens = parseLibraryTokensFromUrl();
if (libraryUrlTokens) {
event.stopImmediatePropagation();
// If hash changed and it contains library url, import it and replace
// the url to its previous state (important in case of collaboration
// and similar).
// Using history API won't trigger another hashchange.
window.history.replaceState({}, "", event.oldURL);
importLibraryFromURL(libraryUrlTokens);
}
};
// -------------------------------------------------------------------------
// ------ init load --------------------------------------------------------
if (getInitialLibraryRef.current) {
excalidrawAPI.updateLibrary({
libraryItems: getInitialLibraryRef.current(),
});
}
const libraryUrlTokens = parseLibraryTokensFromUrl();
if (libraryUrlTokens) {
importLibraryFromURL(libraryUrlTokens);
}
// --------------------------------------------------------- init load -----
window.addEventListener(EVENT.HASHCHANGE, onHashChange);
return () => {
window.removeEventListener(EVENT.HASHCHANGE, onHashChange);
};
}, [excalidrawAPI]);
};
+54 -14
View File
@@ -10,7 +10,11 @@ import {
NormalizedZoomValue,
} from "../types";
import { ImportedDataState } from "./types";
import { getNormalizedDimensions, isInvisiblySmallElement } from "../element";
import {
getNonDeletedElements,
getNormalizedDimensions,
isInvisiblySmallElement,
} from "../element";
import { isLinearElementType } from "../element/typeChecks";
import { randomId } from "../random";
import {
@@ -22,7 +26,7 @@ import {
import { getDefaultAppState } from "../appState";
import { LinearElementEditor } from "../element/linearElementEditor";
import { bumpVersion } from "../element/mutateElement";
import { getUpdatedTimestamp } from "../utils";
import { getUpdatedTimestamp, updateActiveTool } from "../utils";
import { arrayToMap } from "../utils";
type RestoredAppState = Omit<
@@ -30,8 +34,8 @@ type RestoredAppState = Omit<
"offsetTop" | "offsetLeft" | "width" | "height"
>;
export const AllowedExcalidrawElementTypes: Record<
AppState["elementType"],
export const AllowedExcalidrawActiveTools: Record<
AppState["activeTool"]["type"],
boolean
> = {
selection: true,
@@ -44,6 +48,7 @@ export const AllowedExcalidrawElementTypes: Record<
arrow: true,
freedraw: true,
eraser: false,
custom: true,
};
export type RestoredDataState = {
@@ -107,6 +112,7 @@ const restoreElementWithProperties = <
: element.boundElements ?? [],
updated: element.updated ?? getUpdatedTimestamp(),
link: element.link ?? null,
locked: element.locked ?? false,
};
return {
@@ -193,6 +199,7 @@ const restoreElement = (
y,
});
}
// generic elements
case "ellipse":
return restoreElementWithProperties(element, {});
@@ -235,10 +242,8 @@ export const restoreAppState = (
localAppState: Partial<AppState> | null | undefined,
): RestoredAppState => {
appState = appState || {};
const defaultAppState = getDefaultAppState();
const nextAppState = {} as typeof defaultAppState;
for (const [key, defaultValue] of Object.entries(defaultAppState) as [
keyof typeof defaultAppState,
any,
@@ -255,9 +260,22 @@ export const restoreAppState = (
return {
...nextAppState,
elementType: AllowedExcalidrawElementTypes[nextAppState.elementType]
? nextAppState.elementType
: "selection",
cursorButton: localAppState?.cursorButton || "up",
// reset on fresh restore so as to hide the UI button if penMode not active
penDetected:
localAppState?.penDetected ??
(appState.penMode ? appState.penDetected ?? false : false),
activeTool: {
...updateActiveTool(
defaultAppState,
nextAppState.activeTool.type &&
AllowedExcalidrawActiveTools[nextAppState.activeTool.type]
? nextAppState.activeTool
: { type: "selection" },
),
lastActiveToolBeforeEraser: null,
locked: nextAppState.activeTool.locked ?? false,
},
// Migrates from previous version where appState.zoom was a number
zoom:
typeof appState.zoom === "number"
@@ -265,11 +283,16 @@ export const restoreAppState = (
value: appState.zoom as NormalizedZoomValue,
}
: appState.zoom || defaultAppState.zoom,
// when sidebar docked and user left it open in last session,
// keep it open. If not docked, keep it closed irrespective of last state.
isLibraryOpen: nextAppState.isLibraryMenuDocked
? nextAppState.isLibraryOpen
: false,
};
};
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
* 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 = (
libraryItems: NonOptional<ImportedDataState["libraryItems"]>,
libraryItems: ImportedDataState["libraryItems"] = [],
defaultStatus: LibraryItem["status"],
) => {
const restoredItems: LibraryItem[] = [];
for (const item of libraryItems) {
// migrate older libraries
if (Array.isArray(item)) {
restoredItems.push({
const restoredItem = restoreLibraryItem({
status: defaultStatus,
elements: item,
id: randomId(),
created: Date.now(),
});
if (restoredItem) {
restoredItems.push(restoredItem);
}
} else {
const _item = item as MarkOptional<LibraryItem, "id" | "status">;
restoredItems.push({
const _item = item as MarkOptional<
LibraryItem,
"id" | "status" | "created"
>;
const restoredItem = restoreLibraryItem({
..._item,
id: _item.id || randomId(),
status: _item.status || defaultStatus,
created: _item.created || Date.now(),
});
if (restoredItem) {
restoredItems.push(restoredItem);
}
}
}
return restoredItems;
+9 -4
View File
@@ -1,6 +1,11 @@
import { ExcalidrawElement } from "../element/types";
import { AppState, BinaryFiles, LibraryItems, LibraryItems_v1 } from "../types";
import type { cleanAppStateForExport } from "../appState";
import {
AppState,
BinaryFiles,
LibraryItems,
LibraryItems_anyVersion,
} from "../types";
import type { cleanAppStateForTextExport } from "../appState";
import { VERSIONS } from "../constants";
export interface ExportedDataState {
@@ -8,7 +13,7 @@ export interface ExportedDataState {
version: number;
source: string;
elements: readonly ExcalidrawElement[];
appState: ReturnType<typeof cleanAppStateForExport>;
appState: ReturnType<typeof cleanAppStateForTextExport>;
files: BinaryFiles | undefined;
}
@@ -19,7 +24,7 @@ export interface ImportedDataState {
elements?: readonly ExcalidrawElement[] | null;
appState?: Readonly<Partial<AppState>> | null;
scrollToContent?: boolean;
libraryItems?: LibraryItems | LibraryItems_v1;
libraryItems?: LibraryItems_anyVersion;
files?: BinaryFiles;
}
+4 -3
View File
@@ -262,9 +262,7 @@ export const actionLink = register({
commitToHistory: true,
};
},
trackEvent: (action, source) => {
trackEvent("hyperlink", "edit", source);
},
trackEvent: { category: "hyperlink", action: "click" },
keyTest: (event) => event[KEYS.CTRL_OR_CMD] && event.key === KEYS.K,
contextItemLabel: (elements, appState) =>
getContextMenuLabel(elements, appState),
@@ -337,6 +335,9 @@ export const isPointHittingLinkIcon = (
[x, y]: Point,
isMobile: boolean,
) => {
if (!element.link || appState.selectedElementIds[element.id]) {
return false;
}
const threshold = 4 / appState.zoom.value;
if (
!isMobile &&
+109 -62
View File
@@ -47,6 +47,20 @@ export const isBindingEnabled = (appState: AppState): boolean => {
return appState.isBindingEnabled;
};
const getNonDeletedElements = (
scene: Scene,
ids: readonly ExcalidrawElement["id"][],
): NonDeleted<ExcalidrawElement>[] => {
const result: NonDeleted<ExcalidrawElement>[] = [];
ids.forEach((id) => {
const element = scene.getNonDeletedElement(id);
if (element != null) {
result.push(element);
}
});
return result;
};
export const bindOrUnbindLinearElement = (
linearElement: NonDeleted<ExcalidrawLinearElement>,
startBindingElement: ExcalidrawBindableElement | null | "keep",
@@ -74,16 +88,17 @@ export const bindOrUnbindLinearElement = (
const onlyUnbound = Array.from(unboundFromElementIds).filter(
(id) => !boundToElementIds.has(id),
);
Scene.getScene(linearElement)!
.getNonDeletedElements(onlyUnbound)
.forEach((element) => {
getNonDeletedElements(Scene.getScene(linearElement)!, onlyUnbound).forEach(
(element) => {
mutateElement(element, {
boundElements: element.boundElements?.filter(
(element) =>
element.type !== "arrow" || element.id !== linearElement.id,
),
});
});
},
);
};
const bindOrUnbindLinearElementEdge = (
@@ -253,9 +268,10 @@ export const getHoveredElementForBinding = (
scene: Scene,
): NonDeleted<ExcalidrawBindableElement> | null => {
const hoveredElement = getElementAtPosition(
scene.getElements(),
scene.getNonDeletedElements(),
(element) =>
isBindableElement(element) && bindingBorderTest(element, pointerCoords),
isBindableElement(element, false) &&
bindingBorderTest(element, pointerCoords),
);
return hoveredElement as NonDeleted<ExcalidrawBindableElement> | null;
};
@@ -304,46 +320,48 @@ export const updateBoundElements = (
const simultaneouslyUpdatedElementIds = getSimultaneouslyUpdatedElementIds(
simultaneouslyUpdated,
);
Scene.getScene(changedElement)!
.getNonDeletedElements(boundLinearElements.map((el) => el.id))
.forEach((element) => {
if (!isLinearElement(element)) {
return;
}
const bindableElement = changedElement as ExcalidrawBindableElement;
// In case the boundElements are stale
if (!doesNeedUpdate(element, bindableElement)) {
return;
}
const startBinding = maybeCalculateNewGapWhenScaling(
bindableElement,
element.startBinding,
newSize,
);
const endBinding = maybeCalculateNewGapWhenScaling(
bindableElement,
element.endBinding,
newSize,
);
// `linearElement` is being moved/scaled already, just update the binding
if (simultaneouslyUpdatedElementIds.has(element.id)) {
mutateElement(element, { startBinding, endBinding });
return;
}
updateBoundPoint(
element,
"start",
startBinding,
changedElement as ExcalidrawBindableElement,
);
updateBoundPoint(
element,
"end",
endBinding,
changedElement as ExcalidrawBindableElement,
);
});
getNonDeletedElements(
Scene.getScene(changedElement)!,
boundLinearElements.map((el) => el.id),
).forEach((element) => {
if (!isLinearElement(element)) {
return;
}
const bindableElement = changedElement as ExcalidrawBindableElement;
// In case the boundElements are stale
if (!doesNeedUpdate(element, bindableElement)) {
return;
}
const startBinding = maybeCalculateNewGapWhenScaling(
bindableElement,
element.startBinding,
newSize,
);
const endBinding = maybeCalculateNewGapWhenScaling(
bindableElement,
element.endBinding,
newSize,
);
// `linearElement` is being moved/scaled already, just update the binding
if (simultaneouslyUpdatedElementIds.has(element.id)) {
mutateElement(element, { startBinding, endBinding });
return;
}
updateBoundPoint(
element,
"start",
startBinding,
changedElement as ExcalidrawBindableElement,
);
updateBoundPoint(
element,
"end",
endBinding,
changedElement as ExcalidrawBindableElement,
);
});
};
const doesNeedUpdate = (
@@ -456,13 +474,13 @@ export const getEligibleElementsForBinding = (
): SuggestedBinding[] => {
const includedElementIds = new Set(elements.map(({ id }) => id));
return elements.flatMap((element) =>
isBindingElement(element)
isBindingElement(element, false)
? (getElligibleElementsForBindingElement(
element as NonDeleted<ExcalidrawLinearElement>,
).filter(
(element) => !includedElementIds.has(element.id),
) as SuggestedBinding[])
: isBindableElement(element)
: isBindableElement(element, false)
? getElligibleElementsForBindableElementAndWhere(element).filter(
(binding) => !includedElementIds.has(binding[0].id),
)
@@ -506,9 +524,9 @@ const getElligibleElementsForBindableElementAndWhere = (
bindableElement: NonDeleted<ExcalidrawBindableElement>,
): SuggestedPointBinding[] => {
return Scene.getScene(bindableElement)!
.getElements()
.getNonDeletedElements()
.map((element) => {
if (!isBindingElement(element)) {
if (!isBindingElement(element, false)) {
return null;
}
const canBindStart = isLinearElementEligibleForNewBindingByBindable(
@@ -659,28 +677,47 @@ export const fixBindingsAfterDeletion = (
const deletedElementIds = new Set(
deletedElements.map((element) => element.id),
);
// Non deleted and need an update
const boundElementIds: Set<ExcalidrawElement["id"]> = new Set();
// non-deleted which bindings need to be updated
const affectedElements: Set<ExcalidrawElement["id"]> = new Set();
deletedElements.forEach((deletedElement) => {
if (isBindableElement(deletedElement)) {
deletedElement.boundElements?.forEach((element) => {
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.filter(({ id }) =>
boundElementIds.has(id),
) as ExcalidrawLinearElement[]
).forEach((element: ExcalidrawLinearElement) => {
const { startBinding, endBinding } = element;
mutateElement(element, {
startBinding: newBindingAfterDeletion(startBinding, deletedElementIds),
endBinding: newBindingAfterDeletion(endBinding, deletedElementIds),
sceneElements
.filter(({ id }) => affectedElements.has(id))
.forEach((element) => {
if (isBindableElement(element)) {
mutateElement(element, {
boundElements: newBoundElementsAfterDeletion(
element.boundElements,
deletedElementIds,
),
});
} else if (isBindingElement(element)) {
mutateElement(element, {
startBinding: newBindingAfterDeletion(
element.startBinding,
deletedElementIds,
),
endBinding: newBindingAfterDeletion(
element.endBinding,
deletedElementIds,
),
});
}
});
});
};
const newBindingAfterDeletion = (
@@ -692,3 +729,13 @@ const newBindingAfterDeletion = (
}
return binding;
};
const newBoundElementsAfterDeletion = (
boundElements: ExcalidrawElement["boundElements"],
deletedElementIds: Set<ExcalidrawElement["id"]>,
) => {
if (!boundElements) {
return null;
}
return boundElements.filter((ele) => !deletedElementIds.has(ele.id));
};
+1 -1
View File
@@ -92,7 +92,7 @@ export const getDragOffsetXY = (
export const dragNewElement = (
draggingElement: NonDeletedExcalidrawElement,
elementType: AppState["elementType"],
elementType: AppState["activeTool"]["type"],
originX: number,
originY: number,
x: number,

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