Compare commits
128 Commits
fix_wysiwy
...
kb/auto-sa
Author | SHA1 | Date | |
---|---|---|---|
![]() |
2bf886c941 | ||
![]() |
6215256787 | ||
![]() |
1e48aafb9c | ||
![]() |
34761200bf | ||
![]() |
a0899966ff | ||
![]() |
c2b40dff92 | ||
![]() |
9733ecb3df | ||
![]() |
189b721eed | ||
![]() |
90fd4a95df | ||
![]() |
35d195e891 | ||
![]() |
9d3d7f3500 | ||
![]() |
0b32757085 | ||
![]() |
6442a45bd4 | ||
![]() |
5d3e98fa04 | ||
![]() |
422c25449f | ||
![]() |
67289ef4ce | ||
![]() |
233576628c | ||
![]() |
c54a099010 | ||
![]() |
3b976613ba | ||
![]() |
bee59747d1 | ||
![]() |
2e1352f3fa | ||
![]() |
6b65db7b68 | ||
![]() |
e4c5ebf867 | ||
![]() |
0602f3cfe4 | ||
![]() |
eade72b744 | ||
![]() |
ef5c9002ad | ||
![]() |
aa9e1c4566 | ||
![]() |
edc7f7bf47 | ||
![]() |
1310256dcc | ||
![]() |
4ac1841d92 | ||
![]() |
bdf6e53289 | ||
![]() |
a6706cff20 | ||
![]() |
c739ac5c61 | ||
![]() |
0d818f3810 | ||
![]() |
58a7568c9f | ||
![]() |
722e5ca845 | ||
![]() |
bb568a9670 | ||
![]() |
0f5b0d1d1d | ||
![]() |
25fd275158 | ||
![]() |
3d047d57a7 | ||
![]() |
26a6f9e76d | ||
![]() |
1c11bb5b41 | ||
![]() |
aced1cc6f5 | ||
![]() |
f3f85b4c90 | ||
![]() |
86781f09dd | ||
![]() |
a94b44440e | ||
![]() |
77bf553ed8 | ||
![]() |
fce7047199 | ||
![]() |
9905deb4b4 | ||
![]() |
fee84f3807 | ||
![]() |
9020ab3761 | ||
![]() |
136f8b2279 | ||
![]() |
8670b2d587 | ||
![]() |
b081a09962 | ||
![]() |
10a23a10a5 | ||
![]() |
d7a015cb3a | ||
![]() |
f68404fbed | ||
![]() |
30ae4b8bf2 | ||
![]() |
cf9e29834d | ||
![]() |
5d26c15daf | ||
![]() |
b0d7ff290f | ||
![]() |
458e6d6c24 | ||
![]() |
a21db08cae | ||
![]() |
1b626175de | ||
![]() |
5ffdd3f32d | ||
![]() |
77b873251a | ||
![]() |
b50b8f7b0d | ||
![]() |
40656c70d1 | ||
![]() |
c5b4b04d6b | ||
![]() |
1ad212677b | ||
![]() |
01f5914a82 | ||
![]() |
5e1e16c150 | ||
![]() |
32427c355c | ||
![]() |
402a812159 | ||
![]() |
0480753581 | ||
![]() |
f7e17a28fa | ||
![]() |
78f3a92dd1 | ||
![]() |
c8743a8c02 | ||
![]() |
127c1be6ad | ||
![]() |
86bf2d697d | ||
![]() |
7ee8de0a46 | ||
![]() |
981f327b48 | ||
![]() |
eeea8406c9 | ||
![]() |
0f249e3b26 | ||
![]() |
2c7c80bd75 | ||
![]() |
94ad8eaa19 | ||
![]() |
13d9374cde | ||
![]() |
efb6d0825b | ||
![]() |
14537cbaba | ||
![]() |
92ac11c49d | ||
![]() |
90d68b3e0b | ||
![]() |
80a61db72f | ||
![]() |
9a13dd8836 | ||
![]() |
cf6a5ff16b | ||
![]() |
fa8c7abf50 | ||
![]() |
c3ecbcb3ab | ||
![]() |
de99484a1f | ||
![]() |
add1785ace | ||
![]() |
0e3eb3cc63 | ||
![]() |
c1379c3c10 | ||
![]() |
70c36e196c | ||
![]() |
9757f8e2e5 | ||
![]() |
052b73d95b | ||
![]() |
e90e56452f | ||
![]() |
edc62c550a | ||
![]() |
84a1863233 | ||
![]() |
1f295955d0 | ||
![]() |
8f45aa2924 | ||
![]() |
b4c0bc6ace | ||
![]() |
8fdc25ce10 | ||
![]() |
efc2db3af1 | ||
![]() |
fa186c69cb | ||
![]() |
f2adda46e5 | ||
![]() |
0786b472cc | ||
![]() |
15ae64eb7a | ||
![]() |
f3b9bd7c1d | ||
![]() |
eae25329f0 | ||
![]() |
99de8e79e2 | ||
![]() |
adcfaf34de | ||
![]() |
04ae836874 | ||
![]() |
f6ffa7ed45 | ||
![]() |
2964d36d9a | ||
![]() |
b9e70ec666 | ||
![]() |
f1daff2437 | ||
![]() |
91c8b6ecbf | ||
![]() |
47c26cd4cf | ||
![]() |
006aad052d | ||
![]() |
98a7707e26 |
6
.github/assets/crowdin.svg
vendored
Normal file
@@ -0,0 +1,6 @@
|
||||
<svg height="50" viewBox="0 0 257 50" xmlns="http://www.w3.org/2000/svg" fill-rule="evenodd" clip-rule="evenodd" stroke-linejoin="round" stroke-miterlimit="2">
|
||||
<path fill="#fff" d="M-7.977-9.253h288.95v78.13H-7.977z" />
|
||||
<path d="M67.626 32.315c-1.34 0-2.207 0-2.207-1.025 0-.236.079-.551.236-.946l4.02-8.907h12.929c1.34 0 2.128-.08 2.128.946 0 .315-.078.63-.236.946l-.788 1.734h5.439l1.104-2.444c.157-.394.157-.71.157-1.025 0-2.207-2.365-3.31-4.257-3.31H65.655l-5.754 12.691c-.158.394-.158.71-.158 1.025 0 2.365 1.97 3.547 4.73 3.547h20.26l1.26-3.232H67.627zm42.727-14.11H95.059l-6.937 17.342h5.518l5.519-14.032h8.435c1.34 0 2.05-.157 2.05.868 0 .315-.08.63-.237.946l-.789 1.734h5.518l1.104-2.444c.158-.394.158-.71.158-1.025 0-1.025-.552-1.892-1.734-2.522-.946-.473-2.208-.868-3.311-.868zm30.35 0h-21.285l-5.754 12.691c-.158.316-.158.63-.158 1.025 0 1.97 1.419 3.547 3.232 3.547h21.52l5.834-13.007c.158-.394.158-.71.158-1.024 0-2.05-1.734-3.233-3.547-3.233zm-6.701 14.19h-12.85c-1.34 0-1.97-.159-1.97-1.183 0-.316.079-.631.236-.946l4.178-8.908h12.929c1.26 0 1.891-.08 1.891.946 0 .315-.078.63-.236 1.025l-4.178 9.065zm13.953 3.152h28.695l7.41-17.264h-5.676l-6.149 14.032h-9.223l6.149-14.11h-5.676l-6.386 14.031h-6.306c-1.34 0-2.05-.157-2.05-1.182 0-.315.08-.63.237-.946l5.282-11.982h-5.519l-5.518 12.455c-1.103 3.39 2.207 4.966 4.73 4.966zm67.874-23.649l-5.913 1.577-1.97 4.73h-14.584c-3.548 0-6.7 1.576-8.278 4.73l-3.941 9.46c-.788 1.576.63 3.152 3.31 3.152h21.128l10.248-23.649zm-27.591 20.496c-1.183 0-1.735-.788-1.577-1.577l3.469-7.567c.788-1.813 2.68-1.892 4.414-1.892h11.825l-4.73 11.036h-13.401zm26.802 3.153l7.49-17.737-6.307 1.183-7.095 16.554h5.912zm8.435-19.944l1.656-3.705-6.228 1.261-1.577 3.705 6.15-1.26zm22.23 2.601h-20.417l-7.094 17.343h5.518l5.518-14.19h13.48c1.34 0 2.05-.078 2.05 1.026 0 .315-.08.63-.237.946l-5.518 12.297h5.518l5.834-13.007c.157-.315.157-.63.157-1.025 0-1.025-.552-1.892-1.734-2.522-.867-.473-1.892-.868-3.074-.868zm-192.82.868c-8.672-1.025-16.476.71-17.58 6.148 0 .237-.157 1.262-.157 1.42l1.419.157v2.207l-1.34-.157c.551 5.597 3.626 7.252 6.858 7.331h.236c1.42.079 2.917-.237 4.178-.788.08 0 .08-.08.08-.08v-.157c0-.079-.08-.079-.08-.157-.078 0-.078-.08-.157-.08-2.996.395-5.755-2.049-5.755-7.015 0-6.228 4.888-8.514 12.298-8.514.236.158.315-.237 0-.315zM36.803 30.344c.788 0 1.498.158 2.207.237.237 1.655 1.025 3.232 2.208 4.336-1.183-.158-2.208-.71-3.075-1.498a6.051 6.051 0 01-1.34-3.075zm2.68-5.439c0 .237-.157.552-.236.946h-1.025c-.552 0-1.025-.079-1.576-.158v-.157c.63-3.39 4.02-4.73 7.252-5.36a7.997 7.997 0 00-2.76 1.812c-.787.868-1.34 1.813-1.655 2.917z" fill="#2e3340" fill-rule="nonzero" />
|
||||
<path d="M56.274 14.105c-6.543-1.813-34.055-4.02-34.055 11.273.946.158 1.577.315 2.05.394-.079 1.183 0 2.444 0 3.626l-2.444-.394c0 8.83 6.464 11.667 11.588 11.667.868 0 1.656-.078 2.523-.157 2.128-.237 4.178-.867 5.991-1.892.079 0 .079-.08.079-.08v-.157c0-.079-.079-.079-.079-.157-.079 0-.079-.08-.157-.08-4.336.868-10.17-.315-10.17-10.563 0-13.637 19.156-12.77 24.753-13.007.08 0 .08-.079.08-.079v-.157c0-.08 0-.08-.08-.158 0-.079 0-.079-.079-.079zM33.414 39.41a9.362 9.362 0 01-6.78-2.286c-1.892-1.656-3.074-3.942-3.31-6.385 1.655.236 3.704.394 5.438.473a9.43 9.43 0 001.577 4.808c.946 1.42 2.207 2.602 3.705 3.39h-.63zM28.92 24.984l-2.601-.237-2.602-.315c0-7.962 12.77-11.036 18.683-10.484-5.912 1.34-13.086 4.099-13.48 11.036z" fill="#2e3340" fill-rule="nonzero" />
|
||||
<path d="M59.664 9.533c-7.962-2.68-17.027-4.02-25.462-3.941-12.22 0-27.67 3.626-28.064 16.081l3.31.788c-.393 1.577-.393 4.81-.393 4.81s-1.892-.553-2.917-.79c0 14.821 8.671 18.526 17.027 18.526 3.39 0 6.701-.552 9.854-1.734.08 0 .08-.08.08-.08v-.157c0-.079-.08-.079-.08-.157h-.157c-2.602 0-4.651.867-8.75-2.05-7.963-5.597-7.017-20.102 2.128-26.408 9.46-6.701 29.798-4.573 33.267-4.415h.157s.079 0 .079-.079v-.236l-.079-.158zm-36.42 34.292c-9.932 0-14.978-5.36-15.45-15.609 2.68.71 5.202 1.34 7.961 1.734-.157 4.02 1.262 7.962 4.02 11.037a12.488 12.488 0 005.046 2.916l-1.577-.078zM45.632 7.956c-12.06 0-26.014 1.42-28.773 14.584 0 0-7.41-1.182-9.066-1.576C9.843 4.409 38.38 5.67 49.89 7.956h-4.257z" fill="#2e3340" fill-rule="nonzero" />
|
||||
</svg>
|
After Width: | Height: | Size: 4.1 KiB |
9
.github/assets/sentry.svg
vendored
Normal file
@@ -0,0 +1,9 @@
|
||||
<svg class="__sntry__ css-15xgryy e10nushx5" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 222 66" height="50" style="background-color: rgb(255, 255, 255);">
|
||||
<defs>
|
||||
<style type="text/css">
|
||||
@media (prefers-color-scheme: dark) {svg.__sntry__ { background-color: #584674 !important; }path.__sntry__ { fill: #ffffff !important; }}
|
||||
</style>
|
||||
</defs>
|
||||
<path d="M29,2.26a4.67,4.67,0,0,0-8,0L14.42,13.53A32.21,32.21,0,0,1,32.17,40.19H27.55A27.68,27.68,0,0,0,12.09,17.47L6,28a15.92,15.92,0,0,1,9.23,12.17H4.62A.76.76,0,0,1,4,39.06l2.94-5a10.74,10.74,0,0,0-3.36-1.9l-2.91,5a4.54,4.54,0,0,0,1.69,6.24A4.66,4.66,0,0,0,4.62,44H19.15a19.4,19.4,0,0,0-8-17.31l2.31-4A23.87,23.87,0,0,1,23.76,44H36.07a35.88,35.88,0,0,0-16.41-31.8l4.67-8a.77.77,0,0,1,1.05-.27c.53.29,20.29,34.77,20.66,35.17a.76.76,0,0,1-.68,1.13H40.6q.09,1.91,0,3.81h4.78A4.59,4.59,0,0,0,50,39.43a4.49,4.49,0,0,0-.62-2.28Z M124.32,28.28,109.56,9.22h-3.68V34.77h3.73V15.19l15.18,19.58h3.26V9.22h-3.73ZM87.15,23.54h13.23V20.22H87.14V12.53h14.93V9.21H83.34V34.77h18.92V31.45H87.14ZM71.59,20.3h0C66.44,19.06,65,18.08,65,15.7c0-2.14,1.89-3.59,4.71-3.59a12.06,12.06,0,0,1,7.07,2.55l2-2.83a14.1,14.1,0,0,0-9-3c-5.06,0-8.59,3-8.59,7.27,0,4.6,3,6.19,8.46,7.52C74.51,24.74,76,25.78,76,28.11s-2,3.77-5.09,3.77a12.34,12.34,0,0,1-8.3-3.26l-2.25,2.69a15.94,15.94,0,0,0,10.42,3.85c5.48,0,9-2.95,9-7.51C79.75,23.79,77.47,21.72,71.59,20.3ZM195.7,9.22l-7.69,12-7.64-12h-4.46L186,24.67V34.78h3.84V24.55L200,9.22Zm-64.63,3.46h8.37v22.1h3.84V12.68h8.37V9.22H131.08ZM169.41,24.8c3.86-1.07,6-3.77,6-7.63,0-4.91-3.59-8-9.38-8H154.67V34.76h3.8V25.58h6.45l6.48,9.2h4.44l-7-9.82Zm-10.95-2.5V12.6h7.17c3.74,0,5.88,1.77,5.88,4.84s-2.29,4.86-5.84,4.86Z" transform="translate(11, 11)" fill="#362d59" class="__sntry__">
|
||||
</path>
|
||||
</svg>
|
After Width: | Height: | Size: 1.7 KiB |
3
.github/assets/vercel.svg
vendored
Normal file
@@ -0,0 +1,3 @@
|
||||
<svg height="50" viewBox="0 0 164 50" xmlns="http://www.w3.org/2000/svg" style="background:#fff" fill-rule="evenodd" clip-rule="evenodd" stroke-linejoin="round" stroke-miterlimit="2">
|
||||
<path d="M78.21 15.587c-5.672 0-9.762 3.864-9.762 9.661s4.604 9.66 10.276 9.66c3.427 0 6.448-1.416 8.319-3.805l-3.931-2.372c-1.038 1.186-2.615 1.879-4.388 1.879-2.461 0-4.552-1.342-5.328-3.489h14.397c.113-.601.18-1.223.18-1.879 0-5.79-4.09-9.655-9.763-9.655zm-4.86 7.783c.642-2.142 2.399-3.489 4.855-3.489 2.461 0 4.219 1.347 4.855 3.489h-9.71zm60.187-7.783c-5.673 0-9.763 3.864-9.763 9.661s4.604 9.66 10.276 9.66c3.427 0 6.449-1.416 8.319-3.805l-3.931-2.372c-1.038 1.186-2.615 1.879-4.388 1.879-2.461 0-4.552-1.342-5.328-3.489h14.397c.113-.601.18-1.223.18-1.879 0-5.79-4.09-9.655-9.762-9.655zm-4.856 7.783c.642-2.142 2.4-3.489 4.856-3.489 2.46 0 4.218 1.347 4.855 3.489h-9.711zm-20.054 1.878c0 3.22 2.015 5.367 5.139 5.367 2.116 0 3.704-1.003 4.52-2.64l3.947 2.378c-1.634 2.843-4.696 4.556-8.467 4.556-5.678 0-9.763-3.864-9.763-9.661s4.09-9.66 9.763-9.66c3.77 0 6.828 1.712 8.467 4.556l-3.946 2.377c-.817-1.637-2.405-2.64-4.521-2.64-3.12 0-5.139 2.147-5.139 5.367zm42.378-15.565v24.69h-4.624V9.682h4.624zM24.73 7l18.985 34.35H5.744L24.73 7zm47.465 2.683L57.956 35.446 43.72 9.683h5.338l8.9 16.102 8.898-16.102h5.339zm30.268 6.44v5.202a5.634 5.634 0 00-1.644-.263c-2.985 0-5.138 2.147-5.138 5.367v7.943h-4.624V16.124h4.624v4.938c0-2.727 3.036-4.938 6.782-4.938z" fill-rule="nonzero" />
|
||||
</svg>
|
After Width: | Height: | Size: 1.4 KiB |
1
.gitignore
vendored
@@ -20,3 +20,4 @@ package-lock.json
|
||||
static
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
src/packages/excalidraw/types
|
||||
|
@@ -5,7 +5,7 @@
|
||||
### Option 1 - Manual
|
||||
|
||||
1. Fork and clone the repo
|
||||
1. Run `npm install` to install dependencies
|
||||
1. Run `yarn` to install dependencies
|
||||
1. Create a branch for your PR with `git checkout -b your-branch-name`
|
||||
|
||||
> To keep `master` branch pointing to remote repository and make pull requests from branches on your fork. To do this, run:
|
||||
|
@@ -28,6 +28,10 @@ If you like the project, you can become a sponsor at [Open Collective](https://o
|
||||
|
||||
<a href="https://opencollective.com/excalidraw#category-CONTRIBUTE" target="_blank"><img src="https://opencollective.com/excalidraw/tiers/backers.svg?avatarHeight=32"/></a>
|
||||
|
||||
Last but not least, we're thankful to these companies for offering their services for free:
|
||||
|
||||
[](https://vercel.com) [](https://sentry.io) [](https://crowdin.com)
|
||||
|
||||
## Documentation
|
||||
|
||||
### Shortcuts
|
||||
|
22
package.json
@@ -19,20 +19,20 @@
|
||||
]
|
||||
},
|
||||
"dependencies": {
|
||||
"@sentry/browser": "6.2.1",
|
||||
"@sentry/browser": "6.2.2",
|
||||
"@sentry/integrations": "6.2.1",
|
||||
"@testing-library/jest-dom": "5.11.9",
|
||||
"@testing-library/jest-dom": "5.11.10",
|
||||
"@testing-library/react": "11.2.5",
|
||||
"@types/jest": "26.0.20",
|
||||
"@types/react": "17.0.2",
|
||||
"@types/react-dom": "17.0.1",
|
||||
"@types/socket.io-client": "1.4.35",
|
||||
"browser-fs-access": "0.14.1",
|
||||
"@types/jest": "26.0.22",
|
||||
"@types/react": "17.0.3",
|
||||
"@types/react-dom": "17.0.2",
|
||||
"@types/socket.io-client": "1.4.36",
|
||||
"browser-fs-access": "0.16.2",
|
||||
"clsx": "1.1.1",
|
||||
"firebase": "8.2.10",
|
||||
"i18next-browser-languagedetector": "6.0.1",
|
||||
"lodash.throttle": "4.1.1",
|
||||
"nanoid": "3.1.20",
|
||||
"nanoid": "3.1.22",
|
||||
"open-color": "1.8.0",
|
||||
"pako": "1.0.11",
|
||||
"png-chunk-text": "1.0.0",
|
||||
@@ -40,8 +40,8 @@
|
||||
"png-chunks-extract": "1.0.0",
|
||||
"points-on-curve": "0.2.0",
|
||||
"pwacompat": "2.0.17",
|
||||
"react": "17.0.1",
|
||||
"react-dom": "17.0.1",
|
||||
"react": "17.0.2",
|
||||
"react-dom": "17.0.2",
|
||||
"react-scripts": "4.0.3",
|
||||
"roughjs": "4.3.1",
|
||||
"sass": "1.32.8",
|
||||
@@ -56,7 +56,7 @@
|
||||
"@types/resize-observer-browser": "0.1.5",
|
||||
"eslint-config-prettier": "8.1.0",
|
||||
"eslint-plugin-prettier": "3.3.1",
|
||||
"firebase-tools": "9.5.0",
|
||||
"firebase-tools": "9.6.1",
|
||||
"husky": "4.3.8",
|
||||
"jest-canvas-mock": "2.3.1",
|
||||
"lint-staged": "10.5.4",
|
||||
|
@@ -51,8 +51,7 @@
|
||||
name="twitter:description"
|
||||
content="Excalidraw is a whiteboard tool that lets you easily sketch diagrams that have a hand-drawn feel to them."
|
||||
/>
|
||||
<!-- OG tags require absolute url for images -->
|
||||
<meta name="twitter:image" content="https://excalidraw.com/og-image.png" />
|
||||
|
||||
<link rel="shortcut icon" href="favicon.ico" type="image/x-icon" />
|
||||
|
||||
<!-- Excalidraw version -->
|
||||
@@ -88,6 +87,8 @@
|
||||
<link rel="stylesheet" href="fonts.css" type="text/css" />
|
||||
<script>
|
||||
window.EXCALIDRAW_ASSET_PATH = "/";
|
||||
// setting this so that libraries installation reuses this window tab.
|
||||
window.name = "_excalidraw";
|
||||
</script>
|
||||
<% if (process.env.REACT_APP_GOOGLE_ANALYTICS_ID) { %>
|
||||
<script
|
||||
@@ -112,8 +113,7 @@
|
||||
Roboto, Helvetica, Arial, sans-serif;
|
||||
font-family: var(--ui-font);
|
||||
-webkit-text-size-adjust: 100%;
|
||||
-webkit-user-select: none;
|
||||
user-select: none;
|
||||
|
||||
width: 100vw;
|
||||
height: 100vh;
|
||||
}
|
||||
@@ -147,6 +147,9 @@
|
||||
color: var(--popup-text-color);
|
||||
font-size: 1.3em;
|
||||
}
|
||||
#root {
|
||||
height: 100%;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
|
||||
|
@@ -26,5 +26,50 @@
|
||||
}
|
||||
}
|
||||
],
|
||||
"capture_links": "new_client"
|
||||
"capture_links": "new_client",
|
||||
"share_target": {
|
||||
"action": "/web-share-target",
|
||||
"method": "POST",
|
||||
"enctype": "multipart/form-data",
|
||||
"params": {
|
||||
"files": [
|
||||
{
|
||||
"name": "file",
|
||||
"accept": ["application/vnd.excalidraw+json", "application/json", ".excalidraw"]
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"screenshots": [
|
||||
{
|
||||
"src": "/screenshots/virtual-whiteboard.png",
|
||||
"type": "image/png",
|
||||
"sizes": "462x945"
|
||||
},
|
||||
{
|
||||
"src": "/screenshots/wireframe.png",
|
||||
"type": "image/png",
|
||||
"sizes": "462x945"
|
||||
},
|
||||
{
|
||||
"src": "/screenshots/illustration.png",
|
||||
"type": "image/png",
|
||||
"sizes": "462x945"
|
||||
},
|
||||
{
|
||||
"src": "/screenshots/shapes.png",
|
||||
"type": "image/png",
|
||||
"sizes": "462x945"
|
||||
},
|
||||
{
|
||||
"src": "/screenshots/collaboration.png",
|
||||
"type": "image/png",
|
||||
"sizes": "462x945"
|
||||
},
|
||||
{
|
||||
"src": "/screenshots/export.png",
|
||||
"type": "image/png",
|
||||
"sizes": "462x945"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
BIN
public/screenshots/collaboration.png
Normal file
After Width: | Height: | Size: 28 KiB |
BIN
public/screenshots/export.png
Normal file
After Width: | Height: | Size: 25 KiB |
BIN
public/screenshots/illustration.png
Normal file
After Width: | Height: | Size: 47 KiB |
BIN
public/screenshots/shapes.png
Normal file
After Width: | Height: | Size: 25 KiB |
BIN
public/screenshots/virtual-whiteboard.png
Normal file
After Width: | Height: | Size: 27 KiB |
BIN
public/screenshots/wireframe.png
Normal file
After Width: | Height: | Size: 27 KiB |
@@ -58,7 +58,7 @@ export const actionAlignTop = register({
|
||||
<ToolButton
|
||||
hidden={!enableActionGroup(elements, appState)}
|
||||
type="button"
|
||||
icon={<AlignTopIcon appearance={appState.appearance} />}
|
||||
icon={<AlignTopIcon theme={appState.theme} />}
|
||||
onClick={() => updateData(null)}
|
||||
title={`${t("labels.alignTop")} — ${getShortcutKey(
|
||||
"CtrlOrCmd+Shift+Up",
|
||||
@@ -87,7 +87,7 @@ export const actionAlignBottom = register({
|
||||
<ToolButton
|
||||
hidden={!enableActionGroup(elements, appState)}
|
||||
type="button"
|
||||
icon={<AlignBottomIcon appearance={appState.appearance} />}
|
||||
icon={<AlignBottomIcon theme={appState.theme} />}
|
||||
onClick={() => updateData(null)}
|
||||
title={`${t("labels.alignBottom")} — ${getShortcutKey(
|
||||
"CtrlOrCmd+Shift+Down",
|
||||
@@ -116,7 +116,7 @@ export const actionAlignLeft = register({
|
||||
<ToolButton
|
||||
hidden={!enableActionGroup(elements, appState)}
|
||||
type="button"
|
||||
icon={<AlignLeftIcon appearance={appState.appearance} />}
|
||||
icon={<AlignLeftIcon theme={appState.theme} />}
|
||||
onClick={() => updateData(null)}
|
||||
title={`${t("labels.alignLeft")} — ${getShortcutKey(
|
||||
"CtrlOrCmd+Shift+Left",
|
||||
@@ -145,7 +145,7 @@ export const actionAlignRight = register({
|
||||
<ToolButton
|
||||
hidden={!enableActionGroup(elements, appState)}
|
||||
type="button"
|
||||
icon={<AlignRightIcon appearance={appState.appearance} />}
|
||||
icon={<AlignRightIcon theme={appState.theme} />}
|
||||
onClick={() => updateData(null)}
|
||||
title={`${t("labels.alignRight")} — ${getShortcutKey(
|
||||
"CtrlOrCmd+Shift+Right",
|
||||
@@ -172,7 +172,7 @@ export const actionAlignVerticallyCentered = register({
|
||||
<ToolButton
|
||||
hidden={!enableActionGroup(elements, appState)}
|
||||
type="button"
|
||||
icon={<CenterVerticallyIcon appearance={appState.appearance} />}
|
||||
icon={<CenterVerticallyIcon theme={appState.theme} />}
|
||||
onClick={() => updateData(null)}
|
||||
title={t("labels.centerVertically")}
|
||||
aria-label={t("labels.centerVertically")}
|
||||
@@ -197,7 +197,7 @@ export const actionAlignHorizontallyCentered = register({
|
||||
<ToolButton
|
||||
hidden={!enableActionGroup(elements, appState)}
|
||||
type="button"
|
||||
icon={<CenterHorizontallyIcon appearance={appState.appearance} />}
|
||||
icon={<CenterHorizontallyIcon theme={appState.theme} />}
|
||||
onClick={() => updateData(null)}
|
||||
title={t("labels.centerHorizontally")}
|
||||
aria-label={t("labels.centerHorizontally")}
|
||||
|
@@ -8,7 +8,7 @@ import { getCommonBounds, getNonDeletedElements } from "../element";
|
||||
import { newElementWith } from "../element/mutateElement";
|
||||
import { ExcalidrawElement } from "../element/types";
|
||||
import { t } from "../i18n";
|
||||
import useIsMobile from "../is-mobile";
|
||||
import { useIsMobile } from "../is-mobile";
|
||||
import { CODES, KEYS } from "../keys";
|
||||
import { getNormalizedZoom, getSelectedElements } from "../scene";
|
||||
import { centerScrollOn } from "../scene/scroll";
|
||||
@@ -33,6 +33,7 @@ export const actionChangeViewBackgroundColor = register({
|
||||
type="canvasBackground"
|
||||
color={appState.viewBackgroundColor}
|
||||
onChange={(color) => updateData(color)}
|
||||
data-testid="canvas-background-picker"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
@@ -48,7 +49,7 @@ export const actionClearCanvas = register({
|
||||
),
|
||||
appState: {
|
||||
...getDefaultAppState(),
|
||||
appearance: appState.appearance,
|
||||
theme: appState.theme,
|
||||
elementLocked: appState.elementLocked,
|
||||
exportBackground: appState.exportBackground,
|
||||
exportEmbedScene: appState.exportEmbedScene,
|
||||
@@ -72,6 +73,7 @@ export const actionClearCanvas = register({
|
||||
updateData(null);
|
||||
}
|
||||
}}
|
||||
data-testid="clear-canvas-button"
|
||||
/>
|
||||
),
|
||||
});
|
||||
|
@@ -17,7 +17,8 @@ export const actionCopy = register({
|
||||
};
|
||||
},
|
||||
contextItemLabel: "labels.copy",
|
||||
// Don't assign keyTest since its handled via copy event
|
||||
// don't supply a shortcut since we handle this conditionally via onCopy event
|
||||
keyTest: undefined,
|
||||
});
|
||||
|
||||
export const actionCut = register({
|
||||
|
@@ -53,7 +53,7 @@ export const distributeHorizontally = register({
|
||||
<ToolButton
|
||||
hidden={!enableActionGroup(elements, appState)}
|
||||
type="button"
|
||||
icon={<DistributeHorizontallyIcon appearance={appState.appearance} />}
|
||||
icon={<DistributeHorizontallyIcon theme={appState.theme} />}
|
||||
onClick={() => updateData(null)}
|
||||
title={`${t("labels.distributeHorizontally")} — ${getShortcutKey(
|
||||
"Alt+H",
|
||||
@@ -81,7 +81,7 @@ export const distributeVertically = register({
|
||||
<ToolButton
|
||||
hidden={!enableActionGroup(elements, appState)}
|
||||
type="button"
|
||||
icon={<DistributeVerticallyIcon appearance={appState.appearance} />}
|
||||
icon={<DistributeVerticallyIcon theme={appState.theme} />}
|
||||
onClick={() => updateData(null)}
|
||||
title={`${t("labels.distributeVertically")} — ${getShortcutKey("Alt+V")}`}
|
||||
aria-label={t("labels.distributeVertically")}
|
||||
|
@@ -8,9 +8,10 @@ import { Tooltip } from "../components/Tooltip";
|
||||
import { DarkModeToggle, Appearence } from "../components/DarkModeToggle";
|
||||
import { loadFromJSON, saveAsJSON } from "../data";
|
||||
import { t } from "../i18n";
|
||||
import useIsMobile from "../is-mobile";
|
||||
import { useIsMobile } from "../is-mobile";
|
||||
import { KEYS } from "../keys";
|
||||
import { register } from "./register";
|
||||
import { supported } from "browser-fs-access";
|
||||
|
||||
export const actionChangeProjectName = register({
|
||||
name: "changeProjectName",
|
||||
@@ -18,11 +19,14 @@ export const actionChangeProjectName = register({
|
||||
trackEvent("change", "title");
|
||||
return { appState: { ...appState, name: value }, commitToHistory: false };
|
||||
},
|
||||
PanelComponent: ({ appState, updateData }) => (
|
||||
PanelComponent: ({ appState, updateData, appProps }) => (
|
||||
<ProjectName
|
||||
label={t("labels.fileTitle")}
|
||||
value={appState.name || "Unnamed"}
|
||||
onChange={(name: string) => updateData(name)}
|
||||
isNameEditable={
|
||||
typeof appProps.name === "undefined" && !appState.viewModeEnabled
|
||||
}
|
||||
/>
|
||||
),
|
||||
});
|
||||
@@ -132,6 +136,7 @@ export const actionSaveScene = register({
|
||||
aria-label={t("buttons.save")}
|
||||
showAriaLabel={useIsMobile()}
|
||||
onClick={() => updateData(null)}
|
||||
data-testid="save-button"
|
||||
/>
|
||||
),
|
||||
});
|
||||
@@ -161,10 +166,9 @@ export const actionSaveAsScene = register({
|
||||
title={t("buttons.saveAs")}
|
||||
aria-label={t("buttons.saveAs")}
|
||||
showAriaLabel={useIsMobile()}
|
||||
hidden={
|
||||
!("chooseFileSystemEntries" in window || "showOpenFilePicker" in window)
|
||||
}
|
||||
hidden={!supported}
|
||||
onClick={() => updateData(null)}
|
||||
data-testid="save-as-button"
|
||||
/>
|
||||
),
|
||||
});
|
||||
@@ -202,6 +206,7 @@ export const actionLoadScene = register({
|
||||
aria-label={t("buttons.load")}
|
||||
showAriaLabel={useIsMobile()}
|
||||
onClick={updateData}
|
||||
data-testid="load-button"
|
||||
/>
|
||||
),
|
||||
});
|
||||
@@ -225,11 +230,45 @@ export const actionExportWithDarkMode = register({
|
||||
>
|
||||
<DarkModeToggle
|
||||
value={appState.exportWithDarkMode ? "dark" : "light"}
|
||||
onChange={(appearance: Appearence) => {
|
||||
updateData(appearance === "dark");
|
||||
onChange={(theme: Appearence) => {
|
||||
updateData(theme === "dark");
|
||||
}}
|
||||
title={t("labels.toggleExportColorScheme")}
|
||||
/>
|
||||
</div>
|
||||
),
|
||||
});
|
||||
|
||||
export const actionToggleAutosave = register({
|
||||
name: "toggleAutosave",
|
||||
perform(elements, appState) {
|
||||
trackEvent("toggle", "autosave");
|
||||
return {
|
||||
appState: {
|
||||
...appState,
|
||||
autosave: !appState.autosave,
|
||||
},
|
||||
commitToHistory: false,
|
||||
};
|
||||
},
|
||||
PanelComponent: ({ appState, updateData }) =>
|
||||
supported && appState.fileHandle ? (
|
||||
<label style={{ display: "flex" }}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={appState.autosave}
|
||||
onChange={(event) => updateData(event.target.checked)}
|
||||
/>{" "}
|
||||
{t("labels.toggleAutosave")}
|
||||
<Tooltip
|
||||
label={t("labels.toggleAutosave_details")}
|
||||
position="above"
|
||||
long={true}
|
||||
>
|
||||
<div className="TooltipIcon">{questionCircle}</div>
|
||||
</Tooltip>
|
||||
</label>
|
||||
) : (
|
||||
<></>
|
||||
),
|
||||
});
|
||||
|
207
src/actions/actionFlip.ts
Normal file
@@ -0,0 +1,207 @@
|
||||
import { register } from "./register";
|
||||
import { getSelectedElements } from "../scene";
|
||||
import { getElementMap, getNonDeletedElements } from "../element";
|
||||
import { mutateElement } from "../element/mutateElement";
|
||||
import { ExcalidrawElement, NonDeleted } from "../element/types";
|
||||
import { normalizeAngle, resizeSingleElement } from "../element/resizeElements";
|
||||
import { AppState } from "../types";
|
||||
import { getTransformHandles } from "../element/transformHandles";
|
||||
import { isLinearElement } from "../element/typeChecks";
|
||||
import { updateBoundElements } from "../element/binding";
|
||||
import { LinearElementEditor } from "../element/linearElementEditor";
|
||||
|
||||
const enableActionFlipHorizontal = (
|
||||
elements: readonly ExcalidrawElement[],
|
||||
appState: AppState,
|
||||
) => {
|
||||
const eligibleElements = getSelectedElements(
|
||||
getNonDeletedElements(elements),
|
||||
appState,
|
||||
);
|
||||
return eligibleElements.length === 1 && eligibleElements[0].type !== "text";
|
||||
};
|
||||
|
||||
const enableActionFlipVertical = (
|
||||
elements: readonly ExcalidrawElement[],
|
||||
appState: AppState,
|
||||
) => {
|
||||
const eligibleElements = getSelectedElements(
|
||||
getNonDeletedElements(elements),
|
||||
appState,
|
||||
);
|
||||
return eligibleElements.length === 1;
|
||||
};
|
||||
|
||||
export const actionFlipHorizontal = register({
|
||||
name: "flipHorizontal",
|
||||
perform: (elements, appState) => {
|
||||
return {
|
||||
elements: flipSelectedElements(elements, appState, "horizontal"),
|
||||
appState,
|
||||
commitToHistory: true,
|
||||
};
|
||||
},
|
||||
keyTest: (event) => event.shiftKey && event.code === "KeyH",
|
||||
contextItemLabel: "labels.flipHorizontal",
|
||||
contextItemPredicate: (elements, appState) =>
|
||||
enableActionFlipHorizontal(elements, appState),
|
||||
});
|
||||
|
||||
export const actionFlipVertical = register({
|
||||
name: "flipVertical",
|
||||
perform: (elements, appState) => {
|
||||
return {
|
||||
elements: flipSelectedElements(elements, appState, "vertical"),
|
||||
appState,
|
||||
commitToHistory: true,
|
||||
};
|
||||
},
|
||||
keyTest: (event) => event.shiftKey && event.code === "KeyV",
|
||||
contextItemLabel: "labels.flipVertical",
|
||||
contextItemPredicate: (elements, appState) =>
|
||||
enableActionFlipVertical(elements, appState),
|
||||
});
|
||||
|
||||
const flipSelectedElements = (
|
||||
elements: readonly ExcalidrawElement[],
|
||||
appState: Readonly<AppState>,
|
||||
flipDirection: "horizontal" | "vertical",
|
||||
) => {
|
||||
const selectedElements = getSelectedElements(
|
||||
getNonDeletedElements(elements),
|
||||
appState,
|
||||
);
|
||||
|
||||
// remove once we allow for groups of elements to be flipped
|
||||
if (selectedElements.length > 1) {
|
||||
return elements;
|
||||
}
|
||||
|
||||
const updatedElements = flipElements(
|
||||
selectedElements,
|
||||
appState,
|
||||
flipDirection,
|
||||
);
|
||||
|
||||
const updatedElementsMap = getElementMap(updatedElements);
|
||||
|
||||
return elements.map((element) => updatedElementsMap[element.id] || element);
|
||||
};
|
||||
|
||||
const flipElements = (
|
||||
elements: NonDeleted<ExcalidrawElement>[],
|
||||
appState: AppState,
|
||||
flipDirection: "horizontal" | "vertical",
|
||||
): ExcalidrawElement[] => {
|
||||
for (let i = 0; i < elements.length; i++) {
|
||||
flipElement(elements[i], appState);
|
||||
// If vertical flip, rotate an extra 180
|
||||
if (flipDirection === "vertical") {
|
||||
rotateElement(elements[i], Math.PI);
|
||||
}
|
||||
}
|
||||
return elements;
|
||||
};
|
||||
|
||||
const flipElement = (
|
||||
element: NonDeleted<ExcalidrawElement>,
|
||||
appState: AppState,
|
||||
) => {
|
||||
const originalX = element.x;
|
||||
const originalY = element.y;
|
||||
const width = element.width;
|
||||
const height = element.height;
|
||||
const originalAngle = normalizeAngle(element.angle);
|
||||
|
||||
let finalOffsetX = 0;
|
||||
if (isLinearElement(element)) {
|
||||
finalOffsetX =
|
||||
element.points.reduce((max, point) => Math.max(max, point[0]), 0) * 2 -
|
||||
element.width;
|
||||
}
|
||||
|
||||
// Rotate back to zero, if necessary
|
||||
mutateElement(element, {
|
||||
angle: normalizeAngle(0),
|
||||
});
|
||||
// Flip unrotated by pulling TransformHandle to opposite side
|
||||
const transformHandles = getTransformHandles(element, appState.zoom);
|
||||
let usingNWHandle = true;
|
||||
let newNCoordsX = 0;
|
||||
let nHandle = transformHandles.nw;
|
||||
if (!nHandle) {
|
||||
// Use ne handle instead
|
||||
usingNWHandle = false;
|
||||
nHandle = transformHandles.ne;
|
||||
if (!nHandle) {
|
||||
mutateElement(element, {
|
||||
angle: originalAngle,
|
||||
});
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if (isLinearElement(element)) {
|
||||
for (let i = 1; i < element.points.length; i++) {
|
||||
LinearElementEditor.movePoint(element, i, [
|
||||
-element.points[i][0],
|
||||
element.points[i][1],
|
||||
]);
|
||||
}
|
||||
LinearElementEditor.normalizePoints(element);
|
||||
} else {
|
||||
// calculate new x-coord for transformation
|
||||
newNCoordsX = usingNWHandle ? element.x + 2 * width : element.x - 2 * width;
|
||||
resizeSingleElement(
|
||||
element,
|
||||
true,
|
||||
element,
|
||||
usingNWHandle ? "nw" : "ne",
|
||||
false,
|
||||
newNCoordsX,
|
||||
nHandle[1],
|
||||
);
|
||||
// fix the size to account for handle sizes
|
||||
mutateElement(element, {
|
||||
width,
|
||||
height,
|
||||
});
|
||||
}
|
||||
|
||||
// Rotate by (360 degrees - original angle)
|
||||
let angle = normalizeAngle(2 * Math.PI - originalAngle);
|
||||
if (angle < 0) {
|
||||
// check, probably unnecessary
|
||||
angle = normalizeAngle(angle + 2 * Math.PI);
|
||||
}
|
||||
mutateElement(element, {
|
||||
angle,
|
||||
});
|
||||
|
||||
// Move back to original spot to appear "flipped in place"
|
||||
mutateElement(element, {
|
||||
x: originalX + finalOffsetX,
|
||||
y: originalY,
|
||||
});
|
||||
|
||||
updateBoundElements(element);
|
||||
};
|
||||
|
||||
const rotateElement = (element: ExcalidrawElement, rotationAngle: number) => {
|
||||
const originalX = element.x;
|
||||
const originalY = element.y;
|
||||
let angle = normalizeAngle(element.angle + rotationAngle);
|
||||
if (angle < 0) {
|
||||
// check, probably unnecessary
|
||||
angle = normalizeAngle(2 * Math.PI + angle);
|
||||
}
|
||||
mutateElement(element, {
|
||||
angle,
|
||||
});
|
||||
|
||||
// Move back to original spot
|
||||
mutateElement(element, {
|
||||
x: originalX,
|
||||
y: originalY,
|
||||
});
|
||||
};
|
@@ -134,7 +134,7 @@ export const actionGroup = register({
|
||||
<ToolButton
|
||||
hidden={!enableActionGroup(elements, appState)}
|
||||
type="button"
|
||||
icon={<GroupIcon appearance={appState.appearance} />}
|
||||
icon={<GroupIcon theme={appState.theme} />}
|
||||
onClick={() => updateData(null)}
|
||||
title={`${t("labels.group")} — ${getShortcutKey("CtrlOrCmd+G")}`}
|
||||
aria-label={t("labels.group")}
|
||||
@@ -181,7 +181,7 @@ export const actionUngroup = register({
|
||||
<ToolButton
|
||||
type="button"
|
||||
hidden={getSelectedGroupIds(appState).length === 0}
|
||||
icon={<UngroupIcon appearance={appState.appearance} />}
|
||||
icon={<UngroupIcon theme={appState.theme} />}
|
||||
onClick={() => updateData(null)}
|
||||
title={`${t("labels.ungroup")} — ${getShortcutKey("CtrlOrCmd+Shift+G")}`}
|
||||
aria-label={t("labels.ungroup")}
|
||||
|
@@ -1,7 +1,6 @@
|
||||
import React from "react";
|
||||
import { AppState } from "../../src/types";
|
||||
import { ButtonIconSelect } from "../components/ButtonIconSelect";
|
||||
import { ButtonSelect } from "../components/ButtonSelect";
|
||||
import { ColorPicker } from "../components/ColorPicker";
|
||||
import { IconPicker } from "../components/IconPicker";
|
||||
import {
|
||||
@@ -21,6 +20,16 @@ import {
|
||||
StrokeStyleDottedIcon,
|
||||
StrokeStyleSolidIcon,
|
||||
StrokeWidthIcon,
|
||||
FontSizeSmallIcon,
|
||||
FontSizeMediumIcon,
|
||||
FontSizeLargeIcon,
|
||||
FontSizeExtraLargeIcon,
|
||||
FontFamilyHandDrawnIcon,
|
||||
FontFamilyNormalIcon,
|
||||
FontFamilyCodeIcon,
|
||||
TextAlignLeftIcon,
|
||||
TextAlignCenterIcon,
|
||||
TextAlignRightIcon,
|
||||
} from "../components/icons";
|
||||
import { DEFAULT_FONT_FAMILY, DEFAULT_FONT_SIZE } from "../constants";
|
||||
import {
|
||||
@@ -169,17 +178,17 @@ export const actionChangeFillStyle = register({
|
||||
{
|
||||
value: "hachure",
|
||||
text: t("labels.hachure"),
|
||||
icon: <FillHachureIcon appearance={appState.appearance} />,
|
||||
icon: <FillHachureIcon theme={appState.theme} />,
|
||||
},
|
||||
{
|
||||
value: "cross-hatch",
|
||||
text: t("labels.crossHatch"),
|
||||
icon: <FillCrossHatchIcon appearance={appState.appearance} />,
|
||||
icon: <FillCrossHatchIcon theme={appState.theme} />,
|
||||
},
|
||||
{
|
||||
value: "solid",
|
||||
text: t("labels.solid"),
|
||||
icon: <FillSolidIcon appearance={appState.appearance} />,
|
||||
icon: <FillSolidIcon theme={appState.theme} />,
|
||||
},
|
||||
]}
|
||||
group="fill"
|
||||
@@ -219,32 +228,17 @@ export const actionChangeStrokeWidth = register({
|
||||
{
|
||||
value: 1,
|
||||
text: t("labels.thin"),
|
||||
icon: (
|
||||
<StrokeWidthIcon
|
||||
appearance={appState.appearance}
|
||||
strokeWidth={2}
|
||||
/>
|
||||
),
|
||||
icon: <StrokeWidthIcon theme={appState.theme} strokeWidth={2} />,
|
||||
},
|
||||
{
|
||||
value: 2,
|
||||
text: t("labels.bold"),
|
||||
icon: (
|
||||
<StrokeWidthIcon
|
||||
appearance={appState.appearance}
|
||||
strokeWidth={6}
|
||||
/>
|
||||
),
|
||||
icon: <StrokeWidthIcon theme={appState.theme} strokeWidth={6} />,
|
||||
},
|
||||
{
|
||||
value: 4,
|
||||
text: t("labels.extraBold"),
|
||||
icon: (
|
||||
<StrokeWidthIcon
|
||||
appearance={appState.appearance}
|
||||
strokeWidth={10}
|
||||
/>
|
||||
),
|
||||
icon: <StrokeWidthIcon theme={appState.theme} strokeWidth={10} />,
|
||||
},
|
||||
]}
|
||||
value={getFormValue(
|
||||
@@ -282,17 +276,17 @@ export const actionChangeSloppiness = register({
|
||||
{
|
||||
value: 0,
|
||||
text: t("labels.architect"),
|
||||
icon: <SloppinessArchitectIcon appearance={appState.appearance} />,
|
||||
icon: <SloppinessArchitectIcon theme={appState.theme} />,
|
||||
},
|
||||
{
|
||||
value: 1,
|
||||
text: t("labels.artist"),
|
||||
icon: <SloppinessArtistIcon appearance={appState.appearance} />,
|
||||
icon: <SloppinessArtistIcon theme={appState.theme} />,
|
||||
},
|
||||
{
|
||||
value: 2,
|
||||
text: t("labels.cartoonist"),
|
||||
icon: <SloppinessCartoonistIcon appearance={appState.appearance} />,
|
||||
icon: <SloppinessCartoonistIcon theme={appState.theme} />,
|
||||
},
|
||||
]}
|
||||
value={getFormValue(
|
||||
@@ -329,17 +323,17 @@ export const actionChangeStrokeStyle = register({
|
||||
{
|
||||
value: "solid",
|
||||
text: t("labels.strokeStyle_solid"),
|
||||
icon: <StrokeStyleSolidIcon appearance={appState.appearance} />,
|
||||
icon: <StrokeStyleSolidIcon theme={appState.theme} />,
|
||||
},
|
||||
{
|
||||
value: "dashed",
|
||||
text: t("labels.strokeStyle_dashed"),
|
||||
icon: <StrokeStyleDashedIcon appearance={appState.appearance} />,
|
||||
icon: <StrokeStyleDashedIcon theme={appState.theme} />,
|
||||
},
|
||||
{
|
||||
value: "dotted",
|
||||
text: t("labels.strokeStyle_dotted"),
|
||||
icon: <StrokeStyleDottedIcon appearance={appState.appearance} />,
|
||||
icon: <StrokeStyleDottedIcon theme={appState.theme} />,
|
||||
},
|
||||
]}
|
||||
value={getFormValue(
|
||||
@@ -428,13 +422,29 @@ export const actionChangeFontSize = register({
|
||||
PanelComponent: ({ elements, appState, updateData }) => (
|
||||
<fieldset>
|
||||
<legend>{t("labels.fontSize")}</legend>
|
||||
<ButtonSelect
|
||||
<ButtonIconSelect
|
||||
group="font-size"
|
||||
options={[
|
||||
{ value: 16, text: t("labels.small") },
|
||||
{ value: 20, text: t("labels.medium") },
|
||||
{ value: 28, text: t("labels.large") },
|
||||
{ value: 36, text: t("labels.veryLarge") },
|
||||
{
|
||||
value: 16,
|
||||
text: t("labels.small"),
|
||||
icon: <FontSizeSmallIcon theme={appState.theme} />,
|
||||
},
|
||||
{
|
||||
value: 20,
|
||||
text: t("labels.medium"),
|
||||
icon: <FontSizeMediumIcon theme={appState.theme} />,
|
||||
},
|
||||
{
|
||||
value: 28,
|
||||
text: t("labels.large"),
|
||||
icon: <FontSizeLargeIcon theme={appState.theme} />,
|
||||
},
|
||||
{
|
||||
value: 36,
|
||||
text: t("labels.veryLarge"),
|
||||
icon: <FontSizeExtraLargeIcon theme={appState.theme} />,
|
||||
},
|
||||
]}
|
||||
value={getFormValue(
|
||||
elements,
|
||||
@@ -471,16 +481,28 @@ export const actionChangeFontFamily = register({
|
||||
};
|
||||
},
|
||||
PanelComponent: ({ elements, appState, updateData }) => {
|
||||
const options: { value: FontFamily; text: string }[] = [
|
||||
{ value: 1, text: t("labels.handDrawn") },
|
||||
{ value: 2, text: t("labels.normal") },
|
||||
{ value: 3, text: t("labels.code") },
|
||||
const options: { value: FontFamily; text: string; icon: JSX.Element }[] = [
|
||||
{
|
||||
value: 1,
|
||||
text: t("labels.handDrawn"),
|
||||
icon: <FontFamilyHandDrawnIcon theme={appState.theme} />,
|
||||
},
|
||||
{
|
||||
value: 2,
|
||||
text: t("labels.normal"),
|
||||
icon: <FontFamilyNormalIcon theme={appState.theme} />,
|
||||
},
|
||||
{
|
||||
value: 3,
|
||||
text: t("labels.code"),
|
||||
icon: <FontFamilyCodeIcon theme={appState.theme} />,
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<fieldset>
|
||||
<legend>{t("labels.fontFamily")}</legend>
|
||||
<ButtonSelect<FontFamily | false>
|
||||
<ButtonIconSelect<FontFamily | false>
|
||||
group="font-family"
|
||||
options={options}
|
||||
value={getFormValue(
|
||||
@@ -521,12 +543,24 @@ export const actionChangeTextAlign = register({
|
||||
PanelComponent: ({ elements, appState, updateData }) => (
|
||||
<fieldset>
|
||||
<legend>{t("labels.textAlign")}</legend>
|
||||
<ButtonSelect<TextAlign | false>
|
||||
<ButtonIconSelect<TextAlign | false>
|
||||
group="text-align"
|
||||
options={[
|
||||
{ value: "left", text: t("labels.left") },
|
||||
{ value: "center", text: t("labels.center") },
|
||||
{ value: "right", text: t("labels.right") },
|
||||
{
|
||||
value: "left",
|
||||
text: t("labels.left"),
|
||||
icon: <TextAlignLeftIcon theme={appState.theme} />,
|
||||
},
|
||||
{
|
||||
value: "center",
|
||||
text: t("labels.center"),
|
||||
icon: <TextAlignCenterIcon theme={appState.theme} />,
|
||||
},
|
||||
{
|
||||
value: "right",
|
||||
text: t("labels.right"),
|
||||
icon: <TextAlignRightIcon theme={appState.theme} />,
|
||||
},
|
||||
]}
|
||||
value={getFormValue(
|
||||
elements,
|
||||
@@ -580,12 +614,12 @@ export const actionChangeSharpness = register({
|
||||
{
|
||||
value: "sharp",
|
||||
text: t("labels.sharp"),
|
||||
icon: <EdgeSharpIcon appearance={appState.appearance} />,
|
||||
icon: <EdgeSharpIcon theme={appState.theme} />,
|
||||
},
|
||||
{
|
||||
value: "round",
|
||||
text: t("labels.round"),
|
||||
icon: <EdgeRoundIcon appearance={appState.appearance} />,
|
||||
icon: <EdgeRoundIcon theme={appState.theme} />,
|
||||
},
|
||||
]}
|
||||
value={getFormValue(
|
||||
@@ -653,40 +687,27 @@ export const actionChangeArrowhead = register({
|
||||
{
|
||||
value: null,
|
||||
text: t("labels.arrowhead_none"),
|
||||
icon: <ArrowheadNoneIcon appearance={appState.appearance} />,
|
||||
icon: <ArrowheadNoneIcon theme={appState.theme} />,
|
||||
keyBinding: "q",
|
||||
},
|
||||
{
|
||||
value: "arrow",
|
||||
text: t("labels.arrowhead_arrow"),
|
||||
icon: (
|
||||
<ArrowheadArrowIcon
|
||||
appearance={appState.appearance}
|
||||
flip={!isRTL}
|
||||
/>
|
||||
<ArrowheadArrowIcon theme={appState.theme} flip={!isRTL} />
|
||||
),
|
||||
keyBinding: "w",
|
||||
},
|
||||
{
|
||||
value: "bar",
|
||||
text: t("labels.arrowhead_bar"),
|
||||
icon: (
|
||||
<ArrowheadBarIcon
|
||||
appearance={appState.appearance}
|
||||
flip={!isRTL}
|
||||
/>
|
||||
),
|
||||
icon: <ArrowheadBarIcon theme={appState.theme} flip={!isRTL} />,
|
||||
keyBinding: "e",
|
||||
},
|
||||
{
|
||||
value: "dot",
|
||||
text: t("labels.arrowhead_dot"),
|
||||
icon: (
|
||||
<ArrowheadDotIcon
|
||||
appearance={appState.appearance}
|
||||
flip={!isRTL}
|
||||
/>
|
||||
),
|
||||
icon: <ArrowheadDotIcon theme={appState.theme} flip={!isRTL} />,
|
||||
keyBinding: "r",
|
||||
},
|
||||
]}
|
||||
@@ -709,40 +730,27 @@ export const actionChangeArrowhead = register({
|
||||
value: null,
|
||||
text: t("labels.arrowhead_none"),
|
||||
keyBinding: "q",
|
||||
icon: <ArrowheadNoneIcon appearance={appState.appearance} />,
|
||||
icon: <ArrowheadNoneIcon theme={appState.theme} />,
|
||||
},
|
||||
{
|
||||
value: "arrow",
|
||||
text: t("labels.arrowhead_arrow"),
|
||||
keyBinding: "w",
|
||||
icon: (
|
||||
<ArrowheadArrowIcon
|
||||
appearance={appState.appearance}
|
||||
flip={isRTL}
|
||||
/>
|
||||
<ArrowheadArrowIcon theme={appState.theme} flip={isRTL} />
|
||||
),
|
||||
},
|
||||
{
|
||||
value: "bar",
|
||||
text: t("labels.arrowhead_bar"),
|
||||
keyBinding: "e",
|
||||
icon: (
|
||||
<ArrowheadBarIcon
|
||||
appearance={appState.appearance}
|
||||
flip={isRTL}
|
||||
/>
|
||||
),
|
||||
icon: <ArrowheadBarIcon theme={appState.theme} flip={isRTL} />,
|
||||
},
|
||||
{
|
||||
value: "dot",
|
||||
text: t("labels.arrowhead_dot"),
|
||||
keyBinding: "r",
|
||||
icon: (
|
||||
<ArrowheadDotIcon
|
||||
appearance={appState.appearance}
|
||||
flip={isRTL}
|
||||
/>
|
||||
),
|
||||
icon: <ArrowheadDotIcon theme={appState.theme} flip={isRTL} />,
|
||||
},
|
||||
]}
|
||||
value={getFormValue<Arrowhead | null>(
|
||||
|
@@ -38,7 +38,7 @@ export const actionSendBackward = register({
|
||||
onClick={() => updateData(null)}
|
||||
title={`${t("labels.sendBackward")} — ${getShortcutKey("CtrlOrCmd+[")}`}
|
||||
>
|
||||
<SendBackwardIcon appearance={appState.appearance} />
|
||||
<SendBackwardIcon theme={appState.theme} />
|
||||
</button>
|
||||
),
|
||||
});
|
||||
@@ -65,7 +65,7 @@ export const actionBringForward = register({
|
||||
onClick={() => updateData(null)}
|
||||
title={`${t("labels.bringForward")} — ${getShortcutKey("CtrlOrCmd+]")}`}
|
||||
>
|
||||
<BringForwardIcon appearance={appState.appearance} />
|
||||
<BringForwardIcon theme={appState.theme} />
|
||||
</button>
|
||||
),
|
||||
});
|
||||
@@ -99,7 +99,7 @@ export const actionSendToBack = register({
|
||||
: getShortcutKey("CtrlOrCmd+Shift+[")
|
||||
}`}
|
||||
>
|
||||
<SendToBackIcon appearance={appState.appearance} />
|
||||
<SendToBackIcon theme={appState.theme} />
|
||||
</button>
|
||||
),
|
||||
});
|
||||
@@ -133,7 +133,7 @@ export const actionBringToFront = register({
|
||||
: getShortcutKey("CtrlOrCmd+Shift+]")
|
||||
}`}
|
||||
>
|
||||
<BringToFrontIcon appearance={appState.appearance} />
|
||||
<BringToFrontIcon theme={appState.theme} />
|
||||
</button>
|
||||
),
|
||||
});
|
||||
|
@@ -33,6 +33,7 @@ export { actionFinalize } from "./actionFinalize";
|
||||
export {
|
||||
actionChangeProjectName,
|
||||
actionChangeExportBackground,
|
||||
actionToggleAutosave,
|
||||
actionSaveScene,
|
||||
actionSaveAsScene,
|
||||
actionLoadScene,
|
||||
@@ -66,6 +67,8 @@ export {
|
||||
distributeVertically,
|
||||
} from "./actionDistribute";
|
||||
|
||||
export { actionFlipHorizontal, actionFlipVertical } from "./actionFlip";
|
||||
|
||||
export {
|
||||
actionCopy,
|
||||
actionCut,
|
||||
|
@@ -7,12 +7,12 @@ import {
|
||||
ActionResult,
|
||||
} from "./types";
|
||||
import { ExcalidrawElement } from "../element/types";
|
||||
import { AppState, ExcalidrawProps } from "../types";
|
||||
import { AppProps, AppState } 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 };
|
||||
type App = { canvas: HTMLCanvasElement | null; props: AppProps };
|
||||
|
||||
export class ActionManager implements ActionsManagerInterface {
|
||||
actions = {} as ActionsManagerInterface["actions"];
|
||||
@@ -52,10 +52,14 @@ export class ActionManager implements ActionsManagerInterface {
|
||||
}
|
||||
|
||||
handleKeyDown(event: KeyboardEvent) {
|
||||
const canvasActions = this.app.props.UIOptions.canvasActions;
|
||||
const data = Object.values(this.actions)
|
||||
.sort((a, b) => (b.keyPriority || 0) - (a.keyPriority || 0))
|
||||
.filter(
|
||||
(action) =>
|
||||
(action.name in canvasActions
|
||||
? canvasActions[action.name as keyof typeof canvasActions]
|
||||
: true) &&
|
||||
action.keyTest &&
|
||||
action.keyTest(
|
||||
event,
|
||||
@@ -102,7 +106,15 @@ export class ActionManager implements ActionsManagerInterface {
|
||||
// like the user list. We can use this key to extract more
|
||||
// data from app state. This is an alternative to generic prop hell!
|
||||
renderAction = (name: ActionName, id?: string) => {
|
||||
if (this.actions[name] && "PanelComponent" in this.actions[name]) {
|
||||
const canvasActions = this.app.props.UIOptions.canvasActions;
|
||||
|
||||
if (
|
||||
this.actions[name] &&
|
||||
"PanelComponent" in this.actions[name] &&
|
||||
(name in canvasActions
|
||||
? canvasActions[name as keyof typeof canvasActions]
|
||||
: true)
|
||||
) {
|
||||
const action = this.actions[name];
|
||||
const PanelComponent = action.PanelComponent!;
|
||||
const updateData = (formState?: any) => {
|
||||
@@ -122,6 +134,7 @@ export class ActionManager implements ActionsManagerInterface {
|
||||
appState={this.getAppState()}
|
||||
updateData={updateData}
|
||||
id={id}
|
||||
appProps={this.app.props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
@@ -23,7 +23,9 @@ export type ShortcutName =
|
||||
| "zenMode"
|
||||
| "stats"
|
||||
| "addToLibrary"
|
||||
| "viewMode";
|
||||
| "viewMode"
|
||||
| "flipHorizontal"
|
||||
| "flipVertical";
|
||||
|
||||
const shortcutMap: Record<ShortcutName, string[]> = {
|
||||
cut: [getShortcutKey("CtrlOrCmd+X")],
|
||||
@@ -57,6 +59,8 @@ const shortcutMap: Record<ShortcutName, string[]> = {
|
||||
zenMode: [getShortcutKey("Alt+Z")],
|
||||
stats: [],
|
||||
addToLibrary: [],
|
||||
flipHorizontal: [getShortcutKey("Shift+H")],
|
||||
flipVertical: [getShortcutKey("Shift+V")],
|
||||
viewMode: [getShortcutKey("Alt+R")],
|
||||
};
|
||||
|
||||
|
@@ -1,12 +1,15 @@
|
||||
import React from "react";
|
||||
import { ExcalidrawElement } from "../element/types";
|
||||
import { AppState } from "../types";
|
||||
import { AppState, ExcalidrawProps } from "../types";
|
||||
|
||||
/** if false, the action should be prevented */
|
||||
export type ActionResult =
|
||||
| {
|
||||
elements?: readonly ExcalidrawElement[] | null;
|
||||
appState?: MarkOptional<AppState, "offsetTop" | "offsetLeft"> | null;
|
||||
appState?: MarkOptional<
|
||||
AppState,
|
||||
"offsetTop" | "offsetLeft" | "width" | "height"
|
||||
> | null;
|
||||
commitToHistory: boolean;
|
||||
syncHistory?: boolean;
|
||||
}
|
||||
@@ -48,6 +51,7 @@ export type ActionName =
|
||||
| "changeOpacity"
|
||||
| "changeFontSize"
|
||||
| "toggleCanvasMenu"
|
||||
| "toggleAutosave"
|
||||
| "toggleEditMenu"
|
||||
| "undo"
|
||||
| "redo"
|
||||
@@ -85,6 +89,8 @@ export type ActionName =
|
||||
| "alignHorizontallyCentered"
|
||||
| "distributeHorizontally"
|
||||
| "distributeVertically"
|
||||
| "flipHorizontal"
|
||||
| "flipVertical"
|
||||
| "viewMode"
|
||||
| "exportWithDarkMode";
|
||||
|
||||
@@ -94,6 +100,7 @@ export interface Action {
|
||||
elements: readonly ExcalidrawElement[];
|
||||
appState: AppState;
|
||||
updateData: (formData?: any) => void;
|
||||
appProps: ExcalidrawProps;
|
||||
id?: string;
|
||||
}>;
|
||||
perform: ActionFn;
|
||||
|
@@ -10,10 +10,11 @@ import { getDateTime } from "./utils";
|
||||
|
||||
export const getDefaultAppState = (): Omit<
|
||||
AppState,
|
||||
"offsetTop" | "offsetLeft"
|
||||
"offsetTop" | "offsetLeft" | "width" | "height"
|
||||
> => {
|
||||
return {
|
||||
appearance: "light",
|
||||
autosave: false,
|
||||
theme: "light",
|
||||
collaborators: new Map(),
|
||||
currentChartType: "bar",
|
||||
currentItemBackgroundColor: "transparent",
|
||||
@@ -43,7 +44,6 @@ export const getDefaultAppState = (): Omit<
|
||||
exportWithDarkMode: false,
|
||||
fileHandle: null,
|
||||
gridSize: null,
|
||||
height: window.innerHeight,
|
||||
isBindingEnabled: true,
|
||||
isLibraryOpen: false,
|
||||
isLoading: false,
|
||||
@@ -70,7 +70,6 @@ export const getDefaultAppState = (): Omit<
|
||||
suggestedBindings: [],
|
||||
toastMessage: null,
|
||||
viewBackgroundColor: oc.white,
|
||||
width: window.innerWidth,
|
||||
zenModeEnabled: false,
|
||||
zoom: { value: 1 as NormalizedZoomValue, translation: { x: 0, y: 0 } },
|
||||
viewModeEnabled: false,
|
||||
@@ -92,7 +91,8 @@ const APP_STATE_STORAGE_CONF = (<
|
||||
>(
|
||||
config: { [K in keyof T]: K extends keyof AppState ? T[K] : never },
|
||||
) => config)({
|
||||
appearance: { browser: true, export: false },
|
||||
autosave: { browser: true, export: false },
|
||||
theme: { browser: true, export: false },
|
||||
collaborators: { browser: false, export: false },
|
||||
currentChartType: { browser: true, export: false },
|
||||
currentItemBackgroundColor: { browser: true, export: false },
|
||||
|
@@ -7,12 +7,10 @@ import { AppState } from "./types";
|
||||
import { SVG_EXPORT_TAG } from "./scene/export";
|
||||
import { tryParseSpreadsheet, Spreadsheet, VALID_SPREADSHEET } from "./charts";
|
||||
import { canvasToBlob } from "./data/blob";
|
||||
|
||||
const TYPE_ELEMENTS = "excalidraw/elements";
|
||||
import { EXPORT_DATA_TYPES } from "./constants";
|
||||
|
||||
type ElementsClipboard = {
|
||||
type: typeof TYPE_ELEMENTS;
|
||||
created: number;
|
||||
type: typeof EXPORT_DATA_TYPES.excalidrawClipboard;
|
||||
elements: ExcalidrawElement[];
|
||||
};
|
||||
|
||||
@@ -31,8 +29,16 @@ export const probablySupportsClipboardBlob =
|
||||
"ClipboardItem" in window &&
|
||||
"toBlob" in HTMLCanvasElement.prototype;
|
||||
|
||||
const isElementsClipboard = (contents: any): contents is ElementsClipboard => {
|
||||
if (contents?.type === TYPE_ELEMENTS) {
|
||||
const clipboardContainsElements = (
|
||||
contents: any,
|
||||
): contents is { elements: ExcalidrawElement[] } => {
|
||||
if (
|
||||
[
|
||||
EXPORT_DATA_TYPES.excalidraw,
|
||||
EXPORT_DATA_TYPES.excalidrawClipboard,
|
||||
].includes(contents?.type) &&
|
||||
Array.isArray(contents.elements)
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
@@ -43,8 +49,7 @@ export const copyToClipboard = async (
|
||||
appState: AppState,
|
||||
) => {
|
||||
const contents: ElementsClipboard = {
|
||||
type: TYPE_ELEMENTS,
|
||||
created: Date.now(),
|
||||
type: EXPORT_DATA_TYPES.excalidrawClipboard,
|
||||
elements: getSelectedElements(elements, appState),
|
||||
};
|
||||
const json = JSON.stringify(contents);
|
||||
@@ -131,15 +136,9 @@ export const parseClipboard = async (
|
||||
|
||||
try {
|
||||
const systemClipboardData = JSON.parse(systemClipboard);
|
||||
// system clipboard elements are newer than in-app clipboard
|
||||
if (
|
||||
isElementsClipboard(systemClipboardData) &&
|
||||
(!appClipboardData?.created ||
|
||||
appClipboardData.created < systemClipboardData.created)
|
||||
) {
|
||||
if (clipboardContainsElements(systemClipboardData)) {
|
||||
return { elements: systemClipboardData.elements };
|
||||
}
|
||||
// in-app clipboard is newer than system clipboard
|
||||
return appClipboardData;
|
||||
} catch {
|
||||
// system clipboard doesn't contain excalidraw elements → return plaintext
|
||||
|
@@ -3,7 +3,7 @@ import { ActionManager } from "../actions/manager";
|
||||
import { getNonDeletedElements } from "../element";
|
||||
import { ExcalidrawElement } from "../element/types";
|
||||
import { t } from "../i18n";
|
||||
import useIsMobile from "../is-mobile";
|
||||
import { useIsMobile } from "../is-mobile";
|
||||
import {
|
||||
canChangeSharpness,
|
||||
canHaveArrowheads,
|
||||
|
@@ -3,6 +3,7 @@ import React from "react";
|
||||
import { RoughCanvas } from "roughjs/bin/canvas";
|
||||
import rough from "roughjs/bin/rough";
|
||||
import clsx from "clsx";
|
||||
import { supported } from "browser-fs-access";
|
||||
|
||||
import {
|
||||
actionAddToLibrary,
|
||||
@@ -16,6 +17,8 @@ import {
|
||||
actionDeleteSelected,
|
||||
actionDuplicateSelection,
|
||||
actionFinalize,
|
||||
actionFlipHorizontal,
|
||||
actionFlipVertical,
|
||||
actionGroup,
|
||||
actionPasteStyles,
|
||||
actionSelectAll,
|
||||
@@ -41,6 +44,7 @@ import {
|
||||
import {
|
||||
APP_NAME,
|
||||
CURSOR_TYPE,
|
||||
DEFAULT_UI_OPTIONS,
|
||||
DEFAULT_VERTICAL_ALIGN,
|
||||
DRAGGING_THRESHOLD,
|
||||
ELEMENT_SHIFT_TRANSLATE_AMOUNT,
|
||||
@@ -52,13 +56,16 @@ import {
|
||||
MIME_TYPES,
|
||||
POINTER_BUTTON,
|
||||
SCROLL_TIMEOUT,
|
||||
AUTO_SAVE_TIMEOUT,
|
||||
TAP_TWICE_TIMEOUT,
|
||||
TEXT_TO_CENTER_SNAP_THRESHOLD,
|
||||
TOUCH_CTX_MENU_TIMEOUT,
|
||||
URL_HASH_KEYS,
|
||||
URL_QUERY_KEYS,
|
||||
ZOOM_STEP,
|
||||
} from "../constants";
|
||||
import { loadFromBlob } from "../data";
|
||||
import { isValidLibrary } from "../data/json";
|
||||
import { saveAsJSON, isValidLibrary } from "../data/json";
|
||||
import { Library } from "../data/library";
|
||||
import { restore } from "../data/restore";
|
||||
import {
|
||||
@@ -155,13 +162,7 @@ import Scene from "../scene/Scene";
|
||||
import { SceneState, ScrollBars } from "../scene/types";
|
||||
import { getNewZoom } from "../scene/zoom";
|
||||
import { findShapeByKey } from "../shapes";
|
||||
import {
|
||||
AppState,
|
||||
ExcalidrawProps,
|
||||
Gesture,
|
||||
GestureEvent,
|
||||
SceneData,
|
||||
} from "../types";
|
||||
import { AppProps, AppState, Gesture, GestureEvent, SceneData } from "../types";
|
||||
import {
|
||||
debounce,
|
||||
distance,
|
||||
@@ -271,48 +272,52 @@ export type ExcalidrawImperativeAPI = {
|
||||
history: {
|
||||
clear: InstanceType<typeof App>["resetHistory"];
|
||||
};
|
||||
setScrollToCenter: InstanceType<typeof App>["setScrollToCenter"];
|
||||
setScrollToContent: InstanceType<typeof App>["setScrollToContent"];
|
||||
getSceneElements: InstanceType<typeof App>["getSceneElements"];
|
||||
getAppState: () => InstanceType<typeof App>["state"];
|
||||
refresh: InstanceType<typeof App>["refresh"];
|
||||
importLibrary: InstanceType<typeof App>["importLibraryFromUrl"];
|
||||
setToastMessage: InstanceType<typeof App>["setToastMessage"];
|
||||
readyPromise: ResolvablePromise<ExcalidrawImperativeAPI>;
|
||||
ready: true;
|
||||
};
|
||||
|
||||
class App extends React.Component<ExcalidrawProps, AppState> {
|
||||
class App extends React.Component<AppProps, AppState> {
|
||||
canvas: HTMLCanvasElement | null = null;
|
||||
rc: RoughCanvas | null = null;
|
||||
unmounted: boolean = false;
|
||||
actionManager: ActionManager;
|
||||
private excalidrawContainerRef = React.createRef<HTMLDivElement>();
|
||||
|
||||
public static defaultProps: Partial<ExcalidrawProps> = {
|
||||
width: window.innerWidth,
|
||||
height: window.innerHeight,
|
||||
public static defaultProps: Partial<AppProps> = {
|
||||
// needed for tests to pass since we directly render App in many tests
|
||||
UIOptions: DEFAULT_UI_OPTIONS,
|
||||
};
|
||||
|
||||
private scene: Scene;
|
||||
constructor(props: ExcalidrawProps) {
|
||||
private resizeObserver: ResizeObserver | undefined;
|
||||
constructor(props: AppProps) {
|
||||
super(props);
|
||||
const defaultAppState = getDefaultAppState();
|
||||
|
||||
const {
|
||||
width = window.innerWidth,
|
||||
height = window.innerHeight,
|
||||
offsetLeft,
|
||||
offsetTop,
|
||||
excalidrawRef,
|
||||
viewModeEnabled = false,
|
||||
zenModeEnabled = false,
|
||||
gridModeEnabled = false,
|
||||
theme = defaultAppState.theme,
|
||||
name = defaultAppState.name,
|
||||
} = props;
|
||||
this.state = {
|
||||
...defaultAppState,
|
||||
theme,
|
||||
isLoading: true,
|
||||
width,
|
||||
height,
|
||||
...this.getCanvasOffsets({ offsetLeft, offsetTop }),
|
||||
...this.getCanvasOffsets(),
|
||||
viewModeEnabled,
|
||||
zenModeEnabled,
|
||||
gridSize: gridModeEnabled ? GRID_SIZE : null,
|
||||
name,
|
||||
width: window.innerWidth,
|
||||
height: window.innerHeight,
|
||||
};
|
||||
if (excalidrawRef) {
|
||||
const readyPromise =
|
||||
@@ -328,9 +333,12 @@ class App extends React.Component<ExcalidrawProps, AppState> {
|
||||
history: {
|
||||
clear: this.resetHistory,
|
||||
},
|
||||
setScrollToCenter: this.setScrollToCenter,
|
||||
setScrollToContent: this.setScrollToContent,
|
||||
getSceneElements: this.getSceneElements,
|
||||
getAppState: () => this.state,
|
||||
refresh: this.refresh,
|
||||
importLibrary: this.importLibraryFromUrl,
|
||||
setToastMessage: this.setToastMessage,
|
||||
} as const;
|
||||
if (typeof excalidrawRef === "function") {
|
||||
excalidrawRef(api);
|
||||
@@ -402,7 +410,6 @@ class App extends React.Component<ExcalidrawProps, AppState> {
|
||||
onPointerUp={this.removePointer}
|
||||
onPointerCancel={this.removePointer}
|
||||
onTouchMove={this.handleTouchMove}
|
||||
onDrop={this.handleCanvasOnDrop}
|
||||
>
|
||||
{t("labels.drawingCanvas")}
|
||||
</canvas>
|
||||
@@ -417,7 +424,12 @@ class App extends React.Component<ExcalidrawProps, AppState> {
|
||||
viewModeEnabled,
|
||||
} = this.state;
|
||||
|
||||
const { onCollabButtonClick, onExportToBackend, renderFooter } = this.props;
|
||||
const {
|
||||
onCollabButtonClick,
|
||||
onExportToBackend,
|
||||
renderFooter,
|
||||
renderCustomStats,
|
||||
} = this.props;
|
||||
|
||||
const DEFAULT_PASTE_X = canvasDOMWidth / 2;
|
||||
const DEFAULT_PASTE_Y = canvasDOMHeight / 2;
|
||||
@@ -428,10 +440,7 @@ class App extends React.Component<ExcalidrawProps, AppState> {
|
||||
"excalidraw--view-mode": viewModeEnabled,
|
||||
})}
|
||||
ref={this.excalidrawContainerRef}
|
||||
style={{
|
||||
width: canvasDOMWidth,
|
||||
height: canvasDOMHeight,
|
||||
}}
|
||||
onDrop={this.handleAppOnDrop}
|
||||
>
|
||||
<LayerUI
|
||||
canvas={this.canvas}
|
||||
@@ -458,14 +467,22 @@ class App extends React.Component<ExcalidrawProps, AppState> {
|
||||
showExitZenModeBtn={
|
||||
typeof this.props?.zenModeEnabled === "undefined" && zenModeEnabled
|
||||
}
|
||||
showThemeBtn={
|
||||
typeof this.props?.theme === "undefined" &&
|
||||
this.props.UIOptions.canvasActions.theme
|
||||
}
|
||||
libraryReturnUrl={this.props.libraryReturnUrl}
|
||||
UIOptions={this.props.UIOptions}
|
||||
/>
|
||||
<div className="excalidraw-textEditorContainer" />
|
||||
<div className="excalidraw-contextMenuContainer" />
|
||||
{this.state.showStats && (
|
||||
<Stats
|
||||
appState={this.state}
|
||||
setAppState={this.setAppState}
|
||||
elements={this.scene.getElements()}
|
||||
onClose={this.toggleStats}
|
||||
renderCustomStats={renderCustomStats}
|
||||
/>
|
||||
)}
|
||||
{this.state.toastMessage !== null && (
|
||||
@@ -518,7 +535,8 @@ class App extends React.Component<ExcalidrawProps, AppState> {
|
||||
let viewModeEnabled = actionResult?.appState?.viewModeEnabled || false;
|
||||
let zenModeEnabled = actionResult?.appState?.zenModeEnabled || false;
|
||||
let gridSize = actionResult?.appState?.gridSize || null;
|
||||
|
||||
let theme = actionResult?.appState?.theme || "light";
|
||||
let name = actionResult?.appState?.name ?? this.state.name;
|
||||
if (typeof this.props.viewModeEnabled !== "undefined") {
|
||||
viewModeEnabled = this.props.viewModeEnabled;
|
||||
}
|
||||
@@ -531,6 +549,13 @@ class App extends React.Component<ExcalidrawProps, AppState> {
|
||||
gridSize = this.props.gridModeEnabled ? GRID_SIZE : null;
|
||||
}
|
||||
|
||||
if (typeof this.props.theme !== "undefined") {
|
||||
theme = this.props.theme;
|
||||
}
|
||||
|
||||
if (typeof this.props.name !== "undefined") {
|
||||
name = this.props.name;
|
||||
}
|
||||
this.setState(
|
||||
(state) => {
|
||||
// using Object.assign instead of spread to fool TS 4.2.2+ into
|
||||
@@ -539,13 +564,11 @@ class App extends React.Component<ExcalidrawProps, AppState> {
|
||||
return Object.assign(actionResult.appState || {}, {
|
||||
editingElement:
|
||||
editingElement || actionResult.appState?.editingElement || null,
|
||||
width: state.width,
|
||||
height: state.height,
|
||||
offsetTop: state.offsetTop,
|
||||
offsetLeft: state.offsetLeft,
|
||||
viewModeEnabled,
|
||||
zenModeEnabled,
|
||||
gridSize,
|
||||
theme,
|
||||
name,
|
||||
});
|
||||
},
|
||||
() => {
|
||||
@@ -585,24 +608,36 @@ class App extends React.Component<ExcalidrawProps, AppState> {
|
||||
this.onSceneUpdated();
|
||||
};
|
||||
|
||||
private importLibraryFromUrl = async (url: string) => {
|
||||
window.history.replaceState({}, APP_NAME, window.location.origin);
|
||||
private importLibraryFromUrl = async (url: string, token?: string | null) => {
|
||||
if (window.location.hash.includes(URL_HASH_KEYS.addLibrary)) {
|
||||
const hash = new URLSearchParams(window.location.hash.slice(1));
|
||||
hash.delete(URL_HASH_KEYS.addLibrary);
|
||||
window.history.replaceState({}, APP_NAME, `#${hash.toString()}`);
|
||||
} else if (window.location.search.includes(URL_QUERY_KEYS.addLibrary)) {
|
||||
const query = new URLSearchParams(window.location.search);
|
||||
query.delete(URL_QUERY_KEYS.addLibrary);
|
||||
window.history.replaceState({}, APP_NAME, `?${query.toString()}`);
|
||||
}
|
||||
|
||||
try {
|
||||
const request = await fetch(url);
|
||||
const request = await fetch(decodeURIComponent(url));
|
||||
const blob = await request.blob();
|
||||
const json = JSON.parse(await blob.text());
|
||||
if (!isValidLibrary(json)) {
|
||||
throw new Error();
|
||||
}
|
||||
if (
|
||||
token === Library.csrfToken ||
|
||||
window.confirm(
|
||||
t("alerts.confirmAddLibrary", { numShapes: json.library.length }),
|
||||
)
|
||||
) {
|
||||
await Library.importLibrary(blob);
|
||||
this.setState({
|
||||
isLibraryOpen: true,
|
||||
});
|
||||
// hack to rerender the library items after import
|
||||
if (this.state.isLibraryOpen) {
|
||||
this.setState({ isLibraryOpen: false });
|
||||
}
|
||||
this.setState({ isLibraryOpen: true });
|
||||
}
|
||||
} catch (error) {
|
||||
window.alert(t("alerts.errorLoadingLibrary"));
|
||||
@@ -624,7 +659,7 @@ class App extends React.Component<ExcalidrawProps, AppState> {
|
||||
this.setState((state) => ({
|
||||
...getDefaultAppState(),
|
||||
isLoading: opts?.resetLoadingState ? false : state.isLoading,
|
||||
appearance: this.state.appearance,
|
||||
theme: this.state.theme,
|
||||
}));
|
||||
this.resetHistory();
|
||||
},
|
||||
@@ -661,7 +696,6 @@ class App extends React.Component<ExcalidrawProps, AppState> {
|
||||
if (!this.state.isLoading) {
|
||||
this.setState({ isLoading: true });
|
||||
}
|
||||
|
||||
let initialData = null;
|
||||
try {
|
||||
initialData = (await this.props.initialData) || null;
|
||||
@@ -670,12 +704,11 @@ class App extends React.Component<ExcalidrawProps, AppState> {
|
||||
}
|
||||
|
||||
const scene = restore(initialData, null);
|
||||
|
||||
scene.appState = {
|
||||
...scene.appState,
|
||||
isLoading: false,
|
||||
};
|
||||
if (initialData?.scrollToCenter) {
|
||||
if (initialData?.scrollToContent) {
|
||||
scene.appState = {
|
||||
...scene.appState,
|
||||
...calculateScrollCenter(
|
||||
@@ -698,12 +731,18 @@ class App extends React.Component<ExcalidrawProps, AppState> {
|
||||
commitToHistory: true,
|
||||
});
|
||||
|
||||
const addToLibraryUrl = new URLSearchParams(window.location.search).get(
|
||||
"addLibrary",
|
||||
);
|
||||
const libraryUrl =
|
||||
// current
|
||||
new URLSearchParams(window.location.hash.slice(1)).get(
|
||||
URL_HASH_KEYS.addLibrary,
|
||||
) ||
|
||||
// legacy, kept for compat reasons
|
||||
new URLSearchParams(window.location.search).get(
|
||||
URL_QUERY_KEYS.addLibrary,
|
||||
);
|
||||
|
||||
if (addToLibraryUrl) {
|
||||
await this.importLibraryFromUrl(addToLibraryUrl);
|
||||
if (libraryUrl) {
|
||||
await this.importLibraryFromUrl(libraryUrl);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -736,20 +775,24 @@ class App extends React.Component<ExcalidrawProps, AppState> {
|
||||
this.scene.addCallback(this.onSceneUpdated);
|
||||
this.addEventListeners();
|
||||
|
||||
// optim to avoid extra render on init
|
||||
if (
|
||||
typeof this.props.offsetLeft === "number" &&
|
||||
typeof this.props.offsetTop === "number"
|
||||
) {
|
||||
this.initializeScene();
|
||||
} else {
|
||||
this.setState(this.getCanvasOffsets(this.props), () => {
|
||||
this.initializeScene();
|
||||
if ("ResizeObserver" in window && this.excalidrawContainerRef?.current) {
|
||||
this.resizeObserver = new ResizeObserver(() => {
|
||||
this.updateDOMRect();
|
||||
});
|
||||
this.resizeObserver?.observe(this.excalidrawContainerRef.current);
|
||||
}
|
||||
const searchParams = new URLSearchParams(window.location.search.slice(1));
|
||||
|
||||
if (searchParams.has("web-share-target")) {
|
||||
// Obtain a file that was shared via the Web Share Target API.
|
||||
this.restoreFileFromShare();
|
||||
} else {
|
||||
this.updateDOMRect(this.initializeScene);
|
||||
}
|
||||
}
|
||||
|
||||
public componentWillUnmount() {
|
||||
this.resizeObserver?.disconnect();
|
||||
this.unmounted = true;
|
||||
this.removeEventListeners();
|
||||
this.scene.destroy();
|
||||
@@ -841,26 +884,11 @@ class App extends React.Component<ExcalidrawProps, AppState> {
|
||||
window.addEventListener(EVENT.DROP, this.disableEvent, false);
|
||||
}
|
||||
|
||||
componentDidUpdate(prevProps: ExcalidrawProps, prevState: AppState) {
|
||||
componentDidUpdate(prevProps: AppProps, prevState: AppState) {
|
||||
if (prevProps.langCode !== this.props.langCode) {
|
||||
this.updateLanguage();
|
||||
}
|
||||
|
||||
if (
|
||||
prevProps.width !== this.props.width ||
|
||||
prevProps.height !== this.props.height ||
|
||||
(typeof this.props.offsetLeft === "number" &&
|
||||
prevProps.offsetLeft !== this.props.offsetLeft) ||
|
||||
(typeof this.props.offsetTop === "number" &&
|
||||
prevProps.offsetTop !== this.props.offsetTop)
|
||||
) {
|
||||
this.setState({
|
||||
width: this.props.width ?? window.innerWidth,
|
||||
height: this.props.height ?? window.innerHeight,
|
||||
...this.getCanvasOffsets(this.props),
|
||||
});
|
||||
}
|
||||
|
||||
if (prevProps.viewModeEnabled !== this.props.viewModeEnabled) {
|
||||
this.setState(
|
||||
{ viewModeEnabled: !!this.props.viewModeEnabled },
|
||||
@@ -876,14 +904,32 @@ class App extends React.Component<ExcalidrawProps, AppState> {
|
||||
this.setState({ zenModeEnabled: !!this.props.zenModeEnabled });
|
||||
}
|
||||
|
||||
if (prevProps.theme !== this.props.theme && this.props.theme) {
|
||||
this.setState({ theme: this.props.theme });
|
||||
}
|
||||
|
||||
if (prevProps.gridModeEnabled !== this.props.gridModeEnabled) {
|
||||
this.setState({
|
||||
gridSize: this.props.gridModeEnabled ? GRID_SIZE : null,
|
||||
});
|
||||
}
|
||||
|
||||
if (this.props.name && prevProps.name !== this.props.name) {
|
||||
this.setState({
|
||||
name: this.props.name,
|
||||
});
|
||||
}
|
||||
|
||||
document
|
||||
.querySelector(".excalidraw")
|
||||
?.classList.toggle("Appearance_dark", this.state.appearance === "dark");
|
||||
?.classList.toggle("theme--dark", this.state.theme === "dark");
|
||||
|
||||
if (this.state.autosave && this.state.fileHandle && supported) {
|
||||
this.autosaveLocalSceneDebounced(
|
||||
this.scene.getElementsIncludingDeleted(),
|
||||
this.state,
|
||||
);
|
||||
}
|
||||
|
||||
if (
|
||||
this.state.editingLinearElement &&
|
||||
@@ -1017,6 +1063,37 @@ class App extends React.Component<ExcalidrawProps, AppState> {
|
||||
});
|
||||
}, SCROLL_TIMEOUT);
|
||||
|
||||
private autosaveLocalSceneDebounced = debounce(
|
||||
async (elements: readonly ExcalidrawElement[], state: AppState) => {
|
||||
if (this.state.autosave && this.state.fileHandle && supported) {
|
||||
try {
|
||||
await saveAsJSON(
|
||||
elements,
|
||||
state,
|
||||
// only if fileHandle valid
|
||||
true,
|
||||
);
|
||||
} catch (error) {
|
||||
this.setState({
|
||||
autosave: false,
|
||||
toastMessage:
|
||||
error.name === "NotAllowedError"
|
||||
? t("toast.autosaveFailed_notAllowed")
|
||||
: error.name === "NotFoundError"
|
||||
? t("toast.autosaveFailed_notFound")
|
||||
: t("toast.autosaveFailed"),
|
||||
});
|
||||
|
||||
// shouldn't happen, so let's log it
|
||||
if (!["NotAllowedError", "NotFoundError"].includes(error.name)) {
|
||||
console.error(error);
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
AUTO_SAVE_TIMEOUT,
|
||||
);
|
||||
|
||||
// Copy/paste
|
||||
|
||||
private onCut = withBatchedUpdates((event: ClipboardEvent) => {
|
||||
@@ -1029,8 +1106,16 @@ class App extends React.Component<ExcalidrawProps, AppState> {
|
||||
|
||||
private onCopy = withBatchedUpdates((event: ClipboardEvent) => {
|
||||
const activeSelection = document.getSelection();
|
||||
// if there's a selected text is outside the component, prevent our copy
|
||||
// action
|
||||
if (
|
||||
activeSelection?.anchorNode &&
|
||||
// it can happen that certain interactions will create a selection
|
||||
// outside (or potentially inside) the component without actually
|
||||
// selecting anything (i.e. the selection range is collapsed). Copying
|
||||
// in such case wouldn't copy anything to the clipboard anyway, so prevent
|
||||
// our copy handler only if the selection isn't collapsed
|
||||
!activeSelection.isCollapsed &&
|
||||
!this.excalidrawContainerRef.current!.contains(activeSelection.anchorNode)
|
||||
) {
|
||||
return;
|
||||
@@ -1086,7 +1171,6 @@ class App extends React.Component<ExcalidrawProps, AppState> {
|
||||
};
|
||||
|
||||
private onTapEnd = (event: TouchEvent) => {
|
||||
event.preventDefault();
|
||||
if (event.touches.length > 0) {
|
||||
this.setState({
|
||||
previousSelectedElementIds: {},
|
||||
@@ -1263,7 +1347,7 @@ class App extends React.Component<ExcalidrawProps, AppState> {
|
||||
this.actionManager.executeAction(actionToggleStats);
|
||||
};
|
||||
|
||||
setScrollToCenter = (remoteElements: readonly ExcalidrawElement[]) => {
|
||||
setScrollToContent = (remoteElements: readonly ExcalidrawElement[]) => {
|
||||
this.setState({
|
||||
...calculateScrollCenter(
|
||||
getNonDeletedElements(remoteElements),
|
||||
@@ -1277,6 +1361,26 @@ class App extends React.Component<ExcalidrawProps, AppState> {
|
||||
this.setState({ toastMessage: null });
|
||||
};
|
||||
|
||||
setToastMessage = (toastMessage: string) => {
|
||||
this.setState({ toastMessage });
|
||||
};
|
||||
|
||||
restoreFileFromShare = async () => {
|
||||
try {
|
||||
const webShareTargetCache = await caches.open("web-share-target");
|
||||
|
||||
const file = await webShareTargetCache.match("shared-file");
|
||||
if (file) {
|
||||
const blob = await file.blob();
|
||||
this.loadFileToCanvas(blob);
|
||||
await webShareTargetCache.delete("shared-file");
|
||||
window.history.replaceState(null, APP_NAME, window.location.pathname);
|
||||
}
|
||||
} catch (error) {
|
||||
this.setState({ errorMessage: error.message });
|
||||
}
|
||||
};
|
||||
|
||||
public updateScene = withBatchedUpdates((sceneData: SceneData) => {
|
||||
if (sceneData.commitToHistory) {
|
||||
history.resumeRecording();
|
||||
@@ -1358,7 +1462,7 @@ class App extends React.Component<ExcalidrawProps, AppState> {
|
||||
return;
|
||||
}
|
||||
|
||||
if (event[KEYS.CTRL_OR_CMD]) {
|
||||
if (event[KEYS.CTRL_OR_CMD] && this.state.isBindingEnabled) {
|
||||
this.setState({ isBindingEnabled: false });
|
||||
}
|
||||
|
||||
@@ -1596,10 +1700,12 @@ class App extends React.Component<ExcalidrawProps, AppState> {
|
||||
updateBoundElements(element);
|
||||
}
|
||||
}),
|
||||
onSubmit: withBatchedUpdates((text) => {
|
||||
onSubmit: withBatchedUpdates(({ text, viaKeyboard }) => {
|
||||
const isDeleted = !text.trim();
|
||||
updateElement(text, isDeleted);
|
||||
if (!isDeleted) {
|
||||
// select the created text element only if submitting via keyboard
|
||||
// (when submitting via click it should act as signal to deselect)
|
||||
if (!isDeleted && viaKeyboard) {
|
||||
this.setState((prevState) => ({
|
||||
selectedElementIds: {
|
||||
...prevState.selectedElementIds,
|
||||
@@ -1832,8 +1938,7 @@ class App extends React.Component<ExcalidrawProps, AppState> {
|
||||
}
|
||||
|
||||
resetCursor(this.canvas);
|
||||
|
||||
if (!event[KEYS.CTRL_OR_CMD]) {
|
||||
if (!event[KEYS.CTRL_OR_CMD] && !this.state.viewModeEnabled) {
|
||||
this.startTextEditing({
|
||||
sceneX,
|
||||
sceneY,
|
||||
@@ -2089,6 +2194,14 @@ class App extends React.Component<ExcalidrawProps, AppState> {
|
||||
) => {
|
||||
event.persist();
|
||||
|
||||
// remove any active selection when we start to interact with canvas
|
||||
// (mainly, we care about removing selection outside the component which
|
||||
// would prevent our copy handling otherwise)
|
||||
const selection = document.getSelection();
|
||||
if (selection?.anchorNode) {
|
||||
selection.removeAllRanges();
|
||||
}
|
||||
|
||||
this.maybeOpenContextMenuAfterPointerDownOnTouchDevices(event);
|
||||
this.maybeCleanupAfterMissingPointerUp(event);
|
||||
|
||||
@@ -2116,15 +2229,6 @@ class App extends React.Component<ExcalidrawProps, AppState> {
|
||||
|
||||
this.updateGestureOnPointerDown(event);
|
||||
|
||||
// fixes pointermove causing selection of UI texts #32
|
||||
event.preventDefault();
|
||||
// Preventing the event above disables default behavior
|
||||
// of defocusing potentially focused element, which is what we
|
||||
// want when clicking inside the canvas.
|
||||
if (document.activeElement instanceof HTMLElement) {
|
||||
document.activeElement.blur();
|
||||
}
|
||||
|
||||
// don't select while panning
|
||||
if (gesture.pointers.size > 1) {
|
||||
return;
|
||||
@@ -2208,10 +2312,7 @@ class App extends React.Component<ExcalidrawProps, AppState> {
|
||||
touchTimeout = window.setTimeout(() => {
|
||||
touchTimeout = 0;
|
||||
if (!invalidateContextMenu) {
|
||||
this.openContextMenu({
|
||||
clientX: event.clientX,
|
||||
clientY: event.clientY,
|
||||
});
|
||||
this.handleCanvasContextMenu(event);
|
||||
}
|
||||
}, TOUCH_CTX_MENU_TIMEOUT);
|
||||
}
|
||||
@@ -3522,9 +3623,7 @@ class App extends React.Component<ExcalidrawProps, AppState> {
|
||||
}
|
||||
};
|
||||
|
||||
private handleCanvasOnDrop = async (
|
||||
event: React.DragEvent<HTMLCanvasElement>,
|
||||
) => {
|
||||
private handleAppOnDrop = async (event: React.DragEvent<HTMLDivElement>) => {
|
||||
try {
|
||||
const file = event.dataTransfer.files[0];
|
||||
if (file?.type === "image/png" || file?.type === "image/svg+xml") {
|
||||
@@ -3558,14 +3657,22 @@ class App extends React.Component<ExcalidrawProps, AppState> {
|
||||
|
||||
const file = event.dataTransfer?.files[0];
|
||||
if (
|
||||
file?.type === "application/json" ||
|
||||
file?.name.endsWith(".excalidraw")
|
||||
file?.type === MIME_TYPES.excalidrawlib ||
|
||||
file?.name?.endsWith(".excalidrawlib")
|
||||
) {
|
||||
Library.importLibrary(file)
|
||||
.then(() => {
|
||||
// Close and then open to get the libraries updated
|
||||
this.setState({ isLibraryOpen: false });
|
||||
this.setState({ isLibraryOpen: true });
|
||||
})
|
||||
.catch((error) =>
|
||||
this.setState({ isLoading: false, errorMessage: error.message }),
|
||||
);
|
||||
// default: assume an Excalidraw file regardless of extension/MimeType
|
||||
} else {
|
||||
this.setState({ isLoading: true });
|
||||
if (
|
||||
"chooseFileSystemEntries" in window ||
|
||||
"showOpenFilePicker" in window
|
||||
) {
|
||||
if (supported) {
|
||||
try {
|
||||
// This will only work as of Chrome 86,
|
||||
// but can be safely ignored on older releases.
|
||||
@@ -3575,44 +3682,52 @@ class App extends React.Component<ExcalidrawProps, AppState> {
|
||||
console.warn(error.name, error.message);
|
||||
}
|
||||
}
|
||||
loadFromBlob(file, this.state)
|
||||
.then(({ elements, appState }) =>
|
||||
this.syncActionResult({
|
||||
elements,
|
||||
appState: {
|
||||
...(appState || this.state),
|
||||
isLoading: false,
|
||||
},
|
||||
commitToHistory: true,
|
||||
}),
|
||||
)
|
||||
.catch((error) => {
|
||||
this.setState({ isLoading: false, errorMessage: error.message });
|
||||
});
|
||||
} else if (
|
||||
file?.type === MIME_TYPES.excalidrawlib ||
|
||||
file?.name.endsWith(".excalidrawlib")
|
||||
) {
|
||||
Library.importLibrary(file)
|
||||
.then(() => {
|
||||
this.setState({ isLibraryOpen: false });
|
||||
})
|
||||
.catch((error) =>
|
||||
this.setState({ isLoading: false, errorMessage: error.message }),
|
||||
);
|
||||
} else {
|
||||
this.setState({
|
||||
isLoading: false,
|
||||
errorMessage: t("alerts.couldNotLoadInvalidFile"),
|
||||
});
|
||||
await this.loadFileToCanvas(file);
|
||||
}
|
||||
};
|
||||
|
||||
loadFileToCanvas = (file: Blob) => {
|
||||
loadFromBlob(file, this.state)
|
||||
.then(({ elements, appState }) =>
|
||||
this.syncActionResult({
|
||||
elements,
|
||||
appState: {
|
||||
...(appState || this.state),
|
||||
isLoading: false,
|
||||
},
|
||||
commitToHistory: true,
|
||||
}),
|
||||
)
|
||||
.catch((error) => {
|
||||
this.setState({ isLoading: false, errorMessage: error.message });
|
||||
});
|
||||
};
|
||||
|
||||
private handleCanvasContextMenu = (
|
||||
event: React.PointerEvent<HTMLCanvasElement>,
|
||||
) => {
|
||||
event.preventDefault();
|
||||
this.openContextMenu(event);
|
||||
|
||||
const { x, y } = viewportCoordsToSceneCoords(event, this.state);
|
||||
const element = this.getElementAtPosition(x, y);
|
||||
|
||||
const type = element ? "element" : "canvas";
|
||||
|
||||
const container = this.excalidrawContainerRef.current!;
|
||||
const {
|
||||
top: offsetTop,
|
||||
left: offsetLeft,
|
||||
} = container.getBoundingClientRect();
|
||||
const left = event.clientX - offsetLeft;
|
||||
const top = event.clientY - offsetTop;
|
||||
|
||||
if (element && !this.state.selectedElementIds[element.id]) {
|
||||
this.setState({ selectedElementIds: { [element.id]: true } }, () => {
|
||||
this._openContextMenu({ top, left }, type);
|
||||
});
|
||||
} else {
|
||||
this._openContextMenu({ top, left }, type);
|
||||
}
|
||||
};
|
||||
|
||||
private maybeDragNewGenericElement = (
|
||||
@@ -3702,18 +3817,17 @@ class App extends React.Component<ExcalidrawProps, AppState> {
|
||||
return false;
|
||||
};
|
||||
|
||||
private openContextMenu = ({
|
||||
clientX,
|
||||
clientY,
|
||||
}: {
|
||||
clientX: number;
|
||||
clientY: number;
|
||||
}) => {
|
||||
const { x, y } = viewportCoordsToSceneCoords(
|
||||
{ clientX, clientY },
|
||||
this.state,
|
||||
);
|
||||
|
||||
/** @private use this.handleCanvasContextMenu */
|
||||
private _openContextMenu = (
|
||||
{
|
||||
left,
|
||||
top,
|
||||
}: {
|
||||
left: number;
|
||||
top: number;
|
||||
},
|
||||
type: "canvas" | "element",
|
||||
) => {
|
||||
const maybeGroupAction = actionGroup.contextItemPredicate!(
|
||||
this.actionManager.getElementsIncludingDeleted(),
|
||||
this.actionManager.getAppState(),
|
||||
@@ -3724,12 +3838,22 @@ class App extends React.Component<ExcalidrawProps, AppState> {
|
||||
this.actionManager.getAppState(),
|
||||
);
|
||||
|
||||
const maybeFlipHorizontal = actionFlipHorizontal.contextItemPredicate!(
|
||||
this.actionManager.getElementsIncludingDeleted(),
|
||||
this.actionManager.getAppState(),
|
||||
);
|
||||
|
||||
const maybeFlipVertical = actionFlipVertical.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);
|
||||
@@ -3738,7 +3862,7 @@ class App extends React.Component<ExcalidrawProps, AppState> {
|
||||
if (probablySupportsClipboardWriteText && elements.length > 0) {
|
||||
options.push(actionCopyAsSvg);
|
||||
}
|
||||
if (!element) {
|
||||
if (type === "canvas") {
|
||||
const viewModeOptions = [
|
||||
...options,
|
||||
typeof this.props.gridModeEnabled === "undefined" &&
|
||||
@@ -3751,10 +3875,11 @@ class App extends React.Component<ExcalidrawProps, AppState> {
|
||||
|
||||
ContextMenu.push({
|
||||
options: viewModeOptions,
|
||||
top: clientY,
|
||||
left: clientX,
|
||||
top,
|
||||
left,
|
||||
actionManager: this.actionManager,
|
||||
appState: this.state,
|
||||
container: this.excalidrawContainerRef.current!,
|
||||
});
|
||||
|
||||
if (this.state.viewModeEnabled) {
|
||||
@@ -3794,25 +3919,23 @@ class App extends React.Component<ExcalidrawProps, AppState> {
|
||||
actionToggleViewMode,
|
||||
actionToggleStats,
|
||||
],
|
||||
top: clientY,
|
||||
left: clientX,
|
||||
top,
|
||||
left,
|
||||
actionManager: this.actionManager,
|
||||
appState: this.state,
|
||||
container: this.excalidrawContainerRef.current!,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (!this.state.selectedElementIds[element.id]) {
|
||||
this.setState({ selectedElementIds: { [element.id]: true } });
|
||||
}
|
||||
|
||||
if (this.state.viewModeEnabled) {
|
||||
ContextMenu.push({
|
||||
options: [navigator.clipboard && actionCopy, ...options],
|
||||
top: clientY,
|
||||
left: clientX,
|
||||
top,
|
||||
left,
|
||||
actionManager: this.actionManager,
|
||||
appState: this.state,
|
||||
container: this.excalidrawContainerRef.current!,
|
||||
});
|
||||
return;
|
||||
}
|
||||
@@ -3848,13 +3971,17 @@ class App extends React.Component<ExcalidrawProps, AppState> {
|
||||
actionSendToBack,
|
||||
actionBringToFront,
|
||||
separator,
|
||||
maybeFlipHorizontal && actionFlipHorizontal,
|
||||
maybeFlipVertical && actionFlipVertical,
|
||||
(maybeFlipHorizontal || maybeFlipVertical) && separator,
|
||||
actionDuplicateSelection,
|
||||
actionDeleteSelected,
|
||||
],
|
||||
top: clientY,
|
||||
left: clientX,
|
||||
top,
|
||||
left,
|
||||
actionManager: this.actionManager,
|
||||
appState: this.state,
|
||||
container: this.excalidrawContainerRef.current!,
|
||||
});
|
||||
};
|
||||
|
||||
@@ -3988,33 +4115,64 @@ class App extends React.Component<ExcalidrawProps, AppState> {
|
||||
}
|
||||
}, 300);
|
||||
|
||||
private getCanvasOffsets(offsets?: {
|
||||
offsetLeft?: number;
|
||||
offsetTop?: number;
|
||||
}): Pick<AppState, "offsetTop" | "offsetLeft"> {
|
||||
if (
|
||||
typeof offsets?.offsetLeft === "number" &&
|
||||
typeof offsets?.offsetTop === "number"
|
||||
) {
|
||||
return {
|
||||
offsetLeft: offsets.offsetLeft,
|
||||
offsetTop: offsets.offsetTop,
|
||||
};
|
||||
private updateDOMRect = (cb?: () => void) => {
|
||||
if (this.excalidrawContainerRef?.current) {
|
||||
const excalidrawContainer = this.excalidrawContainerRef.current;
|
||||
const {
|
||||
width,
|
||||
height,
|
||||
left: offsetLeft,
|
||||
top: offsetTop,
|
||||
} = excalidrawContainer.getBoundingClientRect();
|
||||
const {
|
||||
width: currentWidth,
|
||||
height: currentHeight,
|
||||
offsetTop: currentOffsetTop,
|
||||
offsetLeft: currentOffsetLeft,
|
||||
} = this.state;
|
||||
|
||||
if (
|
||||
width === currentWidth &&
|
||||
height === currentHeight &&
|
||||
offsetLeft === currentOffsetLeft &&
|
||||
offsetTop === currentOffsetTop
|
||||
) {
|
||||
if (cb) {
|
||||
cb();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
this.setState(
|
||||
{
|
||||
width,
|
||||
height,
|
||||
offsetLeft,
|
||||
offsetTop,
|
||||
},
|
||||
() => {
|
||||
cb && cb();
|
||||
},
|
||||
);
|
||||
}
|
||||
if (this.excalidrawContainerRef?.current?.parentElement) {
|
||||
const parentElement = this.excalidrawContainerRef.current.parentElement;
|
||||
const { left, top } = parentElement.getBoundingClientRect();
|
||||
};
|
||||
|
||||
public refresh = () => {
|
||||
this.setState({ ...this.getCanvasOffsets() });
|
||||
};
|
||||
|
||||
private getCanvasOffsets(): Pick<AppState, "offsetTop" | "offsetLeft"> {
|
||||
if (this.excalidrawContainerRef?.current) {
|
||||
const excalidrawContainer = this.excalidrawContainerRef.current;
|
||||
const { left, top } = excalidrawContainer.getBoundingClientRect();
|
||||
return {
|
||||
offsetLeft:
|
||||
typeof offsets?.offsetLeft === "number" ? offsets.offsetLeft : left,
|
||||
offsetTop:
|
||||
typeof offsets?.offsetTop === "number" ? offsets.offsetTop : top,
|
||||
offsetLeft: left,
|
||||
offsetTop: top,
|
||||
};
|
||||
}
|
||||
return {
|
||||
offsetLeft:
|
||||
typeof offsets?.offsetLeft === "number" ? offsets.offsetLeft : 0,
|
||||
offsetTop: typeof offsets?.offsetTop === "number" ? offsets.offsetTop : 0,
|
||||
offsetLeft: 0,
|
||||
offsetTop: 0,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -4040,9 +4198,6 @@ declare global {
|
||||
history: SceneHistory;
|
||||
app: InstanceType<typeof App>;
|
||||
library: typeof Library;
|
||||
collab: InstanceType<
|
||||
typeof import("../excalidraw-app/collab/CollabWrapper").default
|
||||
>;
|
||||
};
|
||||
}
|
||||
}
|
||||
|
@@ -7,20 +7,24 @@ export const BackgroundPickerAndDarkModeToggle = ({
|
||||
appState,
|
||||
setAppState,
|
||||
actionManager,
|
||||
showThemeBtn,
|
||||
}: {
|
||||
actionManager: ActionManager;
|
||||
appState: AppState;
|
||||
setAppState: React.Component<any, AppState>["setState"];
|
||||
showThemeBtn: boolean;
|
||||
}) => (
|
||||
<div style={{ display: "flex" }}>
|
||||
{actionManager.renderAction("changeViewBackgroundColor")}
|
||||
<div style={{ marginInlineStart: "0.25rem" }}>
|
||||
<DarkModeToggle
|
||||
value={appState.appearance}
|
||||
onChange={(appearance) => {
|
||||
setAppState({ appearance });
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
{showThemeBtn && (
|
||||
<div style={{ marginInlineStart: "0.25rem" }}>
|
||||
<DarkModeToggle
|
||||
value={appState.theme}
|
||||
onChange={(theme) => {
|
||||
setAppState({ theme });
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
@@ -1,4 +1,3 @@
|
||||
import React from "react";
|
||||
import clsx from "clsx";
|
||||
|
||||
export const ButtonIconCycle = <T extends any>({
|
||||
@@ -14,11 +13,11 @@ export const ButtonIconCycle = <T extends any>({
|
||||
}) => {
|
||||
const current = options.find((op) => op.value === value);
|
||||
|
||||
function cycle() {
|
||||
const cycle = () => {
|
||||
const index = options.indexOf(current!);
|
||||
const next = (index + 1) % options.length;
|
||||
onChange(options[next].value);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<label key={group} className={clsx({ active: current!.value !== null })}>
|
||||
|
@@ -2,7 +2,7 @@ import React from "react";
|
||||
import clsx from "clsx";
|
||||
import { ToolButton } from "./ToolButton";
|
||||
import { t } from "../i18n";
|
||||
import useIsMobile from "../is-mobile";
|
||||
import { useIsMobile } from "../is-mobile";
|
||||
import { users } from "./icons";
|
||||
|
||||
import "./CollabButton.scss";
|
||||
|
@@ -73,7 +73,7 @@
|
||||
box-sizing: border-box;
|
||||
border: 1px solid #ddd;
|
||||
background-color: currentColor !important;
|
||||
filter: var(--appearance-filter);
|
||||
filter: var(--theme-filter);
|
||||
|
||||
&:focus {
|
||||
/* TODO: only show the border when the color is too light to see as a shadow */
|
||||
@@ -192,7 +192,7 @@
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
background-color: transparent !important;
|
||||
filter: var(--appearance-filter);
|
||||
filter: var(--theme-filter);
|
||||
|
||||
&:after {
|
||||
content: "";
|
||||
@@ -239,7 +239,7 @@
|
||||
color: #d4d4d4;
|
||||
}
|
||||
|
||||
&.Appearance_dark {
|
||||
&.theme--dark {
|
||||
.color-picker-type-elementBackground .color-picker-keybinding {
|
||||
color: $oc-black;
|
||||
}
|
||||
|
@@ -32,67 +32,63 @@ const ContextMenu = ({
|
||||
actionManager,
|
||||
appState,
|
||||
}: ContextMenuProps) => {
|
||||
const isDarkTheme = !!document
|
||||
.querySelector(".excalidraw")
|
||||
?.classList.contains("Appearance_dark");
|
||||
return (
|
||||
<div
|
||||
className={clsx("excalidraw", {
|
||||
"Appearance_dark Appearance_dark-background-none": isDarkTheme,
|
||||
})}
|
||||
<Popover
|
||||
onCloseRequest={onCloseRequest}
|
||||
top={top}
|
||||
left={left}
|
||||
fitInViewport={true}
|
||||
>
|
||||
<Popover
|
||||
onCloseRequest={onCloseRequest}
|
||||
top={top}
|
||||
left={left}
|
||||
fitInViewport={true}
|
||||
<ul
|
||||
className="context-menu"
|
||||
onContextMenu={(event) => event.preventDefault()}
|
||||
>
|
||||
<ul
|
||||
className="context-menu"
|
||||
onContextMenu={(event) => event.preventDefault()}
|
||||
>
|
||||
{options.map((option, idx) => {
|
||||
if (option === "separator") {
|
||||
return <hr key={idx} className="context-menu-option-separator" />;
|
||||
}
|
||||
{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>
|
||||
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>
|
||||
);
|
||||
};
|
||||
|
||||
let contextMenuNode: HTMLDivElement;
|
||||
const getContextMenuNode = (): HTMLDivElement => {
|
||||
const contextMenuNodeByContainer = new WeakMap<HTMLElement, HTMLDivElement>();
|
||||
|
||||
const getContextMenuNode = (container: HTMLElement): HTMLDivElement => {
|
||||
let contextMenuNode = contextMenuNodeByContainer.get(container);
|
||||
if (contextMenuNode) {
|
||||
return contextMenuNode;
|
||||
}
|
||||
const div = document.createElement("div");
|
||||
document.body.appendChild(div);
|
||||
return (contextMenuNode = div);
|
||||
contextMenuNode = document.createElement("div");
|
||||
container
|
||||
.querySelector(".excalidraw-contextMenuContainer")!
|
||||
.appendChild(contextMenuNode);
|
||||
contextMenuNodeByContainer.set(container, contextMenuNode);
|
||||
return contextMenuNode;
|
||||
};
|
||||
|
||||
type ContextMenuParams = {
|
||||
@@ -101,10 +97,16 @@ type ContextMenuParams = {
|
||||
left: ContextMenuProps["left"];
|
||||
actionManager: ContextMenuProps["actionManager"];
|
||||
appState: Readonly<AppState>;
|
||||
container: HTMLElement;
|
||||
};
|
||||
|
||||
const handleClose = () => {
|
||||
unmountComponentAtNode(getContextMenuNode());
|
||||
const handleClose = (container: HTMLElement) => {
|
||||
const contextMenuNode = contextMenuNodeByContainer.get(container);
|
||||
if (contextMenuNode) {
|
||||
unmountComponentAtNode(contextMenuNode);
|
||||
contextMenuNode.remove();
|
||||
contextMenuNodeByContainer.delete(container);
|
||||
}
|
||||
};
|
||||
|
||||
export default {
|
||||
@@ -121,11 +123,11 @@ export default {
|
||||
top={params.top}
|
||||
left={params.left}
|
||||
options={options}
|
||||
onCloseRequest={handleClose}
|
||||
onCloseRequest={() => handleClose(params.container)}
|
||||
actionManager={params.actionManager}
|
||||
appState={params.appState}
|
||||
/>,
|
||||
getContextMenuNode(),
|
||||
getContextMenuNode(params.container),
|
||||
);
|
||||
}
|
||||
},
|
||||
|
@@ -20,7 +20,8 @@ export const DarkModeToggle = (props: {
|
||||
|
||||
return (
|
||||
<label
|
||||
className={`ToolIcon ToolIcon_type_floating ToolIcon_size_M`}
|
||||
className="ToolIcon ToolIcon_type_floating ToolIcon_size_M"
|
||||
data-testid="toggle-dark-mode"
|
||||
title={title}
|
||||
>
|
||||
<input
|
||||
|
@@ -2,7 +2,7 @@ import clsx from "clsx";
|
||||
import React, { useEffect } from "react";
|
||||
import { useCallbackRefState } from "../hooks/useCallbackRefState";
|
||||
import { t } from "../i18n";
|
||||
import useIsMobile from "../is-mobile";
|
||||
import { useIsMobile } from "../is-mobile";
|
||||
import { KEYS } from "../keys";
|
||||
import "./Dialog.scss";
|
||||
import { back, close } from "./icons";
|
||||
|
@@ -28,14 +28,7 @@ export const ErrorDialog = ({
|
||||
onCloseRequest={handleClose}
|
||||
title={t("errorDialog.title")}
|
||||
>
|
||||
<div>
|
||||
{message.split("\n").map((line) => (
|
||||
<>
|
||||
{line}
|
||||
<br />
|
||||
</>
|
||||
))}
|
||||
</div>
|
||||
<div style={{ whiteSpace: "pre-wrap" }}>{message}</div>
|
||||
</Dialog>
|
||||
)}
|
||||
</>
|
||||
|
@@ -16,7 +16,7 @@
|
||||
max-height: 25rem;
|
||||
}
|
||||
|
||||
&.Appearance_dark .ExportDialog__preview canvas {
|
||||
&.theme--dark .ExportDialog__preview canvas {
|
||||
filter: none;
|
||||
}
|
||||
|
||||
@@ -31,9 +31,27 @@
|
||||
.ExportDialog__name {
|
||||
grid-column: project-name;
|
||||
margin: auto;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
.TextInput {
|
||||
height: calc(1rem - 3px);
|
||||
width: 200px;
|
||||
overflow: hidden;
|
||||
text-align: center;
|
||||
margin-left: 8px;
|
||||
text-overflow: ellipsis;
|
||||
|
||||
&--readonly {
|
||||
background: none;
|
||||
border: none;
|
||||
&:hover {
|
||||
background: none;
|
||||
}
|
||||
width: auto;
|
||||
max-width: 200px;
|
||||
padding-left: 2px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
@@ -6,7 +6,7 @@ import { canvasToBlob } from "../data/blob";
|
||||
import { NonDeletedExcalidrawElement } from "../element/types";
|
||||
import { CanvasError } from "../errors";
|
||||
import { t } from "../i18n";
|
||||
import useIsMobile from "../is-mobile";
|
||||
import { useIsMobile } from "../is-mobile";
|
||||
import { getSelectedElements, isSomeElementSelected } from "../scene";
|
||||
import { exportToCanvas, getExportSize } from "../scene/export";
|
||||
import { AppState } from "../types";
|
||||
@@ -202,6 +202,7 @@ const ExportModal = ({
|
||||
})}
|
||||
</Stack.Row>
|
||||
</div>
|
||||
{actionManager.renderAction("toggleAutosave")}
|
||||
{actionManager.renderAction("changeExportBackground")}
|
||||
{someElementIsSelected && (
|
||||
<div>
|
||||
@@ -257,6 +258,7 @@ export const ExportDialog = ({
|
||||
onClick={() => {
|
||||
setModalIsShown(true);
|
||||
}}
|
||||
data-testid="export-button"
|
||||
icon={exportFile}
|
||||
type="button"
|
||||
aria-label={t("buttons.export")}
|
||||
|
@@ -3,7 +3,7 @@ import React from "react";
|
||||
|
||||
// https://github.com/tholman/github-corners
|
||||
export const GitHubCorner = React.memo(
|
||||
({ appearance }: { appearance: "light" | "dark" }) => (
|
||||
({ theme }: { theme: "light" | "dark" }) => (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="40"
|
||||
@@ -19,18 +19,18 @@ export const GitHubCorner = React.memo(
|
||||
>
|
||||
<path
|
||||
d="M0 0l115 115h15l12 27 108 108V0z"
|
||||
fill={appearance === "light" ? oc.gray[6] : oc.gray[8]}
|
||||
fill={theme === "light" ? oc.gray[6] : oc.gray[8]}
|
||||
/>
|
||||
<path
|
||||
className="octo-arm"
|
||||
d="M128 109c-15-9-9-19-9-19 3-7 2-11 2-11-1-7 3-2 3-2 4 5 2 11 2 11-3 10 5 15 9 16"
|
||||
style={{ transformOrigin: "130px 106px" }}
|
||||
fill={appearance === "light" ? oc.white : oc.black}
|
||||
fill={theme === "light" ? oc.white : oc.black}
|
||||
/>
|
||||
<path
|
||||
className="octo-body"
|
||||
d="M115 115s4 2 5 0l14-14c3-2 6-3 8-3-8-11-15-24 2-41 5-5 10-7 16-7 1-2 3-7 12-11 0 0 5 3 7 16 4 2 8 5 12 9s7 8 9 12c14 3 17 7 17 7-4 8-9 11-11 11 0 6-2 11-7 16-16 16-30 10-41 2 0 3-1 7-5 11l-12 11c-1 1 1 5 1 5z"
|
||||
fill={appearance === "light" ? oc.white : oc.black}
|
||||
fill={theme === "light" ? oc.white : oc.black}
|
||||
/>
|
||||
</a>
|
||||
</svg>
|
||||
|
@@ -349,6 +349,14 @@ export const HelpDialog = ({ onClose }: { onClose?: () => void }) => {
|
||||
label={t("labels.ungroup")}
|
||||
shortcuts={[getShortcutKey("CtrlOrCmd+Shift+G")]}
|
||||
/>
|
||||
<Shortcut
|
||||
label={t("labels.flipHorizontal")}
|
||||
shortcuts={[getShortcutKey("Shift+H")]}
|
||||
/>
|
||||
<Shortcut
|
||||
label={t("labels.flipVertical")}
|
||||
shortcuts={[getShortcutKey("Shift+V")]}
|
||||
/>
|
||||
</ShortcutIsland>
|
||||
</Column>
|
||||
</Columns>
|
||||
|
@@ -9,7 +9,13 @@ type HelpIconProps = {
|
||||
};
|
||||
|
||||
export const HelpIcon = (props: HelpIconProps) => (
|
||||
<label title={`${props.title} — ?`} className="help-icon">
|
||||
<div onClick={props.onClick}>{questionCircle}</div>
|
||||
</label>
|
||||
<button
|
||||
className="help-icon"
|
||||
onClick={props.onClick}
|
||||
type="button"
|
||||
title={`${props.title} — ?`}
|
||||
aria-label={props.title}
|
||||
>
|
||||
{questionCircle}
|
||||
</button>
|
||||
);
|
||||
|
@@ -132,7 +132,7 @@
|
||||
color: #d4d4d4;
|
||||
}
|
||||
|
||||
&.Appearance_dark {
|
||||
&.theme--dark {
|
||||
.picker-type-elementBackground .picker-keybinding {
|
||||
color: $oc-black;
|
||||
}
|
||||
|
@@ -14,10 +14,16 @@ import { Library } from "../data/library";
|
||||
import { isTextElement, showSelectedShapeActions } from "../element";
|
||||
import { NonDeletedExcalidrawElement } from "../element/types";
|
||||
import { Language, t } from "../i18n";
|
||||
import useIsMobile from "../is-mobile";
|
||||
import { useIsMobile } from "../is-mobile";
|
||||
import { calculateScrollCenter, getSelectedElements } from "../scene";
|
||||
import { ExportType } from "../scene/types";
|
||||
import { AppState, LibraryItem, LibraryItems } from "../types";
|
||||
import {
|
||||
AppProps,
|
||||
AppState,
|
||||
ExcalidrawProps,
|
||||
LibraryItem,
|
||||
LibraryItems,
|
||||
} from "../types";
|
||||
import { muteFSAbortError } from "../utils";
|
||||
import { SelectedShapeActions, ShapesSwitcher, ZoomActions } from "./Actions";
|
||||
import { BackgroundPickerAndDarkModeToggle } from "./BackgroundPickerAndDarkModeToggle";
|
||||
@@ -53,6 +59,7 @@ interface LayerUIProps {
|
||||
onInsertElements: (elements: readonly NonDeletedExcalidrawElement[]) => void;
|
||||
zenModeEnabled: boolean;
|
||||
showExitZenModeBtn: boolean;
|
||||
showThemeBtn: boolean;
|
||||
toggleZenMode: () => void;
|
||||
langCode: Language["code"];
|
||||
isCollaborating: boolean;
|
||||
@@ -63,6 +70,8 @@ interface LayerUIProps {
|
||||
) => void;
|
||||
renderCustomFooter?: (isMobile: boolean) => JSX.Element;
|
||||
viewModeEnabled: boolean;
|
||||
libraryReturnUrl: ExcalidrawProps["libraryReturnUrl"];
|
||||
UIOptions: AppProps["UIOptions"];
|
||||
}
|
||||
|
||||
const useOnClickOutside = (
|
||||
@@ -101,6 +110,7 @@ const LibraryMenuItems = ({
|
||||
pendingElements,
|
||||
setAppState,
|
||||
setLibraryItems,
|
||||
libraryReturnUrl,
|
||||
}: {
|
||||
library: LibraryItems;
|
||||
pendingElements: LibraryItem;
|
||||
@@ -109,6 +119,7 @@ const LibraryMenuItems = ({
|
||||
onAddToLibrary: (elements: LibraryItem) => void;
|
||||
setAppState: React.Component<any, AppState>["setState"];
|
||||
setLibraryItems: (library: LibraryItems) => void;
|
||||
libraryReturnUrl: ExcalidrawProps["libraryReturnUrl"];
|
||||
}) => {
|
||||
const isMobile = useIsMobile();
|
||||
const numCells = library.length + (pendingElements.length > 0 ? 1 : 0);
|
||||
@@ -117,8 +128,11 @@ const LibraryMenuItems = ({
|
||||
const rows = [];
|
||||
let addedPendingElements = false;
|
||||
|
||||
const referrer =
|
||||
libraryReturnUrl || window.location.origin + window.location.pathname;
|
||||
|
||||
rows.push(
|
||||
<div className="layer-ui__library-header">
|
||||
<div className="layer-ui__library-header" key="library-header">
|
||||
<ToolButton
|
||||
key="import"
|
||||
type="button"
|
||||
@@ -128,9 +142,9 @@ const LibraryMenuItems = ({
|
||||
onClick={() => {
|
||||
importLibraryFromJSON()
|
||||
.then(() => {
|
||||
// Maybe we should close and open the menu so that the items get updated.
|
||||
// But for now we just close the menu.
|
||||
// Close and then open to get the libraries updated
|
||||
setAppState({ isLibraryOpen: false });
|
||||
setAppState({ isLibraryOpen: true });
|
||||
})
|
||||
.catch(muteFSAbortError)
|
||||
.catch((error) => {
|
||||
@@ -138,35 +152,43 @@ const LibraryMenuItems = ({
|
||||
});
|
||||
}}
|
||||
/>
|
||||
<ToolButton
|
||||
key="export"
|
||||
type="button"
|
||||
title={t("buttons.export")}
|
||||
aria-label={t("buttons.export")}
|
||||
icon={exportFile}
|
||||
onClick={() => {
|
||||
saveLibraryAsJSON()
|
||||
.catch(muteFSAbortError)
|
||||
.catch((error) => {
|
||||
setAppState({ errorMessage: error.message });
|
||||
});
|
||||
}}
|
||||
/>
|
||||
<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">
|
||||
{!!library.length && (
|
||||
<>
|
||||
<ToolButton
|
||||
key="export"
|
||||
type="button"
|
||||
title={t("buttons.export")}
|
||||
aria-label={t("buttons.export")}
|
||||
icon={exportFile}
|
||||
onClick={() => {
|
||||
saveLibraryAsJSON()
|
||||
.catch(muteFSAbortError)
|
||||
.catch((error) => {
|
||||
setAppState({ errorMessage: error.message });
|
||||
});
|
||||
}}
|
||||
/>
|
||||
<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=${
|
||||
window.name || "_blank"
|
||||
}&referrer=${referrer}&useHash=true&token=${Library.csrfToken}`}
|
||||
target="_excalidraw_libraries"
|
||||
>
|
||||
{t("labels.libraries")}
|
||||
</a>
|
||||
</div>,
|
||||
@@ -219,12 +241,14 @@ const LibraryMenu = ({
|
||||
pendingElements,
|
||||
onAddToLibrary,
|
||||
setAppState,
|
||||
libraryReturnUrl,
|
||||
}: {
|
||||
pendingElements: LibraryItem;
|
||||
onClickOutside: (event: MouseEvent) => void;
|
||||
onInsertShape: (elements: LibraryItem) => void;
|
||||
onAddToLibrary: () => void;
|
||||
setAppState: React.Component<any, AppState>["setState"];
|
||||
libraryReturnUrl: ExcalidrawProps["libraryReturnUrl"];
|
||||
}) => {
|
||||
const ref = useRef<HTMLDivElement | null>(null);
|
||||
useOnClickOutside(ref, (event) => {
|
||||
@@ -297,6 +321,7 @@ const LibraryMenu = ({
|
||||
pendingElements={pendingElements}
|
||||
setAppState={setAppState}
|
||||
setLibraryItems={setLibraryItems}
|
||||
libraryReturnUrl={libraryReturnUrl}
|
||||
/>
|
||||
)}
|
||||
</Island>
|
||||
@@ -314,11 +339,14 @@ const LayerUI = ({
|
||||
onInsertElements,
|
||||
zenModeEnabled,
|
||||
showExitZenModeBtn,
|
||||
showThemeBtn,
|
||||
toggleZenMode,
|
||||
isCollaborating,
|
||||
onExportToBackend,
|
||||
renderCustomFooter,
|
||||
viewModeEnabled,
|
||||
libraryReturnUrl,
|
||||
UIOptions,
|
||||
}: LayerUIProps) => {
|
||||
const isMobile = useIsMobile();
|
||||
|
||||
@@ -330,6 +358,7 @@ const LayerUI = ({
|
||||
href="https://blog.excalidraw.com/end-to-end-encryption/"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
aria-label={t("encrypted.link")}
|
||||
>
|
||||
<Tooltip label={t("encrypted.tooltip")} position="above" long={true}>
|
||||
{shield}
|
||||
@@ -338,6 +367,10 @@ const LayerUI = ({
|
||||
);
|
||||
|
||||
const renderExportDialog = () => {
|
||||
if (!UIOptions.canvasActions.export) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const createExporter = (type: ExportType): ExportCB => async (
|
||||
exportedElements,
|
||||
scale,
|
||||
@@ -429,6 +462,7 @@ const LayerUI = ({
|
||||
actionManager={actionManager}
|
||||
appState={appState}
|
||||
setAppState={setAppState}
|
||||
showThemeBtn={showThemeBtn}
|
||||
/>
|
||||
</Stack.Col>
|
||||
</Island>
|
||||
@@ -482,6 +516,7 @@ const LayerUI = ({
|
||||
onInsertShape={onInsertElements}
|
||||
onAddToLibrary={deselectItems}
|
||||
setAppState={setAppState}
|
||||
libraryReturnUrl={libraryReturnUrl}
|
||||
/>
|
||||
) : null;
|
||||
|
||||
@@ -590,7 +625,7 @@ const LayerUI = ({
|
||||
},
|
||||
)}
|
||||
>
|
||||
<GitHubCorner appearance={appState.appearance} />
|
||||
<GitHubCorner theme={appState.theme} />
|
||||
</aside>
|
||||
);
|
||||
};
|
||||
@@ -658,6 +693,7 @@ const LayerUI = ({
|
||||
isCollaborating={isCollaborating}
|
||||
renderCustomFooter={renderCustomFooter}
|
||||
viewModeEnabled={viewModeEnabled}
|
||||
showThemeBtn={showThemeBtn}
|
||||
/>
|
||||
</>
|
||||
) : (
|
||||
|
@@ -16,7 +16,7 @@
|
||||
}
|
||||
|
||||
.library-unit__dragger > svg {
|
||||
filter: var(--appearance-filter);
|
||||
filter: var(--theme-filter);
|
||||
flex-grow: 1;
|
||||
max-height: 100%;
|
||||
max-width: 100%;
|
||||
|
@@ -4,7 +4,7 @@ import React, { useEffect, useRef, useState } from "react";
|
||||
import { close } from "../components/icons";
|
||||
import { MIME_TYPES } from "../constants";
|
||||
import { t } from "../i18n";
|
||||
import useIsMobile from "../is-mobile";
|
||||
import { useIsMobile } from "../is-mobile";
|
||||
import { exportToSvg } from "../scene/export";
|
||||
import { LibraryItem } from "../types";
|
||||
import "./LibraryUnit.scss";
|
||||
|
@@ -30,6 +30,7 @@ type MobileMenuProps = {
|
||||
isCollaborating: boolean;
|
||||
renderCustomFooter?: (isMobile: boolean) => JSX.Element;
|
||||
viewModeEnabled: boolean;
|
||||
showThemeBtn: boolean;
|
||||
};
|
||||
|
||||
export const MobileMenu = ({
|
||||
@@ -45,6 +46,7 @@ export const MobileMenu = ({
|
||||
isCollaborating,
|
||||
renderCustomFooter,
|
||||
viewModeEnabled,
|
||||
showThemeBtn,
|
||||
}: MobileMenuProps) => {
|
||||
const renderToolbar = () => {
|
||||
return (
|
||||
@@ -130,6 +132,7 @@ export const MobileMenu = ({
|
||||
actionManager={actionManager}
|
||||
appState={appState}
|
||||
setAppState={setAppState}
|
||||
showThemeBtn={showThemeBtn}
|
||||
/>
|
||||
}
|
||||
</>
|
||||
|
@@ -50,6 +50,7 @@
|
||||
border: 1px solid var(--dialog-border-color);
|
||||
box-shadow: 0 2px 10px transparentize($oc-black, 0.75);
|
||||
border-radius: 6px;
|
||||
box-sizing: border-box;
|
||||
|
||||
@media #{$is-mobile-query} {
|
||||
max-width: 100%;
|
||||
|
@@ -51,14 +51,14 @@ const useBodyRoot = () => {
|
||||
useLayoutEffect(() => {
|
||||
const isDarkTheme = !!document
|
||||
.querySelector(".excalidraw")
|
||||
?.classList.contains("Appearance_dark");
|
||||
?.classList.contains("theme--dark");
|
||||
const div = document.createElement("div");
|
||||
|
||||
div.classList.add("excalidraw", "excalidraw-modal-container");
|
||||
|
||||
if (isDarkTheme) {
|
||||
div.classList.add("Appearance_dark");
|
||||
div.classList.add("Appearance_dark-background-none");
|
||||
div.classList.add("theme--dark");
|
||||
div.classList.add("theme--dark-background-none");
|
||||
}
|
||||
document.body.appendChild(div);
|
||||
|
||||
|
@@ -1,6 +1,6 @@
|
||||
.excalidraw {
|
||||
.popover {
|
||||
position: fixed;
|
||||
position: absolute;
|
||||
z-index: 10;
|
||||
}
|
||||
}
|
||||
|
@@ -1,25 +1,26 @@
|
||||
import "./TextInput.scss";
|
||||
|
||||
import React, { Component } from "react";
|
||||
import { selectNode, removeSelection } from "../utils";
|
||||
|
||||
type Props = {
|
||||
value: string;
|
||||
onChange: (value: string) => void;
|
||||
label: string;
|
||||
isNameEditable: boolean;
|
||||
};
|
||||
|
||||
export class ProjectName extends Component<Props> {
|
||||
private handleFocus = (event: React.FocusEvent<HTMLElement>) => {
|
||||
selectNode(event.currentTarget);
|
||||
type State = {
|
||||
fileName: string;
|
||||
};
|
||||
export class ProjectName extends Component<Props, State> {
|
||||
state = {
|
||||
fileName: this.props.value,
|
||||
};
|
||||
|
||||
private handleBlur = (event: React.FocusEvent<HTMLElement>) => {
|
||||
const value = event.currentTarget.innerText.trim();
|
||||
private handleBlur = (event: any) => {
|
||||
const value = event.target.value;
|
||||
if (value !== this.props.value) {
|
||||
this.props.onChange(value);
|
||||
}
|
||||
removeSelection();
|
||||
};
|
||||
|
||||
private handleKeyDown = (event: React.KeyboardEvent<HTMLElement>) => {
|
||||
@@ -31,32 +32,30 @@ export class ProjectName extends Component<Props> {
|
||||
event.currentTarget.blur();
|
||||
}
|
||||
};
|
||||
private makeEditable = (editable: HTMLSpanElement | null) => {
|
||||
if (!editable) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
editable.contentEditable = "plaintext-only";
|
||||
} catch {
|
||||
editable.contentEditable = "true";
|
||||
}
|
||||
};
|
||||
|
||||
public render() {
|
||||
return (
|
||||
<span
|
||||
suppressContentEditableWarning
|
||||
ref={this.makeEditable}
|
||||
data-type="wysiwyg"
|
||||
className="TextInput"
|
||||
role="textbox"
|
||||
aria-label={this.props.label}
|
||||
onBlur={this.handleBlur}
|
||||
onKeyDown={this.handleKeyDown}
|
||||
onFocus={this.handleFocus}
|
||||
>
|
||||
{this.props.value}
|
||||
</span>
|
||||
<>
|
||||
<label htmlFor="file-name">
|
||||
{`${this.props.label}${this.props.isNameEditable ? "" : ":"}`}
|
||||
</label>
|
||||
{this.props.isNameEditable ? (
|
||||
<input
|
||||
className="TextInput"
|
||||
onBlur={this.handleBlur}
|
||||
onKeyDown={this.handleKeyDown}
|
||||
id="file-name"
|
||||
value={this.state.fileName}
|
||||
onChange={(event) =>
|
||||
this.setState({ fileName: event.target.value })
|
||||
}
|
||||
/>
|
||||
) : (
|
||||
<span className="TextInput TextInput--readonly" id="file-name">
|
||||
{this.props.value}
|
||||
</span>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
@@ -1,49 +1,22 @@
|
||||
import React, { useEffect, useState } from "react";
|
||||
import { copyTextToSystemClipboard } from "../clipboard";
|
||||
import { DEFAULT_VERSION } from "../constants";
|
||||
import React from "react";
|
||||
import { getCommonBounds } from "../element/bounds";
|
||||
import { NonDeletedExcalidrawElement } from "../element/types";
|
||||
import {
|
||||
getElementsStorageSize,
|
||||
getTotalStorageSize,
|
||||
} from "../excalidraw-app/data/localStorage";
|
||||
import { t } from "../i18n";
|
||||
import useIsMobile from "../is-mobile";
|
||||
import { useIsMobile } from "../is-mobile";
|
||||
import { getTargetElements } from "../scene";
|
||||
import { AppState } from "../types";
|
||||
import { debounce, getVersion, nFormatter } from "../utils";
|
||||
import { AppState, ExcalidrawProps } from "../types";
|
||||
import { close } from "./icons";
|
||||
import { Island } from "./Island";
|
||||
import "./Stats.scss";
|
||||
|
||||
type StorageSizes = { scene: number; total: number };
|
||||
|
||||
const getStorageSizes = debounce((cb: (sizes: StorageSizes) => void) => {
|
||||
cb({
|
||||
scene: getElementsStorageSize(),
|
||||
total: getTotalStorageSize(),
|
||||
});
|
||||
}, 500);
|
||||
|
||||
export const Stats = (props: {
|
||||
appState: AppState;
|
||||
setAppState: React.Component<any, AppState>["setState"];
|
||||
elements: readonly NonDeletedExcalidrawElement[];
|
||||
onClose: () => void;
|
||||
renderCustomStats: ExcalidrawProps["renderCustomStats"];
|
||||
}) => {
|
||||
const isMobile = useIsMobile();
|
||||
const [storageSizes, setStorageSizes] = useState<StorageSizes>({
|
||||
scene: 0,
|
||||
total: 0,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
getStorageSizes((sizes) => {
|
||||
setStorageSizes(sizes);
|
||||
});
|
||||
});
|
||||
|
||||
useEffect(() => () => getStorageSizes.cancel(), []);
|
||||
|
||||
const boundingBox = getCommonBounds(props.elements);
|
||||
const selectedElements = getTargetElements(props.elements, props.appState);
|
||||
@@ -53,17 +26,6 @@ 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}>
|
||||
@@ -88,17 +50,7 @@ export const Stats = (props: {
|
||||
<td>{t("stats.height")}</td>
|
||||
<td>{Math.round(boundingBox[3]) - Math.round(boundingBox[1])}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th colSpan={2}>{t("stats.storage")}</th>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>{t("stats.scene")}</td>
|
||||
<td>{nFormatter(storageSizes.scene, 1)}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>{t("stats.total")}</td>
|
||||
<td>{nFormatter(storageSizes.total, 1)}</td>
|
||||
</tr>
|
||||
|
||||
{selectedElements.length === 1 && (
|
||||
<tr>
|
||||
<th colSpan={2}>{t("stats.element")}</th>
|
||||
@@ -120,31 +72,17 @@ export const Stats = (props: {
|
||||
<>
|
||||
<tr>
|
||||
<td>{"x"}</td>
|
||||
<td>
|
||||
{Math.round(
|
||||
selectedElements.length === 1
|
||||
? selectedElements[0].x
|
||||
: selectedBoundingBox[0],
|
||||
)}
|
||||
</td>
|
||||
<td>{Math.round(selectedBoundingBox[0])}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>{"y"}</td>
|
||||
<td>
|
||||
{Math.round(
|
||||
selectedElements.length === 1
|
||||
? selectedElements[0].y
|
||||
: selectedBoundingBox[1],
|
||||
)}
|
||||
</td>
|
||||
<td>{Math.round(selectedBoundingBox[1])}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>{t("stats.width")}</td>
|
||||
<td>
|
||||
{Math.round(
|
||||
selectedElements.length === 1
|
||||
? selectedElements[0].width
|
||||
: selectedBoundingBox[2] - selectedBoundingBox[0],
|
||||
selectedBoundingBox[2] - selectedBoundingBox[0],
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
@@ -152,9 +90,7 @@ export const Stats = (props: {
|
||||
<td>{t("stats.height")}</td>
|
||||
<td>
|
||||
{Math.round(
|
||||
selectedElements.length === 1
|
||||
? selectedElements[0].height
|
||||
: selectedBoundingBox[3] - selectedBoundingBox[1],
|
||||
selectedBoundingBox[3] - selectedBoundingBox[1],
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
@@ -170,28 +106,7 @@ 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>
|
||||
{props.renderCustomStats?.(props.elements, props.appState)}
|
||||
</tbody>
|
||||
</table>
|
||||
</Island>
|
||||
|
@@ -10,7 +10,7 @@
|
||||
cursor: default;
|
||||
left: 50%;
|
||||
margin-left: -150px;
|
||||
padding: 4px 0;
|
||||
padding: 8px;
|
||||
position: absolute;
|
||||
text-align: center;
|
||||
width: 300px;
|
||||
|
@@ -58,6 +58,7 @@ export const ToolButton = React.forwardRef((props: ToolButtonProps, ref) => {
|
||||
"ToolIcon--selected": props.selected,
|
||||
},
|
||||
)}
|
||||
data-testid={props["data-testid"]}
|
||||
hidden={props.hidden}
|
||||
title={props.title}
|
||||
aria-label={props["aria-label"]}
|
||||
|
@@ -11,12 +11,12 @@ import React from "react";
|
||||
import oc from "open-color";
|
||||
import clsx from "clsx";
|
||||
|
||||
const activeElementColor = (appearance: "light" | "dark") =>
|
||||
appearance === "light" ? oc.orange[4] : oc.orange[9];
|
||||
const iconFillColor = (appearance: "light" | "dark") =>
|
||||
appearance === "light" ? oc.black : oc.gray[4];
|
||||
const handlerColor = (appearance: "light" | "dark") =>
|
||||
appearance === "light" ? oc.white : "#1e1e1e";
|
||||
const activeElementColor = (theme: "light" | "dark") =>
|
||||
theme === "light" ? oc.orange[4] : oc.orange[9];
|
||||
const iconFillColor = (theme: "light" | "dark") =>
|
||||
theme === "light" ? oc.black : oc.gray[4];
|
||||
const handlerColor = (theme: "light" | "dark") =>
|
||||
theme === "light" ? oc.white : "#1e1e1e";
|
||||
|
||||
type Opts = {
|
||||
width?: number;
|
||||
@@ -123,6 +123,22 @@ export const shareIOS = createIcon(
|
||||
{ width: 24, height: 24 },
|
||||
);
|
||||
|
||||
export const shareWindows = createIcon(
|
||||
<>
|
||||
<path
|
||||
stroke="currentColor"
|
||||
fill="currentColor"
|
||||
d="M40 5.6v6.1l-4.1.7c-8.9 1.4-16.5 6.9-20.6 15C13 32 10.9 43 12.4 43c.4 0 2.4-1.3 4.4-3 5-3.9 12.1-7 18.2-7.7l5-.6v12.8l11.2-11.3L62.5 22 51.2 10.8 40-.5v6.1zm10.2 22.6L44 34.5v-6.8l-6.9.6c-3.9.3-9.8 1.7-13.2 3.1-3.5 1.4-6.5 2.4-6.7 2.2-.9-1 3-7.5 6.4-10.8C28 18.6 34.4 16 40.1 16c3.7 0 3.9-.1 3.9-3.2V9.5l6.2 6.3 6.3 6.2-6.3 6.2z"
|
||||
/>
|
||||
<path
|
||||
stroke="currentColor"
|
||||
fill="currentColor"
|
||||
d="M0 36v20h48v-6.2c0-6 0-6.1-2-4.3-1.1 1-2 2.9-2 4.2V52H4V34c0-17.3-.1-18-2-18s-2 .7-2 20z"
|
||||
/>
|
||||
</>,
|
||||
{ width: 64, height: 64 },
|
||||
);
|
||||
|
||||
// Icon imported form Storybook
|
||||
// Storybook is licensed under MIT https://github.com/storybookjs/storybook/blob/next/LICENSE
|
||||
export const resetZoom = createIcon(
|
||||
@@ -136,19 +152,19 @@ export const resetZoom = createIcon(
|
||||
);
|
||||
|
||||
export const BringForwardIcon = React.memo(
|
||||
({ appearance }: { appearance: "light" | "dark" }) =>
|
||||
({ theme }: { theme: "light" | "dark" }) =>
|
||||
createIcon(
|
||||
<>
|
||||
<path
|
||||
d="M22 9.556C22 8.696 21.303 8 20.444 8H16v8H8v4.444C8 21.304 8.697 22 9.556 22h10.888c.86 0 1.556-.697 1.556-1.556V9.556z"
|
||||
fill={iconFillColor(appearance)}
|
||||
stroke={iconFillColor(appearance)}
|
||||
fill={iconFillColor(theme)}
|
||||
stroke={iconFillColor(theme)}
|
||||
strokeWidth="2"
|
||||
/>
|
||||
<path
|
||||
d="M16 3.556C16 2.696 15.303 2 14.444 2H3.556C2.696 2 2 2.697 2 3.556v10.888C2 15.304 2.697 16 3.556 16h10.888c.86 0 1.556-.697 1.556-1.556V3.556z"
|
||||
fill={activeElementColor(appearance)}
|
||||
stroke={activeElementColor(appearance)}
|
||||
fill={activeElementColor(theme)}
|
||||
stroke={activeElementColor(theme)}
|
||||
strokeWidth="2"
|
||||
/>
|
||||
</>,
|
||||
@@ -157,19 +173,19 @@ export const BringForwardIcon = React.memo(
|
||||
);
|
||||
|
||||
export const SendBackwardIcon = React.memo(
|
||||
({ appearance }: { appearance: "light" | "dark" }) =>
|
||||
({ theme }: { theme: "light" | "dark" }) =>
|
||||
createIcon(
|
||||
<>
|
||||
<path
|
||||
d="M16 3.556C16 2.696 15.303 2 14.444 2H3.556C2.696 2 2 2.697 2 3.556v10.888C2 15.304 2.697 16 3.556 16h10.888c.86 0 1.556-.697 1.556-1.556V3.556z"
|
||||
fill={activeElementColor(appearance)}
|
||||
stroke={activeElementColor(appearance)}
|
||||
fill={activeElementColor(theme)}
|
||||
stroke={activeElementColor(theme)}
|
||||
strokeWidth="2"
|
||||
/>
|
||||
<path
|
||||
d="M22 9.556C22 8.696 21.303 8 20.444 8H9.556C8.696 8 8 8.697 8 9.556v10.888C8 21.304 8.697 22 9.556 22h10.888c.86 0 1.556-.697 1.556-1.556V9.556z"
|
||||
fill={iconFillColor(appearance)}
|
||||
stroke={iconFillColor(appearance)}
|
||||
fill={iconFillColor(theme)}
|
||||
stroke={iconFillColor(theme)}
|
||||
strokeWidth="2"
|
||||
/>
|
||||
</>,
|
||||
@@ -178,19 +194,19 @@ export const SendBackwardIcon = React.memo(
|
||||
);
|
||||
|
||||
export const BringToFrontIcon = React.memo(
|
||||
({ appearance }: { appearance: "light" | "dark" }) =>
|
||||
({ theme }: { theme: "light" | "dark" }) =>
|
||||
createIcon(
|
||||
<>
|
||||
<path
|
||||
d="M13 21a1 1 0 001 1h7a1 1 0 001-1v-7a1 1 0 00-1-1h-3v5h-5v3zM11 3a1 1 0 00-1-1H3a1 1 0 00-1 1v7a1 1 0 001 1h3V6h5V3z"
|
||||
fill={iconFillColor(appearance)}
|
||||
stroke={iconFillColor(appearance)}
|
||||
fill={iconFillColor(theme)}
|
||||
stroke={iconFillColor(theme)}
|
||||
strokeWidth="2"
|
||||
/>
|
||||
<path
|
||||
d="M18 7.333C18 6.597 17.403 6 16.667 6H7.333C6.597 6 6 6.597 6 7.333v9.334C6 17.403 6.597 18 7.333 18h9.334c.736 0 1.333-.597 1.333-1.333V7.333z"
|
||||
fill={activeElementColor(appearance)}
|
||||
stroke={activeElementColor(appearance)}
|
||||
fill={activeElementColor(theme)}
|
||||
stroke={activeElementColor(theme)}
|
||||
strokeWidth="2"
|
||||
/>
|
||||
</>,
|
||||
@@ -199,20 +215,20 @@ export const BringToFrontIcon = React.memo(
|
||||
);
|
||||
|
||||
export const SendToBackIcon = React.memo(
|
||||
({ appearance }: { appearance: "light" | "dark" }) =>
|
||||
({ theme }: { theme: "light" | "dark" }) =>
|
||||
createIcon(
|
||||
<>
|
||||
<path
|
||||
d="M18 7.333C18 6.597 17.403 6 16.667 6H7.333C6.597 6 6 6.597 6 7.333v9.334C6 17.403 6.597 18 7.333 18h9.334c.736 0 1.333-.597 1.333-1.333V7.333z"
|
||||
fill={activeElementColor(appearance)}
|
||||
stroke={activeElementColor(appearance)}
|
||||
fill={activeElementColor(theme)}
|
||||
stroke={activeElementColor(theme)}
|
||||
strokeLinejoin="round"
|
||||
strokeWidth="2"
|
||||
/>
|
||||
<path
|
||||
d="M11 3a1 1 0 00-1-1H3a1 1 0 00-1 1v7a1 1 0 001 1h8V3zM22 14a1 1 0 00-1-1h-7a1 1 0 00-1 1v7a1 1 0 001 1h8v-8z"
|
||||
fill={iconFillColor(appearance)}
|
||||
stroke={iconFillColor(appearance)}
|
||||
fill={iconFillColor(theme)}
|
||||
stroke={iconFillColor(theme)}
|
||||
strokeLinejoin="round"
|
||||
strokeWidth="2"
|
||||
/>
|
||||
@@ -228,20 +244,20 @@ export const SendToBackIcon = React.memo(
|
||||
// that would make them lie about their function.
|
||||
//
|
||||
export const AlignTopIcon = React.memo(
|
||||
({ appearance }: { appearance: "light" | "dark" }) =>
|
||||
({ theme }: { theme: "light" | "dark" }) =>
|
||||
createIcon(
|
||||
<>
|
||||
<path
|
||||
d="M 2,5 H 22"
|
||||
fill={iconFillColor(appearance)}
|
||||
stroke={iconFillColor(appearance)}
|
||||
fill={iconFillColor(theme)}
|
||||
stroke={iconFillColor(theme)}
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
/>
|
||||
<path
|
||||
d="M 6,7 C 5.446,7 5,7.446 5,8 v 9.999992 c 0,0.554 0.446,1 1,1 h 3.0000001 c 0.554,0 0.9999999,-0.446 0.9999999,-1 V 8 C 10,7.446 9.5540001,7 9.0000001,7 Z m 9,0 c -0.554,0 -1,0.446 -1,1 v 5.999992 c 0,0.554 0.446,1 1,1 h 3 c 0.554,0 1,-0.446 1,-1 V 8 C 19,7.446 18.554,7 18,7 Z"
|
||||
fill={activeElementColor(appearance)}
|
||||
stroke={activeElementColor(appearance)}
|
||||
fill={activeElementColor(theme)}
|
||||
stroke={activeElementColor(theme)}
|
||||
strokeWidth="2"
|
||||
/>
|
||||
</>,
|
||||
@@ -250,20 +266,20 @@ export const AlignTopIcon = React.memo(
|
||||
);
|
||||
|
||||
export const AlignBottomIcon = React.memo(
|
||||
({ appearance }: { appearance: "light" | "dark" }) =>
|
||||
({ theme }: { theme: "light" | "dark" }) =>
|
||||
createIcon(
|
||||
<>
|
||||
<path
|
||||
d="M 2,19 H 22"
|
||||
fill={iconFillColor(appearance)}
|
||||
stroke={iconFillColor(appearance)}
|
||||
fill={iconFillColor(theme)}
|
||||
stroke={iconFillColor(theme)}
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
/>
|
||||
<path
|
||||
d="m 6,16.999992 c -0.554,0 -1,-0.446 -1,-1 V 6 C 5,5.446 5.446,5 6,5 H 9.0000001 C 9.5540001,5 10,5.446 10,6 v 9.999992 c 0,0.554 -0.4459999,1 -0.9999999,1 z m 9,0 c -0.554,0 -1,-0.446 -1,-1 V 10 c 0,-0.554 0.446,-1 1,-1 h 3 c 0.554,0 1,0.446 1,1 v 5.999992 c 0,0.554 -0.446,1 -1,1 z"
|
||||
fill={activeElementColor(appearance)}
|
||||
stroke={activeElementColor(appearance)}
|
||||
fill={activeElementColor(theme)}
|
||||
stroke={activeElementColor(theme)}
|
||||
strokeWidth="2"
|
||||
/>
|
||||
</>,
|
||||
@@ -272,20 +288,20 @@ export const AlignBottomIcon = React.memo(
|
||||
);
|
||||
|
||||
export const AlignLeftIcon = React.memo(
|
||||
({ appearance }: { appearance: "light" | "dark" }) =>
|
||||
({ theme }: { theme: "light" | "dark" }) =>
|
||||
createIcon(
|
||||
<>
|
||||
<path
|
||||
d="M 5,2 V 22"
|
||||
fill={iconFillColor(appearance)}
|
||||
stroke={iconFillColor(appearance)}
|
||||
fill={iconFillColor(theme)}
|
||||
stroke={iconFillColor(theme)}
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
/>
|
||||
<path
|
||||
d="m 7.000004,5.999996 c 0,-0.554 0.446,-1 1,-1 h 9.999992 c 0.554,0 1,0.446 1,1 v 3.0000001 c 0,0.554 -0.446,0.9999999 -1,0.9999999 H 8.000004 c -0.554,0 -1,-0.4459999 -1,-0.9999999 z m 0,9 c 0,-0.554 0.446,-1 1,-1 h 5.999992 c 0.554,0 1,0.446 1,1 v 3 c 0,0.554 -0.446,1 -1,1 H 8.000004 c -0.554,0 -1,-0.446 -1,-1 z"
|
||||
fill={activeElementColor(appearance)}
|
||||
stroke={activeElementColor(appearance)}
|
||||
fill={activeElementColor(theme)}
|
||||
stroke={activeElementColor(theme)}
|
||||
strokeWidth="2"
|
||||
/>
|
||||
</>,
|
||||
@@ -294,20 +310,20 @@ export const AlignLeftIcon = React.memo(
|
||||
);
|
||||
|
||||
export const AlignRightIcon = React.memo(
|
||||
({ appearance }: { appearance: "light" | "dark" }) =>
|
||||
({ theme }: { theme: "light" | "dark" }) =>
|
||||
createIcon(
|
||||
<>
|
||||
<path
|
||||
d="M 19,2 V 22"
|
||||
fill={iconFillColor(appearance)}
|
||||
stroke={iconFillColor(appearance)}
|
||||
fill={iconFillColor(theme)}
|
||||
stroke={iconFillColor(theme)}
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
/>
|
||||
<path
|
||||
d="m 16.999996,5.999996 c 0,-0.554 -0.446,-1 -1,-1 H 6.000004 c -0.554,0 -1,0.446 -1,1 v 3.0000001 c 0,0.554 0.446,0.9999999 1,0.9999999 h 9.999992 c 0.554,0 1,-0.4459999 1,-0.9999999 z m 0,9 c 0,-0.554 -0.446,-1 -1,-1 h -5.999992 c -0.554,0 -1,0.446 -1,1 v 3 c 0,0.554 0.446,1 1,1 h 5.999992 c 0.554,0 1,-0.446 1,-1 z"
|
||||
fill={activeElementColor(appearance)}
|
||||
stroke={activeElementColor(appearance)}
|
||||
fill={activeElementColor(theme)}
|
||||
stroke={activeElementColor(theme)}
|
||||
strokeWidth="2"
|
||||
/>
|
||||
</>,
|
||||
@@ -316,20 +332,20 @@ export const AlignRightIcon = React.memo(
|
||||
);
|
||||
|
||||
export const DistributeHorizontallyIcon = React.memo(
|
||||
({ appearance }: { appearance: "light" | "dark" }) =>
|
||||
({ theme }: { theme: "light" | "dark" }) =>
|
||||
createIcon(
|
||||
<>
|
||||
<path d="M5 5V19Z" fill="black" />
|
||||
<path
|
||||
d="M19 5V19M5 5V19"
|
||||
stroke={iconFillColor(appearance)}
|
||||
stroke={iconFillColor(theme)}
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
/>
|
||||
<path
|
||||
d="M15 9C15.554 9 16 9.446 16 10V14C16 14.554 15.554 15 15 15H9C8.446 15 8 14.554 8 14V10C8 9.446 8.446 9 9 9H15Z"
|
||||
fill={activeElementColor(appearance)}
|
||||
stroke={activeElementColor(appearance)}
|
||||
fill={activeElementColor(theme)}
|
||||
stroke={activeElementColor(theme)}
|
||||
strokeWidth="2"
|
||||
/>
|
||||
</>,
|
||||
@@ -346,20 +362,20 @@ export const DistributeHorizontallyIcon = React.memo(
|
||||
></svg>;
|
||||
|
||||
export const DistributeVerticallyIcon = React.memo(
|
||||
({ appearance }: { appearance: "light" | "dark" }) =>
|
||||
({ theme }: { theme: "light" | "dark" }) =>
|
||||
createIcon(
|
||||
<>
|
||||
<path
|
||||
d="M5 5L19 5M5 19H19"
|
||||
fill={iconFillColor(appearance)}
|
||||
stroke={iconFillColor(appearance)}
|
||||
fill={iconFillColor(theme)}
|
||||
stroke={iconFillColor(theme)}
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
/>
|
||||
<path
|
||||
d="M15 9C15.554 9 16 9.446 16 10V14C16 14.554 15.554 15 15 15H9C8.446 15 8 14.554 8 14V10C8 9.446 8.446 9 9 9H15Z"
|
||||
fill={activeElementColor(appearance)}
|
||||
stroke={activeElementColor(appearance)}
|
||||
fill={activeElementColor(theme)}
|
||||
stroke={activeElementColor(theme)}
|
||||
strokeWidth="2"
|
||||
/>
|
||||
</>,
|
||||
@@ -368,19 +384,19 @@ export const DistributeVerticallyIcon = React.memo(
|
||||
);
|
||||
|
||||
export const CenterVerticallyIcon = React.memo(
|
||||
({ appearance }: { appearance: "light" | "dark" }) =>
|
||||
({ theme }: { theme: "light" | "dark" }) =>
|
||||
createIcon(
|
||||
<>
|
||||
<path
|
||||
d="m 5.000004,16.999996 c 0,0.554 0.446,1 1,1 h 3 c 0.554,0 1,-0.446 1,-1 v -10 c 0,-0.554 -0.446,-1 -1,-1 h -3 c -0.554,0 -1,0.446 -1,1 z m 9,-2 c 0,0.554 0.446,1 1,1 h 3 c 0.554,0 1,-0.446 1,-1 v -6 c 0,-0.554 -0.446,-1 -1,-1 h -3 c -0.554,0 -1,0.446 -1,1 z"
|
||||
fill={activeElementColor(appearance)}
|
||||
stroke={activeElementColor(appearance)}
|
||||
fill={activeElementColor(theme)}
|
||||
stroke={activeElementColor(theme)}
|
||||
strokeWidth="2"
|
||||
/>
|
||||
<path
|
||||
d="M 2,12 H 22"
|
||||
fill={iconFillColor(appearance)}
|
||||
stroke={iconFillColor(appearance)}
|
||||
fill={iconFillColor(theme)}
|
||||
stroke={iconFillColor(theme)}
|
||||
strokeWidth="2"
|
||||
strokeDasharray="1, 2.8"
|
||||
strokeLinecap="round"
|
||||
@@ -391,19 +407,19 @@ export const CenterVerticallyIcon = React.memo(
|
||||
);
|
||||
|
||||
export const CenterHorizontallyIcon = React.memo(
|
||||
({ appearance }: { appearance: "light" | "dark" }) =>
|
||||
({ theme }: { theme: "light" | "dark" }) =>
|
||||
createIcon(
|
||||
<>
|
||||
<path
|
||||
d="M 7 5 C 6.446 5 6 5.446 6 6 L 6 9 C 6 9.554 6.446 10 7 10 L 17 10 C 17.554 10 18 9.554 18 9 L 18 6 C 18 5.446 17.554 5 17 5 L 7 5 z M 9 14 C 8.446 14 8 14.446 8 15 L 8 18 C 8 18.554 8.446 19 9 19 L 15 19 C 15.554 19 16 18.554 16 18 L 16 15 C 16 14.446 15.554 14 15 14 L 9 14 z "
|
||||
fill={activeElementColor(appearance)}
|
||||
stroke={activeElementColor(appearance)}
|
||||
fill={activeElementColor(theme)}
|
||||
stroke={activeElementColor(theme)}
|
||||
strokeWidth="2"
|
||||
/>
|
||||
<path
|
||||
d="M 12,2 V 22"
|
||||
fill={iconFillColor(appearance)}
|
||||
stroke={iconFillColor(appearance)}
|
||||
fill={iconFillColor(theme)}
|
||||
stroke={iconFillColor(theme)}
|
||||
strokeWidth="2"
|
||||
strokeDasharray="1, 2.8"
|
||||
strokeLinecap="round"
|
||||
@@ -448,77 +464,76 @@ export const shield = createIcon(
|
||||
{ width: 24 },
|
||||
);
|
||||
|
||||
export const GroupIcon = React.memo(
|
||||
({ appearance }: { appearance: "light" | "dark" }) =>
|
||||
createIcon(
|
||||
<>
|
||||
<path d="M25 26H111V111H25" fill={iconFillColor(appearance)} />
|
||||
<path
|
||||
d="M25 111C25 80.2068 25 49.4135 25 26M25 26C48.6174 26 72.2348 26 111 26H25ZM25 26C53.3671 26 81.7343 26 111 26H25ZM111 26C111 52.303 111 78.606 111 111V26ZM111 26C111 51.2947 111 76.5893 111 111V26ZM111 111C87.0792 111 63.1585 111 25 111H111ZM111 111C87.4646 111 63.9293 111 25 111H111ZM25 111C25 81.1514 25 51.3028 25 26V111Z"
|
||||
stroke={iconFillColor(appearance)}
|
||||
strokeWidth="2"
|
||||
/>
|
||||
<path d="M100 100H160V160H100" fill={iconFillColor(appearance)} />
|
||||
<path
|
||||
d="M100 160C100 144.106 100 128.211 100 100M100 100C117.706 100 135.412 100 160 100H100ZM100 100C114.214 100 128.428 100 160 100H100ZM160 100C160 120.184 160 140.369 160 160V100ZM160 100C160 113.219 160 126.437 160 160V100ZM160 160C145.534 160 131.068 160 100 160H160ZM160 160C143.467 160 126.934 160 100 160H160ZM100 160C100 143.661 100 127.321 100 100V160Z"
|
||||
stroke={iconFillColor(appearance)}
|
||||
strokeWidth="2"
|
||||
/>
|
||||
<rect
|
||||
x="2.5"
|
||||
y="2.5"
|
||||
width="30"
|
||||
height="30"
|
||||
fill={handlerColor(appearance)}
|
||||
stroke={iconFillColor(appearance)}
|
||||
strokeWidth="6"
|
||||
/>
|
||||
<rect
|
||||
x="2.5"
|
||||
y="149.5"
|
||||
width="30"
|
||||
height="30"
|
||||
fill={handlerColor(appearance)}
|
||||
stroke={iconFillColor(appearance)}
|
||||
strokeWidth="6"
|
||||
/>
|
||||
<rect
|
||||
x="147.5"
|
||||
y="149.5"
|
||||
width="30"
|
||||
height="30"
|
||||
fill={handlerColor(appearance)}
|
||||
stroke={iconFillColor(appearance)}
|
||||
strokeWidth="6"
|
||||
/>
|
||||
<rect
|
||||
x="147.5"
|
||||
y="2.5"
|
||||
width="30"
|
||||
height="30"
|
||||
fill={handlerColor(appearance)}
|
||||
stroke={iconFillColor(appearance)}
|
||||
strokeWidth="6"
|
||||
/>
|
||||
</>,
|
||||
{ width: 182, height: 182, mirror: true },
|
||||
),
|
||||
export const GroupIcon = React.memo(({ theme }: { theme: "light" | "dark" }) =>
|
||||
createIcon(
|
||||
<>
|
||||
<path d="M25 26H111V111H25" fill={iconFillColor(theme)} />
|
||||
<path
|
||||
d="M25 111C25 80.2068 25 49.4135 25 26M25 26C48.6174 26 72.2348 26 111 26H25ZM25 26C53.3671 26 81.7343 26 111 26H25ZM111 26C111 52.303 111 78.606 111 111V26ZM111 26C111 51.2947 111 76.5893 111 111V26ZM111 111C87.0792 111 63.1585 111 25 111H111ZM111 111C87.4646 111 63.9293 111 25 111H111ZM25 111C25 81.1514 25 51.3028 25 26V111Z"
|
||||
stroke={iconFillColor(theme)}
|
||||
strokeWidth="2"
|
||||
/>
|
||||
<path d="M100 100H160V160H100" fill={iconFillColor(theme)} />
|
||||
<path
|
||||
d="M100 160C100 144.106 100 128.211 100 100M100 100C117.706 100 135.412 100 160 100H100ZM100 100C114.214 100 128.428 100 160 100H100ZM160 100C160 120.184 160 140.369 160 160V100ZM160 100C160 113.219 160 126.437 160 160V100ZM160 160C145.534 160 131.068 160 100 160H160ZM160 160C143.467 160 126.934 160 100 160H160ZM100 160C100 143.661 100 127.321 100 100V160Z"
|
||||
stroke={iconFillColor(theme)}
|
||||
strokeWidth="2"
|
||||
/>
|
||||
<rect
|
||||
x="2.5"
|
||||
y="2.5"
|
||||
width="30"
|
||||
height="30"
|
||||
fill={handlerColor(theme)}
|
||||
stroke={iconFillColor(theme)}
|
||||
strokeWidth="6"
|
||||
/>
|
||||
<rect
|
||||
x="2.5"
|
||||
y="149.5"
|
||||
width="30"
|
||||
height="30"
|
||||
fill={handlerColor(theme)}
|
||||
stroke={iconFillColor(theme)}
|
||||
strokeWidth="6"
|
||||
/>
|
||||
<rect
|
||||
x="147.5"
|
||||
y="149.5"
|
||||
width="30"
|
||||
height="30"
|
||||
fill={handlerColor(theme)}
|
||||
stroke={iconFillColor(theme)}
|
||||
strokeWidth="6"
|
||||
/>
|
||||
<rect
|
||||
x="147.5"
|
||||
y="2.5"
|
||||
width="30"
|
||||
height="30"
|
||||
fill={handlerColor(theme)}
|
||||
stroke={iconFillColor(theme)}
|
||||
strokeWidth="6"
|
||||
/>
|
||||
</>,
|
||||
{ width: 182, height: 182, mirror: true },
|
||||
),
|
||||
);
|
||||
|
||||
export const UngroupIcon = React.memo(
|
||||
({ appearance }: { appearance: "light" | "dark" }) =>
|
||||
({ theme }: { theme: "light" | "dark" }) =>
|
||||
createIcon(
|
||||
<>
|
||||
<path d="M25 26H111V111H25" fill={iconFillColor(appearance)} />
|
||||
<path d="M25 26H111V111H25" fill={iconFillColor(theme)} />
|
||||
<path
|
||||
d="M25 111C25 80.2068 25 49.4135 25 26M25 26C48.6174 26 72.2348 26 111 26H25ZM25 26C53.3671 26 81.7343 26 111 26H25ZM111 26C111 52.303 111 78.606 111 111V26ZM111 26C111 51.2947 111 76.5893 111 111V26ZM111 111C87.0792 111 63.1585 111 25 111H111ZM111 111C87.4646 111 63.9293 111 25 111H111ZM25 111C25 81.1514 25 51.3028 25 26V111Z"
|
||||
stroke={iconFillColor(appearance)}
|
||||
stroke={iconFillColor(theme)}
|
||||
strokeWidth="2"
|
||||
/>
|
||||
<path d="M100 100H160V160H100" fill={iconFillColor(appearance)} />
|
||||
<path d="M100 100H160V160H100" fill={iconFillColor(theme)} />
|
||||
<path
|
||||
d="M100 160C100 144.106 100 128.211 100 100M100 100C117.706 100 135.412 100 160 100H100ZM100 100C114.214 100 128.428 100 160 100H100ZM160 100C160 120.184 160 140.369 160 160V100ZM160 100C160 113.219 160 126.437 160 160V100ZM160 160C145.534 160 131.068 160 100 160H160ZM160 160C143.467 160 126.934 160 100 160H160ZM100 160C100 143.661 100 127.321 100 100V160Z"
|
||||
stroke={iconFillColor(appearance)}
|
||||
stroke={iconFillColor(theme)}
|
||||
strokeWidth="2"
|
||||
/>
|
||||
<rect
|
||||
@@ -526,8 +541,8 @@ export const UngroupIcon = React.memo(
|
||||
y="2.5"
|
||||
width="30"
|
||||
height="30"
|
||||
fill={handlerColor(appearance)}
|
||||
stroke={iconFillColor(appearance)}
|
||||
fill={handlerColor(theme)}
|
||||
stroke={iconFillColor(theme)}
|
||||
strokeWidth="6"
|
||||
/>
|
||||
<rect
|
||||
@@ -535,8 +550,8 @@ export const UngroupIcon = React.memo(
|
||||
y="149.5"
|
||||
width="30"
|
||||
height="30"
|
||||
fill={handlerColor(appearance)}
|
||||
stroke={iconFillColor(appearance)}
|
||||
fill={handlerColor(theme)}
|
||||
stroke={iconFillColor(theme)}
|
||||
strokeWidth="6"
|
||||
/>
|
||||
<rect
|
||||
@@ -544,8 +559,8 @@ export const UngroupIcon = React.memo(
|
||||
y="149.5"
|
||||
width="30"
|
||||
height="30"
|
||||
fill={handlerColor(appearance)}
|
||||
stroke={iconFillColor(appearance)}
|
||||
fill={handlerColor(theme)}
|
||||
stroke={iconFillColor(theme)}
|
||||
strokeWidth="6"
|
||||
/>
|
||||
<rect
|
||||
@@ -553,8 +568,8 @@ export const UngroupIcon = React.memo(
|
||||
y="78.5"
|
||||
width="30"
|
||||
height="30"
|
||||
fill={handlerColor(appearance)}
|
||||
stroke={iconFillColor(appearance)}
|
||||
fill={handlerColor(theme)}
|
||||
stroke={iconFillColor(theme)}
|
||||
strokeWidth="6"
|
||||
/>
|
||||
<rect
|
||||
@@ -562,8 +577,8 @@ export const UngroupIcon = React.memo(
|
||||
y="2.5"
|
||||
width="30"
|
||||
height="30"
|
||||
fill={handlerColor(appearance)}
|
||||
stroke={iconFillColor(appearance)}
|
||||
fill={handlerColor(theme)}
|
||||
stroke={iconFillColor(theme)}
|
||||
strokeWidth="6"
|
||||
/>
|
||||
<rect
|
||||
@@ -571,8 +586,8 @@ export const UngroupIcon = React.memo(
|
||||
y="102.5"
|
||||
width="30"
|
||||
height="30"
|
||||
fill={handlerColor(appearance)}
|
||||
stroke={iconFillColor(appearance)}
|
||||
fill={handlerColor(theme)}
|
||||
stroke={iconFillColor(theme)}
|
||||
strokeWidth="6"
|
||||
/>
|
||||
</>,
|
||||
@@ -581,22 +596,22 @@ export const UngroupIcon = React.memo(
|
||||
);
|
||||
|
||||
export const FillHachureIcon = React.memo(
|
||||
({ appearance }: { appearance: "light" | "dark" }) =>
|
||||
({ theme }: { theme: "light" | "dark" }) =>
|
||||
createIcon(
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
clipRule="evenodd"
|
||||
d="M20.101 16H28.0934L36 8.95989V4H33.5779L20.101 16ZM30.5704 4L17.0935 16H9.10101L22.5779 4H30.5704ZM19.5704 4L6.09349 16H4V10.7475L11.5779 4H19.5704ZM8.57036 4H4V8.06952L8.57036 4ZM36 11.6378L31.101 16H36V11.6378ZM2 2V18H38V2H2Z"
|
||||
fill={iconFillColor(appearance)}
|
||||
fill={iconFillColor(theme)}
|
||||
/>,
|
||||
{ width: 40, height: 20 },
|
||||
),
|
||||
);
|
||||
|
||||
export const FillCrossHatchIcon = React.memo(
|
||||
({ appearance }: { appearance: "light" | "dark" }) =>
|
||||
({ theme }: { theme: "light" | "dark" }) =>
|
||||
createIcon(
|
||||
<g fill={iconFillColor(appearance)} fillRule="evenodd" clipRule="evenodd">
|
||||
<g fill={iconFillColor(theme)} fillRule="evenodd" clipRule="evenodd">
|
||||
<path d="M20.101 16H28.0934L36 8.95989V4H33.5779L20.101 16ZM30.5704 4L17.0935 16H9.10101L22.5779 4H30.5704ZM19.5704 4L6.09349 16H4V10.7475L11.5779 4H19.5704ZM8.57036 4H4V8.06952L8.57036 4ZM36 11.6378L31.101 16H36V11.6378ZM2 2V18H38V2H2Z" />
|
||||
<path d="M14.0001 18L3.00006 4.00002L4.5727 2.76438L15.5727 16.7644L14.0001 18ZM25.0001 18L14.0001 4.00002L15.5727 2.76438L26.5727 16.7644L25.0001 18ZM36.0001 18L25.0001 4.00002L26.5727 2.76438L37.5727 16.7644L36.0001 18Z" />
|
||||
</g>,
|
||||
@@ -605,25 +620,19 @@ export const FillCrossHatchIcon = React.memo(
|
||||
);
|
||||
|
||||
export const FillSolidIcon = React.memo(
|
||||
({ appearance }: { appearance: "light" | "dark" }) =>
|
||||
createIcon(<path d="M2 2H38V18H2V2Z" fill={iconFillColor(appearance)} />, {
|
||||
({ theme }: { theme: "light" | "dark" }) =>
|
||||
createIcon(<path d="M2 2H38V18H2V2Z" fill={iconFillColor(theme)} />, {
|
||||
width: 40,
|
||||
height: 20,
|
||||
}),
|
||||
);
|
||||
|
||||
export const StrokeWidthIcon = React.memo(
|
||||
({
|
||||
appearance,
|
||||
strokeWidth,
|
||||
}: {
|
||||
appearance: "light" | "dark";
|
||||
strokeWidth: number;
|
||||
}) =>
|
||||
({ theme, strokeWidth }: { theme: "light" | "dark"; strokeWidth: number }) =>
|
||||
createIcon(
|
||||
<path
|
||||
d="M6 10H34"
|
||||
stroke={iconFillColor(appearance)}
|
||||
stroke={iconFillColor(theme)}
|
||||
strokeWidth={strokeWidth}
|
||||
fill="none"
|
||||
/>,
|
||||
@@ -632,11 +641,11 @@ export const StrokeWidthIcon = React.memo(
|
||||
);
|
||||
|
||||
export const StrokeStyleSolidIcon = React.memo(
|
||||
({ appearance }: { appearance: "light" | "dark" }) =>
|
||||
({ theme }: { theme: "light" | "dark" }) =>
|
||||
createIcon(
|
||||
<path
|
||||
d="M6 10H34"
|
||||
stroke={iconFillColor(appearance)}
|
||||
stroke={iconFillColor(theme)}
|
||||
strokeWidth={2}
|
||||
fill="none"
|
||||
/>,
|
||||
@@ -648,11 +657,11 @@ export const StrokeStyleSolidIcon = React.memo(
|
||||
);
|
||||
|
||||
export const StrokeStyleDashedIcon = React.memo(
|
||||
({ appearance }: { appearance: "light" | "dark" }) =>
|
||||
({ theme }: { theme: "light" | "dark" }) =>
|
||||
createIcon(
|
||||
<path
|
||||
d="M6 10H34"
|
||||
stroke={iconFillColor(appearance)}
|
||||
stroke={iconFillColor(theme)}
|
||||
strokeWidth={2.5}
|
||||
strokeDasharray={"10, 8"}
|
||||
fill="none"
|
||||
@@ -662,11 +671,11 @@ export const StrokeStyleDashedIcon = React.memo(
|
||||
);
|
||||
|
||||
export const StrokeStyleDottedIcon = React.memo(
|
||||
({ appearance }: { appearance: "light" | "dark" }) =>
|
||||
({ theme }: { theme: "light" | "dark" }) =>
|
||||
createIcon(
|
||||
<path
|
||||
d="M6 10H34"
|
||||
stroke={iconFillColor(appearance)}
|
||||
stroke={iconFillColor(theme)}
|
||||
strokeWidth={2.5}
|
||||
strokeDasharray={"4, 4"}
|
||||
fill="none"
|
||||
@@ -676,11 +685,11 @@ export const StrokeStyleDottedIcon = React.memo(
|
||||
);
|
||||
|
||||
export const SloppinessArchitectIcon = React.memo(
|
||||
({ appearance }: { appearance: "light" | "dark" }) =>
|
||||
({ theme }: { theme: "light" | "dark" }) =>
|
||||
createIcon(
|
||||
<path
|
||||
d="M3.00098 16.1691C6.28774 13.9744 19.6399 2.8905 22.7215 3.00082C25.8041 3.11113 19.1158 15.5488 21.4962 16.8309C23.8757 18.1131 34.4155 11.7148 37.0001 10.6919"
|
||||
stroke={iconFillColor(appearance)}
|
||||
stroke={iconFillColor(theme)}
|
||||
strokeWidth={2}
|
||||
fill="none"
|
||||
/>,
|
||||
@@ -689,11 +698,11 @@ export const SloppinessArchitectIcon = React.memo(
|
||||
);
|
||||
|
||||
export const SloppinessArtistIcon = React.memo(
|
||||
({ appearance }: { appearance: "light" | "dark" }) =>
|
||||
({ theme }: { theme: "light" | "dark" }) =>
|
||||
createIcon(
|
||||
<path
|
||||
d="M3 17C6.68158 14.8752 16.1296 9.09849 22.0648 6.54922C28 3.99995 22.2896 13.3209 25 14C27.7104 14.6791 36.3757 9.6471 36.3757 9.6471M6.40706 15C13 11.1918 20.0468 1.51045 23.0234 3.0052C26 4.49995 20.457 12.8659 22.7285 16.4329C25 20 36.3757 13 36.3757 13"
|
||||
stroke={iconFillColor(appearance)}
|
||||
stroke={iconFillColor(theme)}
|
||||
strokeWidth={2}
|
||||
fill="none"
|
||||
/>,
|
||||
@@ -702,11 +711,11 @@ export const SloppinessArtistIcon = React.memo(
|
||||
);
|
||||
|
||||
export const SloppinessCartoonistIcon = React.memo(
|
||||
({ appearance }: { appearance: "light" | "dark" }) =>
|
||||
({ theme }: { theme: "light" | "dark" }) =>
|
||||
createIcon(
|
||||
<path
|
||||
d="M3 15.6468C6.93692 13.5378 22.5544 2.81528 26.6206 3.00242C30.6877 3.18956 25.6708 15.3346 27.4009 16.7705C29.1309 18.2055 35.4001 12.4762 37 11.6177M3.97143 10.4917C6.61158 9.24563 16.3706 2.61886 19.8104 3.01724C23.2522 3.41472 22.0773 12.2013 24.6181 12.8783C27.1598 13.5536 33.3179 8.04068 35.0571 7.07244"
|
||||
stroke={iconFillColor(appearance)}
|
||||
stroke={iconFillColor(theme)}
|
||||
strokeWidth={2}
|
||||
fill="none"
|
||||
/>,
|
||||
@@ -715,11 +724,11 @@ export const SloppinessCartoonistIcon = React.memo(
|
||||
);
|
||||
|
||||
export const EdgeSharpIcon = React.memo(
|
||||
({ appearance }: { appearance: "light" | "dark" }) =>
|
||||
({ theme }: { theme: "light" | "dark" }) =>
|
||||
createIcon(
|
||||
<path
|
||||
d="M10 17L10 5L35 5"
|
||||
stroke={iconFillColor(appearance)}
|
||||
stroke={iconFillColor(theme)}
|
||||
strokeWidth={2}
|
||||
fill="none"
|
||||
/>,
|
||||
@@ -728,11 +737,11 @@ export const EdgeSharpIcon = React.memo(
|
||||
);
|
||||
|
||||
export const EdgeRoundIcon = React.memo(
|
||||
({ appearance }: { appearance: "light" | "dark" }) =>
|
||||
({ theme }: { theme: "light" | "dark" }) =>
|
||||
createIcon(
|
||||
<path
|
||||
d="M10 17V15C10 8 13 5 21 5L33.5 5"
|
||||
stroke={iconFillColor(appearance)}
|
||||
stroke={iconFillColor(theme)}
|
||||
strokeWidth={2}
|
||||
fill="none"
|
||||
/>,
|
||||
@@ -741,11 +750,11 @@ export const EdgeRoundIcon = React.memo(
|
||||
);
|
||||
|
||||
export const ArrowheadNoneIcon = React.memo(
|
||||
({ appearance }: { appearance: "light" | "dark" }) =>
|
||||
({ theme }: { theme: "light" | "dark" }) =>
|
||||
createIcon(
|
||||
<path
|
||||
d="M6 10H34"
|
||||
stroke={iconFillColor(appearance)}
|
||||
stroke={iconFillColor(theme)}
|
||||
strokeWidth={2}
|
||||
fill="none"
|
||||
/>,
|
||||
@@ -757,17 +766,11 @@ export const ArrowheadNoneIcon = React.memo(
|
||||
);
|
||||
|
||||
export const ArrowheadArrowIcon = React.memo(
|
||||
({
|
||||
appearance,
|
||||
flip = false,
|
||||
}: {
|
||||
appearance: "light" | "dark";
|
||||
flip?: boolean;
|
||||
}) =>
|
||||
({ theme, flip = false }: { theme: "light" | "dark"; flip?: boolean }) =>
|
||||
createIcon(
|
||||
<g
|
||||
transform={flip ? "translate(40, 0) scale(-1, 1)" : ""}
|
||||
stroke={iconFillColor(appearance)}
|
||||
stroke={iconFillColor(theme)}
|
||||
strokeWidth={2}
|
||||
fill="none"
|
||||
>
|
||||
@@ -779,17 +782,11 @@ export const ArrowheadArrowIcon = React.memo(
|
||||
);
|
||||
|
||||
export const ArrowheadDotIcon = React.memo(
|
||||
({
|
||||
appearance,
|
||||
flip = false,
|
||||
}: {
|
||||
appearance: "light" | "dark";
|
||||
flip?: boolean;
|
||||
}) =>
|
||||
({ theme, flip = false }: { theme: "light" | "dark"; flip?: boolean }) =>
|
||||
createIcon(
|
||||
<g
|
||||
stroke={iconFillColor(appearance)}
|
||||
fill={iconFillColor(appearance)}
|
||||
stroke={iconFillColor(theme)}
|
||||
fill={iconFillColor(theme)}
|
||||
transform={flip ? "translate(40, 0) scale(-1, 1)" : ""}
|
||||
>
|
||||
<path d="M32 10L6 10" strokeWidth={2} />
|
||||
@@ -800,18 +797,12 @@ export const ArrowheadDotIcon = React.memo(
|
||||
);
|
||||
|
||||
export const ArrowheadBarIcon = React.memo(
|
||||
({
|
||||
appearance,
|
||||
flip = false,
|
||||
}: {
|
||||
appearance: "light" | "dark";
|
||||
flip?: boolean;
|
||||
}) =>
|
||||
({ theme, flip = false }: { theme: "light" | "dark"; flip?: boolean }) =>
|
||||
createIcon(
|
||||
<g transform={flip ? "translate(40, 0) scale(-1, 1)" : ""}>
|
||||
<path
|
||||
d="M34 10H5.99996M34 10L34 5M34 10L34 15"
|
||||
stroke={iconFillColor(appearance)}
|
||||
stroke={iconFillColor(theme)}
|
||||
strokeWidth={2}
|
||||
fill="none"
|
||||
/>
|
||||
@@ -819,3 +810,121 @@ export const ArrowheadBarIcon = React.memo(
|
||||
{ width: 40, height: 20 },
|
||||
),
|
||||
);
|
||||
|
||||
export const FontSizeSmallIcon = React.memo(
|
||||
({ theme }: { theme: "light" | "dark" }) =>
|
||||
createIcon(
|
||||
<path
|
||||
fill={iconFillColor(theme)}
|
||||
d="M 0 69.092 L 0 55.03 A 124.24 124.24 0 0 0 4.706 57.02 Q 6.826 57.863 8.708 58.5 A 53.466 53.466 0 0 0 12.231 59.571 Q 17.236 60.889 21.387 60.889 A 20.909 20.909 0 0 0 24.265 60.704 Q 25.719 60.502 26.903 60.077 A 8.649 8.649 0 0 0 29.028 58.985 Q 31.689 57.08 31.689 53.321 Q 31.689 51.221 30.518 49.585 A 10.126 10.126 0 0 0 29.282 48.177 Q 28.352 47.287 27.075 46.436 A 23.719 23.719 0 0 0 25.752 45.627 Q 23.774 44.492 20.176 42.735 A 254.44 254.44 0 0 0 17.822 41.602 Q 11.503 38.631 8.236 35.888 A 19.742 19.742 0 0 1 8.008 35.694 A 22.18 22.18 0 0 1 2.783 29.102 Q 0.83 25.342 0.83 20.313 A 22.471 22.471 0 0 1 1.733 13.778 A 17.283 17.283 0 0 1 7.251 5.42 A 21.486 21.486 0 0 1 15.177 1.272 Q 18.361 0.338 22.166 0.09 A 43.573 43.573 0 0 1 25 0 A 42.399 42.399 0 0 1 34.349 1.01 A 39.075 39.075 0 0 1 35.62 1.319 A 67.407 67.407 0 0 1 42.108 3.382 A 83.357 83.357 0 0 1 46.191 5.03 L 41.309 16.797 Q 35.596 14.453 31.86 13.526 A 30.762 30.762 0 0 0 25.417 12.612 A 28.337 28.337 0 0 0 24.512 12.598 A 14.846 14.846 0 0 0 22.022 12.793 Q 19.498 13.224 17.92 14.6 Q 15.625 16.602 15.625 19.824 Q 15.625 21.826 16.553 23.316 Q 17.48 24.805 19.507 26.197 A 18.343 18.343 0 0 0 20.659 26.912 Q 22.596 28.035 26.516 29.953 A 299.99 299.99 0 0 0 29.102 31.201 Q 37.91 35.412 41.841 39.642 A 16.553 16.553 0 0 1 42.822 40.796 A 17.675 17.675 0 0 1 46.301 49.233 A 23.517 23.517 0 0 1 46.533 52.588 A 21.581 21.581 0 0 1 45.471 59.515 A 17.733 17.733 0 0 1 39.575 67.823 Q 33.745 72.486 24.094 73.243 A 49.683 49.683 0 0 1 20.215 73.389 A 51.712 51.712 0 0 1 9.448 72.315 A 40.672 40.672 0 0 1 0 69.092 Z"
|
||||
/>,
|
||||
{ width: 47, height: 77 },
|
||||
),
|
||||
);
|
||||
|
||||
export const FontSizeMediumIcon = React.memo(
|
||||
({ theme }: { theme: "light" | "dark" }) =>
|
||||
createIcon(
|
||||
<path
|
||||
fill={iconFillColor(theme)}
|
||||
d="M 44.092 71.387 L 30.225 71.387 L 13.037 15.381 L 12.598 15.381 A 1505.093 1505.093 0 0 1 12.959 22.313 Q 13.426 31.715 13.508 36.4 A 102.991 102.991 0 0 1 13.525 38.184 L 13.525 71.387 L 0 71.387 L 0 0 L 20.605 0 L 37.5 54.59 L 37.793 54.59 L 55.713 0 L 76.318 0 L 76.318 71.387 L 62.207 71.387 L 62.207 37.598 Q 62.207 35.205 62.28 32.08 A 160.703 160.703 0 0 1 62.326 30.544 Q 62.452 26.754 62.866 17.168 A 5390.536 5390.536 0 0 1 62.939 15.479 L 62.5 15.479 L 44.092 71.387 Z"
|
||||
/>,
|
||||
{ width: 77, height: 75 },
|
||||
),
|
||||
);
|
||||
|
||||
export const FontSizeLargeIcon = React.memo(
|
||||
({ theme }: { theme: "light" | "dark" }) =>
|
||||
createIcon(
|
||||
<path
|
||||
fill={iconFillColor(theme)}
|
||||
d="M 44.092 71.387 L 0 71.387 L 0 0 L 15.137 0 L 15.137 58.887 L 44.092 58.887 L 44.092 71.387 Z"
|
||||
/>,
|
||||
{ width: 45, height: 75 },
|
||||
),
|
||||
);
|
||||
|
||||
export const FontSizeExtraLargeIcon = React.memo(
|
||||
({ theme }: { theme: "light" | "dark" }) =>
|
||||
createIcon(
|
||||
<path
|
||||
fill={iconFillColor(theme)}
|
||||
d="M 42.578 35.4 L 66.699 71.387 L 49.414 71.387 L 32.813 44.385 L 16.211 71.387 L 0 71.387 L 23.682 34.57 L 1.514 0 L 18.213 0 L 33.594 25.684 L 48.682 0 L 64.99 0 L 42.578 35.4 Z M 119.775 71.387 L 75.684 71.387 L 75.684 0 L 90.82 0 L 90.82 58.887 L 119.775 58.887 L 119.775 71.387 Z"
|
||||
/>,
|
||||
{ width: 120, height: 75 },
|
||||
),
|
||||
);
|
||||
|
||||
export const FontFamilyHandDrawnIcon = React.memo(
|
||||
({ theme }: { theme: "light" | "dark" }) =>
|
||||
createIcon(
|
||||
<path
|
||||
fill={iconFillColor(theme)}
|
||||
d="M290.74 93.24l128.02 128.02-277.99 277.99-114.14 12.6C11.35 513.54-1.56 500.62.14 485.34l12.7-114.22 277.9-277.88zm207.2-19.06l-60.11-60.11c-18.75-18.75-49.16-18.75-67.91 0l-56.55 56.55 128.02 128.02 56.55-56.55c18.75-18.76 18.75-49.16 0-67.91z"
|
||||
/>,
|
||||
{ width: 448, height: 512 },
|
||||
),
|
||||
);
|
||||
|
||||
export const FontFamilyNormalIcon = React.memo(
|
||||
({ theme }: { theme: "light" | "dark" }) =>
|
||||
createIcon(
|
||||
<>
|
||||
<path
|
||||
fill={iconFillColor(theme)}
|
||||
d="M 63.818 71.68 L 54.492 71.68 L 45.898 49.561 L 17.578 49.561 L 9.082 71.68 L 0 71.68 L 27.881 0 L 35.986 0 L 63.818 71.68 Z M 20.605 41.602 L 43.213 41.602 L 35.205 19.971 L 31.787 9.277 Q 30.322 15.137 28.711 19.971 L 20.605 41.602 Z"
|
||||
/>
|
||||
<path
|
||||
fill={iconFillColor(theme)}
|
||||
d="M 68.994 71.68 L 52.686 71.68 L 47.51 54.688 L 21.484 54.688 L 16.309 71.68 L 0 71.68 L 25.195 0 L 43.701 0 L 68.994 71.68 Z M 25.293 41.992 L 43.896 41.992 A 27590.463 27590.463 0 0 1 42.2 36.532 Q 36.965 19.676 35.937 16.273 A 120.932 120.932 0 0 1 35.815 15.869 A 131.65 131.65 0 0 1 35.396 14.435 Q 34.951 12.879 34.675 11.741 A 34.866 34.866 0 0 1 34.521 11.084 A 141.762 141.762 0 0 1 33.706 14.075 Q 31.482 21.957 25.293 41.992 Z"
|
||||
/>
|
||||
</>,
|
||||
{ width: 70, height: 78 },
|
||||
),
|
||||
);
|
||||
|
||||
export const FontFamilyCodeIcon = React.memo(
|
||||
({ theme }: { theme: "light" | "dark" }) =>
|
||||
createIcon(
|
||||
<>
|
||||
<path
|
||||
fill={iconFillColor(theme)}
|
||||
d="M278.9 511.5l-61-17.7c-6.4-1.8-10-8.5-8.2-14.9L346.2 8.7c1.8-6.4 8.5-10 14.9-8.2l61 17.7c6.4 1.8 10 8.5 8.2 14.9L293.8 503.3c-1.9 6.4-8.5 10.1-14.9 8.2zm-114-112.2l43.5-46.4c4.6-4.9 4.3-12.7-.8-17.2L117 256l90.6-79.7c5.1-4.5 5.5-12.3.8-17.2l-43.5-46.4c-4.5-4.8-12.1-5.1-17-.5L3.8 247.2c-5.1 4.7-5.1 12.8 0 17.5l144.1 135.1c4.9 4.6 12.5 4.4 17-.5zm327.2.6l144.1-135.1c5.1-4.7 5.1-12.8 0-17.5L492.1 112.1c-4.8-4.5-12.4-4.3-17 .5L431.6 159c-4.6 4.9-4.3 12.7.8 17.2L523 256l-90.6 79.7c-5.1 4.5-5.5 12.3-.8 17.2l43.5 46.4c4.5 4.9 12.1 5.1 17 .6z"
|
||||
/>
|
||||
</>,
|
||||
{ width: 640, height: 512 },
|
||||
),
|
||||
);
|
||||
|
||||
export const TextAlignLeftIcon = React.memo(
|
||||
({ theme }: { theme: "light" | "dark" }) =>
|
||||
createIcon(
|
||||
<path
|
||||
d="M12.83 352h262.34A12.82 12.82 0 00288 339.17v-38.34A12.82 12.82 0 00275.17 288H12.83A12.82 12.82 0 000 300.83v38.34A12.82 12.82 0 0012.83 352zm0-256h262.34A12.82 12.82 0 00288 83.17V44.83A12.82 12.82 0 00275.17 32H12.83A12.82 12.82 0 000 44.83v38.34A12.82 12.82 0 0012.83 96zM432 160H16a16 16 0 00-16 16v32a16 16 0 0016 16h416a16 16 0 0016-16v-32a16 16 0 00-16-16zm0 256H16a16 16 0 00-16 16v32a16 16 0 0016 16h416a16 16 0 0016-16v-32a16 16 0 00-16-16z"
|
||||
fill={iconFillColor(theme)}
|
||||
/>,
|
||||
{ width: 448, height: 512 },
|
||||
),
|
||||
);
|
||||
|
||||
export const TextAlignCenterIcon = React.memo(
|
||||
({ theme }: { theme: "light" | "dark" }) =>
|
||||
createIcon(
|
||||
<path
|
||||
d="M432 160H16a16 16 0 00-16 16v32a16 16 0 0016 16h416a16 16 0 0016-16v-32a16 16 0 00-16-16zm0 256H16a16 16 0 00-16 16v32a16 16 0 0016 16h416a16 16 0 0016-16v-32a16 16 0 00-16-16zM108.1 96h231.81A12.09 12.09 0 00352 83.9V44.09A12.09 12.09 0 00339.91 32H108.1A12.09 12.09 0 0096 44.09V83.9A12.1 12.1 0 00108.1 96zm231.81 256A12.09 12.09 0 00352 339.9v-39.81A12.09 12.09 0 00339.91 288H108.1A12.09 12.09 0 0096 300.09v39.81a12.1 12.1 0 0012.1 12.1z"
|
||||
fill={iconFillColor(theme)}
|
||||
/>,
|
||||
{ width: 448, height: 512 },
|
||||
),
|
||||
);
|
||||
|
||||
export const TextAlignRightIcon = React.memo(
|
||||
({ theme }: { theme: "light" | "dark" }) =>
|
||||
createIcon(
|
||||
<path
|
||||
d="M16 224h416a16 16 0 0016-16v-32a16 16 0 00-16-16H16a16 16 0 00-16 16v32a16 16 0 0016 16zm416 192H16a16 16 0 00-16 16v32a16 16 0 0016 16h416a16 16 0 0016-16v-32a16 16 0 00-16-16zm3.17-384H172.83A12.82 12.82 0 00160 44.83v38.34A12.82 12.82 0 00172.83 96h262.34A12.82 12.82 0 00448 83.17V44.83A12.82 12.82 0 00435.17 32zm0 256H172.83A12.82 12.82 0 00160 300.83v38.34A12.82 12.82 0 00172.83 352h262.34A12.82 12.82 0 00448 339.17v-38.34A12.82 12.82 0 00435.17 288z"
|
||||
fill={iconFillColor(theme)}
|
||||
/>,
|
||||
{ width: 448, height: 512 },
|
||||
),
|
||||
);
|
||||
|
@@ -1,5 +1,6 @@
|
||||
import { FontFamily } from "./element/types";
|
||||
import cssVariables from "./css/variables.module.scss";
|
||||
import { AppProps } from "./types";
|
||||
|
||||
export const APP_NAME = "Excalidraw";
|
||||
|
||||
@@ -84,9 +85,15 @@ export const MIME_TYPES = {
|
||||
excalidrawlib: "application/vnd.excalidrawlib+json",
|
||||
};
|
||||
|
||||
export const EXPORT_DATA_TYPES = {
|
||||
excalidraw: "excalidraw",
|
||||
excalidrawClipboard: "excalidraw/clipboard",
|
||||
excalidrawLibrary: "excalidrawlib",
|
||||
} as const;
|
||||
|
||||
export const STORAGE_KEYS = {
|
||||
LOCAL_STORAGE_LIBRARY: "excalidraw-library",
|
||||
};
|
||||
} as const;
|
||||
|
||||
// time in milliseconds
|
||||
export const TAP_TWICE_TIMEOUT = 300;
|
||||
@@ -94,6 +101,7 @@ export const TOUCH_CTX_MENU_TIMEOUT = 500;
|
||||
export const TITLE_TIMEOUT = 10000;
|
||||
export const TOAST_TIMEOUT = 5000;
|
||||
export const VERSION_TIMEOUT = 30000;
|
||||
export const AUTO_SAVE_TIMEOUT = 500;
|
||||
export const SCROLL_TIMEOUT = 100;
|
||||
|
||||
export const ZOOM_STEP = 0.1;
|
||||
@@ -109,4 +117,24 @@ export const MODES = {
|
||||
GRID: "gridMode",
|
||||
};
|
||||
|
||||
export const APPEARANCE_FILTER = cssVariables.appearanceFilter;
|
||||
export const THEME_FILTER = cssVariables.themeFilter;
|
||||
|
||||
export const URL_QUERY_KEYS = {
|
||||
addLibrary: "addLibrary",
|
||||
} as const;
|
||||
|
||||
export const URL_HASH_KEYS = {
|
||||
addLibrary: "addLibrary",
|
||||
} as const;
|
||||
|
||||
export const DEFAULT_UI_OPTIONS: AppProps["UIOptions"] = {
|
||||
canvasActions: {
|
||||
changeViewBackgroundColor: true,
|
||||
clearCanvas: true,
|
||||
export: true,
|
||||
loadScene: true,
|
||||
saveAsScene: true,
|
||||
saveScene: true,
|
||||
theme: true,
|
||||
},
|
||||
};
|
||||
|
@@ -16,6 +16,15 @@
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
|
||||
// serves 2 purposes:
|
||||
// 1. prevent selecting text outside the component when double-clicking or
|
||||
// dragging inside it (e.g. on canvas)
|
||||
// 2. prevent selecting UI, both from the inside, and from outside the
|
||||
// component (e.g. if you select text in a sidebar)
|
||||
user-select: none;
|
||||
|
||||
a {
|
||||
font-weight: 500;
|
||||
@@ -29,7 +38,6 @@
|
||||
|
||||
canvas {
|
||||
touch-action: none;
|
||||
user-select: none;
|
||||
|
||||
// following props improve blurriness at certain devicePixelRatios.
|
||||
// AFAIK it doesn't affect export (in fact, export seems sharp either way).
|
||||
@@ -41,13 +49,19 @@
|
||||
z-index: var(--zIndex-canvas);
|
||||
}
|
||||
|
||||
&.Appearance_dark {
|
||||
#canvas {
|
||||
// Remove the main canvas from document flow to avoid resizeObserver
|
||||
// feedback loop (see https://github.com/excalidraw/excalidraw/pull/3379)
|
||||
position: absolute;
|
||||
}
|
||||
|
||||
&.theme--dark {
|
||||
// The percentage is inspired by
|
||||
// https://material.io/design/color/dark-theme.html#properties, which
|
||||
// recommends surface color of #121212, 93% yields #111111 for #FFF
|
||||
|
||||
canvas {
|
||||
filter: var(--appearance-filter);
|
||||
filter: var(--theme-filter);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -216,7 +230,8 @@
|
||||
align-items: center;
|
||||
svg {
|
||||
width: 36px;
|
||||
height: 18px;
|
||||
height: 14px;
|
||||
padding: 2px;
|
||||
opacity: 0.6;
|
||||
}
|
||||
&.active svg {
|
||||
@@ -447,6 +462,14 @@
|
||||
fill: $oc-gray-6;
|
||||
bottom: 14px;
|
||||
width: 1.5rem;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
background: none;
|
||||
color: var(--icon-fill-color);
|
||||
|
||||
&:hover {
|
||||
background: none;
|
||||
}
|
||||
|
||||
:root[dir="ltr"] & {
|
||||
right: 14px;
|
||||
@@ -575,14 +598,3 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.excalidraw-textEditorContainer {
|
||||
overflow: hidden;
|
||||
position: absolute;
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
pointer-events: none;
|
||||
textarea {
|
||||
pointer-events: all;
|
||||
}
|
||||
}
|
||||
|
@@ -2,7 +2,7 @@
|
||||
@import "./variables.module.scss";
|
||||
|
||||
.excalidraw {
|
||||
--appearance-filter: none;
|
||||
--theme-filter: none;
|
||||
--button-destructive-bg-color: #{$oc-red-1};
|
||||
--button-destructive-color: #{$oc-red-9};
|
||||
--button-gray-1: #{$oc-gray-2};
|
||||
@@ -35,16 +35,16 @@
|
||||
--space-factor: 0.25rem;
|
||||
--text-primary-color: #{$oc-gray-8};
|
||||
|
||||
&.Appearance_dark {
|
||||
&.theme--dark {
|
||||
background: $oc-black;
|
||||
|
||||
&.Appearance_dark-background-none {
|
||||
&.theme--dark-background-none {
|
||||
background: none;
|
||||
}
|
||||
}
|
||||
|
||||
&.Appearance_dark {
|
||||
--appearance-filter: #{$appearance-filter};
|
||||
&.theme--dark {
|
||||
--theme-filter: #{$theme-filter};
|
||||
--button-destructive-bg-color: #5a0000;
|
||||
--button-destructive-color: #{$oc-red-3};
|
||||
--button-gray-1: #363636;
|
||||
|
@@ -2,9 +2,9 @@
|
||||
|
||||
// keep up to date with is-mobile.tsx
|
||||
$is-mobile-query: "(max-width: 600px), (max-height: 500px) and (max-width: 1000px)";
|
||||
$appearance-filter: "invert(93%) hue-rotate(180deg)";
|
||||
$theme-filter: "invert(93%) hue-rotate(180deg)";
|
||||
|
||||
:export {
|
||||
isMobileQuery: unquote($is-mobile-query);
|
||||
appearanceFilter: unquote($appearance-filter);
|
||||
themeFilter: unquote($theme-filter);
|
||||
}
|
||||
|
@@ -1,5 +1,5 @@
|
||||
import { cleanAppStateForExport } from "../appState";
|
||||
import { MIME_TYPES } from "../constants";
|
||||
import { EXPORT_DATA_TYPES } from "../constants";
|
||||
import { clearElementsForExport } from "../element";
|
||||
import { CanvasError } from "../errors";
|
||||
import { t } from "../i18n";
|
||||
@@ -94,14 +94,8 @@ export const loadFromBlob = async (
|
||||
{
|
||||
elements: clearElementsForExport(data.elements || []),
|
||||
appState: {
|
||||
appearance: localAppState?.appearance,
|
||||
fileHandle:
|
||||
blob.handle &&
|
||||
["application/json", MIME_TYPES.excalidraw].includes(
|
||||
getMimeType(blob),
|
||||
)
|
||||
? blob.handle
|
||||
: null,
|
||||
theme: localAppState?.theme,
|
||||
fileHandle: (!blob.type.startsWith("image/") && blob.handle) || null,
|
||||
...cleanAppStateForExport(data.appState || {}),
|
||||
...(localAppState
|
||||
? calculateScrollCenter(data.elements || [], localAppState, null)
|
||||
@@ -121,7 +115,7 @@ export const loadFromBlob = async (
|
||||
export const loadLibraryFromBlob = async (blob: Blob) => {
|
||||
const contents = await parseFileContents(blob);
|
||||
const data: LibraryData = JSON.parse(contents);
|
||||
if (data.type !== "excalidrawlib") {
|
||||
if (data.type !== EXPORT_DATA_TYPES.excalidrawLibrary) {
|
||||
throw new Error(t("alerts.couldNotLoadInvalidFile"));
|
||||
}
|
||||
return data;
|
||||
|
@@ -2,7 +2,7 @@ import decodePng from "png-chunks-extract";
|
||||
import tEXt from "png-chunk-text";
|
||||
import encodePng from "png-chunks-encode";
|
||||
import { stringToBase64, encode, decode, base64ToString } from "./encode";
|
||||
import { MIME_TYPES } from "../constants";
|
||||
import { EXPORT_DATA_TYPES, MIME_TYPES } from "../constants";
|
||||
|
||||
// -----------------------------------------------------------------------------
|
||||
// PNG
|
||||
@@ -67,7 +67,10 @@ export const decodePngMetadata = async (blob: Blob) => {
|
||||
const encodedData = JSON.parse(metadata.text);
|
||||
if (!("encoded" in encodedData)) {
|
||||
// legacy, un-encoded scene JSON
|
||||
if ("type" in encodedData && encodedData.type === "excalidraw") {
|
||||
if (
|
||||
"type" in encodedData &&
|
||||
encodedData.type === EXPORT_DATA_TYPES.excalidraw
|
||||
) {
|
||||
return metadata.text;
|
||||
}
|
||||
throw new Error("FAILED");
|
||||
@@ -115,7 +118,10 @@ export const decodeSvgMetadata = async ({ svg }: { svg: string }) => {
|
||||
const encodedData = JSON.parse(json);
|
||||
if (!("encoded" in encodedData)) {
|
||||
// legacy, un-encoded scene JSON
|
||||
if ("type" in encodedData && encodedData.type === "excalidraw") {
|
||||
if (
|
||||
"type" in encodedData &&
|
||||
encodedData.type === EXPORT_DATA_TYPES.excalidraw
|
||||
) {
|
||||
return json;
|
||||
}
|
||||
throw new Error("FAILED");
|
||||
|
@@ -1,6 +1,6 @@
|
||||
import { fileOpen, fileSave } from "browser-fs-access";
|
||||
import { cleanAppStateForExport } from "../appState";
|
||||
import { MIME_TYPES } from "../constants";
|
||||
import { EXPORT_DATA_TYPES, MIME_TYPES } from "../constants";
|
||||
import { clearElementsForExport } from "../element";
|
||||
import { ExcalidrawElement } from "../element/types";
|
||||
import { AppState } from "../types";
|
||||
@@ -14,7 +14,7 @@ export const serializeAsJSON = (
|
||||
): string =>
|
||||
JSON.stringify(
|
||||
{
|
||||
type: "excalidraw",
|
||||
type: EXPORT_DATA_TYPES.excalidraw,
|
||||
version: 2,
|
||||
source: window.location.origin,
|
||||
elements: clearElementsForExport(elements),
|
||||
@@ -27,20 +27,22 @@ export const serializeAsJSON = (
|
||||
export const saveAsJSON = async (
|
||||
elements: readonly ExcalidrawElement[],
|
||||
appState: AppState,
|
||||
onlyIfFileHandleValid = false,
|
||||
) => {
|
||||
const serialized = serializeAsJSON(elements, appState);
|
||||
const blob = new Blob([serialized], {
|
||||
type: "application/json",
|
||||
type: MIME_TYPES.excalidraw,
|
||||
});
|
||||
|
||||
const fileHandle = await fileSave(
|
||||
blob,
|
||||
{
|
||||
fileName: appState.name,
|
||||
fileName: `${appState.name}.excalidraw`,
|
||||
description: "Excalidraw file",
|
||||
extensions: [".excalidraw"],
|
||||
},
|
||||
appState.fileHandle,
|
||||
onlyIfFileHandleValid,
|
||||
);
|
||||
return { fileHandle };
|
||||
};
|
||||
@@ -48,8 +50,17 @@ export const saveAsJSON = async (
|
||||
export const loadFromJSON = async (localAppState: AppState) => {
|
||||
const blob = await fileOpen({
|
||||
description: "Excalidraw files",
|
||||
// ToDo: Be over-permissive until https://bugs.webkit.org/show_bug.cgi?id=34442
|
||||
// gets resolved. Else, iOS users cannot open `.excalidraw` files.
|
||||
/*
|
||||
extensions: [".json", ".excalidraw", ".png", ".svg"],
|
||||
mimeTypes: ["application/json", "image/png", "image/svg+xml"],
|
||||
mimeTypes: [
|
||||
MIME_TYPES.excalidraw,
|
||||
"application/json",
|
||||
"image/png",
|
||||
"image/svg+xml",
|
||||
],
|
||||
*/
|
||||
});
|
||||
return loadFromBlob(blob, localAppState);
|
||||
};
|
||||
@@ -60,7 +71,7 @@ export const isValidExcalidrawData = (data?: {
|
||||
appState?: any;
|
||||
}): data is ImportedDataState => {
|
||||
return (
|
||||
data?.type === "excalidraw" &&
|
||||
data?.type === EXPORT_DATA_TYPES.excalidraw &&
|
||||
(!data.elements ||
|
||||
(Array.isArray(data.elements) &&
|
||||
(!data.appState || typeof data.appState === "object")))
|
||||
@@ -71,7 +82,7 @@ export const isValidLibrary = (json: any) => {
|
||||
return (
|
||||
typeof json === "object" &&
|
||||
json &&
|
||||
json.type === "excalidrawlib" &&
|
||||
json.type === EXPORT_DATA_TYPES.excalidrawLibrary &&
|
||||
json.version === 1
|
||||
);
|
||||
};
|
||||
@@ -80,7 +91,7 @@ export const saveLibraryAsJSON = async () => {
|
||||
const library = await Library.loadLibrary();
|
||||
const serialized = JSON.stringify(
|
||||
{
|
||||
type: "excalidrawlib",
|
||||
type: EXPORT_DATA_TYPES.excalidrawLibrary,
|
||||
version: 1,
|
||||
library,
|
||||
},
|
||||
@@ -101,8 +112,11 @@ export const saveLibraryAsJSON = async () => {
|
||||
export const importLibraryFromJSON = async () => {
|
||||
const blob = await fileOpen({
|
||||
description: "Excalidraw library files",
|
||||
// ToDo: Be over-permissive until https://bugs.webkit.org/show_bug.cgi?id=34442
|
||||
// gets resolved. Else, iOS users cannot open `.excalidraw` files.
|
||||
/*
|
||||
extensions: [".json", ".excalidrawlib"],
|
||||
mimeTypes: ["application/json"],
|
||||
*/
|
||||
});
|
||||
Library.importLibrary(blob);
|
||||
await Library.importLibrary(blob);
|
||||
};
|
||||
|
@@ -4,9 +4,11 @@ import { restoreElements } from "./restore";
|
||||
import { STORAGE_KEYS } from "../constants";
|
||||
import { getNonDeletedElements } from "../element";
|
||||
import { NonDeleted, ExcalidrawElement } from "../element/types";
|
||||
import { nanoid } from "nanoid";
|
||||
|
||||
export class Library {
|
||||
private static libraryCache: LibraryItems | null = null;
|
||||
public static csrfToken = nanoid();
|
||||
|
||||
static resetLibrary = () => {
|
||||
Library.libraryCache = null;
|
||||
|
@@ -144,7 +144,7 @@ export const restoreElements = (
|
||||
export const restoreAppState = (
|
||||
appState: ImportedDataState["appState"],
|
||||
localAppState: Partial<AppState> | null,
|
||||
): AppState => {
|
||||
): DataState["appState"] => {
|
||||
appState = appState || {};
|
||||
|
||||
const defaultAppState = getDefaultAppState();
|
||||
@@ -166,8 +166,6 @@ export const restoreAppState = (
|
||||
|
||||
return {
|
||||
...nextAppState,
|
||||
offsetLeft: appState.offsetLeft || 0,
|
||||
offsetTop: appState.offsetTop || 0,
|
||||
// Migrates from previous version where appState.zoom was a number
|
||||
zoom:
|
||||
typeof appState.zoom === "number"
|
||||
|
@@ -6,7 +6,7 @@ export interface DataState {
|
||||
version?: string;
|
||||
source?: string;
|
||||
elements: readonly ExcalidrawElement[];
|
||||
appState: MarkOptional<AppState, "offsetTop" | "offsetLeft">;
|
||||
appState: Omit<AppState, "offsetTop" | "offsetLeft" | "width" | "height">;
|
||||
}
|
||||
|
||||
export interface ImportedDataState {
|
||||
@@ -15,7 +15,7 @@ export interface ImportedDataState {
|
||||
source?: string;
|
||||
elements?: DataState["elements"] | null;
|
||||
appState?: Partial<DataState["appState"]> | null;
|
||||
scrollToCenter?: boolean;
|
||||
scrollToContent?: boolean;
|
||||
}
|
||||
|
||||
export interface LibraryData {
|
||||
|
@@ -87,9 +87,30 @@ export const mutateElement = <TElement extends Mutable<ExcalidrawElement>>(
|
||||
export const newElementWith = <TElement extends ExcalidrawElement>(
|
||||
element: TElement,
|
||||
updates: ElementUpdate<TElement>,
|
||||
): TElement => ({
|
||||
...element,
|
||||
...updates,
|
||||
version: element.version + 1,
|
||||
versionNonce: randomInteger(),
|
||||
});
|
||||
): TElement => {
|
||||
let didChange = false;
|
||||
for (const key in updates) {
|
||||
const value = (updates as any)[key];
|
||||
if (typeof value !== "undefined") {
|
||||
if (
|
||||
(element as any)[key] === value &&
|
||||
// if object, always update in case its deep prop was mutated
|
||||
(typeof value !== "object" || value === null || key === "groupIds")
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
didChange = true;
|
||||
}
|
||||
}
|
||||
|
||||
if (!didChange) {
|
||||
return element;
|
||||
}
|
||||
|
||||
return {
|
||||
...element,
|
||||
...updates,
|
||||
version: element.version + 1,
|
||||
versionNonce: randomInteger(),
|
||||
};
|
||||
};
|
||||
|
@@ -31,7 +31,7 @@ import {
|
||||
import { PointerDownState } from "../components/App";
|
||||
import { Point } from "../types";
|
||||
|
||||
const normalizeAngle = (angle: number): number => {
|
||||
export const normalizeAngle = (angle: number): number => {
|
||||
if (angle >= 2 * Math.PI) {
|
||||
return angle - 2 * Math.PI;
|
||||
}
|
||||
@@ -181,7 +181,7 @@ const getPerfectElementSizeWithRotation = (
|
||||
return rotate(size.width, size.height, 0, 0, -angle);
|
||||
};
|
||||
|
||||
const reshapeSingleTwoPointElement = (
|
||||
export const reshapeSingleTwoPointElement = (
|
||||
element: NonDeleted<ExcalidrawLinearElement>,
|
||||
resizeArrowDirection: "origin" | "end",
|
||||
isRotateWithDiscreteAngle: boolean,
|
||||
@@ -378,7 +378,7 @@ const resizeSingleTextElement = (
|
||||
}
|
||||
};
|
||||
|
||||
const resizeSingleElement = (
|
||||
export const resizeSingleElement = (
|
||||
stateAtResizeStart: NonDeletedExcalidrawElement,
|
||||
shouldKeepSidesRatio: boolean,
|
||||
element: NonDeletedExcalidrawElement,
|
||||
|
@@ -21,14 +21,18 @@ const getTransform = (
|
||||
height: number,
|
||||
angle: number,
|
||||
appState: AppState,
|
||||
maxWidth: number,
|
||||
) => {
|
||||
const { zoom, offsetTop, offsetLeft } = appState;
|
||||
const degree = (180 * angle) / Math.PI;
|
||||
// offsets must be multiplied by 2 to account for the division by 2 of
|
||||
// the whole expression afterwards
|
||||
return `translate(${((width - offsetLeft * 2) * (zoom.value - 1)) / 2}px, ${
|
||||
((height - offsetTop * 2) * (zoom.value - 1)) / 2
|
||||
}px) scale(${zoom.value}) rotate(${degree}deg)`;
|
||||
let translateX = ((width - offsetLeft * 2) * (zoom.value - 1)) / 2;
|
||||
const translateY = ((height - offsetTop * 2) * (zoom.value - 1)) / 2;
|
||||
if (width > maxWidth && zoom.value !== 1) {
|
||||
translateX = (maxWidth / 2) * (zoom.value - 1);
|
||||
}
|
||||
return `translate(${translateX}px, ${translateY}px) scale(${zoom.value}) rotate(${degree}deg)`;
|
||||
};
|
||||
|
||||
export const textWysiwyg = ({
|
||||
@@ -43,7 +47,7 @@ export const textWysiwyg = ({
|
||||
id: ExcalidrawElement["id"];
|
||||
appState: AppState;
|
||||
onChange?: (text: string) => void;
|
||||
onSubmit: (text: string) => void;
|
||||
onSubmit: (data: { text: string; viaKeyboard: boolean }) => void;
|
||||
getViewportCoords: (x: number, y: number) => [number, number];
|
||||
element: ExcalidrawElement;
|
||||
canvas: HTMLCanvasElement | null;
|
||||
@@ -61,6 +65,15 @@ export const textWysiwyg = ({
|
||||
|
||||
const lines = updatedElement.text.replace(/\r\n?/g, "\n").split("\n");
|
||||
const lineHeight = updatedElement.height / lines.length;
|
||||
const maxWidth =
|
||||
(appState.offsetLeft + appState.width - viewportX - 8) /
|
||||
appState.zoom.value -
|
||||
// margin-right of parent if any
|
||||
Number(
|
||||
getComputedStyle(
|
||||
document.querySelector(".excalidraw")!.parentNode as Element,
|
||||
).marginRight.slice(0, -2),
|
||||
);
|
||||
|
||||
Object.assign(editable.style, {
|
||||
font: getFontString(updatedElement),
|
||||
@@ -75,11 +88,13 @@ export const textWysiwyg = ({
|
||||
updatedElement.height,
|
||||
angle,
|
||||
appState,
|
||||
maxWidth,
|
||||
),
|
||||
textAlign,
|
||||
color: updatedElement.strokeColor,
|
||||
opacity: updatedElement.opacity / 100,
|
||||
filter: "var(--appearance-filter)",
|
||||
filter: "var(--theme-filter)",
|
||||
maxWidth: `${maxWidth}px`,
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -93,7 +108,7 @@ export const textWysiwyg = ({
|
||||
editable.wrap = "off";
|
||||
|
||||
Object.assign(editable.style, {
|
||||
position: "relative",
|
||||
position: "absolute",
|
||||
display: "inline-block",
|
||||
minHeight: "1em",
|
||||
backfaceVisibility: "hidden",
|
||||
@@ -121,12 +136,14 @@ export const textWysiwyg = ({
|
||||
editable.onkeydown = (event) => {
|
||||
if (event.key === KEYS.ESCAPE) {
|
||||
event.preventDefault();
|
||||
submittedViaKeyboard = true;
|
||||
handleSubmit();
|
||||
} else if (event.key === KEYS.ENTER && event[KEYS.CTRL_OR_CMD]) {
|
||||
event.preventDefault();
|
||||
if (event.isComposing || event.keyCode === 229) {
|
||||
return;
|
||||
}
|
||||
submittedViaKeyboard = true;
|
||||
handleSubmit();
|
||||
} else if (event.key === KEYS.ENTER && !event.altKey) {
|
||||
event.stopPropagation();
|
||||
@@ -138,8 +155,14 @@ export const textWysiwyg = ({
|
||||
event.stopPropagation();
|
||||
};
|
||||
|
||||
// using a state variable instead of passing it to the handleSubmit callback
|
||||
// so that we don't need to create separate a callback for event handlers
|
||||
let submittedViaKeyboard = false;
|
||||
const handleSubmit = () => {
|
||||
onSubmit(normalizeText(editable.value));
|
||||
onSubmit({
|
||||
text: normalizeText(editable.value),
|
||||
viaKeyboard: submittedViaKeyboard,
|
||||
});
|
||||
cleanup();
|
||||
};
|
||||
|
||||
@@ -160,7 +183,7 @@ export const textWysiwyg = ({
|
||||
window.removeEventListener("resize", updateWysiwygStyle);
|
||||
window.removeEventListener("wheel", stopEvent, true);
|
||||
window.removeEventListener("pointerdown", onPointerDown);
|
||||
window.removeEventListener("pointerup", rebindBlur);
|
||||
window.removeEventListener("pointerup", bindBlurEvent);
|
||||
window.removeEventListener("blur", handleSubmit);
|
||||
|
||||
unbindUpdate();
|
||||
@@ -168,10 +191,12 @@ export const textWysiwyg = ({
|
||||
editable.remove();
|
||||
};
|
||||
|
||||
const rebindBlur = () => {
|
||||
window.removeEventListener("pointerup", rebindBlur);
|
||||
// deferred to guard against focus traps on various UIs that steal focus
|
||||
// upon pointerUp
|
||||
const bindBlurEvent = () => {
|
||||
window.removeEventListener("pointerup", bindBlurEvent);
|
||||
// Deferred so that the pointerdown that initiates the wysiwyg doesn't
|
||||
// trigger the blur on ensuing pointerup.
|
||||
// Also to handle cases such as picking a color which would trigger a blur
|
||||
// in that same tick.
|
||||
setTimeout(() => {
|
||||
editable.onblur = handleSubmit;
|
||||
// case: clicking on the same property → no change → no update → no focus
|
||||
@@ -182,12 +207,13 @@ export const textWysiwyg = ({
|
||||
// prevent blur when changing properties from the menu
|
||||
const onPointerDown = (event: MouseEvent) => {
|
||||
if (
|
||||
event.target instanceof HTMLElement &&
|
||||
(event.target instanceof HTMLElement ||
|
||||
event.target instanceof SVGElement) &&
|
||||
event.target.closest(`.${CLASSES.SHAPE_ACTIONS_MENU}`) &&
|
||||
!isWritableElement(event.target)
|
||||
) {
|
||||
editable.onblur = null;
|
||||
window.addEventListener("pointerup", rebindBlur);
|
||||
window.addEventListener("pointerup", bindBlurEvent);
|
||||
// handle edge-case where pointerup doesn't fire e.g. due to user
|
||||
// alt-tabbing away
|
||||
window.addEventListener("blur", handleSubmit);
|
||||
@@ -200,9 +226,14 @@ export const textWysiwyg = ({
|
||||
editable.focus();
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
let isDestroyed = false;
|
||||
|
||||
editable.onblur = handleSubmit;
|
||||
// select on init (focusing is done separately inside the bindBlurEvent()
|
||||
// because we need it to happen *after* the blur event from `pointerdown`)
|
||||
editable.select();
|
||||
bindBlurEvent();
|
||||
|
||||
// reposition wysiwyg in case of canvas is resized. Using ResizeObserver
|
||||
// is preferred so we catch changes from host, where window may not resize.
|
||||
@@ -224,6 +255,4 @@ export const textWysiwyg = ({
|
||||
document
|
||||
.querySelector(".excalidraw-textEditorContainer")!
|
||||
.appendChild(editable);
|
||||
editable.focus();
|
||||
editable.select();
|
||||
};
|
||||
|
85
src/excalidraw-app/CustomStats.tsx
Normal file
@@ -0,0 +1,85 @@
|
||||
import React, { useEffect, useState } from "react";
|
||||
import { debounce, getVersion, nFormatter } from "../utils";
|
||||
import {
|
||||
getElementsStorageSize,
|
||||
getTotalStorageSize,
|
||||
} from "./data/localStorage";
|
||||
import { DEFAULT_VERSION } from "../constants";
|
||||
import { t } from "../i18n";
|
||||
import { copyTextToSystemClipboard } from "../clipboard";
|
||||
type StorageSizes = { scene: number; total: number };
|
||||
|
||||
const STORAGE_SIZE_TIMEOUT = 500;
|
||||
|
||||
const getStorageSizes = debounce((cb: (sizes: StorageSizes) => void) => {
|
||||
cb({
|
||||
scene: getElementsStorageSize(),
|
||||
total: getTotalStorageSize(),
|
||||
});
|
||||
}, STORAGE_SIZE_TIMEOUT);
|
||||
|
||||
type Props = {
|
||||
setToastMessage: (message: string) => void;
|
||||
};
|
||||
const CustomStats = (props: Props) => {
|
||||
const [storageSizes, setStorageSizes] = useState<StorageSizes>({
|
||||
scene: 0,
|
||||
total: 0,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
getStorageSizes((sizes) => {
|
||||
setStorageSizes(sizes);
|
||||
});
|
||||
});
|
||||
useEffect(() => () => getStorageSizes.cancel(), []);
|
||||
|
||||
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 (
|
||||
<>
|
||||
<tr>
|
||||
<th colSpan={2}>{t("stats.storage")}</th>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>{t("stats.scene")}</td>
|
||||
<td>{nFormatter(storageSizes.scene, 1)}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>{t("stats.total")}</td>
|
||||
<td>{nFormatter(storageSizes.total, 1)}</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.setToastMessage(t("toast.copyToClipboard"));
|
||||
} catch {}
|
||||
}}
|
||||
title={t("stats.versionCopy")}
|
||||
>
|
||||
{timestamp}
|
||||
<br />
|
||||
{hash}
|
||||
</td>
|
||||
</tr>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default CustomStats;
|
@@ -38,7 +38,7 @@ import Portal from "./Portal";
|
||||
import RoomDialog from "./RoomDialog";
|
||||
import { createInverseContext } from "../../createInverseContext";
|
||||
import { t } from "../../i18n";
|
||||
import { UserIdleState } from "./types";
|
||||
import { UserIdleState } from "../../types";
|
||||
import { IDLE_THRESHOLD, ACTIVE_THRESHOLD } from "../../constants";
|
||||
import { trackEvent } from "../../analytics";
|
||||
|
||||
@@ -113,8 +113,8 @@ class CollabWrapper extends PureComponent<Props, CollabState> {
|
||||
process.env.NODE_ENV === ENV.TEST ||
|
||||
process.env.NODE_ENV === ENV.DEVELOPMENT
|
||||
) {
|
||||
window.h = window.h || ({} as Window["h"]);
|
||||
Object.defineProperties(window.h, {
|
||||
window.collab = window.collab || ({} as Window["collab"]);
|
||||
Object.defineProperties(window, {
|
||||
collab: {
|
||||
configurable: true,
|
||||
value: this,
|
||||
@@ -259,7 +259,7 @@ class CollabWrapper extends PureComponent<Props, CollabState> {
|
||||
if (elements) {
|
||||
scenePromise.resolve({
|
||||
elements,
|
||||
scrollToCenter: true,
|
||||
scrollToContent: true,
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
@@ -313,7 +313,7 @@ class CollabWrapper extends PureComponent<Props, CollabState> {
|
||||
// noop if already resolved via init from firebase
|
||||
scenePromise.resolve({
|
||||
elements: reconciledElements,
|
||||
scrollToCenter: true,
|
||||
scrollToContent: true,
|
||||
});
|
||||
}
|
||||
break;
|
||||
@@ -448,15 +448,8 @@ class CollabWrapper extends PureComponent<Props, CollabState> {
|
||||
|
||||
private handleRemoteSceneUpdate = (
|
||||
elements: ReconciledElements,
|
||||
{
|
||||
init = false,
|
||||
initFromSnapshot = false,
|
||||
}: { init?: boolean; initFromSnapshot?: boolean } = {},
|
||||
{ init = false }: { init?: boolean } = {},
|
||||
) => {
|
||||
if (init || initFromSnapshot) {
|
||||
this.excalidrawAPI.setScrollToCenter(elements);
|
||||
}
|
||||
|
||||
this.excalidrawAPI.updateScene({
|
||||
elements,
|
||||
commitToHistory: !!init,
|
||||
@@ -665,4 +658,17 @@ class CollabWrapper extends PureComponent<Props, CollabState> {
|
||||
}
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
collab: InstanceType<typeof CollabWrapper>;
|
||||
}
|
||||
}
|
||||
|
||||
if (
|
||||
process.env.NODE_ENV === ENV.TEST ||
|
||||
process.env.NODE_ENV === ENV.DEVELOPMENT
|
||||
) {
|
||||
window.collab = window.collab || ({} as Window["collab"]);
|
||||
}
|
||||
|
||||
export default CollabWrapper;
|
||||
|
@@ -9,7 +9,7 @@ import CollabWrapper from "./CollabWrapper";
|
||||
import { getSyncableElements } from "../../packages/excalidraw/index";
|
||||
import { ExcalidrawElement } from "../../element/types";
|
||||
import { BROADCAST, SCENE } from "../app_constants";
|
||||
import { UserIdleState } from "./types";
|
||||
import { UserIdleState } from "../../types";
|
||||
import { trackEvent } from "../../analytics";
|
||||
|
||||
class Portal {
|
||||
|
@@ -7,12 +7,27 @@ import {
|
||||
stop,
|
||||
share,
|
||||
shareIOS,
|
||||
shareWindows,
|
||||
} from "../../components/icons";
|
||||
import { ToolButton } from "../../components/ToolButton";
|
||||
import { t } from "../../i18n";
|
||||
import "./RoomDialog.scss";
|
||||
import Stack from "../../components/Stack";
|
||||
|
||||
const getShareIcon = () => {
|
||||
const navigator = window.navigator as any;
|
||||
const isAppleBrowser = /Apple/.test(navigator.vendor);
|
||||
const isWindowsBrowser = navigator.appVersion.indexOf("Win") !== -1;
|
||||
|
||||
if (isAppleBrowser) {
|
||||
return shareIOS;
|
||||
} else if (isWindowsBrowser) {
|
||||
return shareWindows;
|
||||
}
|
||||
|
||||
return share;
|
||||
};
|
||||
|
||||
const RoomDialog = ({
|
||||
handleClose,
|
||||
activeRoomLink,
|
||||
@@ -31,8 +46,6 @@ const RoomDialog = ({
|
||||
setErrorMessage: (message: string) => void;
|
||||
}) => {
|
||||
const roomLinkInput = useRef<HTMLInputElement>(null);
|
||||
const navigator = window.navigator as any;
|
||||
const isAppleBrowser = /Apple/.test(navigator.vendor);
|
||||
|
||||
const copyRoomLink = async () => {
|
||||
try {
|
||||
@@ -93,7 +106,7 @@ const RoomDialog = ({
|
||||
{"share" in navigator ? (
|
||||
<ToolButton
|
||||
type="button"
|
||||
icon={isAppleBrowser ? shareIOS : share}
|
||||
icon={getShareIcon()}
|
||||
title={t("labels.share")}
|
||||
aria-label={t("labels.share")}
|
||||
onClick={shareRoomLink}
|
||||
|
@@ -1,5 +0,0 @@
|
||||
export enum UserIdleState {
|
||||
ACTIVE = "active",
|
||||
AWAY = "away",
|
||||
IDLE = "idle",
|
||||
}
|
@@ -3,8 +3,7 @@ import { restore } from "../../data/restore";
|
||||
import { ImportedDataState } from "../../data/types";
|
||||
import { ExcalidrawElement } from "../../element/types";
|
||||
import { t } from "../../i18n";
|
||||
import { AppState } from "../../types";
|
||||
import { UserIdleState } from "../collab/types";
|
||||
import { AppState, UserIdleState } from "../../types";
|
||||
|
||||
const byteToHex = (byte: number): string => `0${byte.toString(16)}`.slice(-2);
|
||||
|
||||
@@ -80,8 +79,10 @@ export type SocketUpdateData = SocketUpdateDataSource[keyof SocketUpdateDataSour
|
||||
_brand: "socketUpdateData";
|
||||
};
|
||||
|
||||
const IV_LENGTH_BYTES = 12; // 96 bits
|
||||
|
||||
export const createIV = () => {
|
||||
const arr = new Uint8Array(12);
|
||||
const arr = new Uint8Array(IV_LENGTH_BYTES);
|
||||
return window.crypto.getRandomValues(arr);
|
||||
};
|
||||
|
||||
@@ -175,6 +176,22 @@ export const getImportedKey = (key: string, usage: KeyUsage) =>
|
||||
[usage],
|
||||
);
|
||||
|
||||
const decryptImported = async (
|
||||
iv: ArrayBuffer,
|
||||
encrypted: ArrayBuffer,
|
||||
privateKey: string,
|
||||
): Promise<ArrayBuffer> => {
|
||||
const key = await getImportedKey(privateKey, "decrypt");
|
||||
return window.crypto.subtle.decrypt(
|
||||
{
|
||||
name: "AES-GCM",
|
||||
iv,
|
||||
},
|
||||
key,
|
||||
encrypted,
|
||||
);
|
||||
};
|
||||
|
||||
const importFromBackend = async (
|
||||
id: string | null,
|
||||
privateKey?: string | null,
|
||||
@@ -183,6 +200,7 @@ const importFromBackend = async (
|
||||
const response = await fetch(
|
||||
privateKey ? `${BACKEND_V2_GET}${id}` : `${BACKEND_GET}${id}.json`,
|
||||
);
|
||||
|
||||
if (!response.ok) {
|
||||
window.alert(t("alerts.importBackendFailed"));
|
||||
return {};
|
||||
@@ -190,16 +208,19 @@ const importFromBackend = async (
|
||||
let data: ImportedDataState;
|
||||
if (privateKey) {
|
||||
const buffer = await response.arrayBuffer();
|
||||
const key = await getImportedKey(privateKey, "decrypt");
|
||||
const iv = new Uint8Array(12);
|
||||
const decrypted = await window.crypto.subtle.decrypt(
|
||||
{
|
||||
name: "AES-GCM",
|
||||
iv,
|
||||
},
|
||||
key,
|
||||
buffer,
|
||||
);
|
||||
|
||||
let decrypted: ArrayBuffer;
|
||||
try {
|
||||
// Buffer should contain both the IV (fixed length) and encrypted data
|
||||
const iv = buffer.slice(0, IV_LENGTH_BYTES);
|
||||
const encrypted = buffer.slice(IV_LENGTH_BYTES, buffer.byteLength);
|
||||
decrypted = await decryptImported(iv, encrypted, privateKey);
|
||||
} catch (error) {
|
||||
// Fixed IV (old format, backward compatibility)
|
||||
const fixedIv = new Uint8Array(IV_LENGTH_BYTES);
|
||||
decrypted = await decryptImported(fixedIv, buffer, privateKey);
|
||||
}
|
||||
|
||||
// We need to convert the decrypted array buffer to a string
|
||||
const string = new window.TextDecoder("utf-8").decode(
|
||||
new Uint8Array(decrypted) as any,
|
||||
@@ -263,9 +284,8 @@ export const exportToBackend = async (
|
||||
true, // extractable
|
||||
["encrypt", "decrypt"],
|
||||
);
|
||||
// The iv is set to 0. We are never going to reuse the same key so we don't
|
||||
// need to have an iv. (I hope that's correct...)
|
||||
const iv = new Uint8Array(12);
|
||||
|
||||
const iv = createIV();
|
||||
// We use symmetric encryption. AES-GCM is the recommended algorithm and
|
||||
// includes checks that the ciphertext has not been modified by an attacker.
|
||||
const encrypted = await window.crypto.subtle.encrypt(
|
||||
@@ -276,6 +296,11 @@ export const exportToBackend = async (
|
||||
key,
|
||||
encoded,
|
||||
);
|
||||
|
||||
// Concatenate IV with encrypted data (IV does not have to be secret).
|
||||
const payloadBlob = new Blob([iv.buffer, encrypted]);
|
||||
const payload = await new Response(payloadBlob).arrayBuffer();
|
||||
|
||||
// We use jwk encoding to be able to extract just the base64 encoded key.
|
||||
// We will hardcode the rest of the attributes when importing back the key.
|
||||
const exportedKey = await window.crypto.subtle.exportKey("jwk", key);
|
||||
@@ -283,7 +308,7 @@ export const exportToBackend = async (
|
||||
try {
|
||||
const response = await fetch(BACKEND_V2_POST, {
|
||||
method: "POST",
|
||||
body: encrypted,
|
||||
body: payload,
|
||||
});
|
||||
const json = await response.json();
|
||||
if (json.id) {
|
||||
|
@@ -101,7 +101,7 @@ export const importFromLocalStorage = () => {
|
||||
export const getElementsStorageSize = () => {
|
||||
try {
|
||||
const elements = localStorage.getItem(STORAGE_KEYS.LOCAL_STORAGE_ELEMENTS);
|
||||
const elementsSize = elements ? JSON.stringify(elements).length : 0;
|
||||
const elementsSize = elements?.length || 0;
|
||||
return elementsSize;
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
@@ -117,9 +117,9 @@ export const getTotalStorageSize = () => {
|
||||
APP_STORAGE_KEYS.LOCAL_STORAGE_LIBRARY,
|
||||
);
|
||||
|
||||
const appStateSize = appState ? JSON.stringify(appState).length : 0;
|
||||
const collabSize = collab ? JSON.stringify(collab).length : 0;
|
||||
const librarySize = library ? JSON.stringify(library).length : 0;
|
||||
const appStateSize = appState?.length || 0;
|
||||
const collabSize = collab?.length || 0;
|
||||
const librarySize = library?.length || 0;
|
||||
|
||||
return appStateSize + collabSize + librarySize + getElementsStorageSize();
|
||||
} catch (error) {
|
||||
|
@@ -3,7 +3,6 @@ import React, {
|
||||
useCallback,
|
||||
useContext,
|
||||
useEffect,
|
||||
useLayoutEffect,
|
||||
useRef,
|
||||
useState,
|
||||
} from "react";
|
||||
@@ -12,7 +11,13 @@ import { getDefaultAppState } from "../appState";
|
||||
import { ExcalidrawImperativeAPI } from "../components/App";
|
||||
import { ErrorDialog } from "../components/ErrorDialog";
|
||||
import { TopErrorBoundary } from "../components/TopErrorBoundary";
|
||||
import { APP_NAME, EVENT, TITLE_TIMEOUT, VERSION_TIMEOUT } from "../constants";
|
||||
import {
|
||||
APP_NAME,
|
||||
EVENT,
|
||||
TITLE_TIMEOUT,
|
||||
URL_HASH_KEYS,
|
||||
VERSION_TIMEOUT,
|
||||
} from "../constants";
|
||||
import { loadFromBlob } from "../data/blob";
|
||||
import { DataState, ImportedDataState } from "../data/types";
|
||||
import {
|
||||
@@ -44,6 +49,7 @@ import {
|
||||
importFromLocalStorage,
|
||||
saveToLocalStorage,
|
||||
} from "./data/localStorage";
|
||||
import CustomStats from "./CustomStats";
|
||||
|
||||
const languageDetector = new LanguageDetector();
|
||||
languageDetector.init({
|
||||
@@ -77,7 +83,7 @@ const initializeScene = async (opts: {
|
||||
|
||||
const initialData = importFromLocalStorage();
|
||||
|
||||
let scene: DataState & { scrollToCenter?: boolean } = await loadScene(
|
||||
let scene: DataState & { scrollToContent?: boolean } = await loadScene(
|
||||
null,
|
||||
null,
|
||||
initialData,
|
||||
@@ -104,7 +110,7 @@ const initializeScene = async (opts: {
|
||||
initialData,
|
||||
);
|
||||
}
|
||||
scene.scrollToCenter = true;
|
||||
scene.scrollToContent = true;
|
||||
if (!roomLinkData) {
|
||||
window.history.replaceState({}, APP_NAME, window.location.origin);
|
||||
}
|
||||
@@ -155,31 +161,11 @@ const initializeScene = async (opts: {
|
||||
return null;
|
||||
};
|
||||
|
||||
function ExcalidrawWrapper() {
|
||||
// dimensions
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const [dimensions, setDimensions] = useState({
|
||||
width: window.innerWidth,
|
||||
height: window.innerHeight,
|
||||
});
|
||||
const ExcalidrawWrapper = () => {
|
||||
const [errorMessage, setErrorMessage] = useState("");
|
||||
const currentLangCode = languageDetector.detect() || defaultLang.code;
|
||||
const [langCode, setLangCode] = useState(currentLangCode);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
const onResize = () => {
|
||||
setDimensions({
|
||||
width: window.innerWidth,
|
||||
height: window.innerHeight,
|
||||
});
|
||||
};
|
||||
|
||||
window.addEventListener("resize", onResize);
|
||||
|
||||
return () => window.removeEventListener("resize", onResize);
|
||||
}, []);
|
||||
|
||||
// initial state
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
@@ -213,12 +199,24 @@ function ExcalidrawWrapper() {
|
||||
initialStatePromiseRef.current.promise.resolve(scene);
|
||||
});
|
||||
|
||||
const onHashChange = (_: HashChangeEvent) => {
|
||||
initializeScene({ collabAPI }).then((scene) => {
|
||||
if (scene) {
|
||||
excalidrawAPI.updateScene(scene);
|
||||
}
|
||||
});
|
||||
const onHashChange = (event: HashChangeEvent) => {
|
||||
event.preventDefault();
|
||||
const hash = new URLSearchParams(window.location.hash.slice(1));
|
||||
const libraryUrl = hash.get(URL_HASH_KEYS.addLibrary);
|
||||
if (libraryUrl) {
|
||||
// If hash changed and it contains library url, import it and replace
|
||||
// the url to its previous state (important in case of collaboration
|
||||
// and similar).
|
||||
// Using history API won't trigger another hashchange.
|
||||
window.history.replaceState({}, "", event.oldURL);
|
||||
excalidrawAPI.importLibrary(libraryUrl, hash.get("token"));
|
||||
} else {
|
||||
initializeScene({ collabAPI }).then((scene) => {
|
||||
if (scene) {
|
||||
excalidrawAPI.updateScene(scene);
|
||||
}
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const titleTimeout = setTimeout(
|
||||
@@ -305,13 +303,19 @@ function ExcalidrawWrapper() {
|
||||
[langCode],
|
||||
);
|
||||
|
||||
const renderCustomStats = () => {
|
||||
return (
|
||||
<CustomStats
|
||||
setToastMessage={(message) => excalidrawAPI!.setToastMessage(message)}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<Excalidraw
|
||||
ref={excalidrawRefCallback}
|
||||
onChange={onChange}
|
||||
width={dimensions.width}
|
||||
height={dimensions.height}
|
||||
initialData={initialStatePromiseRef.current.promise}
|
||||
onCollabButtonClick={collabAPI?.onCollabButtonClick}
|
||||
isCollaborating={collabAPI?.isCollaborating()}
|
||||
@@ -319,6 +323,7 @@ function ExcalidrawWrapper() {
|
||||
onExportToBackend={onExportToBackend}
|
||||
renderFooter={renderFooter}
|
||||
langCode={langCode}
|
||||
renderCustomStats={renderCustomStats}
|
||||
/>
|
||||
{excalidrawAPI && <CollabWrapper excalidrawAPI={excalidrawAPI} />}
|
||||
{errorMessage && (
|
||||
@@ -329,9 +334,9 @@ function ExcalidrawWrapper() {
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
export default function ExcalidrawApp() {
|
||||
const ExcalidrawApp = () => {
|
||||
return (
|
||||
<TopErrorBoundary>
|
||||
<CollabContextConsumer>
|
||||
@@ -339,4 +344,6 @@ export default function ExcalidrawApp() {
|
||||
</CollabContextConsumer>
|
||||
</TopErrorBoundary>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
export default ExcalidrawApp;
|
||||
|
2
src/global.d.ts
vendored
@@ -89,3 +89,5 @@ interface Blob {
|
||||
handle?: import("browser-fs-acces").FileSystemHandle;
|
||||
name?: string;
|
||||
}
|
||||
|
||||
declare module "*.scss";
|
||||
|
@@ -63,6 +63,8 @@ const canvas = exportToCanvas(
|
||||
...getDefaultAppState(),
|
||||
offsetTop: 0,
|
||||
offsetLeft: 0,
|
||||
width: 0,
|
||||
height: 0,
|
||||
},
|
||||
{
|
||||
exportBackground: true,
|
||||
|
@@ -34,7 +34,4 @@ export const IsMobileProvider = ({
|
||||
};
|
||||
|
||||
export const isMobile = () => getIsMobileMatcher().matches;
|
||||
|
||||
export default function useIsMobile() {
|
||||
return useContext(context);
|
||||
}
|
||||
export const useIsMobile = () => useContext(context);
|
||||
|
@@ -40,7 +40,6 @@ export const KEYS = {
|
||||
TAB: "Tab",
|
||||
|
||||
A: "a",
|
||||
C: "c",
|
||||
D: "d",
|
||||
E: "e",
|
||||
L: "l",
|
||||
|
@@ -61,7 +61,7 @@
|
||||
"architect": "معماري",
|
||||
"artist": "رسام",
|
||||
"cartoonist": "كرتوني",
|
||||
"fileTitle": "عنوان الملف",
|
||||
"fileTitle": "",
|
||||
"colorPicker": "اختيار الألوان",
|
||||
"canvasBackground": "خلفية اللوحة",
|
||||
"drawingCanvas": "لوحة الرسم",
|
||||
@@ -77,7 +77,7 @@
|
||||
"group": "تحديد مجموعة",
|
||||
"ungroup": "إلغاء تحديد مجموعة",
|
||||
"collaborators": "المتعاونون",
|
||||
"showGrid": "",
|
||||
"showGrid": "إظهار الشبكة",
|
||||
"addToLibrary": "أضف إلى المكتبة",
|
||||
"removeFromLibrary": "حذف من المكتبة",
|
||||
"libraryLoadingMessage": "جارٍ تحميل المكتبة…",
|
||||
@@ -92,8 +92,11 @@
|
||||
"centerHorizontally": "توسيط أفقي",
|
||||
"distributeHorizontally": "التوزيع الأفقي",
|
||||
"distributeVertically": "التوزيع عمودياً",
|
||||
"viewMode": "",
|
||||
"toggleExportColorScheme": ""
|
||||
"flipHorizontal": "",
|
||||
"flipVertical": "",
|
||||
"viewMode": "نمط العرض",
|
||||
"toggleExportColorScheme": "",
|
||||
"share": "مشاركة"
|
||||
},
|
||||
"buttons": {
|
||||
"clearReset": "إعادة تعيين اللوحة",
|
||||
@@ -118,7 +121,7 @@
|
||||
"edit": "تعديل",
|
||||
"undo": "تراجع",
|
||||
"redo": "إعادة تنفيذ",
|
||||
"resetLibrary": "",
|
||||
"resetLibrary": "إعادة ضبط المكتبة",
|
||||
"createNewRoom": "إنشاء غرفة جديدة",
|
||||
"fullScreen": "شاشة كاملة",
|
||||
"darkMode": "الوضع المظلم",
|
||||
@@ -137,7 +140,7 @@
|
||||
"decryptFailed": "تعذر فك تشفير البيانات.",
|
||||
"uploadedSecurly": "تم تأمين التحميل بتشفير النهاية إلى النهاية، مما يعني أن خادوم Excalidraw والأطراف الثالثة لا يمكنها قراءة المحتوى.",
|
||||
"loadSceneOverridePrompt": "تحميل الرسم الخارجي سيحل محل المحتوى الموجود لديك. هل ترغب في المتابعة؟",
|
||||
"collabStopOverridePrompt": "",
|
||||
"collabStopOverridePrompt": "إيقاف الجلسة سيؤدي إلى الكتابة فوق رسومك السابقة المخزنة داخليا. هل أنت متأكد؟\n\n(إذا كنت ترغب في الاحتفاظ برسمك المخزن داخليا، ببساطة أغلق علامة تبويب المتصفح بدلاً من ذلك.)",
|
||||
"errorLoadingLibrary": "حصل خطأ أثناء تحميل مكتبة الطرف الثالث.",
|
||||
"confirmAddLibrary": "هذا سيضيف {{numShapes}} شكل إلى مكتبتك. هل أنت متأكد؟",
|
||||
"imageDoesNotContainScene": "استيراد الصور غير مدعوم في الوقت الراهن.\n\nهل تريد استيراد مشهد؟ لا يبدو أن هذه الصورة تحتوي على أي بيانات مشهد. هل قمت بسماح هذا أثناء التصدير؟",
|
||||
@@ -199,7 +202,8 @@
|
||||
"button_stopSession": "إيقاف الجلسة",
|
||||
"desc_inProgressIntro": "تجري الآن المشاركة الحية.",
|
||||
"desc_shareLink": "شارك هذا الرابط مع أي شخص تريده أن يشاركك الجلسة:",
|
||||
"desc_exitSession": "إيقاف الجلسة سيؤدي إلى قطع الاتصال الخاص بك من الغرفة، ولكن ستتمكن من مواصلة العمل مع المشهد، محليا. لاحظ أن هذا لن يؤثر على الأشخاص الآخرين، و سيظلون قادرين على التعاون في إصدارهم."
|
||||
"desc_exitSession": "إيقاف الجلسة سيؤدي إلى قطع الاتصال الخاص بك من الغرفة، ولكن ستتمكن من مواصلة العمل مع المشهد، محليا. لاحظ أن هذا لن يؤثر على الأشخاص الآخرين، و سيظلون قادرين على التعاون في إصدارهم.",
|
||||
"shareTitle": ""
|
||||
},
|
||||
"errorDialog": {
|
||||
"title": "خطأ"
|
||||
@@ -210,9 +214,9 @@
|
||||
"curvedArrow": "",
|
||||
"curvedLine": "",
|
||||
"documentation": "",
|
||||
"drag": "",
|
||||
"editor": "",
|
||||
"github": "",
|
||||
"drag": "اسحب",
|
||||
"editor": "المحرر",
|
||||
"github": "عثرت على مشكلة؟ إرسال",
|
||||
"howto": "",
|
||||
"or": "",
|
||||
"preventBinding": "",
|
||||
@@ -226,7 +230,8 @@
|
||||
"zoomToSelection": ""
|
||||
},
|
||||
"encrypted": {
|
||||
"tooltip": "رسوماتك مشفرة من النهاية إلى النهاية حتى أن خوادم Excalidraw لن تراها أبدا."
|
||||
"tooltip": "رسوماتك مشفرة من النهاية إلى النهاية حتى أن خوادم Excalidraw لن تراها أبدا.",
|
||||
"link": ""
|
||||
},
|
||||
"stats": {
|
||||
"angle": "الزاوية",
|
||||
|
@@ -61,7 +61,7 @@
|
||||
"architect": "Архитект",
|
||||
"artist": "Художник",
|
||||
"cartoonist": "Карикатурист",
|
||||
"fileTitle": "Заглавие на файл",
|
||||
"fileTitle": "",
|
||||
"colorPicker": "Избор на цвят",
|
||||
"canvasBackground": "Фон на платно",
|
||||
"drawingCanvas": "Платно за рисуване",
|
||||
@@ -92,8 +92,11 @@
|
||||
"centerHorizontally": "Центрирай хоризонтално",
|
||||
"distributeHorizontally": "Разпредели хоризонтално",
|
||||
"distributeVertically": "Разпредели вертикално",
|
||||
"flipHorizontal": "",
|
||||
"flipVertical": "",
|
||||
"viewMode": "Изглед",
|
||||
"toggleExportColorScheme": ""
|
||||
"toggleExportColorScheme": "",
|
||||
"share": ""
|
||||
},
|
||||
"buttons": {
|
||||
"clearReset": "Нулиране на платно",
|
||||
@@ -199,7 +202,8 @@
|
||||
"button_stopSession": "Стоп на сесията",
|
||||
"desc_inProgressIntro": "Сесията за сътрудничество на живо е в ход.",
|
||||
"desc_shareLink": "Споделете тази връзка с всеки, с когото искате да си сътрудничите:",
|
||||
"desc_exitSession": "Спирането на сесията ще ви изключи от стаята, но ще можете да продължите да работите със сцената, локално. Имайте предвид, че това няма да засегне други хора и те все още ще могат да си сътрудничат с тяхната версия."
|
||||
"desc_exitSession": "Спирането на сесията ще ви изключи от стаята, но ще можете да продължите да работите със сцената, локално. Имайте предвид, че това няма да засегне други хора и те все още ще могат да си сътрудничат с тяхната версия.",
|
||||
"shareTitle": ""
|
||||
},
|
||||
"errorDialog": {
|
||||
"title": "Грешка"
|
||||
@@ -226,7 +230,8 @@
|
||||
"zoomToSelection": "Приближи селекцията"
|
||||
},
|
||||
"encrypted": {
|
||||
"tooltip": "Вашите рисунки са криптирани от край до край, така че сървърите на Excalidraw няма да могат да ги виждат."
|
||||
"tooltip": "Вашите рисунки са криптирани от край до край, така че сървърите на Excalidraw няма да могат да ги виждат.",
|
||||
"link": ""
|
||||
},
|
||||
"stats": {
|
||||
"angle": "Ъгъл",
|
||||
|
@@ -61,7 +61,7 @@
|
||||
"architect": "Arquitecte",
|
||||
"artist": "Artista",
|
||||
"cartoonist": "Dibuixant",
|
||||
"fileTitle": "Títol del fitxer",
|
||||
"fileTitle": "",
|
||||
"colorPicker": "Selector de colors",
|
||||
"canvasBackground": "Fons del llenç",
|
||||
"drawingCanvas": "Llenç de dibuix",
|
||||
@@ -92,8 +92,11 @@
|
||||
"centerHorizontally": "Centrar horitzontalment",
|
||||
"distributeHorizontally": "Distribuir horitzontalment",
|
||||
"distributeVertically": "Distribuir verticalment",
|
||||
"flipHorizontal": "",
|
||||
"flipVertical": "",
|
||||
"viewMode": "Mode de visualització",
|
||||
"toggleExportColorScheme": ""
|
||||
"toggleExportColorScheme": "Canvia l'esquema de colors de l'exportació",
|
||||
"share": "Compartir"
|
||||
},
|
||||
"buttons": {
|
||||
"clearReset": "Netejar el llenç",
|
||||
@@ -142,7 +145,7 @@
|
||||
"confirmAddLibrary": "Això afegirà {{numShapes}} forma(es) a la vostra biblioteca. Estàs segur?",
|
||||
"imageDoesNotContainScene": "En aquest moment no s’admet la importació d’imatges.\n\nVolies importar una escena? Sembla que aquesta imatge no conté cap dada d’escena. Ho has activat durant l'exportació?",
|
||||
"cannotRestoreFromImage": "L’escena no s’ha pogut restaurar des d’aquest fitxer d’imatge",
|
||||
"invalidSceneUrl": "",
|
||||
"invalidSceneUrl": "No s'ha pogut importar l'escena des de l'adreça URL proporcionada. Està malformada o no conté dades Excalidraw JSON vàlides.",
|
||||
"resetLibrary": "Tot el llenç s'esborrarà. Estàs segur?"
|
||||
},
|
||||
"toolBar": {
|
||||
@@ -199,7 +202,8 @@
|
||||
"button_stopSession": "Aturar sessió",
|
||||
"desc_inProgressIntro": "La sessió de col·laboració en directe està en marxa.",
|
||||
"desc_shareLink": "Comparteix aquest enllaç amb qualsevol persona amb qui vulguis col·laborar:",
|
||||
"desc_exitSession": "Si aturas la sessió, et desconectarás de la sala, però podrás continuar treballant amb el dibuix localment. Tingues en compte que això no afectarà a altres persones, i encara podran col·laborar en la seva versió."
|
||||
"desc_exitSession": "Si aturas la sessió, et desconectarás de la sala, però podrás continuar treballant amb el dibuix localment. Tingues en compte que això no afectarà a altres persones, i encara podran col·laborar en la seva versió.",
|
||||
"shareTitle": "Uniu-vos a una sessió de col·laboració en directe a Excalidraw"
|
||||
},
|
||||
"errorDialog": {
|
||||
"title": "Error"
|
||||
@@ -226,7 +230,8 @@
|
||||
"zoomToSelection": "Zoom per veure la selecció"
|
||||
},
|
||||
"encrypted": {
|
||||
"tooltip": "Els vostres dibuixos estan xifrats de punta a punta de manera que els servidors d’Excalidraw no els veuran mai."
|
||||
"tooltip": "Els vostres dibuixos estan xifrats de punta a punta de manera que els servidors d’Excalidraw no els veuran mai.",
|
||||
"link": ""
|
||||
},
|
||||
"stats": {
|
||||
"angle": "Angle",
|
||||
@@ -240,16 +245,16 @@
|
||||
"total": "Total",
|
||||
"version": "Versió",
|
||||
"versionCopy": "Feu clic per a copiar",
|
||||
"versionNotAvailable": "",
|
||||
"versionNotAvailable": "Versió no disponible",
|
||||
"width": "Amplada"
|
||||
},
|
||||
"toast": {
|
||||
"copyStyles": "",
|
||||
"copyToClipboard": "",
|
||||
"copyToClipboardAsPng": "",
|
||||
"fileSaved": "",
|
||||
"fileSavedToFilename": "",
|
||||
"canvas": "",
|
||||
"selection": ""
|
||||
"copyStyles": "S'han copiat els estils.",
|
||||
"copyToClipboard": "S'ha copiat al porta-retalls.",
|
||||
"copyToClipboardAsPng": "S'ha copiat {{exportSelection}} al porta-retalls en format PNG\n({{exportColorScheme}})",
|
||||
"fileSaved": "S'ha desat el fitxer.",
|
||||
"fileSavedToFilename": "S'ha desat a {filename}",
|
||||
"canvas": "el llenç",
|
||||
"selection": "la selecció"
|
||||
}
|
||||
}
|
||||
|
@@ -92,8 +92,11 @@
|
||||
"centerHorizontally": "Horizontal zentrieren",
|
||||
"distributeHorizontally": "Horizontal verteilen",
|
||||
"distributeVertically": "Vertikal verteilen",
|
||||
"flipHorizontal": "Horizontal spiegeln",
|
||||
"flipVertical": "Vertikal spiegeln",
|
||||
"viewMode": "Ansichtsmodus",
|
||||
"toggleExportColorScheme": "Farbschema für Export umschalten"
|
||||
"toggleExportColorScheme": "Farbschema für Export umschalten",
|
||||
"share": "Teilen"
|
||||
},
|
||||
"buttons": {
|
||||
"clearReset": "Zeichenfläche löschen & Hintergrundfarbe zurücksetzen",
|
||||
@@ -199,7 +202,8 @@
|
||||
"button_stopSession": "Sitzung beenden",
|
||||
"desc_inProgressIntro": "Die Live-Sitzung wird nun ausgeführt.",
|
||||
"desc_shareLink": "Teile diesen Link mit allen, mit denen du zusammenarbeiten möchtest:",
|
||||
"desc_exitSession": "Wenn du die Sitzung beendest, wird deine Verbindung zum Raum getrennt. Du kannst jedoch lokal weiter an der Zeichnung arbeiten. Beachte, dass dies keine Auswirkungen auf andere hat und diese weiterhin gemeinsam an ihrer Version arbeiten können."
|
||||
"desc_exitSession": "Wenn du die Sitzung beendest, wird deine Verbindung zum Raum getrennt. Du kannst jedoch lokal weiter an der Zeichnung arbeiten. Beachte, dass dies keine Auswirkungen auf andere hat und diese weiterhin gemeinsam an ihrer Version arbeiten können.",
|
||||
"shareTitle": "An einer Live-Kollaborationssitzung auf Excalidraw teilnehmen"
|
||||
},
|
||||
"errorDialog": {
|
||||
"title": "Fehler"
|
||||
@@ -226,7 +230,8 @@
|
||||
"zoomToSelection": "Auf Auswahl zoomen"
|
||||
},
|
||||
"encrypted": {
|
||||
"tooltip": "Da deine Zeichnungen Ende-zu-Ende verschlüsselt werden, sehen auch unsere Excalidraw-Server sie niemals."
|
||||
"tooltip": "Da deine Zeichnungen Ende-zu-Ende verschlüsselt werden, sehen auch unsere Excalidraw-Server sie niemals.",
|
||||
"link": "Blogbeitrag über Ende-zu-Ende-Verschlüsselung in Excalidraw"
|
||||
},
|
||||
"stats": {
|
||||
"angle": "Winkel",
|
||||
|
@@ -61,7 +61,7 @@
|
||||
"architect": "Αρχιτέκτονας",
|
||||
"artist": "Καλλιτέχνης",
|
||||
"cartoonist": "Σκιτσογράφος",
|
||||
"fileTitle": "Τίτλος αρχείου",
|
||||
"fileTitle": "Όνομα αρχείου",
|
||||
"colorPicker": "Επιλογή Χρώματος",
|
||||
"canvasBackground": "Φόντο καμβά",
|
||||
"drawingCanvas": "Σχεδίαση καμβά",
|
||||
@@ -92,8 +92,11 @@
|
||||
"centerHorizontally": "Κέντρο οριζόντια",
|
||||
"distributeHorizontally": "Οριζόντια κατανομή",
|
||||
"distributeVertically": "Κατακόρυφη κατανομή",
|
||||
"flipHorizontal": "Οριζόντια αναστροφή",
|
||||
"flipVertical": "Κατακόρυφη αναστροφή",
|
||||
"viewMode": "Λειτουργία προβολής",
|
||||
"toggleExportColorScheme": "Εναλλαγή εξαγωγής θέματος χρωμάτων"
|
||||
"toggleExportColorScheme": "Εναλλαγή εξαγωγής θέματος χρωμάτων",
|
||||
"share": "Κοινοποίηση"
|
||||
},
|
||||
"buttons": {
|
||||
"clearReset": "Επαναφορά του καμβά",
|
||||
@@ -199,7 +202,8 @@
|
||||
"button_stopSession": "Τερματισμός Συνεδρίας",
|
||||
"desc_inProgressIntro": "Η ζωντανή συνεργασία με άλλους είναι σε ενεργή.",
|
||||
"desc_shareLink": "Μοιραστείτε τον σύνδεσμο με όποιον θέλετε να δουλέψετε μαζί:",
|
||||
"desc_exitSession": "Η διακοπή θα σας αποσυνδέσει από το δωμάτιο, αλλά θα μπορείτε να συνεχίσετε να δουλεύετε στον πίνακα, τοπικά. Σημειώσατε ότι αυτό δεν θα επηρεάσει τον πίνακα άλλων, και θα μπορούν ακόμα να συνεισφέρουν στην δική τους έκδοση."
|
||||
"desc_exitSession": "Η διακοπή θα σας αποσυνδέσει από το δωμάτιο, αλλά θα μπορείτε να συνεχίσετε να δουλεύετε στον πίνακα, τοπικά. Σημειώσατε ότι αυτό δεν θα επηρεάσει τον πίνακα άλλων, και θα μπορούν ακόμα να συνεισφέρουν στην δική τους έκδοση.",
|
||||
"shareTitle": ""
|
||||
},
|
||||
"errorDialog": {
|
||||
"title": "Σφάλμα"
|
||||
@@ -226,7 +230,8 @@
|
||||
"zoomToSelection": "Ζουμ στην επιλογή"
|
||||
},
|
||||
"encrypted": {
|
||||
"tooltip": "Τα σχέδιά σου είναι κρυπτογραφημένα από άκρο σε άκρο, έτσι δεν θα είναι ποτέ ορατά μέσα από τους διακομιστές του Excalidraw."
|
||||
"tooltip": "Τα σχέδιά σου είναι κρυπτογραφημένα από άκρο σε άκρο, έτσι δεν θα είναι ποτέ ορατά μέσα από τους διακομιστές του Excalidraw.",
|
||||
"link": ""
|
||||
},
|
||||
"stats": {
|
||||
"angle": "Γωνία",
|
||||
|
@@ -61,7 +61,7 @@
|
||||
"architect": "Architect",
|
||||
"artist": "Artist",
|
||||
"cartoonist": "Cartoonist",
|
||||
"fileTitle": "File title",
|
||||
"fileTitle": "File name",
|
||||
"colorPicker": "Color picker",
|
||||
"canvasBackground": "Canvas background",
|
||||
"drawingCanvas": "Drawing canvas",
|
||||
@@ -78,6 +78,8 @@
|
||||
"ungroup": "Ungroup selection",
|
||||
"collaborators": "Collaborators",
|
||||
"showGrid": "Show grid",
|
||||
"toggleAutosave": "Autosave to current file",
|
||||
"toggleAutosave_details": "Automatically save changes when working on an existing file.",
|
||||
"addToLibrary": "Add to library",
|
||||
"removeFromLibrary": "Remove from library",
|
||||
"libraryLoadingMessage": "Loading library…",
|
||||
@@ -92,6 +94,8 @@
|
||||
"centerHorizontally": "Center horizontally",
|
||||
"distributeHorizontally": "Distribute horizontally",
|
||||
"distributeVertically": "Distribute vertically",
|
||||
"flipHorizontal": "Flip horizontal",
|
||||
"flipVertical": "Flip vertical",
|
||||
"viewMode": "View mode",
|
||||
"toggleExportColorScheme": "Toggle export color scheme",
|
||||
"share": "Share"
|
||||
@@ -228,7 +232,8 @@
|
||||
"zoomToSelection": "Zoom to selection"
|
||||
},
|
||||
"encrypted": {
|
||||
"tooltip": "Your drawings are end-to-end encrypted so Excalidraw's servers will never see them."
|
||||
"tooltip": "Your drawings are end-to-end encrypted so Excalidraw's servers will never see them.",
|
||||
"link": "Blog post on end-to-end encryption in Excalidraw"
|
||||
},
|
||||
"stats": {
|
||||
"angle": "Angle",
|
||||
@@ -246,6 +251,9 @@
|
||||
"width": "Width"
|
||||
},
|
||||
"toast": {
|
||||
"autosaveFailed_notAllowed": "Autosave was disabled.",
|
||||
"autosaveFailed_notFound": "Autosave failed.\nIt seems the file no longer exists.",
|
||||
"autosaveFailed": "Autosave failed.",
|
||||
"copyStyles": "Copied styles.",
|
||||
"copyToClipboard": "Copied to clipboard.",
|
||||
"copyToClipboardAsPng": "Copied {{exportSelection}} to clipboard as PNG\n({{exportColorScheme}})",
|
||||
|
@@ -61,7 +61,7 @@
|
||||
"architect": "Arquitecto",
|
||||
"artist": "Artista",
|
||||
"cartoonist": "Caricatura",
|
||||
"fileTitle": "Título del archivo",
|
||||
"fileTitle": "Nombre del archivo",
|
||||
"colorPicker": "Selector de color",
|
||||
"canvasBackground": "Fondo del lienzo",
|
||||
"drawingCanvas": "Lienzo de dibujo",
|
||||
@@ -92,8 +92,11 @@
|
||||
"centerHorizontally": "Centrar horizontalmente",
|
||||
"distributeHorizontally": "Distribuir horizontalmente",
|
||||
"distributeVertically": "Distribuir verticalmente",
|
||||
"flipHorizontal": "Girar horizontalmente",
|
||||
"flipVertical": "Girar verticalmente",
|
||||
"viewMode": "Modo presentación",
|
||||
"toggleExportColorScheme": "Cambiar el esquema de colores de exportación"
|
||||
"toggleExportColorScheme": "Cambiar el esquema de colores de exportación",
|
||||
"share": "Compartir"
|
||||
},
|
||||
"buttons": {
|
||||
"clearReset": "Limpiar lienzo y reiniciar el color de fondo",
|
||||
@@ -199,7 +202,8 @@
|
||||
"button_stopSession": "Detener sesión",
|
||||
"desc_inProgressIntro": "La sesión de colaboración en vivo está ahora en progreso.",
|
||||
"desc_shareLink": "Comparte este enlace con cualquier persona con quien quieras colaborar:",
|
||||
"desc_exitSession": "Detener la sesión te desconectará de la sala, pero podrás seguir trabajando con la escena en su computadora, esto es de modo local. Ten en cuenta que esto no afectará a otras personas, y que las mismas seguirán siendo capaces de colaborar en tu escena."
|
||||
"desc_exitSession": "Detener la sesión te desconectará de la sala, pero podrás seguir trabajando con la escena en su computadora, esto es de modo local. Ten en cuenta que esto no afectará a otras personas, y que las mismas seguirán siendo capaces de colaborar en tu escena.",
|
||||
"shareTitle": "Únete a una sesión colaborativa en directo en Excalidraw"
|
||||
},
|
||||
"errorDialog": {
|
||||
"title": "Error"
|
||||
@@ -226,7 +230,8 @@
|
||||
"zoomToSelection": "Hacer zoom a la selección"
|
||||
},
|
||||
"encrypted": {
|
||||
"tooltip": "Tus dibujos están cifrados de punto a punto, por lo que los servidores de Excalidraw nunca los verán."
|
||||
"tooltip": "Tus dibujos están cifrados de punto a punto, por lo que los servidores de Excalidraw nunca los verán.",
|
||||
"link": ""
|
||||
},
|
||||
"stats": {
|
||||
"angle": "Ángulo",
|
||||
|
@@ -61,7 +61,7 @@
|
||||
"architect": "معمار",
|
||||
"artist": "هنرمند",
|
||||
"cartoonist": "کارتونیست",
|
||||
"fileTitle": "عنوان فایل",
|
||||
"fileTitle": "",
|
||||
"colorPicker": "انتخابگر رنگ",
|
||||
"canvasBackground": "بوم",
|
||||
"drawingCanvas": "بوم نقاشی",
|
||||
@@ -92,8 +92,11 @@
|
||||
"centerHorizontally": "وسط قرار دادن به صورت افقی",
|
||||
"distributeHorizontally": "توزیع کردن به صورت افقی",
|
||||
"distributeVertically": "توزیع کردن به صورت عمودی",
|
||||
"flipHorizontal": "",
|
||||
"flipVertical": "",
|
||||
"viewMode": "",
|
||||
"toggleExportColorScheme": ""
|
||||
"toggleExportColorScheme": "",
|
||||
"share": ""
|
||||
},
|
||||
"buttons": {
|
||||
"clearReset": "پاکسازی بوم نقاشی",
|
||||
@@ -199,7 +202,8 @@
|
||||
"button_stopSession": "پایان جلسه",
|
||||
"desc_inProgressIntro": "جلسه همکاری آنلاین در حال انجام است.",
|
||||
"desc_shareLink": "این لینک را با هر کسی که می خواهید با او همکاری کنید به اشتراک بگذارید:",
|
||||
"desc_exitSession": "با پایان دادن جلسه، شما از اتاق حذف میکند، اما می توانید به صورت محلی کار خود را با بوم ادامه دهید. توجه داشته باشید که این مورد بر سایر افراد تأثیر نمی گذارد و همچنان می توانند در نسخه خود همکاری کنند."
|
||||
"desc_exitSession": "با پایان دادن جلسه، شما از اتاق حذف میکند، اما می توانید به صورت محلی کار خود را با بوم ادامه دهید. توجه داشته باشید که این مورد بر سایر افراد تأثیر نمی گذارد و همچنان می توانند در نسخه خود همکاری کنند.",
|
||||
"shareTitle": ""
|
||||
},
|
||||
"errorDialog": {
|
||||
"title": "خطا"
|
||||
@@ -226,7 +230,8 @@
|
||||
"zoomToSelection": "بزرگنمایی قسمت انتخاب شده"
|
||||
},
|
||||
"encrypted": {
|
||||
"tooltip": "شما در یک محیط رمزگزاری شده دو طرفه در حال طراحی هستید پس Excalidraw هرگز طرح های شما را نمیبند."
|
||||
"tooltip": "شما در یک محیط رمزگزاری شده دو طرفه در حال طراحی هستید پس Excalidraw هرگز طرح های شما را نمیبند.",
|
||||
"link": ""
|
||||
},
|
||||
"stats": {
|
||||
"angle": "زاویه",
|
||||
|
@@ -61,7 +61,7 @@
|
||||
"architect": "Arkkitehti",
|
||||
"artist": "Taiteilija",
|
||||
"cartoonist": "Sarjakuva",
|
||||
"fileTitle": "Tiedoston otsikko",
|
||||
"fileTitle": "Tiedostonimi",
|
||||
"colorPicker": "Värin valinta",
|
||||
"canvasBackground": "Piirtoalueen tausta",
|
||||
"drawingCanvas": "Piirtoalue",
|
||||
@@ -92,8 +92,11 @@
|
||||
"centerHorizontally": "Keskitä vaakasuunnassa",
|
||||
"distributeHorizontally": "Jaa vaakasuunnassa",
|
||||
"distributeVertically": "Jaa pystysuunnassa",
|
||||
"flipHorizontal": "Käännä vaakasuunnassa",
|
||||
"flipVertical": "Käännä pystysuunnassa",
|
||||
"viewMode": "Katselutila",
|
||||
"toggleExportColorScheme": "Vaihda viennin väriteema"
|
||||
"toggleExportColorScheme": "Vaihda viennin väriteema",
|
||||
"share": "Jaa"
|
||||
},
|
||||
"buttons": {
|
||||
"clearReset": "Tyhjennä piirtoalue",
|
||||
@@ -199,7 +202,8 @@
|
||||
"button_stopSession": "Lopeta istunto",
|
||||
"desc_inProgressIntro": "Jaettu istunto on nyt käynnissä.",
|
||||
"desc_shareLink": "Jaa tämä linkki kenelle tahansa, jonka kanssa haluat tehdä yhteistyötä:",
|
||||
"desc_exitSession": "Istunnon pysäyttäminen katkaisee yhteyden huoneeseen, mutta voit vielä jatkaa työskentelyä paikallisesti. Huomaa, että tämä ei vaikuta muihin käyttäjiin ja he voivat jatkaa oman versionsa parissa työskentelyä."
|
||||
"desc_exitSession": "Istunnon pysäyttäminen katkaisee yhteyden huoneeseen, mutta voit vielä jatkaa työskentelyä paikallisesti. Huomaa, että tämä ei vaikuta muihin käyttäjiin ja he voivat jatkaa oman versionsa parissa työskentelyä.",
|
||||
"shareTitle": "Liity Excalidraw live-yhteistyöistuntoon"
|
||||
},
|
||||
"errorDialog": {
|
||||
"title": "Virhe"
|
||||
@@ -226,7 +230,8 @@
|
||||
"zoomToSelection": "Näytä valinta"
|
||||
},
|
||||
"encrypted": {
|
||||
"tooltip": "Piirroksesi ovat päästä-päähän-salattuja, joten Excalidrawin palvelimet eivät koskaan näe niitä."
|
||||
"tooltip": "Piirroksesi ovat päästä-päähän-salattuja, joten Excalidrawin palvelimet eivät koskaan näe niitä.",
|
||||
"link": ""
|
||||
},
|
||||
"stats": {
|
||||
"angle": "Kulma",
|
||||
|
@@ -61,7 +61,7 @@
|
||||
"architect": "Architecte",
|
||||
"artist": "Artiste",
|
||||
"cartoonist": "Caricaturiste",
|
||||
"fileTitle": "Titre du fichier",
|
||||
"fileTitle": "Nom du fichier",
|
||||
"colorPicker": "Sélecteur de couleur",
|
||||
"canvasBackground": "Arrière-plan du canevas",
|
||||
"drawingCanvas": "Zone de dessin",
|
||||
@@ -92,8 +92,11 @@
|
||||
"centerHorizontally": "Centrer horizontalement",
|
||||
"distributeHorizontally": "Distribuer horizontalement",
|
||||
"distributeVertically": "Distribuer verticalement",
|
||||
"flipHorizontal": "Retourner horizontalement",
|
||||
"flipVertical": "Retourner verticalement",
|
||||
"viewMode": "Mode présentation",
|
||||
"toggleExportColorScheme": "Activer/Désactiver l'export du thème de couleur"
|
||||
"toggleExportColorScheme": "Activer/Désactiver l'export du thème de couleur",
|
||||
"share": "Partager"
|
||||
},
|
||||
"buttons": {
|
||||
"clearReset": "Réinitialiser le canevas",
|
||||
@@ -199,7 +202,8 @@
|
||||
"button_stopSession": "Arrêter la session",
|
||||
"desc_inProgressIntro": "La session de collaboration en direct est maintenant en cours.",
|
||||
"desc_shareLink": "Partagez ce lien avec les personnes avec lesquelles vous souhaitez collaborer :",
|
||||
"desc_exitSession": "Arrêter la session vous déconnectera de la salle, mais vous pourrez continuer à travailler avec la scène, localement. Notez que cela n'affectera pas les autres personnes, et ils pourront toujours collaborer sur leur version."
|
||||
"desc_exitSession": "Arrêter la session vous déconnectera de la salle, mais vous pourrez continuer à travailler avec la scène, localement. Notez que cela n'affectera pas les autres personnes, et ils pourront toujours collaborer sur leur version.",
|
||||
"shareTitle": "Rejoindre une session de collaboration en direct sur Excalidraw"
|
||||
},
|
||||
"errorDialog": {
|
||||
"title": "Erreur"
|
||||
@@ -226,7 +230,8 @@
|
||||
"zoomToSelection": "Zoomer sur la sélection"
|
||||
},
|
||||
"encrypted": {
|
||||
"tooltip": "Vos dessins sont chiffrés de bout en bout, les serveurs d'Excalidraw ne les verront jamais."
|
||||
"tooltip": "Vos dessins sont chiffrés de bout en bout, les serveurs d'Excalidraw ne les verront jamais.",
|
||||
"link": "Article de blog sur le chiffrement de bout en bout dans Excalidraw"
|
||||
},
|
||||
"stats": {
|
||||
"angle": "Angle",
|
||||
|
@@ -61,7 +61,7 @@
|
||||
"architect": "ארכיטקט",
|
||||
"artist": "אמן",
|
||||
"cartoonist": "קריקטוריסט",
|
||||
"fileTitle": "כותרת הקובץ",
|
||||
"fileTitle": "",
|
||||
"colorPicker": "בחירת צבע",
|
||||
"canvasBackground": "רקע הלוח",
|
||||
"drawingCanvas": "לוח ציור",
|
||||
@@ -92,8 +92,11 @@
|
||||
"centerHorizontally": "מרכז אופקית",
|
||||
"distributeHorizontally": "חלוקה אופקית",
|
||||
"distributeVertically": "חלוקה אנכית",
|
||||
"flipHorizontal": "",
|
||||
"flipVertical": "",
|
||||
"viewMode": "מצב תצוגה",
|
||||
"toggleExportColorScheme": ""
|
||||
"toggleExportColorScheme": "",
|
||||
"share": ""
|
||||
},
|
||||
"buttons": {
|
||||
"clearReset": "אפס את הלוח",
|
||||
@@ -199,7 +202,8 @@
|
||||
"button_stopSession": "הפסק שיתוף",
|
||||
"desc_inProgressIntro": "שיתוף חי כרגע בפעולה.",
|
||||
"desc_shareLink": "שתף את הקישור עם כל מי שאתה מעוניין לעבוד אתו:",
|
||||
"desc_exitSession": "עצירת השיתוף תנתק אותך מהחדר, אבל עדיין תוכל להמשיך לעבוד על הלוח, מקומית. שים לב שזה לא ישפיע על אנשים אחרים, והם עדיין יוכלו לשתף פעולה עם הגירסה שלהם."
|
||||
"desc_exitSession": "עצירת השיתוף תנתק אותך מהחדר, אבל עדיין תוכל להמשיך לעבוד על הלוח, מקומית. שים לב שזה לא ישפיע על אנשים אחרים, והם עדיין יוכלו לשתף פעולה עם הגירסה שלהם.",
|
||||
"shareTitle": ""
|
||||
},
|
||||
"errorDialog": {
|
||||
"title": "שגיאה"
|
||||
@@ -226,7 +230,8 @@
|
||||
"zoomToSelection": "התמקד בבחירה"
|
||||
},
|
||||
"encrypted": {
|
||||
"tooltip": "הרישומים שלך מוצפנים מקצה לקצה כך שהשרתים של Excalidraw לא יראו אותם לעולם."
|
||||
"tooltip": "הרישומים שלך מוצפנים מקצה לקצה כך שהשרתים של Excalidraw לא יראו אותם לעולם.",
|
||||
"link": ""
|
||||
},
|
||||
"stats": {
|
||||
"angle": "זווית",
|
||||
|
@@ -61,7 +61,7 @@
|
||||
"architect": "वास्तुकार",
|
||||
"artist": "कलाकार",
|
||||
"cartoonist": "व्यंग्य चित्रकार",
|
||||
"fileTitle": "फ़ाइल का शीर्षक",
|
||||
"fileTitle": "फ़ाइल का नाम",
|
||||
"colorPicker": "रंग चयन",
|
||||
"canvasBackground": "कैनवास बैकग्राउंड",
|
||||
"drawingCanvas": "कैनवास बना रहे हैं",
|
||||
@@ -92,8 +92,11 @@
|
||||
"centerHorizontally": "क्षैतिज केन्द्रित",
|
||||
"distributeHorizontally": "क्षैतिज रूप से वितरित करें",
|
||||
"distributeVertically": "खड़ी रूप से वितरित करें",
|
||||
"flipHorizontal": "",
|
||||
"flipVertical": "",
|
||||
"viewMode": "",
|
||||
"toggleExportColorScheme": ""
|
||||
"toggleExportColorScheme": "",
|
||||
"share": ""
|
||||
},
|
||||
"buttons": {
|
||||
"clearReset": "कैनवास रीसेट करें",
|
||||
@@ -199,7 +202,8 @@
|
||||
"button_stopSession": "सत्र रुकें",
|
||||
"desc_inProgressIntro": "लाइव सहयोग सत्र अब जारी है।",
|
||||
"desc_shareLink": "इस लिंक को आप जिस किसी के साथ भी सहयोग करना चाहते हैं, उसके साथ साझा करें",
|
||||
"desc_exitSession": "सत्र रोकना आपको रूम से बाहर कर देगा, लेकिन आप स्थानीय स्तर पर दृश्य के साथ काम करना जारी रख पाएंगे। ध्यान दें कि यह अन्य लोगों को प्रभावित नहीं करेगा, और वे अभी भी अपने संस्करण पर सहयोग करने में सक्षम होंगे।"
|
||||
"desc_exitSession": "सत्र रोकना आपको रूम से बाहर कर देगा, लेकिन आप स्थानीय स्तर पर दृश्य के साथ काम करना जारी रख पाएंगे। ध्यान दें कि यह अन्य लोगों को प्रभावित नहीं करेगा, और वे अभी भी अपने संस्करण पर सहयोग करने में सक्षम होंगे।",
|
||||
"shareTitle": ""
|
||||
},
|
||||
"errorDialog": {
|
||||
"title": "गलती"
|
||||
@@ -226,7 +230,8 @@
|
||||
"zoomToSelection": "चयन तक ज़ूम करे"
|
||||
},
|
||||
"encrypted": {
|
||||
"tooltip": "आपके चित्र अंत-से-अंत एन्क्रिप्टेड हैं, इसलिए एक्सक्लूसिव्रॉव के सर्वर उन्हें कभी नहीं देखेंगे।"
|
||||
"tooltip": "आपके चित्र अंत-से-अंत एन्क्रिप्टेड हैं, इसलिए एक्सक्लूसिव्रॉव के सर्वर उन्हें कभी नहीं देखेंगे।",
|
||||
"link": ""
|
||||
},
|
||||
"stats": {
|
||||
"angle": "कोण",
|
||||
|
@@ -61,7 +61,7 @@
|
||||
"architect": "Tervezői",
|
||||
"artist": "Művészi",
|
||||
"cartoonist": "Karikatúrás",
|
||||
"fileTitle": "Fájl címe",
|
||||
"fileTitle": "",
|
||||
"colorPicker": "Színválasztó",
|
||||
"canvasBackground": "Vászon háttérszíne",
|
||||
"drawingCanvas": "Rajzvászon",
|
||||
@@ -92,8 +92,11 @@
|
||||
"centerHorizontally": "Vízszintesen középre igazított",
|
||||
"distributeHorizontally": "Vízszintes elosztás",
|
||||
"distributeVertically": "Függőleges elosztás",
|
||||
"flipHorizontal": "",
|
||||
"flipVertical": "",
|
||||
"viewMode": "",
|
||||
"toggleExportColorScheme": ""
|
||||
"toggleExportColorScheme": "",
|
||||
"share": ""
|
||||
},
|
||||
"buttons": {
|
||||
"clearReset": "Vászon törlése",
|
||||
@@ -199,7 +202,8 @@
|
||||
"button_stopSession": "Munkamenet leállítása",
|
||||
"desc_inProgressIntro": "Az élő együttműködési munkamenet folyamatban van.",
|
||||
"desc_shareLink": "Ossza meg ezt a linket bárkivel, akivel együtt szeretne működni:",
|
||||
"desc_exitSession": "Az munkamenet leállítása kilépteti önt a szobából, de folytathatja a munkát a saját gépén. Vegye figyelembe, hogy ez nem érinti más emberek munkáját és ők továbbra is együttműködhetnek a saját változatukon."
|
||||
"desc_exitSession": "Az munkamenet leállítása kilépteti önt a szobából, de folytathatja a munkát a saját gépén. Vegye figyelembe, hogy ez nem érinti más emberek munkáját és ők továbbra is együttműködhetnek a saját változatukon.",
|
||||
"shareTitle": ""
|
||||
},
|
||||
"errorDialog": {
|
||||
"title": "Hiba"
|
||||
@@ -226,7 +230,8 @@
|
||||
"zoomToSelection": ""
|
||||
},
|
||||
"encrypted": {
|
||||
"tooltip": "A rajzaidat végpontok közötti titkosítással tároljuk, tehát az Excalidraw szervereiről se tud más belenézni."
|
||||
"tooltip": "A rajzaidat végpontok közötti titkosítással tároljuk, tehát az Excalidraw szervereiről se tud más belenézni.",
|
||||
"link": ""
|
||||
},
|
||||
"stats": {
|
||||
"angle": "Szög",
|
||||
|
@@ -61,7 +61,7 @@
|
||||
"architect": "Arsitek",
|
||||
"artist": "Artis",
|
||||
"cartoonist": "Kartunis",
|
||||
"fileTitle": "Judul file",
|
||||
"fileTitle": "Nama file",
|
||||
"colorPicker": "Pilihan Warna",
|
||||
"canvasBackground": "Latar Kanvas",
|
||||
"drawingCanvas": "Kanvas",
|
||||
@@ -92,8 +92,11 @@
|
||||
"centerHorizontally": "Pusatkan secara horizontal",
|
||||
"distributeHorizontally": "Distribusikan horizontal",
|
||||
"distributeVertically": "Distribusikan vertikal",
|
||||
"flipHorizontal": "Balikkan horizontal",
|
||||
"flipVertical": "Balikkan vertikal",
|
||||
"viewMode": "Mode tampilan",
|
||||
"toggleExportColorScheme": "Ubah skema warna ekspor"
|
||||
"toggleExportColorScheme": "Ubah skema warna ekspor",
|
||||
"share": "Bagikan"
|
||||
},
|
||||
"buttons": {
|
||||
"clearReset": "Setel Ulang Kanvas",
|
||||
@@ -142,7 +145,7 @@
|
||||
"confirmAddLibrary": "Ini akan menambahkan {{numShapes}} bentuk ke pustaka Anda. Anda yakin?",
|
||||
"imageDoesNotContainScene": "Mengimpor gambar tidak didukung saat ini.\n\nApakah Anda ingin impor pemandangan? Gambar ini tidak berisi data pemandangan. Sudah ka Anda aktifkan ini ketika ekspor?",
|
||||
"cannotRestoreFromImage": "Pemandangan tidak dapat dipulihkan dari file gambar ini",
|
||||
"invalidSceneUrl": "",
|
||||
"invalidSceneUrl": "Tidak dapat impor pemandangan dari URL. Kemungkinan URL itu rusak atau tidak berisi data JSON Excalidraw yang valid.",
|
||||
"resetLibrary": "Ini akan menghapus pustaka Anda. Anda yakin?"
|
||||
},
|
||||
"toolBar": {
|
||||
@@ -199,7 +202,8 @@
|
||||
"button_stopSession": "Hentikan sesi",
|
||||
"desc_inProgressIntro": "Sesi kolaborasi sedang berlangsung sekarang.",
|
||||
"desc_shareLink": "Bagikan tautan ini dengan siapa pun yang Anda inginkan untuk kolaborasi bersama:",
|
||||
"desc_exitSession": "Menghentikan sesi akan memutuskan hubungan Anda dari ruangan, tetapi Anda dapat melanjutkan bekerja dengan pemandangan Anda secara lokal. Perhatikan bahwa ini tidak memengaruhi orang lain, dan mereka masih dapat berkolaborasi pada versi mereka."
|
||||
"desc_exitSession": "Menghentikan sesi akan memutuskan hubungan Anda dari ruangan, tetapi Anda dapat melanjutkan bekerja dengan pemandangan Anda secara lokal. Perhatikan bahwa ini tidak memengaruhi orang lain, dan mereka masih dapat berkolaborasi pada versi mereka.",
|
||||
"shareTitle": "Gabung sesi kolaborasi langsung di Excalidraw"
|
||||
},
|
||||
"errorDialog": {
|
||||
"title": "Kesalahan"
|
||||
@@ -226,7 +230,8 @@
|
||||
"zoomToSelection": "Perbesar ke seleksi"
|
||||
},
|
||||
"encrypted": {
|
||||
"tooltip": "Gambar anda terenkripsi end-to-end sehingga server Excalidraw tidak akan pernah dapat melihatnya."
|
||||
"tooltip": "Gambar anda terenkripsi end-to-end sehingga server Excalidraw tidak akan pernah dapat melihatnya.",
|
||||
"link": "Pos blog tentang enkripsi ujung ke ujung di Excalidraw"
|
||||
},
|
||||
"stats": {
|
||||
"angle": "Sudut",
|
||||
|
@@ -61,7 +61,7 @@
|
||||
"architect": "Architetto",
|
||||
"artist": "Artista",
|
||||
"cartoonist": "Fumettista",
|
||||
"fileTitle": "Titolo del file",
|
||||
"fileTitle": "Nome del file",
|
||||
"colorPicker": "Selettore colore",
|
||||
"canvasBackground": "Sfondo tela",
|
||||
"drawingCanvas": "Area di disegno",
|
||||
@@ -92,8 +92,11 @@
|
||||
"centerHorizontally": "Centra orizzontalmente",
|
||||
"distributeHorizontally": "Distribuisci orizzontalmente",
|
||||
"distributeVertically": "Distribuisci verticalmente",
|
||||
"flipHorizontal": "Capovolgi orizzontalmente",
|
||||
"flipVertical": "Capovolgi verticalmente",
|
||||
"viewMode": "Modalità visualizzazione",
|
||||
"toggleExportColorScheme": "Cambia lo schema di colori in esportazione"
|
||||
"toggleExportColorScheme": "Cambia lo schema di colori in esportazione",
|
||||
"share": "Condividi"
|
||||
},
|
||||
"buttons": {
|
||||
"clearReset": "Svuota la tela",
|
||||
@@ -199,7 +202,8 @@
|
||||
"button_stopSession": "Termina sessione",
|
||||
"desc_inProgressIntro": "La sessione di collaborazione è attualmente in corso.",
|
||||
"desc_shareLink": "Condividi questo link con chiunque desideri collaborare:",
|
||||
"desc_exitSession": "Interrompere la sessione scollegherà la tua stanza ma potrai continuare a lavorare con la scena, localmente. Tieni presente che questo non influirà sulle altre persone, e che saranno ancora in grado di collaborare alla loro versione."
|
||||
"desc_exitSession": "Interrompere la sessione scollegherà la tua stanza ma potrai continuare a lavorare con la scena, localmente. Tieni presente che questo non influirà sulle altre persone, e che saranno ancora in grado di collaborare alla loro versione.",
|
||||
"shareTitle": "Partecipa a una sessione di collaborazione live su Excalidraw"
|
||||
},
|
||||
"errorDialog": {
|
||||
"title": "Errore"
|
||||
@@ -226,7 +230,8 @@
|
||||
"zoomToSelection": "Zoom alla selezione"
|
||||
},
|
||||
"encrypted": {
|
||||
"tooltip": "I tuoi disegni sono crittografati end-to-end in modo che i server di Excalidraw non li possano mai vedere."
|
||||
"tooltip": "I tuoi disegni sono crittografati end-to-end in modo che i server di Excalidraw non li possano mai vedere.",
|
||||
"link": "Articolo del blog sulla crittografia end-to-end di Excalidraw"
|
||||
},
|
||||
"stats": {
|
||||
"angle": "Angolo",
|
||||
|
@@ -92,8 +92,11 @@
|
||||
"centerHorizontally": "横方向に中央揃え",
|
||||
"distributeHorizontally": "水平方向に分散配置",
|
||||
"distributeVertically": "垂直方向に分散配置",
|
||||
"flipHorizontal": "水平方向に反転",
|
||||
"flipVertical": "垂直方向に反転",
|
||||
"viewMode": "閲覧モード",
|
||||
"toggleExportColorScheme": ""
|
||||
"toggleExportColorScheme": "",
|
||||
"share": ""
|
||||
},
|
||||
"buttons": {
|
||||
"clearReset": "キャンバスのリセット",
|
||||
@@ -199,7 +202,8 @@
|
||||
"button_stopSession": "セッションを終了する",
|
||||
"desc_inProgressIntro": "共同編集セッションが有効になっています。",
|
||||
"desc_shareLink": "下記URLを共同編集したい人に共有してください:",
|
||||
"desc_exitSession": "セッションを終了すると部屋から切断されますが、手元の環境で編集を続けることができます。変更内容は他の人には反映されません。"
|
||||
"desc_exitSession": "セッションを終了すると部屋から切断されますが、手元の環境で編集を続けることができます。変更内容は他の人には反映されません。",
|
||||
"shareTitle": "Excalidrawのライブコラボレーションセッションに参加する"
|
||||
},
|
||||
"errorDialog": {
|
||||
"title": "エラー"
|
||||
@@ -226,7 +230,8 @@
|
||||
"zoomToSelection": "選択要素にズーム"
|
||||
},
|
||||
"encrypted": {
|
||||
"tooltip": "描画内容はエンドツーエンド暗号化が施されており、Excalidrawサーバーが内容を見ることはできません。"
|
||||
"tooltip": "描画内容はエンドツーエンド暗号化が施されており、Excalidrawサーバーが内容を見ることはできません。",
|
||||
"link": "Excalidrawのエンドツーエンド暗号化に関するブログ記事"
|
||||
},
|
||||
"stats": {
|
||||
"angle": "角度",
|
||||
@@ -249,7 +254,7 @@
|
||||
"copyToClipboardAsPng": "",
|
||||
"fileSaved": "ファイルを保存しました",
|
||||
"fileSavedToFilename": "{filename} に保存しました",
|
||||
"canvas": "",
|
||||
"canvas": "キャンバス",
|
||||
"selection": ""
|
||||
}
|
||||
}
|
||||
|
@@ -61,7 +61,7 @@
|
||||
"architect": "Amasdag",
|
||||
"artist": "Anaẓur",
|
||||
"cartoonist": "",
|
||||
"fileTitle": "Azwel n ufaylu",
|
||||
"fileTitle": "Isem n ufaylu",
|
||||
"colorPicker": "Amafran n yini",
|
||||
"canvasBackground": "Agilal n teɣzut n usuneɣ",
|
||||
"drawingCanvas": "Taɣzut n usuneɣ",
|
||||
@@ -92,8 +92,11 @@
|
||||
"centerHorizontally": "Di tlemmast s uglawi",
|
||||
"distributeHorizontally": "Freq s uglawi",
|
||||
"distributeVertically": "Freq s yibeddi",
|
||||
"flipHorizontal": "Tuttya taglawant",
|
||||
"flipVertical": "Tuttya tubdidt",
|
||||
"viewMode": "Askar n tmuɣli",
|
||||
"toggleExportColorScheme": "Sermed/sens asifeḍ usentel n yini"
|
||||
"toggleExportColorScheme": "Sermed/sens asifeḍ usentel n yini",
|
||||
"share": "Bḍu"
|
||||
},
|
||||
"buttons": {
|
||||
"clearReset": "Ales awennez n teɣzut n usuneɣ",
|
||||
@@ -142,7 +145,7 @@
|
||||
"confirmAddLibrary": "Ayagi adirnu talɣa (win) {{numShapes}} ɣer temkarḍit-inek (m). Tetḥeqqeḍ?",
|
||||
"imageDoesNotContainScene": "Taktert n tugniwin ur tettwadhel ara akka tura.\nTebɣiḍ ad tketreḍ asayes? Tugna-agi tettban-d ur tegbir ara isefka n usnas. Tesremdeḍ ayagi deg usifeḍ?",
|
||||
"cannotRestoreFromImage": "Asayes ulamek ara d-yettwarr seg ufaylu-agi n tugna",
|
||||
"invalidSceneUrl": "",
|
||||
"invalidSceneUrl": "Ulamek taktert n usayes seg URL i d-ittunefken. Ahat mačči d tameɣtut neɣ ur tegbir ara isefka JSON n Excalidraw.",
|
||||
"resetLibrary": "Ayagi ad isfeḍ tamkarḍit-inek•m. Tetḥeqqeḍ?"
|
||||
},
|
||||
"toolBar": {
|
||||
@@ -199,7 +202,8 @@
|
||||
"button_stopSession": "Ḥbes tiɣimit",
|
||||
"desc_inProgressIntro": "Tiɣimit n umɛawen s srid tetteddu akka tura.",
|
||||
"desc_shareLink": "Bḍu aseɣwen-agi akked medden ukud tebɣiḍ ad temɛawaneḍ:",
|
||||
"desc_exitSession": "Aḥbas n tɣimit ad k (m) yesenser si texxamt, maca ad tizmireḍ ad tkemmeleḍ amahil s usayes, s wudem adigan. Ẓer belli ayagi ur yettḥaz ara imdanen-nniḍen, yerna ad izmiren ad kemmelen ad mɛawanen di tsuffeɣt-nnsen."
|
||||
"desc_exitSession": "Aḥbas n tɣimit ad k (m) yesenser si texxamt, maca ad tizmireḍ ad tkemmeleḍ amahil s usayes, s wudem adigan. Ẓer belli ayagi ur yettḥaz ara imdanen-nniḍen, yerna ad izmiren ad kemmelen ad mɛawanen di tsuffeɣt-nnsen.",
|
||||
"shareTitle": "Rnu ɣer tɣimit n umɛiwen s srid n Excalidraw"
|
||||
},
|
||||
"errorDialog": {
|
||||
"title": "Tuccḍa"
|
||||
@@ -226,7 +230,8 @@
|
||||
"zoomToSelection": "Simɣur ɣer tefrayt"
|
||||
},
|
||||
"encrypted": {
|
||||
"tooltip": "Unuɣen-inek (m) ttuwgelhnen seg yixef s ixef dɣa iqeddacen n Excalidraw werǧin ad ten-walin. "
|
||||
"tooltip": "Unuɣen-inek (m) ttuwgelhnen seg yixef s ixef dɣa iqeddacen n Excalidraw werǧin ad ten-walin. ",
|
||||
"link": "Amagrad ɣef uwgelhen ixef s ixef di Excalidraw"
|
||||
},
|
||||
"stats": {
|
||||
"angle": "Tiɣmeṛt",
|
||||
|