Compare commits

...
145 Commits
Author SHA1 Message Date
tk338g 232412d7bc Test fixup 2021-02-19 21:17:58 +03:00
tk338g 6a8680f500 Moved minimap rendering to offscreen canvas 2021-02-19 21:07:09 +03:00
tk338g 3b0aff0ac6 Fix minimap rerendering on scroll 2021-02-19 20:12:48 +03:00
tk338g 93d0a56bdb Check for NaN before applying styles 2021-02-19 20:12:48 +03:00
tk338g 0a3675e1b9 Update test snapshots 2021-02-19 20:12:48 +03:00
tk338g cf35caaf23 Toggle minimap with "M" key 2021-02-19 20:12:48 +03:00
tk338g 4e3bf7e8d2 Use one canvas for minimap and preserve viewport borders 2021-02-19 20:12:47 +03:00
tk338g 4c3544df4a Simple minimap implementation 2021-02-19 20:12:47 +03:00
ArunandGitHub 74e82d0d7c fix: Hide scrollbars on mobile (#3044)
* Hide scrollbars on mobile

* Fix package build

* Revert "Fix package build"

This reverts commit 7bf4a0aac1.

* Make requested changes
2021-02-19 12:55:58 +01:00
LipisandGitHub 9dd2257932 chore: Consistent CSS variable names and general housekeeping of styles (#2947) 2021-02-16 20:22:18 +02:00
Aakansha DoshiandGitHub 9c0f832a41 fix: show user state only when passed from host (#3050) 2021-02-16 15:08:27 +05:30
Aakansha DoshiandGitHub 6cafb6bb90 feat: export restore API's from Excalidraw package (#3049) 2021-02-15 14:22:04 +01:00
e6cd97c4f2 feat: adjust line-confirm-threshold based on zoom (#2884)
Co-authored-by: Lipis <lipiridis@gmail.com>
2021-02-14 14:43:23 +01:00
Panayiotis Lipiridis ba9b65b051 chore: npm audit fix 2021-02-14 15:18:17 +02:00
Aakansha DoshiandGitHub 830fb64a25 fix: Support Excalidraw inside scrollable container (#3018)
* refactor: remove position fixed from excalidraw container, modal and stats

* remove unused css

* remove position fixed from toast and scroll to content

* Make excal interactable by fixing offsets and set popover as fixed since position needs to be calculate from viewport  top

* Assign 200px less than height of Excalidraw to the selected shapes actions o UI doesn't overflow

* update changelog, readme and package.json
2021-02-14 18:18:34 +05:30
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
5b343a9d46 chore(deps-dev): bump @babel/preset-env from 7.12.13 to 7.12.16 in /src/packages/utils (#3030)
Bumps [@babel/preset-env](https://github.com/babel/babel/tree/HEAD/packages/babel-preset-env) from 7.12.13 to 7.12.16.
- [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.16/packages/babel-preset-env)

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

Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2021-02-14 11:47:25 +02:00
Ivan KurnosovandGitHub f8beb305de refactor: Removed redundant import from App.tsx (#3040)
Given it's followed by qualified import - unqualified import looks redundant, unless I'm missing some tricky edge case.
2021-02-14 11:25:57 +02:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
4b4eecbd27 chore(deps): bump react-scripts from 4.0.1 to 4.0.2 (#2979)
Bumps [react-scripts](https://github.com/facebook/create-react-app/tree/HEAD/packages/react-scripts) from 4.0.1 to 4.0.2.
- [Release notes](https://github.com/facebook/create-react-app/releases)
- [Changelog](https://github.com/facebook/create-react-app/blob/master/CHANGELOG.md)
- [Commits](https://github.com/facebook/create-react-app/commits/react-scripts@4.0.2/packages/react-scripts)

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

Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2021-02-14 09:23:05 +00:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
e8bd910b9b chore(deps-dev): bump @babel/preset-env from 7.12.13 to 7.12.16 in /src/packages/excalidraw (#3019)
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2021-02-14 08:20:30 +00:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
a9a3e1bca5 chore(deps-dev): bump ts-loader from 8.0.15 to 8.0.17 in /src/packages/utils (#3027)
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2021-02-14 08:16:28 +00:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2a922dd477 chore(deps-dev): bump @babel/preset-typescript from 7.12.13 to 7.12.16 in /src/packages/excalidraw (#3025)
Bumps [@babel/preset-typescript](https://github.com/babel/babel/tree/HEAD/packages/babel-preset-typescript) from 7.12.13 to 7.12.16.
- [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.16/packages/babel-preset-typescript)

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

Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2021-02-14 08:12:33 +00:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
07dab85ebf chore(deps-dev): bump sass-loader from 11.0.0 to 11.0.1 in /src/packages/excalidraw (#3020)
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2021-02-14 08:07:01 +00:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
d2ce4a7523 chore(deps-dev): bump ts-loader from 8.0.15 to 8.0.17 in /src/packages/excalidraw (#3029)
Bumps [ts-loader](https://github.com/TypeStrong/ts-loader) from 8.0.15 to 8.0.17.
- [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.15...v8.0.17)

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

Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2021-02-14 08:01:57 +00:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
dc73f3a9eb chore(deps-dev): bump @babel/core from 7.12.13 to 7.12.16 in /src/packages/excalidraw (#3031)
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2021-02-14 07:59:28 +00:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
d100f38750 chore(deps-dev): bump @babel/preset-typescript from 7.12.13 to 7.12.16 in /src/packages/utils (#3024)
Bumps [@babel/preset-typescript](https://github.com/babel/babel/tree/HEAD/packages/babel-preset-typescript) from 7.12.13 to 7.12.16.
- [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.16/packages/babel-preset-typescript)

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

Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2021-02-14 07:55:41 +00:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
503500cc74 chore(deps-dev): bump @babel/plugin-transform-typescript from 7.12.13 to 7.12.16 in /src/packages/utils (#3028)
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2021-02-14 07:46:40 +00:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
1a828a43d9 chore(deps): bump @types/react-dom from 17.0.0 to 17.0.1 (#3035)
Bumps [@types/react-dom](https://github.com/DefinitelyTyped/DefinitelyTyped/tree/HEAD/types/react-dom) from 17.0.0 to 17.0.1.
- [Release notes](https://github.com/DefinitelyTyped/DefinitelyTyped/releases)
- [Commits](https://github.com/DefinitelyTyped/DefinitelyTyped/commits/HEAD/types/react-dom)

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

Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2021-02-14 07:45:50 +00:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
d88884466b chore(deps-dev): bump @babel/core from 7.12.13 to 7.12.16 in /src/packages/utils (#3021)
Bumps [@babel/core](https://github.com/babel/babel/tree/HEAD/packages/babel-core) from 7.12.13 to 7.12.16.
- [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.16/packages/babel-core)

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

Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2021-02-14 09:44:21 +02:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
d3d470ac3d chore(deps-dev): bump jest-canvas-mock from 2.3.0 to 2.3.1 (#3037)
Bumps [jest-canvas-mock](https://github.com/hustcc/jest-canvas-mock) from 2.3.0 to 2.3.1.
- [Release notes](https://github.com/hustcc/jest-canvas-mock/releases)
- [Changelog](https://github.com/hustcc/jest-canvas-mock/blob/master/CHANGELOG.md)
- [Commits](https://github.com/hustcc/jest-canvas-mock/commits)

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

Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2021-02-14 09:43:49 +02:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
54df521a78 chore(deps-dev): bump mini-css-extract-plugin from 1.3.5 to 1.3.6 in /src/packages/excalidraw (#3022)
Bumps [mini-css-extract-plugin](https://github.com/webpack-contrib/mini-css-extract-plugin) from 1.3.5 to 1.3.6.
- [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.5...v1.3.6)

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

Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2021-02-14 09:43:38 +02:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
c5557b5cc1 chore(deps): bump typescript from 4.1.3 to 4.1.5 (#3034)
Bumps [typescript](https://github.com/Microsoft/TypeScript) from 4.1.3 to 4.1.5.
- [Release notes](https://github.com/Microsoft/TypeScript/releases)
- [Commits](https://github.com/Microsoft/TypeScript/compare/v4.1.3...v4.1.5)

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

Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2021-02-14 09:43:27 +02:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
51875fd627 chore(deps): bump firebase from 8.2.6 to 8.2.7 (#3038)
Bumps [firebase](https://github.com/firebase/firebase-js-sdk) from 8.2.6 to 8.2.7.
- [Release notes](https://github.com/firebase/firebase-js-sdk/releases)
- [Changelog](https://github.com/firebase/firebase-js-sdk/blob/master/CHANGELOG.md)
- [Commits](https://github.com/firebase/firebase-js-sdk/compare/firebase@8.2.6...firebase@8.2.7)

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

Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2021-02-14 09:42:53 +02:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
b2ba61bbcf chore(deps-dev): bump webpack from 5.21.1 to 5.21.2 in /src/packages/excalidraw (#3032)
Bumps [webpack](https://github.com/webpack/webpack) from 5.21.1 to 5.21.2.
- [Release notes](https://github.com/webpack/webpack/releases)
- [Commits](https://github.com/webpack/webpack/compare/v5.21.1...v5.21.2)

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

Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2021-02-14 09:42:36 +02:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
3b7f62c9a0 chore(deps-dev): bump @babel/plugin-transform-typescript from 7.12.13 to 7.12.16 in /src/packages/excalidraw (#3033)
Bumps [@babel/plugin-transform-typescript](https://github.com/babel/babel/tree/HEAD/packages/babel-plugin-transform-typescript) from 7.12.13 to 7.12.16.
- [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.16/packages/babel-plugin-transform-typescript)

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

Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2021-02-14 09:42:26 +02:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
aeafb81479 chore(deps-dev): bump css-loader in /src/packages/excalidraw (#3026)
Bumps [css-loader](https://github.com/webpack-contrib/css-loader) from 5.0.1 to 5.0.2.
- [Release notes](https://github.com/webpack-contrib/css-loader/releases)
- [Changelog](https://github.com/webpack-contrib/css-loader/blob/master/CHANGELOG.md)
- [Commits](https://github.com/webpack-contrib/css-loader/compare/v5.0.1...v5.0.2)

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

Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2021-02-14 09:42:13 +02:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
dfe81bf6b2 chore(deps-dev): bump webpack from 5.21.1 to 5.21.2 in /src/packages/utils (#3023)
Bumps [webpack](https://github.com/webpack/webpack) from 5.21.1 to 5.21.2.
- [Release notes](https://github.com/webpack/webpack/releases)
- [Commits](https://github.com/webpack/webpack/compare/v5.21.1...v5.21.2)

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

Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2021-02-14 09:42:02 +02:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
1d332d597a chore(deps): bump @types/react from 17.0.1 to 17.0.2 (#3036)
Bumps [@types/react](https://github.com/DefinitelyTyped/DefinitelyTyped/tree/HEAD/types/react) from 17.0.1 to 17.0.2.
- [Release notes](https://github.com/DefinitelyTyped/DefinitelyTyped/releases)
- [Commits](https://github.com/DefinitelyTyped/DefinitelyTyped/commits/HEAD/types/react)

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

Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2021-02-14 09:41:09 +02:00
Aakansha DoshiandGitHub b5fc8757a4 fix: allow to toggle between modes when view only mode to make UI consistent (#3009) 2021-02-12 10:40:40 +02:00
Aakansha DoshiandGitHub ecbd5ba55d docs: update readme, changelog and release 0.3.0 🎉 (#3003) 2021-02-11 19:55:38 +05:30
Excalidraw BotandGitHub 6967d8c985 chore: Update translations from Crowdin (#3000) 2021-02-11 14:13:32 +02:00
David LuzarandGitHub 4b253c7362 fix: refresh wysiwyg position on canvas resize (#3008) 2021-02-11 12:24:26 +01:00
Aakansha DoshiandGitHub 4fdddb518a docs(readme): link to @excalidraw/excalidraw npm package to give more visibility (#3002) 2021-02-10 21:58:29 +05:30
Aakansha DoshiandGitHub 0b2e4dd60b build(webpack): remove publicPath so __webpack_public_path__ can be used to host assets (#2835)
* build(webpack): remove publicPath so __webpack_public_path__ can be use to host assets

* update readme and changelog

* fix

* revert version so its released in v3
2021-02-10 21:49:16 +05:30
David LuzarandGitHub f162512988 docs: link to specific sponsors (#2950) 2021-02-10 10:22:49 +02:00
LipisandGitHub 2f7154cdf2 chore: Use Sentence case for Live collaboration 2021-02-09 15:55:03 +02:00
ArunandGitHub 5ab0ce5a33 feat: Add the ability to clear library (#2997)
* Add the ability to clear libraries

* Update 'libraries' to 'library'

* Update en.json
2021-02-09 11:19:04 +01:00
Excalidraw BotandGitHub 073f4032f3 chore: Update translations from Crowdin (#2986) 2021-02-09 11:06:23 +02:00
Jed FoxandGitHub 73cba59d2d feat: Updates to Collaboration and RTL UX (#2994) 2021-02-08 21:43:51 +01:00
José dBruxellesandGitHub 4085071347 docs: Fix typo (#2996) 2021-02-08 18:39:59 +02:00
José dBruxellesandGitHub 8a63187d4f Update the lang attribute with the current lang. (#2995)
Currently, when changing the app language, the `lang` attribute still in `en`.
2021-02-08 12:38:38 +02:00
David LuzarandGitHub 9c51ba6067 feat: show toast when saving to existing file (#2988) 2021-02-07 22:01:22 +01:00
Excalidraw BotandGitHub bf50c9cae7 chore: Update translations from Crowdin (#2946) 2021-02-07 12:33:37 +02:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
1801048763 chore(deps-dev): bump @babel/plugin-transform-arrow-functions from 7.12.1 to 7.12.13 in /src/packages/excalidraw (#2961)
Bumps [@babel/plugin-transform-arrow-functions](https://github.com/babel/babel/tree/HEAD/packages/babel-plugin-transform-arrow-functions) from 7.12.1 to 7.12.13.
- [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.13/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>
2021-02-07 11:25:00 +02:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
414deea084 chore(deps-dev): bump @babel/preset-typescript from 7.12.7 to 7.12.13 in /src/packages/excalidraw (#2955)
Bumps [@babel/preset-typescript](https://github.com/babel/babel/tree/HEAD/packages/babel-preset-typescript) from 7.12.7 to 7.12.13.
- [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.13/packages/babel-preset-typescript)

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

Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2021-02-07 09:15:47 +00:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
979d28d5c6 chore(deps-dev): bump @babel/preset-env from 7.12.11 to 7.12.13 in /src/packages/utils (#2970)
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2021-02-07 09:14:45 +00:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
1f8b7e417f chore(deps-dev): bump @babel/plugin-transform-async-to-generator from 7.12.1 to 7.12.13 in /src/packages/excalidraw (#2957)
Bumps [@babel/plugin-transform-async-to-generator](https://github.com/babel/babel/tree/HEAD/packages/babel-plugin-transform-async-to-generator) from 7.12.1 to 7.12.13.
- [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.13/packages/babel-plugin-transform-async-to-generator)

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

Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2021-02-07 09:05:16 +00:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
8c968cd13e chore(deps-dev): bump @babel/preset-typescript from 7.12.7 to 7.12.13 in /src/packages/utils (#2964)
Bumps [@babel/preset-typescript](https://github.com/babel/babel/tree/HEAD/packages/babel-preset-typescript) from 7.12.7 to 7.12.13.
- [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.13/packages/babel-preset-typescript)

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

Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2021-02-07 08:59:54 +00:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
f798000006 chore(deps-dev): bump @babel/plugin-transform-async-to-generator from 7.12.1 to 7.12.13 in /src/packages/utils (#2972)
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2021-02-07 08:55:43 +00:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
7ff3a71179 chore(deps-dev): bump @babel/preset-env from 7.12.11 to 7.12.13 in /src/packages/excalidraw (#2973)
Bumps [@babel/preset-env](https://github.com/babel/babel/tree/HEAD/packages/babel-preset-env) from 7.12.11 to 7.12.13.
- [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.13/packages/babel-preset-env)

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

Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2021-02-07 08:48:42 +00:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
6c0804d4c3 chore(deps-dev): bump @babel/core from 7.12.10 to 7.12.13 in /src/packages/excalidraw (#2952)
Bumps [@babel/core](https://github.com/babel/babel/tree/HEAD/packages/babel-core) from 7.12.10 to 7.12.13.
- [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.13/packages/babel-core)

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

Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2021-02-07 08:45:04 +00:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
ae8e7aca16 chore(deps-dev): bump ts-loader from 8.0.14 to 8.0.15 in /src/packages/excalidraw (#2969)
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2021-02-07 08:41:10 +00:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
842b185aa6 chore(deps-dev): bump @babel/plugin-transform-runtime from 7.12.10 to 7.12.15 in /src/packages/utils (#2968)
Bumps [@babel/plugin-transform-runtime](https://github.com/babel/babel/tree/HEAD/packages/babel-plugin-transform-runtime) from 7.12.10 to 7.12.15.
- [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.15/packages/babel-plugin-transform-runtime)

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

Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2021-02-07 08:34:00 +00:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
f59387471e chore(deps): bump firebase from 8.2.5 to 8.2.6 (#2983)
Bumps [firebase](https://github.com/firebase/firebase-js-sdk) from 8.2.5 to 8.2.6.
- [Release notes](https://github.com/firebase/firebase-js-sdk/releases)
- [Changelog](https://github.com/firebase/firebase-js-sdk/blob/master/CHANGELOG.md)
- [Commits](https://github.com/firebase/firebase-js-sdk/compare/firebase@8.2.5...firebase@8.2.6)

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

Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2021-02-07 08:31:32 +00:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
60eb709eb3 chore(deps-dev): bump @babel/plugin-transform-runtime from 7.12.10 to 7.12.15 in /src/packages/excalidraw (#2967)
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2021-02-07 08:27:00 +00:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
02539bbb89 chore(deps): bump @testing-library/react from 11.2.3 to 11.2.5 (#2975)
Bumps [@testing-library/react](https://github.com/testing-library/react-testing-library) from 11.2.3 to 11.2.5.
- [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.3...v11.2.5)

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

Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2021-02-07 10:19:28 +02:00
LipisandGitHub 77ae5d4605 chore(deps): bump @sentry/* from 6.0.3 to 6.1.0 (#2984) 2021-02-07 10:17:05 +02:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
bdd4f69bf6 chore(deps-dev): bump @babel/preset-react from 7.12.10 to 7.12.13 in /src/packages/excalidraw (#2963)
Bumps [@babel/preset-react](https://github.com/babel/babel/tree/HEAD/packages/babel-preset-react) from 7.12.10 to 7.12.13.
- [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.13/packages/babel-preset-react)

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

Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2021-02-07 10:16:47 +02:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
87ca829490 chore(deps-dev): bump webpack-cli from 4.4.0 to 4.5.0 in /src/packages/excalidraw (#2953)
Bumps [webpack-cli](https://github.com/webpack/webpack-cli) from 4.4.0 to 4.5.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/webpack-cli@4.4.0...webpack-cli@4.5.0)

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

Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2021-02-07 10:15:42 +02:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
a31a7fd766 chore(deps-dev): bump @babel/plugin-transform-typescript from 7.12.1 to 7.12.13 in /src/packages/utils (#2954)
Bumps [@babel/plugin-transform-typescript](https://github.com/babel/babel/tree/HEAD/packages/babel-plugin-transform-typescript) from 7.12.1 to 7.12.13.
- [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.13/packages/babel-plugin-transform-typescript)

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

Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2021-02-07 08:12:48 +00:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
e70f02063f chore(deps-dev): bump sass-loader from 10.1.1 to 11.0.0 in /src/packages/excalidraw (#2971)
Bumps [sass-loader](https://github.com/webpack-contrib/sass-loader) from 10.1.1 to 11.0.0.
- [Release notes](https://github.com/webpack-contrib/sass-loader/releases)
- [Changelog](https://github.com/webpack-contrib/sass-loader/blob/master/CHANGELOG.md)
- [Commits](https://github.com/webpack-contrib/sass-loader/compare/v10.1.1...v11.0.0)

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

Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2021-02-07 08:10:24 +00:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
769f727bd4 chore(deps-dev): bump @babel/plugin-transform-arrow-functions from 7.12.1 to 7.12.13 in /src/packages/utils (#2960)
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2021-02-07 07:58:51 +00:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
bc994fcbe2 chore(deps-dev): bump @babel/core from 7.12.10 to 7.12.13 in /src/packages/utils (#2956)
Bumps [@babel/core](https://github.com/babel/babel/tree/HEAD/packages/babel-core) from 7.12.10 to 7.12.13.
- [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.13/packages/babel-core)

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

Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2021-02-07 09:52:55 +02:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
ac5e058222 chore(deps-dev): bump webpack from 5.19.0 to 5.21.1 in /src/packages/utils (#2966)
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2021-02-07 07:52:22 +00:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
d61970cdac chore(deps): bump browser-fs-access from 0.13.0 to 0.13.1 (#2980)
Bumps [browser-fs-access](https://github.com/GoogleChromeLabs/browser-fs-access) from 0.13.0 to 0.13.1.
- [Release notes](https://github.com/GoogleChromeLabs/browser-fs-access/releases)
- [Commits](https://github.com/GoogleChromeLabs/browser-fs-access/commits)

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

Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2021-02-07 09:43:49 +02:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
489d4b7469 chore(deps-dev): bump webpack from 5.19.0 to 5.21.1 in /src/packages/excalidraw (#2965)
Bumps [webpack](https://github.com/webpack/webpack) from 5.19.0 to 5.21.1.
- [Release notes](https://github.com/webpack/webpack/releases)
- [Commits](https://github.com/webpack/webpack/compare/v5.19.0...v5.21.1)

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

Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2021-02-07 09:43:26 +02:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
d88de08872 chore(deps-dev): bump webpack-cli from 4.4.0 to 4.5.0 in /src/packages/utils (#2958)
Bumps [webpack-cli](https://github.com/webpack/webpack-cli) from 4.4.0 to 4.5.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/webpack-cli@4.4.0...webpack-cli@4.5.0)

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

Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2021-02-07 09:42:51 +02:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
42882e2a93 chore(deps-dev): bump @babel/plugin-transform-typescript (#2959)
Bumps [@babel/plugin-transform-typescript](https://github.com/babel/babel/tree/HEAD/packages/babel-plugin-transform-typescript) from 7.12.1 to 7.12.13.
- [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.13/packages/babel-plugin-transform-typescript)

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

Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2021-02-07 09:42:37 +02:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
f6374e5bde chore(deps-dev): bump ts-loader from 8.0.14 to 8.0.15 in /src/packages/utils (#2962)
Bumps [ts-loader](https://github.com/TypeStrong/ts-loader) from 8.0.14 to 8.0.15.
- [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.14...v8.0.15)

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

Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2021-02-07 09:42:20 +02:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
aac9d4e837 chore(deps): bump @types/react from 17.0.0 to 17.0.1 (#2978)
Bumps [@types/react](https://github.com/DefinitelyTyped/DefinitelyTyped/tree/HEAD/types/react) from 17.0.0 to 17.0.1.
- [Release notes](https://github.com/DefinitelyTyped/DefinitelyTyped/releases)
- [Commits](https://github.com/DefinitelyTyped/DefinitelyTyped/commits/HEAD/types/react)

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

Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2021-02-07 09:42:12 +02:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
d9103b8b24 chore(deps-dev): bump firebase-tools from 9.2.2 to 9.3.0 (#2974)
Bumps [firebase-tools](https://github.com/firebase/firebase-tools) from 9.2.2 to 9.3.0.
- [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/v9.2.2...v9.3.0)

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

Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2021-02-07 09:42:02 +02:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
8b56346011 chore(deps-dev): bump lint-staged from 10.5.3 to 10.5.4 (#2976)
Bumps [lint-staged](https://github.com/okonet/lint-staged) from 10.5.3 to 10.5.4.
- [Release notes](https://github.com/okonet/lint-staged/releases)
- [Commits](https://github.com/okonet/lint-staged/compare/v10.5.3...v10.5.4)

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

Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2021-02-07 09:41:52 +02:00
Aakansha DoshiandGitHub e63a0ec5be feat: allow host to pass color for collaborators (#2943)
* feat: allow host to pass color for collaborators

* remove user prop as its not used anywhere

* update changelog and readme

* add pr link
2021-02-06 23:33:52 +05:30
86222662f2 docs: Add open collective in readme (#2948)
Co-authored-by: David Luzar <luzar.david@gmail.com>
2021-02-06 18:12:35 +02:00
066560311b feat: add props zenModeEnabled and gridModeEnabled so host can control completely (#2901)
* feat: add props zenModeEnabled and gridModeEnabled so host can control completely

* dnt show exit zenmode button when prop present

* fix

* update when props change

* Add tests

* Add tests

* update changelog and readme

* update

* Update src/tests/excalidrawPackage.test.tsx

* Update src/packages/excalidraw/README.md

Co-authored-by: Lipis <lipiridis@gmail.com>

* Update src/packages/excalidraw/README.md

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

* Apply suggestions from code review

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

* fix specs

Co-authored-by: Lipis <lipiridis@gmail.com>
Co-authored-by: David Luzar <luzar.david@gmail.com>
2021-02-06 21:22:28 +05:30
ArunandGitHub d6ca981f7a fix: Rename 'Grid mode' to 'Show grid' (#2944) 2021-02-05 23:50:53 +02:00
Excalidraw BotandGitHub f7f98d9dda chore: Update translations from Crowdin (#2930) 2021-02-05 20:07:33 +02:00
Thomas SteinerandGitHub 1a67642fd1 chore: [Idle detection] Deal with users on systems that don't handle emoji (#2941)
* Deal with users on systems that don't handle emoji

* Leave no trailing space

* Move function to utils, and actually call it 🤣
Chapeau, TypeScript!

* Use grinning face instead of koala

* Tweak globalAlpha
2021-02-05 18:34:35 +01:00
David LuzarandGitHub 6aa22bada8 fix: mobile toolbar tooltip regression (#2939) 2021-02-05 16:09:40 +01:00
00209ef9c3 fix: hide collaborator list on mobile if empty (#2938)
Co-authored-by: Lipis <lipiridis@gmail.com>
2021-02-05 15:45:44 +01:00
David LuzarandGitHub b79ef0d428 fix: don't prompt on empty scenes (#2937) 2021-02-05 12:04:33 +01:00
Excalidraw BotandGitHub dc25fe06d0 chore: Update translations from Crowdin (#2906) 2021-02-04 20:18:57 +02:00
David LuzarandGitHub 1e17c1967b fix: toolbar unnecessarily eats too much width (#2924) 2021-02-04 16:52:16 +01:00
David LuzarandGitHub 23a8891e0e fix: mistakenly hardcoding scale (#2925) 2021-02-04 16:52:00 +01:00
David LuzarandGitHub 6c81a32d62 fix: text editor not visible in dark mode (#2920) 2021-02-04 16:21:48 +01:00
David LuzarandGitHub f0f5430313 feat: support supplying custom scale when exporting canvas (#2904) 2021-02-04 14:54:08 +01:00
David LuzarandGitHub e18e945cd3 fix: incorrect z-index of text editor (#2914) 2021-02-04 14:54:00 +01:00
bd0c6e63ff feat: Show version in the stats dialog (#2908)
Co-authored-by: dwelle <luzar.david@gmail.com>
2021-02-04 13:47:46 +01:00
dbae33e4f8 fix: make scrollbars draggable when offsets are set (#2916)
Co-authored-by: dwelle <luzar.david@gmail.com>
2021-02-04 13:07:29 +01:00
LipisandGitHub 0f4a053759 chore: Run actions on pull requests as well (#2917)
* chore: Run actions on pull requests as well

* Update cancel.yml

* Update lint.yml

* Update test.yml

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

* First working version

* Add screen state

* Add type safety

* Better rendering, enum types, localization

* Add origin trial token

* Fix

* Refactor idle detection to no longer use IdleDetector API

* Cleanup some leftovers

* Fix

* Apply suggestions from code review

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

* Address feedback from code review
Thanks, @lipis

* Deal with unmount

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

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

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

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

* Update both

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

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

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

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

Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2021-01-31 20:57:53 +02:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
cdcc91faa5 chore(deps-dev): bump mini-css-extract-plugin from 1.3.4 to 1.3.5 in /src/packages/excalidraw (#2885)
Bumps [mini-css-extract-plugin](https://github.com/webpack-contrib/mini-css-extract-plugin) from 1.3.4 to 1.3.5.
- [Release notes](https://github.com/webpack-contrib/mini-css-extract-plugin/releases)
- [Changelog](https://github.com/webpack-contrib/mini-css-extract-plugin/blob/master/CHANGELOG.md)
- [Commits](https://github.com/webpack-contrib/mini-css-extract-plugin/compare/v1.3.4...v1.3.5)

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

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

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

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

fixes #2870

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

* fix

* use clsx

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

* fix build

* Make requested changes

* More changes

* make into function

* update changelog

* fix tests

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

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

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

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

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

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

Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2021-01-24 10:52:52 +02:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
d63ec678db chore(deps): bump @sentry/integrations from 5.30.0 to 6.0.1 (#2854)
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2021-01-24 10:52:41 +02:00
LipisandGitHub 26acebcdb6 chore: Update translations from Crowdin (#2817) 2021-01-22 18:06:21 +02:00
David LuzarandGitHub 9dc930b447 feat: add ctrl-y to redo (#2831) 2021-01-21 16:21:54 +01:00
Aakansha DoshiandGitHub 6e767fc949 fix(actionmenu): toggle help dialog when "shift+?" is pressed (#2828) 2021-01-21 13:13:47 +02:00
160 changed files with 36242 additions and 4883 deletions
+1 -1
View File
@@ -2,6 +2,7 @@
"extends": ["prettier", "react-app"], "extends": ["prettier", "react-app"],
"plugins": ["prettier"], "plugins": ["prettier"],
"rules": { "rules": {
"@typescript-eslint/no-unused-vars": "warn",
"curly": "warn", "curly": "warn",
"dot-notation": "warn", "dot-notation": "warn",
"import/no-anonymous-default-export": "off", "import/no-anonymous-default-export": "off",
@@ -22,7 +23,6 @@
], ],
"no-unneeded-ternary": "warn", "no-unneeded-ternary": "warn",
"no-unused-expressions": "warn", "no-unused-expressions": "warn",
"no-unused-vars": "warn",
"no-useless-return": "warn", "no-useless-return": "warn",
"no-var": "warn", "no-var": "warn",
"object-shorthand": "warn", "object-shorthand": "warn",
+1 -1
View File
@@ -6,7 +6,7 @@ on:
- master - master
jobs: jobs:
build-docker: build:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
+2 -2
View File
@@ -13,10 +13,10 @@ jobs:
steps: steps:
- uses: actions/checkout@v1 - uses: actions/checkout@v1
- name: Setup Node.js 12.x - name: Setup Node.js 14.x
uses: actions/setup-node@v1 uses: actions/setup-node@v1
with: with:
node-version: 12.x node-version: 14.x
- name: Install dependencies - name: Install dependencies
run: | run: |
+9 -3
View File
@@ -1,9 +1,15 @@
name: Cancel name: Cancel previous runs
on: [push]
on:
push:
branches:
- master
pull_request:
jobs: jobs:
cancel: cancel:
name: "Cancel Previous Runs"
runs-on: ubuntu-latest runs-on: ubuntu-latest
timeout-minutes: 3 timeout-minutes: 3
steps: steps:
- uses: styfle/cancel-workflow-action@0.6.0 - uses: styfle/cancel-workflow-action@0.6.0
+3 -9
View File
@@ -1,10 +1,6 @@
name: Lint name: Lint
on: on: pull_request
push:
branches:
- master
pull_request:
jobs: jobs:
lint: lint:
@@ -13,10 +9,10 @@ jobs:
steps: steps:
- uses: actions/checkout@v1 - uses: actions/checkout@v1
- name: Setup Node.js 12.x - name: Setup Node.js 14.x
uses: actions/setup-node@v1 uses: actions/setup-node@v1
with: with:
node-version: 12.x node-version: 14.x
- name: Install and lint - name: Install and lint
run: | run: |
@@ -24,5 +20,3 @@ jobs:
npm run test:other npm run test:other
npm run test:code npm run test:code
npm run test:typecheck npm run test:typecheck
env:
CI: true
+4 -4
View File
@@ -14,18 +14,18 @@ jobs:
with: with:
token: ${{ secrets.PUSH_TRANSLATIONS_COVERAGE_PAT }} token: ${{ secrets.PUSH_TRANSLATIONS_COVERAGE_PAT }}
- name: Setup Node.js 12.x - name: Setup Node.js 14.x
uses: actions/setup-node@v1 uses: actions/setup-node@v1
with: with:
node-version: 12.x node-version: 14.x
- name: Create report file - name: Create report file
run: | run: |
npm run locales-coverage npm run locales-coverage
FILE_CHANGED=$(git diff src/locales/percentages.json) FILE_CHANGED=$(git diff src/locales/percentages.json)
if [ ! -z "${FILE_CHANGED}" ]; then if [ ! -z "${FILE_CHANGED}" ]; then
git config --global user.name 'Kostas Bariotis' git config --global user.name 'Excalidraw Bot'
git config --global user.email 'konmpar@gmail.com' git config --global user.email 'bot@excalidraw.com'
git add src/locales/percentages.json git add src/locales/percentages.json
git commit -am "Auto commit: Calculate translation coverage" git commit -am "Auto commit: Calculate translation coverage"
git push git push
+1
View File
@@ -10,6 +10,7 @@ on:
jobs: jobs:
main: main:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- uses: amannn/action-semantic-pull-request@v3.0.0 - uses: amannn/action-semantic-pull-request@v3.0.0
env: env:
+3 -2
View File
@@ -8,13 +8,14 @@ on:
jobs: jobs:
release: release:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- uses: actions/checkout@v1.0.0 - uses: actions/checkout@v1.0.0
- name: Setup Node.js 12.x - name: Setup Node.js 14.x
uses: actions/setup-node@v1 uses: actions/setup-node@v1
with: with:
node-version: 12.x node-version: 14.x
- name: Install and build - name: Install and build
run: | run: |
+3 -9
View File
@@ -1,10 +1,6 @@
name: Tests name: Tests
on: on: pull_request
push:
branches:
- master
pull_request:
jobs: jobs:
test: test:
@@ -13,14 +9,12 @@ jobs:
steps: steps:
- uses: actions/checkout@v1 - uses: actions/checkout@v1
- name: Setup Node.js 12.x - name: Setup Node.js 14.x
uses: actions/setup-node@v1 uses: actions/setup-node@v1
with: with:
node-version: 12.x node-version: 14.x
- name: Install and test - name: Install and test
run: | run: |
npm ci npm ci
npm run test:app npm run test:app
env:
CI: true
+1 -2
View File
@@ -19,7 +19,7 @@
### Option 2 - CodeSandbox ### Option 2 - CodeSandbox
1. Go to https://codesandbox.io/s/github/excalidraw/excalidraw 1. Go to https://codesandbox.io/s/github/excalidraw/excalidraw
1. Connect your Github account 1. Connect your GitHub account
1. Go to Git tab on left side 1. Go to Git tab on left side
1. Tap on `Fork Sandbox` 1. Tap on `Fork Sandbox`
1. Write your code 1. Write your code
@@ -35,7 +35,6 @@ Make sure the title starts with a semantic prefix:
- **feat**: A new feature - **feat**: A new feature
- **fix**: A bug fix - **fix**: A bug fix
- **improvement**: An improvement to a current feature
- **docs**: Documentation only changes - **docs**: Documentation only changes
- **style**: Changes that do not affect the meaning of the code (white-space, formatting, missing semi-colons, etc) - **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 - **refactor**: A code change that neither fixes a bug nor adds a feature
+21 -8
View File
@@ -2,7 +2,7 @@
<a href="https://excalidraw.com"> <a href="https://excalidraw.com">
<img width="540" src="./public/og-image-sm.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> </a>
<h3>Virtual whiteboard for sketching hand-drawn like diagrams.<br>Collaborative and end to end encrypted.</h3> <h3>Virtual whiteboard for sketching hand-drawn like diagrams.<br>Collaborative and end-to-end encrypted.</h3>
<p> <p>
<a href="https://twitter.com/Excalidraw"> <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"> <img alt="Follow Excalidraw on Twitter" src="https://img.shields.io/twitter/follow/excalidraw.svg?label=follow+excalidraw&style=social&logo=twitter">
@@ -11,6 +11,7 @@
<img src="https://badges.crowdin.net/excalidraw/localized.svg"> <img src="https://badges.crowdin.net/excalidraw/localized.svg">
</a> </a>
</p> </p>
<p>Ask questions or hang out on our <a target="_blank" href="https://discord.gg/UexuTaE">discord.gg/UexuTaE</a>.</p>
</div> </div>
## Try it now ## Try it now
@@ -19,6 +20,14 @@ Go to [excalidraw.com](https://excalidraw.com) to start sketching.
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/). 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/).
## Supporting Excalidraw
If you like the project, you can become a sponsor at [Open Collective](https://opencollective.com/excalidraw).
[<img src="https://opencollective.com/excalidraw/tiers/sponsors/0/avatar.svg?avatarHeight=120">](https://opencollective.com/excalidraw/tiers/sponsors/0/website) [<img src="https://opencollective.com/excalidraw/tiers/sponsors/1/avatar.svg?avatarHeight=120">](https://opencollective.com/excalidraw/tiers/sponsors/1/website) [<img src="https://opencollective.com/excalidraw/tiers/sponsors/2/avatar.svg?avatarHeight=120">](https://opencollective.com/excalidraw/tiers/sponsors/2/website) [<img src="https://opencollective.com/excalidraw/tiers/sponsors/3/avatar.svg?avatarHeight=120">](https://opencollective.com/excalidraw/tiers/sponsors/3/website) [<img src="https://opencollective.com/excalidraw/tiers/sponsors/4/avatar.svg?avatarHeight=120">](https://opencollective.com/excalidraw/tiers/sponsors/4/website) [<img src="https://opencollective.com/excalidraw/tiers/sponsors/5/avatar.svg?avatarHeight=120">](https://opencollective.com/excalidraw/tiers/sponsors/5/website) [<img src="https://opencollective.com/excalidraw/tiers/sponsors/6/avatar.svg?avatarHeight=120">](https://opencollective.com/excalidraw/tiers/sponsors/6/website) [<img src="https://opencollective.com/excalidraw/tiers/sponsors/7/avatar.svg?avatarHeight=120">](https://opencollective.com/excalidraw/tiers/sponsors/7/website) [<img src="https://opencollective.com/excalidraw/tiers/sponsors/8/avatar.svg?avatarHeight=120">](https://opencollective.com/excalidraw/tiers/sponsors/8/website) [<img src="https://opencollective.com/excalidraw/tiers/sponsors/9/avatar.svg?avatarHeight=120">](https://opencollective.com/excalidraw/tiers/sponsors/9/website) [<img src="https://opencollective.com/excalidraw/tiers/sponsors/10/avatar.svg?avatarHeight=120">](https://opencollective.com/excalidraw/tiers/sponsors/10/website)
<a href="https://opencollective.com/excalidraw#category-CONTRIBUTE" target="_blank"><img src="https://opencollective.com/excalidraw/tiers/backers.svg?avatarHeight=32"/></a>
## Documentation ## Documentation
### Shortcuts ### Shortcuts
@@ -41,7 +50,7 @@ Translations will be available on the app if they exceed a certain threshold of
### Create a collaboration session manually ### Create a collaboration session manually
In order to create a session manually you just need to generate a link of this form: 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} https://excalidraw.com/#room=[0-9a-f]{20},[a-zA-Z0-9_-]{22}
@@ -61,12 +70,16 @@ The second set of digits is the encryption key. The Excalidraw server doesnt
Find a growing list of libraries containing assets for your drawings at [libraries.excalidraw.com](https://libraries.excalidraw.com). Find a growing list of libraries containing assets for your drawings at [libraries.excalidraw.com](https://libraries.excalidraw.com).
## Developement ## Embedding Excalidraw in your App?
Try out [`@excalidraw/excalidraw`](https://www.npmjs.com/package/@excalidraw/excalidraw). This package allows you to easily embed Excalidraw as a React component into your apps.
## Development
### Code Sandbox ### Code Sandbox
- Go to https://codesandbox.io/s/github/excalidraw/excalidraw - Go to https://codesandbox.io/s/github/excalidraw/excalidraw
- You may need to sign in with Github and reload the page - You may need to sign in with GitHub and reload the page
- You can start coding instantly, and even send PRs from there! - You can start coding instantly, and even send PRs from there!
### Local Installation ### Local Installation
@@ -92,15 +105,15 @@ git clone https://github.com/excalidraw/excalidraw.git
#### Docker Compose #### Docker Compose
You can use docker-compose to work on excalidraw locally if you don't want to setup a Node.js env. You can use docker-compose to work on Excalidraw locally if you don't want to setup a Node.js env.
```sh ```sh
docker-compose up --build -d 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. 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.
```sh ```sh
docker build -t excalidraw/excalidraw . docker build -t excalidraw/excalidraw .
@@ -111,7 +124,7 @@ The Docker image is free of analytics and other tracking libraries.
**At the moment, self-hosting your own instance doesn't support sharing or collaboration features.** **At the moment, self-hosting your own instance doesn't support sharing or collaboration features.**
We are working towards providing a full-fledged solution for self hosting your own Excalidraw. We are working towards providing a full-fledged solution for self-hosting your own Excalidraw.
## Contributing ## Contributing
+29606 -1570
View File
File diff suppressed because it is too large Load Diff
+20 -15
View File
@@ -19,17 +19,17 @@
] ]
}, },
"dependencies": { "dependencies": {
"@sentry/browser": "5.30.0", "@sentry/browser": "6.1.0",
"@sentry/integrations": "5.30.0", "@sentry/integrations": "6.1.0",
"@testing-library/jest-dom": "5.11.9", "@testing-library/jest-dom": "5.11.9",
"@testing-library/react": "11.2.3", "@testing-library/react": "11.2.5",
"@types/jest": "26.0.20", "@types/jest": "26.0.20",
"@types/react": "17.0.0", "@types/react": "17.0.2",
"@types/react-dom": "17.0.0", "@types/react-dom": "17.0.1",
"@types/socket.io-client": "1.4.35", "@types/socket.io-client": "1.4.35",
"browser-nativefs": "0.12.0", "browser-fs-access": "0.13.1",
"clsx": "1.1.1", "clsx": "1.1.1",
"firebase": "8.2.3", "firebase": "8.2.7",
"i18next-browser-languagedetector": "6.0.1", "i18next-browser-languagedetector": "6.0.1",
"lodash.throttle": "4.1.1", "lodash.throttle": "4.1.1",
"nanoid": "3.1.20", "nanoid": "3.1.20",
@@ -43,23 +43,25 @@
"pwacompat": "2.0.17", "pwacompat": "2.0.17",
"react": "17.0.1", "react": "17.0.1",
"react-dom": "17.0.1", "react-dom": "17.0.1",
"react-scripts": "4.0.1", "react-scripts": "4.0.2",
"roughjs": "4.3.1", "roughjs": "4.3.1",
"socket.io-client": "2.3.1", "socket.io-client": "2.3.1",
"typescript": "4.1.3" "typescript": "4.1.5"
}, },
"devDependencies": { "devDependencies": {
"@types/lodash.throttle": "4.1.6", "@types/lodash.throttle": "4.1.6",
"@types/pako": "1.0.1", "@types/pako": "1.0.1",
"eslint-config-prettier": "7.1.0", "@types/resize-observer-browser": "0.1.5",
"eslint-config-prettier": "7.2.0",
"eslint-plugin-prettier": "3.3.1", "eslint-plugin-prettier": "3.3.1",
"firebase-tools": "9.2.1", "firebase-tools": "9.3.0",
"husky": "4.3.8", "husky": "4.3.8",
"jest-canvas-mock": "2.3.0", "jest-canvas-mock": "2.3.1",
"lint-staged": "10.5.3", "lint-staged": "10.5.4",
"pepjs": "0.5.3", "pepjs": "0.5.3",
"prettier": "2.2.1", "prettier": "2.2.1",
"rewire": "5.0.0" "rewire": "5.0.0",
"worker-loader": "3.0.8"
}, },
"engines": { "engines": {
"node": ">=12.0.0" "node": ">=12.0.0"
@@ -72,8 +74,11 @@
}, },
"jest": { "jest": {
"transformIgnorePatterns": [ "transformIgnorePatterns": [
"node_modules/(?!(roughjs|points-on-curve|path-data-parser|points-on-path|browser-nativefs)/)" "node_modules/(?!(roughjs|points-on-curve|path-data-parser|points-on-path|browser-fs-access)/)"
], ],
"moduleNameMapper": {
"^worker-loader!.+": "<rootDir>/src/__mocks__/worker-mock.js"
},
"resetMocks": false "resetMocks": false
}, },
"name": "excalidraw", "name": "excalidraw",
+1
View File
@@ -57,6 +57,7 @@
<!-- Excalidraw version --> <!-- Excalidraw version -->
<meta name="version" content="{version}" /> <meta name="version" content="{version}" />
<link <link
rel="preload" rel="preload"
href="FG_Virgil.woff2" href="FG_Virgil.woff2"
+4 -1
View File
@@ -18,6 +18,7 @@ const crowdinMap = {
"id-ID": "en-id", "id-ID": "en-id",
"it-IT": "en-it", "it-IT": "en-it",
"ja-JP": "en-ja", "ja-JP": "en-ja",
"kab-KAB": "en-kab",
"ko-KR": "en-ko", "ko-KR": "en-ko",
"my-MM": "en-my", "my-MM": "en-my",
"nb-NO": "en-nb", "nb-NO": "en-nb",
@@ -40,7 +41,7 @@ const crowdinMap = {
const flags = { const flags = {
"ar-SA": "🇸🇦", "ar-SA": "🇸🇦",
"bg-BG": "🇧🇬", "bg-BG": "🇧🇬",
"ca-ES": "🇪🇸", "ca-ES": "🏳",
"de-DE": "🇩🇪", "de-DE": "🇩🇪",
"el-GR": "🇬🇷", "el-GR": "🇬🇷",
"es-ES": "🇪🇸", "es-ES": "🇪🇸",
@@ -53,6 +54,7 @@ const flags = {
"id-ID": "🇮🇩", "id-ID": "🇮🇩",
"it-IT": "🇮🇹", "it-IT": "🇮🇹",
"ja-JP": "🇯🇵", "ja-JP": "🇯🇵",
"kab-KAB": "🏳",
"ko-KR": "🇰🇷", "ko-KR": "🇰🇷",
"my-MM": "🇲🇲", "my-MM": "🇲🇲",
"nb-NO": "🇳🇴", "nb-NO": "🇳🇴",
@@ -88,6 +90,7 @@ const languages = {
"id-ID": "Bahasa Indonesia", "id-ID": "Bahasa Indonesia",
"it-IT": "Italiano", "it-IT": "Italiano",
"ja-JP": "日本語", "ja-JP": "日本語",
"kab-KAB": "Taqbaylit",
"ko-KR": "한국어", "ko-KR": "한국어",
"my-MM": "Burmese", "my-MM": "Burmese",
"nb-NO": "Norsk bokmål", "nb-NO": "Norsk bokmål",
+4
View File
@@ -0,0 +1,4 @@
module.exports = class {
postMessage() {}
terminate() {}
};
-1
View File
@@ -17,6 +17,5 @@ export const actionAddToLibrary = register({
}); });
return false; return false;
}, },
contextMenuOrder: 6,
contextItemLabel: "labels.addToLibrary", contextItemLabel: "labels.addToLibrary",
}); });
+1 -2
View File
@@ -3,6 +3,7 @@ import { getDefaultAppState } from "../appState";
import { ColorPicker } from "../components/ColorPicker"; import { ColorPicker } from "../components/ColorPicker";
import { resetZoom, trash, zoomIn, zoomOut } from "../components/icons"; import { resetZoom, trash, zoomIn, zoomOut } from "../components/icons";
import { ToolButton } from "../components/ToolButton"; import { ToolButton } from "../components/ToolButton";
import { ZOOM_STEP } from "../constants";
import { getCommonBounds, getNonDeletedElements } from "../element"; import { getCommonBounds, getNonDeletedElements } from "../element";
import { newElementWith } from "../element/mutateElement"; import { newElementWith } from "../element/mutateElement";
import { ExcalidrawElement } from "../element/types"; import { ExcalidrawElement } from "../element/types";
@@ -75,8 +76,6 @@ export const actionClearCanvas = register({
), ),
}); });
const ZOOM_STEP = 0.1;
export const actionZoomIn = register({ export const actionZoomIn = register({
name: "zoomIn", name: "zoomIn",
perform: (_elements, appState) => { perform: (_elements, appState) => {
+114
View File
@@ -0,0 +1,114 @@
import { CODES, KEYS } from "../keys";
import { register } from "./register";
import { copyToClipboard } from "../clipboard";
import { actionDeleteSelected } from "./actionDeleteSelected";
import { getSelectedElements } from "../scene/selection";
import { exportCanvas } from "../data/index";
import { getNonDeletedElements } from "../element";
import { t } from "../i18n";
export const actionCopy = register({
name: "copy",
perform: (elements, appState) => {
copyToClipboard(getNonDeletedElements(elements), appState);
return {
commitToHistory: false,
};
},
contextItemLabel: "labels.copy",
keyTest: (event) => event[KEYS.CTRL_OR_CMD] && event.code === CODES.C,
});
export const actionCut = register({
name: "cut",
perform: (elements, appState, data, app) => {
actionCopy.perform(elements, appState, data, app);
return actionDeleteSelected.perform(elements, appState, data, app);
},
contextItemLabel: "labels.cut",
keyTest: (event) => event[KEYS.CTRL_OR_CMD] && event.code === CODES.X,
});
export const actionCopyAsSvg = register({
name: "copyAsSvg",
perform: async (elements, appState, _data, app) => {
if (!app.canvas) {
return {
commitToHistory: false,
};
}
const selectedElements = getSelectedElements(
getNonDeletedElements(elements),
appState,
);
try {
await exportCanvas(
"clipboard-svg",
selectedElements.length
? selectedElements
: getNonDeletedElements(elements),
appState,
app.canvas,
appState,
);
return {
commitToHistory: false,
};
} catch (error) {
console.error(error);
return {
appState: {
...appState,
errorMessage: error.message,
},
commitToHistory: false,
};
}
},
contextItemLabel: "labels.copyAsSvg",
});
export const actionCopyAsPng = register({
name: "copyAsPng",
perform: async (elements, appState, _data, app) => {
if (!app.canvas) {
return {
commitToHistory: false,
};
}
const selectedElements = getSelectedElements(
getNonDeletedElements(elements),
appState,
);
try {
await exportCanvas(
"clipboard",
selectedElements.length
? selectedElements
: getNonDeletedElements(elements),
appState,
app.canvas,
appState,
);
return {
appState: {
...appState,
toastMessage: t("toast.copyToClipboardAsPng"),
},
commitToHistory: false,
};
} catch (error) {
console.error(error);
return {
appState: {
...appState,
errorMessage: error.message,
},
commitToHistory: false,
};
}
},
contextItemLabel: "labels.copyAsPng",
keyTest: (event) => event.code === CODES.C && event.altKey && event.shiftKey,
});
-1
View File
@@ -136,7 +136,6 @@ export const actionDeleteSelected = register({
}; };
}, },
contextItemLabel: "labels.delete", contextItemLabel: "labels.delete",
contextMenuOrder: 999999,
keyTest: (event) => event.key === KEYS.BACKSPACE || event.key === KEYS.DELETE, keyTest: (event) => event.key === KEYS.BACKSPACE || event.key === KEYS.DELETE,
PanelComponent: ({ elements, appState, updateData }) => ( PanelComponent: ({ elements, appState, updateData }) => (
<ToolButton <ToolButton
+16 -1
View File
@@ -96,9 +96,24 @@ export const actionChangeShouldAddWatermark = register({
export const actionSaveScene = register({ export const actionSaveScene = register({
name: "saveScene", name: "saveScene",
perform: async (elements, appState, value) => { perform: async (elements, appState, value) => {
const fileHandleExists = !!appState.fileHandle;
try { try {
const { fileHandle } = await saveAsJSON(elements, appState); const { fileHandle } = await saveAsJSON(elements, appState);
return { commitToHistory: false, appState: { ...appState, fileHandle } }; return {
commitToHistory: false,
appState: {
...appState,
fileHandle,
toastMessage: fileHandleExists
? fileHandle.name
? t("toast.fileSavedToFilename").replace(
"{filename}",
`"${fileHandle.name}"`,
)
: t("toast.fileSaved")
: null,
},
};
} catch (error) { } catch (error) {
if (error?.name !== "AbortError") { if (error?.name !== "AbortError") {
console.error(error); console.error(error);
+1 -1
View File
@@ -83,7 +83,7 @@ export const actionFinalize = register({
// If the multi point line closes the loop, // If the multi point line closes the loop,
// set the last point to first point. // set the last point to first point.
// This ensures that loop remains closed at different scales. // This ensures that loop remains closed at different scales.
const isLoop = isPathALoop(multiPointElement.points); const isLoop = isPathALoop(multiPointElement.points, appState.zoom.value);
if ( if (
multiPointElement.type === "line" || multiPointElement.type === "line" ||
multiPointElement.type === "draw" multiPointElement.type === "draw"
-2
View File
@@ -125,7 +125,6 @@ export const actionGroup = register({
commitToHistory: true, commitToHistory: true,
}; };
}, },
contextMenuOrder: 4,
contextItemLabel: "labels.group", contextItemLabel: "labels.group",
contextItemPredicate: (elements, appState) => contextItemPredicate: (elements, appState) =>
enableActionGroup(elements, appState), enableActionGroup(elements, appState),
@@ -174,7 +173,6 @@ export const actionUngroup = register({
}, },
keyTest: (event) => keyTest: (event) =>
event.shiftKey && event[KEYS.CTRL_OR_CMD] && event.code === CODES.G, event.shiftKey && event[KEYS.CTRL_OR_CMD] && event.code === CODES.G,
contextMenuOrder: 5,
contextItemLabel: "labels.ungroup", contextItemLabel: "labels.ungroup",
contextItemPredicate: (elements, appState) => contextItemPredicate: (elements, appState) =>
getSelectedGroupIds(appState).length > 0, getSelectedGroupIds(appState).length > 0,
+10 -6
View File
@@ -6,7 +6,7 @@ import { t } from "../i18n";
import { SceneHistory, HistoryEntry } from "../history"; import { SceneHistory, HistoryEntry } from "../history";
import { ExcalidrawElement } from "../element/types"; import { ExcalidrawElement } from "../element/types";
import { AppState } from "../types"; import { AppState } from "../types";
import { KEYS } from "../keys"; import { isWindows, KEYS } from "../keys";
import { getElementMap } from "../element"; import { getElementMap } from "../element";
import { newElementWith } from "../element/mutateElement"; import { newElementWith } from "../element/mutateElement";
import { fixBindingsAfterDeletion } from "../element/binding"; import { fixBindingsAfterDeletion } from "../element/binding";
@@ -59,16 +59,16 @@ const writeData = (
return { commitToHistory }; return { commitToHistory };
}; };
const testUndo = (shift: boolean) => (event: KeyboardEvent) =>
event[KEYS.CTRL_OR_CMD] && /z/i.test(event.key) && event.shiftKey === shift;
type ActionCreator = (history: SceneHistory) => Action; type ActionCreator = (history: SceneHistory) => Action;
export const createUndoAction: ActionCreator = (history) => ({ export const createUndoAction: ActionCreator = (history) => ({
name: "undo", name: "undo",
perform: (elements, appState) => perform: (elements, appState) =>
writeData(elements, appState, () => history.undoOnce()), writeData(elements, appState, () => history.undoOnce()),
keyTest: testUndo(false), keyTest: (event) =>
event[KEYS.CTRL_OR_CMD] &&
event.key.toLowerCase() === KEYS.Z &&
!event.shiftKey,
PanelComponent: ({ updateData }) => ( PanelComponent: ({ updateData }) => (
<ToolButton <ToolButton
type="button" type="button"
@@ -84,7 +84,11 @@ export const createRedoAction: ActionCreator = (history) => ({
name: "redo", name: "redo",
perform: (elements, appState) => perform: (elements, appState) =>
writeData(elements, appState, () => history.redoOnce()), writeData(elements, appState, () => history.redoOnce()),
keyTest: testUndo(true), keyTest: (event) =>
(event[KEYS.CTRL_OR_CMD] &&
event.shiftKey &&
event.key.toLowerCase() === KEYS.Z) ||
(isWindows && event.ctrlKey && !event.shiftKey && event.key === KEYS.Y),
PanelComponent: ({ updateData }) => ( PanelComponent: ({ updateData }) => (
<ToolButton <ToolButton
type="button" type="button"
+20 -1
View File
@@ -7,6 +7,7 @@ import { register } from "./register";
import { allowFullScreen, exitFullScreen, isFullScreen } from "../utils"; import { allowFullScreen, exitFullScreen, isFullScreen } from "../utils";
import { CODES, KEYS } from "../keys"; import { CODES, KEYS } from "../keys";
import { HelpIcon } from "../components/HelpIcon"; import { HelpIcon } from "../components/HelpIcon";
import { MiniMap } from "../components/MiniMap";
export const actionToggleCanvasMenu = register({ export const actionToggleCanvasMenu = register({
name: "toggleCanvasMenu", name: "toggleCanvasMenu",
@@ -74,7 +75,7 @@ export const actionShortcuts = register({
return { return {
appState: { appState: {
...appState, ...appState,
showHelpDialog: true, showHelpDialog: !appState.showHelpDialog,
}, },
commitToHistory: false, commitToHistory: false,
}; };
@@ -84,3 +85,21 @@ export const actionShortcuts = register({
), ),
keyTest: (event) => event.key === KEYS.QUESTION_MARK, keyTest: (event) => event.key === KEYS.QUESTION_MARK,
}); });
export const actionMinimap = register({
name: "toggleMinimap",
perform: (_elements, appState) => {
return {
appState: {
...appState,
isMinimapEnabled: !appState.isMinimapEnabled,
},
commitToHistory: false,
};
},
PanelComponent: ({ appState, elements }) =>
appState.isMinimapEnabled ? (
<MiniMap appState={appState} elements={elements} />
) : null,
keyTest: (event) => event.key === KEYS.M,
});
+1 -1
View File
@@ -42,7 +42,7 @@ export const actionGoToCollaborator = register({
return null; return null;
} }
const { background, stroke } = getClientColors(clientId); const { background, stroke } = getClientColors(clientId, appState);
const shortName = getClientInitials(collaborator.username); const shortName = getClientInitials(collaborator.username);
return ( return (
-2
View File
@@ -34,7 +34,6 @@ export const actionCopyStyles = register({
contextItemLabel: "labels.copyStyles", contextItemLabel: "labels.copyStyles",
keyTest: (event) => keyTest: (event) =>
event[KEYS.CTRL_OR_CMD] && event.altKey && event.code === CODES.C, event[KEYS.CTRL_OR_CMD] && event.altKey && event.code === CODES.C,
contextMenuOrder: 0,
}); });
export const actionPasteStyles = register({ export const actionPasteStyles = register({
@@ -74,5 +73,4 @@ export const actionPasteStyles = register({
contextItemLabel: "labels.pasteStyles", contextItemLabel: "labels.pasteStyles",
keyTest: (event) => keyTest: (event) =>
event[KEYS.CTRL_OR_CMD] && event.altKey && event.code === CODES.V, event[KEYS.CTRL_OR_CMD] && event.altKey && event.code === CODES.V,
contextMenuOrder: 1,
}); });
+22
View File
@@ -0,0 +1,22 @@
import { CODES, KEYS } from "../keys";
import { register } from "./register";
import { GRID_SIZE } from "../constants";
import { AppState } from "../types";
import { trackEvent } from "../analytics";
export const actionToggleGridMode = register({
name: "gridMode",
perform(elements, appState) {
trackEvent("view", "mode", "grid");
return {
appState: {
...appState,
gridSize: this.checked!(appState) ? null : GRID_SIZE,
},
commitToHistory: false,
};
},
checked: (appState: AppState) => appState.gridSize !== null,
contextItemLabel: "labels.showGrid",
keyTest: (event) => event[KEYS.CTRL_OR_CMD] && event.code === CODES.QUOTE,
});
+16
View File
@@ -0,0 +1,16 @@
import { register } from "./register";
export const actionToggleStats = register({
name: "stats",
perform(elements, appState) {
return {
appState: {
...appState,
showStats: !this.checked!(appState),
},
commitToHistory: false,
};
},
checked: (appState) => appState.showStats,
contextItemLabel: "stats.title",
});
+22
View File
@@ -0,0 +1,22 @@
import { CODES, KEYS } from "../keys";
import { register } from "./register";
import { trackEvent } from "../analytics";
export const actionToggleViewMode = register({
name: "viewMode",
perform(elements, appState) {
trackEvent("view", "mode", "view");
return {
appState: {
...appState,
viewModeEnabled: !this.checked!(appState),
selectedElementIds: {},
},
commitToHistory: false,
};
},
checked: (appState) => appState.viewModeEnabled,
contextItemLabel: "labels.viewMode",
keyTest: (event) =>
!event[KEYS.CTRL_OR_CMD] && event.altKey && event.code === CODES.R,
});
+22
View File
@@ -0,0 +1,22 @@
import { CODES, KEYS } from "../keys";
import { register } from "./register";
import { trackEvent } from "../analytics";
export const actionToggleZenMode = register({
name: "zenMode",
perform(elements, appState) {
trackEvent("view", "mode", "zen");
return {
appState: {
...appState,
zenModeEnabled: !this.checked!(appState),
},
commitToHistory: false,
};
},
checked: (appState) => appState.zenModeEnabled,
contextItemLabel: "buttons.zenMode",
keyTest: (event) =>
!event[KEYS.CTRL_OR_CMD] && event.altKey && event.code === CODES.Z,
});
+13
View File
@@ -44,6 +44,7 @@ export {
actionToggleEditMenu, actionToggleEditMenu,
actionFullScreen, actionFullScreen,
actionShortcuts, actionShortcuts,
actionMinimap,
} from "./actionMenu"; } from "./actionMenu";
export { actionGroup, actionUngroup } from "./actionGroup"; export { actionGroup, actionUngroup } from "./actionGroup";
@@ -65,3 +66,15 @@ export {
distributeHorizontally, distributeHorizontally,
distributeVertically, distributeVertically,
} from "./actionDistribute"; } from "./actionDistribute";
export {
actionCopy,
actionCut,
actionCopyAsPng,
actionCopyAsSvg,
} from "./actionClipboard";
export { actionToggleGridMode } from "./actionToggleGridMode";
export { actionToggleZenMode } from "./actionToggleZenMode";
export { actionToggleStats } from "./actionToggleStats";
+18 -38
View File
@@ -3,14 +3,16 @@ import {
Action, Action,
ActionsManagerInterface, ActionsManagerInterface,
UpdaterFn, UpdaterFn,
ActionFilterFn,
ActionName, ActionName,
ActionResult, ActionResult,
} from "./types"; } from "./types";
import { ExcalidrawElement } from "../element/types"; import { ExcalidrawElement } from "../element/types";
import { AppState } from "../types"; import { AppState, ExcalidrawProps } from "../types";
import { t } from "../i18n"; import { MODES } from "../constants";
import { ShortcutName } from "./shortcuts";
// This is the <App> component, but for now we don't care about anything but its
// `canvas` state.
type App = { canvas: HTMLCanvasElement | null; props: ExcalidrawProps };
export class ActionManager implements ActionsManagerInterface { export class ActionManager implements ActionsManagerInterface {
actions = {} as ActionsManagerInterface["actions"]; actions = {} as ActionsManagerInterface["actions"];
@@ -18,13 +20,14 @@ export class ActionManager implements ActionsManagerInterface {
updater: (actionResult: ActionResult | Promise<ActionResult>) => void; updater: (actionResult: ActionResult | Promise<ActionResult>) => void;
getAppState: () => Readonly<AppState>; getAppState: () => Readonly<AppState>;
getElementsIncludingDeleted: () => readonly ExcalidrawElement[]; getElementsIncludingDeleted: () => readonly ExcalidrawElement[];
app: App;
constructor( constructor(
updater: UpdaterFn, updater: UpdaterFn,
getAppState: () => AppState, getAppState: () => AppState,
getElementsIncludingDeleted: () => readonly ExcalidrawElement[], getElementsIncludingDeleted: () => readonly ExcalidrawElement[],
app: App,
) { ) {
this.updater = (actionResult) => { this.updater = (actionResult) => {
if (actionResult && "then" in actionResult) { if (actionResult && "then" in actionResult) {
@@ -37,6 +40,7 @@ export class ActionManager implements ActionsManagerInterface {
}; };
this.getAppState = getAppState; this.getAppState = getAppState;
this.getElementsIncludingDeleted = getElementsIncludingDeleted; this.getElementsIncludingDeleted = getElementsIncludingDeleted;
this.app = app;
} }
registerAction(action: Action) { registerAction(action: Action) {
@@ -63,6 +67,12 @@ export class ActionManager implements ActionsManagerInterface {
if (data.length === 0) { if (data.length === 0) {
return false; return false;
} }
const { viewModeEnabled } = this.getAppState();
if (viewModeEnabled) {
if (!Object.values(MODES).includes(data[0].name)) {
return false;
}
}
event.preventDefault(); event.preventDefault();
this.updater( this.updater(
@@ -70,6 +80,7 @@ export class ActionManager implements ActionsManagerInterface {
this.getElementsIncludingDeleted(), this.getElementsIncludingDeleted(),
this.getAppState(), this.getAppState(),
null, null,
this.app,
), ),
); );
return true; return true;
@@ -81,43 +92,11 @@ export class ActionManager implements ActionsManagerInterface {
this.getElementsIncludingDeleted(), this.getElementsIncludingDeleted(),
this.getAppState(), this.getAppState(),
null, null,
this.app,
), ),
); );
} }
getContextMenuItems(actionFilter: ActionFilterFn = (action) => action) {
return Object.values(this.actions)
.filter(actionFilter)
.filter((action) => "contextItemLabel" in action)
.filter((action) =>
action.contextItemPredicate
? action.contextItemPredicate(
this.getElementsIncludingDeleted(),
this.getAppState(),
)
: true,
)
.sort(
(a, b) =>
(a.contextMenuOrder !== undefined ? a.contextMenuOrder : 999) -
(b.contextMenuOrder !== undefined ? b.contextMenuOrder : 999),
)
.map((action) => ({
// take last bit of the label "labels.<shortcutName>"
shortcutName: action.contextItemLabel?.split(".").pop() as ShortcutName,
label: action.contextItemLabel ? t(action.contextItemLabel) : "",
action: () => {
this.updater(
action.perform(
this.getElementsIncludingDeleted(),
this.getAppState(),
null,
),
);
},
}));
}
// Id is an attribute that we can use to pass in data like keys. // Id is an attribute that we can use to pass in data like keys.
// This is needed for dynamically generated action components // This is needed for dynamically generated action components
// like the user list. We can use this key to extract more // like the user list. We can use this key to extract more
@@ -132,6 +111,7 @@ export class ActionManager implements ActionsManagerInterface {
this.getElementsIncludingDeleted(), this.getElementsIncludingDeleted(),
this.getAppState(), this.getAppState(),
formState, formState,
this.app,
), ),
); );
}; };
+5 -3
View File
@@ -9,7 +9,7 @@ export type ShortcutName =
| "copyStyles" | "copyStyles"
| "pasteStyles" | "pasteStyles"
| "selectAll" | "selectAll"
| "delete" | "deleteSelectedElements"
| "duplicateSelection" | "duplicateSelection"
| "sendBackward" | "sendBackward"
| "bringForward" | "bringForward"
@@ -22,7 +22,8 @@ export type ShortcutName =
| "gridMode" | "gridMode"
| "zenMode" | "zenMode"
| "stats" | "stats"
| "addToLibrary"; | "addToLibrary"
| "viewMode";
const shortcutMap: Record<ShortcutName, string[]> = { const shortcutMap: Record<ShortcutName, string[]> = {
cut: [getShortcutKey("CtrlOrCmd+X")], cut: [getShortcutKey("CtrlOrCmd+X")],
@@ -31,7 +32,7 @@ const shortcutMap: Record<ShortcutName, string[]> = {
copyStyles: [getShortcutKey("CtrlOrCmd+Alt+C")], copyStyles: [getShortcutKey("CtrlOrCmd+Alt+C")],
pasteStyles: [getShortcutKey("CtrlOrCmd+Alt+V")], pasteStyles: [getShortcutKey("CtrlOrCmd+Alt+V")],
selectAll: [getShortcutKey("CtrlOrCmd+A")], selectAll: [getShortcutKey("CtrlOrCmd+A")],
delete: [getShortcutKey("Del")], deleteSelectedElements: [getShortcutKey("Del")],
duplicateSelection: [ duplicateSelection: [
getShortcutKey("CtrlOrCmd+D"), getShortcutKey("CtrlOrCmd+D"),
getShortcutKey(`Alt+${t("helpDialog.drag")}`), getShortcutKey(`Alt+${t("helpDialog.drag")}`),
@@ -56,6 +57,7 @@ const shortcutMap: Record<ShortcutName, string[]> = {
zenMode: [getShortcutKey("Alt+Z")], zenMode: [getShortcutKey("Alt+Z")],
stats: [], stats: [],
addToLibrary: [], addToLibrary: [],
viewMode: [getShortcutKey("Alt+R")],
}; };
export const getShortcutFromShortcutName = (name: ShortcutName) => { export const getShortcutFromShortcutName = (name: ShortcutName) => {
+13 -5
View File
@@ -16,12 +16,18 @@ type ActionFn = (
elements: readonly ExcalidrawElement[], elements: readonly ExcalidrawElement[],
appState: Readonly<AppState>, appState: Readonly<AppState>,
formData: any, formData: any,
app: { canvas: HTMLCanvasElement | null },
) => ActionResult | Promise<ActionResult>; ) => ActionResult | Promise<ActionResult>;
export type UpdaterFn = (res: ActionResult) => void; export type UpdaterFn = (res: ActionResult) => void;
export type ActionFilterFn = (action: Action) => void; export type ActionFilterFn = (action: Action) => void;
export type ActionName = export type ActionName =
| "copy"
| "cut"
| "paste"
| "copyAsPng"
| "copyAsSvg"
| "sendBackward" | "sendBackward"
| "bringForward" | "bringForward"
| "sendToBack" | "sendToBack"
@@ -29,6 +35,9 @@ export type ActionName =
| "copyStyles" | "copyStyles"
| "selectAll" | "selectAll"
| "pasteStyles" | "pasteStyles"
| "gridMode"
| "zenMode"
| "stats"
| "changeStrokeColor" | "changeStrokeColor"
| "changeBackgroundColor" | "changeBackgroundColor"
| "changeFillStyle" | "changeFillStyle"
@@ -75,7 +84,9 @@ export type ActionName =
| "alignVerticallyCentered" | "alignVerticallyCentered"
| "alignHorizontallyCentered" | "alignHorizontallyCentered"
| "distributeHorizontally" | "distributeHorizontally"
| "distributeVertically"; | "distributeVertically"
| "viewMode"
| "toggleMinimap";
export interface Action { export interface Action {
name: ActionName; name: ActionName;
@@ -93,19 +104,16 @@ export interface Action {
elements: readonly ExcalidrawElement[], elements: readonly ExcalidrawElement[],
) => boolean; ) => boolean;
contextItemLabel?: string; contextItemLabel?: string;
contextMenuOrder?: number;
contextItemPredicate?: ( contextItemPredicate?: (
elements: readonly ExcalidrawElement[], elements: readonly ExcalidrawElement[],
appState: AppState, appState: AppState,
) => boolean; ) => boolean;
checked?: (appState: Readonly<AppState>) => boolean;
} }
export interface ActionsManagerInterface { export interface ActionsManagerInterface {
actions: Record<ActionName, Action>; actions: Record<ActionName, Action>;
registerAction: (action: Action) => void; registerAction: (action: Action) => void;
handleKeyDown: (event: KeyboardEvent) => boolean; handleKeyDown: (event: KeyboardEvent) => boolean;
getContextMenuItems: (
actionFilter: ActionFilterFn,
) => { label: string; action: () => void }[];
renderAction: (name: ActionName) => React.ReactElement | null; renderAction: (name: ActionName) => React.ReactElement | null;
} }
+9 -5
View File
@@ -5,7 +5,7 @@ import {
DEFAULT_TEXT_ALIGN, DEFAULT_TEXT_ALIGN,
} from "./constants"; } from "./constants";
import { t } from "./i18n"; import { t } from "./i18n";
import { AppState, FlooredNumber, NormalizedZoomValue } from "./types"; import { AppState, NormalizedZoomValue } from "./types";
import { getDateTime } from "./utils"; import { getDateTime } from "./utils";
export const getDefaultAppState = (): Omit< export const getDefaultAppState = (): Omit<
@@ -42,7 +42,7 @@ export const getDefaultAppState = (): Omit<
exportEmbedScene: false, exportEmbedScene: false,
fileHandle: null, fileHandle: null,
gridSize: null, gridSize: null,
height: window.innerHeight, height: globalThis.innerHeight,
isBindingEnabled: true, isBindingEnabled: true,
isLibraryOpen: false, isLibraryOpen: false,
isLoading: false, isLoading: false,
@@ -56,8 +56,8 @@ export const getDefaultAppState = (): Omit<
previousSelectedElementIds: {}, previousSelectedElementIds: {},
resizingElement: null, resizingElement: null,
scrolledOutside: false, scrolledOutside: false,
scrollX: 0 as FlooredNumber, scrollX: 0,
scrollY: 0 as FlooredNumber, scrollY: 0,
selectedElementIds: {}, selectedElementIds: {},
selectedGroupIds: {}, selectedGroupIds: {},
selectionElement: null, selectionElement: null,
@@ -69,9 +69,11 @@ export const getDefaultAppState = (): Omit<
suggestedBindings: [], suggestedBindings: [],
toastMessage: null, toastMessage: null,
viewBackgroundColor: oc.white, viewBackgroundColor: oc.white,
width: window.innerWidth, width: globalThis.innerWidth,
zenModeEnabled: false, zenModeEnabled: false,
zoom: { value: 1 as NormalizedZoomValue, translation: { x: 0, y: 0 } }, zoom: { value: 1 as NormalizedZoomValue, translation: { x: 0, y: 0 } },
viewModeEnabled: false,
isMinimapEnabled: false,
}; };
}; };
@@ -151,6 +153,8 @@ const APP_STATE_STORAGE_CONF = (<
width: { browser: false, export: false }, width: { browser: false, export: false },
zenModeEnabled: { browser: true, export: false }, zenModeEnabled: { browser: true, export: false },
zoom: { browser: true, export: false }, zoom: { browser: true, export: false },
viewModeEnabled: { browser: false, export: false },
isMinimapEnabled: { browser: true, export: false },
}); });
const _clearAppStateForStorage = <ExportType extends "export" | "browser">( const _clearAppStateForStorage = <ExportType extends "export" | "browser">(
+8 -1
View File
@@ -1,6 +1,13 @@
import colors from "./colors"; import colors from "./colors";
import { AppState } from "./types";
export const getClientColors = (clientId: string) => { export const getClientColors = (clientId: string, appState: AppState) => {
if (appState?.collaborators) {
const currentUser = appState.collaborators.get(clientId);
if (currentUser?.color) {
return currentUser.color;
}
}
// Naive way of getting an integer out of the clientId // Naive way of getting an integer out of the clientId
const sum = clientId.split("").reduce((a, str) => a + str.charCodeAt(0), 0); const sum = clientId.split("").reduce((a, str) => a + str.charCodeAt(0), 0);
+337 -208
View File
@@ -2,8 +2,30 @@ import { Point, simplify } from "points-on-curve";
import React from "react"; import React from "react";
import { RoughCanvas } from "roughjs/bin/canvas"; import { RoughCanvas } from "roughjs/bin/canvas";
import rough from "roughjs/bin/rough"; import rough from "roughjs/bin/rough";
import "../actions"; import clsx from "clsx";
import { actionDeleteSelected, actionFinalize } from "../actions";
import {
actionAddToLibrary,
actionBringForward,
actionBringToFront,
actionCopy,
actionCopyAsPng,
actionCopyAsSvg,
actionCopyStyles,
actionCut,
actionDeleteSelected,
actionDuplicateSelection,
actionFinalize,
actionGroup,
actionPasteStyles,
actionSelectAll,
actionSendBackward,
actionSendToBack,
actionToggleGridMode,
actionToggleStats,
actionToggleZenMode,
actionUngroup,
} from "../actions";
import { createRedoAction, createUndoAction } from "../actions/actionHistory"; import { createRedoAction, createUndoAction } from "../actions/actionHistory";
import { ActionManager } from "../actions/manager"; import { ActionManager } from "../actions/manager";
import { actions } from "../actions/register"; import { actions } from "../actions/register";
@@ -18,7 +40,6 @@ import {
} from "../clipboard"; } from "../clipboard";
import { import {
APP_NAME, APP_NAME,
CANVAS_ONLY_ACTIONS,
CURSOR_TYPE, CURSOR_TYPE,
DEFAULT_VERTICAL_ALIGN, DEFAULT_VERTICAL_ALIGN,
DRAGGING_THRESHOLD, DRAGGING_THRESHOLD,
@@ -30,11 +51,13 @@ import {
LINE_CONFIRM_THRESHOLD, LINE_CONFIRM_THRESHOLD,
MIME_TYPES, MIME_TYPES,
POINTER_BUTTON, POINTER_BUTTON,
SCROLL_TIMEOUT,
TAP_TWICE_TIMEOUT, TAP_TWICE_TIMEOUT,
TEXT_TO_CENTER_SNAP_THRESHOLD, TEXT_TO_CENTER_SNAP_THRESHOLD,
TOUCH_CTX_MENU_TIMEOUT, TOUCH_CTX_MENU_TIMEOUT,
ZOOM_STEP,
} from "../constants"; } from "../constants";
import { exportCanvas, loadFromBlob } from "../data"; import { loadFromBlob } from "../data";
import { isValidLibrary } from "../data/json"; import { isValidLibrary } from "../data/json";
import { Library } from "../data/library"; import { Library } from "../data/library";
import { restore } from "../data/restore"; import { restore } from "../data/restore";
@@ -127,7 +150,6 @@ import {
getSelectedElements, getSelectedElements,
isOverScrollBars, isOverScrollBars,
isSomeElementSelected, isSomeElementSelected,
normalizeScroll,
} from "../scene"; } from "../scene";
import Scene from "../scene/Scene"; import Scene from "../scene/Scene";
import { SceneState, ScrollBars } from "../scene/types"; import { SceneState, ScrollBars } from "../scene/types";
@@ -155,10 +177,12 @@ import {
viewportCoordsToSceneCoords, viewportCoordsToSceneCoords,
withBatchedUpdates, withBatchedUpdates,
} from "../utils"; } from "../utils";
import ContextMenu from "./ContextMenu"; import { isMobile } from "../is-mobile";
import ContextMenu, { ContextMenuOption } from "./ContextMenu";
import LayerUI from "./LayerUI"; import LayerUI from "./LayerUI";
import { Stats } from "./Stats"; import { Stats } from "./Stats";
import { Toast } from "./Toast"; import { Toast } from "./Toast";
import { actionToggleViewMode } from "../actions/actionToggleViewMode";
const { history } = createHistory(); const { history } = createHistory();
@@ -248,6 +272,7 @@ export type ExcalidrawImperativeAPI = {
}; };
setScrollToCenter: InstanceType<typeof App>["setScrollToCenter"]; setScrollToCenter: InstanceType<typeof App>["setScrollToCenter"];
getSceneElements: InstanceType<typeof App>["getSceneElements"]; getSceneElements: InstanceType<typeof App>["getSceneElements"];
getAppState: () => InstanceType<typeof App>["state"];
readyPromise: ResolvablePromise<ExcalidrawImperativeAPI>; readyPromise: ResolvablePromise<ExcalidrawImperativeAPI>;
ready: true; ready: true;
}; };
@@ -274,6 +299,9 @@ class App extends React.Component<ExcalidrawProps, AppState> {
offsetLeft, offsetLeft,
offsetTop, offsetTop,
excalidrawRef, excalidrawRef,
viewModeEnabled = false,
zenModeEnabled = false,
gridModeEnabled = false,
} = props; } = props;
this.state = { this.state = {
...defaultAppState, ...defaultAppState,
@@ -281,6 +309,9 @@ class App extends React.Component<ExcalidrawProps, AppState> {
width, width,
height, height,
...this.getCanvasOffsets({ offsetLeft, offsetTop }), ...this.getCanvasOffsets({ offsetLeft, offsetTop }),
viewModeEnabled,
zenModeEnabled,
gridSize: gridModeEnabled ? GRID_SIZE : null,
}; };
if (excalidrawRef) { if (excalidrawRef) {
const readyPromise = const readyPromise =
@@ -298,6 +329,7 @@ class App extends React.Component<ExcalidrawProps, AppState> {
}, },
setScrollToCenter: this.setScrollToCenter, setScrollToCenter: this.setScrollToCenter,
getSceneElements: this.getSceneElements, getSceneElements: this.getSceneElements,
getAppState: () => this.state,
} as const; } as const;
if (typeof excalidrawRef === "function") { if (typeof excalidrawRef === "function") {
excalidrawRef(api); excalidrawRef(api);
@@ -312,6 +344,7 @@ class App extends React.Component<ExcalidrawProps, AppState> {
this.syncActionResult, this.syncActionResult,
() => this.state, () => this.state,
() => this.scene.getElementsIncludingDeleted(), () => this.scene.getElementsIncludingDeleted(),
this,
); );
this.actionManager.registerAll(actions); this.actionManager.registerAll(actions);
@@ -319,6 +352,62 @@ class App extends React.Component<ExcalidrawProps, AppState> {
this.actionManager.registerAction(createRedoAction(history)); this.actionManager.registerAction(createRedoAction(history));
} }
private renderCanvas() {
const canvasScale = window.devicePixelRatio;
const {
width: canvasDOMWidth,
height: canvasDOMHeight,
viewModeEnabled,
} = this.state;
const canvasWidth = canvasDOMWidth * canvasScale;
const canvasHeight = canvasDOMHeight * canvasScale;
if (viewModeEnabled) {
return (
<canvas
id="canvas"
style={{
width: canvasDOMWidth,
height: canvasDOMHeight,
cursor: "grabbing",
}}
width={canvasWidth}
height={canvasHeight}
ref={this.handleCanvasRef}
onContextMenu={this.handleCanvasContextMenu}
onPointerMove={this.handleCanvasPointerMove}
onPointerUp={this.removePointer}
onPointerCancel={this.removePointer}
onTouchMove={this.handleTouchMove}
onPointerDown={this.handleCanvasPointerDown}
>
{t("labels.drawingCanvas")}
</canvas>
);
}
return (
<canvas
id="canvas"
style={{
width: canvasDOMWidth,
height: canvasDOMHeight,
}}
width={canvasWidth}
height={canvasHeight}
ref={this.handleCanvasRef}
onContextMenu={this.handleCanvasContextMenu}
onPointerDown={this.handleCanvasPointerDown}
onDoubleClick={this.handleCanvasDoubleClick}
onPointerMove={this.handleCanvasPointerMove}
onPointerUp={this.removePointer}
onPointerCancel={this.removePointer}
onTouchMove={this.handleTouchMove}
onDrop={this.handleCanvasOnDrop}
>
{t("labels.drawingCanvas")}
</canvas>
);
}
public render() { public render() {
const { const {
zenModeEnabled, zenModeEnabled,
@@ -326,20 +415,19 @@ class App extends React.Component<ExcalidrawProps, AppState> {
height: canvasDOMHeight, height: canvasDOMHeight,
offsetTop, offsetTop,
offsetLeft, offsetLeft,
viewModeEnabled,
} = this.state; } = this.state;
const { onCollabButtonClick, onExportToBackend, renderFooter } = 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_X = canvasDOMWidth / 2;
const DEFAULT_PASTE_Y = canvasDOMHeight / 2; const DEFAULT_PASTE_Y = canvasDOMHeight / 2;
return ( return (
<div <div
className="excalidraw" className={clsx("excalidraw", {
"excalidraw--view-mode": viewModeEnabled,
})}
ref={this.excalidrawContainerRef} ref={this.excalidrawContainerRef}
style={{ style={{
width: canvasDOMWidth, width: canvasDOMWidth,
@@ -369,10 +457,16 @@ class App extends React.Component<ExcalidrawProps, AppState> {
isCollaborating={this.props.isCollaborating || false} isCollaborating={this.props.isCollaborating || false}
onExportToBackend={onExportToBackend} onExportToBackend={onExportToBackend}
renderCustomFooter={renderFooter} renderCustomFooter={renderFooter}
viewModeEnabled={viewModeEnabled}
showExitZenModeBtn={
typeof this.props?.zenModeEnabled === "undefined" && zenModeEnabled
}
/> />
<div className="excalidraw-textEditorContainer" />
{this.state.showStats && ( {this.state.showStats && (
<Stats <Stats
appState={this.state} appState={this.state}
setAppState={this.setAppState}
elements={this.scene.getElements()} elements={this.scene.getElements()}
onClose={this.toggleStats} onClose={this.toggleStats}
/> />
@@ -383,28 +477,7 @@ class App extends React.Component<ExcalidrawProps, AppState> {
clearToast={this.clearToast} clearToast={this.clearToast}
/> />
)} )}
<main> <main>{this.renderCanvas()}</main>
<canvas
id="canvas"
style={{
width: canvasDOMWidth,
height: canvasDOMHeight,
}}
width={canvasWidth}
height={canvasHeight}
ref={this.handleCanvasRef}
onContextMenu={this.handleCanvasContextMenu}
onPointerDown={this.handleCanvasPointerDown}
onDoubleClick={this.handleCanvasDoubleClick}
onPointerMove={this.handleCanvasPointerMove}
onPointerUp={this.removePointer}
onPointerCancel={this.removePointer}
onTouchMove={this.handleTouchMove}
onDrop={this.handleCanvasOnDrop}
>
{t("labels.drawingCanvas")}
</canvas>
</main>
</div> </div>
); );
} }
@@ -444,6 +517,23 @@ class App extends React.Component<ExcalidrawProps, AppState> {
if (actionResult.commitToHistory) { if (actionResult.commitToHistory) {
history.resumeRecording(); history.resumeRecording();
} }
let viewModeEnabled = actionResult?.appState?.viewModeEnabled || false;
let zenModeEnabled = actionResult?.appState?.zenModeEnabled || false;
let gridSize = actionResult?.appState?.gridSize || null;
if (typeof this.props.viewModeEnabled !== "undefined") {
viewModeEnabled = this.props.viewModeEnabled;
}
if (typeof this.props.zenModeEnabled !== "undefined") {
zenModeEnabled = this.props.zenModeEnabled;
}
if (typeof this.props.gridModeEnabled !== "undefined") {
gridSize = this.props.gridModeEnabled ? GRID_SIZE : null;
}
this.setState( this.setState(
(state) => ({ (state) => ({
...actionResult.appState, ...actionResult.appState,
@@ -453,6 +543,9 @@ class App extends React.Component<ExcalidrawProps, AppState> {
height: state.height, height: state.height,
offsetTop: state.offsetTop, offsetTop: state.offsetTop,
offsetLeft: state.offsetLeft, offsetLeft: state.offsetLeft,
viewModeEnabled,
zenModeEnabled,
gridSize,
}), }),
() => { () => {
if (actionResult.syncHistory) { if (actionResult.syncHistory) {
@@ -635,7 +728,6 @@ class App extends React.Component<ExcalidrawProps, AppState> {
} }
this.scene.addCallback(this.onSceneUpdated); this.scene.addCallback(this.onSceneUpdated);
this.addEventListeners(); this.addEventListeners();
// optim to avoid extra render on init // optim to avoid extra render on init
@@ -702,25 +794,16 @@ class App extends React.Component<ExcalidrawProps, AppState> {
} }
private addEventListeners() { private addEventListeners() {
this.removeEventListeners();
document.addEventListener(EVENT.COPY, this.onCopy); document.addEventListener(EVENT.COPY, this.onCopy);
document.addEventListener(EVENT.PASTE, this.pasteFromClipboard);
document.addEventListener(EVENT.CUT, this.onCut);
document.addEventListener(EVENT.KEYDOWN, this.onKeyDown, false); document.addEventListener(EVENT.KEYDOWN, this.onKeyDown, false);
document.addEventListener(EVENT.KEYUP, this.onKeyUp, { passive: true }); document.addEventListener(EVENT.KEYUP, this.onKeyUp, { passive: true });
document.addEventListener( document.addEventListener(
EVENT.MOUSE_MOVE, EVENT.MOUSE_MOVE,
this.updateCurrentCursorPosition, this.updateCurrentCursorPosition,
); );
window.addEventListener(EVENT.RESIZE, this.onResize, false);
window.addEventListener(EVENT.UNLOAD, this.onUnload, false);
window.addEventListener(EVENT.BLUR, this.onBlur, false);
window.addEventListener(EVENT.DRAG_OVER, this.disableEvent, false);
window.addEventListener(EVENT.DROP, this.disableEvent, false);
// rerender text elements on font load to fix #637 && #1553 // rerender text elements on font load to fix #637 && #1553
document.fonts?.addEventListener?.("loadingdone", this.onFontLoaded); document.fonts?.addEventListener?.("loadingdone", this.onFontLoaded);
// Safari-only desktop pinch zoom // Safari-only desktop pinch zoom
document.addEventListener( document.addEventListener(
EVENT.GESTURE_START, EVENT.GESTURE_START,
@@ -737,6 +820,19 @@ class App extends React.Component<ExcalidrawProps, AppState> {
this.onGestureEnd as any, this.onGestureEnd as any,
false, false,
); );
if (this.state.viewModeEnabled) {
return;
}
document.addEventListener(EVENT.PASTE, this.pasteFromClipboard);
document.addEventListener(EVENT.CUT, this.onCut);
document.addEventListener(EVENT.SCROLL, this.onScroll);
window.addEventListener(EVENT.RESIZE, this.onResize, false);
window.addEventListener(EVENT.UNLOAD, this.onUnload, false);
window.addEventListener(EVENT.BLUR, this.onBlur, false);
window.addEventListener(EVENT.DRAG_OVER, this.disableEvent, false);
window.addEventListener(EVENT.DROP, this.disableEvent, false);
} }
componentDidUpdate(prevProps: ExcalidrawProps, prevState: AppState) { componentDidUpdate(prevProps: ExcalidrawProps, prevState: AppState) {
@@ -759,6 +855,26 @@ class App extends React.Component<ExcalidrawProps, AppState> {
}); });
} }
if (prevProps.viewModeEnabled !== this.props.viewModeEnabled) {
this.setState(
{ viewModeEnabled: !!this.props.viewModeEnabled },
this.addEventListeners,
);
}
if (prevState.viewModeEnabled !== this.state.viewModeEnabled) {
this.addEventListeners();
}
if (prevProps.zenModeEnabled !== this.props.zenModeEnabled) {
this.setState({ zenModeEnabled: !!this.props.zenModeEnabled });
}
if (prevProps.gridModeEnabled !== this.props.gridModeEnabled) {
this.setState({
gridSize: this.props.gridModeEnabled ? GRID_SIZE : null,
});
}
document document
.querySelector(".excalidraw") .querySelector(".excalidraw")
?.classList.toggle("Appearance_dark", this.state.appearance === "dark"); ?.classList.toggle("Appearance_dark", this.state.appearance === "dark");
@@ -798,6 +914,7 @@ class App extends React.Component<ExcalidrawProps, AppState> {
const pointerViewportCoords: SceneState["remotePointerViewportCoords"] = {}; const pointerViewportCoords: SceneState["remotePointerViewportCoords"] = {};
const remoteSelectedElementIds: SceneState["remoteSelectedElementIds"] = {}; const remoteSelectedElementIds: SceneState["remoteSelectedElementIds"] = {};
const pointerUsernames: { [id: string]: string } = {}; const pointerUsernames: { [id: string]: string } = {};
const pointerUserStates: { [id: string]: string } = {};
this.state.collaborators.forEach((user, socketId) => { this.state.collaborators.forEach((user, socketId) => {
if (user.selectedElementIds) { if (user.selectedElementIds) {
for (const id of Object.keys(user.selectedElementIds)) { for (const id of Object.keys(user.selectedElementIds)) {
@@ -813,6 +930,9 @@ class App extends React.Component<ExcalidrawProps, AppState> {
if (user.username) { if (user.username) {
pointerUsernames[socketId] = user.username; pointerUsernames[socketId] = user.username;
} }
if (user.userState) {
pointerUserStates[socketId] = user.userState;
}
pointerViewportCoords[socketId] = sceneCoordsToViewportCoords( pointerViewportCoords[socketId] = sceneCoordsToViewportCoords(
{ {
sceneX: user.pointer.x, sceneX: user.pointer.x,
@@ -847,10 +967,12 @@ class App extends React.Component<ExcalidrawProps, AppState> {
remotePointerButton: cursorButton, remotePointerButton: cursorButton,
remoteSelectedElementIds, remoteSelectedElementIds,
remotePointerUsernames: pointerUsernames, remotePointerUsernames: pointerUsernames,
remotePointerUserStates: pointerUserStates,
shouldCacheIgnoreZoom: this.state.shouldCacheIgnoreZoom, shouldCacheIgnoreZoom: this.state.shouldCacheIgnoreZoom,
}, },
{ {
renderOptimizations: true, renderOptimizations: true,
renderScrollbars: !isMobile(),
}, },
); );
if (scrollBars) { if (scrollBars) {
@@ -879,6 +1001,10 @@ class App extends React.Component<ExcalidrawProps, AppState> {
} }
} }
private onScroll = debounce(() => {
this.setState({ ...this.getCanvasOffsets() });
}, SCROLL_TIMEOUT);
// Copy/paste // Copy/paste
private onCut = withBatchedUpdates((event: ClipboardEvent) => { private onCut = withBatchedUpdates((event: ClipboardEvent) => {
@@ -906,44 +1032,6 @@ class App extends React.Component<ExcalidrawProps, AppState> {
copyToClipboard(this.scene.getElements(), this.state); copyToClipboard(this.scene.getElements(), this.state);
}; };
private copyToClipboardAsPng = async () => {
const elements = this.scene.getElements();
const selectedElements = getSelectedElements(elements, this.state);
try {
await exportCanvas(
"clipboard",
selectedElements.length ? selectedElements : elements,
this.state,
this.canvas!,
this.state,
);
this.setState({ toastMessage: t("toast.copyToClipboardAsPng") });
} catch (error) {
console.error(error);
this.setState({ errorMessage: error.message });
}
};
private copyToClipboardAsSvg = async () => {
const selectedElements = getSelectedElements(
this.scene.getElements(),
this.state,
);
try {
await exportCanvas(
"clipboard-svg",
selectedElements.length ? selectedElements : this.scene.getElements(),
this.state,
this.canvas!,
this.state,
);
} catch (error) {
console.error(error);
this.setState({ errorMessage: error.message });
}
};
private static resetTapTwice() { private static resetTapTwice() {
didTapTwice = false; didTapTwice = false;
} }
@@ -1146,24 +1234,14 @@ class App extends React.Component<ExcalidrawProps, AppState> {
}; };
toggleZenMode = () => { toggleZenMode = () => {
this.setState({ this.actionManager.executeAction(actionToggleZenMode);
zenModeEnabled: !this.state.zenModeEnabled,
});
};
toggleGridMode = () => {
this.setState({
gridSize: this.state.gridSize ? null : GRID_SIZE,
});
}; };
toggleStats = () => { toggleStats = () => {
if (!this.state.showStats) { if (!this.state.showStats) {
trackEvent("dialog", "stats"); trackEvent("dialog", "stats");
} }
this.setState({ this.actionManager.executeAction(actionToggleStats);
showStats: !this.state.showStats,
});
}; };
setScrollToCenter = (remoteElements: readonly ExcalidrawElement[]) => { setScrollToCenter = (remoteElements: readonly ExcalidrawElement[]) => {
@@ -1253,27 +1331,18 @@ class App extends React.Component<ExcalidrawProps, AppState> {
}); });
} }
if (!event[KEYS.CTRL_OR_CMD] && event.altKey && event.code === CODES.Z) {
this.toggleZenMode();
}
if (event[KEYS.CTRL_OR_CMD] && event.code === CODES.QUOTE) {
this.toggleGridMode();
}
if (event[KEYS.CTRL_OR_CMD]) {
this.setState({ isBindingEnabled: false });
}
if (event.code === CODES.C && event.altKey && event.shiftKey) {
this.copyToClipboardAsPng();
event.preventDefault();
return;
}
if (this.actionManager.handleKeyDown(event)) { if (this.actionManager.handleKeyDown(event)) {
return; return;
} }
if (this.state.viewModeEnabled) {
return;
}
if (event[KEYS.CTRL_OR_CMD]) {
this.setState({ isBindingEnabled: false });
}
if (event.code === CODES.NINE) { if (event.code === CODES.NINE) {
this.setState({ isLibraryOpen: !this.state.isLibraryOpen }); this.setState({ isLibraryOpen: !this.state.isLibraryOpen });
} }
@@ -1488,6 +1557,7 @@ class App extends React.Component<ExcalidrawProps, AppState> {
textWysiwyg({ textWysiwyg({
id: element.id, id: element.id,
appState: this.state, appState: this.state,
canvas: this.canvas,
getViewportCoords: (x, y) => { getViewportCoords: (x, y) => {
const { x: viewportX, y: viewportY } = sceneCoordsToViewportCoords( const { x: viewportX, y: viewportY } = sceneCoordsToViewportCoords(
{ {
@@ -1778,8 +1848,8 @@ class App extends React.Component<ExcalidrawProps, AppState> {
const scaleFactor = distance / gesture.initialDistance; const scaleFactor = distance / gesture.initialDistance;
this.setState(({ zoom, scrollX, scrollY, offsetLeft, offsetTop }) => ({ this.setState(({ zoom, scrollX, scrollY, offsetLeft, offsetTop }) => ({
scrollX: normalizeScroll(scrollX + deltaX / zoom.value), scrollX: scrollX + deltaX / zoom.value,
scrollY: normalizeScroll(scrollY + deltaY / zoom.value), scrollY: scrollY + deltaY / zoom.value,
zoom: getNewZoom( zoom: getNewZoom(
getNormalizedZoom(initialScale * scaleFactor), getNormalizedZoom(initialScale * scaleFactor),
zoom, zoom,
@@ -1796,10 +1866,11 @@ class App extends React.Component<ExcalidrawProps, AppState> {
if (isHoldingSpace || isPanning || isDraggingScrollBar) { if (isHoldingSpace || isPanning || isDraggingScrollBar) {
return; return;
} }
const isPointerOverScrollBars = isOverScrollBars( const isPointerOverScrollBars = isOverScrollBars(
currentScrollBars, currentScrollBars,
event.clientX, event.clientX - this.state.offsetLeft,
event.clientY, event.clientY - this.state.offsetTop,
); );
const isOverScrollBar = isPointerOverScrollBars.isOverEither; const isOverScrollBar = isPointerOverScrollBars.isOverEither;
if (!this.state.draggingElement && !this.state.multiElement) { if (!this.state.draggingElement && !this.state.multiElement) {
@@ -1893,7 +1964,7 @@ class App extends React.Component<ExcalidrawProps, AppState> {
points: points.slice(0, -1), points: points.slice(0, -1),
}); });
} else { } else {
if (isPathALoop(points)) { if (isPathALoop(points, this.state.zoom.value)) {
document.documentElement.style.cursor = CURSOR_TYPE.POINTER; document.documentElement.style.cursor = CURSOR_TYPE.POINTER;
} }
// update last uncommitted point // update last uncommitted point
@@ -2080,14 +2151,16 @@ class App extends React.Component<ExcalidrawProps, AppState> {
lastPointerUp = onPointerUp; lastPointerUp = onPointerUp;
window.addEventListener(EVENT.POINTER_MOVE, onPointerMove); if (!this.state.viewModeEnabled) {
window.addEventListener(EVENT.POINTER_UP, onPointerUp); window.addEventListener(EVENT.POINTER_MOVE, onPointerMove);
window.addEventListener(EVENT.KEYDOWN, onKeyDown); window.addEventListener(EVENT.POINTER_UP, onPointerUp);
window.addEventListener(EVENT.KEYUP, onKeyUp); window.addEventListener(EVENT.KEYDOWN, onKeyDown);
pointerDownState.eventListeners.onMove = onPointerMove; window.addEventListener(EVENT.KEYUP, onKeyUp);
pointerDownState.eventListeners.onUp = onPointerUp; pointerDownState.eventListeners.onMove = onPointerMove;
pointerDownState.eventListeners.onKeyUp = onKeyUp; pointerDownState.eventListeners.onUp = onPointerUp;
pointerDownState.eventListeners.onKeyDown = onKeyDown; pointerDownState.eventListeners.onKeyUp = onKeyUp;
pointerDownState.eventListeners.onKeyDown = onKeyDown;
}
}; };
private maybeOpenContextMenuAfterPointerDownOnTouchDevices = ( private maybeOpenContextMenuAfterPointerDownOnTouchDevices = (
@@ -2137,7 +2210,8 @@ class App extends React.Component<ExcalidrawProps, AppState> {
!( !(
gesture.pointers.size === 0 && gesture.pointers.size === 0 &&
(event.button === POINTER_BUTTON.WHEEL || (event.button === POINTER_BUTTON.WHEEL ||
(event.button === POINTER_BUTTON.MAIN && isHoldingSpace)) (event.button === POINTER_BUTTON.MAIN && isHoldingSpace) ||
this.state.viewModeEnabled)
) )
) { ) {
return false; return false;
@@ -2190,12 +2264,8 @@ class App extends React.Component<ExcalidrawProps, AppState> {
} }
this.setState({ this.setState({
scrollX: normalizeScroll( scrollX: this.state.scrollX - deltaX / this.state.zoom.value,
this.state.scrollX - deltaX / this.state.zoom.value, scrollY: this.state.scrollY - deltaY / this.state.zoom.value,
),
scrollY: normalizeScroll(
this.state.scrollY - deltaY / this.state.zoom.value,
),
}); });
}); });
const teardown = withBatchedUpdates( const teardown = withBatchedUpdates(
@@ -2256,8 +2326,8 @@ class App extends React.Component<ExcalidrawProps, AppState> {
), ),
scrollbars: isOverScrollBars( scrollbars: isOverScrollBars(
currentScrollBars, currentScrollBars,
event.clientX, event.clientX - this.state.offsetLeft,
event.clientY, event.clientY - this.state.offsetTop,
), ),
// we need to duplicate because we'll be updating this state // we need to duplicate because we'll be updating this state
lastCoords: { ...origin }, lastCoords: { ...origin },
@@ -2566,7 +2636,10 @@ class App extends React.Component<ExcalidrawProps, AppState> {
const { multiElement } = this.state; const { multiElement } = this.state;
// finalize if completing a loop // finalize if completing a loop
if (multiElement.type === "line" && isPathALoop(multiElement.points)) { if (
multiElement.type === "line" &&
isPathALoop(multiElement.points, this.state.zoom.value)
) {
mutateElement(multiElement, { mutateElement(multiElement, {
lastCommittedPoint: lastCommittedPoint:
multiElement.points[multiElement.points.length - 1], multiElement.points[multiElement.points.length - 1],
@@ -3009,9 +3082,7 @@ class App extends React.Component<ExcalidrawProps, AppState> {
const x = event.clientX; const x = event.clientX;
const dx = x - pointerDownState.lastCoords.x; const dx = x - pointerDownState.lastCoords.x;
this.setState({ this.setState({
scrollX: normalizeScroll( scrollX: this.state.scrollX - dx / this.state.zoom.value,
this.state.scrollX - dx / this.state.zoom.value,
),
}); });
pointerDownState.lastCoords.x = x; pointerDownState.lastCoords.x = x;
return true; return true;
@@ -3021,9 +3092,7 @@ class App extends React.Component<ExcalidrawProps, AppState> {
const y = event.clientY; const y = event.clientY;
const dy = y - pointerDownState.lastCoords.y; const dy = y - pointerDownState.lastCoords.y;
this.setState({ this.setState({
scrollY: normalizeScroll( scrollY: this.state.scrollY - dy / this.state.zoom.value,
this.state.scrollY - dy / this.state.zoom.value,
),
}); });
pointerDownState.lastCoords.y = y; pointerDownState.lastCoords.y = y;
return true; return true;
@@ -3616,52 +3685,90 @@ class App extends React.Component<ExcalidrawProps, AppState> {
this.state, this.state,
); );
const maybeGroupAction = actionGroup.contextItemPredicate!(
this.actionManager.getElementsIncludingDeleted(),
this.actionManager.getAppState(),
);
const maybeUngroupAction = actionUngroup.contextItemPredicate!(
this.actionManager.getElementsIncludingDeleted(),
this.actionManager.getAppState(),
);
const separator = "separator";
const _isMobile = isMobile();
const elements = this.scene.getElements(); const elements = this.scene.getElements();
const element = this.getElementAtPosition(x, y); const element = this.getElementAtPosition(x, y);
const options: ContextMenuOption[] = [];
if (probablySupportsClipboardBlob && elements.length > 0) {
options.push(actionCopyAsPng);
}
if (probablySupportsClipboardWriteText && elements.length > 0) {
options.push(actionCopyAsSvg);
}
if (!element) { if (!element) {
const viewModeOptions = [
...options,
typeof this.props.gridModeEnabled === "undefined" &&
actionToggleGridMode,
typeof this.props.zenModeEnabled === "undefined" && actionToggleZenMode,
typeof this.props.viewModeEnabled === "undefined" &&
actionToggleViewMode,
actionToggleStats,
];
ContextMenu.push({
options: viewModeOptions,
top: clientY,
left: clientX,
actionManager: this.actionManager,
appState: this.state,
});
if (this.state.viewModeEnabled) {
return;
}
ContextMenu.push({ ContextMenu.push({
options: [ options: [
navigator.clipboard && { _isMobile &&
shortcutName: "paste", navigator.clipboard && {
label: t("labels.paste"), name: "paste",
action: () => this.pasteFromClipboard(null), perform: (elements, appStates) => {
}, this.pasteFromClipboard(null);
return {
commitToHistory: false,
};
},
contextItemLabel: "labels.paste",
},
_isMobile && navigator.clipboard && separator,
probablySupportsClipboardBlob && probablySupportsClipboardBlob &&
elements.length > 0 && { elements.length > 0 &&
shortcutName: "copyAsPng", actionCopyAsPng,
label: t("labels.copyAsPng"),
action: this.copyToClipboardAsPng,
},
probablySupportsClipboardWriteText && probablySupportsClipboardWriteText &&
elements.length > 0 && { elements.length > 0 &&
shortcutName: "copyAsSvg", actionCopyAsSvg,
label: t("labels.copyAsSvg"), ((probablySupportsClipboardBlob && elements.length > 0) ||
action: this.copyToClipboardAsSvg, (probablySupportsClipboardWriteText && elements.length > 0)) &&
}, separator,
...this.actionManager.getContextMenuItems((action) => actionSelectAll,
CANVAS_ONLY_ACTIONS.includes(action.name), separator,
), typeof this.props.gridModeEnabled === "undefined" &&
{ actionToggleGridMode,
checked: this.state.gridSize !== null, typeof this.props.zenModeEnabled === "undefined" &&
shortcutName: "gridMode", actionToggleZenMode,
label: t("labels.gridMode"), typeof this.props.viewModeEnabled === "undefined" &&
action: this.toggleGridMode, actionToggleViewMode,
}, actionToggleStats,
{
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,
},
], ],
top: clientY, top: clientY,
left: clientX, left: clientX,
actionManager: this.actionManager,
appState: this.state,
}); });
return; return;
} }
@@ -3670,39 +3777,55 @@ class App extends React.Component<ExcalidrawProps, AppState> {
this.setState({ selectedElementIds: { [element.id]: true } }); this.setState({ selectedElementIds: { [element.id]: true } });
} }
if (this.state.viewModeEnabled) {
ContextMenu.push({
options: [navigator.clipboard && actionCopy, ...options],
top: clientY,
left: clientX,
actionManager: this.actionManager,
appState: this.state,
});
return;
}
ContextMenu.push({ ContextMenu.push({
options: [ options: [
{ _isMobile && actionCut,
shortcutName: "cut", _isMobile && navigator.clipboard && actionCopy,
label: t("labels.cut"), _isMobile &&
action: this.cutAll, navigator.clipboard && {
}, name: "paste",
navigator.clipboard && { perform: (elements, appStates) => {
shortcutName: "copy", this.pasteFromClipboard(null);
label: t("labels.copy"), return {
action: this.copyAll, commitToHistory: false,
}, };
navigator.clipboard && { },
shortcutName: "paste", contextItemLabel: "labels.paste",
label: t("labels.paste"), },
action: () => this.pasteFromClipboard(null), _isMobile && separator,
}, ...options,
probablySupportsClipboardBlob && { separator,
shortcutName: "copyAsPng", actionCopyStyles,
label: t("labels.copyAsPng"), actionPasteStyles,
action: this.copyToClipboardAsPng, separator,
}, maybeGroupAction && actionGroup,
probablySupportsClipboardWriteText && { maybeUngroupAction && actionUngroup,
shortcutName: "copyAsSvg", (maybeGroupAction || maybeUngroupAction) && separator,
label: t("labels.copyAsSvg"), actionAddToLibrary,
action: this.copyToClipboardAsSvg, separator,
}, actionSendBackward,
...this.actionManager.getContextMenuItems( actionBringForward,
(action) => !CANVAS_ONLY_ACTIONS.includes(action.name), actionSendToBack,
), actionBringToFront,
separator,
actionDuplicateSelection,
actionDeleteSelected,
], ],
top: clientY, top: clientY,
left: clientX, left: clientX,
actionManager: this.actionManager,
appState: this.state,
}); });
}; };
@@ -3733,9 +3856,15 @@ class App extends React.Component<ExcalidrawProps, AppState> {
}, 1000); }, 1000);
} }
let newZoom = this.state.zoom.value - delta / 100;
// increase zoom steps the more zoomed-in we are (applies to >100% only)
newZoom += Math.log10(Math.max(1, this.state.zoom.value)) * -sign;
// round to nearest step
newZoom = Math.round(newZoom * ZOOM_STEP * 100) / (ZOOM_STEP * 100);
this.setState(({ zoom, offsetLeft, offsetTop }) => ({ this.setState(({ zoom, offsetLeft, offsetTop }) => ({
zoom: getNewZoom( zoom: getNewZoom(
getNormalizedZoom(zoom.value - delta / 100), getNormalizedZoom(newZoom),
zoom, zoom,
{ left: offsetLeft, top: offsetTop }, { left: offsetLeft, top: offsetTop },
{ {
@@ -3758,14 +3887,14 @@ class App extends React.Component<ExcalidrawProps, AppState> {
if (event.shiftKey) { if (event.shiftKey) {
this.setState(({ zoom, scrollX }) => ({ this.setState(({ zoom, scrollX }) => ({
// on Mac, shift+wheel tends to result in deltaX // on Mac, shift+wheel tends to result in deltaX
scrollX: normalizeScroll(scrollX - (deltaY || deltaX) / zoom.value), scrollX: scrollX - (deltaY || deltaX) / zoom.value,
})); }));
return; return;
} }
this.setState(({ zoom, scrollX, scrollY }) => ({ this.setState(({ zoom, scrollX, scrollY }) => ({
scrollX: normalizeScroll(scrollX - deltaX / zoom.value), scrollX: scrollX - deltaX / zoom.value,
scrollY: normalizeScroll(scrollY - deltaY / zoom.value), scrollY: scrollY - deltaY / zoom.value,
})); }));
}); });
+1 -1
View File
@@ -1,4 +1,4 @@
@import "../css/_variables"; @import "../css/variables.module";
.excalidraw { .excalidraw {
.Avatar { .Avatar {
+4 -3
View File
@@ -1,10 +1,11 @@
@import "../css/_variables"; @import "../css/variables.module";
.excalidraw { .excalidraw {
.CollabButton.is-collaborating { .CollabButton.is-collaborating {
background-color: var(--button-special-active-background-color); background-color: var(--button-special-active-bg-color);
.ToolIcon__icon svg { .ToolIcon__icon svg,
.ToolIcon__label {
color: var(--icon-green-fill-color); color: var(--icon-green-fill-color);
} }
} }
+2 -2
View File
@@ -25,8 +25,8 @@ const CollabButton = ({
onClick={onClick} onClick={onClick}
icon={users} icon={users}
type="button" type="button"
title={t("buttons.roomDialog")} title={t("labels.liveCollaboration")}
aria-label={t("buttons.roomDialog")} aria-label={t("labels.liveCollaboration")}
showAriaLabel={useIsMobile()} showAriaLabel={useIsMobile()}
> >
{collaboratorCount > 0 && ( {collaboratorCount > 0 && (
+25 -25
View File
@@ -1,10 +1,10 @@
@import "../css/_variables"; @import "../css/variables.module";
.excalidraw { .excalidraw {
.color-picker { .color-picker {
background: var(--popup-background-color); background: var(--popup-bg-color);
border: 0px solid transparentize($oc-white, 0.75); border: 0 solid transparentize($oc-white, 0.75);
box-shadow: transparentize($oc-black, 0.75) 0px 1px 4px; box-shadow: transparentize($oc-black, 0.75) 0 1px 4px;
border-radius: 4px; border-radius: 4px;
position: absolute; position: absolute;
@@ -24,11 +24,11 @@
} }
.color-picker-triangle { .color-picker-triangle {
width: 0px; width: 0;
height: 0px; height: 0;
border-style: solid; border-style: solid;
border-width: 0px 9px 10px; border-width: 0 9px 10px;
border-color: transparent transparent var(--popup-background-color); border-color: transparent transparent var(--popup-bg-color);
position: absolute; position: absolute;
top: -10px; top: -10px;
@@ -84,12 +84,12 @@
.color-picker-transparent { .color-picker-transparent {
border-radius: 4px; border-radius: 4px;
box-shadow: transparentize($oc-black, 0.9) 0px 0px 0px 1px inset; box-shadow: transparentize($oc-black, 0.9) 0 0 0 1px inset;
position: absolute; position: absolute;
top: 0px; top: 0;
right: 0px; right: 0;
bottom: 0px; bottom: 0;
left: 0px; left: 0;
} }
.color-picker-transparent, .color-picker-transparent,
@@ -104,11 +104,11 @@
width: 1.875rem; width: 1.875rem;
:root[dir="ltr"] & { :root[dir="ltr"] & {
border-radius: 4px 0px 0px 4px; border-radius: 4px 0 0 4px;
} }
:root[dir="rtl"] & { :root[dir="rtl"] & {
border-radius: 0px 4px 4px 0px; border-radius: 0 4px 4px 0;
} }
color: var(--input-label-color); color: var(--input-label-color);
@@ -144,7 +144,7 @@
} }
.color-input-container:focus-within .color-picker-hash::after { .color-input-container:focus-within .color-picker-hash::after {
background: var(--input-background-color); background: var(--input-bg-color);
:root[dir="ltr"] & { :root[dir="ltr"] & {
right: -2px; right: -2px;
@@ -163,19 +163,19 @@
width: 12ch; /* length of `transparent` + 1 */ width: 12ch; /* length of `transparent` + 1 */
margin: 0; margin: 0;
font-size: 1rem; font-size: 1rem;
background-color: var(--input-background-color); background-color: var(--input-bg-color);
color: var(--text-color-primary); color: var(--text-primary-color);
border: 0px; border: 0;
outline: none; outline: none;
height: 1.75em; height: 1.75em;
box-shadow: var(--input-border-color) 0px 0px 0px 1px inset; box-shadow: var(--input-border-color) 0 0 0 1px inset;
:root[dir="ltr"] & { :root[dir="ltr"] & {
border-radius: 0px 4px 4px 0px; border-radius: 0 4px 4px 0;
} }
:root[dir="rtl"] & { :root[dir="rtl"] & {
border-radius: 4px 0px 0px 4px; border-radius: 4px 0 0 4px;
} }
float: left; float: left;
@@ -228,7 +228,7 @@
} }
.color-picker-type-elementBackground .color-picker-keybinding { .color-picker-type-elementBackground .color-picker-keybinding {
color: #fff; color: $oc-white;
} }
.color-picker-swatch[aria-label="transparent"] .color-picker-keybinding { .color-picker-swatch[aria-label="transparent"] .color-picker-keybinding {
@@ -241,10 +241,10 @@
&.Appearance_dark { &.Appearance_dark {
.color-picker-type-elementBackground .color-picker-keybinding { .color-picker-type-elementBackground .color-picker-keybinding {
color: #000; color: $oc-black;
} }
.color-picker-swatch[aria-label="transparent"] .color-picker-keybinding { .color-picker-swatch[aria-label="transparent"] .color-picker-keybinding {
color: #000; color: $oc-black;
} }
} }
} }
+12 -6
View File
@@ -1,17 +1,18 @@
@import "../css/_variables"; @import "../css/variables.module";
.excalidraw { .excalidraw {
.context-menu { .context-menu {
position: relative; position: relative;
border-radius: 4px; border-radius: 4px;
box-shadow: 0px 3px 10px transparentize($oc-black, 0.8); box-shadow: 0 3px 10px transparentize($oc-black, 0.8);
padding: 0; padding: 0;
list-style: none; list-style: none;
user-select: none; user-select: none;
margin: -0.25rem 0 0 0.125rem; margin: -0.25rem 0 0 0.125rem;
padding: 0.25rem 0; padding: 0.5rem 0;
background-color: var(--popup-secondary-background-color); background-color: var(--popup-secondary-bg-color);
border: 1px solid var(--button-gray-3); border: 1px solid var(--button-gray-3);
cursor: default;
} }
.context-menu button { .context-menu button {
@@ -60,12 +61,12 @@
} }
.context-menu-option:hover { .context-menu-option:hover {
color: var(--popup-background-color); color: var(--popup-bg-color);
background-color: var(--select-highlight-color); background-color: var(--select-highlight-color);
&.dangerous { &.dangerous {
.context-menu-option__label { .context-menu-option__label {
color: var(--popup-background-color); color: var(--popup-bg-color);
} }
background-color: $oc-red-6; background-color: $oc-red-6;
} }
@@ -88,4 +89,9 @@
} }
} }
} }
.context-menu-option-separator {
border: none;
border-top: 1px solid $oc-gray-5;
}
} }
+50 -27
View File
@@ -2,28 +2,36 @@ import React from "react";
import { render, unmountComponentAtNode } from "react-dom"; import { render, unmountComponentAtNode } from "react-dom";
import clsx from "clsx"; import clsx from "clsx";
import { Popover } from "./Popover"; import { Popover } from "./Popover";
import { t } from "../i18n";
import "./ContextMenu.scss"; import "./ContextMenu.scss";
import { import {
getShortcutFromShortcutName, getShortcutFromShortcutName,
ShortcutName, ShortcutName,
} from "../actions/shortcuts"; } from "../actions/shortcuts";
import { Action } from "../actions/types";
import { ActionManager } from "../actions/manager";
import { AppState } from "../types";
type ContextMenuOption = { export type ContextMenuOption = "separator" | Action;
checked?: boolean;
shortcutName: ShortcutName;
label: string;
action(): void;
};
type Props = { type ContextMenuProps = {
options: ContextMenuOption[]; options: ContextMenuOption[];
onCloseRequest?(): void; onCloseRequest?(): void;
top: number; top: number;
left: number; left: number;
actionManager: ActionManager;
appState: Readonly<AppState>;
}; };
const ContextMenu = ({ options, onCloseRequest, top, left }: Props) => { const ContextMenu = ({
options,
onCloseRequest,
top,
left,
actionManager,
appState,
}: ContextMenuProps) => {
const isDarkTheme = !!document const isDarkTheme = !!document
.querySelector(".excalidraw") .querySelector(".excalidraw")
?.classList.contains("Appearance_dark"); ?.classList.contains("Appearance_dark");
@@ -43,23 +51,34 @@ const ContextMenu = ({ options, onCloseRequest, top, left }: Props) => {
className="context-menu" className="context-menu"
onContextMenu={(event) => event.preventDefault()} onContextMenu={(event) => event.preventDefault()}
> >
{options.map(({ action, checked, shortcutName, label }, idx) => ( {options.map((option, idx) => {
<li data-testid={shortcutName} key={idx} onClick={onCloseRequest}> if (option === "separator") {
<button return <hr key={idx} className="context-menu-option-separator" />;
className={`context-menu-option }
${shortcutName === "delete" ? "dangerous" : ""}
${checked ? "checkmark" : ""}`} const actionName = option.name;
onClick={action} const label = option.contextItemLabel
> ? t(option.contextItemLabel)
<div className="context-menu-option__label">{label}</div> : "";
<kbd className="context-menu-option__shortcut"> return (
{shortcutName <li key={idx} data-testid={actionName} onClick={onCloseRequest}>
? getShortcutFromShortcutName(shortcutName) <button
: ""} className={clsx("context-menu-option", {
</kbd> dangerous: actionName === "deleteSelectedElements",
</button> checkmark: option.checked?.(appState),
</li> })}
))} onClick={() => actionManager.executeAction(option)}
>
<div className="context-menu-option__label">{label}</div>
<kbd className="context-menu-option__shortcut">
{actionName
? getShortcutFromShortcutName(actionName as ShortcutName)
: ""}
</kbd>
</button>
</li>
);
})}
</ul> </ul>
</Popover> </Popover>
</div> </div>
@@ -78,8 +97,10 @@ const getContextMenuNode = (): HTMLDivElement => {
type ContextMenuParams = { type ContextMenuParams = {
options: (ContextMenuOption | false | null | undefined)[]; options: (ContextMenuOption | false | null | undefined)[];
top: number; top: ContextMenuProps["top"];
left: number; left: ContextMenuProps["left"];
actionManager: ContextMenuProps["actionManager"];
appState: Readonly<AppState>;
}; };
const handleClose = () => { const handleClose = () => {
@@ -101,6 +122,8 @@ export default {
left={params.left} left={params.left}
options={options} options={options}
onCloseRequest={handleClose} onCloseRequest={handleClose}
actionManager={params.actionManager}
appState={params.appState}
/>, />,
getContextMenuNode(), getContextMenuNode(),
); );
+2 -2
View File
@@ -1,4 +1,4 @@
@import "../css/_variables"; @import "../css/variables.module";
.excalidraw { .excalidraw {
.Dialog { .Dialog {
@@ -45,7 +45,7 @@
position: sticky; position: sticky;
top: 0; top: 0;
padding: calc(var(--space-factor) * 2); padding: calc(var(--space-factor) * 2);
background: var(--bg-color-island); background: var(--island-bg-color);
font-size: 1.25em; font-size: 1.25em;
box-sizing: border-box; box-sizing: border-box;
+3 -10
View File
@@ -1,5 +1,6 @@
import clsx from "clsx"; import clsx from "clsx";
import React, { useCallback, useEffect, useState } from "react"; import React, { useEffect } from "react";
import { useCallbackRefState } from "../hooks/useCallbackRefState";
import { t } from "../i18n"; import { t } from "../i18n";
import useIsMobile from "../is-mobile"; import useIsMobile from "../is-mobile";
import { KEYS } from "../keys"; import { KEYS } from "../keys";
@@ -8,14 +9,6 @@ import { back, close } from "./icons";
import { Island } from "./Island"; import { Island } from "./Island";
import { Modal } from "./Modal"; import { Modal } from "./Modal";
const useRefState = <T,>() => {
const [refValue, setRefValue] = useState<T | null>(null);
const refCallback = useCallback((value: T) => {
setRefValue(value);
}, []);
return [refValue, refCallback] as const;
};
export const Dialog = (props: { export const Dialog = (props: {
children: React.ReactNode; children: React.ReactNode;
className?: string; className?: string;
@@ -24,7 +17,7 @@ export const Dialog = (props: {
title: React.ReactNode; title: React.ReactNode;
autofocus?: boolean; autofocus?: boolean;
}) => { }) => {
const [islandNode, setIslandNode] = useRefState<HTMLDivElement>(); const [islandNode, setIslandNode] = useCallbackRefState<HTMLDivElement>();
useEffect(() => { useEffect(() => {
if (!islandNode) { if (!islandNode) {
+1 -1
View File
@@ -1,4 +1,4 @@
@import "../css/_variables"; @import "../css/variables.module";
.excalidraw { .excalidraw {
.ExportDialog__preview { .ExportDialog__preview {
+4
View File
@@ -101,6 +101,10 @@ const ExportModal = ({
shouldAddWatermark, shouldAddWatermark,
}); });
if (canvas instanceof OffscreenCanvas) {
return;
}
// if converting to blob fails, there's some problem that will // if converting to blob fails, there's some problem that will
// likely prevent preview and export (e.g. canvas too big) // likely prevent preview and export (e.g. canvas too big)
canvasToBlob(canvas) canvasToBlob(canvas)
+1 -1
View File
@@ -1,4 +1,4 @@
@import "../css/_variables"; @import "../css/variables.module";
.excalidraw { .excalidraw {
.HelpDialog h3 { .HelpDialog h3 {
+14 -3
View File
@@ -1,6 +1,6 @@
import React from "react"; import React from "react";
import { t } from "../i18n"; import { t } from "../i18n";
import { isDarwin } from "../keys"; import { isDarwin, isWindows } from "../keys";
import { Dialog } from "./Dialog"; import { Dialog } from "./Dialog";
import { getShortcutKey } from "../utils"; import { getShortcutKey } from "../utils";
import "./HelpDialog.scss"; import "./HelpDialog.scss";
@@ -224,9 +224,13 @@ export const HelpDialog = ({ onClose }: { onClose?: () => void }) => {
shortcuts={[getShortcutKey("Alt+Z")]} shortcuts={[getShortcutKey("Alt+Z")]}
/> />
<Shortcut <Shortcut
label={t("labels.gridMode")} label={t("labels.showGrid")}
shortcuts={[getShortcutKey("CtrlOrCmd+'")]} shortcuts={[getShortcutKey("CtrlOrCmd+'")]}
/> />
<Shortcut
label={t("labels.viewMode")}
shortcuts={[getShortcutKey("Alt+R")]}
/>
</ShortcutIsland> </ShortcutIsland>
</Column> </Column>
<Column> <Column>
@@ -328,7 +332,14 @@ export const HelpDialog = ({ onClose }: { onClose?: () => void }) => {
/> />
<Shortcut <Shortcut
label={t("buttons.redo")} label={t("buttons.redo")}
shortcuts={[getShortcutKey("CtrlOrCmd+Shift+Z")]} shortcuts={
isWindows
? [
getShortcutKey("CtrlOrCmd+Y"),
getShortcutKey("CtrlOrCmd+Shift+Z"),
]
: [getShortcutKey("CtrlOrCmd+Shift+Z")]
}
/> />
<Shortcut <Shortcut
label={t("labels.group")} label={t("labels.group")}
+2 -2
View File
@@ -1,4 +1,4 @@
@import "../css/_variables"; @import "../css/variables.module";
// this is loosely based on the longest hint text // this is loosely based on the longest hint text
$wide-viewport-width: 1000px; $wide-viewport-width: 1000px;
@@ -26,7 +26,7 @@ $wide-viewport-width: 1000px;
> span { > span {
padding: 0.2rem 0.4rem; padding: 0.2rem 0.4rem;
background-color: var(--overlay-background-color); background-color: var(--overlay-bg-color);
border-radius: 4px; border-radius: 4px;
} }
} }
+12 -12
View File
@@ -1,4 +1,4 @@
@import "../css/_variables"; @import "../css/variables.module";
.excalidraw { .excalidraw {
.picker-container { .picker-container {
@@ -8,9 +8,9 @@
} }
.picker { .picker {
background: var(--popup-background-color); background: var(--popup-bg-color);
border: 0px solid transparentize($oc-white, 0.75); border: 0 solid transparentize($oc-white, 0.75);
box-shadow: transparentize($oc-black, 0.75) 0px 1px 4px; box-shadow: transparentize($oc-black, 0.75) 0 1px 4px;
border-radius: 4px; border-radius: 4px;
position: absolute; position: absolute;
} }
@@ -56,8 +56,8 @@
} }
.picker-triangle { .picker-triangle {
width: 0px; width: 0;
height: 0px; height: 0;
position: relative; position: relative;
top: -10px; top: -10px;
:root[dir="ltr"] & { :root[dir="ltr"] & {
@@ -73,7 +73,7 @@
content: ""; content: "";
position: absolute; position: absolute;
border-style: solid; border-style: solid;
border-width: 0px 9px 10px; border-width: 0 9px 10px;
border-color: transparent transparent transparentize($oc-black, 0.9); border-color: transparent transparent transparentize($oc-black, 0.9);
top: -1px; top: -1px;
} }
@@ -82,8 +82,8 @@
content: ""; content: "";
position: absolute; position: absolute;
border-style: solid; border-style: solid;
border-width: 0px 9px 10px; border-width: 0 9px 10px;
border-color: transparent transparent var(--popup-background-color); border-color: transparent transparent var(--popup-bg-color);
} }
} }
@@ -121,7 +121,7 @@
} }
.picker-type-elementBackground .picker-keybinding { .picker-type-elementBackground .picker-keybinding {
color: #fff; color: $oc-white;
} }
.picker-swatch[aria-label="transparent"] .picker-keybinding { .picker-swatch[aria-label="transparent"] .picker-keybinding {
@@ -134,10 +134,10 @@
&.Appearance_dark { &.Appearance_dark {
.picker-type-elementBackground .picker-keybinding { .picker-type-elementBackground .picker-keybinding {
color: #000; color: $oc-black;
} }
.picker-swatch[aria-label="transparent"] .picker-keybinding { .picker-swatch[aria-label="transparent"] .picker-keybinding {
color: #000; color: $oc-black;
} }
} }
} }
+2 -7
View File
@@ -1,12 +1,7 @@
import React from "react"; import React from "react";
import { LoadingMessage } from "./LoadingMessage"; import { LoadingMessage } from "./LoadingMessage";
import { import { defaultLang, Language, languages, setLanguage } from "../i18n";
defaultLang,
Language,
languages,
setLanguageFirstTime,
} from "../i18n";
interface Props { interface Props {
langCode: Language["code"]; langCode: Language["code"];
@@ -23,7 +18,7 @@ export class InitializeApp extends React.Component<Props, State> {
const currentLang = const currentLang =
languages.find((lang) => lang.code === this.props.langCode) || languages.find((lang) => lang.code === this.props.langCode) ||
defaultLang; defaultLang;
await setLanguageFirstTime(currentLang); await setLanguage(currentLang);
this.setState({ this.setState({
isLoading: false, isLoading: false,
}); });
+1 -1
View File
@@ -1,7 +1,7 @@
.excalidraw { .excalidraw {
.Island { .Island {
--padding: 0; --padding: 0;
background-color: var(--bg-color-island); background-color: var(--island-bg-color);
backdrop-filter: saturate(100%) blur(10px); backdrop-filter: saturate(100%) blur(10px);
box-shadow: var(--shadow-island); box-shadow: var(--shadow-island);
border-radius: 4px; border-radius: 4px;
+6 -4
View File
@@ -19,9 +19,9 @@
} }
a { a {
margin-left: auto; margin-inline-start: auto;
// 17px for scrollbar (needed for overlay scrollbars on Big Sur?) + 1px extra // 17px for scrollbar (needed for overlay scrollbars on Big Sur?) + 1px extra
padding-right: 18px; padding-inline-end: 18px;
white-space: nowrap; white-space: nowrap;
} }
} }
@@ -38,6 +38,8 @@
} }
.layer-ui__wrapper { .layer-ui__wrapper {
z-index: var(--zIndex-layerUI);
.encrypted-icon { .encrypted-icon {
position: relative; position: relative;
margin-inline-start: 15px; margin-inline-start: 15px;
@@ -71,7 +73,7 @@
&__footer { &__footer {
position: absolute; position: absolute;
z-index: 100; z-index: 100;
bottom: 0px; bottom: 0;
:root[dir="ltr"] & { :root[dir="ltr"] & {
right: 0; right: 0;
@@ -92,7 +94,7 @@
} }
:root[dir="ltr"] &.transition-right { :root[dir="ltr"] &.transition-right {
transform: translate(999px, 0px); transform: translate(999px, 0);
} }
:root[dir="rtl"] &.transition-left { :root[dir="rtl"] &.transition-left {
+137 -70
View File
@@ -11,7 +11,7 @@ import { CLASSES } from "../constants";
import { exportCanvas } from "../data"; import { exportCanvas } from "../data";
import { importLibraryFromJSON, saveLibraryAsJSON } from "../data/json"; import { importLibraryFromJSON, saveLibraryAsJSON } from "../data/json";
import { Library } from "../data/library"; import { Library } from "../data/library";
import { showSelectedShapeActions } from "../element"; import { isTextElement, showSelectedShapeActions } from "../element";
import { NonDeletedExcalidrawElement } from "../element/types"; import { NonDeletedExcalidrawElement } from "../element/types";
import { Language, t } from "../i18n"; import { Language, t } from "../i18n";
import useIsMobile from "../is-mobile"; import useIsMobile from "../is-mobile";
@@ -27,7 +27,7 @@ import { ExportCB, ExportDialog } from "./ExportDialog";
import { FixedSideContainer } from "./FixedSideContainer"; import { FixedSideContainer } from "./FixedSideContainer";
import { GitHubCorner } from "./GitHubCorner"; import { GitHubCorner } from "./GitHubCorner";
import { HintViewer } from "./HintViewer"; import { HintViewer } from "./HintViewer";
import { exportFile, load, shield } from "./icons"; import { exportFile, load, shield, trash } from "./icons";
import { Island } from "./Island"; import { Island } from "./Island";
import "./LayerUI.scss"; import "./LayerUI.scss";
import { LibraryUnit } from "./LibraryUnit"; import { LibraryUnit } from "./LibraryUnit";
@@ -52,6 +52,7 @@ interface LayerUIProps {
onLockToggle: () => void; onLockToggle: () => void;
onInsertElements: (elements: readonly NonDeletedExcalidrawElement[]) => void; onInsertElements: (elements: readonly NonDeletedExcalidrawElement[]) => void;
zenModeEnabled: boolean; zenModeEnabled: boolean;
showExitZenModeBtn: boolean;
toggleZenMode: () => void; toggleZenMode: () => void;
langCode: Language["code"]; langCode: Language["code"];
isCollaborating: boolean; isCollaborating: boolean;
@@ -61,6 +62,7 @@ interface LayerUIProps {
canvas: HTMLCanvasElement | null, canvas: HTMLCanvasElement | null,
) => void; ) => void;
renderCustomFooter?: (isMobile: boolean) => JSX.Element; renderCustomFooter?: (isMobile: boolean) => JSX.Element;
viewModeEnabled: boolean;
} }
const useOnClickOutside = ( const useOnClickOutside = (
@@ -98,6 +100,7 @@ const LibraryMenuItems = ({
onInsertShape, onInsertShape,
pendingElements, pendingElements,
setAppState, setAppState,
setLibraryItems,
}: { }: {
library: LibraryItems; library: LibraryItems;
pendingElements: LibraryItem; pendingElements: LibraryItem;
@@ -105,6 +108,7 @@ const LibraryMenuItems = ({
onInsertShape: (elements: LibraryItem) => void; onInsertShape: (elements: LibraryItem) => void;
onAddToLibrary: (elements: LibraryItem) => void; onAddToLibrary: (elements: LibraryItem) => void;
setAppState: React.Component<any, AppState>["setState"]; setAppState: React.Component<any, AppState>["setState"];
setLibraryItems: (library: LibraryItems) => void;
}) => { }) => {
const isMobile = useIsMobile(); const isMobile = useIsMobile();
const numCells = library.length + (pendingElements.length > 0 ? 1 : 0); const numCells = library.length + (pendingElements.length > 0 ? 1 : 0);
@@ -148,6 +152,19 @@ const LibraryMenuItems = ({
}); });
}} }}
/> />
<ToolButton
key="reset"
type="button"
title={t("buttons.resetLibrary")}
aria-label={t("buttons.resetLibrary")}
icon={trash}
onClick={() => {
if (window.confirm(t("alerts.resetLibrary"))) {
Library.resetLibrary();
setLibraryItems([]);
}
}}
/>
<a href="https://libraries.excalidraw.com" target="_excalidraw_libraries"> <a href="https://libraries.excalidraw.com" target="_excalidraw_libraries">
{t("labels.libraries")} {t("labels.libraries")}
@@ -279,6 +296,7 @@ const LibraryMenu = ({
onInsertShape={onInsertShape} onInsertShape={onInsertShape}
pendingElements={pendingElements} pendingElements={pendingElements}
setAppState={setAppState} setAppState={setAppState}
setLibraryItems={setLibraryItems}
/> />
)} )}
</Island> </Island>
@@ -295,10 +313,12 @@ const LayerUI = ({
onLockToggle, onLockToggle,
onInsertElements, onInsertElements,
zenModeEnabled, zenModeEnabled,
showExitZenModeBtn,
toggleZenMode, toggleZenMode,
isCollaborating, isCollaborating,
onExportToBackend, onExportToBackend,
renderCustomFooter, renderCustomFooter,
viewModeEnabled,
}: LayerUIProps) => { }: LayerUIProps) => {
const isMobile = useIsMobile(); const isMobile = useIsMobile();
@@ -358,6 +378,28 @@ const LayerUI = ({
); );
}; };
const renderViewModeCanvasActions = () => {
return (
<Section
heading="canvasActions"
className={clsx("zen-mode-transition", {
"transition-left": zenModeEnabled,
})}
>
{/* the zIndex ensures this menu has higher stacking order,
see https://github.com/excalidraw/excalidraw/pull/1445 */}
<Island padding={2} style={{ zIndex: 1 }}>
<Stack.Col gap={4}>
<Stack.Row gap={1} justifyContent="space-between">
{actionManager.renderAction("saveScene")}
{actionManager.renderAction("saveAsScene")}
{renderExportDialog()}
</Stack.Row>
</Stack.Col>
</Island>
</Section>
);
};
const renderCanvasActions = () => ( const renderCanvasActions = () => (
<Section <Section
heading="canvasActions" heading="canvasActions"
@@ -400,7 +442,15 @@ const LayerUI = ({
"transition-left": zenModeEnabled, "transition-left": zenModeEnabled,
})} })}
> >
<Island className={CLASSES.SHAPE_ACTIONS_MENU} padding={2}> <Island
className={CLASSES.SHAPE_ACTIONS_MENU}
padding={2}
style={{
// we want to make sure this doesn't overflow so substracting 200
// which is approximately height of zoom footer and top left menu items with some buffer
maxHeight: `${appState.height - 200}px`,
}}
>
<SelectedShapeActions <SelectedShapeActions
appState={appState} appState={appState}
elements={elements} elements={elements}
@@ -448,54 +498,59 @@ const LayerUI = ({
gap={4} gap={4}
className={clsx({ "disable-pointerEvents": zenModeEnabled })} className={clsx({ "disable-pointerEvents": zenModeEnabled })}
> >
{renderCanvasActions()} {viewModeEnabled
? renderViewModeCanvasActions()
: renderCanvasActions()}
{shouldRenderSelectedShapeActions && renderSelectedShapeActions()} {shouldRenderSelectedShapeActions && renderSelectedShapeActions()}
</Stack.Col> </Stack.Col>
<Section heading="shapes"> {!viewModeEnabled && (
{(heading) => ( <Section heading="shapes">
<Stack.Col gap={4} align="start"> {(heading) => (
<Stack.Row gap={1}> <Stack.Col gap={4} align="start">
<Island <Stack.Row gap={1}>
padding={1} <Island
className={clsx({ "zen-mode": zenModeEnabled })} padding={1}
> className={clsx({ "zen-mode": zenModeEnabled })}
<HintViewer appState={appState} elements={elements} /> >
{heading} <HintViewer appState={appState} elements={elements} />
<Stack.Row gap={1}> {heading}
<ShapesSwitcher <Stack.Row gap={1}>
elementType={appState.elementType} <ShapesSwitcher
setAppState={setAppState} elementType={appState.elementType}
isLibraryOpen={appState.isLibraryOpen} setAppState={setAppState}
/> isLibraryOpen={appState.isLibraryOpen}
</Stack.Row> />
</Island> </Stack.Row>
<LockIcon </Island>
zenModeEnabled={zenModeEnabled} <LockIcon
checked={appState.elementLocked} zenModeEnabled={zenModeEnabled}
onChange={onLockToggle} checked={appState.elementLocked}
title={t("toolBar.lock")} onChange={onLockToggle}
/> title={t("toolBar.lock")}
</Stack.Row> />
{libraryMenu} </Stack.Row>
</Stack.Col> {libraryMenu}
)} </Stack.Col>
</Section> )}
</Section>
)}
<UserList <UserList
className={clsx("zen-mode-transition", { className={clsx("zen-mode-transition", {
"transition-right": zenModeEnabled, "transition-right": zenModeEnabled,
})} })}
> >
{Array.from(appState.collaborators) {appState.collaborators.size > 0 &&
// Collaborator is either not initialized or is actually the current user. Array.from(appState.collaborators)
.filter(([_, client]) => Object.keys(client).length !== 0) // Collaborator is either not initialized or is actually the current user.
.map(([clientId, client]) => ( .filter(([_, client]) => Object.keys(client).length !== 0)
<Tooltip .map(([clientId, client]) => (
label={client.username || "Unknown user"} <Tooltip
key={clientId} label={client.username || "Unknown user"}
> key={clientId}
{actionManager.renderAction("goToCollaborator", clientId)} >
</Tooltip> {actionManager.renderAction("goToCollaborator", clientId)}
))} </Tooltip>
))}
</UserList> </UserList>
</div> </div>
</FixedSideContainer> </FixedSideContainer>
@@ -524,6 +579,20 @@ const LayerUI = ({
); );
}; };
const renderGitHubCorner = () => {
return (
<aside
className={clsx(
"layer-ui__wrapper__github-corner zen-mode-transition",
{
"transition-right": zenModeEnabled,
},
)}
>
<GitHubCorner appearance={appState.appearance} />
</aside>
);
};
const renderFooter = () => ( const renderFooter = () => (
<footer role="contentinfo" className="layer-ui__wrapper__footer"> <footer role="contentinfo" className="layer-ui__wrapper__footer">
<div <div
@@ -533,27 +602,16 @@ const LayerUI = ({
> >
{renderCustomFooter?.(false)} {renderCustomFooter?.(false)}
{actionManager.renderAction("toggleShortcuts")} {actionManager.renderAction("toggleShortcuts")}
{actionManager.renderAction("toggleMinimap")}
</div> </div>
<button <button
className={clsx("disable-zen-mode", { className={clsx("disable-zen-mode", {
"disable-zen-mode--visible": zenModeEnabled, "disable-zen-mode--visible": showExitZenModeBtn,
})} })}
onClick={toggleZenMode} onClick={toggleZenMode}
> >
{t("buttons.exitZenMode")} {t("buttons.exitZenMode")}
</button> </button>
{appState.scrolledOutside && (
<button
className="scroll-back-to-content"
onClick={() => {
setAppState({
...calculateScrollCenter(elements, appState, canvas),
});
}}
>
{t("buttons.scrollBackToContent")}
</button>
)}
</footer> </footer>
); );
@@ -599,26 +657,35 @@ const LayerUI = ({
canvas={canvas} canvas={canvas}
isCollaborating={isCollaborating} isCollaborating={isCollaborating}
renderCustomFooter={renderCustomFooter} renderCustomFooter={renderCustomFooter}
viewModeEnabled={viewModeEnabled}
/> />
</> </>
) : ( ) : (
<div className="layer-ui__wrapper"> <div
className={clsx("layer-ui__wrapper", {
"disable-pointerEvents":
appState.draggingElement ||
appState.resizingElement ||
(appState.editingElement && !isTextElement(appState.editingElement)),
})}
>
{dialogs} {dialogs}
{renderFixedSideContainer()} {renderFixedSideContainer()}
{renderBottomAppMenu()} {renderBottomAppMenu()}
{ {renderGitHubCorner()}
<aside
className={clsx(
"layer-ui__wrapper__github-corner zen-mode-transition",
{
"transition-right": zenModeEnabled,
},
)}
>
<GitHubCorner appearance={appState.appearance} />
</aside>
}
{renderFooter()} {renderFooter()}
{appState.scrolledOutside && (
<button
className="scroll-back-to-content"
onClick={() => {
setAppState({
...calculateScrollCenter(elements, appState, canvas),
});
}}
>
{t("buttons.scrollBackToContent")}
</button>
)}
</div> </div>
); );
}; };
+5
View File
@@ -0,0 +1,5 @@
.Island.MiniMap {
position: absolute;
bottom: 50px;
right: calc(var(--space-factor) * 4);
}
+151
View File
@@ -0,0 +1,151 @@
import "./MiniMap.scss";
import React, { useEffect, useRef, useMemo, useState } from "react";
import { getCommonBounds, getNonDeletedElements } from "../element";
import { ExcalidrawElement } from "../element/types";
import { AppState } from "../types";
import { distance, viewportCoordsToSceneCoords } from "../utils";
import { Island } from "./Island";
// eslint-disable-next-line import/no-webpack-loader-syntax
import MinimapWorker from "worker-loader!../renderer/minimapWorker";
const RATIO = 1.2;
const MINIMAP_HEIGHT = 150;
const MINIMAP_WIDTH = MINIMAP_HEIGHT * RATIO;
const MinimapViewport = ({
elements,
appState,
}: {
elements: readonly ExcalidrawElement[];
appState: AppState;
}) => {
const [minX, minY, maxX, maxY] = useMemo(
() => getCommonBounds(getNonDeletedElements(elements)),
[elements],
);
const minimapScale = Math.min(
MINIMAP_WIDTH / distance(minX, maxX),
MINIMAP_HEIGHT / distance(minY, maxY),
);
const leftTop = viewportCoordsToSceneCoords(
{ clientX: 0, clientY: 0 },
appState,
);
const rightBot = viewportCoordsToSceneCoords(
{ clientX: appState.width, clientY: appState.height },
appState,
);
const top = (leftTop.y - minY) * minimapScale;
const left = (leftTop.x - minX) * minimapScale;
const width = (rightBot.x - leftTop.x) * minimapScale;
const height = (rightBot.y - leftTop.y) * minimapScale;
// Set viewport boundaries
const viewportTop = Math.min(Math.max(0, top), MINIMAP_HEIGHT);
const viewportLeft = Math.min(Math.max(0, left), MINIMAP_WIDTH);
const viewportWidth = Math.min(
MINIMAP_WIDTH - viewportLeft,
width,
width + left,
);
const viewportHeight = Math.min(
MINIMAP_HEIGHT - viewportTop,
height,
height + top,
);
if (
Number.isNaN(viewportTop) ||
Number.isNaN(viewportLeft) ||
Number.isNaN(viewportWidth) ||
Number.isNaN(viewportHeight)
) {
return null;
}
return (
<div
style={{
border: "2px solid orange",
boxSizing: "border-box",
position: "absolute",
pointerEvents: "none",
top: viewportTop,
left: viewportLeft,
width: viewportWidth,
height: viewportHeight,
}}
/>
);
};
export function MiniMap({
appState,
elements,
}: {
appState: AppState;
elements: readonly ExcalidrawElement[];
}) {
const [minimapWorker] = useState(() => new MinimapWorker());
const canvasRef = useRef<HTMLCanvasElement>(null);
const elementsRef = useRef(elements);
elementsRef.current = elements;
const appStateRef = useRef(appState);
appStateRef.current = appState;
useEffect(() => {
const canvas = canvasRef.current;
if (!canvas) {
return;
}
const offscreenCanvas = canvas.transferControlToOffscreen();
minimapWorker.postMessage({ type: "INIT", canvas: offscreenCanvas }, [
offscreenCanvas,
]);
minimapWorker.postMessage({
type: "DRAW",
elements: elementsRef.current,
appState: appStateRef.current,
width: MINIMAP_WIDTH,
height: MINIMAP_HEIGHT,
});
setInterval(() => {
minimapWorker.postMessage({
type: "DRAW",
elements: elementsRef.current,
appState: appStateRef.current,
width: MINIMAP_WIDTH,
height: MINIMAP_HEIGHT,
});
}, 1000);
return () => {
minimapWorker.terminate();
};
}, [minimapWorker]);
return (
<Island padding={1} className="MiniMap">
<div
style={{
width: MINIMAP_WIDTH,
height: MINIMAP_HEIGHT,
position: "relative",
overflow: "hidden",
backgroundColor: appState.viewBackgroundColor,
}}
>
<canvas ref={canvasRef} />
<MinimapViewport elements={elements} appState={appState} />
</div>
</Island>
);
}
+162 -116
View File
@@ -29,6 +29,7 @@ type MobileMenuProps = {
canvas: HTMLCanvasElement | null; canvas: HTMLCanvasElement | null;
isCollaborating: boolean; isCollaborating: boolean;
renderCustomFooter?: (isMobile: boolean) => JSX.Element; renderCustomFooter?: (isMobile: boolean) => JSX.Element;
viewModeEnabled: boolean;
}; };
export const MobileMenu = ({ export const MobileMenu = ({
@@ -43,121 +44,166 @@ export const MobileMenu = ({
canvas, canvas,
isCollaborating, isCollaborating,
renderCustomFooter, renderCustomFooter,
}: MobileMenuProps) => ( viewModeEnabled,
<> }: MobileMenuProps) => {
<FixedSideContainer side="top"> const renderToolbar = () => {
<Section heading="shapes"> return (
{(heading) => ( <FixedSideContainer side="top" className="App-top-bar">
<Stack.Col gap={4} align="center"> <Section heading="shapes">
<Stack.Row gap={1}> {(heading) => (
<Island padding={1}> <Stack.Col gap={4} align="center">
{heading} <Stack.Row gap={1}>
<Stack.Row gap={1}> <Island padding={1}>
<ShapesSwitcher {heading}
elementType={appState.elementType} <Stack.Row gap={1}>
setAppState={setAppState} <ShapesSwitcher
isLibraryOpen={appState.isLibraryOpen} elementType={appState.elementType}
/> setAppState={setAppState}
</Stack.Row> isLibraryOpen={appState.isLibraryOpen}
</Island> />
<LockIcon </Stack.Row>
checked={appState.elementLocked} </Island>
onChange={onLockToggle} <LockIcon
title={t("toolBar.lock")} checked={appState.elementLocked}
/> onChange={onLockToggle}
</Stack.Row> title={t("toolBar.lock")}
{libraryMenu}
</Stack.Col>
)}
</Section>
<HintViewer appState={appState} elements={elements} />
</FixedSideContainer>
<div
className="App-bottom-bar"
style={{
marginBottom: SCROLLBAR_WIDTH + SCROLLBAR_MARGIN * 2,
marginLeft: SCROLLBAR_WIDTH + SCROLLBAR_MARGIN * 2,
marginRight: SCROLLBAR_WIDTH + SCROLLBAR_MARGIN * 2,
}}
>
<Island padding={0}>
{appState.openMenu === "canvas" ? (
<Section className="App-mobile-menu" heading="canvasActions">
<div className="panelColumn">
<Stack.Col gap={4}>
{actionManager.renderAction("loadScene")}
{actionManager.renderAction("saveScene")}
{actionManager.renderAction("saveAsScene")}
{exportButton}
{actionManager.renderAction("clearCanvas")}
{onCollabButtonClick && (
<CollabButton
isCollaborating={isCollaborating}
collaboratorCount={appState.collaborators.size}
onClick={onCollabButtonClick}
/>
)}
<BackgroundPickerAndDarkModeToggle
actionManager={actionManager}
appState={appState}
setAppState={setAppState}
/> />
{renderCustomFooter?.(true)} </Stack.Row>
<fieldset> {libraryMenu}
<legend>{t("labels.collaborators")}</legend> </Stack.Col>
<UserList mobile>
{Array.from(appState.collaborators)
// Collaborator is either not initialized or is actually the current user.
.filter(([_, client]) => Object.keys(client).length !== 0)
.map(([clientId, client]) => (
<React.Fragment key={clientId}>
{actionManager.renderAction(
"goToCollaborator",
clientId,
)}
</React.Fragment>
))}
</UserList>
</fieldset>
</Stack.Col>
</div>
</Section>
) : appState.openMenu === "shape" &&
showSelectedShapeActions(appState, elements) ? (
<Section className="App-mobile-menu" heading="selectedShapeActions">
<SelectedShapeActions
appState={appState}
elements={elements}
renderAction={actionManager.renderAction}
elementType={appState.elementType}
/>
</Section>
) : null}
<footer className="App-toolbar">
<div className="App-toolbar-content">
{actionManager.renderAction("toggleCanvasMenu")}
{actionManager.renderAction("toggleEditMenu")}
{actionManager.renderAction("undo")}
{actionManager.renderAction("redo")}
{actionManager.renderAction(
appState.multiElement ? "finalize" : "duplicateSelection",
)}
{actionManager.renderAction("deleteSelectedElements")}
</div>
{appState.scrolledOutside && !appState.openMenu && (
<button
className="scroll-back-to-content"
onClick={() => {
setAppState({
...calculateScrollCenter(elements, appState, canvas),
});
}}
>
{t("buttons.scrollBackToContent")}
</button>
)} )}
</footer> </Section>
</Island> <HintViewer appState={appState} elements={elements} />
</div> </FixedSideContainer>
</> );
); };
const renderAppToolbar = () => {
if (viewModeEnabled) {
return (
<div className="App-toolbar-content">
{actionManager.renderAction("toggleCanvasMenu")}
</div>
);
}
return (
<div className="App-toolbar-content">
{actionManager.renderAction("toggleCanvasMenu")}
{actionManager.renderAction("toggleEditMenu")}
{actionManager.renderAction("undo")}
{actionManager.renderAction("redo")}
{actionManager.renderAction(
appState.multiElement ? "finalize" : "duplicateSelection",
)}
{actionManager.renderAction("deleteSelectedElements")}
</div>
);
};
const renderCanvasActions = () => {
if (viewModeEnabled) {
return (
<>
{actionManager.renderAction("saveScene")}
{actionManager.renderAction("saveAsScene")}
{exportButton}
</>
);
}
return (
<>
{actionManager.renderAction("loadScene")}
{actionManager.renderAction("saveScene")}
{actionManager.renderAction("saveAsScene")}
{exportButton}
{actionManager.renderAction("clearCanvas")}
{onCollabButtonClick && (
<CollabButton
isCollaborating={isCollaborating}
collaboratorCount={appState.collaborators.size}
onClick={onCollabButtonClick}
/>
)}
{
<BackgroundPickerAndDarkModeToggle
actionManager={actionManager}
appState={appState}
setAppState={setAppState}
/>
}
</>
);
};
return (
<>
{!viewModeEnabled && renderToolbar()}
<div
className="App-bottom-bar"
style={{
marginBottom: SCROLLBAR_WIDTH + SCROLLBAR_MARGIN * 2,
marginLeft: SCROLLBAR_WIDTH + SCROLLBAR_MARGIN * 2,
marginRight: SCROLLBAR_WIDTH + SCROLLBAR_MARGIN * 2,
}}
>
<Island padding={0}>
{appState.openMenu === "canvas" ? (
<Section className="App-mobile-menu" heading="canvasActions">
<div className="panelColumn">
<Stack.Col gap={4}>
{renderCanvasActions()}
{renderCustomFooter?.(true)}
{appState.collaborators.size > 0 && (
<fieldset>
<legend>{t("labels.collaborators")}</legend>
<UserList mobile>
{Array.from(appState.collaborators)
// Collaborator is either not initialized or is actually the current user.
.filter(
([_, client]) => Object.keys(client).length !== 0,
)
.map(([clientId, client]) => (
<React.Fragment key={clientId}>
{actionManager.renderAction(
"goToCollaborator",
clientId,
)}
</React.Fragment>
))}
</UserList>
</fieldset>
)}
</Stack.Col>
</div>
</Section>
) : appState.openMenu === "shape" &&
!viewModeEnabled &&
showSelectedShapeActions(appState, elements) ? (
<Section className="App-mobile-menu" heading="selectedShapeActions">
<SelectedShapeActions
appState={appState}
elements={elements}
renderAction={actionManager.renderAction}
elementType={appState.elementType}
/>
</Section>
) : null}
<footer className="App-toolbar">
{renderAppToolbar()}
{appState.scrolledOutside && !appState.openMenu && (
<button
className="scroll-back-to-content"
onClick={() => {
setAppState({
...calculateScrollCenter(elements, appState, canvas),
});
}}
>
{t("buttons.scrollBackToContent")}
</button>
)}
</footer>
</Island>
</div>
</>
);
};
+11 -6
View File
@@ -1,8 +1,13 @@
@import "../css/_variables"; @import "../css/variables.module";
.excalidraw { .excalidraw {
&.excalidraw-modal-container {
position: absolute;
z-index: 10;
}
.Modal { .Modal {
position: fixed; position: absolute;
top: 0; top: 0;
left: 0; left: 0;
right: 0; right: 0;
@@ -15,7 +20,7 @@
} }
.Modal__background { .Modal__background {
position: fixed; position: absolute;
top: 0; top: 0;
left: 0; left: 0;
right: 0; right: 0;
@@ -39,10 +44,10 @@
overflow-y: auto; overflow-y: auto;
// for modals, reset blurry bg // for modals, reset blurry bg
background: var(--bg-color-island); background: var(--island-bg-color);
backdrop-filter: none; backdrop-filter: none;
border: 1px solid var(--dialog-border); border: 1px solid var(--dialog-border-color);
box-shadow: 0 2px 10px transparentize($oc-black, 0.75); box-shadow: 0 2px 10px transparentize($oc-black, 0.75);
border-radius: 6px; border-radius: 6px;
@@ -82,7 +87,7 @@
} }
.Modal__content { .Modal__content {
position: fixed; position: absolute;
top: 0; top: 0;
left: 0; left: 0;
right: 0; right: 0;
+1 -1
View File
@@ -54,7 +54,7 @@ const useBodyRoot = () => {
?.classList.contains("Appearance_dark"); ?.classList.contains("Appearance_dark");
const div = document.createElement("div"); const div = document.createElement("div");
div.classList.add("excalidraw"); div.classList.add("excalidraw", "excalidraw-modal-container");
if (isDarkTheme) { if (isDarkTheme) {
div.classList.add("Appearance_dark"); div.classList.add("Appearance_dark");
+1 -1
View File
@@ -1,4 +1,4 @@
@import "../css/_variables"; @import "../css/variables.module";
.excalidraw { .excalidraw {
.PasteChartDialog { .PasteChartDialog {
+1 -9
View File
@@ -1,14 +1,6 @@
.excalidraw { .excalidraw {
.popover { .popover {
position: absolute; position: fixed;
z-index: 10; z-index: 10;
} }
.popover .cover {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
}
} }
+43 -41
View File
@@ -1,51 +1,53 @@
@import "../css/_variables"; @import "../css/variables.module";
.Stats { .excalidraw {
position: fixed; .Stats {
top: 64px; position: absolute;
right: 12px; top: 64px;
font-size: 12px; right: 12px;
z-index: 999; font-size: 12px;
z-index: 999;
h3 { h3 {
margin: 0 24px 8px 0; margin: 0 24px 8px 0;
white-space: nowrap; white-space: nowrap;
} }
.close { .close {
float: right; float: right;
height: 16px; height: 16px;
width: 16px; width: 16px;
cursor: pointer; cursor: pointer;
svg { svg {
width: 100%;
height: 100%;
}
}
table {
width: 100%; width: 100%;
height: 100%; th {
border-bottom: 1px solid var(--input-border-color);
padding: 4px;
}
tr {
td:nth-child(2) {
min-width: 24px;
text-align: right;
}
}
} }
}
table { :root[dir="rtl"] & {
width: 100%; left: 12px;
th { right: initial;
border-bottom: 1px solid var(--input-border-color);
padding: 4px; h3 {
} margin: 0 0 8px 24px;
tr { }
td:nth-child(2) { .close {
min-width: 24px; float: left;
text-align: right;
} }
} }
} }
:root[dir="rtl"] & {
left: 12px;
right: initial;
h3 {
margin: 0 0 8px 24px;
}
.close {
float: left;
}
}
} }
+37 -1
View File
@@ -1,4 +1,6 @@
import React, { useEffect, useState } from "react"; import React, { useEffect, useState } from "react";
import { copyTextToSystemClipboard } from "../clipboard";
import { DEFAULT_VERSION } from "../constants";
import { getCommonBounds } from "../element/bounds"; import { getCommonBounds } from "../element/bounds";
import { NonDeletedExcalidrawElement } from "../element/types"; import { NonDeletedExcalidrawElement } from "../element/types";
import { import {
@@ -9,7 +11,7 @@ import { t } from "../i18n";
import useIsMobile from "../is-mobile"; import useIsMobile from "../is-mobile";
import { getTargetElements } from "../scene"; import { getTargetElements } from "../scene";
import { AppState } from "../types"; import { AppState } from "../types";
import { debounce, nFormatter } from "../utils"; import { debounce, getVersion, nFormatter } from "../utils";
import { close } from "./icons"; import { close } from "./icons";
import { Island } from "./Island"; import { Island } from "./Island";
import "./Stats.scss"; import "./Stats.scss";
@@ -25,6 +27,7 @@ const getStorageSizes = debounce((cb: (sizes: StorageSizes) => void) => {
export const Stats = (props: { export const Stats = (props: {
appState: AppState; appState: AppState;
setAppState: React.Component<any, AppState>["setState"];
elements: readonly NonDeletedExcalidrawElement[]; elements: readonly NonDeletedExcalidrawElement[];
onClose: () => void; onClose: () => void;
}) => { }) => {
@@ -50,6 +53,17 @@ export const Stats = (props: {
return null; return null;
} }
const version = getVersion();
let hash;
let timestamp;
if (version !== DEFAULT_VERSION) {
timestamp = version.slice(0, 16).replace("T", " ");
hash = version.slice(21);
} else {
timestamp = t("stats.versionNotAvailable");
}
return ( return (
<div className="Stats"> <div className="Stats">
<Island padding={2}> <Island padding={2}>
@@ -156,6 +170,28 @@ export const Stats = (props: {
</td> </td>
</tr> </tr>
)} )}
<tr>
<th colSpan={2}>{t("stats.version")}</th>
</tr>
<tr>
<td
colSpan={2}
style={{ textAlign: "center", cursor: "pointer" }}
onClick={async () => {
try {
await copyTextToSystemClipboard(getVersion());
props.setAppState({
toastMessage: t("toast.copyToClipboard"),
});
} catch {}
}}
title={t("stats.versionCopy")}
>
{timestamp}
<br />
{hash}
</td>
</tr>
</tbody> </tbody>
</table> </table>
</Island> </Island>
+4 -4
View File
@@ -1,19 +1,19 @@
@import "../css/_variables.scss"; @import "../css/variables.module";
.excalidraw { .excalidraw {
.TextInput { .TextInput {
color: var(--text-color-primary); color: var(--text-primary-color);
display: inline-block; display: inline-block;
border: 1.5px solid var(--button-gray-1); border: 1.5px solid var(--button-gray-1);
line-height: 1; line-height: 1;
padding: 0.75rem; padding: 0.75rem;
white-space: nowrap; white-space: nowrap;
border-radius: var(--space-factor); border-radius: var(--space-factor);
background-color: var(--input-background-color); background-color: var(--input-bg-color);
&:not(:focus) { &:not(:focus) {
&:hover { &:hover {
background-color: var(--input-hover-background-color); background-color: var(--input-hover-bg-color);
} }
} }
+2 -2
View File
@@ -1,4 +1,4 @@
@import "../css/_variables"; @import "../css/variables.module";
.excalidraw { .excalidraw {
.Toast { .Toast {
@@ -11,7 +11,7 @@
left: 50%; left: 50%;
margin-left: -150px; margin-left: -150px;
padding: 4px 0; padding: 4px 0;
position: fixed; position: absolute;
text-align: center; text-align: center;
width: 300px; width: 300px;
z-index: 999999; z-index: 999999;
+1 -1
View File
@@ -1,5 +1,5 @@
@import "open-color/open-color.scss"; @import "open-color/open-color.scss";
@import "../css/variables"; @import "../css/variables.module";
.excalidraw { .excalidraw {
.ToolIcon { .ToolIcon {
+2 -10
View File
@@ -1,4 +1,4 @@
@import "../css/_variables"; @import "../css/variables.module";
.excalidraw { .excalidraw {
.Tooltip { .Tooltip {
position: relative; position: relative;
@@ -48,15 +48,7 @@
} }
} }
// the following 3 rules ensure that the tooltip doesn't show (nor affect .Tooltip:hover .Tooltip__label {
// the cursor) when you drag over when you draw on canvas, but at the same
// time it still works when clicking on the link/shield
body:active & .Tooltip:not(:hover) {
pointer-events: none;
}
body:not(:active) & .Tooltip:hover .Tooltip__label {
visibility: visible; visibility: visible;
} }
+19 -3
View File
@@ -2,8 +2,8 @@ import { FontFamily } from "./element/types";
export const APP_NAME = "Excalidraw"; export const APP_NAME = "Excalidraw";
export const DRAGGING_THRESHOLD = 10; // 10px export const DRAGGING_THRESHOLD = 10; // px
export const LINE_CONFIRM_THRESHOLD = 10; // 10px export const LINE_CONFIRM_THRESHOLD = 8; // px
export const ELEMENT_SHIFT_TRANSLATE_AMOUNT = 5; export const ELEMENT_SHIFT_TRANSLATE_AMOUNT = 5;
export const ELEMENT_TRANSLATE_AMOUNT = 1; export const ELEMENT_TRANSLATE_AMOUNT = 1;
export const TEXT_TO_CENTER_SNAP_THRESHOLD = 30; export const TEXT_TO_CENTER_SNAP_THRESHOLD = 30;
@@ -46,6 +46,8 @@ export enum EVENT {
TOUCH_START = "touchstart", TOUCH_START = "touchstart",
TOUCH_END = "touchend", TOUCH_END = "touchend",
HASHCHANGE = "hashchange", HASHCHANGE = "hashchange",
VISIBILITY_CHANGE = "visibilitychange",
SCROLL = "scroll",
} }
export const ENV = { export const ENV = {
@@ -90,4 +92,18 @@ export const TAP_TWICE_TIMEOUT = 300;
export const TOUCH_CTX_MENU_TIMEOUT = 500; export const TOUCH_CTX_MENU_TIMEOUT = 500;
export const TITLE_TIMEOUT = 10000; export const TITLE_TIMEOUT = 10000;
export const TOAST_TIMEOUT = 5000; export const TOAST_TIMEOUT = 5000;
export const VERSION_TIMEOUT = 15000; export const VERSION_TIMEOUT = 30000;
export const SCROLL_TIMEOUT = 500;
export const ZOOM_STEP = 0.1;
// Report a user inactive after IDLE_THRESHOLD milliseconds
export const IDLE_THRESHOLD = 60_000;
// Report a user active each ACTIVE_THRESHOLD milliseconds
export const ACTIVE_THRESHOLD = 3_000;
export const MODES = {
VIEW: "viewMode",
ZEN: "zenMode",
GRID: "gridMode",
};
+42
View File
@@ -0,0 +1,42 @@
import React from "react";
export const createInverseContext = <T extends unknown = null>(
initialValue: T,
) => {
const Context = React.createContext(initialValue) as React.Context<T> & {
_updateProviderValue?: (value: T) => void;
};
class InverseConsumer extends React.Component {
state = { value: initialValue };
constructor(props: any) {
super(props);
Context._updateProviderValue = (value: T) => this.setState({ value });
}
render() {
return (
<Context.Provider value={this.state.value}>
{this.props.children}
</Context.Provider>
);
}
}
class InverseProvider extends React.Component<{ value: T }> {
componentDidMount() {
Context._updateProviderValue?.(this.props.value);
}
componentDidUpdate() {
Context._updateProviderValue?.(this.props.value);
}
render() {
return <Context.Consumer>{() => this.props.children}</Context.Consumer>;
}
}
return {
Context,
Consumer: InverseConsumer,
Provider: InverseProvider,
};
};
+38 -18
View File
@@ -1,10 +1,15 @@
@import "./_variables"; @import "./variables.module";
@import "./theme"; @import "./theme";
:root {
--zIndex-canvas: 1;
--zIndex-wysiwyg: 2;
--zIndex-layerUI: 3;
}
.excalidraw { .excalidraw {
color: var(--text-color-primary); color: var(--text-primary-color);
display: flex; display: flex;
position: fixed;
top: 0; top: 0;
bottom: 0; bottom: 0;
left: 0; left: 0;
@@ -30,6 +35,8 @@
image-rendering: pixelated; // chromium image-rendering: pixelated; // chromium
// NOTE: must be declared *after* the above // NOTE: must be declared *after* the above
image-rendering: -moz-crisp-edges; // FF image-rendering: -moz-crisp-edges; // FF
z-index: var(--zIndex-canvas);
} }
&.Appearance_dark { &.Appearance_dark {
@@ -43,10 +50,10 @@
} }
.FixedSideContainer { .FixedSideContainer {
padding-top: var(--sat, 0px); padding-top: var(--sat, 0);
padding-right: var(--sar, 0px); padding-right: var(--sar, 0);
padding-bottom: var(--sab, 0px); padding-bottom: var(--sab, 0);
padding-left: var(--sal, 0px); padding-left: var(--sal, 0);
} }
.panelRow { .panelRow {
@@ -64,7 +71,7 @@
margin-top: 0.333rem; margin-top: 0.333rem;
margin-bottom: 0.333rem; margin-bottom: 0.333rem;
font-size: 0.75rem; font-size: 0.75rem;
color: var(--text-color-primary); color: var(--text-primary-color);
font-weight: bold; font-weight: bold;
display: block; display: block;
} }
@@ -216,6 +223,13 @@
} }
} }
.App-top-bar {
z-index: var(--zIndex-layerUI);
display: flex;
flex-direction: column;
align-items: center;
}
.App-bottom-bar { .App-bottom-bar {
position: absolute; position: absolute;
top: 0; top: 0;
@@ -223,10 +237,10 @@
left: 0; left: 0;
right: 0; right: 0;
--bar-padding: calc(4 * var(--space-factor)); --bar-padding: calc(4 * var(--space-factor));
padding-top: #{"max(var(--bar-padding), var(--sat, 0px))"}; padding-top: #{"max(var(--bar-padding), var(--sat,0))"};
padding-right: var(--sar, 0px); padding-right: var(--sar, 0);
padding-bottom: var(--sab, 0px); padding-bottom: var(--sab, 0);
padding-left: var(--sal, 0px); padding-left: var(--sal, 0);
z-index: 4; z-index: 4;
display: flex; display: flex;
align-items: flex-end; align-items: flex-end;
@@ -243,7 +257,7 @@
pointer-events: initial; pointer-events: initial;
.panelColumn { .panelColumn {
padding: 8px 8px 0px 8px; padding: 8px 8px 0 8px;
} }
} }
} }
@@ -282,7 +296,7 @@
pointer-events: none !important; pointer-events: none !important;
} }
.App-menu_top > * { .layer-ui__wrapper:not(.disable-pointerEvents) .App-menu_top > * {
pointer-events: all; pointer-events: all;
} }
@@ -323,7 +337,7 @@
} }
} }
.App-menu_bottom > * { .layer-ui__wrapper:not(.disable-pointerEvents) .App-menu_bottom > * {
pointer-events: all; pointer-events: all;
} }
@@ -347,7 +361,6 @@
.App-menu__left { .App-menu__left {
overflow-y: auto; overflow-y: auto;
max-height: calc(100vh - 236px);
} }
.dropdown-select { .dropdown-select {
@@ -419,7 +432,7 @@
.scroll-back-to-content { .scroll-back-to-content {
color: var(--popup-text-color); color: var(--popup-text-color);
position: fixed; position: absolute;
left: 50%; left: 50%;
bottom: 30px; bottom: 30px;
transform: translateX(-50%); transform: translateX(-50%);
@@ -447,7 +460,7 @@
display: none; display: none;
} }
.scroll-back-to-content { .scroll-back-to-content {
bottom: calc(80px + var(--sab, 0px)); bottom: calc(80px + var(--sab, 0));
z-index: -1; z-index: -1;
} }
} }
@@ -492,6 +505,13 @@
pointer-events: none !important; pointer-events: none !important;
} }
&.excalidraw--view-mode {
.App-menu {
display: flex;
justify-content: space-between;
}
}
@media print { @media print {
.App-bottom-bar, .App-bottom-bar,
.FixedSideContainer, .FixedSideContainer,
+42 -42
View File
@@ -1,43 +1,43 @@
@import "open-color/open-color.scss"; @import "open-color/open-color.scss";
:root { :root {
--bg-color-island: rgba(255, 255, 255, 0.9); --appearance-filter: none;
--popup-background-color: #{$oc-white}; --button-destructive-bg-color: #{$oc-red-1};
--space-factor: 0.25rem; --button-destructive-color: #{$oc-red-9};
--button-gray-1: #{$oc-gray-2}; --button-gray-1: #{$oc-gray-2};
--button-gray-2: #{$oc-gray-4}; --button-gray-2: #{$oc-gray-4};
--button-gray-3: #{$oc-gray-5}; --button-gray-3: #{$oc-gray-5};
--input-border-color: #{$oc-gray-3}; --button-special-active-bg-color: #{$oc-green-0};
--input-background-color: #{$oc-white}; --dialog-border-color: #{$oc-gray-6};
--input-hover-background-color: #{$oc-gray-1}; --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>');
--input-label-color: #{$oc-gray-7}; --focus-highlight-color: #{$oc-blue-2};
--icon-fill-color: #{$oc-black}; --icon-fill-color: #{$oc-black};
--icon-green-fill-color: #{$oc-green-9}; --icon-green-fill-color: #{$oc-green-9};
--input-bg-color: #{$oc-white};
--input-border-color: #{$oc-gray-3};
--input-hover-bg-color: #{$oc-gray-1};
--input-label-color: #{$oc-gray-7};
--island-bg-color: rgba(255, 255, 255, 0.9);
--keybinding-color: #{$oc-gray-5}; --keybinding-color: #{$oc-gray-5};
--sat: env(safe-area-inset-top); --link-color: #{$oc-blue-7};
--overlay-bg-color: #{transparentize($oc-white, 0.12)};
--popup-bg-color: #{$oc-white};
--popup-secondary-bg-color: #{$oc-gray-1};
--popup-text-color: #{$oc-black};
--popup-text-inverted-color: #{$oc-white};
--sab: env(safe-area-inset-bottom); --sab: env(safe-area-inset-bottom);
--sal: env(safe-area-inset-left); --sal: env(safe-area-inset-left);
--sar: env(safe-area-inset-right); --sar: env(safe-area-inset-right);
--text-color-primary: #{$oc-gray-8}; --sat: env(safe-area-inset-top);
--shadow-island: 0 1px 5px #{transparentize($oc-black, 0.85)};
--overlay-background-color: #{transparentize($oc-white, 0.12)};
--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}; --select-highlight-color: #{$oc-blue-5};
--appearance-filter: none; --shadow-island: 0 1px 5px #{transparentize($oc-black, 0.85)};
--button-special-active-background-color: #{$oc-green-0}; --space-factor: 0.25rem;
--button-destructive-color: #{$oc-red-9}; --text-primary-color: #{$oc-gray-8};
--button-destructive-background-color: #{$oc-red-1};
--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 { .excalidraw {
&.Appearance_dark { &.Appearance_dark {
background: #000; background: $oc-black;
&.Appearance_dark-background-none { &.Appearance_dark-background-none {
background: none; background: none;
@@ -45,31 +45,31 @@
} }
&.Appearance_dark { &.Appearance_dark {
--text-color-primary: #{$oc-gray-4}; --appearance-filter: invert(93%) hue-rotate(180deg);
--bg-color-island: #1e1e1e; --button-destructive-bg-color: #5a0000;
--popup-background-color: #2c2c2c; --button-destructive-color: #{$oc-red-3};
--button-gray-1: #363636; --button-gray-1: #363636;
--button-gray-2: #272727; --button-gray-2: #272727;
--button-gray-3: #222; --button-gray-3: #222;
--input-border-color: #2e2e2e; --button-special-active-bg-color: #204624;
--input-background-color: #121212; --dialog-border-color: #{$oc-gray-9};
--input-hover-background-color: #181818;
--input-label-color: #{$oc-gray-2};
--icon-fill-color: #{$oc-gray-4};
--icon-green-fill-color: #{$oc-green-4};
--keybinding-color: #{$oc-gray-6};
--shadow-island: 0 1px 5px #{transparentize($oc-black, 0.7)};
--overlay-background-color: rgba(30, 30, 30, 0.88);
--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>'); --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}; --focus-highlight-color: #{$oc-blue-6};
--select-highlight-color: #{$oc-blue-4}; --icon-fill-color: #{$oc-gray-4};
--appearance-filter: invert(93%) hue-rotate(180deg); --icon-green-fill-color: #{$oc-green-4};
--button-special-active-background-color: #204624; --input-bg-color: #121212;
--button-destructive-color: #{$oc-red-3}; --input-border-color: #2e2e2e;
--button-destructive-background-color: #5a0000; --input-hover-bg-color: #181818;
--popup-secondary-background-color: #222; --input-label-color: #{$oc-gray-2};
--island-bg-color: #1e1e1e;
--keybinding-color: #{$oc-gray-6};
--overlay-bg-color: #{transparentize($oc-gray-8, 0.88)};
--popup-bg-color: #2c2c2c;
--popup-secondary-bg-color: #222;
--popup-text-color: #{$oc-gray-4}; --popup-text-color: #{$oc-gray-4};
--popup-text-inverted-color: #2c2c2c; --popup-text-inverted-color: #2c2c2c;
--dialog-border: #{$oc-gray-9}; --select-highlight-color: #{$oc-blue-4};
--shadow-island: 0 1px 5px #{transparentize($oc-black, 0.7)};
--text-primary-color: #{$oc-gray-4};
} }
} }
@@ -2,3 +2,7 @@
// keep up to date with is-mobile.tsx // keep up to date with is-mobile.tsx
$is-mobile-query: "(max-width: 600px), (max-height: 500px) and (max-width: 1000px)"; $is-mobile-query: "(max-width: 600px), (max-height: 500px) and (max-width: 1000px)";
:export {
isMobileQuery: unquote($is-mobile-query);
}
+6 -1
View File
@@ -1,4 +1,4 @@
import { fileSave } from "browser-nativefs"; import { fileSave } from "browser-fs-access";
import { import {
copyCanvasToClipboardAsPng, copyCanvasToClipboardAsPng,
copyTextToSystemClipboard, copyTextToSystemClipboard,
@@ -73,6 +73,11 @@ export const exportCanvas = async (
scale, scale,
shouldAddWatermark, shouldAddWatermark,
}); });
if (tempCanvas instanceof OffscreenCanvas) {
return;
}
tempCanvas.style.display = "none"; tempCanvas.style.display = "none";
document.body.appendChild(tempCanvas); document.body.appendChild(tempCanvas);
+1 -1
View File
@@ -1,4 +1,4 @@
import { fileOpen, fileSave } from "browser-nativefs"; import { fileOpen, fileSave } from "browser-fs-access";
import { cleanAppStateForExport } from "../appState"; import { cleanAppStateForExport } from "../appState";
import { MIME_TYPES } from "../constants"; import { MIME_TYPES } from "../constants";
import { clearElementsForExport } from "../element"; import { clearElementsForExport } from "../element";
+1 -1
View File
@@ -141,7 +141,7 @@ export const restoreElements = (
}, [] as ExcalidrawElement[]); }, [] as ExcalidrawElement[]);
}; };
const restoreAppState = ( export const restoreAppState = (
appState: ImportedDataState["appState"], appState: ImportedDataState["appState"],
localAppState: Partial<AppState> | null, localAppState: Partial<AppState> | null,
): AppState => { ): AppState => {
+1 -1
View File
@@ -129,7 +129,7 @@ export class LinearElementEditor {
isDragging && isDragging &&
(activePointIndex === 0 || activePointIndex === element.points.length - 1) (activePointIndex === 0 || activePointIndex === element.points.length - 1)
) { ) {
if (isPathALoop(element.points)) { if (isPathALoop(element.points, appState.zoom.value)) {
LinearElementEditor.movePoint( LinearElementEditor.movePoint(
element, element,
activePointIndex, activePointIndex,
+4 -3
View File
@@ -7,7 +7,8 @@ export const showSelectedShapeActions = (
elements: readonly NonDeletedExcalidrawElement[], elements: readonly NonDeletedExcalidrawElement[],
) => ) =>
Boolean( Boolean(
appState.editingElement || !appState.viewModeEnabled &&
getSelectedElements(elements, appState).length || (appState.editingElement ||
appState.elementType !== "selection", getSelectedElements(elements, appState).length ||
appState.elementType !== "selection"),
); );
+25 -8
View File
@@ -38,6 +38,7 @@ export const textWysiwyg = ({
onSubmit, onSubmit,
getViewportCoords, getViewportCoords,
element, element,
canvas,
}: { }: {
id: ExcalidrawElement["id"]; id: ExcalidrawElement["id"];
appState: AppState; appState: AppState;
@@ -45,6 +46,7 @@ export const textWysiwyg = ({
onSubmit: (text: string) => void; onSubmit: (text: string) => void;
getViewportCoords: (x: number, y: number) => [number, number]; getViewportCoords: (x: number, y: number) => [number, number];
element: ExcalidrawElement; element: ExcalidrawElement;
canvas: HTMLCanvasElement | null;
}) => { }) => {
const updateWysiwygStyle = () => { const updateWysiwygStyle = () => {
const updatedElement = Scene.getScene(element)?.getElement(id); const updatedElement = Scene.getScene(element)?.getElement(id);
@@ -89,9 +91,6 @@ export const textWysiwyg = ({
editable.dataset.type = "wysiwyg"; editable.dataset.type = "wysiwyg";
// prevent line wrapping on Safari // prevent line wrapping on Safari
editable.wrap = "off"; editable.wrap = "off";
editable.className = `excalidraw ${
appState.appearance === "dark" ? "Appearance_dark" : ""
}`;
Object.assign(editable.style, { Object.assign(editable.style, {
position: "fixed", position: "fixed",
@@ -107,6 +106,8 @@ export const textWysiwyg = ({
overflow: "hidden", overflow: "hidden",
// prevent line wrapping (`whitespace: nowrap` doesn't work on FF) // prevent line wrapping (`whitespace: nowrap` doesn't work on FF)
whiteSpace: "pre", whiteSpace: "pre",
// must be specified because in dark mode canvas creates a stacking context
zIndex: "var(--zIndex-wysiwyg)",
}); });
updateWysiwygStyle(); updateWysiwygStyle();
@@ -152,6 +153,10 @@ export const textWysiwyg = ({
editable.oninput = null; editable.oninput = null;
editable.onkeydown = null; editable.onkeydown = null;
if (observer) {
observer.disconnect();
}
window.removeEventListener("resize", updateWysiwygStyle); window.removeEventListener("resize", updateWysiwygStyle);
window.removeEventListener("wheel", stopEvent, true); window.removeEventListener("wheel", stopEvent, true);
window.removeEventListener("pointerdown", onPointerDown); window.removeEventListener("pointerdown", onPointerDown);
@@ -160,7 +165,7 @@ export const textWysiwyg = ({
unbindUpdate(); unbindUpdate();
document.body.removeChild(editable); editable.remove();
}; };
const rebindBlur = () => { const rebindBlur = () => {
@@ -198,15 +203,27 @@ export const textWysiwyg = ({
let isDestroyed = false; let isDestroyed = false;
editable.onblur = handleSubmit; editable.onblur = handleSubmit;
// reposition wysiwyg in case of window resize. Happens on mobile when
// device keyboard is opened. // reposition wysiwyg in case of canvas is resized. Using ResizeObserver
window.addEventListener("resize", updateWysiwygStyle); // is preferred so we catch changes from host, where window may not resize.
let observer: ResizeObserver | null = null;
if (canvas && "ResizeObserver" in window) {
observer = new window.ResizeObserver(() => {
updateWysiwygStyle();
});
observer.observe(canvas);
} else {
window.addEventListener("resize", updateWysiwygStyle);
}
window.addEventListener("pointerdown", onPointerDown); window.addEventListener("pointerdown", onPointerDown);
window.addEventListener("wheel", stopEvent, { window.addEventListener("wheel", stopEvent, {
passive: false, passive: false,
capture: true, capture: true,
}); });
document.body.appendChild(editable); document
.querySelector(".excalidraw-textEditorContainer")!
.appendChild(editable);
editable.focus(); editable.focus();
editable.select(); editable.select();
}; };
+355 -160
View File
@@ -6,10 +6,11 @@ import { APP_NAME, ENV, EVENT } from "../../constants";
import { ImportedDataState } from "../../data/types"; import { ImportedDataState } from "../../data/types";
import { ExcalidrawElement } from "../../element/types"; import { ExcalidrawElement } from "../../element/types";
import { import {
getElementMap,
getSceneVersion, getSceneVersion,
getSyncableElements, getSyncableElements,
} from "../../packages/excalidraw/index"; } from "../../packages/excalidraw/index";
import { AppState, Collaborator, Gesture } from "../../types"; import { Collaborator, Gesture } from "../../types";
import { resolvablePromise, withBatchedUpdates } from "../../utils"; import { resolvablePromise, withBatchedUpdates } from "../../utils";
import { import {
INITIAL_SCENE_UPDATE_TIMEOUT, INITIAL_SCENE_UPDATE_TIMEOUT,
@@ -18,12 +19,16 @@ import {
} from "../app_constants"; } from "../app_constants";
import { import {
decryptAESGEM, decryptAESGEM,
generateCollaborationLink, generateCollaborationLinkData,
getCollaborationLinkData, getCollaborationLink,
SocketUpdateDataSource, SocketUpdateDataSource,
SOCKET_SERVER, SOCKET_SERVER,
} from "../data"; } from "../data";
import { isSavedToFirebase, saveToFirebase } from "../data/firebase"; import {
isSavedToFirebase,
loadFromFirebase,
saveToFirebase,
} from "../data/firebase";
import { import {
importUsernameFromLocalStorage, importUsernameFromLocalStorage,
saveUsernameToLocalStorage, saveUsernameToLocalStorage,
@@ -31,20 +36,26 @@ import {
} from "../data/localStorage"; } from "../data/localStorage";
import Portal from "./Portal"; import Portal from "./Portal";
import RoomDialog from "./RoomDialog"; import RoomDialog from "./RoomDialog";
import { createInverseContext } from "../../createInverseContext";
import { t } from "../../i18n";
import { UserIdleState } from "./types";
import { IDLE_THRESHOLD, ACTIVE_THRESHOLD } from "../../constants";
interface CollabState { interface CollabState {
isCollaborating: boolean;
modalIsShown: boolean; modalIsShown: boolean;
errorMessage: string; errorMessage: string;
username: string; username: string;
userState: UserIdleState;
activeRoomLink: string; activeRoomLink: string;
} }
type CollabInstance = InstanceType<typeof CollabWrapper>; type CollabInstance = InstanceType<typeof CollabWrapper>;
export interface CollabAPI { export interface CollabAPI {
isCollaborating: CollabState["isCollaborating"]; /** function so that we can access the latest value from stale callbacks */
isCollaborating: () => boolean;
username: CollabState["username"]; username: CollabState["username"];
userState: CollabState["userState"];
onPointerUpdate: CollabInstance["onPointerUpdate"]; onPointerUpdate: CollabInstance["onPointerUpdate"];
initializeSocketClient: CollabInstance["initializeSocketClient"]; initializeSocketClient: CollabInstance["initializeSocketClient"];
onCollabButtonClick: CollabInstance["onCollabButtonClick"]; onCollabButtonClick: CollabInstance["onCollabButtonClick"];
@@ -56,31 +67,41 @@ type ReconciledElements = readonly ExcalidrawElement[] & {
}; };
interface Props { interface Props {
children: (collab: CollabAPI) => React.ReactNode; excalidrawAPI: ExcalidrawImperativeAPI;
// NOTE not type-safe because the refObject may in fact not be initialized
// with ExcalidrawImperativeAPI yet
excalidrawRef: React.MutableRefObject<ExcalidrawImperativeAPI>;
} }
const {
Context: CollabContext,
Consumer: CollabContextConsumer,
Provider: CollabContextProvider,
} = createInverseContext<{ api: CollabAPI | null }>({ api: null });
export { CollabContext, CollabContextConsumer };
class CollabWrapper extends PureComponent<Props, CollabState> { class CollabWrapper extends PureComponent<Props, CollabState> {
portal: Portal; portal: Portal;
excalidrawAPI: Props["excalidrawAPI"];
isCollaborating: boolean = false;
activeIntervalId: number | null;
idleTimeoutId: number | null;
private socketInitializationTimer?: NodeJS.Timeout; private socketInitializationTimer?: NodeJS.Timeout;
private excalidrawRef: Props["excalidrawRef"];
excalidrawAppState?: AppState;
private lastBroadcastedOrReceivedSceneVersion: number = -1; private lastBroadcastedOrReceivedSceneVersion: number = -1;
private collaborators = new Map<string, Collaborator>(); private collaborators = new Map<string, Collaborator>();
constructor(props: Props) { constructor(props: Props) {
super(props); super(props);
this.state = { this.state = {
isCollaborating: false,
modalIsShown: false, modalIsShown: false,
errorMessage: "", errorMessage: "",
username: importUsernameFromLocalStorage() || "", username: importUsernameFromLocalStorage() || "",
userState: UserIdleState.ACTIVE,
activeRoomLink: "", activeRoomLink: "",
}; };
this.portal = new Portal(this); this.portal = new Portal(this);
this.excalidrawRef = props.excalidrawRef; this.excalidrawAPI = props.excalidrawAPI;
this.activeIntervalId = null;
this.idleTimeoutId = null;
} }
componentDidMount() { componentDidMount() {
@@ -104,18 +125,32 @@ class CollabWrapper extends PureComponent<Props, CollabState> {
componentWillUnmount() { componentWillUnmount() {
window.removeEventListener(EVENT.BEFORE_UNLOAD, this.beforeUnload); window.removeEventListener(EVENT.BEFORE_UNLOAD, this.beforeUnload);
window.removeEventListener(EVENT.UNLOAD, this.onUnload); window.removeEventListener(EVENT.UNLOAD, this.onUnload);
window.removeEventListener(EVENT.POINTER_MOVE, this.onPointerMove);
window.removeEventListener(
EVENT.VISIBILITY_CHANGE,
this.onVisibilityChange,
);
if (this.activeIntervalId) {
window.clearInterval(this.activeIntervalId);
this.activeIntervalId = null;
}
if (this.idleTimeoutId) {
window.clearTimeout(this.idleTimeoutId);
this.idleTimeoutId = null;
}
} }
private onUnload = () => { private onUnload = () => {
this.destroySocketClient(); this.destroySocketClient({ isUnload: true });
}; };
private beforeUnload = withBatchedUpdates((event: BeforeUnloadEvent) => { private beforeUnload = withBatchedUpdates((event: BeforeUnloadEvent) => {
const syncableElements = getSyncableElements( const syncableElements = getSyncableElements(
this.getSceneElementsIncludingDeleted(), this.getSceneElementsIncludingDeleted(),
); );
if ( if (
this.state.isCollaborating && this.isCollaborating &&
!isSavedToFirebase(this.portal, syncableElements) !isSavedToFirebase(this.portal, syncableElements)
) { ) {
// this won't run in time if user decides to leave the site, but // this won't run in time if user decides to leave the site, but
@@ -127,7 +162,7 @@ class CollabWrapper extends PureComponent<Props, CollabState> {
event.returnValue = ""; event.returnValue = "";
} }
if (this.state.isCollaborating || this.portal.roomId) { if (this.isCollaborating || this.portal.roomId) {
try { try {
localStorage?.setItem( localStorage?.setItem(
STORAGE_KEYS.LOCAL_STORAGE_KEY_COLLAB_FORCE_FLAG, STORAGE_KEYS.LOCAL_STORAGE_KEY_COLLAB_FORCE_FLAG,
@@ -142,7 +177,7 @@ class CollabWrapper extends PureComponent<Props, CollabState> {
saveCollabRoomToFirebase = async ( saveCollabRoomToFirebase = async (
syncableElements: ExcalidrawElement[] = getSyncableElements( syncableElements: ExcalidrawElement[] = getSyncableElements(
this.excalidrawRef.current!.getSceneElementsIncludingDeleted(), this.excalidrawAPI.getSceneElementsIncludingDeleted(),
), ),
) => { ) => {
try { try {
@@ -153,143 +188,188 @@ class CollabWrapper extends PureComponent<Props, CollabState> {
}; };
openPortal = async () => { openPortal = async () => {
window.history.pushState({}, APP_NAME, await generateCollaborationLink()); return this.initializeSocketClient(null);
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
// existing elements (or clears scene), which would otherwise be persisted
// to database even if deleted before creating the room.
this.excalidrawRef.current!.history.clear();
this.excalidrawRef.current!.updateScene({
elements,
commitToHistory: true,
});
return this.initializeSocketClient();
}; };
closePortal = () => { closePortal = () => {
this.saveCollabRoomToFirebase(); this.saveCollabRoomToFirebase();
window.history.pushState({}, APP_NAME, window.location.origin); if (window.confirm(t("alerts.collabStopOverridePrompt"))) {
this.destroySocketClient(); window.history.pushState({}, APP_NAME, window.location.origin);
this.destroySocketClient();
}
}; };
private destroySocketClient = () => { private destroySocketClient = (opts?: { isUnload: boolean }) => {
this.collaborators = new Map(); if (!opts?.isUnload) {
this.excalidrawRef.current!.updateScene({ this.collaborators = new Map();
collaborators: this.collaborators, this.excalidrawAPI.updateScene({
}); collaborators: this.collaborators,
this.setState({ });
isCollaborating: false, this.setState({
activeRoomLink: "", activeRoomLink: "",
}); });
this.isCollaborating = false;
}
this.portal.close(); this.portal.close();
}; };
private initializeSocketClient = async (): Promise<ImportedDataState | null> => { private initializeSocketClient = async (
existingRoomLinkData: null | { roomId: string; roomKey: string },
): Promise<ImportedDataState | null> => {
if (this.portal.socket) { if (this.portal.socket) {
return null; return null;
} }
const scenePromise = resolvablePromise<ImportedDataState | null>(); let roomId;
let roomKey;
const roomMatch = getCollaborationLinkData(window.location.href); if (existingRoomLinkData) {
({ roomId, roomKey } = existingRoomLinkData);
if (roomMatch) { } else {
const roomId = roomMatch[1]; ({ roomId, roomKey } = await generateCollaborationLinkData());
const roomKey = roomMatch[2]; window.history.pushState(
{},
// fallback in case you're not alone in the room but still don't receive APP_NAME,
// initial SCENE_UPDATE message getCollaborationLink({ roomId, roomKey }),
this.socketInitializationTimer = setTimeout(() => {
this.initializeSocket();
scenePromise.resolve(null);
}, INITIAL_SCENE_UPDATE_TIMEOUT);
const { default: socketIOClient }: any = await import(
/* webpackChunkName: "socketIoClient" */ "socket.io-client"
); );
this.portal.open(socketIOClient(SOCKET_SERVER), roomId, roomKey);
// All socket listeners are moving to Portal
this.portal.socket!.on(
"client-broadcast",
async (encryptedData: ArrayBuffer, iv: Uint8Array) => {
if (!this.portal.roomKey) {
return;
}
const decryptedData = await decryptAESGEM(
encryptedData,
this.portal.roomKey,
iv,
);
switch (decryptedData.type) {
case "INVALID_RESPONSE":
return;
case SCENE.INIT: {
if (!this.portal.socketInitialized) {
const remoteElements = decryptedData.payload.elements;
const reconciledElements = this.reconcileElements(
remoteElements,
);
this.handleRemoteSceneUpdate(reconciledElements, {
init: true,
});
this.initializeSocket();
scenePromise.resolve({ elements: reconciledElements });
}
break;
}
case SCENE.UPDATE:
this.handleRemoteSceneUpdate(
this.reconcileElements(decryptedData.payload.elements),
);
break;
case "MOUSE_LOCATION": {
const {
pointer,
button,
username,
selectedElementIds,
} = decryptedData.payload;
const socketId: SocketUpdateDataSource["MOUSE_LOCATION"]["payload"]["socketId"] =
decryptedData.payload.socketId ||
// @ts-ignore legacy, see #2094 (#2097)
decryptedData.payload.socketID;
const collaborators = new Map(this.collaborators);
const user = collaborators.get(socketId) || {}!;
user.pointer = pointer;
user.button = button;
user.selectedElementIds = selectedElementIds;
user.username = username;
collaborators.set(socketId, user);
this.excalidrawRef.current!.updateScene({
collaborators,
});
break;
}
}
},
);
this.portal.socket!.on("first-in-room", () => {
if (this.portal.socket) {
this.portal.socket.off("first-in-room");
}
this.initializeSocket();
scenePromise.resolve(null);
});
this.setState({
isCollaborating: true,
activeRoomLink: window.location.href,
});
return scenePromise;
} }
return null; const scenePromise = resolvablePromise<ImportedDataState | null>();
this.isCollaborating = true;
const { default: socketIOClient }: any = await import(
/* webpackChunkName: "socketIoClient" */ "socket.io-client"
);
this.portal.open(socketIOClient(SOCKET_SERVER), roomId, roomKey);
if (existingRoomLinkData) {
this.excalidrawAPI.resetScene();
try {
const elements = await loadFromFirebase(
roomId,
roomKey,
this.portal.socket,
);
if (elements) {
scenePromise.resolve({
elements,
});
}
} catch (error) {
// log the error and move on. other peers will sync us the scene.
console.error(error);
}
} else {
const elements = this.excalidrawAPI.getSceneElements();
// remove deleted elements from elements array & history to ensure we don't
// expose potentially sensitive user data in case user manually deletes
// existing elements (or clears scene), which would otherwise be persisted
// to database even if deleted before creating the room.
this.excalidrawAPI.history.clear();
this.excalidrawAPI.updateScene({
elements,
commitToHistory: true,
});
}
// fallback in case you're not alone in the room but still don't receive
// initial SCENE_UPDATE message
this.socketInitializationTimer = setTimeout(() => {
this.initializeSocket();
scenePromise.resolve(null);
}, INITIAL_SCENE_UPDATE_TIMEOUT);
// All socket listeners are moving to Portal
this.portal.socket!.on(
"client-broadcast",
async (encryptedData: ArrayBuffer, iv: Uint8Array) => {
if (!this.portal.roomKey) {
return;
}
const decryptedData = await decryptAESGEM(
encryptedData,
this.portal.roomKey,
iv,
);
switch (decryptedData.type) {
case "INVALID_RESPONSE":
return;
case SCENE.INIT: {
if (!this.portal.socketInitialized) {
this.initializeSocket();
const remoteElements = decryptedData.payload.elements;
const reconciledElements = this.reconcileElements(remoteElements);
this.handleRemoteSceneUpdate(reconciledElements, {
init: true,
});
// noop if already resolved via init from firebase
scenePromise.resolve({ elements: reconciledElements });
}
break;
}
case SCENE.UPDATE:
this.handleRemoteSceneUpdate(
this.reconcileElements(decryptedData.payload.elements),
);
break;
case "MOUSE_LOCATION": {
const {
pointer,
button,
username,
selectedElementIds,
} = decryptedData.payload;
const socketId: SocketUpdateDataSource["MOUSE_LOCATION"]["payload"]["socketId"] =
decryptedData.payload.socketId ||
// @ts-ignore legacy, see #2094 (#2097)
decryptedData.payload.socketID;
const collaborators = new Map(this.collaborators);
const user = collaborators.get(socketId) || {}!;
user.pointer = pointer;
user.button = button;
user.selectedElementIds = selectedElementIds;
user.username = username;
collaborators.set(socketId, user);
this.excalidrawAPI.updateScene({
collaborators,
});
break;
}
case "IDLE_STATUS": {
const { userState, socketId, username } = decryptedData.payload;
const collaborators = new Map(this.collaborators);
const user = collaborators.get(socketId) || {}!;
user.userState = userState;
user.username = username;
this.excalidrawAPI.updateScene({
collaborators,
});
break;
}
}
},
);
this.portal.socket!.on("first-in-room", () => {
if (this.portal.socket) {
this.portal.socket.off("first-in-room");
}
this.initializeSocket();
scenePromise.resolve(null);
});
this.initializeIdleDetector();
this.setState({
activeRoomLink: window.location.href,
});
return scenePromise;
}; };
private initializeSocket = () => { private initializeSocket = () => {
@@ -300,12 +380,60 @@ class CollabWrapper extends PureComponent<Props, CollabState> {
private reconcileElements = ( private reconcileElements = (
elements: readonly ExcalidrawElement[], elements: readonly ExcalidrawElement[],
): ReconciledElements => { ): ReconciledElements => {
const newElements = this.portal.reconcileElements(elements); const currentElements = this.getSceneElementsIncludingDeleted();
// create a map of ids so we don't have to iterate
// over the array more than once.
const localElementMap = getElementMap(currentElements);
const appState = this.excalidrawAPI.getAppState();
// Reconcile
const newElements: readonly ExcalidrawElement[] = elements
.reduce((elements, element) => {
// if the remote element references one that's currently
// edited on local, skip it (it'll be added in the next step)
if (
element.id === appState.editingElement?.id ||
element.id === appState.resizingElement?.id ||
element.id === appState.draggingElement?.id
) {
return elements;
}
if (
localElementMap.hasOwnProperty(element.id) &&
localElementMap[element.id].version > element.version
) {
elements.push(localElementMap[element.id]);
delete localElementMap[element.id];
} else if (
localElementMap.hasOwnProperty(element.id) &&
localElementMap[element.id].version === element.version &&
localElementMap[element.id].versionNonce !== element.versionNonce
) {
// resolve conflicting edits deterministically by taking the one with the lowest versionNonce
if (localElementMap[element.id].versionNonce < element.versionNonce) {
elements.push(localElementMap[element.id]);
} else {
// it should be highly unlikely that the two versionNonces are the same. if we are
// really worried about this, we can replace the versionNonce with the socket id.
elements.push(element);
}
delete localElementMap[element.id];
} else {
elements.push(element);
delete localElementMap[element.id];
}
return elements;
}, [] as Mutable<typeof elements>)
// add local elements that weren't deleted or on remote
.concat(...Object.values(localElementMap));
// Avoid broadcasting to the rest of the collaborators the scene // Avoid broadcasting to the rest of the collaborators the scene
// we just received! // we just received!
// Note: this needs to be set before updating the scene as it // Note: this needs to be set before updating the scene as it
// syncronously calls render. // synchronously calls render.
this.setLastBroadcastedOrReceivedSceneVersion(getSceneVersion(newElements)); this.setLastBroadcastedOrReceivedSceneVersion(getSceneVersion(newElements));
return newElements as ReconciledElements; return newElements as ReconciledElements;
@@ -319,10 +447,10 @@ class CollabWrapper extends PureComponent<Props, CollabState> {
}: { init?: boolean; initFromSnapshot?: boolean } = {}, }: { init?: boolean; initFromSnapshot?: boolean } = {},
) => { ) => {
if (init || initFromSnapshot) { if (init || initFromSnapshot) {
this.excalidrawRef.current!.setScrollToCenter(elements); this.excalidrawAPI.setScrollToCenter(elements);
} }
this.excalidrawRef.current!.updateScene({ this.excalidrawAPI.updateScene({
elements, elements,
commitToHistory: !!init, commitToHistory: !!init,
}); });
@@ -331,7 +459,59 @@ class CollabWrapper extends PureComponent<Props, CollabState> {
// when we receive any messages from another peer. This UX can be pretty rough -- if you // when we receive any messages from another peer. This UX can be pretty rough -- if you
// undo, a user makes a change, and then try to redo, your element(s) will be lost. However, // undo, a user makes a change, and then try to redo, your element(s) will be lost. However,
// right now we think this is the right tradeoff. // right now we think this is the right tradeoff.
this.excalidrawRef.current!.history.clear(); this.excalidrawAPI.history.clear();
};
private onPointerMove = () => {
if (this.idleTimeoutId) {
window.clearTimeout(this.idleTimeoutId);
this.idleTimeoutId = null;
}
this.idleTimeoutId = window.setTimeout(this.reportIdle, IDLE_THRESHOLD);
if (!this.activeIntervalId) {
this.activeIntervalId = window.setInterval(
this.reportActive,
ACTIVE_THRESHOLD,
);
}
};
private onVisibilityChange = () => {
if (document.hidden) {
if (this.idleTimeoutId) {
window.clearTimeout(this.idleTimeoutId);
this.idleTimeoutId = null;
}
if (this.activeIntervalId) {
window.clearInterval(this.activeIntervalId);
this.activeIntervalId = null;
}
this.onIdleStateChange(UserIdleState.AWAY);
} else {
this.idleTimeoutId = window.setTimeout(this.reportIdle, IDLE_THRESHOLD);
this.activeIntervalId = window.setInterval(
this.reportActive,
ACTIVE_THRESHOLD,
);
this.onIdleStateChange(UserIdleState.ACTIVE);
}
};
private reportIdle = () => {
this.onIdleStateChange(UserIdleState.IDLE);
if (this.activeIntervalId) {
window.clearInterval(this.activeIntervalId);
this.activeIntervalId = null;
}
};
private reportActive = () => {
this.onIdleStateChange(UserIdleState.ACTIVE);
};
private initializeIdleDetector = () => {
document.addEventListener(EVENT.POINTER_MOVE, this.onPointerMove);
document.addEventListener(EVENT.VISIBILITY_CHANGE, this.onVisibilityChange);
}; };
setCollaborators(sockets: string[]) { setCollaborators(sockets: string[]) {
@@ -347,7 +527,7 @@ class CollabWrapper extends PureComponent<Props, CollabState> {
} }
} }
this.collaborators = collaborators; this.collaborators = collaborators;
this.excalidrawRef.current!.updateScene({ collaborators }); this.excalidrawAPI.updateScene({ collaborators });
}); });
} }
@@ -360,7 +540,7 @@ class CollabWrapper extends PureComponent<Props, CollabState> {
}; };
public getSceneElementsIncludingDeleted = () => { public getSceneElementsIncludingDeleted = () => {
return this.excalidrawRef.current!.getSceneElementsIncludingDeleted(); return this.excalidrawAPI.getSceneElementsIncludingDeleted();
}; };
onPointerUpdate = (payload: { onPointerUpdate = (payload: {
@@ -373,11 +553,12 @@ class CollabWrapper extends PureComponent<Props, CollabState> {
this.portal.broadcastMouseLocation(payload); this.portal.broadcastMouseLocation(payload);
}; };
broadcastElements = ( onIdleStateChange = (userState: UserIdleState) => {
elements: readonly ExcalidrawElement[], this.setState({ userState });
state: AppState, this.portal.broadcastIdleChange(userState);
) => { };
this.excalidrawAppState = state;
broadcastElements = (elements: readonly ExcalidrawElement[]) => {
if ( if (
getSceneVersion(elements) > getSceneVersion(elements) >
this.getLastBroadcastedOrReceivedSceneVersion() this.getLastBroadcastedOrReceivedSceneVersion()
@@ -396,7 +577,7 @@ class CollabWrapper extends PureComponent<Props, CollabState> {
this.portal.broadcastScene( this.portal.broadcastScene(
SCENE.UPDATE, SCENE.UPDATE,
getSyncableElements( getSyncableElements(
this.excalidrawRef.current!.getSceneElementsIncludingDeleted(), this.excalidrawAPI.getSceneElementsIncludingDeleted(),
), ),
true, true,
); );
@@ -425,8 +606,25 @@ class CollabWrapper extends PureComponent<Props, CollabState> {
}); });
}; };
/** PRIVATE. Use `this.getContextValue()` instead. */
private contextValue: CollabAPI | null = null;
/** Getter of context value. Returned object is stable. */
getContextValue = (): CollabAPI => {
if (!this.contextValue) {
this.contextValue = {} as CollabAPI;
}
this.contextValue.isCollaborating = () => this.isCollaborating;
this.contextValue.username = this.state.username;
this.contextValue.onPointerUpdate = this.onPointerUpdate;
this.contextValue.initializeSocketClient = this.initializeSocketClient;
this.contextValue.onCollabButtonClick = this.onCollabButtonClick;
this.contextValue.broadcastElements = this.broadcastElements;
return this.contextValue;
};
render() { render() {
const { children } = this.props;
const { modalIsShown, username, errorMessage, activeRoomLink } = this.state; const { modalIsShown, username, errorMessage, activeRoomLink } = this.state;
return ( return (
@@ -450,14 +648,11 @@ class CollabWrapper extends PureComponent<Props, CollabState> {
onClose={() => this.setState({ errorMessage: "" })} onClose={() => this.setState({ errorMessage: "" })}
/> />
)} )}
{children({ <CollabContextProvider
isCollaborating: this.state.isCollaborating, value={{
username: this.state.username, api: this.getContextValue(),
onPointerUpdate: this.onPointerUpdate, }}
initializeSocketClient: this.initializeSocketClient, />
onCollabButtonClick: this.onCollabButtonClick,
broadcastElements: this.broadcastElements,
})}
</> </>
); );
} }
+30 -71
View File
@@ -6,23 +6,21 @@ import {
import CollabWrapper from "./CollabWrapper"; import CollabWrapper from "./CollabWrapper";
import { import { getSyncableElements } from "../../packages/excalidraw/index";
getElementMap,
getSyncableElements,
} from "../../packages/excalidraw/index";
import { ExcalidrawElement } from "../../element/types"; import { ExcalidrawElement } from "../../element/types";
import { BROADCAST, SCENE } from "../app_constants"; import { BROADCAST, SCENE } from "../app_constants";
import { UserIdleState } from "./types";
class Portal { class Portal {
app: CollabWrapper; collab: CollabWrapper;
socket: SocketIOClient.Socket | null = null; socket: SocketIOClient.Socket | null = null;
socketInitialized: boolean = false; // we don't want the socket to emit any updates until it is fully initialized socketInitialized: boolean = false; // we don't want the socket to emit any updates until it is fully initialized
roomId: string | null = null; roomId: string | null = null;
roomKey: string | null = null; roomKey: string | null = null;
broadcastedElementVersions: Map<string, number> = new Map(); broadcastedElementVersions: Map<string, number> = new Map();
constructor(app: CollabWrapper) { constructor(collab: CollabWrapper) {
this.app = app; this.collab = collab;
} }
open(socket: SocketIOClient.Socket, id: string, key: string) { open(socket: SocketIOClient.Socket, id: string, key: string) {
@@ -30,7 +28,7 @@ class Portal {
this.roomId = id; this.roomId = id;
this.roomKey = key; this.roomKey = key;
// Initialize socket listeners (moving from App) // Initialize socket listeners
this.socket.on("init-room", () => { this.socket.on("init-room", () => {
if (this.socket) { if (this.socket) {
this.socket.emit("join-room", this.roomId); this.socket.emit("join-room", this.roomId);
@@ -39,12 +37,12 @@ class Portal {
this.socket.on("new-user", async (_socketId: string) => { this.socket.on("new-user", async (_socketId: string) => {
this.broadcastScene( this.broadcastScene(
SCENE.INIT, SCENE.INIT,
getSyncableElements(this.app.getSceneElementsIncludingDeleted()), getSyncableElements(this.collab.getSceneElementsIncludingDeleted()),
/* syncAll */ true, /* syncAll */ true,
); );
}); });
this.socket.on("room-user-change", (clients: string[]) => { this.socket.on("room-user-change", (clients: string[]) => {
this.app.setCollaborators(clients); this.collab.setCollaborators(clients);
}); });
} }
@@ -125,16 +123,33 @@ class Portal {
data as SocketUpdateData, data as SocketUpdateData,
); );
if (syncAll && this.app.state.isCollaborating) { if (syncAll && this.collab.isCollaborating) {
await Promise.all([ await Promise.all([
broadcastPromise, broadcastPromise,
this.app.saveCollabRoomToFirebase(syncableElements), this.collab.saveCollabRoomToFirebase(syncableElements),
]); ]);
} else { } else {
await broadcastPromise; await broadcastPromise;
} }
}; };
broadcastIdleChange = (userState: UserIdleState) => {
if (this.socket?.id) {
const data: SocketUpdateDataSource["IDLE_STATUS"] = {
type: "IDLE_STATUS",
payload: {
socketId: this.socket.id,
userState,
username: this.collab.state.username,
},
};
return this._broadcastSocketData(
data as SocketUpdateData,
true, // volatile
);
}
};
broadcastMouseLocation = (payload: { broadcastMouseLocation = (payload: {
pointer: SocketUpdateDataSource["MOUSE_LOCATION"]["payload"]["pointer"]; pointer: SocketUpdateDataSource["MOUSE_LOCATION"]["payload"]["pointer"];
button: SocketUpdateDataSource["MOUSE_LOCATION"]["payload"]["button"]; button: SocketUpdateDataSource["MOUSE_LOCATION"]["payload"]["button"];
@@ -146,9 +161,9 @@ class Portal {
socketId: this.socket.id, socketId: this.socket.id,
pointer: payload.pointer, pointer: payload.pointer,
button: payload.button || "up", button: payload.button || "up",
selectedElementIds: selectedElementIds: this.collab.excalidrawAPI.getAppState()
this.app.excalidrawAppState?.selectedElementIds || {}, .selectedElementIds,
username: this.app.state.username, username: this.collab.state.username,
}, },
}; };
return this._broadcastSocketData( return this._broadcastSocketData(
@@ -157,62 +172,6 @@ class Portal {
); );
} }
}; };
reconcileElements = (
sceneElements: readonly ExcalidrawElement[],
): readonly ExcalidrawElement[] => {
const currentElements = this.app.getSceneElementsIncludingDeleted();
// create a map of ids so we don't have to iterate
// over the array more than once.
const localElementMap = getElementMap(currentElements);
// Reconcile
return (
sceneElements
.reduce((elements, element) => {
// if the remote element references one that's currently
// edited on local, skip it (it'll be added in the next step)
if (
element.id === this.app.excalidrawAppState?.editingElement?.id ||
element.id === this.app.excalidrawAppState?.resizingElement?.id ||
element.id === this.app.excalidrawAppState?.draggingElement?.id
) {
return elements;
}
if (
localElementMap.hasOwnProperty(element.id) &&
localElementMap[element.id].version > element.version
) {
elements.push(localElementMap[element.id]);
delete localElementMap[element.id];
} else if (
localElementMap.hasOwnProperty(element.id) &&
localElementMap[element.id].version === element.version &&
localElementMap[element.id].versionNonce !== element.versionNonce
) {
// resolve conflicting edits deterministically by taking the one with the lowest versionNonce
if (
localElementMap[element.id].versionNonce < element.versionNonce
) {
elements.push(localElementMap[element.id]);
} else {
// it should be highly unlikely that the two versionNonces are the same. if we are
// really worried about this, we can replace the versionNonce with the socket id.
elements.push(element);
}
delete localElementMap[element.id];
} else {
elements.push(element);
delete localElementMap[element.id];
}
return elements;
}, [] as Mutable<typeof sceneElements>)
// add local elements that weren't deleted or on remote
.concat(...Object.values(localElementMap))
);
};
} }
export default Portal; export default Portal;
+18 -4
View File
@@ -1,4 +1,4 @@
@import "../../css/_variables"; @import "../../css/variables.module";
.excalidraw { .excalidraw {
.RoomDialog-linkContainer { .RoomDialog-linkContainer {
@@ -7,7 +7,7 @@
} }
.RoomDialog-link { .RoomDialog-link {
color: var(--text-color-primary); color: var(--text-primary-color);
min-width: 0; min-width: 0;
flex: 1 1 auto; flex: 1 1 auto;
margin-inline-start: 1em; margin-inline-start: 1em;
@@ -32,15 +32,29 @@
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
@media #{$is-mobile-query} {
flex-direction: column;
align-items: stretch;
}
}
@media #{$is-mobile-query} {
.RoomDialog-usernameLabel {
font-weight: bold;
}
} }
.RoomDialog-username { .RoomDialog-username {
background-color: var(--input-background-color); background-color: var(--input-bg-color);
border-color: var(--input-border-color); border-color: var(--input-border-color);
appearance: none; appearance: none;
min-width: 0; min-width: 0;
flex: 1 1 auto; flex: 1 1 auto;
margin-inline-start: 1em; margin-inline-start: 1em;
@media #{$is-mobile-query} {
margin-top: 0.5em;
margin-inline-start: 0;
}
height: 2.5rem; height: 2.5rem;
font-size: 1em; font-size: 1em;
line-height: 1.5; line-height: 1.5;
@@ -53,7 +67,7 @@
} }
.Modal .RoomDialog-stopSession { .Modal .RoomDialog-stopSession {
background-color: var(--button-destructive-background-color); background-color: var(--button-destructive-bg-color);
.ToolIcon__label, .ToolIcon__label,
.ToolIcon__icon svg { .ToolIcon__icon svg {
+5 -1
View File
@@ -119,7 +119,11 @@ const RoomDialog = ({
); );
}; };
return ( return (
<Dialog small onCloseRequest={handleClose} title={t("labels.createRoom")}> <Dialog
small
onCloseRequest={handleClose}
title={t("labels.liveCollaboration")}
>
{renderRoomDialog()} {renderRoomDialog()}
</Dialog> </Dialog>
); );
+5
View File
@@ -0,0 +1,5 @@
export enum UserIdleState {
ACTIVE = "active",
AWAY = "away",
IDLE = "idle",
}
+9 -1
View File
@@ -148,6 +148,7 @@ export const saveToFirebase = async (
export const loadFromFirebase = async ( export const loadFromFirebase = async (
roomId: string, roomId: string,
roomKey: string, roomKey: string,
socket: SocketIOClient.Socket | null,
): Promise<readonly ExcalidrawElement[] | null> => { ): Promise<readonly ExcalidrawElement[] | null> => {
const firebase = await getFirebase(); const firebase = await getFirebase();
const db = firebase.firestore(); const db = firebase.firestore();
@@ -160,5 +161,12 @@ export const loadFromFirebase = async (
const storedScene = doc.data() as FirebaseStoredScene; const storedScene = doc.data() as FirebaseStoredScene;
const ciphertext = storedScene.ciphertext.toUint8Array(); const ciphertext = storedScene.ciphertext.toUint8Array();
const iv = storedScene.iv.toUint8Array(); const iv = storedScene.iv.toUint8Array();
return restoreElements(await decryptElements(roomKey, iv, ciphertext));
const elements = await decryptElements(roomKey, iv, ciphertext);
if (socket) {
firebaseSceneVersionCache.set(socket, getSceneVersion(elements));
}
return restoreElements(elements);
}; };
+27 -8
View File
@@ -4,6 +4,7 @@ import { ImportedDataState } from "../../data/types";
import { ExcalidrawElement } from "../../element/types"; import { ExcalidrawElement } from "../../element/types";
import { t } from "../../i18n"; import { t } from "../../i18n";
import { AppState } from "../../types"; import { AppState } from "../../types";
import { UserIdleState } from "../collab/types";
const byteToHex = (byte: number): string => `0${byte.toString(16)}`.slice(-2); const byteToHex = (byte: number): string => `0${byte.toString(16)}`.slice(-2);
@@ -59,6 +60,14 @@ export type SocketUpdateDataSource = {
username: string; username: string;
}; };
}; };
IDLE_STATUS: {
type: "IDLE_STATUS";
payload: {
socketId: string;
userState: UserIdleState;
username: string;
};
};
}; };
export type SocketUpdateDataIncoming = export type SocketUpdateDataIncoming =
@@ -125,17 +134,27 @@ export const decryptAESGEM = async (
}; };
export const getCollaborationLinkData = (link: string) => { export const getCollaborationLinkData = (link: string) => {
if (link.length === 0) {
return;
}
const hash = new URL(link).hash; const hash = new URL(link).hash;
return hash.match(/^#room=([a-zA-Z0-9_-]+),([a-zA-Z0-9_-]+)$/); const match = hash.match(/^#room=([a-zA-Z0-9_-]+),([a-zA-Z0-9_-]+)$/);
return match ? { roomId: match[1], roomKey: match[2] } : null;
}; };
export const generateCollaborationLink = async () => { export const generateCollaborationLinkData = async () => {
const id = await generateRandomID(); const roomId = await generateRandomID();
const key = await generateEncryptionKey(); const roomKey = await generateEncryptionKey();
return `${window.location.origin}${window.location.pathname}#room=${id},${key}`;
if (!roomKey) {
throw new Error("Couldn't generate room key");
}
return { roomId, roomKey };
};
export const getCollaborationLink = (data: {
roomId: string;
roomKey: string;
}) => {
return `${window.location.origin}${window.location.pathname}#room=${data.roomId},${data.roomKey}`;
}; };
export const getImportedKey = (key: string, usage: KeyUsage) => export const getImportedKey = (key: string, usage: KeyUsage) =>
+58 -129
View File
@@ -1,6 +1,7 @@
import LanguageDetector from "i18next-browser-languagedetector"; import LanguageDetector from "i18next-browser-languagedetector";
import React, { import React, {
useCallback, useCallback,
useContext,
useEffect, useEffect,
useLayoutEffect, useLayoutEffect,
useRef, useRef,
@@ -17,12 +18,13 @@ import {
ExcalidrawElement, ExcalidrawElement,
NonDeletedExcalidrawElement, NonDeletedExcalidrawElement,
} from "../element/types"; } from "../element/types";
import { useCallbackRefState } from "../hooks/useCallbackRefState";
import { Language, t } from "../i18n"; import { Language, t } from "../i18n";
import Excalidraw, { import Excalidraw, {
defaultLang, defaultLang,
languages, languages,
} from "../packages/excalidraw/index"; } from "../packages/excalidraw/index";
import { AppState, ExcalidrawAPIRefValue } from "../types"; import { AppState } from "../types";
import { import {
debounce, debounce,
getVersion, getVersion,
@@ -30,14 +32,16 @@ import {
resolvablePromise, resolvablePromise,
} from "../utils"; } from "../utils";
import { SAVE_TO_LOCAL_STORAGE_TIMEOUT } from "./app_constants"; import { SAVE_TO_LOCAL_STORAGE_TIMEOUT } from "./app_constants";
import CollabWrapper, { CollabAPI } from "./collab/CollabWrapper"; import CollabWrapper, {
CollabAPI,
CollabContext,
CollabContextConsumer,
} from "./collab/CollabWrapper";
import { LanguageList } from "./components/LanguageList"; import { LanguageList } from "./components/LanguageList";
import { exportToBackend, getCollaborationLinkData, loadScene } from "./data"; import { exportToBackend, getCollaborationLinkData, loadScene } from "./data";
import { loadFromFirebase } from "./data/firebase";
import { import {
importFromLocalStorage, importFromLocalStorage,
saveToLocalStorage, saveToLocalStorage,
STORAGE_KEYS,
} from "./data/localStorage"; } from "./data/localStorage";
const languageDetector = new LanguageDetector(); const languageDetector = new LanguageDetector();
@@ -49,15 +53,6 @@ languageDetector.init({
checkWhitelist: false, checkWhitelist: false,
}); });
const excalidrawRef: React.MutableRefObject<
MarkRequired<ExcalidrawAPIRefValue, "ready" | "readyPromise">
> = {
current: {
readyPromise: resolvablePromise(),
ready: false,
},
};
const saveDebounced = debounce( const saveDebounced = debounce(
(elements: readonly ExcalidrawElement[], state: AppState) => { (elements: readonly ExcalidrawElement[], state: AppState) => {
saveToLocalStorage(elements, state); saveToLocalStorage(elements, state);
@@ -69,50 +64,9 @@ const onBlur = () => {
saveDebounced.flush(); saveDebounced.flush();
}; };
const shouldForceLoadScene = (
scene: ResolutionType<typeof loadScene>,
): boolean => {
if (!scene.elements.length) {
return true;
}
const roomMatch = getCollaborationLinkData(window.location.href);
if (!roomMatch) {
return false;
}
const roomId = roomMatch[1];
let collabForceLoadFlag;
try {
collabForceLoadFlag = localStorage?.getItem(
STORAGE_KEYS.LOCAL_STORAGE_KEY_COLLAB_FORCE_FLAG,
);
} catch {}
if (collabForceLoadFlag) {
try {
const {
room: previousRoom,
timestamp,
}: { room: string; timestamp: number } = JSON.parse(collabForceLoadFlag);
// if loading same room as the one previously unloaded within 15sec
// force reload without prompting
if (previousRoom === roomId && Date.now() - timestamp < 15000) {
return true;
}
} catch {}
}
return false;
};
type Scene = ImportedDataState & { commitToHistory: boolean };
const initializeScene = async (opts: { const initializeScene = async (opts: {
resetScene: ExcalidrawImperativeAPI["resetScene"]; collabAPI: CollabAPI;
initializeSocketClient: CollabAPI["initializeSocketClient"]; }): Promise<ImportedDataState | null> => {
}): Promise<Scene | null> => {
const searchParams = new URLSearchParams(window.location.search); const searchParams = new URLSearchParams(window.location.search);
const id = searchParams.get("id"); const id = searchParams.get("id");
const jsonMatch = window.location.hash.match( const jsonMatch = window.location.hash.match(
@@ -123,11 +77,15 @@ const initializeScene = async (opts: {
let scene = await loadScene(null, null, initialData); let scene = await loadScene(null, null, initialData);
let isCollabScene = !!getCollaborationLinkData(window.location.href); let roomLinkData = getCollaborationLinkData(window.location.href);
const isExternalScene = !!(id || jsonMatch || isCollabScene); const isExternalScene = !!(id || jsonMatch || roomLinkData);
if (isExternalScene) { if (isExternalScene) {
if ( if (
shouldForceLoadScene(scene) || // don't prompt if scene is empty
!scene.elements.length ||
// don't prompt for collab scenes because we don't override local storage
roomLinkData ||
// otherwise, prompt whether user wants to override current scene
window.confirm(t("alerts.loadSceneOverridePrompt")) window.confirm(t("alerts.loadSceneOverridePrompt"))
) { ) {
// Backwards compatibility with legacy url format // Backwards compatibility with legacy url format
@@ -136,7 +94,7 @@ const initializeScene = async (opts: {
} else if (jsonMatch) { } else if (jsonMatch) {
scene = await loadScene(jsonMatch[1], jsonMatch[2], initialData); scene = await loadScene(jsonMatch[1], jsonMatch[2], initialData);
} }
if (!isCollabScene) { if (!roomLinkData) {
window.history.replaceState({}, APP_NAME, window.location.origin); window.history.replaceState({}, APP_NAME, window.location.origin);
} }
} else { } else {
@@ -153,45 +111,19 @@ const initializeScene = async (opts: {
}); });
} }
isCollabScene = false; roomLinkData = null;
window.history.replaceState({}, APP_NAME, window.location.origin); window.history.replaceState({}, APP_NAME, window.location.origin);
} }
} }
if (isCollabScene) { if (roomLinkData) {
// when joining a room we don't want user's local scene data to be merged return opts.collabAPI.initializeSocketClient(roomLinkData);
// into the remote scene
opts.resetScene();
const scenePromise = opts.initializeSocketClient();
try {
const [, roomId, roomKey] = getCollaborationLinkData(
window.location.href,
)!;
const elements = await loadFromFirebase(roomId, roomKey);
if (elements) {
return {
elements,
commitToHistory: true,
};
}
return {
...(await scenePromise),
commitToHistory: true,
};
} catch (error) {
// log the error and move on. other peers will sync us the scene.
console.error(error);
}
return null;
} else if (scene) { } else if (scene) {
return scene; return scene;
} }
return null; return null;
}; };
function ExcalidrawWrapper(props: { collab: CollabAPI }) { function ExcalidrawWrapper() {
// dimensions // dimensions
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@@ -226,38 +158,35 @@ function ExcalidrawWrapper(props: { collab: CollabAPI }) {
initialStatePromiseRef.current.promise = resolvablePromise<ImportedDataState | null>(); initialStatePromiseRef.current.promise = resolvablePromise<ImportedDataState | null>();
} }
const { collab } = props;
useEffect(() => { useEffect(() => {
// Delayed so that the app has a time to load the latest SW // Delayed so that the app has a time to load the latest SW
setTimeout(() => { setTimeout(() => {
trackEvent("load", "version", getVersion()); trackEvent("load", "version", getVersion());
}, VERSION_TIMEOUT); }, VERSION_TIMEOUT);
}, []);
excalidrawRef.current!.readyPromise.then((excalidrawApi) => { const [
initializeScene({ excalidrawAPI,
resetScene: excalidrawApi.resetScene, excalidrawRefCallback,
initializeSocketClient: collab.initializeSocketClient, ] = useCallbackRefState<ExcalidrawImperativeAPI>();
}).then((scene) => {
initialStatePromiseRef.current.promise.resolve(scene); const collabAPI = useContext(CollabContext)?.api;
});
useEffect(() => {
if (!collabAPI || !excalidrawAPI) {
return;
}
initializeScene({ collabAPI }).then((scene) => {
initialStatePromiseRef.current.promise.resolve(scene);
}); });
const onHashChange = (_: HashChangeEvent) => { const onHashChange = (_: HashChangeEvent) => {
const api = excalidrawRef.current!; initializeScene({ collabAPI }).then((scene) => {
if (!api.ready) { if (scene) {
return; excalidrawAPI.updateScene(scene);
} }
if (window.location.hash.length > 1) { });
initializeScene({
resetScene: api.resetScene,
initializeSocketClient: collab.initializeSocketClient,
}).then((scene) => {
if (scene) {
api.updateScene(scene);
}
});
}
}; };
const titleTimeout = setTimeout( const titleTimeout = setTimeout(
@@ -273,7 +202,7 @@ function ExcalidrawWrapper(props: { collab: CollabAPI }) {
window.removeEventListener(EVENT.BLUR, onBlur, false); window.removeEventListener(EVENT.BLUR, onBlur, false);
clearTimeout(titleTimeout); clearTimeout(titleTimeout);
}; };
}, [collab.initializeSocketClient]); }, [collabAPI, excalidrawAPI]);
useEffect(() => { useEffect(() => {
languageDetector.cacheUserLanguage(langCode); languageDetector.cacheUserLanguage(langCode);
@@ -283,9 +212,13 @@ function ExcalidrawWrapper(props: { collab: CollabAPI }) {
elements: readonly ExcalidrawElement[], elements: readonly ExcalidrawElement[],
appState: AppState, appState: AppState,
) => { ) => {
saveDebounced(elements, appState); if (collabAPI?.isCollaborating()) {
if (collab.isCollaborating) { collabAPI.broadcastElements(elements);
collab.broadcastElements(elements, appState); } else {
// collab scenes are persisted to the server, so we don't have to persist
// them locally, which has the added benefit of not overwriting whatever
// the user was working on before joining
saveDebounced(elements, appState);
} }
}; };
@@ -343,19 +276,19 @@ function ExcalidrawWrapper(props: { collab: CollabAPI }) {
return ( return (
<> <>
<Excalidraw <Excalidraw
ref={excalidrawRef} ref={excalidrawRefCallback}
onChange={onChange} onChange={onChange}
width={dimensions.width} width={dimensions.width}
height={dimensions.height} height={dimensions.height}
initialData={initialStatePromiseRef.current.promise} initialData={initialStatePromiseRef.current.promise}
user={{ name: collab.username }} onCollabButtonClick={collabAPI?.onCollabButtonClick}
onCollabButtonClick={collab.onCollabButtonClick} isCollaborating={collabAPI?.isCollaborating()}
isCollaborating={collab.isCollaborating} onPointerUpdate={collabAPI?.onPointerUpdate}
onPointerUpdate={collab.onPointerUpdate}
onExportToBackend={onExportToBackend} onExportToBackend={onExportToBackend}
renderFooter={renderFooter} renderFooter={renderFooter}
langCode={langCode} langCode={langCode}
/> />
{excalidrawAPI && <CollabWrapper excalidrawAPI={excalidrawAPI} />}
{errorMessage && ( {errorMessage && (
<ErrorDialog <ErrorDialog
message={errorMessage} message={errorMessage}
@@ -369,13 +302,9 @@ function ExcalidrawWrapper(props: { collab: CollabAPI }) {
export default function ExcalidrawApp() { export default function ExcalidrawApp() {
return ( return (
<TopErrorBoundary> <TopErrorBoundary>
<CollabWrapper <CollabContextConsumer>
excalidrawRef={ <ExcalidrawWrapper />
excalidrawRef as React.MutableRefObject<ExcalidrawImperativeAPI> </CollabContextConsumer>
}
>
{(collab) => <ExcalidrawWrapper collab={collab} />}
</CollabWrapper>
</TopErrorBoundary> </TopErrorBoundary>
); );
} }
+2 -3
View File
@@ -1,11 +1,10 @@
import { PointerCoords } from "./types"; import { PointerCoords } from "./types";
import { normalizeScroll } from "./scene";
export const getCenter = (pointers: Map<number, PointerCoords>) => { export const getCenter = (pointers: Map<number, PointerCoords>) => {
const allCoords = Array.from(pointers.values()); const allCoords = Array.from(pointers.values());
return { return {
x: normalizeScroll(sum(allCoords, (coords) => coords.x) / allCoords.length), x: sum(allCoords, (coords) => coords.x) / allCoords.length,
y: normalizeScroll(sum(allCoords, (coords) => coords.y) / allCoords.length), y: sum(allCoords, (coords) => coords.y) / allCoords.length,
}; };
}; };
+12 -1
View File
@@ -85,6 +85,17 @@ type ForwardRef<T, P = any> = Parameters<
// --------------------------------------------------------------------------— // --------------------------------------------------------------------------—
interface Blob { interface Blob {
handle?: import("browser-nativefs").FileSystemHandle; handle?: import("browser-fs-acces").FileSystemHandle;
name?: string; name?: string;
} }
declare module "worker-loader!*" {
// You need to change `Worker`, if you specified a different value for the `workerType` option
class WebpackWorker extends Worker {
constructor();
}
// Uncomment this if you set the `esModule` option to `false`
// export = WebpackWorker;
export default WebpackWorker;
}
+7
View File
@@ -0,0 +1,7 @@
import { useCallback, useState } from "react";
export const useCallbackRefState = <T>() => {
const [refValue, setRefValue] = useState<T | null>(null);
const refCallback = useCallback((value: T | null) => setRefValue(value), []);
return [refValue, refCallback] as const;
};
+29 -12
View File
@@ -1,5 +1,6 @@
import fallbackLangData from "./locales/en.json"; import fallbackLangData from "./locales/en.json";
import percentages from "./locales/percentages.json"; import percentages from "./locales/percentages.json";
import { ENV } from "./constants";
const COMPLETION_THRESHOLD = 85; const COMPLETION_THRESHOLD = 85;
@@ -27,6 +28,7 @@ const allLanguages: Language[] = [
{ code: "id-ID", label: "Bahasa Indonesia" }, { code: "id-ID", label: "Bahasa Indonesia" },
{ code: "it-IT", label: "Italiano" }, { code: "it-IT", label: "Italiano" },
{ code: "ja-JP", label: "日本語" }, { code: "ja-JP", label: "日本語" },
{ code: "kab-KAB", label: "Taqbaylit" },
{ code: "ko-KR", label: "한국어" }, { code: "ko-KR", label: "한국어" },
{ code: "my-MM", label: "Burmese" }, { code: "my-MM", label: "Burmese" },
{ code: "nb-NO", label: "Norsk bokmål" }, { code: "nb-NO", label: "Norsk bokmål" },
@@ -54,25 +56,33 @@ export const languages: Language[] = allLanguages
COMPLETION_THRESHOLD, COMPLETION_THRESHOLD,
); );
const TEST_LANG_CODE = "__test__";
if (process.env.NODE_ENV === ENV.DEVELOPMENT) {
languages.unshift(
{ code: TEST_LANG_CODE, label: "test language" },
{
code: `${TEST_LANG_CODE}.rtl`,
label: "\u{202a}test language (rtl)\u{202c}",
rtl: true,
},
);
}
let currentLang: Language = defaultLang; let currentLang: Language = defaultLang;
let currentLangData = {}; let currentLangData = {};
export const setLanguage = async (lang: Language) => { export const setLanguage = async (lang: Language) => {
currentLang = lang; currentLang = lang;
document.documentElement.dir = currentLang.rtl ? "rtl" : "ltr"; document.documentElement.dir = currentLang.rtl ? "rtl" : "ltr";
document.documentElement.lang = currentLang.code;
currentLangData = await import( if (lang.code.startsWith(TEST_LANG_CODE)) {
/* webpackChunkName: "i18n-[request]" */ `./locales/${currentLang.code}.json` currentLangData = {};
); } else {
}; currentLangData = await import(
/* webpackChunkName: "i18n-[request]" */ `./locales/${currentLang.code}.json`
export const setLanguageFirstTime = async (lang: Language) => { );
currentLang = lang; }
document.documentElement.dir = currentLang.rtl ? "rtl" : "ltr";
currentLangData = await import(
/* webpackChunkName: "i18n-[request]" */ `./locales/${currentLang.code}.json`
);
}; };
export const getLanguage = () => currentLang; export const getLanguage = () => currentLang;
@@ -92,6 +102,13 @@ const findPartsForData = (data: any, parts: string[]) => {
}; };
export const t = (path: string, replacement?: { [key: string]: string }) => { export const t = (path: string, replacement?: { [key: string]: string }) => {
if (currentLang.code.startsWith(TEST_LANG_CODE)) {
const name = replacement
? `${path}(${JSON.stringify(replacement).slice(1, -1)})`
: path;
return `\u{202a}[[${name}]]\u{202c}`;
}
const parts = path.split("."); const parts = path.split(".");
let translation = let translation =
findPartsForData(currentLangData, parts) || findPartsForData(currentLangData, parts) ||
+14 -10
View File
@@ -1,7 +1,18 @@
import React, { useState, useEffect, useRef, useContext } from "react"; import React, { useState, useEffect, useRef, useContext } from "react";
import variables from "./css/variables.module.scss";
const context = React.createContext(false); const context = React.createContext(false);
const getIsMobileMatcher = () => {
return window.matchMedia
? window.matchMedia(variables.isMobileQuery)
: (({
matches: false,
addListener: () => {},
removeListener: () => {},
} as any) as MediaQueryList);
};
export const IsMobileProvider = ({ export const IsMobileProvider = ({
children, children,
}: { }: {
@@ -9,16 +20,7 @@ export const IsMobileProvider = ({
}) => { }) => {
const query = useRef<MediaQueryList>(); const query = useRef<MediaQueryList>();
if (!query.current) { if (!query.current) {
query.current = window.matchMedia query.current = getIsMobileMatcher();
? window.matchMedia(
// keep up to date with _variables.scss
"(max-width: 640px), (max-height: 500px) and (max-width: 1000px)",
)
: (({
matches: false,
addListener: () => {},
removeListener: () => {},
} as any) as MediaQueryList);
} }
const [isMobile, setMobile] = useState(query.current.matches); const [isMobile, setMobile] = useState(query.current.matches);
@@ -31,6 +33,8 @@ export const IsMobileProvider = ({
return <context.Provider value={isMobile}>{children}</context.Provider>; return <context.Provider value={isMobile}>{children}</context.Provider>;
}; };
export const isMobile = () => getIsMobileMatcher().matches;
export default function useIsMobile() { export default function useIsMobile() {
return useContext(context); return useContext(context);
} }
+8 -1
View File
@@ -1,4 +1,7 @@
export const isDarwin = /Mac|iPod|iPhone|iPad/.test(window.navigator.platform); export const isDarwin = /Mac|iPod|iPhone|iPad/.test(
globalThis.navigator.platform,
);
export const isWindows = /^Win/.test(globalThis.navigator.platform);
export const CODES = { export const CODES = {
EQUAL: "Equal", EQUAL: "Equal",
@@ -18,7 +21,9 @@ export const CODES = {
F: "KeyF", F: "KeyF",
H: "KeyH", H: "KeyH",
V: "KeyV", V: "KeyV",
X: "KeyX",
Z: "KeyZ", Z: "KeyZ",
R: "KeyR",
} as const; } as const;
export const KEYS = { export const KEYS = {
@@ -40,6 +45,7 @@ export const KEYS = {
D: "d", D: "d",
E: "e", E: "e",
L: "l", L: "l",
M: "m",
O: "o", O: "o",
P: "p", P: "p",
Q: "q", Q: "q",
@@ -48,6 +54,7 @@ export const KEYS = {
T: "t", T: "t",
V: "v", V: "v",
X: "x", X: "x",
Y: "y",
Z: "z", Z: "z",
} as const; } as const;
+17 -8
View File
@@ -68,7 +68,7 @@
"layers": "الطبقات", "layers": "الطبقات",
"actions": "الإجراءات", "actions": "الإجراءات",
"language": "اللغة", "language": "اللغة",
"createRoom": "مشاركة الجلسة مباشرة", "liveCollaboration": "",
"duplicateSelection": "تكرار", "duplicateSelection": "تكرار",
"untitled": "غير معنون", "untitled": "غير معنون",
"name": "الاسم", "name": "الاسم",
@@ -77,12 +77,12 @@
"group": "تحديد مجموعة", "group": "تحديد مجموعة",
"ungroup": "إلغاء تحديد مجموعة", "ungroup": "إلغاء تحديد مجموعة",
"collaborators": "المتعاونون", "collaborators": "المتعاونون",
"gridMode": "وضع الشبكة", "showGrid": "",
"addToLibrary": "أضف إلى المكتبة", "addToLibrary": "أضف إلى المكتبة",
"removeFromLibrary": "حذف من المكتبة", "removeFromLibrary": "حذف من المكتبة",
"libraryLoadingMessage": "جارٍ تحميل المكتبة...", "libraryLoadingMessage": "جارٍ تحميل المكتبة",
"libraries": "تصفح المكتبات", "libraries": "تصفح المكتبات",
"loadingScene": "جاري تحميل المشهد...", "loadingScene": "جاري تحميل المشهد",
"align": "محاذاة", "align": "محاذاة",
"alignTop": "محاذاة إلى اﻷعلى", "alignTop": "محاذاة إلى اﻷعلى",
"alignBottom": "محاذاة إلى اﻷسفل", "alignBottom": "محاذاة إلى اﻷسفل",
@@ -91,7 +91,8 @@
"centerVertically": "توسيط عمودي", "centerVertically": "توسيط عمودي",
"centerHorizontally": "توسيط أفقي", "centerHorizontally": "توسيط أفقي",
"distributeHorizontally": "التوزيع الأفقي", "distributeHorizontally": "التوزيع الأفقي",
"distributeVertically": "التوزيع عمودياً" "distributeVertically": "التوزيع عمودياً",
"viewMode": ""
}, },
"buttons": { "buttons": {
"clearReset": "إعادة تعيين اللوحة", "clearReset": "إعادة تعيين اللوحة",
@@ -116,7 +117,7 @@
"edit": "تعديل", "edit": "تعديل",
"undo": "تراجع", "undo": "تراجع",
"redo": "إعادة تنفيذ", "redo": "إعادة تنفيذ",
"roomDialog": "بدء المشاركة الحية", "resetLibrary": "",
"createNewRoom": "إنشاء غرفة جديدة", "createNewRoom": "إنشاء غرفة جديدة",
"fullScreen": "شاشة كاملة", "fullScreen": "شاشة كاملة",
"darkMode": "الوضع المظلم", "darkMode": "الوضع المظلم",
@@ -135,10 +136,12 @@
"decryptFailed": "تعذر فك تشفير البيانات.", "decryptFailed": "تعذر فك تشفير البيانات.",
"uploadedSecurly": "تم تأمين التحميل بتشفير النهاية إلى النهاية، مما يعني أن خادوم Excalidraw والأطراف الثالثة لا يمكنها قراءة المحتوى.", "uploadedSecurly": "تم تأمين التحميل بتشفير النهاية إلى النهاية، مما يعني أن خادوم Excalidraw والأطراف الثالثة لا يمكنها قراءة المحتوى.",
"loadSceneOverridePrompt": "تحميل الرسم الخارجي سيحل محل المحتوى الموجود لديك. هل ترغب في المتابعة؟", "loadSceneOverridePrompt": "تحميل الرسم الخارجي سيحل محل المحتوى الموجود لديك. هل ترغب في المتابعة؟",
"collabStopOverridePrompt": "",
"errorLoadingLibrary": "حصل خطأ أثناء تحميل مكتبة الطرف الثالث.", "errorLoadingLibrary": "حصل خطأ أثناء تحميل مكتبة الطرف الثالث.",
"confirmAddLibrary": "هذا سيضيف {{numShapes}} شكل إلى مكتبتك. هل أنت متأكد؟", "confirmAddLibrary": "هذا سيضيف {{numShapes}} شكل إلى مكتبتك. هل أنت متأكد؟",
"imageDoesNotContainScene": "استيراد الصور غير مدعوم في الوقت الراهن.\n\nهل تريد استيراد مشهد؟ لا يبدو أن هذه الصورة تحتوي على أي بيانات مشهد. هل قمت بسماح هذا أثناء التصدير؟", "imageDoesNotContainScene": "استيراد الصور غير مدعوم في الوقت الراهن.\n\nهل تريد استيراد مشهد؟ لا يبدو أن هذه الصورة تحتوي على أي بيانات مشهد. هل قمت بسماح هذا أثناء التصدير؟",
"cannotRestoreFromImage": "تعذر استعادة المشهد من ملف الصورة" "cannotRestoreFromImage": "تعذر استعادة المشهد من ملف الصورة",
"resetLibrary": ""
}, },
"toolBar": { "toolBar": {
"selection": "تحديد", "selection": "تحديد",
@@ -233,10 +236,16 @@
"storage": "التخزين", "storage": "التخزين",
"title": "إحصائيات للمهووسين", "title": "إحصائيات للمهووسين",
"total": "المجموع", "total": "المجموع",
"version": "",
"versionCopy": "",
"versionNotAvailable": "",
"width": "العرض" "width": "العرض"
}, },
"toast": { "toast": {
"copyStyles": "", "copyStyles": "",
"copyToClipboardAsPng": "" "copyToClipboard": "",
"copyToClipboardAsPng": "",
"fileSaved": "",
"fileSavedToFilename": ""
} }
} }
+41 -32
View File
@@ -8,11 +8,11 @@
"cut": "Изрежи", "cut": "Изрежи",
"copy": "Копирай", "copy": "Копирай",
"copyAsPng": "Копиране в клипборда", "copyAsPng": "Копиране в клипборда",
"copyAsSvg": "Копиране в клипборда", "copyAsSvg": "Копирано в клипборда като SVG",
"bringForward": "Преместване на~пред", "bringForward": "Преместване напред",
"sendToBack": "Изнасяне назад", "sendToBack": "Изнасяне назад",
"bringToFront": "~Изнасяне отпред", "bringToFront": "Изнасяне отпред",
"sendBackward": "Изпрати назад", "sendBackward": "Изпрати отзад",
"delete": "Изтрий", "delete": "Изтрий",
"copyStyles": "Копирайте стилове", "copyStyles": "Копирайте стилове",
"pasteStyles": "Постави стилове", "pasteStyles": "Постави стилове",
@@ -68,7 +68,7 @@
"layers": "Слоеве", "layers": "Слоеве",
"actions": "Действия", "actions": "Действия",
"language": "Език", "language": "Език",
"createRoom": "Споделете сесия за сътрудничество на живо", "liveCollaboration": "",
"duplicateSelection": "Дублирай", "duplicateSelection": "Дублирай",
"untitled": "Неозаглавено", "untitled": "Неозаглавено",
"name": "Име", "name": "Име",
@@ -77,12 +77,12 @@
"group": "Групирай селекцията", "group": "Групирай селекцията",
"ungroup": "Спри групирането на селекцията", "ungroup": "Спри групирането на селекцията",
"collaborators": "Сътрудници", "collaborators": "Сътрудници",
"gridMode": "Решетъчен режим", "showGrid": "Показване на мрежа",
"addToLibrary": "Добавяне към библиотеката", "addToLibrary": "Добавяне към библиотеката",
"removeFromLibrary": "Премахване от библиотеката", "removeFromLibrary": "Премахване от библиотеката",
"libraryLoadingMessage": "Зареждане на библиотеката...", "libraryLoadingMessage": "Зареждане на библиотеката",
"libraries": "Разглеждане на библиотеките", "libraries": "Разглеждане на библиотеките",
"loadingScene": "Зареждане на сцена...", "loadingScene": "Зареждане на сцена",
"align": "Подравняване", "align": "Подравняване",
"alignTop": "Подравняване отгоре", "alignTop": "Подравняване отгоре",
"alignBottom": "Подравняване отдолу", "alignBottom": "Подравняване отдолу",
@@ -91,7 +91,8 @@
"centerVertically": "Центрирай вертикално", "centerVertically": "Центрирай вертикално",
"centerHorizontally": "Центрирай хоризонтално", "centerHorizontally": "Центрирай хоризонтално",
"distributeHorizontally": "Разпредели хоризонтално", "distributeHorizontally": "Разпредели хоризонтално",
"distributeVertically": "Разпредели вертикално" "distributeVertically": "Разпредели вертикално",
"viewMode": "Изглед"
}, },
"buttons": { "buttons": {
"clearReset": "Нулиране на платно", "clearReset": "Нулиране на платно",
@@ -116,7 +117,7 @@
"edit": "Редактиране", "edit": "Редактиране",
"undo": "Отмяна", "undo": "Отмяна",
"redo": "Повтори", "redo": "Повтори",
"roomDialog": "Започнете сътрудничество на живо", "resetLibrary": "",
"createNewRoom": "Създай нова стая", "createNewRoom": "Създай нова стая",
"fullScreen": "На цял екран", "fullScreen": "На цял екран",
"darkMode": "Тъмен режим", "darkMode": "Тъмен режим",
@@ -135,10 +136,12 @@
"decryptFailed": "Данните не можаха да се дешифрират.", "decryptFailed": "Данните не можаха да се дешифрират.",
"uploadedSecurly": "Качването е защитено с криптиране от край до край, което означава, че сървърът Excalidraw и трети страни не могат да четат съдържанието.", "uploadedSecurly": "Качването е защитено с криптиране от край до край, което означава, че сървърът Excalidraw и трети страни не могат да четат съдържанието.",
"loadSceneOverridePrompt": "Зареждането на външна рисунка ще презапише настоящото ви съдържание. Желаете ли да продължите?", "loadSceneOverridePrompt": "Зареждането на външна рисунка ще презапише настоящото ви съдържание. Желаете ли да продължите?",
"collabStopOverridePrompt": "Прекратяването на сесията ще презапише предишната, локално запазена, рисунка. Сигурни ли сте?\n\n(Ако искате да продължите с локалната рисунка, просто затворете таба на браузъра.)",
"errorLoadingLibrary": "Възникна грешка при зареждането на външна библиотека.", "errorLoadingLibrary": "Възникна грешка при зареждането на външна библиотека.",
"confirmAddLibrary": "Ще се добавят {{numShapes}} фигура(и) във вашата библиотека. Сигурни ли сте?", "confirmAddLibrary": "Ще се добавят {{numShapes}} фигура(и) във вашата библиотека. Сигурни ли сте?",
"imageDoesNotContainScene": "Импортирането на картинки не се поддържва в момента.\n\nИскате да импортнете сцена? Тази картинка не съдържа данни от сцена. Разрешили ли сте последното при експортирането?", "imageDoesNotContainScene": "Импортирането на картинки не се поддържва в момента.\n\nИскате да импортнете сцена? Тази картинка не съдържа данни от сцена. Разрешили ли сте последното при експортирането?",
"cannotRestoreFromImage": "Не може да бъде възстановена сцена от този файл" "cannotRestoreFromImage": "Не може да бъде възстановена сцена от този файл",
"resetLibrary": ""
}, },
"toolBar": { "toolBar": {
"selection": "Селекция", "selection": "Селекция",
@@ -200,25 +203,25 @@
"title": "Грешка" "title": "Грешка"
}, },
"helpDialog": { "helpDialog": {
"blog": "", "blog": "Прочетете нашия блог",
"click": "", "click": "клик",
"curvedArrow": "", "curvedArrow": "Извита стрелка",
"curvedLine": "", "curvedLine": "Извита линия",
"documentation": "", "documentation": "Документация",
"drag": "", "drag": "плъзнете",
"editor": "", "editor": "Редактор",
"github": "", "github": "Намерихте проблем? Изпратете",
"howto": "", "howto": "Следвайте нашите ръководства",
"or": "", "or": "или",
"preventBinding": "", "preventBinding": "Спри прилепяне на стрелките",
"shapes": "", "shapes": "Фигури",
"shortcuts": "", "shortcuts": "Клавиши за бърз достъп",
"textFinish": "", "textFinish": "Завършете редактирането (текст)",
"textNewLine": "", "textNewLine": "Добавяне на нов ред (текст)",
"title": "", "title": "Помощ",
"view": "", "view": "Преглед",
"zoomToFit": "", "zoomToFit": "Приближи докато се виждат всички елементи",
"zoomToSelection": "" "zoomToSelection": "Приближи селекцията"
}, },
"encrypted": { "encrypted": {
"tooltip": "Вашите рисунки са криптирани от край до край, така че сървърите на Excalidraw няма да могат да ги виждат." "tooltip": "Вашите рисунки са криптирани от край до край, така че сървърите на Excalidraw няма да могат да ги виждат."
@@ -233,10 +236,16 @@
"storage": "Съхранение на данни", "storage": "Съхранение на данни",
"title": "Статистика за хакери", "title": "Статистика за хакери",
"total": "Общо", "total": "Общо",
"version": "Версия",
"versionCopy": "Настисни за да копираш",
"versionNotAvailable": "Версията не е налична",
"width": "Широчина" "width": "Широчина"
}, },
"toast": { "toast": {
"copyStyles": "", "copyStyles": "Копирани стилове.",
"copyToClipboardAsPng": "" "copyToClipboard": "Копирано в клипборда.",
"copyToClipboardAsPng": "Копирано в клипборда като PNG.",
"fileSaved": "",
"fileSavedToFilename": ""
} }
} }
+17 -8
View File
@@ -68,7 +68,7 @@
"layers": "Capes", "layers": "Capes",
"actions": "Accions", "actions": "Accions",
"language": "Llengua", "language": "Llengua",
"createRoom": "Compartir una sessió de col·laboració en directe", "liveCollaboration": "",
"duplicateSelection": "Duplicar", "duplicateSelection": "Duplicar",
"untitled": "Sense títol", "untitled": "Sense títol",
"name": "Nom", "name": "Nom",
@@ -77,12 +77,12 @@
"group": "Agrupar la selecció", "group": "Agrupar la selecció",
"ungroup": "Desagrupar la selecció", "ungroup": "Desagrupar la selecció",
"collaborators": "Col·laboradors", "collaborators": "Col·laboradors",
"gridMode": "Mode quadrícula", "showGrid": "",
"addToLibrary": "Afegir a la biblioteca", "addToLibrary": "Afegir a la biblioteca",
"removeFromLibrary": "Eliminar de la biblioteca", "removeFromLibrary": "Eliminar de la biblioteca",
"libraryLoadingMessage": "Carregant la biblioteca...", "libraryLoadingMessage": "Carregant la biblioteca",
"libraries": "Explorar biblioteques", "libraries": "Explorar biblioteques",
"loadingScene": "Carregant escena...", "loadingScene": "Carregant escena",
"align": "Alinear", "align": "Alinear",
"alignTop": "Alinear a dalt", "alignTop": "Alinear a dalt",
"alignBottom": "Alinear a baix", "alignBottom": "Alinear a baix",
@@ -91,7 +91,8 @@
"centerVertically": "Centrar verticalment", "centerVertically": "Centrar verticalment",
"centerHorizontally": "Centrar horitzontalment", "centerHorizontally": "Centrar horitzontalment",
"distributeHorizontally": "Distribuir horitzontalment", "distributeHorizontally": "Distribuir horitzontalment",
"distributeVertically": "Distribuir verticalment" "distributeVertically": "Distribuir verticalment",
"viewMode": ""
}, },
"buttons": { "buttons": {
"clearReset": "Netejar el llenç", "clearReset": "Netejar el llenç",
@@ -116,7 +117,7 @@
"edit": "Editar", "edit": "Editar",
"undo": "Desfer", "undo": "Desfer",
"redo": "Refer", "redo": "Refer",
"roomDialog": "Començar col·laboració en directe", "resetLibrary": "",
"createNewRoom": "Crear sala nova", "createNewRoom": "Crear sala nova",
"fullScreen": "Pantalla completa", "fullScreen": "Pantalla completa",
"darkMode": "Mode fosc", "darkMode": "Mode fosc",
@@ -135,10 +136,12 @@
"decryptFailed": "No s'ha pogut desencriptar.", "decryptFailed": "No s'ha pogut desencriptar.",
"uploadedSecurly": "La càrrega s'ha assegurat amb xifratge punta a punta, cosa que significa que el servidor Excalidraw i tercers no poden llegir el contingut.", "uploadedSecurly": "La càrrega s'ha assegurat amb xifratge punta a punta, cosa que significa que el servidor Excalidraw i tercers no poden llegir el contingut.",
"loadSceneOverridePrompt": "Si carregas aquest dibuix extern, substituirá el que tens. Vols continuar?", "loadSceneOverridePrompt": "Si carregas aquest dibuix extern, substituirá el que tens. Vols continuar?",
"collabStopOverridePrompt": "",
"errorLoadingLibrary": "S'ha produït un error en carregar la biblioteca de tercers.", "errorLoadingLibrary": "S'ha produït un error en carregar la biblioteca de tercers.",
"confirmAddLibrary": "Això afegirà {{numShapes}} forma(es) a la vostra biblioteca. Estàs segur?", "confirmAddLibrary": "Això afegirà {{numShapes}} forma(es) a la vostra biblioteca. Estàs segur?",
"imageDoesNotContainScene": "En aquest moment no sadmet la importació dimatges.\n\nVolies importar una escena? Sembla que aquesta imatge no conté cap dada descena. Ho has activat durant l'exportació?", "imageDoesNotContainScene": "En aquest moment no sadmet la importació dimatges.\n\nVolies importar una escena? Sembla que aquesta imatge no conté cap dada descena. Ho has activat durant l'exportació?",
"cannotRestoreFromImage": "Lescena no sha pogut restaurar des daquest fitxer dimatge" "cannotRestoreFromImage": "Lescena no sha pogut restaurar des daquest fitxer dimatge",
"resetLibrary": ""
}, },
"toolBar": { "toolBar": {
"selection": "Selecció", "selection": "Selecció",
@@ -233,10 +236,16 @@
"storage": "Emmagatzematge", "storage": "Emmagatzematge",
"title": "Estadístiques per nerds", "title": "Estadístiques per nerds",
"total": "Total", "total": "Total",
"version": "",
"versionCopy": "",
"versionNotAvailable": "",
"width": "Amplada" "width": "Amplada"
}, },
"toast": { "toast": {
"copyStyles": "", "copyStyles": "",
"copyToClipboardAsPng": "" "copyToClipboard": "",
"copyToClipboardAsPng": "",
"fileSaved": "",
"fileSavedToFilename": ""
} }
} }
+19 -10
View File
@@ -68,7 +68,7 @@
"layers": "Ebenen", "layers": "Ebenen",
"actions": "Aktionen", "actions": "Aktionen",
"language": "Sprache", "language": "Sprache",
"createRoom": "Live-Kollaborationssitzung teilen", "liveCollaboration": "Live-Zusammenarbeit",
"duplicateSelection": "Duplizieren", "duplicateSelection": "Duplizieren",
"untitled": "Unbenannt", "untitled": "Unbenannt",
"name": "Name", "name": "Name",
@@ -76,13 +76,13 @@
"madeWithExcalidraw": "Made with Excalidraw", "madeWithExcalidraw": "Made with Excalidraw",
"group": "Auswahl gruppieren", "group": "Auswahl gruppieren",
"ungroup": "Gruppierung aufheben", "ungroup": "Gruppierung aufheben",
"collaborators": "Mitarbeitende", "collaborators": "Kollaboratoren",
"gridMode": "Rastermodus", "showGrid": "Raster anzeigen",
"addToLibrary": "Zur Bibliothek hinzufügen", "addToLibrary": "Zur Bibliothek hinzufügen",
"removeFromLibrary": "Aus Bibliothek entfernen", "removeFromLibrary": "Aus Bibliothek entfernen",
"libraryLoadingMessage": "Lade Bibliothek...", "libraryLoadingMessage": "Lade Bibliothek",
"libraries": "Bibliotheken durchsuchen", "libraries": "Bibliotheken durchsuchen",
"loadingScene": "Lade Zeichnung...", "loadingScene": "Lade Zeichnung",
"align": "Ausrichten", "align": "Ausrichten",
"alignTop": "Obere Kanten", "alignTop": "Obere Kanten",
"alignBottom": "Untere Kanten", "alignBottom": "Untere Kanten",
@@ -91,7 +91,8 @@
"centerVertically": "Vertikal zentrieren", "centerVertically": "Vertikal zentrieren",
"centerHorizontally": "Horizontal zentrieren", "centerHorizontally": "Horizontal zentrieren",
"distributeHorizontally": "Horizontal verteilen", "distributeHorizontally": "Horizontal verteilen",
"distributeVertically": "Vertikal verteilen" "distributeVertically": "Vertikal verteilen",
"viewMode": "Ansichtsmodus"
}, },
"buttons": { "buttons": {
"clearReset": "Zeichenfläche löschen & Hintergrundfarbe zurücksetzen", "clearReset": "Zeichenfläche löschen & Hintergrundfarbe zurücksetzen",
@@ -100,7 +101,7 @@
"exportToSvg": "Als SVG exportieren", "exportToSvg": "Als SVG exportieren",
"copyToClipboard": "In Zwischenablage kopieren", "copyToClipboard": "In Zwischenablage kopieren",
"copyPngToClipboard": "PNG in die Zwischenablage kopieren", "copyPngToClipboard": "PNG in die Zwischenablage kopieren",
"scale": "Skalieren", "scale": "Skalierung",
"save": "Speichern", "save": "Speichern",
"saveAs": "Speichern unter", "saveAs": "Speichern unter",
"load": "Laden", "load": "Laden",
@@ -116,7 +117,7 @@
"edit": "Bearbeiten", "edit": "Bearbeiten",
"undo": "Rückgängig machen", "undo": "Rückgängig machen",
"redo": "Wiederholen", "redo": "Wiederholen",
"roomDialog": "Live-Kollaborationssitzung starten", "resetLibrary": "Bibliothek zurücksetzen",
"createNewRoom": "Neuen Raum erstellen", "createNewRoom": "Neuen Raum erstellen",
"fullScreen": "Vollbildanzeige", "fullScreen": "Vollbildanzeige",
"darkMode": "Dunkles Design", "darkMode": "Dunkles Design",
@@ -135,10 +136,12 @@
"decryptFailed": "Daten konnten nicht entschlüsselt werden.", "decryptFailed": "Daten konnten nicht entschlüsselt werden.",
"uploadedSecurly": "Der Upload wurde mit Ende-zu-Ende-Verschlüsselung gespeichert. Weder Excalidraw noch Dritte können den Inhalt einsehen.", "uploadedSecurly": "Der Upload wurde mit Ende-zu-Ende-Verschlüsselung gespeichert. Weder Excalidraw noch Dritte können den Inhalt einsehen.",
"loadSceneOverridePrompt": "Das Laden der externen Zeichnung ersetzt den vorhandenen Inhalt. Möchtest Du fortfahren?", "loadSceneOverridePrompt": "Das Laden der externen Zeichnung ersetzt den vorhandenen Inhalt. Möchtest Du fortfahren?",
"collabStopOverridePrompt": "Das Stoppen der Sitzung wird Deine vorherige, lokal gespeicherte Zeichnung überschreiben. Bist Du sicher?\n\n(Wenn Du Deine lokale Zeichnung behalten möchtest, schließe einfach stattdessen den Browser-Tab.)",
"errorLoadingLibrary": "Beim Laden der Drittanbieter-Bibliothek ist ein Fehler aufgetreten.", "errorLoadingLibrary": "Beim Laden der Drittanbieter-Bibliothek ist ein Fehler aufgetreten.",
"confirmAddLibrary": "Dieses fügt {{numShapes}} Form(en) zu deiner Bibliothek hinzu. Bist du sicher?", "confirmAddLibrary": "Dieses fügt {{numShapes}} Form(en) zu deiner Bibliothek hinzu. Bist du sicher?",
"imageDoesNotContainScene": "Das Importieren von Bildern wird derzeit nicht unterstützt.\n\nMöchtest du eine Szene importieren? Dieses Bild scheint keine Zeichnungsdaten zu enthalten. Hast du dies beim Exportieren aktiviert?", "imageDoesNotContainScene": "Das Importieren von Bildern wird derzeit nicht unterstützt.\n\nMöchtest du eine Szene importieren? Dieses Bild scheint keine Zeichnungsdaten zu enthalten. Hast du dies beim Exportieren aktiviert?",
"cannotRestoreFromImage": "Die Zeichnung konnte aus dieser Bilddatei nicht wiederhergestellt werden" "cannotRestoreFromImage": "Die Zeichnung konnte aus dieser Bilddatei nicht wiederhergestellt werden",
"resetLibrary": "Dieses löscht deine Bibliothek. Bist du sicher?"
}, },
"toolBar": { "toolBar": {
"selection": "Auswahl", "selection": "Auswahl",
@@ -233,10 +236,16 @@
"storage": "Speicher", "storage": "Speicher",
"title": "Statistiken für Nerds", "title": "Statistiken für Nerds",
"total": "Gesamt", "total": "Gesamt",
"version": "Version",
"versionCopy": "Zum Kopieren klicken",
"versionNotAvailable": "Version nicht verfügbar",
"width": "Breite" "width": "Breite"
}, },
"toast": { "toast": {
"copyStyles": "Formatierung kopiert.", "copyStyles": "Formatierung kopiert.",
"copyToClipboardAsPng": "In die Zwischenablage als PNG kopiert." "copyToClipboard": "In die Zwischenablage kopiert.",
"copyToClipboardAsPng": "In die Zwischenablage als PNG kopiert.",
"fileSaved": "Datei gespeichert.",
"fileSavedToFilename": "Als {filename} gespeichert"
} }
} }
+22 -13
View File
@@ -68,7 +68,7 @@
"layers": "Στρώματα", "layers": "Στρώματα",
"actions": "Ενέργειες", "actions": "Ενέργειες",
"language": "Γλώσσα", "language": "Γλώσσα",
"createRoom": "Έναρξη ζωντανής συνεδρίας", "liveCollaboration": "Ζωντανή συνεργασία",
"duplicateSelection": "Δημιουργία αντιγράφου", "duplicateSelection": "Δημιουργία αντιγράφου",
"untitled": "Χωρίς τίτλο", "untitled": "Χωρίς τίτλο",
"name": "Όνομα", "name": "Όνομα",
@@ -77,12 +77,12 @@
"group": "Δημιουργία ομάδας από επιλογή", "group": "Δημιουργία ομάδας από επιλογή",
"ungroup": "Κατάργηση ομάδας από επιλογή", "ungroup": "Κατάργηση ομάδας από επιλογή",
"collaborators": "Συνεργάτες", "collaborators": "Συνεργάτες",
"gridMode": "Εμφάνιση σε πλέγμα", "showGrid": "Προβολή πλέγματος",
"addToLibrary": "Προσθήκη στη βιβλιοθήκη", "addToLibrary": "Προσθήκη στη βιβλιοθήκη",
"removeFromLibrary": "Αφαίρεση από τη βιβλιοθήκη", "removeFromLibrary": "Αφαίρεση από τη βιβλιοθήκη",
"libraryLoadingMessage": "Φόρτωση βιβλιοθήκης...", "libraryLoadingMessage": "Φόρτωση βιβλιοθήκης",
"libraries": "Άλλες βιβλιοθήκες", "libraries": "Άλλες βιβλιοθήκες",
"loadingScene": "Φόρτωση σκηνής...", "loadingScene": "Φόρτωση σκηνής",
"align": "Στοίχιση", "align": "Στοίχιση",
"alignTop": "Στοίχιση πάνω", "alignTop": "Στοίχιση πάνω",
"alignBottom": "Στοίχιση κάτω", "alignBottom": "Στοίχιση κάτω",
@@ -91,7 +91,8 @@
"centerVertically": "Κέντρο κάθετα", "centerVertically": "Κέντρο κάθετα",
"centerHorizontally": "Κέντρο οριζόντια", "centerHorizontally": "Κέντρο οριζόντια",
"distributeHorizontally": "Οριζόντια κατανομή", "distributeHorizontally": "Οριζόντια κατανομή",
"distributeVertically": "Κατακόρυφη κατανομή" "distributeVertically": "Κατακόρυφη κατανομή",
"viewMode": "Λειτουργία προβολής"
}, },
"buttons": { "buttons": {
"clearReset": "Επαναφορά του καμβά", "clearReset": "Επαναφορά του καμβά",
@@ -116,13 +117,13 @@
"edit": "Επεξεργασία", "edit": "Επεξεργασία",
"undo": "Αναίρεση", "undo": "Αναίρεση",
"redo": "Επαναφορά", "redo": "Επαναφορά",
"roomDialog": "Έναρξη ζωντανής συνεργασίας", "resetLibrary": "Καθαρισμός βιβλιοθήκης",
"createNewRoom": "Δημιουργία νέου χώρου", "createNewRoom": "Δημιουργία νέου χώρου",
"fullScreen": "Πλήρης οθόνη", "fullScreen": "Πλήρης οθόνη",
"darkMode": "Σκοτεινή λειτουργία", "darkMode": "Σκοτεινή λειτουργία",
"lightMode": "Φωτεινή λειτουργία", "lightMode": "Φωτεινή λειτουργία",
"zenMode": "Λειτουργία Zεν", "zenMode": "Λειτουργία Zεν",
"exitZenMode": "Έξοδος απο την λειτουργία Zen" "exitZenMode": "Έξοδος από την λειτουργία Zen"
}, },
"alerts": { "alerts": {
"clearReset": "Αυτό θα σβήσει ολόκληρο τον καμβά. Είσαι σίγουρος;", "clearReset": "Αυτό θα σβήσει ολόκληρο τον καμβά. Είσαι σίγουρος;",
@@ -135,10 +136,12 @@
"decryptFailed": "Δεν ήταν δυνατή η αποκρυπτογράφηση δεδομένων.", "decryptFailed": "Δεν ήταν δυνατή η αποκρυπτογράφηση δεδομένων.",
"uploadedSecurly": "Η μεταφόρτωση έχει εξασφαλιστεί με κρυπτογράφηση από άκρο σε άκρο, πράγμα που σημαίνει ότι ο διακομιστής Excalidraw και τρίτα μέρη δεν μπορούν να διαβάσουν το περιεχόμενο.", "uploadedSecurly": "Η μεταφόρτωση έχει εξασφαλιστεί με κρυπτογράφηση από άκρο σε άκρο, πράγμα που σημαίνει ότι ο διακομιστής Excalidraw και τρίτα μέρη δεν μπορούν να διαβάσουν το περιεχόμενο.",
"loadSceneOverridePrompt": "Η φόρτωση εξωτερικού σχεδίου θα αντικαταστήσει το υπάρχον περιεχόμενο. Επιθυμείτε να συνεχίσετε;", "loadSceneOverridePrompt": "Η φόρτωση εξωτερικού σχεδίου θα αντικαταστήσει το υπάρχον περιεχόμενο. Επιθυμείτε να συνεχίσετε;",
"collabStopOverridePrompt": "Η διακοπή της συνεδρίας θα αντικαταστήσει το προηγούμενο, τοπικά αποθηκευμένο σχέδιο. Είστε σίγουροι?\n\n(Αν θέλετε να διατηρήσετε το τοπικό σας σχέδιο, απλά κλείστε την καρτέλα του προγράμματος περιήγησης.)",
"errorLoadingLibrary": "Υπήρξε ένα σφάλμα κατά τη φόρτωση της βιβλιοθήκης τρίτου μέρους.", "errorLoadingLibrary": "Υπήρξε ένα σφάλμα κατά τη φόρτωση της βιβλιοθήκης τρίτου μέρους.",
"confirmAddLibrary": "Αυτό θα προσθέσει {{numShapes}} σχήμα(τα) στη βιβιλιοθήκη σας. Είστε σίγουροι;", "confirmAddLibrary": "Αυτό θα προσθέσει {{numShapes}} σχήμα(τα) στη βιβλιοθήκη σας. Είστε σίγουροι;",
"imageDoesNotContainScene": "Η εισαγωγή εικόνων δεν υποστηρίζεται αυτή τη στιγμή.\n\nΜήπως θέλετε να εισαγάγετε μια σκηνή; Αυτή η εικόνα δεν φαίνεται να περιέχει δεδομένα σκηνής. Έχετε ενεργοποιήσει αυτό κατά την εξαγωγή;", "imageDoesNotContainScene": "Η εισαγωγή εικόνων δεν υποστηρίζεται αυτή τη στιγμή.\n\nΜήπως θέλετε να εισαγάγετε μια σκηνή; Αυτή η εικόνα δεν φαίνεται να περιέχει δεδομένα σκηνής. Έχετε ενεργοποιήσει αυτό κατά την εξαγωγή;",
"cannotRestoreFromImage": "Η σκηνή δεν ήταν δυνατό να αποκατασταθεί από αυτό το αρχείο εικόνας" "cannotRestoreFromImage": "Η σκηνή δεν ήταν δυνατό να αποκατασταθεί από αυτό το αρχείο εικόνας",
"resetLibrary": "Αυτό θα καθαρίσει τη βιβλιοθήκη σας. Είστε σίγουροι;"
}, },
"toolBar": { "toolBar": {
"selection": "Επιλογή", "selection": "Επιλογή",
@@ -159,7 +162,7 @@
}, },
"hints": { "hints": {
"linearElement": "Κάνε κλικ για να ξεκινήσεις πολλαπλά σημεία, σύρε για μια γραμμή", "linearElement": "Κάνε κλικ για να ξεκινήσεις πολλαπλά σημεία, σύρε για μια γραμμή",
"freeDraw": "Κάντε κλικ και σύρατε, απελευθερώσατε όταν έχετε τελειώσει", "freeDraw": "Κάντε κλικ και σύρτε, απελευθερώσατε όταν έχετε τελειώσει",
"text": "Tip: μπορείτε επίσης να προσθέστε κείμενο με διπλό-κλικ οπουδήποτε με το εργαλείο επιλογών", "text": "Tip: μπορείτε επίσης να προσθέστε κείμενο με διπλό-κλικ οπουδήποτε με το εργαλείο επιλογών",
"linearElementMulti": "Κάνε κλικ στο τελευταίο σημείο ή πάτησε Escape ή Enter για να τελειώσεις", "linearElementMulti": "Κάνε κλικ στο τελευταίο σημείο ή πάτησε Escape ή Enter για να τελειώσεις",
"lockAngle": "Μπορείτε να περιορίσετε τη γωνία κρατώντας πατημένο το SHIFT", "lockAngle": "Μπορείτε να περιορίσετε τη γωνία κρατώντας πατημένο το SHIFT",
@@ -182,7 +185,7 @@
"clearCanvasCaveat": " Αυτό θα προκαλέσει απώλεια της δουλειάς σου ", "clearCanvasCaveat": " Αυτό θα προκαλέσει απώλεια της δουλειάς σου ",
"trackedToSentry_pre": "Το σφάλμα με αναγνωριστικό ", "trackedToSentry_pre": "Το σφάλμα με αναγνωριστικό ",
"trackedToSentry_post": " παρακολουθήθηκε στο σύστημά μας.", "trackedToSentry_post": " παρακολουθήθηκε στο σύστημά μας.",
"openIssueMessage_pre": "Ήμασταν πολύ προσεκτικοί για να μην συμπεριλάβουμε τις πληροφορίες της σκηνής σου στο σφάλμα. Αν η σκηνή σου δεν είναι ιδιωτική, παρακαλώ σκέψουν να ακολουθήσεις το δικό μας ", "openIssueMessage_pre": "Ήμασταν πολύ προσεκτικοί για να μην συμπεριλάβουμε τις πληροφορίες της σκηνής σου στο σφάλμα. Αν η σκηνή σου δεν είναι ιδιωτική, παρακαλώ σκέψου να ακολουθήσεις το δικό μας ",
"openIssueMessage_button": "ανιχνευτής σφαλμάτων.", "openIssueMessage_button": "ανιχνευτής σφαλμάτων.",
"openIssueMessage_post": " Παρακαλώ να συμπεριλάβετε τις παρακάτω πληροφορίες, αντιγράφοντας και επικολλώντας το ζήτημα στο GitHub.", "openIssueMessage_post": " Παρακαλώ να συμπεριλάβετε τις παρακάτω πληροφορίες, αντιγράφοντας και επικολλώντας το ζήτημα στο GitHub.",
"sceneContent": "Περιεχόμενο σκηνής:" "sceneContent": "Περιεχόμενο σκηνής:"
@@ -221,7 +224,7 @@
"zoomToSelection": "Ζουμ στην επιλογή" "zoomToSelection": "Ζουμ στην επιλογή"
}, },
"encrypted": { "encrypted": {
"tooltip": "Τα σχέδιά σου είναι κρυπτογραφημένα από άκρο σε άκρο, έτσι δεν θα έιναι ποτέ ορατά μέσα από τους διακομιστές του Excalidraw." "tooltip": "Τα σχέδιά σου είναι κρυπτογραφημένα από άκρο σε άκρο, έτσι δεν θα είναι ποτέ ορατά μέσα από τους διακομιστές του Excalidraw."
}, },
"stats": { "stats": {
"angle": "Γωνία", "angle": "Γωνία",
@@ -233,10 +236,16 @@
"storage": "Χώρος", "storage": "Χώρος",
"title": "Στατιστικά για σπασίκλες", "title": "Στατιστικά για σπασίκλες",
"total": "Σύνολο ", "total": "Σύνολο ",
"version": "Έκδοση",
"versionCopy": "Κάνε κλικ για αντιγραφή",
"versionNotAvailable": "Έκδοση μη διαθέσιμη",
"width": "Πλάτος" "width": "Πλάτος"
}, },
"toast": { "toast": {
"copyStyles": "Αντιγράφηκαν στυλ.", "copyStyles": "Αντιγράφηκαν στυλ.",
"copyToClipboardAsPng": "Αντιγράφτηκε στο πρόχειρο ως PNG." "copyToClipboard": "Αντιγράφηκε στο πρόχειρο.",
"copyToClipboardAsPng": "Αντιγράφτηκε στο πρόχειρο ως PNG.",
"fileSaved": "Το αρχείο αποθηκεύτηκε.",
"fileSavedToFilename": "Αποθηκεύτηκε στο {filename}"
} }
} }
+17 -8
View File
@@ -68,7 +68,7 @@
"layers": "Layers", "layers": "Layers",
"actions": "Actions", "actions": "Actions",
"language": "Language", "language": "Language",
"createRoom": "Share a live-collaboration session", "liveCollaboration": "Live collaboration",
"duplicateSelection": "Duplicate", "duplicateSelection": "Duplicate",
"untitled": "Untitled", "untitled": "Untitled",
"name": "Name", "name": "Name",
@@ -77,12 +77,12 @@
"group": "Group selection", "group": "Group selection",
"ungroup": "Ungroup selection", "ungroup": "Ungroup selection",
"collaborators": "Collaborators", "collaborators": "Collaborators",
"gridMode": "Grid mode", "showGrid": "Show grid",
"addToLibrary": "Add to library", "addToLibrary": "Add to library",
"removeFromLibrary": "Remove from library", "removeFromLibrary": "Remove from library",
"libraryLoadingMessage": "Loading library...", "libraryLoadingMessage": "Loading library",
"libraries": "Browse libraries", "libraries": "Browse libraries",
"loadingScene": "Loading scene...", "loadingScene": "Loading scene",
"align": "Align", "align": "Align",
"alignTop": "Align top", "alignTop": "Align top",
"alignBottom": "Align bottom", "alignBottom": "Align bottom",
@@ -91,7 +91,8 @@
"centerVertically": "Center vertically", "centerVertically": "Center vertically",
"centerHorizontally": "Center horizontally", "centerHorizontally": "Center horizontally",
"distributeHorizontally": "Distribute horizontally", "distributeHorizontally": "Distribute horizontally",
"distributeVertically": "Distribute vertically" "distributeVertically": "Distribute vertically",
"viewMode": "View mode"
}, },
"buttons": { "buttons": {
"clearReset": "Reset the canvas", "clearReset": "Reset the canvas",
@@ -116,7 +117,7 @@
"edit": "Edit", "edit": "Edit",
"undo": "Undo", "undo": "Undo",
"redo": "Redo", "redo": "Redo",
"roomDialog": "Start live collaboration", "resetLibrary": "Reset library",
"createNewRoom": "Create new room", "createNewRoom": "Create new room",
"fullScreen": "Full screen", "fullScreen": "Full screen",
"darkMode": "Dark mode", "darkMode": "Dark mode",
@@ -135,10 +136,12 @@
"decryptFailed": "Couldn't decrypt data.", "decryptFailed": "Couldn't decrypt data.",
"uploadedSecurly": "The upload has been secured with end-to-end encryption, which means that Excalidraw server and third parties can't read the content.", "uploadedSecurly": "The upload has been secured with end-to-end encryption, which means that Excalidraw server and third parties can't read the content.",
"loadSceneOverridePrompt": "Loading external drawing will replace your existing content. Do you wish to continue?", "loadSceneOverridePrompt": "Loading external drawing will replace your existing content. Do you wish to continue?",
"collabStopOverridePrompt": "Stopping the session will overwrite your previous, locally stored drawing. Are you sure?\n\n(If you want to keep your local drawing, simply close the browser tab instead.)",
"errorLoadingLibrary": "There was an error loading the third party library.", "errorLoadingLibrary": "There was an error loading the third party library.",
"confirmAddLibrary": "This will add {{numShapes}} shape(s) to your library. Are you sure?", "confirmAddLibrary": "This will add {{numShapes}} shape(s) to your library. Are you sure?",
"imageDoesNotContainScene": "Importing images isn't supported at the moment.\n\nDid you want to import a scene? This image does not seem to contain any scene data. Have you enabled this during export?", "imageDoesNotContainScene": "Importing images isn't supported at the moment.\n\nDid you want to import a scene? This image does not seem to contain any scene data. Have you enabled this during export?",
"cannotRestoreFromImage": "Scene couldn't be restored from this image file" "cannotRestoreFromImage": "Scene couldn't be restored from this image file",
"resetLibrary": "This will clear your library. Are you sure?"
}, },
"toolBar": { "toolBar": {
"selection": "Selection", "selection": "Selection",
@@ -233,10 +236,16 @@
"storage": "Storage", "storage": "Storage",
"title": "Stats for nerds", "title": "Stats for nerds",
"total": "Total", "total": "Total",
"version": "Version",
"versionCopy": "Click to copy",
"versionNotAvailable": "Version not available",
"width": "Width" "width": "Width"
}, },
"toast": { "toast": {
"copyStyles": "Copied styles.", "copyStyles": "Copied styles.",
"copyToClipboardAsPng": "Copied to clipboard as PNG." "copyToClipboard": "Copied to clipboard.",
"copyToClipboardAsPng": "Copied to clipboard as PNG.",
"fileSaved": "File saved.",
"fileSavedToFilename": "Saved to {filename}"
} }
} }

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