mirror of
https://github.com/excalidraw/excalidraw.git
synced 2025-08-18 15:57:04 +02:00
Compare commits
300 Commits
gcp-portal
...
random_use
Author | SHA1 | Date | |
---|---|---|---|
![]() |
88691b1c3c | ||
![]() |
146c510faa | ||
![]() |
ff29780760 | ||
![]() |
463857ad9a | ||
![]() |
be2da9539e | ||
![]() |
bb7829ef90 | ||
![]() |
1104f6891e | ||
![]() |
a97e172070 | ||
![]() |
39d45afc06 | ||
![]() |
00c6940851 | ||
![]() |
982cba2035 | ||
![]() |
54739cd2df | ||
![]() |
75aeaa6c38 | ||
![]() |
bea4a1e066 | ||
![]() |
e8b462cc31 | ||
![]() |
c86c176e10 | ||
![]() |
b09c11bb14 | ||
![]() |
7199d13f48 | ||
![]() |
7d1fddc144 | ||
![]() |
5da3207633 | ||
![]() |
8c9786e026 | ||
![]() |
f0f13ed694 | ||
![]() |
850d8eb47e | ||
![]() |
f287f9c002 | ||
![]() |
78df5bc852 | ||
![]() |
f0073c7e26 | ||
![]() |
fa7a313412 | ||
![]() |
8b3f236cd8 | ||
![]() |
621812d0eb | ||
![]() |
d607249205 | ||
![]() |
df28c3299f | ||
![]() |
b00a57b4be | ||
![]() |
9277e839db | ||
![]() |
0d5d60944f | ||
![]() |
489a652d73 | ||
![]() |
2b85d96121 | ||
![]() |
6ce535d3a4 | ||
![]() |
da43cf5635 | ||
![]() |
603ecfba34 | ||
![]() |
a589708737 | ||
![]() |
4df401d012 | ||
![]() |
b2c4552416 | ||
![]() |
5cae218f1b | ||
![]() |
4be726d405 | ||
![]() |
99623334d1 | ||
![]() |
685abac81a | ||
![]() |
9581c45522 | ||
![]() |
0749d2c1f3 | ||
![]() |
8787f3dc60 | ||
![]() |
5fabc57277 | ||
![]() |
e7cbb859f0 | ||
![]() |
aa860251c7 | ||
![]() |
380aaa30e6 | ||
![]() |
2e61fec7a6 | ||
![]() |
3c295559c7 | ||
![]() |
55d3287abf | ||
![]() |
e3e967421e | ||
![]() |
77aae63006 | ||
![]() |
ee64a7e264 | ||
![]() |
097362662d | ||
![]() |
038e9c13dd | ||
![]() |
bc8ba08ad0 | ||
![]() |
f861a9fdd0 | ||
![]() |
62303b8a22 | ||
![]() |
9cc741ab3a | ||
![]() |
2d279cbb02 | ||
![]() |
57ea4fdf9a | ||
![]() |
44402f42bf | ||
![]() |
bdead4d164 | ||
![]() |
bfc0656475 | ||
![]() |
a33a3334f7 | ||
![]() |
969d3c694a | ||
![]() |
5cd921549a | ||
![]() |
437afcbea4 | ||
![]() |
6dee02e320 | ||
![]() |
74a2f16501 | ||
![]() |
fd4460be37 | ||
![]() |
e82d0493cf | ||
![]() |
083cb4c656 | ||
![]() |
d067365c1d | ||
![]() |
273cac6b60 | ||
![]() |
b9337b8a36 | ||
![]() |
0e0025921b | ||
![]() |
efc01ddab1 | ||
![]() |
7bce22b114 | ||
![]() |
aab4965bbb | ||
![]() |
486a9a3da8 | ||
![]() |
2425c06082 | ||
![]() |
79ea844901 | ||
![]() |
6690215cd1 | ||
![]() |
7f5e783fe8 | ||
![]() |
9325109836 | ||
![]() |
69b6fbb3f4 | ||
![]() |
6b6002baae | ||
![]() |
54dcb73701 | ||
![]() |
b595d3fcba | ||
![]() |
d0867d1c3b | ||
![]() |
0d19e9210c | ||
![]() |
4249de41d4 | ||
![]() |
15f02ba191 | ||
![]() |
a2e1199907 | ||
![]() |
c08e9c4172 | ||
![]() |
abfc58eb24 | ||
![]() |
035c7affff | ||
![]() |
c819b653bf | ||
![]() |
60cea7a0c2 | ||
![]() |
d63b6a3469 | ||
![]() |
0912fe1c93 | ||
![]() |
360310de31 | ||
![]() |
716c78e930 | ||
![]() |
ba48974351 | ||
![]() |
6c3e4417e1 | ||
![]() |
bc0b6e1888 | ||
![]() |
99a22e8445 | ||
![]() |
e6d9797167 | ||
![]() |
a1e8fdfb1b | ||
![]() |
1cce63b07b | ||
![]() |
e9c2a09c21 | ||
![]() |
55e0812680 | ||
![]() |
0f32278a7e | ||
![]() |
1bdb8da1c3 | ||
![]() |
9c9787e0a0 | ||
![]() |
c2fe24c562 | ||
![]() |
52faa52091 | ||
![]() |
dd12abc583 | ||
![]() |
abebf9aff8 | ||
![]() |
790c9fd02e | ||
![]() |
357266e9ab | ||
![]() |
0bbb4535cf | ||
![]() |
d201d0be1b | ||
![]() |
5662c5141d | ||
![]() |
044614dcf3 | ||
![]() |
9ec15989ab | ||
![]() |
08aafcd248 | ||
![]() |
ea5602457f | ||
![]() |
3c58d19d45 | ||
![]() |
fcfcdebc99 | ||
![]() |
aa97c074a7 | ||
![]() |
d65d2c5279 | ||
![]() |
6d40039f08 | ||
![]() |
f4e10c93e1 | ||
![]() |
82c6df0e1f | ||
![]() |
c37bd59ddd | ||
![]() |
198a5e3b53 | ||
![]() |
a78e1fa99b | ||
![]() |
fc5db9248c | ||
![]() |
ebf64036fd | ||
![]() |
6271a031a3 | ||
![]() |
78da4c075e | ||
![]() |
f1cf28a84e | ||
![]() |
3b9290831a | ||
![]() |
bec34f2d57 | ||
![]() |
07839f8d20 | ||
![]() |
8068d1f853 | ||
![]() |
92c7d3257f | ||
![]() |
a8a5e7b6ff | ||
![]() |
45a4a00b69 | ||
![]() |
436e539d3a | ||
![]() |
ff19167063 | ||
![]() |
3fc531ed6e | ||
![]() |
6f55c00814 | ||
![]() |
a7eb6e1168 | ||
![]() |
641bbdd2da | ||
![]() |
42b0f7a614 | ||
![]() |
c11e3818ac | ||
![]() |
4b6aa5c53b | ||
![]() |
ebd0408d7d | ||
![]() |
f4fefbcee8 | ||
![]() |
11b8cc2caa | ||
![]() |
6bebfe63be | ||
![]() |
91ab7f36e2 | ||
![]() |
5ee8e8249c | ||
![]() |
49c6bdd520 | ||
![]() |
198800136e | ||
![]() |
178ee04d82 | ||
![]() |
18cdafbcbe | ||
![]() |
286e9a1524 | ||
![]() |
bac76778ce | ||
![]() |
f28f7ffb6e | ||
![]() |
12e8cc853f | ||
![]() |
81108bf580 | ||
![]() |
23030a15f2 | ||
![]() |
4ef7cb7365 | ||
![]() |
5cc3f7db80 | ||
![]() |
5c42cb5be4 | ||
![]() |
004d3180b5 | ||
![]() |
c12119278a | ||
![]() |
4d628844de | ||
![]() |
946a209927 | ||
![]() |
811437724b | ||
![]() |
9dcde502aa | ||
![]() |
d3106495b2 | ||
![]() |
891ac82447 | ||
![]() |
354976e08e | ||
![]() |
5c73c5813c | ||
![]() |
3a0b6fb41b | ||
![]() |
37d513ad59 | ||
![]() |
46624cc953 | ||
![]() |
0d23c8dd76 | ||
![]() |
51ef4cd97b | ||
![]() |
b558d19d37 | ||
![]() |
b8fb6580ef | ||
![]() |
6730eb41c2 | ||
![]() |
87c42cb327 | ||
![]() |
8cfd05aa95 | ||
![]() |
3ed8271344 | ||
![]() |
73515b5a83 | ||
![]() |
63d3da9a54 | ||
![]() |
215fb5e357 | ||
![]() |
886177816b | ||
![]() |
7d29351d66 | ||
![]() |
c0047269c1 | ||
![]() |
793b69e592 | ||
![]() |
e0a449aa40 | ||
![]() |
d5a270f643 | ||
![]() |
d126d04d17 | ||
![]() |
153ca6a7c6 | ||
![]() |
2618ac9f6e | ||
![]() |
f64fd80493 | ||
![]() |
a884351137 | ||
![]() |
e546a85a8d | ||
![]() |
29e630086c | ||
![]() |
a82165cb50 | ||
![]() |
4dc0159a05 | ||
![]() |
458787d1d7 | ||
![]() |
815977296e | ||
![]() |
58f840aa93 | ||
![]() |
422149c249 | ||
![]() |
a7cbe68ae8 | ||
![]() |
c19c8ecd27 | ||
![]() |
d91950bd03 | ||
![]() |
89472c14ed | ||
![]() |
09dfd16b17 | ||
![]() |
016e69b9f2 | ||
![]() |
bb1f979718 | ||
![]() |
5fda8400f3 | ||
![]() |
96beaa4354 | ||
![]() |
7183f1c83e | ||
![]() |
24ae9dca2e | ||
![]() |
f6ac3ea7c6 | ||
![]() |
b88e0253cc | ||
![]() |
1e48aafb9c | ||
![]() |
34761200bf | ||
![]() |
a0899966ff | ||
![]() |
c2b40dff92 | ||
![]() |
9733ecb3df | ||
![]() |
189b721eed | ||
![]() |
90fd4a95df | ||
![]() |
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 | ||
![]() |
30ae4b8bf2 | ||
![]() |
cf9e29834d | ||
![]() |
5d26c15daf | ||
![]() |
b0d7ff290f | ||
![]() |
458e6d6c24 | ||
![]() |
a21db08cae | ||
![]() |
1b626175de | ||
![]() |
5ffdd3f32d | ||
![]() |
77b873251a | ||
![]() |
b50b8f7b0d |
2
.env
2
.env
@@ -1,5 +1,5 @@
|
||||
REACT_APP_BACKEND_V1_GET_URL=https://json.excalidraw.com/api/v1/
|
||||
REACT_APP_BACKEND_V2_GET_URL=https://json.excalidraw.com/api/v2/
|
||||
REACT_APP_BACKEND_V2_POST_URL=https://json.excalidraw.com/api/v2/post/
|
||||
REACT_APP_SOCKET_SERVER_URL=https://excalidraw-portal.uc.r.appspot.com
|
||||
REACT_APP_SOCKET_SERVER_URL=https://portal.excalidraw.com
|
||||
REACT_APP_FIREBASE_CONFIG='{"apiKey":"AIzaSyAd15pYlMci_xIp9ko6wkEsDzAAA0Dn0RU","authDomain":"excalidraw-room-persistence.firebaseapp.com","databaseURL":"https://excalidraw-room-persistence.firebaseio.com","projectId":"excalidraw-room-persistence","storageBucket":"excalidraw-room-persistence.appspot.com","messagingSenderId":"654800341332","appId":"1:654800341332:web:4a692de832b55bd57ce0c1"}'
|
||||
|
26
.github/workflows/autorelease-excalidraw.yml
vendored
Normal file
26
.github/workflows/autorelease-excalidraw.yml
vendored
Normal file
@@ -0,0 +1,26 @@
|
||||
name: Auto release @excalidraw/excalidraw-next
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- master
|
||||
|
||||
jobs:
|
||||
Auto-release-excalidraw-next:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v2
|
||||
with:
|
||||
fetch-depth: 2
|
||||
- name: Setup Node.js 14.x
|
||||
uses: actions/setup-node@v2
|
||||
with:
|
||||
node-version: 14.x
|
||||
- name: Set up publish access
|
||||
run: |
|
||||
npm config set //registry.npmjs.org/:_authToken ${NPM_TOKEN}
|
||||
env:
|
||||
NPM_TOKEN: ${{ secrets.NPM_TOKEN }}
|
||||
- name: Auto release
|
||||
run: |
|
||||
yarn autorelease
|
3
.gitignore
vendored
3
.gitignore
vendored
@@ -5,9 +5,11 @@
|
||||
.env.test.local
|
||||
.envrc
|
||||
.eslintcache
|
||||
.history
|
||||
.idea
|
||||
.vercel
|
||||
.vscode
|
||||
.yarn
|
||||
*.log
|
||||
*.tgz
|
||||
build
|
||||
@@ -20,3 +22,4 @@ package-lock.json
|
||||
static
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
src/packages/excalidraw/types
|
||||
|
@@ -10,7 +10,7 @@ ARG NODE_ENV=production
|
||||
COPY . .
|
||||
RUN yarn build:app:docker
|
||||
|
||||
FROM nginx:1.17-alpine
|
||||
FROM nginx:1.21-alpine
|
||||
|
||||
COPY --from=build /opt/node_app/build /usr/share/nginx/html
|
||||
|
||||
|
18
README.md
18
README.md
@@ -70,6 +70,8 @@ The first set of digits is the room. This is visible from the server that’s go
|
||||
|
||||
The second set of digits is the encryption key. The Excalidraw server doesn’t know about it. This is what all the participants use to encrypt/decrypt the messages.
|
||||
|
||||
> Note: Please ensure that the encryption key is 22 characters long.
|
||||
|
||||
## Shape libraries
|
||||
|
||||
Find a growing list of libraries containing assets for your drawings at [libraries.excalidraw.com](https://libraries.excalidraw.com).
|
||||
@@ -93,7 +95,7 @@ These instructions will get you a copy of the project up and running on your loc
|
||||
#### Requirements
|
||||
|
||||
- [Node.js](https://nodejs.org/en/)
|
||||
- [Yarn](https://yarnpkg.com/getting-started/install)
|
||||
- [Yarn](https://yarnpkg.com/getting-started/install) (v1 or v2.4.2+)
|
||||
- [Git](https://git-scm.com/downloads)
|
||||
|
||||
#### Clone the repo
|
||||
@@ -102,6 +104,20 @@ These instructions will get you a copy of the project up and running on your loc
|
||||
git clone https://github.com/excalidraw/excalidraw.git
|
||||
```
|
||||
|
||||
#### Install the dependencies
|
||||
|
||||
```bash
|
||||
yarn
|
||||
```
|
||||
|
||||
#### Start the server
|
||||
|
||||
```bash
|
||||
yarn start
|
||||
```
|
||||
|
||||
Now you can open [http://localhost:3000](http://localhost:3000) and start coding in your favorite code editor.
|
||||
|
||||
#### Commands
|
||||
|
||||
| Command | Description |
|
||||
|
@@ -2,5 +2,8 @@
|
||||
"firestore": {
|
||||
"rules": "firestore.rules",
|
||||
"indexes": "firestore.indexes.json"
|
||||
},
|
||||
"storage": {
|
||||
"rules": "storage.rules"
|
||||
}
|
||||
}
|
||||
|
12
firebase-project/storage.rules
Normal file
12
firebase-project/storage.rules
Normal file
@@ -0,0 +1,12 @@
|
||||
rules_version = '2';
|
||||
service firebase.storage {
|
||||
match /b/{bucket}/o {
|
||||
match /{migrations} {
|
||||
match /{scenes}/{scene} {
|
||||
allow get, write: if true;
|
||||
// redundant, but let's be explicit'
|
||||
allow list: if false;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
42
package.json
42
package.json
@@ -19,34 +19,37 @@
|
||||
]
|
||||
},
|
||||
"dependencies": {
|
||||
"@sentry/browser": "6.2.2",
|
||||
"@sentry/integrations": "6.2.1",
|
||||
"@testing-library/jest-dom": "5.11.9",
|
||||
"@testing-library/react": "11.2.5",
|
||||
"@types/jest": "26.0.21",
|
||||
"@dwelle/browser-fs-access": "0.21.1",
|
||||
"@excalidraw/random-username": "1.0.0",
|
||||
"@sentry/browser": "6.2.5",
|
||||
"@sentry/integrations": "6.2.5",
|
||||
"@testing-library/jest-dom": "5.11.10",
|
||||
"@testing-library/react": "11.2.6",
|
||||
"@tldraw/vec": "0.0.106",
|
||||
"@types/jest": "26.0.22",
|
||||
"@types/react": "17.0.3",
|
||||
"@types/react-dom": "17.0.2",
|
||||
"@types/react-dom": "17.0.3",
|
||||
"@types/socket.io-client": "1.4.36",
|
||||
"browser-fs-access": "0.15.3",
|
||||
"clsx": "1.1.1",
|
||||
"firebase": "8.2.10",
|
||||
"i18next-browser-languagedetector": "6.0.1",
|
||||
"firebase": "8.3.3",
|
||||
"i18next-browser-languagedetector": "6.1.0",
|
||||
"lodash.throttle": "4.1.1",
|
||||
"nanoid": "3.1.21",
|
||||
"nanoid": "3.1.22",
|
||||
"open-color": "1.8.0",
|
||||
"pako": "1.0.11",
|
||||
"perfect-freehand": "1.0.15",
|
||||
"png-chunk-text": "1.0.0",
|
||||
"png-chunks-encode": "1.0.0",
|
||||
"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",
|
||||
"roughjs": "4.4.1",
|
||||
"sass": "1.32.10",
|
||||
"socket.io-client": "2.3.1",
|
||||
"typescript": "4.2.3"
|
||||
"typescript": "4.2.4"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@excalidraw/eslint-config": "1.0.0",
|
||||
@@ -54,9 +57,9 @@
|
||||
"@types/lodash.throttle": "4.1.6",
|
||||
"@types/pako": "1.0.1",
|
||||
"@types/resize-observer-browser": "0.1.5",
|
||||
"eslint-config-prettier": "8.1.0",
|
||||
"eslint-config-prettier": "8.3.0",
|
||||
"eslint-plugin-prettier": "3.3.1",
|
||||
"firebase-tools": "9.6.1",
|
||||
"firebase-tools": "9.9.0",
|
||||
"husky": "4.3.8",
|
||||
"jest-canvas-mock": "2.3.1",
|
||||
"lint-staged": "10.5.4",
|
||||
@@ -75,7 +78,7 @@
|
||||
},
|
||||
"jest": {
|
||||
"transformIgnorePatterns": [
|
||||
"node_modules/(?!(roughjs|points-on-curve|path-data-parser|points-on-path|browser-fs-access)/)"
|
||||
"node_modules/(?!(roughjs|points-on-curve|path-data-parser|points-on-path|@dwelle/browser-fs-access)/)"
|
||||
],
|
||||
"resetMocks": false
|
||||
},
|
||||
@@ -103,6 +106,7 @@
|
||||
"test:other": "yarn prettier --list-different",
|
||||
"test:typecheck": "tsc",
|
||||
"test:update": "yarn test:app --updateSnapshot --watchAll=false",
|
||||
"test": "yarn test:app"
|
||||
"test": "yarn test:app",
|
||||
"autorelease": "node scripts/autorelease.js"
|
||||
}
|
||||
}
|
||||
|
Binary file not shown.
@@ -13,6 +13,18 @@
|
||||
|
||||
<meta name="theme-color" content="#000" />
|
||||
|
||||
<!-- Declarative Link Capturing (https://web.dev/declarative-link-capturing/) -->
|
||||
<meta
|
||||
http-equiv="origin-trial"
|
||||
content="Ak3VyzTheARtX2CnxBZ3ZKxImB0mNyvDakmMxeAChgxrWFMZ3IGN64VP+uj36VxM5OegsbLmrP258b1xvqp7+Q8AAABbeyJvcmlnaW4iOiJodHRwczovL2V4Y2FsaWRyYXcuY29tOjQ0MyIsImZlYXR1cmUiOiJXZWJBcHBMaW5rQ2FwdHVyaW5nIiwiZXhwaXJ5IjoxNjM0MDgzMTk5fQ=="
|
||||
/>
|
||||
|
||||
<!-- File Handling (https://web.dev/file-handling/) -->
|
||||
<meta
|
||||
http-equiv="origin-trial"
|
||||
content="AkMQsAnFmKfRfPKQHNCv2WmZREqgwkqhyt2M7aOwQiCStB+hPYnGnv+mNbkPDAsGXrwsj/waFi76wPzTDUaEeQ0AAABUeyJvcmlnaW4iOiJodHRwczovL2V4Y2FsaWRyYXcuY29tOjQ0MyIsImZlYXR1cmUiOiJGaWxlSGFuZGxpbmciLCJleHBpcnkiOjE2MzQwODMxOTl9"
|
||||
/>
|
||||
|
||||
<!-- General tags -->
|
||||
<meta
|
||||
name="description"
|
||||
@@ -51,8 +63,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 -->
|
||||
@@ -108,15 +119,17 @@
|
||||
|
||||
<!-- FIXME: remove this when we update CRA (fix SW caching) -->
|
||||
<style>
|
||||
body {
|
||||
body,
|
||||
html {
|
||||
margin: 0;
|
||||
--ui-font: system-ui, BlinkMacSystemFont, -apple-system, Segoe UI,
|
||||
Roboto, Helvetica, Arial, sans-serif;
|
||||
font-family: var(--ui-font);
|
||||
-webkit-text-size-adjust: 100%;
|
||||
|
||||
width: 100vw;
|
||||
height: 100vh;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.visually-hidden {
|
||||
@@ -126,6 +139,7 @@
|
||||
overflow: hidden;
|
||||
clip: rect(1px, 1px, 1px, 1px);
|
||||
white-space: nowrap; /* added line */
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.LoadingMessage {
|
||||
@@ -148,6 +162,24 @@
|
||||
color: var(--popup-text-color);
|
||||
font-size: 1.3em;
|
||||
}
|
||||
#root {
|
||||
height: 100%;
|
||||
-webkit-touch-callout: none;
|
||||
-webkit-user-select: none;
|
||||
-khtml-user-select: none;
|
||||
-moz-user-select: none;
|
||||
-ms-user-select: none;
|
||||
user-select: none;
|
||||
|
||||
@media screen and (min-width: 1200px) {
|
||||
-webkit-touch-callout: default;
|
||||
-webkit-user-select: auto;
|
||||
-khtml-user-select: auto;
|
||||
-moz-user-select: auto;
|
||||
-ms-user-select: auto;
|
||||
user-select: auto;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
|
||||
|
@@ -26,7 +26,7 @@
|
||||
}
|
||||
}
|
||||
],
|
||||
"capture_links": "new_client",
|
||||
"capture_links": "new-client",
|
||||
"share_target": {
|
||||
"action": "/web-share-target",
|
||||
"method": "POST",
|
||||
@@ -39,5 +39,37 @@
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
"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
BIN
public/screenshots/collaboration.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 28 KiB |
BIN
public/screenshots/export.png
Normal file
BIN
public/screenshots/export.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 25 KiB |
BIN
public/screenshots/illustration.png
Normal file
BIN
public/screenshots/illustration.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 47 KiB |
BIN
public/screenshots/shapes.png
Normal file
BIN
public/screenshots/shapes.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 25 KiB |
BIN
public/screenshots/virtual-whiteboard.png
Normal file
BIN
public/screenshots/virtual-whiteboard.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 27 KiB |
BIN
public/screenshots/wireframe.png
Normal file
BIN
public/screenshots/wireframe.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 27 KiB |
51
scripts/autorelease.js
Normal file
51
scripts/autorelease.js
Normal file
@@ -0,0 +1,51 @@
|
||||
const fs = require("fs");
|
||||
const { exec, execSync } = require("child_process");
|
||||
|
||||
const excalidrawDir = `${__dirname}/../src/packages/excalidraw`;
|
||||
const excalidrawPackage = `${excalidrawDir}/package.json`;
|
||||
const pkg = require(excalidrawPackage);
|
||||
|
||||
const getShortCommitHash = () => {
|
||||
return execSync("git rev-parse --short HEAD").toString().trim();
|
||||
};
|
||||
|
||||
const publish = () => {
|
||||
try {
|
||||
execSync(`yarn --frozen-lockfile`);
|
||||
execSync(`yarn --frozen-lockfile`, { cwd: excalidrawDir });
|
||||
execSync(`yarn run build:umd`, { cwd: excalidrawDir });
|
||||
execSync(`yarn --cwd ${excalidrawDir} publish`);
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
}
|
||||
};
|
||||
|
||||
// get files changed between prev and head commit
|
||||
exec(`git diff --name-only HEAD^ HEAD`, async (error, stdout, stderr) => {
|
||||
if (error || stderr) {
|
||||
console.error(error);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const changedFiles = stdout.trim().split("\n");
|
||||
const filesToIgnoreRegex = /src\/excalidraw-app|packages\/utils/;
|
||||
|
||||
const excalidrawPackageFiles = changedFiles.filter((file) => {
|
||||
return file.indexOf("src") >= 0 && !filesToIgnoreRegex.test(file);
|
||||
});
|
||||
|
||||
if (!excalidrawPackageFiles.length) {
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
// update package.json
|
||||
pkg.version = `${pkg.version}-${getShortCommitHash()}`;
|
||||
pkg.name = "@excalidraw/excalidraw-next";
|
||||
fs.writeFileSync(excalidrawPackage, JSON.stringify(pkg, null, 2), "utf8");
|
||||
|
||||
// update readme
|
||||
const data = fs.readFileSync(`${excalidrawDir}/README_NEXT.md`, "utf8");
|
||||
fs.writeFileSync(`${excalidrawDir}/README.md`, data, "utf8");
|
||||
|
||||
publish();
|
||||
});
|
@@ -37,6 +37,9 @@ const crowdinMap = {
|
||||
"uk-UA": "en-uk",
|
||||
"zh-CN": "en-zhcn",
|
||||
"zh-TW": "en-zhtw",
|
||||
"lv-LV": "en-lv",
|
||||
"cs-CZ": "en-cs",
|
||||
"kk-KZ": "en-kk",
|
||||
};
|
||||
|
||||
const flags = {
|
||||
@@ -74,6 +77,9 @@ const flags = {
|
||||
"uk-UA": "🇺🇦",
|
||||
"zh-CN": "🇨🇳",
|
||||
"zh-TW": "🇹🇼",
|
||||
"lv-LV": "🇱🇻",
|
||||
"cs-CZ": "🇨🇿",
|
||||
"kk-KZ": "🇰🇿",
|
||||
};
|
||||
|
||||
const languages = {
|
||||
@@ -111,6 +117,9 @@ const languages = {
|
||||
"uk-UA": "Українська",
|
||||
"zh-CN": "简体中文",
|
||||
"zh-TW": "繁體中文",
|
||||
"lv-LV": "Latviešu",
|
||||
"cs-CZ": "Česky",
|
||||
"kk-KZ": "Қазақ тілі",
|
||||
};
|
||||
|
||||
const percentages = fs.readFileSync(
|
||||
|
39
scripts/release.js
Normal file
39
scripts/release.js
Normal file
@@ -0,0 +1,39 @@
|
||||
const fs = require("fs");
|
||||
const util = require("util");
|
||||
const exec = util.promisify(require("child_process").exec);
|
||||
const updateReadme = require("./updateReadme");
|
||||
const updateChangelog = require("./updateChangelog");
|
||||
|
||||
const excalidrawDir = `${__dirname}/../src/packages/excalidraw`;
|
||||
const excalidrawPackage = `${excalidrawDir}/package.json`;
|
||||
|
||||
const updatePackageVersion = (nextVersion) => {
|
||||
const pkg = require(excalidrawPackage);
|
||||
pkg.version = nextVersion;
|
||||
const content = `${JSON.stringify(pkg, null, 2)}\n`;
|
||||
fs.writeFileSync(excalidrawPackage, content, "utf-8");
|
||||
};
|
||||
|
||||
const release = async (nextVersion) => {
|
||||
try {
|
||||
updateReadme();
|
||||
await updateChangelog(nextVersion);
|
||||
updatePackageVersion(nextVersion);
|
||||
await exec(`git add -u`);
|
||||
await exec(
|
||||
`git commit -m "docs: release @excalidraw/excalidraw@${nextVersion} 🎉"`,
|
||||
);
|
||||
/* eslint-disable no-console */
|
||||
console.log("Done!");
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
process.exit(1);
|
||||
}
|
||||
};
|
||||
|
||||
const nextVersion = process.argv.slice(2)[0];
|
||||
if (!nextVersion) {
|
||||
console.error("Pass the next version to release!");
|
||||
process.exit(1);
|
||||
}
|
||||
release(nextVersion);
|
97
scripts/updateChangelog.js
Normal file
97
scripts/updateChangelog.js
Normal file
@@ -0,0 +1,97 @@
|
||||
const fs = require("fs");
|
||||
const util = require("util");
|
||||
const exec = util.promisify(require("child_process").exec);
|
||||
|
||||
const excalidrawDir = `${__dirname}/../src/packages/excalidraw`;
|
||||
const excalidrawPackage = `${excalidrawDir}/package.json`;
|
||||
const pkg = require(excalidrawPackage);
|
||||
const lastVersion = pkg.version;
|
||||
const existingChangeLog = fs.readFileSync(
|
||||
`${excalidrawDir}/CHANGELOG.md`,
|
||||
"utf8",
|
||||
);
|
||||
|
||||
const supportedTypes = ["feat", "fix", "style", "refactor", "perf", "build"];
|
||||
const headerForType = {
|
||||
feat: "Features",
|
||||
fix: "Fixes",
|
||||
style: "Styles",
|
||||
refactor: " Refactor",
|
||||
perf: "Performance",
|
||||
build: "Build",
|
||||
};
|
||||
|
||||
const getCommitHashForLastVersion = async () => {
|
||||
try {
|
||||
const commitMessage = `"release @excalidraw/excalidraw@${lastVersion}"`;
|
||||
const { stdout } = await exec(
|
||||
`git log --format=format:"%H" --grep=${commitMessage}`,
|
||||
);
|
||||
return stdout;
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
}
|
||||
};
|
||||
|
||||
const getLibraryCommitsSinceLastRelease = async () => {
|
||||
const commitHash = await getCommitHashForLastVersion();
|
||||
const { stdout } = await exec(
|
||||
`git log --pretty=format:%s ${commitHash}...master`,
|
||||
);
|
||||
const commitsSinceLastRelease = stdout.split("\n");
|
||||
const commitList = {};
|
||||
supportedTypes.forEach((type) => {
|
||||
commitList[type] = [];
|
||||
});
|
||||
|
||||
commitsSinceLastRelease.forEach((commit) => {
|
||||
const indexOfColon = commit.indexOf(":");
|
||||
const type = commit.slice(0, indexOfColon);
|
||||
if (!supportedTypes.includes(type)) {
|
||||
return;
|
||||
}
|
||||
const messageWithoutType = commit.slice(indexOfColon + 1).trim();
|
||||
const messageWithCapitalizeFirst =
|
||||
messageWithoutType.charAt(0).toUpperCase() + messageWithoutType.slice(1);
|
||||
const prNumber = commit.match(/\(#([0-9]*)\)/)[1];
|
||||
|
||||
// return if the changelog already contains the pr number which would happen for package updates
|
||||
if (existingChangeLog.includes(prNumber)) {
|
||||
return;
|
||||
}
|
||||
const prMarkdown = `[#${prNumber}](https://github.com/excalidraw/excalidraw/pull/${prNumber})`;
|
||||
const messageWithPRLink = messageWithCapitalizeFirst.replace(
|
||||
/\(#[0-9]*\)/,
|
||||
prMarkdown,
|
||||
);
|
||||
commitList[type].push(messageWithPRLink);
|
||||
});
|
||||
return commitList;
|
||||
};
|
||||
|
||||
const updateChangelog = async (nextVersion) => {
|
||||
const commitList = await getLibraryCommitsSinceLastRelease();
|
||||
let changelogForLibrary =
|
||||
"## Excalidraw Library\n\n**_This section lists the updates made to the excalidraw library and will not affect the integration._**\n\n";
|
||||
supportedTypes.forEach((type) => {
|
||||
if (commitList[type].length) {
|
||||
changelogForLibrary += `### ${headerForType[type]}\n\n`;
|
||||
const commits = commitList[type];
|
||||
commits.forEach((commit) => {
|
||||
changelogForLibrary += `- ${commit}\n\n`;
|
||||
});
|
||||
}
|
||||
});
|
||||
changelogForLibrary += "---\n";
|
||||
const lastVersionIndex = existingChangeLog.indexOf(`## ${lastVersion}`);
|
||||
let updatedContent =
|
||||
existingChangeLog.slice(0, lastVersionIndex) +
|
||||
changelogForLibrary +
|
||||
existingChangeLog.slice(lastVersionIndex);
|
||||
const currentDate = new Date().toISOString().slice(0, 10);
|
||||
const newVersion = `## ${nextVersion} (${currentDate})`;
|
||||
updatedContent = updatedContent.replace(`## Unreleased`, newVersion);
|
||||
fs.writeFileSync(`${excalidrawDir}/CHANGELOG.md`, updatedContent, "utf8");
|
||||
};
|
||||
|
||||
module.exports = updateChangelog;
|
27
scripts/updateReadme.js
Normal file
27
scripts/updateReadme.js
Normal file
@@ -0,0 +1,27 @@
|
||||
const fs = require("fs");
|
||||
|
||||
const updateReadme = () => {
|
||||
const excalidrawDir = `${__dirname}/../src/packages/excalidraw`;
|
||||
let data = fs.readFileSync(`${excalidrawDir}/README_NEXT.md`, "utf8");
|
||||
|
||||
// remove note for unstable release
|
||||
data = data.replace(
|
||||
/<!-- unstable-readme-start-->[\s\S]*?<!-- unstable-readme-end-->/,
|
||||
"",
|
||||
);
|
||||
|
||||
// replace "excalidraw-next" with "excalidraw"
|
||||
data = data.replace(/excalidraw-next/g, "excalidraw");
|
||||
data = data.trim();
|
||||
|
||||
const demoIndex = data.indexOf("### Demo");
|
||||
const excalidrawNextNote =
|
||||
"#### Note\n\n**If you don't want to wait for the next stable release and try out the unreleased changes you can use [@excalidraw/excalidraw-next](https://www.npmjs.com/package/@excalidraw/excalidraw-next).**\n\n";
|
||||
// Add excalidraw next note to try out for unreleased changes
|
||||
data = data.slice(0, demoIndex) + excalidrawNextNote + data.slice(demoIndex);
|
||||
|
||||
// update readme
|
||||
fs.writeFileSync(`${excalidrawDir}/README.md`, data, "utf8");
|
||||
};
|
||||
|
||||
module.exports = updateReadme;
|
@@ -2,18 +2,20 @@ import { register } from "./register";
|
||||
import { getSelectedElements } from "../scene";
|
||||
import { getNonDeletedElements } from "../element";
|
||||
import { deepCopyElement } from "../element/newElement";
|
||||
import { Library } from "../data/library";
|
||||
|
||||
export const actionAddToLibrary = register({
|
||||
name: "addToLibrary",
|
||||
perform: (elements, appState) => {
|
||||
perform: (elements, appState, _, app) => {
|
||||
const selectedElements = getSelectedElements(
|
||||
getNonDeletedElements(elements),
|
||||
appState,
|
||||
);
|
||||
|
||||
Library.loadLibrary().then((items) => {
|
||||
Library.saveLibrary([...items, selectedElements.map(deepCopyElement)]);
|
||||
app.library.loadLibrary().then((items) => {
|
||||
app.library.saveLibrary([
|
||||
...items,
|
||||
selectedElements.map(deepCopyElement),
|
||||
]);
|
||||
});
|
||||
return false;
|
||||
},
|
||||
|
@@ -1,4 +1,3 @@
|
||||
import React from "react";
|
||||
import { alignElements, Alignment } from "../align";
|
||||
import {
|
||||
AlignBottomIcon,
|
||||
|
@@ -1,14 +1,14 @@
|
||||
import React from "react";
|
||||
import { getDefaultAppState } from "../appState";
|
||||
import { ColorPicker } from "../components/ColorPicker";
|
||||
import { resetZoom, trash, zoomIn, zoomOut } from "../components/icons";
|
||||
import { trash, zoomIn, zoomOut } from "../components/icons";
|
||||
import { ToolButton } from "../components/ToolButton";
|
||||
import { ZOOM_STEP } from "../constants";
|
||||
import { DarkModeToggle } from "../components/DarkModeToggle";
|
||||
import { THEME, ZOOM_STEP } from "../constants";
|
||||
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 "../components/App";
|
||||
import { CODES, KEYS } from "../keys";
|
||||
import { getNormalizedZoom, getSelectedElements } from "../scene";
|
||||
import { centerScrollOn } from "../scene/scroll";
|
||||
@@ -16,13 +16,14 @@ import { getNewZoom } from "../scene/zoom";
|
||||
import { AppState, NormalizedZoomValue } from "../types";
|
||||
import { getShortcutKey } from "../utils";
|
||||
import { register } from "./register";
|
||||
import { Tooltip } from "../components/Tooltip";
|
||||
|
||||
export const actionChangeViewBackgroundColor = register({
|
||||
name: "changeViewBackgroundColor",
|
||||
perform: (_, appState, value) => {
|
||||
return {
|
||||
appState: { ...appState, viewBackgroundColor: value },
|
||||
commitToHistory: true,
|
||||
appState: { ...appState, ...value },
|
||||
commitToHistory: !!value.viewBackgroundColor,
|
||||
};
|
||||
},
|
||||
PanelComponent: ({ appState, updateData }) => {
|
||||
@@ -32,7 +33,12 @@ export const actionChangeViewBackgroundColor = register({
|
||||
label={t("labels.canvasBackground")}
|
||||
type="canvasBackground"
|
||||
color={appState.viewBackgroundColor}
|
||||
onChange={(color) => updateData(color)}
|
||||
onChange={(color) => updateData({ viewBackgroundColor: color })}
|
||||
isActive={appState.openPopup === "canvasColorPicker"}
|
||||
setActive={(active) =>
|
||||
updateData({ openPopup: active ? "canvasColorPicker" : null })
|
||||
}
|
||||
data-testid="canvas-background-picker"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
@@ -53,7 +59,6 @@ export const actionClearCanvas = register({
|
||||
exportBackground: appState.exportBackground,
|
||||
exportEmbedScene: appState.exportEmbedScene,
|
||||
gridSize: appState.gridSize,
|
||||
shouldAddWatermark: appState.shouldAddWatermark,
|
||||
showStats: appState.showStats,
|
||||
pasteDialog: appState.pasteDialog,
|
||||
},
|
||||
@@ -72,6 +77,7 @@ export const actionClearCanvas = register({
|
||||
updateData(null);
|
||||
}
|
||||
}}
|
||||
data-testid="clear-canvas-button"
|
||||
/>
|
||||
),
|
||||
});
|
||||
@@ -102,6 +108,7 @@ export const actionZoomIn = register({
|
||||
onClick={() => {
|
||||
updateData(null);
|
||||
}}
|
||||
size="small"
|
||||
/>
|
||||
),
|
||||
keyTest: (event) =>
|
||||
@@ -136,6 +143,7 @@ export const actionZoomOut = register({
|
||||
onClick={() => {
|
||||
updateData(null);
|
||||
}}
|
||||
size="small"
|
||||
/>
|
||||
),
|
||||
keyTest: (event) =>
|
||||
@@ -162,16 +170,21 @@ export const actionResetZoom = register({
|
||||
commitToHistory: false,
|
||||
};
|
||||
},
|
||||
PanelComponent: ({ updateData }) => (
|
||||
<ToolButton
|
||||
type="button"
|
||||
icon={resetZoom}
|
||||
title={t("buttons.resetZoom")}
|
||||
aria-label={t("buttons.resetZoom")}
|
||||
onClick={() => {
|
||||
updateData(null);
|
||||
}}
|
||||
/>
|
||||
PanelComponent: ({ updateData, appState }) => (
|
||||
<Tooltip label={t("buttons.resetZoom")}>
|
||||
<ToolButton
|
||||
type="button"
|
||||
className="reset-zoom-button"
|
||||
title={t("buttons.resetZoom")}
|
||||
aria-label={t("buttons.resetZoom")}
|
||||
onClick={() => {
|
||||
updateData(null);
|
||||
}}
|
||||
size="small"
|
||||
>
|
||||
{(appState.zoom.value * 100).toFixed(0)}%
|
||||
</ToolButton>
|
||||
</Tooltip>
|
||||
),
|
||||
keyTest: (event) =>
|
||||
(event.code === CODES.ZERO || event.code === CODES.NUM_ZERO) &&
|
||||
@@ -258,3 +271,28 @@ export const actionZoomToFit = register({
|
||||
!event.altKey &&
|
||||
!event[KEYS.CTRL_OR_CMD],
|
||||
});
|
||||
|
||||
export const actionToggleTheme = register({
|
||||
name: "toggleTheme",
|
||||
perform: (_, appState, value) => {
|
||||
return {
|
||||
appState: {
|
||||
...appState,
|
||||
theme:
|
||||
value || (appState.theme === THEME.LIGHT ? THEME.DARK : THEME.LIGHT),
|
||||
},
|
||||
commitToHistory: false,
|
||||
};
|
||||
},
|
||||
PanelComponent: ({ appState, updateData }) => (
|
||||
<div style={{ marginInlineStart: "0.25rem" }}>
|
||||
<DarkModeToggle
|
||||
value={appState.theme}
|
||||
onChange={(theme) => {
|
||||
updateData(theme);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
),
|
||||
keyTest: (event) => event.altKey && event.shiftKey && event.code === CODES.D,
|
||||
});
|
||||
|
@@ -50,7 +50,6 @@ export const actionCopyAsSvg = register({
|
||||
? selectedElements
|
||||
: getNonDeletedElements(elements),
|
||||
appState,
|
||||
app.canvas,
|
||||
appState,
|
||||
);
|
||||
return {
|
||||
@@ -89,7 +88,6 @@ export const actionCopyAsPng = register({
|
||||
? selectedElements
|
||||
: getNonDeletedElements(elements),
|
||||
appState,
|
||||
app.canvas,
|
||||
appState,
|
||||
);
|
||||
return {
|
||||
|
@@ -1,7 +1,6 @@
|
||||
import { isSomeElementSelected } from "../scene";
|
||||
import { KEYS } from "../keys";
|
||||
import { ToolButton } from "../components/ToolButton";
|
||||
import React from "react";
|
||||
import { trash } from "../components/icons";
|
||||
import { t } from "../i18n";
|
||||
import { register } from "./register";
|
||||
|
@@ -1,4 +1,3 @@
|
||||
import React from "react";
|
||||
import {
|
||||
DistributeHorizontallyIcon,
|
||||
DistributeVerticallyIcon,
|
||||
|
@@ -1,4 +1,3 @@
|
||||
import React from "react";
|
||||
import { KEYS } from "../keys";
|
||||
import { register } from "./register";
|
||||
import { ExcalidrawElement } from "../element/types";
|
||||
|
@@ -1,17 +1,25 @@
|
||||
import React from "react";
|
||||
import { trackEvent } from "../analytics";
|
||||
import { load, questionCircle, save, saveAs } from "../components/icons";
|
||||
import { load, questionCircle, saveAs } from "../components/icons";
|
||||
import { ProjectName } from "../components/ProjectName";
|
||||
import { ToolButton } from "../components/ToolButton";
|
||||
import "../components/ToolIcon.scss";
|
||||
import { Tooltip } from "../components/Tooltip";
|
||||
import { DarkModeToggle, Appearence } from "../components/DarkModeToggle";
|
||||
import { DarkModeToggle } from "../components/DarkModeToggle";
|
||||
import { loadFromJSON, saveAsJSON } from "../data";
|
||||
import { resaveAsImageWithScene } from "../data/resave";
|
||||
import { t } from "../i18n";
|
||||
import useIsMobile from "../is-mobile";
|
||||
import { useIsMobile } from "../components/App";
|
||||
import { KEYS } from "../keys";
|
||||
import { register } from "./register";
|
||||
import { supported } from "browser-fs-access";
|
||||
import { CheckboxItem } from "../components/CheckboxItem";
|
||||
import { getExportSize } from "../scene/export";
|
||||
import { DEFAULT_EXPORT_PADDING, EXPORT_SCALES, THEME } from "../constants";
|
||||
import { getSelectedElements, isSomeElementSelected } from "../scene";
|
||||
import { getNonDeletedElements } from "../element";
|
||||
import { ActiveFile } from "../components/ActiveFile";
|
||||
import { isImageFileHandle } from "../data/blob";
|
||||
import { nativeFileSystemSupported } from "../data/filesystem";
|
||||
import { Theme } from "../element/types";
|
||||
|
||||
export const actionChangeProjectName = register({
|
||||
name: "changeProjectName",
|
||||
@@ -31,6 +39,54 @@ export const actionChangeProjectName = register({
|
||||
),
|
||||
});
|
||||
|
||||
export const actionChangeExportScale = register({
|
||||
name: "changeExportScale",
|
||||
perform: (_elements, appState, value) => {
|
||||
return {
|
||||
appState: { ...appState, exportScale: value },
|
||||
commitToHistory: false,
|
||||
};
|
||||
},
|
||||
PanelComponent: ({ elements: allElements, appState, updateData }) => {
|
||||
const elements = getNonDeletedElements(allElements);
|
||||
const exportSelected = isSomeElementSelected(elements, appState);
|
||||
const exportedElements = exportSelected
|
||||
? getSelectedElements(elements, appState)
|
||||
: elements;
|
||||
|
||||
return (
|
||||
<>
|
||||
{EXPORT_SCALES.map((s) => {
|
||||
const [width, height] = getExportSize(
|
||||
exportedElements,
|
||||
DEFAULT_EXPORT_PADDING,
|
||||
s,
|
||||
);
|
||||
|
||||
const scaleButtonTitle = `${t(
|
||||
"buttons.scale",
|
||||
)} ${s}x (${width}x${height})`;
|
||||
|
||||
return (
|
||||
<ToolButton
|
||||
key={s}
|
||||
size="small"
|
||||
type="radio"
|
||||
icon={`${s}x`}
|
||||
name="export-canvas-scale"
|
||||
title={scaleButtonTitle}
|
||||
aria-label={scaleButtonTitle}
|
||||
id="export-canvas-scale"
|
||||
checked={s === appState.exportScale}
|
||||
onChange={() => updateData(s)}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</>
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
export const actionChangeExportBackground = register({
|
||||
name: "changeExportBackground",
|
||||
perform: (_elements, appState, value) => {
|
||||
@@ -40,14 +96,12 @@ export const actionChangeExportBackground = register({
|
||||
};
|
||||
},
|
||||
PanelComponent: ({ appState, updateData }) => (
|
||||
<label>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={appState.exportBackground}
|
||||
onChange={(event) => updateData(event.target.checked)}
|
||||
/>{" "}
|
||||
<CheckboxItem
|
||||
checked={appState.exportBackground}
|
||||
onChange={(checked) => updateData(checked)}
|
||||
>
|
||||
{t("labels.withBackground")}
|
||||
</label>
|
||||
</CheckboxItem>
|
||||
),
|
||||
});
|
||||
|
||||
@@ -60,57 +114,35 @@ export const actionChangeExportEmbedScene = register({
|
||||
};
|
||||
},
|
||||
PanelComponent: ({ appState, updateData }) => (
|
||||
<label style={{ display: "flex" }}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={appState.exportEmbedScene}
|
||||
onChange={(event) => updateData(event.target.checked)}
|
||||
/>{" "}
|
||||
<CheckboxItem
|
||||
checked={appState.exportEmbedScene}
|
||||
onChange={(checked) => updateData(checked)}
|
||||
>
|
||||
{t("labels.exportEmbedScene")}
|
||||
<Tooltip
|
||||
label={t("labels.exportEmbedScene_details")}
|
||||
position="above"
|
||||
long={true}
|
||||
>
|
||||
<div className="TooltipIcon">{questionCircle}</div>
|
||||
<Tooltip label={t("labels.exportEmbedScene_details")} long={true}>
|
||||
<div className="excalidraw-tooltip-icon">{questionCircle}</div>
|
||||
</Tooltip>
|
||||
</label>
|
||||
</CheckboxItem>
|
||||
),
|
||||
});
|
||||
|
||||
export const actionChangeShouldAddWatermark = register({
|
||||
name: "changeShouldAddWatermark",
|
||||
perform: (_elements, appState, value) => {
|
||||
return {
|
||||
appState: { ...appState, shouldAddWatermark: value },
|
||||
commitToHistory: false,
|
||||
};
|
||||
},
|
||||
PanelComponent: ({ appState, updateData }) => (
|
||||
<label>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={appState.shouldAddWatermark}
|
||||
onChange={(event) => updateData(event.target.checked)}
|
||||
/>{" "}
|
||||
{t("labels.addWatermark")}
|
||||
</label>
|
||||
),
|
||||
});
|
||||
|
||||
export const actionSaveScene = register({
|
||||
name: "saveScene",
|
||||
export const actionSaveToActiveFile = register({
|
||||
name: "saveToActiveFile",
|
||||
perform: async (elements, appState, value) => {
|
||||
const fileHandleExists = !!appState.fileHandle;
|
||||
|
||||
try {
|
||||
const { fileHandle } = await saveAsJSON(elements, appState);
|
||||
const { fileHandle } = isImageFileHandle(appState.fileHandle)
|
||||
? await resaveAsImageWithScene(elements, appState)
|
||||
: await saveAsJSON(elements, appState);
|
||||
|
||||
return {
|
||||
commitToHistory: false,
|
||||
appState: {
|
||||
...appState,
|
||||
fileHandle,
|
||||
toastMessage: fileHandleExists
|
||||
? fileHandle.name
|
||||
? fileHandle?.name
|
||||
? t("toast.fileSavedToFilename").replace(
|
||||
"{filename}",
|
||||
`"${fileHandle.name}"`,
|
||||
@@ -128,20 +160,16 @@ export const actionSaveScene = register({
|
||||
},
|
||||
keyTest: (event) =>
|
||||
event.key === KEYS.S && event[KEYS.CTRL_OR_CMD] && !event.shiftKey,
|
||||
PanelComponent: ({ updateData }) => (
|
||||
<ToolButton
|
||||
type="button"
|
||||
icon={save}
|
||||
title={t("buttons.save")}
|
||||
aria-label={t("buttons.save")}
|
||||
showAriaLabel={useIsMobile()}
|
||||
onClick={() => updateData(null)}
|
||||
PanelComponent: ({ updateData, appState }) => (
|
||||
<ActiveFile
|
||||
onSave={() => updateData(null)}
|
||||
fileName={appState.fileHandle?.name}
|
||||
/>
|
||||
),
|
||||
});
|
||||
|
||||
export const actionSaveAsScene = register({
|
||||
name: "saveAsScene",
|
||||
export const actionSaveFileToDisk = register({
|
||||
name: "saveFileToDisk",
|
||||
perform: async (elements, appState, value) => {
|
||||
try {
|
||||
const { fileHandle } = await saveAsJSON(elements, {
|
||||
@@ -165,8 +193,9 @@ export const actionSaveAsScene = register({
|
||||
title={t("buttons.saveAs")}
|
||||
aria-label={t("buttons.saveAs")}
|
||||
showAriaLabel={useIsMobile()}
|
||||
hidden={!supported}
|
||||
hidden={!nativeFileSystemSupported}
|
||||
onClick={() => updateData(null)}
|
||||
data-testid="save-as-button"
|
||||
/>
|
||||
),
|
||||
});
|
||||
@@ -178,7 +207,7 @@ export const actionLoadScene = register({
|
||||
const {
|
||||
elements: loadedElements,
|
||||
appState: loadedAppState,
|
||||
} = await loadFromJSON(appState);
|
||||
} = await loadFromJSON(appState, elements);
|
||||
return {
|
||||
elements: loadedElements,
|
||||
appState: loadedAppState,
|
||||
@@ -204,6 +233,7 @@ export const actionLoadScene = register({
|
||||
aria-label={t("buttons.load")}
|
||||
showAriaLabel={useIsMobile()}
|
||||
onClick={updateData}
|
||||
data-testid="load-button"
|
||||
/>
|
||||
),
|
||||
});
|
||||
@@ -226,9 +256,9 @@ export const actionExportWithDarkMode = register({
|
||||
}}
|
||||
>
|
||||
<DarkModeToggle
|
||||
value={appState.exportWithDarkMode ? "dark" : "light"}
|
||||
onChange={(theme: Appearence) => {
|
||||
updateData(theme === "dark");
|
||||
value={appState.exportWithDarkMode ? THEME.DARK : THEME.LIGHT}
|
||||
onChange={(theme: Theme) => {
|
||||
updateData(theme === THEME.DARK);
|
||||
}}
|
||||
title={t("labels.toggleExportColorScheme")}
|
||||
/>
|
||||
|
@@ -1,7 +1,6 @@
|
||||
import { KEYS } from "../keys";
|
||||
import { isInvisiblySmallElement } from "../element";
|
||||
import { resetCursor } from "../utils";
|
||||
import React from "react";
|
||||
import { ToolButton } from "../components/ToolButton";
|
||||
import { done } from "../components/icons";
|
||||
import { t } from "../i18n";
|
||||
@@ -18,7 +17,7 @@ import { isBindingElement } from "../element/typeChecks";
|
||||
|
||||
export const actionFinalize = register({
|
||||
name: "finalize",
|
||||
perform: (elements, appState, _, { canvas }) => {
|
||||
perform: (elements, appState, _, { canvas, focusContainer }) => {
|
||||
if (appState.editingLinearElement) {
|
||||
const {
|
||||
elementId,
|
||||
@@ -51,19 +50,19 @@ export const actionFinalize = register({
|
||||
|
||||
let newElements = elements;
|
||||
if (window.document.activeElement instanceof HTMLElement) {
|
||||
window.document.activeElement.blur();
|
||||
focusContainer();
|
||||
}
|
||||
|
||||
const multiPointElement = appState.multiElement
|
||||
? appState.multiElement
|
||||
: appState.editingElement?.type === "draw"
|
||||
: appState.editingElement?.type === "freedraw"
|
||||
? appState.editingElement
|
||||
: null;
|
||||
|
||||
if (multiPointElement) {
|
||||
// pen and mouse have hover
|
||||
if (
|
||||
multiPointElement.type !== "draw" &&
|
||||
multiPointElement.type !== "freedraw" &&
|
||||
appState.lastPointerDownWith !== "touch"
|
||||
) {
|
||||
const { points, lastCommittedPoint } = multiPointElement;
|
||||
@@ -86,7 +85,7 @@ export const actionFinalize = register({
|
||||
const isLoop = isPathALoop(multiPointElement.points, appState.zoom.value);
|
||||
if (
|
||||
multiPointElement.type === "line" ||
|
||||
multiPointElement.type === "draw"
|
||||
multiPointElement.type === "freedraw"
|
||||
) {
|
||||
if (isLoop) {
|
||||
const linePoints = multiPointElement.points;
|
||||
@@ -118,22 +117,24 @@ export const actionFinalize = register({
|
||||
);
|
||||
}
|
||||
|
||||
if (!appState.elementLocked && appState.elementType !== "draw") {
|
||||
if (!appState.elementLocked && appState.elementType !== "freedraw") {
|
||||
appState.selectedElementIds[multiPointElement.id] = true;
|
||||
}
|
||||
}
|
||||
|
||||
if (
|
||||
(!appState.elementLocked && appState.elementType !== "draw") ||
|
||||
(!appState.elementLocked && appState.elementType !== "freedraw") ||
|
||||
!multiPointElement
|
||||
) {
|
||||
resetCursor(canvas);
|
||||
}
|
||||
|
||||
return {
|
||||
elements: newElements,
|
||||
appState: {
|
||||
...appState,
|
||||
elementType:
|
||||
(appState.elementLocked || appState.elementType === "draw") &&
|
||||
(appState.elementLocked || appState.elementType === "freedraw") &&
|
||||
multiPointElement
|
||||
? appState.elementType
|
||||
: "selection",
|
||||
@@ -145,14 +146,14 @@ export const actionFinalize = register({
|
||||
selectedElementIds:
|
||||
multiPointElement &&
|
||||
!appState.elementLocked &&
|
||||
appState.elementType !== "draw"
|
||||
appState.elementType !== "freedraw"
|
||||
? {
|
||||
...appState.selectedElementIds,
|
||||
[multiPointElement.id]: true,
|
||||
}
|
||||
: appState.selectedElementIds,
|
||||
},
|
||||
commitToHistory: appState.elementType === "draw",
|
||||
commitToHistory: appState.elementType === "freedraw",
|
||||
};
|
||||
},
|
||||
keyTest: (event, appState) =>
|
||||
|
207
src/actions/actionFlip.ts
Normal file
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 { isFreeDrawElement, 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) || isFreeDrawElement(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,
|
||||
});
|
||||
};
|
@@ -1,4 +1,3 @@
|
||||
import React from "react";
|
||||
import { CODES, KEYS } from "../keys";
|
||||
import { t } from "../i18n";
|
||||
import { getShortcutKey } from "../utils";
|
||||
|
@@ -1,9 +1,8 @@
|
||||
import { Action, ActionResult } from "./types";
|
||||
import React from "react";
|
||||
import { undo, redo } from "../components/icons";
|
||||
import { ToolButton } from "../components/ToolButton";
|
||||
import { t } from "../i18n";
|
||||
import { SceneHistory, HistoryEntry } from "../history";
|
||||
import History, { HistoryEntry } from "../history";
|
||||
import { ExcalidrawElement } from "../element/types";
|
||||
import { AppState } from "../types";
|
||||
import { isWindows, KEYS } from "../keys";
|
||||
@@ -59,7 +58,7 @@ const writeData = (
|
||||
return { commitToHistory };
|
||||
};
|
||||
|
||||
type ActionCreator = (history: SceneHistory) => Action;
|
||||
type ActionCreator = (history: History) => Action;
|
||||
|
||||
export const createUndoAction: ActionCreator = (history) => ({
|
||||
name: "undo",
|
||||
@@ -69,12 +68,13 @@ export const createUndoAction: ActionCreator = (history) => ({
|
||||
event[KEYS.CTRL_OR_CMD] &&
|
||||
event.key.toLowerCase() === KEYS.Z &&
|
||||
!event.shiftKey,
|
||||
PanelComponent: ({ updateData }) => (
|
||||
PanelComponent: ({ updateData, data }) => (
|
||||
<ToolButton
|
||||
type="button"
|
||||
icon={undo}
|
||||
aria-label={t("buttons.undo")}
|
||||
onClick={updateData}
|
||||
size={data?.size || "medium"}
|
||||
/>
|
||||
),
|
||||
commitToHistory: () => false,
|
||||
@@ -89,12 +89,13 @@ export const createRedoAction: ActionCreator = (history) => ({
|
||||
event.shiftKey &&
|
||||
event.key.toLowerCase() === KEYS.Z) ||
|
||||
(isWindows && event.ctrlKey && !event.shiftKey && event.key === KEYS.Y),
|
||||
PanelComponent: ({ updateData }) => (
|
||||
PanelComponent: ({ updateData, data }) => (
|
||||
<ToolButton
|
||||
type="button"
|
||||
icon={redo}
|
||||
aria-label={t("buttons.redo")}
|
||||
onClick={updateData}
|
||||
size={data?.size || "medium"}
|
||||
/>
|
||||
),
|
||||
commitToHistory: () => false,
|
||||
|
@@ -1,4 +1,3 @@
|
||||
import React from "react";
|
||||
import { menu, palette } from "../components/icons";
|
||||
import { ToolButton } from "../components/ToolButton";
|
||||
import { t } from "../i18n";
|
||||
@@ -70,7 +69,10 @@ export const actionFullScreen = register({
|
||||
|
||||
export const actionShortcuts = register({
|
||||
name: "toggleShortcuts",
|
||||
perform: (_elements, appState) => {
|
||||
perform: (_elements, appState, _, { focusContainer }) => {
|
||||
if (appState.showHelpDialog) {
|
||||
focusContainer();
|
||||
}
|
||||
return {
|
||||
appState: {
|
||||
...appState,
|
||||
|
@@ -1,4 +1,3 @@
|
||||
import React from "react";
|
||||
import { getClientColors, getClientInitials } from "../clients";
|
||||
import { Avatar } from "../components/Avatar";
|
||||
import { centerScrollOn } from "../scene/scroll";
|
||||
@@ -30,8 +29,8 @@ export const actionGoToCollaborator = register({
|
||||
commitToHistory: false,
|
||||
};
|
||||
},
|
||||
PanelComponent: ({ appState, updateData, id }) => {
|
||||
const clientId = id;
|
||||
PanelComponent: ({ appState, updateData, data }) => {
|
||||
const clientId: string | undefined = data?.id;
|
||||
if (!clientId) {
|
||||
return null;
|
||||
}
|
||||
|
@@ -1,4 +1,3 @@
|
||||
import React from "react";
|
||||
import { AppState } from "../../src/types";
|
||||
import { ButtonIconSelect } from "../components/ButtonIconSelect";
|
||||
import { ColorPicker } from "../components/ColorPicker";
|
||||
@@ -13,6 +12,13 @@ import {
|
||||
FillCrossHatchIcon,
|
||||
FillHachureIcon,
|
||||
FillSolidIcon,
|
||||
FontFamilyCodeIcon,
|
||||
FontFamilyHandDrawnIcon,
|
||||
FontFamilyNormalIcon,
|
||||
FontSizeExtraLargeIcon,
|
||||
FontSizeLargeIcon,
|
||||
FontSizeMediumIcon,
|
||||
FontSizeSmallIcon,
|
||||
SloppinessArchitectIcon,
|
||||
SloppinessArtistIcon,
|
||||
SloppinessCartoonistIcon,
|
||||
@@ -20,18 +26,15 @@ import {
|
||||
StrokeStyleDottedIcon,
|
||||
StrokeStyleSolidIcon,
|
||||
StrokeWidthIcon,
|
||||
FontSizeSmallIcon,
|
||||
FontSizeMediumIcon,
|
||||
FontSizeLargeIcon,
|
||||
FontSizeExtraLargeIcon,
|
||||
FontFamilyHandDrawnIcon,
|
||||
FontFamilyNormalIcon,
|
||||
FontFamilyCodeIcon,
|
||||
TextAlignLeftIcon,
|
||||
TextAlignCenterIcon,
|
||||
TextAlignLeftIcon,
|
||||
TextAlignRightIcon,
|
||||
} from "../components/icons";
|
||||
import { DEFAULT_FONT_FAMILY, DEFAULT_FONT_SIZE } from "../constants";
|
||||
import {
|
||||
DEFAULT_FONT_FAMILY,
|
||||
DEFAULT_FONT_SIZE,
|
||||
FONT_FAMILY,
|
||||
} from "../constants";
|
||||
import {
|
||||
getNonDeletedElements,
|
||||
isTextElement,
|
||||
@@ -44,7 +47,7 @@ import {
|
||||
ExcalidrawElement,
|
||||
ExcalidrawLinearElement,
|
||||
ExcalidrawTextElement,
|
||||
FontFamily,
|
||||
FontFamilyValues,
|
||||
TextAlign,
|
||||
} from "../element/types";
|
||||
import { getLanguage, t } from "../i18n";
|
||||
@@ -99,13 +102,18 @@ export const actionChangeStrokeColor = register({
|
||||
name: "changeStrokeColor",
|
||||
perform: (elements, appState, value) => {
|
||||
return {
|
||||
elements: changeProperty(elements, appState, (el) =>
|
||||
newElementWith(el, {
|
||||
strokeColor: value,
|
||||
}),
|
||||
),
|
||||
appState: { ...appState, currentItemStrokeColor: value },
|
||||
commitToHistory: true,
|
||||
...(value.currentItemStrokeColor && {
|
||||
elements: changeProperty(elements, appState, (el) =>
|
||||
newElementWith(el, {
|
||||
strokeColor: value.currentItemStrokeColor,
|
||||
}),
|
||||
),
|
||||
}),
|
||||
appState: {
|
||||
...appState,
|
||||
...value,
|
||||
},
|
||||
commitToHistory: !!value.currentItemStrokeColor,
|
||||
};
|
||||
},
|
||||
PanelComponent: ({ elements, appState, updateData }) => (
|
||||
@@ -120,7 +128,11 @@ export const actionChangeStrokeColor = register({
|
||||
(element) => element.strokeColor,
|
||||
appState.currentItemStrokeColor,
|
||||
)}
|
||||
onChange={updateData}
|
||||
onChange={(color) => updateData({ currentItemStrokeColor: color })}
|
||||
isActive={appState.openPopup === "strokeColorPicker"}
|
||||
setActive={(active) =>
|
||||
updateData({ openPopup: active ? "strokeColorPicker" : null })
|
||||
}
|
||||
/>
|
||||
</>
|
||||
),
|
||||
@@ -130,13 +142,18 @@ export const actionChangeBackgroundColor = register({
|
||||
name: "changeBackgroundColor",
|
||||
perform: (elements, appState, value) => {
|
||||
return {
|
||||
elements: changeProperty(elements, appState, (el) =>
|
||||
newElementWith(el, {
|
||||
backgroundColor: value,
|
||||
}),
|
||||
),
|
||||
appState: { ...appState, currentItemBackgroundColor: value },
|
||||
commitToHistory: true,
|
||||
...(value.currentItemBackgroundColor && {
|
||||
elements: changeProperty(elements, appState, (el) =>
|
||||
newElementWith(el, {
|
||||
backgroundColor: value.currentItemBackgroundColor,
|
||||
}),
|
||||
),
|
||||
}),
|
||||
appState: {
|
||||
...appState,
|
||||
...value,
|
||||
},
|
||||
commitToHistory: !!value.currentItemBackgroundColor,
|
||||
};
|
||||
},
|
||||
PanelComponent: ({ elements, appState, updateData }) => (
|
||||
@@ -151,7 +168,11 @@ export const actionChangeBackgroundColor = register({
|
||||
(element) => element.backgroundColor,
|
||||
appState.currentItemBackgroundColor,
|
||||
)}
|
||||
onChange={updateData}
|
||||
onChange={(color) => updateData({ currentItemBackgroundColor: color })}
|
||||
isActive={appState.openPopup === "backgroundColorPicker"}
|
||||
setActive={(active) =>
|
||||
updateData({ openPopup: active ? "backgroundColorPicker" : null })
|
||||
}
|
||||
/>
|
||||
</>
|
||||
),
|
||||
@@ -481,19 +502,23 @@ export const actionChangeFontFamily = register({
|
||||
};
|
||||
},
|
||||
PanelComponent: ({ elements, appState, updateData }) => {
|
||||
const options: { value: FontFamily; text: string; icon: JSX.Element }[] = [
|
||||
const options: {
|
||||
value: FontFamilyValues;
|
||||
text: string;
|
||||
icon: JSX.Element;
|
||||
}[] = [
|
||||
{
|
||||
value: 1,
|
||||
value: FONT_FAMILY.Virgil,
|
||||
text: t("labels.handDrawn"),
|
||||
icon: <FontFamilyHandDrawnIcon theme={appState.theme} />,
|
||||
},
|
||||
{
|
||||
value: 2,
|
||||
value: FONT_FAMILY.Helvetica,
|
||||
text: t("labels.normal"),
|
||||
icon: <FontFamilyNormalIcon theme={appState.theme} />,
|
||||
},
|
||||
{
|
||||
value: 3,
|
||||
value: FONT_FAMILY.Cascadia,
|
||||
text: t("labels.code"),
|
||||
icon: <FontFamilyCodeIcon theme={appState.theme} />,
|
||||
},
|
||||
@@ -502,7 +527,7 @@ export const actionChangeFontFamily = register({
|
||||
return (
|
||||
<fieldset>
|
||||
<legend>{t("labels.fontFamily")}</legend>
|
||||
<ButtonIconSelect<FontFamily | false>
|
||||
<ButtonIconSelect<FontFamilyValues | false>
|
||||
group="font-family"
|
||||
options={options}
|
||||
value={getFormValue(
|
||||
|
@@ -1,4 +1,5 @@
|
||||
import { register } from "./register";
|
||||
import { CODES, KEYS } from "../keys";
|
||||
|
||||
export const actionToggleStats = register({
|
||||
name: "stats",
|
||||
@@ -13,4 +14,6 @@ export const actionToggleStats = register({
|
||||
},
|
||||
checked: (appState) => appState.showStats,
|
||||
contextItemLabel: "stats.title",
|
||||
keyTest: (event) =>
|
||||
!event[KEYS.CTRL_OR_CMD] && event.altKey && event.code === CODES.SLASH,
|
||||
});
|
||||
|
@@ -10,7 +10,6 @@ export const actionToggleViewMode = register({
|
||||
appState: {
|
||||
...appState,
|
||||
viewModeEnabled: !this.checked!(appState),
|
||||
selectedElementIds: {},
|
||||
},
|
||||
commitToHistory: false,
|
||||
};
|
||||
|
@@ -26,6 +26,7 @@ export {
|
||||
actionZoomOut,
|
||||
actionResetZoom,
|
||||
actionZoomToFit,
|
||||
actionToggleTheme,
|
||||
} from "./actionCanvas";
|
||||
|
||||
export { actionFinalize } from "./actionFinalize";
|
||||
@@ -33,8 +34,8 @@ export { actionFinalize } from "./actionFinalize";
|
||||
export {
|
||||
actionChangeProjectName,
|
||||
actionChangeExportBackground,
|
||||
actionSaveScene,
|
||||
actionSaveAsScene,
|
||||
actionSaveToActiveFile,
|
||||
actionSaveFileToDisk,
|
||||
actionLoadScene,
|
||||
} from "./actionExport";
|
||||
|
||||
@@ -66,6 +67,8 @@ export {
|
||||
distributeVertically,
|
||||
} from "./actionDistribute";
|
||||
|
||||
export { actionFlipHorizontal, actionFlipVertical } from "./actionFlip";
|
||||
|
||||
export {
|
||||
actionCopy,
|
||||
actionCut,
|
||||
|
@@ -5,14 +5,21 @@ import {
|
||||
UpdaterFn,
|
||||
ActionName,
|
||||
ActionResult,
|
||||
PanelComponentProps,
|
||||
} from "./types";
|
||||
import { ExcalidrawElement } from "../element/types";
|
||||
import { AppState, ExcalidrawProps } from "../types";
|
||||
import { AppProps, AppState } from "../types";
|
||||
import { MODES } from "../constants";
|
||||
import Library from "../data/library";
|
||||
|
||||
// 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;
|
||||
focusContainer: () => void;
|
||||
props: AppProps;
|
||||
library: Library;
|
||||
};
|
||||
|
||||
export class ActionManager implements ActionsManagerInterface {
|
||||
actions = {} as ActionsManagerInterface["actions"];
|
||||
@@ -51,11 +58,15 @@ export class ActionManager implements ActionsManagerInterface {
|
||||
actions.forEach((action) => this.registerAction(action));
|
||||
}
|
||||
|
||||
handleKeyDown(event: KeyboardEvent) {
|
||||
handleKeyDown(event: React.KeyboardEvent | 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,
|
||||
@@ -97,12 +108,19 @@ export class ActionManager implements ActionsManagerInterface {
|
||||
);
|
||||
}
|
||||
|
||||
// Id is an attribute that we can use to pass in data like keys.
|
||||
// This is needed for dynamically generated action components
|
||||
// like the user list. We can use this key to extract more
|
||||
// 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]) {
|
||||
/**
|
||||
* @param data additional data sent to the PanelComponent
|
||||
*/
|
||||
renderAction = (name: ActionName, data?: PanelComponentProps["data"]) => {
|
||||
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) => {
|
||||
@@ -121,8 +139,8 @@ export class ActionManager implements ActionsManagerInterface {
|
||||
elements={this.getElementsIncludingDeleted()}
|
||||
appState={this.getAppState()}
|
||||
updateData={updateData}
|
||||
id={id}
|
||||
appProps={this.app.props}
|
||||
data={data}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
@@ -23,7 +23,9 @@ export type ShortcutName =
|
||||
| "zenMode"
|
||||
| "stats"
|
||||
| "addToLibrary"
|
||||
| "viewMode";
|
||||
| "viewMode"
|
||||
| "flipHorizontal"
|
||||
| "flipVertical";
|
||||
|
||||
const shortcutMap: Record<ShortcutName, string[]> = {
|
||||
cut: [getShortcutKey("CtrlOrCmd+X")],
|
||||
@@ -55,8 +57,10 @@ const shortcutMap: Record<ShortcutName, string[]> = {
|
||||
ungroup: [getShortcutKey("CtrlOrCmd+Shift+G")],
|
||||
gridMode: [getShortcutKey("CtrlOrCmd+'")],
|
||||
zenMode: [getShortcutKey("Alt+Z")],
|
||||
stats: [],
|
||||
stats: [getShortcutKey("Alt+/")],
|
||||
addToLibrary: [],
|
||||
flipHorizontal: [getShortcutKey("Shift+H")],
|
||||
flipVertical: [getShortcutKey("Shift+V")],
|
||||
viewMode: [getShortcutKey("Alt+R")],
|
||||
};
|
||||
|
||||
|
@@ -1,22 +1,33 @@
|
||||
import React from "react";
|
||||
import { ExcalidrawElement } from "../element/types";
|
||||
import { AppState, ExcalidrawProps } from "../types";
|
||||
import Library from "../data/library";
|
||||
import { ToolButtonSize } from "../components/ToolButton";
|
||||
|
||||
/** 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;
|
||||
}
|
||||
| false;
|
||||
|
||||
type AppAPI = {
|
||||
canvas: HTMLCanvasElement | null;
|
||||
focusContainer(): void;
|
||||
library: Library;
|
||||
};
|
||||
|
||||
type ActionFn = (
|
||||
elements: readonly ExcalidrawElement[],
|
||||
appState: Readonly<AppState>,
|
||||
formData: any,
|
||||
app: { canvas: HTMLCanvasElement | null },
|
||||
app: AppAPI,
|
||||
) => ActionResult | Promise<ActionResult>;
|
||||
|
||||
export type UpdaterFn = (res: ActionResult) => void;
|
||||
@@ -42,6 +53,7 @@ export type ActionName =
|
||||
| "changeBackgroundColor"
|
||||
| "changeFillStyle"
|
||||
| "changeStrokeWidth"
|
||||
| "changeStrokeShape"
|
||||
| "changeSloppiness"
|
||||
| "changeStrokeStyle"
|
||||
| "changeArrowhead"
|
||||
@@ -55,9 +67,9 @@ export type ActionName =
|
||||
| "changeProjectName"
|
||||
| "changeExportBackground"
|
||||
| "changeExportEmbedScene"
|
||||
| "changeShouldAddWatermark"
|
||||
| "saveScene"
|
||||
| "saveAsScene"
|
||||
| "changeExportScale"
|
||||
| "saveToActiveFile"
|
||||
| "saveFileToDisk"
|
||||
| "loadScene"
|
||||
| "duplicateSelection"
|
||||
| "deleteSelectedElements"
|
||||
@@ -85,22 +97,27 @@ export type ActionName =
|
||||
| "alignHorizontallyCentered"
|
||||
| "distributeHorizontally"
|
||||
| "distributeVertically"
|
||||
| "flipHorizontal"
|
||||
| "flipVertical"
|
||||
| "viewMode"
|
||||
| "exportWithDarkMode";
|
||||
| "exportWithDarkMode"
|
||||
| "toggleTheme";
|
||||
|
||||
export type PanelComponentProps = {
|
||||
elements: readonly ExcalidrawElement[];
|
||||
appState: AppState;
|
||||
updateData: (formData?: any) => void;
|
||||
appProps: ExcalidrawProps;
|
||||
data?: Partial<{ id: string; size: ToolButtonSize }>;
|
||||
};
|
||||
|
||||
export interface Action {
|
||||
name: ActionName;
|
||||
PanelComponent?: React.FC<{
|
||||
elements: readonly ExcalidrawElement[];
|
||||
appState: AppState;
|
||||
updateData: (formData?: any) => void;
|
||||
appProps: ExcalidrawProps;
|
||||
id?: string;
|
||||
}>;
|
||||
PanelComponent?: React.FC<PanelComponentProps>;
|
||||
perform: ActionFn;
|
||||
keyPriority?: number;
|
||||
keyTest?: (
|
||||
event: KeyboardEvent,
|
||||
event: React.KeyboardEvent | KeyboardEvent,
|
||||
appState: AppState,
|
||||
elements: readonly ExcalidrawElement[],
|
||||
) => boolean;
|
||||
@@ -115,6 +132,7 @@ export interface Action {
|
||||
export interface ActionsManagerInterface {
|
||||
actions: Record<ActionName, Action>;
|
||||
registerAction: (action: Action) => void;
|
||||
handleKeyDown: (event: KeyboardEvent) => boolean;
|
||||
handleKeyDown: (event: React.KeyboardEvent | KeyboardEvent) => boolean;
|
||||
renderAction: (name: ActionName) => React.ReactElement | null;
|
||||
executeAction: (action: Action) => void;
|
||||
}
|
||||
|
@@ -3,17 +3,23 @@ import {
|
||||
DEFAULT_FONT_FAMILY,
|
||||
DEFAULT_FONT_SIZE,
|
||||
DEFAULT_TEXT_ALIGN,
|
||||
EXPORT_SCALES,
|
||||
THEME,
|
||||
} from "./constants";
|
||||
import { t } from "./i18n";
|
||||
import { AppState, NormalizedZoomValue } from "./types";
|
||||
import { getDateTime } from "./utils";
|
||||
|
||||
const defaultExportScale = EXPORT_SCALES.includes(devicePixelRatio)
|
||||
? devicePixelRatio
|
||||
: 1;
|
||||
|
||||
export const getDefaultAppState = (): Omit<
|
||||
AppState,
|
||||
"offsetTop" | "offsetLeft"
|
||||
"offsetTop" | "offsetLeft" | "width" | "height"
|
||||
> => {
|
||||
return {
|
||||
theme: "light",
|
||||
theme: THEME.LIGHT,
|
||||
collaborators: new Map(),
|
||||
currentChartType: "bar",
|
||||
currentItemBackgroundColor: "transparent",
|
||||
@@ -39,11 +45,11 @@ export const getDefaultAppState = (): Omit<
|
||||
elementType: "selection",
|
||||
errorMessage: null,
|
||||
exportBackground: true,
|
||||
exportScale: defaultExportScale,
|
||||
exportEmbedScene: false,
|
||||
exportWithDarkMode: false,
|
||||
fileHandle: null,
|
||||
gridSize: null,
|
||||
height: window.innerHeight,
|
||||
isBindingEnabled: true,
|
||||
isLibraryOpen: false,
|
||||
isLoading: false,
|
||||
@@ -53,6 +59,7 @@ export const getDefaultAppState = (): Omit<
|
||||
multiElement: null,
|
||||
name: `${t("labels.untitled")}-${getDateTime()}`,
|
||||
openMenu: null,
|
||||
openPopup: null,
|
||||
pasteDialog: { shown: false, data: null },
|
||||
previousSelectedElementIds: {},
|
||||
resizingElement: null,
|
||||
@@ -62,7 +69,6 @@ export const getDefaultAppState = (): Omit<
|
||||
selectedElementIds: {},
|
||||
selectedGroupIds: {},
|
||||
selectionElement: null,
|
||||
shouldAddWatermark: false,
|
||||
shouldCacheIgnoreZoom: false,
|
||||
showHelpDialog: false,
|
||||
showStats: false,
|
||||
@@ -70,7 +76,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,
|
||||
@@ -119,6 +124,7 @@ const APP_STATE_STORAGE_CONF = (<
|
||||
errorMessage: { browser: false, export: false },
|
||||
exportBackground: { browser: true, export: false },
|
||||
exportEmbedScene: { browser: true, export: false },
|
||||
exportScale: { browser: true, export: false },
|
||||
exportWithDarkMode: { browser: true, export: false },
|
||||
fileHandle: { browser: false, export: false },
|
||||
gridSize: { browser: true, export: true },
|
||||
@@ -134,6 +140,7 @@ const APP_STATE_STORAGE_CONF = (<
|
||||
offsetLeft: { browser: false, export: false },
|
||||
offsetTop: { browser: false, export: false },
|
||||
openMenu: { browser: true, export: false },
|
||||
openPopup: { browser: false, export: false },
|
||||
pasteDialog: { browser: false, export: false },
|
||||
previousSelectedElementIds: { browser: true, export: false },
|
||||
resizingElement: { browser: false, export: false },
|
||||
@@ -143,7 +150,6 @@ const APP_STATE_STORAGE_CONF = (<
|
||||
selectedElementIds: { browser: true, export: false },
|
||||
selectedGroupIds: { browser: true, export: false },
|
||||
selectionElement: { browser: false, export: false },
|
||||
shouldAddWatermark: { browser: true, export: false },
|
||||
shouldCacheIgnoreZoom: { browser: true, export: false },
|
||||
showHelpDialog: { browser: false, export: false },
|
||||
showStats: { browser: true, export: false },
|
||||
|
@@ -6,7 +6,6 @@ import { getSelectedElements } from "./scene";
|
||||
import { AppState } from "./types";
|
||||
import { SVG_EXPORT_TAG } from "./scene/export";
|
||||
import { tryParseSpreadsheet, Spreadsheet, VALID_SPREADSHEET } from "./charts";
|
||||
import { canvasToBlob } from "./data/blob";
|
||||
import { EXPORT_DATA_TYPES } from "./constants";
|
||||
|
||||
type ElementsClipboard = {
|
||||
@@ -14,6 +13,13 @@ type ElementsClipboard = {
|
||||
elements: ExcalidrawElement[];
|
||||
};
|
||||
|
||||
export interface ClipboardData {
|
||||
spreadsheet?: Spreadsheet;
|
||||
elements?: readonly ExcalidrawElement[];
|
||||
text?: string;
|
||||
errorMessage?: string;
|
||||
}
|
||||
|
||||
let CLIPBOARD = "";
|
||||
let PREFER_APP_CLIPBOARD = false;
|
||||
|
||||
@@ -110,12 +116,7 @@ const getSystemClipboard = async (
|
||||
*/
|
||||
export const parseClipboard = async (
|
||||
event: ClipboardEvent | null,
|
||||
): Promise<{
|
||||
spreadsheet?: Spreadsheet;
|
||||
elements?: readonly ExcalidrawElement[];
|
||||
text?: string;
|
||||
errorMessage?: string;
|
||||
}> => {
|
||||
): Promise<ClipboardData> => {
|
||||
const systemClipboard = await getSystemClipboard(event);
|
||||
|
||||
// if system clipboard empty, couldn't be resolved, or contains previously
|
||||
@@ -150,8 +151,7 @@ export const parseClipboard = async (
|
||||
}
|
||||
};
|
||||
|
||||
export const copyCanvasToClipboardAsPng = async (canvas: HTMLCanvasElement) => {
|
||||
const blob = await canvasToBlob(canvas);
|
||||
export const copyBlobToClipboardAsPng = async (blob: Blob) => {
|
||||
await navigator.clipboard.write([
|
||||
new window.ClipboardItem({ "image/png": blob }),
|
||||
]);
|
||||
|
@@ -3,13 +3,14 @@ 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 "../components/App";
|
||||
import {
|
||||
canChangeSharpness,
|
||||
canHaveArrowheads,
|
||||
getTargetElements,
|
||||
hasBackground,
|
||||
hasStroke,
|
||||
hasStrokeStyle,
|
||||
hasStrokeWidth,
|
||||
hasText,
|
||||
} from "../scene";
|
||||
import { SHAPES } from "../shapes";
|
||||
@@ -53,10 +54,17 @@ export const SelectedShapeActions = ({
|
||||
{showChangeBackgroundIcons && renderAction("changeBackgroundColor")}
|
||||
{showFillIcons && renderAction("changeFillStyle")}
|
||||
|
||||
{(hasStroke(elementType) ||
|
||||
targetElements.some((element) => hasStroke(element.type))) && (
|
||||
{(hasStrokeWidth(elementType) ||
|
||||
targetElements.some((element) => hasStrokeWidth(element.type))) &&
|
||||
renderAction("changeStrokeWidth")}
|
||||
|
||||
{(elementType === "freedraw" ||
|
||||
targetElements.some((element) => element.type === "freedraw")) &&
|
||||
renderAction("changeStrokeShape")}
|
||||
|
||||
{(hasStrokeStyle(elementType) ||
|
||||
targetElements.some((element) => hasStrokeStyle(element.type))) && (
|
||||
<>
|
||||
{renderAction("changeStrokeWidth")}
|
||||
{renderAction("changeStrokeStyle")}
|
||||
{renderAction("changeSloppiness")}
|
||||
</>
|
||||
@@ -143,23 +151,14 @@ export const SelectedShapeActions = ({
|
||||
);
|
||||
};
|
||||
|
||||
const LIBRARY_ICON = (
|
||||
// fa-th-large
|
||||
<svg viewBox="0 0 512 512">
|
||||
<path d="M296 32h192c13.255 0 24 10.745 24 24v160c0 13.255-10.745 24-24 24H296c-13.255 0-24-10.745-24-24V56c0-13.255 10.745-24 24-24zm-80 0H24C10.745 32 0 42.745 0 56v160c0 13.255 10.745 24 24 24h192c13.255 0 24-10.745 24-24V56c0-13.255-10.745-24-24-24zM0 296v160c0 13.255 10.745 24 24 24h192c13.255 0 24-10.745 24-24V296c0-13.255-10.745-24-24-24H24c-13.255 0-24 10.745-24 24zm296 184h192c13.255 0 24-10.745 24-24V296c0-13.255-10.745-24-24-24H296c-13.255 0-24 10.745-24 24v160c0 13.255 10.745 24 24 24z" />
|
||||
</svg>
|
||||
);
|
||||
|
||||
export const ShapesSwitcher = ({
|
||||
canvas,
|
||||
elementType,
|
||||
setAppState,
|
||||
isLibraryOpen,
|
||||
}: {
|
||||
canvas: HTMLCanvasElement | null;
|
||||
elementType: ExcalidrawElement["type"];
|
||||
setAppState: React.Component<any, AppState>["setState"];
|
||||
isLibraryOpen: boolean;
|
||||
}) => (
|
||||
<>
|
||||
{SHAPES.map(({ value, icon, key }, index) => {
|
||||
@@ -193,19 +192,6 @@ export const ShapesSwitcher = ({
|
||||
/>
|
||||
);
|
||||
})}
|
||||
<ToolButton
|
||||
className="Shape ToolIcon_type_button__library"
|
||||
type="button"
|
||||
icon={LIBRARY_ICON}
|
||||
name="editor-library"
|
||||
keyBindingLabel="9"
|
||||
aria-keyshortcuts="9"
|
||||
title={`${capitalizeString(t("toolBar.library"))} — 9`}
|
||||
aria-label={capitalizeString(t("toolBar.library"))}
|
||||
onClick={() => {
|
||||
setAppState({ isLibraryOpen: !isLibraryOpen });
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -218,12 +204,9 @@ export const ZoomActions = ({
|
||||
}) => (
|
||||
<Stack.Col gap={1}>
|
||||
<Stack.Row gap={1} align="center">
|
||||
{renderAction("zoomIn")}
|
||||
{renderAction("zoomOut")}
|
||||
{renderAction("zoomIn")}
|
||||
{renderAction("resetZoom")}
|
||||
<div style={{ marginInlineStart: 4 }}>
|
||||
{(zoom.value * 100).toFixed(0)}%
|
||||
</div>
|
||||
</Stack.Row>
|
||||
</Stack.Col>
|
||||
);
|
||||
|
21
src/components/ActiveFile.scss
Normal file
21
src/components/ActiveFile.scss
Normal file
@@ -0,0 +1,21 @@
|
||||
.excalidraw {
|
||||
.ActiveFile {
|
||||
.ActiveFile__fileName {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
span {
|
||||
text-overflow: ellipsis;
|
||||
overflow: hidden;
|
||||
white-space: nowrap;
|
||||
width: 9.3em;
|
||||
}
|
||||
|
||||
svg {
|
||||
width: 1.15em;
|
||||
margin-inline-end: 0.3em;
|
||||
transform: scaleY(0.9);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
28
src/components/ActiveFile.tsx
Normal file
28
src/components/ActiveFile.tsx
Normal file
@@ -0,0 +1,28 @@
|
||||
import Stack from "../components/Stack";
|
||||
import { ToolButton } from "../components/ToolButton";
|
||||
import { save, file } from "../components/icons";
|
||||
import { t } from "../i18n";
|
||||
|
||||
import "./ActiveFile.scss";
|
||||
|
||||
type ActiveFileProps = {
|
||||
fileName?: string;
|
||||
onSave: () => void;
|
||||
};
|
||||
|
||||
export const ActiveFile = ({ fileName, onSave }: ActiveFileProps) => (
|
||||
<Stack.Row className="ActiveFile" gap={1} align="center">
|
||||
<span className="ActiveFile__fileName">
|
||||
{file}
|
||||
<span>{fileName}</span>
|
||||
</span>
|
||||
<ToolButton
|
||||
type="icon"
|
||||
icon={save}
|
||||
title={t("buttons.save")}
|
||||
aria-label={t("buttons.save")}
|
||||
onClick={onSave}
|
||||
data-testid="save-button"
|
||||
/>
|
||||
</Stack.Row>
|
||||
);
|
File diff suppressed because it is too large
Load Diff
@@ -1,7 +1,6 @@
|
||||
import React from "react";
|
||||
import { ActionManager } from "../actions/manager";
|
||||
import { AppState } from "../types";
|
||||
import { DarkModeToggle } from "./DarkModeToggle";
|
||||
|
||||
export const BackgroundPickerAndDarkModeToggle = ({
|
||||
appState,
|
||||
@@ -16,15 +15,6 @@ export const BackgroundPickerAndDarkModeToggle = ({
|
||||
}) => (
|
||||
<div style={{ display: "flex" }}>
|
||||
{actionManager.renderAction("changeViewBackgroundColor")}
|
||||
{showThemeBtn && (
|
||||
<div style={{ marginInlineStart: "0.25rem" }}>
|
||||
<DarkModeToggle
|
||||
value={appState.theme}
|
||||
onChange={(theme) => {
|
||||
setAppState({ theme });
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{showThemeBtn && actionManager.renderAction("toggleTheme")}
|
||||
</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 })}>
|
||||
|
@@ -1,4 +1,3 @@
|
||||
import React from "react";
|
||||
import clsx from "clsx";
|
||||
|
||||
// TODO: It might be "clever" to add option.icon to the existing component <ButtonSelect />
|
||||
|
@@ -1,4 +1,3 @@
|
||||
import React from "react";
|
||||
import clsx from "clsx";
|
||||
|
||||
export const ButtonSelect = <T extends Object>({
|
||||
|
53
src/components/Card.scss
Normal file
53
src/components/Card.scss
Normal file
@@ -0,0 +1,53 @@
|
||||
@import "../css/variables.module";
|
||||
|
||||
.excalidraw {
|
||||
.Card {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
|
||||
max-width: 290px;
|
||||
|
||||
margin: 1em;
|
||||
|
||||
text-align: center;
|
||||
|
||||
.Card-icon {
|
||||
font-size: 2.6em;
|
||||
display: flex;
|
||||
flex: 0 0 auto;
|
||||
padding: 1.4rem;
|
||||
border-radius: 50%;
|
||||
background: var(--card-color);
|
||||
color: $oc-white;
|
||||
|
||||
svg {
|
||||
width: 2.8rem;
|
||||
height: 2.8rem;
|
||||
}
|
||||
}
|
||||
|
||||
.Card-details {
|
||||
font-size: 0.96em;
|
||||
min-height: 90px;
|
||||
padding: 0 1em;
|
||||
margin-bottom: auto;
|
||||
}
|
||||
|
||||
& .Card-button.ToolIcon_type_button {
|
||||
height: 2.5rem;
|
||||
margin-top: 1em;
|
||||
margin-bottom: 0.3em;
|
||||
background-color: var(--card-color);
|
||||
&:hover {
|
||||
background-color: var(--card-color-darker);
|
||||
}
|
||||
&:active {
|
||||
background-color: var(--card-color-darkest);
|
||||
}
|
||||
.ToolIcon__label {
|
||||
color: $oc-white;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
20
src/components/Card.tsx
Normal file
20
src/components/Card.tsx
Normal file
@@ -0,0 +1,20 @@
|
||||
import OpenColor from "open-color";
|
||||
|
||||
import "./Card.scss";
|
||||
|
||||
export const Card: React.FC<{
|
||||
color: keyof OpenColor;
|
||||
}> = ({ children, color }) => {
|
||||
return (
|
||||
<div
|
||||
className="Card"
|
||||
style={{
|
||||
["--card-color" as any]: OpenColor[color][7],
|
||||
["--card-color-darker" as any]: OpenColor[color][8],
|
||||
["--card-color-darkest" as any]: OpenColor[color][9],
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
};
|
89
src/components/CheckboxItem.scss
Normal file
89
src/components/CheckboxItem.scss
Normal file
@@ -0,0 +1,89 @@
|
||||
@import "../css/variables.module";
|
||||
|
||||
.excalidraw {
|
||||
.Checkbox {
|
||||
margin: 4px 0.3em;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
|
||||
-webkit-tap-highlight-color: transparent;
|
||||
|
||||
&:hover:not(.is-checked) .Checkbox-box:not(:focus) {
|
||||
box-shadow: 0 0 0 2px #{$oc-blue-4};
|
||||
}
|
||||
|
||||
&:hover:not(.is-checked) .Checkbox-box:not(:focus) {
|
||||
svg {
|
||||
display: block;
|
||||
opacity: 0.3;
|
||||
}
|
||||
}
|
||||
|
||||
&:active {
|
||||
.Checkbox-box {
|
||||
box-shadow: 0 0 2px 1px inset #{$oc-blue-7} !important;
|
||||
}
|
||||
}
|
||||
|
||||
&:hover {
|
||||
.Checkbox-box {
|
||||
background-color: fade-out($oc-blue-1, 0.8);
|
||||
}
|
||||
}
|
||||
|
||||
&.is-checked {
|
||||
.Checkbox-box {
|
||||
background-color: #{$oc-blue-1};
|
||||
svg {
|
||||
display: block;
|
||||
}
|
||||
}
|
||||
&:hover .Checkbox-box {
|
||||
background-color: #{$oc-blue-2};
|
||||
}
|
||||
}
|
||||
|
||||
.Checkbox-box {
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
padding: 0;
|
||||
flex: 0 0 auto;
|
||||
|
||||
margin: 0 1em;
|
||||
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
|
||||
box-shadow: 0 0 0 2px #{$oc-blue-7};
|
||||
background-color: transparent;
|
||||
border-radius: 4px;
|
||||
|
||||
color: #{$oc-blue-7};
|
||||
|
||||
&:focus {
|
||||
box-shadow: 0 0 0 3px #{$oc-blue-7};
|
||||
}
|
||||
|
||||
svg {
|
||||
display: none;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
stroke-width: 3px;
|
||||
}
|
||||
}
|
||||
|
||||
.Checkbox-label {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.excalidraw-tooltip-icon {
|
||||
width: 1em;
|
||||
height: 1em;
|
||||
}
|
||||
}
|
||||
}
|
27
src/components/CheckboxItem.tsx
Normal file
27
src/components/CheckboxItem.tsx
Normal file
@@ -0,0 +1,27 @@
|
||||
import React from "react";
|
||||
import clsx from "clsx";
|
||||
import { checkIcon } from "./icons";
|
||||
|
||||
import "./CheckboxItem.scss";
|
||||
|
||||
export const CheckboxItem: React.FC<{
|
||||
checked: boolean;
|
||||
onChange: (checked: boolean) => void;
|
||||
}> = ({ children, checked, onChange }) => {
|
||||
return (
|
||||
<div
|
||||
className={clsx("Checkbox", { "is-checked": checked })}
|
||||
onClick={(event) => {
|
||||
onChange(!checked);
|
||||
((event.currentTarget as HTMLDivElement).querySelector(
|
||||
".Checkbox-box",
|
||||
) as HTMLButtonElement).focus();
|
||||
}}
|
||||
>
|
||||
<button className="Checkbox-box" role="checkbox" aria-checked={checked}>
|
||||
{checkIcon}
|
||||
</button>
|
||||
<div className="Checkbox-label">{children}</div>
|
||||
</div>
|
||||
);
|
||||
};
|
@@ -1,8 +1,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 "../components/App";
|
||||
import { users } from "./icons";
|
||||
|
||||
import "./CollabButton.scss";
|
||||
|
@@ -160,7 +160,7 @@
|
||||
}
|
||||
|
||||
.color-picker-input {
|
||||
width: 12ch; /* length of `transparent` + 1 */
|
||||
width: 11ch; /* length of `transparent` */
|
||||
margin: 0;
|
||||
font-size: 1rem;
|
||||
background-color: var(--input-bg-color);
|
||||
@@ -218,7 +218,7 @@
|
||||
left: 2px;
|
||||
}
|
||||
|
||||
@media #{$is-mobile-query} {
|
||||
@include isMobile {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
@@ -1,5 +1,6 @@
|
||||
import React from "react";
|
||||
import { Popover } from "./Popover";
|
||||
import { isTransparent } from "../utils";
|
||||
|
||||
import "./ColorPicker.scss";
|
||||
import { isArrowKey, KEYS } from "../keys";
|
||||
@@ -14,7 +15,7 @@ const isValidColor = (color: string) => {
|
||||
};
|
||||
|
||||
const getColor = (color: string): string | null => {
|
||||
if (color === "transparent") {
|
||||
if (isTransparent(color)) {
|
||||
return color;
|
||||
}
|
||||
|
||||
@@ -115,6 +116,7 @@ const Picker = ({
|
||||
onClose();
|
||||
}
|
||||
event.nativeEvent.stopImmediatePropagation();
|
||||
event.stopPropagation();
|
||||
};
|
||||
|
||||
return (
|
||||
@@ -136,36 +138,41 @@ const Picker = ({
|
||||
}}
|
||||
tabIndex={0}
|
||||
>
|
||||
{colors.map((_color, i) => (
|
||||
<button
|
||||
className="color-picker-swatch"
|
||||
onClick={(event) => {
|
||||
(event.currentTarget as HTMLButtonElement).focus();
|
||||
onChange(_color);
|
||||
}}
|
||||
title={`${_color} — ${keyBindings[i].toUpperCase()}`}
|
||||
aria-label={_color}
|
||||
aria-keyshortcuts={keyBindings[i]}
|
||||
style={{ color: _color }}
|
||||
key={_color}
|
||||
ref={(el) => {
|
||||
if (el && i === 0) {
|
||||
firstItem.current = el;
|
||||
}
|
||||
if (el && _color === color) {
|
||||
activeItem.current = el;
|
||||
}
|
||||
}}
|
||||
onFocus={() => {
|
||||
onChange(_color);
|
||||
}}
|
||||
>
|
||||
{_color === "transparent" ? (
|
||||
<div className="color-picker-transparent"></div>
|
||||
) : undefined}
|
||||
<span className="color-picker-keybinding">{keyBindings[i]}</span>
|
||||
</button>
|
||||
))}
|
||||
{colors.map((_color, i) => {
|
||||
const _colorWithoutHash = _color.replace("#", "");
|
||||
return (
|
||||
<button
|
||||
className="color-picker-swatch"
|
||||
onClick={(event) => {
|
||||
(event.currentTarget as HTMLButtonElement).focus();
|
||||
onChange(_color);
|
||||
}}
|
||||
title={`${t(`colors.${_colorWithoutHash}`)}${
|
||||
!isTransparent(_color) ? ` (${_color})` : ""
|
||||
} — ${keyBindings[i].toUpperCase()}`}
|
||||
aria-label={t(`colors.${_colorWithoutHash}`)}
|
||||
aria-keyshortcuts={keyBindings[i]}
|
||||
style={{ color: _color }}
|
||||
key={_color}
|
||||
ref={(el) => {
|
||||
if (el && i === 0) {
|
||||
firstItem.current = el;
|
||||
}
|
||||
if (el && _color === color) {
|
||||
activeItem.current = el;
|
||||
}
|
||||
}}
|
||||
onFocus={() => {
|
||||
onChange(_color);
|
||||
}}
|
||||
>
|
||||
{isTransparent(_color) ? (
|
||||
<div className="color-picker-transparent"></div>
|
||||
) : undefined}
|
||||
<span className="color-picker-keybinding">{keyBindings[i]}</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
{showInput && (
|
||||
<ColorInput
|
||||
color={color}
|
||||
@@ -237,13 +244,16 @@ export const ColorPicker = ({
|
||||
color,
|
||||
onChange,
|
||||
label,
|
||||
isActive,
|
||||
setActive,
|
||||
}: {
|
||||
type: "canvasBackground" | "elementBackground" | "elementStroke";
|
||||
color: string | null;
|
||||
onChange: (color: string) => void;
|
||||
label: string;
|
||||
isActive: boolean;
|
||||
setActive: (active: boolean) => void;
|
||||
}) => {
|
||||
const [isActive, setActive] = React.useState(false);
|
||||
const pickerButton = React.useRef<HTMLButtonElement>(null);
|
||||
|
||||
return (
|
||||
|
@@ -76,7 +76,7 @@
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
@media #{$is-mobile-query} {
|
||||
@include isMobile {
|
||||
.context-menu-option {
|
||||
display: block;
|
||||
|
||||
|
@@ -1,4 +1,3 @@
|
||||
import React from "react";
|
||||
import { render, unmountComponentAtNode } from "react-dom";
|
||||
import clsx from "clsx";
|
||||
import { Popover } from "./Popover";
|
||||
@@ -32,67 +31,63 @@ const ContextMenu = ({
|
||||
actionManager,
|
||||
appState,
|
||||
}: ContextMenuProps) => {
|
||||
const isDarkTheme = !!document
|
||||
.querySelector(".excalidraw")
|
||||
?.classList.contains("theme--dark");
|
||||
return (
|
||||
<div
|
||||
className={clsx("excalidraw", {
|
||||
"theme--dark theme--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 +96,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 +122,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),
|
||||
);
|
||||
}
|
||||
},
|
||||
|
@@ -1,42 +1,32 @@
|
||||
import "./ToolIcon.scss";
|
||||
|
||||
import React from "react";
|
||||
import { t } from "../i18n";
|
||||
|
||||
export type Appearence = "light" | "dark";
|
||||
import { ToolButton } from "./ToolButton";
|
||||
import { THEME } from "../constants";
|
||||
import { Theme } from "../element/types";
|
||||
|
||||
// We chose to use only explicit toggle and not a third option for system value,
|
||||
// but this could be added in the future.
|
||||
export const DarkModeToggle = (props: {
|
||||
value: Appearence;
|
||||
onChange: (value: Appearence) => void;
|
||||
value: Theme;
|
||||
onChange: (value: Theme) => void;
|
||||
title?: string;
|
||||
}) => {
|
||||
const title = props.title
|
||||
? props.title
|
||||
: props.value === "dark"
|
||||
? t("buttons.lightMode")
|
||||
: t("buttons.darkMode");
|
||||
const title =
|
||||
props.title ||
|
||||
(props.value === "dark" ? t("buttons.lightMode") : t("buttons.darkMode"));
|
||||
|
||||
return (
|
||||
<label
|
||||
className="ToolIcon ToolIcon_type_floating ToolIcon_size_M"
|
||||
data-testid="toggle-dark-mode"
|
||||
<ToolButton
|
||||
type="icon"
|
||||
icon={props.value === THEME.LIGHT ? ICONS.MOON : ICONS.SUN}
|
||||
title={title}
|
||||
>
|
||||
<input
|
||||
className="ToolIcon_type_checkbox ToolIcon_toggle_opaque"
|
||||
type="checkbox"
|
||||
onChange={(event) =>
|
||||
props.onChange(event.target.checked ? "dark" : "light")
|
||||
}
|
||||
checked={props.value === "dark"}
|
||||
aria-label={title}
|
||||
/>
|
||||
<div className="ToolIcon__icon">
|
||||
{props.value === "light" ? ICONS.MOON : ICONS.SUN}
|
||||
</div>
|
||||
</label>
|
||||
aria-label={title}
|
||||
onClick={() =>
|
||||
props.onChange(props.value === THEME.DARK ? THEME.LIGHT : THEME.DARK)
|
||||
}
|
||||
data-testid="toggle-dark-mode"
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
|
@@ -31,7 +31,7 @@
|
||||
padding: 0 16px 16px;
|
||||
}
|
||||
|
||||
@media #{$is-mobile-query} {
|
||||
@include isMobile {
|
||||
.Dialog {
|
||||
--metric: calc(var(--space-factor) * 4);
|
||||
--inset-left: #{"max(var(--metric), var(--sal))"};
|
||||
|
@@ -1,13 +1,14 @@
|
||||
import clsx from "clsx";
|
||||
import React, { useEffect } from "react";
|
||||
import React, { useEffect, useState } from "react";
|
||||
import { useCallbackRefState } from "../hooks/useCallbackRefState";
|
||||
import { t } from "../i18n";
|
||||
import useIsMobile from "../is-mobile";
|
||||
import { useExcalidrawContainer, useIsMobile } from "../components/App";
|
||||
import { KEYS } from "../keys";
|
||||
import "./Dialog.scss";
|
||||
import { back, close } from "./icons";
|
||||
import { Island } from "./Island";
|
||||
import { Modal } from "./Modal";
|
||||
import { AppState } from "../types";
|
||||
|
||||
export const Dialog = (props: {
|
||||
children: React.ReactNode;
|
||||
@@ -16,8 +17,11 @@ export const Dialog = (props: {
|
||||
onCloseRequest(): void;
|
||||
title: React.ReactNode;
|
||||
autofocus?: boolean;
|
||||
theme?: AppState["theme"];
|
||||
}) => {
|
||||
const [islandNode, setIslandNode] = useCallbackRefState<HTMLDivElement>();
|
||||
const [lastActiveElement] = useState(document.activeElement);
|
||||
const { id } = useExcalidrawContainer();
|
||||
|
||||
useEffect(() => {
|
||||
if (!islandNode) {
|
||||
@@ -65,19 +69,25 @@ export const Dialog = (props: {
|
||||
return focusableElements ? Array.from(focusableElements) : [];
|
||||
};
|
||||
|
||||
const onClose = () => {
|
||||
(lastActiveElement as HTMLElement).focus();
|
||||
props.onCloseRequest();
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal
|
||||
className={clsx("Dialog", props.className)}
|
||||
labelledBy="dialog-title"
|
||||
maxWidth={props.small ? 550 : 800}
|
||||
onCloseRequest={props.onCloseRequest}
|
||||
onCloseRequest={onClose}
|
||||
theme={props.theme}
|
||||
>
|
||||
<Island ref={setIslandNode}>
|
||||
<h2 id="dialog-title" className="Dialog__title">
|
||||
<h2 id={`${id}-dialog-title`} className="Dialog__title">
|
||||
<span className="Dialog__titleContent">{props.title}</span>
|
||||
<button
|
||||
className="Modal__close"
|
||||
onClick={props.onCloseRequest}
|
||||
onClick={onClose}
|
||||
aria-label={t("buttons.close")}
|
||||
>
|
||||
{useIsMobile() ? back : close}
|
||||
|
@@ -2,6 +2,7 @@ import React, { useState } from "react";
|
||||
import { t } from "../i18n";
|
||||
|
||||
import { Dialog } from "./Dialog";
|
||||
import { useExcalidrawContainer } from "./App";
|
||||
|
||||
export const ErrorDialog = ({
|
||||
message,
|
||||
@@ -11,6 +12,7 @@ export const ErrorDialog = ({
|
||||
onClose?: () => void;
|
||||
}) => {
|
||||
const [modalIsShown, setModalIsShown] = useState(!!message);
|
||||
const { container: excalidrawContainer } = useExcalidrawContainer();
|
||||
|
||||
const handleClose = React.useCallback(() => {
|
||||
setModalIsShown(false);
|
||||
@@ -18,7 +20,9 @@ export const ErrorDialog = ({
|
||||
if (onClose) {
|
||||
onClose();
|
||||
}
|
||||
}, [onClose]);
|
||||
// TODO: Fix the A11y issues so this is never needed since we should always focus on last active element
|
||||
excalidrawContainer?.focus();
|
||||
}, [onClose, excalidrawContainer]);
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -28,14 +32,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>
|
||||
)}
|
||||
</>
|
||||
|
@@ -28,34 +28,7 @@
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@media #{$is-mobile-query} {
|
||||
@include isMobile {
|
||||
.ExportDialog {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
@@ -84,4 +57,63 @@
|
||||
overflow-y: auto;
|
||||
}
|
||||
}
|
||||
|
||||
.ExportDialog--json {
|
||||
.ExportDialog-cards {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
|
||||
justify-items: center;
|
||||
row-gap: 2em;
|
||||
|
||||
@media (max-width: 460px) {
|
||||
grid-template-columns: repeat(auto-fit, minmax(240px, 1fr));
|
||||
.Card-details {
|
||||
min-height: 40px;
|
||||
}
|
||||
}
|
||||
|
||||
.ProjectName {
|
||||
width: fit-content;
|
||||
margin: 1em auto;
|
||||
align-items: flex-start;
|
||||
flex-direction: column;
|
||||
|
||||
.TextInput {
|
||||
width: auto;
|
||||
}
|
||||
}
|
||||
|
||||
.ProjectName-label {
|
||||
margin: 0.625em 0;
|
||||
font-weight: bold;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
button.ExportDialog-imageExportButton {
|
||||
width: 5rem;
|
||||
height: 5rem;
|
||||
margin: 0 0.2em;
|
||||
|
||||
border-radius: 1rem;
|
||||
background-color: var(--button-color);
|
||||
box-shadow: 0 3px 5px -1px rgba(0, 0, 0, 0.28),
|
||||
0 6px 10px 0 rgba(0, 0, 0, 0.14);
|
||||
|
||||
font-family: Cascadia;
|
||||
font-size: 1.8em;
|
||||
color: $oc-white;
|
||||
|
||||
&:hover {
|
||||
background-color: var(--button-color-darker);
|
||||
}
|
||||
&:active {
|
||||
background-color: var(--button-color-darkest);
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
svg {
|
||||
width: 0.9em;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@@ -1,6 +1,5 @@
|
||||
.excalidraw {
|
||||
.FixedSideContainer {
|
||||
--margin: 0.25rem;
|
||||
position: absolute;
|
||||
pointer-events: none;
|
||||
}
|
||||
@@ -10,9 +9,9 @@
|
||||
}
|
||||
|
||||
.FixedSideContainer_side_top {
|
||||
left: var(--margin);
|
||||
top: var(--margin);
|
||||
right: var(--margin);
|
||||
left: var(--space-factor);
|
||||
top: var(--space-factor);
|
||||
right: var(--space-factor);
|
||||
z-index: 2;
|
||||
}
|
||||
|
||||
@@ -23,16 +22,16 @@
|
||||
|
||||
/* TODO: if these are used, make sure to implement RTL support
|
||||
.FixedSideContainer_side_left {
|
||||
left: var(--margin);
|
||||
top: var(--margin);
|
||||
bottom: var(--margin);
|
||||
left: var(--space-factor);
|
||||
top: var(--space-factor);
|
||||
bottom: var(--space-factor);
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.FixedSideContainer_side_right {
|
||||
right: var(--margin);
|
||||
top: var(--margin);
|
||||
bottom: var(--margin);
|
||||
right: var(--space-factor);
|
||||
top: var(--space-factor);
|
||||
bottom: var(--space-factor);
|
||||
z-index: 3;
|
||||
}
|
||||
*/
|
||||
|
@@ -153,10 +153,17 @@ export const HelpDialog = ({ onClose }: { onClose?: () => void }) => {
|
||||
<Shortcut label={t("toolBar.arrow")} shortcuts={["A", "5"]} />
|
||||
<Shortcut label={t("toolBar.line")} shortcuts={["P", "6"]} />
|
||||
<Shortcut
|
||||
label={t("toolBar.draw")}
|
||||
label={t("toolBar.freedraw")}
|
||||
shortcuts={["Shift+P", "7"]}
|
||||
/>
|
||||
<Shortcut label={t("toolBar.text")} shortcuts={["T", "8"]} />
|
||||
<Shortcut
|
||||
label={t("helpDialog.editSelectedShape")}
|
||||
shortcuts={[
|
||||
getShortcutKey("Enter"),
|
||||
t("helpDialog.doubleClick"),
|
||||
]}
|
||||
/>
|
||||
<Shortcut
|
||||
label={t("helpDialog.textNewLine")}
|
||||
shortcuts={[
|
||||
@@ -231,6 +238,14 @@ export const HelpDialog = ({ onClose }: { onClose?: () => void }) => {
|
||||
label={t("labels.viewMode")}
|
||||
shortcuts={[getShortcutKey("Alt+R")]}
|
||||
/>
|
||||
<Shortcut
|
||||
label={t("labels.toggleTheme")}
|
||||
shortcuts={[getShortcutKey("Alt+Shift+D")]}
|
||||
/>
|
||||
<Shortcut
|
||||
label={t("stats.title")}
|
||||
shortcuts={[getShortcutKey("Alt+/")]}
|
||||
/>
|
||||
</ShortcutIsland>
|
||||
</Column>
|
||||
<Column>
|
||||
@@ -349,6 +364,22 @@ 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")]}
|
||||
/>
|
||||
<Shortcut
|
||||
label={t("labels.showStroke")}
|
||||
shortcuts={[getShortcutKey("S")]}
|
||||
/>
|
||||
<Shortcut
|
||||
label={t("labels.showBackground")}
|
||||
shortcuts={[getShortcutKey("G")]}
|
||||
/>
|
||||
</ShortcutIsland>
|
||||
</Column>
|
||||
</Columns>
|
||||
|
@@ -1,4 +1,3 @@
|
||||
import React from "react";
|
||||
import { questionCircle } from "../components/icons";
|
||||
|
||||
type HelpIconProps = {
|
||||
|
@@ -19,7 +19,7 @@ $wide-viewport-width: 1000px;
|
||||
color: $oc-gray-6;
|
||||
font-size: 0.8rem;
|
||||
|
||||
@media #{$is-mobile-query} {
|
||||
@include isMobile {
|
||||
position: static;
|
||||
padding-right: 2em;
|
||||
}
|
||||
|
@@ -1,11 +1,10 @@
|
||||
import React from "react";
|
||||
import { t } from "../i18n";
|
||||
import { NonDeletedExcalidrawElement } from "../element/types";
|
||||
import { getSelectedElements } from "../scene";
|
||||
|
||||
import "./HintViewer.scss";
|
||||
import { AppState } from "../types";
|
||||
import { isLinearElement } from "../element/typeChecks";
|
||||
import { isLinearElement, isTextElement } from "../element/typeChecks";
|
||||
import { getShortcutKey } from "../utils";
|
||||
|
||||
interface Hint {
|
||||
@@ -23,7 +22,7 @@ const getHints = ({ appState, elements }: Hint) => {
|
||||
return t("hints.linearElementMulti");
|
||||
}
|
||||
|
||||
if (elementType === "draw") {
|
||||
if (elementType === "freedraw") {
|
||||
return t("hints.freeDraw");
|
||||
}
|
||||
|
||||
@@ -57,6 +56,14 @@ const getHints = ({ appState, elements }: Hint) => {
|
||||
return t("hints.lineEditor_info");
|
||||
}
|
||||
|
||||
if (selectedElements.length === 1 && isTextElement(selectedElements[0])) {
|
||||
return t("hints.text_selected");
|
||||
}
|
||||
|
||||
if (appState.editingElement && isTextElement(appState.editingElement)) {
|
||||
return t("hints.text_editing");
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
|
@@ -111,7 +111,7 @@
|
||||
:root[dir="rtl"] & {
|
||||
left: 2px;
|
||||
}
|
||||
@media #{$is-mobile-query} {
|
||||
@include isMobile {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
@@ -88,6 +88,7 @@ function Picker<T>({
|
||||
onClose();
|
||||
}
|
||||
event.nativeEvent.stopImmediatePropagation();
|
||||
event.stopPropagation();
|
||||
};
|
||||
|
||||
return (
|
||||
|
@@ -6,18 +6,19 @@ 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 "./App";
|
||||
import { getSelectedElements, isSomeElementSelected } from "../scene";
|
||||
import { exportToCanvas, getExportSize } from "../scene/export";
|
||||
import { exportToCanvas } from "../scene/export";
|
||||
import { AppState } from "../types";
|
||||
import { Dialog } from "./Dialog";
|
||||
import "./ExportDialog.scss";
|
||||
import { clipboard, exportFile, link } from "./icons";
|
||||
import { clipboard, exportImage } from "./icons";
|
||||
import Stack from "./Stack";
|
||||
import { ToolButton } from "./ToolButton";
|
||||
|
||||
const scales = [1, 2, 3];
|
||||
const defaultScale = scales.includes(devicePixelRatio) ? devicePixelRatio : 1;
|
||||
import "./ExportDialog.scss";
|
||||
import OpenColor from "open-color";
|
||||
import { CheckboxItem } from "./CheckboxItem";
|
||||
import { DEFAULT_EXPORT_PADDING } from "../constants";
|
||||
import { nativeFileSystemSupported } from "../data/filesystem";
|
||||
|
||||
const supportsContextFilters =
|
||||
"filter" in document.createElement("canvas").getContext("2d")!;
|
||||
@@ -52,15 +53,37 @@ export type ExportCB = (
|
||||
scale?: number,
|
||||
) => void;
|
||||
|
||||
const ExportModal = ({
|
||||
const ExportButton: React.FC<{
|
||||
color: keyof OpenColor;
|
||||
onClick: () => void;
|
||||
title: string;
|
||||
shade?: number;
|
||||
}> = ({ children, title, onClick, color, shade = 6 }) => {
|
||||
return (
|
||||
<button
|
||||
className="ExportDialog-imageExportButton"
|
||||
style={{
|
||||
["--button-color" as any]: OpenColor[color][shade],
|
||||
["--button-color-darker" as any]: OpenColor[color][shade + 1],
|
||||
["--button-color-darkest" as any]: OpenColor[color][shade + 2],
|
||||
}}
|
||||
title={title}
|
||||
aria-label={title}
|
||||
onClick={onClick}
|
||||
>
|
||||
{children}
|
||||
</button>
|
||||
);
|
||||
};
|
||||
|
||||
const ImageExportModal = ({
|
||||
elements,
|
||||
appState,
|
||||
exportPadding = 10,
|
||||
exportPadding = DEFAULT_EXPORT_PADDING,
|
||||
actionManager,
|
||||
onExportToPng,
|
||||
onExportToSvg,
|
||||
onExportToClipboard,
|
||||
onExportToBackend,
|
||||
}: {
|
||||
appState: AppState;
|
||||
elements: readonly NonDeletedExcalidrawElement[];
|
||||
@@ -69,18 +92,12 @@ const ExportModal = ({
|
||||
onExportToPng: ExportCB;
|
||||
onExportToSvg: ExportCB;
|
||||
onExportToClipboard: ExportCB;
|
||||
onExportToBackend?: ExportCB;
|
||||
onCloseRequest: () => void;
|
||||
}) => {
|
||||
const someElementIsSelected = isSomeElementSelected(elements, appState);
|
||||
const [scale, setScale] = useState(defaultScale);
|
||||
const [exportSelected, setExportSelected] = useState(someElementIsSelected);
|
||||
const previewRef = useRef<HTMLDivElement>(null);
|
||||
const {
|
||||
exportBackground,
|
||||
viewBackgroundColor,
|
||||
shouldAddWatermark,
|
||||
} = appState;
|
||||
const { exportBackground, viewBackgroundColor } = appState;
|
||||
|
||||
const exportedElements = exportSelected
|
||||
? getSelectedElements(elements, appState)
|
||||
@@ -100,8 +117,6 @@ const ExportModal = ({
|
||||
exportBackground,
|
||||
viewBackgroundColor,
|
||||
exportPadding,
|
||||
scale,
|
||||
shouldAddWatermark,
|
||||
});
|
||||
|
||||
// if converting to blob fails, there's some problem that will
|
||||
@@ -124,8 +139,6 @@ const ExportModal = ({
|
||||
exportBackground,
|
||||
exportPadding,
|
||||
viewBackgroundColor,
|
||||
scale,
|
||||
shouldAddWatermark,
|
||||
]);
|
||||
|
||||
return (
|
||||
@@ -133,106 +146,85 @@ const ExportModal = ({
|
||||
<div className="ExportDialog__preview" ref={previewRef} />
|
||||
{supportsContextFilters &&
|
||||
actionManager.renderAction("exportWithDarkMode")}
|
||||
<Stack.Col gap={2} align="center">
|
||||
<div className="ExportDialog__actions">
|
||||
<Stack.Row gap={2}>
|
||||
<ToolButton
|
||||
type="button"
|
||||
label="PNG"
|
||||
title={t("buttons.exportToPng")}
|
||||
aria-label={t("buttons.exportToPng")}
|
||||
onClick={() => onExportToPng(exportedElements, scale)}
|
||||
/>
|
||||
<ToolButton
|
||||
type="button"
|
||||
label="SVG"
|
||||
title={t("buttons.exportToSvg")}
|
||||
aria-label={t("buttons.exportToSvg")}
|
||||
onClick={() => onExportToSvg(exportedElements, scale)}
|
||||
/>
|
||||
{probablySupportsClipboardBlob && (
|
||||
<ToolButton
|
||||
type="button"
|
||||
icon={clipboard}
|
||||
title={t("buttons.copyPngToClipboard")}
|
||||
aria-label={t("buttons.copyPngToClipboard")}
|
||||
onClick={() => onExportToClipboard(exportedElements, scale)}
|
||||
/>
|
||||
)}
|
||||
{onExportToBackend && (
|
||||
<ToolButton
|
||||
type="button"
|
||||
icon={link}
|
||||
title={t("buttons.getShareableLink")}
|
||||
aria-label={t("buttons.getShareableLink")}
|
||||
onClick={() => onExportToBackend(exportedElements)}
|
||||
/>
|
||||
)}
|
||||
</Stack.Row>
|
||||
<div className="ExportDialog__name">
|
||||
{actionManager.renderAction("changeProjectName")}
|
||||
</div>
|
||||
<Stack.Row gap={2}>
|
||||
{scales.map((s) => {
|
||||
const [width, height] = getExportSize(
|
||||
exportedElements,
|
||||
exportPadding,
|
||||
shouldAddWatermark,
|
||||
s,
|
||||
);
|
||||
|
||||
const scaleButtonTitle = `${t(
|
||||
"buttons.scale",
|
||||
)} ${s}x (${width}x${height})`;
|
||||
|
||||
return (
|
||||
<ToolButton
|
||||
key={s}
|
||||
size="s"
|
||||
type="radio"
|
||||
icon={`${s}x`}
|
||||
name="export-canvas-scale"
|
||||
title={scaleButtonTitle}
|
||||
aria-label={scaleButtonTitle}
|
||||
id="export-canvas-scale"
|
||||
checked={s === scale}
|
||||
onChange={() => setScale(s)}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</Stack.Row>
|
||||
</div>
|
||||
{actionManager.renderAction("changeExportBackground")}
|
||||
{someElementIsSelected && (
|
||||
<div>
|
||||
<label>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={exportSelected}
|
||||
onChange={(event) =>
|
||||
setExportSelected(event.currentTarget.checked)
|
||||
}
|
||||
/>{" "}
|
||||
<div style={{ display: "grid", gridTemplateColumns: "1fr" }}>
|
||||
<div
|
||||
style={{
|
||||
display: "grid",
|
||||
gridTemplateColumns: "repeat(auto-fit, minmax(190px, 1fr))",
|
||||
// dunno why this is needed, but when the items wrap it creates
|
||||
// an overflow
|
||||
overflow: "hidden",
|
||||
}}
|
||||
>
|
||||
{actionManager.renderAction("changeExportBackground")}
|
||||
{someElementIsSelected && (
|
||||
<CheckboxItem
|
||||
checked={exportSelected}
|
||||
onChange={(checked) => setExportSelected(checked)}
|
||||
>
|
||||
{t("labels.onlySelected")}
|
||||
</label>
|
||||
</div>
|
||||
</CheckboxItem>
|
||||
)}
|
||||
{actionManager.renderAction("changeExportEmbedScene")}
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ display: "flex", alignItems: "center", marginTop: ".6em" }}>
|
||||
<Stack.Row gap={2}>
|
||||
{actionManager.renderAction("changeExportScale")}
|
||||
</Stack.Row>
|
||||
<p style={{ marginLeft: "1em", userSelect: "none" }}>Scale</p>
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
margin: ".6em 0",
|
||||
}}
|
||||
>
|
||||
{!nativeFileSystemSupported &&
|
||||
actionManager.renderAction("changeProjectName")}
|
||||
</div>
|
||||
<Stack.Row gap={2} justifyContent="center" style={{ margin: "2em 0" }}>
|
||||
<ExportButton
|
||||
color="indigo"
|
||||
title={t("buttons.exportToPng")}
|
||||
aria-label={t("buttons.exportToPng")}
|
||||
onClick={() => onExportToPng(exportedElements)}
|
||||
>
|
||||
PNG
|
||||
</ExportButton>
|
||||
<ExportButton
|
||||
color="red"
|
||||
title={t("buttons.exportToSvg")}
|
||||
aria-label={t("buttons.exportToSvg")}
|
||||
onClick={() => onExportToSvg(exportedElements)}
|
||||
>
|
||||
SVG
|
||||
</ExportButton>
|
||||
{probablySupportsClipboardBlob && (
|
||||
<ExportButton
|
||||
title={t("buttons.copyPngToClipboard")}
|
||||
onClick={() => onExportToClipboard(exportedElements)}
|
||||
color="gray"
|
||||
shade={7}
|
||||
>
|
||||
{clipboard}
|
||||
</ExportButton>
|
||||
)}
|
||||
{actionManager.renderAction("changeExportEmbedScene")}
|
||||
{actionManager.renderAction("changeShouldAddWatermark")}
|
||||
</Stack.Col>
|
||||
</Stack.Row>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export const ExportDialog = ({
|
||||
export const ImageExportDialog = ({
|
||||
elements,
|
||||
appState,
|
||||
exportPadding = 10,
|
||||
exportPadding = DEFAULT_EXPORT_PADDING,
|
||||
actionManager,
|
||||
onExportToPng,
|
||||
onExportToSvg,
|
||||
onExportToClipboard,
|
||||
onExportToBackend,
|
||||
}: {
|
||||
appState: AppState;
|
||||
elements: readonly NonDeletedExcalidrawElement[];
|
||||
@@ -241,14 +233,11 @@ export const ExportDialog = ({
|
||||
onExportToPng: ExportCB;
|
||||
onExportToSvg: ExportCB;
|
||||
onExportToClipboard: ExportCB;
|
||||
onExportToBackend?: ExportCB;
|
||||
}) => {
|
||||
const [modalIsShown, setModalIsShown] = useState(false);
|
||||
const triggerButton = useRef<HTMLButtonElement>(null);
|
||||
|
||||
const handleClose = React.useCallback(() => {
|
||||
setModalIsShown(false);
|
||||
triggerButton.current?.focus();
|
||||
}, []);
|
||||
|
||||
return (
|
||||
@@ -257,17 +246,16 @@ export const ExportDialog = ({
|
||||
onClick={() => {
|
||||
setModalIsShown(true);
|
||||
}}
|
||||
data-testid="export-button"
|
||||
icon={exportFile}
|
||||
data-testid="image-export-button"
|
||||
icon={exportImage}
|
||||
type="button"
|
||||
aria-label={t("buttons.export")}
|
||||
aria-label={t("buttons.exportImage")}
|
||||
showAriaLabel={useIsMobile()}
|
||||
title={t("buttons.export")}
|
||||
ref={triggerButton}
|
||||
title={t("buttons.exportImage")}
|
||||
/>
|
||||
{modalIsShown && (
|
||||
<Dialog onCloseRequest={handleClose} title={t("buttons.export")}>
|
||||
<ExportModal
|
||||
<Dialog onCloseRequest={handleClose} title={t("buttons.exportImage")}>
|
||||
<ImageExportModal
|
||||
elements={elements}
|
||||
appState={appState}
|
||||
exportPadding={exportPadding}
|
||||
@@ -275,7 +263,6 @@ export const ExportDialog = ({
|
||||
onExportToPng={onExportToPng}
|
||||
onExportToSvg={onExportToSvg}
|
||||
onExportToClipboard={onExportToClipboard}
|
||||
onExportToBackend={onExportToBackend}
|
||||
onCloseRequest={handleClose}
|
||||
/>
|
||||
</Dialog>
|
@@ -1,30 +1,25 @@
|
||||
import React from "react";
|
||||
import React, { useEffect, useState } from "react";
|
||||
|
||||
import { LoadingMessage } from "./LoadingMessage";
|
||||
import { defaultLang, Language, languages, setLanguage } from "../i18n";
|
||||
|
||||
interface Props {
|
||||
langCode: Language["code"];
|
||||
children: React.ReactElement;
|
||||
}
|
||||
interface State {
|
||||
isLoading: boolean;
|
||||
}
|
||||
export class InitializeApp extends React.Component<Props, State> {
|
||||
public state: { isLoading: boolean } = {
|
||||
isLoading: true,
|
||||
};
|
||||
|
||||
async componentDidMount() {
|
||||
export const InitializeApp = (props: Props) => {
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
const updateLang = async () => {
|
||||
await setLanguage(currentLang);
|
||||
};
|
||||
const currentLang =
|
||||
languages.find((lang) => lang.code === this.props.langCode) ||
|
||||
defaultLang;
|
||||
await setLanguage(currentLang);
|
||||
this.setState({
|
||||
isLoading: false,
|
||||
});
|
||||
}
|
||||
languages.find((lang) => lang.code === props.langCode) || defaultLang;
|
||||
updateLang();
|
||||
setLoading(false);
|
||||
}, [props.langCode]);
|
||||
|
||||
public render() {
|
||||
return this.state.isLoading ? <LoadingMessage /> : this.props.children;
|
||||
}
|
||||
}
|
||||
return loading ? <LoadingMessage /> : props.children;
|
||||
};
|
||||
|
@@ -2,7 +2,6 @@
|
||||
.Island {
|
||||
--padding: 0;
|
||||
background-color: var(--island-bg-color);
|
||||
backdrop-filter: saturate(100%) blur(10px);
|
||||
box-shadow: var(--shadow-island);
|
||||
border-radius: 4px;
|
||||
padding: calc(var(--padding) * var(--space-factor));
|
||||
|
128
src/components/JSONExportDialog.tsx
Normal file
128
src/components/JSONExportDialog.tsx
Normal file
@@ -0,0 +1,128 @@
|
||||
import React, { useState } from "react";
|
||||
import { ActionsManagerInterface } from "../actions/types";
|
||||
import { NonDeletedExcalidrawElement } from "../element/types";
|
||||
import { t } from "../i18n";
|
||||
import { useIsMobile } from "./App";
|
||||
import { AppState, ExportOpts } from "../types";
|
||||
import { Dialog } from "./Dialog";
|
||||
import { exportFile, exportToFileIcon, link } from "./icons";
|
||||
import { ToolButton } from "./ToolButton";
|
||||
import { actionSaveFileToDisk } from "../actions/actionExport";
|
||||
import { Card } from "./Card";
|
||||
|
||||
import "./ExportDialog.scss";
|
||||
import { nativeFileSystemSupported } from "../data/filesystem";
|
||||
|
||||
export type ExportCB = (
|
||||
elements: readonly NonDeletedExcalidrawElement[],
|
||||
scale?: number,
|
||||
) => void;
|
||||
|
||||
const JSONExportModal = ({
|
||||
elements,
|
||||
appState,
|
||||
actionManager,
|
||||
exportOpts,
|
||||
canvas,
|
||||
}: {
|
||||
appState: AppState;
|
||||
elements: readonly NonDeletedExcalidrawElement[];
|
||||
actionManager: ActionsManagerInterface;
|
||||
onCloseRequest: () => void;
|
||||
exportOpts: ExportOpts;
|
||||
canvas: HTMLCanvasElement | null;
|
||||
}) => {
|
||||
const { onExportToBackend } = exportOpts;
|
||||
return (
|
||||
<div className="ExportDialog ExportDialog--json">
|
||||
<div className="ExportDialog-cards">
|
||||
{exportOpts.saveFileToDisk && (
|
||||
<Card color="lime">
|
||||
<div className="Card-icon">{exportToFileIcon}</div>
|
||||
<h2>{t("exportDialog.disk_title")}</h2>
|
||||
<div className="Card-details">
|
||||
{t("exportDialog.disk_details")}
|
||||
{!nativeFileSystemSupported &&
|
||||
actionManager.renderAction("changeProjectName")}
|
||||
</div>
|
||||
<ToolButton
|
||||
className="Card-button"
|
||||
type="button"
|
||||
title={t("exportDialog.disk_button")}
|
||||
aria-label={t("exportDialog.disk_button")}
|
||||
showAriaLabel={true}
|
||||
onClick={() => {
|
||||
actionManager.executeAction(actionSaveFileToDisk);
|
||||
}}
|
||||
/>
|
||||
</Card>
|
||||
)}
|
||||
{onExportToBackend && (
|
||||
<Card color="pink">
|
||||
<div className="Card-icon">{link}</div>
|
||||
<h2>{t("exportDialog.link_title")}</h2>
|
||||
<div className="Card-details">{t("exportDialog.link_details")}</div>
|
||||
<ToolButton
|
||||
className="Card-button"
|
||||
type="button"
|
||||
title={t("exportDialog.link_button")}
|
||||
aria-label={t("exportDialog.link_button")}
|
||||
showAriaLabel={true}
|
||||
onClick={() => onExportToBackend(elements, appState, canvas)}
|
||||
/>
|
||||
</Card>
|
||||
)}
|
||||
{exportOpts.renderCustomUI &&
|
||||
exportOpts.renderCustomUI(elements, appState, canvas)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export const JSONExportDialog = ({
|
||||
elements,
|
||||
appState,
|
||||
actionManager,
|
||||
exportOpts,
|
||||
canvas,
|
||||
}: {
|
||||
appState: AppState;
|
||||
elements: readonly NonDeletedExcalidrawElement[];
|
||||
actionManager: ActionsManagerInterface;
|
||||
exportOpts: ExportOpts;
|
||||
canvas: HTMLCanvasElement | null;
|
||||
}) => {
|
||||
const [modalIsShown, setModalIsShown] = useState(false);
|
||||
|
||||
const handleClose = React.useCallback(() => {
|
||||
setModalIsShown(false);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<>
|
||||
<ToolButton
|
||||
onClick={() => {
|
||||
setModalIsShown(true);
|
||||
}}
|
||||
data-testid="json-export-button"
|
||||
icon={exportFile}
|
||||
type="button"
|
||||
aria-label={t("buttons.export")}
|
||||
showAriaLabel={useIsMobile()}
|
||||
title={t("buttons.export")}
|
||||
/>
|
||||
{modalIsShown && (
|
||||
<Dialog onCloseRequest={handleClose} title={t("buttons.export")}>
|
||||
<JSONExportModal
|
||||
elements={elements}
|
||||
appState={appState}
|
||||
actionManager={actionManager}
|
||||
onCloseRequest={handleClose}
|
||||
exportOpts={exportOpts}
|
||||
canvas={canvas}
|
||||
/>
|
||||
</Dialog>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
@@ -40,50 +40,17 @@
|
||||
.layer-ui__wrapper {
|
||||
z-index: var(--zIndex-layerUI);
|
||||
|
||||
.encrypted-icon {
|
||||
position: relative;
|
||||
margin-inline-start: 15px;
|
||||
&__top-right {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
border-radius: var(--space-factor);
|
||||
color: $oc-green-9;
|
||||
|
||||
svg {
|
||||
width: 1.2rem;
|
||||
height: 1.2rem;
|
||||
}
|
||||
}
|
||||
|
||||
&__github-corner {
|
||||
top: 0;
|
||||
|
||||
:root[dir="ltr"] & {
|
||||
right: 0;
|
||||
}
|
||||
|
||||
:root[dir="rtl"] & {
|
||||
left: 0;
|
||||
}
|
||||
|
||||
position: absolute;
|
||||
width: 40px;
|
||||
}
|
||||
|
||||
&__footer {
|
||||
position: absolute;
|
||||
z-index: 100;
|
||||
bottom: 0;
|
||||
width: 100%;
|
||||
|
||||
:root[dir="ltr"] & {
|
||||
right: 0;
|
||||
&-right {
|
||||
z-index: 100;
|
||||
display: flex;
|
||||
}
|
||||
|
||||
:root[dir="rtl"] & {
|
||||
left: 0;
|
||||
}
|
||||
|
||||
width: 190px;
|
||||
}
|
||||
|
||||
.zen-mode-transition {
|
||||
@@ -105,11 +72,15 @@
|
||||
transform: translate(-999px, 0);
|
||||
}
|
||||
|
||||
:root[dir="ltr"] &.App-menu_bottom--transition-left {
|
||||
transform: translate(-92px, 0);
|
||||
:root[dir="ltr"] &.layer-ui__wrapper__footer-left--transition-left {
|
||||
transform: translate(-76px, 0);
|
||||
}
|
||||
:root[dir="rtl"] &.App-menu_bottom--transition-left {
|
||||
transform: translate(92px, 0);
|
||||
:root[dir="rtl"] &.layer-ui__wrapper__footer-left--transition-left {
|
||||
transform: translate(76px, 0);
|
||||
}
|
||||
|
||||
&.layer-ui__wrapper__footer-left--transition-bottom {
|
||||
transform: translate(0, 92px);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -137,5 +108,27 @@
|
||||
transition-delay: 0.8s;
|
||||
}
|
||||
}
|
||||
|
||||
.layer-ui__wrapper__footer-center {
|
||||
pointer-events: none;
|
||||
& > * {
|
||||
pointer-events: all;
|
||||
}
|
||||
}
|
||||
.layer-ui__wrapper__footer-left,
|
||||
.layer-ui__wrapper__footer-right,
|
||||
.disable-zen-mode--visible {
|
||||
pointer-events: all;
|
||||
}
|
||||
|
||||
.layer-ui__wrapper__footer-left {
|
||||
margin-bottom: 0.2em;
|
||||
}
|
||||
|
||||
.layer-ui__wrapper__footer-right {
|
||||
margin-top: auto;
|
||||
margin-bottom: auto;
|
||||
margin-inline-end: 1em;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@@ -10,29 +10,33 @@ import { ActionManager } from "../actions/manager";
|
||||
import { CLASSES } from "../constants";
|
||||
import { exportCanvas } from "../data";
|
||||
import { importLibraryFromJSON, saveLibraryAsJSON } from "../data/json";
|
||||
import { Library } from "../data/library";
|
||||
import { isTextElement, showSelectedShapeActions } from "../element";
|
||||
import { NonDeletedExcalidrawElement } from "../element/types";
|
||||
import { Language, t } from "../i18n";
|
||||
import useIsMobile from "../is-mobile";
|
||||
import { useIsMobile } from "../components/App";
|
||||
import { calculateScrollCenter, getSelectedElements } from "../scene";
|
||||
import { ExportType } from "../scene/types";
|
||||
import { AppState, ExcalidrawProps, 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";
|
||||
import CollabButton from "./CollabButton";
|
||||
import { ErrorDialog } from "./ErrorDialog";
|
||||
import { ExportCB, ExportDialog } from "./ExportDialog";
|
||||
import { ExportCB, ImageExportDialog } from "./ImageExportDialog";
|
||||
import { FixedSideContainer } from "./FixedSideContainer";
|
||||
import { GitHubCorner } from "./GitHubCorner";
|
||||
import { HintViewer } from "./HintViewer";
|
||||
import { exportFile, load, shield, trash } from "./icons";
|
||||
import { exportFile, load, trash } from "./icons";
|
||||
import { Island } from "./Island";
|
||||
import "./LayerUI.scss";
|
||||
import { LibraryUnit } from "./LibraryUnit";
|
||||
import { LoadingMessage } from "./LoadingMessage";
|
||||
import { LockIcon } from "./LockIcon";
|
||||
import { LockButton } from "./LockButton";
|
||||
import { MobileMenu } from "./MobileMenu";
|
||||
import { PasteChartDialog } from "./PasteChartDialog";
|
||||
import { Section } from "./Section";
|
||||
@@ -41,6 +45,10 @@ import Stack from "./Stack";
|
||||
import { ToolButton } from "./ToolButton";
|
||||
import { Tooltip } from "./Tooltip";
|
||||
import { UserList } from "./UserList";
|
||||
import Library from "../data/library";
|
||||
import { JSONExportDialog } from "./JSONExportDialog";
|
||||
import { LibraryButton } from "./LibraryButton";
|
||||
import { isImageFileHandle } from "../data/blob";
|
||||
|
||||
interface LayerUIProps {
|
||||
actionManager: ActionManager;
|
||||
@@ -57,14 +65,14 @@ interface LayerUIProps {
|
||||
toggleZenMode: () => void;
|
||||
langCode: Language["code"];
|
||||
isCollaborating: boolean;
|
||||
onExportToBackend?: (
|
||||
exportedElements: readonly NonDeletedExcalidrawElement[],
|
||||
appState: AppState,
|
||||
canvas: HTMLCanvasElement | null,
|
||||
) => void;
|
||||
renderCustomFooter?: (isMobile: boolean) => JSX.Element;
|
||||
renderTopRightUI?: (isMobile: boolean, appState: AppState) => JSX.Element;
|
||||
renderCustomFooter?: (isMobile: boolean, appState: AppState) => JSX.Element;
|
||||
viewModeEnabled: boolean;
|
||||
libraryReturnUrl: ExcalidrawProps["libraryReturnUrl"];
|
||||
UIOptions: AppProps["UIOptions"];
|
||||
focusContainer: () => void;
|
||||
library: Library;
|
||||
id: string;
|
||||
}
|
||||
|
||||
const useOnClickOutside = (
|
||||
@@ -96,35 +104,44 @@ const useOnClickOutside = (
|
||||
};
|
||||
|
||||
const LibraryMenuItems = ({
|
||||
library,
|
||||
libraryItems,
|
||||
onRemoveFromLibrary,
|
||||
onAddToLibrary,
|
||||
onInsertShape,
|
||||
pendingElements,
|
||||
theme,
|
||||
setAppState,
|
||||
setLibraryItems,
|
||||
libraryReturnUrl,
|
||||
focusContainer,
|
||||
library,
|
||||
id,
|
||||
}: {
|
||||
library: LibraryItems;
|
||||
libraryItems: LibraryItems;
|
||||
pendingElements: LibraryItem;
|
||||
onRemoveFromLibrary: (index: number) => void;
|
||||
onInsertShape: (elements: LibraryItem) => void;
|
||||
onAddToLibrary: (elements: LibraryItem) => void;
|
||||
theme: AppState["theme"];
|
||||
setAppState: React.Component<any, AppState>["setState"];
|
||||
setLibraryItems: (library: LibraryItems) => void;
|
||||
libraryReturnUrl: ExcalidrawProps["libraryReturnUrl"];
|
||||
focusContainer: () => void;
|
||||
library: Library;
|
||||
id: string;
|
||||
}) => {
|
||||
const isMobile = useIsMobile();
|
||||
const numCells = library.length + (pendingElements.length > 0 ? 1 : 0);
|
||||
const numCells = libraryItems.length + (pendingElements.length > 0 ? 1 : 0);
|
||||
const CELLS_PER_ROW = isMobile ? 4 : 6;
|
||||
const numRows = Math.max(1, Math.ceil(numCells / CELLS_PER_ROW));
|
||||
const rows = [];
|
||||
let addedPendingElements = false;
|
||||
|
||||
const referrer = libraryReturnUrl || window.location.origin;
|
||||
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"
|
||||
@@ -132,11 +149,11 @@ const LibraryMenuItems = ({
|
||||
aria-label={t("buttons.load")}
|
||||
icon={load}
|
||||
onClick={() => {
|
||||
importLibraryFromJSON()
|
||||
importLibraryFromJSON(library)
|
||||
.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) => {
|
||||
@@ -144,7 +161,7 @@ const LibraryMenuItems = ({
|
||||
});
|
||||
}}
|
||||
/>
|
||||
{!!library.length && (
|
||||
{!!libraryItems.length && (
|
||||
<>
|
||||
<ToolButton
|
||||
key="export"
|
||||
@@ -153,7 +170,7 @@ const LibraryMenuItems = ({
|
||||
aria-label={t("buttons.export")}
|
||||
icon={exportFile}
|
||||
onClick={() => {
|
||||
saveLibraryAsJSON()
|
||||
saveLibraryAsJSON(library)
|
||||
.catch(muteFSAbortError)
|
||||
.catch((error) => {
|
||||
setAppState({ errorMessage: error.message });
|
||||
@@ -168,8 +185,9 @@ const LibraryMenuItems = ({
|
||||
icon={trash}
|
||||
onClick={() => {
|
||||
if (window.confirm(t("alerts.resetLibrary"))) {
|
||||
Library.resetLibrary();
|
||||
library.resetLibrary();
|
||||
setLibraryItems([]);
|
||||
focusContainer();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
@@ -178,7 +196,7 @@ const LibraryMenuItems = ({
|
||||
<a
|
||||
href={`https://libraries.excalidraw.com?target=${
|
||||
window.name || "_blank"
|
||||
}&referrer=${referrer}`}
|
||||
}&referrer=${referrer}&useHash=true&token=${id}&theme=${theme}`}
|
||||
target="_excalidraw_libraries"
|
||||
>
|
||||
{t("labels.libraries")}
|
||||
@@ -193,13 +211,13 @@ const LibraryMenuItems = ({
|
||||
const shouldAddPendingElements: boolean =
|
||||
pendingElements.length > 0 &&
|
||||
!addedPendingElements &&
|
||||
y + x >= library.length;
|
||||
y + x >= libraryItems.length;
|
||||
addedPendingElements = addedPendingElements || shouldAddPendingElements;
|
||||
|
||||
children.push(
|
||||
<Stack.Col key={x}>
|
||||
<LibraryUnit
|
||||
elements={library[y + x]}
|
||||
elements={libraryItems[y + x]}
|
||||
pendingElements={
|
||||
shouldAddPendingElements ? pendingElements : undefined
|
||||
}
|
||||
@@ -207,7 +225,7 @@ const LibraryMenuItems = ({
|
||||
onClick={
|
||||
shouldAddPendingElements
|
||||
? onAddToLibrary.bind(null, pendingElements)
|
||||
: onInsertShape.bind(null, library[y + x])
|
||||
: onInsertShape.bind(null, libraryItems[y + x])
|
||||
}
|
||||
/>
|
||||
</Stack.Col>,
|
||||
@@ -232,15 +250,23 @@ const LibraryMenu = ({
|
||||
onInsertShape,
|
||||
pendingElements,
|
||||
onAddToLibrary,
|
||||
theme,
|
||||
setAppState,
|
||||
libraryReturnUrl,
|
||||
focusContainer,
|
||||
library,
|
||||
id,
|
||||
}: {
|
||||
pendingElements: LibraryItem;
|
||||
onClickOutside: (event: MouseEvent) => void;
|
||||
onInsertShape: (elements: LibraryItem) => void;
|
||||
onAddToLibrary: () => void;
|
||||
theme: AppState["theme"];
|
||||
setAppState: React.Component<any, AppState>["setState"];
|
||||
libraryReturnUrl: ExcalidrawProps["libraryReturnUrl"];
|
||||
focusContainer: () => void;
|
||||
library: Library;
|
||||
id: string;
|
||||
}) => {
|
||||
const ref = useRef<HTMLDivElement | null>(null);
|
||||
useOnClickOutside(ref, (event) => {
|
||||
@@ -266,7 +292,7 @@ const LibraryMenu = ({
|
||||
resolve("loading");
|
||||
}, 100);
|
||||
}),
|
||||
Library.loadLibrary().then((items) => {
|
||||
library.loadLibrary().then((items) => {
|
||||
setLibraryItems(items);
|
||||
setIsLoading("ready");
|
||||
}),
|
||||
@@ -278,24 +304,33 @@ const LibraryMenu = ({
|
||||
return () => {
|
||||
clearTimeout(loadingTimerRef.current!);
|
||||
};
|
||||
}, []);
|
||||
}, [library]);
|
||||
|
||||
const removeFromLibrary = useCallback(async (indexToRemove) => {
|
||||
const items = await Library.loadLibrary();
|
||||
const nextItems = items.filter((_, index) => index !== indexToRemove);
|
||||
Library.saveLibrary(nextItems);
|
||||
setLibraryItems(nextItems);
|
||||
}, []);
|
||||
const removeFromLibrary = useCallback(
|
||||
async (indexToRemove) => {
|
||||
const items = await library.loadLibrary();
|
||||
const nextItems = items.filter((_, index) => index !== indexToRemove);
|
||||
library.saveLibrary(nextItems).catch((error) => {
|
||||
setLibraryItems(items);
|
||||
setAppState({ errorMessage: t("alerts.errorRemovingFromLibrary") });
|
||||
});
|
||||
setLibraryItems(nextItems);
|
||||
},
|
||||
[library, setAppState],
|
||||
);
|
||||
|
||||
const addToLibrary = useCallback(
|
||||
async (elements: LibraryItem) => {
|
||||
const items = await Library.loadLibrary();
|
||||
const items = await library.loadLibrary();
|
||||
const nextItems = [...items, elements];
|
||||
onAddToLibrary();
|
||||
Library.saveLibrary(nextItems);
|
||||
library.saveLibrary(nextItems).catch((error) => {
|
||||
setLibraryItems(items);
|
||||
setAppState({ errorMessage: t("alerts.errorAddingToLibrary") });
|
||||
});
|
||||
setLibraryItems(nextItems);
|
||||
},
|
||||
[onAddToLibrary],
|
||||
[onAddToLibrary, library, setAppState],
|
||||
);
|
||||
|
||||
return loadingState === "preloading" ? null : (
|
||||
@@ -306,7 +341,7 @@ const LibraryMenu = ({
|
||||
</div>
|
||||
) : (
|
||||
<LibraryMenuItems
|
||||
library={libraryItems}
|
||||
libraryItems={libraryItems}
|
||||
onRemoveFromLibrary={removeFromLibrary}
|
||||
onAddToLibrary={addToLibrary}
|
||||
onInsertShape={onInsertShape}
|
||||
@@ -314,6 +349,10 @@ const LibraryMenu = ({
|
||||
setAppState={setAppState}
|
||||
setLibraryItems={setLibraryItems}
|
||||
libraryReturnUrl={libraryReturnUrl}
|
||||
focusContainer={focusContainer}
|
||||
library={library}
|
||||
theme={theme}
|
||||
id={id}
|
||||
/>
|
||||
)}
|
||||
</Island>
|
||||
@@ -334,69 +373,77 @@ const LayerUI = ({
|
||||
showThemeBtn,
|
||||
toggleZenMode,
|
||||
isCollaborating,
|
||||
onExportToBackend,
|
||||
renderTopRightUI,
|
||||
renderCustomFooter,
|
||||
viewModeEnabled,
|
||||
libraryReturnUrl,
|
||||
UIOptions,
|
||||
focusContainer,
|
||||
library,
|
||||
id,
|
||||
}: LayerUIProps) => {
|
||||
const isMobile = useIsMobile();
|
||||
|
||||
const renderEncryptedIcon = () => (
|
||||
<a
|
||||
className={clsx("encrypted-icon tooltip zen-mode-visibility", {
|
||||
"zen-mode-visibility--hidden": zenModeEnabled,
|
||||
})}
|
||||
href="https://blog.excalidraw.com/end-to-end-encryption/"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
<Tooltip label={t("encrypted.tooltip")} position="above" long={true}>
|
||||
{shield}
|
||||
</Tooltip>
|
||||
</a>
|
||||
);
|
||||
const renderJSONExportDialog = () => {
|
||||
if (!UIOptions.canvasActions.export) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<JSONExportDialog
|
||||
elements={elements}
|
||||
appState={appState}
|
||||
actionManager={actionManager}
|
||||
exportOpts={UIOptions.canvasActions.export}
|
||||
canvas={canvas}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
const renderImageExportDialog = () => {
|
||||
if (!UIOptions.canvasActions.saveAsImage) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const renderExportDialog = () => {
|
||||
const createExporter = (type: ExportType): ExportCB => async (
|
||||
exportedElements,
|
||||
scale,
|
||||
) => {
|
||||
if (canvas) {
|
||||
await exportCanvas(type, exportedElements, appState, canvas, {
|
||||
exportBackground: appState.exportBackground,
|
||||
name: appState.name,
|
||||
viewBackgroundColor: appState.viewBackgroundColor,
|
||||
scale,
|
||||
shouldAddWatermark: appState.shouldAddWatermark,
|
||||
})
|
||||
.catch(muteFSAbortError)
|
||||
.catch((error) => {
|
||||
console.error(error);
|
||||
setAppState({ errorMessage: error.message });
|
||||
});
|
||||
const fileHandle = await exportCanvas(type, exportedElements, appState, {
|
||||
exportBackground: appState.exportBackground,
|
||||
name: appState.name,
|
||||
viewBackgroundColor: appState.viewBackgroundColor,
|
||||
})
|
||||
.catch(muteFSAbortError)
|
||||
.catch((error) => {
|
||||
console.error(error);
|
||||
setAppState({ errorMessage: error.message });
|
||||
});
|
||||
|
||||
if (
|
||||
appState.exportEmbedScene &&
|
||||
fileHandle &&
|
||||
isImageFileHandle(fileHandle)
|
||||
) {
|
||||
setAppState({ fileHandle });
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<ExportDialog
|
||||
<ImageExportDialog
|
||||
elements={elements}
|
||||
appState={appState}
|
||||
actionManager={actionManager}
|
||||
onExportToPng={createExporter("png")}
|
||||
onExportToSvg={createExporter("svg")}
|
||||
onExportToClipboard={createExporter("clipboard")}
|
||||
onExportToBackend={
|
||||
onExportToBackend
|
||||
? (elements) => {
|
||||
onExportToBackend &&
|
||||
onExportToBackend(elements, appState, canvas);
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
const Separator = () => {
|
||||
return <div style={{ width: ".625em" }} />;
|
||||
};
|
||||
|
||||
const renderViewModeCanvasActions = () => {
|
||||
return (
|
||||
<Section
|
||||
@@ -410,9 +457,8 @@ const LayerUI = ({
|
||||
<Island padding={2} style={{ zIndex: 1 }}>
|
||||
<Stack.Col gap={4}>
|
||||
<Stack.Row gap={1} justifyContent="space-between">
|
||||
{actionManager.renderAction("saveScene")}
|
||||
{actionManager.renderAction("saveAsScene")}
|
||||
{renderExportDialog()}
|
||||
{renderJSONExportDialog()}
|
||||
{renderImageExportDialog()}
|
||||
</Stack.Row>
|
||||
</Stack.Col>
|
||||
</Island>
|
||||
@@ -431,11 +477,12 @@ const LayerUI = ({
|
||||
<Island padding={2} style={{ zIndex: 1 }}>
|
||||
<Stack.Col gap={4}>
|
||||
<Stack.Row gap={1} justifyContent="space-between">
|
||||
{actionManager.renderAction("loadScene")}
|
||||
{actionManager.renderAction("saveScene")}
|
||||
{actionManager.renderAction("saveAsScene")}
|
||||
{renderExportDialog()}
|
||||
{actionManager.renderAction("clearCanvas")}
|
||||
<Separator />
|
||||
{actionManager.renderAction("loadScene")}
|
||||
{renderJSONExportDialog()}
|
||||
{renderImageExportDialog()}
|
||||
<Separator />
|
||||
{onCollabButtonClick && (
|
||||
<CollabButton
|
||||
isCollaborating={isCollaborating}
|
||||
@@ -450,6 +497,9 @@ const LayerUI = ({
|
||||
setAppState={setAppState}
|
||||
showThemeBtn={showThemeBtn}
|
||||
/>
|
||||
{appState.fileHandle && (
|
||||
<>{actionManager.renderAction("saveToActiveFile")}</>
|
||||
)}
|
||||
</Stack.Col>
|
||||
</Island>
|
||||
</Section>
|
||||
@@ -468,7 +518,8 @@ const LayerUI = ({
|
||||
style={{
|
||||
// we want to make sure this doesn't overflow so substracting 200
|
||||
// which is approximately height of zoom footer and top left menu items with some buffer
|
||||
maxHeight: `${appState.height - 200}px`,
|
||||
// if active file name is displayed, subtracting 248 to account for its height
|
||||
maxHeight: `${appState.height - (appState.fileHandle ? 248 : 200)}px`,
|
||||
}}
|
||||
>
|
||||
<SelectedShapeActions
|
||||
@@ -503,6 +554,10 @@ const LayerUI = ({
|
||||
onAddToLibrary={deselectItems}
|
||||
setAppState={setAppState}
|
||||
libraryReturnUrl={libraryReturnUrl}
|
||||
focusContainer={focusContainer}
|
||||
library={library}
|
||||
theme={appState.theme}
|
||||
id={id}
|
||||
/>
|
||||
) : null;
|
||||
|
||||
@@ -529,6 +584,12 @@ const LayerUI = ({
|
||||
{(heading) => (
|
||||
<Stack.Col gap={4} align="start">
|
||||
<Stack.Row gap={1}>
|
||||
<LockButton
|
||||
zenModeEnabled={zenModeEnabled}
|
||||
checked={appState.elementLocked}
|
||||
onChange={onLockToggle}
|
||||
title={t("toolBar.lock")}
|
||||
/>
|
||||
<Island
|
||||
padding={1}
|
||||
className={clsx({ "zen-mode": zenModeEnabled })}
|
||||
@@ -540,15 +601,12 @@ const LayerUI = ({
|
||||
canvas={canvas}
|
||||
elementType={appState.elementType}
|
||||
setAppState={setAppState}
|
||||
isLibraryOpen={appState.isLibraryOpen}
|
||||
/>
|
||||
</Stack.Row>
|
||||
</Island>
|
||||
<LockIcon
|
||||
zenModeEnabled={zenModeEnabled}
|
||||
checked={appState.elementLocked}
|
||||
onChange={onLockToggle}
|
||||
title={t("toolBar.lock")}
|
||||
<LibraryButton
|
||||
appState={appState}
|
||||
setAppState={setAppState}
|
||||
/>
|
||||
</Stack.Row>
|
||||
{libraryMenu}
|
||||
@@ -556,24 +614,32 @@ const LayerUI = ({
|
||||
)}
|
||||
</Section>
|
||||
)}
|
||||
<UserList
|
||||
className={clsx("zen-mode-transition", {
|
||||
"transition-right": zenModeEnabled,
|
||||
})}
|
||||
<div
|
||||
className={clsx(
|
||||
"layer-ui__wrapper__top-right zen-mode-transition",
|
||||
{
|
||||
"transition-right": zenModeEnabled,
|
||||
},
|
||||
)}
|
||||
>
|
||||
{appState.collaborators.size > 0 &&
|
||||
Array.from(appState.collaborators)
|
||||
// Collaborator is either not initialized or is actually the current user.
|
||||
.filter(([_, client]) => Object.keys(client).length !== 0)
|
||||
.map(([clientId, client]) => (
|
||||
<Tooltip
|
||||
label={client.username || "Unknown user"}
|
||||
key={clientId}
|
||||
>
|
||||
{actionManager.renderAction("goToCollaborator", clientId)}
|
||||
</Tooltip>
|
||||
))}
|
||||
</UserList>
|
||||
<UserList>
|
||||
{appState.collaborators.size > 0 &&
|
||||
Array.from(appState.collaborators)
|
||||
// Collaborator is either not initialized or is actually the current user.
|
||||
.filter(([_, client]) => Object.keys(client).length !== 0)
|
||||
.map(([clientId, client]) => (
|
||||
<Tooltip
|
||||
label={client.username || "Unknown user"}
|
||||
key={clientId}
|
||||
>
|
||||
{actionManager.renderAction("goToCollaborator", {
|
||||
id: clientId,
|
||||
})}
|
||||
</Tooltip>
|
||||
))}
|
||||
</UserList>
|
||||
{renderTopRightUI?.(isMobile, appState)}
|
||||
</div>
|
||||
</div>
|
||||
</FixedSideContainer>
|
||||
);
|
||||
@@ -581,61 +647,71 @@ const LayerUI = ({
|
||||
|
||||
const renderBottomAppMenu = () => {
|
||||
return (
|
||||
<div
|
||||
className={clsx("App-menu App-menu_bottom zen-mode-transition", {
|
||||
"App-menu_bottom--transition-left": zenModeEnabled,
|
||||
})}
|
||||
<footer
|
||||
role="contentinfo"
|
||||
className="layer-ui__wrapper__footer App-menu App-menu_bottom"
|
||||
>
|
||||
<Stack.Col gap={2}>
|
||||
<Section heading="canvasActions">
|
||||
<Island padding={1}>
|
||||
<ZoomActions
|
||||
renderAction={actionManager.renderAction}
|
||||
zoom={appState.zoom}
|
||||
/>
|
||||
</Island>
|
||||
{renderEncryptedIcon()}
|
||||
</Section>
|
||||
</Stack.Col>
|
||||
</div>
|
||||
<div
|
||||
className={clsx(
|
||||
"layer-ui__wrapper__footer-left zen-mode-transition",
|
||||
{
|
||||
"layer-ui__wrapper__footer-left--transition-left": zenModeEnabled,
|
||||
},
|
||||
)}
|
||||
>
|
||||
<Stack.Col gap={2}>
|
||||
<Section heading="canvasActions">
|
||||
<Island padding={1}>
|
||||
<ZoomActions
|
||||
renderAction={actionManager.renderAction}
|
||||
zoom={appState.zoom}
|
||||
/>
|
||||
</Island>
|
||||
{!viewModeEnabled && (
|
||||
<div
|
||||
className={clsx("undo-redo-buttons zen-mode-transition", {
|
||||
"layer-ui__wrapper__footer-left--transition-bottom": zenModeEnabled,
|
||||
})}
|
||||
>
|
||||
{actionManager.renderAction("undo", { size: "small" })}
|
||||
{actionManager.renderAction("redo", { size: "small" })}
|
||||
</div>
|
||||
)}
|
||||
</Section>
|
||||
</Stack.Col>
|
||||
</div>
|
||||
<div
|
||||
className={clsx(
|
||||
"layer-ui__wrapper__footer-center zen-mode-transition",
|
||||
{
|
||||
"layer-ui__wrapper__footer-left--transition-bottom": zenModeEnabled,
|
||||
},
|
||||
)}
|
||||
>
|
||||
{renderCustomFooter?.(false, appState)}
|
||||
</div>
|
||||
<div
|
||||
className={clsx(
|
||||
"layer-ui__wrapper__footer-right zen-mode-transition",
|
||||
{
|
||||
"transition-right disable-pointerEvents": zenModeEnabled,
|
||||
},
|
||||
)}
|
||||
>
|
||||
{actionManager.renderAction("toggleShortcuts")}
|
||||
</div>
|
||||
<button
|
||||
className={clsx("disable-zen-mode", {
|
||||
"disable-zen-mode--visible": showExitZenModeBtn,
|
||||
})}
|
||||
onClick={toggleZenMode}
|
||||
>
|
||||
{t("buttons.exitZenMode")}
|
||||
</button>
|
||||
</footer>
|
||||
);
|
||||
};
|
||||
|
||||
const renderGitHubCorner = () => {
|
||||
return (
|
||||
<aside
|
||||
className={clsx(
|
||||
"layer-ui__wrapper__github-corner zen-mode-transition",
|
||||
{
|
||||
"transition-right": zenModeEnabled,
|
||||
},
|
||||
)}
|
||||
>
|
||||
<GitHubCorner theme={appState.theme} />
|
||||
</aside>
|
||||
);
|
||||
};
|
||||
const renderFooter = () => (
|
||||
<footer role="contentinfo" className="layer-ui__wrapper__footer">
|
||||
<div
|
||||
className={clsx("zen-mode-transition", {
|
||||
"transition-right disable-pointerEvents": zenModeEnabled,
|
||||
})}
|
||||
>
|
||||
{renderCustomFooter?.(false)}
|
||||
{actionManager.renderAction("toggleShortcuts")}
|
||||
</div>
|
||||
<button
|
||||
className={clsx("disable-zen-mode", {
|
||||
"disable-zen-mode--visible": showExitZenModeBtn,
|
||||
})}
|
||||
onClick={toggleZenMode}
|
||||
>
|
||||
{t("buttons.exitZenMode")}
|
||||
</button>
|
||||
</footer>
|
||||
);
|
||||
|
||||
const dialogs = (
|
||||
<>
|
||||
{appState.isLoading && <LoadingMessage />}
|
||||
@@ -646,7 +722,11 @@ const LayerUI = ({
|
||||
/>
|
||||
)}
|
||||
{appState.showHelpDialog && (
|
||||
<HelpDialog onClose={() => setAppState({ showHelpDialog: false })} />
|
||||
<HelpDialog
|
||||
onClose={() => {
|
||||
setAppState({ showHelpDialog: false });
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{appState.pasteDialog.shown && (
|
||||
<PasteChartDialog
|
||||
@@ -671,7 +751,8 @@ const LayerUI = ({
|
||||
elements={elements}
|
||||
actionManager={actionManager}
|
||||
libraryMenu={libraryMenu}
|
||||
exportButton={renderExportDialog()}
|
||||
renderJSONExportDialog={renderJSONExportDialog}
|
||||
renderImageExportDialog={renderImageExportDialog}
|
||||
setAppState={setAppState}
|
||||
onCollabButtonClick={onCollabButtonClick}
|
||||
onLockToggle={onLockToggle}
|
||||
@@ -694,8 +775,6 @@ const LayerUI = ({
|
||||
{dialogs}
|
||||
{renderFixedSideContainer()}
|
||||
{renderBottomAppMenu()}
|
||||
{renderGitHubCorner()}
|
||||
{renderFooter()}
|
||||
{appState.scrolledOutside && (
|
||||
<button
|
||||
className="scroll-back-to-content"
|
||||
|
46
src/components/LibraryButton.tsx
Normal file
46
src/components/LibraryButton.tsx
Normal file
@@ -0,0 +1,46 @@
|
||||
import React from "react";
|
||||
import clsx from "clsx";
|
||||
import { t } from "../i18n";
|
||||
import { AppState } from "../types";
|
||||
import { capitalizeString } from "../utils";
|
||||
|
||||
const LIBRARY_ICON = (
|
||||
<svg viewBox="0 0 576 512">
|
||||
<path
|
||||
fill="currentColor"
|
||||
d="M542.22 32.05c-54.8 3.11-163.72 14.43-230.96 55.59-4.64 2.84-7.27 7.89-7.27 13.17v363.87c0 11.55 12.63 18.85 23.28 13.49 69.18-34.82 169.23-44.32 218.7-46.92 16.89-.89 30.02-14.43 30.02-30.66V62.75c.01-17.71-15.35-31.74-33.77-30.7zM264.73 87.64C197.5 46.48 88.58 35.17 33.78 32.05 15.36 31.01 0 45.04 0 62.75V400.6c0 16.24 13.13 29.78 30.02 30.66 49.49 2.6 149.59 12.11 218.77 46.95 10.62 5.35 23.21-1.94 23.21-13.46V100.63c0-5.29-2.62-10.14-7.27-12.99z"
|
||||
></path>
|
||||
</svg>
|
||||
);
|
||||
|
||||
export const LibraryButton: React.FC<{
|
||||
appState: AppState;
|
||||
setAppState: React.Component<any, AppState>["setState"];
|
||||
}> = ({ appState, setAppState }) => {
|
||||
return (
|
||||
<label
|
||||
className={clsx(
|
||||
"ToolIcon ToolIcon_type_floating ToolIcon__library zen-mode-visibility",
|
||||
`ToolIcon_size_medium`,
|
||||
{
|
||||
"zen-mode-visibility--hidden": appState.zenModeEnabled,
|
||||
},
|
||||
)}
|
||||
title={`${capitalizeString(t("toolBar.library"))} — 9`}
|
||||
style={{ marginInlineStart: "var(--space-factor)" }}
|
||||
>
|
||||
<input
|
||||
className="ToolIcon_type_checkbox"
|
||||
type="checkbox"
|
||||
name="editor-library"
|
||||
onChange={(event) => {
|
||||
setAppState({ isLibraryOpen: event.target.checked });
|
||||
}}
|
||||
checked={appState.isLibraryOpen}
|
||||
aria-label={capitalizeString(t("toolBar.library"))}
|
||||
aria-keyshortcuts="9"
|
||||
/>
|
||||
<div className="ToolIcon__icon">{LIBRARY_ICON}</div>
|
||||
</label>
|
||||
);
|
||||
};
|
@@ -1,10 +1,10 @@
|
||||
import clsx from "clsx";
|
||||
import oc from "open-color";
|
||||
import React, { useEffect, useRef, useState } from "react";
|
||||
import { 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 "../components/App";
|
||||
import { exportToSvg } from "../scene/export";
|
||||
import { LibraryItem } from "../types";
|
||||
import "./LibraryUnit.scss";
|
||||
@@ -36,22 +36,27 @@ export const LibraryUnit = ({
|
||||
if (!elementsToRender) {
|
||||
return;
|
||||
}
|
||||
const svg = exportToSvg(elementsToRender, {
|
||||
exportBackground: false,
|
||||
viewBackgroundColor: oc.white,
|
||||
shouldAddWatermark: false,
|
||||
});
|
||||
for (const child of ref.current!.children) {
|
||||
if (child.tagName !== "svg") {
|
||||
continue;
|
||||
}
|
||||
ref.current!.removeChild(child);
|
||||
}
|
||||
ref.current!.appendChild(svg);
|
||||
|
||||
let svg: SVGSVGElement;
|
||||
const current = ref.current!;
|
||||
|
||||
(async () => {
|
||||
svg = await exportToSvg(elementsToRender, {
|
||||
exportBackground: false,
|
||||
viewBackgroundColor: oc.white,
|
||||
});
|
||||
for (const child of ref.current!.children) {
|
||||
if (child.tagName !== "svg") {
|
||||
continue;
|
||||
}
|
||||
current!.removeChild(child);
|
||||
}
|
||||
current!.appendChild(svg);
|
||||
})();
|
||||
|
||||
return () => {
|
||||
current.removeChild(svg);
|
||||
if (svg) {
|
||||
current.removeChild(svg);
|
||||
}
|
||||
};
|
||||
}, [elements, pendingElements]);
|
||||
|
||||
|
@@ -1,4 +1,3 @@
|
||||
import React from "react";
|
||||
import { t } from "../i18n";
|
||||
|
||||
export const LoadingMessage = () => {
|
||||
|
@@ -2,20 +2,17 @@ import "./ToolIcon.scss";
|
||||
|
||||
import React from "react";
|
||||
import clsx from "clsx";
|
||||
|
||||
type LockIconSize = "s" | "m";
|
||||
import { ToolButtonSize } from "./ToolButton";
|
||||
|
||||
type LockIconProps = {
|
||||
title?: string;
|
||||
name?: string;
|
||||
id?: string;
|
||||
checked: boolean;
|
||||
onChange?(): void;
|
||||
size?: LockIconSize;
|
||||
zenModeEnabled?: boolean;
|
||||
};
|
||||
|
||||
const DEFAULT_SIZE: LockIconSize = "m";
|
||||
const DEFAULT_SIZE: ToolButtonSize = "medium";
|
||||
|
||||
const ICONS = {
|
||||
CHECKED: (
|
||||
@@ -41,12 +38,12 @@ const ICONS = {
|
||||
),
|
||||
};
|
||||
|
||||
export const LockIcon = (props: LockIconProps) => {
|
||||
export const LockButton = (props: LockIconProps) => {
|
||||
return (
|
||||
<label
|
||||
className={clsx(
|
||||
"ToolIcon ToolIcon__lock ToolIcon_type_floating zen-mode-visibility",
|
||||
`ToolIcon_size_${props.size || DEFAULT_SIZE}`,
|
||||
`ToolIcon_size_${DEFAULT_SIZE}`,
|
||||
{
|
||||
"zen-mode-visibility--hidden": props.zenModeEnabled,
|
||||
},
|
||||
@@ -57,7 +54,6 @@ export const LockIcon = (props: LockIconProps) => {
|
||||
className="ToolIcon_type_checkbox"
|
||||
type="checkbox"
|
||||
name={props.name}
|
||||
id={props.id}
|
||||
onChange={props.onChange}
|
||||
checked={props.checked}
|
||||
aria-label={props.title}
|
@@ -13,14 +13,16 @@ import { SelectedShapeActions, ShapesSwitcher } from "./Actions";
|
||||
import { Section } from "./Section";
|
||||
import CollabButton from "./CollabButton";
|
||||
import { SCROLLBAR_WIDTH, SCROLLBAR_MARGIN } from "../scene/scrollbars";
|
||||
import { LockIcon } from "./LockIcon";
|
||||
import { LockButton } from "./LockButton";
|
||||
import { UserList } from "./UserList";
|
||||
import { BackgroundPickerAndDarkModeToggle } from "./BackgroundPickerAndDarkModeToggle";
|
||||
import { LibraryButton } from "./LibraryButton";
|
||||
|
||||
type MobileMenuProps = {
|
||||
appState: AppState;
|
||||
actionManager: ActionManager;
|
||||
exportButton: React.ReactNode;
|
||||
renderJSONExportDialog: () => React.ReactNode;
|
||||
renderImageExportDialog: () => React.ReactNode;
|
||||
setAppState: React.Component<any, AppState>["setState"];
|
||||
elements: readonly NonDeletedExcalidrawElement[];
|
||||
libraryMenu: JSX.Element | null;
|
||||
@@ -28,7 +30,7 @@ type MobileMenuProps = {
|
||||
onLockToggle: () => void;
|
||||
canvas: HTMLCanvasElement | null;
|
||||
isCollaborating: boolean;
|
||||
renderCustomFooter?: (isMobile: boolean) => JSX.Element;
|
||||
renderCustomFooter?: (isMobile: boolean, appState: AppState) => JSX.Element;
|
||||
viewModeEnabled: boolean;
|
||||
showThemeBtn: boolean;
|
||||
};
|
||||
@@ -38,7 +40,8 @@ export const MobileMenu = ({
|
||||
elements,
|
||||
libraryMenu,
|
||||
actionManager,
|
||||
exportButton,
|
||||
renderJSONExportDialog,
|
||||
renderImageExportDialog,
|
||||
setAppState,
|
||||
onCollabButtonClick,
|
||||
onLockToggle,
|
||||
@@ -62,15 +65,15 @@ export const MobileMenu = ({
|
||||
canvas={canvas}
|
||||
elementType={appState.elementType}
|
||||
setAppState={setAppState}
|
||||
isLibraryOpen={appState.isLibraryOpen}
|
||||
/>
|
||||
</Stack.Row>
|
||||
</Island>
|
||||
<LockIcon
|
||||
<LockButton
|
||||
checked={appState.elementLocked}
|
||||
onChange={onLockToggle}
|
||||
title={t("toolBar.lock")}
|
||||
/>
|
||||
<LibraryButton appState={appState} setAppState={setAppState} />
|
||||
</Stack.Row>
|
||||
{libraryMenu}
|
||||
</Stack.Col>
|
||||
@@ -107,19 +110,17 @@ export const MobileMenu = ({
|
||||
if (viewModeEnabled) {
|
||||
return (
|
||||
<>
|
||||
{actionManager.renderAction("saveScene")}
|
||||
{actionManager.renderAction("saveAsScene")}
|
||||
{exportButton}
|
||||
{renderJSONExportDialog()}
|
||||
{renderImageExportDialog()}
|
||||
</>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<>
|
||||
{actionManager.renderAction("loadScene")}
|
||||
{actionManager.renderAction("saveScene")}
|
||||
{actionManager.renderAction("saveAsScene")}
|
||||
{exportButton}
|
||||
{actionManager.renderAction("clearCanvas")}
|
||||
{actionManager.renderAction("loadScene")}
|
||||
{renderJSONExportDialog()}
|
||||
{renderImageExportDialog()}
|
||||
{onCollabButtonClick && (
|
||||
<CollabButton
|
||||
isCollaborating={isCollaborating}
|
||||
@@ -155,7 +156,7 @@ export const MobileMenu = ({
|
||||
<div className="panelColumn">
|
||||
<Stack.Col gap={4}>
|
||||
{renderCanvasActions()}
|
||||
{renderCustomFooter?.(true)}
|
||||
{renderCustomFooter?.(true, appState)}
|
||||
{appState.collaborators.size > 0 && (
|
||||
<fieldset>
|
||||
<legend>{t("labels.collaborators")}</legend>
|
||||
@@ -167,10 +168,9 @@ export const MobileMenu = ({
|
||||
)
|
||||
.map(([clientId, client]) => (
|
||||
<React.Fragment key={clientId}>
|
||||
{actionManager.renderAction(
|
||||
"goToCollaborator",
|
||||
clientId,
|
||||
)}
|
||||
{actionManager.renderAction("goToCollaborator", {
|
||||
id: clientId,
|
||||
})}
|
||||
</React.Fragment>
|
||||
))}
|
||||
</UserList>
|
||||
|
@@ -26,8 +26,7 @@
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
z-index: 1;
|
||||
background-color: transparentize($oc-black, 0.7);
|
||||
backdrop-filter: blur(2px);
|
||||
background-color: transparentize($oc-black, 0.3);
|
||||
}
|
||||
|
||||
.Modal__content {
|
||||
@@ -45,14 +44,17 @@
|
||||
|
||||
// for modals, reset blurry bg
|
||||
background: var(--island-bg-color);
|
||||
backdrop-filter: none;
|
||||
|
||||
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} {
|
||||
&:focus {
|
||||
outline: none;
|
||||
}
|
||||
|
||||
@include isMobile {
|
||||
max-width: 100%;
|
||||
border: 0;
|
||||
border-radius: 0;
|
||||
@@ -82,7 +84,7 @@
|
||||
}
|
||||
}
|
||||
|
||||
@media #{$is-mobile-query} {
|
||||
@include isMobile {
|
||||
.Modal {
|
||||
padding: 0;
|
||||
}
|
||||
|
@@ -1,9 +1,12 @@
|
||||
import "./Modal.scss";
|
||||
|
||||
import React, { useState, useLayoutEffect } from "react";
|
||||
import React, { useState, useLayoutEffect, useRef } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import clsx from "clsx";
|
||||
import { KEYS } from "../keys";
|
||||
import { useExcalidrawContainer, useIsMobile } from "./App";
|
||||
import { AppState } from "../types";
|
||||
import { THEME } from "../constants";
|
||||
|
||||
export const Modal = (props: {
|
||||
className?: string;
|
||||
@@ -11,8 +14,10 @@ export const Modal = (props: {
|
||||
maxWidth?: number;
|
||||
onCloseRequest(): void;
|
||||
labelledBy: string;
|
||||
theme?: AppState["theme"];
|
||||
}) => {
|
||||
const modalRoot = useBodyRoot();
|
||||
const { theme = THEME.LIGHT } = props;
|
||||
const modalRoot = useBodyRoot(theme);
|
||||
|
||||
if (!modalRoot) {
|
||||
return null;
|
||||
@@ -21,6 +26,7 @@ export const Modal = (props: {
|
||||
const handleKeydown = (event: React.KeyboardEvent) => {
|
||||
if (event.key === KEYS.ESCAPE) {
|
||||
event.nativeEvent.stopImmediatePropagation();
|
||||
event.stopPropagation();
|
||||
props.onCloseRequest();
|
||||
}
|
||||
};
|
||||
@@ -37,6 +43,7 @@ export const Modal = (props: {
|
||||
<div
|
||||
className="Modal__content"
|
||||
style={{ "--max-width": `${props.maxWidth}px` }}
|
||||
tabIndex={0}
|
||||
>
|
||||
{props.children}
|
||||
</div>
|
||||
@@ -45,16 +52,29 @@ export const Modal = (props: {
|
||||
);
|
||||
};
|
||||
|
||||
const useBodyRoot = () => {
|
||||
const useBodyRoot = (theme: AppState["theme"]) => {
|
||||
const [div, setDiv] = useState<HTMLDivElement | null>(null);
|
||||
|
||||
const isMobile = useIsMobile();
|
||||
const isMobileRef = useRef(isMobile);
|
||||
isMobileRef.current = isMobile;
|
||||
|
||||
const { container: excalidrawContainer } = useExcalidrawContainer();
|
||||
|
||||
useLayoutEffect(() => {
|
||||
const isDarkTheme = !!document
|
||||
.querySelector(".excalidraw")
|
||||
?.classList.contains("theme--dark");
|
||||
if (div) {
|
||||
div.classList.toggle("excalidraw--mobile", isMobile);
|
||||
}
|
||||
}, [div, isMobile]);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
const isDarkTheme =
|
||||
!!excalidrawContainer?.classList.contains("theme--dark") ||
|
||||
theme === "dark";
|
||||
const div = document.createElement("div");
|
||||
|
||||
div.classList.add("excalidraw", "excalidraw-modal-container");
|
||||
div.classList.toggle("excalidraw--mobile", isMobileRef.current);
|
||||
|
||||
if (isDarkTheme) {
|
||||
div.classList.add("theme--dark");
|
||||
@@ -67,7 +87,7 @@ const useBodyRoot = () => {
|
||||
return () => {
|
||||
document.body.removeChild(div);
|
||||
};
|
||||
}, []);
|
||||
}, [excalidrawContainer, theme]);
|
||||
|
||||
return div;
|
||||
};
|
||||
|
@@ -2,7 +2,7 @@
|
||||
|
||||
.excalidraw {
|
||||
.PasteChartDialog {
|
||||
@media #{$is-mobile-query} {
|
||||
@include isMobile {
|
||||
.Island {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
@@ -13,7 +13,7 @@
|
||||
align-items: center;
|
||||
justify-content: space-around;
|
||||
flex-wrap: wrap;
|
||||
@media #{$is-mobile-query} {
|
||||
@include isMobile {
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
}
|
||||
|
@@ -34,20 +34,21 @@ const ChartPreviewBtn = (props: {
|
||||
0,
|
||||
);
|
||||
setChartElements(elements);
|
||||
|
||||
const svg = exportToSvg(elements, {
|
||||
exportBackground: false,
|
||||
viewBackgroundColor: oc.white,
|
||||
shouldAddWatermark: false,
|
||||
});
|
||||
|
||||
let svg: SVGSVGElement;
|
||||
const previewNode = previewRef.current!;
|
||||
|
||||
previewNode.appendChild(svg);
|
||||
(async () => {
|
||||
svg = await exportToSvg(elements, {
|
||||
exportBackground: false,
|
||||
viewBackgroundColor: oc.white,
|
||||
});
|
||||
|
||||
if (props.selected) {
|
||||
(previewNode.parentNode as HTMLDivElement).focus();
|
||||
}
|
||||
previewNode.appendChild(svg);
|
||||
|
||||
if (props.selected) {
|
||||
(previewNode.parentNode as HTMLDivElement).focus();
|
||||
}
|
||||
})();
|
||||
|
||||
return () => {
|
||||
previewNode.removeChild(svg);
|
||||
|
@@ -1,6 +1,6 @@
|
||||
.excalidraw {
|
||||
.popover {
|
||||
position: fixed;
|
||||
position: absolute;
|
||||
z-index: 10;
|
||||
}
|
||||
}
|
||||
|
25
src/components/ProjectName.scss
Normal file
25
src/components/ProjectName.scss
Normal file
@@ -0,0 +1,25 @@
|
||||
.ProjectName {
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
@@ -1,6 +1,10 @@
|
||||
import "./TextInput.scss";
|
||||
|
||||
import React, { Component } from "react";
|
||||
import React, { useState } from "react";
|
||||
import { focusNearestParent } from "../utils";
|
||||
|
||||
import "./ProjectName.scss";
|
||||
import { useExcalidrawContainer } from "./App";
|
||||
|
||||
type Props = {
|
||||
value: string;
|
||||
@@ -9,21 +13,19 @@ type Props = {
|
||||
isNameEditable: boolean;
|
||||
};
|
||||
|
||||
type State = {
|
||||
fileName: string;
|
||||
};
|
||||
export class ProjectName extends Component<Props, State> {
|
||||
state = {
|
||||
fileName: this.props.value,
|
||||
};
|
||||
private handleBlur = (event: any) => {
|
||||
export const ProjectName = (props: Props) => {
|
||||
const { id } = useExcalidrawContainer();
|
||||
const [fileName, setFileName] = useState<string>(props.value);
|
||||
|
||||
const handleBlur = (event: any) => {
|
||||
focusNearestParent(event.target);
|
||||
const value = event.target.value;
|
||||
if (value !== this.props.value) {
|
||||
this.props.onChange(value);
|
||||
if (value !== props.value) {
|
||||
props.onChange(value);
|
||||
}
|
||||
};
|
||||
|
||||
private handleKeyDown = (event: React.KeyboardEvent<HTMLElement>) => {
|
||||
const handleKeyDown = (event: React.KeyboardEvent<HTMLElement>) => {
|
||||
if (event.key === "Enter") {
|
||||
event.preventDefault();
|
||||
if (event.nativeEvent.isComposing || event.keyCode === 229) {
|
||||
@@ -33,29 +35,25 @@ export class ProjectName extends Component<Props, State> {
|
||||
}
|
||||
};
|
||||
|
||||
public render() {
|
||||
return (
|
||||
<>
|
||||
<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>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
}
|
||||
return (
|
||||
<div className="ProjectName">
|
||||
<label className="ProjectName-label" htmlFor="filename">
|
||||
{`${props.label}${props.isNameEditable ? "" : ":"}`}
|
||||
</label>
|
||||
{props.isNameEditable ? (
|
||||
<input
|
||||
className="TextInput"
|
||||
onBlur={handleBlur}
|
||||
onKeyDown={handleKeyDown}
|
||||
id={`${id}-filename`}
|
||||
value={fileName}
|
||||
onChange={(event) => setFileName(event.target.value)}
|
||||
/>
|
||||
) : (
|
||||
<span className="TextInput TextInput--readonly" id={`${id}-filename`}>
|
||||
{props.value}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
@@ -1,5 +1,6 @@
|
||||
import React from "react";
|
||||
import { t } from "../i18n";
|
||||
import { useExcalidrawContainer } from "./App";
|
||||
|
||||
interface SectionProps extends React.HTMLProps<HTMLElement> {
|
||||
heading: string;
|
||||
@@ -7,13 +8,14 @@ interface SectionProps extends React.HTMLProps<HTMLElement> {
|
||||
}
|
||||
|
||||
export const Section = ({ heading, children, ...props }: SectionProps) => {
|
||||
const { id } = useExcalidrawContainer();
|
||||
const header = (
|
||||
<h2 className="visually-hidden" id={`${heading}-title`}>
|
||||
<h2 className="visually-hidden" id={`${id}-${heading}-title`}>
|
||||
{t(`headings.${heading}`)}
|
||||
</h2>
|
||||
);
|
||||
return (
|
||||
<section {...props} aria-labelledby={`${heading}-title`}>
|
||||
<section {...props} aria-labelledby={`${id}-${heading}-title`}>
|
||||
{typeof children === "function" ? (
|
||||
children(header)
|
||||
) : (
|
||||
|
@@ -6,7 +6,7 @@
|
||||
top: 64px;
|
||||
right: 12px;
|
||||
font-size: 12px;
|
||||
z-index: 999;
|
||||
z-index: 10;
|
||||
|
||||
h3 {
|
||||
margin: 0 24px 8px 0;
|
||||
|
@@ -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 "../components/App";
|
||||
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>
|
||||
|
@@ -1,4 +1,4 @@
|
||||
import React, { useCallback, useEffect, useRef } from "react";
|
||||
import { useCallback, useEffect, useRef } from "react";
|
||||
import { TOAST_TIMEOUT } from "../constants";
|
||||
import "./Toast.scss";
|
||||
|
||||
|
@@ -2,8 +2,9 @@ import "./ToolIcon.scss";
|
||||
|
||||
import React from "react";
|
||||
import clsx from "clsx";
|
||||
import { useExcalidrawContainer } from "./App";
|
||||
|
||||
type ToolIconSize = "s" | "m";
|
||||
export type ToolButtonSize = "small" | "medium";
|
||||
|
||||
type ToolButtonBaseProps = {
|
||||
icon?: React.ReactNode;
|
||||
@@ -14,7 +15,7 @@ type ToolButtonBaseProps = {
|
||||
title?: string;
|
||||
name?: string;
|
||||
id?: string;
|
||||
size?: ToolIconSize;
|
||||
size?: ToolButtonSize;
|
||||
keyBindingLabel?: string;
|
||||
showAriaLabel?: boolean;
|
||||
hidden?: boolean;
|
||||
@@ -29,21 +30,24 @@ type ToolButtonProps =
|
||||
children?: React.ReactNode;
|
||||
onClick?(): void;
|
||||
})
|
||||
| (ToolButtonBaseProps & {
|
||||
type: "icon";
|
||||
children?: React.ReactNode;
|
||||
onClick?(): void;
|
||||
})
|
||||
| (ToolButtonBaseProps & {
|
||||
type: "radio";
|
||||
|
||||
checked: boolean;
|
||||
onChange?(): void;
|
||||
});
|
||||
|
||||
const DEFAULT_SIZE: ToolIconSize = "m";
|
||||
|
||||
export const ToolButton = React.forwardRef((props: ToolButtonProps, ref) => {
|
||||
const { id: excalId } = useExcalidrawContainer();
|
||||
const innerRef = React.useRef(null);
|
||||
React.useImperativeHandle(ref, () => innerRef.current);
|
||||
const sizeCn = `ToolIcon_size_${props.size || DEFAULT_SIZE}`;
|
||||
const sizeCn = `ToolIcon_size_${props.size}`;
|
||||
|
||||
if (props.type === "button") {
|
||||
if (props.type === "button" || props.type === "icon") {
|
||||
return (
|
||||
<button
|
||||
className={clsx(
|
||||
@@ -56,6 +60,7 @@ export const ToolButton = React.forwardRef((props: ToolButtonProps, ref) => {
|
||||
{
|
||||
ToolIcon: !props.hidden,
|
||||
"ToolIcon--selected": props.selected,
|
||||
"ToolIcon--plain": props.type === "icon",
|
||||
},
|
||||
)}
|
||||
data-testid={props["data-testid"]}
|
||||
@@ -66,14 +71,16 @@ export const ToolButton = React.forwardRef((props: ToolButtonProps, ref) => {
|
||||
onClick={props.onClick}
|
||||
ref={innerRef}
|
||||
>
|
||||
<div className="ToolIcon__icon" aria-hidden="true">
|
||||
{props.icon || props.label}
|
||||
{props.keyBindingLabel && (
|
||||
<span className="ToolIcon__keybinding">
|
||||
{props.keyBindingLabel}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{(props.icon || props.label) && (
|
||||
<div className="ToolIcon__icon" aria-hidden="true">
|
||||
{props.icon || props.label}
|
||||
{props.keyBindingLabel && (
|
||||
<span className="ToolIcon__keybinding">
|
||||
{props.keyBindingLabel}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{props.showAriaLabel && (
|
||||
<div className="ToolIcon__label">{props["aria-label"]}</div>
|
||||
)}
|
||||
@@ -91,7 +98,7 @@ export const ToolButton = React.forwardRef((props: ToolButtonProps, ref) => {
|
||||
aria-label={props["aria-label"]}
|
||||
aria-keyshortcuts={props["aria-keyshortcuts"]}
|
||||
data-testid={props["data-testid"]}
|
||||
id={props.id}
|
||||
id={`${excalId}-${props.id}`}
|
||||
onChange={props.onChange}
|
||||
checked={props.checked}
|
||||
ref={innerRef}
|
||||
@@ -109,4 +116,5 @@ export const ToolButton = React.forwardRef((props: ToolButtonProps, ref) => {
|
||||
ToolButton.defaultProps = {
|
||||
visible: true,
|
||||
className: "",
|
||||
size: "medium",
|
||||
};
|
||||
|
@@ -8,9 +8,26 @@
|
||||
position: relative;
|
||||
font-family: Cascadia;
|
||||
cursor: pointer;
|
||||
background-color: var(--button-gray-1);
|
||||
-webkit-tap-highlight-color: transparent;
|
||||
border-radius: var(--space-factor);
|
||||
user-select: none;
|
||||
|
||||
background-color: var(--button-gray-1);
|
||||
|
||||
&:hover {
|
||||
background-color: var(--button-gray-2);
|
||||
}
|
||||
&:active {
|
||||
background-color: var(--button-gray-3);
|
||||
}
|
||||
}
|
||||
|
||||
.ToolIcon--plain {
|
||||
background-color: transparent;
|
||||
.ToolIcon__icon {
|
||||
width: 2rem;
|
||||
height: 2rem;
|
||||
}
|
||||
}
|
||||
|
||||
.ToolIcon__icon {
|
||||
@@ -43,9 +60,9 @@
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.ToolIcon_size_s .ToolIcon__icon {
|
||||
width: 1.4rem;
|
||||
height: 1.4rem;
|
||||
.ToolIcon_size_small .ToolIcon__icon {
|
||||
width: 2rem;
|
||||
height: 2rem;
|
||||
font-size: 0.8em;
|
||||
}
|
||||
|
||||
@@ -57,14 +74,6 @@
|
||||
margin: 0;
|
||||
font-size: inherit;
|
||||
|
||||
&:hover {
|
||||
background-color: var(--button-gray-1);
|
||||
}
|
||||
|
||||
&:active {
|
||||
background-color: var(--button-gray-2);
|
||||
}
|
||||
|
||||
&:focus {
|
||||
box-shadow: 0 0 0 2px var(--focus-highlight-color);
|
||||
}
|
||||
@@ -77,6 +86,14 @@
|
||||
}
|
||||
}
|
||||
|
||||
&:hover {
|
||||
background-color: var(--button-gray-2);
|
||||
}
|
||||
|
||||
&:active {
|
||||
background-color: var(--button-gray-3);
|
||||
}
|
||||
|
||||
&--show {
|
||||
visibility: visible;
|
||||
}
|
||||
@@ -94,6 +111,9 @@
|
||||
|
||||
&:not(.ToolIcon_toggle_opaque):checked + .ToolIcon__icon {
|
||||
background-color: var(--button-gray-2);
|
||||
&:active {
|
||||
background-color: var(--button-gray-3);
|
||||
}
|
||||
}
|
||||
|
||||
&:focus + .ToolIcon__icon {
|
||||
@@ -121,12 +141,21 @@
|
||||
}
|
||||
|
||||
.ToolIcon__icon {
|
||||
background-color: var(--button-gray-1);
|
||||
&:hover {
|
||||
background-color: var(--button-gray-2);
|
||||
}
|
||||
&:active {
|
||||
background-color: var(--button-gray-3);
|
||||
}
|
||||
|
||||
width: 2rem;
|
||||
height: 2em;
|
||||
}
|
||||
}
|
||||
|
||||
.ToolIcon.ToolIcon__lock {
|
||||
margin-inline-end: var(--space-factor);
|
||||
&.ToolIcon_type_floating {
|
||||
margin-left: 0.1rem;
|
||||
}
|
||||
@@ -157,10 +186,9 @@
|
||||
// move the lock button out of the way on small viewports
|
||||
// it begins to collide with the GitHub icon before we switch to mobile mode
|
||||
@media (max-width: 760px) {
|
||||
.ToolIcon.ToolIcon__lock {
|
||||
.ToolIcon.ToolIcon_type_floating {
|
||||
display: inline-block;
|
||||
position: absolute;
|
||||
top: 60px;
|
||||
right: -8px;
|
||||
|
||||
margin-left: 0;
|
||||
@@ -185,16 +213,13 @@
|
||||
position: static;
|
||||
}
|
||||
}
|
||||
}
|
||||
.ToolIcon.ToolIcon__library {
|
||||
top: 100px;
|
||||
}
|
||||
|
||||
.TooltipIcon {
|
||||
width: 0.9em;
|
||||
height: 0.9em;
|
||||
margin-left: 5px;
|
||||
margin-top: 1px;
|
||||
|
||||
@media #{$is-mobile-query} {
|
||||
display: none;
|
||||
.ToolIcon.ToolIcon__lock {
|
||||
margin-inline-end: 0;
|
||||
top: 60px;
|
||||
}
|
||||
}
|
||||
|
||||
|
@@ -1,58 +1,45 @@
|
||||
@import "../css/variables.module";
|
||||
.excalidraw {
|
||||
.Tooltip {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.Tooltip__label {
|
||||
--arrow-size: 4px;
|
||||
visibility: hidden;
|
||||
background: $oc-black;
|
||||
color: $oc-white;
|
||||
text-align: center;
|
||||
border-radius: 6px;
|
||||
padding: 8px;
|
||||
position: absolute;
|
||||
z-index: 10;
|
||||
font-size: 13px;
|
||||
line-height: 1.5;
|
||||
font-weight: 500;
|
||||
// extra pixel offset for unknown reasons
|
||||
left: calc(50% + var(--arrow-size) / 2 - 1px);
|
||||
transform: translateX(-50%);
|
||||
word-wrap: break-word;
|
||||
// container in body where the actual tooltip is appended to
|
||||
.excalidraw-tooltip {
|
||||
position: absolute;
|
||||
z-index: 1000;
|
||||
|
||||
&::after {
|
||||
content: "";
|
||||
border: var(--arrow-size) solid transparent;
|
||||
position: absolute;
|
||||
left: calc(50% - var(--arrow-size));
|
||||
}
|
||||
padding: 8px;
|
||||
border-radius: 6px;
|
||||
box-sizing: border-box;
|
||||
pointer-events: none;
|
||||
word-wrap: break-word;
|
||||
|
||||
&--above {
|
||||
bottom: calc(100% + var(--arrow-size) + 3px);
|
||||
background: $oc-black;
|
||||
|
||||
&::after {
|
||||
border-top-color: $oc-black;
|
||||
top: 100%;
|
||||
}
|
||||
}
|
||||
line-height: 1.5;
|
||||
text-align: center;
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
color: $oc-white;
|
||||
|
||||
&--below {
|
||||
top: calc(100% + var(--arrow-size) + 3px);
|
||||
display: none;
|
||||
|
||||
&::after {
|
||||
border-bottom-color: $oc-black;
|
||||
bottom: 100%;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.Tooltip:hover .Tooltip__label {
|
||||
visibility: visible;
|
||||
}
|
||||
|
||||
.Tooltip__label:hover {
|
||||
visibility: visible;
|
||||
&.excalidraw-tooltip--visible {
|
||||
display: block;
|
||||
}
|
||||
}
|
||||
|
||||
// wraps the element we want to apply the tooltip to
|
||||
.excalidraw-tooltip-wrapper {
|
||||
display: flex;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.excalidraw-tooltip-icon {
|
||||
width: 0.9em;
|
||||
height: 0.9em;
|
||||
margin-left: 5px;
|
||||
margin-top: 1px;
|
||||
display: flex;
|
||||
|
||||
@include isMobile {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
@@ -1,31 +1,93 @@
|
||||
import "./Tooltip.scss";
|
||||
|
||||
import React from "react";
|
||||
import React, { useEffect } from "react";
|
||||
|
||||
const getTooltipDiv = () => {
|
||||
const existingDiv = document.querySelector<HTMLDivElement>(
|
||||
".excalidraw-tooltip",
|
||||
);
|
||||
if (existingDiv) {
|
||||
return existingDiv;
|
||||
}
|
||||
const div = document.createElement("div");
|
||||
document.body.appendChild(div);
|
||||
div.classList.add("excalidraw-tooltip");
|
||||
return div;
|
||||
};
|
||||
|
||||
const updateTooltip = (
|
||||
item: HTMLDivElement,
|
||||
tooltip: HTMLDivElement,
|
||||
label: string,
|
||||
long: boolean,
|
||||
) => {
|
||||
tooltip.classList.add("excalidraw-tooltip--visible");
|
||||
tooltip.style.minWidth = long ? "50ch" : "10ch";
|
||||
tooltip.style.maxWidth = long ? "50ch" : "15ch";
|
||||
|
||||
tooltip.textContent = label;
|
||||
|
||||
const {
|
||||
x: itemX,
|
||||
bottom: itemBottom,
|
||||
top: itemTop,
|
||||
width: itemWidth,
|
||||
} = item.getBoundingClientRect();
|
||||
|
||||
const {
|
||||
width: labelWidth,
|
||||
height: labelHeight,
|
||||
} = tooltip.getBoundingClientRect();
|
||||
|
||||
const viewportWidth = window.innerWidth;
|
||||
const viewportHeight = window.innerHeight;
|
||||
|
||||
const margin = 5;
|
||||
|
||||
const left = itemX + itemWidth / 2 - labelWidth / 2;
|
||||
const offsetLeft =
|
||||
left + labelWidth >= viewportWidth ? left + labelWidth - viewportWidth : 0;
|
||||
|
||||
const top = itemBottom + margin;
|
||||
const offsetTop =
|
||||
top + labelHeight >= viewportHeight
|
||||
? itemBottom - itemTop + labelHeight + margin * 2
|
||||
: 0;
|
||||
|
||||
Object.assign(tooltip.style, {
|
||||
top: `${top - offsetTop}px`,
|
||||
left: `${left - offsetLeft}px`,
|
||||
});
|
||||
};
|
||||
|
||||
type TooltipProps = {
|
||||
children: React.ReactNode;
|
||||
label: string;
|
||||
position?: "above" | "below";
|
||||
long?: boolean;
|
||||
};
|
||||
|
||||
export const Tooltip = ({
|
||||
children,
|
||||
label,
|
||||
position = "below",
|
||||
long = false,
|
||||
}: TooltipProps) => (
|
||||
<div className="Tooltip">
|
||||
<span
|
||||
className={
|
||||
position === "above"
|
||||
? "Tooltip__label Tooltip__label--above"
|
||||
: "Tooltip__label Tooltip__label--below"
|
||||
export const Tooltip = ({ children, label, long = false }: TooltipProps) => {
|
||||
useEffect(() => {
|
||||
return () =>
|
||||
getTooltipDiv().classList.remove("excalidraw-tooltip--visible");
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div
|
||||
className="excalidraw-tooltip-wrapper"
|
||||
onPointerEnter={(event) =>
|
||||
updateTooltip(
|
||||
event.currentTarget as HTMLDivElement,
|
||||
getTooltipDiv(),
|
||||
label,
|
||||
long,
|
||||
)
|
||||
}
|
||||
onPointerLeave={() =>
|
||||
getTooltipDiv().classList.remove("excalidraw-tooltip--visible")
|
||||
}
|
||||
style={{ width: long ? "50ch" : "10ch" }}
|
||||
>
|
||||
{label}
|
||||
</span>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user