Compare commits

..

138 Commits

Author SHA1 Message Date
b20fe944e7 add max shape count 2021-02-05 10:27:06 -08:00
1156ef6b96 pixelated images initial experiment 2021-02-04 23:27:35 -08:00
dc25fe06d0 chore: Update translations from Crowdin (#2906) 2021-02-04 20:18:57 +02:00
1e17c1967b fix: toolbar unnecessarily eats too much width (#2924) 2021-02-04 16:52:16 +01:00
23a8891e0e fix: mistakenly hardcoding scale (#2925) 2021-02-04 16:52:00 +01:00
6c81a32d62 fix: text editor not visible in dark mode (#2920) 2021-02-04 16:21:48 +01:00
f0f5430313 feat: support supplying custom scale when exporting canvas (#2904) 2021-02-04 14:54:08 +01:00
e18e945cd3 fix: incorrect z-index of text editor (#2914) 2021-02-04 14:54:00 +01:00
bd0c6e63ff feat: Show version in the stats dialog (#2908)
Co-authored-by: dwelle <luzar.david@gmail.com>
2021-02-04 13:47:46 +01:00
dbae33e4f8 fix: make scrollbars draggable when offsets are set (#2916)
Co-authored-by: dwelle <luzar.david@gmail.com>
2021-02-04 13:07:29 +01:00
0f4a053759 chore: Run actions on pull requests as well (#2917)
* chore: Run actions on pull requests as well

* Update cancel.yml

* Update lint.yml

* Update test.yml

* Update cancel.yml
2021-02-04 13:01:20 +01:00
1837147c55 feat: Add idle detection to collaboration feature (#2877)
* Start idle detection implementation

* First working version

* Add screen state

* Add type safety

* Better rendering, enum types, localization

* Add origin trial token

* Fix

* Refactor idle detection to no longer use IdleDetector API

* Cleanup some leftovers

* Fix

* Apply suggestions from code review

* Three state: active 🟢, idle 💤, away ️

* Address feedback from code review
Thanks, @lipis

* Deal with unmount

Co-authored-by: Panayiotis Lipiridis <lipiridis@gmail.com>
2021-02-04 11:55:43 +01:00
15f698dc21 fix: pointer-events being disabled on free-draw (#2912) 2021-02-03 20:43:16 +01:00
ce507b0a0b feat: don't store to LS during collab (#2909) 2021-02-03 19:14:26 +01:00
02598c6163 chore: Update translations from Crowdin (#2898) 2021-02-02 22:06:44 +00:00
3e2890bd21 fix: track zenmode and grid mode usage (#2900) 2021-02-02 20:57:22 +05:30
210649f383 refactor: Use the latest vercel configuration instead of now (#2893)
* refactor: Use the latest vercel configuration instead of now

* Remove redict
2021-02-02 14:38:33 +01:00
f8087e01c8 chore: Update translations from Crowdin (#2894) 2021-02-02 09:41:57 +00:00
e2522645f7 Update i18n.ts 2021-02-02 11:35:52 +02:00
d46a9166be Update locales-coverage-description.js 2021-02-02 11:35:00 +02:00
675da16ca4 feat: add view mode in Excalidraw (#2840)
Co-authored-by: Lipis <lipiridis@gmail.com>
2021-02-01 21:56:42 +01:00
2b1b62d8f2 chore(deps): bump @sentry/integrations from 6.0.1 to 6.0.3 (#2889)
* chore(deps): bump @sentry/integrations from 6.0.1 to 6.0.3

Bumps [@sentry/integrations](https://github.com/getsentry/sentry-javascript) from 6.0.1 to 6.0.3.
- [Release notes](https://github.com/getsentry/sentry-javascript/releases)
- [Changelog](https://github.com/getsentry/sentry-javascript/blob/master/CHANGELOG.md)
- [Commits](https://github.com/getsentry/sentry-javascript/compare/6.0.1...6.0.3)

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

* Update both

Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Panayiotis Lipiridis <lipiridis@gmail.com>
2021-02-01 12:59:03 +00:00
627c56ef1c fix: disable UI pointer-events on canvas drag (#2856) 2021-02-01 14:55:38 +02:00
0d0fe32485 chore: Update translations from Crowdin (#2875) 2021-02-01 13:23:08 +02:00
6576b9442e chore(deps): bump firebase from 8.2.4 to 8.2.5 (#2888)
Bumps [firebase](https://github.com/firebase/firebase-js-sdk) from 8.2.4 to 8.2.5.
- [Release notes](https://github.com/firebase/firebase-js-sdk/releases)
- [Changelog](https://github.com/firebase/firebase-js-sdk/blob/master/CHANGELOG.md)
- [Commits](https://github.com/firebase/firebase-js-sdk/compare/firebase@8.2.4...firebase@8.2.5)

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

Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2021-01-31 20:58:05 +02:00
ee4cb2d4a9 chore(deps-dev): bump webpack from 5.17.0 to 5.19.0 in /src/packages/utils (#2887)
Bumps [webpack](https://github.com/webpack/webpack) from 5.17.0 to 5.19.0.
- [Release notes](https://github.com/webpack/webpack/releases)
- [Commits](https://github.com/webpack/webpack/compare/v5.17.0...v5.19.0)

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

Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2021-01-31 20:57:53 +02:00
cdcc91faa5 chore(deps-dev): bump mini-css-extract-plugin from 1.3.4 to 1.3.5 in /src/packages/excalidraw (#2885)
Bumps [mini-css-extract-plugin](https://github.com/webpack-contrib/mini-css-extract-plugin) from 1.3.4 to 1.3.5.
- [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/v1.3.4...v1.3.5)

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

Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2021-01-31 20:57:36 +02:00
9093341dc1 chore(deps-dev): bump webpack from 5.17.0 to 5.19.0 in /src/packages/excalidraw (#2886)
Bumps [webpack](https://github.com/webpack/webpack) from 5.17.0 to 5.19.0.
- [Release notes](https://github.com/webpack/webpack/releases)
- [Commits](https://github.com/webpack/webpack/compare/v5.17.0...v5.19.0)

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

Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2021-01-31 20:57:19 +02:00
1973ae9444 fix: stop flooring scroll positions (#2883) 2021-01-31 10:47:43 +01:00
10cd6a24b0 feat: increase max zoom (#2881) 2021-01-30 18:03:23 +01:00
6abf4f52ff refactor: remove duplicate key handling (#2878) 2021-01-30 10:30:00 +01:00
4624ec2bd6 fix: apply initialData appState for zenmode and grid stats and refactor check param for actions (#2871)
* fix: pass default value for grid mode / zen mode so it sets the value from initialData appState

fixes #2870

* change checked from boolean to be a function which recieves appState and returns boolean

* fix

* use clsx

Co-authored-by: dwelle <luzar.david@gmail.com>
2021-01-29 23:38:37 +05:30
e8685c5236 feat: Remove copy & paste from context menu on desktop (#2872)
* Remove copy & paste from context menu on desktop

* fix build

* Make requested changes

* More changes

* make into function

* update changelog

* fix tests

Co-authored-by: dwelle <luzar.david@gmail.com>
2021-01-28 22:02:00 +01:00
6e9df2bae7 fix(app.tsx): show correct state of Nerd stats in context menu when nerd stats dialog closed (#2874)
fixes #2873
2021-01-29 02:21:10 +05:30
ed0bec41dc chore: Update workflows to use the latest Node (#2863) 2021-01-28 19:20:48 +05:30
d4e12a2962 reuse scss variables in js for SSOT (#2867) 2021-01-28 17:28:35 +05:30
978e85a33b feat: Add separators on context menu (#2659)
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: dwelle <luzar.david@gmail.com>
Co-authored-by: Lipis <lipiridis@gmail.com>
2021-01-27 20:11:17 +01:00
b5e26ba81f refactor: Rename browser-nativefs to browser-fs-access (#2862) 2021-01-27 20:47:55 +02:00
4392a4644a chore: Update translations from Crowdin (#2861) 2021-01-27 14:22:37 +02:00
b1c8c538ee fix: Change the timeout for reporting the version to 30s (#2858) 2021-01-26 22:22:41 +02:00
f6492895df chore: Update translations from Crowdin (#2839)
Co-authored-by: Excalidraw Bot <77840495+excalidrawbot@users.noreply.github.com>
2021-01-26 14:48:36 +02:00
e75f5f20e7 fix: remote pointers not accounting for offset (#2855) 2021-01-25 10:47:48 +01:00
0a0be839b9 refactor: rewrite collabWrapper to remove TDZs and simplify (#2834) 2021-01-25 10:47:35 +01:00
03f6d9c783 chore(deps-dev): bump webpack-cli from 4.3.1 to 4.4.0 in /src/packages/excalidraw (#2847)
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2021-01-24 09:00:02 +00:00
df745c1098 chore(deps-dev): bump webpack from 5.15.0 to 5.17.0 in /src/packages/excalidraw (#2844)
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2021-01-24 08:59:37 +00:00
b888f7e7ba chore(deps-dev): bump webpack-bundle-analyzer from 4.3.0 to 4.4.0 in /src/packages/utils (#2848)
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2021-01-24 08:58:30 +00:00
3010253f72 chore(deps): bump @sentry/browser from 5.30.0 to 6.0.1 (#2852)
Bumps [@sentry/browser](https://github.com/getsentry/sentry-javascript) from 5.30.0 to 6.0.1.
- [Release notes](https://github.com/getsentry/sentry-javascript/releases)
- [Changelog](https://github.com/getsentry/sentry-javascript/blob/master/CHANGELOG.md)
- [Commits](https://github.com/getsentry/sentry-javascript/compare/5.30.0...6.0.1)

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

Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2021-01-24 08:57:12 +00:00
0815f3282e chore(deps-dev): bump webpack-cli from 4.3.1 to 4.4.0 in /src/packages/utils (#2849)
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2021-01-24 10:55:38 +02:00
9dd58288de chore(deps-dev): bump webpack-bundle-analyzer from 4.3.0 to 4.4.0 in /src/packages/excalidraw (#2845)
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2021-01-24 10:54:48 +02:00
c2e2bb495c chore(deps-dev): bump webpack from 5.15.0 to 5.17.0 in /src/packages/utils (#2846)
Bumps [webpack](https://github.com/webpack/webpack) from 5.15.0 to 5.17.0.
- [Release notes](https://github.com/webpack/webpack/releases)
- [Commits](https://github.com/webpack/webpack/compare/v5.15.0...v5.17.0)

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

Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2021-01-24 10:54:36 +02:00
a31cfe1f07 chore(deps-dev): bump firebase-tools from 9.2.1 to 9.2.2 (#2850)
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2021-01-24 10:53:35 +02:00
d3367bfe12 chore(deps-dev): bump eslint-config-prettier from 7.1.0 to 7.2.0 (#2851)
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2021-01-24 10:53:23 +02:00
70791dfa7b chore(deps): bump firebase from 8.2.3 to 8.2.4 (#2853)
Bumps [firebase](https://github.com/firebase/firebase-js-sdk) from 8.2.3 to 8.2.4.
- [Release notes](https://github.com/firebase/firebase-js-sdk/releases)
- [Changelog](https://github.com/firebase/firebase-js-sdk/blob/master/CHANGELOG.md)
- [Commits](https://github.com/firebase/firebase-js-sdk/compare/firebase@8.2.3...firebase@8.2.4)

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

Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2021-01-24 10:52:52 +02:00
d63ec678db chore(deps): bump @sentry/integrations from 5.30.0 to 6.0.1 (#2854)
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2021-01-24 10:52:41 +02:00
26acebcdb6 chore: Update translations from Crowdin (#2817) 2021-01-22 18:06:21 +02:00
9dc930b447 feat: add ctrl-y to redo (#2831) 2021-01-21 16:21:54 +01:00
6e767fc949 fix(actionmenu): toggle help dialog when "shift+?" is pressed (#2828) 2021-01-21 13:13:47 +02:00
49bd683401 fix(analytics.ts): add safe check for process so Excalidraw can be loaded via script (#2824)
* fix(analytics.ts): add safe check for process so Excalidraw can be loaded via script

* fix
2021-01-20 21:27:33 +05:30
3922ee8c11 docs: update changelog and readme and release 0.2.1 (#2821) 2021-01-19 17:38:04 +01:00
8c2bc94336 build(webpack): bundle css files with js (#2819) 2021-01-19 15:29:05 +01:00
fb4d97ef78 fix: Use VERCEL_GIT_COMMIT_SHA env variable (#2816) 2021-01-18 19:22:02 +02:00
a8e28afbad chore: Update translations from Crowdin (#2790)
Co-authored-by: Kostas Bariotis <konmpar@gmail.com>
2021-01-18 17:00:42 +02:00
68347ba476 docs(changelog): add about extra api's in changelog and separate package and library updates (#2813) 2021-01-17 18:48:19 +01:00
ce2c341910 feat: Change shortcuts menu to help menu (#2812)
Co-authored-by: Panayiotis Lipiridis <lipiridis@gmail.com>
2021-01-17 18:46:23 +02:00
07cc858926 chore(deps-dev): bump husky from 4.3.7 to 4.3.8 (#2804)
Bumps [husky](https://github.com/typicode/husky) from 4.3.7 to 4.3.8.
- [Release notes](https://github.com/typicode/husky/releases)
- [Commits](https://github.com/typicode/husky/compare/v4.3.7...v4.3.8)

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

Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2021-01-17 11:33:24 +02:00
a7a2936f7c chore(deps): bump @sentry/integrations from 5.29.2 to 5.30.0 (#2807)
Bumps [@sentry/integrations](https://github.com/getsentry/sentry-javascript) from 5.29.2 to 5.30.0.
- [Release notes](https://github.com/getsentry/sentry-javascript/releases)
- [Changelog](https://github.com/getsentry/sentry-javascript/blob/master/CHANGELOG.md)
- [Commits](https://github.com/getsentry/sentry-javascript/compare/5.29.2...5.30.0)

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

Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2021-01-17 11:33:15 +02:00
e72ff6be66 chore(deps-dev): bump webpack from 5.12.3 to 5.15.0 in /src/packages/utils (#2803)
Bumps [webpack](https://github.com/webpack/webpack) from 5.12.3 to 5.15.0.
- [Release notes](https://github.com/webpack/webpack/releases)
- [Commits](https://github.com/webpack/webpack/compare/v5.12.3...v5.15.0)

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

Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2021-01-17 11:33:02 +02:00
0ea29675df chore(deps-dev): bump webpack from 5.12.3 to 5.15.0 in /src/packages/excalidraw (#2800)
Bumps [webpack](https://github.com/webpack/webpack) from 5.12.3 to 5.15.0.
- [Release notes](https://github.com/webpack/webpack/releases)
- [Commits](https://github.com/webpack/webpack/compare/v5.12.3...v5.15.0)

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

Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2021-01-17 11:31:59 +02:00
da2ad4f37c chore(deps-dev): bump sass-loader from 10.1.0 to 10.1.1 in /src/packages/excalidraw (#2801)
Bumps [sass-loader](https://github.com/webpack-contrib/sass-loader) from 10.1.0 to 10.1.1.
- [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/v10.1.0...v10.1.1)

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

Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2021-01-17 11:31:45 +02:00
33a7cf0d3f chore(deps): bump firebase from 8.2.2 to 8.2.3 (#2810)
Bumps [firebase](https://github.com/firebase/firebase-js-sdk) from 8.2.2 to 8.2.3.
- [Release notes](https://github.com/firebase/firebase-js-sdk/releases)
- [Changelog](https://github.com/firebase/firebase-js-sdk/blob/master/CHANGELOG.md)
- [Commits](https://github.com/firebase/firebase-js-sdk/commits/firebase@8.2.3)

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

Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2021-01-17 11:31:17 +02:00
f8e890df7b chore(deps-dev): bump mini-css-extract-plugin from 1.3.3 to 1.3.4 in /src/packages/excalidraw (#2802)
Bumps [mini-css-extract-plugin](https://github.com/webpack-contrib/mini-css-extract-plugin) from 1.3.3 to 1.3.4.
- [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/v1.3.3...v1.3.4)

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

Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2021-01-17 11:28:23 +02:00
12337a54a3 chore(deps): bump @testing-library/jest-dom from 5.11.8 to 5.11.9 (#2805)
Bumps [@testing-library/jest-dom](https://github.com/testing-library/jest-dom) from 5.11.8 to 5.11.9.
- [Release notes](https://github.com/testing-library/jest-dom/releases)
- [Changelog](https://github.com/testing-library/jest-dom/blob/master/CHANGELOG.md)
- [Commits](https://github.com/testing-library/jest-dom/compare/v5.11.8...v5.11.9)

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

Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2021-01-17 11:27:38 +02:00
7a9ed2cfa1 chore(deps-dev): bump firebase-tools from 9.1.2 to 9.2.1 (#2808)
Bumps [firebase-tools](https://github.com/firebase/firebase-tools) from 9.1.2 to 9.2.1.
- [Release notes](https://github.com/firebase/firebase-tools/releases)
- [Commits](https://github.com/firebase/firebase-tools/compare/v9.1.2...v9.2.1)

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

Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2021-01-17 11:27:12 +02:00
553bf2956f chore(deps): bump @sentry/browser from 5.29.2 to 5.30.0 (#2809)
Bumps [@sentry/browser](https://github.com/getsentry/sentry-javascript) from 5.29.2 to 5.30.0.
- [Release notes](https://github.com/getsentry/sentry-javascript/releases)
- [Changelog](https://github.com/getsentry/sentry-javascript/blob/master/CHANGELOG.md)
- [Commits](https://github.com/getsentry/sentry-javascript/compare/5.29.2...5.30.0)

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

Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2021-01-17 11:27:01 +02:00
1e16a6e5bd chore(deps): bump @types/socket.io-client from 1.4.34 to 1.4.35 (#2811)
Bumps [@types/socket.io-client](https://github.com/DefinitelyTyped/DefinitelyTyped/tree/HEAD/types/socket.io-client) from 1.4.34 to 1.4.35.
- [Release notes](https://github.com/DefinitelyTyped/DefinitelyTyped/releases)
- [Commits](https://github.com/DefinitelyTyped/DefinitelyTyped/commits/HEAD/types/socket.io-client)

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

Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2021-01-17 11:26:44 +02:00
e26f374ca6 improvement: Enhance resize for non generic elements (#2720) 2021-01-16 19:49:13 +01:00
c799b28a0e fix: Count all versions (#2798) 2021-01-16 18:59:26 +01:00
ee703206b0 docs: update changelog and release 0.2.0 (#2725)
* docs(changelog): update changelog and release 0.2.0

* fix

* Add missing API's

* update

* fix

* fix

* Apply suggestions from code review

* remove

* fix

* fix
2021-01-16 22:20:46 +05:30
543c624405 feat: Add toast (#2772)
Co-authored-by: Lipis <lipiridis@gmail.com>
Co-authored-by: dwelle <luzar.david@gmail.com>
2021-01-15 16:02:46 +01:00
0bf6830373 docs: Update readme with documentation (#2788) 2021-01-14 18:31:52 +02:00
af79461f41 fix: allow text-selecting in dialogs & reset cursor (#2783)
Co-authored-by: dwelle <luzar.david@gmail.com>
2021-01-14 14:42:15 +01:00
fd699c0447 chore: Update translations from Crowdin (#2742)
Co-authored-by: Kostas Bariotis <konmpar@gmail.com>
2021-01-14 12:09:05 +02:00
6a16caf13c fix: broken Individuals link (#2782) 2021-01-14 09:24:50 +02:00
511eb62228 refactor: Converting span to kbd tag (#2774) 2021-01-14 09:24:32 +02:00
04c46fc01a fix: don't render due to zoom after unmount (#2779)
* fix: don't render due to zoom after unmount

* update changelog

* remove unnecessary flush
2021-01-13 17:42:42 +01:00
49e792649d fix: Track the chart type correctly (#2773) 2021-01-13 15:23:14 +02:00
4e1caf2417 chore(deps-dev): bump terser-webpack-plugin in /src/packages/excalidraw (#2750)
Bumps [terser-webpack-plugin](https://github.com/webpack-contrib/terser-webpack-plugin) from 5.0.3 to 5.1.1.
- [Release notes](https://github.com/webpack-contrib/terser-webpack-plugin/releases)
- [Changelog](https://github.com/webpack-contrib/terser-webpack-plugin/blob/master/CHANGELOG.md)
- [Commits](https://github.com/webpack-contrib/terser-webpack-plugin/compare/v5.0.3...v5.1.1)

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

Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2021-01-13 00:44:32 +05:30
034f2e470b chore(deps-dev): bump webpack in /src/packages/utils (#2768)
Bumps [webpack](https://github.com/webpack/webpack) from 5.11.1 to 5.12.3.
- [Release notes](https://github.com/webpack/webpack/releases)
- [Commits](https://github.com/webpack/webpack/compare/v5.11.1...v5.12.3)

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

Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2021-01-13 00:44:11 +05:30
adcd28f348 fix: delay version logging & prevent duplicates (#2770) 2021-01-12 18:47:31 +02:00
62f1ed74f1 chore(deps-dev): bump webpack in /src/packages/excalidraw (#2769)
Bumps [webpack](https://github.com/webpack/webpack) from 5.11.1 to 5.12.3.
- [Release notes](https://github.com/webpack/webpack/releases)
- [Commits](https://github.com/webpack/webpack/compare/v5.11.1...v5.12.3)

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

Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2021-01-12 02:03:31 +05:30
8fa4273969 chore(deps-dev): bump ts-loader in /src/packages/excalidraw (#2749)
Bumps [ts-loader](https://github.com/TypeStrong/ts-loader) from 8.0.13 to 8.0.14.
- [Release notes](https://github.com/TypeStrong/ts-loader/releases)
- [Changelog](https://github.com/TypeStrong/ts-loader/blob/master/CHANGELOG.md)
- [Commits](https://github.com/TypeStrong/ts-loader/compare/v8.0.13...v8.0.14)

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

Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2021-01-12 01:43:13 +05:30
672068ce7e chore(deps-dev): bump ts-loader in /src/packages/utils (#2753)
Bumps [ts-loader](https://github.com/TypeStrong/ts-loader) from 8.0.13 to 8.0.14.
- [Release notes](https://github.com/TypeStrong/ts-loader/releases)
- [Changelog](https://github.com/TypeStrong/ts-loader/blob/master/CHANGELOG.md)
- [Commits](https://github.com/TypeStrong/ts-loader/compare/v8.0.13...v8.0.14)

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

Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2021-01-12 01:42:42 +05:30
f1fc308a5d chore(deps): bump nanoid from 2.1.11 to 3.1.20 (#2581)
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Panayiotis Lipiridis <lipiridis@gmail.com>
2021-01-11 12:47:10 +02:00
001880ba88 feat: Track current version (#2731) 2021-01-10 20:48:12 +02:00
3a130cb102 chore(actions): Use cancel workflow action (#2763) 2021-01-10 20:09:44 +02:00
e682cf9bf6 chore(deps): bump @testing-library/react from 11.2.2 to 11.2.3 (#2755)
Bumps [@testing-library/react](https://github.com/testing-library/react-testing-library) from 11.2.2 to 11.2.3.
- [Release notes](https://github.com/testing-library/react-testing-library/releases)
- [Changelog](https://github.com/testing-library/react-testing-library/blob/master/CHANGELOG.md)
- [Commits](https://github.com/testing-library/react-testing-library/compare/v11.2.2...v11.2.3)

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

Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2021-01-10 12:39:44 +02:00
2a169924d0 chore(deps-dev): bump firebase-tools from 9.1.0 to 9.1.2 (#2761)
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2021-01-10 12:39:29 +02:00
eb6e75b806 chore(deps-dev): bump eslint-plugin-prettier from 3.3.0 to 3.3.1 (#2754)
Bumps [eslint-plugin-prettier](https://github.com/prettier/eslint-plugin-prettier) from 3.3.0 to 3.3.1.
- [Release notes](https://github.com/prettier/eslint-plugin-prettier/releases)
- [Changelog](https://github.com/prettier/eslint-plugin-prettier/blob/master/CHANGELOG.md)
- [Commits](https://github.com/prettier/eslint-plugin-prettier/compare/v3.3.0...v3.3.1)

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

Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2021-01-10 12:34:37 +02:00
38857b9e9d chore(deps): bump firebase from 8.2.1 to 8.2.2 (#2758)
Bumps [firebase](https://github.com/firebase/firebase-js-sdk) from 8.2.1 to 8.2.2.
- [Release notes](https://github.com/firebase/firebase-js-sdk/releases)
- [Changelog](https://github.com/firebase/firebase-js-sdk/blob/master/CHANGELOG.md)
- [Commits](https://github.com/firebase/firebase-js-sdk/commits)

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

Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2021-01-10 12:34:05 +02:00
342289f261 chore(deps-dev): bump husky from 4.3.6 to 4.3.7 (#2757)
Bumps [husky](https://github.com/typicode/husky) from 4.3.6 to 4.3.7.
- [Release notes](https://github.com/typicode/husky/releases)
- [Commits](https://github.com/typicode/husky/compare/v4.3.6...v4.3.7)

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

Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2021-01-10 12:33:47 +02:00
095d7de618 chore(deps): bump open-color from 1.7.0 to 1.8.0 (#2756)
Bumps [open-color](https://github.com/yeun/open-color) from 1.7.0 to 1.8.0.
- [Release notes](https://github.com/yeun/open-color/releases)
- [Changelog](https://github.com/yeun/open-color/blob/master/build_release)
- [Commits](https://github.com/yeun/open-color/compare/v1.7.0...v1.8.0)

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

Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2021-01-10 12:33:25 +02:00
f57d52028a chore(deps): bump @types/jest from 26.0.19 to 26.0.20 (#2759)
Bumps [@types/jest](https://github.com/DefinitelyTyped/DefinitelyTyped/tree/HEAD/types/jest) from 26.0.19 to 26.0.20.
- [Release notes](https://github.com/DefinitelyTyped/DefinitelyTyped/releases)
- [Commits](https://github.com/DefinitelyTyped/DefinitelyTyped/commits/HEAD/types/jest)

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

Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2021-01-10 12:33:02 +02:00
60557df23a ci(semantic-pr-title.yml): update version to 3.0.0 so that action doesn't run twice (#2741)
* ci(semantic-pr-title.yml): don't run this action twice

* Update semantic-pr-title.yml

Co-authored-by: Lipis <lipiridis@gmail.com>
2021-01-08 20:12:48 +05:30
bafbe9bbc8 feat: Add language separator and list English twice (#2739) 2021-01-07 21:39:57 +02:00
eb71e571e0 improvement: Perform lossless compression on all PNG images (#2740) 2021-01-07 18:04:28 +02:00
b608ab74cc chore: New Crowdin updates (#2681)
Co-authored-by: Kostas Bariotis <konmpar@gmail.com>
Co-authored-by: dwelle <luzar.david@gmail.com>
2021-01-07 11:30:22 +02:00
a13c4f72f5 docs: adding PR guidelines for contributors (#2736) 2021-01-06 22:53:12 +02:00
629341da4d improvement: adding zen mode to context menu (#2734) 2021-01-06 17:23:05 +01:00
778e4b08af feat: Add cmd+o shortcut to load scene (#2732) 2021-01-06 13:36:55 +01:00
e16266ce5d Fix translation of Catalan to read Català 2021-01-06 00:46:42 +01:00
b6708fb73f improvement: Make arrowheads keybinds color accessible on dark mode (#2724) 2021-01-05 22:35:03 +02:00
cdffed285d fix(readme): fix typo for initialData and point all links to master (#2707)
* fix(readme): fix typo for initialData

* Update src/packages/excalidraw/README.md

* fix lint

* Update src/packages/excalidraw/README.md

* point all links to master

Co-authored-by: Lipis <lipiridis@gmail.com>
2021-01-06 00:00:18 +05:30
3aa01ad272 chore: Remove tracking (#2722)
* chore: Remove tracking

* process

* rename

* remove

* prod

* Update public/index.html

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

* Update public/index.html

* eol

* more

* stats

Co-authored-by: David Luzar <luzar.david@gmail.com>
2021-01-05 19:06:14 +01:00
4acdc47ef0 improvement: do not reset to selection for draw tool (#2721)
Co-authored-by: Lipis <lipiridis@gmail.com>
Co-authored-by: dwelle <luzar.david@gmail.com>
2021-01-05 14:04:06 +01:00
ade2565f49 feat: add langCode and renderFooter props (#2644)
Co-authored-by: Aakansha Doshi <aakansha1216@gmail.com>
Co-authored-by: dwelle <luzar.david@gmail.com>
2021-01-03 21:51:52 +01:00
c35d983fef chore(deps): bump @testing-library/jest-dom from 5.11.6 to 5.11.8 (#2702)
Bumps [@testing-library/jest-dom](https://github.com/testing-library/jest-dom) from 5.11.6 to 5.11.8.
- [Release notes](https://github.com/testing-library/jest-dom/releases)
- [Changelog](https://github.com/testing-library/jest-dom/blob/master/CHANGELOG.md)
- [Commits](https://github.com/testing-library/jest-dom/compare/v5.11.6...v5.11.8)

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

Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2021-01-03 10:52:02 +01:00
69878167c2 improvement: Make dialogs look more like dialogs (#2686)
Co-authored-by: Jed Fox <git@jedfox.com>
Co-authored-by: dwelle <luzar.david@gmail.com>
2021-01-03 10:50:41 +01:00
eb1f717d35 chore(deps-dev): bump ts-loader in /src/packages/utils (#2700)
Bumps [ts-loader](https://github.com/TypeStrong/ts-loader) from 8.0.12 to 8.0.13.
- [Release notes](https://github.com/TypeStrong/ts-loader/releases)
- [Changelog](https://github.com/TypeStrong/ts-loader/blob/master/CHANGELOG.md)
- [Commits](https://github.com/TypeStrong/ts-loader/compare/v8.0.12...v8.0.13)

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

Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2021-01-03 15:20:28 +05:30
8e9af5c51b chore(deps-dev): bump webpack in /src/packages/excalidraw (#2698)
Bumps [webpack](https://github.com/webpack/webpack) from 5.11.0 to 5.11.1.
- [Release notes](https://github.com/webpack/webpack/releases)
- [Commits](https://github.com/webpack/webpack/compare/v5.11.0...v5.11.1)

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

Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2021-01-03 15:20:06 +05:30
afe0c760f6 chore(deps-dev): bump webpack-cli in /src/packages/utils (#2701)
Bumps [webpack-cli](https://github.com/webpack/webpack-cli) from 4.2.0 to 4.3.1.
- [Release notes](https://github.com/webpack/webpack-cli/releases)
- [Changelog](https://github.com/webpack/webpack-cli/blob/master/CHANGELOG.md)
- [Commits](https://github.com/webpack/webpack-cli/compare/webpack-cli@4.2.0...webpack-cli@4.3.1)

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

Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2021-01-03 15:14:21 +05:30
a231cefac0 chore(deps-dev): bump webpack-cli in /src/packages/excalidraw (#2697)
Bumps [webpack-cli](https://github.com/webpack/webpack-cli) from 4.2.0 to 4.3.1.
- [Release notes](https://github.com/webpack/webpack-cli/releases)
- [Changelog](https://github.com/webpack/webpack-cli/blob/master/CHANGELOG.md)
- [Commits](https://github.com/webpack/webpack-cli/compare/webpack-cli@4.2.0...webpack-cli@4.3.1)

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

Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2021-01-03 15:14:01 +05:30
cb4c9d16fc chore(deps-dev): bump webpack in /src/packages/utils (#2699)
Bumps [webpack](https://github.com/webpack/webpack) from 5.11.0 to 5.11.1.
- [Release notes](https://github.com/webpack/webpack/releases)
- [Commits](https://github.com/webpack/webpack/compare/v5.11.0...v5.11.1)

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

Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2021-01-03 15:02:27 +05:30
7366f089ba chore(packages/excalidraw): bump ts-loader from 8.0.12 to 8.0.13. (#2696)
Bumps [ts-loader](https://github.com/TypeStrong/ts-loader) from 8.0.12 to 8.0.13.
- [Release notes](https://github.com/TypeStrong/ts-loader/releases)
- [Changelog](https://github.com/TypeStrong/ts-loader/blob/master/CHANGELOG.md)
- [Commits](https://github.com/TypeStrong/ts-loader/compare/v8.0.12...v8.0.13)

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

Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2021-01-03 15:01:57 +05:30
7c3513b9df feat: browse libraries styles fixed (#2694)
* feat: browse libraries styles fixed

* simplify jsx & css

* remove justify-content

* fix padding/margin

* Update src/components/LayerUI.scss

Co-authored-by: benjamin.kugler <benjamin.kugler@elliemae.com>
Co-authored-by: dwelle <luzar.david@gmail.com>
2021-01-02 17:13:48 +01:00
aef3644c93 fix: scene not initialized properly when tab not focused (#2677)
Co-authored-by: Lipis <lipiridis@gmail.com>
2020-12-29 21:03:34 +01:00
0cf58adb4c chore: Update portal URL (#2689) 2020-12-28 13:14:22 +01:00
3aab81bc35 docs: add tsdoc for certain element props (#2673)
Co-authored-by: Panayiotis Lipiridis <lipiridis@gmail.com>
2020-12-28 00:17:27 +02:00
3b0fb1562d feat: Require use of a preset dialog size; adjust dialog sizing (#2684) 2020-12-28 00:07:05 +02:00
0488b7b5c6 fix(css): Fix compile error (#2685) 2020-12-27 23:42:23 +02:00
b8d13c98b5 refactor: Media queries (#2680) 2020-12-27 23:27:25 +02:00
6f82a88b79 chore: Cleanup unused labels (#2682) 2020-12-27 18:51:47 +02:00
022f349dc6 feat: Add line chart and paste dialog selection (#2670)
Co-authored-by: dwelle <luzar.david@gmail.com>
Co-authored-by: Jed Fox <git@jedfox.com>
2020-12-27 18:26:30 +02:00
c1e2146d78 chore(deps-dev): bump firebase-tools from 9.0.1 to 9.1.0 (#2676)
Bumps [firebase-tools](https://github.com/firebase/firebase-tools) from 9.0.1 to 9.1.0.
- [Release notes](https://github.com/firebase/firebase-tools/releases)
- [Commits](https://github.com/firebase/firebase-tools/compare/v9.0.1...v9.1.0)

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

Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2020-12-27 13:51:54 +02:00
8091ac6c08 style: media query for hiding shortcuts for mobile view (#2667)
Co-authored-by: Lipis <lipiridis@gmail.com>
2020-12-26 21:23:51 +02:00
Luo
bc414ccaaf feat: tweak editing behavior (#2668)
* feat: tweak editing behavior

* fix tests

Co-authored-by: dwelle <luzar.david@gmail.com>
2020-12-25 19:34:47 +01:00
0cf5f1ac1f chore: update Sentry (#2669) 2020-12-25 20:02:12 +02:00
e9cb7ee77c chore: New Crowdin updates (#2620)
Co-authored-by: Kostas Bariotis <konmpar@gmail.com>
2020-12-25 12:15:34 +02:00
86c036505b chore: Remove unused cursorX, cursorY from AppState (#2665) 2020-12-24 22:05:22 +01:00
194 changed files with 8785 additions and 9116 deletions

2
.env
View File

@ -1,5 +1,5 @@
REACT_APP_BACKEND_V1_GET_URL=https://json.excalidraw.com/api/v1/
REACT_APP_BACKEND_V2_GET_URL=https://json.excalidraw.com/api/v2/
REACT_APP_BACKEND_V2_POST_URL=https://json.excalidraw.com/api/v2/post/
REACT_APP_SOCKET_SERVER_URL=https://excalidraw-socket.herokuapp.com
REACT_APP_SOCKET_SERVER_URL=https://portal.excalidraw.com
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"}'

View File

@ -1 +1 @@
REACT_APP_INCLUDE_GTAG=true
REACT_APP_GOOGLE_ANALYTICS_ID=UA-387204-13

Binary file not shown.

Before

Width:  |  Height:  |  Size: 39 KiB

After

Width:  |  Height:  |  Size: 20 KiB

View File

@ -6,7 +6,7 @@ on:
- master
jobs:
build-docker:
build:
runs-on: ubuntu-latest
steps:

View File

@ -13,10 +13,10 @@ jobs:
steps:
- uses: actions/checkout@v1
- name: Setup Node.js 12.x
- name: Setup Node.js 14.x
uses: actions/setup-node@v1
with:
node-version: 12.x
node-version: 14.x
- name: Install dependencies
run: |

18
.github/workflows/cancel.yml vendored Normal file
View File

@ -0,0 +1,18 @@
name: Cancel previous runs
on:
push:
branches:
- master
pull_request:
jobs:
cancel:
runs-on: ubuntu-latest
timeout-minutes: 3
steps:
- uses: styfle/cancel-workflow-action@0.6.0
with:
workflow_id: 400555, 400556, 905313, 1451724, 1710116, 3185001, 3438604
access_token: ${{ secrets.GITHUB_TOKEN }}

View File

@ -1,10 +1,6 @@
name: Lint
on:
push:
branches:
- master
pull_request:
on: pull_request
jobs:
lint:
@ -13,10 +9,10 @@ jobs:
steps:
- uses: actions/checkout@v1
- name: Setup Node.js 12.x
- name: Setup Node.js 14.x
uses: actions/setup-node@v1
with:
node-version: 12.x
node-version: 14.x
- name: Install and lint
run: |
@ -24,5 +20,3 @@ jobs:
npm run test:other
npm run test:code
npm run test:typecheck
env:
CI: true

View File

@ -14,18 +14,18 @@ jobs:
with:
token: ${{ secrets.PUSH_TRANSLATIONS_COVERAGE_PAT }}
- name: Setup Node.js 12.x
- name: Setup Node.js 14.x
uses: actions/setup-node@v1
with:
node-version: 12.x
node-version: 14.x
- name: Create report file
run: |
npm run locales-coverage
FILE_CHANGED=$(git diff src/locales/percentages.json)
if [ ! -z "${FILE_CHANGED}" ]; then
git config --global user.name 'Kostas Bariotis'
git config --global user.email 'konmpar@gmail.com'
git config --global user.name 'Excalidraw Bot'
git config --global user.email 'bot@excalidraw.com'
git add src/locales/percentages.json
git commit -am "Auto commit: Calculate translation coverage"
git push
@ -43,5 +43,5 @@ jobs:
uses: kt3k/update-pr-description@v1.0.1
with:
pr_body: ${{ steps.getCommentBody.outputs.body }}
pr_title: "chore: New Crowdin updates"
pr_title: "chore: Update translations from Crowdin"
github_token: ${{ secrets.PUSH_TRANSLATIONS_COVERAGE_PAT }}

View File

@ -10,7 +10,8 @@ on:
jobs:
main:
runs-on: ubuntu-latest
steps:
- uses: amannn/action-semantic-pull-request@v2.1.0
- uses: amannn/action-semantic-pull-request@v3.0.0
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}

View File

@ -8,13 +8,14 @@ on:
jobs:
release:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v1.0.0
- name: Setup Node.js 12.x
- name: Setup Node.js 14.x
uses: actions/setup-node@v1
with:
node-version: 12.x
node-version: 14.x
- name: Install and build
run: |

View File

@ -1,10 +1,6 @@
name: Tests
on:
push:
branches:
- master
pull_request:
on: pull_request
jobs:
test:
@ -13,14 +9,12 @@ jobs:
steps:
- uses: actions/checkout@v1
- name: Setup Node.js 12.x
- name: Setup Node.js 14.x
uses: actions/setup-node@v1
with:
node-version: 12.x
node-version: 14.x
- name: Install and test
run: |
npm ci
npm run test:app
env:
CI: true

View File

@ -1,3 +1,4 @@
{
"proseWrap": "never",
"trailingComma": "all"
}

View File

@ -8,8 +8,7 @@
1. Run `npm install` to install dependencies
1. Create a branch for your PR with `git checkout -b your-branch-name`
> To keep `master` branch pointing to remote repository and make
> pull requests from branches on your fork. To do this, run:
> To keep `master` branch pointing to remote repository and make pull requests from branches on your fork. To do this, run:
>
> ```sh
> git remote add upstream https://github.com/excalidraw/excalidraw.git
@ -25,3 +24,41 @@
1. Tap on `Fork Sandbox`
1. Write your code
1. Commit and PR automatically
## Pull Request Guidelines
Don't worry if you get any of the below wrong, or if you don't know how. We'll gladly help out.
### Title
Make sure the title starts with a semantic prefix:
- **feat**: A new feature
- **fix**: A bug fix
- **improvement**: An improvement to a current feature
- **docs**: Documentation only changes
- **style**: Changes that do not affect the meaning of the code (white-space, formatting, missing semi-colons, etc)
- **refactor**: A code change that neither fixes a bug nor adds a feature
- **perf**: A code change that improves performance
- **test**: Adding missing tests or correcting existing tests
- **build**: Changes that affect the build system or external dependencies (example scopes: gulp, broccoli, npm)
- **ci**: Changes to our CI configuration files and scripts (example scopes: Travis, Circle, BrowserStack, SauceLabs)
- **chore**: Other changes that don't modify src or test files
- **revert**: Reverts a previous commit
### Changelog
Add a brief description of your pull request to the changelog located here: [`src/packages/excalidraw/CHANGELOG.md`](src/packages/excalidraw/CHANGELOG.md)
Notes:
- Make sure to prepend to the section corresponding with the semantic prefix you selected in the title
- Link to your pull request - this will require updating the CHANGELOG _after_ creating the pull request
### Testing
Once you submit your pull request it will automatically be tested. Be sure to check the results of the test and fix any issues that arise.
It's also a good idea to consider if your change should include additional tests. This is highly recommended for new features or bug-fixes. For example, it's good practice to create a test for each bug you fix which ensures that we don't regress the code in the future.
Finally - always manually test your changes using the convenient staging environment deployed for each pull request. As much as local development attempts to replicate production, there can still be subtle differences in behavior. For larger features consider testing your change in multiple browsers as well.

View File

@ -5,7 +5,6 @@ WORKDIR /opt/node_app
COPY package.json package-lock.json ./
RUN npm i --no-optional
ARG REACT_APP_INCLUDE_GTAG=false
ARG NODE_ENV=production
COPY . .

101
README.md
View File

@ -1,8 +1,8 @@
<div align="center" style="display:flex;flex-direction:column;">
<a href="https://excalidraw.com">
<img src="./public/og-image.png" alt="Excalidraw logo: Sketch handrawn like diagrams." />
<img width="540" src="./public/og-image-sm.png" alt="Excalidraw logo: Sketch handrawn like diagrams." />
</a>
<h3>Virtual whiteboard for sketching hand-drawn like diagrams.</h3>
<h3>Virtual whiteboard for sketching hand-drawn like diagrams.<br>Collaborative and end to end encrypted.</h3>
<p>
<a href="https://twitter.com/Excalidraw">
<img alt="Follow Excalidraw on Twitter" src="https://img.shields.io/twitter/follow/excalidraw.svg?label=follow+excalidraw&style=social&logo=twitter">
@ -10,9 +10,6 @@
<a target="_blank" href="https://crowdin.com/project/excalidraw">
<img src="https://badges.crowdin.net/excalidraw/localized.svg">
</a>
<a target="_blank" href="https://hub.docker.com/r/excalidraw/excalidraw">
<img src="https://img.shields.io/docker/pulls/excalidraw/excalidraw">
</a>
</p>
</div>
@ -20,13 +17,51 @@
Go to [excalidraw.com](https://excalidraw.com) to start sketching.
Read our [blog](https://blog.excalidraw.com) and follow the [guides](https://howto.excalidraw.com) to learn more about Excalidraw and how to use it effectively.
Read the latest news and updates on our [blog](https://blog.excalidraw.com). A good start is to see all the updates of [One Year of Excalidraw](https://blog.excalidraw.com/one-year-of-excalidraw/).
## Documentation
### Shortcuts
You can almost do anything with shortcuts. Click on the help icon on the bottom right corner to see them all.
### Curved lines and arrows
Choose line or arrow and click click click instead of drag.
### Charts
You can easily create charts by copy pasting data from Excel or just plain comma separated text.
### Translating
To translate Excalidraw into other languages, please visit [our Crowdin page](https://crowdin.com/project/excalidraw). To add a new language, [open an issue](https://github.com/excalidraw/excalidraw/issues/new) so we can get things set up on our end first.
Translations will be available on the app if they exceed a certain threshold of completion (currently 85%).
### Create a collaboration session manually
In order to create a session manually you just need to generate a link of this form:
```
https://excalidraw.com/#room=[0-9a-f]{20},[a-zA-Z0-9_-]{22}
```
#### Example
```
https://excalidraw.com/#room=91bd46ae3aa84dff9d20,pfLqgEoY1c2ioq8LmGwsFA
```
The first set of digits is the room. This is visible from the server thats going to dispatch messages to everyone that knows this number.
The second set of digits is the encryption key. The Excalidraw server doesnt know about it. This is what all the participants use to encrypt/decrypt the messages.
## Shape libraries
Find a growing list of libraries containing assets for your drawings at [libraries.excalidraw.com](https://libraries.excalidraw.com).
## Run the code
## Developement
### Code Sandbox
@ -63,7 +98,7 @@ You can use docker-compose to work on excalidraw locally if you don't want to se
docker-compose up --build -d
```
## Self hosting
### Self hosting
We publish a Docker image with the Excalidraw client at [excalidraw/excalidraw](https://hub.docker.com/r/excalidraw/excalidraw). You can use it to self host your own client under your own domain, on Kubernetes, AWS ECS, etc.
@ -82,57 +117,11 @@ We are working towards providing a full-fledged solution for self hosting your o
Pull requests are welcome. For major changes, please [open an issue](https://github.com/excalidraw/excalidraw/issues/new) first to discuss what you would like to change.
## Translating
## Notable used tools
To translate Excalidraw into other languages, please visit [our Crowdin page](https://crowdin.com/project/excalidraw). To add a new language, [open an issue](https://github.com/excalidraw/excalidraw/issues/new) so we can get things set up on our end first.
Translations will be available on the app if they exceed a certain threshold of completion (currently 85%).
## Excalidraw is built using these awesome tools
- [React](https://reactjs.org)
- [Create React App](https://github.com/facebook/create-react-app)
- [Rough.js](https://roughjs.com)
- [TypeScript](https://www.typescriptlang.org)
- [Vercel](https://vercel.com)
And the main source of inspiration for starting the project is the awesome [Zwibbler](https://zwibbler.com/demo/) app.
## Testimonials
<a href="https://twitter.com/Lissy_Sykes/status/1213813117177729026"><img width="398" src="https://user-images.githubusercontent.com/197597/71783813-dbf8a600-2fa0-11ea-9c0d-bb3cc45969e6.png"></a>
<a href="https://twitter.com/dan_abramov/status/1213762494428262400"><img width="398" src="https://user-images.githubusercontent.com/197597/71783990-4d395880-2fa3-11ea-9ad7-186138db5003.png"></a>
<a href="https://twitter.com/kyehohenberger/status/1214288572037025792"><img width="423" src="https://user-images.githubusercontent.com/197597/71851802-34f13880-308c-11ea-9416-191099e6349c.png"></a>
<a href="https://twitter.com/lucasazzola/status/1215126440330416128"><img width="429" src="https://user-images.githubusercontent.com/197597/72039003-48e99580-3258-11ea-8daa-85dd055f2a82.png">
<a href="https://twitter.com/jordwalke/status/1214858186789806080"><img width="434" src="https://user-images.githubusercontent.com/197597/72036874-07a1b780-3251-11ea-99e8-6bafd93483a0.png"></a>
## Contributors
### Code Contributors
This project exists thanks to all the people who contribute. [[Contribute](CONTRIBUTING.md)].
<a href="https://github.com/excalidraw/excalidraw/graphs/contributors"><img src="https://opencollective.com/excalidraw/contributors.svg?width=890&button=false" /></a>
### Financial Contributors
Become a financial contributor and help us sustain our community. [[Contribute](https://opencollective.com/excalidraw/contribute)]
#### Individuals
<a href="https://opencollective.com/excalidraw"><img src="https://opencollective.com/excalidraw/individuals.svg?width=890"></a>
#### Organizations
Support this project with your organization. Your logo will show up here with a link to your website. [[Contribute](https://opencollective.com/excalidraw/contribute)]
<a href="https://opencollective.com/excalidraw/organization/0/website"><img src="https://opencollective.com/excalidraw/organization/0/avatar.svg"></a>
<a href="https://opencollective.com/excalidraw/organization/1/website"><img src="https://opencollective.com/excalidraw/organization/1/avatar.svg"></a>
<a href="https://opencollective.com/excalidraw/organization/2/website"><img src="https://opencollective.com/excalidraw/organization/2/avatar.svg"></a>
<a href="https://opencollective.com/excalidraw/organization/3/website"><img src="https://opencollective.com/excalidraw/organization/3/avatar.svg"></a>
<a href="https://opencollective.com/excalidraw/organization/4/website"><img src="https://opencollective.com/excalidraw/organization/4/avatar.svg"></a>
<a href="https://opencollective.com/excalidraw/organization/5/website"><img src="https://opencollective.com/excalidraw/organization/5/avatar.svg"></a>
<a href="https://opencollective.com/excalidraw/organization/6/website"><img src="https://opencollective.com/excalidraw/organization/6/avatar.svg"></a>
<a href="https://opencollective.com/excalidraw/organization/7/website"><img src="https://opencollective.com/excalidraw/organization/7/avatar.svg"></a>
<a href="https://opencollective.com/excalidraw/organization/8/website"><img src="https://opencollective.com/excalidraw/organization/8/avatar.svg"></a>
<a href="https://opencollective.com/excalidraw/organization/9/website"><img src="https://opencollective.com/excalidraw/organization/9/avatar.svg"></a>

View File

@ -1,64 +0,0 @@
| Excalidraw | Category | Name | Label | Value |
| ----------------------- | -------- | ---------------------------------- | ------------------------------- | --------- |
| Shape / Selection | shape | selection, rectangle, diamond, etc | `toolbar` or `shortcut` |
| Text on double click | shape | text | `double-click` |
| Lock selection | shape | lock | `on` or `off` |
| Clear canvas | action | clear canvas |
| Zoom in | action | zoom | in | `zoom` |
| Zoom out | action | zoom | out | `zoom` |
| Zoom fit | action | zoom | fit | `zoom` |
| Zoom reset | action | zoom | reset | `zoom` |
| Scroll back to content | action | scroll to content |
| Load file | io | load | `MIME type` |
| Import from URL | io | import |
| Save | io | save |
| Save as | io | save as |
| Export to backend | io | export | backend |
| Export as SVG | io | export | `svg` or `clipboard-svg` |
| Export to PNG | io | export | `png` or `clipboard-png` |
| Canvas color | change | canvas color | `color` |
| Background color | change | background color | `color` |
| Stroke color | change | stroke color | `color` |
| Stroke width | change | stroke | width | `width` |
| Stroke style | change | style | `solid` or `dashed` or `dotted` |
| Stroke sloppiness | change | stroke | sloppiness | `value` |
| Fill | change | fill | `value` |
| Edge | change | edge | `value` |
| Opacity | change | opacity | value | `opacity` |
| Project name | change | title |
| Theme | change | theme | `light` or `dark` |
| Change language | change | language | `language` |
| Send to back | layer | move | `back` |
| Send backward | layer | move | `down` |
| Bring to front | layer | move | `front` |
| Bring forward | layer | move | `up` |
| Align left | align | align | `left` |
| Align right | align | align | `right` |
| Align top | align | align | `top` |
| Align bottom | align | align | `bottom` |
| Center horizontally | align | horizontally | `center` |
| Center vertically | align | vertically | `center` |
| Distribute horizontally | align | distribute | `horizontally` |
| Distribute vertically | align | distribute | `vertically` |
| Start session | share | session start |
| Join session | share | session join |
| Start end | share | session end |
| Copy room link | share | copy link |
| Go to collaborator | share | go to collaborator |
| Change name | share | name |
| Add to library | library | add |
| Remove from library | library | remove |
| Load library | library | load |
| Save library | library | save |
| Import library | library | import |
| Shortcuts dialog | dialog | shortcuts |
| Collaboration dialog | dialog | collaboration |
| Export dialog | dialog | export |
| Library dialog | dialog | library |
| E2EE shield | exit | e2ee shield |
| GitHub corner | exit | github |
| Excalidraw blog | exit | blog |
| Excalidraw guides | exit | guides |
| File issues | exit | issues |
| First load | load | first load |
| Load from stroage | load | storage | size | `bytes` |

3969
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -19,23 +19,22 @@
]
},
"dependencies": {
"@sentry/browser": "5.29.0",
"@sentry/integrations": "5.29.0",
"@testing-library/jest-dom": "5.11.6",
"@testing-library/react": "11.2.2",
"@types/jest": "26.0.19",
"@types/nanoid": "2.1.0",
"@sentry/browser": "6.0.3",
"@sentry/integrations": "6.0.3",
"@testing-library/jest-dom": "5.11.9",
"@testing-library/react": "11.2.3",
"@types/jest": "26.0.20",
"@types/react": "17.0.0",
"@types/react-dom": "17.0.0",
"@types/socket.io-client": "1.4.34",
"browser-nativefs": "0.12.0",
"@types/socket.io-client": "1.4.35",
"browser-fs-access": "0.13.0",
"clsx": "1.1.1",
"firebase": "8.2.1",
"firebase": "8.2.5",
"i18next-browser-languagedetector": "6.0.1",
"lodash.throttle": "4.1.1",
"nanoid": "2.1.11",
"nanoid": "3.1.20",
"node-sass": "4.14.1",
"open-color": "1.7.0",
"open-color": "1.8.0",
"pako": "1.0.11",
"png-chunk-text": "1.0.0",
"png-chunks-encode": "1.0.0",
@ -52,10 +51,10 @@
"devDependencies": {
"@types/lodash.throttle": "4.1.6",
"@types/pako": "1.0.1",
"eslint-config-prettier": "7.1.0",
"eslint-plugin-prettier": "3.3.0",
"firebase-tools": "9.0.1",
"husky": "4.3.6",
"eslint-config-prettier": "7.2.0",
"eslint-plugin-prettier": "3.3.1",
"firebase-tools": "9.2.2",
"husky": "4.3.8",
"jest-canvas-mock": "2.3.0",
"lint-staged": "10.5.3",
"pepjs": "0.5.3",
@ -73,7 +72,7 @@
},
"jest": {
"transformIgnorePatterns": [
"node_modules/(?!(roughjs|points-on-curve|path-data-parser|points-on-path|browser-nativefs)/)"
"node_modules/(?!(roughjs|points-on-curve|path-data-parser|points-on-path|browser-fs-access)/)"
],
"resetMocks": false
},
@ -81,8 +80,8 @@
"private": true,
"scripts": {
"build-node": "node ./scripts/build-node.js",
"build:app:docker": "REACT_APP_INCLUDE_GTAG=false REACT_APP_DISABLE_SENTRY=true react-scripts build",
"build:app": "REACT_APP_INCLUDE_GTAG=true REACT_APP_GIT_SHA=$NOW_GITHUB_COMMIT_SHA react-scripts build",
"build:app:docker": "REACT_APP_DISABLE_SENTRY=true react-scripts build",
"build:app": "REACT_APP_GIT_SHA=$VERCEL_GIT_COMMIT_SHA react-scripts build",
"build:version": "node ./scripts/build-version.js",
"build": "npm run build:app && npm run build:version",
"eject": "react-scripts eject",

Binary file not shown.

Before

Width:  |  Height:  |  Size: 9.0 KiB

After

Width:  |  Height:  |  Size: 4.3 KiB

View File

@ -57,6 +57,7 @@
<!-- Excalidraw version -->
<meta name="version" content="{version}" />
<link
rel="preload"
href="FG_Virgil.woff2"
@ -86,10 +87,10 @@
<link rel="stylesheet" href="fonts.css" type="text/css" />
<% if (process.env.REACT_APP_INCLUDE_GTAG === 'true') { %>
<% if (process.env.REACT_APP_GOOGLE_ANALYTICS_ID) { %>
<script
async
src="https://www.googletagmanager.com/gtag/js?id=UA-387204-13"
src="https://www.googletagmanager.com/gtag/js?id=%REACT_APP_GOOGLE_ANALYTICS_ID%"
></script>
<script>
window.dataLayer = window.dataLayer || [];
@ -97,7 +98,7 @@
dataLayer.push(arguments);
}
gtag("js", new Date());
gtag("config", "UA-387204-13");
gtag("config", "%REACT_APP_GOOGLE_ANALYTICS_ID%");
</script>
<% } %>

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.4 KiB

After

Width:  |  Height:  |  Size: 2.6 KiB

BIN
public/og-image-sm.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 76 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 83 KiB

After

Width:  |  Height:  |  Size: 70 KiB

View File

@ -5,23 +5,40 @@ const path = require("path");
const versionFile = path.join("build", "version.json");
const indexFile = path.join("build", "index.html");
const zero = (digit) => `0${digit}`.slice(-2);
const versionDate = (date) => date.toISOString().replace(".000", "");
const versionDate = (date) => {
const date_ = `${date.getFullYear()}-${zero(date.getMonth() + 1)}-${zero(
date.getDate(),
)}`;
const time = `${zero(date.getHours())}-${zero(date.getMinutes())}-${zero(
date.getSeconds(),
)}`;
return `${date_}-${time}`;
const commitHash = () => {
try {
return require("child_process")
.execSync("git rev-parse --short HEAD")
.toString()
.trim();
} catch {
return "none";
}
};
const now = new Date();
const commitDate = (hash) => {
try {
const unix = require("child_process")
.execSync(`git show -s --format=%ct ${hash}`)
.toString()
.trim();
const date = new Date(parseInt(unix) * 1000);
return versionDate(date);
} catch {
return versionDate(new Date());
}
};
const getFullVersion = () => {
const hash = commitHash();
return `${commitDate(hash)}-${hash}`;
};
const data = JSON.stringify(
{
version: versionDate(now),
version: getFullVersion(),
},
undefined,
2,
@ -34,7 +51,7 @@ fs.readFile(indexFile, "utf8", (error, data) => {
if (error) {
return console.error(error);
}
const result = data.replace(/{version}/g, versionDate(now));
const result = data.replace(/{version}/g, getFullVersion());
fs.writeFile(indexFile, result, "utf8", (error) => {
if (error) {

View File

@ -4,26 +4,29 @@ const THRESSHOLD = 85;
const crowdinMap = {
"ar-SA": "en-ar",
"el-GR": "en-el",
"fi-FI": "en-fi",
"ja-JP": "en-ja",
"bg-BG": "en-bg",
"ca-ES": "en-ca",
"de-DE": "en-de",
"el-GR": "en-el",
"es-ES": "en-es",
"fa-IR": "en-fa",
"fi-FI": "en-fi",
"fr-FR": "en-fr",
"he-IL": "en-he",
"hi-IN": "en-hi",
"hu-HU": "en-hu",
"id-ID": "en-id",
"it-IT": "en-it",
"ja-JP": "en-ja",
"kab-KAB": "en-kab",
"ko-KR": "en-ko",
"my-MM": "en-my",
"nb-NO": "en-nb",
"nl-NL": "en-nl",
"nn-NO": "en-nnno",
"pa-IN": "en-pain",
"pl-PL": "en-pl",
"pt-BR": "en-ptbr",
"pt-PT": "en-pt",
"ro-RO": "en-ro",
"ru-RU": "en-ru",
@ -38,7 +41,7 @@ const crowdinMap = {
const flags = {
"ar-SA": "🇸🇦",
"bg-BG": "🇧🇬",
"ca-ES": "🇪🇸",
"ca-ES": "🏳",
"de-DE": "🇩🇪",
"el-GR": "🇬🇷",
"es-ES": "🇪🇸",
@ -51,12 +54,15 @@ const flags = {
"id-ID": "🇮🇩",
"it-IT": "🇮🇹",
"ja-JP": "🇯🇵",
"kab-KAB": "🏳",
"ko-KR": "🇰🇷",
"my-MM": "🇲🇲",
"nb-NO": "🇳🇴",
"nl-NL": "🇳🇱",
"nn-NO": "🇳🇴",
"pa-IN": "🇮🇳",
"pl-PL": "🇵🇱",
"pt-BR": "🇧🇷",
"pt-PT": "🇵🇹",
"ro-RO": "🇷🇴",
"ru-RU": "🇷🇺",
@ -71,7 +77,7 @@ const flags = {
const languages = {
"ar-SA": "العربية",
"bg-BG": "Български",
"ca-ES": "Catalan",
"ca-ES": "Català",
"de-DE": "Deutsch",
"el-GR": "Ελληνικά",
"es-ES": "Español",
@ -84,12 +90,15 @@ const languages = {
"id-ID": "Bahasa Indonesia",
"it-IT": "Italiano",
"ja-JP": "日本語",
"kab-KAB": "Taqbaylit",
"ko-KR": "한국어",
"my-MM": "Burmese",
"nb-NO": "Norsk bokmål",
"nl-NL": "Nederlands",
"nn-NO": "Norsk nynorsk",
"pa-IN": "ਪੰਜਾਬੀ",
"pl-PL": "Polski",
"pt-BR": "Português Brasileiro",
"pt-PT": "Português",
"ro-RO": "Română",
"ru-RU": "Русский",
@ -114,16 +123,14 @@ const boldIf = (text, condition) => (condition ? `**${text}**` : text);
const printHeader = () => {
let result = "| | Flag | Locale | % |\n";
result += "| --: | :--: | -- | --: |";
result += "| :--: | :--: | -- | :--: |";
return result;
};
const printRow = (id, locale, coverage) => {
const isOver = coverage > THRESSHOLD;
let result = `| ${boldIf(id, isOver)} | `;
const isOver = coverage >= THRESSHOLD;
let result = `| ${isOver ? id : "..."} | `;
result += `${locale in flags ? flags[locale] : ""} | `;
const language = locale in languages ? languages[locale] : locale;
if (locale in crowdinMap && crowdinMap[locale]) {
result += `[${boldIf(
@ -133,14 +140,12 @@ const printRow = (id, locale, coverage) => {
} else {
result += `${boldIf(language, isOver)} | `;
}
result += `${boldIf(coverage, isOver)} |`;
result += `${coverage === 100 ? "💯" : boldIf(coverage, isOver)} |`;
return result;
};
console.info("## Languages check");
console.info("\n\r");
console.info(
`Our translations for every languages should be at least **${THRESSHOLD}%** to appear on Excalidraw. Join our project in [Crowdin](https://crowdin.com/project/excalidraw) and help us translate it in your language. **Can't find your own?** Open an [issue](https://github.com/excalidraw/excalidraw/issues/new) and we'll add it to the list.`,
`Each language must be at least **${THRESSHOLD}%** translated in order to appear on Excalidraw. Join us on [Crowdin](https://crowdin.com/project/excalidraw) and help us translate your own language. **Can't find yours yet?** Open an [issue](https://github.com/excalidraw/excalidraw/issues/new) and we'll add it to the list.`,
);
console.info("\n\r");
console.info(printHeader());

View File

@ -3,7 +3,6 @@ import { getSelectedElements } from "../scene";
import { getNonDeletedElements } from "../element";
import { deepCopyElement } from "../element/newElement";
import { Library } from "../data/library";
import { EVENT_LIBRARY, trackEvent } from "../analytics";
export const actionAddToLibrary = register({
name: "addToLibrary",
@ -16,9 +15,7 @@ export const actionAddToLibrary = register({
Library.loadLibrary().then((items) => {
Library.saveLibrary([...items, selectedElements.map(deepCopyElement)]);
});
trackEvent(EVENT_LIBRARY, "add");
return false;
},
contextMenuOrder: 6,
contextItemLabel: "labels.addToLibrary",
});

View File

@ -1,7 +1,5 @@
import React from "react";
import { KEYS } from "../keys";
import { t } from "../i18n";
import { register } from "./register";
import { alignElements, Alignment } from "../align";
import {
AlignBottomIcon,
AlignLeftIcon,
@ -10,14 +8,15 @@ import {
CenterHorizontallyIcon,
CenterVerticallyIcon,
} from "../components/icons";
import { getSelectedElements, isSomeElementSelected } from "../scene";
import { getElementMap, getNonDeletedElements } from "../element";
import { ToolButton } from "../components/ToolButton";
import { getElementMap, getNonDeletedElements } from "../element";
import { ExcalidrawElement } from "../element/types";
import { t } from "../i18n";
import { KEYS } from "../keys";
import { getSelectedElements, isSomeElementSelected } from "../scene";
import { AppState } from "../types";
import { alignElements, Alignment } from "../align";
import { getShortcutKey } from "../utils";
import { trackEvent, EVENT_ALIGN } from "../analytics";
import { register } from "./register";
const enableActionGroup = (
elements: readonly ExcalidrawElement[],
@ -44,7 +43,6 @@ const alignSelectedElements = (
export const actionAlignTop = register({
name: "alignTop",
perform: (elements, appState) => {
trackEvent(EVENT_ALIGN, "align", "top");
return {
appState,
elements: alignSelectedElements(elements, appState, {
@ -74,7 +72,6 @@ export const actionAlignTop = register({
export const actionAlignBottom = register({
name: "alignBottom",
perform: (elements, appState) => {
trackEvent(EVENT_ALIGN, "align", "bottom");
return {
appState,
elements: alignSelectedElements(elements, appState, {
@ -104,7 +101,6 @@ export const actionAlignBottom = register({
export const actionAlignLeft = register({
name: "alignLeft",
perform: (elements, appState) => {
trackEvent(EVENT_ALIGN, "align", "left");
return {
appState,
elements: alignSelectedElements(elements, appState, {
@ -134,7 +130,6 @@ export const actionAlignLeft = register({
export const actionAlignRight = register({
name: "alignRight",
perform: (elements, appState) => {
trackEvent(EVENT_ALIGN, "align", "right");
return {
appState,
elements: alignSelectedElements(elements, appState, {
@ -164,7 +159,6 @@ export const actionAlignRight = register({
export const actionAlignVerticallyCentered = register({
name: "alignVerticallyCentered",
perform: (elements, appState) => {
trackEvent(EVENT_ALIGN, "vertically", "center");
return {
appState,
elements: alignSelectedElements(elements, appState, {
@ -190,7 +184,6 @@ export const actionAlignVerticallyCentered = register({
export const actionAlignHorizontallyCentered = register({
name: "alignHorizontallyCentered",
perform: (elements, appState) => {
trackEvent(EVENT_ALIGN, "horizontally", "center");
return {
appState,
elements: alignSelectedElements(elements, appState, {

View File

@ -1,36 +1,25 @@
import React from "react";
import { ColorPicker } from "../components/ColorPicker";
import { getDefaultAppState } from "../appState";
import { trash, zoomIn, zoomOut, resetZoom } from "../components/icons";
import { ColorPicker } from "../components/ColorPicker";
import { resetZoom, trash, zoomIn, zoomOut } from "../components/icons";
import { ToolButton } from "../components/ToolButton";
import { t } from "../i18n";
import { getNormalizedZoom, getSelectedElements } from "../scene";
import { getNonDeletedElements } from "../element";
import { CODES, KEYS } from "../keys";
import { getShortcutKey } from "../utils";
import useIsMobile from "../is-mobile";
import { register } from "./register";
import { ZOOM_STEP } from "../constants";
import { getCommonBounds, getNonDeletedElements } from "../element";
import { newElementWith } from "../element/mutateElement";
import { ExcalidrawElement } from "../element/types";
import { AppState, NormalizedZoomValue } from "../types";
import { getCommonBounds } from "../element";
import { getNewZoom } from "../scene/zoom";
import { t } from "../i18n";
import useIsMobile from "../is-mobile";
import { CODES, KEYS } from "../keys";
import { getNormalizedZoom, getSelectedElements } from "../scene";
import { centerScrollOn } from "../scene/scroll";
import { EVENT_ACTION, EVENT_CHANGE, trackEvent } from "../analytics";
import colors from "../colors";
import { getNewZoom } from "../scene/zoom";
import { AppState, NormalizedZoomValue } from "../types";
import { getShortcutKey } from "../utils";
import { register } from "./register";
export const actionChangeViewBackgroundColor = register({
name: "changeViewBackgroundColor",
perform: (_, appState, value) => {
if (value !== appState.viewBackgroundColor) {
trackEvent(
EVENT_CHANGE,
"canvas color",
colors.canvasBackground.includes(value)
? `${value} (picker ${colors.canvasBackground.indexOf(value)})`
: value,
);
}
return {
appState: { ...appState, viewBackgroundColor: value },
commitToHistory: true,
@ -53,7 +42,6 @@ export const actionChangeViewBackgroundColor = register({
export const actionClearCanvas = register({
name: "clearCanvas",
perform: (elements, appState: AppState) => {
trackEvent(EVENT_ACTION, "clear canvas");
return {
elements: elements.map((element) =>
newElementWith(element, { isDeleted: true }),
@ -64,9 +52,10 @@ export const actionClearCanvas = register({
elementLocked: appState.elementLocked,
exportBackground: appState.exportBackground,
exportEmbedScene: appState.exportEmbedScene,
showGrid: appState.showGrid,
gridSize: appState.gridSize,
shouldAddWatermark: appState.shouldAddWatermark,
showStats: appState.showStats,
pasteDialog: appState.pasteDialog,
},
commitToHistory: true,
};
@ -87,8 +76,6 @@ export const actionClearCanvas = register({
),
});
const ZOOM_STEP = 0.1;
export const actionZoomIn = register({
name: "zoomIn",
perform: (_elements, appState) => {
@ -98,7 +85,6 @@ export const actionZoomIn = register({
{ left: appState.offsetLeft, top: appState.offsetTop },
{ x: appState.width / 2, y: appState.height / 2 },
);
trackEvent(EVENT_ACTION, "zoom", "in", zoom.value * 100);
return {
appState: {
...appState,
@ -133,7 +119,6 @@ export const actionZoomOut = register({
{ x: appState.width / 2, y: appState.height / 2 },
);
trackEvent(EVENT_ACTION, "zoom", "out", zoom.value * 100);
return {
appState: {
...appState,
@ -161,7 +146,6 @@ export const actionZoomOut = register({
export const actionResetZoom = register({
name: "resetZoom",
perform: (_elements, appState) => {
trackEvent(EVENT_ACTION, "zoom", "reset", 100);
return {
appState: {
...appState,
@ -234,12 +218,10 @@ const zoomToFitElements = (
left: appState.offsetLeft,
top: appState.offsetTop,
});
const action = zoomToSelection ? "selection" : "fit";
const [x1, y1, x2, y2] = commonBounds;
const centerX = (x1 + x2) / 2;
const centerY = (y1 + y2) / 2;
trackEvent(EVENT_ACTION, "zoom", action, newZoom.value * 100);
return {
appState: {
...appState,

View File

@ -0,0 +1,114 @@
import { CODES, KEYS } from "../keys";
import { register } from "./register";
import { copyToClipboard } from "../clipboard";
import { actionDeleteSelected } from "./actionDeleteSelected";
import { getSelectedElements } from "../scene/selection";
import { exportCanvas } from "../data/index";
import { getNonDeletedElements } from "../element";
import { t } from "../i18n";
export const actionCopy = register({
name: "copy",
perform: (elements, appState) => {
copyToClipboard(getNonDeletedElements(elements), appState);
return {
commitToHistory: false,
};
},
contextItemLabel: "labels.copy",
keyTest: (event) => event[KEYS.CTRL_OR_CMD] && event.code === CODES.C,
});
export const actionCut = register({
name: "cut",
perform: (elements, appState, data, app) => {
actionCopy.perform(elements, appState, data, app);
return actionDeleteSelected.perform(elements, appState, data, app);
},
contextItemLabel: "labels.cut",
keyTest: (event) => event[KEYS.CTRL_OR_CMD] && event.code === CODES.X,
});
export const actionCopyAsSvg = register({
name: "copyAsSvg",
perform: async (elements, appState, _data, app) => {
if (!app.canvas) {
return {
commitToHistory: false,
};
}
const selectedElements = getSelectedElements(
getNonDeletedElements(elements),
appState,
);
try {
await exportCanvas(
"clipboard-svg",
selectedElements.length
? selectedElements
: getNonDeletedElements(elements),
appState,
app.canvas,
appState,
);
return {
commitToHistory: false,
};
} catch (error) {
console.error(error);
return {
appState: {
...appState,
errorMessage: error.message,
},
commitToHistory: false,
};
}
},
contextItemLabel: "labels.copyAsSvg",
});
export const actionCopyAsPng = register({
name: "copyAsPng",
perform: async (elements, appState, _data, app) => {
if (!app.canvas) {
return {
commitToHistory: false,
};
}
const selectedElements = getSelectedElements(
getNonDeletedElements(elements),
appState,
);
try {
await exportCanvas(
"clipboard",
selectedElements.length
? selectedElements
: getNonDeletedElements(elements),
appState,
app.canvas,
appState,
);
return {
appState: {
...appState,
toastMessage: t("toast.copyToClipboardAsPng"),
},
commitToHistory: false,
};
} catch (error) {
console.error(error);
return {
appState: {
...appState,
errorMessage: error.message,
},
commitToHistory: false,
};
}
},
contextItemLabel: "labels.copyAsPng",
keyTest: (event) => event.code === CODES.C && event.altKey && event.shiftKey,
});

View File

@ -136,7 +136,6 @@ export const actionDeleteSelected = register({
};
},
contextItemLabel: "labels.delete",
contextMenuOrder: 999999,
keyTest: (event) => event.key === KEYS.BACKSPACE || event.key === KEYS.DELETE,
PanelComponent: ({ elements, appState, updateData }) => (
<ToolButton

View File

@ -1,19 +1,18 @@
import React from "react";
import { CODES } from "../keys";
import { t } from "../i18n";
import { register } from "./register";
import {
DistributeHorizontallyIcon,
DistributeVerticallyIcon,
} from "../components/icons";
import { getSelectedElements, isSomeElementSelected } from "../scene";
import { getElementMap, getNonDeletedElements } from "../element";
import { ToolButton } from "../components/ToolButton";
import { ExcalidrawElement } from "../element/types";
import { AppState } from "../types";
import { distributeElements, Distribution } from "../disitrubte";
import { getElementMap, getNonDeletedElements } from "../element";
import { ExcalidrawElement } from "../element/types";
import { t } from "../i18n";
import { CODES } from "../keys";
import { getSelectedElements, isSomeElementSelected } from "../scene";
import { AppState } from "../types";
import { getShortcutKey } from "../utils";
import { EVENT_ALIGN, trackEvent } from "../analytics";
import { register } from "./register";
const enableActionGroup = (
elements: readonly ExcalidrawElement[],
@ -40,7 +39,6 @@ const distributeSelectedElements = (
export const distributeHorizontally = register({
name: "distributeHorizontally",
perform: (elements, appState) => {
trackEvent(EVENT_ALIGN, "distribute", "horizontally");
return {
appState,
elements: distributeSelectedElements(elements, appState, {
@ -69,7 +67,6 @@ export const distributeHorizontally = register({
export const distributeVertically = register({
name: "distributeVertically",
perform: (elements, appState) => {
trackEvent(EVENT_ALIGN, "distribute", "vertically");
return {
appState,
elements: distributeSelectedElements(elements, appState, {

View File

@ -1,22 +1,20 @@
import React from "react";
import { EVENT_CHANGE, EVENT_IO, trackEvent } from "../analytics";
import { load, save, saveAs } from "../components/icons";
import { trackEvent } from "../analytics";
import { load, questionCircle, save, saveAs } from "../components/icons";
import { ProjectName } from "../components/ProjectName";
import { ToolButton } from "../components/ToolButton";
import "../components/ToolIcon.scss";
import { Tooltip } from "../components/Tooltip";
import { questionCircle } from "../components/icons";
import { loadFromJSON, saveAsJSON } from "../data";
import { t } from "../i18n";
import useIsMobile from "../is-mobile";
import { KEYS } from "../keys";
import { muteFSAbortError } from "../utils";
import { register } from "./register";
import "../components/ToolIcon.scss";
export const actionChangeProjectName = register({
name: "changeProjectName",
perform: (_elements, appState, value) => {
trackEvent(EVENT_CHANGE, "title");
trackEvent("change", "title");
return { appState: { ...appState, name: value }, commitToHistory: false };
},
PanelComponent: ({ appState, updateData }) => (
@ -100,7 +98,6 @@ export const actionSaveScene = register({
perform: async (elements, appState, value) => {
try {
const { fileHandle } = await saveAsJSON(elements, appState);
trackEvent(EVENT_IO, "save");
return { commitToHistory: false, appState: { ...appState, fileHandle } };
} catch (error) {
if (error?.name !== "AbortError") {
@ -131,7 +128,6 @@ export const actionSaveAsScene = register({
...appState,
fileHandle: null,
});
trackEvent(EVENT_IO, "save as");
return { commitToHistory: false, appState: { ...appState, fileHandle } };
} catch (error) {
if (error?.name !== "AbortError") {
@ -159,18 +155,29 @@ export const actionSaveAsScene = register({
export const actionLoadScene = register({
name: "loadScene",
perform: (
elements,
appState,
{ elements: loadedElements, appState: loadedAppState, error },
) => ({
elements: loadedElements,
appState: {
...loadedAppState,
errorMessage: error,
},
commitToHistory: true,
}),
perform: async (elements, appState) => {
try {
const {
elements: loadedElements,
appState: loadedAppState,
} = await loadFromJSON(appState);
return {
elements: loadedElements,
appState: loadedAppState,
commitToHistory: true,
};
} catch (error) {
if (error?.name === "AbortError") {
return false;
}
return {
elements,
appState: { ...appState, errorMessage: error.message },
commitToHistory: false,
};
}
},
keyTest: (event) => event[KEYS.CTRL_OR_CMD] && event.key === KEYS.O,
PanelComponent: ({ updateData, appState }) => (
<ToolButton
type="button"
@ -178,16 +185,7 @@ export const actionLoadScene = register({
title={t("buttons.load")}
aria-label={t("buttons.load")}
showAriaLabel={useIsMobile()}
onClick={() => {
loadFromJSON(appState)
.then(({ elements, appState }) => {
updateData({ elements, appState });
})
.catch(muteFSAbortError)
.catch((error) => {
updateData({ error: error.message });
});
}}
onClick={updateData}
/>
),
});

View File

@ -118,11 +118,14 @@ export const actionFinalize = register({
);
}
if (!appState.elementLocked) {
if (!appState.elementLocked && appState.elementType !== "draw") {
appState.selectedElementIds[multiPointElement.id] = true;
}
}
if (!appState.elementLocked || !multiPointElement) {
if (
(!appState.elementLocked && appState.elementType !== "draw") ||
!multiPointElement
) {
resetCursor();
}
return {
@ -130,7 +133,8 @@ export const actionFinalize = register({
appState: {
...appState,
elementType:
appState.elementLocked && multiPointElement
(appState.elementLocked || appState.elementType === "draw") &&
multiPointElement
? appState.elementType
: "selection",
draggingElement: null,
@ -139,7 +143,9 @@ export const actionFinalize = register({
startBoundElement: null,
suggestedBindings: [],
selectedElementIds:
multiPointElement && !appState.elementLocked
multiPointElement &&
!appState.elementLocked &&
appState.elementType !== "draw"
? {
...appState.selectedElementIds,
[multiPointElement.id]: true,

View File

@ -125,7 +125,6 @@ export const actionGroup = register({
commitToHistory: true,
};
},
contextMenuOrder: 4,
contextItemLabel: "labels.group",
contextItemPredicate: (elements, appState) =>
enableActionGroup(elements, appState),
@ -174,7 +173,6 @@ export const actionUngroup = register({
},
keyTest: (event) =>
event.shiftKey && event[KEYS.CTRL_OR_CMD] && event.code === CODES.G,
contextMenuOrder: 5,
contextItemLabel: "labels.ungroup",
contextItemPredicate: (elements, appState) =>
getSelectedGroupIds(appState).length > 0,

View File

@ -6,7 +6,7 @@ import { t } from "../i18n";
import { SceneHistory, HistoryEntry } from "../history";
import { ExcalidrawElement } from "../element/types";
import { AppState } from "../types";
import { KEYS } from "../keys";
import { isWindows, KEYS } from "../keys";
import { getElementMap } from "../element";
import { newElementWith } from "../element/mutateElement";
import { fixBindingsAfterDeletion } from "../element/binding";
@ -59,16 +59,16 @@ const writeData = (
return { commitToHistory };
};
const testUndo = (shift: boolean) => (event: KeyboardEvent) =>
event[KEYS.CTRL_OR_CMD] && /z/i.test(event.key) && event.shiftKey === shift;
type ActionCreator = (history: SceneHistory) => Action;
export const createUndoAction: ActionCreator = (history) => ({
name: "undo",
perform: (elements, appState) =>
writeData(elements, appState, () => history.undoOnce()),
keyTest: testUndo(false),
keyTest: (event) =>
event[KEYS.CTRL_OR_CMD] &&
event.key.toLowerCase() === KEYS.Z &&
!event.shiftKey,
PanelComponent: ({ updateData }) => (
<ToolButton
type="button"
@ -84,7 +84,11 @@ export const createRedoAction: ActionCreator = (history) => ({
name: "redo",
perform: (elements, appState) =>
writeData(elements, appState, () => history.redoOnce()),
keyTest: testUndo(true),
keyTest: (event) =>
(event[KEYS.CTRL_OR_CMD] &&
event.shiftKey &&
event.key.toLowerCase() === KEYS.Z) ||
(isWindows && event.ctrlKey && !event.shiftKey && event.key === KEYS.Y),
PanelComponent: ({ updateData }) => (
<ToolButton
type="button"

View File

@ -7,7 +7,6 @@ import { register } from "./register";
import { allowFullScreen, exitFullScreen, isFullScreen } from "../utils";
import { CODES, KEYS } from "../keys";
import { HelpIcon } from "../components/HelpIcon";
import { EVENT_DIALOG, trackEvent } from "../analytics";
export const actionToggleCanvasMenu = register({
name: "toggleCanvasMenu",
@ -72,17 +71,16 @@ export const actionFullScreen = register({
export const actionShortcuts = register({
name: "toggleShortcuts",
perform: (_elements, appState) => {
trackEvent(EVENT_DIALOG, "shortcuts");
return {
appState: {
...appState,
showShortcutsDialog: true,
showHelpDialog: !appState.showHelpDialog,
},
commitToHistory: false,
};
},
PanelComponent: ({ updateData }) => (
<HelpIcon title={t("shortcutsDialog.title")} onClick={updateData} />
<HelpIcon title={t("helpDialog.title")} onClick={updateData} />
),
keyTest: (event) => event.key === KEYS.QUESTION_MARK,
});

View File

@ -1,16 +1,14 @@
import React from "react";
import { Avatar } from "../components/Avatar";
import { register } from "./register";
import { getClientColors, getClientInitials } from "../clients";
import { Collaborator } from "../types";
import { Avatar } from "../components/Avatar";
import { centerScrollOn } from "../scene/scroll";
import { EVENT_SHARE, trackEvent } from "../analytics";
import { Collaborator } from "../types";
import { register } from "./register";
export const actionGoToCollaborator = register({
name: "goToCollaborator",
perform: (_elements, appState, value) => {
const point = value as Collaborator["pointer"];
trackEvent(EVENT_SHARE, "go to collaborator");
if (!point) {
return { appState, commitToHistory: false };
}

View File

@ -1,56 +1,53 @@
import React from "react";
import { getLanguage } from "../i18n";
import {
ExcalidrawElement,
ExcalidrawTextElement,
TextAlign,
FontFamily,
ExcalidrawLinearElement,
Arrowhead,
} from "../element/types";
import {
getCommonAttributeOfSelectedElements,
isSomeElementSelected,
getTargetElements,
canChangeSharpness,
canHaveArrowheads,
} from "../scene";
import { ButtonSelect } from "../components/ButtonSelect";
import { AppState } from "../../src/types";
import { ButtonIconSelect } from "../components/ButtonIconSelect";
import { ButtonSelect } from "../components/ButtonSelect";
import { ColorPicker } from "../components/ColorPicker";
import { IconPicker } from "../components/IconPicker";
import {
isTextElement,
redrawTextBoundingBox,
getNonDeletedElements,
} from "../element";
import { isLinearElement, isLinearElementType } from "../element/typeChecks";
import { ColorPicker } from "../components/ColorPicker";
import { AppState } from "../../src/types";
import { t } from "../i18n";
import { register } from "./register";
import { newElementWith } from "../element/mutateElement";
import { DEFAULT_FONT_SIZE, DEFAULT_FONT_FAMILY } from "../constants";
import { randomInteger } from "../random";
import {
FillHachureIcon,
FillCrossHatchIcon,
FillSolidIcon,
StrokeWidthIcon,
StrokeStyleSolidIcon,
StrokeStyleDashedIcon,
StrokeStyleDottedIcon,
EdgeSharpIcon,
EdgeRoundIcon,
SloppinessArchitectIcon,
SloppinessArtistIcon,
SloppinessCartoonistIcon,
ArrowheadArrowIcon,
ArrowheadBarIcon,
ArrowheadDotIcon,
ArrowheadNoneIcon,
EdgeRoundIcon,
EdgeSharpIcon,
FillCrossHatchIcon,
FillHachureIcon,
FillSolidIcon,
SloppinessArchitectIcon,
SloppinessArtistIcon,
SloppinessCartoonistIcon,
StrokeStyleDashedIcon,
StrokeStyleDottedIcon,
StrokeStyleSolidIcon,
StrokeWidthIcon,
} from "../components/icons";
import { EVENT_CHANGE, trackEvent } from "../analytics";
import colors from "../colors";
import { DEFAULT_FONT_FAMILY, DEFAULT_FONT_SIZE } from "../constants";
import {
getNonDeletedElements,
isTextElement,
redrawTextBoundingBox,
} from "../element";
import { newElementWith } from "../element/mutateElement";
import { isLinearElement, isLinearElementType } from "../element/typeChecks";
import {
Arrowhead,
ExcalidrawElement,
ExcalidrawLinearElement,
ExcalidrawTextElement,
FontFamily,
TextAlign,
} from "../element/types";
import { getLanguage, t } from "../i18n";
import { randomInteger } from "../random";
import {
canChangeSharpness,
canHaveArrowheads,
getCommonAttributeOfSelectedElements,
getTargetElements,
isSomeElementSelected,
} from "../scene";
import { register } from "./register";
const changeProperty = (
elements: readonly ExcalidrawElement[],
@ -92,15 +89,6 @@ const getFormValue = function <T>(
export const actionChangeStrokeColor = register({
name: "changeStrokeColor",
perform: (elements, appState, value) => {
if (value !== appState.currentItemStrokeColor) {
trackEvent(
EVENT_CHANGE,
"stroke color",
colors.elementStroke.includes(value)
? `${value} (picker ${colors.elementStroke.indexOf(value)})`
: value,
);
}
return {
elements: changeProperty(elements, appState, (el) =>
newElementWith(el, {
@ -132,16 +120,6 @@ export const actionChangeStrokeColor = register({
export const actionChangeBackgroundColor = register({
name: "changeBackgroundColor",
perform: (elements, appState, value) => {
if (value !== appState.currentItemBackgroundColor) {
trackEvent(
EVENT_CHANGE,
"background color",
colors.elementBackground.includes(value)
? `${value} (picker ${colors.elementBackground.indexOf(value)})`
: value,
);
}
return {
elements: changeProperty(elements, appState, (el) =>
newElementWith(el, {
@ -173,7 +151,6 @@ export const actionChangeBackgroundColor = register({
export const actionChangeFillStyle = register({
name: "changeFillStyle",
perform: (elements, appState, value) => {
trackEvent(EVENT_CHANGE, "fill", value);
return {
elements: changeProperty(elements, appState, (el) =>
newElementWith(el, {
@ -223,7 +200,6 @@ export const actionChangeFillStyle = register({
export const actionChangeStrokeWidth = register({
name: "changeStrokeWidth",
perform: (elements, appState, value) => {
trackEvent(EVENT_CHANGE, "stroke", "width", value);
return {
elements: changeProperty(elements, appState, (el) =>
newElementWith(el, {
@ -286,7 +262,6 @@ export const actionChangeStrokeWidth = register({
export const actionChangeSloppiness = register({
name: "changeSloppiness",
perform: (elements, appState, value) => {
trackEvent(EVENT_CHANGE, "stroke", "sloppiness", value);
return {
elements: changeProperty(elements, appState, (el) =>
newElementWith(el, {
@ -335,7 +310,6 @@ export const actionChangeSloppiness = register({
export const actionChangeStrokeStyle = register({
name: "changeStrokeStyle",
perform: (elements, appState, value) => {
trackEvent(EVENT_CHANGE, "style", value);
return {
elements: changeProperty(elements, appState, (el) =>
newElementWith(el, {
@ -383,7 +357,6 @@ export const actionChangeStrokeStyle = register({
export const actionChangeOpacity = register({
name: "changeOpacity",
perform: (elements, appState, value) => {
trackEvent(EVENT_CHANGE, "opacity", "value", value);
return {
elements: changeProperty(elements, appState, (el) =>
newElementWith(el, {
@ -580,7 +553,6 @@ export const actionChangeSharpness = register({
const shouldUpdateForLinearElements = targetElements.length
? targetElements.every(isLinearElement)
: isLinearElementType(appState.elementType);
trackEvent(EVENT_CHANGE, "edge", value);
return {
elements: changeProperty(elements, appState, (el) =>
newElementWith(el, {
@ -642,12 +614,6 @@ export const actionChangeArrowhead = register({
return {
elements: changeProperty(elements, appState, (el) => {
if (isLinearElement(el)) {
trackEvent(
EVENT_CHANGE,
`arrowhead ${value.position}`,
value.type || "none",
);
const { position, type } = value;
if (position === "start") {

View File

@ -4,6 +4,7 @@ import {
redrawTextBoundingBox,
} from "../element";
import { CODES, KEYS } from "../keys";
import { t } from "../i18n";
import { register } from "./register";
import { mutateElement, newElementWith } from "../element/mutateElement";
import {
@ -23,13 +24,16 @@ export const actionCopyStyles = register({
copiedStyles = JSON.stringify(element);
}
return {
appState: {
...appState,
toastMessage: t("toast.copyStyles"),
},
commitToHistory: false,
};
},
contextItemLabel: "labels.copyStyles",
keyTest: (event) =>
event[KEYS.CTRL_OR_CMD] && event.altKey && event.code === CODES.C,
contextMenuOrder: 0,
});
export const actionPasteStyles = register({
@ -69,5 +73,4 @@ export const actionPasteStyles = register({
contextItemLabel: "labels.pasteStyles",
keyTest: (event) =>
event[KEYS.CTRL_OR_CMD] && event.altKey && event.code === CODES.V,
contextMenuOrder: 1,
});

View File

@ -0,0 +1,22 @@
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",
perform(elements, appState) {
trackEvent("view", "mode", "grid");
return {
appState: {
...appState,
gridSize: this.checked!(appState) ? null : GRID_SIZE,
},
commitToHistory: false,
};
},
checked: (appState: AppState) => appState.gridSize !== null,
contextItemLabel: "labels.gridMode",
keyTest: (event) => event[KEYS.CTRL_OR_CMD] && event.code === CODES.QUOTE,
});

View File

@ -0,0 +1,16 @@
import { register } from "./register";
export const actionToggleStats = register({
name: "stats",
perform(elements, appState) {
return {
appState: {
...appState,
showStats: !this.checked!(appState),
},
commitToHistory: false,
};
},
checked: (appState) => appState.showStats,
contextItemLabel: "stats.title",
});

View File

@ -0,0 +1,22 @@
import { CODES, KEYS } from "../keys";
import { register } from "./register";
import { trackEvent } from "../analytics";
export const actionToggleViewMode = register({
name: "viewMode",
perform(elements, appState) {
trackEvent("view", "mode", "view");
return {
appState: {
...appState,
viewModeEnabled: !this.checked!(appState),
selectedElementIds: {},
},
commitToHistory: false,
};
},
checked: (appState) => appState.viewModeEnabled,
contextItemLabel: "labels.viewMode",
keyTest: (event) =>
!event[KEYS.CTRL_OR_CMD] && event.altKey && event.code === CODES.R,
});

View File

@ -0,0 +1,22 @@
import { CODES, KEYS } from "../keys";
import { register } from "./register";
import { trackEvent } from "../analytics";
export const actionToggleZenMode = register({
name: "zenMode",
perform(elements, appState) {
trackEvent("view", "mode", "zen");
return {
appState: {
...appState,
zenModeEnabled: !this.checked!(appState),
},
commitToHistory: false,
};
},
checked: (appState) => appState.zenModeEnabled,
contextItemLabel: "buttons.zenMode",
keyTest: (event) =>
!event[KEYS.CTRL_OR_CMD] && event.altKey && event.code === CODES.Z,
});

View File

@ -65,3 +65,15 @@ export {
distributeHorizontally,
distributeVertically,
} from "./actionDistribute";
export {
actionCopy,
actionCut,
actionCopyAsPng,
actionCopyAsSvg,
} from "./actionClipboard";
export { actionToggleGridMode } from "./actionToggleGridMode";
export { actionToggleZenMode } from "./actionToggleZenMode";
export { actionToggleStats } from "./actionToggleStats";

View File

@ -3,14 +3,15 @@ import {
Action,
ActionsManagerInterface,
UpdaterFn,
ActionFilterFn,
ActionName,
ActionResult,
} from "./types";
import { ExcalidrawElement } from "../element/types";
import { AppState } from "../types";
import { t } from "../i18n";
import { ShortcutName } from "./shortcuts";
import { AppState, ExcalidrawProps } from "../types";
// This is the <App> component, but for now we don't care about anything but its
// `canvas` state.
type App = { canvas: HTMLCanvasElement | null; props: ExcalidrawProps };
export class ActionManager implements ActionsManagerInterface {
actions = {} as ActionsManagerInterface["actions"];
@ -18,13 +19,14 @@ export class ActionManager implements ActionsManagerInterface {
updater: (actionResult: ActionResult | Promise<ActionResult>) => void;
getAppState: () => Readonly<AppState>;
getElementsIncludingDeleted: () => readonly ExcalidrawElement[];
app: App;
constructor(
updater: UpdaterFn,
getAppState: () => AppState,
getElementsIncludingDeleted: () => readonly ExcalidrawElement[],
app: App,
) {
this.updater = (actionResult) => {
if (actionResult && "then" in actionResult) {
@ -37,6 +39,7 @@ export class ActionManager implements ActionsManagerInterface {
};
this.getAppState = getAppState;
this.getElementsIncludingDeleted = getElementsIncludingDeleted;
this.app = app;
}
registerAction(action: Action) {
@ -63,6 +66,12 @@ export class ActionManager implements ActionsManagerInterface {
if (data.length === 0) {
return false;
}
const { viewModeEnabled } = this.getAppState();
if (viewModeEnabled) {
if (data[0].name !== "viewMode") {
return false;
}
}
event.preventDefault();
this.updater(
@ -70,6 +79,7 @@ export class ActionManager implements ActionsManagerInterface {
this.getElementsIncludingDeleted(),
this.getAppState(),
null,
this.app,
),
);
return true;
@ -81,43 +91,11 @@ export class ActionManager implements ActionsManagerInterface {
this.getElementsIncludingDeleted(),
this.getAppState(),
null,
this.app,
),
);
}
getContextMenuItems(actionFilter: ActionFilterFn = (action) => action) {
return Object.values(this.actions)
.filter(actionFilter)
.filter((action) => "contextItemLabel" in action)
.filter((action) =>
action.contextItemPredicate
? action.contextItemPredicate(
this.getElementsIncludingDeleted(),
this.getAppState(),
)
: true,
)
.sort(
(a, b) =>
(a.contextMenuOrder !== undefined ? a.contextMenuOrder : 999) -
(b.contextMenuOrder !== undefined ? b.contextMenuOrder : 999),
)
.map((action) => ({
// take last bit of the label "labels.<shortcutName>"
shortcutName: action.contextItemLabel?.split(".").pop() as ShortcutName,
label: action.contextItemLabel ? t(action.contextItemLabel) : "",
action: () => {
this.updater(
action.perform(
this.getElementsIncludingDeleted(),
this.getAppState(),
null,
),
);
},
}));
}
// Id is an attribute that we can use to pass in data like keys.
// This is needed for dynamically generated action components
// like the user list. We can use this key to extract more
@ -132,6 +110,7 @@ export class ActionManager implements ActionsManagerInterface {
this.getElementsIncludingDeleted(),
this.getAppState(),
formState,
this.app,
),
);
};

View File

@ -9,7 +9,7 @@ export type ShortcutName =
| "copyStyles"
| "pasteStyles"
| "selectAll"
| "delete"
| "deleteSelectedElements"
| "duplicateSelection"
| "sendBackward"
| "bringForward"
@ -20,8 +20,10 @@ export type ShortcutName =
| "group"
| "ungroup"
| "gridMode"
| "zenMode"
| "stats"
| "addToLibrary";
| "addToLibrary"
| "viewMode";
const shortcutMap: Record<ShortcutName, string[]> = {
cut: [getShortcutKey("CtrlOrCmd+X")],
@ -30,10 +32,10 @@ const shortcutMap: Record<ShortcutName, string[]> = {
copyStyles: [getShortcutKey("CtrlOrCmd+Alt+C")],
pasteStyles: [getShortcutKey("CtrlOrCmd+Alt+V")],
selectAll: [getShortcutKey("CtrlOrCmd+A")],
delete: [getShortcutKey("Del")],
deleteSelectedElements: [getShortcutKey("Del")],
duplicateSelection: [
getShortcutKey("CtrlOrCmd+D"),
getShortcutKey(`Alt+${t("shortcutsDialog.drag")}`),
getShortcutKey(`Alt+${t("helpDialog.drag")}`),
],
sendBackward: [getShortcutKey("CtrlOrCmd+[")],
bringForward: [getShortcutKey("CtrlOrCmd+]")],
@ -52,8 +54,10 @@ const shortcutMap: Record<ShortcutName, string[]> = {
group: [getShortcutKey("CtrlOrCmd+G")],
ungroup: [getShortcutKey("CtrlOrCmd+Shift+G")],
gridMode: [getShortcutKey("CtrlOrCmd+'")],
zenMode: [getShortcutKey("Alt+Z")],
stats: [],
addToLibrary: [],
viewMode: [getShortcutKey("Alt+R")],
};
export const getShortcutFromShortcutName = (name: ShortcutName) => {

View File

@ -16,12 +16,18 @@ type ActionFn = (
elements: readonly ExcalidrawElement[],
appState: Readonly<AppState>,
formData: any,
app: { canvas: HTMLCanvasElement | null },
) => ActionResult | Promise<ActionResult>;
export type UpdaterFn = (res: ActionResult) => void;
export type ActionFilterFn = (action: Action) => void;
export type ActionName =
| "copy"
| "cut"
| "paste"
| "copyAsPng"
| "copyAsSvg"
| "sendBackward"
| "bringForward"
| "sendToBack"
@ -29,6 +35,9 @@ export type ActionName =
| "copyStyles"
| "selectAll"
| "pasteStyles"
| "gridMode"
| "zenMode"
| "stats"
| "changeStrokeColor"
| "changeBackgroundColor"
| "changeFillStyle"
@ -75,7 +84,8 @@ export type ActionName =
| "alignVerticallyCentered"
| "alignHorizontallyCentered"
| "distributeHorizontally"
| "distributeVertically";
| "distributeVertically"
| "viewMode";
export interface Action {
name: ActionName;
@ -93,19 +103,16 @@ export interface Action {
elements: readonly ExcalidrawElement[],
) => boolean;
contextItemLabel?: string;
contextMenuOrder?: number;
contextItemPredicate?: (
elements: readonly ExcalidrawElement[],
appState: AppState,
) => boolean;
checked?: (appState: Readonly<AppState>) => boolean;
}
export interface ActionsManagerInterface {
actions: Record<ActionName, Action>;
registerAction: (action: Action) => void;
handleKeyDown: (event: KeyboardEvent) => boolean;
getContextMenuItems: (
actionFilter: ActionFilterFn,
) => { label: string; action: () => void }[];
renderAction: (name: ActionName) => React.ReactElement | null;
}

View File

@ -1,18 +1,8 @@
export const EVENT_ACTION = "action";
export const EVENT_ALIGN = "align";
export const EVENT_CHANGE = "change";
export const EVENT_DIALOG = "dialog";
export const EVENT_EXIT = "exit";
export const EVENT_IO = "io";
export const EVENT_LAYER = "layer";
export const EVENT_LIBRARY = "library";
export const EVENT_LOAD = "load";
export const EVENT_SHAPE = "shape";
export const EVENT_SHARE = "share";
export const EVENT_MAGIC = "magic";
export const trackEvent =
typeof window !== "undefined" && window.gtag
typeof process !== "undefined" &&
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,
@ -20,8 +10,9 @@ export const trackEvent =
value,
});
}
: typeof process !== "undefined" && process?.env?.JEST_WORKER_ID
: typeof process !== "undefined" && process.env?.JEST_WORKER_ID
? (category: string, name: string, label?: string, value?: number) => {}
: (category: string, name: string, label?: string, value?: number) => {
console.info("Track Event", category, name, label, value);
// Uncomment the next line to track locally
// console.info("Track Event", category, name, label, value);
};

View File

@ -1,12 +1,12 @@
import oc from "open-color";
import { AppState, FlooredNumber, NormalizedZoomValue } from "./types";
import { getDateTime } from "./utils";
import { t } from "./i18n";
import {
DEFAULT_FONT_SIZE,
DEFAULT_FONT_FAMILY,
DEFAULT_FONT_SIZE,
DEFAULT_TEXT_ALIGN,
} from "./constants";
import { t } from "./i18n";
import { AppState, NormalizedZoomValue } from "./types";
import { getDateTime } from "./utils";
export const getDefaultAppState = (): Omit<
AppState,
@ -14,66 +14,65 @@ export const getDefaultAppState = (): Omit<
> => {
return {
appearance: "light",
isLoading: false,
errorMessage: null,
collaborators: new Map(),
currentChartType: "bar",
currentItemBackgroundColor: "transparent",
currentItemEndArrowhead: "arrow",
currentItemFillStyle: "hachure",
currentItemFontFamily: DEFAULT_FONT_FAMILY,
currentItemFontSize: DEFAULT_FONT_SIZE,
currentItemLinearStrokeSharpness: "round",
currentItemOpacity: 100,
currentItemRoughness: 1,
currentItemStartArrowhead: null,
currentItemStrokeColor: oc.black,
currentItemStrokeSharpness: "sharp",
currentItemStrokeStyle: "solid",
currentItemStrokeWidth: 1,
currentItemTextAlign: DEFAULT_TEXT_ALIGN,
cursorButton: "up",
draggingElement: null,
resizingElement: null,
multiElement: null,
editingElement: null,
startBoundElement: null,
editingGroupId: null,
editingLinearElement: null,
elementType: "selection",
elementLocked: false,
elementType: "selection",
errorMessage: null,
exportBackground: true,
exportEmbedScene: false,
shouldAddWatermark: false,
currentItemStrokeColor: oc.black,
currentItemBackgroundColor: "transparent",
currentItemFillStyle: "hachure",
currentItemStrokeWidth: 1,
currentItemStrokeStyle: "solid",
currentItemRoughness: 1,
currentItemOpacity: 100,
currentItemFontSize: DEFAULT_FONT_SIZE,
currentItemFontFamily: DEFAULT_FONT_FAMILY,
currentItemTextAlign: DEFAULT_TEXT_ALIGN,
currentItemStrokeSharpness: "sharp",
currentItemLinearStrokeSharpness: "round",
currentItemStartArrowhead: null,
currentItemEndArrowhead: "arrow",
viewBackgroundColor: oc.white,
scrollX: 0 as FlooredNumber,
scrollY: 0 as FlooredNumber,
cursorX: 0,
cursorY: 0,
cursorButton: "up",
scrolledOutside: false,
name: `${t("labels.untitled")}-${getDateTime()}`,
fileHandle: null,
gridSize: null,
height: window.innerHeight,
isBindingEnabled: true,
isLibraryOpen: false,
isLoading: false,
isResizing: false,
isRotating: false,
selectionElement: null,
zoom: {
value: 1 as NormalizedZoomValue,
translation: { x: 0, y: 0 },
},
openMenu: null,
lastPointerDownWith: "mouse",
selectedElementIds: {},
multiElement: null,
name: `${t("labels.untitled")}-${getDateTime()}`,
openMenu: null,
pasteDialog: { shown: false, data: null },
previousSelectedElementIds: {},
shouldCacheIgnoreZoom: false,
showShortcutsDialog: false,
suggestedBindings: [],
zenModeEnabled: false,
showGrid: false,
editingGroupId: null,
resizingElement: null,
scrolledOutside: false,
scrollX: 0,
scrollY: 0,
selectedElementIds: {},
selectedGroupIds: {},
width: window.innerWidth,
height: window.innerHeight,
isLibraryOpen: false,
fileHandle: null,
collaborators: new Map(),
selectionElement: null,
shouldAddWatermark: false,
shouldCacheIgnoreZoom: false,
showHelpDialog: false,
showStats: false,
startBoundElement: null,
suggestedBindings: [],
toastMessage: null,
viewBackgroundColor: oc.white,
width: window.innerWidth,
zenModeEnabled: false,
zoom: { value: 1 as NormalizedZoomValue, translation: { x: 0, y: 0 } },
viewModeEnabled: false,
};
};
@ -93,26 +92,25 @@ const APP_STATE_STORAGE_CONF = (<
config: { [K in keyof T]: K extends keyof AppState ? T[K] : never },
) => config)({
appearance: { browser: true, export: false },
collaborators: { browser: false, export: false },
currentChartType: { browser: true, export: false },
currentItemBackgroundColor: { browser: true, export: false },
currentItemEndArrowhead: { browser: true, export: false },
currentItemFillStyle: { browser: true, export: false },
currentItemFontFamily: { browser: true, export: false },
currentItemFontSize: { browser: true, export: false },
currentItemLinearStrokeSharpness: { browser: true, export: false },
currentItemOpacity: { browser: true, export: false },
currentItemRoughness: { browser: true, export: false },
currentItemStartArrowhead: { browser: true, export: false },
currentItemStrokeColor: { browser: true, export: false },
currentItemStrokeSharpness: { browser: true, export: false },
currentItemStrokeStyle: { browser: true, export: false },
currentItemStrokeWidth: { browser: true, export: false },
currentItemTextAlign: { browser: true, export: false },
currentItemStrokeSharpness: { browser: true, export: false },
currentItemLinearStrokeSharpness: { browser: true, export: false },
currentItemStartArrowhead: { browser: true, export: false },
currentItemEndArrowhead: { browser: true, export: false },
cursorButton: { browser: true, export: false },
cursorX: { browser: true, export: false },
cursorY: { browser: true, export: false },
draggingElement: { browser: false, export: false },
editingElement: { browser: false, export: false },
startBoundElement: { browser: false, export: false },
editingGroupId: { browser: true, export: false },
editingLinearElement: { browser: false, export: false },
elementLocked: { browser: true, export: false },
@ -120,7 +118,8 @@ const APP_STATE_STORAGE_CONF = (<
errorMessage: { browser: false, export: false },
exportBackground: { browser: true, export: false },
exportEmbedScene: { browser: true, export: false },
showGrid: { browser: true, export: false },
fileHandle: { browser: false, export: false },
gridSize: { browser: true, export: true },
height: { browser: false, export: false },
isBindingEnabled: { browser: false, export: false },
isLibraryOpen: { browser: false, export: false },
@ -130,7 +129,10 @@ const APP_STATE_STORAGE_CONF = (<
lastPointerDownWith: { browser: true, export: false },
multiElement: { browser: false, export: false },
name: { browser: true, export: false },
offsetLeft: { browser: false, export: false },
offsetTop: { browser: false, export: false },
openMenu: { browser: true, export: false },
pasteDialog: { browser: false, export: false },
previousSelectedElementIds: { browser: true, export: false },
resizingElement: { browser: false, export: false },
scrolledOutside: { browser: true, export: false },
@ -141,17 +143,16 @@ const APP_STATE_STORAGE_CONF = (<
selectionElement: { browser: false, export: false },
shouldAddWatermark: { browser: true, export: false },
shouldCacheIgnoreZoom: { browser: true, export: false },
showShortcutsDialog: { browser: false, export: false },
showHelpDialog: { browser: false, export: false },
showStats: { browser: true, export: false },
startBoundElement: { browser: false, export: false },
suggestedBindings: { browser: false, export: false },
toastMessage: { browser: false, export: false },
viewBackgroundColor: { browser: true, export: true },
width: { browser: false, export: false },
zenModeEnabled: { browser: true, export: false },
zoom: { browser: true, export: false },
offsetTop: { browser: false, export: false },
offsetLeft: { browser: false, export: false },
fileHandle: { browser: false, export: false },
collaborators: { browser: false, export: false },
showStats: { browser: true, export: false },
viewModeEnabled: { browser: false, export: false },
});
const _clearAppStateForStorage = <ExportType extends "export" | "browser">(

View File

@ -1,13 +1,15 @@
import { EVENT_MAGIC, trackEvent } from "./analytics";
import colors from "./colors";
import { DEFAULT_FONT_FAMILY, DEFAULT_FONT_SIZE } from "./constants";
import { newElement, newTextElement, newLinearElement } from "./element";
import { ExcalidrawElement } from "./element/types";
import { DEFAULT_FONT_FAMILY, DEFAULT_FONT_SIZE, ENV } from "./constants";
import { newElement, newLinearElement, newTextElement } from "./element";
import { NonDeletedExcalidrawElement } from "./element/types";
import { randomId } from "./random";
export type ChartElements = readonly NonDeletedExcalidrawElement[];
const BAR_WIDTH = 32;
const BAR_GAP = 12;
const BAR_HEIGHT = 256;
const GRID_OPACITY = 50;
export interface Spreadsheet {
title: string | null;
@ -139,114 +141,48 @@ export const tryParseSpreadsheet = (text: string): ParseSpreadsheetResult => {
return transposedResults;
}
}
return result;
};
// For the maths behind it https://excalidraw.com/#json=6320864370884608,O_5xfD-Agh32tytHpRJx1g
export const renderSpreadsheet = (
const bgColors = colors.elementBackground.slice(
2,
colors.elementBackground.length,
);
// Put all the common properties here so when the whole chart is selected
// the properties dialog shows the correct selected values
const commonProps = {
fillStyle: "hachure",
fontFamily: DEFAULT_FONT_FAMILY,
fontSize: DEFAULT_FONT_SIZE,
opacity: 100,
roughness: 1,
strokeColor: colors.elementStroke[0],
strokeSharpness: "sharp",
strokeStyle: "solid",
strokeWidth: 1,
verticalAlign: "middle",
} as const;
const getChartDimentions = (spreadsheet: Spreadsheet) => {
const chartWidth =
(BAR_WIDTH + BAR_GAP) * spreadsheet.values.length + BAR_GAP;
const chartHeight = BAR_HEIGHT + BAR_GAP * 2;
return { chartWidth, chartHeight };
};
const chartXLabels = (
spreadsheet: Spreadsheet,
x: number,
y: number,
): ExcalidrawElement[] => {
const values = spreadsheet.values;
const max = Math.max(...values);
const chartHeight = BAR_HEIGHT + BAR_GAP * 2;
const chartWidth = (BAR_WIDTH + BAR_GAP) * values.length + BAR_GAP;
const maxColors = colors.elementBackground.length;
const bgColors = colors.elementBackground.slice(2, maxColors);
// Put all the common properties here so when the whole chart is selected
// the properties dialog shows the correct selected values
const commonProps = {
backgroundColor: bgColors[Math.floor(Math.random() * bgColors.length)],
fillStyle: "hachure",
fontFamily: DEFAULT_FONT_FAMILY,
fontSize: DEFAULT_FONT_SIZE,
groupIds: [randomId()],
opacity: 100,
roughness: 1,
strokeColor: colors.elementStroke[0],
strokeSharpness: "sharp",
strokeStyle: "solid",
strokeWidth: 1,
verticalAlign: "middle",
} as const;
const minYLabel = newTextElement({
...commonProps,
x: x - BAR_GAP,
y: y - BAR_GAP,
text: "0",
textAlign: "right",
});
const maxYLabel = newTextElement({
...commonProps,
x: x - BAR_GAP,
y: y - BAR_HEIGHT - minYLabel.height / 2,
text: max.toLocaleString(),
textAlign: "right",
});
const xAxisLine = newLinearElement({
type: "line",
x,
y,
startArrowhead: null,
endArrowhead: null,
width: chartWidth,
points: [
[0, 0],
[chartWidth, 0],
],
...commonProps,
});
const yAxisLine = newLinearElement({
type: "line",
x,
y,
startArrowhead: null,
endArrowhead: null,
height: chartHeight,
points: [
[0, 0],
[0, -chartHeight],
],
...commonProps,
});
const maxValueLine = newLinearElement({
type: "line",
x,
y: y - BAR_HEIGHT - BAR_GAP,
startArrowhead: null,
endArrowhead: null,
...commonProps,
strokeStyle: "dotted",
width: chartWidth,
points: [
[0, 0],
[chartWidth, 0],
],
});
const bars = values.map((value, index) => {
const barHeight = (value / max) * BAR_HEIGHT;
return newElement({
...commonProps,
type: "rectangle",
x: x + index * (BAR_WIDTH + BAR_GAP) + BAR_GAP,
y: y - barHeight - BAR_GAP,
width: BAR_WIDTH,
height: barHeight,
});
});
const xLabels =
groupId: string,
backgroundColor: string,
): ChartElements => {
return (
spreadsheet.labels?.map((label, index) => {
return newTextElement({
groupIds: [groupId],
backgroundColor,
...commonProps,
text: label.length > 8 ? `${label.slice(0, 5)}...` : label,
x: x + index * (BAR_WIDTH + BAR_GAP) + BAR_GAP * 2,
@ -257,29 +193,287 @@ export const renderSpreadsheet = (
textAlign: "center",
verticalAlign: "top",
});
}) || [];
}) || []
);
};
const chartYLabels = (
spreadsheet: Spreadsheet,
x: number,
y: number,
groupId: string,
backgroundColor: string,
): ChartElements => {
const minYLabel = newTextElement({
groupIds: [groupId],
backgroundColor,
...commonProps,
x: x - BAR_GAP,
y: y - BAR_GAP,
text: "0",
textAlign: "right",
});
const maxYLabel = newTextElement({
groupIds: [groupId],
backgroundColor,
...commonProps,
x: x - BAR_GAP,
y: y - BAR_HEIGHT - minYLabel.height / 2,
text: Math.max(...spreadsheet.values).toLocaleString(),
textAlign: "right",
});
return [minYLabel, maxYLabel];
};
const chartLines = (
spreadsheet: Spreadsheet,
x: number,
y: number,
groupId: string,
backgroundColor: string,
): ChartElements => {
const { chartWidth, chartHeight } = getChartDimentions(spreadsheet);
const xLine = newLinearElement({
backgroundColor,
groupIds: [groupId],
...commonProps,
type: "line",
x,
y,
startArrowhead: null,
endArrowhead: null,
width: chartWidth,
points: [
[0, 0],
[chartWidth, 0],
],
});
const yLine = newLinearElement({
backgroundColor,
groupIds: [groupId],
...commonProps,
type: "line",
x,
y,
startArrowhead: null,
endArrowhead: null,
height: chartHeight,
points: [
[0, 0],
[0, -chartHeight],
],
});
const maxLine = newLinearElement({
backgroundColor,
groupIds: [groupId],
...commonProps,
type: "line",
x,
y: y - BAR_HEIGHT - BAR_GAP,
startArrowhead: null,
endArrowhead: null,
strokeStyle: "dotted",
width: chartWidth,
opacity: GRID_OPACITY,
points: [
[0, 0],
[chartWidth, 0],
],
});
return [xLine, yLine, maxLine];
};
// For the maths behind it https://excalidraw.com/#json=6320864370884608,O_5xfD-Agh32tytHpRJx1g
const chartBaseElements = (
spreadsheet: Spreadsheet,
x: number,
y: number,
groupId: string,
backgroundColor: string,
debug?: boolean,
): ChartElements => {
const { chartWidth, chartHeight } = getChartDimentions(spreadsheet);
const title = spreadsheet.title
? newTextElement({
backgroundColor,
groupIds: [groupId],
...commonProps,
text: spreadsheet.title,
x: x + chartWidth / 2,
y: y - BAR_HEIGHT - BAR_GAP * 2 - maxYLabel.height,
y: y - BAR_HEIGHT - BAR_GAP * 2 - DEFAULT_FONT_SIZE,
strokeSharpness: "sharp",
strokeStyle: "solid",
textAlign: "center",
})
: null;
trackEvent(EVENT_MAGIC, "chart", "bars", bars.length);
const debugRect = debug
? newElement({
backgroundColor,
groupIds: [groupId],
...commonProps,
type: "rectangle",
x,
y: y - chartHeight,
width: chartWidth,
height: chartHeight,
strokeColor: colors.elementStroke[0],
fillStyle: "solid",
opacity: 6,
})
: null;
return [
title,
...bars,
...xLabels,
xAxisLine,
yAxisLine,
maxValueLine,
minYLabel,
maxYLabel,
].filter((element) => element !== null) as ExcalidrawElement[];
...(debugRect ? [debugRect] : []),
...(title ? [title] : []),
...chartXLabels(spreadsheet, x, y, groupId, backgroundColor),
...chartYLabels(spreadsheet, x, y, groupId, backgroundColor),
...chartLines(spreadsheet, x, y, groupId, backgroundColor),
];
};
const chartTypeBar = (
spreadsheet: Spreadsheet,
x: number,
y: number,
): ChartElements => {
const max = Math.max(...spreadsheet.values);
const groupId = randomId();
const backgroundColor = bgColors[Math.floor(Math.random() * bgColors.length)];
const bars = spreadsheet.values.map((value, index) => {
const barHeight = (value / max) * BAR_HEIGHT;
return newElement({
backgroundColor,
groupIds: [groupId],
...commonProps,
type: "rectangle",
x: x + index * (BAR_WIDTH + BAR_GAP) + BAR_GAP,
y: y - barHeight - BAR_GAP,
width: BAR_WIDTH,
height: barHeight,
});
});
return [
...bars,
...chartBaseElements(
spreadsheet,
x,
y,
groupId,
backgroundColor,
process.env.NODE_ENV === ENV.DEVELOPMENT,
),
];
};
const chartTypeLine = (
spreadsheet: Spreadsheet,
x: number,
y: number,
): ChartElements => {
const max = Math.max(...spreadsheet.values);
const groupId = randomId();
const backgroundColor = bgColors[Math.floor(Math.random() * bgColors.length)];
let index = 0;
const points = [];
for (const value of spreadsheet.values) {
const cx = index * (BAR_WIDTH + BAR_GAP);
const cy = -(value / max) * BAR_HEIGHT;
points.push([cx, cy]);
index++;
}
const maxX = Math.max(...points.map((element) => element[0]));
const maxY = Math.max(...points.map((element) => element[1]));
const minX = Math.min(...points.map((element) => element[0]));
const minY = Math.min(...points.map((element) => element[1]));
const line = newLinearElement({
backgroundColor,
groupIds: [groupId],
...commonProps,
type: "line",
x: x + BAR_GAP + BAR_WIDTH / 2,
y: y - BAR_GAP,
startArrowhead: null,
endArrowhead: null,
height: maxY - minY,
width: maxX - minX,
strokeWidth: 2,
points: points as any,
});
const dots = spreadsheet.values.map((value, index) => {
const cx = index * (BAR_WIDTH + BAR_GAP) + BAR_GAP / 2;
const cy = -(value / max) * BAR_HEIGHT + BAR_GAP / 2;
return newElement({
backgroundColor,
groupIds: [groupId],
...commonProps,
fillStyle: "solid",
strokeWidth: 2,
type: "ellipse",
x: x + cx + BAR_WIDTH / 2,
y: y + cy - BAR_GAP * 2,
width: BAR_GAP,
height: BAR_GAP,
});
});
const lines = spreadsheet.values.map((value, index) => {
const cx = index * (BAR_WIDTH + BAR_GAP) + BAR_GAP / 2;
const cy = (value / max) * BAR_HEIGHT + BAR_GAP / 2 + BAR_GAP;
return newLinearElement({
backgroundColor,
groupIds: [groupId],
...commonProps,
type: "line",
x: x + cx + BAR_WIDTH / 2 + BAR_GAP / 2,
y: y - cy,
startArrowhead: null,
endArrowhead: null,
height: cy,
strokeStyle: "dotted",
opacity: GRID_OPACITY,
points: [
[0, 0],
[0, cy],
],
});
});
return [
...chartBaseElements(
spreadsheet,
x,
y,
groupId,
backgroundColor,
process.env.NODE_ENV === ENV.DEVELOPMENT,
),
line,
...lines,
...dots,
];
};
export const renderSpreadsheet = (
chartType: string,
spreadsheet: Spreadsheet,
x: number,
y: number,
): ChartElements => {
if (chartType === "line") {
return chartTypeLine(spreadsheet, x, y);
}
return chartTypeBar(spreadsheet, x, y);
};

View File

@ -1,23 +1,22 @@
import React from "react";
import { AppState, Zoom } from "../types";
import { ExcalidrawElement } from "../element/types";
import { ActionManager } from "../actions/manager";
import { getNonDeletedElements } from "../element";
import { ExcalidrawElement } from "../element/types";
import { t } from "../i18n";
import useIsMobile from "../is-mobile";
import {
hasBackground,
hasStroke,
canChangeSharpness,
hasText,
canHaveArrowheads,
getTargetElements,
hasBackground,
hasStroke,
hasText,
} from "../scene";
import { t } from "../i18n";
import { SHAPES } from "../shapes";
import { ToolButton } from "./ToolButton";
import { AppState, Zoom } from "../types";
import { capitalizeString, isTransparent, setCursorForShape } from "../utils";
import Stack from "./Stack";
import useIsMobile from "../is-mobile";
import { getNonDeletedElements } from "../element";
import { trackEvent, EVENT_SHAPE, EVENT_DIALOG } from "../analytics";
import { ToolButton } from "./ToolButton";
export const SelectedShapeActions = ({
appState,
@ -164,9 +163,9 @@ export const ShapesSwitcher = ({
{SHAPES.map(({ value, icon, key }, index) => {
const label = t(`toolBar.${value}`);
const letter = typeof key === "string" ? key : key[0];
const shortcut = `${capitalizeString(letter)} ${t(
"shortcutsDialog.or",
)} ${index + 1}`;
const shortcut = `${capitalizeString(letter)} ${t("helpDialog.or")} ${
index + 1
}`;
return (
<ToolButton
className="Shape"
@ -181,7 +180,6 @@ export const ShapesSwitcher = ({
aria-keyshortcuts={shortcut}
data-testid={value}
onChange={() => {
trackEvent(EVENT_SHAPE, value, "toolbar");
setAppState({
elementType: value,
multiElement: null,
@ -203,9 +201,6 @@ export const ShapesSwitcher = ({
title={`${capitalizeString(t("toolBar.library"))} — 9`}
aria-label={capitalizeString(t("toolBar.library"))}
onClick={() => {
if (!isLibraryOpen) {
trackEvent(EVENT_DIALOG, "library");
}
setAppState({ isLibraryOpen: !isLibraryOpen });
}}
/>

File diff suppressed because it is too large Load Diff

View File

@ -1,4 +1,4 @@
@import "../css/_variables";
@import "../css/variables.module";
.excalidraw {
.Avatar {

View File

@ -1,6 +1,5 @@
import React from "react";
import { ActionManager } from "../actions/manager";
import { EVENT_CHANGE, trackEvent } from "../analytics";
import { AppState } from "../types";
import { DarkModeToggle } from "./DarkModeToggle";
@ -19,8 +18,6 @@ export const BackgroundPickerAndDarkModeToggle = ({
<DarkModeToggle
value={appState.appearance}
onChange={(appearance) => {
// TODO: track the theme on the first load too
trackEvent(EVENT_CHANGE, "theme", appearance);
setAppState({ appearance });
}}
/>

View File

@ -1,4 +1,4 @@
@import "../css/_variables";
@import "../css/variables.module";
.excalidraw {
.CollabButton.is-collaborating {

View File

@ -6,7 +6,6 @@ import useIsMobile from "../is-mobile";
import { users } from "./icons";
import "./CollabButton.scss";
import { EVENT_DIALOG, trackEvent } from "../analytics";
const CollabButton = ({
isCollaborating,
@ -23,10 +22,7 @@ const CollabButton = ({
className={clsx("CollabButton", {
"is-collaborating": isCollaborating,
})}
onClick={() => {
trackEvent(EVENT_DIALOG, "collaboration");
onClick();
}}
onClick={onClick}
icon={users}
type="button"
title={t("buttons.roomDialog")}

View File

@ -1,4 +1,4 @@
@import "../css/_variables";
@import "../css/variables.module";
.excalidraw {
.color-picker {
@ -218,7 +218,7 @@
left: 2px;
}
@media #{$media-query} {
@media #{$is-mobile-query} {
display: none;
}
}

View File

@ -1,4 +1,4 @@
@import "open-color/open-color.scss";
@import "../css/variables.module";
.excalidraw {
.context-menu {
@ -9,9 +9,10 @@
list-style: none;
user-select: none;
margin: -0.25rem 0 0 0.125rem;
padding: 0.25rem 0;
padding: 0.5rem 0;
background-color: var(--popup-secondary-background-color);
border: 1px solid var(--button-gray-3);
cursor: default;
}
.context-menu button {
@ -42,18 +43,19 @@
}
&.dangerous {
div:nth-child(1) {
.context-menu-option__label {
color: $oc-red-7;
}
}
div:nth-child(1) {
.context-menu-option__label {
justify-self: start;
margin-inline-end: 20px;
}
div:nth-child(2) {
.context-menu-option__shortcut {
justify-self: end;
opacity: 0.6;
font-family: inherit;
font-size: 0.7rem;
}
}
@ -63,7 +65,7 @@
background-color: var(--select-highlight-color);
&.dangerous {
div:nth-child(1) {
.context-menu-option__label {
color: var(--popup-background-color);
}
background-color: $oc-red-6;
@ -73,4 +75,23 @@
.context-menu-option:focus {
z-index: 1;
}
@media #{$is-mobile-query} {
.context-menu-option {
display: block;
.context-menu-option__label {
margin-inline-end: 0;
}
.context-menu-option__shortcut {
display: none;
}
}
}
.context-menu-option-separator {
border: none;
border-top: 1px solid $oc-gray-5;
}
}

View File

@ -2,28 +2,36 @@ import React from "react";
import { render, unmountComponentAtNode } from "react-dom";
import clsx from "clsx";
import { Popover } from "./Popover";
import { t } from "../i18n";
import "./ContextMenu.scss";
import {
getShortcutFromShortcutName,
ShortcutName,
} from "../actions/shortcuts";
import { Action } from "../actions/types";
import { ActionManager } from "../actions/manager";
import { AppState } from "../types";
type ContextMenuOption = {
checked?: boolean;
shortcutName: ShortcutName;
label: string;
action(): void;
};
export type ContextMenuOption = "separator" | Action;
type Props = {
type ContextMenuProps = {
options: ContextMenuOption[];
onCloseRequest?(): void;
top: number;
left: number;
actionManager: ActionManager;
appState: Readonly<AppState>;
};
const ContextMenu = ({ options, onCloseRequest, top, left }: Props) => {
const ContextMenu = ({
options,
onCloseRequest,
top,
left,
actionManager,
appState,
}: ContextMenuProps) => {
const isDarkTheme = !!document
.querySelector(".excalidraw")
?.classList.contains("Appearance_dark");
@ -43,23 +51,34 @@ const ContextMenu = ({ options, onCloseRequest, top, left }: Props) => {
className="context-menu"
onContextMenu={(event) => event.preventDefault()}
>
{options.map(({ action, checked, shortcutName, label }, idx) => (
<li data-testid={shortcutName} key={idx} onClick={onCloseRequest}>
<button
className={`context-menu-option
${shortcutName === "delete" ? "dangerous" : ""}
${checked ? "checkmark" : ""}`}
onClick={action}
>
<div>{label}</div>
<div>
{shortcutName
? getShortcutFromShortcutName(shortcutName)
: ""}
</div>
</button>
</li>
))}
{options.map((option, idx) => {
if (option === "separator") {
return <hr key={idx} className="context-menu-option-separator" />;
}
const actionName = option.name;
const label = option.contextItemLabel
? t(option.contextItemLabel)
: "";
return (
<li key={idx} data-testid={actionName} onClick={onCloseRequest}>
<button
className={clsx("context-menu-option", {
dangerous: actionName === "deleteSelectedElements",
checkmark: option.checked?.(appState),
})}
onClick={() => actionManager.executeAction(option)}
>
<div className="context-menu-option__label">{label}</div>
<kbd className="context-menu-option__shortcut">
{actionName
? getShortcutFromShortcutName(actionName as ShortcutName)
: ""}
</kbd>
</button>
</li>
);
})}
</ul>
</Popover>
</div>
@ -78,8 +97,10 @@ const getContextMenuNode = (): HTMLDivElement => {
type ContextMenuParams = {
options: (ContextMenuOption | false | null | undefined)[];
top: number;
left: number;
top: ContextMenuProps["top"];
left: ContextMenuProps["left"];
actionManager: ContextMenuProps["actionManager"];
appState: Readonly<AppState>;
};
const handleClose = () => {
@ -101,6 +122,8 @@ export default {
left={params.left}
options={options}
onCloseRequest={handleClose}
actionManager={params.actionManager}
appState={params.appState}
/>,
getContextMenuNode(),
);

View File

@ -1,12 +1,21 @@
@import "../css/_variables";
@import "../css/variables.module";
.excalidraw {
.Dialog {
user-select: text;
cursor: auto;
}
.Dialog__title {
display: grid;
align-items: center;
margin-top: 0;
grid-template-columns: 1fr calc(var(--space-factor) * 7);
grid-gap: var(--metric);
padding: calc(var(--space-factor) * 2);
text-align: center;
font-variant: small-caps;
font-size: 1.2em;
}
.Dialog__titleContent {
@ -18,7 +27,11 @@
margin: 0;
}
@media #{$media-query} {
.Dialog__content {
padding: 0 16px 16px;
}
@media #{$is-mobile-query} {
.Dialog {
--metric: calc(var(--space-factor) * 4);
--inset-left: #{"max(var(--metric), var(--sal))"};
@ -30,13 +43,8 @@
var(--space-factor) * 7
);
position: sticky;
top: calc(-1 * var(--metric));
margin: calc(-1 * var(--inset-right));
margin-top: calc(-1 * var(--metric));
margin-bottom: var(--metric);
top: 0;
padding: calc(var(--space-factor) * 2);
padding-left: var(--inset-left);
padding-right: var(--inset-right);
background: var(--bg-color-island);
font-size: 1.25em;

View File

@ -1,30 +1,23 @@
import React, { useCallback, useEffect, useState } from "react";
import clsx from "clsx";
import { Modal } from "./Modal";
import { Island } from "./Island";
import React, { useEffect } from "react";
import { useCallbackRefState } from "../hooks/useCallbackRefState";
import { t } from "../i18n";
import useIsMobile from "../is-mobile";
import { back, close } from "./icons";
import { KEYS } from "../keys";
import "./Dialog.scss";
const useRefState = <T,>() => {
const [refValue, setRefValue] = useState<T | null>(null);
const refCallback = useCallback((value: T) => {
setRefValue(value);
}, []);
return [refValue, refCallback] as const;
};
import { back, close } from "./icons";
import { Island } from "./Island";
import { Modal } from "./Modal";
export const Dialog = (props: {
children: React.ReactNode;
className?: string;
maxWidth?: number;
small?: boolean;
onCloseRequest(): void;
title: React.ReactNode;
autofocus?: boolean;
}) => {
const [islandNode, setIslandNode] = useRefState<HTMLDivElement>();
const [islandNode, setIslandNode] = useCallbackRefState<HTMLDivElement>();
useEffect(() => {
if (!islandNode) {
@ -33,7 +26,7 @@ export const Dialog = (props: {
const focusableElements = queryFocusableElements(islandNode);
if (focusableElements.length > 0) {
if (focusableElements.length > 0 && props.autofocus !== false) {
// If there's an element other than close, focus it.
(focusableElements[1] || focusableElements[0]).focus();
}
@ -62,7 +55,7 @@ export const Dialog = (props: {
islandNode.addEventListener("keydown", handleKeyDown);
return () => islandNode.removeEventListener("keydown", handleKeyDown);
}, [islandNode]);
}, [islandNode, props.autofocus]);
const queryFocusableElements = (node: HTMLElement) => {
const focusableElements = node.querySelectorAll<HTMLElement>(
@ -76,10 +69,10 @@ export const Dialog = (props: {
<Modal
className={clsx("Dialog", props.className)}
labelledBy="dialog-title"
maxWidth={props.maxWidth}
maxWidth={props.small ? 550 : 800}
onCloseRequest={props.onCloseRequest}
>
<Island padding={4} ref={setIslandNode}>
<Island ref={setIslandNode}>
<h2 id="dialog-title" className="Dialog__title">
<span className="Dialog__titleContent">{props.title}</span>
<button
@ -90,7 +83,7 @@ export const Dialog = (props: {
{useIsMobile() ? back : close}
</button>
</h2>
{props.children}
<div className="Dialog__content">{props.children}</div>
</Island>
</Modal>
);

View File

@ -24,7 +24,7 @@ export const ErrorDialog = ({
<>
{modalIsShown && (
<Dialog
maxWidth={500}
small
onCloseRequest={handleClose}
title={t("errorDialog.title")}
>

View File

@ -1,4 +1,4 @@
@import "../css/_variables";
@import "../css/variables.module";
.excalidraw {
.ExportDialog__preview {
@ -37,7 +37,7 @@
}
}
@media (max-width: 550px) {
@media #{$is-mobile-query} {
.ExportDialog {
display: flex;
flex-direction: column;
@ -51,9 +51,7 @@
.ExportDialog__actions > * {
margin-bottom: calc(var(--space-factor) * 3);
}
}
@media #{$media-query} {
.ExportDialog__preview canvas {
max-height: 30vh;
}

View File

@ -1,7 +1,6 @@
import React, { useEffect, useRef, useState } from "react";
import { render, unmountComponentAtNode } from "react-dom";
import { ActionsManagerInterface } from "../actions/types";
import { EVENT_DIALOG, trackEvent } from "../analytics";
import { probablySupportsClipboardBlob } from "../clipboard";
import { canvasToBlob } from "../data/blob";
import { NonDeletedExcalidrawElement } from "../element/types";
@ -251,7 +250,6 @@ export const ExportDialog = ({
<>
<ToolButton
onClick={() => {
trackEvent(EVENT_DIALOG, "export");
setModalIsShown(true);
}}
icon={exportFile}
@ -262,11 +260,7 @@ export const ExportDialog = ({
ref={triggerButton}
/>
{modalIsShown && (
<Dialog
maxWidth={800}
onCloseRequest={handleClose}
title={t("buttons.export")}
>
<Dialog onCloseRequest={handleClose} title={t("buttons.export")}>
<ExportModal
elements={elements}
appState={appState}

View File

@ -1,6 +1,5 @@
import React from "react";
import oc from "open-color";
import { EVENT_EXIT, trackEvent } from "../analytics";
import React from "react";
// https://github.com/tholman/github-corners
export const GitHubCorner = React.memo(
@ -17,9 +16,6 @@ export const GitHubCorner = React.memo(
target="_blank"
rel="noopener noreferrer"
aria-label="GitHub repository"
onClick={() => {
trackEvent(EVENT_EXIT, "github");
}}
>
<path
d="M0 0l115 115h15l12 27 108 108V0z"

View File

@ -1,23 +1,28 @@
@import "../css/_variables";
@import "../css/variables.module";
.excalidraw {
.ShortcutsDialog-island {
.HelpDialog h3 {
border-bottom: 1px solid var(--button-gray-2);
padding-bottom: 4px;
}
.HelpDialog--island {
border: 1px solid var(--button-gray-2);
margin-bottom: 16px;
}
.ShortcutsDialog-island-title {
.HelpDialog--island-title {
margin: 0;
padding: 4px;
background-color: var(--button-gray-1);
text-align: center;
}
.ShorcutsDialog-shortcut {
.HelpDialog--shortcut {
border-top: 1px solid var(--button-gray-2);
}
.ShorcutsDialog-key {
.HelpDialog--key {
word-break: keep-all;
border: 1px solid var(--button-gray-2);
padding: 2px 8px;
@ -29,14 +34,23 @@
box-sizing: border-box;
display: flex;
align-items: center;
font-family: inherit;
}
.ShortcutsDialog-footer {
.HelpDialog--header {
display: flex;
flex-direction: row;
justify-content: space-evenly;
border-top: 1px solid var(--button-gray-2);
margin-top: 8px;
padding-top: 16px;
margin-bottom: 32px;
padding-bottom: 16px;
}
.HelpDialog--btn {
border: 1px solid var(--link-color);
padding: 8px 32px;
border-radius: 4px;
}
.HelpDialog--btn:hover {
text-decoration: none;
}
}

View File

@ -0,0 +1,359 @@
import React from "react";
import { t } from "../i18n";
import { isDarwin, isWindows } from "../keys";
import { Dialog } from "./Dialog";
import { getShortcutKey } from "../utils";
import "./HelpDialog.scss";
const Header = () => (
<div className="HelpDialog--header">
<a
className="HelpDialog--btn"
href="https://github.com/excalidraw/excalidraw#documentation"
target="_blank"
rel="noopener noreferrer"
>
{t("helpDialog.documentation")}
</a>
<a
className="HelpDialog--btn"
href="https://blog.excalidraw.com"
target="_blank"
rel="noopener noreferrer"
>
{t("helpDialog.blog")}
</a>
<a
className="HelpDialog--btn"
href="https://github.com/excalidraw/excalidraw/issues"
target="_blank"
rel="noopener noreferrer"
>
{t("helpDialog.github")}
</a>
</div>
);
const Section = (props: { title: string; children: React.ReactNode }) => (
<>
<h3>{props.title}</h3>
{props.children}
</>
);
const Columns = (props: { children: React.ReactNode }) => (
<div
style={{
display: "flex",
flexDirection: "row",
flexWrap: "wrap",
justifyContent: "space-between",
}}
>
{props.children}
</div>
);
const Column = (props: { children: React.ReactNode }) => (
<div style={{ width: "49%" }}>{props.children}</div>
);
const ShortcutIsland = (props: {
caption: string;
children: React.ReactNode;
}) => (
<div className="HelpDialog--island">
<h3 className="HelpDialog--island-title">{props.caption}</h3>
{props.children}
</div>
);
const Shortcut = (props: {
label: string;
shortcuts: string[];
isOr: boolean;
}) => {
return (
<div className="HelpDialog--shortcut">
<div
style={{
display: "flex",
margin: "0",
padding: "4px 8px",
alignItems: "center",
}}
>
<div
style={{
lineHeight: 1.4,
}}
>
{props.label}
</div>
<div
style={{
display: "flex",
flex: "0 0 auto",
justifyContent: "flex-end",
marginInlineStart: "auto",
minWidth: "30%",
}}
>
{props.shortcuts.map((shortcut, index) => (
<React.Fragment key={index}>
<ShortcutKey>{shortcut}</ShortcutKey>
{props.isOr &&
index !== props.shortcuts.length - 1 &&
t("helpDialog.or")}
</React.Fragment>
))}
</div>
</div>
</div>
);
};
Shortcut.defaultProps = {
isOr: true,
};
const ShortcutKey = (props: { children: React.ReactNode }) => (
<kbd className="HelpDialog--key" {...props} />
);
export const HelpDialog = ({ onClose }: { onClose?: () => void }) => {
const handleClose = React.useCallback(() => {
if (onClose) {
onClose();
}
}, [onClose]);
return (
<>
<Dialog
onCloseRequest={handleClose}
title={t("helpDialog.title")}
className={"HelpDialog"}
>
<Header />
<Section title={t("helpDialog.shortcuts")}>
<Columns>
<Column>
<ShortcutIsland caption={t("helpDialog.shapes")}>
<Shortcut
label={t("toolBar.selection")}
shortcuts={["V", "1"]}
/>
<Shortcut
label={t("toolBar.rectangle")}
shortcuts={["R", "2"]}
/>
<Shortcut label={t("toolBar.diamond")} shortcuts={["D", "3"]} />
<Shortcut label={t("toolBar.ellipse")} shortcuts={["E", "4"]} />
<Shortcut label={t("toolBar.arrow")} shortcuts={["A", "5"]} />
<Shortcut label={t("toolBar.line")} shortcuts={["P", "6"]} />
<Shortcut
label={t("toolBar.draw")}
shortcuts={["Shift+P", "7"]}
/>
<Shortcut label={t("toolBar.text")} shortcuts={["T", "8"]} />
<Shortcut
label={t("helpDialog.textNewLine")}
shortcuts={[
getShortcutKey("Enter"),
getShortcutKey("Shift+Enter"),
]}
/>
<Shortcut
label={t("helpDialog.textFinish")}
shortcuts={[
getShortcutKey("Esc"),
getShortcutKey("CtrlOrCmd+Enter"),
]}
/>
<Shortcut
label={t("helpDialog.curvedArrow")}
shortcuts={[
"A",
t("helpDialog.click"),
t("helpDialog.click"),
t("helpDialog.click"),
]}
isOr={false}
/>
<Shortcut
label={t("helpDialog.curvedLine")}
shortcuts={[
"L",
t("helpDialog.click"),
t("helpDialog.click"),
t("helpDialog.click"),
]}
isOr={false}
/>
<Shortcut label={t("toolBar.lock")} shortcuts={["Q"]} />
<Shortcut
label={t("helpDialog.preventBinding")}
shortcuts={[getShortcutKey("CtrlOrCmd")]}
/>
</ShortcutIsland>
<ShortcutIsland caption={t("helpDialog.view")}>
<Shortcut
label={t("buttons.zoomIn")}
shortcuts={[getShortcutKey("CtrlOrCmd++")]}
/>
<Shortcut
label={t("buttons.zoomOut")}
shortcuts={[getShortcutKey("CtrlOrCmd+-")]}
/>
<Shortcut
label={t("buttons.resetZoom")}
shortcuts={[getShortcutKey("CtrlOrCmd+0")]}
/>
<Shortcut
label={t("helpDialog.zoomToFit")}
shortcuts={["Shift+1"]}
/>
<Shortcut
label={t("helpDialog.zoomToSelection")}
shortcuts={["Shift+2"]}
/>
<Shortcut label={t("buttons.fullScreen")} shortcuts={["F"]} />
<Shortcut
label={t("buttons.zenMode")}
shortcuts={[getShortcutKey("Alt+Z")]}
/>
<Shortcut
label={t("labels.gridMode")}
shortcuts={[getShortcutKey("CtrlOrCmd+'")]}
/>
<Shortcut
label={t("labels.viewMode")}
shortcuts={[getShortcutKey("Alt+R")]}
/>
</ShortcutIsland>
</Column>
<Column>
<ShortcutIsland caption={t("helpDialog.editor")}>
<Shortcut
label={t("labels.selectAll")}
shortcuts={[getShortcutKey("CtrlOrCmd+A")]}
/>
<Shortcut
label={t("labels.multiSelect")}
shortcuts={[getShortcutKey(`Shift+${t("helpDialog.click")}`)]}
/>
<Shortcut
label={t("labels.moveCanvas")}
shortcuts={[
getShortcutKey(`Space+${t("helpDialog.drag")}`),
getShortcutKey(`Wheel+${t("helpDialog.drag")}`),
]}
isOr={true}
/>
<Shortcut
label={t("labels.cut")}
shortcuts={[getShortcutKey("CtrlOrCmd+X")]}
/>
<Shortcut
label={t("labels.copy")}
shortcuts={[getShortcutKey("CtrlOrCmd+C")]}
/>
<Shortcut
label={t("labels.paste")}
shortcuts={[getShortcutKey("CtrlOrCmd+V")]}
/>
<Shortcut
label={t("labels.copyAsPng")}
shortcuts={[getShortcutKey("Shift+Alt+C")]}
/>
<Shortcut
label={t("labels.copyStyles")}
shortcuts={[getShortcutKey("CtrlOrCmd+Alt+C")]}
/>
<Shortcut
label={t("labels.pasteStyles")}
shortcuts={[getShortcutKey("CtrlOrCmd+Alt+V")]}
/>
<Shortcut
label={t("labels.delete")}
shortcuts={[getShortcutKey("Del")]}
/>
<Shortcut
label={t("labels.sendToBack")}
shortcuts={[
isDarwin
? getShortcutKey("CtrlOrCmd+Alt+[")
: getShortcutKey("CtrlOrCmd+Shift+["),
]}
/>
<Shortcut
label={t("labels.bringToFront")}
shortcuts={[
isDarwin
? getShortcutKey("CtrlOrCmd+Alt+]")
: getShortcutKey("CtrlOrCmd+Shift+]"),
]}
/>
<Shortcut
label={t("labels.sendBackward")}
shortcuts={[getShortcutKey("CtrlOrCmd+[")]}
/>
<Shortcut
label={t("labels.bringForward")}
shortcuts={[getShortcutKey("CtrlOrCmd+]")]}
/>
<Shortcut
label={t("labels.alignTop")}
shortcuts={[getShortcutKey("CtrlOrCmd+Shift+Up")]}
/>
<Shortcut
label={t("labels.alignBottom")}
shortcuts={[getShortcutKey("CtrlOrCmd+Shift+Down")]}
/>
<Shortcut
label={t("labels.alignLeft")}
shortcuts={[getShortcutKey("CtrlOrCmd+Shift+Left")]}
/>
<Shortcut
label={t("labels.alignRight")}
shortcuts={[getShortcutKey("CtrlOrCmd+Shift+Right")]}
/>
<Shortcut
label={t("labels.duplicateSelection")}
shortcuts={[
getShortcutKey("CtrlOrCmd+D"),
getShortcutKey(`Alt+${t("helpDialog.drag")}`),
]}
/>
<Shortcut
label={t("buttons.undo")}
shortcuts={[getShortcutKey("CtrlOrCmd+Z")]}
/>
<Shortcut
label={t("buttons.redo")}
shortcuts={
isWindows
? [
getShortcutKey("CtrlOrCmd+Y"),
getShortcutKey("CtrlOrCmd+Shift+Z"),
]
: [getShortcutKey("CtrlOrCmd+Shift+Z")]
}
/>
<Shortcut
label={t("labels.group")}
shortcuts={[getShortcutKey("CtrlOrCmd+G")]}
/>
<Shortcut
label={t("labels.ungroup")}
shortcuts={[getShortcutKey("CtrlOrCmd+Shift+G")]}
/>
</ShortcutIsland>
</Column>
</Columns>
</Section>
</Dialog>
</>
);
};

View File

@ -1,4 +1,5 @@
import React from "react";
import { questionCircle } from "../components/icons";
type HelpIconProps = {
title?: string;
@ -7,19 +8,8 @@ type HelpIconProps = {
onClick?(): void;
};
const ICON = (
<svg
width="30"
height="22"
viewBox="0 0 512 512"
xmlns="http://www.w3.org/2000/svg"
>
<path d="M528 448H48c-26.51 0-48-21.49-48-48V112c0-26.51 21.49-48 48-48h480c26.51 0 48 21.49 48 48v288c0 26.51-21.49 48-48 48zM128 180v-40c0-6.627-5.373-12-12-12H76c-6.627 0-12 5.373-12 12v40c0 6.627 5.373 12 12 12h40c6.627 0 12-5.373 12-12zm96 0v-40c0-6.627-5.373-12-12-12h-40c-6.627 0-12 5.373-12 12v40c0 6.627 5.373 12 12 12h40c6.627 0 12-5.373 12-12zm96 0v-40c0-6.627-5.373-12-12-12h-40c-6.627 0-12 5.373-12 12v40c0 6.627 5.373 12 12 12h40c6.627 0 12-5.373 12-12zm96 0v-40c0-6.627-5.373-12-12-12h-40c-6.627 0-12 5.373-12 12v40c0 6.627 5.373 12 12 12h40c6.627 0 12-5.373 12-12zm96 0v-40c0-6.627-5.373-12-12-12h-40c-6.627 0-12 5.373-12 12v40c0 6.627 5.373 12 12 12h40c6.627 0 12-5.373 12-12zm-336 96v-40c0-6.627-5.373-12-12-12h-40c-6.627 0-12 5.373-12 12v40c0 6.627 5.373 12 12 12h40c6.627 0 12-5.373 12-12zm96 0v-40c0-6.627-5.373-12-12-12h-40c-6.627 0-12 5.373-12 12v40c0 6.627 5.373 12 12 12h40c6.627 0 12-5.373 12-12zm96 0v-40c0-6.627-5.373-12-12-12h-40c-6.627 0-12 5.373-12 12v40c0 6.627 5.373 12 12 12h40c6.627 0 12-5.373 12-12zm96 0v-40c0-6.627-5.373-12-12-12h-40c-6.627 0-12 5.373-12 12v40c0 6.627 5.373 12 12 12h40c6.627 0 12-5.373 12-12zm-336 96v-40c0-6.627-5.373-12-12-12H76c-6.627 0-12 5.373-12 12v40c0 6.627 5.373 12 12 12h40c6.627 0 12-5.373 12-12zm288 0v-40c0-6.627-5.373-12-12-12H172c-6.627 0-12 5.373-12 12v40c0 6.627 5.373 12 12 12h232c6.627 0 12-5.373 12-12zm96 0v-40c0-6.627-5.373-12-12-12h-40c-6.627 0-12 5.373-12 12v40c0 6.627 5.373 12 12 12h40c6.627 0 12-5.373 12-12z" />
</svg>
);
export const HelpIcon = (props: HelpIconProps) => (
<label title={`${props.title} — ?`} className="help-icon">
<div onClick={props.onClick}>{ICON}</div>
<div onClick={props.onClick}>{questionCircle}</div>
</label>
);

View File

@ -1,4 +1,7 @@
@import "../css/_variables";
@import "../css/variables.module";
// this is loosely based on the longest hint text
$wide-viewport-width: 1000px;
.excalidraw {
.HintViewer {
@ -16,12 +19,9 @@
color: $oc-gray-6;
font-size: 0.8rem;
@media (min-width: 1200px) {
white-space: pre;
}
@media #{$media-query} {
@media #{$is-mobile-query} {
position: static;
padding-right: 2em;
}
> span {

View File

@ -1,4 +1,4 @@
@import "../css/_variables";
@import "../css/variables.module";
.excalidraw {
.picker-container {
@ -102,6 +102,7 @@
position: absolute;
bottom: 2px;
font-size: 0.7em;
color: var(--keybinding-color);
:root[dir="ltr"] & {
right: 2px;
@ -110,7 +111,7 @@
:root[dir="rtl"] & {
left: 2px;
}
@media #{$media-query} {
@media #{$is-mobile-query} {
display: none;
}
}

View File

@ -1,18 +1,29 @@
import React from "react";
import { LoadingMessage } from "./LoadingMessage";
import { setLanguageFirstTime } from "../i18n";
import {
defaultLang,
Language,
languages,
setLanguageFirstTime,
} from "../i18n";
export class InitializeApp extends React.Component<
any,
{ isLoading: boolean }
> {
interface Props {
langCode: Language["code"];
}
interface State {
isLoading: boolean;
}
export class InitializeApp extends React.Component<Props, State> {
public state: { isLoading: boolean } = {
isLoading: true,
};
async componentDidMount() {
await setLanguageFirstTime();
const currentLang =
languages.find((lang) => lang.code === this.props.langCode) ||
defaultLang;
await setLanguageFirstTime(currentLang);
this.setState({
isLoading: false,
});

View File

@ -4,7 +4,7 @@
background-color: var(--bg-color-island);
backdrop-filter: saturate(100%) blur(10px);
box-shadow: var(--shadow-island);
border-radius: var(--border-radius-m);
border-radius: 4px;
padding: calc(var(--padding) * var(--space-factor));
position: relative;
transition: box-shadow 0.5s ease-in-out;

View File

@ -7,11 +7,23 @@
align-items: center;
justify-content: center;
.browse-libraries {
position: absolute;
right: 12px;
top: 16px;
white-space: nowrap;
.layer-ui__library-header {
display: flex;
align-items: center;
width: 100%;
margin: 2px 0;
button {
// 2px from the left to account for focus border of left-most button
margin: 0 2px;
}
a {
margin-left: auto;
// 17px for scrollbar (needed for overlay scrollbars on Big Sur?) + 1px extra
padding-right: 18px;
white-space: nowrap;
}
}
}
@ -26,6 +38,8 @@
}
.layer-ui__wrapper {
z-index: var(--zIndex-layerUI);
.encrypted-icon {
position: relative;
margin-inline-start: 15px;

View File

@ -1,56 +1,46 @@
import clsx from "clsx";
import React, {
RefObject,
useCallback,
useEffect,
useRef,
useState,
RefObject,
useEffect,
useCallback,
} from "react";
import { showSelectedShapeActions } from "../element";
import { calculateScrollCenter, getSelectedElements } from "../scene";
import { exportCanvas } from "../data";
import { AppState, LibraryItems, LibraryItem } from "../types";
import { NonDeletedExcalidrawElement } from "../element/types";
import { ActionManager } from "../actions/manager";
import { Island } from "./Island";
import Stack from "./Stack";
import { FixedSideContainer } from "./FixedSideContainer";
import { UserList } from "./UserList";
import { LockIcon } from "./LockIcon";
import { ExportDialog, ExportCB } from "./ExportDialog";
import { LanguageList } from "./LanguageList";
import { t, languages, setLanguage } from "../i18n";
import { HintViewer } from "./HintViewer";
import { CLASSES } from "../constants";
import { exportCanvas } from "../data";
import { importLibraryFromJSON, saveLibraryAsJSON } from "../data/json";
import { Library } from "../data/library";
import { isTextElement, showSelectedShapeActions } from "../element";
import { NonDeletedExcalidrawElement } from "../element/types";
import { Language, t } from "../i18n";
import useIsMobile from "../is-mobile";
import { calculateScrollCenter, getSelectedElements } from "../scene";
import { ExportType } from "../scene/types";
import { MobileMenu } from "./MobileMenu";
import { ZoomActions, SelectedShapeActions, ShapesSwitcher } from "./Actions";
import { Section } from "./Section";
import { AppState, LibraryItem, LibraryItems } from "../types";
import { muteFSAbortError } from "../utils";
import { SelectedShapeActions, ShapesSwitcher, ZoomActions } from "./Actions";
import { BackgroundPickerAndDarkModeToggle } from "./BackgroundPickerAndDarkModeToggle";
import CollabButton from "./CollabButton";
import { ErrorDialog } from "./ErrorDialog";
import { ShortcutsDialog } from "./ShortcutsDialog";
import { LoadingMessage } from "./LoadingMessage";
import { CLASSES } from "../constants";
import { shield, exportFile, load } from "./icons";
import { ExportCB, ExportDialog } from "./ExportDialog";
import { FixedSideContainer } from "./FixedSideContainer";
import { GitHubCorner } from "./GitHubCorner";
import { Tooltip } from "./Tooltip";
import { HintViewer } from "./HintViewer";
import { exportFile, load, shield } from "./icons";
import { Island } from "./Island";
import "./LayerUI.scss";
import { LibraryUnit } from "./LibraryUnit";
import { LoadingMessage } from "./LoadingMessage";
import { LockIcon } from "./LockIcon";
import { MobileMenu } from "./MobileMenu";
import { PasteChartDialog } from "./PasteChartDialog";
import { Section } from "./Section";
import { HelpDialog } from "./HelpDialog";
import Stack from "./Stack";
import { ToolButton } from "./ToolButton";
import { saveLibraryAsJSON, importLibraryFromJSON } from "../data/json";
import { muteFSAbortError } from "../utils";
import { BackgroundPickerAndDarkModeToggle } from "./BackgroundPickerAndDarkModeToggle";
import clsx from "clsx";
import { Library } from "../data/library";
import {
EVENT_ACTION,
EVENT_EXIT,
EVENT_LIBRARY,
trackEvent,
} from "../analytics";
import { Tooltip } from "./Tooltip";
import { UserList } from "./UserList";
interface LayerUIProps {
actionManager: ActionManager;
@ -60,16 +50,18 @@ interface LayerUIProps {
elements: readonly NonDeletedExcalidrawElement[];
onCollabButtonClick?: () => void;
onLockToggle: () => void;
onInsertShape: (elements: LibraryItem) => void;
onInsertElements: (elements: readonly NonDeletedExcalidrawElement[]) => void;
zenModeEnabled: boolean;
toggleZenMode: () => void;
lng: string;
langCode: Language["code"];
isCollaborating: boolean;
onExportToBackend?: (
exportedElements: readonly NonDeletedExcalidrawElement[],
appState: AppState,
canvas: HTMLCanvasElement | null,
) => void;
renderCustomFooter?: (isMobile: boolean) => JSX.Element;
viewModeEnabled: boolean;
}
const useOnClickOutside = (
@ -123,59 +115,45 @@ const LibraryMenuItems = ({
let addedPendingElements = false;
rows.push(
<>
<a
className="browse-libraries"
href="https://libraries.excalidraw.com"
target="_excalidraw_libraries"
<div className="layer-ui__library-header">
<ToolButton
key="import"
type="button"
title={t("buttons.load")}
aria-label={t("buttons.load")}
icon={load}
onClick={() => {
trackEvent(EVENT_EXIT, "libraries");
importLibraryFromJSON()
.then(() => {
// Maybe we should close and open the menu so that the items get updated.
// But for now we just close the menu.
setAppState({ isLibraryOpen: false });
})
.catch(muteFSAbortError)
.catch((error) => {
setAppState({ errorMessage: error.message });
});
}}
>
/>
<ToolButton
key="export"
type="button"
title={t("buttons.export")}
aria-label={t("buttons.export")}
icon={exportFile}
onClick={() => {
saveLibraryAsJSON()
.catch(muteFSAbortError)
.catch((error) => {
setAppState({ errorMessage: error.message });
});
}}
/>
<a href="https://libraries.excalidraw.com" target="_excalidraw_libraries">
{t("labels.libraries")}
</a>
<Stack.Row
align="center"
gap={1}
key={"actions"}
style={{ padding: "2px" }}
>
<ToolButton
key="import"
type="button"
title={t("buttons.load")}
aria-label={t("buttons.load")}
icon={load}
onClick={() => {
importLibraryFromJSON()
.then(() => {
// Maybe we should close and open the menu so that the items get updated.
// But for now we just close the menu.
setAppState({ isLibraryOpen: false });
})
.catch(muteFSAbortError)
.catch((error) => {
setAppState({ errorMessage: error.message });
});
}}
/>
<ToolButton
key="export"
type="button"
title={t("buttons.export")}
aria-label={t("buttons.export")}
icon={exportFile}
onClick={() => {
saveLibraryAsJSON()
.catch(muteFSAbortError)
.catch((error) => {
setAppState({ errorMessage: error.message });
});
}}
/>
</Stack.Row>
</>,
</div>,
);
for (let row = 0; row < numRows; row++) {
@ -274,7 +252,6 @@ const LibraryMenu = ({
const items = await Library.loadLibrary();
const nextItems = items.filter((_, index) => index !== indexToRemove);
Library.saveLibrary(nextItems);
trackEvent(EVENT_LIBRARY, "remove");
setLibraryItems(nextItems);
}, []);
@ -283,7 +260,6 @@ const LibraryMenu = ({
const items = await Library.loadLibrary();
const nextItems = [...items, elements];
onAddToLibrary();
trackEvent(EVENT_LIBRARY, "add");
Library.saveLibrary(nextItems);
setLibraryItems(nextItems);
},
@ -318,11 +294,13 @@ const LayerUI = ({
elements,
onCollabButtonClick,
onLockToggle,
onInsertShape,
onInsertElements,
zenModeEnabled,
toggleZenMode,
isCollaborating,
onExportToBackend,
renderCustomFooter,
viewModeEnabled,
}: LayerUIProps) => {
const isMobile = useIsMobile();
@ -334,9 +312,6 @@ const LayerUI = ({
href="https://blog.excalidraw.com/end-to-end-encryption/"
target="_blank"
rel="noopener noreferrer"
onClick={() => {
trackEvent(EVENT_EXIT, "e2ee shield");
}}
>
<Tooltip label={t("encrypted.tooltip")} position="above" long={true}>
{shield}
@ -385,6 +360,28 @@ const LayerUI = ({
);
};
const renderViewModeCanvasActions = () => {
return (
<Section
heading="canvasActions"
className={clsx("zen-mode-transition", {
"transition-left": zenModeEnabled,
})}
>
{/* the zIndex ensures this menu has higher stacking order,
see https://github.com/excalidraw/excalidraw/pull/1445 */}
<Island padding={2} style={{ zIndex: 1 }}>
<Stack.Col gap={4}>
<Stack.Row gap={1} justifyContent="space-between">
{actionManager.renderAction("saveScene")}
{actionManager.renderAction("saveAsScene")}
{renderExportDialog()}
</Stack.Row>
</Stack.Col>
</Island>
</Section>
);
};
const renderCanvasActions = () => (
<Section
heading="canvasActions"
@ -456,7 +453,7 @@ const LayerUI = ({
<LibraryMenu
pendingElements={getSelectedElements(elements, appState)}
onClickOutside={closeLibrary}
onInsertShape={onInsertShape}
onInsertShape={onInsertElements}
onAddToLibrary={deselectItems}
setAppState={setAppState}
/>
@ -475,38 +472,42 @@ const LayerUI = ({
gap={4}
className={clsx({ "disable-pointerEvents": zenModeEnabled })}
>
{renderCanvasActions()}
{viewModeEnabled
? renderViewModeCanvasActions()
: renderCanvasActions()}
{shouldRenderSelectedShapeActions && renderSelectedShapeActions()}
</Stack.Col>
<Section heading="shapes">
{(heading) => (
<Stack.Col gap={4} align="start">
<Stack.Row gap={1}>
<Island
padding={1}
className={clsx({ "zen-mode": zenModeEnabled })}
>
<HintViewer appState={appState} elements={elements} />
{heading}
<Stack.Row gap={1}>
<ShapesSwitcher
elementType={appState.elementType}
setAppState={setAppState}
isLibraryOpen={appState.isLibraryOpen}
/>
</Stack.Row>
</Island>
<LockIcon
zenModeEnabled={zenModeEnabled}
checked={appState.elementLocked}
onChange={onLockToggle}
title={t("toolBar.lock")}
/>
</Stack.Row>
{libraryMenu}
</Stack.Col>
)}
</Section>
{!viewModeEnabled && (
<Section heading="shapes">
{(heading) => (
<Stack.Col gap={4} align="start">
<Stack.Row gap={1}>
<Island
padding={1}
className={clsx({ "zen-mode": zenModeEnabled })}
>
<HintViewer appState={appState} elements={elements} />
{heading}
<Stack.Row gap={1}>
<ShapesSwitcher
elementType={appState.elementType}
setAppState={setAppState}
isLibraryOpen={appState.isLibraryOpen}
/>
</Stack.Row>
</Island>
<LockIcon
zenModeEnabled={zenModeEnabled}
checked={appState.elementLocked}
onChange={onLockToggle}
title={t("toolBar.lock")}
/>
</Stack.Row>
{libraryMenu}
</Stack.Col>
)}
</Section>
)}
<UserList
className={clsx("zen-mode-transition", {
"transition-right": zenModeEnabled,
@ -551,6 +552,20 @@ const LayerUI = ({
);
};
const renderGitHubCorner = () => {
return (
<aside
className={clsx(
"layer-ui__wrapper__github-corner zen-mode-transition",
{
"transition-right": zenModeEnabled,
},
)}
>
<GitHubCorner appearance={appState.appearance} />
</aside>
);
};
const renderFooter = () => (
<footer role="contentinfo" className="layer-ui__wrapper__footer">
<div
@ -558,14 +573,7 @@ const LayerUI = ({
"transition-right disable-pointerEvents": zenModeEnabled,
})}
>
<LanguageList
onChange={async (lng) => {
await setLanguage(lng);
setAppState({});
}}
languages={languages}
floating
/>
{renderCustomFooter?.(false)}
{actionManager.renderAction("toggleShortcuts")}
</div>
<button
@ -580,7 +588,6 @@ const LayerUI = ({
<button
className="scroll-back-to-content"
onClick={() => {
trackEvent(EVENT_ACTION, "scroll to content");
setAppState({
...calculateScrollCenter(elements, appState, canvas),
});
@ -592,21 +599,8 @@ const LayerUI = ({
</footer>
);
return isMobile ? (
<MobileMenu
appState={appState}
elements={elements}
actionManager={actionManager}
libraryMenu={libraryMenu}
exportButton={renderExportDialog()}
setAppState={setAppState}
onCollabButtonClick={onCollabButtonClick}
onLockToggle={onLockToggle}
canvas={canvas}
isCollaborating={isCollaborating}
/>
) : (
<div className="layer-ui__wrapper">
const dialogs = (
<>
{appState.isLoading && <LoadingMessage />}
{appState.errorMessage && (
<ErrorDialog
@ -614,25 +608,55 @@ const LayerUI = ({
onClose={() => setAppState({ errorMessage: null })}
/>
)}
{appState.showShortcutsDialog && (
<ShortcutsDialog
onClose={() => setAppState({ showShortcutsDialog: false })}
{appState.showHelpDialog && (
<HelpDialog onClose={() => setAppState({ showHelpDialog: false })} />
)}
{appState.pasteDialog.shown && (
<PasteChartDialog
setAppState={setAppState}
appState={appState}
onInsertChart={onInsertElements}
onClose={() =>
setAppState({
pasteDialog: { shown: false, data: null },
})
}
/>
)}
</>
);
return isMobile ? (
<>
{dialogs}
<MobileMenu
appState={appState}
elements={elements}
actionManager={actionManager}
libraryMenu={libraryMenu}
exportButton={renderExportDialog()}
setAppState={setAppState}
onCollabButtonClick={onCollabButtonClick}
onLockToggle={onLockToggle}
canvas={canvas}
isCollaborating={isCollaborating}
renderCustomFooter={renderCustomFooter}
viewModeEnabled={viewModeEnabled}
/>
</>
) : (
<div
className={clsx("layer-ui__wrapper", {
"disable-pointerEvents":
appState.draggingElement ||
appState.resizingElement ||
(appState.editingElement && !isTextElement(appState.editingElement)),
})}
>
{dialogs}
{renderFixedSideContainer()}
{renderBottomAppMenu()}
{
<aside
className={clsx(
"layer-ui__wrapper__github-corner zen-mode-transition",
{
"transition-right": zenModeEnabled,
},
)}
>
<GitHubCorner appearance={appState.appearance} />
</aside>
}
{renderGitHubCorner()}
{renderFooter()}
</div>
);
@ -641,8 +665,6 @@ const LayerUI = ({
const areEqual = (prev: LayerUIProps, next: LayerUIProps) => {
const getNecessaryObj = (appState: AppState): Partial<AppState> => {
const {
cursorX,
cursorY,
suggestedBindings,
startBoundElement: boundElement,
...ret
@ -653,9 +675,8 @@ const areEqual = (prev: LayerUIProps, next: LayerUIProps) => {
const nextAppState = getNecessaryObj(next.appState);
const keys = Object.keys(prevAppState) as (keyof Partial<AppState>)[];
return (
prev.lng === next.lng &&
prev.langCode === next.langCode &&
prev.elements === next.elements &&
keys.every((key) => prevAppState[key] === nextAppState[key])
);

View File

@ -1,13 +1,13 @@
import React, { useRef, useEffect, useState } from "react";
import clsx from "clsx";
import { exportToSvg } from "../scene/export";
import oc from "open-color";
import React, { useEffect, useRef, useState } from "react";
import { close } from "../components/icons";
import "./LibraryUnit.scss";
import { MIME_TYPES } from "../constants";
import { t } from "../i18n";
import useIsMobile from "../is-mobile";
import { exportToSvg } from "../scene/export";
import { LibraryItem } from "../types";
import { MIME_TYPES } from "../constants";
import "./LibraryUnit.scss";
// fa-plus
const PLUS_ICON = (
@ -38,7 +38,7 @@ export const LibraryUnit = ({
}
const svg = exportToSvg(elementsToRender, {
exportBackground: false,
viewBackgroundColor: "#fff",
viewBackgroundColor: oc.white,
shouldAddWatermark: false,
});
for (const child of ref.current!.children) {

View File

@ -1,9 +1,8 @@
import React from "react";
import { AppState } from "../types";
import { ActionManager } from "../actions/manager";
import { t, setLanguage } from "../i18n";
import { t } from "../i18n";
import Stack from "./Stack";
import { LanguageList } from "./LanguageList";
import { showSelectedShapeActions } from "../element";
import { NonDeletedExcalidrawElement } from "../element/types";
import { FixedSideContainer } from "./FixedSideContainer";
@ -15,10 +14,8 @@ import { Section } from "./Section";
import CollabButton from "./CollabButton";
import { SCROLLBAR_WIDTH, SCROLLBAR_MARGIN } from "../scene/scrollbars";
import { LockIcon } from "./LockIcon";
import { LoadingMessage } from "./LoadingMessage";
import { UserList } from "./UserList";
import { BackgroundPickerAndDarkModeToggle } from "./BackgroundPickerAndDarkModeToggle";
import { EVENT_ACTION, trackEvent } from "../analytics";
type MobileMenuProps = {
appState: AppState;
@ -31,6 +28,8 @@ type MobileMenuProps = {
onLockToggle: () => void;
canvas: HTMLCanvasElement | null;
isCollaborating: boolean;
renderCustomFooter?: (isMobile: boolean) => JSX.Element;
viewModeEnabled: boolean;
};
export const MobileMenu = ({
@ -44,131 +43,165 @@ export const MobileMenu = ({
onLockToggle,
canvas,
isCollaborating,
}: MobileMenuProps) => (
<>
{appState.isLoading && <LoadingMessage />}
<FixedSideContainer side="top">
<Section heading="shapes">
{(heading) => (
<Stack.Col gap={4} align="center">
<Stack.Row gap={1}>
<Island padding={1}>
{heading}
<Stack.Row gap={1}>
<ShapesSwitcher
elementType={appState.elementType}
setAppState={setAppState}
isLibraryOpen={appState.isLibraryOpen}
/>
</Stack.Row>
</Island>
<LockIcon
checked={appState.elementLocked}
onChange={onLockToggle}
title={t("toolBar.lock")}
/>
</Stack.Row>
{libraryMenu}
</Stack.Col>
)}
</Section>
<HintViewer appState={appState} elements={elements} />
</FixedSideContainer>
<div
className="App-bottom-bar"
style={{
marginBottom: SCROLLBAR_WIDTH + SCROLLBAR_MARGIN * 2,
marginLeft: SCROLLBAR_WIDTH + SCROLLBAR_MARGIN * 2,
marginRight: SCROLLBAR_WIDTH + SCROLLBAR_MARGIN * 2,
}}
>
<Island padding={0}>
{appState.openMenu === "canvas" ? (
<Section className="App-mobile-menu" heading="canvasActions">
<div className="panelColumn">
<Stack.Col gap={4}>
{actionManager.renderAction("loadScene")}
{actionManager.renderAction("saveScene")}
{actionManager.renderAction("saveAsScene")}
{exportButton}
{actionManager.renderAction("clearCanvas")}
{onCollabButtonClick && (
<CollabButton
isCollaborating={isCollaborating}
collaboratorCount={appState.collaborators.size}
onClick={onCollabButtonClick}
/>
)}
<BackgroundPickerAndDarkModeToggle
actionManager={actionManager}
appState={appState}
setAppState={setAppState}
renderCustomFooter,
viewModeEnabled,
}: MobileMenuProps) => {
const renderToolbar = () => {
return (
<FixedSideContainer side="top" className="App-top-bar">
<Section heading="shapes">
{(heading) => (
<Stack.Col gap={4} align="center">
<Stack.Row gap={1}>
<Island padding={1}>
{heading}
<Stack.Row gap={1}>
<ShapesSwitcher
elementType={appState.elementType}
setAppState={setAppState}
isLibraryOpen={appState.isLibraryOpen}
/>
</Stack.Row>
</Island>
<LockIcon
checked={appState.elementLocked}
onChange={onLockToggle}
title={t("toolBar.lock")}
/>
<fieldset>
<legend>{t("labels.language")}</legend>
<LanguageList
onChange={async (lng) => {
await setLanguage(lng);
setAppState({});
}}
/>
</fieldset>
<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",
clientId,
)}
</React.Fragment>
))}
</UserList>
</fieldset>
</Stack.Col>
</div>
</Section>
) : appState.openMenu === "shape" &&
showSelectedShapeActions(appState, elements) ? (
<Section className="App-mobile-menu" heading="selectedShapeActions">
<SelectedShapeActions
appState={appState}
elements={elements}
renderAction={actionManager.renderAction}
elementType={appState.elementType}
/>
</Section>
) : null}
<footer className="App-toolbar">
<div className="App-toolbar-content">
{actionManager.renderAction("toggleCanvasMenu")}
{actionManager.renderAction("toggleEditMenu")}
{actionManager.renderAction("undo")}
{actionManager.renderAction("redo")}
{actionManager.renderAction(
appState.multiElement ? "finalize" : "duplicateSelection",
)}
{actionManager.renderAction("deleteSelectedElements")}
</div>
{appState.scrolledOutside && !appState.openMenu && (
<button
className="scroll-back-to-content"
onClick={() => {
trackEvent(EVENT_ACTION, "scroll to content");
setAppState({
...calculateScrollCenter(elements, appState, canvas),
});
}}
>
{t("buttons.scrollBackToContent")}
</button>
</Stack.Row>
{libraryMenu}
</Stack.Col>
)}
</footer>
</Island>
</div>
</>
);
</Section>
<HintViewer appState={appState} elements={elements} />
</FixedSideContainer>
);
};
const renderAppToolbar = () => {
if (viewModeEnabled) {
return (
<div className="App-toolbar-content">
{actionManager.renderAction("toggleCanvasMenu")}
</div>
);
}
return (
<div className="App-toolbar-content">
{actionManager.renderAction("toggleCanvasMenu")}
{actionManager.renderAction("toggleEditMenu")}
{actionManager.renderAction("undo")}
{actionManager.renderAction("redo")}
{actionManager.renderAction(
appState.multiElement ? "finalize" : "duplicateSelection",
)}
{actionManager.renderAction("deleteSelectedElements")}
</div>
);
};
const renderCanvasActions = () => {
if (viewModeEnabled) {
return (
<>
{actionManager.renderAction("saveScene")}
{actionManager.renderAction("saveAsScene")}
{exportButton}
</>
);
}
return (
<>
{actionManager.renderAction("loadScene")}
{actionManager.renderAction("saveScene")}
{actionManager.renderAction("saveAsScene")}
{exportButton}
{actionManager.renderAction("clearCanvas")}
{onCollabButtonClick && (
<CollabButton
isCollaborating={isCollaborating}
collaboratorCount={appState.collaborators.size}
onClick={onCollabButtonClick}
/>
)}
{
<BackgroundPickerAndDarkModeToggle
actionManager={actionManager}
appState={appState}
setAppState={setAppState}
/>
}
</>
);
};
return (
<>
{!viewModeEnabled && renderToolbar()}
<div
className="App-bottom-bar"
style={{
marginBottom: SCROLLBAR_WIDTH + SCROLLBAR_MARGIN * 2,
marginLeft: SCROLLBAR_WIDTH + SCROLLBAR_MARGIN * 2,
marginRight: SCROLLBAR_WIDTH + SCROLLBAR_MARGIN * 2,
}}
>
<Island padding={0}>
{appState.openMenu === "canvas" ? (
<Section className="App-mobile-menu" heading="canvasActions">
<div className="panelColumn">
<Stack.Col gap={4}>
{renderCanvasActions()}
{renderCustomFooter?.(true)}
<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",
clientId,
)}
</React.Fragment>
))}
</UserList>
</fieldset>
</Stack.Col>
</div>
</Section>
) : appState.openMenu === "shape" &&
!viewModeEnabled &&
showSelectedShapeActions(appState, elements) ? (
<Section className="App-mobile-menu" heading="selectedShapeActions">
<SelectedShapeActions
appState={appState}
elements={elements}
renderAction={actionManager.renderAction}
elementType={appState.elementType}
/>
</Section>
) : null}
<footer className="App-toolbar">
{renderAppToolbar()}
{appState.scrolledOutside && !appState.openMenu && (
<button
className="scroll-back-to-content"
onClick={() => {
setAppState({
...calculateScrollCenter(elements, appState, canvas),
});
}}
>
{t("buttons.scrollBackToContent")}
</button>
)}
</footer>
</Island>
</div>
</>
);
};

View File

@ -1,4 +1,4 @@
@import "../css/_variables";
@import "../css/variables.module";
.excalidraw {
.Modal {
@ -30,18 +30,26 @@
z-index: 2;
width: 100%;
max-width: var(--max-width);
max-height: 100%;
opacity: 0;
transform: translateY(10px);
animation: Modal__content_fade-in 0.1s ease-out 0.05s forwards;
position: relative;
overflow-y: auto;
// for modals, reset blurry bg
background: var(--bg-color-island);
backdrop-filter: none;
@media #{$media-query} {
border: 1px solid var(--dialog-border);
box-shadow: 0 2px 10px transparentize($oc-black, 0.75);
border-radius: 6px;
@media #{$is-mobile-query} {
max-width: 100%;
border: 0;
border-radius: 0;
}
}
@ -68,13 +76,7 @@
}
}
.Modal__close--floating {
position: absolute;
right: calc(var(--space-factor) * 5);
top: calc(var(--space-factor) * 5);
}
@media #{$media-query} {
@media #{$is-mobile-query} {
.Modal {
padding: 0;
}

View File

@ -36,11 +36,7 @@ export const Modal = (props: {
<div className="Modal__background" onClick={props.onCloseRequest}></div>
<div
className="Modal__content"
style={{
"--max-width": `${props.maxWidth}px`,
maxHeight: "100%",
overflowY: "scroll",
}}
style={{ "--max-width": `${props.maxWidth}px` }}
>
{props.children}
</div>

View File

@ -0,0 +1,46 @@
@import "../css/variables.module";
.excalidraw {
.PasteChartDialog {
@media #{$is-mobile-query} {
.Island {
display: flex;
flex-direction: column;
}
}
.container {
display: flex;
align-items: center;
justify-content: space-around;
flex-wrap: wrap;
@media #{$is-mobile-query} {
flex-direction: column;
justify-content: center;
}
}
.ChartPreview {
margin: 8px;
text-align: center;
width: 192px;
height: 128px;
border-radius: 2px;
padding: 1px;
border: 1px solid $oc-gray-4;
display: flex;
align-items: center;
justify-content: center;
background: transparent;
div {
display: inline-block;
}
svg {
max-height: 120px;
max-width: 186px;
}
&:hover {
padding: 0;
border: 2px solid $oc-blue-5;
}
}
}
}

View File

@ -0,0 +1,124 @@
import oc from "open-color";
import React, { useLayoutEffect, useRef, useState } from "react";
import { trackEvent } from "../analytics";
import { ChartElements, renderSpreadsheet, Spreadsheet } from "../charts";
import { ChartType } from "../element/types";
import { t } from "../i18n";
import { exportToSvg } from "../scene/export";
import { AppState, LibraryItem } from "../types";
import { Dialog } from "./Dialog";
import "./PasteChartDialog.scss";
type OnInsertChart = (chartType: ChartType, elements: ChartElements) => void;
const ChartPreviewBtn = (props: {
spreadsheet: Spreadsheet | null;
chartType: ChartType;
selected: boolean;
onClick: OnInsertChart;
}) => {
const previewRef = useRef<HTMLDivElement | null>(null);
const [chartElements, setChartElements] = useState<ChartElements | null>(
null,
);
useLayoutEffect(() => {
if (!props.spreadsheet) {
return;
}
const elements = renderSpreadsheet(
props.chartType,
props.spreadsheet,
0,
0,
);
setChartElements(elements);
const svg = exportToSvg(elements, {
exportBackground: false,
viewBackgroundColor: oc.white,
shouldAddWatermark: false,
});
const previewNode = previewRef.current!;
previewNode.appendChild(svg);
if (props.selected) {
(previewNode.parentNode as HTMLDivElement).focus();
}
return () => {
previewNode.removeChild(svg);
};
}, [props.spreadsheet, props.chartType, props.selected]);
return (
<button
className="ChartPreview"
onClick={() => {
if (chartElements) {
props.onClick(props.chartType, chartElements);
}
}}
>
<div ref={previewRef} />
</button>
);
};
export const PasteChartDialog = ({
setAppState,
appState,
onClose,
onInsertChart,
}: {
appState: AppState;
onClose: () => void;
setAppState: React.Component<any, AppState>["setState"];
onInsertChart: (elements: LibraryItem) => void;
}) => {
const handleClose = React.useCallback(() => {
if (onClose) {
onClose();
}
}, [onClose]);
const handleChartClick = (chartType: ChartType, elements: ChartElements) => {
onInsertChart(elements);
trackEvent("magic", "chart", chartType);
setAppState({
currentChartType: chartType,
pasteDialog: {
shown: false,
data: null,
},
});
};
return (
<Dialog
small
onCloseRequest={handleClose}
title={t("labels.pasteCharts")}
className={"PasteChartDialog"}
autofocus={false}
>
<div className={"container"}>
<ChartPreviewBtn
chartType="bar"
spreadsheet={appState.pasteDialog.data}
selected={appState.currentChartType === "bar"}
onClick={handleChartClick}
/>
<ChartPreviewBtn
chartType="line"
spreadsheet={appState.pasteDialog.data}
selected={appState.currentChartType === "line"}
onClick={handleChartClick}
/>
</div>
</Dialog>
);
};

View File

@ -1,342 +0,0 @@
import React from "react";
import { t } from "../i18n";
import { isDarwin } from "../keys";
import { Dialog } from "./Dialog";
import { getShortcutKey } from "../utils";
import "./ShortcutsDialog.scss";
import { EVENT_EXIT, trackEvent } from "../analytics";
const Columns = (props: { children: React.ReactNode }) => (
<div
style={{
display: "flex",
flexDirection: "row",
flexWrap: "wrap",
justifyContent: "space-between",
}}
>
{props.children}
</div>
);
const Column = (props: { children: React.ReactNode }) => (
<div style={{ width: "49%" }}>{props.children}</div>
);
const ShortcutIsland = (props: {
caption: string;
children: React.ReactNode;
}) => (
<div className="ShortcutsDialog-island">
<h3 className="ShortcutsDialog-island-title">{props.caption}</h3>
{props.children}
</div>
);
const Shortcut = (props: {
label: string;
shortcuts: string[];
isOr: boolean;
}) => {
return (
<div className="ShorcutsDialog-shortcut">
<div
style={{
display: "flex",
margin: "0",
padding: "4px 8px",
alignItems: "center",
}}
>
<div
style={{
lineHeight: 1.4,
}}
>
{props.label}
</div>
<div
style={{
display: "flex",
flex: "0 0 auto",
justifyContent: "flex-end",
marginInlineStart: "auto",
minWidth: "30%",
}}
>
{props.shortcuts.map((shortcut, index) => (
<React.Fragment key={index}>
<ShortcutKey>{shortcut}</ShortcutKey>
{props.isOr &&
index !== props.shortcuts.length - 1 &&
t("shortcutsDialog.or")}
</React.Fragment>
))}
</div>
</div>
</div>
);
};
Shortcut.defaultProps = {
isOr: true,
};
const ShortcutKey = (props: { children: React.ReactNode }) => (
<span className="ShorcutsDialog-key" {...props} />
);
const Footer = () => (
<div className="ShortcutsDialog-footer">
<a
href="https://blog.excalidraw.com"
target="_blank"
rel="noopener noreferrer"
onClick={() => {
trackEvent(EVENT_EXIT, "blog");
}}
>
{t("shortcutsDialog.blog")}
</a>
<a
href="https://howto.excalidraw.com"
target="_blank"
rel="noopener noreferrer"
onClick={() => {
trackEvent(EVENT_EXIT, "guides");
}}
>
{t("shortcutsDialog.howto")}
</a>
<a
href="https://github.com/excalidraw/excalidraw/issues"
target="_blank"
rel="noopener noreferrer"
onClick={() => {
trackEvent(EVENT_EXIT, "issues");
}}
>
{t("shortcutsDialog.github")}
</a>
</div>
);
export const ShortcutsDialog = ({ onClose }: { onClose?: () => void }) => {
const handleClose = React.useCallback(() => {
if (onClose) {
onClose();
}
}, [onClose]);
return (
<>
<Dialog
maxWidth={900}
onCloseRequest={handleClose}
title={t("shortcutsDialog.title")}
>
<Columns>
<Column>
<ShortcutIsland caption={t("shortcutsDialog.shapes")}>
<Shortcut label={t("toolBar.selection")} shortcuts={["V", "1"]} />
<Shortcut label={t("toolBar.rectangle")} shortcuts={["R", "2"]} />
<Shortcut label={t("toolBar.diamond")} shortcuts={["D", "3"]} />
<Shortcut label={t("toolBar.ellipse")} shortcuts={["E", "4"]} />
<Shortcut label={t("toolBar.arrow")} shortcuts={["A", "5"]} />
<Shortcut label={t("toolBar.line")} shortcuts={["P", "6"]} />
<Shortcut
label={t("toolBar.draw")}
shortcuts={["Shift+P", "7"]}
/>
<Shortcut label={t("toolBar.text")} shortcuts={["T", "8"]} />
<Shortcut
label={t("shortcutsDialog.textNewLine")}
shortcuts={[
getShortcutKey("Enter"),
getShortcutKey("Shift+Enter"),
]}
/>
<Shortcut
label={t("shortcutsDialog.textFinish")}
shortcuts={[
getShortcutKey("Esc"),
getShortcutKey("CtrlOrCmd+Enter"),
]}
/>
<Shortcut
label={t("shortcutsDialog.curvedArrow")}
shortcuts={[
"A",
t("shortcutsDialog.click"),
t("shortcutsDialog.click"),
t("shortcutsDialog.click"),
]}
isOr={false}
/>
<Shortcut
label={t("shortcutsDialog.curvedLine")}
shortcuts={[
"L",
t("shortcutsDialog.click"),
t("shortcutsDialog.click"),
t("shortcutsDialog.click"),
]}
isOr={false}
/>
<Shortcut label={t("toolBar.lock")} shortcuts={["Q"]} />
<Shortcut
label={t("shortcutsDialog.preventBinding")}
shortcuts={[getShortcutKey("CtrlOrCmd")]}
/>
</ShortcutIsland>
<ShortcutIsland caption={t("shortcutsDialog.view")}>
<Shortcut
label={t("buttons.zoomIn")}
shortcuts={[getShortcutKey("CtrlOrCmd++")]}
/>
<Shortcut
label={t("buttons.zoomOut")}
shortcuts={[getShortcutKey("CtrlOrCmd+-")]}
/>
<Shortcut
label={t("buttons.resetZoom")}
shortcuts={[getShortcutKey("CtrlOrCmd+0")]}
/>
<Shortcut
label={t("shortcutsDialog.zoomToFit")}
shortcuts={["Shift+1"]}
/>
<Shortcut
label={t("shortcutsDialog.zoomToSelection")}
shortcuts={["Shift+2"]}
/>
<Shortcut label={t("buttons.fullScreen")} shortcuts={["F"]} />
<Shortcut
label={t("buttons.zenMode")}
shortcuts={[getShortcutKey("Alt+Z")]}
/>
<Shortcut
label={t("labels.gridMode")}
shortcuts={[getShortcutKey("CtrlOrCmd+'")]}
/>
</ShortcutIsland>
</Column>
<Column>
<ShortcutIsland caption={t("shortcutsDialog.editor")}>
<Shortcut
label={t("labels.selectAll")}
shortcuts={[getShortcutKey("CtrlOrCmd+A")]}
/>
<Shortcut
label={t("labels.multiSelect")}
shortcuts={[
getShortcutKey(`Shift+${t("shortcutsDialog.click")}`),
]}
/>
<Shortcut
label={t("labels.moveCanvas")}
shortcuts={[
getShortcutKey(`Space+${t("shortcutsDialog.drag")}`),
getShortcutKey(`Wheel+${t("shortcutsDialog.drag")}`),
]}
isOr={true}
/>
<Shortcut
label={t("labels.cut")}
shortcuts={[getShortcutKey("CtrlOrCmd+X")]}
/>
<Shortcut
label={t("labels.copy")}
shortcuts={[getShortcutKey("CtrlOrCmd+C")]}
/>
<Shortcut
label={t("labels.paste")}
shortcuts={[getShortcutKey("CtrlOrCmd+V")]}
/>
<Shortcut
label={t("labels.copyAsPng")}
shortcuts={[getShortcutKey("Shift+Alt+C")]}
/>
<Shortcut
label={t("labels.copyStyles")}
shortcuts={[getShortcutKey("CtrlOrCmd+Alt+C")]}
/>
<Shortcut
label={t("labels.pasteStyles")}
shortcuts={[getShortcutKey("CtrlOrCmd+Alt+V")]}
/>
<Shortcut
label={t("labels.delete")}
shortcuts={[getShortcutKey("Del")]}
/>
<Shortcut
label={t("labels.sendToBack")}
shortcuts={[
isDarwin
? getShortcutKey("CtrlOrCmd+Alt+[")
: getShortcutKey("CtrlOrCmd+Shift+["),
]}
/>
<Shortcut
label={t("labels.bringToFront")}
shortcuts={[
isDarwin
? getShortcutKey("CtrlOrCmd+Alt+]")
: getShortcutKey("CtrlOrCmd+Shift+]"),
]}
/>
<Shortcut
label={t("labels.sendBackward")}
shortcuts={[getShortcutKey("CtrlOrCmd+[")]}
/>
<Shortcut
label={t("labels.bringForward")}
shortcuts={[getShortcutKey("CtrlOrCmd+]")]}
/>
<Shortcut
label={t("labels.alignTop")}
shortcuts={[getShortcutKey("CtrlOrCmd+Shift+Up")]}
/>
<Shortcut
label={t("labels.alignBottom")}
shortcuts={[getShortcutKey("CtrlOrCmd+Shift+Down")]}
/>
<Shortcut
label={t("labels.alignLeft")}
shortcuts={[getShortcutKey("CtrlOrCmd+Shift+Left")]}
/>
<Shortcut
label={t("labels.alignRight")}
shortcuts={[getShortcutKey("CtrlOrCmd+Shift+Right")]}
/>
<Shortcut
label={t("labels.duplicateSelection")}
shortcuts={[
getShortcutKey("CtrlOrCmd+D"),
getShortcutKey(`Alt+${t("shortcutsDialog.drag")}`),
]}
/>
<Shortcut
label={t("buttons.undo")}
shortcuts={[getShortcutKey("CtrlOrCmd+Z")]}
/>
<Shortcut
label={t("buttons.redo")}
shortcuts={[getShortcutKey("CtrlOrCmd+Shift+Z")]}
/>
<Shortcut
label={t("labels.group")}
shortcuts={[getShortcutKey("CtrlOrCmd+G")]}
/>
<Shortcut
label={t("labels.ungroup")}
shortcuts={[getShortcutKey("CtrlOrCmd+Shift+G")]}
/>
</ShortcutIsland>
</Column>
</Columns>
<Footer />
</Dialog>
</>
);
};

View File

@ -1,4 +1,4 @@
@import "../css/_variables";
@import "../css/variables.module";
.Stats {
position: fixed;

View File

@ -1,4 +1,6 @@
import React, { useEffect, useState } from "react";
import { copyTextToSystemClipboard } from "../clipboard";
import { DEFAULT_VERSION } from "../constants";
import { getCommonBounds } from "../element/bounds";
import { NonDeletedExcalidrawElement } from "../element/types";
import {
@ -9,7 +11,7 @@ import { t } from "../i18n";
import useIsMobile from "../is-mobile";
import { getTargetElements } from "../scene";
import { AppState } from "../types";
import { debounce, nFormatter } from "../utils";
import { debounce, getVersion, nFormatter } from "../utils";
import { close } from "./icons";
import { Island } from "./Island";
import "./Stats.scss";
@ -25,6 +27,7 @@ const getStorageSizes = debounce((cb: (sizes: StorageSizes) => void) => {
export const Stats = (props: {
appState: AppState;
setAppState: React.Component<any, AppState>["setState"];
elements: readonly NonDeletedExcalidrawElement[];
onClose: () => void;
}) => {
@ -50,6 +53,17 @@ export const Stats = (props: {
return null;
}
const version = getVersion();
let hash;
let timestamp;
if (version !== DEFAULT_VERSION) {
timestamp = version.slice(0, 16).replace("T", " ");
hash = version.slice(21);
} else {
timestamp = t("stats.versionNotAvailable");
}
return (
<div className="Stats">
<Island padding={2}>
@ -85,7 +99,6 @@ export const Stats = (props: {
<td>{t("stats.total")}</td>
<td>{nFormatter(storageSizes.total, 1)}</td>
</tr>
{selectedElements.length === 1 && (
<tr>
<th colSpan={2}>{t("stats.element")}</th>
@ -157,6 +170,28 @@ export const Stats = (props: {
</td>
</tr>
)}
<tr>
<th colSpan={2}>{t("stats.version")}</th>
</tr>
<tr>
<td
colSpan={2}
style={{ textAlign: "center", cursor: "pointer" }}
onClick={async () => {
try {
await copyTextToSystemClipboard(getVersion());
props.setAppState({
toastMessage: t("toast.copyToClipboard"),
});
} catch {}
}}
title={t("stats.versionCopy")}
>
{timestamp}
<br />
{hash}
</td>
</tr>
</tbody>
</table>
</Island>

View File

@ -1,4 +1,4 @@
@import "../css/_variables.scss";
@import "../css/variables.module";
.excalidraw {
.TextInput {

32
src/components/Toast.scss Normal file
View File

@ -0,0 +1,32 @@
@import "../css/variables.module";
.excalidraw {
.Toast {
animation: fade-in 0.5s;
background-color: var(--button-gray-1);
border-radius: 4px;
bottom: 10px;
box-sizing: border-box;
cursor: default;
left: 50%;
margin-left: -150px;
padding: 4px 0;
position: fixed;
text-align: center;
width: 300px;
z-index: 999999;
}
.Toast__message {
color: var(--popup-text-color);
}
@keyframes fade-in {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
}

34
src/components/Toast.tsx Normal file
View File

@ -0,0 +1,34 @@
import React, { useCallback, useEffect, useRef } from "react";
import { TOAST_TIMEOUT } from "../constants";
import "./Toast.scss";
export const Toast = ({
message,
clearToast,
}: {
message: string;
clearToast: () => void;
}) => {
const timerRef = useRef<number>(0);
const scheduleTimeout = useCallback(
() =>
(timerRef.current = window.setTimeout(() => clearToast(), TOAST_TIMEOUT)),
[clearToast],
);
useEffect(() => {
scheduleTimeout();
return () => clearTimeout(timerRef.current);
}, [scheduleTimeout, message]);
return (
<div
className="Toast"
onMouseEnter={() => clearTimeout(timerRef?.current)}
onMouseLeave={scheduleTimeout}
>
<p className="Toast__message">{message}</p>
</div>
);
};

View File

@ -1,5 +1,5 @@
@import "open-color/open-color.scss";
@import "../css/variables";
@import "../css/variables.module";
.excalidraw {
.ToolIcon {
@ -142,6 +142,7 @@
user-select: none;
}
// shrink shape icons on small viewports to make them fit
@media (max-width: 425px) {
.Shape .ToolIcon__icon {
width: 2rem;
@ -153,6 +154,8 @@
}
}
// move the lock button out of the way on small viewports
// it begins to collide with the GitHub icon before we switch to mobile mode
@media (max-width: 760px) {
.ToolIcon.ToolIcon__lock {
display: inline-block;
@ -162,6 +165,7 @@
margin-left: 0;
border-radius: 20px 0 0 20px;
z-index: 1;
background-color: var(--button-gray-1);
@ -189,7 +193,7 @@
margin-left: 5px;
margin-top: 1px;
@media #{$media-query} {
@media #{$is-mobile-query} {
display: none;
}
}

View File

@ -1,4 +1,4 @@
@import "../css/_variables";
@import "../css/variables.module";
.excalidraw {
.Tooltip {
position: relative;
@ -48,15 +48,7 @@
}
}
// the following 3 rules ensure that the tooltip doesn't show (nor affect
// the cursor) when you drag over when you draw on canvas, but at the same
// time it still works when clicking on the link/shield
body:active & .Tooltip:not(:hover) {
pointer-events: none;
}
body:not(:active) & .Tooltip:hover .Tooltip__label {
.Tooltip:hover .Tooltip__label {
visibility: visible;
}

View File

@ -46,6 +46,7 @@ export enum EVENT {
TOUCH_START = "touchstart",
TOUCH_END = "touchend",
HASHCHANGE = "hashchange",
VISIBILITY_CHANGE = "visibilitychange",
}
export const ENV = {
@ -70,10 +71,11 @@ export const DEFAULT_FONT_SIZE = 20;
export const DEFAULT_FONT_FAMILY: FontFamily = 1;
export const DEFAULT_TEXT_ALIGN = "left";
export const DEFAULT_VERTICAL_ALIGN = "top";
export const DEFAULT_VERSION = "{version}";
export const CANVAS_ONLY_ACTIONS = ["selectAll"];
export const GRID_SIZE = 20;
export const GRID_SIZE = 20; // TODO make it configurable?
export const MIME_TYPES = {
excalidraw: "application/vnd.excalidraw+json",
@ -88,3 +90,12 @@ export const STORAGE_KEYS = {
export const TAP_TWICE_TIMEOUT = 300;
export const TOUCH_CTX_MENU_TIMEOUT = 500;
export const TITLE_TIMEOUT = 10000;
export const TOAST_TIMEOUT = 5000;
export const VERSION_TIMEOUT = 30000;
export const ZOOM_STEP = 0.1;
// Report a user inactive after IDLE_THRESHOLD milliseconds
export const IDLE_THRESHOLD = 60_000;
// Report a user active each ACTIVE_THRESHOLD milliseconds
export const ACTIVE_THRESHOLD = 3_000;

View File

@ -0,0 +1,42 @@
import React from "react";
export const createInverseContext = <T extends unknown = null>(
initialValue: T,
) => {
const Context = React.createContext(initialValue) as React.Context<T> & {
_updateProviderValue?: (value: T) => void;
};
class InverseConsumer extends React.Component {
state = { value: initialValue };
constructor(props: any) {
super(props);
Context._updateProviderValue = (value: T) => this.setState({ value });
}
render() {
return (
<Context.Provider value={this.state.value}>
{this.props.children}
</Context.Provider>
);
}
}
class InverseProvider extends React.Component<{ value: T }> {
componentDidMount() {
Context._updateProviderValue?.(this.props.value);
}
componentDidUpdate() {
Context._updateProviderValue?.(this.props.value);
}
render() {
return <Context.Consumer>{() => this.props.children}</Context.Consumer>;
}
}
return {
Context,
Consumer: InverseConsumer,
Provider: InverseProvider,
};
};

View File

@ -1,3 +0,0 @@
@import "open-color/open-color.scss";
$media-query: "(max-width: 600px), (max-height: 500px) and (max-width: 1000px)";

View File

@ -1,6 +1,12 @@
@import "./_variables";
@import "./variables.module";
@import "./theme";
:root {
--zIndex-canvas: 1;
--zIndex-wysiwyg: 2;
--zIndex-layerUI: 3;
}
.excalidraw {
color: var(--text-color-primary);
display: flex;
@ -13,7 +19,7 @@
a {
font-weight: 500;
text-decoration: none;
color: $oc-blue-7; /* OC Blue 7 */
color: var(--link-color);
&:hover {
text-decoration: underline;
@ -30,6 +36,8 @@
image-rendering: pixelated; // chromium
// NOTE: must be declared *after* the above
image-rendering: -moz-crisp-edges; // FF
z-index: var(--zIndex-canvas);
}
&.Appearance_dark {
@ -216,6 +224,12 @@
}
}
.App-top-bar {
z-index: var(--zIndex-layerUI);
display: flex;
justify-content: center;
}
.App-bottom-bar {
position: absolute;
top: 0;
@ -282,7 +296,7 @@
pointer-events: none !important;
}
.App-menu_top > * {
.layer-ui__wrapper:not(.disable-pointerEvents) .App-menu_top > * {
pointer-events: all;
}
@ -323,7 +337,7 @@
}
}
.App-menu_bottom > * {
.layer-ui__wrapper:not(.disable-pointerEvents) .App-menu_bottom > * {
pointer-events: all;
}
@ -431,6 +445,7 @@
cursor: pointer;
fill: $oc-gray-6;
bottom: 14px;
width: 1.5rem;
:root[dir="ltr"] & {
right: 14px;
@ -441,7 +456,7 @@
}
}
@media #{$media-query} {
@media #{$is-mobile-query} {
aside {
display: none;
}
@ -491,6 +506,13 @@
pointer-events: none !important;
}
&.excalidraw--view-mode {
.App-menu {
display: flex;
justify-content: space-between;
}
}
@media print {
.App-bottom-bar,
.FixedSideContainer,

View File

@ -3,7 +3,6 @@
:root {
--bg-color-island: rgba(255, 255, 255, 0.9);
--popup-background-color: #{$oc-white};
--border-radius-m: 4px;
--space-factor: 0.25rem;
--button-gray-1: #{$oc-gray-2};
--button-gray-2: #{$oc-gray-4};
@ -15,7 +14,6 @@
--icon-fill-color: #{$oc-black};
--icon-green-fill-color: #{$oc-green-9};
--keybinding-color: #{$oc-gray-5};
--color-overlay-text-color: #ccc;
--sat: env(safe-area-inset-top);
--sab: env(safe-area-inset-bottom);
--sal: env(safe-area-inset-left);
@ -23,8 +21,6 @@
--text-color-primary: #{$oc-gray-8};
--shadow-island: 0 1px 5px #{transparentize($oc-black, 0.85)};
--overlay-background-color: #{transparentize($oc-white, 0.12)};
--border-radius-m: 4px;
--space-factor: 0.25rem;
--dropdown-icon: url('data:image/svg+xml,<svg xmlns="http://www.w3.org/2000/svg" width="292.4" height="292.4" viewBox="0 0 292 292"><path d="M287 197L159 69c-4-3-8-5-13-5s-9 2-13 5L5 197c-3 4-5 8-5 13s2 9 5 13c4 4 8 5 13 5h256c5 0 9-1 13-5s5-8 5-13-1-9-5-13z"/></svg>');
--focus-highlight-color: #{$oc-blue-2};
--select-highlight-color: #{$oc-blue-5};
@ -35,6 +31,8 @@
--popup-secondary-background-color: #{$oc-gray-1};
--popup-text-color: #{$oc-black};
--popup-text-inverted-color: #{$oc-white};
--dialog-border: #{$oc-gray-6};
--link-color: #{$oc-blue-7};
}
.excalidraw {
@ -60,10 +58,8 @@
--icon-fill-color: #{$oc-gray-4};
--icon-green-fill-color: #{$oc-green-4};
--keybinding-color: #{$oc-gray-6};
--color-overlay-text-color: #bbb;
--shadow-island: 0 1px 5px #{transparentize($oc-black, 0.7)};
--overlay-background-color: rgba(30, 30, 30, 0.88);
// #{$oc-gray-4}; inlined
--dropdown-icon: url('data:image/svg+xml,<svg xmlns="http://www.w3.org/2000/svg" width="292.4" height="292.4" viewBox="0 0 292 292"><path fill="%23ced4da" d="M287 197L159 69c-4-3-8-5-13-5s-9 2-13 5L5 197c-3 4-5 8-5 13s2 9 5 13c4 4 8 5 13 5h256c5 0 9-1 13-5s5-8 5-13-1-9-5-13z"/></svg>');
--focus-highlight-color: #{$oc-blue-6};
--select-highlight-color: #{$oc-blue-4};
@ -74,5 +70,6 @@
--popup-secondary-background-color: #222;
--popup-text-color: #{$oc-gray-4};
--popup-text-inverted-color: #2c2c2c;
--dialog-border: #{$oc-gray-9};
}
}

View File

@ -0,0 +1,8 @@
@import "open-color/open-color.scss";
// keep up to date with is-mobile.tsx
$is-mobile-query: "(max-width: 600px), (max-height: 500px) and (max-width: 1000px)";
:export {
isMobileQuery: unquote($is-mobile-query);
}

View File

@ -1,4 +1,3 @@
import { EVENT_IO, trackEvent } from "../analytics";
import { cleanAppStateForExport } from "../appState";
import { MIME_TYPES } from "../constants";
import { clearElementsForExport } from "../element";
@ -111,7 +110,6 @@ export const loadFromBlob = async (
localAppState,
);
trackEvent(EVENT_IO, "load", getMimeType(blob));
return result;
} catch (error) {
console.error(error.message);

View File

@ -1,5 +1,4 @@
import { fileSave } from "browser-nativefs";
import { EVENT_IO, trackEvent } from "../analytics";
import { fileSave } from "browser-fs-access";
import {
copyCanvasToClipboardAsPng,
copyTextToSystemClipboard,
@ -8,8 +7,8 @@ import { NonDeletedExcalidrawElement } from "../element/types";
import { t } from "../i18n";
import { exportToCanvas, exportToSvg } from "../scene/export";
import { ExportType } from "../scene/types";
import { canvasToBlob } from "./blob";
import { AppState } from "../types";
import { canvasToBlob } from "./blob";
import { serializeAsJSON } from "./json";
export { loadFromBlob } from "./blob";
@ -37,7 +36,7 @@ export const exportCanvas = async (
},
) => {
if (elements.length === 0) {
return window.alert(t("alerts.cannotExportEmptyCanvas"));
throw new Error(t("alerts.cannotExportEmptyCanvas"));
}
if (type === "svg" || type === "clipboard-svg") {
const tempSvg = exportToSvg(elements, {
@ -60,10 +59,8 @@ export const exportCanvas = async (
fileName: `${name}.svg`,
extensions: [".svg"],
});
trackEvent(EVENT_IO, "export", "svg");
return;
} else if (type === "clipboard-svg") {
trackEvent(EVENT_IO, "export", "clipboard-svg");
copyTextToSystemClipboard(tempSvg.outerHTML);
return;
}
@ -95,11 +92,9 @@ export const exportCanvas = async (
fileName,
extensions: [".png"],
});
trackEvent(EVENT_IO, "export", "png");
} else if (type === "clipboard") {
try {
await copyCanvasToClipboardAsPng(tempCanvas);
trackEvent(EVENT_IO, "export", "clipboard-png");
} catch (error) {
if (error.name === "CANVAS_POSSIBLY_TOO_BIG") {
throw error;

View File

@ -1,13 +1,11 @@
import { ExcalidrawElement } from "../element/types";
import { AppState } from "../types";
import { fileOpen, fileSave } from "browser-fs-access";
import { cleanAppStateForExport } from "../appState";
import { fileOpen, fileSave } from "browser-nativefs";
import { loadFromBlob } from "./blob";
import { Library } from "./library";
import { MIME_TYPES } from "../constants";
import { clearElementsForExport } from "../element";
import { EVENT_LIBRARY, trackEvent } from "../analytics";
import { ExcalidrawElement } from "../element/types";
import { AppState } from "../types";
import { loadFromBlob } from "./blob";
import { Library } from "./library";
export const serializeAsJSON = (
elements: readonly ExcalidrawElement[],
@ -84,7 +82,6 @@ export const saveLibraryAsJSON = async () => {
description: "Excalidraw library file",
extensions: [".excalidrawlib"],
});
trackEvent(EVENT_LIBRARY, "save");
};
export const importLibraryFromJSON = async () => {
@ -93,6 +90,5 @@ export const importLibraryFromJSON = async () => {
extensions: [".json", ".excalidrawlib"],
mimeTypes: ["application/json"],
});
trackEvent(EVENT_LIBRARY, "load");
Library.importLibrary(blob);
};

106
src/data/pixelated-image.ts Normal file
View File

@ -0,0 +1,106 @@
import { ExcalidrawGenericElement, NonDeleted } from "../element/types";
import { newElement } from "../element";
import { DEFAULT_FONT_FAMILY, DEFAULT_FONT_SIZE } from "../constants";
import { randomId } from "../random";
const loadImage = async (url: string): Promise<HTMLImageElement> => {
const image = new Image();
return new Promise<HTMLImageElement>((resolve, reject) => {
image.onload = () => resolve(image);
image.onerror = (err) =>
reject(
new Error(
`Failed to load image: ${err ? err.toString : "unknown error"}`,
),
);
image.onabort = () =>
reject(new Error(`Failed to load image: image load aborted`));
image.src = url;
});
};
const commonProps = {
fillStyle: "solid",
fontFamily: DEFAULT_FONT_FAMILY,
fontSize: DEFAULT_FONT_SIZE,
opacity: 100,
roughness: 1,
strokeColor: "transparent",
strokeSharpness: "sharp",
strokeStyle: "solid",
strokeWidth: 1,
verticalAlign: "middle",
} as const;
export const pixelateImage = async (
blob: Blob,
cellSize: number,
suggestedMaxShapeCount: number,
x: number,
y: number,
) => {
const url = URL.createObjectURL(blob);
try {
const image = await loadImage(url);
// initialize canvas for pixelation
const { width, height } = image;
let canvasWidth = Math.floor(width / cellSize);
let canvasHeight = Math.floor(height / cellSize);
const shapeCount = canvasHeight * canvasWidth;
if (shapeCount > suggestedMaxShapeCount) {
canvasWidth = Math.floor(
canvasWidth * (suggestedMaxShapeCount / shapeCount),
);
canvasHeight = Math.floor(
canvasHeight * (suggestedMaxShapeCount / shapeCount),
);
}
const xOffset = x - (canvasWidth * cellSize) / 2;
const yOffset = y - (canvasHeight * cellSize) / 2;
const canvas =
"OffscreenCanvas" in window
? new OffscreenCanvas(canvasWidth, canvasHeight)
: document.createElement("canvas");
canvas.width = canvasWidth;
canvas.height = canvasHeight;
// Draw image on canvas
const ctx = canvas.getContext("2d")!;
ctx.drawImage(image, 0, 0, width, height, 0, 0, canvasWidth, canvasHeight);
const imageData = ctx.getImageData(0, 0, canvasWidth, canvasHeight);
const buffer = imageData.data;
const groupId = randomId();
const shapes: NonDeleted<ExcalidrawGenericElement>[] = [];
for (let row = 0; row < canvasHeight; row++) {
for (let col = 0; col < canvasWidth; col++) {
const offset = row * canvasWidth * 4 + col * 4;
const r = buffer[offset];
const g = buffer[offset + 1];
const b = buffer[offset + 2];
const alpha = buffer[offset + 3];
if (alpha) {
const color = `rgba(${r}, ${g}, ${b}, ${alpha})`;
const rectangle = newElement({
backgroundColor: color,
groupIds: [groupId],
...commonProps,
type: "rectangle",
x: xOffset + col * cellSize,
y: yOffset + row * cellSize,
width: cellSize,
height: cellSize,
});
shapes.push(rectangle);
}
}
}
return shapes;
} finally {
URL.revokeObjectURL(url);
}
};

View File

@ -34,7 +34,6 @@ export {
export {
resizeTest,
getCursorForResizingElement,
normalizeTransformHandleType,
getElementWithTransformHandleType,
getTransformHandleTypeFromCoords,
} from "./resizeTest";

View File

@ -102,7 +102,7 @@ export class LinearElementEditor {
element,
scenePointerX - editingLinearElement.pointerOffset.x,
scenePointerY - editingLinearElement.pointerOffset.y,
appState.showGrid,
appState.gridSize,
);
LinearElementEditor.movePoint(element, activePointIndex, newPoint);
if (isBindingElement(element)) {
@ -198,7 +198,7 @@ export class LinearElementEditor {
element,
scenePointer.x,
scenePointer.y,
appState.showGrid,
appState.gridSize,
),
],
});
@ -282,7 +282,7 @@ export class LinearElementEditor {
scenePointerX: number,
scenePointerY: number,
editingLinearElement: LinearElementEditor,
isGridOn: boolean,
gridSize: number | null,
): LinearElementEditor {
const { elementId, lastUncommittedPoint } = editingLinearElement;
const element = LinearElementEditor.getElement(elementId);
@ -304,7 +304,7 @@ export class LinearElementEditor {
element,
scenePointerX - editingLinearElement.pointerOffset.x,
scenePointerY - editingLinearElement.pointerOffset.y,
isGridOn,
gridSize,
);
if (lastPoint === lastUncommittedPoint) {
@ -398,9 +398,9 @@ export class LinearElementEditor {
element: NonDeleted<ExcalidrawLinearElement>,
scenePointerX: number,
scenePointerY: number,
isGridOn: boolean,
gridSize: number | null,
): Point {
const pointerOnGrid = getGridPoint(scenePointerX, scenePointerY, isGridOn);
const pointerOnGrid = getGridPoint(scenePointerX, scenePointerY, gridSize);
const [x1, y1, x2, y2] = getElementAbsoluteCoords(element);
const cx = (x1 + x2) / 2;
const cy = (y1 + y2) / 2;

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