mirror of
https://github.com/excalidraw/excalidraw.git
synced 2025-08-26 03:37:01 +02:00
Compare commits
746 Commits
close_canv
...
perf_debug
Author | SHA1 | Date | |
---|---|---|---|
![]() |
1d65536360 | ||
![]() |
d731a6463c | ||
![]() |
9f325a626e | ||
![]() |
41200ea28d | ||
![]() |
46a61ad4df | ||
![]() |
f4b1a30bef | ||
![]() |
32aa79164b | ||
![]() |
b5fd904808 | ||
![]() |
8f8dd1105f | ||
![]() |
b914ad41fc | ||
![]() |
551c38f60b | ||
![]() |
38e8ae46c9 | ||
![]() |
ad0c4c4c78 | ||
![]() |
27cf5ed17e | ||
![]() |
fd946adbae | ||
![]() |
c37977af4b | ||
![]() |
a0d413ab4e | ||
![]() |
b67a2b4f65 | ||
![]() |
5a8dbe8030 | ||
![]() |
731093f631 | ||
![]() |
fe56975f19 | ||
![]() |
2d800feeeb | ||
![]() |
93cccd596a | ||
![]() |
45b592227d | ||
![]() |
b818df1098 | ||
![]() |
4359e2935d | ||
![]() |
3d9d398378 | ||
![]() |
0a5da0269f | ||
![]() |
08ce7c7fc3 | ||
![]() |
fe7fbff7f6 | ||
![]() |
501397cb61 | ||
![]() |
865d29388c | ||
![]() |
54c7ec416a | ||
![]() |
aca284057d | ||
![]() |
2820cd112e | ||
![]() |
426b5d9537 | ||
![]() |
e7d34677c6 | ||
![]() |
3d5356cb8e | ||
![]() |
46f5ce5ce0 | ||
![]() |
b00bd3d6c0 | ||
![]() |
91fc22182c | ||
![]() |
966ca2ffa6 | ||
![]() |
2b049b4a65 | ||
![]() |
339212e563 | ||
![]() |
f8b4bb66b4 | ||
![]() |
f4312bba5e | ||
![]() |
ac66665b64 | ||
![]() |
2b71a1f0bd | ||
![]() |
58845e450a | ||
![]() |
15d79f8fee | ||
![]() |
958ebeae61 | ||
![]() |
31f51ca53b | ||
![]() |
5abbf73050 | ||
![]() |
7cf766630d | ||
![]() |
59fccafeac | ||
![]() |
19a6996e6b | ||
![]() |
86c4f90910 | ||
![]() |
4d88112021 | ||
![]() |
de5c63e299 | ||
![]() |
da0853a121 | ||
![]() |
57cc4b6a29 | ||
![]() |
e2ddd7b27a | ||
![]() |
693de8501e | ||
![]() |
c6df6d444e | ||
![]() |
ad5692c5f8 | ||
![]() |
60ab3337af | ||
![]() |
dd847793d2 | ||
![]() |
6d6e9f0dd3 | ||
![]() |
0fe0d7ca6b | ||
![]() |
abcf1f1bae | ||
![]() |
7d0b03f754 | ||
![]() |
bd8931d3d1 | ||
![]() |
0d86c04939 | ||
![]() |
8436ebbf68 | ||
![]() |
824f94b3df | ||
![]() |
f9a8e686b2 | ||
![]() |
e442a44ba8 | ||
![]() |
f1fd29571a | ||
![]() |
6a482a7ba2 | ||
![]() |
bfea434a55 | ||
![]() |
acb22c5a64 | ||
![]() |
7cd1b621d1 | ||
![]() |
9c37b25bab | ||
![]() |
a8bb9a78ef | ||
![]() |
e4aff04061 | ||
![]() |
c5cadc7de3 | ||
![]() |
7dc0c0d96a | ||
![]() |
2c9c8c8e05 | ||
![]() |
b5d7ae57e5 | ||
![]() |
0f66ee3a41 | ||
![]() |
771372c66b | ||
![]() |
a7937681e9 | ||
![]() |
792f238d16 | ||
![]() |
ba16416c75 | ||
![]() |
6e0ac52a64 | ||
![]() |
5bc40402a6 | ||
![]() |
df14c69977 | ||
![]() |
1ea67ba93d | ||
![]() |
a7153d9d1d | ||
![]() |
e885057a71 | ||
![]() |
7efa081976 | ||
![]() |
5deb93a083 | ||
![]() |
e3908e6fe3 | ||
![]() |
fe3d0b5e8b | ||
![]() |
b6bb74d08d | ||
![]() |
c725f84334 | ||
![]() |
11a3380d83 | ||
![]() |
76a5bb060e | ||
![]() |
dac8dda4d4 | ||
![]() |
a1a62468a6 | ||
![]() |
ba3a723e99 | ||
![]() |
c5355c08cf | ||
![]() |
6102380051 | ||
![]() |
655e59a707 | ||
![]() |
d05745070b | ||
![]() |
88c313bf86 | ||
![]() |
a7705848ec | ||
![]() |
69e1bae8dd | ||
![]() |
d361757e4a | ||
![]() |
0ef202f2df | ||
![]() |
bbfd2b3cd3 | ||
![]() |
120c8f373c | ||
![]() |
9135ebf2e2 | ||
![]() |
af31e9dcc2 | ||
![]() |
50bc7e099a | ||
![]() |
39d17c4a3c | ||
![]() |
d34c2a75db | ||
![]() |
de95c68d75 | ||
![]() |
cdf352d4c3 | ||
![]() |
4712393b62 | ||
![]() |
fd48c2cf79 | ||
![]() |
5feacd9a3b | ||
![]() |
ec35d5db51 | ||
![]() |
ddf088e428 | ||
![]() |
adc1e585ff | ||
![]() |
84b47a2ed5 | ||
![]() |
6196fba286 | ||
![]() |
5daff2d3cd | ||
![]() |
f1bc90e08a | ||
![]() |
aabcdc20fd | ||
![]() |
269fbcc2f3 | ||
![]() |
d08179c215 | ||
![]() |
90e739d444 | ||
![]() |
4a9fac2d1e | ||
![]() |
07ebd7c68c | ||
![]() |
92f30f7ed6 | ||
![]() |
605aa554d0 | ||
![]() |
bed9fca4a5 | ||
![]() |
b9968e2e72 | ||
![]() |
ab1a30073c | ||
![]() |
31049d06e8 | ||
![]() |
ef8559d060 | ||
![]() |
33bb23d2f3 | ||
![]() |
b27ac257e7 | ||
![]() |
d2cc76e52e | ||
![]() |
cad6097d60 | ||
![]() |
2537b225ac | ||
![]() |
4ee48d2729 | ||
![]() |
68f23d652f | ||
![]() |
a078508c05 | ||
![]() |
abf4dc9256 | ||
![]() |
ba8f12d588 | ||
![]() |
d57560db06 | ||
![]() |
0d26049b4e | ||
![]() |
f72e9b6ea5 | ||
![]() |
029cfb31b0 | ||
![]() |
3a288eb09c | ||
![]() |
803909abb6 | ||
![]() |
56c75b769c | ||
![]() |
eea48d94d3 | ||
![]() |
e29152ab30 | ||
![]() |
f4aa36b35d | ||
![]() |
2903a763a7 | ||
![]() |
4a980ed5db | ||
![]() |
d2e687ed0a | ||
![]() |
0d70690ec8 | ||
![]() |
a524eeb66e | ||
![]() |
3d56ceb794 | ||
![]() |
65c32b3319 | ||
![]() |
9e8e047aae | ||
![]() |
64d330a332 | ||
![]() |
1ed1529f96 | ||
![]() |
b30066ca19 | ||
![]() |
aae8e2fa5d | ||
![]() |
9e6d5fdbcb | ||
![]() |
22b2e10ddb | ||
![]() |
d53ac2a61e | ||
![]() |
6a0f800716 | ||
![]() |
aee1e2451e | ||
![]() |
da94eb1284 | ||
![]() |
ea51251fe6 | ||
![]() |
399ce1e01a | ||
![]() |
7df8302ba2 | ||
![]() |
af8c59b5bb | ||
![]() |
cf0f00285b | ||
![]() |
b5c67a384c | ||
![]() |
af93cedc08 | ||
![]() |
b6a6f2d465 | ||
![]() |
6bcbf8b50a | ||
![]() |
666516d7e9 | ||
![]() |
b941c5b661 | ||
![]() |
8f8c85c64e | ||
![]() |
116b0c48da | ||
![]() |
aa2971e8c5 | ||
![]() |
5656ac1e3e | ||
![]() |
e6a9ff1b96 | ||
![]() |
832b88249c | ||
![]() |
9902092fd1 | ||
![]() |
8f0863d335 | ||
![]() |
9423ac3263 | ||
![]() |
a66cfe2627 | ||
![]() |
86cf28f2b4 | ||
![]() |
b5a46dd671 | ||
![]() |
cd942c3e3b | ||
![]() |
55ccd5b79b | ||
![]() |
4348c55c31 | ||
![]() |
a3fbe40b26 | ||
![]() |
7431ca81d1 | ||
![]() |
4d13dbf625 | ||
![]() |
3840e2f4e6 | ||
![]() |
52d10bb41e | ||
![]() |
96c87f920a | ||
![]() |
7d4189c624 | ||
![]() |
f3e17c90d3 | ||
![]() |
70b3a9de49 | ||
![]() |
bf6d0eeef7 | ||
![]() |
5359e4fec9 | ||
![]() |
58fe639b8d | ||
![]() |
327ed0e2d1 | ||
![]() |
c2fce6d8c4 | ||
![]() |
cb6b7559b4 | ||
![]() |
77d789ed8e | ||
![]() |
89471094ce | ||
![]() |
670ceafc84 | ||
![]() |
873afdacd3 | ||
![]() |
880e4feede | ||
![]() |
9ba7ca3845 | ||
![]() |
734bb4d2ed | ||
![]() |
f2d2f97546 | ||
![]() |
2fa69ddc32 | ||
![]() |
1331cffe93 | ||
![]() |
f242721f3b | ||
![]() |
e940aeb1a3 | ||
![]() |
580e719580 | ||
![]() |
127af9db23 | ||
![]() |
2209e2c1e8 | ||
![]() |
ed31980f84 | ||
![]() |
db28595302 | ||
![]() |
cded1cd63d | ||
![]() |
8e447b4c32 | ||
![]() |
e29d3fc5e6 | ||
![]() |
9da56e46f0 | ||
![]() |
625ecc64ed | ||
![]() |
ceb43ed8fb | ||
![]() |
8c0a0415de | ||
![]() |
192debd829 | ||
![]() |
1cfb4dfd8b | ||
![]() |
fb32886355 | ||
![]() |
065df495ba | ||
![]() |
558227f744 | ||
![]() |
6d45430344 | ||
![]() |
3aa0c5ebc0 | ||
![]() |
e940993e0e | ||
![]() |
8f90aeb8d5 | ||
![]() |
e92d133973 | ||
![]() |
b682d88167 | ||
![]() |
7daf1a7944 | ||
![]() |
5c0eff50a0 | ||
![]() |
19056d635b | ||
![]() |
4d5f00ff08 | ||
![]() |
20de06ef50 | ||
![]() |
1849ff6ee2 | ||
![]() |
6765fc16be | ||
![]() |
5ca4f5bbf4 | ||
![]() |
9392ec276d | ||
![]() |
b26e4fcf99 | ||
![]() |
45f3410da8 | ||
![]() |
94b387ef7b | ||
![]() |
6d0716eb6b | ||
![]() |
8e26d5b500 | ||
![]() |
c5a7723185 | ||
![]() |
49172ac2d3 | ||
![]() |
618a846451 | ||
![]() |
d9f49ffd67 | ||
![]() |
46e43baad1 | ||
![]() |
bd35b682fa | ||
![]() |
b6f9a8005e | ||
![]() |
1acfaf6b6e | ||
![]() |
5cf7087754 | ||
![]() |
b2d49155ef | ||
![]() |
9745461db7 | ||
![]() |
21e9fcb2f5 | ||
![]() |
e203203993 | ||
![]() |
f224e4d596 | ||
![]() |
e0ca689759 | ||
![]() |
f792eb5ae7 | ||
![]() |
4604c8d823 | ||
![]() |
0896892f8a | ||
![]() |
7fe225ee99 | ||
![]() |
d2fd7be457 | ||
![]() |
5c61613a2e | ||
![]() |
b2767924de | ||
![]() |
59d0a77862 | ||
![]() |
987526d1e5 | ||
![]() |
e894d41a22 | ||
![]() |
14d1d39e8e | ||
![]() |
69336b4832 | ||
![]() |
32b677fb8a | ||
![]() |
570f725516 | ||
![]() |
a60860867c | ||
![]() |
7a61196462 | ||
![]() |
9653d676fe | ||
![]() |
0cdd0eebf1 | ||
![]() |
ae8b1d8bf7 | ||
![]() |
92ffe8dda6 | ||
![]() |
4d9dbd5a45 | ||
![]() |
c66cabaefd | ||
![]() |
e073128469 | ||
![]() |
835848d711 | ||
![]() |
2bd1d7ef59 | ||
![]() |
37c8b9c2ff | ||
![]() |
cf9f00f55f | ||
![]() |
7ae9043221 | ||
![]() |
7c567408c5 | ||
![]() |
54612621aa | ||
![]() |
d27b3bbebe | ||
![]() |
e4ffc9812e | ||
![]() |
a066317d3c | ||
![]() |
050bc1ce2b | ||
![]() |
5007df6522 | ||
![]() |
d450c36581 | ||
![]() |
66c92fc65a | ||
![]() |
5f1cd4591a | ||
![]() |
9be6243873 | ||
![]() |
c3f6d6d344 | ||
![]() |
339636caab | ||
![]() |
08115ef311 | ||
![]() |
e68abdbab4 | ||
![]() |
8aff076782 | ||
![]() |
96de887cc8 | ||
![]() |
98ea46664c | ||
![]() |
00e30ca0e4 | ||
![]() |
de6371aac4 | ||
![]() |
f47ddb988f | ||
![]() |
59cbf5fde5 | ||
![]() |
4486fbc2c6 | ||
![]() |
edfbac9d7d | ||
![]() |
719ae7b72f | ||
![]() |
631a228ca1 | ||
![]() |
4b5270ab12 | ||
![]() |
dcee594b66 | ||
![]() |
79d323fab1 | ||
![]() |
e4edda4555 | ||
![]() |
ca89d47d4c | ||
![]() |
18c526d877 | ||
![]() |
cbc6bd1ad8 | ||
![]() |
83d9282dbf | ||
![]() |
abff780983 | ||
![]() |
c009e03c8e | ||
![]() |
24bf4cb5fb | ||
![]() |
0850ab0dd0 | ||
![]() |
a7473169ba | ||
![]() |
f6325b1e5e | ||
![]() |
466220a3a8 | ||
![]() |
d9cc7d1033 | ||
![]() |
c037e9854c | ||
![]() |
9373961857 | ||
![]() |
1fd2fe56ee | ||
![]() |
dba71e358d | ||
![]() |
1ef287027b | ||
![]() |
a51ed9ced6 | ||
![]() |
4501d6d630 | ||
![]() |
92a5936c7f | ||
![]() |
50bd5fbae1 | ||
![]() |
62bead66d7 | ||
![]() |
b3073984b3 | ||
![]() |
3c9ee13979 | ||
![]() |
228c8136cf | ||
![]() |
324dd460c8 | ||
![]() |
d8ea085a94 | ||
![]() |
adbd486f32 | ||
![]() |
0a89c4b0c8 | ||
![]() |
c03845bac3 | ||
![]() |
d5a6014076 | ||
![]() |
74861b1398 | ||
![]() |
ac71ee7278 | ||
![]() |
9088df8f5a | ||
![]() |
c5fe0cd446 | ||
![]() |
9f8783c2dd | ||
![]() |
b475412199 | ||
![]() |
5f1616f2c5 | ||
![]() |
cec92c1d17 | ||
![]() |
5f476e09d4 | ||
![]() |
9aa6a27252 | ||
![]() |
a2e8806f57 | ||
![]() |
b71e702991 | ||
![]() |
5c67329be6 | ||
![]() |
28546fbb55 | ||
![]() |
b0cccbb9e8 | ||
![]() |
b621d065de | ||
![]() |
96580c92a5 | ||
![]() |
975441549b | ||
![]() |
4be701416a | ||
![]() |
1acb1e33f1 | ||
![]() |
986e1e40d3 | ||
![]() |
fab4a0e060 | ||
![]() |
b265ebf88f | ||
![]() |
351845019e | ||
![]() |
c0fcce6f27 | ||
![]() |
b093d2d2b6 | ||
![]() |
69548c5502 | ||
![]() |
6ca0afa6e5 | ||
![]() |
c50f81b829 | ||
![]() |
b122c8c4eb | ||
![]() |
9a7216fe94 | ||
![]() |
8eee749076 | ||
![]() |
2158ad0656 | ||
![]() |
74c3fea7f5 | ||
![]() |
5e456e6d05 | ||
![]() |
477cce2ed6 | ||
![]() |
dd8e465304 | ||
![]() |
11396a21de | ||
![]() |
38236bc5e0 | ||
![]() |
63ce5b82d7 | ||
![]() |
bae0e985b2 | ||
![]() |
04f852a40a | ||
![]() |
f463c047c0 | ||
![]() |
1fd347cade | ||
![]() |
ef62390841 | ||
![]() |
bf2bca221e | ||
![]() |
d0733b1960 | ||
![]() |
64c2d76cfa | ||
![]() |
c76784b774 | ||
![]() |
25e54e5999 | ||
![]() |
55b7a7d554 | ||
![]() |
c1c37a6ee7 | ||
![]() |
25b529f519 | ||
![]() |
8e6a747873 | ||
![]() |
089b05db1b | ||
![]() |
081e097cef | ||
![]() |
8b5657e1ce | ||
![]() |
8b2b03347c | ||
![]() |
c2a8712593 | ||
![]() |
ff1d7728a0 | ||
![]() |
98b5c37e45 | ||
![]() |
7db63bd397 | ||
![]() |
390da3fd0f | ||
![]() |
104664cb9e | ||
![]() |
c822055ec8 | ||
![]() |
e15d73d94c | ||
![]() |
80ee097b85 | ||
![]() |
10048b877b | ||
![]() |
5dd5862bb9 | ||
![]() |
79989fedda | ||
![]() |
cecabc2196 | ||
![]() |
ed8fb40b63 | ||
![]() |
6e391728fe | ||
![]() |
dfbfbc3f11 | ||
![]() |
9b8ee3cacf | ||
![]() |
4ea73d5d5b | ||
![]() |
618f204ddd | ||
![]() |
720588130c | ||
![]() |
f354788cd0 | ||
![]() |
1c7ee09010 | ||
![]() |
ca15b0a008 | ||
![]() |
650930c5ce | ||
![]() |
79c0d59244 | ||
![]() |
cd50b5f7e9 | ||
![]() |
c0434957ff | ||
![]() |
66aeaeb38d | ||
![]() |
7f545e74ab | ||
![]() |
a776955579 | ||
![]() |
afa7932c9b | ||
![]() |
1ee8d7d082 | ||
![]() |
06db702b5d | ||
![]() |
b53d1f6f3e | ||
![]() |
ca1f3aa094 | ||
![]() |
8ff159e76e | ||
![]() |
f9d2d537a2 | ||
![]() |
dac970c640 | ||
![]() |
78bb3b3d84 | ||
![]() |
7d9d7ad297 | ||
![]() |
de20a5e3ba | ||
![]() |
289f72e45d | ||
![]() |
6dd0e6a4c5 | ||
![]() |
96b31ecbce | ||
![]() |
a132f154cb | ||
![]() |
23acd8f6d1 | ||
![]() |
a60709f5ea | ||
![]() |
896c476716 | ||
![]() |
133ba19919 | ||
![]() |
a2136bfe9d | ||
![]() |
6fbd64fdaa | ||
![]() |
cc4b0c2932 | ||
![]() |
b6ef953dc9 | ||
![]() |
620b662085 | ||
![]() |
1c11df011a | ||
![]() |
59e9651547 | ||
![]() |
1c48d122e0 | ||
![]() |
e4d02fb275 | ||
![]() |
34a382ace9 | ||
![]() |
e60e48e67d | ||
![]() |
84d1d9993c | ||
![]() |
3ff9744b39 | ||
![]() |
b9abcc825a | ||
![]() |
9679eaf74c | ||
![]() |
284747d742 | ||
![]() |
876f85fd7a | ||
![]() |
efc2bbed21 | ||
![]() |
61d193b87b | ||
![]() |
3989d6a989 | ||
![]() |
f6559b65ef | ||
![]() |
bc6b066c07 | ||
![]() |
6370d517a2 | ||
![]() |
b8a37c42e4 | ||
![]() |
76763b80a9 | ||
![]() |
d2a2c9d6b5 | ||
![]() |
3a72f347d2 | ||
![]() |
c1d9456235 | ||
![]() |
c4f8b98208 | ||
![]() |
b6eb57d3f1 | ||
![]() |
473b8ca0ca | ||
![]() |
45206c4ef1 | ||
![]() |
56b4a29aaa | ||
![]() |
bb4dda64b5 | ||
![]() |
39e53b4ae7 | ||
![]() |
6143d5195a | ||
![]() |
f59e608f18 | ||
![]() |
6b24592e4a | ||
![]() |
7b442997dc | ||
![]() |
4bfc5bbcaa | ||
![]() |
2b29b9a96d | ||
![]() |
cc201a6d80 | ||
![]() |
5be58b59e0 | ||
![]() |
f1eb969565 | ||
![]() |
8d4f455cd3 | ||
![]() |
60262cb4cc | ||
![]() |
7501c24f22 | ||
![]() |
00d81aa982 | ||
![]() |
67fe156d06 | ||
![]() |
ef433233d1 | ||
![]() |
1c7056bdaa | ||
![]() |
277ffaacb9 | ||
![]() |
2a3e242cfd | ||
![]() |
b1c6051d6b | ||
![]() |
8df9742463 | ||
![]() |
9fdc382d71 | ||
![]() |
f70d11c2d1 | ||
![]() |
05e54d6785 | ||
![]() |
795a6e4546 | ||
![]() |
a01a4ad739 | ||
![]() |
e09b96ac6f | ||
![]() |
d48fb17718 | ||
![]() |
ede3c4af82 | ||
![]() |
8bcfd97fc5 | ||
![]() |
5721c6dfb5 | ||
![]() |
9b1f77c3be | ||
![]() |
3369035f40 | ||
![]() |
dbc7a8599b | ||
![]() |
09f649daf7 | ||
![]() |
d357664850 | ||
![]() |
f973fdfa89 | ||
![]() |
c15bc50f17 | ||
![]() |
c2d0107cc5 | ||
![]() |
c43fac31a1 | ||
![]() |
9dfaf1752b | ||
![]() |
d9a1eb2f01 | ||
![]() |
f1e17a320f | ||
![]() |
75ecd818b3 | ||
![]() |
a7abc71f6a | ||
![]() |
6d0f0c8f21 | ||
![]() |
790e6da500 | ||
![]() |
8df1a11535 | ||
![]() |
b61ee56dc8 | ||
![]() |
c61f95a327 | ||
![]() |
d6d629f416 | ||
![]() |
65dec605f2 | ||
![]() |
cacec0b5c4 | ||
![]() |
87a302d7e9 | ||
![]() |
899b36c206 | ||
![]() |
534cbef982 | ||
![]() |
b7f118404e | ||
![]() |
aab5067718 | ||
![]() |
b679da02ee | ||
![]() |
ec652820ea | ||
![]() |
5d941ed107 | ||
![]() |
adc478ca34 | ||
![]() |
f1202adb15 | ||
![]() |
fd439cf38a | ||
![]() |
83c63be846 | ||
![]() |
b59d49dd7f | ||
![]() |
0116b70edf | ||
![]() |
3f390d4858 | ||
![]() |
fdde73bff4 | ||
![]() |
90a416e265 | ||
![]() |
a828b2e5de | ||
![]() |
7c51d3c24c | ||
![]() |
4d2d6f181a | ||
![]() |
071416f6ef | ||
![]() |
d675b07089 | ||
![]() |
3975fd592a | ||
![]() |
34a9a4dac6 | ||
![]() |
78e419b790 | ||
![]() |
8d8769ba4e | ||
![]() |
d89fb3371b | ||
![]() |
8410972cff | ||
![]() |
2c8d041987 | ||
![]() |
94519c8250 | ||
![]() |
add8a1b1a7 | ||
![]() |
516e7656f3 | ||
![]() |
d7cdee37bf | ||
![]() |
5c5b8c517f | ||
![]() |
7dbd0c5e0a | ||
![]() |
ba35eb8f8c | ||
![]() |
163ad1f4c4 | ||
![]() |
0f0244224d | ||
![]() |
6eecadce60 | ||
![]() |
bc88cf5002 | ||
![]() |
571be9c0fe | ||
![]() |
5d925c7d3f | ||
![]() |
45c520341f | ||
![]() |
c6ffc06541 | ||
![]() |
ff29780760 | ||
![]() |
463857ad9a | ||
![]() |
be2da9539e | ||
![]() |
bb7829ef90 | ||
![]() |
1104f6891e | ||
![]() |
a97e172070 | ||
![]() |
39d45afc06 | ||
![]() |
00c6940851 | ||
![]() |
982cba2035 | ||
![]() |
54739cd2df | ||
![]() |
75aeaa6c38 | ||
![]() |
bea4a1e066 | ||
![]() |
e8b462cc31 | ||
![]() |
c86c176e10 | ||
![]() |
b09c11bb14 | ||
![]() |
7199d13f48 | ||
![]() |
7d1fddc144 | ||
![]() |
5da3207633 | ||
![]() |
8c9786e026 | ||
![]() |
f0f13ed694 | ||
![]() |
850d8eb47e | ||
![]() |
f287f9c002 | ||
![]() |
78df5bc852 | ||
![]() |
f0073c7e26 | ||
![]() |
fa7a313412 | ||
![]() |
8b3f236cd8 | ||
![]() |
621812d0eb | ||
![]() |
d607249205 | ||
![]() |
df28c3299f | ||
![]() |
b00a57b4be | ||
![]() |
9277e839db | ||
![]() |
0d5d60944f | ||
![]() |
489a652d73 | ||
![]() |
2b85d96121 | ||
![]() |
6ce535d3a4 | ||
![]() |
da43cf5635 | ||
![]() |
603ecfba34 | ||
![]() |
a589708737 | ||
![]() |
4df401d012 | ||
![]() |
b2c4552416 | ||
![]() |
5cae218f1b | ||
![]() |
4be726d405 | ||
![]() |
99623334d1 | ||
![]() |
685abac81a | ||
![]() |
9581c45522 | ||
![]() |
0749d2c1f3 | ||
![]() |
8787f3dc60 | ||
![]() |
5fabc57277 | ||
![]() |
e7cbb859f0 | ||
![]() |
aa860251c7 | ||
![]() |
380aaa30e6 | ||
![]() |
2e61fec7a6 | ||
![]() |
3c295559c7 | ||
![]() |
55d3287abf | ||
![]() |
e3e967421e | ||
![]() |
77aae63006 | ||
![]() |
ee64a7e264 | ||
![]() |
097362662d | ||
![]() |
038e9c13dd | ||
![]() |
bc8ba08ad0 | ||
![]() |
f861a9fdd0 | ||
![]() |
62303b8a22 | ||
![]() |
9cc741ab3a | ||
![]() |
2d279cbb02 | ||
![]() |
57ea4fdf9a | ||
![]() |
44402f42bf | ||
![]() |
bdead4d164 | ||
![]() |
bfc0656475 | ||
![]() |
a33a3334f7 | ||
![]() |
969d3c694a | ||
![]() |
5cd921549a | ||
![]() |
437afcbea4 | ||
![]() |
6dee02e320 | ||
![]() |
74a2f16501 | ||
![]() |
fd4460be37 | ||
![]() |
e82d0493cf | ||
![]() |
083cb4c656 | ||
![]() |
d067365c1d | ||
![]() |
273cac6b60 | ||
![]() |
b9337b8a36 | ||
![]() |
0e0025921b | ||
![]() |
efc01ddab1 | ||
![]() |
7bce22b114 | ||
![]() |
aab4965bbb | ||
![]() |
486a9a3da8 | ||
![]() |
2425c06082 | ||
![]() |
79ea844901 | ||
![]() |
6690215cd1 | ||
![]() |
7f5e783fe8 | ||
![]() |
9325109836 | ||
![]() |
69b6fbb3f4 | ||
![]() |
6b6002baae | ||
![]() |
54dcb73701 | ||
![]() |
b595d3fcba | ||
![]() |
d0867d1c3b | ||
![]() |
0d19e9210c | ||
![]() |
4249de41d4 | ||
![]() |
15f02ba191 | ||
![]() |
a2e1199907 | ||
![]() |
c08e9c4172 | ||
![]() |
abfc58eb24 | ||
![]() |
035c7affff | ||
![]() |
c819b653bf | ||
![]() |
60cea7a0c2 | ||
![]() |
d63b6a3469 | ||
![]() |
0912fe1c93 | ||
![]() |
360310de31 | ||
![]() |
716c78e930 | ||
![]() |
ba48974351 | ||
![]() |
6c3e4417e1 | ||
![]() |
bc0b6e1888 | ||
![]() |
99a22e8445 | ||
![]() |
e6d9797167 | ||
![]() |
a1e8fdfb1b | ||
![]() |
1cce63b07b | ||
![]() |
e9c2a09c21 | ||
![]() |
55e0812680 | ||
![]() |
0f32278a7e | ||
![]() |
1bdb8da1c3 | ||
![]() |
9c9787e0a0 | ||
![]() |
c2fe24c562 | ||
![]() |
52faa52091 | ||
![]() |
dd12abc583 | ||
![]() |
abebf9aff8 |
5
.env
5
.env
@@ -1,5 +0,0 @@
|
|||||||
REACT_APP_BACKEND_V1_GET_URL=https://json.excalidraw.com/api/v1/
|
|
||||||
REACT_APP_BACKEND_V2_GET_URL=https://json.excalidraw.com/api/v2/
|
|
||||||
REACT_APP_BACKEND_V2_POST_URL=https://json.excalidraw.com/api/v2/post/
|
|
||||||
REACT_APP_SOCKET_SERVER_URL=https://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"}'
|
|
22
.env.development
Normal file
22
.env.development
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
REACT_APP_BACKEND_V2_GET_URL=https://json-dev.excalidraw.com/api/v2/
|
||||||
|
REACT_APP_BACKEND_V2_POST_URL=https://json-dev.excalidraw.com/api/v2/post/
|
||||||
|
|
||||||
|
REACT_APP_LIBRARY_URL=https://libraries.excalidraw.com
|
||||||
|
REACT_APP_LIBRARY_BACKEND=https://us-central1-excalidraw-room-persistence.cloudfunctions.net/libraries
|
||||||
|
|
||||||
|
# collaboration WebSocket server (https://github.com/excalidraw/excalidraw-room)
|
||||||
|
REACT_APP_WS_SERVER_URL=http://localhost:3002
|
||||||
|
|
||||||
|
# set this only if using the collaboration workflow we use on excalidraw.com
|
||||||
|
REACT_APP_PORTAL_URL=
|
||||||
|
|
||||||
|
REACT_APP_FIREBASE_CONFIG='{"apiKey":"AIzaSyCMkxA60XIW8KbqMYL7edC4qT5l4qHX2h8","authDomain":"excalidraw-oss-dev.firebaseapp.com","projectId":"excalidraw-oss-dev","storageBucket":"excalidraw-oss-dev.appspot.com","messagingSenderId":"664559512677","appId":"1:664559512677:web:a385181f2928d328a7aa8c"}'
|
||||||
|
|
||||||
|
# put these in your .env.local, or make sure you don't commit!
|
||||||
|
# must be lowercase `true` when turned on
|
||||||
|
#
|
||||||
|
# whether to enable Service Workers in development
|
||||||
|
REACT_APP_DEV_ENABLE_SW=
|
||||||
|
# whether to disable live reload / HMR. Usuaully what you want to do when
|
||||||
|
# debugging Service Workers.
|
||||||
|
REACT_APP_DEV_DISABLE_LIVE_RELOAD=
|
@@ -1 +1,17 @@
|
|||||||
|
REACT_APP_BACKEND_V2_GET_URL=https://json.excalidraw.com/api/v2/
|
||||||
|
REACT_APP_BACKEND_V2_POST_URL=https://json.excalidraw.com/api/v2/post/
|
||||||
|
|
||||||
|
REACT_APP_LIBRARY_URL=https://libraries.excalidraw.com
|
||||||
|
REACT_APP_LIBRARY_BACKEND=https://us-central1-excalidraw-room-persistence.cloudfunctions.net/libraries
|
||||||
|
|
||||||
|
REACT_APP_PORTAL_URL=https://portal.excalidraw.com
|
||||||
|
# Fill to set socket server URL used for collaboration.
|
||||||
|
# Meant for forks only: excalidraw.com uses custom REACT_APP_PORTAL_URL flow
|
||||||
|
REACT_APP_WS_SERVER_URL=
|
||||||
|
|
||||||
|
REACT_APP_FIREBASE_CONFIG='{"apiKey":"AIzaSyAd15pYlMci_xIp9ko6wkEsDzAAA0Dn0RU","authDomain":"excalidraw-room-persistence.firebaseapp.com","databaseURL":"https://excalidraw-room-persistence.firebaseio.com","projectId":"excalidraw-room-persistence","storageBucket":"excalidraw-room-persistence.appspot.com","messagingSenderId":"654800341332","appId":"1:654800341332:web:4a692de832b55bd57ce0c1"}'
|
||||||
|
|
||||||
|
# production-only vars
|
||||||
REACT_APP_GOOGLE_ANALYTICS_ID=UA-387204-13
|
REACT_APP_GOOGLE_ANALYTICS_ID=UA-387204-13
|
||||||
|
|
||||||
|
REACT_APP_PLUS_APP=https://app.excalidraw.com
|
||||||
|
@@ -5,3 +5,4 @@ package-lock.json
|
|||||||
firebase/
|
firebase/
|
||||||
dist/
|
dist/
|
||||||
public/workbox
|
public/workbox
|
||||||
|
src/packages/excalidraw/types
|
||||||
|
@@ -1,6 +1,7 @@
|
|||||||
{
|
{
|
||||||
"extends": ["@excalidraw/eslint-config", "react-app"],
|
"extends": ["@excalidraw/eslint-config", "react-app"],
|
||||||
"rules": {
|
"rules": {
|
||||||
"import/no-anonymous-default-export": "off"
|
"import/no-anonymous-default-export": "off",
|
||||||
|
"no-restricted-globals": "off"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
34
.github/dependabot.yml
vendored
34
.github/dependabot.yml
vendored
@@ -1,34 +0,0 @@
|
|||||||
version: 2
|
|
||||||
updates:
|
|
||||||
- package-ecosystem: npm
|
|
||||||
directory: /
|
|
||||||
schedule:
|
|
||||||
interval: weekly
|
|
||||||
day: sunday
|
|
||||||
time: "01:00"
|
|
||||||
reviewers:
|
|
||||||
- lipis
|
|
||||||
assignees:
|
|
||||||
- lipis
|
|
||||||
|
|
||||||
- package-ecosystem: npm
|
|
||||||
directory: /src/packages/excalidraw/
|
|
||||||
schedule:
|
|
||||||
interval: weekly
|
|
||||||
day: sunday
|
|
||||||
time: "01:00"
|
|
||||||
reviewers:
|
|
||||||
- ad1992
|
|
||||||
assignees:
|
|
||||||
- ad1992
|
|
||||||
|
|
||||||
- package-ecosystem: npm
|
|
||||||
directory: /src/packages/utils/
|
|
||||||
schedule:
|
|
||||||
interval: weekly
|
|
||||||
day: sunday
|
|
||||||
time: "01:00"
|
|
||||||
reviewers:
|
|
||||||
- ad1992
|
|
||||||
assignees:
|
|
||||||
- ad1992
|
|
3
.github/workflows/autorelease-excalidraw.yml
vendored
3
.github/workflows/autorelease-excalidraw.yml
vendored
@@ -1,4 +1,4 @@
|
|||||||
name: Auto release @excalidraw/excalidraw-next
|
name: Auto release excalidraw next
|
||||||
on:
|
on:
|
||||||
push:
|
push:
|
||||||
branches:
|
branches:
|
||||||
@@ -23,4 +23,5 @@ jobs:
|
|||||||
NPM_TOKEN: ${{ secrets.NPM_TOKEN }}
|
NPM_TOKEN: ${{ secrets.NPM_TOKEN }}
|
||||||
- name: Auto release
|
- name: Auto release
|
||||||
run: |
|
run: |
|
||||||
|
yarn add @actions/core
|
||||||
yarn autorelease
|
yarn autorelease
|
||||||
|
55
.github/workflows/autorelease-preview.yml
vendored
Normal file
55
.github/workflows/autorelease-preview.yml
vendored
Normal file
@@ -0,0 +1,55 @@
|
|||||||
|
name: Auto release excalidraw preview
|
||||||
|
on:
|
||||||
|
issue_comment:
|
||||||
|
types: [created, edited]
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
Auto-release-excalidraw-preview:
|
||||||
|
name: Auto release preview
|
||||||
|
if: github.event.comment.body == '@excalibot trigger release' && github.event.issue.pull_request
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- name: React to release comment
|
||||||
|
uses: peter-evans/create-or-update-comment@v1
|
||||||
|
with:
|
||||||
|
token: ${{ secrets.PUSH_TRANSLATIONS_COVERAGE_PAT }}
|
||||||
|
comment-id: ${{ github.event.comment.id }}
|
||||||
|
reactions: "+1"
|
||||||
|
- name: Get PR SHA
|
||||||
|
id: sha
|
||||||
|
uses: actions/github-script@v4
|
||||||
|
with:
|
||||||
|
result-encoding: string
|
||||||
|
script: |
|
||||||
|
const { owner, repo, number } = context.issue;
|
||||||
|
const pr = await github.pulls.get({
|
||||||
|
owner,
|
||||||
|
repo,
|
||||||
|
pull_number: number,
|
||||||
|
});
|
||||||
|
return pr.data.head.sha
|
||||||
|
- uses: actions/checkout@v2
|
||||||
|
with:
|
||||||
|
ref: ${{ steps.sha.outputs.result }}
|
||||||
|
fetch-depth: 2
|
||||||
|
- name: Setup Node.js 14.x
|
||||||
|
uses: actions/setup-node@v2
|
||||||
|
with:
|
||||||
|
node-version: 14.x
|
||||||
|
- name: Set up publish access
|
||||||
|
run: |
|
||||||
|
npm config set //registry.npmjs.org/:_authToken ${NPM_TOKEN}
|
||||||
|
env:
|
||||||
|
NPM_TOKEN: ${{ secrets.NPM_TOKEN }}
|
||||||
|
- name: Auto release preview
|
||||||
|
id: "autorelease"
|
||||||
|
run: |
|
||||||
|
yarn add @actions/core
|
||||||
|
yarn autorelease preview ${{ github.event.issue.number }}
|
||||||
|
- name: Post comment post release
|
||||||
|
if: always()
|
||||||
|
uses: peter-evans/create-or-update-comment@v1
|
||||||
|
with:
|
||||||
|
token: ${{ secrets.PUSH_TRANSLATIONS_COVERAGE_PAT }}
|
||||||
|
issue-number: ${{ github.event.issue.number }}
|
||||||
|
body: "@${{ github.event.comment.user.login }} ${{ steps.autorelease.outputs.result }}"
|
29
.github/workflows/build-packages.yml
vendored
29
.github/workflows/build-packages.yml
vendored
@@ -1,29 +0,0 @@
|
|||||||
name: Build packages
|
|
||||||
|
|
||||||
on:
|
|
||||||
push:
|
|
||||||
branches:
|
|
||||||
- master
|
|
||||||
pull_request:
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
packages:
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
|
|
||||||
steps:
|
|
||||||
- uses: actions/checkout@v2
|
|
||||||
- name: Setup Node.js 14.x
|
|
||||||
uses: actions/setup-node@v2
|
|
||||||
with:
|
|
||||||
node-version: 14.x
|
|
||||||
- name: Install dependencies
|
|
||||||
run: |
|
|
||||||
yarn --frozen-lockfile
|
|
||||||
yarn --cwd src/packages/excalidraw
|
|
||||||
yarn --cwd src/packages/utils
|
|
||||||
- name: Build @excalidraw/excalidraw
|
|
||||||
run: |
|
|
||||||
yarn --cwd src/packages/excalidraw run pack
|
|
||||||
- name: Build @excalidraw/utils
|
|
||||||
run: |
|
|
||||||
yarn --cwd src/packages/utils run pack
|
|
15
.github/workflows/publish-docker.yml
vendored
15
.github/workflows/publish-docker.yml
vendored
@@ -10,11 +10,16 @@ jobs:
|
|||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v2
|
- name: Checkout repository
|
||||||
- uses: docker/build-push-action@v1
|
uses: actions/checkout@v3
|
||||||
|
- name: Login to DockerHub
|
||||||
|
uses: docker/login-action@v2
|
||||||
with:
|
with:
|
||||||
username: ${{ secrets.DOCKER_USERNAME }}
|
username: ${{ secrets.DOCKER_USERNAME }}
|
||||||
password: ${{ secrets.DOCKER_PASSWORD }}
|
password: ${{ secrets.DOCKER_PASSWORD }}
|
||||||
repository: excalidraw/excalidraw
|
- name: Build and push
|
||||||
tag_with_ref: true
|
uses: docker/build-push-action@v3
|
||||||
tag_with_sha: true
|
with:
|
||||||
|
context: .
|
||||||
|
push: true
|
||||||
|
tags: excalidraw/excalidraw:latest
|
||||||
|
7
.gitignore
vendored
7
.gitignore
vendored
@@ -5,9 +5,11 @@
|
|||||||
.env.test.local
|
.env.test.local
|
||||||
.envrc
|
.envrc
|
||||||
.eslintcache
|
.eslintcache
|
||||||
|
.history
|
||||||
.idea
|
.idea
|
||||||
.vercel
|
.vercel
|
||||||
.vscode
|
.vscode
|
||||||
|
.yarn
|
||||||
*.log
|
*.log
|
||||||
*.tgz
|
*.tgz
|
||||||
build
|
build
|
||||||
@@ -17,7 +19,10 @@ logs
|
|||||||
node_modules
|
node_modules
|
||||||
npm-debug.log*
|
npm-debug.log*
|
||||||
package-lock.json
|
package-lock.json
|
||||||
static
|
|
||||||
yarn-debug.log*
|
yarn-debug.log*
|
||||||
yarn-error.log*
|
yarn-error.log*
|
||||||
src/packages/excalidraw/types
|
src/packages/excalidraw/types
|
||||||
|
src/packages/excalidraw/example/public/bundle.js
|
||||||
|
src/packages/excalidraw/example/public/excalidraw-assets-dev
|
||||||
|
src/packages/excalidraw/example/public/excalidraw.development.js
|
||||||
|
|
||||||
|
2
.husky/pre-commit
Executable file
2
.husky/pre-commit
Executable file
@@ -0,0 +1,2 @@
|
|||||||
|
#!/bin/sh
|
||||||
|
yarn lint-staged
|
@@ -10,7 +10,7 @@ ARG NODE_ENV=production
|
|||||||
COPY . .
|
COPY . .
|
||||||
RUN yarn build:app:docker
|
RUN yarn build:app:docker
|
||||||
|
|
||||||
FROM nginx:1.17-alpine
|
FROM nginx:1.21-alpine
|
||||||
|
|
||||||
COPY --from=build /opt/node_app/build /usr/share/nginx/html
|
COPY --from=build /opt/node_app/build /usr/share/nginx/html
|
||||||
|
|
||||||
|
55
README.md
55
README.md
@@ -32,6 +32,10 @@ Last but not least, we're thankful to these companies for offering their service
|
|||||||
|
|
||||||
[](https://vercel.com) [](https://sentry.io) [](https://crowdin.com)
|
[](https://vercel.com) [](https://sentry.io) [](https://crowdin.com)
|
||||||
|
|
||||||
|
## Who's integrating Excalidraw
|
||||||
|
|
||||||
|
[Google Cloud](https://googlecloudcheatsheet.withgoogle.com/architecture) • [Meta](https://meta.com/) • [CodeSandbox](https://codesandbox.io/) • [Obsidian Excalidraw](https://github.com/zsviczian/obsidian-excalidraw-plugin) • [Replit](https://replit.com/) • [Slite](https://slite.com/) • [Notion](https://notion.so/) • [HackerRank](https://www.hackerrank.com/) •
|
||||||
|
|
||||||
## Documentation
|
## Documentation
|
||||||
|
|
||||||
### Shortcuts
|
### Shortcuts
|
||||||
@@ -70,6 +74,8 @@ The first set of digits is the room. This is visible from the server that’s go
|
|||||||
|
|
||||||
The second set of digits is the encryption key. The Excalidraw server doesn’t know about it. This is what all the participants use to encrypt/decrypt the messages.
|
The second set of digits is the encryption key. The Excalidraw server doesn’t know about it. This is what all the participants use to encrypt/decrypt the messages.
|
||||||
|
|
||||||
|
> Note: Please ensure that the encryption key is 22 characters long.
|
||||||
|
|
||||||
## Shape libraries
|
## Shape libraries
|
||||||
|
|
||||||
Find a growing list of libraries containing assets for your drawings at [libraries.excalidraw.com](https://libraries.excalidraw.com).
|
Find a growing list of libraries containing assets for your drawings at [libraries.excalidraw.com](https://libraries.excalidraw.com).
|
||||||
@@ -93,7 +99,7 @@ These instructions will get you a copy of the project up and running on your loc
|
|||||||
#### Requirements
|
#### Requirements
|
||||||
|
|
||||||
- [Node.js](https://nodejs.org/en/)
|
- [Node.js](https://nodejs.org/en/)
|
||||||
- [Yarn](https://yarnpkg.com/getting-started/install)
|
- [Yarn](https://yarnpkg.com/getting-started/install) (v1 or v2.4.2+)
|
||||||
- [Git](https://git-scm.com/downloads)
|
- [Git](https://git-scm.com/downloads)
|
||||||
|
|
||||||
#### Clone the repo
|
#### Clone the repo
|
||||||
@@ -116,16 +122,47 @@ yarn start
|
|||||||
|
|
||||||
Now you can open [http://localhost:3000](http://localhost:3000) and start coding in your favorite code editor.
|
Now you can open [http://localhost:3000](http://localhost:3000) and start coding in your favorite code editor.
|
||||||
|
|
||||||
|
#### Collaboration
|
||||||
|
|
||||||
|
For collaboration, you will need to set up [collab server](https://github.com/excalidraw/excalidraw-room) in local.
|
||||||
|
|
||||||
#### Commands
|
#### Commands
|
||||||
|
|
||||||
| Command | Description |
|
##### Install the dependencies
|
||||||
| ------------------ | --------------------------------- |
|
|
||||||
| `yarn` | Install the dependencies |
|
```
|
||||||
| `yarn start` | Run the project |
|
yarn
|
||||||
| `yarn fix` | Reformat all files with Prettier |
|
```
|
||||||
| `yarn test` | Run tests |
|
|
||||||
| `yarn test:update` | Update test snapshots |
|
##### Run the project
|
||||||
| `yarn test:code` | Test for formatting with Prettier |
|
|
||||||
|
```
|
||||||
|
yarn start
|
||||||
|
```
|
||||||
|
|
||||||
|
##### Reformat all files with Prettier
|
||||||
|
|
||||||
|
```
|
||||||
|
yarn fix
|
||||||
|
```
|
||||||
|
|
||||||
|
##### Run tests
|
||||||
|
|
||||||
|
```
|
||||||
|
yarn test
|
||||||
|
```
|
||||||
|
|
||||||
|
##### Update test snapshots
|
||||||
|
|
||||||
|
```
|
||||||
|
yarn test:update
|
||||||
|
```
|
||||||
|
|
||||||
|
##### Test for formatting with Prettier
|
||||||
|
|
||||||
|
```
|
||||||
|
yarn test:code
|
||||||
|
```
|
||||||
|
|
||||||
#### Docker Compose
|
#### Docker Compose
|
||||||
|
|
||||||
|
20
dev-docs/.gitignore
vendored
Normal file
20
dev-docs/.gitignore
vendored
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
# Dependencies
|
||||||
|
/node_modules
|
||||||
|
|
||||||
|
# Production
|
||||||
|
/build
|
||||||
|
|
||||||
|
# Generated files
|
||||||
|
.docusaurus
|
||||||
|
.cache-loader
|
||||||
|
|
||||||
|
# Misc
|
||||||
|
.DS_Store
|
||||||
|
.env.local
|
||||||
|
.env.development.local
|
||||||
|
.env.test.local
|
||||||
|
.env.production.local
|
||||||
|
|
||||||
|
npm-debug.log*
|
||||||
|
yarn-debug.log*
|
||||||
|
yarn-error.log*
|
41
dev-docs/README.md
Normal file
41
dev-docs/README.md
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
# Website
|
||||||
|
|
||||||
|
This website is built using [Docusaurus 2](https://docusaurus.io/), a modern static website generator.
|
||||||
|
|
||||||
|
### Installation
|
||||||
|
|
||||||
|
```
|
||||||
|
$ yarn
|
||||||
|
```
|
||||||
|
|
||||||
|
### Local Development
|
||||||
|
|
||||||
|
```
|
||||||
|
$ yarn start
|
||||||
|
```
|
||||||
|
|
||||||
|
This command starts a local development server and opens up a browser window. Most changes are reflected live without having to restart the server.
|
||||||
|
|
||||||
|
### Build
|
||||||
|
|
||||||
|
```
|
||||||
|
$ yarn build
|
||||||
|
```
|
||||||
|
|
||||||
|
This command generates static content into the `build` directory and can be served using any static contents hosting service.
|
||||||
|
|
||||||
|
### Deployment
|
||||||
|
|
||||||
|
Using SSH:
|
||||||
|
|
||||||
|
```
|
||||||
|
$ USE_SSH=true yarn deploy
|
||||||
|
```
|
||||||
|
|
||||||
|
Not using SSH:
|
||||||
|
|
||||||
|
```
|
||||||
|
$ GIT_USER=<Your GitHub username> yarn deploy
|
||||||
|
```
|
||||||
|
|
||||||
|
If you are using GitHub pages for hosting, this command is a convenient way to build the website and push to the `gh-pages` branch.
|
3
dev-docs/babel.config.js
Normal file
3
dev-docs/babel.config.js
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
module.exports = {
|
||||||
|
presets: [require.resolve("@docusaurus/core/lib/babel/preset")],
|
||||||
|
};
|
6
dev-docs/docs/codebase/overview.md
Normal file
6
dev-docs/docs/codebase/overview.md
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
---
|
||||||
|
sidebar_position: 1
|
||||||
|
title: Overview
|
||||||
|
---
|
||||||
|
|
||||||
|
In development. For now, refer to [excalidraw Readme](https://github.com/excalidraw/excalidraw/blob/master/README.md).
|
8
dev-docs/docs/get-started.md
Normal file
8
dev-docs/docs/get-started.md
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
---
|
||||||
|
sidebar_position: 1
|
||||||
|
title: Introduction
|
||||||
|
---
|
||||||
|
|
||||||
|
Want to integrate Excalidraw into your app? Head over to the [package docs](/docs/package/overview).
|
||||||
|
|
||||||
|
If you're looking into the Excalidraw codebase itself, start [here](/docs/codebase/overview).
|
6
dev-docs/docs/package/overview.md
Normal file
6
dev-docs/docs/package/overview.md
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
---
|
||||||
|
sidebar_position: 1
|
||||||
|
title: Overview
|
||||||
|
---
|
||||||
|
|
||||||
|
In development. For now, refer to [excalidraw package readme](https://github.com/excalidraw/excalidraw/blob/master/src/packages/excalidraw/README.md).
|
121
dev-docs/docusaurus.config.js
Normal file
121
dev-docs/docusaurus.config.js
Normal file
@@ -0,0 +1,121 @@
|
|||||||
|
// @ts-check
|
||||||
|
// Note: type annotations allow type checking and IDEs autocompletion
|
||||||
|
|
||||||
|
const lightCodeTheme = require("prism-react-renderer/themes/github");
|
||||||
|
const darkCodeTheme = require("prism-react-renderer/themes/dracula");
|
||||||
|
|
||||||
|
/** @type {import('@docusaurus/types').Config} */
|
||||||
|
const config = {
|
||||||
|
title: "Excalidraw developer docs",
|
||||||
|
tagline:
|
||||||
|
"For Excalidraw contributors or those integrating the Excalidraw editor",
|
||||||
|
url: "https://docs.excalidraw.com.com",
|
||||||
|
baseUrl: "/",
|
||||||
|
onBrokenLinks: "throw",
|
||||||
|
onBrokenMarkdownLinks: "warn",
|
||||||
|
favicon: "img/favicon.ico",
|
||||||
|
organizationName: "Excalidraw", // Usually your GitHub org/user name.
|
||||||
|
projectName: "excalidraw", // Usually your repo name.
|
||||||
|
|
||||||
|
// Even if you don't use internalization, you can use this field to set useful
|
||||||
|
// metadata like html lang. For example, if your site is Chinese, you may want
|
||||||
|
// to replace "en" with "zh-Hans".
|
||||||
|
i18n: {
|
||||||
|
defaultLocale: "en",
|
||||||
|
locales: ["en"],
|
||||||
|
},
|
||||||
|
|
||||||
|
presets: [
|
||||||
|
[
|
||||||
|
"classic",
|
||||||
|
/** @type {import('@docusaurus/preset-classic').Options} */
|
||||||
|
({
|
||||||
|
docs: {
|
||||||
|
sidebarPath: require.resolve("./sidebars.js"),
|
||||||
|
// Please change this to your repo.
|
||||||
|
editUrl: "https://github.com/excalidraw/docs/tree/master/",
|
||||||
|
},
|
||||||
|
theme: {
|
||||||
|
customCss: require.resolve("./src/css/custom.css"),
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
],
|
||||||
|
|
||||||
|
themeConfig:
|
||||||
|
/** @type {import('@docusaurus/preset-classic').ThemeConfig} */
|
||||||
|
({
|
||||||
|
navbar: {
|
||||||
|
title: "Excalidraw Docs",
|
||||||
|
logo: {
|
||||||
|
alt: "Excalidraw Logo",
|
||||||
|
src: "img/logo.svg",
|
||||||
|
},
|
||||||
|
items: [
|
||||||
|
{
|
||||||
|
type: "doc",
|
||||||
|
docId: "get-started",
|
||||||
|
position: "left",
|
||||||
|
label: "Get started",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
to: "https://blog.excalidraw.com",
|
||||||
|
label: "Blog",
|
||||||
|
position: "left",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
to: "https://github.com/excalidraw/excalidraw",
|
||||||
|
label: "GitHub",
|
||||||
|
position: "right",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
footer: {
|
||||||
|
style: "dark",
|
||||||
|
links: [
|
||||||
|
{
|
||||||
|
title: "Docs",
|
||||||
|
items: [
|
||||||
|
{
|
||||||
|
label: "Get Started",
|
||||||
|
to: "/docs/get-started",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "Community",
|
||||||
|
items: [
|
||||||
|
{
|
||||||
|
label: "Discord",
|
||||||
|
href: "https://discord.gg/UexuTaE",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "Twitter",
|
||||||
|
href: "https://twitter.com/excalidraw",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "More",
|
||||||
|
items: [
|
||||||
|
{
|
||||||
|
label: "Blog",
|
||||||
|
to: "https://blog.excalidraw.com",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "GitHub",
|
||||||
|
to: "https://github.com/excalidraw/excalidraw",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
copyright: `Made with ❤️ Built with Docusaurus`,
|
||||||
|
},
|
||||||
|
prism: {
|
||||||
|
theme: lightCodeTheme,
|
||||||
|
darkTheme: darkCodeTheme,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
|
||||||
|
module.exports = config;
|
46
dev-docs/package.json
Normal file
46
dev-docs/package.json
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
{
|
||||||
|
"name": "docs",
|
||||||
|
"version": "0.0.0",
|
||||||
|
"private": true,
|
||||||
|
"scripts": {
|
||||||
|
"docusaurus": "docusaurus",
|
||||||
|
"start": "docusaurus start --port 3003",
|
||||||
|
"build": "docusaurus build",
|
||||||
|
"swizzle": "docusaurus swizzle",
|
||||||
|
"deploy": "docusaurus deploy",
|
||||||
|
"clear": "docusaurus clear",
|
||||||
|
"serve": "docusaurus serve",
|
||||||
|
"write-translations": "docusaurus write-translations",
|
||||||
|
"write-heading-ids": "docusaurus write-heading-ids",
|
||||||
|
"typecheck": "tsc"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@docusaurus/core": "2.0.0-rc.1",
|
||||||
|
"@docusaurus/preset-classic": "2.0.0-rc.1",
|
||||||
|
"@mdx-js/react": "^1.6.22",
|
||||||
|
"clsx": "^1.2.1",
|
||||||
|
"prism-react-renderer": "^1.3.5",
|
||||||
|
"react": "^17.0.2",
|
||||||
|
"react-dom": "^17.0.2"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@docusaurus/module-type-aliases": "2.0.0-rc.1",
|
||||||
|
"@tsconfig/docusaurus": "^1.0.5",
|
||||||
|
"typescript": "^4.7.4"
|
||||||
|
},
|
||||||
|
"browserslist": {
|
||||||
|
"production": [
|
||||||
|
">0.5%",
|
||||||
|
"not dead",
|
||||||
|
"not op_mini all"
|
||||||
|
],
|
||||||
|
"development": [
|
||||||
|
"last 1 chrome version",
|
||||||
|
"last 1 firefox version",
|
||||||
|
"last 1 safari version"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=16.14"
|
||||||
|
}
|
||||||
|
}
|
31
dev-docs/sidebars.js
Normal file
31
dev-docs/sidebars.js
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
/**
|
||||||
|
* Creating a sidebar enables you to:
|
||||||
|
- create an ordered group of docs
|
||||||
|
- render a sidebar for each doc of that group
|
||||||
|
- provide next/previous navigation
|
||||||
|
|
||||||
|
The sidebars can be generated from the filesystem, or explicitly defined here.
|
||||||
|
|
||||||
|
Create as many sidebars as you want.
|
||||||
|
*/
|
||||||
|
|
||||||
|
// @ts-check
|
||||||
|
|
||||||
|
/** @type {import('@docusaurus/plugin-content-docs').SidebarsConfig} */
|
||||||
|
const sidebars = {
|
||||||
|
// By default, Docusaurus generates a sidebar from the docs folder structure
|
||||||
|
tutorialSidebar: [{ type: "autogenerated", dirName: "." }],
|
||||||
|
|
||||||
|
// But you can create a sidebar manually
|
||||||
|
/*
|
||||||
|
tutorialSidebar: [
|
||||||
|
{
|
||||||
|
type: 'category',
|
||||||
|
label: 'Tutorial',
|
||||||
|
items: ['hello'],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
*/
|
||||||
|
};
|
||||||
|
|
||||||
|
module.exports = sidebars;
|
62
dev-docs/src/components/Homepage/index.js
Normal file
62
dev-docs/src/components/Homepage/index.js
Normal file
@@ -0,0 +1,62 @@
|
|||||||
|
import React from "react";
|
||||||
|
import clsx from "clsx";
|
||||||
|
import styles from "./styles.module.css";
|
||||||
|
|
||||||
|
const FeatureList = [
|
||||||
|
{
|
||||||
|
title: "Learn how Excalidraw works",
|
||||||
|
Svg: require("@site/static/img/undraw_innovative.svg").default,
|
||||||
|
description: (
|
||||||
|
<>Want to contribute to Excalidraw but got lost in the codebase?</>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "Integrate Excalidraw",
|
||||||
|
Svg: require("@site/static/img/undraw_blank_canvas.svg").default,
|
||||||
|
description: (
|
||||||
|
<>
|
||||||
|
Want to build your own app powered by Excalidraw by don't know where to
|
||||||
|
start?
|
||||||
|
</>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "Help us improve",
|
||||||
|
Svg: require("@site/static/img/undraw_add_files.svg").default,
|
||||||
|
description: (
|
||||||
|
<>
|
||||||
|
Are the docs missing something? Anything you had trouble understanding
|
||||||
|
or needs an explanation? Come contribute to the docs to make them even
|
||||||
|
better!
|
||||||
|
</>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
function Feature({ Svg, title, description }) {
|
||||||
|
return (
|
||||||
|
<div className={clsx("col col--4")}>
|
||||||
|
<div className="text--center">
|
||||||
|
<Svg className={styles.featureSvg} role="img" />
|
||||||
|
</div>
|
||||||
|
<div className="text--center padding-horiz--md">
|
||||||
|
<h3>{title}</h3>
|
||||||
|
<p>{description}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function HomepageFeatures() {
|
||||||
|
return (
|
||||||
|
<section className={styles.features}>
|
||||||
|
<div className="container">
|
||||||
|
<div className="row">
|
||||||
|
{FeatureList.map((props, idx) => (
|
||||||
|
<Feature key={idx} {...props} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
70
dev-docs/src/components/Homepage/index.tsx
Normal file
70
dev-docs/src/components/Homepage/index.tsx
Normal file
@@ -0,0 +1,70 @@
|
|||||||
|
import React from "react";
|
||||||
|
import clsx from "clsx";
|
||||||
|
import styles from "./styles.module.css";
|
||||||
|
|
||||||
|
type FeatureItem = {
|
||||||
|
title: string;
|
||||||
|
Svg: React.ComponentType<React.ComponentProps<"svg">>;
|
||||||
|
description: JSX.Element;
|
||||||
|
};
|
||||||
|
|
||||||
|
const FeatureList: FeatureItem[] = [
|
||||||
|
{
|
||||||
|
title: "Easy to Use",
|
||||||
|
Svg: require("@site/static/img/undraw_docusaurus_mountain.svg").default,
|
||||||
|
description: (
|
||||||
|
<>
|
||||||
|
Docusaurus was designed from the ground up to be easily installed and
|
||||||
|
used to get your website up and running quickly.
|
||||||
|
</>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "Focus on What Matters",
|
||||||
|
Svg: require("@site/static/img/undraw_docusaurus_tree.svg").default,
|
||||||
|
description: (
|
||||||
|
<>
|
||||||
|
Docusaurus lets you focus on your docs, and we'll do the chores. Go
|
||||||
|
ahead and move your docs into the <code>docs</code> directory.
|
||||||
|
</>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "Powered by React",
|
||||||
|
Svg: require("@site/static/img/undraw_docusaurus_react.svg").default,
|
||||||
|
description: (
|
||||||
|
<>
|
||||||
|
Extend or customize your website layout by reusing React. Docusaurus can
|
||||||
|
be extended while reusing the same header and footer.
|
||||||
|
</>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
function Feature({ title, Svg, description }: FeatureItem) {
|
||||||
|
return (
|
||||||
|
<div className={clsx("col col--4")}>
|
||||||
|
<div className="text--center">
|
||||||
|
<Svg className={styles.featureSvg} role="img" />
|
||||||
|
</div>
|
||||||
|
<div className="text--center padding-horiz--md">
|
||||||
|
<h3>{title}</h3>
|
||||||
|
<p>{description}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function HomepageFeatures(): JSX.Element {
|
||||||
|
return (
|
||||||
|
<section className={styles.features}>
|
||||||
|
<div className="container">
|
||||||
|
<div className="row">
|
||||||
|
{FeatureList.map((props, idx) => (
|
||||||
|
<Feature key={idx} {...props} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
11
dev-docs/src/components/Homepage/styles.module.css
Normal file
11
dev-docs/src/components/Homepage/styles.module.css
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
.features {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
padding: 2rem 0;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.featureSvg {
|
||||||
|
height: 200px;
|
||||||
|
width: 200px;
|
||||||
|
}
|
43
dev-docs/src/css/custom.css
Normal file
43
dev-docs/src/css/custom.css
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
/**
|
||||||
|
* Any CSS included here will be global. The classic template
|
||||||
|
* bundles Infima by default. Infima is a CSS framework designed to
|
||||||
|
* work well for content-centric websites.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/* You can override the default Infima variables here. */
|
||||||
|
:root {
|
||||||
|
--ifm-color-primary: #6965db;
|
||||||
|
--ifm-color-primary-dark: #5b57d1;
|
||||||
|
--ifm-color-primary-darker: #5b57d1;
|
||||||
|
--ifm-color-primary-darkest: #4a47b1;
|
||||||
|
--ifm-color-primary-light: #5b57d1;
|
||||||
|
--ifm-color-primary-lighter: #5b57d1;
|
||||||
|
--ifm-color-primary-lightest: #5b57d1;
|
||||||
|
--ifm-code-font-size: 95%;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* For readability concerns, you should choose a lighter palette in dark mode. */
|
||||||
|
[data-theme="dark"] {
|
||||||
|
--ifm-color-primary: #5650f0;
|
||||||
|
--ifm-color-primary-dark: #4b46d8;
|
||||||
|
--ifm-color-primary-darker: #4b46d8;
|
||||||
|
--ifm-color-primary-darkest: #3e39be;
|
||||||
|
--ifm-color-primary-light: #3f3d64;
|
||||||
|
--ifm-color-primary-lighter: #3f3d64;
|
||||||
|
--ifm-color-primary-lightest: #3f3d64;
|
||||||
|
}
|
||||||
|
|
||||||
|
.docusaurus-highlight-code-line {
|
||||||
|
background-color: rgba(0, 0, 0, 0.1);
|
||||||
|
display: block;
|
||||||
|
margin: 0 calc(-1 * var(--ifm-pre-padding));
|
||||||
|
padding: 0 var(--ifm-pre-padding);
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-theme="dark"] .docusaurus-highlight-code-line {
|
||||||
|
background-color: rgba(0, 0, 0, 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-theme="dark"] .navbar__logo {
|
||||||
|
filter: invert(93%) hue-rotate(180deg);
|
||||||
|
}
|
42
dev-docs/src/pages/index.js
Normal file
42
dev-docs/src/pages/index.js
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
import React from "react";
|
||||||
|
import clsx from "clsx";
|
||||||
|
import Layout from "@theme/Layout";
|
||||||
|
import Link from "@docusaurus/Link";
|
||||||
|
import useDocusaurusContext from "@docusaurus/useDocusaurusContext";
|
||||||
|
import styles from "./index.module.css";
|
||||||
|
import HomepageFeatures from "@site/src/components/Homepage";
|
||||||
|
|
||||||
|
function HomepageHeader() {
|
||||||
|
const { siteConfig } = useDocusaurusContext();
|
||||||
|
return (
|
||||||
|
<header className={clsx("hero hero--primary", styles.heroBanner)}>
|
||||||
|
<div className="container">
|
||||||
|
<h1 className="hero__title">{siteConfig.title}</h1>
|
||||||
|
<p className="hero__subtitle">{siteConfig.tagline}</p>
|
||||||
|
<div className={styles.buttons}>
|
||||||
|
<Link
|
||||||
|
className="button button--secondary button--lg"
|
||||||
|
to="/docs/get-started"
|
||||||
|
>
|
||||||
|
Get started
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function Home() {
|
||||||
|
const { siteConfig } = useDocusaurusContext();
|
||||||
|
return (
|
||||||
|
<Layout
|
||||||
|
title={`Hello from ${siteConfig.title}`}
|
||||||
|
description="Description will go into a meta tag in <head />"
|
||||||
|
>
|
||||||
|
<HomepageHeader />
|
||||||
|
<main>
|
||||||
|
<HomepageFeatures />
|
||||||
|
</main>
|
||||||
|
</Layout>
|
||||||
|
);
|
||||||
|
}
|
27
dev-docs/src/pages/index.module.css
Normal file
27
dev-docs/src/pages/index.module.css
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
/**
|
||||||
|
* CSS files with the .module.css suffix will be treated as CSS modules
|
||||||
|
* and scoped locally.
|
||||||
|
*/
|
||||||
|
|
||||||
|
.heroBanner {
|
||||||
|
padding: 4rem 0;
|
||||||
|
text-align: center;
|
||||||
|
position: relative;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-theme="dark"] .heroBanner {
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media screen and (max-width: 996px) {
|
||||||
|
.heroBanner {
|
||||||
|
padding: 2rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.buttons {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
42
dev-docs/src/pages/index.tsx
Normal file
42
dev-docs/src/pages/index.tsx
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
import React from "react";
|
||||||
|
import clsx from "clsx";
|
||||||
|
import Layout from "@theme/Layout";
|
||||||
|
import Link from "@docusaurus/Link";
|
||||||
|
import useDocusaurusContext from "@docusaurus/useDocusaurusContext";
|
||||||
|
import styles from "./index.module.css";
|
||||||
|
import HomepageFeatures from "@site/src/components/Homepage";
|
||||||
|
|
||||||
|
function HomepageHeader() {
|
||||||
|
const { siteConfig } = useDocusaurusContext();
|
||||||
|
return (
|
||||||
|
<header className={clsx("hero hero--primary", styles.heroBanner)}>
|
||||||
|
<div className="container">
|
||||||
|
<h1 className="hero__title">{siteConfig.title}</h1>
|
||||||
|
<p className="hero__subtitle">{siteConfig.tagline}</p>
|
||||||
|
<div className={styles.buttons}>
|
||||||
|
<Link
|
||||||
|
className="button button--secondary button--lg"
|
||||||
|
to="/docs/get-started"
|
||||||
|
>
|
||||||
|
Get started
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function Home() {
|
||||||
|
const { siteConfig } = useDocusaurusContext();
|
||||||
|
return (
|
||||||
|
<Layout
|
||||||
|
title={`Hello from ${siteConfig.title}`}
|
||||||
|
description="Description will go into a meta tag in <head />"
|
||||||
|
>
|
||||||
|
<HomepageHeader />
|
||||||
|
<main>
|
||||||
|
<HomepageFeatures />
|
||||||
|
</main>
|
||||||
|
</Layout>
|
||||||
|
);
|
||||||
|
}
|
7
dev-docs/src/pages/markdown-page.md
Normal file
7
dev-docs/src/pages/markdown-page.md
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
---
|
||||||
|
title: Markdown page example
|
||||||
|
---
|
||||||
|
|
||||||
|
# Markdown page example
|
||||||
|
|
||||||
|
You don't need React to write simple standalone pages.
|
0
dev-docs/static/.nojekyll
Normal file
0
dev-docs/static/.nojekyll
Normal file
BIN
dev-docs/static/img/docusaurus.png
Normal file
BIN
dev-docs/static/img/docusaurus.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 5.0 KiB |
BIN
dev-docs/static/img/favicon.ico
Normal file
BIN
dev-docs/static/img/favicon.ico
Normal file
Binary file not shown.
After Width: | Height: | Size: 26 KiB |
4
dev-docs/static/img/logo.svg
Normal file
4
dev-docs/static/img/logo.svg
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
<svg viewBox="0 0 80 180" xmlns="http://www.w3.org/2000/svg" xml:space="preserve" style="fill-rule:evenodd;clip-rule:evenodd;stroke-linejoin:round;stroke-miterlimit:2">
|
||||||
|
<path d="M22.197 150.382c-4.179-3.359-10.618-9.051-15.702-13.946l-4.01-3.813.734-5.009c.396-2.732 1.13-8.083 1.582-11.839.508-3.757 1.017-7.286 1.186-7.798.226-.683 0-1.025-.621-1.025-1.073 0-1.13.285 1.807-9.107a617.602 617.602 0 0 1 2.203-7.229c.113-.398.565-.569 1.073-.398.508.227.791.683.621 1.081-.169.455.113.911.565 1.082.621.227.565.683-.395 2.333-1.525 2.562-5.422 24.419-5.648 31.477-.17 5.009-.17 5.066 1.92 7.912 2.033 2.789 6.721 7.001 13.951 12.351 2.033 1.537 4.067 3.245 4.631 3.814.848 1.024 1.243.74 8.36-6.887 4.123-4.383 8.698-8.88 10.166-10.018l2.711-2.049-2.089-4.44c-1.13-2.391-5.705-11.612-10.223-20.377-9.433-18.442-7.513-16.678-18.47-16.849l-7.117-.056-2.372-2.733c-2.485-2.903-2.824-3.984-1.638-5.805.452-.627.791-1.651.791-2.277 0-1.025.395-1.196 2.655-1.309 1.412-.057 2.711-.228 2.88-.399.17-.171.396-3.7.565-7.855l.226-7.513-3.784-8.197C2.485 39.844 0 33.583 0 31.533c0-1.081.226-1.992.452-1.992.565 0 .565.057 23.553 48.382 10.675 22.426 20.785 43.544 22.479 47.016 1.695 3.472 3.22 6.659 3.333 7.115.113.512-3.785 4.439-9.998 9.961-5.591 5.008-10.505 9.562-10.957 10.074-1.299 1.594-3.219 1.082-6.665-1.707Zm1.921-65.458c-2.599-5.066-2.712-5.123-9.828-5.464-6.27-.342-6.383-.285-6.383.911 0 .683-.226 1.593-.508 2.049-.339.512-.113 1.423.678 2.675l1.242 1.935h5.649c3.106.057 6.664.285 7.907.512 1.243.228 2.316.342 2.429.285.113-.057-.452-1.366-1.186-2.903Zm-4.745-9.107c-.452-1.195-1.638-3.7-2.598-5.578-1.581-3.188-1.751-3.301-2.146-1.992-.226.797-.396 3.13-.452 5.236-.057 4.155-.17 4.098 4.575 4.383l1.525.057-.904-2.106Z" style="fill-rule:nonzero;stroke:#000;stroke-width:2px" transform="matrix(1.01351 0 0 -1 9.088 166.517)" />
|
||||||
|
<path d="M23.892 136.835c-1.017-.74-1.299-1.48-1.299-3.358 0-2.22.169-2.562 1.694-3.188 1.525-.626 1.92-.569 3.671.626 2.316 1.594 2.373 1.992.678 4.554-1.468 2.22-2.937 2.618-4.744 1.366Zm3.219-2.049c.904-1.594.339-2.789-1.355-2.789-1.525 0-2.203 1.536-1.356 3.073.678 1.253 1.977 1.139 2.711-.284ZM59.306 124.028c0-.285-.339-.569-.735-.569-.339 0-1.299-1.594-2.033-3.529-2.259-5.92-24.852-50.943-24.908-49.52 0 .74-.339 1.252-.904 1.252-.791 0-.904-.456-.565-2.675.339-2.562.113-3.131-7.907-18.841-4.519-8.936-9.376-18.271-10.788-20.775-1.469-2.619-2.598-5.465-2.711-6.66-.17-2.049.056-2.334 4.97-6.603 2.824-2.504 6.439-5.635 8.02-7.058C28.862 2.504 32.194-.114 33.098.057c1.356.228 22.31 22.369 22.367 23.622 0 .569-1.017 9.221-2.259 19.238-2.147 17.076-4.18 37.055-3.954 38.99.169 1.196-.678 7.229-1.299 9.847-.509 2.05-.283 2.903 3.784 12.238 2.372 5.521 5.479 12.295 6.834 15.027 1.299 2.732 2.429 5.123 2.429 5.294 0 .17-.395.284-.847.284-.452 0-.847-.228-.847-.569ZM46.315 81.509c.621-3.984 1.864-13.547 2.767-21.231 1.751-14.116 3.785-29.769 4.349-33.753.339-1.993.113-2.391-3.558-6.489-6.382-7.229-13.16-14.344-15.476-16.165l-2.146-1.708-11.014 10.359C11.07 21.971 10.223 22.939 10.844 24.077c.339.626 3.22 5.92 6.383 11.725 3.163 5.806 7.342 13.547 9.263 17.19 1.977 3.7 3.784 6.887 4.123 7.058.395.228.508-5.521.395-17.759-.226-18.271-.169-18.328 1.638-17.929.226 0 .396 9.221.396 20.434v20.377l5.93 11.953c3.276 6.603 5.987 11.896 6.1 11.84.113-.058.678-3.416 1.243-7.457Z" style="fill-rule:nonzero;stroke:#000;stroke-width:2px" transform="matrix(1.01351 0 0 -1 9.088 166.517)" />
|
||||||
|
</svg>
|
After Width: | Height: | Size: 3.4 KiB |
1
dev-docs/static/img/undraw_add_files.svg
Normal file
1
dev-docs/static/img/undraw_add_files.svg
Normal file
File diff suppressed because one or more lines are too long
After Width: | Height: | Size: 5.7 KiB |
1
dev-docs/static/img/undraw_blank_canvas.svg
Normal file
1
dev-docs/static/img/undraw_blank_canvas.svg
Normal file
File diff suppressed because one or more lines are too long
After Width: | Height: | Size: 12 KiB |
1
dev-docs/static/img/undraw_innovative.svg
Normal file
1
dev-docs/static/img/undraw_innovative.svg
Normal file
File diff suppressed because one or more lines are too long
After Width: | Height: | Size: 5.4 KiB |
7
dev-docs/tsconfig.json
Normal file
7
dev-docs/tsconfig.json
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
{
|
||||||
|
// This file is not used in compilation. It is here just for a nice editor experience.
|
||||||
|
"extends": "@tsconfig/docusaurus/tsconfig.json",
|
||||||
|
"compilerOptions": {
|
||||||
|
"baseUrl": "."
|
||||||
|
}
|
||||||
|
}
|
7489
dev-docs/yarn.lock
Normal file
7489
dev-docs/yarn.lock
Normal file
File diff suppressed because it is too large
Load Diff
@@ -2,5 +2,8 @@
|
|||||||
"firestore": {
|
"firestore": {
|
||||||
"rules": "firestore.rules",
|
"rules": "firestore.rules",
|
||||||
"indexes": "firestore.indexes.json"
|
"indexes": "firestore.indexes.json"
|
||||||
|
},
|
||||||
|
"storage": {
|
||||||
|
"rules": "storage.rules"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
11
firebase-project/storage.rules
Normal file
11
firebase-project/storage.rules
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
rules_version = '2';
|
||||||
|
service firebase.storage {
|
||||||
|
match /b/{bucket}/o {
|
||||||
|
match /{files}/rooms/{room}/{file} {
|
||||||
|
allow get, write: if true;
|
||||||
|
}
|
||||||
|
match /{files}/shareLinks/{shareLink}/{file} {
|
||||||
|
allow get, write: if true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
81
package.json
81
package.json
@@ -21,59 +21,67 @@
|
|||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@sentry/browser": "6.2.5",
|
"@sentry/browser": "6.2.5",
|
||||||
"@sentry/integrations": "6.2.5",
|
"@sentry/integrations": "6.2.5",
|
||||||
"@testing-library/jest-dom": "5.11.10",
|
"@testing-library/jest-dom": "5.16.2",
|
||||||
"@testing-library/react": "11.2.6",
|
"@testing-library/react": "12.1.5",
|
||||||
"@types/jest": "26.0.22",
|
"@tldraw/vec": "1.7.1",
|
||||||
"@types/react": "17.0.3",
|
"@types/jest": "27.4.0",
|
||||||
"@types/react-dom": "17.0.3",
|
"@types/pica": "5.1.3",
|
||||||
|
"@types/react": "18.0.15",
|
||||||
|
"@types/react-dom": "18.0.6",
|
||||||
"@types/socket.io-client": "1.4.36",
|
"@types/socket.io-client": "1.4.36",
|
||||||
"browser-fs-access": "0.16.4",
|
"browser-fs-access": "0.29.1",
|
||||||
"clsx": "1.1.1",
|
"clsx": "1.1.1",
|
||||||
|
"cross-env": "7.0.3",
|
||||||
|
"fake-indexeddb": "3.1.7",
|
||||||
"firebase": "8.3.3",
|
"firebase": "8.3.3",
|
||||||
"i18next-browser-languagedetector": "6.1.0",
|
"http-server": "14.1.1",
|
||||||
|
"i18next-browser-languagedetector": "6.1.4",
|
||||||
|
"idb-keyval": "6.0.3",
|
||||||
|
"image-blob-reduce": "3.0.1",
|
||||||
|
"jotai": "1.6.4",
|
||||||
"lodash.throttle": "4.1.1",
|
"lodash.throttle": "4.1.1",
|
||||||
"nanoid": "3.1.22",
|
"nanoid": "3.3.3",
|
||||||
"open-color": "1.8.0",
|
"open-color": "1.9.1",
|
||||||
"pako": "1.0.11",
|
"pako": "1.0.11",
|
||||||
"perfect-freehand": "0.4.7",
|
"perfect-freehand": "1.0.16",
|
||||||
"png-chunk-text": "1.0.0",
|
"png-chunk-text": "1.0.0",
|
||||||
"png-chunks-encode": "1.0.0",
|
"png-chunks-encode": "1.0.0",
|
||||||
"png-chunks-extract": "1.0.0",
|
"png-chunks-extract": "1.0.0",
|
||||||
"points-on-curve": "0.2.0",
|
"points-on-curve": "0.2.0",
|
||||||
"pwacompat": "2.0.17",
|
"pwacompat": "2.0.17",
|
||||||
"react": "17.0.2",
|
"react": "18.2.0",
|
||||||
"react-dom": "17.0.2",
|
"react-dom": "18.2.0",
|
||||||
"react-scripts": "4.0.3",
|
"react-scripts": "4.0.3",
|
||||||
"roughjs": "4.4.1",
|
"roughjs": "4.5.2",
|
||||||
"sass": "1.32.10",
|
"sass": "1.51.0",
|
||||||
"socket.io-client": "2.3.1",
|
"socket.io-client": "2.3.1",
|
||||||
"typescript": "4.2.4"
|
"typescript": "4.5.5"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@excalidraw/eslint-config": "1.0.0",
|
"@excalidraw/eslint-config": "1.0.0",
|
||||||
"@excalidraw/prettier-config": "1.0.2",
|
"@excalidraw/prettier-config": "1.0.2",
|
||||||
"@types/lodash.throttle": "4.1.6",
|
"@types/chai": "4.3.0",
|
||||||
"@types/pako": "1.0.1",
|
"@types/lodash.throttle": "4.1.7",
|
||||||
"@types/resize-observer-browser": "0.1.5",
|
"@types/pako": "1.0.3",
|
||||||
"eslint-config-prettier": "8.3.0",
|
"@types/resize-observer-browser": "0.1.7",
|
||||||
|
"chai": "4.3.6",
|
||||||
|
"dotenv": "16.0.1",
|
||||||
|
"eslint-config-prettier": "8.5.0",
|
||||||
"eslint-plugin-prettier": "3.3.1",
|
"eslint-plugin-prettier": "3.3.1",
|
||||||
"firebase-tools": "9.9.0",
|
"husky": "7.0.4",
|
||||||
"husky": "4.3.8",
|
"jest-canvas-mock": "2.4.0",
|
||||||
"jest-canvas-mock": "2.3.1",
|
"lint-staged": "12.3.7",
|
||||||
"lint-staged": "10.5.4",
|
|
||||||
"pepjs": "0.5.3",
|
"pepjs": "0.5.3",
|
||||||
"prettier": "2.2.1",
|
"prettier": "2.6.2",
|
||||||
"rewire": "5.0.0"
|
"rewire": "6.0.0"
|
||||||
|
},
|
||||||
|
"resolutions": {
|
||||||
|
"@typescript-eslint/typescript-estree": "5.10.2"
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=14.0.0"
|
"node": ">=14.0.0"
|
||||||
},
|
},
|
||||||
"homepage": ".",
|
"homepage": ".",
|
||||||
"husky": {
|
|
||||||
"hooks": {
|
|
||||||
"pre-commit": "lint-staged"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"jest": {
|
"jest": {
|
||||||
"transformIgnorePatterns": [
|
"transformIgnorePatterns": [
|
||||||
"node_modules/(?!(roughjs|points-on-curve|path-data-parser|points-on-path|browser-fs-access)/)"
|
"node_modules/(?!(roughjs|points-on-curve|path-data-parser|points-on-path|browser-fs-access)/)"
|
||||||
@@ -85,18 +93,21 @@
|
|||||||
"private": true,
|
"private": true,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"build-node": "node ./scripts/build-node.js",
|
"build-node": "node ./scripts/build-node.js",
|
||||||
"build:app:docker": "REACT_APP_DISABLE_SENTRY=true react-scripts build",
|
"build:app:docker": "cross-env REACT_APP_DISABLE_SENTRY=true react-scripts build",
|
||||||
"build:app": "REACT_APP_GIT_SHA=$VERCEL_GIT_COMMIT_SHA react-scripts build",
|
"build:app": "cross-env REACT_APP_GIT_SHA=$VERCEL_GIT_COMMIT_SHA react-scripts build",
|
||||||
"build:version": "node ./scripts/build-version.js",
|
"build:version": "node ./scripts/build-version.js",
|
||||||
"build": "yarn build:app && yarn build:version",
|
"build:prebuild": "node ./scripts/prebuild.js",
|
||||||
|
"build": "yarn build:prebuild && yarn build:app && yarn build:version",
|
||||||
"eject": "react-scripts eject",
|
"eject": "react-scripts eject",
|
||||||
"fix:code": "yarn test:code --fix",
|
"fix:code": "yarn test:code --fix",
|
||||||
"fix:other": "yarn prettier --write",
|
"fix:other": "yarn prettier --write",
|
||||||
"fix": "yarn fix:other && yarn fix:code",
|
"fix": "yarn fix:other && yarn fix:code",
|
||||||
"locales-coverage": "node scripts/build-locales-coverage.js",
|
"locales-coverage": "node scripts/build-locales-coverage.js",
|
||||||
"locales-coverage:description": "node scripts/locales-coverage-description.js",
|
"locales-coverage:description": "node scripts/locales-coverage-description.js",
|
||||||
|
"prepare": "husky install",
|
||||||
"prettier": "prettier \"**/*.{css,scss,json,md,html,yml}\" --ignore-path=.eslintignore",
|
"prettier": "prettier \"**/*.{css,scss,json,md,html,yml}\" --ignore-path=.eslintignore",
|
||||||
"start": "react-scripts start",
|
"start": "react-scripts start",
|
||||||
|
"start:build": "npm run build && npx http-server build -a localhost -p 3001 -o",
|
||||||
"test:all": "yarn test:typecheck && yarn test:code && yarn test:other && yarn test:app --watchAll=false",
|
"test:all": "yarn test:typecheck && yarn test:code && yarn test:other && yarn test:app --watchAll=false",
|
||||||
"test:app": "react-scripts test --passWithNoTests",
|
"test:app": "react-scripts test --passWithNoTests",
|
||||||
"test:code": "eslint --max-warnings=0 --ext .js,.ts,.tsx .",
|
"test:code": "eslint --max-warnings=0 --ext .js,.ts,.tsx .",
|
||||||
@@ -105,6 +116,8 @@
|
|||||||
"test:typecheck": "tsc",
|
"test:typecheck": "tsc",
|
||||||
"test:update": "yarn test:app --updateSnapshot --watchAll=false",
|
"test:update": "yarn test:app --updateSnapshot --watchAll=false",
|
||||||
"test": "yarn test:app",
|
"test": "yarn test:app",
|
||||||
"autorelease": "node scripts/autorelease.js"
|
"autorelease": "node scripts/autorelease.js",
|
||||||
|
"prerelease": "node scripts/prerelease.js",
|
||||||
|
"release": "node scripts/release.js"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
Binary file not shown.
@@ -52,6 +52,25 @@
|
|||||||
content="Excalidraw is a whiteboard tool that lets you easily sketch diagrams that have a hand-drawn feel to them."
|
content="Excalidraw is a whiteboard tool that lets you easily sketch diagrams that have a hand-drawn feel to them."
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
// Redirect Excalidraw+ users which have auto-redirect enabled.
|
||||||
|
//
|
||||||
|
// Redirect only the bare root path, so link/room/library urls are not
|
||||||
|
// redirected.
|
||||||
|
//
|
||||||
|
// Putting into index.html for best performance (can't redirect on server
|
||||||
|
// due to location.hash checks).
|
||||||
|
if (
|
||||||
|
window.location.pathname === "/" &&
|
||||||
|
!window.location.hash &&
|
||||||
|
!window.location.search &&
|
||||||
|
// if its present redirect
|
||||||
|
document.cookie.includes("excplus-autoredirect=true")
|
||||||
|
) {
|
||||||
|
window.location.href = "https://app.excalidraw.com";
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
<link rel="shortcut icon" href="favicon.ico" type="image/x-icon" />
|
<link rel="shortcut icon" href="favicon.ico" type="image/x-icon" />
|
||||||
|
|
||||||
<!-- Excalidraw version -->
|
<!-- Excalidraw version -->
|
||||||
@@ -72,12 +91,6 @@
|
|||||||
crossorigin="anonymous"
|
crossorigin="anonymous"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<link
|
|
||||||
href="%REACT_APP_SOCKET_SERVER_URL%/socket.io"
|
|
||||||
rel="preconnect"
|
|
||||||
crossorigin="anonymous"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<link
|
<link
|
||||||
rel="manifest"
|
rel="manifest"
|
||||||
href="manifest.json"
|
href="manifest.json"
|
||||||
@@ -85,6 +98,22 @@
|
|||||||
/>
|
/>
|
||||||
|
|
||||||
<link rel="stylesheet" href="fonts.css" type="text/css" />
|
<link rel="stylesheet" href="fonts.css" type="text/css" />
|
||||||
|
<% if (process.env.REACT_APP_DEV_DISABLE_LIVE_RELOAD === "true") { %>
|
||||||
|
<script>
|
||||||
|
{
|
||||||
|
const _WebSocket = window.WebSocket;
|
||||||
|
window.WebSocket = function (url) {
|
||||||
|
if (/ws:\/\/localhost:.+?\/sockjs-node/.test(url)) {
|
||||||
|
console.info(
|
||||||
|
"[!!!] Live reload is disabled via process.env.REACT_APP_DEV_DISABLE_LIVE_RELOAD [!!!]",
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
return new _WebSocket(url);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
<% } %>
|
||||||
<script>
|
<script>
|
||||||
window.EXCALIDRAW_ASSET_PATH = "/";
|
window.EXCALIDRAW_ASSET_PATH = "/";
|
||||||
// setting this so that libraries installation reuses this window tab.
|
// setting this so that libraries installation reuses this window tab.
|
||||||
@@ -117,6 +146,7 @@
|
|||||||
|
|
||||||
width: 100%;
|
width: 100%;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
.visually-hidden {
|
.visually-hidden {
|
||||||
@@ -129,26 +159,6 @@
|
|||||||
user-select: none;
|
user-select: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.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;
|
|
||||||
}
|
|
||||||
#root {
|
#root {
|
||||||
height: 100%;
|
height: 100%;
|
||||||
-webkit-touch-callout: none;
|
-webkit-touch-callout: none;
|
||||||
@@ -157,8 +167,10 @@
|
|||||||
-moz-user-select: none;
|
-moz-user-select: none;
|
||||||
-ms-user-select: none;
|
-ms-user-select: none;
|
||||||
user-select: none;
|
user-select: none;
|
||||||
|
}
|
||||||
|
|
||||||
@media screen and (min-width: 1200px) {
|
@media screen and (min-width: 1200px) {
|
||||||
|
#root {
|
||||||
-webkit-touch-callout: default;
|
-webkit-touch-callout: default;
|
||||||
-webkit-user-select: auto;
|
-webkit-user-select: auto;
|
||||||
-khtml-user-select: auto;
|
-khtml-user-select: auto;
|
||||||
@@ -175,10 +187,6 @@
|
|||||||
<header>
|
<header>
|
||||||
<h1 class="visually-hidden">Excalidraw</h1>
|
<h1 class="visually-hidden">Excalidraw</h1>
|
||||||
</header>
|
</header>
|
||||||
<div id="root">
|
<div id="root"></div>
|
||||||
<div class="LoadingMessage">
|
|
||||||
<span>Loading scene...</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
@@ -26,7 +26,6 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"capture_links": "new_client",
|
|
||||||
"share_target": {
|
"share_target": {
|
||||||
"action": "/web-share-target",
|
"action": "/web-share-target",
|
||||||
"method": "POST",
|
"method": "POST",
|
||||||
|
@@ -17,11 +17,23 @@
|
|||||||
* See https://goo.gl/2aRDsh
|
* See https://goo.gl/2aRDsh
|
||||||
*/
|
*/
|
||||||
|
|
||||||
importScripts("/workbox/workbox-sw.js");
|
// in dev, `process` is undefined because this file is not compiled until build
|
||||||
|
const IS_DEVELOPMENT =
|
||||||
|
typeof process === "undefined" || process.env.NODE_ENV !== "production";
|
||||||
|
|
||||||
workbox.setConfig({
|
if (IS_DEVELOPMENT) {
|
||||||
modulePathPrefix: "/workbox/",
|
importScripts(
|
||||||
});
|
"https://storage.googleapis.com/workbox-cdn/releases/4.3.1/workbox-sw.js",
|
||||||
|
);
|
||||||
|
workbox.setConfig({
|
||||||
|
debug: true,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
importScripts("/workbox/workbox-sw.js");
|
||||||
|
workbox.setConfig({
|
||||||
|
modulePathPrefix: "/workbox/",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
self.addEventListener("message", (event) => {
|
self.addEventListener("message", (event) => {
|
||||||
if (event.data && event.data.type === "SKIP_WAITING") {
|
if (event.data && event.data.type === "SKIP_WAITING") {
|
||||||
@@ -30,14 +42,17 @@ self.addEventListener("message", (event) => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
workbox.core.clientsClaim();
|
workbox.core.clientsClaim();
|
||||||
workbox.precaching.precacheAndRoute(self.__WB_MANIFEST);
|
|
||||||
|
|
||||||
workbox.routing.registerNavigationRoute(
|
if (!IS_DEVELOPMENT) {
|
||||||
workbox.precaching.getCacheKeyForURL("./index.html"),
|
workbox.precaching.precacheAndRoute(self.__WB_MANIFEST);
|
||||||
{
|
|
||||||
blacklist: [/^\/_/, /\/[^/?]+\.[^/]+$/],
|
workbox.routing.registerNavigationRoute(
|
||||||
},
|
workbox.precaching.getCacheKeyForURL("./index.html"),
|
||||||
);
|
{
|
||||||
|
blacklist: [/^\/_/, /\/[^/?]+\.[^/]+$/],
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
// Cache relevant font files
|
// Cache relevant font files
|
||||||
workbox.routing.registerRoute(
|
workbox.routing.registerRoute(
|
@@ -1,51 +1,72 @@
|
|||||||
const fs = require("fs");
|
const fs = require("fs");
|
||||||
const { exec, execSync } = require("child_process");
|
const { exec, execSync } = require("child_process");
|
||||||
|
const core = require("@actions/core");
|
||||||
|
|
||||||
const excalidrawDir = `${__dirname}/../src/packages/excalidraw`;
|
const excalidrawDir = `${__dirname}/../src/packages/excalidraw`;
|
||||||
const excalidrawPackage = `${excalidrawDir}/package.json`;
|
const excalidrawPackage = `${excalidrawDir}/package.json`;
|
||||||
const pkg = require(excalidrawPackage);
|
const pkg = require(excalidrawPackage);
|
||||||
|
const isPreview = process.argv.slice(2)[0] === "preview";
|
||||||
|
|
||||||
const getShortCommitHash = () => {
|
const getShortCommitHash = () => {
|
||||||
return execSync("git rev-parse --short HEAD").toString().trim();
|
return execSync("git rev-parse --short HEAD").toString().trim();
|
||||||
};
|
};
|
||||||
|
|
||||||
const publish = () => {
|
const publish = () => {
|
||||||
|
const tag = isPreview ? "preview" : "next";
|
||||||
|
|
||||||
try {
|
try {
|
||||||
execSync(`yarn --frozen-lockfile`);
|
execSync(`yarn --frozen-lockfile`);
|
||||||
execSync(`yarn --frozen-lockfile`, { cwd: excalidrawDir });
|
execSync(`yarn --frozen-lockfile`, { cwd: excalidrawDir });
|
||||||
execSync(`yarn run build:umd`, { cwd: excalidrawDir });
|
execSync(`yarn run build:umd`, { cwd: excalidrawDir });
|
||||||
execSync(`yarn --cwd ${excalidrawDir} publish`);
|
execSync(`yarn --cwd ${excalidrawDir} publish --tag ${tag}`);
|
||||||
} catch (e) {
|
console.info(`Published ${pkg.name}@${tag}🎉`);
|
||||||
console.error(e);
|
core.setOutput(
|
||||||
|
"result",
|
||||||
|
`**Preview version has been shipped** :rocket:
|
||||||
|
You can use [@excalidraw/excalidraw@${pkg.version}](https://www.npmjs.com/package/@excalidraw/excalidraw/v/${pkg.version}) for testing!`,
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
core.setOutput("result", "package couldn't be published :warning:!");
|
||||||
|
console.error(error);
|
||||||
|
process.exit(1);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// get files changed between prev and head commit
|
// get files changed between prev and head commit
|
||||||
exec(`git diff --name-only HEAD^ HEAD`, async (error, stdout, stderr) => {
|
exec(`git diff --name-only HEAD^ HEAD`, async (error, stdout, stderr) => {
|
||||||
if (error || stderr) {
|
if (error || stderr) {
|
||||||
console.error(error);
|
console.error(error);
|
||||||
|
core.setOutput("result", ":warning: Package couldn't be published!");
|
||||||
process.exit(1);
|
process.exit(1);
|
||||||
}
|
}
|
||||||
|
|
||||||
const changedFiles = stdout.trim().split("\n");
|
const changedFiles = stdout.trim().split("\n");
|
||||||
const filesToIgnoreRegex = /src\/excalidraw-app|packages\/utils/;
|
const filesToIgnoreRegex = /src\/excalidraw-app|packages\/utils/;
|
||||||
|
|
||||||
const excalidrawPackageFiles = changedFiles.filter((file) => {
|
const excalidrawPackageFiles = changedFiles.filter((file) => {
|
||||||
return file.indexOf("src") >= 0 && !filesToIgnoreRegex.test(file);
|
return (
|
||||||
|
(file.indexOf("src") >= 0 || file.indexOf("package.json")) >= 0 &&
|
||||||
|
!filesToIgnoreRegex.test(file)
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!excalidrawPackageFiles.length) {
|
if (!excalidrawPackageFiles.length) {
|
||||||
|
console.info("Skipping release as no valid diff found");
|
||||||
|
core.setOutput("result", "Skipping release as no valid diff found");
|
||||||
process.exit(0);
|
process.exit(0);
|
||||||
}
|
}
|
||||||
|
|
||||||
// update package.json
|
// update package.json
|
||||||
pkg.version = `${pkg.version}-${getShortCommitHash()}`;
|
let version = `${pkg.version}-${getShortCommitHash()}`;
|
||||||
pkg.name = "@excalidraw/excalidraw-next";
|
|
||||||
fs.writeFileSync(excalidrawPackage, JSON.stringify(pkg, null, 2), "utf8");
|
|
||||||
|
|
||||||
// update readme
|
// update readme
|
||||||
const data = fs.readFileSync(`${excalidrawDir}/README_NEXT.md`, "utf8");
|
|
||||||
fs.writeFileSync(`${excalidrawDir}/README.md`, data, "utf8");
|
|
||||||
|
|
||||||
|
if (isPreview) {
|
||||||
|
// use pullNumber-commithash as the version for preview
|
||||||
|
const pullRequestNumber = process.argv.slice(3)[0];
|
||||||
|
version = `${pkg.version}-${pullRequestNumber}-${getShortCommitHash()}`;
|
||||||
|
}
|
||||||
|
pkg.version = version;
|
||||||
|
|
||||||
|
fs.writeFileSync(excalidrawPackage, JSON.stringify(pkg, null, 2), "utf8");
|
||||||
|
|
||||||
|
console.info("Publish in progress...");
|
||||||
publish();
|
publish();
|
||||||
});
|
});
|
||||||
|
@@ -1,11 +1,16 @@
|
|||||||
const { readdirSync, writeFileSync } = require("fs");
|
const { readdirSync, writeFileSync } = require("fs");
|
||||||
const files = readdirSync(`${__dirname}/../src/locales`);
|
const files = readdirSync(`${__dirname}/../src/locales`);
|
||||||
|
|
||||||
const flatten = (object) =>
|
const flatten = (object = {}, result = {}, extraKey = "") => {
|
||||||
Object.keys(object).reduce(
|
for (const key in object) {
|
||||||
(initial, current) => ({ ...initial, ...object[current] }),
|
if (typeof object[key] !== "object") {
|
||||||
{},
|
result[extraKey + key] = object[key];
|
||||||
);
|
} else {
|
||||||
|
flatten(object[key], result, `${extraKey}${key}.`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
};
|
||||||
|
|
||||||
const locales = files.filter(
|
const locales = files.filter(
|
||||||
(file) => file !== "README.md" && file !== "percentages.json",
|
(file) => file !== "README.md" && file !== "percentages.json",
|
||||||
@@ -19,10 +24,8 @@ for (let index = 0; index < locales.length; index++) {
|
|||||||
|
|
||||||
const allKeys = Object.keys(data);
|
const allKeys = Object.keys(data);
|
||||||
const translatedKeys = allKeys.filter((item) => data[item] !== "");
|
const translatedKeys = allKeys.filter((item) => data[item] !== "");
|
||||||
|
const percentage = Math.floor((100 * translatedKeys.length) / allKeys.length);
|
||||||
const percentage = (100 * translatedKeys.length) / allKeys.length;
|
percentages[currentLocale.replace(".json", "")] = percentage;
|
||||||
|
|
||||||
percentages[currentLocale.replace(".json", "")] = parseInt(percentage);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
writeFileSync(
|
writeFileSync(
|
||||||
|
21
scripts/buildDocs.js
Normal file
21
scripts/buildDocs.js
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
const { exec } = require("child_process");
|
||||||
|
|
||||||
|
// get files changed between prev and head commit
|
||||||
|
exec(`git diff --name-only HEAD^ HEAD`, async (error, stdout, stderr) => {
|
||||||
|
if (error || stderr) {
|
||||||
|
console.error(error);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
const changedFiles = stdout.trim().split("\n");
|
||||||
|
|
||||||
|
const docFiles = changedFiles.filter((file) => {
|
||||||
|
return file.indexOf("docs") >= 0;
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!docFiles.length) {
|
||||||
|
console.info("Skipping building docs as no valid diff found");
|
||||||
|
process.exit(0);
|
||||||
|
}
|
||||||
|
// Exit code 1 to build the docs in ignoredBuildStep
|
||||||
|
process.exit(1);
|
||||||
|
});
|
@@ -5,10 +5,13 @@ const THRESSHOLD = 85;
|
|||||||
const crowdinMap = {
|
const crowdinMap = {
|
||||||
"ar-SA": "en-ar",
|
"ar-SA": "en-ar",
|
||||||
"bg-BG": "en-bg",
|
"bg-BG": "en-bg",
|
||||||
|
"bn-BD": "en-bn",
|
||||||
"ca-ES": "en-ca",
|
"ca-ES": "en-ca",
|
||||||
|
"da-DK": "en-da",
|
||||||
"de-DE": "en-de",
|
"de-DE": "en-de",
|
||||||
"el-GR": "en-el",
|
"el-GR": "en-el",
|
||||||
"es-ES": "en-es",
|
"es-ES": "en-es",
|
||||||
|
"eu-ES": "en-eu",
|
||||||
"fa-IR": "en-fa",
|
"fa-IR": "en-fa",
|
||||||
"fi-FI": "en-fi",
|
"fi-FI": "en-fi",
|
||||||
"fr-FR": "en-fr",
|
"fr-FR": "en-fr",
|
||||||
@@ -31,19 +34,31 @@ const crowdinMap = {
|
|||||||
"pt-PT": "en-pt",
|
"pt-PT": "en-pt",
|
||||||
"ro-RO": "en-ro",
|
"ro-RO": "en-ro",
|
||||||
"ru-RU": "en-ru",
|
"ru-RU": "en-ru",
|
||||||
|
"si-LK": "en-silk",
|
||||||
"sk-SK": "en-sk",
|
"sk-SK": "en-sk",
|
||||||
|
"sl-SI": "en-sl",
|
||||||
"sv-SE": "en-sv",
|
"sv-SE": "en-sv",
|
||||||
|
"ta-IN": "en-ta",
|
||||||
"tr-TR": "en-tr",
|
"tr-TR": "en-tr",
|
||||||
"uk-UA": "en-uk",
|
"uk-UA": "en-uk",
|
||||||
"zh-CN": "en-zhcn",
|
"zh-CN": "en-zhcn",
|
||||||
|
"zh-HK": "en-zhhk",
|
||||||
"zh-TW": "en-zhtw",
|
"zh-TW": "en-zhtw",
|
||||||
|
"lt-LT": "en-lt",
|
||||||
"lv-LV": "en-lv",
|
"lv-LV": "en-lv",
|
||||||
|
"cs-CZ": "en-cs",
|
||||||
|
"kk-KZ": "en-kk",
|
||||||
|
"vi-vn": "en-vi",
|
||||||
|
"mr-in": "en-mr",
|
||||||
};
|
};
|
||||||
|
|
||||||
const flags = {
|
const flags = {
|
||||||
"ar-SA": "🇸🇦",
|
"ar-SA": "🇸🇦",
|
||||||
"bg-BG": "🇧🇬",
|
"bg-BG": "🇧🇬",
|
||||||
|
"bn-BD": "🇧🇩",
|
||||||
"ca-ES": "🏳",
|
"ca-ES": "🏳",
|
||||||
|
"cs-CZ": "🇨🇿",
|
||||||
|
"da-DK": "🇩🇰",
|
||||||
"de-DE": "🇩🇪",
|
"de-DE": "🇩🇪",
|
||||||
"el-GR": "🇬🇷",
|
"el-GR": "🇬🇷",
|
||||||
"es-ES": "🇪🇸",
|
"es-ES": "🇪🇸",
|
||||||
@@ -57,7 +72,10 @@ const flags = {
|
|||||||
"it-IT": "🇮🇹",
|
"it-IT": "🇮🇹",
|
||||||
"ja-JP": "🇯🇵",
|
"ja-JP": "🇯🇵",
|
||||||
"kab-KAB": "🏳",
|
"kab-KAB": "🏳",
|
||||||
|
"kk-KZ": "🇰🇿",
|
||||||
"ko-KR": "🇰🇷",
|
"ko-KR": "🇰🇷",
|
||||||
|
"lt-LT": "🇱🇹",
|
||||||
|
"lv-LV": "🇱🇻",
|
||||||
"my-MM": "🇲🇲",
|
"my-MM": "🇲🇲",
|
||||||
"nb-NO": "🇳🇴",
|
"nb-NO": "🇳🇴",
|
||||||
"nl-NL": "🇳🇱",
|
"nl-NL": "🇳🇱",
|
||||||
@@ -69,22 +87,32 @@ const flags = {
|
|||||||
"pt-PT": "🇵🇹",
|
"pt-PT": "🇵🇹",
|
||||||
"ro-RO": "🇷🇴",
|
"ro-RO": "🇷🇴",
|
||||||
"ru-RU": "🇷🇺",
|
"ru-RU": "🇷🇺",
|
||||||
|
"si-LK": "🇱🇰",
|
||||||
"sk-SK": "🇸🇰",
|
"sk-SK": "🇸🇰",
|
||||||
|
"sl-SI": "🇸🇮",
|
||||||
"sv-SE": "🇸🇪",
|
"sv-SE": "🇸🇪",
|
||||||
|
"ta-IN": "🇮🇳",
|
||||||
"tr-TR": "🇹🇷",
|
"tr-TR": "🇹🇷",
|
||||||
"uk-UA": "🇺🇦",
|
"uk-UA": "🇺🇦",
|
||||||
"zh-CN": "🇨🇳",
|
"zh-CN": "🇨🇳",
|
||||||
|
"zh-HK": "🇭🇰",
|
||||||
"zh-TW": "🇹🇼",
|
"zh-TW": "🇹🇼",
|
||||||
"lv-LV": "🇱🇻",
|
"eu-ES": "🇪🇦",
|
||||||
|
"vi-VN": "🇻🇳",
|
||||||
|
"mr-IN": "🇮🇳",
|
||||||
};
|
};
|
||||||
|
|
||||||
const languages = {
|
const languages = {
|
||||||
"ar-SA": "العربية",
|
"ar-SA": "العربية",
|
||||||
"bg-BG": "Български",
|
"bg-BG": "Български",
|
||||||
|
"bn-BD": "Bengali",
|
||||||
"ca-ES": "Català",
|
"ca-ES": "Català",
|
||||||
|
"cs-CZ": "Česky",
|
||||||
|
"da-DK": "Dansk",
|
||||||
"de-DE": "Deutsch",
|
"de-DE": "Deutsch",
|
||||||
"el-GR": "Ελληνικά",
|
"el-GR": "Ελληνικά",
|
||||||
"es-ES": "Español",
|
"es-ES": "Español",
|
||||||
|
"eu-ES": "Euskara",
|
||||||
"fa-IR": "فارسی",
|
"fa-IR": "فارسی",
|
||||||
"fi-FI": "Suomi",
|
"fi-FI": "Suomi",
|
||||||
"fr-FR": "Français",
|
"fr-FR": "Français",
|
||||||
@@ -95,7 +123,10 @@ const languages = {
|
|||||||
"it-IT": "Italiano",
|
"it-IT": "Italiano",
|
||||||
"ja-JP": "日本語",
|
"ja-JP": "日本語",
|
||||||
"kab-KAB": "Taqbaylit",
|
"kab-KAB": "Taqbaylit",
|
||||||
|
"kk-KZ": "Қазақ тілі",
|
||||||
"ko-KR": "한국어",
|
"ko-KR": "한국어",
|
||||||
|
"lt-LT": "Lietuvių",
|
||||||
|
"lv-LV": "Latviešu",
|
||||||
"my-MM": "Burmese",
|
"my-MM": "Burmese",
|
||||||
"nb-NO": "Norsk bokmål",
|
"nb-NO": "Norsk bokmål",
|
||||||
"nl-NL": "Nederlands",
|
"nl-NL": "Nederlands",
|
||||||
@@ -107,13 +138,18 @@ const languages = {
|
|||||||
"pt-PT": "Português",
|
"pt-PT": "Português",
|
||||||
"ro-RO": "Română",
|
"ro-RO": "Română",
|
||||||
"ru-RU": "Русский",
|
"ru-RU": "Русский",
|
||||||
|
"si-LK": "සිංහල",
|
||||||
"sk-SK": "Slovenčina",
|
"sk-SK": "Slovenčina",
|
||||||
|
"sl-SI": "Slovenščina",
|
||||||
"sv-SE": "Svenska",
|
"sv-SE": "Svenska",
|
||||||
|
"ta-IN": "Tamil",
|
||||||
"tr-TR": "Türkçe",
|
"tr-TR": "Türkçe",
|
||||||
"uk-UA": "Українська",
|
"uk-UA": "Українська",
|
||||||
"zh-CN": "简体中文",
|
"zh-CN": "简体中文",
|
||||||
|
"zh-HK": "繁體中文 (香港)",
|
||||||
"zh-TW": "繁體中文",
|
"zh-TW": "繁體中文",
|
||||||
"lv-LV": "Latviešu",
|
"vi-VN": "Tiếng Việt",
|
||||||
|
"mr-IN": "मराठी",
|
||||||
};
|
};
|
||||||
|
|
||||||
const percentages = fs.readFileSync(
|
const percentages = fs.readFileSync(
|
||||||
|
23
scripts/prebuild.js
Normal file
23
scripts/prebuild.js
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
const fs = require("fs");
|
||||||
|
const path = require("path");
|
||||||
|
|
||||||
|
// for development purposes we want to have the service-worker.js file
|
||||||
|
// accessible from the public folder. On build though, we need to compile it
|
||||||
|
// and CRA expects that file to be in src/ folder.
|
||||||
|
const moveServiceWorkerScript = () => {
|
||||||
|
const oldPath = path.resolve(__dirname, "../public/service-worker.js");
|
||||||
|
const newPath = path.resolve(__dirname, "../src/service-worker.js");
|
||||||
|
|
||||||
|
fs.rename(oldPath, newPath, (error) => {
|
||||||
|
if (error) {
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
console.info("public/service-worker.js moved to src/");
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------------
|
||||||
|
|
||||||
|
if (process.env.CI) {
|
||||||
|
moveServiceWorkerScript();
|
||||||
|
}
|
37
scripts/prerelease.js
Normal file
37
scripts/prerelease.js
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
const fs = require("fs");
|
||||||
|
const util = require("util");
|
||||||
|
const exec = util.promisify(require("child_process").exec);
|
||||||
|
const updateChangelog = require("./updateChangelog");
|
||||||
|
|
||||||
|
const excalidrawDir = `${__dirname}/../src/packages/excalidraw`;
|
||||||
|
const excalidrawPackage = `${excalidrawDir}/package.json`;
|
||||||
|
|
||||||
|
const updatePackageVersion = (nextVersion) => {
|
||||||
|
const pkg = require(excalidrawPackage);
|
||||||
|
pkg.version = nextVersion;
|
||||||
|
const content = `${JSON.stringify(pkg, null, 2)}\n`;
|
||||||
|
fs.writeFileSync(excalidrawPackage, content, "utf-8");
|
||||||
|
};
|
||||||
|
|
||||||
|
const prerelease = async (nextVersion) => {
|
||||||
|
try {
|
||||||
|
await updateChangelog(nextVersion);
|
||||||
|
updatePackageVersion(nextVersion);
|
||||||
|
await exec(`git add -u`);
|
||||||
|
await exec(
|
||||||
|
`git commit -m "docs: release @excalidraw/excalidraw@${nextVersion} 🎉"`,
|
||||||
|
);
|
||||||
|
|
||||||
|
console.info("Done!");
|
||||||
|
} catch (error) {
|
||||||
|
console.error(error);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const nextVersion = process.argv.slice(2)[0];
|
||||||
|
if (!nextVersion) {
|
||||||
|
console.error("Pass the next version to release!");
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
prerelease(nextVersion);
|
44
scripts/release.js
Normal file
44
scripts/release.js
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
const fs = require("fs");
|
||||||
|
const { execSync } = require("child_process");
|
||||||
|
|
||||||
|
const excalidrawDir = `${__dirname}/../src/packages/excalidraw`;
|
||||||
|
const excalidrawPackage = `${excalidrawDir}/package.json`;
|
||||||
|
const pkg = require(excalidrawPackage);
|
||||||
|
|
||||||
|
const originalReadMe = fs.readFileSync(`${excalidrawDir}/README.md`, "utf8");
|
||||||
|
|
||||||
|
const updateReadme = () => {
|
||||||
|
const excalidrawIndex = originalReadMe.indexOf("### Excalidraw");
|
||||||
|
|
||||||
|
// remove note for stable readme
|
||||||
|
const data = originalReadMe.slice(excalidrawIndex);
|
||||||
|
|
||||||
|
// update readme
|
||||||
|
fs.writeFileSync(`${excalidrawDir}/README.md`, data, "utf8");
|
||||||
|
};
|
||||||
|
|
||||||
|
const publish = () => {
|
||||||
|
try {
|
||||||
|
execSync(`yarn --frozen-lockfile`);
|
||||||
|
execSync(`yarn --frozen-lockfile`, { cwd: excalidrawDir });
|
||||||
|
execSync(`yarn run build:umd`, { cwd: excalidrawDir });
|
||||||
|
execSync(`yarn --cwd ${excalidrawDir} publish`);
|
||||||
|
} catch (error) {
|
||||||
|
console.error(error);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const release = () => {
|
||||||
|
updateReadme();
|
||||||
|
console.info("Note for stable readme removed");
|
||||||
|
|
||||||
|
publish();
|
||||||
|
console.info(`Published ${pkg.version}!`);
|
||||||
|
|
||||||
|
// revert readme after release
|
||||||
|
fs.writeFileSync(`${excalidrawDir}/README.md`, originalReadMe, "utf8");
|
||||||
|
console.info("Readme reverted");
|
||||||
|
};
|
||||||
|
|
||||||
|
release();
|
104
scripts/updateChangelog.js
Normal file
104
scripts/updateChangelog.js
Normal file
@@ -0,0 +1,104 @@
|
|||||||
|
const fs = require("fs");
|
||||||
|
const util = require("util");
|
||||||
|
const exec = util.promisify(require("child_process").exec);
|
||||||
|
|
||||||
|
const excalidrawDir = `${__dirname}/../src/packages/excalidraw`;
|
||||||
|
const excalidrawPackage = `${excalidrawDir}/package.json`;
|
||||||
|
const pkg = require(excalidrawPackage);
|
||||||
|
const lastVersion = pkg.version;
|
||||||
|
const existingChangeLog = fs.readFileSync(
|
||||||
|
`${excalidrawDir}/CHANGELOG.md`,
|
||||||
|
"utf8",
|
||||||
|
);
|
||||||
|
|
||||||
|
const supportedTypes = ["feat", "fix", "style", "refactor", "perf", "build"];
|
||||||
|
const headerForType = {
|
||||||
|
feat: "Features",
|
||||||
|
fix: "Fixes",
|
||||||
|
style: "Styles",
|
||||||
|
refactor: " Refactor",
|
||||||
|
perf: "Performance",
|
||||||
|
build: "Build",
|
||||||
|
};
|
||||||
|
const badCommits = [];
|
||||||
|
const getCommitHashForLastVersion = async () => {
|
||||||
|
try {
|
||||||
|
const commitMessage = `"release @excalidraw/excalidraw@${lastVersion}"`;
|
||||||
|
const { stdout } = await exec(
|
||||||
|
`git log --format=format:"%H" --grep=${commitMessage}`,
|
||||||
|
);
|
||||||
|
return stdout;
|
||||||
|
} catch (error) {
|
||||||
|
console.error(error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const getLibraryCommitsSinceLastRelease = async () => {
|
||||||
|
const commitHash = await getCommitHashForLastVersion();
|
||||||
|
const { stdout } = await exec(
|
||||||
|
`git log --pretty=format:%s ${commitHash}...master`,
|
||||||
|
);
|
||||||
|
const commitsSinceLastRelease = stdout.split("\n");
|
||||||
|
const commitList = {};
|
||||||
|
supportedTypes.forEach((type) => {
|
||||||
|
commitList[type] = [];
|
||||||
|
});
|
||||||
|
|
||||||
|
commitsSinceLastRelease.forEach((commit) => {
|
||||||
|
const indexOfColon = commit.indexOf(":");
|
||||||
|
const type = commit.slice(0, indexOfColon);
|
||||||
|
if (!supportedTypes.includes(type)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const messageWithoutType = commit.slice(indexOfColon + 1).trim();
|
||||||
|
const messageWithCapitalizeFirst =
|
||||||
|
messageWithoutType.charAt(0).toUpperCase() + messageWithoutType.slice(1);
|
||||||
|
const prMatch = commit.match(/\(#([0-9]*)\)/);
|
||||||
|
if (prMatch) {
|
||||||
|
const prNumber = prMatch[1];
|
||||||
|
|
||||||
|
// return if the changelog already contains the pr number which would happen for package updates
|
||||||
|
if (existingChangeLog.includes(prNumber)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const prMarkdown = `[#${prNumber}](https://github.com/excalidraw/excalidraw/pull/${prNumber})`;
|
||||||
|
const messageWithPRLink = messageWithCapitalizeFirst.replace(
|
||||||
|
/\(#[0-9]*\)/,
|
||||||
|
prMarkdown,
|
||||||
|
);
|
||||||
|
commitList[type].push(messageWithPRLink);
|
||||||
|
} else {
|
||||||
|
badCommits.push(commit);
|
||||||
|
commitList[type].push(messageWithCapitalizeFirst);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
console.info("Bad commits:", badCommits);
|
||||||
|
return commitList;
|
||||||
|
};
|
||||||
|
|
||||||
|
const updateChangelog = async (nextVersion) => {
|
||||||
|
const commitList = await getLibraryCommitsSinceLastRelease();
|
||||||
|
let changelogForLibrary =
|
||||||
|
"## Excalidraw Library\n\n**_This section lists the updates made to the excalidraw library and will not affect the integration._**\n\n";
|
||||||
|
supportedTypes.forEach((type) => {
|
||||||
|
if (commitList[type].length) {
|
||||||
|
changelogForLibrary += `### ${headerForType[type]}\n\n`;
|
||||||
|
const commits = commitList[type];
|
||||||
|
commits.forEach((commit) => {
|
||||||
|
changelogForLibrary += `- ${commit}\n\n`;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
changelogForLibrary += "---\n";
|
||||||
|
const lastVersionIndex = existingChangeLog.indexOf(`## ${lastVersion}`);
|
||||||
|
let updatedContent =
|
||||||
|
existingChangeLog.slice(0, lastVersionIndex) +
|
||||||
|
changelogForLibrary +
|
||||||
|
existingChangeLog.slice(lastVersionIndex);
|
||||||
|
const currentDate = new Date().toISOString().slice(0, 10);
|
||||||
|
const newVersion = `## ${nextVersion} (${currentDate})`;
|
||||||
|
updatedContent = updatedContent.replace(`## Unreleased`, newVersion);
|
||||||
|
fs.writeFileSync(`${excalidrawDir}/CHANGELOG.md`, updatedContent, "utf8");
|
||||||
|
};
|
||||||
|
|
||||||
|
module.exports = updateChangelog;
|
@@ -2,22 +2,59 @@ import { register } from "./register";
|
|||||||
import { getSelectedElements } from "../scene";
|
import { getSelectedElements } from "../scene";
|
||||||
import { getNonDeletedElements } from "../element";
|
import { getNonDeletedElements } from "../element";
|
||||||
import { deepCopyElement } from "../element/newElement";
|
import { deepCopyElement } from "../element/newElement";
|
||||||
|
import { randomId } from "../random";
|
||||||
|
import { t } from "../i18n";
|
||||||
|
|
||||||
export const actionAddToLibrary = register({
|
export const actionAddToLibrary = register({
|
||||||
name: "addToLibrary",
|
name: "addToLibrary",
|
||||||
|
trackEvent: { category: "element" },
|
||||||
perform: (elements, appState, _, app) => {
|
perform: (elements, appState, _, app) => {
|
||||||
const selectedElements = getSelectedElements(
|
const selectedElements = getSelectedElements(
|
||||||
getNonDeletedElements(elements),
|
getNonDeletedElements(elements),
|
||||||
appState,
|
appState,
|
||||||
|
true,
|
||||||
);
|
);
|
||||||
|
if (selectedElements.some((element) => element.type === "image")) {
|
||||||
|
return {
|
||||||
|
commitToHistory: false,
|
||||||
|
appState: {
|
||||||
|
...appState,
|
||||||
|
errorMessage: "Support for adding images to the library coming soon!",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
app.library.loadLibrary().then((items) => {
|
return app.library
|
||||||
app.library.saveLibrary([
|
.getLatestLibrary()
|
||||||
...items,
|
.then((items) => {
|
||||||
selectedElements.map(deepCopyElement),
|
return app.library.setLibrary([
|
||||||
]);
|
{
|
||||||
});
|
id: randomId(),
|
||||||
return false;
|
status: "unpublished",
|
||||||
|
elements: selectedElements.map(deepCopyElement),
|
||||||
|
created: Date.now(),
|
||||||
|
},
|
||||||
|
...items,
|
||||||
|
]);
|
||||||
|
})
|
||||||
|
.then(() => {
|
||||||
|
return {
|
||||||
|
commitToHistory: false,
|
||||||
|
appState: {
|
||||||
|
...appState,
|
||||||
|
toast: { message: t("toast.addedToLibrary") },
|
||||||
|
},
|
||||||
|
};
|
||||||
|
})
|
||||||
|
.catch((error) => {
|
||||||
|
return {
|
||||||
|
commitToHistory: false,
|
||||||
|
appState: {
|
||||||
|
...appState,
|
||||||
|
errorMessage: error.message,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
});
|
||||||
},
|
},
|
||||||
contextItemLabel: "labels.addToLibrary",
|
contextItemLabel: "labels.addToLibrary",
|
||||||
});
|
});
|
||||||
|
@@ -1,4 +1,3 @@
|
|||||||
import React from "react";
|
|
||||||
import { alignElements, Alignment } from "../align";
|
import { alignElements, Alignment } from "../align";
|
||||||
import {
|
import {
|
||||||
AlignBottomIcon,
|
AlignBottomIcon,
|
||||||
@@ -9,13 +8,13 @@ import {
|
|||||||
CenterVerticallyIcon,
|
CenterVerticallyIcon,
|
||||||
} from "../components/icons";
|
} from "../components/icons";
|
||||||
import { ToolButton } from "../components/ToolButton";
|
import { ToolButton } from "../components/ToolButton";
|
||||||
import { getElementMap, getNonDeletedElements } from "../element";
|
import { getNonDeletedElements } from "../element";
|
||||||
import { ExcalidrawElement } from "../element/types";
|
import { ExcalidrawElement } from "../element/types";
|
||||||
import { t } from "../i18n";
|
import { t } from "../i18n";
|
||||||
import { KEYS } from "../keys";
|
import { KEYS } from "../keys";
|
||||||
import { getSelectedElements, isSomeElementSelected } from "../scene";
|
import { getSelectedElements, isSomeElementSelected } from "../scene";
|
||||||
import { AppState } from "../types";
|
import { AppState } from "../types";
|
||||||
import { getShortcutKey } from "../utils";
|
import { arrayToMap, getShortcutKey } from "../utils";
|
||||||
import { register } from "./register";
|
import { register } from "./register";
|
||||||
|
|
||||||
const enableActionGroup = (
|
const enableActionGroup = (
|
||||||
@@ -35,13 +34,16 @@ const alignSelectedElements = (
|
|||||||
|
|
||||||
const updatedElements = alignElements(selectedElements, alignment);
|
const updatedElements = alignElements(selectedElements, alignment);
|
||||||
|
|
||||||
const updatedElementsMap = getElementMap(updatedElements);
|
const updatedElementsMap = arrayToMap(updatedElements);
|
||||||
|
|
||||||
return elements.map((element) => updatedElementsMap[element.id] || element);
|
return elements.map(
|
||||||
|
(element) => updatedElementsMap.get(element.id) || element,
|
||||||
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
export const actionAlignTop = register({
|
export const actionAlignTop = register({
|
||||||
name: "alignTop",
|
name: "alignTop",
|
||||||
|
trackEvent: { category: "element" },
|
||||||
perform: (elements, appState) => {
|
perform: (elements, appState) => {
|
||||||
return {
|
return {
|
||||||
appState,
|
appState,
|
||||||
@@ -71,6 +73,7 @@ export const actionAlignTop = register({
|
|||||||
|
|
||||||
export const actionAlignBottom = register({
|
export const actionAlignBottom = register({
|
||||||
name: "alignBottom",
|
name: "alignBottom",
|
||||||
|
trackEvent: { category: "element" },
|
||||||
perform: (elements, appState) => {
|
perform: (elements, appState) => {
|
||||||
return {
|
return {
|
||||||
appState,
|
appState,
|
||||||
@@ -100,6 +103,7 @@ export const actionAlignBottom = register({
|
|||||||
|
|
||||||
export const actionAlignLeft = register({
|
export const actionAlignLeft = register({
|
||||||
name: "alignLeft",
|
name: "alignLeft",
|
||||||
|
trackEvent: { category: "element" },
|
||||||
perform: (elements, appState) => {
|
perform: (elements, appState) => {
|
||||||
return {
|
return {
|
||||||
appState,
|
appState,
|
||||||
@@ -129,6 +133,8 @@ export const actionAlignLeft = register({
|
|||||||
|
|
||||||
export const actionAlignRight = register({
|
export const actionAlignRight = register({
|
||||||
name: "alignRight",
|
name: "alignRight",
|
||||||
|
trackEvent: { category: "element" },
|
||||||
|
|
||||||
perform: (elements, appState) => {
|
perform: (elements, appState) => {
|
||||||
return {
|
return {
|
||||||
appState,
|
appState,
|
||||||
@@ -158,6 +164,8 @@ export const actionAlignRight = register({
|
|||||||
|
|
||||||
export const actionAlignVerticallyCentered = register({
|
export const actionAlignVerticallyCentered = register({
|
||||||
name: "alignVerticallyCentered",
|
name: "alignVerticallyCentered",
|
||||||
|
trackEvent: { category: "element" },
|
||||||
|
|
||||||
perform: (elements, appState) => {
|
perform: (elements, appState) => {
|
||||||
return {
|
return {
|
||||||
appState,
|
appState,
|
||||||
@@ -183,6 +191,7 @@ export const actionAlignVerticallyCentered = register({
|
|||||||
|
|
||||||
export const actionAlignHorizontallyCentered = register({
|
export const actionAlignHorizontallyCentered = register({
|
||||||
name: "alignHorizontallyCentered",
|
name: "alignHorizontallyCentered",
|
||||||
|
trackEvent: { category: "element" },
|
||||||
perform: (elements, appState) => {
|
perform: (elements, appState) => {
|
||||||
return {
|
return {
|
||||||
appState,
|
appState,
|
||||||
|
136
src/actions/actionBoundText.tsx
Normal file
136
src/actions/actionBoundText.tsx
Normal file
@@ -0,0 +1,136 @@
|
|||||||
|
import { VERTICAL_ALIGN } from "../constants";
|
||||||
|
import { getNonDeletedElements, isTextElement } from "../element";
|
||||||
|
import { mutateElement } from "../element/mutateElement";
|
||||||
|
import {
|
||||||
|
getBoundTextElement,
|
||||||
|
measureText,
|
||||||
|
redrawTextBoundingBox,
|
||||||
|
} from "../element/textElement";
|
||||||
|
import {
|
||||||
|
hasBoundTextElement,
|
||||||
|
isTextBindableContainer,
|
||||||
|
} from "../element/typeChecks";
|
||||||
|
import {
|
||||||
|
ExcalidrawTextContainer,
|
||||||
|
ExcalidrawTextElement,
|
||||||
|
} from "../element/types";
|
||||||
|
import { getSelectedElements } from "../scene";
|
||||||
|
import { getFontString } from "../utils";
|
||||||
|
import { register } from "./register";
|
||||||
|
|
||||||
|
export const actionUnbindText = register({
|
||||||
|
name: "unbindText",
|
||||||
|
contextItemLabel: "labels.unbindText",
|
||||||
|
trackEvent: { category: "element" },
|
||||||
|
contextItemPredicate: (elements, appState) => {
|
||||||
|
const selectedElements = getSelectedElements(elements, appState);
|
||||||
|
return selectedElements.some((element) => hasBoundTextElement(element));
|
||||||
|
},
|
||||||
|
perform: (elements, appState) => {
|
||||||
|
const selectedElements = getSelectedElements(
|
||||||
|
getNonDeletedElements(elements),
|
||||||
|
appState,
|
||||||
|
);
|
||||||
|
selectedElements.forEach((element) => {
|
||||||
|
const boundTextElement = getBoundTextElement(element);
|
||||||
|
if (boundTextElement) {
|
||||||
|
const { width, height, baseline } = measureText(
|
||||||
|
boundTextElement.originalText,
|
||||||
|
getFontString(boundTextElement),
|
||||||
|
);
|
||||||
|
mutateElement(boundTextElement as ExcalidrawTextElement, {
|
||||||
|
containerId: null,
|
||||||
|
width,
|
||||||
|
height,
|
||||||
|
baseline,
|
||||||
|
text: boundTextElement.originalText,
|
||||||
|
});
|
||||||
|
mutateElement(element, {
|
||||||
|
boundElements: element.boundElements?.filter(
|
||||||
|
(ele) => ele.id !== boundTextElement.id,
|
||||||
|
),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return {
|
||||||
|
elements,
|
||||||
|
appState,
|
||||||
|
commitToHistory: true,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
export const actionBindText = register({
|
||||||
|
name: "bindText",
|
||||||
|
contextItemLabel: "labels.bindText",
|
||||||
|
trackEvent: { category: "element" },
|
||||||
|
contextItemPredicate: (elements, appState) => {
|
||||||
|
const selectedElements = getSelectedElements(elements, appState);
|
||||||
|
|
||||||
|
if (selectedElements.length === 2) {
|
||||||
|
const textElement =
|
||||||
|
isTextElement(selectedElements[0]) ||
|
||||||
|
isTextElement(selectedElements[1]);
|
||||||
|
|
||||||
|
let bindingContainer;
|
||||||
|
if (isTextBindableContainer(selectedElements[0])) {
|
||||||
|
bindingContainer = selectedElements[0];
|
||||||
|
} else if (isTextBindableContainer(selectedElements[1])) {
|
||||||
|
bindingContainer = selectedElements[1];
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
textElement &&
|
||||||
|
bindingContainer &&
|
||||||
|
getBoundTextElement(bindingContainer) === null
|
||||||
|
) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
},
|
||||||
|
perform: (elements, appState) => {
|
||||||
|
const selectedElements = getSelectedElements(
|
||||||
|
getNonDeletedElements(elements),
|
||||||
|
appState,
|
||||||
|
);
|
||||||
|
|
||||||
|
let textElement: ExcalidrawTextElement;
|
||||||
|
let container: ExcalidrawTextContainer;
|
||||||
|
|
||||||
|
if (
|
||||||
|
isTextElement(selectedElements[0]) &&
|
||||||
|
isTextBindableContainer(selectedElements[1])
|
||||||
|
) {
|
||||||
|
textElement = selectedElements[0];
|
||||||
|
container = selectedElements[1];
|
||||||
|
} else {
|
||||||
|
textElement = selectedElements[1] as ExcalidrawTextElement;
|
||||||
|
container = selectedElements[0] as ExcalidrawTextContainer;
|
||||||
|
}
|
||||||
|
mutateElement(textElement, {
|
||||||
|
containerId: container.id,
|
||||||
|
verticalAlign: VERTICAL_ALIGN.MIDDLE,
|
||||||
|
});
|
||||||
|
mutateElement(container, {
|
||||||
|
boundElements: (container.boundElements || []).concat({
|
||||||
|
type: "text",
|
||||||
|
id: textElement.id,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
redrawTextBoundingBox(textElement, container);
|
||||||
|
const updatedElements = elements.slice();
|
||||||
|
const textElementIndex = updatedElements.findIndex(
|
||||||
|
(ele) => ele.id === textElement.id,
|
||||||
|
);
|
||||||
|
updatedElements.splice(textElementIndex, 1);
|
||||||
|
const containerIndex = updatedElements.findIndex(
|
||||||
|
(ele) => ele.id === container.id,
|
||||||
|
);
|
||||||
|
updatedElements.splice(containerIndex + 1, 0, textElement);
|
||||||
|
return {
|
||||||
|
elements: updatedElements,
|
||||||
|
appState: { ...appState, selectedElementIds: { [container.id]: true } },
|
||||||
|
commitToHistory: true,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
});
|
@@ -1,40 +1,48 @@
|
|||||||
import React from "react";
|
|
||||||
import { getDefaultAppState } from "../appState";
|
|
||||||
import { ColorPicker } from "../components/ColorPicker";
|
import { ColorPicker } from "../components/ColorPicker";
|
||||||
import { resetZoom, trash, zoomIn, zoomOut } from "../components/icons";
|
import { eraser, zoomIn, zoomOut } from "../components/icons";
|
||||||
import { ToolButton } from "../components/ToolButton";
|
import { ToolButton } from "../components/ToolButton";
|
||||||
import { DarkModeToggle } from "../components/DarkModeToggle";
|
import { DarkModeToggle } from "../components/DarkModeToggle";
|
||||||
import { ZOOM_STEP } from "../constants";
|
import { THEME, ZOOM_STEP } from "../constants";
|
||||||
import { getCommonBounds, getNonDeletedElements } from "../element";
|
import { getCommonBounds, getNonDeletedElements } from "../element";
|
||||||
import { newElementWith } from "../element/mutateElement";
|
|
||||||
import { ExcalidrawElement } from "../element/types";
|
import { ExcalidrawElement } from "../element/types";
|
||||||
import { t } from "../i18n";
|
import { t } from "../i18n";
|
||||||
import { useIsMobile } from "../components/App";
|
|
||||||
import { CODES, KEYS } from "../keys";
|
import { CODES, KEYS } from "../keys";
|
||||||
import { getNormalizedZoom, getSelectedElements } from "../scene";
|
import { getNormalizedZoom, getSelectedElements } from "../scene";
|
||||||
import { centerScrollOn } from "../scene/scroll";
|
import { centerScrollOn } from "../scene/scroll";
|
||||||
import { getNewZoom } from "../scene/zoom";
|
import { getStateForZoom } from "../scene/zoom";
|
||||||
import { AppState, NormalizedZoomValue } from "../types";
|
import { AppState, NormalizedZoomValue } from "../types";
|
||||||
import { getShortcutKey } from "../utils";
|
import { getShortcutKey, updateActiveTool } from "../utils";
|
||||||
import { register } from "./register";
|
import { register } from "./register";
|
||||||
|
import { Tooltip } from "../components/Tooltip";
|
||||||
|
import { newElementWith } from "../element/mutateElement";
|
||||||
|
import { getDefaultAppState, isEraserActive } from "../appState";
|
||||||
|
import ClearCanvas from "../components/ClearCanvas";
|
||||||
|
import clsx from "clsx";
|
||||||
|
|
||||||
export const actionChangeViewBackgroundColor = register({
|
export const actionChangeViewBackgroundColor = register({
|
||||||
name: "changeViewBackgroundColor",
|
name: "changeViewBackgroundColor",
|
||||||
|
trackEvent: false,
|
||||||
perform: (_, appState, value) => {
|
perform: (_, appState, value) => {
|
||||||
return {
|
return {
|
||||||
appState: { ...appState, viewBackgroundColor: value },
|
appState: { ...appState, ...value },
|
||||||
commitToHistory: true,
|
commitToHistory: !!value.viewBackgroundColor,
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
PanelComponent: ({ appState, updateData }) => {
|
PanelComponent: ({ elements, appState, updateData }) => {
|
||||||
return (
|
return (
|
||||||
<div style={{ position: "relative" }}>
|
<div style={{ position: "relative" }}>
|
||||||
<ColorPicker
|
<ColorPicker
|
||||||
label={t("labels.canvasBackground")}
|
label={t("labels.canvasBackground")}
|
||||||
type="canvasBackground"
|
type="canvasBackground"
|
||||||
color={appState.viewBackgroundColor}
|
color={appState.viewBackgroundColor}
|
||||||
onChange={(color) => updateData(color)}
|
onChange={(color) => updateData({ viewBackgroundColor: color })}
|
||||||
|
isActive={appState.openPopup === "canvasColorPicker"}
|
||||||
|
setActive={(active) =>
|
||||||
|
updateData({ openPopup: active ? "canvasColorPicker" : null })
|
||||||
|
}
|
||||||
data-testid="canvas-background-picker"
|
data-testid="canvas-background-picker"
|
||||||
|
elements={elements}
|
||||||
|
appState={appState}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@@ -43,55 +51,51 @@ export const actionChangeViewBackgroundColor = register({
|
|||||||
|
|
||||||
export const actionClearCanvas = register({
|
export const actionClearCanvas = register({
|
||||||
name: "clearCanvas",
|
name: "clearCanvas",
|
||||||
perform: (elements, appState: AppState) => {
|
trackEvent: { category: "canvas" },
|
||||||
|
perform: (elements, appState, _, app) => {
|
||||||
|
app.imageCache.clear();
|
||||||
return {
|
return {
|
||||||
elements: elements.map((element) =>
|
elements: elements.map((element) =>
|
||||||
newElementWith(element, { isDeleted: true }),
|
newElementWith(element, { isDeleted: true }),
|
||||||
),
|
),
|
||||||
appState: {
|
appState: {
|
||||||
...getDefaultAppState(),
|
...getDefaultAppState(),
|
||||||
|
files: {},
|
||||||
theme: appState.theme,
|
theme: appState.theme,
|
||||||
elementLocked: appState.elementLocked,
|
penMode: appState.penMode,
|
||||||
|
penDetected: appState.penDetected,
|
||||||
exportBackground: appState.exportBackground,
|
exportBackground: appState.exportBackground,
|
||||||
exportEmbedScene: appState.exportEmbedScene,
|
exportEmbedScene: appState.exportEmbedScene,
|
||||||
gridSize: appState.gridSize,
|
gridSize: appState.gridSize,
|
||||||
shouldAddWatermark: appState.shouldAddWatermark,
|
|
||||||
showStats: appState.showStats,
|
showStats: appState.showStats,
|
||||||
pasteDialog: appState.pasteDialog,
|
pasteDialog: appState.pasteDialog,
|
||||||
|
activeTool:
|
||||||
|
appState.activeTool.type === "image"
|
||||||
|
? { ...appState.activeTool, type: "selection" }
|
||||||
|
: appState.activeTool,
|
||||||
},
|
},
|
||||||
commitToHistory: true,
|
commitToHistory: true,
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
PanelComponent: ({ updateData }) => (
|
|
||||||
<ToolButton
|
PanelComponent: ({ updateData }) => <ClearCanvas onConfirm={updateData} />,
|
||||||
type="button"
|
|
||||||
icon={trash}
|
|
||||||
title={t("buttons.clearReset")}
|
|
||||||
aria-label={t("buttons.clearReset")}
|
|
||||||
showAriaLabel={useIsMobile()}
|
|
||||||
onClick={() => {
|
|
||||||
if (window.confirm(t("alerts.clearReset"))) {
|
|
||||||
updateData(null);
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
data-testid="clear-canvas-button"
|
|
||||||
/>
|
|
||||||
),
|
|
||||||
});
|
});
|
||||||
|
|
||||||
export const actionZoomIn = register({
|
export const actionZoomIn = register({
|
||||||
name: "zoomIn",
|
name: "zoomIn",
|
||||||
perform: (_elements, appState) => {
|
trackEvent: { category: "canvas" },
|
||||||
const zoom = getNewZoom(
|
perform: (_elements, appState, _, app) => {
|
||||||
getNormalizedZoom(appState.zoom.value + ZOOM_STEP),
|
|
||||||
appState.zoom,
|
|
||||||
{ left: appState.offsetLeft, top: appState.offsetTop },
|
|
||||||
{ x: appState.width / 2, y: appState.height / 2 },
|
|
||||||
);
|
|
||||||
return {
|
return {
|
||||||
appState: {
|
appState: {
|
||||||
...appState,
|
...appState,
|
||||||
zoom,
|
...getStateForZoom(
|
||||||
|
{
|
||||||
|
viewportX: appState.width / 2 + appState.offsetLeft,
|
||||||
|
viewportY: appState.height / 2 + appState.offsetTop,
|
||||||
|
nextZoom: getNormalizedZoom(appState.zoom.value + ZOOM_STEP),
|
||||||
|
},
|
||||||
|
appState,
|
||||||
|
),
|
||||||
},
|
},
|
||||||
commitToHistory: false,
|
commitToHistory: false,
|
||||||
};
|
};
|
||||||
@@ -105,6 +109,7 @@ export const actionZoomIn = register({
|
|||||||
onClick={() => {
|
onClick={() => {
|
||||||
updateData(null);
|
updateData(null);
|
||||||
}}
|
}}
|
||||||
|
size="small"
|
||||||
/>
|
/>
|
||||||
),
|
),
|
||||||
keyTest: (event) =>
|
keyTest: (event) =>
|
||||||
@@ -114,18 +119,19 @@ export const actionZoomIn = register({
|
|||||||
|
|
||||||
export const actionZoomOut = register({
|
export const actionZoomOut = register({
|
||||||
name: "zoomOut",
|
name: "zoomOut",
|
||||||
perform: (_elements, appState) => {
|
trackEvent: { category: "canvas" },
|
||||||
const zoom = getNewZoom(
|
perform: (_elements, appState, _, app) => {
|
||||||
getNormalizedZoom(appState.zoom.value - ZOOM_STEP),
|
|
||||||
appState.zoom,
|
|
||||||
{ left: appState.offsetLeft, top: appState.offsetTop },
|
|
||||||
{ x: appState.width / 2, y: appState.height / 2 },
|
|
||||||
);
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
appState: {
|
appState: {
|
||||||
...appState,
|
...appState,
|
||||||
zoom,
|
...getStateForZoom(
|
||||||
|
{
|
||||||
|
viewportX: appState.width / 2 + appState.offsetLeft,
|
||||||
|
viewportY: appState.height / 2 + appState.offsetTop,
|
||||||
|
nextZoom: getNormalizedZoom(appState.zoom.value - ZOOM_STEP),
|
||||||
|
},
|
||||||
|
appState,
|
||||||
|
),
|
||||||
},
|
},
|
||||||
commitToHistory: false,
|
commitToHistory: false,
|
||||||
};
|
};
|
||||||
@@ -139,6 +145,7 @@ export const actionZoomOut = register({
|
|||||||
onClick={() => {
|
onClick={() => {
|
||||||
updateData(null);
|
updateData(null);
|
||||||
}}
|
}}
|
||||||
|
size="small"
|
||||||
/>
|
/>
|
||||||
),
|
),
|
||||||
keyTest: (event) =>
|
keyTest: (event) =>
|
||||||
@@ -148,33 +155,38 @@ export const actionZoomOut = register({
|
|||||||
|
|
||||||
export const actionResetZoom = register({
|
export const actionResetZoom = register({
|
||||||
name: "resetZoom",
|
name: "resetZoom",
|
||||||
perform: (_elements, appState) => {
|
trackEvent: { category: "canvas" },
|
||||||
|
perform: (_elements, appState, _, app) => {
|
||||||
return {
|
return {
|
||||||
appState: {
|
appState: {
|
||||||
...appState,
|
...appState,
|
||||||
zoom: getNewZoom(
|
...getStateForZoom(
|
||||||
1 as NormalizedZoomValue,
|
|
||||||
appState.zoom,
|
|
||||||
{ left: appState.offsetLeft, top: appState.offsetTop },
|
|
||||||
{
|
{
|
||||||
x: appState.width / 2,
|
viewportX: appState.width / 2 + appState.offsetLeft,
|
||||||
y: appState.height / 2,
|
viewportY: appState.height / 2 + appState.offsetTop,
|
||||||
|
nextZoom: getNormalizedZoom(1),
|
||||||
},
|
},
|
||||||
|
appState,
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
commitToHistory: false,
|
commitToHistory: false,
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
PanelComponent: ({ updateData }) => (
|
PanelComponent: ({ updateData, appState }) => (
|
||||||
<ToolButton
|
<Tooltip label={t("buttons.resetZoom")} style={{ height: "100%" }}>
|
||||||
type="button"
|
<ToolButton
|
||||||
icon={resetZoom}
|
type="button"
|
||||||
title={t("buttons.resetZoom")}
|
className="reset-zoom-button"
|
||||||
aria-label={t("buttons.resetZoom")}
|
title={t("buttons.resetZoom")}
|
||||||
onClick={() => {
|
aria-label={t("buttons.resetZoom")}
|
||||||
updateData(null);
|
onClick={() => {
|
||||||
}}
|
updateData(null);
|
||||||
/>
|
}}
|
||||||
|
size="small"
|
||||||
|
>
|
||||||
|
{(appState.zoom.value * 100).toFixed(0)}%
|
||||||
|
</ToolButton>
|
||||||
|
</Tooltip>
|
||||||
),
|
),
|
||||||
keyTest: (event) =>
|
keyTest: (event) =>
|
||||||
(event.code === CODES.ZERO || event.code === CODES.NUM_ZERO) &&
|
(event.code === CODES.ZERO || event.code === CODES.NUM_ZERO) &&
|
||||||
@@ -213,14 +225,12 @@ const zoomToFitElements = (
|
|||||||
? getCommonBounds(selectedElements)
|
? getCommonBounds(selectedElements)
|
||||||
: getCommonBounds(nonDeletedElements);
|
: getCommonBounds(nonDeletedElements);
|
||||||
|
|
||||||
const zoomValue = zoomValueToFitBoundsOnViewport(commonBounds, {
|
const newZoom = {
|
||||||
width: appState.width,
|
value: zoomValueToFitBoundsOnViewport(commonBounds, {
|
||||||
height: appState.height,
|
width: appState.width,
|
||||||
});
|
height: appState.height,
|
||||||
const newZoom = getNewZoom(zoomValue, appState.zoom, {
|
}),
|
||||||
left: appState.offsetLeft,
|
};
|
||||||
top: appState.offsetTop,
|
|
||||||
});
|
|
||||||
|
|
||||||
const [x1, y1, x2, y2] = commonBounds;
|
const [x1, y1, x2, y2] = commonBounds;
|
||||||
const centerX = (x1 + x2) / 2;
|
const centerX = (x1 + x2) / 2;
|
||||||
@@ -244,6 +254,7 @@ const zoomToFitElements = (
|
|||||||
|
|
||||||
export const actionZoomToSelected = register({
|
export const actionZoomToSelected = register({
|
||||||
name: "zoomToSelection",
|
name: "zoomToSelection",
|
||||||
|
trackEvent: { category: "canvas" },
|
||||||
perform: (elements, appState) => zoomToFitElements(elements, appState, true),
|
perform: (elements, appState) => zoomToFitElements(elements, appState, true),
|
||||||
keyTest: (event) =>
|
keyTest: (event) =>
|
||||||
event.code === CODES.TWO &&
|
event.code === CODES.TWO &&
|
||||||
@@ -254,6 +265,7 @@ export const actionZoomToSelected = register({
|
|||||||
|
|
||||||
export const actionZoomToFit = register({
|
export const actionZoomToFit = register({
|
||||||
name: "zoomToFit",
|
name: "zoomToFit",
|
||||||
|
trackEvent: { category: "canvas" },
|
||||||
perform: (elements, appState) => zoomToFitElements(elements, appState, false),
|
perform: (elements, appState) => zoomToFitElements(elements, appState, false),
|
||||||
keyTest: (event) =>
|
keyTest: (event) =>
|
||||||
event.code === CODES.ONE &&
|
event.code === CODES.ONE &&
|
||||||
@@ -264,11 +276,13 @@ export const actionZoomToFit = register({
|
|||||||
|
|
||||||
export const actionToggleTheme = register({
|
export const actionToggleTheme = register({
|
||||||
name: "toggleTheme",
|
name: "toggleTheme",
|
||||||
|
trackEvent: { category: "canvas" },
|
||||||
perform: (_, appState, value) => {
|
perform: (_, appState, value) => {
|
||||||
return {
|
return {
|
||||||
appState: {
|
appState: {
|
||||||
...appState,
|
...appState,
|
||||||
theme: value || (appState.theme === "light" ? "dark" : "light"),
|
theme:
|
||||||
|
value || (appState.theme === THEME.LIGHT ? THEME.DARK : THEME.LIGHT),
|
||||||
},
|
},
|
||||||
commitToHistory: false,
|
commitToHistory: false,
|
||||||
};
|
};
|
||||||
@@ -285,3 +299,49 @@ export const actionToggleTheme = register({
|
|||||||
),
|
),
|
||||||
keyTest: (event) => event.altKey && event.shiftKey && event.code === CODES.D,
|
keyTest: (event) => event.altKey && event.shiftKey && event.code === CODES.D,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
export const actionErase = register({
|
||||||
|
name: "eraser",
|
||||||
|
trackEvent: { category: "toolbar" },
|
||||||
|
perform: (elements, appState) => {
|
||||||
|
let activeTool: AppState["activeTool"];
|
||||||
|
|
||||||
|
if (isEraserActive(appState)) {
|
||||||
|
activeTool = updateActiveTool(appState, {
|
||||||
|
...(appState.activeTool.lastActiveToolBeforeEraser || {
|
||||||
|
type: "selection",
|
||||||
|
}),
|
||||||
|
lastActiveToolBeforeEraser: null,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
activeTool = updateActiveTool(appState, {
|
||||||
|
type: "eraser",
|
||||||
|
lastActiveToolBeforeEraser: appState.activeTool,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
appState: {
|
||||||
|
...appState,
|
||||||
|
selectedElementIds: {},
|
||||||
|
selectedGroupIds: {},
|
||||||
|
activeTool,
|
||||||
|
},
|
||||||
|
commitToHistory: true,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
keyTest: (event) => event.key === KEYS.E,
|
||||||
|
PanelComponent: ({ elements, appState, updateData, data }) => (
|
||||||
|
<ToolButton
|
||||||
|
type="button"
|
||||||
|
icon={eraser}
|
||||||
|
className={clsx("eraser", { active: isEraserActive(appState) })}
|
||||||
|
title={`${t("toolBar.eraser")}-${getShortcutKey("E")}`}
|
||||||
|
aria-label={t("toolBar.eraser")}
|
||||||
|
onClick={() => {
|
||||||
|
updateData(null);
|
||||||
|
}}
|
||||||
|
size={data?.size || "medium"}
|
||||||
|
></ToolButton>
|
||||||
|
),
|
||||||
|
});
|
||||||
|
@@ -1,16 +1,23 @@
|
|||||||
import { CODES, KEYS } from "../keys";
|
import { CODES, KEYS } from "../keys";
|
||||||
import { register } from "./register";
|
import { register } from "./register";
|
||||||
import { copyToClipboard } from "../clipboard";
|
import {
|
||||||
|
copyTextToSystemClipboard,
|
||||||
|
copyToClipboard,
|
||||||
|
probablySupportsClipboardWriteText,
|
||||||
|
} from "../clipboard";
|
||||||
import { actionDeleteSelected } from "./actionDeleteSelected";
|
import { actionDeleteSelected } from "./actionDeleteSelected";
|
||||||
import { getSelectedElements } from "../scene/selection";
|
import { getSelectedElements } from "../scene/selection";
|
||||||
import { exportCanvas } from "../data/index";
|
import { exportCanvas } from "../data/index";
|
||||||
import { getNonDeletedElements } from "../element";
|
import { getNonDeletedElements, isTextElement } from "../element";
|
||||||
import { t } from "../i18n";
|
import { t } from "../i18n";
|
||||||
|
|
||||||
export const actionCopy = register({
|
export const actionCopy = register({
|
||||||
name: "copy",
|
name: "copy",
|
||||||
perform: (elements, appState) => {
|
trackEvent: { category: "element" },
|
||||||
copyToClipboard(getNonDeletedElements(elements), appState);
|
perform: (elements, appState, _, app) => {
|
||||||
|
const selectedElements = getSelectedElements(elements, appState, true);
|
||||||
|
|
||||||
|
copyToClipboard(selectedElements, appState, app.files);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
commitToHistory: false,
|
commitToHistory: false,
|
||||||
@@ -23,9 +30,10 @@ export const actionCopy = register({
|
|||||||
|
|
||||||
export const actionCut = register({
|
export const actionCut = register({
|
||||||
name: "cut",
|
name: "cut",
|
||||||
|
trackEvent: { category: "element" },
|
||||||
perform: (elements, appState, data, app) => {
|
perform: (elements, appState, data, app) => {
|
||||||
actionCopy.perform(elements, appState, data, app);
|
actionCopy.perform(elements, appState, data, app);
|
||||||
return actionDeleteSelected.perform(elements, appState, data, app);
|
return actionDeleteSelected.perform(elements, appState);
|
||||||
},
|
},
|
||||||
contextItemLabel: "labels.cut",
|
contextItemLabel: "labels.cut",
|
||||||
keyTest: (event) => event[KEYS.CTRL_OR_CMD] && event.code === CODES.X,
|
keyTest: (event) => event[KEYS.CTRL_OR_CMD] && event.code === CODES.X,
|
||||||
@@ -33,6 +41,7 @@ export const actionCut = register({
|
|||||||
|
|
||||||
export const actionCopyAsSvg = register({
|
export const actionCopyAsSvg = register({
|
||||||
name: "copyAsSvg",
|
name: "copyAsSvg",
|
||||||
|
trackEvent: { category: "element" },
|
||||||
perform: async (elements, appState, _data, app) => {
|
perform: async (elements, appState, _data, app) => {
|
||||||
if (!app.canvas) {
|
if (!app.canvas) {
|
||||||
return {
|
return {
|
||||||
@@ -42,6 +51,7 @@ export const actionCopyAsSvg = register({
|
|||||||
const selectedElements = getSelectedElements(
|
const selectedElements = getSelectedElements(
|
||||||
getNonDeletedElements(elements),
|
getNonDeletedElements(elements),
|
||||||
appState,
|
appState,
|
||||||
|
true,
|
||||||
);
|
);
|
||||||
try {
|
try {
|
||||||
await exportCanvas(
|
await exportCanvas(
|
||||||
@@ -50,12 +60,13 @@ export const actionCopyAsSvg = register({
|
|||||||
? selectedElements
|
? selectedElements
|
||||||
: getNonDeletedElements(elements),
|
: getNonDeletedElements(elements),
|
||||||
appState,
|
appState,
|
||||||
|
app.files,
|
||||||
appState,
|
appState,
|
||||||
);
|
);
|
||||||
return {
|
return {
|
||||||
commitToHistory: false,
|
commitToHistory: false,
|
||||||
};
|
};
|
||||||
} catch (error) {
|
} catch (error: any) {
|
||||||
console.error(error);
|
console.error(error);
|
||||||
return {
|
return {
|
||||||
appState: {
|
appState: {
|
||||||
@@ -71,6 +82,7 @@ export const actionCopyAsSvg = register({
|
|||||||
|
|
||||||
export const actionCopyAsPng = register({
|
export const actionCopyAsPng = register({
|
||||||
name: "copyAsPng",
|
name: "copyAsPng",
|
||||||
|
trackEvent: { category: "element" },
|
||||||
perform: async (elements, appState, _data, app) => {
|
perform: async (elements, appState, _data, app) => {
|
||||||
if (!app.canvas) {
|
if (!app.canvas) {
|
||||||
return {
|
return {
|
||||||
@@ -80,6 +92,7 @@ export const actionCopyAsPng = register({
|
|||||||
const selectedElements = getSelectedElements(
|
const selectedElements = getSelectedElements(
|
||||||
getNonDeletedElements(elements),
|
getNonDeletedElements(elements),
|
||||||
appState,
|
appState,
|
||||||
|
true,
|
||||||
);
|
);
|
||||||
try {
|
try {
|
||||||
await exportCanvas(
|
await exportCanvas(
|
||||||
@@ -88,23 +101,26 @@ export const actionCopyAsPng = register({
|
|||||||
? selectedElements
|
? selectedElements
|
||||||
: getNonDeletedElements(elements),
|
: getNonDeletedElements(elements),
|
||||||
appState,
|
appState,
|
||||||
|
app.files,
|
||||||
appState,
|
appState,
|
||||||
);
|
);
|
||||||
return {
|
return {
|
||||||
appState: {
|
appState: {
|
||||||
...appState,
|
...appState,
|
||||||
toastMessage: t("toast.copyToClipboardAsPng", {
|
toast: {
|
||||||
exportSelection: selectedElements.length
|
message: t("toast.copyToClipboardAsPng", {
|
||||||
? t("toast.selection")
|
exportSelection: selectedElements.length
|
||||||
: t("toast.canvas"),
|
? t("toast.selection")
|
||||||
exportColorScheme: appState.exportWithDarkMode
|
: t("toast.canvas"),
|
||||||
? t("buttons.darkMode")
|
exportColorScheme: appState.exportWithDarkMode
|
||||||
: t("buttons.lightMode"),
|
? t("buttons.darkMode")
|
||||||
}),
|
: t("buttons.lightMode"),
|
||||||
|
}),
|
||||||
|
},
|
||||||
},
|
},
|
||||||
commitToHistory: false,
|
commitToHistory: false,
|
||||||
};
|
};
|
||||||
} catch (error) {
|
} catch (error: any) {
|
||||||
console.error(error);
|
console.error(error);
|
||||||
return {
|
return {
|
||||||
appState: {
|
appState: {
|
||||||
@@ -118,3 +134,35 @@ export const actionCopyAsPng = register({
|
|||||||
contextItemLabel: "labels.copyAsPng",
|
contextItemLabel: "labels.copyAsPng",
|
||||||
keyTest: (event) => event.code === CODES.C && event.altKey && event.shiftKey,
|
keyTest: (event) => event.code === CODES.C && event.altKey && event.shiftKey,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
export const copyText = register({
|
||||||
|
name: "copyText",
|
||||||
|
trackEvent: { category: "element" },
|
||||||
|
perform: (elements, appState) => {
|
||||||
|
const selectedElements = getSelectedElements(
|
||||||
|
getNonDeletedElements(elements),
|
||||||
|
appState,
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
|
||||||
|
const text = selectedElements
|
||||||
|
.reduce((acc: string[], element) => {
|
||||||
|
if (isTextElement(element)) {
|
||||||
|
acc.push(element.text);
|
||||||
|
}
|
||||||
|
return acc;
|
||||||
|
}, [])
|
||||||
|
.join("\n\n");
|
||||||
|
copyTextToSystemClipboard(text);
|
||||||
|
return {
|
||||||
|
commitToHistory: false,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
contextItemPredicate: (elements, appState) => {
|
||||||
|
return (
|
||||||
|
probablySupportsClipboardWriteText &&
|
||||||
|
getSelectedElements(elements, appState, true).some(isTextElement)
|
||||||
|
);
|
||||||
|
},
|
||||||
|
contextItemLabel: "labels.copyText",
|
||||||
|
});
|
||||||
|
@@ -1,7 +1,6 @@
|
|||||||
import { isSomeElementSelected } from "../scene";
|
import { isSomeElementSelected } from "../scene";
|
||||||
import { KEYS } from "../keys";
|
import { KEYS } from "../keys";
|
||||||
import { ToolButton } from "../components/ToolButton";
|
import { ToolButton } from "../components/ToolButton";
|
||||||
import React from "react";
|
|
||||||
import { trash } from "../components/icons";
|
import { trash } from "../components/icons";
|
||||||
import { t } from "../i18n";
|
import { t } from "../i18n";
|
||||||
import { register } from "./register";
|
import { register } from "./register";
|
||||||
@@ -12,6 +11,8 @@ import { newElementWith } from "../element/mutateElement";
|
|||||||
import { getElementsInGroup } from "../groups";
|
import { getElementsInGroup } from "../groups";
|
||||||
import { LinearElementEditor } from "../element/linearElementEditor";
|
import { LinearElementEditor } from "../element/linearElementEditor";
|
||||||
import { fixBindingsAfterDeletion } from "../element/binding";
|
import { fixBindingsAfterDeletion } from "../element/binding";
|
||||||
|
import { isBoundToContainer } from "../element/typeChecks";
|
||||||
|
import { updateActiveTool } from "../utils";
|
||||||
|
|
||||||
const deleteSelectedElements = (
|
const deleteSelectedElements = (
|
||||||
elements: readonly ExcalidrawElement[],
|
elements: readonly ExcalidrawElement[],
|
||||||
@@ -22,6 +23,12 @@ const deleteSelectedElements = (
|
|||||||
if (appState.selectedElementIds[el.id]) {
|
if (appState.selectedElementIds[el.id]) {
|
||||||
return newElementWith(el, { isDeleted: true });
|
return newElementWith(el, { isDeleted: true });
|
||||||
}
|
}
|
||||||
|
if (
|
||||||
|
isBoundToContainer(el) &&
|
||||||
|
appState.selectedElementIds[el.containerId]
|
||||||
|
) {
|
||||||
|
return newElementWith(el, { isDeleted: true });
|
||||||
|
}
|
||||||
return el;
|
return el;
|
||||||
}),
|
}),
|
||||||
appState: {
|
appState: {
|
||||||
@@ -52,11 +59,12 @@ const handleGroupEditingState = (
|
|||||||
|
|
||||||
export const actionDeleteSelected = register({
|
export const actionDeleteSelected = register({
|
||||||
name: "deleteSelectedElements",
|
name: "deleteSelectedElements",
|
||||||
|
trackEvent: { category: "element", action: "delete" },
|
||||||
perform: (elements, appState) => {
|
perform: (elements, appState) => {
|
||||||
if (appState.editingLinearElement) {
|
if (appState.editingLinearElement) {
|
||||||
const {
|
const {
|
||||||
elementId,
|
elementId,
|
||||||
activePointIndex,
|
selectedPointsIndices,
|
||||||
startBindingElement,
|
startBindingElement,
|
||||||
endBindingElement,
|
endBindingElement,
|
||||||
} = appState.editingLinearElement;
|
} = appState.editingLinearElement;
|
||||||
@@ -66,8 +74,7 @@ export const actionDeleteSelected = register({
|
|||||||
}
|
}
|
||||||
if (
|
if (
|
||||||
// case: no point selected → delete whole element
|
// case: no point selected → delete whole element
|
||||||
activePointIndex == null ||
|
selectedPointsIndices == null ||
|
||||||
activePointIndex === -1 ||
|
|
||||||
// case: deleting last remaining point
|
// case: deleting last remaining point
|
||||||
element.points.length < 2
|
element.points.length < 2
|
||||||
) {
|
) {
|
||||||
@@ -87,15 +94,17 @@ export const actionDeleteSelected = register({
|
|||||||
// We cannot do this inside `movePoint` because it is also called
|
// We cannot do this inside `movePoint` because it is also called
|
||||||
// when deleting the uncommitted point (which hasn't caused any binding)
|
// when deleting the uncommitted point (which hasn't caused any binding)
|
||||||
const binding = {
|
const binding = {
|
||||||
startBindingElement:
|
startBindingElement: selectedPointsIndices?.includes(0)
|
||||||
activePointIndex === 0 ? null : startBindingElement,
|
? null
|
||||||
endBindingElement:
|
: startBindingElement,
|
||||||
activePointIndex === element.points.length - 1
|
endBindingElement: selectedPointsIndices?.includes(
|
||||||
? null
|
element.points.length - 1,
|
||||||
: endBindingElement,
|
)
|
||||||
|
? null
|
||||||
|
: endBindingElement,
|
||||||
};
|
};
|
||||||
|
|
||||||
LinearElementEditor.movePoint(element, activePointIndex, "delete");
|
LinearElementEditor.deletePoints(element, selectedPointsIndices);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
elements,
|
elements,
|
||||||
@@ -104,17 +113,17 @@ export const actionDeleteSelected = register({
|
|||||||
editingLinearElement: {
|
editingLinearElement: {
|
||||||
...appState.editingLinearElement,
|
...appState.editingLinearElement,
|
||||||
...binding,
|
...binding,
|
||||||
activePointIndex: activePointIndex > 0 ? activePointIndex - 1 : 0,
|
selectedPointsIndices:
|
||||||
|
selectedPointsIndices?.[0] > 0
|
||||||
|
? [selectedPointsIndices[0] - 1]
|
||||||
|
: [0],
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
commitToHistory: true,
|
commitToHistory: true,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
let { elements: nextElements, appState: nextAppState } =
|
||||||
let {
|
deleteSelectedElements(elements, appState);
|
||||||
elements: nextElements,
|
|
||||||
appState: nextAppState,
|
|
||||||
} = deleteSelectedElements(elements, appState);
|
|
||||||
fixBindingsAfterDeletion(
|
fixBindingsAfterDeletion(
|
||||||
nextElements,
|
nextElements,
|
||||||
elements.filter(({ id }) => appState.selectedElementIds[id]),
|
elements.filter(({ id }) => appState.selectedElementIds[id]),
|
||||||
@@ -126,7 +135,7 @@ export const actionDeleteSelected = register({
|
|||||||
elements: nextElements,
|
elements: nextElements,
|
||||||
appState: {
|
appState: {
|
||||||
...nextAppState,
|
...nextAppState,
|
||||||
elementType: "selection",
|
activeTool: updateActiveTool(appState, { type: "selection" }),
|
||||||
multiElement: null,
|
multiElement: null,
|
||||||
},
|
},
|
||||||
commitToHistory: isSomeElementSelected(
|
commitToHistory: isSomeElementSelected(
|
||||||
|
@@ -1,17 +1,16 @@
|
|||||||
import React from "react";
|
|
||||||
import {
|
import {
|
||||||
DistributeHorizontallyIcon,
|
DistributeHorizontallyIcon,
|
||||||
DistributeVerticallyIcon,
|
DistributeVerticallyIcon,
|
||||||
} from "../components/icons";
|
} from "../components/icons";
|
||||||
import { ToolButton } from "../components/ToolButton";
|
import { ToolButton } from "../components/ToolButton";
|
||||||
import { distributeElements, Distribution } from "../disitrubte";
|
import { distributeElements, Distribution } from "../distribute";
|
||||||
import { getElementMap, getNonDeletedElements } from "../element";
|
import { getNonDeletedElements } from "../element";
|
||||||
import { ExcalidrawElement } from "../element/types";
|
import { ExcalidrawElement } from "../element/types";
|
||||||
import { t } from "../i18n";
|
import { t } from "../i18n";
|
||||||
import { CODES } from "../keys";
|
import { CODES, KEYS } from "../keys";
|
||||||
import { getSelectedElements, isSomeElementSelected } from "../scene";
|
import { getSelectedElements, isSomeElementSelected } from "../scene";
|
||||||
import { AppState } from "../types";
|
import { AppState } from "../types";
|
||||||
import { getShortcutKey } from "../utils";
|
import { arrayToMap, getShortcutKey } from "../utils";
|
||||||
import { register } from "./register";
|
import { register } from "./register";
|
||||||
|
|
||||||
const enableActionGroup = (
|
const enableActionGroup = (
|
||||||
@@ -31,13 +30,16 @@ const distributeSelectedElements = (
|
|||||||
|
|
||||||
const updatedElements = distributeElements(selectedElements, distribution);
|
const updatedElements = distributeElements(selectedElements, distribution);
|
||||||
|
|
||||||
const updatedElementsMap = getElementMap(updatedElements);
|
const updatedElementsMap = arrayToMap(updatedElements);
|
||||||
|
|
||||||
return elements.map((element) => updatedElementsMap[element.id] || element);
|
return elements.map(
|
||||||
|
(element) => updatedElementsMap.get(element.id) || element,
|
||||||
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
export const distributeHorizontally = register({
|
export const distributeHorizontally = register({
|
||||||
name: "distributeHorizontally",
|
name: "distributeHorizontally",
|
||||||
|
trackEvent: { category: "element" },
|
||||||
perform: (elements, appState) => {
|
perform: (elements, appState) => {
|
||||||
return {
|
return {
|
||||||
appState,
|
appState,
|
||||||
@@ -48,7 +50,8 @@ export const distributeHorizontally = register({
|
|||||||
commitToHistory: true,
|
commitToHistory: true,
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
keyTest: (event) => event.altKey && event.code === CODES.H,
|
keyTest: (event) =>
|
||||||
|
!event[KEYS.CTRL_OR_CMD] && event.altKey && event.code === CODES.H,
|
||||||
PanelComponent: ({ elements, appState, updateData }) => (
|
PanelComponent: ({ elements, appState, updateData }) => (
|
||||||
<ToolButton
|
<ToolButton
|
||||||
hidden={!enableActionGroup(elements, appState)}
|
hidden={!enableActionGroup(elements, appState)}
|
||||||
@@ -66,6 +69,7 @@ export const distributeHorizontally = register({
|
|||||||
|
|
||||||
export const distributeVertically = register({
|
export const distributeVertically = register({
|
||||||
name: "distributeVertically",
|
name: "distributeVertically",
|
||||||
|
trackEvent: { category: "element" },
|
||||||
perform: (elements, appState) => {
|
perform: (elements, appState) => {
|
||||||
return {
|
return {
|
||||||
appState,
|
appState,
|
||||||
@@ -76,7 +80,8 @@ export const distributeVertically = register({
|
|||||||
commitToHistory: true,
|
commitToHistory: true,
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
keyTest: (event) => event.altKey && event.code === CODES.V,
|
keyTest: (event) =>
|
||||||
|
!event[KEYS.CTRL_OR_CMD] && event.altKey && event.code === CODES.V,
|
||||||
PanelComponent: ({ elements, appState, updateData }) => (
|
PanelComponent: ({ elements, appState, updateData }) => (
|
||||||
<ToolButton
|
<ToolButton
|
||||||
hidden={!enableActionGroup(elements, appState)}
|
hidden={!enableActionGroup(elements, appState)}
|
||||||
|
@@ -1,15 +1,13 @@
|
|||||||
import React from "react";
|
|
||||||
import { KEYS } from "../keys";
|
import { KEYS } from "../keys";
|
||||||
import { register } from "./register";
|
import { register } from "./register";
|
||||||
import { ExcalidrawElement } from "../element/types";
|
import { ExcalidrawElement } from "../element/types";
|
||||||
import { duplicateElement, getNonDeletedElements } from "../element";
|
import { duplicateElement, getNonDeletedElements } from "../element";
|
||||||
import { isSomeElementSelected } from "../scene";
|
import { getSelectedElements, isSomeElementSelected } from "../scene";
|
||||||
import { ToolButton } from "../components/ToolButton";
|
import { ToolButton } from "../components/ToolButton";
|
||||||
import { clone } from "../components/icons";
|
import { clone } from "../components/icons";
|
||||||
import { t } from "../i18n";
|
import { t } from "../i18n";
|
||||||
import { getShortcutKey } from "../utils";
|
import { arrayToMap, getShortcutKey } from "../utils";
|
||||||
import { LinearElementEditor } from "../element/linearElementEditor";
|
import { LinearElementEditor } from "../element/linearElementEditor";
|
||||||
import { mutateElement } from "../element/mutateElement";
|
|
||||||
import {
|
import {
|
||||||
selectGroupsForSelectedElements,
|
selectGroupsForSelectedElements,
|
||||||
getSelectedGroupForElement,
|
getSelectedGroupForElement,
|
||||||
@@ -19,41 +17,24 @@ import { AppState } from "../types";
|
|||||||
import { fixBindingsAfterDuplication } from "../element/binding";
|
import { fixBindingsAfterDuplication } from "../element/binding";
|
||||||
import { ActionResult } from "./types";
|
import { ActionResult } from "./types";
|
||||||
import { GRID_SIZE } from "../constants";
|
import { GRID_SIZE } from "../constants";
|
||||||
|
import { bindTextToShapeAfterDuplication } from "../element/textElement";
|
||||||
|
import { isBoundToContainer } from "../element/typeChecks";
|
||||||
|
|
||||||
export const actionDuplicateSelection = register({
|
export const actionDuplicateSelection = register({
|
||||||
name: "duplicateSelection",
|
name: "duplicateSelection",
|
||||||
|
trackEvent: { category: "element" },
|
||||||
perform: (elements, appState) => {
|
perform: (elements, appState) => {
|
||||||
// duplicate point if selected while editing multi-point element
|
// duplicate selected point(s) if editing a line
|
||||||
if (appState.editingLinearElement) {
|
if (appState.editingLinearElement) {
|
||||||
const { activePointIndex, elementId } = appState.editingLinearElement;
|
const ret = LinearElementEditor.duplicateSelectedPoints(appState);
|
||||||
const element = LinearElementEditor.getElement(elementId);
|
|
||||||
if (!element || activePointIndex === null) {
|
if (!ret) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
const { points } = element;
|
|
||||||
const selectedPoint = points[activePointIndex];
|
|
||||||
const nextPoint = points[activePointIndex + 1];
|
|
||||||
mutateElement(element, {
|
|
||||||
points: [
|
|
||||||
...points.slice(0, activePointIndex + 1),
|
|
||||||
nextPoint
|
|
||||||
? [
|
|
||||||
(selectedPoint[0] + nextPoint[0]) / 2,
|
|
||||||
(selectedPoint[1] + nextPoint[1]) / 2,
|
|
||||||
]
|
|
||||||
: [selectedPoint[0] + 30, selectedPoint[1] + 30],
|
|
||||||
...points.slice(activePointIndex + 1),
|
|
||||||
],
|
|
||||||
});
|
|
||||||
return {
|
return {
|
||||||
appState: {
|
|
||||||
...appState,
|
|
||||||
editingLinearElement: {
|
|
||||||
...appState.editingLinearElement,
|
|
||||||
activePointIndex: activePointIndex + 1,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
elements,
|
elements,
|
||||||
|
appState: ret.appState,
|
||||||
commitToHistory: true,
|
commitToHistory: true,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -107,9 +88,12 @@ const duplicateElements = (
|
|||||||
const finalElements: ExcalidrawElement[] = [];
|
const finalElements: ExcalidrawElement[] = [];
|
||||||
|
|
||||||
let index = 0;
|
let index = 0;
|
||||||
|
const selectedElementIds = arrayToMap(
|
||||||
|
getSelectedElements(elements, appState, true),
|
||||||
|
);
|
||||||
while (index < elements.length) {
|
while (index < elements.length) {
|
||||||
const element = elements[index];
|
const element = elements[index];
|
||||||
if (appState.selectedElementIds[element.id]) {
|
if (selectedElementIds.get(element.id)) {
|
||||||
if (element.groupIds.length) {
|
if (element.groupIds.length) {
|
||||||
const groupId = getSelectedGroupForElement(appState, element);
|
const groupId = getSelectedGroupForElement(appState, element);
|
||||||
// if group selected, duplicate it atomically
|
// if group selected, duplicate it atomically
|
||||||
@@ -131,7 +115,11 @@ const duplicateElements = (
|
|||||||
}
|
}
|
||||||
index++;
|
index++;
|
||||||
}
|
}
|
||||||
|
bindTextToShapeAfterDuplication(
|
||||||
|
finalElements,
|
||||||
|
oldElements,
|
||||||
|
oldIdToDuplicatedId,
|
||||||
|
);
|
||||||
fixBindingsAfterDuplication(finalElements, oldElements, oldIdToDuplicatedId);
|
fixBindingsAfterDuplication(finalElements, oldElements, oldIdToDuplicatedId);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@@ -140,10 +128,15 @@ const duplicateElements = (
|
|||||||
{
|
{
|
||||||
...appState,
|
...appState,
|
||||||
selectedGroupIds: {},
|
selectedGroupIds: {},
|
||||||
selectedElementIds: newElements.reduce((acc, element) => {
|
selectedElementIds: newElements.reduce(
|
||||||
acc[element.id] = true;
|
(acc: Record<ExcalidrawElement["id"], true>, element) => {
|
||||||
return acc;
|
if (!isBoundToContainer(element)) {
|
||||||
}, {} as any),
|
acc[element.id] = true;
|
||||||
|
}
|
||||||
|
return acc;
|
||||||
|
},
|
||||||
|
{},
|
||||||
|
),
|
||||||
},
|
},
|
||||||
getNonDeletedElements(finalElements),
|
getNonDeletedElements(finalElements),
|
||||||
),
|
),
|
||||||
|
@@ -1,23 +1,29 @@
|
|||||||
import React from "react";
|
import { load, questionCircle, saveAs } from "../components/icons";
|
||||||
import { trackEvent } from "../analytics";
|
|
||||||
import { load, questionCircle, save, saveAs } from "../components/icons";
|
|
||||||
import { ProjectName } from "../components/ProjectName";
|
import { ProjectName } from "../components/ProjectName";
|
||||||
import { ToolButton } from "../components/ToolButton";
|
import { ToolButton } from "../components/ToolButton";
|
||||||
import "../components/ToolIcon.scss";
|
import "../components/ToolIcon.scss";
|
||||||
import { Tooltip } from "../components/Tooltip";
|
import { Tooltip } from "../components/Tooltip";
|
||||||
import { DarkModeToggle, Appearence } from "../components/DarkModeToggle";
|
import { DarkModeToggle } from "../components/DarkModeToggle";
|
||||||
import { loadFromJSON, saveAsJSON } from "../data";
|
import { loadFromJSON, saveAsJSON } from "../data";
|
||||||
|
import { resaveAsImageWithScene } from "../data/resave";
|
||||||
import { t } from "../i18n";
|
import { t } from "../i18n";
|
||||||
import { useIsMobile } from "../components/App";
|
import { useDevice } from "../components/App";
|
||||||
import { KEYS } from "../keys";
|
import { KEYS } from "../keys";
|
||||||
import { register } from "./register";
|
import { register } from "./register";
|
||||||
import { supported as fsSupported } from "browser-fs-access";
|
|
||||||
import { CheckboxItem } from "../components/CheckboxItem";
|
import { CheckboxItem } from "../components/CheckboxItem";
|
||||||
|
import { getExportSize } from "../scene/export";
|
||||||
|
import { DEFAULT_EXPORT_PADDING, EXPORT_SCALES, THEME } from "../constants";
|
||||||
|
import { getSelectedElements, isSomeElementSelected } from "../scene";
|
||||||
|
import { getNonDeletedElements } from "../element";
|
||||||
|
import { ActiveFile } from "../components/ActiveFile";
|
||||||
|
import { isImageFileHandle } from "../data/blob";
|
||||||
|
import { nativeFileSystemSupported } from "../data/filesystem";
|
||||||
|
import { Theme } from "../element/types";
|
||||||
|
|
||||||
export const actionChangeProjectName = register({
|
export const actionChangeProjectName = register({
|
||||||
name: "changeProjectName",
|
name: "changeProjectName",
|
||||||
|
trackEvent: false,
|
||||||
perform: (_elements, appState, value) => {
|
perform: (_elements, appState, value) => {
|
||||||
trackEvent("change", "title");
|
|
||||||
return { appState: { ...appState, name: value }, commitToHistory: false };
|
return { appState: { ...appState, name: value }, commitToHistory: false };
|
||||||
},
|
},
|
||||||
PanelComponent: ({ appState, updateData, appProps }) => (
|
PanelComponent: ({ appState, updateData, appProps }) => (
|
||||||
@@ -32,8 +38,58 @@ export const actionChangeProjectName = register({
|
|||||||
),
|
),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
export const actionChangeExportScale = register({
|
||||||
|
name: "changeExportScale",
|
||||||
|
trackEvent: { category: "export", action: "scale" },
|
||||||
|
perform: (_elements, appState, value) => {
|
||||||
|
return {
|
||||||
|
appState: { ...appState, exportScale: value },
|
||||||
|
commitToHistory: false,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
PanelComponent: ({ elements: allElements, appState, updateData }) => {
|
||||||
|
const elements = getNonDeletedElements(allElements);
|
||||||
|
const exportSelected = isSomeElementSelected(elements, appState);
|
||||||
|
const exportedElements = exportSelected
|
||||||
|
? getSelectedElements(elements, appState)
|
||||||
|
: elements;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{EXPORT_SCALES.map((s) => {
|
||||||
|
const [width, height] = getExportSize(
|
||||||
|
exportedElements,
|
||||||
|
DEFAULT_EXPORT_PADDING,
|
||||||
|
s,
|
||||||
|
);
|
||||||
|
|
||||||
|
const scaleButtonTitle = `${t(
|
||||||
|
"buttons.scale",
|
||||||
|
)} ${s}x (${width}x${height})`;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ToolButton
|
||||||
|
key={s}
|
||||||
|
size="small"
|
||||||
|
type="radio"
|
||||||
|
icon={`${s}x`}
|
||||||
|
name="export-canvas-scale"
|
||||||
|
title={scaleButtonTitle}
|
||||||
|
aria-label={scaleButtonTitle}
|
||||||
|
id="export-canvas-scale"
|
||||||
|
checked={s === appState.exportScale}
|
||||||
|
onChange={() => updateData(s)}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
export const actionChangeExportBackground = register({
|
export const actionChangeExportBackground = register({
|
||||||
name: "changeExportBackground",
|
name: "changeExportBackground",
|
||||||
|
trackEvent: { category: "export", action: "toggleBackground" },
|
||||||
perform: (_elements, appState, value) => {
|
perform: (_elements, appState, value) => {
|
||||||
return {
|
return {
|
||||||
appState: { ...appState, exportBackground: value },
|
appState: { ...appState, exportBackground: value },
|
||||||
@@ -52,6 +108,7 @@ export const actionChangeExportBackground = register({
|
|||||||
|
|
||||||
export const actionChangeExportEmbedScene = register({
|
export const actionChangeExportEmbedScene = register({
|
||||||
name: "changeExportEmbedScene",
|
name: "changeExportEmbedScene",
|
||||||
|
trackEvent: { category: "export", action: "embedScene" },
|
||||||
perform: (_elements, appState, value) => {
|
perform: (_elements, appState, value) => {
|
||||||
return {
|
return {
|
||||||
appState: { ...appState, exportEmbedScene: value },
|
appState: { ...appState, exportEmbedScene: value },
|
||||||
@@ -65,84 +122,78 @@ export const actionChangeExportEmbedScene = register({
|
|||||||
>
|
>
|
||||||
{t("labels.exportEmbedScene")}
|
{t("labels.exportEmbedScene")}
|
||||||
<Tooltip label={t("labels.exportEmbedScene_details")} long={true}>
|
<Tooltip label={t("labels.exportEmbedScene_details")} long={true}>
|
||||||
<div className="Tooltip-icon">{questionCircle}</div>
|
<div className="excalidraw-tooltip-icon">{questionCircle}</div>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
</CheckboxItem>
|
</CheckboxItem>
|
||||||
),
|
),
|
||||||
});
|
});
|
||||||
|
|
||||||
export const actionChangeShouldAddWatermark = register({
|
export const actionSaveToActiveFile = register({
|
||||||
name: "changeShouldAddWatermark",
|
name: "saveToActiveFile",
|
||||||
perform: (_elements, appState, value) => {
|
trackEvent: { category: "export" },
|
||||||
return {
|
perform: async (elements, appState, value, app) => {
|
||||||
appState: { ...appState, shouldAddWatermark: value },
|
|
||||||
commitToHistory: false,
|
|
||||||
};
|
|
||||||
},
|
|
||||||
PanelComponent: ({ appState, updateData }) => (
|
|
||||||
<CheckboxItem
|
|
||||||
checked={appState.shouldAddWatermark}
|
|
||||||
onChange={(checked) => updateData(checked)}
|
|
||||||
>
|
|
||||||
{t("labels.addWatermark")}
|
|
||||||
</CheckboxItem>
|
|
||||||
),
|
|
||||||
});
|
|
||||||
|
|
||||||
export const actionSaveScene = register({
|
|
||||||
name: "saveScene",
|
|
||||||
perform: async (elements, appState, value) => {
|
|
||||||
const fileHandleExists = !!appState.fileHandle;
|
const fileHandleExists = !!appState.fileHandle;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const { fileHandle } = await saveAsJSON(elements, appState);
|
const { fileHandle } = isImageFileHandle(appState.fileHandle)
|
||||||
|
? await resaveAsImageWithScene(elements, appState, app.files)
|
||||||
|
: await saveAsJSON(elements, appState, app.files);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
commitToHistory: false,
|
commitToHistory: false,
|
||||||
appState: {
|
appState: {
|
||||||
...appState,
|
...appState,
|
||||||
fileHandle,
|
fileHandle,
|
||||||
toastMessage: fileHandleExists
|
toast: fileHandleExists
|
||||||
? fileHandle.name
|
? {
|
||||||
? t("toast.fileSavedToFilename").replace(
|
message: fileHandle?.name
|
||||||
"{filename}",
|
? t("toast.fileSavedToFilename").replace(
|
||||||
`"${fileHandle.name}"`,
|
"{filename}",
|
||||||
)
|
`"${fileHandle.name}"`,
|
||||||
: t("toast.fileSaved")
|
)
|
||||||
|
: t("toast.fileSaved"),
|
||||||
|
}
|
||||||
: null,
|
: null,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
} catch (error) {
|
} catch (error: any) {
|
||||||
if (error?.name !== "AbortError") {
|
if (error?.name !== "AbortError") {
|
||||||
console.error(error);
|
console.error(error);
|
||||||
|
} else {
|
||||||
|
console.warn(error);
|
||||||
}
|
}
|
||||||
return { commitToHistory: false };
|
return { commitToHistory: false };
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
keyTest: (event) =>
|
keyTest: (event) =>
|
||||||
event.key === KEYS.S && event[KEYS.CTRL_OR_CMD] && !event.shiftKey,
|
event.key === KEYS.S && event[KEYS.CTRL_OR_CMD] && !event.shiftKey,
|
||||||
PanelComponent: ({ updateData }) => (
|
PanelComponent: ({ updateData, appState }) => (
|
||||||
<ToolButton
|
<ActiveFile
|
||||||
type="icon"
|
onSave={() => updateData(null)}
|
||||||
icon={save}
|
fileName={appState.fileHandle?.name}
|
||||||
title={t("buttons.save")}
|
|
||||||
aria-label={t("buttons.save")}
|
|
||||||
onClick={() => updateData(null)}
|
|
||||||
data-testid="save-button"
|
|
||||||
/>
|
/>
|
||||||
),
|
),
|
||||||
});
|
});
|
||||||
|
|
||||||
export const actionSaveAsScene = register({
|
export const actionSaveFileToDisk = register({
|
||||||
name: "saveAsScene",
|
name: "saveFileToDisk",
|
||||||
perform: async (elements, appState, value) => {
|
trackEvent: { category: "export" },
|
||||||
|
perform: async (elements, appState, value, app) => {
|
||||||
try {
|
try {
|
||||||
const { fileHandle } = await saveAsJSON(elements, {
|
const { fileHandle } = await saveAsJSON(
|
||||||
...appState,
|
elements,
|
||||||
fileHandle: null,
|
{
|
||||||
});
|
...appState,
|
||||||
|
fileHandle: null,
|
||||||
|
},
|
||||||
|
app.files,
|
||||||
|
);
|
||||||
return { commitToHistory: false, appState: { ...appState, fileHandle } };
|
return { commitToHistory: false, appState: { ...appState, fileHandle } };
|
||||||
} catch (error) {
|
} catch (error: any) {
|
||||||
if (error?.name !== "AbortError") {
|
if (error?.name !== "AbortError") {
|
||||||
console.error(error);
|
console.error(error);
|
||||||
|
} else {
|
||||||
|
console.warn(error);
|
||||||
}
|
}
|
||||||
return { commitToHistory: false };
|
return { commitToHistory: false };
|
||||||
}
|
}
|
||||||
@@ -155,8 +206,8 @@ export const actionSaveAsScene = register({
|
|||||||
icon={saveAs}
|
icon={saveAs}
|
||||||
title={t("buttons.saveAs")}
|
title={t("buttons.saveAs")}
|
||||||
aria-label={t("buttons.saveAs")}
|
aria-label={t("buttons.saveAs")}
|
||||||
showAriaLabel={useIsMobile()}
|
showAriaLabel={useDevice().isMobile}
|
||||||
hidden={!fsSupported}
|
hidden={!nativeFileSystemSupported}
|
||||||
onClick={() => updateData(null)}
|
onClick={() => updateData(null)}
|
||||||
data-testid="save-as-button"
|
data-testid="save-as-button"
|
||||||
/>
|
/>
|
||||||
@@ -165,24 +216,29 @@ export const actionSaveAsScene = register({
|
|||||||
|
|
||||||
export const actionLoadScene = register({
|
export const actionLoadScene = register({
|
||||||
name: "loadScene",
|
name: "loadScene",
|
||||||
perform: async (elements, appState) => {
|
trackEvent: { category: "export" },
|
||||||
|
perform: async (elements, appState, _, app) => {
|
||||||
try {
|
try {
|
||||||
const {
|
const {
|
||||||
elements: loadedElements,
|
elements: loadedElements,
|
||||||
appState: loadedAppState,
|
appState: loadedAppState,
|
||||||
} = await loadFromJSON(appState);
|
files,
|
||||||
|
} = await loadFromJSON(appState, elements);
|
||||||
return {
|
return {
|
||||||
elements: loadedElements,
|
elements: loadedElements,
|
||||||
appState: loadedAppState,
|
appState: loadedAppState,
|
||||||
|
files,
|
||||||
commitToHistory: true,
|
commitToHistory: true,
|
||||||
};
|
};
|
||||||
} catch (error) {
|
} catch (error: any) {
|
||||||
if (error?.name === "AbortError") {
|
if (error?.name === "AbortError") {
|
||||||
|
console.warn(error);
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
return {
|
return {
|
||||||
elements,
|
elements,
|
||||||
appState: { ...appState, errorMessage: error.message },
|
appState: { ...appState, errorMessage: error.message },
|
||||||
|
files: app.files,
|
||||||
commitToHistory: false,
|
commitToHistory: false,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -194,7 +250,7 @@ export const actionLoadScene = register({
|
|||||||
icon={load}
|
icon={load}
|
||||||
title={t("buttons.load")}
|
title={t("buttons.load")}
|
||||||
aria-label={t("buttons.load")}
|
aria-label={t("buttons.load")}
|
||||||
showAriaLabel={useIsMobile()}
|
showAriaLabel={useDevice().isMobile}
|
||||||
onClick={updateData}
|
onClick={updateData}
|
||||||
data-testid="load-button"
|
data-testid="load-button"
|
||||||
/>
|
/>
|
||||||
@@ -203,6 +259,7 @@ export const actionLoadScene = register({
|
|||||||
|
|
||||||
export const actionExportWithDarkMode = register({
|
export const actionExportWithDarkMode = register({
|
||||||
name: "exportWithDarkMode",
|
name: "exportWithDarkMode",
|
||||||
|
trackEvent: { category: "export", action: "toggleTheme" },
|
||||||
perform: (_elements, appState, value) => {
|
perform: (_elements, appState, value) => {
|
||||||
return {
|
return {
|
||||||
appState: { ...appState, exportWithDarkMode: value },
|
appState: { ...appState, exportWithDarkMode: value },
|
||||||
@@ -219,9 +276,9 @@ export const actionExportWithDarkMode = register({
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<DarkModeToggle
|
<DarkModeToggle
|
||||||
value={appState.exportWithDarkMode ? "dark" : "light"}
|
value={appState.exportWithDarkMode ? THEME.DARK : THEME.LIGHT}
|
||||||
onChange={(theme: Appearence) => {
|
onChange={(theme: Theme) => {
|
||||||
updateData(theme === "dark");
|
updateData(theme === THEME.DARK);
|
||||||
}}
|
}}
|
||||||
title={t("labels.toggleExportColorScheme")}
|
title={t("labels.toggleExportColorScheme")}
|
||||||
/>
|
/>
|
||||||
|
@@ -1,7 +1,6 @@
|
|||||||
import { KEYS } from "../keys";
|
import { KEYS } from "../keys";
|
||||||
import { isInvisiblySmallElement } from "../element";
|
import { isInvisiblySmallElement } from "../element";
|
||||||
import { resetCursor } from "../utils";
|
import { updateActiveTool, resetCursor } from "../utils";
|
||||||
import React from "react";
|
|
||||||
import { ToolButton } from "../components/ToolButton";
|
import { ToolButton } from "../components/ToolButton";
|
||||||
import { done } from "../components/icons";
|
import { done } from "../components/icons";
|
||||||
import { t } from "../i18n";
|
import { t } from "../i18n";
|
||||||
@@ -14,17 +13,16 @@ import {
|
|||||||
maybeBindLinearElement,
|
maybeBindLinearElement,
|
||||||
bindOrUnbindLinearElement,
|
bindOrUnbindLinearElement,
|
||||||
} from "../element/binding";
|
} from "../element/binding";
|
||||||
import { isBindingElement } from "../element/typeChecks";
|
import { isBindingElement, isLinearElement } from "../element/typeChecks";
|
||||||
|
import { AppState } from "../types";
|
||||||
|
|
||||||
export const actionFinalize = register({
|
export const actionFinalize = register({
|
||||||
name: "finalize",
|
name: "finalize",
|
||||||
perform: (elements, appState, _, { canvas, focusContainer }) => {
|
trackEvent: false,
|
||||||
|
perform: (elements, appState, _, { canvas, focusContainer, scene }) => {
|
||||||
if (appState.editingLinearElement) {
|
if (appState.editingLinearElement) {
|
||||||
const {
|
const { elementId, startBindingElement, endBindingElement } =
|
||||||
elementId,
|
appState.editingLinearElement;
|
||||||
startBindingElement,
|
|
||||||
endBindingElement,
|
|
||||||
} = appState.editingLinearElement;
|
|
||||||
const element = LinearElementEditor.getElement(elementId);
|
const element = LinearElementEditor.getElement(elementId);
|
||||||
|
|
||||||
if (element) {
|
if (element) {
|
||||||
@@ -42,6 +40,7 @@ export const actionFinalize = register({
|
|||||||
: undefined,
|
: undefined,
|
||||||
appState: {
|
appState: {
|
||||||
...appState,
|
...appState,
|
||||||
|
cursorButton: "up",
|
||||||
editingLinearElement: null,
|
editingLinearElement: null,
|
||||||
},
|
},
|
||||||
commitToHistory: true,
|
commitToHistory: true,
|
||||||
@@ -50,6 +49,15 @@ export const actionFinalize = register({
|
|||||||
}
|
}
|
||||||
|
|
||||||
let newElements = elements;
|
let newElements = elements;
|
||||||
|
|
||||||
|
const pendingImageElement =
|
||||||
|
appState.pendingImageElementId &&
|
||||||
|
scene.getElement(appState.pendingImageElementId);
|
||||||
|
|
||||||
|
if (pendingImageElement) {
|
||||||
|
mutateElement(pendingImageElement, { isDeleted: true }, false);
|
||||||
|
}
|
||||||
|
|
||||||
if (window.document.activeElement instanceof HTMLElement) {
|
if (window.document.activeElement instanceof HTMLElement) {
|
||||||
focusContainer();
|
focusContainer();
|
||||||
}
|
}
|
||||||
@@ -118,27 +126,47 @@ export const actionFinalize = register({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!appState.elementLocked && appState.elementType !== "freedraw") {
|
if (
|
||||||
|
!appState.activeTool.locked &&
|
||||||
|
appState.activeTool.type !== "freedraw"
|
||||||
|
) {
|
||||||
appState.selectedElementIds[multiPointElement.id] = true;
|
appState.selectedElementIds[multiPointElement.id] = true;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (
|
if (
|
||||||
(!appState.elementLocked && appState.elementType !== "freedraw") ||
|
(!appState.activeTool.locked &&
|
||||||
|
appState.activeTool.type !== "freedraw") ||
|
||||||
!multiPointElement
|
!multiPointElement
|
||||||
) {
|
) {
|
||||||
resetCursor(canvas);
|
resetCursor(canvas);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let activeTool: AppState["activeTool"];
|
||||||
|
if (appState.activeTool.type === "eraser") {
|
||||||
|
activeTool = updateActiveTool(appState, {
|
||||||
|
...(appState.activeTool.lastActiveToolBeforeEraser || {
|
||||||
|
type: "selection",
|
||||||
|
}),
|
||||||
|
lastActiveToolBeforeEraser: null,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
activeTool = updateActiveTool(appState, {
|
||||||
|
type: "selection",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
elements: newElements,
|
elements: newElements,
|
||||||
appState: {
|
appState: {
|
||||||
...appState,
|
...appState,
|
||||||
elementType:
|
cursorButton: "up",
|
||||||
(appState.elementLocked || appState.elementType === "freedraw") &&
|
activeTool:
|
||||||
|
(appState.activeTool.locked ||
|
||||||
|
appState.activeTool.type === "freedraw") &&
|
||||||
multiPointElement
|
multiPointElement
|
||||||
? appState.elementType
|
? appState.activeTool
|
||||||
: "selection",
|
: activeTool,
|
||||||
draggingElement: null,
|
draggingElement: null,
|
||||||
multiElement: null,
|
multiElement: null,
|
||||||
editingElement: null,
|
editingElement: null,
|
||||||
@@ -146,15 +174,21 @@ export const actionFinalize = register({
|
|||||||
suggestedBindings: [],
|
suggestedBindings: [],
|
||||||
selectedElementIds:
|
selectedElementIds:
|
||||||
multiPointElement &&
|
multiPointElement &&
|
||||||
!appState.elementLocked &&
|
!appState.activeTool.locked &&
|
||||||
appState.elementType !== "freedraw"
|
appState.activeTool.type !== "freedraw"
|
||||||
? {
|
? {
|
||||||
...appState.selectedElementIds,
|
...appState.selectedElementIds,
|
||||||
[multiPointElement.id]: true,
|
[multiPointElement.id]: true,
|
||||||
}
|
}
|
||||||
: appState.selectedElementIds,
|
: appState.selectedElementIds,
|
||||||
|
// To select the linear element when user has finished mutipoint editing
|
||||||
|
selectedLinearElement:
|
||||||
|
multiPointElement && isLinearElement(multiPointElement)
|
||||||
|
? new LinearElementEditor(multiPointElement, scene)
|
||||||
|
: appState.selectedLinearElement,
|
||||||
|
pendingImageElementId: null,
|
||||||
},
|
},
|
||||||
commitToHistory: appState.elementType === "freedraw",
|
commitToHistory: appState.activeTool.type === "freedraw",
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
keyTest: (event, appState) =>
|
keyTest: (event, appState) =>
|
||||||
@@ -163,7 +197,7 @@ export const actionFinalize = register({
|
|||||||
(!appState.draggingElement && appState.multiElement === null))) ||
|
(!appState.draggingElement && appState.multiElement === null))) ||
|
||||||
((event.key === KEYS.ESCAPE || event.key === KEYS.ENTER) &&
|
((event.key === KEYS.ESCAPE || event.key === KEYS.ENTER) &&
|
||||||
appState.multiElement !== null),
|
appState.multiElement !== null),
|
||||||
PanelComponent: ({ appState, updateData }) => (
|
PanelComponent: ({ appState, updateData, data }) => (
|
||||||
<ToolButton
|
<ToolButton
|
||||||
type="button"
|
type="button"
|
||||||
icon={done}
|
icon={done}
|
||||||
@@ -171,6 +205,7 @@ export const actionFinalize = register({
|
|||||||
aria-label={t("buttons.done")}
|
aria-label={t("buttons.done")}
|
||||||
onClick={updateData}
|
onClick={updateData}
|
||||||
visible={appState.multiElement != null}
|
visible={appState.multiElement != null}
|
||||||
|
size={data?.size || "medium"}
|
||||||
/>
|
/>
|
||||||
),
|
),
|
||||||
});
|
});
|
||||||
|
@@ -1,6 +1,6 @@
|
|||||||
import { register } from "./register";
|
import { register } from "./register";
|
||||||
import { getSelectedElements } from "../scene";
|
import { getSelectedElements } from "../scene";
|
||||||
import { getElementMap, getNonDeletedElements } from "../element";
|
import { getNonDeletedElements } from "../element";
|
||||||
import { mutateElement } from "../element/mutateElement";
|
import { mutateElement } from "../element/mutateElement";
|
||||||
import { ExcalidrawElement, NonDeleted } from "../element/types";
|
import { ExcalidrawElement, NonDeleted } from "../element/types";
|
||||||
import { normalizeAngle, resizeSingleElement } from "../element/resizeElements";
|
import { normalizeAngle, resizeSingleElement } from "../element/resizeElements";
|
||||||
@@ -9,6 +9,7 @@ import { getTransformHandles } from "../element/transformHandles";
|
|||||||
import { isFreeDrawElement, isLinearElement } from "../element/typeChecks";
|
import { isFreeDrawElement, isLinearElement } from "../element/typeChecks";
|
||||||
import { updateBoundElements } from "../element/binding";
|
import { updateBoundElements } from "../element/binding";
|
||||||
import { LinearElementEditor } from "../element/linearElementEditor";
|
import { LinearElementEditor } from "../element/linearElementEditor";
|
||||||
|
import { arrayToMap } from "../utils";
|
||||||
|
|
||||||
const enableActionFlipHorizontal = (
|
const enableActionFlipHorizontal = (
|
||||||
elements: readonly ExcalidrawElement[],
|
elements: readonly ExcalidrawElement[],
|
||||||
@@ -34,6 +35,7 @@ const enableActionFlipVertical = (
|
|||||||
|
|
||||||
export const actionFlipHorizontal = register({
|
export const actionFlipHorizontal = register({
|
||||||
name: "flipHorizontal",
|
name: "flipHorizontal",
|
||||||
|
trackEvent: { category: "element" },
|
||||||
perform: (elements, appState) => {
|
perform: (elements, appState) => {
|
||||||
return {
|
return {
|
||||||
elements: flipSelectedElements(elements, appState, "horizontal"),
|
elements: flipSelectedElements(elements, appState, "horizontal"),
|
||||||
@@ -49,6 +51,7 @@ export const actionFlipHorizontal = register({
|
|||||||
|
|
||||||
export const actionFlipVertical = register({
|
export const actionFlipVertical = register({
|
||||||
name: "flipVertical",
|
name: "flipVertical",
|
||||||
|
trackEvent: { category: "element" },
|
||||||
perform: (elements, appState) => {
|
perform: (elements, appState) => {
|
||||||
return {
|
return {
|
||||||
elements: flipSelectedElements(elements, appState, "vertical"),
|
elements: flipSelectedElements(elements, appState, "vertical"),
|
||||||
@@ -83,9 +86,11 @@ const flipSelectedElements = (
|
|||||||
flipDirection,
|
flipDirection,
|
||||||
);
|
);
|
||||||
|
|
||||||
const updatedElementsMap = getElementMap(updatedElements);
|
const updatedElementsMap = arrayToMap(updatedElements);
|
||||||
|
|
||||||
return elements.map((element) => updatedElementsMap[element.id] || element);
|
return elements.map(
|
||||||
|
(element) => updatedElementsMap.get(element.id) || element,
|
||||||
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
const flipElements = (
|
const flipElements = (
|
||||||
@@ -93,13 +98,13 @@ const flipElements = (
|
|||||||
appState: AppState,
|
appState: AppState,
|
||||||
flipDirection: "horizontal" | "vertical",
|
flipDirection: "horizontal" | "vertical",
|
||||||
): ExcalidrawElement[] => {
|
): ExcalidrawElement[] => {
|
||||||
for (let i = 0; i < elements.length; i++) {
|
elements.forEach((element) => {
|
||||||
flipElement(elements[i], appState);
|
flipElement(element, appState);
|
||||||
// If vertical flip, rotate an extra 180
|
// If vertical flip, rotate an extra 180
|
||||||
if (flipDirection === "vertical") {
|
if (flipDirection === "vertical") {
|
||||||
rotateElement(elements[i], Math.PI);
|
rotateElement(element, Math.PI);
|
||||||
}
|
}
|
||||||
}
|
});
|
||||||
return elements;
|
return elements;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -142,10 +147,9 @@ const flipElement = (
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (isLinearElement(element)) {
|
if (isLinearElement(element)) {
|
||||||
for (let i = 1; i < element.points.length; i++) {
|
for (let index = 1; index < element.points.length; index++) {
|
||||||
LinearElementEditor.movePoint(element, i, [
|
LinearElementEditor.movePoints(element, [
|
||||||
-element.points[i][0],
|
{ index, point: [-element.points[index][0], element.points[index][1]] },
|
||||||
element.points[i][1],
|
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
LinearElementEditor.normalizePoints(element);
|
LinearElementEditor.normalizePoints(element);
|
||||||
@@ -153,7 +157,7 @@ const flipElement = (
|
|||||||
// calculate new x-coord for transformation
|
// calculate new x-coord for transformation
|
||||||
newNCoordsX = usingNWHandle ? element.x + 2 * width : element.x - 2 * width;
|
newNCoordsX = usingNWHandle ? element.x + 2 * width : element.x - 2 * width;
|
||||||
resizeSingleElement(
|
resizeSingleElement(
|
||||||
element,
|
new Map().set(element.id, element),
|
||||||
true,
|
true,
|
||||||
element,
|
element,
|
||||||
usingNWHandle ? "nw" : "ne",
|
usingNWHandle ? "nw" : "ne",
|
||||||
|
@@ -1,7 +1,6 @@
|
|||||||
import React from "react";
|
|
||||||
import { CODES, KEYS } from "../keys";
|
import { CODES, KEYS } from "../keys";
|
||||||
import { t } from "../i18n";
|
import { t } from "../i18n";
|
||||||
import { getShortcutKey } from "../utils";
|
import { arrayToMap, getShortcutKey } from "../utils";
|
||||||
import { register } from "./register";
|
import { register } from "./register";
|
||||||
import { UngroupIcon, GroupIcon } from "../components/icons";
|
import { UngroupIcon, GroupIcon } from "../components/icons";
|
||||||
import { newElementWith } from "../element/mutateElement";
|
import { newElementWith } from "../element/mutateElement";
|
||||||
@@ -18,8 +17,9 @@ import {
|
|||||||
import { getNonDeletedElements } from "../element";
|
import { getNonDeletedElements } from "../element";
|
||||||
import { randomId } from "../random";
|
import { randomId } from "../random";
|
||||||
import { ToolButton } from "../components/ToolButton";
|
import { ToolButton } from "../components/ToolButton";
|
||||||
import { ExcalidrawElement } from "../element/types";
|
import { ExcalidrawElement, ExcalidrawTextElement } from "../element/types";
|
||||||
import { AppState } from "../types";
|
import { AppState } from "../types";
|
||||||
|
import { isBoundToContainer } from "../element/typeChecks";
|
||||||
|
|
||||||
const allElementsInSameGroup = (elements: readonly ExcalidrawElement[]) => {
|
const allElementsInSameGroup = (elements: readonly ExcalidrawElement[]) => {
|
||||||
if (elements.length >= 2) {
|
if (elements.length >= 2) {
|
||||||
@@ -45,6 +45,7 @@ const enableActionGroup = (
|
|||||||
const selectedElements = getSelectedElements(
|
const selectedElements = getSelectedElements(
|
||||||
getNonDeletedElements(elements),
|
getNonDeletedElements(elements),
|
||||||
appState,
|
appState,
|
||||||
|
true,
|
||||||
);
|
);
|
||||||
return (
|
return (
|
||||||
selectedElements.length >= 2 && !allElementsInSameGroup(selectedElements)
|
selectedElements.length >= 2 && !allElementsInSameGroup(selectedElements)
|
||||||
@@ -53,10 +54,12 @@ const enableActionGroup = (
|
|||||||
|
|
||||||
export const actionGroup = register({
|
export const actionGroup = register({
|
||||||
name: "group",
|
name: "group",
|
||||||
|
trackEvent: { category: "element" },
|
||||||
perform: (elements, appState) => {
|
perform: (elements, appState) => {
|
||||||
const selectedElements = getSelectedElements(
|
const selectedElements = getSelectedElements(
|
||||||
getNonDeletedElements(elements),
|
getNonDeletedElements(elements),
|
||||||
appState,
|
appState,
|
||||||
|
true,
|
||||||
);
|
);
|
||||||
if (selectedElements.length < 2) {
|
if (selectedElements.length < 2) {
|
||||||
// nothing to group
|
// nothing to group
|
||||||
@@ -84,8 +87,9 @@ export const actionGroup = register({
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
const newGroupId = randomId();
|
const newGroupId = randomId();
|
||||||
|
const selectElementIds = arrayToMap(selectedElements);
|
||||||
const updatedElements = elements.map((element) => {
|
const updatedElements = elements.map((element) => {
|
||||||
if (!appState.selectedElementIds[element.id]) {
|
if (!selectElementIds.get(element.id)) {
|
||||||
return element;
|
return element;
|
||||||
}
|
}
|
||||||
return newElementWith(element, {
|
return newElementWith(element, {
|
||||||
@@ -100,9 +104,8 @@ export const actionGroup = register({
|
|||||||
// to the z order of the highest element in the layer stack
|
// to the z order of the highest element in the layer stack
|
||||||
const elementsInGroup = getElementsInGroup(updatedElements, newGroupId);
|
const elementsInGroup = getElementsInGroup(updatedElements, newGroupId);
|
||||||
const lastElementInGroup = elementsInGroup[elementsInGroup.length - 1];
|
const lastElementInGroup = elementsInGroup[elementsInGroup.length - 1];
|
||||||
const lastGroupElementIndex = updatedElements.lastIndexOf(
|
const lastGroupElementIndex =
|
||||||
lastElementInGroup,
|
updatedElements.lastIndexOf(lastElementInGroup);
|
||||||
);
|
|
||||||
const elementsAfterGroup = updatedElements.slice(lastGroupElementIndex + 1);
|
const elementsAfterGroup = updatedElements.slice(lastGroupElementIndex + 1);
|
||||||
const elementsBeforeGroup = updatedElements
|
const elementsBeforeGroup = updatedElements
|
||||||
.slice(0, lastGroupElementIndex)
|
.slice(0, lastGroupElementIndex)
|
||||||
@@ -145,12 +148,18 @@ export const actionGroup = register({
|
|||||||
|
|
||||||
export const actionUngroup = register({
|
export const actionUngroup = register({
|
||||||
name: "ungroup",
|
name: "ungroup",
|
||||||
|
trackEvent: { category: "element" },
|
||||||
perform: (elements, appState) => {
|
perform: (elements, appState) => {
|
||||||
const groupIds = getSelectedGroupIds(appState);
|
const groupIds = getSelectedGroupIds(appState);
|
||||||
if (groupIds.length === 0) {
|
if (groupIds.length === 0) {
|
||||||
return { appState, elements, commitToHistory: false };
|
return { appState, elements, commitToHistory: false };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const boundTextElementIds: ExcalidrawTextElement["id"][] = [];
|
||||||
const nextElements = elements.map((element) => {
|
const nextElements = elements.map((element) => {
|
||||||
|
if (isBoundToContainer(element)) {
|
||||||
|
boundTextElementIds.push(element.id);
|
||||||
|
}
|
||||||
const nextGroupIds = removeFromSelectedGroups(
|
const nextGroupIds = removeFromSelectedGroups(
|
||||||
element.groupIds,
|
element.groupIds,
|
||||||
appState.selectedGroupIds,
|
appState.selectedGroupIds,
|
||||||
@@ -162,11 +171,19 @@ export const actionUngroup = register({
|
|||||||
groupIds: nextGroupIds,
|
groupIds: nextGroupIds,
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const updateAppState = selectGroupsForSelectedElements(
|
||||||
|
{ ...appState, selectedGroupIds: {} },
|
||||||
|
getNonDeletedElements(nextElements),
|
||||||
|
);
|
||||||
|
|
||||||
|
// remove binded text elements from selection
|
||||||
|
boundTextElementIds.forEach(
|
||||||
|
(id) => (updateAppState.selectedElementIds[id] = false),
|
||||||
|
);
|
||||||
return {
|
return {
|
||||||
appState: selectGroupsForSelectedElements(
|
appState: updateAppState,
|
||||||
{ ...appState, selectedGroupIds: {} },
|
|
||||||
getNonDeletedElements(nextElements),
|
|
||||||
),
|
|
||||||
elements: nextElements,
|
elements: nextElements,
|
||||||
commitToHistory: true,
|
commitToHistory: true,
|
||||||
};
|
};
|
||||||
|
@@ -1,5 +1,4 @@
|
|||||||
import { Action, ActionResult } from "./types";
|
import { Action, ActionResult } from "./types";
|
||||||
import React from "react";
|
|
||||||
import { undo, redo } from "../components/icons";
|
import { undo, redo } from "../components/icons";
|
||||||
import { ToolButton } from "../components/ToolButton";
|
import { ToolButton } from "../components/ToolButton";
|
||||||
import { t } from "../i18n";
|
import { t } from "../i18n";
|
||||||
@@ -7,9 +6,9 @@ import History, { HistoryEntry } from "../history";
|
|||||||
import { ExcalidrawElement } from "../element/types";
|
import { ExcalidrawElement } from "../element/types";
|
||||||
import { AppState } from "../types";
|
import { AppState } from "../types";
|
||||||
import { isWindows, KEYS } from "../keys";
|
import { isWindows, KEYS } from "../keys";
|
||||||
import { getElementMap } from "../element";
|
|
||||||
import { newElementWith } from "../element/mutateElement";
|
import { newElementWith } from "../element/mutateElement";
|
||||||
import { fixBindingsAfterDeletion } from "../element/binding";
|
import { fixBindingsAfterDeletion } from "../element/binding";
|
||||||
|
import { arrayToMap } from "../utils";
|
||||||
|
|
||||||
const writeData = (
|
const writeData = (
|
||||||
prevElements: readonly ExcalidrawElement[],
|
prevElements: readonly ExcalidrawElement[],
|
||||||
@@ -28,17 +27,17 @@ const writeData = (
|
|||||||
return { commitToHistory };
|
return { commitToHistory };
|
||||||
}
|
}
|
||||||
|
|
||||||
const prevElementMap = getElementMap(prevElements);
|
const prevElementMap = arrayToMap(prevElements);
|
||||||
const nextElements = data.elements;
|
const nextElements = data.elements;
|
||||||
const nextElementMap = getElementMap(nextElements);
|
const nextElementMap = arrayToMap(nextElements);
|
||||||
|
|
||||||
const deletedElements = prevElements.filter(
|
const deletedElements = prevElements.filter(
|
||||||
(prevElement) => !nextElementMap.hasOwnProperty(prevElement.id),
|
(prevElement) => !nextElementMap.has(prevElement.id),
|
||||||
);
|
);
|
||||||
const elements = nextElements
|
const elements = nextElements
|
||||||
.map((nextElement) =>
|
.map((nextElement) =>
|
||||||
newElementWith(
|
newElementWith(
|
||||||
prevElementMap[nextElement.id] || nextElement,
|
prevElementMap.get(nextElement.id) || nextElement,
|
||||||
nextElement,
|
nextElement,
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
@@ -63,18 +62,20 @@ type ActionCreator = (history: History) => Action;
|
|||||||
|
|
||||||
export const createUndoAction: ActionCreator = (history) => ({
|
export const createUndoAction: ActionCreator = (history) => ({
|
||||||
name: "undo",
|
name: "undo",
|
||||||
|
trackEvent: { category: "history" },
|
||||||
perform: (elements, appState) =>
|
perform: (elements, appState) =>
|
||||||
writeData(elements, appState, () => history.undoOnce()),
|
writeData(elements, appState, () => history.undoOnce()),
|
||||||
keyTest: (event) =>
|
keyTest: (event) =>
|
||||||
event[KEYS.CTRL_OR_CMD] &&
|
event[KEYS.CTRL_OR_CMD] &&
|
||||||
event.key.toLowerCase() === KEYS.Z &&
|
event.key.toLowerCase() === KEYS.Z &&
|
||||||
!event.shiftKey,
|
!event.shiftKey,
|
||||||
PanelComponent: ({ updateData }) => (
|
PanelComponent: ({ updateData, data }) => (
|
||||||
<ToolButton
|
<ToolButton
|
||||||
type="button"
|
type="button"
|
||||||
icon={undo}
|
icon={undo}
|
||||||
aria-label={t("buttons.undo")}
|
aria-label={t("buttons.undo")}
|
||||||
onClick={updateData}
|
onClick={updateData}
|
||||||
|
size={data?.size || "medium"}
|
||||||
/>
|
/>
|
||||||
),
|
),
|
||||||
commitToHistory: () => false,
|
commitToHistory: () => false,
|
||||||
@@ -82,6 +83,7 @@ export const createUndoAction: ActionCreator = (history) => ({
|
|||||||
|
|
||||||
export const createRedoAction: ActionCreator = (history) => ({
|
export const createRedoAction: ActionCreator = (history) => ({
|
||||||
name: "redo",
|
name: "redo",
|
||||||
|
trackEvent: { category: "history" },
|
||||||
perform: (elements, appState) =>
|
perform: (elements, appState) =>
|
||||||
writeData(elements, appState, () => history.redoOnce()),
|
writeData(elements, appState, () => history.redoOnce()),
|
||||||
keyTest: (event) =>
|
keyTest: (event) =>
|
||||||
@@ -89,12 +91,13 @@ export const createRedoAction: ActionCreator = (history) => ({
|
|||||||
event.shiftKey &&
|
event.shiftKey &&
|
||||||
event.key.toLowerCase() === KEYS.Z) ||
|
event.key.toLowerCase() === KEYS.Z) ||
|
||||||
(isWindows && event.ctrlKey && !event.shiftKey && event.key === KEYS.Y),
|
(isWindows && event.ctrlKey && !event.shiftKey && event.key === KEYS.Y),
|
||||||
PanelComponent: ({ updateData }) => (
|
PanelComponent: ({ updateData, data }) => (
|
||||||
<ToolButton
|
<ToolButton
|
||||||
type="button"
|
type="button"
|
||||||
icon={redo}
|
icon={redo}
|
||||||
aria-label={t("buttons.redo")}
|
aria-label={t("buttons.redo")}
|
||||||
onClick={updateData}
|
onClick={updateData}
|
||||||
|
size={data?.size || "medium"}
|
||||||
/>
|
/>
|
||||||
),
|
),
|
||||||
commitToHistory: () => false,
|
commitToHistory: () => false,
|
||||||
|
@@ -1,4 +1,3 @@
|
|||||||
import React from "react";
|
|
||||||
import { menu, palette } from "../components/icons";
|
import { menu, palette } from "../components/icons";
|
||||||
import { ToolButton } from "../components/ToolButton";
|
import { ToolButton } from "../components/ToolButton";
|
||||||
import { t } from "../i18n";
|
import { t } from "../i18n";
|
||||||
@@ -10,6 +9,7 @@ import { HelpIcon } from "../components/HelpIcon";
|
|||||||
|
|
||||||
export const actionToggleCanvasMenu = register({
|
export const actionToggleCanvasMenu = register({
|
||||||
name: "toggleCanvasMenu",
|
name: "toggleCanvasMenu",
|
||||||
|
trackEvent: { category: "menu" },
|
||||||
perform: (_, appState) => ({
|
perform: (_, appState) => ({
|
||||||
appState: {
|
appState: {
|
||||||
...appState,
|
...appState,
|
||||||
@@ -30,6 +30,7 @@ export const actionToggleCanvasMenu = register({
|
|||||||
|
|
||||||
export const actionToggleEditMenu = register({
|
export const actionToggleEditMenu = register({
|
||||||
name: "toggleEditMenu",
|
name: "toggleEditMenu",
|
||||||
|
trackEvent: { category: "menu" },
|
||||||
perform: (_elements, appState) => ({
|
perform: (_elements, appState) => ({
|
||||||
appState: {
|
appState: {
|
||||||
...appState,
|
...appState,
|
||||||
@@ -54,6 +55,7 @@ export const actionToggleEditMenu = register({
|
|||||||
|
|
||||||
export const actionFullScreen = register({
|
export const actionFullScreen = register({
|
||||||
name: "toggleFullScreen",
|
name: "toggleFullScreen",
|
||||||
|
trackEvent: { category: "canvas", predicate: (appState) => !isFullScreen() },
|
||||||
perform: () => {
|
perform: () => {
|
||||||
if (!isFullScreen()) {
|
if (!isFullScreen()) {
|
||||||
allowFullScreen();
|
allowFullScreen();
|
||||||
@@ -70,6 +72,7 @@ export const actionFullScreen = register({
|
|||||||
|
|
||||||
export const actionShortcuts = register({
|
export const actionShortcuts = register({
|
||||||
name: "toggleShortcuts",
|
name: "toggleShortcuts",
|
||||||
|
trackEvent: { category: "menu", action: "toggleHelpDialog" },
|
||||||
perform: (_elements, appState, _, { focusContainer }) => {
|
perform: (_elements, appState, _, { focusContainer }) => {
|
||||||
if (appState.showHelpDialog) {
|
if (appState.showHelpDialog) {
|
||||||
focusContainer();
|
focusContainer();
|
||||||
|
@@ -1,5 +1,4 @@
|
|||||||
import React from "react";
|
import { getClientColors } from "../clients";
|
||||||
import { getClientColors, getClientInitials } from "../clients";
|
|
||||||
import { Avatar } from "../components/Avatar";
|
import { Avatar } from "../components/Avatar";
|
||||||
import { centerScrollOn } from "../scene/scroll";
|
import { centerScrollOn } from "../scene/scroll";
|
||||||
import { Collaborator } from "../types";
|
import { Collaborator } from "../types";
|
||||||
@@ -7,6 +6,7 @@ import { register } from "./register";
|
|||||||
|
|
||||||
export const actionGoToCollaborator = register({
|
export const actionGoToCollaborator = register({
|
||||||
name: "goToCollaborator",
|
name: "goToCollaborator",
|
||||||
|
trackEvent: { category: "collab" },
|
||||||
perform: (_elements, appState, value) => {
|
perform: (_elements, appState, value) => {
|
||||||
const point = value as Collaborator["pointer"];
|
const point = value as Collaborator["pointer"];
|
||||||
if (!point) {
|
if (!point) {
|
||||||
@@ -30,29 +30,19 @@ export const actionGoToCollaborator = register({
|
|||||||
commitToHistory: false,
|
commitToHistory: false,
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
PanelComponent: ({ appState, updateData, id }) => {
|
PanelComponent: ({ appState, updateData, data }) => {
|
||||||
const clientId = id;
|
const [clientId, collaborator] = data as [string, Collaborator];
|
||||||
if (!clientId) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
const collaborator = appState.collaborators.get(clientId);
|
|
||||||
|
|
||||||
if (!collaborator) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
const { background, stroke } = getClientColors(clientId, appState);
|
const { background, stroke } = getClientColors(clientId, appState);
|
||||||
const shortName = getClientInitials(collaborator.username);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Avatar
|
<Avatar
|
||||||
color={background}
|
color={background}
|
||||||
border={stroke}
|
border={stroke}
|
||||||
onClick={() => updateData(collaborator.pointer)}
|
onClick={() => updateData(collaborator.pointer)}
|
||||||
>
|
name={collaborator.username || ""}
|
||||||
{shortName}
|
src={collaborator.avatarUrl}
|
||||||
</Avatar>
|
/>
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
@@ -1,4 +1,3 @@
|
|||||||
import React from "react";
|
|
||||||
import { AppState } from "../../src/types";
|
import { AppState } from "../../src/types";
|
||||||
import { ButtonIconSelect } from "../components/ButtonIconSelect";
|
import { ButtonIconSelect } from "../components/ButtonIconSelect";
|
||||||
import { ColorPicker } from "../components/ColorPicker";
|
import { ColorPicker } from "../components/ColorPicker";
|
||||||
@@ -7,12 +6,20 @@ import {
|
|||||||
ArrowheadArrowIcon,
|
ArrowheadArrowIcon,
|
||||||
ArrowheadBarIcon,
|
ArrowheadBarIcon,
|
||||||
ArrowheadDotIcon,
|
ArrowheadDotIcon,
|
||||||
|
ArrowheadTriangleIcon,
|
||||||
ArrowheadNoneIcon,
|
ArrowheadNoneIcon,
|
||||||
EdgeRoundIcon,
|
EdgeRoundIcon,
|
||||||
EdgeSharpIcon,
|
EdgeSharpIcon,
|
||||||
FillCrossHatchIcon,
|
FillCrossHatchIcon,
|
||||||
FillHachureIcon,
|
FillHachureIcon,
|
||||||
FillSolidIcon,
|
FillSolidIcon,
|
||||||
|
FontFamilyCodeIcon,
|
||||||
|
FontFamilyHandDrawnIcon,
|
||||||
|
FontFamilyNormalIcon,
|
||||||
|
FontSizeExtraLargeIcon,
|
||||||
|
FontSizeLargeIcon,
|
||||||
|
FontSizeMediumIcon,
|
||||||
|
FontSizeSmallIcon,
|
||||||
SloppinessArchitectIcon,
|
SloppinessArchitectIcon,
|
||||||
SloppinessArtistIcon,
|
SloppinessArtistIcon,
|
||||||
SloppinessCartoonistIcon,
|
SloppinessCartoonistIcon,
|
||||||
@@ -20,52 +27,72 @@ import {
|
|||||||
StrokeStyleDottedIcon,
|
StrokeStyleDottedIcon,
|
||||||
StrokeStyleSolidIcon,
|
StrokeStyleSolidIcon,
|
||||||
StrokeWidthIcon,
|
StrokeWidthIcon,
|
||||||
FontSizeSmallIcon,
|
|
||||||
FontSizeMediumIcon,
|
|
||||||
FontSizeLargeIcon,
|
|
||||||
FontSizeExtraLargeIcon,
|
|
||||||
FontFamilyHandDrawnIcon,
|
|
||||||
FontFamilyNormalIcon,
|
|
||||||
FontFamilyCodeIcon,
|
|
||||||
TextAlignLeftIcon,
|
|
||||||
TextAlignCenterIcon,
|
TextAlignCenterIcon,
|
||||||
|
TextAlignLeftIcon,
|
||||||
TextAlignRightIcon,
|
TextAlignRightIcon,
|
||||||
|
TextAlignTopIcon,
|
||||||
|
TextAlignBottomIcon,
|
||||||
|
TextAlignMiddleIcon,
|
||||||
} from "../components/icons";
|
} from "../components/icons";
|
||||||
import { DEFAULT_FONT_FAMILY, DEFAULT_FONT_SIZE } from "../constants";
|
import {
|
||||||
|
DEFAULT_FONT_FAMILY,
|
||||||
|
DEFAULT_FONT_SIZE,
|
||||||
|
FONT_FAMILY,
|
||||||
|
VERTICAL_ALIGN,
|
||||||
|
} from "../constants";
|
||||||
import {
|
import {
|
||||||
getNonDeletedElements,
|
getNonDeletedElements,
|
||||||
isTextElement,
|
isTextElement,
|
||||||
redrawTextBoundingBox,
|
redrawTextBoundingBox,
|
||||||
} from "../element";
|
} from "../element";
|
||||||
import { newElementWith } from "../element/mutateElement";
|
import { mutateElement, newElementWith } from "../element/mutateElement";
|
||||||
import { isLinearElement, isLinearElementType } from "../element/typeChecks";
|
import {
|
||||||
|
getBoundTextElement,
|
||||||
|
getContainerElement,
|
||||||
|
} from "../element/textElement";
|
||||||
|
import {
|
||||||
|
isBoundToContainer,
|
||||||
|
isLinearElement,
|
||||||
|
isLinearElementType,
|
||||||
|
} from "../element/typeChecks";
|
||||||
import {
|
import {
|
||||||
Arrowhead,
|
Arrowhead,
|
||||||
ExcalidrawElement,
|
ExcalidrawElement,
|
||||||
ExcalidrawLinearElement,
|
ExcalidrawLinearElement,
|
||||||
ExcalidrawTextElement,
|
ExcalidrawTextElement,
|
||||||
FontFamily,
|
FontFamilyValues,
|
||||||
TextAlign,
|
TextAlign,
|
||||||
|
VerticalAlign,
|
||||||
} from "../element/types";
|
} from "../element/types";
|
||||||
import { getLanguage, t } from "../i18n";
|
import { getLanguage, t } from "../i18n";
|
||||||
|
import { KEYS } from "../keys";
|
||||||
import { randomInteger } from "../random";
|
import { randomInteger } from "../random";
|
||||||
import {
|
import {
|
||||||
canChangeSharpness,
|
canChangeSharpness,
|
||||||
canHaveArrowheads,
|
canHaveArrowheads,
|
||||||
getCommonAttributeOfSelectedElements,
|
getCommonAttributeOfSelectedElements,
|
||||||
|
getSelectedElements,
|
||||||
getTargetElements,
|
getTargetElements,
|
||||||
isSomeElementSelected,
|
isSomeElementSelected,
|
||||||
} from "../scene";
|
} from "../scene";
|
||||||
|
import { hasStrokeColor } from "../scene/comparisons";
|
||||||
|
import { arrayToMap } from "../utils";
|
||||||
import { register } from "./register";
|
import { register } from "./register";
|
||||||
|
|
||||||
|
const FONT_SIZE_RELATIVE_INCREASE_STEP = 0.1;
|
||||||
|
|
||||||
const changeProperty = (
|
const changeProperty = (
|
||||||
elements: readonly ExcalidrawElement[],
|
elements: readonly ExcalidrawElement[],
|
||||||
appState: AppState,
|
appState: AppState,
|
||||||
callback: (element: ExcalidrawElement) => ExcalidrawElement,
|
callback: (element: ExcalidrawElement) => ExcalidrawElement,
|
||||||
|
includeBoundText = false,
|
||||||
) => {
|
) => {
|
||||||
|
const selectedElementIds = arrayToMap(
|
||||||
|
getSelectedElements(elements, appState, includeBoundText),
|
||||||
|
);
|
||||||
return elements.map((element) => {
|
return elements.map((element) => {
|
||||||
if (
|
if (
|
||||||
appState.selectedElementIds[element.id] ||
|
selectedElementIds.get(element.id) ||
|
||||||
element.id === appState.editingElement?.id
|
element.id === appState.editingElement?.id
|
||||||
) {
|
) {
|
||||||
return callback(element);
|
return callback(element);
|
||||||
@@ -95,17 +122,100 @@ const getFormValue = function <T>(
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const offsetElementAfterFontResize = (
|
||||||
|
prevElement: ExcalidrawTextElement,
|
||||||
|
nextElement: ExcalidrawTextElement,
|
||||||
|
) => {
|
||||||
|
if (isBoundToContainer(nextElement)) {
|
||||||
|
return nextElement;
|
||||||
|
}
|
||||||
|
return mutateElement(
|
||||||
|
nextElement,
|
||||||
|
{
|
||||||
|
x:
|
||||||
|
prevElement.textAlign === "left"
|
||||||
|
? prevElement.x
|
||||||
|
: prevElement.x +
|
||||||
|
(prevElement.width - nextElement.width) /
|
||||||
|
(prevElement.textAlign === "center" ? 2 : 1),
|
||||||
|
// centering vertically is non-standard, but for Excalidraw I think
|
||||||
|
// it makes sense
|
||||||
|
y: prevElement.y + (prevElement.height - nextElement.height) / 2,
|
||||||
|
},
|
||||||
|
false,
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const changeFontSize = (
|
||||||
|
elements: readonly ExcalidrawElement[],
|
||||||
|
appState: AppState,
|
||||||
|
getNewFontSize: (element: ExcalidrawTextElement) => number,
|
||||||
|
fallbackValue?: ExcalidrawTextElement["fontSize"],
|
||||||
|
) => {
|
||||||
|
const newFontSizes = new Set<number>();
|
||||||
|
|
||||||
|
return {
|
||||||
|
elements: changeProperty(
|
||||||
|
elements,
|
||||||
|
appState,
|
||||||
|
(oldElement) => {
|
||||||
|
if (isTextElement(oldElement)) {
|
||||||
|
const newFontSize = getNewFontSize(oldElement);
|
||||||
|
newFontSizes.add(newFontSize);
|
||||||
|
|
||||||
|
let newElement: ExcalidrawTextElement = newElementWith(oldElement, {
|
||||||
|
fontSize: newFontSize,
|
||||||
|
});
|
||||||
|
redrawTextBoundingBox(newElement, getContainerElement(oldElement));
|
||||||
|
|
||||||
|
newElement = offsetElementAfterFontResize(oldElement, newElement);
|
||||||
|
|
||||||
|
return newElement;
|
||||||
|
}
|
||||||
|
|
||||||
|
return oldElement;
|
||||||
|
},
|
||||||
|
true,
|
||||||
|
),
|
||||||
|
appState: {
|
||||||
|
...appState,
|
||||||
|
// update state only if we've set all select text elements to
|
||||||
|
// the same font size
|
||||||
|
currentItemFontSize:
|
||||||
|
newFontSizes.size === 1
|
||||||
|
? [...newFontSizes][0]
|
||||||
|
: fallbackValue ?? appState.currentItemFontSize,
|
||||||
|
},
|
||||||
|
commitToHistory: true,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------------
|
||||||
|
|
||||||
export const actionChangeStrokeColor = register({
|
export const actionChangeStrokeColor = register({
|
||||||
name: "changeStrokeColor",
|
name: "changeStrokeColor",
|
||||||
|
trackEvent: false,
|
||||||
perform: (elements, appState, value) => {
|
perform: (elements, appState, value) => {
|
||||||
return {
|
return {
|
||||||
elements: changeProperty(elements, appState, (el) =>
|
...(value.currentItemStrokeColor && {
|
||||||
newElementWith(el, {
|
elements: changeProperty(
|
||||||
strokeColor: value,
|
elements,
|
||||||
}),
|
appState,
|
||||||
),
|
(el) => {
|
||||||
appState: { ...appState, currentItemStrokeColor: value },
|
return hasStrokeColor(el.type)
|
||||||
commitToHistory: true,
|
? newElementWith(el, {
|
||||||
|
strokeColor: value.currentItemStrokeColor,
|
||||||
|
})
|
||||||
|
: el;
|
||||||
|
},
|
||||||
|
true,
|
||||||
|
),
|
||||||
|
}),
|
||||||
|
appState: {
|
||||||
|
...appState,
|
||||||
|
...value,
|
||||||
|
},
|
||||||
|
commitToHistory: !!value.currentItemStrokeColor,
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
PanelComponent: ({ elements, appState, updateData }) => (
|
PanelComponent: ({ elements, appState, updateData }) => (
|
||||||
@@ -120,7 +230,13 @@ export const actionChangeStrokeColor = register({
|
|||||||
(element) => element.strokeColor,
|
(element) => element.strokeColor,
|
||||||
appState.currentItemStrokeColor,
|
appState.currentItemStrokeColor,
|
||||||
)}
|
)}
|
||||||
onChange={updateData}
|
onChange={(color) => updateData({ currentItemStrokeColor: color })}
|
||||||
|
isActive={appState.openPopup === "strokeColorPicker"}
|
||||||
|
setActive={(active) =>
|
||||||
|
updateData({ openPopup: active ? "strokeColorPicker" : null })
|
||||||
|
}
|
||||||
|
elements={elements}
|
||||||
|
appState={appState}
|
||||||
/>
|
/>
|
||||||
</>
|
</>
|
||||||
),
|
),
|
||||||
@@ -128,15 +244,21 @@ export const actionChangeStrokeColor = register({
|
|||||||
|
|
||||||
export const actionChangeBackgroundColor = register({
|
export const actionChangeBackgroundColor = register({
|
||||||
name: "changeBackgroundColor",
|
name: "changeBackgroundColor",
|
||||||
|
trackEvent: false,
|
||||||
perform: (elements, appState, value) => {
|
perform: (elements, appState, value) => {
|
||||||
return {
|
return {
|
||||||
elements: changeProperty(elements, appState, (el) =>
|
...(value.currentItemBackgroundColor && {
|
||||||
newElementWith(el, {
|
elements: changeProperty(elements, appState, (el) =>
|
||||||
backgroundColor: value,
|
newElementWith(el, {
|
||||||
}),
|
backgroundColor: value.currentItemBackgroundColor,
|
||||||
),
|
}),
|
||||||
appState: { ...appState, currentItemBackgroundColor: value },
|
),
|
||||||
commitToHistory: true,
|
}),
|
||||||
|
appState: {
|
||||||
|
...appState,
|
||||||
|
...value,
|
||||||
|
},
|
||||||
|
commitToHistory: !!value.currentItemBackgroundColor,
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
PanelComponent: ({ elements, appState, updateData }) => (
|
PanelComponent: ({ elements, appState, updateData }) => (
|
||||||
@@ -151,7 +273,13 @@ export const actionChangeBackgroundColor = register({
|
|||||||
(element) => element.backgroundColor,
|
(element) => element.backgroundColor,
|
||||||
appState.currentItemBackgroundColor,
|
appState.currentItemBackgroundColor,
|
||||||
)}
|
)}
|
||||||
onChange={updateData}
|
onChange={(color) => updateData({ currentItemBackgroundColor: color })}
|
||||||
|
isActive={appState.openPopup === "backgroundColorPicker"}
|
||||||
|
setActive={(active) =>
|
||||||
|
updateData({ openPopup: active ? "backgroundColorPicker" : null })
|
||||||
|
}
|
||||||
|
elements={elements}
|
||||||
|
appState={appState}
|
||||||
/>
|
/>
|
||||||
</>
|
</>
|
||||||
),
|
),
|
||||||
@@ -159,6 +287,7 @@ export const actionChangeBackgroundColor = register({
|
|||||||
|
|
||||||
export const actionChangeFillStyle = register({
|
export const actionChangeFillStyle = register({
|
||||||
name: "changeFillStyle",
|
name: "changeFillStyle",
|
||||||
|
trackEvent: false,
|
||||||
perform: (elements, appState, value) => {
|
perform: (elements, appState, value) => {
|
||||||
return {
|
return {
|
||||||
elements: changeProperty(elements, appState, (el) =>
|
elements: changeProperty(elements, appState, (el) =>
|
||||||
@@ -208,6 +337,7 @@ export const actionChangeFillStyle = register({
|
|||||||
|
|
||||||
export const actionChangeStrokeWidth = register({
|
export const actionChangeStrokeWidth = register({
|
||||||
name: "changeStrokeWidth",
|
name: "changeStrokeWidth",
|
||||||
|
trackEvent: false,
|
||||||
perform: (elements, appState, value) => {
|
perform: (elements, appState, value) => {
|
||||||
return {
|
return {
|
||||||
elements: changeProperty(elements, appState, (el) =>
|
elements: changeProperty(elements, appState, (el) =>
|
||||||
@@ -255,6 +385,7 @@ export const actionChangeStrokeWidth = register({
|
|||||||
|
|
||||||
export const actionChangeSloppiness = register({
|
export const actionChangeSloppiness = register({
|
||||||
name: "changeSloppiness",
|
name: "changeSloppiness",
|
||||||
|
trackEvent: false,
|
||||||
perform: (elements, appState, value) => {
|
perform: (elements, appState, value) => {
|
||||||
return {
|
return {
|
||||||
elements: changeProperty(elements, appState, (el) =>
|
elements: changeProperty(elements, appState, (el) =>
|
||||||
@@ -303,6 +434,7 @@ export const actionChangeSloppiness = register({
|
|||||||
|
|
||||||
export const actionChangeStrokeStyle = register({
|
export const actionChangeStrokeStyle = register({
|
||||||
name: "changeStrokeStyle",
|
name: "changeStrokeStyle",
|
||||||
|
trackEvent: false,
|
||||||
perform: (elements, appState, value) => {
|
perform: (elements, appState, value) => {
|
||||||
return {
|
return {
|
||||||
elements: changeProperty(elements, appState, (el) =>
|
elements: changeProperty(elements, appState, (el) =>
|
||||||
@@ -350,12 +482,17 @@ export const actionChangeStrokeStyle = register({
|
|||||||
|
|
||||||
export const actionChangeOpacity = register({
|
export const actionChangeOpacity = register({
|
||||||
name: "changeOpacity",
|
name: "changeOpacity",
|
||||||
|
trackEvent: false,
|
||||||
perform: (elements, appState, value) => {
|
perform: (elements, appState, value) => {
|
||||||
return {
|
return {
|
||||||
elements: changeProperty(elements, appState, (el) =>
|
elements: changeProperty(
|
||||||
newElementWith(el, {
|
elements,
|
||||||
opacity: value,
|
appState,
|
||||||
}),
|
(el) =>
|
||||||
|
newElementWith(el, {
|
||||||
|
opacity: value,
|
||||||
|
}),
|
||||||
|
true,
|
||||||
),
|
),
|
||||||
appState: { ...appState, currentItemOpacity: value },
|
appState: { ...appState, currentItemOpacity: value },
|
||||||
commitToHistory: true,
|
commitToHistory: true,
|
||||||
@@ -370,20 +507,6 @@ export const actionChangeOpacity = register({
|
|||||||
max="100"
|
max="100"
|
||||||
step="10"
|
step="10"
|
||||||
onChange={(event) => updateData(+event.target.value)}
|
onChange={(event) => updateData(+event.target.value)}
|
||||||
onWheel={(event) => {
|
|
||||||
event.stopPropagation();
|
|
||||||
const target = event.target as HTMLInputElement;
|
|
||||||
const STEP = 10;
|
|
||||||
const MAX = 100;
|
|
||||||
const MIN = 0;
|
|
||||||
const value = +target.value;
|
|
||||||
|
|
||||||
if (event.deltaY < 0 && value < MAX) {
|
|
||||||
updateData(value + STEP);
|
|
||||||
} else if (event.deltaY > 0 && value > MIN) {
|
|
||||||
updateData(value - STEP);
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
value={
|
value={
|
||||||
getFormValue(
|
getFormValue(
|
||||||
elements,
|
elements,
|
||||||
@@ -399,25 +522,9 @@ export const actionChangeOpacity = register({
|
|||||||
|
|
||||||
export const actionChangeFontSize = register({
|
export const actionChangeFontSize = register({
|
||||||
name: "changeFontSize",
|
name: "changeFontSize",
|
||||||
|
trackEvent: false,
|
||||||
perform: (elements, appState, value) => {
|
perform: (elements, appState, value) => {
|
||||||
return {
|
return changeFontSize(elements, appState, () => value, value);
|
||||||
elements: changeProperty(elements, appState, (el) => {
|
|
||||||
if (isTextElement(el)) {
|
|
||||||
const element: ExcalidrawTextElement = newElementWith(el, {
|
|
||||||
fontSize: value,
|
|
||||||
});
|
|
||||||
redrawTextBoundingBox(element);
|
|
||||||
return element;
|
|
||||||
}
|
|
||||||
|
|
||||||
return el;
|
|
||||||
}),
|
|
||||||
appState: {
|
|
||||||
...appState,
|
|
||||||
currentItemFontSize: value,
|
|
||||||
},
|
|
||||||
commitToHistory: true,
|
|
||||||
};
|
|
||||||
},
|
},
|
||||||
PanelComponent: ({ elements, appState, updateData }) => (
|
PanelComponent: ({ elements, appState, updateData }) => (
|
||||||
<fieldset>
|
<fieldset>
|
||||||
@@ -429,27 +536,40 @@ export const actionChangeFontSize = register({
|
|||||||
value: 16,
|
value: 16,
|
||||||
text: t("labels.small"),
|
text: t("labels.small"),
|
||||||
icon: <FontSizeSmallIcon theme={appState.theme} />,
|
icon: <FontSizeSmallIcon theme={appState.theme} />,
|
||||||
|
testId: "fontSize-small",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
value: 20,
|
value: 20,
|
||||||
text: t("labels.medium"),
|
text: t("labels.medium"),
|
||||||
icon: <FontSizeMediumIcon theme={appState.theme} />,
|
icon: <FontSizeMediumIcon theme={appState.theme} />,
|
||||||
|
testId: "fontSize-medium",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
value: 28,
|
value: 28,
|
||||||
text: t("labels.large"),
|
text: t("labels.large"),
|
||||||
icon: <FontSizeLargeIcon theme={appState.theme} />,
|
icon: <FontSizeLargeIcon theme={appState.theme} />,
|
||||||
|
testId: "fontSize-large",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
value: 36,
|
value: 36,
|
||||||
text: t("labels.veryLarge"),
|
text: t("labels.veryLarge"),
|
||||||
icon: <FontSizeExtraLargeIcon theme={appState.theme} />,
|
icon: <FontSizeExtraLargeIcon theme={appState.theme} />,
|
||||||
|
testId: "fontSize-veryLarge",
|
||||||
},
|
},
|
||||||
]}
|
]}
|
||||||
value={getFormValue(
|
value={getFormValue(
|
||||||
elements,
|
elements,
|
||||||
appState,
|
appState,
|
||||||
(element) => isTextElement(element) && element.fontSize,
|
(element) => {
|
||||||
|
if (isTextElement(element)) {
|
||||||
|
return element.fontSize;
|
||||||
|
}
|
||||||
|
const boundTextElement = getBoundTextElement(element);
|
||||||
|
if (boundTextElement) {
|
||||||
|
return boundTextElement.fontSize;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
},
|
||||||
appState.currentItemFontSize || DEFAULT_FONT_SIZE,
|
appState.currentItemFontSize || DEFAULT_FONT_SIZE,
|
||||||
)}
|
)}
|
||||||
onChange={(value) => updateData(value)}
|
onChange={(value) => updateData(value)}
|
||||||
@@ -458,21 +578,70 @@ export const actionChangeFontSize = register({
|
|||||||
),
|
),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
export const actionDecreaseFontSize = register({
|
||||||
|
name: "decreaseFontSize",
|
||||||
|
trackEvent: false,
|
||||||
|
perform: (elements, appState, value) => {
|
||||||
|
return changeFontSize(elements, appState, (element) =>
|
||||||
|
Math.round(
|
||||||
|
// get previous value before relative increase (doesn't work fully
|
||||||
|
// due to rounding and float precision issues)
|
||||||
|
(1 / (1 + FONT_SIZE_RELATIVE_INCREASE_STEP)) * element.fontSize,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
keyTest: (event) => {
|
||||||
|
return (
|
||||||
|
event[KEYS.CTRL_OR_CMD] &&
|
||||||
|
event.shiftKey &&
|
||||||
|
// KEYS.COMMA needed for MacOS
|
||||||
|
(event.key === KEYS.CHEVRON_LEFT || event.key === KEYS.COMMA)
|
||||||
|
);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
export const actionIncreaseFontSize = register({
|
||||||
|
name: "increaseFontSize",
|
||||||
|
trackEvent: false,
|
||||||
|
perform: (elements, appState, value) => {
|
||||||
|
return changeFontSize(elements, appState, (element) =>
|
||||||
|
Math.round(element.fontSize * (1 + FONT_SIZE_RELATIVE_INCREASE_STEP)),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
keyTest: (event) => {
|
||||||
|
return (
|
||||||
|
event[KEYS.CTRL_OR_CMD] &&
|
||||||
|
event.shiftKey &&
|
||||||
|
// KEYS.PERIOD needed for MacOS
|
||||||
|
(event.key === KEYS.CHEVRON_RIGHT || event.key === KEYS.PERIOD)
|
||||||
|
);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
export const actionChangeFontFamily = register({
|
export const actionChangeFontFamily = register({
|
||||||
name: "changeFontFamily",
|
name: "changeFontFamily",
|
||||||
|
trackEvent: false,
|
||||||
perform: (elements, appState, value) => {
|
perform: (elements, appState, value) => {
|
||||||
return {
|
return {
|
||||||
elements: changeProperty(elements, appState, (el) => {
|
elements: changeProperty(
|
||||||
if (isTextElement(el)) {
|
elements,
|
||||||
const element: ExcalidrawTextElement = newElementWith(el, {
|
appState,
|
||||||
fontFamily: value,
|
(oldElement) => {
|
||||||
});
|
if (isTextElement(oldElement)) {
|
||||||
redrawTextBoundingBox(element);
|
const newElement: ExcalidrawTextElement = newElementWith(
|
||||||
return element;
|
oldElement,
|
||||||
}
|
{
|
||||||
|
fontFamily: value,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
redrawTextBoundingBox(newElement, getContainerElement(oldElement));
|
||||||
|
return newElement;
|
||||||
|
}
|
||||||
|
|
||||||
return el;
|
return oldElement;
|
||||||
}),
|
},
|
||||||
|
true,
|
||||||
|
),
|
||||||
appState: {
|
appState: {
|
||||||
...appState,
|
...appState,
|
||||||
currentItemFontFamily: value,
|
currentItemFontFamily: value,
|
||||||
@@ -481,19 +650,23 @@ export const actionChangeFontFamily = register({
|
|||||||
};
|
};
|
||||||
},
|
},
|
||||||
PanelComponent: ({ elements, appState, updateData }) => {
|
PanelComponent: ({ elements, appState, updateData }) => {
|
||||||
const options: { value: FontFamily; text: string; icon: JSX.Element }[] = [
|
const options: {
|
||||||
|
value: FontFamilyValues;
|
||||||
|
text: string;
|
||||||
|
icon: JSX.Element;
|
||||||
|
}[] = [
|
||||||
{
|
{
|
||||||
value: 1,
|
value: FONT_FAMILY.Virgil,
|
||||||
text: t("labels.handDrawn"),
|
text: t("labels.handDrawn"),
|
||||||
icon: <FontFamilyHandDrawnIcon theme={appState.theme} />,
|
icon: <FontFamilyHandDrawnIcon theme={appState.theme} />,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
value: 2,
|
value: FONT_FAMILY.Helvetica,
|
||||||
text: t("labels.normal"),
|
text: t("labels.normal"),
|
||||||
icon: <FontFamilyNormalIcon theme={appState.theme} />,
|
icon: <FontFamilyNormalIcon theme={appState.theme} />,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
value: 3,
|
value: FONT_FAMILY.Cascadia,
|
||||||
text: t("labels.code"),
|
text: t("labels.code"),
|
||||||
icon: <FontFamilyCodeIcon theme={appState.theme} />,
|
icon: <FontFamilyCodeIcon theme={appState.theme} />,
|
||||||
},
|
},
|
||||||
@@ -502,13 +675,22 @@ export const actionChangeFontFamily = register({
|
|||||||
return (
|
return (
|
||||||
<fieldset>
|
<fieldset>
|
||||||
<legend>{t("labels.fontFamily")}</legend>
|
<legend>{t("labels.fontFamily")}</legend>
|
||||||
<ButtonIconSelect<FontFamily | false>
|
<ButtonIconSelect<FontFamilyValues | false>
|
||||||
group="font-family"
|
group="font-family"
|
||||||
options={options}
|
options={options}
|
||||||
value={getFormValue(
|
value={getFormValue(
|
||||||
elements,
|
elements,
|
||||||
appState,
|
appState,
|
||||||
(element) => isTextElement(element) && element.fontFamily,
|
(element) => {
|
||||||
|
if (isTextElement(element)) {
|
||||||
|
return element.fontFamily;
|
||||||
|
}
|
||||||
|
const boundTextElement = getBoundTextElement(element);
|
||||||
|
if (boundTextElement) {
|
||||||
|
return boundTextElement.fontFamily;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
},
|
||||||
appState.currentItemFontFamily || DEFAULT_FONT_FAMILY,
|
appState.currentItemFontFamily || DEFAULT_FONT_FAMILY,
|
||||||
)}
|
)}
|
||||||
onChange={(value) => updateData(value)}
|
onChange={(value) => updateData(value)}
|
||||||
@@ -520,19 +702,26 @@ export const actionChangeFontFamily = register({
|
|||||||
|
|
||||||
export const actionChangeTextAlign = register({
|
export const actionChangeTextAlign = register({
|
||||||
name: "changeTextAlign",
|
name: "changeTextAlign",
|
||||||
|
trackEvent: false,
|
||||||
perform: (elements, appState, value) => {
|
perform: (elements, appState, value) => {
|
||||||
return {
|
return {
|
||||||
elements: changeProperty(elements, appState, (el) => {
|
elements: changeProperty(
|
||||||
if (isTextElement(el)) {
|
elements,
|
||||||
const element: ExcalidrawTextElement = newElementWith(el, {
|
appState,
|
||||||
textAlign: value,
|
(oldElement) => {
|
||||||
});
|
if (isTextElement(oldElement)) {
|
||||||
redrawTextBoundingBox(element);
|
const newElement: ExcalidrawTextElement = newElementWith(
|
||||||
return element;
|
oldElement,
|
||||||
}
|
{ textAlign: value },
|
||||||
|
);
|
||||||
|
redrawTextBoundingBox(newElement, getContainerElement(oldElement));
|
||||||
|
return newElement;
|
||||||
|
}
|
||||||
|
|
||||||
return el;
|
return oldElement;
|
||||||
}),
|
},
|
||||||
|
true,
|
||||||
|
),
|
||||||
appState: {
|
appState: {
|
||||||
...appState,
|
...appState,
|
||||||
currentItemTextAlign: value,
|
currentItemTextAlign: value,
|
||||||
@@ -540,42 +729,121 @@ export const actionChangeTextAlign = register({
|
|||||||
commitToHistory: true,
|
commitToHistory: true,
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
PanelComponent: ({ elements, appState, updateData }) => (
|
PanelComponent: ({ elements, appState, updateData }) => {
|
||||||
<fieldset>
|
return (
|
||||||
<legend>{t("labels.textAlign")}</legend>
|
<fieldset>
|
||||||
<ButtonIconSelect<TextAlign | false>
|
<legend>{t("labels.textAlign")}</legend>
|
||||||
group="text-align"
|
<ButtonIconSelect<TextAlign | false>
|
||||||
options={[
|
group="text-align"
|
||||||
{
|
options={[
|
||||||
value: "left",
|
{
|
||||||
text: t("labels.left"),
|
value: "left",
|
||||||
icon: <TextAlignLeftIcon theme={appState.theme} />,
|
text: t("labels.left"),
|
||||||
},
|
icon: <TextAlignLeftIcon theme={appState.theme} />,
|
||||||
{
|
},
|
||||||
value: "center",
|
{
|
||||||
text: t("labels.center"),
|
value: "center",
|
||||||
icon: <TextAlignCenterIcon theme={appState.theme} />,
|
text: t("labels.center"),
|
||||||
},
|
icon: <TextAlignCenterIcon theme={appState.theme} />,
|
||||||
{
|
},
|
||||||
value: "right",
|
{
|
||||||
text: t("labels.right"),
|
value: "right",
|
||||||
icon: <TextAlignRightIcon theme={appState.theme} />,
|
text: t("labels.right"),
|
||||||
},
|
icon: <TextAlignRightIcon theme={appState.theme} />,
|
||||||
]}
|
},
|
||||||
value={getFormValue(
|
]}
|
||||||
elements,
|
value={getFormValue(
|
||||||
appState,
|
elements,
|
||||||
(element) => isTextElement(element) && element.textAlign,
|
appState,
|
||||||
appState.currentItemTextAlign,
|
(element) => {
|
||||||
)}
|
if (isTextElement(element)) {
|
||||||
onChange={(value) => updateData(value)}
|
return element.textAlign;
|
||||||
/>
|
}
|
||||||
</fieldset>
|
const boundTextElement = getBoundTextElement(element);
|
||||||
),
|
if (boundTextElement) {
|
||||||
|
return boundTextElement.textAlign;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
},
|
||||||
|
appState.currentItemTextAlign,
|
||||||
|
)}
|
||||||
|
onChange={(value) => updateData(value)}
|
||||||
|
/>
|
||||||
|
</fieldset>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
export const actionChangeVerticalAlign = register({
|
||||||
|
name: "changeVerticalAlign",
|
||||||
|
trackEvent: { category: "element" },
|
||||||
|
perform: (elements, appState, value) => {
|
||||||
|
return {
|
||||||
|
elements: changeProperty(
|
||||||
|
elements,
|
||||||
|
appState,
|
||||||
|
(oldElement) => {
|
||||||
|
if (isTextElement(oldElement)) {
|
||||||
|
const newElement: ExcalidrawTextElement = newElementWith(
|
||||||
|
oldElement,
|
||||||
|
{ verticalAlign: value },
|
||||||
|
);
|
||||||
|
|
||||||
|
redrawTextBoundingBox(newElement, getContainerElement(oldElement));
|
||||||
|
return newElement;
|
||||||
|
}
|
||||||
|
|
||||||
|
return oldElement;
|
||||||
|
},
|
||||||
|
true,
|
||||||
|
),
|
||||||
|
appState: {
|
||||||
|
...appState,
|
||||||
|
},
|
||||||
|
commitToHistory: true,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
PanelComponent: ({ elements, appState, updateData }) => {
|
||||||
|
return (
|
||||||
|
<fieldset>
|
||||||
|
<ButtonIconSelect<VerticalAlign | false>
|
||||||
|
group="text-align"
|
||||||
|
options={[
|
||||||
|
{
|
||||||
|
value: VERTICAL_ALIGN.TOP,
|
||||||
|
text: t("labels.alignTop"),
|
||||||
|
icon: <TextAlignTopIcon theme={appState.theme} />,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: VERTICAL_ALIGN.MIDDLE,
|
||||||
|
text: t("labels.centerVertically"),
|
||||||
|
icon: <TextAlignMiddleIcon theme={appState.theme} />,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: VERTICAL_ALIGN.BOTTOM,
|
||||||
|
text: t("labels.alignBottom"),
|
||||||
|
icon: <TextAlignBottomIcon theme={appState.theme} />,
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
value={getFormValue(elements, appState, (element) => {
|
||||||
|
if (isTextElement(element) && element.containerId) {
|
||||||
|
return element.verticalAlign;
|
||||||
|
}
|
||||||
|
const boundTextElement = getBoundTextElement(element);
|
||||||
|
if (boundTextElement) {
|
||||||
|
return boundTextElement.verticalAlign;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
})}
|
||||||
|
onChange={(value) => updateData(value)}
|
||||||
|
/>
|
||||||
|
</fieldset>
|
||||||
|
);
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
export const actionChangeSharpness = register({
|
export const actionChangeSharpness = register({
|
||||||
name: "changeSharpness",
|
name: "changeSharpness",
|
||||||
|
trackEvent: false,
|
||||||
perform: (elements, appState, value) => {
|
perform: (elements, appState, value) => {
|
||||||
const targetElements = getTargetElements(
|
const targetElements = getTargetElements(
|
||||||
getNonDeletedElements(elements),
|
getNonDeletedElements(elements),
|
||||||
@@ -583,10 +851,10 @@ export const actionChangeSharpness = register({
|
|||||||
);
|
);
|
||||||
const shouldUpdateForNonLinearElements = targetElements.length
|
const shouldUpdateForNonLinearElements = targetElements.length
|
||||||
? targetElements.every((el) => !isLinearElement(el))
|
? targetElements.every((el) => !isLinearElement(el))
|
||||||
: !isLinearElementType(appState.elementType);
|
: !isLinearElementType(appState.activeTool.type);
|
||||||
const shouldUpdateForLinearElements = targetElements.length
|
const shouldUpdateForLinearElements = targetElements.length
|
||||||
? targetElements.every(isLinearElement)
|
? targetElements.every(isLinearElement)
|
||||||
: isLinearElementType(appState.elementType);
|
: isLinearElementType(appState.activeTool.type);
|
||||||
return {
|
return {
|
||||||
elements: changeProperty(elements, appState, (el) =>
|
elements: changeProperty(elements, appState, (el) =>
|
||||||
newElementWith(el, {
|
newElementWith(el, {
|
||||||
@@ -626,8 +894,8 @@ export const actionChangeSharpness = register({
|
|||||||
elements,
|
elements,
|
||||||
appState,
|
appState,
|
||||||
(element) => element.strokeSharpness,
|
(element) => element.strokeSharpness,
|
||||||
(canChangeSharpness(appState.elementType) &&
|
(canChangeSharpness(appState.activeTool.type) &&
|
||||||
(isLinearElementType(appState.elementType)
|
(isLinearElementType(appState.activeTool.type)
|
||||||
? appState.currentItemLinearStrokeSharpness
|
? appState.currentItemLinearStrokeSharpness
|
||||||
: appState.currentItemStrokeSharpness)) ||
|
: appState.currentItemStrokeSharpness)) ||
|
||||||
null,
|
null,
|
||||||
@@ -640,6 +908,7 @@ export const actionChangeSharpness = register({
|
|||||||
|
|
||||||
export const actionChangeArrowhead = register({
|
export const actionChangeArrowhead = register({
|
||||||
name: "changeArrowhead",
|
name: "changeArrowhead",
|
||||||
|
trackEvent: false,
|
||||||
perform: (
|
perform: (
|
||||||
elements,
|
elements,
|
||||||
appState,
|
appState,
|
||||||
@@ -710,6 +979,14 @@ export const actionChangeArrowhead = register({
|
|||||||
icon: <ArrowheadDotIcon theme={appState.theme} flip={!isRTL} />,
|
icon: <ArrowheadDotIcon theme={appState.theme} flip={!isRTL} />,
|
||||||
keyBinding: "r",
|
keyBinding: "r",
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
value: "triangle",
|
||||||
|
text: t("labels.arrowhead_triangle"),
|
||||||
|
icon: (
|
||||||
|
<ArrowheadTriangleIcon theme={appState.theme} flip={!isRTL} />
|
||||||
|
),
|
||||||
|
keyBinding: "t",
|
||||||
|
},
|
||||||
]}
|
]}
|
||||||
value={getFormValue<Arrowhead | null>(
|
value={getFormValue<Arrowhead | null>(
|
||||||
elements,
|
elements,
|
||||||
@@ -752,6 +1029,14 @@ export const actionChangeArrowhead = register({
|
|||||||
keyBinding: "r",
|
keyBinding: "r",
|
||||||
icon: <ArrowheadDotIcon theme={appState.theme} flip={isRTL} />,
|
icon: <ArrowheadDotIcon theme={appState.theme} flip={isRTL} />,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
value: "triangle",
|
||||||
|
text: t("labels.arrowhead_triangle"),
|
||||||
|
icon: (
|
||||||
|
<ArrowheadTriangleIcon theme={appState.theme} flip={isRTL} />
|
||||||
|
),
|
||||||
|
keyBinding: "t",
|
||||||
|
},
|
||||||
]}
|
]}
|
||||||
value={getFormValue<Arrowhead | null>(
|
value={getFormValue<Arrowhead | null>(
|
||||||
elements,
|
elements,
|
||||||
|
@@ -1,25 +1,44 @@
|
|||||||
import { KEYS } from "../keys";
|
import { KEYS } from "../keys";
|
||||||
import { register } from "./register";
|
import { register } from "./register";
|
||||||
import { selectGroupsForSelectedElements } from "../groups";
|
import { selectGroupsForSelectedElements } from "../groups";
|
||||||
import { getNonDeletedElements } from "../element";
|
import { getNonDeletedElements, isTextElement } from "../element";
|
||||||
|
import { ExcalidrawElement } from "../element/types";
|
||||||
|
import { isLinearElement } from "../element/typeChecks";
|
||||||
|
import { LinearElementEditor } from "../element/linearElementEditor";
|
||||||
|
|
||||||
export const actionSelectAll = register({
|
export const actionSelectAll = register({
|
||||||
name: "selectAll",
|
name: "selectAll",
|
||||||
perform: (elements, appState) => {
|
trackEvent: { category: "canvas" },
|
||||||
|
perform: (elements, appState, value, app) => {
|
||||||
if (appState.editingLinearElement) {
|
if (appState.editingLinearElement) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
const selectedElementIds = elements.reduce(
|
||||||
|
(map: Record<ExcalidrawElement["id"], true>, element) => {
|
||||||
|
if (
|
||||||
|
!element.isDeleted &&
|
||||||
|
!(isTextElement(element) && element.containerId) &&
|
||||||
|
!element.locked
|
||||||
|
) {
|
||||||
|
map[element.id] = true;
|
||||||
|
}
|
||||||
|
return map;
|
||||||
|
},
|
||||||
|
{},
|
||||||
|
);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
appState: selectGroupsForSelectedElements(
|
appState: selectGroupsForSelectedElements(
|
||||||
{
|
{
|
||||||
...appState,
|
...appState,
|
||||||
|
selectedLinearElement:
|
||||||
|
// single linear element selected
|
||||||
|
Object.keys(selectedElementIds).length === 1 &&
|
||||||
|
isLinearElement(elements[0])
|
||||||
|
? new LinearElementEditor(elements[0], app.scene)
|
||||||
|
: null,
|
||||||
editingGroupId: null,
|
editingGroupId: null,
|
||||||
selectedElementIds: elements.reduce((map, element) => {
|
selectedElementIds,
|
||||||
if (!element.isDeleted) {
|
|
||||||
map[element.id] = true;
|
|
||||||
}
|
|
||||||
return map;
|
|
||||||
}, {} as any),
|
|
||||||
},
|
},
|
||||||
getNonDeletedElements(elements),
|
getNonDeletedElements(elements),
|
||||||
),
|
),
|
||||||
|
71
src/actions/actionStyles.test.tsx
Normal file
71
src/actions/actionStyles.test.tsx
Normal file
@@ -0,0 +1,71 @@
|
|||||||
|
import ExcalidrawApp from "../excalidraw-app";
|
||||||
|
import { t } from "../i18n";
|
||||||
|
import { CODES } from "../keys";
|
||||||
|
import { API } from "../tests/helpers/api";
|
||||||
|
import { Keyboard, Pointer, UI } from "../tests/helpers/ui";
|
||||||
|
import { fireEvent, render, screen } from "../tests/test-utils";
|
||||||
|
import { copiedStyles } from "./actionStyles";
|
||||||
|
|
||||||
|
const { h } = window;
|
||||||
|
|
||||||
|
const mouse = new Pointer("mouse");
|
||||||
|
|
||||||
|
describe("actionStyles", () => {
|
||||||
|
beforeEach(async () => {
|
||||||
|
await render(<ExcalidrawApp />);
|
||||||
|
});
|
||||||
|
it("should copy & paste styles via keyboard", () => {
|
||||||
|
UI.clickTool("rectangle");
|
||||||
|
mouse.down(10, 10);
|
||||||
|
mouse.up(20, 20);
|
||||||
|
|
||||||
|
UI.clickTool("rectangle");
|
||||||
|
mouse.down(10, 10);
|
||||||
|
mouse.up(20, 20);
|
||||||
|
|
||||||
|
// Change some styles of second rectangle
|
||||||
|
UI.clickLabeledElement("Stroke");
|
||||||
|
UI.clickLabeledElement(t("colors.c92a2a"));
|
||||||
|
UI.clickLabeledElement("Background");
|
||||||
|
UI.clickLabeledElement(t("colors.e64980"));
|
||||||
|
// Fill style
|
||||||
|
fireEvent.click(screen.getByTitle("Cross-hatch"));
|
||||||
|
// Stroke width
|
||||||
|
fireEvent.click(screen.getByTitle("Bold"));
|
||||||
|
// Stroke style
|
||||||
|
fireEvent.click(screen.getByTitle("Dotted"));
|
||||||
|
// Roughness
|
||||||
|
fireEvent.click(screen.getByTitle("Cartoonist"));
|
||||||
|
// Opacity
|
||||||
|
fireEvent.change(screen.getByLabelText("Opacity"), {
|
||||||
|
target: { value: "60" },
|
||||||
|
});
|
||||||
|
|
||||||
|
mouse.reset();
|
||||||
|
|
||||||
|
API.setSelectedElements([h.elements[1]]);
|
||||||
|
|
||||||
|
Keyboard.withModifierKeys({ ctrl: true, alt: true }, () => {
|
||||||
|
Keyboard.codeDown(CODES.C);
|
||||||
|
});
|
||||||
|
const secondRect = JSON.parse(copiedStyles)[0];
|
||||||
|
expect(secondRect.id).toBe(h.elements[1].id);
|
||||||
|
|
||||||
|
mouse.reset();
|
||||||
|
// Paste styles to first rectangle
|
||||||
|
API.setSelectedElements([h.elements[0]]);
|
||||||
|
Keyboard.withModifierKeys({ ctrl: true, alt: true }, () => {
|
||||||
|
Keyboard.codeDown(CODES.V);
|
||||||
|
});
|
||||||
|
|
||||||
|
const firstRect = API.getSelectedElement();
|
||||||
|
expect(firstRect.id).toBe(h.elements[0].id);
|
||||||
|
expect(firstRect.strokeColor).toBe("#c92a2a");
|
||||||
|
expect(firstRect.backgroundColor).toBe("#e64980");
|
||||||
|
expect(firstRect.fillStyle).toBe("cross-hatch");
|
||||||
|
expect(firstRect.strokeWidth).toBe(2); // Bold: 2
|
||||||
|
expect(firstRect.strokeStyle).toBe("dotted");
|
||||||
|
expect(firstRect.roughness).toBe(2); // Cartoonist: 2
|
||||||
|
expect(firstRect.opacity).toBe(60);
|
||||||
|
});
|
||||||
|
});
|
@@ -6,27 +6,37 @@ import {
|
|||||||
import { CODES, KEYS } from "../keys";
|
import { CODES, KEYS } from "../keys";
|
||||||
import { t } from "../i18n";
|
import { t } from "../i18n";
|
||||||
import { register } from "./register";
|
import { register } from "./register";
|
||||||
import { mutateElement, newElementWith } from "../element/mutateElement";
|
import { newElementWith } from "../element/mutateElement";
|
||||||
import {
|
import {
|
||||||
DEFAULT_FONT_SIZE,
|
DEFAULT_FONT_SIZE,
|
||||||
DEFAULT_FONT_FAMILY,
|
DEFAULT_FONT_FAMILY,
|
||||||
DEFAULT_TEXT_ALIGN,
|
DEFAULT_TEXT_ALIGN,
|
||||||
} from "../constants";
|
} from "../constants";
|
||||||
|
import { getBoundTextElement } from "../element/textElement";
|
||||||
|
import { hasBoundTextElement } from "../element/typeChecks";
|
||||||
|
import { getSelectedElements } from "../scene";
|
||||||
|
|
||||||
// `copiedStyles` is exported only for tests.
|
// `copiedStyles` is exported only for tests.
|
||||||
export let copiedStyles: string = "{}";
|
export let copiedStyles: string = "{}";
|
||||||
|
|
||||||
export const actionCopyStyles = register({
|
export const actionCopyStyles = register({
|
||||||
name: "copyStyles",
|
name: "copyStyles",
|
||||||
|
trackEvent: { category: "element" },
|
||||||
perform: (elements, appState) => {
|
perform: (elements, appState) => {
|
||||||
|
const elementsCopied = [];
|
||||||
const element = elements.find((el) => appState.selectedElementIds[el.id]);
|
const element = elements.find((el) => appState.selectedElementIds[el.id]);
|
||||||
|
elementsCopied.push(element);
|
||||||
|
if (element && hasBoundTextElement(element)) {
|
||||||
|
const boundTextElement = getBoundTextElement(element);
|
||||||
|
elementsCopied.push(boundTextElement);
|
||||||
|
}
|
||||||
if (element) {
|
if (element) {
|
||||||
copiedStyles = JSON.stringify(element);
|
copiedStyles = JSON.stringify(elementsCopied);
|
||||||
}
|
}
|
||||||
return {
|
return {
|
||||||
appState: {
|
appState: {
|
||||||
...appState,
|
...appState,
|
||||||
toastMessage: t("toast.copyStyles"),
|
toast: { message: t("toast.copyStyles") },
|
||||||
},
|
},
|
||||||
commitToHistory: false,
|
commitToHistory: false,
|
||||||
};
|
};
|
||||||
@@ -38,31 +48,64 @@ export const actionCopyStyles = register({
|
|||||||
|
|
||||||
export const actionPasteStyles = register({
|
export const actionPasteStyles = register({
|
||||||
name: "pasteStyles",
|
name: "pasteStyles",
|
||||||
|
trackEvent: { category: "element" },
|
||||||
perform: (elements, appState) => {
|
perform: (elements, appState) => {
|
||||||
const pastedElement = JSON.parse(copiedStyles);
|
const elementsCopied = JSON.parse(copiedStyles);
|
||||||
|
const pastedElement = elementsCopied[0];
|
||||||
|
const boundTextElement = elementsCopied[1];
|
||||||
if (!isExcalidrawElement(pastedElement)) {
|
if (!isExcalidrawElement(pastedElement)) {
|
||||||
return { elements, commitToHistory: false };
|
return { elements, commitToHistory: false };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const selectedElements = getSelectedElements(elements, appState, true);
|
||||||
|
const selectedElementIds = selectedElements.map((element) => element.id);
|
||||||
return {
|
return {
|
||||||
elements: elements.map((element) => {
|
elements: elements.map((element) => {
|
||||||
if (appState.selectedElementIds[element.id]) {
|
if (selectedElementIds.includes(element.id)) {
|
||||||
const newElement = newElementWith(element, {
|
let elementStylesToCopyFrom = pastedElement;
|
||||||
backgroundColor: pastedElement?.backgroundColor,
|
if (isTextElement(element) && element.containerId) {
|
||||||
strokeWidth: pastedElement?.strokeWidth,
|
elementStylesToCopyFrom = boundTextElement;
|
||||||
strokeColor: pastedElement?.strokeColor,
|
|
||||||
strokeStyle: pastedElement?.strokeStyle,
|
|
||||||
fillStyle: pastedElement?.fillStyle,
|
|
||||||
opacity: pastedElement?.opacity,
|
|
||||||
roughness: pastedElement?.roughness,
|
|
||||||
});
|
|
||||||
if (isTextElement(newElement)) {
|
|
||||||
mutateElement(newElement, {
|
|
||||||
fontSize: pastedElement?.fontSize || DEFAULT_FONT_SIZE,
|
|
||||||
fontFamily: pastedElement?.fontFamily || DEFAULT_FONT_FAMILY,
|
|
||||||
textAlign: pastedElement?.textAlign || DEFAULT_TEXT_ALIGN,
|
|
||||||
});
|
|
||||||
redrawTextBoundingBox(newElement);
|
|
||||||
}
|
}
|
||||||
|
if (!elementStylesToCopyFrom) {
|
||||||
|
return element;
|
||||||
|
}
|
||||||
|
let newElement = newElementWith(element, {
|
||||||
|
backgroundColor: elementStylesToCopyFrom?.backgroundColor,
|
||||||
|
strokeWidth: elementStylesToCopyFrom?.strokeWidth,
|
||||||
|
strokeColor: elementStylesToCopyFrom?.strokeColor,
|
||||||
|
strokeStyle: elementStylesToCopyFrom?.strokeStyle,
|
||||||
|
fillStyle: elementStylesToCopyFrom?.fillStyle,
|
||||||
|
opacity: elementStylesToCopyFrom?.opacity,
|
||||||
|
roughness: elementStylesToCopyFrom?.roughness,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (isTextElement(newElement)) {
|
||||||
|
newElement = newElementWith(newElement, {
|
||||||
|
fontSize: elementStylesToCopyFrom?.fontSize || DEFAULT_FONT_SIZE,
|
||||||
|
fontFamily:
|
||||||
|
elementStylesToCopyFrom?.fontFamily || DEFAULT_FONT_FAMILY,
|
||||||
|
textAlign:
|
||||||
|
elementStylesToCopyFrom?.textAlign || DEFAULT_TEXT_ALIGN,
|
||||||
|
});
|
||||||
|
let container = null;
|
||||||
|
if (newElement.containerId) {
|
||||||
|
container =
|
||||||
|
selectedElements.find(
|
||||||
|
(element) =>
|
||||||
|
isTextElement(newElement) &&
|
||||||
|
element.id === newElement.containerId,
|
||||||
|
) || null;
|
||||||
|
}
|
||||||
|
redrawTextBoundingBox(newElement, container);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (newElement.type === "arrow") {
|
||||||
|
newElement = newElementWith(newElement, {
|
||||||
|
startArrowhead: elementStylesToCopyFrom.startArrowhead,
|
||||||
|
endArrowhead: elementStylesToCopyFrom.endArrowhead,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
return newElement;
|
return newElement;
|
||||||
}
|
}
|
||||||
return element;
|
return element;
|
||||||
|
@@ -2,12 +2,14 @@ import { CODES, KEYS } from "../keys";
|
|||||||
import { register } from "./register";
|
import { register } from "./register";
|
||||||
import { GRID_SIZE } from "../constants";
|
import { GRID_SIZE } from "../constants";
|
||||||
import { AppState } from "../types";
|
import { AppState } from "../types";
|
||||||
import { trackEvent } from "../analytics";
|
|
||||||
|
|
||||||
export const actionToggleGridMode = register({
|
export const actionToggleGridMode = register({
|
||||||
name: "gridMode",
|
name: "gridMode",
|
||||||
|
trackEvent: {
|
||||||
|
category: "canvas",
|
||||||
|
predicate: (appState) => !appState.gridSize,
|
||||||
|
},
|
||||||
perform(elements, appState) {
|
perform(elements, appState) {
|
||||||
trackEvent("view", "mode", "grid");
|
|
||||||
return {
|
return {
|
||||||
appState: {
|
appState: {
|
||||||
...appState,
|
...appState,
|
||||||
|
66
src/actions/actionToggleLock.ts
Normal file
66
src/actions/actionToggleLock.ts
Normal file
@@ -0,0 +1,66 @@
|
|||||||
|
import { newElementWith } from "../element/mutateElement";
|
||||||
|
import { ExcalidrawElement } from "../element/types";
|
||||||
|
import { KEYS } from "../keys";
|
||||||
|
import { getSelectedElements } from "../scene";
|
||||||
|
import { arrayToMap } from "../utils";
|
||||||
|
import { register } from "./register";
|
||||||
|
|
||||||
|
export const actionToggleLock = register({
|
||||||
|
name: "toggleLock",
|
||||||
|
trackEvent: { category: "element" },
|
||||||
|
perform: (elements, appState) => {
|
||||||
|
const selectedElements = getSelectedElements(elements, appState, true);
|
||||||
|
|
||||||
|
if (!selectedElements.length) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const operation = getOperation(selectedElements);
|
||||||
|
const selectedElementsMap = arrayToMap(selectedElements);
|
||||||
|
const lock = operation === "lock";
|
||||||
|
return {
|
||||||
|
elements: elements.map((element) => {
|
||||||
|
if (!selectedElementsMap.has(element.id)) {
|
||||||
|
return element;
|
||||||
|
}
|
||||||
|
|
||||||
|
return newElementWith(element, { locked: lock });
|
||||||
|
}),
|
||||||
|
appState: {
|
||||||
|
...appState,
|
||||||
|
selectedLinearElement: lock ? null : appState.selectedLinearElement,
|
||||||
|
},
|
||||||
|
commitToHistory: true,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
contextItemLabel: (elements, appState) => {
|
||||||
|
const selected = getSelectedElements(elements, appState, false);
|
||||||
|
if (selected.length === 1) {
|
||||||
|
return selected[0].locked
|
||||||
|
? "labels.elementLock.unlock"
|
||||||
|
: "labels.elementLock.lock";
|
||||||
|
}
|
||||||
|
|
||||||
|
if (selected.length > 1) {
|
||||||
|
return getOperation(selected) === "lock"
|
||||||
|
? "labels.elementLock.lockAll"
|
||||||
|
: "labels.elementLock.unlockAll";
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new Error(
|
||||||
|
"Unexpected zero elements to lock/unlock. This should never happen.",
|
||||||
|
);
|
||||||
|
},
|
||||||
|
keyTest: (event, appState, elements) => {
|
||||||
|
return (
|
||||||
|
event.key.toLocaleLowerCase() === KEYS.L &&
|
||||||
|
event[KEYS.CTRL_OR_CMD] &&
|
||||||
|
event.shiftKey &&
|
||||||
|
getSelectedElements(elements, appState, false).length > 0
|
||||||
|
);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const getOperation = (
|
||||||
|
elements: readonly ExcalidrawElement[],
|
||||||
|
): "lock" | "unlock" => (elements.some((el) => !el.locked) ? "lock" : "unlock");
|
@@ -3,6 +3,7 @@ import { CODES, KEYS } from "../keys";
|
|||||||
|
|
||||||
export const actionToggleStats = register({
|
export const actionToggleStats = register({
|
||||||
name: "stats",
|
name: "stats",
|
||||||
|
trackEvent: { category: "menu" },
|
||||||
perform(elements, appState) {
|
perform(elements, appState) {
|
||||||
return {
|
return {
|
||||||
appState: {
|
appState: {
|
||||||
|
@@ -1,16 +1,17 @@
|
|||||||
import { CODES, KEYS } from "../keys";
|
import { CODES, KEYS } from "../keys";
|
||||||
import { register } from "./register";
|
import { register } from "./register";
|
||||||
import { trackEvent } from "../analytics";
|
|
||||||
|
|
||||||
export const actionToggleViewMode = register({
|
export const actionToggleViewMode = register({
|
||||||
name: "viewMode",
|
name: "viewMode",
|
||||||
|
trackEvent: {
|
||||||
|
category: "canvas",
|
||||||
|
predicate: (appState) => !appState.viewModeEnabled,
|
||||||
|
},
|
||||||
perform(elements, appState) {
|
perform(elements, appState) {
|
||||||
trackEvent("view", "mode", "view");
|
|
||||||
return {
|
return {
|
||||||
appState: {
|
appState: {
|
||||||
...appState,
|
...appState,
|
||||||
viewModeEnabled: !this.checked!(appState),
|
viewModeEnabled: !this.checked!(appState),
|
||||||
selectedElementIds: {},
|
|
||||||
},
|
},
|
||||||
commitToHistory: false,
|
commitToHistory: false,
|
||||||
};
|
};
|
||||||
|
@@ -1,12 +1,13 @@
|
|||||||
import { CODES, KEYS } from "../keys";
|
import { CODES, KEYS } from "../keys";
|
||||||
import { register } from "./register";
|
import { register } from "./register";
|
||||||
import { trackEvent } from "../analytics";
|
|
||||||
|
|
||||||
export const actionToggleZenMode = register({
|
export const actionToggleZenMode = register({
|
||||||
name: "zenMode",
|
name: "zenMode",
|
||||||
|
trackEvent: {
|
||||||
|
category: "canvas",
|
||||||
|
predicate: (appState) => !appState.zenModeEnabled,
|
||||||
|
},
|
||||||
perform(elements, appState) {
|
perform(elements, appState) {
|
||||||
trackEvent("view", "mode", "zen");
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
appState: {
|
appState: {
|
||||||
...appState,
|
...appState,
|
||||||
|
@@ -18,6 +18,7 @@ import {
|
|||||||
|
|
||||||
export const actionSendBackward = register({
|
export const actionSendBackward = register({
|
||||||
name: "sendBackward",
|
name: "sendBackward",
|
||||||
|
trackEvent: { category: "element" },
|
||||||
perform: (elements, appState) => {
|
perform: (elements, appState) => {
|
||||||
return {
|
return {
|
||||||
elements: moveOneLeft(elements, appState),
|
elements: moveOneLeft(elements, appState),
|
||||||
@@ -45,6 +46,7 @@ export const actionSendBackward = register({
|
|||||||
|
|
||||||
export const actionBringForward = register({
|
export const actionBringForward = register({
|
||||||
name: "bringForward",
|
name: "bringForward",
|
||||||
|
trackEvent: { category: "element" },
|
||||||
perform: (elements, appState) => {
|
perform: (elements, appState) => {
|
||||||
return {
|
return {
|
||||||
elements: moveOneRight(elements, appState),
|
elements: moveOneRight(elements, appState),
|
||||||
@@ -72,6 +74,7 @@ export const actionBringForward = register({
|
|||||||
|
|
||||||
export const actionSendToBack = register({
|
export const actionSendToBack = register({
|
||||||
name: "sendToBack",
|
name: "sendToBack",
|
||||||
|
trackEvent: { category: "element" },
|
||||||
perform: (elements, appState) => {
|
perform: (elements, appState) => {
|
||||||
return {
|
return {
|
||||||
elements: moveAllLeft(elements, appState),
|
elements: moveAllLeft(elements, appState),
|
||||||
@@ -106,6 +109,8 @@ export const actionSendToBack = register({
|
|||||||
|
|
||||||
export const actionBringToFront = register({
|
export const actionBringToFront = register({
|
||||||
name: "bringToFront",
|
name: "bringToFront",
|
||||||
|
trackEvent: { category: "element" },
|
||||||
|
|
||||||
perform: (elements, appState) => {
|
perform: (elements, appState) => {
|
||||||
return {
|
return {
|
||||||
elements: moveAllRight(elements, appState),
|
elements: moveAllRight(elements, appState),
|
||||||
|
@@ -17,6 +17,7 @@ export {
|
|||||||
actionChangeFontSize,
|
actionChangeFontSize,
|
||||||
actionChangeFontFamily,
|
actionChangeFontFamily,
|
||||||
actionChangeTextAlign,
|
actionChangeTextAlign,
|
||||||
|
actionChangeVerticalAlign,
|
||||||
} from "./actionProperties";
|
} from "./actionProperties";
|
||||||
|
|
||||||
export {
|
export {
|
||||||
@@ -34,8 +35,8 @@ export { actionFinalize } from "./actionFinalize";
|
|||||||
export {
|
export {
|
||||||
actionChangeProjectName,
|
actionChangeProjectName,
|
||||||
actionChangeExportBackground,
|
actionChangeExportBackground,
|
||||||
actionSaveScene,
|
actionSaveToActiveFile,
|
||||||
actionSaveAsScene,
|
actionSaveFileToDisk,
|
||||||
actionLoadScene,
|
actionLoadScene,
|
||||||
} from "./actionExport";
|
} from "./actionExport";
|
||||||
|
|
||||||
@@ -74,9 +75,13 @@ export {
|
|||||||
actionCut,
|
actionCut,
|
||||||
actionCopyAsPng,
|
actionCopyAsPng,
|
||||||
actionCopyAsSvg,
|
actionCopyAsSvg,
|
||||||
|
copyText,
|
||||||
} from "./actionClipboard";
|
} from "./actionClipboard";
|
||||||
|
|
||||||
export { actionToggleGridMode } from "./actionToggleGridMode";
|
export { actionToggleGridMode } from "./actionToggleGridMode";
|
||||||
export { actionToggleZenMode } from "./actionToggleZenMode";
|
export { actionToggleZenMode } from "./actionToggleZenMode";
|
||||||
|
|
||||||
export { actionToggleStats } from "./actionToggleStats";
|
export { actionToggleStats } from "./actionToggleStats";
|
||||||
|
export { actionUnbindText, actionBindText } from "./actionBoundText";
|
||||||
|
export { actionLink } from "../element/Hyperlink";
|
||||||
|
export { actionToggleLock } from "./actionToggleLock";
|
||||||
|
@@ -1,39 +1,59 @@
|
|||||||
import React from "react";
|
import React from "react";
|
||||||
import {
|
import {
|
||||||
Action,
|
Action,
|
||||||
ActionsManagerInterface,
|
|
||||||
UpdaterFn,
|
UpdaterFn,
|
||||||
ActionName,
|
ActionName,
|
||||||
ActionResult,
|
ActionResult,
|
||||||
|
PanelComponentProps,
|
||||||
|
ActionSource,
|
||||||
} from "./types";
|
} from "./types";
|
||||||
import { ExcalidrawElement } from "../element/types";
|
import { ExcalidrawElement } from "../element/types";
|
||||||
import { AppProps, AppState } from "../types";
|
import { AppClassProperties, AppState } from "../types";
|
||||||
import { MODES } from "../constants";
|
import { MODES } from "../constants";
|
||||||
import Library from "../data/library";
|
import { trackEvent } from "../analytics";
|
||||||
|
|
||||||
// This is the <App> component, but for now we don't care about anything but its
|
const trackAction = (
|
||||||
// `canvas` state.
|
action: Action,
|
||||||
type App = {
|
source: ActionSource,
|
||||||
canvas: HTMLCanvasElement | null;
|
appState: Readonly<AppState>,
|
||||||
focusContainer: () => void;
|
elements: readonly ExcalidrawElement[],
|
||||||
props: AppProps;
|
app: AppClassProperties,
|
||||||
library: Library;
|
value: any,
|
||||||
|
) => {
|
||||||
|
if (action.trackEvent) {
|
||||||
|
try {
|
||||||
|
if (typeof action.trackEvent === "object") {
|
||||||
|
const shouldTrack = action.trackEvent.predicate
|
||||||
|
? action.trackEvent.predicate(appState, elements, value)
|
||||||
|
: true;
|
||||||
|
if (shouldTrack) {
|
||||||
|
trackEvent(
|
||||||
|
action.trackEvent.category,
|
||||||
|
action.trackEvent.action || action.name,
|
||||||
|
`${source} (${app.device.isMobile ? "mobile" : "desktop"})`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error("error while logging action:", error);
|
||||||
|
}
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
export class ActionManager implements ActionsManagerInterface {
|
export class ActionManager {
|
||||||
actions = {} as ActionsManagerInterface["actions"];
|
actions = {} as Record<ActionName, Action>;
|
||||||
|
|
||||||
updater: (actionResult: ActionResult | Promise<ActionResult>) => void;
|
updater: (actionResult: ActionResult | Promise<ActionResult>) => void;
|
||||||
|
|
||||||
getAppState: () => Readonly<AppState>;
|
getAppState: () => Readonly<AppState>;
|
||||||
getElementsIncludingDeleted: () => readonly ExcalidrawElement[];
|
getElementsIncludingDeleted: () => readonly ExcalidrawElement[];
|
||||||
app: App;
|
app: AppClassProperties;
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
updater: UpdaterFn,
|
updater: UpdaterFn,
|
||||||
getAppState: () => AppState,
|
getAppState: () => AppState,
|
||||||
getElementsIncludingDeleted: () => readonly ExcalidrawElement[],
|
getElementsIncludingDeleted: () => readonly ExcalidrawElement[],
|
||||||
app: App,
|
app: AppClassProperties,
|
||||||
) {
|
) {
|
||||||
this.updater = (actionResult) => {
|
this.updater = (actionResult) => {
|
||||||
if (actionResult && "then" in actionResult) {
|
if (actionResult && "then" in actionResult) {
|
||||||
@@ -74,9 +94,15 @@ export class ActionManager implements ActionsManagerInterface {
|
|||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
if (data.length === 0) {
|
if (data.length !== 1) {
|
||||||
|
if (data.length > 1) {
|
||||||
|
console.warn("Canceling as multiple actions match this shortcut", data);
|
||||||
|
}
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const action = data[0];
|
||||||
|
|
||||||
const { viewModeEnabled } = this.getAppState();
|
const { viewModeEnabled } = this.getAppState();
|
||||||
if (viewModeEnabled) {
|
if (viewModeEnabled) {
|
||||||
if (!Object.values(MODES).includes(data[0].name)) {
|
if (!Object.values(MODES).includes(data[0].name)) {
|
||||||
@@ -84,34 +110,32 @@ export class ActionManager implements ActionsManagerInterface {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const elements = this.getElementsIncludingDeleted();
|
||||||
|
const appState = this.getAppState();
|
||||||
|
const value = null;
|
||||||
|
|
||||||
|
trackAction(action, "keyboard", appState, elements, this.app, null);
|
||||||
|
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
this.updater(
|
event.stopPropagation();
|
||||||
data[0].perform(
|
this.updater(data[0].perform(elements, appState, value, this.app));
|
||||||
this.getElementsIncludingDeleted(),
|
|
||||||
this.getAppState(),
|
|
||||||
null,
|
|
||||||
this.app,
|
|
||||||
),
|
|
||||||
);
|
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
executeAction(action: Action) {
|
executeAction(action: Action, source: ActionSource = "api") {
|
||||||
this.updater(
|
const elements = this.getElementsIncludingDeleted();
|
||||||
action.perform(
|
const appState = this.getAppState();
|
||||||
this.getElementsIncludingDeleted(),
|
const value = null;
|
||||||
this.getAppState(),
|
|
||||||
null,
|
trackAction(action, source, appState, elements, this.app, value);
|
||||||
this.app,
|
|
||||||
),
|
this.updater(action.perform(elements, appState, value, this.app));
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Id is an attribute that we can use to pass in data like keys.
|
/**
|
||||||
// This is needed for dynamically generated action components
|
* @param data additional data sent to the PanelComponent
|
||||||
// like the user list. We can use this key to extract more
|
*/
|
||||||
// data from app state. This is an alternative to generic prop hell!
|
renderAction = (name: ActionName, data?: PanelComponentProps["data"]) => {
|
||||||
renderAction = (name: ActionName, id?: string) => {
|
|
||||||
const canvasActions = this.app.props.UIOptions.canvasActions;
|
const canvasActions = this.app.props.UIOptions.canvasActions;
|
||||||
|
|
||||||
if (
|
if (
|
||||||
@@ -123,7 +147,11 @@ export class ActionManager implements ActionsManagerInterface {
|
|||||||
) {
|
) {
|
||||||
const action = this.actions[name];
|
const action = this.actions[name];
|
||||||
const PanelComponent = action.PanelComponent!;
|
const PanelComponent = action.PanelComponent!;
|
||||||
|
const elements = this.getElementsIncludingDeleted();
|
||||||
|
const appState = this.getAppState();
|
||||||
const updateData = (formState?: any) => {
|
const updateData = (formState?: any) => {
|
||||||
|
trackAction(action, "ui", appState, elements, this.app, formState);
|
||||||
|
|
||||||
this.updater(
|
this.updater(
|
||||||
action.perform(
|
action.perform(
|
||||||
this.getElementsIncludingDeleted(),
|
this.getElementsIncludingDeleted(),
|
||||||
@@ -139,8 +167,8 @@ export class ActionManager implements ActionsManagerInterface {
|
|||||||
elements={this.getElementsIncludingDeleted()}
|
elements={this.getElementsIncludingDeleted()}
|
||||||
appState={this.getAppState()}
|
appState={this.getAppState()}
|
||||||
updateData={updateData}
|
updateData={updateData}
|
||||||
id={id}
|
|
||||||
appProps={this.app.props}
|
appProps={this.app.props}
|
||||||
|
data={data}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
@@ -2,7 +2,9 @@ import { Action } from "./types";
|
|||||||
|
|
||||||
export let actions: readonly Action[] = [];
|
export let actions: readonly Action[] = [];
|
||||||
|
|
||||||
export const register = (action: Action): Action => {
|
export const register = <T extends Action>(action: T) => {
|
||||||
actions = actions.concat(action);
|
actions = actions.concat(action);
|
||||||
return action;
|
return action as T & {
|
||||||
|
keyTest?: unknown extends T["keyTest"] ? never : T["keyTest"];
|
||||||
|
};
|
||||||
};
|
};
|
||||||
|
@@ -1,8 +1,10 @@
|
|||||||
import { t } from "../i18n";
|
import { t } from "../i18n";
|
||||||
import { isDarwin } from "../keys";
|
import { isDarwin } from "../keys";
|
||||||
import { getShortcutKey } from "../utils";
|
import { getShortcutKey } from "../utils";
|
||||||
|
import { ActionName } from "./types";
|
||||||
|
|
||||||
export type ShortcutName =
|
export type ShortcutName = SubtypeOf<
|
||||||
|
ActionName,
|
||||||
| "cut"
|
| "cut"
|
||||||
| "copy"
|
| "copy"
|
||||||
| "paste"
|
| "paste"
|
||||||
@@ -25,7 +27,10 @@ export type ShortcutName =
|
|||||||
| "addToLibrary"
|
| "addToLibrary"
|
||||||
| "viewMode"
|
| "viewMode"
|
||||||
| "flipHorizontal"
|
| "flipHorizontal"
|
||||||
| "flipVertical";
|
| "flipVertical"
|
||||||
|
| "hyperlink"
|
||||||
|
| "toggleLock"
|
||||||
|
>;
|
||||||
|
|
||||||
const shortcutMap: Record<ShortcutName, string[]> = {
|
const shortcutMap: Record<ShortcutName, string[]> = {
|
||||||
cut: [getShortcutKey("CtrlOrCmd+X")],
|
cut: [getShortcutKey("CtrlOrCmd+X")],
|
||||||
@@ -62,10 +67,12 @@ const shortcutMap: Record<ShortcutName, string[]> = {
|
|||||||
flipHorizontal: [getShortcutKey("Shift+H")],
|
flipHorizontal: [getShortcutKey("Shift+H")],
|
||||||
flipVertical: [getShortcutKey("Shift+V")],
|
flipVertical: [getShortcutKey("Shift+V")],
|
||||||
viewMode: [getShortcutKey("Alt+R")],
|
viewMode: [getShortcutKey("Alt+R")],
|
||||||
|
hyperlink: [getShortcutKey("CtrlOrCmd+K")],
|
||||||
|
toggleLock: [getShortcutKey("CtrlOrCmd+Shift+L")],
|
||||||
};
|
};
|
||||||
|
|
||||||
export const getShortcutFromShortcutName = (name: ShortcutName) => {
|
export const getShortcutFromShortcutName = (name: ShortcutName) => {
|
||||||
const shortcuts = shortcutMap[name];
|
const shortcuts = shortcutMap[name];
|
||||||
// if multiple shortcuts availiable, take the first one
|
// if multiple shortcuts available, take the first one
|
||||||
return shortcuts && shortcuts.length > 0 ? shortcuts[0] : "";
|
return shortcuts && shortcuts.length > 0 ? shortcuts[0] : "";
|
||||||
};
|
};
|
||||||
|
@@ -1,7 +1,13 @@
|
|||||||
import React from "react";
|
import React from "react";
|
||||||
import { ExcalidrawElement } from "../element/types";
|
import { ExcalidrawElement } from "../element/types";
|
||||||
import { AppState, ExcalidrawProps } from "../types";
|
import {
|
||||||
import Library from "../data/library";
|
AppClassProperties,
|
||||||
|
AppState,
|
||||||
|
ExcalidrawProps,
|
||||||
|
BinaryFiles,
|
||||||
|
} from "../types";
|
||||||
|
|
||||||
|
export type ActionSource = "ui" | "keyboard" | "contextMenu" | "api";
|
||||||
|
|
||||||
/** if false, the action should be prevented */
|
/** if false, the action should be prevented */
|
||||||
export type ActionResult =
|
export type ActionResult =
|
||||||
@@ -11,22 +17,18 @@ export type ActionResult =
|
|||||||
AppState,
|
AppState,
|
||||||
"offsetTop" | "offsetLeft" | "width" | "height"
|
"offsetTop" | "offsetLeft" | "width" | "height"
|
||||||
> | null;
|
> | null;
|
||||||
|
files?: BinaryFiles | null;
|
||||||
commitToHistory: boolean;
|
commitToHistory: boolean;
|
||||||
syncHistory?: boolean;
|
syncHistory?: boolean;
|
||||||
|
replaceFiles?: boolean;
|
||||||
}
|
}
|
||||||
| false;
|
| false;
|
||||||
|
|
||||||
type AppAPI = {
|
|
||||||
canvas: HTMLCanvasElement | null;
|
|
||||||
focusContainer(): void;
|
|
||||||
library: Library;
|
|
||||||
};
|
|
||||||
|
|
||||||
type ActionFn = (
|
type ActionFn = (
|
||||||
elements: readonly ExcalidrawElement[],
|
elements: readonly ExcalidrawElement[],
|
||||||
appState: Readonly<AppState>,
|
appState: Readonly<AppState>,
|
||||||
formData: any,
|
formData: any,
|
||||||
app: AppAPI,
|
app: AppClassProperties,
|
||||||
) => ActionResult | Promise<ActionResult>;
|
) => ActionResult | Promise<ActionResult>;
|
||||||
|
|
||||||
export type UpdaterFn = (res: ActionResult) => void;
|
export type UpdaterFn = (res: ActionResult) => void;
|
||||||
@@ -38,6 +40,7 @@ export type ActionName =
|
|||||||
| "paste"
|
| "paste"
|
||||||
| "copyAsPng"
|
| "copyAsPng"
|
||||||
| "copyAsSvg"
|
| "copyAsSvg"
|
||||||
|
| "copyText"
|
||||||
| "sendBackward"
|
| "sendBackward"
|
||||||
| "bringForward"
|
| "bringForward"
|
||||||
| "sendToBack"
|
| "sendToBack"
|
||||||
@@ -66,9 +69,9 @@ export type ActionName =
|
|||||||
| "changeProjectName"
|
| "changeProjectName"
|
||||||
| "changeExportBackground"
|
| "changeExportBackground"
|
||||||
| "changeExportEmbedScene"
|
| "changeExportEmbedScene"
|
||||||
| "changeShouldAddWatermark"
|
| "changeExportScale"
|
||||||
| "saveScene"
|
| "saveToActiveFile"
|
||||||
| "saveAsScene"
|
| "saveFileToDisk"
|
||||||
| "loadScene"
|
| "loadScene"
|
||||||
| "duplicateSelection"
|
| "duplicateSelection"
|
||||||
| "deleteSelectedElements"
|
| "deleteSelectedElements"
|
||||||
@@ -81,6 +84,7 @@ export type ActionName =
|
|||||||
| "zoomToSelection"
|
| "zoomToSelection"
|
||||||
| "changeFontFamily"
|
| "changeFontFamily"
|
||||||
| "changeTextAlign"
|
| "changeTextAlign"
|
||||||
|
| "changeVerticalAlign"
|
||||||
| "toggleFullScreen"
|
| "toggleFullScreen"
|
||||||
| "toggleShortcuts"
|
| "toggleShortcuts"
|
||||||
| "group"
|
| "group"
|
||||||
@@ -100,17 +104,26 @@ export type ActionName =
|
|||||||
| "flipVertical"
|
| "flipVertical"
|
||||||
| "viewMode"
|
| "viewMode"
|
||||||
| "exportWithDarkMode"
|
| "exportWithDarkMode"
|
||||||
| "toggleTheme";
|
| "toggleTheme"
|
||||||
|
| "increaseFontSize"
|
||||||
|
| "decreaseFontSize"
|
||||||
|
| "unbindText"
|
||||||
|
| "hyperlink"
|
||||||
|
| "eraser"
|
||||||
|
| "bindText"
|
||||||
|
| "toggleLock";
|
||||||
|
|
||||||
|
export type PanelComponentProps = {
|
||||||
|
elements: readonly ExcalidrawElement[];
|
||||||
|
appState: AppState;
|
||||||
|
updateData: (formData?: any) => void;
|
||||||
|
appProps: ExcalidrawProps;
|
||||||
|
data?: Record<string, any>;
|
||||||
|
};
|
||||||
|
|
||||||
export interface Action {
|
export interface Action {
|
||||||
name: ActionName;
|
name: ActionName;
|
||||||
PanelComponent?: React.FC<{
|
PanelComponent?: React.FC<PanelComponentProps>;
|
||||||
elements: readonly ExcalidrawElement[];
|
|
||||||
appState: AppState;
|
|
||||||
updateData: (formData?: any) => void;
|
|
||||||
appProps: ExcalidrawProps;
|
|
||||||
id?: string;
|
|
||||||
}>;
|
|
||||||
perform: ActionFn;
|
perform: ActionFn;
|
||||||
keyPriority?: number;
|
keyPriority?: number;
|
||||||
keyTest?: (
|
keyTest?: (
|
||||||
@@ -118,18 +131,34 @@ export interface Action {
|
|||||||
appState: AppState,
|
appState: AppState,
|
||||||
elements: readonly ExcalidrawElement[],
|
elements: readonly ExcalidrawElement[],
|
||||||
) => boolean;
|
) => boolean;
|
||||||
contextItemLabel?: string;
|
contextItemLabel?:
|
||||||
|
| string
|
||||||
|
| ((
|
||||||
|
elements: readonly ExcalidrawElement[],
|
||||||
|
appState: Readonly<AppState>,
|
||||||
|
) => string);
|
||||||
contextItemPredicate?: (
|
contextItemPredicate?: (
|
||||||
elements: readonly ExcalidrawElement[],
|
elements: readonly ExcalidrawElement[],
|
||||||
appState: AppState,
|
appState: AppState,
|
||||||
) => boolean;
|
) => boolean;
|
||||||
checked?: (appState: Readonly<AppState>) => boolean;
|
checked?: (appState: Readonly<AppState>) => boolean;
|
||||||
}
|
trackEvent:
|
||||||
|
| false
|
||||||
export interface ActionsManagerInterface {
|
| {
|
||||||
actions: Record<ActionName, Action>;
|
category:
|
||||||
registerAction: (action: Action) => void;
|
| "toolbar"
|
||||||
handleKeyDown: (event: React.KeyboardEvent | KeyboardEvent) => boolean;
|
| "element"
|
||||||
renderAction: (name: ActionName) => React.ReactElement | null;
|
| "canvas"
|
||||||
executeAction: (action: Action) => void;
|
| "export"
|
||||||
|
| "history"
|
||||||
|
| "menu"
|
||||||
|
| "collab"
|
||||||
|
| "hyperlink";
|
||||||
|
action?: string;
|
||||||
|
predicate?: (
|
||||||
|
appState: Readonly<AppState>,
|
||||||
|
elements: readonly ExcalidrawElement[],
|
||||||
|
value: any,
|
||||||
|
) => boolean;
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
37
src/align.ts
37
src/align.ts
@@ -1,13 +1,7 @@
|
|||||||
import { ExcalidrawElement } from "./element/types";
|
import { ExcalidrawElement } from "./element/types";
|
||||||
import { newElementWith } from "./element/mutateElement";
|
import { newElementWith } from "./element/mutateElement";
|
||||||
import { getCommonBounds } from "./element";
|
import { Box, getCommonBoundingBox } from "./element/bounds";
|
||||||
|
import { getMaximumGroups } from "./groups";
|
||||||
interface Box {
|
|
||||||
minX: number;
|
|
||||||
minY: number;
|
|
||||||
maxX: number;
|
|
||||||
maxY: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface Alignment {
|
export interface Alignment {
|
||||||
position: "start" | "center" | "end";
|
position: "start" | "center" | "end";
|
||||||
@@ -37,28 +31,6 @@ export const alignElements = (
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
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 = (
|
const calculateTranslation = (
|
||||||
group: ExcalidrawElement[],
|
group: ExcalidrawElement[],
|
||||||
selectionBoundingBox: Box,
|
selectionBoundingBox: Box,
|
||||||
@@ -88,8 +60,3 @@ const calculateTranslation = (
|
|||||||
(groupBoundingBox[min] + groupBoundingBox[max]) / 2,
|
(groupBoundingBox[min] + groupBoundingBox[max]) / 2,
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
const getCommonBoundingBox = (elements: ExcalidrawElement[]): Box => {
|
|
||||||
const [minX, minY, maxX, maxY] = getCommonBounds(elements);
|
|
||||||
return { minX, minY, maxX, maxY };
|
|
||||||
};
|
|
||||||
|
@@ -3,16 +3,20 @@ export const trackEvent =
|
|||||||
process.env?.REACT_APP_GOOGLE_ANALYTICS_ID &&
|
process.env?.REACT_APP_GOOGLE_ANALYTICS_ID &&
|
||||||
typeof window !== "undefined" &&
|
typeof window !== "undefined" &&
|
||||||
window.gtag
|
window.gtag
|
||||||
? (category: string, name: string, label?: string, value?: number) => {
|
? (category: string, action: string, label?: string, value?: number) => {
|
||||||
window.gtag("event", name, {
|
try {
|
||||||
event_category: category,
|
window.gtag("event", action, {
|
||||||
event_label: label,
|
event_category: category,
|
||||||
value,
|
event_label: label,
|
||||||
});
|
value,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error("error logging to ga", error);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
: typeof process !== "undefined" && process.env?.JEST_WORKER_ID
|
: typeof process !== "undefined" && process.env?.JEST_WORKER_ID
|
||||||
? (category: string, name: string, label?: string, value?: number) => {}
|
? (category: string, action: string, label?: string, value?: number) => {}
|
||||||
: (category: string, name: string, label?: string, value?: number) => {
|
: (category: string, action: string, label?: string, value?: number) => {
|
||||||
// Uncomment the next line to track locally
|
// Uncomment the next line to track locally
|
||||||
// console.info("Track Event", category, name, label, value);
|
// console.log("Track Event", { category, action, label, value });
|
||||||
};
|
};
|
||||||
|
196
src/appState.ts
196
src/appState.ts
@@ -3,17 +3,23 @@ import {
|
|||||||
DEFAULT_FONT_FAMILY,
|
DEFAULT_FONT_FAMILY,
|
||||||
DEFAULT_FONT_SIZE,
|
DEFAULT_FONT_SIZE,
|
||||||
DEFAULT_TEXT_ALIGN,
|
DEFAULT_TEXT_ALIGN,
|
||||||
|
EXPORT_SCALES,
|
||||||
|
THEME,
|
||||||
} from "./constants";
|
} from "./constants";
|
||||||
import { t } from "./i18n";
|
import { t } from "./i18n";
|
||||||
import { AppState, NormalizedZoomValue } from "./types";
|
import { AppState, NormalizedZoomValue } from "./types";
|
||||||
import { getDateTime } from "./utils";
|
import { getDateTime } from "./utils";
|
||||||
|
|
||||||
|
const defaultExportScale = EXPORT_SCALES.includes(devicePixelRatio)
|
||||||
|
? devicePixelRatio
|
||||||
|
: 1;
|
||||||
|
|
||||||
export const getDefaultAppState = (): Omit<
|
export const getDefaultAppState = (): Omit<
|
||||||
AppState,
|
AppState,
|
||||||
"offsetTop" | "offsetLeft" | "width" | "height"
|
"offsetTop" | "offsetLeft" | "width" | "height"
|
||||||
> => {
|
> => {
|
||||||
return {
|
return {
|
||||||
theme: "light",
|
theme: THEME.LIGHT,
|
||||||
collaborators: new Map(),
|
collaborators: new Map(),
|
||||||
currentChartType: "bar",
|
currentChartType: "bar",
|
||||||
currentItemBackgroundColor: "transparent",
|
currentItemBackgroundColor: "transparent",
|
||||||
@@ -35,16 +41,24 @@ export const getDefaultAppState = (): Omit<
|
|||||||
editingElement: null,
|
editingElement: null,
|
||||||
editingGroupId: null,
|
editingGroupId: null,
|
||||||
editingLinearElement: null,
|
editingLinearElement: null,
|
||||||
elementLocked: false,
|
activeTool: {
|
||||||
elementType: "selection",
|
type: "selection",
|
||||||
|
customType: null,
|
||||||
|
locked: false,
|
||||||
|
lastActiveToolBeforeEraser: null,
|
||||||
|
},
|
||||||
|
penMode: false,
|
||||||
|
penDetected: false,
|
||||||
errorMessage: null,
|
errorMessage: null,
|
||||||
exportBackground: true,
|
exportBackground: true,
|
||||||
|
exportScale: defaultExportScale,
|
||||||
exportEmbedScene: false,
|
exportEmbedScene: false,
|
||||||
exportWithDarkMode: false,
|
exportWithDarkMode: false,
|
||||||
fileHandle: null,
|
fileHandle: null,
|
||||||
gridSize: null,
|
gridSize: null,
|
||||||
isBindingEnabled: true,
|
isBindingEnabled: true,
|
||||||
isLibraryOpen: false,
|
isLibraryOpen: false,
|
||||||
|
isLibraryMenuDocked: false,
|
||||||
isLoading: false,
|
isLoading: false,
|
||||||
isResizing: false,
|
isResizing: false,
|
||||||
isRotating: false,
|
isRotating: false,
|
||||||
@@ -52,6 +66,7 @@ export const getDefaultAppState = (): Omit<
|
|||||||
multiElement: null,
|
multiElement: null,
|
||||||
name: `${t("labels.untitled")}-${getDateTime()}`,
|
name: `${t("labels.untitled")}-${getDateTime()}`,
|
||||||
openMenu: null,
|
openMenu: null,
|
||||||
|
openPopup: null,
|
||||||
pasteDialog: { shown: false, data: null },
|
pasteDialog: { shown: false, data: null },
|
||||||
previousSelectedElementIds: {},
|
previousSelectedElementIds: {},
|
||||||
resizingElement: null,
|
resizingElement: null,
|
||||||
@@ -61,17 +76,21 @@ export const getDefaultAppState = (): Omit<
|
|||||||
selectedElementIds: {},
|
selectedElementIds: {},
|
||||||
selectedGroupIds: {},
|
selectedGroupIds: {},
|
||||||
selectionElement: null,
|
selectionElement: null,
|
||||||
shouldAddWatermark: false,
|
|
||||||
shouldCacheIgnoreZoom: false,
|
shouldCacheIgnoreZoom: false,
|
||||||
showHelpDialog: false,
|
showHelpDialog: false,
|
||||||
showStats: false,
|
showStats: false,
|
||||||
startBoundElement: null,
|
startBoundElement: null,
|
||||||
suggestedBindings: [],
|
suggestedBindings: [],
|
||||||
toastMessage: null,
|
toast: null,
|
||||||
viewBackgroundColor: oc.white,
|
viewBackgroundColor: oc.white,
|
||||||
zenModeEnabled: false,
|
zenModeEnabled: false,
|
||||||
zoom: { value: 1 as NormalizedZoomValue, translation: { x: 0, y: 0 } },
|
zoom: {
|
||||||
|
value: 1 as NormalizedZoomValue,
|
||||||
|
},
|
||||||
viewModeEnabled: false,
|
viewModeEnabled: false,
|
||||||
|
pendingImageElementId: null,
|
||||||
|
showHyperlinkPopup: false,
|
||||||
|
selectedLinearElement: null,
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -85,77 +104,90 @@ const APP_STATE_STORAGE_CONF = (<
|
|||||||
browser: boolean;
|
browser: boolean;
|
||||||
/** whether to keep when exporting to file/database */
|
/** whether to keep when exporting to file/database */
|
||||||
export: boolean;
|
export: boolean;
|
||||||
|
/** server (shareLink/collab/...) */
|
||||||
|
server: boolean;
|
||||||
},
|
},
|
||||||
T extends Record<keyof AppState, Values>
|
T extends Record<keyof AppState, Values>,
|
||||||
>(
|
>(config: { [K in keyof T]: K extends keyof AppState ? T[K] : never }) =>
|
||||||
config: { [K in keyof T]: K extends keyof AppState ? T[K] : never },
|
config)({
|
||||||
) => config)({
|
theme: { browser: true, export: false, server: false },
|
||||||
theme: { browser: true, export: false },
|
collaborators: { browser: false, export: false, server: false },
|
||||||
collaborators: { browser: false, export: false },
|
currentChartType: { browser: true, export: false, server: false },
|
||||||
currentChartType: { browser: true, export: false },
|
currentItemBackgroundColor: { browser: true, export: false, server: false },
|
||||||
currentItemBackgroundColor: { browser: true, export: false },
|
currentItemEndArrowhead: { browser: true, export: false, server: false },
|
||||||
currentItemEndArrowhead: { browser: true, export: false },
|
currentItemFillStyle: { browser: true, export: false, server: false },
|
||||||
currentItemFillStyle: { browser: true, export: false },
|
currentItemFontFamily: { browser: true, export: false, server: false },
|
||||||
currentItemFontFamily: { browser: true, export: false },
|
currentItemFontSize: { browser: true, export: false, server: false },
|
||||||
currentItemFontSize: { browser: true, export: false },
|
currentItemLinearStrokeSharpness: {
|
||||||
currentItemLinearStrokeSharpness: { browser: true, export: false },
|
browser: true,
|
||||||
currentItemOpacity: { browser: true, export: false },
|
export: false,
|
||||||
currentItemRoughness: { browser: true, export: false },
|
server: false,
|
||||||
currentItemStartArrowhead: { browser: true, export: false },
|
},
|
||||||
currentItemStrokeColor: { browser: true, export: false },
|
currentItemOpacity: { browser: true, export: false, server: false },
|
||||||
currentItemStrokeSharpness: { browser: true, export: false },
|
currentItemRoughness: { browser: true, export: false, server: false },
|
||||||
currentItemStrokeStyle: { browser: true, export: false },
|
currentItemStartArrowhead: { browser: true, export: false, server: false },
|
||||||
currentItemStrokeWidth: { browser: true, export: false },
|
currentItemStrokeColor: { browser: true, export: false, server: false },
|
||||||
currentItemTextAlign: { browser: true, export: false },
|
currentItemStrokeSharpness: { browser: true, export: false, server: false },
|
||||||
cursorButton: { browser: true, export: false },
|
currentItemStrokeStyle: { browser: true, export: false, server: false },
|
||||||
draggingElement: { browser: false, export: false },
|
currentItemStrokeWidth: { browser: true, export: false, server: false },
|
||||||
editingElement: { browser: false, export: false },
|
currentItemTextAlign: { browser: true, export: false, server: false },
|
||||||
editingGroupId: { browser: true, export: false },
|
cursorButton: { browser: true, export: false, server: false },
|
||||||
editingLinearElement: { browser: false, export: false },
|
draggingElement: { browser: false, export: false, server: false },
|
||||||
elementLocked: { browser: true, export: false },
|
editingElement: { browser: false, export: false, server: false },
|
||||||
elementType: { browser: true, export: false },
|
editingGroupId: { browser: true, export: false, server: false },
|
||||||
errorMessage: { browser: false, export: false },
|
editingLinearElement: { browser: false, export: false, server: false },
|
||||||
exportBackground: { browser: true, export: false },
|
activeTool: { browser: true, export: false, server: false },
|
||||||
exportEmbedScene: { browser: true, export: false },
|
penMode: { browser: true, export: false, server: false },
|
||||||
exportWithDarkMode: { browser: true, export: false },
|
penDetected: { browser: true, export: false, server: false },
|
||||||
fileHandle: { browser: false, export: false },
|
errorMessage: { browser: false, export: false, server: false },
|
||||||
gridSize: { browser: true, export: true },
|
exportBackground: { browser: true, export: false, server: false },
|
||||||
height: { browser: false, export: false },
|
exportEmbedScene: { browser: true, export: false, server: false },
|
||||||
isBindingEnabled: { browser: false, export: false },
|
exportScale: { browser: true, export: false, server: false },
|
||||||
isLibraryOpen: { browser: false, export: false },
|
exportWithDarkMode: { browser: true, export: false, server: false },
|
||||||
isLoading: { browser: false, export: false },
|
fileHandle: { browser: false, export: false, server: false },
|
||||||
isResizing: { browser: false, export: false },
|
gridSize: { browser: true, export: true, server: true },
|
||||||
isRotating: { browser: false, export: false },
|
height: { browser: false, export: false, server: false },
|
||||||
lastPointerDownWith: { browser: true, export: false },
|
isBindingEnabled: { browser: false, export: false, server: false },
|
||||||
multiElement: { browser: false, export: false },
|
isLibraryOpen: { browser: true, export: false, server: false },
|
||||||
name: { browser: true, export: false },
|
isLibraryMenuDocked: { browser: true, export: false, server: false },
|
||||||
offsetLeft: { browser: false, export: false },
|
isLoading: { browser: false, export: false, server: false },
|
||||||
offsetTop: { browser: false, export: false },
|
isResizing: { browser: false, export: false, server: false },
|
||||||
openMenu: { browser: true, export: false },
|
isRotating: { browser: false, export: false, server: false },
|
||||||
pasteDialog: { browser: false, export: false },
|
lastPointerDownWith: { browser: true, export: false, server: false },
|
||||||
previousSelectedElementIds: { browser: true, export: false },
|
multiElement: { browser: false, export: false, server: false },
|
||||||
resizingElement: { browser: false, export: false },
|
name: { browser: true, export: false, server: false },
|
||||||
scrolledOutside: { browser: true, export: false },
|
offsetLeft: { browser: false, export: false, server: false },
|
||||||
scrollX: { browser: true, export: false },
|
offsetTop: { browser: false, export: false, server: false },
|
||||||
scrollY: { browser: true, export: false },
|
openMenu: { browser: true, export: false, server: false },
|
||||||
selectedElementIds: { browser: true, export: false },
|
openPopup: { browser: false, export: false, server: false },
|
||||||
selectedGroupIds: { browser: true, export: false },
|
pasteDialog: { browser: false, export: false, server: false },
|
||||||
selectionElement: { browser: false, export: false },
|
previousSelectedElementIds: { browser: true, export: false, server: false },
|
||||||
shouldAddWatermark: { browser: true, export: false },
|
resizingElement: { browser: false, export: false, server: false },
|
||||||
shouldCacheIgnoreZoom: { browser: true, export: false },
|
scrolledOutside: { browser: true, export: false, server: false },
|
||||||
showHelpDialog: { browser: false, export: false },
|
scrollX: { browser: true, export: false, server: false },
|
||||||
showStats: { browser: true, export: false },
|
scrollY: { browser: true, export: false, server: false },
|
||||||
startBoundElement: { browser: false, export: false },
|
selectedElementIds: { browser: true, export: false, server: false },
|
||||||
suggestedBindings: { browser: false, export: false },
|
selectedGroupIds: { browser: true, export: false, server: false },
|
||||||
toastMessage: { browser: false, export: false },
|
selectionElement: { browser: false, export: false, server: false },
|
||||||
viewBackgroundColor: { browser: true, export: true },
|
shouldCacheIgnoreZoom: { browser: true, export: false, server: false },
|
||||||
width: { browser: false, export: false },
|
showHelpDialog: { browser: false, export: false, server: false },
|
||||||
zenModeEnabled: { browser: true, export: false },
|
showStats: { browser: true, export: false, server: false },
|
||||||
zoom: { browser: true, export: false },
|
startBoundElement: { browser: false, export: false, server: false },
|
||||||
viewModeEnabled: { browser: false, export: false },
|
suggestedBindings: { browser: false, export: false, server: false },
|
||||||
|
toast: { browser: false, export: false, server: false },
|
||||||
|
viewBackgroundColor: { browser: true, export: true, server: true },
|
||||||
|
width: { browser: false, export: false, server: false },
|
||||||
|
zenModeEnabled: { browser: true, export: false, server: false },
|
||||||
|
zoom: { browser: true, export: false, server: false },
|
||||||
|
viewModeEnabled: { browser: false, export: false, server: false },
|
||||||
|
pendingImageElementId: { browser: false, export: false, server: false },
|
||||||
|
showHyperlinkPopup: { browser: false, export: false, server: false },
|
||||||
|
selectedLinearElement: { browser: true, export: false, server: false },
|
||||||
});
|
});
|
||||||
|
|
||||||
const _clearAppStateForStorage = <ExportType extends "export" | "browser">(
|
const _clearAppStateForStorage = <
|
||||||
|
ExportType extends "export" | "browser" | "server",
|
||||||
|
>(
|
||||||
appState: Partial<AppState>,
|
appState: Partial<AppState>,
|
||||||
exportType: ExportType,
|
exportType: ExportType,
|
||||||
) => {
|
) => {
|
||||||
@@ -168,8 +200,10 @@ const _clearAppStateForStorage = <ExportType extends "export" | "browser">(
|
|||||||
for (const key of Object.keys(appState) as (keyof typeof appState)[]) {
|
for (const key of Object.keys(appState) as (keyof typeof appState)[]) {
|
||||||
const propConfig = APP_STATE_STORAGE_CONF[key];
|
const propConfig = APP_STATE_STORAGE_CONF[key];
|
||||||
if (propConfig?.[exportType]) {
|
if (propConfig?.[exportType]) {
|
||||||
// @ts-ignore see https://github.com/microsoft/TypeScript/issues/31445
|
const nextValue = appState[key];
|
||||||
stateForExport[key] = appState[key];
|
|
||||||
|
// https://github.com/microsoft/TypeScript/issues/31445
|
||||||
|
(stateForExport as any)[key] = nextValue;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return stateForExport;
|
return stateForExport;
|
||||||
@@ -182,3 +216,13 @@ export const clearAppStateForLocalStorage = (appState: Partial<AppState>) => {
|
|||||||
export const cleanAppStateForExport = (appState: Partial<AppState>) => {
|
export const cleanAppStateForExport = (appState: Partial<AppState>) => {
|
||||||
return _clearAppStateForStorage(appState, "export");
|
return _clearAppStateForStorage(appState, "export");
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const clearAppStateForDatabase = (appState: Partial<AppState>) => {
|
||||||
|
return _clearAppStateForStorage(appState, "server");
|
||||||
|
};
|
||||||
|
|
||||||
|
export const isEraserActive = ({
|
||||||
|
activeTool,
|
||||||
|
}: {
|
||||||
|
activeTool: AppState["activeTool"];
|
||||||
|
}) => activeTool.type === "eraser";
|
||||||
|
121
src/charts.test.ts
Normal file
121
src/charts.test.ts
Normal file
@@ -0,0 +1,121 @@
|
|||||||
|
import {
|
||||||
|
Spreadsheet,
|
||||||
|
tryParseCells,
|
||||||
|
tryParseNumber,
|
||||||
|
VALID_SPREADSHEET,
|
||||||
|
} from "./charts";
|
||||||
|
|
||||||
|
describe("charts", () => {
|
||||||
|
describe("tryParseNumber", () => {
|
||||||
|
it.each<[string, number]>([
|
||||||
|
["1", 1],
|
||||||
|
["0", 0],
|
||||||
|
["-1", -1],
|
||||||
|
["0.1", 0.1],
|
||||||
|
[".1", 0.1],
|
||||||
|
["1.", 1],
|
||||||
|
["424.", 424],
|
||||||
|
["$1", 1],
|
||||||
|
["-.1", -0.1],
|
||||||
|
["-$1", -1],
|
||||||
|
["$-1", -1],
|
||||||
|
])("should correctly identify %s as numbers", (given, expected) => {
|
||||||
|
expect(tryParseNumber(given)).toEqual(expected);
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each<[string]>([["a"], ["$"], ["$a"], ["-$a"]])(
|
||||||
|
"should correctly identify %s as not a number",
|
||||||
|
(given) => {
|
||||||
|
expect(tryParseNumber(given)).toBeNull();
|
||||||
|
},
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("tryParseCells", () => {
|
||||||
|
it("Successfully parses a spreadsheet", () => {
|
||||||
|
const spreadsheet = [
|
||||||
|
["time", "value"],
|
||||||
|
["01:00", "61"],
|
||||||
|
["02:00", "-60"],
|
||||||
|
["03:00", "85"],
|
||||||
|
["04:00", "-67"],
|
||||||
|
["05:00", "54"],
|
||||||
|
["06:00", "95"],
|
||||||
|
];
|
||||||
|
|
||||||
|
const result = tryParseCells(spreadsheet);
|
||||||
|
|
||||||
|
expect(result.type).toBe(VALID_SPREADSHEET);
|
||||||
|
|
||||||
|
const { title, labels, values } = (
|
||||||
|
result as { type: typeof VALID_SPREADSHEET; spreadsheet: Spreadsheet }
|
||||||
|
).spreadsheet;
|
||||||
|
|
||||||
|
expect(title).toEqual("value");
|
||||||
|
expect(labels).toEqual([
|
||||||
|
"01:00",
|
||||||
|
"02:00",
|
||||||
|
"03:00",
|
||||||
|
"04:00",
|
||||||
|
"05:00",
|
||||||
|
"06:00",
|
||||||
|
]);
|
||||||
|
expect(values).toEqual([61, -60, 85, -67, 54, 95]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("Uses the second column as the label if it is not a number", () => {
|
||||||
|
const spreadsheet = [
|
||||||
|
["time", "value"],
|
||||||
|
["01:00", "61"],
|
||||||
|
["02:00", "-60"],
|
||||||
|
["03:00", "85"],
|
||||||
|
["04:00", "-67"],
|
||||||
|
["05:00", "54"],
|
||||||
|
["06:00", "95"],
|
||||||
|
];
|
||||||
|
|
||||||
|
const result = tryParseCells(spreadsheet);
|
||||||
|
|
||||||
|
expect(result.type).toBe(VALID_SPREADSHEET);
|
||||||
|
|
||||||
|
const { title, labels, values } = (
|
||||||
|
result as { type: typeof VALID_SPREADSHEET; spreadsheet: Spreadsheet }
|
||||||
|
).spreadsheet;
|
||||||
|
|
||||||
|
expect(title).toEqual("value");
|
||||||
|
expect(labels).toEqual([
|
||||||
|
"01:00",
|
||||||
|
"02:00",
|
||||||
|
"03:00",
|
||||||
|
"04:00",
|
||||||
|
"05:00",
|
||||||
|
"06:00",
|
||||||
|
]);
|
||||||
|
expect(values).toEqual([61, -60, 85, -67, 54, 95]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("treats the first column as labels if both columns are numbers", () => {
|
||||||
|
const spreadsheet = [
|
||||||
|
["time", "value"],
|
||||||
|
["01", "61"],
|
||||||
|
["02", "-60"],
|
||||||
|
["03", "85"],
|
||||||
|
["04", "-67"],
|
||||||
|
["05", "54"],
|
||||||
|
["06", "95"],
|
||||||
|
];
|
||||||
|
|
||||||
|
const result = tryParseCells(spreadsheet);
|
||||||
|
|
||||||
|
expect(result.type).toBe(VALID_SPREADSHEET);
|
||||||
|
|
||||||
|
const { title, labels, values } = (
|
||||||
|
result as { type: typeof VALID_SPREADSHEET; spreadsheet: Spreadsheet }
|
||||||
|
).spreadsheet;
|
||||||
|
|
||||||
|
expect(title).toEqual("value");
|
||||||
|
expect(labels).toEqual(["01", "02", "03", "04", "05", "06"]);
|
||||||
|
expect(values).toEqual([61, -60, 85, -67, 54, 95]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
@@ -1,5 +1,10 @@
|
|||||||
import colors from "./colors";
|
import colors from "./colors";
|
||||||
import { DEFAULT_FONT_FAMILY, DEFAULT_FONT_SIZE, ENV } from "./constants";
|
import {
|
||||||
|
DEFAULT_FONT_FAMILY,
|
||||||
|
DEFAULT_FONT_SIZE,
|
||||||
|
ENV,
|
||||||
|
VERTICAL_ALIGN,
|
||||||
|
} from "./constants";
|
||||||
import { newElement, newLinearElement, newTextElement } from "./element";
|
import { newElement, newLinearElement, newTextElement } from "./element";
|
||||||
import { NonDeletedExcalidrawElement } from "./element/types";
|
import { NonDeletedExcalidrawElement } from "./element/types";
|
||||||
import { randomId } from "./random";
|
import { randomId } from "./random";
|
||||||
@@ -24,18 +29,24 @@ type ParseSpreadsheetResult =
|
|||||||
| { type: typeof NOT_SPREADSHEET; reason: string }
|
| { type: typeof NOT_SPREADSHEET; reason: string }
|
||||||
| { type: typeof VALID_SPREADSHEET; spreadsheet: Spreadsheet };
|
| { type: typeof VALID_SPREADSHEET; spreadsheet: Spreadsheet };
|
||||||
|
|
||||||
const tryParseNumber = (s: string): number | null => {
|
/**
|
||||||
const match = /^[$€£¥₩]?([0-9,]+(\.[0-9]+)?)$/.exec(s);
|
* @private exported for testing
|
||||||
|
*/
|
||||||
|
export const tryParseNumber = (s: string): number | null => {
|
||||||
|
const match = /^([-+]?)[$€£¥₩]?([-+]?)([\d.,]+)[%]?$/.exec(s);
|
||||||
if (!match) {
|
if (!match) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
return parseFloat(match[1].replace(/,/g, ""));
|
return parseFloat(`${(match[1] || match[2]) + match[3]}`.replace(/,/g, ""));
|
||||||
};
|
};
|
||||||
|
|
||||||
const isNumericColumn = (lines: string[][], columnIndex: number) =>
|
const isNumericColumn = (lines: string[][], columnIndex: number) =>
|
||||||
lines.slice(1).every((line) => tryParseNumber(line[columnIndex]) !== null);
|
lines.slice(1).every((line) => tryParseNumber(line[columnIndex]) !== null);
|
||||||
|
|
||||||
const tryParseCells = (cells: string[][]): ParseSpreadsheetResult => {
|
/**
|
||||||
|
* @private exported for testing
|
||||||
|
*/
|
||||||
|
export const tryParseCells = (cells: string[][]): ParseSpreadsheetResult => {
|
||||||
const numCols = cells[0].length;
|
const numCols = cells[0].length;
|
||||||
|
|
||||||
if (numCols > 2) {
|
if (numCols > 2) {
|
||||||
@@ -66,13 +77,16 @@ const tryParseCells = (cells: string[][]): ParseSpreadsheetResult => {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
const valueColumnIndex = isNumericColumn(cells, 0) ? 0 : 1;
|
const labelColumnNumeric = isNumericColumn(cells, 0);
|
||||||
|
const valueColumnNumeric = isNumericColumn(cells, 1);
|
||||||
|
|
||||||
if (!isNumericColumn(cells, valueColumnIndex)) {
|
if (!labelColumnNumeric && !valueColumnNumeric) {
|
||||||
return { type: NOT_SPREADSHEET, reason: "Value is not numeric" };
|
return { type: NOT_SPREADSHEET, reason: "Value is not numeric" };
|
||||||
}
|
}
|
||||||
|
|
||||||
const labelColumnIndex = (valueColumnIndex + 1) % 2;
|
const [labelColumnIndex, valueColumnIndex] = valueColumnNumeric
|
||||||
|
? [0, 1]
|
||||||
|
: [1, 0];
|
||||||
const hasHeader = tryParseNumber(cells[0][valueColumnIndex]) === null;
|
const hasHeader = tryParseNumber(cells[0][valueColumnIndex]) === null;
|
||||||
const rows = hasHeader ? cells.slice(1) : cells;
|
const rows = hasHeader ? cells.slice(1) : cells;
|
||||||
|
|
||||||
@@ -103,7 +117,7 @@ const transposeCells = (cells: string[][]) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export const tryParseSpreadsheet = (text: string): ParseSpreadsheetResult => {
|
export const tryParseSpreadsheet = (text: string): ParseSpreadsheetResult => {
|
||||||
// Copy/paste from excel, spreadhseets, tsv, csv.
|
// Copy/paste from excel, spreadsheets, tsv, csv.
|
||||||
// For now we only accept 2 columns with an optional header
|
// For now we only accept 2 columns with an optional header
|
||||||
|
|
||||||
// Check for tab separated values
|
// Check for tab separated values
|
||||||
@@ -161,7 +175,8 @@ const commonProps = {
|
|||||||
strokeSharpness: "sharp",
|
strokeSharpness: "sharp",
|
||||||
strokeStyle: "solid",
|
strokeStyle: "solid",
|
||||||
strokeWidth: 1,
|
strokeWidth: 1,
|
||||||
verticalAlign: "middle",
|
verticalAlign: VERTICAL_ALIGN.MIDDLE,
|
||||||
|
locked: false,
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
const getChartDimentions = (spreadsheet: Spreadsheet) => {
|
const getChartDimentions = (spreadsheet: Spreadsheet) => {
|
||||||
|
@@ -2,20 +2,23 @@ import {
|
|||||||
ExcalidrawElement,
|
ExcalidrawElement,
|
||||||
NonDeletedExcalidrawElement,
|
NonDeletedExcalidrawElement,
|
||||||
} from "./element/types";
|
} from "./element/types";
|
||||||
import { getSelectedElements } from "./scene";
|
import { AppState, BinaryFiles } from "./types";
|
||||||
import { AppState } from "./types";
|
|
||||||
import { SVG_EXPORT_TAG } from "./scene/export";
|
import { SVG_EXPORT_TAG } from "./scene/export";
|
||||||
import { tryParseSpreadsheet, Spreadsheet, VALID_SPREADSHEET } from "./charts";
|
import { tryParseSpreadsheet, Spreadsheet, VALID_SPREADSHEET } from "./charts";
|
||||||
import { EXPORT_DATA_TYPES } from "./constants";
|
import { EXPORT_DATA_TYPES, MIME_TYPES } from "./constants";
|
||||||
|
import { isInitializedImageElement } from "./element/typeChecks";
|
||||||
|
import { isPromiseLike } from "./utils";
|
||||||
|
|
||||||
type ElementsClipboard = {
|
type ElementsClipboard = {
|
||||||
type: typeof EXPORT_DATA_TYPES.excalidrawClipboard;
|
type: typeof EXPORT_DATA_TYPES.excalidrawClipboard;
|
||||||
elements: ExcalidrawElement[];
|
elements: readonly NonDeletedExcalidrawElement[];
|
||||||
|
files: BinaryFiles | undefined;
|
||||||
};
|
};
|
||||||
|
|
||||||
export interface ClipboardData {
|
export interface ClipboardData {
|
||||||
spreadsheet?: Spreadsheet;
|
spreadsheet?: Spreadsheet;
|
||||||
elements?: readonly ExcalidrawElement[];
|
elements?: readonly ExcalidrawElement[];
|
||||||
|
files?: BinaryFiles;
|
||||||
text?: string;
|
text?: string;
|
||||||
errorMessage?: string;
|
errorMessage?: string;
|
||||||
}
|
}
|
||||||
@@ -37,7 +40,7 @@ export const probablySupportsClipboardBlob =
|
|||||||
|
|
||||||
const clipboardContainsElements = (
|
const clipboardContainsElements = (
|
||||||
contents: any,
|
contents: any,
|
||||||
): contents is { elements: ExcalidrawElement[] } => {
|
): contents is { elements: ExcalidrawElement[]; files?: BinaryFiles } => {
|
||||||
if (
|
if (
|
||||||
[
|
[
|
||||||
EXPORT_DATA_TYPES.excalidraw,
|
EXPORT_DATA_TYPES.excalidraw,
|
||||||
@@ -53,17 +56,27 @@ const clipboardContainsElements = (
|
|||||||
export const copyToClipboard = async (
|
export const copyToClipboard = async (
|
||||||
elements: readonly NonDeletedExcalidrawElement[],
|
elements: readonly NonDeletedExcalidrawElement[],
|
||||||
appState: AppState,
|
appState: AppState,
|
||||||
|
files: BinaryFiles | null,
|
||||||
) => {
|
) => {
|
||||||
|
// select binded text elements when copying
|
||||||
const contents: ElementsClipboard = {
|
const contents: ElementsClipboard = {
|
||||||
type: EXPORT_DATA_TYPES.excalidrawClipboard,
|
type: EXPORT_DATA_TYPES.excalidrawClipboard,
|
||||||
elements: getSelectedElements(elements, appState),
|
elements,
|
||||||
|
files: files
|
||||||
|
? elements.reduce((acc, element) => {
|
||||||
|
if (isInitializedImageElement(element) && files[element.fileId]) {
|
||||||
|
acc[element.fileId] = files[element.fileId];
|
||||||
|
}
|
||||||
|
return acc;
|
||||||
|
}, {} as BinaryFiles)
|
||||||
|
: undefined,
|
||||||
};
|
};
|
||||||
const json = JSON.stringify(contents);
|
const json = JSON.stringify(contents);
|
||||||
CLIPBOARD = json;
|
CLIPBOARD = json;
|
||||||
try {
|
try {
|
||||||
PREFER_APP_CLIPBOARD = false;
|
PREFER_APP_CLIPBOARD = false;
|
||||||
await copyTextToSystemClipboard(json);
|
await copyTextToSystemClipboard(json);
|
||||||
} catch (error) {
|
} catch (error: any) {
|
||||||
PREFER_APP_CLIPBOARD = true;
|
PREFER_APP_CLIPBOARD = true;
|
||||||
console.error(error);
|
console.error(error);
|
||||||
}
|
}
|
||||||
@@ -76,7 +89,7 @@ const getAppClipboard = (): Partial<ElementsClipboard> => {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
return JSON.parse(CLIPBOARD);
|
return JSON.parse(CLIPBOARD);
|
||||||
} catch (error) {
|
} catch (error: any) {
|
||||||
console.error(error);
|
console.error(error);
|
||||||
return {};
|
return {};
|
||||||
}
|
}
|
||||||
@@ -112,7 +125,7 @@ const getSystemClipboard = async (
|
|||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Attemps to parse clipboard. Prefers system clipboard.
|
* Attempts to parse clipboard. Prefers system clipboard.
|
||||||
*/
|
*/
|
||||||
export const parseClipboard = async (
|
export const parseClipboard = async (
|
||||||
event: ClipboardEvent | null,
|
event: ClipboardEvent | null,
|
||||||
@@ -138,7 +151,10 @@ export const parseClipboard = async (
|
|||||||
try {
|
try {
|
||||||
const systemClipboardData = JSON.parse(systemClipboard);
|
const systemClipboardData = JSON.parse(systemClipboard);
|
||||||
if (clipboardContainsElements(systemClipboardData)) {
|
if (clipboardContainsElements(systemClipboardData)) {
|
||||||
return { elements: systemClipboardData.elements };
|
return {
|
||||||
|
elements: systemClipboardData.elements,
|
||||||
|
files: systemClipboardData.files,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
return appClipboardData;
|
return appClipboardData;
|
||||||
} catch {
|
} catch {
|
||||||
@@ -151,10 +167,35 @@ export const parseClipboard = async (
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
export const copyBlobToClipboardAsPng = async (blob: Blob) => {
|
export const copyBlobToClipboardAsPng = async (blob: Blob | Promise<Blob>) => {
|
||||||
await navigator.clipboard.write([
|
let promise;
|
||||||
new window.ClipboardItem({ "image/png": blob }),
|
try {
|
||||||
]);
|
// in Safari so far we need to construct the ClipboardItem synchronously
|
||||||
|
// (i.e. in the same tick) otherwise browser will complain for lack of
|
||||||
|
// user intent. Using a Promise ClipboardItem constructor solves this.
|
||||||
|
// https://bugs.webkit.org/show_bug.cgi?id=222262
|
||||||
|
//
|
||||||
|
// not await so that we can detect whether the thrown error likely relates
|
||||||
|
// to a lack of support for the Promise ClipboardItem constructor
|
||||||
|
promise = navigator.clipboard.write([
|
||||||
|
new window.ClipboardItem({
|
||||||
|
[MIME_TYPES.png]: blob,
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
} catch (error: any) {
|
||||||
|
// if we're using a Promise ClipboardItem, let's try constructing
|
||||||
|
// with resolution value instead
|
||||||
|
if (isPromiseLike(blob)) {
|
||||||
|
await navigator.clipboard.write([
|
||||||
|
new window.ClipboardItem({
|
||||||
|
[MIME_TYPES.png]: await blob,
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
} else {
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
await promise;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const copyTextToSystemClipboard = async (text: string | null) => {
|
export const copyTextToSystemClipboard = async (text: string | null) => {
|
||||||
@@ -165,7 +206,7 @@ export const copyTextToSystemClipboard = async (text: string | null) => {
|
|||||||
// not focused
|
// not focused
|
||||||
await navigator.clipboard.writeText(text || "");
|
await navigator.clipboard.writeText(text || "");
|
||||||
copied = true;
|
copied = true;
|
||||||
} catch (error) {
|
} catch (error: any) {
|
||||||
console.error(error);
|
console.error(error);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -205,7 +246,7 @@ const copyTextViaExecCommand = (text: string) => {
|
|||||||
textarea.setSelectionRange(0, textarea.value.length);
|
textarea.setSelectionRange(0, textarea.value.length);
|
||||||
|
|
||||||
success = document.execCommand("copy");
|
success = document.execCommand("copy");
|
||||||
} catch (error) {
|
} catch (error: any) {
|
||||||
console.error(error);
|
console.error(error);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@@ -1,9 +1,9 @@
|
|||||||
import React from "react";
|
import React from "react";
|
||||||
import { ActionManager } from "../actions/manager";
|
import { ActionManager } from "../actions/manager";
|
||||||
import { getNonDeletedElements } from "../element";
|
import { getNonDeletedElements } from "../element";
|
||||||
import { ExcalidrawElement } from "../element/types";
|
import { ExcalidrawElement, PointerType } from "../element/types";
|
||||||
import { t } from "../i18n";
|
import { t } from "../i18n";
|
||||||
import { useIsMobile } from "../components/App";
|
import { useDevice } from "../components/App";
|
||||||
import {
|
import {
|
||||||
canChangeSharpness,
|
canChangeSharpness,
|
||||||
canHaveArrowheads,
|
canHaveArrowheads,
|
||||||
@@ -15,54 +15,87 @@ import {
|
|||||||
} from "../scene";
|
} from "../scene";
|
||||||
import { SHAPES } from "../shapes";
|
import { SHAPES } from "../shapes";
|
||||||
import { AppState, Zoom } from "../types";
|
import { AppState, Zoom } from "../types";
|
||||||
import { capitalizeString, isTransparent, setCursorForShape } from "../utils";
|
import {
|
||||||
|
capitalizeString,
|
||||||
|
isTransparent,
|
||||||
|
updateActiveTool,
|
||||||
|
setCursorForShape,
|
||||||
|
} from "../utils";
|
||||||
import Stack from "./Stack";
|
import Stack from "./Stack";
|
||||||
import { ToolButton } from "./ToolButton";
|
import { ToolButton } from "./ToolButton";
|
||||||
|
import { hasStrokeColor } from "../scene/comparisons";
|
||||||
|
import { trackEvent } from "../analytics";
|
||||||
|
import { hasBoundTextElement, isBoundToContainer } from "../element/typeChecks";
|
||||||
|
|
||||||
export const SelectedShapeActions = ({
|
export const SelectedShapeActions = ({
|
||||||
appState,
|
appState,
|
||||||
elements,
|
elements,
|
||||||
renderAction,
|
renderAction,
|
||||||
elementType,
|
activeTool,
|
||||||
}: {
|
}: {
|
||||||
appState: AppState;
|
appState: AppState;
|
||||||
elements: readonly ExcalidrawElement[];
|
elements: readonly ExcalidrawElement[];
|
||||||
renderAction: ActionManager["renderAction"];
|
renderAction: ActionManager["renderAction"];
|
||||||
elementType: ExcalidrawElement["type"];
|
activeTool: AppState["activeTool"]["type"];
|
||||||
}) => {
|
}) => {
|
||||||
const targetElements = getTargetElements(
|
const targetElements = getTargetElements(
|
||||||
getNonDeletedElements(elements),
|
getNonDeletedElements(elements),
|
||||||
appState,
|
appState,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
let isSingleElementBoundContainer = false;
|
||||||
|
if (
|
||||||
|
targetElements.length === 2 &&
|
||||||
|
(hasBoundTextElement(targetElements[0]) ||
|
||||||
|
hasBoundTextElement(targetElements[1]))
|
||||||
|
) {
|
||||||
|
isSingleElementBoundContainer = true;
|
||||||
|
}
|
||||||
const isEditing = Boolean(appState.editingElement);
|
const isEditing = Boolean(appState.editingElement);
|
||||||
const isMobile = useIsMobile();
|
const device = useDevice();
|
||||||
const isRTL = document.documentElement.getAttribute("dir") === "rtl";
|
const isRTL = document.documentElement.getAttribute("dir") === "rtl";
|
||||||
|
|
||||||
const showFillIcons =
|
const showFillIcons =
|
||||||
hasBackground(elementType) ||
|
hasBackground(activeTool) ||
|
||||||
targetElements.some(
|
targetElements.some(
|
||||||
(element) =>
|
(element) =>
|
||||||
hasBackground(element.type) && !isTransparent(element.backgroundColor),
|
hasBackground(element.type) && !isTransparent(element.backgroundColor),
|
||||||
);
|
);
|
||||||
const showChangeBackgroundIcons =
|
const showChangeBackgroundIcons =
|
||||||
hasBackground(elementType) ||
|
hasBackground(activeTool) ||
|
||||||
targetElements.some((element) => hasBackground(element.type));
|
targetElements.some((element) => hasBackground(element.type));
|
||||||
|
|
||||||
|
const showLinkIcon =
|
||||||
|
targetElements.length === 1 || isSingleElementBoundContainer;
|
||||||
|
|
||||||
|
let commonSelectedType: string | null = targetElements[0]?.type || null;
|
||||||
|
|
||||||
|
for (const element of targetElements) {
|
||||||
|
if (element.type !== commonSelectedType) {
|
||||||
|
commonSelectedType = null;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="panelColumn">
|
<div className="panelColumn">
|
||||||
{renderAction("changeStrokeColor")}
|
{((hasStrokeColor(activeTool) &&
|
||||||
|
activeTool !== "image" &&
|
||||||
|
commonSelectedType !== "image") ||
|
||||||
|
targetElements.some((element) => hasStrokeColor(element.type))) &&
|
||||||
|
renderAction("changeStrokeColor")}
|
||||||
{showChangeBackgroundIcons && renderAction("changeBackgroundColor")}
|
{showChangeBackgroundIcons && renderAction("changeBackgroundColor")}
|
||||||
{showFillIcons && renderAction("changeFillStyle")}
|
{showFillIcons && renderAction("changeFillStyle")}
|
||||||
|
|
||||||
{(hasStrokeWidth(elementType) ||
|
{(hasStrokeWidth(activeTool) ||
|
||||||
targetElements.some((element) => hasStrokeWidth(element.type))) &&
|
targetElements.some((element) => hasStrokeWidth(element.type))) &&
|
||||||
renderAction("changeStrokeWidth")}
|
renderAction("changeStrokeWidth")}
|
||||||
|
|
||||||
{(elementType === "freedraw" ||
|
{(activeTool === "freedraw" ||
|
||||||
targetElements.some((element) => element.type === "freedraw")) &&
|
targetElements.some((element) => element.type === "freedraw")) &&
|
||||||
renderAction("changeStrokeShape")}
|
renderAction("changeStrokeShape")}
|
||||||
|
|
||||||
{(hasStrokeStyle(elementType) ||
|
{(hasStrokeStyle(activeTool) ||
|
||||||
targetElements.some((element) => hasStrokeStyle(element.type))) && (
|
targetElements.some((element) => hasStrokeStyle(element.type))) && (
|
||||||
<>
|
<>
|
||||||
{renderAction("changeStrokeStyle")}
|
{renderAction("changeStrokeStyle")}
|
||||||
@@ -70,12 +103,12 @@ export const SelectedShapeActions = ({
|
|||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{(canChangeSharpness(elementType) ||
|
{(canChangeSharpness(activeTool) ||
|
||||||
targetElements.some((element) => canChangeSharpness(element.type))) && (
|
targetElements.some((element) => canChangeSharpness(element.type))) && (
|
||||||
<>{renderAction("changeSharpness")}</>
|
<>{renderAction("changeSharpness")}</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{(hasText(elementType) ||
|
{(hasText(activeTool) ||
|
||||||
targetElements.some((element) => hasText(element.type))) && (
|
targetElements.some((element) => hasText(element.type))) && (
|
||||||
<>
|
<>
|
||||||
{renderAction("changeFontSize")}
|
{renderAction("changeFontSize")}
|
||||||
@@ -86,7 +119,11 @@ export const SelectedShapeActions = ({
|
|||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{(canHaveArrowheads(elementType) ||
|
{targetElements.some(
|
||||||
|
(element) =>
|
||||||
|
hasBoundTextElement(element) || isBoundToContainer(element),
|
||||||
|
) && renderAction("changeVerticalAlign")}
|
||||||
|
{(canHaveArrowheads(activeTool) ||
|
||||||
targetElements.some((element) => canHaveArrowheads(element.type))) && (
|
targetElements.some((element) => canHaveArrowheads(element.type))) && (
|
||||||
<>{renderAction("changeArrowhead")}</>
|
<>{renderAction("changeArrowhead")}</>
|
||||||
)}
|
)}
|
||||||
@@ -103,7 +140,7 @@ export const SelectedShapeActions = ({
|
|||||||
</div>
|
</div>
|
||||||
</fieldset>
|
</fieldset>
|
||||||
|
|
||||||
{targetElements.length > 1 && (
|
{targetElements.length > 1 && !isSingleElementBoundContainer && (
|
||||||
<fieldset>
|
<fieldset>
|
||||||
<legend>{t("labels.align")}</legend>
|
<legend>{t("labels.align")}</legend>
|
||||||
<div className="buttonList">
|
<div className="buttonList">
|
||||||
@@ -136,14 +173,15 @@ export const SelectedShapeActions = ({
|
|||||||
</div>
|
</div>
|
||||||
</fieldset>
|
</fieldset>
|
||||||
)}
|
)}
|
||||||
{!isMobile && !isEditing && targetElements.length > 0 && (
|
{!isEditing && targetElements.length > 0 && (
|
||||||
<fieldset>
|
<fieldset>
|
||||||
<legend>{t("labels.actions")}</legend>
|
<legend>{t("labels.actions")}</legend>
|
||||||
<div className="buttonList">
|
<div className="buttonList">
|
||||||
{renderAction("duplicateSelection")}
|
{!device.isMobile && renderAction("duplicateSelection")}
|
||||||
{renderAction("deleteSelectedElements")}
|
{!device.isMobile && renderAction("deleteSelectedElements")}
|
||||||
{renderAction("group")}
|
{renderAction("group")}
|
||||||
{renderAction("ungroup")}
|
{renderAction("ungroup")}
|
||||||
|
{showLinkIcon && renderAction("hyperlink")}
|
||||||
</div>
|
</div>
|
||||||
</fieldset>
|
</fieldset>
|
||||||
)}
|
)}
|
||||||
@@ -151,69 +189,70 @@ export const SelectedShapeActions = ({
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
const LIBRARY_ICON = (
|
|
||||||
// fa-th-large
|
|
||||||
<svg viewBox="0 0 512 512">
|
|
||||||
<path d="M296 32h192c13.255 0 24 10.745 24 24v160c0 13.255-10.745 24-24 24H296c-13.255 0-24-10.745-24-24V56c0-13.255 10.745-24 24-24zm-80 0H24C10.745 32 0 42.745 0 56v160c0 13.255 10.745 24 24 24h192c13.255 0 24-10.745 24-24V56c0-13.255-10.745-24-24-24zM0 296v160c0 13.255 10.745 24 24 24h192c13.255 0 24-10.745 24-24V296c0-13.255-10.745-24-24-24H24c-13.255 0-24 10.745-24 24zm296 184h192c13.255 0 24-10.745 24-24V296c0-13.255-10.745-24-24-24H296c-13.255 0-24 10.745-24 24v160c0 13.255 10.745 24 24 24z" />
|
|
||||||
</svg>
|
|
||||||
);
|
|
||||||
|
|
||||||
export const ShapesSwitcher = ({
|
export const ShapesSwitcher = ({
|
||||||
canvas,
|
canvas,
|
||||||
elementType,
|
activeTool,
|
||||||
setAppState,
|
setAppState,
|
||||||
isLibraryOpen,
|
onImageAction,
|
||||||
|
appState,
|
||||||
}: {
|
}: {
|
||||||
canvas: HTMLCanvasElement | null;
|
canvas: HTMLCanvasElement | null;
|
||||||
elementType: ExcalidrawElement["type"];
|
activeTool: AppState["activeTool"];
|
||||||
setAppState: React.Component<any, AppState>["setState"];
|
setAppState: React.Component<any, AppState>["setState"];
|
||||||
isLibraryOpen: boolean;
|
onImageAction: (data: { pointerType: PointerType | null }) => void;
|
||||||
|
appState: AppState;
|
||||||
}) => (
|
}) => (
|
||||||
<>
|
<>
|
||||||
{SHAPES.map(({ value, icon, key }, index) => {
|
{SHAPES.map(({ value, icon, key }, index) => {
|
||||||
const label = t(`toolBar.${value}`);
|
const label = t(`toolBar.${value}`);
|
||||||
const letter = typeof key === "string" ? key : key[0];
|
const letter = key && (typeof key === "string" ? key : key[0]);
|
||||||
const shortcut = `${capitalizeString(letter)} ${t("helpDialog.or")} ${
|
const shortcut = letter
|
||||||
index + 1
|
? `${capitalizeString(letter)} ${t("helpDialog.or")} ${index + 1}`
|
||||||
}`;
|
: `${index + 1}`;
|
||||||
return (
|
return (
|
||||||
<ToolButton
|
<ToolButton
|
||||||
className="Shape"
|
className="Shape"
|
||||||
key={value}
|
key={value}
|
||||||
type="radio"
|
type="radio"
|
||||||
icon={icon}
|
icon={icon}
|
||||||
checked={elementType === value}
|
checked={activeTool.type === value}
|
||||||
name="editor-current-shape"
|
name="editor-current-shape"
|
||||||
title={`${capitalizeString(label)} — ${shortcut}`}
|
title={`${capitalizeString(label)} — ${shortcut}`}
|
||||||
keyBindingLabel={`${index + 1}`}
|
keyBindingLabel={`${index + 1}`}
|
||||||
aria-label={capitalizeString(label)}
|
aria-label={capitalizeString(label)}
|
||||||
aria-keyshortcuts={shortcut}
|
aria-keyshortcuts={shortcut}
|
||||||
data-testid={value}
|
data-testid={value}
|
||||||
onChange={() => {
|
onPointerDown={({ pointerType }) => {
|
||||||
|
if (!appState.penDetected && pointerType === "pen") {
|
||||||
|
setAppState({
|
||||||
|
penDetected: true,
|
||||||
|
penMode: true,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
onChange={({ pointerType }) => {
|
||||||
|
if (appState.activeTool.type !== value) {
|
||||||
|
trackEvent("toolbar", value, "ui");
|
||||||
|
}
|
||||||
|
const nextActiveTool = updateActiveTool(appState, {
|
||||||
|
type: value,
|
||||||
|
});
|
||||||
setAppState({
|
setAppState({
|
||||||
elementType: value,
|
activeTool: nextActiveTool,
|
||||||
multiElement: null,
|
multiElement: null,
|
||||||
selectedElementIds: {},
|
selectedElementIds: {},
|
||||||
});
|
});
|
||||||
setCursorForShape(canvas, value);
|
setCursorForShape(canvas, {
|
||||||
setAppState({});
|
...appState,
|
||||||
|
activeTool: nextActiveTool,
|
||||||
|
});
|
||||||
|
if (value === "image") {
|
||||||
|
onImageAction({ pointerType });
|
||||||
|
}
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
<ToolButton
|
|
||||||
className="Shape ToolIcon_type_button__library"
|
|
||||||
type="button"
|
|
||||||
icon={LIBRARY_ICON}
|
|
||||||
name="editor-library"
|
|
||||||
keyBindingLabel="9"
|
|
||||||
aria-keyshortcuts="9"
|
|
||||||
title={`${capitalizeString(t("toolBar.library"))} — 9`}
|
|
||||||
aria-label={capitalizeString(t("toolBar.library"))}
|
|
||||||
onClick={() => {
|
|
||||||
setAppState({ isLibraryOpen: !isLibraryOpen });
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -226,12 +265,9 @@ export const ZoomActions = ({
|
|||||||
}) => (
|
}) => (
|
||||||
<Stack.Col gap={1}>
|
<Stack.Col gap={1}>
|
||||||
<Stack.Row gap={1} align="center">
|
<Stack.Row gap={1} align="center">
|
||||||
{renderAction("zoomIn")}
|
|
||||||
{renderAction("zoomOut")}
|
{renderAction("zoomOut")}
|
||||||
|
{renderAction("zoomIn")}
|
||||||
{renderAction("resetZoom")}
|
{renderAction("resetZoom")}
|
||||||
<div style={{ marginInlineStart: 4 }}>
|
|
||||||
{(zoom.value * 100).toFixed(0)}%
|
|
||||||
</div>
|
|
||||||
</Stack.Row>
|
</Stack.Row>
|
||||||
</Stack.Col>
|
</Stack.Col>
|
||||||
);
|
);
|
||||||
|
21
src/components/ActiveFile.scss
Normal file
21
src/components/ActiveFile.scss
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
.excalidraw {
|
||||||
|
.ActiveFile {
|
||||||
|
.ActiveFile__fileName {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
|
||||||
|
span {
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
overflow: hidden;
|
||||||
|
white-space: nowrap;
|
||||||
|
width: 9.3em;
|
||||||
|
}
|
||||||
|
|
||||||
|
svg {
|
||||||
|
width: 1.15em;
|
||||||
|
margin-inline-end: 0.3em;
|
||||||
|
transform: scaleY(0.9);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
28
src/components/ActiveFile.tsx
Normal file
28
src/components/ActiveFile.tsx
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
import Stack from "../components/Stack";
|
||||||
|
import { ToolButton } from "../components/ToolButton";
|
||||||
|
import { save, file } from "../components/icons";
|
||||||
|
import { t } from "../i18n";
|
||||||
|
|
||||||
|
import "./ActiveFile.scss";
|
||||||
|
|
||||||
|
type ActiveFileProps = {
|
||||||
|
fileName?: string;
|
||||||
|
onSave: () => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const ActiveFile = ({ fileName, onSave }: ActiveFileProps) => (
|
||||||
|
<Stack.Row className="ActiveFile" gap={1} align="center">
|
||||||
|
<span className="ActiveFile__fileName">
|
||||||
|
{file}
|
||||||
|
<span>{fileName}</span>
|
||||||
|
</span>
|
||||||
|
<ToolButton
|
||||||
|
type="icon"
|
||||||
|
icon={save}
|
||||||
|
title={t("buttons.save")}
|
||||||
|
aria-label={t("buttons.save")}
|
||||||
|
onClick={onSave}
|
||||||
|
data-testid="save-button"
|
||||||
|
/>
|
||||||
|
</Stack.Row>
|
||||||
|
);
|
File diff suppressed because it is too large
Load Diff
@@ -12,5 +12,11 @@
|
|||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
font-size: 0.8rem;
|
font-size: 0.8rem;
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
|
|
||||||
|
&-img {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
border-radius: 100%;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@@ -1,20 +1,36 @@
|
|||||||
import "./Avatar.scss";
|
import "./Avatar.scss";
|
||||||
|
|
||||||
import React from "react";
|
import React, { useState } from "react";
|
||||||
|
import { getClientInitials } from "../clients";
|
||||||
|
|
||||||
type AvatarProps = {
|
type AvatarProps = {
|
||||||
children: string;
|
|
||||||
onClick: (e: React.MouseEvent<HTMLDivElement, MouseEvent>) => void;
|
onClick: (e: React.MouseEvent<HTMLDivElement, MouseEvent>) => void;
|
||||||
color: string;
|
color: string;
|
||||||
border: string;
|
border: string;
|
||||||
|
name: string;
|
||||||
|
src?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const Avatar = ({ children, color, border, onClick }: AvatarProps) => (
|
export const Avatar = ({ color, border, onClick, name, src }: AvatarProps) => {
|
||||||
<div
|
const shortName = getClientInitials(name);
|
||||||
className="Avatar"
|
const [error, setError] = useState(false);
|
||||||
style={{ background: color, border: `1px solid ${border}` }}
|
const loadImg = !error && src;
|
||||||
onClick={onClick}
|
const style = loadImg
|
||||||
>
|
? undefined
|
||||||
{children}
|
: { background: color, border: `1px solid ${border}` };
|
||||||
</div>
|
return (
|
||||||
);
|
<div className="Avatar" style={style} onClick={onClick}>
|
||||||
|
{loadImg ? (
|
||||||
|
<img
|
||||||
|
className="Avatar-img"
|
||||||
|
src={src}
|
||||||
|
alt={shortName}
|
||||||
|
referrerPolicy="no-referrer"
|
||||||
|
onError={() => setError(true)}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
shortName
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
@@ -16,10 +16,5 @@ export const BackgroundPickerAndDarkModeToggle = ({
|
|||||||
<div style={{ display: "flex" }}>
|
<div style={{ display: "flex" }}>
|
||||||
{actionManager.renderAction("changeViewBackgroundColor")}
|
{actionManager.renderAction("changeViewBackgroundColor")}
|
||||||
{showThemeBtn && actionManager.renderAction("toggleTheme")}
|
{showThemeBtn && actionManager.renderAction("toggleTheme")}
|
||||||
{appState.fileHandle && (
|
|
||||||
<div style={{ marginInlineStart: "0.25rem" }}>
|
|
||||||
{actionManager.renderAction("saveScene")}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
@@ -1,4 +1,3 @@
|
|||||||
import React from "react";
|
|
||||||
import clsx from "clsx";
|
import clsx from "clsx";
|
||||||
|
|
||||||
// TODO: It might be "clever" to add option.icon to the existing component <ButtonSelect />
|
// TODO: It might be "clever" to add option.icon to the existing component <ButtonSelect />
|
||||||
@@ -8,7 +7,7 @@ export const ButtonIconSelect = <T extends Object>({
|
|||||||
onChange,
|
onChange,
|
||||||
group,
|
group,
|
||||||
}: {
|
}: {
|
||||||
options: { value: T; text: string; icon: JSX.Element }[];
|
options: { value: T; text: string; icon: JSX.Element; testId?: string }[];
|
||||||
value: T | null;
|
value: T | null;
|
||||||
onChange: (value: T) => void;
|
onChange: (value: T) => void;
|
||||||
group: string;
|
group: string;
|
||||||
@@ -25,6 +24,7 @@ export const ButtonIconSelect = <T extends Object>({
|
|||||||
name={group}
|
name={group}
|
||||||
onChange={() => onChange(option.value)}
|
onChange={() => onChange(option.value)}
|
||||||
checked={value === option.value}
|
checked={value === option.value}
|
||||||
|
data-testid={option.testId}
|
||||||
/>
|
/>
|
||||||
{option.icon}
|
{option.icon}
|
||||||
</label>
|
</label>
|
||||||
|
@@ -1,4 +1,3 @@
|
|||||||
import React from "react";
|
|
||||||
import clsx from "clsx";
|
import clsx from "clsx";
|
||||||
|
|
||||||
export const ButtonSelect = <T extends Object>({
|
export const ButtonSelect = <T extends Object>({
|
||||||
|
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user