mirror of
https://github.com/excalidraw/excalidraw.git
synced 2025-09-01 22:57:19 +02:00
Compare commits
594 Commits
upload-ima
...
make_defau
Author | SHA1 | Date | |
---|---|---|---|
![]() |
fe973e3513 | ||
![]() |
ade2565f49 | ||
![]() |
c35d983fef | ||
![]() |
69878167c2 | ||
![]() |
eb1f717d35 | ||
![]() |
8e9af5c51b | ||
![]() |
afe0c760f6 | ||
![]() |
a231cefac0 | ||
![]() |
cb4c9d16fc | ||
![]() |
7366f089ba | ||
![]() |
7c3513b9df | ||
![]() |
aef3644c93 | ||
![]() |
0cf58adb4c | ||
![]() |
3aab81bc35 | ||
![]() |
3b0fb1562d | ||
![]() |
0488b7b5c6 | ||
![]() |
b8d13c98b5 | ||
![]() |
6f82a88b79 | ||
![]() |
022f349dc6 | ||
![]() |
c1e2146d78 | ||
![]() |
8091ac6c08 | ||
![]() |
bc414ccaaf | ||
![]() |
0cf5f1ac1f | ||
![]() |
e9cb7ee77c | ||
![]() |
86c036505b | ||
![]() |
39e7b8cf4f | ||
![]() |
e0ece680a6 | ||
![]() |
8dfea49ec1 | ||
![]() |
d7f314cda8 | ||
![]() |
6adb45ef5a | ||
![]() |
b0eeb8e6e6 | ||
![]() |
c3c20b6087 | ||
![]() |
0faec7efb6 | ||
![]() |
aff817c667 | ||
![]() |
9a3a3ecb44 | ||
![]() |
81f8039ec7 | ||
![]() |
14759d5b72 | ||
![]() |
b997e69ebc | ||
![]() |
4a89aba682 | ||
![]() |
34dcf998bd | ||
![]() |
325d1bec91 | ||
![]() |
b917e42694 | ||
![]() |
eb9e67e36a | ||
![]() |
f14ae52e94 | ||
![]() |
f93eb658d6 | ||
![]() |
9bac44ee75 | ||
![]() |
91b4109f67 | ||
![]() |
6e45cb95db | ||
![]() |
5edf82898b | ||
![]() |
60b82e3055 | ||
![]() |
5d6590c200 | ||
![]() |
d3bebbc68d | ||
![]() |
4ff8f3b006 | ||
![]() |
abbc756887 | ||
![]() |
10e07e434c | ||
![]() |
fb582b45db | ||
![]() |
23f21434ff | ||
![]() |
7e9fdf85a0 | ||
![]() |
98c26642d0 | ||
![]() |
2b434db062 | ||
![]() |
f919907855 | ||
![]() |
bfeb3c7dfd | ||
![]() |
8729ab3c54 | ||
![]() |
37f53bccbf | ||
![]() |
c783763307 | ||
![]() |
f151f45df8 | ||
![]() |
f33880e005 | ||
![]() |
29ed50f6ea | ||
![]() |
ce52c18382 | ||
![]() |
7c3e1d8d1b | ||
![]() |
0d15934a96 | ||
![]() |
a0069d04f0 | ||
![]() |
3b86944365 | ||
![]() |
4c7b1a2269 | ||
![]() |
60864ace54 | ||
![]() |
4b32c03994 | ||
![]() |
222dbdcc00 | ||
![]() |
1346227d30 | ||
![]() |
ecbddd214c | ||
![]() |
4999ca5c82 | ||
![]() |
174638889d | ||
![]() |
480998582e | ||
![]() |
94544e458c | ||
![]() |
f664ba9e1e | ||
![]() |
015a60638e | ||
![]() |
7abb80530f | ||
![]() |
5abe9b93e8 | ||
![]() |
59cff0f219 | ||
![]() |
81c17a56fb | ||
![]() |
802b8c50d5 | ||
![]() |
94fe1ff6e6 | ||
![]() |
9cfe7b45e5 | ||
![]() |
9cf54041dc | ||
![]() |
8f269eb840 | ||
![]() |
1a134a88bd | ||
![]() |
ae15380a9f | ||
![]() |
0efa62cf7c | ||
![]() |
c742225f43 | ||
![]() |
7c7fb4903b | ||
![]() |
a4e1f2c5c1 | ||
![]() |
068c9b4876 | ||
![]() |
b2d442abce | ||
![]() |
7bfe7a1924 | ||
![]() |
48e27f327f | ||
![]() |
182a3e39e1 | ||
![]() |
4672a2a135 | ||
![]() |
c7b5cdb71e | ||
![]() |
4d71078f48 | ||
![]() |
38e1a0fd05 | ||
![]() |
8f8fd023f8 | ||
![]() |
fba37e422d | ||
![]() |
88fc961559 | ||
![]() |
c291edfc44 | ||
![]() |
bd8e860d7f | ||
![]() |
3be5038c14 | ||
![]() |
5e57f408c5 | ||
![]() |
dd993adc5c | ||
![]() |
5cdb9bd2ed | ||
![]() |
d055fc0334 | ||
![]() |
fbdf796c9f | ||
![]() |
90867ed9c1 | ||
![]() |
6d6bf52f88 | ||
![]() |
aa221837fc | ||
![]() |
4c90ea5667 | ||
![]() |
5071cffb02 | ||
![]() |
fb02329c11 | ||
![]() |
6081bb5941 | ||
![]() |
533815c081 | ||
![]() |
2b6d1470f9 | ||
![]() |
545b214558 | ||
![]() |
e617ccc252 | ||
![]() |
d8a0dc3b4d | ||
![]() |
e392bebc40 | ||
![]() |
a2132c9bb7 | ||
![]() |
66e5b18e4e | ||
![]() |
c43109a230 | ||
![]() |
668150a667 | ||
![]() |
0ef60dce2d | ||
![]() |
abde1daba4 | ||
![]() |
014097a97e | ||
![]() |
58fcb44de0 | ||
![]() |
102169581c | ||
![]() |
eb75dc55cb | ||
![]() |
df33ab23f8 | ||
![]() |
e8421bc5ab | ||
![]() |
36980160ae | ||
![]() |
084aff2bf3 | ||
![]() |
bdb1fb2dae | ||
![]() |
b21fd49412 | ||
![]() |
204c8370a0 | ||
![]() |
ca60244aa3 | ||
![]() |
6c0296c434 | ||
![]() |
1269b9ab17 | ||
![]() |
2f9a849170 | ||
![]() |
8d479ab238 | ||
![]() |
fec48060f7 | ||
![]() |
2de7f73a71 | ||
![]() |
198106e297 | ||
![]() |
d3c3894108 | ||
![]() |
6718902645 | ||
![]() |
38aa6da7a3 | ||
![]() |
2c008c8adf | ||
![]() |
a6292a789e | ||
![]() |
5f7d48e551 | ||
![]() |
c7831e854d | ||
![]() |
7aa58561c8 | ||
![]() |
db5acff860 | ||
![]() |
a267fc85b4 | ||
![]() |
7d2ce4e52f | ||
![]() |
5e6ec19ce1 | ||
![]() |
9a38f87147 | ||
![]() |
5e7d09d723 | ||
![]() |
5db77a4e7d | ||
![]() |
ca3cf6971d | ||
![]() |
a1fbec1030 | ||
![]() |
4371c29f0c | ||
![]() |
bf143ed0b8 | ||
![]() |
68aafe31f9 | ||
![]() |
b06cf86811 | ||
![]() |
5b829772d9 | ||
![]() |
dad9ad9bf4 | ||
![]() |
f90cbeb089 | ||
![]() |
d2a730837e | ||
![]() |
5b63371c14 | ||
![]() |
a05679b3c5 | ||
![]() |
396c49c7d8 | ||
![]() |
0d01738029 | ||
![]() |
c5c375dd2e | ||
![]() |
b09d96ad14 | ||
![]() |
a58873af13 | ||
![]() |
08031d3f85 | ||
![]() |
a20f3240fd | ||
![]() |
e05acd6fd9 | ||
![]() |
56938cf874 | ||
![]() |
5d295415db | ||
![]() |
2a20c44338 | ||
![]() |
dcedd17f57 | ||
![]() |
455badb23e | ||
![]() |
566e6a5ede | ||
![]() |
facde7ace0 | ||
![]() |
eca2bdabcc | ||
![]() |
a7da8901d8 | ||
![]() |
58861e87e5 | ||
![]() |
a646a12758 | ||
![]() |
1be4a2d649 | ||
![]() |
25feaefe9e | ||
![]() |
7491fcc3f3 | ||
![]() |
856ab50090 | ||
![]() |
411bc2aa0a | ||
![]() |
ba3f548b91 | ||
![]() |
8a50916ef2 | ||
![]() |
471a8b7676 | ||
![]() |
56215c6c2b | ||
![]() |
fc58e51ab3 | ||
![]() |
5c26bd19d7 | ||
![]() |
9de6c947ef | ||
![]() |
44af6b4a78 | ||
![]() |
a47d372a90 | ||
![]() |
f4da7f38d2 | ||
![]() |
2098db901c | ||
![]() |
8621eb0d0c | ||
![]() |
fb8be3ba0f | ||
![]() |
348976aa59 | ||
![]() |
2d6a231634 | ||
![]() |
41d4bb1491 | ||
![]() |
7b53d29066 | ||
![]() |
6f5a285266 | ||
![]() |
9e40dcdcdc | ||
![]() |
5e55e77f54 | ||
![]() |
f404ab6f50 | ||
![]() |
915bda9fd8 | ||
![]() |
46c421ee26 | ||
![]() |
5168a03373 | ||
![]() |
36700b9376 | ||
![]() |
900e0f27ad | ||
![]() |
e916d7f6f6 | ||
![]() |
1845b5e32c | ||
![]() |
72a3450c99 | ||
![]() |
499a60309f | ||
![]() |
1034ec91b8 | ||
![]() |
6db5647048 | ||
![]() |
cc5e27af42 | ||
![]() |
f7f27bba17 | ||
![]() |
adef15862c | ||
![]() |
62b69c3dd7 | ||
![]() |
7c1d0175bf | ||
![]() |
fdae50cece | ||
![]() |
31aab2d202 | ||
![]() |
12b73aaac6 | ||
![]() |
b50c54f855 | ||
![]() |
1484c5a63b | ||
![]() |
4a26845395 | ||
![]() |
41ccd47791 | ||
![]() |
8f5c5f80d3 | ||
![]() |
538f2be097 | ||
![]() |
25d460be96 | ||
![]() |
b3263c2a69 | ||
![]() |
e8a39b5f84 | ||
![]() |
f40a2230ec | ||
![]() |
5950fa9a40 | ||
![]() |
7618ca48d7 | ||
![]() |
b91f929503 | ||
![]() |
d20cdbd736 | ||
![]() |
dab9659590 | ||
![]() |
a0a8397fb4 | ||
![]() |
8b1e0275cf | ||
![]() |
d012fda59d | ||
![]() |
63566ecb92 | ||
![]() |
8a10f2a0b8 | ||
![]() |
3835fa60e4 | ||
![]() |
215128ffdf | ||
![]() |
d18a72c879 | ||
![]() |
ae1ab1ab37 | ||
![]() |
e424ca53c6 | ||
![]() |
556f9123f8 | ||
![]() |
1963afe289 | ||
![]() |
6382b82acf | ||
![]() |
823a5697c0 | ||
![]() |
04f2564947 | ||
![]() |
16d3d2fb54 | ||
![]() |
6ca7420252 | ||
![]() |
d0985fe67a | ||
![]() |
f2135ab739 | ||
![]() |
8ab9ffbe28 | ||
![]() |
6b7516bc3c | ||
![]() |
a61b212220 | ||
![]() |
403576861c | ||
![]() |
66c345b732 | ||
![]() |
65d9352648 | ||
![]() |
1404b4b958 | ||
![]() |
22f12352c6 | ||
![]() |
adb1ac5788 | ||
![]() |
b2822f3538 | ||
![]() |
68bdfaefbe | ||
![]() |
2a2630082f | ||
![]() |
e3f3427b31 | ||
![]() |
c814917927 | ||
![]() |
8b8adb146f | ||
![]() |
3e404d33fd | ||
![]() |
3d3af88a41 | ||
![]() |
d6adfe88bd | ||
![]() |
786d1bba94 | ||
![]() |
31f7e2b8b2 | ||
![]() |
55ecbdcca9 | ||
![]() |
7d7ce04e9b | ||
![]() |
fbc4c70ed8 | ||
![]() |
242ccac290 | ||
![]() |
b9d584714a | ||
![]() |
c6736fa14e | ||
![]() |
d07099aadd | ||
![]() |
ea020f2c50 | ||
![]() |
48c2a13c7a | ||
![]() |
aaddde5dd9 | ||
![]() |
9cac7816cc | ||
![]() |
f2401c9163 | ||
![]() |
18759ec133 | ||
![]() |
6319f9b156 | ||
![]() |
47dba05c91 | ||
![]() |
950ec66907 | ||
![]() |
640dcc90c2 | ||
![]() |
15e4b51bb1 | ||
![]() |
924292dc9a | ||
![]() |
a693b36d37 | ||
![]() |
dd0c44864d | ||
![]() |
6824572d21 | ||
![]() |
7ca9452d80 | ||
![]() |
825aa86016 | ||
![]() |
2d59689436 | ||
![]() |
b7e3d98c0f | ||
![]() |
98a4d0a595 | ||
![]() |
b215e165d2 | ||
![]() |
730a11e0a5 | ||
![]() |
0ab58b38e0 | ||
![]() |
46bff3dace | ||
![]() |
d15444e232 | ||
![]() |
7ebeae2d38 | ||
![]() |
26ef235019 | ||
![]() |
0e28177ccc | ||
![]() |
1828a93ba7 | ||
![]() |
84c49ebaa1 | ||
![]() |
8b9e2a540d | ||
![]() |
4c2d34ffd7 | ||
![]() |
b8f8bc2e32 | ||
![]() |
546e13571d | ||
![]() |
e7d186b439 | ||
![]() |
4718c31da5 | ||
![]() |
0e0a695e81 | ||
![]() |
86d9bee3f4 | ||
![]() |
d58e71d566 | ||
![]() |
37b4883629 | ||
![]() |
3c66335ec1 | ||
![]() |
0470e85da7 | ||
![]() |
b39e282ca2 | ||
![]() |
94555287d4 | ||
![]() |
e34cf3aee3 | ||
![]() |
960b640e89 | ||
![]() |
eda8333c05 | ||
![]() |
c3de4cd4c5 | ||
![]() |
643e6bd08d | ||
![]() |
ab7073abdb | ||
![]() |
80cbe13167 | ||
![]() |
aac83325c5 | ||
![]() |
460ec83ca5 | ||
![]() |
7e1b919fba | ||
![]() |
abfc265f01 | ||
![]() |
01a1504cb3 | ||
![]() |
7edffd8e7b | ||
![]() |
036bdcfa3f | ||
![]() |
c6e2877418 | ||
![]() |
415bf7bb5b | ||
![]() |
4644ca1778 | ||
![]() |
009eba6315 | ||
![]() |
14317c2232 | ||
![]() |
41cb1fbeba | ||
![]() |
930813387b | ||
![]() |
3f2b0fdd0a | ||
![]() |
2cb8ba6521 | ||
![]() |
5670c47789 | ||
![]() |
c0dd870c6e | ||
![]() |
c9d5ec9277 | ||
![]() |
6e5b1a1c2a | ||
![]() |
296e3677cf | ||
![]() |
a96406f505 | ||
![]() |
36c01eb982 | ||
![]() |
bd1a1c966e | ||
![]() |
964746e569 | ||
![]() |
950bcd0b72 | ||
![]() |
85d000ccda | ||
![]() |
8bbeb32e87 | ||
![]() |
26f67d27ec | ||
![]() |
5f195694ee | ||
![]() |
403e8bd307 | ||
![]() |
c06988a202 | ||
![]() |
0eff9d525d | ||
![]() |
e4f429f1c1 | ||
![]() |
32d5507d41 | ||
![]() |
7f4e50d6de | ||
![]() |
4dfb043331 | ||
![]() |
c7b45da85f | ||
![]() |
fea257765d | ||
![]() |
818821c293 | ||
![]() |
c171fb4c7f | ||
![]() |
20500b7822 | ||
![]() |
54f8d8f820 | ||
![]() |
ab980b252c | ||
![]() |
925db9dcca | ||
![]() |
c0ca6bae37 | ||
![]() |
ee8fa6aaad | ||
![]() |
7eff6893c5 | ||
![]() |
63edbb9517 | ||
![]() |
a4ad22bc4d | ||
![]() |
a945e16274 | ||
![]() |
f0ac606ed7 | ||
![]() |
f295550940 | ||
![]() |
df4e903bd6 | ||
![]() |
a2e7d8d560 | ||
![]() |
ebf2923c5e | ||
![]() |
880cac2359 | ||
![]() |
d3a38202e3 | ||
![]() |
dc1f6c4d4c | ||
![]() |
c5d37a07c8 | ||
![]() |
2d8430593d | ||
![]() |
3c52c5bfc2 | ||
![]() |
5a7595cf4e | ||
![]() |
76c36397bc | ||
![]() |
46574713eb | ||
![]() |
365a03e930 | ||
![]() |
f9524c79b0 | ||
![]() |
f15d62aa44 | ||
![]() |
b07aa6e205 | ||
![]() |
cf36cb394b | ||
![]() |
29f803e25d | ||
![]() |
e2640edb79 | ||
![]() |
c1488fa353 | ||
![]() |
494b7d08c5 | ||
![]() |
4cfc8bd4b3 | ||
![]() |
f9793835e6 | ||
![]() |
bac20fa641 | ||
![]() |
e2cc961c76 | ||
![]() |
eb3455fc03 | ||
![]() |
8ab7921796 | ||
![]() |
8f3ccac54c | ||
![]() |
77254aa2f7 | ||
![]() |
c4a308e4e6 | ||
![]() |
953cd5563c | ||
![]() |
0ee2c15929 | ||
![]() |
6428b59ccb | ||
![]() |
7ab0c1aba8 | ||
![]() |
4ab4fce998 | ||
![]() |
6e357c0291 | ||
![]() |
51a8ab65f3 | ||
![]() |
5664de0459 | ||
![]() |
6cc6e13892 | ||
![]() |
5d7020cce6 | ||
![]() |
d5e7d08586 | ||
![]() |
1b9b824c70 | ||
![]() |
6f13b5ac75 | ||
![]() |
df5eb3f0d9 | ||
![]() |
6f1cff101a | ||
![]() |
eb9a9b628a | ||
![]() |
3a4bc68025 | ||
![]() |
01e546c230 | ||
![]() |
ead58bf2d9 | ||
![]() |
9351b2821c | ||
![]() |
b1261eea70 | ||
![]() |
84abda82d5 | ||
![]() |
93137c0bb2 | ||
![]() |
30cbe21a47 | ||
![]() |
cc52ea4ac2 | ||
![]() |
8621ddb6a2 | ||
![]() |
b21f723eee | ||
![]() |
d9e84b90ce | ||
![]() |
79c3b846d7 | ||
![]() |
d39d8e3cb1 | ||
![]() |
24fe05f023 | ||
![]() |
53d2c67b52 | ||
![]() |
2ee8bb9846 | ||
![]() |
19038d0d7e | ||
![]() |
bcf9cc2a5b | ||
![]() |
ecc3a72583 | ||
![]() |
89cf826555 | ||
![]() |
2a25480272 | ||
![]() |
9a5ae05bbf | ||
![]() |
54a72b821a | ||
![]() |
8c3549f336 | ||
![]() |
e23f7d37b6 | ||
![]() |
483796f6ff | ||
![]() |
a679ef7876 | ||
![]() |
0a3fb70ec7 | ||
![]() |
5970bb7ee9 | ||
![]() |
1991511ef7 | ||
![]() |
cd87bd6901 | ||
![]() |
9c89504b6f | ||
![]() |
159890860a | ||
![]() |
caa9b54893 | ||
![]() |
e7ef02cc0f | ||
![]() |
24a385ff73 | ||
![]() |
cb13cc36cf | ||
![]() |
5444abc822 | ||
![]() |
76cbefb4ce | ||
![]() |
baa8fb6c14 | ||
![]() |
b6bf011d0d | ||
![]() |
482fa2d90f | ||
![]() |
a357d00bbe | ||
![]() |
bda8415714 | ||
![]() |
ca87ca6fe9 | ||
![]() |
8f65e37dac | ||
![]() |
f1ceeab8d9 | ||
![]() |
046c0818c5 | ||
![]() |
6baa091762 | ||
![]() |
2bab4d2c24 | ||
![]() |
3a4e48e5af | ||
![]() |
ed89cfc62c | ||
![]() |
ed0d707851 | ||
![]() |
0f15608ad5 | ||
![]() |
2eb58da4c3 | ||
![]() |
f0463fadc3 | ||
![]() |
3cee0768cc | ||
![]() |
5d3867d8ac | ||
![]() |
0ed6a96b6a | ||
![]() |
dadf054ea2 | ||
![]() |
6b87278a0f | ||
![]() |
998f0ae458 | ||
![]() |
60973f6dc5 | ||
![]() |
53ab46126d | ||
![]() |
ebb1341bbd | ||
![]() |
5a5f9aed0d | ||
![]() |
bec8e55c42 | ||
![]() |
1df43d0f9f | ||
![]() |
d171e9705d | ||
![]() |
f7c4efbd35 | ||
![]() |
d1be2a5481 | ||
![]() |
4eb6c3e8a4 | ||
![]() |
476c0e9f8a | ||
![]() |
c6e73c56fd | ||
![]() |
669e84b5d7 | ||
![]() |
ff93d95998 | ||
![]() |
1f375522d6 | ||
![]() |
5ed4614a8c | ||
![]() |
a118bed82f | ||
![]() |
fd75b88bd3 | ||
![]() |
1e9adf0a80 | ||
![]() |
0190af2d76 | ||
![]() |
b0d3f18824 | ||
![]() |
b74fa0dcf0 | ||
![]() |
3f31a6ce89 | ||
![]() |
d3d9994c74 | ||
![]() |
14a66956d7 | ||
![]() |
db316f32e0 | ||
![]() |
9151da772c | ||
![]() |
f413bab3de | ||
![]() |
17e9cc4506 | ||
![]() |
fa359034c5 | ||
![]() |
0db7ac78c4 | ||
![]() |
44a88d2d58 | ||
![]() |
56f8bc092d | ||
![]() |
4f3bf79708 | ||
![]() |
7edcea9a93 | ||
![]() |
5327e8a3dc | ||
![]() |
5b75925928 | ||
![]() |
ce8d88a486 | ||
![]() |
63c10743fb | ||
![]() |
46b574283f | ||
![]() |
61e5b66dac | ||
![]() |
5252726307 | ||
![]() |
5a64447adc | ||
![]() |
f1afeda62c | ||
![]() |
1a1cbb345b | ||
![]() |
2867af6528 | ||
![]() |
35049e3de7 | ||
![]() |
d315e3dc4d | ||
![]() |
6512ede9ca | ||
![]() |
51608c07b0 | ||
![]() |
d2ae18995c | ||
![]() |
22f7945c70 | ||
![]() |
ce6f2ff88c | ||
![]() |
fb897c75a7 | ||
![]() |
584e4182a7 | ||
![]() |
c427aa3cce | ||
![]() |
33fe223b5d | ||
![]() |
8de47ed36b | ||
![]() |
cc3abfc6ff | ||
![]() |
4d2e8f9ad1 | ||
![]() |
6b628bb1a6 | ||
![]() |
7f35b805d1 | ||
![]() |
755cd9c320 | ||
![]() |
afbfe2b8b1 | ||
![]() |
9bd72f91fc | ||
![]() |
876170ee27 | ||
![]() |
39c56a4c01 | ||
![]() |
f6be200388 | ||
![]() |
828c9c4d65 | ||
![]() |
d9b8dcfbb4 | ||
![]() |
c32640d174 |
@@ -2,6 +2,9 @@
|
||||
!public/
|
||||
!src/
|
||||
!.npmrc
|
||||
!.eslintrc.json
|
||||
!.prettierrc
|
||||
!package-lock.json
|
||||
!package.json
|
||||
!tsconfig.json
|
||||
!.env
|
||||
|
5
.env
Normal file
5
.env
Normal file
@@ -0,0 +1,5 @@
|
||||
REACT_APP_BACKEND_V1_GET_URL=https://json.excalidraw.com/api/v1/
|
||||
REACT_APP_BACKEND_V2_GET_URL=https://json.excalidraw.com/api/v2/
|
||||
REACT_APP_BACKEND_V2_POST_URL=https://json.excalidraw.com/api/v2/post/
|
||||
REACT_APP_SOCKET_SERVER_URL=https://portal.excalidraw.com
|
||||
REACT_APP_FIREBASE_CONFIG='{"apiKey":"AIzaSyAd15pYlMci_xIp9ko6wkEsDzAAA0Dn0RU","authDomain":"excalidraw-room-persistence.firebaseapp.com","databaseURL":"https://excalidraw-room-persistence.firebaseio.com","projectId":"excalidraw-room-persistence","storageBucket":"excalidraw-room-persistence.appspot.com","messagingSenderId":"654800341332","appId":"1:654800341332:web:4a692de832b55bd57ce0c1"}'
|
1
.env.production
Normal file
1
.env.production
Normal file
@@ -0,0 +1 @@
|
||||
REACT_APP_INCLUDE_GTAG=true
|
@@ -3,3 +3,4 @@ build/
|
||||
package-lock.json
|
||||
.vscode/
|
||||
firebase/
|
||||
dist/
|
||||
|
@@ -3,6 +3,8 @@
|
||||
"plugins": ["prettier"],
|
||||
"rules": {
|
||||
"curly": "warn",
|
||||
"dot-notation": "warn",
|
||||
"import/no-anonymous-default-export": "off",
|
||||
"no-console": [
|
||||
"warn",
|
||||
{
|
||||
@@ -10,7 +12,22 @@
|
||||
}
|
||||
],
|
||||
"no-else-return": "warn",
|
||||
"no-lonely-if": "warn",
|
||||
"no-restricted-syntax": [
|
||||
"warn",
|
||||
{
|
||||
"message": "Use 't(...)' instead of literal text in JSX",
|
||||
"selector": "JSXText[value=/\\w/]"
|
||||
}
|
||||
],
|
||||
"no-unneeded-ternary": "warn",
|
||||
"no-unused-expressions": "warn",
|
||||
"no-unused-vars": "warn",
|
||||
"no-useless-return": "warn",
|
||||
"no-var": "warn",
|
||||
"object-shorthand": "warn",
|
||||
"one-var": ["warn", "never"],
|
||||
"prefer-arrow-callback": "warn",
|
||||
"prefer-const": [
|
||||
"warn",
|
||||
{
|
||||
@@ -18,13 +35,6 @@
|
||||
}
|
||||
],
|
||||
"prefer-template": "warn",
|
||||
"prettier/prettier": "warn",
|
||||
"no-restricted-syntax": [
|
||||
"warn",
|
||||
{
|
||||
"selector": "JSXText[value=/\\w/]",
|
||||
"message": "Use 't(...)' instead of literal text in JSX"
|
||||
}
|
||||
]
|
||||
"prettier/prettier": "warn"
|
||||
}
|
||||
}
|
||||
|
1
.gitattributes
vendored
Normal file
1
.gitattributes
vendored
Normal file
@@ -0,0 +1 @@
|
||||
* text=auto eol=lf
|
37
.github/dependabot.yml
vendored
Normal file
37
.github/dependabot.yml
vendored
Normal file
@@ -0,0 +1,37 @@
|
||||
version: 2
|
||||
updates:
|
||||
- package-ecosystem: npm
|
||||
directory: "/"
|
||||
schedule:
|
||||
interval: weekly
|
||||
day: sunday
|
||||
time: "01:00"
|
||||
open-pull-requests-limit: 99
|
||||
reviewers:
|
||||
- lipis
|
||||
assignees:
|
||||
- lipis
|
||||
|
||||
- package-ecosystem: npm
|
||||
directory: "/src/packages/excalidraw/"
|
||||
schedule:
|
||||
interval: weekly
|
||||
day: sunday
|
||||
time: "01:00"
|
||||
open-pull-requests-limit: 99
|
||||
reviewers:
|
||||
- ad1992
|
||||
assignees:
|
||||
- ad1992
|
||||
|
||||
- package-ecosystem: npm
|
||||
directory: "/src/packages/utils/"
|
||||
schedule:
|
||||
interval: weekly
|
||||
day: sunday
|
||||
time: "01:00"
|
||||
open-pull-requests-limit: 99
|
||||
reviewers:
|
||||
- ad1992
|
||||
assignees:
|
||||
- ad1992
|
14
.github/workflows/build-docker.yml
vendored
Normal file
14
.github/workflows/build-docker.yml
vendored
Normal file
@@ -0,0 +1,14 @@
|
||||
name: Build Docker image
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- master
|
||||
|
||||
jobs:
|
||||
build-docker:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v1
|
||||
- run: docker build -t excalidraw .
|
33
.github/workflows/build-packages.yml
vendored
Normal file
33
.github/workflows/build-packages.yml
vendored
Normal file
@@ -0,0 +1,33 @@
|
||||
name: Build packages
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- master
|
||||
pull_request:
|
||||
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v1
|
||||
|
||||
- name: Setup Node.js 12.x
|
||||
uses: actions/setup-node@v1
|
||||
with:
|
||||
node-version: 12.x
|
||||
|
||||
- name: Install dependencies
|
||||
run: |
|
||||
npm ci
|
||||
npm ci --prefix src/packages/excalidraw
|
||||
npm ci --prefix src/packages/utils
|
||||
|
||||
- name: Build @excalidraw/excalidraw
|
||||
run: |
|
||||
npm run pack --prefix src/packages/excalidraw
|
||||
|
||||
- name: Build @excalidraw/utils
|
||||
run: |
|
||||
npm run pack --prefix src/packages/utils
|
47
.github/workflows/locales-coverage.yml
vendored
Normal file
47
.github/workflows/locales-coverage.yml
vendored
Normal file
@@ -0,0 +1,47 @@
|
||||
name: Build locales coverage
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- "l10n_master"
|
||||
|
||||
jobs:
|
||||
locales:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v2
|
||||
with:
|
||||
token: ${{ secrets.PUSH_TRANSLATIONS_COVERAGE_PAT }}
|
||||
|
||||
- name: Setup Node.js 12.x
|
||||
uses: actions/setup-node@v1
|
||||
with:
|
||||
node-version: 12.x
|
||||
|
||||
- name: Create report file
|
||||
run: |
|
||||
npm run locales-coverage
|
||||
FILE_CHANGED=$(git diff src/locales/percentages.json)
|
||||
if [ ! -z "${FILE_CHANGED}" ]; then
|
||||
git config --global user.name 'Kostas Bariotis'
|
||||
git config --global user.email 'konmpar@gmail.com'
|
||||
git add src/locales/percentages.json
|
||||
git commit -am "Auto commit: Calculate translation coverage"
|
||||
git push
|
||||
fi
|
||||
- name: Construct comment body
|
||||
id: getCommentBody
|
||||
run: |
|
||||
body=$(npm run locales-coverage:description | grep '^[^>]')
|
||||
body="${body//'%'/'%25'}"
|
||||
body="${body//$'\n'/'%0A'}"
|
||||
body="${body//$'\r'/'%0D'}"
|
||||
echo ::set-output name=body::$body
|
||||
|
||||
- name: Update description with coverage
|
||||
uses: kt3k/update-pr-description@v1.0.1
|
||||
with:
|
||||
pr_body: ${{ steps.getCommentBody.outputs.body }}
|
||||
pr_title: "chore: New Crowdin updates"
|
||||
github_token: ${{ secrets.PUSH_TRANSLATIONS_COVERAGE_PAT }}
|
20
.github/workflows/publish-docker.yml
vendored
Normal file
20
.github/workflows/publish-docker.yml
vendored
Normal file
@@ -0,0 +1,20 @@
|
||||
name: Publish Docker
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- master
|
||||
|
||||
jobs:
|
||||
publish-docker:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v2
|
||||
- uses: docker/build-push-action@v1
|
||||
with:
|
||||
username: ${{ secrets.DOCKER_USERNAME }}
|
||||
password: ${{ secrets.DOCKER_PASSWORD }}
|
||||
repository: excalidraw/excalidraw
|
||||
tag_with_ref: true
|
||||
tag_with_sha: true
|
16
.github/workflows/semantic-pr-title.yml
vendored
Normal file
16
.github/workflows/semantic-pr-title.yml
vendored
Normal file
@@ -0,0 +1,16 @@
|
||||
name: "Semantic PR title"
|
||||
|
||||
on:
|
||||
pull_request_target:
|
||||
types:
|
||||
- opened
|
||||
- edited
|
||||
- synchronize
|
||||
|
||||
jobs:
|
||||
main:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: amannn/action-semantic-pull-request@v2.1.0
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
15
.gitignore
vendored
15
.gitignore
vendored
@@ -1,10 +1,18 @@
|
||||
*.log
|
||||
.DS_Store
|
||||
.env.development.local
|
||||
.env.local
|
||||
.env.production.local
|
||||
.env.test.local
|
||||
.envrc
|
||||
.now
|
||||
.eslintcache
|
||||
.idea
|
||||
.vercel
|
||||
.vscode
|
||||
*.log
|
||||
*.tgz
|
||||
build
|
||||
firebase/
|
||||
dist
|
||||
firebase
|
||||
logs
|
||||
node_modules
|
||||
npm-debug.log*
|
||||
@@ -12,4 +20,3 @@ static
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
yarn.lock
|
||||
.idea
|
||||
|
@@ -1,7 +1,7 @@
|
||||
const { CLIEngine } = require("eslint");
|
||||
|
||||
// see https://github.com/okonet/lint-staged#how-can-i-ignore-files-from-eslintignore-
|
||||
// for explanation
|
||||
// for explanation
|
||||
const cli = new CLIEngine({});
|
||||
|
||||
module.exports = {
|
||||
|
0
.prettierignore
Normal file
0
.prettierignore
Normal file
3
CHANGELOG.md
Normal file
3
CHANGELOG.md
Normal file
@@ -0,0 +1,3 @@
|
||||
## 2020-10-13
|
||||
|
||||
- Added ability to embed scene source into exported PNG/SVG files so you can import the scene from them (open via `Load` button or drag & drop). #2219
|
12
Dockerfile
12
Dockerfile
@@ -1,16 +1,18 @@
|
||||
FROM node:14-alpine AS build
|
||||
|
||||
ENV NODE_ENV=production
|
||||
WORKDIR /usr/src/app
|
||||
WORKDIR /opt/node_app
|
||||
|
||||
COPY package.json package-lock.json ./
|
||||
RUN npm install
|
||||
RUN npm i --no-optional
|
||||
|
||||
ARG REACT_APP_INCLUDE_GTAG=false
|
||||
ARG NODE_ENV=production
|
||||
|
||||
COPY . .
|
||||
RUN npm run build:app
|
||||
RUN npm run build:app:docker
|
||||
|
||||
FROM nginx:1.17-alpine
|
||||
|
||||
COPY --from=build /usr/src/app/build /usr/share/nginx/html
|
||||
COPY --from=build /opt/node_app/build /usr/share/nginx/html
|
||||
|
||||
HEALTHCHECK CMD wget -q -O /dev/null http://localhost || exit 1
|
||||
|
33
README.md
33
README.md
@@ -2,23 +2,30 @@
|
||||
<a href="https://excalidraw.com">
|
||||
<img src="./public/og-image.png" alt="Excalidraw logo: Sketch handrawn like diagrams." />
|
||||
</a>
|
||||
<h3>Excalidraw is a whiteboard tool that lets you easily sketch diagrams with a hand-drawn feel.</h3>
|
||||
<h3>Virtual whiteboard for sketching hand-drawn like diagrams.</h3>
|
||||
<p>
|
||||
<a href="https://twitter.com/Excalidraw">
|
||||
<img alt="Follow Excalidraw on Twitter" src="https://img.shields.io/twitter/follow/excalidraw.svg?label=follow+excalidraw&style=social&logo=twitter">
|
||||
</a>
|
||||
<a title="Crowdin" target="_blank" href="https://crowdin.com/project/excalidraw">
|
||||
<a target="_blank" href="https://crowdin.com/project/excalidraw">
|
||||
<img src="https://badges.crowdin.net/excalidraw/localized.svg">
|
||||
</a>
|
||||
<a target="_blank" href="https://hub.docker.com/r/excalidraw/excalidraw">
|
||||
<img src="https://img.shields.io/docker/pulls/excalidraw/excalidraw">
|
||||
</a>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
## Try it now
|
||||
|
||||
Go to https://excalidraw.com to start sketching.
|
||||
Go to [excalidraw.com](https://excalidraw.com) to start sketching.
|
||||
|
||||
Read our [blog](https://blog.excalidraw.com) and follow the [guides](https://howto.excalidraw.com) to learn more about Excalidraw and how to use it effectively.
|
||||
|
||||
## Shape libraries
|
||||
|
||||
Find a growing list of libraries containing assets for your drawings at [libraries.excalidraw.com](https://libraries.excalidraw.com).
|
||||
|
||||
## Run the code
|
||||
|
||||
### Code Sandbox
|
||||
@@ -48,24 +55,28 @@ git clone https://github.com/excalidraw/excalidraw.git
|
||||
| `npm run test:update` | Update test snapshots |
|
||||
| `npm run test:code` | Test for formatting with Prettier |
|
||||
|
||||
### Docker Installation
|
||||
|
||||
A production-ready version for deploying to e.g. Kubernetes or OpenShift can be built using Docker.
|
||||
|
||||
#### Docker Compose
|
||||
|
||||
You can use docker-compose to work on excalidraw locally if you don't want to setup a Node.js env.
|
||||
|
||||
```sh
|
||||
docker-compose up --build -d
|
||||
```
|
||||
|
||||
#### Native Docker
|
||||
## Self hosting
|
||||
|
||||
We publish a Docker image with the Excalidraw client at [excalidraw/excalidraw](https://hub.docker.com/r/excalidraw/excalidraw). You can use it to self host your own client under your own domain, on Kubernetes, AWS ECS, etc.
|
||||
|
||||
```sh
|
||||
docker build -t excalidraw/excalidraw .
|
||||
docker run --rm -dit --name excalidraw -p 5000:80 excalidraw/excalidraw:latest
|
||||
```
|
||||
|
||||
After building the image and running the container, open <http://localhost:5000> to see the application.
|
||||
The Docker image is free of analytics and other tracking libraries.
|
||||
|
||||
**At the moment, self-hosting your own instance doesn't support sharing or collaboration features.**
|
||||
|
||||
We are working towards providing a full-fledged solution for self hosting your own Excalidraw.
|
||||
|
||||
## Contributing
|
||||
|
||||
@@ -75,11 +86,13 @@ Pull requests are welcome. For major changes, please [open an issue](https://git
|
||||
|
||||
To translate Excalidraw into other languages, please visit [our Crowdin page](https://crowdin.com/project/excalidraw). To add a new language, [open an issue](https://github.com/excalidraw/excalidraw/issues/new) so we can get things set up on our end first.
|
||||
|
||||
Translations will be available on the app if they exceed a certain threshold of completion (currently 85%).
|
||||
|
||||
## Excalidraw is built using these awesome tools
|
||||
|
||||
- [React](https://reactjs.org)
|
||||
- [Rough.js](https://roughjs.com)
|
||||
- [TypeScript](https://typescriptlang.org)
|
||||
- [TypeScript](https://www.typescriptlang.org)
|
||||
- [Vercel](https://vercel.com)
|
||||
|
||||
And the main source of inspiration for starting the project is the awesome [Zwibbler](https://zwibbler.com/demo/) app.
|
||||
|
64
analytics.md
Normal file
64
analytics.md
Normal file
@@ -0,0 +1,64 @@
|
||||
| Excalidraw | Category | Name | Label | Value |
|
||||
| ----------------------- | -------- | ---------------------------------- | ------------------------------- | --------- |
|
||||
| Shape / Selection | shape | selection, rectangle, diamond, etc | `toolbar` or `shortcut` |
|
||||
| Text on double click | shape | text | `double-click` |
|
||||
| Lock selection | shape | lock | `on` or `off` |
|
||||
| Clear canvas | action | clear canvas |
|
||||
| Zoom in | action | zoom | in | `zoom` |
|
||||
| Zoom out | action | zoom | out | `zoom` |
|
||||
| Zoom fit | action | zoom | fit | `zoom` |
|
||||
| Zoom reset | action | zoom | reset | `zoom` |
|
||||
| Scroll back to content | action | scroll to content |
|
||||
| Load file | io | load | `MIME type` |
|
||||
| Import from URL | io | import |
|
||||
| Save | io | save |
|
||||
| Save as | io | save as |
|
||||
| Export to backend | io | export | backend |
|
||||
| Export as SVG | io | export | `svg` or `clipboard-svg` |
|
||||
| Export to PNG | io | export | `png` or `clipboard-png` |
|
||||
| Canvas color | change | canvas color | `color` |
|
||||
| Background color | change | background color | `color` |
|
||||
| Stroke color | change | stroke color | `color` |
|
||||
| Stroke width | change | stroke | width | `width` |
|
||||
| Stroke style | change | style | `solid` or `dashed` or `dotted` |
|
||||
| Stroke sloppiness | change | stroke | sloppiness | `value` |
|
||||
| Fill | change | fill | `value` |
|
||||
| Edge | change | edge | `value` |
|
||||
| Opacity | change | opacity | value | `opacity` |
|
||||
| Project name | change | title |
|
||||
| Theme | change | theme | `light` or `dark` |
|
||||
| Change language | change | language | `language` |
|
||||
| Send to back | layer | move | `back` |
|
||||
| Send backward | layer | move | `down` |
|
||||
| Bring to front | layer | move | `front` |
|
||||
| Bring forward | layer | move | `up` |
|
||||
| Align left | align | align | `left` |
|
||||
| Align right | align | align | `right` |
|
||||
| Align top | align | align | `top` |
|
||||
| Align bottom | align | align | `bottom` |
|
||||
| Center horizontally | align | horizontally | `center` |
|
||||
| Center vertically | align | vertically | `center` |
|
||||
| Distribute horizontally | align | distribute | `horizontally` |
|
||||
| Distribute vertically | align | distribute | `vertically` |
|
||||
| Start session | share | session start |
|
||||
| Join session | share | session join |
|
||||
| Start end | share | session end |
|
||||
| Copy room link | share | copy link |
|
||||
| Go to collaborator | share | go to collaborator |
|
||||
| Change name | share | name |
|
||||
| Add to library | library | add |
|
||||
| Remove from library | library | remove |
|
||||
| Load library | library | load |
|
||||
| Save library | library | save |
|
||||
| Import library | library | import |
|
||||
| Shortcuts dialog | dialog | shortcuts |
|
||||
| Collaboration dialog | dialog | collaboration |
|
||||
| Export dialog | dialog | export |
|
||||
| Library dialog | dialog | library |
|
||||
| E2EE shield | exit | e2ee shield |
|
||||
| GitHub corner | exit | github |
|
||||
| Excalidraw blog | exit | blog |
|
||||
| Excalidraw guides | exit | guides |
|
||||
| File issues | exit | issues |
|
||||
| First load | load | first load |
|
||||
| Load from stroage | load | storage | size | `bytes` |
|
@@ -1,9 +1,25 @@
|
||||
version: "3"
|
||||
version: "3.8"
|
||||
|
||||
services:
|
||||
excalidraw:
|
||||
build: .
|
||||
build:
|
||||
context: .
|
||||
args:
|
||||
- NODE_ENV=development
|
||||
container_name: excalidraw
|
||||
ports:
|
||||
- "5000:80"
|
||||
- "3000:80"
|
||||
restart: on-failure
|
||||
stdin_open: true
|
||||
healthcheck:
|
||||
disable: true
|
||||
environment:
|
||||
- NODE_ENV=development
|
||||
volumes:
|
||||
- ./:/opt/node_app/app:delegated
|
||||
- ./package.json:/opt/node_app/package.json
|
||||
- ./package-lock.json:/opt/node_app/package-lock.json
|
||||
- notused:/opt/node_app/app/node_modules
|
||||
|
||||
volumes:
|
||||
notused:
|
||||
|
5
firebase-project/.firebaserc
Normal file
5
firebase-project/.firebaserc
Normal file
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"projects": {
|
||||
"default": "excalidraw-room-persistence"
|
||||
}
|
||||
}
|
66
firebase-project/.gitignore
vendored
Normal file
66
firebase-project/.gitignore
vendored
Normal file
@@ -0,0 +1,66 @@
|
||||
# Logs
|
||||
logs
|
||||
*.log
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
firebase-debug.log*
|
||||
firebase-debug.*.log*
|
||||
|
||||
# Firebase cache
|
||||
.firebase/
|
||||
|
||||
# Firebase config
|
||||
|
||||
# Uncomment this if you'd like others to create their own Firebase project.
|
||||
# For a team working on the same Firebase project(s), it is recommended to leave
|
||||
# it commented so all members can deploy to the same project(s) in .firebaserc.
|
||||
# .firebaserc
|
||||
|
||||
# Runtime data
|
||||
pids
|
||||
*.pid
|
||||
*.seed
|
||||
*.pid.lock
|
||||
|
||||
# Directory for instrumented libs generated by jscoverage/JSCover
|
||||
lib-cov
|
||||
|
||||
# Coverage directory used by tools like istanbul
|
||||
coverage
|
||||
|
||||
# nyc test coverage
|
||||
.nyc_output
|
||||
|
||||
# Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files)
|
||||
.grunt
|
||||
|
||||
# Bower dependency directory (https://bower.io/)
|
||||
bower_components
|
||||
|
||||
# node-waf configuration
|
||||
.lock-wscript
|
||||
|
||||
# Compiled binary addons (http://nodejs.org/api/addons.html)
|
||||
build/Release
|
||||
|
||||
# Dependency directories
|
||||
node_modules/
|
||||
|
||||
# Optional npm cache directory
|
||||
.npm
|
||||
|
||||
# Optional eslint cache
|
||||
.eslintcache
|
||||
|
||||
# Optional REPL history
|
||||
.node_repl_history
|
||||
|
||||
# Output of 'npm pack'
|
||||
*.tgz
|
||||
|
||||
# Yarn Integrity file
|
||||
.yarn-integrity
|
||||
|
||||
# dotenv environment variables file
|
||||
.env
|
6
firebase-project/firebase.json
Normal file
6
firebase-project/firebase.json
Normal file
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"firestore": {
|
||||
"rules": "firestore.rules",
|
||||
"indexes": "firestore.indexes.json"
|
||||
}
|
||||
}
|
4
firebase-project/firestore.indexes.json
Normal file
4
firebase-project/firestore.indexes.json
Normal file
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"indexes": [],
|
||||
"fieldOverrides": []
|
||||
}
|
10
firebase-project/firestore.rules
Normal file
10
firebase-project/firestore.rules
Normal file
@@ -0,0 +1,10 @@
|
||||
rules_version = '2';
|
||||
service cloud.firestore {
|
||||
match /databases/{database}/documents {
|
||||
match /{document=**} {
|
||||
allow get, write: if true;
|
||||
// never set this to true, otherwise anyone can delete anyone else's drawing.
|
||||
allow list: if false;
|
||||
}
|
||||
}
|
||||
}
|
18271
package-lock.json
generated
18271
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
79
package.json
79
package.json
@@ -19,41 +19,47 @@
|
||||
]
|
||||
},
|
||||
"dependencies": {
|
||||
"@sentry/browser": "5.15.5",
|
||||
"@sentry/integrations": "5.15.5",
|
||||
"@testing-library/jest-dom": "5.7.0",
|
||||
"@testing-library/react": "10.0.4",
|
||||
"@types/jest": "25.2.1",
|
||||
"@sentry/browser": "5.29.2",
|
||||
"@sentry/integrations": "5.29.2",
|
||||
"@testing-library/jest-dom": "5.11.8",
|
||||
"@testing-library/react": "11.2.2",
|
||||
"@types/jest": "26.0.19",
|
||||
"@types/nanoid": "2.1.0",
|
||||
"@types/react": "16.9.35",
|
||||
"@types/react-dom": "16.9.8",
|
||||
"@types/socket.io-client": "1.4.32",
|
||||
"browser-nativefs": "0.7.3",
|
||||
"i18next-browser-languagedetector": "4.1.1",
|
||||
"@types/react": "17.0.0",
|
||||
"@types/react-dom": "17.0.0",
|
||||
"@types/socket.io-client": "1.4.34",
|
||||
"browser-nativefs": "0.12.0",
|
||||
"clsx": "1.1.1",
|
||||
"firebase": "8.2.1",
|
||||
"i18next-browser-languagedetector": "6.0.1",
|
||||
"lodash.throttle": "4.1.1",
|
||||
"nanoid": "2.1.11",
|
||||
"node-sass": "4.14.1",
|
||||
"open-color": "1.7.0",
|
||||
"pako": "1.0.11",
|
||||
"png-chunk-text": "1.0.0",
|
||||
"png-chunks-encode": "1.0.0",
|
||||
"png-chunks-extract": "1.0.0",
|
||||
"points-on-curve": "0.2.0",
|
||||
"pwacompat": "2.0.12",
|
||||
"react": "16.13.1",
|
||||
"react-dom": "16.13.1",
|
||||
"react-scripts": "3.4.1",
|
||||
"pwacompat": "2.0.17",
|
||||
"react": "17.0.1",
|
||||
"react-dom": "17.0.1",
|
||||
"react-scripts": "4.0.1",
|
||||
"roughjs": "4.3.1",
|
||||
"socket.io-client": "2.3.0",
|
||||
"typescript": "3.8.3"
|
||||
"socket.io-client": "2.3.1",
|
||||
"typescript": "4.1.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/lodash.throttle": "4.1.6",
|
||||
"asar": "3.0.3",
|
||||
"eslint": "6.8.0",
|
||||
"eslint-config-prettier": "6.11.0",
|
||||
"eslint-plugin-prettier": "3.1.3",
|
||||
"husky": "4.2.5",
|
||||
"jest-canvas-mock": "2.2.0",
|
||||
"lint-staged": "10.2.2",
|
||||
"pepjs": "0.5.2",
|
||||
"prettier": "2.0.5",
|
||||
"@types/pako": "1.0.1",
|
||||
"eslint-config-prettier": "7.1.0",
|
||||
"eslint-plugin-prettier": "3.3.0",
|
||||
"firebase-tools": "9.1.0",
|
||||
"husky": "4.3.6",
|
||||
"jest-canvas-mock": "2.3.0",
|
||||
"lint-staged": "10.5.3",
|
||||
"pepjs": "0.5.3",
|
||||
"prettier": "2.2.1",
|
||||
"rewire": "5.0.0"
|
||||
},
|
||||
"engines": {
|
||||
@@ -68,27 +74,32 @@
|
||||
"jest": {
|
||||
"transformIgnorePatterns": [
|
||||
"node_modules/(?!(roughjs|points-on-curve|path-data-parser|points-on-path|browser-nativefs)/)"
|
||||
]
|
||||
],
|
||||
"resetMocks": false
|
||||
},
|
||||
"name": "excalidraw",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"build": "npm run build:app && npm run build:zip",
|
||||
"build-node": "node ./scripts/build-node.js",
|
||||
"build:app": "REACT_APP_GIT_SHA=$NOW_GITHUB_COMMIT_SHA react-scripts build",
|
||||
"build:zip": "node ./scripts/build-version.js",
|
||||
"build:app:docker": "REACT_APP_INCLUDE_GTAG=false REACT_APP_DISABLE_SENTRY=true react-scripts build",
|
||||
"build:app": "REACT_APP_INCLUDE_GTAG=true REACT_APP_GIT_SHA=$NOW_GITHUB_COMMIT_SHA react-scripts build",
|
||||
"build:version": "node ./scripts/build-version.js",
|
||||
"build": "npm run build:app && npm run build:version",
|
||||
"eject": "react-scripts eject",
|
||||
"fix": "npm run fix:other && npm run fix:code",
|
||||
"fix:code": "npm run test:code -- --fix",
|
||||
"fix:other": "npm run prettier -- --write",
|
||||
"fix": "npm run fix:other && npm run fix:code",
|
||||
"locales-coverage": "node scripts/build-locales-coverage.js",
|
||||
"locales-coverage:description": "node scripts/locales-coverage-description.js",
|
||||
"prettier": "prettier \"**/*.{css,scss,json,md,html,yml}\" --ignore-path=.eslintignore",
|
||||
"start": "react-scripts start",
|
||||
"test": "npm run test:app",
|
||||
"test:all": "npm run test:typecheck && npm run test:code && npm run test:other && npm run test:app -- --watchAll=false",
|
||||
"test:update": "npm run test:app -- --updateSnapshot --watchAll=false",
|
||||
"test:app": "react-scripts test --env=jsdom --passWithNoTests",
|
||||
"test:app": "react-scripts test --passWithNoTests",
|
||||
"test:code": "eslint --max-warnings=0 --ignore-path .gitignore --ext .js,.ts,.tsx .",
|
||||
"test:debug": "react-scripts --inspect-brk test --runInBand --no-cache",
|
||||
"test:other": "npm run prettier -- --list-different",
|
||||
"test:typecheck": "tsc"
|
||||
"test:typecheck": "tsc",
|
||||
"test:update": "npm run test:app -- --updateSnapshot --watchAll=false",
|
||||
"test": "npm run test:app"
|
||||
}
|
||||
}
|
||||
|
@@ -13,18 +13,6 @@
|
||||
|
||||
<meta name="theme-color" content="#000" />
|
||||
|
||||
<!-- Origin Trial token for the Native File System API v1 https://developers.chrome.com/origintrials/#/view_trial/3868592079911256065 (Chrome 78–81) -->
|
||||
<meta
|
||||
http-equiv="origin-trial"
|
||||
content="AoGjY+6r8OQZ5c0AXpK+bbca0pJdCTSHWFqSFNulxiW4OwFBB63kHdDHNo433GeuEOir8IvSovR0LOZLfPnEDAUAAABceyJvcmlnaW4iOiJodHRwczovL3d3dy5leGNhbGlkcmF3LmNvbTo0NDMiLCJmZWF0dXJlIjoiTmF0aXZlRmlsZVN5c3RlbSIsImV4cGlyeSI6MTU4OTMyNzk5OX0="
|
||||
/>
|
||||
|
||||
<!-- Origin Trial token for the Native File System API v2 https://developers.chrome.com/origintrials/#/view_trial/4019462667428167681 (Chrome 83–85) -->
|
||||
<meta
|
||||
http-equiv="origin-trial"
|
||||
content="AgMee3sqSZkE0QaZP8f/F9OJj5iSLdnNMRGttIDlOQy552MI4GoL41jyCAHOYsQ8UWM1kPdrb6PVmbSllX/JqwEAAABZeyJvcmlnaW4iOiJodHRwczovL2V4Y2FsaWRyYXcuY29tOjQ0MyIsImZlYXR1cmUiOiJOYXRpdmVGaWxlU3lzdGVtMiIsImV4cGlyeSI6MTU5MDU3MzM5MX0="
|
||||
/>
|
||||
|
||||
<!-- General tags -->
|
||||
<meta
|
||||
name="description"
|
||||
@@ -66,7 +54,9 @@
|
||||
<!-- 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="stylesheet" href="fonts.css" type="text/css" />
|
||||
|
||||
<!-- Excalidraw version -->
|
||||
<meta name="version" content="{version}" />
|
||||
<link
|
||||
rel="preload"
|
||||
href="FG_Virgil.woff2"
|
||||
@@ -83,7 +73,7 @@
|
||||
/>
|
||||
|
||||
<link
|
||||
href="https://excalidraw-socket.herokuapp.com/socket.io"
|
||||
href="%REACT_APP_SOCKET_SERVER_URL%/socket.io"
|
||||
rel="preconnect"
|
||||
crossorigin="anonymous"
|
||||
/>
|
||||
@@ -91,38 +81,12 @@
|
||||
<link
|
||||
rel="manifest"
|
||||
href="manifest.json"
|
||||
style="--pwacompat-splash-font: 24px Virgil;"
|
||||
style="--pwacompat-splash-font: 24px Virgil"
|
||||
/>
|
||||
|
||||
<style>
|
||||
.LoadingMessage {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
z-index: 999;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
pointer-events: none;
|
||||
}
|
||||
.LoadingMessage span {
|
||||
background-color: rgba(255, 255, 255, 0.8);
|
||||
border-radius: 5px;
|
||||
padding: 0.8em 1.2em;
|
||||
font-size: 1.3em;
|
||||
}
|
||||
.visually-hidden {
|
||||
position: absolute !important;
|
||||
height: 1px;
|
||||
width: 1px;
|
||||
overflow: hidden;
|
||||
clip: rect(1px 1px 1px 1px); /* IE6, IE7 */
|
||||
clip: rect(1px, 1px, 1px, 1px);
|
||||
white-space: nowrap; /* added line */
|
||||
}
|
||||
</style>
|
||||
<link rel="stylesheet" href="fonts.css" type="text/css" />
|
||||
|
||||
<% if (process.env.REACT_APP_INCLUDE_GTAG === 'true') { %>
|
||||
<script
|
||||
async
|
||||
src="https://www.googletagmanager.com/gtag/js?id=UA-387204-13"
|
||||
@@ -135,12 +99,56 @@
|
||||
gtag("js", new Date());
|
||||
gtag("config", "UA-387204-13");
|
||||
</script>
|
||||
<% } %>
|
||||
|
||||
<!-- FIXME: remove this when we update CRA (fix SW caching) -->
|
||||
<style>
|
||||
body {
|
||||
margin: 0;
|
||||
--ui-font: system-ui, BlinkMacSystemFont, -apple-system, Segoe UI,
|
||||
Roboto, Helvetica, Arial, sans-serif;
|
||||
font-family: var(--ui-font);
|
||||
-webkit-text-size-adjust: 100%;
|
||||
-webkit-user-select: none;
|
||||
user-select: none;
|
||||
width: 100vw;
|
||||
height: 100vh;
|
||||
}
|
||||
|
||||
.visually-hidden {
|
||||
position: absolute !important;
|
||||
height: 1px;
|
||||
width: 1px;
|
||||
overflow: hidden;
|
||||
clip: rect(1px, 1px, 1px, 1px);
|
||||
white-space: nowrap; /* added line */
|
||||
}
|
||||
|
||||
.LoadingMessage {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
z-index: 999;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.LoadingMessage span {
|
||||
background-color: var(--button-gray-1);
|
||||
border-radius: 5px;
|
||||
padding: 0.8em 1.2em;
|
||||
color: var(--popup-text-color);
|
||||
font-size: 1.3em;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<noscript>
|
||||
You need to enable JavaScript to run this app.
|
||||
</noscript>
|
||||
<noscript> You need to enable JavaScript to run this app. </noscript>
|
||||
<header>
|
||||
<h1 class="visually-hidden">Excalidraw</h1>
|
||||
</header>
|
||||
|
@@ -14,8 +14,17 @@
|
||||
"sizes": "256x256"
|
||||
}
|
||||
],
|
||||
"start_url": ".",
|
||||
"start_url": "/",
|
||||
"display": "standalone",
|
||||
"theme_color": "#000000",
|
||||
"background_color": "#ffffff"
|
||||
"background_color": "#ffffff",
|
||||
"file_handlers": [
|
||||
{
|
||||
"action": "/",
|
||||
"accept": {
|
||||
"application/vnd.excalidraw+json": [".excalidraw"]
|
||||
}
|
||||
}
|
||||
],
|
||||
"capture_links": "new_client"
|
||||
}
|
||||
|
3
public/robots.txt
Normal file
3
public/robots.txt
Normal file
@@ -0,0 +1,3 @@
|
||||
user-agent: *
|
||||
Allow: /$
|
||||
Disallow: /
|
32
scripts/build-locales-coverage.js
Normal file
32
scripts/build-locales-coverage.js
Normal file
@@ -0,0 +1,32 @@
|
||||
const { readdirSync, writeFileSync } = require("fs");
|
||||
const files = readdirSync(`${__dirname}/../src/locales`);
|
||||
|
||||
const flatten = (object) =>
|
||||
Object.keys(object).reduce(
|
||||
(initial, current) => ({ ...initial, ...object[current] }),
|
||||
{},
|
||||
);
|
||||
|
||||
const locales = files.filter(
|
||||
(file) => file !== "README.md" && file !== "percentages.json",
|
||||
);
|
||||
|
||||
const percentages = {};
|
||||
|
||||
for (let index = 0; index < locales.length; index++) {
|
||||
const currentLocale = locales[index];
|
||||
const data = flatten(require(`${__dirname}/../src/locales/${currentLocale}`));
|
||||
|
||||
const allKeys = Object.keys(data);
|
||||
const translatedKeys = allKeys.filter((item) => data[item] !== "");
|
||||
|
||||
const percentage = (100 * translatedKeys.length) / allKeys.length;
|
||||
|
||||
percentages[currentLocale.replace(".json", "")] = parseInt(percentage);
|
||||
}
|
||||
|
||||
writeFileSync(
|
||||
`${__dirname}/../src/locales/percentages.json`,
|
||||
`${JSON.stringify(percentages, null, 2)}\n`,
|
||||
"utf8",
|
||||
);
|
@@ -9,9 +9,9 @@
|
||||
// node build/static/js/build-node.js
|
||||
// open test.png
|
||||
|
||||
var rewire = require("rewire");
|
||||
var defaults = rewire("react-scripts/scripts/build.js");
|
||||
var config = defaults.__get__("config");
|
||||
const rewire = require("rewire");
|
||||
const defaults = rewire("react-scripts/scripts/build.js");
|
||||
const config = defaults.__get__("config");
|
||||
|
||||
// Disable multiple chunks
|
||||
config.optimization.runtimeChunk = false;
|
||||
@@ -29,7 +29,7 @@ config.entry = "./src/index-node";
|
||||
// By default, webpack is going to replace the require of the canvas.node file
|
||||
// to just a string with the path of the canvas.node file. We need to tell
|
||||
// webpack to avoid rewriting that dependency.
|
||||
config.externals = function (context, request, callback) {
|
||||
config.externals = (context, request, callback) => {
|
||||
if (/\.node$/.test(request)) {
|
||||
return callback(
|
||||
null,
|
||||
|
@@ -2,7 +2,8 @@
|
||||
|
||||
const fs = require("fs");
|
||||
const path = require("path");
|
||||
const asar = require("asar");
|
||||
const versionFile = path.join("build", "version.json");
|
||||
const indexFile = path.join("build", "index.html");
|
||||
|
||||
const zero = (digit) => `0${digit}`.slice(-2);
|
||||
|
||||
@@ -20,18 +21,24 @@ const now = new Date();
|
||||
|
||||
const data = JSON.stringify(
|
||||
{
|
||||
asar: `excalidraw.asar`,
|
||||
version: versionDate(now),
|
||||
},
|
||||
undefined,
|
||||
2,
|
||||
);
|
||||
|
||||
fs.writeFileSync(path.join("build", "version.json"), data);
|
||||
fs.writeFileSync(versionFile, data);
|
||||
|
||||
(async () => {
|
||||
const src = "build/";
|
||||
const dest = path.join("build", `excalidraw.asar`);
|
||||
// https://stackoverflow.com/a/14181136/8418
|
||||
fs.readFile(indexFile, "utf8", (error, data) => {
|
||||
if (error) {
|
||||
return console.error(error);
|
||||
}
|
||||
const result = data.replace(/{version}/g, versionDate(now));
|
||||
|
||||
await asar.createPackage(src, dest);
|
||||
})();
|
||||
fs.writeFile(indexFile, result, "utf8", (error) => {
|
||||
if (error) {
|
||||
return console.error(error);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
155
scripts/locales-coverage-description.js
Normal file
155
scripts/locales-coverage-description.js
Normal file
@@ -0,0 +1,155 @@
|
||||
const fs = require("fs");
|
||||
|
||||
const THRESSHOLD = 85;
|
||||
|
||||
const crowdinMap = {
|
||||
"ar-SA": "en-ar",
|
||||
"el-GR": "en-el",
|
||||
"fi-FI": "en-fi",
|
||||
"ja-JP": "en-ja",
|
||||
"bg-BG": "en-bg",
|
||||
"ca-ES": "en-ca",
|
||||
"de-DE": "en-de",
|
||||
"es-ES": "en-es",
|
||||
"fa-IR": "en-fa",
|
||||
"fr-FR": "en-fr",
|
||||
"he-IL": "en-he",
|
||||
"hi-IN": "en-hi",
|
||||
"hu-HU": "en-hu",
|
||||
"id-ID": "en-id",
|
||||
"it-IT": "en-it",
|
||||
"ko-KR": "en-ko",
|
||||
"my-MM": "en-my",
|
||||
"nb-NO": "en-nb",
|
||||
"nl-NL": "en-nl",
|
||||
"nn-NO": "en-nnno",
|
||||
"pl-PL": "en-pl",
|
||||
"pt-BR": "en-ptbr",
|
||||
"pt-PT": "en-pt",
|
||||
"ro-RO": "en-ro",
|
||||
"ru-RU": "en-ru",
|
||||
"sk-SK": "en-sk",
|
||||
"sv-SE": "en-sv",
|
||||
"tr-TR": "en-tr",
|
||||
"uk-UA": "en-uk",
|
||||
"zh-CN": "en-zhcn",
|
||||
"zh-TW": "en-zhtw",
|
||||
};
|
||||
|
||||
const flags = {
|
||||
"ar-SA": "🇸🇦",
|
||||
"bg-BG": "🇧🇬",
|
||||
"ca-ES": "🇪🇸",
|
||||
"de-DE": "🇩🇪",
|
||||
"el-GR": "🇬🇷",
|
||||
"es-ES": "🇪🇸",
|
||||
"fa-IR": "🇮🇷",
|
||||
"fi-FI": "🇫🇮",
|
||||
"fr-FR": "🇫🇷",
|
||||
"he-IL": "🇮🇱",
|
||||
"hi-IN": "🇮🇳",
|
||||
"hu-HU": "🇭🇺",
|
||||
"id-ID": "🇮🇩",
|
||||
"it-IT": "🇮🇹",
|
||||
"ja-JP": "🇯🇵",
|
||||
"ko-KR": "🇰🇷",
|
||||
"my-MM": "🇲🇲",
|
||||
"nb-NO": "🇳🇴",
|
||||
"nl-NL": "🇳🇱",
|
||||
"nn-NO": "🇳🇴",
|
||||
"pl-PL": "🇵🇱",
|
||||
"pt-BR": "🇧🇷",
|
||||
"pt-PT": "🇵🇹",
|
||||
"ro-RO": "🇷🇴",
|
||||
"ru-RU": "🇷🇺",
|
||||
"sk-SK": "🇸🇰",
|
||||
"sv-SE": "🇸🇪",
|
||||
"tr-TR": "🇹🇷",
|
||||
"uk-UA": "🇺🇦",
|
||||
"zh-CN": "🇨🇳",
|
||||
"zh-TW": "🇹🇼",
|
||||
};
|
||||
|
||||
const languages = {
|
||||
"ar-SA": "العربية",
|
||||
"bg-BG": "Български",
|
||||
"ca-ES": "Catalan",
|
||||
"de-DE": "Deutsch",
|
||||
"el-GR": "Ελληνικά",
|
||||
"es-ES": "Español",
|
||||
"fa-IR": "فارسی",
|
||||
"fi-FI": "Suomi",
|
||||
"fr-FR": "Français",
|
||||
"he-IL": "עברית",
|
||||
"hi-IN": "हिन्दी",
|
||||
"hu-HU": "Magyar",
|
||||
"id-ID": "Bahasa Indonesia",
|
||||
"it-IT": "Italiano",
|
||||
"ja-JP": "日本語",
|
||||
"ko-KR": "한국어",
|
||||
"my-MM": "Burmese",
|
||||
"nb-NO": "Norsk bokmål",
|
||||
"nl-NL": "Nederlands",
|
||||
"nn-NO": "Norsk nynorsk",
|
||||
"pl-PL": "Polski",
|
||||
"pt-BR": "Português Brasileiro",
|
||||
"pt-PT": "Português",
|
||||
"ro-RO": "Română",
|
||||
"ru-RU": "Русский",
|
||||
"sk-SK": "Slovenčina",
|
||||
"sv-SE": "Svenska",
|
||||
"tr-TR": "Türkçe",
|
||||
"uk-UA": "Українська",
|
||||
"zh-CN": "简体中文",
|
||||
"zh-TW": "繁體中文",
|
||||
};
|
||||
|
||||
const percentages = fs.readFileSync(
|
||||
`${__dirname}/../src/locales/percentages.json`,
|
||||
);
|
||||
const rowData = JSON.parse(percentages);
|
||||
|
||||
const coverages = Object.entries(rowData)
|
||||
.sort(([, a], [, b]) => b - a)
|
||||
.reduce((r, [k, v]) => ({ ...r, [k]: v }), {});
|
||||
|
||||
const boldIf = (text, condition) => (condition ? `**${text}**` : text);
|
||||
|
||||
const printHeader = () => {
|
||||
let result = "| | Flag | Locale | % |\n";
|
||||
result += "| :--: | :--: | -- | :--: |";
|
||||
return result;
|
||||
};
|
||||
|
||||
const printRow = (id, locale, coverage) => {
|
||||
const isOver = coverage >= THRESSHOLD;
|
||||
let result = `| ${isOver ? id : "..."} | `;
|
||||
result += `${locale in flags ? flags[locale] : ""} | `;
|
||||
const language = locale in languages ? languages[locale] : locale;
|
||||
if (locale in crowdinMap && crowdinMap[locale]) {
|
||||
result += `[${boldIf(
|
||||
language,
|
||||
isOver,
|
||||
)}](https://crowdin.com/translate/excalidraw/10/${crowdinMap[locale]}) | `;
|
||||
} else {
|
||||
result += `${boldIf(language, isOver)} | `;
|
||||
}
|
||||
result += `${coverage === 100 ? "✅" : boldIf(coverage, isOver)} |`;
|
||||
return result;
|
||||
};
|
||||
|
||||
console.info(
|
||||
`Each language must be at least **${THRESSHOLD}%** translated in order to appear on Excalidraw. Join us on [Crowdin](https://crowdin.com/project/excalidraw) and help us translate your own language. **Can't find yours yet?** Open an [issue](https://github.com/excalidraw/excalidraw/issues/new) and we'll add it to the list.`,
|
||||
);
|
||||
console.info("\n\r");
|
||||
console.info(printHeader());
|
||||
let index = 1;
|
||||
for (const coverage in coverages) {
|
||||
if (coverage === "en") {
|
||||
continue;
|
||||
}
|
||||
console.info(printRow(index, coverage, coverages[coverage]));
|
||||
index++;
|
||||
}
|
||||
console.info("\n\r");
|
||||
console.info("\\* Languages in **bold** are going to appear on production.");
|
24
src/actions/actionAddToLibrary.ts
Normal file
24
src/actions/actionAddToLibrary.ts
Normal file
@@ -0,0 +1,24 @@
|
||||
import { register } from "./register";
|
||||
import { getSelectedElements } from "../scene";
|
||||
import { getNonDeletedElements } from "../element";
|
||||
import { deepCopyElement } from "../element/newElement";
|
||||
import { Library } from "../data/library";
|
||||
import { EVENT_LIBRARY, trackEvent } from "../analytics";
|
||||
|
||||
export const actionAddToLibrary = register({
|
||||
name: "addToLibrary",
|
||||
perform: (elements, appState) => {
|
||||
const selectedElements = getSelectedElements(
|
||||
getNonDeletedElements(elements),
|
||||
appState,
|
||||
);
|
||||
|
||||
Library.loadLibrary().then((items) => {
|
||||
Library.saveLibrary([...items, selectedElements.map(deepCopyElement)]);
|
||||
});
|
||||
trackEvent(EVENT_LIBRARY, "add");
|
||||
return false;
|
||||
},
|
||||
contextMenuOrder: 6,
|
||||
contextItemLabel: "labels.addToLibrary",
|
||||
});
|
214
src/actions/actionAlign.tsx
Normal file
214
src/actions/actionAlign.tsx
Normal file
@@ -0,0 +1,214 @@
|
||||
import React from "react";
|
||||
import { KEYS } from "../keys";
|
||||
import { t } from "../i18n";
|
||||
import { register } from "./register";
|
||||
import {
|
||||
AlignBottomIcon,
|
||||
AlignLeftIcon,
|
||||
AlignRightIcon,
|
||||
AlignTopIcon,
|
||||
CenterHorizontallyIcon,
|
||||
CenterVerticallyIcon,
|
||||
} from "../components/icons";
|
||||
import { getSelectedElements, isSomeElementSelected } from "../scene";
|
||||
import { getElementMap, getNonDeletedElements } from "../element";
|
||||
import { ToolButton } from "../components/ToolButton";
|
||||
import { ExcalidrawElement } from "../element/types";
|
||||
import { AppState } from "../types";
|
||||
import { alignElements, Alignment } from "../align";
|
||||
import { getShortcutKey } from "../utils";
|
||||
import { trackEvent, EVENT_ALIGN } from "../analytics";
|
||||
|
||||
const enableActionGroup = (
|
||||
elements: readonly ExcalidrawElement[],
|
||||
appState: AppState,
|
||||
) => getSelectedElements(getNonDeletedElements(elements), appState).length > 1;
|
||||
|
||||
const alignSelectedElements = (
|
||||
elements: readonly ExcalidrawElement[],
|
||||
appState: Readonly<AppState>,
|
||||
alignment: Alignment,
|
||||
) => {
|
||||
const selectedElements = getSelectedElements(
|
||||
getNonDeletedElements(elements),
|
||||
appState,
|
||||
);
|
||||
|
||||
const updatedElements = alignElements(selectedElements, alignment);
|
||||
|
||||
const updatedElementsMap = getElementMap(updatedElements);
|
||||
|
||||
return elements.map((element) => updatedElementsMap[element.id] || element);
|
||||
};
|
||||
|
||||
export const actionAlignTop = register({
|
||||
name: "alignTop",
|
||||
perform: (elements, appState) => {
|
||||
trackEvent(EVENT_ALIGN, "align", "top");
|
||||
return {
|
||||
appState,
|
||||
elements: alignSelectedElements(elements, appState, {
|
||||
position: "start",
|
||||
axis: "y",
|
||||
}),
|
||||
commitToHistory: true,
|
||||
};
|
||||
},
|
||||
keyTest: (event) =>
|
||||
event[KEYS.CTRL_OR_CMD] && event.shiftKey && event.key === KEYS.ARROW_UP,
|
||||
PanelComponent: ({ elements, appState, updateData }) => (
|
||||
<ToolButton
|
||||
hidden={!enableActionGroup(elements, appState)}
|
||||
type="button"
|
||||
icon={<AlignTopIcon appearance={appState.appearance} />}
|
||||
onClick={() => updateData(null)}
|
||||
title={`${t("labels.alignTop")} — ${getShortcutKey(
|
||||
"CtrlOrCmd+Shift+Up",
|
||||
)}`}
|
||||
aria-label={t("labels.alignTop")}
|
||||
visible={isSomeElementSelected(getNonDeletedElements(elements), appState)}
|
||||
/>
|
||||
),
|
||||
});
|
||||
|
||||
export const actionAlignBottom = register({
|
||||
name: "alignBottom",
|
||||
perform: (elements, appState) => {
|
||||
trackEvent(EVENT_ALIGN, "align", "bottom");
|
||||
return {
|
||||
appState,
|
||||
elements: alignSelectedElements(elements, appState, {
|
||||
position: "end",
|
||||
axis: "y",
|
||||
}),
|
||||
commitToHistory: true,
|
||||
};
|
||||
},
|
||||
keyTest: (event) =>
|
||||
event[KEYS.CTRL_OR_CMD] && event.shiftKey && event.key === KEYS.ARROW_DOWN,
|
||||
PanelComponent: ({ elements, appState, updateData }) => (
|
||||
<ToolButton
|
||||
hidden={!enableActionGroup(elements, appState)}
|
||||
type="button"
|
||||
icon={<AlignBottomIcon appearance={appState.appearance} />}
|
||||
onClick={() => updateData(null)}
|
||||
title={`${t("labels.alignBottom")} — ${getShortcutKey(
|
||||
"CtrlOrCmd+Shift+Down",
|
||||
)}`}
|
||||
aria-label={t("labels.alignBottom")}
|
||||
visible={isSomeElementSelected(getNonDeletedElements(elements), appState)}
|
||||
/>
|
||||
),
|
||||
});
|
||||
|
||||
export const actionAlignLeft = register({
|
||||
name: "alignLeft",
|
||||
perform: (elements, appState) => {
|
||||
trackEvent(EVENT_ALIGN, "align", "left");
|
||||
return {
|
||||
appState,
|
||||
elements: alignSelectedElements(elements, appState, {
|
||||
position: "start",
|
||||
axis: "x",
|
||||
}),
|
||||
commitToHistory: true,
|
||||
};
|
||||
},
|
||||
keyTest: (event) =>
|
||||
event[KEYS.CTRL_OR_CMD] && event.shiftKey && event.key === KEYS.ARROW_LEFT,
|
||||
PanelComponent: ({ elements, appState, updateData }) => (
|
||||
<ToolButton
|
||||
hidden={!enableActionGroup(elements, appState)}
|
||||
type="button"
|
||||
icon={<AlignLeftIcon appearance={appState.appearance} />}
|
||||
onClick={() => updateData(null)}
|
||||
title={`${t("labels.alignLeft")} — ${getShortcutKey(
|
||||
"CtrlOrCmd+Shift+Left",
|
||||
)}`}
|
||||
aria-label={t("labels.alignLeft")}
|
||||
visible={isSomeElementSelected(getNonDeletedElements(elements), appState)}
|
||||
/>
|
||||
),
|
||||
});
|
||||
|
||||
export const actionAlignRight = register({
|
||||
name: "alignRight",
|
||||
perform: (elements, appState) => {
|
||||
trackEvent(EVENT_ALIGN, "align", "right");
|
||||
return {
|
||||
appState,
|
||||
elements: alignSelectedElements(elements, appState, {
|
||||
position: "end",
|
||||
axis: "x",
|
||||
}),
|
||||
commitToHistory: true,
|
||||
};
|
||||
},
|
||||
keyTest: (event) =>
|
||||
event[KEYS.CTRL_OR_CMD] && event.shiftKey && event.key === KEYS.ARROW_RIGHT,
|
||||
PanelComponent: ({ elements, appState, updateData }) => (
|
||||
<ToolButton
|
||||
hidden={!enableActionGroup(elements, appState)}
|
||||
type="button"
|
||||
icon={<AlignRightIcon appearance={appState.appearance} />}
|
||||
onClick={() => updateData(null)}
|
||||
title={`${t("labels.alignRight")} — ${getShortcutKey(
|
||||
"CtrlOrCmd+Shift+Right",
|
||||
)}`}
|
||||
aria-label={t("labels.alignRight")}
|
||||
visible={isSomeElementSelected(getNonDeletedElements(elements), appState)}
|
||||
/>
|
||||
),
|
||||
});
|
||||
|
||||
export const actionAlignVerticallyCentered = register({
|
||||
name: "alignVerticallyCentered",
|
||||
perform: (elements, appState) => {
|
||||
trackEvent(EVENT_ALIGN, "vertically", "center");
|
||||
return {
|
||||
appState,
|
||||
elements: alignSelectedElements(elements, appState, {
|
||||
position: "center",
|
||||
axis: "y",
|
||||
}),
|
||||
commitToHistory: true,
|
||||
};
|
||||
},
|
||||
PanelComponent: ({ elements, appState, updateData }) => (
|
||||
<ToolButton
|
||||
hidden={!enableActionGroup(elements, appState)}
|
||||
type="button"
|
||||
icon={<CenterVerticallyIcon appearance={appState.appearance} />}
|
||||
onClick={() => updateData(null)}
|
||||
title={t("labels.centerVertically")}
|
||||
aria-label={t("labels.centerVertically")}
|
||||
visible={isSomeElementSelected(getNonDeletedElements(elements), appState)}
|
||||
/>
|
||||
),
|
||||
});
|
||||
|
||||
export const actionAlignHorizontallyCentered = register({
|
||||
name: "alignHorizontallyCentered",
|
||||
perform: (elements, appState) => {
|
||||
trackEvent(EVENT_ALIGN, "horizontally", "center");
|
||||
return {
|
||||
appState,
|
||||
elements: alignSelectedElements(elements, appState, {
|
||||
position: "center",
|
||||
axis: "x",
|
||||
}),
|
||||
commitToHistory: true,
|
||||
};
|
||||
},
|
||||
PanelComponent: ({ elements, appState, updateData }) => (
|
||||
<ToolButton
|
||||
hidden={!enableActionGroup(elements, appState)}
|
||||
type="button"
|
||||
icon={<CenterHorizontallyIcon appearance={appState.appearance} />}
|
||||
onClick={() => updateData(null)}
|
||||
title={t("labels.centerHorizontally")}
|
||||
aria-label={t("labels.centerHorizontally")}
|
||||
visible={isSomeElementSelected(getNonDeletedElements(elements), appState)}
|
||||
/>
|
||||
),
|
||||
});
|
@@ -1,21 +1,35 @@
|
||||
import React from "react";
|
||||
import { ColorPicker } from "../components/ColorPicker";
|
||||
import { EVENT_ACTION, EVENT_CHANGE, trackEvent } from "../analytics";
|
||||
import { getDefaultAppState } from "../appState";
|
||||
import { trash, zoomIn, zoomOut, resetZoom } from "../components/icons";
|
||||
import colors from "../colors";
|
||||
import { ColorPicker } from "../components/ColorPicker";
|
||||
import { resetZoom, trash, zoomIn, zoomOut } from "../components/icons";
|
||||
import { ToolButton } from "../components/ToolButton";
|
||||
import { t } from "../i18n";
|
||||
import { getNormalizedZoom, calculateScrollCenter } from "../scene";
|
||||
import { KEYS } from "../keys";
|
||||
import { getShortcutKey } from "../utils";
|
||||
import useIsMobile from "../is-mobile";
|
||||
import { register } from "./register";
|
||||
import { getCommonBounds, getNonDeletedElements } from "../element";
|
||||
import { newElementWith } from "../element/mutateElement";
|
||||
import { AppState, FlooredNumber } from "../types";
|
||||
import { getCommonBounds } from "../element";
|
||||
import { ExcalidrawElement } from "../element/types";
|
||||
import { t } from "../i18n";
|
||||
import useIsMobile from "../is-mobile";
|
||||
import { CODES, KEYS } from "../keys";
|
||||
import { getNormalizedZoom, getSelectedElements } from "../scene";
|
||||
import { centerScrollOn } from "../scene/scroll";
|
||||
import { getNewZoom } from "../scene/zoom";
|
||||
import { AppState, NormalizedZoomValue } from "../types";
|
||||
import { getNewSceneName, getShortcutKey } from "../utils";
|
||||
import { register } from "./register";
|
||||
|
||||
export const actionChangeViewBackgroundColor = register({
|
||||
name: "changeViewBackgroundColor",
|
||||
perform: (_, appState, value) => {
|
||||
if (value !== appState.viewBackgroundColor) {
|
||||
trackEvent(
|
||||
EVENT_CHANGE,
|
||||
"canvas color",
|
||||
colors.canvasBackground.includes(value)
|
||||
? `${value} (picker ${colors.canvasBackground.indexOf(value)})`
|
||||
: value,
|
||||
);
|
||||
}
|
||||
return {
|
||||
appState: { ...appState, viewBackgroundColor: value },
|
||||
commitToHistory: true,
|
||||
@@ -38,13 +52,22 @@ export const actionChangeViewBackgroundColor = register({
|
||||
export const actionClearCanvas = register({
|
||||
name: "clearCanvas",
|
||||
perform: (elements, appState: AppState) => {
|
||||
trackEvent(EVENT_ACTION, "clear canvas");
|
||||
return {
|
||||
elements: elements.map((element) =>
|
||||
newElementWith(element, { isDeleted: true }),
|
||||
),
|
||||
appState: {
|
||||
...getDefaultAppState(),
|
||||
username: appState.username,
|
||||
name: getNewSceneName(),
|
||||
appearance: appState.appearance,
|
||||
elementLocked: appState.elementLocked,
|
||||
exportBackground: appState.exportBackground,
|
||||
exportEmbedScene: appState.exportEmbedScene,
|
||||
gridSize: appState.gridSize,
|
||||
shouldAddWatermark: appState.shouldAddWatermark,
|
||||
showStats: appState.showStats,
|
||||
pasteDialog: appState.pasteDialog,
|
||||
},
|
||||
commitToHistory: true,
|
||||
};
|
||||
@@ -58,10 +81,6 @@ export const actionClearCanvas = register({
|
||||
showAriaLabel={useIsMobile()}
|
||||
onClick={() => {
|
||||
if (window.confirm(t("alerts.clearReset"))) {
|
||||
// TODO: Defined globally, since file handles aren't yet serializable.
|
||||
// Once `FileSystemFileHandle` can be serialized, make this
|
||||
// part of `AppState`.
|
||||
(window as any).handle = null;
|
||||
updateData(null);
|
||||
}
|
||||
}}
|
||||
@@ -71,23 +90,20 @@ export const actionClearCanvas = register({
|
||||
|
||||
const ZOOM_STEP = 0.1;
|
||||
|
||||
const KEY_CODES = {
|
||||
MINUS: "Minus",
|
||||
EQUAL: "Equal",
|
||||
ONE: "Digit1",
|
||||
ZERO: "Digit0",
|
||||
NUM_SUBTRACT: "NumpadSubtract",
|
||||
NUM_ADD: "NumpadAdd",
|
||||
NUM_ZERO: "Numpad0",
|
||||
};
|
||||
|
||||
export const actionZoomIn = register({
|
||||
name: "zoomIn",
|
||||
perform: (_elements, appState) => {
|
||||
const zoom = getNewZoom(
|
||||
getNormalizedZoom(appState.zoom.value + ZOOM_STEP),
|
||||
appState.zoom,
|
||||
{ left: appState.offsetLeft, top: appState.offsetTop },
|
||||
{ x: appState.width / 2, y: appState.height / 2 },
|
||||
);
|
||||
trackEvent(EVENT_ACTION, "zoom", "in", zoom.value * 100);
|
||||
return {
|
||||
appState: {
|
||||
...appState,
|
||||
zoom: getNormalizedZoom(appState.zoom + ZOOM_STEP),
|
||||
zoom,
|
||||
},
|
||||
commitToHistory: false,
|
||||
};
|
||||
@@ -104,17 +120,25 @@ export const actionZoomIn = register({
|
||||
/>
|
||||
),
|
||||
keyTest: (event) =>
|
||||
(event.code === KEY_CODES.EQUAL || event.code === KEY_CODES.NUM_ADD) &&
|
||||
(event.code === CODES.EQUAL || event.code === CODES.NUM_ADD) &&
|
||||
(event[KEYS.CTRL_OR_CMD] || event.shiftKey),
|
||||
});
|
||||
|
||||
export const actionZoomOut = register({
|
||||
name: "zoomOut",
|
||||
perform: (_elements, appState) => {
|
||||
const zoom = getNewZoom(
|
||||
getNormalizedZoom(appState.zoom.value - ZOOM_STEP),
|
||||
appState.zoom,
|
||||
{ left: appState.offsetLeft, top: appState.offsetTop },
|
||||
{ x: appState.width / 2, y: appState.height / 2 },
|
||||
);
|
||||
|
||||
trackEvent(EVENT_ACTION, "zoom", "out", zoom.value * 100);
|
||||
return {
|
||||
appState: {
|
||||
...appState,
|
||||
zoom: getNormalizedZoom(appState.zoom - ZOOM_STEP),
|
||||
zoom,
|
||||
},
|
||||
commitToHistory: false,
|
||||
};
|
||||
@@ -131,17 +155,26 @@ export const actionZoomOut = register({
|
||||
/>
|
||||
),
|
||||
keyTest: (event) =>
|
||||
(event.code === KEY_CODES.MINUS || event.code === KEY_CODES.NUM_SUBTRACT) &&
|
||||
(event.code === CODES.MINUS || event.code === CODES.NUM_SUBTRACT) &&
|
||||
(event[KEYS.CTRL_OR_CMD] || event.shiftKey),
|
||||
});
|
||||
|
||||
export const actionResetZoom = register({
|
||||
name: "resetZoom",
|
||||
perform: (_elements, appState) => {
|
||||
trackEvent(EVENT_ACTION, "zoom", "reset", 100);
|
||||
return {
|
||||
appState: {
|
||||
...appState,
|
||||
zoom: 1,
|
||||
zoom: getNewZoom(
|
||||
1 as NormalizedZoomValue,
|
||||
appState.zoom,
|
||||
{ left: appState.offsetLeft, top: appState.offsetTop },
|
||||
{
|
||||
x: appState.width / 2,
|
||||
y: appState.height / 2,
|
||||
},
|
||||
),
|
||||
},
|
||||
commitToHistory: false,
|
||||
};
|
||||
@@ -158,66 +191,88 @@ export const actionResetZoom = register({
|
||||
/>
|
||||
),
|
||||
keyTest: (event) =>
|
||||
(event.code === KEY_CODES.ZERO || event.code === KEY_CODES.NUM_ZERO) &&
|
||||
(event.code === CODES.ZERO || event.code === CODES.NUM_ZERO) &&
|
||||
(event[KEYS.CTRL_OR_CMD] || event.shiftKey),
|
||||
});
|
||||
|
||||
const calculateZoom = (
|
||||
commonBounds: number[],
|
||||
currentZoom: number,
|
||||
{
|
||||
scrollX,
|
||||
scrollY,
|
||||
}: {
|
||||
scrollX: FlooredNumber;
|
||||
scrollY: FlooredNumber;
|
||||
},
|
||||
): number => {
|
||||
const { innerWidth, innerHeight } = window;
|
||||
const [x, y] = commonBounds;
|
||||
const zoomX = -innerWidth / (2 * scrollX + 2 * x - innerWidth);
|
||||
const zoomY = -innerHeight / (2 * scrollY + 2 * y - innerHeight);
|
||||
const margin = 0.01;
|
||||
let newZoom;
|
||||
|
||||
if (zoomX < zoomY) {
|
||||
newZoom = zoomX - margin;
|
||||
} else if (zoomY <= zoomX) {
|
||||
newZoom = zoomY - margin;
|
||||
} else {
|
||||
newZoom = currentZoom;
|
||||
}
|
||||
|
||||
if (newZoom <= 0.1) {
|
||||
return 0.1;
|
||||
}
|
||||
if (newZoom >= 1) {
|
||||
return 1;
|
||||
}
|
||||
|
||||
return newZoom;
|
||||
const zoomValueToFitBoundsOnViewport = (
|
||||
bounds: [number, number, number, number],
|
||||
viewportDimensions: { width: number; height: number },
|
||||
) => {
|
||||
const [x1, y1, x2, y2] = bounds;
|
||||
const commonBoundsWidth = x2 - x1;
|
||||
const zoomValueForWidth = viewportDimensions.width / commonBoundsWidth;
|
||||
const commonBoundsHeight = y2 - y1;
|
||||
const zoomValueForHeight = viewportDimensions.height / commonBoundsHeight;
|
||||
const smallestZoomValue = Math.min(zoomValueForWidth, zoomValueForHeight);
|
||||
const zoomAdjustedToSteps =
|
||||
Math.floor(smallestZoomValue / ZOOM_STEP) * ZOOM_STEP;
|
||||
const clampedZoomValueToFitElements = Math.min(
|
||||
Math.max(zoomAdjustedToSteps, ZOOM_STEP),
|
||||
1,
|
||||
);
|
||||
return clampedZoomValueToFitElements as NormalizedZoomValue;
|
||||
};
|
||||
|
||||
export const actionZoomToFit = register({
|
||||
name: "zoomToFit",
|
||||
perform: (elements, appState) => {
|
||||
const nonDeletedElements = elements.filter((element) => !element.isDeleted);
|
||||
const scrollCenter = calculateScrollCenter(nonDeletedElements);
|
||||
const commonBounds = getCommonBounds(nonDeletedElements);
|
||||
const zoom = calculateZoom(commonBounds, appState.zoom, scrollCenter);
|
||||
const zoomToFitElements = (
|
||||
elements: readonly ExcalidrawElement[],
|
||||
appState: Readonly<AppState>,
|
||||
zoomToSelection: boolean,
|
||||
) => {
|
||||
const nonDeletedElements = getNonDeletedElements(elements);
|
||||
const selectedElements = getSelectedElements(nonDeletedElements, appState);
|
||||
|
||||
return {
|
||||
appState: {
|
||||
...appState,
|
||||
scrollX: scrollCenter.scrollX,
|
||||
scrollY: scrollCenter.scrollY,
|
||||
zoom,
|
||||
},
|
||||
commitToHistory: false,
|
||||
};
|
||||
},
|
||||
const commonBounds =
|
||||
zoomToSelection && selectedElements.length > 0
|
||||
? getCommonBounds(selectedElements)
|
||||
: getCommonBounds(nonDeletedElements);
|
||||
|
||||
const zoomValue = zoomValueToFitBoundsOnViewport(commonBounds, {
|
||||
width: appState.width,
|
||||
height: appState.height,
|
||||
});
|
||||
const newZoom = getNewZoom(zoomValue, appState.zoom, {
|
||||
left: appState.offsetLeft,
|
||||
top: appState.offsetTop,
|
||||
});
|
||||
const action = zoomToSelection ? "selection" : "fit";
|
||||
|
||||
const [x1, y1, x2, y2] = commonBounds;
|
||||
const centerX = (x1 + x2) / 2;
|
||||
const centerY = (y1 + y2) / 2;
|
||||
trackEvent(EVENT_ACTION, "zoom", action, newZoom.value * 100);
|
||||
return {
|
||||
appState: {
|
||||
...appState,
|
||||
...centerScrollOn({
|
||||
scenePoint: { x: centerX, y: centerY },
|
||||
viewportDimensions: {
|
||||
width: appState.width,
|
||||
height: appState.height,
|
||||
},
|
||||
zoom: newZoom,
|
||||
}),
|
||||
zoom: newZoom,
|
||||
},
|
||||
commitToHistory: false,
|
||||
};
|
||||
};
|
||||
|
||||
export const actionZoomToSelected = register({
|
||||
name: "zoomToSelection",
|
||||
perform: (elements, appState) => zoomToFitElements(elements, appState, true),
|
||||
keyTest: (event) =>
|
||||
event.code === KEY_CODES.ONE &&
|
||||
event.code === CODES.TWO &&
|
||||
event.shiftKey &&
|
||||
!event.altKey &&
|
||||
!event[KEYS.CTRL_OR_CMD],
|
||||
});
|
||||
|
||||
export const actionZoomToFit = register({
|
||||
name: "zoomToFit",
|
||||
perform: (elements, appState) => zoomToFitElements(elements, appState, false),
|
||||
keyTest: (event) =>
|
||||
event.code === CODES.ONE &&
|
||||
event.shiftKey &&
|
||||
!event.altKey &&
|
||||
!event[KEYS.CTRL_OR_CMD],
|
||||
|
@@ -1,4 +1,4 @@
|
||||
import { deleteSelectedElements, isSomeElementSelected } from "../scene";
|
||||
import { isSomeElementSelected } from "../scene";
|
||||
import { KEYS } from "../keys";
|
||||
import { ToolButton } from "../components/ToolButton";
|
||||
import React from "react";
|
||||
@@ -6,14 +6,122 @@ import { trash } from "../components/icons";
|
||||
import { t } from "../i18n";
|
||||
import { register } from "./register";
|
||||
import { getNonDeletedElements } from "../element";
|
||||
import { ExcalidrawElement } from "../element/types";
|
||||
import { AppState } from "../types";
|
||||
import { newElementWith } from "../element/mutateElement";
|
||||
import { getElementsInGroup } from "../groups";
|
||||
import { LinearElementEditor } from "../element/linearElementEditor";
|
||||
import { fixBindingsAfterDeletion } from "../element/binding";
|
||||
|
||||
const deleteSelectedElements = (
|
||||
elements: readonly ExcalidrawElement[],
|
||||
appState: AppState,
|
||||
) => {
|
||||
return {
|
||||
elements: elements.map((el) => {
|
||||
if (appState.selectedElementIds[el.id]) {
|
||||
return newElementWith(el, { isDeleted: true });
|
||||
}
|
||||
return el;
|
||||
}),
|
||||
appState: {
|
||||
...appState,
|
||||
selectedElementIds: {},
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
const handleGroupEditingState = (
|
||||
appState: AppState,
|
||||
elements: readonly ExcalidrawElement[],
|
||||
): AppState => {
|
||||
if (appState.editingGroupId) {
|
||||
const siblingElements = getElementsInGroup(
|
||||
getNonDeletedElements(elements),
|
||||
appState.editingGroupId!,
|
||||
);
|
||||
if (siblingElements.length) {
|
||||
return {
|
||||
...appState,
|
||||
selectedElementIds: { [siblingElements[0].id]: true },
|
||||
};
|
||||
}
|
||||
}
|
||||
return appState;
|
||||
};
|
||||
|
||||
export const actionDeleteSelected = register({
|
||||
name: "deleteSelectedElements",
|
||||
perform: (elements, appState) => {
|
||||
const {
|
||||
if (appState.editingLinearElement) {
|
||||
const {
|
||||
elementId,
|
||||
activePointIndex,
|
||||
startBindingElement,
|
||||
endBindingElement,
|
||||
} = appState.editingLinearElement;
|
||||
const element = LinearElementEditor.getElement(elementId);
|
||||
if (!element) {
|
||||
return false;
|
||||
}
|
||||
if (
|
||||
// case: no point selected → delete whole element
|
||||
activePointIndex == null ||
|
||||
activePointIndex === -1 ||
|
||||
// case: deleting last remaining point
|
||||
element.points.length < 2
|
||||
) {
|
||||
const nextElements = elements.filter((el) => el.id !== element.id);
|
||||
const nextAppState = handleGroupEditingState(appState, nextElements);
|
||||
|
||||
return {
|
||||
elements: nextElements,
|
||||
appState: {
|
||||
...nextAppState,
|
||||
editingLinearElement: null,
|
||||
},
|
||||
commitToHistory: false,
|
||||
};
|
||||
}
|
||||
|
||||
// We cannot do this inside `movePoint` because it is also called
|
||||
// when deleting the uncommitted point (which hasn't caused any binding)
|
||||
const binding = {
|
||||
startBindingElement:
|
||||
activePointIndex === 0 ? null : startBindingElement,
|
||||
endBindingElement:
|
||||
activePointIndex === element.points.length - 1
|
||||
? null
|
||||
: endBindingElement,
|
||||
};
|
||||
|
||||
LinearElementEditor.movePoint(element, activePointIndex, "delete");
|
||||
|
||||
return {
|
||||
elements,
|
||||
appState: {
|
||||
...appState,
|
||||
editingLinearElement: {
|
||||
...appState.editingLinearElement,
|
||||
...binding,
|
||||
activePointIndex: activePointIndex > 0 ? activePointIndex - 1 : 0,
|
||||
},
|
||||
},
|
||||
commitToHistory: true,
|
||||
};
|
||||
}
|
||||
|
||||
let {
|
||||
elements: nextElements,
|
||||
appState: nextAppState,
|
||||
} = deleteSelectedElements(elements, appState);
|
||||
fixBindingsAfterDeletion(
|
||||
nextElements,
|
||||
elements.filter(({ id }) => appState.selectedElementIds[id]),
|
||||
);
|
||||
|
||||
nextAppState = handleGroupEditingState(nextAppState, nextElements);
|
||||
|
||||
return {
|
||||
elements: nextElements,
|
||||
appState: {
|
||||
@@ -28,7 +136,7 @@ export const actionDeleteSelected = register({
|
||||
};
|
||||
},
|
||||
contextItemLabel: "labels.delete",
|
||||
contextMenuOrder: 3,
|
||||
contextMenuOrder: 999999,
|
||||
keyTest: (event) => event.key === KEYS.BACKSPACE || event.key === KEYS.DELETE,
|
||||
PanelComponent: ({ elements, appState, updateData }) => (
|
||||
<ToolButton
|
||||
|
94
src/actions/actionDistribute.tsx
Normal file
94
src/actions/actionDistribute.tsx
Normal file
@@ -0,0 +1,94 @@
|
||||
import React from "react";
|
||||
import { CODES } from "../keys";
|
||||
import { t } from "../i18n";
|
||||
import { register } from "./register";
|
||||
import {
|
||||
DistributeHorizontallyIcon,
|
||||
DistributeVerticallyIcon,
|
||||
} from "../components/icons";
|
||||
import { getSelectedElements, isSomeElementSelected } from "../scene";
|
||||
import { getElementMap, getNonDeletedElements } from "../element";
|
||||
import { ToolButton } from "../components/ToolButton";
|
||||
import { ExcalidrawElement } from "../element/types";
|
||||
import { AppState } from "../types";
|
||||
import { distributeElements, Distribution } from "../disitrubte";
|
||||
import { getShortcutKey } from "../utils";
|
||||
import { EVENT_ALIGN, trackEvent } from "../analytics";
|
||||
|
||||
const enableActionGroup = (
|
||||
elements: readonly ExcalidrawElement[],
|
||||
appState: AppState,
|
||||
) => getSelectedElements(getNonDeletedElements(elements), appState).length > 1;
|
||||
|
||||
const distributeSelectedElements = (
|
||||
elements: readonly ExcalidrawElement[],
|
||||
appState: Readonly<AppState>,
|
||||
distribution: Distribution,
|
||||
) => {
|
||||
const selectedElements = getSelectedElements(
|
||||
getNonDeletedElements(elements),
|
||||
appState,
|
||||
);
|
||||
|
||||
const updatedElements = distributeElements(selectedElements, distribution);
|
||||
|
||||
const updatedElementsMap = getElementMap(updatedElements);
|
||||
|
||||
return elements.map((element) => updatedElementsMap[element.id] || element);
|
||||
};
|
||||
|
||||
export const distributeHorizontally = register({
|
||||
name: "distributeHorizontally",
|
||||
perform: (elements, appState) => {
|
||||
trackEvent(EVENT_ALIGN, "distribute", "horizontally");
|
||||
return {
|
||||
appState,
|
||||
elements: distributeSelectedElements(elements, appState, {
|
||||
space: "between",
|
||||
axis: "x",
|
||||
}),
|
||||
commitToHistory: true,
|
||||
};
|
||||
},
|
||||
keyTest: (event) => event.altKey && event.code === CODES.H,
|
||||
PanelComponent: ({ elements, appState, updateData }) => (
|
||||
<ToolButton
|
||||
hidden={!enableActionGroup(elements, appState)}
|
||||
type="button"
|
||||
icon={<DistributeHorizontallyIcon appearance={appState.appearance} />}
|
||||
onClick={() => updateData(null)}
|
||||
title={`${t("labels.distributeHorizontally")} — ${getShortcutKey(
|
||||
"Alt+H",
|
||||
)}`}
|
||||
aria-label={t("labels.distributeHorizontally")}
|
||||
visible={isSomeElementSelected(getNonDeletedElements(elements), appState)}
|
||||
/>
|
||||
),
|
||||
});
|
||||
|
||||
export const distributeVertically = register({
|
||||
name: "distributeVertically",
|
||||
perform: (elements, appState) => {
|
||||
trackEvent(EVENT_ALIGN, "distribute", "vertically");
|
||||
return {
|
||||
appState,
|
||||
elements: distributeSelectedElements(elements, appState, {
|
||||
space: "between",
|
||||
axis: "y",
|
||||
}),
|
||||
commitToHistory: true,
|
||||
};
|
||||
},
|
||||
keyTest: (event) => event.altKey && event.code === CODES.V,
|
||||
PanelComponent: ({ elements, appState, updateData }) => (
|
||||
<ToolButton
|
||||
hidden={!enableActionGroup(elements, appState)}
|
||||
type="button"
|
||||
icon={<DistributeVerticallyIcon appearance={appState.appearance} />}
|
||||
onClick={() => updateData(null)}
|
||||
title={`${t("labels.distributeVertically")} — ${getShortcutKey("Alt+V")}`}
|
||||
aria-label={t("labels.distributeVertically")}
|
||||
visible={isSomeElementSelected(getNonDeletedElements(elements), appState)}
|
||||
/>
|
||||
),
|
||||
});
|
@@ -8,32 +8,63 @@ import { ToolButton } from "../components/ToolButton";
|
||||
import { clone } from "../components/icons";
|
||||
import { t } from "../i18n";
|
||||
import { getShortcutKey } from "../utils";
|
||||
import { LinearElementEditor } from "../element/linearElementEditor";
|
||||
import { mutateElement } from "../element/mutateElement";
|
||||
import {
|
||||
selectGroupsForSelectedElements,
|
||||
getSelectedGroupForElement,
|
||||
getElementsInGroup,
|
||||
} from "../groups";
|
||||
import { AppState } from "../types";
|
||||
import { fixBindingsAfterDuplication } from "../element/binding";
|
||||
import { ActionResult } from "./types";
|
||||
import { GRID_SIZE } from "../constants";
|
||||
|
||||
export const actionDuplicateSelection = register({
|
||||
name: "duplicateSelection",
|
||||
perform: (elements, appState) => {
|
||||
return {
|
||||
appState,
|
||||
elements: elements.reduce(
|
||||
(acc: Array<ExcalidrawElement>, element: ExcalidrawElement) => {
|
||||
if (appState.selectedElementIds[element.id]) {
|
||||
const newElement = duplicateElement(element, {
|
||||
x: element.x + 10,
|
||||
y: element.y + 10,
|
||||
});
|
||||
appState.selectedElementIds[newElement.id] = true;
|
||||
delete appState.selectedElementIds[element.id];
|
||||
return acc.concat([element, newElement]);
|
||||
}
|
||||
return acc.concat(element);
|
||||
// duplicate point if selected while editing multi-point element
|
||||
if (appState.editingLinearElement) {
|
||||
const { activePointIndex, elementId } = appState.editingLinearElement;
|
||||
const element = LinearElementEditor.getElement(elementId);
|
||||
if (!element || activePointIndex === null) {
|
||||
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 {
|
||||
appState: {
|
||||
...appState,
|
||||
editingLinearElement: {
|
||||
...appState.editingLinearElement,
|
||||
activePointIndex: activePointIndex + 1,
|
||||
},
|
||||
},
|
||||
[],
|
||||
),
|
||||
elements,
|
||||
commitToHistory: true,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
...duplicateElements(elements, appState),
|
||||
commitToHistory: true,
|
||||
};
|
||||
},
|
||||
contextItemLabel: "labels.duplicateSelection",
|
||||
keyTest: (event) => event[KEYS.CTRL_OR_CMD] && event.key === "d",
|
||||
keyTest: (event) => event[KEYS.CTRL_OR_CMD] && event.key === KEYS.D,
|
||||
PanelComponent: ({ elements, appState, updateData }) => (
|
||||
<ToolButton
|
||||
type="button"
|
||||
@@ -47,3 +78,74 @@ export const actionDuplicateSelection = register({
|
||||
/>
|
||||
),
|
||||
});
|
||||
|
||||
const duplicateElements = (
|
||||
elements: readonly ExcalidrawElement[],
|
||||
appState: AppState,
|
||||
): Partial<ActionResult> => {
|
||||
const groupIdMap = new Map();
|
||||
const newElements: ExcalidrawElement[] = [];
|
||||
const oldElements: ExcalidrawElement[] = [];
|
||||
const oldIdToDuplicatedId = new Map();
|
||||
|
||||
const duplicateAndOffsetElement = (element: ExcalidrawElement) => {
|
||||
const newElement = duplicateElement(
|
||||
appState.editingGroupId,
|
||||
groupIdMap,
|
||||
element,
|
||||
{
|
||||
x: element.x + GRID_SIZE / 2,
|
||||
y: element.y + GRID_SIZE / 2,
|
||||
},
|
||||
);
|
||||
oldIdToDuplicatedId.set(element.id, newElement.id);
|
||||
oldElements.push(element);
|
||||
newElements.push(newElement);
|
||||
return newElement;
|
||||
};
|
||||
|
||||
const finalElements: ExcalidrawElement[] = [];
|
||||
|
||||
let index = 0;
|
||||
while (index < elements.length) {
|
||||
const element = elements[index];
|
||||
if (appState.selectedElementIds[element.id]) {
|
||||
if (element.groupIds.length) {
|
||||
const groupId = getSelectedGroupForElement(appState, element);
|
||||
// if group selected, duplicate it atomically
|
||||
if (groupId) {
|
||||
const groupElements = getElementsInGroup(elements, groupId);
|
||||
finalElements.push(
|
||||
...groupElements,
|
||||
...groupElements.map((element) =>
|
||||
duplicateAndOffsetElement(element),
|
||||
),
|
||||
);
|
||||
index = index + groupElements.length;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
finalElements.push(element, duplicateAndOffsetElement(element));
|
||||
} else {
|
||||
finalElements.push(element);
|
||||
}
|
||||
index++;
|
||||
}
|
||||
|
||||
fixBindingsAfterDuplication(finalElements, oldElements, oldIdToDuplicatedId);
|
||||
|
||||
return {
|
||||
elements: finalElements,
|
||||
appState: selectGroupsForSelectedElements(
|
||||
{
|
||||
...appState,
|
||||
selectedGroupIds: {},
|
||||
selectedElementIds: newElements.reduce((acc, element) => {
|
||||
acc[element.id] = true;
|
||||
return acc;
|
||||
}, {} as any),
|
||||
},
|
||||
getNonDeletedElements(finalElements),
|
||||
),
|
||||
};
|
||||
};
|
||||
|
@@ -1,22 +1,29 @@
|
||||
import React from "react";
|
||||
import { EVENT_CHANGE, EVENT_IO, trackEvent } from "../analytics";
|
||||
import { load, save, saveAs } from "../components/icons";
|
||||
import { ProjectName } from "../components/ProjectName";
|
||||
import { saveAsJSON, loadFromJSON } from "../data";
|
||||
import { load, save } from "../components/icons";
|
||||
import { ToolButton } from "../components/ToolButton";
|
||||
import { Tooltip } from "../components/Tooltip";
|
||||
import { questionCircle } from "../components/icons";
|
||||
import { loadFromJSON, saveAsJSON } from "../data";
|
||||
import { t } from "../i18n";
|
||||
import useIsMobile from "../is-mobile";
|
||||
import { register } from "./register";
|
||||
import { KEYS } from "../keys";
|
||||
import { muteFSAbortError } from "../utils";
|
||||
import { register } from "./register";
|
||||
import "../components/ToolIcon.scss";
|
||||
import { SCENE_NAME_FALLBACK } from "../constants";
|
||||
|
||||
export const actionChangeProjectName = register({
|
||||
name: "changeProjectName",
|
||||
perform: (_elements, appState, value) => {
|
||||
trackEvent(EVENT_CHANGE, "title");
|
||||
return { appState: { ...appState, name: value }, commitToHistory: false };
|
||||
},
|
||||
PanelComponent: ({ appState, updateData }) => (
|
||||
<ProjectName
|
||||
label={t("labels.fileTitle")}
|
||||
value={appState.name || "Unnamed"}
|
||||
value={appState.name || SCENE_NAME_FALLBACK}
|
||||
onChange={(name: string) => updateData(name)}
|
||||
/>
|
||||
),
|
||||
@@ -42,6 +49,33 @@ export const actionChangeExportBackground = register({
|
||||
),
|
||||
});
|
||||
|
||||
export const actionChangeExportEmbedScene = register({
|
||||
name: "changeExportEmbedScene",
|
||||
perform: (_elements, appState, value) => {
|
||||
return {
|
||||
appState: { ...appState, exportEmbedScene: value },
|
||||
commitToHistory: false,
|
||||
};
|
||||
},
|
||||
PanelComponent: ({ appState, updateData }) => (
|
||||
<label style={{ display: "flex" }}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={appState.exportEmbedScene}
|
||||
onChange={(event) => updateData(event.target.checked)}
|
||||
/>{" "}
|
||||
{t("labels.exportEmbedScene")}
|
||||
<Tooltip
|
||||
label={t("labels.exportEmbedScene_details")}
|
||||
position="above"
|
||||
long={true}
|
||||
>
|
||||
<div className="TooltipIcon">{questionCircle}</div>
|
||||
</Tooltip>
|
||||
</label>
|
||||
),
|
||||
});
|
||||
|
||||
export const actionChangeShouldAddWatermark = register({
|
||||
name: "changeShouldAddWatermark",
|
||||
perform: (_elements, appState, value) => {
|
||||
@@ -64,13 +98,20 @@ export const actionChangeShouldAddWatermark = register({
|
||||
|
||||
export const actionSaveScene = register({
|
||||
name: "saveScene",
|
||||
perform: (elements, appState, value) => {
|
||||
saveAsJSON(elements, appState).catch((error) => console.error(error));
|
||||
return { commitToHistory: false };
|
||||
},
|
||||
keyTest: (event) => {
|
||||
return event.key === "s" && event[KEYS.CTRL_OR_CMD];
|
||||
perform: async (elements, appState, value) => {
|
||||
try {
|
||||
const { fileHandle } = await saveAsJSON(elements, appState);
|
||||
trackEvent(EVENT_IO, "save");
|
||||
return { commitToHistory: false, appState: { ...appState, fileHandle } };
|
||||
} catch (error) {
|
||||
if (error?.name !== "AbortError") {
|
||||
console.error(error);
|
||||
}
|
||||
return { commitToHistory: false };
|
||||
}
|
||||
},
|
||||
keyTest: (event) =>
|
||||
event.key === KEYS.S && event[KEYS.CTRL_OR_CMD] && !event.shiftKey,
|
||||
PanelComponent: ({ updateData }) => (
|
||||
<ToolButton
|
||||
type="button"
|
||||
@@ -83,23 +124,55 @@ export const actionSaveScene = register({
|
||||
),
|
||||
});
|
||||
|
||||
export const actionSaveAsScene = register({
|
||||
name: "saveAsScene",
|
||||
perform: async (elements, appState, value) => {
|
||||
try {
|
||||
const { fileHandle } = await saveAsJSON(elements, {
|
||||
...appState,
|
||||
fileHandle: null,
|
||||
});
|
||||
trackEvent(EVENT_IO, "save as");
|
||||
return { commitToHistory: false, appState: { ...appState, fileHandle } };
|
||||
} catch (error) {
|
||||
if (error?.name !== "AbortError") {
|
||||
console.error(error);
|
||||
}
|
||||
return { commitToHistory: false };
|
||||
}
|
||||
},
|
||||
keyTest: (event) =>
|
||||
event.key === KEYS.S && event.shiftKey && event[KEYS.CTRL_OR_CMD],
|
||||
PanelComponent: ({ updateData }) => (
|
||||
<ToolButton
|
||||
type="button"
|
||||
icon={saveAs}
|
||||
title={t("buttons.saveAs")}
|
||||
aria-label={t("buttons.saveAs")}
|
||||
showAriaLabel={useIsMobile()}
|
||||
hidden={
|
||||
!("chooseFileSystemEntries" in window || "showOpenFilePicker" in window)
|
||||
}
|
||||
onClick={() => updateData(null)}
|
||||
/>
|
||||
),
|
||||
});
|
||||
|
||||
export const actionLoadScene = register({
|
||||
name: "loadScene",
|
||||
perform: (
|
||||
elements,
|
||||
appState,
|
||||
{ elements: loadedElements, appState: loadedAppState, error },
|
||||
) => {
|
||||
return {
|
||||
elements: loadedElements,
|
||||
appState: {
|
||||
...loadedAppState,
|
||||
errorMessage: error,
|
||||
},
|
||||
commitToHistory: false,
|
||||
};
|
||||
},
|
||||
PanelComponent: ({ updateData }) => (
|
||||
) => ({
|
||||
elements: loadedElements,
|
||||
appState: {
|
||||
...loadedAppState,
|
||||
errorMessage: error,
|
||||
},
|
||||
commitToHistory: true,
|
||||
}),
|
||||
PanelComponent: ({ updateData, appState }) => (
|
||||
<ToolButton
|
||||
type="button"
|
||||
icon={load}
|
||||
@@ -107,15 +180,12 @@ export const actionLoadScene = register({
|
||||
aria-label={t("buttons.load")}
|
||||
showAriaLabel={useIsMobile()}
|
||||
onClick={() => {
|
||||
loadFromJSON()
|
||||
loadFromJSON(appState)
|
||||
.then(({ elements, appState }) => {
|
||||
updateData({ elements: elements, appState: appState });
|
||||
updateData({ elements, appState });
|
||||
})
|
||||
.catch(muteFSAbortError)
|
||||
.catch((error) => {
|
||||
// if user cancels, ignore the error
|
||||
if (error.name === "AbortError") {
|
||||
return;
|
||||
}
|
||||
updateData({ error: error.message });
|
||||
});
|
||||
}}
|
||||
|
@@ -8,10 +8,47 @@ import { t } from "../i18n";
|
||||
import { register } from "./register";
|
||||
import { mutateElement } from "../element/mutateElement";
|
||||
import { isPathALoop } from "../math";
|
||||
import { LinearElementEditor } from "../element/linearElementEditor";
|
||||
import Scene from "../scene/Scene";
|
||||
import {
|
||||
maybeBindLinearElement,
|
||||
bindOrUnbindLinearElement,
|
||||
} from "../element/binding";
|
||||
import { isBindingElement } from "../element/typeChecks";
|
||||
|
||||
export const actionFinalize = register({
|
||||
name: "finalize",
|
||||
perform: (elements, appState) => {
|
||||
if (appState.editingLinearElement) {
|
||||
const {
|
||||
elementId,
|
||||
startBindingElement,
|
||||
endBindingElement,
|
||||
} = appState.editingLinearElement;
|
||||
const element = LinearElementEditor.getElement(elementId);
|
||||
|
||||
if (element) {
|
||||
if (isBindingElement(element)) {
|
||||
bindOrUnbindLinearElement(
|
||||
element,
|
||||
startBindingElement,
|
||||
endBindingElement,
|
||||
);
|
||||
}
|
||||
return {
|
||||
elements:
|
||||
element.points.length < 2 || isInvisiblySmallElement(element)
|
||||
? elements.filter((el) => el.id !== element.id)
|
||||
: undefined,
|
||||
appState: {
|
||||
...appState,
|
||||
editingLinearElement: null,
|
||||
},
|
||||
commitToHistory: true,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
let newElements = elements;
|
||||
if (window.document.activeElement instanceof HTMLElement) {
|
||||
window.document.activeElement.blur();
|
||||
@@ -46,16 +83,17 @@ export const actionFinalize = register({
|
||||
// If the multi point line closes the loop,
|
||||
// set the last point to first point.
|
||||
// This ensures that loop remains closed at different scales.
|
||||
const isLoop = isPathALoop(multiPointElement.points);
|
||||
if (
|
||||
multiPointElement.type === "line" ||
|
||||
multiPointElement.type === "draw"
|
||||
) {
|
||||
if (isPathALoop(multiPointElement.points)) {
|
||||
if (isLoop) {
|
||||
const linePoints = multiPointElement.points;
|
||||
const firstPoint = linePoints[0];
|
||||
mutateElement(multiPointElement, {
|
||||
points: linePoints.map((point, i) =>
|
||||
i === linePoints.length - 1
|
||||
points: linePoints.map((point, index) =>
|
||||
index === linePoints.length - 1
|
||||
? ([firstPoint[0], firstPoint[1]] as const)
|
||||
: point,
|
||||
),
|
||||
@@ -63,6 +101,23 @@ export const actionFinalize = register({
|
||||
}
|
||||
}
|
||||
|
||||
if (
|
||||
isBindingElement(multiPointElement) &&
|
||||
!isLoop &&
|
||||
multiPointElement.points.length > 1
|
||||
) {
|
||||
const [x, y] = LinearElementEditor.getPointAtIndexGlobalCoordinates(
|
||||
multiPointElement,
|
||||
-1,
|
||||
);
|
||||
maybeBindLinearElement(
|
||||
multiPointElement,
|
||||
appState,
|
||||
Scene.getScene(multiPointElement)!,
|
||||
{ x, y },
|
||||
);
|
||||
}
|
||||
|
||||
if (!appState.elementLocked) {
|
||||
appState.selectedElementIds[multiPointElement.id] = true;
|
||||
}
|
||||
@@ -81,6 +136,8 @@ export const actionFinalize = register({
|
||||
draggingElement: null,
|
||||
multiElement: null,
|
||||
editingElement: null,
|
||||
startBoundElement: null,
|
||||
suggestedBindings: [],
|
||||
selectedElementIds:
|
||||
multiPointElement && !appState.elementLocked
|
||||
? {
|
||||
@@ -89,13 +146,13 @@ export const actionFinalize = register({
|
||||
}
|
||||
: appState.selectedElementIds,
|
||||
},
|
||||
commitToHistory: false,
|
||||
commitToHistory: appState.elementType === "draw",
|
||||
};
|
||||
},
|
||||
keyTest: (event, appState) =>
|
||||
(event.key === KEYS.ESCAPE &&
|
||||
!appState.draggingElement &&
|
||||
appState.multiElement === null) ||
|
||||
(appState.editingLinearElement !== null ||
|
||||
(!appState.draggingElement && appState.multiElement === null))) ||
|
||||
((event.key === KEYS.ESCAPE || event.key === KEYS.ENTER) &&
|
||||
appState.multiElement !== null),
|
||||
PanelComponent: ({ appState, updateData }) => (
|
||||
|
193
src/actions/actionGroup.tsx
Normal file
193
src/actions/actionGroup.tsx
Normal file
@@ -0,0 +1,193 @@
|
||||
import React from "react";
|
||||
import { CODES, KEYS } from "../keys";
|
||||
import { t } from "../i18n";
|
||||
import { getShortcutKey } from "../utils";
|
||||
import { register } from "./register";
|
||||
import { UngroupIcon, GroupIcon } from "../components/icons";
|
||||
import { newElementWith } from "../element/mutateElement";
|
||||
import { getSelectedElements, isSomeElementSelected } from "../scene";
|
||||
import {
|
||||
getSelectedGroupIds,
|
||||
selectGroup,
|
||||
selectGroupsForSelectedElements,
|
||||
getElementsInGroup,
|
||||
addToGroup,
|
||||
removeFromSelectedGroups,
|
||||
isElementInGroup,
|
||||
} from "../groups";
|
||||
import { getNonDeletedElements } from "../element";
|
||||
import { randomId } from "../random";
|
||||
import { ToolButton } from "../components/ToolButton";
|
||||
import { ExcalidrawElement } from "../element/types";
|
||||
import { AppState } from "../types";
|
||||
|
||||
const allElementsInSameGroup = (elements: readonly ExcalidrawElement[]) => {
|
||||
if (elements.length >= 2) {
|
||||
const groupIds = elements[0].groupIds;
|
||||
for (const groupId of groupIds) {
|
||||
if (
|
||||
elements.reduce(
|
||||
(acc, element) => acc && isElementInGroup(element, groupId),
|
||||
true,
|
||||
)
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
return false;
|
||||
};
|
||||
|
||||
const enableActionGroup = (
|
||||
elements: readonly ExcalidrawElement[],
|
||||
appState: AppState,
|
||||
) => {
|
||||
const selectedElements = getSelectedElements(
|
||||
getNonDeletedElements(elements),
|
||||
appState,
|
||||
);
|
||||
return (
|
||||
selectedElements.length >= 2 && !allElementsInSameGroup(selectedElements)
|
||||
);
|
||||
};
|
||||
|
||||
export const actionGroup = register({
|
||||
name: "group",
|
||||
perform: (elements, appState) => {
|
||||
const selectedElements = getSelectedElements(
|
||||
getNonDeletedElements(elements),
|
||||
appState,
|
||||
);
|
||||
if (selectedElements.length < 2) {
|
||||
// nothing to group
|
||||
return { appState, elements, commitToHistory: false };
|
||||
}
|
||||
// if everything is already grouped into 1 group, there is nothing to do
|
||||
const selectedGroupIds = getSelectedGroupIds(appState);
|
||||
if (selectedGroupIds.length === 1) {
|
||||
const selectedGroupId = selectedGroupIds[0];
|
||||
const elementIdsInGroup = new Set(
|
||||
getElementsInGroup(elements, selectedGroupId).map(
|
||||
(element) => element.id,
|
||||
),
|
||||
);
|
||||
const selectedElementIds = new Set(
|
||||
selectedElements.map((element) => element.id),
|
||||
);
|
||||
const combinedSet = new Set([
|
||||
...Array.from(elementIdsInGroup),
|
||||
...Array.from(selectedElementIds),
|
||||
]);
|
||||
if (combinedSet.size === elementIdsInGroup.size) {
|
||||
// no incremental ids in the selected ids
|
||||
return { appState, elements, commitToHistory: false };
|
||||
}
|
||||
}
|
||||
const newGroupId = randomId();
|
||||
const updatedElements = elements.map((element) => {
|
||||
if (!appState.selectedElementIds[element.id]) {
|
||||
return element;
|
||||
}
|
||||
return newElementWith(element, {
|
||||
groupIds: addToGroup(
|
||||
element.groupIds,
|
||||
newGroupId,
|
||||
appState.editingGroupId,
|
||||
),
|
||||
});
|
||||
});
|
||||
// keep the z order within the group the same, but move them
|
||||
// to the z order of the highest element in the layer stack
|
||||
const elementsInGroup = getElementsInGroup(updatedElements, newGroupId);
|
||||
const lastElementInGroup = elementsInGroup[elementsInGroup.length - 1];
|
||||
const lastGroupElementIndex = updatedElements.lastIndexOf(
|
||||
lastElementInGroup,
|
||||
);
|
||||
const elementsAfterGroup = updatedElements.slice(lastGroupElementIndex + 1);
|
||||
const elementsBeforeGroup = updatedElements
|
||||
.slice(0, lastGroupElementIndex)
|
||||
.filter(
|
||||
(updatedElement) => !isElementInGroup(updatedElement, newGroupId),
|
||||
);
|
||||
const updatedElementsInOrder = [
|
||||
...elementsBeforeGroup,
|
||||
...elementsInGroup,
|
||||
...elementsAfterGroup,
|
||||
];
|
||||
|
||||
return {
|
||||
appState: selectGroup(
|
||||
newGroupId,
|
||||
{ ...appState, selectedGroupIds: {} },
|
||||
getNonDeletedElements(updatedElementsInOrder),
|
||||
),
|
||||
elements: updatedElementsInOrder,
|
||||
commitToHistory: true,
|
||||
};
|
||||
},
|
||||
contextMenuOrder: 4,
|
||||
contextItemLabel: "labels.group",
|
||||
contextItemPredicate: (elements, appState) =>
|
||||
enableActionGroup(elements, appState),
|
||||
keyTest: (event) =>
|
||||
!event.shiftKey && event[KEYS.CTRL_OR_CMD] && event.code === CODES.G,
|
||||
PanelComponent: ({ elements, appState, updateData }) => (
|
||||
<ToolButton
|
||||
hidden={!enableActionGroup(elements, appState)}
|
||||
type="button"
|
||||
icon={<GroupIcon appearance={appState.appearance} />}
|
||||
onClick={() => updateData(null)}
|
||||
title={`${t("labels.group")} — ${getShortcutKey("CtrlOrCmd+G")}`}
|
||||
aria-label={t("labels.group")}
|
||||
visible={isSomeElementSelected(getNonDeletedElements(elements), appState)}
|
||||
></ToolButton>
|
||||
),
|
||||
});
|
||||
|
||||
export const actionUngroup = register({
|
||||
name: "ungroup",
|
||||
perform: (elements, appState) => {
|
||||
const groupIds = getSelectedGroupIds(appState);
|
||||
if (groupIds.length === 0) {
|
||||
return { appState, elements, commitToHistory: false };
|
||||
}
|
||||
const nextElements = elements.map((element) => {
|
||||
const nextGroupIds = removeFromSelectedGroups(
|
||||
element.groupIds,
|
||||
appState.selectedGroupIds,
|
||||
);
|
||||
if (nextGroupIds.length === element.groupIds.length) {
|
||||
return element;
|
||||
}
|
||||
return newElementWith(element, {
|
||||
groupIds: nextGroupIds,
|
||||
});
|
||||
});
|
||||
return {
|
||||
appState: selectGroupsForSelectedElements(
|
||||
{ ...appState, selectedGroupIds: {} },
|
||||
getNonDeletedElements(nextElements),
|
||||
),
|
||||
elements: nextElements,
|
||||
commitToHistory: true,
|
||||
};
|
||||
},
|
||||
keyTest: (event) =>
|
||||
event.shiftKey && event[KEYS.CTRL_OR_CMD] && event.code === CODES.G,
|
||||
contextMenuOrder: 5,
|
||||
contextItemLabel: "labels.ungroup",
|
||||
contextItemPredicate: (elements, appState) =>
|
||||
getSelectedGroupIds(appState).length > 0,
|
||||
|
||||
PanelComponent: ({ elements, appState, updateData }) => (
|
||||
<ToolButton
|
||||
type="button"
|
||||
hidden={getSelectedGroupIds(appState).length === 0}
|
||||
icon={<UngroupIcon appearance={appState.appearance} />}
|
||||
onClick={() => updateData(null)}
|
||||
title={`${t("labels.ungroup")} — ${getShortcutKey("CtrlOrCmd+Shift+G")}`}
|
||||
aria-label={t("labels.ungroup")}
|
||||
visible={isSomeElementSelected(getNonDeletedElements(elements), appState)}
|
||||
></ToolButton>
|
||||
),
|
||||
});
|
@@ -3,20 +3,18 @@ import React from "react";
|
||||
import { undo, redo } from "../components/icons";
|
||||
import { ToolButton } from "../components/ToolButton";
|
||||
import { t } from "../i18n";
|
||||
import { SceneHistory } from "../history";
|
||||
import { SceneHistory, HistoryEntry } from "../history";
|
||||
import { ExcalidrawElement } from "../element/types";
|
||||
import { AppState } from "../types";
|
||||
import { KEYS } from "../keys";
|
||||
import { getElementMap } from "../element";
|
||||
import { newElementWith } from "../element/mutateElement";
|
||||
import { fixBindingsAfterDeletion } from "../element/binding";
|
||||
|
||||
const writeData = (
|
||||
prevElements: readonly ExcalidrawElement[],
|
||||
appState: AppState,
|
||||
updater: () => {
|
||||
elements: ExcalidrawElement[];
|
||||
appState: AppState;
|
||||
} | null,
|
||||
updater: () => HistoryEntry | null,
|
||||
): ActionResult => {
|
||||
const commitToHistory = false;
|
||||
if (
|
||||
@@ -33,25 +31,29 @@ const writeData = (
|
||||
const prevElementMap = getElementMap(prevElements);
|
||||
const nextElements = data.elements;
|
||||
const nextElementMap = getElementMap(nextElements);
|
||||
return {
|
||||
elements: nextElements
|
||||
.map((nextElement) =>
|
||||
newElementWith(
|
||||
prevElementMap[nextElement.id] || nextElement,
|
||||
nextElement,
|
||||
),
|
||||
)
|
||||
.concat(
|
||||
prevElements
|
||||
.filter(
|
||||
(prevElement) => !nextElementMap.hasOwnProperty(prevElement.id),
|
||||
)
|
||||
.map((prevElement) =>
|
||||
newElementWith(prevElement, { isDeleted: true }),
|
||||
),
|
||||
|
||||
const deletedElements = prevElements.filter(
|
||||
(prevElement) => !nextElementMap.hasOwnProperty(prevElement.id),
|
||||
);
|
||||
const elements = nextElements
|
||||
.map((nextElement) =>
|
||||
newElementWith(
|
||||
prevElementMap[nextElement.id] || nextElement,
|
||||
nextElement,
|
||||
),
|
||||
)
|
||||
.concat(
|
||||
deletedElements.map((prevElement) =>
|
||||
newElementWith(prevElement, { isDeleted: true }),
|
||||
),
|
||||
);
|
||||
fixBindingsAfterDeletion(elements, deletedElements);
|
||||
|
||||
return {
|
||||
elements,
|
||||
appState: { ...appState, ...data.appState },
|
||||
commitToHistory,
|
||||
syncHistory: true,
|
||||
};
|
||||
}
|
||||
return { commitToHistory };
|
||||
|
@@ -5,8 +5,9 @@ import { t } from "../i18n";
|
||||
import { showSelectedShapeActions, getNonDeletedElements } from "../element";
|
||||
import { register } from "./register";
|
||||
import { allowFullScreen, exitFullScreen, isFullScreen } from "../utils";
|
||||
import { KEYS } from "../keys";
|
||||
import { CODES, KEYS } from "../keys";
|
||||
import { HelpIcon } from "../components/HelpIcon";
|
||||
import { EVENT_DIALOG, trackEvent } from "../analytics";
|
||||
|
||||
export const actionToggleCanvasMenu = register({
|
||||
name: "toggleCanvasMenu",
|
||||
@@ -65,12 +66,13 @@ export const actionFullScreen = register({
|
||||
commitToHistory: false,
|
||||
};
|
||||
},
|
||||
keyTest: (event) => event.keyCode === KEYS.F_KEY_CODE,
|
||||
keyTest: (event) => event.code === CODES.F && !event[KEYS.CTRL_OR_CMD],
|
||||
});
|
||||
|
||||
export const actionShortcuts = register({
|
||||
name: "toggleShortcuts",
|
||||
perform: (_elements, appState) => {
|
||||
trackEvent(EVENT_DIALOG, "shortcuts");
|
||||
return {
|
||||
appState: {
|
||||
...appState,
|
||||
|
60
src/actions/actionNavigate.tsx
Normal file
60
src/actions/actionNavigate.tsx
Normal file
@@ -0,0 +1,60 @@
|
||||
import React from "react";
|
||||
import { Avatar } from "../components/Avatar";
|
||||
import { register } from "./register";
|
||||
import { getClientColors, getClientInitials } from "../clients";
|
||||
import { Collaborator } from "../types";
|
||||
import { centerScrollOn } from "../scene/scroll";
|
||||
import { EVENT_SHARE, trackEvent } from "../analytics";
|
||||
|
||||
export const actionGoToCollaborator = register({
|
||||
name: "goToCollaborator",
|
||||
perform: (_elements, appState, value) => {
|
||||
const point = value as Collaborator["pointer"];
|
||||
trackEvent(EVENT_SHARE, "go to collaborator");
|
||||
if (!point) {
|
||||
return { appState, commitToHistory: false };
|
||||
}
|
||||
|
||||
return {
|
||||
appState: {
|
||||
...appState,
|
||||
...centerScrollOn({
|
||||
scenePoint: point,
|
||||
viewportDimensions: {
|
||||
width: appState.width,
|
||||
height: appState.height,
|
||||
},
|
||||
zoom: appState.zoom,
|
||||
}),
|
||||
// Close mobile menu
|
||||
openMenu: appState.openMenu === "canvas" ? null : appState.openMenu,
|
||||
},
|
||||
commitToHistory: false,
|
||||
};
|
||||
},
|
||||
PanelComponent: ({ appState, updateData, id }) => {
|
||||
const clientId = id;
|
||||
if (!clientId) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const collaborator = appState.collaborators.get(clientId);
|
||||
|
||||
if (!collaborator) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const { background, stroke } = getClientColors(clientId);
|
||||
const shortName = getClientInitials(collaborator.username);
|
||||
|
||||
return (
|
||||
<Avatar
|
||||
color={background}
|
||||
border={stroke}
|
||||
onClick={() => updateData(collaborator.pointer)}
|
||||
>
|
||||
{shortName}
|
||||
</Avatar>
|
||||
);
|
||||
},
|
||||
});
|
@@ -1,25 +1,56 @@
|
||||
import React from "react";
|
||||
import { getLanguage } from "../i18n";
|
||||
import {
|
||||
ExcalidrawElement,
|
||||
ExcalidrawTextElement,
|
||||
TextAlign,
|
||||
FontFamily,
|
||||
ExcalidrawLinearElement,
|
||||
Arrowhead,
|
||||
} from "../element/types";
|
||||
import {
|
||||
getCommonAttributeOfSelectedElements,
|
||||
isSomeElementSelected,
|
||||
getTargetElements,
|
||||
canChangeSharpness,
|
||||
canHaveArrowheads,
|
||||
} from "../scene";
|
||||
import { ButtonSelect } from "../components/ButtonSelect";
|
||||
import { ButtonIconSelect } from "../components/ButtonIconSelect";
|
||||
import { IconPicker } from "../components/IconPicker";
|
||||
import {
|
||||
isTextElement,
|
||||
redrawTextBoundingBox,
|
||||
getNonDeletedElements,
|
||||
} from "../element";
|
||||
import { isLinearElement, isLinearElementType } from "../element/typeChecks";
|
||||
import { ColorPicker } from "../components/ColorPicker";
|
||||
import { AppState } from "../../src/types";
|
||||
import { t } from "../i18n";
|
||||
import { DEFAULT_FONT } from "../appState";
|
||||
import { register } from "./register";
|
||||
import { newElementWith } from "../element/mutateElement";
|
||||
import { DEFAULT_FONT_SIZE, DEFAULT_FONT_FAMILY } from "../constants";
|
||||
import { randomInteger } from "../random";
|
||||
import {
|
||||
FillHachureIcon,
|
||||
FillCrossHatchIcon,
|
||||
FillSolidIcon,
|
||||
StrokeWidthIcon,
|
||||
StrokeStyleSolidIcon,
|
||||
StrokeStyleDashedIcon,
|
||||
StrokeStyleDottedIcon,
|
||||
EdgeSharpIcon,
|
||||
EdgeRoundIcon,
|
||||
SloppinessArchitectIcon,
|
||||
SloppinessArtistIcon,
|
||||
SloppinessCartoonistIcon,
|
||||
ArrowheadArrowIcon,
|
||||
ArrowheadBarIcon,
|
||||
ArrowheadDotIcon,
|
||||
ArrowheadNoneIcon,
|
||||
} from "../components/icons";
|
||||
import { EVENT_CHANGE, trackEvent } from "../analytics";
|
||||
import colors from "../colors";
|
||||
|
||||
const changeProperty = (
|
||||
elements: readonly ExcalidrawElement[],
|
||||
@@ -61,6 +92,15 @@ const getFormValue = function <T>(
|
||||
export const actionChangeStrokeColor = register({
|
||||
name: "changeStrokeColor",
|
||||
perform: (elements, appState, value) => {
|
||||
if (value !== appState.currentItemStrokeColor) {
|
||||
trackEvent(
|
||||
EVENT_CHANGE,
|
||||
"stroke color",
|
||||
colors.elementStroke.includes(value)
|
||||
? `${value} (picker ${colors.elementStroke.indexOf(value)})`
|
||||
: value,
|
||||
);
|
||||
}
|
||||
return {
|
||||
elements: changeProperty(elements, appState, (el) =>
|
||||
newElementWith(el, {
|
||||
@@ -92,6 +132,16 @@ export const actionChangeStrokeColor = register({
|
||||
export const actionChangeBackgroundColor = register({
|
||||
name: "changeBackgroundColor",
|
||||
perform: (elements, appState, value) => {
|
||||
if (value !== appState.currentItemBackgroundColor) {
|
||||
trackEvent(
|
||||
EVENT_CHANGE,
|
||||
"background color",
|
||||
colors.elementBackground.includes(value)
|
||||
? `${value} (picker ${colors.elementBackground.indexOf(value)})`
|
||||
: value,
|
||||
);
|
||||
}
|
||||
|
||||
return {
|
||||
elements: changeProperty(elements, appState, (el) =>
|
||||
newElementWith(el, {
|
||||
@@ -123,6 +173,7 @@ export const actionChangeBackgroundColor = register({
|
||||
export const actionChangeFillStyle = register({
|
||||
name: "changeFillStyle",
|
||||
perform: (elements, appState, value) => {
|
||||
trackEvent(EVENT_CHANGE, "fill", value);
|
||||
return {
|
||||
elements: changeProperty(elements, appState, (el) =>
|
||||
newElementWith(el, {
|
||||
@@ -136,11 +187,23 @@ export const actionChangeFillStyle = register({
|
||||
PanelComponent: ({ elements, appState, updateData }) => (
|
||||
<fieldset>
|
||||
<legend>{t("labels.fill")}</legend>
|
||||
<ButtonSelect
|
||||
<ButtonIconSelect
|
||||
options={[
|
||||
{ value: "hachure", text: t("labels.hachure") },
|
||||
{ value: "cross-hatch", text: t("labels.crossHatch") },
|
||||
{ value: "solid", text: t("labels.solid") },
|
||||
{
|
||||
value: "hachure",
|
||||
text: t("labels.hachure"),
|
||||
icon: <FillHachureIcon appearance={appState.appearance} />,
|
||||
},
|
||||
{
|
||||
value: "cross-hatch",
|
||||
text: t("labels.crossHatch"),
|
||||
icon: <FillCrossHatchIcon appearance={appState.appearance} />,
|
||||
},
|
||||
{
|
||||
value: "solid",
|
||||
text: t("labels.solid"),
|
||||
icon: <FillSolidIcon appearance={appState.appearance} />,
|
||||
},
|
||||
]}
|
||||
group="fill"
|
||||
value={getFormValue(
|
||||
@@ -160,6 +223,7 @@ export const actionChangeFillStyle = register({
|
||||
export const actionChangeStrokeWidth = register({
|
||||
name: "changeStrokeWidth",
|
||||
perform: (elements, appState, value) => {
|
||||
trackEvent(EVENT_CHANGE, "stroke", "width", value);
|
||||
return {
|
||||
elements: changeProperty(elements, appState, (el) =>
|
||||
newElementWith(el, {
|
||||
@@ -173,12 +237,39 @@ export const actionChangeStrokeWidth = register({
|
||||
PanelComponent: ({ elements, appState, updateData }) => (
|
||||
<fieldset>
|
||||
<legend>{t("labels.strokeWidth")}</legend>
|
||||
<ButtonSelect
|
||||
<ButtonIconSelect
|
||||
group="stroke-width"
|
||||
options={[
|
||||
{ value: 1, text: t("labels.thin") },
|
||||
{ value: 2, text: t("labels.bold") },
|
||||
{ value: 4, text: t("labels.extraBold") },
|
||||
{
|
||||
value: 1,
|
||||
text: t("labels.thin"),
|
||||
icon: (
|
||||
<StrokeWidthIcon
|
||||
appearance={appState.appearance}
|
||||
strokeWidth={2}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
value: 2,
|
||||
text: t("labels.bold"),
|
||||
icon: (
|
||||
<StrokeWidthIcon
|
||||
appearance={appState.appearance}
|
||||
strokeWidth={6}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
value: 4,
|
||||
text: t("labels.extraBold"),
|
||||
icon: (
|
||||
<StrokeWidthIcon
|
||||
appearance={appState.appearance}
|
||||
strokeWidth={10}
|
||||
/>
|
||||
),
|
||||
},
|
||||
]}
|
||||
value={getFormValue(
|
||||
elements,
|
||||
@@ -195,9 +286,11 @@ export const actionChangeStrokeWidth = register({
|
||||
export const actionChangeSloppiness = register({
|
||||
name: "changeSloppiness",
|
||||
perform: (elements, appState, value) => {
|
||||
trackEvent(EVENT_CHANGE, "stroke", "sloppiness", value);
|
||||
return {
|
||||
elements: changeProperty(elements, appState, (el) =>
|
||||
newElementWith(el, {
|
||||
seed: randomInteger(),
|
||||
roughness: value,
|
||||
}),
|
||||
),
|
||||
@@ -208,12 +301,24 @@ export const actionChangeSloppiness = register({
|
||||
PanelComponent: ({ elements, appState, updateData }) => (
|
||||
<fieldset>
|
||||
<legend>{t("labels.sloppiness")}</legend>
|
||||
<ButtonSelect
|
||||
<ButtonIconSelect
|
||||
group="sloppiness"
|
||||
options={[
|
||||
{ value: 0, text: t("labels.architect") },
|
||||
{ value: 1, text: t("labels.artist") },
|
||||
{ value: 2, text: t("labels.cartoonist") },
|
||||
{
|
||||
value: 0,
|
||||
text: t("labels.architect"),
|
||||
icon: <SloppinessArchitectIcon appearance={appState.appearance} />,
|
||||
},
|
||||
{
|
||||
value: 1,
|
||||
text: t("labels.artist"),
|
||||
icon: <SloppinessArtistIcon appearance={appState.appearance} />,
|
||||
},
|
||||
{
|
||||
value: 2,
|
||||
text: t("labels.cartoonist"),
|
||||
icon: <SloppinessCartoonistIcon appearance={appState.appearance} />,
|
||||
},
|
||||
]}
|
||||
value={getFormValue(
|
||||
elements,
|
||||
@@ -227,9 +332,58 @@ export const actionChangeSloppiness = register({
|
||||
),
|
||||
});
|
||||
|
||||
export const actionChangeStrokeStyle = register({
|
||||
name: "changeStrokeStyle",
|
||||
perform: (elements, appState, value) => {
|
||||
trackEvent(EVENT_CHANGE, "style", value);
|
||||
return {
|
||||
elements: changeProperty(elements, appState, (el) =>
|
||||
newElementWith(el, {
|
||||
strokeStyle: value,
|
||||
}),
|
||||
),
|
||||
appState: { ...appState, currentItemStrokeStyle: value },
|
||||
commitToHistory: true,
|
||||
};
|
||||
},
|
||||
PanelComponent: ({ elements, appState, updateData }) => (
|
||||
<fieldset>
|
||||
<legend>{t("labels.strokeStyle")}</legend>
|
||||
<ButtonIconSelect
|
||||
group="strokeStyle"
|
||||
options={[
|
||||
{
|
||||
value: "solid",
|
||||
text: t("labels.strokeStyle_solid"),
|
||||
icon: <StrokeStyleSolidIcon appearance={appState.appearance} />,
|
||||
},
|
||||
{
|
||||
value: "dashed",
|
||||
text: t("labels.strokeStyle_dashed"),
|
||||
icon: <StrokeStyleDashedIcon appearance={appState.appearance} />,
|
||||
},
|
||||
{
|
||||
value: "dotted",
|
||||
text: t("labels.strokeStyle_dotted"),
|
||||
icon: <StrokeStyleDottedIcon appearance={appState.appearance} />,
|
||||
},
|
||||
]}
|
||||
value={getFormValue(
|
||||
elements,
|
||||
appState,
|
||||
(element) => element.strokeStyle,
|
||||
appState.currentItemStrokeStyle,
|
||||
)}
|
||||
onChange={(value) => updateData(value)}
|
||||
/>
|
||||
</fieldset>
|
||||
),
|
||||
});
|
||||
|
||||
export const actionChangeOpacity = register({
|
||||
name: "changeOpacity",
|
||||
perform: (elements, appState, value) => {
|
||||
trackEvent(EVENT_CHANGE, "opacity", "value", value);
|
||||
return {
|
||||
elements: changeProperty(elements, appState, (el) =>
|
||||
newElementWith(el, {
|
||||
@@ -283,7 +437,7 @@ export const actionChangeFontSize = register({
|
||||
elements: changeProperty(elements, appState, (el) => {
|
||||
if (isTextElement(el)) {
|
||||
const element: ExcalidrawTextElement = newElementWith(el, {
|
||||
font: `${value}px ${el.font.split("px ")[1]}`,
|
||||
fontSize: value,
|
||||
});
|
||||
redrawTextBoundingBox(element);
|
||||
return element;
|
||||
@@ -293,9 +447,7 @@ export const actionChangeFontSize = register({
|
||||
}),
|
||||
appState: {
|
||||
...appState,
|
||||
currentItemFont: `${value}px ${
|
||||
appState.currentItemFont.split("px ")[1]
|
||||
}`,
|
||||
currentItemFontSize: value,
|
||||
},
|
||||
commitToHistory: true,
|
||||
};
|
||||
@@ -314,8 +466,8 @@ export const actionChangeFontSize = register({
|
||||
value={getFormValue(
|
||||
elements,
|
||||
appState,
|
||||
(element) => isTextElement(element) && +element.font.split("px ")[0],
|
||||
+(appState.currentItemFont || DEFAULT_FONT).split("px ")[0],
|
||||
(element) => isTextElement(element) && element.fontSize,
|
||||
appState.currentItemFontSize || DEFAULT_FONT_SIZE,
|
||||
)}
|
||||
onChange={(value) => updateData(value)}
|
||||
/>
|
||||
@@ -330,7 +482,7 @@ export const actionChangeFontFamily = register({
|
||||
elements: changeProperty(elements, appState, (el) => {
|
||||
if (isTextElement(el)) {
|
||||
const element: ExcalidrawTextElement = newElementWith(el, {
|
||||
font: `${el.font.split("px ")[0]}px ${value}`,
|
||||
fontFamily: value,
|
||||
});
|
||||
redrawTextBoundingBox(element);
|
||||
return element;
|
||||
@@ -340,33 +492,35 @@ export const actionChangeFontFamily = register({
|
||||
}),
|
||||
appState: {
|
||||
...appState,
|
||||
currentItemFont: `${
|
||||
appState.currentItemFont.split("px ")[0]
|
||||
}px ${value}`,
|
||||
currentItemFontFamily: value,
|
||||
},
|
||||
commitToHistory: true,
|
||||
};
|
||||
},
|
||||
PanelComponent: ({ elements, appState, updateData }) => (
|
||||
<fieldset>
|
||||
<legend>{t("labels.fontFamily")}</legend>
|
||||
<ButtonSelect
|
||||
group="font-family"
|
||||
options={[
|
||||
{ value: "Virgil", text: t("labels.handDrawn") },
|
||||
{ value: "Helvetica", text: t("labels.normal") },
|
||||
{ value: "Cascadia", text: t("labels.code") },
|
||||
]}
|
||||
value={getFormValue(
|
||||
elements,
|
||||
appState,
|
||||
(element) => isTextElement(element) && element.font.split("px ")[1],
|
||||
(appState.currentItemFont || DEFAULT_FONT).split("px ")[1],
|
||||
)}
|
||||
onChange={(value) => updateData(value)}
|
||||
/>
|
||||
</fieldset>
|
||||
),
|
||||
PanelComponent: ({ elements, appState, updateData }) => {
|
||||
const options: { value: FontFamily; text: string }[] = [
|
||||
{ value: 1, text: t("labels.handDrawn") },
|
||||
{ value: 2, text: t("labels.normal") },
|
||||
{ value: 3, text: t("labels.code") },
|
||||
];
|
||||
|
||||
return (
|
||||
<fieldset>
|
||||
<legend>{t("labels.fontFamily")}</legend>
|
||||
<ButtonSelect<FontFamily | false>
|
||||
group="font-family"
|
||||
options={options}
|
||||
value={getFormValue(
|
||||
elements,
|
||||
appState,
|
||||
(element) => isTextElement(element) && element.fontFamily,
|
||||
appState.currentItemFontFamily || DEFAULT_FONT_FAMILY,
|
||||
)}
|
||||
onChange={(value) => updateData(value)}
|
||||
/>
|
||||
</fieldset>
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
export const actionChangeTextAlign = register({
|
||||
@@ -412,3 +566,232 @@ export const actionChangeTextAlign = register({
|
||||
</fieldset>
|
||||
),
|
||||
});
|
||||
|
||||
export const actionChangeSharpness = register({
|
||||
name: "changeSharpness",
|
||||
perform: (elements, appState, value) => {
|
||||
const targetElements = getTargetElements(
|
||||
getNonDeletedElements(elements),
|
||||
appState,
|
||||
);
|
||||
const shouldUpdateForNonLinearElements = targetElements.length
|
||||
? targetElements.every((el) => !isLinearElement(el))
|
||||
: !isLinearElementType(appState.elementType);
|
||||
const shouldUpdateForLinearElements = targetElements.length
|
||||
? targetElements.every(isLinearElement)
|
||||
: isLinearElementType(appState.elementType);
|
||||
trackEvent(EVENT_CHANGE, "edge", value);
|
||||
return {
|
||||
elements: changeProperty(elements, appState, (el) =>
|
||||
newElementWith(el, {
|
||||
strokeSharpness: value,
|
||||
}),
|
||||
),
|
||||
appState: {
|
||||
...appState,
|
||||
currentItemStrokeSharpness: shouldUpdateForNonLinearElements
|
||||
? value
|
||||
: appState.currentItemStrokeSharpness,
|
||||
currentItemLinearStrokeSharpness: shouldUpdateForLinearElements
|
||||
? value
|
||||
: appState.currentItemLinearStrokeSharpness,
|
||||
},
|
||||
commitToHistory: true,
|
||||
};
|
||||
},
|
||||
PanelComponent: ({ elements, appState, updateData }) => (
|
||||
<fieldset>
|
||||
<legend>{t("labels.edges")}</legend>
|
||||
<ButtonIconSelect
|
||||
group="edges"
|
||||
options={[
|
||||
{
|
||||
value: "sharp",
|
||||
text: t("labels.sharp"),
|
||||
icon: <EdgeSharpIcon appearance={appState.appearance} />,
|
||||
},
|
||||
{
|
||||
value: "round",
|
||||
text: t("labels.round"),
|
||||
icon: <EdgeRoundIcon appearance={appState.appearance} />,
|
||||
},
|
||||
]}
|
||||
value={getFormValue(
|
||||
elements,
|
||||
appState,
|
||||
(element) => element.strokeSharpness,
|
||||
(canChangeSharpness(appState.elementType) &&
|
||||
(isLinearElementType(appState.elementType)
|
||||
? appState.currentItemLinearStrokeSharpness
|
||||
: appState.currentItemStrokeSharpness)) ||
|
||||
null,
|
||||
)}
|
||||
onChange={(value) => updateData(value)}
|
||||
/>
|
||||
</fieldset>
|
||||
),
|
||||
});
|
||||
|
||||
export const actionChangeArrowhead = register({
|
||||
name: "changeArrowhead",
|
||||
perform: (
|
||||
elements,
|
||||
appState,
|
||||
value: { position: "start" | "end"; type: Arrowhead },
|
||||
) => {
|
||||
return {
|
||||
elements: changeProperty(elements, appState, (el) => {
|
||||
if (isLinearElement(el)) {
|
||||
trackEvent(
|
||||
EVENT_CHANGE,
|
||||
`arrowhead ${value.position}`,
|
||||
value.type || "none",
|
||||
);
|
||||
|
||||
const { position, type } = value;
|
||||
|
||||
if (position === "start") {
|
||||
const element: ExcalidrawLinearElement = newElementWith(el, {
|
||||
startArrowhead: type,
|
||||
});
|
||||
return element;
|
||||
} else if (position === "end") {
|
||||
const element: ExcalidrawLinearElement = newElementWith(el, {
|
||||
endArrowhead: type,
|
||||
});
|
||||
return element;
|
||||
}
|
||||
}
|
||||
|
||||
return el;
|
||||
}),
|
||||
appState: {
|
||||
...appState,
|
||||
[value.position === "start"
|
||||
? "currentItemStartArrowhead"
|
||||
: "currentItemEndArrowhead"]: value.type,
|
||||
},
|
||||
commitToHistory: true,
|
||||
};
|
||||
},
|
||||
PanelComponent: ({ elements, appState, updateData }) => {
|
||||
const isRTL = getLanguage().rtl;
|
||||
|
||||
return (
|
||||
<fieldset>
|
||||
<legend>{t("labels.arrowheads")}</legend>
|
||||
<div className="iconSelectList">
|
||||
<IconPicker
|
||||
label="arrowhead_start"
|
||||
options={[
|
||||
{
|
||||
value: null,
|
||||
text: t("labels.arrowhead_none"),
|
||||
icon: <ArrowheadNoneIcon appearance={appState.appearance} />,
|
||||
keyBinding: "q",
|
||||
},
|
||||
{
|
||||
value: "arrow",
|
||||
text: t("labels.arrowhead_arrow"),
|
||||
icon: (
|
||||
<ArrowheadArrowIcon
|
||||
appearance={appState.appearance}
|
||||
flip={!isRTL}
|
||||
/>
|
||||
),
|
||||
keyBinding: "w",
|
||||
},
|
||||
{
|
||||
value: "bar",
|
||||
text: t("labels.arrowhead_bar"),
|
||||
icon: (
|
||||
<ArrowheadBarIcon
|
||||
appearance={appState.appearance}
|
||||
flip={!isRTL}
|
||||
/>
|
||||
),
|
||||
keyBinding: "e",
|
||||
},
|
||||
{
|
||||
value: "dot",
|
||||
text: t("labels.arrowhead_dot"),
|
||||
icon: (
|
||||
<ArrowheadDotIcon
|
||||
appearance={appState.appearance}
|
||||
flip={!isRTL}
|
||||
/>
|
||||
),
|
||||
keyBinding: "r",
|
||||
},
|
||||
]}
|
||||
value={getFormValue<Arrowhead | null>(
|
||||
elements,
|
||||
appState,
|
||||
(element) =>
|
||||
isLinearElement(element) && canHaveArrowheads(element.type)
|
||||
? element.startArrowhead
|
||||
: appState.currentItemStartArrowhead,
|
||||
appState.currentItemStartArrowhead,
|
||||
)}
|
||||
onChange={(value) => updateData({ position: "start", type: value })}
|
||||
/>
|
||||
<IconPicker
|
||||
label="arrowhead_end"
|
||||
group="arrowheads"
|
||||
options={[
|
||||
{
|
||||
value: null,
|
||||
text: t("labels.arrowhead_none"),
|
||||
keyBinding: "q",
|
||||
icon: <ArrowheadNoneIcon appearance={appState.appearance} />,
|
||||
},
|
||||
{
|
||||
value: "arrow",
|
||||
text: t("labels.arrowhead_arrow"),
|
||||
keyBinding: "w",
|
||||
icon: (
|
||||
<ArrowheadArrowIcon
|
||||
appearance={appState.appearance}
|
||||
flip={isRTL}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
value: "bar",
|
||||
text: t("labels.arrowhead_bar"),
|
||||
keyBinding: "e",
|
||||
icon: (
|
||||
<ArrowheadBarIcon
|
||||
appearance={appState.appearance}
|
||||
flip={isRTL}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
value: "dot",
|
||||
text: t("labels.arrowhead_dot"),
|
||||
keyBinding: "r",
|
||||
icon: (
|
||||
<ArrowheadDotIcon
|
||||
appearance={appState.appearance}
|
||||
flip={isRTL}
|
||||
/>
|
||||
),
|
||||
},
|
||||
]}
|
||||
value={getFormValue<Arrowhead | null>(
|
||||
elements,
|
||||
appState,
|
||||
(element) =>
|
||||
isLinearElement(element) && canHaveArrowheads(element.type)
|
||||
? element.endArrowhead
|
||||
: appState.currentItemEndArrowhead,
|
||||
appState.currentItemEndArrowhead,
|
||||
)}
|
||||
onChange={(value) => updateData({ position: "end", type: value })}
|
||||
/>
|
||||
</div>
|
||||
</fieldset>
|
||||
);
|
||||
},
|
||||
});
|
||||
|
@@ -1,22 +1,31 @@
|
||||
import { KEYS } from "../keys";
|
||||
import { register } from "./register";
|
||||
import { selectGroupsForSelectedElements } from "../groups";
|
||||
import { getNonDeletedElements } from "../element";
|
||||
|
||||
export const actionSelectAll = register({
|
||||
name: "selectAll",
|
||||
perform: (elements, appState) => {
|
||||
if (appState.editingLinearElement) {
|
||||
return false;
|
||||
}
|
||||
return {
|
||||
appState: {
|
||||
...appState,
|
||||
selectedElementIds: elements.reduce((map, element) => {
|
||||
if (!element.isDeleted) {
|
||||
map[element.id] = true;
|
||||
}
|
||||
return map;
|
||||
}, {} as any),
|
||||
},
|
||||
appState: selectGroupsForSelectedElements(
|
||||
{
|
||||
...appState,
|
||||
editingGroupId: null,
|
||||
selectedElementIds: elements.reduce((map, element) => {
|
||||
if (!element.isDeleted) {
|
||||
map[element.id] = true;
|
||||
}
|
||||
return map;
|
||||
}, {} as any),
|
||||
},
|
||||
getNonDeletedElements(elements),
|
||||
),
|
||||
commitToHistory: true,
|
||||
};
|
||||
},
|
||||
contextItemLabel: "labels.selectAll",
|
||||
keyTest: (event) => event[KEYS.CTRL_OR_CMD] && event.key === "a",
|
||||
keyTest: (event) => event[KEYS.CTRL_OR_CMD] && event.key === KEYS.A,
|
||||
});
|
||||
|
@@ -3,12 +3,17 @@ import {
|
||||
isExcalidrawElement,
|
||||
redrawTextBoundingBox,
|
||||
} from "../element";
|
||||
import { KEYS } from "../keys";
|
||||
import { DEFAULT_FONT, DEFAULT_TEXT_ALIGN } from "../appState";
|
||||
import { CODES, KEYS } from "../keys";
|
||||
import { register } from "./register";
|
||||
import { mutateElement, newElementWith } from "../element/mutateElement";
|
||||
import {
|
||||
DEFAULT_FONT_SIZE,
|
||||
DEFAULT_FONT_FAMILY,
|
||||
DEFAULT_TEXT_ALIGN,
|
||||
} from "../constants";
|
||||
|
||||
let copiedStyles: string = "{}";
|
||||
// `copiedStyles` is exported only for tests.
|
||||
export let copiedStyles: string = "{}";
|
||||
|
||||
export const actionCopyStyles = register({
|
||||
name: "copyStyles",
|
||||
@@ -23,7 +28,7 @@ export const actionCopyStyles = register({
|
||||
},
|
||||
contextItemLabel: "labels.copyStyles",
|
||||
keyTest: (event) =>
|
||||
event[KEYS.CTRL_OR_CMD] && event.shiftKey && event.key === "C",
|
||||
event[KEYS.CTRL_OR_CMD] && event.altKey && event.code === CODES.C,
|
||||
contextMenuOrder: 0,
|
||||
});
|
||||
|
||||
@@ -41,13 +46,15 @@ export const actionPasteStyles = register({
|
||||
backgroundColor: pastedElement?.backgroundColor,
|
||||
strokeWidth: pastedElement?.strokeWidth,
|
||||
strokeColor: pastedElement?.strokeColor,
|
||||
strokeStyle: pastedElement?.strokeStyle,
|
||||
fillStyle: pastedElement?.fillStyle,
|
||||
opacity: pastedElement?.opacity,
|
||||
roughness: pastedElement?.roughness,
|
||||
});
|
||||
if (isTextElement(newElement)) {
|
||||
mutateElement(newElement, {
|
||||
font: pastedElement?.font || DEFAULT_FONT,
|
||||
fontSize: pastedElement?.fontSize || DEFAULT_FONT_SIZE,
|
||||
fontFamily: pastedElement?.fontFamily || DEFAULT_FONT_FAMILY,
|
||||
textAlign: pastedElement?.textAlign || DEFAULT_TEXT_ALIGN,
|
||||
});
|
||||
redrawTextBoundingBox(newElement);
|
||||
@@ -61,6 +68,6 @@ export const actionPasteStyles = register({
|
||||
},
|
||||
contextItemLabel: "labels.pasteStyles",
|
||||
keyTest: (event) =>
|
||||
event[KEYS.CTRL_OR_CMD] && event.shiftKey && event.key === "V",
|
||||
event[KEYS.CTRL_OR_CMD] && event.altKey && event.code === CODES.V,
|
||||
contextMenuOrder: 1,
|
||||
});
|
||||
|
@@ -5,79 +5,22 @@ import {
|
||||
moveAllLeft,
|
||||
moveAllRight,
|
||||
} from "../zindex";
|
||||
import { KEYS, isDarwin } from "../keys";
|
||||
import { KEYS, isDarwin, CODES } from "../keys";
|
||||
import { t } from "../i18n";
|
||||
import { getShortcutKey } from "../utils";
|
||||
import { register } from "./register";
|
||||
import {
|
||||
sendBackward,
|
||||
bringToFront,
|
||||
sendToBack,
|
||||
bringForward,
|
||||
SendBackwardIcon,
|
||||
BringToFrontIcon,
|
||||
SendToBackIcon,
|
||||
BringForwardIcon,
|
||||
} from "../components/icons";
|
||||
import { ExcalidrawElement } from "../element/types";
|
||||
import { AppState } from "../types";
|
||||
|
||||
function getElementIndices(
|
||||
direction: "left" | "right",
|
||||
elements: readonly ExcalidrawElement[],
|
||||
appState: AppState,
|
||||
) {
|
||||
const selectedIndices: number[] = [];
|
||||
let deletedIndicesCache: number[] = [];
|
||||
|
||||
function cb(element: ExcalidrawElement, index: number) {
|
||||
if (element.isDeleted) {
|
||||
// we want to build an array of deleted elements that are preceeding
|
||||
// a selected element so that we move them together
|
||||
deletedIndicesCache.push(index);
|
||||
} else {
|
||||
if (appState.selectedElementIds[element.id]) {
|
||||
selectedIndices.push(...deletedIndicesCache, index);
|
||||
}
|
||||
// always empty cache of deleted elements after either pushing a group
|
||||
// of selected/deleted elements, of after encountering non-deleted elem
|
||||
deletedIndicesCache = [];
|
||||
}
|
||||
}
|
||||
|
||||
// sending back → select contiguous deleted elements that are to the left of
|
||||
// selected element(s)
|
||||
if (direction === "left") {
|
||||
let i = -1;
|
||||
const len = elements.length;
|
||||
while (++i < len) {
|
||||
cb(elements[i], i);
|
||||
}
|
||||
// moving to front → loop from right to left so that we don't need to
|
||||
// backtrack when gathering deleted elements
|
||||
} else {
|
||||
let i = elements.length;
|
||||
while (--i > -1) {
|
||||
cb(elements[i], i);
|
||||
}
|
||||
}
|
||||
// sort in case we were gathering indexes from right to left
|
||||
return selectedIndices.sort();
|
||||
}
|
||||
|
||||
function moveElements(
|
||||
func: typeof moveOneLeft,
|
||||
elements: readonly ExcalidrawElement[],
|
||||
appState: AppState,
|
||||
) {
|
||||
const _elements = elements.slice();
|
||||
const direction =
|
||||
func === moveOneLeft || func === moveAllLeft ? "left" : "right";
|
||||
const indices = getElementIndices(direction, _elements, appState);
|
||||
return func(_elements, indices);
|
||||
}
|
||||
|
||||
export const actionSendBackward = register({
|
||||
name: "sendBackward",
|
||||
perform: (elements, appState) => {
|
||||
return {
|
||||
elements: moveElements(moveOneLeft, elements, appState),
|
||||
elements: moveOneLeft(elements, appState),
|
||||
appState,
|
||||
commitToHistory: true,
|
||||
};
|
||||
@@ -85,15 +28,17 @@ export const actionSendBackward = register({
|
||||
contextItemLabel: "labels.sendBackward",
|
||||
keyPriority: 40,
|
||||
keyTest: (event) =>
|
||||
event[KEYS.CTRL_OR_CMD] && !event.shiftKey && event.code === "BracketLeft",
|
||||
PanelComponent: ({ updateData }) => (
|
||||
event[KEYS.CTRL_OR_CMD] &&
|
||||
!event.shiftKey &&
|
||||
event.code === CODES.BRACKET_LEFT,
|
||||
PanelComponent: ({ updateData, appState }) => (
|
||||
<button
|
||||
type="button"
|
||||
className="zIndexButton"
|
||||
onClick={() => updateData(null)}
|
||||
title={`${t("labels.sendBackward")} — ${getShortcutKey("CtrlOrCmd+[")}`}
|
||||
>
|
||||
{sendBackward}
|
||||
<SendBackwardIcon appearance={appState.appearance} />
|
||||
</button>
|
||||
),
|
||||
});
|
||||
@@ -102,7 +47,7 @@ export const actionBringForward = register({
|
||||
name: "bringForward",
|
||||
perform: (elements, appState) => {
|
||||
return {
|
||||
elements: moveElements(moveOneRight, elements, appState),
|
||||
elements: moveOneRight(elements, appState),
|
||||
appState,
|
||||
commitToHistory: true,
|
||||
};
|
||||
@@ -110,15 +55,17 @@ export const actionBringForward = register({
|
||||
contextItemLabel: "labels.bringForward",
|
||||
keyPriority: 40,
|
||||
keyTest: (event) =>
|
||||
event[KEYS.CTRL_OR_CMD] && !event.shiftKey && event.code === "BracketRight",
|
||||
PanelComponent: ({ updateData }) => (
|
||||
event[KEYS.CTRL_OR_CMD] &&
|
||||
!event.shiftKey &&
|
||||
event.code === CODES.BRACKET_RIGHT,
|
||||
PanelComponent: ({ updateData, appState }) => (
|
||||
<button
|
||||
type="button"
|
||||
className="zIndexButton"
|
||||
onClick={() => updateData(null)}
|
||||
title={`${t("labels.bringForward")} — ${getShortcutKey("CtrlOrCmd+]")}`}
|
||||
>
|
||||
{bringForward}
|
||||
<BringForwardIcon appearance={appState.appearance} />
|
||||
</button>
|
||||
),
|
||||
});
|
||||
@@ -127,20 +74,21 @@ export const actionSendToBack = register({
|
||||
name: "sendToBack",
|
||||
perform: (elements, appState) => {
|
||||
return {
|
||||
elements: moveElements(moveAllLeft, elements, appState),
|
||||
elements: moveAllLeft(elements, appState),
|
||||
appState,
|
||||
commitToHistory: true,
|
||||
};
|
||||
},
|
||||
contextItemLabel: "labels.sendToBack",
|
||||
keyTest: (event) => {
|
||||
return isDarwin
|
||||
? event[KEYS.CTRL_OR_CMD] && event.altKey && event.code === "BracketLeft"
|
||||
keyTest: (event) =>
|
||||
isDarwin
|
||||
? event[KEYS.CTRL_OR_CMD] &&
|
||||
event.altKey &&
|
||||
event.code === CODES.BRACKET_LEFT
|
||||
: event[KEYS.CTRL_OR_CMD] &&
|
||||
event.shiftKey &&
|
||||
event.code === "BracketLeft";
|
||||
},
|
||||
PanelComponent: ({ updateData }) => (
|
||||
event.shiftKey &&
|
||||
event.code === CODES.BRACKET_LEFT,
|
||||
PanelComponent: ({ updateData, appState }) => (
|
||||
<button
|
||||
type="button"
|
||||
className="zIndexButton"
|
||||
@@ -151,7 +99,7 @@ export const actionSendToBack = register({
|
||||
: getShortcutKey("CtrlOrCmd+Shift+[")
|
||||
}`}
|
||||
>
|
||||
{sendToBack}
|
||||
<SendToBackIcon appearance={appState.appearance} />
|
||||
</button>
|
||||
),
|
||||
});
|
||||
@@ -160,20 +108,21 @@ export const actionBringToFront = register({
|
||||
name: "bringToFront",
|
||||
perform: (elements, appState) => {
|
||||
return {
|
||||
elements: moveElements(moveAllRight, elements, appState),
|
||||
elements: moveAllRight(elements, appState),
|
||||
appState,
|
||||
commitToHistory: true,
|
||||
};
|
||||
},
|
||||
contextItemLabel: "labels.bringToFront",
|
||||
keyTest: (event) => {
|
||||
return isDarwin
|
||||
? event[KEYS.CTRL_OR_CMD] && event.altKey && event.code === "BracketRight"
|
||||
keyTest: (event) =>
|
||||
isDarwin
|
||||
? event[KEYS.CTRL_OR_CMD] &&
|
||||
event.altKey &&
|
||||
event.code === CODES.BRACKET_RIGHT
|
||||
: event[KEYS.CTRL_OR_CMD] &&
|
||||
event.shiftKey &&
|
||||
event.code === "BracketRight";
|
||||
},
|
||||
PanelComponent: ({ updateData }) => (
|
||||
event.shiftKey &&
|
||||
event.code === CODES.BRACKET_RIGHT,
|
||||
PanelComponent: ({ updateData, appState }) => (
|
||||
<button
|
||||
type="button"
|
||||
className="zIndexButton"
|
||||
@@ -184,7 +133,7 @@ export const actionBringToFront = register({
|
||||
: getShortcutKey("CtrlOrCmd+Shift+]")
|
||||
}`}
|
||||
>
|
||||
{bringToFront}
|
||||
<BringToFrontIcon appearance={appState.appearance} />
|
||||
</button>
|
||||
),
|
||||
});
|
||||
|
@@ -34,6 +34,7 @@ export {
|
||||
actionChangeProjectName,
|
||||
actionChangeExportBackground,
|
||||
actionSaveScene,
|
||||
actionSaveAsScene,
|
||||
actionLoadScene,
|
||||
} from "./actionExport";
|
||||
|
||||
@@ -44,3 +45,23 @@ export {
|
||||
actionFullScreen,
|
||||
actionShortcuts,
|
||||
} from "./actionMenu";
|
||||
|
||||
export { actionGroup, actionUngroup } from "./actionGroup";
|
||||
|
||||
export { actionGoToCollaborator } from "./actionNavigate";
|
||||
|
||||
export { actionAddToLibrary } from "./actionAddToLibrary";
|
||||
|
||||
export {
|
||||
actionAlignTop,
|
||||
actionAlignBottom,
|
||||
actionAlignLeft,
|
||||
actionAlignRight,
|
||||
actionAlignVerticallyCentered,
|
||||
actionAlignHorizontallyCentered,
|
||||
} from "./actionAlign";
|
||||
|
||||
export {
|
||||
distributeHorizontally,
|
||||
distributeVertically,
|
||||
} from "./actionDistribute";
|
||||
|
@@ -5,29 +5,36 @@ import {
|
||||
UpdaterFn,
|
||||
ActionFilterFn,
|
||||
ActionName,
|
||||
ActionResult,
|
||||
} from "./types";
|
||||
import { ExcalidrawElement } from "../element/types";
|
||||
import { AppState } from "../types";
|
||||
import { t } from "../i18n";
|
||||
import { globalSceneState } from "../scene";
|
||||
import { ShortcutName } from "./shortcuts";
|
||||
|
||||
export class ActionManager implements ActionsManagerInterface {
|
||||
actions = {} as ActionsManagerInterface["actions"];
|
||||
|
||||
updater: UpdaterFn;
|
||||
updater: (actionResult: ActionResult | Promise<ActionResult>) => void;
|
||||
|
||||
getAppState: () => AppState;
|
||||
getAppState: () => Readonly<AppState>;
|
||||
|
||||
getElementsIncludingDeleted: () => readonly ExcalidrawElement[];
|
||||
|
||||
constructor(
|
||||
updater: UpdaterFn,
|
||||
getAppState: () => AppState,
|
||||
getElementsIncludingDeleted: () => ReturnType<
|
||||
typeof globalSceneState["getElementsIncludingDeleted"]
|
||||
>,
|
||||
getElementsIncludingDeleted: () => readonly ExcalidrawElement[],
|
||||
) {
|
||||
this.updater = updater;
|
||||
this.updater = (actionResult) => {
|
||||
if (actionResult && "then" in actionResult) {
|
||||
actionResult.then((actionResult) => {
|
||||
return updater(actionResult);
|
||||
});
|
||||
} else {
|
||||
return updater(actionResult);
|
||||
}
|
||||
};
|
||||
this.getAppState = getAppState;
|
||||
this.getElementsIncludingDeleted = getElementsIncludingDeleted;
|
||||
}
|
||||
@@ -82,12 +89,22 @@ export class ActionManager implements ActionsManagerInterface {
|
||||
return Object.values(this.actions)
|
||||
.filter(actionFilter)
|
||||
.filter((action) => "contextItemLabel" in action)
|
||||
.filter((action) =>
|
||||
action.contextItemPredicate
|
||||
? action.contextItemPredicate(
|
||||
this.getElementsIncludingDeleted(),
|
||||
this.getAppState(),
|
||||
)
|
||||
: true,
|
||||
)
|
||||
.sort(
|
||||
(a, b) =>
|
||||
(a.contextMenuOrder !== undefined ? a.contextMenuOrder : 999) -
|
||||
(b.contextMenuOrder !== undefined ? b.contextMenuOrder : 999),
|
||||
)
|
||||
.map((action) => ({
|
||||
// take last bit of the label "labels.<shortcutName>"
|
||||
shortcutName: action.contextItemLabel?.split(".").pop() as ShortcutName,
|
||||
label: action.contextItemLabel ? t(action.contextItemLabel) : "",
|
||||
action: () => {
|
||||
this.updater(
|
||||
@@ -101,7 +118,11 @@ export class ActionManager implements ActionsManagerInterface {
|
||||
}));
|
||||
}
|
||||
|
||||
renderAction = (name: ActionName) => {
|
||||
// Id is an attribute that we can use to pass in data like keys.
|
||||
// This is needed for dynamically generated action components
|
||||
// like the user list. We can use this key to extract more
|
||||
// data from app state. This is an alternative to generic prop hell!
|
||||
renderAction = (name: ActionName, id?: string) => {
|
||||
if (this.actions[name] && "PanelComponent" in this.actions[name]) {
|
||||
const action = this.actions[name];
|
||||
const PanelComponent = action.PanelComponent!;
|
||||
@@ -120,6 +141,7 @@ export class ActionManager implements ActionsManagerInterface {
|
||||
elements={this.getElementsIncludingDeleted()}
|
||||
appState={this.getAppState()}
|
||||
updateData={updateData}
|
||||
id={id}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
@@ -2,7 +2,7 @@ import { Action } from "./types";
|
||||
|
||||
export let actions: readonly Action[] = [];
|
||||
|
||||
export function register(action: Action): Action {
|
||||
export const register = (action: Action): Action => {
|
||||
actions = actions.concat(action);
|
||||
return action;
|
||||
}
|
||||
};
|
||||
|
63
src/actions/shortcuts.ts
Normal file
63
src/actions/shortcuts.ts
Normal file
@@ -0,0 +1,63 @@
|
||||
import { t } from "../i18n";
|
||||
import { isDarwin } from "../keys";
|
||||
import { getShortcutKey } from "../utils";
|
||||
|
||||
export type ShortcutName =
|
||||
| "cut"
|
||||
| "copy"
|
||||
| "paste"
|
||||
| "copyStyles"
|
||||
| "pasteStyles"
|
||||
| "selectAll"
|
||||
| "delete"
|
||||
| "duplicateSelection"
|
||||
| "sendBackward"
|
||||
| "bringForward"
|
||||
| "sendToBack"
|
||||
| "bringToFront"
|
||||
| "copyAsPng"
|
||||
| "copyAsSvg"
|
||||
| "group"
|
||||
| "ungroup"
|
||||
| "gridMode"
|
||||
| "stats"
|
||||
| "addToLibrary";
|
||||
|
||||
const shortcutMap: Record<ShortcutName, string[]> = {
|
||||
cut: [getShortcutKey("CtrlOrCmd+X")],
|
||||
copy: [getShortcutKey("CtrlOrCmd+C")],
|
||||
paste: [getShortcutKey("CtrlOrCmd+V")],
|
||||
copyStyles: [getShortcutKey("CtrlOrCmd+Alt+C")],
|
||||
pasteStyles: [getShortcutKey("CtrlOrCmd+Alt+V")],
|
||||
selectAll: [getShortcutKey("CtrlOrCmd+A")],
|
||||
delete: [getShortcutKey("Del")],
|
||||
duplicateSelection: [
|
||||
getShortcutKey("CtrlOrCmd+D"),
|
||||
getShortcutKey(`Alt+${t("shortcutsDialog.drag")}`),
|
||||
],
|
||||
sendBackward: [getShortcutKey("CtrlOrCmd+[")],
|
||||
bringForward: [getShortcutKey("CtrlOrCmd+]")],
|
||||
sendToBack: [
|
||||
isDarwin
|
||||
? getShortcutKey("CtrlOrCmd+Alt+[")
|
||||
: getShortcutKey("CtrlOrCmd+Shift+["),
|
||||
],
|
||||
bringToFront: [
|
||||
isDarwin
|
||||
? getShortcutKey("CtrlOrCmd+Alt+]")
|
||||
: getShortcutKey("CtrlOrCmd+Shift+]"),
|
||||
],
|
||||
copyAsPng: [getShortcutKey("Shift+Alt+C")],
|
||||
copyAsSvg: [],
|
||||
group: [getShortcutKey("CtrlOrCmd+G")],
|
||||
ungroup: [getShortcutKey("CtrlOrCmd+Shift+G")],
|
||||
gridMode: [getShortcutKey("CtrlOrCmd+'")],
|
||||
stats: [],
|
||||
addToLibrary: [],
|
||||
};
|
||||
|
||||
export const getShortcutFromShortcutName = (name: ShortcutName) => {
|
||||
const shortcuts = shortcutMap[name];
|
||||
// if multiple shortcuts availiable, take the first one
|
||||
return shortcuts && shortcuts.length > 0 ? shortcuts[0] : "";
|
||||
};
|
@@ -2,19 +2,23 @@ import React from "react";
|
||||
import { ExcalidrawElement } from "../element/types";
|
||||
import { AppState } from "../types";
|
||||
|
||||
export type ActionResult = {
|
||||
elements?: readonly ExcalidrawElement[] | null;
|
||||
appState?: AppState | null;
|
||||
commitToHistory: boolean;
|
||||
};
|
||||
/** if false, the action should be prevented */
|
||||
export type ActionResult =
|
||||
| {
|
||||
elements?: readonly ExcalidrawElement[] | null;
|
||||
appState?: MarkOptional<AppState, "offsetTop" | "offsetLeft"> | null;
|
||||
commitToHistory: boolean;
|
||||
syncHistory?: boolean;
|
||||
}
|
||||
| false;
|
||||
|
||||
type ActionFn = (
|
||||
elements: readonly ExcalidrawElement[],
|
||||
appState: AppState,
|
||||
appState: Readonly<AppState>,
|
||||
formData: any,
|
||||
) => ActionResult;
|
||||
) => ActionResult | Promise<ActionResult>;
|
||||
|
||||
export type UpdaterFn = (res: ActionResult, commitToHistory?: boolean) => void;
|
||||
export type UpdaterFn = (res: ActionResult) => void;
|
||||
export type ActionFilterFn = (action: Action) => void;
|
||||
|
||||
export type ActionName =
|
||||
@@ -30,6 +34,8 @@ export type ActionName =
|
||||
| "changeFillStyle"
|
||||
| "changeStrokeWidth"
|
||||
| "changeSloppiness"
|
||||
| "changeStrokeStyle"
|
||||
| "changeArrowhead"
|
||||
| "changeOpacity"
|
||||
| "changeFontSize"
|
||||
| "toggleCanvasMenu"
|
||||
@@ -39,8 +45,10 @@ export type ActionName =
|
||||
| "finalize"
|
||||
| "changeProjectName"
|
||||
| "changeExportBackground"
|
||||
| "changeExportEmbedScene"
|
||||
| "changeShouldAddWatermark"
|
||||
| "saveScene"
|
||||
| "saveAsScene"
|
||||
| "loadScene"
|
||||
| "duplicateSelection"
|
||||
| "deleteSelectedElements"
|
||||
@@ -50,10 +58,24 @@ export type ActionName =
|
||||
| "zoomOut"
|
||||
| "resetZoom"
|
||||
| "zoomToFit"
|
||||
| "zoomToSelection"
|
||||
| "changeFontFamily"
|
||||
| "changeTextAlign"
|
||||
| "toggleFullScreen"
|
||||
| "toggleShortcuts";
|
||||
| "toggleShortcuts"
|
||||
| "group"
|
||||
| "ungroup"
|
||||
| "goToCollaborator"
|
||||
| "addToLibrary"
|
||||
| "changeSharpness"
|
||||
| "alignTop"
|
||||
| "alignBottom"
|
||||
| "alignLeft"
|
||||
| "alignRight"
|
||||
| "alignVerticallyCentered"
|
||||
| "alignHorizontallyCentered"
|
||||
| "distributeHorizontally"
|
||||
| "distributeVertically";
|
||||
|
||||
export interface Action {
|
||||
name: ActionName;
|
||||
@@ -61,6 +83,7 @@ export interface Action {
|
||||
elements: readonly ExcalidrawElement[];
|
||||
appState: AppState;
|
||||
updateData: (formData?: any) => void;
|
||||
id?: string;
|
||||
}>;
|
||||
perform: ActionFn;
|
||||
keyPriority?: number;
|
||||
@@ -71,12 +94,14 @@ export interface Action {
|
||||
) => boolean;
|
||||
contextItemLabel?: string;
|
||||
contextMenuOrder?: number;
|
||||
contextItemPredicate?: (
|
||||
elements: readonly ExcalidrawElement[],
|
||||
appState: AppState,
|
||||
) => boolean;
|
||||
}
|
||||
|
||||
export interface ActionsManagerInterface {
|
||||
actions: {
|
||||
[actionName in ActionName]: Action;
|
||||
};
|
||||
actions: Record<ActionName, Action>;
|
||||
registerAction: (action: Action) => void;
|
||||
handleKeyDown: (event: KeyboardEvent) => boolean;
|
||||
getContextMenuItems: (
|
||||
|
95
src/align.ts
Normal file
95
src/align.ts
Normal file
@@ -0,0 +1,95 @@
|
||||
import { ExcalidrawElement } from "./element/types";
|
||||
import { newElementWith } from "./element/mutateElement";
|
||||
import { getCommonBounds } from "./element";
|
||||
|
||||
interface Box {
|
||||
minX: number;
|
||||
minY: number;
|
||||
maxX: number;
|
||||
maxY: number;
|
||||
}
|
||||
|
||||
export interface Alignment {
|
||||
position: "start" | "center" | "end";
|
||||
axis: "x" | "y";
|
||||
}
|
||||
|
||||
export const alignElements = (
|
||||
selectedElements: ExcalidrawElement[],
|
||||
alignment: Alignment,
|
||||
): ExcalidrawElement[] => {
|
||||
const groups: ExcalidrawElement[][] = getMaximumGroups(selectedElements);
|
||||
|
||||
const selectionBoundingBox = getCommonBoundingBox(selectedElements);
|
||||
|
||||
return groups.flatMap((group) => {
|
||||
const translation = calculateTranslation(
|
||||
group,
|
||||
selectionBoundingBox,
|
||||
alignment,
|
||||
);
|
||||
return group.map((element) =>
|
||||
newElementWith(element, {
|
||||
x: element.x + translation.x,
|
||||
y: element.y + translation.y,
|
||||
}),
|
||||
);
|
||||
});
|
||||
};
|
||||
|
||||
export const getMaximumGroups = (
|
||||
elements: ExcalidrawElement[],
|
||||
): ExcalidrawElement[][] => {
|
||||
const groups: Map<String, ExcalidrawElement[]> = new Map<
|
||||
String,
|
||||
ExcalidrawElement[]
|
||||
>();
|
||||
|
||||
elements.forEach((element: ExcalidrawElement) => {
|
||||
const groupId =
|
||||
element.groupIds.length === 0
|
||||
? element.id
|
||||
: element.groupIds[element.groupIds.length - 1];
|
||||
|
||||
const currentGroupMembers = groups.get(groupId) || [];
|
||||
|
||||
groups.set(groupId, [...currentGroupMembers, element]);
|
||||
});
|
||||
|
||||
return Array.from(groups.values());
|
||||
};
|
||||
|
||||
const calculateTranslation = (
|
||||
group: ExcalidrawElement[],
|
||||
selectionBoundingBox: Box,
|
||||
{ axis, position }: Alignment,
|
||||
): { x: number; y: number } => {
|
||||
const groupBoundingBox = getCommonBoundingBox(group);
|
||||
|
||||
const [min, max]: ["minX" | "minY", "maxX" | "maxY"] =
|
||||
axis === "x" ? ["minX", "maxX"] : ["minY", "maxY"];
|
||||
|
||||
const noTranslation = { x: 0, y: 0 };
|
||||
if (position === "start") {
|
||||
return {
|
||||
...noTranslation,
|
||||
[axis]: selectionBoundingBox[min] - groupBoundingBox[min],
|
||||
};
|
||||
} else if (position === "end") {
|
||||
return {
|
||||
...noTranslation,
|
||||
[axis]: selectionBoundingBox[max] - groupBoundingBox[max],
|
||||
};
|
||||
} // else if (position === "center") {
|
||||
return {
|
||||
...noTranslation,
|
||||
[axis]:
|
||||
(selectionBoundingBox[min] + selectionBoundingBox[max]) / 2 -
|
||||
(groupBoundingBox[min] + groupBoundingBox[max]) / 2,
|
||||
};
|
||||
};
|
||||
|
||||
const getCommonBoundingBox = (elements: ExcalidrawElement[]): Box => {
|
||||
const [minX, minY, maxX, maxY] = getCommonBounds(elements);
|
||||
return { minX, minY, maxX, maxY };
|
||||
};
|
27
src/analytics.ts
Normal file
27
src/analytics.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
export const EVENT_ACTION = "action";
|
||||
export const EVENT_ALIGN = "align";
|
||||
export const EVENT_CHANGE = "change";
|
||||
export const EVENT_DIALOG = "dialog";
|
||||
export const EVENT_EXIT = "exit";
|
||||
export const EVENT_IO = "io";
|
||||
export const EVENT_LAYER = "layer";
|
||||
export const EVENT_LIBRARY = "library";
|
||||
export const EVENT_LOAD = "load";
|
||||
export const EVENT_SHAPE = "shape";
|
||||
export const EVENT_SHARE = "share";
|
||||
export const EVENT_MAGIC = "magic";
|
||||
|
||||
export const trackEvent =
|
||||
typeof window !== "undefined" && window.gtag
|
||||
? (category: string, name: string, label?: string, value?: number) => {
|
||||
window.gtag("event", name, {
|
||||
event_category: category,
|
||||
event_label: label,
|
||||
value,
|
||||
});
|
||||
}
|
||||
: typeof process !== "undefined" && process?.env?.JEST_WORKER_ID
|
||||
? (category: string, name: string, label?: string, value?: number) => {}
|
||||
: (category: string, name: string, label?: string, value?: number) => {
|
||||
console.info("Track Event", category, name, label, value);
|
||||
};
|
244
src/appState.ts
244
src/appState.ts
@@ -1,96 +1,188 @@
|
||||
import oc from "open-color";
|
||||
import { AppState, FlooredNumber } from "./types";
|
||||
import { getDateTime } from "./utils";
|
||||
import { t } from "./i18n";
|
||||
import {
|
||||
DEFAULT_FONT_FAMILY,
|
||||
DEFAULT_FONT_SIZE,
|
||||
SCENE_NAME_FALLBACK,
|
||||
DEFAULT_TEXT_ALIGN,
|
||||
} from "./constants";
|
||||
import { AppState, FlooredNumber, NormalizedZoomValue } from "./types";
|
||||
|
||||
export const DEFAULT_FONT = "20px Virgil";
|
||||
export const DEFAULT_TEXT_ALIGN = "left";
|
||||
type DefaultAppState = Omit<AppState, "offsetTop" | "offsetLeft" | "name"> & {
|
||||
/**
|
||||
* You should override this with current appState.name, or whatever is
|
||||
* applicable at a given place where you get default appState.
|
||||
*/
|
||||
name: undefined;
|
||||
};
|
||||
|
||||
export function getDefaultAppState(): AppState {
|
||||
export const getDefaultAppState = (): DefaultAppState => {
|
||||
return {
|
||||
isLoading: false,
|
||||
errorMessage: null,
|
||||
draggingElement: null,
|
||||
resizingElement: null,
|
||||
multiElement: null,
|
||||
editingElement: null,
|
||||
elementType: "selection",
|
||||
elementLocked: false,
|
||||
exportBackground: true,
|
||||
shouldAddWatermark: false,
|
||||
currentItemStrokeColor: oc.black,
|
||||
appearance: "light",
|
||||
collaborators: new Map(),
|
||||
currentChartType: "bar",
|
||||
currentItemBackgroundColor: "transparent",
|
||||
currentItemEndArrowhead: "arrow",
|
||||
currentItemFillStyle: "hachure",
|
||||
currentItemStrokeWidth: 1,
|
||||
currentItemRoughness: 1,
|
||||
currentItemFontFamily: DEFAULT_FONT_FAMILY,
|
||||
currentItemFontSize: DEFAULT_FONT_SIZE,
|
||||
currentItemLinearStrokeSharpness: "round",
|
||||
currentItemOpacity: 100,
|
||||
currentItemFont: DEFAULT_FONT,
|
||||
currentItemRoughness: 1,
|
||||
currentItemStartArrowhead: null,
|
||||
currentItemStrokeColor: oc.black,
|
||||
currentItemStrokeSharpness: "sharp",
|
||||
currentItemStrokeStyle: "solid",
|
||||
currentItemStrokeWidth: 1,
|
||||
currentItemTextAlign: DEFAULT_TEXT_ALIGN,
|
||||
viewBackgroundColor: oc.white,
|
||||
scrollX: 0 as FlooredNumber,
|
||||
scrollY: 0 as FlooredNumber,
|
||||
cursorX: 0,
|
||||
cursorY: 0,
|
||||
cursorButton: "up",
|
||||
scrolledOutside: false,
|
||||
name: `${t("labels.untitled")}-${getDateTime()}`,
|
||||
username: "",
|
||||
isCollaborating: false,
|
||||
draggingElement: null,
|
||||
editingElement: null,
|
||||
editingGroupId: null,
|
||||
editingLinearElement: null,
|
||||
elementLocked: false,
|
||||
elementType: "selection",
|
||||
errorMessage: null,
|
||||
exportBackground: true,
|
||||
exportEmbedScene: false,
|
||||
fileHandle: null,
|
||||
gridSize: null,
|
||||
height: window.innerHeight,
|
||||
isBindingEnabled: true,
|
||||
isLibraryOpen: false,
|
||||
isLoading: false,
|
||||
isResizing: false,
|
||||
isRotating: false,
|
||||
selectionElement: null,
|
||||
zoom: 1,
|
||||
openMenu: null,
|
||||
lastPointerDownWith: "mouse",
|
||||
multiElement: null,
|
||||
// for safety (because TS mostly doesn't distinguish optional types and
|
||||
// undefined values), we set `name` to the fallback name, but we cast it to
|
||||
// `undefined` so that TS forces us to explicitly specify it wherever
|
||||
// possible
|
||||
name: (SCENE_NAME_FALLBACK as unknown) as undefined,
|
||||
openMenu: null,
|
||||
pasteDialog: { shown: false, data: null },
|
||||
previousSelectedElementIds: {},
|
||||
resizingElement: null,
|
||||
scrolledOutside: false,
|
||||
scrollX: 0 as FlooredNumber,
|
||||
scrollY: 0 as FlooredNumber,
|
||||
selectedElementIds: {},
|
||||
collaborators: new Map(),
|
||||
selectedGroupIds: {},
|
||||
selectionElement: null,
|
||||
shouldAddWatermark: false,
|
||||
shouldCacheIgnoreZoom: false,
|
||||
showShortcutsDialog: false,
|
||||
showStats: false,
|
||||
startBoundElement: null,
|
||||
suggestedBindings: [],
|
||||
viewBackgroundColor: oc.white,
|
||||
width: window.innerWidth,
|
||||
zenModeEnabled: false,
|
||||
zoom: { value: 1 as NormalizedZoomValue, translation: { x: 0, y: 0 } },
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
export function clearAppStateForLocalStorage(appState: AppState) {
|
||||
const {
|
||||
draggingElement,
|
||||
resizingElement,
|
||||
multiElement,
|
||||
editingElement,
|
||||
selectionElement,
|
||||
isResizing,
|
||||
isRotating,
|
||||
collaborators,
|
||||
isCollaborating,
|
||||
isLoading,
|
||||
errorMessage,
|
||||
showShortcutsDialog,
|
||||
...exportedState
|
||||
} = appState;
|
||||
return exportedState;
|
||||
}
|
||||
/**
|
||||
* Config containing all AppState keys. Used to determine whether given state
|
||||
* prop should be stripped when exporting to given storage type.
|
||||
*/
|
||||
const APP_STATE_STORAGE_CONF = (<
|
||||
Values extends {
|
||||
/** whether to keep when storing to browser storage (localStorage/IDB) */
|
||||
browser: boolean;
|
||||
/** whether to keep when exporting to file/database */
|
||||
export: boolean;
|
||||
},
|
||||
T extends Record<keyof AppState, Values>
|
||||
>(
|
||||
config: { [K in keyof T]: K extends keyof AppState ? T[K] : never },
|
||||
) => config)({
|
||||
appearance: { browser: true, export: false },
|
||||
collaborators: { browser: false, export: false },
|
||||
currentChartType: { browser: true, export: false },
|
||||
currentItemBackgroundColor: { browser: true, export: false },
|
||||
currentItemEndArrowhead: { browser: true, export: false },
|
||||
currentItemFillStyle: { browser: true, export: false },
|
||||
currentItemFontFamily: { browser: true, export: false },
|
||||
currentItemFontSize: { browser: true, export: false },
|
||||
currentItemLinearStrokeSharpness: { browser: true, export: false },
|
||||
currentItemOpacity: { browser: true, export: false },
|
||||
currentItemRoughness: { browser: true, export: false },
|
||||
currentItemStartArrowhead: { browser: true, export: false },
|
||||
currentItemStrokeColor: { browser: true, export: false },
|
||||
currentItemStrokeSharpness: { browser: true, export: false },
|
||||
currentItemStrokeStyle: { browser: true, export: false },
|
||||
currentItemStrokeWidth: { browser: true, export: false },
|
||||
currentItemTextAlign: { browser: true, export: false },
|
||||
cursorButton: { browser: true, export: false },
|
||||
draggingElement: { browser: false, export: false },
|
||||
editingElement: { browser: false, export: false },
|
||||
editingGroupId: { browser: true, export: false },
|
||||
editingLinearElement: { browser: false, export: false },
|
||||
elementLocked: { browser: true, export: false },
|
||||
elementType: { browser: true, export: false },
|
||||
errorMessage: { browser: false, export: false },
|
||||
exportBackground: { browser: true, export: false },
|
||||
exportEmbedScene: { browser: true, export: false },
|
||||
fileHandle: { browser: false, export: false },
|
||||
gridSize: { browser: true, export: true },
|
||||
height: { browser: false, export: false },
|
||||
isBindingEnabled: { browser: false, export: false },
|
||||
isLibraryOpen: { browser: false, export: false },
|
||||
isLoading: { browser: false, export: false },
|
||||
isResizing: { browser: false, export: false },
|
||||
isRotating: { browser: false, export: false },
|
||||
lastPointerDownWith: { browser: true, export: false },
|
||||
multiElement: { browser: false, export: false },
|
||||
name: { browser: true, export: false },
|
||||
offsetLeft: { browser: false, export: false },
|
||||
offsetTop: { browser: false, export: false },
|
||||
openMenu: { browser: true, export: false },
|
||||
pasteDialog: { browser: false, export: false },
|
||||
previousSelectedElementIds: { browser: true, export: false },
|
||||
resizingElement: { browser: false, export: false },
|
||||
scrolledOutside: { browser: true, export: false },
|
||||
scrollX: { browser: true, export: false },
|
||||
scrollY: { browser: true, export: false },
|
||||
selectedElementIds: { browser: true, export: false },
|
||||
selectedGroupIds: { browser: true, export: false },
|
||||
selectionElement: { browser: false, export: false },
|
||||
shouldAddWatermark: { browser: true, export: false },
|
||||
shouldCacheIgnoreZoom: { browser: true, export: false },
|
||||
showShortcutsDialog: { browser: false, export: false },
|
||||
showStats: { browser: true, export: false },
|
||||
startBoundElement: { browser: false, export: false },
|
||||
suggestedBindings: { browser: false, export: false },
|
||||
viewBackgroundColor: { browser: true, export: true },
|
||||
width: { browser: false, export: false },
|
||||
zenModeEnabled: { browser: true, export: false },
|
||||
zoom: { browser: true, export: false },
|
||||
});
|
||||
|
||||
export function clearAppStatePropertiesForHistory(
|
||||
appState: AppState,
|
||||
): Partial<AppState> {
|
||||
return {
|
||||
selectedElementIds: appState.selectedElementIds,
|
||||
exportBackground: appState.exportBackground,
|
||||
shouldAddWatermark: appState.shouldAddWatermark,
|
||||
currentItemStrokeColor: appState.currentItemStrokeColor,
|
||||
currentItemBackgroundColor: appState.currentItemBackgroundColor,
|
||||
currentItemFillStyle: appState.currentItemFillStyle,
|
||||
currentItemStrokeWidth: appState.currentItemStrokeWidth,
|
||||
currentItemRoughness: appState.currentItemRoughness,
|
||||
currentItemOpacity: appState.currentItemOpacity,
|
||||
currentItemFont: appState.currentItemFont,
|
||||
currentItemTextAlign: appState.currentItemTextAlign,
|
||||
viewBackgroundColor: appState.viewBackgroundColor,
|
||||
name: appState.name,
|
||||
};
|
||||
}
|
||||
const _clearAppStateForStorage = <ExportType extends "export" | "browser">(
|
||||
appState: Partial<AppState>,
|
||||
exportType: ExportType,
|
||||
) => {
|
||||
type ExportableKeys = {
|
||||
[K in keyof typeof APP_STATE_STORAGE_CONF]: typeof APP_STATE_STORAGE_CONF[K][ExportType] extends true
|
||||
? K
|
||||
: never;
|
||||
}[keyof typeof APP_STATE_STORAGE_CONF];
|
||||
const stateForExport = {} as { [K in ExportableKeys]?: typeof appState[K] };
|
||||
for (const key of Object.keys(appState) as (keyof typeof appState)[]) {
|
||||
const propConfig = APP_STATE_STORAGE_CONF[key];
|
||||
if (propConfig?.[exportType]) {
|
||||
// @ts-ignore see https://github.com/microsoft/TypeScript/issues/31445
|
||||
stateForExport[key] = appState[key];
|
||||
}
|
||||
}
|
||||
return stateForExport;
|
||||
};
|
||||
|
||||
export function cleanAppStateForExport(appState: AppState) {
|
||||
return {
|
||||
viewBackgroundColor: appState.viewBackgroundColor,
|
||||
};
|
||||
}
|
||||
export const clearAppStateForLocalStorage = (appState: Partial<AppState>) => {
|
||||
return _clearAppStateForStorage(appState, "browser");
|
||||
};
|
||||
|
||||
export const cleanAppStateForExport = (appState: Partial<AppState>) => {
|
||||
return _clearAppStateForStorage(appState, "export");
|
||||
};
|
||||
|
481
src/charts.ts
Normal file
481
src/charts.ts
Normal file
@@ -0,0 +1,481 @@
|
||||
import { EVENT_MAGIC, trackEvent } from "./analytics";
|
||||
import colors from "./colors";
|
||||
import { DEFAULT_FONT_FAMILY, DEFAULT_FONT_SIZE, ENV } from "./constants";
|
||||
import { newElement, newLinearElement, newTextElement } from "./element";
|
||||
import { NonDeletedExcalidrawElement } from "./element/types";
|
||||
import { randomId } from "./random";
|
||||
|
||||
export type ChartElements = readonly NonDeletedExcalidrawElement[];
|
||||
|
||||
const BAR_WIDTH = 32;
|
||||
const BAR_GAP = 12;
|
||||
const BAR_HEIGHT = 256;
|
||||
const GRID_OPACITY = 50;
|
||||
|
||||
export interface Spreadsheet {
|
||||
title: string | null;
|
||||
labels: string[] | null;
|
||||
values: number[];
|
||||
}
|
||||
|
||||
export const NOT_SPREADSHEET = "NOT_SPREADSHEET";
|
||||
export const VALID_SPREADSHEET = "VALID_SPREADSHEET";
|
||||
|
||||
type ParseSpreadsheetResult =
|
||||
| { type: typeof NOT_SPREADSHEET; reason: string }
|
||||
| { type: typeof VALID_SPREADSHEET; spreadsheet: Spreadsheet };
|
||||
|
||||
const tryParseNumber = (s: string): number | null => {
|
||||
const match = /^[$€£¥₩]?([0-9,]+(\.[0-9]+)?)$/.exec(s);
|
||||
if (!match) {
|
||||
return null;
|
||||
}
|
||||
return parseFloat(match[1].replace(/,/g, ""));
|
||||
};
|
||||
|
||||
const isNumericColumn = (lines: string[][], columnIndex: number) =>
|
||||
lines.slice(1).every((line) => tryParseNumber(line[columnIndex]) !== null);
|
||||
|
||||
const tryParseCells = (cells: string[][]): ParseSpreadsheetResult => {
|
||||
const numCols = cells[0].length;
|
||||
|
||||
if (numCols > 2) {
|
||||
return { type: NOT_SPREADSHEET, reason: "More than 2 columns" };
|
||||
}
|
||||
|
||||
if (numCols === 1) {
|
||||
if (!isNumericColumn(cells, 0)) {
|
||||
return { type: NOT_SPREADSHEET, reason: "Value is not numeric" };
|
||||
}
|
||||
|
||||
const hasHeader = tryParseNumber(cells[0][0]) === null;
|
||||
const values = (hasHeader ? cells.slice(1) : cells).map((line) =>
|
||||
tryParseNumber(line[0]),
|
||||
);
|
||||
|
||||
if (values.length < 2) {
|
||||
return { type: NOT_SPREADSHEET, reason: "Less than two rows" };
|
||||
}
|
||||
|
||||
return {
|
||||
type: VALID_SPREADSHEET,
|
||||
spreadsheet: {
|
||||
title: hasHeader ? cells[0][0] : null,
|
||||
labels: null,
|
||||
values: values as number[],
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const valueColumnIndex = isNumericColumn(cells, 0) ? 0 : 1;
|
||||
|
||||
if (!isNumericColumn(cells, valueColumnIndex)) {
|
||||
return { type: NOT_SPREADSHEET, reason: "Value is not numeric" };
|
||||
}
|
||||
|
||||
const labelColumnIndex = (valueColumnIndex + 1) % 2;
|
||||
const hasHeader = tryParseNumber(cells[0][valueColumnIndex]) === null;
|
||||
const rows = hasHeader ? cells.slice(1) : cells;
|
||||
|
||||
if (rows.length < 2) {
|
||||
return { type: NOT_SPREADSHEET, reason: "Less than 2 rows" };
|
||||
}
|
||||
|
||||
return {
|
||||
type: VALID_SPREADSHEET,
|
||||
spreadsheet: {
|
||||
title: hasHeader ? cells[0][valueColumnIndex] : null,
|
||||
labels: rows.map((row) => row[labelColumnIndex]),
|
||||
values: rows.map((row) => tryParseNumber(row[valueColumnIndex])!),
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
const transposeCells = (cells: string[][]) => {
|
||||
const nextCells: string[][] = [];
|
||||
for (let col = 0; col < cells[0].length; col++) {
|
||||
const nextCellRow: string[] = [];
|
||||
for (let row = 0; row < cells.length; row++) {
|
||||
nextCellRow.push(cells[row][col]);
|
||||
}
|
||||
nextCells.push(nextCellRow);
|
||||
}
|
||||
return nextCells;
|
||||
};
|
||||
|
||||
export const tryParseSpreadsheet = (text: string): ParseSpreadsheetResult => {
|
||||
// Copy/paste from excel, spreadhseets, tsv, csv.
|
||||
// For now we only accept 2 columns with an optional header
|
||||
|
||||
// Check for tab separated values
|
||||
let lines = text
|
||||
.trim()
|
||||
.split("\n")
|
||||
.map((line) => line.trim().split("\t"));
|
||||
|
||||
// Check for comma separated files
|
||||
if (lines.length && lines[0].length !== 2) {
|
||||
lines = text
|
||||
.trim()
|
||||
.split("\n")
|
||||
.map((line) => line.trim().split(","));
|
||||
}
|
||||
|
||||
if (lines.length === 0) {
|
||||
return { type: NOT_SPREADSHEET, reason: "No values" };
|
||||
}
|
||||
|
||||
const numColsFirstLine = lines[0].length;
|
||||
const isSpreadsheet = lines.every((line) => line.length === numColsFirstLine);
|
||||
|
||||
if (!isSpreadsheet) {
|
||||
return {
|
||||
type: NOT_SPREADSHEET,
|
||||
reason: "All rows don't have same number of columns",
|
||||
};
|
||||
}
|
||||
|
||||
const result = tryParseCells(lines);
|
||||
if (result.type !== VALID_SPREADSHEET) {
|
||||
const transposedResults = tryParseCells(transposeCells(lines));
|
||||
if (transposedResults.type === VALID_SPREADSHEET) {
|
||||
return transposedResults;
|
||||
}
|
||||
}
|
||||
return result;
|
||||
};
|
||||
|
||||
const bgColors = colors.elementBackground.slice(
|
||||
2,
|
||||
colors.elementBackground.length,
|
||||
);
|
||||
|
||||
// Put all the common properties here so when the whole chart is selected
|
||||
// the properties dialog shows the correct selected values
|
||||
const commonProps = {
|
||||
fillStyle: "hachure",
|
||||
fontFamily: DEFAULT_FONT_FAMILY,
|
||||
fontSize: DEFAULT_FONT_SIZE,
|
||||
opacity: 100,
|
||||
roughness: 1,
|
||||
strokeColor: colors.elementStroke[0],
|
||||
strokeSharpness: "sharp",
|
||||
strokeStyle: "solid",
|
||||
strokeWidth: 1,
|
||||
verticalAlign: "middle",
|
||||
} as const;
|
||||
|
||||
const getChartDimentions = (spreadsheet: Spreadsheet) => {
|
||||
const chartWidth =
|
||||
(BAR_WIDTH + BAR_GAP) * spreadsheet.values.length + BAR_GAP;
|
||||
const chartHeight = BAR_HEIGHT + BAR_GAP * 2;
|
||||
return { chartWidth, chartHeight };
|
||||
};
|
||||
|
||||
const chartXLabels = (
|
||||
spreadsheet: Spreadsheet,
|
||||
x: number,
|
||||
y: number,
|
||||
groupId: string,
|
||||
backgroundColor: string,
|
||||
): ChartElements => {
|
||||
return (
|
||||
spreadsheet.labels?.map((label, index) => {
|
||||
return newTextElement({
|
||||
groupIds: [groupId],
|
||||
backgroundColor,
|
||||
...commonProps,
|
||||
text: label.length > 8 ? `${label.slice(0, 5)}...` : label,
|
||||
x: x + index * (BAR_WIDTH + BAR_GAP) + BAR_GAP * 2,
|
||||
y: y + BAR_GAP / 2,
|
||||
width: BAR_WIDTH,
|
||||
angle: 5.87,
|
||||
fontSize: 16,
|
||||
textAlign: "center",
|
||||
verticalAlign: "top",
|
||||
});
|
||||
}) || []
|
||||
);
|
||||
};
|
||||
|
||||
const chartYLabels = (
|
||||
spreadsheet: Spreadsheet,
|
||||
x: number,
|
||||
y: number,
|
||||
groupId: string,
|
||||
backgroundColor: string,
|
||||
): ChartElements => {
|
||||
const minYLabel = newTextElement({
|
||||
groupIds: [groupId],
|
||||
backgroundColor,
|
||||
...commonProps,
|
||||
x: x - BAR_GAP,
|
||||
y: y - BAR_GAP,
|
||||
text: "0",
|
||||
textAlign: "right",
|
||||
});
|
||||
|
||||
const maxYLabel = newTextElement({
|
||||
groupIds: [groupId],
|
||||
backgroundColor,
|
||||
...commonProps,
|
||||
x: x - BAR_GAP,
|
||||
y: y - BAR_HEIGHT - minYLabel.height / 2,
|
||||
text: Math.max(...spreadsheet.values).toLocaleString(),
|
||||
textAlign: "right",
|
||||
});
|
||||
|
||||
return [minYLabel, maxYLabel];
|
||||
};
|
||||
|
||||
const chartLines = (
|
||||
spreadsheet: Spreadsheet,
|
||||
x: number,
|
||||
y: number,
|
||||
groupId: string,
|
||||
backgroundColor: string,
|
||||
): ChartElements => {
|
||||
const { chartWidth, chartHeight } = getChartDimentions(spreadsheet);
|
||||
const xLine = newLinearElement({
|
||||
backgroundColor,
|
||||
groupIds: [groupId],
|
||||
...commonProps,
|
||||
type: "line",
|
||||
x,
|
||||
y,
|
||||
startArrowhead: null,
|
||||
endArrowhead: null,
|
||||
width: chartWidth,
|
||||
points: [
|
||||
[0, 0],
|
||||
[chartWidth, 0],
|
||||
],
|
||||
});
|
||||
|
||||
const yLine = newLinearElement({
|
||||
backgroundColor,
|
||||
groupIds: [groupId],
|
||||
...commonProps,
|
||||
type: "line",
|
||||
x,
|
||||
y,
|
||||
startArrowhead: null,
|
||||
endArrowhead: null,
|
||||
height: chartHeight,
|
||||
points: [
|
||||
[0, 0],
|
||||
[0, -chartHeight],
|
||||
],
|
||||
});
|
||||
|
||||
const maxLine = newLinearElement({
|
||||
backgroundColor,
|
||||
groupIds: [groupId],
|
||||
...commonProps,
|
||||
type: "line",
|
||||
x,
|
||||
y: y - BAR_HEIGHT - BAR_GAP,
|
||||
startArrowhead: null,
|
||||
endArrowhead: null,
|
||||
strokeStyle: "dotted",
|
||||
width: chartWidth,
|
||||
opacity: GRID_OPACITY,
|
||||
points: [
|
||||
[0, 0],
|
||||
[chartWidth, 0],
|
||||
],
|
||||
});
|
||||
|
||||
return [xLine, yLine, maxLine];
|
||||
};
|
||||
|
||||
// For the maths behind it https://excalidraw.com/#json=6320864370884608,O_5xfD-Agh32tytHpRJx1g
|
||||
const chartBaseElements = (
|
||||
spreadsheet: Spreadsheet,
|
||||
x: number,
|
||||
y: number,
|
||||
groupId: string,
|
||||
backgroundColor: string,
|
||||
debug?: boolean,
|
||||
): ChartElements => {
|
||||
const { chartWidth, chartHeight } = getChartDimentions(spreadsheet);
|
||||
|
||||
const title = spreadsheet.title
|
||||
? newTextElement({
|
||||
backgroundColor,
|
||||
groupIds: [groupId],
|
||||
...commonProps,
|
||||
text: spreadsheet.title,
|
||||
x: x + chartWidth / 2,
|
||||
y: y - BAR_HEIGHT - BAR_GAP * 2 - DEFAULT_FONT_SIZE,
|
||||
strokeSharpness: "sharp",
|
||||
strokeStyle: "solid",
|
||||
textAlign: "center",
|
||||
})
|
||||
: null;
|
||||
|
||||
const debugRect = debug
|
||||
? newElement({
|
||||
backgroundColor,
|
||||
groupIds: [groupId],
|
||||
...commonProps,
|
||||
type: "rectangle",
|
||||
x,
|
||||
y: y - chartHeight,
|
||||
width: chartWidth,
|
||||
height: chartHeight,
|
||||
strokeColor: colors.elementStroke[0],
|
||||
fillStyle: "solid",
|
||||
opacity: 6,
|
||||
})
|
||||
: null;
|
||||
|
||||
return [
|
||||
...(debugRect ? [debugRect] : []),
|
||||
...(title ? [title] : []),
|
||||
...chartXLabels(spreadsheet, x, y, groupId, backgroundColor),
|
||||
...chartYLabels(spreadsheet, x, y, groupId, backgroundColor),
|
||||
...chartLines(spreadsheet, x, y, groupId, backgroundColor),
|
||||
];
|
||||
};
|
||||
|
||||
const chartTypeBar = (
|
||||
spreadsheet: Spreadsheet,
|
||||
x: number,
|
||||
y: number,
|
||||
): ChartElements => {
|
||||
const max = Math.max(...spreadsheet.values);
|
||||
const groupId = randomId();
|
||||
const backgroundColor = bgColors[Math.floor(Math.random() * bgColors.length)];
|
||||
|
||||
const bars = spreadsheet.values.map((value, index) => {
|
||||
const barHeight = (value / max) * BAR_HEIGHT;
|
||||
return newElement({
|
||||
backgroundColor,
|
||||
groupIds: [groupId],
|
||||
...commonProps,
|
||||
type: "rectangle",
|
||||
x: x + index * (BAR_WIDTH + BAR_GAP) + BAR_GAP,
|
||||
y: y - barHeight - BAR_GAP,
|
||||
width: BAR_WIDTH,
|
||||
height: barHeight,
|
||||
});
|
||||
});
|
||||
|
||||
return [
|
||||
...bars,
|
||||
...chartBaseElements(
|
||||
spreadsheet,
|
||||
x,
|
||||
y,
|
||||
groupId,
|
||||
backgroundColor,
|
||||
process.env.NODE_ENV === ENV.DEVELOPMENT,
|
||||
),
|
||||
];
|
||||
};
|
||||
|
||||
const chartTypeLine = (
|
||||
spreadsheet: Spreadsheet,
|
||||
x: number,
|
||||
y: number,
|
||||
): ChartElements => {
|
||||
const max = Math.max(...spreadsheet.values);
|
||||
const groupId = randomId();
|
||||
const backgroundColor = bgColors[Math.floor(Math.random() * bgColors.length)];
|
||||
|
||||
let index = 0;
|
||||
const points = [];
|
||||
for (const value of spreadsheet.values) {
|
||||
const cx = index * (BAR_WIDTH + BAR_GAP);
|
||||
const cy = -(value / max) * BAR_HEIGHT;
|
||||
points.push([cx, cy]);
|
||||
index++;
|
||||
}
|
||||
|
||||
const maxX = Math.max(...points.map((element) => element[0]));
|
||||
const maxY = Math.max(...points.map((element) => element[1]));
|
||||
const minX = Math.min(...points.map((element) => element[0]));
|
||||
const minY = Math.min(...points.map((element) => element[1]));
|
||||
|
||||
const line = newLinearElement({
|
||||
backgroundColor,
|
||||
groupIds: [groupId],
|
||||
...commonProps,
|
||||
type: "line",
|
||||
x: x + BAR_GAP + BAR_WIDTH / 2,
|
||||
y: y - BAR_GAP,
|
||||
startArrowhead: null,
|
||||
endArrowhead: null,
|
||||
height: maxY - minY,
|
||||
width: maxX - minX,
|
||||
strokeWidth: 2,
|
||||
points: points as any,
|
||||
});
|
||||
|
||||
const dots = spreadsheet.values.map((value, index) => {
|
||||
const cx = index * (BAR_WIDTH + BAR_GAP) + BAR_GAP / 2;
|
||||
const cy = -(value / max) * BAR_HEIGHT + BAR_GAP / 2;
|
||||
return newElement({
|
||||
backgroundColor,
|
||||
groupIds: [groupId],
|
||||
...commonProps,
|
||||
fillStyle: "solid",
|
||||
strokeWidth: 2,
|
||||
type: "ellipse",
|
||||
x: x + cx + BAR_WIDTH / 2,
|
||||
y: y + cy - BAR_GAP * 2,
|
||||
width: BAR_GAP,
|
||||
height: BAR_GAP,
|
||||
});
|
||||
});
|
||||
|
||||
const lines = spreadsheet.values.map((value, index) => {
|
||||
const cx = index * (BAR_WIDTH + BAR_GAP) + BAR_GAP / 2;
|
||||
const cy = (value / max) * BAR_HEIGHT + BAR_GAP / 2 + BAR_GAP;
|
||||
return newLinearElement({
|
||||
backgroundColor,
|
||||
groupIds: [groupId],
|
||||
...commonProps,
|
||||
type: "line",
|
||||
x: x + cx + BAR_WIDTH / 2 + BAR_GAP / 2,
|
||||
y: y - cy,
|
||||
startArrowhead: null,
|
||||
endArrowhead: null,
|
||||
height: cy,
|
||||
strokeStyle: "dotted",
|
||||
opacity: GRID_OPACITY,
|
||||
points: [
|
||||
[0, 0],
|
||||
[0, cy],
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
return [
|
||||
...chartBaseElements(
|
||||
spreadsheet,
|
||||
x,
|
||||
y,
|
||||
groupId,
|
||||
backgroundColor,
|
||||
process.env.NODE_ENV === ENV.DEVELOPMENT,
|
||||
),
|
||||
line,
|
||||
...lines,
|
||||
...dots,
|
||||
];
|
||||
};
|
||||
|
||||
export const renderSpreadsheet = (
|
||||
chartType: string,
|
||||
spreadsheet: Spreadsheet,
|
||||
x: number,
|
||||
y: number,
|
||||
): ChartElements => {
|
||||
trackEvent(EVENT_MAGIC, "chart", chartType, spreadsheet.values.length);
|
||||
if (chartType === "line") {
|
||||
return chartTypeLine(spreadsheet, x, y);
|
||||
}
|
||||
return chartTypeBar(spreadsheet, x, y);
|
||||
};
|
30
src/clients.ts
Normal file
30
src/clients.ts
Normal file
@@ -0,0 +1,30 @@
|
||||
import colors from "./colors";
|
||||
|
||||
export const getClientColors = (clientId: string) => {
|
||||
// Naive way of getting an integer out of the clientId
|
||||
const sum = clientId.split("").reduce((a, str) => a + str.charCodeAt(0), 0);
|
||||
|
||||
// Skip transparent background.
|
||||
const backgrounds = colors.elementBackground.slice(1);
|
||||
const strokes = colors.elementStroke.slice(1);
|
||||
return {
|
||||
background: backgrounds[sum % backgrounds.length],
|
||||
stroke: strokes[sum % strokes.length],
|
||||
};
|
||||
};
|
||||
|
||||
export const getClientInitials = (username?: string | null) => {
|
||||
if (!username) {
|
||||
return "?";
|
||||
}
|
||||
const names = username.trim().split(" ");
|
||||
|
||||
if (names.length < 2) {
|
||||
return names[0].substring(0, 2).toUpperCase();
|
||||
}
|
||||
|
||||
const firstName = names[0];
|
||||
const lastName = names[names.length - 1];
|
||||
|
||||
return (firstName[0] + lastName[0]).toUpperCase();
|
||||
};
|
179
src/clipboard.ts
179
src/clipboard.ts
@@ -5,6 +5,16 @@ import {
|
||||
import { getSelectedElements } from "./scene";
|
||||
import { AppState } from "./types";
|
||||
import { SVG_EXPORT_TAG } from "./scene/export";
|
||||
import { tryParseSpreadsheet, Spreadsheet, VALID_SPREADSHEET } from "./charts";
|
||||
import { canvasToBlob } from "./data/blob";
|
||||
|
||||
const TYPE_ELEMENTS = "excalidraw/elements";
|
||||
|
||||
type ElementsClipboard = {
|
||||
type: typeof TYPE_ELEMENTS;
|
||||
created: number;
|
||||
elements: ExcalidrawElement[];
|
||||
};
|
||||
|
||||
let CLIPBOARD = "";
|
||||
let PREFER_APP_CLIPBOARD = false;
|
||||
@@ -21,104 +31,139 @@ export const probablySupportsClipboardBlob =
|
||||
"ClipboardItem" in window &&
|
||||
"toBlob" in HTMLCanvasElement.prototype;
|
||||
|
||||
export async function copyToAppClipboard(
|
||||
const isElementsClipboard = (contents: any): contents is ElementsClipboard => {
|
||||
if (contents?.type === TYPE_ELEMENTS) {
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
};
|
||||
|
||||
export const copyToClipboard = async (
|
||||
elements: readonly NonDeletedExcalidrawElement[],
|
||||
appState: AppState,
|
||||
) {
|
||||
CLIPBOARD = JSON.stringify(getSelectedElements(elements, appState));
|
||||
) => {
|
||||
const contents: ElementsClipboard = {
|
||||
type: TYPE_ELEMENTS,
|
||||
created: Date.now(),
|
||||
elements: getSelectedElements(elements, appState),
|
||||
};
|
||||
const json = JSON.stringify(contents);
|
||||
CLIPBOARD = json;
|
||||
try {
|
||||
// when copying to in-app clipboard, clear system clipboard so that if
|
||||
// system clip contains text on paste we know it was copied *after* user
|
||||
// copied elements, and thus we should prefer the text content.
|
||||
await copyTextToSystemClipboard(null);
|
||||
PREFER_APP_CLIPBOARD = false;
|
||||
} catch {
|
||||
// if clearing system clipboard didn't work, we should prefer in-app
|
||||
// clipboard even if there's text in system clipboard on paste, because
|
||||
// we can't be sure of the order of copy operations
|
||||
await copyTextToSystemClipboard(json);
|
||||
} catch (error) {
|
||||
PREFER_APP_CLIPBOARD = true;
|
||||
console.error(error);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
export function getAppClipboard(): {
|
||||
elements?: readonly ExcalidrawElement[];
|
||||
} {
|
||||
const getAppClipboard = (): Partial<ElementsClipboard> => {
|
||||
if (!CLIPBOARD) {
|
||||
return {};
|
||||
}
|
||||
|
||||
try {
|
||||
const clipboardElements = JSON.parse(CLIPBOARD);
|
||||
|
||||
if (
|
||||
Array.isArray(clipboardElements) &&
|
||||
clipboardElements.length > 0 &&
|
||||
clipboardElements[0].type // need to implement a better check here...
|
||||
) {
|
||||
return { elements: clipboardElements };
|
||||
}
|
||||
return JSON.parse(CLIPBOARD);
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
return {};
|
||||
}
|
||||
};
|
||||
|
||||
return {};
|
||||
}
|
||||
const parsePotentialSpreadsheet = (
|
||||
text: string,
|
||||
): { spreadsheet: Spreadsheet } | { errorMessage: string } | null => {
|
||||
const result = tryParseSpreadsheet(text);
|
||||
if (result.type === VALID_SPREADSHEET) {
|
||||
return { spreadsheet: result.spreadsheet };
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
export async function getClipboardContent(
|
||||
/**
|
||||
* Retrieves content from system clipboard (either from ClipboardEvent or
|
||||
* via async clipboard API if supported)
|
||||
*/
|
||||
const getSystemClipboard = async (
|
||||
event: ClipboardEvent | null,
|
||||
): Promise<{
|
||||
text?: string;
|
||||
elements?: readonly ExcalidrawElement[];
|
||||
}> {
|
||||
): Promise<string> => {
|
||||
try {
|
||||
const text = event
|
||||
? event.clipboardData?.getData("text/plain").trim()
|
||||
: probablySupportsClipboardReadText &&
|
||||
(await navigator.clipboard.readText());
|
||||
|
||||
if (text && !PREFER_APP_CLIPBOARD && !text.includes(SVG_EXPORT_TAG)) {
|
||||
return { text };
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
return text || "";
|
||||
} catch {
|
||||
return "";
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Attemps to parse clipboard. Prefers system clipboard.
|
||||
*/
|
||||
export const parseClipboard = async (
|
||||
event: ClipboardEvent | null,
|
||||
): Promise<{
|
||||
spreadsheet?: Spreadsheet;
|
||||
elements?: readonly ExcalidrawElement[];
|
||||
text?: string;
|
||||
errorMessage?: string;
|
||||
}> => {
|
||||
const systemClipboard = await getSystemClipboard(event);
|
||||
|
||||
// if system clipboard empty, couldn't be resolved, or contains previously
|
||||
// copied excalidraw scene as SVG, fall back to previously copied excalidraw
|
||||
// elements
|
||||
if (!systemClipboard || systemClipboard.includes(SVG_EXPORT_TAG)) {
|
||||
return getAppClipboard();
|
||||
}
|
||||
|
||||
return getAppClipboard();
|
||||
}
|
||||
// if system clipboard contains spreadsheet, use it even though it's
|
||||
// technically possible it's staler than in-app clipboard
|
||||
const spreadsheetResult = parsePotentialSpreadsheet(systemClipboard);
|
||||
if (spreadsheetResult) {
|
||||
return spreadsheetResult;
|
||||
}
|
||||
|
||||
export async function copyCanvasToClipboardAsPng(canvas: HTMLCanvasElement) {
|
||||
return new Promise((resolve, reject) => {
|
||||
try {
|
||||
canvas.toBlob(async function (blob: any) {
|
||||
try {
|
||||
await navigator.clipboard.write([
|
||||
new window.ClipboardItem({ "image/png": blob }),
|
||||
]);
|
||||
resolve();
|
||||
} catch (error) {
|
||||
reject(error);
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
reject(error);
|
||||
}
|
||||
});
|
||||
}
|
||||
const appClipboardData = getAppClipboard();
|
||||
|
||||
export async function copyCanvasToClipboardAsSvg(svgroot: SVGSVGElement) {
|
||||
try {
|
||||
await navigator.clipboard.writeText(svgroot.outerHTML);
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
const systemClipboardData = JSON.parse(systemClipboard);
|
||||
// system clipboard elements are newer than in-app clipboard
|
||||
if (
|
||||
isElementsClipboard(systemClipboardData) &&
|
||||
(!appClipboardData?.created ||
|
||||
appClipboardData.created < systemClipboardData.created)
|
||||
) {
|
||||
return { elements: systemClipboardData.elements };
|
||||
}
|
||||
// in-app clipboard is newer than system clipboard
|
||||
return appClipboardData;
|
||||
} catch {
|
||||
// system clipboard doesn't contain excalidraw elements → return plaintext
|
||||
// unless we set a flag to prefer in-app clipboard because browser didn't
|
||||
// support storing to system clipboard on copy
|
||||
return PREFER_APP_CLIPBOARD && appClipboardData.elements
|
||||
? appClipboardData
|
||||
: { text: systemClipboard };
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
export async function copyTextToSystemClipboard(text: string | null) {
|
||||
export const copyCanvasToClipboardAsPng = async (canvas: HTMLCanvasElement) => {
|
||||
const blob = await canvasToBlob(canvas);
|
||||
await navigator.clipboard.write([
|
||||
new window.ClipboardItem({ "image/png": blob }),
|
||||
]);
|
||||
};
|
||||
|
||||
export const copyTextToSystemClipboard = async (text: string | null) => {
|
||||
let copied = false;
|
||||
if (probablySupportsClipboardWriteText) {
|
||||
try {
|
||||
// NOTE: doesn't work on FF on non-HTTPS domains, or when document
|
||||
// not focused
|
||||
// not focused
|
||||
await navigator.clipboard.writeText(text || "");
|
||||
copied = true;
|
||||
} catch (error) {
|
||||
@@ -127,14 +172,14 @@ export async function copyTextToSystemClipboard(text: string | null) {
|
||||
}
|
||||
|
||||
// Note that execCommand doesn't allow copying empty strings, so if we're
|
||||
// clearing clipboard using this API, we must copy at least an empty char
|
||||
// clearing clipboard using this API, we must copy at least an empty char
|
||||
if (!copied && !copyTextViaExecCommand(text || " ")) {
|
||||
throw new Error("couldn't copy");
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// adapted from https://github.com/zenorocha/clipboard.js/blob/ce79f170aa655c408b6aab33c9472e8e4fa52e19/src/clipboard-action.js#L48
|
||||
function copyTextViaExecCommand(text: string) {
|
||||
const copyTextViaExecCommand = (text: string) => {
|
||||
const isRTL = document.documentElement.getAttribute("dir") === "rtl";
|
||||
|
||||
const textarea = document.createElement("textarea");
|
||||
@@ -168,4 +213,4 @@ function copyTextViaExecCommand(text: string) {
|
||||
textarea.remove();
|
||||
|
||||
return success;
|
||||
}
|
||||
};
|
||||
|
@@ -1,18 +1,18 @@
|
||||
import oc from "open-color";
|
||||
|
||||
const shades = (i: number) => [
|
||||
oc.red[i],
|
||||
oc.pink[i],
|
||||
oc.grape[i],
|
||||
oc.violet[i],
|
||||
oc.indigo[i],
|
||||
oc.blue[i],
|
||||
oc.cyan[i],
|
||||
oc.teal[i],
|
||||
oc.green[i],
|
||||
oc.lime[i],
|
||||
oc.yellow[i],
|
||||
oc.orange[i],
|
||||
const shades = (index: number) => [
|
||||
oc.red[index],
|
||||
oc.pink[index],
|
||||
oc.grape[index],
|
||||
oc.violet[index],
|
||||
oc.indigo[index],
|
||||
oc.blue[index],
|
||||
oc.cyan[index],
|
||||
oc.teal[index],
|
||||
oc.green[index],
|
||||
oc.lime[index],
|
||||
oc.yellow[index],
|
||||
oc.orange[index],
|
||||
];
|
||||
|
||||
export default {
|
||||
|
@@ -1,17 +1,25 @@
|
||||
import React from "react";
|
||||
import { AppState } from "../types";
|
||||
import { AppState, Zoom } from "../types";
|
||||
import { ExcalidrawElement } from "../element/types";
|
||||
import { ActionManager } from "../actions/manager";
|
||||
import { hasBackground, hasStroke, hasText, getTargetElement } from "../scene";
|
||||
import {
|
||||
hasBackground,
|
||||
hasStroke,
|
||||
canChangeSharpness,
|
||||
hasText,
|
||||
canHaveArrowheads,
|
||||
getTargetElements,
|
||||
} from "../scene";
|
||||
import { t } from "../i18n";
|
||||
import { SHAPES } from "../shapes";
|
||||
import { ToolButton } from "./ToolButton";
|
||||
import { capitalizeString, setCursorForShape } from "../utils";
|
||||
import { capitalizeString, isTransparent, setCursorForShape } from "../utils";
|
||||
import Stack from "./Stack";
|
||||
import useIsMobile from "../is-mobile";
|
||||
import { getNonDeletedElements } from "../element";
|
||||
import { trackEvent, EVENT_SHAPE, EVENT_DIALOG } from "../analytics";
|
||||
|
||||
export function SelectedShapeActions({
|
||||
export const SelectedShapeActions = ({
|
||||
appState,
|
||||
elements,
|
||||
renderAction,
|
||||
@@ -21,35 +29,45 @@ export function SelectedShapeActions({
|
||||
elements: readonly ExcalidrawElement[];
|
||||
renderAction: ActionManager["renderAction"];
|
||||
elementType: ExcalidrawElement["type"];
|
||||
}) {
|
||||
const targetElements = getTargetElement(
|
||||
}) => {
|
||||
const targetElements = getTargetElements(
|
||||
getNonDeletedElements(elements),
|
||||
appState,
|
||||
);
|
||||
const isEditing = Boolean(appState.editingElement);
|
||||
const isMobile = useIsMobile();
|
||||
const isRTL = document.documentElement.getAttribute("dir") === "rtl";
|
||||
|
||||
const showFillIcons =
|
||||
hasBackground(elementType) ||
|
||||
targetElements.some(
|
||||
(element) =>
|
||||
hasBackground(element.type) && !isTransparent(element.backgroundColor),
|
||||
);
|
||||
const showChangeBackgroundIcons =
|
||||
hasBackground(elementType) ||
|
||||
targetElements.some((element) => hasBackground(element.type));
|
||||
|
||||
return (
|
||||
<div className="panelColumn">
|
||||
{renderAction("changeStrokeColor")}
|
||||
{(hasBackground(elementType) ||
|
||||
targetElements.some((element) => hasBackground(element.type))) && (
|
||||
<>
|
||||
{renderAction("changeBackgroundColor")}
|
||||
|
||||
{renderAction("changeFillStyle")}
|
||||
</>
|
||||
)}
|
||||
{showChangeBackgroundIcons && renderAction("changeBackgroundColor")}
|
||||
{showFillIcons && renderAction("changeFillStyle")}
|
||||
|
||||
{(hasStroke(elementType) ||
|
||||
targetElements.some((element) => hasStroke(element.type))) && (
|
||||
<>
|
||||
{renderAction("changeStrokeWidth")}
|
||||
|
||||
{renderAction("changeStrokeStyle")}
|
||||
{renderAction("changeSloppiness")}
|
||||
</>
|
||||
)}
|
||||
|
||||
{(canChangeSharpness(elementType) ||
|
||||
targetElements.some((element) => canChangeSharpness(element.type))) && (
|
||||
<>{renderAction("changeSharpness")}</>
|
||||
)}
|
||||
|
||||
{(hasText(elementType) ||
|
||||
targetElements.some((element) => hasText(element.type))) && (
|
||||
<>
|
||||
@@ -61,6 +79,11 @@ export function SelectedShapeActions({
|
||||
</>
|
||||
)}
|
||||
|
||||
{(canHaveArrowheads(elementType) ||
|
||||
targetElements.some((element) => canHaveArrowheads(element.type))) && (
|
||||
<>{renderAction("changeArrowhead")}</>
|
||||
)}
|
||||
|
||||
{renderAction("changeOpacity")}
|
||||
|
||||
<fieldset>
|
||||
@@ -72,76 +95,138 @@ export function SelectedShapeActions({
|
||||
{renderAction("bringForward")}
|
||||
</div>
|
||||
</fieldset>
|
||||
|
||||
{targetElements.length > 1 && (
|
||||
<fieldset>
|
||||
<legend>{t("labels.align")}</legend>
|
||||
<div className="buttonList">
|
||||
{
|
||||
// swap this order for RTL so the button positions always match their action
|
||||
// (i.e. the leftmost button aligns left)
|
||||
}
|
||||
{isRTL ? (
|
||||
<>
|
||||
{renderAction("alignRight")}
|
||||
{renderAction("alignHorizontallyCentered")}
|
||||
{renderAction("alignLeft")}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
{renderAction("alignLeft")}
|
||||
{renderAction("alignHorizontallyCentered")}
|
||||
{renderAction("alignRight")}
|
||||
</>
|
||||
)}
|
||||
{targetElements.length > 2 &&
|
||||
renderAction("distributeHorizontally")}
|
||||
<div className="iconRow">
|
||||
{renderAction("alignTop")}
|
||||
{renderAction("alignVerticallyCentered")}
|
||||
{renderAction("alignBottom")}
|
||||
{targetElements.length > 2 &&
|
||||
renderAction("distributeVertically")}
|
||||
</div>
|
||||
</div>
|
||||
</fieldset>
|
||||
)}
|
||||
{!isMobile && !isEditing && targetElements.length > 0 && (
|
||||
<fieldset>
|
||||
<legend>{t("labels.actions")}</legend>
|
||||
<div className="buttonList">
|
||||
{renderAction("duplicateSelection")}
|
||||
{renderAction("deleteSelectedElements")}
|
||||
{renderAction("group")}
|
||||
{renderAction("ungroup")}
|
||||
</div>
|
||||
</fieldset>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
export function ShapesSwitcher({
|
||||
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 = ({
|
||||
elementType,
|
||||
setAppState,
|
||||
isLibraryOpen,
|
||||
}: {
|
||||
elementType: ExcalidrawElement["type"];
|
||||
setAppState: any;
|
||||
}) {
|
||||
return (
|
||||
<>
|
||||
{SHAPES.map(({ value, icon, key }, index) => {
|
||||
const label = t(`toolBar.${value}`);
|
||||
const shortcut = `${capitalizeString(key)} ${t("shortcutsDialog.or")} ${
|
||||
index + 1
|
||||
}`;
|
||||
return (
|
||||
<ToolButton
|
||||
key={value}
|
||||
type="radio"
|
||||
icon={icon}
|
||||
checked={elementType === value}
|
||||
name="editor-current-shape"
|
||||
title={`${capitalizeString(label)} — ${shortcut}`}
|
||||
keyBindingLabel={`${index + 1}`}
|
||||
aria-label={capitalizeString(label)}
|
||||
aria-keyshortcuts={`${key} ${index + 1}`}
|
||||
data-testid={value}
|
||||
onChange={() => {
|
||||
setAppState({
|
||||
elementType: value,
|
||||
multiElement: null,
|
||||
selectedElementIds: {},
|
||||
});
|
||||
setCursorForShape(value);
|
||||
setAppState({});
|
||||
}}
|
||||
></ToolButton>
|
||||
);
|
||||
})}
|
||||
</>
|
||||
);
|
||||
}
|
||||
setAppState: React.Component<any, AppState>["setState"];
|
||||
isLibraryOpen: boolean;
|
||||
}) => (
|
||||
<>
|
||||
{SHAPES.map(({ value, icon, key }, index) => {
|
||||
const label = t(`toolBar.${value}`);
|
||||
const letter = typeof key === "string" ? key : key[0];
|
||||
const shortcut = `${capitalizeString(letter)} ${t(
|
||||
"shortcutsDialog.or",
|
||||
)} ${index + 1}`;
|
||||
return (
|
||||
<ToolButton
|
||||
className="Shape"
|
||||
key={value}
|
||||
type="radio"
|
||||
icon={icon}
|
||||
checked={elementType === value}
|
||||
name="editor-current-shape"
|
||||
title={`${capitalizeString(label)} — ${shortcut}`}
|
||||
keyBindingLabel={`${index + 1}`}
|
||||
aria-label={capitalizeString(label)}
|
||||
aria-keyshortcuts={shortcut}
|
||||
data-testid={value}
|
||||
onChange={() => {
|
||||
trackEvent(EVENT_SHAPE, value, "toolbar");
|
||||
setAppState({
|
||||
elementType: value,
|
||||
multiElement: null,
|
||||
selectedElementIds: {},
|
||||
});
|
||||
setCursorForShape(value);
|
||||
setAppState({});
|
||||
}}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
<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={() => {
|
||||
if (!isLibraryOpen) {
|
||||
trackEvent(EVENT_DIALOG, "library");
|
||||
}
|
||||
setAppState({ isLibraryOpen: !isLibraryOpen });
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
|
||||
export function ZoomActions({
|
||||
export const ZoomActions = ({
|
||||
renderAction,
|
||||
zoom,
|
||||
}: {
|
||||
renderAction: ActionManager["renderAction"];
|
||||
zoom: number;
|
||||
}) {
|
||||
return (
|
||||
<Stack.Col gap={1}>
|
||||
<Stack.Row gap={1} align="center">
|
||||
{renderAction("zoomIn")}
|
||||
{renderAction("zoomOut")}
|
||||
{renderAction("resetZoom")}
|
||||
<div style={{ marginInlineStart: 4 }}>{(zoom * 100).toFixed(0)}%</div>
|
||||
</Stack.Row>
|
||||
</Stack.Col>
|
||||
);
|
||||
}
|
||||
zoom: Zoom;
|
||||
}) => (
|
||||
<Stack.Col gap={1}>
|
||||
<Stack.Row gap={1} align="center">
|
||||
{renderAction("zoomIn")}
|
||||
{renderAction("zoomOut")}
|
||||
{renderAction("resetZoom")}
|
||||
<div style={{ marginInlineStart: 4 }}>
|
||||
{(zoom.value * 100).toFixed(0)}%
|
||||
</div>
|
||||
</Stack.Row>
|
||||
</Stack.Col>
|
||||
);
|
||||
|
File diff suppressed because it is too large
Load Diff
16
src/components/Avatar.scss
Normal file
16
src/components/Avatar.scss
Normal file
@@ -0,0 +1,16 @@
|
||||
@import "../css/_variables";
|
||||
|
||||
.excalidraw {
|
||||
.Avatar {
|
||||
width: 2.5rem;
|
||||
height: 2.5rem;
|
||||
border-radius: 1.25rem;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
color: $oc-white;
|
||||
cursor: pointer;
|
||||
font-size: 0.8rem;
|
||||
font-weight: 500;
|
||||
}
|
||||
}
|
20
src/components/Avatar.tsx
Normal file
20
src/components/Avatar.tsx
Normal file
@@ -0,0 +1,20 @@
|
||||
import "./Avatar.scss";
|
||||
|
||||
import React from "react";
|
||||
|
||||
type AvatarProps = {
|
||||
children: string;
|
||||
onClick: (e: React.MouseEvent<HTMLDivElement, MouseEvent>) => void;
|
||||
color: string;
|
||||
border: string;
|
||||
};
|
||||
|
||||
export const Avatar = ({ children, color, border, onClick }: AvatarProps) => (
|
||||
<div
|
||||
className="Avatar"
|
||||
style={{ background: color, border: `1px solid ${border}` }}
|
||||
onClick={onClick}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
);
|
29
src/components/BackgroundPickerAndDarkModeToggle.tsx
Normal file
29
src/components/BackgroundPickerAndDarkModeToggle.tsx
Normal file
@@ -0,0 +1,29 @@
|
||||
import React from "react";
|
||||
import { ActionManager } from "../actions/manager";
|
||||
import { EVENT_CHANGE, trackEvent } from "../analytics";
|
||||
import { AppState } from "../types";
|
||||
import { DarkModeToggle } from "./DarkModeToggle";
|
||||
|
||||
export const BackgroundPickerAndDarkModeToggle = ({
|
||||
appState,
|
||||
setAppState,
|
||||
actionManager,
|
||||
}: {
|
||||
actionManager: ActionManager;
|
||||
appState: AppState;
|
||||
setAppState: React.Component<any, AppState>["setState"];
|
||||
}) => (
|
||||
<div style={{ display: "flex" }}>
|
||||
{actionManager.renderAction("changeViewBackgroundColor")}
|
||||
<div style={{ marginInlineStart: "0.25rem" }}>
|
||||
<DarkModeToggle
|
||||
value={appState.appearance}
|
||||
onChange={(appearance) => {
|
||||
// TODO: track the theme on the first load too
|
||||
trackEvent(EVENT_CHANGE, "theme", appearance);
|
||||
setAppState({ appearance });
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
29
src/components/ButtonIconCycle.tsx
Normal file
29
src/components/ButtonIconCycle.tsx
Normal file
@@ -0,0 +1,29 @@
|
||||
import React from "react";
|
||||
import clsx from "clsx";
|
||||
|
||||
export const ButtonIconCycle = <T extends any>({
|
||||
options,
|
||||
value,
|
||||
onChange,
|
||||
group,
|
||||
}: {
|
||||
options: { value: T; text: string; icon: JSX.Element }[];
|
||||
value: T | null;
|
||||
onChange: (value: T) => void;
|
||||
group: string;
|
||||
}) => {
|
||||
const current = options.find((op) => op.value === value);
|
||||
|
||||
function cycle() {
|
||||
const index = options.indexOf(current!);
|
||||
const next = (index + 1) % options.length;
|
||||
onChange(options[next].value);
|
||||
}
|
||||
|
||||
return (
|
||||
<label key={group} className={clsx({ active: current!.value !== null })}>
|
||||
<input type="button" name={group} onClick={cycle} />
|
||||
{current!.icon}
|
||||
</label>
|
||||
);
|
||||
};
|
33
src/components/ButtonIconSelect.tsx
Normal file
33
src/components/ButtonIconSelect.tsx
Normal file
@@ -0,0 +1,33 @@
|
||||
import React from "react";
|
||||
import clsx from "clsx";
|
||||
|
||||
// TODO: It might be "clever" to add option.icon to the existing component <ButtonSelect />
|
||||
export const ButtonIconSelect = <T extends Object>({
|
||||
options,
|
||||
value,
|
||||
onChange,
|
||||
group,
|
||||
}: {
|
||||
options: { value: T; text: string; icon: JSX.Element }[];
|
||||
value: T | null;
|
||||
onChange: (value: T) => void;
|
||||
group: string;
|
||||
}) => (
|
||||
<div className="buttonList buttonListIcon">
|
||||
{options.map((option) => (
|
||||
<label
|
||||
key={option.text}
|
||||
className={clsx({ active: value === option.value })}
|
||||
title={option.text}
|
||||
>
|
||||
<input
|
||||
type="radio"
|
||||
name={group}
|
||||
onChange={() => onChange(option.value)}
|
||||
checked={value === option.value}
|
||||
/>
|
||||
{option.icon}
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
);
|
@@ -1,6 +1,7 @@
|
||||
import React from "react";
|
||||
import clsx from "clsx";
|
||||
|
||||
export function ButtonSelect<T>({
|
||||
export const ButtonSelect = <T extends Object>({
|
||||
options,
|
||||
value,
|
||||
onChange,
|
||||
@@ -10,23 +11,21 @@ export function ButtonSelect<T>({
|
||||
value: T | null;
|
||||
onChange: (value: T) => void;
|
||||
group: string;
|
||||
}) {
|
||||
return (
|
||||
<div className="buttonList">
|
||||
{options.map((option) => (
|
||||
<label
|
||||
key={option.text}
|
||||
className={value === option.value ? "active" : ""}
|
||||
>
|
||||
<input
|
||||
type="radio"
|
||||
name={group}
|
||||
onChange={() => onChange(option.value)}
|
||||
checked={value === option.value ? true : false}
|
||||
/>
|
||||
{option.text}
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}) => (
|
||||
<div className="buttonList">
|
||||
{options.map((option) => (
|
||||
<label
|
||||
key={option.text}
|
||||
className={clsx({ active: value === option.value })}
|
||||
>
|
||||
<input
|
||||
type="radio"
|
||||
name={group}
|
||||
onChange={() => onChange(option.value)}
|
||||
checked={value === option.value}
|
||||
/>
|
||||
{option.text}
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
|
29
src/components/CollabButton.scss
Normal file
29
src/components/CollabButton.scss
Normal file
@@ -0,0 +1,29 @@
|
||||
@import "../css/_variables";
|
||||
|
||||
.excalidraw {
|
||||
.CollabButton.is-collaborating {
|
||||
background-color: var(--button-special-active-background-color);
|
||||
|
||||
.ToolIcon__icon svg {
|
||||
color: var(--icon-green-fill-color);
|
||||
}
|
||||
}
|
||||
|
||||
.CollabButton-collaborators {
|
||||
:root[dir="ltr"] & {
|
||||
right: -5px;
|
||||
}
|
||||
:root[dir="rtl"] & {
|
||||
left: -5px;
|
||||
}
|
||||
min-width: 1em;
|
||||
position: absolute;
|
||||
bottom: -5px;
|
||||
padding: 3px;
|
||||
border-radius: 50%;
|
||||
background-color: $oc-green-6;
|
||||
color: $oc-white;
|
||||
font-size: 0.7em;
|
||||
font-family: var(--ui-font);
|
||||
}
|
||||
}
|
44
src/components/CollabButton.tsx
Normal file
44
src/components/CollabButton.tsx
Normal file
@@ -0,0 +1,44 @@
|
||||
import React from "react";
|
||||
import clsx from "clsx";
|
||||
import { ToolButton } from "./ToolButton";
|
||||
import { t } from "../i18n";
|
||||
import useIsMobile from "../is-mobile";
|
||||
import { users } from "./icons";
|
||||
|
||||
import "./CollabButton.scss";
|
||||
import { EVENT_DIALOG, trackEvent } from "../analytics";
|
||||
|
||||
const CollabButton = ({
|
||||
isCollaborating,
|
||||
collaboratorCount,
|
||||
onClick,
|
||||
}: {
|
||||
isCollaborating: boolean;
|
||||
collaboratorCount: number;
|
||||
onClick: () => void;
|
||||
}) => {
|
||||
return (
|
||||
<>
|
||||
<ToolButton
|
||||
className={clsx("CollabButton", {
|
||||
"is-collaborating": isCollaborating,
|
||||
})}
|
||||
onClick={() => {
|
||||
trackEvent(EVENT_DIALOG, "collaboration");
|
||||
onClick();
|
||||
}}
|
||||
icon={users}
|
||||
type="button"
|
||||
title={t("buttons.roomDialog")}
|
||||
aria-label={t("buttons.roomDialog")}
|
||||
showAriaLabel={useIsMobile()}
|
||||
>
|
||||
{collaboratorCount > 0 && (
|
||||
<div className="CollabButton-collaborators">{collaboratorCount}</div>
|
||||
)}
|
||||
</ToolButton>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default CollabButton;
|
@@ -1,191 +1,250 @@
|
||||
@import "open-color/open-color.scss";
|
||||
@import "../css/_variables";
|
||||
|
||||
.color-picker {
|
||||
background: $oc-white;
|
||||
border: 0px solid transparentize($oc-white, 0.75);
|
||||
box-shadow: transparentize($oc-black, 0.75) 0px 1px 4px;
|
||||
border-radius: 4px;
|
||||
position: absolute;
|
||||
:root[dir="ltr"] & {
|
||||
left: -5.5px;
|
||||
.excalidraw {
|
||||
.color-picker {
|
||||
background: var(--popup-background-color);
|
||||
border: 0px solid transparentize($oc-white, 0.75);
|
||||
box-shadow: transparentize($oc-black, 0.75) 0px 1px 4px;
|
||||
border-radius: 4px;
|
||||
position: absolute;
|
||||
|
||||
:root[dir="ltr"] & {
|
||||
left: -5.5px;
|
||||
}
|
||||
|
||||
:root[dir="rtl"] & {
|
||||
right: -5.5px;
|
||||
}
|
||||
}
|
||||
:root[dir="rtl"] & {
|
||||
right: -5.5px;
|
||||
|
||||
.color-picker-control-container {
|
||||
display: grid;
|
||||
grid-template-columns: auto 1fr;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.color-picker-triangle {
|
||||
width: 0px;
|
||||
height: 0px;
|
||||
border-style: solid;
|
||||
border-width: 0px 9px 10px;
|
||||
border-color: transparent transparent var(--popup-background-color);
|
||||
position: absolute;
|
||||
top: -10px;
|
||||
|
||||
:root[dir="ltr"] & {
|
||||
left: 12px;
|
||||
}
|
||||
|
||||
:root[dir="rtl"] & {
|
||||
right: 12px;
|
||||
}
|
||||
}
|
||||
|
||||
.color-picker-triangle-shadow {
|
||||
border-color: transparent transparent transparentize($oc-black, 0.9);
|
||||
top: -11px;
|
||||
}
|
||||
|
||||
.color-picker-content {
|
||||
padding: 0.5rem;
|
||||
display: grid;
|
||||
grid-template-columns: repeat(5, auto);
|
||||
grid-gap: 0.5rem;
|
||||
border-radius: 4px;
|
||||
|
||||
&:focus {
|
||||
outline: none;
|
||||
box-shadow: 0 0 0 2px var(--focus-highlight-color);
|
||||
}
|
||||
}
|
||||
|
||||
.color-picker-content .color-input-container {
|
||||
grid-column: 1 / span 5;
|
||||
}
|
||||
|
||||
.color-picker-swatch {
|
||||
position: relative;
|
||||
height: 1.875rem;
|
||||
width: 1.875rem;
|
||||
cursor: pointer;
|
||||
border-radius: 4px;
|
||||
margin: 0;
|
||||
box-sizing: border-box;
|
||||
border: 1px solid #ddd;
|
||||
background-color: currentColor !important;
|
||||
filter: var(--appearance-filter);
|
||||
|
||||
&:focus {
|
||||
/* TODO: only show the border when the color is too light to see as a shadow */
|
||||
box-shadow: 0 0 4px 1px currentColor;
|
||||
border-color: var(--select-highlight-color);
|
||||
}
|
||||
}
|
||||
|
||||
.color-picker-transparent {
|
||||
border-radius: 4px;
|
||||
box-shadow: transparentize($oc-black, 0.9) 0px 0px 0px 1px inset;
|
||||
position: absolute;
|
||||
top: 0px;
|
||||
right: 0px;
|
||||
bottom: 0px;
|
||||
left: 0px;
|
||||
}
|
||||
|
||||
.color-picker-transparent,
|
||||
.color-picker-label-swatch {
|
||||
background: url("data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABAAAAAQCAYAAAAf8/9hAAAAMUlEQVQ4T2NkYGAQYcAP3uCTZhw1gGGYhAGBZIA/nYDCgBDAm9BGDWAAJyRCgLaBCAAgXwixzAS0pgAAAABJRU5ErkJggg==")
|
||||
left center;
|
||||
}
|
||||
|
||||
.color-picker-hash {
|
||||
background: var(--input-border-color);
|
||||
height: 1.875rem;
|
||||
width: 1.875rem;
|
||||
|
||||
:root[dir="ltr"] & {
|
||||
border-radius: 4px 0px 0px 4px;
|
||||
}
|
||||
|
||||
:root[dir="rtl"] & {
|
||||
border-radius: 0px 4px 4px 0px;
|
||||
}
|
||||
|
||||
color: var(--input-label-color);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.color-input-container:focus-within .color-picker-hash {
|
||||
box-shadow: 0 0 0 2px var(--focus-highlight-color);
|
||||
}
|
||||
|
||||
.color-input-container:focus-within .color-picker-hash::before,
|
||||
.color-input-container:focus-within .color-picker-hash::after {
|
||||
content: "";
|
||||
width: 1px;
|
||||
height: 100%;
|
||||
position: absolute;
|
||||
top: 0;
|
||||
}
|
||||
|
||||
.color-input-container:focus-within .color-picker-hash::before {
|
||||
background: var(--input-border-color);
|
||||
|
||||
:root[dir="ltr"] & {
|
||||
right: -1px;
|
||||
}
|
||||
|
||||
:root[dir="rtl"] & {
|
||||
left: -1px;
|
||||
}
|
||||
}
|
||||
|
||||
.color-input-container:focus-within .color-picker-hash::after {
|
||||
background: var(--input-background-color);
|
||||
|
||||
:root[dir="ltr"] & {
|
||||
right: -2px;
|
||||
}
|
||||
|
||||
:root[dir="rtl"] & {
|
||||
left: -2px;
|
||||
}
|
||||
}
|
||||
|
||||
.color-input-container {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.color-picker-input {
|
||||
width: 12ch; /* length of `transparent` + 1 */
|
||||
margin: 0;
|
||||
font-size: 1rem;
|
||||
background-color: var(--input-background-color);
|
||||
color: var(--text-color-primary);
|
||||
border: 0px;
|
||||
outline: none;
|
||||
height: 1.75em;
|
||||
box-shadow: var(--input-border-color) 0px 0px 0px 1px inset;
|
||||
|
||||
:root[dir="ltr"] & {
|
||||
border-radius: 0px 4px 4px 0px;
|
||||
}
|
||||
|
||||
:root[dir="rtl"] & {
|
||||
border-radius: 4px 0px 0px 4px;
|
||||
}
|
||||
|
||||
float: left;
|
||||
padding: 1px;
|
||||
padding-inline-start: 0.5em;
|
||||
appearance: none;
|
||||
}
|
||||
|
||||
.color-picker-label-swatch {
|
||||
height: 1.875rem;
|
||||
width: 1.875rem;
|
||||
margin-inline-end: 0.25rem;
|
||||
border: 1px solid $oc-gray-3;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
background-color: transparent !important;
|
||||
filter: var(--appearance-filter);
|
||||
|
||||
&:after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background: var(--swatch-color);
|
||||
}
|
||||
}
|
||||
|
||||
.color-picker-keybinding {
|
||||
position: absolute;
|
||||
bottom: 2px;
|
||||
font-size: 0.7em;
|
||||
|
||||
:root[dir="ltr"] & {
|
||||
right: 2px;
|
||||
}
|
||||
|
||||
:root[dir="rtl"] & {
|
||||
left: 2px;
|
||||
}
|
||||
|
||||
@media #{$is-mobile-query} {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
.color-picker-type-canvasBackground .color-picker-keybinding {
|
||||
color: #aaa;
|
||||
}
|
||||
|
||||
.color-picker-type-elementBackground .color-picker-keybinding {
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.color-picker-swatch[aria-label="transparent"] .color-picker-keybinding {
|
||||
color: #aaa;
|
||||
}
|
||||
|
||||
.color-picker-type-elementStroke .color-picker-keybinding {
|
||||
color: #d4d4d4;
|
||||
}
|
||||
|
||||
&.Appearance_dark {
|
||||
.color-picker-type-elementBackground .color-picker-keybinding {
|
||||
color: #000;
|
||||
}
|
||||
.color-picker-swatch[aria-label="transparent"] .color-picker-keybinding {
|
||||
color: #000;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.color-picker-control-container {
|
||||
display: grid;
|
||||
grid-template-columns: auto 1fr;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.color-picker-triangle {
|
||||
width: 0px;
|
||||
height: 0px;
|
||||
border-style: solid;
|
||||
border-width: 0px 9px 10px;
|
||||
border-color: transparent transparent $oc-white;
|
||||
position: absolute;
|
||||
top: -10px;
|
||||
:root[dir="ltr"] & {
|
||||
left: 12px;
|
||||
}
|
||||
:root[dir="rtl"] & {
|
||||
right: 12px;
|
||||
}
|
||||
}
|
||||
|
||||
.color-picker-triangle-shadow {
|
||||
border-color: transparent transparent transparentize($oc-black, 0.9);
|
||||
top: -11px;
|
||||
}
|
||||
|
||||
.color-picker-content {
|
||||
padding: 0.5rem;
|
||||
display: grid;
|
||||
grid-template-columns: repeat(5, auto);
|
||||
grid-gap: 0.5rem;
|
||||
}
|
||||
|
||||
.color-picker-content .color-input-container {
|
||||
grid-column: 1 / span 5;
|
||||
}
|
||||
|
||||
.color-picker-swatch {
|
||||
position: relative;
|
||||
height: 1.875rem;
|
||||
width: 1.875rem;
|
||||
cursor: pointer;
|
||||
border-radius: 4px;
|
||||
margin: 0;
|
||||
box-sizing: border-box;
|
||||
border: 1px solid #ddd;
|
||||
background-color: currentColor !important;
|
||||
}
|
||||
|
||||
.color-picker-swatch:focus {
|
||||
/* TODO: only show the border when the color is too light to see as a shadow */
|
||||
box-shadow: 0 0 4px 1px currentColor;
|
||||
border-color: $oc-blue-5;
|
||||
}
|
||||
|
||||
.color-picker-transparent {
|
||||
border-radius: 4px;
|
||||
box-shadow: transparentize($oc-black, 0.9) 0px 0px 0px 1px inset;
|
||||
position: absolute;
|
||||
top: 0px;
|
||||
right: 0px;
|
||||
bottom: 0px;
|
||||
left: 0px;
|
||||
}
|
||||
.color-picker-transparent,
|
||||
.color-picker-label-swatch {
|
||||
background: url("data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABAAAAAQCAYAAAAf8/9hAAAAMUlEQVQ4T2NkYGAQYcAP3uCTZhw1gGGYhAGBZIA/nYDCgBDAm9BGDWAAJyRCgLaBCAAgXwixzAS0pgAAAABJRU5ErkJggg==")
|
||||
left center;
|
||||
}
|
||||
|
||||
.color-picker-hash {
|
||||
background: $oc-gray-3;
|
||||
height: 1.875rem;
|
||||
width: 1.875rem;
|
||||
:root[dir="ltr"] & {
|
||||
border-radius: 4px 0px 0px 4px;
|
||||
}
|
||||
:root[dir="rtl"] & {
|
||||
border-radius: 0px 4px 4px 0px;
|
||||
}
|
||||
color: $oc-gray-7;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 1;
|
||||
position: relative;
|
||||
}
|
||||
.color-input-container:focus-within .color-picker-hash {
|
||||
box-shadow: 0 0 0 2px $oc-blue-2;
|
||||
}
|
||||
.color-input-container:focus-within .color-picker-hash::before,
|
||||
.color-input-container:focus-within .color-picker-hash::after {
|
||||
content: "";
|
||||
width: 1px;
|
||||
height: 100%;
|
||||
position: absolute;
|
||||
top: 0;
|
||||
}
|
||||
.color-input-container:focus-within .color-picker-hash::before {
|
||||
background: $oc-gray-3;
|
||||
:root[dir="ltr"] & {
|
||||
right: -1px;
|
||||
}
|
||||
:root[dir="rtl"] & {
|
||||
left: -1px;
|
||||
}
|
||||
}
|
||||
.color-input-container:focus-within .color-picker-hash::after {
|
||||
background: #fff;
|
||||
:root[dir="ltr"] & {
|
||||
right: -2px;
|
||||
}
|
||||
:root[dir="rtl"] & {
|
||||
left: -2px;
|
||||
}
|
||||
}
|
||||
|
||||
.color-input-container {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.color-picker-input {
|
||||
width: 12ch; /* length of `transparent` + 1 */
|
||||
margin: 0;
|
||||
font-size: 1rem;
|
||||
color: $oc-gray-8;
|
||||
border: 0px;
|
||||
outline: none;
|
||||
height: 1.75em;
|
||||
box-shadow: $oc-gray-3 0px 0px 0px 1px inset;
|
||||
:root[dir="ltr"] & {
|
||||
border-radius: 0px 4px 4px 0px;
|
||||
}
|
||||
:root[dir="rtl"] & {
|
||||
border-radius: 4px 0px 0px 4px;
|
||||
}
|
||||
float: left;
|
||||
padding: 1px;
|
||||
padding-inline-start: 0.5em;
|
||||
appearance: none;
|
||||
}
|
||||
|
||||
.color-picker-label-swatch {
|
||||
height: 1.875rem;
|
||||
width: 1.875rem;
|
||||
margin-inline-end: 0.25rem;
|
||||
border: 1px solid $oc-gray-3;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
background-color: transparent !important;
|
||||
}
|
||||
|
||||
.color-picker-label-swatch::after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background: var(--swatch-color);
|
||||
}
|
||||
|
||||
.color-picker-keybinding {
|
||||
position: absolute;
|
||||
bottom: 2px;
|
||||
:root[dir="ltr"] & {
|
||||
right: 2px;
|
||||
}
|
||||
:root[dir="rtl"] & {
|
||||
left: 2px;
|
||||
}
|
||||
font-size: 0.7em;
|
||||
color: #ccc;
|
||||
}
|
||||
|
@@ -2,16 +2,16 @@ import React from "react";
|
||||
import { Popover } from "./Popover";
|
||||
|
||||
import "./ColorPicker.scss";
|
||||
import { KEYS } from "../keys";
|
||||
import { isArrowKey, KEYS } from "../keys";
|
||||
import { t, getLanguage } from "../i18n";
|
||||
import { isWritableElement } from "../utils";
|
||||
import colors from "../colors";
|
||||
|
||||
function isValidColor(color: string) {
|
||||
const isValidColor = (color: string) => {
|
||||
const style = new Option().style;
|
||||
style.color = color;
|
||||
return !!style.color;
|
||||
}
|
||||
};
|
||||
|
||||
const getColor = (color: string): string | null => {
|
||||
if (color === "transparent") {
|
||||
@@ -36,13 +36,14 @@ const keyBindings = [
|
||||
["a", "s", "d", "f", "g"],
|
||||
].flat();
|
||||
|
||||
const Picker = function ({
|
||||
const Picker = ({
|
||||
colors,
|
||||
color,
|
||||
onChange,
|
||||
onClose,
|
||||
label,
|
||||
showInput = true,
|
||||
type,
|
||||
}: {
|
||||
colors: string[];
|
||||
color: string | null;
|
||||
@@ -50,19 +51,21 @@ const Picker = function ({
|
||||
onClose: () => void;
|
||||
label: string;
|
||||
showInput: boolean;
|
||||
}) {
|
||||
type: "canvasBackground" | "elementBackground" | "elementStroke";
|
||||
}) => {
|
||||
const firstItem = React.useRef<HTMLButtonElement>();
|
||||
const activeItem = React.useRef<HTMLButtonElement>();
|
||||
const gallery = React.useRef<HTMLDivElement>();
|
||||
const colorInput = React.useRef<HTMLInputElement>();
|
||||
|
||||
React.useEffect(() => {
|
||||
// After the component is first mounted
|
||||
// focus on first input
|
||||
// After the component is first mounted focus on first input
|
||||
if (activeItem.current) {
|
||||
activeItem.current.focus();
|
||||
} else if (colorInput.current) {
|
||||
colorInput.current.focus();
|
||||
} else if (gallery.current) {
|
||||
gallery.current.focus();
|
||||
}
|
||||
}, []);
|
||||
|
||||
@@ -74,18 +77,11 @@ const Picker = function ({
|
||||
colorInput.current?.focus();
|
||||
event.preventDefault();
|
||||
}
|
||||
} else {
|
||||
if (activeElement === colorInput.current) {
|
||||
firstItem.current?.focus();
|
||||
event.preventDefault();
|
||||
}
|
||||
} else if (activeElement === colorInput.current) {
|
||||
firstItem.current?.focus();
|
||||
event.preventDefault();
|
||||
}
|
||||
} else if (
|
||||
event.key === KEYS.ARROW_RIGHT ||
|
||||
event.key === KEYS.ARROW_LEFT ||
|
||||
event.key === KEYS.ARROW_UP ||
|
||||
event.key === KEYS.ARROW_DOWN
|
||||
) {
|
||||
} else if (isArrowKey(event.key)) {
|
||||
const { activeElement } = document;
|
||||
const isRTL = getLanguage().rtl;
|
||||
const index = Array.prototype.indexOf.call(
|
||||
@@ -123,7 +119,7 @@ const Picker = function ({
|
||||
|
||||
return (
|
||||
<div
|
||||
className="color-picker"
|
||||
className={`color-picker color-picker-type-${type}`}
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label={t("labels.colorPicker")}
|
||||
@@ -138,6 +134,7 @@ const Picker = function ({
|
||||
gallery.current = el;
|
||||
}
|
||||
}}
|
||||
tabIndex={0}
|
||||
>
|
||||
{colors.map((_color, i) => (
|
||||
<button
|
||||
@@ -235,7 +232,7 @@ const ColorInput = React.forwardRef(
|
||||
},
|
||||
);
|
||||
|
||||
export function ColorPicker({
|
||||
export const ColorPicker = ({
|
||||
type,
|
||||
color,
|
||||
onChange,
|
||||
@@ -245,7 +242,7 @@ export function ColorPicker({
|
||||
color: string | null;
|
||||
onChange: (color: string) => void;
|
||||
label: string;
|
||||
}) {
|
||||
}) => {
|
||||
const [isActive, setActive] = React.useState(false);
|
||||
const pickerButton = React.useRef<HTMLButtonElement>(null);
|
||||
|
||||
@@ -255,11 +252,7 @@ export function ColorPicker({
|
||||
<button
|
||||
className="color-picker-label-swatch"
|
||||
aria-label={label}
|
||||
style={
|
||||
color
|
||||
? ({ "--swatch-color": color } as React.CSSProperties)
|
||||
: undefined
|
||||
}
|
||||
style={color ? { "--swatch-color": color } : undefined}
|
||||
onClick={() => setActive(!isActive)}
|
||||
ref={pickerButton}
|
||||
/>
|
||||
@@ -290,10 +283,11 @@ export function ColorPicker({
|
||||
}}
|
||||
label={label}
|
||||
showInput={false}
|
||||
type={type}
|
||||
/>
|
||||
</Popover>
|
||||
) : null}
|
||||
</React.Suspense>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
@@ -1,36 +1,90 @@
|
||||
@import "open-color/open-color.scss";
|
||||
@import "../css/_variables";
|
||||
|
||||
.context-menu {
|
||||
position: relative;
|
||||
border-radius: 4px;
|
||||
box-shadow: 0px 3px 10px transparentize($oc-black, 0.8);
|
||||
padding: 0;
|
||||
list-style: none;
|
||||
user-select: none;
|
||||
margin: -0.25rem 0 0 0.125rem;
|
||||
padding: 0.25rem 0;
|
||||
background-color: $oc-gray-1;
|
||||
border: 1px solid $oc-gray-5;
|
||||
}
|
||||
.excalidraw {
|
||||
.context-menu {
|
||||
position: relative;
|
||||
border-radius: 4px;
|
||||
box-shadow: 0px 3px 10px transparentize($oc-black, 0.8);
|
||||
padding: 0;
|
||||
list-style: none;
|
||||
user-select: none;
|
||||
margin: -0.25rem 0 0 0.125rem;
|
||||
padding: 0.25rem 0;
|
||||
background-color: var(--popup-secondary-background-color);
|
||||
border: 1px solid var(--button-gray-3);
|
||||
}
|
||||
|
||||
.context-menu-option {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
min-width: 9.5rem;
|
||||
margin: 0;
|
||||
padding: 0.25rem 1rem 0.25rem 1.25rem;
|
||||
text-align: start;
|
||||
border-radius: 0;
|
||||
background-color: transparent;
|
||||
border: none;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.context-menu button {
|
||||
color: var(--popup-text-color);
|
||||
}
|
||||
|
||||
.context-menu-option:hover {
|
||||
color: $oc-white;
|
||||
background-color: $oc-blue-5;
|
||||
}
|
||||
.context-menu-option {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
min-width: 9.5rem;
|
||||
margin: 0;
|
||||
padding: 0.25rem 1rem 0.25rem 1.25rem;
|
||||
text-align: start;
|
||||
border-radius: 0;
|
||||
background-color: transparent;
|
||||
border: none;
|
||||
white-space: nowrap;
|
||||
|
||||
.context-menu-option:focus {
|
||||
z-index: 1;
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 0.2fr;
|
||||
align-items: center;
|
||||
|
||||
&.checkmark::before {
|
||||
position: absolute;
|
||||
left: 6px;
|
||||
margin-bottom: 1px;
|
||||
content: "\2713";
|
||||
}
|
||||
|
||||
&.dangerous {
|
||||
.context-menu-option__label {
|
||||
color: $oc-red-7;
|
||||
}
|
||||
}
|
||||
|
||||
.context-menu-option__label {
|
||||
justify-self: start;
|
||||
margin-inline-end: 20px;
|
||||
}
|
||||
.context-menu-option__shortcut {
|
||||
justify-self: end;
|
||||
opacity: 0.6;
|
||||
font-size: 0.7rem;
|
||||
}
|
||||
}
|
||||
|
||||
.context-menu-option:hover {
|
||||
color: var(--popup-background-color);
|
||||
background-color: var(--select-highlight-color);
|
||||
|
||||
&.dangerous {
|
||||
.context-menu-option__label {
|
||||
color: var(--popup-background-color);
|
||||
}
|
||||
background-color: $oc-red-6;
|
||||
}
|
||||
}
|
||||
|
||||
.context-menu-option:focus {
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
@media #{$is-mobile-query} {
|
||||
.context-menu-option {
|
||||
display: block;
|
||||
|
||||
.context-menu-option__label {
|
||||
margin-inline-end: 0;
|
||||
}
|
||||
|
||||
.context-menu-option__shortcut {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@@ -1,10 +1,17 @@
|
||||
import React from "react";
|
||||
import { Popover } from "./Popover";
|
||||
import { render, unmountComponentAtNode } from "react-dom";
|
||||
import clsx from "clsx";
|
||||
import { Popover } from "./Popover";
|
||||
|
||||
import "./ContextMenu.scss";
|
||||
import {
|
||||
getShortcutFromShortcutName,
|
||||
ShortcutName,
|
||||
} from "../actions/shortcuts";
|
||||
|
||||
type ContextMenuOption = {
|
||||
checked?: boolean;
|
||||
shortcutName: ShortcutName;
|
||||
label: string;
|
||||
action(): void;
|
||||
};
|
||||
@@ -16,45 +23,58 @@ type Props = {
|
||||
left: number;
|
||||
};
|
||||
|
||||
function ContextMenu({ options, onCloseRequest, top, left }: Props) {
|
||||
const ContextMenu = ({ options, onCloseRequest, top, left }: Props) => {
|
||||
const isDarkTheme = !!document
|
||||
.querySelector(".excalidraw")
|
||||
?.classList.contains("Appearance_dark");
|
||||
return (
|
||||
<Popover
|
||||
onCloseRequest={onCloseRequest}
|
||||
top={top}
|
||||
left={left}
|
||||
fitInViewport={true}
|
||||
<div
|
||||
className={clsx("excalidraw", {
|
||||
"Appearance_dark Appearance_dark-background-none": isDarkTheme,
|
||||
})}
|
||||
>
|
||||
<ul
|
||||
className="context-menu"
|
||||
onContextMenu={(event) => event.preventDefault()}
|
||||
<Popover
|
||||
onCloseRequest={onCloseRequest}
|
||||
top={top}
|
||||
left={left}
|
||||
fitInViewport={true}
|
||||
>
|
||||
{options.map((option, idx) => (
|
||||
<li key={idx} onClick={onCloseRequest}>
|
||||
<ContextMenuOption {...option} />
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</Popover>
|
||||
<ul
|
||||
className="context-menu"
|
||||
onContextMenu={(event) => event.preventDefault()}
|
||||
>
|
||||
{options.map(({ action, checked, shortcutName, label }, idx) => (
|
||||
<li data-testid={shortcutName} key={idx} onClick={onCloseRequest}>
|
||||
<button
|
||||
className={`context-menu-option
|
||||
${shortcutName === "delete" ? "dangerous" : ""}
|
||||
${checked ? "checkmark" : ""}`}
|
||||
onClick={action}
|
||||
>
|
||||
<div className="context-menu-option__label">{label}</div>
|
||||
<div className="context-menu-option__shortcut">
|
||||
{shortcutName
|
||||
? getShortcutFromShortcutName(shortcutName)
|
||||
: ""}
|
||||
</div>
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</Popover>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ContextMenuOption({ label, action }: ContextMenuOption) {
|
||||
return (
|
||||
<button className="context-menu-option" onClick={action}>
|
||||
{label}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
let contextMenuNode: HTMLDivElement;
|
||||
function getContextMenuNode(): HTMLDivElement {
|
||||
const getContextMenuNode = (): HTMLDivElement => {
|
||||
if (contextMenuNode) {
|
||||
return contextMenuNode;
|
||||
}
|
||||
const div = document.createElement("div");
|
||||
document.body.appendChild(div);
|
||||
return (contextMenuNode = div);
|
||||
}
|
||||
};
|
||||
|
||||
type ContextMenuParams = {
|
||||
options: (ContextMenuOption | false | null | undefined)[];
|
||||
@@ -62,9 +82,9 @@ type ContextMenuParams = {
|
||||
left: number;
|
||||
};
|
||||
|
||||
function handleClose() {
|
||||
const handleClose = () => {
|
||||
unmountComponentAtNode(getContextMenuNode());
|
||||
}
|
||||
};
|
||||
|
||||
export default {
|
||||
push(params: ContextMenuParams) {
|
||||
|
58
src/components/DarkModeToggle.tsx
Normal file
58
src/components/DarkModeToggle.tsx
Normal file
@@ -0,0 +1,58 @@
|
||||
import "./ToolIcon.scss";
|
||||
|
||||
import React from "react";
|
||||
import { t } from "../i18n";
|
||||
|
||||
export type Appearence = "light" | "dark";
|
||||
|
||||
// We chose to use only explicit toggle and not a third option for system value,
|
||||
// but this could be added in the future.
|
||||
export const DarkModeToggle = (props: {
|
||||
value: Appearence;
|
||||
onChange: (value: Appearence) => void;
|
||||
}) => {
|
||||
return (
|
||||
<label
|
||||
className={`ToolIcon ToolIcon_type_floating ToolIcon_size_M`}
|
||||
title={
|
||||
props.value === "dark" ? t("buttons.lightMode") : t("buttons.darkMode")
|
||||
}
|
||||
>
|
||||
<input
|
||||
className="ToolIcon_type_checkbox ToolIcon_toggle_opaque"
|
||||
type="checkbox"
|
||||
onChange={(event) =>
|
||||
props.onChange(event.target.checked ? "dark" : "light")
|
||||
}
|
||||
checked={props.value === "dark"}
|
||||
aria-label={
|
||||
props.value === "dark"
|
||||
? t("buttons.lightMode")
|
||||
: t("buttons.darkMode")
|
||||
}
|
||||
/>
|
||||
<div className="ToolIcon__icon">
|
||||
{props.value === "light" ? ICONS.MOON : ICONS.SUN}
|
||||
</div>
|
||||
</label>
|
||||
);
|
||||
};
|
||||
|
||||
const ICONS = {
|
||||
SUN: (
|
||||
<svg width="512" height="512" className="rtl-mirror" viewBox="0 0 512 512">
|
||||
<path
|
||||
fill="currentColor"
|
||||
d="M256 160c-52.9 0-96 43.1-96 96s43.1 96 96 96 96-43.1 96-96-43.1-96-96-96zm246.4 80.5l-94.7-47.3 33.5-100.4c4.5-13.6-8.4-26.5-21.9-21.9l-100.4 33.5-47.4-94.8c-6.4-12.8-24.6-12.8-31 0l-47.3 94.7L92.7 70.8c-13.6-4.5-26.5 8.4-21.9 21.9l33.5 100.4-94.7 47.4c-12.8 6.4-12.8 24.6 0 31l94.7 47.3-33.5 100.5c-4.5 13.6 8.4 26.5 21.9 21.9l100.4-33.5 47.3 94.7c6.4 12.8 24.6 12.8 31 0l47.3-94.7 100.4 33.5c13.6 4.5 26.5-8.4 21.9-21.9l-33.5-100.4 94.7-47.3c13-6.5 13-24.7.2-31.1zm-155.9 106c-49.9 49.9-131.1 49.9-181 0-49.9-49.9-49.9-131.1 0-181 49.9-49.9 131.1-49.9 181 0 49.9 49.9 49.9 131.1 0 181z"
|
||||
></path>
|
||||
</svg>
|
||||
),
|
||||
MOON: (
|
||||
<svg width="512" height="512" className="rtl-mirror" viewBox="0 0 512 512">
|
||||
<path
|
||||
fill="currentColor"
|
||||
d="M283.211 512c78.962 0 151.079-35.925 198.857-94.792 7.068-8.708-.639-21.43-11.562-19.35-124.203 23.654-238.262-71.576-238.262-196.954 0-72.222 38.662-138.635 101.498-174.394 9.686-5.512 7.25-20.197-3.756-22.23A258.156 258.156 0 0 0 283.211 0c-141.309 0-256 114.511-256 256 0 141.309 114.511 256 256 256z"
|
||||
></path>
|
||||
</svg>
|
||||
),
|
||||
};
|
@@ -1,60 +1,68 @@
|
||||
@import "../css/_variables";
|
||||
|
||||
.Dialog__title {
|
||||
display: grid;
|
||||
align-items: center;
|
||||
margin-top: 0;
|
||||
grid-template-columns: 1fr calc(var(--space-factor) * 7);
|
||||
grid-gap: var(--metric);
|
||||
}
|
||||
|
||||
.Dialog__titleContent {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.Dialog .Modal__close {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
@media #{$media-query} {
|
||||
.Dialog {
|
||||
--metric: calc(var(--space-factor) * 4);
|
||||
--inset-left: #{"max(var(--metric), var(--sal))"};
|
||||
--inset-right: #{"max(var(--metric), var(--sar))"};
|
||||
}
|
||||
.excalidraw {
|
||||
.Dialog__title {
|
||||
grid-template-columns: calc(var(--space-factor) * 7) 1fr calc(
|
||||
var(--space-factor) * 7
|
||||
);
|
||||
position: sticky;
|
||||
top: calc(-1 * var(--metric));
|
||||
margin: calc(-1 * var(--inset-right));
|
||||
margin-top: calc(-1 * var(--metric));
|
||||
margin-bottom: var(--metric);
|
||||
display: grid;
|
||||
align-items: center;
|
||||
margin-top: 0;
|
||||
grid-template-columns: 1fr calc(var(--space-factor) * 7);
|
||||
grid-gap: var(--metric);
|
||||
padding: calc(var(--space-factor) * 2);
|
||||
padding-left: var(--inset-left);
|
||||
padding-right: var(--inset-right);
|
||||
background: white;
|
||||
font-size: 1.25em;
|
||||
|
||||
box-sizing: border-box;
|
||||
border-bottom: 1px solid $oc-gray-4;
|
||||
z-index: 1;
|
||||
}
|
||||
.Dialog__titleContent {
|
||||
text-align: center;
|
||||
font-variant: small-caps;
|
||||
}
|
||||
.Dialog .Island {
|
||||
width: 100vw;
|
||||
height: 100%;
|
||||
box-sizing: border-box;
|
||||
overflow-y: auto;
|
||||
padding-left: #{"max(calc(var(--padding) * var(--space-factor)), var(--sal))"};
|
||||
padding-right: #{"max(calc(var(--padding) * var(--space-factor)), var(--sar))"};
|
||||
padding-bottom: #{"max(calc(var(--padding) * var(--space-factor)), var(--sab))"};
|
||||
|
||||
.Dialog__titleContent {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.Dialog .Modal__close {
|
||||
order: -1;
|
||||
color: var(--icon-fill-color);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.Dialog__content {
|
||||
padding: 0 16px 16px;
|
||||
}
|
||||
|
||||
@media #{$is-mobile-query} {
|
||||
.Dialog {
|
||||
--metric: calc(var(--space-factor) * 4);
|
||||
--inset-left: #{"max(var(--metric), var(--sal))"};
|
||||
--inset-right: #{"max(var(--metric), var(--sar))"};
|
||||
}
|
||||
|
||||
.Dialog__title {
|
||||
grid-template-columns: calc(var(--space-factor) * 7) 1fr calc(
|
||||
var(--space-factor) * 7
|
||||
);
|
||||
position: sticky;
|
||||
top: 0;
|
||||
padding: calc(var(--space-factor) * 2);
|
||||
background: var(--bg-color-island);
|
||||
font-size: 1.25em;
|
||||
|
||||
box-sizing: border-box;
|
||||
border-bottom: 1px solid var(--button-gray-2);
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.Dialog__titleContent {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.Dialog .Island {
|
||||
width: 100vw;
|
||||
height: 100%;
|
||||
box-sizing: border-box;
|
||||
overflow-y: auto;
|
||||
padding-left: #{"max(calc(var(--padding) * var(--space-factor)), var(--sal))"};
|
||||
padding-right: #{"max(calc(var(--padding) * var(--space-factor)), var(--sar))"};
|
||||
padding-bottom: #{"max(calc(var(--padding) * var(--space-factor)), var(--sab))"};
|
||||
}
|
||||
|
||||
.Dialog .Modal__close {
|
||||
order: -1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@@ -1,39 +1,46 @@
|
||||
import React, { useEffect, useRef } from "react";
|
||||
import { Modal } from "./Modal";
|
||||
import { Island } from "./Island";
|
||||
import clsx from "clsx";
|
||||
import React, { useCallback, useEffect, useState } from "react";
|
||||
import { t } from "../i18n";
|
||||
import useIsMobile from "../is-mobile";
|
||||
import { back, close } from "./icons";
|
||||
import { KEYS } from "../keys";
|
||||
|
||||
import "./Dialog.scss";
|
||||
import { back, close } from "./icons";
|
||||
import { Island } from "./Island";
|
||||
import { Modal } from "./Modal";
|
||||
|
||||
export function Dialog(props: {
|
||||
const useRefState = <T,>() => {
|
||||
const [refValue, setRefValue] = useState<T | null>(null);
|
||||
const refCallback = useCallback((value: T) => {
|
||||
setRefValue(value);
|
||||
}, []);
|
||||
return [refValue, refCallback] as const;
|
||||
};
|
||||
|
||||
export const Dialog = (props: {
|
||||
children: React.ReactNode;
|
||||
className?: string;
|
||||
maxWidth?: number;
|
||||
small?: boolean;
|
||||
onCloseRequest(): void;
|
||||
title: React.ReactNode;
|
||||
}) {
|
||||
const islandRef = useRef<HTMLDivElement>(null);
|
||||
autofocus?: boolean;
|
||||
}) => {
|
||||
const [islandNode, setIslandNode] = useRefState<HTMLDivElement>();
|
||||
|
||||
useEffect(() => {
|
||||
const focusableElements = queryFocusableElements();
|
||||
|
||||
if (focusableElements.length > 0) {
|
||||
// If there's an element other than close, focus it.
|
||||
(focusableElements[1] || focusableElements[0]).focus();
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!islandRef.current) {
|
||||
if (!islandNode) {
|
||||
return;
|
||||
}
|
||||
|
||||
function handleKeyDown(event: KeyboardEvent) {
|
||||
const focusableElements = queryFocusableElements(islandNode);
|
||||
|
||||
if (focusableElements.length > 0 && props.autofocus !== false) {
|
||||
// If there's an element other than close, focus it.
|
||||
(focusableElements[1] || focusableElements[0]).focus();
|
||||
}
|
||||
|
||||
const handleKeyDown = (event: KeyboardEvent) => {
|
||||
if (event.key === KEYS.TAB) {
|
||||
const focusableElements = queryFocusableElements();
|
||||
const focusableElements = queryFocusableElements(islandNode);
|
||||
const { activeElement } = document;
|
||||
const currentIndex = focusableElements.findIndex(
|
||||
(element) => element === activeElement,
|
||||
@@ -50,31 +57,30 @@ export function Dialog(props: {
|
||||
event.preventDefault();
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const node = islandRef.current;
|
||||
node.addEventListener("keydown", handleKeyDown);
|
||||
islandNode.addEventListener("keydown", handleKeyDown);
|
||||
|
||||
return () => node.removeEventListener("keydown", handleKeyDown);
|
||||
}, []);
|
||||
return () => islandNode.removeEventListener("keydown", handleKeyDown);
|
||||
}, [islandNode, props.autofocus]);
|
||||
|
||||
function queryFocusableElements() {
|
||||
const focusableElements = islandRef.current?.querySelectorAll<HTMLElement>(
|
||||
const queryFocusableElements = (node: HTMLElement) => {
|
||||
const focusableElements = node.querySelectorAll<HTMLElement>(
|
||||
"button, a, input, select, textarea, div[tabindex]",
|
||||
);
|
||||
|
||||
return focusableElements ? Array.from(focusableElements) : [];
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal
|
||||
className={`${props.className ?? ""} Dialog`}
|
||||
className={clsx("Dialog", props.className)}
|
||||
labelledBy="dialog-title"
|
||||
maxWidth={props.maxWidth}
|
||||
maxWidth={props.small ? 550 : 800}
|
||||
onCloseRequest={props.onCloseRequest}
|
||||
>
|
||||
<Island padding={4} ref={islandRef}>
|
||||
<h2 id="dialog-title" className="Dialog__title">
|
||||
<Island ref={setIslandNode}>
|
||||
<h3 id="dialog-title" className="Dialog__title">
|
||||
<span className="Dialog__titleContent">{props.title}</span>
|
||||
<button
|
||||
className="Modal__close"
|
||||
@@ -83,9 +89,9 @@ export function Dialog(props: {
|
||||
>
|
||||
{useIsMobile() ? back : close}
|
||||
</button>
|
||||
</h2>
|
||||
{props.children}
|
||||
</h3>
|
||||
<div className="Dialog__content">{props.children}</div>
|
||||
</Island>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
@@ -3,13 +3,13 @@ import { t } from "../i18n";
|
||||
|
||||
import { Dialog } from "./Dialog";
|
||||
|
||||
export function ErrorDialog({
|
||||
export const ErrorDialog = ({
|
||||
message,
|
||||
onClose,
|
||||
}: {
|
||||
message: string;
|
||||
onClose?: () => void;
|
||||
}) {
|
||||
}) => {
|
||||
const [modalIsShown, setModalIsShown] = useState(!!message);
|
||||
|
||||
const handleClose = React.useCallback(() => {
|
||||
@@ -24,13 +24,20 @@ export function ErrorDialog({
|
||||
<>
|
||||
{modalIsShown && (
|
||||
<Dialog
|
||||
maxWidth={500}
|
||||
small
|
||||
onCloseRequest={handleClose}
|
||||
title={t("errorDialog.title")}
|
||||
>
|
||||
<div>{message}</div>
|
||||
<div>
|
||||
{message.split("\n").map((line) => (
|
||||
<>
|
||||
{line}
|
||||
<br />
|
||||
</>
|
||||
))}
|
||||
</div>
|
||||
</Dialog>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
@@ -1,57 +1,69 @@
|
||||
@import "../css/_variables";
|
||||
|
||||
.ExportDialog__preview {
|
||||
--preview-padding: calc(var(--space-factor) * 4);
|
||||
.excalidraw {
|
||||
.ExportDialog__preview {
|
||||
--preview-padding: calc(var(--space-factor) * 4);
|
||||
|
||||
background: url("data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABAAAAAQCAYAAAAf8/9hAAAAMUlEQVQ4T2NkYGAQYcAP3uCTZhw1gGGYhAGBZIA/nYDCgBDAm9BGDWAAJyRCgLaBCAAgXwixzAS0pgAAAABJRU5ErkJggg==")
|
||||
left center;
|
||||
text-align: center;
|
||||
padding: var(--preview-padding);
|
||||
margin-bottom: calc(var(--space-factor) * 3);
|
||||
}
|
||||
|
||||
.ExportDialog__preview canvas {
|
||||
max-width: calc(100% - var(--preview-padding) * 2);
|
||||
max-height: 25rem;
|
||||
}
|
||||
|
||||
.ExportDialog__actions {
|
||||
width: 100%;
|
||||
display: flex;
|
||||
grid-gap: calc(var(--space-factor) * 2);
|
||||
align-items: top;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.ExportDialog__name {
|
||||
grid-column: project-name;
|
||||
margin: auto;
|
||||
}
|
||||
|
||||
@media (max-width: 550px) {
|
||||
.ExportDialog {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
.ExportDialog__actions {
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
}
|
||||
.ExportDialog__actions > * {
|
||||
background: url("data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABAAAAAQCAYAAAAf8/9hAAAAMUlEQVQ4T2NkYGAQYcAP3uCTZhw1gGGYhAGBZIA/nYDCgBDAm9BGDWAAJyRCgLaBCAAgXwixzAS0pgAAAABJRU5ErkJggg==")
|
||||
left center;
|
||||
text-align: center;
|
||||
padding: var(--preview-padding);
|
||||
margin-bottom: calc(var(--space-factor) * 3);
|
||||
}
|
||||
}
|
||||
|
||||
@media #{$media-query} {
|
||||
.ExportDialog__preview canvas {
|
||||
max-height: 30vh;
|
||||
max-width: calc(100% - var(--preview-padding) * 2);
|
||||
max-height: 25rem;
|
||||
}
|
||||
.ExportDialog__dialog,
|
||||
.ExportDialog__dialog .Island {
|
||||
height: 100%;
|
||||
box-sizing: border-box;
|
||||
|
||||
&.Appearance_dark .ExportDialog__preview canvas {
|
||||
filter: none;
|
||||
}
|
||||
.ExportDialog__dialog .Island {
|
||||
overflow-y: auto;
|
||||
|
||||
.ExportDialog__actions {
|
||||
width: 100%;
|
||||
display: flex;
|
||||
grid-gap: calc(var(--space-factor) * 2);
|
||||
align-items: top;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.ExportDialog__name {
|
||||
grid-column: project-name;
|
||||
margin: auto;
|
||||
|
||||
.TextInput {
|
||||
height: calc(1rem - 3px);
|
||||
}
|
||||
}
|
||||
|
||||
@media #{$is-mobile-query} {
|
||||
.ExportDialog {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.ExportDialog__actions {
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.ExportDialog__actions > * {
|
||||
margin-bottom: calc(var(--space-factor) * 3);
|
||||
}
|
||||
|
||||
.ExportDialog__preview canvas {
|
||||
max-height: 30vh;
|
||||
}
|
||||
|
||||
.ExportDialog__dialog,
|
||||
.ExportDialog__dialog .Island {
|
||||
height: 100%;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.ExportDialog__dialog .Island {
|
||||
overflow-y: auto;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@@ -1,30 +1,56 @@
|
||||
import "./ExportDialog.scss";
|
||||
|
||||
import React, { useState, useEffect, useRef } from "react";
|
||||
|
||||
import { ToolButton } from "./ToolButton";
|
||||
import { clipboard, exportFile, link } from "./icons";
|
||||
import { NonDeletedExcalidrawElement } from "../element/types";
|
||||
import { AppState } from "../types";
|
||||
import { exportToCanvas } from "../scene/export";
|
||||
import React, { useEffect, useRef, useState } from "react";
|
||||
import { render, unmountComponentAtNode } from "react-dom";
|
||||
import { ActionsManagerInterface } from "../actions/types";
|
||||
import Stack from "./Stack";
|
||||
import { t } from "../i18n";
|
||||
|
||||
import { EVENT_DIALOG, trackEvent } from "../analytics";
|
||||
import { probablySupportsClipboardBlob } from "../clipboard";
|
||||
import { getSelectedElements, isSomeElementSelected } from "../scene";
|
||||
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;
|
||||
|
||||
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;
|
||||
|
||||
function ExportModal({
|
||||
const ExportModal = ({
|
||||
elements,
|
||||
appState,
|
||||
exportPadding = 10,
|
||||
@@ -41,9 +67,9 @@ function ExportModal({
|
||||
onExportToPng: ExportCB;
|
||||
onExportToSvg: ExportCB;
|
||||
onExportToClipboard: ExportCB;
|
||||
onExportToBackend: ExportCB;
|
||||
onExportToBackend?: ExportCB;
|
||||
onCloseRequest: () => void;
|
||||
}) {
|
||||
}) => {
|
||||
const someElementIsSelected = isSomeElementSelected(elements, appState);
|
||||
const [scale, setScale] = useState(defaultScale);
|
||||
const [exportSelected, setExportSelected] = useState(someElementIsSelected);
|
||||
@@ -64,17 +90,32 @@ function ExportModal({
|
||||
|
||||
useEffect(() => {
|
||||
const previewNode = previewRef.current;
|
||||
const canvas = exportToCanvas(exportedElements, appState, {
|
||||
exportBackground,
|
||||
viewBackgroundColor,
|
||||
exportPadding,
|
||||
scale,
|
||||
shouldAddWatermark,
|
||||
});
|
||||
previewNode?.appendChild(canvas);
|
||||
return () => {
|
||||
previewNode?.removeChild(canvas);
|
||||
};
|
||||
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,
|
||||
@@ -87,7 +128,7 @@ function ExportModal({
|
||||
|
||||
return (
|
||||
<div className="ExportDialog">
|
||||
<div className="ExportDialog__preview" ref={previewRef}></div>
|
||||
<div className="ExportDialog__preview" ref={previewRef} />
|
||||
<Stack.Col gap={2} align="center">
|
||||
<div className="ExportDialog__actions">
|
||||
<Stack.Row gap={2}>
|
||||
@@ -114,31 +155,47 @@ function ExportModal({
|
||||
onClick={() => onExportToClipboard(exportedElements, scale)}
|
||||
/>
|
||||
)}
|
||||
<ToolButton
|
||||
type="button"
|
||||
icon={link}
|
||||
title={t("buttons.getShareableLink")}
|
||||
aria-label={t("buttons.getShareableLink")}
|
||||
onClick={() => onExportToBackend(exportedElements)}
|
||||
/>
|
||||
{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) => (
|
||||
<ToolButton
|
||||
key={s}
|
||||
size="s"
|
||||
type="radio"
|
||||
icon={`x${s}`}
|
||||
name="export-canvas-scale"
|
||||
aria-label={`Scale ${s} x`}
|
||||
id="export-canvas-scale"
|
||||
checked={s === scale}
|
||||
onChange={() => setScale(s)}
|
||||
/>
|
||||
))}
|
||||
{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")}
|
||||
@@ -156,13 +213,14 @@ function ExportModal({
|
||||
</label>
|
||||
</div>
|
||||
)}
|
||||
{actionManager.renderAction("changeExportEmbedScene")}
|
||||
{actionManager.renderAction("changeShouldAddWatermark")}
|
||||
</Stack.Col>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
export function ExportDialog({
|
||||
export const ExportDialog = ({
|
||||
elements,
|
||||
appState,
|
||||
exportPadding = 10,
|
||||
@@ -179,8 +237,8 @@ export function ExportDialog({
|
||||
onExportToPng: ExportCB;
|
||||
onExportToSvg: ExportCB;
|
||||
onExportToClipboard: ExportCB;
|
||||
onExportToBackend: ExportCB;
|
||||
}) {
|
||||
onExportToBackend?: ExportCB;
|
||||
}) => {
|
||||
const [modalIsShown, setModalIsShown] = useState(false);
|
||||
const triggerButton = useRef<HTMLButtonElement>(null);
|
||||
|
||||
@@ -192,7 +250,10 @@ export function ExportDialog({
|
||||
return (
|
||||
<>
|
||||
<ToolButton
|
||||
onClick={() => setModalIsShown(true)}
|
||||
onClick={() => {
|
||||
trackEvent(EVENT_DIALOG, "export");
|
||||
setModalIsShown(true);
|
||||
}}
|
||||
icon={exportFile}
|
||||
type="button"
|
||||
aria-label={t("buttons.export")}
|
||||
@@ -201,11 +262,7 @@ export function ExportDialog({
|
||||
ref={triggerButton}
|
||||
/>
|
||||
{modalIsShown && (
|
||||
<Dialog
|
||||
maxWidth={800}
|
||||
onCloseRequest={handleClose}
|
||||
title={t("buttons.export")}
|
||||
>
|
||||
<Dialog onCloseRequest={handleClose} title={t("buttons.export")}>
|
||||
<ExportModal
|
||||
elements={elements}
|
||||
appState={appState}
|
||||
@@ -221,4 +278,4 @@ export function ExportDialog({
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
@@ -1,36 +0,0 @@
|
||||
.FixedSideContainer {
|
||||
--margin: 0.25rem;
|
||||
position: fixed;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.FixedSideContainer > * {
|
||||
pointer-events: all;
|
||||
}
|
||||
|
||||
.FixedSideContainer_side_top {
|
||||
left: var(--margin);
|
||||
top: var(--margin);
|
||||
right: var(--margin);
|
||||
z-index: 2;
|
||||
}
|
||||
|
||||
.FixedSideContainer_side_top.zen-mode {
|
||||
right: 42px;
|
||||
}
|
||||
|
||||
/* TODO: if these are used, make sure to implement RTL support
|
||||
.FixedSideContainer_side_left {
|
||||
left: var(--margin);
|
||||
top: var(--margin);
|
||||
bottom: var(--margin);
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.FixedSideContainer_side_right {
|
||||
right: var(--margin);
|
||||
top: var(--margin);
|
||||
bottom: var(--margin);
|
||||
z-index: 3;
|
||||
}
|
||||
*/
|
38
src/components/FixedSideContainer.scss
Normal file
38
src/components/FixedSideContainer.scss
Normal file
@@ -0,0 +1,38 @@
|
||||
.excalidraw {
|
||||
.FixedSideContainer {
|
||||
--margin: 0.25rem;
|
||||
position: absolute;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.FixedSideContainer > * {
|
||||
pointer-events: all;
|
||||
}
|
||||
|
||||
.FixedSideContainer_side_top {
|
||||
left: var(--margin);
|
||||
top: var(--margin);
|
||||
right: var(--margin);
|
||||
z-index: 2;
|
||||
}
|
||||
|
||||
.FixedSideContainer_side_top.zen-mode {
|
||||
right: 42px;
|
||||
}
|
||||
}
|
||||
|
||||
/* TODO: if these are used, make sure to implement RTL support
|
||||
.FixedSideContainer_side_left {
|
||||
left: var(--margin);
|
||||
top: var(--margin);
|
||||
bottom: var(--margin);
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.FixedSideContainer_side_right {
|
||||
right: var(--margin);
|
||||
top: var(--margin);
|
||||
bottom: var(--margin);
|
||||
z-index: 3;
|
||||
}
|
||||
*/
|
@@ -1,6 +1,7 @@
|
||||
import "./FixedSideContainer.css";
|
||||
import "./FixedSideContainer.scss";
|
||||
|
||||
import React from "react";
|
||||
import clsx from "clsx";
|
||||
|
||||
type FixedSideContainerProps = {
|
||||
children: React.ReactNode;
|
||||
@@ -8,16 +9,18 @@ type FixedSideContainerProps = {
|
||||
className?: string;
|
||||
};
|
||||
|
||||
export function FixedSideContainer({
|
||||
export const FixedSideContainer = ({
|
||||
children,
|
||||
side,
|
||||
className,
|
||||
}: FixedSideContainerProps) {
|
||||
return (
|
||||
<div
|
||||
className={`FixedSideContainer FixedSideContainer_side_${side} ${className}`}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}: FixedSideContainerProps) => (
|
||||
<div
|
||||
className={clsx(
|
||||
"FixedSideContainer",
|
||||
`FixedSideContainer_side_${side}`,
|
||||
className,
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
|
@@ -1,33 +1,42 @@
|
||||
import React from "react";
|
||||
import oc from "open-color";
|
||||
import { EVENT_EXIT, trackEvent } from "../analytics";
|
||||
|
||||
// https://github.com/tholman/github-corners
|
||||
export const GitHubCorner = React.memo(() => (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="40"
|
||||
height="40"
|
||||
viewBox="0 0 250 250"
|
||||
className="github-corner rtl-mirror"
|
||||
>
|
||||
<a
|
||||
href="https://github.com/excalidraw/excalidraw"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
aria-label="GitHub repository"
|
||||
export const GitHubCorner = React.memo(
|
||||
({ appearance }: { appearance: "light" | "dark" }) => (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="40"
|
||||
height="40"
|
||||
viewBox="0 0 250 250"
|
||||
className="github-corner rtl-mirror"
|
||||
>
|
||||
<path d="M0 0l115 115h15l12 27 108 108V0z" fill={oc.gray[6]} />
|
||||
<path
|
||||
className="octo-arm"
|
||||
d="M128 109c-15-9-9-19-9-19 3-7 2-11 2-11-1-7 3-2 3-2 4 5 2 11 2 11-3 10 5 15 9 16"
|
||||
style={{ transformOrigin: "130px 106px" }}
|
||||
fill={oc.white}
|
||||
/>
|
||||
<path
|
||||
className="octo-body"
|
||||
d="M115 115s4 2 5 0l14-14c3-2 6-3 8-3-8-11-15-24 2-41 5-5 10-7 16-7 1-2 3-7 12-11 0 0 5 3 7 16 4 2 8 5 12 9s7 8 9 12c14 3 17 7 17 7-4 8-9 11-11 11 0 6-2 11-7 16-16 16-30 10-41 2 0 3-1 7-5 11l-12 11c-1 1 1 5 1 5z"
|
||||
fill={oc.white}
|
||||
/>
|
||||
</a>
|
||||
</svg>
|
||||
));
|
||||
<a
|
||||
href="https://github.com/excalidraw/excalidraw"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
aria-label="GitHub repository"
|
||||
onClick={() => {
|
||||
trackEvent(EVENT_EXIT, "github");
|
||||
}}
|
||||
>
|
||||
<path
|
||||
d="M0 0l115 115h15l12 27 108 108V0z"
|
||||
fill={appearance === "light" ? oc.gray[6] : oc.gray[8]}
|
||||
/>
|
||||
<path
|
||||
className="octo-arm"
|
||||
d="M128 109c-15-9-9-19-9-19 3-7 2-11 2-11-1-7 3-2 3-2 4 5 2 11 2 11-3 10 5 15 9 16"
|
||||
style={{ transformOrigin: "130px 106px" }}
|
||||
fill={appearance === "light" ? oc.white : oc.black}
|
||||
/>
|
||||
<path
|
||||
className="octo-body"
|
||||
d="M115 115s4 2 5 0l14-14c3-2 6-3 8-3-8-11-15-24 2-41 5-5 10-7 16-7 1-2 3-7 12-11 0 0 5 3 7 16 4 2 8 5 12 9s7 8 9 12c14 3 17 7 17 7-4 8-9 11-11 11 0 6-2 11-7 16-16 16-30 10-41 2 0 3-1 7-5 11l-12 11c-1 1 1 5 1 5z"
|
||||
fill={appearance === "light" ? oc.white : oc.black}
|
||||
/>
|
||||
</a>
|
||||
</svg>
|
||||
),
|
||||
);
|
||||
|
@@ -18,10 +18,8 @@ const ICON = (
|
||||
</svg>
|
||||
);
|
||||
|
||||
export function HelpIcon(props: HelpIconProps) {
|
||||
return (
|
||||
<label title={`${props.title} — ?`} className="help-icon">
|
||||
<div onClick={props.onClick}>{ICON}</div>
|
||||
</label>
|
||||
);
|
||||
}
|
||||
export const HelpIcon = (props: HelpIconProps) => (
|
||||
<label title={`${props.title} — ?`} className="help-icon">
|
||||
<div onClick={props.onClick}>{ICON}</div>
|
||||
</label>
|
||||
);
|
||||
|
@@ -1,24 +1,33 @@
|
||||
@import "../css/_variables";
|
||||
|
||||
.HintViewer {
|
||||
color: $oc-gray-6;
|
||||
font-size: 0.8rem;
|
||||
left: 50%;
|
||||
pointer-events: none;
|
||||
position: absolute;
|
||||
top: 54px;
|
||||
transform: translateX(calc(-50% - 16px)); /* 16px is half of lock icon */
|
||||
white-space: pre;
|
||||
text-align: center;
|
||||
@media #{$media-query} {
|
||||
position: static;
|
||||
transform: none;
|
||||
margin-top: 0.5rem;
|
||||
}
|
||||
// this is loosely based on the longest hint text
|
||||
$wide-viewport-width: 1000px;
|
||||
|
||||
> span {
|
||||
background-color: transparentize($oc-white, 0.12);
|
||||
padding: 0.2rem 0.4rem;
|
||||
border-radius: 3px;
|
||||
.excalidraw {
|
||||
.HintViewer {
|
||||
pointer-events: none;
|
||||
box-sizing: border-box;
|
||||
position: absolute;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
left: 0;
|
||||
top: 100%;
|
||||
max-width: 100%;
|
||||
width: 100%;
|
||||
margin-top: 6px;
|
||||
text-align: center;
|
||||
color: $oc-gray-6;
|
||||
font-size: 0.8rem;
|
||||
|
||||
@media #{$is-mobile-query} {
|
||||
position: static;
|
||||
padding-right: 2em;
|
||||
}
|
||||
|
||||
> span {
|
||||
padding: 0.2rem 0.4rem;
|
||||
background-color: var(--overlay-background-color);
|
||||
border-radius: 4px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@@ -6,6 +6,7 @@ import { getSelectedElements } from "../scene";
|
||||
import "./HintViewer.scss";
|
||||
import { AppState } from "../types";
|
||||
import { isLinearElement } from "../element/typeChecks";
|
||||
import { getShortcutKey } from "../utils";
|
||||
|
||||
interface Hint {
|
||||
appState: AppState;
|
||||
@@ -26,6 +27,10 @@ const getHints = ({ appState, elements }: Hint) => {
|
||||
return t("hints.freeDraw");
|
||||
}
|
||||
|
||||
if (elementType === "text") {
|
||||
return t("hints.text");
|
||||
}
|
||||
|
||||
const selectedElements = getSelectedElements(elements, appState);
|
||||
if (
|
||||
isResizing &&
|
||||
@@ -33,8 +38,8 @@ const getHints = ({ appState, elements }: Hint) => {
|
||||
selectedElements.length === 1
|
||||
) {
|
||||
const targetElement = selectedElements[0];
|
||||
if (isLinearElement(targetElement) && targetElement.points.length > 2) {
|
||||
return null;
|
||||
if (isLinearElement(targetElement) && targetElement.points.length === 2) {
|
||||
return t("hints.lockAngle");
|
||||
}
|
||||
return t("hints.resize");
|
||||
}
|
||||
@@ -43,11 +48,20 @@ const getHints = ({ appState, elements }: Hint) => {
|
||||
return t("hints.rotate");
|
||||
}
|
||||
|
||||
if (selectedElements.length === 1 && isLinearElement(selectedElements[0])) {
|
||||
if (appState.editingLinearElement) {
|
||||
return appState.editingLinearElement.activePointIndex
|
||||
? t("hints.lineEditor_pointSelected")
|
||||
: t("hints.lineEditor_nothingSelected");
|
||||
}
|
||||
return t("hints.lineEditor_info");
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
export const HintViewer = ({ appState, elements }: Hint) => {
|
||||
const hint = getHints({
|
||||
let hint = getHints({
|
||||
appState,
|
||||
elements,
|
||||
});
|
||||
@@ -55,6 +69,8 @@ export const HintViewer = ({ appState, elements }: Hint) => {
|
||||
return null;
|
||||
}
|
||||
|
||||
hint = getShortcutKey(hint);
|
||||
|
||||
return (
|
||||
<div className="HintViewer">
|
||||
<span>{hint}</span>
|
||||
|
142
src/components/IconPicker.scss
Normal file
142
src/components/IconPicker.scss
Normal file
@@ -0,0 +1,142 @@
|
||||
@import "../css/_variables";
|
||||
|
||||
.excalidraw {
|
||||
.picker-container {
|
||||
display: inline-block;
|
||||
box-sizing: border-box;
|
||||
margin-right: 0.25rem;
|
||||
}
|
||||
|
||||
.picker {
|
||||
background: var(--popup-background-color);
|
||||
border: 0px solid transparentize($oc-white, 0.75);
|
||||
box-shadow: transparentize($oc-black, 0.75) 0px 1px 4px;
|
||||
border-radius: 4px;
|
||||
position: absolute;
|
||||
}
|
||||
|
||||
.picker-container button,
|
||||
.picker button {
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
|
||||
&:focus {
|
||||
outline: transparent;
|
||||
background-color: var(--button-gray-2);
|
||||
& svg {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
&:hover {
|
||||
background-color: var(--button-gray-2);
|
||||
}
|
||||
|
||||
&:active {
|
||||
background-color: var(--button-gray-3);
|
||||
}
|
||||
|
||||
&:disabled {
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
svg {
|
||||
margin: 0;
|
||||
width: 36px;
|
||||
height: 18px;
|
||||
opacity: 0.6;
|
||||
pointer-events: none;
|
||||
}
|
||||
}
|
||||
|
||||
.picker button {
|
||||
padding: 0.25rem 0.28rem 0.35rem 0.25rem;
|
||||
}
|
||||
|
||||
.picker-triangle {
|
||||
width: 0px;
|
||||
height: 0px;
|
||||
position: relative;
|
||||
top: -10px;
|
||||
:root[dir="ltr"] & {
|
||||
left: 12px;
|
||||
}
|
||||
|
||||
:root[dir="rtl"] & {
|
||||
right: 12px;
|
||||
}
|
||||
z-index: 10;
|
||||
|
||||
&:before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
border-style: solid;
|
||||
border-width: 0px 9px 10px;
|
||||
border-color: transparent transparent transparentize($oc-black, 0.9);
|
||||
top: -1px;
|
||||
}
|
||||
|
||||
&:after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
border-style: solid;
|
||||
border-width: 0px 9px 10px;
|
||||
border-color: transparent transparent var(--popup-background-color);
|
||||
}
|
||||
}
|
||||
|
||||
.picker-content {
|
||||
padding: 0.5rem;
|
||||
display: grid;
|
||||
grid-auto-flow: column;
|
||||
grid-gap: 0.5rem;
|
||||
border-radius: 4px;
|
||||
:root[dir="rtl"] & {
|
||||
padding: 0.4rem;
|
||||
}
|
||||
}
|
||||
|
||||
.picker-keybinding {
|
||||
position: absolute;
|
||||
bottom: 2px;
|
||||
font-size: 0.7em;
|
||||
|
||||
:root[dir="ltr"] & {
|
||||
right: 2px;
|
||||
}
|
||||
|
||||
:root[dir="rtl"] & {
|
||||
left: 2px;
|
||||
}
|
||||
@media #{$is-mobile-query} {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
.picker-type-canvasBackground .picker-keybinding {
|
||||
color: #aaa;
|
||||
}
|
||||
|
||||
.picker-type-elementBackground .picker-keybinding {
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.picker-swatch[aria-label="transparent"] .picker-keybinding {
|
||||
color: #aaa;
|
||||
}
|
||||
|
||||
.picker-type-elementStroke .picker-keybinding {
|
||||
color: #d4d4d4;
|
||||
}
|
||||
|
||||
&.Appearance_dark {
|
||||
.picker-type-elementBackground .picker-keybinding {
|
||||
color: #000;
|
||||
}
|
||||
.picker-swatch[aria-label="transparent"] .picker-keybinding {
|
||||
color: #000;
|
||||
}
|
||||
}
|
||||
}
|
188
src/components/IconPicker.tsx
Normal file
188
src/components/IconPicker.tsx
Normal file
@@ -0,0 +1,188 @@
|
||||
import React from "react";
|
||||
import { Popover } from "./Popover";
|
||||
|
||||
import "./IconPicker.scss";
|
||||
import { isArrowKey, KEYS } from "../keys";
|
||||
import { getLanguage } from "../i18n";
|
||||
|
||||
function Picker<T>({
|
||||
options,
|
||||
value,
|
||||
label,
|
||||
onChange,
|
||||
onClose,
|
||||
}: {
|
||||
label: string;
|
||||
value: T;
|
||||
options: { value: T; text: string; icon: JSX.Element; keyBinding: string }[];
|
||||
onChange: (value: T) => void;
|
||||
onClose: () => void;
|
||||
}) {
|
||||
const rFirstItem = React.useRef<HTMLButtonElement>();
|
||||
const rActiveItem = React.useRef<HTMLButtonElement>();
|
||||
const rGallery = React.useRef<HTMLDivElement>(null);
|
||||
|
||||
React.useEffect(() => {
|
||||
// After the component is first mounted focus on first input
|
||||
if (rActiveItem.current) {
|
||||
rActiveItem.current.focus();
|
||||
} else if (rGallery.current) {
|
||||
rGallery.current.focus();
|
||||
}
|
||||
}, []);
|
||||
|
||||
const handleKeyDown = (event: React.KeyboardEvent) => {
|
||||
const pressedOption = options.find(
|
||||
(option) => option.keyBinding === event.key.toLowerCase(),
|
||||
)!;
|
||||
|
||||
if (!(event.metaKey || event.altKey || event.ctrlKey) && pressedOption) {
|
||||
// Keybinding navigation
|
||||
const index = options.indexOf(pressedOption);
|
||||
(rGallery!.current!.children![index] as any).focus();
|
||||
event.preventDefault();
|
||||
} else if (event.key === KEYS.TAB) {
|
||||
// Tab navigation cycle through options. If the user tabs
|
||||
// away from the picker, close the picker. We need to use
|
||||
// a timeout here to let the stack clear before checking.
|
||||
setTimeout(() => {
|
||||
const active = rActiveItem.current;
|
||||
const docActive = document.activeElement;
|
||||
if (active !== docActive) {
|
||||
onClose();
|
||||
}
|
||||
}, 0);
|
||||
} else if (isArrowKey(event.key)) {
|
||||
// Arrow navigation
|
||||
const { activeElement } = document;
|
||||
const isRTL = getLanguage().rtl;
|
||||
const index = Array.prototype.indexOf.call(
|
||||
rGallery!.current!.children,
|
||||
activeElement,
|
||||
);
|
||||
if (index !== -1) {
|
||||
const length = options.length;
|
||||
let nextIndex = index;
|
||||
|
||||
switch (event.key) {
|
||||
// Select the next option
|
||||
case isRTL ? KEYS.ARROW_LEFT : KEYS.ARROW_RIGHT:
|
||||
case KEYS.ARROW_DOWN: {
|
||||
nextIndex = (index + 1) % length;
|
||||
break;
|
||||
}
|
||||
// Select the previous option
|
||||
case isRTL ? KEYS.ARROW_RIGHT : KEYS.ARROW_LEFT:
|
||||
case KEYS.ARROW_UP: {
|
||||
nextIndex = (length + index - 1) % length;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
(rGallery.current!.children![nextIndex] as any).focus();
|
||||
}
|
||||
event.preventDefault();
|
||||
} else if (event.key === KEYS.ESCAPE || event.key === KEYS.ENTER) {
|
||||
// Close on escape or enter
|
||||
event.preventDefault();
|
||||
onClose();
|
||||
}
|
||||
event.nativeEvent.stopImmediatePropagation();
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`picker`}
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label={label}
|
||||
onKeyDown={handleKeyDown}
|
||||
>
|
||||
<div className="picker-content" ref={rGallery}>
|
||||
{options.map((option, i) => (
|
||||
<button
|
||||
className="picker-option"
|
||||
onClick={(event) => {
|
||||
(event.currentTarget as HTMLButtonElement).focus();
|
||||
onChange(option.value);
|
||||
}}
|
||||
title={`${option.text} — ${option.keyBinding.toUpperCase()}`}
|
||||
aria-label={option.text || "none"}
|
||||
aria-keyshortcuts={option.keyBinding}
|
||||
key={option.text}
|
||||
ref={(el) => {
|
||||
if (el && i === 0) {
|
||||
rFirstItem.current = el;
|
||||
}
|
||||
if (el && option.value === value) {
|
||||
rActiveItem.current = el;
|
||||
}
|
||||
}}
|
||||
onFocus={() => {
|
||||
onChange(option.value);
|
||||
}}
|
||||
>
|
||||
{option.icon}
|
||||
<span className="picker-keybinding">{option.keyBinding}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function IconPicker<T>({
|
||||
value,
|
||||
label,
|
||||
options,
|
||||
onChange,
|
||||
group = "",
|
||||
}: {
|
||||
label: string;
|
||||
value: T;
|
||||
options: { value: T; text: string; icon: JSX.Element; keyBinding: string }[];
|
||||
onChange: (value: T) => void;
|
||||
group?: string;
|
||||
}) {
|
||||
const [isActive, setActive] = React.useState(false);
|
||||
const rPickerButton = React.useRef<any>(null);
|
||||
const isRTL = getLanguage().rtl;
|
||||
|
||||
return (
|
||||
<label className={"picker-container"}>
|
||||
<button
|
||||
name={group}
|
||||
className={isActive ? "active" : ""}
|
||||
aria-label={label}
|
||||
onClick={() => setActive(!isActive)}
|
||||
ref={rPickerButton}
|
||||
>
|
||||
{options.find((option) => option.value === value)?.icon}
|
||||
</button>
|
||||
<React.Suspense fallback="">
|
||||
{isActive ? (
|
||||
<>
|
||||
<Popover
|
||||
onCloseRequest={(event) =>
|
||||
event.target !== rPickerButton.current && setActive(false)
|
||||
}
|
||||
{...(isRTL ? { right: 5.5 } : { left: -5.5 })}
|
||||
>
|
||||
<Picker
|
||||
options={options}
|
||||
value={value}
|
||||
label={label}
|
||||
onChange={onChange}
|
||||
onClose={() => {
|
||||
setActive(false);
|
||||
rPickerButton.current?.focus();
|
||||
}}
|
||||
/>
|
||||
</Popover>
|
||||
<div className="picker-triangle" />
|
||||
</>
|
||||
) : null}
|
||||
</React.Suspense>
|
||||
</label>
|
||||
);
|
||||
}
|
35
src/components/InitializeApp.tsx
Normal file
35
src/components/InitializeApp.tsx
Normal file
@@ -0,0 +1,35 @@
|
||||
import React from "react";
|
||||
|
||||
import { LoadingMessage } from "./LoadingMessage";
|
||||
import {
|
||||
defaultLang,
|
||||
Language,
|
||||
languages,
|
||||
setLanguageFirstTime,
|
||||
} from "../i18n";
|
||||
|
||||
interface Props {
|
||||
langCode: Language["code"];
|
||||
}
|
||||
interface State {
|
||||
isLoading: boolean;
|
||||
}
|
||||
export class InitializeApp extends React.Component<Props, State> {
|
||||
public state: { isLoading: boolean } = {
|
||||
isLoading: true,
|
||||
};
|
||||
|
||||
async componentDidMount() {
|
||||
const currentLang =
|
||||
languages.find((lang) => lang.code === this.props.langCode) ||
|
||||
defaultLang;
|
||||
await setLanguageFirstTime(currentLang);
|
||||
this.setState({
|
||||
isLoading: false,
|
||||
});
|
||||
}
|
||||
|
||||
public render() {
|
||||
return this.state.isLoading ? <LoadingMessage /> : this.props.children;
|
||||
}
|
||||
}
|
@@ -1,14 +1,16 @@
|
||||
.Island {
|
||||
--padding: 0;
|
||||
background-color: var(--bg-color-island);
|
||||
backdrop-filter: saturate(100%) blur(10px);
|
||||
box-shadow: var(--shadow-island);
|
||||
border-radius: var(--border-radius-m);
|
||||
padding: calc(var(--padding) * var(--space-factor));
|
||||
position: relative;
|
||||
transition: box-shadow 0.5s ease-in-out;
|
||||
.excalidraw {
|
||||
.Island {
|
||||
--padding: 0;
|
||||
background-color: var(--bg-color-island);
|
||||
backdrop-filter: saturate(100%) blur(10px);
|
||||
box-shadow: var(--shadow-island);
|
||||
border-radius: 4px;
|
||||
padding: calc(var(--padding) * var(--space-factor));
|
||||
position: relative;
|
||||
transition: box-shadow 0.5s ease-in-out;
|
||||
|
||||
&.zen-mode {
|
||||
box-shadow: none;
|
||||
&.zen-mode {
|
||||
box-shadow: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@@ -1,6 +1,7 @@
|
||||
import "./Island.scss";
|
||||
|
||||
import React from "react";
|
||||
import clsx from "clsx";
|
||||
|
||||
type IslandProps = {
|
||||
children: React.ReactNode;
|
||||
@@ -12,8 +13,8 @@ type IslandProps = {
|
||||
export const Island = React.forwardRef<HTMLDivElement, IslandProps>(
|
||||
({ children, padding, className, style }, ref) => (
|
||||
<div
|
||||
className={`${className ?? ""} Island`}
|
||||
style={{ "--padding": padding, ...style } as React.CSSProperties}
|
||||
className={clsx("Island", className)}
|
||||
style={{ "--padding": padding, ...style }}
|
||||
ref={ref}
|
||||
>
|
||||
{children}
|
||||
|
@@ -1,33 +0,0 @@
|
||||
import React from "react";
|
||||
import * as i18n from "../i18n";
|
||||
|
||||
export function LanguageList({
|
||||
onChange,
|
||||
languages = i18n.languages,
|
||||
currentLanguage = i18n.getLanguage().lng,
|
||||
floating,
|
||||
}: {
|
||||
languages?: { lng: string; label: string }[];
|
||||
onChange: (value: string) => void;
|
||||
currentLanguage?: string;
|
||||
floating?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<React.Fragment>
|
||||
<select
|
||||
className={`dropdown-select dropdown-select__language${
|
||||
floating ? " dropdown-select--floating" : ""
|
||||
}`}
|
||||
onChange={({ target }) => onChange(target.value)}
|
||||
value={currentLanguage}
|
||||
aria-label={i18n.t("buttons.selectLanguage")}
|
||||
>
|
||||
{languages.map((language) => (
|
||||
<option key={language.lng} value={language.lng}>
|
||||
{language.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</React.Fragment>
|
||||
);
|
||||
}
|
@@ -1,133 +1,139 @@
|
||||
@import "open-color/open-color";
|
||||
|
||||
.layer-ui__wrapper {
|
||||
.encrypted-icon {
|
||||
position: relative;
|
||||
margin-inline-start: 15px;
|
||||
.excalidraw {
|
||||
.layer-ui__library {
|
||||
margin: auto;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
border-radius: var(--space-factor);
|
||||
color: $oc-green-9;
|
||||
justify-content: center;
|
||||
|
||||
svg {
|
||||
width: 1.2rem;
|
||||
height: 1.2rem;
|
||||
.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-left: auto;
|
||||
// 17px for scrollbar (needed for overlay scrollbars on Big Sur?) + 1px extra
|
||||
padding-right: 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 {
|
||||
.encrypted-icon {
|
||||
position: relative;
|
||||
margin-inline-start: 15px;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
border-radius: var(--space-factor);
|
||||
color: $oc-green-9;
|
||||
|
||||
svg {
|
||||
width: 1.2rem;
|
||||
height: 1.2rem;
|
||||
}
|
||||
}
|
||||
|
||||
&.tooltip .tooltip-text {
|
||||
visibility: hidden;
|
||||
width: 20rem;
|
||||
bottom: calc(50% + 0.8rem + 6px);
|
||||
&__github-corner {
|
||||
top: 0;
|
||||
|
||||
:root[dir="ltr"] & {
|
||||
left: -5px;
|
||||
right: 0;
|
||||
}
|
||||
|
||||
:root[dir="rtl"] & {
|
||||
right: -5px;
|
||||
left: 0;
|
||||
}
|
||||
background-color: $oc-black;
|
||||
color: $oc-white;
|
||||
text-align: center;
|
||||
border-radius: 6px;
|
||||
padding: 5px;
|
||||
|
||||
position: absolute;
|
||||
z-index: 10;
|
||||
font-size: 13px;
|
||||
line-height: 1.5;
|
||||
white-space: pre-wrap;
|
||||
width: 40px;
|
||||
}
|
||||
|
||||
&::after {
|
||||
--size: 6px;
|
||||
content: "";
|
||||
border: var(--size) solid transparent;
|
||||
border-top-color: $oc-black;
|
||||
position: absolute;
|
||||
bottom: calc(-2 * var(--size));
|
||||
:root[dir="ltr"] & {
|
||||
left: calc(5px + var(--size) / 2);
|
||||
}
|
||||
:root[dir="rtl"] & {
|
||||
right: calc(5px + var(--size) / 2);
|
||||
}
|
||||
&__footer {
|
||||
position: absolute;
|
||||
z-index: 100;
|
||||
bottom: 0px;
|
||||
|
||||
:root[dir="ltr"] & {
|
||||
right: 0;
|
||||
}
|
||||
|
||||
:root[dir="rtl"] & {
|
||||
left: 0;
|
||||
}
|
||||
|
||||
width: 190px;
|
||||
}
|
||||
|
||||
.zen-mode-transition {
|
||||
transition: transform 0.5s ease-in-out;
|
||||
|
||||
:root[dir="ltr"] &.transition-left {
|
||||
transform: translate(-999px, 0);
|
||||
}
|
||||
|
||||
:root[dir="ltr"] &.transition-right {
|
||||
transform: translate(999px, 0px);
|
||||
}
|
||||
|
||||
:root[dir="rtl"] &.transition-left {
|
||||
transform: translate(999px, 0);
|
||||
}
|
||||
|
||||
:root[dir="rtl"] &.transition-right {
|
||||
transform: translate(-999px, 0);
|
||||
}
|
||||
|
||||
:root[dir="ltr"] &.App-menu_bottom--transition-left {
|
||||
transform: translate(-92px, 0);
|
||||
}
|
||||
:root[dir="rtl"] &.App-menu_bottom--transition-left {
|
||||
transform: translate(92px, 0);
|
||||
}
|
||||
}
|
||||
|
||||
// the following 3 rules ensure that the tooltip doesn't show (nor affect
|
||||
// the cursor) when you drag over when you draw on canvas, but at the same
|
||||
// time it still works when clicking on the link/shield
|
||||
.disable-zen-mode {
|
||||
height: 30px;
|
||||
position: absolute;
|
||||
bottom: 10px;
|
||||
[dir="ltr"] & {
|
||||
right: 15px;
|
||||
}
|
||||
[dir="rtl"] & {
|
||||
left: 15px;
|
||||
}
|
||||
font-size: 10px;
|
||||
padding: 10px;
|
||||
font-weight: 500;
|
||||
opacity: 0;
|
||||
visibility: hidden;
|
||||
transition: visibility 0s linear 0s, opacity 0.5s;
|
||||
|
||||
body:active &.tooltip:not(:hover) {
|
||||
pointer-events: none;
|
||||
}
|
||||
body:not(:active) &.tooltip:hover .tooltip-text {
|
||||
visibility: visible;
|
||||
}
|
||||
.tooltip-text:hover {
|
||||
visibility: visible;
|
||||
}
|
||||
}
|
||||
|
||||
&__github-corner {
|
||||
top: 0;
|
||||
:root[dir="ltr"] & {
|
||||
right: 0;
|
||||
}
|
||||
:root[dir="rtl"] & {
|
||||
left: 0;
|
||||
}
|
||||
position: absolute;
|
||||
width: 40px;
|
||||
}
|
||||
|
||||
&__footer {
|
||||
position: absolute;
|
||||
bottom: 0px;
|
||||
:root[dir="ltr"] & {
|
||||
right: 0;
|
||||
}
|
||||
:root[dir="rtl"] & {
|
||||
left: 0;
|
||||
}
|
||||
width: 190px;
|
||||
}
|
||||
|
||||
.zen-mode-transition {
|
||||
transition: transform 0.5s ease-in-out;
|
||||
:root[dir="ltr"] &.transition-left {
|
||||
transform: translate(-999px, 0);
|
||||
}
|
||||
:root[dir="ltr"] &.transition-right {
|
||||
transform: translate(999px, 0px);
|
||||
}
|
||||
:root[dir="rtl"] &.transition-left {
|
||||
transform: translate(999px, 0);
|
||||
}
|
||||
:root[dir="rtl"] &.transition-right {
|
||||
transform: translate(-999px, 0);
|
||||
}
|
||||
|
||||
&.App-menu_bottom--transition-left {
|
||||
transform: translate(-92px, 0);
|
||||
}
|
||||
}
|
||||
|
||||
.disable-zen-mode {
|
||||
height: 30px;
|
||||
position: absolute;
|
||||
bottom: 10px;
|
||||
right: 15px;
|
||||
font-size: 10px;
|
||||
padding: 10px;
|
||||
font-weight: 500;
|
||||
opacity: 0;
|
||||
visibility: hidden;
|
||||
transition: visibility 0s linear 0s, opacity 0.5s;
|
||||
|
||||
&--visible {
|
||||
opacity: 1;
|
||||
visibility: visible;
|
||||
transition: visibility 0s linear 300ms, opacity 0.5s;
|
||||
transition-delay: 0.8s;
|
||||
&--visible {
|
||||
opacity: 1;
|
||||
visibility: visible;
|
||||
transition: visibility 0s linear 300ms, opacity 0.5s;
|
||||
transition-delay: 0.8s;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@@ -1,19 +1,25 @@
|
||||
import React from "react";
|
||||
import React, {
|
||||
useRef,
|
||||
useState,
|
||||
RefObject,
|
||||
useEffect,
|
||||
useCallback,
|
||||
} from "react";
|
||||
import { showSelectedShapeActions } from "../element";
|
||||
import { calculateScrollCenter } from "../scene";
|
||||
import { calculateScrollCenter, getSelectedElements } from "../scene";
|
||||
import { exportCanvas } from "../data";
|
||||
|
||||
import { AppState } from "../types";
|
||||
import { AppState, LibraryItems, LibraryItem } from "../types";
|
||||
import { NonDeletedExcalidrawElement } from "../element/types";
|
||||
|
||||
import { ActionManager } from "../actions/manager";
|
||||
import { Island } from "./Island";
|
||||
import Stack from "./Stack";
|
||||
import { FixedSideContainer } from "./FixedSideContainer";
|
||||
import { UserList } from "./UserList";
|
||||
import { LockIcon } from "./LockIcon";
|
||||
import { ExportDialog, ExportCB } from "./ExportDialog";
|
||||
import { LanguageList } from "./LanguageList";
|
||||
import { t, languages, setLanguage } from "../i18n";
|
||||
import { Language, t } from "../i18n";
|
||||
import { HintViewer } from "./HintViewer";
|
||||
import useIsMobile from "../is-mobile";
|
||||
|
||||
@@ -21,76 +27,338 @@ import { ExportType } from "../scene/types";
|
||||
import { MobileMenu } from "./MobileMenu";
|
||||
import { ZoomActions, SelectedShapeActions, ShapesSwitcher } from "./Actions";
|
||||
import { Section } from "./Section";
|
||||
import { RoomDialog } from "./RoomDialog";
|
||||
import CollabButton from "./CollabButton";
|
||||
import { ErrorDialog } from "./ErrorDialog";
|
||||
import { ShortcutsDialog } from "./ShortcutsDialog";
|
||||
import { LoadingMessage } from "./LoadingMessage";
|
||||
import { CLASSES } from "../constants";
|
||||
import { shield } from "./icons";
|
||||
import { shield, exportFile, load } from "./icons";
|
||||
import { GitHubCorner } from "./GitHubCorner";
|
||||
import { Tooltip } from "./Tooltip";
|
||||
|
||||
import "./LayerUI.scss";
|
||||
import { LibraryUnit } from "./LibraryUnit";
|
||||
import { ToolButton } from "./ToolButton";
|
||||
import { saveLibraryAsJSON, importLibraryFromJSON } from "../data/json";
|
||||
import { muteFSAbortError } from "../utils";
|
||||
import { BackgroundPickerAndDarkModeToggle } from "./BackgroundPickerAndDarkModeToggle";
|
||||
import clsx from "clsx";
|
||||
import { Library } from "../data/library";
|
||||
import {
|
||||
EVENT_ACTION,
|
||||
EVENT_EXIT,
|
||||
EVENT_LIBRARY,
|
||||
trackEvent,
|
||||
} from "../analytics";
|
||||
import { PasteChartDialog } from "./PasteChartDialog";
|
||||
|
||||
interface LayerUIProps {
|
||||
actionManager: ActionManager;
|
||||
appState: AppState;
|
||||
canvas: HTMLCanvasElement | null;
|
||||
setAppState: any;
|
||||
setAppState: React.Component<any, AppState>["setState"];
|
||||
elements: readonly NonDeletedExcalidrawElement[];
|
||||
onRoomCreate: () => void;
|
||||
onUsernameChange: (username: string) => void;
|
||||
onRoomDestroy: () => void;
|
||||
onCollabButtonClick?: () => void;
|
||||
onLockToggle: () => void;
|
||||
onInsertElements: (elements: readonly NonDeletedExcalidrawElement[]) => void;
|
||||
zenModeEnabled: boolean;
|
||||
toggleZenMode: () => void;
|
||||
langCode: Language["code"];
|
||||
isCollaborating: boolean;
|
||||
onExportToBackend?: (
|
||||
exportedElements: readonly NonDeletedExcalidrawElement[],
|
||||
appState: AppState,
|
||||
canvas: HTMLCanvasElement | null,
|
||||
) => void;
|
||||
renderCustomFooter?: (isMobile: boolean) => JSX.Element;
|
||||
}
|
||||
|
||||
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,
|
||||
}: {
|
||||
library: LibraryItems;
|
||||
pendingElements: LibraryItem;
|
||||
onRemoveFromLibrary: (index: number) => void;
|
||||
onInsertShape: (elements: LibraryItem) => void;
|
||||
onAddToLibrary: (elements: LibraryItem) => void;
|
||||
setAppState: React.Component<any, AppState>["setState"];
|
||||
}) => {
|
||||
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;
|
||||
|
||||
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 });
|
||||
});
|
||||
}}
|
||||
/>
|
||||
<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 });
|
||||
});
|
||||
}}
|
||||
/>
|
||||
|
||||
<a
|
||||
href="https://libraries.excalidraw.com"
|
||||
target="_excalidraw_libraries"
|
||||
onClick={() => {
|
||||
trackEvent(EVENT_EXIT, "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,
|
||||
}: {
|
||||
pendingElements: LibraryItem;
|
||||
onClickOutside: (event: MouseEvent) => void;
|
||||
onInsertShape: (elements: LibraryItem) => void;
|
||||
onAddToLibrary: () => void;
|
||||
setAppState: React.Component<any, AppState>["setState"];
|
||||
}) => {
|
||||
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);
|
||||
trackEvent(EVENT_LIBRARY, "remove");
|
||||
setLibraryItems(nextItems);
|
||||
}, []);
|
||||
|
||||
const addToLibrary = useCallback(
|
||||
async (elements: LibraryItem) => {
|
||||
const items = await Library.loadLibrary();
|
||||
const nextItems = [...items, elements];
|
||||
onAddToLibrary();
|
||||
trackEvent(EVENT_LIBRARY, "add");
|
||||
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}
|
||||
/>
|
||||
)}
|
||||
</Island>
|
||||
);
|
||||
};
|
||||
|
||||
const LayerUI = ({
|
||||
actionManager,
|
||||
appState,
|
||||
setAppState,
|
||||
canvas,
|
||||
elements,
|
||||
onRoomCreate,
|
||||
onUsernameChange,
|
||||
onRoomDestroy,
|
||||
onCollabButtonClick,
|
||||
onLockToggle,
|
||||
onInsertElements,
|
||||
zenModeEnabled,
|
||||
toggleZenMode,
|
||||
isCollaborating,
|
||||
onExportToBackend,
|
||||
renderCustomFooter,
|
||||
}: LayerUIProps) => {
|
||||
const isMobile = useIsMobile();
|
||||
|
||||
const renderEncryptedIcon = () => (
|
||||
<a
|
||||
className={`encrypted-icon tooltip zen-mode-visibility ${
|
||||
zenModeEnabled ? "zen-mode-visibility--hidden" : ""
|
||||
}`}
|
||||
className={clsx("encrypted-icon tooltip zen-mode-visibility", {
|
||||
"zen-mode-visibility--hidden": zenModeEnabled,
|
||||
})}
|
||||
href="https://blog.excalidraw.com/end-to-end-encryption/"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
onClick={() => {
|
||||
trackEvent(EVENT_EXIT, "e2ee shield");
|
||||
}}
|
||||
>
|
||||
<span className="tooltip-text" dir="auto">
|
||||
{t("encrypted.tooltip")}
|
||||
</span>
|
||||
{shield}
|
||||
<Tooltip label={t("encrypted.tooltip")} position="above" long={true}>
|
||||
{shield}
|
||||
</Tooltip>
|
||||
</a>
|
||||
);
|
||||
|
||||
const renderExportDialog = () => {
|
||||
const createExporter = (type: ExportType): ExportCB => (
|
||||
const createExporter = (type: ExportType): ExportCB => async (
|
||||
exportedElements,
|
||||
scale,
|
||||
) => {
|
||||
if (canvas) {
|
||||
exportCanvas(type, exportedElements, appState, canvas, {
|
||||
await exportCanvas(type, exportedElements, appState, canvas, {
|
||||
exportBackground: appState.exportBackground,
|
||||
name: appState.name,
|
||||
viewBackgroundColor: appState.viewBackgroundColor,
|
||||
scale,
|
||||
shouldAddWatermark: appState.shouldAddWatermark,
|
||||
});
|
||||
})
|
||||
.catch(muteFSAbortError)
|
||||
.catch((error) => {
|
||||
console.error(error);
|
||||
setAppState({ errorMessage: error.message });
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<ExportDialog
|
||||
elements={elements}
|
||||
@@ -99,20 +367,14 @@ const LayerUI = ({
|
||||
onExportToPng={createExporter("png")}
|
||||
onExportToSvg={createExporter("svg")}
|
||||
onExportToClipboard={createExporter("clipboard")}
|
||||
onExportToBackend={(exportedElements) => {
|
||||
if (canvas) {
|
||||
exportCanvas(
|
||||
"backend",
|
||||
exportedElements,
|
||||
{
|
||||
...appState,
|
||||
selectedElementIds: {},
|
||||
},
|
||||
canvas,
|
||||
appState,
|
||||
);
|
||||
}
|
||||
}}
|
||||
onExportToBackend={
|
||||
onExportToBackend
|
||||
? (elements) => {
|
||||
onExportToBackend &&
|
||||
onExportToBackend(elements, appState, canvas);
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
);
|
||||
};
|
||||
@@ -120,27 +382,33 @@ const LayerUI = ({
|
||||
const renderCanvasActions = () => (
|
||||
<Section
|
||||
heading="canvasActions"
|
||||
className={`zen-mode-transition ${zenModeEnabled && "transition-left"}`}
|
||||
className={clsx("zen-mode-transition", {
|
||||
"transition-left": zenModeEnabled,
|
||||
})}
|
||||
>
|
||||
{/* the zIndex ensures this menu has higher stacking order,
|
||||
see https://github.com/excalidraw/excalidraw/pull/1445 */}
|
||||
<Island padding={4} style={{ zIndex: 1 }}>
|
||||
<Island padding={2} style={{ zIndex: 1 }}>
|
||||
<Stack.Col gap={4}>
|
||||
<Stack.Row gap={1} justifyContent="space-between">
|
||||
{actionManager.renderAction("loadScene")}
|
||||
{actionManager.renderAction("saveScene")}
|
||||
{actionManager.renderAction("saveAsScene")}
|
||||
{renderExportDialog()}
|
||||
{actionManager.renderAction("clearCanvas")}
|
||||
<RoomDialog
|
||||
isCollaborating={appState.isCollaborating}
|
||||
collaboratorCount={appState.collaborators.size}
|
||||
username={appState.username}
|
||||
onUsernameChange={onUsernameChange}
|
||||
onRoomCreate={onRoomCreate}
|
||||
onRoomDestroy={onRoomDestroy}
|
||||
/>
|
||||
{onCollabButtonClick && (
|
||||
<CollabButton
|
||||
isCollaborating={isCollaborating}
|
||||
collaboratorCount={appState.collaborators.size}
|
||||
onClick={onCollabButtonClick}
|
||||
/>
|
||||
)}
|
||||
</Stack.Row>
|
||||
{actionManager.renderAction("changeViewBackgroundColor")}
|
||||
<BackgroundPickerAndDarkModeToggle
|
||||
actionManager={actionManager}
|
||||
appState={appState}
|
||||
setAppState={setAppState}
|
||||
/>
|
||||
</Stack.Col>
|
||||
</Island>
|
||||
</Section>
|
||||
@@ -149,9 +417,11 @@ const LayerUI = ({
|
||||
const renderSelectedShapeActions = () => (
|
||||
<Section
|
||||
heading="selectedShapeActions"
|
||||
className={`zen-mode-transition ${zenModeEnabled && "transition-left"}`}
|
||||
className={clsx("zen-mode-transition", {
|
||||
"transition-left": zenModeEnabled,
|
||||
})}
|
||||
>
|
||||
<Island className={CLASSES.SHAPE_ACTIONS_MENU} padding={4}>
|
||||
<Island className={CLASSES.SHAPE_ACTIONS_MENU} padding={2}>
|
||||
<SelectedShapeActions
|
||||
appState={appState}
|
||||
elements={elements}
|
||||
@@ -162,18 +432,42 @@ const LayerUI = ({
|
||||
</Section>
|
||||
);
|
||||
|
||||
const closeLibrary = useCallback(
|
||||
(event) => {
|
||||
setAppState({ isLibraryOpen: false });
|
||||
},
|
||||
[setAppState],
|
||||
);
|
||||
|
||||
const deselectItems = useCallback(() => {
|
||||
setAppState({
|
||||
selectedElementIds: {},
|
||||
selectedGroupIds: {},
|
||||
});
|
||||
}, [setAppState]);
|
||||
|
||||
const libraryMenu = appState.isLibraryOpen ? (
|
||||
<LibraryMenu
|
||||
pendingElements={getSelectedElements(elements, appState)}
|
||||
onClickOutside={closeLibrary}
|
||||
onInsertShape={onInsertElements}
|
||||
onAddToLibrary={deselectItems}
|
||||
setAppState={setAppState}
|
||||
/>
|
||||
) : null;
|
||||
|
||||
const renderFixedSideContainer = () => {
|
||||
const shouldRenderSelectedShapeActions = showSelectedShapeActions(
|
||||
appState,
|
||||
elements,
|
||||
);
|
||||
|
||||
return (
|
||||
<FixedSideContainer side="top">
|
||||
<HintViewer appState={appState} elements={elements} />
|
||||
<div className="App-menu App-menu_top">
|
||||
<Stack.Col
|
||||
gap={4}
|
||||
className={zenModeEnabled && "disable-pointerEvents"}
|
||||
className={clsx({ "disable-pointerEvents": zenModeEnabled })}
|
||||
>
|
||||
{renderCanvasActions()}
|
||||
{shouldRenderSelectedShapeActions && renderSelectedShapeActions()}
|
||||
@@ -182,12 +476,17 @@ const LayerUI = ({
|
||||
{(heading) => (
|
||||
<Stack.Col gap={4} align="start">
|
||||
<Stack.Row gap={1}>
|
||||
<Island padding={1} className={zenModeEnabled && "zen-mode"}>
|
||||
<Island
|
||||
padding={1}
|
||||
className={clsx({ "zen-mode": zenModeEnabled })}
|
||||
>
|
||||
<HintViewer appState={appState} elements={elements} />
|
||||
{heading}
|
||||
<Stack.Row gap={1}>
|
||||
<ShapesSwitcher
|
||||
elementType={appState.elementType}
|
||||
setAppState={setAppState}
|
||||
isLibraryOpen={appState.isLibraryOpen}
|
||||
/>
|
||||
</Stack.Row>
|
||||
</Island>
|
||||
@@ -198,55 +497,68 @@ const LayerUI = ({
|
||||
title={t("toolBar.lock")}
|
||||
/>
|
||||
</Stack.Row>
|
||||
{libraryMenu}
|
||||
</Stack.Col>
|
||||
)}
|
||||
</Section>
|
||||
<div />
|
||||
</div>
|
||||
{
|
||||
<div
|
||||
className={`App-menu App-menu_bottom zen-mode-transition ${
|
||||
zenModeEnabled && "App-menu_bottom--transition-left"
|
||||
}`}
|
||||
<UserList
|
||||
className={clsx("zen-mode-transition", {
|
||||
"transition-right": zenModeEnabled,
|
||||
})}
|
||||
>
|
||||
<Stack.Col gap={2}>
|
||||
<Section heading="canvasActions">
|
||||
<Island padding={1}>
|
||||
<ZoomActions
|
||||
renderAction={actionManager.renderAction}
|
||||
zoom={appState.zoom}
|
||||
/>
|
||||
</Island>
|
||||
{renderEncryptedIcon()}
|
||||
</Section>
|
||||
</Stack.Col>
|
||||
</div>
|
||||
}
|
||||
{Array.from(appState.collaborators)
|
||||
// Collaborator is either not initialized or is actually the current user.
|
||||
.filter(([_, client]) => Object.keys(client).length !== 0)
|
||||
.map(([clientId, client]) => (
|
||||
<Tooltip
|
||||
label={client.username || "Unknown user"}
|
||||
key={clientId}
|
||||
>
|
||||
{actionManager.renderAction("goToCollaborator", clientId)}
|
||||
</Tooltip>
|
||||
))}
|
||||
</UserList>
|
||||
</div>
|
||||
</FixedSideContainer>
|
||||
);
|
||||
};
|
||||
|
||||
const renderBottomAppMenu = () => {
|
||||
return (
|
||||
<div
|
||||
className={clsx("App-menu App-menu_bottom zen-mode-transition", {
|
||||
"App-menu_bottom--transition-left": zenModeEnabled,
|
||||
})}
|
||||
>
|
||||
<Stack.Col gap={2}>
|
||||
<Section heading="canvasActions">
|
||||
<Island padding={1}>
|
||||
<ZoomActions
|
||||
renderAction={actionManager.renderAction}
|
||||
zoom={appState.zoom}
|
||||
/>
|
||||
</Island>
|
||||
{renderEncryptedIcon()}
|
||||
</Section>
|
||||
</Stack.Col>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const renderFooter = () => (
|
||||
<footer role="contentinfo" className="layer-ui__wrapper__footer">
|
||||
<div
|
||||
className={`zen-mode-transition ${
|
||||
zenModeEnabled && "transition-right disable-pointerEvents"
|
||||
}`}
|
||||
className={clsx("zen-mode-transition", {
|
||||
"transition-right disable-pointerEvents": zenModeEnabled,
|
||||
})}
|
||||
>
|
||||
<LanguageList
|
||||
onChange={(lng) => {
|
||||
setLanguage(lng);
|
||||
setAppState({});
|
||||
}}
|
||||
languages={languages}
|
||||
floating
|
||||
/>
|
||||
{renderCustomFooter?.(false)}
|
||||
{actionManager.renderAction("toggleShortcuts")}
|
||||
</div>
|
||||
<button
|
||||
className={`disable-zen-mode ${
|
||||
zenModeEnabled && "disable-zen-mode--visible"
|
||||
}`}
|
||||
className={clsx("disable-zen-mode", {
|
||||
"disable-zen-mode--visible": zenModeEnabled,
|
||||
})}
|
||||
onClick={toggleZenMode}
|
||||
>
|
||||
{t("buttons.exitZenMode")}
|
||||
@@ -255,7 +567,10 @@ const LayerUI = ({
|
||||
<button
|
||||
className="scroll-back-to-content"
|
||||
onClick={() => {
|
||||
setAppState({ ...calculateScrollCenter(elements) });
|
||||
trackEvent(EVENT_ACTION, "scroll to content");
|
||||
setAppState({
|
||||
...calculateScrollCenter(elements, appState, canvas),
|
||||
});
|
||||
}}
|
||||
>
|
||||
{t("buttons.scrollBackToContent")}
|
||||
@@ -264,20 +579,8 @@ const LayerUI = ({
|
||||
</footer>
|
||||
);
|
||||
|
||||
return isMobile ? (
|
||||
<MobileMenu
|
||||
appState={appState}
|
||||
elements={elements}
|
||||
actionManager={actionManager}
|
||||
exportButton={renderExportDialog()}
|
||||
setAppState={setAppState}
|
||||
onUsernameChange={onUsernameChange}
|
||||
onRoomCreate={onRoomCreate}
|
||||
onRoomDestroy={onRoomDestroy}
|
||||
onLockToggle={onLockToggle}
|
||||
/>
|
||||
) : (
|
||||
<div className="layer-ui__wrapper">
|
||||
const dialogs = (
|
||||
<>
|
||||
{appState.isLoading && <LoadingMessage />}
|
||||
{appState.errorMessage && (
|
||||
<ErrorDialog
|
||||
@@ -287,17 +590,56 @@ const LayerUI = ({
|
||||
)}
|
||||
{appState.showShortcutsDialog && (
|
||||
<ShortcutsDialog
|
||||
onClose={() => setAppState({ showShortcutsDialog: null })}
|
||||
onClose={() => setAppState({ showShortcutsDialog: false })}
|
||||
/>
|
||||
)}
|
||||
{appState.pasteDialog.shown && (
|
||||
<PasteChartDialog
|
||||
setAppState={setAppState}
|
||||
appState={appState}
|
||||
onInsertChart={onInsertElements}
|
||||
onClose={() =>
|
||||
setAppState({
|
||||
pasteDialog: { shown: false, data: null },
|
||||
})
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
|
||||
return isMobile ? (
|
||||
<>
|
||||
{dialogs}
|
||||
<MobileMenu
|
||||
appState={appState}
|
||||
elements={elements}
|
||||
actionManager={actionManager}
|
||||
libraryMenu={libraryMenu}
|
||||
exportButton={renderExportDialog()}
|
||||
setAppState={setAppState}
|
||||
onCollabButtonClick={onCollabButtonClick}
|
||||
onLockToggle={onLockToggle}
|
||||
canvas={canvas}
|
||||
isCollaborating={isCollaborating}
|
||||
renderCustomFooter={renderCustomFooter}
|
||||
/>
|
||||
</>
|
||||
) : (
|
||||
<div className="layer-ui__wrapper">
|
||||
{dialogs}
|
||||
{renderFixedSideContainer()}
|
||||
{renderBottomAppMenu()}
|
||||
{
|
||||
<aside
|
||||
className={`layer-ui__wrapper__github-corner zen-mode-transition ${
|
||||
zenModeEnabled && "transition-right"
|
||||
}`}
|
||||
className={clsx(
|
||||
"layer-ui__wrapper__github-corner zen-mode-transition",
|
||||
{
|
||||
"transition-right": zenModeEnabled,
|
||||
},
|
||||
)}
|
||||
>
|
||||
<GitHubCorner />
|
||||
<GitHubCorner appearance={appState.appearance} />
|
||||
</aside>
|
||||
}
|
||||
{renderFooter()}
|
||||
@@ -308,13 +650,8 @@ const LayerUI = ({
|
||||
const areEqual = (prev: LayerUIProps, next: LayerUIProps) => {
|
||||
const getNecessaryObj = (appState: AppState): Partial<AppState> => {
|
||||
const {
|
||||
draggingElement,
|
||||
resizingElement,
|
||||
multiElement,
|
||||
editingElement,
|
||||
isResizing,
|
||||
cursorX,
|
||||
cursorY,
|
||||
suggestedBindings,
|
||||
startBoundElement: boundElement,
|
||||
...ret
|
||||
} = appState;
|
||||
return ret;
|
||||
@@ -323,8 +660,8 @@ const areEqual = (prev: LayerUIProps, next: LayerUIProps) => {
|
||||
const nextAppState = getNecessaryObj(next.appState);
|
||||
|
||||
const keys = Object.keys(prevAppState) as (keyof Partial<AppState>)[];
|
||||
|
||||
return (
|
||||
prev.langCode === next.langCode &&
|
||||
prev.elements === next.elements &&
|
||||
keys.every((key) => prevAppState[key] === nextAppState[key])
|
||||
);
|
||||
|
79
src/components/LibraryUnit.scss
Normal file
79
src/components/LibraryUnit.scss
Normal file
@@ -0,0 +1,79 @@
|
||||
.excalidraw {
|
||||
.library-unit {
|
||||
align-items: center;
|
||||
border: 1px solid var(--button-gray-2);
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
position: relative;
|
||||
width: 63px;
|
||||
height: 63px; // match width
|
||||
}
|
||||
|
||||
.library-unit__dragger {
|
||||
display: flex;
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.library-unit__dragger > svg {
|
||||
filter: var(--appearance-filter);
|
||||
flex-grow: 1;
|
||||
max-height: 100%;
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
.library-unit__removeFromLibrary,
|
||||
.library-unit__removeFromLibrary:hover,
|
||||
.library-unit__removeFromLibrary:active {
|
||||
align-items: center;
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--icon-fill-color);
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
position: absolute;
|
||||
right: 5px;
|
||||
top: 5px;
|
||||
}
|
||||
|
||||
.library-unit__removeFromLibrary > svg {
|
||||
height: 16px;
|
||||
width: 16px;
|
||||
}
|
||||
|
||||
.library-unit__pulse {
|
||||
transform: scale(1);
|
||||
animation: library-unit__pulse-animation 1s ease-in infinite;
|
||||
}
|
||||
|
||||
.library-unit__adder {
|
||||
position: absolute;
|
||||
left: 50%;
|
||||
top: 50%;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
margin-left: -10px;
|
||||
margin-top: -10px;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.library-unit__active {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
@keyframes library-unit__pulse-animation {
|
||||
0% {
|
||||
transform: scale(0.95);
|
||||
}
|
||||
|
||||
50% {
|
||||
transform: scale(1);
|
||||
}
|
||||
|
||||
100% {
|
||||
transform: scale(0.95);
|
||||
}
|
||||
}
|
||||
}
|
100
src/components/LibraryUnit.tsx
Normal file
100
src/components/LibraryUnit.tsx
Normal file
@@ -0,0 +1,100 @@
|
||||
import clsx from "clsx";
|
||||
import oc from "open-color";
|
||||
import React, { useEffect, useRef, useState } from "react";
|
||||
import { close } from "../components/icons";
|
||||
import { MIME_TYPES } from "../constants";
|
||||
import { t } from "../i18n";
|
||||
import useIsMobile from "../is-mobile";
|
||||
import { exportToSvg } from "../scene/export";
|
||||
import { LibraryItem } from "../types";
|
||||
import "./LibraryUnit.scss";
|
||||
|
||||
// fa-plus
|
||||
const PLUS_ICON = (
|
||||
<svg viewBox="0 0 1792 1792">
|
||||
<path
|
||||
fill="currentColor"
|
||||
d="M1600 736v192q0 40-28 68t-68 28h-416v416q0 40-28 68t-68 28h-192q-40 0-68-28t-28-68v-416h-416q-40 0-68-28t-28-68v-192q0-40 28-68t68-28h416v-416q0-40 28-68t68-28h192q40 0 68 28t28 68v416h416q40 0 68 28t28 68z"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
|
||||
export const LibraryUnit = ({
|
||||
elements,
|
||||
pendingElements,
|
||||
onRemoveFromLibrary,
|
||||
onClick,
|
||||
}: {
|
||||
elements?: LibraryItem;
|
||||
pendingElements?: LibraryItem;
|
||||
onRemoveFromLibrary: () => void;
|
||||
onClick: () => void;
|
||||
}) => {
|
||||
const ref = useRef<HTMLDivElement | null>(null);
|
||||
useEffect(() => {
|
||||
const elementsToRender = elements || pendingElements;
|
||||
if (!elementsToRender) {
|
||||
return;
|
||||
}
|
||||
const svg = exportToSvg(elementsToRender, {
|
||||
exportBackground: false,
|
||||
viewBackgroundColor: oc.white,
|
||||
shouldAddWatermark: false,
|
||||
});
|
||||
for (const child of ref.current!.children) {
|
||||
if (child.tagName !== "svg") {
|
||||
continue;
|
||||
}
|
||||
ref.current!.removeChild(child);
|
||||
}
|
||||
ref.current!.appendChild(svg);
|
||||
|
||||
const current = ref.current!;
|
||||
return () => {
|
||||
current.removeChild(svg);
|
||||
};
|
||||
}, [elements, pendingElements]);
|
||||
|
||||
const [isHovered, setIsHovered] = useState(false);
|
||||
const isMobile = useIsMobile();
|
||||
|
||||
const adder = (isHovered || isMobile) && pendingElements && (
|
||||
<div className="library-unit__adder">{PLUS_ICON}</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={clsx("library-unit", {
|
||||
"library-unit__active": elements || pendingElements,
|
||||
})}
|
||||
onMouseEnter={() => setIsHovered(true)}
|
||||
onMouseLeave={() => setIsHovered(false)}
|
||||
>
|
||||
<div
|
||||
className={clsx("library-unit__dragger", {
|
||||
"library-unit__pulse": !!pendingElements,
|
||||
})}
|
||||
ref={ref}
|
||||
draggable={!!elements}
|
||||
onClick={!!elements || !!pendingElements ? onClick : undefined}
|
||||
onDragStart={(event) => {
|
||||
setIsHovered(false);
|
||||
event.dataTransfer.setData(
|
||||
MIME_TYPES.excalidrawlib,
|
||||
JSON.stringify(elements),
|
||||
);
|
||||
}}
|
||||
/>
|
||||
{adder}
|
||||
{elements && (isHovered || isMobile) && (
|
||||
<button
|
||||
className="library-unit__removeFromLibrary"
|
||||
aria-label={t("labels.removeFromLibrary")}
|
||||
onClick={onRemoveFromLibrary}
|
||||
>
|
||||
{close}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
@@ -1,10 +1,11 @@
|
||||
import React from "react";
|
||||
import { t } from "../i18n";
|
||||
|
||||
export const LoadingMessage = () => {
|
||||
// !! KEEP THIS IN SYNC WITH index.html !!
|
||||
return (
|
||||
<div className="LoadingMessage">
|
||||
<span>{"Loading scene..."}</span>
|
||||
<span>{t("labels.loadingScene")}</span>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user