Compare commits

...

139 Commits

Author SHA1 Message Date
Aakansha Doshi bba0117377 fix 2021-02-20 02:22:40 +05:30
Aakansha Doshi dcedba88a2 feat: add shareable viewonly links 2021-02-20 01:56:47 +05:30
Arun 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
Lipis 9dd2257932 chore: Consistent CSS variable names and general housekeeping of styles (#2947) 2021-02-16 20:22:18 +02:00
Aakansha Doshi 9c0f832a41 fix: show user state only when passed from host (#3050) 2021-02-16 15:08:27 +05:30
Aakansha Doshi 6cafb6bb90 feat: export restore API's from Excalidraw package (#3049) 2021-02-15 14:22:04 +01:00
David Luzar 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 Doshi 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] 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 Kurnosov 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] 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] 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] 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] 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] 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] 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] 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] 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] 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] 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] 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] 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] 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] 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] 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] 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] 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] 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] 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] 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 Doshi 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 Doshi ecbd5ba55d docs: update readme, changelog and release 0.3.0 🎉 (#3003) 2021-02-11 19:55:38 +05:30
Excalidraw Bot 6967d8c985 chore: Update translations from Crowdin (#3000) 2021-02-11 14:13:32 +02:00
David Luzar 4b253c7362 fix: refresh wysiwyg position on canvas resize (#3008) 2021-02-11 12:24:26 +01:00
Aakansha Doshi 4fdddb518a docs(readme): link to @excalidraw/excalidraw npm package to give more visibility (#3002) 2021-02-10 21:58:29 +05:30
Aakansha Doshi 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 Luzar f162512988 docs: link to specific sponsors (#2950) 2021-02-10 10:22:49 +02:00
Lipis 2f7154cdf2 chore: Use Sentence case for Live collaboration 2021-02-09 15:55:03 +02:00
Arun 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 Bot 073f4032f3 chore: Update translations from Crowdin (#2986) 2021-02-09 11:06:23 +02:00
Jed Fox 73cba59d2d feat: Updates to Collaboration and RTL UX (#2994) 2021-02-08 21:43:51 +01:00
José dBruxelles 4085071347 docs: Fix typo (#2996) 2021-02-08 18:39:59 +02:00
José dBruxelles 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 Luzar 9c51ba6067 feat: show toast when saving to existing file (#2988) 2021-02-07 22:01:22 +01:00
Excalidraw Bot bf50c9cae7 chore: Update translations from Crowdin (#2946) 2021-02-07 12:33:37 +02:00
dependabot[bot] 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] 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] 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] 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] 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] 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] 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] 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] 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] 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] 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] 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] 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
Lipis 77ae5d4605 chore(deps): bump @sentry/* from 6.0.3 to 6.1.0 (#2984) 2021-02-07 10:17:05 +02:00
dependabot[bot] 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] 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] 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] 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] 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] 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] 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] 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] 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] 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] 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] 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] 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] 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] 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 Doshi 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
Lipis 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
Aakansha Doshi 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
Arun d6ca981f7a fix: Rename 'Grid mode' to 'Show grid' (#2944) 2021-02-05 23:50:53 +02:00
Excalidraw Bot f7f98d9dda chore: Update translations from Crowdin (#2930) 2021-02-05 20:07:33 +02:00
Thomas Steiner 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 Luzar 6aa22bada8 fix: mobile toolbar tooltip regression (#2939) 2021-02-05 16:09:40 +01:00
David Luzar 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 Luzar b79ef0d428 fix: don't prompt on empty scenes (#2937) 2021-02-05 12:04:33 +01:00
Excalidraw Bot dc25fe06d0 chore: Update translations from Crowdin (#2906) 2021-02-04 20:18:57 +02:00
David Luzar 1e17c1967b fix: toolbar unnecessarily eats too much width (#2924) 2021-02-04 16:52:16 +01:00
David Luzar 23a8891e0e fix: mistakenly hardcoding scale (#2925) 2021-02-04 16:52:00 +01:00
David Luzar 6c81a32d62 fix: text editor not visible in dark mode (#2920) 2021-02-04 16:21:48 +01:00
David Luzar f0f5430313 feat: support supplying custom scale when exporting canvas (#2904) 2021-02-04 14:54:08 +01:00
David Luzar e18e945cd3 fix: incorrect z-index of text editor (#2914) 2021-02-04 14:54:00 +01:00
Lipis 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
Christophe pasquier 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
Lipis 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
Thomas Steiner 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 Luzar 15f698dc21 fix: pointer-events being disabled on free-draw (#2912) 2021-02-03 20:43:16 +01:00
David Luzar ce507b0a0b feat: don't store to LS during collab (#2909) 2021-02-03 19:14:26 +01:00
Excalidraw Bot 02598c6163 chore: Update translations from Crowdin (#2898) 2021-02-02 22:06:44 +00:00
Aakansha Doshi 3e2890bd21 fix: track zenmode and grid mode usage (#2900) 2021-02-02 20:57:22 +05:30
Lipis 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 Bot f8087e01c8 chore: Update translations from Crowdin (#2894) 2021-02-02 09:41:57 +00:00
Excalidraw Bot e2522645f7 Update i18n.ts 2021-02-02 11:35:52 +02:00
Excalidraw Bot d46a9166be Update locales-coverage-description.js 2021-02-02 11:35:00 +02:00
Aakansha Doshi 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] 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 Luzar 627c56ef1c fix: disable UI pointer-events on canvas drag (#2856) 2021-02-01 14:55:38 +02:00
Excalidraw Bot 0d0fe32485 chore: Update translations from Crowdin (#2875) 2021-02-01 13:23:08 +02:00
dependabot[bot] 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] 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] 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] 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 Luzar 1973ae9444 fix: stop flooring scroll positions (#2883) 2021-01-31 10:47:43 +01:00
David Luzar 10cd6a24b0 feat: increase max zoom (#2881) 2021-01-30 18:03:23 +01:00
David Luzar 6abf4f52ff refactor: remove duplicate key handling (#2878) 2021-01-30 10:30:00 +01:00
Aakansha Doshi 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
Arun 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 Doshi 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
Lipis ed0bec41dc chore: Update workflows to use the latest Node (#2863) 2021-01-28 19:20:48 +05:30
David Luzar d4e12a2962 reuse scss variables in js for SSOT (#2867) 2021-01-28 17:28:35 +05:30
Kartik Prajapati 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 Steiner b5e26ba81f refactor: Rename browser-nativefs to browser-fs-access (#2862) 2021-01-27 20:47:55 +02:00
Excalidraw Bot 4392a4644a chore: Update translations from Crowdin (#2861) 2021-01-27 14:22:37 +02:00
Lipis b1c8c538ee fix: Change the timeout for reporting the version to 30s (#2858) 2021-01-26 22:22:41 +02:00
Excalidraw Bot 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 Luzar e75f5f20e7 fix: remote pointers not accounting for offset (#2855) 2021-01-25 10:47:48 +01:00
David Luzar 0a0be839b9 refactor: rewrite collabWrapper to remove TDZs and simplify (#2834) 2021-01-25 10:47:35 +01:00
dependabot[bot] 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] 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] 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] 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] 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] 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] 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] 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] 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] 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] 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
Lipis 26acebcdb6 chore: Update translations from Crowdin (#2817) 2021-01-22 18:06:21 +02:00
David Luzar 9dc930b447 feat: add ctrl-y to redo (#2831) 2021-01-21 16:21:54 +01:00
Aakansha Doshi 6e767fc949 fix(actionmenu): toggle help dialog when "shift+?" is pressed (#2828) 2021-01-21 13:13:47 +02:00
155 changed files with 7964 additions and 4770 deletions
+1 -1
View File
@@ -2,6 +2,7 @@
"extends": ["prettier", "react-app"],
"plugins": ["prettier"],
"rules": {
"@typescript-eslint/no-unused-vars": "warn",
"curly": "warn",
"dot-notation": "warn",
"import/no-anonymous-default-export": "off",
@@ -22,7 +23,6 @@
],
"no-unneeded-ternary": "warn",
"no-unused-expressions": "warn",
"no-unused-vars": "warn",
"no-useless-return": "warn",
"no-var": "warn",
"object-shorthand": "warn",
+1 -1
View File
@@ -6,7 +6,7 @@ on:
- master
jobs:
build-docker:
build:
runs-on: ubuntu-latest
steps:
+2 -2
View File
@@ -13,10 +13,10 @@ jobs:
steps:
- uses: actions/checkout@v1
- name: Setup Node.js 12.x
- name: Setup Node.js 14.x
uses: actions/setup-node@v1
with:
node-version: 12.x
node-version: 14.x
- name: Install dependencies
run: |
+9 -3
View File
@@ -1,9 +1,15 @@
name: Cancel
on: [push]
name: Cancel previous runs
on:
push:
branches:
- master
pull_request:
jobs:
cancel:
name: "Cancel Previous Runs"
runs-on: ubuntu-latest
timeout-minutes: 3
steps:
- uses: styfle/cancel-workflow-action@0.6.0
+3 -9
View File
@@ -1,10 +1,6 @@
name: Lint
on:
push:
branches:
- master
pull_request:
on: pull_request
jobs:
lint:
@@ -13,10 +9,10 @@ jobs:
steps:
- uses: actions/checkout@v1
- name: Setup Node.js 12.x
- name: Setup Node.js 14.x
uses: actions/setup-node@v1
with:
node-version: 12.x
node-version: 14.x
- name: Install and lint
run: |
@@ -24,5 +20,3 @@ jobs:
npm run test:other
npm run test:code
npm run test:typecheck
env:
CI: true
+4 -4
View File
@@ -14,18 +14,18 @@ jobs:
with:
token: ${{ secrets.PUSH_TRANSLATIONS_COVERAGE_PAT }}
- name: Setup Node.js 12.x
- name: Setup Node.js 14.x
uses: actions/setup-node@v1
with:
node-version: 12.x
node-version: 14.x
- name: Create report file
run: |
npm run locales-coverage
FILE_CHANGED=$(git diff src/locales/percentages.json)
if [ ! -z "${FILE_CHANGED}" ]; then
git config --global user.name 'Kostas Bariotis'
git config --global user.email 'konmpar@gmail.com'
git config --global user.name 'Excalidraw Bot'
git config --global user.email 'bot@excalidraw.com'
git add src/locales/percentages.json
git commit -am "Auto commit: Calculate translation coverage"
git push
+1
View File
@@ -10,6 +10,7 @@ on:
jobs:
main:
runs-on: ubuntu-latest
steps:
- uses: amannn/action-semantic-pull-request@v3.0.0
env:
+3 -2
View File
@@ -8,13 +8,14 @@ on:
jobs:
release:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v1.0.0
- name: Setup Node.js 12.x
- name: Setup Node.js 14.x
uses: actions/setup-node@v1
with:
node-version: 12.x
node-version: 14.x
- name: Install and build
run: |
+3 -9
View File
@@ -1,10 +1,6 @@
name: Tests
on:
push:
branches:
- master
pull_request:
on: pull_request
jobs:
test:
@@ -13,14 +9,12 @@ jobs:
steps:
- uses: actions/checkout@v1
- name: Setup Node.js 12.x
- name: Setup Node.js 14.x
uses: actions/setup-node@v1
with:
node-version: 12.x
node-version: 14.x
- name: Install and test
run: |
npm ci
npm run test:app
env:
CI: true
+1 -2
View File
@@ -19,7 +19,7 @@
### Option 2 - CodeSandbox
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. Tap on `Fork Sandbox`
1. Write your code
@@ -35,7 +35,6 @@ Make sure the title starts with a semantic prefix:
- **feat**: A new feature
- **fix**: A bug fix
- **improvement**: An improvement to a current feature
- **docs**: Documentation only changes
- **style**: Changes that do not affect the meaning of the code (white-space, formatting, missing semi-colons, etc)
- **refactor**: A code change that neither fixes a bug nor adds a feature
+21 -8
View File
@@ -2,7 +2,7 @@
<a href="https://excalidraw.com">
<img width="540" src="./public/og-image-sm.png" alt="Excalidraw logo: Sketch handrawn like diagrams." />
</a>
<h3>Virtual whiteboard for sketching hand-drawn like diagrams.<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>
<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">
@@ -11,6 +11,7 @@
<img src="https://badges.crowdin.net/excalidraw/localized.svg">
</a>
</p>
<p>Ask questions or hang out on our <a target="_blank" href="https://discord.gg/UexuTaE">discord.gg/UexuTaE</a>.</p>
</div>
## 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/).
## 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
### Shortcuts
@@ -41,7 +50,7 @@ Translations will be available on the app if they exceed a certain threshold of
### 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}
@@ -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).
## 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
- 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!
### Local Installation
@@ -92,15 +105,15 @@ git clone https://github.com/excalidraw/excalidraw.git
#### 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
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
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.**
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
+1625 -1460
View File
File diff suppressed because it is too large Load Diff
+15 -14
View File
@@ -19,17 +19,17 @@
]
},
"dependencies": {
"@sentry/browser": "5.30.0",
"@sentry/integrations": "5.30.0",
"@sentry/browser": "6.1.0",
"@sentry/integrations": "6.1.0",
"@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/react": "17.0.0",
"@types/react-dom": "17.0.0",
"@types/react": "17.0.2",
"@types/react-dom": "17.0.1",
"@types/socket.io-client": "1.4.35",
"browser-nativefs": "0.12.0",
"browser-fs-access": "0.13.1",
"clsx": "1.1.1",
"firebase": "8.2.3",
"firebase": "8.2.7",
"i18next-browser-languagedetector": "6.0.1",
"lodash.throttle": "4.1.1",
"nanoid": "3.1.20",
@@ -43,20 +43,21 @@
"pwacompat": "2.0.17",
"react": "17.0.1",
"react-dom": "17.0.1",
"react-scripts": "4.0.1",
"react-scripts": "4.0.2",
"roughjs": "4.3.1",
"socket.io-client": "2.3.1",
"typescript": "4.1.3"
"typescript": "4.1.5"
},
"devDependencies": {
"@types/lodash.throttle": "4.1.6",
"@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",
"firebase-tools": "9.2.1",
"firebase-tools": "9.3.0",
"husky": "4.3.8",
"jest-canvas-mock": "2.3.0",
"lint-staged": "10.5.3",
"jest-canvas-mock": "2.3.1",
"lint-staged": "10.5.4",
"pepjs": "0.5.3",
"prettier": "2.2.1",
"rewire": "5.0.0"
@@ -72,7 +73,7 @@
},
"jest": {
"transformIgnorePatterns": [
"node_modules/(?!(roughjs|points-on-curve|path-data-parser|points-on-path|browser-nativefs)/)"
"node_modules/(?!(roughjs|points-on-curve|path-data-parser|points-on-path|browser-fs-access)/)"
],
"resetMocks": false
},
+1
View File
@@ -57,6 +57,7 @@
<!-- Excalidraw version -->
<meta name="version" content="{version}" />
<link
rel="preload"
href="FG_Virgil.woff2"
+4 -1
View File
@@ -18,6 +18,7 @@ const crowdinMap = {
"id-ID": "en-id",
"it-IT": "en-it",
"ja-JP": "en-ja",
"kab-KAB": "en-kab",
"ko-KR": "en-ko",
"my-MM": "en-my",
"nb-NO": "en-nb",
@@ -40,7 +41,7 @@ const crowdinMap = {
const flags = {
"ar-SA": "🇸🇦",
"bg-BG": "🇧🇬",
"ca-ES": "🇪🇸",
"ca-ES": "🏳",
"de-DE": "🇩🇪",
"el-GR": "🇬🇷",
"es-ES": "🇪🇸",
@@ -53,6 +54,7 @@ const flags = {
"id-ID": "🇮🇩",
"it-IT": "🇮🇹",
"ja-JP": "🇯🇵",
"kab-KAB": "🏳",
"ko-KR": "🇰🇷",
"my-MM": "🇲🇲",
"nb-NO": "🇳🇴",
@@ -88,6 +90,7 @@ const languages = {
"id-ID": "Bahasa Indonesia",
"it-IT": "Italiano",
"ja-JP": "日本語",
"kab-KAB": "Taqbaylit",
"ko-KR": "한국어",
"my-MM": "Burmese",
"nb-NO": "Norsk bokmål",
-1
View File
@@ -17,6 +17,5 @@ export const actionAddToLibrary = register({
});
return false;
},
contextMenuOrder: 6,
contextItemLabel: "labels.addToLibrary",
});
+1 -2
View File
@@ -3,6 +3,7 @@ import { getDefaultAppState } from "../appState";
import { ColorPicker } from "../components/ColorPicker";
import { resetZoom, trash, zoomIn, zoomOut } from "../components/icons";
import { ToolButton } from "../components/ToolButton";
import { ZOOM_STEP } from "../constants";
import { getCommonBounds, getNonDeletedElements } from "../element";
import { newElementWith } from "../element/mutateElement";
import { ExcalidrawElement } from "../element/types";
@@ -75,8 +76,6 @@ export const actionClearCanvas = register({
),
});
const ZOOM_STEP = 0.1;
export const actionZoomIn = register({
name: "zoomIn",
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",
contextMenuOrder: 999999,
keyTest: (event) => event.key === KEYS.BACKSPACE || event.key === KEYS.DELETE,
PanelComponent: ({ elements, appState, updateData }) => (
<ToolButton
+16 -1
View File
@@ -96,9 +96,24 @@ export const actionChangeShouldAddWatermark = register({
export const actionSaveScene = register({
name: "saveScene",
perform: async (elements, appState, value) => {
const fileHandleExists = !!appState.fileHandle;
try {
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) {
if (error?.name !== "AbortError") {
console.error(error);
+1 -1
View File
@@ -83,7 +83,7 @@ export const actionFinalize = register({
// If the multi point line closes the loop,
// set the last point to first point.
// This ensures that loop remains closed at different scales.
const isLoop = isPathALoop(multiPointElement.points);
const isLoop = isPathALoop(multiPointElement.points, appState.zoom.value);
if (
multiPointElement.type === "line" ||
multiPointElement.type === "draw"
-2
View File
@@ -125,7 +125,6 @@ export const actionGroup = register({
commitToHistory: true,
};
},
contextMenuOrder: 4,
contextItemLabel: "labels.group",
contextItemPredicate: (elements, appState) =>
enableActionGroup(elements, appState),
@@ -174,7 +173,6 @@ export const actionUngroup = register({
},
keyTest: (event) =>
event.shiftKey && event[KEYS.CTRL_OR_CMD] && event.code === CODES.G,
contextMenuOrder: 5,
contextItemLabel: "labels.ungroup",
contextItemPredicate: (elements, appState) =>
getSelectedGroupIds(appState).length > 0,
+10 -6
View File
@@ -6,7 +6,7 @@ import { t } from "../i18n";
import { SceneHistory, HistoryEntry } from "../history";
import { ExcalidrawElement } from "../element/types";
import { AppState } from "../types";
import { KEYS } from "../keys";
import { isWindows, KEYS } from "../keys";
import { getElementMap } from "../element";
import { newElementWith } from "../element/mutateElement";
import { fixBindingsAfterDeletion } from "../element/binding";
@@ -59,16 +59,16 @@ const writeData = (
return { commitToHistory };
};
const testUndo = (shift: boolean) => (event: KeyboardEvent) =>
event[KEYS.CTRL_OR_CMD] && /z/i.test(event.key) && event.shiftKey === shift;
type ActionCreator = (history: SceneHistory) => Action;
export const createUndoAction: ActionCreator = (history) => ({
name: "undo",
perform: (elements, appState) =>
writeData(elements, appState, () => history.undoOnce()),
keyTest: testUndo(false),
keyTest: (event) =>
event[KEYS.CTRL_OR_CMD] &&
event.key.toLowerCase() === KEYS.Z &&
!event.shiftKey,
PanelComponent: ({ updateData }) => (
<ToolButton
type="button"
@@ -84,7 +84,11 @@ export const createRedoAction: ActionCreator = (history) => ({
name: "redo",
perform: (elements, appState) =>
writeData(elements, appState, () => history.redoOnce()),
keyTest: testUndo(true),
keyTest: (event) =>
(event[KEYS.CTRL_OR_CMD] &&
event.shiftKey &&
event.key.toLowerCase() === KEYS.Z) ||
(isWindows && event.ctrlKey && !event.shiftKey && event.key === KEYS.Y),
PanelComponent: ({ updateData }) => (
<ToolButton
type="button"
+1 -1
View File
@@ -74,7 +74,7 @@ export const actionShortcuts = register({
return {
appState: {
...appState,
showHelpDialog: true,
showHelpDialog: !appState.showHelpDialog,
},
commitToHistory: false,
};
+1 -1
View File
@@ -42,7 +42,7 @@ export const actionGoToCollaborator = register({
return null;
}
const { background, stroke } = getClientColors(clientId);
const { background, stroke } = getClientColors(clientId, appState);
const shortName = getClientInitials(collaborator.username);
return (
-2
View File
@@ -34,7 +34,6 @@ export const actionCopyStyles = register({
contextItemLabel: "labels.copyStyles",
keyTest: (event) =>
event[KEYS.CTRL_OR_CMD] && event.altKey && event.code === CODES.C,
contextMenuOrder: 0,
});
export const actionPasteStyles = register({
@@ -74,5 +73,4 @@ export const actionPasteStyles = register({
contextItemLabel: "labels.pasteStyles",
keyTest: (event) =>
event[KEYS.CTRL_OR_CMD] && event.altKey && event.code === CODES.V,
contextMenuOrder: 1,
});
+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,
});
+12
View File
@@ -65,3 +65,15 @@ export {
distributeHorizontally,
distributeVertically,
} from "./actionDistribute";
export {
actionCopy,
actionCut,
actionCopyAsPng,
actionCopyAsSvg,
} from "./actionClipboard";
export { actionToggleGridMode } from "./actionToggleGridMode";
export { actionToggleZenMode } from "./actionToggleZenMode";
export { actionToggleStats } from "./actionToggleStats";
+18 -38
View File
@@ -3,14 +3,16 @@ import {
Action,
ActionsManagerInterface,
UpdaterFn,
ActionFilterFn,
ActionName,
ActionResult,
} from "./types";
import { ExcalidrawElement } from "../element/types";
import { AppState } from "../types";
import { t } from "../i18n";
import { ShortcutName } from "./shortcuts";
import { AppState, ExcalidrawProps } from "../types";
import { MODES } from "../constants";
// This is the <App> component, but for now we don't care about anything but its
// `canvas` state.
type App = { canvas: HTMLCanvasElement | null; props: ExcalidrawProps };
export class ActionManager implements ActionsManagerInterface {
actions = {} as ActionsManagerInterface["actions"];
@@ -18,13 +20,14 @@ export class ActionManager implements ActionsManagerInterface {
updater: (actionResult: ActionResult | Promise<ActionResult>) => void;
getAppState: () => Readonly<AppState>;
getElementsIncludingDeleted: () => readonly ExcalidrawElement[];
app: App;
constructor(
updater: UpdaterFn,
getAppState: () => AppState,
getElementsIncludingDeleted: () => readonly ExcalidrawElement[],
app: App,
) {
this.updater = (actionResult) => {
if (actionResult && "then" in actionResult) {
@@ -37,6 +40,7 @@ export class ActionManager implements ActionsManagerInterface {
};
this.getAppState = getAppState;
this.getElementsIncludingDeleted = getElementsIncludingDeleted;
this.app = app;
}
registerAction(action: Action) {
@@ -63,6 +67,12 @@ export class ActionManager implements ActionsManagerInterface {
if (data.length === 0) {
return false;
}
const { viewModeEnabled } = this.getAppState();
if (viewModeEnabled) {
if (!Object.values(MODES).includes(data[0].name)) {
return false;
}
}
event.preventDefault();
this.updater(
@@ -70,6 +80,7 @@ export class ActionManager implements ActionsManagerInterface {
this.getElementsIncludingDeleted(),
this.getAppState(),
null,
this.app,
),
);
return true;
@@ -81,43 +92,11 @@ export class ActionManager implements ActionsManagerInterface {
this.getElementsIncludingDeleted(),
this.getAppState(),
null,
this.app,
),
);
}
getContextMenuItems(actionFilter: ActionFilterFn = (action) => action) {
return Object.values(this.actions)
.filter(actionFilter)
.filter((action) => "contextItemLabel" in action)
.filter((action) =>
action.contextItemPredicate
? action.contextItemPredicate(
this.getElementsIncludingDeleted(),
this.getAppState(),
)
: true,
)
.sort(
(a, b) =>
(a.contextMenuOrder !== undefined ? a.contextMenuOrder : 999) -
(b.contextMenuOrder !== undefined ? b.contextMenuOrder : 999),
)
.map((action) => ({
// take last bit of the label "labels.<shortcutName>"
shortcutName: action.contextItemLabel?.split(".").pop() as ShortcutName,
label: action.contextItemLabel ? t(action.contextItemLabel) : "",
action: () => {
this.updater(
action.perform(
this.getElementsIncludingDeleted(),
this.getAppState(),
null,
),
);
},
}));
}
// Id is an attribute that we can use to pass in data like keys.
// This is needed for dynamically generated action components
// like the user list. We can use this key to extract more
@@ -132,6 +111,7 @@ export class ActionManager implements ActionsManagerInterface {
this.getElementsIncludingDeleted(),
this.getAppState(),
formState,
this.app,
),
);
};
+5 -3
View File
@@ -9,7 +9,7 @@ export type ShortcutName =
| "copyStyles"
| "pasteStyles"
| "selectAll"
| "delete"
| "deleteSelectedElements"
| "duplicateSelection"
| "sendBackward"
| "bringForward"
@@ -22,7 +22,8 @@ export type ShortcutName =
| "gridMode"
| "zenMode"
| "stats"
| "addToLibrary";
| "addToLibrary"
| "viewMode";
const shortcutMap: Record<ShortcutName, string[]> = {
cut: [getShortcutKey("CtrlOrCmd+X")],
@@ -31,7 +32,7 @@ const shortcutMap: Record<ShortcutName, string[]> = {
copyStyles: [getShortcutKey("CtrlOrCmd+Alt+C")],
pasteStyles: [getShortcutKey("CtrlOrCmd+Alt+V")],
selectAll: [getShortcutKey("CtrlOrCmd+A")],
delete: [getShortcutKey("Del")],
deleteSelectedElements: [getShortcutKey("Del")],
duplicateSelection: [
getShortcutKey("CtrlOrCmd+D"),
getShortcutKey(`Alt+${t("helpDialog.drag")}`),
@@ -56,6 +57,7 @@ const shortcutMap: Record<ShortcutName, string[]> = {
zenMode: [getShortcutKey("Alt+Z")],
stats: [],
addToLibrary: [],
viewMode: [getShortcutKey("Alt+R")],
};
export const getShortcutFromShortcutName = (name: ShortcutName) => {
+12 -5
View File
@@ -16,12 +16,18 @@ type ActionFn = (
elements: readonly ExcalidrawElement[],
appState: Readonly<AppState>,
formData: any,
app: { canvas: HTMLCanvasElement | null },
) => ActionResult | Promise<ActionResult>;
export type UpdaterFn = (res: ActionResult) => void;
export type ActionFilterFn = (action: Action) => void;
export type ActionName =
| "copy"
| "cut"
| "paste"
| "copyAsPng"
| "copyAsSvg"
| "sendBackward"
| "bringForward"
| "sendToBack"
@@ -29,6 +35,9 @@ export type ActionName =
| "copyStyles"
| "selectAll"
| "pasteStyles"
| "gridMode"
| "zenMode"
| "stats"
| "changeStrokeColor"
| "changeBackgroundColor"
| "changeFillStyle"
@@ -75,7 +84,8 @@ export type ActionName =
| "alignVerticallyCentered"
| "alignHorizontallyCentered"
| "distributeHorizontally"
| "distributeVertically";
| "distributeVertically"
| "viewMode";
export interface Action {
name: ActionName;
@@ -93,19 +103,16 @@ export interface Action {
elements: readonly ExcalidrawElement[],
) => boolean;
contextItemLabel?: string;
contextMenuOrder?: number;
contextItemPredicate?: (
elements: readonly ExcalidrawElement[],
appState: AppState,
) => boolean;
checked?: (appState: Readonly<AppState>) => boolean;
}
export interface ActionsManagerInterface {
actions: Record<ActionName, Action>;
registerAction: (action: Action) => void;
handleKeyDown: (event: KeyboardEvent) => boolean;
getContextMenuItems: (
actionFilter: ActionFilterFn,
) => { label: string; action: () => void }[];
renderAction: (name: ActionName) => React.ReactElement | null;
}
+5 -3
View File
@@ -5,7 +5,7 @@ import {
DEFAULT_TEXT_ALIGN,
} from "./constants";
import { t } from "./i18n";
import { AppState, FlooredNumber, NormalizedZoomValue } from "./types";
import { AppState, NormalizedZoomValue } from "./types";
import { getDateTime } from "./utils";
export const getDefaultAppState = (): Omit<
@@ -56,8 +56,8 @@ export const getDefaultAppState = (): Omit<
previousSelectedElementIds: {},
resizingElement: null,
scrolledOutside: false,
scrollX: 0 as FlooredNumber,
scrollY: 0 as FlooredNumber,
scrollX: 0,
scrollY: 0,
selectedElementIds: {},
selectedGroupIds: {},
selectionElement: null,
@@ -72,6 +72,7 @@ export const getDefaultAppState = (): Omit<
width: window.innerWidth,
zenModeEnabled: false,
zoom: { value: 1 as NormalizedZoomValue, translation: { x: 0, y: 0 } },
viewModeEnabled: false,
};
};
@@ -151,6 +152,7 @@ const APP_STATE_STORAGE_CONF = (<
width: { browser: false, export: false },
zenModeEnabled: { browser: true, export: false },
zoom: { browser: true, export: false },
viewModeEnabled: { browser: false, export: false },
});
const _clearAppStateForStorage = <ExportType extends "export" | "browser">(
+8 -1
View File
@@ -1,6 +1,13 @@
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
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 { RoughCanvas } from "roughjs/bin/canvas";
import rough from "roughjs/bin/rough";
import "../actions";
import { actionDeleteSelected, actionFinalize } from "../actions";
import clsx from "clsx";
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 { ActionManager } from "../actions/manager";
import { actions } from "../actions/register";
@@ -18,7 +40,6 @@ import {
} from "../clipboard";
import {
APP_NAME,
CANVAS_ONLY_ACTIONS,
CURSOR_TYPE,
DEFAULT_VERTICAL_ALIGN,
DRAGGING_THRESHOLD,
@@ -30,11 +51,13 @@ import {
LINE_CONFIRM_THRESHOLD,
MIME_TYPES,
POINTER_BUTTON,
SCROLL_TIMEOUT,
TAP_TWICE_TIMEOUT,
TEXT_TO_CENTER_SNAP_THRESHOLD,
TOUCH_CTX_MENU_TIMEOUT,
ZOOM_STEP,
} from "../constants";
import { exportCanvas, loadFromBlob } from "../data";
import { loadFromBlob } from "../data";
import { isValidLibrary } from "../data/json";
import { Library } from "../data/library";
import { restore } from "../data/restore";
@@ -127,7 +150,6 @@ import {
getSelectedElements,
isOverScrollBars,
isSomeElementSelected,
normalizeScroll,
} from "../scene";
import Scene from "../scene/Scene";
import { SceneState, ScrollBars } from "../scene/types";
@@ -155,10 +177,12 @@ import {
viewportCoordsToSceneCoords,
withBatchedUpdates,
} from "../utils";
import ContextMenu from "./ContextMenu";
import { isMobile } from "../is-mobile";
import ContextMenu, { ContextMenuOption } from "./ContextMenu";
import LayerUI from "./LayerUI";
import { Stats } from "./Stats";
import { Toast } from "./Toast";
import { actionToggleViewMode } from "../actions/actionToggleViewMode";
const { history } = createHistory();
@@ -248,6 +272,7 @@ export type ExcalidrawImperativeAPI = {
};
setScrollToCenter: InstanceType<typeof App>["setScrollToCenter"];
getSceneElements: InstanceType<typeof App>["getSceneElements"];
getAppState: () => InstanceType<typeof App>["state"];
readyPromise: ResolvablePromise<ExcalidrawImperativeAPI>;
ready: true;
};
@@ -274,6 +299,9 @@ class App extends React.Component<ExcalidrawProps, AppState> {
offsetLeft,
offsetTop,
excalidrawRef,
viewModeEnabled = false,
zenModeEnabled = false,
gridModeEnabled = false,
} = props;
this.state = {
...defaultAppState,
@@ -281,6 +309,9 @@ class App extends React.Component<ExcalidrawProps, AppState> {
width,
height,
...this.getCanvasOffsets({ offsetLeft, offsetTop }),
viewModeEnabled,
zenModeEnabled,
gridSize: gridModeEnabled ? GRID_SIZE : null,
};
if (excalidrawRef) {
const readyPromise =
@@ -298,6 +329,7 @@ class App extends React.Component<ExcalidrawProps, AppState> {
},
setScrollToCenter: this.setScrollToCenter,
getSceneElements: this.getSceneElements,
getAppState: () => this.state,
} as const;
if (typeof excalidrawRef === "function") {
excalidrawRef(api);
@@ -312,6 +344,7 @@ class App extends React.Component<ExcalidrawProps, AppState> {
this.syncActionResult,
() => this.state,
() => this.scene.getElementsIncludingDeleted(),
this,
);
this.actionManager.registerAll(actions);
@@ -319,6 +352,62 @@ class App extends React.Component<ExcalidrawProps, AppState> {
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() {
const {
zenModeEnabled,
@@ -326,20 +415,19 @@ class App extends React.Component<ExcalidrawProps, AppState> {
height: canvasDOMHeight,
offsetTop,
offsetLeft,
viewModeEnabled,
} = this.state;
const { onCollabButtonClick, onExportToBackend, renderFooter } = this.props;
const canvasScale = window.devicePixelRatio;
const canvasWidth = canvasDOMWidth * canvasScale;
const canvasHeight = canvasDOMHeight * canvasScale;
const DEFAULT_PASTE_X = canvasDOMWidth / 2;
const DEFAULT_PASTE_Y = canvasDOMHeight / 2;
return (
<div
className="excalidraw"
className={clsx("excalidraw", {
"excalidraw--view-mode": viewModeEnabled,
})}
ref={this.excalidrawContainerRef}
style={{
width: canvasDOMWidth,
@@ -369,10 +457,16 @@ class App extends React.Component<ExcalidrawProps, AppState> {
isCollaborating={this.props.isCollaborating || false}
onExportToBackend={onExportToBackend}
renderCustomFooter={renderFooter}
viewModeEnabled={viewModeEnabled}
showExitZenModeBtn={
typeof this.props?.zenModeEnabled === "undefined" && zenModeEnabled
}
/>
<div className="excalidraw-textEditorContainer" />
{this.state.showStats && (
<Stats
appState={this.state}
setAppState={this.setAppState}
elements={this.scene.getElements()}
onClose={this.toggleStats}
/>
@@ -383,28 +477,7 @@ class App extends React.Component<ExcalidrawProps, AppState> {
clearToast={this.clearToast}
/>
)}
<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>
<main>{this.renderCanvas()}</main>
</div>
);
}
@@ -444,6 +517,23 @@ class App extends React.Component<ExcalidrawProps, AppState> {
if (actionResult.commitToHistory) {
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(
(state) => ({
...actionResult.appState,
@@ -453,6 +543,9 @@ class App extends React.Component<ExcalidrawProps, AppState> {
height: state.height,
offsetTop: state.offsetTop,
offsetLeft: state.offsetLeft,
viewModeEnabled,
zenModeEnabled,
gridSize,
}),
() => {
if (actionResult.syncHistory) {
@@ -635,7 +728,6 @@ class App extends React.Component<ExcalidrawProps, AppState> {
}
this.scene.addCallback(this.onSceneUpdated);
this.addEventListeners();
// optim to avoid extra render on init
@@ -702,25 +794,16 @@ class App extends React.Component<ExcalidrawProps, AppState> {
}
private addEventListeners() {
this.removeEventListeners();
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.KEYUP, this.onKeyUp, { passive: true });
document.addEventListener(
EVENT.MOUSE_MOVE,
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
document.fonts?.addEventListener?.("loadingdone", this.onFontLoaded);
// Safari-only desktop pinch zoom
document.addEventListener(
EVENT.GESTURE_START,
@@ -737,6 +820,19 @@ class App extends React.Component<ExcalidrawProps, AppState> {
this.onGestureEnd as any,
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) {
@@ -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
.querySelector(".excalidraw")
?.classList.toggle("Appearance_dark", this.state.appearance === "dark");
@@ -798,6 +914,7 @@ class App extends React.Component<ExcalidrawProps, AppState> {
const pointerViewportCoords: SceneState["remotePointerViewportCoords"] = {};
const remoteSelectedElementIds: SceneState["remoteSelectedElementIds"] = {};
const pointerUsernames: { [id: string]: string } = {};
const pointerUserStates: { [id: string]: string } = {};
this.state.collaborators.forEach((user, socketId) => {
if (user.selectedElementIds) {
for (const id of Object.keys(user.selectedElementIds)) {
@@ -813,6 +930,9 @@ class App extends React.Component<ExcalidrawProps, AppState> {
if (user.username) {
pointerUsernames[socketId] = user.username;
}
if (user.userState) {
pointerUserStates[socketId] = user.userState;
}
pointerViewportCoords[socketId] = sceneCoordsToViewportCoords(
{
sceneX: user.pointer.x,
@@ -847,10 +967,12 @@ class App extends React.Component<ExcalidrawProps, AppState> {
remotePointerButton: cursorButton,
remoteSelectedElementIds,
remotePointerUsernames: pointerUsernames,
remotePointerUserStates: pointerUserStates,
shouldCacheIgnoreZoom: this.state.shouldCacheIgnoreZoom,
},
{
renderOptimizations: true,
renderScrollbars: !isMobile(),
},
);
if (scrollBars) {
@@ -879,6 +1001,10 @@ class App extends React.Component<ExcalidrawProps, AppState> {
}
}
private onScroll = debounce(() => {
this.setState({ ...this.getCanvasOffsets() });
}, SCROLL_TIMEOUT);
// Copy/paste
private onCut = withBatchedUpdates((event: ClipboardEvent) => {
@@ -906,44 +1032,6 @@ class App extends React.Component<ExcalidrawProps, AppState> {
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() {
didTapTwice = false;
}
@@ -1146,24 +1234,14 @@ class App extends React.Component<ExcalidrawProps, AppState> {
};
toggleZenMode = () => {
this.setState({
zenModeEnabled: !this.state.zenModeEnabled,
});
};
toggleGridMode = () => {
this.setState({
gridSize: this.state.gridSize ? null : GRID_SIZE,
});
this.actionManager.executeAction(actionToggleZenMode);
};
toggleStats = () => {
if (!this.state.showStats) {
trackEvent("dialog", "stats");
}
this.setState({
showStats: !this.state.showStats,
});
this.actionManager.executeAction(actionToggleStats);
};
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)) {
return;
}
if (this.state.viewModeEnabled) {
return;
}
if (event[KEYS.CTRL_OR_CMD]) {
this.setState({ isBindingEnabled: false });
}
if (event.code === CODES.NINE) {
this.setState({ isLibraryOpen: !this.state.isLibraryOpen });
}
@@ -1488,6 +1557,7 @@ class App extends React.Component<ExcalidrawProps, AppState> {
textWysiwyg({
id: element.id,
appState: this.state,
canvas: this.canvas,
getViewportCoords: (x, y) => {
const { x: viewportX, y: viewportY } = sceneCoordsToViewportCoords(
{
@@ -1778,8 +1848,8 @@ class App extends React.Component<ExcalidrawProps, AppState> {
const scaleFactor = distance / gesture.initialDistance;
this.setState(({ zoom, scrollX, scrollY, offsetLeft, offsetTop }) => ({
scrollX: normalizeScroll(scrollX + deltaX / zoom.value),
scrollY: normalizeScroll(scrollY + deltaY / zoom.value),
scrollX: scrollX + deltaX / zoom.value,
scrollY: scrollY + deltaY / zoom.value,
zoom: getNewZoom(
getNormalizedZoom(initialScale * scaleFactor),
zoom,
@@ -1796,10 +1866,11 @@ class App extends React.Component<ExcalidrawProps, AppState> {
if (isHoldingSpace || isPanning || isDraggingScrollBar) {
return;
}
const isPointerOverScrollBars = isOverScrollBars(
currentScrollBars,
event.clientX,
event.clientY,
event.clientX - this.state.offsetLeft,
event.clientY - this.state.offsetTop,
);
const isOverScrollBar = isPointerOverScrollBars.isOverEither;
if (!this.state.draggingElement && !this.state.multiElement) {
@@ -1893,7 +1964,7 @@ class App extends React.Component<ExcalidrawProps, AppState> {
points: points.slice(0, -1),
});
} else {
if (isPathALoop(points)) {
if (isPathALoop(points, this.state.zoom.value)) {
document.documentElement.style.cursor = CURSOR_TYPE.POINTER;
}
// update last uncommitted point
@@ -2080,14 +2151,16 @@ class App extends React.Component<ExcalidrawProps, AppState> {
lastPointerUp = onPointerUp;
window.addEventListener(EVENT.POINTER_MOVE, onPointerMove);
window.addEventListener(EVENT.POINTER_UP, onPointerUp);
window.addEventListener(EVENT.KEYDOWN, onKeyDown);
window.addEventListener(EVENT.KEYUP, onKeyUp);
pointerDownState.eventListeners.onMove = onPointerMove;
pointerDownState.eventListeners.onUp = onPointerUp;
pointerDownState.eventListeners.onKeyUp = onKeyUp;
pointerDownState.eventListeners.onKeyDown = onKeyDown;
if (!this.state.viewModeEnabled) {
window.addEventListener(EVENT.POINTER_MOVE, onPointerMove);
window.addEventListener(EVENT.POINTER_UP, onPointerUp);
window.addEventListener(EVENT.KEYDOWN, onKeyDown);
window.addEventListener(EVENT.KEYUP, onKeyUp);
pointerDownState.eventListeners.onMove = onPointerMove;
pointerDownState.eventListeners.onUp = onPointerUp;
pointerDownState.eventListeners.onKeyUp = onKeyUp;
pointerDownState.eventListeners.onKeyDown = onKeyDown;
}
};
private maybeOpenContextMenuAfterPointerDownOnTouchDevices = (
@@ -2137,7 +2210,8 @@ class App extends React.Component<ExcalidrawProps, AppState> {
!(
gesture.pointers.size === 0 &&
(event.button === POINTER_BUTTON.WHEEL ||
(event.button === POINTER_BUTTON.MAIN && isHoldingSpace))
(event.button === POINTER_BUTTON.MAIN && isHoldingSpace) ||
this.state.viewModeEnabled)
)
) {
return false;
@@ -2190,12 +2264,8 @@ class App extends React.Component<ExcalidrawProps, AppState> {
}
this.setState({
scrollX: normalizeScroll(
this.state.scrollX - deltaX / this.state.zoom.value,
),
scrollY: normalizeScroll(
this.state.scrollY - deltaY / this.state.zoom.value,
),
scrollX: this.state.scrollX - deltaX / this.state.zoom.value,
scrollY: this.state.scrollY - deltaY / this.state.zoom.value,
});
});
const teardown = withBatchedUpdates(
@@ -2256,8 +2326,8 @@ class App extends React.Component<ExcalidrawProps, AppState> {
),
scrollbars: isOverScrollBars(
currentScrollBars,
event.clientX,
event.clientY,
event.clientX - this.state.offsetLeft,
event.clientY - this.state.offsetTop,
),
// we need to duplicate because we'll be updating this state
lastCoords: { ...origin },
@@ -2566,7 +2636,10 @@ class App extends React.Component<ExcalidrawProps, AppState> {
const { multiElement } = this.state;
// 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, {
lastCommittedPoint:
multiElement.points[multiElement.points.length - 1],
@@ -3009,9 +3082,7 @@ class App extends React.Component<ExcalidrawProps, AppState> {
const x = event.clientX;
const dx = x - pointerDownState.lastCoords.x;
this.setState({
scrollX: normalizeScroll(
this.state.scrollX - dx / this.state.zoom.value,
),
scrollX: this.state.scrollX - dx / this.state.zoom.value,
});
pointerDownState.lastCoords.x = x;
return true;
@@ -3021,9 +3092,7 @@ class App extends React.Component<ExcalidrawProps, AppState> {
const y = event.clientY;
const dy = y - pointerDownState.lastCoords.y;
this.setState({
scrollY: normalizeScroll(
this.state.scrollY - dy / this.state.zoom.value,
),
scrollY: this.state.scrollY - dy / this.state.zoom.value,
});
pointerDownState.lastCoords.y = y;
return true;
@@ -3616,52 +3685,90 @@ class App extends React.Component<ExcalidrawProps, AppState> {
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 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) {
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({
options: [
navigator.clipboard && {
shortcutName: "paste",
label: t("labels.paste"),
action: () => this.pasteFromClipboard(null),
},
_isMobile &&
navigator.clipboard && {
name: "paste",
perform: (elements, appStates) => {
this.pasteFromClipboard(null);
return {
commitToHistory: false,
};
},
contextItemLabel: "labels.paste",
},
_isMobile && navigator.clipboard && separator,
probablySupportsClipboardBlob &&
elements.length > 0 && {
shortcutName: "copyAsPng",
label: t("labels.copyAsPng"),
action: this.copyToClipboardAsPng,
},
elements.length > 0 &&
actionCopyAsPng,
probablySupportsClipboardWriteText &&
elements.length > 0 && {
shortcutName: "copyAsSvg",
label: t("labels.copyAsSvg"),
action: this.copyToClipboardAsSvg,
},
...this.actionManager.getContextMenuItems((action) =>
CANVAS_ONLY_ACTIONS.includes(action.name),
),
{
checked: this.state.gridSize !== null,
shortcutName: "gridMode",
label: t("labels.gridMode"),
action: this.toggleGridMode,
},
{
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,
},
elements.length > 0 &&
actionCopyAsSvg,
((probablySupportsClipboardBlob && elements.length > 0) ||
(probablySupportsClipboardWriteText && elements.length > 0)) &&
separator,
actionSelectAll,
separator,
typeof this.props.gridModeEnabled === "undefined" &&
actionToggleGridMode,
typeof this.props.zenModeEnabled === "undefined" &&
actionToggleZenMode,
typeof this.props.viewModeEnabled === "undefined" &&
actionToggleViewMode,
actionToggleStats,
],
top: clientY,
left: clientX,
actionManager: this.actionManager,
appState: this.state,
});
return;
}
@@ -3670,39 +3777,55 @@ class App extends React.Component<ExcalidrawProps, AppState> {
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({
options: [
{
shortcutName: "cut",
label: t("labels.cut"),
action: this.cutAll,
},
navigator.clipboard && {
shortcutName: "copy",
label: t("labels.copy"),
action: this.copyAll,
},
navigator.clipboard && {
shortcutName: "paste",
label: t("labels.paste"),
action: () => this.pasteFromClipboard(null),
},
probablySupportsClipboardBlob && {
shortcutName: "copyAsPng",
label: t("labels.copyAsPng"),
action: this.copyToClipboardAsPng,
},
probablySupportsClipboardWriteText && {
shortcutName: "copyAsSvg",
label: t("labels.copyAsSvg"),
action: this.copyToClipboardAsSvg,
},
...this.actionManager.getContextMenuItems(
(action) => !CANVAS_ONLY_ACTIONS.includes(action.name),
),
_isMobile && actionCut,
_isMobile && navigator.clipboard && actionCopy,
_isMobile &&
navigator.clipboard && {
name: "paste",
perform: (elements, appStates) => {
this.pasteFromClipboard(null);
return {
commitToHistory: false,
};
},
contextItemLabel: "labels.paste",
},
_isMobile && separator,
...options,
separator,
actionCopyStyles,
actionPasteStyles,
separator,
maybeGroupAction && actionGroup,
maybeUngroupAction && actionUngroup,
(maybeGroupAction || maybeUngroupAction) && separator,
actionAddToLibrary,
separator,
actionSendBackward,
actionBringForward,
actionSendToBack,
actionBringToFront,
separator,
actionDuplicateSelection,
actionDeleteSelected,
],
top: clientY,
left: clientX,
actionManager: this.actionManager,
appState: this.state,
});
};
@@ -3733,9 +3856,15 @@ class App extends React.Component<ExcalidrawProps, AppState> {
}, 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 }) => ({
zoom: getNewZoom(
getNormalizedZoom(zoom.value - delta / 100),
getNormalizedZoom(newZoom),
zoom,
{ left: offsetLeft, top: offsetTop },
{
@@ -3758,14 +3887,14 @@ class App extends React.Component<ExcalidrawProps, AppState> {
if (event.shiftKey) {
this.setState(({ zoom, scrollX }) => ({
// on Mac, shift+wheel tends to result in deltaX
scrollX: normalizeScroll(scrollX - (deltaY || deltaX) / zoom.value),
scrollX: scrollX - (deltaY || deltaX) / zoom.value,
}));
return;
}
this.setState(({ zoom, scrollX, scrollY }) => ({
scrollX: normalizeScroll(scrollX - deltaX / zoom.value),
scrollY: normalizeScroll(scrollY - deltaY / zoom.value),
scrollX: scrollX - deltaX / zoom.value,
scrollY: scrollY - deltaY / zoom.value,
}));
});
+1 -1
View File
@@ -1,4 +1,4 @@
@import "../css/_variables";
@import "../css/variables.module";
.excalidraw {
.Avatar {
+4 -3
View File
@@ -1,10 +1,11 @@
@import "../css/_variables";
@import "../css/variables.module";
.excalidraw {
.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);
}
}
+2 -2
View File
@@ -25,8 +25,8 @@ const CollabButton = ({
onClick={onClick}
icon={users}
type="button"
title={t("buttons.roomDialog")}
aria-label={t("buttons.roomDialog")}
title={t("labels.liveCollaboration")}
aria-label={t("labels.liveCollaboration")}
showAriaLabel={useIsMobile()}
>
{collaboratorCount > 0 && (
+25 -25
View File
@@ -1,10 +1,10 @@
@import "../css/_variables";
@import "../css/variables.module";
.excalidraw {
.color-picker {
background: var(--popup-background-color);
border: 0px solid transparentize($oc-white, 0.75);
box-shadow: transparentize($oc-black, 0.75) 0px 1px 4px;
background: var(--popup-bg-color);
border: 0 solid transparentize($oc-white, 0.75);
box-shadow: transparentize($oc-black, 0.75) 0 1px 4px;
border-radius: 4px;
position: absolute;
@@ -24,11 +24,11 @@
}
.color-picker-triangle {
width: 0px;
height: 0px;
width: 0;
height: 0;
border-style: solid;
border-width: 0px 9px 10px;
border-color: transparent transparent var(--popup-background-color);
border-width: 0 9px 10px;
border-color: transparent transparent var(--popup-bg-color);
position: absolute;
top: -10px;
@@ -84,12 +84,12 @@
.color-picker-transparent {
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;
top: 0px;
right: 0px;
bottom: 0px;
left: 0px;
top: 0;
right: 0;
bottom: 0;
left: 0;
}
.color-picker-transparent,
@@ -104,11 +104,11 @@
width: 1.875rem;
:root[dir="ltr"] & {
border-radius: 4px 0px 0px 4px;
border-radius: 4px 0 0 4px;
}
:root[dir="rtl"] & {
border-radius: 0px 4px 4px 0px;
border-radius: 0 4px 4px 0;
}
color: var(--input-label-color);
@@ -144,7 +144,7 @@
}
.color-input-container:focus-within .color-picker-hash::after {
background: var(--input-background-color);
background: var(--input-bg-color);
:root[dir="ltr"] & {
right: -2px;
@@ -163,19 +163,19 @@
width: 12ch; /* length of `transparent` + 1 */
margin: 0;
font-size: 1rem;
background-color: var(--input-background-color);
color: var(--text-color-primary);
border: 0px;
background-color: var(--input-bg-color);
color: var(--text-primary-color);
border: 0;
outline: none;
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"] & {
border-radius: 0px 4px 4px 0px;
border-radius: 0 4px 4px 0;
}
:root[dir="rtl"] & {
border-radius: 4px 0px 0px 4px;
border-radius: 4px 0 0 4px;
}
float: left;
@@ -228,7 +228,7 @@
}
.color-picker-type-elementBackground .color-picker-keybinding {
color: #fff;
color: $oc-white;
}
.color-picker-swatch[aria-label="transparent"] .color-picker-keybinding {
@@ -241,10 +241,10 @@
&.Appearance_dark {
.color-picker-type-elementBackground .color-picker-keybinding {
color: #000;
color: $oc-black;
}
.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 {
.context-menu {
position: relative;
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;
list-style: none;
user-select: none;
margin: -0.25rem 0 0 0.125rem;
padding: 0.25rem 0;
background-color: var(--popup-secondary-background-color);
padding: 0.5rem 0;
background-color: var(--popup-secondary-bg-color);
border: 1px solid var(--button-gray-3);
cursor: default;
}
.context-menu button {
@@ -60,12 +61,12 @@
}
.context-menu-option:hover {
color: var(--popup-background-color);
color: var(--popup-bg-color);
background-color: var(--select-highlight-color);
&.dangerous {
.context-menu-option__label {
color: var(--popup-background-color);
color: var(--popup-bg-color);
}
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 clsx from "clsx";
import { Popover } from "./Popover";
import { t } from "../i18n";
import "./ContextMenu.scss";
import {
getShortcutFromShortcutName,
ShortcutName,
} from "../actions/shortcuts";
import { Action } from "../actions/types";
import { ActionManager } from "../actions/manager";
import { AppState } from "../types";
type ContextMenuOption = {
checked?: boolean;
shortcutName: ShortcutName;
label: string;
action(): void;
};
export type ContextMenuOption = "separator" | Action;
type Props = {
type ContextMenuProps = {
options: ContextMenuOption[];
onCloseRequest?(): void;
top: number;
left: number;
actionManager: ActionManager;
appState: Readonly<AppState>;
};
const ContextMenu = ({ options, onCloseRequest, top, left }: Props) => {
const ContextMenu = ({
options,
onCloseRequest,
top,
left,
actionManager,
appState,
}: ContextMenuProps) => {
const isDarkTheme = !!document
.querySelector(".excalidraw")
?.classList.contains("Appearance_dark");
@@ -43,23 +51,34 @@ const ContextMenu = ({ options, onCloseRequest, top, left }: Props) => {
className="context-menu"
onContextMenu={(event) => event.preventDefault()}
>
{options.map(({ action, checked, shortcutName, label }, idx) => (
<li data-testid={shortcutName} key={idx} onClick={onCloseRequest}>
<button
className={`context-menu-option
${shortcutName === "delete" ? "dangerous" : ""}
${checked ? "checkmark" : ""}`}
onClick={action}
>
<div className="context-menu-option__label">{label}</div>
<kbd className="context-menu-option__shortcut">
{shortcutName
? getShortcutFromShortcutName(shortcutName)
: ""}
</kbd>
</button>
</li>
))}
{options.map((option, idx) => {
if (option === "separator") {
return <hr key={idx} className="context-menu-option-separator" />;
}
const actionName = option.name;
const label = option.contextItemLabel
? t(option.contextItemLabel)
: "";
return (
<li key={idx} data-testid={actionName} onClick={onCloseRequest}>
<button
className={clsx("context-menu-option", {
dangerous: actionName === "deleteSelectedElements",
checkmark: option.checked?.(appState),
})}
onClick={() => actionManager.executeAction(option)}
>
<div className="context-menu-option__label">{label}</div>
<kbd className="context-menu-option__shortcut">
{actionName
? getShortcutFromShortcutName(actionName as ShortcutName)
: ""}
</kbd>
</button>
</li>
);
})}
</ul>
</Popover>
</div>
@@ -78,8 +97,10 @@ const getContextMenuNode = (): HTMLDivElement => {
type ContextMenuParams = {
options: (ContextMenuOption | false | null | undefined)[];
top: number;
left: number;
top: ContextMenuProps["top"];
left: ContextMenuProps["left"];
actionManager: ContextMenuProps["actionManager"];
appState: Readonly<AppState>;
};
const handleClose = () => {
@@ -101,6 +122,8 @@ export default {
left={params.left}
options={options}
onCloseRequest={handleClose}
actionManager={params.actionManager}
appState={params.appState}
/>,
getContextMenuNode(),
);
+2 -2
View File
@@ -1,4 +1,4 @@
@import "../css/_variables";
@import "../css/variables.module";
.excalidraw {
.Dialog {
@@ -45,7 +45,7 @@
position: sticky;
top: 0;
padding: calc(var(--space-factor) * 2);
background: var(--bg-color-island);
background: var(--island-bg-color);
font-size: 1.25em;
box-sizing: border-box;
+3 -10
View File
@@ -1,5 +1,6 @@
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 useIsMobile from "../is-mobile";
import { KEYS } from "../keys";
@@ -8,14 +9,6 @@ import { back, close } from "./icons";
import { Island } from "./Island";
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: {
children: React.ReactNode;
className?: string;
@@ -24,7 +17,7 @@ export const Dialog = (props: {
title: React.ReactNode;
autofocus?: boolean;
}) => {
const [islandNode, setIslandNode] = useRefState<HTMLDivElement>();
const [islandNode, setIslandNode] = useCallbackRefState<HTMLDivElement>();
useEffect(() => {
if (!islandNode) {
+7 -1
View File
@@ -1,4 +1,4 @@
@import "../css/_variables";
@import "../css/variables.module";
.excalidraw {
.ExportDialog__preview {
@@ -66,4 +66,10 @@
overflow-y: auto;
}
}
.shareable-link--viewonly {
svg {
width: 1.5em;
}
}
}
+26 -10
View File
@@ -12,7 +12,7 @@ import { exportToCanvas, getExportSize } from "../scene/export";
import { AppState } from "../types";
import { Dialog } from "./Dialog";
import "./ExportDialog.scss";
import { clipboard, exportFile, link } from "./icons";
import { clipboard, exportFile, eyeIcon, link } from "./icons";
import Stack from "./Stack";
import { ToolButton } from "./ToolButton";
@@ -66,7 +66,10 @@ const ExportModal = ({
onExportToPng: ExportCB;
onExportToSvg: ExportCB;
onExportToClipboard: ExportCB;
onExportToBackend?: ExportCB;
onExportToBackend?: (
elements: readonly NonDeletedExcalidrawElement[],
viewonly: boolean,
) => void;
onCloseRequest: () => void;
}) => {
const someElementIsSelected = isSomeElementSelected(elements, appState);
@@ -155,13 +158,23 @@ const ExportModal = ({
/>
)}
{onExportToBackend && (
<ToolButton
type="button"
icon={link}
title={t("buttons.getShareableLink")}
aria-label={t("buttons.getShareableLink")}
onClick={() => onExportToBackend(exportedElements)}
/>
<>
<ToolButton
type="button"
icon={link}
title={t("buttons.getShareableLink")}
aria-label={t("buttons.getShareableLink")}
onClick={() => onExportToBackend(exportedElements, false)}
/>
<ToolButton
type="button"
icon={eyeIcon}
className="shareable-link--viewonly"
title={t("buttons.getViewonlyShareableLink")}
aria-label={t("buttons.getShareableLink")}
onClick={() => onExportToBackend(exportedElements, true)}
/>
</>
)}
</Stack.Row>
<div className="ExportDialog__name">
@@ -236,7 +249,10 @@ export const ExportDialog = ({
onExportToPng: ExportCB;
onExportToSvg: ExportCB;
onExportToClipboard: ExportCB;
onExportToBackend?: ExportCB;
onExportToBackend?: (
elements: readonly NonDeletedExcalidrawElement[],
viewonly: boolean,
) => void;
}) => {
const [modalIsShown, setModalIsShown] = useState(false);
const triggerButton = useRef<HTMLButtonElement>(null);
+1 -1
View File
@@ -1,4 +1,4 @@
@import "../css/_variables";
@import "../css/variables.module";
.excalidraw {
.HelpDialog h3 {
+14 -3
View File
@@ -1,6 +1,6 @@
import React from "react";
import { t } from "../i18n";
import { isDarwin } from "../keys";
import { isDarwin, isWindows } from "../keys";
import { Dialog } from "./Dialog";
import { getShortcutKey } from "../utils";
import "./HelpDialog.scss";
@@ -224,9 +224,13 @@ export const HelpDialog = ({ onClose }: { onClose?: () => void }) => {
shortcuts={[getShortcutKey("Alt+Z")]}
/>
<Shortcut
label={t("labels.gridMode")}
label={t("labels.showGrid")}
shortcuts={[getShortcutKey("CtrlOrCmd+'")]}
/>
<Shortcut
label={t("labels.viewMode")}
shortcuts={[getShortcutKey("Alt+R")]}
/>
</ShortcutIsland>
</Column>
<Column>
@@ -328,7 +332,14 @@ export const HelpDialog = ({ onClose }: { onClose?: () => void }) => {
/>
<Shortcut
label={t("buttons.redo")}
shortcuts={[getShortcutKey("CtrlOrCmd+Shift+Z")]}
shortcuts={
isWindows
? [
getShortcutKey("CtrlOrCmd+Y"),
getShortcutKey("CtrlOrCmd+Shift+Z"),
]
: [getShortcutKey("CtrlOrCmd+Shift+Z")]
}
/>
<Shortcut
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
$wide-viewport-width: 1000px;
@@ -26,7 +26,7 @@ $wide-viewport-width: 1000px;
> span {
padding: 0.2rem 0.4rem;
background-color: var(--overlay-background-color);
background-color: var(--overlay-bg-color);
border-radius: 4px;
}
}
+12 -12
View File
@@ -1,4 +1,4 @@
@import "../css/_variables";
@import "../css/variables.module";
.excalidraw {
.picker-container {
@@ -8,9 +8,9 @@
}
.picker {
background: var(--popup-background-color);
border: 0px solid transparentize($oc-white, 0.75);
box-shadow: transparentize($oc-black, 0.75) 0px 1px 4px;
background: var(--popup-bg-color);
border: 0 solid transparentize($oc-white, 0.75);
box-shadow: transparentize($oc-black, 0.75) 0 1px 4px;
border-radius: 4px;
position: absolute;
}
@@ -56,8 +56,8 @@
}
.picker-triangle {
width: 0px;
height: 0px;
width: 0;
height: 0;
position: relative;
top: -10px;
:root[dir="ltr"] & {
@@ -73,7 +73,7 @@
content: "";
position: absolute;
border-style: solid;
border-width: 0px 9px 10px;
border-width: 0 9px 10px;
border-color: transparent transparent transparentize($oc-black, 0.9);
top: -1px;
}
@@ -82,8 +82,8 @@
content: "";
position: absolute;
border-style: solid;
border-width: 0px 9px 10px;
border-color: transparent transparent var(--popup-background-color);
border-width: 0 9px 10px;
border-color: transparent transparent var(--popup-bg-color);
}
}
@@ -121,7 +121,7 @@
}
.picker-type-elementBackground .picker-keybinding {
color: #fff;
color: $oc-white;
}
.picker-swatch[aria-label="transparent"] .picker-keybinding {
@@ -134,10 +134,10 @@
&.Appearance_dark {
.picker-type-elementBackground .picker-keybinding {
color: #000;
color: $oc-black;
}
.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 { LoadingMessage } from "./LoadingMessage";
import {
defaultLang,
Language,
languages,
setLanguageFirstTime,
} from "../i18n";
import { defaultLang, Language, languages, setLanguage } from "../i18n";
interface Props {
langCode: Language["code"];
@@ -23,7 +18,7 @@ export class InitializeApp extends React.Component<Props, State> {
const currentLang =
languages.find((lang) => lang.code === this.props.langCode) ||
defaultLang;
await setLanguageFirstTime(currentLang);
await setLanguage(currentLang);
this.setState({
isLoading: false,
});
+1 -1
View File
@@ -1,7 +1,7 @@
.excalidraw {
.Island {
--padding: 0;
background-color: var(--bg-color-island);
background-color: var(--island-bg-color);
backdrop-filter: saturate(100%) blur(10px);
box-shadow: var(--shadow-island);
border-radius: 4px;
+6 -4
View File
@@ -19,9 +19,9 @@
}
a {
margin-left: auto;
margin-inline-start: auto;
// 17px for scrollbar (needed for overlay scrollbars on Big Sur?) + 1px extra
padding-right: 18px;
padding-inline-end: 18px;
white-space: nowrap;
}
}
@@ -38,6 +38,8 @@
}
.layer-ui__wrapper {
z-index: var(--zIndex-layerUI);
.encrypted-icon {
position: relative;
margin-inline-start: 15px;
@@ -71,7 +73,7 @@
&__footer {
position: absolute;
z-index: 100;
bottom: 0px;
bottom: 0;
:root[dir="ltr"] & {
right: 0;
@@ -92,7 +94,7 @@
}
:root[dir="ltr"] &.transition-right {
transform: translate(999px, 0px);
transform: translate(999px, 0);
}
:root[dir="rtl"] &.transition-left {
+139 -72
View File
@@ -11,7 +11,7 @@ import { CLASSES } from "../constants";
import { exportCanvas } from "../data";
import { importLibraryFromJSON, saveLibraryAsJSON } from "../data/json";
import { Library } from "../data/library";
import { showSelectedShapeActions } from "../element";
import { isTextElement, showSelectedShapeActions } from "../element";
import { NonDeletedExcalidrawElement } from "../element/types";
import { Language, t } from "../i18n";
import useIsMobile from "../is-mobile";
@@ -27,7 +27,7 @@ import { ExportCB, ExportDialog } from "./ExportDialog";
import { FixedSideContainer } from "./FixedSideContainer";
import { GitHubCorner } from "./GitHubCorner";
import { HintViewer } from "./HintViewer";
import { exportFile, load, shield } from "./icons";
import { exportFile, load, shield, trash } from "./icons";
import { Island } from "./Island";
import "./LayerUI.scss";
import { LibraryUnit } from "./LibraryUnit";
@@ -52,15 +52,18 @@ interface LayerUIProps {
onLockToggle: () => void;
onInsertElements: (elements: readonly NonDeletedExcalidrawElement[]) => void;
zenModeEnabled: boolean;
showExitZenModeBtn: boolean;
toggleZenMode: () => void;
langCode: Language["code"];
isCollaborating: boolean;
onExportToBackend?: (
exportedElements: readonly NonDeletedExcalidrawElement[],
viewonly: boolean,
appState: AppState,
canvas: HTMLCanvasElement | null,
) => void;
renderCustomFooter?: (isMobile: boolean) => JSX.Element;
viewModeEnabled: boolean;
}
const useOnClickOutside = (
@@ -98,6 +101,7 @@ const LibraryMenuItems = ({
onInsertShape,
pendingElements,
setAppState,
setLibraryItems,
}: {
library: LibraryItems;
pendingElements: LibraryItem;
@@ -105,6 +109,7 @@ const LibraryMenuItems = ({
onInsertShape: (elements: LibraryItem) => void;
onAddToLibrary: (elements: LibraryItem) => void;
setAppState: React.Component<any, AppState>["setState"];
setLibraryItems: (library: LibraryItems) => void;
}) => {
const isMobile = useIsMobile();
const numCells = library.length + (pendingElements.length > 0 ? 1 : 0);
@@ -148,6 +153,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">
{t("labels.libraries")}
@@ -279,6 +297,7 @@ const LibraryMenu = ({
onInsertShape={onInsertShape}
pendingElements={pendingElements}
setAppState={setAppState}
setLibraryItems={setLibraryItems}
/>
)}
</Island>
@@ -295,10 +314,12 @@ const LayerUI = ({
onLockToggle,
onInsertElements,
zenModeEnabled,
showExitZenModeBtn,
toggleZenMode,
isCollaborating,
onExportToBackend,
renderCustomFooter,
viewModeEnabled,
}: LayerUIProps) => {
const isMobile = useIsMobile();
@@ -348,9 +369,9 @@ const LayerUI = ({
onExportToClipboard={createExporter("clipboard")}
onExportToBackend={
onExportToBackend
? (elements) => {
? (elements, viewonly) => {
onExportToBackend &&
onExportToBackend(elements, appState, canvas);
onExportToBackend(elements, viewonly, appState, canvas);
}
: undefined
}
@@ -358,6 +379,28 @@ const LayerUI = ({
);
};
const renderViewModeCanvasActions = () => {
return (
<Section
heading="canvasActions"
className={clsx("zen-mode-transition", {
"transition-left": zenModeEnabled,
})}
>
{/* the zIndex ensures this menu has higher stacking order,
see https://github.com/excalidraw/excalidraw/pull/1445 */}
<Island padding={2} style={{ zIndex: 1 }}>
<Stack.Col gap={4}>
<Stack.Row gap={1} justifyContent="space-between">
{actionManager.renderAction("saveScene")}
{actionManager.renderAction("saveAsScene")}
{renderExportDialog()}
</Stack.Row>
</Stack.Col>
</Island>
</Section>
);
};
const renderCanvasActions = () => (
<Section
heading="canvasActions"
@@ -400,7 +443,15 @@ const LayerUI = ({
"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
appState={appState}
elements={elements}
@@ -448,54 +499,59 @@ const LayerUI = ({
gap={4}
className={clsx({ "disable-pointerEvents": zenModeEnabled })}
>
{renderCanvasActions()}
{viewModeEnabled
? renderViewModeCanvasActions()
: renderCanvasActions()}
{shouldRenderSelectedShapeActions && renderSelectedShapeActions()}
</Stack.Col>
<Section heading="shapes">
{(heading) => (
<Stack.Col gap={4} align="start">
<Stack.Row gap={1}>
<Island
padding={1}
className={clsx({ "zen-mode": zenModeEnabled })}
>
<HintViewer appState={appState} elements={elements} />
{heading}
<Stack.Row gap={1}>
<ShapesSwitcher
elementType={appState.elementType}
setAppState={setAppState}
isLibraryOpen={appState.isLibraryOpen}
/>
</Stack.Row>
</Island>
<LockIcon
zenModeEnabled={zenModeEnabled}
checked={appState.elementLocked}
onChange={onLockToggle}
title={t("toolBar.lock")}
/>
</Stack.Row>
{libraryMenu}
</Stack.Col>
)}
</Section>
{!viewModeEnabled && (
<Section heading="shapes">
{(heading) => (
<Stack.Col gap={4} align="start">
<Stack.Row gap={1}>
<Island
padding={1}
className={clsx({ "zen-mode": zenModeEnabled })}
>
<HintViewer appState={appState} elements={elements} />
{heading}
<Stack.Row gap={1}>
<ShapesSwitcher
elementType={appState.elementType}
setAppState={setAppState}
isLibraryOpen={appState.isLibraryOpen}
/>
</Stack.Row>
</Island>
<LockIcon
zenModeEnabled={zenModeEnabled}
checked={appState.elementLocked}
onChange={onLockToggle}
title={t("toolBar.lock")}
/>
</Stack.Row>
{libraryMenu}
</Stack.Col>
)}
</Section>
)}
<UserList
className={clsx("zen-mode-transition", {
"transition-right": zenModeEnabled,
})}
>
{Array.from(appState.collaborators)
// Collaborator is either not initialized or is actually the current user.
.filter(([_, client]) => Object.keys(client).length !== 0)
.map(([clientId, client]) => (
<Tooltip
label={client.username || "Unknown user"}
key={clientId}
>
{actionManager.renderAction("goToCollaborator", clientId)}
</Tooltip>
))}
{appState.collaborators.size > 0 &&
Array.from(appState.collaborators)
// Collaborator is either not initialized or is actually the current user.
.filter(([_, client]) => Object.keys(client).length !== 0)
.map(([clientId, client]) => (
<Tooltip
label={client.username || "Unknown user"}
key={clientId}
>
{actionManager.renderAction("goToCollaborator", clientId)}
</Tooltip>
))}
</UserList>
</div>
</FixedSideContainer>
@@ -524,6 +580,20 @@ const LayerUI = ({
);
};
const renderGitHubCorner = () => {
return (
<aside
className={clsx(
"layer-ui__wrapper__github-corner zen-mode-transition",
{
"transition-right": zenModeEnabled,
},
)}
>
<GitHubCorner appearance={appState.appearance} />
</aside>
);
};
const renderFooter = () => (
<footer role="contentinfo" className="layer-ui__wrapper__footer">
<div
@@ -536,24 +606,12 @@ const LayerUI = ({
</div>
<button
className={clsx("disable-zen-mode", {
"disable-zen-mode--visible": zenModeEnabled,
"disable-zen-mode--visible": showExitZenModeBtn,
})}
onClick={toggleZenMode}
>
{t("buttons.exitZenMode")}
</button>
{appState.scrolledOutside && (
<button
className="scroll-back-to-content"
onClick={() => {
setAppState({
...calculateScrollCenter(elements, appState, canvas),
});
}}
>
{t("buttons.scrollBackToContent")}
</button>
)}
</footer>
);
@@ -599,26 +657,35 @@ const LayerUI = ({
canvas={canvas}
isCollaborating={isCollaborating}
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}
{renderFixedSideContainer()}
{renderBottomAppMenu()}
{
<aside
className={clsx(
"layer-ui__wrapper__github-corner zen-mode-transition",
{
"transition-right": zenModeEnabled,
},
)}
>
<GitHubCorner appearance={appState.appearance} />
</aside>
}
{renderGitHubCorner()}
{renderFooter()}
{appState.scrolledOutside && (
<button
className="scroll-back-to-content"
onClick={() => {
setAppState({
...calculateScrollCenter(elements, appState, canvas),
});
}}
>
{t("buttons.scrollBackToContent")}
</button>
)}
</div>
);
};
+162 -116
View File
@@ -29,6 +29,7 @@ type MobileMenuProps = {
canvas: HTMLCanvasElement | null;
isCollaborating: boolean;
renderCustomFooter?: (isMobile: boolean) => JSX.Element;
viewModeEnabled: boolean;
};
export const MobileMenu = ({
@@ -43,121 +44,166 @@ export const MobileMenu = ({
canvas,
isCollaborating,
renderCustomFooter,
}: MobileMenuProps) => (
<>
<FixedSideContainer side="top">
<Section heading="shapes">
{(heading) => (
<Stack.Col gap={4} align="center">
<Stack.Row gap={1}>
<Island padding={1}>
{heading}
<Stack.Row gap={1}>
<ShapesSwitcher
elementType={appState.elementType}
setAppState={setAppState}
isLibraryOpen={appState.isLibraryOpen}
/>
</Stack.Row>
</Island>
<LockIcon
checked={appState.elementLocked}
onChange={onLockToggle}
title={t("toolBar.lock")}
/>
</Stack.Row>
{libraryMenu}
</Stack.Col>
)}
</Section>
<HintViewer appState={appState} elements={elements} />
</FixedSideContainer>
<div
className="App-bottom-bar"
style={{
marginBottom: SCROLLBAR_WIDTH + SCROLLBAR_MARGIN * 2,
marginLeft: SCROLLBAR_WIDTH + SCROLLBAR_MARGIN * 2,
marginRight: SCROLLBAR_WIDTH + SCROLLBAR_MARGIN * 2,
}}
>
<Island padding={0}>
{appState.openMenu === "canvas" ? (
<Section className="App-mobile-menu" heading="canvasActions">
<div className="panelColumn">
<Stack.Col gap={4}>
{actionManager.renderAction("loadScene")}
{actionManager.renderAction("saveScene")}
{actionManager.renderAction("saveAsScene")}
{exportButton}
{actionManager.renderAction("clearCanvas")}
{onCollabButtonClick && (
<CollabButton
isCollaborating={isCollaborating}
collaboratorCount={appState.collaborators.size}
onClick={onCollabButtonClick}
/>
)}
<BackgroundPickerAndDarkModeToggle
actionManager={actionManager}
appState={appState}
setAppState={setAppState}
viewModeEnabled,
}: MobileMenuProps) => {
const renderToolbar = () => {
return (
<FixedSideContainer side="top" className="App-top-bar">
<Section heading="shapes">
{(heading) => (
<Stack.Col gap={4} align="center">
<Stack.Row gap={1}>
<Island padding={1}>
{heading}
<Stack.Row gap={1}>
<ShapesSwitcher
elementType={appState.elementType}
setAppState={setAppState}
isLibraryOpen={appState.isLibraryOpen}
/>
</Stack.Row>
</Island>
<LockIcon
checked={appState.elementLocked}
onChange={onLockToggle}
title={t("toolBar.lock")}
/>
{renderCustomFooter?.(true)}
<fieldset>
<legend>{t("labels.collaborators")}</legend>
<UserList mobile>
{Array.from(appState.collaborators)
// Collaborator is either not initialized or is actually the current user.
.filter(([_, client]) => Object.keys(client).length !== 0)
.map(([clientId, client]) => (
<React.Fragment key={clientId}>
{actionManager.renderAction(
"goToCollaborator",
clientId,
)}
</React.Fragment>
))}
</UserList>
</fieldset>
</Stack.Col>
</div>
</Section>
) : appState.openMenu === "shape" &&
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>
</Stack.Row>
{libraryMenu}
</Stack.Col>
)}
</footer>
</Island>
</div>
</>
);
</Section>
<HintViewer appState={appState} elements={elements} />
</FixedSideContainer>
);
};
const renderAppToolbar = () => {
if (viewModeEnabled) {
return (
<div className="App-toolbar-content">
{actionManager.renderAction("toggleCanvasMenu")}
</div>
);
}
return (
<div className="App-toolbar-content">
{actionManager.renderAction("toggleCanvasMenu")}
{actionManager.renderAction("toggleEditMenu")}
{actionManager.renderAction("undo")}
{actionManager.renderAction("redo")}
{actionManager.renderAction(
appState.multiElement ? "finalize" : "duplicateSelection",
)}
{actionManager.renderAction("deleteSelectedElements")}
</div>
);
};
const renderCanvasActions = () => {
if (viewModeEnabled) {
return (
<>
{actionManager.renderAction("saveScene")}
{actionManager.renderAction("saveAsScene")}
{exportButton}
</>
);
}
return (
<>
{actionManager.renderAction("loadScene")}
{actionManager.renderAction("saveScene")}
{actionManager.renderAction("saveAsScene")}
{exportButton}
{actionManager.renderAction("clearCanvas")}
{onCollabButtonClick && (
<CollabButton
isCollaborating={isCollaborating}
collaboratorCount={appState.collaborators.size}
onClick={onCollabButtonClick}
/>
)}
{
<BackgroundPickerAndDarkModeToggle
actionManager={actionManager}
appState={appState}
setAppState={setAppState}
/>
}
</>
);
};
return (
<>
{!viewModeEnabled && renderToolbar()}
<div
className="App-bottom-bar"
style={{
marginBottom: SCROLLBAR_WIDTH + SCROLLBAR_MARGIN * 2,
marginLeft: SCROLLBAR_WIDTH + SCROLLBAR_MARGIN * 2,
marginRight: SCROLLBAR_WIDTH + SCROLLBAR_MARGIN * 2,
}}
>
<Island padding={0}>
{appState.openMenu === "canvas" ? (
<Section className="App-mobile-menu" heading="canvasActions">
<div className="panelColumn">
<Stack.Col gap={4}>
{renderCanvasActions()}
{renderCustomFooter?.(true)}
{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-modal-container {
position: absolute;
z-index: 10;
}
.Modal {
position: fixed;
position: absolute;
top: 0;
left: 0;
right: 0;
@@ -15,7 +20,7 @@
}
.Modal__background {
position: fixed;
position: absolute;
top: 0;
left: 0;
right: 0;
@@ -39,10 +44,10 @@
overflow-y: auto;
// for modals, reset blurry bg
background: var(--bg-color-island);
background: var(--island-bg-color);
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);
border-radius: 6px;
@@ -82,7 +87,7 @@
}
.Modal__content {
position: fixed;
position: absolute;
top: 0;
left: 0;
right: 0;
+1 -1
View File
@@ -54,7 +54,7 @@ const useBodyRoot = () => {
?.classList.contains("Appearance_dark");
const div = document.createElement("div");
div.classList.add("excalidraw");
div.classList.add("excalidraw", "excalidraw-modal-container");
if (isDarkTheme) {
div.classList.add("Appearance_dark");
+1 -1
View File
@@ -1,4 +1,4 @@
@import "../css/_variables";
@import "../css/variables.module";
.excalidraw {
.PasteChartDialog {
+1 -9
View File
@@ -1,14 +1,6 @@
.excalidraw {
.popover {
position: absolute;
position: fixed;
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 {
position: fixed;
top: 64px;
right: 12px;
font-size: 12px;
z-index: 999;
.excalidraw {
.Stats {
position: absolute;
top: 64px;
right: 12px;
font-size: 12px;
z-index: 999;
h3 {
margin: 0 24px 8px 0;
white-space: nowrap;
}
h3 {
margin: 0 24px 8px 0;
white-space: nowrap;
}
.close {
float: right;
height: 16px;
width: 16px;
cursor: pointer;
svg {
.close {
float: right;
height: 16px;
width: 16px;
cursor: pointer;
svg {
width: 100%;
height: 100%;
}
}
table {
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 {
width: 100%;
th {
border-bottom: 1px solid var(--input-border-color);
padding: 4px;
}
tr {
td:nth-child(2) {
min-width: 24px;
text-align: right;
:root[dir="rtl"] & {
left: 12px;
right: initial;
h3 {
margin: 0 0 8px 24px;
}
.close {
float: left;
}
}
}
: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 { copyTextToSystemClipboard } from "../clipboard";
import { DEFAULT_VERSION } from "../constants";
import { getCommonBounds } from "../element/bounds";
import { NonDeletedExcalidrawElement } from "../element/types";
import {
@@ -9,7 +11,7 @@ import { t } from "../i18n";
import useIsMobile from "../is-mobile";
import { getTargetElements } from "../scene";
import { AppState } from "../types";
import { debounce, nFormatter } from "../utils";
import { debounce, getVersion, nFormatter } from "../utils";
import { close } from "./icons";
import { Island } from "./Island";
import "./Stats.scss";
@@ -25,6 +27,7 @@ const getStorageSizes = debounce((cb: (sizes: StorageSizes) => void) => {
export const Stats = (props: {
appState: AppState;
setAppState: React.Component<any, AppState>["setState"];
elements: readonly NonDeletedExcalidrawElement[];
onClose: () => void;
}) => {
@@ -50,6 +53,17 @@ export const Stats = (props: {
return null;
}
const version = getVersion();
let hash;
let timestamp;
if (version !== DEFAULT_VERSION) {
timestamp = version.slice(0, 16).replace("T", " ");
hash = version.slice(21);
} else {
timestamp = t("stats.versionNotAvailable");
}
return (
<div className="Stats">
<Island padding={2}>
@@ -156,6 +170,28 @@ export const Stats = (props: {
</td>
</tr>
)}
<tr>
<th colSpan={2}>{t("stats.version")}</th>
</tr>
<tr>
<td
colSpan={2}
style={{ textAlign: "center", cursor: "pointer" }}
onClick={async () => {
try {
await copyTextToSystemClipboard(getVersion());
props.setAppState({
toastMessage: t("toast.copyToClipboard"),
});
} catch {}
}}
title={t("stats.versionCopy")}
>
{timestamp}
<br />
{hash}
</td>
</tr>
</tbody>
</table>
</Island>
+4 -4
View File
@@ -1,19 +1,19 @@
@import "../css/_variables.scss";
@import "../css/variables.module";
.excalidraw {
.TextInput {
color: var(--text-color-primary);
color: var(--text-primary-color);
display: inline-block;
border: 1.5px solid var(--button-gray-1);
line-height: 1;
padding: 0.75rem;
white-space: nowrap;
border-radius: var(--space-factor);
background-color: var(--input-background-color);
background-color: var(--input-bg-color);
&:not(:focus) {
&: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 {
.Toast {
@@ -11,7 +11,7 @@
left: 50%;
margin-left: -150px;
padding: 4px 0;
position: fixed;
position: absolute;
text-align: center;
width: 300px;
z-index: 999999;
+1 -1
View File
@@ -1,5 +1,5 @@
@import "open-color/open-color.scss";
@import "../css/variables";
@import "../css/variables.module";
.excalidraw {
.ToolIcon {
+2 -10
View File
@@ -1,4 +1,4 @@
@import "../css/_variables";
@import "../css/variables.module";
.excalidraw {
.Tooltip {
position: relative;
@@ -48,15 +48,7 @@
}
}
// the following 3 rules ensure that the tooltip doesn't show (nor affect
// the cursor) when you drag over when you draw on canvas, but at the same
// time it still works when clicking on the link/shield
body:active & .Tooltip:not(:hover) {
pointer-events: none;
}
body:not(:active) & .Tooltip:hover .Tooltip__label {
.Tooltip:hover .Tooltip__label {
visibility: visible;
}
+4
View File
@@ -113,6 +113,10 @@ export const questionCircle = createIcon(
{ mirror: true },
);
export const eyeIcon = createIcon(
"M572.52 241.4C518.29 135.59 410.93 64 288 64S57.68 135.64 3.48 241.41a32.35 32.35 0 0 0 0 29.19C57.71 376.41 165.07 448 288 448s230.32-71.64 284.52-177.41a32.35 32.35 0 0 0 0-29.19zM288 400a144 144 0 1 1 144-144 143.93 143.93 0 0 1-144 144zm0-240a95.31 95.31 0 0 0-25.31 3.79 47.85 47.85 0 0 1-66.9 66.9A95.78 95.78 0 1 0 288 160z",
);
// Icon imported form Storybook
// Storybook is licensed under MIT https://github.com/storybookjs/storybook/blob/next/LICENSE
export const resetZoom = createIcon(
+19 -3
View File
@@ -2,8 +2,8 @@ import { FontFamily } from "./element/types";
export const APP_NAME = "Excalidraw";
export const DRAGGING_THRESHOLD = 10; // 10px
export const LINE_CONFIRM_THRESHOLD = 10; // 10px
export const DRAGGING_THRESHOLD = 10; // px
export const LINE_CONFIRM_THRESHOLD = 8; // px
export const ELEMENT_SHIFT_TRANSLATE_AMOUNT = 5;
export const ELEMENT_TRANSLATE_AMOUNT = 1;
export const TEXT_TO_CENTER_SNAP_THRESHOLD = 30;
@@ -46,6 +46,8 @@ export enum EVENT {
TOUCH_START = "touchstart",
TOUCH_END = "touchend",
HASHCHANGE = "hashchange",
VISIBILITY_CHANGE = "visibilitychange",
SCROLL = "scroll",
}
export const ENV = {
@@ -90,4 +92,18 @@ export const TAP_TWICE_TIMEOUT = 300;
export const TOUCH_CTX_MENU_TIMEOUT = 500;
export const TITLE_TIMEOUT = 10000;
export const TOAST_TIMEOUT = 5000;
export const VERSION_TIMEOUT = 15000;
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";
:root {
--zIndex-canvas: 1;
--zIndex-wysiwyg: 2;
--zIndex-layerUI: 3;
}
.excalidraw {
color: var(--text-color-primary);
color: var(--text-primary-color);
display: flex;
position: fixed;
top: 0;
bottom: 0;
left: 0;
@@ -30,6 +35,8 @@
image-rendering: pixelated; // chromium
// NOTE: must be declared *after* the above
image-rendering: -moz-crisp-edges; // FF
z-index: var(--zIndex-canvas);
}
&.Appearance_dark {
@@ -43,10 +50,10 @@
}
.FixedSideContainer {
padding-top: var(--sat, 0px);
padding-right: var(--sar, 0px);
padding-bottom: var(--sab, 0px);
padding-left: var(--sal, 0px);
padding-top: var(--sat, 0);
padding-right: var(--sar, 0);
padding-bottom: var(--sab, 0);
padding-left: var(--sal, 0);
}
.panelRow {
@@ -64,7 +71,7 @@
margin-top: 0.333rem;
margin-bottom: 0.333rem;
font-size: 0.75rem;
color: var(--text-color-primary);
color: var(--text-primary-color);
font-weight: bold;
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 {
position: absolute;
top: 0;
@@ -223,10 +237,10 @@
left: 0;
right: 0;
--bar-padding: calc(4 * var(--space-factor));
padding-top: #{"max(var(--bar-padding), var(--sat, 0px))"};
padding-right: var(--sar, 0px);
padding-bottom: var(--sab, 0px);
padding-left: var(--sal, 0px);
padding-top: #{"max(var(--bar-padding), var(--sat,0))"};
padding-right: var(--sar, 0);
padding-bottom: var(--sab, 0);
padding-left: var(--sal, 0);
z-index: 4;
display: flex;
align-items: flex-end;
@@ -243,7 +257,7 @@
pointer-events: initial;
.panelColumn {
padding: 8px 8px 0px 8px;
padding: 8px 8px 0 8px;
}
}
}
@@ -282,7 +296,7 @@
pointer-events: none !important;
}
.App-menu_top > * {
.layer-ui__wrapper:not(.disable-pointerEvents) .App-menu_top > * {
pointer-events: all;
}
@@ -323,7 +337,7 @@
}
}
.App-menu_bottom > * {
.layer-ui__wrapper:not(.disable-pointerEvents) .App-menu_bottom > * {
pointer-events: all;
}
@@ -347,7 +361,6 @@
.App-menu__left {
overflow-y: auto;
max-height: calc(100vh - 236px);
}
.dropdown-select {
@@ -419,7 +432,7 @@
.scroll-back-to-content {
color: var(--popup-text-color);
position: fixed;
position: absolute;
left: 50%;
bottom: 30px;
transform: translateX(-50%);
@@ -447,7 +460,7 @@
display: none;
}
.scroll-back-to-content {
bottom: calc(80px + var(--sab, 0px));
bottom: calc(80px + var(--sab, 0));
z-index: -1;
}
}
@@ -492,6 +505,13 @@
pointer-events: none !important;
}
&.excalidraw--view-mode {
.App-menu {
display: flex;
justify-content: space-between;
}
}
@media print {
.App-bottom-bar,
.FixedSideContainer,
+42 -42
View File
@@ -1,43 +1,43 @@
@import "open-color/open-color.scss";
:root {
--bg-color-island: rgba(255, 255, 255, 0.9);
--popup-background-color: #{$oc-white};
--space-factor: 0.25rem;
--appearance-filter: none;
--button-destructive-bg-color: #{$oc-red-1};
--button-destructive-color: #{$oc-red-9};
--button-gray-1: #{$oc-gray-2};
--button-gray-2: #{$oc-gray-4};
--button-gray-3: #{$oc-gray-5};
--input-border-color: #{$oc-gray-3};
--input-background-color: #{$oc-white};
--input-hover-background-color: #{$oc-gray-1};
--input-label-color: #{$oc-gray-7};
--button-special-active-bg-color: #{$oc-green-0};
--dialog-border-color: #{$oc-gray-6};
--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};
--icon-fill-color: #{$oc-black};
--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};
--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);
--sal: env(safe-area-inset-left);
--sar: env(safe-area-inset-right);
--text-color-primary: #{$oc-gray-8};
--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};
--sat: env(safe-area-inset-top);
--select-highlight-color: #{$oc-blue-5};
--appearance-filter: none;
--button-special-active-background-color: #{$oc-green-0};
--button-destructive-color: #{$oc-red-9};
--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};
--shadow-island: 0 1px 5px #{transparentize($oc-black, 0.85)};
--space-factor: 0.25rem;
--text-primary-color: #{$oc-gray-8};
}
.excalidraw {
&.Appearance_dark {
background: #000;
background: $oc-black;
&.Appearance_dark-background-none {
background: none;
@@ -45,31 +45,31 @@
}
&.Appearance_dark {
--text-color-primary: #{$oc-gray-4};
--bg-color-island: #1e1e1e;
--popup-background-color: #2c2c2c;
--appearance-filter: invert(93%) hue-rotate(180deg);
--button-destructive-bg-color: #5a0000;
--button-destructive-color: #{$oc-red-3};
--button-gray-1: #363636;
--button-gray-2: #272727;
--button-gray-3: #222;
--input-border-color: #2e2e2e;
--input-background-color: #121212;
--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);
--button-special-active-bg-color: #204624;
--dialog-border-color: #{$oc-gray-9};
--dropdown-icon: url('data:image/svg+xml,<svg xmlns="http://www.w3.org/2000/svg" width="292.4" height="292.4" viewBox="0 0 292 292"><path fill="%23ced4da" d="M287 197L159 69c-4-3-8-5-13-5s-9 2-13 5L5 197c-3 4-5 8-5 13s2 9 5 13c4 4 8 5 13 5h256c5 0 9-1 13-5s5-8 5-13-1-9-5-13z"/></svg>');
--focus-highlight-color: #{$oc-blue-6};
--select-highlight-color: #{$oc-blue-4};
--appearance-filter: invert(93%) hue-rotate(180deg);
--button-special-active-background-color: #204624;
--button-destructive-color: #{$oc-red-3};
--button-destructive-background-color: #5a0000;
--popup-secondary-background-color: #222;
--icon-fill-color: #{$oc-gray-4};
--icon-green-fill-color: #{$oc-green-4};
--input-bg-color: #121212;
--input-border-color: #2e2e2e;
--input-hover-bg-color: #181818;
--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-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
$is-mobile-query: "(max-width: 600px), (max-height: 500px) and (max-width: 1000px)";
:export {
isMobileQuery: unquote($is-mobile-query);
}
+1 -1
View File
@@ -1,4 +1,4 @@
import { fileSave } from "browser-nativefs";
import { fileSave } from "browser-fs-access";
import {
copyCanvasToClipboardAsPng,
copyTextToSystemClipboard,
+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 { MIME_TYPES } from "../constants";
import { clearElementsForExport } from "../element";
+1 -1
View File
@@ -141,7 +141,7 @@ export const restoreElements = (
}, [] as ExcalidrawElement[]);
};
const restoreAppState = (
export const restoreAppState = (
appState: ImportedDataState["appState"],
localAppState: Partial<AppState> | null,
): AppState => {
+1 -1
View File
@@ -129,7 +129,7 @@ export class LinearElementEditor {
isDragging &&
(activePointIndex === 0 || activePointIndex === element.points.length - 1)
) {
if (isPathALoop(element.points)) {
if (isPathALoop(element.points, appState.zoom.value)) {
LinearElementEditor.movePoint(
element,
activePointIndex,
+4 -3
View File
@@ -7,7 +7,8 @@ export const showSelectedShapeActions = (
elements: readonly NonDeletedExcalidrawElement[],
) =>
Boolean(
appState.editingElement ||
getSelectedElements(elements, appState).length ||
appState.elementType !== "selection",
!appState.viewModeEnabled &&
(appState.editingElement ||
getSelectedElements(elements, appState).length ||
appState.elementType !== "selection"),
);
+25 -8
View File
@@ -38,6 +38,7 @@ export const textWysiwyg = ({
onSubmit,
getViewportCoords,
element,
canvas,
}: {
id: ExcalidrawElement["id"];
appState: AppState;
@@ -45,6 +46,7 @@ export const textWysiwyg = ({
onSubmit: (text: string) => void;
getViewportCoords: (x: number, y: number) => [number, number];
element: ExcalidrawElement;
canvas: HTMLCanvasElement | null;
}) => {
const updateWysiwygStyle = () => {
const updatedElement = Scene.getScene(element)?.getElement(id);
@@ -89,9 +91,6 @@ export const textWysiwyg = ({
editable.dataset.type = "wysiwyg";
// prevent line wrapping on Safari
editable.wrap = "off";
editable.className = `excalidraw ${
appState.appearance === "dark" ? "Appearance_dark" : ""
}`;
Object.assign(editable.style, {
position: "fixed",
@@ -107,6 +106,8 @@ export const textWysiwyg = ({
overflow: "hidden",
// prevent line wrapping (`whitespace: nowrap` doesn't work on FF)
whiteSpace: "pre",
// must be specified because in dark mode canvas creates a stacking context
zIndex: "var(--zIndex-wysiwyg)",
});
updateWysiwygStyle();
@@ -152,6 +153,10 @@ export const textWysiwyg = ({
editable.oninput = null;
editable.onkeydown = null;
if (observer) {
observer.disconnect();
}
window.removeEventListener("resize", updateWysiwygStyle);
window.removeEventListener("wheel", stopEvent, true);
window.removeEventListener("pointerdown", onPointerDown);
@@ -160,7 +165,7 @@ export const textWysiwyg = ({
unbindUpdate();
document.body.removeChild(editable);
editable.remove();
};
const rebindBlur = () => {
@@ -198,15 +203,27 @@ export const textWysiwyg = ({
let isDestroyed = false;
editable.onblur = handleSubmit;
// reposition wysiwyg in case of window resize. Happens on mobile when
// device keyboard is opened.
window.addEventListener("resize", updateWysiwygStyle);
// reposition wysiwyg in case of canvas is resized. Using ResizeObserver
// 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("wheel", stopEvent, {
passive: false,
capture: true,
});
document.body.appendChild(editable);
document
.querySelector(".excalidraw-textEditorContainer")!
.appendChild(editable);
editable.focus();
editable.select();
};
+355 -160
View File
@@ -6,10 +6,11 @@ import { APP_NAME, ENV, EVENT } from "../../constants";
import { ImportedDataState } from "../../data/types";
import { ExcalidrawElement } from "../../element/types";
import {
getElementMap,
getSceneVersion,
getSyncableElements,
} from "../../packages/excalidraw/index";
import { AppState, Collaborator, Gesture } from "../../types";
import { Collaborator, Gesture } from "../../types";
import { resolvablePromise, withBatchedUpdates } from "../../utils";
import {
INITIAL_SCENE_UPDATE_TIMEOUT,
@@ -18,12 +19,16 @@ import {
} from "../app_constants";
import {
decryptAESGEM,
generateCollaborationLink,
getCollaborationLinkData,
generateCollaborationLinkData,
getCollaborationLink,
SocketUpdateDataSource,
SOCKET_SERVER,
} from "../data";
import { isSavedToFirebase, saveToFirebase } from "../data/firebase";
import {
isSavedToFirebase,
loadFromFirebase,
saveToFirebase,
} from "../data/firebase";
import {
importUsernameFromLocalStorage,
saveUsernameToLocalStorage,
@@ -31,20 +36,26 @@ import {
} from "../data/localStorage";
import Portal from "./Portal";
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 {
isCollaborating: boolean;
modalIsShown: boolean;
errorMessage: string;
username: string;
userState: UserIdleState;
activeRoomLink: string;
}
type CollabInstance = InstanceType<typeof CollabWrapper>;
export interface CollabAPI {
isCollaborating: CollabState["isCollaborating"];
/** function so that we can access the latest value from stale callbacks */
isCollaborating: () => boolean;
username: CollabState["username"];
userState: CollabState["userState"];
onPointerUpdate: CollabInstance["onPointerUpdate"];
initializeSocketClient: CollabInstance["initializeSocketClient"];
onCollabButtonClick: CollabInstance["onCollabButtonClick"];
@@ -56,31 +67,41 @@ type ReconciledElements = readonly ExcalidrawElement[] & {
};
interface Props {
children: (collab: CollabAPI) => React.ReactNode;
// NOTE not type-safe because the refObject may in fact not be initialized
// with ExcalidrawImperativeAPI yet
excalidrawRef: React.MutableRefObject<ExcalidrawImperativeAPI>;
excalidrawAPI: ExcalidrawImperativeAPI;
}
const {
Context: CollabContext,
Consumer: CollabContextConsumer,
Provider: CollabContextProvider,
} = createInverseContext<{ api: CollabAPI | null }>({ api: null });
export { CollabContext, CollabContextConsumer };
class CollabWrapper extends PureComponent<Props, CollabState> {
portal: Portal;
excalidrawAPI: Props["excalidrawAPI"];
isCollaborating: boolean = false;
activeIntervalId: number | null;
idleTimeoutId: number | null;
private socketInitializationTimer?: NodeJS.Timeout;
private excalidrawRef: Props["excalidrawRef"];
excalidrawAppState?: AppState;
private lastBroadcastedOrReceivedSceneVersion: number = -1;
private collaborators = new Map<string, Collaborator>();
constructor(props: Props) {
super(props);
this.state = {
isCollaborating: false,
modalIsShown: false,
errorMessage: "",
username: importUsernameFromLocalStorage() || "",
userState: UserIdleState.ACTIVE,
activeRoomLink: "",
};
this.portal = new Portal(this);
this.excalidrawRef = props.excalidrawRef;
this.excalidrawAPI = props.excalidrawAPI;
this.activeIntervalId = null;
this.idleTimeoutId = null;
}
componentDidMount() {
@@ -104,18 +125,32 @@ class CollabWrapper extends PureComponent<Props, CollabState> {
componentWillUnmount() {
window.removeEventListener(EVENT.BEFORE_UNLOAD, this.beforeUnload);
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 = () => {
this.destroySocketClient();
this.destroySocketClient({ isUnload: true });
};
private beforeUnload = withBatchedUpdates((event: BeforeUnloadEvent) => {
const syncableElements = getSyncableElements(
this.getSceneElementsIncludingDeleted(),
);
if (
this.state.isCollaborating &&
this.isCollaborating &&
!isSavedToFirebase(this.portal, syncableElements)
) {
// 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 = "";
}
if (this.state.isCollaborating || this.portal.roomId) {
if (this.isCollaborating || this.portal.roomId) {
try {
localStorage?.setItem(
STORAGE_KEYS.LOCAL_STORAGE_KEY_COLLAB_FORCE_FLAG,
@@ -142,7 +177,7 @@ class CollabWrapper extends PureComponent<Props, CollabState> {
saveCollabRoomToFirebase = async (
syncableElements: ExcalidrawElement[] = getSyncableElements(
this.excalidrawRef.current!.getSceneElementsIncludingDeleted(),
this.excalidrawAPI.getSceneElementsIncludingDeleted(),
),
) => {
try {
@@ -153,143 +188,188 @@ class CollabWrapper extends PureComponent<Props, CollabState> {
};
openPortal = async () => {
window.history.pushState({}, APP_NAME, await generateCollaborationLink());
const elements = this.excalidrawRef.current!.getSceneElements();
// remove deleted elements from elements array & history to ensure we don't
// expose potentially sensitive user data in case user manually deletes
// 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();
return this.initializeSocketClient(null);
};
closePortal = () => {
this.saveCollabRoomToFirebase();
window.history.pushState({}, APP_NAME, window.location.origin);
this.destroySocketClient();
if (window.confirm(t("alerts.collabStopOverridePrompt"))) {
window.history.pushState({}, APP_NAME, window.location.origin);
this.destroySocketClient();
}
};
private destroySocketClient = () => {
this.collaborators = new Map();
this.excalidrawRef.current!.updateScene({
collaborators: this.collaborators,
});
this.setState({
isCollaborating: false,
activeRoomLink: "",
});
private destroySocketClient = (opts?: { isUnload: boolean }) => {
if (!opts?.isUnload) {
this.collaborators = new Map();
this.excalidrawAPI.updateScene({
collaborators: this.collaborators,
});
this.setState({
activeRoomLink: "",
});
this.isCollaborating = false;
}
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) {
return null;
}
const scenePromise = resolvablePromise<ImportedDataState | null>();
let roomId;
let roomKey;
const roomMatch = getCollaborationLinkData(window.location.href);
if (roomMatch) {
const roomId = roomMatch[1];
const roomKey = roomMatch[2];
// 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);
const { default: socketIOClient }: any = await import(
/* webpackChunkName: "socketIoClient" */ "socket.io-client"
if (existingRoomLinkData) {
({ roomId, roomKey } = existingRoomLinkData);
} else {
({ roomId, roomKey } = await generateCollaborationLinkData());
window.history.pushState(
{},
APP_NAME,
getCollaborationLink({ roomId, roomKey }),
);
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 = () => {
@@ -300,12 +380,60 @@ class CollabWrapper extends PureComponent<Props, CollabState> {
private reconcileElements = (
elements: readonly ExcalidrawElement[],
): 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
// we just received!
// Note: this needs to be set before updating the scene as it
// syncronously calls render.
// synchronously calls render.
this.setLastBroadcastedOrReceivedSceneVersion(getSceneVersion(newElements));
return newElements as ReconciledElements;
@@ -319,10 +447,10 @@ class CollabWrapper extends PureComponent<Props, CollabState> {
}: { init?: boolean; initFromSnapshot?: boolean } = {},
) => {
if (init || initFromSnapshot) {
this.excalidrawRef.current!.setScrollToCenter(elements);
this.excalidrawAPI.setScrollToCenter(elements);
}
this.excalidrawRef.current!.updateScene({
this.excalidrawAPI.updateScene({
elements,
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
// 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.
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[]) {
@@ -347,7 +527,7 @@ class CollabWrapper extends PureComponent<Props, CollabState> {
}
}
this.collaborators = collaborators;
this.excalidrawRef.current!.updateScene({ collaborators });
this.excalidrawAPI.updateScene({ collaborators });
});
}
@@ -360,7 +540,7 @@ class CollabWrapper extends PureComponent<Props, CollabState> {
};
public getSceneElementsIncludingDeleted = () => {
return this.excalidrawRef.current!.getSceneElementsIncludingDeleted();
return this.excalidrawAPI.getSceneElementsIncludingDeleted();
};
onPointerUpdate = (payload: {
@@ -373,11 +553,12 @@ class CollabWrapper extends PureComponent<Props, CollabState> {
this.portal.broadcastMouseLocation(payload);
};
broadcastElements = (
elements: readonly ExcalidrawElement[],
state: AppState,
) => {
this.excalidrawAppState = state;
onIdleStateChange = (userState: UserIdleState) => {
this.setState({ userState });
this.portal.broadcastIdleChange(userState);
};
broadcastElements = (elements: readonly ExcalidrawElement[]) => {
if (
getSceneVersion(elements) >
this.getLastBroadcastedOrReceivedSceneVersion()
@@ -396,7 +577,7 @@ class CollabWrapper extends PureComponent<Props, CollabState> {
this.portal.broadcastScene(
SCENE.UPDATE,
getSyncableElements(
this.excalidrawRef.current!.getSceneElementsIncludingDeleted(),
this.excalidrawAPI.getSceneElementsIncludingDeleted(),
),
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() {
const { children } = this.props;
const { modalIsShown, username, errorMessage, activeRoomLink } = this.state;
return (
@@ -450,14 +648,11 @@ class CollabWrapper extends PureComponent<Props, CollabState> {
onClose={() => this.setState({ errorMessage: "" })}
/>
)}
{children({
isCollaborating: this.state.isCollaborating,
username: this.state.username,
onPointerUpdate: this.onPointerUpdate,
initializeSocketClient: this.initializeSocketClient,
onCollabButtonClick: this.onCollabButtonClick,
broadcastElements: this.broadcastElements,
})}
<CollabContextProvider
value={{
api: this.getContextValue(),
}}
/>
</>
);
}
+30 -71
View File
@@ -6,23 +6,21 @@ import {
import CollabWrapper from "./CollabWrapper";
import {
getElementMap,
getSyncableElements,
} from "../../packages/excalidraw/index";
import { getSyncableElements } from "../../packages/excalidraw/index";
import { ExcalidrawElement } from "../../element/types";
import { BROADCAST, SCENE } from "../app_constants";
import { UserIdleState } from "./types";
class Portal {
app: CollabWrapper;
collab: CollabWrapper;
socket: SocketIOClient.Socket | null = null;
socketInitialized: boolean = false; // we don't want the socket to emit any updates until it is fully initialized
roomId: string | null = null;
roomKey: string | null = null;
broadcastedElementVersions: Map<string, number> = new Map();
constructor(app: CollabWrapper) {
this.app = app;
constructor(collab: CollabWrapper) {
this.collab = collab;
}
open(socket: SocketIOClient.Socket, id: string, key: string) {
@@ -30,7 +28,7 @@ class Portal {
this.roomId = id;
this.roomKey = key;
// Initialize socket listeners (moving from App)
// Initialize socket listeners
this.socket.on("init-room", () => {
if (this.socket) {
this.socket.emit("join-room", this.roomId);
@@ -39,12 +37,12 @@ class Portal {
this.socket.on("new-user", async (_socketId: string) => {
this.broadcastScene(
SCENE.INIT,
getSyncableElements(this.app.getSceneElementsIncludingDeleted()),
getSyncableElements(this.collab.getSceneElementsIncludingDeleted()),
/* syncAll */ true,
);
});
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,
);
if (syncAll && this.app.state.isCollaborating) {
if (syncAll && this.collab.isCollaborating) {
await Promise.all([
broadcastPromise,
this.app.saveCollabRoomToFirebase(syncableElements),
this.collab.saveCollabRoomToFirebase(syncableElements),
]);
} else {
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: {
pointer: SocketUpdateDataSource["MOUSE_LOCATION"]["payload"]["pointer"];
button: SocketUpdateDataSource["MOUSE_LOCATION"]["payload"]["button"];
@@ -146,9 +161,9 @@ class Portal {
socketId: this.socket.id,
pointer: payload.pointer,
button: payload.button || "up",
selectedElementIds:
this.app.excalidrawAppState?.selectedElementIds || {},
username: this.app.state.username,
selectedElementIds: this.collab.excalidrawAPI.getAppState()
.selectedElementIds,
username: this.collab.state.username,
},
};
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;
+18 -4
View File
@@ -1,4 +1,4 @@
@import "../../css/_variables";
@import "../../css/variables.module";
.excalidraw {
.RoomDialog-linkContainer {
@@ -7,7 +7,7 @@
}
.RoomDialog-link {
color: var(--text-color-primary);
color: var(--text-primary-color);
min-width: 0;
flex: 1 1 auto;
margin-inline-start: 1em;
@@ -32,15 +32,29 @@
display: flex;
align-items: 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 {
background-color: var(--input-background-color);
background-color: var(--input-bg-color);
border-color: var(--input-border-color);
appearance: none;
min-width: 0;
flex: 1 1 auto;
margin-inline-start: 1em;
@media #{$is-mobile-query} {
margin-top: 0.5em;
margin-inline-start: 0;
}
height: 2.5rem;
font-size: 1em;
line-height: 1.5;
@@ -53,7 +67,7 @@
}
.Modal .RoomDialog-stopSession {
background-color: var(--button-destructive-background-color);
background-color: var(--button-destructive-bg-color);
.ToolIcon__label,
.ToolIcon__icon svg {
+5 -1
View File
@@ -119,7 +119,11 @@ const RoomDialog = ({
);
};
return (
<Dialog small onCloseRequest={handleClose} title={t("labels.createRoom")}>
<Dialog
small
onCloseRequest={handleClose}
title={t("labels.liveCollaboration")}
>
{renderRoomDialog()}
</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 (
roomId: string,
roomKey: string,
socket: SocketIOClient.Socket | null,
): Promise<readonly ExcalidrawElement[] | null> => {
const firebase = await getFirebase();
const db = firebase.firestore();
@@ -160,5 +161,12 @@ export const loadFromFirebase = async (
const storedScene = doc.data() as FirebaseStoredScene;
const ciphertext = storedScene.ciphertext.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);
};
+33 -9
View File
@@ -4,6 +4,7 @@ import { ImportedDataState } from "../../data/types";
import { ExcalidrawElement } from "../../element/types";
import { t } from "../../i18n";
import { AppState } from "../../types";
import { UserIdleState } from "../collab/types";
const byteToHex = (byte: number): string => `0${byte.toString(16)}`.slice(-2);
@@ -59,6 +60,14 @@ export type SocketUpdateDataSource = {
username: string;
};
};
IDLE_STATUS: {
type: "IDLE_STATUS";
payload: {
socketId: string;
userState: UserIdleState;
username: string;
};
};
};
export type SocketUpdateDataIncoming =
@@ -125,17 +134,27 @@ export const decryptAESGEM = async (
};
export const getCollaborationLinkData = (link: string) => {
if (link.length === 0) {
return;
}
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 () => {
const id = await generateRandomID();
const key = await generateEncryptionKey();
return `${window.location.origin}${window.location.pathname}#room=${id},${key}`;
export const generateCollaborationLinkData = async () => {
const roomId = await generateRandomID();
const roomKey = await generateEncryptionKey();
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) =>
@@ -232,6 +251,7 @@ export const loadScene = async (
export const exportToBackend = async (
elements: readonly ExcalidrawElement[],
appState: AppState,
viewonly: boolean,
) => {
const json = serializeAsJSON(elements, appState);
const encoded = new TextEncoder().encode(json);
@@ -269,9 +289,13 @@ export const exportToBackend = async (
const json = await response.json();
if (json.id) {
const url = new URL(window.location.href);
url.hash = "#";
if (viewonly) {
url.hash = `${url.hash}viewonly=true&`;
}
// We need to store the key (and less importantly the id) as hash instead
// of queryParam in order to never send it to the server
url.hash = `json=${json.id},${exportedKey.k!}`;
url.hash = `${url.hash}json=${json.id},${exportedKey.k!}`;
const urlString = url.toString();
window.prompt(`🔒${t("alerts.uploadedSecurly")}`, urlString);
} else if (json.error_class === "RequestTooLargeError") {
+81 -138
View File
@@ -1,6 +1,7 @@
import LanguageDetector from "i18next-browser-languagedetector";
import React, {
useCallback,
useContext,
useEffect,
useLayoutEffect,
useRef,
@@ -17,12 +18,13 @@ import {
ExcalidrawElement,
NonDeletedExcalidrawElement,
} from "../element/types";
import { useCallbackRefState } from "../hooks/useCallbackRefState";
import { Language, t } from "../i18n";
import Excalidraw, {
defaultLang,
languages,
} from "../packages/excalidraw/index";
import { AppState, ExcalidrawAPIRefValue } from "../types";
import { AppState } from "../types";
import {
debounce,
getVersion,
@@ -30,14 +32,16 @@ import {
resolvablePromise,
} from "../utils";
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 { exportToBackend, getCollaborationLinkData, loadScene } from "./data";
import { loadFromFirebase } from "./data/firebase";
import {
importFromLocalStorage,
saveToLocalStorage,
STORAGE_KEYS,
} from "./data/localStorage";
const languageDetector = new LanguageDetector();
@@ -49,15 +53,6 @@ languageDetector.init({
checkWhitelist: false,
});
const excalidrawRef: React.MutableRefObject<
MarkRequired<ExcalidrawAPIRefValue, "ready" | "readyPromise">
> = {
current: {
readyPromise: resolvablePromise(),
ready: false,
},
};
const saveDebounced = debounce(
(elements: readonly ExcalidrawElement[], state: AppState) => {
saveToLocalStorage(elements, state);
@@ -69,65 +64,27 @@ const onBlur = () => {
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: {
resetScene: ExcalidrawImperativeAPI["resetScene"];
initializeSocketClient: CollabAPI["initializeSocketClient"];
}): Promise<Scene | null> => {
collabAPI: CollabAPI;
}): Promise<ImportedDataState | null> => {
const searchParams = new URLSearchParams(window.location.search);
const id = searchParams.get("id");
const jsonMatch = window.location.hash.match(
/^#json=([0-9]+),([a-zA-Z0-9_-]+)$/,
/json=([0-9]+),([a-zA-Z0-9_-]+)/,
);
const initialData = importFromLocalStorage();
let scene = await loadScene(null, null, initialData);
let isCollabScene = !!getCollaborationLinkData(window.location.href);
const isExternalScene = !!(id || jsonMatch || isCollabScene);
let roomLinkData = getCollaborationLinkData(window.location.href);
const isExternalScene = !!(id || jsonMatch || roomLinkData);
if (isExternalScene) {
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"))
) {
// Backwards compatibility with legacy url format
@@ -136,7 +93,11 @@ const initializeScene = async (opts: {
} else if (jsonMatch) {
scene = await loadScene(jsonMatch[1], jsonMatch[2], initialData);
}
if (!isCollabScene) {
const isViewModeEnabled = !!window.location.hash.match(/#viewonly=true/);
if (isViewModeEnabled) {
scene.appState.viewModeEnabled = true;
}
if (!roomLinkData) {
window.history.replaceState({}, APP_NAME, window.location.origin);
}
} else {
@@ -153,45 +114,19 @@ const initializeScene = async (opts: {
});
}
isCollabScene = false;
roomLinkData = null;
window.history.replaceState({}, APP_NAME, window.location.origin);
}
}
if (isCollabScene) {
// when joining a room we don't want user's local scene data to be merged
// 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;
if (roomLinkData) {
return opts.collabAPI.initializeSocketClient(roomLinkData);
} else if (scene) {
return scene;
}
return null;
};
function ExcalidrawWrapper(props: { collab: CollabAPI }) {
function ExcalidrawWrapper() {
// dimensions
// ---------------------------------------------------------------------------
@@ -202,6 +137,7 @@ function ExcalidrawWrapper(props: { collab: CollabAPI }) {
const [errorMessage, setErrorMessage] = useState("");
const currentLangCode = languageDetector.detect() || defaultLang.code;
const [langCode, setLangCode] = useState(currentLangCode);
const [viewModeEnabled, setViewModeEnabled] = useState(false);
useLayoutEffect(() => {
const onResize = () => {
@@ -225,39 +161,41 @@ function ExcalidrawWrapper(props: { collab: CollabAPI }) {
if (!initialStatePromiseRef.current.promise) {
initialStatePromiseRef.current.promise = resolvablePromise<ImportedDataState | null>();
}
const { collab } = props;
useEffect(() => {
// Delayed so that the app has a time to load the latest SW
setTimeout(() => {
trackEvent("load", "version", getVersion());
}, VERSION_TIMEOUT);
}, []);
excalidrawRef.current!.readyPromise.then((excalidrawApi) => {
initializeScene({
resetScene: excalidrawApi.resetScene,
initializeSocketClient: collab.initializeSocketClient,
}).then((scene) => {
initialStatePromiseRef.current.promise.resolve(scene);
});
const [
excalidrawAPI,
excalidrawRefCallback,
] = useCallbackRefState<ExcalidrawImperativeAPI>();
const collabAPI = useContext(CollabContext)?.api;
useEffect(() => {
if (!collabAPI || !excalidrawAPI) {
return;
}
initializeScene({ collabAPI }).then((scene) => {
if (scene?.appState?.viewModeEnabled) {
setViewModeEnabled(true);
}
initialStatePromiseRef.current.promise.resolve(scene);
});
const onHashChange = (_: HashChangeEvent) => {
const api = excalidrawRef.current!;
if (!api.ready) {
return;
}
if (window.location.hash.length > 1) {
initializeScene({
resetScene: api.resetScene,
initializeSocketClient: collab.initializeSocketClient,
}).then((scene) => {
if (scene) {
api.updateScene(scene);
initializeScene({ collabAPI }).then((scene) => {
if (scene) {
if (scene?.appState?.viewModeEnabled) {
setViewModeEnabled(true);
}
});
}
excalidrawAPI.updateScene(scene);
}
});
};
const titleTimeout = setTimeout(
@@ -273,7 +211,7 @@ function ExcalidrawWrapper(props: { collab: CollabAPI }) {
window.removeEventListener(EVENT.BLUR, onBlur, false);
clearTimeout(titleTimeout);
};
}, [collab.initializeSocketClient]);
}, [collabAPI, excalidrawAPI]);
useEffect(() => {
languageDetector.cacheUserLanguage(langCode);
@@ -283,14 +221,19 @@ function ExcalidrawWrapper(props: { collab: CollabAPI }) {
elements: readonly ExcalidrawElement[],
appState: AppState,
) => {
saveDebounced(elements, appState);
if (collab.isCollaborating) {
collab.broadcastElements(elements, appState);
if (collabAPI?.isCollaborating()) {
collabAPI.broadcastElements(elements);
} 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);
}
};
const onExportToBackend = async (
exportedElements: readonly NonDeletedExcalidrawElement[],
viewonly: boolean,
appState: AppState,
canvas: HTMLCanvasElement | null,
) => {
@@ -299,12 +242,16 @@ function ExcalidrawWrapper(props: { collab: CollabAPI }) {
}
if (canvas) {
try {
await exportToBackend(exportedElements, {
...appState,
viewBackgroundColor: appState.exportBackground
? appState.viewBackgroundColor
: getDefaultAppState().viewBackgroundColor,
});
await exportToBackend(
exportedElements,
{
...appState,
viewBackgroundColor: appState.exportBackground
? appState.viewBackgroundColor
: getDefaultAppState().viewBackgroundColor,
},
viewonly,
);
} catch (error) {
if (error.name !== "AbortError") {
const { width, height } = canvas;
@@ -339,23 +286,23 @@ function ExcalidrawWrapper(props: { collab: CollabAPI }) {
},
[langCode],
);
return (
<>
<Excalidraw
ref={excalidrawRef}
ref={excalidrawRefCallback}
onChange={onChange}
width={dimensions.width}
height={dimensions.height}
initialData={initialStatePromiseRef.current.promise}
user={{ name: collab.username }}
onCollabButtonClick={collab.onCollabButtonClick}
isCollaborating={collab.isCollaborating}
onPointerUpdate={collab.onPointerUpdate}
onCollabButtonClick={collabAPI?.onCollabButtonClick}
isCollaborating={collabAPI?.isCollaborating()}
onPointerUpdate={collabAPI?.onPointerUpdate}
onExportToBackend={onExportToBackend}
renderFooter={renderFooter}
langCode={langCode}
viewModeEnabled={viewModeEnabled ? viewModeEnabled : undefined}
/>
{excalidrawAPI && <CollabWrapper excalidrawAPI={excalidrawAPI} />}
{errorMessage && (
<ErrorDialog
message={errorMessage}
@@ -369,13 +316,9 @@ function ExcalidrawWrapper(props: { collab: CollabAPI }) {
export default function ExcalidrawApp() {
return (
<TopErrorBoundary>
<CollabWrapper
excalidrawRef={
excalidrawRef as React.MutableRefObject<ExcalidrawImperativeAPI>
}
>
{(collab) => <ExcalidrawWrapper collab={collab} />}
</CollabWrapper>
<CollabContextConsumer>
<ExcalidrawWrapper />
</CollabContextConsumer>
</TopErrorBoundary>
);
}
+2 -3
View File
@@ -1,11 +1,10 @@
import { PointerCoords } from "./types";
import { normalizeScroll } from "./scene";
export const getCenter = (pointers: Map<number, PointerCoords>) => {
const allCoords = Array.from(pointers.values());
return {
x: normalizeScroll(sum(allCoords, (coords) => coords.x) / allCoords.length),
y: normalizeScroll(sum(allCoords, (coords) => coords.y) / allCoords.length),
x: sum(allCoords, (coords) => coords.x) / allCoords.length,
y: sum(allCoords, (coords) => coords.y) / allCoords.length,
};
};
+1 -1
View File
@@ -85,6 +85,6 @@ type ForwardRef<T, P = any> = Parameters<
// --------------------------------------------------------------------------—
interface Blob {
handle?: import("browser-nativefs").FileSystemHandle;
handle?: import("browser-fs-acces").FileSystemHandle;
name?: string;
}
+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 percentages from "./locales/percentages.json";
import { ENV } from "./constants";
const COMPLETION_THRESHOLD = 85;
@@ -27,6 +28,7 @@ const allLanguages: Language[] = [
{ code: "id-ID", label: "Bahasa Indonesia" },
{ code: "it-IT", label: "Italiano" },
{ code: "ja-JP", label: "日本語" },
{ code: "kab-KAB", label: "Taqbaylit" },
{ code: "ko-KR", label: "한국어" },
{ code: "my-MM", label: "Burmese" },
{ code: "nb-NO", label: "Norsk bokmål" },
@@ -54,25 +56,33 @@ export const languages: Language[] = allLanguages
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 currentLangData = {};
export const setLanguage = async (lang: Language) => {
currentLang = lang;
document.documentElement.dir = currentLang.rtl ? "rtl" : "ltr";
document.documentElement.lang = currentLang.code;
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`
);
if (lang.code.startsWith(TEST_LANG_CODE)) {
currentLangData = {};
} else {
currentLangData = await import(
/* webpackChunkName: "i18n-[request]" */ `./locales/${currentLang.code}.json`
);
}
};
export const getLanguage = () => currentLang;
@@ -92,6 +102,13 @@ const findPartsForData = (data: any, parts: 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(".");
let translation =
findPartsForData(currentLangData, parts) ||
+14 -10
View File
@@ -1,7 +1,18 @@
import React, { useState, useEffect, useRef, useContext } from "react";
import variables from "./css/variables.module.scss";
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 = ({
children,
}: {
@@ -9,16 +20,7 @@ export const IsMobileProvider = ({
}) => {
const query = useRef<MediaQueryList>();
if (!query.current) {
query.current = window.matchMedia
? window.matchMedia(
// keep up to date with _variables.scss
"(max-width: 640px), (max-height: 500px) and (max-width: 1000px)",
)
: (({
matches: false,
addListener: () => {},
removeListener: () => {},
} as any) as MediaQueryList);
query.current = getIsMobileMatcher();
}
const [isMobile, setMobile] = useState(query.current.matches);
@@ -31,6 +33,8 @@ export const IsMobileProvider = ({
return <context.Provider value={isMobile}>{children}</context.Provider>;
};
export const isMobile = () => getIsMobileMatcher().matches;
export default function useIsMobile() {
return useContext(context);
}
+4
View File
@@ -1,4 +1,5 @@
export const isDarwin = /Mac|iPod|iPhone|iPad/.test(window.navigator.platform);
export const isWindows = /^Win/.test(window.navigator.platform);
export const CODES = {
EQUAL: "Equal",
@@ -18,7 +19,9 @@ export const CODES = {
F: "KeyF",
H: "KeyH",
V: "KeyV",
X: "KeyX",
Z: "KeyZ",
R: "KeyR",
} as const;
export const KEYS = {
@@ -48,6 +51,7 @@ export const KEYS = {
T: "t",
V: "v",
X: "x",
Y: "y",
Z: "z",
} as const;
+17 -8
View File
@@ -68,7 +68,7 @@
"layers": "الطبقات",
"actions": "الإجراءات",
"language": "اللغة",
"createRoom": "مشاركة الجلسة مباشرة",
"liveCollaboration": "",
"duplicateSelection": "تكرار",
"untitled": "غير معنون",
"name": "الاسم",
@@ -77,12 +77,12 @@
"group": "تحديد مجموعة",
"ungroup": "إلغاء تحديد مجموعة",
"collaborators": "المتعاونون",
"gridMode": "وضع الشبكة",
"showGrid": "",
"addToLibrary": "أضف إلى المكتبة",
"removeFromLibrary": "حذف من المكتبة",
"libraryLoadingMessage": "جارٍ تحميل المكتبة...",
"libraryLoadingMessage": "جارٍ تحميل المكتبة",
"libraries": "تصفح المكتبات",
"loadingScene": "جاري تحميل المشهد...",
"loadingScene": "جاري تحميل المشهد",
"align": "محاذاة",
"alignTop": "محاذاة إلى اﻷعلى",
"alignBottom": "محاذاة إلى اﻷسفل",
@@ -91,7 +91,8 @@
"centerVertically": "توسيط عمودي",
"centerHorizontally": "توسيط أفقي",
"distributeHorizontally": "التوزيع الأفقي",
"distributeVertically": "التوزيع عمودياً"
"distributeVertically": "التوزيع عمودياً",
"viewMode": ""
},
"buttons": {
"clearReset": "إعادة تعيين اللوحة",
@@ -116,7 +117,7 @@
"edit": "تعديل",
"undo": "تراجع",
"redo": "إعادة تنفيذ",
"roomDialog": "بدء المشاركة الحية",
"resetLibrary": "",
"createNewRoom": "إنشاء غرفة جديدة",
"fullScreen": "شاشة كاملة",
"darkMode": "الوضع المظلم",
@@ -135,10 +136,12 @@
"decryptFailed": "تعذر فك تشفير البيانات.",
"uploadedSecurly": "تم تأمين التحميل بتشفير النهاية إلى النهاية، مما يعني أن خادوم Excalidraw والأطراف الثالثة لا يمكنها قراءة المحتوى.",
"loadSceneOverridePrompt": "تحميل الرسم الخارجي سيحل محل المحتوى الموجود لديك. هل ترغب في المتابعة؟",
"collabStopOverridePrompt": "",
"errorLoadingLibrary": "حصل خطأ أثناء تحميل مكتبة الطرف الثالث.",
"confirmAddLibrary": "هذا سيضيف {{numShapes}} شكل إلى مكتبتك. هل أنت متأكد؟",
"imageDoesNotContainScene": "استيراد الصور غير مدعوم في الوقت الراهن.\n\nهل تريد استيراد مشهد؟ لا يبدو أن هذه الصورة تحتوي على أي بيانات مشهد. هل قمت بسماح هذا أثناء التصدير؟",
"cannotRestoreFromImage": "تعذر استعادة المشهد من ملف الصورة"
"cannotRestoreFromImage": "تعذر استعادة المشهد من ملف الصورة",
"resetLibrary": ""
},
"toolBar": {
"selection": "تحديد",
@@ -233,10 +236,16 @@
"storage": "التخزين",
"title": "إحصائيات للمهووسين",
"total": "المجموع",
"version": "",
"versionCopy": "",
"versionNotAvailable": "",
"width": "العرض"
},
"toast": {
"copyStyles": "",
"copyToClipboardAsPng": ""
"copyToClipboard": "",
"copyToClipboardAsPng": "",
"fileSaved": "",
"fileSavedToFilename": ""
}
}
+41 -32
View File
@@ -8,11 +8,11 @@
"cut": "Изрежи",
"copy": "Копирай",
"copyAsPng": "Копиране в клипборда",
"copyAsSvg": "Копиране в клипборда",
"bringForward": "Преместване на~пред",
"copyAsSvg": "Копирано в клипборда като SVG",
"bringForward": "Преместване напред",
"sendToBack": "Изнасяне назад",
"bringToFront": "~Изнасяне отпред",
"sendBackward": "Изпрати назад",
"bringToFront": "Изнасяне отпред",
"sendBackward": "Изпрати отзад",
"delete": "Изтрий",
"copyStyles": "Копирайте стилове",
"pasteStyles": "Постави стилове",
@@ -68,7 +68,7 @@
"layers": "Слоеве",
"actions": "Действия",
"language": "Език",
"createRoom": "Споделете сесия за сътрудничество на живо",
"liveCollaboration": "",
"duplicateSelection": "Дублирай",
"untitled": "Неозаглавено",
"name": "Име",
@@ -77,12 +77,12 @@
"group": "Групирай селекцията",
"ungroup": "Спри групирането на селекцията",
"collaborators": "Сътрудници",
"gridMode": "Решетъчен режим",
"showGrid": "Показване на мрежа",
"addToLibrary": "Добавяне към библиотеката",
"removeFromLibrary": "Премахване от библиотеката",
"libraryLoadingMessage": "Зареждане на библиотеката...",
"libraryLoadingMessage": "Зареждане на библиотеката",
"libraries": "Разглеждане на библиотеките",
"loadingScene": "Зареждане на сцена...",
"loadingScene": "Зареждане на сцена",
"align": "Подравняване",
"alignTop": "Подравняване отгоре",
"alignBottom": "Подравняване отдолу",
@@ -91,7 +91,8 @@
"centerVertically": "Центрирай вертикално",
"centerHorizontally": "Центрирай хоризонтално",
"distributeHorizontally": "Разпредели хоризонтално",
"distributeVertically": "Разпредели вертикално"
"distributeVertically": "Разпредели вертикално",
"viewMode": "Изглед"
},
"buttons": {
"clearReset": "Нулиране на платно",
@@ -116,7 +117,7 @@
"edit": "Редактиране",
"undo": "Отмяна",
"redo": "Повтори",
"roomDialog": "Започнете сътрудничество на живо",
"resetLibrary": "",
"createNewRoom": "Създай нова стая",
"fullScreen": "На цял екран",
"darkMode": "Тъмен режим",
@@ -135,10 +136,12 @@
"decryptFailed": "Данните не можаха да се дешифрират.",
"uploadedSecurly": "Качването е защитено с криптиране от край до край, което означава, че сървърът Excalidraw и трети страни не могат да четат съдържанието.",
"loadSceneOverridePrompt": "Зареждането на външна рисунка ще презапише настоящото ви съдържание. Желаете ли да продължите?",
"collabStopOverridePrompt": "Прекратяването на сесията ще презапише предишната, локално запазена, рисунка. Сигурни ли сте?\n\n(Ако искате да продължите с локалната рисунка, просто затворете таба на браузъра.)",
"errorLoadingLibrary": "Възникна грешка при зареждането на външна библиотека.",
"confirmAddLibrary": "Ще се добавят {{numShapes}} фигура(и) във вашата библиотека. Сигурни ли сте?",
"imageDoesNotContainScene": "Импортирането на картинки не се поддържва в момента.\n\nИскате да импортнете сцена? Тази картинка не съдържа данни от сцена. Разрешили ли сте последното при експортирането?",
"cannotRestoreFromImage": "Не може да бъде възстановена сцена от този файл"
"cannotRestoreFromImage": "Не може да бъде възстановена сцена от този файл",
"resetLibrary": ""
},
"toolBar": {
"selection": "Селекция",
@@ -200,25 +203,25 @@
"title": "Грешка"
},
"helpDialog": {
"blog": "",
"click": "",
"curvedArrow": "",
"curvedLine": "",
"documentation": "",
"drag": "",
"editor": "",
"github": "",
"howto": "",
"or": "",
"preventBinding": "",
"shapes": "",
"shortcuts": "",
"textFinish": "",
"textNewLine": "",
"title": "",
"view": "",
"zoomToFit": "",
"zoomToSelection": ""
"blog": "Прочетете нашия блог",
"click": "клик",
"curvedArrow": "Извита стрелка",
"curvedLine": "Извита линия",
"documentation": "Документация",
"drag": "плъзнете",
"editor": "Редактор",
"github": "Намерихте проблем? Изпратете",
"howto": "Следвайте нашите ръководства",
"or": "или",
"preventBinding": "Спри прилепяне на стрелките",
"shapes": "Фигури",
"shortcuts": "Клавиши за бърз достъп",
"textFinish": "Завършете редактирането (текст)",
"textNewLine": "Добавяне на нов ред (текст)",
"title": "Помощ",
"view": "Преглед",
"zoomToFit": "Приближи докато се виждат всички елементи",
"zoomToSelection": "Приближи селекцията"
},
"encrypted": {
"tooltip": "Вашите рисунки са криптирани от край до край, така че сървърите на Excalidraw няма да могат да ги виждат."
@@ -233,10 +236,16 @@
"storage": "Съхранение на данни",
"title": "Статистика за хакери",
"total": "Общо",
"version": "Версия",
"versionCopy": "Настисни за да копираш",
"versionNotAvailable": "Версията не е налична",
"width": "Широчина"
},
"toast": {
"copyStyles": "",
"copyToClipboardAsPng": ""
"copyStyles": "Копирани стилове.",
"copyToClipboard": "Копирано в клипборда.",
"copyToClipboardAsPng": "Копирано в клипборда като PNG.",
"fileSaved": "",
"fileSavedToFilename": ""
}
}
+17 -8
View File
@@ -68,7 +68,7 @@
"layers": "Capes",
"actions": "Accions",
"language": "Llengua",
"createRoom": "Compartir una sessió de col·laboració en directe",
"liveCollaboration": "",
"duplicateSelection": "Duplicar",
"untitled": "Sense títol",
"name": "Nom",
@@ -77,12 +77,12 @@
"group": "Agrupar la selecció",
"ungroup": "Desagrupar la selecció",
"collaborators": "Col·laboradors",
"gridMode": "Mode quadrícula",
"showGrid": "",
"addToLibrary": "Afegir a la biblioteca",
"removeFromLibrary": "Eliminar de la biblioteca",
"libraryLoadingMessage": "Carregant la biblioteca...",
"libraryLoadingMessage": "Carregant la biblioteca",
"libraries": "Explorar biblioteques",
"loadingScene": "Carregant escena...",
"loadingScene": "Carregant escena",
"align": "Alinear",
"alignTop": "Alinear a dalt",
"alignBottom": "Alinear a baix",
@@ -91,7 +91,8 @@
"centerVertically": "Centrar verticalment",
"centerHorizontally": "Centrar horitzontalment",
"distributeHorizontally": "Distribuir horitzontalment",
"distributeVertically": "Distribuir verticalment"
"distributeVertically": "Distribuir verticalment",
"viewMode": ""
},
"buttons": {
"clearReset": "Netejar el llenç",
@@ -116,7 +117,7 @@
"edit": "Editar",
"undo": "Desfer",
"redo": "Refer",
"roomDialog": "Començar col·laboració en directe",
"resetLibrary": "",
"createNewRoom": "Crear sala nova",
"fullScreen": "Pantalla completa",
"darkMode": "Mode fosc",
@@ -135,10 +136,12 @@
"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.",
"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.",
"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ó?",
"cannotRestoreFromImage": "Lescena no sha pogut restaurar des daquest fitxer dimatge"
"cannotRestoreFromImage": "Lescena no sha pogut restaurar des daquest fitxer dimatge",
"resetLibrary": ""
},
"toolBar": {
"selection": "Selecció",
@@ -233,10 +236,16 @@
"storage": "Emmagatzematge",
"title": "Estadístiques per nerds",
"total": "Total",
"version": "",
"versionCopy": "",
"versionNotAvailable": "",
"width": "Amplada"
},
"toast": {
"copyStyles": "",
"copyToClipboardAsPng": ""
"copyToClipboard": "",
"copyToClipboardAsPng": "",
"fileSaved": "",
"fileSavedToFilename": ""
}
}
+19 -10
View File
@@ -68,7 +68,7 @@
"layers": "Ebenen",
"actions": "Aktionen",
"language": "Sprache",
"createRoom": "Live-Kollaborationssitzung teilen",
"liveCollaboration": "Live-Zusammenarbeit",
"duplicateSelection": "Duplizieren",
"untitled": "Unbenannt",
"name": "Name",
@@ -76,13 +76,13 @@
"madeWithExcalidraw": "Made with Excalidraw",
"group": "Auswahl gruppieren",
"ungroup": "Gruppierung aufheben",
"collaborators": "Mitarbeitende",
"gridMode": "Rastermodus",
"collaborators": "Kollaboratoren",
"showGrid": "Raster anzeigen",
"addToLibrary": "Zur Bibliothek hinzufügen",
"removeFromLibrary": "Aus Bibliothek entfernen",
"libraryLoadingMessage": "Lade Bibliothek...",
"libraryLoadingMessage": "Lade Bibliothek",
"libraries": "Bibliotheken durchsuchen",
"loadingScene": "Lade Zeichnung...",
"loadingScene": "Lade Zeichnung",
"align": "Ausrichten",
"alignTop": "Obere Kanten",
"alignBottom": "Untere Kanten",
@@ -91,7 +91,8 @@
"centerVertically": "Vertikal zentrieren",
"centerHorizontally": "Horizontal zentrieren",
"distributeHorizontally": "Horizontal verteilen",
"distributeVertically": "Vertikal verteilen"
"distributeVertically": "Vertikal verteilen",
"viewMode": "Ansichtsmodus"
},
"buttons": {
"clearReset": "Zeichenfläche löschen & Hintergrundfarbe zurücksetzen",
@@ -100,7 +101,7 @@
"exportToSvg": "Als SVG exportieren",
"copyToClipboard": "In Zwischenablage kopieren",
"copyPngToClipboard": "PNG in die Zwischenablage kopieren",
"scale": "Skalieren",
"scale": "Skalierung",
"save": "Speichern",
"saveAs": "Speichern unter",
"load": "Laden",
@@ -116,7 +117,7 @@
"edit": "Bearbeiten",
"undo": "Rückgängig machen",
"redo": "Wiederholen",
"roomDialog": "Live-Kollaborationssitzung starten",
"resetLibrary": "Bibliothek zurücksetzen",
"createNewRoom": "Neuen Raum erstellen",
"fullScreen": "Vollbildanzeige",
"darkMode": "Dunkles Design",
@@ -135,10 +136,12 @@
"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.",
"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.",
"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?",
"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": {
"selection": "Auswahl",
@@ -233,10 +236,16 @@
"storage": "Speicher",
"title": "Statistiken für Nerds",
"total": "Gesamt",
"version": "Version",
"versionCopy": "Zum Kopieren klicken",
"versionNotAvailable": "Version nicht verfügbar",
"width": "Breite"
},
"toast": {
"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": "Στρώματα",
"actions": "Ενέργειες",
"language": "Γλώσσα",
"createRoom": "Έναρξη ζωντανής συνεδρίας",
"liveCollaboration": "Ζωντανή συνεργασία",
"duplicateSelection": "Δημιουργία αντιγράφου",
"untitled": "Χωρίς τίτλο",
"name": "Όνομα",
@@ -77,12 +77,12 @@
"group": "Δημιουργία ομάδας από επιλογή",
"ungroup": "Κατάργηση ομάδας από επιλογή",
"collaborators": "Συνεργάτες",
"gridMode": "Εμφάνιση σε πλέγμα",
"showGrid": "Προβολή πλέγματος",
"addToLibrary": "Προσθήκη στη βιβλιοθήκη",
"removeFromLibrary": "Αφαίρεση από τη βιβλιοθήκη",
"libraryLoadingMessage": "Φόρτωση βιβλιοθήκης...",
"libraryLoadingMessage": "Φόρτωση βιβλιοθήκης",
"libraries": "Άλλες βιβλιοθήκες",
"loadingScene": "Φόρτωση σκηνής...",
"loadingScene": "Φόρτωση σκηνής",
"align": "Στοίχιση",
"alignTop": "Στοίχιση πάνω",
"alignBottom": "Στοίχιση κάτω",
@@ -91,7 +91,8 @@
"centerVertically": "Κέντρο κάθετα",
"centerHorizontally": "Κέντρο οριζόντια",
"distributeHorizontally": "Οριζόντια κατανομή",
"distributeVertically": "Κατακόρυφη κατανομή"
"distributeVertically": "Κατακόρυφη κατανομή",
"viewMode": "Λειτουργία προβολής"
},
"buttons": {
"clearReset": "Επαναφορά του καμβά",
@@ -116,13 +117,13 @@
"edit": "Επεξεργασία",
"undo": "Αναίρεση",
"redo": "Επαναφορά",
"roomDialog": "Έναρξη ζωντανής συνεργασίας",
"resetLibrary": "Καθαρισμός βιβλιοθήκης",
"createNewRoom": "Δημιουργία νέου χώρου",
"fullScreen": "Πλήρης οθόνη",
"darkMode": "Σκοτεινή λειτουργία",
"lightMode": "Φωτεινή λειτουργία",
"zenMode": "Λειτουργία Zεν",
"exitZenMode": "Έξοδος απο την λειτουργία Zen"
"exitZenMode": "Έξοδος από την λειτουργία Zen"
},
"alerts": {
"clearReset": "Αυτό θα σβήσει ολόκληρο τον καμβά. Είσαι σίγουρος;",
@@ -135,10 +136,12 @@
"decryptFailed": "Δεν ήταν δυνατή η αποκρυπτογράφηση δεδομένων.",
"uploadedSecurly": "Η μεταφόρτωση έχει εξασφαλιστεί με κρυπτογράφηση από άκρο σε άκρο, πράγμα που σημαίνει ότι ο διακομιστής Excalidraw και τρίτα μέρη δεν μπορούν να διαβάσουν το περιεχόμενο.",
"loadSceneOverridePrompt": "Η φόρτωση εξωτερικού σχεδίου θα αντικαταστήσει το υπάρχον περιεχόμενο. Επιθυμείτε να συνεχίσετε;",
"collabStopOverridePrompt": "Η διακοπή της συνεδρίας θα αντικαταστήσει το προηγούμενο, τοπικά αποθηκευμένο σχέδιο. Είστε σίγουροι?\n\n(Αν θέλετε να διατηρήσετε το τοπικό σας σχέδιο, απλά κλείστε την καρτέλα του προγράμματος περιήγησης.)",
"errorLoadingLibrary": "Υπήρξε ένα σφάλμα κατά τη φόρτωση της βιβλιοθήκης τρίτου μέρους.",
"confirmAddLibrary": "Αυτό θα προσθέσει {{numShapes}} σχήμα(τα) στη βιβιλιοθήκη σας. Είστε σίγουροι;",
"confirmAddLibrary": "Αυτό θα προσθέσει {{numShapes}} σχήμα(τα) στη βιβλιοθήκη σας. Είστε σίγουροι;",
"imageDoesNotContainScene": "Η εισαγωγή εικόνων δεν υποστηρίζεται αυτή τη στιγμή.\n\nΜήπως θέλετε να εισαγάγετε μια σκηνή; Αυτή η εικόνα δεν φαίνεται να περιέχει δεδομένα σκηνής. Έχετε ενεργοποιήσει αυτό κατά την εξαγωγή;",
"cannotRestoreFromImage": "Η σκηνή δεν ήταν δυνατό να αποκατασταθεί από αυτό το αρχείο εικόνας"
"cannotRestoreFromImage": "Η σκηνή δεν ήταν δυνατό να αποκατασταθεί από αυτό το αρχείο εικόνας",
"resetLibrary": "Αυτό θα καθαρίσει τη βιβλιοθήκη σας. Είστε σίγουροι;"
},
"toolBar": {
"selection": "Επιλογή",
@@ -159,7 +162,7 @@
},
"hints": {
"linearElement": "Κάνε κλικ για να ξεκινήσεις πολλαπλά σημεία, σύρε για μια γραμμή",
"freeDraw": "Κάντε κλικ και σύρατε, απελευθερώσατε όταν έχετε τελειώσει",
"freeDraw": "Κάντε κλικ και σύρτε, απελευθερώσατε όταν έχετε τελειώσει",
"text": "Tip: μπορείτε επίσης να προσθέστε κείμενο με διπλό-κλικ οπουδήποτε με το εργαλείο επιλογών",
"linearElementMulti": "Κάνε κλικ στο τελευταίο σημείο ή πάτησε Escape ή Enter για να τελειώσεις",
"lockAngle": "Μπορείτε να περιορίσετε τη γωνία κρατώντας πατημένο το SHIFT",
@@ -182,7 +185,7 @@
"clearCanvasCaveat": " Αυτό θα προκαλέσει απώλεια της δουλειάς σου ",
"trackedToSentry_pre": "Το σφάλμα με αναγνωριστικό ",
"trackedToSentry_post": " παρακολουθήθηκε στο σύστημά μας.",
"openIssueMessage_pre": "Ήμασταν πολύ προσεκτικοί για να μην συμπεριλάβουμε τις πληροφορίες της σκηνής σου στο σφάλμα. Αν η σκηνή σου δεν είναι ιδιωτική, παρακαλώ σκέψουν να ακολουθήσεις το δικό μας ",
"openIssueMessage_pre": "Ήμασταν πολύ προσεκτικοί για να μην συμπεριλάβουμε τις πληροφορίες της σκηνής σου στο σφάλμα. Αν η σκηνή σου δεν είναι ιδιωτική, παρακαλώ σκέψου να ακολουθήσεις το δικό μας ",
"openIssueMessage_button": "ανιχνευτής σφαλμάτων.",
"openIssueMessage_post": " Παρακαλώ να συμπεριλάβετε τις παρακάτω πληροφορίες, αντιγράφοντας και επικολλώντας το ζήτημα στο GitHub.",
"sceneContent": "Περιεχόμενο σκηνής:"
@@ -221,7 +224,7 @@
"zoomToSelection": "Ζουμ στην επιλογή"
},
"encrypted": {
"tooltip": "Τα σχέδιά σου είναι κρυπτογραφημένα από άκρο σε άκρο, έτσι δεν θα έιναι ποτέ ορατά μέσα από τους διακομιστές του Excalidraw."
"tooltip": "Τα σχέδιά σου είναι κρυπτογραφημένα από άκρο σε άκρο, έτσι δεν θα είναι ποτέ ορατά μέσα από τους διακομιστές του Excalidraw."
},
"stats": {
"angle": "Γωνία",
@@ -233,10 +236,16 @@
"storage": "Χώρος",
"title": "Στατιστικά για σπασίκλες",
"total": "Σύνολο ",
"version": "Έκδοση",
"versionCopy": "Κάνε κλικ για αντιγραφή",
"versionNotAvailable": "Έκδοση μη διαθέσιμη",
"width": "Πλάτος"
},
"toast": {
"copyStyles": "Αντιγράφηκαν στυλ.",
"copyToClipboardAsPng": "Αντιγράφτηκε στο πρόχειρο ως PNG."
"copyToClipboard": "Αντιγράφηκε στο πρόχειρο.",
"copyToClipboardAsPng": "Αντιγράφτηκε στο πρόχειρο ως PNG.",
"fileSaved": "Το αρχείο αποθηκεύτηκε.",
"fileSavedToFilename": "Αποθηκεύτηκε στο {filename}"
}
}
+18 -8
View File
@@ -68,7 +68,7 @@
"layers": "Layers",
"actions": "Actions",
"language": "Language",
"createRoom": "Share a live-collaboration session",
"liveCollaboration": "Live collaboration",
"duplicateSelection": "Duplicate",
"untitled": "Untitled",
"name": "Name",
@@ -77,12 +77,12 @@
"group": "Group selection",
"ungroup": "Ungroup selection",
"collaborators": "Collaborators",
"gridMode": "Grid mode",
"showGrid": "Show grid",
"addToLibrary": "Add to library",
"removeFromLibrary": "Remove from library",
"libraryLoadingMessage": "Loading library...",
"libraryLoadingMessage": "Loading library",
"libraries": "Browse libraries",
"loadingScene": "Loading scene...",
"loadingScene": "Loading scene",
"align": "Align",
"alignTop": "Align top",
"alignBottom": "Align bottom",
@@ -91,7 +91,8 @@
"centerVertically": "Center vertically",
"centerHorizontally": "Center horizontally",
"distributeHorizontally": "Distribute horizontally",
"distributeVertically": "Distribute vertically"
"distributeVertically": "Distribute vertically",
"viewMode": "View mode"
},
"buttons": {
"clearReset": "Reset the canvas",
@@ -105,6 +106,7 @@
"saveAs": "Save as",
"load": "Load",
"getShareableLink": "Get shareable link",
"getViewonlyShareableLink": "Get View only shareable link",
"close": "Close",
"selectLanguage": "Select language",
"scrollBackToContent": "Scroll back to content",
@@ -116,7 +118,7 @@
"edit": "Edit",
"undo": "Undo",
"redo": "Redo",
"roomDialog": "Start live collaboration",
"resetLibrary": "Reset library",
"createNewRoom": "Create new room",
"fullScreen": "Full screen",
"darkMode": "Dark mode",
@@ -135,10 +137,12 @@
"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.",
"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.",
"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?",
"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": {
"selection": "Selection",
@@ -233,10 +237,16 @@
"storage": "Storage",
"title": "Stats for nerds",
"total": "Total",
"version": "Version",
"versionCopy": "Click to copy",
"versionNotAvailable": "Version not available",
"width": "Width"
},
"toast": {
"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}"
}
}
+17 -8
View File
@@ -68,7 +68,7 @@
"layers": "Capas",
"actions": "Acciones",
"language": "Idioma",
"createRoom": "Compartir una sesión de colaboración en vivo",
"liveCollaboration": "Colaboración en directo",
"duplicateSelection": "Duplicar",
"untitled": "Sin título",
"name": "Nombre",
@@ -77,12 +77,12 @@
"group": "Agrupar selección",
"ungroup": "Desagrupar selección",
"collaborators": "Colaboradores",
"gridMode": "Modo cuadrícula",
"showGrid": "Mostrar cuadrícula",
"addToLibrary": "Añadir a la biblioteca",
"removeFromLibrary": "Eliminar de la biblioteca",
"libraryLoadingMessage": "Cargando biblioteca...",
"libraryLoadingMessage": "Cargando librería…",
"libraries": "Explorar bibliotecas",
"loadingScene": "Cargando escena...",
"loadingScene": "Cargando escena",
"align": "Alinear",
"alignTop": "Alineación superior",
"alignBottom": "Alineación inferior",
@@ -91,7 +91,8 @@
"centerVertically": "Centrar verticalmente",
"centerHorizontally": "Centrar horizontalmente",
"distributeHorizontally": "Distribuir horizontalmente",
"distributeVertically": "Distribuir verticalmente"
"distributeVertically": "Distribuir verticalmente",
"viewMode": "Modo presentación"
},
"buttons": {
"clearReset": "Limpiar lienzo y reiniciar el color de fondo",
@@ -116,7 +117,7 @@
"edit": "Editar",
"undo": "Deshacer",
"redo": "Rehacer",
"roomDialog": "Iniciar colaboración en vivo",
"resetLibrary": "Resetear librería",
"createNewRoom": "Crear nueva sala",
"fullScreen": "Pantalla completa",
"darkMode": "Modo oscuro",
@@ -135,10 +136,12 @@
"decryptFailed": "No se pudieron descifrar los datos.",
"uploadedSecurly": "La carga ha sido asegurada con cifrado de principio a fin, lo que significa que el servidor de Excalidraw y terceros no pueden leer el contenido.",
"loadSceneOverridePrompt": "Si carga este dibujo externo, reemplazará el que tiene. ¿Desea continuar?",
"collabStopOverridePrompt": "Detener la sesión sobrescribirá su dibujo anterior almacenado en local. ¿Estás seguro?\n\n(Si quieres mantener tu dibujo en local, simplemente cierre la pestaña del navegador en su lugar.)",
"errorLoadingLibrary": "Se ha producido un error al cargar la biblioteca de terceros.",
"confirmAddLibrary": "Esto añadirá {{numShapes}} forma(s) a tu biblioteca. ¿Estás seguro?",
"imageDoesNotContainScene": "La importación de imágenes no está homologada en este momento.\n\n¿Deseas importar una escena? Esta imagen no parece contener ningún dato de escena. ¿Lo has activado durante la exportación?",
"cannotRestoreFromImage": "No se pudo restaurar la escena desde este archivo de imagen"
"cannotRestoreFromImage": "No se pudo restaurar la escena desde este archivo de imagen",
"resetLibrary": "Esto eliminará tu librería. ¿Estás seguro?"
},
"toolBar": {
"selection": "Selección",
@@ -233,10 +236,16 @@
"storage": "Almacenamiento",
"title": "Estadísticas para nerds",
"total": "Total",
"version": "Versión",
"versionCopy": "Clic para copiar",
"versionNotAvailable": "Versión no disponible",
"width": "Ancho"
},
"toast": {
"copyStyles": "Estilos copiados.",
"copyToClipboardAsPng": "Copiado al portapapeles como PNG."
"copyToClipboard": "Copiado en el portapapeles.",
"copyToClipboardAsPng": "Copiado al portapapeles como PNG.",
"fileSaved": "Archivo guardado.",
"fileSavedToFilename": "Guardado en {filename}"
}
}
+34 -25
View File
@@ -68,7 +68,7 @@
"layers": "لایه ها",
"actions": "عملیات",
"language": "زبان",
"createRoom": "اشتراک گذاری جلسه همکاری زنده",
"liveCollaboration": "",
"duplicateSelection": "تکرار",
"untitled": "بدون عنوان",
"name": "نام",
@@ -77,12 +77,12 @@
"group": "گروهبندی انتخابها",
"ungroup": "حذف گروهبندی انتخابها",
"collaborators": "همکاران",
"gridMode": "حالت شبکه ای",
"showGrid": "",
"addToLibrary": "افزودن به کتابخانه",
"removeFromLibrary": "حذف از کتابخانه",
"libraryLoadingMessage": "بارگذاری کتابخانه...",
"libraryLoadingMessage": "بارگذاری کتابخانه",
"libraries": "مرور کردن کتابخانه ها",
"loadingScene": "باگذاری صحنه...",
"loadingScene": "باگذاری صحنه",
"align": "تراز",
"alignTop": "تراز به بالا",
"alignBottom": "تراز به پایین",
@@ -91,7 +91,8 @@
"centerVertically": "وسط قرار دادن به صورت عمودی",
"centerHorizontally": "وسط قرار دادن به صورت افقی",
"distributeHorizontally": "توزیع کردن به صورت افقی",
"distributeVertically": "توزیع کردن به صورت عمودی"
"distributeVertically": "توزیع کردن به صورت عمودی",
"viewMode": ""
},
"buttons": {
"clearReset": "پاکسازی بوم نقاشی",
@@ -116,7 +117,7 @@
"edit": "ویرایش",
"undo": "بازگرد",
"redo": "از سر",
"roomDialog": "همکاری آنلاین را شروع کنید",
"resetLibrary": "",
"createNewRoom": "ایجاد یک اتاق جدید",
"fullScreen": "تمام‌صفحه",
"darkMode": "حالت تیره",
@@ -135,10 +136,12 @@
"decryptFailed": "رمزگشایی داده ها امکان پذیر نیست.",
"uploadedSecurly": "آپلود با رمزگذاری دو طرفه انجام میشود، به این معنی که سرور Excalidraw و اشخاص ثالث نمی توانند مطالب شما را بخوانند.",
"loadSceneOverridePrompt": "بارگزاری یک طرح خارجی محتوای فعلی رو از بین میبرد. آیا میخواهید ادامه دهید؟",
"collabStopOverridePrompt": "",
"errorLoadingLibrary": "خطایی در بارگذاری کتابخانه ثالث وجود داشت.",
"confirmAddLibrary": "{{numShapes}} از اشکال به کتابخانه شما اضافه خواهد شد. مطمئن هستید؟",
"imageDoesNotContainScene": "وارد کردن تصویر در این لحظه امکان پذیر نمی باشد.\nآیا مایل به وارد کردن یک صحنه هستید؟ این تصویر به نظر می رسد که فاقد هرگونه اطلاعاتی مربوط به صحنه باشد. آیا این گزینه را در زمان وارد کردن تصویر فعال کرده اید؟",
"cannotRestoreFromImage": "صحنه را نمی توان از این فایل تصویری بازیابی کرد"
"cannotRestoreFromImage": "صحنه را نمی توان از این فایل تصویری بازیابی کرد",
"resetLibrary": ""
},
"toolBar": {
"selection": "گزینش",
@@ -200,25 +203,25 @@
"title": "خطا"
},
"helpDialog": {
"blog": "",
"blog": "بلاگ ما را بخوانید",
"click": "",
"curvedArrow": "",
"curvedLine": "",
"documentation": "",
"curvedArrow": "فلش خمیده",
"curvedLine": "منحنی",
"documentation": "مستندات",
"drag": "",
"editor": "",
"github": "",
"howto": "",
"or": "",
"preventBinding": "",
"shapes": "",
"shortcuts": "",
"editor": "ویرایشگر",
"github": "اشکالی می بینید؟ گزارش دهید",
"howto": "راهنمای ما را دنبال کنید",
"or": "یا",
"preventBinding": "مانع شدن از چسبیدن فلش ها",
"shapes": "شکل‌ها",
"shortcuts": "میانبرهای صفحه کلید",
"textFinish": "",
"textNewLine": "",
"title": "",
"view": "",
"zoomToFit": "",
"zoomToSelection": ""
"textNewLine": "یک خط جدید اضافه کنید (متن)",
"title": "راهنما",
"view": "مشاهده",
"zoomToFit": "بزرگنمایی برای دیدن تمام آیتم ها",
"zoomToSelection": "بزرگنمایی قسمت انتخاب شده"
},
"encrypted": {
"tooltip": "شما در یک محیط رمزگزاری شده دو طرفه در حال طراحی هستید پس Excalidraw هرگز طرح های شما را نمیبند."
@@ -233,10 +236,16 @@
"storage": "حافظه",
"title": "آمار برای نردها",
"total": "مجموع",
"version": "",
"versionCopy": "",
"versionNotAvailable": "",
"width": "عرض"
},
"toast": {
"copyStyles": "",
"copyToClipboardAsPng": ""
"copyStyles": "کپی سبک.",
"copyToClipboard": "",
"copyToClipboardAsPng": "کپی در حافطه موقت به صورت PNG.",
"fileSaved": "",
"fileSavedToFilename": ""
}
}

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