Compare commits
594 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| fe973e3513 | |||
| ade2565f49 | |||
| c35d983fef | |||
| 69878167c2 | |||
| eb1f717d35 | |||
| 8e9af5c51b | |||
| afe0c760f6 | |||
| a231cefac0 | |||
| cb4c9d16fc | |||
| 7366f089ba | |||
| 7c3513b9df | |||
| aef3644c93 | |||
| 0cf58adb4c | |||
| 3aab81bc35 | |||
| 3b0fb1562d | |||
| 0488b7b5c6 | |||
| b8d13c98b5 | |||
| 6f82a88b79 | |||
| 022f349dc6 | |||
| c1e2146d78 | |||
| 8091ac6c08 | |||
| bc414ccaaf | |||
| 0cf5f1ac1f | |||
| e9cb7ee77c | |||
| 86c036505b | |||
| 39e7b8cf4f | |||
| e0ece680a6 | |||
| 8dfea49ec1 | |||
| d7f314cda8 | |||
| 6adb45ef5a | |||
| b0eeb8e6e6 | |||
| c3c20b6087 | |||
| 0faec7efb6 | |||
| aff817c667 | |||
| 9a3a3ecb44 | |||
| 81f8039ec7 | |||
| 14759d5b72 | |||
| b997e69ebc | |||
| 4a89aba682 | |||
| 34dcf998bd | |||
| 325d1bec91 | |||
| b917e42694 | |||
| eb9e67e36a | |||
| f14ae52e94 | |||
| f93eb658d6 | |||
| 9bac44ee75 | |||
| 91b4109f67 | |||
| 6e45cb95db | |||
| 5edf82898b | |||
| 60b82e3055 | |||
| 5d6590c200 | |||
| d3bebbc68d | |||
| 4ff8f3b006 | |||
| abbc756887 | |||
| 10e07e434c | |||
| fb582b45db | |||
| 23f21434ff | |||
| 7e9fdf85a0 | |||
| 98c26642d0 | |||
| 2b434db062 | |||
| f919907855 | |||
| bfeb3c7dfd | |||
| 8729ab3c54 | |||
| 37f53bccbf | |||
| c783763307 | |||
| f151f45df8 | |||
| f33880e005 | |||
| 29ed50f6ea | |||
| ce52c18382 | |||
| 7c3e1d8d1b | |||
| 0d15934a96 | |||
| a0069d04f0 | |||
| 3b86944365 | |||
| 4c7b1a2269 | |||
| 60864ace54 | |||
| 4b32c03994 | |||
| 222dbdcc00 | |||
| 1346227d30 | |||
| ecbddd214c | |||
| 4999ca5c82 | |||
| 174638889d | |||
| 480998582e | |||
| 94544e458c | |||
| f664ba9e1e | |||
| 015a60638e | |||
| 7abb80530f | |||
| 5abe9b93e8 | |||
| 59cff0f219 | |||
| 81c17a56fb | |||
| 802b8c50d5 | |||
| 94fe1ff6e6 | |||
| 9cfe7b45e5 | |||
| 9cf54041dc | |||
| 8f269eb840 | |||
| 1a134a88bd | |||
| ae15380a9f | |||
| 0efa62cf7c | |||
| c742225f43 | |||
| 7c7fb4903b | |||
| a4e1f2c5c1 | |||
| 068c9b4876 | |||
| b2d442abce | |||
| 7bfe7a1924 | |||
| 48e27f327f | |||
| 182a3e39e1 | |||
| 4672a2a135 | |||
| c7b5cdb71e | |||
| 4d71078f48 | |||
| 38e1a0fd05 | |||
| 8f8fd023f8 | |||
| fba37e422d | |||
| 88fc961559 | |||
| c291edfc44 | |||
| bd8e860d7f | |||
| 3be5038c14 | |||
| 5e57f408c5 | |||
| dd993adc5c | |||
| 5cdb9bd2ed | |||
| d055fc0334 | |||
| fbdf796c9f | |||
| 90867ed9c1 | |||
| 6d6bf52f88 | |||
| aa221837fc | |||
| 4c90ea5667 | |||
| 5071cffb02 | |||
| fb02329c11 | |||
| 6081bb5941 | |||
| 533815c081 | |||
| 2b6d1470f9 | |||
| 545b214558 | |||
| e617ccc252 | |||
| d8a0dc3b4d | |||
| e392bebc40 | |||
| a2132c9bb7 | |||
| 66e5b18e4e | |||
| c43109a230 | |||
| 668150a667 | |||
| 0ef60dce2d | |||
| abde1daba4 | |||
| 014097a97e | |||
| 58fcb44de0 | |||
| 102169581c | |||
| eb75dc55cb | |||
| df33ab23f8 | |||
| e8421bc5ab | |||
| 36980160ae | |||
| 084aff2bf3 | |||
| bdb1fb2dae | |||
| b21fd49412 | |||
| 204c8370a0 | |||
| ca60244aa3 | |||
| 6c0296c434 | |||
| 1269b9ab17 | |||
| 2f9a849170 | |||
| 8d479ab238 | |||
| fec48060f7 | |||
| 2de7f73a71 | |||
| 198106e297 | |||
| d3c3894108 | |||
| 6718902645 | |||
| 38aa6da7a3 | |||
| 2c008c8adf | |||
| a6292a789e | |||
| 5f7d48e551 | |||
| c7831e854d | |||
| 7aa58561c8 | |||
| db5acff860 | |||
| a267fc85b4 | |||
| 7d2ce4e52f | |||
| 5e6ec19ce1 | |||
| 9a38f87147 | |||
| 5e7d09d723 | |||
| 5db77a4e7d | |||
| ca3cf6971d | |||
| a1fbec1030 | |||
| 4371c29f0c | |||
| bf143ed0b8 | |||
| 68aafe31f9 | |||
| b06cf86811 | |||
| 5b829772d9 | |||
| dad9ad9bf4 | |||
| f90cbeb089 | |||
| d2a730837e | |||
| 5b63371c14 | |||
| a05679b3c5 | |||
| 396c49c7d8 | |||
| 0d01738029 | |||
| c5c375dd2e | |||
| b09d96ad14 | |||
| a58873af13 | |||
| 08031d3f85 | |||
| a20f3240fd | |||
| e05acd6fd9 | |||
| 56938cf874 | |||
| 5d295415db | |||
| 2a20c44338 | |||
| dcedd17f57 | |||
| 455badb23e | |||
| 566e6a5ede | |||
| facde7ace0 | |||
| eca2bdabcc | |||
| a7da8901d8 | |||
| 58861e87e5 | |||
| a646a12758 | |||
| 1be4a2d649 | |||
| 25feaefe9e | |||
| 7491fcc3f3 | |||
| 856ab50090 | |||
| 411bc2aa0a | |||
| ba3f548b91 | |||
| 8a50916ef2 | |||
| 471a8b7676 | |||
| 56215c6c2b | |||
| fc58e51ab3 | |||
| 5c26bd19d7 | |||
| 9de6c947ef | |||
| 44af6b4a78 | |||
| a47d372a90 | |||
| f4da7f38d2 | |||
| 2098db901c | |||
| 8621eb0d0c | |||
| fb8be3ba0f | |||
| 348976aa59 | |||
| 2d6a231634 | |||
| 41d4bb1491 | |||
| 7b53d29066 | |||
| 6f5a285266 | |||
| 9e40dcdcdc | |||
| 5e55e77f54 | |||
| f404ab6f50 | |||
| 915bda9fd8 | |||
| 46c421ee26 | |||
| 5168a03373 | |||
| 36700b9376 | |||
| 900e0f27ad | |||
| e916d7f6f6 | |||
| 1845b5e32c | |||
| 72a3450c99 | |||
| 499a60309f | |||
| 1034ec91b8 | |||
| 6db5647048 | |||
| cc5e27af42 | |||
| f7f27bba17 | |||
| adef15862c | |||
| 62b69c3dd7 | |||
| 7c1d0175bf | |||
| fdae50cece | |||
| 31aab2d202 | |||
| 12b73aaac6 | |||
| b50c54f855 | |||
| 1484c5a63b | |||
| 4a26845395 | |||
| 41ccd47791 | |||
| 8f5c5f80d3 | |||
| 538f2be097 | |||
| 25d460be96 | |||
| b3263c2a69 | |||
| e8a39b5f84 | |||
| f40a2230ec | |||
| 5950fa9a40 | |||
| 7618ca48d7 | |||
| b91f929503 | |||
| d20cdbd736 | |||
| dab9659590 | |||
| a0a8397fb4 | |||
| 8b1e0275cf | |||
| d012fda59d | |||
| 63566ecb92 | |||
| 8a10f2a0b8 | |||
| 3835fa60e4 | |||
| 215128ffdf | |||
| d18a72c879 | |||
| ae1ab1ab37 | |||
| e424ca53c6 | |||
| 556f9123f8 | |||
| 1963afe289 | |||
| 6382b82acf | |||
| 823a5697c0 | |||
| 04f2564947 | |||
| 16d3d2fb54 | |||
| 6ca7420252 | |||
| d0985fe67a | |||
| f2135ab739 | |||
| 8ab9ffbe28 | |||
| 6b7516bc3c | |||
| a61b212220 | |||
| 403576861c | |||
| 66c345b732 | |||
| 65d9352648 | |||
| 1404b4b958 | |||
| 22f12352c6 | |||
| adb1ac5788 | |||
| b2822f3538 | |||
| 68bdfaefbe | |||
| 2a2630082f | |||
| e3f3427b31 | |||
| c814917927 | |||
| 8b8adb146f | |||
| 3e404d33fd | |||
| 3d3af88a41 | |||
| d6adfe88bd | |||
| 786d1bba94 | |||
| 31f7e2b8b2 | |||
| 55ecbdcca9 | |||
| 7d7ce04e9b | |||
| fbc4c70ed8 | |||
| 242ccac290 | |||
| b9d584714a | |||
| c6736fa14e | |||
| d07099aadd | |||
| ea020f2c50 | |||
| 48c2a13c7a | |||
| aaddde5dd9 | |||
| 9cac7816cc | |||
| f2401c9163 | |||
| 18759ec133 | |||
| 6319f9b156 | |||
| 47dba05c91 | |||
| 950ec66907 | |||
| 640dcc90c2 | |||
| 15e4b51bb1 | |||
| 924292dc9a | |||
| a693b36d37 | |||
| dd0c44864d | |||
| 6824572d21 | |||
| 7ca9452d80 | |||
| 825aa86016 | |||
| 2d59689436 | |||
| b7e3d98c0f | |||
| 98a4d0a595 | |||
| b215e165d2 | |||
| 730a11e0a5 | |||
| 0ab58b38e0 | |||
| 46bff3dace | |||
| d15444e232 | |||
| 7ebeae2d38 | |||
| 26ef235019 | |||
| 0e28177ccc | |||
| 1828a93ba7 | |||
| 84c49ebaa1 | |||
| 8b9e2a540d | |||
| 4c2d34ffd7 | |||
| b8f8bc2e32 | |||
| 546e13571d | |||
| e7d186b439 | |||
| 4718c31da5 | |||
| 0e0a695e81 | |||
| 86d9bee3f4 | |||
| d58e71d566 | |||
| 37b4883629 | |||
| 3c66335ec1 | |||
| 0470e85da7 | |||
| b39e282ca2 | |||
| 94555287d4 | |||
| e34cf3aee3 | |||
| 960b640e89 | |||
| eda8333c05 | |||
| c3de4cd4c5 | |||
| 643e6bd08d | |||
| ab7073abdb | |||
| 80cbe13167 | |||
| aac83325c5 | |||
| 460ec83ca5 | |||
| 7e1b919fba | |||
| abfc265f01 | |||
| 01a1504cb3 | |||
| 7edffd8e7b | |||
| 036bdcfa3f | |||
| c6e2877418 | |||
| 415bf7bb5b | |||
| 4644ca1778 | |||
| 009eba6315 | |||
| 14317c2232 | |||
| 41cb1fbeba | |||
| 930813387b | |||
| 3f2b0fdd0a | |||
| 2cb8ba6521 | |||
| 5670c47789 | |||
| c0dd870c6e | |||
| c9d5ec9277 | |||
| 6e5b1a1c2a | |||
| 296e3677cf | |||
| a96406f505 | |||
| 36c01eb982 | |||
| bd1a1c966e | |||
| 964746e569 | |||
| 950bcd0b72 | |||
| 85d000ccda | |||
| 8bbeb32e87 | |||
| 26f67d27ec | |||
| 5f195694ee | |||
| 403e8bd307 | |||
| c06988a202 | |||
| 0eff9d525d | |||
| e4f429f1c1 | |||
| 32d5507d41 | |||
| 7f4e50d6de | |||
| 4dfb043331 | |||
| c7b45da85f | |||
| fea257765d | |||
| 818821c293 | |||
| c171fb4c7f | |||
| 20500b7822 | |||
| 54f8d8f820 | |||
| ab980b252c | |||
| 925db9dcca | |||
| c0ca6bae37 | |||
| ee8fa6aaad | |||
| 7eff6893c5 | |||
| 63edbb9517 | |||
| a4ad22bc4d | |||
| a945e16274 | |||
| f0ac606ed7 | |||
| f295550940 | |||
| df4e903bd6 | |||
| a2e7d8d560 | |||
| ebf2923c5e | |||
| 880cac2359 | |||
| d3a38202e3 | |||
| dc1f6c4d4c | |||
| c5d37a07c8 | |||
| 2d8430593d | |||
| 3c52c5bfc2 | |||
| 5a7595cf4e | |||
| 76c36397bc | |||
| 46574713eb | |||
| 365a03e930 | |||
| f9524c79b0 | |||
| f15d62aa44 | |||
| b07aa6e205 | |||
| cf36cb394b | |||
| 29f803e25d | |||
| e2640edb79 | |||
| c1488fa353 | |||
| 494b7d08c5 | |||
| 4cfc8bd4b3 | |||
| f9793835e6 | |||
| bac20fa641 | |||
| e2cc961c76 | |||
| eb3455fc03 | |||
| 8ab7921796 | |||
| 8f3ccac54c | |||
| 77254aa2f7 | |||
| c4a308e4e6 | |||
| 953cd5563c | |||
| 0ee2c15929 | |||
| 6428b59ccb | |||
| 7ab0c1aba8 | |||
| 4ab4fce998 | |||
| 6e357c0291 | |||
| 51a8ab65f3 | |||
| 5664de0459 | |||
| 6cc6e13892 | |||
| 5d7020cce6 | |||
| d5e7d08586 | |||
| 1b9b824c70 | |||
| 6f13b5ac75 | |||
| df5eb3f0d9 | |||
| 6f1cff101a | |||
| eb9a9b628a | |||
| 3a4bc68025 | |||
| 01e546c230 | |||
| ead58bf2d9 | |||
| 9351b2821c | |||
| b1261eea70 | |||
| 84abda82d5 | |||
| 93137c0bb2 | |||
| 30cbe21a47 | |||
| cc52ea4ac2 | |||
| 8621ddb6a2 | |||
| b21f723eee | |||
| d9e84b90ce | |||
| 79c3b846d7 | |||
| d39d8e3cb1 | |||
| 24fe05f023 | |||
| 53d2c67b52 | |||
| 2ee8bb9846 | |||
| 19038d0d7e | |||
| bcf9cc2a5b | |||
| ecc3a72583 | |||
| 89cf826555 | |||
| 2a25480272 | |||
| 9a5ae05bbf | |||
| 54a72b821a | |||
| 8c3549f336 | |||
| e23f7d37b6 | |||
| 483796f6ff | |||
| a679ef7876 | |||
| 0a3fb70ec7 | |||
| 5970bb7ee9 | |||
| 1991511ef7 | |||
| cd87bd6901 | |||
| 9c89504b6f | |||
| 159890860a | |||
| caa9b54893 | |||
| e7ef02cc0f | |||
| 24a385ff73 | |||
| cb13cc36cf | |||
| 5444abc822 | |||
| 76cbefb4ce | |||
| baa8fb6c14 | |||
| b6bf011d0d | |||
| 482fa2d90f | |||
| a357d00bbe | |||
| bda8415714 | |||
| ca87ca6fe9 | |||
| 8f65e37dac | |||
| f1ceeab8d9 | |||
| 046c0818c5 | |||
| 6baa091762 | |||
| 2bab4d2c24 | |||
| 3a4e48e5af | |||
| ed89cfc62c | |||
| ed0d707851 | |||
| 0f15608ad5 | |||
| 2eb58da4c3 | |||
| f0463fadc3 | |||
| 3cee0768cc | |||
| 5d3867d8ac | |||
| 0ed6a96b6a | |||
| dadf054ea2 | |||
| 6b87278a0f | |||
| 998f0ae458 | |||
| 60973f6dc5 | |||
| 53ab46126d | |||
| ebb1341bbd | |||
| 5a5f9aed0d | |||
| bec8e55c42 | |||
| 1df43d0f9f | |||
| d171e9705d | |||
| f7c4efbd35 | |||
| d1be2a5481 | |||
| 4eb6c3e8a4 | |||
| 476c0e9f8a | |||
| c6e73c56fd | |||
| 669e84b5d7 | |||
| ff93d95998 | |||
| 1f375522d6 | |||
| 5ed4614a8c | |||
| a118bed82f | |||
| fd75b88bd3 | |||
| 1e9adf0a80 | |||
| 0190af2d76 | |||
| b0d3f18824 | |||
| b74fa0dcf0 | |||
| 3f31a6ce89 | |||
| d3d9994c74 | |||
| 14a66956d7 | |||
| db316f32e0 | |||
| 9151da772c | |||
| f413bab3de | |||
| 17e9cc4506 | |||
| fa359034c5 | |||
| 0db7ac78c4 | |||
| 44a88d2d58 | |||
| 56f8bc092d | |||
| 4f3bf79708 | |||
| 7edcea9a93 | |||
| 5327e8a3dc | |||
| 5b75925928 | |||
| ce8d88a486 | |||
| 63c10743fb | |||
| 46b574283f | |||
| 61e5b66dac | |||
| 5252726307 | |||
| 5a64447adc | |||
| f1afeda62c | |||
| 1a1cbb345b | |||
| 2867af6528 | |||
| 35049e3de7 | |||
| d315e3dc4d | |||
| 6512ede9ca | |||
| 51608c07b0 | |||
| d2ae18995c | |||
| 22f7945c70 | |||
| ce6f2ff88c | |||
| fb897c75a7 | |||
| 584e4182a7 | |||
| c427aa3cce | |||
| 33fe223b5d | |||
| 8de47ed36b | |||
| cc3abfc6ff | |||
| 4d2e8f9ad1 | |||
| 6b628bb1a6 | |||
| 7f35b805d1 | |||
| 755cd9c320 | |||
| afbfe2b8b1 | |||
| 9bd72f91fc | |||
| 876170ee27 | |||
| 39c56a4c01 | |||
| f6be200388 | |||
| 828c9c4d65 | |||
| d9b8dcfbb4 | |||
| c32640d174 |
@@ -2,6 +2,9 @@
|
|||||||
!public/
|
!public/
|
||||||
!src/
|
!src/
|
||||||
!.npmrc
|
!.npmrc
|
||||||
|
!.eslintrc.json
|
||||||
|
!.prettierrc
|
||||||
!package-lock.json
|
!package-lock.json
|
||||||
!package.json
|
!package.json
|
||||||
!tsconfig.json
|
!tsconfig.json
|
||||||
|
!.env
|
||||||
|
|||||||
@@ -0,0 +1,5 @@
|
|||||||
|
REACT_APP_BACKEND_V1_GET_URL=https://json.excalidraw.com/api/v1/
|
||||||
|
REACT_APP_BACKEND_V2_GET_URL=https://json.excalidraw.com/api/v2/
|
||||||
|
REACT_APP_BACKEND_V2_POST_URL=https://json.excalidraw.com/api/v2/post/
|
||||||
|
REACT_APP_SOCKET_SERVER_URL=https://portal.excalidraw.com
|
||||||
|
REACT_APP_FIREBASE_CONFIG='{"apiKey":"AIzaSyAd15pYlMci_xIp9ko6wkEsDzAAA0Dn0RU","authDomain":"excalidraw-room-persistence.firebaseapp.com","databaseURL":"https://excalidraw-room-persistence.firebaseio.com","projectId":"excalidraw-room-persistence","storageBucket":"excalidraw-room-persistence.appspot.com","messagingSenderId":"654800341332","appId":"1:654800341332:web:4a692de832b55bd57ce0c1"}'
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
REACT_APP_INCLUDE_GTAG=true
|
||||||
@@ -3,3 +3,4 @@ build/
|
|||||||
package-lock.json
|
package-lock.json
|
||||||
.vscode/
|
.vscode/
|
||||||
firebase/
|
firebase/
|
||||||
|
dist/
|
||||||
|
|||||||
+18
-8
@@ -3,6 +3,8 @@
|
|||||||
"plugins": ["prettier"],
|
"plugins": ["prettier"],
|
||||||
"rules": {
|
"rules": {
|
||||||
"curly": "warn",
|
"curly": "warn",
|
||||||
|
"dot-notation": "warn",
|
||||||
|
"import/no-anonymous-default-export": "off",
|
||||||
"no-console": [
|
"no-console": [
|
||||||
"warn",
|
"warn",
|
||||||
{
|
{
|
||||||
@@ -10,7 +12,22 @@
|
|||||||
}
|
}
|
||||||
],
|
],
|
||||||
"no-else-return": "warn",
|
"no-else-return": "warn",
|
||||||
|
"no-lonely-if": "warn",
|
||||||
|
"no-restricted-syntax": [
|
||||||
|
"warn",
|
||||||
|
{
|
||||||
|
"message": "Use 't(...)' instead of literal text in JSX",
|
||||||
|
"selector": "JSXText[value=/\\w/]"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"no-unneeded-ternary": "warn",
|
||||||
|
"no-unused-expressions": "warn",
|
||||||
|
"no-unused-vars": "warn",
|
||||||
"no-useless-return": "warn",
|
"no-useless-return": "warn",
|
||||||
|
"no-var": "warn",
|
||||||
|
"object-shorthand": "warn",
|
||||||
|
"one-var": ["warn", "never"],
|
||||||
|
"prefer-arrow-callback": "warn",
|
||||||
"prefer-const": [
|
"prefer-const": [
|
||||||
"warn",
|
"warn",
|
||||||
{
|
{
|
||||||
@@ -18,13 +35,6 @@
|
|||||||
}
|
}
|
||||||
],
|
],
|
||||||
"prefer-template": "warn",
|
"prefer-template": "warn",
|
||||||
"prettier/prettier": "warn",
|
"prettier/prettier": "warn"
|
||||||
"no-restricted-syntax": [
|
|
||||||
"warn",
|
|
||||||
{
|
|
||||||
"selector": "JSXText[value=/\\w/]",
|
|
||||||
"message": "Use 't(...)' instead of literal text in JSX"
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1 @@
|
|||||||
|
* text=auto eol=lf
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
version: 2
|
||||||
|
updates:
|
||||||
|
- package-ecosystem: npm
|
||||||
|
directory: "/"
|
||||||
|
schedule:
|
||||||
|
interval: weekly
|
||||||
|
day: sunday
|
||||||
|
time: "01:00"
|
||||||
|
open-pull-requests-limit: 99
|
||||||
|
reviewers:
|
||||||
|
- lipis
|
||||||
|
assignees:
|
||||||
|
- lipis
|
||||||
|
|
||||||
|
- package-ecosystem: npm
|
||||||
|
directory: "/src/packages/excalidraw/"
|
||||||
|
schedule:
|
||||||
|
interval: weekly
|
||||||
|
day: sunday
|
||||||
|
time: "01:00"
|
||||||
|
open-pull-requests-limit: 99
|
||||||
|
reviewers:
|
||||||
|
- ad1992
|
||||||
|
assignees:
|
||||||
|
- ad1992
|
||||||
|
|
||||||
|
- package-ecosystem: npm
|
||||||
|
directory: "/src/packages/utils/"
|
||||||
|
schedule:
|
||||||
|
interval: weekly
|
||||||
|
day: sunday
|
||||||
|
time: "01:00"
|
||||||
|
open-pull-requests-limit: 99
|
||||||
|
reviewers:
|
||||||
|
- ad1992
|
||||||
|
assignees:
|
||||||
|
- ad1992
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
name: Build Docker image
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches:
|
||||||
|
- master
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
build-docker:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v1
|
||||||
|
- run: docker build -t excalidraw .
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
name: Build packages
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches:
|
||||||
|
- master
|
||||||
|
pull_request:
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
build:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v1
|
||||||
|
|
||||||
|
- name: Setup Node.js 12.x
|
||||||
|
uses: actions/setup-node@v1
|
||||||
|
with:
|
||||||
|
node-version: 12.x
|
||||||
|
|
||||||
|
- name: Install dependencies
|
||||||
|
run: |
|
||||||
|
npm ci
|
||||||
|
npm ci --prefix src/packages/excalidraw
|
||||||
|
npm ci --prefix src/packages/utils
|
||||||
|
|
||||||
|
- name: Build @excalidraw/excalidraw
|
||||||
|
run: |
|
||||||
|
npm run pack --prefix src/packages/excalidraw
|
||||||
|
|
||||||
|
- name: Build @excalidraw/utils
|
||||||
|
run: |
|
||||||
|
npm run pack --prefix src/packages/utils
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
name: Build locales coverage
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches:
|
||||||
|
- "l10n_master"
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
locales:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v2
|
||||||
|
with:
|
||||||
|
token: ${{ secrets.PUSH_TRANSLATIONS_COVERAGE_PAT }}
|
||||||
|
|
||||||
|
- name: Setup Node.js 12.x
|
||||||
|
uses: actions/setup-node@v1
|
||||||
|
with:
|
||||||
|
node-version: 12.x
|
||||||
|
|
||||||
|
- name: Create report file
|
||||||
|
run: |
|
||||||
|
npm run locales-coverage
|
||||||
|
FILE_CHANGED=$(git diff src/locales/percentages.json)
|
||||||
|
if [ ! -z "${FILE_CHANGED}" ]; then
|
||||||
|
git config --global user.name 'Kostas Bariotis'
|
||||||
|
git config --global user.email 'konmpar@gmail.com'
|
||||||
|
git add src/locales/percentages.json
|
||||||
|
git commit -am "Auto commit: Calculate translation coverage"
|
||||||
|
git push
|
||||||
|
fi
|
||||||
|
- name: Construct comment body
|
||||||
|
id: getCommentBody
|
||||||
|
run: |
|
||||||
|
body=$(npm run locales-coverage:description | grep '^[^>]')
|
||||||
|
body="${body//'%'/'%25'}"
|
||||||
|
body="${body//$'\n'/'%0A'}"
|
||||||
|
body="${body//$'\r'/'%0D'}"
|
||||||
|
echo ::set-output name=body::$body
|
||||||
|
|
||||||
|
- name: Update description with coverage
|
||||||
|
uses: kt3k/update-pr-description@v1.0.1
|
||||||
|
with:
|
||||||
|
pr_body: ${{ steps.getCommentBody.outputs.body }}
|
||||||
|
pr_title: "chore: New Crowdin updates"
|
||||||
|
github_token: ${{ secrets.PUSH_TRANSLATIONS_COVERAGE_PAT }}
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
name: Publish Docker
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches:
|
||||||
|
- master
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
publish-docker:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v2
|
||||||
|
- uses: docker/build-push-action@v1
|
||||||
|
with:
|
||||||
|
username: ${{ secrets.DOCKER_USERNAME }}
|
||||||
|
password: ${{ secrets.DOCKER_PASSWORD }}
|
||||||
|
repository: excalidraw/excalidraw
|
||||||
|
tag_with_ref: true
|
||||||
|
tag_with_sha: true
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
name: "Semantic PR title"
|
||||||
|
|
||||||
|
on:
|
||||||
|
pull_request_target:
|
||||||
|
types:
|
||||||
|
- opened
|
||||||
|
- edited
|
||||||
|
- synchronize
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
main:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: amannn/action-semantic-pull-request@v2.1.0
|
||||||
|
env:
|
||||||
|
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
+11
-4
@@ -1,10 +1,18 @@
|
|||||||
*.log
|
|
||||||
.DS_Store
|
.DS_Store
|
||||||
|
.env.development.local
|
||||||
|
.env.local
|
||||||
|
.env.production.local
|
||||||
|
.env.test.local
|
||||||
.envrc
|
.envrc
|
||||||
.now
|
.eslintcache
|
||||||
|
.idea
|
||||||
|
.vercel
|
||||||
.vscode
|
.vscode
|
||||||
|
*.log
|
||||||
|
*.tgz
|
||||||
build
|
build
|
||||||
firebase/
|
dist
|
||||||
|
firebase
|
||||||
logs
|
logs
|
||||||
node_modules
|
node_modules
|
||||||
npm-debug.log*
|
npm-debug.log*
|
||||||
@@ -12,4 +20,3 @@ static
|
|||||||
yarn-debug.log*
|
yarn-debug.log*
|
||||||
yarn-error.log*
|
yarn-error.log*
|
||||||
yarn.lock
|
yarn.lock
|
||||||
.idea
|
|
||||||
|
|||||||
+1
-1
@@ -1,7 +1,7 @@
|
|||||||
const { CLIEngine } = require("eslint");
|
const { CLIEngine } = require("eslint");
|
||||||
|
|
||||||
// see https://github.com/okonet/lint-staged#how-can-i-ignore-files-from-eslintignore-
|
// see https://github.com/okonet/lint-staged#how-can-i-ignore-files-from-eslintignore-
|
||||||
// for explanation
|
// for explanation
|
||||||
const cli = new CLIEngine({});
|
const cli = new CLIEngine({});
|
||||||
|
|
||||||
module.exports = {
|
module.exports = {
|
||||||
|
|||||||
@@ -0,0 +1,3 @@
|
|||||||
|
## 2020-10-13
|
||||||
|
|
||||||
|
- Added ability to embed scene source into exported PNG/SVG files so you can import the scene from them (open via `Load` button or drag & drop). #2219
|
||||||
+7
-5
@@ -1,16 +1,18 @@
|
|||||||
FROM node:14-alpine AS build
|
FROM node:14-alpine AS build
|
||||||
|
|
||||||
ENV NODE_ENV=production
|
WORKDIR /opt/node_app
|
||||||
WORKDIR /usr/src/app
|
|
||||||
|
|
||||||
COPY package.json package-lock.json ./
|
COPY package.json package-lock.json ./
|
||||||
RUN npm install
|
RUN npm i --no-optional
|
||||||
|
|
||||||
|
ARG REACT_APP_INCLUDE_GTAG=false
|
||||||
|
ARG NODE_ENV=production
|
||||||
|
|
||||||
COPY . .
|
COPY . .
|
||||||
RUN npm run build:app
|
RUN npm run build:app:docker
|
||||||
|
|
||||||
FROM nginx:1.17-alpine
|
FROM nginx:1.17-alpine
|
||||||
|
|
||||||
COPY --from=build /usr/src/app/build /usr/share/nginx/html
|
COPY --from=build /opt/node_app/build /usr/share/nginx/html
|
||||||
|
|
||||||
HEALTHCHECK CMD wget -q -O /dev/null http://localhost || exit 1
|
HEALTHCHECK CMD wget -q -O /dev/null http://localhost || exit 1
|
||||||
|
|||||||
@@ -2,23 +2,30 @@
|
|||||||
<a href="https://excalidraw.com">
|
<a href="https://excalidraw.com">
|
||||||
<img src="./public/og-image.png" alt="Excalidraw logo: Sketch handrawn like diagrams." />
|
<img src="./public/og-image.png" alt="Excalidraw logo: Sketch handrawn like diagrams." />
|
||||||
</a>
|
</a>
|
||||||
<h3>Excalidraw is a whiteboard tool that lets you easily sketch diagrams with a hand-drawn feel.</h3>
|
<h3>Virtual whiteboard for sketching hand-drawn like diagrams.</h3>
|
||||||
<p>
|
<p>
|
||||||
<a href="https://twitter.com/Excalidraw">
|
<a href="https://twitter.com/Excalidraw">
|
||||||
<img alt="Follow Excalidraw on Twitter" src="https://img.shields.io/twitter/follow/excalidraw.svg?label=follow+excalidraw&style=social&logo=twitter">
|
<img alt="Follow Excalidraw on Twitter" src="https://img.shields.io/twitter/follow/excalidraw.svg?label=follow+excalidraw&style=social&logo=twitter">
|
||||||
</a>
|
</a>
|
||||||
<a title="Crowdin" target="_blank" href="https://crowdin.com/project/excalidraw">
|
<a target="_blank" href="https://crowdin.com/project/excalidraw">
|
||||||
<img src="https://badges.crowdin.net/excalidraw/localized.svg">
|
<img src="https://badges.crowdin.net/excalidraw/localized.svg">
|
||||||
</a>
|
</a>
|
||||||
|
<a target="_blank" href="https://hub.docker.com/r/excalidraw/excalidraw">
|
||||||
|
<img src="https://img.shields.io/docker/pulls/excalidraw/excalidraw">
|
||||||
|
</a>
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
## Try it now
|
## Try it now
|
||||||
|
|
||||||
Go to https://excalidraw.com to start sketching.
|
Go to [excalidraw.com](https://excalidraw.com) to start sketching.
|
||||||
|
|
||||||
Read our [blog](https://blog.excalidraw.com) and follow the [guides](https://howto.excalidraw.com) to learn more about Excalidraw and how to use it effectively.
|
Read our [blog](https://blog.excalidraw.com) and follow the [guides](https://howto.excalidraw.com) to learn more about Excalidraw and how to use it effectively.
|
||||||
|
|
||||||
|
## Shape libraries
|
||||||
|
|
||||||
|
Find a growing list of libraries containing assets for your drawings at [libraries.excalidraw.com](https://libraries.excalidraw.com).
|
||||||
|
|
||||||
## Run the code
|
## Run the code
|
||||||
|
|
||||||
### Code Sandbox
|
### Code Sandbox
|
||||||
@@ -48,24 +55,28 @@ git clone https://github.com/excalidraw/excalidraw.git
|
|||||||
| `npm run test:update` | Update test snapshots |
|
| `npm run test:update` | Update test snapshots |
|
||||||
| `npm run test:code` | Test for formatting with Prettier |
|
| `npm run test:code` | Test for formatting with Prettier |
|
||||||
|
|
||||||
### Docker Installation
|
|
||||||
|
|
||||||
A production-ready version for deploying to e.g. Kubernetes or OpenShift can be built using Docker.
|
|
||||||
|
|
||||||
#### Docker Compose
|
#### Docker Compose
|
||||||
|
|
||||||
|
You can use docker-compose to work on excalidraw locally if you don't want to setup a Node.js env.
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
docker-compose up --build -d
|
docker-compose up --build -d
|
||||||
```
|
```
|
||||||
|
|
||||||
#### Native Docker
|
## Self hosting
|
||||||
|
|
||||||
|
We publish a Docker image with the Excalidraw client at [excalidraw/excalidraw](https://hub.docker.com/r/excalidraw/excalidraw). You can use it to self host your own client under your own domain, on Kubernetes, AWS ECS, etc.
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
docker build -t excalidraw/excalidraw .
|
docker build -t excalidraw/excalidraw .
|
||||||
docker run --rm -dit --name excalidraw -p 5000:80 excalidraw/excalidraw:latest
|
docker run --rm -dit --name excalidraw -p 5000:80 excalidraw/excalidraw:latest
|
||||||
```
|
```
|
||||||
|
|
||||||
After building the image and running the container, open <http://localhost:5000> to see the application.
|
The Docker image is free of analytics and other tracking libraries.
|
||||||
|
|
||||||
|
**At the moment, self-hosting your own instance doesn't support sharing or collaboration features.**
|
||||||
|
|
||||||
|
We are working towards providing a full-fledged solution for self hosting your own Excalidraw.
|
||||||
|
|
||||||
## Contributing
|
## Contributing
|
||||||
|
|
||||||
@@ -75,11 +86,13 @@ Pull requests are welcome. For major changes, please [open an issue](https://git
|
|||||||
|
|
||||||
To translate Excalidraw into other languages, please visit [our Crowdin page](https://crowdin.com/project/excalidraw). To add a new language, [open an issue](https://github.com/excalidraw/excalidraw/issues/new) so we can get things set up on our end first.
|
To translate Excalidraw into other languages, please visit [our Crowdin page](https://crowdin.com/project/excalidraw). To add a new language, [open an issue](https://github.com/excalidraw/excalidraw/issues/new) so we can get things set up on our end first.
|
||||||
|
|
||||||
|
Translations will be available on the app if they exceed a certain threshold of completion (currently 85%).
|
||||||
|
|
||||||
## Excalidraw is built using these awesome tools
|
## Excalidraw is built using these awesome tools
|
||||||
|
|
||||||
- [React](https://reactjs.org)
|
- [React](https://reactjs.org)
|
||||||
- [Rough.js](https://roughjs.com)
|
- [Rough.js](https://roughjs.com)
|
||||||
- [TypeScript](https://typescriptlang.org)
|
- [TypeScript](https://www.typescriptlang.org)
|
||||||
- [Vercel](https://vercel.com)
|
- [Vercel](https://vercel.com)
|
||||||
|
|
||||||
And the main source of inspiration for starting the project is the awesome [Zwibbler](https://zwibbler.com/demo/) app.
|
And the main source of inspiration for starting the project is the awesome [Zwibbler](https://zwibbler.com/demo/) app.
|
||||||
|
|||||||
@@ -0,0 +1,64 @@
|
|||||||
|
| Excalidraw | Category | Name | Label | Value |
|
||||||
|
| ----------------------- | -------- | ---------------------------------- | ------------------------------- | --------- |
|
||||||
|
| Shape / Selection | shape | selection, rectangle, diamond, etc | `toolbar` or `shortcut` |
|
||||||
|
| Text on double click | shape | text | `double-click` |
|
||||||
|
| Lock selection | shape | lock | `on` or `off` |
|
||||||
|
| Clear canvas | action | clear canvas |
|
||||||
|
| Zoom in | action | zoom | in | `zoom` |
|
||||||
|
| Zoom out | action | zoom | out | `zoom` |
|
||||||
|
| Zoom fit | action | zoom | fit | `zoom` |
|
||||||
|
| Zoom reset | action | zoom | reset | `zoom` |
|
||||||
|
| Scroll back to content | action | scroll to content |
|
||||||
|
| Load file | io | load | `MIME type` |
|
||||||
|
| Import from URL | io | import |
|
||||||
|
| Save | io | save |
|
||||||
|
| Save as | io | save as |
|
||||||
|
| Export to backend | io | export | backend |
|
||||||
|
| Export as SVG | io | export | `svg` or `clipboard-svg` |
|
||||||
|
| Export to PNG | io | export | `png` or `clipboard-png` |
|
||||||
|
| Canvas color | change | canvas color | `color` |
|
||||||
|
| Background color | change | background color | `color` |
|
||||||
|
| Stroke color | change | stroke color | `color` |
|
||||||
|
| Stroke width | change | stroke | width | `width` |
|
||||||
|
| Stroke style | change | style | `solid` or `dashed` or `dotted` |
|
||||||
|
| Stroke sloppiness | change | stroke | sloppiness | `value` |
|
||||||
|
| Fill | change | fill | `value` |
|
||||||
|
| Edge | change | edge | `value` |
|
||||||
|
| Opacity | change | opacity | value | `opacity` |
|
||||||
|
| Project name | change | title |
|
||||||
|
| Theme | change | theme | `light` or `dark` |
|
||||||
|
| Change language | change | language | `language` |
|
||||||
|
| Send to back | layer | move | `back` |
|
||||||
|
| Send backward | layer | move | `down` |
|
||||||
|
| Bring to front | layer | move | `front` |
|
||||||
|
| Bring forward | layer | move | `up` |
|
||||||
|
| Align left | align | align | `left` |
|
||||||
|
| Align right | align | align | `right` |
|
||||||
|
| Align top | align | align | `top` |
|
||||||
|
| Align bottom | align | align | `bottom` |
|
||||||
|
| Center horizontally | align | horizontally | `center` |
|
||||||
|
| Center vertically | align | vertically | `center` |
|
||||||
|
| Distribute horizontally | align | distribute | `horizontally` |
|
||||||
|
| Distribute vertically | align | distribute | `vertically` |
|
||||||
|
| Start session | share | session start |
|
||||||
|
| Join session | share | session join |
|
||||||
|
| Start end | share | session end |
|
||||||
|
| Copy room link | share | copy link |
|
||||||
|
| Go to collaborator | share | go to collaborator |
|
||||||
|
| Change name | share | name |
|
||||||
|
| Add to library | library | add |
|
||||||
|
| Remove from library | library | remove |
|
||||||
|
| Load library | library | load |
|
||||||
|
| Save library | library | save |
|
||||||
|
| Import library | library | import |
|
||||||
|
| Shortcuts dialog | dialog | shortcuts |
|
||||||
|
| Collaboration dialog | dialog | collaboration |
|
||||||
|
| Export dialog | dialog | export |
|
||||||
|
| Library dialog | dialog | library |
|
||||||
|
| E2EE shield | exit | e2ee shield |
|
||||||
|
| GitHub corner | exit | github |
|
||||||
|
| Excalidraw blog | exit | blog |
|
||||||
|
| Excalidraw guides | exit | guides |
|
||||||
|
| File issues | exit | issues |
|
||||||
|
| First load | load | first load |
|
||||||
|
| Load from stroage | load | storage | size | `bytes` |
|
||||||
+19
-3
@@ -1,9 +1,25 @@
|
|||||||
version: "3"
|
version: "3.8"
|
||||||
|
|
||||||
services:
|
services:
|
||||||
excalidraw:
|
excalidraw:
|
||||||
build: .
|
build:
|
||||||
|
context: .
|
||||||
|
args:
|
||||||
|
- NODE_ENV=development
|
||||||
container_name: excalidraw
|
container_name: excalidraw
|
||||||
ports:
|
ports:
|
||||||
- "5000:80"
|
- "3000:80"
|
||||||
restart: on-failure
|
restart: on-failure
|
||||||
|
stdin_open: true
|
||||||
|
healthcheck:
|
||||||
|
disable: true
|
||||||
|
environment:
|
||||||
|
- NODE_ENV=development
|
||||||
|
volumes:
|
||||||
|
- ./:/opt/node_app/app:delegated
|
||||||
|
- ./package.json:/opt/node_app/package.json
|
||||||
|
- ./package-lock.json:/opt/node_app/package-lock.json
|
||||||
|
- notused:/opt/node_app/app/node_modules
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
notused:
|
||||||
|
|||||||
@@ -0,0 +1,5 @@
|
|||||||
|
{
|
||||||
|
"projects": {
|
||||||
|
"default": "excalidraw-room-persistence"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,66 @@
|
|||||||
|
# Logs
|
||||||
|
logs
|
||||||
|
*.log
|
||||||
|
npm-debug.log*
|
||||||
|
yarn-debug.log*
|
||||||
|
yarn-error.log*
|
||||||
|
firebase-debug.log*
|
||||||
|
firebase-debug.*.log*
|
||||||
|
|
||||||
|
# Firebase cache
|
||||||
|
.firebase/
|
||||||
|
|
||||||
|
# Firebase config
|
||||||
|
|
||||||
|
# Uncomment this if you'd like others to create their own Firebase project.
|
||||||
|
# For a team working on the same Firebase project(s), it is recommended to leave
|
||||||
|
# it commented so all members can deploy to the same project(s) in .firebaserc.
|
||||||
|
# .firebaserc
|
||||||
|
|
||||||
|
# Runtime data
|
||||||
|
pids
|
||||||
|
*.pid
|
||||||
|
*.seed
|
||||||
|
*.pid.lock
|
||||||
|
|
||||||
|
# Directory for instrumented libs generated by jscoverage/JSCover
|
||||||
|
lib-cov
|
||||||
|
|
||||||
|
# Coverage directory used by tools like istanbul
|
||||||
|
coverage
|
||||||
|
|
||||||
|
# nyc test coverage
|
||||||
|
.nyc_output
|
||||||
|
|
||||||
|
# Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files)
|
||||||
|
.grunt
|
||||||
|
|
||||||
|
# Bower dependency directory (https://bower.io/)
|
||||||
|
bower_components
|
||||||
|
|
||||||
|
# node-waf configuration
|
||||||
|
.lock-wscript
|
||||||
|
|
||||||
|
# Compiled binary addons (http://nodejs.org/api/addons.html)
|
||||||
|
build/Release
|
||||||
|
|
||||||
|
# Dependency directories
|
||||||
|
node_modules/
|
||||||
|
|
||||||
|
# Optional npm cache directory
|
||||||
|
.npm
|
||||||
|
|
||||||
|
# Optional eslint cache
|
||||||
|
.eslintcache
|
||||||
|
|
||||||
|
# Optional REPL history
|
||||||
|
.node_repl_history
|
||||||
|
|
||||||
|
# Output of 'npm pack'
|
||||||
|
*.tgz
|
||||||
|
|
||||||
|
# Yarn Integrity file
|
||||||
|
.yarn-integrity
|
||||||
|
|
||||||
|
# dotenv environment variables file
|
||||||
|
.env
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
{
|
||||||
|
"firestore": {
|
||||||
|
"rules": "firestore.rules",
|
||||||
|
"indexes": "firestore.indexes.json"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
{
|
||||||
|
"indexes": [],
|
||||||
|
"fieldOverrides": []
|
||||||
|
}
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
rules_version = '2';
|
||||||
|
service cloud.firestore {
|
||||||
|
match /databases/{database}/documents {
|
||||||
|
match /{document=**} {
|
||||||
|
allow get, write: if true;
|
||||||
|
// never set this to true, otherwise anyone can delete anyone else's drawing.
|
||||||
|
allow list: if false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Generated
+12182
-6089
File diff suppressed because it is too large
Load Diff
+45
-34
@@ -19,41 +19,47 @@
|
|||||||
]
|
]
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@sentry/browser": "5.15.5",
|
"@sentry/browser": "5.29.2",
|
||||||
"@sentry/integrations": "5.15.5",
|
"@sentry/integrations": "5.29.2",
|
||||||
"@testing-library/jest-dom": "5.7.0",
|
"@testing-library/jest-dom": "5.11.8",
|
||||||
"@testing-library/react": "10.0.4",
|
"@testing-library/react": "11.2.2",
|
||||||
"@types/jest": "25.2.1",
|
"@types/jest": "26.0.19",
|
||||||
"@types/nanoid": "2.1.0",
|
"@types/nanoid": "2.1.0",
|
||||||
"@types/react": "16.9.35",
|
"@types/react": "17.0.0",
|
||||||
"@types/react-dom": "16.9.8",
|
"@types/react-dom": "17.0.0",
|
||||||
"@types/socket.io-client": "1.4.32",
|
"@types/socket.io-client": "1.4.34",
|
||||||
"browser-nativefs": "0.7.3",
|
"browser-nativefs": "0.12.0",
|
||||||
"i18next-browser-languagedetector": "4.1.1",
|
"clsx": "1.1.1",
|
||||||
|
"firebase": "8.2.1",
|
||||||
|
"i18next-browser-languagedetector": "6.0.1",
|
||||||
"lodash.throttle": "4.1.1",
|
"lodash.throttle": "4.1.1",
|
||||||
"nanoid": "2.1.11",
|
"nanoid": "2.1.11",
|
||||||
"node-sass": "4.14.1",
|
"node-sass": "4.14.1",
|
||||||
"open-color": "1.7.0",
|
"open-color": "1.7.0",
|
||||||
|
"pako": "1.0.11",
|
||||||
|
"png-chunk-text": "1.0.0",
|
||||||
|
"png-chunks-encode": "1.0.0",
|
||||||
|
"png-chunks-extract": "1.0.0",
|
||||||
"points-on-curve": "0.2.0",
|
"points-on-curve": "0.2.0",
|
||||||
"pwacompat": "2.0.12",
|
"pwacompat": "2.0.17",
|
||||||
"react": "16.13.1",
|
"react": "17.0.1",
|
||||||
"react-dom": "16.13.1",
|
"react-dom": "17.0.1",
|
||||||
"react-scripts": "3.4.1",
|
"react-scripts": "4.0.1",
|
||||||
"roughjs": "4.3.1",
|
"roughjs": "4.3.1",
|
||||||
"socket.io-client": "2.3.0",
|
"socket.io-client": "2.3.1",
|
||||||
"typescript": "3.8.3"
|
"typescript": "4.1.3"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/lodash.throttle": "4.1.6",
|
"@types/lodash.throttle": "4.1.6",
|
||||||
"asar": "3.0.3",
|
"@types/pako": "1.0.1",
|
||||||
"eslint": "6.8.0",
|
"eslint-config-prettier": "7.1.0",
|
||||||
"eslint-config-prettier": "6.11.0",
|
"eslint-plugin-prettier": "3.3.0",
|
||||||
"eslint-plugin-prettier": "3.1.3",
|
"firebase-tools": "9.1.0",
|
||||||
"husky": "4.2.5",
|
"husky": "4.3.6",
|
||||||
"jest-canvas-mock": "2.2.0",
|
"jest-canvas-mock": "2.3.0",
|
||||||
"lint-staged": "10.2.2",
|
"lint-staged": "10.5.3",
|
||||||
"pepjs": "0.5.2",
|
"pepjs": "0.5.3",
|
||||||
"prettier": "2.0.5",
|
"prettier": "2.2.1",
|
||||||
"rewire": "5.0.0"
|
"rewire": "5.0.0"
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
@@ -68,27 +74,32 @@
|
|||||||
"jest": {
|
"jest": {
|
||||||
"transformIgnorePatterns": [
|
"transformIgnorePatterns": [
|
||||||
"node_modules/(?!(roughjs|points-on-curve|path-data-parser|points-on-path|browser-nativefs)/)"
|
"node_modules/(?!(roughjs|points-on-curve|path-data-parser|points-on-path|browser-nativefs)/)"
|
||||||
]
|
],
|
||||||
|
"resetMocks": false
|
||||||
},
|
},
|
||||||
|
"name": "excalidraw",
|
||||||
"private": true,
|
"private": true,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"build": "npm run build:app && npm run build:zip",
|
|
||||||
"build-node": "node ./scripts/build-node.js",
|
"build-node": "node ./scripts/build-node.js",
|
||||||
"build:app": "REACT_APP_GIT_SHA=$NOW_GITHUB_COMMIT_SHA react-scripts build",
|
"build:app:docker": "REACT_APP_INCLUDE_GTAG=false REACT_APP_DISABLE_SENTRY=true react-scripts build",
|
||||||
"build:zip": "node ./scripts/build-version.js",
|
"build:app": "REACT_APP_INCLUDE_GTAG=true REACT_APP_GIT_SHA=$NOW_GITHUB_COMMIT_SHA react-scripts build",
|
||||||
|
"build:version": "node ./scripts/build-version.js",
|
||||||
|
"build": "npm run build:app && npm run build:version",
|
||||||
"eject": "react-scripts eject",
|
"eject": "react-scripts eject",
|
||||||
"fix": "npm run fix:other && npm run fix:code",
|
|
||||||
"fix:code": "npm run test:code -- --fix",
|
"fix:code": "npm run test:code -- --fix",
|
||||||
"fix:other": "npm run prettier -- --write",
|
"fix:other": "npm run prettier -- --write",
|
||||||
|
"fix": "npm run fix:other && npm run fix:code",
|
||||||
|
"locales-coverage": "node scripts/build-locales-coverage.js",
|
||||||
|
"locales-coverage:description": "node scripts/locales-coverage-description.js",
|
||||||
"prettier": "prettier \"**/*.{css,scss,json,md,html,yml}\" --ignore-path=.eslintignore",
|
"prettier": "prettier \"**/*.{css,scss,json,md,html,yml}\" --ignore-path=.eslintignore",
|
||||||
"start": "react-scripts start",
|
"start": "react-scripts start",
|
||||||
"test": "npm run test:app",
|
|
||||||
"test:all": "npm run test:typecheck && npm run test:code && npm run test:other && npm run test:app -- --watchAll=false",
|
"test:all": "npm run test:typecheck && npm run test:code && npm run test:other && npm run test:app -- --watchAll=false",
|
||||||
"test:update": "npm run test:app -- --updateSnapshot --watchAll=false",
|
"test:app": "react-scripts test --passWithNoTests",
|
||||||
"test:app": "react-scripts test --env=jsdom --passWithNoTests",
|
|
||||||
"test:code": "eslint --max-warnings=0 --ignore-path .gitignore --ext .js,.ts,.tsx .",
|
"test:code": "eslint --max-warnings=0 --ignore-path .gitignore --ext .js,.ts,.tsx .",
|
||||||
"test:debug": "react-scripts --inspect-brk test --runInBand --no-cache",
|
"test:debug": "react-scripts --inspect-brk test --runInBand --no-cache",
|
||||||
"test:other": "npm run prettier -- --list-different",
|
"test:other": "npm run prettier -- --list-different",
|
||||||
"test:typecheck": "tsc"
|
"test:typecheck": "tsc",
|
||||||
|
"test:update": "npm run test:app -- --updateSnapshot --watchAll=false",
|
||||||
|
"test": "npm run test:app"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+55
-47
@@ -13,18 +13,6 @@
|
|||||||
|
|
||||||
<meta name="theme-color" content="#000" />
|
<meta name="theme-color" content="#000" />
|
||||||
|
|
||||||
<!-- Origin Trial token for the Native File System API v1 https://developers.chrome.com/origintrials/#/view_trial/3868592079911256065 (Chrome 78–81) -->
|
|
||||||
<meta
|
|
||||||
http-equiv="origin-trial"
|
|
||||||
content="AoGjY+6r8OQZ5c0AXpK+bbca0pJdCTSHWFqSFNulxiW4OwFBB63kHdDHNo433GeuEOir8IvSovR0LOZLfPnEDAUAAABceyJvcmlnaW4iOiJodHRwczovL3d3dy5leGNhbGlkcmF3LmNvbTo0NDMiLCJmZWF0dXJlIjoiTmF0aXZlRmlsZVN5c3RlbSIsImV4cGlyeSI6MTU4OTMyNzk5OX0="
|
|
||||||
/>
|
|
||||||
|
|
||||||
<!-- Origin Trial token for the Native File System API v2 https://developers.chrome.com/origintrials/#/view_trial/4019462667428167681 (Chrome 83–85) -->
|
|
||||||
<meta
|
|
||||||
http-equiv="origin-trial"
|
|
||||||
content="AgMee3sqSZkE0QaZP8f/F9OJj5iSLdnNMRGttIDlOQy552MI4GoL41jyCAHOYsQ8UWM1kPdrb6PVmbSllX/JqwEAAABZeyJvcmlnaW4iOiJodHRwczovL2V4Y2FsaWRyYXcuY29tOjQ0MyIsImZlYXR1cmUiOiJOYXRpdmVGaWxlU3lzdGVtMiIsImV4cGlyeSI6MTU5MDU3MzM5MX0="
|
|
||||||
/>
|
|
||||||
|
|
||||||
<!-- General tags -->
|
<!-- General tags -->
|
||||||
<meta
|
<meta
|
||||||
name="description"
|
name="description"
|
||||||
@@ -66,7 +54,9 @@
|
|||||||
<!-- OG tags require absolute url for images -->
|
<!-- OG tags require absolute url for images -->
|
||||||
<meta name="twitter:image" content="https://excalidraw.com/og-image.png" />
|
<meta name="twitter:image" content="https://excalidraw.com/og-image.png" />
|
||||||
<link rel="shortcut icon" href="favicon.ico" type="image/x-icon" />
|
<link rel="shortcut icon" href="favicon.ico" type="image/x-icon" />
|
||||||
<link rel="stylesheet" href="fonts.css" type="text/css" />
|
|
||||||
|
<!-- Excalidraw version -->
|
||||||
|
<meta name="version" content="{version}" />
|
||||||
<link
|
<link
|
||||||
rel="preload"
|
rel="preload"
|
||||||
href="FG_Virgil.woff2"
|
href="FG_Virgil.woff2"
|
||||||
@@ -83,7 +73,7 @@
|
|||||||
/>
|
/>
|
||||||
|
|
||||||
<link
|
<link
|
||||||
href="https://excalidraw-socket.herokuapp.com/socket.io"
|
href="%REACT_APP_SOCKET_SERVER_URL%/socket.io"
|
||||||
rel="preconnect"
|
rel="preconnect"
|
||||||
crossorigin="anonymous"
|
crossorigin="anonymous"
|
||||||
/>
|
/>
|
||||||
@@ -91,38 +81,12 @@
|
|||||||
<link
|
<link
|
||||||
rel="manifest"
|
rel="manifest"
|
||||||
href="manifest.json"
|
href="manifest.json"
|
||||||
style="--pwacompat-splash-font: 24px Virgil;"
|
style="--pwacompat-splash-font: 24px Virgil"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<style>
|
<link rel="stylesheet" href="fonts.css" type="text/css" />
|
||||||
.LoadingMessage {
|
|
||||||
position: fixed;
|
<% if (process.env.REACT_APP_INCLUDE_GTAG === 'true') { %>
|
||||||
top: 0;
|
|
||||||
right: 0;
|
|
||||||
bottom: 0;
|
|
||||||
left: 0;
|
|
||||||
z-index: 999;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
pointer-events: none;
|
|
||||||
}
|
|
||||||
.LoadingMessage span {
|
|
||||||
background-color: rgba(255, 255, 255, 0.8);
|
|
||||||
border-radius: 5px;
|
|
||||||
padding: 0.8em 1.2em;
|
|
||||||
font-size: 1.3em;
|
|
||||||
}
|
|
||||||
.visually-hidden {
|
|
||||||
position: absolute !important;
|
|
||||||
height: 1px;
|
|
||||||
width: 1px;
|
|
||||||
overflow: hidden;
|
|
||||||
clip: rect(1px 1px 1px 1px); /* IE6, IE7 */
|
|
||||||
clip: rect(1px, 1px, 1px, 1px);
|
|
||||||
white-space: nowrap; /* added line */
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
<script
|
<script
|
||||||
async
|
async
|
||||||
src="https://www.googletagmanager.com/gtag/js?id=UA-387204-13"
|
src="https://www.googletagmanager.com/gtag/js?id=UA-387204-13"
|
||||||
@@ -135,12 +99,56 @@
|
|||||||
gtag("js", new Date());
|
gtag("js", new Date());
|
||||||
gtag("config", "UA-387204-13");
|
gtag("config", "UA-387204-13");
|
||||||
</script>
|
</script>
|
||||||
|
<% } %>
|
||||||
|
|
||||||
|
<!-- FIXME: remove this when we update CRA (fix SW caching) -->
|
||||||
|
<style>
|
||||||
|
body {
|
||||||
|
margin: 0;
|
||||||
|
--ui-font: system-ui, BlinkMacSystemFont, -apple-system, Segoe UI,
|
||||||
|
Roboto, Helvetica, Arial, sans-serif;
|
||||||
|
font-family: var(--ui-font);
|
||||||
|
-webkit-text-size-adjust: 100%;
|
||||||
|
-webkit-user-select: none;
|
||||||
|
user-select: none;
|
||||||
|
width: 100vw;
|
||||||
|
height: 100vh;
|
||||||
|
}
|
||||||
|
|
||||||
|
.visually-hidden {
|
||||||
|
position: absolute !important;
|
||||||
|
height: 1px;
|
||||||
|
width: 1px;
|
||||||
|
overflow: hidden;
|
||||||
|
clip: rect(1px, 1px, 1px, 1px);
|
||||||
|
white-space: nowrap; /* added line */
|
||||||
|
}
|
||||||
|
|
||||||
|
.LoadingMessage {
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
right: 0;
|
||||||
|
bottom: 0;
|
||||||
|
left: 0;
|
||||||
|
z-index: 999;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.LoadingMessage span {
|
||||||
|
background-color: var(--button-gray-1);
|
||||||
|
border-radius: 5px;
|
||||||
|
padding: 0.8em 1.2em;
|
||||||
|
color: var(--popup-text-color);
|
||||||
|
font-size: 1.3em;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
</head>
|
</head>
|
||||||
|
|
||||||
<body>
|
<body>
|
||||||
<noscript>
|
<noscript> You need to enable JavaScript to run this app. </noscript>
|
||||||
You need to enable JavaScript to run this app.
|
|
||||||
</noscript>
|
|
||||||
<header>
|
<header>
|
||||||
<h1 class="visually-hidden">Excalidraw</h1>
|
<h1 class="visually-hidden">Excalidraw</h1>
|
||||||
</header>
|
</header>
|
||||||
|
|||||||
+11
-2
@@ -14,8 +14,17 @@
|
|||||||
"sizes": "256x256"
|
"sizes": "256x256"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"start_url": ".",
|
"start_url": "/",
|
||||||
"display": "standalone",
|
"display": "standalone",
|
||||||
"theme_color": "#000000",
|
"theme_color": "#000000",
|
||||||
"background_color": "#ffffff"
|
"background_color": "#ffffff",
|
||||||
|
"file_handlers": [
|
||||||
|
{
|
||||||
|
"action": "/",
|
||||||
|
"accept": {
|
||||||
|
"application/vnd.excalidraw+json": [".excalidraw"]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"capture_links": "new_client"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,3 @@
|
|||||||
|
user-agent: *
|
||||||
|
Allow: /$
|
||||||
|
Disallow: /
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
const { readdirSync, writeFileSync } = require("fs");
|
||||||
|
const files = readdirSync(`${__dirname}/../src/locales`);
|
||||||
|
|
||||||
|
const flatten = (object) =>
|
||||||
|
Object.keys(object).reduce(
|
||||||
|
(initial, current) => ({ ...initial, ...object[current] }),
|
||||||
|
{},
|
||||||
|
);
|
||||||
|
|
||||||
|
const locales = files.filter(
|
||||||
|
(file) => file !== "README.md" && file !== "percentages.json",
|
||||||
|
);
|
||||||
|
|
||||||
|
const percentages = {};
|
||||||
|
|
||||||
|
for (let index = 0; index < locales.length; index++) {
|
||||||
|
const currentLocale = locales[index];
|
||||||
|
const data = flatten(require(`${__dirname}/../src/locales/${currentLocale}`));
|
||||||
|
|
||||||
|
const allKeys = Object.keys(data);
|
||||||
|
const translatedKeys = allKeys.filter((item) => data[item] !== "");
|
||||||
|
|
||||||
|
const percentage = (100 * translatedKeys.length) / allKeys.length;
|
||||||
|
|
||||||
|
percentages[currentLocale.replace(".json", "")] = parseInt(percentage);
|
||||||
|
}
|
||||||
|
|
||||||
|
writeFileSync(
|
||||||
|
`${__dirname}/../src/locales/percentages.json`,
|
||||||
|
`${JSON.stringify(percentages, null, 2)}\n`,
|
||||||
|
"utf8",
|
||||||
|
);
|
||||||
@@ -9,9 +9,9 @@
|
|||||||
// node build/static/js/build-node.js
|
// node build/static/js/build-node.js
|
||||||
// open test.png
|
// open test.png
|
||||||
|
|
||||||
var rewire = require("rewire");
|
const rewire = require("rewire");
|
||||||
var defaults = rewire("react-scripts/scripts/build.js");
|
const defaults = rewire("react-scripts/scripts/build.js");
|
||||||
var config = defaults.__get__("config");
|
const config = defaults.__get__("config");
|
||||||
|
|
||||||
// Disable multiple chunks
|
// Disable multiple chunks
|
||||||
config.optimization.runtimeChunk = false;
|
config.optimization.runtimeChunk = false;
|
||||||
@@ -29,7 +29,7 @@ config.entry = "./src/index-node";
|
|||||||
// By default, webpack is going to replace the require of the canvas.node file
|
// By default, webpack is going to replace the require of the canvas.node file
|
||||||
// to just a string with the path of the canvas.node file. We need to tell
|
// to just a string with the path of the canvas.node file. We need to tell
|
||||||
// webpack to avoid rewriting that dependency.
|
// webpack to avoid rewriting that dependency.
|
||||||
config.externals = function (context, request, callback) {
|
config.externals = (context, request, callback) => {
|
||||||
if (/\.node$/.test(request)) {
|
if (/\.node$/.test(request)) {
|
||||||
return callback(
|
return callback(
|
||||||
null,
|
null,
|
||||||
|
|||||||
@@ -2,7 +2,8 @@
|
|||||||
|
|
||||||
const fs = require("fs");
|
const fs = require("fs");
|
||||||
const path = require("path");
|
const path = require("path");
|
||||||
const asar = require("asar");
|
const versionFile = path.join("build", "version.json");
|
||||||
|
const indexFile = path.join("build", "index.html");
|
||||||
|
|
||||||
const zero = (digit) => `0${digit}`.slice(-2);
|
const zero = (digit) => `0${digit}`.slice(-2);
|
||||||
|
|
||||||
@@ -20,18 +21,24 @@ const now = new Date();
|
|||||||
|
|
||||||
const data = JSON.stringify(
|
const data = JSON.stringify(
|
||||||
{
|
{
|
||||||
asar: `excalidraw.asar`,
|
|
||||||
version: versionDate(now),
|
version: versionDate(now),
|
||||||
},
|
},
|
||||||
undefined,
|
undefined,
|
||||||
2,
|
2,
|
||||||
);
|
);
|
||||||
|
|
||||||
fs.writeFileSync(path.join("build", "version.json"), data);
|
fs.writeFileSync(versionFile, data);
|
||||||
|
|
||||||
(async () => {
|
// https://stackoverflow.com/a/14181136/8418
|
||||||
const src = "build/";
|
fs.readFile(indexFile, "utf8", (error, data) => {
|
||||||
const dest = path.join("build", `excalidraw.asar`);
|
if (error) {
|
||||||
|
return console.error(error);
|
||||||
|
}
|
||||||
|
const result = data.replace(/{version}/g, versionDate(now));
|
||||||
|
|
||||||
await asar.createPackage(src, dest);
|
fs.writeFile(indexFile, result, "utf8", (error) => {
|
||||||
})();
|
if (error) {
|
||||||
|
return console.error(error);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -0,0 +1,155 @@
|
|||||||
|
const fs = require("fs");
|
||||||
|
|
||||||
|
const THRESSHOLD = 85;
|
||||||
|
|
||||||
|
const crowdinMap = {
|
||||||
|
"ar-SA": "en-ar",
|
||||||
|
"el-GR": "en-el",
|
||||||
|
"fi-FI": "en-fi",
|
||||||
|
"ja-JP": "en-ja",
|
||||||
|
"bg-BG": "en-bg",
|
||||||
|
"ca-ES": "en-ca",
|
||||||
|
"de-DE": "en-de",
|
||||||
|
"es-ES": "en-es",
|
||||||
|
"fa-IR": "en-fa",
|
||||||
|
"fr-FR": "en-fr",
|
||||||
|
"he-IL": "en-he",
|
||||||
|
"hi-IN": "en-hi",
|
||||||
|
"hu-HU": "en-hu",
|
||||||
|
"id-ID": "en-id",
|
||||||
|
"it-IT": "en-it",
|
||||||
|
"ko-KR": "en-ko",
|
||||||
|
"my-MM": "en-my",
|
||||||
|
"nb-NO": "en-nb",
|
||||||
|
"nl-NL": "en-nl",
|
||||||
|
"nn-NO": "en-nnno",
|
||||||
|
"pl-PL": "en-pl",
|
||||||
|
"pt-BR": "en-ptbr",
|
||||||
|
"pt-PT": "en-pt",
|
||||||
|
"ro-RO": "en-ro",
|
||||||
|
"ru-RU": "en-ru",
|
||||||
|
"sk-SK": "en-sk",
|
||||||
|
"sv-SE": "en-sv",
|
||||||
|
"tr-TR": "en-tr",
|
||||||
|
"uk-UA": "en-uk",
|
||||||
|
"zh-CN": "en-zhcn",
|
||||||
|
"zh-TW": "en-zhtw",
|
||||||
|
};
|
||||||
|
|
||||||
|
const flags = {
|
||||||
|
"ar-SA": "🇸🇦",
|
||||||
|
"bg-BG": "🇧🇬",
|
||||||
|
"ca-ES": "🇪🇸",
|
||||||
|
"de-DE": "🇩🇪",
|
||||||
|
"el-GR": "🇬🇷",
|
||||||
|
"es-ES": "🇪🇸",
|
||||||
|
"fa-IR": "🇮🇷",
|
||||||
|
"fi-FI": "🇫🇮",
|
||||||
|
"fr-FR": "🇫🇷",
|
||||||
|
"he-IL": "🇮🇱",
|
||||||
|
"hi-IN": "🇮🇳",
|
||||||
|
"hu-HU": "🇭🇺",
|
||||||
|
"id-ID": "🇮🇩",
|
||||||
|
"it-IT": "🇮🇹",
|
||||||
|
"ja-JP": "🇯🇵",
|
||||||
|
"ko-KR": "🇰🇷",
|
||||||
|
"my-MM": "🇲🇲",
|
||||||
|
"nb-NO": "🇳🇴",
|
||||||
|
"nl-NL": "🇳🇱",
|
||||||
|
"nn-NO": "🇳🇴",
|
||||||
|
"pl-PL": "🇵🇱",
|
||||||
|
"pt-BR": "🇧🇷",
|
||||||
|
"pt-PT": "🇵🇹",
|
||||||
|
"ro-RO": "🇷🇴",
|
||||||
|
"ru-RU": "🇷🇺",
|
||||||
|
"sk-SK": "🇸🇰",
|
||||||
|
"sv-SE": "🇸🇪",
|
||||||
|
"tr-TR": "🇹🇷",
|
||||||
|
"uk-UA": "🇺🇦",
|
||||||
|
"zh-CN": "🇨🇳",
|
||||||
|
"zh-TW": "🇹🇼",
|
||||||
|
};
|
||||||
|
|
||||||
|
const languages = {
|
||||||
|
"ar-SA": "العربية",
|
||||||
|
"bg-BG": "Български",
|
||||||
|
"ca-ES": "Catalan",
|
||||||
|
"de-DE": "Deutsch",
|
||||||
|
"el-GR": "Ελληνικά",
|
||||||
|
"es-ES": "Español",
|
||||||
|
"fa-IR": "فارسی",
|
||||||
|
"fi-FI": "Suomi",
|
||||||
|
"fr-FR": "Français",
|
||||||
|
"he-IL": "עברית",
|
||||||
|
"hi-IN": "हिन्दी",
|
||||||
|
"hu-HU": "Magyar",
|
||||||
|
"id-ID": "Bahasa Indonesia",
|
||||||
|
"it-IT": "Italiano",
|
||||||
|
"ja-JP": "日本語",
|
||||||
|
"ko-KR": "한국어",
|
||||||
|
"my-MM": "Burmese",
|
||||||
|
"nb-NO": "Norsk bokmål",
|
||||||
|
"nl-NL": "Nederlands",
|
||||||
|
"nn-NO": "Norsk nynorsk",
|
||||||
|
"pl-PL": "Polski",
|
||||||
|
"pt-BR": "Português Brasileiro",
|
||||||
|
"pt-PT": "Português",
|
||||||
|
"ro-RO": "Română",
|
||||||
|
"ru-RU": "Русский",
|
||||||
|
"sk-SK": "Slovenčina",
|
||||||
|
"sv-SE": "Svenska",
|
||||||
|
"tr-TR": "Türkçe",
|
||||||
|
"uk-UA": "Українська",
|
||||||
|
"zh-CN": "简体中文",
|
||||||
|
"zh-TW": "繁體中文",
|
||||||
|
};
|
||||||
|
|
||||||
|
const percentages = fs.readFileSync(
|
||||||
|
`${__dirname}/../src/locales/percentages.json`,
|
||||||
|
);
|
||||||
|
const rowData = JSON.parse(percentages);
|
||||||
|
|
||||||
|
const coverages = Object.entries(rowData)
|
||||||
|
.sort(([, a], [, b]) => b - a)
|
||||||
|
.reduce((r, [k, v]) => ({ ...r, [k]: v }), {});
|
||||||
|
|
||||||
|
const boldIf = (text, condition) => (condition ? `**${text}**` : text);
|
||||||
|
|
||||||
|
const printHeader = () => {
|
||||||
|
let result = "| | Flag | Locale | % |\n";
|
||||||
|
result += "| :--: | :--: | -- | :--: |";
|
||||||
|
return result;
|
||||||
|
};
|
||||||
|
|
||||||
|
const printRow = (id, locale, coverage) => {
|
||||||
|
const isOver = coverage >= THRESSHOLD;
|
||||||
|
let result = `| ${isOver ? id : "..."} | `;
|
||||||
|
result += `${locale in flags ? flags[locale] : ""} | `;
|
||||||
|
const language = locale in languages ? languages[locale] : locale;
|
||||||
|
if (locale in crowdinMap && crowdinMap[locale]) {
|
||||||
|
result += `[${boldIf(
|
||||||
|
language,
|
||||||
|
isOver,
|
||||||
|
)}](https://crowdin.com/translate/excalidraw/10/${crowdinMap[locale]}) | `;
|
||||||
|
} else {
|
||||||
|
result += `${boldIf(language, isOver)} | `;
|
||||||
|
}
|
||||||
|
result += `${coverage === 100 ? "✅" : boldIf(coverage, isOver)} |`;
|
||||||
|
return result;
|
||||||
|
};
|
||||||
|
|
||||||
|
console.info(
|
||||||
|
`Each language must be at least **${THRESSHOLD}%** translated in order to appear on Excalidraw. Join us on [Crowdin](https://crowdin.com/project/excalidraw) and help us translate your own language. **Can't find yours yet?** Open an [issue](https://github.com/excalidraw/excalidraw/issues/new) and we'll add it to the list.`,
|
||||||
|
);
|
||||||
|
console.info("\n\r");
|
||||||
|
console.info(printHeader());
|
||||||
|
let index = 1;
|
||||||
|
for (const coverage in coverages) {
|
||||||
|
if (coverage === "en") {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
console.info(printRow(index, coverage, coverages[coverage]));
|
||||||
|
index++;
|
||||||
|
}
|
||||||
|
console.info("\n\r");
|
||||||
|
console.info("\\* Languages in **bold** are going to appear on production.");
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
import { register } from "./register";
|
||||||
|
import { getSelectedElements } from "../scene";
|
||||||
|
import { getNonDeletedElements } from "../element";
|
||||||
|
import { deepCopyElement } from "../element/newElement";
|
||||||
|
import { Library } from "../data/library";
|
||||||
|
import { EVENT_LIBRARY, trackEvent } from "../analytics";
|
||||||
|
|
||||||
|
export const actionAddToLibrary = register({
|
||||||
|
name: "addToLibrary",
|
||||||
|
perform: (elements, appState) => {
|
||||||
|
const selectedElements = getSelectedElements(
|
||||||
|
getNonDeletedElements(elements),
|
||||||
|
appState,
|
||||||
|
);
|
||||||
|
|
||||||
|
Library.loadLibrary().then((items) => {
|
||||||
|
Library.saveLibrary([...items, selectedElements.map(deepCopyElement)]);
|
||||||
|
});
|
||||||
|
trackEvent(EVENT_LIBRARY, "add");
|
||||||
|
return false;
|
||||||
|
},
|
||||||
|
contextMenuOrder: 6,
|
||||||
|
contextItemLabel: "labels.addToLibrary",
|
||||||
|
});
|
||||||
@@ -0,0 +1,214 @@
|
|||||||
|
import React from "react";
|
||||||
|
import { KEYS } from "../keys";
|
||||||
|
import { t } from "../i18n";
|
||||||
|
import { register } from "./register";
|
||||||
|
import {
|
||||||
|
AlignBottomIcon,
|
||||||
|
AlignLeftIcon,
|
||||||
|
AlignRightIcon,
|
||||||
|
AlignTopIcon,
|
||||||
|
CenterHorizontallyIcon,
|
||||||
|
CenterVerticallyIcon,
|
||||||
|
} from "../components/icons";
|
||||||
|
import { getSelectedElements, isSomeElementSelected } from "../scene";
|
||||||
|
import { getElementMap, getNonDeletedElements } from "../element";
|
||||||
|
import { ToolButton } from "../components/ToolButton";
|
||||||
|
import { ExcalidrawElement } from "../element/types";
|
||||||
|
import { AppState } from "../types";
|
||||||
|
import { alignElements, Alignment } from "../align";
|
||||||
|
import { getShortcutKey } from "../utils";
|
||||||
|
import { trackEvent, EVENT_ALIGN } from "../analytics";
|
||||||
|
|
||||||
|
const enableActionGroup = (
|
||||||
|
elements: readonly ExcalidrawElement[],
|
||||||
|
appState: AppState,
|
||||||
|
) => getSelectedElements(getNonDeletedElements(elements), appState).length > 1;
|
||||||
|
|
||||||
|
const alignSelectedElements = (
|
||||||
|
elements: readonly ExcalidrawElement[],
|
||||||
|
appState: Readonly<AppState>,
|
||||||
|
alignment: Alignment,
|
||||||
|
) => {
|
||||||
|
const selectedElements = getSelectedElements(
|
||||||
|
getNonDeletedElements(elements),
|
||||||
|
appState,
|
||||||
|
);
|
||||||
|
|
||||||
|
const updatedElements = alignElements(selectedElements, alignment);
|
||||||
|
|
||||||
|
const updatedElementsMap = getElementMap(updatedElements);
|
||||||
|
|
||||||
|
return elements.map((element) => updatedElementsMap[element.id] || element);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const actionAlignTop = register({
|
||||||
|
name: "alignTop",
|
||||||
|
perform: (elements, appState) => {
|
||||||
|
trackEvent(EVENT_ALIGN, "align", "top");
|
||||||
|
return {
|
||||||
|
appState,
|
||||||
|
elements: alignSelectedElements(elements, appState, {
|
||||||
|
position: "start",
|
||||||
|
axis: "y",
|
||||||
|
}),
|
||||||
|
commitToHistory: true,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
keyTest: (event) =>
|
||||||
|
event[KEYS.CTRL_OR_CMD] && event.shiftKey && event.key === KEYS.ARROW_UP,
|
||||||
|
PanelComponent: ({ elements, appState, updateData }) => (
|
||||||
|
<ToolButton
|
||||||
|
hidden={!enableActionGroup(elements, appState)}
|
||||||
|
type="button"
|
||||||
|
icon={<AlignTopIcon appearance={appState.appearance} />}
|
||||||
|
onClick={() => updateData(null)}
|
||||||
|
title={`${t("labels.alignTop")} — ${getShortcutKey(
|
||||||
|
"CtrlOrCmd+Shift+Up",
|
||||||
|
)}`}
|
||||||
|
aria-label={t("labels.alignTop")}
|
||||||
|
visible={isSomeElementSelected(getNonDeletedElements(elements), appState)}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const actionAlignBottom = register({
|
||||||
|
name: "alignBottom",
|
||||||
|
perform: (elements, appState) => {
|
||||||
|
trackEvent(EVENT_ALIGN, "align", "bottom");
|
||||||
|
return {
|
||||||
|
appState,
|
||||||
|
elements: alignSelectedElements(elements, appState, {
|
||||||
|
position: "end",
|
||||||
|
axis: "y",
|
||||||
|
}),
|
||||||
|
commitToHistory: true,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
keyTest: (event) =>
|
||||||
|
event[KEYS.CTRL_OR_CMD] && event.shiftKey && event.key === KEYS.ARROW_DOWN,
|
||||||
|
PanelComponent: ({ elements, appState, updateData }) => (
|
||||||
|
<ToolButton
|
||||||
|
hidden={!enableActionGroup(elements, appState)}
|
||||||
|
type="button"
|
||||||
|
icon={<AlignBottomIcon appearance={appState.appearance} />}
|
||||||
|
onClick={() => updateData(null)}
|
||||||
|
title={`${t("labels.alignBottom")} — ${getShortcutKey(
|
||||||
|
"CtrlOrCmd+Shift+Down",
|
||||||
|
)}`}
|
||||||
|
aria-label={t("labels.alignBottom")}
|
||||||
|
visible={isSomeElementSelected(getNonDeletedElements(elements), appState)}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const actionAlignLeft = register({
|
||||||
|
name: "alignLeft",
|
||||||
|
perform: (elements, appState) => {
|
||||||
|
trackEvent(EVENT_ALIGN, "align", "left");
|
||||||
|
return {
|
||||||
|
appState,
|
||||||
|
elements: alignSelectedElements(elements, appState, {
|
||||||
|
position: "start",
|
||||||
|
axis: "x",
|
||||||
|
}),
|
||||||
|
commitToHistory: true,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
keyTest: (event) =>
|
||||||
|
event[KEYS.CTRL_OR_CMD] && event.shiftKey && event.key === KEYS.ARROW_LEFT,
|
||||||
|
PanelComponent: ({ elements, appState, updateData }) => (
|
||||||
|
<ToolButton
|
||||||
|
hidden={!enableActionGroup(elements, appState)}
|
||||||
|
type="button"
|
||||||
|
icon={<AlignLeftIcon appearance={appState.appearance} />}
|
||||||
|
onClick={() => updateData(null)}
|
||||||
|
title={`${t("labels.alignLeft")} — ${getShortcutKey(
|
||||||
|
"CtrlOrCmd+Shift+Left",
|
||||||
|
)}`}
|
||||||
|
aria-label={t("labels.alignLeft")}
|
||||||
|
visible={isSomeElementSelected(getNonDeletedElements(elements), appState)}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const actionAlignRight = register({
|
||||||
|
name: "alignRight",
|
||||||
|
perform: (elements, appState) => {
|
||||||
|
trackEvent(EVENT_ALIGN, "align", "right");
|
||||||
|
return {
|
||||||
|
appState,
|
||||||
|
elements: alignSelectedElements(elements, appState, {
|
||||||
|
position: "end",
|
||||||
|
axis: "x",
|
||||||
|
}),
|
||||||
|
commitToHistory: true,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
keyTest: (event) =>
|
||||||
|
event[KEYS.CTRL_OR_CMD] && event.shiftKey && event.key === KEYS.ARROW_RIGHT,
|
||||||
|
PanelComponent: ({ elements, appState, updateData }) => (
|
||||||
|
<ToolButton
|
||||||
|
hidden={!enableActionGroup(elements, appState)}
|
||||||
|
type="button"
|
||||||
|
icon={<AlignRightIcon appearance={appState.appearance} />}
|
||||||
|
onClick={() => updateData(null)}
|
||||||
|
title={`${t("labels.alignRight")} — ${getShortcutKey(
|
||||||
|
"CtrlOrCmd+Shift+Right",
|
||||||
|
)}`}
|
||||||
|
aria-label={t("labels.alignRight")}
|
||||||
|
visible={isSomeElementSelected(getNonDeletedElements(elements), appState)}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const actionAlignVerticallyCentered = register({
|
||||||
|
name: "alignVerticallyCentered",
|
||||||
|
perform: (elements, appState) => {
|
||||||
|
trackEvent(EVENT_ALIGN, "vertically", "center");
|
||||||
|
return {
|
||||||
|
appState,
|
||||||
|
elements: alignSelectedElements(elements, appState, {
|
||||||
|
position: "center",
|
||||||
|
axis: "y",
|
||||||
|
}),
|
||||||
|
commitToHistory: true,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
PanelComponent: ({ elements, appState, updateData }) => (
|
||||||
|
<ToolButton
|
||||||
|
hidden={!enableActionGroup(elements, appState)}
|
||||||
|
type="button"
|
||||||
|
icon={<CenterVerticallyIcon appearance={appState.appearance} />}
|
||||||
|
onClick={() => updateData(null)}
|
||||||
|
title={t("labels.centerVertically")}
|
||||||
|
aria-label={t("labels.centerVertically")}
|
||||||
|
visible={isSomeElementSelected(getNonDeletedElements(elements), appState)}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const actionAlignHorizontallyCentered = register({
|
||||||
|
name: "alignHorizontallyCentered",
|
||||||
|
perform: (elements, appState) => {
|
||||||
|
trackEvent(EVENT_ALIGN, "horizontally", "center");
|
||||||
|
return {
|
||||||
|
appState,
|
||||||
|
elements: alignSelectedElements(elements, appState, {
|
||||||
|
position: "center",
|
||||||
|
axis: "x",
|
||||||
|
}),
|
||||||
|
commitToHistory: true,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
PanelComponent: ({ elements, appState, updateData }) => (
|
||||||
|
<ToolButton
|
||||||
|
hidden={!enableActionGroup(elements, appState)}
|
||||||
|
type="button"
|
||||||
|
icon={<CenterHorizontallyIcon appearance={appState.appearance} />}
|
||||||
|
onClick={() => updateData(null)}
|
||||||
|
title={t("labels.centerHorizontally")}
|
||||||
|
aria-label={t("labels.centerHorizontally")}
|
||||||
|
visible={isSomeElementSelected(getNonDeletedElements(elements), appState)}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
});
|
||||||
+138
-83
@@ -1,21 +1,35 @@
|
|||||||
import React from "react";
|
import React from "react";
|
||||||
import { ColorPicker } from "../components/ColorPicker";
|
import { EVENT_ACTION, EVENT_CHANGE, trackEvent } from "../analytics";
|
||||||
import { getDefaultAppState } from "../appState";
|
import { getDefaultAppState } from "../appState";
|
||||||
import { trash, zoomIn, zoomOut, resetZoom } from "../components/icons";
|
import colors from "../colors";
|
||||||
|
import { ColorPicker } from "../components/ColorPicker";
|
||||||
|
import { resetZoom, trash, zoomIn, zoomOut } from "../components/icons";
|
||||||
import { ToolButton } from "../components/ToolButton";
|
import { ToolButton } from "../components/ToolButton";
|
||||||
import { t } from "../i18n";
|
import { getCommonBounds, getNonDeletedElements } from "../element";
|
||||||
import { getNormalizedZoom, calculateScrollCenter } from "../scene";
|
|
||||||
import { KEYS } from "../keys";
|
|
||||||
import { getShortcutKey } from "../utils";
|
|
||||||
import useIsMobile from "../is-mobile";
|
|
||||||
import { register } from "./register";
|
|
||||||
import { newElementWith } from "../element/mutateElement";
|
import { newElementWith } from "../element/mutateElement";
|
||||||
import { AppState, FlooredNumber } from "../types";
|
import { ExcalidrawElement } from "../element/types";
|
||||||
import { getCommonBounds } from "../element";
|
import { t } from "../i18n";
|
||||||
|
import useIsMobile from "../is-mobile";
|
||||||
|
import { CODES, KEYS } from "../keys";
|
||||||
|
import { getNormalizedZoom, getSelectedElements } from "../scene";
|
||||||
|
import { centerScrollOn } from "../scene/scroll";
|
||||||
|
import { getNewZoom } from "../scene/zoom";
|
||||||
|
import { AppState, NormalizedZoomValue } from "../types";
|
||||||
|
import { getNewSceneName, getShortcutKey } from "../utils";
|
||||||
|
import { register } from "./register";
|
||||||
|
|
||||||
export const actionChangeViewBackgroundColor = register({
|
export const actionChangeViewBackgroundColor = register({
|
||||||
name: "changeViewBackgroundColor",
|
name: "changeViewBackgroundColor",
|
||||||
perform: (_, appState, value) => {
|
perform: (_, appState, value) => {
|
||||||
|
if (value !== appState.viewBackgroundColor) {
|
||||||
|
trackEvent(
|
||||||
|
EVENT_CHANGE,
|
||||||
|
"canvas color",
|
||||||
|
colors.canvasBackground.includes(value)
|
||||||
|
? `${value} (picker ${colors.canvasBackground.indexOf(value)})`
|
||||||
|
: value,
|
||||||
|
);
|
||||||
|
}
|
||||||
return {
|
return {
|
||||||
appState: { ...appState, viewBackgroundColor: value },
|
appState: { ...appState, viewBackgroundColor: value },
|
||||||
commitToHistory: true,
|
commitToHistory: true,
|
||||||
@@ -38,13 +52,22 @@ export const actionChangeViewBackgroundColor = register({
|
|||||||
export const actionClearCanvas = register({
|
export const actionClearCanvas = register({
|
||||||
name: "clearCanvas",
|
name: "clearCanvas",
|
||||||
perform: (elements, appState: AppState) => {
|
perform: (elements, appState: AppState) => {
|
||||||
|
trackEvent(EVENT_ACTION, "clear canvas");
|
||||||
return {
|
return {
|
||||||
elements: elements.map((element) =>
|
elements: elements.map((element) =>
|
||||||
newElementWith(element, { isDeleted: true }),
|
newElementWith(element, { isDeleted: true }),
|
||||||
),
|
),
|
||||||
appState: {
|
appState: {
|
||||||
...getDefaultAppState(),
|
...getDefaultAppState(),
|
||||||
username: appState.username,
|
name: getNewSceneName(),
|
||||||
|
appearance: appState.appearance,
|
||||||
|
elementLocked: appState.elementLocked,
|
||||||
|
exportBackground: appState.exportBackground,
|
||||||
|
exportEmbedScene: appState.exportEmbedScene,
|
||||||
|
gridSize: appState.gridSize,
|
||||||
|
shouldAddWatermark: appState.shouldAddWatermark,
|
||||||
|
showStats: appState.showStats,
|
||||||
|
pasteDialog: appState.pasteDialog,
|
||||||
},
|
},
|
||||||
commitToHistory: true,
|
commitToHistory: true,
|
||||||
};
|
};
|
||||||
@@ -58,10 +81,6 @@ export const actionClearCanvas = register({
|
|||||||
showAriaLabel={useIsMobile()}
|
showAriaLabel={useIsMobile()}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
if (window.confirm(t("alerts.clearReset"))) {
|
if (window.confirm(t("alerts.clearReset"))) {
|
||||||
// TODO: Defined globally, since file handles aren't yet serializable.
|
|
||||||
// Once `FileSystemFileHandle` can be serialized, make this
|
|
||||||
// part of `AppState`.
|
|
||||||
(window as any).handle = null;
|
|
||||||
updateData(null);
|
updateData(null);
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
@@ -71,23 +90,20 @@ export const actionClearCanvas = register({
|
|||||||
|
|
||||||
const ZOOM_STEP = 0.1;
|
const ZOOM_STEP = 0.1;
|
||||||
|
|
||||||
const KEY_CODES = {
|
|
||||||
MINUS: "Minus",
|
|
||||||
EQUAL: "Equal",
|
|
||||||
ONE: "Digit1",
|
|
||||||
ZERO: "Digit0",
|
|
||||||
NUM_SUBTRACT: "NumpadSubtract",
|
|
||||||
NUM_ADD: "NumpadAdd",
|
|
||||||
NUM_ZERO: "Numpad0",
|
|
||||||
};
|
|
||||||
|
|
||||||
export const actionZoomIn = register({
|
export const actionZoomIn = register({
|
||||||
name: "zoomIn",
|
name: "zoomIn",
|
||||||
perform: (_elements, appState) => {
|
perform: (_elements, appState) => {
|
||||||
|
const zoom = getNewZoom(
|
||||||
|
getNormalizedZoom(appState.zoom.value + ZOOM_STEP),
|
||||||
|
appState.zoom,
|
||||||
|
{ left: appState.offsetLeft, top: appState.offsetTop },
|
||||||
|
{ x: appState.width / 2, y: appState.height / 2 },
|
||||||
|
);
|
||||||
|
trackEvent(EVENT_ACTION, "zoom", "in", zoom.value * 100);
|
||||||
return {
|
return {
|
||||||
appState: {
|
appState: {
|
||||||
...appState,
|
...appState,
|
||||||
zoom: getNormalizedZoom(appState.zoom + ZOOM_STEP),
|
zoom,
|
||||||
},
|
},
|
||||||
commitToHistory: false,
|
commitToHistory: false,
|
||||||
};
|
};
|
||||||
@@ -104,17 +120,25 @@ export const actionZoomIn = register({
|
|||||||
/>
|
/>
|
||||||
),
|
),
|
||||||
keyTest: (event) =>
|
keyTest: (event) =>
|
||||||
(event.code === KEY_CODES.EQUAL || event.code === KEY_CODES.NUM_ADD) &&
|
(event.code === CODES.EQUAL || event.code === CODES.NUM_ADD) &&
|
||||||
(event[KEYS.CTRL_OR_CMD] || event.shiftKey),
|
(event[KEYS.CTRL_OR_CMD] || event.shiftKey),
|
||||||
});
|
});
|
||||||
|
|
||||||
export const actionZoomOut = register({
|
export const actionZoomOut = register({
|
||||||
name: "zoomOut",
|
name: "zoomOut",
|
||||||
perform: (_elements, appState) => {
|
perform: (_elements, appState) => {
|
||||||
|
const zoom = getNewZoom(
|
||||||
|
getNormalizedZoom(appState.zoom.value - ZOOM_STEP),
|
||||||
|
appState.zoom,
|
||||||
|
{ left: appState.offsetLeft, top: appState.offsetTop },
|
||||||
|
{ x: appState.width / 2, y: appState.height / 2 },
|
||||||
|
);
|
||||||
|
|
||||||
|
trackEvent(EVENT_ACTION, "zoom", "out", zoom.value * 100);
|
||||||
return {
|
return {
|
||||||
appState: {
|
appState: {
|
||||||
...appState,
|
...appState,
|
||||||
zoom: getNormalizedZoom(appState.zoom - ZOOM_STEP),
|
zoom,
|
||||||
},
|
},
|
||||||
commitToHistory: false,
|
commitToHistory: false,
|
||||||
};
|
};
|
||||||
@@ -131,17 +155,26 @@ export const actionZoomOut = register({
|
|||||||
/>
|
/>
|
||||||
),
|
),
|
||||||
keyTest: (event) =>
|
keyTest: (event) =>
|
||||||
(event.code === KEY_CODES.MINUS || event.code === KEY_CODES.NUM_SUBTRACT) &&
|
(event.code === CODES.MINUS || event.code === CODES.NUM_SUBTRACT) &&
|
||||||
(event[KEYS.CTRL_OR_CMD] || event.shiftKey),
|
(event[KEYS.CTRL_OR_CMD] || event.shiftKey),
|
||||||
});
|
});
|
||||||
|
|
||||||
export const actionResetZoom = register({
|
export const actionResetZoom = register({
|
||||||
name: "resetZoom",
|
name: "resetZoom",
|
||||||
perform: (_elements, appState) => {
|
perform: (_elements, appState) => {
|
||||||
|
trackEvent(EVENT_ACTION, "zoom", "reset", 100);
|
||||||
return {
|
return {
|
||||||
appState: {
|
appState: {
|
||||||
...appState,
|
...appState,
|
||||||
zoom: 1,
|
zoom: getNewZoom(
|
||||||
|
1 as NormalizedZoomValue,
|
||||||
|
appState.zoom,
|
||||||
|
{ left: appState.offsetLeft, top: appState.offsetTop },
|
||||||
|
{
|
||||||
|
x: appState.width / 2,
|
||||||
|
y: appState.height / 2,
|
||||||
|
},
|
||||||
|
),
|
||||||
},
|
},
|
||||||
commitToHistory: false,
|
commitToHistory: false,
|
||||||
};
|
};
|
||||||
@@ -158,66 +191,88 @@ export const actionResetZoom = register({
|
|||||||
/>
|
/>
|
||||||
),
|
),
|
||||||
keyTest: (event) =>
|
keyTest: (event) =>
|
||||||
(event.code === KEY_CODES.ZERO || event.code === KEY_CODES.NUM_ZERO) &&
|
(event.code === CODES.ZERO || event.code === CODES.NUM_ZERO) &&
|
||||||
(event[KEYS.CTRL_OR_CMD] || event.shiftKey),
|
(event[KEYS.CTRL_OR_CMD] || event.shiftKey),
|
||||||
});
|
});
|
||||||
|
|
||||||
const calculateZoom = (
|
const zoomValueToFitBoundsOnViewport = (
|
||||||
commonBounds: number[],
|
bounds: [number, number, number, number],
|
||||||
currentZoom: number,
|
viewportDimensions: { width: number; height: number },
|
||||||
{
|
) => {
|
||||||
scrollX,
|
const [x1, y1, x2, y2] = bounds;
|
||||||
scrollY,
|
const commonBoundsWidth = x2 - x1;
|
||||||
}: {
|
const zoomValueForWidth = viewportDimensions.width / commonBoundsWidth;
|
||||||
scrollX: FlooredNumber;
|
const commonBoundsHeight = y2 - y1;
|
||||||
scrollY: FlooredNumber;
|
const zoomValueForHeight = viewportDimensions.height / commonBoundsHeight;
|
||||||
},
|
const smallestZoomValue = Math.min(zoomValueForWidth, zoomValueForHeight);
|
||||||
): number => {
|
const zoomAdjustedToSteps =
|
||||||
const { innerWidth, innerHeight } = window;
|
Math.floor(smallestZoomValue / ZOOM_STEP) * ZOOM_STEP;
|
||||||
const [x, y] = commonBounds;
|
const clampedZoomValueToFitElements = Math.min(
|
||||||
const zoomX = -innerWidth / (2 * scrollX + 2 * x - innerWidth);
|
Math.max(zoomAdjustedToSteps, ZOOM_STEP),
|
||||||
const zoomY = -innerHeight / (2 * scrollY + 2 * y - innerHeight);
|
1,
|
||||||
const margin = 0.01;
|
);
|
||||||
let newZoom;
|
return clampedZoomValueToFitElements as NormalizedZoomValue;
|
||||||
|
|
||||||
if (zoomX < zoomY) {
|
|
||||||
newZoom = zoomX - margin;
|
|
||||||
} else if (zoomY <= zoomX) {
|
|
||||||
newZoom = zoomY - margin;
|
|
||||||
} else {
|
|
||||||
newZoom = currentZoom;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (newZoom <= 0.1) {
|
|
||||||
return 0.1;
|
|
||||||
}
|
|
||||||
if (newZoom >= 1) {
|
|
||||||
return 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
return newZoom;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export const actionZoomToFit = register({
|
const zoomToFitElements = (
|
||||||
name: "zoomToFit",
|
elements: readonly ExcalidrawElement[],
|
||||||
perform: (elements, appState) => {
|
appState: Readonly<AppState>,
|
||||||
const nonDeletedElements = elements.filter((element) => !element.isDeleted);
|
zoomToSelection: boolean,
|
||||||
const scrollCenter = calculateScrollCenter(nonDeletedElements);
|
) => {
|
||||||
const commonBounds = getCommonBounds(nonDeletedElements);
|
const nonDeletedElements = getNonDeletedElements(elements);
|
||||||
const zoom = calculateZoom(commonBounds, appState.zoom, scrollCenter);
|
const selectedElements = getSelectedElements(nonDeletedElements, appState);
|
||||||
|
|
||||||
return {
|
const commonBounds =
|
||||||
appState: {
|
zoomToSelection && selectedElements.length > 0
|
||||||
...appState,
|
? getCommonBounds(selectedElements)
|
||||||
scrollX: scrollCenter.scrollX,
|
: getCommonBounds(nonDeletedElements);
|
||||||
scrollY: scrollCenter.scrollY,
|
|
||||||
zoom,
|
const zoomValue = zoomValueToFitBoundsOnViewport(commonBounds, {
|
||||||
},
|
width: appState.width,
|
||||||
commitToHistory: false,
|
height: appState.height,
|
||||||
};
|
});
|
||||||
},
|
const newZoom = getNewZoom(zoomValue, appState.zoom, {
|
||||||
|
left: appState.offsetLeft,
|
||||||
|
top: appState.offsetTop,
|
||||||
|
});
|
||||||
|
const action = zoomToSelection ? "selection" : "fit";
|
||||||
|
|
||||||
|
const [x1, y1, x2, y2] = commonBounds;
|
||||||
|
const centerX = (x1 + x2) / 2;
|
||||||
|
const centerY = (y1 + y2) / 2;
|
||||||
|
trackEvent(EVENT_ACTION, "zoom", action, newZoom.value * 100);
|
||||||
|
return {
|
||||||
|
appState: {
|
||||||
|
...appState,
|
||||||
|
...centerScrollOn({
|
||||||
|
scenePoint: { x: centerX, y: centerY },
|
||||||
|
viewportDimensions: {
|
||||||
|
width: appState.width,
|
||||||
|
height: appState.height,
|
||||||
|
},
|
||||||
|
zoom: newZoom,
|
||||||
|
}),
|
||||||
|
zoom: newZoom,
|
||||||
|
},
|
||||||
|
commitToHistory: false,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export const actionZoomToSelected = register({
|
||||||
|
name: "zoomToSelection",
|
||||||
|
perform: (elements, appState) => zoomToFitElements(elements, appState, true),
|
||||||
keyTest: (event) =>
|
keyTest: (event) =>
|
||||||
event.code === KEY_CODES.ONE &&
|
event.code === CODES.TWO &&
|
||||||
|
event.shiftKey &&
|
||||||
|
!event.altKey &&
|
||||||
|
!event[KEYS.CTRL_OR_CMD],
|
||||||
|
});
|
||||||
|
|
||||||
|
export const actionZoomToFit = register({
|
||||||
|
name: "zoomToFit",
|
||||||
|
perform: (elements, appState) => zoomToFitElements(elements, appState, false),
|
||||||
|
keyTest: (event) =>
|
||||||
|
event.code === CODES.ONE &&
|
||||||
event.shiftKey &&
|
event.shiftKey &&
|
||||||
!event.altKey &&
|
!event.altKey &&
|
||||||
!event[KEYS.CTRL_OR_CMD],
|
!event[KEYS.CTRL_OR_CMD],
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { deleteSelectedElements, 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 React from "react";
|
||||||
@@ -6,14 +6,122 @@ import { trash } from "../components/icons";
|
|||||||
import { t } from "../i18n";
|
import { t } from "../i18n";
|
||||||
import { register } from "./register";
|
import { register } from "./register";
|
||||||
import { getNonDeletedElements } from "../element";
|
import { getNonDeletedElements } from "../element";
|
||||||
|
import { ExcalidrawElement } from "../element/types";
|
||||||
|
import { AppState } from "../types";
|
||||||
|
import { newElementWith } from "../element/mutateElement";
|
||||||
|
import { getElementsInGroup } from "../groups";
|
||||||
|
import { LinearElementEditor } from "../element/linearElementEditor";
|
||||||
|
import { fixBindingsAfterDeletion } from "../element/binding";
|
||||||
|
|
||||||
|
const deleteSelectedElements = (
|
||||||
|
elements: readonly ExcalidrawElement[],
|
||||||
|
appState: AppState,
|
||||||
|
) => {
|
||||||
|
return {
|
||||||
|
elements: elements.map((el) => {
|
||||||
|
if (appState.selectedElementIds[el.id]) {
|
||||||
|
return newElementWith(el, { isDeleted: true });
|
||||||
|
}
|
||||||
|
return el;
|
||||||
|
}),
|
||||||
|
appState: {
|
||||||
|
...appState,
|
||||||
|
selectedElementIds: {},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleGroupEditingState = (
|
||||||
|
appState: AppState,
|
||||||
|
elements: readonly ExcalidrawElement[],
|
||||||
|
): AppState => {
|
||||||
|
if (appState.editingGroupId) {
|
||||||
|
const siblingElements = getElementsInGroup(
|
||||||
|
getNonDeletedElements(elements),
|
||||||
|
appState.editingGroupId!,
|
||||||
|
);
|
||||||
|
if (siblingElements.length) {
|
||||||
|
return {
|
||||||
|
...appState,
|
||||||
|
selectedElementIds: { [siblingElements[0].id]: true },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return appState;
|
||||||
|
};
|
||||||
|
|
||||||
export const actionDeleteSelected = register({
|
export const actionDeleteSelected = register({
|
||||||
name: "deleteSelectedElements",
|
name: "deleteSelectedElements",
|
||||||
perform: (elements, appState) => {
|
perform: (elements, appState) => {
|
||||||
const {
|
if (appState.editingLinearElement) {
|
||||||
|
const {
|
||||||
|
elementId,
|
||||||
|
activePointIndex,
|
||||||
|
startBindingElement,
|
||||||
|
endBindingElement,
|
||||||
|
} = appState.editingLinearElement;
|
||||||
|
const element = LinearElementEditor.getElement(elementId);
|
||||||
|
if (!element) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
// case: no point selected → delete whole element
|
||||||
|
activePointIndex == null ||
|
||||||
|
activePointIndex === -1 ||
|
||||||
|
// case: deleting last remaining point
|
||||||
|
element.points.length < 2
|
||||||
|
) {
|
||||||
|
const nextElements = elements.filter((el) => el.id !== element.id);
|
||||||
|
const nextAppState = handleGroupEditingState(appState, nextElements);
|
||||||
|
|
||||||
|
return {
|
||||||
|
elements: nextElements,
|
||||||
|
appState: {
|
||||||
|
...nextAppState,
|
||||||
|
editingLinearElement: null,
|
||||||
|
},
|
||||||
|
commitToHistory: false,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// We cannot do this inside `movePoint` because it is also called
|
||||||
|
// when deleting the uncommitted point (which hasn't caused any binding)
|
||||||
|
const binding = {
|
||||||
|
startBindingElement:
|
||||||
|
activePointIndex === 0 ? null : startBindingElement,
|
||||||
|
endBindingElement:
|
||||||
|
activePointIndex === element.points.length - 1
|
||||||
|
? null
|
||||||
|
: endBindingElement,
|
||||||
|
};
|
||||||
|
|
||||||
|
LinearElementEditor.movePoint(element, activePointIndex, "delete");
|
||||||
|
|
||||||
|
return {
|
||||||
|
elements,
|
||||||
|
appState: {
|
||||||
|
...appState,
|
||||||
|
editingLinearElement: {
|
||||||
|
...appState.editingLinearElement,
|
||||||
|
...binding,
|
||||||
|
activePointIndex: activePointIndex > 0 ? activePointIndex - 1 : 0,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
commitToHistory: true,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
let {
|
||||||
elements: nextElements,
|
elements: nextElements,
|
||||||
appState: nextAppState,
|
appState: nextAppState,
|
||||||
} = deleteSelectedElements(elements, appState);
|
} = deleteSelectedElements(elements, appState);
|
||||||
|
fixBindingsAfterDeletion(
|
||||||
|
nextElements,
|
||||||
|
elements.filter(({ id }) => appState.selectedElementIds[id]),
|
||||||
|
);
|
||||||
|
|
||||||
|
nextAppState = handleGroupEditingState(nextAppState, nextElements);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
elements: nextElements,
|
elements: nextElements,
|
||||||
appState: {
|
appState: {
|
||||||
@@ -28,7 +136,7 @@ export const actionDeleteSelected = register({
|
|||||||
};
|
};
|
||||||
},
|
},
|
||||||
contextItemLabel: "labels.delete",
|
contextItemLabel: "labels.delete",
|
||||||
contextMenuOrder: 3,
|
contextMenuOrder: 999999,
|
||||||
keyTest: (event) => event.key === KEYS.BACKSPACE || event.key === KEYS.DELETE,
|
keyTest: (event) => event.key === KEYS.BACKSPACE || event.key === KEYS.DELETE,
|
||||||
PanelComponent: ({ elements, appState, updateData }) => (
|
PanelComponent: ({ elements, appState, updateData }) => (
|
||||||
<ToolButton
|
<ToolButton
|
||||||
|
|||||||
@@ -0,0 +1,94 @@
|
|||||||
|
import React from "react";
|
||||||
|
import { CODES } from "../keys";
|
||||||
|
import { t } from "../i18n";
|
||||||
|
import { register } from "./register";
|
||||||
|
import {
|
||||||
|
DistributeHorizontallyIcon,
|
||||||
|
DistributeVerticallyIcon,
|
||||||
|
} from "../components/icons";
|
||||||
|
import { getSelectedElements, isSomeElementSelected } from "../scene";
|
||||||
|
import { getElementMap, getNonDeletedElements } from "../element";
|
||||||
|
import { ToolButton } from "../components/ToolButton";
|
||||||
|
import { ExcalidrawElement } from "../element/types";
|
||||||
|
import { AppState } from "../types";
|
||||||
|
import { distributeElements, Distribution } from "../disitrubte";
|
||||||
|
import { getShortcutKey } from "../utils";
|
||||||
|
import { EVENT_ALIGN, trackEvent } from "../analytics";
|
||||||
|
|
||||||
|
const enableActionGroup = (
|
||||||
|
elements: readonly ExcalidrawElement[],
|
||||||
|
appState: AppState,
|
||||||
|
) => getSelectedElements(getNonDeletedElements(elements), appState).length > 1;
|
||||||
|
|
||||||
|
const distributeSelectedElements = (
|
||||||
|
elements: readonly ExcalidrawElement[],
|
||||||
|
appState: Readonly<AppState>,
|
||||||
|
distribution: Distribution,
|
||||||
|
) => {
|
||||||
|
const selectedElements = getSelectedElements(
|
||||||
|
getNonDeletedElements(elements),
|
||||||
|
appState,
|
||||||
|
);
|
||||||
|
|
||||||
|
const updatedElements = distributeElements(selectedElements, distribution);
|
||||||
|
|
||||||
|
const updatedElementsMap = getElementMap(updatedElements);
|
||||||
|
|
||||||
|
return elements.map((element) => updatedElementsMap[element.id] || element);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const distributeHorizontally = register({
|
||||||
|
name: "distributeHorizontally",
|
||||||
|
perform: (elements, appState) => {
|
||||||
|
trackEvent(EVENT_ALIGN, "distribute", "horizontally");
|
||||||
|
return {
|
||||||
|
appState,
|
||||||
|
elements: distributeSelectedElements(elements, appState, {
|
||||||
|
space: "between",
|
||||||
|
axis: "x",
|
||||||
|
}),
|
||||||
|
commitToHistory: true,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
keyTest: (event) => event.altKey && event.code === CODES.H,
|
||||||
|
PanelComponent: ({ elements, appState, updateData }) => (
|
||||||
|
<ToolButton
|
||||||
|
hidden={!enableActionGroup(elements, appState)}
|
||||||
|
type="button"
|
||||||
|
icon={<DistributeHorizontallyIcon appearance={appState.appearance} />}
|
||||||
|
onClick={() => updateData(null)}
|
||||||
|
title={`${t("labels.distributeHorizontally")} — ${getShortcutKey(
|
||||||
|
"Alt+H",
|
||||||
|
)}`}
|
||||||
|
aria-label={t("labels.distributeHorizontally")}
|
||||||
|
visible={isSomeElementSelected(getNonDeletedElements(elements), appState)}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const distributeVertically = register({
|
||||||
|
name: "distributeVertically",
|
||||||
|
perform: (elements, appState) => {
|
||||||
|
trackEvent(EVENT_ALIGN, "distribute", "vertically");
|
||||||
|
return {
|
||||||
|
appState,
|
||||||
|
elements: distributeSelectedElements(elements, appState, {
|
||||||
|
space: "between",
|
||||||
|
axis: "y",
|
||||||
|
}),
|
||||||
|
commitToHistory: true,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
keyTest: (event) => event.altKey && event.code === CODES.V,
|
||||||
|
PanelComponent: ({ elements, appState, updateData }) => (
|
||||||
|
<ToolButton
|
||||||
|
hidden={!enableActionGroup(elements, appState)}
|
||||||
|
type="button"
|
||||||
|
icon={<DistributeVerticallyIcon appearance={appState.appearance} />}
|
||||||
|
onClick={() => updateData(null)}
|
||||||
|
title={`${t("labels.distributeVertically")} — ${getShortcutKey("Alt+V")}`}
|
||||||
|
aria-label={t("labels.distributeVertically")}
|
||||||
|
visible={isSomeElementSelected(getNonDeletedElements(elements), appState)}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
});
|
||||||
@@ -8,32 +8,63 @@ import { ToolButton } from "../components/ToolButton";
|
|||||||
import { clone } from "../components/icons";
|
import { clone } from "../components/icons";
|
||||||
import { t } from "../i18n";
|
import { t } from "../i18n";
|
||||||
import { getShortcutKey } from "../utils";
|
import { getShortcutKey } from "../utils";
|
||||||
|
import { LinearElementEditor } from "../element/linearElementEditor";
|
||||||
|
import { mutateElement } from "../element/mutateElement";
|
||||||
|
import {
|
||||||
|
selectGroupsForSelectedElements,
|
||||||
|
getSelectedGroupForElement,
|
||||||
|
getElementsInGroup,
|
||||||
|
} from "../groups";
|
||||||
|
import { AppState } from "../types";
|
||||||
|
import { fixBindingsAfterDuplication } from "../element/binding";
|
||||||
|
import { ActionResult } from "./types";
|
||||||
|
import { GRID_SIZE } from "../constants";
|
||||||
|
|
||||||
export const actionDuplicateSelection = register({
|
export const actionDuplicateSelection = register({
|
||||||
name: "duplicateSelection",
|
name: "duplicateSelection",
|
||||||
perform: (elements, appState) => {
|
perform: (elements, appState) => {
|
||||||
return {
|
// duplicate point if selected while editing multi-point element
|
||||||
appState,
|
if (appState.editingLinearElement) {
|
||||||
elements: elements.reduce(
|
const { activePointIndex, elementId } = appState.editingLinearElement;
|
||||||
(acc: Array<ExcalidrawElement>, element: ExcalidrawElement) => {
|
const element = LinearElementEditor.getElement(elementId);
|
||||||
if (appState.selectedElementIds[element.id]) {
|
if (!element || activePointIndex === null) {
|
||||||
const newElement = duplicateElement(element, {
|
return false;
|
||||||
x: element.x + 10,
|
}
|
||||||
y: element.y + 10,
|
const { points } = element;
|
||||||
});
|
const selectedPoint = points[activePointIndex];
|
||||||
appState.selectedElementIds[newElement.id] = true;
|
const nextPoint = points[activePointIndex + 1];
|
||||||
delete appState.selectedElementIds[element.id];
|
mutateElement(element, {
|
||||||
return acc.concat([element, newElement]);
|
points: [
|
||||||
}
|
...points.slice(0, activePointIndex + 1),
|
||||||
return acc.concat(element);
|
nextPoint
|
||||||
|
? [
|
||||||
|
(selectedPoint[0] + nextPoint[0]) / 2,
|
||||||
|
(selectedPoint[1] + nextPoint[1]) / 2,
|
||||||
|
]
|
||||||
|
: [selectedPoint[0] + 30, selectedPoint[1] + 30],
|
||||||
|
...points.slice(activePointIndex + 1),
|
||||||
|
],
|
||||||
|
});
|
||||||
|
return {
|
||||||
|
appState: {
|
||||||
|
...appState,
|
||||||
|
editingLinearElement: {
|
||||||
|
...appState.editingLinearElement,
|
||||||
|
activePointIndex: activePointIndex + 1,
|
||||||
|
},
|
||||||
},
|
},
|
||||||
[],
|
elements,
|
||||||
),
|
commitToHistory: true,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
...duplicateElements(elements, appState),
|
||||||
commitToHistory: true,
|
commitToHistory: true,
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
contextItemLabel: "labels.duplicateSelection",
|
contextItemLabel: "labels.duplicateSelection",
|
||||||
keyTest: (event) => event[KEYS.CTRL_OR_CMD] && event.key === "d",
|
keyTest: (event) => event[KEYS.CTRL_OR_CMD] && event.key === KEYS.D,
|
||||||
PanelComponent: ({ elements, appState, updateData }) => (
|
PanelComponent: ({ elements, appState, updateData }) => (
|
||||||
<ToolButton
|
<ToolButton
|
||||||
type="button"
|
type="button"
|
||||||
@@ -47,3 +78,74 @@ export const actionDuplicateSelection = register({
|
|||||||
/>
|
/>
|
||||||
),
|
),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const duplicateElements = (
|
||||||
|
elements: readonly ExcalidrawElement[],
|
||||||
|
appState: AppState,
|
||||||
|
): Partial<ActionResult> => {
|
||||||
|
const groupIdMap = new Map();
|
||||||
|
const newElements: ExcalidrawElement[] = [];
|
||||||
|
const oldElements: ExcalidrawElement[] = [];
|
||||||
|
const oldIdToDuplicatedId = new Map();
|
||||||
|
|
||||||
|
const duplicateAndOffsetElement = (element: ExcalidrawElement) => {
|
||||||
|
const newElement = duplicateElement(
|
||||||
|
appState.editingGroupId,
|
||||||
|
groupIdMap,
|
||||||
|
element,
|
||||||
|
{
|
||||||
|
x: element.x + GRID_SIZE / 2,
|
||||||
|
y: element.y + GRID_SIZE / 2,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
oldIdToDuplicatedId.set(element.id, newElement.id);
|
||||||
|
oldElements.push(element);
|
||||||
|
newElements.push(newElement);
|
||||||
|
return newElement;
|
||||||
|
};
|
||||||
|
|
||||||
|
const finalElements: ExcalidrawElement[] = [];
|
||||||
|
|
||||||
|
let index = 0;
|
||||||
|
while (index < elements.length) {
|
||||||
|
const element = elements[index];
|
||||||
|
if (appState.selectedElementIds[element.id]) {
|
||||||
|
if (element.groupIds.length) {
|
||||||
|
const groupId = getSelectedGroupForElement(appState, element);
|
||||||
|
// if group selected, duplicate it atomically
|
||||||
|
if (groupId) {
|
||||||
|
const groupElements = getElementsInGroup(elements, groupId);
|
||||||
|
finalElements.push(
|
||||||
|
...groupElements,
|
||||||
|
...groupElements.map((element) =>
|
||||||
|
duplicateAndOffsetElement(element),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
index = index + groupElements.length;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
finalElements.push(element, duplicateAndOffsetElement(element));
|
||||||
|
} else {
|
||||||
|
finalElements.push(element);
|
||||||
|
}
|
||||||
|
index++;
|
||||||
|
}
|
||||||
|
|
||||||
|
fixBindingsAfterDuplication(finalElements, oldElements, oldIdToDuplicatedId);
|
||||||
|
|
||||||
|
return {
|
||||||
|
elements: finalElements,
|
||||||
|
appState: selectGroupsForSelectedElements(
|
||||||
|
{
|
||||||
|
...appState,
|
||||||
|
selectedGroupIds: {},
|
||||||
|
selectedElementIds: newElements.reduce((acc, element) => {
|
||||||
|
acc[element.id] = true;
|
||||||
|
return acc;
|
||||||
|
}, {} as any),
|
||||||
|
},
|
||||||
|
getNonDeletedElements(finalElements),
|
||||||
|
),
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|||||||
@@ -1,22 +1,29 @@
|
|||||||
import React from "react";
|
import React from "react";
|
||||||
|
import { EVENT_CHANGE, EVENT_IO, trackEvent } from "../analytics";
|
||||||
|
import { load, save, saveAs } from "../components/icons";
|
||||||
import { ProjectName } from "../components/ProjectName";
|
import { ProjectName } from "../components/ProjectName";
|
||||||
import { saveAsJSON, loadFromJSON } from "../data";
|
|
||||||
import { load, save } from "../components/icons";
|
|
||||||
import { ToolButton } from "../components/ToolButton";
|
import { ToolButton } from "../components/ToolButton";
|
||||||
|
import { Tooltip } from "../components/Tooltip";
|
||||||
|
import { questionCircle } from "../components/icons";
|
||||||
|
import { loadFromJSON, saveAsJSON } from "../data";
|
||||||
import { t } from "../i18n";
|
import { t } from "../i18n";
|
||||||
import useIsMobile from "../is-mobile";
|
import useIsMobile from "../is-mobile";
|
||||||
import { register } from "./register";
|
|
||||||
import { KEYS } from "../keys";
|
import { KEYS } from "../keys";
|
||||||
|
import { muteFSAbortError } from "../utils";
|
||||||
|
import { register } from "./register";
|
||||||
|
import "../components/ToolIcon.scss";
|
||||||
|
import { SCENE_NAME_FALLBACK } from "../constants";
|
||||||
|
|
||||||
export const actionChangeProjectName = register({
|
export const actionChangeProjectName = register({
|
||||||
name: "changeProjectName",
|
name: "changeProjectName",
|
||||||
perform: (_elements, appState, value) => {
|
perform: (_elements, appState, value) => {
|
||||||
|
trackEvent(EVENT_CHANGE, "title");
|
||||||
return { appState: { ...appState, name: value }, commitToHistory: false };
|
return { appState: { ...appState, name: value }, commitToHistory: false };
|
||||||
},
|
},
|
||||||
PanelComponent: ({ appState, updateData }) => (
|
PanelComponent: ({ appState, updateData }) => (
|
||||||
<ProjectName
|
<ProjectName
|
||||||
label={t("labels.fileTitle")}
|
label={t("labels.fileTitle")}
|
||||||
value={appState.name || "Unnamed"}
|
value={appState.name || SCENE_NAME_FALLBACK}
|
||||||
onChange={(name: string) => updateData(name)}
|
onChange={(name: string) => updateData(name)}
|
||||||
/>
|
/>
|
||||||
),
|
),
|
||||||
@@ -42,6 +49,33 @@ export const actionChangeExportBackground = register({
|
|||||||
),
|
),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
export const actionChangeExportEmbedScene = register({
|
||||||
|
name: "changeExportEmbedScene",
|
||||||
|
perform: (_elements, appState, value) => {
|
||||||
|
return {
|
||||||
|
appState: { ...appState, exportEmbedScene: value },
|
||||||
|
commitToHistory: false,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
PanelComponent: ({ appState, updateData }) => (
|
||||||
|
<label style={{ display: "flex" }}>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={appState.exportEmbedScene}
|
||||||
|
onChange={(event) => updateData(event.target.checked)}
|
||||||
|
/>{" "}
|
||||||
|
{t("labels.exportEmbedScene")}
|
||||||
|
<Tooltip
|
||||||
|
label={t("labels.exportEmbedScene_details")}
|
||||||
|
position="above"
|
||||||
|
long={true}
|
||||||
|
>
|
||||||
|
<div className="TooltipIcon">{questionCircle}</div>
|
||||||
|
</Tooltip>
|
||||||
|
</label>
|
||||||
|
),
|
||||||
|
});
|
||||||
|
|
||||||
export const actionChangeShouldAddWatermark = register({
|
export const actionChangeShouldAddWatermark = register({
|
||||||
name: "changeShouldAddWatermark",
|
name: "changeShouldAddWatermark",
|
||||||
perform: (_elements, appState, value) => {
|
perform: (_elements, appState, value) => {
|
||||||
@@ -64,13 +98,20 @@ export const actionChangeShouldAddWatermark = register({
|
|||||||
|
|
||||||
export const actionSaveScene = register({
|
export const actionSaveScene = register({
|
||||||
name: "saveScene",
|
name: "saveScene",
|
||||||
perform: (elements, appState, value) => {
|
perform: async (elements, appState, value) => {
|
||||||
saveAsJSON(elements, appState).catch((error) => console.error(error));
|
try {
|
||||||
return { commitToHistory: false };
|
const { fileHandle } = await saveAsJSON(elements, appState);
|
||||||
},
|
trackEvent(EVENT_IO, "save");
|
||||||
keyTest: (event) => {
|
return { commitToHistory: false, appState: { ...appState, fileHandle } };
|
||||||
return event.key === "s" && event[KEYS.CTRL_OR_CMD];
|
} catch (error) {
|
||||||
|
if (error?.name !== "AbortError") {
|
||||||
|
console.error(error);
|
||||||
|
}
|
||||||
|
return { commitToHistory: false };
|
||||||
|
}
|
||||||
},
|
},
|
||||||
|
keyTest: (event) =>
|
||||||
|
event.key === KEYS.S && event[KEYS.CTRL_OR_CMD] && !event.shiftKey,
|
||||||
PanelComponent: ({ updateData }) => (
|
PanelComponent: ({ updateData }) => (
|
||||||
<ToolButton
|
<ToolButton
|
||||||
type="button"
|
type="button"
|
||||||
@@ -83,23 +124,55 @@ export const actionSaveScene = register({
|
|||||||
),
|
),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
export const actionSaveAsScene = register({
|
||||||
|
name: "saveAsScene",
|
||||||
|
perform: async (elements, appState, value) => {
|
||||||
|
try {
|
||||||
|
const { fileHandle } = await saveAsJSON(elements, {
|
||||||
|
...appState,
|
||||||
|
fileHandle: null,
|
||||||
|
});
|
||||||
|
trackEvent(EVENT_IO, "save as");
|
||||||
|
return { commitToHistory: false, appState: { ...appState, fileHandle } };
|
||||||
|
} catch (error) {
|
||||||
|
if (error?.name !== "AbortError") {
|
||||||
|
console.error(error);
|
||||||
|
}
|
||||||
|
return { commitToHistory: false };
|
||||||
|
}
|
||||||
|
},
|
||||||
|
keyTest: (event) =>
|
||||||
|
event.key === KEYS.S && event.shiftKey && event[KEYS.CTRL_OR_CMD],
|
||||||
|
PanelComponent: ({ updateData }) => (
|
||||||
|
<ToolButton
|
||||||
|
type="button"
|
||||||
|
icon={saveAs}
|
||||||
|
title={t("buttons.saveAs")}
|
||||||
|
aria-label={t("buttons.saveAs")}
|
||||||
|
showAriaLabel={useIsMobile()}
|
||||||
|
hidden={
|
||||||
|
!("chooseFileSystemEntries" in window || "showOpenFilePicker" in window)
|
||||||
|
}
|
||||||
|
onClick={() => updateData(null)}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
});
|
||||||
|
|
||||||
export const actionLoadScene = register({
|
export const actionLoadScene = register({
|
||||||
name: "loadScene",
|
name: "loadScene",
|
||||||
perform: (
|
perform: (
|
||||||
elements,
|
elements,
|
||||||
appState,
|
appState,
|
||||||
{ elements: loadedElements, appState: loadedAppState, error },
|
{ elements: loadedElements, appState: loadedAppState, error },
|
||||||
) => {
|
) => ({
|
||||||
return {
|
elements: loadedElements,
|
||||||
elements: loadedElements,
|
appState: {
|
||||||
appState: {
|
...loadedAppState,
|
||||||
...loadedAppState,
|
errorMessage: error,
|
||||||
errorMessage: error,
|
},
|
||||||
},
|
commitToHistory: true,
|
||||||
commitToHistory: false,
|
}),
|
||||||
};
|
PanelComponent: ({ updateData, appState }) => (
|
||||||
},
|
|
||||||
PanelComponent: ({ updateData }) => (
|
|
||||||
<ToolButton
|
<ToolButton
|
||||||
type="button"
|
type="button"
|
||||||
icon={load}
|
icon={load}
|
||||||
@@ -107,15 +180,12 @@ export const actionLoadScene = register({
|
|||||||
aria-label={t("buttons.load")}
|
aria-label={t("buttons.load")}
|
||||||
showAriaLabel={useIsMobile()}
|
showAriaLabel={useIsMobile()}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
loadFromJSON()
|
loadFromJSON(appState)
|
||||||
.then(({ elements, appState }) => {
|
.then(({ elements, appState }) => {
|
||||||
updateData({ elements: elements, appState: appState });
|
updateData({ elements, appState });
|
||||||
})
|
})
|
||||||
|
.catch(muteFSAbortError)
|
||||||
.catch((error) => {
|
.catch((error) => {
|
||||||
// if user cancels, ignore the error
|
|
||||||
if (error.name === "AbortError") {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
updateData({ error: error.message });
|
updateData({ error: error.message });
|
||||||
});
|
});
|
||||||
}}
|
}}
|
||||||
|
|||||||
@@ -8,10 +8,47 @@ import { t } from "../i18n";
|
|||||||
import { register } from "./register";
|
import { register } from "./register";
|
||||||
import { mutateElement } from "../element/mutateElement";
|
import { mutateElement } from "../element/mutateElement";
|
||||||
import { isPathALoop } from "../math";
|
import { isPathALoop } from "../math";
|
||||||
|
import { LinearElementEditor } from "../element/linearElementEditor";
|
||||||
|
import Scene from "../scene/Scene";
|
||||||
|
import {
|
||||||
|
maybeBindLinearElement,
|
||||||
|
bindOrUnbindLinearElement,
|
||||||
|
} from "../element/binding";
|
||||||
|
import { isBindingElement } from "../element/typeChecks";
|
||||||
|
|
||||||
export const actionFinalize = register({
|
export const actionFinalize = register({
|
||||||
name: "finalize",
|
name: "finalize",
|
||||||
perform: (elements, appState) => {
|
perform: (elements, appState) => {
|
||||||
|
if (appState.editingLinearElement) {
|
||||||
|
const {
|
||||||
|
elementId,
|
||||||
|
startBindingElement,
|
||||||
|
endBindingElement,
|
||||||
|
} = appState.editingLinearElement;
|
||||||
|
const element = LinearElementEditor.getElement(elementId);
|
||||||
|
|
||||||
|
if (element) {
|
||||||
|
if (isBindingElement(element)) {
|
||||||
|
bindOrUnbindLinearElement(
|
||||||
|
element,
|
||||||
|
startBindingElement,
|
||||||
|
endBindingElement,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
elements:
|
||||||
|
element.points.length < 2 || isInvisiblySmallElement(element)
|
||||||
|
? elements.filter((el) => el.id !== element.id)
|
||||||
|
: undefined,
|
||||||
|
appState: {
|
||||||
|
...appState,
|
||||||
|
editingLinearElement: null,
|
||||||
|
},
|
||||||
|
commitToHistory: true,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
let newElements = elements;
|
let newElements = elements;
|
||||||
if (window.document.activeElement instanceof HTMLElement) {
|
if (window.document.activeElement instanceof HTMLElement) {
|
||||||
window.document.activeElement.blur();
|
window.document.activeElement.blur();
|
||||||
@@ -46,16 +83,17 @@ export const actionFinalize = register({
|
|||||||
// If the multi point line closes the loop,
|
// If the multi point line closes the loop,
|
||||||
// set the last point to first point.
|
// set the last point to first point.
|
||||||
// This ensures that loop remains closed at different scales.
|
// This ensures that loop remains closed at different scales.
|
||||||
|
const isLoop = isPathALoop(multiPointElement.points);
|
||||||
if (
|
if (
|
||||||
multiPointElement.type === "line" ||
|
multiPointElement.type === "line" ||
|
||||||
multiPointElement.type === "draw"
|
multiPointElement.type === "draw"
|
||||||
) {
|
) {
|
||||||
if (isPathALoop(multiPointElement.points)) {
|
if (isLoop) {
|
||||||
const linePoints = multiPointElement.points;
|
const linePoints = multiPointElement.points;
|
||||||
const firstPoint = linePoints[0];
|
const firstPoint = linePoints[0];
|
||||||
mutateElement(multiPointElement, {
|
mutateElement(multiPointElement, {
|
||||||
points: linePoints.map((point, i) =>
|
points: linePoints.map((point, index) =>
|
||||||
i === linePoints.length - 1
|
index === linePoints.length - 1
|
||||||
? ([firstPoint[0], firstPoint[1]] as const)
|
? ([firstPoint[0], firstPoint[1]] as const)
|
||||||
: point,
|
: point,
|
||||||
),
|
),
|
||||||
@@ -63,6 +101,23 @@ export const actionFinalize = register({
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
isBindingElement(multiPointElement) &&
|
||||||
|
!isLoop &&
|
||||||
|
multiPointElement.points.length > 1
|
||||||
|
) {
|
||||||
|
const [x, y] = LinearElementEditor.getPointAtIndexGlobalCoordinates(
|
||||||
|
multiPointElement,
|
||||||
|
-1,
|
||||||
|
);
|
||||||
|
maybeBindLinearElement(
|
||||||
|
multiPointElement,
|
||||||
|
appState,
|
||||||
|
Scene.getScene(multiPointElement)!,
|
||||||
|
{ x, y },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
if (!appState.elementLocked) {
|
if (!appState.elementLocked) {
|
||||||
appState.selectedElementIds[multiPointElement.id] = true;
|
appState.selectedElementIds[multiPointElement.id] = true;
|
||||||
}
|
}
|
||||||
@@ -81,6 +136,8 @@ export const actionFinalize = register({
|
|||||||
draggingElement: null,
|
draggingElement: null,
|
||||||
multiElement: null,
|
multiElement: null,
|
||||||
editingElement: null,
|
editingElement: null,
|
||||||
|
startBoundElement: null,
|
||||||
|
suggestedBindings: [],
|
||||||
selectedElementIds:
|
selectedElementIds:
|
||||||
multiPointElement && !appState.elementLocked
|
multiPointElement && !appState.elementLocked
|
||||||
? {
|
? {
|
||||||
@@ -89,13 +146,13 @@ export const actionFinalize = register({
|
|||||||
}
|
}
|
||||||
: appState.selectedElementIds,
|
: appState.selectedElementIds,
|
||||||
},
|
},
|
||||||
commitToHistory: false,
|
commitToHistory: appState.elementType === "draw",
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
keyTest: (event, appState) =>
|
keyTest: (event, appState) =>
|
||||||
(event.key === KEYS.ESCAPE &&
|
(event.key === KEYS.ESCAPE &&
|
||||||
!appState.draggingElement &&
|
(appState.editingLinearElement !== null ||
|
||||||
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 }) => (
|
||||||
|
|||||||
@@ -0,0 +1,193 @@
|
|||||||
|
import React from "react";
|
||||||
|
import { CODES, KEYS } from "../keys";
|
||||||
|
import { t } from "../i18n";
|
||||||
|
import { getShortcutKey } from "../utils";
|
||||||
|
import { register } from "./register";
|
||||||
|
import { UngroupIcon, GroupIcon } from "../components/icons";
|
||||||
|
import { newElementWith } from "../element/mutateElement";
|
||||||
|
import { getSelectedElements, isSomeElementSelected } from "../scene";
|
||||||
|
import {
|
||||||
|
getSelectedGroupIds,
|
||||||
|
selectGroup,
|
||||||
|
selectGroupsForSelectedElements,
|
||||||
|
getElementsInGroup,
|
||||||
|
addToGroup,
|
||||||
|
removeFromSelectedGroups,
|
||||||
|
isElementInGroup,
|
||||||
|
} from "../groups";
|
||||||
|
import { getNonDeletedElements } from "../element";
|
||||||
|
import { randomId } from "../random";
|
||||||
|
import { ToolButton } from "../components/ToolButton";
|
||||||
|
import { ExcalidrawElement } from "../element/types";
|
||||||
|
import { AppState } from "../types";
|
||||||
|
|
||||||
|
const allElementsInSameGroup = (elements: readonly ExcalidrawElement[]) => {
|
||||||
|
if (elements.length >= 2) {
|
||||||
|
const groupIds = elements[0].groupIds;
|
||||||
|
for (const groupId of groupIds) {
|
||||||
|
if (
|
||||||
|
elements.reduce(
|
||||||
|
(acc, element) => acc && isElementInGroup(element, groupId),
|
||||||
|
true,
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
|
||||||
|
const enableActionGroup = (
|
||||||
|
elements: readonly ExcalidrawElement[],
|
||||||
|
appState: AppState,
|
||||||
|
) => {
|
||||||
|
const selectedElements = getSelectedElements(
|
||||||
|
getNonDeletedElements(elements),
|
||||||
|
appState,
|
||||||
|
);
|
||||||
|
return (
|
||||||
|
selectedElements.length >= 2 && !allElementsInSameGroup(selectedElements)
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const actionGroup = register({
|
||||||
|
name: "group",
|
||||||
|
perform: (elements, appState) => {
|
||||||
|
const selectedElements = getSelectedElements(
|
||||||
|
getNonDeletedElements(elements),
|
||||||
|
appState,
|
||||||
|
);
|
||||||
|
if (selectedElements.length < 2) {
|
||||||
|
// nothing to group
|
||||||
|
return { appState, elements, commitToHistory: false };
|
||||||
|
}
|
||||||
|
// if everything is already grouped into 1 group, there is nothing to do
|
||||||
|
const selectedGroupIds = getSelectedGroupIds(appState);
|
||||||
|
if (selectedGroupIds.length === 1) {
|
||||||
|
const selectedGroupId = selectedGroupIds[0];
|
||||||
|
const elementIdsInGroup = new Set(
|
||||||
|
getElementsInGroup(elements, selectedGroupId).map(
|
||||||
|
(element) => element.id,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
const selectedElementIds = new Set(
|
||||||
|
selectedElements.map((element) => element.id),
|
||||||
|
);
|
||||||
|
const combinedSet = new Set([
|
||||||
|
...Array.from(elementIdsInGroup),
|
||||||
|
...Array.from(selectedElementIds),
|
||||||
|
]);
|
||||||
|
if (combinedSet.size === elementIdsInGroup.size) {
|
||||||
|
// no incremental ids in the selected ids
|
||||||
|
return { appState, elements, commitToHistory: false };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const newGroupId = randomId();
|
||||||
|
const updatedElements = elements.map((element) => {
|
||||||
|
if (!appState.selectedElementIds[element.id]) {
|
||||||
|
return element;
|
||||||
|
}
|
||||||
|
return newElementWith(element, {
|
||||||
|
groupIds: addToGroup(
|
||||||
|
element.groupIds,
|
||||||
|
newGroupId,
|
||||||
|
appState.editingGroupId,
|
||||||
|
),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
// keep the z order within the group the same, but move them
|
||||||
|
// to the z order of the highest element in the layer stack
|
||||||
|
const elementsInGroup = getElementsInGroup(updatedElements, newGroupId);
|
||||||
|
const lastElementInGroup = elementsInGroup[elementsInGroup.length - 1];
|
||||||
|
const lastGroupElementIndex = updatedElements.lastIndexOf(
|
||||||
|
lastElementInGroup,
|
||||||
|
);
|
||||||
|
const elementsAfterGroup = updatedElements.slice(lastGroupElementIndex + 1);
|
||||||
|
const elementsBeforeGroup = updatedElements
|
||||||
|
.slice(0, lastGroupElementIndex)
|
||||||
|
.filter(
|
||||||
|
(updatedElement) => !isElementInGroup(updatedElement, newGroupId),
|
||||||
|
);
|
||||||
|
const updatedElementsInOrder = [
|
||||||
|
...elementsBeforeGroup,
|
||||||
|
...elementsInGroup,
|
||||||
|
...elementsAfterGroup,
|
||||||
|
];
|
||||||
|
|
||||||
|
return {
|
||||||
|
appState: selectGroup(
|
||||||
|
newGroupId,
|
||||||
|
{ ...appState, selectedGroupIds: {} },
|
||||||
|
getNonDeletedElements(updatedElementsInOrder),
|
||||||
|
),
|
||||||
|
elements: updatedElementsInOrder,
|
||||||
|
commitToHistory: true,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
contextMenuOrder: 4,
|
||||||
|
contextItemLabel: "labels.group",
|
||||||
|
contextItemPredicate: (elements, appState) =>
|
||||||
|
enableActionGroup(elements, appState),
|
||||||
|
keyTest: (event) =>
|
||||||
|
!event.shiftKey && event[KEYS.CTRL_OR_CMD] && event.code === CODES.G,
|
||||||
|
PanelComponent: ({ elements, appState, updateData }) => (
|
||||||
|
<ToolButton
|
||||||
|
hidden={!enableActionGroup(elements, appState)}
|
||||||
|
type="button"
|
||||||
|
icon={<GroupIcon appearance={appState.appearance} />}
|
||||||
|
onClick={() => updateData(null)}
|
||||||
|
title={`${t("labels.group")} — ${getShortcutKey("CtrlOrCmd+G")}`}
|
||||||
|
aria-label={t("labels.group")}
|
||||||
|
visible={isSomeElementSelected(getNonDeletedElements(elements), appState)}
|
||||||
|
></ToolButton>
|
||||||
|
),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const actionUngroup = register({
|
||||||
|
name: "ungroup",
|
||||||
|
perform: (elements, appState) => {
|
||||||
|
const groupIds = getSelectedGroupIds(appState);
|
||||||
|
if (groupIds.length === 0) {
|
||||||
|
return { appState, elements, commitToHistory: false };
|
||||||
|
}
|
||||||
|
const nextElements = elements.map((element) => {
|
||||||
|
const nextGroupIds = removeFromSelectedGroups(
|
||||||
|
element.groupIds,
|
||||||
|
appState.selectedGroupIds,
|
||||||
|
);
|
||||||
|
if (nextGroupIds.length === element.groupIds.length) {
|
||||||
|
return element;
|
||||||
|
}
|
||||||
|
return newElementWith(element, {
|
||||||
|
groupIds: nextGroupIds,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
return {
|
||||||
|
appState: selectGroupsForSelectedElements(
|
||||||
|
{ ...appState, selectedGroupIds: {} },
|
||||||
|
getNonDeletedElements(nextElements),
|
||||||
|
),
|
||||||
|
elements: nextElements,
|
||||||
|
commitToHistory: true,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
keyTest: (event) =>
|
||||||
|
event.shiftKey && event[KEYS.CTRL_OR_CMD] && event.code === CODES.G,
|
||||||
|
contextMenuOrder: 5,
|
||||||
|
contextItemLabel: "labels.ungroup",
|
||||||
|
contextItemPredicate: (elements, appState) =>
|
||||||
|
getSelectedGroupIds(appState).length > 0,
|
||||||
|
|
||||||
|
PanelComponent: ({ elements, appState, updateData }) => (
|
||||||
|
<ToolButton
|
||||||
|
type="button"
|
||||||
|
hidden={getSelectedGroupIds(appState).length === 0}
|
||||||
|
icon={<UngroupIcon appearance={appState.appearance} />}
|
||||||
|
onClick={() => updateData(null)}
|
||||||
|
title={`${t("labels.ungroup")} — ${getShortcutKey("CtrlOrCmd+Shift+G")}`}
|
||||||
|
aria-label={t("labels.ungroup")}
|
||||||
|
visible={isSomeElementSelected(getNonDeletedElements(elements), appState)}
|
||||||
|
></ToolButton>
|
||||||
|
),
|
||||||
|
});
|
||||||
@@ -3,20 +3,18 @@ import React from "react";
|
|||||||
import { undo, redo } from "../components/icons";
|
import { undo, redo } from "../components/icons";
|
||||||
import { ToolButton } from "../components/ToolButton";
|
import { ToolButton } from "../components/ToolButton";
|
||||||
import { t } from "../i18n";
|
import { t } from "../i18n";
|
||||||
import { SceneHistory } from "../history";
|
import { SceneHistory, HistoryEntry } from "../history";
|
||||||
import { ExcalidrawElement } from "../element/types";
|
import { ExcalidrawElement } from "../element/types";
|
||||||
import { AppState } from "../types";
|
import { AppState } from "../types";
|
||||||
import { KEYS } from "../keys";
|
import { KEYS } from "../keys";
|
||||||
import { getElementMap } from "../element";
|
import { getElementMap } from "../element";
|
||||||
import { newElementWith } from "../element/mutateElement";
|
import { newElementWith } from "../element/mutateElement";
|
||||||
|
import { fixBindingsAfterDeletion } from "../element/binding";
|
||||||
|
|
||||||
const writeData = (
|
const writeData = (
|
||||||
prevElements: readonly ExcalidrawElement[],
|
prevElements: readonly ExcalidrawElement[],
|
||||||
appState: AppState,
|
appState: AppState,
|
||||||
updater: () => {
|
updater: () => HistoryEntry | null,
|
||||||
elements: ExcalidrawElement[];
|
|
||||||
appState: AppState;
|
|
||||||
} | null,
|
|
||||||
): ActionResult => {
|
): ActionResult => {
|
||||||
const commitToHistory = false;
|
const commitToHistory = false;
|
||||||
if (
|
if (
|
||||||
@@ -33,25 +31,29 @@ const writeData = (
|
|||||||
const prevElementMap = getElementMap(prevElements);
|
const prevElementMap = getElementMap(prevElements);
|
||||||
const nextElements = data.elements;
|
const nextElements = data.elements;
|
||||||
const nextElementMap = getElementMap(nextElements);
|
const nextElementMap = getElementMap(nextElements);
|
||||||
return {
|
|
||||||
elements: nextElements
|
const deletedElements = prevElements.filter(
|
||||||
.map((nextElement) =>
|
(prevElement) => !nextElementMap.hasOwnProperty(prevElement.id),
|
||||||
newElementWith(
|
);
|
||||||
prevElementMap[nextElement.id] || nextElement,
|
const elements = nextElements
|
||||||
nextElement,
|
.map((nextElement) =>
|
||||||
),
|
newElementWith(
|
||||||
)
|
prevElementMap[nextElement.id] || nextElement,
|
||||||
.concat(
|
nextElement,
|
||||||
prevElements
|
|
||||||
.filter(
|
|
||||||
(prevElement) => !nextElementMap.hasOwnProperty(prevElement.id),
|
|
||||||
)
|
|
||||||
.map((prevElement) =>
|
|
||||||
newElementWith(prevElement, { isDeleted: true }),
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
|
)
|
||||||
|
.concat(
|
||||||
|
deletedElements.map((prevElement) =>
|
||||||
|
newElementWith(prevElement, { isDeleted: true }),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
fixBindingsAfterDeletion(elements, deletedElements);
|
||||||
|
|
||||||
|
return {
|
||||||
|
elements,
|
||||||
appState: { ...appState, ...data.appState },
|
appState: { ...appState, ...data.appState },
|
||||||
commitToHistory,
|
commitToHistory,
|
||||||
|
syncHistory: true,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
return { commitToHistory };
|
return { commitToHistory };
|
||||||
|
|||||||
@@ -5,8 +5,9 @@ import { t } from "../i18n";
|
|||||||
import { showSelectedShapeActions, getNonDeletedElements } from "../element";
|
import { showSelectedShapeActions, getNonDeletedElements } from "../element";
|
||||||
import { register } from "./register";
|
import { register } from "./register";
|
||||||
import { allowFullScreen, exitFullScreen, isFullScreen } from "../utils";
|
import { allowFullScreen, exitFullScreen, isFullScreen } from "../utils";
|
||||||
import { KEYS } from "../keys";
|
import { CODES, KEYS } from "../keys";
|
||||||
import { HelpIcon } from "../components/HelpIcon";
|
import { HelpIcon } from "../components/HelpIcon";
|
||||||
|
import { EVENT_DIALOG, trackEvent } from "../analytics";
|
||||||
|
|
||||||
export const actionToggleCanvasMenu = register({
|
export const actionToggleCanvasMenu = register({
|
||||||
name: "toggleCanvasMenu",
|
name: "toggleCanvasMenu",
|
||||||
@@ -65,12 +66,13 @@ export const actionFullScreen = register({
|
|||||||
commitToHistory: false,
|
commitToHistory: false,
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
keyTest: (event) => event.keyCode === KEYS.F_KEY_CODE,
|
keyTest: (event) => event.code === CODES.F && !event[KEYS.CTRL_OR_CMD],
|
||||||
});
|
});
|
||||||
|
|
||||||
export const actionShortcuts = register({
|
export const actionShortcuts = register({
|
||||||
name: "toggleShortcuts",
|
name: "toggleShortcuts",
|
||||||
perform: (_elements, appState) => {
|
perform: (_elements, appState) => {
|
||||||
|
trackEvent(EVENT_DIALOG, "shortcuts");
|
||||||
return {
|
return {
|
||||||
appState: {
|
appState: {
|
||||||
...appState,
|
...appState,
|
||||||
|
|||||||
@@ -0,0 +1,60 @@
|
|||||||
|
import React from "react";
|
||||||
|
import { Avatar } from "../components/Avatar";
|
||||||
|
import { register } from "./register";
|
||||||
|
import { getClientColors, getClientInitials } from "../clients";
|
||||||
|
import { Collaborator } from "../types";
|
||||||
|
import { centerScrollOn } from "../scene/scroll";
|
||||||
|
import { EVENT_SHARE, trackEvent } from "../analytics";
|
||||||
|
|
||||||
|
export const actionGoToCollaborator = register({
|
||||||
|
name: "goToCollaborator",
|
||||||
|
perform: (_elements, appState, value) => {
|
||||||
|
const point = value as Collaborator["pointer"];
|
||||||
|
trackEvent(EVENT_SHARE, "go to collaborator");
|
||||||
|
if (!point) {
|
||||||
|
return { appState, commitToHistory: false };
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
appState: {
|
||||||
|
...appState,
|
||||||
|
...centerScrollOn({
|
||||||
|
scenePoint: point,
|
||||||
|
viewportDimensions: {
|
||||||
|
width: appState.width,
|
||||||
|
height: appState.height,
|
||||||
|
},
|
||||||
|
zoom: appState.zoom,
|
||||||
|
}),
|
||||||
|
// Close mobile menu
|
||||||
|
openMenu: appState.openMenu === "canvas" ? null : appState.openMenu,
|
||||||
|
},
|
||||||
|
commitToHistory: false,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
PanelComponent: ({ appState, updateData, id }) => {
|
||||||
|
const clientId = id;
|
||||||
|
if (!clientId) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const collaborator = appState.collaborators.get(clientId);
|
||||||
|
|
||||||
|
if (!collaborator) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { background, stroke } = getClientColors(clientId);
|
||||||
|
const shortName = getClientInitials(collaborator.username);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Avatar
|
||||||
|
color={background}
|
||||||
|
border={stroke}
|
||||||
|
onClick={() => updateData(collaborator.pointer)}
|
||||||
|
>
|
||||||
|
{shortName}
|
||||||
|
</Avatar>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -1,25 +1,56 @@
|
|||||||
import React from "react";
|
import React from "react";
|
||||||
|
import { getLanguage } from "../i18n";
|
||||||
import {
|
import {
|
||||||
ExcalidrawElement,
|
ExcalidrawElement,
|
||||||
ExcalidrawTextElement,
|
ExcalidrawTextElement,
|
||||||
TextAlign,
|
TextAlign,
|
||||||
|
FontFamily,
|
||||||
|
ExcalidrawLinearElement,
|
||||||
|
Arrowhead,
|
||||||
} from "../element/types";
|
} from "../element/types";
|
||||||
import {
|
import {
|
||||||
getCommonAttributeOfSelectedElements,
|
getCommonAttributeOfSelectedElements,
|
||||||
isSomeElementSelected,
|
isSomeElementSelected,
|
||||||
|
getTargetElements,
|
||||||
|
canChangeSharpness,
|
||||||
|
canHaveArrowheads,
|
||||||
} from "../scene";
|
} from "../scene";
|
||||||
import { ButtonSelect } from "../components/ButtonSelect";
|
import { ButtonSelect } from "../components/ButtonSelect";
|
||||||
|
import { ButtonIconSelect } from "../components/ButtonIconSelect";
|
||||||
|
import { IconPicker } from "../components/IconPicker";
|
||||||
import {
|
import {
|
||||||
isTextElement,
|
isTextElement,
|
||||||
redrawTextBoundingBox,
|
redrawTextBoundingBox,
|
||||||
getNonDeletedElements,
|
getNonDeletedElements,
|
||||||
} from "../element";
|
} from "../element";
|
||||||
|
import { isLinearElement, isLinearElementType } from "../element/typeChecks";
|
||||||
import { ColorPicker } from "../components/ColorPicker";
|
import { ColorPicker } from "../components/ColorPicker";
|
||||||
import { AppState } from "../../src/types";
|
import { AppState } from "../../src/types";
|
||||||
import { t } from "../i18n";
|
import { t } from "../i18n";
|
||||||
import { DEFAULT_FONT } from "../appState";
|
|
||||||
import { register } from "./register";
|
import { register } from "./register";
|
||||||
import { newElementWith } from "../element/mutateElement";
|
import { newElementWith } from "../element/mutateElement";
|
||||||
|
import { DEFAULT_FONT_SIZE, DEFAULT_FONT_FAMILY } from "../constants";
|
||||||
|
import { randomInteger } from "../random";
|
||||||
|
import {
|
||||||
|
FillHachureIcon,
|
||||||
|
FillCrossHatchIcon,
|
||||||
|
FillSolidIcon,
|
||||||
|
StrokeWidthIcon,
|
||||||
|
StrokeStyleSolidIcon,
|
||||||
|
StrokeStyleDashedIcon,
|
||||||
|
StrokeStyleDottedIcon,
|
||||||
|
EdgeSharpIcon,
|
||||||
|
EdgeRoundIcon,
|
||||||
|
SloppinessArchitectIcon,
|
||||||
|
SloppinessArtistIcon,
|
||||||
|
SloppinessCartoonistIcon,
|
||||||
|
ArrowheadArrowIcon,
|
||||||
|
ArrowheadBarIcon,
|
||||||
|
ArrowheadDotIcon,
|
||||||
|
ArrowheadNoneIcon,
|
||||||
|
} from "../components/icons";
|
||||||
|
import { EVENT_CHANGE, trackEvent } from "../analytics";
|
||||||
|
import colors from "../colors";
|
||||||
|
|
||||||
const changeProperty = (
|
const changeProperty = (
|
||||||
elements: readonly ExcalidrawElement[],
|
elements: readonly ExcalidrawElement[],
|
||||||
@@ -61,6 +92,15 @@ const getFormValue = function <T>(
|
|||||||
export const actionChangeStrokeColor = register({
|
export const actionChangeStrokeColor = register({
|
||||||
name: "changeStrokeColor",
|
name: "changeStrokeColor",
|
||||||
perform: (elements, appState, value) => {
|
perform: (elements, appState, value) => {
|
||||||
|
if (value !== appState.currentItemStrokeColor) {
|
||||||
|
trackEvent(
|
||||||
|
EVENT_CHANGE,
|
||||||
|
"stroke color",
|
||||||
|
colors.elementStroke.includes(value)
|
||||||
|
? `${value} (picker ${colors.elementStroke.indexOf(value)})`
|
||||||
|
: value,
|
||||||
|
);
|
||||||
|
}
|
||||||
return {
|
return {
|
||||||
elements: changeProperty(elements, appState, (el) =>
|
elements: changeProperty(elements, appState, (el) =>
|
||||||
newElementWith(el, {
|
newElementWith(el, {
|
||||||
@@ -92,6 +132,16 @@ export const actionChangeStrokeColor = register({
|
|||||||
export const actionChangeBackgroundColor = register({
|
export const actionChangeBackgroundColor = register({
|
||||||
name: "changeBackgroundColor",
|
name: "changeBackgroundColor",
|
||||||
perform: (elements, appState, value) => {
|
perform: (elements, appState, value) => {
|
||||||
|
if (value !== appState.currentItemBackgroundColor) {
|
||||||
|
trackEvent(
|
||||||
|
EVENT_CHANGE,
|
||||||
|
"background color",
|
||||||
|
colors.elementBackground.includes(value)
|
||||||
|
? `${value} (picker ${colors.elementBackground.indexOf(value)})`
|
||||||
|
: value,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
elements: changeProperty(elements, appState, (el) =>
|
elements: changeProperty(elements, appState, (el) =>
|
||||||
newElementWith(el, {
|
newElementWith(el, {
|
||||||
@@ -123,6 +173,7 @@ export const actionChangeBackgroundColor = register({
|
|||||||
export const actionChangeFillStyle = register({
|
export const actionChangeFillStyle = register({
|
||||||
name: "changeFillStyle",
|
name: "changeFillStyle",
|
||||||
perform: (elements, appState, value) => {
|
perform: (elements, appState, value) => {
|
||||||
|
trackEvent(EVENT_CHANGE, "fill", value);
|
||||||
return {
|
return {
|
||||||
elements: changeProperty(elements, appState, (el) =>
|
elements: changeProperty(elements, appState, (el) =>
|
||||||
newElementWith(el, {
|
newElementWith(el, {
|
||||||
@@ -136,11 +187,23 @@ export const actionChangeFillStyle = register({
|
|||||||
PanelComponent: ({ elements, appState, updateData }) => (
|
PanelComponent: ({ elements, appState, updateData }) => (
|
||||||
<fieldset>
|
<fieldset>
|
||||||
<legend>{t("labels.fill")}</legend>
|
<legend>{t("labels.fill")}</legend>
|
||||||
<ButtonSelect
|
<ButtonIconSelect
|
||||||
options={[
|
options={[
|
||||||
{ value: "hachure", text: t("labels.hachure") },
|
{
|
||||||
{ value: "cross-hatch", text: t("labels.crossHatch") },
|
value: "hachure",
|
||||||
{ value: "solid", text: t("labels.solid") },
|
text: t("labels.hachure"),
|
||||||
|
icon: <FillHachureIcon appearance={appState.appearance} />,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: "cross-hatch",
|
||||||
|
text: t("labels.crossHatch"),
|
||||||
|
icon: <FillCrossHatchIcon appearance={appState.appearance} />,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: "solid",
|
||||||
|
text: t("labels.solid"),
|
||||||
|
icon: <FillSolidIcon appearance={appState.appearance} />,
|
||||||
|
},
|
||||||
]}
|
]}
|
||||||
group="fill"
|
group="fill"
|
||||||
value={getFormValue(
|
value={getFormValue(
|
||||||
@@ -160,6 +223,7 @@ export const actionChangeFillStyle = register({
|
|||||||
export const actionChangeStrokeWidth = register({
|
export const actionChangeStrokeWidth = register({
|
||||||
name: "changeStrokeWidth",
|
name: "changeStrokeWidth",
|
||||||
perform: (elements, appState, value) => {
|
perform: (elements, appState, value) => {
|
||||||
|
trackEvent(EVENT_CHANGE, "stroke", "width", value);
|
||||||
return {
|
return {
|
||||||
elements: changeProperty(elements, appState, (el) =>
|
elements: changeProperty(elements, appState, (el) =>
|
||||||
newElementWith(el, {
|
newElementWith(el, {
|
||||||
@@ -173,12 +237,39 @@ export const actionChangeStrokeWidth = register({
|
|||||||
PanelComponent: ({ elements, appState, updateData }) => (
|
PanelComponent: ({ elements, appState, updateData }) => (
|
||||||
<fieldset>
|
<fieldset>
|
||||||
<legend>{t("labels.strokeWidth")}</legend>
|
<legend>{t("labels.strokeWidth")}</legend>
|
||||||
<ButtonSelect
|
<ButtonIconSelect
|
||||||
group="stroke-width"
|
group="stroke-width"
|
||||||
options={[
|
options={[
|
||||||
{ value: 1, text: t("labels.thin") },
|
{
|
||||||
{ value: 2, text: t("labels.bold") },
|
value: 1,
|
||||||
{ value: 4, text: t("labels.extraBold") },
|
text: t("labels.thin"),
|
||||||
|
icon: (
|
||||||
|
<StrokeWidthIcon
|
||||||
|
appearance={appState.appearance}
|
||||||
|
strokeWidth={2}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: 2,
|
||||||
|
text: t("labels.bold"),
|
||||||
|
icon: (
|
||||||
|
<StrokeWidthIcon
|
||||||
|
appearance={appState.appearance}
|
||||||
|
strokeWidth={6}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: 4,
|
||||||
|
text: t("labels.extraBold"),
|
||||||
|
icon: (
|
||||||
|
<StrokeWidthIcon
|
||||||
|
appearance={appState.appearance}
|
||||||
|
strokeWidth={10}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
},
|
||||||
]}
|
]}
|
||||||
value={getFormValue(
|
value={getFormValue(
|
||||||
elements,
|
elements,
|
||||||
@@ -195,9 +286,11 @@ export const actionChangeStrokeWidth = register({
|
|||||||
export const actionChangeSloppiness = register({
|
export const actionChangeSloppiness = register({
|
||||||
name: "changeSloppiness",
|
name: "changeSloppiness",
|
||||||
perform: (elements, appState, value) => {
|
perform: (elements, appState, value) => {
|
||||||
|
trackEvent(EVENT_CHANGE, "stroke", "sloppiness", value);
|
||||||
return {
|
return {
|
||||||
elements: changeProperty(elements, appState, (el) =>
|
elements: changeProperty(elements, appState, (el) =>
|
||||||
newElementWith(el, {
|
newElementWith(el, {
|
||||||
|
seed: randomInteger(),
|
||||||
roughness: value,
|
roughness: value,
|
||||||
}),
|
}),
|
||||||
),
|
),
|
||||||
@@ -208,12 +301,24 @@ export const actionChangeSloppiness = register({
|
|||||||
PanelComponent: ({ elements, appState, updateData }) => (
|
PanelComponent: ({ elements, appState, updateData }) => (
|
||||||
<fieldset>
|
<fieldset>
|
||||||
<legend>{t("labels.sloppiness")}</legend>
|
<legend>{t("labels.sloppiness")}</legend>
|
||||||
<ButtonSelect
|
<ButtonIconSelect
|
||||||
group="sloppiness"
|
group="sloppiness"
|
||||||
options={[
|
options={[
|
||||||
{ value: 0, text: t("labels.architect") },
|
{
|
||||||
{ value: 1, text: t("labels.artist") },
|
value: 0,
|
||||||
{ value: 2, text: t("labels.cartoonist") },
|
text: t("labels.architect"),
|
||||||
|
icon: <SloppinessArchitectIcon appearance={appState.appearance} />,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: 1,
|
||||||
|
text: t("labels.artist"),
|
||||||
|
icon: <SloppinessArtistIcon appearance={appState.appearance} />,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: 2,
|
||||||
|
text: t("labels.cartoonist"),
|
||||||
|
icon: <SloppinessCartoonistIcon appearance={appState.appearance} />,
|
||||||
|
},
|
||||||
]}
|
]}
|
||||||
value={getFormValue(
|
value={getFormValue(
|
||||||
elements,
|
elements,
|
||||||
@@ -227,9 +332,58 @@ export const actionChangeSloppiness = register({
|
|||||||
),
|
),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
export const actionChangeStrokeStyle = register({
|
||||||
|
name: "changeStrokeStyle",
|
||||||
|
perform: (elements, appState, value) => {
|
||||||
|
trackEvent(EVENT_CHANGE, "style", value);
|
||||||
|
return {
|
||||||
|
elements: changeProperty(elements, appState, (el) =>
|
||||||
|
newElementWith(el, {
|
||||||
|
strokeStyle: value,
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
appState: { ...appState, currentItemStrokeStyle: value },
|
||||||
|
commitToHistory: true,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
PanelComponent: ({ elements, appState, updateData }) => (
|
||||||
|
<fieldset>
|
||||||
|
<legend>{t("labels.strokeStyle")}</legend>
|
||||||
|
<ButtonIconSelect
|
||||||
|
group="strokeStyle"
|
||||||
|
options={[
|
||||||
|
{
|
||||||
|
value: "solid",
|
||||||
|
text: t("labels.strokeStyle_solid"),
|
||||||
|
icon: <StrokeStyleSolidIcon appearance={appState.appearance} />,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: "dashed",
|
||||||
|
text: t("labels.strokeStyle_dashed"),
|
||||||
|
icon: <StrokeStyleDashedIcon appearance={appState.appearance} />,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: "dotted",
|
||||||
|
text: t("labels.strokeStyle_dotted"),
|
||||||
|
icon: <StrokeStyleDottedIcon appearance={appState.appearance} />,
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
value={getFormValue(
|
||||||
|
elements,
|
||||||
|
appState,
|
||||||
|
(element) => element.strokeStyle,
|
||||||
|
appState.currentItemStrokeStyle,
|
||||||
|
)}
|
||||||
|
onChange={(value) => updateData(value)}
|
||||||
|
/>
|
||||||
|
</fieldset>
|
||||||
|
),
|
||||||
|
});
|
||||||
|
|
||||||
export const actionChangeOpacity = register({
|
export const actionChangeOpacity = register({
|
||||||
name: "changeOpacity",
|
name: "changeOpacity",
|
||||||
perform: (elements, appState, value) => {
|
perform: (elements, appState, value) => {
|
||||||
|
trackEvent(EVENT_CHANGE, "opacity", "value", value);
|
||||||
return {
|
return {
|
||||||
elements: changeProperty(elements, appState, (el) =>
|
elements: changeProperty(elements, appState, (el) =>
|
||||||
newElementWith(el, {
|
newElementWith(el, {
|
||||||
@@ -283,7 +437,7 @@ export const actionChangeFontSize = register({
|
|||||||
elements: changeProperty(elements, appState, (el) => {
|
elements: changeProperty(elements, appState, (el) => {
|
||||||
if (isTextElement(el)) {
|
if (isTextElement(el)) {
|
||||||
const element: ExcalidrawTextElement = newElementWith(el, {
|
const element: ExcalidrawTextElement = newElementWith(el, {
|
||||||
font: `${value}px ${el.font.split("px ")[1]}`,
|
fontSize: value,
|
||||||
});
|
});
|
||||||
redrawTextBoundingBox(element);
|
redrawTextBoundingBox(element);
|
||||||
return element;
|
return element;
|
||||||
@@ -293,9 +447,7 @@ export const actionChangeFontSize = register({
|
|||||||
}),
|
}),
|
||||||
appState: {
|
appState: {
|
||||||
...appState,
|
...appState,
|
||||||
currentItemFont: `${value}px ${
|
currentItemFontSize: value,
|
||||||
appState.currentItemFont.split("px ")[1]
|
|
||||||
}`,
|
|
||||||
},
|
},
|
||||||
commitToHistory: true,
|
commitToHistory: true,
|
||||||
};
|
};
|
||||||
@@ -314,8 +466,8 @@ export const actionChangeFontSize = register({
|
|||||||
value={getFormValue(
|
value={getFormValue(
|
||||||
elements,
|
elements,
|
||||||
appState,
|
appState,
|
||||||
(element) => isTextElement(element) && +element.font.split("px ")[0],
|
(element) => isTextElement(element) && element.fontSize,
|
||||||
+(appState.currentItemFont || DEFAULT_FONT).split("px ")[0],
|
appState.currentItemFontSize || DEFAULT_FONT_SIZE,
|
||||||
)}
|
)}
|
||||||
onChange={(value) => updateData(value)}
|
onChange={(value) => updateData(value)}
|
||||||
/>
|
/>
|
||||||
@@ -330,7 +482,7 @@ export const actionChangeFontFamily = register({
|
|||||||
elements: changeProperty(elements, appState, (el) => {
|
elements: changeProperty(elements, appState, (el) => {
|
||||||
if (isTextElement(el)) {
|
if (isTextElement(el)) {
|
||||||
const element: ExcalidrawTextElement = newElementWith(el, {
|
const element: ExcalidrawTextElement = newElementWith(el, {
|
||||||
font: `${el.font.split("px ")[0]}px ${value}`,
|
fontFamily: value,
|
||||||
});
|
});
|
||||||
redrawTextBoundingBox(element);
|
redrawTextBoundingBox(element);
|
||||||
return element;
|
return element;
|
||||||
@@ -340,33 +492,35 @@ export const actionChangeFontFamily = register({
|
|||||||
}),
|
}),
|
||||||
appState: {
|
appState: {
|
||||||
...appState,
|
...appState,
|
||||||
currentItemFont: `${
|
currentItemFontFamily: value,
|
||||||
appState.currentItemFont.split("px ")[0]
|
|
||||||
}px ${value}`,
|
|
||||||
},
|
},
|
||||||
commitToHistory: true,
|
commitToHistory: true,
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
PanelComponent: ({ elements, appState, updateData }) => (
|
PanelComponent: ({ elements, appState, updateData }) => {
|
||||||
<fieldset>
|
const options: { value: FontFamily; text: string }[] = [
|
||||||
<legend>{t("labels.fontFamily")}</legend>
|
{ value: 1, text: t("labels.handDrawn") },
|
||||||
<ButtonSelect
|
{ value: 2, text: t("labels.normal") },
|
||||||
group="font-family"
|
{ value: 3, text: t("labels.code") },
|
||||||
options={[
|
];
|
||||||
{ value: "Virgil", text: t("labels.handDrawn") },
|
|
||||||
{ value: "Helvetica", text: t("labels.normal") },
|
return (
|
||||||
{ value: "Cascadia", text: t("labels.code") },
|
<fieldset>
|
||||||
]}
|
<legend>{t("labels.fontFamily")}</legend>
|
||||||
value={getFormValue(
|
<ButtonSelect<FontFamily | false>
|
||||||
elements,
|
group="font-family"
|
||||||
appState,
|
options={options}
|
||||||
(element) => isTextElement(element) && element.font.split("px ")[1],
|
value={getFormValue(
|
||||||
(appState.currentItemFont || DEFAULT_FONT).split("px ")[1],
|
elements,
|
||||||
)}
|
appState,
|
||||||
onChange={(value) => updateData(value)}
|
(element) => isTextElement(element) && element.fontFamily,
|
||||||
/>
|
appState.currentItemFontFamily || DEFAULT_FONT_FAMILY,
|
||||||
</fieldset>
|
)}
|
||||||
),
|
onChange={(value) => updateData(value)}
|
||||||
|
/>
|
||||||
|
</fieldset>
|
||||||
|
);
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
export const actionChangeTextAlign = register({
|
export const actionChangeTextAlign = register({
|
||||||
@@ -412,3 +566,232 @@ export const actionChangeTextAlign = register({
|
|||||||
</fieldset>
|
</fieldset>
|
||||||
),
|
),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
export const actionChangeSharpness = register({
|
||||||
|
name: "changeSharpness",
|
||||||
|
perform: (elements, appState, value) => {
|
||||||
|
const targetElements = getTargetElements(
|
||||||
|
getNonDeletedElements(elements),
|
||||||
|
appState,
|
||||||
|
);
|
||||||
|
const shouldUpdateForNonLinearElements = targetElements.length
|
||||||
|
? targetElements.every((el) => !isLinearElement(el))
|
||||||
|
: !isLinearElementType(appState.elementType);
|
||||||
|
const shouldUpdateForLinearElements = targetElements.length
|
||||||
|
? targetElements.every(isLinearElement)
|
||||||
|
: isLinearElementType(appState.elementType);
|
||||||
|
trackEvent(EVENT_CHANGE, "edge", value);
|
||||||
|
return {
|
||||||
|
elements: changeProperty(elements, appState, (el) =>
|
||||||
|
newElementWith(el, {
|
||||||
|
strokeSharpness: value,
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
appState: {
|
||||||
|
...appState,
|
||||||
|
currentItemStrokeSharpness: shouldUpdateForNonLinearElements
|
||||||
|
? value
|
||||||
|
: appState.currentItemStrokeSharpness,
|
||||||
|
currentItemLinearStrokeSharpness: shouldUpdateForLinearElements
|
||||||
|
? value
|
||||||
|
: appState.currentItemLinearStrokeSharpness,
|
||||||
|
},
|
||||||
|
commitToHistory: true,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
PanelComponent: ({ elements, appState, updateData }) => (
|
||||||
|
<fieldset>
|
||||||
|
<legend>{t("labels.edges")}</legend>
|
||||||
|
<ButtonIconSelect
|
||||||
|
group="edges"
|
||||||
|
options={[
|
||||||
|
{
|
||||||
|
value: "sharp",
|
||||||
|
text: t("labels.sharp"),
|
||||||
|
icon: <EdgeSharpIcon appearance={appState.appearance} />,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: "round",
|
||||||
|
text: t("labels.round"),
|
||||||
|
icon: <EdgeRoundIcon appearance={appState.appearance} />,
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
value={getFormValue(
|
||||||
|
elements,
|
||||||
|
appState,
|
||||||
|
(element) => element.strokeSharpness,
|
||||||
|
(canChangeSharpness(appState.elementType) &&
|
||||||
|
(isLinearElementType(appState.elementType)
|
||||||
|
? appState.currentItemLinearStrokeSharpness
|
||||||
|
: appState.currentItemStrokeSharpness)) ||
|
||||||
|
null,
|
||||||
|
)}
|
||||||
|
onChange={(value) => updateData(value)}
|
||||||
|
/>
|
||||||
|
</fieldset>
|
||||||
|
),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const actionChangeArrowhead = register({
|
||||||
|
name: "changeArrowhead",
|
||||||
|
perform: (
|
||||||
|
elements,
|
||||||
|
appState,
|
||||||
|
value: { position: "start" | "end"; type: Arrowhead },
|
||||||
|
) => {
|
||||||
|
return {
|
||||||
|
elements: changeProperty(elements, appState, (el) => {
|
||||||
|
if (isLinearElement(el)) {
|
||||||
|
trackEvent(
|
||||||
|
EVENT_CHANGE,
|
||||||
|
`arrowhead ${value.position}`,
|
||||||
|
value.type || "none",
|
||||||
|
);
|
||||||
|
|
||||||
|
const { position, type } = value;
|
||||||
|
|
||||||
|
if (position === "start") {
|
||||||
|
const element: ExcalidrawLinearElement = newElementWith(el, {
|
||||||
|
startArrowhead: type,
|
||||||
|
});
|
||||||
|
return element;
|
||||||
|
} else if (position === "end") {
|
||||||
|
const element: ExcalidrawLinearElement = newElementWith(el, {
|
||||||
|
endArrowhead: type,
|
||||||
|
});
|
||||||
|
return element;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return el;
|
||||||
|
}),
|
||||||
|
appState: {
|
||||||
|
...appState,
|
||||||
|
[value.position === "start"
|
||||||
|
? "currentItemStartArrowhead"
|
||||||
|
: "currentItemEndArrowhead"]: value.type,
|
||||||
|
},
|
||||||
|
commitToHistory: true,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
PanelComponent: ({ elements, appState, updateData }) => {
|
||||||
|
const isRTL = getLanguage().rtl;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<fieldset>
|
||||||
|
<legend>{t("labels.arrowheads")}</legend>
|
||||||
|
<div className="iconSelectList">
|
||||||
|
<IconPicker
|
||||||
|
label="arrowhead_start"
|
||||||
|
options={[
|
||||||
|
{
|
||||||
|
value: null,
|
||||||
|
text: t("labels.arrowhead_none"),
|
||||||
|
icon: <ArrowheadNoneIcon appearance={appState.appearance} />,
|
||||||
|
keyBinding: "q",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: "arrow",
|
||||||
|
text: t("labels.arrowhead_arrow"),
|
||||||
|
icon: (
|
||||||
|
<ArrowheadArrowIcon
|
||||||
|
appearance={appState.appearance}
|
||||||
|
flip={!isRTL}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
keyBinding: "w",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: "bar",
|
||||||
|
text: t("labels.arrowhead_bar"),
|
||||||
|
icon: (
|
||||||
|
<ArrowheadBarIcon
|
||||||
|
appearance={appState.appearance}
|
||||||
|
flip={!isRTL}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
keyBinding: "e",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: "dot",
|
||||||
|
text: t("labels.arrowhead_dot"),
|
||||||
|
icon: (
|
||||||
|
<ArrowheadDotIcon
|
||||||
|
appearance={appState.appearance}
|
||||||
|
flip={!isRTL}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
keyBinding: "r",
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
value={getFormValue<Arrowhead | null>(
|
||||||
|
elements,
|
||||||
|
appState,
|
||||||
|
(element) =>
|
||||||
|
isLinearElement(element) && canHaveArrowheads(element.type)
|
||||||
|
? element.startArrowhead
|
||||||
|
: appState.currentItemStartArrowhead,
|
||||||
|
appState.currentItemStartArrowhead,
|
||||||
|
)}
|
||||||
|
onChange={(value) => updateData({ position: "start", type: value })}
|
||||||
|
/>
|
||||||
|
<IconPicker
|
||||||
|
label="arrowhead_end"
|
||||||
|
group="arrowheads"
|
||||||
|
options={[
|
||||||
|
{
|
||||||
|
value: null,
|
||||||
|
text: t("labels.arrowhead_none"),
|
||||||
|
keyBinding: "q",
|
||||||
|
icon: <ArrowheadNoneIcon appearance={appState.appearance} />,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: "arrow",
|
||||||
|
text: t("labels.arrowhead_arrow"),
|
||||||
|
keyBinding: "w",
|
||||||
|
icon: (
|
||||||
|
<ArrowheadArrowIcon
|
||||||
|
appearance={appState.appearance}
|
||||||
|
flip={isRTL}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: "bar",
|
||||||
|
text: t("labels.arrowhead_bar"),
|
||||||
|
keyBinding: "e",
|
||||||
|
icon: (
|
||||||
|
<ArrowheadBarIcon
|
||||||
|
appearance={appState.appearance}
|
||||||
|
flip={isRTL}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: "dot",
|
||||||
|
text: t("labels.arrowhead_dot"),
|
||||||
|
keyBinding: "r",
|
||||||
|
icon: (
|
||||||
|
<ArrowheadDotIcon
|
||||||
|
appearance={appState.appearance}
|
||||||
|
flip={isRTL}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
value={getFormValue<Arrowhead | null>(
|
||||||
|
elements,
|
||||||
|
appState,
|
||||||
|
(element) =>
|
||||||
|
isLinearElement(element) && canHaveArrowheads(element.type)
|
||||||
|
? element.endArrowhead
|
||||||
|
: appState.currentItemEndArrowhead,
|
||||||
|
appState.currentItemEndArrowhead,
|
||||||
|
)}
|
||||||
|
onChange={(value) => updateData({ position: "end", type: value })}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</fieldset>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|||||||
@@ -1,22 +1,31 @@
|
|||||||
import { KEYS } from "../keys";
|
import { KEYS } from "../keys";
|
||||||
import { register } from "./register";
|
import { register } from "./register";
|
||||||
|
import { selectGroupsForSelectedElements } from "../groups";
|
||||||
|
import { getNonDeletedElements } from "../element";
|
||||||
|
|
||||||
export const actionSelectAll = register({
|
export const actionSelectAll = register({
|
||||||
name: "selectAll",
|
name: "selectAll",
|
||||||
perform: (elements, appState) => {
|
perform: (elements, appState) => {
|
||||||
|
if (appState.editingLinearElement) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
return {
|
return {
|
||||||
appState: {
|
appState: selectGroupsForSelectedElements(
|
||||||
...appState,
|
{
|
||||||
selectedElementIds: elements.reduce((map, element) => {
|
...appState,
|
||||||
if (!element.isDeleted) {
|
editingGroupId: null,
|
||||||
map[element.id] = true;
|
selectedElementIds: elements.reduce((map, element) => {
|
||||||
}
|
if (!element.isDeleted) {
|
||||||
return map;
|
map[element.id] = true;
|
||||||
}, {} as any),
|
}
|
||||||
},
|
return map;
|
||||||
|
}, {} as any),
|
||||||
|
},
|
||||||
|
getNonDeletedElements(elements),
|
||||||
|
),
|
||||||
commitToHistory: true,
|
commitToHistory: true,
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
contextItemLabel: "labels.selectAll",
|
contextItemLabel: "labels.selectAll",
|
||||||
keyTest: (event) => event[KEYS.CTRL_OR_CMD] && event.key === "a",
|
keyTest: (event) => event[KEYS.CTRL_OR_CMD] && event.key === KEYS.A,
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -3,12 +3,17 @@ import {
|
|||||||
isExcalidrawElement,
|
isExcalidrawElement,
|
||||||
redrawTextBoundingBox,
|
redrawTextBoundingBox,
|
||||||
} from "../element";
|
} from "../element";
|
||||||
import { KEYS } from "../keys";
|
import { CODES, KEYS } from "../keys";
|
||||||
import { DEFAULT_FONT, DEFAULT_TEXT_ALIGN } from "../appState";
|
|
||||||
import { register } from "./register";
|
import { register } from "./register";
|
||||||
import { mutateElement, newElementWith } from "../element/mutateElement";
|
import { mutateElement, newElementWith } from "../element/mutateElement";
|
||||||
|
import {
|
||||||
|
DEFAULT_FONT_SIZE,
|
||||||
|
DEFAULT_FONT_FAMILY,
|
||||||
|
DEFAULT_TEXT_ALIGN,
|
||||||
|
} from "../constants";
|
||||||
|
|
||||||
let copiedStyles: string = "{}";
|
// `copiedStyles` is exported only for tests.
|
||||||
|
export let copiedStyles: string = "{}";
|
||||||
|
|
||||||
export const actionCopyStyles = register({
|
export const actionCopyStyles = register({
|
||||||
name: "copyStyles",
|
name: "copyStyles",
|
||||||
@@ -23,7 +28,7 @@ export const actionCopyStyles = register({
|
|||||||
},
|
},
|
||||||
contextItemLabel: "labels.copyStyles",
|
contextItemLabel: "labels.copyStyles",
|
||||||
keyTest: (event) =>
|
keyTest: (event) =>
|
||||||
event[KEYS.CTRL_OR_CMD] && event.shiftKey && event.key === "C",
|
event[KEYS.CTRL_OR_CMD] && event.altKey && event.code === CODES.C,
|
||||||
contextMenuOrder: 0,
|
contextMenuOrder: 0,
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -41,13 +46,15 @@ export const actionPasteStyles = register({
|
|||||||
backgroundColor: pastedElement?.backgroundColor,
|
backgroundColor: pastedElement?.backgroundColor,
|
||||||
strokeWidth: pastedElement?.strokeWidth,
|
strokeWidth: pastedElement?.strokeWidth,
|
||||||
strokeColor: pastedElement?.strokeColor,
|
strokeColor: pastedElement?.strokeColor,
|
||||||
|
strokeStyle: pastedElement?.strokeStyle,
|
||||||
fillStyle: pastedElement?.fillStyle,
|
fillStyle: pastedElement?.fillStyle,
|
||||||
opacity: pastedElement?.opacity,
|
opacity: pastedElement?.opacity,
|
||||||
roughness: pastedElement?.roughness,
|
roughness: pastedElement?.roughness,
|
||||||
});
|
});
|
||||||
if (isTextElement(newElement)) {
|
if (isTextElement(newElement)) {
|
||||||
mutateElement(newElement, {
|
mutateElement(newElement, {
|
||||||
font: pastedElement?.font || DEFAULT_FONT,
|
fontSize: pastedElement?.fontSize || DEFAULT_FONT_SIZE,
|
||||||
|
fontFamily: pastedElement?.fontFamily || DEFAULT_FONT_FAMILY,
|
||||||
textAlign: pastedElement?.textAlign || DEFAULT_TEXT_ALIGN,
|
textAlign: pastedElement?.textAlign || DEFAULT_TEXT_ALIGN,
|
||||||
});
|
});
|
||||||
redrawTextBoundingBox(newElement);
|
redrawTextBoundingBox(newElement);
|
||||||
@@ -61,6 +68,6 @@ export const actionPasteStyles = register({
|
|||||||
},
|
},
|
||||||
contextItemLabel: "labels.pasteStyles",
|
contextItemLabel: "labels.pasteStyles",
|
||||||
keyTest: (event) =>
|
keyTest: (event) =>
|
||||||
event[KEYS.CTRL_OR_CMD] && event.shiftKey && event.key === "V",
|
event[KEYS.CTRL_OR_CMD] && event.altKey && event.code === CODES.V,
|
||||||
contextMenuOrder: 1,
|
contextMenuOrder: 1,
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -5,79 +5,22 @@ import {
|
|||||||
moveAllLeft,
|
moveAllLeft,
|
||||||
moveAllRight,
|
moveAllRight,
|
||||||
} from "../zindex";
|
} from "../zindex";
|
||||||
import { KEYS, isDarwin } from "../keys";
|
import { KEYS, isDarwin, CODES } from "../keys";
|
||||||
import { t } from "../i18n";
|
import { t } from "../i18n";
|
||||||
import { getShortcutKey } from "../utils";
|
import { getShortcutKey } from "../utils";
|
||||||
import { register } from "./register";
|
import { register } from "./register";
|
||||||
import {
|
import {
|
||||||
sendBackward,
|
SendBackwardIcon,
|
||||||
bringToFront,
|
BringToFrontIcon,
|
||||||
sendToBack,
|
SendToBackIcon,
|
||||||
bringForward,
|
BringForwardIcon,
|
||||||
} from "../components/icons";
|
} from "../components/icons";
|
||||||
import { ExcalidrawElement } from "../element/types";
|
|
||||||
import { AppState } from "../types";
|
|
||||||
|
|
||||||
function getElementIndices(
|
|
||||||
direction: "left" | "right",
|
|
||||||
elements: readonly ExcalidrawElement[],
|
|
||||||
appState: AppState,
|
|
||||||
) {
|
|
||||||
const selectedIndices: number[] = [];
|
|
||||||
let deletedIndicesCache: number[] = [];
|
|
||||||
|
|
||||||
function cb(element: ExcalidrawElement, index: number) {
|
|
||||||
if (element.isDeleted) {
|
|
||||||
// we want to build an array of deleted elements that are preceeding
|
|
||||||
// a selected element so that we move them together
|
|
||||||
deletedIndicesCache.push(index);
|
|
||||||
} else {
|
|
||||||
if (appState.selectedElementIds[element.id]) {
|
|
||||||
selectedIndices.push(...deletedIndicesCache, index);
|
|
||||||
}
|
|
||||||
// always empty cache of deleted elements after either pushing a group
|
|
||||||
// of selected/deleted elements, of after encountering non-deleted elem
|
|
||||||
deletedIndicesCache = [];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// sending back → select contiguous deleted elements that are to the left of
|
|
||||||
// selected element(s)
|
|
||||||
if (direction === "left") {
|
|
||||||
let i = -1;
|
|
||||||
const len = elements.length;
|
|
||||||
while (++i < len) {
|
|
||||||
cb(elements[i], i);
|
|
||||||
}
|
|
||||||
// moving to front → loop from right to left so that we don't need to
|
|
||||||
// backtrack when gathering deleted elements
|
|
||||||
} else {
|
|
||||||
let i = elements.length;
|
|
||||||
while (--i > -1) {
|
|
||||||
cb(elements[i], i);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// sort in case we were gathering indexes from right to left
|
|
||||||
return selectedIndices.sort();
|
|
||||||
}
|
|
||||||
|
|
||||||
function moveElements(
|
|
||||||
func: typeof moveOneLeft,
|
|
||||||
elements: readonly ExcalidrawElement[],
|
|
||||||
appState: AppState,
|
|
||||||
) {
|
|
||||||
const _elements = elements.slice();
|
|
||||||
const direction =
|
|
||||||
func === moveOneLeft || func === moveAllLeft ? "left" : "right";
|
|
||||||
const indices = getElementIndices(direction, _elements, appState);
|
|
||||||
return func(_elements, indices);
|
|
||||||
}
|
|
||||||
|
|
||||||
export const actionSendBackward = register({
|
export const actionSendBackward = register({
|
||||||
name: "sendBackward",
|
name: "sendBackward",
|
||||||
perform: (elements, appState) => {
|
perform: (elements, appState) => {
|
||||||
return {
|
return {
|
||||||
elements: moveElements(moveOneLeft, elements, appState),
|
elements: moveOneLeft(elements, appState),
|
||||||
appState,
|
appState,
|
||||||
commitToHistory: true,
|
commitToHistory: true,
|
||||||
};
|
};
|
||||||
@@ -85,15 +28,17 @@ export const actionSendBackward = register({
|
|||||||
contextItemLabel: "labels.sendBackward",
|
contextItemLabel: "labels.sendBackward",
|
||||||
keyPriority: 40,
|
keyPriority: 40,
|
||||||
keyTest: (event) =>
|
keyTest: (event) =>
|
||||||
event[KEYS.CTRL_OR_CMD] && !event.shiftKey && event.code === "BracketLeft",
|
event[KEYS.CTRL_OR_CMD] &&
|
||||||
PanelComponent: ({ updateData }) => (
|
!event.shiftKey &&
|
||||||
|
event.code === CODES.BRACKET_LEFT,
|
||||||
|
PanelComponent: ({ updateData, appState }) => (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="zIndexButton"
|
className="zIndexButton"
|
||||||
onClick={() => updateData(null)}
|
onClick={() => updateData(null)}
|
||||||
title={`${t("labels.sendBackward")} — ${getShortcutKey("CtrlOrCmd+[")}`}
|
title={`${t("labels.sendBackward")} — ${getShortcutKey("CtrlOrCmd+[")}`}
|
||||||
>
|
>
|
||||||
{sendBackward}
|
<SendBackwardIcon appearance={appState.appearance} />
|
||||||
</button>
|
</button>
|
||||||
),
|
),
|
||||||
});
|
});
|
||||||
@@ -102,7 +47,7 @@ export const actionBringForward = register({
|
|||||||
name: "bringForward",
|
name: "bringForward",
|
||||||
perform: (elements, appState) => {
|
perform: (elements, appState) => {
|
||||||
return {
|
return {
|
||||||
elements: moveElements(moveOneRight, elements, appState),
|
elements: moveOneRight(elements, appState),
|
||||||
appState,
|
appState,
|
||||||
commitToHistory: true,
|
commitToHistory: true,
|
||||||
};
|
};
|
||||||
@@ -110,15 +55,17 @@ export const actionBringForward = register({
|
|||||||
contextItemLabel: "labels.bringForward",
|
contextItemLabel: "labels.bringForward",
|
||||||
keyPriority: 40,
|
keyPriority: 40,
|
||||||
keyTest: (event) =>
|
keyTest: (event) =>
|
||||||
event[KEYS.CTRL_OR_CMD] && !event.shiftKey && event.code === "BracketRight",
|
event[KEYS.CTRL_OR_CMD] &&
|
||||||
PanelComponent: ({ updateData }) => (
|
!event.shiftKey &&
|
||||||
|
event.code === CODES.BRACKET_RIGHT,
|
||||||
|
PanelComponent: ({ updateData, appState }) => (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="zIndexButton"
|
className="zIndexButton"
|
||||||
onClick={() => updateData(null)}
|
onClick={() => updateData(null)}
|
||||||
title={`${t("labels.bringForward")} — ${getShortcutKey("CtrlOrCmd+]")}`}
|
title={`${t("labels.bringForward")} — ${getShortcutKey("CtrlOrCmd+]")}`}
|
||||||
>
|
>
|
||||||
{bringForward}
|
<BringForwardIcon appearance={appState.appearance} />
|
||||||
</button>
|
</button>
|
||||||
),
|
),
|
||||||
});
|
});
|
||||||
@@ -127,20 +74,21 @@ export const actionSendToBack = register({
|
|||||||
name: "sendToBack",
|
name: "sendToBack",
|
||||||
perform: (elements, appState) => {
|
perform: (elements, appState) => {
|
||||||
return {
|
return {
|
||||||
elements: moveElements(moveAllLeft, elements, appState),
|
elements: moveAllLeft(elements, appState),
|
||||||
appState,
|
appState,
|
||||||
commitToHistory: true,
|
commitToHistory: true,
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
contextItemLabel: "labels.sendToBack",
|
contextItemLabel: "labels.sendToBack",
|
||||||
keyTest: (event) => {
|
keyTest: (event) =>
|
||||||
return isDarwin
|
isDarwin
|
||||||
? event[KEYS.CTRL_OR_CMD] && event.altKey && event.code === "BracketLeft"
|
? event[KEYS.CTRL_OR_CMD] &&
|
||||||
|
event.altKey &&
|
||||||
|
event.code === CODES.BRACKET_LEFT
|
||||||
: event[KEYS.CTRL_OR_CMD] &&
|
: event[KEYS.CTRL_OR_CMD] &&
|
||||||
event.shiftKey &&
|
event.shiftKey &&
|
||||||
event.code === "BracketLeft";
|
event.code === CODES.BRACKET_LEFT,
|
||||||
},
|
PanelComponent: ({ updateData, appState }) => (
|
||||||
PanelComponent: ({ updateData }) => (
|
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="zIndexButton"
|
className="zIndexButton"
|
||||||
@@ -151,7 +99,7 @@ export const actionSendToBack = register({
|
|||||||
: getShortcutKey("CtrlOrCmd+Shift+[")
|
: getShortcutKey("CtrlOrCmd+Shift+[")
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
{sendToBack}
|
<SendToBackIcon appearance={appState.appearance} />
|
||||||
</button>
|
</button>
|
||||||
),
|
),
|
||||||
});
|
});
|
||||||
@@ -160,20 +108,21 @@ export const actionBringToFront = register({
|
|||||||
name: "bringToFront",
|
name: "bringToFront",
|
||||||
perform: (elements, appState) => {
|
perform: (elements, appState) => {
|
||||||
return {
|
return {
|
||||||
elements: moveElements(moveAllRight, elements, appState),
|
elements: moveAllRight(elements, appState),
|
||||||
appState,
|
appState,
|
||||||
commitToHistory: true,
|
commitToHistory: true,
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
contextItemLabel: "labels.bringToFront",
|
contextItemLabel: "labels.bringToFront",
|
||||||
keyTest: (event) => {
|
keyTest: (event) =>
|
||||||
return isDarwin
|
isDarwin
|
||||||
? event[KEYS.CTRL_OR_CMD] && event.altKey && event.code === "BracketRight"
|
? event[KEYS.CTRL_OR_CMD] &&
|
||||||
|
event.altKey &&
|
||||||
|
event.code === CODES.BRACKET_RIGHT
|
||||||
: event[KEYS.CTRL_OR_CMD] &&
|
: event[KEYS.CTRL_OR_CMD] &&
|
||||||
event.shiftKey &&
|
event.shiftKey &&
|
||||||
event.code === "BracketRight";
|
event.code === CODES.BRACKET_RIGHT,
|
||||||
},
|
PanelComponent: ({ updateData, appState }) => (
|
||||||
PanelComponent: ({ updateData }) => (
|
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="zIndexButton"
|
className="zIndexButton"
|
||||||
@@ -184,7 +133,7 @@ export const actionBringToFront = register({
|
|||||||
: getShortcutKey("CtrlOrCmd+Shift+]")
|
: getShortcutKey("CtrlOrCmd+Shift+]")
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
{bringToFront}
|
<BringToFrontIcon appearance={appState.appearance} />
|
||||||
</button>
|
</button>
|
||||||
),
|
),
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -34,6 +34,7 @@ export {
|
|||||||
actionChangeProjectName,
|
actionChangeProjectName,
|
||||||
actionChangeExportBackground,
|
actionChangeExportBackground,
|
||||||
actionSaveScene,
|
actionSaveScene,
|
||||||
|
actionSaveAsScene,
|
||||||
actionLoadScene,
|
actionLoadScene,
|
||||||
} from "./actionExport";
|
} from "./actionExport";
|
||||||
|
|
||||||
@@ -44,3 +45,23 @@ export {
|
|||||||
actionFullScreen,
|
actionFullScreen,
|
||||||
actionShortcuts,
|
actionShortcuts,
|
||||||
} from "./actionMenu";
|
} from "./actionMenu";
|
||||||
|
|
||||||
|
export { actionGroup, actionUngroup } from "./actionGroup";
|
||||||
|
|
||||||
|
export { actionGoToCollaborator } from "./actionNavigate";
|
||||||
|
|
||||||
|
export { actionAddToLibrary } from "./actionAddToLibrary";
|
||||||
|
|
||||||
|
export {
|
||||||
|
actionAlignTop,
|
||||||
|
actionAlignBottom,
|
||||||
|
actionAlignLeft,
|
||||||
|
actionAlignRight,
|
||||||
|
actionAlignVerticallyCentered,
|
||||||
|
actionAlignHorizontallyCentered,
|
||||||
|
} from "./actionAlign";
|
||||||
|
|
||||||
|
export {
|
||||||
|
distributeHorizontally,
|
||||||
|
distributeVertically,
|
||||||
|
} from "./actionDistribute";
|
||||||
|
|||||||
+30
-8
@@ -5,29 +5,36 @@ import {
|
|||||||
UpdaterFn,
|
UpdaterFn,
|
||||||
ActionFilterFn,
|
ActionFilterFn,
|
||||||
ActionName,
|
ActionName,
|
||||||
|
ActionResult,
|
||||||
} from "./types";
|
} from "./types";
|
||||||
import { ExcalidrawElement } from "../element/types";
|
import { ExcalidrawElement } from "../element/types";
|
||||||
import { AppState } from "../types";
|
import { AppState } from "../types";
|
||||||
import { t } from "../i18n";
|
import { t } from "../i18n";
|
||||||
import { globalSceneState } from "../scene";
|
import { ShortcutName } from "./shortcuts";
|
||||||
|
|
||||||
export class ActionManager implements ActionsManagerInterface {
|
export class ActionManager implements ActionsManagerInterface {
|
||||||
actions = {} as ActionsManagerInterface["actions"];
|
actions = {} as ActionsManagerInterface["actions"];
|
||||||
|
|
||||||
updater: UpdaterFn;
|
updater: (actionResult: ActionResult | Promise<ActionResult>) => void;
|
||||||
|
|
||||||
getAppState: () => AppState;
|
getAppState: () => Readonly<AppState>;
|
||||||
|
|
||||||
getElementsIncludingDeleted: () => readonly ExcalidrawElement[];
|
getElementsIncludingDeleted: () => readonly ExcalidrawElement[];
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
updater: UpdaterFn,
|
updater: UpdaterFn,
|
||||||
getAppState: () => AppState,
|
getAppState: () => AppState,
|
||||||
getElementsIncludingDeleted: () => ReturnType<
|
getElementsIncludingDeleted: () => readonly ExcalidrawElement[],
|
||||||
typeof globalSceneState["getElementsIncludingDeleted"]
|
|
||||||
>,
|
|
||||||
) {
|
) {
|
||||||
this.updater = updater;
|
this.updater = (actionResult) => {
|
||||||
|
if (actionResult && "then" in actionResult) {
|
||||||
|
actionResult.then((actionResult) => {
|
||||||
|
return updater(actionResult);
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
return updater(actionResult);
|
||||||
|
}
|
||||||
|
};
|
||||||
this.getAppState = getAppState;
|
this.getAppState = getAppState;
|
||||||
this.getElementsIncludingDeleted = getElementsIncludingDeleted;
|
this.getElementsIncludingDeleted = getElementsIncludingDeleted;
|
||||||
}
|
}
|
||||||
@@ -82,12 +89,22 @@ export class ActionManager implements ActionsManagerInterface {
|
|||||||
return Object.values(this.actions)
|
return Object.values(this.actions)
|
||||||
.filter(actionFilter)
|
.filter(actionFilter)
|
||||||
.filter((action) => "contextItemLabel" in action)
|
.filter((action) => "contextItemLabel" in action)
|
||||||
|
.filter((action) =>
|
||||||
|
action.contextItemPredicate
|
||||||
|
? action.contextItemPredicate(
|
||||||
|
this.getElementsIncludingDeleted(),
|
||||||
|
this.getAppState(),
|
||||||
|
)
|
||||||
|
: true,
|
||||||
|
)
|
||||||
.sort(
|
.sort(
|
||||||
(a, b) =>
|
(a, b) =>
|
||||||
(a.contextMenuOrder !== undefined ? a.contextMenuOrder : 999) -
|
(a.contextMenuOrder !== undefined ? a.contextMenuOrder : 999) -
|
||||||
(b.contextMenuOrder !== undefined ? b.contextMenuOrder : 999),
|
(b.contextMenuOrder !== undefined ? b.contextMenuOrder : 999),
|
||||||
)
|
)
|
||||||
.map((action) => ({
|
.map((action) => ({
|
||||||
|
// take last bit of the label "labels.<shortcutName>"
|
||||||
|
shortcutName: action.contextItemLabel?.split(".").pop() as ShortcutName,
|
||||||
label: action.contextItemLabel ? t(action.contextItemLabel) : "",
|
label: action.contextItemLabel ? t(action.contextItemLabel) : "",
|
||||||
action: () => {
|
action: () => {
|
||||||
this.updater(
|
this.updater(
|
||||||
@@ -101,7 +118,11 @@ export class ActionManager implements ActionsManagerInterface {
|
|||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
renderAction = (name: ActionName) => {
|
// Id is an attribute that we can use to pass in data like keys.
|
||||||
|
// This is needed for dynamically generated action components
|
||||||
|
// like the user list. We can use this key to extract more
|
||||||
|
// data from app state. This is an alternative to generic prop hell!
|
||||||
|
renderAction = (name: ActionName, id?: string) => {
|
||||||
if (this.actions[name] && "PanelComponent" in this.actions[name]) {
|
if (this.actions[name] && "PanelComponent" in this.actions[name]) {
|
||||||
const action = this.actions[name];
|
const action = this.actions[name];
|
||||||
const PanelComponent = action.PanelComponent!;
|
const PanelComponent = action.PanelComponent!;
|
||||||
@@ -120,6 +141,7 @@ export class ActionManager implements ActionsManagerInterface {
|
|||||||
elements={this.getElementsIncludingDeleted()}
|
elements={this.getElementsIncludingDeleted()}
|
||||||
appState={this.getAppState()}
|
appState={this.getAppState()}
|
||||||
updateData={updateData}
|
updateData={updateData}
|
||||||
|
id={id}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { Action } from "./types";
|
|||||||
|
|
||||||
export let actions: readonly Action[] = [];
|
export let actions: readonly Action[] = [];
|
||||||
|
|
||||||
export function register(action: Action): Action {
|
export const register = (action: Action): Action => {
|
||||||
actions = actions.concat(action);
|
actions = actions.concat(action);
|
||||||
return action;
|
return action;
|
||||||
}
|
};
|
||||||
|
|||||||
@@ -0,0 +1,63 @@
|
|||||||
|
import { t } from "../i18n";
|
||||||
|
import { isDarwin } from "../keys";
|
||||||
|
import { getShortcutKey } from "../utils";
|
||||||
|
|
||||||
|
export type ShortcutName =
|
||||||
|
| "cut"
|
||||||
|
| "copy"
|
||||||
|
| "paste"
|
||||||
|
| "copyStyles"
|
||||||
|
| "pasteStyles"
|
||||||
|
| "selectAll"
|
||||||
|
| "delete"
|
||||||
|
| "duplicateSelection"
|
||||||
|
| "sendBackward"
|
||||||
|
| "bringForward"
|
||||||
|
| "sendToBack"
|
||||||
|
| "bringToFront"
|
||||||
|
| "copyAsPng"
|
||||||
|
| "copyAsSvg"
|
||||||
|
| "group"
|
||||||
|
| "ungroup"
|
||||||
|
| "gridMode"
|
||||||
|
| "stats"
|
||||||
|
| "addToLibrary";
|
||||||
|
|
||||||
|
const shortcutMap: Record<ShortcutName, string[]> = {
|
||||||
|
cut: [getShortcutKey("CtrlOrCmd+X")],
|
||||||
|
copy: [getShortcutKey("CtrlOrCmd+C")],
|
||||||
|
paste: [getShortcutKey("CtrlOrCmd+V")],
|
||||||
|
copyStyles: [getShortcutKey("CtrlOrCmd+Alt+C")],
|
||||||
|
pasteStyles: [getShortcutKey("CtrlOrCmd+Alt+V")],
|
||||||
|
selectAll: [getShortcutKey("CtrlOrCmd+A")],
|
||||||
|
delete: [getShortcutKey("Del")],
|
||||||
|
duplicateSelection: [
|
||||||
|
getShortcutKey("CtrlOrCmd+D"),
|
||||||
|
getShortcutKey(`Alt+${t("shortcutsDialog.drag")}`),
|
||||||
|
],
|
||||||
|
sendBackward: [getShortcutKey("CtrlOrCmd+[")],
|
||||||
|
bringForward: [getShortcutKey("CtrlOrCmd+]")],
|
||||||
|
sendToBack: [
|
||||||
|
isDarwin
|
||||||
|
? getShortcutKey("CtrlOrCmd+Alt+[")
|
||||||
|
: getShortcutKey("CtrlOrCmd+Shift+["),
|
||||||
|
],
|
||||||
|
bringToFront: [
|
||||||
|
isDarwin
|
||||||
|
? getShortcutKey("CtrlOrCmd+Alt+]")
|
||||||
|
: getShortcutKey("CtrlOrCmd+Shift+]"),
|
||||||
|
],
|
||||||
|
copyAsPng: [getShortcutKey("Shift+Alt+C")],
|
||||||
|
copyAsSvg: [],
|
||||||
|
group: [getShortcutKey("CtrlOrCmd+G")],
|
||||||
|
ungroup: [getShortcutKey("CtrlOrCmd+Shift+G")],
|
||||||
|
gridMode: [getShortcutKey("CtrlOrCmd+'")],
|
||||||
|
stats: [],
|
||||||
|
addToLibrary: [],
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getShortcutFromShortcutName = (name: ShortcutName) => {
|
||||||
|
const shortcuts = shortcutMap[name];
|
||||||
|
// if multiple shortcuts availiable, take the first one
|
||||||
|
return shortcuts && shortcuts.length > 0 ? shortcuts[0] : "";
|
||||||
|
};
|
||||||
+37
-12
@@ -2,19 +2,23 @@ import React from "react";
|
|||||||
import { ExcalidrawElement } from "../element/types";
|
import { ExcalidrawElement } from "../element/types";
|
||||||
import { AppState } from "../types";
|
import { AppState } from "../types";
|
||||||
|
|
||||||
export type ActionResult = {
|
/** if false, the action should be prevented */
|
||||||
elements?: readonly ExcalidrawElement[] | null;
|
export type ActionResult =
|
||||||
appState?: AppState | null;
|
| {
|
||||||
commitToHistory: boolean;
|
elements?: readonly ExcalidrawElement[] | null;
|
||||||
};
|
appState?: MarkOptional<AppState, "offsetTop" | "offsetLeft"> | null;
|
||||||
|
commitToHistory: boolean;
|
||||||
|
syncHistory?: boolean;
|
||||||
|
}
|
||||||
|
| false;
|
||||||
|
|
||||||
type ActionFn = (
|
type ActionFn = (
|
||||||
elements: readonly ExcalidrawElement[],
|
elements: readonly ExcalidrawElement[],
|
||||||
appState: AppState,
|
appState: Readonly<AppState>,
|
||||||
formData: any,
|
formData: any,
|
||||||
) => ActionResult;
|
) => ActionResult | Promise<ActionResult>;
|
||||||
|
|
||||||
export type UpdaterFn = (res: ActionResult, commitToHistory?: boolean) => void;
|
export type UpdaterFn = (res: ActionResult) => void;
|
||||||
export type ActionFilterFn = (action: Action) => void;
|
export type ActionFilterFn = (action: Action) => void;
|
||||||
|
|
||||||
export type ActionName =
|
export type ActionName =
|
||||||
@@ -30,6 +34,8 @@ export type ActionName =
|
|||||||
| "changeFillStyle"
|
| "changeFillStyle"
|
||||||
| "changeStrokeWidth"
|
| "changeStrokeWidth"
|
||||||
| "changeSloppiness"
|
| "changeSloppiness"
|
||||||
|
| "changeStrokeStyle"
|
||||||
|
| "changeArrowhead"
|
||||||
| "changeOpacity"
|
| "changeOpacity"
|
||||||
| "changeFontSize"
|
| "changeFontSize"
|
||||||
| "toggleCanvasMenu"
|
| "toggleCanvasMenu"
|
||||||
@@ -39,8 +45,10 @@ export type ActionName =
|
|||||||
| "finalize"
|
| "finalize"
|
||||||
| "changeProjectName"
|
| "changeProjectName"
|
||||||
| "changeExportBackground"
|
| "changeExportBackground"
|
||||||
|
| "changeExportEmbedScene"
|
||||||
| "changeShouldAddWatermark"
|
| "changeShouldAddWatermark"
|
||||||
| "saveScene"
|
| "saveScene"
|
||||||
|
| "saveAsScene"
|
||||||
| "loadScene"
|
| "loadScene"
|
||||||
| "duplicateSelection"
|
| "duplicateSelection"
|
||||||
| "deleteSelectedElements"
|
| "deleteSelectedElements"
|
||||||
@@ -50,10 +58,24 @@ export type ActionName =
|
|||||||
| "zoomOut"
|
| "zoomOut"
|
||||||
| "resetZoom"
|
| "resetZoom"
|
||||||
| "zoomToFit"
|
| "zoomToFit"
|
||||||
|
| "zoomToSelection"
|
||||||
| "changeFontFamily"
|
| "changeFontFamily"
|
||||||
| "changeTextAlign"
|
| "changeTextAlign"
|
||||||
| "toggleFullScreen"
|
| "toggleFullScreen"
|
||||||
| "toggleShortcuts";
|
| "toggleShortcuts"
|
||||||
|
| "group"
|
||||||
|
| "ungroup"
|
||||||
|
| "goToCollaborator"
|
||||||
|
| "addToLibrary"
|
||||||
|
| "changeSharpness"
|
||||||
|
| "alignTop"
|
||||||
|
| "alignBottom"
|
||||||
|
| "alignLeft"
|
||||||
|
| "alignRight"
|
||||||
|
| "alignVerticallyCentered"
|
||||||
|
| "alignHorizontallyCentered"
|
||||||
|
| "distributeHorizontally"
|
||||||
|
| "distributeVertically";
|
||||||
|
|
||||||
export interface Action {
|
export interface Action {
|
||||||
name: ActionName;
|
name: ActionName;
|
||||||
@@ -61,6 +83,7 @@ export interface Action {
|
|||||||
elements: readonly ExcalidrawElement[];
|
elements: readonly ExcalidrawElement[];
|
||||||
appState: AppState;
|
appState: AppState;
|
||||||
updateData: (formData?: any) => void;
|
updateData: (formData?: any) => void;
|
||||||
|
id?: string;
|
||||||
}>;
|
}>;
|
||||||
perform: ActionFn;
|
perform: ActionFn;
|
||||||
keyPriority?: number;
|
keyPriority?: number;
|
||||||
@@ -71,12 +94,14 @@ export interface Action {
|
|||||||
) => boolean;
|
) => boolean;
|
||||||
contextItemLabel?: string;
|
contextItemLabel?: string;
|
||||||
contextMenuOrder?: number;
|
contextMenuOrder?: number;
|
||||||
|
contextItemPredicate?: (
|
||||||
|
elements: readonly ExcalidrawElement[],
|
||||||
|
appState: AppState,
|
||||||
|
) => boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ActionsManagerInterface {
|
export interface ActionsManagerInterface {
|
||||||
actions: {
|
actions: Record<ActionName, Action>;
|
||||||
[actionName in ActionName]: Action;
|
|
||||||
};
|
|
||||||
registerAction: (action: Action) => void;
|
registerAction: (action: Action) => void;
|
||||||
handleKeyDown: (event: KeyboardEvent) => boolean;
|
handleKeyDown: (event: KeyboardEvent) => boolean;
|
||||||
getContextMenuItems: (
|
getContextMenuItems: (
|
||||||
|
|||||||
@@ -0,0 +1,95 @@
|
|||||||
|
import { ExcalidrawElement } from "./element/types";
|
||||||
|
import { newElementWith } from "./element/mutateElement";
|
||||||
|
import { getCommonBounds } from "./element";
|
||||||
|
|
||||||
|
interface Box {
|
||||||
|
minX: number;
|
||||||
|
minY: number;
|
||||||
|
maxX: number;
|
||||||
|
maxY: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Alignment {
|
||||||
|
position: "start" | "center" | "end";
|
||||||
|
axis: "x" | "y";
|
||||||
|
}
|
||||||
|
|
||||||
|
export const alignElements = (
|
||||||
|
selectedElements: ExcalidrawElement[],
|
||||||
|
alignment: Alignment,
|
||||||
|
): ExcalidrawElement[] => {
|
||||||
|
const groups: ExcalidrawElement[][] = getMaximumGroups(selectedElements);
|
||||||
|
|
||||||
|
const selectionBoundingBox = getCommonBoundingBox(selectedElements);
|
||||||
|
|
||||||
|
return groups.flatMap((group) => {
|
||||||
|
const translation = calculateTranslation(
|
||||||
|
group,
|
||||||
|
selectionBoundingBox,
|
||||||
|
alignment,
|
||||||
|
);
|
||||||
|
return group.map((element) =>
|
||||||
|
newElementWith(element, {
|
||||||
|
x: element.x + translation.x,
|
||||||
|
y: element.y + translation.y,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getMaximumGroups = (
|
||||||
|
elements: ExcalidrawElement[],
|
||||||
|
): ExcalidrawElement[][] => {
|
||||||
|
const groups: Map<String, ExcalidrawElement[]> = new Map<
|
||||||
|
String,
|
||||||
|
ExcalidrawElement[]
|
||||||
|
>();
|
||||||
|
|
||||||
|
elements.forEach((element: ExcalidrawElement) => {
|
||||||
|
const groupId =
|
||||||
|
element.groupIds.length === 0
|
||||||
|
? element.id
|
||||||
|
: element.groupIds[element.groupIds.length - 1];
|
||||||
|
|
||||||
|
const currentGroupMembers = groups.get(groupId) || [];
|
||||||
|
|
||||||
|
groups.set(groupId, [...currentGroupMembers, element]);
|
||||||
|
});
|
||||||
|
|
||||||
|
return Array.from(groups.values());
|
||||||
|
};
|
||||||
|
|
||||||
|
const calculateTranslation = (
|
||||||
|
group: ExcalidrawElement[],
|
||||||
|
selectionBoundingBox: Box,
|
||||||
|
{ axis, position }: Alignment,
|
||||||
|
): { x: number; y: number } => {
|
||||||
|
const groupBoundingBox = getCommonBoundingBox(group);
|
||||||
|
|
||||||
|
const [min, max]: ["minX" | "minY", "maxX" | "maxY"] =
|
||||||
|
axis === "x" ? ["minX", "maxX"] : ["minY", "maxY"];
|
||||||
|
|
||||||
|
const noTranslation = { x: 0, y: 0 };
|
||||||
|
if (position === "start") {
|
||||||
|
return {
|
||||||
|
...noTranslation,
|
||||||
|
[axis]: selectionBoundingBox[min] - groupBoundingBox[min],
|
||||||
|
};
|
||||||
|
} else if (position === "end") {
|
||||||
|
return {
|
||||||
|
...noTranslation,
|
||||||
|
[axis]: selectionBoundingBox[max] - groupBoundingBox[max],
|
||||||
|
};
|
||||||
|
} // else if (position === "center") {
|
||||||
|
return {
|
||||||
|
...noTranslation,
|
||||||
|
[axis]:
|
||||||
|
(selectionBoundingBox[min] + selectionBoundingBox[max]) / 2 -
|
||||||
|
(groupBoundingBox[min] + groupBoundingBox[max]) / 2,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const getCommonBoundingBox = (elements: ExcalidrawElement[]): Box => {
|
||||||
|
const [minX, minY, maxX, maxY] = getCommonBounds(elements);
|
||||||
|
return { minX, minY, maxX, maxY };
|
||||||
|
};
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
export const EVENT_ACTION = "action";
|
||||||
|
export const EVENT_ALIGN = "align";
|
||||||
|
export const EVENT_CHANGE = "change";
|
||||||
|
export const EVENT_DIALOG = "dialog";
|
||||||
|
export const EVENT_EXIT = "exit";
|
||||||
|
export const EVENT_IO = "io";
|
||||||
|
export const EVENT_LAYER = "layer";
|
||||||
|
export const EVENT_LIBRARY = "library";
|
||||||
|
export const EVENT_LOAD = "load";
|
||||||
|
export const EVENT_SHAPE = "shape";
|
||||||
|
export const EVENT_SHARE = "share";
|
||||||
|
export const EVENT_MAGIC = "magic";
|
||||||
|
|
||||||
|
export const trackEvent =
|
||||||
|
typeof window !== "undefined" && window.gtag
|
||||||
|
? (category: string, name: string, label?: string, value?: number) => {
|
||||||
|
window.gtag("event", name, {
|
||||||
|
event_category: category,
|
||||||
|
event_label: label,
|
||||||
|
value,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
: typeof process !== "undefined" && process?.env?.JEST_WORKER_ID
|
||||||
|
? (category: string, name: string, label?: string, value?: number) => {}
|
||||||
|
: (category: string, name: string, label?: string, value?: number) => {
|
||||||
|
console.info("Track Event", category, name, label, value);
|
||||||
|
};
|
||||||
+168
-76
@@ -1,96 +1,188 @@
|
|||||||
import oc from "open-color";
|
import oc from "open-color";
|
||||||
import { AppState, FlooredNumber } from "./types";
|
import {
|
||||||
import { getDateTime } from "./utils";
|
DEFAULT_FONT_FAMILY,
|
||||||
import { t } from "./i18n";
|
DEFAULT_FONT_SIZE,
|
||||||
|
SCENE_NAME_FALLBACK,
|
||||||
|
DEFAULT_TEXT_ALIGN,
|
||||||
|
} from "./constants";
|
||||||
|
import { AppState, FlooredNumber, NormalizedZoomValue } from "./types";
|
||||||
|
|
||||||
export const DEFAULT_FONT = "20px Virgil";
|
type DefaultAppState = Omit<AppState, "offsetTop" | "offsetLeft" | "name"> & {
|
||||||
export const DEFAULT_TEXT_ALIGN = "left";
|
/**
|
||||||
|
* You should override this with current appState.name, or whatever is
|
||||||
|
* applicable at a given place where you get default appState.
|
||||||
|
*/
|
||||||
|
name: undefined;
|
||||||
|
};
|
||||||
|
|
||||||
export function getDefaultAppState(): AppState {
|
export const getDefaultAppState = (): DefaultAppState => {
|
||||||
return {
|
return {
|
||||||
isLoading: false,
|
appearance: "light",
|
||||||
errorMessage: null,
|
collaborators: new Map(),
|
||||||
draggingElement: null,
|
currentChartType: "bar",
|
||||||
resizingElement: null,
|
|
||||||
multiElement: null,
|
|
||||||
editingElement: null,
|
|
||||||
elementType: "selection",
|
|
||||||
elementLocked: false,
|
|
||||||
exportBackground: true,
|
|
||||||
shouldAddWatermark: false,
|
|
||||||
currentItemStrokeColor: oc.black,
|
|
||||||
currentItemBackgroundColor: "transparent",
|
currentItemBackgroundColor: "transparent",
|
||||||
|
currentItemEndArrowhead: "arrow",
|
||||||
currentItemFillStyle: "hachure",
|
currentItemFillStyle: "hachure",
|
||||||
currentItemStrokeWidth: 1,
|
currentItemFontFamily: DEFAULT_FONT_FAMILY,
|
||||||
currentItemRoughness: 1,
|
currentItemFontSize: DEFAULT_FONT_SIZE,
|
||||||
|
currentItemLinearStrokeSharpness: "round",
|
||||||
currentItemOpacity: 100,
|
currentItemOpacity: 100,
|
||||||
currentItemFont: DEFAULT_FONT,
|
currentItemRoughness: 1,
|
||||||
|
currentItemStartArrowhead: null,
|
||||||
|
currentItemStrokeColor: oc.black,
|
||||||
|
currentItemStrokeSharpness: "sharp",
|
||||||
|
currentItemStrokeStyle: "solid",
|
||||||
|
currentItemStrokeWidth: 1,
|
||||||
currentItemTextAlign: DEFAULT_TEXT_ALIGN,
|
currentItemTextAlign: DEFAULT_TEXT_ALIGN,
|
||||||
viewBackgroundColor: oc.white,
|
|
||||||
scrollX: 0 as FlooredNumber,
|
|
||||||
scrollY: 0 as FlooredNumber,
|
|
||||||
cursorX: 0,
|
|
||||||
cursorY: 0,
|
|
||||||
cursorButton: "up",
|
cursorButton: "up",
|
||||||
scrolledOutside: false,
|
draggingElement: null,
|
||||||
name: `${t("labels.untitled")}-${getDateTime()}`,
|
editingElement: null,
|
||||||
username: "",
|
editingGroupId: null,
|
||||||
isCollaborating: false,
|
editingLinearElement: null,
|
||||||
|
elementLocked: false,
|
||||||
|
elementType: "selection",
|
||||||
|
errorMessage: null,
|
||||||
|
exportBackground: true,
|
||||||
|
exportEmbedScene: false,
|
||||||
|
fileHandle: null,
|
||||||
|
gridSize: null,
|
||||||
|
height: window.innerHeight,
|
||||||
|
isBindingEnabled: true,
|
||||||
|
isLibraryOpen: false,
|
||||||
|
isLoading: false,
|
||||||
isResizing: false,
|
isResizing: false,
|
||||||
isRotating: false,
|
isRotating: false,
|
||||||
selectionElement: null,
|
|
||||||
zoom: 1,
|
|
||||||
openMenu: null,
|
|
||||||
lastPointerDownWith: "mouse",
|
lastPointerDownWith: "mouse",
|
||||||
|
multiElement: null,
|
||||||
|
// for safety (because TS mostly doesn't distinguish optional types and
|
||||||
|
// undefined values), we set `name` to the fallback name, but we cast it to
|
||||||
|
// `undefined` so that TS forces us to explicitly specify it wherever
|
||||||
|
// possible
|
||||||
|
name: (SCENE_NAME_FALLBACK as unknown) as undefined,
|
||||||
|
openMenu: null,
|
||||||
|
pasteDialog: { shown: false, data: null },
|
||||||
|
previousSelectedElementIds: {},
|
||||||
|
resizingElement: null,
|
||||||
|
scrolledOutside: false,
|
||||||
|
scrollX: 0 as FlooredNumber,
|
||||||
|
scrollY: 0 as FlooredNumber,
|
||||||
selectedElementIds: {},
|
selectedElementIds: {},
|
||||||
collaborators: new Map(),
|
selectedGroupIds: {},
|
||||||
|
selectionElement: null,
|
||||||
|
shouldAddWatermark: false,
|
||||||
shouldCacheIgnoreZoom: false,
|
shouldCacheIgnoreZoom: false,
|
||||||
showShortcutsDialog: false,
|
showShortcutsDialog: false,
|
||||||
|
showStats: false,
|
||||||
|
startBoundElement: null,
|
||||||
|
suggestedBindings: [],
|
||||||
|
viewBackgroundColor: oc.white,
|
||||||
|
width: window.innerWidth,
|
||||||
zenModeEnabled: false,
|
zenModeEnabled: false,
|
||||||
|
zoom: { value: 1 as NormalizedZoomValue, translation: { x: 0, y: 0 } },
|
||||||
};
|
};
|
||||||
}
|
};
|
||||||
|
|
||||||
export function clearAppStateForLocalStorage(appState: AppState) {
|
/**
|
||||||
const {
|
* Config containing all AppState keys. Used to determine whether given state
|
||||||
draggingElement,
|
* prop should be stripped when exporting to given storage type.
|
||||||
resizingElement,
|
*/
|
||||||
multiElement,
|
const APP_STATE_STORAGE_CONF = (<
|
||||||
editingElement,
|
Values extends {
|
||||||
selectionElement,
|
/** whether to keep when storing to browser storage (localStorage/IDB) */
|
||||||
isResizing,
|
browser: boolean;
|
||||||
isRotating,
|
/** whether to keep when exporting to file/database */
|
||||||
collaborators,
|
export: boolean;
|
||||||
isCollaborating,
|
},
|
||||||
isLoading,
|
T extends Record<keyof AppState, Values>
|
||||||
errorMessage,
|
>(
|
||||||
showShortcutsDialog,
|
config: { [K in keyof T]: K extends keyof AppState ? T[K] : never },
|
||||||
...exportedState
|
) => config)({
|
||||||
} = appState;
|
appearance: { browser: true, export: false },
|
||||||
return exportedState;
|
collaborators: { browser: false, export: false },
|
||||||
}
|
currentChartType: { browser: true, export: false },
|
||||||
|
currentItemBackgroundColor: { browser: true, export: false },
|
||||||
|
currentItemEndArrowhead: { browser: true, export: false },
|
||||||
|
currentItemFillStyle: { browser: true, export: false },
|
||||||
|
currentItemFontFamily: { browser: true, export: false },
|
||||||
|
currentItemFontSize: { browser: true, export: false },
|
||||||
|
currentItemLinearStrokeSharpness: { browser: true, export: false },
|
||||||
|
currentItemOpacity: { browser: true, export: false },
|
||||||
|
currentItemRoughness: { browser: true, export: false },
|
||||||
|
currentItemStartArrowhead: { browser: true, export: false },
|
||||||
|
currentItemStrokeColor: { browser: true, export: false },
|
||||||
|
currentItemStrokeSharpness: { browser: true, export: false },
|
||||||
|
currentItemStrokeStyle: { browser: true, export: false },
|
||||||
|
currentItemStrokeWidth: { browser: true, export: false },
|
||||||
|
currentItemTextAlign: { browser: true, export: false },
|
||||||
|
cursorButton: { browser: true, export: false },
|
||||||
|
draggingElement: { browser: false, export: false },
|
||||||
|
editingElement: { browser: false, export: false },
|
||||||
|
editingGroupId: { browser: true, export: false },
|
||||||
|
editingLinearElement: { browser: false, export: false },
|
||||||
|
elementLocked: { browser: true, export: false },
|
||||||
|
elementType: { browser: true, export: false },
|
||||||
|
errorMessage: { browser: false, export: false },
|
||||||
|
exportBackground: { browser: true, export: false },
|
||||||
|
exportEmbedScene: { browser: true, export: false },
|
||||||
|
fileHandle: { browser: false, export: false },
|
||||||
|
gridSize: { browser: true, export: true },
|
||||||
|
height: { browser: false, export: false },
|
||||||
|
isBindingEnabled: { browser: false, export: false },
|
||||||
|
isLibraryOpen: { browser: false, export: false },
|
||||||
|
isLoading: { browser: false, export: false },
|
||||||
|
isResizing: { browser: false, export: false },
|
||||||
|
isRotating: { browser: false, export: false },
|
||||||
|
lastPointerDownWith: { browser: true, export: false },
|
||||||
|
multiElement: { browser: false, export: false },
|
||||||
|
name: { browser: true, export: false },
|
||||||
|
offsetLeft: { browser: false, export: false },
|
||||||
|
offsetTop: { browser: false, export: false },
|
||||||
|
openMenu: { browser: true, export: false },
|
||||||
|
pasteDialog: { browser: false, export: false },
|
||||||
|
previousSelectedElementIds: { browser: true, export: false },
|
||||||
|
resizingElement: { browser: false, export: false },
|
||||||
|
scrolledOutside: { browser: true, export: false },
|
||||||
|
scrollX: { browser: true, export: false },
|
||||||
|
scrollY: { browser: true, export: false },
|
||||||
|
selectedElementIds: { browser: true, export: false },
|
||||||
|
selectedGroupIds: { browser: true, export: false },
|
||||||
|
selectionElement: { browser: false, export: false },
|
||||||
|
shouldAddWatermark: { browser: true, export: false },
|
||||||
|
shouldCacheIgnoreZoom: { browser: true, export: false },
|
||||||
|
showShortcutsDialog: { browser: false, export: false },
|
||||||
|
showStats: { browser: true, export: false },
|
||||||
|
startBoundElement: { browser: false, export: false },
|
||||||
|
suggestedBindings: { browser: false, export: false },
|
||||||
|
viewBackgroundColor: { browser: true, export: true },
|
||||||
|
width: { browser: false, export: false },
|
||||||
|
zenModeEnabled: { browser: true, export: false },
|
||||||
|
zoom: { browser: true, export: false },
|
||||||
|
});
|
||||||
|
|
||||||
export function clearAppStatePropertiesForHistory(
|
const _clearAppStateForStorage = <ExportType extends "export" | "browser">(
|
||||||
appState: AppState,
|
appState: Partial<AppState>,
|
||||||
): Partial<AppState> {
|
exportType: ExportType,
|
||||||
return {
|
) => {
|
||||||
selectedElementIds: appState.selectedElementIds,
|
type ExportableKeys = {
|
||||||
exportBackground: appState.exportBackground,
|
[K in keyof typeof APP_STATE_STORAGE_CONF]: typeof APP_STATE_STORAGE_CONF[K][ExportType] extends true
|
||||||
shouldAddWatermark: appState.shouldAddWatermark,
|
? K
|
||||||
currentItemStrokeColor: appState.currentItemStrokeColor,
|
: never;
|
||||||
currentItemBackgroundColor: appState.currentItemBackgroundColor,
|
}[keyof typeof APP_STATE_STORAGE_CONF];
|
||||||
currentItemFillStyle: appState.currentItemFillStyle,
|
const stateForExport = {} as { [K in ExportableKeys]?: typeof appState[K] };
|
||||||
currentItemStrokeWidth: appState.currentItemStrokeWidth,
|
for (const key of Object.keys(appState) as (keyof typeof appState)[]) {
|
||||||
currentItemRoughness: appState.currentItemRoughness,
|
const propConfig = APP_STATE_STORAGE_CONF[key];
|
||||||
currentItemOpacity: appState.currentItemOpacity,
|
if (propConfig?.[exportType]) {
|
||||||
currentItemFont: appState.currentItemFont,
|
// @ts-ignore see https://github.com/microsoft/TypeScript/issues/31445
|
||||||
currentItemTextAlign: appState.currentItemTextAlign,
|
stateForExport[key] = appState[key];
|
||||||
viewBackgroundColor: appState.viewBackgroundColor,
|
}
|
||||||
name: appState.name,
|
}
|
||||||
};
|
return stateForExport;
|
||||||
}
|
};
|
||||||
|
|
||||||
export function cleanAppStateForExport(appState: AppState) {
|
export const clearAppStateForLocalStorage = (appState: Partial<AppState>) => {
|
||||||
return {
|
return _clearAppStateForStorage(appState, "browser");
|
||||||
viewBackgroundColor: appState.viewBackgroundColor,
|
};
|
||||||
};
|
|
||||||
}
|
export const cleanAppStateForExport = (appState: Partial<AppState>) => {
|
||||||
|
return _clearAppStateForStorage(appState, "export");
|
||||||
|
};
|
||||||
|
|||||||
+481
@@ -0,0 +1,481 @@
|
|||||||
|
import { EVENT_MAGIC, trackEvent } from "./analytics";
|
||||||
|
import colors from "./colors";
|
||||||
|
import { DEFAULT_FONT_FAMILY, DEFAULT_FONT_SIZE, ENV } from "./constants";
|
||||||
|
import { newElement, newLinearElement, newTextElement } from "./element";
|
||||||
|
import { NonDeletedExcalidrawElement } from "./element/types";
|
||||||
|
import { randomId } from "./random";
|
||||||
|
|
||||||
|
export type ChartElements = readonly NonDeletedExcalidrawElement[];
|
||||||
|
|
||||||
|
const BAR_WIDTH = 32;
|
||||||
|
const BAR_GAP = 12;
|
||||||
|
const BAR_HEIGHT = 256;
|
||||||
|
const GRID_OPACITY = 50;
|
||||||
|
|
||||||
|
export interface Spreadsheet {
|
||||||
|
title: string | null;
|
||||||
|
labels: string[] | null;
|
||||||
|
values: number[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export const NOT_SPREADSHEET = "NOT_SPREADSHEET";
|
||||||
|
export const VALID_SPREADSHEET = "VALID_SPREADSHEET";
|
||||||
|
|
||||||
|
type ParseSpreadsheetResult =
|
||||||
|
| { type: typeof NOT_SPREADSHEET; reason: string }
|
||||||
|
| { type: typeof VALID_SPREADSHEET; spreadsheet: Spreadsheet };
|
||||||
|
|
||||||
|
const tryParseNumber = (s: string): number | null => {
|
||||||
|
const match = /^[$€£¥₩]?([0-9,]+(\.[0-9]+)?)$/.exec(s);
|
||||||
|
if (!match) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return parseFloat(match[1].replace(/,/g, ""));
|
||||||
|
};
|
||||||
|
|
||||||
|
const isNumericColumn = (lines: string[][], columnIndex: number) =>
|
||||||
|
lines.slice(1).every((line) => tryParseNumber(line[columnIndex]) !== null);
|
||||||
|
|
||||||
|
const tryParseCells = (cells: string[][]): ParseSpreadsheetResult => {
|
||||||
|
const numCols = cells[0].length;
|
||||||
|
|
||||||
|
if (numCols > 2) {
|
||||||
|
return { type: NOT_SPREADSHEET, reason: "More than 2 columns" };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (numCols === 1) {
|
||||||
|
if (!isNumericColumn(cells, 0)) {
|
||||||
|
return { type: NOT_SPREADSHEET, reason: "Value is not numeric" };
|
||||||
|
}
|
||||||
|
|
||||||
|
const hasHeader = tryParseNumber(cells[0][0]) === null;
|
||||||
|
const values = (hasHeader ? cells.slice(1) : cells).map((line) =>
|
||||||
|
tryParseNumber(line[0]),
|
||||||
|
);
|
||||||
|
|
||||||
|
if (values.length < 2) {
|
||||||
|
return { type: NOT_SPREADSHEET, reason: "Less than two rows" };
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
type: VALID_SPREADSHEET,
|
||||||
|
spreadsheet: {
|
||||||
|
title: hasHeader ? cells[0][0] : null,
|
||||||
|
labels: null,
|
||||||
|
values: values as number[],
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const valueColumnIndex = isNumericColumn(cells, 0) ? 0 : 1;
|
||||||
|
|
||||||
|
if (!isNumericColumn(cells, valueColumnIndex)) {
|
||||||
|
return { type: NOT_SPREADSHEET, reason: "Value is not numeric" };
|
||||||
|
}
|
||||||
|
|
||||||
|
const labelColumnIndex = (valueColumnIndex + 1) % 2;
|
||||||
|
const hasHeader = tryParseNumber(cells[0][valueColumnIndex]) === null;
|
||||||
|
const rows = hasHeader ? cells.slice(1) : cells;
|
||||||
|
|
||||||
|
if (rows.length < 2) {
|
||||||
|
return { type: NOT_SPREADSHEET, reason: "Less than 2 rows" };
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
type: VALID_SPREADSHEET,
|
||||||
|
spreadsheet: {
|
||||||
|
title: hasHeader ? cells[0][valueColumnIndex] : null,
|
||||||
|
labels: rows.map((row) => row[labelColumnIndex]),
|
||||||
|
values: rows.map((row) => tryParseNumber(row[valueColumnIndex])!),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const transposeCells = (cells: string[][]) => {
|
||||||
|
const nextCells: string[][] = [];
|
||||||
|
for (let col = 0; col < cells[0].length; col++) {
|
||||||
|
const nextCellRow: string[] = [];
|
||||||
|
for (let row = 0; row < cells.length; row++) {
|
||||||
|
nextCellRow.push(cells[row][col]);
|
||||||
|
}
|
||||||
|
nextCells.push(nextCellRow);
|
||||||
|
}
|
||||||
|
return nextCells;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const tryParseSpreadsheet = (text: string): ParseSpreadsheetResult => {
|
||||||
|
// Copy/paste from excel, spreadhseets, tsv, csv.
|
||||||
|
// For now we only accept 2 columns with an optional header
|
||||||
|
|
||||||
|
// Check for tab separated values
|
||||||
|
let lines = text
|
||||||
|
.trim()
|
||||||
|
.split("\n")
|
||||||
|
.map((line) => line.trim().split("\t"));
|
||||||
|
|
||||||
|
// Check for comma separated files
|
||||||
|
if (lines.length && lines[0].length !== 2) {
|
||||||
|
lines = text
|
||||||
|
.trim()
|
||||||
|
.split("\n")
|
||||||
|
.map((line) => line.trim().split(","));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (lines.length === 0) {
|
||||||
|
return { type: NOT_SPREADSHEET, reason: "No values" };
|
||||||
|
}
|
||||||
|
|
||||||
|
const numColsFirstLine = lines[0].length;
|
||||||
|
const isSpreadsheet = lines.every((line) => line.length === numColsFirstLine);
|
||||||
|
|
||||||
|
if (!isSpreadsheet) {
|
||||||
|
return {
|
||||||
|
type: NOT_SPREADSHEET,
|
||||||
|
reason: "All rows don't have same number of columns",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = tryParseCells(lines);
|
||||||
|
if (result.type !== VALID_SPREADSHEET) {
|
||||||
|
const transposedResults = tryParseCells(transposeCells(lines));
|
||||||
|
if (transposedResults.type === VALID_SPREADSHEET) {
|
||||||
|
return transposedResults;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
};
|
||||||
|
|
||||||
|
const bgColors = colors.elementBackground.slice(
|
||||||
|
2,
|
||||||
|
colors.elementBackground.length,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Put all the common properties here so when the whole chart is selected
|
||||||
|
// the properties dialog shows the correct selected values
|
||||||
|
const commonProps = {
|
||||||
|
fillStyle: "hachure",
|
||||||
|
fontFamily: DEFAULT_FONT_FAMILY,
|
||||||
|
fontSize: DEFAULT_FONT_SIZE,
|
||||||
|
opacity: 100,
|
||||||
|
roughness: 1,
|
||||||
|
strokeColor: colors.elementStroke[0],
|
||||||
|
strokeSharpness: "sharp",
|
||||||
|
strokeStyle: "solid",
|
||||||
|
strokeWidth: 1,
|
||||||
|
verticalAlign: "middle",
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
const getChartDimentions = (spreadsheet: Spreadsheet) => {
|
||||||
|
const chartWidth =
|
||||||
|
(BAR_WIDTH + BAR_GAP) * spreadsheet.values.length + BAR_GAP;
|
||||||
|
const chartHeight = BAR_HEIGHT + BAR_GAP * 2;
|
||||||
|
return { chartWidth, chartHeight };
|
||||||
|
};
|
||||||
|
|
||||||
|
const chartXLabels = (
|
||||||
|
spreadsheet: Spreadsheet,
|
||||||
|
x: number,
|
||||||
|
y: number,
|
||||||
|
groupId: string,
|
||||||
|
backgroundColor: string,
|
||||||
|
): ChartElements => {
|
||||||
|
return (
|
||||||
|
spreadsheet.labels?.map((label, index) => {
|
||||||
|
return newTextElement({
|
||||||
|
groupIds: [groupId],
|
||||||
|
backgroundColor,
|
||||||
|
...commonProps,
|
||||||
|
text: label.length > 8 ? `${label.slice(0, 5)}...` : label,
|
||||||
|
x: x + index * (BAR_WIDTH + BAR_GAP) + BAR_GAP * 2,
|
||||||
|
y: y + BAR_GAP / 2,
|
||||||
|
width: BAR_WIDTH,
|
||||||
|
angle: 5.87,
|
||||||
|
fontSize: 16,
|
||||||
|
textAlign: "center",
|
||||||
|
verticalAlign: "top",
|
||||||
|
});
|
||||||
|
}) || []
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const chartYLabels = (
|
||||||
|
spreadsheet: Spreadsheet,
|
||||||
|
x: number,
|
||||||
|
y: number,
|
||||||
|
groupId: string,
|
||||||
|
backgroundColor: string,
|
||||||
|
): ChartElements => {
|
||||||
|
const minYLabel = newTextElement({
|
||||||
|
groupIds: [groupId],
|
||||||
|
backgroundColor,
|
||||||
|
...commonProps,
|
||||||
|
x: x - BAR_GAP,
|
||||||
|
y: y - BAR_GAP,
|
||||||
|
text: "0",
|
||||||
|
textAlign: "right",
|
||||||
|
});
|
||||||
|
|
||||||
|
const maxYLabel = newTextElement({
|
||||||
|
groupIds: [groupId],
|
||||||
|
backgroundColor,
|
||||||
|
...commonProps,
|
||||||
|
x: x - BAR_GAP,
|
||||||
|
y: y - BAR_HEIGHT - minYLabel.height / 2,
|
||||||
|
text: Math.max(...spreadsheet.values).toLocaleString(),
|
||||||
|
textAlign: "right",
|
||||||
|
});
|
||||||
|
|
||||||
|
return [minYLabel, maxYLabel];
|
||||||
|
};
|
||||||
|
|
||||||
|
const chartLines = (
|
||||||
|
spreadsheet: Spreadsheet,
|
||||||
|
x: number,
|
||||||
|
y: number,
|
||||||
|
groupId: string,
|
||||||
|
backgroundColor: string,
|
||||||
|
): ChartElements => {
|
||||||
|
const { chartWidth, chartHeight } = getChartDimentions(spreadsheet);
|
||||||
|
const xLine = newLinearElement({
|
||||||
|
backgroundColor,
|
||||||
|
groupIds: [groupId],
|
||||||
|
...commonProps,
|
||||||
|
type: "line",
|
||||||
|
x,
|
||||||
|
y,
|
||||||
|
startArrowhead: null,
|
||||||
|
endArrowhead: null,
|
||||||
|
width: chartWidth,
|
||||||
|
points: [
|
||||||
|
[0, 0],
|
||||||
|
[chartWidth, 0],
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
const yLine = newLinearElement({
|
||||||
|
backgroundColor,
|
||||||
|
groupIds: [groupId],
|
||||||
|
...commonProps,
|
||||||
|
type: "line",
|
||||||
|
x,
|
||||||
|
y,
|
||||||
|
startArrowhead: null,
|
||||||
|
endArrowhead: null,
|
||||||
|
height: chartHeight,
|
||||||
|
points: [
|
||||||
|
[0, 0],
|
||||||
|
[0, -chartHeight],
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
const maxLine = newLinearElement({
|
||||||
|
backgroundColor,
|
||||||
|
groupIds: [groupId],
|
||||||
|
...commonProps,
|
||||||
|
type: "line",
|
||||||
|
x,
|
||||||
|
y: y - BAR_HEIGHT - BAR_GAP,
|
||||||
|
startArrowhead: null,
|
||||||
|
endArrowhead: null,
|
||||||
|
strokeStyle: "dotted",
|
||||||
|
width: chartWidth,
|
||||||
|
opacity: GRID_OPACITY,
|
||||||
|
points: [
|
||||||
|
[0, 0],
|
||||||
|
[chartWidth, 0],
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
return [xLine, yLine, maxLine];
|
||||||
|
};
|
||||||
|
|
||||||
|
// For the maths behind it https://excalidraw.com/#json=6320864370884608,O_5xfD-Agh32tytHpRJx1g
|
||||||
|
const chartBaseElements = (
|
||||||
|
spreadsheet: Spreadsheet,
|
||||||
|
x: number,
|
||||||
|
y: number,
|
||||||
|
groupId: string,
|
||||||
|
backgroundColor: string,
|
||||||
|
debug?: boolean,
|
||||||
|
): ChartElements => {
|
||||||
|
const { chartWidth, chartHeight } = getChartDimentions(spreadsheet);
|
||||||
|
|
||||||
|
const title = spreadsheet.title
|
||||||
|
? newTextElement({
|
||||||
|
backgroundColor,
|
||||||
|
groupIds: [groupId],
|
||||||
|
...commonProps,
|
||||||
|
text: spreadsheet.title,
|
||||||
|
x: x + chartWidth / 2,
|
||||||
|
y: y - BAR_HEIGHT - BAR_GAP * 2 - DEFAULT_FONT_SIZE,
|
||||||
|
strokeSharpness: "sharp",
|
||||||
|
strokeStyle: "solid",
|
||||||
|
textAlign: "center",
|
||||||
|
})
|
||||||
|
: null;
|
||||||
|
|
||||||
|
const debugRect = debug
|
||||||
|
? newElement({
|
||||||
|
backgroundColor,
|
||||||
|
groupIds: [groupId],
|
||||||
|
...commonProps,
|
||||||
|
type: "rectangle",
|
||||||
|
x,
|
||||||
|
y: y - chartHeight,
|
||||||
|
width: chartWidth,
|
||||||
|
height: chartHeight,
|
||||||
|
strokeColor: colors.elementStroke[0],
|
||||||
|
fillStyle: "solid",
|
||||||
|
opacity: 6,
|
||||||
|
})
|
||||||
|
: null;
|
||||||
|
|
||||||
|
return [
|
||||||
|
...(debugRect ? [debugRect] : []),
|
||||||
|
...(title ? [title] : []),
|
||||||
|
...chartXLabels(spreadsheet, x, y, groupId, backgroundColor),
|
||||||
|
...chartYLabels(spreadsheet, x, y, groupId, backgroundColor),
|
||||||
|
...chartLines(spreadsheet, x, y, groupId, backgroundColor),
|
||||||
|
];
|
||||||
|
};
|
||||||
|
|
||||||
|
const chartTypeBar = (
|
||||||
|
spreadsheet: Spreadsheet,
|
||||||
|
x: number,
|
||||||
|
y: number,
|
||||||
|
): ChartElements => {
|
||||||
|
const max = Math.max(...spreadsheet.values);
|
||||||
|
const groupId = randomId();
|
||||||
|
const backgroundColor = bgColors[Math.floor(Math.random() * bgColors.length)];
|
||||||
|
|
||||||
|
const bars = spreadsheet.values.map((value, index) => {
|
||||||
|
const barHeight = (value / max) * BAR_HEIGHT;
|
||||||
|
return newElement({
|
||||||
|
backgroundColor,
|
||||||
|
groupIds: [groupId],
|
||||||
|
...commonProps,
|
||||||
|
type: "rectangle",
|
||||||
|
x: x + index * (BAR_WIDTH + BAR_GAP) + BAR_GAP,
|
||||||
|
y: y - barHeight - BAR_GAP,
|
||||||
|
width: BAR_WIDTH,
|
||||||
|
height: barHeight,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
return [
|
||||||
|
...bars,
|
||||||
|
...chartBaseElements(
|
||||||
|
spreadsheet,
|
||||||
|
x,
|
||||||
|
y,
|
||||||
|
groupId,
|
||||||
|
backgroundColor,
|
||||||
|
process.env.NODE_ENV === ENV.DEVELOPMENT,
|
||||||
|
),
|
||||||
|
];
|
||||||
|
};
|
||||||
|
|
||||||
|
const chartTypeLine = (
|
||||||
|
spreadsheet: Spreadsheet,
|
||||||
|
x: number,
|
||||||
|
y: number,
|
||||||
|
): ChartElements => {
|
||||||
|
const max = Math.max(...spreadsheet.values);
|
||||||
|
const groupId = randomId();
|
||||||
|
const backgroundColor = bgColors[Math.floor(Math.random() * bgColors.length)];
|
||||||
|
|
||||||
|
let index = 0;
|
||||||
|
const points = [];
|
||||||
|
for (const value of spreadsheet.values) {
|
||||||
|
const cx = index * (BAR_WIDTH + BAR_GAP);
|
||||||
|
const cy = -(value / max) * BAR_HEIGHT;
|
||||||
|
points.push([cx, cy]);
|
||||||
|
index++;
|
||||||
|
}
|
||||||
|
|
||||||
|
const maxX = Math.max(...points.map((element) => element[0]));
|
||||||
|
const maxY = Math.max(...points.map((element) => element[1]));
|
||||||
|
const minX = Math.min(...points.map((element) => element[0]));
|
||||||
|
const minY = Math.min(...points.map((element) => element[1]));
|
||||||
|
|
||||||
|
const line = newLinearElement({
|
||||||
|
backgroundColor,
|
||||||
|
groupIds: [groupId],
|
||||||
|
...commonProps,
|
||||||
|
type: "line",
|
||||||
|
x: x + BAR_GAP + BAR_WIDTH / 2,
|
||||||
|
y: y - BAR_GAP,
|
||||||
|
startArrowhead: null,
|
||||||
|
endArrowhead: null,
|
||||||
|
height: maxY - minY,
|
||||||
|
width: maxX - minX,
|
||||||
|
strokeWidth: 2,
|
||||||
|
points: points as any,
|
||||||
|
});
|
||||||
|
|
||||||
|
const dots = spreadsheet.values.map((value, index) => {
|
||||||
|
const cx = index * (BAR_WIDTH + BAR_GAP) + BAR_GAP / 2;
|
||||||
|
const cy = -(value / max) * BAR_HEIGHT + BAR_GAP / 2;
|
||||||
|
return newElement({
|
||||||
|
backgroundColor,
|
||||||
|
groupIds: [groupId],
|
||||||
|
...commonProps,
|
||||||
|
fillStyle: "solid",
|
||||||
|
strokeWidth: 2,
|
||||||
|
type: "ellipse",
|
||||||
|
x: x + cx + BAR_WIDTH / 2,
|
||||||
|
y: y + cy - BAR_GAP * 2,
|
||||||
|
width: BAR_GAP,
|
||||||
|
height: BAR_GAP,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
const lines = spreadsheet.values.map((value, index) => {
|
||||||
|
const cx = index * (BAR_WIDTH + BAR_GAP) + BAR_GAP / 2;
|
||||||
|
const cy = (value / max) * BAR_HEIGHT + BAR_GAP / 2 + BAR_GAP;
|
||||||
|
return newLinearElement({
|
||||||
|
backgroundColor,
|
||||||
|
groupIds: [groupId],
|
||||||
|
...commonProps,
|
||||||
|
type: "line",
|
||||||
|
x: x + cx + BAR_WIDTH / 2 + BAR_GAP / 2,
|
||||||
|
y: y - cy,
|
||||||
|
startArrowhead: null,
|
||||||
|
endArrowhead: null,
|
||||||
|
height: cy,
|
||||||
|
strokeStyle: "dotted",
|
||||||
|
opacity: GRID_OPACITY,
|
||||||
|
points: [
|
||||||
|
[0, 0],
|
||||||
|
[0, cy],
|
||||||
|
],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
return [
|
||||||
|
...chartBaseElements(
|
||||||
|
spreadsheet,
|
||||||
|
x,
|
||||||
|
y,
|
||||||
|
groupId,
|
||||||
|
backgroundColor,
|
||||||
|
process.env.NODE_ENV === ENV.DEVELOPMENT,
|
||||||
|
),
|
||||||
|
line,
|
||||||
|
...lines,
|
||||||
|
...dots,
|
||||||
|
];
|
||||||
|
};
|
||||||
|
|
||||||
|
export const renderSpreadsheet = (
|
||||||
|
chartType: string,
|
||||||
|
spreadsheet: Spreadsheet,
|
||||||
|
x: number,
|
||||||
|
y: number,
|
||||||
|
): ChartElements => {
|
||||||
|
trackEvent(EVENT_MAGIC, "chart", chartType, spreadsheet.values.length);
|
||||||
|
if (chartType === "line") {
|
||||||
|
return chartTypeLine(spreadsheet, x, y);
|
||||||
|
}
|
||||||
|
return chartTypeBar(spreadsheet, x, y);
|
||||||
|
};
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
import colors from "./colors";
|
||||||
|
|
||||||
|
export const getClientColors = (clientId: string) => {
|
||||||
|
// Naive way of getting an integer out of the clientId
|
||||||
|
const sum = clientId.split("").reduce((a, str) => a + str.charCodeAt(0), 0);
|
||||||
|
|
||||||
|
// Skip transparent background.
|
||||||
|
const backgrounds = colors.elementBackground.slice(1);
|
||||||
|
const strokes = colors.elementStroke.slice(1);
|
||||||
|
return {
|
||||||
|
background: backgrounds[sum % backgrounds.length],
|
||||||
|
stroke: strokes[sum % strokes.length],
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getClientInitials = (username?: string | null) => {
|
||||||
|
if (!username) {
|
||||||
|
return "?";
|
||||||
|
}
|
||||||
|
const names = username.trim().split(" ");
|
||||||
|
|
||||||
|
if (names.length < 2) {
|
||||||
|
return names[0].substring(0, 2).toUpperCase();
|
||||||
|
}
|
||||||
|
|
||||||
|
const firstName = names[0];
|
||||||
|
const lastName = names[names.length - 1];
|
||||||
|
|
||||||
|
return (firstName[0] + lastName[0]).toUpperCase();
|
||||||
|
};
|
||||||
+112
-67
@@ -5,6 +5,16 @@ import {
|
|||||||
import { getSelectedElements } from "./scene";
|
import { getSelectedElements } from "./scene";
|
||||||
import { AppState } 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 { canvasToBlob } from "./data/blob";
|
||||||
|
|
||||||
|
const TYPE_ELEMENTS = "excalidraw/elements";
|
||||||
|
|
||||||
|
type ElementsClipboard = {
|
||||||
|
type: typeof TYPE_ELEMENTS;
|
||||||
|
created: number;
|
||||||
|
elements: ExcalidrawElement[];
|
||||||
|
};
|
||||||
|
|
||||||
let CLIPBOARD = "";
|
let CLIPBOARD = "";
|
||||||
let PREFER_APP_CLIPBOARD = false;
|
let PREFER_APP_CLIPBOARD = false;
|
||||||
@@ -21,104 +31,139 @@ export const probablySupportsClipboardBlob =
|
|||||||
"ClipboardItem" in window &&
|
"ClipboardItem" in window &&
|
||||||
"toBlob" in HTMLCanvasElement.prototype;
|
"toBlob" in HTMLCanvasElement.prototype;
|
||||||
|
|
||||||
export async function copyToAppClipboard(
|
const isElementsClipboard = (contents: any): contents is ElementsClipboard => {
|
||||||
|
if (contents?.type === TYPE_ELEMENTS) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const copyToClipboard = async (
|
||||||
elements: readonly NonDeletedExcalidrawElement[],
|
elements: readonly NonDeletedExcalidrawElement[],
|
||||||
appState: AppState,
|
appState: AppState,
|
||||||
) {
|
) => {
|
||||||
CLIPBOARD = JSON.stringify(getSelectedElements(elements, appState));
|
const contents: ElementsClipboard = {
|
||||||
|
type: TYPE_ELEMENTS,
|
||||||
|
created: Date.now(),
|
||||||
|
elements: getSelectedElements(elements, appState),
|
||||||
|
};
|
||||||
|
const json = JSON.stringify(contents);
|
||||||
|
CLIPBOARD = json;
|
||||||
try {
|
try {
|
||||||
// when copying to in-app clipboard, clear system clipboard so that if
|
|
||||||
// system clip contains text on paste we know it was copied *after* user
|
|
||||||
// copied elements, and thus we should prefer the text content.
|
|
||||||
await copyTextToSystemClipboard(null);
|
|
||||||
PREFER_APP_CLIPBOARD = false;
|
PREFER_APP_CLIPBOARD = false;
|
||||||
} catch {
|
await copyTextToSystemClipboard(json);
|
||||||
// if clearing system clipboard didn't work, we should prefer in-app
|
} catch (error) {
|
||||||
// clipboard even if there's text in system clipboard on paste, because
|
|
||||||
// we can't be sure of the order of copy operations
|
|
||||||
PREFER_APP_CLIPBOARD = true;
|
PREFER_APP_CLIPBOARD = true;
|
||||||
|
console.error(error);
|
||||||
}
|
}
|
||||||
}
|
};
|
||||||
|
|
||||||
export function getAppClipboard(): {
|
const getAppClipboard = (): Partial<ElementsClipboard> => {
|
||||||
elements?: readonly ExcalidrawElement[];
|
|
||||||
} {
|
|
||||||
if (!CLIPBOARD) {
|
if (!CLIPBOARD) {
|
||||||
return {};
|
return {};
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const clipboardElements = JSON.parse(CLIPBOARD);
|
return JSON.parse(CLIPBOARD);
|
||||||
|
|
||||||
if (
|
|
||||||
Array.isArray(clipboardElements) &&
|
|
||||||
clipboardElements.length > 0 &&
|
|
||||||
clipboardElements[0].type // need to implement a better check here...
|
|
||||||
) {
|
|
||||||
return { elements: clipboardElements };
|
|
||||||
}
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(error);
|
console.error(error);
|
||||||
|
return {};
|
||||||
}
|
}
|
||||||
|
};
|
||||||
|
|
||||||
return {};
|
const parsePotentialSpreadsheet = (
|
||||||
}
|
text: string,
|
||||||
|
): { spreadsheet: Spreadsheet } | { errorMessage: string } | null => {
|
||||||
|
const result = tryParseSpreadsheet(text);
|
||||||
|
if (result.type === VALID_SPREADSHEET) {
|
||||||
|
return { spreadsheet: result.spreadsheet };
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
|
||||||
export async function getClipboardContent(
|
/**
|
||||||
|
* Retrieves content from system clipboard (either from ClipboardEvent or
|
||||||
|
* via async clipboard API if supported)
|
||||||
|
*/
|
||||||
|
const getSystemClipboard = async (
|
||||||
event: ClipboardEvent | null,
|
event: ClipboardEvent | null,
|
||||||
): Promise<{
|
): Promise<string> => {
|
||||||
text?: string;
|
|
||||||
elements?: readonly ExcalidrawElement[];
|
|
||||||
}> {
|
|
||||||
try {
|
try {
|
||||||
const text = event
|
const text = event
|
||||||
? event.clipboardData?.getData("text/plain").trim()
|
? event.clipboardData?.getData("text/plain").trim()
|
||||||
: probablySupportsClipboardReadText &&
|
: probablySupportsClipboardReadText &&
|
||||||
(await navigator.clipboard.readText());
|
(await navigator.clipboard.readText());
|
||||||
|
|
||||||
if (text && !PREFER_APP_CLIPBOARD && !text.includes(SVG_EXPORT_TAG)) {
|
return text || "";
|
||||||
return { text };
|
} catch {
|
||||||
}
|
return "";
|
||||||
} catch (error) {
|
}
|
||||||
console.error(error);
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Attemps to parse clipboard. Prefers system clipboard.
|
||||||
|
*/
|
||||||
|
export const parseClipboard = async (
|
||||||
|
event: ClipboardEvent | null,
|
||||||
|
): Promise<{
|
||||||
|
spreadsheet?: Spreadsheet;
|
||||||
|
elements?: readonly ExcalidrawElement[];
|
||||||
|
text?: string;
|
||||||
|
errorMessage?: string;
|
||||||
|
}> => {
|
||||||
|
const systemClipboard = await getSystemClipboard(event);
|
||||||
|
|
||||||
|
// if system clipboard empty, couldn't be resolved, or contains previously
|
||||||
|
// copied excalidraw scene as SVG, fall back to previously copied excalidraw
|
||||||
|
// elements
|
||||||
|
if (!systemClipboard || systemClipboard.includes(SVG_EXPORT_TAG)) {
|
||||||
|
return getAppClipboard();
|
||||||
}
|
}
|
||||||
|
|
||||||
return getAppClipboard();
|
// if system clipboard contains spreadsheet, use it even though it's
|
||||||
}
|
// technically possible it's staler than in-app clipboard
|
||||||
|
const spreadsheetResult = parsePotentialSpreadsheet(systemClipboard);
|
||||||
|
if (spreadsheetResult) {
|
||||||
|
return spreadsheetResult;
|
||||||
|
}
|
||||||
|
|
||||||
export async function copyCanvasToClipboardAsPng(canvas: HTMLCanvasElement) {
|
const appClipboardData = getAppClipboard();
|
||||||
return new Promise((resolve, reject) => {
|
|
||||||
try {
|
|
||||||
canvas.toBlob(async function (blob: any) {
|
|
||||||
try {
|
|
||||||
await navigator.clipboard.write([
|
|
||||||
new window.ClipboardItem({ "image/png": blob }),
|
|
||||||
]);
|
|
||||||
resolve();
|
|
||||||
} catch (error) {
|
|
||||||
reject(error);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
} catch (error) {
|
|
||||||
reject(error);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function copyCanvasToClipboardAsSvg(svgroot: SVGSVGElement) {
|
|
||||||
try {
|
try {
|
||||||
await navigator.clipboard.writeText(svgroot.outerHTML);
|
const systemClipboardData = JSON.parse(systemClipboard);
|
||||||
} catch (error) {
|
// system clipboard elements are newer than in-app clipboard
|
||||||
console.error(error);
|
if (
|
||||||
|
isElementsClipboard(systemClipboardData) &&
|
||||||
|
(!appClipboardData?.created ||
|
||||||
|
appClipboardData.created < systemClipboardData.created)
|
||||||
|
) {
|
||||||
|
return { elements: systemClipboardData.elements };
|
||||||
|
}
|
||||||
|
// in-app clipboard is newer than system clipboard
|
||||||
|
return appClipboardData;
|
||||||
|
} catch {
|
||||||
|
// system clipboard doesn't contain excalidraw elements → return plaintext
|
||||||
|
// unless we set a flag to prefer in-app clipboard because browser didn't
|
||||||
|
// support storing to system clipboard on copy
|
||||||
|
return PREFER_APP_CLIPBOARD && appClipboardData.elements
|
||||||
|
? appClipboardData
|
||||||
|
: { text: systemClipboard };
|
||||||
}
|
}
|
||||||
}
|
};
|
||||||
|
|
||||||
export async function copyTextToSystemClipboard(text: string | null) {
|
export const copyCanvasToClipboardAsPng = async (canvas: HTMLCanvasElement) => {
|
||||||
|
const blob = await canvasToBlob(canvas);
|
||||||
|
await navigator.clipboard.write([
|
||||||
|
new window.ClipboardItem({ "image/png": blob }),
|
||||||
|
]);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const copyTextToSystemClipboard = async (text: string | null) => {
|
||||||
let copied = false;
|
let copied = false;
|
||||||
if (probablySupportsClipboardWriteText) {
|
if (probablySupportsClipboardWriteText) {
|
||||||
try {
|
try {
|
||||||
// NOTE: doesn't work on FF on non-HTTPS domains, or when document
|
// NOTE: doesn't work on FF on non-HTTPS domains, or when document
|
||||||
// not focused
|
// not focused
|
||||||
await navigator.clipboard.writeText(text || "");
|
await navigator.clipboard.writeText(text || "");
|
||||||
copied = true;
|
copied = true;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -127,14 +172,14 @@ export async function copyTextToSystemClipboard(text: string | null) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Note that execCommand doesn't allow copying empty strings, so if we're
|
// Note that execCommand doesn't allow copying empty strings, so if we're
|
||||||
// clearing clipboard using this API, we must copy at least an empty char
|
// clearing clipboard using this API, we must copy at least an empty char
|
||||||
if (!copied && !copyTextViaExecCommand(text || " ")) {
|
if (!copied && !copyTextViaExecCommand(text || " ")) {
|
||||||
throw new Error("couldn't copy");
|
throw new Error("couldn't copy");
|
||||||
}
|
}
|
||||||
}
|
};
|
||||||
|
|
||||||
// adapted from https://github.com/zenorocha/clipboard.js/blob/ce79f170aa655c408b6aab33c9472e8e4fa52e19/src/clipboard-action.js#L48
|
// adapted from https://github.com/zenorocha/clipboard.js/blob/ce79f170aa655c408b6aab33c9472e8e4fa52e19/src/clipboard-action.js#L48
|
||||||
function copyTextViaExecCommand(text: string) {
|
const copyTextViaExecCommand = (text: string) => {
|
||||||
const isRTL = document.documentElement.getAttribute("dir") === "rtl";
|
const isRTL = document.documentElement.getAttribute("dir") === "rtl";
|
||||||
|
|
||||||
const textarea = document.createElement("textarea");
|
const textarea = document.createElement("textarea");
|
||||||
@@ -168,4 +213,4 @@ function copyTextViaExecCommand(text: string) {
|
|||||||
textarea.remove();
|
textarea.remove();
|
||||||
|
|
||||||
return success;
|
return success;
|
||||||
}
|
};
|
||||||
|
|||||||
+13
-13
@@ -1,18 +1,18 @@
|
|||||||
import oc from "open-color";
|
import oc from "open-color";
|
||||||
|
|
||||||
const shades = (i: number) => [
|
const shades = (index: number) => [
|
||||||
oc.red[i],
|
oc.red[index],
|
||||||
oc.pink[i],
|
oc.pink[index],
|
||||||
oc.grape[i],
|
oc.grape[index],
|
||||||
oc.violet[i],
|
oc.violet[index],
|
||||||
oc.indigo[i],
|
oc.indigo[index],
|
||||||
oc.blue[i],
|
oc.blue[index],
|
||||||
oc.cyan[i],
|
oc.cyan[index],
|
||||||
oc.teal[i],
|
oc.teal[index],
|
||||||
oc.green[i],
|
oc.green[index],
|
||||||
oc.lime[i],
|
oc.lime[index],
|
||||||
oc.yellow[i],
|
oc.yellow[index],
|
||||||
oc.orange[i],
|
oc.orange[index],
|
||||||
];
|
];
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
|
|||||||
+152
-67
@@ -1,17 +1,25 @@
|
|||||||
import React from "react";
|
import React from "react";
|
||||||
import { AppState } from "../types";
|
import { AppState, Zoom } from "../types";
|
||||||
import { ExcalidrawElement } from "../element/types";
|
import { ExcalidrawElement } from "../element/types";
|
||||||
import { ActionManager } from "../actions/manager";
|
import { ActionManager } from "../actions/manager";
|
||||||
import { hasBackground, hasStroke, hasText, getTargetElement } from "../scene";
|
import {
|
||||||
|
hasBackground,
|
||||||
|
hasStroke,
|
||||||
|
canChangeSharpness,
|
||||||
|
hasText,
|
||||||
|
canHaveArrowheads,
|
||||||
|
getTargetElements,
|
||||||
|
} from "../scene";
|
||||||
import { t } from "../i18n";
|
import { t } from "../i18n";
|
||||||
import { SHAPES } from "../shapes";
|
import { SHAPES } from "../shapes";
|
||||||
import { ToolButton } from "./ToolButton";
|
import { ToolButton } from "./ToolButton";
|
||||||
import { capitalizeString, setCursorForShape } from "../utils";
|
import { capitalizeString, isTransparent, setCursorForShape } from "../utils";
|
||||||
import Stack from "./Stack";
|
import Stack from "./Stack";
|
||||||
import useIsMobile from "../is-mobile";
|
import useIsMobile from "../is-mobile";
|
||||||
import { getNonDeletedElements } from "../element";
|
import { getNonDeletedElements } from "../element";
|
||||||
|
import { trackEvent, EVENT_SHAPE, EVENT_DIALOG } from "../analytics";
|
||||||
|
|
||||||
export function SelectedShapeActions({
|
export const SelectedShapeActions = ({
|
||||||
appState,
|
appState,
|
||||||
elements,
|
elements,
|
||||||
renderAction,
|
renderAction,
|
||||||
@@ -21,35 +29,45 @@ export function SelectedShapeActions({
|
|||||||
elements: readonly ExcalidrawElement[];
|
elements: readonly ExcalidrawElement[];
|
||||||
renderAction: ActionManager["renderAction"];
|
renderAction: ActionManager["renderAction"];
|
||||||
elementType: ExcalidrawElement["type"];
|
elementType: ExcalidrawElement["type"];
|
||||||
}) {
|
}) => {
|
||||||
const targetElements = getTargetElement(
|
const targetElements = getTargetElements(
|
||||||
getNonDeletedElements(elements),
|
getNonDeletedElements(elements),
|
||||||
appState,
|
appState,
|
||||||
);
|
);
|
||||||
const isEditing = Boolean(appState.editingElement);
|
const isEditing = Boolean(appState.editingElement);
|
||||||
const isMobile = useIsMobile();
|
const isMobile = useIsMobile();
|
||||||
|
const isRTL = document.documentElement.getAttribute("dir") === "rtl";
|
||||||
|
|
||||||
|
const showFillIcons =
|
||||||
|
hasBackground(elementType) ||
|
||||||
|
targetElements.some(
|
||||||
|
(element) =>
|
||||||
|
hasBackground(element.type) && !isTransparent(element.backgroundColor),
|
||||||
|
);
|
||||||
|
const showChangeBackgroundIcons =
|
||||||
|
hasBackground(elementType) ||
|
||||||
|
targetElements.some((element) => hasBackground(element.type));
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="panelColumn">
|
<div className="panelColumn">
|
||||||
{renderAction("changeStrokeColor")}
|
{renderAction("changeStrokeColor")}
|
||||||
{(hasBackground(elementType) ||
|
{showChangeBackgroundIcons && renderAction("changeBackgroundColor")}
|
||||||
targetElements.some((element) => hasBackground(element.type))) && (
|
{showFillIcons && renderAction("changeFillStyle")}
|
||||||
<>
|
|
||||||
{renderAction("changeBackgroundColor")}
|
|
||||||
|
|
||||||
{renderAction("changeFillStyle")}
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{(hasStroke(elementType) ||
|
{(hasStroke(elementType) ||
|
||||||
targetElements.some((element) => hasStroke(element.type))) && (
|
targetElements.some((element) => hasStroke(element.type))) && (
|
||||||
<>
|
<>
|
||||||
{renderAction("changeStrokeWidth")}
|
{renderAction("changeStrokeWidth")}
|
||||||
|
{renderAction("changeStrokeStyle")}
|
||||||
{renderAction("changeSloppiness")}
|
{renderAction("changeSloppiness")}
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{(canChangeSharpness(elementType) ||
|
||||||
|
targetElements.some((element) => canChangeSharpness(element.type))) && (
|
||||||
|
<>{renderAction("changeSharpness")}</>
|
||||||
|
)}
|
||||||
|
|
||||||
{(hasText(elementType) ||
|
{(hasText(elementType) ||
|
||||||
targetElements.some((element) => hasText(element.type))) && (
|
targetElements.some((element) => hasText(element.type))) && (
|
||||||
<>
|
<>
|
||||||
@@ -61,6 +79,11 @@ export function SelectedShapeActions({
|
|||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{(canHaveArrowheads(elementType) ||
|
||||||
|
targetElements.some((element) => canHaveArrowheads(element.type))) && (
|
||||||
|
<>{renderAction("changeArrowhead")}</>
|
||||||
|
)}
|
||||||
|
|
||||||
{renderAction("changeOpacity")}
|
{renderAction("changeOpacity")}
|
||||||
|
|
||||||
<fieldset>
|
<fieldset>
|
||||||
@@ -72,76 +95,138 @@ export function SelectedShapeActions({
|
|||||||
{renderAction("bringForward")}
|
{renderAction("bringForward")}
|
||||||
</div>
|
</div>
|
||||||
</fieldset>
|
</fieldset>
|
||||||
|
|
||||||
|
{targetElements.length > 1 && (
|
||||||
|
<fieldset>
|
||||||
|
<legend>{t("labels.align")}</legend>
|
||||||
|
<div className="buttonList">
|
||||||
|
{
|
||||||
|
// swap this order for RTL so the button positions always match their action
|
||||||
|
// (i.e. the leftmost button aligns left)
|
||||||
|
}
|
||||||
|
{isRTL ? (
|
||||||
|
<>
|
||||||
|
{renderAction("alignRight")}
|
||||||
|
{renderAction("alignHorizontallyCentered")}
|
||||||
|
{renderAction("alignLeft")}
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
{renderAction("alignLeft")}
|
||||||
|
{renderAction("alignHorizontallyCentered")}
|
||||||
|
{renderAction("alignRight")}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{targetElements.length > 2 &&
|
||||||
|
renderAction("distributeHorizontally")}
|
||||||
|
<div className="iconRow">
|
||||||
|
{renderAction("alignTop")}
|
||||||
|
{renderAction("alignVerticallyCentered")}
|
||||||
|
{renderAction("alignBottom")}
|
||||||
|
{targetElements.length > 2 &&
|
||||||
|
renderAction("distributeVertically")}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</fieldset>
|
||||||
|
)}
|
||||||
{!isMobile && !isEditing && targetElements.length > 0 && (
|
{!isMobile && !isEditing && targetElements.length > 0 && (
|
||||||
<fieldset>
|
<fieldset>
|
||||||
<legend>{t("labels.actions")}</legend>
|
<legend>{t("labels.actions")}</legend>
|
||||||
<div className="buttonList">
|
<div className="buttonList">
|
||||||
{renderAction("duplicateSelection")}
|
{renderAction("duplicateSelection")}
|
||||||
{renderAction("deleteSelectedElements")}
|
{renderAction("deleteSelectedElements")}
|
||||||
|
{renderAction("group")}
|
||||||
|
{renderAction("ungroup")}
|
||||||
</div>
|
</div>
|
||||||
</fieldset>
|
</fieldset>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
};
|
||||||
|
|
||||||
export function ShapesSwitcher({
|
const LIBRARY_ICON = (
|
||||||
|
// fa-th-large
|
||||||
|
<svg viewBox="0 0 512 512">
|
||||||
|
<path d="M296 32h192c13.255 0 24 10.745 24 24v160c0 13.255-10.745 24-24 24H296c-13.255 0-24-10.745-24-24V56c0-13.255 10.745-24 24-24zm-80 0H24C10.745 32 0 42.745 0 56v160c0 13.255 10.745 24 24 24h192c13.255 0 24-10.745 24-24V56c0-13.255-10.745-24-24-24zM0 296v160c0 13.255 10.745 24 24 24h192c13.255 0 24-10.745 24-24V296c0-13.255-10.745-24-24-24H24c-13.255 0-24 10.745-24 24zm296 184h192c13.255 0 24-10.745 24-24V296c0-13.255-10.745-24-24-24H296c-13.255 0-24 10.745-24 24v160c0 13.255 10.745 24 24 24z" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
|
||||||
|
export const ShapesSwitcher = ({
|
||||||
elementType,
|
elementType,
|
||||||
setAppState,
|
setAppState,
|
||||||
|
isLibraryOpen,
|
||||||
}: {
|
}: {
|
||||||
elementType: ExcalidrawElement["type"];
|
elementType: ExcalidrawElement["type"];
|
||||||
setAppState: any;
|
setAppState: React.Component<any, AppState>["setState"];
|
||||||
}) {
|
isLibraryOpen: boolean;
|
||||||
return (
|
}) => (
|
||||||
<>
|
<>
|
||||||
{SHAPES.map(({ value, icon, key }, index) => {
|
{SHAPES.map(({ value, icon, key }, index) => {
|
||||||
const label = t(`toolBar.${value}`);
|
const label = t(`toolBar.${value}`);
|
||||||
const shortcut = `${capitalizeString(key)} ${t("shortcutsDialog.or")} ${
|
const letter = typeof key === "string" ? key : key[0];
|
||||||
index + 1
|
const shortcut = `${capitalizeString(letter)} ${t(
|
||||||
}`;
|
"shortcutsDialog.or",
|
||||||
return (
|
)} ${index + 1}`;
|
||||||
<ToolButton
|
return (
|
||||||
key={value}
|
<ToolButton
|
||||||
type="radio"
|
className="Shape"
|
||||||
icon={icon}
|
key={value}
|
||||||
checked={elementType === value}
|
type="radio"
|
||||||
name="editor-current-shape"
|
icon={icon}
|
||||||
title={`${capitalizeString(label)} — ${shortcut}`}
|
checked={elementType === value}
|
||||||
keyBindingLabel={`${index + 1}`}
|
name="editor-current-shape"
|
||||||
aria-label={capitalizeString(label)}
|
title={`${capitalizeString(label)} — ${shortcut}`}
|
||||||
aria-keyshortcuts={`${key} ${index + 1}`}
|
keyBindingLabel={`${index + 1}`}
|
||||||
data-testid={value}
|
aria-label={capitalizeString(label)}
|
||||||
onChange={() => {
|
aria-keyshortcuts={shortcut}
|
||||||
setAppState({
|
data-testid={value}
|
||||||
elementType: value,
|
onChange={() => {
|
||||||
multiElement: null,
|
trackEvent(EVENT_SHAPE, value, "toolbar");
|
||||||
selectedElementIds: {},
|
setAppState({
|
||||||
});
|
elementType: value,
|
||||||
setCursorForShape(value);
|
multiElement: null,
|
||||||
setAppState({});
|
selectedElementIds: {},
|
||||||
}}
|
});
|
||||||
></ToolButton>
|
setCursorForShape(value);
|
||||||
);
|
setAppState({});
|
||||||
})}
|
}}
|
||||||
</>
|
/>
|
||||||
);
|
);
|
||||||
}
|
})}
|
||||||
|
<ToolButton
|
||||||
|
className="Shape ToolIcon_type_button__library"
|
||||||
|
type="button"
|
||||||
|
icon={LIBRARY_ICON}
|
||||||
|
name="editor-library"
|
||||||
|
keyBindingLabel="9"
|
||||||
|
aria-keyshortcuts="9"
|
||||||
|
title={`${capitalizeString(t("toolBar.library"))} — 9`}
|
||||||
|
aria-label={capitalizeString(t("toolBar.library"))}
|
||||||
|
onClick={() => {
|
||||||
|
if (!isLibraryOpen) {
|
||||||
|
trackEvent(EVENT_DIALOG, "library");
|
||||||
|
}
|
||||||
|
setAppState({ isLibraryOpen: !isLibraryOpen });
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
|
||||||
export function ZoomActions({
|
export const ZoomActions = ({
|
||||||
renderAction,
|
renderAction,
|
||||||
zoom,
|
zoom,
|
||||||
}: {
|
}: {
|
||||||
renderAction: ActionManager["renderAction"];
|
renderAction: ActionManager["renderAction"];
|
||||||
zoom: number;
|
zoom: Zoom;
|
||||||
}) {
|
}) => (
|
||||||
return (
|
<Stack.Col gap={1}>
|
||||||
<Stack.Col gap={1}>
|
<Stack.Row gap={1} align="center">
|
||||||
<Stack.Row gap={1} align="center">
|
{renderAction("zoomIn")}
|
||||||
{renderAction("zoomIn")}
|
{renderAction("zoomOut")}
|
||||||
{renderAction("zoomOut")}
|
{renderAction("resetZoom")}
|
||||||
{renderAction("resetZoom")}
|
<div style={{ marginInlineStart: 4 }}>
|
||||||
<div style={{ marginInlineStart: 4 }}>{(zoom * 100).toFixed(0)}%</div>
|
{(zoom.value * 100).toFixed(0)}%
|
||||||
</Stack.Row>
|
</div>
|
||||||
</Stack.Col>
|
</Stack.Row>
|
||||||
);
|
</Stack.Col>
|
||||||
}
|
);
|
||||||
|
|||||||
+2738
-1620
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,16 @@
|
|||||||
|
@import "../css/_variables";
|
||||||
|
|
||||||
|
.excalidraw {
|
||||||
|
.Avatar {
|
||||||
|
width: 2.5rem;
|
||||||
|
height: 2.5rem;
|
||||||
|
border-radius: 1.25rem;
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
color: $oc-white;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
import "./Avatar.scss";
|
||||||
|
|
||||||
|
import React from "react";
|
||||||
|
|
||||||
|
type AvatarProps = {
|
||||||
|
children: string;
|
||||||
|
onClick: (e: React.MouseEvent<HTMLDivElement, MouseEvent>) => void;
|
||||||
|
color: string;
|
||||||
|
border: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const Avatar = ({ children, color, border, onClick }: AvatarProps) => (
|
||||||
|
<div
|
||||||
|
className="Avatar"
|
||||||
|
style={{ background: color, border: `1px solid ${border}` }}
|
||||||
|
onClick={onClick}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
import React from "react";
|
||||||
|
import { ActionManager } from "../actions/manager";
|
||||||
|
import { EVENT_CHANGE, trackEvent } from "../analytics";
|
||||||
|
import { AppState } from "../types";
|
||||||
|
import { DarkModeToggle } from "./DarkModeToggle";
|
||||||
|
|
||||||
|
export const BackgroundPickerAndDarkModeToggle = ({
|
||||||
|
appState,
|
||||||
|
setAppState,
|
||||||
|
actionManager,
|
||||||
|
}: {
|
||||||
|
actionManager: ActionManager;
|
||||||
|
appState: AppState;
|
||||||
|
setAppState: React.Component<any, AppState>["setState"];
|
||||||
|
}) => (
|
||||||
|
<div style={{ display: "flex" }}>
|
||||||
|
{actionManager.renderAction("changeViewBackgroundColor")}
|
||||||
|
<div style={{ marginInlineStart: "0.25rem" }}>
|
||||||
|
<DarkModeToggle
|
||||||
|
value={appState.appearance}
|
||||||
|
onChange={(appearance) => {
|
||||||
|
// TODO: track the theme on the first load too
|
||||||
|
trackEvent(EVENT_CHANGE, "theme", appearance);
|
||||||
|
setAppState({ appearance });
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
import React from "react";
|
||||||
|
import clsx from "clsx";
|
||||||
|
|
||||||
|
export const ButtonIconCycle = <T extends any>({
|
||||||
|
options,
|
||||||
|
value,
|
||||||
|
onChange,
|
||||||
|
group,
|
||||||
|
}: {
|
||||||
|
options: { value: T; text: string; icon: JSX.Element }[];
|
||||||
|
value: T | null;
|
||||||
|
onChange: (value: T) => void;
|
||||||
|
group: string;
|
||||||
|
}) => {
|
||||||
|
const current = options.find((op) => op.value === value);
|
||||||
|
|
||||||
|
function cycle() {
|
||||||
|
const index = options.indexOf(current!);
|
||||||
|
const next = (index + 1) % options.length;
|
||||||
|
onChange(options[next].value);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<label key={group} className={clsx({ active: current!.value !== null })}>
|
||||||
|
<input type="button" name={group} onClick={cycle} />
|
||||||
|
{current!.icon}
|
||||||
|
</label>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
import React from "react";
|
||||||
|
import clsx from "clsx";
|
||||||
|
|
||||||
|
// TODO: It might be "clever" to add option.icon to the existing component <ButtonSelect />
|
||||||
|
export const ButtonIconSelect = <T extends Object>({
|
||||||
|
options,
|
||||||
|
value,
|
||||||
|
onChange,
|
||||||
|
group,
|
||||||
|
}: {
|
||||||
|
options: { value: T; text: string; icon: JSX.Element }[];
|
||||||
|
value: T | null;
|
||||||
|
onChange: (value: T) => void;
|
||||||
|
group: string;
|
||||||
|
}) => (
|
||||||
|
<div className="buttonList buttonListIcon">
|
||||||
|
{options.map((option) => (
|
||||||
|
<label
|
||||||
|
key={option.text}
|
||||||
|
className={clsx({ active: value === option.value })}
|
||||||
|
title={option.text}
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
type="radio"
|
||||||
|
name={group}
|
||||||
|
onChange={() => onChange(option.value)}
|
||||||
|
checked={value === option.value}
|
||||||
|
/>
|
||||||
|
{option.icon}
|
||||||
|
</label>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
import React from "react";
|
import React from "react";
|
||||||
|
import clsx from "clsx";
|
||||||
|
|
||||||
export function ButtonSelect<T>({
|
export const ButtonSelect = <T extends Object>({
|
||||||
options,
|
options,
|
||||||
value,
|
value,
|
||||||
onChange,
|
onChange,
|
||||||
@@ -10,23 +11,21 @@ export function ButtonSelect<T>({
|
|||||||
value: T | null;
|
value: T | null;
|
||||||
onChange: (value: T) => void;
|
onChange: (value: T) => void;
|
||||||
group: string;
|
group: string;
|
||||||
}) {
|
}) => (
|
||||||
return (
|
<div className="buttonList">
|
||||||
<div className="buttonList">
|
{options.map((option) => (
|
||||||
{options.map((option) => (
|
<label
|
||||||
<label
|
key={option.text}
|
||||||
key={option.text}
|
className={clsx({ active: value === option.value })}
|
||||||
className={value === option.value ? "active" : ""}
|
>
|
||||||
>
|
<input
|
||||||
<input
|
type="radio"
|
||||||
type="radio"
|
name={group}
|
||||||
name={group}
|
onChange={() => onChange(option.value)}
|
||||||
onChange={() => onChange(option.value)}
|
checked={value === option.value}
|
||||||
checked={value === option.value ? true : false}
|
/>
|
||||||
/>
|
{option.text}
|
||||||
{option.text}
|
</label>
|
||||||
</label>
|
))}
|
||||||
))}
|
</div>
|
||||||
</div>
|
);
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -0,0 +1,29 @@
|
|||||||
|
@import "../css/_variables";
|
||||||
|
|
||||||
|
.excalidraw {
|
||||||
|
.CollabButton.is-collaborating {
|
||||||
|
background-color: var(--button-special-active-background-color);
|
||||||
|
|
||||||
|
.ToolIcon__icon svg {
|
||||||
|
color: var(--icon-green-fill-color);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.CollabButton-collaborators {
|
||||||
|
:root[dir="ltr"] & {
|
||||||
|
right: -5px;
|
||||||
|
}
|
||||||
|
:root[dir="rtl"] & {
|
||||||
|
left: -5px;
|
||||||
|
}
|
||||||
|
min-width: 1em;
|
||||||
|
position: absolute;
|
||||||
|
bottom: -5px;
|
||||||
|
padding: 3px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background-color: $oc-green-6;
|
||||||
|
color: $oc-white;
|
||||||
|
font-size: 0.7em;
|
||||||
|
font-family: var(--ui-font);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
import React from "react";
|
||||||
|
import clsx from "clsx";
|
||||||
|
import { ToolButton } from "./ToolButton";
|
||||||
|
import { t } from "../i18n";
|
||||||
|
import useIsMobile from "../is-mobile";
|
||||||
|
import { users } from "./icons";
|
||||||
|
|
||||||
|
import "./CollabButton.scss";
|
||||||
|
import { EVENT_DIALOG, trackEvent } from "../analytics";
|
||||||
|
|
||||||
|
const CollabButton = ({
|
||||||
|
isCollaborating,
|
||||||
|
collaboratorCount,
|
||||||
|
onClick,
|
||||||
|
}: {
|
||||||
|
isCollaborating: boolean;
|
||||||
|
collaboratorCount: number;
|
||||||
|
onClick: () => void;
|
||||||
|
}) => {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<ToolButton
|
||||||
|
className={clsx("CollabButton", {
|
||||||
|
"is-collaborating": isCollaborating,
|
||||||
|
})}
|
||||||
|
onClick={() => {
|
||||||
|
trackEvent(EVENT_DIALOG, "collaboration");
|
||||||
|
onClick();
|
||||||
|
}}
|
||||||
|
icon={users}
|
||||||
|
type="button"
|
||||||
|
title={t("buttons.roomDialog")}
|
||||||
|
aria-label={t("buttons.roomDialog")}
|
||||||
|
showAriaLabel={useIsMobile()}
|
||||||
|
>
|
||||||
|
{collaboratorCount > 0 && (
|
||||||
|
<div className="CollabButton-collaborators">{collaboratorCount}</div>
|
||||||
|
)}
|
||||||
|
</ToolButton>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default CollabButton;
|
||||||
+246
-187
@@ -1,191 +1,250 @@
|
|||||||
@import "open-color/open-color.scss";
|
@import "../css/_variables";
|
||||||
|
|
||||||
.color-picker {
|
.excalidraw {
|
||||||
background: $oc-white;
|
.color-picker {
|
||||||
border: 0px solid transparentize($oc-white, 0.75);
|
background: var(--popup-background-color);
|
||||||
box-shadow: transparentize($oc-black, 0.75) 0px 1px 4px;
|
border: 0px solid transparentize($oc-white, 0.75);
|
||||||
border-radius: 4px;
|
box-shadow: transparentize($oc-black, 0.75) 0px 1px 4px;
|
||||||
position: absolute;
|
border-radius: 4px;
|
||||||
:root[dir="ltr"] & {
|
position: absolute;
|
||||||
left: -5.5px;
|
|
||||||
|
:root[dir="ltr"] & {
|
||||||
|
left: -5.5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
:root[dir="rtl"] & {
|
||||||
|
right: -5.5px;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
:root[dir="rtl"] & {
|
|
||||||
right: -5.5px;
|
.color-picker-control-container {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: auto 1fr;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.color-picker-triangle {
|
||||||
|
width: 0px;
|
||||||
|
height: 0px;
|
||||||
|
border-style: solid;
|
||||||
|
border-width: 0px 9px 10px;
|
||||||
|
border-color: transparent transparent var(--popup-background-color);
|
||||||
|
position: absolute;
|
||||||
|
top: -10px;
|
||||||
|
|
||||||
|
:root[dir="ltr"] & {
|
||||||
|
left: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
:root[dir="rtl"] & {
|
||||||
|
right: 12px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.color-picker-triangle-shadow {
|
||||||
|
border-color: transparent transparent transparentize($oc-black, 0.9);
|
||||||
|
top: -11px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.color-picker-content {
|
||||||
|
padding: 0.5rem;
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(5, auto);
|
||||||
|
grid-gap: 0.5rem;
|
||||||
|
border-radius: 4px;
|
||||||
|
|
||||||
|
&:focus {
|
||||||
|
outline: none;
|
||||||
|
box-shadow: 0 0 0 2px var(--focus-highlight-color);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.color-picker-content .color-input-container {
|
||||||
|
grid-column: 1 / span 5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.color-picker-swatch {
|
||||||
|
position: relative;
|
||||||
|
height: 1.875rem;
|
||||||
|
width: 1.875rem;
|
||||||
|
cursor: pointer;
|
||||||
|
border-radius: 4px;
|
||||||
|
margin: 0;
|
||||||
|
box-sizing: border-box;
|
||||||
|
border: 1px solid #ddd;
|
||||||
|
background-color: currentColor !important;
|
||||||
|
filter: var(--appearance-filter);
|
||||||
|
|
||||||
|
&:focus {
|
||||||
|
/* TODO: only show the border when the color is too light to see as a shadow */
|
||||||
|
box-shadow: 0 0 4px 1px currentColor;
|
||||||
|
border-color: var(--select-highlight-color);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.color-picker-transparent {
|
||||||
|
border-radius: 4px;
|
||||||
|
box-shadow: transparentize($oc-black, 0.9) 0px 0px 0px 1px inset;
|
||||||
|
position: absolute;
|
||||||
|
top: 0px;
|
||||||
|
right: 0px;
|
||||||
|
bottom: 0px;
|
||||||
|
left: 0px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.color-picker-transparent,
|
||||||
|
.color-picker-label-swatch {
|
||||||
|
background: url("data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABAAAAAQCAYAAAAf8/9hAAAAMUlEQVQ4T2NkYGAQYcAP3uCTZhw1gGGYhAGBZIA/nYDCgBDAm9BGDWAAJyRCgLaBCAAgXwixzAS0pgAAAABJRU5ErkJggg==")
|
||||||
|
left center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.color-picker-hash {
|
||||||
|
background: var(--input-border-color);
|
||||||
|
height: 1.875rem;
|
||||||
|
width: 1.875rem;
|
||||||
|
|
||||||
|
:root[dir="ltr"] & {
|
||||||
|
border-radius: 4px 0px 0px 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
:root[dir="rtl"] & {
|
||||||
|
border-radius: 0px 4px 4px 0px;
|
||||||
|
}
|
||||||
|
|
||||||
|
color: var(--input-label-color);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.color-input-container:focus-within .color-picker-hash {
|
||||||
|
box-shadow: 0 0 0 2px var(--focus-highlight-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.color-input-container:focus-within .color-picker-hash::before,
|
||||||
|
.color-input-container:focus-within .color-picker-hash::after {
|
||||||
|
content: "";
|
||||||
|
width: 1px;
|
||||||
|
height: 100%;
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.color-input-container:focus-within .color-picker-hash::before {
|
||||||
|
background: var(--input-border-color);
|
||||||
|
|
||||||
|
:root[dir="ltr"] & {
|
||||||
|
right: -1px;
|
||||||
|
}
|
||||||
|
|
||||||
|
:root[dir="rtl"] & {
|
||||||
|
left: -1px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.color-input-container:focus-within .color-picker-hash::after {
|
||||||
|
background: var(--input-background-color);
|
||||||
|
|
||||||
|
:root[dir="ltr"] & {
|
||||||
|
right: -2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
:root[dir="rtl"] & {
|
||||||
|
left: -2px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.color-input-container {
|
||||||
|
display: flex;
|
||||||
|
}
|
||||||
|
|
||||||
|
.color-picker-input {
|
||||||
|
width: 12ch; /* length of `transparent` + 1 */
|
||||||
|
margin: 0;
|
||||||
|
font-size: 1rem;
|
||||||
|
background-color: var(--input-background-color);
|
||||||
|
color: var(--text-color-primary);
|
||||||
|
border: 0px;
|
||||||
|
outline: none;
|
||||||
|
height: 1.75em;
|
||||||
|
box-shadow: var(--input-border-color) 0px 0px 0px 1px inset;
|
||||||
|
|
||||||
|
:root[dir="ltr"] & {
|
||||||
|
border-radius: 0px 4px 4px 0px;
|
||||||
|
}
|
||||||
|
|
||||||
|
:root[dir="rtl"] & {
|
||||||
|
border-radius: 4px 0px 0px 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
float: left;
|
||||||
|
padding: 1px;
|
||||||
|
padding-inline-start: 0.5em;
|
||||||
|
appearance: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.color-picker-label-swatch {
|
||||||
|
height: 1.875rem;
|
||||||
|
width: 1.875rem;
|
||||||
|
margin-inline-end: 0.25rem;
|
||||||
|
border: 1px solid $oc-gray-3;
|
||||||
|
position: relative;
|
||||||
|
overflow: hidden;
|
||||||
|
background-color: transparent !important;
|
||||||
|
filter: var(--appearance-filter);
|
||||||
|
|
||||||
|
&:after {
|
||||||
|
content: "";
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
background: var(--swatch-color);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.color-picker-keybinding {
|
||||||
|
position: absolute;
|
||||||
|
bottom: 2px;
|
||||||
|
font-size: 0.7em;
|
||||||
|
|
||||||
|
:root[dir="ltr"] & {
|
||||||
|
right: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
:root[dir="rtl"] & {
|
||||||
|
left: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media #{$is-mobile-query} {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.color-picker-type-canvasBackground .color-picker-keybinding {
|
||||||
|
color: #aaa;
|
||||||
|
}
|
||||||
|
|
||||||
|
.color-picker-type-elementBackground .color-picker-keybinding {
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.color-picker-swatch[aria-label="transparent"] .color-picker-keybinding {
|
||||||
|
color: #aaa;
|
||||||
|
}
|
||||||
|
|
||||||
|
.color-picker-type-elementStroke .color-picker-keybinding {
|
||||||
|
color: #d4d4d4;
|
||||||
|
}
|
||||||
|
|
||||||
|
&.Appearance_dark {
|
||||||
|
.color-picker-type-elementBackground .color-picker-keybinding {
|
||||||
|
color: #000;
|
||||||
|
}
|
||||||
|
.color-picker-swatch[aria-label="transparent"] .color-picker-keybinding {
|
||||||
|
color: #000;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.color-picker-control-container {
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: auto 1fr;
|
|
||||||
align-items: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.color-picker-triangle {
|
|
||||||
width: 0px;
|
|
||||||
height: 0px;
|
|
||||||
border-style: solid;
|
|
||||||
border-width: 0px 9px 10px;
|
|
||||||
border-color: transparent transparent $oc-white;
|
|
||||||
position: absolute;
|
|
||||||
top: -10px;
|
|
||||||
:root[dir="ltr"] & {
|
|
||||||
left: 12px;
|
|
||||||
}
|
|
||||||
:root[dir="rtl"] & {
|
|
||||||
right: 12px;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.color-picker-triangle-shadow {
|
|
||||||
border-color: transparent transparent transparentize($oc-black, 0.9);
|
|
||||||
top: -11px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.color-picker-content {
|
|
||||||
padding: 0.5rem;
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: repeat(5, auto);
|
|
||||||
grid-gap: 0.5rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.color-picker-content .color-input-container {
|
|
||||||
grid-column: 1 / span 5;
|
|
||||||
}
|
|
||||||
|
|
||||||
.color-picker-swatch {
|
|
||||||
position: relative;
|
|
||||||
height: 1.875rem;
|
|
||||||
width: 1.875rem;
|
|
||||||
cursor: pointer;
|
|
||||||
border-radius: 4px;
|
|
||||||
margin: 0;
|
|
||||||
box-sizing: border-box;
|
|
||||||
border: 1px solid #ddd;
|
|
||||||
background-color: currentColor !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
.color-picker-swatch:focus {
|
|
||||||
/* TODO: only show the border when the color is too light to see as a shadow */
|
|
||||||
box-shadow: 0 0 4px 1px currentColor;
|
|
||||||
border-color: $oc-blue-5;
|
|
||||||
}
|
|
||||||
|
|
||||||
.color-picker-transparent {
|
|
||||||
border-radius: 4px;
|
|
||||||
box-shadow: transparentize($oc-black, 0.9) 0px 0px 0px 1px inset;
|
|
||||||
position: absolute;
|
|
||||||
top: 0px;
|
|
||||||
right: 0px;
|
|
||||||
bottom: 0px;
|
|
||||||
left: 0px;
|
|
||||||
}
|
|
||||||
.color-picker-transparent,
|
|
||||||
.color-picker-label-swatch {
|
|
||||||
background: url("data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABAAAAAQCAYAAAAf8/9hAAAAMUlEQVQ4T2NkYGAQYcAP3uCTZhw1gGGYhAGBZIA/nYDCgBDAm9BGDWAAJyRCgLaBCAAgXwixzAS0pgAAAABJRU5ErkJggg==")
|
|
||||||
left center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.color-picker-hash {
|
|
||||||
background: $oc-gray-3;
|
|
||||||
height: 1.875rem;
|
|
||||||
width: 1.875rem;
|
|
||||||
:root[dir="ltr"] & {
|
|
||||||
border-radius: 4px 0px 0px 4px;
|
|
||||||
}
|
|
||||||
:root[dir="rtl"] & {
|
|
||||||
border-radius: 0px 4px 4px 0px;
|
|
||||||
}
|
|
||||||
color: $oc-gray-7;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
z-index: 1;
|
|
||||||
position: relative;
|
|
||||||
}
|
|
||||||
.color-input-container:focus-within .color-picker-hash {
|
|
||||||
box-shadow: 0 0 0 2px $oc-blue-2;
|
|
||||||
}
|
|
||||||
.color-input-container:focus-within .color-picker-hash::before,
|
|
||||||
.color-input-container:focus-within .color-picker-hash::after {
|
|
||||||
content: "";
|
|
||||||
width: 1px;
|
|
||||||
height: 100%;
|
|
||||||
position: absolute;
|
|
||||||
top: 0;
|
|
||||||
}
|
|
||||||
.color-input-container:focus-within .color-picker-hash::before {
|
|
||||||
background: $oc-gray-3;
|
|
||||||
:root[dir="ltr"] & {
|
|
||||||
right: -1px;
|
|
||||||
}
|
|
||||||
:root[dir="rtl"] & {
|
|
||||||
left: -1px;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.color-input-container:focus-within .color-picker-hash::after {
|
|
||||||
background: #fff;
|
|
||||||
:root[dir="ltr"] & {
|
|
||||||
right: -2px;
|
|
||||||
}
|
|
||||||
:root[dir="rtl"] & {
|
|
||||||
left: -2px;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.color-input-container {
|
|
||||||
display: flex;
|
|
||||||
}
|
|
||||||
|
|
||||||
.color-picker-input {
|
|
||||||
width: 12ch; /* length of `transparent` + 1 */
|
|
||||||
margin: 0;
|
|
||||||
font-size: 1rem;
|
|
||||||
color: $oc-gray-8;
|
|
||||||
border: 0px;
|
|
||||||
outline: none;
|
|
||||||
height: 1.75em;
|
|
||||||
box-shadow: $oc-gray-3 0px 0px 0px 1px inset;
|
|
||||||
:root[dir="ltr"] & {
|
|
||||||
border-radius: 0px 4px 4px 0px;
|
|
||||||
}
|
|
||||||
:root[dir="rtl"] & {
|
|
||||||
border-radius: 4px 0px 0px 4px;
|
|
||||||
}
|
|
||||||
float: left;
|
|
||||||
padding: 1px;
|
|
||||||
padding-inline-start: 0.5em;
|
|
||||||
appearance: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.color-picker-label-swatch {
|
|
||||||
height: 1.875rem;
|
|
||||||
width: 1.875rem;
|
|
||||||
margin-inline-end: 0.25rem;
|
|
||||||
border: 1px solid $oc-gray-3;
|
|
||||||
position: relative;
|
|
||||||
overflow: hidden;
|
|
||||||
background-color: transparent !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
.color-picker-label-swatch::after {
|
|
||||||
content: "";
|
|
||||||
position: absolute;
|
|
||||||
top: 0;
|
|
||||||
left: 0;
|
|
||||||
width: 100%;
|
|
||||||
height: 100%;
|
|
||||||
background: var(--swatch-color);
|
|
||||||
}
|
|
||||||
|
|
||||||
.color-picker-keybinding {
|
|
||||||
position: absolute;
|
|
||||||
bottom: 2px;
|
|
||||||
:root[dir="ltr"] & {
|
|
||||||
right: 2px;
|
|
||||||
}
|
|
||||||
:root[dir="rtl"] & {
|
|
||||||
left: 2px;
|
|
||||||
}
|
|
||||||
font-size: 0.7em;
|
|
||||||
color: #ccc;
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -2,16 +2,16 @@ import React from "react";
|
|||||||
import { Popover } from "./Popover";
|
import { Popover } from "./Popover";
|
||||||
|
|
||||||
import "./ColorPicker.scss";
|
import "./ColorPicker.scss";
|
||||||
import { KEYS } from "../keys";
|
import { isArrowKey, KEYS } from "../keys";
|
||||||
import { t, getLanguage } from "../i18n";
|
import { t, getLanguage } from "../i18n";
|
||||||
import { isWritableElement } from "../utils";
|
import { isWritableElement } from "../utils";
|
||||||
import colors from "../colors";
|
import colors from "../colors";
|
||||||
|
|
||||||
function isValidColor(color: string) {
|
const isValidColor = (color: string) => {
|
||||||
const style = new Option().style;
|
const style = new Option().style;
|
||||||
style.color = color;
|
style.color = color;
|
||||||
return !!style.color;
|
return !!style.color;
|
||||||
}
|
};
|
||||||
|
|
||||||
const getColor = (color: string): string | null => {
|
const getColor = (color: string): string | null => {
|
||||||
if (color === "transparent") {
|
if (color === "transparent") {
|
||||||
@@ -36,13 +36,14 @@ const keyBindings = [
|
|||||||
["a", "s", "d", "f", "g"],
|
["a", "s", "d", "f", "g"],
|
||||||
].flat();
|
].flat();
|
||||||
|
|
||||||
const Picker = function ({
|
const Picker = ({
|
||||||
colors,
|
colors,
|
||||||
color,
|
color,
|
||||||
onChange,
|
onChange,
|
||||||
onClose,
|
onClose,
|
||||||
label,
|
label,
|
||||||
showInput = true,
|
showInput = true,
|
||||||
|
type,
|
||||||
}: {
|
}: {
|
||||||
colors: string[];
|
colors: string[];
|
||||||
color: string | null;
|
color: string | null;
|
||||||
@@ -50,19 +51,21 @@ const Picker = function ({
|
|||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
label: string;
|
label: string;
|
||||||
showInput: boolean;
|
showInput: boolean;
|
||||||
}) {
|
type: "canvasBackground" | "elementBackground" | "elementStroke";
|
||||||
|
}) => {
|
||||||
const firstItem = React.useRef<HTMLButtonElement>();
|
const firstItem = React.useRef<HTMLButtonElement>();
|
||||||
const activeItem = React.useRef<HTMLButtonElement>();
|
const activeItem = React.useRef<HTMLButtonElement>();
|
||||||
const gallery = React.useRef<HTMLDivElement>();
|
const gallery = React.useRef<HTMLDivElement>();
|
||||||
const colorInput = React.useRef<HTMLInputElement>();
|
const colorInput = React.useRef<HTMLInputElement>();
|
||||||
|
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
// After the component is first mounted
|
// After the component is first mounted focus on first input
|
||||||
// focus on first input
|
|
||||||
if (activeItem.current) {
|
if (activeItem.current) {
|
||||||
activeItem.current.focus();
|
activeItem.current.focus();
|
||||||
} else if (colorInput.current) {
|
} else if (colorInput.current) {
|
||||||
colorInput.current.focus();
|
colorInput.current.focus();
|
||||||
|
} else if (gallery.current) {
|
||||||
|
gallery.current.focus();
|
||||||
}
|
}
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
@@ -74,18 +77,11 @@ const Picker = function ({
|
|||||||
colorInput.current?.focus();
|
colorInput.current?.focus();
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
}
|
}
|
||||||
} else {
|
} else if (activeElement === colorInput.current) {
|
||||||
if (activeElement === colorInput.current) {
|
firstItem.current?.focus();
|
||||||
firstItem.current?.focus();
|
event.preventDefault();
|
||||||
event.preventDefault();
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
} else if (
|
} else if (isArrowKey(event.key)) {
|
||||||
event.key === KEYS.ARROW_RIGHT ||
|
|
||||||
event.key === KEYS.ARROW_LEFT ||
|
|
||||||
event.key === KEYS.ARROW_UP ||
|
|
||||||
event.key === KEYS.ARROW_DOWN
|
|
||||||
) {
|
|
||||||
const { activeElement } = document;
|
const { activeElement } = document;
|
||||||
const isRTL = getLanguage().rtl;
|
const isRTL = getLanguage().rtl;
|
||||||
const index = Array.prototype.indexOf.call(
|
const index = Array.prototype.indexOf.call(
|
||||||
@@ -123,7 +119,7 @@ const Picker = function ({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className="color-picker"
|
className={`color-picker color-picker-type-${type}`}
|
||||||
role="dialog"
|
role="dialog"
|
||||||
aria-modal="true"
|
aria-modal="true"
|
||||||
aria-label={t("labels.colorPicker")}
|
aria-label={t("labels.colorPicker")}
|
||||||
@@ -138,6 +134,7 @@ const Picker = function ({
|
|||||||
gallery.current = el;
|
gallery.current = el;
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
|
tabIndex={0}
|
||||||
>
|
>
|
||||||
{colors.map((_color, i) => (
|
{colors.map((_color, i) => (
|
||||||
<button
|
<button
|
||||||
@@ -235,7 +232,7 @@ const ColorInput = React.forwardRef(
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
export function ColorPicker({
|
export const ColorPicker = ({
|
||||||
type,
|
type,
|
||||||
color,
|
color,
|
||||||
onChange,
|
onChange,
|
||||||
@@ -245,7 +242,7 @@ export function ColorPicker({
|
|||||||
color: string | null;
|
color: string | null;
|
||||||
onChange: (color: string) => void;
|
onChange: (color: string) => void;
|
||||||
label: string;
|
label: string;
|
||||||
}) {
|
}) => {
|
||||||
const [isActive, setActive] = React.useState(false);
|
const [isActive, setActive] = React.useState(false);
|
||||||
const pickerButton = React.useRef<HTMLButtonElement>(null);
|
const pickerButton = React.useRef<HTMLButtonElement>(null);
|
||||||
|
|
||||||
@@ -255,11 +252,7 @@ export function ColorPicker({
|
|||||||
<button
|
<button
|
||||||
className="color-picker-label-swatch"
|
className="color-picker-label-swatch"
|
||||||
aria-label={label}
|
aria-label={label}
|
||||||
style={
|
style={color ? { "--swatch-color": color } : undefined}
|
||||||
color
|
|
||||||
? ({ "--swatch-color": color } as React.CSSProperties)
|
|
||||||
: undefined
|
|
||||||
}
|
|
||||||
onClick={() => setActive(!isActive)}
|
onClick={() => setActive(!isActive)}
|
||||||
ref={pickerButton}
|
ref={pickerButton}
|
||||||
/>
|
/>
|
||||||
@@ -290,10 +283,11 @@ export function ColorPicker({
|
|||||||
}}
|
}}
|
||||||
label={label}
|
label={label}
|
||||||
showInput={false}
|
showInput={false}
|
||||||
|
type={type}
|
||||||
/>
|
/>
|
||||||
</Popover>
|
</Popover>
|
||||||
) : null}
|
) : null}
|
||||||
</React.Suspense>
|
</React.Suspense>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
};
|
||||||
|
|||||||
@@ -1,36 +1,90 @@
|
|||||||
@import "open-color/open-color.scss";
|
@import "../css/_variables";
|
||||||
|
|
||||||
.context-menu {
|
.excalidraw {
|
||||||
position: relative;
|
.context-menu {
|
||||||
border-radius: 4px;
|
position: relative;
|
||||||
box-shadow: 0px 3px 10px transparentize($oc-black, 0.8);
|
border-radius: 4px;
|
||||||
padding: 0;
|
box-shadow: 0px 3px 10px transparentize($oc-black, 0.8);
|
||||||
list-style: none;
|
padding: 0;
|
||||||
user-select: none;
|
list-style: none;
|
||||||
margin: -0.25rem 0 0 0.125rem;
|
user-select: none;
|
||||||
padding: 0.25rem 0;
|
margin: -0.25rem 0 0 0.125rem;
|
||||||
background-color: $oc-gray-1;
|
padding: 0.25rem 0;
|
||||||
border: 1px solid $oc-gray-5;
|
background-color: var(--popup-secondary-background-color);
|
||||||
}
|
border: 1px solid var(--button-gray-3);
|
||||||
|
}
|
||||||
|
|
||||||
.context-menu-option {
|
.context-menu button {
|
||||||
position: relative;
|
color: var(--popup-text-color);
|
||||||
width: 100%;
|
}
|
||||||
min-width: 9.5rem;
|
|
||||||
margin: 0;
|
|
||||||
padding: 0.25rem 1rem 0.25rem 1.25rem;
|
|
||||||
text-align: start;
|
|
||||||
border-radius: 0;
|
|
||||||
background-color: transparent;
|
|
||||||
border: none;
|
|
||||||
white-space: nowrap;
|
|
||||||
}
|
|
||||||
|
|
||||||
.context-menu-option:hover {
|
.context-menu-option {
|
||||||
color: $oc-white;
|
position: relative;
|
||||||
background-color: $oc-blue-5;
|
width: 100%;
|
||||||
}
|
min-width: 9.5rem;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0.25rem 1rem 0.25rem 1.25rem;
|
||||||
|
text-align: start;
|
||||||
|
border-radius: 0;
|
||||||
|
background-color: transparent;
|
||||||
|
border: none;
|
||||||
|
white-space: nowrap;
|
||||||
|
|
||||||
.context-menu-option:focus {
|
display: grid;
|
||||||
z-index: 1;
|
grid-template-columns: 1fr 0.2fr;
|
||||||
|
align-items: center;
|
||||||
|
|
||||||
|
&.checkmark::before {
|
||||||
|
position: absolute;
|
||||||
|
left: 6px;
|
||||||
|
margin-bottom: 1px;
|
||||||
|
content: "\2713";
|
||||||
|
}
|
||||||
|
|
||||||
|
&.dangerous {
|
||||||
|
.context-menu-option__label {
|
||||||
|
color: $oc-red-7;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.context-menu-option__label {
|
||||||
|
justify-self: start;
|
||||||
|
margin-inline-end: 20px;
|
||||||
|
}
|
||||||
|
.context-menu-option__shortcut {
|
||||||
|
justify-self: end;
|
||||||
|
opacity: 0.6;
|
||||||
|
font-size: 0.7rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.context-menu-option:hover {
|
||||||
|
color: var(--popup-background-color);
|
||||||
|
background-color: var(--select-highlight-color);
|
||||||
|
|
||||||
|
&.dangerous {
|
||||||
|
.context-menu-option__label {
|
||||||
|
color: var(--popup-background-color);
|
||||||
|
}
|
||||||
|
background-color: $oc-red-6;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.context-menu-option:focus {
|
||||||
|
z-index: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media #{$is-mobile-query} {
|
||||||
|
.context-menu-option {
|
||||||
|
display: block;
|
||||||
|
|
||||||
|
.context-menu-option__label {
|
||||||
|
margin-inline-end: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.context-menu-option__shortcut {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,10 +1,17 @@
|
|||||||
import React from "react";
|
import React from "react";
|
||||||
import { Popover } from "./Popover";
|
|
||||||
import { render, unmountComponentAtNode } from "react-dom";
|
import { render, unmountComponentAtNode } from "react-dom";
|
||||||
|
import clsx from "clsx";
|
||||||
|
import { Popover } from "./Popover";
|
||||||
|
|
||||||
import "./ContextMenu.scss";
|
import "./ContextMenu.scss";
|
||||||
|
import {
|
||||||
|
getShortcutFromShortcutName,
|
||||||
|
ShortcutName,
|
||||||
|
} from "../actions/shortcuts";
|
||||||
|
|
||||||
type ContextMenuOption = {
|
type ContextMenuOption = {
|
||||||
|
checked?: boolean;
|
||||||
|
shortcutName: ShortcutName;
|
||||||
label: string;
|
label: string;
|
||||||
action(): void;
|
action(): void;
|
||||||
};
|
};
|
||||||
@@ -16,45 +23,58 @@ type Props = {
|
|||||||
left: number;
|
left: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
function ContextMenu({ options, onCloseRequest, top, left }: Props) {
|
const ContextMenu = ({ options, onCloseRequest, top, left }: Props) => {
|
||||||
|
const isDarkTheme = !!document
|
||||||
|
.querySelector(".excalidraw")
|
||||||
|
?.classList.contains("Appearance_dark");
|
||||||
return (
|
return (
|
||||||
<Popover
|
<div
|
||||||
onCloseRequest={onCloseRequest}
|
className={clsx("excalidraw", {
|
||||||
top={top}
|
"Appearance_dark Appearance_dark-background-none": isDarkTheme,
|
||||||
left={left}
|
})}
|
||||||
fitInViewport={true}
|
|
||||||
>
|
>
|
||||||
<ul
|
<Popover
|
||||||
className="context-menu"
|
onCloseRequest={onCloseRequest}
|
||||||
onContextMenu={(event) => event.preventDefault()}
|
top={top}
|
||||||
|
left={left}
|
||||||
|
fitInViewport={true}
|
||||||
>
|
>
|
||||||
{options.map((option, idx) => (
|
<ul
|
||||||
<li key={idx} onClick={onCloseRequest}>
|
className="context-menu"
|
||||||
<ContextMenuOption {...option} />
|
onContextMenu={(event) => event.preventDefault()}
|
||||||
</li>
|
>
|
||||||
))}
|
{options.map(({ action, checked, shortcutName, label }, idx) => (
|
||||||
</ul>
|
<li data-testid={shortcutName} key={idx} onClick={onCloseRequest}>
|
||||||
</Popover>
|
<button
|
||||||
|
className={`context-menu-option
|
||||||
|
${shortcutName === "delete" ? "dangerous" : ""}
|
||||||
|
${checked ? "checkmark" : ""}`}
|
||||||
|
onClick={action}
|
||||||
|
>
|
||||||
|
<div className="context-menu-option__label">{label}</div>
|
||||||
|
<div className="context-menu-option__shortcut">
|
||||||
|
{shortcutName
|
||||||
|
? getShortcutFromShortcutName(shortcutName)
|
||||||
|
: ""}
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</Popover>
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
}
|
};
|
||||||
|
|
||||||
function ContextMenuOption({ label, action }: ContextMenuOption) {
|
|
||||||
return (
|
|
||||||
<button className="context-menu-option" onClick={action}>
|
|
||||||
{label}
|
|
||||||
</button>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
let contextMenuNode: HTMLDivElement;
|
let contextMenuNode: HTMLDivElement;
|
||||||
function getContextMenuNode(): HTMLDivElement {
|
const getContextMenuNode = (): HTMLDivElement => {
|
||||||
if (contextMenuNode) {
|
if (contextMenuNode) {
|
||||||
return contextMenuNode;
|
return contextMenuNode;
|
||||||
}
|
}
|
||||||
const div = document.createElement("div");
|
const div = document.createElement("div");
|
||||||
document.body.appendChild(div);
|
document.body.appendChild(div);
|
||||||
return (contextMenuNode = div);
|
return (contextMenuNode = div);
|
||||||
}
|
};
|
||||||
|
|
||||||
type ContextMenuParams = {
|
type ContextMenuParams = {
|
||||||
options: (ContextMenuOption | false | null | undefined)[];
|
options: (ContextMenuOption | false | null | undefined)[];
|
||||||
@@ -62,9 +82,9 @@ type ContextMenuParams = {
|
|||||||
left: number;
|
left: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
function handleClose() {
|
const handleClose = () => {
|
||||||
unmountComponentAtNode(getContextMenuNode());
|
unmountComponentAtNode(getContextMenuNode());
|
||||||
}
|
};
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
push(params: ContextMenuParams) {
|
push(params: ContextMenuParams) {
|
||||||
|
|||||||
@@ -0,0 +1,58 @@
|
|||||||
|
import "./ToolIcon.scss";
|
||||||
|
|
||||||
|
import React from "react";
|
||||||
|
import { t } from "../i18n";
|
||||||
|
|
||||||
|
export type Appearence = "light" | "dark";
|
||||||
|
|
||||||
|
// We chose to use only explicit toggle and not a third option for system value,
|
||||||
|
// but this could be added in the future.
|
||||||
|
export const DarkModeToggle = (props: {
|
||||||
|
value: Appearence;
|
||||||
|
onChange: (value: Appearence) => void;
|
||||||
|
}) => {
|
||||||
|
return (
|
||||||
|
<label
|
||||||
|
className={`ToolIcon ToolIcon_type_floating ToolIcon_size_M`}
|
||||||
|
title={
|
||||||
|
props.value === "dark" ? t("buttons.lightMode") : t("buttons.darkMode")
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
className="ToolIcon_type_checkbox ToolIcon_toggle_opaque"
|
||||||
|
type="checkbox"
|
||||||
|
onChange={(event) =>
|
||||||
|
props.onChange(event.target.checked ? "dark" : "light")
|
||||||
|
}
|
||||||
|
checked={props.value === "dark"}
|
||||||
|
aria-label={
|
||||||
|
props.value === "dark"
|
||||||
|
? t("buttons.lightMode")
|
||||||
|
: t("buttons.darkMode")
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<div className="ToolIcon__icon">
|
||||||
|
{props.value === "light" ? ICONS.MOON : ICONS.SUN}
|
||||||
|
</div>
|
||||||
|
</label>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const ICONS = {
|
||||||
|
SUN: (
|
||||||
|
<svg width="512" height="512" className="rtl-mirror" viewBox="0 0 512 512">
|
||||||
|
<path
|
||||||
|
fill="currentColor"
|
||||||
|
d="M256 160c-52.9 0-96 43.1-96 96s43.1 96 96 96 96-43.1 96-96-43.1-96-96-96zm246.4 80.5l-94.7-47.3 33.5-100.4c4.5-13.6-8.4-26.5-21.9-21.9l-100.4 33.5-47.4-94.8c-6.4-12.8-24.6-12.8-31 0l-47.3 94.7L92.7 70.8c-13.6-4.5-26.5 8.4-21.9 21.9l33.5 100.4-94.7 47.4c-12.8 6.4-12.8 24.6 0 31l94.7 47.3-33.5 100.5c-4.5 13.6 8.4 26.5 21.9 21.9l100.4-33.5 47.3 94.7c6.4 12.8 24.6 12.8 31 0l47.3-94.7 100.4 33.5c13.6 4.5 26.5-8.4 21.9-21.9l-33.5-100.4 94.7-47.3c13-6.5 13-24.7.2-31.1zm-155.9 106c-49.9 49.9-131.1 49.9-181 0-49.9-49.9-49.9-131.1 0-181 49.9-49.9 131.1-49.9 181 0 49.9 49.9 49.9 131.1 0 181z"
|
||||||
|
></path>
|
||||||
|
</svg>
|
||||||
|
),
|
||||||
|
MOON: (
|
||||||
|
<svg width="512" height="512" className="rtl-mirror" viewBox="0 0 512 512">
|
||||||
|
<path
|
||||||
|
fill="currentColor"
|
||||||
|
d="M283.211 512c78.962 0 151.079-35.925 198.857-94.792 7.068-8.708-.639-21.43-11.562-19.35-124.203 23.654-238.262-71.576-238.262-196.954 0-72.222 38.662-138.635 101.498-174.394 9.686-5.512 7.25-20.197-3.756-22.23A258.156 258.156 0 0 0 283.211 0c-141.309 0-256 114.511-256 256 0 141.309 114.511 256 256 256z"
|
||||||
|
></path>
|
||||||
|
</svg>
|
||||||
|
),
|
||||||
|
};
|
||||||
+57
-49
@@ -1,60 +1,68 @@
|
|||||||
@import "../css/_variables";
|
@import "../css/_variables";
|
||||||
|
|
||||||
.Dialog__title {
|
.excalidraw {
|
||||||
display: grid;
|
|
||||||
align-items: center;
|
|
||||||
margin-top: 0;
|
|
||||||
grid-template-columns: 1fr calc(var(--space-factor) * 7);
|
|
||||||
grid-gap: var(--metric);
|
|
||||||
}
|
|
||||||
|
|
||||||
.Dialog__titleContent {
|
|
||||||
flex: 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
.Dialog .Modal__close {
|
|
||||||
margin: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
@media #{$media-query} {
|
|
||||||
.Dialog {
|
|
||||||
--metric: calc(var(--space-factor) * 4);
|
|
||||||
--inset-left: #{"max(var(--metric), var(--sal))"};
|
|
||||||
--inset-right: #{"max(var(--metric), var(--sar))"};
|
|
||||||
}
|
|
||||||
.Dialog__title {
|
.Dialog__title {
|
||||||
grid-template-columns: calc(var(--space-factor) * 7) 1fr calc(
|
display: grid;
|
||||||
var(--space-factor) * 7
|
align-items: center;
|
||||||
);
|
margin-top: 0;
|
||||||
position: sticky;
|
grid-template-columns: 1fr calc(var(--space-factor) * 7);
|
||||||
top: calc(-1 * var(--metric));
|
grid-gap: var(--metric);
|
||||||
margin: calc(-1 * var(--inset-right));
|
|
||||||
margin-top: calc(-1 * var(--metric));
|
|
||||||
margin-bottom: var(--metric);
|
|
||||||
padding: calc(var(--space-factor) * 2);
|
padding: calc(var(--space-factor) * 2);
|
||||||
padding-left: var(--inset-left);
|
|
||||||
padding-right: var(--inset-right);
|
|
||||||
background: white;
|
|
||||||
font-size: 1.25em;
|
|
||||||
|
|
||||||
box-sizing: border-box;
|
|
||||||
border-bottom: 1px solid $oc-gray-4;
|
|
||||||
z-index: 1;
|
|
||||||
}
|
|
||||||
.Dialog__titleContent {
|
|
||||||
text-align: center;
|
text-align: center;
|
||||||
|
font-variant: small-caps;
|
||||||
}
|
}
|
||||||
.Dialog .Island {
|
|
||||||
width: 100vw;
|
.Dialog__titleContent {
|
||||||
height: 100%;
|
flex: 1;
|
||||||
box-sizing: border-box;
|
|
||||||
overflow-y: auto;
|
|
||||||
padding-left: #{"max(calc(var(--padding) * var(--space-factor)), var(--sal))"};
|
|
||||||
padding-right: #{"max(calc(var(--padding) * var(--space-factor)), var(--sar))"};
|
|
||||||
padding-bottom: #{"max(calc(var(--padding) * var(--space-factor)), var(--sab))"};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.Dialog .Modal__close {
|
.Dialog .Modal__close {
|
||||||
order: -1;
|
color: var(--icon-fill-color);
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.Dialog__content {
|
||||||
|
padding: 0 16px 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media #{$is-mobile-query} {
|
||||||
|
.Dialog {
|
||||||
|
--metric: calc(var(--space-factor) * 4);
|
||||||
|
--inset-left: #{"max(var(--metric), var(--sal))"};
|
||||||
|
--inset-right: #{"max(var(--metric), var(--sar))"};
|
||||||
|
}
|
||||||
|
|
||||||
|
.Dialog__title {
|
||||||
|
grid-template-columns: calc(var(--space-factor) * 7) 1fr calc(
|
||||||
|
var(--space-factor) * 7
|
||||||
|
);
|
||||||
|
position: sticky;
|
||||||
|
top: 0;
|
||||||
|
padding: calc(var(--space-factor) * 2);
|
||||||
|
background: var(--bg-color-island);
|
||||||
|
font-size: 1.25em;
|
||||||
|
|
||||||
|
box-sizing: border-box;
|
||||||
|
border-bottom: 1px solid var(--button-gray-2);
|
||||||
|
z-index: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.Dialog__titleContent {
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.Dialog .Island {
|
||||||
|
width: 100vw;
|
||||||
|
height: 100%;
|
||||||
|
box-sizing: border-box;
|
||||||
|
overflow-y: auto;
|
||||||
|
padding-left: #{"max(calc(var(--padding) * var(--space-factor)), var(--sal))"};
|
||||||
|
padding-right: #{"max(calc(var(--padding) * var(--space-factor)), var(--sar))"};
|
||||||
|
padding-bottom: #{"max(calc(var(--padding) * var(--space-factor)), var(--sab))"};
|
||||||
|
}
|
||||||
|
|
||||||
|
.Dialog .Modal__close {
|
||||||
|
order: -1;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+42
-36
@@ -1,39 +1,46 @@
|
|||||||
import React, { useEffect, useRef } from "react";
|
import clsx from "clsx";
|
||||||
import { Modal } from "./Modal";
|
import React, { useCallback, useEffect, useState } from "react";
|
||||||
import { Island } from "./Island";
|
|
||||||
import { t } from "../i18n";
|
import { t } from "../i18n";
|
||||||
import useIsMobile from "../is-mobile";
|
import useIsMobile from "../is-mobile";
|
||||||
import { back, close } from "./icons";
|
|
||||||
import { KEYS } from "../keys";
|
import { KEYS } from "../keys";
|
||||||
|
|
||||||
import "./Dialog.scss";
|
import "./Dialog.scss";
|
||||||
|
import { back, close } from "./icons";
|
||||||
|
import { Island } from "./Island";
|
||||||
|
import { Modal } from "./Modal";
|
||||||
|
|
||||||
export function Dialog(props: {
|
const useRefState = <T,>() => {
|
||||||
|
const [refValue, setRefValue] = useState<T | null>(null);
|
||||||
|
const refCallback = useCallback((value: T) => {
|
||||||
|
setRefValue(value);
|
||||||
|
}, []);
|
||||||
|
return [refValue, refCallback] as const;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const Dialog = (props: {
|
||||||
children: React.ReactNode;
|
children: React.ReactNode;
|
||||||
className?: string;
|
className?: string;
|
||||||
maxWidth?: number;
|
small?: boolean;
|
||||||
onCloseRequest(): void;
|
onCloseRequest(): void;
|
||||||
title: React.ReactNode;
|
title: React.ReactNode;
|
||||||
}) {
|
autofocus?: boolean;
|
||||||
const islandRef = useRef<HTMLDivElement>(null);
|
}) => {
|
||||||
|
const [islandNode, setIslandNode] = useRefState<HTMLDivElement>();
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const focusableElements = queryFocusableElements();
|
if (!islandNode) {
|
||||||
|
|
||||||
if (focusableElements.length > 0) {
|
|
||||||
// If there's an element other than close, focus it.
|
|
||||||
(focusableElements[1] || focusableElements[0]).focus();
|
|
||||||
}
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!islandRef.current) {
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleKeyDown(event: KeyboardEvent) {
|
const focusableElements = queryFocusableElements(islandNode);
|
||||||
|
|
||||||
|
if (focusableElements.length > 0 && props.autofocus !== false) {
|
||||||
|
// If there's an element other than close, focus it.
|
||||||
|
(focusableElements[1] || focusableElements[0]).focus();
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleKeyDown = (event: KeyboardEvent) => {
|
||||||
if (event.key === KEYS.TAB) {
|
if (event.key === KEYS.TAB) {
|
||||||
const focusableElements = queryFocusableElements();
|
const focusableElements = queryFocusableElements(islandNode);
|
||||||
const { activeElement } = document;
|
const { activeElement } = document;
|
||||||
const currentIndex = focusableElements.findIndex(
|
const currentIndex = focusableElements.findIndex(
|
||||||
(element) => element === activeElement,
|
(element) => element === activeElement,
|
||||||
@@ -50,31 +57,30 @@ export function Dialog(props: {
|
|||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
};
|
||||||
|
|
||||||
const node = islandRef.current;
|
islandNode.addEventListener("keydown", handleKeyDown);
|
||||||
node.addEventListener("keydown", handleKeyDown);
|
|
||||||
|
|
||||||
return () => node.removeEventListener("keydown", handleKeyDown);
|
return () => islandNode.removeEventListener("keydown", handleKeyDown);
|
||||||
}, []);
|
}, [islandNode, props.autofocus]);
|
||||||
|
|
||||||
function queryFocusableElements() {
|
const queryFocusableElements = (node: HTMLElement) => {
|
||||||
const focusableElements = islandRef.current?.querySelectorAll<HTMLElement>(
|
const focusableElements = node.querySelectorAll<HTMLElement>(
|
||||||
"button, a, input, select, textarea, div[tabindex]",
|
"button, a, input, select, textarea, div[tabindex]",
|
||||||
);
|
);
|
||||||
|
|
||||||
return focusableElements ? Array.from(focusableElements) : [];
|
return focusableElements ? Array.from(focusableElements) : [];
|
||||||
}
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Modal
|
<Modal
|
||||||
className={`${props.className ?? ""} Dialog`}
|
className={clsx("Dialog", props.className)}
|
||||||
labelledBy="dialog-title"
|
labelledBy="dialog-title"
|
||||||
maxWidth={props.maxWidth}
|
maxWidth={props.small ? 550 : 800}
|
||||||
onCloseRequest={props.onCloseRequest}
|
onCloseRequest={props.onCloseRequest}
|
||||||
>
|
>
|
||||||
<Island padding={4} ref={islandRef}>
|
<Island ref={setIslandNode}>
|
||||||
<h2 id="dialog-title" className="Dialog__title">
|
<h3 id="dialog-title" className="Dialog__title">
|
||||||
<span className="Dialog__titleContent">{props.title}</span>
|
<span className="Dialog__titleContent">{props.title}</span>
|
||||||
<button
|
<button
|
||||||
className="Modal__close"
|
className="Modal__close"
|
||||||
@@ -83,9 +89,9 @@ export function Dialog(props: {
|
|||||||
>
|
>
|
||||||
{useIsMobile() ? back : close}
|
{useIsMobile() ? back : close}
|
||||||
</button>
|
</button>
|
||||||
</h2>
|
</h3>
|
||||||
{props.children}
|
<div className="Dialog__content">{props.children}</div>
|
||||||
</Island>
|
</Island>
|
||||||
</Modal>
|
</Modal>
|
||||||
);
|
);
|
||||||
}
|
};
|
||||||
|
|||||||
@@ -3,13 +3,13 @@ import { t } from "../i18n";
|
|||||||
|
|
||||||
import { Dialog } from "./Dialog";
|
import { Dialog } from "./Dialog";
|
||||||
|
|
||||||
export function ErrorDialog({
|
export const ErrorDialog = ({
|
||||||
message,
|
message,
|
||||||
onClose,
|
onClose,
|
||||||
}: {
|
}: {
|
||||||
message: string;
|
message: string;
|
||||||
onClose?: () => void;
|
onClose?: () => void;
|
||||||
}) {
|
}) => {
|
||||||
const [modalIsShown, setModalIsShown] = useState(!!message);
|
const [modalIsShown, setModalIsShown] = useState(!!message);
|
||||||
|
|
||||||
const handleClose = React.useCallback(() => {
|
const handleClose = React.useCallback(() => {
|
||||||
@@ -24,13 +24,20 @@ export function ErrorDialog({
|
|||||||
<>
|
<>
|
||||||
{modalIsShown && (
|
{modalIsShown && (
|
||||||
<Dialog
|
<Dialog
|
||||||
maxWidth={500}
|
small
|
||||||
onCloseRequest={handleClose}
|
onCloseRequest={handleClose}
|
||||||
title={t("errorDialog.title")}
|
title={t("errorDialog.title")}
|
||||||
>
|
>
|
||||||
<div>{message}</div>
|
<div>
|
||||||
|
{message.split("\n").map((line) => (
|
||||||
|
<>
|
||||||
|
{line}
|
||||||
|
<br />
|
||||||
|
</>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
};
|
||||||
|
|||||||
@@ -1,57 +1,69 @@
|
|||||||
@import "../css/_variables";
|
@import "../css/_variables";
|
||||||
|
|
||||||
.ExportDialog__preview {
|
.excalidraw {
|
||||||
--preview-padding: calc(var(--space-factor) * 4);
|
.ExportDialog__preview {
|
||||||
|
--preview-padding: calc(var(--space-factor) * 4);
|
||||||
|
|
||||||
background: url("data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABAAAAAQCAYAAAAf8/9hAAAAMUlEQVQ4T2NkYGAQYcAP3uCTZhw1gGGYhAGBZIA/nYDCgBDAm9BGDWAAJyRCgLaBCAAgXwixzAS0pgAAAABJRU5ErkJggg==")
|
background: url("data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABAAAAAQCAYAAAAf8/9hAAAAMUlEQVQ4T2NkYGAQYcAP3uCTZhw1gGGYhAGBZIA/nYDCgBDAm9BGDWAAJyRCgLaBCAAgXwixzAS0pgAAAABJRU5ErkJggg==")
|
||||||
left center;
|
left center;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
padding: var(--preview-padding);
|
padding: var(--preview-padding);
|
||||||
margin-bottom: calc(var(--space-factor) * 3);
|
|
||||||
}
|
|
||||||
|
|
||||||
.ExportDialog__preview canvas {
|
|
||||||
max-width: calc(100% - var(--preview-padding) * 2);
|
|
||||||
max-height: 25rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.ExportDialog__actions {
|
|
||||||
width: 100%;
|
|
||||||
display: flex;
|
|
||||||
grid-gap: calc(var(--space-factor) * 2);
|
|
||||||
align-items: top;
|
|
||||||
justify-content: space-between;
|
|
||||||
}
|
|
||||||
|
|
||||||
.ExportDialog__name {
|
|
||||||
grid-column: project-name;
|
|
||||||
margin: auto;
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (max-width: 550px) {
|
|
||||||
.ExportDialog {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
}
|
|
||||||
.ExportDialog__actions {
|
|
||||||
flex-direction: column;
|
|
||||||
align-items: center;
|
|
||||||
}
|
|
||||||
.ExportDialog__actions > * {
|
|
||||||
margin-bottom: calc(var(--space-factor) * 3);
|
margin-bottom: calc(var(--space-factor) * 3);
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
@media #{$media-query} {
|
|
||||||
.ExportDialog__preview canvas {
|
.ExportDialog__preview canvas {
|
||||||
max-height: 30vh;
|
max-width: calc(100% - var(--preview-padding) * 2);
|
||||||
|
max-height: 25rem;
|
||||||
}
|
}
|
||||||
.ExportDialog__dialog,
|
|
||||||
.ExportDialog__dialog .Island {
|
&.Appearance_dark .ExportDialog__preview canvas {
|
||||||
height: 100%;
|
filter: none;
|
||||||
box-sizing: border-box;
|
|
||||||
}
|
}
|
||||||
.ExportDialog__dialog .Island {
|
|
||||||
overflow-y: auto;
|
.ExportDialog__actions {
|
||||||
|
width: 100%;
|
||||||
|
display: flex;
|
||||||
|
grid-gap: calc(var(--space-factor) * 2);
|
||||||
|
align-items: top;
|
||||||
|
justify-content: space-between;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ExportDialog__name {
|
||||||
|
grid-column: project-name;
|
||||||
|
margin: auto;
|
||||||
|
|
||||||
|
.TextInput {
|
||||||
|
height: calc(1rem - 3px);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media #{$is-mobile-query} {
|
||||||
|
.ExportDialog {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ExportDialog__actions {
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ExportDialog__actions > * {
|
||||||
|
margin-bottom: calc(var(--space-factor) * 3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ExportDialog__preview canvas {
|
||||||
|
max-height: 30vh;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ExportDialog__dialog,
|
||||||
|
.ExportDialog__dialog .Island {
|
||||||
|
height: 100%;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ExportDialog__dialog .Island {
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+116
-59
@@ -1,30 +1,56 @@
|
|||||||
import "./ExportDialog.scss";
|
import React, { useEffect, useRef, useState } from "react";
|
||||||
|
import { render, unmountComponentAtNode } from "react-dom";
|
||||||
import React, { useState, useEffect, useRef } from "react";
|
|
||||||
|
|
||||||
import { ToolButton } from "./ToolButton";
|
|
||||||
import { clipboard, exportFile, link } from "./icons";
|
|
||||||
import { NonDeletedExcalidrawElement } from "../element/types";
|
|
||||||
import { AppState } from "../types";
|
|
||||||
import { exportToCanvas } from "../scene/export";
|
|
||||||
import { ActionsManagerInterface } from "../actions/types";
|
import { ActionsManagerInterface } from "../actions/types";
|
||||||
import Stack from "./Stack";
|
import { EVENT_DIALOG, trackEvent } from "../analytics";
|
||||||
import { t } from "../i18n";
|
|
||||||
|
|
||||||
import { probablySupportsClipboardBlob } from "../clipboard";
|
import { probablySupportsClipboardBlob } from "../clipboard";
|
||||||
import { getSelectedElements, isSomeElementSelected } from "../scene";
|
import { canvasToBlob } from "../data/blob";
|
||||||
|
import { NonDeletedExcalidrawElement } from "../element/types";
|
||||||
|
import { CanvasError } from "../errors";
|
||||||
|
import { t } from "../i18n";
|
||||||
import useIsMobile from "../is-mobile";
|
import useIsMobile from "../is-mobile";
|
||||||
|
import { getSelectedElements, isSomeElementSelected } from "../scene";
|
||||||
|
import { exportToCanvas, getExportSize } from "../scene/export";
|
||||||
|
import { AppState } from "../types";
|
||||||
import { Dialog } from "./Dialog";
|
import { Dialog } from "./Dialog";
|
||||||
|
import "./ExportDialog.scss";
|
||||||
|
import { clipboard, exportFile, link } from "./icons";
|
||||||
|
import Stack from "./Stack";
|
||||||
|
import { ToolButton } from "./ToolButton";
|
||||||
|
|
||||||
const scales = [1, 2, 3];
|
const scales = [1, 2, 3];
|
||||||
const defaultScale = scales.includes(devicePixelRatio) ? devicePixelRatio : 1;
|
const defaultScale = scales.includes(devicePixelRatio) ? devicePixelRatio : 1;
|
||||||
|
|
||||||
|
export const ErrorCanvasPreview = () => {
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<h3>{t("canvasError.cannotShowPreview")}</h3>
|
||||||
|
<p>
|
||||||
|
<span>{t("canvasError.canvasTooBig")}</span>
|
||||||
|
</p>
|
||||||
|
<em>({t("canvasError.canvasTooBigTip")})</em>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const renderPreview = (
|
||||||
|
content: HTMLCanvasElement | Error,
|
||||||
|
previewNode: HTMLDivElement,
|
||||||
|
) => {
|
||||||
|
unmountComponentAtNode(previewNode);
|
||||||
|
previewNode.innerHTML = "";
|
||||||
|
if (content instanceof HTMLCanvasElement) {
|
||||||
|
previewNode.appendChild(content);
|
||||||
|
} else {
|
||||||
|
render(<ErrorCanvasPreview />, previewNode);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
export type ExportCB = (
|
export type ExportCB = (
|
||||||
elements: readonly NonDeletedExcalidrawElement[],
|
elements: readonly NonDeletedExcalidrawElement[],
|
||||||
scale?: number,
|
scale?: number,
|
||||||
) => void;
|
) => void;
|
||||||
|
|
||||||
function ExportModal({
|
const ExportModal = ({
|
||||||
elements,
|
elements,
|
||||||
appState,
|
appState,
|
||||||
exportPadding = 10,
|
exportPadding = 10,
|
||||||
@@ -41,9 +67,9 @@ function ExportModal({
|
|||||||
onExportToPng: ExportCB;
|
onExportToPng: ExportCB;
|
||||||
onExportToSvg: ExportCB;
|
onExportToSvg: ExportCB;
|
||||||
onExportToClipboard: ExportCB;
|
onExportToClipboard: ExportCB;
|
||||||
onExportToBackend: ExportCB;
|
onExportToBackend?: ExportCB;
|
||||||
onCloseRequest: () => void;
|
onCloseRequest: () => void;
|
||||||
}) {
|
}) => {
|
||||||
const someElementIsSelected = isSomeElementSelected(elements, appState);
|
const someElementIsSelected = isSomeElementSelected(elements, appState);
|
||||||
const [scale, setScale] = useState(defaultScale);
|
const [scale, setScale] = useState(defaultScale);
|
||||||
const [exportSelected, setExportSelected] = useState(someElementIsSelected);
|
const [exportSelected, setExportSelected] = useState(someElementIsSelected);
|
||||||
@@ -64,17 +90,32 @@ function ExportModal({
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const previewNode = previewRef.current;
|
const previewNode = previewRef.current;
|
||||||
const canvas = exportToCanvas(exportedElements, appState, {
|
if (!previewNode) {
|
||||||
exportBackground,
|
return;
|
||||||
viewBackgroundColor,
|
}
|
||||||
exportPadding,
|
try {
|
||||||
scale,
|
const canvas = exportToCanvas(exportedElements, appState, {
|
||||||
shouldAddWatermark,
|
exportBackground,
|
||||||
});
|
viewBackgroundColor,
|
||||||
previewNode?.appendChild(canvas);
|
exportPadding,
|
||||||
return () => {
|
scale,
|
||||||
previewNode?.removeChild(canvas);
|
shouldAddWatermark,
|
||||||
};
|
});
|
||||||
|
|
||||||
|
// if converting to blob fails, there's some problem that will
|
||||||
|
// likely prevent preview and export (e.g. canvas too big)
|
||||||
|
canvasToBlob(canvas)
|
||||||
|
.then(() => {
|
||||||
|
renderPreview(canvas, previewNode);
|
||||||
|
})
|
||||||
|
.catch((error) => {
|
||||||
|
console.error(error);
|
||||||
|
renderPreview(new CanvasError(), previewNode);
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error(error);
|
||||||
|
renderPreview(new CanvasError(), previewNode);
|
||||||
|
}
|
||||||
}, [
|
}, [
|
||||||
appState,
|
appState,
|
||||||
exportedElements,
|
exportedElements,
|
||||||
@@ -87,7 +128,7 @@ function ExportModal({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="ExportDialog">
|
<div className="ExportDialog">
|
||||||
<div className="ExportDialog__preview" ref={previewRef}></div>
|
<div className="ExportDialog__preview" ref={previewRef} />
|
||||||
<Stack.Col gap={2} align="center">
|
<Stack.Col gap={2} align="center">
|
||||||
<div className="ExportDialog__actions">
|
<div className="ExportDialog__actions">
|
||||||
<Stack.Row gap={2}>
|
<Stack.Row gap={2}>
|
||||||
@@ -114,31 +155,47 @@ function ExportModal({
|
|||||||
onClick={() => onExportToClipboard(exportedElements, scale)}
|
onClick={() => onExportToClipboard(exportedElements, scale)}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
<ToolButton
|
{onExportToBackend && (
|
||||||
type="button"
|
<ToolButton
|
||||||
icon={link}
|
type="button"
|
||||||
title={t("buttons.getShareableLink")}
|
icon={link}
|
||||||
aria-label={t("buttons.getShareableLink")}
|
title={t("buttons.getShareableLink")}
|
||||||
onClick={() => onExportToBackend(exportedElements)}
|
aria-label={t("buttons.getShareableLink")}
|
||||||
/>
|
onClick={() => onExportToBackend(exportedElements)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</Stack.Row>
|
</Stack.Row>
|
||||||
<div className="ExportDialog__name">
|
<div className="ExportDialog__name">
|
||||||
{actionManager.renderAction("changeProjectName")}
|
{actionManager.renderAction("changeProjectName")}
|
||||||
</div>
|
</div>
|
||||||
<Stack.Row gap={2}>
|
<Stack.Row gap={2}>
|
||||||
{scales.map((s) => (
|
{scales.map((s) => {
|
||||||
<ToolButton
|
const [width, height] = getExportSize(
|
||||||
key={s}
|
exportedElements,
|
||||||
size="s"
|
exportPadding,
|
||||||
type="radio"
|
shouldAddWatermark,
|
||||||
icon={`x${s}`}
|
s,
|
||||||
name="export-canvas-scale"
|
);
|
||||||
aria-label={`Scale ${s} x`}
|
|
||||||
id="export-canvas-scale"
|
const scaleButtonTitle = `${t(
|
||||||
checked={s === scale}
|
"buttons.scale",
|
||||||
onChange={() => setScale(s)}
|
)} ${s}x (${width}x${height})`;
|
||||||
/>
|
|
||||||
))}
|
return (
|
||||||
|
<ToolButton
|
||||||
|
key={s}
|
||||||
|
size="s"
|
||||||
|
type="radio"
|
||||||
|
icon={`${s}x`}
|
||||||
|
name="export-canvas-scale"
|
||||||
|
title={scaleButtonTitle}
|
||||||
|
aria-label={scaleButtonTitle}
|
||||||
|
id="export-canvas-scale"
|
||||||
|
checked={s === scale}
|
||||||
|
onChange={() => setScale(s)}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
})}
|
||||||
</Stack.Row>
|
</Stack.Row>
|
||||||
</div>
|
</div>
|
||||||
{actionManager.renderAction("changeExportBackground")}
|
{actionManager.renderAction("changeExportBackground")}
|
||||||
@@ -156,13 +213,14 @@ function ExportModal({
|
|||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
{actionManager.renderAction("changeExportEmbedScene")}
|
||||||
{actionManager.renderAction("changeShouldAddWatermark")}
|
{actionManager.renderAction("changeShouldAddWatermark")}
|
||||||
</Stack.Col>
|
</Stack.Col>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
};
|
||||||
|
|
||||||
export function ExportDialog({
|
export const ExportDialog = ({
|
||||||
elements,
|
elements,
|
||||||
appState,
|
appState,
|
||||||
exportPadding = 10,
|
exportPadding = 10,
|
||||||
@@ -179,8 +237,8 @@ export function ExportDialog({
|
|||||||
onExportToPng: ExportCB;
|
onExportToPng: ExportCB;
|
||||||
onExportToSvg: ExportCB;
|
onExportToSvg: ExportCB;
|
||||||
onExportToClipboard: ExportCB;
|
onExportToClipboard: ExportCB;
|
||||||
onExportToBackend: ExportCB;
|
onExportToBackend?: ExportCB;
|
||||||
}) {
|
}) => {
|
||||||
const [modalIsShown, setModalIsShown] = useState(false);
|
const [modalIsShown, setModalIsShown] = useState(false);
|
||||||
const triggerButton = useRef<HTMLButtonElement>(null);
|
const triggerButton = useRef<HTMLButtonElement>(null);
|
||||||
|
|
||||||
@@ -192,7 +250,10 @@ export function ExportDialog({
|
|||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<ToolButton
|
<ToolButton
|
||||||
onClick={() => setModalIsShown(true)}
|
onClick={() => {
|
||||||
|
trackEvent(EVENT_DIALOG, "export");
|
||||||
|
setModalIsShown(true);
|
||||||
|
}}
|
||||||
icon={exportFile}
|
icon={exportFile}
|
||||||
type="button"
|
type="button"
|
||||||
aria-label={t("buttons.export")}
|
aria-label={t("buttons.export")}
|
||||||
@@ -201,11 +262,7 @@ export function ExportDialog({
|
|||||||
ref={triggerButton}
|
ref={triggerButton}
|
||||||
/>
|
/>
|
||||||
{modalIsShown && (
|
{modalIsShown && (
|
||||||
<Dialog
|
<Dialog onCloseRequest={handleClose} title={t("buttons.export")}>
|
||||||
maxWidth={800}
|
|
||||||
onCloseRequest={handleClose}
|
|
||||||
title={t("buttons.export")}
|
|
||||||
>
|
|
||||||
<ExportModal
|
<ExportModal
|
||||||
elements={elements}
|
elements={elements}
|
||||||
appState={appState}
|
appState={appState}
|
||||||
@@ -221,4 +278,4 @@ export function ExportDialog({
|
|||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
};
|
||||||
|
|||||||
@@ -1,36 +0,0 @@
|
|||||||
.FixedSideContainer {
|
|
||||||
--margin: 0.25rem;
|
|
||||||
position: fixed;
|
|
||||||
pointer-events: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.FixedSideContainer > * {
|
|
||||||
pointer-events: all;
|
|
||||||
}
|
|
||||||
|
|
||||||
.FixedSideContainer_side_top {
|
|
||||||
left: var(--margin);
|
|
||||||
top: var(--margin);
|
|
||||||
right: var(--margin);
|
|
||||||
z-index: 2;
|
|
||||||
}
|
|
||||||
|
|
||||||
.FixedSideContainer_side_top.zen-mode {
|
|
||||||
right: 42px;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* TODO: if these are used, make sure to implement RTL support
|
|
||||||
.FixedSideContainer_side_left {
|
|
||||||
left: var(--margin);
|
|
||||||
top: var(--margin);
|
|
||||||
bottom: var(--margin);
|
|
||||||
z-index: 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
.FixedSideContainer_side_right {
|
|
||||||
right: var(--margin);
|
|
||||||
top: var(--margin);
|
|
||||||
bottom: var(--margin);
|
|
||||||
z-index: 3;
|
|
||||||
}
|
|
||||||
*/
|
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
.excalidraw {
|
||||||
|
.FixedSideContainer {
|
||||||
|
--margin: 0.25rem;
|
||||||
|
position: absolute;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.FixedSideContainer > * {
|
||||||
|
pointer-events: all;
|
||||||
|
}
|
||||||
|
|
||||||
|
.FixedSideContainer_side_top {
|
||||||
|
left: var(--margin);
|
||||||
|
top: var(--margin);
|
||||||
|
right: var(--margin);
|
||||||
|
z-index: 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.FixedSideContainer_side_top.zen-mode {
|
||||||
|
right: 42px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* TODO: if these are used, make sure to implement RTL support
|
||||||
|
.FixedSideContainer_side_left {
|
||||||
|
left: var(--margin);
|
||||||
|
top: var(--margin);
|
||||||
|
bottom: var(--margin);
|
||||||
|
z-index: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.FixedSideContainer_side_right {
|
||||||
|
right: var(--margin);
|
||||||
|
top: var(--margin);
|
||||||
|
bottom: var(--margin);
|
||||||
|
z-index: 3;
|
||||||
|
}
|
||||||
|
*/
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
import "./FixedSideContainer.css";
|
import "./FixedSideContainer.scss";
|
||||||
|
|
||||||
import React from "react";
|
import React from "react";
|
||||||
|
import clsx from "clsx";
|
||||||
|
|
||||||
type FixedSideContainerProps = {
|
type FixedSideContainerProps = {
|
||||||
children: React.ReactNode;
|
children: React.ReactNode;
|
||||||
@@ -8,16 +9,18 @@ type FixedSideContainerProps = {
|
|||||||
className?: string;
|
className?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
export function FixedSideContainer({
|
export const FixedSideContainer = ({
|
||||||
children,
|
children,
|
||||||
side,
|
side,
|
||||||
className,
|
className,
|
||||||
}: FixedSideContainerProps) {
|
}: FixedSideContainerProps) => (
|
||||||
return (
|
<div
|
||||||
<div
|
className={clsx(
|
||||||
className={`FixedSideContainer FixedSideContainer_side_${side} ${className}`}
|
"FixedSideContainer",
|
||||||
>
|
`FixedSideContainer_side_${side}`,
|
||||||
{children}
|
className,
|
||||||
</div>
|
)}
|
||||||
);
|
>
|
||||||
}
|
{children}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|||||||
@@ -1,33 +1,42 @@
|
|||||||
import React from "react";
|
import React from "react";
|
||||||
import oc from "open-color";
|
import oc from "open-color";
|
||||||
|
import { EVENT_EXIT, trackEvent } from "../analytics";
|
||||||
|
|
||||||
// https://github.com/tholman/github-corners
|
// https://github.com/tholman/github-corners
|
||||||
export const GitHubCorner = React.memo(() => (
|
export const GitHubCorner = React.memo(
|
||||||
<svg
|
({ appearance }: { appearance: "light" | "dark" }) => (
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
<svg
|
||||||
width="40"
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
height="40"
|
width="40"
|
||||||
viewBox="0 0 250 250"
|
height="40"
|
||||||
className="github-corner rtl-mirror"
|
viewBox="0 0 250 250"
|
||||||
>
|
className="github-corner rtl-mirror"
|
||||||
<a
|
|
||||||
href="https://github.com/excalidraw/excalidraw"
|
|
||||||
target="_blank"
|
|
||||||
rel="noopener noreferrer"
|
|
||||||
aria-label="GitHub repository"
|
|
||||||
>
|
>
|
||||||
<path d="M0 0l115 115h15l12 27 108 108V0z" fill={oc.gray[6]} />
|
<a
|
||||||
<path
|
href="https://github.com/excalidraw/excalidraw"
|
||||||
className="octo-arm"
|
target="_blank"
|
||||||
d="M128 109c-15-9-9-19-9-19 3-7 2-11 2-11-1-7 3-2 3-2 4 5 2 11 2 11-3 10 5 15 9 16"
|
rel="noopener noreferrer"
|
||||||
style={{ transformOrigin: "130px 106px" }}
|
aria-label="GitHub repository"
|
||||||
fill={oc.white}
|
onClick={() => {
|
||||||
/>
|
trackEvent(EVENT_EXIT, "github");
|
||||||
<path
|
}}
|
||||||
className="octo-body"
|
>
|
||||||
d="M115 115s4 2 5 0l14-14c3-2 6-3 8-3-8-11-15-24 2-41 5-5 10-7 16-7 1-2 3-7 12-11 0 0 5 3 7 16 4 2 8 5 12 9s7 8 9 12c14 3 17 7 17 7-4 8-9 11-11 11 0 6-2 11-7 16-16 16-30 10-41 2 0 3-1 7-5 11l-12 11c-1 1 1 5 1 5z"
|
<path
|
||||||
fill={oc.white}
|
d="M0 0l115 115h15l12 27 108 108V0z"
|
||||||
/>
|
fill={appearance === "light" ? oc.gray[6] : oc.gray[8]}
|
||||||
</a>
|
/>
|
||||||
</svg>
|
<path
|
||||||
));
|
className="octo-arm"
|
||||||
|
d="M128 109c-15-9-9-19-9-19 3-7 2-11 2-11-1-7 3-2 3-2 4 5 2 11 2 11-3 10 5 15 9 16"
|
||||||
|
style={{ transformOrigin: "130px 106px" }}
|
||||||
|
fill={appearance === "light" ? oc.white : oc.black}
|
||||||
|
/>
|
||||||
|
<path
|
||||||
|
className="octo-body"
|
||||||
|
d="M115 115s4 2 5 0l14-14c3-2 6-3 8-3-8-11-15-24 2-41 5-5 10-7 16-7 1-2 3-7 12-11 0 0 5 3 7 16 4 2 8 5 12 9s7 8 9 12c14 3 17 7 17 7-4 8-9 11-11 11 0 6-2 11-7 16-16 16-30 10-41 2 0 3-1 7-5 11l-12 11c-1 1 1 5 1 5z"
|
||||||
|
fill={appearance === "light" ? oc.white : oc.black}
|
||||||
|
/>
|
||||||
|
</a>
|
||||||
|
</svg>
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|||||||
@@ -18,10 +18,8 @@ const ICON = (
|
|||||||
</svg>
|
</svg>
|
||||||
);
|
);
|
||||||
|
|
||||||
export function HelpIcon(props: HelpIconProps) {
|
export const HelpIcon = (props: HelpIconProps) => (
|
||||||
return (
|
<label title={`${props.title} — ?`} className="help-icon">
|
||||||
<label title={`${props.title} — ?`} className="help-icon">
|
<div onClick={props.onClick}>{ICON}</div>
|
||||||
<div onClick={props.onClick}>{ICON}</div>
|
</label>
|
||||||
</label>
|
);
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,24 +1,33 @@
|
|||||||
@import "../css/_variables";
|
@import "../css/_variables";
|
||||||
|
|
||||||
.HintViewer {
|
// this is loosely based on the longest hint text
|
||||||
color: $oc-gray-6;
|
$wide-viewport-width: 1000px;
|
||||||
font-size: 0.8rem;
|
|
||||||
left: 50%;
|
|
||||||
pointer-events: none;
|
|
||||||
position: absolute;
|
|
||||||
top: 54px;
|
|
||||||
transform: translateX(calc(-50% - 16px)); /* 16px is half of lock icon */
|
|
||||||
white-space: pre;
|
|
||||||
text-align: center;
|
|
||||||
@media #{$media-query} {
|
|
||||||
position: static;
|
|
||||||
transform: none;
|
|
||||||
margin-top: 0.5rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
> span {
|
.excalidraw {
|
||||||
background-color: transparentize($oc-white, 0.12);
|
.HintViewer {
|
||||||
padding: 0.2rem 0.4rem;
|
pointer-events: none;
|
||||||
border-radius: 3px;
|
box-sizing: border-box;
|
||||||
|
position: absolute;
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
left: 0;
|
||||||
|
top: 100%;
|
||||||
|
max-width: 100%;
|
||||||
|
width: 100%;
|
||||||
|
margin-top: 6px;
|
||||||
|
text-align: center;
|
||||||
|
color: $oc-gray-6;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
|
||||||
|
@media #{$is-mobile-query} {
|
||||||
|
position: static;
|
||||||
|
padding-right: 2em;
|
||||||
|
}
|
||||||
|
|
||||||
|
> span {
|
||||||
|
padding: 0.2rem 0.4rem;
|
||||||
|
background-color: var(--overlay-background-color);
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import { getSelectedElements } from "../scene";
|
|||||||
import "./HintViewer.scss";
|
import "./HintViewer.scss";
|
||||||
import { AppState } from "../types";
|
import { AppState } from "../types";
|
||||||
import { isLinearElement } from "../element/typeChecks";
|
import { isLinearElement } from "../element/typeChecks";
|
||||||
|
import { getShortcutKey } from "../utils";
|
||||||
|
|
||||||
interface Hint {
|
interface Hint {
|
||||||
appState: AppState;
|
appState: AppState;
|
||||||
@@ -26,6 +27,10 @@ const getHints = ({ appState, elements }: Hint) => {
|
|||||||
return t("hints.freeDraw");
|
return t("hints.freeDraw");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (elementType === "text") {
|
||||||
|
return t("hints.text");
|
||||||
|
}
|
||||||
|
|
||||||
const selectedElements = getSelectedElements(elements, appState);
|
const selectedElements = getSelectedElements(elements, appState);
|
||||||
if (
|
if (
|
||||||
isResizing &&
|
isResizing &&
|
||||||
@@ -33,8 +38,8 @@ const getHints = ({ appState, elements }: Hint) => {
|
|||||||
selectedElements.length === 1
|
selectedElements.length === 1
|
||||||
) {
|
) {
|
||||||
const targetElement = selectedElements[0];
|
const targetElement = selectedElements[0];
|
||||||
if (isLinearElement(targetElement) && targetElement.points.length > 2) {
|
if (isLinearElement(targetElement) && targetElement.points.length === 2) {
|
||||||
return null;
|
return t("hints.lockAngle");
|
||||||
}
|
}
|
||||||
return t("hints.resize");
|
return t("hints.resize");
|
||||||
}
|
}
|
||||||
@@ -43,11 +48,20 @@ const getHints = ({ appState, elements }: Hint) => {
|
|||||||
return t("hints.rotate");
|
return t("hints.rotate");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (selectedElements.length === 1 && isLinearElement(selectedElements[0])) {
|
||||||
|
if (appState.editingLinearElement) {
|
||||||
|
return appState.editingLinearElement.activePointIndex
|
||||||
|
? t("hints.lineEditor_pointSelected")
|
||||||
|
: t("hints.lineEditor_nothingSelected");
|
||||||
|
}
|
||||||
|
return t("hints.lineEditor_info");
|
||||||
|
}
|
||||||
|
|
||||||
return null;
|
return null;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const HintViewer = ({ appState, elements }: Hint) => {
|
export const HintViewer = ({ appState, elements }: Hint) => {
|
||||||
const hint = getHints({
|
let hint = getHints({
|
||||||
appState,
|
appState,
|
||||||
elements,
|
elements,
|
||||||
});
|
});
|
||||||
@@ -55,6 +69,8 @@ export const HintViewer = ({ appState, elements }: Hint) => {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
hint = getShortcutKey(hint);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="HintViewer">
|
<div className="HintViewer">
|
||||||
<span>{hint}</span>
|
<span>{hint}</span>
|
||||||
|
|||||||
@@ -0,0 +1,142 @@
|
|||||||
|
@import "../css/_variables";
|
||||||
|
|
||||||
|
.excalidraw {
|
||||||
|
.picker-container {
|
||||||
|
display: inline-block;
|
||||||
|
box-sizing: border-box;
|
||||||
|
margin-right: 0.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.picker {
|
||||||
|
background: var(--popup-background-color);
|
||||||
|
border: 0px solid transparentize($oc-white, 0.75);
|
||||||
|
box-shadow: transparentize($oc-black, 0.75) 0px 1px 4px;
|
||||||
|
border-radius: 4px;
|
||||||
|
position: absolute;
|
||||||
|
}
|
||||||
|
|
||||||
|
.picker-container button,
|
||||||
|
.picker button {
|
||||||
|
position: relative;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
|
||||||
|
&:focus {
|
||||||
|
outline: transparent;
|
||||||
|
background-color: var(--button-gray-2);
|
||||||
|
& svg {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background-color: var(--button-gray-2);
|
||||||
|
}
|
||||||
|
|
||||||
|
&:active {
|
||||||
|
background-color: var(--button-gray-3);
|
||||||
|
}
|
||||||
|
|
||||||
|
&:disabled {
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
svg {
|
||||||
|
margin: 0;
|
||||||
|
width: 36px;
|
||||||
|
height: 18px;
|
||||||
|
opacity: 0.6;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.picker button {
|
||||||
|
padding: 0.25rem 0.28rem 0.35rem 0.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.picker-triangle {
|
||||||
|
width: 0px;
|
||||||
|
height: 0px;
|
||||||
|
position: relative;
|
||||||
|
top: -10px;
|
||||||
|
:root[dir="ltr"] & {
|
||||||
|
left: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
:root[dir="rtl"] & {
|
||||||
|
right: 12px;
|
||||||
|
}
|
||||||
|
z-index: 10;
|
||||||
|
|
||||||
|
&:before {
|
||||||
|
content: "";
|
||||||
|
position: absolute;
|
||||||
|
border-style: solid;
|
||||||
|
border-width: 0px 9px 10px;
|
||||||
|
border-color: transparent transparent transparentize($oc-black, 0.9);
|
||||||
|
top: -1px;
|
||||||
|
}
|
||||||
|
|
||||||
|
&:after {
|
||||||
|
content: "";
|
||||||
|
position: absolute;
|
||||||
|
border-style: solid;
|
||||||
|
border-width: 0px 9px 10px;
|
||||||
|
border-color: transparent transparent var(--popup-background-color);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.picker-content {
|
||||||
|
padding: 0.5rem;
|
||||||
|
display: grid;
|
||||||
|
grid-auto-flow: column;
|
||||||
|
grid-gap: 0.5rem;
|
||||||
|
border-radius: 4px;
|
||||||
|
:root[dir="rtl"] & {
|
||||||
|
padding: 0.4rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.picker-keybinding {
|
||||||
|
position: absolute;
|
||||||
|
bottom: 2px;
|
||||||
|
font-size: 0.7em;
|
||||||
|
|
||||||
|
:root[dir="ltr"] & {
|
||||||
|
right: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
:root[dir="rtl"] & {
|
||||||
|
left: 2px;
|
||||||
|
}
|
||||||
|
@media #{$is-mobile-query} {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.picker-type-canvasBackground .picker-keybinding {
|
||||||
|
color: #aaa;
|
||||||
|
}
|
||||||
|
|
||||||
|
.picker-type-elementBackground .picker-keybinding {
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.picker-swatch[aria-label="transparent"] .picker-keybinding {
|
||||||
|
color: #aaa;
|
||||||
|
}
|
||||||
|
|
||||||
|
.picker-type-elementStroke .picker-keybinding {
|
||||||
|
color: #d4d4d4;
|
||||||
|
}
|
||||||
|
|
||||||
|
&.Appearance_dark {
|
||||||
|
.picker-type-elementBackground .picker-keybinding {
|
||||||
|
color: #000;
|
||||||
|
}
|
||||||
|
.picker-swatch[aria-label="transparent"] .picker-keybinding {
|
||||||
|
color: #000;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,188 @@
|
|||||||
|
import React from "react";
|
||||||
|
import { Popover } from "./Popover";
|
||||||
|
|
||||||
|
import "./IconPicker.scss";
|
||||||
|
import { isArrowKey, KEYS } from "../keys";
|
||||||
|
import { getLanguage } from "../i18n";
|
||||||
|
|
||||||
|
function Picker<T>({
|
||||||
|
options,
|
||||||
|
value,
|
||||||
|
label,
|
||||||
|
onChange,
|
||||||
|
onClose,
|
||||||
|
}: {
|
||||||
|
label: string;
|
||||||
|
value: T;
|
||||||
|
options: { value: T; text: string; icon: JSX.Element; keyBinding: string }[];
|
||||||
|
onChange: (value: T) => void;
|
||||||
|
onClose: () => void;
|
||||||
|
}) {
|
||||||
|
const rFirstItem = React.useRef<HTMLButtonElement>();
|
||||||
|
const rActiveItem = React.useRef<HTMLButtonElement>();
|
||||||
|
const rGallery = React.useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
// After the component is first mounted focus on first input
|
||||||
|
if (rActiveItem.current) {
|
||||||
|
rActiveItem.current.focus();
|
||||||
|
} else if (rGallery.current) {
|
||||||
|
rGallery.current.focus();
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleKeyDown = (event: React.KeyboardEvent) => {
|
||||||
|
const pressedOption = options.find(
|
||||||
|
(option) => option.keyBinding === event.key.toLowerCase(),
|
||||||
|
)!;
|
||||||
|
|
||||||
|
if (!(event.metaKey || event.altKey || event.ctrlKey) && pressedOption) {
|
||||||
|
// Keybinding navigation
|
||||||
|
const index = options.indexOf(pressedOption);
|
||||||
|
(rGallery!.current!.children![index] as any).focus();
|
||||||
|
event.preventDefault();
|
||||||
|
} else if (event.key === KEYS.TAB) {
|
||||||
|
// Tab navigation cycle through options. If the user tabs
|
||||||
|
// away from the picker, close the picker. We need to use
|
||||||
|
// a timeout here to let the stack clear before checking.
|
||||||
|
setTimeout(() => {
|
||||||
|
const active = rActiveItem.current;
|
||||||
|
const docActive = document.activeElement;
|
||||||
|
if (active !== docActive) {
|
||||||
|
onClose();
|
||||||
|
}
|
||||||
|
}, 0);
|
||||||
|
} else if (isArrowKey(event.key)) {
|
||||||
|
// Arrow navigation
|
||||||
|
const { activeElement } = document;
|
||||||
|
const isRTL = getLanguage().rtl;
|
||||||
|
const index = Array.prototype.indexOf.call(
|
||||||
|
rGallery!.current!.children,
|
||||||
|
activeElement,
|
||||||
|
);
|
||||||
|
if (index !== -1) {
|
||||||
|
const length = options.length;
|
||||||
|
let nextIndex = index;
|
||||||
|
|
||||||
|
switch (event.key) {
|
||||||
|
// Select the next option
|
||||||
|
case isRTL ? KEYS.ARROW_LEFT : KEYS.ARROW_RIGHT:
|
||||||
|
case KEYS.ARROW_DOWN: {
|
||||||
|
nextIndex = (index + 1) % length;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
// Select the previous option
|
||||||
|
case isRTL ? KEYS.ARROW_RIGHT : KEYS.ARROW_LEFT:
|
||||||
|
case KEYS.ARROW_UP: {
|
||||||
|
nextIndex = (length + index - 1) % length;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
(rGallery.current!.children![nextIndex] as any).focus();
|
||||||
|
}
|
||||||
|
event.preventDefault();
|
||||||
|
} else if (event.key === KEYS.ESCAPE || event.key === KEYS.ENTER) {
|
||||||
|
// Close on escape or enter
|
||||||
|
event.preventDefault();
|
||||||
|
onClose();
|
||||||
|
}
|
||||||
|
event.nativeEvent.stopImmediatePropagation();
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={`picker`}
|
||||||
|
role="dialog"
|
||||||
|
aria-modal="true"
|
||||||
|
aria-label={label}
|
||||||
|
onKeyDown={handleKeyDown}
|
||||||
|
>
|
||||||
|
<div className="picker-content" ref={rGallery}>
|
||||||
|
{options.map((option, i) => (
|
||||||
|
<button
|
||||||
|
className="picker-option"
|
||||||
|
onClick={(event) => {
|
||||||
|
(event.currentTarget as HTMLButtonElement).focus();
|
||||||
|
onChange(option.value);
|
||||||
|
}}
|
||||||
|
title={`${option.text} — ${option.keyBinding.toUpperCase()}`}
|
||||||
|
aria-label={option.text || "none"}
|
||||||
|
aria-keyshortcuts={option.keyBinding}
|
||||||
|
key={option.text}
|
||||||
|
ref={(el) => {
|
||||||
|
if (el && i === 0) {
|
||||||
|
rFirstItem.current = el;
|
||||||
|
}
|
||||||
|
if (el && option.value === value) {
|
||||||
|
rActiveItem.current = el;
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
onFocus={() => {
|
||||||
|
onChange(option.value);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{option.icon}
|
||||||
|
<span className="picker-keybinding">{option.keyBinding}</span>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function IconPicker<T>({
|
||||||
|
value,
|
||||||
|
label,
|
||||||
|
options,
|
||||||
|
onChange,
|
||||||
|
group = "",
|
||||||
|
}: {
|
||||||
|
label: string;
|
||||||
|
value: T;
|
||||||
|
options: { value: T; text: string; icon: JSX.Element; keyBinding: string }[];
|
||||||
|
onChange: (value: T) => void;
|
||||||
|
group?: string;
|
||||||
|
}) {
|
||||||
|
const [isActive, setActive] = React.useState(false);
|
||||||
|
const rPickerButton = React.useRef<any>(null);
|
||||||
|
const isRTL = getLanguage().rtl;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<label className={"picker-container"}>
|
||||||
|
<button
|
||||||
|
name={group}
|
||||||
|
className={isActive ? "active" : ""}
|
||||||
|
aria-label={label}
|
||||||
|
onClick={() => setActive(!isActive)}
|
||||||
|
ref={rPickerButton}
|
||||||
|
>
|
||||||
|
{options.find((option) => option.value === value)?.icon}
|
||||||
|
</button>
|
||||||
|
<React.Suspense fallback="">
|
||||||
|
{isActive ? (
|
||||||
|
<>
|
||||||
|
<Popover
|
||||||
|
onCloseRequest={(event) =>
|
||||||
|
event.target !== rPickerButton.current && setActive(false)
|
||||||
|
}
|
||||||
|
{...(isRTL ? { right: 5.5 } : { left: -5.5 })}
|
||||||
|
>
|
||||||
|
<Picker
|
||||||
|
options={options}
|
||||||
|
value={value}
|
||||||
|
label={label}
|
||||||
|
onChange={onChange}
|
||||||
|
onClose={() => {
|
||||||
|
setActive(false);
|
||||||
|
rPickerButton.current?.focus();
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</Popover>
|
||||||
|
<div className="picker-triangle" />
|
||||||
|
</>
|
||||||
|
) : null}
|
||||||
|
</React.Suspense>
|
||||||
|
</label>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
import React from "react";
|
||||||
|
|
||||||
|
import { LoadingMessage } from "./LoadingMessage";
|
||||||
|
import {
|
||||||
|
defaultLang,
|
||||||
|
Language,
|
||||||
|
languages,
|
||||||
|
setLanguageFirstTime,
|
||||||
|
} from "../i18n";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
langCode: Language["code"];
|
||||||
|
}
|
||||||
|
interface State {
|
||||||
|
isLoading: boolean;
|
||||||
|
}
|
||||||
|
export class InitializeApp extends React.Component<Props, State> {
|
||||||
|
public state: { isLoading: boolean } = {
|
||||||
|
isLoading: true,
|
||||||
|
};
|
||||||
|
|
||||||
|
async componentDidMount() {
|
||||||
|
const currentLang =
|
||||||
|
languages.find((lang) => lang.code === this.props.langCode) ||
|
||||||
|
defaultLang;
|
||||||
|
await setLanguageFirstTime(currentLang);
|
||||||
|
this.setState({
|
||||||
|
isLoading: false,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public render() {
|
||||||
|
return this.state.isLoading ? <LoadingMessage /> : this.props.children;
|
||||||
|
}
|
||||||
|
}
|
||||||
+13
-11
@@ -1,14 +1,16 @@
|
|||||||
.Island {
|
.excalidraw {
|
||||||
--padding: 0;
|
.Island {
|
||||||
background-color: var(--bg-color-island);
|
--padding: 0;
|
||||||
backdrop-filter: saturate(100%) blur(10px);
|
background-color: var(--bg-color-island);
|
||||||
box-shadow: var(--shadow-island);
|
backdrop-filter: saturate(100%) blur(10px);
|
||||||
border-radius: var(--border-radius-m);
|
box-shadow: var(--shadow-island);
|
||||||
padding: calc(var(--padding) * var(--space-factor));
|
border-radius: 4px;
|
||||||
position: relative;
|
padding: calc(var(--padding) * var(--space-factor));
|
||||||
transition: box-shadow 0.5s ease-in-out;
|
position: relative;
|
||||||
|
transition: box-shadow 0.5s ease-in-out;
|
||||||
|
|
||||||
&.zen-mode {
|
&.zen-mode {
|
||||||
box-shadow: none;
|
box-shadow: none;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import "./Island.scss";
|
import "./Island.scss";
|
||||||
|
|
||||||
import React from "react";
|
import React from "react";
|
||||||
|
import clsx from "clsx";
|
||||||
|
|
||||||
type IslandProps = {
|
type IslandProps = {
|
||||||
children: React.ReactNode;
|
children: React.ReactNode;
|
||||||
@@ -12,8 +13,8 @@ type IslandProps = {
|
|||||||
export const Island = React.forwardRef<HTMLDivElement, IslandProps>(
|
export const Island = React.forwardRef<HTMLDivElement, IslandProps>(
|
||||||
({ children, padding, className, style }, ref) => (
|
({ children, padding, className, style }, ref) => (
|
||||||
<div
|
<div
|
||||||
className={`${className ?? ""} Island`}
|
className={clsx("Island", className)}
|
||||||
style={{ "--padding": padding, ...style } as React.CSSProperties}
|
style={{ "--padding": padding, ...style }}
|
||||||
ref={ref}
|
ref={ref}
|
||||||
>
|
>
|
||||||
{children}
|
{children}
|
||||||
|
|||||||
@@ -1,33 +0,0 @@
|
|||||||
import React from "react";
|
|
||||||
import * as i18n from "../i18n";
|
|
||||||
|
|
||||||
export function LanguageList({
|
|
||||||
onChange,
|
|
||||||
languages = i18n.languages,
|
|
||||||
currentLanguage = i18n.getLanguage().lng,
|
|
||||||
floating,
|
|
||||||
}: {
|
|
||||||
languages?: { lng: string; label: string }[];
|
|
||||||
onChange: (value: string) => void;
|
|
||||||
currentLanguage?: string;
|
|
||||||
floating?: boolean;
|
|
||||||
}) {
|
|
||||||
return (
|
|
||||||
<React.Fragment>
|
|
||||||
<select
|
|
||||||
className={`dropdown-select dropdown-select__language${
|
|
||||||
floating ? " dropdown-select--floating" : ""
|
|
||||||
}`}
|
|
||||||
onChange={({ target }) => onChange(target.value)}
|
|
||||||
value={currentLanguage}
|
|
||||||
aria-label={i18n.t("buttons.selectLanguage")}
|
|
||||||
>
|
|
||||||
{languages.map((language) => (
|
|
||||||
<option key={language.lng} value={language.lng}>
|
|
||||||
{language.label}
|
|
||||||
</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
</React.Fragment>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
+119
-113
@@ -1,133 +1,139 @@
|
|||||||
@import "open-color/open-color";
|
@import "open-color/open-color";
|
||||||
|
|
||||||
.layer-ui__wrapper {
|
.excalidraw {
|
||||||
.encrypted-icon {
|
.layer-ui__library {
|
||||||
position: relative;
|
margin: auto;
|
||||||
margin-inline-start: 15px;
|
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: center;
|
|
||||||
align-items: center;
|
align-items: center;
|
||||||
border-radius: var(--space-factor);
|
justify-content: center;
|
||||||
color: $oc-green-9;
|
|
||||||
|
|
||||||
svg {
|
.layer-ui__library-header {
|
||||||
width: 1.2rem;
|
display: flex;
|
||||||
height: 1.2rem;
|
align-items: center;
|
||||||
|
width: 100%;
|
||||||
|
margin: 2px 0;
|
||||||
|
|
||||||
|
button {
|
||||||
|
// 2px from the left to account for focus border of left-most button
|
||||||
|
margin: 0 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
a {
|
||||||
|
margin-left: auto;
|
||||||
|
// 17px for scrollbar (needed for overlay scrollbars on Big Sur?) + 1px extra
|
||||||
|
padding-right: 18px;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.layer-ui__library-message {
|
||||||
|
padding: 10px 20px;
|
||||||
|
max-width: 200px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.layer-ui__library-items {
|
||||||
|
max-height: 50vh;
|
||||||
|
overflow: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.layer-ui__wrapper {
|
||||||
|
.encrypted-icon {
|
||||||
|
position: relative;
|
||||||
|
margin-inline-start: 15px;
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
border-radius: var(--space-factor);
|
||||||
|
color: $oc-green-9;
|
||||||
|
|
||||||
|
svg {
|
||||||
|
width: 1.2rem;
|
||||||
|
height: 1.2rem;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
&.tooltip .tooltip-text {
|
&__github-corner {
|
||||||
visibility: hidden;
|
top: 0;
|
||||||
width: 20rem;
|
|
||||||
bottom: calc(50% + 0.8rem + 6px);
|
|
||||||
:root[dir="ltr"] & {
|
:root[dir="ltr"] & {
|
||||||
left: -5px;
|
right: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
:root[dir="rtl"] & {
|
:root[dir="rtl"] & {
|
||||||
right: -5px;
|
left: 0;
|
||||||
}
|
}
|
||||||
background-color: $oc-black;
|
|
||||||
color: $oc-white;
|
|
||||||
text-align: center;
|
|
||||||
border-radius: 6px;
|
|
||||||
padding: 5px;
|
|
||||||
position: absolute;
|
position: absolute;
|
||||||
z-index: 10;
|
width: 40px;
|
||||||
font-size: 13px;
|
}
|
||||||
line-height: 1.5;
|
|
||||||
white-space: pre-wrap;
|
|
||||||
|
|
||||||
&::after {
|
&__footer {
|
||||||
--size: 6px;
|
position: absolute;
|
||||||
content: "";
|
z-index: 100;
|
||||||
border: var(--size) solid transparent;
|
bottom: 0px;
|
||||||
border-top-color: $oc-black;
|
|
||||||
position: absolute;
|
:root[dir="ltr"] & {
|
||||||
bottom: calc(-2 * var(--size));
|
right: 0;
|
||||||
:root[dir="ltr"] & {
|
}
|
||||||
left: calc(5px + var(--size) / 2);
|
|
||||||
}
|
:root[dir="rtl"] & {
|
||||||
:root[dir="rtl"] & {
|
left: 0;
|
||||||
right: calc(5px + var(--size) / 2);
|
}
|
||||||
}
|
|
||||||
|
width: 190px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.zen-mode-transition {
|
||||||
|
transition: transform 0.5s ease-in-out;
|
||||||
|
|
||||||
|
:root[dir="ltr"] &.transition-left {
|
||||||
|
transform: translate(-999px, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
:root[dir="ltr"] &.transition-right {
|
||||||
|
transform: translate(999px, 0px);
|
||||||
|
}
|
||||||
|
|
||||||
|
:root[dir="rtl"] &.transition-left {
|
||||||
|
transform: translate(999px, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
:root[dir="rtl"] &.transition-right {
|
||||||
|
transform: translate(-999px, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
:root[dir="ltr"] &.App-menu_bottom--transition-left {
|
||||||
|
transform: translate(-92px, 0);
|
||||||
|
}
|
||||||
|
:root[dir="rtl"] &.App-menu_bottom--transition-left {
|
||||||
|
transform: translate(92px, 0);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// the following 3 rules ensure that the tooltip doesn't show (nor affect
|
.disable-zen-mode {
|
||||||
// the cursor) when you drag over when you draw on canvas, but at the same
|
height: 30px;
|
||||||
// time it still works when clicking on the link/shield
|
position: absolute;
|
||||||
|
bottom: 10px;
|
||||||
|
[dir="ltr"] & {
|
||||||
|
right: 15px;
|
||||||
|
}
|
||||||
|
[dir="rtl"] & {
|
||||||
|
left: 15px;
|
||||||
|
}
|
||||||
|
font-size: 10px;
|
||||||
|
padding: 10px;
|
||||||
|
font-weight: 500;
|
||||||
|
opacity: 0;
|
||||||
|
visibility: hidden;
|
||||||
|
transition: visibility 0s linear 0s, opacity 0.5s;
|
||||||
|
|
||||||
body:active &.tooltip:not(:hover) {
|
&--visible {
|
||||||
pointer-events: none;
|
opacity: 1;
|
||||||
}
|
visibility: visible;
|
||||||
body:not(:active) &.tooltip:hover .tooltip-text {
|
transition: visibility 0s linear 300ms, opacity 0.5s;
|
||||||
visibility: visible;
|
transition-delay: 0.8s;
|
||||||
}
|
}
|
||||||
.tooltip-text:hover {
|
|
||||||
visibility: visible;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
&__github-corner {
|
|
||||||
top: 0;
|
|
||||||
:root[dir="ltr"] & {
|
|
||||||
right: 0;
|
|
||||||
}
|
|
||||||
:root[dir="rtl"] & {
|
|
||||||
left: 0;
|
|
||||||
}
|
|
||||||
position: absolute;
|
|
||||||
width: 40px;
|
|
||||||
}
|
|
||||||
|
|
||||||
&__footer {
|
|
||||||
position: absolute;
|
|
||||||
bottom: 0px;
|
|
||||||
:root[dir="ltr"] & {
|
|
||||||
right: 0;
|
|
||||||
}
|
|
||||||
:root[dir="rtl"] & {
|
|
||||||
left: 0;
|
|
||||||
}
|
|
||||||
width: 190px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.zen-mode-transition {
|
|
||||||
transition: transform 0.5s ease-in-out;
|
|
||||||
:root[dir="ltr"] &.transition-left {
|
|
||||||
transform: translate(-999px, 0);
|
|
||||||
}
|
|
||||||
:root[dir="ltr"] &.transition-right {
|
|
||||||
transform: translate(999px, 0px);
|
|
||||||
}
|
|
||||||
:root[dir="rtl"] &.transition-left {
|
|
||||||
transform: translate(999px, 0);
|
|
||||||
}
|
|
||||||
:root[dir="rtl"] &.transition-right {
|
|
||||||
transform: translate(-999px, 0);
|
|
||||||
}
|
|
||||||
|
|
||||||
&.App-menu_bottom--transition-left {
|
|
||||||
transform: translate(-92px, 0);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.disable-zen-mode {
|
|
||||||
height: 30px;
|
|
||||||
position: absolute;
|
|
||||||
bottom: 10px;
|
|
||||||
right: 15px;
|
|
||||||
font-size: 10px;
|
|
||||||
padding: 10px;
|
|
||||||
font-weight: 500;
|
|
||||||
opacity: 0;
|
|
||||||
visibility: hidden;
|
|
||||||
transition: visibility 0s linear 0s, opacity 0.5s;
|
|
||||||
|
|
||||||
&--visible {
|
|
||||||
opacity: 1;
|
|
||||||
visibility: visible;
|
|
||||||
transition: visibility 0s linear 300ms, opacity 0.5s;
|
|
||||||
transition-delay: 0.8s;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+453
-116
@@ -1,19 +1,25 @@
|
|||||||
import React from "react";
|
import React, {
|
||||||
|
useRef,
|
||||||
|
useState,
|
||||||
|
RefObject,
|
||||||
|
useEffect,
|
||||||
|
useCallback,
|
||||||
|
} from "react";
|
||||||
import { showSelectedShapeActions } from "../element";
|
import { showSelectedShapeActions } from "../element";
|
||||||
import { calculateScrollCenter } from "../scene";
|
import { calculateScrollCenter, getSelectedElements } from "../scene";
|
||||||
import { exportCanvas } from "../data";
|
import { exportCanvas } from "../data";
|
||||||
|
|
||||||
import { AppState } from "../types";
|
import { AppState, LibraryItems, LibraryItem } from "../types";
|
||||||
import { NonDeletedExcalidrawElement } from "../element/types";
|
import { NonDeletedExcalidrawElement } from "../element/types";
|
||||||
|
|
||||||
import { ActionManager } from "../actions/manager";
|
import { ActionManager } from "../actions/manager";
|
||||||
import { Island } from "./Island";
|
import { Island } from "./Island";
|
||||||
import Stack from "./Stack";
|
import Stack from "./Stack";
|
||||||
import { FixedSideContainer } from "./FixedSideContainer";
|
import { FixedSideContainer } from "./FixedSideContainer";
|
||||||
|
import { UserList } from "./UserList";
|
||||||
import { LockIcon } from "./LockIcon";
|
import { LockIcon } from "./LockIcon";
|
||||||
import { ExportDialog, ExportCB } from "./ExportDialog";
|
import { ExportDialog, ExportCB } from "./ExportDialog";
|
||||||
import { LanguageList } from "./LanguageList";
|
import { Language, t } from "../i18n";
|
||||||
import { t, languages, setLanguage } from "../i18n";
|
|
||||||
import { HintViewer } from "./HintViewer";
|
import { HintViewer } from "./HintViewer";
|
||||||
import useIsMobile from "../is-mobile";
|
import useIsMobile from "../is-mobile";
|
||||||
|
|
||||||
@@ -21,76 +27,338 @@ import { ExportType } from "../scene/types";
|
|||||||
import { MobileMenu } from "./MobileMenu";
|
import { MobileMenu } from "./MobileMenu";
|
||||||
import { ZoomActions, SelectedShapeActions, ShapesSwitcher } from "./Actions";
|
import { ZoomActions, SelectedShapeActions, ShapesSwitcher } from "./Actions";
|
||||||
import { Section } from "./Section";
|
import { Section } from "./Section";
|
||||||
import { RoomDialog } from "./RoomDialog";
|
import CollabButton from "./CollabButton";
|
||||||
import { ErrorDialog } from "./ErrorDialog";
|
import { ErrorDialog } from "./ErrorDialog";
|
||||||
import { ShortcutsDialog } from "./ShortcutsDialog";
|
import { ShortcutsDialog } from "./ShortcutsDialog";
|
||||||
import { LoadingMessage } from "./LoadingMessage";
|
import { LoadingMessage } from "./LoadingMessage";
|
||||||
import { CLASSES } from "../constants";
|
import { CLASSES } from "../constants";
|
||||||
import { shield } from "./icons";
|
import { shield, exportFile, load } from "./icons";
|
||||||
import { GitHubCorner } from "./GitHubCorner";
|
import { GitHubCorner } from "./GitHubCorner";
|
||||||
|
import { Tooltip } from "./Tooltip";
|
||||||
|
|
||||||
import "./LayerUI.scss";
|
import "./LayerUI.scss";
|
||||||
|
import { LibraryUnit } from "./LibraryUnit";
|
||||||
|
import { ToolButton } from "./ToolButton";
|
||||||
|
import { saveLibraryAsJSON, importLibraryFromJSON } from "../data/json";
|
||||||
|
import { muteFSAbortError } from "../utils";
|
||||||
|
import { BackgroundPickerAndDarkModeToggle } from "./BackgroundPickerAndDarkModeToggle";
|
||||||
|
import clsx from "clsx";
|
||||||
|
import { Library } from "../data/library";
|
||||||
|
import {
|
||||||
|
EVENT_ACTION,
|
||||||
|
EVENT_EXIT,
|
||||||
|
EVENT_LIBRARY,
|
||||||
|
trackEvent,
|
||||||
|
} from "../analytics";
|
||||||
|
import { PasteChartDialog } from "./PasteChartDialog";
|
||||||
|
|
||||||
interface LayerUIProps {
|
interface LayerUIProps {
|
||||||
actionManager: ActionManager;
|
actionManager: ActionManager;
|
||||||
appState: AppState;
|
appState: AppState;
|
||||||
canvas: HTMLCanvasElement | null;
|
canvas: HTMLCanvasElement | null;
|
||||||
setAppState: any;
|
setAppState: React.Component<any, AppState>["setState"];
|
||||||
elements: readonly NonDeletedExcalidrawElement[];
|
elements: readonly NonDeletedExcalidrawElement[];
|
||||||
onRoomCreate: () => void;
|
onCollabButtonClick?: () => void;
|
||||||
onUsernameChange: (username: string) => void;
|
|
||||||
onRoomDestroy: () => void;
|
|
||||||
onLockToggle: () => void;
|
onLockToggle: () => void;
|
||||||
|
onInsertElements: (elements: readonly NonDeletedExcalidrawElement[]) => void;
|
||||||
zenModeEnabled: boolean;
|
zenModeEnabled: boolean;
|
||||||
toggleZenMode: () => void;
|
toggleZenMode: () => void;
|
||||||
|
langCode: Language["code"];
|
||||||
|
isCollaborating: boolean;
|
||||||
|
onExportToBackend?: (
|
||||||
|
exportedElements: readonly NonDeletedExcalidrawElement[],
|
||||||
|
appState: AppState,
|
||||||
|
canvas: HTMLCanvasElement | null,
|
||||||
|
) => void;
|
||||||
|
renderCustomFooter?: (isMobile: boolean) => JSX.Element;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const useOnClickOutside = (
|
||||||
|
ref: RefObject<HTMLElement>,
|
||||||
|
cb: (event: MouseEvent) => void,
|
||||||
|
) => {
|
||||||
|
useEffect(() => {
|
||||||
|
const listener = (event: MouseEvent) => {
|
||||||
|
if (!ref.current) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
event.target instanceof Element &&
|
||||||
|
(ref.current.contains(event.target) ||
|
||||||
|
!document.body.contains(event.target))
|
||||||
|
) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
cb(event);
|
||||||
|
};
|
||||||
|
document.addEventListener("pointerdown", listener, false);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
document.removeEventListener("pointerdown", listener);
|
||||||
|
};
|
||||||
|
}, [ref, cb]);
|
||||||
|
};
|
||||||
|
|
||||||
|
const LibraryMenuItems = ({
|
||||||
|
library,
|
||||||
|
onRemoveFromLibrary,
|
||||||
|
onAddToLibrary,
|
||||||
|
onInsertShape,
|
||||||
|
pendingElements,
|
||||||
|
setAppState,
|
||||||
|
}: {
|
||||||
|
library: LibraryItems;
|
||||||
|
pendingElements: LibraryItem;
|
||||||
|
onRemoveFromLibrary: (index: number) => void;
|
||||||
|
onInsertShape: (elements: LibraryItem) => void;
|
||||||
|
onAddToLibrary: (elements: LibraryItem) => void;
|
||||||
|
setAppState: React.Component<any, AppState>["setState"];
|
||||||
|
}) => {
|
||||||
|
const isMobile = useIsMobile();
|
||||||
|
const numCells = library.length + (pendingElements.length > 0 ? 1 : 0);
|
||||||
|
const CELLS_PER_ROW = isMobile ? 4 : 6;
|
||||||
|
const numRows = Math.max(1, Math.ceil(numCells / CELLS_PER_ROW));
|
||||||
|
const rows = [];
|
||||||
|
let addedPendingElements = false;
|
||||||
|
|
||||||
|
rows.push(
|
||||||
|
<div className="layer-ui__library-header">
|
||||||
|
<ToolButton
|
||||||
|
key="import"
|
||||||
|
type="button"
|
||||||
|
title={t("buttons.load")}
|
||||||
|
aria-label={t("buttons.load")}
|
||||||
|
icon={load}
|
||||||
|
onClick={() => {
|
||||||
|
importLibraryFromJSON()
|
||||||
|
.then(() => {
|
||||||
|
// Maybe we should close and open the menu so that the items get updated.
|
||||||
|
// But for now we just close the menu.
|
||||||
|
setAppState({ isLibraryOpen: false });
|
||||||
|
})
|
||||||
|
.catch(muteFSAbortError)
|
||||||
|
.catch((error) => {
|
||||||
|
setAppState({ errorMessage: error.message });
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<ToolButton
|
||||||
|
key="export"
|
||||||
|
type="button"
|
||||||
|
title={t("buttons.export")}
|
||||||
|
aria-label={t("buttons.export")}
|
||||||
|
icon={exportFile}
|
||||||
|
onClick={() => {
|
||||||
|
saveLibraryAsJSON()
|
||||||
|
.catch(muteFSAbortError)
|
||||||
|
.catch((error) => {
|
||||||
|
setAppState({ errorMessage: error.message });
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<a
|
||||||
|
href="https://libraries.excalidraw.com"
|
||||||
|
target="_excalidraw_libraries"
|
||||||
|
onClick={() => {
|
||||||
|
trackEvent(EVENT_EXIT, "libraries");
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{t("labels.libraries")}
|
||||||
|
</a>
|
||||||
|
</div>,
|
||||||
|
);
|
||||||
|
|
||||||
|
for (let row = 0; row < numRows; row++) {
|
||||||
|
const y = CELLS_PER_ROW * row;
|
||||||
|
const children = [];
|
||||||
|
for (let x = 0; x < CELLS_PER_ROW; x++) {
|
||||||
|
const shouldAddPendingElements: boolean =
|
||||||
|
pendingElements.length > 0 &&
|
||||||
|
!addedPendingElements &&
|
||||||
|
y + x >= library.length;
|
||||||
|
addedPendingElements = addedPendingElements || shouldAddPendingElements;
|
||||||
|
|
||||||
|
children.push(
|
||||||
|
<Stack.Col key={x}>
|
||||||
|
<LibraryUnit
|
||||||
|
elements={library[y + x]}
|
||||||
|
pendingElements={
|
||||||
|
shouldAddPendingElements ? pendingElements : undefined
|
||||||
|
}
|
||||||
|
onRemoveFromLibrary={onRemoveFromLibrary.bind(null, y + x)}
|
||||||
|
onClick={
|
||||||
|
shouldAddPendingElements
|
||||||
|
? onAddToLibrary.bind(null, pendingElements)
|
||||||
|
: onInsertShape.bind(null, library[y + x])
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</Stack.Col>,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
rows.push(
|
||||||
|
<Stack.Row align="center" gap={1} key={row}>
|
||||||
|
{children}
|
||||||
|
</Stack.Row>,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Stack.Col align="start" gap={1} className="layer-ui__library-items">
|
||||||
|
{rows}
|
||||||
|
</Stack.Col>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const LibraryMenu = ({
|
||||||
|
onClickOutside,
|
||||||
|
onInsertShape,
|
||||||
|
pendingElements,
|
||||||
|
onAddToLibrary,
|
||||||
|
setAppState,
|
||||||
|
}: {
|
||||||
|
pendingElements: LibraryItem;
|
||||||
|
onClickOutside: (event: MouseEvent) => void;
|
||||||
|
onInsertShape: (elements: LibraryItem) => void;
|
||||||
|
onAddToLibrary: () => void;
|
||||||
|
setAppState: React.Component<any, AppState>["setState"];
|
||||||
|
}) => {
|
||||||
|
const ref = useRef<HTMLDivElement | null>(null);
|
||||||
|
useOnClickOutside(ref, (event) => {
|
||||||
|
// If click on the library icon, do nothing.
|
||||||
|
if ((event.target as Element).closest(".ToolIcon_type_button__library")) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
onClickOutside(event);
|
||||||
|
});
|
||||||
|
|
||||||
|
const [libraryItems, setLibraryItems] = useState<LibraryItems>([]);
|
||||||
|
|
||||||
|
const [loadingState, setIsLoading] = useState<
|
||||||
|
"preloading" | "loading" | "ready"
|
||||||
|
>("preloading");
|
||||||
|
|
||||||
|
const loadingTimerRef = useRef<NodeJS.Timeout | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
Promise.race([
|
||||||
|
new Promise((resolve) => {
|
||||||
|
loadingTimerRef.current = setTimeout(() => {
|
||||||
|
resolve("loading");
|
||||||
|
}, 100);
|
||||||
|
}),
|
||||||
|
Library.loadLibrary().then((items) => {
|
||||||
|
setLibraryItems(items);
|
||||||
|
setIsLoading("ready");
|
||||||
|
}),
|
||||||
|
]).then((data) => {
|
||||||
|
if (data === "loading") {
|
||||||
|
setIsLoading("loading");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return () => {
|
||||||
|
clearTimeout(loadingTimerRef.current!);
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const removeFromLibrary = useCallback(async (indexToRemove) => {
|
||||||
|
const items = await Library.loadLibrary();
|
||||||
|
const nextItems = items.filter((_, index) => index !== indexToRemove);
|
||||||
|
Library.saveLibrary(nextItems);
|
||||||
|
trackEvent(EVENT_LIBRARY, "remove");
|
||||||
|
setLibraryItems(nextItems);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const addToLibrary = useCallback(
|
||||||
|
async (elements: LibraryItem) => {
|
||||||
|
const items = await Library.loadLibrary();
|
||||||
|
const nextItems = [...items, elements];
|
||||||
|
onAddToLibrary();
|
||||||
|
trackEvent(EVENT_LIBRARY, "add");
|
||||||
|
Library.saveLibrary(nextItems);
|
||||||
|
setLibraryItems(nextItems);
|
||||||
|
},
|
||||||
|
[onAddToLibrary],
|
||||||
|
);
|
||||||
|
|
||||||
|
return loadingState === "preloading" ? null : (
|
||||||
|
<Island padding={1} ref={ref} className="layer-ui__library">
|
||||||
|
{loadingState === "loading" ? (
|
||||||
|
<div className="layer-ui__library-message">
|
||||||
|
{t("labels.libraryLoadingMessage")}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<LibraryMenuItems
|
||||||
|
library={libraryItems}
|
||||||
|
onRemoveFromLibrary={removeFromLibrary}
|
||||||
|
onAddToLibrary={addToLibrary}
|
||||||
|
onInsertShape={onInsertShape}
|
||||||
|
pendingElements={pendingElements}
|
||||||
|
setAppState={setAppState}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</Island>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
const LayerUI = ({
|
const LayerUI = ({
|
||||||
actionManager,
|
actionManager,
|
||||||
appState,
|
appState,
|
||||||
setAppState,
|
setAppState,
|
||||||
canvas,
|
canvas,
|
||||||
elements,
|
elements,
|
||||||
onRoomCreate,
|
onCollabButtonClick,
|
||||||
onUsernameChange,
|
|
||||||
onRoomDestroy,
|
|
||||||
onLockToggle,
|
onLockToggle,
|
||||||
|
onInsertElements,
|
||||||
zenModeEnabled,
|
zenModeEnabled,
|
||||||
toggleZenMode,
|
toggleZenMode,
|
||||||
|
isCollaborating,
|
||||||
|
onExportToBackend,
|
||||||
|
renderCustomFooter,
|
||||||
}: LayerUIProps) => {
|
}: LayerUIProps) => {
|
||||||
const isMobile = useIsMobile();
|
const isMobile = useIsMobile();
|
||||||
|
|
||||||
const renderEncryptedIcon = () => (
|
const renderEncryptedIcon = () => (
|
||||||
<a
|
<a
|
||||||
className={`encrypted-icon tooltip zen-mode-visibility ${
|
className={clsx("encrypted-icon tooltip zen-mode-visibility", {
|
||||||
zenModeEnabled ? "zen-mode-visibility--hidden" : ""
|
"zen-mode-visibility--hidden": zenModeEnabled,
|
||||||
}`}
|
})}
|
||||||
href="https://blog.excalidraw.com/end-to-end-encryption/"
|
href="https://blog.excalidraw.com/end-to-end-encryption/"
|
||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noopener noreferrer"
|
rel="noopener noreferrer"
|
||||||
|
onClick={() => {
|
||||||
|
trackEvent(EVENT_EXIT, "e2ee shield");
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
<span className="tooltip-text" dir="auto">
|
<Tooltip label={t("encrypted.tooltip")} position="above" long={true}>
|
||||||
{t("encrypted.tooltip")}
|
{shield}
|
||||||
</span>
|
</Tooltip>
|
||||||
{shield}
|
|
||||||
</a>
|
</a>
|
||||||
);
|
);
|
||||||
|
|
||||||
const renderExportDialog = () => {
|
const renderExportDialog = () => {
|
||||||
const createExporter = (type: ExportType): ExportCB => (
|
const createExporter = (type: ExportType): ExportCB => async (
|
||||||
exportedElements,
|
exportedElements,
|
||||||
scale,
|
scale,
|
||||||
) => {
|
) => {
|
||||||
if (canvas) {
|
if (canvas) {
|
||||||
exportCanvas(type, exportedElements, appState, canvas, {
|
await exportCanvas(type, exportedElements, appState, canvas, {
|
||||||
exportBackground: appState.exportBackground,
|
exportBackground: appState.exportBackground,
|
||||||
name: appState.name,
|
name: appState.name,
|
||||||
viewBackgroundColor: appState.viewBackgroundColor,
|
viewBackgroundColor: appState.viewBackgroundColor,
|
||||||
scale,
|
scale,
|
||||||
shouldAddWatermark: appState.shouldAddWatermark,
|
shouldAddWatermark: appState.shouldAddWatermark,
|
||||||
});
|
})
|
||||||
|
.catch(muteFSAbortError)
|
||||||
|
.catch((error) => {
|
||||||
|
console.error(error);
|
||||||
|
setAppState({ errorMessage: error.message });
|
||||||
|
});
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ExportDialog
|
<ExportDialog
|
||||||
elements={elements}
|
elements={elements}
|
||||||
@@ -99,20 +367,14 @@ const LayerUI = ({
|
|||||||
onExportToPng={createExporter("png")}
|
onExportToPng={createExporter("png")}
|
||||||
onExportToSvg={createExporter("svg")}
|
onExportToSvg={createExporter("svg")}
|
||||||
onExportToClipboard={createExporter("clipboard")}
|
onExportToClipboard={createExporter("clipboard")}
|
||||||
onExportToBackend={(exportedElements) => {
|
onExportToBackend={
|
||||||
if (canvas) {
|
onExportToBackend
|
||||||
exportCanvas(
|
? (elements) => {
|
||||||
"backend",
|
onExportToBackend &&
|
||||||
exportedElements,
|
onExportToBackend(elements, appState, canvas);
|
||||||
{
|
}
|
||||||
...appState,
|
: undefined
|
||||||
selectedElementIds: {},
|
}
|
||||||
},
|
|
||||||
canvas,
|
|
||||||
appState,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
@@ -120,27 +382,33 @@ const LayerUI = ({
|
|||||||
const renderCanvasActions = () => (
|
const renderCanvasActions = () => (
|
||||||
<Section
|
<Section
|
||||||
heading="canvasActions"
|
heading="canvasActions"
|
||||||
className={`zen-mode-transition ${zenModeEnabled && "transition-left"}`}
|
className={clsx("zen-mode-transition", {
|
||||||
|
"transition-left": zenModeEnabled,
|
||||||
|
})}
|
||||||
>
|
>
|
||||||
{/* the zIndex ensures this menu has higher stacking order,
|
{/* the zIndex ensures this menu has higher stacking order,
|
||||||
see https://github.com/excalidraw/excalidraw/pull/1445 */}
|
see https://github.com/excalidraw/excalidraw/pull/1445 */}
|
||||||
<Island padding={4} style={{ zIndex: 1 }}>
|
<Island padding={2} style={{ zIndex: 1 }}>
|
||||||
<Stack.Col gap={4}>
|
<Stack.Col gap={4}>
|
||||||
<Stack.Row gap={1} justifyContent="space-between">
|
<Stack.Row gap={1} justifyContent="space-between">
|
||||||
{actionManager.renderAction("loadScene")}
|
{actionManager.renderAction("loadScene")}
|
||||||
{actionManager.renderAction("saveScene")}
|
{actionManager.renderAction("saveScene")}
|
||||||
|
{actionManager.renderAction("saveAsScene")}
|
||||||
{renderExportDialog()}
|
{renderExportDialog()}
|
||||||
{actionManager.renderAction("clearCanvas")}
|
{actionManager.renderAction("clearCanvas")}
|
||||||
<RoomDialog
|
{onCollabButtonClick && (
|
||||||
isCollaborating={appState.isCollaborating}
|
<CollabButton
|
||||||
collaboratorCount={appState.collaborators.size}
|
isCollaborating={isCollaborating}
|
||||||
username={appState.username}
|
collaboratorCount={appState.collaborators.size}
|
||||||
onUsernameChange={onUsernameChange}
|
onClick={onCollabButtonClick}
|
||||||
onRoomCreate={onRoomCreate}
|
/>
|
||||||
onRoomDestroy={onRoomDestroy}
|
)}
|
||||||
/>
|
|
||||||
</Stack.Row>
|
</Stack.Row>
|
||||||
{actionManager.renderAction("changeViewBackgroundColor")}
|
<BackgroundPickerAndDarkModeToggle
|
||||||
|
actionManager={actionManager}
|
||||||
|
appState={appState}
|
||||||
|
setAppState={setAppState}
|
||||||
|
/>
|
||||||
</Stack.Col>
|
</Stack.Col>
|
||||||
</Island>
|
</Island>
|
||||||
</Section>
|
</Section>
|
||||||
@@ -149,9 +417,11 @@ const LayerUI = ({
|
|||||||
const renderSelectedShapeActions = () => (
|
const renderSelectedShapeActions = () => (
|
||||||
<Section
|
<Section
|
||||||
heading="selectedShapeActions"
|
heading="selectedShapeActions"
|
||||||
className={`zen-mode-transition ${zenModeEnabled && "transition-left"}`}
|
className={clsx("zen-mode-transition", {
|
||||||
|
"transition-left": zenModeEnabled,
|
||||||
|
})}
|
||||||
>
|
>
|
||||||
<Island className={CLASSES.SHAPE_ACTIONS_MENU} padding={4}>
|
<Island className={CLASSES.SHAPE_ACTIONS_MENU} padding={2}>
|
||||||
<SelectedShapeActions
|
<SelectedShapeActions
|
||||||
appState={appState}
|
appState={appState}
|
||||||
elements={elements}
|
elements={elements}
|
||||||
@@ -162,18 +432,42 @@ const LayerUI = ({
|
|||||||
</Section>
|
</Section>
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const closeLibrary = useCallback(
|
||||||
|
(event) => {
|
||||||
|
setAppState({ isLibraryOpen: false });
|
||||||
|
},
|
||||||
|
[setAppState],
|
||||||
|
);
|
||||||
|
|
||||||
|
const deselectItems = useCallback(() => {
|
||||||
|
setAppState({
|
||||||
|
selectedElementIds: {},
|
||||||
|
selectedGroupIds: {},
|
||||||
|
});
|
||||||
|
}, [setAppState]);
|
||||||
|
|
||||||
|
const libraryMenu = appState.isLibraryOpen ? (
|
||||||
|
<LibraryMenu
|
||||||
|
pendingElements={getSelectedElements(elements, appState)}
|
||||||
|
onClickOutside={closeLibrary}
|
||||||
|
onInsertShape={onInsertElements}
|
||||||
|
onAddToLibrary={deselectItems}
|
||||||
|
setAppState={setAppState}
|
||||||
|
/>
|
||||||
|
) : null;
|
||||||
|
|
||||||
const renderFixedSideContainer = () => {
|
const renderFixedSideContainer = () => {
|
||||||
const shouldRenderSelectedShapeActions = showSelectedShapeActions(
|
const shouldRenderSelectedShapeActions = showSelectedShapeActions(
|
||||||
appState,
|
appState,
|
||||||
elements,
|
elements,
|
||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<FixedSideContainer side="top">
|
<FixedSideContainer side="top">
|
||||||
<HintViewer appState={appState} elements={elements} />
|
|
||||||
<div className="App-menu App-menu_top">
|
<div className="App-menu App-menu_top">
|
||||||
<Stack.Col
|
<Stack.Col
|
||||||
gap={4}
|
gap={4}
|
||||||
className={zenModeEnabled && "disable-pointerEvents"}
|
className={clsx({ "disable-pointerEvents": zenModeEnabled })}
|
||||||
>
|
>
|
||||||
{renderCanvasActions()}
|
{renderCanvasActions()}
|
||||||
{shouldRenderSelectedShapeActions && renderSelectedShapeActions()}
|
{shouldRenderSelectedShapeActions && renderSelectedShapeActions()}
|
||||||
@@ -182,12 +476,17 @@ const LayerUI = ({
|
|||||||
{(heading) => (
|
{(heading) => (
|
||||||
<Stack.Col gap={4} align="start">
|
<Stack.Col gap={4} align="start">
|
||||||
<Stack.Row gap={1}>
|
<Stack.Row gap={1}>
|
||||||
<Island padding={1} className={zenModeEnabled && "zen-mode"}>
|
<Island
|
||||||
|
padding={1}
|
||||||
|
className={clsx({ "zen-mode": zenModeEnabled })}
|
||||||
|
>
|
||||||
|
<HintViewer appState={appState} elements={elements} />
|
||||||
{heading}
|
{heading}
|
||||||
<Stack.Row gap={1}>
|
<Stack.Row gap={1}>
|
||||||
<ShapesSwitcher
|
<ShapesSwitcher
|
||||||
elementType={appState.elementType}
|
elementType={appState.elementType}
|
||||||
setAppState={setAppState}
|
setAppState={setAppState}
|
||||||
|
isLibraryOpen={appState.isLibraryOpen}
|
||||||
/>
|
/>
|
||||||
</Stack.Row>
|
</Stack.Row>
|
||||||
</Island>
|
</Island>
|
||||||
@@ -198,55 +497,68 @@ const LayerUI = ({
|
|||||||
title={t("toolBar.lock")}
|
title={t("toolBar.lock")}
|
||||||
/>
|
/>
|
||||||
</Stack.Row>
|
</Stack.Row>
|
||||||
|
{libraryMenu}
|
||||||
</Stack.Col>
|
</Stack.Col>
|
||||||
)}
|
)}
|
||||||
</Section>
|
</Section>
|
||||||
<div />
|
<UserList
|
||||||
</div>
|
className={clsx("zen-mode-transition", {
|
||||||
{
|
"transition-right": zenModeEnabled,
|
||||||
<div
|
})}
|
||||||
className={`App-menu App-menu_bottom zen-mode-transition ${
|
|
||||||
zenModeEnabled && "App-menu_bottom--transition-left"
|
|
||||||
}`}
|
|
||||||
>
|
>
|
||||||
<Stack.Col gap={2}>
|
{Array.from(appState.collaborators)
|
||||||
<Section heading="canvasActions">
|
// Collaborator is either not initialized or is actually the current user.
|
||||||
<Island padding={1}>
|
.filter(([_, client]) => Object.keys(client).length !== 0)
|
||||||
<ZoomActions
|
.map(([clientId, client]) => (
|
||||||
renderAction={actionManager.renderAction}
|
<Tooltip
|
||||||
zoom={appState.zoom}
|
label={client.username || "Unknown user"}
|
||||||
/>
|
key={clientId}
|
||||||
</Island>
|
>
|
||||||
{renderEncryptedIcon()}
|
{actionManager.renderAction("goToCollaborator", clientId)}
|
||||||
</Section>
|
</Tooltip>
|
||||||
</Stack.Col>
|
))}
|
||||||
</div>
|
</UserList>
|
||||||
}
|
</div>
|
||||||
</FixedSideContainer>
|
</FixedSideContainer>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const renderBottomAppMenu = () => {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={clsx("App-menu App-menu_bottom zen-mode-transition", {
|
||||||
|
"App-menu_bottom--transition-left": zenModeEnabled,
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
<Stack.Col gap={2}>
|
||||||
|
<Section heading="canvasActions">
|
||||||
|
<Island padding={1}>
|
||||||
|
<ZoomActions
|
||||||
|
renderAction={actionManager.renderAction}
|
||||||
|
zoom={appState.zoom}
|
||||||
|
/>
|
||||||
|
</Island>
|
||||||
|
{renderEncryptedIcon()}
|
||||||
|
</Section>
|
||||||
|
</Stack.Col>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
const renderFooter = () => (
|
const renderFooter = () => (
|
||||||
<footer role="contentinfo" className="layer-ui__wrapper__footer">
|
<footer role="contentinfo" className="layer-ui__wrapper__footer">
|
||||||
<div
|
<div
|
||||||
className={`zen-mode-transition ${
|
className={clsx("zen-mode-transition", {
|
||||||
zenModeEnabled && "transition-right disable-pointerEvents"
|
"transition-right disable-pointerEvents": zenModeEnabled,
|
||||||
}`}
|
})}
|
||||||
>
|
>
|
||||||
<LanguageList
|
{renderCustomFooter?.(false)}
|
||||||
onChange={(lng) => {
|
|
||||||
setLanguage(lng);
|
|
||||||
setAppState({});
|
|
||||||
}}
|
|
||||||
languages={languages}
|
|
||||||
floating
|
|
||||||
/>
|
|
||||||
{actionManager.renderAction("toggleShortcuts")}
|
{actionManager.renderAction("toggleShortcuts")}
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
className={`disable-zen-mode ${
|
className={clsx("disable-zen-mode", {
|
||||||
zenModeEnabled && "disable-zen-mode--visible"
|
"disable-zen-mode--visible": zenModeEnabled,
|
||||||
}`}
|
})}
|
||||||
onClick={toggleZenMode}
|
onClick={toggleZenMode}
|
||||||
>
|
>
|
||||||
{t("buttons.exitZenMode")}
|
{t("buttons.exitZenMode")}
|
||||||
@@ -255,7 +567,10 @@ const LayerUI = ({
|
|||||||
<button
|
<button
|
||||||
className="scroll-back-to-content"
|
className="scroll-back-to-content"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setAppState({ ...calculateScrollCenter(elements) });
|
trackEvent(EVENT_ACTION, "scroll to content");
|
||||||
|
setAppState({
|
||||||
|
...calculateScrollCenter(elements, appState, canvas),
|
||||||
|
});
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{t("buttons.scrollBackToContent")}
|
{t("buttons.scrollBackToContent")}
|
||||||
@@ -264,20 +579,8 @@ const LayerUI = ({
|
|||||||
</footer>
|
</footer>
|
||||||
);
|
);
|
||||||
|
|
||||||
return isMobile ? (
|
const dialogs = (
|
||||||
<MobileMenu
|
<>
|
||||||
appState={appState}
|
|
||||||
elements={elements}
|
|
||||||
actionManager={actionManager}
|
|
||||||
exportButton={renderExportDialog()}
|
|
||||||
setAppState={setAppState}
|
|
||||||
onUsernameChange={onUsernameChange}
|
|
||||||
onRoomCreate={onRoomCreate}
|
|
||||||
onRoomDestroy={onRoomDestroy}
|
|
||||||
onLockToggle={onLockToggle}
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
<div className="layer-ui__wrapper">
|
|
||||||
{appState.isLoading && <LoadingMessage />}
|
{appState.isLoading && <LoadingMessage />}
|
||||||
{appState.errorMessage && (
|
{appState.errorMessage && (
|
||||||
<ErrorDialog
|
<ErrorDialog
|
||||||
@@ -287,17 +590,56 @@ const LayerUI = ({
|
|||||||
)}
|
)}
|
||||||
{appState.showShortcutsDialog && (
|
{appState.showShortcutsDialog && (
|
||||||
<ShortcutsDialog
|
<ShortcutsDialog
|
||||||
onClose={() => setAppState({ showShortcutsDialog: null })}
|
onClose={() => setAppState({ showShortcutsDialog: false })}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
{appState.pasteDialog.shown && (
|
||||||
|
<PasteChartDialog
|
||||||
|
setAppState={setAppState}
|
||||||
|
appState={appState}
|
||||||
|
onInsertChart={onInsertElements}
|
||||||
|
onClose={() =>
|
||||||
|
setAppState({
|
||||||
|
pasteDialog: { shown: false, data: null },
|
||||||
|
})
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
|
||||||
|
return isMobile ? (
|
||||||
|
<>
|
||||||
|
{dialogs}
|
||||||
|
<MobileMenu
|
||||||
|
appState={appState}
|
||||||
|
elements={elements}
|
||||||
|
actionManager={actionManager}
|
||||||
|
libraryMenu={libraryMenu}
|
||||||
|
exportButton={renderExportDialog()}
|
||||||
|
setAppState={setAppState}
|
||||||
|
onCollabButtonClick={onCollabButtonClick}
|
||||||
|
onLockToggle={onLockToggle}
|
||||||
|
canvas={canvas}
|
||||||
|
isCollaborating={isCollaborating}
|
||||||
|
renderCustomFooter={renderCustomFooter}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<div className="layer-ui__wrapper">
|
||||||
|
{dialogs}
|
||||||
{renderFixedSideContainer()}
|
{renderFixedSideContainer()}
|
||||||
|
{renderBottomAppMenu()}
|
||||||
{
|
{
|
||||||
<aside
|
<aside
|
||||||
className={`layer-ui__wrapper__github-corner zen-mode-transition ${
|
className={clsx(
|
||||||
zenModeEnabled && "transition-right"
|
"layer-ui__wrapper__github-corner zen-mode-transition",
|
||||||
}`}
|
{
|
||||||
|
"transition-right": zenModeEnabled,
|
||||||
|
},
|
||||||
|
)}
|
||||||
>
|
>
|
||||||
<GitHubCorner />
|
<GitHubCorner appearance={appState.appearance} />
|
||||||
</aside>
|
</aside>
|
||||||
}
|
}
|
||||||
{renderFooter()}
|
{renderFooter()}
|
||||||
@@ -308,13 +650,8 @@ const LayerUI = ({
|
|||||||
const areEqual = (prev: LayerUIProps, next: LayerUIProps) => {
|
const areEqual = (prev: LayerUIProps, next: LayerUIProps) => {
|
||||||
const getNecessaryObj = (appState: AppState): Partial<AppState> => {
|
const getNecessaryObj = (appState: AppState): Partial<AppState> => {
|
||||||
const {
|
const {
|
||||||
draggingElement,
|
suggestedBindings,
|
||||||
resizingElement,
|
startBoundElement: boundElement,
|
||||||
multiElement,
|
|
||||||
editingElement,
|
|
||||||
isResizing,
|
|
||||||
cursorX,
|
|
||||||
cursorY,
|
|
||||||
...ret
|
...ret
|
||||||
} = appState;
|
} = appState;
|
||||||
return ret;
|
return ret;
|
||||||
@@ -323,8 +660,8 @@ const areEqual = (prev: LayerUIProps, next: LayerUIProps) => {
|
|||||||
const nextAppState = getNecessaryObj(next.appState);
|
const nextAppState = getNecessaryObj(next.appState);
|
||||||
|
|
||||||
const keys = Object.keys(prevAppState) as (keyof Partial<AppState>)[];
|
const keys = Object.keys(prevAppState) as (keyof Partial<AppState>)[];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
prev.langCode === next.langCode &&
|
||||||
prev.elements === next.elements &&
|
prev.elements === next.elements &&
|
||||||
keys.every((key) => prevAppState[key] === nextAppState[key])
|
keys.every((key) => prevAppState[key] === nextAppState[key])
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -0,0 +1,79 @@
|
|||||||
|
.excalidraw {
|
||||||
|
.library-unit {
|
||||||
|
align-items: center;
|
||||||
|
border: 1px solid var(--button-gray-2);
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
position: relative;
|
||||||
|
width: 63px;
|
||||||
|
height: 63px; // match width
|
||||||
|
}
|
||||||
|
|
||||||
|
.library-unit__dragger {
|
||||||
|
display: flex;
|
||||||
|
height: 100%;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.library-unit__dragger > svg {
|
||||||
|
filter: var(--appearance-filter);
|
||||||
|
flex-grow: 1;
|
||||||
|
max-height: 100%;
|
||||||
|
max-width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.library-unit__removeFromLibrary,
|
||||||
|
.library-unit__removeFromLibrary:hover,
|
||||||
|
.library-unit__removeFromLibrary:active {
|
||||||
|
align-items: center;
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
color: var(--icon-fill-color);
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
position: absolute;
|
||||||
|
right: 5px;
|
||||||
|
top: 5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.library-unit__removeFromLibrary > svg {
|
||||||
|
height: 16px;
|
||||||
|
width: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.library-unit__pulse {
|
||||||
|
transform: scale(1);
|
||||||
|
animation: library-unit__pulse-animation 1s ease-in infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
.library-unit__adder {
|
||||||
|
position: absolute;
|
||||||
|
left: 50%;
|
||||||
|
top: 50%;
|
||||||
|
width: 20px;
|
||||||
|
height: 20px;
|
||||||
|
margin-left: -10px;
|
||||||
|
margin-top: -10px;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.library-unit__active {
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes library-unit__pulse-animation {
|
||||||
|
0% {
|
||||||
|
transform: scale(0.95);
|
||||||
|
}
|
||||||
|
|
||||||
|
50% {
|
||||||
|
transform: scale(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
100% {
|
||||||
|
transform: scale(0.95);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,100 @@
|
|||||||
|
import clsx from "clsx";
|
||||||
|
import oc from "open-color";
|
||||||
|
import React, { useEffect, useRef, useState } from "react";
|
||||||
|
import { close } from "../components/icons";
|
||||||
|
import { MIME_TYPES } from "../constants";
|
||||||
|
import { t } from "../i18n";
|
||||||
|
import useIsMobile from "../is-mobile";
|
||||||
|
import { exportToSvg } from "../scene/export";
|
||||||
|
import { LibraryItem } from "../types";
|
||||||
|
import "./LibraryUnit.scss";
|
||||||
|
|
||||||
|
// fa-plus
|
||||||
|
const PLUS_ICON = (
|
||||||
|
<svg viewBox="0 0 1792 1792">
|
||||||
|
<path
|
||||||
|
fill="currentColor"
|
||||||
|
d="M1600 736v192q0 40-28 68t-68 28h-416v416q0 40-28 68t-68 28h-192q-40 0-68-28t-28-68v-416h-416q-40 0-68-28t-28-68v-192q0-40 28-68t68-28h416v-416q0-40 28-68t68-28h192q40 0 68 28t28 68v416h416q40 0 68 28t28 68z"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
|
||||||
|
export const LibraryUnit = ({
|
||||||
|
elements,
|
||||||
|
pendingElements,
|
||||||
|
onRemoveFromLibrary,
|
||||||
|
onClick,
|
||||||
|
}: {
|
||||||
|
elements?: LibraryItem;
|
||||||
|
pendingElements?: LibraryItem;
|
||||||
|
onRemoveFromLibrary: () => void;
|
||||||
|
onClick: () => void;
|
||||||
|
}) => {
|
||||||
|
const ref = useRef<HTMLDivElement | null>(null);
|
||||||
|
useEffect(() => {
|
||||||
|
const elementsToRender = elements || pendingElements;
|
||||||
|
if (!elementsToRender) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const svg = exportToSvg(elementsToRender, {
|
||||||
|
exportBackground: false,
|
||||||
|
viewBackgroundColor: oc.white,
|
||||||
|
shouldAddWatermark: false,
|
||||||
|
});
|
||||||
|
for (const child of ref.current!.children) {
|
||||||
|
if (child.tagName !== "svg") {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
ref.current!.removeChild(child);
|
||||||
|
}
|
||||||
|
ref.current!.appendChild(svg);
|
||||||
|
|
||||||
|
const current = ref.current!;
|
||||||
|
return () => {
|
||||||
|
current.removeChild(svg);
|
||||||
|
};
|
||||||
|
}, [elements, pendingElements]);
|
||||||
|
|
||||||
|
const [isHovered, setIsHovered] = useState(false);
|
||||||
|
const isMobile = useIsMobile();
|
||||||
|
|
||||||
|
const adder = (isHovered || isMobile) && pendingElements && (
|
||||||
|
<div className="library-unit__adder">{PLUS_ICON}</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={clsx("library-unit", {
|
||||||
|
"library-unit__active": elements || pendingElements,
|
||||||
|
})}
|
||||||
|
onMouseEnter={() => setIsHovered(true)}
|
||||||
|
onMouseLeave={() => setIsHovered(false)}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className={clsx("library-unit__dragger", {
|
||||||
|
"library-unit__pulse": !!pendingElements,
|
||||||
|
})}
|
||||||
|
ref={ref}
|
||||||
|
draggable={!!elements}
|
||||||
|
onClick={!!elements || !!pendingElements ? onClick : undefined}
|
||||||
|
onDragStart={(event) => {
|
||||||
|
setIsHovered(false);
|
||||||
|
event.dataTransfer.setData(
|
||||||
|
MIME_TYPES.excalidrawlib,
|
||||||
|
JSON.stringify(elements),
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
{adder}
|
||||||
|
{elements && (isHovered || isMobile) && (
|
||||||
|
<button
|
||||||
|
className="library-unit__removeFromLibrary"
|
||||||
|
aria-label={t("labels.removeFromLibrary")}
|
||||||
|
onClick={onRemoveFromLibrary}
|
||||||
|
>
|
||||||
|
{close}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -1,10 +1,11 @@
|
|||||||
import React from "react";
|
import React from "react";
|
||||||
|
import { t } from "../i18n";
|
||||||
|
|
||||||
export const LoadingMessage = () => {
|
export const LoadingMessage = () => {
|
||||||
// !! KEEP THIS IN SYNC WITH index.html !!
|
// !! KEEP THIS IN SYNC WITH index.html !!
|
||||||
return (
|
return (
|
||||||
<div className="LoadingMessage">
|
<div className="LoadingMessage">
|
||||||
<span>{"Loading scene..."}</span>
|
<span>{t("labels.loadingScene")}</span>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user