mirror of
https://github.com/excalidraw/excalidraw.git
synced 2025-08-29 13:16:58 +02:00
Compare commits
481 Commits
gcp-portal
...
image_back
Author | SHA1 | Date | |
---|---|---|---|
![]() |
3c83a322b6 | ||
![]() |
8b5657e1ce | ||
![]() |
8b2b03347c | ||
![]() |
c2a8712593 | ||
![]() |
ff1d7728a0 | ||
![]() |
98b5c37e45 | ||
![]() |
7db63bd397 | ||
![]() |
390da3fd0f | ||
![]() |
104664cb9e | ||
![]() |
c822055ec8 | ||
![]() |
e15d73d94c | ||
![]() |
80ee097b85 | ||
![]() |
10048b877b | ||
![]() |
5dd5862bb9 | ||
![]() |
79989fedda | ||
![]() |
cecabc2196 | ||
![]() |
ed8fb40b63 | ||
![]() |
6e391728fe | ||
![]() |
dfbfbc3f11 | ||
![]() |
9b8ee3cacf | ||
![]() |
4ea73d5d5b | ||
![]() |
618f204ddd | ||
![]() |
720588130c | ||
![]() |
f354788cd0 | ||
![]() |
1c7ee09010 | ||
![]() |
ca15b0a008 | ||
![]() |
650930c5ce | ||
![]() |
79c0d59244 | ||
![]() |
cd50b5f7e9 | ||
![]() |
c0434957ff | ||
![]() |
66aeaeb38d | ||
![]() |
7f545e74ab | ||
![]() |
a776955579 | ||
![]() |
afa7932c9b | ||
![]() |
1ee8d7d082 | ||
![]() |
06db702b5d | ||
![]() |
b53d1f6f3e | ||
![]() |
ca1f3aa094 | ||
![]() |
8ff159e76e | ||
![]() |
f9d2d537a2 | ||
![]() |
dac970c640 | ||
![]() |
78bb3b3d84 | ||
![]() |
7d9d7ad297 | ||
![]() |
de20a5e3ba | ||
![]() |
289f72e45d | ||
![]() |
6dd0e6a4c5 | ||
![]() |
96b31ecbce | ||
![]() |
a132f154cb | ||
![]() |
23acd8f6d1 | ||
![]() |
a60709f5ea | ||
![]() |
896c476716 | ||
![]() |
133ba19919 | ||
![]() |
a2136bfe9d | ||
![]() |
6fbd64fdaa | ||
![]() |
cc4b0c2932 | ||
![]() |
b6ef953dc9 | ||
![]() |
620b662085 | ||
![]() |
1c11df011a | ||
![]() |
59e9651547 | ||
![]() |
1c48d122e0 | ||
![]() |
e4d02fb275 | ||
![]() |
34a382ace9 | ||
![]() |
e60e48e67d | ||
![]() |
84d1d9993c | ||
![]() |
3ff9744b39 | ||
![]() |
b9abcc825a | ||
![]() |
9679eaf74c | ||
![]() |
284747d742 | ||
![]() |
876f85fd7a | ||
![]() |
efc2bbed21 | ||
![]() |
61d193b87b | ||
![]() |
3989d6a989 | ||
![]() |
f6559b65ef | ||
![]() |
bc6b066c07 | ||
![]() |
6370d517a2 | ||
![]() |
b8a37c42e4 | ||
![]() |
76763b80a9 | ||
![]() |
d2a2c9d6b5 | ||
![]() |
3a72f347d2 | ||
![]() |
c1d9456235 | ||
![]() |
c4f8b98208 | ||
![]() |
b6eb57d3f1 | ||
![]() |
473b8ca0ca | ||
![]() |
45206c4ef1 | ||
![]() |
56b4a29aaa | ||
![]() |
bb4dda64b5 | ||
![]() |
39e53b4ae7 | ||
![]() |
6143d5195a | ||
![]() |
f59e608f18 | ||
![]() |
6b24592e4a | ||
![]() |
7b442997dc | ||
![]() |
4bfc5bbcaa | ||
![]() |
2b29b9a96d | ||
![]() |
cc201a6d80 | ||
![]() |
5be58b59e0 | ||
![]() |
f1eb969565 | ||
![]() |
8d4f455cd3 | ||
![]() |
60262cb4cc | ||
![]() |
7501c24f22 | ||
![]() |
00d81aa982 | ||
![]() |
67fe156d06 | ||
![]() |
ef433233d1 | ||
![]() |
1c7056bdaa | ||
![]() |
277ffaacb9 | ||
![]() |
2a3e242cfd | ||
![]() |
b1c6051d6b | ||
![]() |
8df9742463 | ||
![]() |
9fdc382d71 | ||
![]() |
f70d11c2d1 | ||
![]() |
05e54d6785 | ||
![]() |
795a6e4546 | ||
![]() |
a01a4ad739 | ||
![]() |
e09b96ac6f | ||
![]() |
d48fb17718 | ||
![]() |
ede3c4af82 | ||
![]() |
8bcfd97fc5 | ||
![]() |
5721c6dfb5 | ||
![]() |
9b1f77c3be | ||
![]() |
3369035f40 | ||
![]() |
dbc7a8599b | ||
![]() |
09f649daf7 | ||
![]() |
d357664850 | ||
![]() |
f973fdfa89 | ||
![]() |
c15bc50f17 | ||
![]() |
c2d0107cc5 | ||
![]() |
c43fac31a1 | ||
![]() |
9dfaf1752b | ||
![]() |
d9a1eb2f01 | ||
![]() |
f1e17a320f | ||
![]() |
75ecd818b3 | ||
![]() |
a7abc71f6a | ||
![]() |
6d0f0c8f21 | ||
![]() |
790e6da500 | ||
![]() |
8df1a11535 | ||
![]() |
b61ee56dc8 | ||
![]() |
c61f95a327 | ||
![]() |
d6d629f416 | ||
![]() |
65dec605f2 | ||
![]() |
cacec0b5c4 | ||
![]() |
87a302d7e9 | ||
![]() |
899b36c206 | ||
![]() |
534cbef982 | ||
![]() |
b7f118404e | ||
![]() |
aab5067718 | ||
![]() |
b679da02ee | ||
![]() |
ec652820ea | ||
![]() |
5d941ed107 | ||
![]() |
adc478ca34 | ||
![]() |
f1202adb15 | ||
![]() |
fd439cf38a | ||
![]() |
83c63be846 | ||
![]() |
b59d49dd7f | ||
![]() |
0116b70edf | ||
![]() |
3f390d4858 | ||
![]() |
fdde73bff4 | ||
![]() |
90a416e265 | ||
![]() |
a828b2e5de | ||
![]() |
7c51d3c24c | ||
![]() |
4d2d6f181a | ||
![]() |
071416f6ef | ||
![]() |
d675b07089 | ||
![]() |
3975fd592a | ||
![]() |
34a9a4dac6 | ||
![]() |
78e419b790 | ||
![]() |
8d8769ba4e | ||
![]() |
d89fb3371b | ||
![]() |
8410972cff | ||
![]() |
2c8d041987 | ||
![]() |
94519c8250 | ||
![]() |
add8a1b1a7 | ||
![]() |
516e7656f3 | ||
![]() |
d7cdee37bf | ||
![]() |
5c5b8c517f | ||
![]() |
7dbd0c5e0a | ||
![]() |
ba35eb8f8c | ||
![]() |
163ad1f4c4 | ||
![]() |
0f0244224d | ||
![]() |
6eecadce60 | ||
![]() |
bc88cf5002 | ||
![]() |
571be9c0fe | ||
![]() |
5d925c7d3f | ||
![]() |
45c520341f | ||
![]() |
c6ffc06541 | ||
![]() |
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 |
5
.env
5
.env
@@ -1,5 +0,0 @@
|
|||||||
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_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"}'
|
|
8
.env.development
Normal file
8
.env.development
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
REACT_APP_BACKEND_V2_GET_URL=https://json-dev.excalidraw.com/api/v2/
|
||||||
|
REACT_APP_BACKEND_V2_POST_URL=https://json-dev.excalidraw.com/api/v2/post/
|
||||||
|
|
||||||
|
REACT_APP_LIBRARY_URL=https://libraries.excalidraw.com
|
||||||
|
REACT_APP_LIBRARY_BACKEND=https://us-central1-excalidraw-room-persistence.cloudfunctions.net/libraries
|
||||||
|
|
||||||
|
REACT_APP_SOCKET_SERVER_URL=http://localhost:3000
|
||||||
|
REACT_APP_FIREBASE_CONFIG='{"apiKey":"AIzaSyCMkxA60XIW8KbqMYL7edC4qT5l4qHX2h8","authDomain":"excalidraw-oss-dev.firebaseapp.com","projectId":"excalidraw-oss-dev","storageBucket":"excalidraw-oss-dev.appspot.com","messagingSenderId":"664559512677","appId":"1:664559512677:web:a385181f2928d328a7aa8c"}'
|
@@ -1 +1,11 @@
|
|||||||
|
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_LIBRARY_URL=https://libraries.excalidraw.com
|
||||||
|
REACT_APP_LIBRARY_BACKEND=https://us-central1-excalidraw-room-persistence.cloudfunctions.net/libraries
|
||||||
|
|
||||||
|
REACT_APP_SOCKET_SERVER_URL=https://oss-collab-us1.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"}'
|
||||||
|
|
||||||
|
# production-only vars
|
||||||
REACT_APP_GOOGLE_ANALYTICS_ID=UA-387204-13
|
REACT_APP_GOOGLE_ANALYTICS_ID=UA-387204-13
|
||||||
|
@@ -5,3 +5,4 @@ package-lock.json
|
|||||||
firebase/
|
firebase/
|
||||||
dist/
|
dist/
|
||||||
public/workbox
|
public/workbox
|
||||||
|
src/packages/excalidraw/types
|
||||||
|
@@ -1,6 +1,7 @@
|
|||||||
{
|
{
|
||||||
"extends": ["@excalidraw/eslint-config", "react-app"],
|
"extends": ["@excalidraw/eslint-config", "react-app"],
|
||||||
"rules": {
|
"rules": {
|
||||||
"import/no-anonymous-default-export": "off"
|
"import/no-anonymous-default-export": "off",
|
||||||
|
"no-restricted-globals": "off"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
3
.github/dependabot.yml
vendored
3
.github/dependabot.yml
vendored
@@ -10,6 +10,7 @@ updates:
|
|||||||
- lipis
|
- lipis
|
||||||
assignees:
|
assignees:
|
||||||
- lipis
|
- lipis
|
||||||
|
open-pull-requests-limit: 20
|
||||||
|
|
||||||
- package-ecosystem: npm
|
- package-ecosystem: npm
|
||||||
directory: /src/packages/excalidraw/
|
directory: /src/packages/excalidraw/
|
||||||
@@ -21,6 +22,7 @@ updates:
|
|||||||
- ad1992
|
- ad1992
|
||||||
assignees:
|
assignees:
|
||||||
- ad1992
|
- ad1992
|
||||||
|
open-pull-requests-limit: 20
|
||||||
|
|
||||||
- package-ecosystem: npm
|
- package-ecosystem: npm
|
||||||
directory: /src/packages/utils/
|
directory: /src/packages/utils/
|
||||||
@@ -32,3 +34,4 @@ updates:
|
|||||||
- ad1992
|
- ad1992
|
||||||
assignees:
|
assignees:
|
||||||
- ad1992
|
- ad1992
|
||||||
|
open-pull-requests-limit: 20
|
||||||
|
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
|
2
.github/workflows/publish-docker.yml
vendored
2
.github/workflows/publish-docker.yml
vendored
@@ -11,7 +11,7 @@ jobs:
|
|||||||
|
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v2
|
- uses: actions/checkout@v2
|
||||||
- uses: docker/build-push-action@v1
|
- uses: docker/build-push-action@v2
|
||||||
with:
|
with:
|
||||||
username: ${{ secrets.DOCKER_USERNAME }}
|
username: ${{ secrets.DOCKER_USERNAME }}
|
||||||
password: ${{ secrets.DOCKER_PASSWORD }}
|
password: ${{ secrets.DOCKER_PASSWORD }}
|
||||||
|
3
.gitignore
vendored
3
.gitignore
vendored
@@ -5,9 +5,11 @@
|
|||||||
.env.test.local
|
.env.test.local
|
||||||
.envrc
|
.envrc
|
||||||
.eslintcache
|
.eslintcache
|
||||||
|
.history
|
||||||
.idea
|
.idea
|
||||||
.vercel
|
.vercel
|
||||||
.vscode
|
.vscode
|
||||||
|
.yarn
|
||||||
*.log
|
*.log
|
||||||
*.tgz
|
*.tgz
|
||||||
build
|
build
|
||||||
@@ -20,3 +22,4 @@ package-lock.json
|
|||||||
static
|
static
|
||||||
yarn-debug.log*
|
yarn-debug.log*
|
||||||
yarn-error.log*
|
yarn-error.log*
|
||||||
|
src/packages/excalidraw/types
|
||||||
|
2
.husky/pre-commit
Executable file
2
.husky/pre-commit
Executable file
@@ -0,0 +1,2 @@
|
|||||||
|
#!/bin/sh
|
||||||
|
yarn lint-staged
|
@@ -10,7 +10,7 @@ ARG NODE_ENV=production
|
|||||||
COPY . .
|
COPY . .
|
||||||
RUN yarn build:app:docker
|
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
|
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.
|
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
|
## Shape libraries
|
||||||
|
|
||||||
Find a growing list of libraries containing assets for your drawings at [libraries.excalidraw.com](https://libraries.excalidraw.com).
|
Find a growing list of libraries containing assets for your drawings at [libraries.excalidraw.com](https://libraries.excalidraw.com).
|
||||||
@@ -93,7 +95,7 @@ These instructions will get you a copy of the project up and running on your loc
|
|||||||
#### Requirements
|
#### Requirements
|
||||||
|
|
||||||
- [Node.js](https://nodejs.org/en/)
|
- [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)
|
- [Git](https://git-scm.com/downloads)
|
||||||
|
|
||||||
#### Clone the repo
|
#### 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
|
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
|
#### Commands
|
||||||
|
|
||||||
| Command | Description |
|
| Command | Description |
|
||||||
|
@@ -2,5 +2,8 @@
|
|||||||
"firestore": {
|
"firestore": {
|
||||||
"rules": "firestore.rules",
|
"rules": "firestore.rules",
|
||||||
"indexes": "firestore.indexes.json"
|
"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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
69
package.json
69
package.json
@@ -19,60 +19,67 @@
|
|||||||
]
|
]
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@sentry/browser": "6.2.2",
|
"@sentry/browser": "6.2.5",
|
||||||
"@sentry/integrations": "6.2.1",
|
"@sentry/integrations": "6.2.5",
|
||||||
"@testing-library/jest-dom": "5.11.9",
|
"@testing-library/jest-dom": "5.15.1",
|
||||||
"@testing-library/react": "11.2.5",
|
"@testing-library/react": "12.1.2",
|
||||||
"@types/jest": "26.0.21",
|
"@tldraw/vec": "1.1.5",
|
||||||
"@types/react": "17.0.3",
|
"@types/jest": "27.0.3",
|
||||||
"@types/react-dom": "17.0.2",
|
"@types/pica": "5.1.3",
|
||||||
|
"@types/react": "17.0.37",
|
||||||
|
"@types/react-dom": "17.0.11",
|
||||||
"@types/socket.io-client": "1.4.36",
|
"@types/socket.io-client": "1.4.36",
|
||||||
"browser-fs-access": "0.15.3",
|
"browser-fs-access": "0.23.0",
|
||||||
"clsx": "1.1.1",
|
"clsx": "1.1.1",
|
||||||
"firebase": "8.2.10",
|
"fake-indexeddb": "3.1.7",
|
||||||
"i18next-browser-languagedetector": "6.0.1",
|
"firebase": "8.3.3",
|
||||||
|
"i18next-browser-languagedetector": "6.1.2",
|
||||||
|
"idb-keyval": "6.0.3",
|
||||||
|
"image-blob-reduce": "3.0.1",
|
||||||
"lodash.throttle": "4.1.1",
|
"lodash.throttle": "4.1.1",
|
||||||
"nanoid": "3.1.21",
|
"nanoid": "3.1.30",
|
||||||
"open-color": "1.8.0",
|
"open-color": "1.9.1",
|
||||||
"pako": "1.0.11",
|
"pako": "1.0.11",
|
||||||
|
"perfect-freehand": "1.0.16",
|
||||||
"png-chunk-text": "1.0.0",
|
"png-chunk-text": "1.0.0",
|
||||||
"png-chunks-encode": "1.0.0",
|
"png-chunks-encode": "1.0.0",
|
||||||
"png-chunks-extract": "1.0.0",
|
"png-chunks-extract": "1.0.0",
|
||||||
"points-on-curve": "0.2.0",
|
"points-on-curve": "0.2.0",
|
||||||
"pwacompat": "2.0.17",
|
"pwacompat": "2.0.17",
|
||||||
"react": "17.0.1",
|
"react": "17.0.2",
|
||||||
"react-dom": "17.0.1",
|
"react-dom": "17.0.2",
|
||||||
"react-scripts": "4.0.3",
|
"react-scripts": "4.0.3",
|
||||||
"roughjs": "4.3.1",
|
"roughjs": "4.5.2",
|
||||||
"sass": "1.32.8",
|
"sass": "1.43.5",
|
||||||
"socket.io-client": "2.3.1",
|
"socket.io-client": "2.3.1",
|
||||||
"typescript": "4.2.3"
|
"typescript": "4.5.2"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@excalidraw/eslint-config": "1.0.0",
|
"@excalidraw/eslint-config": "1.0.0",
|
||||||
"@excalidraw/prettier-config": "1.0.2",
|
"@excalidraw/prettier-config": "1.0.2",
|
||||||
|
"@types/chai": "4.2.22",
|
||||||
"@types/lodash.throttle": "4.1.6",
|
"@types/lodash.throttle": "4.1.6",
|
||||||
"@types/pako": "1.0.1",
|
"@types/pako": "1.0.2",
|
||||||
"@types/resize-observer-browser": "0.1.5",
|
"@types/resize-observer-browser": "0.1.6",
|
||||||
"eslint-config-prettier": "8.1.0",
|
"chai": "4.3.4",
|
||||||
|
"dotenv": "10.0.0",
|
||||||
|
"eslint-config-prettier": "8.3.0",
|
||||||
"eslint-plugin-prettier": "3.3.1",
|
"eslint-plugin-prettier": "3.3.1",
|
||||||
"firebase-tools": "9.6.1",
|
"firebase-tools": "9.23.0",
|
||||||
"husky": "4.3.8",
|
"husky": "7.0.4",
|
||||||
"jest-canvas-mock": "2.3.1",
|
"jest-canvas-mock": "2.3.1",
|
||||||
"lint-staged": "10.5.4",
|
"lint-staged": "12.1.2",
|
||||||
"pepjs": "0.5.3",
|
"pepjs": "0.5.3",
|
||||||
"prettier": "2.2.1",
|
"prettier": "2.5.0",
|
||||||
"rewire": "5.0.0"
|
"rewire": "5.0.0"
|
||||||
},
|
},
|
||||||
|
"resolutions": {
|
||||||
|
"@typescript-eslint/typescript-estree": "5.3.0"
|
||||||
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=14.0.0"
|
"node": ">=14.0.0"
|
||||||
},
|
},
|
||||||
"homepage": ".",
|
"homepage": ".",
|
||||||
"husky": {
|
|
||||||
"hooks": {
|
|
||||||
"pre-commit": "lint-staged"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"jest": {
|
"jest": {
|
||||||
"transformIgnorePatterns": [
|
"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|browser-fs-access)/)"
|
||||||
@@ -94,6 +101,7 @@
|
|||||||
"fix": "yarn fix:other && yarn fix:code",
|
"fix": "yarn fix:other && yarn fix:code",
|
||||||
"locales-coverage": "node scripts/build-locales-coverage.js",
|
"locales-coverage": "node scripts/build-locales-coverage.js",
|
||||||
"locales-coverage:description": "node scripts/locales-coverage-description.js",
|
"locales-coverage:description": "node scripts/locales-coverage-description.js",
|
||||||
|
"prepare": "husky install",
|
||||||
"prettier": "prettier \"**/*.{css,scss,json,md,html,yml}\" --ignore-path=.eslintignore",
|
"prettier": "prettier \"**/*.{css,scss,json,md,html,yml}\" --ignore-path=.eslintignore",
|
||||||
"start": "react-scripts start",
|
"start": "react-scripts start",
|
||||||
"test:all": "yarn test:typecheck && yarn test:code && yarn test:other && yarn test:app --watchAll=false",
|
"test:all": "yarn test:typecheck && yarn test:code && yarn test:other && yarn test:app --watchAll=false",
|
||||||
@@ -103,6 +111,7 @@
|
|||||||
"test:other": "yarn prettier --list-different",
|
"test:other": "yarn prettier --list-different",
|
||||||
"test:typecheck": "tsc",
|
"test:typecheck": "tsc",
|
||||||
"test:update": "yarn test:app --updateSnapshot --watchAll=false",
|
"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.
@@ -51,8 +51,7 @@
|
|||||||
name="twitter:description"
|
name="twitter:description"
|
||||||
content="Excalidraw is a whiteboard tool that lets you easily sketch diagrams that have a hand-drawn feel to them."
|
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" />
|
<link rel="shortcut icon" href="favicon.ico" type="image/x-icon" />
|
||||||
|
|
||||||
<!-- Excalidraw version -->
|
<!-- Excalidraw version -->
|
||||||
@@ -108,15 +107,17 @@
|
|||||||
|
|
||||||
<!-- FIXME: remove this when we update CRA (fix SW caching) -->
|
<!-- FIXME: remove this when we update CRA (fix SW caching) -->
|
||||||
<style>
|
<style>
|
||||||
body {
|
body,
|
||||||
|
html {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
--ui-font: system-ui, BlinkMacSystemFont, -apple-system, Segoe UI,
|
--ui-font: system-ui, BlinkMacSystemFont, -apple-system, Segoe UI,
|
||||||
Roboto, Helvetica, Arial, sans-serif;
|
Roboto, Helvetica, Arial, sans-serif;
|
||||||
font-family: var(--ui-font);
|
font-family: var(--ui-font);
|
||||||
-webkit-text-size-adjust: 100%;
|
-webkit-text-size-adjust: 100%;
|
||||||
|
|
||||||
width: 100vw;
|
width: 100%;
|
||||||
height: 100vh;
|
height: 100%;
|
||||||
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
.visually-hidden {
|
.visually-hidden {
|
||||||
@@ -126,6 +127,7 @@
|
|||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
clip: rect(1px, 1px, 1px, 1px);
|
clip: rect(1px, 1px, 1px, 1px);
|
||||||
white-space: nowrap; /* added line */
|
white-space: nowrap; /* added line */
|
||||||
|
user-select: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.LoadingMessage {
|
.LoadingMessage {
|
||||||
@@ -148,6 +150,24 @@
|
|||||||
color: var(--popup-text-color);
|
color: var(--popup-text-color);
|
||||||
font-size: 1.3em;
|
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>
|
</style>
|
||||||
</head>
|
</head>
|
||||||
|
|
||||||
|
@@ -26,7 +26,6 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"capture_links": "new_client",
|
|
||||||
"share_target": {
|
"share_target": {
|
||||||
"action": "/web-share-target",
|
"action": "/web-share-target",
|
||||||
"method": "POST",
|
"method": "POST",
|
||||||
@@ -39,5 +38,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 |
52
scripts/autorelease.js
Normal file
52
scripts/autorelease.js
Normal file
@@ -0,0 +1,52 @@
|
|||||||
|
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 (error) {
|
||||||
|
console.error(error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 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 || file.indexOf("package.json")) >= 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();
|
||||||
|
});
|
@@ -1,11 +1,16 @@
|
|||||||
const { readdirSync, writeFileSync } = require("fs");
|
const { readdirSync, writeFileSync } = require("fs");
|
||||||
const files = readdirSync(`${__dirname}/../src/locales`);
|
const files = readdirSync(`${__dirname}/../src/locales`);
|
||||||
|
|
||||||
const flatten = (object) =>
|
const flatten = (object = {}, result = {}, extraKey = "") => {
|
||||||
Object.keys(object).reduce(
|
for (const key in object) {
|
||||||
(initial, current) => ({ ...initial, ...object[current] }),
|
if (typeof object[key] !== "object") {
|
||||||
{},
|
result[extraKey + key] = object[key];
|
||||||
);
|
} else {
|
||||||
|
flatten(object[key], result, `${extraKey}${key}.`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
};
|
||||||
|
|
||||||
const locales = files.filter(
|
const locales = files.filter(
|
||||||
(file) => file !== "README.md" && file !== "percentages.json",
|
(file) => file !== "README.md" && file !== "percentages.json",
|
||||||
@@ -19,10 +24,8 @@ for (let index = 0; index < locales.length; index++) {
|
|||||||
|
|
||||||
const allKeys = Object.keys(data);
|
const allKeys = Object.keys(data);
|
||||||
const translatedKeys = allKeys.filter((item) => data[item] !== "");
|
const translatedKeys = allKeys.filter((item) => data[item] !== "");
|
||||||
|
const percentage = Math.floor((100 * translatedKeys.length) / allKeys.length);
|
||||||
const percentage = (100 * translatedKeys.length) / allKeys.length;
|
percentages[currentLocale.replace(".json", "")] = percentage;
|
||||||
|
|
||||||
percentages[currentLocale.replace(".json", "")] = parseInt(percentage);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
writeFileSync(
|
writeFileSync(
|
||||||
|
@@ -5,7 +5,9 @@ const THRESSHOLD = 85;
|
|||||||
const crowdinMap = {
|
const crowdinMap = {
|
||||||
"ar-SA": "en-ar",
|
"ar-SA": "en-ar",
|
||||||
"bg-BG": "en-bg",
|
"bg-BG": "en-bg",
|
||||||
|
"bn-BD": "en-bn",
|
||||||
"ca-ES": "en-ca",
|
"ca-ES": "en-ca",
|
||||||
|
"da-DK": "en-da",
|
||||||
"de-DE": "en-de",
|
"de-DE": "en-de",
|
||||||
"el-GR": "en-el",
|
"el-GR": "en-el",
|
||||||
"es-ES": "en-es",
|
"es-ES": "en-es",
|
||||||
@@ -31,18 +33,27 @@ const crowdinMap = {
|
|||||||
"pt-PT": "en-pt",
|
"pt-PT": "en-pt",
|
||||||
"ro-RO": "en-ro",
|
"ro-RO": "en-ro",
|
||||||
"ru-RU": "en-ru",
|
"ru-RU": "en-ru",
|
||||||
|
"si-LK": "en-silk",
|
||||||
"sk-SK": "en-sk",
|
"sk-SK": "en-sk",
|
||||||
"sv-SE": "en-sv",
|
"sv-SE": "en-sv",
|
||||||
|
"ta-IN": "en-ta",
|
||||||
"tr-TR": "en-tr",
|
"tr-TR": "en-tr",
|
||||||
"uk-UA": "en-uk",
|
"uk-UA": "en-uk",
|
||||||
"zh-CN": "en-zhcn",
|
"zh-CN": "en-zhcn",
|
||||||
|
"zh-HK": "en-zhhk",
|
||||||
"zh-TW": "en-zhtw",
|
"zh-TW": "en-zhtw",
|
||||||
|
"lv-LV": "en-lv",
|
||||||
|
"cs-CZ": "en-cs",
|
||||||
|
"kk-KZ": "en-kk",
|
||||||
};
|
};
|
||||||
|
|
||||||
const flags = {
|
const flags = {
|
||||||
"ar-SA": "🇸🇦",
|
"ar-SA": "🇸🇦",
|
||||||
"bg-BG": "🇧🇬",
|
"bg-BG": "🇧🇬",
|
||||||
|
"bn-BD": "🇧🇩",
|
||||||
"ca-ES": "🏳",
|
"ca-ES": "🏳",
|
||||||
|
"cs-CZ": "🇨🇿",
|
||||||
|
"da-DK": "🇩🇰",
|
||||||
"de-DE": "🇩🇪",
|
"de-DE": "🇩🇪",
|
||||||
"el-GR": "🇬🇷",
|
"el-GR": "🇬🇷",
|
||||||
"es-ES": "🇪🇸",
|
"es-ES": "🇪🇸",
|
||||||
@@ -56,7 +67,9 @@ const flags = {
|
|||||||
"it-IT": "🇮🇹",
|
"it-IT": "🇮🇹",
|
||||||
"ja-JP": "🇯🇵",
|
"ja-JP": "🇯🇵",
|
||||||
"kab-KAB": "🏳",
|
"kab-KAB": "🏳",
|
||||||
|
"kk-KZ": "🇰🇿",
|
||||||
"ko-KR": "🇰🇷",
|
"ko-KR": "🇰🇷",
|
||||||
|
"lv-LV": "🇱🇻",
|
||||||
"my-MM": "🇲🇲",
|
"my-MM": "🇲🇲",
|
||||||
"nb-NO": "🇳🇴",
|
"nb-NO": "🇳🇴",
|
||||||
"nl-NL": "🇳🇱",
|
"nl-NL": "🇳🇱",
|
||||||
@@ -68,18 +81,24 @@ const flags = {
|
|||||||
"pt-PT": "🇵🇹",
|
"pt-PT": "🇵🇹",
|
||||||
"ro-RO": "🇷🇴",
|
"ro-RO": "🇷🇴",
|
||||||
"ru-RU": "🇷🇺",
|
"ru-RU": "🇷🇺",
|
||||||
|
"si-LK": "🇱🇰",
|
||||||
"sk-SK": "🇸🇰",
|
"sk-SK": "🇸🇰",
|
||||||
"sv-SE": "🇸🇪",
|
"sv-SE": "🇸🇪",
|
||||||
|
"ta-IN": "🇮🇳",
|
||||||
"tr-TR": "🇹🇷",
|
"tr-TR": "🇹🇷",
|
||||||
"uk-UA": "🇺🇦",
|
"uk-UA": "🇺🇦",
|
||||||
"zh-CN": "🇨🇳",
|
"zh-CN": "🇨🇳",
|
||||||
|
"zh-HK": "🇭🇰",
|
||||||
"zh-TW": "🇹🇼",
|
"zh-TW": "🇹🇼",
|
||||||
};
|
};
|
||||||
|
|
||||||
const languages = {
|
const languages = {
|
||||||
"ar-SA": "العربية",
|
"ar-SA": "العربية",
|
||||||
"bg-BG": "Български",
|
"bg-BG": "Български",
|
||||||
|
"bn-BD": "Bengali",
|
||||||
"ca-ES": "Català",
|
"ca-ES": "Català",
|
||||||
|
"cs-CZ": "Česky",
|
||||||
|
"da-DK": "Dansk",
|
||||||
"de-DE": "Deutsch",
|
"de-DE": "Deutsch",
|
||||||
"el-GR": "Ελληνικά",
|
"el-GR": "Ελληνικά",
|
||||||
"es-ES": "Español",
|
"es-ES": "Español",
|
||||||
@@ -93,7 +112,9 @@ const languages = {
|
|||||||
"it-IT": "Italiano",
|
"it-IT": "Italiano",
|
||||||
"ja-JP": "日本語",
|
"ja-JP": "日本語",
|
||||||
"kab-KAB": "Taqbaylit",
|
"kab-KAB": "Taqbaylit",
|
||||||
|
"kk-KZ": "Қазақ тілі",
|
||||||
"ko-KR": "한국어",
|
"ko-KR": "한국어",
|
||||||
|
"lv-LV": "Latviešu",
|
||||||
"my-MM": "Burmese",
|
"my-MM": "Burmese",
|
||||||
"nb-NO": "Norsk bokmål",
|
"nb-NO": "Norsk bokmål",
|
||||||
"nl-NL": "Nederlands",
|
"nl-NL": "Nederlands",
|
||||||
@@ -105,11 +126,14 @@ const languages = {
|
|||||||
"pt-PT": "Português",
|
"pt-PT": "Português",
|
||||||
"ro-RO": "Română",
|
"ro-RO": "Română",
|
||||||
"ru-RU": "Русский",
|
"ru-RU": "Русский",
|
||||||
|
"si-LK": "සිංහල",
|
||||||
"sk-SK": "Slovenčina",
|
"sk-SK": "Slovenčina",
|
||||||
"sv-SE": "Svenska",
|
"sv-SE": "Svenska",
|
||||||
|
"ta-IN": "Tamil",
|
||||||
"tr-TR": "Türkçe",
|
"tr-TR": "Türkçe",
|
||||||
"uk-UA": "Українська",
|
"uk-UA": "Українська",
|
||||||
"zh-CN": "简体中文",
|
"zh-CN": "简体中文",
|
||||||
|
"zh-HK": "繁體中文 (香港)",
|
||||||
"zh-TW": "繁體中文",
|
"zh-TW": "繁體中文",
|
||||||
};
|
};
|
||||||
|
|
||||||
|
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 (error) {
|
||||||
|
console.error(error);
|
||||||
|
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 (error) {
|
||||||
|
console.error(error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
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,20 +2,58 @@ import { register } from "./register";
|
|||||||
import { getSelectedElements } from "../scene";
|
import { getSelectedElements } from "../scene";
|
||||||
import { getNonDeletedElements } from "../element";
|
import { getNonDeletedElements } from "../element";
|
||||||
import { deepCopyElement } from "../element/newElement";
|
import { deepCopyElement } from "../element/newElement";
|
||||||
import { Library } from "../data/library";
|
import { randomId } from "../random";
|
||||||
|
import { t } from "../i18n";
|
||||||
|
|
||||||
export const actionAddToLibrary = register({
|
export const actionAddToLibrary = register({
|
||||||
name: "addToLibrary",
|
name: "addToLibrary",
|
||||||
perform: (elements, appState) => {
|
perform: (elements, appState, _, app) => {
|
||||||
const selectedElements = getSelectedElements(
|
const selectedElements = getSelectedElements(
|
||||||
getNonDeletedElements(elements),
|
getNonDeletedElements(elements),
|
||||||
appState,
|
appState,
|
||||||
|
true,
|
||||||
);
|
);
|
||||||
|
if (selectedElements.some((element) => element.type === "image")) {
|
||||||
|
return {
|
||||||
|
commitToHistory: false,
|
||||||
|
appState: {
|
||||||
|
...appState,
|
||||||
|
errorMessage: "Support for adding images to the library coming soon!",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
Library.loadLibrary().then((items) => {
|
return app.library
|
||||||
Library.saveLibrary([...items, selectedElements.map(deepCopyElement)]);
|
.loadLibrary()
|
||||||
});
|
.then((items) => {
|
||||||
return false;
|
return app.library.saveLibrary([
|
||||||
|
{
|
||||||
|
id: randomId(),
|
||||||
|
status: "unpublished",
|
||||||
|
elements: selectedElements.map(deepCopyElement),
|
||||||
|
created: Date.now(),
|
||||||
|
},
|
||||||
|
...items,
|
||||||
|
]);
|
||||||
|
})
|
||||||
|
.then(() => {
|
||||||
|
return {
|
||||||
|
commitToHistory: false,
|
||||||
|
appState: {
|
||||||
|
...appState,
|
||||||
|
toastMessage: t("toast.addedToLibrary"),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
})
|
||||||
|
.catch((error) => {
|
||||||
|
return {
|
||||||
|
commitToHistory: false,
|
||||||
|
appState: {
|
||||||
|
...appState,
|
||||||
|
errorMessage: error.message,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
});
|
||||||
},
|
},
|
||||||
contextItemLabel: "labels.addToLibrary",
|
contextItemLabel: "labels.addToLibrary",
|
||||||
});
|
});
|
||||||
|
@@ -1,4 +1,3 @@
|
|||||||
import React from "react";
|
|
||||||
import { alignElements, Alignment } from "../align";
|
import { alignElements, Alignment } from "../align";
|
||||||
import {
|
import {
|
||||||
AlignBottomIcon,
|
AlignBottomIcon,
|
||||||
@@ -9,13 +8,13 @@ import {
|
|||||||
CenterVerticallyIcon,
|
CenterVerticallyIcon,
|
||||||
} from "../components/icons";
|
} from "../components/icons";
|
||||||
import { ToolButton } from "../components/ToolButton";
|
import { ToolButton } from "../components/ToolButton";
|
||||||
import { getElementMap, getNonDeletedElements } from "../element";
|
import { getNonDeletedElements } from "../element";
|
||||||
import { ExcalidrawElement } from "../element/types";
|
import { ExcalidrawElement } from "../element/types";
|
||||||
import { t } from "../i18n";
|
import { t } from "../i18n";
|
||||||
import { KEYS } from "../keys";
|
import { KEYS } from "../keys";
|
||||||
import { getSelectedElements, isSomeElementSelected } from "../scene";
|
import { getSelectedElements, isSomeElementSelected } from "../scene";
|
||||||
import { AppState } from "../types";
|
import { AppState } from "../types";
|
||||||
import { getShortcutKey } from "../utils";
|
import { arrayToMap, getShortcutKey } from "../utils";
|
||||||
import { register } from "./register";
|
import { register } from "./register";
|
||||||
|
|
||||||
const enableActionGroup = (
|
const enableActionGroup = (
|
||||||
@@ -35,9 +34,11 @@ const alignSelectedElements = (
|
|||||||
|
|
||||||
const updatedElements = alignElements(selectedElements, alignment);
|
const updatedElements = alignElements(selectedElements, alignment);
|
||||||
|
|
||||||
const updatedElementsMap = getElementMap(updatedElements);
|
const updatedElementsMap = arrayToMap(updatedElements);
|
||||||
|
|
||||||
return elements.map((element) => updatedElementsMap[element.id] || element);
|
return elements.map(
|
||||||
|
(element) => updatedElementsMap.get(element.id) || element,
|
||||||
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
export const actionAlignTop = register({
|
export const actionAlignTop = register({
|
||||||
|
@@ -1,14 +1,11 @@
|
|||||||
import React from "react";
|
|
||||||
import { getDefaultAppState } from "../appState";
|
|
||||||
import { ColorPicker } from "../components/ColorPicker";
|
import { ColorPicker } from "../components/ColorPicker";
|
||||||
import { resetZoom, trash, zoomIn, zoomOut } from "../components/icons";
|
import { zoomIn, zoomOut } from "../components/icons";
|
||||||
import { ToolButton } from "../components/ToolButton";
|
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 { getCommonBounds, getNonDeletedElements } from "../element";
|
||||||
import { newElementWith } from "../element/mutateElement";
|
|
||||||
import { ExcalidrawElement } from "../element/types";
|
import { ExcalidrawElement } from "../element/types";
|
||||||
import { t } from "../i18n";
|
import { t } from "../i18n";
|
||||||
import useIsMobile from "../is-mobile";
|
|
||||||
import { CODES, KEYS } from "../keys";
|
import { CODES, KEYS } from "../keys";
|
||||||
import { getNormalizedZoom, getSelectedElements } from "../scene";
|
import { getNormalizedZoom, getSelectedElements } from "../scene";
|
||||||
import { centerScrollOn } from "../scene/scroll";
|
import { centerScrollOn } from "../scene/scroll";
|
||||||
@@ -16,13 +13,17 @@ import { getNewZoom } from "../scene/zoom";
|
|||||||
import { AppState, NormalizedZoomValue } from "../types";
|
import { AppState, NormalizedZoomValue } from "../types";
|
||||||
import { getShortcutKey } from "../utils";
|
import { getShortcutKey } from "../utils";
|
||||||
import { register } from "./register";
|
import { register } from "./register";
|
||||||
|
import { Tooltip } from "../components/Tooltip";
|
||||||
|
import { newElementWith } from "../element/mutateElement";
|
||||||
|
import { getDefaultAppState } from "../appState";
|
||||||
|
import ClearCanvas from "../components/ClearCanvas";
|
||||||
|
|
||||||
export const actionChangeViewBackgroundColor = register({
|
export const actionChangeViewBackgroundColor = register({
|
||||||
name: "changeViewBackgroundColor",
|
name: "changeViewBackgroundColor",
|
||||||
perform: (_, appState, value) => {
|
perform: (_, appState, value) => {
|
||||||
return {
|
return {
|
||||||
appState: { ...appState, viewBackgroundColor: value },
|
appState: { ...appState, ...value },
|
||||||
commitToHistory: true,
|
commitToHistory: !!value.viewBackgroundColor,
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
PanelComponent: ({ appState, updateData }) => {
|
PanelComponent: ({ appState, updateData }) => {
|
||||||
@@ -32,7 +33,12 @@ export const actionChangeViewBackgroundColor = register({
|
|||||||
label={t("labels.canvasBackground")}
|
label={t("labels.canvasBackground")}
|
||||||
type="canvasBackground"
|
type="canvasBackground"
|
||||||
color={appState.viewBackgroundColor}
|
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>
|
</div>
|
||||||
);
|
);
|
||||||
@@ -41,39 +47,28 @@ export const actionChangeViewBackgroundColor = register({
|
|||||||
|
|
||||||
export const actionClearCanvas = register({
|
export const actionClearCanvas = register({
|
||||||
name: "clearCanvas",
|
name: "clearCanvas",
|
||||||
perform: (elements, appState: AppState) => {
|
perform: (elements, appState, _, app) => {
|
||||||
|
app.imageCache.clear();
|
||||||
return {
|
return {
|
||||||
elements: elements.map((element) =>
|
elements: elements.map((element) =>
|
||||||
newElementWith(element, { isDeleted: true }),
|
newElementWith(element, { isDeleted: true }),
|
||||||
),
|
),
|
||||||
appState: {
|
appState: {
|
||||||
...getDefaultAppState(),
|
...getDefaultAppState(),
|
||||||
|
files: {},
|
||||||
theme: appState.theme,
|
theme: appState.theme,
|
||||||
elementLocked: appState.elementLocked,
|
elementLocked: appState.elementLocked,
|
||||||
exportBackground: appState.exportBackground,
|
exportBackground: appState.exportBackground,
|
||||||
exportEmbedScene: appState.exportEmbedScene,
|
exportEmbedScene: appState.exportEmbedScene,
|
||||||
gridSize: appState.gridSize,
|
gridSize: appState.gridSize,
|
||||||
shouldAddWatermark: appState.shouldAddWatermark,
|
|
||||||
showStats: appState.showStats,
|
showStats: appState.showStats,
|
||||||
pasteDialog: appState.pasteDialog,
|
pasteDialog: appState.pasteDialog,
|
||||||
},
|
},
|
||||||
commitToHistory: true,
|
commitToHistory: true,
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
PanelComponent: ({ updateData }) => (
|
|
||||||
<ToolButton
|
PanelComponent: ({ updateData }) => <ClearCanvas onConfirm={updateData} />,
|
||||||
type="button"
|
|
||||||
icon={trash}
|
|
||||||
title={t("buttons.clearReset")}
|
|
||||||
aria-label={t("buttons.clearReset")}
|
|
||||||
showAriaLabel={useIsMobile()}
|
|
||||||
onClick={() => {
|
|
||||||
if (window.confirm(t("alerts.clearReset"))) {
|
|
||||||
updateData(null);
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
),
|
|
||||||
});
|
});
|
||||||
|
|
||||||
export const actionZoomIn = register({
|
export const actionZoomIn = register({
|
||||||
@@ -102,6 +97,7 @@ export const actionZoomIn = register({
|
|||||||
onClick={() => {
|
onClick={() => {
|
||||||
updateData(null);
|
updateData(null);
|
||||||
}}
|
}}
|
||||||
|
size="small"
|
||||||
/>
|
/>
|
||||||
),
|
),
|
||||||
keyTest: (event) =>
|
keyTest: (event) =>
|
||||||
@@ -136,6 +132,7 @@ export const actionZoomOut = register({
|
|||||||
onClick={() => {
|
onClick={() => {
|
||||||
updateData(null);
|
updateData(null);
|
||||||
}}
|
}}
|
||||||
|
size="small"
|
||||||
/>
|
/>
|
||||||
),
|
),
|
||||||
keyTest: (event) =>
|
keyTest: (event) =>
|
||||||
@@ -162,16 +159,21 @@ export const actionResetZoom = register({
|
|||||||
commitToHistory: false,
|
commitToHistory: false,
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
PanelComponent: ({ updateData }) => (
|
PanelComponent: ({ updateData, appState }) => (
|
||||||
<ToolButton
|
<Tooltip label={t("buttons.resetZoom")} style={{ height: "100%" }}>
|
||||||
type="button"
|
<ToolButton
|
||||||
icon={resetZoom}
|
type="button"
|
||||||
title={t("buttons.resetZoom")}
|
className="reset-zoom-button"
|
||||||
aria-label={t("buttons.resetZoom")}
|
title={t("buttons.resetZoom")}
|
||||||
onClick={() => {
|
aria-label={t("buttons.resetZoom")}
|
||||||
updateData(null);
|
onClick={() => {
|
||||||
}}
|
updateData(null);
|
||||||
/>
|
}}
|
||||||
|
size="small"
|
||||||
|
>
|
||||||
|
{(appState.zoom.value * 100).toFixed(0)}%
|
||||||
|
</ToolButton>
|
||||||
|
</Tooltip>
|
||||||
),
|
),
|
||||||
keyTest: (event) =>
|
keyTest: (event) =>
|
||||||
(event.code === CODES.ZERO || event.code === CODES.NUM_ZERO) &&
|
(event.code === CODES.ZERO || event.code === CODES.NUM_ZERO) &&
|
||||||
@@ -258,3 +260,28 @@ export const actionZoomToFit = register({
|
|||||||
!event.altKey &&
|
!event.altKey &&
|
||||||
!event[KEYS.CTRL_OR_CMD],
|
!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,
|
||||||
|
});
|
||||||
|
@@ -9,8 +9,8 @@ import { t } from "../i18n";
|
|||||||
|
|
||||||
export const actionCopy = register({
|
export const actionCopy = register({
|
||||||
name: "copy",
|
name: "copy",
|
||||||
perform: (elements, appState) => {
|
perform: (elements, appState, _, app) => {
|
||||||
copyToClipboard(getNonDeletedElements(elements), appState);
|
copyToClipboard(getNonDeletedElements(elements), appState, app.files);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
commitToHistory: false,
|
commitToHistory: false,
|
||||||
@@ -42,6 +42,7 @@ export const actionCopyAsSvg = register({
|
|||||||
const selectedElements = getSelectedElements(
|
const selectedElements = getSelectedElements(
|
||||||
getNonDeletedElements(elements),
|
getNonDeletedElements(elements),
|
||||||
appState,
|
appState,
|
||||||
|
true,
|
||||||
);
|
);
|
||||||
try {
|
try {
|
||||||
await exportCanvas(
|
await exportCanvas(
|
||||||
@@ -50,13 +51,13 @@ export const actionCopyAsSvg = register({
|
|||||||
? selectedElements
|
? selectedElements
|
||||||
: getNonDeletedElements(elements),
|
: getNonDeletedElements(elements),
|
||||||
appState,
|
appState,
|
||||||
app.canvas,
|
app.files,
|
||||||
appState,
|
appState,
|
||||||
);
|
);
|
||||||
return {
|
return {
|
||||||
commitToHistory: false,
|
commitToHistory: false,
|
||||||
};
|
};
|
||||||
} catch (error) {
|
} catch (error: any) {
|
||||||
console.error(error);
|
console.error(error);
|
||||||
return {
|
return {
|
||||||
appState: {
|
appState: {
|
||||||
@@ -81,6 +82,7 @@ export const actionCopyAsPng = register({
|
|||||||
const selectedElements = getSelectedElements(
|
const selectedElements = getSelectedElements(
|
||||||
getNonDeletedElements(elements),
|
getNonDeletedElements(elements),
|
||||||
appState,
|
appState,
|
||||||
|
true,
|
||||||
);
|
);
|
||||||
try {
|
try {
|
||||||
await exportCanvas(
|
await exportCanvas(
|
||||||
@@ -89,7 +91,7 @@ export const actionCopyAsPng = register({
|
|||||||
? selectedElements
|
? selectedElements
|
||||||
: getNonDeletedElements(elements),
|
: getNonDeletedElements(elements),
|
||||||
appState,
|
appState,
|
||||||
app.canvas,
|
app.files,
|
||||||
appState,
|
appState,
|
||||||
);
|
);
|
||||||
return {
|
return {
|
||||||
@@ -106,7 +108,7 @@ export const actionCopyAsPng = register({
|
|||||||
},
|
},
|
||||||
commitToHistory: false,
|
commitToHistory: false,
|
||||||
};
|
};
|
||||||
} catch (error) {
|
} catch (error: any) {
|
||||||
console.error(error);
|
console.error(error);
|
||||||
return {
|
return {
|
||||||
appState: {
|
appState: {
|
||||||
|
@@ -1,7 +1,6 @@
|
|||||||
import { isSomeElementSelected } from "../scene";
|
import { isSomeElementSelected } from "../scene";
|
||||||
import { KEYS } from "../keys";
|
import { KEYS } from "../keys";
|
||||||
import { ToolButton } from "../components/ToolButton";
|
import { ToolButton } from "../components/ToolButton";
|
||||||
import React from "react";
|
|
||||||
import { trash } from "../components/icons";
|
import { trash } from "../components/icons";
|
||||||
import { t } from "../i18n";
|
import { t } from "../i18n";
|
||||||
import { register } from "./register";
|
import { register } from "./register";
|
||||||
@@ -12,6 +11,7 @@ import { newElementWith } from "../element/mutateElement";
|
|||||||
import { getElementsInGroup } from "../groups";
|
import { getElementsInGroup } from "../groups";
|
||||||
import { LinearElementEditor } from "../element/linearElementEditor";
|
import { LinearElementEditor } from "../element/linearElementEditor";
|
||||||
import { fixBindingsAfterDeletion } from "../element/binding";
|
import { fixBindingsAfterDeletion } from "../element/binding";
|
||||||
|
import { isBoundToContainer } from "../element/typeChecks";
|
||||||
|
|
||||||
const deleteSelectedElements = (
|
const deleteSelectedElements = (
|
||||||
elements: readonly ExcalidrawElement[],
|
elements: readonly ExcalidrawElement[],
|
||||||
@@ -22,6 +22,12 @@ const deleteSelectedElements = (
|
|||||||
if (appState.selectedElementIds[el.id]) {
|
if (appState.selectedElementIds[el.id]) {
|
||||||
return newElementWith(el, { isDeleted: true });
|
return newElementWith(el, { isDeleted: true });
|
||||||
}
|
}
|
||||||
|
if (
|
||||||
|
isBoundToContainer(el) &&
|
||||||
|
appState.selectedElementIds[el.containerId]
|
||||||
|
) {
|
||||||
|
return newElementWith(el, { isDeleted: true });
|
||||||
|
}
|
||||||
return el;
|
return el;
|
||||||
}),
|
}),
|
||||||
appState: {
|
appState: {
|
||||||
@@ -56,7 +62,7 @@ export const actionDeleteSelected = register({
|
|||||||
if (appState.editingLinearElement) {
|
if (appState.editingLinearElement) {
|
||||||
const {
|
const {
|
||||||
elementId,
|
elementId,
|
||||||
activePointIndex,
|
selectedPointsIndices,
|
||||||
startBindingElement,
|
startBindingElement,
|
||||||
endBindingElement,
|
endBindingElement,
|
||||||
} = appState.editingLinearElement;
|
} = appState.editingLinearElement;
|
||||||
@@ -66,8 +72,7 @@ export const actionDeleteSelected = register({
|
|||||||
}
|
}
|
||||||
if (
|
if (
|
||||||
// case: no point selected → delete whole element
|
// case: no point selected → delete whole element
|
||||||
activePointIndex == null ||
|
selectedPointsIndices == null ||
|
||||||
activePointIndex === -1 ||
|
|
||||||
// case: deleting last remaining point
|
// case: deleting last remaining point
|
||||||
element.points.length < 2
|
element.points.length < 2
|
||||||
) {
|
) {
|
||||||
@@ -87,15 +92,17 @@ export const actionDeleteSelected = register({
|
|||||||
// We cannot do this inside `movePoint` because it is also called
|
// We cannot do this inside `movePoint` because it is also called
|
||||||
// when deleting the uncommitted point (which hasn't caused any binding)
|
// when deleting the uncommitted point (which hasn't caused any binding)
|
||||||
const binding = {
|
const binding = {
|
||||||
startBindingElement:
|
startBindingElement: selectedPointsIndices?.includes(0)
|
||||||
activePointIndex === 0 ? null : startBindingElement,
|
? null
|
||||||
endBindingElement:
|
: startBindingElement,
|
||||||
activePointIndex === element.points.length - 1
|
endBindingElement: selectedPointsIndices?.includes(
|
||||||
? null
|
element.points.length - 1,
|
||||||
: endBindingElement,
|
)
|
||||||
|
? null
|
||||||
|
: endBindingElement,
|
||||||
};
|
};
|
||||||
|
|
||||||
LinearElementEditor.movePoint(element, activePointIndex, "delete");
|
LinearElementEditor.deletePoints(element, selectedPointsIndices);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
elements,
|
elements,
|
||||||
@@ -104,17 +111,17 @@ export const actionDeleteSelected = register({
|
|||||||
editingLinearElement: {
|
editingLinearElement: {
|
||||||
...appState.editingLinearElement,
|
...appState.editingLinearElement,
|
||||||
...binding,
|
...binding,
|
||||||
activePointIndex: activePointIndex > 0 ? activePointIndex - 1 : 0,
|
selectedPointsIndices:
|
||||||
|
selectedPointsIndices?.[0] > 0
|
||||||
|
? [selectedPointsIndices[0] - 1]
|
||||||
|
: [0],
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
commitToHistory: true,
|
commitToHistory: true,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
let { elements: nextElements, appState: nextAppState } =
|
||||||
let {
|
deleteSelectedElements(elements, appState);
|
||||||
elements: nextElements,
|
|
||||||
appState: nextAppState,
|
|
||||||
} = deleteSelectedElements(elements, appState);
|
|
||||||
fixBindingsAfterDeletion(
|
fixBindingsAfterDeletion(
|
||||||
nextElements,
|
nextElements,
|
||||||
elements.filter(({ id }) => appState.selectedElementIds[id]),
|
elements.filter(({ id }) => appState.selectedElementIds[id]),
|
||||||
|
@@ -1,17 +1,16 @@
|
|||||||
import React from "react";
|
|
||||||
import {
|
import {
|
||||||
DistributeHorizontallyIcon,
|
DistributeHorizontallyIcon,
|
||||||
DistributeVerticallyIcon,
|
DistributeVerticallyIcon,
|
||||||
} from "../components/icons";
|
} from "../components/icons";
|
||||||
import { ToolButton } from "../components/ToolButton";
|
import { ToolButton } from "../components/ToolButton";
|
||||||
import { distributeElements, Distribution } from "../disitrubte";
|
import { distributeElements, Distribution } from "../disitrubte";
|
||||||
import { getElementMap, getNonDeletedElements } from "../element";
|
import { getNonDeletedElements } from "../element";
|
||||||
import { ExcalidrawElement } from "../element/types";
|
import { ExcalidrawElement } from "../element/types";
|
||||||
import { t } from "../i18n";
|
import { t } from "../i18n";
|
||||||
import { CODES } from "../keys";
|
import { CODES } from "../keys";
|
||||||
import { getSelectedElements, isSomeElementSelected } from "../scene";
|
import { getSelectedElements, isSomeElementSelected } from "../scene";
|
||||||
import { AppState } from "../types";
|
import { AppState } from "../types";
|
||||||
import { getShortcutKey } from "../utils";
|
import { arrayToMap, getShortcutKey } from "../utils";
|
||||||
import { register } from "./register";
|
import { register } from "./register";
|
||||||
|
|
||||||
const enableActionGroup = (
|
const enableActionGroup = (
|
||||||
@@ -31,9 +30,11 @@ const distributeSelectedElements = (
|
|||||||
|
|
||||||
const updatedElements = distributeElements(selectedElements, distribution);
|
const updatedElements = distributeElements(selectedElements, distribution);
|
||||||
|
|
||||||
const updatedElementsMap = getElementMap(updatedElements);
|
const updatedElementsMap = arrayToMap(updatedElements);
|
||||||
|
|
||||||
return elements.map((element) => updatedElementsMap[element.id] || element);
|
return elements.map(
|
||||||
|
(element) => updatedElementsMap.get(element.id) || element,
|
||||||
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
export const distributeHorizontally = register({
|
export const distributeHorizontally = register({
|
||||||
|
@@ -1,15 +1,13 @@
|
|||||||
import React from "react";
|
|
||||||
import { KEYS } from "../keys";
|
import { KEYS } from "../keys";
|
||||||
import { register } from "./register";
|
import { register } from "./register";
|
||||||
import { ExcalidrawElement } from "../element/types";
|
import { ExcalidrawElement } from "../element/types";
|
||||||
import { duplicateElement, getNonDeletedElements } from "../element";
|
import { duplicateElement, getNonDeletedElements } from "../element";
|
||||||
import { isSomeElementSelected } from "../scene";
|
import { getSelectedElements, isSomeElementSelected } from "../scene";
|
||||||
import { ToolButton } from "../components/ToolButton";
|
import { ToolButton } from "../components/ToolButton";
|
||||||
import { clone } from "../components/icons";
|
import { clone } from "../components/icons";
|
||||||
import { t } from "../i18n";
|
import { t } from "../i18n";
|
||||||
import { getShortcutKey } from "../utils";
|
import { arrayToMap, getShortcutKey } from "../utils";
|
||||||
import { LinearElementEditor } from "../element/linearElementEditor";
|
import { LinearElementEditor } from "../element/linearElementEditor";
|
||||||
import { mutateElement } from "../element/mutateElement";
|
|
||||||
import {
|
import {
|
||||||
selectGroupsForSelectedElements,
|
selectGroupsForSelectedElements,
|
||||||
getSelectedGroupForElement,
|
getSelectedGroupForElement,
|
||||||
@@ -19,41 +17,23 @@ import { AppState } from "../types";
|
|||||||
import { fixBindingsAfterDuplication } from "../element/binding";
|
import { fixBindingsAfterDuplication } from "../element/binding";
|
||||||
import { ActionResult } from "./types";
|
import { ActionResult } from "./types";
|
||||||
import { GRID_SIZE } from "../constants";
|
import { GRID_SIZE } from "../constants";
|
||||||
|
import { bindTextToShapeAfterDuplication } from "../element/textElement";
|
||||||
|
import { isBoundToContainer } from "../element/typeChecks";
|
||||||
|
|
||||||
export const actionDuplicateSelection = register({
|
export const actionDuplicateSelection = register({
|
||||||
name: "duplicateSelection",
|
name: "duplicateSelection",
|
||||||
perform: (elements, appState) => {
|
perform: (elements, appState) => {
|
||||||
// duplicate point if selected while editing multi-point element
|
// duplicate selected point(s) if editing a line
|
||||||
if (appState.editingLinearElement) {
|
if (appState.editingLinearElement) {
|
||||||
const { activePointIndex, elementId } = appState.editingLinearElement;
|
const ret = LinearElementEditor.duplicateSelectedPoints(appState);
|
||||||
const element = LinearElementEditor.getElement(elementId);
|
|
||||||
if (!element || activePointIndex === null) {
|
if (!ret) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
const { points } = element;
|
|
||||||
const selectedPoint = points[activePointIndex];
|
|
||||||
const nextPoint = points[activePointIndex + 1];
|
|
||||||
mutateElement(element, {
|
|
||||||
points: [
|
|
||||||
...points.slice(0, activePointIndex + 1),
|
|
||||||
nextPoint
|
|
||||||
? [
|
|
||||||
(selectedPoint[0] + nextPoint[0]) / 2,
|
|
||||||
(selectedPoint[1] + nextPoint[1]) / 2,
|
|
||||||
]
|
|
||||||
: [selectedPoint[0] + 30, selectedPoint[1] + 30],
|
|
||||||
...points.slice(activePointIndex + 1),
|
|
||||||
],
|
|
||||||
});
|
|
||||||
return {
|
return {
|
||||||
appState: {
|
|
||||||
...appState,
|
|
||||||
editingLinearElement: {
|
|
||||||
...appState.editingLinearElement,
|
|
||||||
activePointIndex: activePointIndex + 1,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
elements,
|
elements,
|
||||||
|
appState: ret.appState,
|
||||||
commitToHistory: true,
|
commitToHistory: true,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -107,9 +87,12 @@ const duplicateElements = (
|
|||||||
const finalElements: ExcalidrawElement[] = [];
|
const finalElements: ExcalidrawElement[] = [];
|
||||||
|
|
||||||
let index = 0;
|
let index = 0;
|
||||||
|
const selectedElementIds = arrayToMap(
|
||||||
|
getSelectedElements(elements, appState, true),
|
||||||
|
);
|
||||||
while (index < elements.length) {
|
while (index < elements.length) {
|
||||||
const element = elements[index];
|
const element = elements[index];
|
||||||
if (appState.selectedElementIds[element.id]) {
|
if (selectedElementIds.get(element.id)) {
|
||||||
if (element.groupIds.length) {
|
if (element.groupIds.length) {
|
||||||
const groupId = getSelectedGroupForElement(appState, element);
|
const groupId = getSelectedGroupForElement(appState, element);
|
||||||
// if group selected, duplicate it atomically
|
// if group selected, duplicate it atomically
|
||||||
@@ -131,7 +114,11 @@ const duplicateElements = (
|
|||||||
}
|
}
|
||||||
index++;
|
index++;
|
||||||
}
|
}
|
||||||
|
bindTextToShapeAfterDuplication(
|
||||||
|
finalElements,
|
||||||
|
oldElements,
|
||||||
|
oldIdToDuplicatedId,
|
||||||
|
);
|
||||||
fixBindingsAfterDuplication(finalElements, oldElements, oldIdToDuplicatedId);
|
fixBindingsAfterDuplication(finalElements, oldElements, oldIdToDuplicatedId);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@@ -141,7 +128,9 @@ const duplicateElements = (
|
|||||||
...appState,
|
...appState,
|
||||||
selectedGroupIds: {},
|
selectedGroupIds: {},
|
||||||
selectedElementIds: newElements.reduce((acc, element) => {
|
selectedElementIds: newElements.reduce((acc, element) => {
|
||||||
acc[element.id] = true;
|
if (!isBoundToContainer(element)) {
|
||||||
|
acc[element.id] = true;
|
||||||
|
}
|
||||||
return acc;
|
return acc;
|
||||||
}, {} as any),
|
}, {} as any),
|
||||||
},
|
},
|
||||||
|
@@ -1,17 +1,25 @@
|
|||||||
import React from "react";
|
|
||||||
import { trackEvent } from "../analytics";
|
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 { ProjectName } from "../components/ProjectName";
|
||||||
import { ToolButton } from "../components/ToolButton";
|
import { ToolButton } from "../components/ToolButton";
|
||||||
import "../components/ToolIcon.scss";
|
import "../components/ToolIcon.scss";
|
||||||
import { Tooltip } from "../components/Tooltip";
|
import { Tooltip } from "../components/Tooltip";
|
||||||
import { DarkModeToggle, Appearence } from "../components/DarkModeToggle";
|
import { DarkModeToggle } from "../components/DarkModeToggle";
|
||||||
import { loadFromJSON, saveAsJSON } from "../data";
|
import { loadFromJSON, saveAsJSON } from "../data";
|
||||||
|
import { resaveAsImageWithScene } from "../data/resave";
|
||||||
import { t } from "../i18n";
|
import { t } from "../i18n";
|
||||||
import useIsMobile from "../is-mobile";
|
import { useIsMobile } from "../components/App";
|
||||||
import { KEYS } from "../keys";
|
import { KEYS } from "../keys";
|
||||||
import { register } from "./register";
|
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({
|
export const actionChangeProjectName = register({
|
||||||
name: "changeProjectName",
|
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({
|
export const actionChangeExportBackground = register({
|
||||||
name: "changeExportBackground",
|
name: "changeExportBackground",
|
||||||
perform: (_elements, appState, value) => {
|
perform: (_elements, appState, value) => {
|
||||||
@@ -40,14 +96,12 @@ export const actionChangeExportBackground = register({
|
|||||||
};
|
};
|
||||||
},
|
},
|
||||||
PanelComponent: ({ appState, updateData }) => (
|
PanelComponent: ({ appState, updateData }) => (
|
||||||
<label>
|
<CheckboxItem
|
||||||
<input
|
checked={appState.exportBackground}
|
||||||
type="checkbox"
|
onChange={(checked) => updateData(checked)}
|
||||||
checked={appState.exportBackground}
|
>
|
||||||
onChange={(event) => updateData(event.target.checked)}
|
|
||||||
/>{" "}
|
|
||||||
{t("labels.withBackground")}
|
{t("labels.withBackground")}
|
||||||
</label>
|
</CheckboxItem>
|
||||||
),
|
),
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -60,57 +114,35 @@ export const actionChangeExportEmbedScene = register({
|
|||||||
};
|
};
|
||||||
},
|
},
|
||||||
PanelComponent: ({ appState, updateData }) => (
|
PanelComponent: ({ appState, updateData }) => (
|
||||||
<label style={{ display: "flex" }}>
|
<CheckboxItem
|
||||||
<input
|
checked={appState.exportEmbedScene}
|
||||||
type="checkbox"
|
onChange={(checked) => updateData(checked)}
|
||||||
checked={appState.exportEmbedScene}
|
>
|
||||||
onChange={(event) => updateData(event.target.checked)}
|
|
||||||
/>{" "}
|
|
||||||
{t("labels.exportEmbedScene")}
|
{t("labels.exportEmbedScene")}
|
||||||
<Tooltip
|
<Tooltip label={t("labels.exportEmbedScene_details")} long={true}>
|
||||||
label={t("labels.exportEmbedScene_details")}
|
<div className="excalidraw-tooltip-icon">{questionCircle}</div>
|
||||||
position="above"
|
|
||||||
long={true}
|
|
||||||
>
|
|
||||||
<div className="TooltipIcon">{questionCircle}</div>
|
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
</label>
|
</CheckboxItem>
|
||||||
),
|
),
|
||||||
});
|
});
|
||||||
|
|
||||||
export const actionChangeShouldAddWatermark = register({
|
export const actionSaveToActiveFile = register({
|
||||||
name: "changeShouldAddWatermark",
|
name: "saveToActiveFile",
|
||||||
perform: (_elements, appState, value) => {
|
perform: async (elements, appState, value, app) => {
|
||||||
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",
|
|
||||||
perform: async (elements, appState, value) => {
|
|
||||||
const fileHandleExists = !!appState.fileHandle;
|
const fileHandleExists = !!appState.fileHandle;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const { fileHandle } = await saveAsJSON(elements, appState);
|
const { fileHandle } = isImageFileHandle(appState.fileHandle)
|
||||||
|
? await resaveAsImageWithScene(elements, appState, app.files)
|
||||||
|
: await saveAsJSON(elements, appState, app.files);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
commitToHistory: false,
|
commitToHistory: false,
|
||||||
appState: {
|
appState: {
|
||||||
...appState,
|
...appState,
|
||||||
fileHandle,
|
fileHandle,
|
||||||
toastMessage: fileHandleExists
|
toastMessage: fileHandleExists
|
||||||
? fileHandle.name
|
? fileHandle?.name
|
||||||
? t("toast.fileSavedToFilename").replace(
|
? t("toast.fileSavedToFilename").replace(
|
||||||
"{filename}",
|
"{filename}",
|
||||||
`"${fileHandle.name}"`,
|
`"${fileHandle.name}"`,
|
||||||
@@ -119,39 +151,43 @@ export const actionSaveScene = register({
|
|||||||
: null,
|
: null,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
} catch (error) {
|
} catch (error: any) {
|
||||||
if (error?.name !== "AbortError") {
|
if (error?.name !== "AbortError") {
|
||||||
console.error(error);
|
console.error(error);
|
||||||
|
} else {
|
||||||
|
console.warn(error);
|
||||||
}
|
}
|
||||||
return { commitToHistory: false };
|
return { commitToHistory: false };
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
keyTest: (event) =>
|
keyTest: (event) =>
|
||||||
event.key === KEYS.S && event[KEYS.CTRL_OR_CMD] && !event.shiftKey,
|
event.key === KEYS.S && event[KEYS.CTRL_OR_CMD] && !event.shiftKey,
|
||||||
PanelComponent: ({ updateData }) => (
|
PanelComponent: ({ updateData, appState }) => (
|
||||||
<ToolButton
|
<ActiveFile
|
||||||
type="button"
|
onSave={() => updateData(null)}
|
||||||
icon={save}
|
fileName={appState.fileHandle?.name}
|
||||||
title={t("buttons.save")}
|
|
||||||
aria-label={t("buttons.save")}
|
|
||||||
showAriaLabel={useIsMobile()}
|
|
||||||
onClick={() => updateData(null)}
|
|
||||||
/>
|
/>
|
||||||
),
|
),
|
||||||
});
|
});
|
||||||
|
|
||||||
export const actionSaveAsScene = register({
|
export const actionSaveFileToDisk = register({
|
||||||
name: "saveAsScene",
|
name: "saveFileToDisk",
|
||||||
perform: async (elements, appState, value) => {
|
perform: async (elements, appState, value, app) => {
|
||||||
try {
|
try {
|
||||||
const { fileHandle } = await saveAsJSON(elements, {
|
const { fileHandle } = await saveAsJSON(
|
||||||
...appState,
|
elements,
|
||||||
fileHandle: null,
|
{
|
||||||
});
|
...appState,
|
||||||
|
fileHandle: null,
|
||||||
|
},
|
||||||
|
app.files,
|
||||||
|
);
|
||||||
return { commitToHistory: false, appState: { ...appState, fileHandle } };
|
return { commitToHistory: false, appState: { ...appState, fileHandle } };
|
||||||
} catch (error) {
|
} catch (error: any) {
|
||||||
if (error?.name !== "AbortError") {
|
if (error?.name !== "AbortError") {
|
||||||
console.error(error);
|
console.error(error);
|
||||||
|
} else {
|
||||||
|
console.warn(error);
|
||||||
}
|
}
|
||||||
return { commitToHistory: false };
|
return { commitToHistory: false };
|
||||||
}
|
}
|
||||||
@@ -165,32 +201,37 @@ export const actionSaveAsScene = register({
|
|||||||
title={t("buttons.saveAs")}
|
title={t("buttons.saveAs")}
|
||||||
aria-label={t("buttons.saveAs")}
|
aria-label={t("buttons.saveAs")}
|
||||||
showAriaLabel={useIsMobile()}
|
showAriaLabel={useIsMobile()}
|
||||||
hidden={!supported}
|
hidden={!nativeFileSystemSupported}
|
||||||
onClick={() => updateData(null)}
|
onClick={() => updateData(null)}
|
||||||
|
data-testid="save-as-button"
|
||||||
/>
|
/>
|
||||||
),
|
),
|
||||||
});
|
});
|
||||||
|
|
||||||
export const actionLoadScene = register({
|
export const actionLoadScene = register({
|
||||||
name: "loadScene",
|
name: "loadScene",
|
||||||
perform: async (elements, appState) => {
|
perform: async (elements, appState, _, app) => {
|
||||||
try {
|
try {
|
||||||
const {
|
const {
|
||||||
elements: loadedElements,
|
elements: loadedElements,
|
||||||
appState: loadedAppState,
|
appState: loadedAppState,
|
||||||
} = await loadFromJSON(appState);
|
files,
|
||||||
|
} = await loadFromJSON(appState, elements);
|
||||||
return {
|
return {
|
||||||
elements: loadedElements,
|
elements: loadedElements,
|
||||||
appState: loadedAppState,
|
appState: loadedAppState,
|
||||||
|
files,
|
||||||
commitToHistory: true,
|
commitToHistory: true,
|
||||||
};
|
};
|
||||||
} catch (error) {
|
} catch (error: any) {
|
||||||
if (error?.name === "AbortError") {
|
if (error?.name === "AbortError") {
|
||||||
|
console.warn(error);
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
return {
|
return {
|
||||||
elements,
|
elements,
|
||||||
appState: { ...appState, errorMessage: error.message },
|
appState: { ...appState, errorMessage: error.message },
|
||||||
|
files: app.files,
|
||||||
commitToHistory: false,
|
commitToHistory: false,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -204,6 +245,7 @@ export const actionLoadScene = register({
|
|||||||
aria-label={t("buttons.load")}
|
aria-label={t("buttons.load")}
|
||||||
showAriaLabel={useIsMobile()}
|
showAriaLabel={useIsMobile()}
|
||||||
onClick={updateData}
|
onClick={updateData}
|
||||||
|
data-testid="load-button"
|
||||||
/>
|
/>
|
||||||
),
|
),
|
||||||
});
|
});
|
||||||
@@ -226,9 +268,9 @@ export const actionExportWithDarkMode = register({
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<DarkModeToggle
|
<DarkModeToggle
|
||||||
value={appState.exportWithDarkMode ? "dark" : "light"}
|
value={appState.exportWithDarkMode ? THEME.DARK : THEME.LIGHT}
|
||||||
onChange={(theme: Appearence) => {
|
onChange={(theme: Theme) => {
|
||||||
updateData(theme === "dark");
|
updateData(theme === THEME.DARK);
|
||||||
}}
|
}}
|
||||||
title={t("labels.toggleExportColorScheme")}
|
title={t("labels.toggleExportColorScheme")}
|
||||||
/>
|
/>
|
||||||
|
@@ -1,7 +1,6 @@
|
|||||||
import { KEYS } from "../keys";
|
import { KEYS } from "../keys";
|
||||||
import { isInvisiblySmallElement } from "../element";
|
import { isInvisiblySmallElement } from "../element";
|
||||||
import { resetCursor } from "../utils";
|
import { resetCursor } from "../utils";
|
||||||
import React from "react";
|
|
||||||
import { ToolButton } from "../components/ToolButton";
|
import { ToolButton } from "../components/ToolButton";
|
||||||
import { done } from "../components/icons";
|
import { done } from "../components/icons";
|
||||||
import { t } from "../i18n";
|
import { t } from "../i18n";
|
||||||
@@ -15,16 +14,63 @@ import {
|
|||||||
bindOrUnbindLinearElement,
|
bindOrUnbindLinearElement,
|
||||||
} from "../element/binding";
|
} from "../element/binding";
|
||||||
import { isBindingElement } from "../element/typeChecks";
|
import { isBindingElement } from "../element/typeChecks";
|
||||||
|
import { ExcalidrawImageElement } from "../element/types";
|
||||||
|
import { imageFromImageData } from "../element/image";
|
||||||
|
|
||||||
export const actionFinalize = register({
|
export const actionFinalize = register({
|
||||||
name: "finalize",
|
name: "finalize",
|
||||||
perform: (elements, appState, _, { canvas }) => {
|
perform: (
|
||||||
|
elements,
|
||||||
|
appState,
|
||||||
|
_,
|
||||||
|
{ canvas, focusContainer, imageCache, addFiles },
|
||||||
|
) => {
|
||||||
|
if (appState.editingImageElement) {
|
||||||
|
const { elementId, imageData } = appState.editingImageElement;
|
||||||
|
const editingImageElement = elements.find((el) => el.id === elementId) as
|
||||||
|
| ExcalidrawImageElement
|
||||||
|
| undefined;
|
||||||
|
if (editingImageElement?.fileId) {
|
||||||
|
const cachedImageData = imageCache.get(editingImageElement.fileId);
|
||||||
|
if (cachedImageData) {
|
||||||
|
const { image, dataURL } = imageFromImageData(imageData);
|
||||||
|
|
||||||
|
imageCache.set(editingImageElement.fileId, {
|
||||||
|
...cachedImageData,
|
||||||
|
image,
|
||||||
|
});
|
||||||
|
|
||||||
|
addFiles([
|
||||||
|
{
|
||||||
|
id: editingImageElement.fileId,
|
||||||
|
dataURL,
|
||||||
|
mimeType: cachedImageData.mimeType,
|
||||||
|
created: Date.now(),
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
appState: {
|
||||||
|
...appState,
|
||||||
|
editingImageElement: null,
|
||||||
|
},
|
||||||
|
commitToHistory: false,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
appState: {
|
||||||
|
...appState,
|
||||||
|
editingImageElement: null,
|
||||||
|
},
|
||||||
|
commitToHistory: false,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
if (appState.editingLinearElement) {
|
if (appState.editingLinearElement) {
|
||||||
const {
|
const { elementId, startBindingElement, endBindingElement } =
|
||||||
elementId,
|
appState.editingLinearElement;
|
||||||
startBindingElement,
|
|
||||||
endBindingElement,
|
|
||||||
} = appState.editingLinearElement;
|
|
||||||
const element = LinearElementEditor.getElement(elementId);
|
const element = LinearElementEditor.getElement(elementId);
|
||||||
|
|
||||||
if (element) {
|
if (element) {
|
||||||
@@ -50,20 +96,25 @@ export const actionFinalize = register({
|
|||||||
}
|
}
|
||||||
|
|
||||||
let newElements = elements;
|
let newElements = elements;
|
||||||
|
|
||||||
|
if (appState.pendingImageElement) {
|
||||||
|
mutateElement(appState.pendingImageElement, { isDeleted: true }, false);
|
||||||
|
}
|
||||||
|
|
||||||
if (window.document.activeElement instanceof HTMLElement) {
|
if (window.document.activeElement instanceof HTMLElement) {
|
||||||
window.document.activeElement.blur();
|
focusContainer();
|
||||||
}
|
}
|
||||||
|
|
||||||
const multiPointElement = appState.multiElement
|
const multiPointElement = appState.multiElement
|
||||||
? appState.multiElement
|
? appState.multiElement
|
||||||
: appState.editingElement?.type === "draw"
|
: appState.editingElement?.type === "freedraw"
|
||||||
? appState.editingElement
|
? appState.editingElement
|
||||||
: null;
|
: null;
|
||||||
|
|
||||||
if (multiPointElement) {
|
if (multiPointElement) {
|
||||||
// pen and mouse have hover
|
// pen and mouse have hover
|
||||||
if (
|
if (
|
||||||
multiPointElement.type !== "draw" &&
|
multiPointElement.type !== "freedraw" &&
|
||||||
appState.lastPointerDownWith !== "touch"
|
appState.lastPointerDownWith !== "touch"
|
||||||
) {
|
) {
|
||||||
const { points, lastCommittedPoint } = multiPointElement;
|
const { points, lastCommittedPoint } = multiPointElement;
|
||||||
@@ -86,7 +137,7 @@ export const actionFinalize = register({
|
|||||||
const isLoop = isPathALoop(multiPointElement.points, appState.zoom.value);
|
const isLoop = isPathALoop(multiPointElement.points, appState.zoom.value);
|
||||||
if (
|
if (
|
||||||
multiPointElement.type === "line" ||
|
multiPointElement.type === "line" ||
|
||||||
multiPointElement.type === "draw"
|
multiPointElement.type === "freedraw"
|
||||||
) {
|
) {
|
||||||
if (isLoop) {
|
if (isLoop) {
|
||||||
const linePoints = multiPointElement.points;
|
const linePoints = multiPointElement.points;
|
||||||
@@ -118,22 +169,24 @@ export const actionFinalize = register({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!appState.elementLocked && appState.elementType !== "draw") {
|
if (!appState.elementLocked && appState.elementType !== "freedraw") {
|
||||||
appState.selectedElementIds[multiPointElement.id] = true;
|
appState.selectedElementIds[multiPointElement.id] = true;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (
|
if (
|
||||||
(!appState.elementLocked && appState.elementType !== "draw") ||
|
(!appState.elementLocked && appState.elementType !== "freedraw") ||
|
||||||
!multiPointElement
|
!multiPointElement
|
||||||
) {
|
) {
|
||||||
resetCursor(canvas);
|
resetCursor(canvas);
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
elements: newElements,
|
elements: newElements,
|
||||||
appState: {
|
appState: {
|
||||||
...appState,
|
...appState,
|
||||||
elementType:
|
elementType:
|
||||||
(appState.elementLocked || appState.elementType === "draw") &&
|
(appState.elementLocked || appState.elementType === "freedraw") &&
|
||||||
multiPointElement
|
multiPointElement
|
||||||
? appState.elementType
|
? appState.elementType
|
||||||
: "selection",
|
: "selection",
|
||||||
@@ -145,19 +198,21 @@ export const actionFinalize = register({
|
|||||||
selectedElementIds:
|
selectedElementIds:
|
||||||
multiPointElement &&
|
multiPointElement &&
|
||||||
!appState.elementLocked &&
|
!appState.elementLocked &&
|
||||||
appState.elementType !== "draw"
|
appState.elementType !== "freedraw"
|
||||||
? {
|
? {
|
||||||
...appState.selectedElementIds,
|
...appState.selectedElementIds,
|
||||||
[multiPointElement.id]: true,
|
[multiPointElement.id]: true,
|
||||||
}
|
}
|
||||||
: appState.selectedElementIds,
|
: appState.selectedElementIds,
|
||||||
|
pendingImageElement: null,
|
||||||
},
|
},
|
||||||
commitToHistory: appState.elementType === "draw",
|
commitToHistory: appState.elementType === "freedraw",
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
keyTest: (event, appState) =>
|
keyTest: (event, appState) =>
|
||||||
(event.key === KEYS.ESCAPE &&
|
(event.key === KEYS.ESCAPE &&
|
||||||
(appState.editingLinearElement !== null ||
|
(appState.editingLinearElement !== null ||
|
||||||
|
appState.editingImageElement !== null ||
|
||||||
(!appState.draggingElement && appState.multiElement === null))) ||
|
(!appState.draggingElement && appState.multiElement === null))) ||
|
||||||
((event.key === KEYS.ESCAPE || event.key === KEYS.ENTER) &&
|
((event.key === KEYS.ESCAPE || event.key === KEYS.ENTER) &&
|
||||||
appState.multiElement !== null),
|
appState.multiElement !== null),
|
||||||
|
209
src/actions/actionFlip.ts
Normal file
209
src/actions/actionFlip.ts
Normal file
@@ -0,0 +1,209 @@
|
|||||||
|
import { register } from "./register";
|
||||||
|
import { getSelectedElements } from "../scene";
|
||||||
|
import { 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";
|
||||||
|
import { arrayToMap } from "../utils";
|
||||||
|
|
||||||
|
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 = arrayToMap(updatedElements);
|
||||||
|
|
||||||
|
return elements.map(
|
||||||
|
(element) => updatedElementsMap.get(element.id) || element,
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const flipElements = (
|
||||||
|
elements: NonDeleted<ExcalidrawElement>[],
|
||||||
|
appState: AppState,
|
||||||
|
flipDirection: "horizontal" | "vertical",
|
||||||
|
): ExcalidrawElement[] => {
|
||||||
|
elements.forEach((element) => {
|
||||||
|
flipElement(element, appState);
|
||||||
|
// If vertical flip, rotate an extra 180
|
||||||
|
if (flipDirection === "vertical") {
|
||||||
|
rotateElement(element, 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 index = 1; index < element.points.length; index++) {
|
||||||
|
LinearElementEditor.movePoints(element, [
|
||||||
|
{ index, point: [-element.points[index][0], element.points[index][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,7 +1,6 @@
|
|||||||
import React from "react";
|
|
||||||
import { CODES, KEYS } from "../keys";
|
import { CODES, KEYS } from "../keys";
|
||||||
import { t } from "../i18n";
|
import { t } from "../i18n";
|
||||||
import { getShortcutKey } from "../utils";
|
import { arrayToMap, getShortcutKey } from "../utils";
|
||||||
import { register } from "./register";
|
import { register } from "./register";
|
||||||
import { UngroupIcon, GroupIcon } from "../components/icons";
|
import { UngroupIcon, GroupIcon } from "../components/icons";
|
||||||
import { newElementWith } from "../element/mutateElement";
|
import { newElementWith } from "../element/mutateElement";
|
||||||
@@ -45,6 +44,7 @@ const enableActionGroup = (
|
|||||||
const selectedElements = getSelectedElements(
|
const selectedElements = getSelectedElements(
|
||||||
getNonDeletedElements(elements),
|
getNonDeletedElements(elements),
|
||||||
appState,
|
appState,
|
||||||
|
true,
|
||||||
);
|
);
|
||||||
return (
|
return (
|
||||||
selectedElements.length >= 2 && !allElementsInSameGroup(selectedElements)
|
selectedElements.length >= 2 && !allElementsInSameGroup(selectedElements)
|
||||||
@@ -57,6 +57,7 @@ export const actionGroup = register({
|
|||||||
const selectedElements = getSelectedElements(
|
const selectedElements = getSelectedElements(
|
||||||
getNonDeletedElements(elements),
|
getNonDeletedElements(elements),
|
||||||
appState,
|
appState,
|
||||||
|
true,
|
||||||
);
|
);
|
||||||
if (selectedElements.length < 2) {
|
if (selectedElements.length < 2) {
|
||||||
// nothing to group
|
// nothing to group
|
||||||
@@ -84,8 +85,9 @@ export const actionGroup = register({
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
const newGroupId = randomId();
|
const newGroupId = randomId();
|
||||||
|
const selectElementIds = arrayToMap(selectedElements);
|
||||||
const updatedElements = elements.map((element) => {
|
const updatedElements = elements.map((element) => {
|
||||||
if (!appState.selectedElementIds[element.id]) {
|
if (!selectElementIds.get(element.id)) {
|
||||||
return element;
|
return element;
|
||||||
}
|
}
|
||||||
return newElementWith(element, {
|
return newElementWith(element, {
|
||||||
@@ -100,9 +102,8 @@ export const actionGroup = register({
|
|||||||
// to the z order of the highest element in the layer stack
|
// to the z order of the highest element in the layer stack
|
||||||
const elementsInGroup = getElementsInGroup(updatedElements, newGroupId);
|
const elementsInGroup = getElementsInGroup(updatedElements, newGroupId);
|
||||||
const lastElementInGroup = elementsInGroup[elementsInGroup.length - 1];
|
const lastElementInGroup = elementsInGroup[elementsInGroup.length - 1];
|
||||||
const lastGroupElementIndex = updatedElements.lastIndexOf(
|
const lastGroupElementIndex =
|
||||||
lastElementInGroup,
|
updatedElements.lastIndexOf(lastElementInGroup);
|
||||||
);
|
|
||||||
const elementsAfterGroup = updatedElements.slice(lastGroupElementIndex + 1);
|
const elementsAfterGroup = updatedElements.slice(lastGroupElementIndex + 1);
|
||||||
const elementsBeforeGroup = updatedElements
|
const elementsBeforeGroup = updatedElements
|
||||||
.slice(0, lastGroupElementIndex)
|
.slice(0, lastGroupElementIndex)
|
||||||
|
@@ -1,15 +1,14 @@
|
|||||||
import { Action, ActionResult } from "./types";
|
import { Action, ActionResult } from "./types";
|
||||||
import React from "react";
|
|
||||||
import { undo, redo } from "../components/icons";
|
import { undo, redo } from "../components/icons";
|
||||||
import { ToolButton } from "../components/ToolButton";
|
import { ToolButton } from "../components/ToolButton";
|
||||||
import { t } from "../i18n";
|
import { t } from "../i18n";
|
||||||
import { SceneHistory, HistoryEntry } from "../history";
|
import History, { HistoryEntry } from "../history";
|
||||||
import { ExcalidrawElement } from "../element/types";
|
import { ExcalidrawElement } from "../element/types";
|
||||||
import { AppState } from "../types";
|
import { AppState } from "../types";
|
||||||
import { isWindows, KEYS } from "../keys";
|
import { isWindows, KEYS } from "../keys";
|
||||||
import { getElementMap } from "../element";
|
|
||||||
import { newElementWith } from "../element/mutateElement";
|
import { newElementWith } from "../element/mutateElement";
|
||||||
import { fixBindingsAfterDeletion } from "../element/binding";
|
import { fixBindingsAfterDeletion } from "../element/binding";
|
||||||
|
import { arrayToMap } from "../utils";
|
||||||
|
|
||||||
const writeData = (
|
const writeData = (
|
||||||
prevElements: readonly ExcalidrawElement[],
|
prevElements: readonly ExcalidrawElement[],
|
||||||
@@ -28,17 +27,17 @@ const writeData = (
|
|||||||
return { commitToHistory };
|
return { commitToHistory };
|
||||||
}
|
}
|
||||||
|
|
||||||
const prevElementMap = getElementMap(prevElements);
|
const prevElementMap = arrayToMap(prevElements);
|
||||||
const nextElements = data.elements;
|
const nextElements = data.elements;
|
||||||
const nextElementMap = getElementMap(nextElements);
|
const nextElementMap = arrayToMap(nextElements);
|
||||||
|
|
||||||
const deletedElements = prevElements.filter(
|
const deletedElements = prevElements.filter(
|
||||||
(prevElement) => !nextElementMap.hasOwnProperty(prevElement.id),
|
(prevElement) => !nextElementMap.has(prevElement.id),
|
||||||
);
|
);
|
||||||
const elements = nextElements
|
const elements = nextElements
|
||||||
.map((nextElement) =>
|
.map((nextElement) =>
|
||||||
newElementWith(
|
newElementWith(
|
||||||
prevElementMap[nextElement.id] || nextElement,
|
prevElementMap.get(nextElement.id) || nextElement,
|
||||||
nextElement,
|
nextElement,
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
@@ -59,7 +58,7 @@ const writeData = (
|
|||||||
return { commitToHistory };
|
return { commitToHistory };
|
||||||
};
|
};
|
||||||
|
|
||||||
type ActionCreator = (history: SceneHistory) => Action;
|
type ActionCreator = (history: History) => Action;
|
||||||
|
|
||||||
export const createUndoAction: ActionCreator = (history) => ({
|
export const createUndoAction: ActionCreator = (history) => ({
|
||||||
name: "undo",
|
name: "undo",
|
||||||
@@ -69,12 +68,13 @@ export const createUndoAction: ActionCreator = (history) => ({
|
|||||||
event[KEYS.CTRL_OR_CMD] &&
|
event[KEYS.CTRL_OR_CMD] &&
|
||||||
event.key.toLowerCase() === KEYS.Z &&
|
event.key.toLowerCase() === KEYS.Z &&
|
||||||
!event.shiftKey,
|
!event.shiftKey,
|
||||||
PanelComponent: ({ updateData }) => (
|
PanelComponent: ({ updateData, data }) => (
|
||||||
<ToolButton
|
<ToolButton
|
||||||
type="button"
|
type="button"
|
||||||
icon={undo}
|
icon={undo}
|
||||||
aria-label={t("buttons.undo")}
|
aria-label={t("buttons.undo")}
|
||||||
onClick={updateData}
|
onClick={updateData}
|
||||||
|
size={data?.size || "medium"}
|
||||||
/>
|
/>
|
||||||
),
|
),
|
||||||
commitToHistory: () => false,
|
commitToHistory: () => false,
|
||||||
@@ -89,12 +89,13 @@ export const createRedoAction: ActionCreator = (history) => ({
|
|||||||
event.shiftKey &&
|
event.shiftKey &&
|
||||||
event.key.toLowerCase() === KEYS.Z) ||
|
event.key.toLowerCase() === KEYS.Z) ||
|
||||||
(isWindows && event.ctrlKey && !event.shiftKey && event.key === KEYS.Y),
|
(isWindows && event.ctrlKey && !event.shiftKey && event.key === KEYS.Y),
|
||||||
PanelComponent: ({ updateData }) => (
|
PanelComponent: ({ updateData, data }) => (
|
||||||
<ToolButton
|
<ToolButton
|
||||||
type="button"
|
type="button"
|
||||||
icon={redo}
|
icon={redo}
|
||||||
aria-label={t("buttons.redo")}
|
aria-label={t("buttons.redo")}
|
||||||
onClick={updateData}
|
onClick={updateData}
|
||||||
|
size={data?.size || "medium"}
|
||||||
/>
|
/>
|
||||||
),
|
),
|
||||||
commitToHistory: () => false,
|
commitToHistory: () => false,
|
||||||
|
75
src/actions/actionImageEditing.tsx
Normal file
75
src/actions/actionImageEditing.tsx
Normal file
@@ -0,0 +1,75 @@
|
|||||||
|
import { getSelectedElements, isSomeElementSelected } from "../scene";
|
||||||
|
import { ToolButton } from "../components/ToolButton";
|
||||||
|
import { backgroundIcon } from "../components/icons";
|
||||||
|
import { register } from "./register";
|
||||||
|
import { getNonDeletedElements } from "../element";
|
||||||
|
import { isInitializedImageElement } from "../element/typeChecks";
|
||||||
|
import Scene from "../scene/Scene";
|
||||||
|
|
||||||
|
export const actionEditImageAlpha = register({
|
||||||
|
name: "editImageAlpha",
|
||||||
|
perform: async (elements, appState, _, app) => {
|
||||||
|
if (appState.editingImageElement) {
|
||||||
|
return {
|
||||||
|
appState: {
|
||||||
|
...appState,
|
||||||
|
editingImageElement: null,
|
||||||
|
},
|
||||||
|
commitToHistory: false,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const selectedElements = getSelectedElements(elements, appState);
|
||||||
|
const selectedElement = selectedElements[0];
|
||||||
|
if (
|
||||||
|
selectedElements.length === 1 &&
|
||||||
|
isInitializedImageElement(selectedElement)
|
||||||
|
) {
|
||||||
|
const imgData = app.imageCache.get(selectedElement.fileId);
|
||||||
|
if (!imgData) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const image = await imgData.image;
|
||||||
|
const { width, height } = image;
|
||||||
|
|
||||||
|
const canvas = document.createElement("canvas");
|
||||||
|
canvas.height = height;
|
||||||
|
canvas.width = width;
|
||||||
|
const context = canvas.getContext("2d")!;
|
||||||
|
|
||||||
|
context.drawImage(image, 0, 0, width, height);
|
||||||
|
|
||||||
|
const imageData = context.getImageData(0, 0, width, height);
|
||||||
|
|
||||||
|
Scene.mapElementToScene(selectedElement.id, app.scene);
|
||||||
|
|
||||||
|
return {
|
||||||
|
appState: {
|
||||||
|
...appState,
|
||||||
|
editingImageElement: {
|
||||||
|
editorType: "alpha",
|
||||||
|
elementId: selectedElement.id,
|
||||||
|
origImageData: imageData,
|
||||||
|
imageData,
|
||||||
|
pointerDownState: { screenX: 0, screenY: 0, sampledPixel: null },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
commitToHistory: false,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
},
|
||||||
|
PanelComponent: ({ elements, appState, updateData }) => (
|
||||||
|
<ToolButton
|
||||||
|
type="button"
|
||||||
|
icon={backgroundIcon}
|
||||||
|
label="Edit Image Alpha"
|
||||||
|
className={appState.editingImageElement ? "active" : ""}
|
||||||
|
title={"Edit image alpha"}
|
||||||
|
aria-label={"Edit image alpha"}
|
||||||
|
onClick={() => updateData(null)}
|
||||||
|
visible={isSomeElementSelected(getNonDeletedElements(elements), appState)}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
});
|
@@ -1,4 +1,3 @@
|
|||||||
import React from "react";
|
|
||||||
import { menu, palette } from "../components/icons";
|
import { menu, palette } from "../components/icons";
|
||||||
import { ToolButton } from "../components/ToolButton";
|
import { ToolButton } from "../components/ToolButton";
|
||||||
import { t } from "../i18n";
|
import { t } from "../i18n";
|
||||||
@@ -70,7 +69,10 @@ export const actionFullScreen = register({
|
|||||||
|
|
||||||
export const actionShortcuts = register({
|
export const actionShortcuts = register({
|
||||||
name: "toggleShortcuts",
|
name: "toggleShortcuts",
|
||||||
perform: (_elements, appState) => {
|
perform: (_elements, appState, _, { focusContainer }) => {
|
||||||
|
if (appState.showHelpDialog) {
|
||||||
|
focusContainer();
|
||||||
|
}
|
||||||
return {
|
return {
|
||||||
appState: {
|
appState: {
|
||||||
...appState,
|
...appState,
|
||||||
|
@@ -1,4 +1,3 @@
|
|||||||
import React from "react";
|
|
||||||
import { getClientColors, getClientInitials } from "../clients";
|
import { getClientColors, getClientInitials } from "../clients";
|
||||||
import { Avatar } from "../components/Avatar";
|
import { Avatar } from "../components/Avatar";
|
||||||
import { centerScrollOn } from "../scene/scroll";
|
import { centerScrollOn } from "../scene/scroll";
|
||||||
@@ -30,8 +29,8 @@ export const actionGoToCollaborator = register({
|
|||||||
commitToHistory: false,
|
commitToHistory: false,
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
PanelComponent: ({ appState, updateData, id }) => {
|
PanelComponent: ({ appState, updateData, data }) => {
|
||||||
const clientId = id;
|
const clientId: string | undefined = data?.id;
|
||||||
if (!clientId) {
|
if (!clientId) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
@@ -1,4 +1,3 @@
|
|||||||
import React from "react";
|
|
||||||
import { AppState } from "../../src/types";
|
import { AppState } from "../../src/types";
|
||||||
import { ButtonIconSelect } from "../components/ButtonIconSelect";
|
import { ButtonIconSelect } from "../components/ButtonIconSelect";
|
||||||
import { ColorPicker } from "../components/ColorPicker";
|
import { ColorPicker } from "../components/ColorPicker";
|
||||||
@@ -7,12 +6,20 @@ import {
|
|||||||
ArrowheadArrowIcon,
|
ArrowheadArrowIcon,
|
||||||
ArrowheadBarIcon,
|
ArrowheadBarIcon,
|
||||||
ArrowheadDotIcon,
|
ArrowheadDotIcon,
|
||||||
|
ArrowheadTriangleIcon,
|
||||||
ArrowheadNoneIcon,
|
ArrowheadNoneIcon,
|
||||||
EdgeRoundIcon,
|
EdgeRoundIcon,
|
||||||
EdgeSharpIcon,
|
EdgeSharpIcon,
|
||||||
FillCrossHatchIcon,
|
FillCrossHatchIcon,
|
||||||
FillHachureIcon,
|
FillHachureIcon,
|
||||||
FillSolidIcon,
|
FillSolidIcon,
|
||||||
|
FontFamilyCodeIcon,
|
||||||
|
FontFamilyHandDrawnIcon,
|
||||||
|
FontFamilyNormalIcon,
|
||||||
|
FontSizeExtraLargeIcon,
|
||||||
|
FontSizeLargeIcon,
|
||||||
|
FontSizeMediumIcon,
|
||||||
|
FontSizeSmallIcon,
|
||||||
SloppinessArchitectIcon,
|
SloppinessArchitectIcon,
|
||||||
SloppinessArtistIcon,
|
SloppinessArtistIcon,
|
||||||
SloppinessCartoonistIcon,
|
SloppinessCartoonistIcon,
|
||||||
@@ -20,18 +27,15 @@ import {
|
|||||||
StrokeStyleDottedIcon,
|
StrokeStyleDottedIcon,
|
||||||
StrokeStyleSolidIcon,
|
StrokeStyleSolidIcon,
|
||||||
StrokeWidthIcon,
|
StrokeWidthIcon,
|
||||||
FontSizeSmallIcon,
|
|
||||||
FontSizeMediumIcon,
|
|
||||||
FontSizeLargeIcon,
|
|
||||||
FontSizeExtraLargeIcon,
|
|
||||||
FontFamilyHandDrawnIcon,
|
|
||||||
FontFamilyNormalIcon,
|
|
||||||
FontFamilyCodeIcon,
|
|
||||||
TextAlignLeftIcon,
|
|
||||||
TextAlignCenterIcon,
|
TextAlignCenterIcon,
|
||||||
|
TextAlignLeftIcon,
|
||||||
TextAlignRightIcon,
|
TextAlignRightIcon,
|
||||||
} from "../components/icons";
|
} from "../components/icons";
|
||||||
import { DEFAULT_FONT_FAMILY, DEFAULT_FONT_SIZE } from "../constants";
|
import {
|
||||||
|
DEFAULT_FONT_FAMILY,
|
||||||
|
DEFAULT_FONT_SIZE,
|
||||||
|
FONT_FAMILY,
|
||||||
|
} from "../constants";
|
||||||
import {
|
import {
|
||||||
getNonDeletedElements,
|
getNonDeletedElements,
|
||||||
isTextElement,
|
isTextElement,
|
||||||
@@ -44,7 +48,7 @@ import {
|
|||||||
ExcalidrawElement,
|
ExcalidrawElement,
|
||||||
ExcalidrawLinearElement,
|
ExcalidrawLinearElement,
|
||||||
ExcalidrawTextElement,
|
ExcalidrawTextElement,
|
||||||
FontFamily,
|
FontFamilyValues,
|
||||||
TextAlign,
|
TextAlign,
|
||||||
} from "../element/types";
|
} from "../element/types";
|
||||||
import { getLanguage, t } from "../i18n";
|
import { getLanguage, t } from "../i18n";
|
||||||
@@ -56,6 +60,7 @@ import {
|
|||||||
getTargetElements,
|
getTargetElements,
|
||||||
isSomeElementSelected,
|
isSomeElementSelected,
|
||||||
} from "../scene";
|
} from "../scene";
|
||||||
|
import { hasStrokeColor } from "../scene/comparisons";
|
||||||
import { register } from "./register";
|
import { register } from "./register";
|
||||||
|
|
||||||
const changeProperty = (
|
const changeProperty = (
|
||||||
@@ -99,13 +104,20 @@ export const actionChangeStrokeColor = register({
|
|||||||
name: "changeStrokeColor",
|
name: "changeStrokeColor",
|
||||||
perform: (elements, appState, value) => {
|
perform: (elements, appState, value) => {
|
||||||
return {
|
return {
|
||||||
elements: changeProperty(elements, appState, (el) =>
|
...(value.currentItemStrokeColor && {
|
||||||
newElementWith(el, {
|
elements: changeProperty(elements, appState, (el) => {
|
||||||
strokeColor: value,
|
return hasStrokeColor(el.type)
|
||||||
|
? newElementWith(el, {
|
||||||
|
strokeColor: value.currentItemStrokeColor,
|
||||||
|
})
|
||||||
|
: el;
|
||||||
}),
|
}),
|
||||||
),
|
}),
|
||||||
appState: { ...appState, currentItemStrokeColor: value },
|
appState: {
|
||||||
commitToHistory: true,
|
...appState,
|
||||||
|
...value,
|
||||||
|
},
|
||||||
|
commitToHistory: !!value.currentItemStrokeColor,
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
PanelComponent: ({ elements, appState, updateData }) => (
|
PanelComponent: ({ elements, appState, updateData }) => (
|
||||||
@@ -120,7 +132,11 @@ export const actionChangeStrokeColor = register({
|
|||||||
(element) => element.strokeColor,
|
(element) => element.strokeColor,
|
||||||
appState.currentItemStrokeColor,
|
appState.currentItemStrokeColor,
|
||||||
)}
|
)}
|
||||||
onChange={updateData}
|
onChange={(color) => updateData({ currentItemStrokeColor: color })}
|
||||||
|
isActive={appState.openPopup === "strokeColorPicker"}
|
||||||
|
setActive={(active) =>
|
||||||
|
updateData({ openPopup: active ? "strokeColorPicker" : null })
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
</>
|
</>
|
||||||
),
|
),
|
||||||
@@ -130,13 +146,18 @@ export const actionChangeBackgroundColor = register({
|
|||||||
name: "changeBackgroundColor",
|
name: "changeBackgroundColor",
|
||||||
perform: (elements, appState, value) => {
|
perform: (elements, appState, value) => {
|
||||||
return {
|
return {
|
||||||
elements: changeProperty(elements, appState, (el) =>
|
...(value.currentItemBackgroundColor && {
|
||||||
newElementWith(el, {
|
elements: changeProperty(elements, appState, (el) =>
|
||||||
backgroundColor: value,
|
newElementWith(el, {
|
||||||
}),
|
backgroundColor: value.currentItemBackgroundColor,
|
||||||
),
|
}),
|
||||||
appState: { ...appState, currentItemBackgroundColor: value },
|
),
|
||||||
commitToHistory: true,
|
}),
|
||||||
|
appState: {
|
||||||
|
...appState,
|
||||||
|
...value,
|
||||||
|
},
|
||||||
|
commitToHistory: !!value.currentItemBackgroundColor,
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
PanelComponent: ({ elements, appState, updateData }) => (
|
PanelComponent: ({ elements, appState, updateData }) => (
|
||||||
@@ -151,7 +172,11 @@ export const actionChangeBackgroundColor = register({
|
|||||||
(element) => element.backgroundColor,
|
(element) => element.backgroundColor,
|
||||||
appState.currentItemBackgroundColor,
|
appState.currentItemBackgroundColor,
|
||||||
)}
|
)}
|
||||||
onChange={updateData}
|
onChange={(color) => updateData({ currentItemBackgroundColor: color })}
|
||||||
|
isActive={appState.openPopup === "backgroundColorPicker"}
|
||||||
|
setActive={(active) =>
|
||||||
|
updateData({ openPopup: active ? "backgroundColorPicker" : null })
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
</>
|
</>
|
||||||
),
|
),
|
||||||
@@ -481,19 +506,23 @@ export const actionChangeFontFamily = register({
|
|||||||
};
|
};
|
||||||
},
|
},
|
||||||
PanelComponent: ({ elements, appState, updateData }) => {
|
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"),
|
text: t("labels.handDrawn"),
|
||||||
icon: <FontFamilyHandDrawnIcon theme={appState.theme} />,
|
icon: <FontFamilyHandDrawnIcon theme={appState.theme} />,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
value: 2,
|
value: FONT_FAMILY.Helvetica,
|
||||||
text: t("labels.normal"),
|
text: t("labels.normal"),
|
||||||
icon: <FontFamilyNormalIcon theme={appState.theme} />,
|
icon: <FontFamilyNormalIcon theme={appState.theme} />,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
value: 3,
|
value: FONT_FAMILY.Cascadia,
|
||||||
text: t("labels.code"),
|
text: t("labels.code"),
|
||||||
icon: <FontFamilyCodeIcon theme={appState.theme} />,
|
icon: <FontFamilyCodeIcon theme={appState.theme} />,
|
||||||
},
|
},
|
||||||
@@ -502,7 +531,7 @@ export const actionChangeFontFamily = register({
|
|||||||
return (
|
return (
|
||||||
<fieldset>
|
<fieldset>
|
||||||
<legend>{t("labels.fontFamily")}</legend>
|
<legend>{t("labels.fontFamily")}</legend>
|
||||||
<ButtonIconSelect<FontFamily | false>
|
<ButtonIconSelect<FontFamilyValues | false>
|
||||||
group="font-family"
|
group="font-family"
|
||||||
options={options}
|
options={options}
|
||||||
value={getFormValue(
|
value={getFormValue(
|
||||||
@@ -710,6 +739,14 @@ export const actionChangeArrowhead = register({
|
|||||||
icon: <ArrowheadDotIcon theme={appState.theme} flip={!isRTL} />,
|
icon: <ArrowheadDotIcon theme={appState.theme} flip={!isRTL} />,
|
||||||
keyBinding: "r",
|
keyBinding: "r",
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
value: "triangle",
|
||||||
|
text: t("labels.arrowhead_triangle"),
|
||||||
|
icon: (
|
||||||
|
<ArrowheadTriangleIcon theme={appState.theme} flip={!isRTL} />
|
||||||
|
),
|
||||||
|
keyBinding: "t",
|
||||||
|
},
|
||||||
]}
|
]}
|
||||||
value={getFormValue<Arrowhead | null>(
|
value={getFormValue<Arrowhead | null>(
|
||||||
elements,
|
elements,
|
||||||
@@ -752,6 +789,14 @@ export const actionChangeArrowhead = register({
|
|||||||
keyBinding: "r",
|
keyBinding: "r",
|
||||||
icon: <ArrowheadDotIcon theme={appState.theme} flip={isRTL} />,
|
icon: <ArrowheadDotIcon theme={appState.theme} flip={isRTL} />,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
value: "triangle",
|
||||||
|
text: t("labels.arrowhead_triangle"),
|
||||||
|
icon: (
|
||||||
|
<ArrowheadTriangleIcon theme={appState.theme} flip={isRTL} />
|
||||||
|
),
|
||||||
|
keyBinding: "t",
|
||||||
|
},
|
||||||
]}
|
]}
|
||||||
value={getFormValue<Arrowhead | null>(
|
value={getFormValue<Arrowhead | null>(
|
||||||
elements,
|
elements,
|
||||||
|
@@ -1,7 +1,7 @@
|
|||||||
import { KEYS } from "../keys";
|
import { KEYS } from "../keys";
|
||||||
import { register } from "./register";
|
import { register } from "./register";
|
||||||
import { selectGroupsForSelectedElements } from "../groups";
|
import { selectGroupsForSelectedElements } from "../groups";
|
||||||
import { getNonDeletedElements } from "../element";
|
import { getNonDeletedElements, isTextElement } from "../element";
|
||||||
|
|
||||||
export const actionSelectAll = register({
|
export const actionSelectAll = register({
|
||||||
name: "selectAll",
|
name: "selectAll",
|
||||||
@@ -15,7 +15,10 @@ export const actionSelectAll = register({
|
|||||||
...appState,
|
...appState,
|
||||||
editingGroupId: null,
|
editingGroupId: null,
|
||||||
selectedElementIds: elements.reduce((map, element) => {
|
selectedElementIds: elements.reduce((map, element) => {
|
||||||
if (!element.isDeleted) {
|
if (
|
||||||
|
!element.isDeleted &&
|
||||||
|
!(isTextElement(element) && element.containerId)
|
||||||
|
) {
|
||||||
map[element.id] = true;
|
map[element.id] = true;
|
||||||
}
|
}
|
||||||
return map;
|
return map;
|
||||||
|
@@ -1,4 +1,5 @@
|
|||||||
import { register } from "./register";
|
import { register } from "./register";
|
||||||
|
import { CODES, KEYS } from "../keys";
|
||||||
|
|
||||||
export const actionToggleStats = register({
|
export const actionToggleStats = register({
|
||||||
name: "stats",
|
name: "stats",
|
||||||
@@ -13,4 +14,6 @@ export const actionToggleStats = register({
|
|||||||
},
|
},
|
||||||
checked: (appState) => appState.showStats,
|
checked: (appState) => appState.showStats,
|
||||||
contextItemLabel: "stats.title",
|
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: {
|
||||||
...appState,
|
...appState,
|
||||||
viewModeEnabled: !this.checked!(appState),
|
viewModeEnabled: !this.checked!(appState),
|
||||||
selectedElementIds: {},
|
|
||||||
},
|
},
|
||||||
commitToHistory: false,
|
commitToHistory: false,
|
||||||
};
|
};
|
||||||
|
@@ -26,6 +26,7 @@ export {
|
|||||||
actionZoomOut,
|
actionZoomOut,
|
||||||
actionResetZoom,
|
actionResetZoom,
|
||||||
actionZoomToFit,
|
actionZoomToFit,
|
||||||
|
actionToggleTheme,
|
||||||
} from "./actionCanvas";
|
} from "./actionCanvas";
|
||||||
|
|
||||||
export { actionFinalize } from "./actionFinalize";
|
export { actionFinalize } from "./actionFinalize";
|
||||||
@@ -33,8 +34,8 @@ export { actionFinalize } from "./actionFinalize";
|
|||||||
export {
|
export {
|
||||||
actionChangeProjectName,
|
actionChangeProjectName,
|
||||||
actionChangeExportBackground,
|
actionChangeExportBackground,
|
||||||
actionSaveScene,
|
actionSaveToActiveFile,
|
||||||
actionSaveAsScene,
|
actionSaveFileToDisk,
|
||||||
actionLoadScene,
|
actionLoadScene,
|
||||||
} from "./actionExport";
|
} from "./actionExport";
|
||||||
|
|
||||||
@@ -66,6 +67,8 @@ export {
|
|||||||
distributeVertically,
|
distributeVertically,
|
||||||
} from "./actionDistribute";
|
} from "./actionDistribute";
|
||||||
|
|
||||||
|
export { actionFlipHorizontal, actionFlipVertical } from "./actionFlip";
|
||||||
|
|
||||||
export {
|
export {
|
||||||
actionCopy,
|
actionCopy,
|
||||||
actionCut,
|
actionCut,
|
||||||
@@ -77,3 +80,4 @@ export { actionToggleGridMode } from "./actionToggleGridMode";
|
|||||||
export { actionToggleZenMode } from "./actionToggleZenMode";
|
export { actionToggleZenMode } from "./actionToggleZenMode";
|
||||||
|
|
||||||
export { actionToggleStats } from "./actionToggleStats";
|
export { actionToggleStats } from "./actionToggleStats";
|
||||||
|
export { actionEditImageAlpha } from "./actionImageEditing";
|
||||||
|
@@ -5,15 +5,12 @@ import {
|
|||||||
UpdaterFn,
|
UpdaterFn,
|
||||||
ActionName,
|
ActionName,
|
||||||
ActionResult,
|
ActionResult,
|
||||||
|
PanelComponentProps,
|
||||||
} from "./types";
|
} from "./types";
|
||||||
import { ExcalidrawElement } from "../element/types";
|
import { ExcalidrawElement } from "../element/types";
|
||||||
import { AppState, ExcalidrawProps } from "../types";
|
import { AppClassProperties, AppState } from "../types";
|
||||||
import { MODES } from "../constants";
|
import { MODES } from "../constants";
|
||||||
|
|
||||||
// This is the <App> component, but for now we don't care about anything but its
|
|
||||||
// `canvas` state.
|
|
||||||
type App = { canvas: HTMLCanvasElement | null; props: ExcalidrawProps };
|
|
||||||
|
|
||||||
export class ActionManager implements ActionsManagerInterface {
|
export class ActionManager implements ActionsManagerInterface {
|
||||||
actions = {} as ActionsManagerInterface["actions"];
|
actions = {} as ActionsManagerInterface["actions"];
|
||||||
|
|
||||||
@@ -21,13 +18,13 @@ export class ActionManager implements ActionsManagerInterface {
|
|||||||
|
|
||||||
getAppState: () => Readonly<AppState>;
|
getAppState: () => Readonly<AppState>;
|
||||||
getElementsIncludingDeleted: () => readonly ExcalidrawElement[];
|
getElementsIncludingDeleted: () => readonly ExcalidrawElement[];
|
||||||
app: App;
|
app: AppClassProperties;
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
updater: UpdaterFn,
|
updater: UpdaterFn,
|
||||||
getAppState: () => AppState,
|
getAppState: () => AppState,
|
||||||
getElementsIncludingDeleted: () => readonly ExcalidrawElement[],
|
getElementsIncludingDeleted: () => readonly ExcalidrawElement[],
|
||||||
app: App,
|
app: AppClassProperties,
|
||||||
) {
|
) {
|
||||||
this.updater = (actionResult) => {
|
this.updater = (actionResult) => {
|
||||||
if (actionResult && "then" in actionResult) {
|
if (actionResult && "then" in actionResult) {
|
||||||
@@ -51,11 +48,15 @@ export class ActionManager implements ActionsManagerInterface {
|
|||||||
actions.forEach((action) => this.registerAction(action));
|
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)
|
const data = Object.values(this.actions)
|
||||||
.sort((a, b) => (b.keyPriority || 0) - (a.keyPriority || 0))
|
.sort((a, b) => (b.keyPriority || 0) - (a.keyPriority || 0))
|
||||||
.filter(
|
.filter(
|
||||||
(action) =>
|
(action) =>
|
||||||
|
(action.name in canvasActions
|
||||||
|
? canvasActions[action.name as keyof typeof canvasActions]
|
||||||
|
: true) &&
|
||||||
action.keyTest &&
|
action.keyTest &&
|
||||||
action.keyTest(
|
action.keyTest(
|
||||||
event,
|
event,
|
||||||
@@ -97,12 +98,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
|
* @param data additional data sent to the PanelComponent
|
||||||
// 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, data?: PanelComponentProps["data"]) => {
|
||||||
renderAction = (name: ActionName, id?: string) => {
|
const canvasActions = this.app.props.UIOptions.canvasActions;
|
||||||
if (this.actions[name] && "PanelComponent" in this.actions[name]) {
|
|
||||||
|
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 action = this.actions[name];
|
||||||
const PanelComponent = action.PanelComponent!;
|
const PanelComponent = action.PanelComponent!;
|
||||||
const updateData = (formState?: any) => {
|
const updateData = (formState?: any) => {
|
||||||
@@ -121,8 +129,8 @@ export class ActionManager implements ActionsManagerInterface {
|
|||||||
elements={this.getElementsIncludingDeleted()}
|
elements={this.getElementsIncludingDeleted()}
|
||||||
appState={this.getAppState()}
|
appState={this.getAppState()}
|
||||||
updateData={updateData}
|
updateData={updateData}
|
||||||
id={id}
|
|
||||||
appProps={this.app.props}
|
appProps={this.app.props}
|
||||||
|
data={data}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
@@ -23,7 +23,9 @@ export type ShortcutName =
|
|||||||
| "zenMode"
|
| "zenMode"
|
||||||
| "stats"
|
| "stats"
|
||||||
| "addToLibrary"
|
| "addToLibrary"
|
||||||
| "viewMode";
|
| "viewMode"
|
||||||
|
| "flipHorizontal"
|
||||||
|
| "flipVertical";
|
||||||
|
|
||||||
const shortcutMap: Record<ShortcutName, string[]> = {
|
const shortcutMap: Record<ShortcutName, string[]> = {
|
||||||
cut: [getShortcutKey("CtrlOrCmd+X")],
|
cut: [getShortcutKey("CtrlOrCmd+X")],
|
||||||
@@ -55,8 +57,10 @@ const shortcutMap: Record<ShortcutName, string[]> = {
|
|||||||
ungroup: [getShortcutKey("CtrlOrCmd+Shift+G")],
|
ungroup: [getShortcutKey("CtrlOrCmd+Shift+G")],
|
||||||
gridMode: [getShortcutKey("CtrlOrCmd+'")],
|
gridMode: [getShortcutKey("CtrlOrCmd+'")],
|
||||||
zenMode: [getShortcutKey("Alt+Z")],
|
zenMode: [getShortcutKey("Alt+Z")],
|
||||||
stats: [],
|
stats: [getShortcutKey("Alt+/")],
|
||||||
addToLibrary: [],
|
addToLibrary: [],
|
||||||
|
flipHorizontal: [getShortcutKey("Shift+H")],
|
||||||
|
flipVertical: [getShortcutKey("Shift+V")],
|
||||||
viewMode: [getShortcutKey("Alt+R")],
|
viewMode: [getShortcutKey("Alt+R")],
|
||||||
};
|
};
|
||||||
|
|
||||||
|
@@ -1,14 +1,25 @@
|
|||||||
import React from "react";
|
import React from "react";
|
||||||
import { ExcalidrawElement } from "../element/types";
|
import { ExcalidrawElement } from "../element/types";
|
||||||
import { AppState, ExcalidrawProps } from "../types";
|
import {
|
||||||
|
AppClassProperties,
|
||||||
|
AppState,
|
||||||
|
ExcalidrawProps,
|
||||||
|
BinaryFiles,
|
||||||
|
} from "../types";
|
||||||
|
import { ToolButtonSize } from "../components/ToolButton";
|
||||||
|
|
||||||
/** if false, the action should be prevented */
|
/** if false, the action should be prevented */
|
||||||
export type ActionResult =
|
export type ActionResult =
|
||||||
| {
|
| {
|
||||||
elements?: readonly ExcalidrawElement[] | null;
|
elements?: readonly ExcalidrawElement[] | null;
|
||||||
appState?: MarkOptional<AppState, "offsetTop" | "offsetLeft"> | null;
|
appState?: MarkOptional<
|
||||||
|
AppState,
|
||||||
|
"offsetTop" | "offsetLeft" | "width" | "height"
|
||||||
|
> | null;
|
||||||
|
files?: BinaryFiles | null;
|
||||||
commitToHistory: boolean;
|
commitToHistory: boolean;
|
||||||
syncHistory?: boolean;
|
syncHistory?: boolean;
|
||||||
|
replaceFiles?: boolean;
|
||||||
}
|
}
|
||||||
| false;
|
| false;
|
||||||
|
|
||||||
@@ -16,7 +27,7 @@ type ActionFn = (
|
|||||||
elements: readonly ExcalidrawElement[],
|
elements: readonly ExcalidrawElement[],
|
||||||
appState: Readonly<AppState>,
|
appState: Readonly<AppState>,
|
||||||
formData: any,
|
formData: any,
|
||||||
app: { canvas: HTMLCanvasElement | null },
|
app: AppClassProperties,
|
||||||
) => ActionResult | Promise<ActionResult>;
|
) => ActionResult | Promise<ActionResult>;
|
||||||
|
|
||||||
export type UpdaterFn = (res: ActionResult) => void;
|
export type UpdaterFn = (res: ActionResult) => void;
|
||||||
@@ -42,6 +53,7 @@ export type ActionName =
|
|||||||
| "changeBackgroundColor"
|
| "changeBackgroundColor"
|
||||||
| "changeFillStyle"
|
| "changeFillStyle"
|
||||||
| "changeStrokeWidth"
|
| "changeStrokeWidth"
|
||||||
|
| "changeStrokeShape"
|
||||||
| "changeSloppiness"
|
| "changeSloppiness"
|
||||||
| "changeStrokeStyle"
|
| "changeStrokeStyle"
|
||||||
| "changeArrowhead"
|
| "changeArrowhead"
|
||||||
@@ -55,9 +67,9 @@ export type ActionName =
|
|||||||
| "changeProjectName"
|
| "changeProjectName"
|
||||||
| "changeExportBackground"
|
| "changeExportBackground"
|
||||||
| "changeExportEmbedScene"
|
| "changeExportEmbedScene"
|
||||||
| "changeShouldAddWatermark"
|
| "changeExportScale"
|
||||||
| "saveScene"
|
| "saveToActiveFile"
|
||||||
| "saveAsScene"
|
| "saveFileToDisk"
|
||||||
| "loadScene"
|
| "loadScene"
|
||||||
| "duplicateSelection"
|
| "duplicateSelection"
|
||||||
| "deleteSelectedElements"
|
| "deleteSelectedElements"
|
||||||
@@ -85,22 +97,28 @@ export type ActionName =
|
|||||||
| "alignHorizontallyCentered"
|
| "alignHorizontallyCentered"
|
||||||
| "distributeHorizontally"
|
| "distributeHorizontally"
|
||||||
| "distributeVertically"
|
| "distributeVertically"
|
||||||
|
| "flipHorizontal"
|
||||||
|
| "flipVertical"
|
||||||
| "viewMode"
|
| "viewMode"
|
||||||
| "exportWithDarkMode";
|
| "exportWithDarkMode"
|
||||||
|
| "toggleTheme"
|
||||||
|
| "editImageAlpha";
|
||||||
|
|
||||||
|
export type PanelComponentProps = {
|
||||||
|
elements: readonly ExcalidrawElement[];
|
||||||
|
appState: AppState;
|
||||||
|
updateData: (formData?: any) => void;
|
||||||
|
appProps: ExcalidrawProps;
|
||||||
|
data?: Partial<{ id: string; size: ToolButtonSize }>;
|
||||||
|
};
|
||||||
|
|
||||||
export interface Action {
|
export interface Action {
|
||||||
name: ActionName;
|
name: ActionName;
|
||||||
PanelComponent?: React.FC<{
|
PanelComponent?: React.FC<PanelComponentProps>;
|
||||||
elements: readonly ExcalidrawElement[];
|
|
||||||
appState: AppState;
|
|
||||||
updateData: (formData?: any) => void;
|
|
||||||
appProps: ExcalidrawProps;
|
|
||||||
id?: string;
|
|
||||||
}>;
|
|
||||||
perform: ActionFn;
|
perform: ActionFn;
|
||||||
keyPriority?: number;
|
keyPriority?: number;
|
||||||
keyTest?: (
|
keyTest?: (
|
||||||
event: KeyboardEvent,
|
event: React.KeyboardEvent | KeyboardEvent,
|
||||||
appState: AppState,
|
appState: AppState,
|
||||||
elements: readonly ExcalidrawElement[],
|
elements: readonly ExcalidrawElement[],
|
||||||
) => boolean;
|
) => boolean;
|
||||||
@@ -115,6 +133,7 @@ export interface Action {
|
|||||||
export interface ActionsManagerInterface {
|
export interface ActionsManagerInterface {
|
||||||
actions: Record<ActionName, Action>;
|
actions: Record<ActionName, Action>;
|
||||||
registerAction: (action: Action) => void;
|
registerAction: (action: Action) => void;
|
||||||
handleKeyDown: (event: KeyboardEvent) => boolean;
|
handleKeyDown: (event: React.KeyboardEvent | KeyboardEvent) => boolean;
|
||||||
renderAction: (name: ActionName) => React.ReactElement | null;
|
renderAction: (name: ActionName) => React.ReactElement | null;
|
||||||
|
executeAction: (action: Action) => void;
|
||||||
}
|
}
|
||||||
|
14
src/align.ts
14
src/align.ts
@@ -1,13 +1,6 @@
|
|||||||
import { ExcalidrawElement } from "./element/types";
|
import { ExcalidrawElement } from "./element/types";
|
||||||
import { newElementWith } from "./element/mutateElement";
|
import { newElementWith } from "./element/mutateElement";
|
||||||
import { getCommonBounds } from "./element";
|
import { Box, getCommonBoundingBox } from "./element/bounds";
|
||||||
|
|
||||||
interface Box {
|
|
||||||
minX: number;
|
|
||||||
minY: number;
|
|
||||||
maxX: number;
|
|
||||||
maxY: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface Alignment {
|
export interface Alignment {
|
||||||
position: "start" | "center" | "end";
|
position: "start" | "center" | "end";
|
||||||
@@ -88,8 +81,3 @@ const calculateTranslation = (
|
|||||||
(groupBoundingBox[min] + groupBoundingBox[max]) / 2,
|
(groupBoundingBox[min] + groupBoundingBox[max]) / 2,
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
const getCommonBoundingBox = (elements: ExcalidrawElement[]): Box => {
|
|
||||||
const [minX, minY, maxX, maxY] = getCommonBounds(elements);
|
|
||||||
return { minX, minY, maxX, maxY };
|
|
||||||
};
|
|
||||||
|
173
src/appState.ts
173
src/appState.ts
@@ -3,17 +3,23 @@ import {
|
|||||||
DEFAULT_FONT_FAMILY,
|
DEFAULT_FONT_FAMILY,
|
||||||
DEFAULT_FONT_SIZE,
|
DEFAULT_FONT_SIZE,
|
||||||
DEFAULT_TEXT_ALIGN,
|
DEFAULT_TEXT_ALIGN,
|
||||||
|
EXPORT_SCALES,
|
||||||
|
THEME,
|
||||||
} from "./constants";
|
} from "./constants";
|
||||||
import { t } from "./i18n";
|
import { t } from "./i18n";
|
||||||
import { AppState, NormalizedZoomValue } from "./types";
|
import { AppState, NormalizedZoomValue } from "./types";
|
||||||
import { getDateTime } from "./utils";
|
import { getDateTime } from "./utils";
|
||||||
|
|
||||||
|
const defaultExportScale = EXPORT_SCALES.includes(devicePixelRatio)
|
||||||
|
? devicePixelRatio
|
||||||
|
: 1;
|
||||||
|
|
||||||
export const getDefaultAppState = (): Omit<
|
export const getDefaultAppState = (): Omit<
|
||||||
AppState,
|
AppState,
|
||||||
"offsetTop" | "offsetLeft"
|
"offsetTop" | "offsetLeft" | "width" | "height"
|
||||||
> => {
|
> => {
|
||||||
return {
|
return {
|
||||||
theme: "light",
|
theme: THEME.LIGHT,
|
||||||
collaborators: new Map(),
|
collaborators: new Map(),
|
||||||
currentChartType: "bar",
|
currentChartType: "bar",
|
||||||
currentItemBackgroundColor: "transparent",
|
currentItemBackgroundColor: "transparent",
|
||||||
@@ -35,15 +41,16 @@ export const getDefaultAppState = (): Omit<
|
|||||||
editingElement: null,
|
editingElement: null,
|
||||||
editingGroupId: null,
|
editingGroupId: null,
|
||||||
editingLinearElement: null,
|
editingLinearElement: null,
|
||||||
|
editingImageElement: null,
|
||||||
elementLocked: false,
|
elementLocked: false,
|
||||||
elementType: "selection",
|
elementType: "selection",
|
||||||
errorMessage: null,
|
errorMessage: null,
|
||||||
exportBackground: true,
|
exportBackground: true,
|
||||||
|
exportScale: defaultExportScale,
|
||||||
exportEmbedScene: false,
|
exportEmbedScene: false,
|
||||||
exportWithDarkMode: false,
|
exportWithDarkMode: false,
|
||||||
fileHandle: null,
|
fileHandle: null,
|
||||||
gridSize: null,
|
gridSize: null,
|
||||||
height: window.innerHeight,
|
|
||||||
isBindingEnabled: true,
|
isBindingEnabled: true,
|
||||||
isLibraryOpen: false,
|
isLibraryOpen: false,
|
||||||
isLoading: false,
|
isLoading: false,
|
||||||
@@ -53,6 +60,7 @@ export const getDefaultAppState = (): Omit<
|
|||||||
multiElement: null,
|
multiElement: null,
|
||||||
name: `${t("labels.untitled")}-${getDateTime()}`,
|
name: `${t("labels.untitled")}-${getDateTime()}`,
|
||||||
openMenu: null,
|
openMenu: null,
|
||||||
|
openPopup: null,
|
||||||
pasteDialog: { shown: false, data: null },
|
pasteDialog: { shown: false, data: null },
|
||||||
previousSelectedElementIds: {},
|
previousSelectedElementIds: {},
|
||||||
resizingElement: null,
|
resizingElement: null,
|
||||||
@@ -62,7 +70,6 @@ export const getDefaultAppState = (): Omit<
|
|||||||
selectedElementIds: {},
|
selectedElementIds: {},
|
||||||
selectedGroupIds: {},
|
selectedGroupIds: {},
|
||||||
selectionElement: null,
|
selectionElement: null,
|
||||||
shouldAddWatermark: false,
|
|
||||||
shouldCacheIgnoreZoom: false,
|
shouldCacheIgnoreZoom: false,
|
||||||
showHelpDialog: false,
|
showHelpDialog: false,
|
||||||
showStats: false,
|
showStats: false,
|
||||||
@@ -70,10 +77,10 @@ export const getDefaultAppState = (): Omit<
|
|||||||
suggestedBindings: [],
|
suggestedBindings: [],
|
||||||
toastMessage: null,
|
toastMessage: null,
|
||||||
viewBackgroundColor: oc.white,
|
viewBackgroundColor: oc.white,
|
||||||
width: window.innerWidth,
|
|
||||||
zenModeEnabled: false,
|
zenModeEnabled: false,
|
||||||
zoom: { value: 1 as NormalizedZoomValue, translation: { x: 0, y: 0 } },
|
zoom: { value: 1 as NormalizedZoomValue, translation: { x: 0, y: 0 } },
|
||||||
viewModeEnabled: false,
|
viewModeEnabled: false,
|
||||||
|
pendingImageElement: null,
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -87,77 +94,87 @@ const APP_STATE_STORAGE_CONF = (<
|
|||||||
browser: boolean;
|
browser: boolean;
|
||||||
/** whether to keep when exporting to file/database */
|
/** whether to keep when exporting to file/database */
|
||||||
export: boolean;
|
export: boolean;
|
||||||
|
/** server (shareLink/collab/...) */
|
||||||
|
server: boolean;
|
||||||
},
|
},
|
||||||
T extends Record<keyof AppState, Values>
|
T extends Record<keyof AppState, Values>,
|
||||||
>(
|
>(config: { [K in keyof T]: K extends keyof AppState ? T[K] : never }) =>
|
||||||
config: { [K in keyof T]: K extends keyof AppState ? T[K] : never },
|
config)({
|
||||||
) => config)({
|
theme: { browser: true, export: false, server: false },
|
||||||
theme: { browser: true, export: false },
|
collaborators: { browser: false, export: false, server: false },
|
||||||
collaborators: { browser: false, export: false },
|
currentChartType: { browser: true, export: false, server: false },
|
||||||
currentChartType: { browser: true, export: false },
|
currentItemBackgroundColor: { browser: true, export: false, server: false },
|
||||||
currentItemBackgroundColor: { browser: true, export: false },
|
currentItemEndArrowhead: { browser: true, export: false, server: false },
|
||||||
currentItemEndArrowhead: { browser: true, export: false },
|
currentItemFillStyle: { browser: true, export: false, server: false },
|
||||||
currentItemFillStyle: { browser: true, export: false },
|
currentItemFontFamily: { browser: true, export: false, server: false },
|
||||||
currentItemFontFamily: { browser: true, export: false },
|
currentItemFontSize: { browser: true, export: false, server: false },
|
||||||
currentItemFontSize: { browser: true, export: false },
|
currentItemLinearStrokeSharpness: {
|
||||||
currentItemLinearStrokeSharpness: { browser: true, export: false },
|
browser: true,
|
||||||
currentItemOpacity: { browser: true, export: false },
|
export: false,
|
||||||
currentItemRoughness: { browser: true, export: false },
|
server: false,
|
||||||
currentItemStartArrowhead: { browser: true, export: false },
|
},
|
||||||
currentItemStrokeColor: { browser: true, export: false },
|
currentItemOpacity: { browser: true, export: false, server: false },
|
||||||
currentItemStrokeSharpness: { browser: true, export: false },
|
currentItemRoughness: { browser: true, export: false, server: false },
|
||||||
currentItemStrokeStyle: { browser: true, export: false },
|
currentItemStartArrowhead: { browser: true, export: false, server: false },
|
||||||
currentItemStrokeWidth: { browser: true, export: false },
|
currentItemStrokeColor: { browser: true, export: false, server: false },
|
||||||
currentItemTextAlign: { browser: true, export: false },
|
currentItemStrokeSharpness: { browser: true, export: false, server: false },
|
||||||
cursorButton: { browser: true, export: false },
|
currentItemStrokeStyle: { browser: true, export: false, server: false },
|
||||||
draggingElement: { browser: false, export: false },
|
currentItemStrokeWidth: { browser: true, export: false, server: false },
|
||||||
editingElement: { browser: false, export: false },
|
currentItemTextAlign: { browser: true, export: false, server: false },
|
||||||
editingGroupId: { browser: true, export: false },
|
cursorButton: { browser: true, export: false, server: false },
|
||||||
editingLinearElement: { browser: false, export: false },
|
draggingElement: { browser: false, export: false, server: false },
|
||||||
elementLocked: { browser: true, export: false },
|
editingElement: { browser: false, export: false, server: false },
|
||||||
elementType: { browser: true, export: false },
|
editingGroupId: { browser: true, export: false, server: false },
|
||||||
errorMessage: { browser: false, export: false },
|
editingLinearElement: { browser: false, export: false, server: false },
|
||||||
exportBackground: { browser: true, export: false },
|
editingImageElement: { browser: false, export: false, server: false },
|
||||||
exportEmbedScene: { browser: true, export: false },
|
elementLocked: { browser: true, export: false, server: false },
|
||||||
exportWithDarkMode: { browser: true, export: false },
|
elementType: { browser: true, export: false, server: false },
|
||||||
fileHandle: { browser: false, export: false },
|
errorMessage: { browser: false, export: false, server: false },
|
||||||
gridSize: { browser: true, export: true },
|
exportBackground: { browser: true, export: false, server: false },
|
||||||
height: { browser: false, export: false },
|
exportEmbedScene: { browser: true, export: false, server: false },
|
||||||
isBindingEnabled: { browser: false, export: false },
|
exportScale: { browser: true, export: false, server: false },
|
||||||
isLibraryOpen: { browser: false, export: false },
|
exportWithDarkMode: { browser: true, export: false, server: false },
|
||||||
isLoading: { browser: false, export: false },
|
fileHandle: { browser: false, export: false, server: false },
|
||||||
isResizing: { browser: false, export: false },
|
gridSize: { browser: true, export: true, server: true },
|
||||||
isRotating: { browser: false, export: false },
|
height: { browser: false, export: false, server: false },
|
||||||
lastPointerDownWith: { browser: true, export: false },
|
isBindingEnabled: { browser: false, export: false, server: false },
|
||||||
multiElement: { browser: false, export: false },
|
isLibraryOpen: { browser: false, export: false, server: false },
|
||||||
name: { browser: true, export: false },
|
isLoading: { browser: false, export: false, server: false },
|
||||||
offsetLeft: { browser: false, export: false },
|
isResizing: { browser: false, export: false, server: false },
|
||||||
offsetTop: { browser: false, export: false },
|
isRotating: { browser: false, export: false, server: false },
|
||||||
openMenu: { browser: true, export: false },
|
lastPointerDownWith: { browser: true, export: false, server: false },
|
||||||
pasteDialog: { browser: false, export: false },
|
multiElement: { browser: false, export: false, server: false },
|
||||||
previousSelectedElementIds: { browser: true, export: false },
|
name: { browser: true, export: false, server: false },
|
||||||
resizingElement: { browser: false, export: false },
|
offsetLeft: { browser: false, export: false, server: false },
|
||||||
scrolledOutside: { browser: true, export: false },
|
offsetTop: { browser: false, export: false, server: false },
|
||||||
scrollX: { browser: true, export: false },
|
openMenu: { browser: true, export: false, server: false },
|
||||||
scrollY: { browser: true, export: false },
|
openPopup: { browser: false, export: false, server: false },
|
||||||
selectedElementIds: { browser: true, export: false },
|
pasteDialog: { browser: false, export: false, server: false },
|
||||||
selectedGroupIds: { browser: true, export: false },
|
previousSelectedElementIds: { browser: true, export: false, server: false },
|
||||||
selectionElement: { browser: false, export: false },
|
resizingElement: { browser: false, export: false, server: false },
|
||||||
shouldAddWatermark: { browser: true, export: false },
|
scrolledOutside: { browser: true, export: false, server: false },
|
||||||
shouldCacheIgnoreZoom: { browser: true, export: false },
|
scrollX: { browser: true, export: false, server: false },
|
||||||
showHelpDialog: { browser: false, export: false },
|
scrollY: { browser: true, export: false, server: false },
|
||||||
showStats: { browser: true, export: false },
|
selectedElementIds: { browser: true, export: false, server: false },
|
||||||
startBoundElement: { browser: false, export: false },
|
selectedGroupIds: { browser: true, export: false, server: false },
|
||||||
suggestedBindings: { browser: false, export: false },
|
selectionElement: { browser: false, export: false, server: false },
|
||||||
toastMessage: { browser: false, export: false },
|
shouldCacheIgnoreZoom: { browser: true, export: false, server: false },
|
||||||
viewBackgroundColor: { browser: true, export: true },
|
showHelpDialog: { browser: false, export: false, server: false },
|
||||||
width: { browser: false, export: false },
|
showStats: { browser: true, export: false, server: false },
|
||||||
zenModeEnabled: { browser: true, export: false },
|
startBoundElement: { browser: false, export: false, server: false },
|
||||||
zoom: { browser: true, export: false },
|
suggestedBindings: { browser: false, export: false, server: false },
|
||||||
viewModeEnabled: { browser: false, export: false },
|
toastMessage: { browser: false, export: false, server: false },
|
||||||
|
viewBackgroundColor: { browser: true, export: true, server: true },
|
||||||
|
width: { browser: false, export: false, server: false },
|
||||||
|
zenModeEnabled: { browser: true, export: false, server: false },
|
||||||
|
zoom: { browser: true, export: false, server: false },
|
||||||
|
viewModeEnabled: { browser: false, export: false, server: false },
|
||||||
|
pendingImageElement: { browser: false, export: false, server: false },
|
||||||
});
|
});
|
||||||
|
|
||||||
const _clearAppStateForStorage = <ExportType extends "export" | "browser">(
|
const _clearAppStateForStorage = <
|
||||||
|
ExportType extends "export" | "browser" | "server",
|
||||||
|
>(
|
||||||
appState: Partial<AppState>,
|
appState: Partial<AppState>,
|
||||||
exportType: ExportType,
|
exportType: ExportType,
|
||||||
) => {
|
) => {
|
||||||
@@ -170,8 +187,10 @@ const _clearAppStateForStorage = <ExportType extends "export" | "browser">(
|
|||||||
for (const key of Object.keys(appState) as (keyof typeof appState)[]) {
|
for (const key of Object.keys(appState) as (keyof typeof appState)[]) {
|
||||||
const propConfig = APP_STATE_STORAGE_CONF[key];
|
const propConfig = APP_STATE_STORAGE_CONF[key];
|
||||||
if (propConfig?.[exportType]) {
|
if (propConfig?.[exportType]) {
|
||||||
// @ts-ignore see https://github.com/microsoft/TypeScript/issues/31445
|
const nextValue = appState[key];
|
||||||
stateForExport[key] = appState[key];
|
|
||||||
|
// https://github.com/microsoft/TypeScript/issues/31445
|
||||||
|
(stateForExport as any)[key] = nextValue;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return stateForExport;
|
return stateForExport;
|
||||||
@@ -184,3 +203,7 @@ export const clearAppStateForLocalStorage = (appState: Partial<AppState>) => {
|
|||||||
export const cleanAppStateForExport = (appState: Partial<AppState>) => {
|
export const cleanAppStateForExport = (appState: Partial<AppState>) => {
|
||||||
return _clearAppStateForStorage(appState, "export");
|
return _clearAppStateForStorage(appState, "export");
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const clearAppStateForDatabase = (appState: Partial<AppState>) => {
|
||||||
|
return _clearAppStateForStorage(appState, "server");
|
||||||
|
};
|
||||||
|
@@ -3,17 +3,26 @@ import {
|
|||||||
NonDeletedExcalidrawElement,
|
NonDeletedExcalidrawElement,
|
||||||
} from "./element/types";
|
} from "./element/types";
|
||||||
import { getSelectedElements } from "./scene";
|
import { getSelectedElements } from "./scene";
|
||||||
import { AppState } from "./types";
|
import { AppState, BinaryFiles } from "./types";
|
||||||
import { SVG_EXPORT_TAG } from "./scene/export";
|
import { SVG_EXPORT_TAG } from "./scene/export";
|
||||||
import { tryParseSpreadsheet, Spreadsheet, VALID_SPREADSHEET } from "./charts";
|
import { tryParseSpreadsheet, Spreadsheet, VALID_SPREADSHEET } from "./charts";
|
||||||
import { canvasToBlob } from "./data/blob";
|
import { EXPORT_DATA_TYPES, MIME_TYPES } from "./constants";
|
||||||
import { EXPORT_DATA_TYPES } from "./constants";
|
import { isInitializedImageElement } from "./element/typeChecks";
|
||||||
|
|
||||||
type ElementsClipboard = {
|
type ElementsClipboard = {
|
||||||
type: typeof EXPORT_DATA_TYPES.excalidrawClipboard;
|
type: typeof EXPORT_DATA_TYPES.excalidrawClipboard;
|
||||||
elements: ExcalidrawElement[];
|
elements: ExcalidrawElement[];
|
||||||
|
files: BinaryFiles | undefined;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export interface ClipboardData {
|
||||||
|
spreadsheet?: Spreadsheet;
|
||||||
|
elements?: readonly ExcalidrawElement[];
|
||||||
|
files?: BinaryFiles;
|
||||||
|
text?: string;
|
||||||
|
errorMessage?: string;
|
||||||
|
}
|
||||||
|
|
||||||
let CLIPBOARD = "";
|
let CLIPBOARD = "";
|
||||||
let PREFER_APP_CLIPBOARD = false;
|
let PREFER_APP_CLIPBOARD = false;
|
||||||
|
|
||||||
@@ -31,7 +40,7 @@ export const probablySupportsClipboardBlob =
|
|||||||
|
|
||||||
const clipboardContainsElements = (
|
const clipboardContainsElements = (
|
||||||
contents: any,
|
contents: any,
|
||||||
): contents is { elements: ExcalidrawElement[] } => {
|
): contents is { elements: ExcalidrawElement[]; files?: BinaryFiles } => {
|
||||||
if (
|
if (
|
||||||
[
|
[
|
||||||
EXPORT_DATA_TYPES.excalidraw,
|
EXPORT_DATA_TYPES.excalidraw,
|
||||||
@@ -47,17 +56,26 @@ const clipboardContainsElements = (
|
|||||||
export const copyToClipboard = async (
|
export const copyToClipboard = async (
|
||||||
elements: readonly NonDeletedExcalidrawElement[],
|
elements: readonly NonDeletedExcalidrawElement[],
|
||||||
appState: AppState,
|
appState: AppState,
|
||||||
|
files: BinaryFiles,
|
||||||
) => {
|
) => {
|
||||||
|
// select binded text elements when copying
|
||||||
|
const selectedElements = getSelectedElements(elements, appState, true);
|
||||||
const contents: ElementsClipboard = {
|
const contents: ElementsClipboard = {
|
||||||
type: EXPORT_DATA_TYPES.excalidrawClipboard,
|
type: EXPORT_DATA_TYPES.excalidrawClipboard,
|
||||||
elements: getSelectedElements(elements, appState),
|
elements: selectedElements,
|
||||||
|
files: selectedElements.reduce((acc, element) => {
|
||||||
|
if (isInitializedImageElement(element) && files[element.fileId]) {
|
||||||
|
acc[element.fileId] = files[element.fileId];
|
||||||
|
}
|
||||||
|
return acc;
|
||||||
|
}, {} as BinaryFiles),
|
||||||
};
|
};
|
||||||
const json = JSON.stringify(contents);
|
const json = JSON.stringify(contents);
|
||||||
CLIPBOARD = json;
|
CLIPBOARD = json;
|
||||||
try {
|
try {
|
||||||
PREFER_APP_CLIPBOARD = false;
|
PREFER_APP_CLIPBOARD = false;
|
||||||
await copyTextToSystemClipboard(json);
|
await copyTextToSystemClipboard(json);
|
||||||
} catch (error) {
|
} catch (error: any) {
|
||||||
PREFER_APP_CLIPBOARD = true;
|
PREFER_APP_CLIPBOARD = true;
|
||||||
console.error(error);
|
console.error(error);
|
||||||
}
|
}
|
||||||
@@ -70,7 +88,7 @@ const getAppClipboard = (): Partial<ElementsClipboard> => {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
return JSON.parse(CLIPBOARD);
|
return JSON.parse(CLIPBOARD);
|
||||||
} catch (error) {
|
} catch (error: any) {
|
||||||
console.error(error);
|
console.error(error);
|
||||||
return {};
|
return {};
|
||||||
}
|
}
|
||||||
@@ -110,12 +128,7 @@ const getSystemClipboard = async (
|
|||||||
*/
|
*/
|
||||||
export const parseClipboard = async (
|
export const parseClipboard = async (
|
||||||
event: ClipboardEvent | null,
|
event: ClipboardEvent | null,
|
||||||
): Promise<{
|
): Promise<ClipboardData> => {
|
||||||
spreadsheet?: Spreadsheet;
|
|
||||||
elements?: readonly ExcalidrawElement[];
|
|
||||||
text?: string;
|
|
||||||
errorMessage?: string;
|
|
||||||
}> => {
|
|
||||||
const systemClipboard = await getSystemClipboard(event);
|
const systemClipboard = await getSystemClipboard(event);
|
||||||
|
|
||||||
// if system clipboard empty, couldn't be resolved, or contains previously
|
// if system clipboard empty, couldn't be resolved, or contains previously
|
||||||
@@ -137,7 +150,10 @@ export const parseClipboard = async (
|
|||||||
try {
|
try {
|
||||||
const systemClipboardData = JSON.parse(systemClipboard);
|
const systemClipboardData = JSON.parse(systemClipboard);
|
||||||
if (clipboardContainsElements(systemClipboardData)) {
|
if (clipboardContainsElements(systemClipboardData)) {
|
||||||
return { elements: systemClipboardData.elements };
|
return {
|
||||||
|
elements: systemClipboardData.elements,
|
||||||
|
files: systemClipboardData.files,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
return appClipboardData;
|
return appClipboardData;
|
||||||
} catch {
|
} catch {
|
||||||
@@ -150,10 +166,9 @@ export const parseClipboard = async (
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
export const copyCanvasToClipboardAsPng = async (canvas: HTMLCanvasElement) => {
|
export const copyBlobToClipboardAsPng = async (blob: Blob) => {
|
||||||
const blob = await canvasToBlob(canvas);
|
|
||||||
await navigator.clipboard.write([
|
await navigator.clipboard.write([
|
||||||
new window.ClipboardItem({ "image/png": blob }),
|
new window.ClipboardItem({ [MIME_TYPES.png]: blob }),
|
||||||
]);
|
]);
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -165,7 +180,7 @@ export const copyTextToSystemClipboard = async (text: string | null) => {
|
|||||||
// not focused
|
// not focused
|
||||||
await navigator.clipboard.writeText(text || "");
|
await navigator.clipboard.writeText(text || "");
|
||||||
copied = true;
|
copied = true;
|
||||||
} catch (error) {
|
} catch (error: any) {
|
||||||
console.error(error);
|
console.error(error);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -205,7 +220,7 @@ const copyTextViaExecCommand = (text: string) => {
|
|||||||
textarea.setSelectionRange(0, textarea.value.length);
|
textarea.setSelectionRange(0, textarea.value.length);
|
||||||
|
|
||||||
success = document.execCommand("copy");
|
success = document.execCommand("copy");
|
||||||
} catch (error) {
|
} catch (error: any) {
|
||||||
console.error(error);
|
console.error(error);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@@ -1,15 +1,16 @@
|
|||||||
import React from "react";
|
import React from "react";
|
||||||
import { ActionManager } from "../actions/manager";
|
import { ActionManager } from "../actions/manager";
|
||||||
import { getNonDeletedElements } from "../element";
|
import { getNonDeletedElements } from "../element";
|
||||||
import { ExcalidrawElement } from "../element/types";
|
import { ExcalidrawElement, PointerType } from "../element/types";
|
||||||
import { t } from "../i18n";
|
import { t } from "../i18n";
|
||||||
import useIsMobile from "../is-mobile";
|
import { useIsMobile } from "../components/App";
|
||||||
import {
|
import {
|
||||||
canChangeSharpness,
|
canChangeSharpness,
|
||||||
canHaveArrowheads,
|
canHaveArrowheads,
|
||||||
getTargetElements,
|
getTargetElements,
|
||||||
hasBackground,
|
hasBackground,
|
||||||
hasStroke,
|
hasStrokeStyle,
|
||||||
|
hasStrokeWidth,
|
||||||
hasText,
|
hasText,
|
||||||
} from "../scene";
|
} from "../scene";
|
||||||
import { SHAPES } from "../shapes";
|
import { SHAPES } from "../shapes";
|
||||||
@@ -17,6 +18,8 @@ import { AppState, Zoom } from "../types";
|
|||||||
import { capitalizeString, isTransparent, setCursorForShape } from "../utils";
|
import { capitalizeString, isTransparent, setCursorForShape } from "../utils";
|
||||||
import Stack from "./Stack";
|
import Stack from "./Stack";
|
||||||
import { ToolButton } from "./ToolButton";
|
import { ToolButton } from "./ToolButton";
|
||||||
|
import { hasStrokeColor } from "../scene/comparisons";
|
||||||
|
import { isImageElement } from "../element/typeChecks";
|
||||||
|
|
||||||
export const SelectedShapeActions = ({
|
export const SelectedShapeActions = ({
|
||||||
appState,
|
appState,
|
||||||
@@ -47,16 +50,36 @@ export const SelectedShapeActions = ({
|
|||||||
hasBackground(elementType) ||
|
hasBackground(elementType) ||
|
||||||
targetElements.some((element) => hasBackground(element.type));
|
targetElements.some((element) => hasBackground(element.type));
|
||||||
|
|
||||||
|
let commonSelectedType: string | null = targetElements[0]?.type || null;
|
||||||
|
|
||||||
|
for (const element of targetElements) {
|
||||||
|
if (element.type !== commonSelectedType) {
|
||||||
|
commonSelectedType = null;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="panelColumn">
|
<div className="panelColumn">
|
||||||
{renderAction("changeStrokeColor")}
|
{((hasStrokeColor(elementType) &&
|
||||||
|
elementType !== "image" &&
|
||||||
|
commonSelectedType !== "image") ||
|
||||||
|
targetElements.some((element) => hasStrokeColor(element.type))) &&
|
||||||
|
renderAction("changeStrokeColor")}
|
||||||
{showChangeBackgroundIcons && renderAction("changeBackgroundColor")}
|
{showChangeBackgroundIcons && renderAction("changeBackgroundColor")}
|
||||||
{showFillIcons && renderAction("changeFillStyle")}
|
{showFillIcons && renderAction("changeFillStyle")}
|
||||||
|
|
||||||
{(hasStroke(elementType) ||
|
{(hasStrokeWidth(elementType) ||
|
||||||
targetElements.some((element) => hasStroke(element.type))) && (
|
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("changeStrokeStyle")}
|
||||||
{renderAction("changeSloppiness")}
|
{renderAction("changeSloppiness")}
|
||||||
</>
|
</>
|
||||||
@@ -83,6 +106,13 @@ export const SelectedShapeActions = ({
|
|||||||
<>{renderAction("changeArrowhead")}</>
|
<>{renderAction("changeArrowhead")}</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
<fieldset>
|
||||||
|
<div className="buttonList">
|
||||||
|
{targetElements.some((element) => isImageElement(element)) &&
|
||||||
|
renderAction("editImageAlpha")}
|
||||||
|
</div>
|
||||||
|
</fieldset>
|
||||||
|
|
||||||
{renderAction("changeOpacity")}
|
{renderAction("changeOpacity")}
|
||||||
|
|
||||||
<fieldset>
|
<fieldset>
|
||||||
@@ -143,31 +173,24 @@ 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 = ({
|
export const ShapesSwitcher = ({
|
||||||
canvas,
|
canvas,
|
||||||
elementType,
|
elementType,
|
||||||
setAppState,
|
setAppState,
|
||||||
isLibraryOpen,
|
onImageAction,
|
||||||
}: {
|
}: {
|
||||||
canvas: HTMLCanvasElement | null;
|
canvas: HTMLCanvasElement | null;
|
||||||
elementType: ExcalidrawElement["type"];
|
elementType: ExcalidrawElement["type"];
|
||||||
setAppState: React.Component<any, AppState>["setState"];
|
setAppState: React.Component<any, AppState>["setState"];
|
||||||
isLibraryOpen: boolean;
|
onImageAction: (data: { pointerType: PointerType | null }) => void;
|
||||||
}) => (
|
}) => (
|
||||||
<>
|
<>
|
||||||
{SHAPES.map(({ value, icon, key }, index) => {
|
{SHAPES.map(({ value, icon, key }, index) => {
|
||||||
const label = t(`toolBar.${value}`);
|
const label = t(`toolBar.${value}`);
|
||||||
const letter = typeof key === "string" ? key : key[0];
|
const letter = key && (typeof key === "string" ? key : key[0]);
|
||||||
const shortcut = `${capitalizeString(letter)} ${t("helpDialog.or")} ${
|
const shortcut = letter
|
||||||
index + 1
|
? `${capitalizeString(letter)} ${t("helpDialog.or")} ${index + 1}`
|
||||||
}`;
|
: `${index + 1}`;
|
||||||
return (
|
return (
|
||||||
<ToolButton
|
<ToolButton
|
||||||
className="Shape"
|
className="Shape"
|
||||||
@@ -181,31 +204,20 @@ export const ShapesSwitcher = ({
|
|||||||
aria-label={capitalizeString(label)}
|
aria-label={capitalizeString(label)}
|
||||||
aria-keyshortcuts={shortcut}
|
aria-keyshortcuts={shortcut}
|
||||||
data-testid={value}
|
data-testid={value}
|
||||||
onChange={() => {
|
onChange={({ pointerType }) => {
|
||||||
setAppState({
|
setAppState({
|
||||||
elementType: value,
|
elementType: value,
|
||||||
multiElement: null,
|
multiElement: null,
|
||||||
selectedElementIds: {},
|
selectedElementIds: {},
|
||||||
});
|
});
|
||||||
setCursorForShape(canvas, value);
|
setCursorForShape(canvas, value);
|
||||||
setAppState({});
|
if (value === "image") {
|
||||||
|
onImageAction({ pointerType });
|
||||||
|
}
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
<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 +230,9 @@ export const ZoomActions = ({
|
|||||||
}) => (
|
}) => (
|
||||||
<Stack.Col gap={1}>
|
<Stack.Col gap={1}>
|
||||||
<Stack.Row gap={1} align="center">
|
<Stack.Row gap={1} align="center">
|
||||||
{renderAction("zoomIn")}
|
|
||||||
{renderAction("zoomOut")}
|
{renderAction("zoomOut")}
|
||||||
|
{renderAction("zoomIn")}
|
||||||
{renderAction("resetZoom")}
|
{renderAction("resetZoom")}
|
||||||
<div style={{ marginInlineStart: 4 }}>
|
|
||||||
{(zoom.value * 100).toFixed(0)}%
|
|
||||||
</div>
|
|
||||||
</Stack.Row>
|
</Stack.Row>
|
||||||
</Stack.Col>
|
</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 React from "react";
|
||||||
import { ActionManager } from "../actions/manager";
|
import { ActionManager } from "../actions/manager";
|
||||||
import { AppState } from "../types";
|
import { AppState } from "../types";
|
||||||
import { DarkModeToggle } from "./DarkModeToggle";
|
|
||||||
|
|
||||||
export const BackgroundPickerAndDarkModeToggle = ({
|
export const BackgroundPickerAndDarkModeToggle = ({
|
||||||
appState,
|
appState,
|
||||||
@@ -16,15 +15,6 @@ export const BackgroundPickerAndDarkModeToggle = ({
|
|||||||
}) => (
|
}) => (
|
||||||
<div style={{ display: "flex" }}>
|
<div style={{ display: "flex" }}>
|
||||||
{actionManager.renderAction("changeViewBackgroundColor")}
|
{actionManager.renderAction("changeViewBackgroundColor")}
|
||||||
{showThemeBtn && (
|
{showThemeBtn && actionManager.renderAction("toggleTheme")}
|
||||||
<div style={{ marginInlineStart: "0.25rem" }}>
|
|
||||||
<DarkModeToggle
|
|
||||||
value={appState.theme}
|
|
||||||
onChange={(theme) => {
|
|
||||||
setAppState({ theme });
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
@@ -1,4 +1,3 @@
|
|||||||
import React from "react";
|
|
||||||
import clsx from "clsx";
|
import clsx from "clsx";
|
||||||
|
|
||||||
export const ButtonIconCycle = <T extends any>({
|
export const ButtonIconCycle = <T extends any>({
|
||||||
@@ -14,11 +13,11 @@ export const ButtonIconCycle = <T extends any>({
|
|||||||
}) => {
|
}) => {
|
||||||
const current = options.find((op) => op.value === value);
|
const current = options.find((op) => op.value === value);
|
||||||
|
|
||||||
function cycle() {
|
const cycle = () => {
|
||||||
const index = options.indexOf(current!);
|
const index = options.indexOf(current!);
|
||||||
const next = (index + 1) % options.length;
|
const next = (index + 1) % options.length;
|
||||||
onChange(options[next].value);
|
onChange(options[next].value);
|
||||||
}
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<label key={group} className={clsx({ active: current!.value !== null })}>
|
<label key={group} className={clsx({ active: current!.value !== null })}>
|
||||||
|
@@ -1,4 +1,3 @@
|
|||||||
import React from "react";
|
|
||||||
import clsx from "clsx";
|
import clsx from "clsx";
|
||||||
|
|
||||||
// TODO: It might be "clever" to add option.icon to the existing component <ButtonSelect />
|
// 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";
|
import clsx from "clsx";
|
||||||
|
|
||||||
export const ButtonSelect = <T extends Object>({
|
export const ButtonSelect = <T extends Object>({
|
||||||
|
57
src/components/Card.scss
Normal file
57
src/components/Card.scss
Normal file
@@ -0,0 +1,57 @@
|
|||||||
|
@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;
|
||||||
|
}
|
||||||
|
|
||||||
|
.Spinner {
|
||||||
|
--spinner-color: #fff;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
27
src/components/Card.tsx
Normal file
27
src/components/Card.tsx
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
import OpenColor from "open-color";
|
||||||
|
|
||||||
|
import "./Card.scss";
|
||||||
|
|
||||||
|
export const Card: React.FC<{
|
||||||
|
color: keyof OpenColor | "primary";
|
||||||
|
}> = ({ children, color }) => {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="Card"
|
||||||
|
style={{
|
||||||
|
["--card-color" as any]:
|
||||||
|
color === "primary" ? "var(--color-primary)" : OpenColor[color][7],
|
||||||
|
["--card-color-darker" as any]:
|
||||||
|
color === "primary"
|
||||||
|
? "var(--color-primary-darker)"
|
||||||
|
: OpenColor[color][8],
|
||||||
|
["--card-color-darkest" as any]:
|
||||||
|
color === "primary"
|
||||||
|
? "var(--color-primary-darkest)"
|
||||||
|
: 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
30
src/components/CheckboxItem.tsx
Normal file
30
src/components/CheckboxItem.tsx
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
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, event: React.MouseEvent) => void;
|
||||||
|
className?: string;
|
||||||
|
}> = ({ children, checked, onChange, className }) => {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={clsx("Checkbox", className, { "is-checked": checked })}
|
||||||
|
onClick={(event) => {
|
||||||
|
onChange(!checked, event);
|
||||||
|
(
|
||||||
|
(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>
|
||||||
|
);
|
||||||
|
};
|
43
src/components/ClearCanvas.tsx
Normal file
43
src/components/ClearCanvas.tsx
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
import { useState } from "react";
|
||||||
|
import { t } from "../i18n";
|
||||||
|
import { useIsMobile } from "./App";
|
||||||
|
import { trash } from "./icons";
|
||||||
|
import { ToolButton } from "./ToolButton";
|
||||||
|
|
||||||
|
import ConfirmDialog from "./ConfirmDialog";
|
||||||
|
|
||||||
|
const ClearCanvas = ({ onConfirm }: { onConfirm: () => void }) => {
|
||||||
|
const [showDialog, setShowDialog] = useState(false);
|
||||||
|
const toggleDialog = () => {
|
||||||
|
setShowDialog(!showDialog);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<ToolButton
|
||||||
|
type="button"
|
||||||
|
icon={trash}
|
||||||
|
title={t("buttons.clearReset")}
|
||||||
|
aria-label={t("buttons.clearReset")}
|
||||||
|
showAriaLabel={useIsMobile()}
|
||||||
|
onClick={toggleDialog}
|
||||||
|
data-testid="clear-canvas-button"
|
||||||
|
/>
|
||||||
|
|
||||||
|
{showDialog && (
|
||||||
|
<ConfirmDialog
|
||||||
|
onConfirm={() => {
|
||||||
|
onConfirm();
|
||||||
|
toggleDialog();
|
||||||
|
}}
|
||||||
|
onCancel={toggleDialog}
|
||||||
|
title={t("clearCanvasDialog.title")}
|
||||||
|
>
|
||||||
|
<p className="clear-canvas__content"> {t("alerts.clearReset")}</p>
|
||||||
|
</ConfirmDialog>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default ClearCanvas;
|
@@ -1,8 +1,7 @@
|
|||||||
import React from "react";
|
|
||||||
import clsx from "clsx";
|
import clsx from "clsx";
|
||||||
import { ToolButton } from "./ToolButton";
|
import { ToolButton } from "./ToolButton";
|
||||||
import { t } from "../i18n";
|
import { t } from "../i18n";
|
||||||
import useIsMobile from "../is-mobile";
|
import { useIsMobile } from "../components/App";
|
||||||
import { users } from "./icons";
|
import { users } from "./icons";
|
||||||
|
|
||||||
import "./CollabButton.scss";
|
import "./CollabButton.scss";
|
||||||
|
@@ -160,7 +160,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.color-picker-input {
|
.color-picker-input {
|
||||||
width: 12ch; /* length of `transparent` + 1 */
|
width: 11ch; /* length of `transparent` */
|
||||||
margin: 0;
|
margin: 0;
|
||||||
font-size: 1rem;
|
font-size: 1rem;
|
||||||
background-color: var(--input-bg-color);
|
background-color: var(--input-bg-color);
|
||||||
@@ -218,7 +218,7 @@
|
|||||||
left: 2px;
|
left: 2px;
|
||||||
}
|
}
|
||||||
|
|
||||||
@media #{$is-mobile-query} {
|
@include isMobile {
|
||||||
display: none;
|
display: none;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@@ -1,5 +1,6 @@
|
|||||||
import React from "react";
|
import React from "react";
|
||||||
import { Popover } from "./Popover";
|
import { Popover } from "./Popover";
|
||||||
|
import { isTransparent } from "../utils";
|
||||||
|
|
||||||
import "./ColorPicker.scss";
|
import "./ColorPicker.scss";
|
||||||
import { isArrowKey, KEYS } from "../keys";
|
import { isArrowKey, KEYS } from "../keys";
|
||||||
@@ -14,7 +15,7 @@ const isValidColor = (color: string) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const getColor = (color: string): string | null => {
|
const getColor = (color: string): string | null => {
|
||||||
if (color === "transparent") {
|
if (isTransparent(color)) {
|
||||||
return color;
|
return color;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -115,6 +116,7 @@ const Picker = ({
|
|||||||
onClose();
|
onClose();
|
||||||
}
|
}
|
||||||
event.nativeEvent.stopImmediatePropagation();
|
event.nativeEvent.stopImmediatePropagation();
|
||||||
|
event.stopPropagation();
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -136,36 +138,41 @@ const Picker = ({
|
|||||||
}}
|
}}
|
||||||
tabIndex={0}
|
tabIndex={0}
|
||||||
>
|
>
|
||||||
{colors.map((_color, i) => (
|
{colors.map((_color, i) => {
|
||||||
<button
|
const _colorWithoutHash = _color.replace("#", "");
|
||||||
className="color-picker-swatch"
|
return (
|
||||||
onClick={(event) => {
|
<button
|
||||||
(event.currentTarget as HTMLButtonElement).focus();
|
className="color-picker-swatch"
|
||||||
onChange(_color);
|
onClick={(event) => {
|
||||||
}}
|
(event.currentTarget as HTMLButtonElement).focus();
|
||||||
title={`${_color} — ${keyBindings[i].toUpperCase()}`}
|
onChange(_color);
|
||||||
aria-label={_color}
|
}}
|
||||||
aria-keyshortcuts={keyBindings[i]}
|
title={`${t(`colors.${_colorWithoutHash}`)}${
|
||||||
style={{ color: _color }}
|
!isTransparent(_color) ? ` (${_color})` : ""
|
||||||
key={_color}
|
} — ${keyBindings[i].toUpperCase()}`}
|
||||||
ref={(el) => {
|
aria-label={t(`colors.${_colorWithoutHash}`)}
|
||||||
if (el && i === 0) {
|
aria-keyshortcuts={keyBindings[i]}
|
||||||
firstItem.current = el;
|
style={{ color: _color }}
|
||||||
}
|
key={_color}
|
||||||
if (el && _color === color) {
|
ref={(el) => {
|
||||||
activeItem.current = el;
|
if (el && i === 0) {
|
||||||
}
|
firstItem.current = el;
|
||||||
}}
|
}
|
||||||
onFocus={() => {
|
if (el && _color === color) {
|
||||||
onChange(_color);
|
activeItem.current = el;
|
||||||
}}
|
}
|
||||||
>
|
}}
|
||||||
{_color === "transparent" ? (
|
onFocus={() => {
|
||||||
<div className="color-picker-transparent"></div>
|
onChange(_color);
|
||||||
) : undefined}
|
}}
|
||||||
<span className="color-picker-keybinding">{keyBindings[i]}</span>
|
>
|
||||||
</button>
|
{isTransparent(_color) ? (
|
||||||
))}
|
<div className="color-picker-transparent"></div>
|
||||||
|
) : undefined}
|
||||||
|
<span className="color-picker-keybinding">{keyBindings[i]}</span>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
{showInput && (
|
{showInput && (
|
||||||
<ColorInput
|
<ColorInput
|
||||||
color={color}
|
color={color}
|
||||||
@@ -237,13 +244,16 @@ export const ColorPicker = ({
|
|||||||
color,
|
color,
|
||||||
onChange,
|
onChange,
|
||||||
label,
|
label,
|
||||||
|
isActive,
|
||||||
|
setActive,
|
||||||
}: {
|
}: {
|
||||||
type: "canvasBackground" | "elementBackground" | "elementStroke";
|
type: "canvasBackground" | "elementBackground" | "elementStroke";
|
||||||
color: string | null;
|
color: string | null;
|
||||||
onChange: (color: string) => void;
|
onChange: (color: string) => void;
|
||||||
label: string;
|
label: string;
|
||||||
|
isActive: boolean;
|
||||||
|
setActive: (active: boolean) => void;
|
||||||
}) => {
|
}) => {
|
||||||
const [isActive, setActive] = React.useState(false);
|
|
||||||
const pickerButton = React.useRef<HTMLButtonElement>(null);
|
const pickerButton = React.useRef<HTMLButtonElement>(null);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
37
src/components/ConfirmDialog.scss
Normal file
37
src/components/ConfirmDialog.scss
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
@import "../css/variables.module";
|
||||||
|
|
||||||
|
.excalidraw {
|
||||||
|
.confirm-dialog {
|
||||||
|
&-buttons {
|
||||||
|
display: flex;
|
||||||
|
padding: 0.2rem 0;
|
||||||
|
justify-content: flex-end;
|
||||||
|
}
|
||||||
|
.ToolIcon__icon {
|
||||||
|
min-width: 2.5rem;
|
||||||
|
width: auto;
|
||||||
|
font-size: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ToolIcon_type_button {
|
||||||
|
margin-left: 0.8rem;
|
||||||
|
padding: 0 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__content {
|
||||||
|
font-size: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
&--confirm.ToolIcon_type_button {
|
||||||
|
background-color: $oc-red-6;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background-color: $oc-red-8;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ToolIcon__icon {
|
||||||
|
color: $oc-white;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
52
src/components/ConfirmDialog.tsx
Normal file
52
src/components/ConfirmDialog.tsx
Normal file
@@ -0,0 +1,52 @@
|
|||||||
|
import { t } from "../i18n";
|
||||||
|
import { Dialog, DialogProps } from "./Dialog";
|
||||||
|
import { ToolButton } from "./ToolButton";
|
||||||
|
|
||||||
|
import "./ConfirmDialog.scss";
|
||||||
|
|
||||||
|
interface Props extends Omit<DialogProps, "onCloseRequest"> {
|
||||||
|
onConfirm: () => void;
|
||||||
|
onCancel: () => void;
|
||||||
|
confirmText?: string;
|
||||||
|
cancelText?: string;
|
||||||
|
}
|
||||||
|
const ConfirmDialog = (props: Props) => {
|
||||||
|
const {
|
||||||
|
onConfirm,
|
||||||
|
onCancel,
|
||||||
|
children,
|
||||||
|
confirmText = t("buttons.confirm"),
|
||||||
|
cancelText = t("buttons.cancel"),
|
||||||
|
className = "",
|
||||||
|
...rest
|
||||||
|
} = props;
|
||||||
|
return (
|
||||||
|
<Dialog
|
||||||
|
onCloseRequest={onCancel}
|
||||||
|
small={true}
|
||||||
|
{...rest}
|
||||||
|
className={`confirm-dialog ${className}`}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
<div className="confirm-dialog-buttons">
|
||||||
|
<ToolButton
|
||||||
|
type="button"
|
||||||
|
title={cancelText}
|
||||||
|
aria-label={cancelText}
|
||||||
|
label={cancelText}
|
||||||
|
onClick={onCancel}
|
||||||
|
className="confirm-dialog--cancel"
|
||||||
|
/>
|
||||||
|
<ToolButton
|
||||||
|
type="button"
|
||||||
|
title={confirmText}
|
||||||
|
aria-label={confirmText}
|
||||||
|
label={confirmText}
|
||||||
|
onClick={onConfirm}
|
||||||
|
className="confirm-dialog--confirm"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
export default ConfirmDialog;
|
@@ -76,7 +76,7 @@
|
|||||||
z-index: 1;
|
z-index: 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
@media #{$is-mobile-query} {
|
@include isMobile {
|
||||||
.context-menu-option {
|
.context-menu-option {
|
||||||
display: block;
|
display: block;
|
||||||
|
|
||||||
|
@@ -1,4 +1,3 @@
|
|||||||
import React from "react";
|
|
||||||
import { render, unmountComponentAtNode } from "react-dom";
|
import { render, unmountComponentAtNode } from "react-dom";
|
||||||
import clsx from "clsx";
|
import clsx from "clsx";
|
||||||
import { Popover } from "./Popover";
|
import { Popover } from "./Popover";
|
||||||
@@ -32,67 +31,63 @@ const ContextMenu = ({
|
|||||||
actionManager,
|
actionManager,
|
||||||
appState,
|
appState,
|
||||||
}: ContextMenuProps) => {
|
}: ContextMenuProps) => {
|
||||||
const isDarkTheme = !!document
|
|
||||||
.querySelector(".excalidraw")
|
|
||||||
?.classList.contains("theme--dark");
|
|
||||||
return (
|
return (
|
||||||
<div
|
<Popover
|
||||||
className={clsx("excalidraw", {
|
onCloseRequest={onCloseRequest}
|
||||||
"theme--dark theme--dark-background-none": isDarkTheme,
|
top={top}
|
||||||
})}
|
left={left}
|
||||||
|
fitInViewport={true}
|
||||||
>
|
>
|
||||||
<Popover
|
<ul
|
||||||
onCloseRequest={onCloseRequest}
|
className="context-menu"
|
||||||
top={top}
|
onContextMenu={(event) => event.preventDefault()}
|
||||||
left={left}
|
|
||||||
fitInViewport={true}
|
|
||||||
>
|
>
|
||||||
<ul
|
{options.map((option, idx) => {
|
||||||
className="context-menu"
|
if (option === "separator") {
|
||||||
onContextMenu={(event) => event.preventDefault()}
|
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 actionName = option.name;
|
||||||
const label = option.contextItemLabel
|
const label = option.contextItemLabel
|
||||||
? t(option.contextItemLabel)
|
? t(option.contextItemLabel)
|
||||||
: "";
|
: "";
|
||||||
return (
|
return (
|
||||||
<li key={idx} data-testid={actionName} onClick={onCloseRequest}>
|
<li key={idx} data-testid={actionName} onClick={onCloseRequest}>
|
||||||
<button
|
<button
|
||||||
className={clsx("context-menu-option", {
|
className={clsx("context-menu-option", {
|
||||||
dangerous: actionName === "deleteSelectedElements",
|
dangerous: actionName === "deleteSelectedElements",
|
||||||
checkmark: option.checked?.(appState),
|
checkmark: option.checked?.(appState),
|
||||||
})}
|
})}
|
||||||
onClick={() => actionManager.executeAction(option)}
|
onClick={() => actionManager.executeAction(option)}
|
||||||
>
|
>
|
||||||
<div className="context-menu-option__label">{label}</div>
|
<div className="context-menu-option__label">{label}</div>
|
||||||
<kbd className="context-menu-option__shortcut">
|
<kbd className="context-menu-option__shortcut">
|
||||||
{actionName
|
{actionName
|
||||||
? getShortcutFromShortcutName(actionName as ShortcutName)
|
? getShortcutFromShortcutName(actionName as ShortcutName)
|
||||||
: ""}
|
: ""}
|
||||||
</kbd>
|
</kbd>
|
||||||
</button>
|
</button>
|
||||||
</li>
|
</li>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</ul>
|
</ul>
|
||||||
</Popover>
|
</Popover>
|
||||||
</div>
|
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
let contextMenuNode: HTMLDivElement;
|
const contextMenuNodeByContainer = new WeakMap<HTMLElement, HTMLDivElement>();
|
||||||
const getContextMenuNode = (): HTMLDivElement => {
|
|
||||||
|
const getContextMenuNode = (container: HTMLElement): HTMLDivElement => {
|
||||||
|
let contextMenuNode = contextMenuNodeByContainer.get(container);
|
||||||
if (contextMenuNode) {
|
if (contextMenuNode) {
|
||||||
return contextMenuNode;
|
return contextMenuNode;
|
||||||
}
|
}
|
||||||
const div = document.createElement("div");
|
contextMenuNode = document.createElement("div");
|
||||||
document.body.appendChild(div);
|
container
|
||||||
return (contextMenuNode = div);
|
.querySelector(".excalidraw-contextMenuContainer")!
|
||||||
|
.appendChild(contextMenuNode);
|
||||||
|
contextMenuNodeByContainer.set(container, contextMenuNode);
|
||||||
|
return contextMenuNode;
|
||||||
};
|
};
|
||||||
|
|
||||||
type ContextMenuParams = {
|
type ContextMenuParams = {
|
||||||
@@ -101,10 +96,16 @@ type ContextMenuParams = {
|
|||||||
left: ContextMenuProps["left"];
|
left: ContextMenuProps["left"];
|
||||||
actionManager: ContextMenuProps["actionManager"];
|
actionManager: ContextMenuProps["actionManager"];
|
||||||
appState: Readonly<AppState>;
|
appState: Readonly<AppState>;
|
||||||
|
container: HTMLElement;
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleClose = () => {
|
const handleClose = (container: HTMLElement) => {
|
||||||
unmountComponentAtNode(getContextMenuNode());
|
const contextMenuNode = contextMenuNodeByContainer.get(container);
|
||||||
|
if (contextMenuNode) {
|
||||||
|
unmountComponentAtNode(contextMenuNode);
|
||||||
|
contextMenuNode.remove();
|
||||||
|
contextMenuNodeByContainer.delete(container);
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
@@ -121,11 +122,11 @@ export default {
|
|||||||
top={params.top}
|
top={params.top}
|
||||||
left={params.left}
|
left={params.left}
|
||||||
options={options}
|
options={options}
|
||||||
onCloseRequest={handleClose}
|
onCloseRequest={() => handleClose(params.container)}
|
||||||
actionManager={params.actionManager}
|
actionManager={params.actionManager}
|
||||||
appState={params.appState}
|
appState={params.appState}
|
||||||
/>,
|
/>,
|
||||||
getContextMenuNode(),
|
getContextMenuNode(params.container),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
@@ -1,42 +1,32 @@
|
|||||||
import "./ToolIcon.scss";
|
import "./ToolIcon.scss";
|
||||||
|
|
||||||
import React from "react";
|
|
||||||
import { t } from "../i18n";
|
import { t } from "../i18n";
|
||||||
|
import { ToolButton } from "./ToolButton";
|
||||||
export type Appearence = "light" | "dark";
|
import { THEME } from "../constants";
|
||||||
|
import { Theme } from "../element/types";
|
||||||
|
|
||||||
// We chose to use only explicit toggle and not a third option for system value,
|
// We chose to use only explicit toggle and not a third option for system value,
|
||||||
// but this could be added in the future.
|
// but this could be added in the future.
|
||||||
export const DarkModeToggle = (props: {
|
export const DarkModeToggle = (props: {
|
||||||
value: Appearence;
|
value: Theme;
|
||||||
onChange: (value: Appearence) => void;
|
onChange: (value: Theme) => void;
|
||||||
title?: string;
|
title?: string;
|
||||||
}) => {
|
}) => {
|
||||||
const title = props.title
|
const title =
|
||||||
? props.title
|
props.title ||
|
||||||
: props.value === "dark"
|
(props.value === "dark" ? t("buttons.lightMode") : t("buttons.darkMode"));
|
||||||
? t("buttons.lightMode")
|
|
||||||
: t("buttons.darkMode");
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<label
|
<ToolButton
|
||||||
className="ToolIcon ToolIcon_type_floating ToolIcon_size_M"
|
type="icon"
|
||||||
data-testid="toggle-dark-mode"
|
icon={props.value === THEME.LIGHT ? ICONS.MOON : ICONS.SUN}
|
||||||
title={title}
|
title={title}
|
||||||
>
|
aria-label={title}
|
||||||
<input
|
onClick={() =>
|
||||||
className="ToolIcon_type_checkbox ToolIcon_toggle_opaque"
|
props.onChange(props.value === THEME.DARK ? THEME.LIGHT : THEME.DARK)
|
||||||
type="checkbox"
|
}
|
||||||
onChange={(event) =>
|
data-testid="toggle-dark-mode"
|
||||||
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>
|
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
@@ -31,7 +31,7 @@
|
|||||||
padding: 0 16px 16px;
|
padding: 0 16px 16px;
|
||||||
}
|
}
|
||||||
|
|
||||||
@media #{$is-mobile-query} {
|
@include isMobile {
|
||||||
.Dialog {
|
.Dialog {
|
||||||
--metric: calc(var(--space-factor) * 4);
|
--metric: calc(var(--space-factor) * 4);
|
||||||
--inset-left: #{"max(var(--metric), var(--sal))"};
|
--inset-left: #{"max(var(--metric), var(--sal))"};
|
||||||
|
@@ -1,23 +1,30 @@
|
|||||||
import clsx from "clsx";
|
import clsx from "clsx";
|
||||||
import React, { useEffect } from "react";
|
import React, { useEffect, useState } from "react";
|
||||||
import { useCallbackRefState } from "../hooks/useCallbackRefState";
|
import { useCallbackRefState } from "../hooks/useCallbackRefState";
|
||||||
import { t } from "../i18n";
|
import { t } from "../i18n";
|
||||||
import useIsMobile from "../is-mobile";
|
import { useExcalidrawContainer, useIsMobile } from "../components/App";
|
||||||
import { KEYS } from "../keys";
|
import { KEYS } from "../keys";
|
||||||
import "./Dialog.scss";
|
import "./Dialog.scss";
|
||||||
import { back, close } from "./icons";
|
import { back, close } from "./icons";
|
||||||
import { Island } from "./Island";
|
import { Island } from "./Island";
|
||||||
import { Modal } from "./Modal";
|
import { Modal } from "./Modal";
|
||||||
|
import { AppState } from "../types";
|
||||||
|
|
||||||
export const Dialog = (props: {
|
export interface DialogProps {
|
||||||
children: React.ReactNode;
|
children: React.ReactNode;
|
||||||
className?: string;
|
className?: string;
|
||||||
small?: boolean;
|
small?: boolean;
|
||||||
onCloseRequest(): void;
|
onCloseRequest(): void;
|
||||||
title: React.ReactNode;
|
title: React.ReactNode;
|
||||||
autofocus?: boolean;
|
autofocus?: boolean;
|
||||||
}) => {
|
theme?: AppState["theme"];
|
||||||
|
closeOnClickOutside?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const Dialog = (props: DialogProps) => {
|
||||||
const [islandNode, setIslandNode] = useCallbackRefState<HTMLDivElement>();
|
const [islandNode, setIslandNode] = useCallbackRefState<HTMLDivElement>();
|
||||||
|
const [lastActiveElement] = useState(document.activeElement);
|
||||||
|
const { id } = useExcalidrawContainer();
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!islandNode) {
|
if (!islandNode) {
|
||||||
@@ -65,19 +72,26 @@ export const Dialog = (props: {
|
|||||||
return focusableElements ? Array.from(focusableElements) : [];
|
return focusableElements ? Array.from(focusableElements) : [];
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const onClose = () => {
|
||||||
|
(lastActiveElement as HTMLElement).focus();
|
||||||
|
props.onCloseRequest();
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Modal
|
<Modal
|
||||||
className={clsx("Dialog", props.className)}
|
className={clsx("Dialog", props.className)}
|
||||||
labelledBy="dialog-title"
|
labelledBy="dialog-title"
|
||||||
maxWidth={props.small ? 550 : 800}
|
maxWidth={props.small ? 550 : 800}
|
||||||
onCloseRequest={props.onCloseRequest}
|
onCloseRequest={onClose}
|
||||||
|
theme={props.theme}
|
||||||
|
closeOnClickOutside={props.closeOnClickOutside}
|
||||||
>
|
>
|
||||||
<Island ref={setIslandNode}>
|
<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>
|
<span className="Dialog__titleContent">{props.title}</span>
|
||||||
<button
|
<button
|
||||||
className="Modal__close"
|
className="Modal__close"
|
||||||
onClick={props.onCloseRequest}
|
onClick={onClose}
|
||||||
aria-label={t("buttons.close")}
|
aria-label={t("buttons.close")}
|
||||||
>
|
>
|
||||||
{useIsMobile() ? back : close}
|
{useIsMobile() ? back : close}
|
||||||
|
@@ -2,6 +2,7 @@ import React, { useState } from "react";
|
|||||||
import { t } from "../i18n";
|
import { t } from "../i18n";
|
||||||
|
|
||||||
import { Dialog } from "./Dialog";
|
import { Dialog } from "./Dialog";
|
||||||
|
import { useExcalidrawContainer } from "./App";
|
||||||
|
|
||||||
export const ErrorDialog = ({
|
export const ErrorDialog = ({
|
||||||
message,
|
message,
|
||||||
@@ -11,6 +12,7 @@ export const ErrorDialog = ({
|
|||||||
onClose?: () => void;
|
onClose?: () => void;
|
||||||
}) => {
|
}) => {
|
||||||
const [modalIsShown, setModalIsShown] = useState(!!message);
|
const [modalIsShown, setModalIsShown] = useState(!!message);
|
||||||
|
const { container: excalidrawContainer } = useExcalidrawContainer();
|
||||||
|
|
||||||
const handleClose = React.useCallback(() => {
|
const handleClose = React.useCallback(() => {
|
||||||
setModalIsShown(false);
|
setModalIsShown(false);
|
||||||
@@ -18,7 +20,9 @@ export const ErrorDialog = ({
|
|||||||
if (onClose) {
|
if (onClose) {
|
||||||
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 (
|
return (
|
||||||
<>
|
<>
|
||||||
@@ -28,14 +32,7 @@ export const ErrorDialog = ({
|
|||||||
onCloseRequest={handleClose}
|
onCloseRequest={handleClose}
|
||||||
title={t("errorDialog.title")}
|
title={t("errorDialog.title")}
|
||||||
>
|
>
|
||||||
<div>
|
<div style={{ whiteSpace: "pre-wrap" }}>{message}</div>
|
||||||
{message.split("\n").map((line) => (
|
|
||||||
<>
|
|
||||||
{line}
|
|
||||||
<br />
|
|
||||||
</>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</Dialog>
|
</Dialog>
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
|
@@ -28,34 +28,7 @@
|
|||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
}
|
}
|
||||||
|
|
||||||
.ExportDialog__name {
|
@include isMobile {
|
||||||
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} {
|
|
||||||
.ExportDialog {
|
.ExportDialog {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
@@ -84,4 +57,63 @@
|
|||||||
overflow-y: auto;
|
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,285 +0,0 @@
|
|||||||
import React, { useEffect, useRef, useState } from "react";
|
|
||||||
import { render, unmountComponentAtNode } from "react-dom";
|
|
||||||
import { ActionsManagerInterface } from "../actions/types";
|
|
||||||
import { probablySupportsClipboardBlob } from "../clipboard";
|
|
||||||
import { canvasToBlob } from "../data/blob";
|
|
||||||
import { NonDeletedExcalidrawElement } from "../element/types";
|
|
||||||
import { CanvasError } from "../errors";
|
|
||||||
import { t } from "../i18n";
|
|
||||||
import useIsMobile from "../is-mobile";
|
|
||||||
import { getSelectedElements, isSomeElementSelected } from "../scene";
|
|
||||||
import { exportToCanvas, getExportSize } from "../scene/export";
|
|
||||||
import { AppState } from "../types";
|
|
||||||
import { Dialog } from "./Dialog";
|
|
||||||
import "./ExportDialog.scss";
|
|
||||||
import { clipboard, exportFile, link } from "./icons";
|
|
||||||
import Stack from "./Stack";
|
|
||||||
import { ToolButton } from "./ToolButton";
|
|
||||||
|
|
||||||
const scales = [1, 2, 3];
|
|
||||||
const defaultScale = scales.includes(devicePixelRatio) ? devicePixelRatio : 1;
|
|
||||||
|
|
||||||
const supportsContextFilters =
|
|
||||||
"filter" in document.createElement("canvas").getContext("2d")!;
|
|
||||||
|
|
||||||
export const ErrorCanvasPreview = () => {
|
|
||||||
return (
|
|
||||||
<div>
|
|
||||||
<h3>{t("canvasError.cannotShowPreview")}</h3>
|
|
||||||
<p>
|
|
||||||
<span>{t("canvasError.canvasTooBig")}</span>
|
|
||||||
</p>
|
|
||||||
<em>({t("canvasError.canvasTooBigTip")})</em>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
const renderPreview = (
|
|
||||||
content: HTMLCanvasElement | Error,
|
|
||||||
previewNode: HTMLDivElement,
|
|
||||||
) => {
|
|
||||||
unmountComponentAtNode(previewNode);
|
|
||||||
previewNode.innerHTML = "";
|
|
||||||
if (content instanceof HTMLCanvasElement) {
|
|
||||||
previewNode.appendChild(content);
|
|
||||||
} else {
|
|
||||||
render(<ErrorCanvasPreview />, previewNode);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
export type ExportCB = (
|
|
||||||
elements: readonly NonDeletedExcalidrawElement[],
|
|
||||||
scale?: number,
|
|
||||||
) => void;
|
|
||||||
|
|
||||||
const ExportModal = ({
|
|
||||||
elements,
|
|
||||||
appState,
|
|
||||||
exportPadding = 10,
|
|
||||||
actionManager,
|
|
||||||
onExportToPng,
|
|
||||||
onExportToSvg,
|
|
||||||
onExportToClipboard,
|
|
||||||
onExportToBackend,
|
|
||||||
}: {
|
|
||||||
appState: AppState;
|
|
||||||
elements: readonly NonDeletedExcalidrawElement[];
|
|
||||||
exportPadding?: number;
|
|
||||||
actionManager: ActionsManagerInterface;
|
|
||||||
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 exportedElements = exportSelected
|
|
||||||
? getSelectedElements(elements, appState)
|
|
||||||
: elements;
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
setExportSelected(someElementIsSelected);
|
|
||||||
}, [someElementIsSelected]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const previewNode = previewRef.current;
|
|
||||||
if (!previewNode) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
const canvas = exportToCanvas(exportedElements, appState, {
|
|
||||||
exportBackground,
|
|
||||||
viewBackgroundColor,
|
|
||||||
exportPadding,
|
|
||||||
scale,
|
|
||||||
shouldAddWatermark,
|
|
||||||
});
|
|
||||||
|
|
||||||
// if converting to blob fails, there's some problem that will
|
|
||||||
// likely prevent preview and export (e.g. canvas too big)
|
|
||||||
canvasToBlob(canvas)
|
|
||||||
.then(() => {
|
|
||||||
renderPreview(canvas, previewNode);
|
|
||||||
})
|
|
||||||
.catch((error) => {
|
|
||||||
console.error(error);
|
|
||||||
renderPreview(new CanvasError(), previewNode);
|
|
||||||
});
|
|
||||||
} catch (error) {
|
|
||||||
console.error(error);
|
|
||||||
renderPreview(new CanvasError(), previewNode);
|
|
||||||
}
|
|
||||||
}, [
|
|
||||||
appState,
|
|
||||||
exportedElements,
|
|
||||||
exportBackground,
|
|
||||||
exportPadding,
|
|
||||||
viewBackgroundColor,
|
|
||||||
scale,
|
|
||||||
shouldAddWatermark,
|
|
||||||
]);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="ExportDialog">
|
|
||||||
<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)
|
|
||||||
}
|
|
||||||
/>{" "}
|
|
||||||
{t("labels.onlySelected")}
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{actionManager.renderAction("changeExportEmbedScene")}
|
|
||||||
{actionManager.renderAction("changeShouldAddWatermark")}
|
|
||||||
</Stack.Col>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export const ExportDialog = ({
|
|
||||||
elements,
|
|
||||||
appState,
|
|
||||||
exportPadding = 10,
|
|
||||||
actionManager,
|
|
||||||
onExportToPng,
|
|
||||||
onExportToSvg,
|
|
||||||
onExportToClipboard,
|
|
||||||
onExportToBackend,
|
|
||||||
}: {
|
|
||||||
appState: AppState;
|
|
||||||
elements: readonly NonDeletedExcalidrawElement[];
|
|
||||||
exportPadding?: number;
|
|
||||||
actionManager: ActionsManagerInterface;
|
|
||||||
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 (
|
|
||||||
<>
|
|
||||||
<ToolButton
|
|
||||||
onClick={() => {
|
|
||||||
setModalIsShown(true);
|
|
||||||
}}
|
|
||||||
data-testid="export-button"
|
|
||||||
icon={exportFile}
|
|
||||||
type="button"
|
|
||||||
aria-label={t("buttons.export")}
|
|
||||||
showAriaLabel={useIsMobile()}
|
|
||||||
title={t("buttons.export")}
|
|
||||||
ref={triggerButton}
|
|
||||||
/>
|
|
||||||
{modalIsShown && (
|
|
||||||
<Dialog onCloseRequest={handleClose} title={t("buttons.export")}>
|
|
||||||
<ExportModal
|
|
||||||
elements={elements}
|
|
||||||
appState={appState}
|
|
||||||
exportPadding={exportPadding}
|
|
||||||
actionManager={actionManager}
|
|
||||||
onExportToPng={onExportToPng}
|
|
||||||
onExportToSvg={onExportToSvg}
|
|
||||||
onExportToClipboard={onExportToClipboard}
|
|
||||||
onExportToBackend={onExportToBackend}
|
|
||||||
onCloseRequest={handleClose}
|
|
||||||
/>
|
|
||||||
</Dialog>
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
};
|
|
@@ -1,6 +1,5 @@
|
|||||||
.excalidraw {
|
.excalidraw {
|
||||||
.FixedSideContainer {
|
.FixedSideContainer {
|
||||||
--margin: 0.25rem;
|
|
||||||
position: absolute;
|
position: absolute;
|
||||||
pointer-events: none;
|
pointer-events: none;
|
||||||
}
|
}
|
||||||
@@ -10,9 +9,9 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.FixedSideContainer_side_top {
|
.FixedSideContainer_side_top {
|
||||||
left: var(--margin);
|
left: var(--space-factor);
|
||||||
top: var(--margin);
|
top: var(--space-factor);
|
||||||
right: var(--margin);
|
right: var(--space-factor);
|
||||||
z-index: 2;
|
z-index: 2;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -23,16 +22,16 @@
|
|||||||
|
|
||||||
/* TODO: if these are used, make sure to implement RTL support
|
/* TODO: if these are used, make sure to implement RTL support
|
||||||
.FixedSideContainer_side_left {
|
.FixedSideContainer_side_left {
|
||||||
left: var(--margin);
|
left: var(--space-factor);
|
||||||
top: var(--margin);
|
top: var(--space-factor);
|
||||||
bottom: var(--margin);
|
bottom: var(--space-factor);
|
||||||
z-index: 1;
|
z-index: 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
.FixedSideContainer_side_right {
|
.FixedSideContainer_side_right {
|
||||||
right: var(--margin);
|
right: var(--space-factor);
|
||||||
top: var(--margin);
|
top: var(--space-factor);
|
||||||
bottom: var(--margin);
|
bottom: var(--space-factor);
|
||||||
z-index: 3;
|
z-index: 3;
|
||||||
}
|
}
|
||||||
*/
|
*/
|
||||||
|
@@ -153,10 +153,19 @@ export const HelpDialog = ({ onClose }: { onClose?: () => void }) => {
|
|||||||
<Shortcut label={t("toolBar.arrow")} shortcuts={["A", "5"]} />
|
<Shortcut label={t("toolBar.arrow")} shortcuts={["A", "5"]} />
|
||||||
<Shortcut label={t("toolBar.line")} shortcuts={["P", "6"]} />
|
<Shortcut label={t("toolBar.line")} shortcuts={["P", "6"]} />
|
||||||
<Shortcut
|
<Shortcut
|
||||||
label={t("toolBar.draw")}
|
label={t("toolBar.freedraw")}
|
||||||
shortcuts={["Shift+P", "7"]}
|
shortcuts={["Shift+P", "7"]}
|
||||||
/>
|
/>
|
||||||
<Shortcut label={t("toolBar.text")} shortcuts={["T", "8"]} />
|
<Shortcut label={t("toolBar.text")} shortcuts={["T", "8"]} />
|
||||||
|
<Shortcut label={t("toolBar.image")} shortcuts={["9"]} />
|
||||||
|
<Shortcut label={t("toolBar.library")} shortcuts={["0"]} />
|
||||||
|
<Shortcut
|
||||||
|
label={t("helpDialog.editSelectedShape")}
|
||||||
|
shortcuts={[
|
||||||
|
getShortcutKey("Enter"),
|
||||||
|
t("helpDialog.doubleClick"),
|
||||||
|
]}
|
||||||
|
/>
|
||||||
<Shortcut
|
<Shortcut
|
||||||
label={t("helpDialog.textNewLine")}
|
label={t("helpDialog.textNewLine")}
|
||||||
shortcuts={[
|
shortcuts={[
|
||||||
@@ -231,6 +240,14 @@ export const HelpDialog = ({ onClose }: { onClose?: () => void }) => {
|
|||||||
label={t("labels.viewMode")}
|
label={t("labels.viewMode")}
|
||||||
shortcuts={[getShortcutKey("Alt+R")]}
|
shortcuts={[getShortcutKey("Alt+R")]}
|
||||||
/>
|
/>
|
||||||
|
<Shortcut
|
||||||
|
label={t("labels.toggleTheme")}
|
||||||
|
shortcuts={[getShortcutKey("Alt+Shift+D")]}
|
||||||
|
/>
|
||||||
|
<Shortcut
|
||||||
|
label={t("stats.title")}
|
||||||
|
shortcuts={[getShortcutKey("Alt+/")]}
|
||||||
|
/>
|
||||||
</ShortcutIsland>
|
</ShortcutIsland>
|
||||||
</Column>
|
</Column>
|
||||||
<Column>
|
<Column>
|
||||||
@@ -349,6 +366,22 @@ export const HelpDialog = ({ onClose }: { onClose?: () => void }) => {
|
|||||||
label={t("labels.ungroup")}
|
label={t("labels.ungroup")}
|
||||||
shortcuts={[getShortcutKey("CtrlOrCmd+Shift+G")]}
|
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>
|
</ShortcutIsland>
|
||||||
</Column>
|
</Column>
|
||||||
</Columns>
|
</Columns>
|
||||||
|
@@ -1,4 +1,3 @@
|
|||||||
import React from "react";
|
|
||||||
import { questionCircle } from "../components/icons";
|
import { questionCircle } from "../components/icons";
|
||||||
|
|
||||||
type HelpIconProps = {
|
type HelpIconProps = {
|
||||||
|
@@ -19,7 +19,7 @@ $wide-viewport-width: 1000px;
|
|||||||
color: $oc-gray-6;
|
color: $oc-gray-6;
|
||||||
font-size: 0.8rem;
|
font-size: 0.8rem;
|
||||||
|
|
||||||
@media #{$is-mobile-query} {
|
@include isMobile {
|
||||||
position: static;
|
position: static;
|
||||||
padding-right: 2em;
|
padding-right: 2em;
|
||||||
}
|
}
|
||||||
|
@@ -1,21 +1,27 @@
|
|||||||
import React from "react";
|
|
||||||
import { t } from "../i18n";
|
import { t } from "../i18n";
|
||||||
import { NonDeletedExcalidrawElement } from "../element/types";
|
import { NonDeletedExcalidrawElement } from "../element/types";
|
||||||
import { getSelectedElements } from "../scene";
|
import { getSelectedElements } from "../scene";
|
||||||
|
|
||||||
import "./HintViewer.scss";
|
import "./HintViewer.scss";
|
||||||
import { AppState } from "../types";
|
import { AppState } from "../types";
|
||||||
import { isLinearElement } from "../element/typeChecks";
|
import {
|
||||||
|
isImageElement,
|
||||||
|
isLinearElement,
|
||||||
|
isTextBindableContainer,
|
||||||
|
isTextElement,
|
||||||
|
} from "../element/typeChecks";
|
||||||
import { getShortcutKey } from "../utils";
|
import { getShortcutKey } from "../utils";
|
||||||
|
|
||||||
interface Hint {
|
interface HintViewerProps {
|
||||||
appState: AppState;
|
appState: AppState;
|
||||||
elements: readonly NonDeletedExcalidrawElement[];
|
elements: readonly NonDeletedExcalidrawElement[];
|
||||||
|
isMobile: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
const getHints = ({ appState, elements }: Hint) => {
|
const getHints = ({ appState, elements, isMobile }: HintViewerProps) => {
|
||||||
const { elementType, isResizing, isRotating, lastPointerDownWith } = appState;
|
const { elementType, isResizing, isRotating, lastPointerDownWith } = appState;
|
||||||
const multiMode = appState.multiElement !== null;
|
const multiMode = appState.multiElement !== null;
|
||||||
|
|
||||||
if (elementType === "arrow" || elementType === "line") {
|
if (elementType === "arrow" || elementType === "line") {
|
||||||
if (!multiMode) {
|
if (!multiMode) {
|
||||||
return t("hints.linearElement");
|
return t("hints.linearElement");
|
||||||
@@ -23,7 +29,7 @@ const getHints = ({ appState, elements }: Hint) => {
|
|||||||
return t("hints.linearElementMulti");
|
return t("hints.linearElementMulti");
|
||||||
}
|
}
|
||||||
|
|
||||||
if (elementType === "draw") {
|
if (elementType === "freedraw") {
|
||||||
return t("hints.freeDraw");
|
return t("hints.freeDraw");
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -31,7 +37,12 @@ const getHints = ({ appState, elements }: Hint) => {
|
|||||||
return t("hints.text");
|
return t("hints.text");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (appState.elementType === "image" && appState.pendingImageElement) {
|
||||||
|
return t("hints.placeImage");
|
||||||
|
}
|
||||||
|
|
||||||
const selectedElements = getSelectedElements(elements, appState);
|
const selectedElements = getSelectedElements(elements, appState);
|
||||||
|
|
||||||
if (
|
if (
|
||||||
isResizing &&
|
isResizing &&
|
||||||
lastPointerDownWith === "mouse" &&
|
lastPointerDownWith === "mouse" &&
|
||||||
@@ -41,29 +52,53 @@ const getHints = ({ appState, elements }: Hint) => {
|
|||||||
if (isLinearElement(targetElement) && targetElement.points.length === 2) {
|
if (isLinearElement(targetElement) && targetElement.points.length === 2) {
|
||||||
return t("hints.lockAngle");
|
return t("hints.lockAngle");
|
||||||
}
|
}
|
||||||
return t("hints.resize");
|
return isImageElement(targetElement)
|
||||||
|
? t("hints.resizeImage")
|
||||||
|
: t("hints.resize");
|
||||||
}
|
}
|
||||||
|
|
||||||
if (isRotating && lastPointerDownWith === "mouse") {
|
if (isRotating && lastPointerDownWith === "mouse") {
|
||||||
return t("hints.rotate");
|
return t("hints.rotate");
|
||||||
}
|
}
|
||||||
|
|
||||||
if (selectedElements.length === 1 && isLinearElement(selectedElements[0])) {
|
if (selectedElements.length === 1) {
|
||||||
if (appState.editingLinearElement) {
|
if (isLinearElement(selectedElements[0])) {
|
||||||
return appState.editingLinearElement.activePointIndex
|
if (appState.editingLinearElement) {
|
||||||
? t("hints.lineEditor_pointSelected")
|
return appState.editingLinearElement.selectedPointsIndices
|
||||||
: t("hints.lineEditor_nothingSelected");
|
? t("hints.lineEditor_pointSelected")
|
||||||
|
: t("hints.lineEditor_nothingSelected");
|
||||||
|
}
|
||||||
|
return t("hints.lineEditor_info");
|
||||||
}
|
}
|
||||||
return t("hints.lineEditor_info");
|
if (isTextBindableContainer(selectedElements[0])) {
|
||||||
|
return t("hints.bindTextToElement");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (selectedElements.length === 1 && isTextElement(selectedElements[0])) {
|
||||||
|
return t("hints.text_selected");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (appState.editingElement && isTextElement(appState.editingElement)) {
|
||||||
|
return t("hints.text_editing");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (elementType === "selection" && !selectedElements.length && !isMobile) {
|
||||||
|
return t("hints.canvasPanning");
|
||||||
}
|
}
|
||||||
|
|
||||||
return null;
|
return null;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const HintViewer = ({ appState, elements }: Hint) => {
|
export const HintViewer = ({
|
||||||
|
appState,
|
||||||
|
elements,
|
||||||
|
isMobile,
|
||||||
|
}: HintViewerProps) => {
|
||||||
let hint = getHints({
|
let hint = getHints({
|
||||||
appState,
|
appState,
|
||||||
elements,
|
elements,
|
||||||
|
isMobile,
|
||||||
});
|
});
|
||||||
if (!hint) {
|
if (!hint) {
|
||||||
return null;
|
return null;
|
||||||
|
@@ -22,7 +22,7 @@
|
|||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
|
|
||||||
&:focus {
|
&:focus-visible {
|
||||||
outline: transparent;
|
outline: transparent;
|
||||||
background-color: var(--button-gray-2);
|
background-color: var(--button-gray-2);
|
||||||
& svg {
|
& svg {
|
||||||
@@ -90,7 +90,7 @@
|
|||||||
.picker-content {
|
.picker-content {
|
||||||
padding: 0.5rem;
|
padding: 0.5rem;
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-auto-flow: column;
|
grid-template-columns: repeat(3, auto);
|
||||||
grid-gap: 0.5rem;
|
grid-gap: 0.5rem;
|
||||||
border-radius: 4px;
|
border-radius: 4px;
|
||||||
:root[dir="rtl"] & {
|
:root[dir="rtl"] & {
|
||||||
@@ -111,7 +111,7 @@
|
|||||||
:root[dir="rtl"] & {
|
:root[dir="rtl"] & {
|
||||||
left: 2px;
|
left: 2px;
|
||||||
}
|
}
|
||||||
@media #{$is-mobile-query} {
|
@include isMobile {
|
||||||
display: none;
|
display: none;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@@ -88,6 +88,7 @@ function Picker<T>({
|
|||||||
onClose();
|
onClose();
|
||||||
}
|
}
|
||||||
event.nativeEvent.stopImmediatePropagation();
|
event.nativeEvent.stopImmediatePropagation();
|
||||||
|
event.stopPropagation();
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
273
src/components/ImageExportDialog.tsx
Normal file
273
src/components/ImageExportDialog.tsx
Normal file
@@ -0,0 +1,273 @@
|
|||||||
|
import React, { useEffect, useRef, useState } from "react";
|
||||||
|
import { render, unmountComponentAtNode } from "react-dom";
|
||||||
|
import { ActionsManagerInterface } from "../actions/types";
|
||||||
|
import { probablySupportsClipboardBlob } from "../clipboard";
|
||||||
|
import { canvasToBlob } from "../data/blob";
|
||||||
|
import { NonDeletedExcalidrawElement } from "../element/types";
|
||||||
|
import { CanvasError } from "../errors";
|
||||||
|
import { t } from "../i18n";
|
||||||
|
import { useIsMobile } from "./App";
|
||||||
|
import { getSelectedElements, isSomeElementSelected } from "../scene";
|
||||||
|
import { exportToCanvas } from "../scene/export";
|
||||||
|
import { AppState, BinaryFiles } from "../types";
|
||||||
|
import { Dialog } from "./Dialog";
|
||||||
|
import { clipboard, exportImage } from "./icons";
|
||||||
|
import Stack from "./Stack";
|
||||||
|
import { ToolButton } from "./ToolButton";
|
||||||
|
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")!;
|
||||||
|
|
||||||
|
export const ErrorCanvasPreview = () => {
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<h3>{t("canvasError.cannotShowPreview")}</h3>
|
||||||
|
<p>
|
||||||
|
<span>{t("canvasError.canvasTooBig")}</span>
|
||||||
|
</p>
|
||||||
|
<em>({t("canvasError.canvasTooBigTip")})</em>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const renderPreview = (
|
||||||
|
content: HTMLCanvasElement | Error,
|
||||||
|
previewNode: HTMLDivElement,
|
||||||
|
) => {
|
||||||
|
unmountComponentAtNode(previewNode);
|
||||||
|
previewNode.innerHTML = "";
|
||||||
|
if (content instanceof HTMLCanvasElement) {
|
||||||
|
previewNode.appendChild(content);
|
||||||
|
} else {
|
||||||
|
render(<ErrorCanvasPreview />, previewNode);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ExportCB = (
|
||||||
|
elements: readonly NonDeletedExcalidrawElement[],
|
||||||
|
scale?: number,
|
||||||
|
) => void;
|
||||||
|
|
||||||
|
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,
|
||||||
|
files,
|
||||||
|
exportPadding = DEFAULT_EXPORT_PADDING,
|
||||||
|
actionManager,
|
||||||
|
onExportToPng,
|
||||||
|
onExportToSvg,
|
||||||
|
onExportToClipboard,
|
||||||
|
}: {
|
||||||
|
appState: AppState;
|
||||||
|
elements: readonly NonDeletedExcalidrawElement[];
|
||||||
|
files: BinaryFiles;
|
||||||
|
exportPadding?: number;
|
||||||
|
actionManager: ActionsManagerInterface;
|
||||||
|
onExportToPng: ExportCB;
|
||||||
|
onExportToSvg: ExportCB;
|
||||||
|
onExportToClipboard: ExportCB;
|
||||||
|
onCloseRequest: () => void;
|
||||||
|
}) => {
|
||||||
|
const someElementIsSelected = isSomeElementSelected(elements, appState);
|
||||||
|
const [exportSelected, setExportSelected] = useState(someElementIsSelected);
|
||||||
|
const previewRef = useRef<HTMLDivElement>(null);
|
||||||
|
const { exportBackground, viewBackgroundColor } = appState;
|
||||||
|
|
||||||
|
const exportedElements = exportSelected
|
||||||
|
? getSelectedElements(elements, appState, true)
|
||||||
|
: elements;
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setExportSelected(someElementIsSelected);
|
||||||
|
}, [someElementIsSelected]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const previewNode = previewRef.current;
|
||||||
|
if (!previewNode) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
exportToCanvas(exportedElements, appState, files, {
|
||||||
|
exportBackground,
|
||||||
|
viewBackgroundColor,
|
||||||
|
exportPadding,
|
||||||
|
})
|
||||||
|
.then((canvas) => {
|
||||||
|
// if converting to blob fails, there's some problem that will
|
||||||
|
// likely prevent preview and export (e.g. canvas too big)
|
||||||
|
return canvasToBlob(canvas).then(() => {
|
||||||
|
renderPreview(canvas, previewNode);
|
||||||
|
});
|
||||||
|
})
|
||||||
|
.catch((error) => {
|
||||||
|
console.error(error);
|
||||||
|
renderPreview(new CanvasError(), previewNode);
|
||||||
|
});
|
||||||
|
}, [
|
||||||
|
appState,
|
||||||
|
files,
|
||||||
|
exportedElements,
|
||||||
|
exportBackground,
|
||||||
|
exportPadding,
|
||||||
|
viewBackgroundColor,
|
||||||
|
]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="ExportDialog">
|
||||||
|
<div className="ExportDialog__preview" ref={previewRef} />
|
||||||
|
{supportsContextFilters &&
|
||||||
|
actionManager.renderAction("exportWithDarkMode")}
|
||||||
|
<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")}
|
||||||
|
</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>
|
||||||
|
)}
|
||||||
|
</Stack.Row>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const ImageExportDialog = ({
|
||||||
|
elements,
|
||||||
|
appState,
|
||||||
|
files,
|
||||||
|
exportPadding = DEFAULT_EXPORT_PADDING,
|
||||||
|
actionManager,
|
||||||
|
onExportToPng,
|
||||||
|
onExportToSvg,
|
||||||
|
onExportToClipboard,
|
||||||
|
}: {
|
||||||
|
appState: AppState;
|
||||||
|
elements: readonly NonDeletedExcalidrawElement[];
|
||||||
|
files: BinaryFiles;
|
||||||
|
exportPadding?: number;
|
||||||
|
actionManager: ActionsManagerInterface;
|
||||||
|
onExportToPng: ExportCB;
|
||||||
|
onExportToSvg: ExportCB;
|
||||||
|
onExportToClipboard: ExportCB;
|
||||||
|
}) => {
|
||||||
|
const [modalIsShown, setModalIsShown] = useState(false);
|
||||||
|
|
||||||
|
const handleClose = React.useCallback(() => {
|
||||||
|
setModalIsShown(false);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<ToolButton
|
||||||
|
onClick={() => {
|
||||||
|
setModalIsShown(true);
|
||||||
|
}}
|
||||||
|
data-testid="image-export-button"
|
||||||
|
icon={exportImage}
|
||||||
|
type="button"
|
||||||
|
aria-label={t("buttons.exportImage")}
|
||||||
|
showAriaLabel={useIsMobile()}
|
||||||
|
title={t("buttons.exportImage")}
|
||||||
|
/>
|
||||||
|
{modalIsShown && (
|
||||||
|
<Dialog onCloseRequest={handleClose} title={t("buttons.exportImage")}>
|
||||||
|
<ImageExportModal
|
||||||
|
elements={elements}
|
||||||
|
appState={appState}
|
||||||
|
files={files}
|
||||||
|
exportPadding={exportPadding}
|
||||||
|
actionManager={actionManager}
|
||||||
|
onExportToPng={onExportToPng}
|
||||||
|
onExportToSvg={onExportToSvg}
|
||||||
|
onExportToClipboard={onExportToClipboard}
|
||||||
|
onCloseRequest={handleClose}
|
||||||
|
/>
|
||||||
|
</Dialog>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
@@ -1,30 +1,25 @@
|
|||||||
import React from "react";
|
import React, { useEffect, useState } from "react";
|
||||||
|
|
||||||
import { LoadingMessage } from "./LoadingMessage";
|
import { LoadingMessage } from "./LoadingMessage";
|
||||||
import { defaultLang, Language, languages, setLanguage } from "../i18n";
|
import { defaultLang, Language, languages, setLanguage } from "../i18n";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
langCode: Language["code"];
|
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 =
|
const currentLang =
|
||||||
languages.find((lang) => lang.code === this.props.langCode) ||
|
languages.find((lang) => lang.code === props.langCode) || defaultLang;
|
||||||
defaultLang;
|
updateLang();
|
||||||
await setLanguage(currentLang);
|
setLoading(false);
|
||||||
this.setState({
|
}, [props.langCode]);
|
||||||
isLoading: false,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
public render() {
|
return loading ? <LoadingMessage /> : props.children;
|
||||||
return this.state.isLoading ? <LoadingMessage /> : this.props.children;
|
};
|
||||||
}
|
|
||||||
}
|
|
||||||
|
@@ -2,9 +2,8 @@
|
|||||||
.Island {
|
.Island {
|
||||||
--padding: 0;
|
--padding: 0;
|
||||||
background-color: var(--island-bg-color);
|
background-color: var(--island-bg-color);
|
||||||
backdrop-filter: saturate(100%) blur(10px);
|
|
||||||
box-shadow: var(--shadow-island);
|
box-shadow: var(--shadow-island);
|
||||||
border-radius: 4px;
|
border-radius: var(--border-radius-lg);
|
||||||
padding: calc(var(--padding) * var(--space-factor));
|
padding: calc(var(--padding) * var(--space-factor));
|
||||||
position: relative;
|
position: relative;
|
||||||
transition: box-shadow 0.5s ease-in-out;
|
transition: box-shadow 0.5s ease-in-out;
|
||||||
|
135
src/components/JSONExportDialog.tsx
Normal file
135
src/components/JSONExportDialog.tsx
Normal file
@@ -0,0 +1,135 @@
|
|||||||
|
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, BinaryFiles } 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,
|
||||||
|
files,
|
||||||
|
actionManager,
|
||||||
|
exportOpts,
|
||||||
|
canvas,
|
||||||
|
}: {
|
||||||
|
appState: AppState;
|
||||||
|
files: BinaryFiles;
|
||||||
|
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, files, canvas)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
{exportOpts.renderCustomUI &&
|
||||||
|
exportOpts.renderCustomUI(elements, appState, files, canvas)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const JSONExportDialog = ({
|
||||||
|
elements,
|
||||||
|
appState,
|
||||||
|
files,
|
||||||
|
actionManager,
|
||||||
|
exportOpts,
|
||||||
|
canvas,
|
||||||
|
}: {
|
||||||
|
elements: readonly NonDeletedExcalidrawElement[];
|
||||||
|
appState: AppState;
|
||||||
|
files: BinaryFiles;
|
||||||
|
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}
|
||||||
|
files={files}
|
||||||
|
actionManager={actionManager}
|
||||||
|
onCloseRequest={handleClose}
|
||||||
|
exportOpts={exportOpts}
|
||||||
|
canvas={canvas}
|
||||||
|
/>
|
||||||
|
</Dialog>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
@@ -1,89 +1,20 @@
|
|||||||
@import "open-color/open-color";
|
@import "open-color/open-color";
|
||||||
|
|
||||||
.excalidraw {
|
.excalidraw {
|
||||||
.layer-ui__library {
|
|
||||||
margin: auto;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
|
|
||||||
.layer-ui__library-header {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
width: 100%;
|
|
||||||
margin: 2px 0;
|
|
||||||
|
|
||||||
button {
|
|
||||||
// 2px from the left to account for focus border of left-most button
|
|
||||||
margin: 0 2px;
|
|
||||||
}
|
|
||||||
|
|
||||||
a {
|
|
||||||
margin-inline-start: auto;
|
|
||||||
// 17px for scrollbar (needed for overlay scrollbars on Big Sur?) + 1px extra
|
|
||||||
padding-inline-end: 18px;
|
|
||||||
white-space: nowrap;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.layer-ui__library-message {
|
|
||||||
padding: 10px 20px;
|
|
||||||
max-width: 200px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.layer-ui__library-items {
|
|
||||||
max-height: 50vh;
|
|
||||||
overflow: auto;
|
|
||||||
}
|
|
||||||
|
|
||||||
.layer-ui__wrapper {
|
.layer-ui__wrapper {
|
||||||
z-index: var(--zIndex-layerUI);
|
z-index: var(--zIndex-layerUI);
|
||||||
|
|
||||||
.encrypted-icon {
|
&__top-right {
|
||||||
position: relative;
|
|
||||||
margin-inline-start: 15px;
|
|
||||||
display: flex;
|
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 {
|
&__footer {
|
||||||
position: absolute;
|
width: 100%;
|
||||||
z-index: 100;
|
|
||||||
bottom: 0;
|
|
||||||
|
|
||||||
:root[dir="ltr"] & {
|
&-right {
|
||||||
right: 0;
|
z-index: 100;
|
||||||
|
display: flex;
|
||||||
}
|
}
|
||||||
|
|
||||||
:root[dir="rtl"] & {
|
|
||||||
left: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
width: 190px;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.zen-mode-transition {
|
.zen-mode-transition {
|
||||||
@@ -105,11 +36,15 @@
|
|||||||
transform: translate(-999px, 0);
|
transform: translate(-999px, 0);
|
||||||
}
|
}
|
||||||
|
|
||||||
:root[dir="ltr"] &.App-menu_bottom--transition-left {
|
:root[dir="ltr"] &.layer-ui__wrapper__footer-left--transition-left {
|
||||||
transform: translate(-92px, 0);
|
transform: translate(-76px, 0);
|
||||||
}
|
}
|
||||||
:root[dir="rtl"] &.App-menu_bottom--transition-left {
|
:root[dir="rtl"] &.layer-ui__wrapper__footer-left--transition-left {
|
||||||
transform: translate(92px, 0);
|
transform: translate(76px, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
&.layer-ui__wrapper__footer-left--transition-bottom {
|
||||||
|
transform: translate(0, 92px);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -137,5 +72,27 @@
|
|||||||
transition-delay: 0.8s;
|
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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@@ -1,50 +1,46 @@
|
|||||||
import clsx from "clsx";
|
import clsx from "clsx";
|
||||||
import React, {
|
import React, { useCallback } from "react";
|
||||||
RefObject,
|
|
||||||
useCallback,
|
|
||||||
useEffect,
|
|
||||||
useRef,
|
|
||||||
useState,
|
|
||||||
} from "react";
|
|
||||||
import { ActionManager } from "../actions/manager";
|
import { ActionManager } from "../actions/manager";
|
||||||
import { CLASSES } from "../constants";
|
import { CLASSES } from "../constants";
|
||||||
import { exportCanvas } from "../data";
|
import { exportCanvas } from "../data";
|
||||||
import { importLibraryFromJSON, saveLibraryAsJSON } from "../data/json";
|
|
||||||
import { Library } from "../data/library";
|
|
||||||
import { isTextElement, showSelectedShapeActions } from "../element";
|
import { isTextElement, showSelectedShapeActions } from "../element";
|
||||||
import { NonDeletedExcalidrawElement } from "../element/types";
|
import { NonDeletedExcalidrawElement } from "../element/types";
|
||||||
import { Language, t } from "../i18n";
|
import { Language, t } from "../i18n";
|
||||||
import useIsMobile from "../is-mobile";
|
import { useIsMobile } from "../components/App";
|
||||||
import { calculateScrollCenter, getSelectedElements } from "../scene";
|
import { calculateScrollCenter, getSelectedElements } from "../scene";
|
||||||
import { ExportType } from "../scene/types";
|
import { ExportType } from "../scene/types";
|
||||||
import { AppState, ExcalidrawProps, LibraryItem, LibraryItems } from "../types";
|
import { AppProps, AppState, ExcalidrawProps, BinaryFiles } from "../types";
|
||||||
import { muteFSAbortError } from "../utils";
|
import { muteFSAbortError } from "../utils";
|
||||||
import { SelectedShapeActions, ShapesSwitcher, ZoomActions } from "./Actions";
|
import { SelectedShapeActions, ShapesSwitcher, ZoomActions } from "./Actions";
|
||||||
import { BackgroundPickerAndDarkModeToggle } from "./BackgroundPickerAndDarkModeToggle";
|
import { BackgroundPickerAndDarkModeToggle } from "./BackgroundPickerAndDarkModeToggle";
|
||||||
import CollabButton from "./CollabButton";
|
import CollabButton from "./CollabButton";
|
||||||
import { ErrorDialog } from "./ErrorDialog";
|
import { ErrorDialog } from "./ErrorDialog";
|
||||||
import { ExportCB, ExportDialog } from "./ExportDialog";
|
import { ExportCB, ImageExportDialog } from "./ImageExportDialog";
|
||||||
import { FixedSideContainer } from "./FixedSideContainer";
|
import { FixedSideContainer } from "./FixedSideContainer";
|
||||||
import { GitHubCorner } from "./GitHubCorner";
|
|
||||||
import { HintViewer } from "./HintViewer";
|
import { HintViewer } from "./HintViewer";
|
||||||
import { exportFile, load, shield, trash } from "./icons";
|
|
||||||
import { Island } from "./Island";
|
import { Island } from "./Island";
|
||||||
import "./LayerUI.scss";
|
|
||||||
import { LibraryUnit } from "./LibraryUnit";
|
|
||||||
import { LoadingMessage } from "./LoadingMessage";
|
import { LoadingMessage } from "./LoadingMessage";
|
||||||
import { LockIcon } from "./LockIcon";
|
import { LockButton } from "./LockButton";
|
||||||
import { MobileMenu } from "./MobileMenu";
|
import { MobileMenu } from "./MobileMenu";
|
||||||
import { PasteChartDialog } from "./PasteChartDialog";
|
import { PasteChartDialog } from "./PasteChartDialog";
|
||||||
import { Section } from "./Section";
|
import { Section } from "./Section";
|
||||||
import { HelpDialog } from "./HelpDialog";
|
import { HelpDialog } from "./HelpDialog";
|
||||||
import Stack from "./Stack";
|
import Stack from "./Stack";
|
||||||
import { ToolButton } from "./ToolButton";
|
|
||||||
import { Tooltip } from "./Tooltip";
|
import { Tooltip } from "./Tooltip";
|
||||||
import { UserList } from "./UserList";
|
import { UserList } from "./UserList";
|
||||||
|
import Library from "../data/library";
|
||||||
|
import { JSONExportDialog } from "./JSONExportDialog";
|
||||||
|
import { LibraryButton } from "./LibraryButton";
|
||||||
|
import { isImageFileHandle } from "../data/blob";
|
||||||
|
import { LibraryMenu } from "./LibraryMenu";
|
||||||
|
|
||||||
|
import "./LayerUI.scss";
|
||||||
|
import "./Toolbar.scss";
|
||||||
|
|
||||||
interface LayerUIProps {
|
interface LayerUIProps {
|
||||||
actionManager: ActionManager;
|
actionManager: ActionManager;
|
||||||
appState: AppState;
|
appState: AppState;
|
||||||
|
files: BinaryFiles;
|
||||||
canvas: HTMLCanvasElement | null;
|
canvas: HTMLCanvasElement | null;
|
||||||
setAppState: React.Component<any, AppState>["setState"];
|
setAppState: React.Component<any, AppState>["setState"];
|
||||||
elements: readonly NonDeletedExcalidrawElement[];
|
elements: readonly NonDeletedExcalidrawElement[];
|
||||||
@@ -57,272 +53,24 @@ interface LayerUIProps {
|
|||||||
toggleZenMode: () => void;
|
toggleZenMode: () => void;
|
||||||
langCode: Language["code"];
|
langCode: Language["code"];
|
||||||
isCollaborating: boolean;
|
isCollaborating: boolean;
|
||||||
onExportToBackend?: (
|
renderTopRightUI?: (
|
||||||
exportedElements: readonly NonDeletedExcalidrawElement[],
|
isMobile: boolean,
|
||||||
appState: AppState,
|
appState: AppState,
|
||||||
canvas: HTMLCanvasElement | null,
|
) => JSX.Element | null;
|
||||||
) => void;
|
renderCustomFooter?: (isMobile: boolean, appState: AppState) => JSX.Element;
|
||||||
renderCustomFooter?: (isMobile: boolean) => JSX.Element;
|
|
||||||
viewModeEnabled: boolean;
|
viewModeEnabled: boolean;
|
||||||
libraryReturnUrl: ExcalidrawProps["libraryReturnUrl"];
|
libraryReturnUrl: ExcalidrawProps["libraryReturnUrl"];
|
||||||
|
UIOptions: AppProps["UIOptions"];
|
||||||
|
focusContainer: () => void;
|
||||||
|
library: Library;
|
||||||
|
id: string;
|
||||||
|
onImageAction: (data: { insertOnCanvasDirectly: boolean }) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
const useOnClickOutside = (
|
|
||||||
ref: RefObject<HTMLElement>,
|
|
||||||
cb: (event: MouseEvent) => void,
|
|
||||||
) => {
|
|
||||||
useEffect(() => {
|
|
||||||
const listener = (event: MouseEvent) => {
|
|
||||||
if (!ref.current) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (
|
|
||||||
event.target instanceof Element &&
|
|
||||||
(ref.current.contains(event.target) ||
|
|
||||||
!document.body.contains(event.target))
|
|
||||||
) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
cb(event);
|
|
||||||
};
|
|
||||||
document.addEventListener("pointerdown", listener, false);
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
document.removeEventListener("pointerdown", listener);
|
|
||||||
};
|
|
||||||
}, [ref, cb]);
|
|
||||||
};
|
|
||||||
|
|
||||||
const LibraryMenuItems = ({
|
|
||||||
library,
|
|
||||||
onRemoveFromLibrary,
|
|
||||||
onAddToLibrary,
|
|
||||||
onInsertShape,
|
|
||||||
pendingElements,
|
|
||||||
setAppState,
|
|
||||||
setLibraryItems,
|
|
||||||
libraryReturnUrl,
|
|
||||||
}: {
|
|
||||||
library: LibraryItems;
|
|
||||||
pendingElements: LibraryItem;
|
|
||||||
onRemoveFromLibrary: (index: number) => void;
|
|
||||||
onInsertShape: (elements: LibraryItem) => void;
|
|
||||||
onAddToLibrary: (elements: LibraryItem) => void;
|
|
||||||
setAppState: React.Component<any, AppState>["setState"];
|
|
||||||
setLibraryItems: (library: LibraryItems) => void;
|
|
||||||
libraryReturnUrl: ExcalidrawProps["libraryReturnUrl"];
|
|
||||||
}) => {
|
|
||||||
const isMobile = useIsMobile();
|
|
||||||
const numCells = library.length + (pendingElements.length > 0 ? 1 : 0);
|
|
||||||
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;
|
|
||||||
|
|
||||||
rows.push(
|
|
||||||
<div className="layer-ui__library-header">
|
|
||||||
<ToolButton
|
|
||||||
key="import"
|
|
||||||
type="button"
|
|
||||||
title={t("buttons.load")}
|
|
||||||
aria-label={t("buttons.load")}
|
|
||||||
icon={load}
|
|
||||||
onClick={() => {
|
|
||||||
importLibraryFromJSON()
|
|
||||||
.then(() => {
|
|
||||||
// Maybe we should close and open the menu so that the items get updated.
|
|
||||||
// But for now we just close the menu.
|
|
||||||
setAppState({ isLibraryOpen: false });
|
|
||||||
})
|
|
||||||
.catch(muteFSAbortError)
|
|
||||||
.catch((error) => {
|
|
||||||
setAppState({ errorMessage: error.message });
|
|
||||||
});
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
{!!library.length && (
|
|
||||||
<>
|
|
||||||
<ToolButton
|
|
||||||
key="export"
|
|
||||||
type="button"
|
|
||||||
title={t("buttons.export")}
|
|
||||||
aria-label={t("buttons.export")}
|
|
||||||
icon={exportFile}
|
|
||||||
onClick={() => {
|
|
||||||
saveLibraryAsJSON()
|
|
||||||
.catch(muteFSAbortError)
|
|
||||||
.catch((error) => {
|
|
||||||
setAppState({ errorMessage: error.message });
|
|
||||||
});
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<ToolButton
|
|
||||||
key="reset"
|
|
||||||
type="button"
|
|
||||||
title={t("buttons.resetLibrary")}
|
|
||||||
aria-label={t("buttons.resetLibrary")}
|
|
||||||
icon={trash}
|
|
||||||
onClick={() => {
|
|
||||||
if (window.confirm(t("alerts.resetLibrary"))) {
|
|
||||||
Library.resetLibrary();
|
|
||||||
setLibraryItems([]);
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
<a
|
|
||||||
href={`https://libraries.excalidraw.com?target=${
|
|
||||||
window.name || "_blank"
|
|
||||||
}&referrer=${referrer}`}
|
|
||||||
target="_excalidraw_libraries"
|
|
||||||
>
|
|
||||||
{t("labels.libraries")}
|
|
||||||
</a>
|
|
||||||
</div>,
|
|
||||||
);
|
|
||||||
|
|
||||||
for (let row = 0; row < numRows; row++) {
|
|
||||||
const y = CELLS_PER_ROW * row;
|
|
||||||
const children = [];
|
|
||||||
for (let x = 0; x < CELLS_PER_ROW; x++) {
|
|
||||||
const shouldAddPendingElements: boolean =
|
|
||||||
pendingElements.length > 0 &&
|
|
||||||
!addedPendingElements &&
|
|
||||||
y + x >= library.length;
|
|
||||||
addedPendingElements = addedPendingElements || shouldAddPendingElements;
|
|
||||||
|
|
||||||
children.push(
|
|
||||||
<Stack.Col key={x}>
|
|
||||||
<LibraryUnit
|
|
||||||
elements={library[y + x]}
|
|
||||||
pendingElements={
|
|
||||||
shouldAddPendingElements ? pendingElements : undefined
|
|
||||||
}
|
|
||||||
onRemoveFromLibrary={onRemoveFromLibrary.bind(null, y + x)}
|
|
||||||
onClick={
|
|
||||||
shouldAddPendingElements
|
|
||||||
? onAddToLibrary.bind(null, pendingElements)
|
|
||||||
: onInsertShape.bind(null, library[y + x])
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
</Stack.Col>,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
rows.push(
|
|
||||||
<Stack.Row align="center" gap={1} key={row}>
|
|
||||||
{children}
|
|
||||||
</Stack.Row>,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Stack.Col align="start" gap={1} className="layer-ui__library-items">
|
|
||||||
{rows}
|
|
||||||
</Stack.Col>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
const LibraryMenu = ({
|
|
||||||
onClickOutside,
|
|
||||||
onInsertShape,
|
|
||||||
pendingElements,
|
|
||||||
onAddToLibrary,
|
|
||||||
setAppState,
|
|
||||||
libraryReturnUrl,
|
|
||||||
}: {
|
|
||||||
pendingElements: LibraryItem;
|
|
||||||
onClickOutside: (event: MouseEvent) => void;
|
|
||||||
onInsertShape: (elements: LibraryItem) => void;
|
|
||||||
onAddToLibrary: () => void;
|
|
||||||
setAppState: React.Component<any, AppState>["setState"];
|
|
||||||
libraryReturnUrl: ExcalidrawProps["libraryReturnUrl"];
|
|
||||||
}) => {
|
|
||||||
const ref = useRef<HTMLDivElement | null>(null);
|
|
||||||
useOnClickOutside(ref, (event) => {
|
|
||||||
// If click on the library icon, do nothing.
|
|
||||||
if ((event.target as Element).closest(".ToolIcon_type_button__library")) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
onClickOutside(event);
|
|
||||||
});
|
|
||||||
|
|
||||||
const [libraryItems, setLibraryItems] = useState<LibraryItems>([]);
|
|
||||||
|
|
||||||
const [loadingState, setIsLoading] = useState<
|
|
||||||
"preloading" | "loading" | "ready"
|
|
||||||
>("preloading");
|
|
||||||
|
|
||||||
const loadingTimerRef = useRef<NodeJS.Timeout | null>(null);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
Promise.race([
|
|
||||||
new Promise((resolve) => {
|
|
||||||
loadingTimerRef.current = setTimeout(() => {
|
|
||||||
resolve("loading");
|
|
||||||
}, 100);
|
|
||||||
}),
|
|
||||||
Library.loadLibrary().then((items) => {
|
|
||||||
setLibraryItems(items);
|
|
||||||
setIsLoading("ready");
|
|
||||||
}),
|
|
||||||
]).then((data) => {
|
|
||||||
if (data === "loading") {
|
|
||||||
setIsLoading("loading");
|
|
||||||
}
|
|
||||||
});
|
|
||||||
return () => {
|
|
||||||
clearTimeout(loadingTimerRef.current!);
|
|
||||||
};
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const removeFromLibrary = useCallback(async (indexToRemove) => {
|
|
||||||
const items = await Library.loadLibrary();
|
|
||||||
const nextItems = items.filter((_, index) => index !== indexToRemove);
|
|
||||||
Library.saveLibrary(nextItems);
|
|
||||||
setLibraryItems(nextItems);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const addToLibrary = useCallback(
|
|
||||||
async (elements: LibraryItem) => {
|
|
||||||
const items = await Library.loadLibrary();
|
|
||||||
const nextItems = [...items, elements];
|
|
||||||
onAddToLibrary();
|
|
||||||
Library.saveLibrary(nextItems);
|
|
||||||
setLibraryItems(nextItems);
|
|
||||||
},
|
|
||||||
[onAddToLibrary],
|
|
||||||
);
|
|
||||||
|
|
||||||
return loadingState === "preloading" ? null : (
|
|
||||||
<Island padding={1} ref={ref} className="layer-ui__library">
|
|
||||||
{loadingState === "loading" ? (
|
|
||||||
<div className="layer-ui__library-message">
|
|
||||||
{t("labels.libraryLoadingMessage")}
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<LibraryMenuItems
|
|
||||||
library={libraryItems}
|
|
||||||
onRemoveFromLibrary={removeFromLibrary}
|
|
||||||
onAddToLibrary={addToLibrary}
|
|
||||||
onInsertShape={onInsertShape}
|
|
||||||
pendingElements={pendingElements}
|
|
||||||
setAppState={setAppState}
|
|
||||||
setLibraryItems={setLibraryItems}
|
|
||||||
libraryReturnUrl={libraryReturnUrl}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</Island>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
const LayerUI = ({
|
const LayerUI = ({
|
||||||
actionManager,
|
actionManager,
|
||||||
appState,
|
appState,
|
||||||
|
files,
|
||||||
setAppState,
|
setAppState,
|
||||||
canvas,
|
canvas,
|
||||||
elements,
|
elements,
|
||||||
@@ -334,69 +82,86 @@ const LayerUI = ({
|
|||||||
showThemeBtn,
|
showThemeBtn,
|
||||||
toggleZenMode,
|
toggleZenMode,
|
||||||
isCollaborating,
|
isCollaborating,
|
||||||
onExportToBackend,
|
renderTopRightUI,
|
||||||
renderCustomFooter,
|
renderCustomFooter,
|
||||||
viewModeEnabled,
|
viewModeEnabled,
|
||||||
libraryReturnUrl,
|
libraryReturnUrl,
|
||||||
|
UIOptions,
|
||||||
|
focusContainer,
|
||||||
|
library,
|
||||||
|
id,
|
||||||
|
onImageAction,
|
||||||
}: LayerUIProps) => {
|
}: LayerUIProps) => {
|
||||||
const isMobile = useIsMobile();
|
const isMobile = useIsMobile();
|
||||||
|
|
||||||
const renderEncryptedIcon = () => (
|
const renderJSONExportDialog = () => {
|
||||||
<a
|
if (!UIOptions.canvasActions.export) {
|
||||||
className={clsx("encrypted-icon tooltip zen-mode-visibility", {
|
return null;
|
||||||
"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 renderExportDialog = () => {
|
return (
|
||||||
const createExporter = (type: ExportType): ExportCB => async (
|
<JSONExportDialog
|
||||||
exportedElements,
|
elements={elements}
|
||||||
scale,
|
appState={appState}
|
||||||
) => {
|
files={files}
|
||||||
if (canvas) {
|
actionManager={actionManager}
|
||||||
await exportCanvas(type, exportedElements, appState, canvas, {
|
exportOpts={UIOptions.canvasActions.export}
|
||||||
exportBackground: appState.exportBackground,
|
canvas={canvas}
|
||||||
name: appState.name,
|
/>
|
||||||
viewBackgroundColor: appState.viewBackgroundColor,
|
);
|
||||||
scale,
|
};
|
||||||
shouldAddWatermark: appState.shouldAddWatermark,
|
|
||||||
})
|
const renderImageExportDialog = () => {
|
||||||
|
if (!UIOptions.canvasActions.saveAsImage) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const createExporter =
|
||||||
|
(type: ExportType): ExportCB =>
|
||||||
|
async (exportedElements) => {
|
||||||
|
const fileHandle = await exportCanvas(
|
||||||
|
type,
|
||||||
|
exportedElements,
|
||||||
|
appState,
|
||||||
|
files,
|
||||||
|
{
|
||||||
|
exportBackground: appState.exportBackground,
|
||||||
|
name: appState.name,
|
||||||
|
viewBackgroundColor: appState.viewBackgroundColor,
|
||||||
|
},
|
||||||
|
)
|
||||||
.catch(muteFSAbortError)
|
.catch(muteFSAbortError)
|
||||||
.catch((error) => {
|
.catch((error) => {
|
||||||
console.error(error);
|
console.error(error);
|
||||||
setAppState({ errorMessage: error.message });
|
setAppState({ errorMessage: error.message });
|
||||||
});
|
});
|
||||||
}
|
|
||||||
};
|
if (
|
||||||
|
appState.exportEmbedScene &&
|
||||||
|
fileHandle &&
|
||||||
|
isImageFileHandle(fileHandle)
|
||||||
|
) {
|
||||||
|
setAppState({ fileHandle });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ExportDialog
|
<ImageExportDialog
|
||||||
elements={elements}
|
elements={elements}
|
||||||
appState={appState}
|
appState={appState}
|
||||||
|
files={files}
|
||||||
actionManager={actionManager}
|
actionManager={actionManager}
|
||||||
onExportToPng={createExporter("png")}
|
onExportToPng={createExporter("png")}
|
||||||
onExportToSvg={createExporter("svg")}
|
onExportToSvg={createExporter("svg")}
|
||||||
onExportToClipboard={createExporter("clipboard")}
|
onExportToClipboard={createExporter("clipboard")}
|
||||||
onExportToBackend={
|
|
||||||
onExportToBackend
|
|
||||||
? (elements) => {
|
|
||||||
onExportToBackend &&
|
|
||||||
onExportToBackend(elements, appState, canvas);
|
|
||||||
}
|
|
||||||
: undefined
|
|
||||||
}
|
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const Separator = () => {
|
||||||
|
return <div style={{ width: ".625em" }} />;
|
||||||
|
};
|
||||||
|
|
||||||
const renderViewModeCanvasActions = () => {
|
const renderViewModeCanvasActions = () => {
|
||||||
return (
|
return (
|
||||||
<Section
|
<Section
|
||||||
@@ -410,15 +175,15 @@ const LayerUI = ({
|
|||||||
<Island padding={2} style={{ zIndex: 1 }}>
|
<Island padding={2} style={{ zIndex: 1 }}>
|
||||||
<Stack.Col gap={4}>
|
<Stack.Col gap={4}>
|
||||||
<Stack.Row gap={1} justifyContent="space-between">
|
<Stack.Row gap={1} justifyContent="space-between">
|
||||||
{actionManager.renderAction("saveScene")}
|
{renderJSONExportDialog()}
|
||||||
{actionManager.renderAction("saveAsScene")}
|
{renderImageExportDialog()}
|
||||||
{renderExportDialog()}
|
|
||||||
</Stack.Row>
|
</Stack.Row>
|
||||||
</Stack.Col>
|
</Stack.Col>
|
||||||
</Island>
|
</Island>
|
||||||
</Section>
|
</Section>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
const renderCanvasActions = () => (
|
const renderCanvasActions = () => (
|
||||||
<Section
|
<Section
|
||||||
heading="canvasActions"
|
heading="canvasActions"
|
||||||
@@ -431,11 +196,12 @@ const LayerUI = ({
|
|||||||
<Island padding={2} style={{ zIndex: 1 }}>
|
<Island padding={2} style={{ zIndex: 1 }}>
|
||||||
<Stack.Col gap={4}>
|
<Stack.Col gap={4}>
|
||||||
<Stack.Row gap={1} justifyContent="space-between">
|
<Stack.Row gap={1} justifyContent="space-between">
|
||||||
{actionManager.renderAction("loadScene")}
|
|
||||||
{actionManager.renderAction("saveScene")}
|
|
||||||
{actionManager.renderAction("saveAsScene")}
|
|
||||||
{renderExportDialog()}
|
|
||||||
{actionManager.renderAction("clearCanvas")}
|
{actionManager.renderAction("clearCanvas")}
|
||||||
|
<Separator />
|
||||||
|
{actionManager.renderAction("loadScene")}
|
||||||
|
{renderJSONExportDialog()}
|
||||||
|
{renderImageExportDialog()}
|
||||||
|
<Separator />
|
||||||
{onCollabButtonClick && (
|
{onCollabButtonClick && (
|
||||||
<CollabButton
|
<CollabButton
|
||||||
isCollaborating={isCollaborating}
|
isCollaborating={isCollaborating}
|
||||||
@@ -450,6 +216,9 @@ const LayerUI = ({
|
|||||||
setAppState={setAppState}
|
setAppState={setAppState}
|
||||||
showThemeBtn={showThemeBtn}
|
showThemeBtn={showThemeBtn}
|
||||||
/>
|
/>
|
||||||
|
{appState.fileHandle && (
|
||||||
|
<>{actionManager.renderAction("saveToActiveFile")}</>
|
||||||
|
)}
|
||||||
</Stack.Col>
|
</Stack.Col>
|
||||||
</Island>
|
</Island>
|
||||||
</Section>
|
</Section>
|
||||||
@@ -468,7 +237,8 @@ const LayerUI = ({
|
|||||||
style={{
|
style={{
|
||||||
// we want to make sure this doesn't overflow so substracting 200
|
// 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
|
// 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
|
<SelectedShapeActions
|
||||||
@@ -481,12 +251,15 @@ const LayerUI = ({
|
|||||||
</Section>
|
</Section>
|
||||||
);
|
);
|
||||||
|
|
||||||
const closeLibrary = useCallback(
|
const closeLibrary = useCallback(() => {
|
||||||
(event) => {
|
const isDialogOpen = !!document.querySelector(".Dialog");
|
||||||
setAppState({ isLibraryOpen: false });
|
|
||||||
},
|
// Prevent closing if any dialog is open
|
||||||
[setAppState],
|
if (isDialogOpen) {
|
||||||
);
|
return;
|
||||||
|
}
|
||||||
|
setAppState({ isLibraryOpen: false });
|
||||||
|
}, [setAppState]);
|
||||||
|
|
||||||
const deselectItems = useCallback(() => {
|
const deselectItems = useCallback(() => {
|
||||||
setAppState({
|
setAppState({
|
||||||
@@ -497,12 +270,18 @@ const LayerUI = ({
|
|||||||
|
|
||||||
const libraryMenu = appState.isLibraryOpen ? (
|
const libraryMenu = appState.isLibraryOpen ? (
|
||||||
<LibraryMenu
|
<LibraryMenu
|
||||||
pendingElements={getSelectedElements(elements, appState)}
|
pendingElements={getSelectedElements(elements, appState, true)}
|
||||||
onClickOutside={closeLibrary}
|
onClose={closeLibrary}
|
||||||
onInsertShape={onInsertElements}
|
onInsertShape={onInsertElements}
|
||||||
onAddToLibrary={deselectItems}
|
onAddToLibrary={deselectItems}
|
||||||
setAppState={setAppState}
|
setAppState={setAppState}
|
||||||
libraryReturnUrl={libraryReturnUrl}
|
libraryReturnUrl={libraryReturnUrl}
|
||||||
|
focusContainer={focusContainer}
|
||||||
|
library={library}
|
||||||
|
theme={appState.theme}
|
||||||
|
files={files}
|
||||||
|
id={id}
|
||||||
|
appState={appState}
|
||||||
/>
|
/>
|
||||||
) : null;
|
) : null;
|
||||||
|
|
||||||
@@ -528,27 +307,46 @@ const LayerUI = ({
|
|||||||
<Section heading="shapes">
|
<Section heading="shapes">
|
||||||
{(heading) => (
|
{(heading) => (
|
||||||
<Stack.Col gap={4} align="start">
|
<Stack.Col gap={4} align="start">
|
||||||
<Stack.Row gap={1}>
|
<Stack.Row
|
||||||
|
gap={1}
|
||||||
|
className={clsx("App-toolbar-container", {
|
||||||
|
"zen-mode": zenModeEnabled,
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
<LockButton
|
||||||
|
zenModeEnabled={zenModeEnabled}
|
||||||
|
checked={appState.elementLocked}
|
||||||
|
onChange={onLockToggle}
|
||||||
|
title={t("toolBar.lock")}
|
||||||
|
/>
|
||||||
<Island
|
<Island
|
||||||
padding={1}
|
padding={1}
|
||||||
className={clsx({ "zen-mode": zenModeEnabled })}
|
className={clsx("App-toolbar", {
|
||||||
|
"zen-mode": zenModeEnabled,
|
||||||
|
})}
|
||||||
>
|
>
|
||||||
<HintViewer appState={appState} elements={elements} />
|
<HintViewer
|
||||||
|
appState={appState}
|
||||||
|
elements={elements}
|
||||||
|
isMobile={isMobile}
|
||||||
|
/>
|
||||||
{heading}
|
{heading}
|
||||||
<Stack.Row gap={1}>
|
<Stack.Row gap={1}>
|
||||||
<ShapesSwitcher
|
<ShapesSwitcher
|
||||||
canvas={canvas}
|
canvas={canvas}
|
||||||
elementType={appState.elementType}
|
elementType={appState.elementType}
|
||||||
setAppState={setAppState}
|
setAppState={setAppState}
|
||||||
isLibraryOpen={appState.isLibraryOpen}
|
onImageAction={({ pointerType }) => {
|
||||||
|
onImageAction({
|
||||||
|
insertOnCanvasDirectly: pointerType !== "mouse",
|
||||||
|
});
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
</Stack.Row>
|
</Stack.Row>
|
||||||
</Island>
|
</Island>
|
||||||
<LockIcon
|
<LibraryButton
|
||||||
zenModeEnabled={zenModeEnabled}
|
appState={appState}
|
||||||
checked={appState.elementLocked}
|
setAppState={setAppState}
|
||||||
onChange={onLockToggle}
|
|
||||||
title={t("toolBar.lock")}
|
|
||||||
/>
|
/>
|
||||||
</Stack.Row>
|
</Stack.Row>
|
||||||
{libraryMenu}
|
{libraryMenu}
|
||||||
@@ -556,24 +354,32 @@ const LayerUI = ({
|
|||||||
)}
|
)}
|
||||||
</Section>
|
</Section>
|
||||||
)}
|
)}
|
||||||
<UserList
|
<div
|
||||||
className={clsx("zen-mode-transition", {
|
className={clsx(
|
||||||
"transition-right": zenModeEnabled,
|
"layer-ui__wrapper__top-right zen-mode-transition",
|
||||||
})}
|
{
|
||||||
|
"transition-right": zenModeEnabled,
|
||||||
|
},
|
||||||
|
)}
|
||||||
>
|
>
|
||||||
{appState.collaborators.size > 0 &&
|
<UserList>
|
||||||
Array.from(appState.collaborators)
|
{appState.collaborators.size > 0 &&
|
||||||
// Collaborator is either not initialized or is actually the current user.
|
Array.from(appState.collaborators)
|
||||||
.filter(([_, client]) => Object.keys(client).length !== 0)
|
// Collaborator is either not initialized or is actually the current user.
|
||||||
.map(([clientId, client]) => (
|
.filter(([_, client]) => Object.keys(client).length !== 0)
|
||||||
<Tooltip
|
.map(([clientId, client]) => (
|
||||||
label={client.username || "Unknown user"}
|
<Tooltip
|
||||||
key={clientId}
|
label={client.username || "Unknown user"}
|
||||||
>
|
key={clientId}
|
||||||
{actionManager.renderAction("goToCollaborator", clientId)}
|
>
|
||||||
</Tooltip>
|
{actionManager.renderAction("goToCollaborator", {
|
||||||
))}
|
id: clientId,
|
||||||
</UserList>
|
})}
|
||||||
|
</Tooltip>
|
||||||
|
))}
|
||||||
|
</UserList>
|
||||||
|
{renderTopRightUI?.(isMobile, appState)}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</FixedSideContainer>
|
</FixedSideContainer>
|
||||||
);
|
);
|
||||||
@@ -581,61 +387,73 @@ const LayerUI = ({
|
|||||||
|
|
||||||
const renderBottomAppMenu = () => {
|
const renderBottomAppMenu = () => {
|
||||||
return (
|
return (
|
||||||
<div
|
<footer
|
||||||
className={clsx("App-menu App-menu_bottom zen-mode-transition", {
|
role="contentinfo"
|
||||||
"App-menu_bottom--transition-left": zenModeEnabled,
|
className="layer-ui__wrapper__footer App-menu App-menu_bottom"
|
||||||
})}
|
|
||||||
>
|
>
|
||||||
<Stack.Col gap={2}>
|
<div
|
||||||
<Section heading="canvasActions">
|
className={clsx(
|
||||||
<Island padding={1}>
|
"layer-ui__wrapper__footer-left zen-mode-transition",
|
||||||
<ZoomActions
|
{
|
||||||
renderAction={actionManager.renderAction}
|
"layer-ui__wrapper__footer-left--transition-left": zenModeEnabled,
|
||||||
zoom={appState.zoom}
|
},
|
||||||
/>
|
)}
|
||||||
</Island>
|
>
|
||||||
{renderEncryptedIcon()}
|
<Stack.Col gap={2}>
|
||||||
</Section>
|
<Section heading="canvasActions">
|
||||||
</Stack.Col>
|
<Island padding={1}>
|
||||||
</div>
|
<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 = (
|
const dialogs = (
|
||||||
<>
|
<>
|
||||||
{appState.isLoading && <LoadingMessage />}
|
{appState.isLoading && <LoadingMessage />}
|
||||||
@@ -646,7 +464,11 @@ const LayerUI = ({
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
{appState.showHelpDialog && (
|
{appState.showHelpDialog && (
|
||||||
<HelpDialog onClose={() => setAppState({ showHelpDialog: false })} />
|
<HelpDialog
|
||||||
|
onClose={() => {
|
||||||
|
setAppState({ showHelpDialog: false });
|
||||||
|
}}
|
||||||
|
/>
|
||||||
)}
|
)}
|
||||||
{appState.pasteDialog.shown && (
|
{appState.pasteDialog.shown && (
|
||||||
<PasteChartDialog
|
<PasteChartDialog
|
||||||
@@ -671,7 +493,8 @@ const LayerUI = ({
|
|||||||
elements={elements}
|
elements={elements}
|
||||||
actionManager={actionManager}
|
actionManager={actionManager}
|
||||||
libraryMenu={libraryMenu}
|
libraryMenu={libraryMenu}
|
||||||
exportButton={renderExportDialog()}
|
renderJSONExportDialog={renderJSONExportDialog}
|
||||||
|
renderImageExportDialog={renderImageExportDialog}
|
||||||
setAppState={setAppState}
|
setAppState={setAppState}
|
||||||
onCollabButtonClick={onCollabButtonClick}
|
onCollabButtonClick={onCollabButtonClick}
|
||||||
onLockToggle={onLockToggle}
|
onLockToggle={onLockToggle}
|
||||||
@@ -680,6 +503,8 @@ const LayerUI = ({
|
|||||||
renderCustomFooter={renderCustomFooter}
|
renderCustomFooter={renderCustomFooter}
|
||||||
viewModeEnabled={viewModeEnabled}
|
viewModeEnabled={viewModeEnabled}
|
||||||
showThemeBtn={showThemeBtn}
|
showThemeBtn={showThemeBtn}
|
||||||
|
onImageAction={onImageAction}
|
||||||
|
renderTopRightUI={renderTopRightUI}
|
||||||
/>
|
/>
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
@@ -694,8 +519,6 @@ const LayerUI = ({
|
|||||||
{dialogs}
|
{dialogs}
|
||||||
{renderFixedSideContainer()}
|
{renderFixedSideContainer()}
|
||||||
{renderBottomAppMenu()}
|
{renderBottomAppMenu()}
|
||||||
{renderGitHubCorner()}
|
|
||||||
{renderFooter()}
|
|
||||||
{appState.scrolledOutside && (
|
{appState.scrolledOutside && (
|
||||||
<button
|
<button
|
||||||
className="scroll-back-to-content"
|
className="scroll-back-to-content"
|
||||||
@@ -729,6 +552,7 @@ const areEqual = (prev: LayerUIProps, next: LayerUIProps) => {
|
|||||||
prev.renderCustomFooter === next.renderCustomFooter &&
|
prev.renderCustomFooter === next.renderCustomFooter &&
|
||||||
prev.langCode === next.langCode &&
|
prev.langCode === next.langCode &&
|
||||||
prev.elements === next.elements &&
|
prev.elements === next.elements &&
|
||||||
|
prev.files === next.files &&
|
||||||
keys.every((key) => prevAppState[key] === nextAppState[key])
|
keys.every((key) => prevAppState[key] === nextAppState[key])
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
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"];
|
||||||
|
isMobile?: boolean;
|
||||||
|
}> = ({ appState, setAppState, isMobile }) => {
|
||||||
|
return (
|
||||||
|
<label
|
||||||
|
className={clsx(
|
||||||
|
"ToolIcon ToolIcon_type_floating ToolIcon__library",
|
||||||
|
`ToolIcon_size_medium`,
|
||||||
|
{
|
||||||
|
"is-mobile": isMobile,
|
||||||
|
},
|
||||||
|
)}
|
||||||
|
title={`${capitalizeString(t("toolBar.library"))} — 0`}
|
||||||
|
>
|
||||||
|
<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="0"
|
||||||
|
/>
|
||||||
|
<div className="ToolIcon__icon">{LIBRARY_ICON}</div>
|
||||||
|
</label>
|
||||||
|
);
|
||||||
|
};
|
55
src/components/LibraryMenu.scss
Normal file
55
src/components/LibraryMenu.scss
Normal file
@@ -0,0 +1,55 @@
|
|||||||
|
@import "open-color/open-color";
|
||||||
|
|
||||||
|
.excalidraw {
|
||||||
|
.layer-ui__library {
|
||||||
|
margin: auto;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
|
||||||
|
.layer-ui__library-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
width: 100%;
|
||||||
|
margin: 2px 0;
|
||||||
|
|
||||||
|
button {
|
||||||
|
// 2px from the left to account for focus border of left-most button
|
||||||
|
margin: 0 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
a {
|
||||||
|
margin-inline-start: auto;
|
||||||
|
// 17px for scrollbar (needed for overlay scrollbars on Big Sur?) + 1px extra
|
||||||
|
padding-inline-end: 18px;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.layer-ui__library-message {
|
||||||
|
padding: 10px 20px;
|
||||||
|
max-width: 200px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.publish-library-success {
|
||||||
|
.Dialog__content {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
&-close.ToolIcon_type_button {
|
||||||
|
background-color: $oc-blue-6;
|
||||||
|
align-self: flex-end;
|
||||||
|
&:hover {
|
||||||
|
background-color: $oc-blue-8;
|
||||||
|
}
|
||||||
|
.ToolIcon__icon {
|
||||||
|
width: auto;
|
||||||
|
font-size: 1rem;
|
||||||
|
color: $oc-white;
|
||||||
|
padding: 0 0.5rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
326
src/components/LibraryMenu.tsx
Normal file
326
src/components/LibraryMenu.tsx
Normal file
@@ -0,0 +1,326 @@
|
|||||||
|
import { useRef, useState, useEffect, useCallback, RefObject } from "react";
|
||||||
|
import Library from "../data/library";
|
||||||
|
import { t } from "../i18n";
|
||||||
|
import { randomId } from "../random";
|
||||||
|
import {
|
||||||
|
LibraryItems,
|
||||||
|
LibraryItem,
|
||||||
|
AppState,
|
||||||
|
BinaryFiles,
|
||||||
|
ExcalidrawProps,
|
||||||
|
} from "../types";
|
||||||
|
import { Dialog } from "./Dialog";
|
||||||
|
import { Island } from "./Island";
|
||||||
|
import PublishLibrary from "./PublishLibrary";
|
||||||
|
import { ToolButton } from "./ToolButton";
|
||||||
|
|
||||||
|
import "./LibraryMenu.scss";
|
||||||
|
import LibraryMenuItems from "./LibraryMenuItems";
|
||||||
|
import { EVENT } from "../constants";
|
||||||
|
import { KEYS } from "../keys";
|
||||||
|
import { arrayToMap } from "../utils";
|
||||||
|
|
||||||
|
const useOnClickOutside = (
|
||||||
|
ref: RefObject<HTMLElement>,
|
||||||
|
cb: (event: MouseEvent) => void,
|
||||||
|
) => {
|
||||||
|
useEffect(() => {
|
||||||
|
const listener = (event: MouseEvent) => {
|
||||||
|
if (!ref.current) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
event.target instanceof Element &&
|
||||||
|
(ref.current.contains(event.target) ||
|
||||||
|
!document.body.contains(event.target))
|
||||||
|
) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
cb(event);
|
||||||
|
};
|
||||||
|
document.addEventListener("pointerdown", listener, false);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
document.removeEventListener("pointerdown", listener);
|
||||||
|
};
|
||||||
|
}, [ref, cb]);
|
||||||
|
};
|
||||||
|
|
||||||
|
const getSelectedItems = (
|
||||||
|
libraryItems: LibraryItems,
|
||||||
|
selectedItems: LibraryItem["id"][],
|
||||||
|
) => libraryItems.filter((item) => selectedItems.includes(item.id));
|
||||||
|
|
||||||
|
export const LibraryMenu = ({
|
||||||
|
onClose,
|
||||||
|
onInsertShape,
|
||||||
|
pendingElements,
|
||||||
|
onAddToLibrary,
|
||||||
|
theme,
|
||||||
|
setAppState,
|
||||||
|
files,
|
||||||
|
libraryReturnUrl,
|
||||||
|
focusContainer,
|
||||||
|
library,
|
||||||
|
id,
|
||||||
|
appState,
|
||||||
|
}: {
|
||||||
|
pendingElements: LibraryItem["elements"];
|
||||||
|
onClose: () => void;
|
||||||
|
onInsertShape: (elements: LibraryItem["elements"]) => void;
|
||||||
|
onAddToLibrary: () => void;
|
||||||
|
theme: AppState["theme"];
|
||||||
|
files: BinaryFiles;
|
||||||
|
setAppState: React.Component<any, AppState>["setState"];
|
||||||
|
libraryReturnUrl: ExcalidrawProps["libraryReturnUrl"];
|
||||||
|
focusContainer: () => void;
|
||||||
|
library: Library;
|
||||||
|
id: string;
|
||||||
|
appState: AppState;
|
||||||
|
}) => {
|
||||||
|
const ref = useRef<HTMLDivElement | null>(null);
|
||||||
|
|
||||||
|
useOnClickOutside(ref, (event) => {
|
||||||
|
// If click on the library icon, do nothing.
|
||||||
|
if ((event.target as Element).closest(".ToolIcon__library")) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
onClose();
|
||||||
|
});
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const handleKeyDown = (event: KeyboardEvent) => {
|
||||||
|
if (event.key === KEYS.ESCAPE) {
|
||||||
|
onClose();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
document.addEventListener(EVENT.KEYDOWN, handleKeyDown);
|
||||||
|
return () => {
|
||||||
|
document.removeEventListener(EVENT.KEYDOWN, handleKeyDown);
|
||||||
|
};
|
||||||
|
}, [onClose]);
|
||||||
|
|
||||||
|
const [libraryItems, setLibraryItems] = useState<LibraryItems>([]);
|
||||||
|
|
||||||
|
const [loadingState, setIsLoading] = useState<
|
||||||
|
"preloading" | "loading" | "ready"
|
||||||
|
>("preloading");
|
||||||
|
const [selectedItems, setSelectedItems] = useState<LibraryItem["id"][]>([]);
|
||||||
|
const [showPublishLibraryDialog, setShowPublishLibraryDialog] =
|
||||||
|
useState(false);
|
||||||
|
const [publishLibSuccess, setPublishLibSuccess] = useState<null | {
|
||||||
|
url: string;
|
||||||
|
authorName: string;
|
||||||
|
}>(null);
|
||||||
|
const loadingTimerRef = useRef<number | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
Promise.race([
|
||||||
|
new Promise((resolve) => {
|
||||||
|
loadingTimerRef.current = window.setTimeout(() => {
|
||||||
|
resolve("loading");
|
||||||
|
}, 100);
|
||||||
|
}),
|
||||||
|
library.loadLibrary().then((items) => {
|
||||||
|
setLibraryItems(items);
|
||||||
|
setIsLoading("ready");
|
||||||
|
}),
|
||||||
|
]).then((data) => {
|
||||||
|
if (data === "loading") {
|
||||||
|
setIsLoading("loading");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return () => {
|
||||||
|
clearTimeout(loadingTimerRef.current!);
|
||||||
|
};
|
||||||
|
}, [library]);
|
||||||
|
|
||||||
|
const removeFromLibrary = useCallback(async () => {
|
||||||
|
const items = await library.loadLibrary();
|
||||||
|
|
||||||
|
const nextItems = items.filter((item) => !selectedItems.includes(item.id));
|
||||||
|
library.saveLibrary(nextItems).catch((error) => {
|
||||||
|
setLibraryItems(items);
|
||||||
|
setAppState({ errorMessage: t("alerts.errorRemovingFromLibrary") });
|
||||||
|
});
|
||||||
|
setSelectedItems([]);
|
||||||
|
setLibraryItems(nextItems);
|
||||||
|
}, [library, setAppState, selectedItems, setSelectedItems]);
|
||||||
|
|
||||||
|
const resetLibrary = useCallback(() => {
|
||||||
|
library.resetLibrary();
|
||||||
|
setLibraryItems([]);
|
||||||
|
focusContainer();
|
||||||
|
}, [library, focusContainer]);
|
||||||
|
|
||||||
|
const addToLibrary = useCallback(
|
||||||
|
async (elements: LibraryItem["elements"]) => {
|
||||||
|
if (elements.some((element) => element.type === "image")) {
|
||||||
|
return setAppState({
|
||||||
|
errorMessage: "Support for adding images to the library coming soon!",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
const items = await library.loadLibrary();
|
||||||
|
const nextItems: LibraryItems = [
|
||||||
|
{
|
||||||
|
status: "unpublished",
|
||||||
|
elements,
|
||||||
|
id: randomId(),
|
||||||
|
created: Date.now(),
|
||||||
|
},
|
||||||
|
...items,
|
||||||
|
];
|
||||||
|
onAddToLibrary();
|
||||||
|
library.saveLibrary(nextItems).catch((error) => {
|
||||||
|
setLibraryItems(items);
|
||||||
|
setAppState({ errorMessage: t("alerts.errorAddingToLibrary") });
|
||||||
|
});
|
||||||
|
setLibraryItems(nextItems);
|
||||||
|
},
|
||||||
|
[onAddToLibrary, library, setAppState],
|
||||||
|
);
|
||||||
|
|
||||||
|
const renderPublishSuccess = useCallback(() => {
|
||||||
|
return (
|
||||||
|
<Dialog
|
||||||
|
onCloseRequest={() => setPublishLibSuccess(null)}
|
||||||
|
title={t("publishSuccessDialog.title")}
|
||||||
|
className="publish-library-success"
|
||||||
|
small={true}
|
||||||
|
>
|
||||||
|
<p>
|
||||||
|
{t("publishSuccessDialog.content", {
|
||||||
|
authorName: publishLibSuccess!.authorName,
|
||||||
|
})}{" "}
|
||||||
|
<a
|
||||||
|
href={publishLibSuccess?.url}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
>
|
||||||
|
{t("publishSuccessDialog.link")}
|
||||||
|
</a>
|
||||||
|
</p>
|
||||||
|
<ToolButton
|
||||||
|
type="button"
|
||||||
|
title={t("buttons.close")}
|
||||||
|
aria-label={t("buttons.close")}
|
||||||
|
label={t("buttons.close")}
|
||||||
|
onClick={() => setPublishLibSuccess(null)}
|
||||||
|
data-testid="publish-library-success-close"
|
||||||
|
className="publish-library-success-close"
|
||||||
|
/>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}, [setPublishLibSuccess, publishLibSuccess]);
|
||||||
|
|
||||||
|
const onPublishLibSuccess = useCallback(
|
||||||
|
(data) => {
|
||||||
|
setShowPublishLibraryDialog(false);
|
||||||
|
setPublishLibSuccess({ url: data.url, authorName: data.authorName });
|
||||||
|
const nextLibItems = libraryItems.slice();
|
||||||
|
nextLibItems.forEach((libItem) => {
|
||||||
|
if (selectedItems.includes(libItem.id)) {
|
||||||
|
libItem.status = "published";
|
||||||
|
}
|
||||||
|
});
|
||||||
|
library.saveLibrary(nextLibItems);
|
||||||
|
setLibraryItems(nextLibItems);
|
||||||
|
},
|
||||||
|
[
|
||||||
|
setShowPublishLibraryDialog,
|
||||||
|
setPublishLibSuccess,
|
||||||
|
libraryItems,
|
||||||
|
selectedItems,
|
||||||
|
library,
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
|
const [lastSelectedItem, setLastSelectedItem] = useState<
|
||||||
|
LibraryItem["id"] | null
|
||||||
|
>(null);
|
||||||
|
|
||||||
|
return loadingState === "preloading" ? null : (
|
||||||
|
<Island padding={1} ref={ref} className="layer-ui__library">
|
||||||
|
{showPublishLibraryDialog && (
|
||||||
|
<PublishLibrary
|
||||||
|
onClose={() => setShowPublishLibraryDialog(false)}
|
||||||
|
libraryItems={getSelectedItems(libraryItems, selectedItems)}
|
||||||
|
appState={appState}
|
||||||
|
onSuccess={onPublishLibSuccess}
|
||||||
|
onError={(error) => window.alert(error)}
|
||||||
|
updateItemsInStorage={() => library.saveLibrary(libraryItems)}
|
||||||
|
onRemove={(id: string) =>
|
||||||
|
setSelectedItems(selectedItems.filter((_id) => _id !== id))
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{publishLibSuccess && renderPublishSuccess()}
|
||||||
|
|
||||||
|
{loadingState === "loading" ? (
|
||||||
|
<div className="layer-ui__library-message">
|
||||||
|
{t("labels.libraryLoadingMessage")}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<LibraryMenuItems
|
||||||
|
libraryItems={libraryItems}
|
||||||
|
onRemoveFromLibrary={removeFromLibrary}
|
||||||
|
onAddToLibrary={addToLibrary}
|
||||||
|
onInsertShape={onInsertShape}
|
||||||
|
pendingElements={pendingElements}
|
||||||
|
setAppState={setAppState}
|
||||||
|
libraryReturnUrl={libraryReturnUrl}
|
||||||
|
library={library}
|
||||||
|
theme={theme}
|
||||||
|
files={files}
|
||||||
|
id={id}
|
||||||
|
selectedItems={selectedItems}
|
||||||
|
onToggle={(id, event) => {
|
||||||
|
const shouldSelect = !selectedItems.includes(id);
|
||||||
|
|
||||||
|
if (shouldSelect) {
|
||||||
|
if (event.shiftKey && lastSelectedItem) {
|
||||||
|
const rangeStart = libraryItems.findIndex(
|
||||||
|
(item) => item.id === lastSelectedItem,
|
||||||
|
);
|
||||||
|
const rangeEnd = libraryItems.findIndex(
|
||||||
|
(item) => item.id === id,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (rangeStart === -1 || rangeEnd === -1) {
|
||||||
|
setSelectedItems([...selectedItems, id]);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const selectedItemsMap = arrayToMap(selectedItems);
|
||||||
|
const nextSelectedIds = libraryItems.reduce(
|
||||||
|
(acc: LibraryItem["id"][], item, idx) => {
|
||||||
|
if (
|
||||||
|
(idx >= rangeStart && idx <= rangeEnd) ||
|
||||||
|
selectedItemsMap.has(item.id)
|
||||||
|
) {
|
||||||
|
acc.push(item.id);
|
||||||
|
}
|
||||||
|
return acc;
|
||||||
|
},
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
|
||||||
|
setSelectedItems(nextSelectedIds);
|
||||||
|
} else {
|
||||||
|
setSelectedItems([...selectedItems, id]);
|
||||||
|
}
|
||||||
|
setLastSelectedItem(id);
|
||||||
|
} else {
|
||||||
|
setLastSelectedItem(null);
|
||||||
|
setSelectedItems(selectedItems.filter((_id) => _id !== id));
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
onPublish={() => setShowPublishLibraryDialog(true)}
|
||||||
|
resetLibrary={resetLibrary}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</Island>
|
||||||
|
);
|
||||||
|
};
|
102
src/components/LibraryMenuItems.scss
Normal file
102
src/components/LibraryMenuItems.scss
Normal file
@@ -0,0 +1,102 @@
|
|||||||
|
@import "open-color/open-color";
|
||||||
|
|
||||||
|
.excalidraw {
|
||||||
|
.library-menu-items-container {
|
||||||
|
.library-actions {
|
||||||
|
display: flex;
|
||||||
|
|
||||||
|
button .library-actions-counter {
|
||||||
|
position: absolute;
|
||||||
|
right: 2px;
|
||||||
|
bottom: 2px;
|
||||||
|
border-radius: 50%;
|
||||||
|
width: 1em;
|
||||||
|
height: 1em;
|
||||||
|
padding: 1px;
|
||||||
|
font-size: 0.7rem;
|
||||||
|
background: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
&--remove {
|
||||||
|
background-color: $oc-red-7;
|
||||||
|
&:hover {
|
||||||
|
background-color: $oc-red-8;
|
||||||
|
}
|
||||||
|
&:active {
|
||||||
|
background-color: $oc-red-9;
|
||||||
|
}
|
||||||
|
svg {
|
||||||
|
color: $oc-white;
|
||||||
|
}
|
||||||
|
.library-actions-counter {
|
||||||
|
color: $oc-red-7;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&--export {
|
||||||
|
background-color: $oc-lime-5;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background-color: $oc-lime-7;
|
||||||
|
}
|
||||||
|
|
||||||
|
&:active {
|
||||||
|
background-color: $oc-lime-8;
|
||||||
|
}
|
||||||
|
svg {
|
||||||
|
color: $oc-white;
|
||||||
|
}
|
||||||
|
.library-actions-counter {
|
||||||
|
color: $oc-lime-5;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&--publish {
|
||||||
|
background-color: $oc-cyan-6;
|
||||||
|
&:hover {
|
||||||
|
background-color: $oc-cyan-7;
|
||||||
|
}
|
||||||
|
&:active {
|
||||||
|
background-color: $oc-cyan-9;
|
||||||
|
}
|
||||||
|
svg {
|
||||||
|
color: $oc-white;
|
||||||
|
}
|
||||||
|
label {
|
||||||
|
margin-left: -0.2em;
|
||||||
|
margin-right: 1.1em;
|
||||||
|
color: $oc-white;
|
||||||
|
font-size: 0.86em;
|
||||||
|
}
|
||||||
|
.library-actions-counter {
|
||||||
|
color: $oc-cyan-6;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&--load {
|
||||||
|
background-color: $oc-blue-6;
|
||||||
|
&:hover {
|
||||||
|
background-color: $oc-blue-7;
|
||||||
|
}
|
||||||
|
&:active {
|
||||||
|
background-color: $oc-blue-9;
|
||||||
|
}
|
||||||
|
svg {
|
||||||
|
color: $oc-white;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
&__items {
|
||||||
|
max-height: 50vh;
|
||||||
|
overflow: auto;
|
||||||
|
margin-top: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.separator {
|
||||||
|
font-weight: 500;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
margin: 0.6em 0.2em;
|
||||||
|
color: var(--text-primary-color);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
323
src/components/LibraryMenuItems.tsx
Normal file
323
src/components/LibraryMenuItems.tsx
Normal file
@@ -0,0 +1,323 @@
|
|||||||
|
import { chunk } from "lodash";
|
||||||
|
import { useCallback, useState } from "react";
|
||||||
|
import { importLibraryFromJSON, saveLibraryAsJSON } from "../data/json";
|
||||||
|
import Library from "../data/library";
|
||||||
|
import { ExcalidrawElement, NonDeleted } from "../element/types";
|
||||||
|
import { t } from "../i18n";
|
||||||
|
import {
|
||||||
|
AppState,
|
||||||
|
BinaryFiles,
|
||||||
|
ExcalidrawProps,
|
||||||
|
LibraryItem,
|
||||||
|
LibraryItems,
|
||||||
|
} from "../types";
|
||||||
|
import { muteFSAbortError } from "../utils";
|
||||||
|
import { useIsMobile } from "./App";
|
||||||
|
import ConfirmDialog from "./ConfirmDialog";
|
||||||
|
import { exportToFileIcon, load, publishIcon, trash } from "./icons";
|
||||||
|
import { LibraryUnit } from "./LibraryUnit";
|
||||||
|
import Stack from "./Stack";
|
||||||
|
import { ToolButton } from "./ToolButton";
|
||||||
|
import { Tooltip } from "./Tooltip";
|
||||||
|
|
||||||
|
import "./LibraryMenuItems.scss";
|
||||||
|
import { VERSIONS } from "../constants";
|
||||||
|
|
||||||
|
const LibraryMenuItems = ({
|
||||||
|
libraryItems,
|
||||||
|
onRemoveFromLibrary,
|
||||||
|
onAddToLibrary,
|
||||||
|
onInsertShape,
|
||||||
|
pendingElements,
|
||||||
|
theme,
|
||||||
|
setAppState,
|
||||||
|
libraryReturnUrl,
|
||||||
|
library,
|
||||||
|
files,
|
||||||
|
id,
|
||||||
|
selectedItems,
|
||||||
|
onToggle,
|
||||||
|
onPublish,
|
||||||
|
resetLibrary,
|
||||||
|
}: {
|
||||||
|
libraryItems: LibraryItems;
|
||||||
|
pendingElements: LibraryItem["elements"];
|
||||||
|
onRemoveFromLibrary: () => void;
|
||||||
|
onInsertShape: (elements: LibraryItem["elements"]) => void;
|
||||||
|
onAddToLibrary: (elements: LibraryItem["elements"]) => void;
|
||||||
|
theme: AppState["theme"];
|
||||||
|
files: BinaryFiles;
|
||||||
|
setAppState: React.Component<any, AppState>["setState"];
|
||||||
|
libraryReturnUrl: ExcalidrawProps["libraryReturnUrl"];
|
||||||
|
library: Library;
|
||||||
|
id: string;
|
||||||
|
selectedItems: LibraryItem["id"][];
|
||||||
|
onToggle: (id: LibraryItem["id"], event: React.MouseEvent) => void;
|
||||||
|
onPublish: () => void;
|
||||||
|
resetLibrary: () => void;
|
||||||
|
}) => {
|
||||||
|
const renderRemoveLibAlert = useCallback(() => {
|
||||||
|
const content = selectedItems.length
|
||||||
|
? t("alerts.removeItemsFromsLibrary", { count: selectedItems.length })
|
||||||
|
: t("alerts.resetLibrary");
|
||||||
|
const title = selectedItems.length
|
||||||
|
? t("confirmDialog.removeItemsFromLib")
|
||||||
|
: t("confirmDialog.resetLibrary");
|
||||||
|
return (
|
||||||
|
<ConfirmDialog
|
||||||
|
onConfirm={() => {
|
||||||
|
if (selectedItems.length) {
|
||||||
|
onRemoveFromLibrary();
|
||||||
|
} else {
|
||||||
|
resetLibrary();
|
||||||
|
}
|
||||||
|
setShowRemoveLibAlert(false);
|
||||||
|
}}
|
||||||
|
onCancel={() => {
|
||||||
|
setShowRemoveLibAlert(false);
|
||||||
|
}}
|
||||||
|
title={title}
|
||||||
|
>
|
||||||
|
<p>{content}</p>
|
||||||
|
</ConfirmDialog>
|
||||||
|
);
|
||||||
|
}, [selectedItems, onRemoveFromLibrary, resetLibrary]);
|
||||||
|
|
||||||
|
const [showRemoveLibAlert, setShowRemoveLibAlert] = useState(false);
|
||||||
|
|
||||||
|
const isMobile = useIsMobile();
|
||||||
|
|
||||||
|
const renderLibraryActions = () => {
|
||||||
|
const itemsSelected = !!selectedItems.length;
|
||||||
|
const items = itemsSelected
|
||||||
|
? libraryItems.filter((item) => selectedItems.includes(item.id))
|
||||||
|
: libraryItems;
|
||||||
|
const resetLabel = itemsSelected
|
||||||
|
? t("buttons.remove")
|
||||||
|
: t("buttons.resetLibrary");
|
||||||
|
return (
|
||||||
|
<div className="library-actions">
|
||||||
|
{(!itemsSelected || !isMobile) && (
|
||||||
|
<ToolButton
|
||||||
|
key="import"
|
||||||
|
type="button"
|
||||||
|
title={t("buttons.load")}
|
||||||
|
aria-label={t("buttons.load")}
|
||||||
|
icon={load}
|
||||||
|
onClick={() => {
|
||||||
|
importLibraryFromJSON(library)
|
||||||
|
.then(() => {
|
||||||
|
// Close and then open to get the libraries updated
|
||||||
|
setAppState({ isLibraryOpen: false });
|
||||||
|
setAppState({ isLibraryOpen: true });
|
||||||
|
})
|
||||||
|
.catch(muteFSAbortError)
|
||||||
|
.catch((error) => {
|
||||||
|
setAppState({ errorMessage: error.message });
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
className="library-actions--load"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{!!items.length && (
|
||||||
|
<>
|
||||||
|
<ToolButton
|
||||||
|
key="export"
|
||||||
|
type="button"
|
||||||
|
title={t("buttons.export")}
|
||||||
|
aria-label={t("buttons.export")}
|
||||||
|
icon={exportToFileIcon}
|
||||||
|
onClick={async () => {
|
||||||
|
const libraryItems = itemsSelected
|
||||||
|
? items
|
||||||
|
: await library.loadLibrary();
|
||||||
|
saveLibraryAsJSON(libraryItems)
|
||||||
|
.catch(muteFSAbortError)
|
||||||
|
.catch((error) => {
|
||||||
|
setAppState({ errorMessage: error.message });
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
className="library-actions--export"
|
||||||
|
>
|
||||||
|
{selectedItems.length > 0 && (
|
||||||
|
<span className="library-actions-counter">
|
||||||
|
{selectedItems.length}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</ToolButton>
|
||||||
|
<ToolButton
|
||||||
|
key="reset"
|
||||||
|
type="button"
|
||||||
|
title={resetLabel}
|
||||||
|
aria-label={resetLabel}
|
||||||
|
icon={trash}
|
||||||
|
onClick={() => setShowRemoveLibAlert(true)}
|
||||||
|
className="library-actions--remove"
|
||||||
|
>
|
||||||
|
{selectedItems.length > 0 && (
|
||||||
|
<span className="library-actions-counter">
|
||||||
|
{selectedItems.length}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</ToolButton>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{itemsSelected && !isPublished && (
|
||||||
|
<Tooltip label={t("hints.publishLibrary")}>
|
||||||
|
<ToolButton
|
||||||
|
type="button"
|
||||||
|
aria-label={t("buttons.publishLibrary")}
|
||||||
|
label={t("buttons.publishLibrary")}
|
||||||
|
icon={publishIcon}
|
||||||
|
className="library-actions--publish"
|
||||||
|
onClick={onPublish}
|
||||||
|
>
|
||||||
|
{!isMobile && <label>{t("buttons.publishLibrary")}</label>}
|
||||||
|
{selectedItems.length > 0 && (
|
||||||
|
<span className="library-actions-counter">
|
||||||
|
{selectedItems.length}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</ToolButton>
|
||||||
|
</Tooltip>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const CELLS_PER_ROW = isMobile ? 4 : 6;
|
||||||
|
|
||||||
|
const referrer =
|
||||||
|
libraryReturnUrl || window.location.origin + window.location.pathname;
|
||||||
|
const isPublished = selectedItems.some(
|
||||||
|
(id) => libraryItems.find((item) => item.id === id)?.status === "published",
|
||||||
|
);
|
||||||
|
|
||||||
|
const createLibraryItemCompo = (params: {
|
||||||
|
item:
|
||||||
|
| LibraryItem
|
||||||
|
| /* pending library item */ {
|
||||||
|
id: null;
|
||||||
|
elements: readonly NonDeleted<ExcalidrawElement>[];
|
||||||
|
}
|
||||||
|
| null;
|
||||||
|
onClick?: () => void;
|
||||||
|
key: string;
|
||||||
|
}) => {
|
||||||
|
return (
|
||||||
|
<Stack.Col key={params.key}>
|
||||||
|
<LibraryUnit
|
||||||
|
elements={params.item?.elements}
|
||||||
|
files={files}
|
||||||
|
isPending={!params.item?.id && !!params.item?.elements}
|
||||||
|
onClick={params.onClick || (() => {})}
|
||||||
|
id={params.item?.id || null}
|
||||||
|
selected={!!params.item?.id && selectedItems.includes(params.item.id)}
|
||||||
|
onToggle={(id, event) => {
|
||||||
|
onToggle(id, event);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</Stack.Col>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const renderLibrarySection = (
|
||||||
|
items: (
|
||||||
|
| LibraryItem
|
||||||
|
| /* pending library item */ {
|
||||||
|
id: null;
|
||||||
|
elements: readonly NonDeleted<ExcalidrawElement>[];
|
||||||
|
}
|
||||||
|
)[],
|
||||||
|
) => {
|
||||||
|
const _items = items.map((item) => {
|
||||||
|
if (item.id) {
|
||||||
|
return createLibraryItemCompo({
|
||||||
|
item,
|
||||||
|
onClick: () => onInsertShape(item.elements),
|
||||||
|
key: item.id,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return createLibraryItemCompo({
|
||||||
|
key: "__pending__item__",
|
||||||
|
item,
|
||||||
|
onClick: () => onAddToLibrary(pendingElements),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ensure we render all empty cells if no items are present
|
||||||
|
let rows = chunk(_items, CELLS_PER_ROW);
|
||||||
|
if (!rows.length) {
|
||||||
|
rows = [[]];
|
||||||
|
}
|
||||||
|
|
||||||
|
return rows.map((rowItems, index, rows) => {
|
||||||
|
if (index === rows.length - 1) {
|
||||||
|
// pad row with empty cells
|
||||||
|
rowItems = rowItems.concat(
|
||||||
|
new Array(CELLS_PER_ROW - rowItems.length)
|
||||||
|
.fill(null)
|
||||||
|
.map((_, index) => {
|
||||||
|
return createLibraryItemCompo({
|
||||||
|
key: `empty_${index}`,
|
||||||
|
item: null,
|
||||||
|
});
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<Stack.Row align="center" gap={1} key={index}>
|
||||||
|
{rowItems}
|
||||||
|
</Stack.Row>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const publishedItems = libraryItems.filter(
|
||||||
|
(item) => item.status === "published",
|
||||||
|
);
|
||||||
|
const unpublishedItems = [
|
||||||
|
// append pending library item
|
||||||
|
...(pendingElements.length
|
||||||
|
? [{ id: null, elements: pendingElements }]
|
||||||
|
: []),
|
||||||
|
...libraryItems.filter((item) => item.status !== "published"),
|
||||||
|
];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="library-menu-items-container">
|
||||||
|
{showRemoveLibAlert && renderRemoveLibAlert()}
|
||||||
|
<div className="layer-ui__library-header" key="library-header">
|
||||||
|
{renderLibraryActions()}
|
||||||
|
<a
|
||||||
|
href={`${process.env.REACT_APP_LIBRARY_URL}?target=${
|
||||||
|
window.name || "_blank"
|
||||||
|
}&referrer=${referrer}&useHash=true&token=${id}&theme=${theme}&version=${
|
||||||
|
VERSIONS.excalidrawLibrary
|
||||||
|
}`}
|
||||||
|
target="_excalidraw_libraries"
|
||||||
|
>
|
||||||
|
{t("labels.libraries")}
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
<Stack.Col
|
||||||
|
className="library-menu-items-container__items"
|
||||||
|
align="start"
|
||||||
|
gap={1}
|
||||||
|
>
|
||||||
|
<>
|
||||||
|
<div className="separator">{t("labels.personalLib")}</div>
|
||||||
|
{renderLibrarySection(unpublishedItems)}
|
||||||
|
</>
|
||||||
|
|
||||||
|
<>
|
||||||
|
<div className="separator">{t("labels.excalidrawLib")} </div>
|
||||||
|
|
||||||
|
{renderLibrarySection(publishedItems)}
|
||||||
|
</>
|
||||||
|
</Stack.Col>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default LibraryMenuItems;
|
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user