Compare commits

..

150 Commits

Author SHA1 Message Date
Aakansha Doshi 09daff487a Merge remote-tracking branch 'origin/master' into aakansha-sm 2021-01-21 13:15:32 +05:30
Aakansha Doshi 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
Aakansha Doshi 17330c4c03 fix 2021-01-20 21:21:50 +05:30
Aakansha Doshi 2a4ad6fc41 upload sourcemaps to cdn 2021-01-20 21:20:28 +05:30
Aakansha Doshi ec6999554a build(webpack): attach sourcemaps so it helps in debugging 2021-01-20 01:58:45 +05:30
Aakansha Doshi 3922ee8c11 docs: update changelog and readme and release 0.2.1 (#2821) 2021-01-19 17:38:04 +01:00
Aakansha Doshi 8c2bc94336 build(webpack): bundle css files with js (#2819) 2021-01-19 15:29:05 +01:00
Luc Leray fb4d97ef78 fix: Use VERCEL_GIT_COMMIT_SHA env variable (#2816) 2021-01-18 19:22:02 +02:00
Lipis a8e28afbad chore: Update translations from Crowdin (#2790)
Co-authored-by: Kostas Bariotis <konmpar@gmail.com>
2021-01-18 17:00:42 +02:00
Aakansha Doshi 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
Victor Massé 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
dependabot[bot] 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
dependabot[bot] 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
dependabot[bot] 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
dependabot[bot] 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
dependabot[bot] 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
dependabot[bot] 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
dependabot[bot] 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
dependabot[bot] 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
dependabot[bot] 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
dependabot[bot] 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
dependabot[bot] 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
João Forja e26f374ca6 improvement: Enhance resize for non generic elements (#2720) 2021-01-16 19:49:13 +01:00
Lipis c799b28a0e fix: Count all versions (#2798) 2021-01-16 18:59:26 +01:00
Aakansha Doshi 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
Arun 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
Lipis 0bf6830373 docs: Update readme with documentation (#2788) 2021-01-14 18:31:52 +02:00
Rafi 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
Lipis fd699c0447 chore: Update translations from Crowdin (#2742)
Co-authored-by: Kostas Bariotis <konmpar@gmail.com>
2021-01-14 12:09:05 +02:00
Shahzeb Parkar 6a16caf13c fix: broken Individuals link (#2782) 2021-01-14 09:24:50 +02:00
Rafi 511eb62228 refactor: Converting span to kbd tag (#2774) 2021-01-14 09:24:32 +02:00
David Luzar 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
Lipis 49e792649d fix: Track the chart type correctly (#2773) 2021-01-13 15:23:14 +02:00
dependabot[bot] 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
dependabot[bot] 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
David Luzar adcd28f348 fix: delay version logging & prevent duplicates (#2770) 2021-01-12 18:47:31 +02:00
dependabot[bot] 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
dependabot[bot] 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
dependabot[bot] 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
dependabot[bot] 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
Lipis 001880ba88 feat: Track current version (#2731) 2021-01-10 20:48:12 +02:00
Arun 3a130cb102 chore(actions): Use cancel workflow action (#2763) 2021-01-10 20:09:44 +02:00
dependabot[bot] 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
dependabot[bot] 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
dependabot[bot] 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
dependabot[bot] 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
dependabot[bot] 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
dependabot[bot] 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
dependabot[bot] 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
Aakansha Doshi 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
Lipis bafbe9bbc8 feat: Add language separator and list English twice (#2739) 2021-01-07 21:39:57 +02:00
Arun eb71e571e0 improvement: Perform lossless compression on all PNG images (#2740) 2021-01-07 18:04:28 +02:00
Lipis 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
Carl Sverre a13c4f72f5 docs: adding PR guidelines for contributors (#2736) 2021-01-06 22:53:12 +02:00
Carl Sverre 629341da4d improvement: adding zen mode to context menu (#2734) 2021-01-06 17:23:05 +01:00
David Luzar 778e4b08af feat: Add cmd+o shortcut to load scene (#2732) 2021-01-06 13:36:55 +01:00
Thomas Steiner e16266ce5d Fix translation of Catalan to read Català 2021-01-06 00:46:42 +01:00
Arun b6708fb73f improvement: Make arrowheads keybinds color accessible on dark mode (#2724) 2021-01-05 22:35:03 +02:00
Aakansha Doshi 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
Lipis 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
Jaiwanth 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
Aakansha Doshi 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
dependabot[bot] 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
Lipis 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
dependabot[bot] 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
dependabot[bot] 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
dependabot[bot] 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
dependabot[bot] 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
dependabot[bot] 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
dependabot[bot] 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
Benja Kugler 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
David Luzar 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
David Luzar 0cf58adb4c chore: Update portal URL (#2689) 2020-12-28 13:14:22 +01:00
David Luzar 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
Jed Fox 3b0fb1562d feat: Require use of a preset dialog size; adjust dialog sizing (#2684) 2020-12-28 00:07:05 +02:00
Jed Fox 0488b7b5c6 fix(css): Fix compile error (#2685) 2020-12-27 23:42:23 +02:00
Jed Fox b8d13c98b5 refactor: Media queries (#2680) 2020-12-27 23:27:25 +02:00
Lipis 6f82a88b79 chore: Cleanup unused labels (#2682) 2020-12-27 18:51:47 +02:00
Lipis 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
dependabot[bot] 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
Harshil Parmar 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
Kostas Bariotis 0cf5f1ac1f chore: update Sentry (#2669) 2020-12-25 20:02:12 +02:00
Lipis e9cb7ee77c chore: New Crowdin updates (#2620)
Co-authored-by: Kostas Bariotis <konmpar@gmail.com>
2020-12-25 12:15:34 +02:00
Lipis 86c036505b chore: Remove unused cursorX, cursorY from AppState (#2665) 2020-12-24 22:05:22 +01:00
dependabot[bot] 39e7b8cf4f chore(deps-dev): bump firebase-tools from 8.19.0 to 9.0.1 (#2629)
Bumps [firebase-tools](https://github.com/firebase/firebase-tools) from 8.19.0 to 9.0.1.
- [Release notes](https://github.com/firebase/firebase-tools/releases)
- [Changelog](https://github.com/firebase/firebase-tools/blob/master/CHANGELOG.md)
- [Commits](https://github.com/firebase/firebase-tools/compare/v8.19.0...v9.0.1)

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

Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2020-12-22 11:20:57 +01:00
dependabot[bot] e0ece680a6 chore(deps): bump firebase from 8.2.0 to 8.2.1 (#2631)
Bumps [firebase](https://github.com/firebase/firebase-js-sdk) from 8.2.0 to 8.2.1.
- [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>
2020-12-22 11:01:03 +01:00
David Luzar 8dfea49ec1 improvement: change hint for 2-point lines on resize (#2655) 2020-12-22 11:00:51 +01:00
Lipis d7f314cda8 refactor: Remove duplicate entry from en.json (#2654) 2020-12-22 11:42:01 +02:00
Lipis 6adb45ef5a feat: Change title to Excalidraw after a timeout (#2656)
* feat: Change title to Excalidraw after a timeout

* clear timeout
2020-12-22 10:34:06 +01:00
Lipis b0eeb8e6e6 refactor: Remove the word toggle from labels (#2648) 2020-12-21 14:58:43 +01:00
dependabot[bot] c3c20b6087 chore(deps): bump browser-nativefs from 0.11.2 to 0.12.0 (#2634)
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2020-12-21 12:35:22 +02:00
Rene 0faec7efb6 feat: Checkmark to toggle context-menu-items (#2645) 2020-12-21 00:20:03 +02:00
Rene aff817c667 align items in context menu (#2640) 2020-12-20 12:15:40 -08:00
Christopher Chedeau 9a3a3ecb44 fix: Center zoom on iPhone and iPad (#2642)
My last attempt removed the gesture handler altogether but broke macbooks. This one keeps it working on macbook but makes sure it zooms properly on iPhone and iPad.
2020-12-20 12:13:15 -08:00
Christopher Chedeau 81f8039ec7 fix: Don't break zoom when zooming in on UI (#2638)
If you start the gesture on the chrome, gesture is not defined and the zoom will be set to NaN and you will have to refresh.

Typescript was actually right and we shouldn't have overridden the bang ;)

Repro:
- On iPhone touch down on a shape, touch down on the chrome, start moving around
- See that the selection is off, but the zoom is not being modified like crazy.

https://user-images.githubusercontent.com/197597/102722206-6c182400-42b4-11eb-9865-6ae1cd0af9be.MP4
2020-12-20 12:07:58 -08:00
Lipis 14759d5b72 chore: Remove changelog check and graphql (#2639) 2020-12-20 21:52:21 +02:00
David Luzar b997e69ebc improvement: Tweak error message on image import (#2619)
* improvement: tweak error message on image import

* tweak copy
2020-12-20 16:11:44 +01:00
dependabot[bot] 4a89aba682 chore(deps-dev): bump webpack in /src/packages/excalidraw (#2625)
Bumps [webpack](https://github.com/webpack/webpack) from 5.10.1 to 5.11.0.
- [Release notes](https://github.com/webpack/webpack/releases)
- [Commits](https://github.com/webpack/webpack/compare/v5.10.1...v5.11.0)

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

Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2020-12-20 20:35:49 +05:30
David Luzar 34dcf998bd docs: excalidraw package usage example tweaks (#2608)
Co-authored-by: Aakansha Doshi <monstershome@gmail.com>
2020-12-20 16:04:12 +01:00
Aakansha Doshi 325d1bec91 feat: Add onExportToBackend prop so host can handle it (#2612)
Co-authored-by: dwelle <luzar.david@gmail.com>
2020-12-20 15:14:04 +01:00
Lipis b917e42694 fix: Consistent case for export locale strings (#2622) 2020-12-20 14:40:11 +02:00
Christopher Chedeau eb9e67e36a improvement: Support numbers with commas in them (#2636) 2020-12-20 14:08:22 +02:00
dependabot[bot] f14ae52e94 chore(deps-dev): bump @babel/preset-env in /src/packages/excalidraw (#2624)
Bumps [@babel/preset-env](https://github.com/babel/babel/tree/HEAD/packages/babel-preset-env) from 7.12.10 to 7.12.11.
- [Release notes](https://github.com/babel/babel/releases)
- [Changelog](https://github.com/babel/babel/blob/main/CHANGELOG.md)
- [Commits](https://github.com/babel/babel/commits/v7.12.11/packages/babel-preset-env)

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

Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2020-12-20 16:49:20 +05:30
dependabot[bot] f93eb658d6 chore(deps-dev): bump webpack-bundle-analyzer (#2623)
Bumps [webpack-bundle-analyzer](https://github.com/webpack-contrib/webpack-bundle-analyzer) from 4.2.0 to 4.3.0.
- [Release notes](https://github.com/webpack-contrib/webpack-bundle-analyzer/releases)
- [Changelog](https://github.com/webpack-contrib/webpack-bundle-analyzer/blob/master/CHANGELOG.md)
- [Commits](https://github.com/webpack-contrib/webpack-bundle-analyzer/compare/v4.2.0...v4.3.0)

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

Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2020-12-20 16:46:37 +05:30
Aakansha Doshi 9bac44ee75 fix(appstate.ts): Remove unnecessary console.error as it was polluting Sentry (#2637) 2020-12-20 16:28:46 +05:30
dependabot[bot] 91b4109f67 chore(deps-dev): bump webpack-bundle-analyzer in /src/packages/utils (#2626)
Bumps [webpack-bundle-analyzer](https://github.com/webpack-contrib/webpack-bundle-analyzer) from 4.2.0 to 4.3.0.
- [Release notes](https://github.com/webpack-contrib/webpack-bundle-analyzer/releases)
- [Changelog](https://github.com/webpack-contrib/webpack-bundle-analyzer/blob/master/CHANGELOG.md)
- [Commits](https://github.com/webpack-contrib/webpack-bundle-analyzer/compare/v4.2.0...v4.3.0)

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

Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2020-12-20 16:21:17 +05:30
dependabot[bot] 6e45cb95db chore(deps-dev): bump webpack in /src/packages/utils (#2627)
Bumps [webpack](https://github.com/webpack/webpack) from 5.10.1 to 5.11.0.
- [Release notes](https://github.com/webpack/webpack/releases)
- [Commits](https://github.com/webpack/webpack/compare/v5.10.1...v5.11.0)

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

Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2020-12-20 15:20:35 +05:30
dependabot[bot] 5edf82898b chore(deps-dev): bump @babel/preset-env in /src/packages/utils (#2628)
Bumps [@babel/preset-env](https://github.com/babel/babel/tree/HEAD/packages/babel-preset-env) from 7.12.10 to 7.12.11.
- [Release notes](https://github.com/babel/babel/releases)
- [Changelog](https://github.com/babel/babel/blob/main/CHANGELOG.md)
- [Commits](https://github.com/babel/babel/commits/v7.12.11/packages/babel-preset-env)

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

Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2020-12-20 15:19:20 +05:30
dependabot[bot] 60b82e3055 chore(deps-dev): bump eslint-config-prettier from 7.0.0 to 7.1.0 (#2632)
Bumps [eslint-config-prettier](https://github.com/prettier/eslint-config-prettier) from 7.0.0 to 7.1.0.
- [Release notes](https://github.com/prettier/eslint-config-prettier/releases)
- [Changelog](https://github.com/prettier/eslint-config-prettier/blob/main/CHANGELOG.md)
- [Commits](https://github.com/prettier/eslint-config-prettier/compare/v7.0.0...v7.1.0)

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

Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2020-12-20 03:16:05 +02:00
Lipis 5d6590c200 ci: Update the coverage report for i18n PRs (#2592)
Co-authored-by: Kostas Bariotis <konmpar@gmail.com>
2020-12-19 22:44:01 +02:00
Lipis d3bebbc68d ci: Better locale coverage comment (#2616)
Co-authored-by: kbariotis <konmpar@gmail.com>
2020-12-19 20:35:03 +02:00
Kostas Bariotis 4ff8f3b006 add commenting on translations PRs, resolves #2587 (#2615) 2020-12-19 13:52:50 +00:00
Aakansha Doshi abbc756887 ci: Add semantic pr title action (#2610) 2020-12-18 21:32:37 +05:30
Lipis 10e07e434c chore: Remove support for deprecated Excalidraw for Desktop (#2465) 2020-12-18 14:02:29 +02:00
Andrés Rivera fb582b45db fix: Fix centering element on init (#2445)
Co-authored-by: Andrés Rivera <andres@MBP.local>
Co-authored-by: Lipis <lipiridis@gmail.com>
Co-authored-by: dwelle <luzar.david@gmail.com>
2020-12-16 18:17:39 +01:00
Aakansha Doshi 23f21434ff Fix changelog workflow (#2604)
Fix the workflow to make sure it runs on pull requests against forked branches and also don't run on l10_master and dependabot branches
2020-12-16 20:19:05 +05:30
Jaiwanth 7e9fdf85a0 fix: Dropdown for Arrowheads overflow (#2596)
Co-authored-by: Lipis <lipiridis@gmail.com>
Co-authored-by: dwelle <luzar.david@gmail.com>
2020-12-16 13:06:55 +01:00
David Luzar 98c26642d0 fix: hide collab button when onCollabButtonClick not supplied (#2598) 2020-12-15 16:21:14 +01:00
Lipis 2b434db062 chore: Don't run docker build on PRs (#2601) 2020-12-15 14:24:00 +01:00
Kartik Prajapati f919907855 Enhance delete button in context menu (#2591) 2020-12-15 00:59:00 +02:00
Aakansha Doshi bfeb3c7dfd fix(changelog-check.yml): ignore l10n_master and dependabot branches in changelog workflow (#2594) 2020-12-15 02:25:45 +05:30
Aakansha Doshi 8729ab3c54 chore(package/utils): update deps (#2593) 2020-12-15 02:13:19 +05:30
Aakansha Doshi 37f53bccbf Revert the changelog check of excluding the branches (#2590)
* temp commit

* fix

* fix

* Update actionAlign.tsx
2020-12-14 22:41:00 +02:00
Aakansha Doshi c783763307 chore(package/excalidraw): update deps (#2589) 2020-12-14 19:21:22 +01:00
dependabot[bot] f151f45df8 Bump webpack from 4.42.0 to 4.44.2 in /src/packages/utils (#2554)
Bumps [webpack](https://github.com/webpack/webpack) from 4.42.0 to 4.44.2.
- [Release notes](https://github.com/webpack/webpack/releases)
- [Commits](https://github.com/webpack/webpack/compare/v4.42.0...v4.44.2)

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

Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2020-12-14 21:08:06 +05:30
dependabot[bot] f33880e005 Bump @babel/plugin-transform-arrow-functions in /src/packages/utils (#2558)
Bumps [@babel/plugin-transform-arrow-functions](https://github.com/babel/babel/tree/HEAD/packages/babel-plugin-transform-arrow-functions) from 7.8.3 to 7.12.1.
- [Release notes](https://github.com/babel/babel/releases)
- [Changelog](https://github.com/babel/babel/blob/main/CHANGELOG.md)
- [Commits](https://github.com/babel/babel/commits/v7.12.1/packages/babel-plugin-transform-arrow-functions)

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

Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2020-12-14 21:02:52 +05:30
dependabot[bot] 29ed50f6ea Bump webpack-cli from 3.3.11 to 4.2.0 in /src/packages/excalidraw (#2571)
Bumps [webpack-cli](https://github.com/webpack/webpack-cli) from 3.3.11 to 4.2.0.
- [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/v3.3.11...webpack-cli@4.2.0)

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

Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Aakansha Doshi <aakansha1216@gmail.com>
2020-12-14 21:00:06 +05:30
Lipis ce52c18382 fix: Add width/height for the lines in charts (#2586)
Co-authored-by: David Luzar <luzar.david@gmail.com>
2020-12-14 16:29:39 +01:00
Thomas Steiner 7c3e1d8d1b Add declarative link capturing
See https://github.com/WICG/sw-launch/blob/master/declarative_link_capturing.md#user-content-proposal:~:text=new_client,-%E2%80%9D%20%E2%80%94 for context.
2020-12-14 16:25:48 +01:00
dependabot[bot] 0d15934a96 Bump firebase-tools from 8.17.0 to 8.19.0 (#2574)
Bumps [firebase-tools](https://github.com/firebase/firebase-tools) from 8.17.0 to 8.19.0.
- [Release notes](https://github.com/firebase/firebase-tools/releases)
- [Commits](https://github.com/firebase/firebase-tools/compare/v8.17.0...v8.19.0)

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

Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2020-12-14 17:15:38 +02:00
dependabot[bot] a0069d04f0 Bump firebase from 8.1.2 to 8.2.0 (#2580)
Bumps [firebase](https://github.com/firebase/firebase-js-sdk) from 8.1.2 to 8.2.0.
- [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>
2020-12-14 17:15:16 +02:00
Lipis 3b86944365 chore: New Crowdin updates (#2480) 2020-12-14 16:49:01 +02:00
João Forja 4c7b1a2269 fix: Visibility and zooming when canvas offset is not zero (#2534) 2020-12-14 15:14:56 +01:00
Kartik Prajapati 60864ace54 feat: Add tooltip with icon for embedding scenes (#2532)
Co-authored-by: dwelle <luzar.david@gmail.com>
2020-12-14 14:24:54 +01:00
Daishi Kato 4b32c03994 feat: export exportToCanvas in utils (#2583) 2020-12-14 14:11:48 +01:00
dependabot[bot] 222dbdcc00 Bump @types/jest from 26.0.16 to 26.0.19 (#2578)
Bumps [@types/jest](https://github.com/DefinitelyTyped/DefinitelyTyped/tree/HEAD/types/jest) from 26.0.16 to 26.0.19.
- [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>
2020-12-14 07:59:56 +02:00
dependabot[bot] 1346227d30 Bump @sentry/browser from 5.28.0 to 5.29.0 (#2561)
Bumps [@sentry/browser](https://github.com/getsentry/sentry-javascript) from 5.28.0 to 5.29.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.28.0...5.29.0)

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

Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2020-12-14 07:59:48 +02:00
dependabot[bot] ecbddd214c Bump pepjs from 0.5.2 to 0.5.3 (#2550)
Bumps [pepjs](https://github.com/jquery/PEP) from 0.5.2 to 0.5.3.
- [Release notes](https://github.com/jquery/PEP/releases)
- [Commits](https://github.com/jquery/PEP/commits/0.5.3)

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

Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2020-12-14 05:29:51 +02:00
dependabot[bot] 4999ca5c82 Bump husky from 4.3.0 to 4.3.6 (#2575)
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2020-12-14 05:29:16 +02:00
dependabot[bot] 174638889d Bump typescript from 4.0.5 to 4.1.3 (#2569)
Bumps [typescript](https://github.com/Microsoft/TypeScript) from 4.0.5 to 4.1.3.
- [Release notes](https://github.com/Microsoft/TypeScript/releases)
- [Commits](https://github.com/Microsoft/TypeScript/commits)

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

Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2020-12-14 05:28:55 +02:00
dependabot[bot] 480998582e Bump eslint-plugin-prettier from 3.1.4 to 3.3.0 (#2573)
Bumps [eslint-plugin-prettier](https://github.com/prettier/eslint-plugin-prettier) from 3.1.4 to 3.3.0.
- [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.1.4...v3.3.0)

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

Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2020-12-14 05:28:40 +02:00
dependabot[bot] 94544e458c Bump browser-nativefs from 0.11.1 to 0.11.2 (#2577)
Bumps [browser-nativefs](https://github.com/GoogleChromeLabs/browser-nativefs) from 0.11.1 to 0.11.2.
- [Release notes](https://github.com/GoogleChromeLabs/browser-nativefs/releases)
- [Commits](https://github.com/GoogleChromeLabs/browser-nativefs/commits)

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

Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2020-12-14 05:28:16 +02:00
dependabot[bot] f664ba9e1e Bump @sentry/integrations from 5.28.0 to 5.29.0 (#2579)
Bumps [@sentry/integrations](https://github.com/getsentry/sentry-javascript) from 5.28.0 to 5.29.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.28.0...5.29.0)

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

Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2020-12-14 05:27:54 +02:00
Panayiotis Lipiridis 015a60638e chore: Update user for packages 2020-12-14 03:49:43 +02:00
Lipis 7abb80530f chore: Add dependabot configuration (#2535) 2020-12-14 03:47:13 +02:00
Zen Tang 5abe9b93e8 feat: Add zoom to fit for selected elements (#2522) 2020-12-13 22:54:35 +02:00
Aakansha Doshi 59cff0f219 ci: Add github action to make sure changelog for @excalidraw/excalidraw is updated (#2518)
Add guidelines for changelog and group the commits
update the changelog with the latest commits since the last release
Co-authored-by: Lipis <lipiridis@gmail.com>
2020-12-13 18:53:14 +05:30
Lipis 81c17a56fb RTL support for the stats dialog (#2530) 2020-12-13 13:39:45 +01:00
Zen Tang 802b8c50d5 Insert Library items in the middle of the screen (#2527)
Co-authored-by: Zen Tang <zen@wayve.ai>
2020-12-13 13:46:42 +02:00
170 changed files with 10514 additions and 18009 deletions
+1 -1
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"}'
+1 -1
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

+37
View File
@@ -0,0 +1,37 @@
version: 2
updates:
- package-ecosystem: npm
directory: "/"
schedule:
interval: weekly
day: sunday
time: "01:00"
open-pull-requests-limit: 99
reviewers:
- lipis
assignees:
- lipis
- package-ecosystem: npm
directory: "/src/packages/excalidraw/"
schedule:
interval: weekly
day: sunday
time: "01:00"
open-pull-requests-limit: 99
reviewers:
- ad1992
assignees:
- ad1992
- package-ecosystem: npm
directory: "/src/packages/utils/"
schedule:
interval: weekly
day: sunday
time: "01:00"
open-pull-requests-limit: 99
reviewers:
- ad1992
assignees:
- ad1992
-1
View File
@@ -4,7 +4,6 @@ on:
push:
branches:
- master
pull_request:
jobs:
build-docker:
+12
View File
@@ -0,0 +1,12 @@
name: Cancel
on: [push]
jobs:
cancel:
name: "Cancel Previous Runs"
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 }}
-33
View File
@@ -1,33 +0,0 @@
name: "CodeQL"
on:
push:
branches: [master]
pull_request:
branches: [master]
schedule:
- cron: "18 7 * * 0"
jobs:
analyze:
name: Analyze
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
language: ["typescript"]
steps:
- name: Checkout repository
uses: actions/checkout@v2
- name: Initialize CodeQL
uses: github/codeql-action/init@v1
with:
languages: ${{ matrix.language }}
- name: Autobuild
uses: github/codeql-action/autobuild@v1
- name: Perform CodeQL Analysis
uses: github/codeql-action/analyze@v1
+15
View File
@@ -30,3 +30,18 @@ jobs:
git commit -am "Auto commit: Calculate translation coverage"
git push
fi
- name: Construct comment body
id: getCommentBody
run: |
body=$(npm run locales-coverage:description | grep '^[^>]')
body="${body//'%'/'%25'}"
body="${body//$'\n'/'%0A'}"
body="${body//$'\r'/'%0D'}"
echo ::set-output name=body::$body
- name: Update description with coverage
uses: kt3k/update-pr-description@v1.0.1
with:
pr_body: ${{ steps.getCommentBody.outputs.body }}
pr_title: "chore: Update translations from Crowdin"
github_token: ${{ secrets.PUSH_TRANSLATIONS_COVERAGE_PAT }}
+16
View File
@@ -0,0 +1,16 @@
name: "Semantic PR title"
on:
pull_request_target:
types:
- opened
- edited
- synchronize
jobs:
main:
runs-on: ubuntu-latest
steps:
- uses: amannn/action-semantic-pull-request@v3.0.0
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+1
View File
@@ -1,3 +1,4 @@
{
"proseWrap": "never",
"trailingComma": "all"
}
+39 -2
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.
-1
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 . .
+45 -56
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>
-64
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` |
+616 -3777
View File
File diff suppressed because it is too large Load Diff
+21 -22
View File
@@ -19,23 +19,22 @@
]
},
"dependencies": {
"@sentry/browser": "5.28.0",
"@sentry/integrations": "5.28.0",
"@testing-library/jest-dom": "5.11.6",
"@testing-library/react": "11.2.2",
"@types/jest": "26.0.16",
"@types/nanoid": "2.1.0",
"@sentry/browser": "5.30.0",
"@sentry/integrations": "5.30.0",
"@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.11.1",
"@types/socket.io-client": "1.4.35",
"browser-nativefs": "0.12.0",
"clsx": "1.1.1",
"firebase": "8.1.2",
"firebase": "8.2.3",
"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",
@@ -47,19 +46,18 @@
"react-scripts": "4.0.1",
"roughjs": "4.3.1",
"socket.io-client": "2.3.1",
"typescript": "4.0.5"
"typescript": "4.1.3"
},
"devDependencies": {
"@types/lodash.throttle": "4.1.6",
"@types/pako": "1.0.1",
"asar": "3.0.3",
"eslint-config-prettier": "7.0.0",
"eslint-plugin-prettier": "3.1.4",
"firebase-tools": "8.17.0",
"husky": "4.3.0",
"eslint-config-prettier": "7.1.0",
"eslint-plugin-prettier": "3.3.1",
"firebase-tools": "9.2.1",
"husky": "4.3.8",
"jest-canvas-mock": "2.3.0",
"lint-staged": "10.5.3",
"pepjs": "0.5.2",
"pepjs": "0.5.3",
"prettier": "2.2.1",
"rewire": "5.0.0"
},
@@ -82,15 +80,16 @@
"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:zip": "node ./scripts/build-version.js",
"build": "npm run build:app && npm run build:zip",
"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",
"fix:code": "npm run test:code -- --fix",
"fix:other": "npm run prettier -- --write",
"fix": "npm run fix:other && npm run fix:code",
"locales-coverage": "node scripts/build-locales-coverage.js",
"locales-coverage:description": "node scripts/locales-coverage-description.js",
"prettier": "prettier \"**/*.{css,scss,json,md,html,yml}\" --ignore-path=.eslintignore",
"start": "react-scripts start",
"test:all": "npm run test:typecheck && npm run test:code && npm run test:other && npm run test:app -- --watchAll=false",
Binary file not shown.

Before

Width:  |  Height:  |  Size: 9.0 KiB

After

Width:  |  Height:  |  Size: 4.3 KiB

+5 -3
View File
@@ -55,6 +55,8 @@
<meta name="twitter:image" content="https://excalidraw.com/og-image.png" />
<link rel="shortcut icon" href="favicon.ico" type="image/x-icon" />
<!-- Excalidraw version -->
<meta name="version" content="{version}" />
<link
rel="preload"
href="FG_Virgil.woff2"
@@ -84,10 +86,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 || [];
@@ -95,7 +97,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

+2 -1
View File
@@ -25,5 +25,6 @@
"application/vnd.excalidraw+json": [".excalidraw"]
}
}
]
],
"capture_links": "new_client"
}
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

+43 -19
View File
@@ -2,36 +2,60 @@
const fs = require("fs");
const path = require("path");
const asar = require("asar");
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(
{
asar: "excalidraw.asar",
version: versionDate(now),
version: getFullVersion(),
},
undefined,
2,
);
fs.writeFileSync(path.join("build", "version.json"), data);
fs.writeFileSync(versionFile, data);
(async () => {
const src = "build/";
const dest = path.join("build", `excalidraw.asar`);
// https://stackoverflow.com/a/14181136/8418
fs.readFile(indexFile, "utf8", (error, data) => {
if (error) {
return console.error(error);
}
const result = data.replace(/{version}/g, getFullVersion());
await asar.createPackage(src, dest);
})();
fs.writeFile(indexFile, result, "utf8", (error) => {
if (error) {
return console.error(error);
}
});
});
+158
View File
@@ -0,0 +1,158 @@
const fs = require("fs");
const THRESSHOLD = 85;
const crowdinMap = {
"ar-SA": "en-ar",
"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",
"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",
"sk-SK": "en-sk",
"sv-SE": "en-sv",
"tr-TR": "en-tr",
"uk-UA": "en-uk",
"zh-CN": "en-zhcn",
"zh-TW": "en-zhtw",
};
const flags = {
"ar-SA": "🇸🇦",
"bg-BG": "🇧🇬",
"ca-ES": "🇪🇸",
"de-DE": "🇩🇪",
"el-GR": "🇬🇷",
"es-ES": "🇪🇸",
"fa-IR": "🇮🇷",
"fi-FI": "🇫🇮",
"fr-FR": "🇫🇷",
"he-IL": "🇮🇱",
"hi-IN": "🇮🇳",
"hu-HU": "🇭🇺",
"id-ID": "🇮🇩",
"it-IT": "🇮🇹",
"ja-JP": "🇯🇵",
"ko-KR": "🇰🇷",
"my-MM": "🇲🇲",
"nb-NO": "🇳🇴",
"nl-NL": "🇳🇱",
"nn-NO": "🇳🇴",
"pa-IN": "🇮🇳",
"pl-PL": "🇵🇱",
"pt-BR": "🇧🇷",
"pt-PT": "🇵🇹",
"ro-RO": "🇷🇴",
"ru-RU": "🇷🇺",
"sk-SK": "🇸🇰",
"sv-SE": "🇸🇪",
"tr-TR": "🇹🇷",
"uk-UA": "🇺🇦",
"zh-CN": "🇨🇳",
"zh-TW": "🇹🇼",
};
const languages = {
"ar-SA": "العربية",
"bg-BG": "Български",
"ca-ES": "Català",
"de-DE": "Deutsch",
"el-GR": "Ελληνικά",
"es-ES": "Español",
"fa-IR": "فارسی",
"fi-FI": "Suomi",
"fr-FR": "Français",
"he-IL": "עברית",
"hi-IN": "हिन्दी",
"hu-HU": "Magyar",
"id-ID": "Bahasa Indonesia",
"it-IT": "Italiano",
"ja-JP": "日本語",
"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": "Русский",
"sk-SK": "Slovenčina",
"sv-SE": "Svenska",
"tr-TR": "Türkçe",
"uk-UA": "Українська",
"zh-CN": "简体中文",
"zh-TW": "繁體中文",
};
const percentages = fs.readFileSync(
`${__dirname}/../src/locales/percentages.json`,
);
const rowData = JSON.parse(percentages);
const coverages = Object.entries(rowData)
.sort(([, a], [, b]) => b - a)
.reduce((r, [k, v]) => ({ ...r, [k]: v }), {});
const boldIf = (text, condition) => (condition ? `**${text}**` : text);
const printHeader = () => {
let result = "| | Flag | Locale | % |\n";
result += "| :--: | :--: | -- | :--: |";
return result;
};
const printRow = (id, locale, coverage) => {
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(
language,
isOver,
)}](https://crowdin.com/translate/excalidraw/10/${crowdinMap[locale]}) | `;
} else {
result += `${boldIf(language, isOver)} | `;
}
result += `${coverage === 100 ? "💯" : boldIf(coverage, isOver)} |`;
return result;
};
console.info(
`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());
let index = 1;
for (const coverage in coverages) {
if (coverage === "en") {
continue;
}
console.info(printRow(index, coverage, coverages[coverage]));
index++;
}
console.info("\n\r");
console.info("\\* Languages in **bold** are going to appear on production.");
-2
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,7 +15,6 @@ export const actionAddToLibrary = register({
Library.loadLibrary().then((items) => {
Library.saveLibrary([...items, selectedElements.map(deepCopyElement)]);
});
trackEvent(EVENT_LIBRARY, "add");
return false;
},
contextMenuOrder: 6,
+6 -13
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, {
+77 -60
View File
@@ -1,34 +1,24 @@
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 } from "../scene";
import { CODES, KEYS } from "../keys";
import { getShortcutKey } from "../utils";
import useIsMobile from "../is-mobile";
import { register } from "./register";
import { getCommonBounds, getNonDeletedElements } from "../element";
import { newElementWith } from "../element/mutateElement";
import { AppState, NormalizedZoomValue } from "../types";
import { getCommonBounds } from "../element";
import { getNewZoom } from "../scene/zoom";
import { ExcalidrawElement } from "../element/types";
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,
@@ -51,7 +41,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 }),
@@ -65,6 +54,7 @@ export const actionClearCanvas = register({
gridSize: appState.gridSize,
shouldAddWatermark: appState.shouldAddWatermark,
showStats: appState.showStats,
pasteDialog: appState.pasteDialog,
},
commitToHistory: true,
};
@@ -93,9 +83,9 @@ export const actionZoomIn = register({
const zoom = getNewZoom(
getNormalizedZoom(appState.zoom.value + ZOOM_STEP),
appState.zoom,
{ 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,
@@ -126,10 +116,10 @@ export const actionZoomOut = register({
const zoom = getNewZoom(
getNormalizedZoom(appState.zoom.value - ZOOM_STEP),
appState.zoom,
{ left: appState.offsetLeft, top: appState.offsetTop },
{ x: appState.width / 2, y: appState.height / 2 },
);
trackEvent(EVENT_ACTION, "zoom", "out", zoom.value * 100);
return {
appState: {
...appState,
@@ -157,14 +147,18 @@ export const actionZoomOut = register({
export const actionResetZoom = register({
name: "resetZoom",
perform: (_elements, appState) => {
trackEvent(EVENT_ACTION, "zoom", "reset", 100);
return {
appState: {
...appState,
zoom: getNewZoom(1 as NormalizedZoomValue, appState.zoom, {
x: appState.width / 2,
y: appState.height / 2,
}),
zoom: getNewZoom(
1 as NormalizedZoomValue,
appState.zoom,
{ left: appState.offsetLeft, top: appState.offsetTop },
{
x: appState.width / 2,
y: appState.height / 2,
},
),
},
commitToHistory: false,
};
@@ -204,38 +198,61 @@ const zoomValueToFitBoundsOnViewport = (
return clampedZoomValueToFitElements as NormalizedZoomValue;
};
const zoomToFitElements = (
elements: readonly ExcalidrawElement[],
appState: Readonly<AppState>,
zoomToSelection: boolean,
) => {
const nonDeletedElements = getNonDeletedElements(elements);
const selectedElements = getSelectedElements(nonDeletedElements, appState);
const commonBounds =
zoomToSelection && selectedElements.length > 0
? getCommonBounds(selectedElements)
: getCommonBounds(nonDeletedElements);
const zoomValue = zoomValueToFitBoundsOnViewport(commonBounds, {
width: appState.width,
height: appState.height,
});
const newZoom = getNewZoom(zoomValue, appState.zoom, {
left: appState.offsetLeft,
top: appState.offsetTop,
});
const [x1, y1, x2, y2] = commonBounds;
const centerX = (x1 + x2) / 2;
const centerY = (y1 + y2) / 2;
return {
appState: {
...appState,
...centerScrollOn({
scenePoint: { x: centerX, y: centerY },
viewportDimensions: {
width: appState.width,
height: appState.height,
},
zoom: newZoom,
}),
zoom: newZoom,
},
commitToHistory: false,
};
};
export const actionZoomToSelected = register({
name: "zoomToSelection",
perform: (elements, appState) => zoomToFitElements(elements, appState, true),
keyTest: (event) =>
event.code === CODES.TWO &&
event.shiftKey &&
!event.altKey &&
!event[KEYS.CTRL_OR_CMD],
});
export const actionZoomToFit = register({
name: "zoomToFit",
perform: (elements, appState) => {
const nonDeletedElements = elements.filter((element) => !element.isDeleted);
const commonBounds = getCommonBounds(nonDeletedElements);
const zoomValue = zoomValueToFitBoundsOnViewport(commonBounds, {
width: appState.width,
height: appState.height,
});
const newZoom = getNewZoom(zoomValue, appState.zoom);
const [x1, y1, x2, y2] = commonBounds;
const centerX = (x1 + x2) / 2;
const centerY = (y1 + y2) / 2;
trackEvent(EVENT_ACTION, "zoom", "fit", newZoom.value * 100);
return {
appState: {
...appState,
...centerScrollOn({
scenePoint: { x: centerX, y: centerY },
viewportDimensions: {
width: appState.width,
height: appState.height,
},
zoom: newZoom,
}),
zoom: newZoom,
},
commitToHistory: false,
};
},
perform: (elements, appState) => zoomToFitElements(elements, appState, false),
keyTest: (event) =>
event.code === CODES.ONE &&
event.shiftKey &&
+1 -1
View File
@@ -136,7 +136,7 @@ export const actionDeleteSelected = register({
};
},
contextItemLabel: "labels.delete",
contextMenuOrder: 3,
contextMenuOrder: 999999,
keyTest: (event) => event.key === KEYS.BACKSPACE || event.key === KEYS.DELETE,
PanelComponent: ({ elements, appState, updateData }) => (
<ToolButton
+7 -10
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, {
+37 -29
View File
@@ -1,19 +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 { 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";
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 }) => (
@@ -54,13 +55,20 @@ export const actionChangeExportEmbedScene = register({
};
},
PanelComponent: ({ appState, updateData }) => (
<label title={t("labels.exportEmbedScene_details")}>
<label style={{ display: "flex" }}>
<input
type="checkbox"
checked={appState.exportEmbedScene}
onChange={(event) => updateData(event.target.checked)}
/>{" "}
{t("labels.exportEmbedScene")}
<Tooltip
label={t("labels.exportEmbedScene_details")}
position="above"
long={true}
>
<div className="TooltipIcon">{questionCircle}</div>
</Tooltip>
</label>
),
});
@@ -90,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") {
@@ -121,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") {
@@ -149,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"
@@ -168,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}
/>
),
});
+10 -4
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,
+2 -4
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: true,
},
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,
});
+3 -5
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 };
}
+41 -75
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") {
+30 -125
View File
@@ -1,109 +1,32 @@
import {
isTextElement,
isExcalidrawElement,
redrawTextBoundingBox,
getNonDeletedElements,
} from "../element";
import { CODES, KEYS } from "../keys";
import { t } from "../i18n";
import { register } from "./register";
import { newElementWith } from "../element/mutateElement";
import { mutateElement, newElementWith } from "../element/mutateElement";
import {
ExcalidrawElement,
ExcalidrawElementPossibleProps,
} from "../element/types";
import { AppState } from "../types";
import {
canChangeSharpness,
getSelectedElements,
hasBackground,
hasStroke,
hasText,
} from "../scene";
import { isLinearElement, isLinearElementType } from "../element/typeChecks";
type AppStateStyles = {
[K in AssertSubset<
keyof AppState,
typeof copyableStyles[number][0]
>]: AppState[K];
};
type ElementStyles = {
[K in AssertSubset<
keyof ExcalidrawElementPossibleProps,
typeof copyableStyles[number][1]
>]: ExcalidrawElementPossibleProps[K];
};
type ElemelementStylesByType = Record<ExcalidrawElement["type"], ElementStyles>;
DEFAULT_FONT_SIZE,
DEFAULT_FONT_FAMILY,
DEFAULT_TEXT_ALIGN,
} from "../constants";
// `copiedStyles` is exported only for tests.
let COPIED_STYLES: {
appStateStyles: Partial<AppStateStyles>;
elementStyles: Partial<ElementStyles>;
elementStylesByType: Partial<ElemelementStylesByType>;
} | null = null;
/* [AppState prop, ExcalidrawElement prop, predicate] */
const copyableStyles = [
["currentItemOpacity", "opacity", () => true],
["currentItemStrokeColor", "strokeColor", () => true],
["currentItemStrokeStyle", "strokeStyle", hasStroke],
["currentItemStrokeWidth", "strokeWidth", hasStroke],
["currentItemRoughness", "roughness", hasStroke],
["currentItemBackgroundColor", "backgroundColor", hasBackground],
["currentItemFillStyle", "fillStyle", hasBackground],
["currentItemStrokeSharpness", "strokeSharpness", canChangeSharpness],
["currentItemLinearStrokeSharpness", "strokeSharpness", isLinearElementType],
["currentItemStartArrowhead", "startArrowhead", isLinearElementType],
["currentItemEndArrowhead", "endArrowhead", isLinearElementType],
["currentItemFontFamily", "fontFamily", hasText],
["currentItemFontSize", "fontSize", hasText],
["currentItemTextAlign", "textAlign", hasText],
] as const;
const getCommonStyleProps = (
elements: readonly ExcalidrawElement[],
): Exclude<typeof COPIED_STYLES, null> => {
const appStateStyles = {} as AppStateStyles;
const elementStyles = {} as ElementStyles;
const elementStylesByType = elements.reduce((acc, element) => {
// only use the first element of given type
if (!acc[element.type]) {
acc[element.type] = {} as ElementStyles;
copyableStyles.forEach(([appStateProp, prop, predicate]) => {
const value = (element as any)[prop];
if (value !== undefined && predicate(element.type)) {
if (appStateStyles[appStateProp] === undefined) {
(appStateStyles as any)[appStateProp] = value;
}
if (elementStyles[prop] === undefined) {
(elementStyles as any)[prop] = value;
}
(acc as any)[element.type][prop] = value;
}
});
}
return acc;
}, {} as ElemelementStylesByType);
// clone in case we ever make some of the props into non-primitives
return JSON.parse(
JSON.stringify({ appStateStyles, elementStyles, elementStylesByType }),
);
};
export let copiedStyles: string = "{}";
export const actionCopyStyles = register({
name: "copyStyles",
perform: (elements, appState) => {
COPIED_STYLES = getCommonStyleProps(
getSelectedElements(getNonDeletedElements(elements), appState),
);
const element = elements.find((el) => appState.selectedElementIds[el.id]);
if (element) {
copiedStyles = JSON.stringify(element);
}
return {
appState: {
...appState,
...COPIED_STYLES.appStateStyles,
toastMessage: t("toast.copyStyles"),
},
commitToHistory: false,
};
@@ -117,49 +40,31 @@ export const actionCopyStyles = register({
export const actionPasteStyles = register({
name: "pasteStyles",
perform: (elements, appState) => {
if (!COPIED_STYLES) {
const pastedElement = JSON.parse(copiedStyles);
if (!isExcalidrawElement(pastedElement)) {
return { elements, commitToHistory: false };
}
const getStyle = <T extends ExcalidrawElement, K extends keyof T>(
element: T,
prop: K,
) => {
return (COPIED_STYLES?.elementStylesByType[element.type]?.[
prop as keyof ElementStyles
] ??
COPIED_STYLES?.elementStyles[prop as keyof ElementStyles] ??
element[prop]) as T[K];
};
return {
elements: elements.map((element) => {
if (appState.selectedElementIds[element.id]) {
const commonProps = {
backgroundColor: getStyle(element, "backgroundColor"),
strokeWidth: getStyle(element, "strokeWidth"),
strokeColor: getStyle(element, "strokeColor"),
strokeStyle: getStyle(element, "strokeStyle"),
fillStyle: getStyle(element, "fillStyle"),
opacity: getStyle(element, "opacity"),
roughness: getStyle(element, "roughness"),
strokeSharpness: getStyle(element, "strokeSharpness"),
};
if (isTextElement(element)) {
const newElement = newElementWith(element, {
...commonProps,
fontSize: getStyle(element, "fontSize"),
fontFamily: getStyle(element, "fontFamily"),
textAlign: getStyle(element, "textAlign"),
const newElement = newElementWith(element, {
backgroundColor: pastedElement?.backgroundColor,
strokeWidth: pastedElement?.strokeWidth,
strokeColor: pastedElement?.strokeColor,
strokeStyle: pastedElement?.strokeStyle,
fillStyle: pastedElement?.fillStyle,
opacity: pastedElement?.opacity,
roughness: pastedElement?.roughness,
});
if (isTextElement(newElement)) {
mutateElement(newElement, {
fontSize: pastedElement?.fontSize || DEFAULT_FONT_SIZE,
fontFamily: pastedElement?.fontFamily || DEFAULT_FONT_FAMILY,
textAlign: pastedElement?.textAlign || DEFAULT_TEXT_ALIGN,
});
redrawTextBoundingBox(newElement);
return newElement;
} else if (isLinearElement(element)) {
return newElementWith(element, {
...commonProps,
startArrowhead: getStyle(element, "startArrowhead"),
endArrowhead: getStyle(element, "endArrowhead"),
});
}
return newElementWith(element, commonProps);
return newElement;
}
return element;
}),
+7 -5
View File
@@ -19,8 +19,9 @@ export type ShortcutName =
| "copyAsSvg"
| "group"
| "ungroup"
| "toggleGridMode"
| "toggleStats"
| "gridMode"
| "zenMode"
| "stats"
| "addToLibrary";
const shortcutMap: Record<ShortcutName, string[]> = {
@@ -33,7 +34,7 @@ const shortcutMap: Record<ShortcutName, string[]> = {
delete: [getShortcutKey("Del")],
duplicateSelection: [
getShortcutKey("CtrlOrCmd+D"),
getShortcutKey(`Alt+${t("shortcutsDialog.drag")}`),
getShortcutKey(`Alt+${t("helpDialog.drag")}`),
],
sendBackward: [getShortcutKey("CtrlOrCmd+[")],
bringForward: [getShortcutKey("CtrlOrCmd+]")],
@@ -51,8 +52,9 @@ const shortcutMap: Record<ShortcutName, string[]> = {
copyAsSvg: [],
group: [getShortcutKey("CtrlOrCmd+G")],
ungroup: [getShortcutKey("CtrlOrCmd+Shift+G")],
toggleGridMode: [getShortcutKey("CtrlOrCmd+'")],
toggleStats: [],
gridMode: [getShortcutKey("CtrlOrCmd+'")],
zenMode: [getShortcutKey("Alt+Z")],
stats: [],
addToLibrary: [],
};
+1
View File
@@ -58,6 +58,7 @@ export type ActionName =
| "zoomOut"
| "resetZoom"
| "zoomToFit"
| "zoomToSelection"
| "changeFontFamily"
| "changeTextAlign"
| "toggleFullScreen"
+18 -24
View File
@@ -1,24 +1,18 @@
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 = window.gtag
? (category: string, name: string, label?: string, value?: number) => {
window.gtag("event", name, {
event_category: category,
event_label: label,
value,
});
}
: (category: string, name: string, label?: string, value?: number) => {
console.info("Track Event", category, name, label, value);
};
export const trackEvent =
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,
event_label: label,
value,
});
}
: typeof process !== "undefined" && process.env?.JEST_WORKER_ID
? (category: string, name: string, label?: string, value?: number) => {}
: (category: string, name: string, label?: string, value?: number) => {
// Uncomment the next line to track locally
// console.info("Track Event", category, name, label, value);
};
+63 -69
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, FlooredNumber, NormalizedZoomValue } from "./types";
import { getDateTime } from "./utils";
export const getDefaultAppState = (): Omit<
AppState,
@@ -14,66 +14,64 @@ 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,
gridSize: null,
editingGroupId: null,
resizingElement: null,
scrolledOutside: false,
scrollX: 0 as FlooredNumber,
scrollY: 0 as FlooredNumber,
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 } },
};
};
@@ -93,26 +91,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,6 +117,7 @@ const APP_STATE_STORAGE_CONF = (<
errorMessage: { browser: false, export: false },
exportBackground: { browser: true, export: false },
exportEmbedScene: { browser: true, export: false },
fileHandle: { browser: false, export: false },
gridSize: { browser: true, export: true },
height: { browser: false, export: false },
isBindingEnabled: { browser: false, export: false },
@@ -130,7 +128,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 +142,15 @@ 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 },
});
const _clearAppStateForStorage = <ExportType extends "export" | "browser">(
@@ -166,11 +165,6 @@ const _clearAppStateForStorage = <ExportType extends "export" | "browser">(
const stateForExport = {} as { [K in ExportableKeys]?: typeof appState[K] };
for (const key of Object.keys(appState) as (keyof typeof appState)[]) {
const propConfig = APP_STATE_STORAGE_CONF[key];
if (!propConfig) {
console.error(
`_clearAppStateForStorage: appState key "${key}" config doesn't exist for "${exportType}" export type`,
);
}
if (propConfig?.[exportType]) {
// @ts-ignore see https://github.com/microsoft/TypeScript/issues/31445
stateForExport[key] = appState[key];
+325 -125
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;
@@ -19,15 +21,15 @@ export const NOT_SPREADSHEET = "NOT_SPREADSHEET";
export const VALID_SPREADSHEET = "VALID_SPREADSHEET";
type ParseSpreadsheetResult =
| { type: typeof NOT_SPREADSHEET }
| { type: typeof NOT_SPREADSHEET; reason: string }
| { type: typeof VALID_SPREADSHEET; spreadsheet: Spreadsheet };
const tryParseNumber = (s: string): number | null => {
const match = /^[$€£¥₩]?([0-9]+(\.[0-9]+)?)$/.exec(s);
const match = /^[$€£¥₩]?([0-9,]+(\.[0-9]+)?)$/.exec(s);
if (!match) {
return null;
}
return parseFloat(match[1]);
return parseFloat(match[1].replace(/,/g, ""));
};
const isNumericColumn = (lines: string[][], columnIndex: number) =>
@@ -37,12 +39,12 @@ const tryParseCells = (cells: string[][]): ParseSpreadsheetResult => {
const numCols = cells[0].length;
if (numCols > 2) {
return { type: NOT_SPREADSHEET };
return { type: NOT_SPREADSHEET, reason: "More than 2 columns" };
}
if (numCols === 1) {
if (!isNumericColumn(cells, 0)) {
return { type: NOT_SPREADSHEET };
return { type: NOT_SPREADSHEET, reason: "Value is not numeric" };
}
const hasHeader = tryParseNumber(cells[0][0]) === null;
@@ -51,7 +53,7 @@ const tryParseCells = (cells: string[][]): ParseSpreadsheetResult => {
);
if (values.length < 2) {
return { type: NOT_SPREADSHEET };
return { type: NOT_SPREADSHEET, reason: "Less than two rows" };
}
return {
@@ -67,7 +69,7 @@ const tryParseCells = (cells: string[][]): ParseSpreadsheetResult => {
const valueColumnIndex = isNumericColumn(cells, 0) ? 0 : 1;
if (!isNumericColumn(cells, valueColumnIndex)) {
return { type: NOT_SPREADSHEET };
return { type: NOT_SPREADSHEET, reason: "Value is not numeric" };
}
const labelColumnIndex = (valueColumnIndex + 1) % 2;
@@ -75,7 +77,7 @@ const tryParseCells = (cells: string[][]): ParseSpreadsheetResult => {
const rows = hasHeader ? cells.slice(1) : cells;
if (rows.length < 2) {
return { type: NOT_SPREADSHEET };
return { type: NOT_SPREADSHEET, reason: "Less than 2 rows" };
}
return {
@@ -104,13 +106,13 @@ export const tryParseSpreadsheet = (text: string): ParseSpreadsheetResult => {
// Copy/paste from excel, spreadhseets, tsv, csv.
// For now we only accept 2 columns with an optional header
// Check for tab separeted values
// Check for tab separated values
let lines = text
.trim()
.split("\n")
.map((line) => line.trim().split("\t"));
// Check for comma separeted files
// Check for comma separated files
if (lines.length && lines[0].length !== 2) {
lines = text
.trim()
@@ -119,14 +121,17 @@ export const tryParseSpreadsheet = (text: string): ParseSpreadsheetResult => {
}
if (lines.length === 0) {
return { type: NOT_SPREADSHEET };
return { type: NOT_SPREADSHEET, reason: "No values" };
}
const numColsFirstLine = lines[0].length;
const isSpreadsheet = lines.every((line) => line.length === numColsFirstLine);
if (!isSpreadsheet) {
return { type: NOT_SPREADSHEET };
return {
type: NOT_SPREADSHEET,
reason: "All rows don't have same number of columns",
};
}
const result = tryParseCells(lines);
@@ -136,111 +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,
points: [
[0, 0],
[chartWidth, 0],
],
...commonProps,
});
const yAxisLine = newLinearElement({
type: "line",
x,
y,
startArrowhead: null,
endArrowhead: null,
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",
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,
@@ -251,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);
};
+12 -17
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 });
}}
/>
+258 -205
View File
@@ -1,181 +1,164 @@
import { Point, simplify } from "points-on-curve";
import React from "react";
import rough from "roughjs/bin/rough";
import { RoughCanvas } from "roughjs/bin/canvas";
import { simplify, Point } from "points-on-curve";
import {
newElement,
newTextElement,
duplicateElement,
isInvisiblySmallElement,
isTextElement,
textWysiwyg,
getCommonBounds,
getCursorForResizingElement,
getPerfectElementSize,
getNormalizedDimensions,
newLinearElement,
transformElements,
getElementWithTransformHandleType,
getResizeOffsetXY,
getResizeArrowDirection,
getTransformHandleTypeFromCoords,
isNonDeletedElement,
updateTextElement,
dragSelectedElements,
getDragOffsetXY,
dragNewElement,
hitTest,
isHittingElementBoundingBoxWithoutHittingElement,
getNonDeletedElements,
} from "../element";
import {
getElementsWithinSelection,
isOverScrollBars,
getElementsAtPosition,
getElementContainingPosition,
getNormalizedZoom,
getSelectedElements,
isSomeElementSelected,
calculateScrollCenter,
} from "../scene";
import { loadFromBlob, exportCanvas } from "../data";
import { renderScene } from "../renderer";
import {
AppState,
GestureEvent,
Gesture,
ExcalidrawProps,
SceneData,
} from "../types";
import {
ExcalidrawElement,
ExcalidrawTextElement,
NonDeleted,
ExcalidrawGenericElement,
ExcalidrawLinearElement,
ExcalidrawBindableElement,
} from "../element/types";
import { distance2d, isPathALoop, getGridPoint } from "../math";
import {
isWritableElement,
isInputLike,
isToolIcon,
debounce,
distance,
resetCursor,
viewportCoordsToSceneCoords,
sceneCoordsToViewportCoords,
setCursorForShape,
tupleToCoors,
ResolvablePromise,
resolvablePromise,
withBatchedUpdates,
} from "../utils";
import {
KEYS,
isArrowKey,
getResizeCenterPointKey,
getResizeWithSidesSameLengthKey,
getRotateWithDiscreteAngleKey,
CODES,
} from "../keys";
import { findShapeByKey } from "../shapes";
import { createHistory, SceneHistory } from "../history";
import ContextMenu from "./ContextMenu";
import { ActionManager } from "../actions/manager";
import rough from "roughjs/bin/rough";
import "../actions";
import { actionDeleteSelected, actionFinalize } from "../actions";
import { createRedoAction, createUndoAction } from "../actions/actionHistory";
import { ActionManager } from "../actions/manager";
import { actions } from "../actions/register";
import { ActionResult } from "../actions/types";
import { trackEvent } from "../analytics";
import { getDefaultAppState } from "../appState";
import { t, getLanguage } from "../i18n";
import {
copyToClipboard,
parseClipboard,
probablySupportsClipboardBlob,
probablySupportsClipboardWriteText,
} from "../clipboard";
import { normalizeScroll } from "../scene";
import { getCenter, getDistance } from "../gesture";
import { createUndoAction, createRedoAction } from "../actions/actionHistory";
import {
APP_NAME,
CANVAS_ONLY_ACTIONS,
CURSOR_TYPE,
DEFAULT_VERTICAL_ALIGN,
DRAGGING_THRESHOLD,
ELEMENT_SHIFT_TRANSLATE_AMOUNT,
ELEMENT_TRANSLATE_AMOUNT,
POINTER_BUTTON,
DRAGGING_THRESHOLD,
TEXT_TO_CENTER_SNAP_THRESHOLD,
LINE_CONFIRM_THRESHOLD,
EVENT,
ENV,
CANVAS_ONLY_ACTIONS,
DEFAULT_VERTICAL_ALIGN,
EVENT,
GRID_SIZE,
LINE_CONFIRM_THRESHOLD,
MIME_TYPES,
POINTER_BUTTON,
TAP_TWICE_TIMEOUT,
TEXT_TO_CENTER_SNAP_THRESHOLD,
TOUCH_CTX_MENU_TIMEOUT,
} from "../constants";
import LayerUI from "./LayerUI";
import { ScrollBars, SceneState } from "../scene/types";
import { mutateElement } from "../element/mutateElement";
import { invalidateShapeForElement } from "../renderer/renderElement";
import {
isLinearElement,
isLinearElementType,
isBindingElement,
isBindingElementType,
} from "../element/typeChecks";
import { actionFinalize, actionDeleteSelected } from "../actions";
import { LinearElementEditor } from "../element/linearElementEditor";
import {
getSelectedGroupIds,
isSelectedViaGroup,
selectGroupsForSelectedElements,
isElementInGroup,
getSelectedGroupIdForElement,
getElementsInGroup,
editGroupForSelectedElement,
} from "../groups";
import { Library } from "../data/library";
import Scene from "../scene/Scene";
import {
getHoveredElementForBinding,
maybeBindLinearElement,
getEligibleElementsForBinding,
bindOrUnbindSelectedElements,
unbindLinearElements,
fixBindingsAfterDuplication,
fixBindingsAfterDeletion,
isLinearElementSimpleAndAlreadyBound,
isBindingEnabled,
updateBoundElements,
shouldEnableBindingForPointerEvent,
} from "../element/binding";
import { MaybeTransformHandleType } from "../element/transformHandles";
import { deepCopyElement } from "../element/newElement";
import { renderSpreadsheet } from "../charts";
import { exportCanvas, loadFromBlob } from "../data";
import { isValidLibrary } from "../data/json";
import { getNewZoom } from "../scene/zoom";
import { Library } from "../data/library";
import { restore } from "../data/restore";
import {
EVENT_DIALOG,
EVENT_LIBRARY,
EVENT_SHAPE,
trackEvent,
} from "../analytics";
dragNewElement,
dragSelectedElements,
duplicateElement,
getCommonBounds,
getCursorForResizingElement,
getDragOffsetXY,
getElementWithTransformHandleType,
getNonDeletedElements,
getNormalizedDimensions,
getPerfectElementSize,
getResizeArrowDirection,
getResizeOffsetXY,
getTransformHandleTypeFromCoords,
hitTest,
isHittingElementBoundingBoxWithoutHittingElement,
isInvisiblySmallElement,
isNonDeletedElement,
isTextElement,
newElement,
newLinearElement,
newTextElement,
textWysiwyg,
transformElements,
updateTextElement,
} from "../element";
import {
bindOrUnbindSelectedElements,
fixBindingsAfterDeletion,
fixBindingsAfterDuplication,
getEligibleElementsForBinding,
getHoveredElementForBinding,
isBindingEnabled,
isLinearElementSimpleAndAlreadyBound,
maybeBindLinearElement,
shouldEnableBindingForPointerEvent,
unbindLinearElements,
updateBoundElements,
} from "../element/binding";
import { LinearElementEditor } from "../element/linearElementEditor";
import { mutateElement } from "../element/mutateElement";
import { deepCopyElement } from "../element/newElement";
import { MaybeTransformHandleType } from "../element/transformHandles";
import {
isBindingElement,
isBindingElementType,
isLinearElement,
isLinearElementType,
} from "../element/typeChecks";
import {
ExcalidrawBindableElement,
ExcalidrawElement,
ExcalidrawGenericElement,
ExcalidrawLinearElement,
ExcalidrawTextElement,
NonDeleted,
} from "../element/types";
import { getCenter, getDistance } from "../gesture";
import {
editGroupForSelectedElement,
getElementsInGroup,
getSelectedGroupIdForElement,
getSelectedGroupIds,
isElementInGroup,
isSelectedViaGroup,
selectGroupsForSelectedElements,
} from "../groups";
import { createHistory, SceneHistory } from "../history";
import { defaultLang, getLanguage, languages, setLanguage, t } from "../i18n";
import {
CODES,
getResizeCenterPointKey,
getResizeWithSidesSameLengthKey,
getRotateWithDiscreteAngleKey,
isArrowKey,
KEYS,
} from "../keys";
import { distance2d, getGridPoint, isPathALoop } from "../math";
import { renderScene } from "../renderer";
import { invalidateShapeForElement } from "../renderer/renderElement";
import {
calculateScrollCenter,
getElementContainingPosition,
getElementsAtPosition,
getElementsWithinSelection,
getNormalizedZoom,
getSelectedElements,
isOverScrollBars,
isSomeElementSelected,
normalizeScroll,
} from "../scene";
import Scene from "../scene/Scene";
import { SceneState, ScrollBars } from "../scene/types";
import { getNewZoom } from "../scene/zoom";
import { findShapeByKey } from "../shapes";
import {
AppState,
ExcalidrawProps,
Gesture,
GestureEvent,
SceneData,
} from "../types";
import {
debounce,
distance,
isInputLike,
isToolIcon,
isWritableElement,
resetCursor,
ResolvablePromise,
resolvablePromise,
sceneCoordsToViewportCoords,
setCursorForShape,
tupleToCoors,
viewportCoordsToSceneCoords,
withBatchedUpdates,
} from "../utils";
import ContextMenu from "./ContextMenu";
import LayerUI from "./LayerUI";
import { Stats } from "./Stats";
import { Toast } from "./Toast";
const { history } = createHistory();
@@ -345,12 +328,15 @@ class App extends React.Component<ExcalidrawProps, AppState> {
offsetLeft,
} = this.state;
const { onCollabButtonClick } = this.props;
const { onCollabButtonClick, onExportToBackend, renderFooter } = this.props;
const canvasScale = window.devicePixelRatio;
const canvasWidth = canvasDOMWidth * canvasScale;
const canvasHeight = canvasDOMHeight * canvasScale;
const DEFAULT_PASTE_X = canvasDOMWidth / 2;
const DEFAULT_PASTE_Y = canvasDOMHeight / 2;
return (
<div
className="excalidraw"
@@ -370,13 +356,19 @@ class App extends React.Component<ExcalidrawProps, AppState> {
elements={this.scene.getElements()}
onCollabButtonClick={onCollabButtonClick}
onLockToggle={this.toggleLock}
onInsertShape={(elements) =>
this.addElementsFromPasteOrLibrary(elements)
onInsertElements={(elements) =>
this.addElementsFromPasteOrLibrary(
elements,
DEFAULT_PASTE_X,
DEFAULT_PASTE_Y,
)
}
zenModeEnabled={zenModeEnabled}
toggleZenMode={this.toggleZenMode}
lng={getLanguage().lng}
langCode={getLanguage().code}
isCollaborating={this.props.isCollaborating || false}
onExportToBackend={onExportToBackend}
renderCustomFooter={renderFooter}
/>
{this.state.showStats && (
<Stats
@@ -385,6 +377,12 @@ class App extends React.Component<ExcalidrawProps, AppState> {
onClose={this.toggleStats}
/>
)}
{this.state.toastMessage !== null && (
<Toast
message={this.state.toastMessage}
clearToast={this.clearToast}
/>
)}
<main>
<canvas
id="canvas"
@@ -494,7 +492,7 @@ class App extends React.Component<ExcalidrawProps, AppState> {
};
private importLibraryFromUrl = async (url: string) => {
window.history.replaceState({}, "Excalidraw", window.location.origin);
window.history.replaceState({}, APP_NAME, window.location.origin);
try {
const request = await fetch(url);
const blob = await request.blob();
@@ -508,7 +506,6 @@ class App extends React.Component<ExcalidrawProps, AppState> {
)
) {
await Library.importLibrary(blob);
trackEvent(EVENT_LIBRARY, "import");
this.setState({
isLibraryOpen: true,
});
@@ -586,6 +583,8 @@ class App extends React.Component<ExcalidrawProps, AppState> {
scene.elements,
{
...scene.appState,
width: this.state.width,
height: this.state.height,
offsetTop: this.state.offsetTop,
offsetLeft: this.state.offsetLeft,
},
@@ -741,6 +740,10 @@ class App extends React.Component<ExcalidrawProps, AppState> {
}
componentDidUpdate(prevProps: ExcalidrawProps, prevState: AppState) {
if (prevProps.langCode !== this.props.langCode) {
this.updateLanguage();
}
if (
prevProps.width !== this.props.width ||
prevProps.height !== this.props.height ||
@@ -864,7 +867,16 @@ class App extends React.Component<ExcalidrawProps, AppState> {
history.record(this.state, this.scene.getElementsIncludingDeleted());
this.props.onChange?.(this.scene.getElementsIncludingDeleted(), this.state);
// Do not notify consumers if we're still loading the scene. Among other
// potential issues, this fixes a case where the tab isn't focused during
// init, which would trigger onChange with empty elements, which would then
// override whatever is in localStorage currently.
if (!this.state.isLoading) {
this.props.onChange?.(
this.scene.getElementsIncludingDeleted(),
this.state,
);
}
}
// Copy/paste
@@ -906,6 +918,7 @@ class App extends React.Component<ExcalidrawProps, AppState> {
this.canvas!,
this.state,
);
this.setState({ toastMessage: t("toast.copyToClipboardAsPng") });
} catch (error) {
console.error(error);
this.setState({ errorMessage: error.message });
@@ -993,9 +1006,12 @@ class App extends React.Component<ExcalidrawProps, AppState> {
if (data.errorMessage) {
this.setState({ errorMessage: data.errorMessage });
} else if (data.spreadsheet) {
this.addElementsFromPasteOrLibrary(
renderSpreadsheet(data.spreadsheet, cursorX, cursorY),
);
this.setState({
pasteDialog: {
data: data.spreadsheet,
shown: true,
},
});
} else if (data.elements) {
this.addElementsFromPasteOrLibrary(data.elements);
} else if (data.text) {
@@ -1120,7 +1136,6 @@ class App extends React.Component<ExcalidrawProps, AppState> {
toggleLock = () => {
this.setState((prevState) => {
trackEvent(EVENT_SHAPE, "lock", !prevState.elementLocked ? "on" : "off");
return {
elementLocked: !prevState.elementLocked,
elementType: prevState.elementLocked
@@ -1144,7 +1159,7 @@ class App extends React.Component<ExcalidrawProps, AppState> {
toggleStats = () => {
if (!this.state.showStats) {
trackEvent(EVENT_DIALOG, "stats");
trackEvent("dialog", "stats");
}
this.setState({
showStats: !this.state.showStats,
@@ -1161,6 +1176,10 @@ class App extends React.Component<ExcalidrawProps, AppState> {
});
};
clearToast = () => {
this.setState({ toastMessage: null });
};
public updateScene = withBatchedUpdates((sceneData: SceneData) => {
if (sceneData.commitToHistory) {
history.resumeRecording();
@@ -1230,7 +1249,7 @@ class App extends React.Component<ExcalidrawProps, AppState> {
if (event.key === KEYS.QUESTION_MARK) {
this.setState({
showShortcutsDialog: true,
showHelpDialog: true,
});
}
@@ -1256,9 +1275,6 @@ class App extends React.Component<ExcalidrawProps, AppState> {
}
if (event.code === CODES.NINE) {
if (!this.state.isLibraryOpen) {
trackEvent(EVENT_DIALOG, "library");
}
this.setState({ isLibraryOpen: !this.state.isLibraryOpen });
}
@@ -1343,7 +1359,6 @@ class App extends React.Component<ExcalidrawProps, AppState> {
) {
const shape = findShapeByKey(event.key);
if (shape) {
trackEvent(EVENT_SHAPE, shape, "shortcut");
this.selectShapeTool(shape);
} else if (event.key === KEYS.Q) {
this.toggleLock();
@@ -1416,13 +1431,27 @@ class App extends React.Component<ExcalidrawProps, AppState> {
private onGestureChange = withBatchedUpdates((event: GestureEvent) => {
event.preventDefault();
this.setState(({ zoom }) => ({
zoom: getNewZoom(
getNormalizedZoom(gesture.initialScale! * event.scale),
zoom,
{ x: cursorX, y: cursorY },
),
}));
// onGestureChange only has zoom factor but not the center.
// If we're on iPad or iPhone, then we recognize multi-touch and will
// zoom in at the right location on the touchMove handler already.
// On Macbook, we don't have those events so will zoom in at the
// current location instead.
if (gesture.pointers.size === 2) {
return;
}
const initialScale = gesture.initialScale;
if (initialScale) {
this.setState(({ zoom, offsetLeft, offsetTop }) => ({
zoom: getNewZoom(
getNormalizedZoom(initialScale * event.scale),
zoom,
{ left: offsetLeft, top: offsetTop },
{ x: cursorX, y: cursorY },
),
}));
}
});
private onGestureEnd = withBatchedUpdates((event: GestureEvent) => {
@@ -1713,7 +1742,6 @@ class App extends React.Component<ExcalidrawProps, AppState> {
resetCursor();
if (!event[KEYS.CTRL_OR_CMD]) {
trackEvent(EVENT_SHAPE, "text", "double-click");
this.startTextEditing({
sceneX,
sceneY,
@@ -1734,21 +1762,28 @@ class App extends React.Component<ExcalidrawProps, AppState> {
});
}
if (gesture.pointers.size === 2) {
const initialScale = gesture.initialScale;
if (
gesture.pointers.size === 2 &&
gesture.lastCenter &&
initialScale &&
gesture.initialDistance
) {
const center = getCenter(gesture.pointers);
const deltaX = center.x - gesture.lastCenter!.x;
const deltaY = center.y - gesture.lastCenter!.y;
const deltaX = center.x - gesture.lastCenter.x;
const deltaY = center.y - gesture.lastCenter.y;
gesture.lastCenter = center;
const distance = getDistance(Array.from(gesture.pointers.values()));
const scaleFactor = distance / gesture.initialDistance!;
const scaleFactor = distance / gesture.initialDistance;
this.setState(({ zoom, scrollX, scrollY }) => ({
this.setState(({ zoom, scrollX, scrollY, offsetLeft, offsetTop }) => ({
scrollX: normalizeScroll(scrollX + deltaX / zoom.value),
scrollY: normalizeScroll(scrollY + deltaY / zoom.value),
zoom: getNewZoom(
getNormalizedZoom(gesture.initialScale! * scaleFactor),
getNormalizedZoom(initialScale * scaleFactor),
zoom,
{ left: offsetLeft, top: offsetTop },
center,
),
shouldCacheIgnoreZoom: true,
@@ -2430,8 +2465,7 @@ class App extends React.Component<ExcalidrawProps, AppState> {
// otherwise, it will trigger selection based on current
// state of the box
if (!this.state.selectedElementIds[hitElement.id]) {
// if we are currently editing a group, treat all selections outside of the group
// as exiting editing mode.
// if we are currently editing a group, exiting editing mode and deselect the group.
if (
this.state.editingGroupId &&
!isElementInGroup(hitElement, this.state.editingGroupId)
@@ -2441,7 +2475,6 @@ class App extends React.Component<ExcalidrawProps, AppState> {
selectedGroupIds: {},
editingGroupId: null,
});
return true;
}
// Add hit element to selection. At this point if we're not holding
@@ -2579,9 +2612,9 @@ class App extends React.Component<ExcalidrawProps, AppState> {
);
/* If arrow is pre-arrowheads, it will have undefined for both start and end arrowheads.
If so, we want it to be null for start and "arrow" for end. If the linear item is not
an arrow, we want it to be null for both. Otherwise, we want it to use the
values from appState. */
If so, we want it to be null for start and "arrow" for end. If the linear item is not
an arrow, we want it to be null for both. Otherwise, we want it to use the
values from appState. */
const { currentItemStartArrowhead, currentItemEndArrowhead } = this.state;
const [startArrowhead, endArrowhead] =
@@ -3108,7 +3141,7 @@ class App extends React.Component<ExcalidrawProps, AppState> {
);
}
this.setState({ suggestedBindings: [], startBoundElement: null });
if (!elementLocked) {
if (!elementLocked && elementType !== "draw") {
resetCursor();
this.setState((prevState) => ({
draggingElement: null,
@@ -3255,7 +3288,7 @@ class App extends React.Component<ExcalidrawProps, AppState> {
return;
}
if (!elementLocked && draggingElement) {
if (!elementLocked && elementType !== "draw" && draggingElement) {
this.setState((prevState) => ({
selectedElementIds: {
...prevState.selectedElementIds,
@@ -3279,7 +3312,7 @@ class App extends React.Component<ExcalidrawProps, AppState> {
);
}
if (!elementLocked) {
if (!elementLocked && elementType !== "draw") {
resetCursor();
this.setState({
draggingElement: null,
@@ -3554,9 +3587,6 @@ class App extends React.Component<ExcalidrawProps, AppState> {
transformElements(
pointerDownState,
transformHandleType,
(newTransformHandle) => {
pointerDownState.resize.handleType = newTransformHandle;
},
selectedElements,
pointerDownState.resize.arrowDirection,
getRotateWithDiscreteAngleKey(event),
@@ -3612,13 +3642,21 @@ class App extends React.Component<ExcalidrawProps, AppState> {
CANVAS_ONLY_ACTIONS.includes(action.name),
),
{
shortcutName: "toggleGridMode",
label: t("labels.toggleGridMode"),
checked: this.state.gridSize !== null,
shortcutName: "gridMode",
label: t("labels.gridMode"),
action: this.toggleGridMode,
},
{
shortcutName: "toggleStats",
label: t("labels.toggleStats"),
checked: this.state.zenModeEnabled,
shortcutName: "zenMode",
label: t("buttons.zenMode"),
action: this.toggleZenMode,
},
{
checked: this.state.showStats,
shortcutName: "stats",
label: t("stats.title"),
action: this.toggleStats,
},
],
@@ -3695,11 +3733,16 @@ class App extends React.Component<ExcalidrawProps, AppState> {
}, 1000);
}
this.setState(({ zoom }) => ({
zoom: getNewZoom(getNormalizedZoom(zoom.value - delta / 100), zoom, {
x: cursorX,
y: cursorY,
}),
this.setState(({ zoom, offsetLeft, offsetTop }) => ({
zoom: getNewZoom(
getNormalizedZoom(zoom.value - delta / 100),
zoom,
{ left: offsetLeft, top: offsetTop },
{
x: cursorX,
y: cursorY,
},
),
selectedElementIds: {},
previousSelectedElementIds:
Object.keys(selectedElementIds).length !== 0
@@ -3782,7 +3825,9 @@ class App extends React.Component<ExcalidrawProps, AppState> {
};
private resetShouldCacheIgnoreZoomDebounced = debounce(() => {
this.setState({ shouldCacheIgnoreZoom: false });
if (!this.unmounted) {
this.setState({ shouldCacheIgnoreZoom: false });
}
}, 300);
private getCanvasOffsets(offsets?: {
@@ -3814,6 +3859,14 @@ class App extends React.Component<ExcalidrawProps, AppState> {
offsetTop: typeof offsets?.offsetTop === "number" ? offsets.offsetTop : 0,
};
}
private async updateLanguage() {
const currentLang =
languages.find((lang) => lang.code === this.props.langCode) ||
defaultLang;
await setLanguage(currentLang);
this.setAppState({});
}
}
// -----------------------------------------------------------------------------
@@ -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 });
}}
/>
+1 -5
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")}
+1 -1
View File
@@ -218,7 +218,7 @@
left: 2px;
}
@media #{$media-query} {
@media #{$is-mobile-query} {
display: none;
}
}
+40 -3
View File
@@ -1,4 +1,4 @@
@import "open-color/open-color.scss";
@import "../css/_variables";
.excalidraw {
.context-menu {
@@ -32,13 +32,29 @@
display: grid;
grid-template-columns: 1fr 0.2fr;
div:nth-child(1) {
align-items: center;
&.checkmark::before {
position: absolute;
left: 6px;
margin-bottom: 1px;
content: "\2713";
}
&.dangerous {
.context-menu-option__label {
color: $oc-red-7;
}
}
.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;
}
}
@@ -46,9 +62,30 @@
.context-menu-option:hover {
color: var(--popup-background-color);
background-color: var(--select-highlight-color);
&.dangerous {
.context-menu-option__label {
color: var(--popup-background-color);
}
background-color: $oc-red-6;
}
}
.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;
}
}
}
}
+11 -6
View File
@@ -10,6 +10,7 @@ import {
} from "../actions/shortcuts";
type ContextMenuOption = {
checked?: boolean;
shortcutName: ShortcutName;
label: string;
action(): void;
@@ -26,7 +27,6 @@ const ContextMenu = ({ options, onCloseRequest, top, left }: Props) => {
const isDarkTheme = !!document
.querySelector(".excalidraw")
?.classList.contains("Appearance_dark");
return (
<div
className={clsx("excalidraw", {
@@ -43,15 +43,20 @@ const ContextMenu = ({ options, onCloseRequest, top, left }: Props) => {
className="context-menu"
onContextMenu={(event) => event.preventDefault()}
>
{options.map(({ action, shortcutName, label }, idx) => (
{options.map(({ action, checked, shortcutName, label }, idx) => (
<li data-testid={shortcutName} key={idx} onClick={onCloseRequest}>
<button className="context-menu-option" onClick={action}>
<div>{label}</div>
<div>
<button
className={`context-menu-option
${shortcutName === "delete" ? "dangerous" : ""}
${checked ? "checkmark" : ""}`}
onClick={action}
>
<div className="context-menu-option__label">{label}</div>
<kbd className="context-menu-option__shortcut">
{shortcutName
? getShortcutFromShortcutName(shortcutName)
: ""}
</div>
</kbd>
</button>
</li>
))}
+8 -2
View File
@@ -14,7 +14,9 @@ export const DarkModeToggle = (props: {
return (
<label
className={`ToolIcon ToolIcon_type_floating ToolIcon_size_M`}
title={t("buttons.toggleDarkMode")}
title={
props.value === "dark" ? t("buttons.lightMode") : t("buttons.darkMode")
}
>
<input
className="ToolIcon_type_checkbox ToolIcon_toggle_opaque"
@@ -23,7 +25,11 @@ export const DarkModeToggle = (props: {
props.onChange(event.target.checked ? "dark" : "light")
}
checked={props.value === "dark"}
aria-label={t("buttons.toggleDarkMode")}
aria-label={
props.value === "dark"
? t("buttons.lightMode")
: t("buttons.darkMode")
}
/>
<div className="ToolIcon__icon">
{props.value === "light" ? ICONS.MOON : ICONS.SUN}
+15 -7
View File
@@ -1,12 +1,21 @@
@import "../css/_variables";
.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;
+11 -11
View File
@@ -1,13 +1,12 @@
import React, { useCallback, useEffect, useState } from "react";
import clsx from "clsx";
import { Modal } from "./Modal";
import { Island } from "./Island";
import React, { useCallback, useEffect, useState } from "react";
import { t } from "../i18n";
import useIsMobile from "../is-mobile";
import { back, close } from "./icons";
import { KEYS } from "../keys";
import "./Dialog.scss";
import { back, close } from "./icons";
import { Island } from "./Island";
import { Modal } from "./Modal";
const useRefState = <T,>() => {
const [refValue, setRefValue] = useState<T | null>(null);
@@ -20,9 +19,10 @@ const useRefState = <T,>() => {
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>();
@@ -33,7 +33,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 +62,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 +76,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 +90,7 @@ export const Dialog = (props: {
{useIsMobile() ? back : close}
</button>
</h2>
{props.children}
<div className="Dialog__content">{props.children}</div>
</Island>
</Modal>
);
+9 -2
View File
@@ -24,11 +24,18 @@ export const ErrorDialog = ({
<>
{modalIsShown && (
<Dialog
maxWidth={500}
small
onCloseRequest={handleClose}
title={t("errorDialog.title")}
>
<div>{message}</div>
<div>
{message.split("\n").map((line) => (
<>
{line}
<br />
</>
))}
</div>
</Dialog>
)}
</>
+1 -3
View File
@@ -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;
}
+12 -16
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";
@@ -67,7 +66,7 @@ const ExportModal = ({
onExportToPng: ExportCB;
onExportToSvg: ExportCB;
onExportToClipboard: ExportCB;
onExportToBackend: ExportCB;
onExportToBackend?: ExportCB;
onCloseRequest: () => void;
}) => {
const someElementIsSelected = isSomeElementSelected(elements, appState);
@@ -155,13 +154,15 @@ const ExportModal = ({
onClick={() => onExportToClipboard(exportedElements, scale)}
/>
)}
<ToolButton
type="button"
icon={link}
title={t("buttons.getShareableLink")}
aria-label={t("buttons.getShareableLink")}
onClick={() => onExportToBackend(exportedElements)}
/>
{onExportToBackend && (
<ToolButton
type="button"
icon={link}
title={t("buttons.getShareableLink")}
aria-label={t("buttons.getShareableLink")}
onClick={() => onExportToBackend(exportedElements)}
/>
)}
</Stack.Row>
<div className="ExportDialog__name">
{actionManager.renderAction("changeProjectName")}
@@ -235,7 +236,7 @@ export const ExportDialog = ({
onExportToPng: ExportCB;
onExportToSvg: ExportCB;
onExportToClipboard: ExportCB;
onExportToBackend: ExportCB;
onExportToBackend?: ExportCB;
}) => {
const [modalIsShown, setModalIsShown] = useState(false);
const triggerButton = useRef<HTMLButtonElement>(null);
@@ -249,7 +250,6 @@ export const ExportDialog = ({
<>
<ToolButton
onClick={() => {
trackEvent(EVENT_DIALOG, "export");
setModalIsShown(true);
}}
icon={exportFile}
@@ -260,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}
+1 -5
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"
@@ -1,23 +1,28 @@
@import "../css/_variables";
.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;
}
}
+348
View File
@@ -0,0 +1,348 @@
import React from "react";
import { t } from "../i18n";
import { isDarwin } 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+'")]}
/>
</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={[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>
</>
);
};
+2 -12
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>
);
+5 -5
View File
@@ -1,5 +1,8 @@
@import "../css/_variables";
// this is loosely based on the longest hint text
$wide-viewport-width: 1000px;
.excalidraw {
.HintViewer {
pointer-events: none;
@@ -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 {
+2 -2
View File
@@ -38,8 +38,8 @@ const getHints = ({ appState, elements }: Hint) => {
selectedElements.length === 1
) {
const targetElement = selectedElements[0];
if (isLinearElement(targetElement) && targetElement.points.length > 2) {
return null;
if (isLinearElement(targetElement) && targetElement.points.length === 2) {
return t("hints.lockAngle");
}
return t("hints.resize");
}
+5 -1
View File
@@ -93,12 +93,16 @@
grid-auto-flow: column;
grid-gap: 0.5rem;
border-radius: 4px;
:root[dir="rtl"] & {
padding: 0.4rem;
}
}
.picker-keybinding {
position: absolute;
bottom: 2px;
font-size: 0.7em;
color: var(--keybinding-color);
:root[dir="ltr"] & {
right: 2px;
@@ -107,7 +111,7 @@
:root[dir="rtl"] & {
left: 2px;
}
@media #{$media-query} {
@media #{$is-mobile-query} {
display: none;
}
}
+17 -6
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,
});
+1 -1
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;
+17 -63
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;
}
}
}
@@ -39,64 +51,6 @@
width: 1.2rem;
height: 1.2rem;
}
&.tooltip .tooltip-text {
visibility: hidden;
width: 20rem;
bottom: calc(50% + 0.8rem + 6px);
:root[dir="ltr"] & {
left: -5px;
}
:root[dir="rtl"] & {
right: -5px;
}
background-color: $oc-black;
color: $oc-white;
text-align: center;
border-radius: 6px;
padding: 5px;
position: absolute;
z-index: 10;
font-size: 13px;
line-height: 1.5;
white-space: pre-wrap;
&::after {
--size: 6px;
content: "";
border: var(--size) solid transparent;
border-top-color: $oc-black;
position: absolute;
bottom: calc(-2 * var(--size));
:root[dir="ltr"] & {
left: calc(5px + var(--size) / 2);
}
:root[dir="rtl"] & {
right: calc(5px + var(--size) / 2);
}
}
}
// 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-text {
visibility: visible;
}
.tooltip-text:hover {
visibility: visible;
}
}
&__github-corner {
+128 -155
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 { 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,11 +50,17 @@ 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;
}
const useOnClickOutside = (
@@ -118,59 +114,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++) {
@@ -269,7 +251,6 @@ const LibraryMenu = ({
const items = await Library.loadLibrary();
const nextItems = items.filter((_, index) => index !== indexToRemove);
Library.saveLibrary(nextItems);
trackEvent(EVENT_LIBRARY, "remove");
setLibraryItems(nextItems);
}, []);
@@ -278,7 +259,6 @@ const LibraryMenu = ({
const items = await Library.loadLibrary();
const nextItems = [...items, elements];
onAddToLibrary();
trackEvent(EVENT_LIBRARY, "add");
Library.saveLibrary(nextItems);
setLibraryItems(nextItems);
},
@@ -313,14 +293,15 @@ const LayerUI = ({
elements,
onCollabButtonClick,
onLockToggle,
onInsertShape,
onInsertElements,
zenModeEnabled,
toggleZenMode,
isCollaborating,
onExportToBackend,
renderCustomFooter,
}: LayerUIProps) => {
const isMobile = useIsMobile();
// TODO: Extend tooltip component and use here.
const renderEncryptedIcon = () => (
<a
className={clsx("encrypted-icon tooltip zen-mode-visibility", {
@@ -329,14 +310,10 @@ const LayerUI = ({
href="https://blog.excalidraw.com/end-to-end-encryption/"
target="_blank"
rel="noopener noreferrer"
onClick={() => {
trackEvent(EVENT_EXIT, "e2ee shield");
}}
>
<span className="tooltip-text" dir="auto">
{t("encrypted.tooltip")}
</span>
{shield}
<Tooltip label={t("encrypted.tooltip")} position="above" long={true}>
{shield}
</Tooltip>
</a>
);
@@ -360,6 +337,7 @@ const LayerUI = ({
});
}
};
return (
<ExportDialog
elements={elements}
@@ -368,28 +346,14 @@ const LayerUI = ({
onExportToPng={createExporter("png")}
onExportToSvg={createExporter("svg")}
onExportToClipboard={createExporter("clipboard")}
onExportToBackend={async (exportedElements) => {
if (canvas) {
try {
await exportCanvas(
"backend",
exportedElements,
{
...appState,
selectedElementIds: {},
},
canvas,
appState,
);
} catch (error) {
if (error.name !== "AbortError") {
const { width, height } = canvas;
console.error(error, { width, height });
setAppState({ errorMessage: error.message });
onExportToBackend={
onExportToBackend
? (elements) => {
onExportToBackend &&
onExportToBackend(elements, appState, canvas);
}
}
}
}}
: undefined
}
/>
);
};
@@ -465,7 +429,7 @@ const LayerUI = ({
<LibraryMenu
pendingElements={getSelectedElements(elements, appState)}
onClickOutside={closeLibrary}
onInsertShape={onInsertShape}
onInsertShape={onInsertElements}
onAddToLibrary={deselectItems}
setAppState={setAppState}
/>
@@ -567,14 +531,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
@@ -589,7 +546,6 @@ const LayerUI = ({
<button
className="scroll-back-to-content"
onClick={() => {
trackEvent(EVENT_ACTION, "scroll to content");
setAppState({
...calculateScrollCenter(elements, appState, canvas),
});
@@ -601,21 +557,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
@@ -623,11 +566,44 @@ 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}
/>
</>
) : (
<div className="layer-ui__wrapper">
{dialogs}
{renderFixedSideContainer()}
{renderBottomAppMenu()}
{
@@ -650,8 +626,6 @@ const LayerUI = ({
const areEqual = (prev: LayerUIProps, next: LayerUIProps) => {
const getNecessaryObj = (appState: AppState): Partial<AppState> => {
const {
cursorX,
cursorY,
suggestedBindings,
startBoundElement: boundElement,
...ret
@@ -662,9 +636,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])
);
+6 -6
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) {
+4 -15
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,7 @@ type MobileMenuProps = {
onLockToggle: () => void;
canvas: HTMLCanvasElement | null;
isCollaborating: boolean;
renderCustomFooter?: (isMobile: boolean) => JSX.Element;
};
export const MobileMenu = ({
@@ -44,9 +42,9 @@ export const MobileMenu = ({
onLockToggle,
canvas,
isCollaborating,
renderCustomFooter,
}: MobileMenuProps) => (
<>
{appState.isLoading && <LoadingMessage />}
<FixedSideContainer side="top">
<Section heading="shapes">
{(heading) => (
@@ -104,15 +102,7 @@ export const MobileMenu = ({
appState={appState}
setAppState={setAppState}
/>
<fieldset>
<legend>{t("labels.language")}</legend>
<LanguageList
onChange={async (lng) => {
await setLanguage(lng);
setAppState({});
}}
/>
</fieldset>
{renderCustomFooter?.(true)}
<fieldset>
<legend>{t("labels.collaborators")}</legend>
<UserList mobile>
@@ -158,7 +148,6 @@ export const MobileMenu = ({
<button
className="scroll-back-to-content"
onClick={() => {
trackEvent(EVENT_ACTION, "scroll to content");
setAppState({
...calculateScrollCenter(elements, appState, canvas),
});
+10 -8
View File
@@ -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;
}
+1 -5
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>
+46
View File
@@ -0,0 +1,46 @@
@import "../css/_variables";
.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;
}
}
}
}
+124
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>
);
};
-341
View File
@@ -1,341 +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("buttons.toggleFullScreen")}
shortcuts={["F"]}
/>
<Shortcut
label={t("buttons.toggleZenMode")}
shortcuts={[getShortcutKey("Alt+Z")]}
/>
<Shortcut
label={t("labels.toggleGridMode")}
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>
</>
);
};
+15 -1
View File
@@ -6,8 +6,10 @@
right: 12px;
font-size: 12px;
z-index: 999;
h3 {
margin: 0 24px 8px 0;
white-space: nowrap;
}
.close {
@@ -29,9 +31,21 @@
}
tr {
td:nth-child(2) {
min-width: 48px;
min-width: 24px;
text-align: right;
}
}
}
:root[dir="rtl"] & {
left: 12px;
right: initial;
h3 {
margin: 0 0 8px 24px;
}
.close {
float: left;
}
}
}
-1
View File
@@ -85,7 +85,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>
+32
View File
@@ -0,0 +1,32 @@
@import "../css/_variables";
.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
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>
);
};
+17
View File
@@ -1,4 +1,6 @@
@import "open-color/open-color.scss";
@import "../css/variables";
.excalidraw {
.ToolIcon {
display: inline-flex;
@@ -140,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;
@@ -151,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;
@@ -160,6 +165,7 @@
margin-left: 0;
border-radius: 20px 0 0 20px;
z-index: 1;
background-color: var(--button-gray-1);
@@ -181,6 +187,17 @@
}
}
.TooltipIcon {
width: 0.9em;
height: 0.9em;
margin-left: 5px;
margin-top: 1px;
@media #{$is-mobile-query} {
display: none;
}
}
.unlocked-icon {
:root[dir="ltr"] & {
left: 2px;
+27 -10
View File
@@ -7,39 +7,56 @@
.Tooltip__label {
--arrow-size: 4px;
visibility: hidden;
width: 10ch;
background: $oc-black;
color: $oc-white;
text-align: center;
border-radius: 4px;
padding: 4px;
border-radius: 6px;
padding: 8px;
position: absolute;
z-index: 10;
font-size: 0.7rem;
font-size: 13px;
line-height: 1.5;
top: calc(100% + var(--arrow-size) + 3px);
font-weight: 500;
// extra pixel offset for unknown reasons
left: calc(-50% + var(--arrow-size) / 2 - 1px);
left: calc(50% + var(--arrow-size) / 2 - 1px);
transform: translateX(-50%);
word-wrap: break-word;
&::after {
content: "";
border: var(--arrow-size) solid transparent;
border-bottom-color: $oc-black;
position: absolute;
bottom: 100%;
left: calc(50% - var(--arrow-size));
}
&--above {
bottom: calc(100% + var(--arrow-size) + 3px);
&::after {
border-top-color: $oc-black;
top: 100%;
}
}
&--below {
top: calc(100% + var(--arrow-size) + 3px);
&::after {
border-bottom-color: $oc-black;
bottom: 100%;
}
}
}
// 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) {
body:active & .Tooltip:not(:hover) {
pointer-events: none;
}
body:not(:active) .Tooltip:hover .Tooltip__label {
body:not(:active) & .Tooltip:hover .Tooltip__label {
visibility: visible;
}
+18 -2
View File
@@ -5,11 +5,27 @@ import React from "react";
type TooltipProps = {
children: React.ReactNode;
label: string;
position?: "above" | "below";
long?: boolean;
};
export const Tooltip = ({ children, label }: TooltipProps) => (
export const Tooltip = ({
children,
label,
position = "below",
long = false,
}: TooltipProps) => (
<div className="Tooltip">
<span className="Tooltip__label">{label}</span>
<span
className={
position === "above"
? "Tooltip__label Tooltip__label--above"
: "Tooltip__label Tooltip__label--below"
}
style={{ width: long ? "50ch" : "10ch" }}
>
{label}
</span>
{children}
</div>
);
+5
View File
@@ -108,6 +108,11 @@ export const redo = createIcon(
{ mirror: true },
);
export const questionCircle = createIcon(
"M504 256c0 136.997-111.043 248-248 248S8 392.997 8 256C8 119.083 119.043 8 256 8s248 111.083 248 248zM262.655 90c-54.497 0-89.255 22.957-116.549 63.758-3.536 5.286-2.353 12.415 2.715 16.258l34.699 26.31c5.205 3.947 12.621 3.008 16.665-2.122 17.864-22.658 30.113-35.797 57.303-35.797 20.429 0 45.698 13.148 45.698 32.958 0 14.976-12.363 22.667-32.534 33.976C247.128 238.528 216 254.941 216 296v4c0 6.627 5.373 12 12 12h56c6.627 0 12-5.373 12-12v-1.333c0-28.462 83.186-29.647 83.186-106.667 0-58.002-60.165-102-116.531-102zM256 338c-25.365 0-46 20.635-46 46 0 25.364 20.635 46 46 46s46-20.636 46-46c0-25.365-20.635-46-46-46z",
{ mirror: true },
);
// Icon imported form Storybook
// Storybook is licensed under MIT https://github.com/storybookjs/storybook/blob/next/LICENSE
export const resetZoom = createIcon(
+6
View File
@@ -1,5 +1,7 @@
import { FontFamily } from "./element/types";
export const APP_NAME = "Excalidraw";
export const DRAGGING_THRESHOLD = 10; // 10px
export const LINE_CONFIRM_THRESHOLD = 10; // 10px
export const ELEMENT_SHIFT_TRANSLATE_AMOUNT = 5;
@@ -68,6 +70,7 @@ 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"];
@@ -85,3 +88,6 @@ export const STORAGE_KEYS = {
// time in milliseconds
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 = 15000;
+2 -1
View File
@@ -1,3 +1,4 @@
@import "open-color/open-color.scss";
$media-query: "(max-width: 600px), (max-height: 500px) and (max-width: 1000px)";
// keep up to date with is-mobile.tsx
$is-mobile-query: "(max-width: 600px), (max-height: 500px) and (max-width: 1000px)";
+3 -2
View File
@@ -13,7 +13,7 @@
a {
font-weight: 500;
text-decoration: none;
color: $oc-blue-7; /* OC Blue 7 */
color: var(--link-color);
&:hover {
text-decoration: underline;
@@ -431,6 +431,7 @@
cursor: pointer;
fill: $oc-gray-6;
bottom: 14px;
width: 1.5rem;
:root[dir="ltr"] & {
right: 14px;
@@ -441,7 +442,7 @@
}
}
@media #{$media-query} {
@media #{$is-mobile-query} {
aside {
display: none;
}
+3 -6
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};
}
}
+1 -3
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";
@@ -9,7 +8,7 @@ import { AppState } from "../types";
import { restore } from "./restore";
import { ImportedDataState, LibraryData } from "./types";
export const parseFileContents = async (blob: Blob | File) => {
const parseFileContents = async (blob: Blob | File) => {
let contents: string;
if (blob.type === "image/png") {
@@ -111,7 +110,6 @@ export const loadFromBlob = async (
localAppState,
);
trackEvent(EVENT_IO, "load", getMimeType(blob));
return result;
} catch (error) {
console.error(error.message);
+3 -78
View File
@@ -1,83 +1,19 @@
import { fileSave } from "browser-nativefs";
import { EVENT_IO, trackEvent } from "../analytics";
import { getDefaultAppState } from "../appState";
import {
copyCanvasToClipboardAsPng,
copyTextToSystemClipboard,
} from "../clipboard";
import {
ExcalidrawElement,
NonDeletedExcalidrawElement,
} from "../element/types";
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";
export { loadFromJSON, saveAsJSON } from "./json";
const BACKEND_V2_POST = process.env.REACT_APP_BACKEND_V2_POST_URL;
export const exportToBackend = async (
elements: readonly ExcalidrawElement[],
appState: AppState,
) => {
const json = serializeAsJSON(elements, appState);
const encoded = new TextEncoder().encode(json);
const key = await window.crypto.subtle.generateKey(
{
name: "AES-GCM",
length: 128,
},
true, // extractable
["encrypt", "decrypt"],
);
// The iv is set to 0. We are never going to reuse the same key so we don't
// need to have an iv. (I hope that's correct...)
const iv = new Uint8Array(12);
// We use symmetric encryption. AES-GCM is the recommended algorithm and
// includes checks that the ciphertext has not been modified by an attacker.
const encrypted = await window.crypto.subtle.encrypt(
{
name: "AES-GCM",
iv,
},
key,
encoded,
);
// We use jwk encoding to be able to extract just the base64 encoded key.
// We will hardcode the rest of the attributes when importing back the key.
const exportedKey = await window.crypto.subtle.exportKey("jwk", key);
try {
const response = await fetch(BACKEND_V2_POST, {
method: "POST",
body: encrypted,
});
const json = await response.json();
if (json.id) {
const url = new URL(window.location.href);
// We need to store the key (and less importantly the id) as hash instead
// of queryParam in order to never send it to the server
url.hash = `json=${json.id},${exportedKey.k!}`;
const urlString = url.toString();
window.prompt(`🔒${t("alerts.uploadedSecurly")}`, urlString);
trackEvent(EVENT_IO, "export", "backend");
} else if (json.error_class === "RequestTooLargeError") {
window.alert(t("alerts.couldNotCreateShareableLinkTooBig"));
} else {
window.alert(t("alerts.couldNotCreateShareableLink"));
}
} catch (error) {
console.error(error);
window.alert(t("alerts.couldNotCreateShareableLink"));
}
};
export const exportCanvas = async (
type: ExportType,
elements: readonly NonDeletedExcalidrawElement[],
@@ -100,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, {
@@ -123,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;
}
@@ -158,24 +92,15 @@ 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;
}
throw new Error(t("alerts.couldNotCopyToClipboard"));
}
} else if (type === "backend") {
exportToBackend(elements, {
...appState,
viewBackgroundColor: exportBackground
? appState.viewBackgroundColor
: getDefaultAppState().viewBackgroundColor,
});
}
// clean up the DOM
+5 -9
View File
@@ -1,13 +1,11 @@
import { ExcalidrawElement } from "../element/types";
import { AppState } from "../types";
import { cleanAppStateForExport } from "../appState";
import { fileOpen, fileSave } from "browser-nativefs";
import { loadFromBlob } from "./blob";
import { Library } from "./library";
import { cleanAppStateForExport } from "../appState";
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);
};
-1
View File
@@ -34,7 +34,6 @@ export {
export {
resizeTest,
getCursorForResizingElement,
normalizeTransformHandleType,
getElementWithTransformHandleType,
getTransformHandleTypeFromCoords,
} from "./resizeTest";
+7 -10
View File
@@ -24,13 +24,13 @@ export const mutateElement = <TElement extends Mutable<ExcalidrawElement>>(
// (see https://github.com/microsoft/TypeScript/issues/21732)
const { points } = updates as any;
if (points !== undefined) {
if (typeof points !== "undefined") {
updates = { ...getSizeFromPoints(points), ...updates };
}
for (const key in updates) {
const value = (updates as any)[key];
if (value !== undefined) {
if (typeof value !== "undefined") {
if (
(element as any)[key] === value &&
// if object, always update in case its deep prop was mutated
@@ -72,9 +72,9 @@ export const mutateElement = <TElement extends Mutable<ExcalidrawElement>>(
}
if (
updates.height !== undefined ||
updates.width !== undefined ||
points !== undefined
typeof updates.height !== "undefined" ||
typeof updates.width !== "undefined" ||
typeof points !== "undefined"
) {
invalidateShapeForElement(element);
}
@@ -84,12 +84,9 @@ export const mutateElement = <TElement extends Mutable<ExcalidrawElement>>(
Scene.getScene(element)?.informMutation();
};
export const newElementWith = <
TElement extends ExcalidrawElement,
K extends keyof Omit<TElement, "id" | "version" | "versionNonce">
>(
export const newElementWith = <TElement extends ExcalidrawElement>(
element: TElement,
updates: Pick<TElement, K>,
updates: ElementUpdate<TElement>,
): TElement => ({
...element,
...updates,
+111 -206
View File
@@ -4,7 +4,6 @@ import { rescalePoints } from "../points";
import {
rotate,
adjustXYWithRotation,
getFlipAdjustment,
centerPoint,
rotatePoint,
} from "../math";
@@ -13,21 +12,16 @@ import {
ExcalidrawTextElement,
NonDeletedExcalidrawElement,
NonDeleted,
ExcalidrawGenericElement,
ExcalidrawElement,
} from "./types";
import {
getElementAbsoluteCoords,
getCommonBounds,
getResizedElementAbsoluteCoords,
} from "./bounds";
import { isGenericElement, isLinearElement, isTextElement } from "./typeChecks";
import { isLinearElement, isTextElement } from "./typeChecks";
import { mutateElement } from "./mutateElement";
import { getPerfectElementSize } from "./sizeHelpers";
import {
getCursorForResizingElement,
normalizeTransformHandleType,
} from "./resizeTest";
import { getCursorForResizingElement } from "./resizeTest";
import { measureText, getFontString } from "../utils";
import { updateBoundElements } from "./binding";
import {
@@ -49,7 +43,6 @@ const normalizeAngle = (angle: number): number => {
export const transformElements = (
pointerDownState: PointerDownState,
transformHandleType: MaybeTransformHandleType,
setTransformHandle: (nextTransformHandle: MaybeTransformHandleType) => void,
selectedElements: readonly NonDeletedExcalidrawElement[],
resizeArrowDirection: "origin" | "end",
isRotateWithDiscreteAngle: boolean,
@@ -101,36 +94,15 @@ export const transformElements = (
);
updateBoundElements(element);
} else if (transformHandleType) {
if (isGenericElement(element)) {
resizeSingleGenericElement(
pointerDownState.originalElements.get(element.id) as typeof element,
shouldKeepSidesRatio,
element,
transformHandleType,
isResizeCenterPoint,
pointerX,
pointerY,
);
} else {
const keepSquareAspectRatio = shouldKeepSidesRatio;
resizeSingleNonGenericElement(
element,
transformHandleType,
isResizeCenterPoint,
keepSquareAspectRatio,
pointerX,
pointerY,
);
setTransformHandle(
normalizeTransformHandleType(element, transformHandleType),
);
if (element.width < 0) {
mutateElement(element, { width: -element.width });
}
if (element.height < 0) {
mutateElement(element, { height: -element.height });
}
}
resizeSingleElement(
pointerDownState.originalElements.get(element.id) as typeof element,
shouldKeepSidesRatio,
element,
transformHandleType,
isResizeCenterPoint,
pointerX,
pointerY,
);
}
// update cursor
@@ -414,8 +386,8 @@ const resizeSingleTextElement = (
}
};
const resizeSingleGenericElement = (
stateAtResizeStart: NonDeleted<ExcalidrawGenericElement>,
const resizeSingleElement = (
stateAtResizeStart: NonDeletedExcalidrawElement,
shouldKeepSidesRatio: boolean,
element: NonDeletedExcalidrawElement,
transformHandleDirection: TransformHandleDirection,
@@ -423,251 +395,184 @@ const resizeSingleGenericElement = (
pointerX: number,
pointerY: number,
) => {
const [x1, y1, x2, y2] = getElementAbsoluteCoords(stateAtResizeStart);
// Gets bounds corners
const [x1, y1, x2, y2] = getResizedElementAbsoluteCoords(
stateAtResizeStart,
stateAtResizeStart.width,
stateAtResizeStart.height,
);
const startTopLeft: Point = [x1, y1];
const startBottomRight: Point = [x2, y2];
const startCenter: Point = centerPoint(startTopLeft, startBottomRight);
// Calculate new dimensions based on cursor position
let newWidth = stateAtResizeStart.width;
let newHeight = stateAtResizeStart.height;
const rotatedPointer = rotatePoint(
[pointerX, pointerY],
startCenter,
-stateAtResizeStart.angle,
);
//Get bounds corners rendered on screen
const [esx1, esy1, esx2, esy2] = getResizedElementAbsoluteCoords(
element,
element.width,
element.height,
);
const boundsCurrentWidth = esx2 - esx1;
const boundsCurrentHeight = esy2 - esy1;
// It's important we set the initial scale value based on the width and height at resize start,
// otherwise previous dimensions affected by modifiers will be taken into account.
const atStartBoundsWidth = startBottomRight[0] - startTopLeft[0];
const atStartBoundsHeight = startBottomRight[1] - startTopLeft[1];
let scaleX = atStartBoundsWidth / boundsCurrentWidth;
let scaleY = atStartBoundsHeight / boundsCurrentHeight;
if (transformHandleDirection.includes("e")) {
newWidth = rotatedPointer[0] - startTopLeft[0];
scaleX = (rotatedPointer[0] - startTopLeft[0]) / boundsCurrentWidth;
}
if (transformHandleDirection.includes("s")) {
newHeight = rotatedPointer[1] - startTopLeft[1];
scaleY = (rotatedPointer[1] - startTopLeft[1]) / boundsCurrentHeight;
}
if (transformHandleDirection.includes("w")) {
newWidth = startBottomRight[0] - rotatedPointer[0];
scaleX = (startBottomRight[0] - rotatedPointer[0]) / boundsCurrentWidth;
}
if (transformHandleDirection.includes("n")) {
newHeight = startBottomRight[1] - rotatedPointer[1];
scaleY = (startBottomRight[1] - rotatedPointer[1]) / boundsCurrentHeight;
}
// Linear elements dimensions differ from bounds dimensions
const eleInitialWidth = stateAtResizeStart.width;
const eleInitialHeight = stateAtResizeStart.height;
// We have to use dimensions of element on screen, otherwise the scaling of the
// dimensions won't match the cursor for linear elements.
let eleNewWidth = element.width * scaleX;
let eleNewHeight = element.height * scaleY;
// adjust dimensions for resizing from center
if (isResizeFromCenter) {
newWidth = 2 * newWidth - stateAtResizeStart.width;
newHeight = 2 * newHeight - stateAtResizeStart.height;
eleNewWidth = 2 * eleNewWidth - eleInitialWidth;
eleNewHeight = 2 * eleNewHeight - eleInitialHeight;
}
// adjust dimensions to keep sides ratio
if (shouldKeepSidesRatio) {
const widthRatio = Math.abs(newWidth) / stateAtResizeStart.width;
const heightRatio = Math.abs(newHeight) / stateAtResizeStart.height;
const widthRatio = Math.abs(eleNewWidth) / eleInitialWidth;
const heightRatio = Math.abs(eleNewHeight) / eleInitialHeight;
if (transformHandleDirection.length === 1) {
newHeight *= widthRatio;
newWidth *= heightRatio;
eleNewHeight *= widthRatio;
eleNewWidth *= heightRatio;
}
if (transformHandleDirection.length === 2) {
const ratio = Math.max(widthRatio, heightRatio);
newWidth = stateAtResizeStart.width * ratio * Math.sign(newWidth);
newHeight = stateAtResizeStart.height * ratio * Math.sign(newHeight);
eleNewWidth = eleInitialWidth * ratio * Math.sign(eleNewWidth);
eleNewHeight = eleInitialHeight * ratio * Math.sign(eleNewHeight);
}
}
const [
newBoundsX1,
newBoundsY1,
newBoundsX2,
newBoundsY2,
] = getResizedElementAbsoluteCoords(
stateAtResizeStart,
eleNewWidth,
eleNewHeight,
);
const newBoundsWidth = newBoundsX2 - newBoundsX1;
const newBoundsHeight = newBoundsY2 - newBoundsY1;
// Calculate new topLeft based on fixed corner during resize
let newTopLeft = startTopLeft as [number, number];
let newTopLeft = [...startTopLeft] as [number, number];
if (["n", "w", "nw"].includes(transformHandleDirection)) {
newTopLeft = [
startBottomRight[0] - Math.abs(newWidth),
startBottomRight[1] - Math.abs(newHeight),
startBottomRight[0] - Math.abs(newBoundsWidth),
startBottomRight[1] - Math.abs(newBoundsHeight),
];
}
if (transformHandleDirection === "ne") {
const bottomLeft = [
stateAtResizeStart.x,
stateAtResizeStart.y + stateAtResizeStart.height,
];
newTopLeft = [bottomLeft[0], bottomLeft[1] - Math.abs(newHeight)];
const bottomLeft = [startTopLeft[0], startBottomRight[1]];
newTopLeft = [bottomLeft[0], bottomLeft[1] - Math.abs(newBoundsHeight)];
}
if (transformHandleDirection === "sw") {
const topRight = [
stateAtResizeStart.x + stateAtResizeStart.width,
stateAtResizeStart.y,
];
newTopLeft = [topRight[0] - Math.abs(newWidth), topRight[1]];
const topRight = [startBottomRight[0], startTopLeft[1]];
newTopLeft = [topRight[0] - Math.abs(newBoundsWidth), topRight[1]];
}
// Keeps opposite handle fixed during resize
if (shouldKeepSidesRatio) {
if (["s", "n"].includes(transformHandleDirection)) {
newTopLeft[0] = startCenter[0] - newWidth / 2;
newTopLeft[0] = startCenter[0] - newBoundsWidth / 2;
}
if (["e", "w"].includes(transformHandleDirection)) {
newTopLeft[1] = startCenter[1] - newHeight / 2;
newTopLeft[1] = startCenter[1] - newBoundsHeight / 2;
}
}
// Flip horizontally
if (newWidth < 0) {
if (eleNewWidth < 0) {
if (transformHandleDirection.includes("e")) {
newTopLeft[0] -= Math.abs(newWidth);
newTopLeft[0] -= Math.abs(newBoundsWidth);
}
if (transformHandleDirection.includes("w")) {
newTopLeft[0] += Math.abs(newWidth);
newTopLeft[0] += Math.abs(newBoundsWidth);
}
}
// Flip vertically
if (newHeight < 0) {
if (eleNewHeight < 0) {
if (transformHandleDirection.includes("s")) {
newTopLeft[1] -= Math.abs(newHeight);
newTopLeft[1] -= Math.abs(newBoundsHeight);
}
if (transformHandleDirection.includes("n")) {
newTopLeft[1] += Math.abs(newHeight);
newTopLeft[1] += Math.abs(newBoundsHeight);
}
}
if (isResizeFromCenter) {
newTopLeft[0] = startCenter[0] - Math.abs(newWidth) / 2;
newTopLeft[1] = startCenter[1] - Math.abs(newHeight) / 2;
newTopLeft[0] = startCenter[0] - Math.abs(newBoundsWidth) / 2;
newTopLeft[1] = startCenter[1] - Math.abs(newBoundsHeight) / 2;
}
// adjust topLeft to new rotation point
const angle = stateAtResizeStart.angle;
const rotatedTopLeft = rotatePoint(newTopLeft, startCenter, angle);
const newCenter: Point = [
newTopLeft[0] + Math.abs(newWidth) / 2,
newTopLeft[1] + Math.abs(newHeight) / 2,
newTopLeft[0] + Math.abs(newBoundsWidth) / 2,
newTopLeft[1] + Math.abs(newBoundsHeight) / 2,
];
const rotatedNewCenter = rotatePoint(newCenter, startCenter, angle);
newTopLeft = rotatePoint(rotatedTopLeft, rotatedNewCenter, -angle);
// Readjust points for linear elements
const rescaledPoints = rescalePointsInElement(
stateAtResizeStart,
eleNewWidth,
eleNewHeight,
);
// For linear elements (x,y) are the coordinates of the first drawn point not the top-left corner
// So we need to readjust (x,y) to be where the first point should be
const newOrigin = [...newTopLeft];
newOrigin[0] += stateAtResizeStart.x - newBoundsX1;
newOrigin[1] += stateAtResizeStart.y - newBoundsY1;
const resizedElement = {
width: Math.abs(newWidth),
height: Math.abs(newHeight),
x: newTopLeft[0],
y: newTopLeft[1],
width: Math.abs(eleNewWidth),
height: Math.abs(eleNewHeight),
x: newOrigin[0],
y: newOrigin[1],
...rescaledPoints,
};
updateBoundElements(element, {
newSize: { width: resizedElement.width, height: resizedElement.height },
});
mutateElement(element, resizedElement);
};
const resizeSingleNonGenericElement = (
element: NonDeleted<Exclude<ExcalidrawElement, ExcalidrawGenericElement>>,
transformHandleType: "n" | "s" | "w" | "e" | "nw" | "ne" | "sw" | "se",
isResizeFromCenter: boolean,
keepSquareAspectRatio: boolean,
pointerX: number,
pointerY: number,
) => {
const [x1, y1, x2, y2] = getElementAbsoluteCoords(element);
const cx = (x1 + x2) / 2;
const cy = (y1 + y2) / 2;
// rotation pointer with reverse angle
const [rotatedX, rotatedY] = rotate(
pointerX,
pointerY,
cx,
cy,
-element.angle,
);
let scaleX = 1;
let scaleY = 1;
if (
transformHandleType === "e" ||
transformHandleType === "ne" ||
transformHandleType === "se"
) {
scaleX = (rotatedX - x1) / (x2 - x1);
}
if (
transformHandleType === "s" ||
transformHandleType === "sw" ||
transformHandleType === "se"
) {
scaleY = (rotatedY - y1) / (y2 - y1);
}
if (
transformHandleType === "w" ||
transformHandleType === "nw" ||
transformHandleType === "sw"
) {
scaleX = (x2 - rotatedX) / (x2 - x1);
}
if (
transformHandleType === "n" ||
transformHandleType === "nw" ||
transformHandleType === "ne"
) {
scaleY = (y2 - rotatedY) / (y2 - y1);
}
let nextWidth = element.width * scaleX;
let nextHeight = element.height * scaleY;
if (keepSquareAspectRatio) {
nextWidth = nextHeight = Math.max(nextWidth, nextHeight);
}
const [nextX1, nextY1, nextX2, nextY2] = getResizedElementAbsoluteCoords(
element,
nextWidth,
nextHeight,
);
const deltaX1 = (x1 - nextX1) / 2;
const deltaY1 = (y1 - nextY1) / 2;
const deltaX2 = (x2 - nextX2) / 2;
const deltaY2 = (y2 - nextY2) / 2;
const rescaledPoints = rescalePointsInElement(element, nextWidth, nextHeight);
updateBoundElements(element, {
newSize: { width: nextWidth, height: nextHeight },
});
const [finalX1, finalY1, finalX2, finalY2] = getResizedElementAbsoluteCoords(
{
...element,
...rescaledPoints,
},
Math.abs(nextWidth),
Math.abs(nextHeight),
);
const [flipDiffX, flipDiffY] = getFlipAdjustment(
transformHandleType,
nextWidth,
nextHeight,
nextX1,
nextY1,
nextX2,
nextY2,
finalX1,
finalY1,
finalX2,
finalY2,
isLinearElement(element),
element.angle,
);
const [nextElementX, nextElementY] = adjustXYWithRotation(
getSidesForTransformHandle(transformHandleType, isResizeFromCenter),
element.x - flipDiffX,
element.y - flipDiffY,
element.angle,
deltaX1,
deltaY1,
deltaX2,
deltaY2,
);
if (
nextWidth !== 0 &&
nextHeight !== 0 &&
Number.isFinite(nextElementX) &&
Number.isFinite(nextElementY)
resizedElement.width !== 0 &&
resizedElement.height !== 0 &&
Number.isFinite(resizedElement.x) &&
Number.isFinite(resizedElement.y)
) {
mutateElement(element, {
width: nextWidth,
height: nextHeight,
x: nextElementX,
y: nextElementY,
...rescaledPoints,
updateBoundElements(element, {
newSize: { width: resizedElement.width, height: resizedElement.height },
});
mutateElement(element, resizedElement);
}
};
-54
View File
@@ -173,57 +173,3 @@ export const getCursorForResizingElement = (resizingElement: {
return cursor ? `${cursor}-resize` : "";
};
export const normalizeTransformHandleType = (
element: ExcalidrawElement,
transformHandleType: TransformHandleType,
): TransformHandleType => {
if (element.width >= 0 && element.height >= 0) {
return transformHandleType;
}
if (element.width < 0 && element.height < 0) {
switch (transformHandleType) {
case "nw":
return "se";
case "ne":
return "sw";
case "se":
return "nw";
case "sw":
return "ne";
}
} else if (element.width < 0) {
switch (transformHandleType) {
case "nw":
return "ne";
case "ne":
return "nw";
case "se":
return "sw";
case "sw":
return "se";
case "e":
return "w";
case "w":
return "e";
}
} else {
switch (transformHandleType) {
case "nw":
return "sw";
case "ne":
return "se";
case "se":
return "ne";
case "sw":
return "nw";
case "n":
return "s";
case "s":
return "n";
}
}
return transformHandleType;
};
+12 -14
View File
@@ -1,6 +1,7 @@
import { Point } from "../types";
import { FONT_FAMILY } from "../constants";
export type ChartType = "bar" | "line";
export type FillStyle = "hachure" | "cross-hatch" | "solid";
export type FontFamily = keyof typeof FONT_FAMILY;
export type FontString = string & { _brand: "fontString" };
@@ -21,16 +22,26 @@ type _ExcalidrawElementBase = Readonly<{
strokeWidth: number;
strokeStyle: StrokeStyle;
strokeSharpness: StrokeSharpness;
roughness: 0 | 1 | 2;
roughness: number;
opacity: number;
width: number;
height: number;
angle: number;
/** Random integer used to seed shape generation so that the roughjs shape
doesn't differ across renders. */
seed: number;
/** Integer that is sequentially incremented on each change. Used to reconcile
elements during collaboration or when saving to server. */
version: number;
/** Random integer that is regenerated on each change.
Used for deterministic reconciliation of updates during collaboration,
in case the versions (see above) are identical. */
versionNonce: number;
isDeleted: boolean;
/** List of groups the element belongs to.
Ordered from deepest to shallowest. */
groupIds: readonly GroupId[];
/** Ids of (linear) elements that are bound to this element. */
boundElementIds: readonly ExcalidrawLinearElement["id"][] | null;
}>;
@@ -110,16 +121,3 @@ export type ExcalidrawLinearElement = _ExcalidrawElementBase &
startArrowhead: Arrowhead | null;
endArrowhead: Arrowhead | null;
}>;
export type ExcalidrawElementTypes = Pick<ExcalidrawElement, "type">["type"];
/** @private */
type __ExcalidrawElementPossibleProps_withoutType<T> = T extends any
? { [K in keyof Omit<T, "type">]: T[K] }
: never;
/** Do not use for anything unless you really need it for some abstract
API types */
export type ExcalidrawElementPossibleProps = UnionToIntersection<
__ExcalidrawElementPossibleProps_withoutType<ExcalidrawElement>
> & { type: ExcalidrawElementTypes };
+24 -34
View File
@@ -1,40 +1,36 @@
import React, { PureComponent } from "react";
import throttle from "lodash.throttle";
import { ENV, EVENT } from "../../constants";
import {
decryptAESGEM,
SocketUpdateDataSource,
getCollaborationLinkData,
generateCollaborationLink,
SOCKET_SERVER,
} from "../data";
import { isSavedToFirebase, saveToFirebase } from "../data/firebase";
import Portal from "./Portal";
import { AppState, Collaborator, Gesture } from "../../types";
import React, { PureComponent } from "react";
import { ExcalidrawImperativeAPI } from "../../components/App";
import { ErrorDialog } from "../../components/ErrorDialog";
import { APP_NAME, ENV, EVENT } from "../../constants";
import { ImportedDataState } from "../../data/types";
import { ExcalidrawElement } from "../../element/types";
import {
importUsernameFromLocalStorage,
saveUsernameToLocalStorage,
STORAGE_KEYS,
} from "../data/localStorage";
import { resolvablePromise, withBatchedUpdates } from "../../utils";
import {
getSceneVersion,
getSyncableElements,
} from "../../packages/excalidraw/index";
import RoomDialog from "./RoomDialog";
import { ErrorDialog } from "../../components/ErrorDialog";
import { ImportedDataState } from "../../data/types";
import { ExcalidrawImperativeAPI } from "../../components/App";
import { AppState, Collaborator, Gesture } from "../../types";
import { resolvablePromise, withBatchedUpdates } from "../../utils";
import {
INITIAL_SCENE_UPDATE_TIMEOUT,
SCENE,
SYNC_FULL_SCENE_INTERVAL_MS,
} from "../app_constants";
import { EVENT_SHARE, trackEvent } from "../../analytics";
import {
decryptAESGEM,
generateCollaborationLink,
getCollaborationLinkData,
SocketUpdateDataSource,
SOCKET_SERVER,
} from "../data";
import { isSavedToFirebase, saveToFirebase } from "../data/firebase";
import {
importUsernameFromLocalStorage,
saveUsernameToLocalStorage,
STORAGE_KEYS,
} from "../data/localStorage";
import Portal from "./Portal";
import RoomDialog from "./RoomDialog";
interface CollabState {
isCollaborating: boolean;
@@ -157,11 +153,7 @@ class CollabWrapper extends PureComponent<Props, CollabState> {
};
openPortal = async () => {
window.history.pushState(
{},
"Excalidraw",
await generateCollaborationLink(),
);
window.history.pushState({}, APP_NAME, await generateCollaborationLink());
const elements = this.excalidrawRef.current!.getSceneElements();
// remove deleted elements from elements array & history to ensure we don't
// expose potentially sensitive user data in case user manually deletes
@@ -172,15 +164,13 @@ class CollabWrapper extends PureComponent<Props, CollabState> {
elements,
commitToHistory: true,
});
trackEvent(EVENT_SHARE, "session start");
return this.initializeSocketClient();
};
closePortal = () => {
this.saveCollabRoomToFirebase();
window.history.pushState({}, "Excalidraw", window.location.origin);
window.history.pushState({}, APP_NAME, window.location.origin);
this.destroySocketClient();
trackEvent(EVENT_SHARE, "session end");
};
private destroySocketClient = () => {
+4 -12
View File
@@ -1,12 +1,10 @@
import React, { useRef } from "react";
import { t } from "../../i18n";
import { Dialog } from "../../components/Dialog";
import { copyTextToSystemClipboard } from "../../clipboard";
import { ToolButton } from "../../components/ToolButton";
import { Dialog } from "../../components/Dialog";
import { clipboard, start, stop } from "../../components/icons";
import { ToolButton } from "../../components/ToolButton";
import { t } from "../../i18n";
import "./RoomDialog.scss";
import { EVENT_SHARE, trackEvent } from "../../analytics";
const RoomDialog = ({
handleClose,
@@ -30,7 +28,6 @@ const RoomDialog = ({
const copyRoomLink = async () => {
try {
await copyTextToSystemClipboard(activeRoomLink);
trackEvent(EVENT_SHARE, "copy link");
} catch (error) {
setErrorMessage(error.message);
}
@@ -95,7 +92,6 @@ const RoomDialog = ({
value={username || ""}
className="RoomDialog-username TextInput"
onChange={(event) => onUsernameChange(event.target.value)}
onBlur={() => trackEvent(EVENT_SHARE, "name")}
onKeyPress={(event) => event.key === "Enter" && handleClose()}
/>
</div>
@@ -123,11 +119,7 @@ const RoomDialog = ({
);
};
return (
<Dialog
maxWidth={800}
onCloseRequest={handleClose}
title={t("labels.createRoom")}
>
<Dialog small onCloseRequest={handleClose} title={t("labels.createRoom")}>
{renderRoomDialog()}
</Dialog>
);
@@ -1,16 +1,16 @@
import React from "react";
import clsx from "clsx";
import * as i18n from "../i18n";
import * as i18n from "../../i18n";
export const LanguageList = ({
onChange,
languages = i18n.languages,
currentLanguage = i18n.getLanguage().lng,
currentLangCode = i18n.getLanguage().code,
floating,
}: {
languages?: { lng: string; label: string }[];
onChange: (value: string) => void;
currentLanguage?: string;
languages?: { code: string; label: string }[];
onChange: (langCode: i18n.Language["code"]) => void;
currentLangCode?: i18n.Language["code"];
floating?: boolean;
}) => (
<React.Fragment>
@@ -19,12 +19,15 @@ export const LanguageList = ({
"dropdown-select--floating": floating,
})}
onChange={({ target }) => onChange(target.value)}
value={currentLanguage}
value={currentLangCode}
aria-label={i18n.t("buttons.selectLanguage")}
>
{languages.map((language) => (
<option key={language.lng} value={language.lng}>
{language.label}
<option key={i18n.defaultLang.code} value={i18n.defaultLang.code}>
{i18n.defaultLang.label}
</option>
{languages.map((lang) => (
<option key={lang.code} value={lang.code}>
{lang.label}
</option>
))}
</select>
+62 -6
View File
@@ -1,14 +1,15 @@
import { t } from "../../i18n";
import { ExcalidrawElement } from "../../element/types";
import { AppState } from "../../types";
import { ImportedDataState } from "../../data/types";
import { serializeAsJSON } from "../../data/json";
import { restore } from "../../data/restore";
import { EVENT_ACTION, trackEvent } from "../../analytics";
import { ImportedDataState } from "../../data/types";
import { ExcalidrawElement } from "../../element/types";
import { t } from "../../i18n";
import { AppState } from "../../types";
const byteToHex = (byte: number): string => `0${byte.toString(16)}`.slice(-2);
const BACKEND_GET = process.env.REACT_APP_BACKEND_V1_GET_URL;
const BACKEND_V2_GET = process.env.REACT_APP_BACKEND_V2_GET_URL;
const BACKEND_V2_POST = process.env.REACT_APP_BACKEND_V2_POST_URL;
const generateRandomID = async () => {
const arr = new Uint8Array(10);
@@ -190,7 +191,6 @@ const importFromBackend = async (
data = await response.json();
}
trackEvent(EVENT_ACTION, "import");
return {
elements: data.elements || null,
appState: data.appState || null,
@@ -228,3 +228,59 @@ export const loadScene = async (
commitToHistory: false,
};
};
export const exportToBackend = async (
elements: readonly ExcalidrawElement[],
appState: AppState,
) => {
const json = serializeAsJSON(elements, appState);
const encoded = new TextEncoder().encode(json);
const key = await window.crypto.subtle.generateKey(
{
name: "AES-GCM",
length: 128,
},
true, // extractable
["encrypt", "decrypt"],
);
// The iv is set to 0. We are never going to reuse the same key so we don't
// need to have an iv. (I hope that's correct...)
const iv = new Uint8Array(12);
// We use symmetric encryption. AES-GCM is the recommended algorithm and
// includes checks that the ciphertext has not been modified by an attacker.
const encrypted = await window.crypto.subtle.encrypt(
{
name: "AES-GCM",
iv,
},
key,
encoded,
);
// We use jwk encoding to be able to extract just the base64 encoded key.
// We will hardcode the rest of the attributes when importing back the key.
const exportedKey = await window.crypto.subtle.exportKey("jwk", key);
try {
const response = await fetch(BACKEND_V2_POST, {
method: "POST",
body: encrypted,
});
const json = await response.json();
if (json.id) {
const url = new URL(window.location.href);
// We need to store the key (and less importantly the id) as hash instead
// of queryParam in order to never send it to the server
url.hash = `json=${json.id},${exportedKey.k!}`;
const urlString = url.toString();
window.prompt(`🔒${t("alerts.uploadedSecurly")}`, urlString);
} else if (json.error_class === "RequestTooLargeError") {
window.alert(t("alerts.couldNotCreateShareableLinkTooBig"));
} else {
window.alert(t("alerts.couldNotCreateShareableLink"));
}
} catch (error) {
console.error(error);
window.alert(t("alerts.couldNotCreateShareableLink"));
}
};
+145 -54
View File
@@ -1,28 +1,53 @@
import React, { useState, useLayoutEffect, useEffect, useRef } from "react";
import Excalidraw from "../packages/excalidraw/index";
import LanguageDetector from "i18next-browser-languagedetector";
import React, {
useCallback,
useEffect,
useLayoutEffect,
useRef,
useState,
} from "react";
import { trackEvent } from "../analytics";
import { getDefaultAppState } from "../appState";
import { ExcalidrawImperativeAPI } from "../components/App";
import { ErrorDialog } from "../components/ErrorDialog";
import { TopErrorBoundary } from "../components/TopErrorBoundary";
import { APP_NAME, EVENT, TITLE_TIMEOUT, VERSION_TIMEOUT } from "../constants";
import { ImportedDataState } from "../data/types";
import {
ExcalidrawElement,
NonDeletedExcalidrawElement,
} from "../element/types";
import { Language, t } from "../i18n";
import Excalidraw, {
defaultLang,
languages,
} from "../packages/excalidraw/index";
import { AppState, ExcalidrawAPIRefValue } from "../types";
import {
debounce,
getVersion,
ResolvablePromise,
resolvablePromise,
} from "../utils";
import { SAVE_TO_LOCAL_STORAGE_TIMEOUT } from "./app_constants";
import CollabWrapper, { CollabAPI } from "./collab/CollabWrapper";
import { LanguageList } from "./components/LanguageList";
import { exportToBackend, getCollaborationLinkData, loadScene } from "./data";
import { loadFromFirebase } from "./data/firebase";
import {
getTotalStorageSize,
importFromLocalStorage,
saveToLocalStorage,
STORAGE_KEYS,
} from "./data/localStorage";
import { ImportedDataState } from "../data/types";
import CollabWrapper, { CollabAPI } from "./collab/CollabWrapper";
import { TopErrorBoundary } from "../components/TopErrorBoundary";
import { t } from "../i18n";
import { loadScene } from "./data";
import { getCollaborationLinkData } from "./data";
import { EVENT } from "../constants";
import { loadFromFirebase } from "./data/firebase";
import { ExcalidrawImperativeAPI } from "../components/App";
import { debounce, ResolvablePromise, resolvablePromise } from "../utils";
import { AppState, ExcalidrawAPIRefValue } from "../types";
import { ExcalidrawElement } from "../element/types";
import { SAVE_TO_LOCAL_STORAGE_TIMEOUT } from "./app_constants";
import { EVENT_LOAD, EVENT_SHARE, trackEvent } from "../analytics";
const languageDetector = new LanguageDetector();
languageDetector.init({
languageUtils: {
formatLanguageCode: (langCode: Language["code"]) => langCode,
isWhitelisted: () => true,
},
checkWhitelist: false,
});
const excalidrawRef: React.MutableRefObject<
MarkRequired<ExcalidrawAPIRefValue, "ready" | "readyPromise">
@@ -87,7 +112,6 @@ type Scene = ImportedDataState & { commitToHistory: boolean };
const initializeScene = async (opts: {
resetScene: ExcalidrawImperativeAPI["resetScene"];
initializeSocketClient: CollabAPI["initializeSocketClient"];
onLateInitialization?: (scene: Scene) => void;
}): Promise<Scene | null> => {
const searchParams = new URLSearchParams(window.location.search);
const id = searchParams.get("id");
@@ -113,26 +137,24 @@ const initializeScene = async (opts: {
scene = await loadScene(jsonMatch[1], jsonMatch[2], initialData);
}
if (!isCollabScene) {
window.history.replaceState({}, "Excalidraw", window.location.origin);
window.history.replaceState({}, APP_NAME, window.location.origin);
}
} else {
// https://github.com/excalidraw/excalidraw/issues/1919
if (document.hidden) {
window.addEventListener(
"focus",
() =>
initializeScene(opts).then((_scene) => {
opts?.onLateInitialization?.(_scene || scene);
}),
{
once: true,
},
);
return null;
return new Promise((resolve, reject) => {
window.addEventListener(
"focus",
() => initializeScene(opts).then(resolve).catch(reject),
{
once: true,
},
);
});
}
isCollabScene = false;
window.history.replaceState({}, "Excalidraw", window.location.origin);
window.history.replaceState({}, APP_NAME, window.location.origin);
}
}
if (isCollabScene) {
@@ -140,7 +162,6 @@ const initializeScene = async (opts: {
// into the remote scene
opts.resetScene();
const scenePromise = opts.initializeSocketClient();
trackEvent(EVENT_SHARE, "session join");
try {
const [, roomId, roomKey] = getCollaborationLinkData(
@@ -178,6 +199,9 @@ function ExcalidrawWrapper(props: { collab: CollabAPI }) {
width: window.innerWidth,
height: window.innerHeight,
});
const [errorMessage, setErrorMessage] = useState("");
const currentLangCode = languageDetector.detect() || defaultLang.code;
const [langCode, setLangCode] = useState(currentLangCode);
useLayoutEffect(() => {
const onResize = () => {
@@ -205,19 +229,15 @@ function ExcalidrawWrapper(props: { collab: CollabAPI }) {
const { collab } = props;
useEffect(() => {
const storageSize = getTotalStorageSize();
if (storageSize) {
trackEvent(EVENT_LOAD, "storage", "size", storageSize);
} else {
trackEvent(EVENT_LOAD, "first time");
}
// Delayed so that the app has a time to load the latest SW
setTimeout(() => {
trackEvent("load", "version", getVersion());
}, VERSION_TIMEOUT);
excalidrawRef.current!.readyPromise.then((excalidrawApi) => {
initializeScene({
resetScene: excalidrawApi.resetScene,
initializeSocketClient: collab.initializeSocketClient,
onLateInitialization: (scene) => {
initialStatePromiseRef.current.promise.resolve(scene);
},
}).then((scene) => {
initialStatePromiseRef.current.promise.resolve(scene);
});
@@ -240,6 +260,10 @@ function ExcalidrawWrapper(props: { collab: CollabAPI }) {
}
};
const titleTimeout = setTimeout(
() => (document.title = APP_NAME),
TITLE_TIMEOUT,
);
window.addEventListener(EVENT.HASHCHANGE, onHashChange, false);
window.addEventListener(EVENT.UNLOAD, onBlur, false);
window.addEventListener(EVENT.BLUR, onBlur, false);
@@ -247,9 +271,14 @@ function ExcalidrawWrapper(props: { collab: CollabAPI }) {
window.removeEventListener(EVENT.HASHCHANGE, onHashChange, false);
window.removeEventListener(EVENT.UNLOAD, onBlur, false);
window.removeEventListener(EVENT.BLUR, onBlur, false);
clearTimeout(titleTimeout);
};
}, [collab.initializeSocketClient]);
useEffect(() => {
languageDetector.cacheUserLanguage(langCode);
}, [langCode]);
const onChange = (
elements: readonly ExcalidrawElement[],
appState: AppState,
@@ -260,18 +289,80 @@ function ExcalidrawWrapper(props: { collab: CollabAPI }) {
}
};
const onExportToBackend = async (
exportedElements: readonly NonDeletedExcalidrawElement[],
appState: AppState,
canvas: HTMLCanvasElement | null,
) => {
if (exportedElements.length === 0) {
return window.alert(t("alerts.cannotExportEmptyCanvas"));
}
if (canvas) {
try {
await exportToBackend(exportedElements, {
...appState,
viewBackgroundColor: appState.exportBackground
? appState.viewBackgroundColor
: getDefaultAppState().viewBackgroundColor,
});
} catch (error) {
if (error.name !== "AbortError") {
const { width, height } = canvas;
console.error(error, { width, height });
setErrorMessage(error.message);
}
}
}
};
const renderFooter = useCallback(
(isMobile: boolean) => {
const renderLanguageList = () => (
<LanguageList
onChange={(langCode) => {
setLangCode(langCode);
}}
languages={languages}
floating={!isMobile}
currentLangCode={langCode}
/>
);
if (isMobile) {
return (
<fieldset>
<legend>{t("labels.language")}</legend>
{renderLanguageList()}
</fieldset>
);
}
return renderLanguageList();
},
[langCode],
);
return (
<Excalidraw
ref={excalidrawRef}
onChange={onChange}
width={dimensions.width}
height={dimensions.height}
initialData={initialStatePromiseRef.current.promise}
user={{ name: collab.username }}
onCollabButtonClick={collab.onCollabButtonClick}
isCollaborating={collab.isCollaborating}
onPointerUpdate={collab.onPointerUpdate}
/>
<>
<Excalidraw
ref={excalidrawRef}
onChange={onChange}
width={dimensions.width}
height={dimensions.height}
initialData={initialStatePromiseRef.current.promise}
user={{ name: collab.username }}
onCollabButtonClick={collab.onCollabButtonClick}
isCollaborating={collab.isCollaborating}
onPointerUpdate={collab.onPointerUpdate}
onExportToBackend={onExportToBackend}
renderFooter={renderFooter}
langCode={langCode}
/>
{errorMessage && (
<ErrorDialog
message={errorMessage}
onClose={() => setErrorMessage("")}
/>
)}
</>
);
}
-9
View File
@@ -46,15 +46,6 @@ type MarkOptional<T, K extends keyof T> = Omit<T, K> & Partial<Pick<T, K>>;
type MarkRequired<T, RK extends keyof T> = Exclude<T, RK> &
Required<Pick<T, RK>>;
type UnionToIntersection<T> = (T extends any ? (x: T) => any : never) extends (
x: infer R,
) => any
? R
: never;
/** Assert K is a subset of T, and returns K */
type AssertSubset<T, K extends T> = K;
// PNG encoding/decoding
// -----------------------------------------------------------------------------
type TEXtChunk = { name: "tEXt"; data: Uint8Array };
+59 -80
View File
@@ -1,93 +1,81 @@
import LanguageDetector from "i18next-browser-languagedetector";
import { EVENT_CHANGE, trackEvent } from "./analytics";
import fallbackLanguageData from "./locales/en.json";
import fallbackLangData from "./locales/en.json";
import percentages from "./locales/percentages.json";
const COMPLETION_THRESHOLD_TO_EXCEED = 85;
const COMPLETION_THRESHOLD = 85;
interface Language {
lng: string;
export interface Language {
code: string;
label: string;
rtl?: boolean;
}
const allLanguages: Language[] = [
{ lng: "ar-SA", label: "العربية", rtl: true },
{ lng: "bg-BG", label: "Български" },
{ lng: "ca-ES", label: "Catalan" },
{ lng: "de-DE", label: "Deutsch" },
{ lng: "el-GR", label: "Ελληνικά" },
{ lng: "es-ES", label: "Español" },
{ lng: "fa-IR", label: "فارسی", rtl: true },
{ lng: "fi-FI", label: "Suomi" },
{ lng: "fr-FR", label: "Français" },
{ lng: "he-IL", label: "עברית", rtl: true },
{ lng: "hi-IN", label: "हिन्दी" },
{ lng: "hu-HU", label: "Magyar" },
{ lng: "id-ID", label: "Bahasa Indonesia" },
{ lng: "it-IT", label: "Italiano" },
{ lng: "ja-JP", label: "日本語" },
{ lng: "ko-KR", label: "한국어" },
{ lng: "my-MM", label: "Burmese" },
{ lng: "nb-NO", label: "Norsk bokmål" },
{ lng: "nl-NL", label: "Nederlands" },
{ lng: "nn-NO", label: "Norsk nynorsk" },
{ lng: "pl-PL", label: "Polski" },
{ lng: "pt-PT", label: "Português" },
{ lng: "ro-RO", label: "Română" },
{ lng: "ru-RU", label: "Русский" },
{ lng: "sk-SK", label: "Slovenčina" },
{ lng: "sv-SE", label: "Svenska" },
{ lng: "tr-TR", label: "Türkçe" },
{ lng: "uk-UA", label: "Українська" },
{ lng: "zh-CN", label: "简体中文" },
{ lng: "zh-TW", label: "繁體中文" },
];
export const defaultLang = { code: "en", label: "English" };
export const languages: Language[] = [{ lng: "en", label: "English" }]
.concat(
allLanguages.sort((left, right) => (left.label > right.label ? 1 : -1)),
)
const allLanguages: Language[] = [
{ code: "ar-SA", label: "العربية", rtl: true },
{ code: "bg-BG", label: "Български" },
{ code: "ca-ES", label: "Català" },
{ code: "de-DE", label: "Deutsch" },
{ code: "el-GR", label: "Ελληνικά" },
{ code: "es-ES", label: "Español" },
{ code: "fa-IR", label: "فارسی", rtl: true },
{ code: "fi-FI", label: "Suomi" },
{ code: "fr-FR", label: "Français" },
{ code: "he-IL", label: "עברית", rtl: true },
{ code: "hi-IN", label: "हिन्दी" },
{ code: "hu-HU", label: "Magyar" },
{ code: "id-ID", label: "Bahasa Indonesia" },
{ code: "it-IT", label: "Italiano" },
{ code: "ja-JP", label: "日本語" },
{ code: "ko-KR", label: "한국어" },
{ code: "my-MM", label: "Burmese" },
{ code: "nb-NO", label: "Norsk bokmål" },
{ code: "nl-NL", label: "Nederlands" },
{ code: "nn-NO", label: "Norsk nynorsk" },
{ code: "pa-IN", label: "ਪੰਜਾਬੀ" },
{ code: "pl-PL", label: "Polski" },
{ code: "pt-BR", label: "Português Brasileiro" },
{ code: "pt-PT", label: "Português" },
{ code: "ro-RO", label: "Română" },
{ code: "ru-RU", label: "Русский" },
{ code: "sk-SK", label: "Slovenčina" },
{ code: "sv-SE", label: "Svenska" },
{ code: "tr-TR", label: "Türkçe" },
{ code: "uk-UA", label: "Українська" },
{ code: "zh-CN", label: "简体中文" },
{ code: "zh-TW", label: "繁體中文" },
].concat([defaultLang]);
export const languages: Language[] = allLanguages
.sort((left, right) => (left.label > right.label ? 1 : -1))
.filter(
(lang) =>
(percentages as Record<string, number>)[lang.lng] >
COMPLETION_THRESHOLD_TO_EXCEED,
(percentages as Record<string, number>)[lang.code] >=
COMPLETION_THRESHOLD,
);
let currentLanguage = languages[0];
let currentLanguageData = {};
const fallbackLanguage = languages[0];
let currentLang: Language = defaultLang;
let currentLangData = {};
export const setLanguage = async (newLng: string | undefined) => {
currentLanguage =
languages.find((language) => language.lng === newLng) || fallbackLanguage;
export const setLanguage = async (lang: Language) => {
currentLang = lang;
document.documentElement.dir = currentLang.rtl ? "rtl" : "ltr";
document.documentElement.dir = currentLanguage.rtl ? "rtl" : "ltr";
currentLanguageData = await import(
/* webpackChunkName: "i18n-[request]" */ `./locales/${currentLanguage.lng}.json`
currentLangData = await import(
/* webpackChunkName: "i18n-[request]" */ `./locales/${currentLang.code}.json`
);
languageDetector.cacheUserLanguage(currentLanguage.lng);
trackEvent(EVENT_CHANGE, "language", currentLanguage.lng);
};
export const setLanguageFirstTime = async () => {
const newLng: string | undefined = languageDetector.detect();
export const setLanguageFirstTime = async (lang: Language) => {
currentLang = lang;
document.documentElement.dir = currentLang.rtl ? "rtl" : "ltr";
currentLanguage =
languages.find((language) => language.lng === newLng) || fallbackLanguage;
document.documentElement.dir = currentLanguage.rtl ? "rtl" : "ltr";
currentLanguageData = await import(
/* webpackChunkName: "i18n-[request]" */ `./locales/${currentLanguage.lng}.json`
currentLangData = await import(
/* webpackChunkName: "i18n-[request]" */ `./locales/${currentLang.code}.json`
);
languageDetector.cacheUserLanguage(currentLanguage.lng);
};
export const getLanguage = () => currentLanguage;
export const getLanguage = () => currentLang;
const findPartsForData = (data: any, parts: string[]) => {
for (let index = 0; index < parts.length; ++index) {
@@ -106,8 +94,8 @@ const findPartsForData = (data: any, parts: string[]) => {
export const t = (path: string, replacement?: { [key: string]: string }) => {
const parts = path.split(".");
let translation =
findPartsForData(currentLanguageData, parts) ||
findPartsForData(fallbackLanguageData, parts);
findPartsForData(currentLangData, parts) ||
findPartsForData(fallbackLangData, parts);
if (translation === undefined) {
throw new Error(`Can't find translation for ${path}`);
}
@@ -119,12 +107,3 @@ export const t = (path: string, replacement?: { [key: string]: string }) => {
}
return translation;
};
const languageDetector = new LanguageDetector();
languageDetector.init({
languageUtils: {
formatLanguageCode: (lng: string) => lng,
isWhitelisted: () => true,
},
checkWhitelist: false,
});
+1
View File
@@ -11,6 +11,7 @@ export const IsMobileProvider = ({
if (!query.current) {
query.current = window.matchMedia
? window.matchMedia(
// keep up to date with _variables.scss
"(max-width: 640px), (max-height: 500px) and (max-width: 1000px)",
)
: (({
+2
View File
@@ -9,6 +9,7 @@ export const CODES = {
BRACKET_RIGHT: "BracketRight",
BRACKET_LEFT: "BracketLeft",
ONE: "Digit1",
TWO: "Digit2",
NINE: "Digit9",
QUOTE: "Quote",
ZERO: "Digit0",
@@ -39,6 +40,7 @@ export const KEYS = {
D: "d",
E: "e",
L: "l",
O: "o",
P: "p",
Q: "q",
R: "r",

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