Compare commits

..

181 Commits

Author SHA1 Message Date
8984d8f19a don't clean export options on load 2022-06-29 10:23:48 +00:00
b80706cd4a Update appState.ts 2022-06-28 17:53:01 +02:00
cf34cbdd30 fix export.test.tsx 2022-06-28 08:16:14 +00:00
6ead3ff839 fix export snapshot 2022-06-28 08:12:38 +00:00
d7f0d4ee21 [feat] serialize export options when embedding scene in an image 2022-06-27 20:31:05 +00:00
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
9135ebf2e2 feat: redirect vscode.excalidraw.com to vscode marketplace (#5285) 2022-06-23 17:42:50 +02:00
af31e9dcc2 fix: focus traps inside popovers (#5317) 2022-06-23 17:33:50 +02:00
50bc7e099a fix: unable to use cmd/ctrl-delete/backspace in inputs (#5348) 2022-06-23 17:27:15 +02:00
39d17c4a3c fix: delay loading until language imported (#5344) 2022-06-22 22:06:29 +05:30
d34c2a75db fix: command to trigger release (#5347) 2022-06-22 18:10:57 +05:30
de95c68d75 fix: remove unnecessary options passed to language detector (#5336) 2022-06-22 01:33:08 +05:30
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
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
fd48c2cf79 fix: non-letter shortcuts being swallowed by color picker (#5316) 2022-06-17 12:37:11 +02:00
5feacd9a3b feat: deduplicate collab avatars based on id (#5309) 2022-06-15 15:35:57 +02:00
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
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
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
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
6196fba286 docs: migrate example to typescript (#5243)
* docs: migrate example to typescript

* fix

* fix sidebar

* fix
2022-06-14 17:56:05 +05:30
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
f1bc90e08a fix: Allow null in renderFooter prop (#5282)
* fix: Allow null in render props

* update docs
2022-06-06 20:45:06 +05:30
aabcdc20fd docs: prevent touch so that pointermove works in touch devices (#5241) 2022-05-25 11:39:16 +05:30
269fbcc2f3 docs: remove dragging threshold when interacting with custom elements (#5240) 2022-05-24 20:56:01 +05:30
d08179c215 fix: transpile browser-fs-access in package build (#5041) 2022-05-24 15:07:05 +02:00
90e739d444 docs: fix offsets in the example when dragging custom elements (#5238) 2022-05-24 16:27:23 +05:30
4a9fac2d1e fix: unsafely accessing draggingElement (#5216) 2022-05-20 17:48:26 +02:00
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
92f30f7ed6 test: add tests for loading library from file picker (#5206) 2022-05-19 00:37:36 +02:00
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
bed9fca4a5 feat: go-to-excalidrawplus button (#5202)
Co-authored-by: dwelle <luzar.david@gmail.com>
2022-05-18 18:30:34 +02:00
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
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
31049d06e8 chore: Update translations from Crowdin (#5061) 2022-05-11 23:44:02 +02:00
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
33bb23d2f3 fix: wheel zoom normalization (#5165)
Co-authored-by: David Luzar <luzar.david@gmail.com>
2022-05-11 22:01:20 +02:00
b27ac257e7 feat: support resubmitting published library items (#5174) 2022-05-11 15:56:11 +02:00
d2cc76e52e feat: support adding multiple library items on canvas (#5116) 2022-05-11 15:51:02 +02:00
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
2537b225ac fix: hide sidebar when custom tool active (#5179) 2022-05-11 12:42:52 +00:00
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
d2e687ed0a feat: make file handling more robust (#5057) 2022-05-09 15:53:04 +02:00
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
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
3d56ceb794 fix: Handle ColorPicker parentSelector being undefined (#5152)
Co-authored-by: dwelle <luzar.david@gmail.com>
2022-05-07 18:16:14 +00:00
65c32b3319 fix: library multiselect not accounting for published state (#5132) 2022-05-07 19:13:10 +02:00
9e8e047aae fix: Chart display fix (#5154) 2022-05-07 19:12:31 +02:00
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
1ed1529f96 fix: update opacity of bound text when opacity of container updated (#5142) 2022-05-04 14:29:05 +05:30
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
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
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
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
d53ac2a61e fix: library init/import race conditions (#5101) 2022-04-29 16:45:02 +02:00
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
aee1e2451e fix: remove opacity scroll wheel interaction (#5111) 2022-04-28 19:10:08 +02:00
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
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
399ce1e01a fix: don't bind text to container if double clicked else instead of center (#5105) 2022-04-27 17:04:21 +05:30
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
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
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
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
af93cedc08 EXCALIDRAW_EXPORT_SOURCE_PATH -> EXCALIDRAW_EXPORT_SOURCE (#5102) 2022-04-27 10:49:02 +02:00
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
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
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
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
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
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
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
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
e6a9ff1b96 fix: toolIcon height not using rem (#5092) 2022-04-24 19:21:41 +02:00
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
9902092fd1 fix setting webpack public path (#5081) 2022-04-23 13:48:57 +05:30
8f0863d335 add a prefix to the extension for image embbedding sceneData (#5079) 2022-04-22 18:31:31 +02:00
9423ac3263 fix: Excalidraw named export type (#5078) 2022-04-22 15:49:36 +02:00
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
86cf28f2b4 fix: don't merge libraryItems on updateScene (#5076) 2022-04-21 10:54:02 +02:00
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
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
55ccd5b79b feat: delay initial loading message & tweak design (#5049) 2022-04-19 19:08:13 +02:00
4348c55c31 chore: Update translations from Crowdin (#4894) 2022-04-18 00:16:43 +02:00
a3fbe40b26 fix: eraser cursor showing on theme change when not using eraser (#4990) 2022-04-17 22:47:36 +02:00
7431ca81d1 fix: update storage.rules (#5020) 2022-04-17 22:47:00 +02:00
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
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
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
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
7d4189c624 fix: Add image button not working on iPad (#5038) 2022-04-15 12:20:51 +02:00
f3e17c90d3 fix: ensure svg image dimensions are always set (#5044) 2022-04-15 12:05:10 +02:00
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
bf6d0eeef7 fix: Pinch zoom in view mode (#5001) 2022-04-12 12:39:28 +02:00
5359e4fec9 feat: refactor local persistence & fix race condition on SW reload (#5032) 2022-04-11 22:15:49 +02:00
58fe639b8d fix: select whole group on righclick & few lock-related fixes (#5022) 2022-04-07 17:53:55 +02:00
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
c2fce6d8c4 fix: export serializeLibraryAsJSON from the package (#5017) 2022-04-07 12:35:44 +05:30
cb6b7559b4 fix: support copying PNG to clipboard on Safari (#3746) 2022-04-06 14:05:09 +02:00
77d789ed8e fix: more copyText fixes (#5016) 2022-04-05 23:11:00 +02:00
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
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
873afdacd3 feat: create and expose serializeLibraryAsJSON (#5009)
Co-authored-by: David Luzar <luzar.david@gmail.com>
2022-04-05 14:35:38 +02:00
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
9ba7ca3845 feat: hide penMode button on reload if not enabled (#4992) 2022-03-30 10:53:22 +02:00
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
f2d2f97546 fix: using stale state when switching tools (#4989) 2022-03-29 17:00:19 +02:00
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
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
f242721f3b chore: add ga for most actions (#4829) 2022-03-28 14:46:40 +02:00
e940aeb1a3 fix: updateWysiwygStyle updatedElement is undefined TypeError (#4980)
Co-authored-by: dwelle <luzar.david@gmail.com>
2022-03-28 00:14:04 +02:00
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
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
2209e2c1e8 fix: show link icon for bound text containers (#4960) 2022-03-23 00:45:08 +05:30
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
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
cded1cd63d fix: restore original opacities when alt pressed while erasing (#4954) 2022-03-22 16:40:28 +05:30
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
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
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
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
ceb43ed8fb fix: erase all elements which are hit with single point click (#4934) 2022-03-17 21:03:59 +05:30
8c0a0415de update README.md to show commands in code blocks (#4932) 2022-03-16 18:49:28 +01:00
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
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
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
065df495ba fix: Hide eraser in view mode in desktop (#4929) 2022-03-16 13:09:34 +05:30
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
6d45430344 fix: undo when erasing elements by clicking (#4921)
* fix: undo when erasing elements by clicking

* newline remove
2022-03-14 14:59:55 +05:30
3aa0c5ebc0 chore(deps-dev): bump css-loader in /src/packages/excalidraw (#4911)
Bumps [css-loader](https://github.com/webpack-contrib/css-loader) from 6.6.0 to 6.7.1.
- [Release notes](https://github.com/webpack-contrib/css-loader/releases)
- [Changelog](https://github.com/webpack-contrib/css-loader/blob/master/CHANGELOG.md)
- [Commits](https://github.com/webpack-contrib/css-loader/compare/v6.6.0...v6.7.1)

---
updated-dependencies:
- dependency-name: css-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-03-14 14:47:01 +05:30
e940993e0e chore(deps-dev): bump css-loader in /src/packages/utils (#4914)
Bumps [css-loader](https://github.com/webpack-contrib/css-loader) from 6.5.1 to 6.7.1.
- [Release notes](https://github.com/webpack-contrib/css-loader/releases)
- [Changelog](https://github.com/webpack-contrib/css-loader/blob/master/CHANGELOG.md)
- [Commits](https://github.com/webpack-contrib/css-loader/compare/v6.5.1...v6.7.1)

---
updated-dependencies:
- dependency-name: css-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-03-14 14:45:33 +05:30
8f90aeb8d5 chore(deps-dev): bump ts-loader in /src/packages/utils (#4913)
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-14 14:45:02 +05:30
e92d133973 fix: undo when erasing (#4900) 2022-03-11 20:44:17 +05:30
b682d88167 fix: incorrectly erasing on mobile (#4899)
* fix: incorrectly erasing on mobile

* reintroduce fix for erasing on single-point click

* fix snaps
2022-03-11 15:45:59 +01:00
7daf1a7944 feat: Add Eraser 🎉 (#4887)
* feat: Add Eraser 🎉

* Eraser working

* remove unused state

* fix

* toggle eraser

* Support deselect with Alt/Option

* rename actionDelete -> actionErase

* Add util isEraserActive

* show eraser in mobile

* render eraser conditionally in mobile

* use selection if eraser in local storage state

* Add sampling to erase accurately

* use pointerDownState

* set eraser to false in AllowedExcalidrawElementTypes

* rename/reword fixes

* don't use updateScene

* handle bound text when erasing

* fix hover state in mobile

* consider all hitElements instead of a single

* code improvements

* revert to select if eraser active and elements selected

* show eraser in zenmode

* erase element when clicked on element while eraser active

* set groupIds to empty when eraser active

* fix test

* remove dragged distance
2022-03-11 19:53:42 +05:30
5c0eff50a0 chore: Update translations from Crowdin (#4729)
Co-authored-by: dwelle <luzar.david@gmail.com>
2022-03-09 12:21:26 +01:00
19056d635b feat: added optional REACT_APP_WS_SERVER_URL for forks usecases (#4889)
Co-authored-by: dwelle <luzar.david@gmail.com>
2022-03-09 12:13:59 +01:00
4d5f00ff08 fix: don't crash on drop highlighted text onto canvas (#4890) 2022-03-09 11:51:13 +01:00
20de06ef50 fix: paste styles shortcut (#4886)
Co-authored-by: Aakansha Doshi <aakansha1216@gmail.com>
2022-03-09 10:59:44 +01:00
1849ff6ee2 fix: freedraw element's background fill color missing from SVG when exporting with package API exportToSvg() (#4871) 2022-03-06 23:35:16 +01:00
6765fc16be fix: improve pointer syncing performance (#4883)
Co-authored-by: dwelle <luzar.david@gmail.com>
2022-03-06 22:11:14 +00:00
5ca4f5bbf4 feat: rewrite collab server connecting (#4881)
Co-authored-by: dwelle <luzar.david@gmail.com>
2022-03-06 22:43:02 +01:00
9392ec276d fix: collab room initialization (#4882) 2022-03-06 15:59:56 +01:00
b26e4fcf99 build: support runtime React Jsx in @excalidraw/utils (#4866)
* build: support runtime React Jsx

* revert version

* upgrade
2022-03-04 10:58:02 +05:30
45f3410da8 build: release @excalidraw/utils 0.1.1 (#4862) 2022-03-03 14:59:08 +01:00
94b387ef7b fix: ensure verticalAlign properties not shown when no element selected (#4860)
* fix: ensure verticalAlign properties not shown when no element selected

* show verticalAlign prop if selection contains at least one applicable element

* simplify
2022-03-02 23:56:20 +01:00
6d0716eb6b fix: binding text to non-bindable containers and not always preferring selection (#4655) 2022-03-02 17:04:09 +01:00
8e26d5b500 feat: support vertical text align for bound containers (#4852)
* feat: support vertical text align for bound containers

* update icons

* use const

* fix lint

* rename to  and show when text editor active

* don't update vertical align if not center

* fix svgs

* fix y coords when vertical align bottm

Co-authored-by: dwelle <luzar.david@gmail.com>
2022-03-02 20:06:07 +05:30
c5a7723185 chore: fix various typos (#4857)
Found via `codespell -q 3 -S ./src/locales,./yarn.lock,./src/packages/excalidraw/yarn.lock -L afterall,doubleclick,originaly,reenable,whats,sur`
2022-03-02 11:37:12 +05:30
49172ac2d3 feat: support custom colors 🎉 (#4843)
* feat: support custom colors 🎉

* remove canvasBackground

* fix tests

* Remove custom color when elements deleted

* persist custom color across sessions

* Choose 5 latest custom colors when populating from elements

* fix tests

* styling

* don't use up/down arrow for custom colors

* Always push latest color to the begining

* don't check if valid in custom color

* calculate custom colors on color picker open

* revert unnecessary changes

* remove newlines

* simplify state

* tweak label

* fix custom color shortcuts throwing if color not exists

* fix

* early return

Co-authored-by: dwelle <luzar.david@gmail.com>
2022-02-28 19:04:26 +05:30
618a846451 chore: remove firebase-tools (#4792) 2022-02-28 10:54:20 +01:00
d9f49ffd67 fix: Don't show align icons for single bound container element (#4846)
* fix: Don't show align icons for single bound container element

* check 2nd element as well
2022-02-28 11:08:28 +05:30
46e43baad1 feat: Support Links in Exported SVG (#4791) 2022-02-25 21:42:10 +01:00
bd35b682fa fix: redraw text bounding box when pasting styles (#4845) 2022-02-25 15:36:56 +05:30
b6f9a8005e docs: list who's integrating excalidraw (#4832)
* docs: list who's integrating excalidraw

* Update README.md
2022-02-23 23:28:17 +01:00
1acfaf6b6e feat: Scale font size when bound text containers resized with shift pressed (#4828)
* feat: Scale font size when bound text containers resized with shift pressed

* revert fontsize once shift pressed/released after resize

* make slightly more typesafe

Co-authored-by: dwelle <luzar.david@gmail.com>
2022-02-22 18:45:59 +05:30
5cf7087754 fix: restore cursor position after bound text container value updated (#4836)
* fix: restore cursor position after bound text container value updated

* only restore cursor when the cursor is not at the end of the line

* compute diff before setting the cursor
2022-02-22 18:24:06 +05:30
b2d49155ef build:remove build packages workflow (#4835) 2022-02-22 13:50:25 +05:30
9745461db7 chore(deps): bump browser-fs-access from 0.23.0 to 0.24.1 (#4820)
Bumps [browser-fs-access](https://github.com/GoogleChromeLabs/browser-fs-access) from 0.23.0 to 0.24.1.
- [Release notes](https://github.com/GoogleChromeLabs/browser-fs-access/releases)
- [Commits](https://github.com/GoogleChromeLabs/browser-fs-access/compare/v0.23.0...v0.24.1)

---
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-02-22 13:26:53 +05:30
21e9fcb2f5 chore: Add tracking for hyperlinks (#4703)
* chore: Add tracking for hyperlinks

* update

* fix

* remove

* tweak

* disable ga logging in dev again

* add logging for hyperlink `edit` & support for tracking in manager

* event label tweaks

* fix tests & make more typesafe

Co-authored-by: dwelle <luzar.david@gmail.com>
2022-02-21 17:44:28 +05:30
e203203993 refactor: don't pass array to handleBindTextResize (#4826) 2022-02-21 17:15:29 +05:30
f224e4d596 fix: support resizing multiple bound text containers (#4824) 2022-02-21 16:46:39 +05:30
e0ca689759 chore(deps): bump url-parse from 1.5.3 to 1.5.7 (#4807)
Bumps [url-parse](https://github.com/unshiftio/url-parse) from 1.5.3 to 1.5.7.
- [Release notes](https://github.com/unshiftio/url-parse/releases)
- [Commits](https://github.com/unshiftio/url-parse/compare/1.5.3...1.5.7)

---
updated-dependencies:
- dependency-name: url-parse
  dependency-type: indirect
...

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

Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2022-02-19 15:16:30 +05:30
f792eb5ae7 fix: also check overflowY: overlay in detectScroll (#4806)
* fix: also check overflowY: overlay in detectScroll

* fix lint

Co-authored-by: dwelle <luzar.david@gmail.com>
2022-02-18 19:17:43 +01:00
4604c8d823 fix: stuck resizing when resizing bound text container very fast beyond threshold (#4804)
* fix: stuck resizing when resizing bound text container very fast beyond threshold

* fix

* fix
2022-02-18 18:20:55 +05:30
246 changed files with 13242 additions and 7657 deletions

View File

@ -4,5 +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_SOCKET_SERVER_URL=http://localhost:3002
# 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"}'

View File

@ -4,8 +4,14 @@ REACT_APP_BACKEND_V2_POST_URL=https://json.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_SOCKET_SERVER_URL=https://oss-collab-us1.excalidraw.com
REACT_APP_PORTAL_URL=https://portal.excalidraw.com
# 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=
REACT_APP_FIREBASE_CONFIG='{"apiKey":"AIzaSyAd15pYlMci_xIp9ko6wkEsDzAAA0Dn0RU","authDomain":"excalidraw-room-persistence.firebaseapp.com","databaseURL":"https://excalidraw-room-persistence.firebaseio.com","projectId":"excalidraw-room-persistence","storageBucket":"excalidraw-room-persistence.appspot.com","messagingSenderId":"654800341332","appId":"1:654800341332:web:4a692de832b55bd57ce0c1"}'
# production-only vars
REACT_APP_GOOGLE_ANALYTICS_ID=UA-387204-13
REACT_APP_PLUS_APP=https://app.excalidraw.com

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

View File

@ -1,29 +0,0 @@
name: Build packages
on:
push:
branches:
- master
pull_request:
jobs:
packages:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v2
- name: Setup Node.js 14.x
uses: actions/setup-node@v2
with:
node-version: 14.x
- name: Install dependencies
run: |
yarn --frozen-lockfile
yarn --cwd src/packages/excalidraw
yarn --cwd src/packages/utils
- name: Build @excalidraw/excalidraw
run: |
yarn --cwd src/packages/excalidraw run pack
- name: Build @excalidraw/utils
run: |
yarn --cwd src/packages/utils run pack

View File

@ -32,6 +32,10 @@ Last but not least, we're thankful to these companies for offering their service
[![Vercel](./.github/assets/vercel.svg)](https://vercel.com) [![Sentry](./.github/assets/sentry.svg)](https://sentry.io) [![Crowdin](./.github/assets/crowdin.svg)](https://crowdin.com)
## Who's integrating Excalidraw
[Google Cloud](https://googlecloudcheatsheet.withgoogle.com/architecture) • [Meta](https://meta.com/) • [CodeSandbox](https://codesandbox.io/) • [Obsidian Excalidraw](https://github.com/zsviczian/obsidian-excalidraw-plugin) • [Replit](https://replit.com/) • [Slite](https://slite.com/) • [Notion](https://notion.so/) • [HackerRank](https://www.hackerrank.com/) •
## Documentation
### Shortcuts
@ -124,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

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;
}
}
}

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.23.0",
"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,14 +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",
"firebase-tools": "9.23.0",
"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": {

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 -->
@ -72,12 +91,6 @@
crossorigin="anonymous"
/>
<link
href="%REACT_APP_SOCKET_SERVER_URL%/socket.io"
rel="preconnect"
crossorigin="anonymous"
/>
<link
rel="manifest"
href="manifest.json"
@ -130,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;
@ -158,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;
@ -176,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>

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",

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,

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,
};
},
});

View File

@ -1,5 +1,5 @@
import { ColorPicker } from "../components/ColorPicker";
import { zoomIn, zoomOut } from "../components/icons";
import { eraser, zoomIn, zoomOut } from "../components/icons";
import { ToolButton } from "../components/ToolButton";
import { DarkModeToggle } from "../components/DarkModeToggle";
import { THEME, ZOOM_STEP } from "../constants";
@ -11,22 +11,24 @@ 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";
import { getDefaultAppState } from "../appState";
import { getDefaultAppState, isEraserActive } from "../appState";
import ClearCanvas from "../components/ClearCanvas";
import clsx from "clsx";
export const actionChangeViewBackgroundColor = register({
name: "changeViewBackgroundColor",
trackEvent: false,
perform: (_, appState, value) => {
return {
appState: { ...appState, ...value },
commitToHistory: !!value.viewBackgroundColor,
};
},
PanelComponent: ({ appState, updateData }) => {
PanelComponent: ({ elements, appState, updateData }) => {
return (
<div style={{ position: "relative" }}>
<ColorPicker
@ -39,6 +41,8 @@ export const actionChangeViewBackgroundColor = register({
updateData({ openPopup: active ? "canvasColorPicker" : null })
}
data-testid="canvas-background-picker"
elements={elements}
appState={appState}
/>
</div>
);
@ -47,6 +51,7 @@ export const actionChangeViewBackgroundColor = register({
export const actionClearCanvas = register({
name: "clearCanvas",
trackEvent: { category: "canvas" },
perform: (elements, appState, _, app) => {
app.imageCache.clear();
return {
@ -57,7 +62,6 @@ export const actionClearCanvas = register({
...getDefaultAppState(),
files: {},
theme: appState.theme,
elementLocked: appState.elementLocked,
penMode: appState.penMode,
penDetected: appState.penDetected,
exportBackground: appState.exportBackground,
@ -65,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,
};
@ -77,6 +83,7 @@ export const actionClearCanvas = register({
export const actionZoomIn = register({
name: "zoomIn",
trackEvent: { category: "canvas" },
perform: (_elements, appState, _, app) => {
return {
appState: {
@ -112,6 +119,7 @@ export const actionZoomIn = register({
export const actionZoomOut = register({
name: "zoomOut",
trackEvent: { category: "canvas" },
perform: (_elements, appState, _, app) => {
return {
appState: {
@ -147,6 +155,7 @@ export const actionZoomOut = register({
export const actionResetZoom = register({
name: "resetZoom",
trackEvent: { category: "canvas" },
perform: (_elements, appState, _, app) => {
return {
appState: {
@ -245,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 &&
@ -255,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 &&
@ -265,6 +276,7 @@ export const actionZoomToFit = register({
export const actionToggleTheme = register({
name: "toggleTheme",
trackEvent: { category: "canvas" },
perform: (_, appState, value) => {
return {
appState: {
@ -287,3 +299,49 @@ export const actionToggleTheme = register({
),
keyTest: (event) => event.altKey && event.shiftKey && event.code === CODES.D,
});
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: {},
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")}-${getShortcutKey("E")}`}
aria-label={t("toolBar.eraser")}
onClick={() => {
updateData(null);
}}
size={data?.size || "medium"}
></ToolButton>
),
});

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",
});

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(

View File

@ -3,11 +3,11 @@ 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";
import { CODES } from "../keys";
import { CODES, KEYS } from "../keys";
import { getSelectedElements, isSomeElementSelected } from "../scene";
import { AppState } from "../types";
import { arrayToMap, getShortcutKey } from "../utils";
@ -39,6 +39,7 @@ const distributeSelectedElements = (
export const distributeHorizontally = register({
name: "distributeHorizontally",
trackEvent: { category: "element" },
perform: (elements, appState) => {
return {
appState,
@ -49,7 +50,8 @@ export const distributeHorizontally = register({
commitToHistory: true,
};
},
keyTest: (event) => event.altKey && event.code === CODES.H,
keyTest: (event) =>
!event[KEYS.CTRL_OR_CMD] && event.altKey && event.code === CODES.H,
PanelComponent: ({ elements, appState, updateData }) => (
<ToolButton
hidden={!enableActionGroup(elements, appState)}
@ -67,6 +69,7 @@ export const distributeHorizontally = register({
export const distributeVertically = register({
name: "distributeVertically",
trackEvent: { category: "element" },
perform: (elements, appState) => {
return {
appState,
@ -77,7 +80,8 @@ export const distributeVertically = register({
commitToHistory: true,
};
},
keyTest: (event) => event.altKey && event.code === CODES.V,
keyTest: (event) =>
!event[KEYS.CTRL_OR_CMD] && event.altKey && event.code === CODES.V,
PanelComponent: ({ elements, appState, updateData }) => (
<ToolButton
hidden={!enableActionGroup(elements, appState)}

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) {

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 },

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"}
/>
),
});

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"),
@ -155,7 +157,7 @@ const flipElement = (
// calculate new x-coord for transformation
newNCoordsX = usingNWHandle ? element.x + 2 * width : element.x - 2 * width;
resizeSingleElement(
element,
new Map().set(element.id, element),
true,
element,
usingNWHandle ? "nw" : "ne",

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) {

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) =>

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();

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}
/>
);
},
});

View File

@ -30,11 +30,15 @@ import {
TextAlignCenterIcon,
TextAlignLeftIcon,
TextAlignRightIcon,
TextAlignTopIcon,
TextAlignBottomIcon,
TextAlignMiddleIcon,
} from "../components/icons";
import {
DEFAULT_FONT_FAMILY,
DEFAULT_FONT_SIZE,
FONT_FAMILY,
VERTICAL_ALIGN,
} from "../constants";
import {
getNonDeletedElements,
@ -58,6 +62,7 @@ import {
ExcalidrawTextElement,
FontFamilyValues,
TextAlign,
VerticalAlign,
} from "../element/types";
import { getLanguage, t } from "../i18n";
import { KEYS } from "../keys";
@ -161,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);
@ -193,6 +194,7 @@ const changeFontSize = (
export const actionChangeStrokeColor = register({
name: "changeStrokeColor",
trackEvent: false,
perform: (elements, appState, value) => {
return {
...(value.currentItemStrokeColor && {
@ -233,6 +235,8 @@ export const actionChangeStrokeColor = register({
setActive={(active) =>
updateData({ openPopup: active ? "strokeColorPicker" : null })
}
elements={elements}
appState={appState}
/>
</>
),
@ -240,6 +244,7 @@ export const actionChangeStrokeColor = register({
export const actionChangeBackgroundColor = register({
name: "changeBackgroundColor",
trackEvent: false,
perform: (elements, appState, value) => {
return {
...(value.currentItemBackgroundColor && {
@ -273,6 +278,8 @@ export const actionChangeBackgroundColor = register({
setActive={(active) =>
updateData({ openPopup: active ? "backgroundColorPicker" : null })
}
elements={elements}
appState={appState}
/>
</>
),
@ -280,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) =>
@ -329,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) =>
@ -376,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) =>
@ -424,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) =>
@ -471,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,
@ -491,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,
@ -520,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);
},
@ -577,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(
@ -598,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)),
@ -615,6 +620,7 @@ export const actionIncreaseFontSize = register({
export const actionChangeFontFamily = register({
name: "changeFontFamily",
trackEvent: false,
perform: (elements, appState, value) => {
return {
elements: changeProperty(
@ -628,11 +634,7 @@ export const actionChangeFontFamily = register({
fontFamily: value,
},
);
redrawTextBoundingBox(
newElement,
getContainerElement(oldElement),
appState,
);
redrawTextBoundingBox(newElement, getContainerElement(oldElement));
return newElement;
}
@ -700,6 +702,7 @@ export const actionChangeFontFamily = register({
export const actionChangeTextAlign = register({
name: "changeTextAlign",
trackEvent: false,
perform: (elements, appState, value) => {
return {
elements: changeProperty(
@ -709,15 +712,9 @@ export const actionChangeTextAlign = register({
if (isTextElement(oldElement)) {
const newElement: ExcalidrawTextElement = newElementWith(
oldElement,
{
textAlign: value,
},
);
redrawTextBoundingBox(
newElement,
getContainerElement(oldElement),
appState,
{ textAlign: value },
);
redrawTextBoundingBox(newElement, getContainerElement(oldElement));
return newElement;
}
@ -732,51 +729,121 @@ export const actionChangeTextAlign = register({
commitToHistory: true,
};
},
PanelComponent: ({ elements, appState, updateData }) => (
<fieldset>
<legend>{t("labels.textAlign")}</legend>
<ButtonIconSelect<TextAlign | false>
group="text-align"
options={[
{
value: "left",
text: t("labels.left"),
icon: <TextAlignLeftIcon theme={appState.theme} />,
},
{
value: "center",
text: t("labels.center"),
icon: <TextAlignCenterIcon theme={appState.theme} />,
},
{
value: "right",
text: t("labels.right"),
icon: <TextAlignRightIcon theme={appState.theme} />,
},
]}
value={getFormValue(
elements,
appState,
(element) => {
if (isTextElement(element)) {
return element.textAlign;
PanelComponent: ({ elements, appState, updateData }) => {
return (
<fieldset>
<legend>{t("labels.textAlign")}</legend>
<ButtonIconSelect<TextAlign | false>
group="text-align"
options={[
{
value: "left",
text: t("labels.left"),
icon: <TextAlignLeftIcon theme={appState.theme} />,
},
{
value: "center",
text: t("labels.center"),
icon: <TextAlignCenterIcon theme={appState.theme} />,
},
{
value: "right",
text: t("labels.right"),
icon: <TextAlignRightIcon theme={appState.theme} />,
},
]}
value={getFormValue(
elements,
appState,
(element) => {
if (isTextElement(element)) {
return element.textAlign;
}
const boundTextElement = getBoundTextElement(element);
if (boundTextElement) {
return boundTextElement.textAlign;
}
return null;
},
appState.currentItemTextAlign,
)}
onChange={(value) => updateData(value)}
/>
</fieldset>
);
},
});
export const actionChangeVerticalAlign = register({
name: "changeVerticalAlign",
trackEvent: { category: "element" },
perform: (elements, appState, value) => {
return {
elements: changeProperty(
elements,
appState,
(oldElement) => {
if (isTextElement(oldElement)) {
const newElement: ExcalidrawTextElement = newElementWith(
oldElement,
{ verticalAlign: value },
);
redrawTextBoundingBox(newElement, getContainerElement(oldElement));
return newElement;
}
return oldElement;
},
true,
),
appState: {
...appState,
},
commitToHistory: true,
};
},
PanelComponent: ({ elements, appState, updateData }) => {
return (
<fieldset>
<ButtonIconSelect<VerticalAlign | false>
group="text-align"
options={[
{
value: VERTICAL_ALIGN.TOP,
text: t("labels.alignTop"),
icon: <TextAlignTopIcon theme={appState.theme} />,
},
{
value: VERTICAL_ALIGN.MIDDLE,
text: t("labels.centerVertically"),
icon: <TextAlignMiddleIcon theme={appState.theme} />,
},
{
value: VERTICAL_ALIGN.BOTTOM,
text: t("labels.alignBottom"),
icon: <TextAlignBottomIcon theme={appState.theme} />,
},
]}
value={getFormValue(elements, appState, (element) => {
if (isTextElement(element) && element.containerId) {
return element.verticalAlign;
}
const boundTextElement = getBoundTextElement(element);
if (boundTextElement) {
return boundTextElement.textAlign;
return boundTextElement.verticalAlign;
}
return null;
},
appState.currentItemTextAlign,
)}
onChange={(value) => updateData(value)}
/>
</fieldset>
),
})}
onChange={(value) => updateData(value)}
/>
</fieldset>
);
},
});
export const actionChangeSharpness = register({
name: "changeSharpness",
trackEvent: false,
perform: (elements, appState, value) => {
const targetElements = getTargetElements(
getNonDeletedElements(elements),
@ -784,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, {
@ -827,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,
@ -841,6 +908,7 @@ export const actionChangeSharpness = register({
export const actionChangeArrowhead = register({
name: "changeArrowhead",
trackEvent: false,
perform: (
elements,
appState,

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;
}

View File

@ -0,0 +1,71 @@
import ExcalidrawApp from "../excalidraw-app";
import { t } from "../i18n";
import { CODES } from "../keys";
import { API } from "../tests/helpers/api";
import { Keyboard, Pointer, UI } from "../tests/helpers/ui";
import { fireEvent, render, screen } from "../tests/test-utils";
import { copiedStyles } from "./actionStyles";
const { h } = window;
const mouse = new Pointer("mouse");
describe("actionStyles", () => {
beforeEach(async () => {
await render(<ExcalidrawApp />);
});
it("should copy & paste styles via keyboard", () => {
UI.clickTool("rectangle");
mouse.down(10, 10);
mouse.up(20, 20);
UI.clickTool("rectangle");
mouse.down(10, 10);
mouse.up(20, 20);
// Change some styles of second rectangle
UI.clickLabeledElement("Stroke");
UI.clickLabeledElement(t("colors.c92a2a"));
UI.clickLabeledElement("Background");
UI.clickLabeledElement(t("colors.e64980"));
// Fill style
fireEvent.click(screen.getByTitle("Cross-hatch"));
// Stroke width
fireEvent.click(screen.getByTitle("Bold"));
// Stroke style
fireEvent.click(screen.getByTitle("Dotted"));
// Roughness
fireEvent.click(screen.getByTitle("Cartoonist"));
// Opacity
fireEvent.change(screen.getByLabelText("Opacity"), {
target: { value: "60" },
});
mouse.reset();
API.setSelectedElements([h.elements[1]]);
Keyboard.withModifierKeys({ ctrl: true, alt: true }, () => {
Keyboard.codeDown(CODES.C);
});
const secondRect = JSON.parse(copiedStyles)[0];
expect(secondRect.id).toBe(h.elements[1].id);
mouse.reset();
// Paste styles to first rectangle
API.setSelectedElements([h.elements[0]]);
Keyboard.withModifierKeys({ ctrl: true, alt: true }, () => {
Keyboard.codeDown(CODES.V);
});
const firstRect = API.getSelectedElement();
expect(firstRect.id).toBe(h.elements[0].id);
expect(firstRect.strokeColor).toBe("#c92a2a");
expect(firstRect.backgroundColor).toBe("#e64980");
expect(firstRect.fillStyle).toBe("cross-hatch");
expect(firstRect.strokeWidth).toBe(2); // Bold: 2
expect(firstRect.strokeStyle).toBe("dotted");
expect(firstRect.roughness).toBe(2); // Cartoonist: 2
expect(firstRect.opacity).toBe(60);
});
});

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(
element,
getContainerElement(element),
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;

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,

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");

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: {

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,

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,

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,
};
},
});

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),

View File

@ -17,6 +17,7 @@ export {
actionChangeFontSize,
actionChangeFontFamily,
actionChangeTextAlign,
actionChangeVerticalAlign,
} from "./actionProperties";
export {
@ -74,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";

View File

@ -1,18 +1,47 @@
import React from "react";
import {
Action,
ActionsManagerInterface,
UpdaterFn,
ActionName,
ActionResult,
PanelComponentProps,
ActionSource,
} from "./types";
import { ExcalidrawElement } from "../element/types";
import { AppClassProperties, AppState } from "../types";
import { MODES } from "../constants";
import { trackEvent } from "../analytics";
export class ActionManager implements ActionsManagerInterface {
actions = {} as ActionsManagerInterface["actions"];
const trackAction = (
action: Action,
source: ActionSource,
appState: Readonly<AppState>,
elements: readonly ExcalidrawElement[],
app: AppClassProperties,
value: any,
) => {
if (action.trackEvent) {
try {
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);
}
}
};
export class ActionManager {
actions = {} as Record<ActionName, Action>;
updater: (actionResult: ActionResult | Promise<ActionResult>) => void;
@ -65,9 +94,15 @@ export class ActionManager implements ActionsManagerInterface {
),
);
if (data.length === 0) {
if (data.length !== 1) {
if (data.length > 1) {
console.warn("Canceling as multiple actions match this shortcut", data);
}
return false;
}
const action = data[0];
const { viewModeEnabled } = this.getAppState();
if (viewModeEnabled) {
if (!Object.values(MODES).includes(data[0].name)) {
@ -75,27 +110,26 @@ export class ActionManager implements ActionsManagerInterface {
}
}
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,
),
);
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));
}
/**
@ -113,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(),

View File

@ -1,8 +1,10 @@
import { t } from "../i18n";
import { isDarwin } from "../keys";
import { getShortcutKey } from "../utils";
import { ActionName } from "./types";
export type ShortcutName =
export type ShortcutName = SubtypeOf<
ActionName,
| "cut"
| "copy"
| "paste"
@ -26,7 +28,9 @@ export type ShortcutName =
| "viewMode"
| "flipHorizontal"
| "flipVertical"
| "link";
| "hyperlink"
| "toggleLock"
>;
const shortcutMap: Record<ShortcutName, string[]> = {
cut: [getShortcutKey("CtrlOrCmd+X")],
@ -63,11 +67,12 @@ const shortcutMap: Record<ShortcutName, string[]> = {
flipHorizontal: [getShortcutKey("Shift+H")],
flipVertical: [getShortcutKey("Shift+V")],
viewMode: [getShortcutKey("Alt+R")],
link: [getShortcutKey("CtrlOrCmd+K")],
hyperlink: [getShortcutKey("CtrlOrCmd+K")],
toggleLock: [getShortcutKey("CtrlOrCmd+Shift+L")],
};
export const getShortcutFromShortcutName = (name: ShortcutName) => {
const shortcuts = shortcutMap[name];
// if multiple shortcuts availiable, take the first one
// if multiple shortcuts available, take the first one
return shortcuts && shortcuts.length > 0 ? shortcuts[0] : "";
};

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"
@ -82,6 +84,7 @@ export type ActionName =
| "zoomToSelection"
| "changeFontFamily"
| "changeTextAlign"
| "changeVerticalAlign"
| "toggleFullScreen"
| "toggleShortcuts"
| "group"
@ -105,14 +108,17 @@ export type ActionName =
| "increaseFontSize"
| "decreaseFontSize"
| "unbindText"
| "link";
| "hyperlink"
| "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 {
@ -136,12 +142,23 @@ export interface Action {
appState: AppState,
) => boolean;
checked?: (appState: Readonly<AppState>) => boolean;
}
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;
};
}

View File

@ -3,16 +3,20 @@ export const trackEvent =
process.env?.REACT_APP_GOOGLE_ANALYTICS_ID &&
typeof window !== "undefined" &&
window.gtag
? (category: string, name: string, label?: string, value?: number) => {
window.gtag("event", name, {
event_category: category,
event_label: label,
value,
});
? (category: string, action: string, label?: string, value?: number) => {
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, name: string, label?: string, value?: number) => {}
: (category: string, name: string, label?: string, value?: number) => {
? (category: string, action: string, label?: string, value?: number) => {}
: (category: string, action: string, label?: string, value?: number) => {
// Uncomment the next line to track locally
// console.info("Track Event", category, name, label, value);
// console.log("Track Event", { category, action, label, value });
};

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,10 +349,20 @@ 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>) => {
return _clearAppStateForStorage(appState, "server");
};
export const isEraserActive = ({
activeTool,
}: {
activeTool: AppState["activeTool"];
}) => activeTool.type === "eraser";

121
src/charts.test.ts Normal file
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]);
});
});
});

View File

@ -1,5 +1,10 @@
import colors from "./colors";
import { DEFAULT_FONT_FAMILY, DEFAULT_FONT_SIZE, ENV } from "./constants";
import {
DEFAULT_FONT_FAMILY,
DEFAULT_FONT_SIZE,
ENV,
VERTICAL_ALIGN,
} from "./constants";
import { newElement, newLinearElement, newTextElement } from "./element";
import { NonDeletedExcalidrawElement } from "./element/types";
import { randomId } from "./random";
@ -24,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) {
@ -66,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;
@ -103,7 +117,7 @@ const transposeCells = (cells: string[][]) => {
};
export const tryParseSpreadsheet = (text: string): ParseSpreadsheetResult => {
// Copy/paste from excel, spreadhseets, tsv, csv.
// Copy/paste from excel, spreadsheets, tsv, csv.
// For now we only accept 2 columns with an optional header
// Check for tab separated values
@ -161,7 +175,8 @@ const commonProps = {
strokeSharpness: "sharp",
strokeStyle: "solid",
strokeWidth: 1,
verticalAlign: "middle",
verticalAlign: VERTICAL_ALIGN.MIDDLE,
locked: false,
} as const;
const getChartDimentions = (spreadsheet: Spreadsheet) => {

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;
@ -124,7 +125,7 @@ const getSystemClipboard = async (
};
/**
* Attemps to parse clipboard. Prefers system clipboard.
* Attempts to parse clipboard. Prefers system clipboard.
*/
export const parseClipboard = async (
event: ClipboardEvent | null,
@ -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) => {

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,40 +15,59 @@ 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: ExcalidrawElement["type"];
activeTool: AppState["activeTool"]["type"];
}) => {
const targetElements = getTargetElements(
getNonDeletedElements(elements),
appState,
);
let isSingleElementBoundContainer = false;
if (
targetElements.length === 2 &&
(hasBoundTextElement(targetElements[0]) ||
hasBoundTextElement(targetElements[1]))
) {
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) {
@ -60,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")}
@ -84,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")}
@ -100,7 +119,11 @@ export const SelectedShapeActions = ({
</>
)}
{(canHaveArrowheads(elementType) ||
{targetElements.some(
(element) =>
hasBoundTextElement(element) || isBoundToContainer(element),
) && renderAction("changeVerticalAlign")}
{(canHaveArrowheads(activeTool) ||
targetElements.some((element) => canHaveArrowheads(element.type))) && (
<>{renderAction("changeArrowhead")}</>
)}
@ -117,7 +140,7 @@ export const SelectedShapeActions = ({
</div>
</fieldset>
{targetElements.length > 1 && (
{targetElements.length > 1 && !isSingleElementBoundContainer && (
<fieldset>
<legend>{t("labels.align")}</legend>
<div className="buttonList">
@ -154,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("link")}
{showLinkIcon && renderAction("hyperlink")}
</div>
</fieldset>
)}
@ -168,14 +191,16 @@ export const SelectedShapeActions = ({
export const ShapesSwitcher = ({
canvas,
elementType,
activeTool,
setAppState,
onImageAction,
appState,
}: {
canvas: HTMLCanvasElement | null;
elementType: ExcalidrawElement["type"];
activeTool: AppState["activeTool"];
setAppState: React.Component<any, AppState>["setState"];
onImageAction: (data: { pointerType: PointerType | null }) => void;
appState: AppState;
}) => (
<>
{SHAPES.map(({ value, icon, key }, index) => {
@ -190,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 });
}

File diff suppressed because it is too large Load Diff

View File

@ -12,5 +12,11 @@
cursor: pointer;
font-size: 0.8rem;
font-weight: 500;
&-img {
width: 100%;
height: 100%;
border-radius: 100%;
}
}
}

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>
);
};

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"
/>

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>

View File

@ -46,7 +46,7 @@
top: -11px;
}
.color-picker-content {
.color-picker-content--default {
padding: 0.5rem;
display: grid;
grid-template-columns: repeat(5, auto);
@ -59,6 +59,26 @@
}
}
.color-picker-content--canvas {
display: flex;
flex-direction: column;
padding: 0.25rem;
&-title {
color: $oc-gray-6;
font-size: 12px;
padding: 0 0.25rem;
}
&-colors {
padding: 0.5rem 0;
.color-picker-swatch {
margin: 0 0.25rem;
}
}
}
.color-picker-content .color-input-container {
grid-column: 1 / span 5;
}

View File

@ -7,6 +7,53 @@ import { isArrowKey, KEYS } from "../keys";
import { t, getLanguage } from "../i18n";
import { isWritableElement } from "../utils";
import colors from "../colors";
import { ExcalidrawElement } from "../element/types";
import { AppState } from "../types";
const MAX_CUSTOM_COLORS = 5;
const MAX_DEFAULT_COLORS = 15;
export const getCustomColors = (
elements: readonly ExcalidrawElement[],
type: "elementBackground" | "elementStroke",
) => {
const customColors: string[] = [];
const updatedElements = elements
.filter((element) => !element.isDeleted)
.sort((ele1, ele2) => ele2.updated - ele1.updated);
let index = 0;
const elementColorTypeMap = {
elementBackground: "backgroundColor",
elementStroke: "strokeColor",
};
const colorType = elementColorTypeMap[type] as
| "backgroundColor"
| "strokeColor";
while (
index < updatedElements.length &&
customColors.length < MAX_CUSTOM_COLORS
) {
const element = updatedElements[index];
if (
customColors.length < MAX_CUSTOM_COLORS &&
isCustomColor(element[colorType], type) &&
!customColors.includes(element[colorType])
) {
customColors.push(element[colorType]);
}
index++;
}
return customColors;
};
const isCustomColor = (
color: string,
type: "elementBackground" | "elementStroke",
) => {
return !colors[type].includes(color);
};
const isValidColor = (color: string) => {
const style = new Option().style;
@ -35,6 +82,7 @@ const keyBindings = [
["1", "2", "3", "4", "5"],
["q", "w", "e", "r", "t"],
["a", "s", "d", "f", "g"],
["z", "x", "c", "v", "b"],
].flat();
const Picker = ({
@ -45,6 +93,7 @@ const Picker = ({
label,
showInput = true,
type,
elements,
}: {
colors: string[];
color: string | null;
@ -53,12 +102,20 @@ const Picker = ({
label: string;
showInput: boolean;
type: "canvasBackground" | "elementBackground" | "elementStroke";
elements: readonly ExcalidrawElement[];
}) => {
const firstItem = React.useRef<HTMLButtonElement>();
const activeItem = React.useRef<HTMLButtonElement>();
const gallery = React.useRef<HTMLDivElement>();
const colorInput = React.useRef<HTMLInputElement>();
const [customColors] = React.useState(() => {
if (type === "canvasBackground") {
return [];
}
return getCustomColors(elements, type);
});
React.useEffect(() => {
// After the component is first mounted focus on first input
if (activeItem.current) {
@ -71,52 +128,119 @@ 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;
const index = Array.prototype.indexOf.call(
gallery!.current!.children,
let isCustom = false;
let index = Array.prototype.indexOf.call(
gallery.current!.querySelector(".color-picker-content--default")
?.children,
activeElement,
);
if (index !== -1) {
const length = gallery!.current!.children.length - (showInput ? 1 : 0);
if (index === -1) {
index = Array.prototype.indexOf.call(
gallery.current!.querySelector(".color-picker-content--canvas-colors")
?.children,
activeElement,
);
if (index !== -1) {
isCustom = true;
}
}
const parentElement = isCustom
? gallery.current?.querySelector(".color-picker-content--canvas-colors")
: gallery.current?.querySelector(".color-picker-content--default");
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
: event.key === (isRTL ? KEYS.ARROW_RIGHT : KEYS.ARROW_LEFT)
? (length + index - 1) % length
: event.key === KEYS.ARROW_DOWN
: !isCustom && event.key === KEYS.ARROW_DOWN
? (index + 5) % length
: event.key === KEYS.ARROW_UP
: !isCustom && event.key === KEYS.ARROW_UP
? (length + index - 5) % length
: index;
(gallery!.current!.children![nextIndex] as any).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());
(gallery!.current!.children![index] as any).focus();
const isCustom = index >= MAX_DEFAULT_COLORS;
const parentElement = isCustom
? gallery?.current?.querySelector(
".color-picker-content--canvas-colors",
)
: gallery?.current?.querySelector(".color-picker-content--default");
const actualIndex = isCustom ? index - MAX_DEFAULT_COLORS : index;
(
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) => {
return colors.map((_color, i) => {
const _colorWithoutHash = _color.replace("#", "");
const keyBinding = custom
? keyBindings[i + MAX_DEFAULT_COLORS]
: keyBindings[i];
const label = custom
? _colorWithoutHash
: t(`colors.${_colorWithoutHash}`);
return (
<button
className="color-picker-swatch"
onClick={(event) => {
(event.currentTarget as HTMLButtonElement).focus();
onChange(_color);
}}
title={`${label}${
!isTransparent(_color) ? ` (${_color})` : ""
}${keyBinding.toUpperCase()}`}
aria-label={label}
aria-keyshortcuts={keyBindings[i]}
style={{ color: _color }}
key={_color}
ref={(el) => {
if (!custom && el && i === 0) {
firstItem.current = el;
}
if (el && _color === color) {
activeItem.current = el;
}
}}
onFocus={() => {
onChange(_color);
}}
>
{isTransparent(_color) ? (
<div className="color-picker-transparent"></div>
) : undefined}
<span className="color-picker-keybinding">{keyBinding}</span>
</button>
);
});
};
return (
@ -136,43 +260,23 @@ const Picker = ({
gallery.current = el;
}
}}
tabIndex={0}
// to allow focusing by clicking but not by tabbing
tabIndex={-1}
>
{colors.map((_color, i) => {
const _colorWithoutHash = _color.replace("#", "");
return (
<button
className="color-picker-swatch"
onClick={(event) => {
(event.currentTarget as HTMLButtonElement).focus();
onChange(_color);
}}
title={`${t(`colors.${_colorWithoutHash}`)}${
!isTransparent(_color) ? ` (${_color})` : ""
}${keyBindings[i].toUpperCase()}`}
aria-label={t(`colors.${_colorWithoutHash}`)}
aria-keyshortcuts={keyBindings[i]}
style={{ color: _color }}
key={_color}
ref={(el) => {
if (el && i === 0) {
firstItem.current = el;
}
if (el && _color === color) {
activeItem.current = el;
}
}}
onFocus={() => {
onChange(_color);
}}
>
{isTransparent(_color) ? (
<div className="color-picker-transparent"></div>
) : undefined}
<span className="color-picker-keybinding">{keyBindings[i]}</span>
</button>
);
})}
<div className="color-picker-content--default">
{renderColors(colors)}
</div>
{!!customColors.length && (
<div className="color-picker-content--canvas">
<span className="color-picker-content--canvas-title">
{t("labels.canvasColors")}
</span>
<div className="color-picker-content--canvas-colors">
{renderColors(customColors, true)}
</div>
</div>
)}
{showInput && (
<ColorInput
color={color}
@ -246,6 +350,8 @@ export const ColorPicker = ({
label,
isActive,
setActive,
elements,
appState,
}: {
type: "canvasBackground" | "elementBackground" | "elementStroke";
color: string | null;
@ -253,6 +359,8 @@ export const ColorPicker = ({
label: string;
isActive: boolean;
setActive: (active: boolean) => void;
elements: readonly ExcalidrawElement[];
appState: AppState;
}) => {
const pickerButton = React.useRef<HTMLButtonElement>(null);
@ -294,6 +402,7 @@ export const ColorPicker = ({
label={label}
showInput={false}
type={type}
elements={elements}
/>
</Popover>
) : null}

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">

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>

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")]}

View File

@ -11,6 +11,7 @@ import {
isTextElement,
} from "../element/typeChecks";
import { getShortcutKey } from "../utils";
import { isEraserActive } from "../appState";
interface HintViewerProps {
appState: AppState;
@ -19,25 +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 (elementType === "arrow" || elementType === "line") {
if (appState.isLibraryOpen) {
return null;
}
if (isEraserActive(appState)) {
return t("hints.eraserRevert");
}
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");
}
@ -69,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 &&

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 && (

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;

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 && (

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;
}

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,
@ -238,7 +239,7 @@ const LayerUI = ({
className={CLASSES.SHAPE_ACTIONS_MENU}
padding={2}
style={{
// we want to make sure this doesn't overflow so substracting 200
// we want to make sure this doesn't overflow so subtracting 200
// which is approximately height of zoom footer and top left menu items with some buffer
// if active file name is displayed, subtracting 248 to account for its height
maxHeight: `${appState.height - (appState.fileHandle ? 248 : 200)}px`,
@ -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,16 +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>
</>
)}
{!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>
@ -466,7 +480,7 @@ const LayerUI = ({
const dialogs = (
<>
{appState.isLoading && <LoadingMessage />}
{appState.isLoading && <LoadingMessage delay={250} />}
{appState.errorMessage && (
<ErrorDialog
message={appState.errorMessage}
@ -495,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
@ -507,7 +538,7 @@ const LayerUI = ({
renderImageExportDialog={renderImageExportDialog}
setAppState={setAppState}
onCollabButtonClick={onCollabButtonClick}
onLockToggle={onLockToggle}
onLockToggle={() => onLockToggle()}
onPenModeToggle={onPenModeToggle}
canvas={canvas}
isCollaborating={isCollaborating}
@ -516,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>
</>
);
};

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"))}

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;
}
}
}

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>
);
};

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;

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>
);
};

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;

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}

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>
);
};

View File

@ -8,7 +8,7 @@ import { NonDeletedExcalidrawElement } from "../element/types";
import { FixedSideContainer } from "./FixedSideContainer";
import { Island } from "./Island";
import { HintViewer } from "./HintViewer";
import { calculateScrollCenter } from "../scene";
import { calculateScrollCenter, getSelectedElements } from "../scene";
import { SelectedShapeActions, ShapesSwitcher } from "./Actions";
import { Section } from "./Section";
import CollabButton from "./CollabButton";
@ -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
@ -113,6 +119,12 @@ export const MobileMenu = ({
};
const renderAppToolbar = () => {
// Render eraser conditionally in mobile
const showEraser =
!appState.viewModeEnabled &&
!appState.editingElement &&
getSelectedElements(elements, appState).length === 0;
if (viewModeEnabled) {
return (
<div className="App-toolbar-content">
@ -120,12 +132,16 @@ export const MobileMenu = ({
</div>
);
}
return (
<div className="App-toolbar-content">
{actionManager.renderAction("toggleCanvasMenu")}
{actionManager.renderAction("toggleEditMenu")}
{actionManager.renderAction("undo")}
{actionManager.renderAction("redo")}
{showEraser && actionManager.renderAction("eraser")}
{actionManager.renderAction(
appState.multiElement ? "finalize" : "duplicateSelection",
)}
@ -170,6 +186,7 @@ export const MobileMenu = ({
return (
<>
{!viewModeEnabled && renderToolbar()}
{renderStats()}
<div
className="App-bottom-bar"
style={{
@ -188,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>
@ -215,7 +223,7 @@ export const MobileMenu = ({
appState={appState}
elements={elements}
renderAction={actionManager.renderAction}
elementType={appState.elementType}
activeTool={appState.activeTool.type}
/>
</Section>
) : null}

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 =

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) {

View File

@ -82,6 +82,10 @@
}
}
&-warning {
color: $oc-red-6;
}
&-note {
padding: 1em;
font-style: italic;

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>

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);
}
}
}

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>
);
};

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;

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")}

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}

View File

@ -7,6 +7,7 @@
right: 12px;
font-size: 12px;
z-index: 10;
pointer-events: all;
h3 {
margin: 0 24px 8px 0;

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}>

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(() => {

View File

@ -155,7 +155,7 @@
}
width: 2rem;
height: 2em;
height: 2rem;
}
}

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 {

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;

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>
);
};

View File

@ -885,6 +885,40 @@ export const TextAlignRightIcon = React.memo(({ theme }: { theme: Theme }) =>
),
);
export const TextAlignTopIcon = React.memo(({ theme }: { theme: Theme }) =>
createIcon(
<path
d="m16,132l416,0c8.837,0 16,-7.163 16,-16l0,-40c0,-8.837 -7.163,-16 -16,-16l-416,0c-8.837,0 -16,7.163 -16,16l0,40c0,8.837 7.163,16 16,16zm0,160l416,0c8.837,0 16,-7.163 16,-16l0,-40c0,-8.837 -7.163,-16 -16,-16l-416,0c-8.837,0 -16,7.163 -16,16l0,40c0,8.837 7.163,16 16,16z"
fill={iconFillColor(theme)}
strokeLinecap="round"
/>,
{ width: 448, height: 512 },
),
);
export const TextAlignBottomIcon = React.memo(({ theme }: { theme: Theme }) =>
createIcon(
<path
d="M16,292L432,292C440.837,292 448,284.837 448,276L448,236C448,227.163 440.837,220 432,220L16,220C7.163,220 0,227.163 0,236L0,276C0,284.837 7.163,292 16,292ZM16,452L432,452C440.837,452 448,444.837 448,436L448,396C448,387.163 440.837,380 432,380L16,380C7.163,380 0,387.163 0,396L0,436C0,444.837 7.163,452 16,452Z"
fill={iconFillColor(theme)}
strokeLinecap="round"
/>,
{ width: 448, height: 512 },
),
);
export const TextAlignMiddleIcon = React.memo(({ theme }: { theme: Theme }) =>
createIcon(
<path
transform="matrix(1,0,0,1,0,80)"
d="M16,132L432,132C440.837,132 448,124.837 448,116L448,76C448,67.163 440.837,60 432,60L16,60C7.163,60 0,67.163 0,76L0,116C0,124.837 7.163,132 16,132ZM16,292L432,292C440.837,292 448,284.837 448,276L448,236C448,227.163 440.837,220 432,220L16,220C7.163,220 0,227.163 0,236L0,276C0,284.837 7.163,292 16,292Z"
fill={iconFillColor(theme)}
strokeLinecap="round"
/>,
{ width: 448, height: 512 },
),
);
export const publishIcon = createIcon(
<path
d="M537.6 226.6c4.1-10.7 6.4-22.4 6.4-34.6 0-53-43-96-96-96-19.7 0-38.1 6-53.3 16.2C367 64.2 315.3 32 256 32c-88.4 0-160 71.6-160 160 0 2.7.1 5.4.2 8.1C40.2 219.8 0 273.2 0 336c0 79.5 64.5 144 144 144h368c70.7 0 128-57.3 128-128 0-61.9-44-113.6-102.4-125.4zM393.4 288H328v112c0 8.8-7.2 16-16 16h-48c-8.8 0-16-7.2-16-16V288h-65.4c-14.3 0-21.4-17.2-11.3-27.3l105.4-105.4c6.2-6.2 16.4-6.2 22.6 0l105.4 105.4c10.1 10.1 2.9 27.3-11.3 27.3z"
@ -900,3 +934,7 @@ export const editIcon = createIcon(
></path>,
{ width: 640, height: 512 },
);
export const eraser = createIcon(
<path d="M480 416C497.7 416 512 430.3 512 448C512 465.7 497.7 480 480 480H150.6C133.7 480 117.4 473.3 105.4 461.3L25.37 381.3C.3786 356.3 .3786 315.7 25.37 290.7L258.7 57.37C283.7 32.38 324.3 32.38 349.3 57.37L486.6 194.7C511.6 219.7 511.6 260.3 486.6 285.3L355.9 416H480zM265.4 416L332.7 348.7L195.3 211.3L70.63 336L150.6 416L265.4 416z" />,
);

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;
@ -182,3 +195,15 @@ export const VERSIONS = {
} as const;
export const BOUND_TEXT_PADDING = 5;
export const VERTICAL_ALIGN = {
TOP: "top",
MIDDLE: "middle",
BOTTOM: "bottom",
};
export const ELEMENT_READY_TO_ERASE_OPACITY = 20;
export const COOKIES = {
AUTH_STATE_COOKIE: "excplus-auth",
} as const;

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;
}
}

View File

@ -290,6 +290,16 @@
width: 100%;
box-sizing: border-box;
.eraser {
&.ToolIcon:hover {
--icon-fill-color: #fff;
--keybinding-color: #fff;
}
&.active {
background-color: var(--color-primary);
}
}
}
.App-toolbar-content {
@ -340,7 +350,6 @@
align-items: flex-start;
cursor: default;
pointer-events: none !important;
z-index: 100;
:root[dir="ltr"] & {
left: 0.25rem;
@ -381,6 +390,7 @@
.App-menu__left {
overflow-y: auto;
box-shadow: var(--shadow-island);
}
.dropdown-select {
@ -439,6 +449,7 @@
bottom: 30px;
transform: translateX(-50%);
padding: 10px 20px;
pointer-events: all;
}
.help-icon {
@ -467,7 +478,17 @@
font-family: var(--ui-font);
}
.undo-redo-buttons {
.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;
grid-auto-flow: column;
gap: 0.4em;
@ -547,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 {

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);
}

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);
});
};

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

View File

@ -13,7 +13,9 @@ type FILE_EXTENSION =
| "gif"
| "jpg"
| "png"
| "excalidraw.png"
| "svg"
| "excalidraw.svg"
| "json"
| "excalidraw"
| "excalidrawlib";

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");
}

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");
}
};

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);
};

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]);
};

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,9 +34,9 @@ type RestoredAppState = Omit<
"offsetTop" | "offsetLeft" | "width" | "height"
>;
export const AllowedExcalidrawElementTypes: Record<
ExcalidrawElement["type"],
true
export const AllowedExcalidrawActiveTools: Record<
AppState["activeTool"]["type"],
boolean
> = {
selection: true,
text: true,
@ -43,6 +47,8 @@ export const AllowedExcalidrawElementTypes: Record<
image: true,
arrow: true,
freedraw: true,
eraser: false,
custom: true,
};
export type RestoredDataState = {
@ -106,6 +112,7 @@ const restoreElementWithProperties = <
: element.boundElements ?? [],
updated: element.updated ?? getUpdatedTimestamp(),
link: element.link ?? null,
locked: element.locked ?? false,
};
return {
@ -192,6 +199,7 @@ const restoreElement = (
y,
});
}
// generic elements
case "ellipse":
return restoreElementWithProperties(element, {});
@ -234,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,
@ -254,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"
@ -264,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
@ -285,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;

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;
}

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