Compare commits

..

416 Commits

Author SHA1 Message Date
Excalidraw Bot 198992f02b Auto commit: Calculate translation coverage 2026-03-11 21:18:02 +00:00
Excalidraw Bot a830820d45 New translations en.json (Karakalpak) 2026-03-11 22:16:57 +01:00
Excalidraw Bot 211c2c5b4c New translations en.json (Kabyle) 2026-03-11 22:16:55 +01:00
Excalidraw Bot ebcd8535ed New translations en.json (Bengali, India) 2026-03-11 22:16:54 +01:00
Excalidraw Bot 34fd9d60d7 New translations en.json (German, Switzerland) 2026-03-11 22:16:53 +01:00
Excalidraw Bot 60e831851b New translations en.json (Occitan) 2026-03-11 22:16:52 +01:00
Excalidraw Bot 40a4146b67 New translations en.json (Norwegian Bokmal) 2026-03-11 22:16:51 +01:00
Excalidraw Bot e466771826 New translations en.json (Uzbek) 2026-03-11 22:16:50 +01:00
Excalidraw Bot 06042fa7ee New translations en.json (Sinhala) 2026-03-11 22:16:48 +01:00
Excalidraw Bot abdf12bcd0 New translations en.json (Chinese Traditional, Hong Kong) 2026-03-11 22:16:47 +01:00
Excalidraw Bot 57a310dfb5 New translations en.json (Burmese) 2026-03-11 22:16:46 +01:00
Excalidraw Bot 06fb8d3d7c New translations en.json (Hindi) 2026-03-11 22:16:45 +01:00
Excalidraw Bot f46dd72595 New translations en.json (Azerbaijani) 2026-03-11 22:16:44 +01:00
Excalidraw Bot da36bc650d New translations en.json (Latvian) 2026-03-11 22:16:43 +01:00
Excalidraw Bot 90d458c089 New translations en.json (Kazakh) 2026-03-11 22:16:41 +01:00
Excalidraw Bot 93cf69a588 New translations en.json (Norwegian Nynorsk) 2026-03-11 22:16:40 +01:00
Excalidraw Bot 7dc916e3dc New translations en.json (Thai) 2026-03-11 22:16:39 +01:00
Excalidraw Bot 460036e324 New translations en.json (Marathi) 2026-03-11 22:16:38 +01:00
Excalidraw Bot c4a527ee17 New translations en.json (Bengali) 2026-03-11 22:16:37 +01:00
Excalidraw Bot 45d11181c9 New translations en.json (Tamil) 2026-03-11 22:16:36 +01:00
Excalidraw Bot bc9fccd61b New translations en.json (Khmer) 2026-03-11 22:16:34 +01:00
Excalidraw Bot a83cfaae6f New translations en.json (Persian) 2026-03-11 22:16:33 +01:00
Excalidraw Bot 795274f9f0 New translations en.json (Indonesian) 2026-03-11 22:16:31 +01:00
Excalidraw Bot f51ac29fbe New translations en.json (Galician) 2026-03-11 22:16:30 +01:00
Excalidraw Bot e1698dd3aa New translations en.json (Vietnamese) 2026-03-11 22:16:29 +01:00
Excalidraw Bot a39587c845 New translations en.json (Chinese Simplified) 2026-03-11 22:16:27 +01:00
Excalidraw Bot 76f004bf99 New translations en.json (Swedish) 2026-03-11 22:16:26 +01:00
Excalidraw Bot 5c0f4fdd42 New translations en.json (Slovenian) 2026-03-11 22:16:25 +01:00
Excalidraw Bot 9d157d7009 New translations en.json (Slovak) 2026-03-11 22:16:24 +01:00
Excalidraw Bot 84e5373a9a New translations en.json (Portuguese) 2026-03-11 22:16:23 +01:00
Excalidraw Bot 107023393c New translations en.json (Polish) 2026-03-11 22:16:22 +01:00
Excalidraw Bot 19f2c741a9 New translations en.json (Punjabi) 2026-03-11 22:16:20 +01:00
Excalidraw Bot a3d000ee71 New translations en.json (Lithuanian) 2026-03-11 22:16:19 +01:00
Excalidraw Bot 6ff96ef739 New translations en.json (Kurdish) 2026-03-11 22:16:18 +01:00
Excalidraw Bot e34139986c New translations en.json (Korean) 2026-03-11 22:16:17 +01:00
Excalidraw Bot 30801b718a New translations en.json (Japanese) 2026-03-11 22:16:16 +01:00
Excalidraw Bot 606b05eb17 New translations en.json (Hungarian) 2026-03-11 22:16:15 +01:00
Excalidraw Bot 625fad6c1b New translations en.json (Hebrew) 2026-03-11 22:16:13 +01:00
Excalidraw Bot 9ebafb63a6 New translations en.json (Finnish) 2026-03-11 22:16:12 +01:00
Excalidraw Bot 4d087cbbd9 New translations en.json (Basque) 2026-03-11 22:16:11 +01:00
Excalidraw Bot 539f6807e3 New translations en.json (Greek) 2026-03-11 22:16:10 +01:00
Excalidraw Bot 20b2604c68 New translations en.json (German) 2026-03-11 22:16:09 +01:00
Excalidraw Bot 0781ac6ad9 New translations en.json (Danish) 2026-03-11 22:16:07 +01:00
Excalidraw Bot 22b8282812 New translations en.json (Czech) 2026-03-11 22:16:06 +01:00
Excalidraw Bot 50500f57f8 New translations en.json (Catalan) 2026-03-11 22:16:05 +01:00
Excalidraw Bot 9a0f8167eb New translations en.json (Bulgarian) 2026-03-11 22:16:04 +01:00
Excalidraw Bot 9564931b69 New translations en.json (Arabic) 2026-03-11 22:16:03 +01:00
Excalidraw Bot 83a3ed78b4 New translations en.json (Spanish) 2026-03-11 22:16:01 +01:00
Excalidraw Bot 8d240c6f66 New translations en.json (Romanian) 2026-03-11 22:16:00 +01:00
Excalidraw Bot 132a8d3cf8 New translations en.json (Turkish) 2026-03-11 22:15:59 +01:00
Excalidraw Bot b2998238f7 New translations en.json (French) 2026-03-11 22:15:58 +01:00
Excalidraw Bot a72f4e1689 New translations en.json (Portuguese, Brazilian) 2026-03-11 22:15:57 +01:00
Excalidraw Bot dc68825d2b New translations en.json (Chinese Traditional) 2026-03-11 22:15:55 +01:00
Excalidraw Bot c49cfc3bde New translations en.json (Ukrainian) 2026-03-11 22:15:54 +01:00
Excalidraw Bot df1566a6ee New translations en.json (Dutch) 2026-03-11 22:15:53 +01:00
Excalidraw Bot e89685b931 New translations en.json (Italian) 2026-03-11 22:15:51 +01:00
Excalidraw Bot b292d2ecbc New translations en.json (Russian) 2026-03-11 22:15:50 +01:00
Excalidraw Bot 4e8d015c6d Auto commit: Calculate translation coverage 2026-03-09 12:30:29 +00:00
Excalidraw Bot f520d6839c New translations en.json (Chinese Traditional) 2026-03-09 13:30:16 +01:00
Excalidraw Bot 1dddcf1633 Auto commit: Calculate translation coverage 2026-03-09 08:53:33 +00:00
Excalidraw Bot db7d73c65e New translations en.json (Romanian) 2026-03-09 09:53:22 +01:00
Excalidraw Bot 5371a13749 Auto commit: Calculate translation coverage 2026-03-08 22:39:08 +00:00
Excalidraw Bot 73ee1552b7 New translations en.json (Karakalpak) 2026-03-08 23:38:00 +01:00
Excalidraw Bot aaa71dad68 New translations en.json (Kabyle) 2026-03-08 23:37:59 +01:00
Excalidraw Bot 365320fdc6 New translations en.json (Bengali, India) 2026-03-08 23:37:57 +01:00
Excalidraw Bot ca05af6aee New translations en.json (German, Switzerland) 2026-03-08 23:37:57 +01:00
Excalidraw Bot 3de073dfa2 New translations en.json (Occitan) 2026-03-08 23:37:55 +01:00
Excalidraw Bot 1f73461abd New translations en.json (Norwegian Bokmal) 2026-03-08 23:37:55 +01:00
Excalidraw Bot b7bc95a216 New translations en.json (Uzbek) 2026-03-08 23:37:54 +01:00
Excalidraw Bot f11580cc71 New translations en.json (Sinhala) 2026-03-08 23:37:52 +01:00
Excalidraw Bot bb5c9439c7 New translations en.json (Chinese Traditional, Hong Kong) 2026-03-08 23:37:51 +01:00
Excalidraw Bot cec234157b New translations en.json (Burmese) 2026-03-08 23:37:51 +01:00
Excalidraw Bot 69cd706970 New translations en.json (Hindi) 2026-03-08 23:37:49 +01:00
Excalidraw Bot fa9ca4598d New translations en.json (Azerbaijani) 2026-03-08 23:37:48 +01:00
Excalidraw Bot 9c88cc2305 New translations en.json (Latvian) 2026-03-08 23:37:47 +01:00
Excalidraw Bot 8817c4ed5e New translations en.json (Kazakh) 2026-03-08 23:37:46 +01:00
Excalidraw Bot f65bdb0a90 New translations en.json (Norwegian Nynorsk) 2026-03-08 23:37:45 +01:00
Excalidraw Bot 99ce84a9b8 New translations en.json (Thai) 2026-03-08 23:37:44 +01:00
Excalidraw Bot 5b15b45bec New translations en.json (Marathi) 2026-03-08 23:37:43 +01:00
Excalidraw Bot 0573bc959b New translations en.json (Bengali) 2026-03-08 23:37:42 +01:00
Excalidraw Bot b950747454 New translations en.json (Tamil) 2026-03-08 23:37:41 +01:00
Excalidraw Bot 60609026c9 New translations en.json (Khmer) 2026-03-08 23:37:40 +01:00
Excalidraw Bot fd5affb040 New translations en.json (Persian) 2026-03-08 23:37:39 +01:00
Excalidraw Bot 5f6dd3dc91 New translations en.json (Indonesian) 2026-03-08 23:37:38 +01:00
Excalidraw Bot 62a178758b New translations en.json (Galician) 2026-03-08 23:37:37 +01:00
Excalidraw Bot c9218fdb10 New translations en.json (Vietnamese) 2026-03-08 23:37:36 +01:00
Excalidraw Bot fd13af7747 New translations en.json (Chinese Simplified) 2026-03-08 23:37:35 +01:00
Excalidraw Bot c310a50fd5 New translations en.json (Swedish) 2026-03-08 23:37:34 +01:00
Excalidraw Bot aba5b98285 New translations en.json (Slovenian) 2026-03-08 23:37:33 +01:00
Excalidraw Bot 53acd4e31b New translations en.json (Slovak) 2026-03-08 23:37:32 +01:00
Excalidraw Bot 38837620e0 New translations en.json (Portuguese) 2026-03-08 23:37:31 +01:00
Excalidraw Bot ec1c4e8ce3 New translations en.json (Polish) 2026-03-08 23:37:30 +01:00
Excalidraw Bot 27a3d5a36d New translations en.json (Punjabi) 2026-03-08 23:37:29 +01:00
Excalidraw Bot 038a44b8ef New translations en.json (Lithuanian) 2026-03-08 23:37:28 +01:00
Excalidraw Bot 56bfebb15c New translations en.json (Kurdish) 2026-03-08 23:37:27 +01:00
Excalidraw Bot 9e14480293 New translations en.json (Korean) 2026-03-08 23:37:25 +01:00
Excalidraw Bot 1b24d6def7 New translations en.json (Japanese) 2026-03-08 23:37:24 +01:00
Excalidraw Bot 5cca65323f New translations en.json (Hungarian) 2026-03-08 23:37:23 +01:00
Excalidraw Bot 32d92bcef5 New translations en.json (Hebrew) 2026-03-08 23:37:22 +01:00
Excalidraw Bot c6c7040cac New translations en.json (Finnish) 2026-03-08 23:37:21 +01:00
Excalidraw Bot c6419e54db New translations en.json (Basque) 2026-03-08 23:37:20 +01:00
Excalidraw Bot 1ce70c7022 New translations en.json (Greek) 2026-03-08 23:37:19 +01:00
Excalidraw Bot af3d94064a New translations en.json (German) 2026-03-08 23:37:18 +01:00
Excalidraw Bot 0750f61536 New translations en.json (Danish) 2026-03-08 23:37:17 +01:00
Excalidraw Bot 979830edf6 New translations en.json (Czech) 2026-03-08 23:37:16 +01:00
Excalidraw Bot 7eea5d5ec4 New translations en.json (Catalan) 2026-03-08 23:37:15 +01:00
Excalidraw Bot eef72ca4fb New translations en.json (Bulgarian) 2026-03-08 23:37:14 +01:00
Excalidraw Bot d0e25ccec2 New translations en.json (Arabic) 2026-03-08 23:37:13 +01:00
Excalidraw Bot cb616e2957 New translations en.json (Spanish) 2026-03-08 23:37:12 +01:00
Excalidraw Bot 80d466cf7a New translations en.json (Romanian) 2026-03-08 23:37:11 +01:00
Excalidraw Bot d0383d0ce3 New translations en.json (Turkish) 2026-03-08 23:37:10 +01:00
Excalidraw Bot d75222110b New translations en.json (French) 2026-03-08 23:37:09 +01:00
Excalidraw Bot e27e4e0e2d New translations en.json (Portuguese, Brazilian) 2026-03-08 23:37:07 +01:00
Excalidraw Bot 6057a40665 New translations en.json (Chinese Traditional) 2026-03-08 23:37:06 +01:00
Excalidraw Bot e29c03554c New translations en.json (Ukrainian) 2026-03-08 23:37:05 +01:00
Excalidraw Bot 3643bf82d0 New translations en.json (Dutch) 2026-03-08 23:37:04 +01:00
Excalidraw Bot 50cfcc26cc New translations en.json (Italian) 2026-03-08 23:37:03 +01:00
Excalidraw Bot d5fb4305a3 New translations en.json (Russian) 2026-03-08 23:37:02 +01:00
Excalidraw Bot 970a1aa720 Auto commit: Calculate translation coverage 2026-03-07 20:55:25 +00:00
Excalidraw Bot e3d305ddc2 New translations en.json (Italian) 2026-03-07 21:55:15 +01:00
Excalidraw Bot 04389e8408 Auto commit: Calculate translation coverage 2026-03-06 08:08:28 +00:00
Excalidraw Bot 29dbc53a56 New translations en.json (Russian) 2026-03-06 09:08:16 +01:00
Excalidraw Bot 8acc174353 Auto commit: Calculate translation coverage 2026-03-06 06:31:04 +00:00
Excalidraw Bot 6985e75394 New translations en.json (French) 2026-03-06 07:30:52 +01:00
Excalidraw Bot 0b65b2cc63 Auto commit: Calculate translation coverage 2026-03-05 20:05:20 +00:00
Excalidraw Bot f6ea4bf135 New translations en.json (Romanian) 2026-03-05 21:05:08 +01:00
Excalidraw Bot a5b37fabea Auto commit: Calculate translation coverage 2026-03-05 18:14:47 +00:00
Excalidraw Bot 43476864ad New translations en.json (Karakalpak) 2026-03-05 19:00:54 +01:00
Excalidraw Bot e29436d8db New translations en.json (Kabyle) 2026-03-05 19:00:53 +01:00
Excalidraw Bot f0b6ace0fc New translations en.json (Bengali, India) 2026-03-05 19:00:51 +01:00
Excalidraw Bot d0a5264b1b New translations en.json (German, Switzerland) 2026-03-05 19:00:50 +01:00
Excalidraw Bot 841ef74430 New translations en.json (Occitan) 2026-03-05 19:00:49 +01:00
Excalidraw Bot 3f7d7f79de New translations en.json (Norwegian Bokmal) 2026-03-05 19:00:47 +01:00
Excalidraw Bot a30d454e5f New translations en.json (Uzbek) 2026-03-05 19:00:46 +01:00
Excalidraw Bot d3471352ed New translations en.json (Sinhala) 2026-03-05 19:00:44 +01:00
Excalidraw Bot de9c8d9fc3 New translations en.json (Chinese Traditional, Hong Kong) 2026-03-05 19:00:42 +01:00
Excalidraw Bot 1ab1db08a8 New translations en.json (Burmese) 2026-03-05 19:00:41 +01:00
Excalidraw Bot 22a72427ec New translations en.json (Hindi) 2026-03-05 19:00:40 +01:00
Excalidraw Bot 74428d8091 New translations en.json (Azerbaijani) 2026-03-05 19:00:38 +01:00
Excalidraw Bot 657929ea6f New translations en.json (Latvian) 2026-03-05 19:00:36 +01:00
Excalidraw Bot 3a0e6aa70c New translations en.json (Kazakh) 2026-03-05 19:00:35 +01:00
Excalidraw Bot 743fd2d38a New translations en.json (Norwegian Nynorsk) 2026-03-05 19:00:33 +01:00
Excalidraw Bot 3b89c89a52 New translations en.json (Thai) 2026-03-05 19:00:32 +01:00
Excalidraw Bot f0a80e9f78 New translations en.json (Marathi) 2026-03-05 19:00:30 +01:00
Excalidraw Bot 5e3dd21a10 New translations en.json (Bengali) 2026-03-05 19:00:29 +01:00
Excalidraw Bot a96134fa81 New translations en.json (Tamil) 2026-03-05 19:00:28 +01:00
Excalidraw Bot 8dd6dd666c New translations en.json (Khmer) 2026-03-05 19:00:26 +01:00
Excalidraw Bot 3a1fc51e44 New translations en.json (Persian) 2026-03-05 19:00:24 +01:00
Excalidraw Bot d83742c5eb New translations en.json (Indonesian) 2026-03-05 19:00:23 +01:00
Excalidraw Bot 83da4bd876 New translations en.json (Galician) 2026-03-05 19:00:21 +01:00
Excalidraw Bot c08a3a4d65 New translations en.json (Vietnamese) 2026-03-05 19:00:20 +01:00
Excalidraw Bot 65e20609d7 New translations en.json (Chinese Simplified) 2026-03-05 19:00:18 +01:00
Excalidraw Bot 5d62d50e4f New translations en.json (Swedish) 2026-03-05 19:00:16 +01:00
Excalidraw Bot 218acae143 New translations en.json (Slovenian) 2026-03-05 19:00:15 +01:00
Excalidraw Bot 35e618cdb6 New translations en.json (Slovak) 2026-03-05 19:00:13 +01:00
Excalidraw Bot e1f14c081c New translations en.json (Portuguese) 2026-03-05 19:00:11 +01:00
Excalidraw Bot cd3165b180 New translations en.json (Polish) 2026-03-05 19:00:10 +01:00
Excalidraw Bot 94da04d9f9 New translations en.json (Punjabi) 2026-03-05 19:00:07 +01:00
Excalidraw Bot a112d5ea41 New translations en.json (Lithuanian) 2026-03-05 19:00:05 +01:00
Excalidraw Bot 77ba23407c New translations en.json (Kurdish) 2026-03-05 19:00:03 +01:00
Excalidraw Bot 4ea2007557 New translations en.json (Korean) 2026-03-05 19:00:02 +01:00
Excalidraw Bot 42d2692b2f New translations en.json (Japanese) 2026-03-05 19:00:00 +01:00
Excalidraw Bot f7c7632ca6 New translations en.json (Hungarian) 2026-03-05 18:59:58 +01:00
Excalidraw Bot 61737f9c1b New translations en.json (Hebrew) 2026-03-05 18:59:57 +01:00
Excalidraw Bot 24d176ba2c New translations en.json (Finnish) 2026-03-05 18:59:56 +01:00
Excalidraw Bot f363fc071b New translations en.json (Basque) 2026-03-05 18:59:55 +01:00
Excalidraw Bot 7221028010 New translations en.json (Greek) 2026-03-05 18:59:53 +01:00
Excalidraw Bot fc1762d0fd New translations en.json (German) 2026-03-05 18:59:52 +01:00
Excalidraw Bot 0581fc1eaf New translations en.json (Danish) 2026-03-05 18:59:51 +01:00
Excalidraw Bot 284ac616e1 New translations en.json (Czech) 2026-03-05 18:59:49 +01:00
Excalidraw Bot 631ff71965 New translations en.json (Catalan) 2026-03-05 18:59:48 +01:00
Excalidraw Bot fb95a77055 New translations en.json (Bulgarian) 2026-03-05 18:59:47 +01:00
Excalidraw Bot 0d57e9f628 New translations en.json (Arabic) 2026-03-05 18:59:45 +01:00
Excalidraw Bot 7a24f13e81 New translations en.json (Spanish) 2026-03-05 18:59:43 +01:00
Excalidraw Bot 8f6e1d942d New translations en.json (Romanian) 2026-03-05 18:59:41 +01:00
Excalidraw Bot b21c6abbe4 New translations en.json (Turkish) 2026-03-05 18:59:40 +01:00
Excalidraw Bot 8757a9ddc4 New translations en.json (French) 2026-03-05 18:59:39 +01:00
Excalidraw Bot f7b3497dee New translations en.json (Portuguese, Brazilian) 2026-03-05 18:59:37 +01:00
Excalidraw Bot 0e17d69535 New translations en.json (Chinese Traditional) 2026-03-05 18:59:36 +01:00
Excalidraw Bot ff6ec195bd New translations en.json (Ukrainian) 2026-03-05 18:59:34 +01:00
Excalidraw Bot dd8637e125 New translations en.json (Dutch) 2026-03-05 18:59:33 +01:00
Excalidraw Bot 6d9257310c New translations en.json (Italian) 2026-03-05 18:59:31 +01:00
Excalidraw Bot 3d3f96880d New translations en.json (Russian) 2026-03-05 18:59:30 +01:00
dwelle 715399b558 Merge branch 'master' into l10n_master 2026-03-05 10:09:15 +01:00
Excalidraw Bot b818b61d8b Auto commit: Calculate translation coverage 2026-03-04 14:42:54 +00:00
Excalidraw Bot 99757b1a06 New translations en.json (Romanian) 2026-03-04 15:42:41 +01:00
Excalidraw Bot fe547a6ada New translations en.json (Russian) 2026-03-04 15:42:38 +01:00
Excalidraw Bot b6cb835cd4 Auto commit: Calculate translation coverage 2026-03-03 23:22:02 +00:00
Excalidraw Bot 917079900a New translations en.json (Karakalpak) 2026-03-04 00:20:53 +01:00
Excalidraw Bot a3304507a1 New translations en.json (Kabyle) 2026-03-04 00:20:51 +01:00
Excalidraw Bot 453d934cd9 New translations en.json (Bengali, India) 2026-03-04 00:20:50 +01:00
Excalidraw Bot cf73a91567 New translations en.json (German, Switzerland) 2026-03-04 00:20:49 +01:00
Excalidraw Bot 368ea9dd7f New translations en.json (Occitan) 2026-03-04 00:20:48 +01:00
Excalidraw Bot 0bdca3493b New translations en.json (Norwegian Bokmal) 2026-03-04 00:20:47 +01:00
Excalidraw Bot f69095d2bd New translations en.json (Uzbek) 2026-03-04 00:20:46 +01:00
Excalidraw Bot f5df6b9e4c New translations en.json (Sinhala) 2026-03-04 00:20:45 +01:00
Excalidraw Bot 0d285b3eec New translations en.json (Chinese Traditional, Hong Kong) 2026-03-04 00:20:44 +01:00
Excalidraw Bot be98b5c7cf New translations en.json (Burmese) 2026-03-04 00:20:43 +01:00
Excalidraw Bot 013cc37a47 New translations en.json (Hindi) 2026-03-04 00:20:42 +01:00
Excalidraw Bot 82f3fa6c57 New translations en.json (Azerbaijani) 2026-03-04 00:20:41 +01:00
Excalidraw Bot 669f8a2b59 New translations en.json (Latvian) 2026-03-04 00:20:40 +01:00
Excalidraw Bot 0518fa5beb New translations en.json (Kazakh) 2026-03-04 00:20:38 +01:00
Excalidraw Bot e48b742ee1 New translations en.json (Norwegian Nynorsk) 2026-03-04 00:20:37 +01:00
Excalidraw Bot d4c45dc9be New translations en.json (Thai) 2026-03-04 00:20:36 +01:00
Excalidraw Bot d728887146 New translations en.json (Marathi) 2026-03-04 00:20:35 +01:00
Excalidraw Bot 240e1b0492 New translations en.json (Bengali) 2026-03-04 00:20:34 +01:00
Excalidraw Bot 4cb28e838f New translations en.json (Tamil) 2026-03-04 00:20:33 +01:00
Excalidraw Bot 5823570530 New translations en.json (Khmer) 2026-03-04 00:20:32 +01:00
Excalidraw Bot b832b44413 New translations en.json (Persian) 2026-03-04 00:20:31 +01:00
Excalidraw Bot 5c7123e77b New translations en.json (Indonesian) 2026-03-04 00:20:30 +01:00
Excalidraw Bot 36b220a067 New translations en.json (Galician) 2026-03-04 00:20:29 +01:00
Excalidraw Bot d03c2eb963 New translations en.json (Vietnamese) 2026-03-04 00:20:27 +01:00
Excalidraw Bot e01199e6b8 New translations en.json (Chinese Simplified) 2026-03-04 00:20:26 +01:00
Excalidraw Bot 9c516f1e0a New translations en.json (Swedish) 2026-03-04 00:20:25 +01:00
Excalidraw Bot 6566456832 New translations en.json (Slovenian) 2026-03-04 00:20:24 +01:00
Excalidraw Bot 60936c48ca New translations en.json (Slovak) 2026-03-04 00:20:23 +01:00
Excalidraw Bot 43be14dc93 New translations en.json (Portuguese) 2026-03-04 00:20:22 +01:00
Excalidraw Bot 526d2c52f3 New translations en.json (Polish) 2026-03-04 00:20:21 +01:00
Excalidraw Bot fcb69c36e6 New translations en.json (Punjabi) 2026-03-04 00:20:20 +01:00
Excalidraw Bot 53a4d76e3b New translations en.json (Lithuanian) 2026-03-04 00:20:19 +01:00
Excalidraw Bot 0710d40919 New translations en.json (Kurdish) 2026-03-04 00:20:18 +01:00
Excalidraw Bot a07cd1b0e8 New translations en.json (Korean) 2026-03-04 00:20:16 +01:00
Excalidraw Bot 4a55ab6bab New translations en.json (Japanese) 2026-03-04 00:20:15 +01:00
Excalidraw Bot 6257bdbfb6 New translations en.json (Hungarian) 2026-03-04 00:20:14 +01:00
Excalidraw Bot 3f377c9d69 New translations en.json (Hebrew) 2026-03-04 00:20:13 +01:00
Excalidraw Bot 176e11bac8 New translations en.json (Finnish) 2026-03-04 00:20:12 +01:00
Excalidraw Bot 84c89018d9 New translations en.json (Basque) 2026-03-04 00:20:11 +01:00
Excalidraw Bot 6cac1c8a04 New translations en.json (Greek) 2026-03-04 00:20:10 +01:00
Excalidraw Bot 1e996e4d94 New translations en.json (German) 2026-03-04 00:20:09 +01:00
Excalidraw Bot d028157a5c New translations en.json (Danish) 2026-03-04 00:20:08 +01:00
Excalidraw Bot 2934b8c308 New translations en.json (Czech) 2026-03-04 00:20:07 +01:00
Excalidraw Bot c6f08724eb New translations en.json (Catalan) 2026-03-04 00:20:06 +01:00
Excalidraw Bot 976da91fe7 New translations en.json (Bulgarian) 2026-03-04 00:20:05 +01:00
Excalidraw Bot 0b42b49d9e New translations en.json (Arabic) 2026-03-04 00:20:04 +01:00
Excalidraw Bot 3a472aa6fc New translations en.json (Spanish) 2026-03-04 00:20:02 +01:00
Excalidraw Bot 6a716adea6 New translations en.json (Romanian) 2026-03-04 00:20:01 +01:00
Excalidraw Bot abb33bf686 New translations en.json (Turkish) 2026-03-04 00:20:00 +01:00
Excalidraw Bot 0463c18f02 New translations en.json (French) 2026-03-04 00:19:59 +01:00
Excalidraw Bot fd83e44c6f New translations en.json (Portuguese, Brazilian) 2026-03-04 00:19:58 +01:00
Excalidraw Bot 2d0d6d6c1b New translations en.json (Chinese Traditional) 2026-03-04 00:19:57 +01:00
Excalidraw Bot ab39228fc9 New translations en.json (Ukrainian) 2026-03-04 00:19:56 +01:00
Excalidraw Bot 6e9085eee7 New translations en.json (Dutch) 2026-03-04 00:19:55 +01:00
Excalidraw Bot eac4fa5b57 New translations en.json (Italian) 2026-03-04 00:19:54 +01:00
Excalidraw Bot ef40b72eb0 New translations en.json (Russian) 2026-03-04 00:19:53 +01:00
Excalidraw Bot e1f5b9f138 Auto commit: Calculate translation coverage 2026-03-02 08:15:54 +00:00
Excalidraw Bot 8bf7fae439 New translations en.json (French) 2026-03-02 09:15:44 +01:00
Excalidraw Bot 503fd4c598 Auto commit: Calculate translation coverage 2026-03-01 14:08:06 +00:00
Excalidraw Bot e99df11729 New translations en.json (Italian) 2026-03-01 15:07:54 +01:00
Excalidraw Bot 39b9224c3c Auto commit: Calculate translation coverage 2026-02-28 08:29:43 +00:00
Excalidraw Bot 38cf2fb51e New translations en.json (Russian) 2026-02-28 09:29:30 +01:00
Excalidraw Bot 333a2ee6fc Auto commit: Calculate translation coverage 2026-02-27 21:22:10 +00:00
Excalidraw Bot 728bb66eb7 New translations en.json (Thai) 2026-02-27 22:21:59 +01:00
Excalidraw Bot 49ea45aa5d Auto commit: Calculate translation coverage 2026-02-27 19:24:41 +00:00
Excalidraw Bot cf5418b128 New translations en.json (Romanian) 2026-02-27 20:24:29 +01:00
Excalidraw Bot 9e0eb0f541 New translations en.json (Chinese Traditional) 2026-02-27 20:24:28 +01:00
Excalidraw Bot d506a822ff Auto commit: Calculate translation coverage 2026-02-27 17:26:28 +00:00
Excalidraw Bot 0fb50e07c0 New translations en.json (Chinese Traditional) 2026-02-27 18:26:16 +01:00
Excalidraw Bot c2edd7c6f4 New translations en.json (Italian) 2026-02-26 22:20:43 +01:00
Excalidraw Bot bf7aafb77d Auto commit: Calculate translation coverage 2026-02-26 15:27:55 +00:00
Excalidraw Bot a3b9763dcd New translations en.json (Karakalpak) 2026-02-26 16:26:58 +01:00
Excalidraw Bot baf93453c3 New translations en.json (Kabyle) 2026-02-26 16:26:57 +01:00
Excalidraw Bot d9b9dc783e New translations en.json (Bengali, India) 2026-02-26 16:26:55 +01:00
Excalidraw Bot dd4343d48c New translations en.json (German, Switzerland) 2026-02-26 16:26:54 +01:00
Excalidraw Bot fdd021574c New translations en.json (Occitan) 2026-02-26 16:26:53 +01:00
Excalidraw Bot 652b0f60f5 New translations en.json (Norwegian Bokmal) 2026-02-26 16:26:51 +01:00
Excalidraw Bot 902c8eaf56 New translations en.json (Uzbek) 2026-02-26 16:26:50 +01:00
Excalidraw Bot 7e9a7cbd26 New translations en.json (Sinhala) 2026-02-26 16:26:48 +01:00
Excalidraw Bot 9d04681dc5 New translations en.json (Chinese Traditional, Hong Kong) 2026-02-26 16:26:47 +01:00
Excalidraw Bot b0bfc7af20 New translations en.json (Burmese) 2026-02-26 16:26:45 +01:00
Excalidraw Bot ba2828914d New translations en.json (Hindi) 2026-02-26 16:26:44 +01:00
Excalidraw Bot 791b63148b New translations en.json (Azerbaijani) 2026-02-26 16:26:42 +01:00
Excalidraw Bot 9ff150bdc2 New translations en.json (Latvian) 2026-02-26 16:26:41 +01:00
Excalidraw Bot 527b2658c1 New translations en.json (Kazakh) 2026-02-26 16:26:39 +01:00
Excalidraw Bot 7b8983c75a New translations en.json (Norwegian Nynorsk) 2026-02-26 16:26:38 +01:00
Excalidraw Bot c4067bfe2f New translations en.json (Thai) 2026-02-26 16:26:36 +01:00
Excalidraw Bot 46abd7bc96 New translations en.json (Marathi) 2026-02-26 16:26:34 +01:00
Excalidraw Bot 3ba91352ed New translations en.json (Bengali) 2026-02-26 16:26:33 +01:00
Excalidraw Bot 0e57d55451 New translations en.json (Tamil) 2026-02-26 16:26:32 +01:00
Excalidraw Bot d4e6e3cae3 New translations en.json (Khmer) 2026-02-26 16:26:30 +01:00
Excalidraw Bot c6395ae166 New translations en.json (Persian) 2026-02-26 16:26:29 +01:00
Excalidraw Bot 2d1aeee971 New translations en.json (Indonesian) 2026-02-26 16:26:28 +01:00
Excalidraw Bot c9455a0de4 New translations en.json (Galician) 2026-02-26 16:26:26 +01:00
Excalidraw Bot 9f944db928 New translations en.json (Vietnamese) 2026-02-26 16:26:25 +01:00
Excalidraw Bot 170bf35513 New translations en.json (Chinese Simplified) 2026-02-26 16:26:24 +01:00
Excalidraw Bot e6e8349a45 New translations en.json (Swedish) 2026-02-26 16:26:22 +01:00
Excalidraw Bot d632694c9b New translations en.json (Slovenian) 2026-02-26 16:26:21 +01:00
Excalidraw Bot cff4ca670d New translations en.json (Slovak) 2026-02-26 16:26:20 +01:00
Excalidraw Bot eaabc26428 New translations en.json (Portuguese) 2026-02-26 16:26:18 +01:00
Excalidraw Bot 0bcc2360a9 New translations en.json (Polish) 2026-02-26 16:26:17 +01:00
Excalidraw Bot f3da2a95ed New translations en.json (Punjabi) 2026-02-26 16:26:15 +01:00
Excalidraw Bot bec4653dcb New translations en.json (Lithuanian) 2026-02-26 16:26:14 +01:00
Excalidraw Bot a2e948d080 New translations en.json (Kurdish) 2026-02-26 16:26:12 +01:00
Excalidraw Bot 6b7da271b5 New translations en.json (Korean) 2026-02-26 16:26:11 +01:00
Excalidraw Bot 65c7c1815f New translations en.json (Japanese) 2026-02-26 16:26:10 +01:00
Excalidraw Bot 8c019fe3fd New translations en.json (Hungarian) 2026-02-26 16:26:08 +01:00
Excalidraw Bot 64466783de New translations en.json (Hebrew) 2026-02-26 16:26:07 +01:00
Excalidraw Bot ce3774e8f2 New translations en.json (Finnish) 2026-02-26 16:26:05 +01:00
Excalidraw Bot 5dd19a04c2 New translations en.json (Basque) 2026-02-26 16:26:04 +01:00
Excalidraw Bot 44769fe876 New translations en.json (Greek) 2026-02-26 16:26:02 +01:00
Excalidraw Bot 4947ec612c New translations en.json (German) 2026-02-26 16:26:01 +01:00
Excalidraw Bot 24b78a1332 New translations en.json (Danish) 2026-02-26 16:25:59 +01:00
Excalidraw Bot ee4f0a43fc New translations en.json (Czech) 2026-02-26 16:25:58 +01:00
Excalidraw Bot cfd9306c57 New translations en.json (Catalan) 2026-02-26 16:25:57 +01:00
Excalidraw Bot 8a3048d4a7 New translations en.json (Bulgarian) 2026-02-26 16:25:55 +01:00
Excalidraw Bot d7138d86ea New translations en.json (Arabic) 2026-02-26 16:25:54 +01:00
Excalidraw Bot b0644a6d3f New translations en.json (Spanish) 2026-02-26 16:25:52 +01:00
Excalidraw Bot 430adfe6f4 New translations en.json (Romanian) 2026-02-26 16:25:51 +01:00
Excalidraw Bot ceb6b47c17 New translations en.json (Turkish) 2026-02-26 16:25:49 +01:00
Excalidraw Bot 54973ba281 New translations en.json (French) 2026-02-26 16:25:48 +01:00
Excalidraw Bot 157b1911e1 New translations en.json (Portuguese, Brazilian) 2026-02-26 16:25:47 +01:00
Excalidraw Bot 482beddd67 New translations en.json (Chinese Traditional) 2026-02-26 16:25:46 +01:00
Excalidraw Bot 904828940d New translations en.json (Ukrainian) 2026-02-26 16:25:44 +01:00
Excalidraw Bot d0990c63db New translations en.json (Dutch) 2026-02-26 16:25:43 +01:00
Excalidraw Bot c86d22be7f New translations en.json (Italian) 2026-02-26 16:25:41 +01:00
Excalidraw Bot 7c03c458a1 New translations en.json (Russian) 2026-02-26 16:25:40 +01:00
Excalidraw Bot 8029e68e17 Auto commit: Calculate translation coverage 2026-02-26 11:56:56 +00:00
Excalidraw Bot c3e9430577 New translations en.json (Turkish) 2026-02-26 12:56:44 +01:00
Excalidraw Bot 9d34d4fcd4 New translations en.json (French) 2026-02-23 13:32:26 +01:00
Excalidraw Bot fe310acebd Auto commit: Calculate translation coverage 2026-02-22 23:34:27 +00:00
Excalidraw Bot d7c7236ee8 New translations en.json (Dutch) 2026-02-23 00:34:17 +01:00
Excalidraw Bot eefc3b9408 Auto commit: Calculate translation coverage 2026-02-21 05:58:59 +00:00
Excalidraw Bot cc67f9d544 New translations en.json (Chinese Traditional) 2026-02-21 06:58:50 +01:00
Excalidraw Bot e2e13bba0b Auto commit: Calculate translation coverage 2026-02-19 13:58:10 +00:00
Excalidraw Bot 041f012f72 New translations en.json (Chinese Traditional) 2026-02-19 14:57:56 +01:00
Excalidraw Bot dbd8cc8d6f Auto commit: Calculate translation coverage 2026-02-18 13:40:53 +00:00
Excalidraw Bot 9fbac79d11 New translations en.json (Chinese Traditional) 2026-02-18 14:40:42 +01:00
Excalidraw Bot ef915b7427 Auto commit: Calculate translation coverage 2026-02-15 09:04:40 +00:00
Excalidraw Bot c5b913ad9f New translations en.json (Italian) 2026-02-15 10:04:24 +01:00
Excalidraw Bot f66c943720 Auto commit: Calculate translation coverage 2026-02-14 08:25:14 +00:00
Excalidraw Bot 9c0edf1cb9 New translations en.json (Russian) 2026-02-14 09:25:02 +01:00
Excalidraw Bot 9112c61edb New translations en.json (Russian) 2026-02-14 07:54:58 +01:00
Excalidraw Bot f58a84a857 Auto commit: Calculate translation coverage 2026-02-13 19:42:14 +00:00
Excalidraw Bot 626e846d10 New translations en.json (Ukrainian) 2026-02-13 20:41:59 +01:00
Excalidraw Bot feacdcd156 Auto commit: Calculate translation coverage 2026-02-13 18:19:44 +00:00
Excalidraw Bot e1fbf09310 New translations en.json (Ukrainian) 2026-02-13 19:19:30 +01:00
Excalidraw Bot dc521a62c1 Auto commit: Calculate translation coverage 2026-02-13 01:22:27 +00:00
Excalidraw Bot f0bbb7614e New translations en.json (Portuguese, Brazilian) 2026-02-13 02:22:11 +01:00
Excalidraw Bot 62b35ab86c Auto commit: Calculate translation coverage 2026-02-09 06:58:01 +00:00
Excalidraw Bot 2ea8c308e4 New translations en.json (Romanian) 2026-02-09 07:57:50 +01:00
Excalidraw Bot 0a13e3a344 Auto commit: Calculate translation coverage 2026-02-08 23:25:09 +00:00
Excalidraw Bot 540809f2a4 New translations en.json (Karakalpak) 2026-02-09 00:23:37 +01:00
Excalidraw Bot 467d9bd08c New translations en.json (Kabyle) 2026-02-09 00:23:35 +01:00
Excalidraw Bot 13e8374bea New translations en.json (Bengali, India) 2026-02-09 00:23:34 +01:00
Excalidraw Bot b96dd6d332 New translations en.json (German, Switzerland) 2026-02-09 00:23:33 +01:00
Excalidraw Bot 32d77172cc New translations en.json (Occitan) 2026-02-09 00:23:32 +01:00
Excalidraw Bot 9e23421d53 New translations en.json (Norwegian Bokmal) 2026-02-09 00:23:31 +01:00
Excalidraw Bot c3d91aac44 New translations en.json (Uzbek) 2026-02-09 00:23:30 +01:00
Excalidraw Bot 99088550be New translations en.json (Sinhala) 2026-02-09 00:23:28 +01:00
Excalidraw Bot b9a171a2ef New translations en.json (Chinese Traditional, Hong Kong) 2026-02-09 00:23:27 +01:00
Excalidraw Bot 521d727b5b New translations en.json (Burmese) 2026-02-09 00:23:26 +01:00
Excalidraw Bot b7d610fbbe New translations en.json (Hindi) 2026-02-09 00:23:25 +01:00
Excalidraw Bot 1f33be6403 New translations en.json (Azerbaijani) 2026-02-09 00:23:23 +01:00
Excalidraw Bot 7ca28ae504 New translations en.json (Latvian) 2026-02-09 00:23:22 +01:00
Excalidraw Bot d648d1147e New translations en.json (Kazakh) 2026-02-09 00:23:21 +01:00
Excalidraw Bot 869739b170 New translations en.json (Norwegian Nynorsk) 2026-02-09 00:23:20 +01:00
Excalidraw Bot ca09cc2830 New translations en.json (Thai) 2026-02-09 00:23:19 +01:00
Excalidraw Bot 5d1e8448bc New translations en.json (Marathi) 2026-02-09 00:23:18 +01:00
Excalidraw Bot 2076b3643a New translations en.json (Bengali) 2026-02-09 00:23:17 +01:00
Excalidraw Bot cbe5581782 New translations en.json (Tamil) 2026-02-09 00:23:15 +01:00
Excalidraw Bot be9fcec967 New translations en.json (Khmer) 2026-02-09 00:23:14 +01:00
Excalidraw Bot 41f8203889 New translations en.json (Persian) 2026-02-09 00:23:13 +01:00
Excalidraw Bot 60be58033f New translations en.json (Indonesian) 2026-02-09 00:23:12 +01:00
Excalidraw Bot 7010f1af88 New translations en.json (Portuguese, Brazilian) 2026-02-09 00:23:10 +01:00
Excalidraw Bot edd1510183 New translations en.json (Galician) 2026-02-09 00:23:09 +01:00
Excalidraw Bot 5926023ab1 New translations en.json (Chinese Traditional) 2026-02-09 00:23:08 +01:00
Excalidraw Bot 7adc3d163f New translations en.json (Chinese Simplified) 2026-02-09 00:23:07 +01:00
Excalidraw Bot 124ecebca6 New translations en.json (Ukrainian) 2026-02-09 00:23:05 +01:00
Excalidraw Bot 8d8fdc2985 New translations en.json (Turkish) 2026-02-09 00:23:04 +01:00
Excalidraw Bot 0691617028 New translations en.json (Swedish) 2026-02-09 00:23:03 +01:00
Excalidraw Bot 3f181f7fa7 New translations en.json (Slovenian) 2026-02-09 00:23:02 +01:00
Excalidraw Bot 00f2600cbb New translations en.json (Slovak) 2026-02-09 00:23:01 +01:00
Excalidraw Bot 4274935c62 New translations en.json (Portuguese) 2026-02-09 00:23:00 +01:00
Excalidraw Bot 6209445644 New translations en.json (Polish) 2026-02-09 00:22:59 +01:00
Excalidraw Bot d9636c4101 New translations en.json (Punjabi) 2026-02-09 00:22:58 +01:00
Excalidraw Bot fc1c3a3985 New translations en.json (Dutch) 2026-02-09 00:22:56 +01:00
Excalidraw Bot 2c7737ed9b New translations en.json (Lithuanian) 2026-02-09 00:22:55 +01:00
Excalidraw Bot c4dcd3a5d2 New translations en.json (Kurdish) 2026-02-09 00:22:54 +01:00
Excalidraw Bot f0a37029bc New translations en.json (Korean) 2026-02-09 00:22:53 +01:00
Excalidraw Bot 2567526103 New translations en.json (Japanese) 2026-02-09 00:22:52 +01:00
Excalidraw Bot a10f7c10ae New translations en.json (Italian) 2026-02-09 00:22:51 +01:00
Excalidraw Bot 60dc788254 New translations en.json (Hungarian) 2026-02-09 00:22:49 +01:00
Excalidraw Bot 34ab5746b4 New translations en.json (Hebrew) 2026-02-09 00:22:48 +01:00
Excalidraw Bot 6c7b8f4bf4 New translations en.json (Finnish) 2026-02-09 00:22:47 +01:00
Excalidraw Bot 7a79d0306c New translations en.json (Basque) 2026-02-09 00:22:46 +01:00
Excalidraw Bot d70108dcfa New translations en.json (Greek) 2026-02-09 00:22:45 +01:00
Excalidraw Bot c2fc95116a New translations en.json (German) 2026-02-09 00:22:44 +01:00
Excalidraw Bot 947a11bac2 New translations en.json (Danish) 2026-02-09 00:22:42 +01:00
Excalidraw Bot 87336c115e New translations en.json (Czech) 2026-02-09 00:22:41 +01:00
Excalidraw Bot 6041c34aeb New translations en.json (Catalan) 2026-02-09 00:22:40 +01:00
Excalidraw Bot 184d9ca6e8 New translations en.json (Bulgarian) 2026-02-09 00:22:39 +01:00
Excalidraw Bot 3a844186a7 New translations en.json (Arabic) 2026-02-09 00:22:38 +01:00
Excalidraw Bot cc262ce5d5 New translations en.json (Spanish) 2026-02-09 00:22:37 +01:00
Excalidraw Bot 802b01562c New translations en.json (French) 2026-02-09 00:22:36 +01:00
Excalidraw Bot 6f2b43ff0c New translations en.json (Romanian) 2026-02-09 00:22:35 +01:00
Excalidraw Bot 461f327887 New translations en.json (Russian) 2026-02-09 00:22:33 +01:00
Excalidraw Bot a33a643a2f New translations en.json (Vietnamese) 2026-02-09 00:22:32 +01:00
Excalidraw Bot 91f7b38a39 Auto commit: Calculate translation coverage 2026-02-08 21:46:20 +00:00
Excalidraw Bot 998a296ca2 New translations en.json (Persian) 2026-02-08 22:45:54 +01:00
Excalidraw Bot 0afec1bf5c Auto commit: Calculate translation coverage 2026-02-08 16:00:00 +00:00
Excalidraw Bot 2436e8498a New translations en.json (Japanese) 2026-02-08 16:59:48 +01:00
Excalidraw Bot 521362a532 Auto commit: Calculate translation coverage 2026-02-08 15:03:07 +00:00
Excalidraw Bot ae5a2dee54 New translations en.json (Japanese) 2026-02-08 16:02:55 +01:00
Excalidraw Bot bf62bce4cb Auto commit: Calculate translation coverage 2026-02-08 13:52:29 +00:00
Excalidraw Bot 92c5acb960 New translations en.json (Japanese) 2026-02-08 14:52:19 +01:00
Excalidraw Bot d4ce7b067a New translations en.json (Japanese) 2026-02-08 13:56:44 +01:00
Excalidraw Bot 214e68ce03 Auto commit: Calculate translation coverage 2026-02-08 10:53:56 +00:00
Excalidraw Bot 19cb9ab30e New translations en.json (Italian) 2026-02-08 11:53:43 +01:00
Excalidraw Bot 3d097669f8 Auto commit: Calculate translation coverage 2026-02-05 18:24:29 +00:00
Excalidraw Bot ede77bd6ac New translations en.json (Chinese Simplified) 2026-02-05 19:24:17 +01:00
Excalidraw Bot 05dde8f0d1 Auto commit: Calculate translation coverage 2026-02-04 07:52:55 +00:00
Excalidraw Bot d11453ae15 New translations en.json (French) 2026-02-04 08:52:41 +01:00
Excalidraw Bot 2d3577bb43 Auto commit: Calculate translation coverage 2026-02-03 06:40:22 +00:00
Excalidraw Bot 4258aeac53 New translations en.json (Japanese) 2026-02-03 07:40:12 +01:00
Excalidraw Bot 4f772d77a4 New translations en.json (Japanese) 2026-02-03 06:35:52 +01:00
Excalidraw Bot 6f57935419 Auto commit: Calculate translation coverage 2026-02-02 12:15:38 +00:00
Excalidraw Bot 61c69915da New translations en.json (Japanese) 2026-02-02 13:15:26 +01:00
302 changed files with 5374 additions and 15459 deletions
+1 -1
View File
@@ -1,4 +1,4 @@
FROM node:24-bullseye FROM node:18-bullseye
# Vite wants to open the browser using `open`, so we # Vite wants to open the browser using `open`, so we
# need to install those utils. # need to install those utils.
-7
View File
@@ -1,7 +0,0 @@
# VITE_DEBUG_DOM
# When "true", testing-library failures (waitFor / getBy*) include the full
# serialized DOM in the error message. It's off by default because it's noisy.
#
# Flip it to "true" (or use `VITE_DEBUG_DOM=true yarn test`) when you need to
# inspect the DOM of a failing test.
VITE_DEBUG_DOM=false
+1 -22
View File
@@ -39,26 +39,5 @@
"allowReferrer": true "allowReferrer": true
} }
] ]
}, }
"overrides": [
{
"files": ["packages/excalidraw/**/*.{ts,tsx}"],
"excludedFiles": ["packages/excalidraw/**/*.test.{ts,tsx}", "packages/excalidraw/**/*.test.*.{ts,tsx}"],
"rules": {
"@typescript-eslint/no-restricted-imports": [
"error",
{
"patterns": [
{
"group": ["@excalidraw/excalidraw"],
"message": "Do not import from the barrel 'index.tsx' files. Use direct relative imports to the specific module instead.",
"allowTypeImports": true
}
],
"paths": [".", "..", "../..", "../../..", "../../../..", "../../../../..", "../index", "../../index", "../../../index", "../../../../index"]
}
]
}
}
]
} }
+2 -2
View File
@@ -9,11 +9,11 @@ jobs:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 - uses: actions/checkout@v2
with: with:
fetch-depth: 2 fetch-depth: 2
- name: Setup Node.js - name: Setup Node.js
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4 uses: actions/setup-node@v2
with: with:
node-version: 20.x node-version: 20.x
- name: Set up publish access - name: Set up publish access
+1 -1
View File
@@ -9,5 +9,5 @@ jobs:
build-docker: build-docker:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 - uses: actions/checkout@v2
- run: docker build -t excalidraw . - run: docker build -t excalidraw .
+1 -1
View File
@@ -11,7 +11,7 @@ jobs:
runs-on: ubuntu-latest runs-on: ubuntu-latest
timeout-minutes: 3 timeout-minutes: 3
steps: steps:
- uses: styfle/cancel-workflow-action@ce177499ccf9fd2aded3b0426c97e5434c2e8a73 # 0.6.0 - uses: styfle/cancel-workflow-action@0.6.0
with: with:
workflow_id: 400555, 400556, 905313, 1451724, 1710116, 3185001, 3438604 workflow_id: 400555, 400556, 905313, 1451724, 1710116, 3185001, 3438604
access_token: ${{ secrets.GITHUB_TOKEN }} access_token: ${{ secrets.GITHUB_TOKEN }}
+2 -2
View File
@@ -7,10 +7,10 @@ jobs:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 - uses: actions/checkout@v2
- name: Setup Node.js - name: Setup Node.js
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4 uses: actions/setup-node@v2
with: with:
node-version: 20.x node-version: 20.x
+3 -3
View File
@@ -10,12 +10,12 @@ jobs:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 - uses: actions/checkout@v4
with: with:
token: ${{ secrets.PUSH_TRANSLATIONS_COVERAGE_PAT }} token: ${{ secrets.PUSH_TRANSLATIONS_COVERAGE_PAT }}
- name: Setup Node.js - name: Setup Node.js
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4 uses: actions/setup-node@v2
with: with:
node-version: 20.x node-version: 20.x
@@ -40,7 +40,7 @@ jobs:
echo ::set-output name=body::$body echo ::set-output name=body::$body
- name: Update description with coverage - name: Update description with coverage
uses: kt3k/update-pr-description@1b35a6dcd84d81aa0bc1889610efdcde7f37b0c0 # v1.0.1 uses: kt3k/update-pr-description@v1.0.1
with: with:
pr_body: ${{ steps.getCommentBody.outputs.body }} pr_body: ${{ steps.getCommentBody.outputs.body }}
pr_title: "chore: Update translations from Crowdin" pr_title: "chore: Update translations from Crowdin"
+5 -5
View File
@@ -11,18 +11,18 @@ jobs:
steps: steps:
- name: Checkout repository - name: Checkout repository
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 uses: actions/checkout@v3
- name: Login to DockerHub - name: Login to DockerHub
uses: docker/login-action@465a07811f14bebb1938fbed4728c6a1ff8901fc # v2 uses: docker/login-action@v2
with: with:
username: ${{ secrets.DOCKER_USERNAME }} username: ${{ secrets.DOCKER_USERNAME }}
password: ${{ secrets.DOCKER_PASSWORD }} password: ${{ secrets.DOCKER_PASSWORD }}
- name: Set up QEMU - name: Set up QEMU
uses: docker/setup-qemu-action@c7c53464625b32c7a7e944ae62b3e17d2b600130 # v3 uses: docker/setup-qemu-action@v3
- name: Set up Docker Buildx - name: Set up Docker Buildx
uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3 uses: docker/setup-buildx-action@v3
- name: Build and push - name: Build and push
uses: docker/build-push-action@ca052bb54ab0790a636c9b5f226502c73d547a25 # v5 uses: docker/build-push-action@v5
with: with:
context: . context: .
push: true push: true
+1 -87
View File
@@ -6,97 +6,11 @@ on:
- opened - opened
- edited - edited
- synchronize - synchronize
- labeled
- unlabeled
jobs: jobs:
semantic: semantic:
runs-on: ubuntu-latest runs-on: ubuntu-latest
permissions:
pull-requests: read
steps: steps:
- uses: amannn/action-semantic-pull-request@e32d7e603df1aa1ba07e981f2a23455dee596825 # v5 - uses: amannn/action-semantic-pull-request@v5
with:
requireScope: true
scopes: |
app
editor
packages/excalidraw
packages/utils
docker
repo
ignoreLabels: |
skip-semantic-title
env: env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
label-scope:
needs: semantic
if: github.event.pull_request.head.repo.full_name == github.repository
runs-on: ubuntu-latest
permissions:
issues: write
pull-requests: write
steps:
- name: Label scoped PR
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
PR_NUMBER: ${{ github.event.pull_request.number }}
PR_TITLE: ${{ github.event.pull_request.title }}
REPOSITORY: ${{ github.repository }}
run: |
set -euo pipefail
scope_labels=(s-app s-editor s-package)
readarray -t desired_labels < <(
node <<'NODE'
const title = process.env.PR_TITLE;
const match = title.match(/^[a-z]+(?:\(([^)]+)\))?!?:/i);
const scopes = match?.[1]?.split(",").map((scope) => scope.trim()) ?? [];
const labels = new Set();
for (const scope of scopes) {
if (scope === "app") {
labels.add("s-app");
} else if (scope === "editor") {
labels.add("s-editor");
} else if (scope.startsWith("packages/")) {
labels.add("s-package");
}
}
process.stdout.write([...labels].join("\n"));
NODE
)
should_apply_label() {
local label="$1"
for desired_label in "${desired_labels[@]}"; do
if [[ "$desired_label" == "$label" ]]; then
return 0
fi
done
return 1
}
for label in "${scope_labels[@]}"; do
if ! should_apply_label "$label"; then
gh api \
--method DELETE \
"repos/${REPOSITORY}/issues/${PR_NUMBER}/labels/${label}" \
--silent 2>/dev/null || true
fi
done
for label in "${desired_labels[@]}"; do
if ! gh api \
--method POST \
"repos/${REPOSITORY}/issues/${PR_NUMBER}/labels" \
--field "labels[]=${label}" \
--silent; then
echo "::warning::Could not apply ${label}. The workflow token likely does not have issues:write permission for this PR."
fi
done
+2 -2
View File
@@ -9,9 +9,9 @@ jobs:
sentry: sentry:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 - uses: actions/checkout@v2
- name: Setup Node.js - name: Setup Node.js
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4 uses: actions/setup-node@v2
with: with:
node-version: 20.x node-version: 20.x
- name: Install and build - name: Install and build
+3 -3
View File
@@ -10,9 +10,9 @@ jobs:
CI_JOB_NUMBER: 1 CI_JOB_NUMBER: 1
steps: steps:
- name: Checkout repository - name: Checkout repository
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 uses: actions/checkout@v3
- name: Setup Node.js - name: Setup Node.js
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4 uses: actions/setup-node@v3
with: with:
node-version: 20.x node-version: 20.x
- name: Install in packages/excalidraw - name: Install in packages/excalidraw
@@ -20,7 +20,7 @@ jobs:
working-directory: packages/excalidraw working-directory: packages/excalidraw
env: env:
CI: true CI: true
- uses: andresz1/size-limit-action@e7493a72a44b113341c0cf6186ab49c17c4b65c1 # v1 - uses: andresz1/size-limit-action@v1
with: with:
github_token: ${{ secrets.GITHUB_TOKEN }} github_token: ${{ secrets.GITHUB_TOKEN }}
build_script: build:esm build_script: build:esm
+3 -3
View File
@@ -10,9 +10,9 @@ jobs:
pull-requests: write pull-requests: write
steps: steps:
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 - uses: actions/checkout@v2
- name: "Install Node" - name: "Install Node"
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4 uses: actions/setup-node@v2
with: with:
node-version: "20.x" node-version: "20.x"
- name: "Install Deps" - name: "Install Deps"
@@ -21,6 +21,6 @@ jobs:
run: yarn test:coverage run: yarn test:coverage
- name: "Report Coverage" - name: "Report Coverage"
if: always() # Also generate the report if tests are failing if: always() # Also generate the report if tests are failing
uses: davelosert/vitest-coverage-report-action@2500dafcee7dd64f85ab689c0b83798a8359770e # v2 uses: davelosert/vitest-coverage-report-action@v2
with: with:
github-token: ${{ secrets.GITHUB_TOKEN }} github-token: ${{ secrets.GITHUB_TOKEN }}
+2 -2
View File
@@ -8,9 +8,9 @@ jobs:
test: test:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 - uses: actions/checkout@v4
- name: Setup Node.js - name: Setup Node.js
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4 uses: actions/setup-node@v4
with: with:
node-version: 20.x node-version: 20.x
- name: Install and test - name: Install and test
+3 -3
View File
@@ -1,4 +1,4 @@
FROM --platform=${BUILDPLATFORM} node:24@sha256:8530f76a96d88820d288761f022e318970dda93d01536919fbc16076b7983e63 AS build FROM --platform=${BUILDPLATFORM} node:18 AS build
WORKDIR /opt/node_app WORKDIR /opt/node_app
@@ -7,13 +7,13 @@ COPY . .
# do not ignore optional dependencies: # do not ignore optional dependencies:
# Error: Cannot find module @rollup/rollup-linux-x64-gnu # Error: Cannot find module @rollup/rollup-linux-x64-gnu
RUN --mount=type=cache,target=/root/.cache/yarn \ RUN --mount=type=cache,target=/root/.cache/yarn \
npm_config_target_arch=${TARGETARCH} yarn --frozen-lockfile --network-timeout 600000 npm_config_target_arch=${TARGETARCH} yarn --network-timeout 600000
ARG NODE_ENV=production ARG NODE_ENV=production
RUN npm_config_target_arch=${TARGETARCH} yarn build:app:docker RUN npm_config_target_arch=${TARGETARCH} yarn build:app:docker
FROM nginx:stable-alpine-slim@sha256:2c605dbeab79a6b2a63340474fe58119d0ef95bdc4b1f41df0aa689659b3d13b FROM --platform=${TARGETPLATFORM} nginx:1.27-alpine
COPY --from=build /opt/node_app/excalidraw-app/build /usr/share/nginx/html COPY --from=build /opt/node_app/excalidraw-app/build /usr/share/nginx/html
+1 -1
View File
@@ -29,7 +29,7 @@
<a href="https://docs.excalidraw.com/docs/introduction/contributing"> <a href="https://docs.excalidraw.com/docs/introduction/contributing">
<img alt="PRs welcome!" src="https://img.shields.io/badge/PRs-welcome-brightgreen.svg?style=flat" /></a> <img alt="PRs welcome!" src="https://img.shields.io/badge/PRs-welcome-brightgreen.svg?style=flat" /></a>
<a href="https://discord.gg/UexuTaE"> <a href="https://discord.gg/UexuTaE">
<img alt="Chat on Discord" src="https://img.shields.io/discord/723672430744174682?color=738ad6&label=Chat%20on%20Discord&logo=discord&logoColor=ffffff&widget=false"/></a> <img alt="Chat on Discord" src="https://img.shields.io/discord/723672430744174682?color=738ad6&label=Chat%20on%20Discord&logo=discord&logoColor=ffffff&widge=false"/></a>
<a href="https://deepwiki.com/excalidraw/excalidraw"> <a href="https://deepwiki.com/excalidraw/excalidraw">
<img alt="Ask DeepWiki" src="https://deepwiki.com/badge.svg" /></a> <img alt="Ask DeepWiki" src="https://deepwiki.com/badge.svg" /></a>
<a href="https://twitter.com/excalidraw"> <a href="https://twitter.com/excalidraw">
@@ -172,7 +172,7 @@ convertToExcalidrawElements([
type: "arrow", type: "arrow",
x: 450, x: 450,
y: 20, y: 20,
startArrowhead: "circle", startArrowhead: "dot",
endArrowhead: "triangle", endArrowhead: "triangle",
strokeColor: "#1971c2", strokeColor: "#1971c2",
strokeWidth: 2, strokeWidth: 2,
+2 -2
View File
@@ -97,8 +97,8 @@ const config = {
href: "https://discord.gg/UexuTaE", href: "https://discord.gg/UexuTaE",
}, },
{ {
label: "𝕏", label: "Twitter",
href: "https://x.com/excalidraw", href: "https://twitter.com/excalidraw",
}, },
{ {
label: "Linkedin", label: "Linkedin",
@@ -1,4 +1,4 @@
import type { ExcalidrawElementSkeleton } from "@excalidraw/excalidraw/element/transform"; import type { ExcalidrawElementSkeleton } from "@excalidraw/excalidraw/data/transform";
import type { FileId } from "@excalidraw/excalidraw/element/types"; import type { FileId } from "@excalidraw/excalidraw/element/types";
const elements: ExcalidrawElementSkeleton[] = [ const elements: ExcalidrawElementSkeleton[] = [
+5 -5
View File
@@ -3,14 +3,14 @@
"version": "1.0.0", "version": "1.0.0",
"private": true, "private": true,
"dependencies": { "dependencies": {
"@excalidraw/excalidraw": "*",
"browser-fs-access": "0.38.0",
"react": "19.0.0", "react": "19.0.0",
"react-dom": "19.0.0" "react-dom": "19.0.0",
"@excalidraw/excalidraw": "*",
"browser-fs-access": "0.29.1"
}, },
"devDependencies": { "devDependencies": {
"typescript": "^5", "vite": "5.0.12",
"vite": "5.0.12" "typescript": "^5"
}, },
"scripts": { "scripts": {
"start": "vite", "start": "vite",
+36
View File
@@ -4,6 +4,8 @@ import { unstable_batchedUpdates } from "react-dom";
type FILE_EXTENSION = Exclude<keyof typeof MIME_TYPES, "binary">; type FILE_EXTENSION = Exclude<keyof typeof MIME_TYPES, "binary">;
const INPUT_CHANGE_INTERVAL_MS = 500;
export type ResolvablePromise<T> = Promise<T> & { export type ResolvablePromise<T> = Promise<T> & {
resolve: [T] extends [undefined] ? (value?: T) => void : (value: T) => void; resolve: [T] extends [undefined] ? (value?: T) => void : (value: T) => void;
reject: (error: Error) => void; reject: (error: Error) => void;
@@ -52,6 +54,40 @@ export const fileOpen = <M extends boolean | undefined = false>(opts: {
extensions, extensions,
mimeTypes, mimeTypes,
multiple: opts.multiple ?? false, multiple: opts.multiple ?? false,
legacySetup: (resolve, reject, input) => {
const scheduleRejection = debounce(reject, INPUT_CHANGE_INTERVAL_MS);
const focusHandler = () => {
checkForFile();
document.addEventListener("keyup", scheduleRejection);
document.addEventListener("pointerup", scheduleRejection);
scheduleRejection();
};
const checkForFile = () => {
// this hack might not work when expecting multiple files
if (input.files?.length) {
const ret = opts.multiple ? [...input.files] : input.files[0];
resolve(ret as RetType);
}
};
requestAnimationFrame(() => {
window.addEventListener("focus", focusHandler);
});
const interval = window.setInterval(() => {
checkForFile();
}, INPUT_CHANGE_INTERVAL_MS);
return (rejectPromise) => {
clearInterval(interval);
scheduleRejection.cancel();
window.removeEventListener("focus", focusHandler);
document.removeEventListener("keyup", scheduleRejection);
document.removeEventListener("pointerup", scheduleRejection);
if (rejectPromise) {
// so that something is shown in console if we need to debug this
console.warn("Opening the file was canceled (legacy-fs).");
rejectPromise(new Error("Request Aborted"));
}
};
},
}) as Promise<RetType>; }) as Promise<RetType>;
}; };
+30 -93
View File
@@ -5,8 +5,6 @@ import {
CaptureUpdateAction, CaptureUpdateAction,
reconcileElements, reconcileElements,
useEditorInterface, useEditorInterface,
ExcalidrawAPIProvider,
useExcalidrawAPI,
} from "@excalidraw/excalidraw"; } from "@excalidraw/excalidraw";
import { trackEvent } from "@excalidraw/excalidraw/analytics"; import { trackEvent } from "@excalidraw/excalidraw/analytics";
import { getDefaultAppState } from "@excalidraw/excalidraw/appState"; import { getDefaultAppState } from "@excalidraw/excalidraw/appState";
@@ -22,6 +20,7 @@ import Trans from "@excalidraw/excalidraw/components/Trans";
import { import {
APP_NAME, APP_NAME,
EVENT, EVENT,
THEME,
VERSION_TIMEOUT, VERSION_TIMEOUT,
debounce, debounce,
getVersion, getVersion,
@@ -35,6 +34,7 @@ import {
import polyfill from "@excalidraw/excalidraw/polyfill"; import polyfill from "@excalidraw/excalidraw/polyfill";
import { useCallback, useEffect, useRef, useState } from "react"; import { useCallback, useEffect, useRef, useState } from "react";
import { loadFromBlob } from "@excalidraw/excalidraw/data/blob"; import { loadFromBlob } from "@excalidraw/excalidraw/data/blob";
import { useCallbackRefState } from "@excalidraw/excalidraw/hooks/useCallbackRefState";
import { t } from "@excalidraw/excalidraw/i18n"; import { t } from "@excalidraw/excalidraw/i18n";
import { import {
@@ -74,7 +74,6 @@ import type {
BinaryFiles, BinaryFiles,
ExcalidrawInitialDataState, ExcalidrawInitialDataState,
UIAppState, UIAppState,
ExcalidrawProps,
} from "@excalidraw/excalidraw/types"; } from "@excalidraw/excalidraw/types";
import type { ResolutionType } from "@excalidraw/common/utility-types"; import type { ResolutionType } from "@excalidraw/common/utility-types";
import type { ResolvablePromise } from "@excalidraw/common/utils"; import type { ResolvablePromise } from "@excalidraw/common/utils";
@@ -115,7 +114,6 @@ import {
} from "./data"; } from "./data";
import { updateStaleImageStatuses } from "./data/FileManager"; import { updateStaleImageStatuses } from "./data/FileManager";
import { FileStatusStore } from "./data/fileStatusStore";
import { import {
importFromLocalStorage, importFromLocalStorage,
importUsernameFromLocalStorage, importUsernameFromLocalStorage,
@@ -371,8 +369,6 @@ const initializeScene = async (opts: {
}; };
const ExcalidrawWrapper = () => { const ExcalidrawWrapper = () => {
const excalidrawAPI = useExcalidrawAPI();
const [errorMessage, setErrorMessage] = useState(""); const [errorMessage, setErrorMessage] = useState("");
const isCollabDisabled = isRunningInIframe(); const isCollabDisabled = isRunningInIframe();
@@ -403,6 +399,9 @@ const ExcalidrawWrapper = () => {
}, VERSION_TIMEOUT); }, VERSION_TIMEOUT);
}, []); }, []);
const [excalidrawAPI, excalidrawRefCallback] =
useCallbackRefState<ExcalidrawImperativeAPI>();
const [, setShareDialogState] = useAtom(shareDialogStateAtom); const [, setShareDialogState] = useAtom(shareDialogStateAtom);
const [collabAPI] = useAtom(collabAPIAtom); const [collabAPI] = useAtom(collabAPIAtom);
const [isCollaborating] = useAtomWithInitialValue(isCollaboratingAtom, () => { const [isCollaborating] = useAtomWithInitialValue(isCollaboratingAtom, () => {
@@ -434,15 +433,18 @@ const ExcalidrawWrapper = () => {
} }
}, [excalidrawAPI]); }, [excalidrawAPI]);
// --------------------------------------------------------------------------- useEffect(() => {
// Hoisted loadImages if (!excalidrawAPI || (!isCollabDisabled && !collabAPI)) {
// --------------------------------------------------------------------------- return;
const loadImages = useCallback( }
(data: ResolutionType<typeof initializeScene>, isInitialLoad = false) => {
if (!data.scene || !excalidrawAPI) { const loadImages = (
data: ResolutionType<typeof initializeScene>,
isInitialLoad = false,
) => {
if (!data.scene) {
return; return;
} }
if (collabAPI?.isCollaborating()) { if (collabAPI?.isCollaborating()) {
if (data.scene.elements) { if (data.scene.elements) {
collabAPI collabAPI
@@ -469,12 +471,6 @@ const ExcalidrawWrapper = () => {
}, [] as FileId[]) || []; }, [] as FileId[]) || [];
if (data.isExternalScene) { if (data.isExternalScene) {
if (fileIds.length) {
// Direct Firebase call (not through FileManager), so track manually
FileStatusStore.updateStatuses(
fileIds.map((id) => [id, "loading"]),
);
}
loadFilesFromFirebase( loadFilesFromFirebase(
`${FIREBASE_STORAGE_PREFIXES.shareLinkFiles}/${data.id}`, `${FIREBASE_STORAGE_PREFIXES.shareLinkFiles}/${data.id}`,
data.key, data.key,
@@ -486,18 +482,12 @@ const ExcalidrawWrapper = () => {
erroredFiles, erroredFiles,
elements: excalidrawAPI.getSceneElementsIncludingDeleted(), elements: excalidrawAPI.getSceneElementsIncludingDeleted(),
}); });
FileStatusStore.updateStatuses([
...loadedFiles.map((f) => [f.id, "loaded"] as [FileId, "loaded"]),
...[...erroredFiles.keys()].map(
(id) => [id, "error"] as [FileId, "error"],
),
]);
}); });
} else if (isInitialLoad) { } else if (isInitialLoad) {
if (fileIds.length) { if (fileIds.length) {
LocalData.fileStorage LocalData.fileStorage
.getFiles(fileIds) .getFiles(fileIds)
.then(async ({ loadedFiles, erroredFiles }) => { .then(({ loadedFiles, erroredFiles }) => {
if (loadedFiles.length) { if (loadedFiles.length) {
excalidrawAPI.addFiles(loadedFiles); excalidrawAPI.addFiles(loadedFiles);
} }
@@ -510,19 +500,10 @@ const ExcalidrawWrapper = () => {
} }
// on fresh load, clear unused files from IDB (from previous // on fresh load, clear unused files from IDB (from previous
// session) // session)
LocalData.fileStorage.clearObsoleteFiles({ LocalData.fileStorage.clearObsoleteFiles({ currentFileIds: fileIds });
currentFileIds: fileIds,
});
} }
} }
}, };
[collabAPI, excalidrawAPI],
);
useEffect(() => {
if (!excalidrawAPI || (!isCollabDisabled && !collabAPI)) {
return;
}
initializeScene({ collabAPI, excalidrawAPI }).then(async (data) => { initializeScene({ collabAPI, excalidrawAPI }).then(async (data) => {
loadImages(data, /* isInitialLoad */ true); loadImages(data, /* isInitialLoad */ true);
@@ -647,7 +628,7 @@ const ExcalidrawWrapper = () => {
false, false,
); );
}; };
}, [isCollabDisabled, collabAPI, excalidrawAPI, setLangCode, loadImages]); }, [isCollabDisabled, collabAPI, excalidrawAPI, setLangCode]);
useEffect(() => { useEffect(() => {
const unloadHandler = (event: BeforeUnloadEvent) => { const unloadHandler = (event: BeforeUnloadEvent) => {
@@ -792,56 +773,6 @@ const ExcalidrawWrapper = () => {
[setShareDialogState], [setShareDialogState],
); );
// ---------------------------------------------------------------------------
// onExport — intercepts file save to wait for pending image loads
// ---------------------------------------------------------------------------
const onExport: Required<ExcalidrawProps>["onExport"] = useCallback(
async function* () {
let snapshot = FileStatusStore.getSnapshot();
const { pending, total } = FileStatusStore.getPendingCount(
snapshot.value,
);
if (pending === 0) {
return;
}
// Yield initial progress
yield {
type: "progress",
progress: (total - pending) / total,
message: `Loading images (${total - pending}/${total})...`,
};
// Wait for all pending images to finish
while (true) {
snapshot = await FileStatusStore.pull(snapshot.version);
const { pending: nowPending, total: nowTotal } =
FileStatusStore.getPendingCount(snapshot.value);
yield {
type: "progress",
progress: (nowTotal - nowPending) / nowTotal,
message: `Loading images (${nowTotal - nowPending}/${nowTotal})...`,
};
if (nowPending === 0) {
await new Promise((r) => setTimeout(r, 500));
yield {
type: "progress",
message: `Preparing export...`,
};
return;
}
}
},
[],
);
// const onExport = () => {
// return new Promise((r) => setTimeout(r, 2500));
// // console.log("onExport");
// };
// browsers generally prevent infinite self-embedding, there are // browsers generally prevent infinite self-embedding, there are
// cases where it still happens, and while we disallow self-embedding // cases where it still happens, and while we disallow self-embedding
// by not whitelisting our own origin, this serves as an additional guard // by not whitelisting our own origin, this serves as an additional guard
@@ -908,8 +839,8 @@ const ExcalidrawWrapper = () => {
})} })}
> >
<Excalidraw <Excalidraw
excalidrawAPI={excalidrawRefCallback}
onChange={onChange} onChange={onChange}
onExport={onExport}
initialData={initialStatePromiseRef.current.promise} initialData={initialStatePromiseRef.current.promise}
isCollaborating={isCollaborating} isCollaborating={isCollaborating}
onPointerUpdate={collabAPI?.onPointerUpdate} onPointerUpdate={collabAPI?.onPointerUpdate}
@@ -951,7 +882,6 @@ const ExcalidrawWrapper = () => {
handleKeyboardGlobally={true} handleKeyboardGlobally={true}
autoFocus={true} autoFocus={true}
theme={editorTheme} theme={editorTheme}
onThemeChange={setAppTheme}
renderTopRightUI={(isMobile) => { renderTopRightUI={(isMobile) => {
if (isMobile || !collabAPI || isCollabDisabled) { if (isMobile || !collabAPI || isCollabDisabled) {
return null; return null;
@@ -988,6 +918,7 @@ const ExcalidrawWrapper = () => {
isCollaborating={isCollaborating} isCollaborating={isCollaborating}
isCollabEnabled={!isCollabDisabled} isCollabEnabled={!isCollabDisabled}
theme={appTheme} theme={appTheme}
setTheme={(theme) => setAppTheme(theme)}
refresh={() => forceRefresh((prev) => !prev)} refresh={() => forceRefresh((prev) => !prev)}
/> />
<AppWelcomeScreen <AppWelcomeScreen
@@ -1228,6 +1159,14 @@ const ExcalidrawWrapper = () => {
} }
}, },
}, },
{
...CommandPalette.defaultItems.toggleTheme,
perform: () => {
setAppTheme(
editorTheme === THEME.DARK ? THEME.LIGHT : THEME.DARK,
);
},
},
{ {
label: t("labels.installPWA"), label: t("labels.installPWA"),
category: DEFAULT_CATEGORIES.app, category: DEFAULT_CATEGORIES.app,
@@ -1267,9 +1206,7 @@ const ExcalidrawApp = () => {
return ( return (
<TopErrorBoundary> <TopErrorBoundary>
<Provider store={appJotaiStore}> <Provider store={appJotaiStore}>
<ExcalidrawAPIProvider> <ExcalidrawWrapper />
<ExcalidrawWrapper />
</ExcalidrawAPIProvider>
</Provider> </Provider>
</TopErrorBoundary> </TopErrorBoundary>
); );
-2
View File
@@ -72,7 +72,6 @@ import {
FileManager, FileManager,
updateStaleImageStatuses, updateStaleImageStatuses,
} from "../data/FileManager"; } from "../data/FileManager";
import { FileStatusStore } from "../data/fileStatusStore";
import { LocalData } from "../data/LocalData"; import { LocalData } from "../data/LocalData";
import { import {
isSavedToFirebase, isSavedToFirebase,
@@ -150,7 +149,6 @@ class Collab extends PureComponent<CollabProps, CollabState> {
}; };
this.portal = new Portal(this); this.portal = new Portal(this);
this.fileManager = new FileManager({ this.fileManager = new FileManager({
onFileStatusChange: FileStatusStore.updateStatuses.bind(FileStatusStore),
getFiles: async (fileIds) => { getFiles: async (fileIds) => {
const { roomId, roomKey } = this.portal; const { roomId, roomKey } = this.portal;
if (!roomId || !roomKey) { if (!roomId || !roomKey) {
+6 -1
View File
@@ -20,6 +20,7 @@ export const AppMainMenu: React.FC<{
isCollaborating: boolean; isCollaborating: boolean;
isCollabEnabled: boolean; isCollabEnabled: boolean;
theme: Theme | "system"; theme: Theme | "system";
setTheme: (theme: Theme | "system") => void;
refresh: () => void; refresh: () => void;
}> = React.memo((props) => { }> = React.memo((props) => {
return ( return (
@@ -77,7 +78,11 @@ export const AppMainMenu: React.FC<{
)} )}
<MainMenu.Separator /> <MainMenu.Separator />
<MainMenu.DefaultItems.Preferences /> <MainMenu.DefaultItems.Preferences />
<MainMenu.DefaultItems.ToggleTheme allowSystemTheme theme={props.theme} /> <MainMenu.DefaultItems.ToggleTheme
allowSystemTheme
theme={props.theme}
onSelect={props.setTheme}
/>
<MainMenu.ItemCustom> <MainMenu.ItemCustom>
<LanguageList style={{ width: "100%" }} /> <LanguageList style={{ width: "100%" }} />
</MainMenu.ItemCustom> </MainMenu.ItemCustom>
@@ -414,6 +414,7 @@ export const debugRenderer = throttleRAF(
) => { ) => {
_debugRenderer(canvas, appState, elements, scale); _debugRenderer(canvas, appState, elements, scale);
}, },
{ trailing: true },
); );
export const loadSavedDebugState = () => { export const loadSavedDebugState = () => {
-22
View File
@@ -40,12 +40,10 @@ export class FileManager {
private _getFiles; private _getFiles;
private _saveFiles; private _saveFiles;
private _onFileStatusChange;
constructor({ constructor({
getFiles, getFiles,
saveFiles, saveFiles,
onFileStatusChange,
}: { }: {
getFiles: (fileIds: FileId[]) => Promise<{ getFiles: (fileIds: FileId[]) => Promise<{
loadedFiles: BinaryFileData[]; loadedFiles: BinaryFileData[];
@@ -55,13 +53,9 @@ export class FileManager {
savedFiles: Map<FileId, BinaryFileData>; savedFiles: Map<FileId, BinaryFileData>;
erroredFiles: Map<FileId, BinaryFileData>; erroredFiles: Map<FileId, BinaryFileData>;
}>; }>;
onFileStatusChange?: (
updates: Array<[FileId, "loading" | "loaded" | "error"]>,
) => void;
}) { }) {
this._getFiles = getFiles; this._getFiles = getFiles;
this._saveFiles = saveFiles; this._saveFiles = saveFiles;
this._onFileStatusChange = onFileStatusChange;
} }
/** /**
@@ -152,8 +146,6 @@ export class FileManager {
this.fetchingFiles.set(id, true); this.fetchingFiles.set(id, true);
} }
this._onFileStatusChange?.(ids.map((id) => [id, "loading"]));
try { try {
const { loadedFiles, erroredFiles } = await this._getFiles(ids); const { loadedFiles, erroredFiles } = await this._getFiles(ids);
@@ -164,13 +156,6 @@ export class FileManager {
this.erroredFiles_fetch.set(fileId, true); this.erroredFiles_fetch.set(fileId, true);
} }
this._onFileStatusChange?.([
...loadedFiles.map((f) => [f.id, "loaded"] as [FileId, "loaded"]),
...[...erroredFiles.keys()].map(
(id) => [id, "error"] as [FileId, "error"],
),
]);
return { loadedFiles, erroredFiles }; return { loadedFiles, erroredFiles };
} finally { } finally {
for (const id of ids) { for (const id of ids) {
@@ -210,13 +195,6 @@ export class FileManager {
}; };
reset() { reset() {
if (this._onFileStatusChange && this.fetchingFiles.size) {
this._onFileStatusChange(
[...this.fetchingFiles.keys()].map(
(id) => [id, "error"] as [FileId, "error"],
),
);
}
this.fetchingFiles.clear(); this.fetchingFiles.clear();
this.savingFiles.clear(); this.savingFiles.clear();
this.savedFiles.clear(); this.savedFiles.clear();
+1 -3
View File
@@ -26,6 +26,7 @@ import {
get, get,
} from "idb-keyval"; } from "idb-keyval";
import { appJotaiStore, atom } from "excalidraw-app/app-jotai";
import { getNonDeletedElements } from "@excalidraw/element"; import { getNonDeletedElements } from "@excalidraw/element";
import type { LibraryPersistedData } from "@excalidraw/excalidraw/data/library"; import type { LibraryPersistedData } from "@excalidraw/excalidraw/data/library";
@@ -38,11 +39,9 @@ import type {
} from "@excalidraw/excalidraw/types"; } from "@excalidraw/excalidraw/types";
import type { MaybePromise } from "@excalidraw/common/utility-types"; import type { MaybePromise } from "@excalidraw/common/utility-types";
import { appJotaiStore, atom } from "../app-jotai";
import { SAVE_TO_LOCAL_STORAGE_TIMEOUT, STORAGE_KEYS } from "../app_constants"; import { SAVE_TO_LOCAL_STORAGE_TIMEOUT, STORAGE_KEYS } from "../app_constants";
import { FileManager } from "./FileManager"; import { FileManager } from "./FileManager";
import { FileStatusStore } from "./fileStatusStore";
import { Locker } from "./Locker"; import { Locker } from "./Locker";
import { updateBrowserStateVersion } from "./tabSync"; import { updateBrowserStateVersion } from "./tabSync";
@@ -167,7 +166,6 @@ export class LocalData {
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
static fileStorage = new LocalFileManager({ static fileStorage = new LocalFileManager({
onFileStatusChange: FileStatusStore.updateStatuses.bind(FileStatusStore),
getFiles(ids) { getFiles(ids) {
return getMany(ids, filesStore).then( return getMany(ids, filesStore).then(
async (filesData: (BinaryFileData | undefined)[]) => { async (filesData: (BinaryFileData | undefined)[]) => {
-48
View File
@@ -1,48 +0,0 @@
import { VersionedSnapshotStore } from "@excalidraw/common";
import type { FileId } from "@excalidraw/element/types";
export type FileLoadingStatus = "loading" | "loaded" | "error";
export class FileStatusStore {
private static store = new VersionedSnapshotStore<
Map<FileId, FileLoadingStatus>
>(new Map());
static getSnapshot() {
return this.store.getSnapshot();
}
static pull(sinceVersion?: number) {
return this.store.pull(sinceVersion);
}
static updateStatuses(updates: Array<[FileId, FileLoadingStatus]>) {
if (!updates.length) {
return;
}
this.store.update((prev) => {
let changed = false;
const next = new Map(prev);
for (const [id, status] of updates) {
if (next.get(id) !== status) {
next.set(id, status);
changed = true;
}
}
return changed ? next : prev;
});
}
static getPendingCount(statuses: Map<FileId, FileLoadingStatus>) {
let pending = 0;
let total = 0;
for (const status of statuses.values()) {
total++;
if (status === "loading") {
pending++;
}
}
return { pending, total };
}
}
+20 -1
View File
@@ -1,4 +1,5 @@
import { THEME } from "@excalidraw/excalidraw"; import { THEME } from "@excalidraw/excalidraw";
import { EVENT, CODES, KEYS } from "@excalidraw/common";
import { useEffect, useLayoutEffect, useState } from "react"; import { useEffect, useLayoutEffect, useState } from "react";
import type { Theme } from "@excalidraw/element/types"; import type { Theme } from "@excalidraw/element/types";
@@ -30,10 +31,28 @@ export const useHandleAppTheme = () => {
mediaQuery?.addEventListener("change", handleChange); mediaQuery?.addEventListener("change", handleChange);
} }
const handleKeydown = (event: KeyboardEvent) => {
if (
!event[KEYS.CTRL_OR_CMD] &&
event.altKey &&
event.shiftKey &&
event.code === CODES.D
) {
event.preventDefault();
event.stopImmediatePropagation();
setAppTheme(editorTheme === THEME.DARK ? THEME.LIGHT : THEME.DARK);
}
};
document.addEventListener(EVENT.KEYDOWN, handleKeydown, { capture: true });
return () => { return () => {
mediaQuery?.removeEventListener("change", handleChange); mediaQuery?.removeEventListener("change", handleChange);
document.removeEventListener(EVENT.KEYDOWN, handleKeydown, {
capture: true,
});
}; };
}, [appTheme]); }, [appTheme, editorTheme, setAppTheme]);
useLayoutEffect(() => { useLayoutEffect(() => {
localStorage.setItem(STORAGE_KEYS.LOCAL_STORAGE_THEME, appTheme); localStorage.setItem(STORAGE_KEYS.LOCAL_STORAGE_THEME, appTheme);
+1 -24
View File
@@ -75,20 +75,6 @@ export default defineConfig(({ mode }) => {
find: /^@excalidraw\/utils\/(.*?)/, find: /^@excalidraw\/utils\/(.*?)/,
replacement: path.resolve(__dirname, "../packages/utils/src/$1"), replacement: path.resolve(__dirname, "../packages/utils/src/$1"),
}, },
{
find: /^@excalidraw\/fractional-indexing$/,
replacement: path.resolve(
__dirname,
"../packages/fractional-indexing/src/index.ts",
),
},
{
find: /^@excalidraw\/laser-pointer$/,
replacement: path.resolve(
__dirname,
"../packages/laser-pointer/src/index.ts",
),
},
], ],
}, },
build: { build: {
@@ -120,10 +106,6 @@ export default defineConfig(({ mode }) => {
if (id.includes("@excalidraw/mermaid-to-excalidraw")) { if (id.includes("@excalidraw/mermaid-to-excalidraw")) {
return "mermaid-to-excalidraw"; return "mermaid-to-excalidraw";
} }
if (id.includes("@codemirror/") || id.includes("@lezer/")) {
return "codemirror.chunk";
}
}, },
}, },
}, },
@@ -168,11 +150,6 @@ export default defineConfig(({ mode }) => {
"**/locales/**", "**/locales/**",
"service-worker.js", "service-worker.js",
"**/*.chunk-*.js", "**/*.chunk-*.js",
// CodeMirrorEditor can't be assigned a `.chunk` name via
// manualChunks because Rollup would hoist shared deps (React)
// via a static import from the main bundle, defeating lazy
// loading. So we exclude it by name instead.
"**/CodeMirrorEditor-*.js",
], ],
runtimeCaching: [ runtimeCaching: [
{ {
@@ -212,7 +189,7 @@ export default defineConfig(({ mode }) => {
}, },
}, },
{ {
urlPattern: new RegExp("(.chunk-.+|CodeMirrorEditor-.+)\\.js"), urlPattern: new RegExp(".chunk-.+.js"),
handler: "CacheFirst", handler: "CacheFirst",
options: { options: {
cacheName: "chunk", cacheName: "chunk",
+1 -3
View File
@@ -56,9 +56,7 @@
"build:element": "yarn --cwd ./packages/element build:esm", "build:element": "yarn --cwd ./packages/element build:esm",
"build:excalidraw": "yarn --cwd ./packages/excalidraw build:esm", "build:excalidraw": "yarn --cwd ./packages/excalidraw build:esm",
"build:math": "yarn --cwd ./packages/math build:esm", "build:math": "yarn --cwd ./packages/math build:esm",
"build:fractional-indexing": "yarn --cwd ./packages/fractional-indexing build:esm", "build:packages": "yarn build:common && yarn build:math && yarn build:element && yarn build:excalidraw",
"build:laser-pointer": "yarn --cwd ./packages/laser-pointer build:esm",
"build:packages": "yarn build:common && yarn build:fractional-indexing && yarn build:laser-pointer && yarn build:math && yarn build:element && yarn build:excalidraw",
"build:version": "yarn --cwd ./excalidraw-app build:version", "build:version": "yarn --cwd ./excalidraw-app build:version",
"build": "yarn --cwd ./excalidraw-app build", "build": "yarn --cwd ./excalidraw-app build",
"build:preview": "yarn --cwd ./excalidraw-app build:preview", "build:preview": "yarn --cwd ./excalidraw-app build:preview",
-74
View File
@@ -1,74 +0,0 @@
import { AppEventBus } from "./appEventBus";
type TestEvents = {
initialize: [api: number];
pointerUp: [pointerId: string];
viewState: [zoom: number];
};
const behavior = {
initialize: { cardinality: "once", replay: "last" },
pointerUp: { cardinality: "many", replay: "none" },
viewState: { cardinality: "many", replay: "last" },
} as const;
const flushMicrotasks = async () => Promise.resolve();
describe("AppEventBus", () => {
it("replays once events to late callback and Promise subscribers", async () => {
const bus = new AppEventBus<TestEvents, typeof behavior>(behavior);
bus.emit("initialize", 42);
const calls: number[] = [];
bus.on("initialize", (value) => {
calls.push(value);
});
expect(calls).toEqual([]);
await flushMicrotasks();
expect(calls).toEqual([42]);
await expect(bus.on("initialize")).resolves.toBe(42);
});
it("does not replay stream events to late subscribers", async () => {
const bus = new AppEventBus<TestEvents, typeof behavior>(behavior);
bus.emit("pointerUp", "first");
const calls: string[] = [];
bus.on("pointerUp", (pointerId) => {
calls.push(pointerId);
});
await flushMicrotasks();
expect(calls).toEqual([]);
bus.emit("pointerUp", "second");
expect(calls).toEqual(["second"]);
});
it("replays replay-last stream events and stays subscribed", async () => {
const bus = new AppEventBus<TestEvents, typeof behavior>(behavior);
bus.emit("viewState", 1);
const calls: number[] = [];
bus.on("viewState", (zoom) => {
calls.push(zoom);
});
await flushMicrotasks();
expect(calls).toEqual([1]);
bus.emit("viewState", 2);
expect(calls).toEqual([1, 2]);
});
it("throws when emitting a once event twice", () => {
const bus = new AppEventBus<TestEvents, typeof behavior>(behavior);
bus.emit("initialize", 1);
expect(() => {
bus.emit("initialize", 2);
}).toThrow('Event "initialize" can only be emitted once');
});
});
-136
View File
@@ -1,136 +0,0 @@
import type { UnsubscribeCallback } from "@excalidraw/excalidraw/types";
import { Emitter } from "./emitter";
import { isProdEnv } from "./utils";
export type AppEventPayloadMap = Record<string, unknown[]>;
export type AppEventBehavior = {
cardinality: "once" | "many";
replay: "none" | "last";
};
export type AppEventBehaviorMap<Events extends AppEventPayloadMap> = {
[K in keyof Events]: AppEventBehavior;
};
type AwaitableAppEventKeys<
Events extends AppEventPayloadMap,
Behavior extends AppEventBehaviorMap<Events>,
> = {
[K in keyof Events]: Behavior[K]["cardinality"] extends "once"
? Behavior[K]["replay"] extends "last"
? K
: never
: never;
}[keyof Events];
type AppEventPromiseValue<Args extends any[]> = Args extends [infer Only]
? Only
: Args;
export class AppEventBus<
Events extends AppEventPayloadMap,
Behavior extends AppEventBehaviorMap<Events>,
> {
private readonly emitters = new Map<keyof Events, Emitter<any>>();
private readonly lastPayload = new Map<keyof Events, any[]>();
private readonly emittedOnce = new Set<keyof Events>();
constructor(private readonly behavior: Behavior) {}
private getEmitter<K extends keyof Events>(name: K): Emitter<Events[K]> {
let emitter = this.emitters.get(name);
if (!emitter) {
emitter = new Emitter<any>();
this.emitters.set(name, emitter);
}
return emitter as Emitter<Events[K]>;
}
private toPromiseValue<Args extends any[]>(
args: Args,
): AppEventPromiseValue<Args> {
return (args.length === 1 ? args[0] : args) as AppEventPromiseValue<Args>;
}
public on<K extends keyof Events>(
name: K,
callback: (...args: Events[K]) => void,
): UnsubscribeCallback;
public on<K extends AwaitableAppEventKeys<Events, Behavior>>(
name: K,
): Promise<AppEventPromiseValue<Events[K]>>;
public on<K extends keyof Events>(
name: K,
callback?: (...args: Events[K]) => void,
): UnsubscribeCallback | Promise<AppEventPromiseValue<Events[K]>> {
const eventBehavior = this.behavior[name];
const cachedPayload = this.lastPayload.get(name) as Events[K] | undefined;
if (callback) {
if (eventBehavior.replay === "last" && cachedPayload) {
queueMicrotask(() => callback(...cachedPayload));
if (eventBehavior.cardinality === "once") {
return () => {};
}
}
return this.getEmitter(name).on(callback);
}
if (
eventBehavior.cardinality !== "once" ||
eventBehavior.replay !== "last"
) {
throw new Error(`Event "${String(name)}" requires a callback`);
}
if (cachedPayload) {
return Promise.resolve(this.toPromiseValue(cachedPayload));
}
return new Promise<AppEventPromiseValue<Events[K]>>((resolve) => {
this.getEmitter(name).once((...args: Events[K]) => {
resolve(this.toPromiseValue(args));
});
});
}
public emit<K extends keyof Events>(name: K, ...args: Events[K]) {
const eventBehavior = this.behavior[name];
if (!isProdEnv()) {
if (eventBehavior.cardinality === "once") {
if (this.emittedOnce.has(name)) {
throw new Error(`Event "${String(name)}" can only be emitted once`);
}
this.emittedOnce.add(name);
}
}
if (eventBehavior.replay === "last") {
this.lastPayload.set(name, args);
}
try {
this.getEmitter(name).trigger(...args);
} finally {
if (eventBehavior.cardinality === "once") {
this.getEmitter(name).clear();
}
}
}
public clear() {
this.lastPayload.clear();
this.emittedOnce.clear();
for (const emitter of this.emitters.values()) {
emitter.clear();
}
this.emitters.clear();
}
}
+1 -5
View File
@@ -80,11 +80,7 @@ const cssInvert = (
return { r: invertedR, g: invertedG, b: invertedB }; return { r: invertedR, g: invertedG, b: invertedB };
}; };
export const applyDarkModeFilter = (color: string, enable = true): string => { export const applyDarkModeFilter = (color: string): string => {
if (!enable) {
return color;
}
const cached = DARK_MODE_COLORS_CACHE?.get(color); const cached = DARK_MODE_COLORS_CACHE?.get(color);
if (cached) { if (cached) {
return cached; return cached;
+7 -47
View File
@@ -337,10 +337,9 @@ export const MAX_DECIMALS_FOR_SVG_EXPORT = 2;
export const EXPORT_SCALES = [1, 2, 3]; export const EXPORT_SCALES = [1, 2, 3];
export const DEFAULT_EXPORT_PADDING = 10; // px export const DEFAULT_EXPORT_PADDING = 10; // px
export const DEFAULT_IMAGE_OPTIONS: AppProps["imageOptions"] = { export const DEFAULT_MAX_IMAGE_WIDTH_OR_HEIGHT = 1440;
maxWidthOrHeight: 1440,
maxFileSizeBytes: 4 * 1024 * 1024, export const MAX_ALLOWED_FILE_BYTES = 4 * 1024 * 1024;
};
export const SVG_NS = "http://www.w3.org/2000/svg"; export const SVG_NS = "http://www.w3.org/2000/svg";
export const SVG_DOCUMENT_PREAMBLE = `<?xml version="1.0" standalone="no"?> export const SVG_DOCUMENT_PREAMBLE = `<?xml version="1.0" standalone="no"?>
@@ -404,47 +403,11 @@ export const ROUGHNESS = {
cartoonist: 2, cartoonist: 2,
} as const; } as const;
export type StrokeWidthKey = "thin" | "medium" | "bold"; export const STROKE_WIDTH = {
export const STROKE_WIDTH_KEYS: readonly StrokeWidthKey[] = [
"thin",
"medium",
"bold",
];
export const STROKE_WIDTH: Readonly<
Record<StrokeWidthKey | "extraBold", ExcalidrawElement["strokeWidth"]>
> = {
thin: 1, thin: 1,
medium: 2,
bold: 4,
extraBold: 8, // unused (may be introduced in the future)
};
// freedraw schema 2.0 uses thinner stroke, but to maintain backwards and
// forwards compatibility, instead of changing the shape renderer, we scale
// the stroke width by 1/2 (previous, thin was 1, medium 2 etc.)
//
// note that in the UI, STROKE_WIDTH.thin == FREEDRAW_STROKE_WIDTH.thin still
export const FREEDRAW_STROKE_WIDTH: Readonly<
Record<StrokeWidthKey | "extraBold", ExcalidrawElement["strokeWidth"]>
> = {
thin: 0.5,
medium: 1,
bold: 2, bold: 2,
extraBold: 4, // legacy (may be used again in the future) extraBold: 4,
}; } as const;
export const getStrokeWidthByKey = (
elementType: ExcalidrawElement["type"],
strokeWidthKey: StrokeWidthKey,
): ExcalidrawElement["strokeWidth"] => {
return elementType === "freedraw"
? FREEDRAW_STROKE_WIDTH[strokeWidthKey]
: STROKE_WIDTH[strokeWidthKey];
};
export const DEFAULT_ELEMENT_STROKE_WIDTH_KEY: StrokeWidthKey = "medium";
export const DEFAULT_ELEMENT_PROPS: { export const DEFAULT_ELEMENT_PROPS: {
strokeColor: ExcalidrawElement["strokeColor"]; strokeColor: ExcalidrawElement["strokeColor"];
@@ -459,7 +422,7 @@ export const DEFAULT_ELEMENT_PROPS: {
strokeColor: COLOR_PALETTE.black, strokeColor: COLOR_PALETTE.black,
backgroundColor: COLOR_PALETTE.transparent, backgroundColor: COLOR_PALETTE.transparent,
fillStyle: "solid", fillStyle: "solid",
strokeWidth: STROKE_WIDTH[DEFAULT_ELEMENT_STROKE_WIDTH_KEY], strokeWidth: 2,
strokeStyle: "solid", strokeStyle: "solid",
roughness: ROUGHNESS.artist, roughness: ROUGHNESS.artist,
opacity: 100, opacity: 100,
@@ -550,6 +513,3 @@ export const BIND_MODE_TIMEOUT = 700; // ms
export const MOBILE_ACTION_BUTTON_BG = { export const MOBILE_ACTION_BUTTON_BG = {
background: "var(--mobile-action-button-bg)", background: "var(--mobile-action-button-bg)",
} as const; } as const;
export const DEFAULT_STROKE_STREAMLINE = 0.5;
export const DEFAULT_STROKE_STREAMLINE_PRECISE = 0.2;
-2
View File
@@ -11,7 +11,5 @@ export * from "./random";
export * from "./url"; export * from "./url";
export * from "./utils"; export * from "./utils";
export * from "./emitter"; export * from "./emitter";
export * from "./appEventBus";
export * from "./editorInterface"; export * from "./editorInterface";
export * from "./versionedSnapshotStore";
export { Debug } from "../debug"; export { Debug } from "../debug";
-89
View File
@@ -3,12 +3,6 @@ import {
mapFind, mapFind,
reduceToCommonValue, reduceToCommonValue,
} from "@excalidraw/common"; } from "@excalidraw/common";
import { vi } from "vitest";
// Import directly to avoid the @excalidraw/common throttleRAF mock from setupTests.ts.
import { throttleRAF } from "./utils";
type RafCallback = FrameRequestCallback;
describe("@excalidraw/common/utils", () => { describe("@excalidraw/common/utils", () => {
describe("isTransparent()", () => { describe("isTransparent()", () => {
@@ -85,87 +79,4 @@ describe("@excalidraw/common/utils", () => {
expect(mapFind([1, 2], () => null)).toBe(undefined); expect(mapFind([1, 2], () => null)).toBe(undefined);
}); });
}); });
describe("throttleRAF()", () => {
let frameCallbacks: Map<number, RafCallback>;
let nextFrameId: number;
const runScheduledFrame = (timestamp = 16) => {
const callbacks = [...frameCallbacks.values()];
frameCallbacks.clear();
callbacks.forEach((callback) => callback(timestamp));
};
beforeEach(() => {
frameCallbacks = new Map();
nextFrameId = 0;
vi.spyOn(window, "requestAnimationFrame").mockImplementation(
(callback) => {
const frameId = ++nextFrameId;
frameCallbacks.set(frameId, callback);
return frameId;
},
);
vi.spyOn(window, "cancelAnimationFrame").mockImplementation((frameId) => {
frameCallbacks.delete(frameId);
});
});
afterEach(() => {
vi.restoreAllMocks();
});
it("should invoke the callback with the last args from the same frame", () => {
const fn = vi.fn();
const throttled = throttleRAF(fn);
throttled("first", 1);
throttled("second", 2);
throttled("last", 3);
expect(fn).not.toHaveBeenCalled();
expect(window.requestAnimationFrame).toHaveBeenCalledTimes(1);
runScheduledFrame();
expect(fn).toHaveBeenCalledTimes(1);
expect(fn).toHaveBeenCalledWith("last", 3);
});
it("should flush the pending callback immediately", () => {
const fn = vi.fn();
const throttled = throttleRAF(fn);
throttled("first");
throttled("last");
throttled.flush();
expect(window.cancelAnimationFrame).toHaveBeenCalledTimes(1);
expect(fn).toHaveBeenCalledTimes(1);
expect(fn).toHaveBeenCalledWith("last");
runScheduledFrame();
expect(fn).toHaveBeenCalledTimes(1);
});
it("should cancel the pending callback", () => {
const fn = vi.fn();
const throttled = throttleRAF(fn);
throttled("first");
throttled("last");
throttled.cancel();
expect(window.cancelAnimationFrame).toHaveBeenCalledTimes(1);
runScheduledFrame();
expect(fn).not.toHaveBeenCalled();
});
});
}); });
+152 -13
View File
@@ -88,8 +88,7 @@ export const isWritableElement = (
(target.type === "text" || (target.type === "text" ||
target.type === "number" || target.type === "number" ||
target.type === "password" || target.type === "password" ||
target.type === "search")) || target.type === "search"));
(target instanceof HTMLElement && target.closest(".cm-editor") !== null);
export const getFontFamilyString = ({ export const getFontFamilyString = ({
fontFamily, fontFamily,
@@ -151,27 +150,38 @@ export const debounce = <T extends any[]>(
return ret; return ret;
}; };
// throttle callback to execute once per animation frame using the latest args // throttle callback to execute once per animation frame
export const throttleRAF = <T extends any[]>(fn: (...args: T) => void) => { export const throttleRAF = <T extends any[]>(
fn: (...args: T) => void,
opts?: { trailing?: boolean },
) => {
let timerId: number | null = null; let timerId: number | null = null;
let lastArgs: T | null = null; let lastArgs: T | null = null;
let lastArgsTrailing: T | null = null;
const scheduleFunc = () => { const scheduleFunc = (args: T) => {
timerId = window.requestAnimationFrame(() => { timerId = window.requestAnimationFrame(() => {
timerId = null; timerId = null;
const args = lastArgs; fn(...args);
lastArgs = null; lastArgs = null;
if (lastArgsTrailing) {
if (args) { lastArgs = lastArgsTrailing;
fn(...args); lastArgsTrailing = null;
scheduleFunc(lastArgs);
} }
}); });
}; };
const ret = (...args: T) => { const ret = (...args: T) => {
if (isTestEnv()) {
fn(...args);
return;
}
lastArgs = args; lastArgs = args;
if (timerId === null) { if (timerId === null) {
scheduleFunc(); scheduleFunc(lastArgs);
} else if (opts?.trailing) {
lastArgsTrailing = args;
} }
}; };
ret.flush = () => { ret.flush = () => {
@@ -180,12 +190,12 @@ export const throttleRAF = <T extends any[]>(fn: (...args: T) => void) => {
timerId = null; timerId = null;
} }
if (lastArgs) { if (lastArgs) {
fn(...lastArgs); fn(...(lastArgsTrailing || lastArgs));
lastArgs = null; lastArgs = lastArgsTrailing = null;
} }
}; };
ret.cancel = () => { ret.cancel = () => {
lastArgs = null; lastArgs = lastArgsTrailing = null;
if (timerId !== null) { if (timerId !== null) {
cancelAnimationFrame(timerId); cancelAnimationFrame(timerId);
timerId = null; timerId = null;
@@ -204,6 +214,135 @@ export const easeOut = (k: number) => {
return 1 - Math.pow(1 - k, 4); return 1 - Math.pow(1 - k, 4);
}; };
const easeOutInterpolate = (from: number, to: number, progress: number) => {
return (to - from) * easeOut(progress) + from;
};
/**
* Animates values from `fromValues` to `toValues` using the requestAnimationFrame API.
* Executes the `onStep` callback on each step with the interpolated values.
* Returns a function that can be called to cancel the animation.
*
* @example
* // Example usage:
* const fromValues = { x: 0, y: 0 };
* const toValues = { x: 100, y: 200 };
* const onStep = ({x, y}) => {
* setState(x, y)
* };
* const onCancel = () => {
* console.log("Animation canceled");
* };
*
* const cancelAnimation = easeToValuesRAF({
* fromValues,
* toValues,
* onStep,
* onCancel,
* });
*
* // To cancel the animation:
* cancelAnimation();
*/
export const easeToValuesRAF = <
T extends Record<keyof T, number>,
K extends keyof T,
>({
fromValues,
toValues,
onStep,
duration = 250,
interpolateValue,
onStart,
onEnd,
onCancel,
}: {
fromValues: T;
toValues: T;
/**
* Interpolate a single value.
* Return undefined to be handled by the default interpolator.
*/
interpolateValue?: (
fromValue: number,
toValue: number,
/** no easing applied */
progress: number,
key: K,
) => number | undefined;
onStep: (values: T) => void;
duration?: number;
onStart?: () => void;
onEnd?: () => void;
onCancel?: () => void;
}) => {
let canceled = false;
let frameId = 0;
let startTime: number;
function step(timestamp: number) {
if (canceled) {
return;
}
if (startTime === undefined) {
startTime = timestamp;
onStart?.();
}
const elapsed = Math.min(timestamp - startTime, duration);
const factor = easeOut(elapsed / duration);
const newValues = {} as T;
Object.keys(fromValues).forEach((key) => {
const _key = key as keyof T;
const result = ((toValues[_key] - fromValues[_key]) * factor +
fromValues[_key]) as T[keyof T];
newValues[_key] = result;
});
onStep(newValues);
if (elapsed < duration) {
const progress = elapsed / duration;
const newValues = {} as T;
Object.keys(fromValues).forEach((key) => {
const _key = key as K;
const startValue = fromValues[_key];
const endValue = toValues[_key];
let result;
result = interpolateValue
? interpolateValue(startValue, endValue, progress, _key)
: easeOutInterpolate(startValue, endValue, progress);
if (result == null) {
result = easeOutInterpolate(startValue, endValue, progress);
}
newValues[_key] = result as T[K];
});
onStep(newValues);
frameId = window.requestAnimationFrame(step);
} else {
onStep(toValues);
onEnd?.();
}
}
frameId = window.requestAnimationFrame(step);
return () => {
onCancel?.();
canceled = true;
window.cancelAnimationFrame(frameId);
};
};
// https://github.com/lodash/lodash/blob/es/chunk.js // https://github.com/lodash/lodash/blob/es/chunk.js
export const chunk = <T extends any>( export const chunk = <T extends any>(
array: readonly T[], array: readonly T[],
@@ -1,70 +0,0 @@
export type VersionedSnapshot<T> = Readonly<{
version: number;
value: T;
}>;
export class VersionedSnapshotStore<T> {
private version = 0;
private value: T;
private readonly waiters = new Set<
(snapshot: VersionedSnapshot<T>) => void
>();
private readonly subscribers = new Set<
(snapshot: VersionedSnapshot<T>) => void
>();
constructor(
initialValue: T,
private readonly isEqual: (prev: T, next: T) => boolean = Object.is,
) {
this.value = initialValue;
}
public getSnapshot(): VersionedSnapshot<T> {
return { version: this.version, value: this.value };
}
public set(nextValue: T): boolean {
if (this.isEqual(this.value, nextValue)) {
return false;
}
this.value = nextValue;
this.version += 1;
const snapshot = this.getSnapshot();
for (const subscriber of this.subscribers) {
subscriber(snapshot);
}
for (const waiter of this.waiters) {
waiter(snapshot);
}
this.waiters.clear();
return true;
}
public update(updater: (prev: T) => T): boolean {
return this.set(updater(this.value));
}
public subscribe(
subscriber: (snapshot: VersionedSnapshot<T>) => void,
): () => void {
this.subscribers.add(subscriber);
return () => {
this.subscribers.delete(subscriber);
};
}
public pull(sinceVersion = -1): Promise<VersionedSnapshot<T>> {
if (this.version !== sinceVersion) {
return Promise.resolve(this.getSnapshot());
}
return new Promise((resolve) => {
this.waiters.add(resolve);
});
}
}
-109
View File
@@ -1,109 +0,0 @@
import { BinaryHeap } from "../src/binary-heap";
describe("BinaryHeap", () => {
const numberHeap = () => new BinaryHeap<number>((n) => n);
const drain = (heap: BinaryHeap<number>) => {
const out: number[] = [];
while (heap.size() > 0) {
out.push(heap.pop()!);
}
return out;
};
describe("empty heap", () => {
it("has size 0", () => {
expect(numberHeap().size()).toBe(0);
});
it("pop() returns null", () => {
expect(numberHeap().pop()).toBe(null);
});
it("remove() is a no-op and does not throw", () => {
const heap = numberHeap();
expect(() => heap.remove(1)).not.toThrow();
expect(heap.size()).toBe(0);
});
});
describe("push / pop", () => {
it("tracks size as items are added and removed", () => {
const heap = numberHeap();
[3, 1, 2].forEach((n) => heap.push(n));
expect(heap.size()).toBe(3);
heap.pop();
expect(heap.size()).toBe(2);
});
it("pops a single pushed element back out", () => {
const heap = numberHeap();
heap.push(42);
expect(heap.pop()).toBe(42);
expect(heap.pop()).toBe(null);
});
it("always pops the smallest score first", () => {
const heap = numberHeap();
[5, 3, 8, 1, 9, 2, 7].forEach((n) => heap.push(n));
expect(drain(heap)).toEqual([1, 2, 3, 5, 7, 8, 9]);
});
it("handles duplicate scores", () => {
const heap = numberHeap();
[4, 1, 4, 1, 2].forEach((n) => heap.push(n));
expect(drain(heap)).toEqual([1, 1, 2, 4, 4]);
});
it("maintains the heap invariant for a large adversarial (reverse-sorted) input", () => {
const heap = numberHeap();
// pushing in descending order forces a sift-up on every insert
const input = Array.from({ length: 1000 }, (_, i) => 1000 - i);
input.forEach((n) => heap.push(n));
expect(drain(heap)).toEqual([...input].sort((a, b) => a - b));
});
});
describe("remove", () => {
it("removes an interior element and keeps the rest ordered", () => {
const heap = numberHeap();
[5, 3, 8, 1, 9].forEach((n) => heap.push(n));
heap.remove(8);
expect(heap.size()).toBe(4);
expect(drain(heap)).toEqual([1, 3, 5, 9]);
});
it("can remove the current minimum", () => {
const heap = numberHeap();
[5, 3, 8, 1, 9].forEach((n) => heap.push(n));
heap.remove(1);
expect(heap.size()).toBe(4);
expect(heap.pop()).toBe(3);
});
});
describe("rescoreElement", () => {
type Node = { id: string; f: number };
it("re-sorts a node after its score is lowered", () => {
const heap = new BinaryHeap<Node>((node) => node.f);
const a = { id: "a", f: 10 };
const b = { id: "b", f: 20 };
const c = { id: "c", f: 30 };
[a, b, c].forEach((node) => heap.push(node));
c.f = 5;
heap.rescoreElement(c);
expect(heap.pop()).toBe(c);
expect(heap.pop()).toBe(a);
expect(heap.pop()).toBe(b);
});
});
});
+1 -2
View File
@@ -1,8 +1,7 @@
{ {
"extends": "../tsconfig.base.json", "extends": "../tsconfig.base.json",
"compilerOptions": { "compilerOptions": {
"outDir": "./dist/types", "outDir": "./dist/types"
"rootDir": "../"
}, },
"include": ["src/**/*", "global.d.ts"], "include": ["src/**/*", "global.d.ts"],
"exclude": ["**/*.test.*", "tests", "types", "examples", "dist"] "exclude": ["**/*.test.*", "tests", "types", "examples", "dist"]
+1 -2
View File
@@ -64,7 +64,6 @@
}, },
"dependencies": { "dependencies": {
"@excalidraw/common": "0.18.0", "@excalidraw/common": "0.18.0",
"@excalidraw/math": "0.18.0", "@excalidraw/math": "0.18.0"
"@excalidraw/fractional-indexing": "3.3.0"
} }
} }
+36 -12
View File
@@ -338,18 +338,27 @@ export class Scene {
this.callbacks.clear(); this.callbacks.clear();
} }
/** low-level - generally use app.insertNewElements() */ insertElementAtIndex(element: ExcalidrawElement, index: number) {
insertElementsAtIndex( if (!Number.isFinite(index) || index < 0) {
elements: ExcalidrawElement[], throw new Error(
/** null indicates end of the array */ "insertElementAtIndex can only be called with index >= 0",
index: number | null, );
) {
if (!elements.length) {
return;
} }
if (index === null) { const nextElements = [
index = this.elements.length; ...this.elements.slice(0, index),
element,
...this.elements.slice(index),
];
syncMovedIndices(nextElements, arrayToMap([element]));
this.replaceAllElements(nextElements);
}
insertElementsAtIndex(elements: ExcalidrawElement[], index: number) {
if (!elements.length) {
return;
} }
if (!Number.isFinite(index) || index < 0) { if (!Number.isFinite(index) || index < 0) {
@@ -369,9 +378,24 @@ export class Scene {
this.replaceAllElements(nextElements); this.replaceAllElements(nextElements);
} }
/** low-level - generally use app.insertNewElement() */
insertElement = (element: ExcalidrawElement) => { insertElement = (element: ExcalidrawElement) => {
this.insertElementsAtIndex([element], null); const index = element.frameId
? this.getElementIndex(element.frameId)
: this.elements.length;
this.insertElementAtIndex(element, index);
};
insertElements = (elements: ExcalidrawElement[]) => {
if (!elements.length) {
return;
}
const index = elements[0]?.frameId
? this.getElementIndex(elements[0].frameId)
: this.elements.length;
this.insertElementsAtIndex(elements, index);
}; };
getElementIndex(elementId: string) { getElementIndex(elementId: string) {
-32
View File
@@ -1,32 +0,0 @@
import type { Arrowhead, AnyArrowhead } from "./types";
export const normalizeArrowhead = (
arrowhead: AnyArrowhead | null | undefined,
): Arrowhead | null => {
switch (arrowhead) {
case undefined:
case null:
return null;
case "dot":
return "circle";
case "crowfoot_one":
return "cardinality_one";
case "crowfoot_many":
return "cardinality_many";
case "crowfoot_one_or_many":
return "cardinality_one_or_many";
default:
return arrowhead;
}
};
export const getArrowheadForPicker = (
arrowhead: AnyArrowhead | null | undefined,
): Arrowhead | null => {
const normalizedArrowhead = normalizeArrowhead(arrowhead);
if (normalizedArrowhead === null) {
return null;
}
return normalizedArrowhead;
};
+18 -25
View File
@@ -643,13 +643,10 @@ const getBindingStrategyForDraggingBindingElementEndpoints_simple = (
let start: BindingStrategy = { mode: undefined }; let start: BindingStrategy = { mode: undefined };
let end: BindingStrategy = { mode: undefined }; let end: BindingStrategy = { mode: undefined };
if (arrow.points.length < 2) { invariant(
console.error( arrow.points.length > 1,
"Attempting to bind a linear element with less than 2 points", "Do not attempt to bind linear elements with a single point",
); );
// a single-point can't be bound -> cancel
return { start: { mode: undefined }, end: { mode: undefined } };
}
// If none of the ends are dragged, we don't change anything // If none of the ends are dragged, we don't change anything
if (!startDragged && !endDragged) { if (!startDragged && !endDragged) {
@@ -737,11 +734,12 @@ const getBindingStrategyForDraggingBindingElementEndpoints_simple = (
}); });
// Handle outside-outside binding to the same element // Handle outside-outside binding to the same element
if ( if (otherBinding && otherBinding.elementId === hit?.id) {
otherBinding && invariant(
otherBinding.elementId === hit?.id && !opts?.newArrow || appState.selectedLinearElement?.initialState.origin,
(!opts?.newArrow || appState.selectedLinearElement?.initialState.origin) "appState.selectedLinearElement.initialState.origin must be defined for new arrows",
) { );
return { return {
start: { start: {
mode: "inside", mode: "inside",
@@ -893,13 +891,10 @@ const getBindingStrategyForDraggingBindingElementEndpoints_complex = (
let start: BindingStrategy = { mode: undefined }; let start: BindingStrategy = { mode: undefined };
let end: BindingStrategy = { mode: undefined }; let end: BindingStrategy = { mode: undefined };
if (arrow.points.length < 2) { invariant(
console.error( arrow.points.length > 1,
"Attempting to bind a linear element with less than 2 points", "Do not attempt to bind linear elements with a single point",
); );
// a single-point can't be bound -> cancel
return { start: { mode: undefined }, end: { mode: undefined } };
}
// If none of the ends are dragged, we don't change anything // If none of the ends are dragged, we don't change anything
if (!startDragged && !endDragged) { if (!startDragged && !endDragged) {
@@ -1948,9 +1943,9 @@ export const calculateFixedPointForElbowArrowBinding = (
return { return {
fixedPoint: normalizeFixedPoint([ fixedPoint: normalizeFixedPoint([
(nonRotatedSnappedGlobalPoint[0] - hoveredElement.x) / (nonRotatedSnappedGlobalPoint[0] - hoveredElement.x) /
Math.max(hoveredElement.width, PRECISION), hoveredElement.width,
(nonRotatedSnappedGlobalPoint[1] - hoveredElement.y) / (nonRotatedSnappedGlobalPoint[1] - hoveredElement.y) /
Math.max(hoveredElement.height, PRECISION), hoveredElement.height,
]), ]),
}; };
}; };
@@ -1981,11 +1976,9 @@ export const calculateFixedPointForNonElbowArrowBinding = (
// Calculate the ratio relative to the element's bounds // Calculate the ratio relative to the element's bounds
const fixedPointX = const fixedPointX =
(nonRotatedPoint[0] - hoveredElement.x) / (nonRotatedPoint[0] - hoveredElement.x) / hoveredElement.width;
Math.max(hoveredElement.width, PRECISION);
const fixedPointY = const fixedPointY =
(nonRotatedPoint[1] - hoveredElement.y) / (nonRotatedPoint[1] - hoveredElement.y) / hoveredElement.height;
Math.max(hoveredElement.height, PRECISION);
return { return {
fixedPoint: normalizeFixedPoint([fixedPointX, fixedPointY]), fixedPoint: normalizeFixedPoint([fixedPointX, fixedPointY]),
+38 -357
View File
@@ -1,4 +1,5 @@
import rough from "roughjs/bin/rough"; import rough from "roughjs/bin/rough";
import { import {
arrayToMap, arrayToMap,
type Bounds, type Bounds,
@@ -6,6 +7,7 @@ import {
rescalePoints, rescalePoints,
sizeOf, sizeOf,
} from "@excalidraw/common"; } from "@excalidraw/common";
import { import {
degreesToRadians, degreesToRadians,
lineSegment, lineSegment,
@@ -14,7 +16,9 @@ import {
pointFromArray, pointFromArray,
pointRotateRads, pointRotateRads,
} from "@excalidraw/math"; } from "@excalidraw/math";
import { getCurvePathOps } from "@excalidraw/utils/shape"; import { getCurvePathOps } from "@excalidraw/utils/shape";
import { pointsOnBezierCurves } from "points-on-curve"; import { pointsOnBezierCurves } from "points-on-curve";
import type { import type {
@@ -25,7 +29,9 @@ import type {
LocalPoint, LocalPoint,
Radians, Radians,
} from "@excalidraw/math"; } from "@excalidraw/math";
import type { AppState } from "@excalidraw/excalidraw/types"; import type { AppState } from "@excalidraw/excalidraw/types";
import type { Mutable } from "@excalidraw/common/utility-types"; import type { Mutable } from "@excalidraw/common/utility-types";
import { generateRoughOptions } from "./shape"; import { generateRoughOptions } from "./shape";
@@ -35,20 +41,18 @@ import { getBoundTextElement, getContainerElement } from "./textElement";
import { import {
isArrowElement, isArrowElement,
isBoundToContainer, isBoundToContainer,
isFrameLikeElement,
isFreeDrawElement, isFreeDrawElement,
isLinearElement, isLinearElement,
isLineElement, isLineElement,
isTextElement, isTextElement,
isExcalidrawElement,
} from "./typeChecks"; } from "./typeChecks";
import { getElementShape } from "./shape"; import { getElementShape } from "./shape";
import { import {
deconstructDiamondElement, deconstructDiamondElement,
deconstructRectanguloidElement, deconstructRectanguloidElement,
} from "./utils"; } from "./utils";
import { intersectElementWithLineSegment } from "./collision";
import { elementOverlapsWithFrame, getContainingFrame } from "./frame";
import type { Drawable, Op } from "roughjs/bin/core"; import type { Drawable, Op } from "roughjs/bin/core";
import type { Point as RoughPoint } from "roughjs/bin/geometry"; import type { Point as RoughPoint } from "roughjs/bin/geometry";
@@ -63,7 +67,6 @@ import type {
ExcalidrawRectanguloidElement, ExcalidrawRectanguloidElement,
ExcalidrawTextElementWithContainer, ExcalidrawTextElementWithContainer,
NonDeleted, NonDeleted,
NonDeletedExcalidrawElement,
} from "./types"; } from "./types";
export type RectangleBox = { export type RectangleBox = {
@@ -677,9 +680,8 @@ export const getMinMaxXYFromCurvePathOps = (
return [minX, minY, maxX, maxY]; return [minX, minY, maxX, maxY];
}; };
export const getBoundsFromPoints = <P extends GlobalPoint | LocalPoint>( export const getBoundsFromPoints = (
points: readonly P[], points: ExcalidrawFreeDrawElement["points"],
padding: number = 0,
): Bounds => { ): Bounds => {
let minX = Infinity; let minX = Infinity;
let minY = Infinity; let minY = Infinity;
@@ -693,7 +695,7 @@ export const getBoundsFromPoints = <P extends GlobalPoint | LocalPoint>(
maxY = Math.max(maxY, y); maxY = Math.max(maxY, y);
} }
return [minX - padding, minY - padding, maxX + padding, maxY + padding]; return [minX, minY, maxX, maxY];
}; };
const getFreeDrawElementAbsoluteCoords = ( const getFreeDrawElementAbsoluteCoords = (
@@ -707,9 +709,6 @@ const getFreeDrawElementAbsoluteCoords = (
return [x1, y1, x2, y2, (x1 + x2) / 2, (y1 + y2) / 2]; return [x1, y1, x2, y2, (x1 + x2) / 2, (y1 + y2) / 2];
}; };
const CARDINALITY_MARKER_SIZE = 20;
const CROWFOOT_ARROWHEAD_SIZE = 15;
/** @returns number in pixels */ /** @returns number in pixels */
export const getArrowheadSize = (arrowhead: Arrowhead): number => { export const getArrowheadSize = (arrowhead: Arrowhead): number => {
switch (arrowhead) { switch (arrowhead) {
@@ -718,14 +717,10 @@ export const getArrowheadSize = (arrowhead: Arrowhead): number => {
case "diamond": case "diamond":
case "diamond_outline": case "diamond_outline":
return 12; return 12;
case "cardinality_many": case "crowfoot_many":
case "cardinality_one_or_many": case "crowfoot_one":
case "cardinality_zero_or_many": case "crowfoot_one_or_many":
return CROWFOOT_ARROWHEAD_SIZE; return 20;
case "cardinality_one":
case "cardinality_exactly_one":
case "cardinality_zero_or_one":
return CARDINALITY_MARKER_SIZE;
default: default:
return 15; return 15;
} }
@@ -748,12 +743,7 @@ export const getArrowheadPoints = (
shape: Drawable[], shape: Drawable[],
position: "start" | "end", position: "start" | "end",
arrowhead: Arrowhead, arrowhead: Arrowhead,
offsetMultiplier = 0,
) => { ) => {
if (arrowhead === null) {
return null;
}
if (shape.length < 1) { if (shape.length < 1) {
return null; return null;
} }
@@ -834,30 +824,29 @@ export const getArrowheadPoints = (
const lengthMultiplier = const lengthMultiplier =
arrowhead === "diamond" || arrowhead === "diamond_outline" ? 0.25 : 0.5; arrowhead === "diamond" || arrowhead === "diamond_outline" ? 0.25 : 0.5;
const minSize = Math.min(size, length * lengthMultiplier); const minSize = Math.min(size, length * lengthMultiplier);
const tx = x2 - nx * minSize * offsetMultiplier; const xs = x2 - nx * minSize;
const ty = y2 - ny * minSize * offsetMultiplier; const ys = y2 - ny * minSize;
const xs = tx - nx * minSize;
const ys = ty - ny * minSize;
if (arrowhead === "circle" || arrowhead === "circle_outline") { if (
const diameter = Math.hypot(ys - ty, xs - tx) + element.strokeWidth - 2; arrowhead === "dot" ||
return [tx, ty, diameter]; arrowhead === "circle" ||
arrowhead === "circle_outline"
) {
const diameter = Math.hypot(ys - y2, xs - x2) + element.strokeWidth - 2;
return [x2, y2, diameter];
} }
const angle = getArrowheadAngle(arrowhead); const angle = getArrowheadAngle(arrowhead);
if ( if (arrowhead === "crowfoot_many" || arrowhead === "crowfoot_one_or_many") {
arrowhead === "cardinality_many" ||
arrowhead === "cardinality_one_or_many"
) {
// swap (xs, ys) with (x2, y2) // swap (xs, ys) with (x2, y2)
const [x3, y3] = pointRotateRads( const [x3, y3] = pointRotateRads(
pointFrom(tx, ty), pointFrom(x2, y2),
pointFrom(xs, ys), pointFrom(xs, ys),
degreesToRadians(-angle as Degrees), degreesToRadians(-angle as Degrees),
); );
const [x4, y4] = pointRotateRads( const [x4, y4] = pointRotateRads(
pointFrom(tx, ty), pointFrom(x2, y2),
pointFrom(xs, ys), pointFrom(xs, ys),
degreesToRadians(angle), degreesToRadians(angle),
); );
@@ -867,12 +856,12 @@ export const getArrowheadPoints = (
// Return points // Return points
const [x3, y3] = pointRotateRads( const [x3, y3] = pointRotateRads(
pointFrom(xs, ys), pointFrom(xs, ys),
pointFrom(tx, ty), pointFrom(x2, y2),
((-angle * Math.PI) / 180) as Radians, ((-angle * Math.PI) / 180) as Radians,
); );
const [x4, y4] = pointRotateRads( const [x4, y4] = pointRotateRads(
pointFrom(xs, ys), pointFrom(xs, ys),
pointFrom(tx, ty), pointFrom(x2, y2),
degreesToRadians(angle), degreesToRadians(angle),
); );
@@ -885,9 +874,9 @@ export const getArrowheadPoints = (
const [px, py] = element.points.length > 1 ? element.points[1] : [0, 0]; const [px, py] = element.points.length > 1 ? element.points[1] : [0, 0];
[ox, oy] = pointRotateRads( [ox, oy] = pointRotateRads(
pointFrom(tx + minSize * 2, ty), pointFrom(x2 + minSize * 2, y2),
pointFrom(tx, ty), pointFrom(x2, y2),
Math.atan2(py - ty, px - tx) as Radians, Math.atan2(py - y2, px - x2) as Radians,
); );
} else { } else {
const [px, py] = const [px, py] =
@@ -896,16 +885,16 @@ export const getArrowheadPoints = (
: [0, 0]; : [0, 0];
[ox, oy] = pointRotateRads( [ox, oy] = pointRotateRads(
pointFrom(tx - minSize * 2, ty), pointFrom(x2 - minSize * 2, y2),
pointFrom(tx, ty), pointFrom(x2, y2),
Math.atan2(ty - py, tx - px) as Radians, Math.atan2(y2 - py, x2 - px) as Radians,
); );
} }
return [tx, ty, x3, y3, ox, oy, x4, y4]; return [x2, y2, x3, y3, ox, oy, x4, y4];
} }
return [tx, ty, x3, y3, x4, y4]; return [x2, y2, x3, y3, x4, y4];
}; };
// TODO reuse shape.ts // TODO reuse shape.ts
@@ -1259,17 +1248,6 @@ export const pointInsideBounds = <P extends GlobalPoint | LocalPoint>(
): boolean => ): boolean =>
p[0] > bounds[0] && p[0] < bounds[2] && p[1] > bounds[1] && p[1] < bounds[3]; p[0] > bounds[0] && p[0] < bounds[2] && p[1] > bounds[1] && p[1] < bounds[3];
// TODO make pointInsideBounds inclusive and remove this function once we
// test nothing is breaking
export const pointInsideBoundsInclusive = <P extends GlobalPoint | LocalPoint>(
p: P,
bounds: Bounds,
): boolean =>
p[0] >= bounds[0] &&
p[0] <= bounds[2] &&
p[1] >= bounds[1] &&
p[1] <= bounds[3];
export const doBoundsIntersect = ( export const doBoundsIntersect = (
bounds1: Bounds | null, bounds1: Bounds | null,
bounds2: Bounds | null, bounds2: Bounds | null,
@@ -1284,310 +1262,13 @@ export const doBoundsIntersect = (
return minX1 < maxX2 && maxX1 > minX2 && minY1 < maxY2 && maxY1 > minY2; return minX1 < maxX2 && maxX1 > minX2 && minY1 < maxY2 && maxY1 > minY2;
}; };
export const boundsContainBounds = (outerBounds: Bounds, innerBounds: Bounds) =>
[
pointFrom<GlobalPoint>(innerBounds[0], innerBounds[1]),
pointFrom<GlobalPoint>(innerBounds[0], innerBounds[3]),
pointFrom<GlobalPoint>(innerBounds[2], innerBounds[1]),
pointFrom<GlobalPoint>(innerBounds[2], innerBounds[3]),
].every((point) => pointInsideBoundsInclusive(point, outerBounds));
/**
* High level helper to get elements overlapping a bounding box.
* It can be used to get elements overlapping a selection box, for example.
*
*/
export const elementsOverlappingBBox = ({
elements,
elementsMap,
bounds,
type,
excludeElementsInFrames,
shouldIgnoreElementFromSelection,
}: {
elements: readonly NonDeletedExcalidrawElement[];
elementsMap?: ElementsMap;
bounds: Bounds | ExcalidrawElement;
/**
* - overlap: elements overlapping or inside bounds
* - contain: elements inside bounds
**/
type: "contain" | "overlap";
excludeElementsInFrames?: boolean;
shouldIgnoreElementFromSelection?: (
element: NonDeletedExcalidrawElement,
) => boolean;
}) => {
if (!elementsMap) {
elementsMap = arrayToMap(elements) as ElementsMap;
}
const selectionBounds = isExcalidrawElement(bounds)
? getElementBounds(bounds, elementsMap)
: bounds;
const [selectionX1, selectionY1, selectionX2, selectionY2] = selectionBounds;
const selectionEdges = [
lineSegment<GlobalPoint>(
pointFrom(selectionX1, selectionY1),
pointFrom(selectionX2, selectionY1),
),
lineSegment<GlobalPoint>(
pointFrom(selectionX2, selectionY1),
pointFrom(selectionX2, selectionY2),
),
lineSegment<GlobalPoint>(
pointFrom(selectionX2, selectionY2),
pointFrom(selectionX1, selectionY2),
),
lineSegment<GlobalPoint>(
pointFrom(selectionX1, selectionY2),
pointFrom(selectionX1, selectionY1),
),
];
const framesInSelection = excludeElementsInFrames
? new Set<NonDeletedExcalidrawElement["id"]>()
: null;
const groups: Record<string, NonDeletedExcalidrawElement[]> = {};
const elementsInSelection: Set<NonDeletedExcalidrawElement> = new Set();
for (const element of elements) {
if (shouldIgnoreElementFromSelection?.(element)) {
continue;
}
// Track only selectable top-level group members, so ignored elements such
// as bound text and locked elements don't affect group selection.
const groupId = element.groupIds.at(-1);
if (groupId) {
if (!groups[groupId]) {
groups[groupId] = [];
}
groups[groupId].push(element);
}
const strokeWidth = element.strokeWidth;
let labelAABB: Bounds | null = null;
let elementAABB = getElementBounds(element, elementsMap);
elementAABB = [
elementAABB[0] - strokeWidth / 2,
elementAABB[1] - strokeWidth / 2,
elementAABB[2] + strokeWidth / 2,
elementAABB[3] + strokeWidth / 2,
] as Bounds;
// Whether the element bounds should include the bound text element bounds
const boundTextElement =
isArrowElement(element) && getBoundTextElement(element, elementsMap);
if (boundTextElement) {
const { x, y } = LinearElementEditor.getBoundTextElementPosition(
element,
boundTextElement,
elementsMap,
);
labelAABB = [
x,
y,
x + boundTextElement.width,
y + boundTextElement.height,
] as Bounds;
}
// Clip element bounds by its containing frame (if any), since only the
// visible (frame-clipped) portion of the element is relevant for selection.
const associatedFrame = getContainingFrame(element, elementsMap);
if (
associatedFrame &&
elementOverlapsWithFrame(element, associatedFrame, elementsMap)
) {
const frameAABB = getElementBounds(associatedFrame, elementsMap);
elementAABB = [
Math.max(elementAABB[0], frameAABB[0]),
Math.max(elementAABB[1], frameAABB[1]),
Math.min(elementAABB[2], frameAABB[2]),
Math.min(elementAABB[3], frameAABB[3]),
] as Bounds;
labelAABB = labelAABB
? ([
Math.max(labelAABB[0], frameAABB[0]),
Math.max(labelAABB[1], frameAABB[1]),
Math.min(labelAABB[2], frameAABB[2]),
Math.min(labelAABB[3], frameAABB[3]),
] as Bounds)
: null;
}
const commonAABB = labelAABB
? ([
Math.min(labelAABB[0], elementAABB[0]),
Math.min(labelAABB[1], elementAABB[1]),
Math.max(labelAABB[2], elementAABB[2]),
Math.max(labelAABB[3], elementAABB[3]),
] as Bounds)
: elementAABB;
// ============== Evaluation ==============
// 1. If the selection box WRAPs the element's AABB, then add it to the
// selection and move on, regardless of the selection mode.
//
// PERF: This trick only works with axis-aligned box selection and the
// current convex element shapes!
if (boundsContainBounds(selectionBounds, commonAABB)) {
if (framesInSelection && isFrameLikeElement(element)) {
framesInSelection.add(element.id);
}
elementsInSelection.add(element);
continue;
}
// 2. Handle the case where the label is overlapped by the selection box
if (
type === "overlap" &&
labelAABB &&
doBoundsIntersect(selectionBounds, labelAABB)
) {
elementsInSelection.add(element);
continue;
}
// 3. Handle the case where the selection is not wrapping the element, but
// it does intersect the element's outline (non-AABB).
if (type === "overlap" && doBoundsIntersect(selectionBounds, elementAABB)) {
let hasIntersection = false;
// Preliminary check potential intersection imprecision
if (isLinearElement(element) || isFreeDrawElement(element)) {
const center = elementCenterPoint(element, elementsMap);
hasIntersection = element.points.some((point) => {
const rotatedPoint = pointRotateRads(
pointFrom<GlobalPoint>(element.x + point[0], element.y + point[1]),
center,
element.angle,
);
return pointInsideBounds(rotatedPoint, selectionBounds);
});
} else {
const nonRotatedElementBounds = getElementBounds(
element,
elementsMap,
true,
);
const center = elementCenterPoint(element, elementsMap);
hasIntersection = [
pointRotateRads(
pointFrom<GlobalPoint>(
(nonRotatedElementBounds[0] + nonRotatedElementBounds[2]) / 2,
nonRotatedElementBounds[1],
),
center,
element.angle,
),
pointRotateRads(
pointFrom<GlobalPoint>(
nonRotatedElementBounds[2],
(nonRotatedElementBounds[1] + nonRotatedElementBounds[3]) / 2,
),
center,
element.angle,
),
pointRotateRads(
pointFrom<GlobalPoint>(
(nonRotatedElementBounds[0] + nonRotatedElementBounds[2]) / 2,
nonRotatedElementBounds[3],
),
center,
element.angle,
),
pointRotateRads(
pointFrom<GlobalPoint>(
nonRotatedElementBounds[0],
(nonRotatedElementBounds[1] + nonRotatedElementBounds[3]) / 2,
),
center,
element.angle,
),
].some((point) => {
return pointInsideBounds(
pointRotateRads(point, center, element.angle),
selectionBounds,
);
});
}
if (!hasIntersection) {
hasIntersection = selectionEdges.some(
(selectionEdge) =>
intersectElementWithLineSegment(
element,
elementsMap,
selectionEdge,
strokeWidth / 2,
true, // Stop at first hit for better performance
).length > 0,
);
}
if (hasIntersection) {
if (framesInSelection && isFrameLikeElement(element)) {
framesInSelection.add(element.id);
}
elementsInSelection.add(element);
continue;
}
}
// 4. We don't need to handle when the selection is inside the element
// as it is separately handled in App.
}
if (framesInSelection) {
elementsInSelection.forEach((element) => {
if (element.frameId && framesInSelection.has(element.frameId)) {
elementsInSelection.delete(element);
}
});
}
if (type === "overlap") {
Array.from(elementsInSelection).forEach((element) => {
const groupId = element.groupIds.at(-1);
const group = groupId ? groups[groupId] : null;
group?.forEach((groupElement) => elementsInSelection.add(groupElement));
});
} else if (type === "contain") {
elementsInSelection.forEach((element) => {
// note: currently we only support top-level group handling since
// we don't support box selecting while editing the group/subgroup
// see https://github.com/excalidraw/excalidraw/pull/11234#issuecomment-4387654451
const groupId = element.groupIds.at(-1);
const group = groupId ? groups[groupId] : null;
if (
group &&
!group.every((groupElement) => elementsInSelection.has(groupElement))
) {
elementsInSelection.delete(element);
}
});
}
// to maintain original order elements (namely for group selection)
return elements.filter((element) => elementsInSelection.has(element));
};
export const elementCenterPoint = ( export const elementCenterPoint = (
element: ExcalidrawElement, element: ExcalidrawElement,
elementsMap: ElementsMap, elementsMap: ElementsMap,
xOffset: number = 0, xOffset: number = 0,
yOffset: number = 0, yOffset: number = 0,
) => { ) => {
if (isLinearElement(element) || isFreeDrawElement(element)) { if (isLinearElement(element)) {
const [x1, y1, x2, y2] = getElementAbsoluteCoords(element, elementsMap); const [x1, y1, x2, y2] = getElementAbsoluteCoords(element, elementsMap);
const [x, y] = pointFrom<GlobalPoint>((x1 + x2) / 2, (y1 + y2) / 2); const [x, y] = pointFrom<GlobalPoint>((x1 + x2) / 2, (y1 + y2) / 2);
+19 -46
View File
@@ -61,8 +61,6 @@ import { distanceToElement } from "./distance";
import { getBindingGap } from "./binding"; import { getBindingGap } from "./binding";
import { hasBackground } from "./comparisons";
import type { import type {
ElementsMap, ElementsMap,
ExcalidrawArrowElement, ExcalidrawArrowElement,
@@ -85,7 +83,7 @@ export const shouldTestInside = (element: ExcalidrawElement) => {
} }
const isDraggableFromInside = const isDraggableFromInside =
(hasBackground(element.type) && !isTransparent(element.backgroundColor)) || !isTransparent(element.backgroundColor) ||
hasBoundTextElement(element) || hasBoundTextElement(element) ||
isIframeLikeElement(element) || isIframeLikeElement(element) ||
isTextElement(element); isTextElement(element);
@@ -156,11 +154,14 @@ export const hitElementItself = ({
// Hit test against the extended, rotated bounding box of the element first // Hit test against the extended, rotated bounding box of the element first
const bounds = getElementBounds(element, elementsMap, true); const bounds = getElementBounds(element, elementsMap, true);
const hitBounds = isPointInRotatedBounds( const hitBounds = isPointWithinBounds(
point, pointFrom(bounds[0] - threshold, bounds[1] - threshold),
bounds, pointRotateRads(
element.angle, point,
threshold, getCenterForBounds(bounds),
-element.angle as Radians,
),
pointFrom(bounds[2] + threshold, bounds[3] + threshold),
); );
// PERF: Bail out early if the point is not even in the // PERF: Bail out early if the point is not even in the
@@ -191,32 +192,18 @@ export const hitElementItself = ({
return result; return result;
}; };
const isPointInRotatedBounds = (
point: GlobalPoint,
bounds: Bounds,
angle: Radians,
tolerance = 0,
) => {
const adjustedPoint =
angle === 0
? point
: pointRotateRads(point, getCenterForBounds(bounds), -angle as Radians);
return isPointWithinBounds(
pointFrom(bounds[0] - tolerance, bounds[1] - tolerance),
adjustedPoint,
pointFrom(bounds[2] + tolerance, bounds[3] + tolerance),
);
};
export const hitElementBoundingBox = ( export const hitElementBoundingBox = (
point: GlobalPoint, point: GlobalPoint,
element: ExcalidrawElement, element: ExcalidrawElement,
elementsMap: ElementsMap, elementsMap: ElementsMap,
tolerance = 0, tolerance = 0,
) => { ) => {
const bounds = getElementBounds(element, elementsMap, true); let [x1, y1, x2, y2] = getElementBounds(element, elementsMap);
return isPointInRotatedBounds(point, bounds, element.angle, tolerance); x1 -= tolerance;
y1 -= tolerance;
x2 += tolerance;
y2 += tolerance;
return isPointWithinBounds(pointFrom(x1, y1), point, pointFrom(x2, y2));
}; };
export const hitElementBoundingBoxOnly = ( export const hitElementBoundingBoxOnly = (
@@ -326,10 +313,7 @@ export const getAllHoveredElementAtPoint = (
) { ) {
candidateElements.push(element); candidateElements.push(element);
if ( if (!isTransparent(element.backgroundColor)) {
hasBackground(element.type) &&
!isTransparent(element.backgroundColor)
) {
break; break;
} }
} }
@@ -481,12 +465,7 @@ export const intersectElementWithLineSegment = (
case "line": case "line":
case "freedraw": case "freedraw":
case "arrow": case "arrow":
return intersectLinearOrFreeDrawWithLineSegment( return intersectLinearOrFreeDrawWithLineSegment(element, line, onlyFirst);
element,
line,
elementsMap,
onlyFirst,
);
} }
}; };
@@ -553,15 +532,11 @@ const lineIntersections = (
const intersectLinearOrFreeDrawWithLineSegment = ( const intersectLinearOrFreeDrawWithLineSegment = (
element: ExcalidrawLinearElement | ExcalidrawFreeDrawElement, element: ExcalidrawLinearElement | ExcalidrawFreeDrawElement,
segment: LineSegment<GlobalPoint>, segment: LineSegment<GlobalPoint>,
elementsMap: ElementsMap,
onlyFirst = false, onlyFirst = false,
): GlobalPoint[] => { ): GlobalPoint[] => {
// NOTE: This is the only one which return the decomposed elements // NOTE: This is the only one which return the decomposed elements
// rotated! This is due to taking advantage of roughjs definitions. // rotated! This is due to taking advantage of roughjs definitions.
const [lines, curves] = deconstructLinearOrFreeDrawElement( const [lines, curves] = deconstructLinearOrFreeDrawElement(element);
element,
elementsMap,
);
const intersections: GlobalPoint[] = []; const intersections: GlobalPoint[] = [];
for (const l of lines) { for (const l of lines) {
@@ -589,9 +564,7 @@ const intersectLinearOrFreeDrawWithLineSegment = (
continue; continue;
} }
const hits = curveIntersectLineSegment(c, segment, { const hits = curveIntersectLineSegment(c, segment);
iterLimit: 10,
});
if (hits.length > 0) { if (hits.length > 0) {
intersections.push(...hits); intersections.push(...hits);
-2
View File
@@ -38,8 +38,6 @@ export const hasStrokeStyle = (type: ElementOrToolType) =>
type === "arrow" || type === "arrow" ||
type === "line"; type === "line";
export const hasFreedrawMode = (type: ElementOrToolType) => type === "freedraw";
export const canChangeRoundness = (type: ElementOrToolType) => export const canChangeRoundness = (type: ElementOrToolType) =>
type === "rectangle" || type === "rectangle" ||
type === "iframe" || type === "iframe" ||
+2 -6
View File
@@ -48,7 +48,7 @@ export const distanceToElement = (
case "line": case "line":
case "arrow": case "arrow":
case "freedraw": case "freedraw":
return distanceToLinearOrFreeDraElement(element, elementsMap, p); return distanceToLinearOrFreeDraElement(element, p);
} }
}; };
@@ -133,13 +133,9 @@ const distanceToEllipseElement = (
const distanceToLinearOrFreeDraElement = ( const distanceToLinearOrFreeDraElement = (
element: ExcalidrawLinearElement | ExcalidrawFreeDrawElement, element: ExcalidrawLinearElement | ExcalidrawFreeDrawElement,
elementsMap: ElementsMap,
p: GlobalPoint, p: GlobalPoint,
) => { ) => {
const [lines, curves] = deconstructLinearOrFreeDrawElement( const [lines, curves] = deconstructLinearOrFreeDrawElement(element);
element,
elementsMap,
);
return Math.min( return Math.min(
...lines.map((s) => distanceToLineSegment(p, s)), ...lines.map((s) => distanceToLineSegment(p, s)),
...curves.map((a) => curvePointDistance(a, p)), ...curves.map((a) => curvePointDistance(a, p)),
+2 -22
View File
@@ -111,9 +111,6 @@ export const duplicateElements = (
* user interaction. * user interaction.
*/ */
type: "everything"; type: "everything";
// TODO remove/review this once we add frame children order migration
// and invariant checks
preserveFrameChildrenOrder?: boolean;
} }
| { | {
/** /**
@@ -173,8 +170,6 @@ export const duplicateElements = (
opts.type === "in-place" opts.type === "in-place"
? opts.idsOfElementsToDuplicate ? opts.idsOfElementsToDuplicate
: new Map(elements.map((el) => [el.id, el])); : new Map(elements.map((el) => [el.id, el]));
const preserveFrameChildrenOrder =
opts.type === "everything" && opts.preserveFrameChildrenOrder;
// For sanity // For sanity
if (opts.type === "in-place") { if (opts.type === "in-place") {
@@ -255,9 +250,6 @@ export const duplicateElements = (
elementsWithDuplicates.splice(index + 1, 0, ...castArray(elements)); elementsWithDuplicates.splice(index + 1, 0, ...castArray(elements));
}; };
// main
// ---------------------------------------------------------------------------
const frameIdsToDuplicate = new Set( const frameIdsToDuplicate = new Set(
elements elements
.filter( .filter(
@@ -282,7 +274,7 @@ export const duplicateElements = (
if (groupId) { if (groupId) {
const groupElements = getElementsInGroup(elements, groupId).flatMap( const groupElements = getElementsInGroup(elements, groupId).flatMap(
(element) => (element) =>
isFrameLikeElement(element) && !preserveFrameChildrenOrder isFrameLikeElement(element)
? [...getFrameChildren(elements, element.id), element] ? [...getFrameChildren(elements, element.id), element]
: [element], : [element],
); );
@@ -298,25 +290,13 @@ export const duplicateElements = (
// frame duplication // frame duplication
// ------------------------------------------------------------------------- // -------------------------------------------------------------------------
if ( if (element.frameId && frameIdsToDuplicate.has(element.frameId)) {
!preserveFrameChildrenOrder &&
element.frameId &&
frameIdsToDuplicate.has(element.frameId)
) {
continue; continue;
} }
if (isFrameLikeElement(element)) { if (isFrameLikeElement(element)) {
const frameId = element.id; const frameId = element.id;
if (preserveFrameChildrenOrder) {
insertBeforeOrAfterIndex(
findLastIndex(elementsWithDuplicates, (el) => el.id === frameId),
copyElements(element),
);
continue;
}
const frameChildren = getFrameChildren(elements, frameId); const frameChildren = getFrameChildren(elements, frameId);
const targetIndex = findLastIndex(elementsWithDuplicates, (el) => { const targetIndex = findLastIndex(elementsWithDuplicates, (el) => {
+2 -2
View File
@@ -2124,8 +2124,8 @@ const normalizeArrowElementUpdate = (
offsetY < -MAX_POS || offsetY < -MAX_POS ||
offsetY > MAX_POS || offsetY > MAX_POS ||
offsetX + points[points.length - 1][0] < -MAX_POS || offsetX + points[points.length - 1][0] < -MAX_POS ||
offsetX + points[points.length - 1][0] > MAX_POS || offsetY + points[points.length - 1][0] > MAX_POS ||
offsetY + points[points.length - 1][1] < -MAX_POS || offsetX + points[points.length - 1][1] < -MAX_POS ||
offsetY + points[points.length - 1][1] > MAX_POS offsetY + points[points.length - 1][1] > MAX_POS
) { ) {
console.error( console.error(
+2 -12
View File
@@ -1,9 +1,6 @@
import { arrayToMap } from "@excalidraw/common"; import { generateNKeysBetween } from "fractional-indexing";
import { import { arrayToMap } from "@excalidraw/common";
validateOrderKey,
generateNKeysBetween,
} from "@excalidraw/fractional-indexing";
import { mutateElement, newElementWith } from "./mutateElement"; import { mutateElement, newElementWith } from "./mutateElement";
import { getBoundTextElement } from "./textElement"; import { getBoundTextElement } from "./textElement";
@@ -385,13 +382,6 @@ const isValidFractionalIndex = (
return false; return false;
} }
try {
// Format validation
validateOrderKey(index);
} catch {
return false;
}
if (predecessor && successor) { if (predecessor && successor) {
return predecessor < index && index < successor; return predecessor < index && index < successor;
} }
+54 -113
View File
@@ -1,9 +1,7 @@
import { arrayToMap } from "@excalidraw/common"; import { arrayToMap } from "@excalidraw/common";
import { import { isPointWithinBounds, pointFrom } from "@excalidraw/math";
isPointWithinBounds, import { doLineSegmentsIntersect } from "@excalidraw/utils/bbox";
pointFrom, import { elementsOverlappingBBox } from "@excalidraw/utils/withinBounds";
segmentsIntersectAt,
} from "@excalidraw/math";
import type { import type {
AppClassProperties, AppClassProperties,
@@ -20,13 +18,9 @@ import {
getElementLineSegments, getElementLineSegments,
getCommonBounds, getCommonBounds,
getElementAbsoluteCoords, getElementAbsoluteCoords,
doBoundsIntersect,
getElementBounds,
boundsContainBounds,
} from "./bounds"; } from "./bounds";
import { mutateElement } from "./mutateElement"; import { mutateElement } from "./mutateElement";
import { getBoundTextElement, getContainerElement } from "./textElement"; import { getBoundTextElement, getContainerElement } from "./textElement";
import { syncMovedIndices } from "./fractionalIndex";
import { import {
isFrameElement, isFrameElement,
isFrameLikeElement, isFrameLikeElement,
@@ -81,7 +75,7 @@ export function isElementIntersectingFrame(
const intersecting = frameLineSegments.some((frameLineSegment) => const intersecting = frameLineSegments.some((frameLineSegment) =>
elementLineSegments.some((elementLineSegment) => elementLineSegments.some((elementLineSegment) =>
segmentsIntersectAt(frameLineSegment, elementLineSegment), doLineSegmentsIntersect(frameLineSegment, elementLineSegment),
), ),
); );
@@ -106,9 +100,8 @@ export const isElementContainingFrame = (
frame: ExcalidrawFrameLikeElement, frame: ExcalidrawFrameLikeElement,
elementsMap: ElementsMap, elementsMap: ElementsMap,
) => { ) => {
return boundsContainBounds( return getElementsWithinSelection([frame], element, elementsMap).some(
getElementBounds(element, elementsMap), (e) => e.id === frame.id,
getElementBounds(frame, elementsMap),
); );
}; };
@@ -495,44 +488,10 @@ export const filterElementsEligibleAsFrameChildren = (
return eligibleElements; return eligibleElements;
}; };
export const getCommonFrameId = (elements: readonly ExcalidrawElement[]) => {
let commonFrameId: ExcalidrawElement["frameId"] | undefined;
for (const element of elements) {
if (isFrameLikeElement(element) || !element.frameId) {
return null;
}
if (commonFrameId === undefined) {
commonFrameId = element.frameId;
} else if (commonFrameId !== element.frameId) {
return null;
}
}
return commonFrameId ?? null;
};
export const getFrameChildrenInsertionIndex = (
elements: readonly ExcalidrawElement[],
frameId: ExcalidrawFrameLikeElement["id"],
): number | null => {
for (let index = elements.length - 1; index >= 0; index--) {
const element = elements[index];
if (element.id === frameId) {
return index;
} else if (element.frameId === frameId) {
return index + 1;
}
}
return null;
};
/** /**
* Adds elements and their bound elements to frame. Reorders added elements to * Retains (or repairs for target frame) the ordering invriant where children
* be just below frame, or just above its highest child (whichever is higher). * elements come right before the parent frame:
* [el, el, child, child, frame, el]
* *
* @returns mutated allElements (same data structure) * @returns mutated allElements (same data structure)
*/ */
@@ -540,11 +499,19 @@ export const addElementsToFrame = <T extends ElementsMapOrArray>(
allElements: T, allElements: T,
elementsToAdd: NonDeletedExcalidrawElement[], elementsToAdd: NonDeletedExcalidrawElement[],
frame: ExcalidrawFrameLikeElement, frame: ExcalidrawFrameLikeElement,
appState: AppState,
): T => { ): T => {
const elementsMap = arrayToMap(allElements); const elementsMap = arrayToMap(allElements);
const commonFrameId = getCommonFrameId(elementsToAdd); const currTargetFrameChildrenMap = new Map<ExcalidrawElement["id"], true>();
for (const element of allElements.values()) {
if (element.frameId === frame.id) {
currTargetFrameChildrenMap.set(element.id, true);
}
}
const finalElementsToAdd = new Set<ExcalidrawElement>(); const suppliedElementsToAddSet = new Set(elementsToAdd.map((el) => el.id));
const finalElementsToAdd: ExcalidrawElement[] = [];
const otherFrames = new Set<ExcalidrawFrameLikeElement["id"]>(); const otherFrames = new Set<ExcalidrawFrameLikeElement["id"]>();
@@ -555,8 +522,7 @@ export const addElementsToFrame = <T extends ElementsMapOrArray>(
} }
// - add bound text elements if not already in the array // - add bound text elements if not already in the array
// - keep elements already in the frame so mixed selections can be reordered // - filter out elements that are already in the frame
// together
for (const element of omitGroupsContainingFrameLikes( for (const element of omitGroupsContainingFrameLikes(
allElements, allElements,
elementsToAdd, elementsToAdd,
@@ -569,64 +535,38 @@ export const addElementsToFrame = <T extends ElementsMapOrArray>(
continue; continue;
} }
finalElementsToAdd.add(element); // if the element is already in another frame (which is also in elementsToAdd),
// it means that frame and children are selected at the same time
// => keep original frame membership, do not add to the target frame
if (
element.frameId &&
appState.selectedElementIds[element.id] &&
appState.selectedElementIds[element.frameId]
) {
continue;
}
if (!currTargetFrameChildrenMap.has(element.id)) {
finalElementsToAdd.push(element);
}
const boundTextElement = getBoundTextElement(element, elementsMap); const boundTextElement = getBoundTextElement(element, elementsMap);
if (boundTextElement && !finalElementsToAdd.has(boundTextElement)) { if (
finalElementsToAdd.add(boundTextElement); boundTextElement &&
!suppliedElementsToAddSet.has(boundTextElement.id) &&
!currTargetFrameChildrenMap.has(boundTextElement.id)
) {
finalElementsToAdd.push(boundTextElement);
} }
} }
for (const element of finalElementsToAdd) { for (const element of finalElementsToAdd) {
// we don't always need to update the element if it's already in the frame, mutateElement(element, elementsMap, {
// but we still need to accumulate in finalElementsToAdd so we potentially frameId: frame.id,
// reorder them if added together });
if (element.frameId !== frame.id) {
mutateElement(element, elementsMap, {
frameId: frame.id,
});
}
} }
// (re)order elements to be just below the frame, return allElements;
// or just above the highest child if that is higher
// (latter case is denormalized order until we migrate)
// ---------------------------------------------------------------------------
if (
!finalElementsToAdd.size ||
// if all elements to add already belong to the frame, then we don't want to
// reorder (case: we're dragging element children within the frame)
commonFrameId === frame.id
) {
return allElements;
}
const otherElements = Array.from(allElements.values()).filter(
(element) => !finalElementsToAdd.has(element),
);
const insertionIndex = getFrameChildrenInsertionIndex(
otherElements,
frame.id,
);
if (insertionIndex === null) {
return allElements;
}
const reorderedElements = [
...otherElements.slice(0, insertionIndex),
...finalElementsToAdd,
...otherElements.slice(insertionIndex),
];
syncMovedIndices(reorderedElements, arrayToMap([...finalElementsToAdd]));
return (
Array.isArray(allElements)
? reorderedElements
: new Map(reorderedElements.map((element) => [element.id, element]))
) as T;
}; };
export const removeElementsFromFrame = ( export const removeElementsFromFrame = (
@@ -680,11 +620,13 @@ export const replaceAllElementsInFrame = <T extends ExcalidrawElement>(
allElements: readonly T[], allElements: readonly T[],
nextElementsInFrame: ExcalidrawElement[], nextElementsInFrame: ExcalidrawElement[],
frame: ExcalidrawFrameLikeElement, frame: ExcalidrawFrameLikeElement,
app: AppClassProperties,
): T[] => { ): T[] => {
return addElementsToFrame( return addElementsToFrame(
removeAllElementsFromFrame(allElements, frame), removeAllElementsFromFrame(allElements, frame),
nextElementsInFrame, nextElementsInFrame,
frame, frame,
app.state,
).slice(); ).slice();
}; };
@@ -978,17 +920,16 @@ export const getFrameLikeTitle = (element: ExcalidrawFrameLikeElement) => {
export const getElementsOverlappingFrame = ( export const getElementsOverlappingFrame = (
elements: readonly ExcalidrawElement[], elements: readonly ExcalidrawElement[],
frame: ExcalidrawFrameLikeElement, frame: ExcalidrawFrameLikeElement,
elementsMap: ElementsMap,
) => { ) => {
return elements.filter( return (
(el) => elementsOverlappingBBox({
// exclude elements which are overlapping, but are in a different frame, elements,
bounds: frame,
type: "overlap",
})
// removes elements who are overlapping, but are in a different frame,
// and thus invisible in target frame // and thus invisible in target frame
(!el.frameId || el.frameId === frame.id) && .filter((el) => !el.frameId || el.frameId === frame.id)
doBoundsIntersect(
getElementBounds(el, elementsMap),
getElementBounds(frame, elementsMap),
),
); );
}; };
-1
View File
@@ -99,4 +99,3 @@ export * from "./typeChecks";
export * from "./utils"; export * from "./utils";
export * from "./zindex"; export * from "./zindex";
export * from "./arrows/helpers"; export * from "./arrows/helpers";
export * from "./arrowheads";
+18 -35
View File
@@ -476,22 +476,16 @@ export class LinearElementEditor {
}); });
} }
if ( invariant(
lastClickedPoint < 0 || lastClickedPoint > -1 &&
!selectedPointsIndices.includes(lastClickedPoint) || selectedPointsIndices.includes(lastClickedPoint) &&
!element.points[lastClickedPoint] element.points[lastClickedPoint],
) { `There must be a valid lastClickedPoint in order to drag it. selectedPointsIndices(${JSON.stringify(
console.error( selectedPointsIndices,
`There must be a valid lastClickedPoint in order to drag it. selectedPointsIndices(${JSON.stringify( )}) points(0..${
selectedPointsIndices, element.points.length - 1
)}) points(0..${ }) lastClickedPoint(${lastClickedPoint})`,
element.points.length - 1 );
}) lastClickedPoint(${lastClickedPoint}) isElbowArrow: ${elbowed}`,
);
// Fall back to the actual last point as a last resort.
lastClickedPoint = element.points.length - 1;
}
// point that's being dragged (out of all selected points) // point that's being dragged (out of all selected points)
const draggingPoint = element.points[lastClickedPoint]; const draggingPoint = element.points[lastClickedPoint];
@@ -800,7 +794,6 @@ export class LinearElementEditor {
element.points[index + 1], element.points[index + 1],
index, index,
appState.zoom, appState.zoom,
elementsMap,
) )
) { ) {
midpoints.push(null); midpoints.push(null);
@@ -810,7 +803,6 @@ export class LinearElementEditor {
const segmentMidPoint = LinearElementEditor.getSegmentMidPoint( const segmentMidPoint = LinearElementEditor.getSegmentMidPoint(
element, element,
index + 1, index + 1,
elementsMap,
); );
midpoints.push(segmentMidPoint); midpoints.push(segmentMidPoint);
index++; index++;
@@ -898,7 +890,6 @@ export class LinearElementEditor {
endPoint: P, endPoint: P,
index: number, index: number,
zoom: Zoom, zoom: Zoom,
elementsMap: ElementsMap,
) { ) {
if (isElbowArrow(element)) { if (isElbowArrow(element)) {
if (index >= 0 && index < element.points.length) { if (index >= 0 && index < element.points.length) {
@@ -913,10 +904,7 @@ export class LinearElementEditor {
let distance = pointDistance(startPoint, endPoint); let distance = pointDistance(startPoint, endPoint);
if (element.points.length > 2 && element.roundness) { if (element.points.length > 2 && element.roundness) {
const [lines, curves] = deconstructLinearOrFreeDrawElement( const [lines, curves] = deconstructLinearOrFreeDrawElement(element);
element,
elementsMap,
);
invariant( invariant(
lines.length === 0 && curves.length > 0, lines.length === 0 && curves.length > 0,
@@ -936,7 +924,6 @@ export class LinearElementEditor {
static getSegmentMidPoint( static getSegmentMidPoint(
element: NonDeleted<ExcalidrawLinearElement>, element: NonDeleted<ExcalidrawLinearElement>,
index: number, index: number,
elementsMap: ElementsMap,
): GlobalPoint { ): GlobalPoint {
if (isElbowArrow(element)) { if (isElbowArrow(element)) {
invariant( invariant(
@@ -949,10 +936,7 @@ export class LinearElementEditor {
return pointFrom<GlobalPoint>(element.x + p[0], element.y + p[1]); return pointFrom<GlobalPoint>(element.x + p[0], element.y + p[1]);
} }
const [lines, curves] = deconstructLinearOrFreeDrawElement( const [lines, curves] = deconstructLinearOrFreeDrawElement(element);
element,
elementsMap,
);
invariant( invariant(
(lines.length === 0 && curves.length > 0) || (lines.length === 0 && curves.length > 0) ||
@@ -1867,7 +1851,6 @@ export class LinearElementEditor {
const midSegmentMidpoint = LinearElementEditor.getSegmentMidPoint( const midSegmentMidpoint = LinearElementEditor.getSegmentMidPoint(
element, element,
index + 1, index + 1,
elementsMap,
); );
x = midSegmentMidpoint[0] - boundTextElement.width / 2; x = midSegmentMidpoint[0] - boundTextElement.width / 2;
@@ -2139,13 +2122,13 @@ const pointDraggingUpdates = (
} => { } => {
const naiveDraggingPoints = new Map( const naiveDraggingPoints = new Map(
selectedPointsIndices.map((pointIndex) => { selectedPointsIndices.map((pointIndex) => {
// NOTE: Avoid stale point index issue potentially caused by elbow
// arrows unpredictably changing the number of points during dragging
const point = element.points[pointIndex] ?? element.points.at(-1);
return [ return [
pointIndex, pointIndex,
{ {
point: pointFrom<LocalPoint>(point[0] + deltaX, point[1] + deltaY), point: pointFrom<LocalPoint>(
element.points[pointIndex][0] + deltaX,
element.points[pointIndex][1] + deltaY,
),
isDragging: true, isDragging: true,
}, },
]; ];
@@ -2417,7 +2400,7 @@ const pointDraggingUpdates = (
? nextArrow.points[0] ? nextArrow.points[0]
: endBindable : endBindable
? updateBoundPoint( ? updateBoundPoint(
nextArrow, element,
"endBinding", "endBinding",
nextArrow.endBinding, nextArrow.endBinding,
endBindable, endBindable,
@@ -2448,7 +2431,7 @@ const pointDraggingUpdates = (
? endLocalPoint ? endLocalPoint
: startBindable : startBindable
? updateBoundPoint( ? updateBoundPoint(
nextArrow, element,
"startBinding", "startBinding",
nextArrow.startBinding, nextArrow.startBinding,
startBindable, startBindable,
-6
View File
@@ -4,7 +4,6 @@ import {
DEFAULT_FONT_SIZE, DEFAULT_FONT_SIZE,
DEFAULT_TEXT_ALIGN, DEFAULT_TEXT_ALIGN,
DEFAULT_VERTICAL_ALIGN, DEFAULT_VERTICAL_ALIGN,
DEFAULT_STROKE_STREAMLINE,
VERTICAL_ALIGN, VERTICAL_ALIGN,
randomInteger, randomInteger,
randomId, randomId,
@@ -445,7 +444,6 @@ export const newFreeDrawElement = (
type: "freedraw"; type: "freedraw";
points?: ExcalidrawFreeDrawElement["points"]; points?: ExcalidrawFreeDrawElement["points"];
simulatePressure: boolean; simulatePressure: boolean;
strokeOptions?: ExcalidrawFreeDrawElement["strokeOptions"];
pressures?: ExcalidrawFreeDrawElement["pressures"]; pressures?: ExcalidrawFreeDrawElement["pressures"];
} & ElementConstructorOpts, } & ElementConstructorOpts,
): NonDeleted<ExcalidrawFreeDrawElement> => { ): NonDeleted<ExcalidrawFreeDrawElement> => {
@@ -454,10 +452,6 @@ export const newFreeDrawElement = (
points: opts.points || [], points: opts.points || [],
pressures: opts.pressures || [], pressures: opts.pressures || [],
simulatePressure: opts.simulatePressure, simulatePressure: opts.simulatePressure,
strokeOptions: opts.strokeOptions ?? {
variability: "variable",
streamline: DEFAULT_STROKE_STREAMLINE,
},
}; };
}; };
+63 -50
View File
@@ -422,10 +422,10 @@ const drawElementOnCanvas = (
for (const shape of shapes) { for (const shape of shapes) {
if (typeof shape === "string") { if (typeof shape === "string") {
context.fillStyle = applyDarkModeFilter( context.fillStyle =
element.strokeColor, renderConfig.theme === THEME.DARK
renderConfig.theme === THEME.DARK, ? applyDarkModeFilter(element.strokeColor)
); : element.strokeColor;
context.fill(new Path2D(shape)); context.fill(new Path2D(shape));
} else { } else {
rc.draw(shape); rc.draw(shape);
@@ -555,10 +555,10 @@ const drawElementOnCanvas = (
context.canvas.setAttribute("dir", rtl ? "rtl" : "ltr"); context.canvas.setAttribute("dir", rtl ? "rtl" : "ltr");
context.save(); context.save();
context.font = getFontString(element); context.font = getFontString(element);
context.fillStyle = applyDarkModeFilter( context.fillStyle =
element.strokeColor, renderConfig.theme === THEME.DARK
renderConfig.theme === THEME.DARK, ? applyDarkModeFilter(element.strokeColor)
); : element.strokeColor;
context.textAlign = element.textAlign as CanvasTextAlign; context.textAlign = element.textAlign as CanvasTextAlign;
// Canvas does not support multiline text by default // Canvas does not support multiline text by default
@@ -811,10 +811,10 @@ export const renderElement = (
context.fillStyle = "rgba(0, 0, 200, 0.04)"; context.fillStyle = "rgba(0, 0, 200, 0.04)";
context.lineWidth = FRAME_STYLE.strokeWidth / appState.zoom.value; context.lineWidth = FRAME_STYLE.strokeWidth / appState.zoom.value;
context.strokeStyle = applyDarkModeFilter( context.strokeStyle =
FRAME_STYLE.strokeColor, appState.theme === THEME.DARK
appState.theme === THEME.DARK, ? applyDarkModeFilter(FRAME_STYLE.strokeColor)
); : FRAME_STYLE.strokeColor;
// TODO change later to only affect AI frames // TODO change later to only affect AI frames
if (isMagicFrameElement(element)) { if (isMagicFrameElement(element)) {
@@ -889,10 +889,8 @@ export const renderElement = (
case "embeddable": { case "embeddable": {
if (renderConfig.isExporting) { if (renderConfig.isExporting) {
const [x1, y1, x2, y2] = getElementAbsoluteCoords(element, elementsMap); const [x1, y1, x2, y2] = getElementAbsoluteCoords(element, elementsMap);
const centerX = (x1 + x2) / 2; const cx = (x1 + x2) / 2 + appState.scrollX;
const centerY = (y1 + y2) / 2; const cy = (y1 + y2) / 2 + appState.scrollY;
const cx = centerX + appState.scrollX;
const cy = centerY + appState.scrollY;
let shiftX = (x2 - x1) / 2 - (element.x - x1); let shiftX = (x2 - x1) / 2 - (element.x - x1);
let shiftY = (y2 - y1) / 2 - (element.y - y1); let shiftY = (y2 - y1) / 2 - (element.y - y1);
if (isTextElement(element)) { if (isTextElement(element)) {
@@ -914,49 +912,64 @@ export const renderElement = (
const boundTextElement = getBoundTextElement(element, elementsMap); const boundTextElement = getBoundTextElement(element, elementsMap);
if (isArrowElement(element) && boundTextElement) { if (isArrowElement(element) && boundTextElement) {
// Draw arrow directly as vector and clear label hole separately. const tempCanvas = document.createElement("canvas");
// This avoids temp-canvas bitmap blit which introduces resampling blur.
const tempCanvasContext = tempCanvas.getContext("2d")!;
// Take max dimensions of arrow canvas so that when canvas is rotated
// the arrow doesn't get clipped
const maxDim = Math.max(distance(x1, x2), distance(y1, y2));
const padding = getCanvasPadding(element);
tempCanvas.width =
maxDim * appState.exportScale + padding * 10 * appState.exportScale;
tempCanvas.height =
maxDim * appState.exportScale + padding * 10 * appState.exportScale;
tempCanvasContext.translate(
tempCanvas.width / 2,
tempCanvas.height / 2,
);
tempCanvasContext.scale(appState.exportScale, appState.exportScale);
// Shift the canvas to left most point of the arrow
shiftX = element.width / 2 - (element.x - x1); shiftX = element.width / 2 - (element.x - x1);
shiftY = element.height / 2 - (element.y - y1); shiftY = element.height / 2 - (element.y - y1);
context.save(); tempCanvasContext.rotate(element.angle);
context.rotate(element.angle); const tempRc = rough.canvas(tempCanvas);
context.translate(-shiftX, -shiftY);
drawElementOnCanvas(element, rc, context, renderConfig);
context.restore();
tempCanvasContext.translate(-shiftX, -shiftY);
drawElementOnCanvas(element, tempRc, tempCanvasContext, renderConfig);
tempCanvasContext.translate(shiftX, shiftY);
tempCanvasContext.rotate(-element.angle);
// Shift the canvas to center of bound text
const [, , , , boundTextCx, boundTextCy] = getElementAbsoluteCoords( const [, , , , boundTextCx, boundTextCy] = getElementAbsoluteCoords(
boundTextElement, boundTextElement,
elementsMap, elementsMap,
); );
const holeX = const boundTextShiftX = (x1 + x2) / 2 - boundTextCx;
boundTextCx - const boundTextShiftY = (y1 + y2) / 2 - boundTextCy;
centerX - tempCanvasContext.translate(-boundTextShiftX, -boundTextShiftY);
boundTextElement.width / 2 -
BOUND_TEXT_PADDING;
const holeY =
boundTextCy -
centerY -
boundTextElement.height / 2 -
BOUND_TEXT_PADDING;
const holeWidth = boundTextElement.width + BOUND_TEXT_PADDING * 2;
const holeHeight = boundTextElement.height + BOUND_TEXT_PADDING * 2;
const isTransparentHole = // Clear the bound text area
"viewBackgroundColor" in appState && tempCanvasContext.clearRect(
(appState.viewBackgroundColor === "transparent" || -boundTextElement.width / 2,
!appState.viewBackgroundColor); -boundTextElement.height / 2,
if (!isTransparentHole) { boundTextElement.width,
context.save(); boundTextElement.height,
context.fillStyle = applyDarkModeFilter( );
renderConfig.canvasBackgroundColor, context.scale(1 / appState.exportScale, 1 / appState.exportScale);
renderConfig.theme === THEME.DARK, context.drawImage(
); tempCanvas,
context.fillRect(holeX, holeY, holeWidth, holeHeight); -tempCanvas.width / 2,
context.restore(); -tempCanvas.height / 2,
} else { tempCanvas.width,
context.clearRect(holeX, holeY, holeWidth, holeHeight); tempCanvas.height,
} );
} else { } else {
context.rotate(element.angle); context.rotate(element.angle);
+59 -61
View File
@@ -1,20 +1,22 @@
import { arrayToMap, isShallowEqual, type Bounds } from "@excalidraw/common"; import { arrayToMap, isShallowEqual } from "@excalidraw/common";
import type { import type {
AppState, AppState,
BoxSelectionMode,
InteractiveCanvasAppState, InteractiveCanvasAppState,
} from "@excalidraw/excalidraw/types"; } from "@excalidraw/excalidraw/types";
import { elementsOverlappingBBox, getElementAbsoluteCoords } from "./bounds"; import { getElementAbsoluteCoords, getElementBounds } from "./bounds";
import { isElementInViewport } from "./sizeHelpers"; import { isElementInViewport } from "./sizeHelpers";
import { import {
isBoundToContainer, isBoundToContainer,
isFrameLikeElement, isFrameLikeElement,
isLinearElement, isLinearElement,
isTextElement,
} from "./typeChecks"; } from "./typeChecks";
import { getFrameChildren } from "./frame"; import {
elementOverlapsWithFrame,
getContainingFrame,
getFrameChildren,
} from "./frame";
import { LinearElementEditor } from "./linearElementEditor"; import { LinearElementEditor } from "./linearElementEditor";
import { selectGroupsForSelectedElements } from "./groups"; import { selectGroupsForSelectedElements } from "./groups";
@@ -23,27 +25,9 @@ import type {
ElementsMap, ElementsMap,
ElementsMapOrArray, ElementsMapOrArray,
ExcalidrawElement, ExcalidrawElement,
ExcalidrawFrameLikeElement,
NonDeleted,
NonDeletedExcalidrawElement, NonDeletedExcalidrawElement,
} from "./types"; } from "./types";
const shouldIgnoreElementFromSelection = (
element: NonDeletedExcalidrawElement,
) => element.locked || isBoundToContainer(element);
const excludeElementsFromFrames = <T extends ExcalidrawElement>(
selectedElements: readonly T[],
framesInSelection: Set<ExcalidrawFrameLikeElement["id"]>,
) => {
return selectedElements.filter((element) => {
if (element.frameId && framesInSelection.has(element.frameId)) {
return false;
}
return true;
});
};
/** /**
* Frames and their containing elements are not to be selected at the same time. * Frames and their containing elements are not to be selected at the same time.
* Given an array of selected elements, if there are frames and their containing elements * Given an array of selected elements, if there are frames and their containing elements
@@ -63,38 +47,68 @@ export const excludeElementsInFramesFromSelection = <
} }
}); });
return excludeElementsFromFrames(selectedElements, framesInSelection); return selectedElements.filter((element) => {
if (element.frameId && framesInSelection.has(element.frameId)) {
return false;
}
return true;
});
}; };
export const getElementsWithinSelection = ( export const getElementsWithinSelection = (
elements: readonly NonDeletedExcalidrawElement[], elements: readonly NonDeletedExcalidrawElement[],
selection: NonDeletedExcalidrawElement, selection: NonDeletedExcalidrawElement,
elementsMap: ElementsMap, elementsMap: ElementsMap,
// TODO remove (this flag is effectively unused AFAIK)
excludeElementsInFrames: boolean = true, excludeElementsInFrames: boolean = true,
boxSelectionMode: BoxSelectionMode = "contain", ) => {
): NonDeletedExcalidrawElement[] => { const [selectionX1, selectionY1, selectionX2, selectionY2] =
const [selectionStartX, selectionStartY, selectionEndX, selectionEndY] =
getElementAbsoluteCoords(selection, elementsMap); getElementAbsoluteCoords(selection, elementsMap);
const selectionX1 = Math.min(selectionStartX, selectionEndX);
const selectionY1 = Math.min(selectionStartY, selectionEndY);
const selectionX2 = Math.max(selectionStartX, selectionEndX);
const selectionY2 = Math.max(selectionStartY, selectionEndY);
const selectionBounds = [
selectionX1,
selectionY1,
selectionX2,
selectionY2,
] as Bounds;
return elementsOverlappingBBox({ let elementsInSelection = elements.filter((element) => {
elements, let [elementX1, elementY1, elementX2, elementY2] = getElementBounds(
bounds: selectionBounds, element,
elementsMap, elementsMap,
type: boxSelectionMode, );
shouldIgnoreElementFromSelection,
excludeElementsInFrames, const containingFrame = getContainingFrame(element, elementsMap);
if (containingFrame) {
const [fx1, fy1, fx2, fy2] = getElementBounds(
containingFrame,
elementsMap,
);
elementX1 = Math.max(fx1, elementX1);
elementY1 = Math.max(fy1, elementY1);
elementX2 = Math.min(fx2, elementX2);
elementY2 = Math.min(fy2, elementY2);
}
return (
element.locked === false &&
element.type !== "selection" &&
!isBoundToContainer(element) &&
selectionX1 <= elementX1 &&
selectionY1 <= elementY1 &&
selectionX2 >= elementX2 &&
selectionY2 >= elementY2
);
}); });
elementsInSelection = excludeElementsInFrames
? excludeElementsInFramesFromSelection(elementsInSelection)
: elementsInSelection;
elementsInSelection = elementsInSelection.filter((element) => {
const containingFrame = getContainingFrame(element, elementsMap);
if (containingFrame) {
return elementOverlapsWithFrame(element, containingFrame, elementsMap);
}
return true;
});
return elementsInSelection;
}; };
export const getVisibleAndNonSelectedElements = ( export const getVisibleAndNonSelectedElements = (
@@ -274,19 +288,3 @@ export const getSelectionStateForElements = (
), ),
}; };
}; };
/**
* Returns editing or single-selected text element, if any.
*/
export const getActiveTextElement = (
selectedElements: readonly NonDeleted<ExcalidrawElement>[],
appState: Pick<AppState, "editingTextElement">,
) => {
const activeTextElement =
appState.editingTextElement ||
(selectedElements.length === 1 &&
isTextElement(selectedElements[0]) &&
selectedElements[0]);
return activeTextElement || null;
};
+121 -304
View File
@@ -1,6 +1,5 @@
import { simplify } from "points-on-curve"; import { simplify } from "points-on-curve";
import { getStroke } from "perfect-freehand"; import { getStroke } from "perfect-freehand";
import { LaserPointer } from "@excalidraw/laser-pointer";
import { import {
type GeometricShape, type GeometricShape,
@@ -25,7 +24,6 @@ import {
COLOR_PALETTE, COLOR_PALETTE,
LINE_POLYGON_POINT_MERGE_DISTANCE, LINE_POLYGON_POINT_MERGE_DISTANCE,
applyDarkModeFilter, applyDarkModeFilter,
DEFAULT_STROKE_STREAMLINE,
} from "@excalidraw/common"; } from "@excalidraw/common";
import { RoughGenerator } from "roughjs/bin/generator"; import { RoughGenerator } from "roughjs/bin/generator";
@@ -59,8 +57,8 @@ import { headingForPointIsHorizontal } from "./heading";
import { canChangeRoundness } from "./comparisons"; import { canChangeRoundness } from "./comparisons";
import { import {
elementCenterPoint,
getArrowheadPoints, getArrowheadPoints,
getCenterForBounds,
getDiamondPoints, getDiamondPoints,
getElementAbsoluteCoords, getElementAbsoluteCoords,
} from "./bounds"; } from "./bounds";
@@ -71,10 +69,10 @@ import type {
NonDeletedExcalidrawElement, NonDeletedExcalidrawElement,
ExcalidrawSelectionElement, ExcalidrawSelectionElement,
ExcalidrawLinearElement, ExcalidrawLinearElement,
Arrowhead,
ExcalidrawFreeDrawElement, ExcalidrawFreeDrawElement,
ElementsMap, ElementsMap,
ExcalidrawLineElement, ExcalidrawLineElement,
Arrowhead,
} from "./types"; } from "./types";
import type { Drawable, Options } from "roughjs/bin/core"; import type { Drawable, Options } from "roughjs/bin/core";
@@ -220,7 +218,9 @@ export const generateRoughOptions = (
fillWeight: element.strokeWidth / 2, fillWeight: element.strokeWidth / 2,
hachureGap: element.strokeWidth * 4, hachureGap: element.strokeWidth * 4,
roughness: adjustRoughness(element), roughness: adjustRoughness(element),
stroke: applyDarkModeFilter(element.strokeColor, isDarkMode), stroke: isDarkMode
? applyDarkModeFilter(element.strokeColor)
: element.strokeColor,
preserveVertices: preserveVertices:
continuousPath || element.roughness < ROUGHNESS.cartoonist, continuousPath || element.roughness < ROUGHNESS.cartoonist,
}; };
@@ -234,7 +234,9 @@ export const generateRoughOptions = (
options.fillStyle = element.fillStyle; options.fillStyle = element.fillStyle;
options.fill = isTransparent(element.backgroundColor) options.fill = isTransparent(element.backgroundColor)
? undefined ? undefined
: applyDarkModeFilter(element.backgroundColor, isDarkMode); : isDarkMode
? applyDarkModeFilter(element.backgroundColor)
: element.backgroundColor;
if (element.type === "ellipse") { if (element.type === "ellipse") {
options.curveFitting = 1; options.curveFitting = 1;
} }
@@ -247,7 +249,9 @@ export const generateRoughOptions = (
options.fill = options.fill =
element.backgroundColor === "transparent" element.backgroundColor === "transparent"
? undefined ? undefined
: applyDarkModeFilter(element.backgroundColor, isDarkMode); : isDarkMode
? applyDarkModeFilter(element.backgroundColor)
: element.backgroundColor;
} }
return options; return options;
} }
@@ -292,82 +296,6 @@ const modifyIframeLikeForRoughOptions = (
return element; return element;
}; };
const generateArrowheadCardinalityOne = (
generator: RoughGenerator,
arrowheadPoints: number[] | null,
lineOptions: Options,
) => {
if (arrowheadPoints === null) {
return [];
}
const [, , x3, y3, x4, y4] = arrowheadPoints;
return [generator.line(x3, y3, x4, y4, lineOptions)];
};
const generateArrowheadLinesToTip = (
generator: RoughGenerator,
arrowheadPoints: number[] | null,
lineOptions: Options,
) => {
if (arrowheadPoints === null) {
return [];
}
const [x2, y2, x3, y3, x4, y4] = arrowheadPoints;
return [
generator.line(x3, y3, x2, y2, lineOptions),
generator.line(x4, y4, x2, y2, lineOptions),
];
};
const getArrowheadLineOptions = (
element: ExcalidrawLinearElement,
options: Options,
) => {
const lineOptions = { ...options };
if (element.strokeStyle === "dotted") {
// for dotted arrows caps, reduce gap to make it more legible
const dash = getDashArrayDotted(element.strokeWidth - 1);
lineOptions.strokeLineDash = [dash[0], dash[1] - 1];
} else {
// for solid/dashed, keep solid arrow cap
delete lineOptions.strokeLineDash;
}
lineOptions.roughness = Math.min(1, lineOptions.roughness || 0);
return lineOptions;
};
const generateArrowheadOutlineCircle = (
generator: RoughGenerator,
options: Options,
strokeColor: string,
arrowheadPoints: number[] | null,
fill: string,
diameterScale = 1,
) => {
if (arrowheadPoints === null) {
return [];
}
const [x, y, diameter] = arrowheadPoints;
const circleOptions = {
...options,
fill,
fillStyle: "solid" as const,
stroke: strokeColor,
roughness: Math.min(0.5, options.roughness || 0),
};
delete circleOptions.strokeLineDash;
return [generator.circle(x, y, diameter * diameterScale, circleOptions)];
};
const getArrowheadShapes = ( const getArrowheadShapes = (
element: ExcalidrawLinearElement, element: ExcalidrawLinearElement,
shape: Drawable[], shape: Drawable[],
@@ -378,53 +306,63 @@ const getArrowheadShapes = (
canvasBackgroundColor: string, canvasBackgroundColor: string,
isDarkMode: boolean, isDarkMode: boolean,
) => { ) => {
if (arrowhead === null) { const arrowheadPoints = getArrowheadPoints(
element,
shape,
position,
arrowhead,
);
if (arrowheadPoints === null) {
return []; return [];
} }
const strokeColor = applyDarkModeFilter(element.strokeColor, isDarkMode); const generateCrowfootOne = (
const backgroundFillColor = applyDarkModeFilter( arrowheadPoints: number[] | null,
canvasBackgroundColor, options: Options,
isDarkMode, ) => {
); if (arrowheadPoints === null) {
const cardinalityOneOrManyOffset = -0.25; return [];
const cardinalityZeroCircleScale = 0.8; }
const [, , x3, y3, x4, y4] = arrowheadPoints;
return [generator.line(x3, y3, x4, y4, options)];
};
const strokeColor = isDarkMode
? applyDarkModeFilter(element.strokeColor)
: element.strokeColor;
switch (arrowhead) { switch (arrowhead) {
case "dot":
case "circle": case "circle":
case "circle_outline": { case "circle_outline": {
return generateArrowheadOutlineCircle( const [x, y, diameter] = arrowheadPoints;
generator,
options, // always use solid stroke for arrowhead
strokeColor, delete options.strokeLineDash;
getArrowheadPoints(element, shape, position, arrowhead),
arrowhead === "circle_outline" ? backgroundFillColor : strokeColor, return [
); generator.circle(x, y, diameter, {
...options,
fill:
arrowhead === "circle_outline"
? canvasBackgroundColor
: strokeColor,
fillStyle: "solid",
stroke: strokeColor,
roughness: Math.min(0.5, options.roughness || 0),
}),
];
} }
case "triangle": case "triangle":
case "triangle_outline": { case "triangle_outline": {
const arrowheadPoints = getArrowheadPoints(
element,
shape,
position,
arrowhead,
);
if (arrowheadPoints === null) {
return [];
}
const [x, y, x2, y2, x3, y3] = arrowheadPoints; const [x, y, x2, y2, x3, y3] = arrowheadPoints;
const triangleOptions = {
...options,
fill:
arrowhead === "triangle_outline" ? backgroundFillColor : strokeColor,
fillStyle: "solid" as const,
roughness: Math.min(1, options.roughness || 0),
};
// always use solid stroke for arrowhead // always use solid stroke for arrowhead
delete triangleOptions.strokeLineDash; delete options.strokeLineDash;
return [ return [
generator.polygon( generator.polygon(
@@ -434,34 +372,24 @@ const getArrowheadShapes = (
[x3, y3], [x3, y3],
[x, y], [x, y],
], ],
triangleOptions, {
...options,
fill:
arrowhead === "triangle_outline"
? canvasBackgroundColor
: strokeColor,
fillStyle: "solid",
roughness: Math.min(1, options.roughness || 0),
},
), ),
]; ];
} }
case "diamond": case "diamond":
case "diamond_outline": { case "diamond_outline": {
const arrowheadPoints = getArrowheadPoints(
element,
shape,
position,
arrowhead,
);
if (arrowheadPoints === null) {
return [];
}
const [x, y, x2, y2, x3, y3, x4, y4] = arrowheadPoints; const [x, y, x2, y2, x3, y3, x4, y4] = arrowheadPoints;
const diamondOptions = {
...options,
fill:
arrowhead === "diamond_outline" ? backgroundFillColor : strokeColor,
fillStyle: "solid" as const,
roughness: Math.min(1, options.roughness || 0),
};
// always use solid stroke for arrowhead // always use solid stroke for arrowhead
delete diamondOptions.strokeLineDash; delete options.strokeLineDash;
return [ return [
generator.polygon( generator.polygon(
@@ -472,117 +400,53 @@ const getArrowheadShapes = (
[x4, y4], [x4, y4],
[x, y], [x, y],
], ],
diamondOptions, {
), ...options,
]; fill:
} arrowhead === "diamond_outline"
case "cardinality_one": ? canvasBackgroundColor
return generateArrowheadCardinalityOne( : strokeColor,
generator, fillStyle: "solid",
getArrowheadPoints(element, shape, position, arrowhead), roughness: Math.min(1, options.roughness || 0),
getArrowheadLineOptions(element, options), },
);
case "cardinality_many":
return generateArrowheadLinesToTip(
generator,
getArrowheadPoints(element, shape, position, arrowhead),
getArrowheadLineOptions(element, options),
);
case "cardinality_one_or_many": {
const lineOptions = getArrowheadLineOptions(element, options);
return [
...generateArrowheadLinesToTip(
generator,
getArrowheadPoints(element, shape, position, "cardinality_many"),
lineOptions,
),
...generateArrowheadCardinalityOne(
generator,
getArrowheadPoints(
element,
shape,
position,
"cardinality_one",
cardinalityOneOrManyOffset,
),
lineOptions,
),
];
}
case "cardinality_exactly_one": {
const lineOptions = getArrowheadLineOptions(element, options);
return [
...generateArrowheadCardinalityOne(
generator,
getArrowheadPoints(element, shape, position, "cardinality_one", -0.5),
lineOptions,
),
...generateArrowheadCardinalityOne(
generator,
getArrowheadPoints(element, shape, position, "cardinality_one"),
lineOptions,
),
];
}
case "cardinality_zero_or_one": {
const lineOptions = getArrowheadLineOptions(element, options);
return [
...generateArrowheadOutlineCircle(
generator,
options,
strokeColor,
getArrowheadPoints(element, shape, position, "circle_outline", 1.5),
backgroundFillColor,
cardinalityZeroCircleScale,
),
...generateArrowheadCardinalityOne(
generator,
getArrowheadPoints(element, shape, position, "cardinality_one", -0.5),
lineOptions,
),
];
}
case "cardinality_zero_or_many": {
const lineOptions = getArrowheadLineOptions(element, options);
return [
...generateArrowheadLinesToTip(
generator,
getArrowheadPoints(element, shape, position, "cardinality_many"),
lineOptions,
),
...generateArrowheadOutlineCircle(
generator,
options,
strokeColor,
getArrowheadPoints(element, shape, position, "circle_outline", 1.5),
backgroundFillColor,
cardinalityZeroCircleScale,
), ),
]; ];
} }
case "crowfoot_one":
return generateCrowfootOne(arrowheadPoints, options);
case "bar": case "bar":
case "arrow": case "arrow":
case "crowfoot_many":
case "crowfoot_one_or_many":
default: { default: {
return generateArrowheadLinesToTip( const [x2, y2, x3, y3, x4, y4] = arrowheadPoints;
generator,
getArrowheadPoints(element, shape, position, arrowhead), if (element.strokeStyle === "dotted") {
getArrowheadLineOptions(element, options), // for dotted arrows caps, reduce gap to make it more legible
); const dash = getDashArrayDotted(element.strokeWidth - 1);
options.strokeLineDash = [dash[0], dash[1] - 1];
} else {
// for solid/dashed, keep solid arrow cap
delete options.strokeLineDash;
}
options.roughness = Math.min(1, options.roughness || 0);
return [
generator.line(x3, y3, x2, y2, options),
generator.line(x4, y4, x2, y2, options),
...(arrowhead === "crowfoot_one_or_many"
? generateCrowfootOne(
getArrowheadPoints(element, shape, position, "crowfoot_one"),
options,
)
: []),
];
} }
} }
}; };
export const generateLinearCollisionShape = ( export const generateLinearCollisionShape = (
element: ExcalidrawLinearElement | ExcalidrawFreeDrawElement, element: ExcalidrawLinearElement | ExcalidrawFreeDrawElement,
elementsMap: ElementsMap, ) => {
): {
op: string;
data: number[];
}[] => {
const generator = new RoughGenerator(); const generator = new RoughGenerator();
const options: Options = { const options: Options = {
seed: element.seed, seed: element.seed,
@@ -591,7 +455,20 @@ export const generateLinearCollisionShape = (
roughness: 0, roughness: 0,
preserveVertices: true, preserveVertices: true,
}; };
const center = elementCenterPoint(element, elementsMap); const center = getCenterForBounds(
// Need a non-rotated center point
element.points.reduce(
(acc, point) => {
return [
Math.min(element.x + point[0], acc[0]),
Math.min(element.y + point[1], acc[1]),
Math.max(element.x + point[0], acc[2]),
Math.max(element.y + point[1], acc[3]),
];
},
[Infinity, Infinity, -Infinity, -Infinity],
),
);
switch (element.type) { switch (element.type) {
case "line": case "line":
@@ -1173,87 +1050,27 @@ const getFreeDrawSvgPath = (element: ExcalidrawFreeDrawElement) => {
) as SVGPathString; ) as SVGPathString;
}; };
/** export const getFreedrawOutlinePoints = (
* Freedraw stroke geometry tuning constants.
*
* These factors are not derived analytically they were tuned empirically by
* visually comparing rendered strokes until they matched the desired feel.
* Treat them as magic numbers backed by visual verification.
*/
const VARIABLE_WIDTH_FREEDRAW = {
/** Stroke size relative to `strokeWidth` for pressure-sensitive strokes. */
SIZE_FACTOR: 4.25,
THINNING: 0.6,
SMOOTHING: 0.5,
} as const;
const CONSTANT_WIDTH_FREEDRAW = {
/** Stroke size relative to `strokeWidth` for uniform (laser) strokes. */
SIZE_FACTOR: 1.4,
} as const;
const getFreedrawStreamline = (element: ExcalidrawFreeDrawElement) =>
element.strokeOptions?.streamline ?? DEFAULT_STROKE_STREAMLINE;
/**
* Pressure-sensitive (variable width) freedraw outline, rendered with
* perfect-freehand. This is the original Excalidraw freedraw look.
*/
const getVariableWidthFreedrawOutline = (
element: ExcalidrawFreeDrawElement, element: ExcalidrawFreeDrawElement,
): [number, number][] => { ) => {
// If input points are empty (should they ever be?) return a dot // If input points are empty (should they ever be?) return a dot
const inputPoints = element.simulatePressure const inputPoints = element.simulatePressure
? element.points ? element.points
: element.points.length : element.points.length
? element.points.map( ? element.points.map(([x, y], i) => [x, y, element.pressures[i]])
([x, y], i) => [x, y, element.pressures[i]] as [number, number, number],
)
: [[0, 0, 0.5]]; : [[0, 0, 0.5]];
return getStroke(inputPoints as number[][], { return getStroke(inputPoints as number[][], {
simulatePressure: element.simulatePressure, simulatePressure: element.simulatePressure,
size: element.strokeWidth * VARIABLE_WIDTH_FREEDRAW.SIZE_FACTOR, size: element.strokeWidth * 4.25,
thinning: VARIABLE_WIDTH_FREEDRAW.THINNING, thinning: 0.6,
smoothing: VARIABLE_WIDTH_FREEDRAW.SMOOTHING, smoothing: 0.5,
streamline: getFreedrawStreamline(element), streamline: 0.5,
easing: (t) => Math.sin((t * Math.PI) / 2), // https://easings.net/#easeOutSine easing: (t) => Math.sin((t * Math.PI) / 2), // https://easings.net/#easeOutSine
last: true, last: true,
}) as [number, number][]; }) as [number, number][];
}; };
const createLaserPointer = (element: ExcalidrawFreeDrawElement) =>
new LaserPointer({
size: element.strokeWidth * CONSTANT_WIDTH_FREEDRAW.SIZE_FACTOR,
streamline: getFreedrawStreamline(element),
simplify: 0,
sizeMapping: (details) => Math.max(0.1, details.pressure),
});
/**
* Uniform (constant width) freedraw outline, rendered with the laser-pointer
* geometry. Pressure is pinned to 1 so the stroke keeps a constant width.
*/
const getConstantWidthFreedrawOutline = (
element: ExcalidrawFreeDrawElement,
): [number, number][] => {
const laserPointer = createLaserPointer(element);
element.points.map(([x, y]) => laserPointer.addPoint([x, y, 1]));
return laserPointer
.getStrokeOutline()
.map(([x, y]) => [x, y] as [number, number]);
};
export const getFreedrawOutlinePoints = (
element: ExcalidrawFreeDrawElement,
): [number, number][] => {
// Unknown/absent variability falls back to the original variable rendering.
return element.strokeOptions?.variability === "constant"
? getConstantWidthFreedrawOutline(element)
: getVariableWidthFreedrawOutline(element);
};
const med = (A: number[], B: number[]) => { const med = (A: number[], B: number[]) => {
return [(A[0] + B[0]) / 2, (A[1] + B[1]) / 2]; return [(A[0] + B[0]) / 2, (A[1] + B[1]) / 2];
}; };
+64 -62
View File
@@ -1,56 +1,59 @@
import { arrayToMap } from "@excalidraw/common"; import { arrayToMapWithIndex } from "@excalidraw/common";
import type { ExcalidrawElement } from "./types"; import type { ExcalidrawElement } from "./types";
const defragmentGroups = (elements: readonly ExcalidrawElement[]) => { const normalizeGroupElementOrder = (elements: readonly ExcalidrawElement[]) => {
const groupIdAtLevel = (element: ExcalidrawElement, level: number) => { const origElements: ExcalidrawElement[] = elements.slice();
return element.groupIds[element.groupIds.length - level - 1]; const sortedElements = new Set<ExcalidrawElement>();
};
const orderLevel = ( const orderInnerGroups = (
levelElements: readonly ExcalidrawElement[], elements: readonly ExcalidrawElement[],
level: number,
): ExcalidrawElement[] => { ): ExcalidrawElement[] => {
const buckets = new Map<string, ExcalidrawElement[]>(); const firstGroupSig = elements[0]?.groupIds?.join("");
// Slots preserve first-occurrence order: a groupId reserves its slot const aGroup: ExcalidrawElement[] = [elements[0]];
// the first time one of its members is seen; loose elements occupy const bGroup: ExcalidrawElement[] = [];
// their own slot. Groups are then expanded (and recursed into) in place. for (const element of elements.slice(1)) {
const slots: (ExcalidrawElement | string)[] = []; if (element.groupIds?.join("") === firstGroupSig) {
aGroup.push(element);
for (const element of levelElements) { } else {
const groupId = groupIdAtLevel(element, level); bGroup.push(element);
if (groupId === undefined) {
slots.push(element);
continue;
} }
let bucket = buckets.get(groupId);
if (!bucket) {
bucket = [];
buckets.set(groupId, bucket);
slots.push(groupId);
}
bucket.push(element);
} }
return bGroup.length ? [...aGroup, ...orderInnerGroups(bGroup)] : aGroup;
return slots.flatMap((slot) =>
typeof slot === "string"
? orderLevel(buckets.get(slot)!, level + 1)
: [slot],
);
}; };
// `groupIds` is stored innermost-first, so the outermost group is the const groupHandledElements = new Map<string, true>();
// last entry. We recurse from level 0 (outermost) inward.
const sortedElements = orderLevel(elements, 0); origElements.forEach((element, idx) => {
if (groupHandledElements.has(element.id)) {
return;
}
if (element.groupIds?.length) {
const topGroup = element.groupIds[element.groupIds.length - 1];
const groupElements = origElements.slice(idx).filter((element) => {
const ret = element?.groupIds?.some((id) => id === topGroup);
if (ret) {
groupHandledElements.set(element!.id, true);
}
return ret;
});
for (const elem of orderInnerGroups(groupElements)) {
sortedElements.add(elem);
}
} else {
sortedElements.add(element);
}
});
// if there's a bug which resulted in losing some of the elements, return // if there's a bug which resulted in losing some of the elements, return
// original instead as that's better than losing data // original instead as that's better than losing data
if (sortedElements.length !== elements.length) { if (sortedElements.size !== elements.length) {
console.error("defragmentGroups: lost some elements... bailing!"); console.error("normalizeGroupElementOrder: lost some elements... bailing!");
return elements; return elements;
} }
return sortedElements; return [...sortedElements];
}; };
/** /**
@@ -65,40 +68,39 @@ const defragmentGroups = (elements: readonly ExcalidrawElement[]) => {
const normalizeBoundElementsOrder = ( const normalizeBoundElementsOrder = (
elements: readonly ExcalidrawElement[], elements: readonly ExcalidrawElement[],
) => { ) => {
const elementsMap = arrayToMap(elements); const elementsMap = arrayToMapWithIndex(elements);
const origElements: (ExcalidrawElement | null)[] = elements.slice();
const sortedElements = new Set<ExcalidrawElement>(); const sortedElements = new Set<ExcalidrawElement>();
for (const element of elements) { origElements.forEach((element, idx) => {
if (sortedElements.has(element)) { if (!element) {
continue; return;
} }
if (element.boundElements?.length) { if (element.boundElements?.length) {
sortedElements.add(element); sortedElements.add(element);
for (const boundElement of element.boundElements) { origElements[idx] = null;
element.boundElements.forEach((boundElement) => {
const child = elementsMap.get(boundElement.id); const child = elementsMap.get(boundElement.id);
if (child && boundElement.type === "text") { if (child && boundElement.type === "text") {
sortedElements.add(child); sortedElements.add(child[0]);
origElements[child[1]] = null;
} }
});
} else if (element.type === "text" && element.containerId) {
const parent = elementsMap.get(element.containerId);
if (!parent?.[0].boundElements?.find((x) => x.id === element.id)) {
sortedElements.add(element);
origElements[idx] = null;
// if element has a container and container lists it, skip this element
// as it'll be taken care of by the container
} }
continue; } else {
sortedElements.add(element);
origElements[idx] = null;
} }
});
// if element has a container and container lists it, skip this element
// as it'll be taken care of by the container
if (
element.type === "text" &&
element.containerId &&
elementsMap
.get(element.containerId)
?.boundElements?.some((el) => el.id === element.id)
) {
continue;
}
sortedElements.add(element);
}
// if there's a bug which resulted in losing some of the elements, return // if there's a bug which resulted in losing some of the elements, return
// original instead as that's better than losing data // original instead as that's better than losing data
@@ -115,5 +117,5 @@ const normalizeBoundElementsOrder = (
export const normalizeElementOrder = ( export const normalizeElementOrder = (
elements: readonly ExcalidrawElement[], elements: readonly ExcalidrawElement[],
) => { ) => {
return normalizeBoundElementsOrder(defragmentGroups(elements)); return normalizeBoundElementsOrder(normalizeGroupElementOrder(elements));
}; };
+1 -3
View File
@@ -347,7 +347,6 @@ export const getContainerCenter = (
midSegmentMidpoint = LinearElementEditor.getSegmentMidPoint( midSegmentMidpoint = LinearElementEditor.getSegmentMidPoint(
container, container,
index + 1, index + 1,
elementsMap,
); );
} }
return { x: midSegmentMidpoint[0], y: midSegmentMidpoint[1] }; return { x: midSegmentMidpoint[0], y: midSegmentMidpoint[1] };
@@ -442,8 +441,7 @@ const VALID_CONTAINER_TYPES = new Set([
export const isValidTextContainer = (element: { export const isValidTextContainer = (element: {
type: ExcalidrawElementType; type: ExcalidrawElementType;
}): element is ExcalidrawTextContainer => }) => VALID_CONTAINER_TYPES.has(element.type);
VALID_CONTAINER_TYPES.has(element.type);
export const computeContainerDimensionForBoundText = ( export const computeContainerDimensionForBoundText = (
dimension: number, dimension: number,
+37 -207
View File
@@ -4,22 +4,6 @@ import { charWidth, getLineWidth } from "./textMeasurements";
import type { FontString } from "./types"; import type { FontString } from "./types";
/**
* This module approximates browser-like soft wrapping for Excalidraw text.
*
* The flow is:
* 1. `parseTokens()` splits a hard line into breakable tokens using a unicode-aware regex.
* 2. `getWrappedTextLines()` reflows each hard line into one or more visual lines and
* records where each visual line came from in the source text.
* 3. `wrapLine()` assembles tokens into lines, and `wrapWord()` handles a single token
* that is wider than the available width.
* 4. `trimLine()` / `trimLineEndAtSoftBreak()` mirror browser behavior around trailing
* whitespace so the rendered text stays consistent with what users see on canvas.
*
* Mostly, you'll want to use wrapText(). getWrappedTextLines() is for callers
* that need metadata such as mapping visual lines back to `originalText`
* for caret placement or future editor features.
*/
let cachedCjkRegex: RegExp | undefined; let cachedCjkRegex: RegExp | undefined;
let cachedLineBreakRegex: RegExp | undefined; let cachedLineBreakRegex: RegExp | undefined;
let cachedEmojiRegex: RegExp | undefined; let cachedEmojiRegex: RegExp | undefined;
@@ -374,10 +358,6 @@ const Break = {
/** /**
* Breaks the line into the tokens based on the found line break opporutnities. * Breaks the line into the tokens based on the found line break opporutnities.
*
* Note: tokenization normalizes to NFC first so decomposed graphemes are treated as
* their composed variants for wrapping. Any code that needs exact source offsets should
* keep in mind that this assumes the input text is already NFC-normalized.
*/ */
export const parseTokens = (line: string) => { export const parseTokens = (line: string) => {
const breakLineRegex = getLineBreakRegex(); const breakLineRegex = getLineBreakRegex();
@@ -390,120 +370,56 @@ export const parseTokens = (line: string) => {
/** /**
* Wraps the original text into the lines based on the given width. * Wraps the original text into the lines based on the given width.
*
* This is a convenience adapter over `getWrappedTextLines()` for call sites
* that only need the rendered wrapped string and not the source offsets.
*/ */
export const wrapText = ( export const wrapText = (
text: string, text: string,
font: FontString, font: FontString,
maxWidth: number, maxWidth: number,
): string => { ): string => {
return getWrappedTextLines(text, font, maxWidth)
.map((line) => line.text)
.join("\n");
};
/**
* A single rendered visual line produced from the original text.
*
* `start` and `end` are end-exclusive code-unit offsets into the original text, and do
* not include synthetic soft line breaks inserted by this module. If trailing whitespace
* was trimmed away at a wrap boundary, `end` points to the last rendered character.
*/
export type WrappedTextLine = {
text: string;
start: number;
end: number;
};
/**
* Splits only on existing hard line breaks and preserves original offsets.
*/
const getHardLineBreaks = (text: string): WrappedTextLine[] => {
let offset = 0;
return text.split("\n").map((line) => {
const start = offset;
const end = start + line.length;
offset = end + 1;
return {
text: line,
start,
end,
};
});
};
/**
* Returns the rendered visual lines together with their source offsets.
*
* This is the source-of-truth wrapping pipeline for callers that need more than the
* final wrapped string, for example caret placement or future editor/rich-text mapping.
*/
export const getWrappedTextLines = (
text: string,
font: FontString,
maxWidth: number,
): WrappedTextLine[] => {
// if maxWidth is not finite or NaN which can happen in case of bugs in // if maxWidth is not finite or NaN which can happen in case of bugs in
// computation, we need to make sure we don't continue as we'll end up // computation, we need to make sure we don't continue as we'll end up
// in an infinite loop // in an infinite loop
if (!Number.isFinite(maxWidth) || maxWidth < 0) { if (!Number.isFinite(maxWidth) || maxWidth < 0) {
return getHardLineBreaks(text); return text;
} }
const lines: WrappedTextLine[] = []; const lines: Array<string> = [];
let offset = 0; const originalLines = text.split("\n");
for (const originalLine of text.split("\n")) { for (const originalLine of originalLines) {
const originalLineWidth = getLineWidth(originalLine, font); const currentLineWidth = getLineWidth(originalLine, font);
if (originalLineWidth <= maxWidth) { if (currentLineWidth <= maxWidth) {
lines.push({ lines.push(originalLine);
text: originalLine, continue;
start: offset,
end: offset + originalLine.length,
});
} else {
lines.push(...wrapLine(originalLine, font, maxWidth, offset));
} }
offset += originalLine.length + 1; const wrappedLine = wrapLine(originalLine, font, maxWidth);
lines.push(...wrappedLine);
} }
return lines; return lines.join("\n");
}; };
/** /**
* Wraps a single hard line into one or more visual lines. * Wraps the original line into the lines based on the given width.
*
* The line-local offsets are tracked in original-text code units so
* we can map the visual line back to the source.
*/ */
const wrapLine = ( const wrapLine = (
line: string, line: string,
font: FontString, font: FontString,
maxWidth: number, maxWidth: number,
lineStart: number, ): string[] => {
): WrappedTextLine[] => { const lines: Array<string> = [];
const lines: WrappedTextLine[] = [];
const tokens = parseTokens(line); const tokens = parseTokens(line);
const tokenIterator = tokens[Symbol.iterator]();
let currentLine = ""; let currentLine = "";
let currentLineStart = lineStart;
let currentLineEnd = lineStart;
let currentLineWidth = 0; let currentLineWidth = 0;
// Tracks the next token's code-unit position in the original source string.
let tokenOffset = lineStart;
let tokenIndex = 0;
while (tokenIndex < tokens.length) { let iterator = tokenIterator.next();
const token = tokens[tokenIndex];
const tokenStart = tokenOffset; while (!iterator.done) {
const tokenEnd = tokenStart + token.length; const token = iterator.value;
const testLine = currentLine + token; const testLine = currentLine + token;
// cache single codepoint whitespace, CJK or emoji width calc. as kerning should not apply here // cache single codepoint whitespace, CJK or emoji width calc. as kerning should not apply here
@@ -513,59 +429,37 @@ const wrapLine = (
// build up the current line, skipping length check for possibly trailing whitespaces // build up the current line, skipping length check for possibly trailing whitespaces
if (/\s/.test(token) || testLineWidth <= maxWidth) { if (/\s/.test(token) || testLineWidth <= maxWidth) {
if (!currentLine) {
currentLineStart = tokenStart;
}
currentLine = testLine; currentLine = testLine;
currentLineEnd = tokenEnd;
currentLineWidth = testLineWidth; currentLineWidth = testLineWidth;
tokenOffset = tokenEnd; iterator = tokenIterator.next();
tokenIndex++;
continue; continue;
} }
// current line is empty => just the token (word) is longer than `maxWidth` and needs to be wrapped // current line is empty => just the token (word) is longer than `maxWidth` and needs to be wrapped
if (!currentLine) { if (!currentLine) {
const wrappedWord = wrapWord(token, font, maxWidth, tokenStart); const wrappedWord = wrapWord(token, font, maxWidth);
const trailingLine = wrappedWord[wrappedWord.length - 1] ?? { const trailingLine = wrappedWord[wrappedWord.length - 1] ?? "";
text: "",
start: tokenStart,
end: tokenStart,
};
const precedingLines = wrappedWord.slice(0, -1); const precedingLines = wrappedWord.slice(0, -1);
lines.push(...precedingLines); lines.push(...precedingLines);
// trailing line of the wrapped word might still be joined with next token/s // trailing line of the wrapped word might still be joined with next token/s
currentLine = trailingLine.text; currentLine = trailingLine;
currentLineStart = trailingLine.start; currentLineWidth = getLineWidth(trailingLine, font);
currentLineEnd = trailingLine.end; iterator = tokenIterator.next();
currentLineWidth = getLineWidth(trailingLine.text, font);
tokenOffset = tokenEnd;
tokenIndex++;
} else { } else {
// push & reset, but don't iterate on the next token, as we didn't use it yet! // push & reset, but don't iterate on the next token, as we didn't use it yet!
lines.push( lines.push(currentLine.trimEnd());
trimLineEndAtSoftBreak(currentLine, currentLineStart, currentLineEnd),
);
// purposefully not iterating and not setting `currentLine` to `token`, so that we could use a simple !currentLine check above // purposefully not iterating and not setting `currentLine` to `token`, so that we could use a simple !currentLine check above
currentLine = ""; currentLine = "";
currentLineStart = tokenStart;
currentLineEnd = tokenStart;
currentLineWidth = 0; currentLineWidth = 0;
} }
} }
// iterator done, push the trailing line if exists // iterator done, push the trailing line if exists
if (currentLine) { if (currentLine) {
const trailingLine = trimLine( const trailingLine = trimLine(currentLine, font, maxWidth);
currentLine,
currentLineStart,
currentLineEnd,
font,
maxWidth,
);
lines.push(trailingLine); lines.push(trailingLine);
} }
@@ -573,100 +467,59 @@ const wrapLine = (
}; };
/** /**
* Wraps a single word that could not be placed on an empty line as-is. * Wraps the word into the lines based on the given width.
*/ */
const wrapWord = ( const wrapWord = (
word: string, word: string,
font: FontString, font: FontString,
maxWidth: number, maxWidth: number,
wordStart: number, ): Array<string> => {
): WrappedTextLine[] => {
// multi-codepoint emojis are already broken apart and shouldn't be broken further // multi-codepoint emojis are already broken apart and shouldn't be broken further
if (getEmojiRegex().test(word)) { if (getEmojiRegex().test(word)) {
return [ return [word];
{
text: word,
start: wordStart,
end: wordStart + word.length,
},
];
} }
satisfiesWordInvariant(word); satisfiesWordInvariant(word);
const lines: WrappedTextLine[] = []; const lines: Array<string> = [];
const chars = Array.from(word); const chars = Array.from(word);
let currentLine = ""; let currentLine = "";
let currentLineStart = wordStart;
let currentLineEnd = wordStart;
let currentLineWidth = 0; let currentLineWidth = 0;
let offset = wordStart;
for (const char of chars) { for (const char of chars) {
const charStart = offset;
const charEnd = charStart + char.length;
const _charWidth = charWidth.calculate(char, font); const _charWidth = charWidth.calculate(char, font);
const testLineWidth = currentLineWidth + _charWidth; const testLineWidth = currentLineWidth + _charWidth;
if (testLineWidth <= maxWidth) { if (testLineWidth <= maxWidth) {
if (!currentLine) {
currentLineStart = charStart;
}
currentLine = currentLine + char; currentLine = currentLine + char;
currentLineEnd = charEnd;
currentLineWidth = testLineWidth; currentLineWidth = testLineWidth;
offset = charEnd;
continue; continue;
} }
if (currentLine) { if (currentLine) {
lines.push({ lines.push(currentLine);
text: currentLine,
start: currentLineStart,
end: currentLineEnd,
});
} }
currentLine = char; currentLine = char;
currentLineStart = charStart;
currentLineEnd = charEnd;
currentLineWidth = _charWidth; currentLineWidth = _charWidth;
offset = charEnd;
} }
if (currentLine) { if (currentLine) {
lines.push({ lines.push(currentLine);
text: currentLine,
start: currentLineStart,
end: currentLineEnd,
});
} }
return lines; return lines;
}; };
/** /**
* Trims trailing whitespace that is exceeding the `maxWidth`. * Similarly to browsers, does not trim all trailing whitespaces, but only those exceeding the `maxWidth`.
*
* Used for the trailing visual line of a hard line, where some trailing
* whitespace may still be visible if it fits into the available width.
*/ */
const trimLine = ( const trimLine = (line: string, font: FontString, maxWidth: number) => {
line: string,
start: number,
end: number,
font: FontString,
maxWidth: number,
): WrappedTextLine => {
const shouldTrimWhitespaces = getLineWidth(line, font) > maxWidth; const shouldTrimWhitespaces = getLineWidth(line, font) > maxWidth;
if (!shouldTrimWhitespaces) { if (!shouldTrimWhitespaces) {
return { return line;
text: line,
start,
end,
};
} }
// defensively default to `trimeEnd` in case the regex does not match // defensively default to `trimeEnd` in case the regex does not match
@@ -690,30 +543,7 @@ const trimLine = (
trimmedLineWidth = testLineWidth; trimmedLineWidth = testLineWidth;
} }
return { return trimmedLine;
text: trimmedLine,
start,
end: end - (line.length - trimmedLine.length),
};
};
/**
* Used for internal soft-wrap boundaries, where trailing whitespace should not
* survive into the rendered line even though it still exists in the original
* text.
*/
const trimLineEndAtSoftBreak = (
line: string,
start: number,
end: number,
): WrappedTextLine => {
const trimmedLine = line.trimEnd();
return {
text: trimmedLine,
start,
end: end - (line.length - trimmedLine.length),
};
}; };
/** /**
-20
View File
@@ -392,23 +392,3 @@ export const canBecomePolygon = (
(points.length === 3 && !pointsEqual(points[0], points[points.length - 1])) (points.length === 3 && !pointsEqual(points[0], points[points.length - 1]))
); );
}; };
export const isEligibleFrameChildType = (type: ElementOrToolType) => {
switch (type) {
case "rectangle":
case "diamond":
case "ellipse":
case "arrow":
case "line":
case "freedraw":
case "text":
case "image":
case "frame":
case "embeddable": {
return true;
}
default: {
return false;
}
}
};
+4 -25
View File
@@ -303,32 +303,19 @@ export type PointsPositionUpdates = Map<
{ point: LocalPoint; isDragging?: boolean } { point: LocalPoint; isDragging?: boolean }
>; >;
export type CardinalityArrowhead =
| "cardinality_one"
| "cardinality_many"
| "cardinality_one_or_many"
| "cardinality_exactly_one"
| "cardinality_zero_or_one"
| "cardinality_zero_or_many";
export type ArrowheadLegacy =
| "dot"
| "crowfoot_one"
| "crowfoot_many"
| "crowfoot_one_or_many";
export type Arrowhead = export type Arrowhead =
| "arrow" | "arrow"
| "bar" | "bar"
| "dot" // legacy. Do not use for new elements.
| "circle" | "circle"
| "circle_outline" | "circle_outline"
| "triangle" | "triangle"
| "triangle_outline" | "triangle_outline"
| "diamond" | "diamond"
| "diamond_outline" | "diamond_outline"
| CardinalityArrowhead; | "crowfoot_one"
| "crowfoot_many"
export type AnyArrowhead = Arrowhead | ArrowheadLegacy; | "crowfoot_one_or_many";
export type ExcalidrawLinearElement = _ExcalidrawElementBase & export type ExcalidrawLinearElement = _ExcalidrawElementBase &
Readonly<{ Readonly<{
@@ -384,20 +371,12 @@ export type ExcalidrawElbowArrowElement = Merge<
} }
>; >;
export type StrokeVariability = "variable" | "constant";
export type StrokeOptions = Readonly<{
variability: StrokeVariability;
streamline: number;
}>;
export type ExcalidrawFreeDrawElement = _ExcalidrawElementBase & export type ExcalidrawFreeDrawElement = _ExcalidrawElementBase &
Readonly<{ Readonly<{
type: "freedraw"; type: "freedraw";
points: readonly LocalPoint[]; points: readonly LocalPoint[];
pressures: readonly number[]; pressures: readonly number[];
simulatePressure: boolean; simulatePressure: boolean;
strokeOptions: StrokeOptions;
}>; }>;
export type FileId = string & { _brand: "FileId" }; export type FileId = string & { _brand: "FileId" };
+4 -2
View File
@@ -124,7 +124,6 @@ const setElementShapesCacheEntry = <T extends ExcalidrawElement>(
*/ */
export function deconstructLinearOrFreeDrawElement( export function deconstructLinearOrFreeDrawElement(
element: ExcalidrawLinearElement | ExcalidrawFreeDrawElement, element: ExcalidrawLinearElement | ExcalidrawFreeDrawElement,
elementsMap: ElementsMap,
): [LineSegment<GlobalPoint>[], Curve<GlobalPoint>[]] { ): [LineSegment<GlobalPoint>[], Curve<GlobalPoint>[]] {
const cachedShape = getElementShapesCacheEntry(element, 0); const cachedShape = getElementShapesCacheEntry(element, 0);
@@ -132,7 +131,10 @@ export function deconstructLinearOrFreeDrawElement(
return cachedShape; return cachedShape;
} }
const ops = generateLinearCollisionShape(element, elementsMap); const ops = generateLinearCollisionShape(element) as {
op: string;
data: number[];
}[];
const lines = []; const lines = [];
const curves = []; const curves = [];
+1 -1
View File
@@ -5,7 +5,6 @@ import {
pointFrom, pointFrom,
type GlobalPoint, type GlobalPoint,
type LocalPoint, type LocalPoint,
type LineSegment,
} from "@excalidraw/math"; } from "@excalidraw/math";
import { type Bounds, isBounds } from "@excalidraw/common"; import { type Bounds, isBounds } from "@excalidraw/common";
import { import {
@@ -18,6 +17,7 @@ import {
import type { ElementsMap, ExcalidrawElement } from "@excalidraw/element/types"; import type { ElementsMap, ExcalidrawElement } from "@excalidraw/element/types";
import type { Curve } from "@excalidraw/math"; import type { Curve } from "@excalidraw/math";
import type { LineSegment } from "@excalidraw/utils";
// The global data holder to collect the debug operations // The global data holder to collect the debug operations
declare global { declare global {
+3 -69
View File
@@ -1,7 +1,5 @@
import { arrayToMap, findIndex, findLastIndex } from "@excalidraw/common"; import { arrayToMap, findIndex, findLastIndex } from "@excalidraw/common";
import { isFiniteNumber } from "@excalidraw/math";
import type { AppState } from "@excalidraw/excalidraw/types"; import type { AppState } from "@excalidraw/excalidraw/types";
import type { GlobalPoint } from "@excalidraw/math"; import type { GlobalPoint } from "@excalidraw/math";
@@ -315,46 +313,12 @@ const getTargetElementsMap = <T extends ExcalidrawElement>(
}, new Map<string, ExcalidrawElement>()); }, new Map<string, ExcalidrawElement>());
}; };
const hasSameElementIds = (
prevElements: readonly ExcalidrawElement[],
nextElements: readonly ExcalidrawElement[],
) => {
if (prevElements.length !== nextElements.length) {
console.error(
"z-index reordering failed: resulting array have different lengths",
);
return false;
}
const prevElementIdCounts = new Map<ExcalidrawElement["id"], number>();
for (const element of prevElements) {
prevElementIdCounts.set(
element.id,
(prevElementIdCounts.get(element.id) || 0) + 1,
);
}
for (const element of nextElements) {
const count = prevElementIdCounts.get(element.id);
if (!count) {
console.error(
"z-index reordering failed: element id mismatch / duplicate ids",
);
return false;
}
prevElementIdCounts.set(element.id, count - 1);
}
return true;
};
const shiftElementsByOne = ( const shiftElementsByOne = (
elements: readonly ExcalidrawElement[], elements: readonly ExcalidrawElement[],
appState: AppState, appState: AppState,
direction: "left" | "right", direction: "left" | "right",
scene: Scene, scene: Scene,
) => { ) => {
const originalElements = elements;
const indicesToMove = getIndicesToMove(elements, appState); const indicesToMove = getIndicesToMove(elements, appState);
const targetElementsMap = getTargetElementsMap(elements, indicesToMove); const targetElementsMap = getTargetElementsMap(elements, indicesToMove);
@@ -425,10 +389,6 @@ const shiftElementsByOne = (
]; ];
}); });
if (!hasSameElementIds(originalElements, elements)) {
return originalElements;
}
syncMovedIndices(elements, targetElementsMap); syncMovedIndices(elements, targetElementsMap);
return elements; return elements;
@@ -442,20 +402,11 @@ const shiftElementsToEnd = (
elementsToBeMoved?: readonly ExcalidrawElement[], elementsToBeMoved?: readonly ExcalidrawElement[],
) => { ) => {
const indicesToMove = getIndicesToMove(elements, appState, elementsToBeMoved); const indicesToMove = getIndicesToMove(elements, appState, elementsToBeMoved);
// Nothing to move (e.g. `elementsToBeMoved` is empty because all selected
// elements were frame children handled in a prior pass). Bail out early —
// otherwise `leadingIndex`/`trailingIndex` below resolve to `undefined` and
// the resulting `slice()` calls overlap, duplicating elements.
if (indicesToMove.length === 0) {
return elements;
}
const targetElementsMap = getTargetElementsMap(elements, indicesToMove); const targetElementsMap = getTargetElementsMap(elements, indicesToMove);
const displacedElements: ExcalidrawElement[] = []; const displacedElements: ExcalidrawElement[] = [];
let leadingIndex: number | undefined; let leadingIndex: number;
let trailingIndex: number | undefined; let trailingIndex: number;
if (direction === "left") { if (direction === "left") {
if (containingFrame) { if (containingFrame) {
leadingIndex = findIndex(elements, (el) => leadingIndex = findIndex(elements, (el) =>
@@ -500,19 +451,6 @@ const shiftElementsToEnd = (
leadingIndex = 0; leadingIndex = 0;
} }
const isValidIndex = (index: number | undefined): index is number => {
return isFiniteNumber(index) && index >= 0;
};
if (
!isValidIndex(leadingIndex) ||
!isValidIndex(trailingIndex) ||
leadingIndex > trailingIndex ||
indicesToMove.some((index) => index < leadingIndex || index > trailingIndex)
) {
return elements;
}
for (let index = leadingIndex; index < trailingIndex + 1; index++) { for (let index = leadingIndex; index < trailingIndex + 1; index++) {
if (!indicesToMove.includes(index)) { if (!indicesToMove.includes(index)) {
displacedElements.push(elements[index]); displacedElements.push(elements[index]);
@@ -537,10 +475,6 @@ const shiftElementsToEnd = (
...trailingElements, ...trailingElements,
]; ];
if (!hasSameElementIds(elements, nextElements)) {
return elements;
}
syncMovedIndices(nextElements, targetElementsMap); syncMovedIndices(nextElements, targetElementsMap);
return nextElements; return nextElements;
@@ -609,7 +543,7 @@ function shiftElementsAccountingForFrames(
for (const [frameId, children] of frameChildrenSets) { for (const [frameId, children] of frameChildrenSets) {
nextElements = shiftFunction( nextElements = shiftFunction(
nextElements, allElements,
appState, appState,
direction, direction,
frameId, frameId,
-60
View File
@@ -178,64 +178,6 @@ describe("binding for simple arrows", () => {
}); });
}); });
describe("self-binding (both ends to the same element) single-click finalize", () => {
// rect spans x:200..400, y:200..400; orbit ring is ~15px outside the outline
const INSIDE: [number, number] = [250, 250];
const ORBIT_LEFT: [number, number] = [187, 300];
const ORBIT_RIGHT: [number, number] = [413, 300];
const MIDDLE: [number, number] = [550, 100];
beforeEach(async () => {
mouse.reset();
await act(() => setLanguage(defaultLang));
await render(<Excalidraw handleKeyboardGlobally={true} />);
UI.createElement("rectangle", {
x: 200,
y: 200,
width: 200,
height: 200,
});
});
const drawSelfArrow = (start: [number, number], end: [number, number]) => {
UI.clickTool("arrow");
mouse.reset();
mouse.clickAt(...start);
mouse.moveTo(...MIDDLE);
mouse.clickAt(...MIDDLE); // commit a middle point so it's a multi-point arrow
mouse.moveTo(...end);
mouse.clickAt(...end); // single click at the end
};
it("orbit -> orbit finalizes on a single click", () => {
drawSelfArrow(ORBIT_LEFT, ORBIT_RIGHT);
const arrow = h.elements[h.elements.length - 1] as ExcalidrawArrowElement;
expect(h.state.multiElement).toBe(null);
expect(h.state.activeTool.type).toBe("selection");
expect(arrow.startBinding?.elementId).toBe(arrow.endBinding?.elementId);
expect(arrow.endBinding?.elementId).not.toBe(undefined);
});
it("inside -> orbit finalizes on a single click", () => {
drawSelfArrow(INSIDE, ORBIT_RIGHT);
const arrow = h.elements[h.elements.length - 1] as ExcalidrawArrowElement;
expect(h.state.multiElement).toBe(null);
expect(h.state.activeTool.type).toBe("selection");
expect(arrow.startBinding?.elementId).toBe(arrow.endBinding?.elementId);
expect(arrow.endBinding?.elementId).not.toBe(undefined);
});
it("inside -> inside keep in multi-point mode (no single-click finalize)", () => {
drawSelfArrow(INSIDE, [INSIDE[0] + 50, INSIDE[1] + 50]); // end dropped inside the rect
// ambiguous → must be confirmed with a second click, so still in progress
expect(h.state.multiElement).not.toBe(null);
expect(h.state.activeTool.type).toBe("arrow");
});
});
describe("when arrow is outside of shape", () => { describe("when arrow is outside of shape", () => {
beforeEach(async () => { beforeEach(async () => {
mouse.reset(); mouse.reset();
@@ -461,7 +403,6 @@ describe("binding for simple arrows", () => {
mouse.moveTo(340, 251); mouse.moveTo(340, 251);
mouse.moveTo(410, 251); mouse.moveTo(410, 251);
mouse.clickAt(410, 251); mouse.clickAt(410, 251);
mouse.clickAt(410, 251);
const arrow = h.elements[h.elements.length - 1] as any; const arrow = h.elements[h.elements.length - 1] as any;
expect(arrow.startBinding?.elementId).toBe(rectLeft.id); expect(arrow.startBinding?.elementId).toBe(rectLeft.id);
@@ -506,7 +447,6 @@ describe("binding for simple arrows", () => {
mouse.moveTo(350, 251); mouse.moveTo(350, 251);
mouse.moveTo(410, 251); mouse.moveTo(410, 251);
mouse.clickAt(410, 251); mouse.clickAt(410, 251);
mouse.clickAt(410, 251);
const arrow = API.getSelectedElement() as ExcalidrawArrowElement; const arrow = API.getSelectedElement() as ExcalidrawArrowElement;
+3 -69
View File
@@ -1,14 +1,10 @@
import { pointFrom } from "@excalidraw/math"; import { pointFrom } from "@excalidraw/math";
import { arrayToMap, type Bounds, ROUNDNESS } from "@excalidraw/common";
import { API } from "@excalidraw/excalidraw/tests/helpers/api"; import { arrayToMap, ROUNDNESS } from "@excalidraw/common";
import type { LocalPoint } from "@excalidraw/math"; import type { LocalPoint } from "@excalidraw/math";
import { import { getElementAbsoluteCoords, getElementBounds } from "../src/bounds";
elementsOverlappingBBox,
getElementAbsoluteCoords,
getElementBounds,
} from "../src/bounds";
import type { ExcalidrawElement, ExcalidrawLinearElement } from "../src/types"; import type { ExcalidrawElement, ExcalidrawLinearElement } from "../src/types";
@@ -145,65 +141,3 @@ describe("getElementBounds", () => {
expect(y2).toEqual(319.8162855827246); expect(y2).toEqual(319.8162855827246);
}); });
}); });
const makeElement = (x: number, y: number, width: number, height: number) =>
API.createElement({
type: "rectangle",
x,
y,
width,
height,
});
const makeBBox = (
minX: number,
minY: number,
maxX: number,
maxY: number,
): Bounds => [minX, minY, maxX, maxY];
describe("elementsOverlappingBBox()", () => {
it("should return elements that overlap bbox", () => {
const bbox = makeBBox(0, 0, 100, 100);
const rectOutside = makeElement(110, 110, 100, 100);
const rectInside = makeElement(10, 10, 85, 85);
const rectContainingBBox = makeElement(-10, -10, 110, 110);
const rectOverlappingTopLeft = makeElement(-10, -10, 50, 50);
expect(
elementsOverlappingBBox({
bounds: bbox,
type: "overlap",
elements: [
rectOutside,
rectInside,
rectContainingBBox,
rectOverlappingTopLeft,
],
}),
).toEqual([rectInside, rectOverlappingTopLeft]);
});
it("should return elements inside/containing bbox", () => {
const bbox = makeBBox(0, 0, 100, 100);
const rectOutside = makeElement(110, 110, 100, 100);
const rectInside = makeElement(10, 10, 85, 85);
const rectContainingBBox = makeElement(-10, -10, 110, 110);
const rectOverlappingTopLeft = makeElement(-10, -10, 50, 50);
expect(
elementsOverlappingBBox({
bounds: bbox,
type: "contain",
elements: [
rectOutside,
rectInside,
rectContainingBBox,
rectOverlappingTopLeft,
],
}),
).toEqual([rectInside]);
});
});
+1 -3
View File
@@ -1,4 +1,4 @@
import { arrayToMap, reseed } from "@excalidraw/common"; import { arrayToMap } from "@excalidraw/common";
import { type GlobalPoint, type LocalPoint, pointFrom } from "@excalidraw/math"; import { type GlobalPoint, type LocalPoint, pointFrom } from "@excalidraw/math";
import { Excalidraw } from "@excalidraw/excalidraw"; import { Excalidraw } from "@excalidraw/excalidraw";
import { API } from "@excalidraw/excalidraw/tests/helpers/api"; import { API } from "@excalidraw/excalidraw/tests/helpers/api";
@@ -12,7 +12,6 @@ import { hitElementItself } from "../src/collision";
describe("check rotated elements can be hit:", () => { describe("check rotated elements can be hit:", () => {
beforeEach(async () => { beforeEach(async () => {
localStorage.clear(); localStorage.clear();
reseed(7);
await render(<Excalidraw handleKeyboardGlobally={true} />); await render(<Excalidraw handleKeyboardGlobally={true} />);
}); });
@@ -57,7 +56,6 @@ describe("hitElementItself cache", () => {
}); });
localStorage.clear(); localStorage.clear();
reseed(7);
await render(<Excalidraw handleKeyboardGlobally={true} />); await render(<Excalidraw handleKeyboardGlobally={true} />);
}); });
+3 -63
View File
@@ -1,8 +1,9 @@
/* eslint-disable no-lone-blocks */ /* eslint-disable no-lone-blocks */
import { generateKeyBetween } from "fractional-indexing";
import { arrayToMap } from "@excalidraw/common"; import { arrayToMap } from "@excalidraw/common";
import { import {
InvalidFractionalIndexError,
syncInvalidIndices, syncInvalidIndices,
syncMovedIndices, syncMovedIndices,
validateFractionalIndices, validateFractionalIndices,
@@ -12,34 +13,13 @@ import { deepCopyElement } from "@excalidraw/element";
import { API } from "@excalidraw/excalidraw/tests/helpers/api"; import { API } from "@excalidraw/excalidraw/tests/helpers/api";
import {
generateKeyBetween,
validateOrderKey,
} from "@excalidraw/fractional-indexing";
import type { import type {
ElementsMap, ElementsMap,
ExcalidrawElement, ExcalidrawElement,
FractionalIndex, FractionalIndex,
} from "@excalidraw/element/types"; } from "@excalidraw/element/types";
describe("fractional index format validation", () => { import { InvalidFractionalIndexError } from "../src/fractionalIndex";
it("should reject malformed base62 order keys", () => {
expect(() => validateOrderKey("a!")).toThrow();
expect(() => validateOrderKey("a_")).toThrow();
expect(() => validateOrderKey("a1!")).toThrow();
expect(() => validateOrderKey("a1_")).toThrow();
expect(() => validateOrderKey("zd0032")).toThrow();
});
it("should accept valid base62 order keys", () => {
expect(() => validateOrderKey("Zz")).not.toThrow();
expect(() => validateOrderKey("a0")).not.toThrow();
expect(() => validateOrderKey("a1")).not.toThrow();
expect(() => validateOrderKey("a1V")).not.toThrow();
expect(() => validateOrderKey("z".padEnd(28, "z"))).not.toThrow();
});
});
describe("sync invalid indices with array order", () => { describe("sync invalid indices with array order", () => {
describe("should NOT sync empty array", () => { describe("should NOT sync empty array", () => {
@@ -124,46 +104,6 @@ describe("sync invalid indices with array order", () => {
}); });
}); });
describe("should sync when fractional index is malformed", () => {
// "zd0032" has head "z" which requires length 28 per getIntegerLength,
// but the string is far too short, so validateOrderKey throws for it
testInvalidIndicesSync({
elements: [{ id: "A", index: "zd0032" }],
expect: {
unchangedElements: [],
},
});
testInvalidIndicesSync({
elements: [
{ id: "A", index: "a1" },
{ id: "B", index: "zd0032" },
{ id: "C", index: "a3" },
],
expect: {
unchangedElements: ["A", "C"],
},
});
testInvalidIndicesSync({
elements: [{ id: "A", index: "a!" }],
expect: {
unchangedElements: [],
},
});
testInvalidIndicesSync({
elements: [
{ id: "A", index: "a1" },
{ id: "B", index: "a!" },
{ id: "C", index: "a2" },
],
expect: {
unchangedElements: ["A", "C"],
},
});
});
describe("should sync when fractional indices are duplicated", () => { describe("should sync when fractional indices are duplicated", () => {
testInvalidIndicesSync({ testInvalidIndicesSync({
elements: [ elements: [
+2 -622
View File
@@ -2,24 +2,15 @@ import {
convertToExcalidrawElements, convertToExcalidrawElements,
Excalidraw, Excalidraw,
} from "@excalidraw/excalidraw"; } from "@excalidraw/excalidraw";
import { arrayToMap } from "@excalidraw/common";
import { API } from "@excalidraw/excalidraw/tests/helpers/api"; import { API } from "@excalidraw/excalidraw/tests/helpers/api";
import { Keyboard, Pointer, UI } from "@excalidraw/excalidraw/tests/helpers/ui"; import { Keyboard, Pointer } from "@excalidraw/excalidraw/tests/helpers/ui";
import { getTextEditor } from "@excalidraw/excalidraw/tests/queries/dom";
import { import {
getCloneByOrigId, getCloneByOrigId,
render, render,
} from "@excalidraw/excalidraw/tests/test-utils"; } from "@excalidraw/excalidraw/tests/test-utils";
import { getSelectedElements } from "@excalidraw/excalidraw/scene"; import type { ExcalidrawElement } from "../src/types";
import { elementOverlapsWithFrame } from "../src/frame";
import type {
ExcalidrawElement,
ExcalidrawFrameLikeElement,
} from "../src/types";
const { h } = window; const { h } = window;
const mouse = new Pointer("mouse"); const mouse = new Pointer("mouse");
@@ -134,250 +125,6 @@ describe("adding elements to frames", () => {
}); });
}); });
it("should treat an element fully containing a frame as overlapping the frame", () => {
const containingRect = API.createElement({
type: "rectangle",
x: -50,
y: -50,
width: 250,
height: 250,
});
API.setElements([containingRect, frame]);
expect(
elementOverlapsWithFrame(
containingRect,
frame as ExcalidrawFrameLikeElement,
arrayToMap(h.elements),
),
).toBe(true);
});
it("should not add a newly created element to a frame behind a non-frame element", () => {
const cover = API.createElement({
id: "cover",
type: "rectangle",
x: 10,
y: 10,
width: 80,
height: 80,
backgroundColor: "#ffc9c9",
});
API.setElements([frame, cover]);
UI.clickTool("rectangle");
mouse.downAt(20, 20);
mouse.moveTo(40, 40);
mouse.upAt(40, 40);
const createdElement = h.elements.find(
(element) => element.id !== frame.id && element.id !== cover.id,
);
expect(createdElement?.frameId).toBe(null);
expect(h.elements.map((element) => element.id)).toEqual([
frame.id,
cover.id,
createdElement?.id,
]);
});
it("should add a newly created element to a frame over a non-frame element", () => {
const cover = API.createElement({
id: "cover",
type: "rectangle",
x: 10,
y: 10,
width: 80,
height: 80,
backgroundColor: "#ffc9c9",
});
API.setElements([cover, frame]);
UI.clickTool("rectangle");
mouse.downAt(20, 20);
mouse.moveTo(40, 40);
mouse.upAt(40, 40);
const createdElement = h.elements.find(
(element) => element.id !== frame.id && element.id !== cover.id,
);
expect(createdElement?.frameId).toBe(frame.id);
});
it("should highlight the target frame while creating a new element", () => {
API.setElements([frame]);
UI.clickTool("rectangle");
mouse.downAt(20, 20);
mouse.moveTo(40, 40);
expect(h.state.frameToHighlight?.id).toBe(frame.id);
mouse.upAt(40, 40);
expect(h.state.frameToHighlight).toBe(null);
});
it("should highlight the target frame while hovering with a creation tool", () => {
API.setElements([frame]);
UI.clickTool("rectangle");
mouse.moveTo(20, 20);
expect(h.state.frameToHighlight?.id).toBe(frame.id);
mouse.moveTo(200, 200);
expect(h.state.frameToHighlight).toBe(null);
});
it("should not add grid-snapped text outside the frame to the clicked frame", async () => {
const offsetFrame = API.createElement({
id: "offsetFrame",
type: "frame",
x: 10,
y: 0,
width: 150,
height: 150,
});
API.setElements([offsetFrame]);
API.setAppState({
gridModeEnabled: true,
});
UI.clickTool("text");
mouse.clickAt(12, 0);
await getTextEditor();
const createdText = h.elements.find(
(element) => element.id !== offsetFrame.id,
);
expect(createdText?.x).toBe(0);
expect(createdText?.y).toBe(0);
expect(createdText?.frameId).toBe(null);
});
it("should add a newly created element to a frame behind another frame", () => {
const lockedFrame = API.createElement({
id: "lockedFrame",
type: "frame",
x: 10,
y: 10,
width: 80,
height: 80,
locked: true,
});
API.setElements([frame, lockedFrame]);
UI.clickTool("rectangle");
mouse.downAt(20, 20);
mouse.moveTo(40, 40);
mouse.upAt(40, 40);
const createdElement = h.elements.find(
(element) => element.id !== frame.id && element.id !== lockedFrame.id,
);
expect(createdElement?.frameId).toBe(frame.id);
});
it("should insert a newly created frame child just below its frame", () => {
const frameChildUnderCursor = API.createElement({
id: "frameChildUnderCursor",
type: "rectangle",
x: 10,
y: 10,
width: 80,
height: 80,
backgroundColor: "#ffc9c9",
frameId: frame.id,
});
const otherFrameChild = API.createElement({
id: "otherFrameChild",
type: "rectangle",
x: 100,
y: 20,
width: 20,
height: 20,
frameId: frame.id,
});
API.setElements([frameChildUnderCursor, otherFrameChild, frame]);
UI.clickTool("rectangle");
mouse.downAt(20, 20);
mouse.moveTo(40, 40);
mouse.upAt(40, 40);
const createdElement = h.elements.find(
(element) =>
element.id !== frame.id &&
element.id !== frameChildUnderCursor.id &&
element.id !== otherFrameChild.id,
);
expect(createdElement?.frameId).toBe(frame.id);
expect(h.elements.map((element) => element.id)).toEqual([
frameChildUnderCursor.id,
otherFrameChild.id,
createdElement?.id,
frame.id,
]);
});
it("should insert a newly created frame child above the highest frame child", () => {
const frameChildUnderCursor = API.createElement({
id: "frameChildUnderCursor",
type: "rectangle",
x: 10,
y: 10,
width: 80,
height: 80,
backgroundColor: "#ffc9c9",
frameId: frame.id,
});
const otherFrameChild = API.createElement({
id: "otherFrameChild",
type: "rectangle",
x: 100,
y: 20,
width: 20,
height: 20,
frameId: frame.id,
});
API.setElements([frame, frameChildUnderCursor, otherFrameChild]);
UI.clickTool("rectangle");
mouse.downAt(20, 20);
mouse.moveTo(40, 40);
mouse.upAt(40, 40);
const createdElement = h.elements.find(
(element) =>
element.id !== frame.id &&
element.id !== frameChildUnderCursor.id &&
element.id !== otherFrameChild.id,
);
expect(createdElement?.frameId).toBe(frame.id);
expect(h.elements.map((element) => element.id)).toEqual([
frame.id,
frameChildUnderCursor.id,
otherFrameChild.id,
createdElement?.id,
]);
});
const commonTestCases = async ( const commonTestCases = async (
func: typeof resizeFrameOverElement | typeof dragElementIntoFrame, func: typeof resizeFrameOverElement | typeof dragElementIntoFrame,
) => { ) => {
@@ -668,373 +415,6 @@ describe("adding elements to frames", () => {
describe("dragging elements into the frame", async () => { describe("dragging elements into the frame", async () => {
await commonTestCases(dragElementIntoFrame); await commonTestCases(dragElementIntoFrame);
it("should add a dragged element fully containing the frame", () => {
const containingRect = API.createElement({
type: "rectangle",
x: 220,
y: 20,
width: 300,
height: 300,
});
API.setElements([frame, containingRect]);
dragElementIntoFrame(frame, containingRect);
expect(API.getElement(containingRect).frameId).toBe(frame.id);
});
it("should drag an element into a frame", () => {
API.setElements([rect2, frame]);
dragElementIntoFrame(frame, rect2);
expect(rect2.frameId).toBe(frame.id);
});
it("should move an element dragged from one frame into another", () => {
const otherFrame = API.createElement({
id: "otherFrame",
type: "frame",
x: 300,
y: 0,
width: 150,
height: 150,
});
const frameChild = API.createElement({
id: "frameChild",
type: "rectangle",
x: 50,
y: 50,
width: 20,
height: 20,
frameId: frame.id,
});
API.setElements([frame, frameChild, otherFrame]);
expect(frameChild.frameId).toBe(frame.id);
dragElementIntoFrame(otherFrame, frameChild);
expect(frameChild.frameId).toBe(otherFrame.id);
});
it("should layer a dragged element above the highest frame child", () => {
const frameChild = API.createElement({
id: "frameChild",
type: "rectangle",
x: 10,
y: 10,
width: 20,
height: 20,
frameId: frame.id,
});
API.setElements([frame, frameChild, rect2]);
dragElementIntoFrame(frame, rect2);
expect(rect2.frameId).toBe(frame.id);
expect(h.elements.map((element) => element.id)).toEqual([
frame.id,
frameChild.id,
rect2.id,
]);
expect(rect2.index! > frameChild.index!).toBe(true);
expect(rect2.index! > frame.index!).toBe(true);
});
it("should preview a dragged element above the highest frame child before pointerup", () => {
const frameChild = API.createElement({
id: "frameChild",
type: "rectangle",
x: 10,
y: 10,
width: 20,
height: 20,
frameId: frame.id,
});
API.setElements([rect2, frame, frameChild]);
API.setSelectedElements([rect2]);
API.updateElement(rect2, {
x: 10,
y: 10,
});
const getRenderableElementIds = (
selectedElementsAreBeingDragged: boolean,
) => {
return h.app.renderer
.getRenderableElements({
zoom: h.state.zoom,
offsetLeft: 0,
offsetTop: 0,
scrollX: 0,
scrollY: 0,
height: 1000,
width: 1000,
editingTextElement: h.state.editingTextElement,
newElement: h.state.newElement,
selectedElements: getSelectedElements(h.elements, h.state),
selectedElementsAreBeingDragged,
frameToHighlight: frame as ExcalidrawFrameLikeElement,
})
.visibleElements.map((element) => element.id);
};
expect(h.elements.map((element) => element.id)).toEqual([
rect2.id,
frame.id,
frameChild.id,
]);
expect(getRenderableElementIds(false)).toEqual([
rect2.id,
frame.id,
frameChild.id,
]);
expect(getRenderableElementIds(true)).toEqual([
frame.id,
frameChild.id,
rect2.id,
]);
expect(h.elements.map((element) => element.id)).toEqual([
rect2.id,
frame.id,
frameChild.id,
]);
expect(rect2.frameId).toBe(null);
});
it("should not preview reorder dragged elements already in the highlighted frame", () => {
const frameChild = API.createElement({
id: "frameChild",
type: "rectangle",
x: 10,
y: 10,
width: 20,
height: 20,
frameId: frame.id,
});
const otherFrameChild = API.createElement({
id: "otherFrameChild",
type: "rectangle",
x: 40,
y: 10,
width: 20,
height: 20,
frameId: frame.id,
});
API.setElements([frameChild, frame, otherFrameChild]);
API.setSelectedElements([frameChild]);
const renderableElementIds = h.app.renderer
.getRenderableElements({
zoom: h.state.zoom,
offsetLeft: 0,
offsetTop: 0,
scrollX: 0,
scrollY: 0,
height: 1000,
width: 1000,
editingTextElement: h.state.editingTextElement,
newElement: h.state.newElement,
selectedElements: getSelectedElements(h.elements, h.state),
selectedElementsAreBeingDragged: true,
frameToHighlight: frame as ExcalidrawFrameLikeElement,
})
.visibleElements.map((element) => element.id);
expect(renderableElementIds).toEqual([
frameChild.id,
frame.id,
otherFrameChild.id,
]);
});
it("should put a dragged mixed selection above the highest frame child", () => {
const frameChild = API.createElement({
id: "frameChild",
type: "rectangle",
x: 50,
y: 10,
width: 20,
height: 20,
frameId: frame.id,
boundElements: [{ id: "boundText", type: "text" }],
});
const boundText = API.createElement({
id: "boundText",
type: "text",
x: 50,
y: 10,
width: 20,
height: 20,
containerId: frameChild.id,
frameId: frame.id,
});
const otherFrameChild = API.createElement({
id: "otherFrameChild",
type: "rectangle",
x: 80,
y: 10,
width: 20,
height: 20,
frameId: frame.id,
});
const nonFrameElement = API.createElement({
id: "nonFrameElement",
type: "rectangle",
x: 155,
y: 10,
width: 20,
height: 20,
});
API.setElements([
frame,
frameChild,
boundText,
otherFrameChild,
nonFrameElement,
]);
API.setSelectedElements([frameChild, nonFrameElement]);
mouse.downAt(
nonFrameElement.x + nonFrameElement.width / 2,
nonFrameElement.y + nonFrameElement.height / 2,
);
mouse.moveTo(frame.x + frame.width - 5, nonFrameElement.y + 10);
mouse.up();
expect(frameChild.frameId).toBe(frame.id);
expect(boundText.frameId).toBe(frame.id);
expect(nonFrameElement.frameId).toBe(frame.id);
expect(h.elements.map((element) => element.id)).toEqual([
frame.id,
otherFrameChild.id,
frameChild.id,
boundText.id,
nonFrameElement.id,
]);
});
it("should not reorder dragged elements already in the highlighted frame", () => {
const frameChild = API.createElement({
id: "frameChild",
type: "rectangle",
x: 50,
y: 10,
width: 20,
height: 20,
frameId: frame.id,
});
const otherFrameChild = API.createElement({
id: "otherFrameChild",
type: "rectangle",
x: 80,
y: 10,
width: 20,
height: 20,
frameId: frame.id,
});
API.setElements([frame, frameChild, otherFrameChild]);
API.setSelectedElements([frameChild]);
mouse.downAt(
frameChild.x + frameChild.width / 2,
frameChild.y + frameChild.height / 2,
);
mouse.moveTo(frameChild.x + frameChild.width / 2 + 5, frameChild.y + 10);
mouse.up();
expect(frameChild.frameId).toBe(frame.id);
expect(h.elements.map((element) => element.id)).toEqual([
frame.id,
frameChild.id,
otherFrameChild.id,
]);
});
it("should not drag an element into a frame behind a non-frame element", () => {
const cover = API.createElement({
id: "cover",
type: "rectangle",
x: 10,
y: 10,
width: 80,
height: 80,
backgroundColor: "#ffc9c9",
});
API.setElements([frame, cover, rect2]);
mouse.clickAt(rect2.x, rect2.y);
mouse.downAt(rect2.x + rect2.width / 2, rect2.y + rect2.height / 2);
mouse.moveTo(20, 20);
mouse.upAt(20, 20);
expect(rect2.frameId).toBe(null);
});
it("should drag an element into a frame over a non-frame element", () => {
const cover = API.createElement({
id: "cover",
type: "rectangle",
x: 10,
y: 10,
width: 80,
height: 80,
backgroundColor: "#ffc9c9",
});
API.setElements([cover, rect2, frame]);
mouse.clickAt(rect2.x, rect2.y);
mouse.downAt(rect2.x + rect2.width / 2, rect2.y + rect2.height / 2);
mouse.moveTo(20, 20);
mouse.upAt(20, 20);
expect(rect2.frameId).toBe(frame.id);
});
it("should keep dragging a frame child over a non-frame element above its frame", () => {
const cover = API.createElement({
id: "cover",
type: "rectangle",
x: 10,
y: 10,
width: 80,
height: 80,
backgroundColor: "#ffc9c9",
});
const frameChild = API.createElement({
id: "frameChild",
type: "rectangle",
x: 100,
y: 20,
width: 20,
height: 20,
frameId: frame.id,
});
API.setElements([frameChild, frame, cover]);
API.setSelectedElements([frameChild]);
mouse.downAt(
frameChild.x + frameChild.width / 2,
frameChild.y + frameChild.height / 2,
);
mouse.moveTo(20, 20);
expect(h.state.frameToHighlight?.id).toBe(frame.id);
mouse.upAt(20, 20);
expect(frameChild.frameId).toBe(frame.id);
});
it.skip("should drag element inside, duplicate it and keep it in frame", () => { it.skip("should drag element inside, duplicate it and keep it in frame", () => {
API.setElements([frame, rect2]); API.setElements([frame, rect2]);
+3 -43
View File
@@ -326,59 +326,19 @@ describe("normalizeElementsOrder", () => {
]), ]),
[ [
"BA_rect1", "BA_rect1",
"CBA_rect3",
"CBA_rect7",
"BA_rect5", "BA_rect5",
"BA_rect6", "BA_rect6",
"A_rect2", "A_rect2",
"A_rect5", "A_rect5",
"CBA_rect3",
"CBA_rect7",
"rect4", "rect4",
"X_rect8", "X_rect8",
"YX_rect10",
"X_rect11", "X_rect11",
"YX_rect10",
"rect9", "rect9",
], ],
); );
assertOrder(
normalizeElementOrder([
API.createElement({
id: "A_rect1",
type: "rectangle",
groupIds: ["A"],
}),
API.createElement({
id: "CBA_rect2",
type: "rectangle",
groupIds: ["C", "B", "A"],
}),
API.createElement({
id: "A_rect3",
type: "rectangle",
groupIds: ["A"],
}),
]),
["A_rect1", "CBA_rect2", "A_rect3"],
);
assertOrder(
normalizeElementOrder([
API.createElement({
id: "abcT_rect1",
type: "rectangle",
groupIds: ["ab", "c", "T"],
}),
API.createElement({
id: "abcT_rect2",
type: "rectangle",
groupIds: ["a", "bc", "T"],
}),
API.createElement({
id: "abcT_rect3",
type: "rectangle",
groupIds: ["ab", "c", "T"],
}),
]),
["abcT_rect1", "abcT_rect3", "abcT_rect2"],
);
}); });
// TODO // TODO
+1 -70
View File
@@ -1,8 +1,4 @@
import { import { wrapText, parseTokens } from "../src/textWrapping";
getWrappedTextLines,
parseTokens,
wrapText,
} from "../src/textWrapping";
import type { FontString } from "../src/types"; import type { FontString } from "../src/types";
@@ -106,71 +102,6 @@ describe("Test wrapText", () => {
expect(res2).toBe(`\tA)\none\ntab\n- two\ntabs\n- 8\nspace\ns`); expect(res2).toBe(`\tA)\none\ntab\n- two\ntabs\n- 8\nspace\ns`);
}); });
it("should retain original text offsets for wrapped lines", () => {
expect(getWrappedTextLines("Hello World!", font, 60)).toEqual([
{
text: "Hello",
start: 0,
end: 5,
},
{
text: "World!",
start: 6,
end: 12,
},
]);
});
it("should exclude whitespace trimmed away at soft-wrap boundaries from line offsets", () => {
expect(getWrappedTextLines(" Hello World", font, 90)).toEqual([
{
text: " Hello",
start: 0,
end: 7,
},
{
text: "World",
start: 9,
end: 14,
},
]);
});
it("should retain offsets when wrapping a single long token", () => {
expect(getWrappedTextLines("Excalidraw", font, 50)).toEqual([
{
text: "Excal",
start: 0,
end: 5,
},
{
text: "idraw",
start: 5,
end: 10,
},
]);
});
it("should preserve empty hard lines in metadata", () => {
expect(getWrappedTextLines("A\n\nB", font, 100)).toEqual([
{
text: "A",
start: 0,
end: 1,
},
{
text: "",
start: 2,
end: 2,
},
{
text: "B",
start: 3,
end: 4,
},
]);
});
describe("When text is CJK", () => { describe("When text is CJK", () => {
it("should break each CJK character when width is very small", () => { it("should break each CJK character when width is very small", () => {
// "안녕하세요" (Hangul) + "こんにちは世界" (Hiragana, Kanji) + "コンニチハ" (Katakana) + "你好" (Han) = "Hello Hello World Hello Hi" // "안녕하세요" (Hangul) + "こんにちは世界" (Hiragana, Kanji) + "コンニチハ" (Katakana) + "你好" (Han) = "Hello Hello World Hello Hi"
-186
View File
@@ -1509,190 +1509,4 @@ describe("z-indexing with frames", () => {
], ],
}); });
}); });
it("bringing to front / sending to back children of MULTIPLE frames at once moves all of them", () => {
assertZindex({
elements: [
{ id: "F1_1", frameId: "F1", isSelected: true },
{ id: "F1_2", frameId: "F1" },
{ id: "F1", type: "frame" },
{ id: "F2_1", frameId: "F2", isSelected: true },
{ id: "F2_2", frameId: "F2" },
{ id: "F2", type: "frame" },
],
operations: [
// +∞: each selected child moves to the front of its own frame
[actionBringToFront, ["F1_2", "F1", "F1_1", "F2_2", "F2", "F2_1"]],
// -∞: each selected child moves to the back of its own frame
[actionSendToBack, ["F1_1", "F1_2", "F1", "F2_1", "F2_2", "F2"]],
],
});
});
it("send to back / bring to front of a grouped frame child (in group-editing mode) must not duplicate elements", () => {
assertZindex({
elements: [
{ id: "F1_1", frameId: "F1", groupIds: ["g1"] },
{ id: "F1_2", frameId: "F1", groupIds: ["g1"], isSelected: true },
{ id: "F1", type: "frame" },
{ id: "F2_1", frameId: "F2", groupIds: ["g2"] },
{ id: "F2_2", frameId: "F2", groupIds: ["g2"] },
{ id: "F2", type: "frame" },
],
appState: { editingGroupId: "g1" },
operations: [
// -∞ (send to back, within the frame)
[actionSendToBack, ["F1_2", "F1_1", "F1", "F2_1", "F2_2", "F2"]],
// +∞ (bring to front, within the frame)
[actionBringToFront, ["F1_1", "F1", "F1_2", "F2_1", "F2_2", "F2"]],
],
});
});
});
/**
* The inputs in this block intentionally VIOLATE the (soft) invariant that a
* frame's children — and a group's members are contiguous in the elements
* array. Such states shouldn't occur in normal use, but they CAN arise from
* bugs or broken input, because nothing re-defragments element order during
* a reorder (`normalizeElementOrder` only runs on duplication). We keep these
* tests so the reordering ops stay exercised against malformed order.
*
* HARD CONTRACT (a failure here is a real bug): a reorder must never throw,
* duplicate, or drop elements. `assertReorderPreservesElements` checks this.
*
* SOFT SNAPSHOT (read before "fixing"): the exact resulting ORDER is NOT a
* contract for invalid input it's whatever the slice math happens to
* produce. If a future change alters an `expected` order below, that is NOT
* necessarily a functional regression. First confirm from the diff that the
* hard contract still holds (nothing duplicated/lost), then update the
* expected order to match, provided it's deemed an improvement over the
* previous order, or it's an acceptable change given the underlying logic
* change.
*/
describe("z-index reordering with broken contiguity (invariant-violating input)", () => {
beforeEach(async () => {
await render(<Excalidraw />);
});
const assertReorderPreservesElements = (
elements: Parameters<typeof populateElements>[0],
appState: Parameters<typeof populateElements>[1],
// each op is applied to a freshly-populated (broken) state
cases: [Actions, string[]][],
) => {
for (const [action, expected] of cases) {
populateElements(elements, appState);
const before = h.elements.map((el) => el.id);
expect(() => API.executeAction(action)).not.toThrow();
const after = h.elements.map((el) => el.id);
// hard contract:
expect(after.length).toBe(before.length); // no loss
expect(new Set(after).size).toBe(after.length); // no duplication
// soft snapshot (see block comment before changing):
expect(after).toEqual(expected);
}
};
it("discontiguous frame children (foreign frame's child interleaved in span)", () => {
// F2_1 (a child of frame F2) sits INSIDE frame F1's z-span. Reordering F1's
// child sweeps F2_1 along (span-based frame handling) — wrong ordering, but
// never a duplication/loss, and the op does not throw.
const elements: Parameters<typeof populateElements>[0] = [
{ id: "F1_1", frameId: "F1", isSelected: true },
{ id: "F2_1", frameId: "F2" },
{ id: "F1_2", frameId: "F1" },
{ id: "F1", type: "frame" },
{ id: "F2", type: "frame" },
];
assertReorderPreservesElements(elements, undefined, [
[actionBringForward, ["F2_1", "F1_2", "F1_1", "F1", "F2"]],
[actionSendBackward, ["F1_1", "F2_1", "F1_2", "F1", "F2"]],
[actionBringToFront, ["F2_1", "F1_2", "F1", "F1_1", "F2"]],
[actionSendToBack, ["F1_1", "F2_1", "F1_2", "F1", "F2"]],
]);
});
it("discontiguous group, whole group selected", () => {
// g1 = {A, C}, scattered by the loose elements B and D.
const elements: Parameters<typeof populateElements>[0] = [
{ id: "A", groupIds: ["g1"], isSelected: true },
{ id: "B" },
{ id: "C", groupIds: ["g1"], isSelected: true },
{ id: "D" },
];
assertReorderPreservesElements(elements, undefined, [
// move-by-one leaves the group scattered (each run moves independently)
[actionBringForward, ["B", "A", "D", "C"]],
[actionSendBackward, ["A", "C", "B", "D"]],
// to-front / to-back gather the scattered members back into one block
[actionBringToFront, ["B", "D", "A", "C"]],
[actionSendToBack, ["A", "C", "B", "D"]],
]);
});
it("discontiguous group, single member selected in group-editing mode", () => {
const elements: Parameters<typeof populateElements>[0] = [
{ id: "A", groupIds: ["g1"] },
{ id: "B" },
{ id: "C", groupIds: ["g1"], isSelected: true },
{ id: "D" },
];
assertReorderPreservesElements(elements, { editingGroupId: "g1" }, [
[actionBringForward, ["A", "B", "C", "D"]],
[actionSendBackward, ["C", "A", "B", "D"]],
[actionBringToFront, ["A", "B", "C", "D"]],
[actionSendToBack, ["C", "A", "B", "D"]],
]);
});
it("two interleaved groups, both fully selected", () => {
const elements: Parameters<typeof populateElements>[0] = [
{ id: "A", groupIds: ["g1"], isSelected: true },
{ id: "X", groupIds: ["g2"], isSelected: true },
{ id: "C", groupIds: ["g1"], isSelected: true },
{ id: "Y", groupIds: ["g2"], isSelected: true },
{ id: "Z" },
];
assertReorderPreservesElements(elements, undefined, [
[actionBringForward, ["Z", "A", "X", "C", "Y"]],
[actionSendBackward, ["A", "X", "C", "Y", "Z"]],
[actionBringToFront, ["Z", "A", "X", "C", "Y"]],
[actionSendToBack, ["A", "X", "C", "Y", "Z"]],
]);
});
});
describe("z-index reordering with inconsistent group-editing state", () => {
beforeEach(async () => {
await render(<Excalidraw />);
});
it("does not duplicate or drop elements when selected elements fall outside the edited group scope", () => {
assertZindex({
elements: [
{ id: "A", groupIds: ["g1"], isSelected: true },
{ id: "C", groupIds: ["g1"] },
{ id: "X", groupIds: ["g2"] },
{ id: "Y", groupIds: ["g2"] },
{ id: "R" },
],
appState: { editingGroupId: "g2" },
operations: [[actionSendToBack, ["A", "C", "X", "Y", "R"]]],
});
assertZindex({
elements: [
{ id: "A", groupIds: ["g1"] },
{ id: "C", groupIds: ["g1"] },
{ id: "X", groupIds: ["g2"], isSelected: true },
{ id: "Y", groupIds: ["g2"] },
{ id: "R" },
],
appState: { editingGroupId: "g1" },
operations: [[actionBringToFront, ["A", "C", "X", "Y", "R"]]],
});
});
}); });
-1
View File
@@ -1,7 +1,6 @@
{ {
"extends": "../tsconfig.base.json", "extends": "../tsconfig.base.json",
"compilerOptions": { "compilerOptions": {
"rootDir": "../",
"outDir": "./dist/types" "outDir": "./dist/types"
}, },
"include": ["src/**/*", "global.d.ts"], "include": ["src/**/*", "global.d.ts"],
-88
View File
@@ -11,94 +11,6 @@ The change should be grouped under one of the below section and must contain PR
Please add the latest change on the top under the correct section. Please add the latest change on the top under the correct section.
--> -->
## Unreleased
## Excalidraw API
### Breaking changes
- Theme changes initiated by the default UI are now delegated to `<Excalidraw onThemeChange={(theme) => ...} />` when supplied. If `onThemeChange` is not supplied, light/dark theme toggling still falls back to updating the internal editor state.
- `MainMenu.DefaultItems.ToggleTheme` no longer accepts the item-level `onSelect` callback. Host apps that need to control light/dark/system theme should pass `onThemeChange` to `<Excalidraw />` instead.
- `MainMenu.DefaultItems.ToggleTheme` with system theme support now uses `allowSystemTheme` together with `theme={Theme | "system"}` only to render the selected value. For the regular light/dark item, pass `allowSystemTheme={false}`.
- `CommandPalette.defaultItems.toggleTheme` was removed. The default theme command is now rendered by the command palette itself when `UIOptions.canvasActions.toggleTheme` enables the action (see below).
- `UIOptions.canvasActions.toggleTheme` still controls default theme UI availability. When it is `null`, it defaults to `true` if `props.theme` is omitted or `props.onThemeChange` is supplied, and otherwise defaults to disabled.
- Renamed the `excalidrawAPI` prop to `onExcalidrawAPI`.
- `onExcalidrawAPI` is now called on mount (instead of during constructor), and later on unmount (with `null` value). The API may be removed altogether in the future (you can use `onMount` & `onUmount` to manage the `ExcalidrawAPI` object (e.g. to cache it to a global state), already).
### Features
- Added `ExcalidrawAPI.isDestroyed` flag. Set to `true` once the editor unmounts. Calling any `get*` method, `onStateChange`, or `onEvent` on a destroyed API instance will throw in development and `console.error` in production. The `ExcalidrawAPI` will be reset to `null` on umount, but to be extra safe, you should check `ExcalidrawAPI.isDestroyed` before calling these methods to guard against subtle race conditions in your code.
- Added `onMount`, `onInitialize`, and `onUnmount` props. `onMount` receives `{ excalidrawAPI, container }` once the editor root is mounted. `onInitialize` fires once the initial scene has loaded. `onUnmount` fires just before unmounting.
- Same events are also accessible imperatively through `api.onEvent(...)`.
```tsx
<Excalidraw
onExcalidrawAPI={(api) => {
api.onEvent("editor:mount", ({ excalidrawAPI, container }) => {
console.log(container);
});
api.onEvent("editor:initialize").then((readyApi) => {
readyApi.scrollToContent();
});
}}
/>
```
Note that in future releases, most, if not all, `excalidrawAPI.on*` subscriptions will be removed in favor of `excalidrawAPI.onEvent(name)`.
- Also added `"editor:unmount"` lifecycle event, only accessible via `api.onEvent("editor:unmount")`.
- Exported `<ExcalidrawAPIProvider/>`, `useExcalidrawAPI()`, `useAppStateValue(prop | props | selectorFunction)`, and `useOnExcalidrawStateChange(prop | props | selectorFunction, callback)` from the package. The imperative API also now exposes `onStateChange(prop | props | selectorFunction, callback?)`, and `onEvent(name, callback)`.
```tsx
<ExcalidrawAPIProvider>
<Excalidraw />
<Logger />
</ExcalidrawAPIProvider>;
function Logger() {
// initially null before the ExcalidrawAPIProvider initializes ater
// <Excalidraw/> renders
// When <Excalidraw/> unmounts, is reset back to null
const api = useExcalidrawAPI();
useAppStateValue("viewModeEnabled", (viewModeEnabled) => {
console.log("view mode changed:", viewModeEnabled);
});
React.useEffect(() => {
if (api) {
console.log("editor instance id:", api.id);
}
}, [api]);
return null;
}
```
- Added `onExport` so host apps can delay JSON export until async work completes. The handler receives the export data plus an `AbortSignal`, and may return a `Promise` or an async generator that yields progress updates for the built-in toast UI.
```tsx
<Excalidraw
onExport={async function* (_type, { files }, { signal }) {
yield { type: "progress", message: "Waiting for images..." };
await waitForImagesToLoad(files, signal);
if (signal.aborted) {
return;
}
yield { type: "progress", message: "Export ready", progress: 1 };
}}
/>
```
## Excalidraw Library ## Excalidraw Library
## 0.18.0 (2025-03-11) ## 0.18.0 (2025-03-11)
+14 -111
View File
@@ -1,10 +1,10 @@
# Excalidraw # Excalidraw
**Excalidraw** is exported as a React component that you can embed directly in your app. **Excalidraw** is exported as a component to be directly embedded in your project.
## Installation ## Installation
Install the package together with its React peer dependencies. Use `npm` or `yarn` to install the package.
```bash ```bash
npm install react react-dom @excalidraw/excalidraw npm install react react-dom @excalidraw/excalidraw
@@ -12,131 +12,34 @@ npm install react react-dom @excalidraw/excalidraw
yarn add react react-dom @excalidraw/excalidraw yarn add react react-dom @excalidraw/excalidraw
``` ```
> **Note**: If you want to try unreleased changes, use `@excalidraw/excalidraw@next`. > **Note**: If you don't want to wait for the next stable release and try out the unreleased changes, use `@excalidraw/excalidraw@next`.
## Quick start #### Self-hosting fonts
The minimum working setup has two easy-to-miss requirements: By default, Excalidraw will try to download all the used fonts from the [CDN](https://esm.run/@excalidraw/excalidraw/dist/prod).
1. Import the package CSS: For self-hosting purposes, you'll have to copy the content of the folder `node_modules/@excalidraw/excalidraw/dist/prod/fonts` to the path where your assets should be served from (i.e. `public/` directory in your project). In that case, you should also set `window.EXCALIDRAW_ASSET_PATH` to the very same path, i.e. `/` in case it's in the root:
```ts ```js
import "@excalidraw/excalidraw/index.css"; <script>window.EXCALIDRAW_ASSET_PATH = "/";</script>
``` ```
2. Render Excalidraw inside a container with a non-zero height. ### Dimensions of Excalidraw
```tsx Excalidraw takes _100%_ of `width` and `height` of the containing block so make sure the container in which you render Excalidraw has non zero dimensions.
import { Excalidraw } from "@excalidraw/excalidraw";
import "@excalidraw/excalidraw/index.css";
export default function App() {
return (
<div style={{ height: "100vh" }}>
<Excalidraw />
</div>
);
}
```
Excalidraw fills `100%` of the width and height of its parent. If the parent has no height, the canvas will not be visible.
## Next.js / SSR frameworks
Excalidraw should be rendered on the client. In SSR frameworks such as Next.js, use a client component and load it dynamically with SSR disabled.
```tsx
// app/components/ExcalidrawClient.tsx
"use client";
import { Excalidraw } from "@excalidraw/excalidraw";
import "@excalidraw/excalidraw/index.css";
export default function ExcalidrawClient() {
return (
<div style={{ height: "100vh" }}>
<Excalidraw />
</div>
);
}
```
```tsx
// app/page.tsx
import dynamic from "next/dynamic";
const ExcalidrawClient = dynamic(
() => import("./components/ExcalidrawClient"),
{ ssr: false },
);
export default function Page() {
return <ExcalidrawClient />;
}
```
See the local examples for complete setups:
- [examples/with-nextjs](https://github.com/excalidraw/excalidraw/tree/master/examples/with-nextjs)
- [examples/with-script-in-browser](https://github.com/excalidraw/excalidraw/tree/master/examples/with-script-in-browser)
## LLM / agent tips
If an LLM or coding agent is setting up Excalidraw, these shortcuts usually save more time than re-prompting:
- Start with a plain `<Excalidraw />` in a `100vh` container. Add refs, `initialData`, persistence, or custom UI only after the base embed works.
- If the canvas is blank, check the CSS import and parent height first. Those are the two most common integration failures.
- In Next.js or other SSR frameworks, assume client-only rendering first. Use `"use client"` and `dynamic(..., { ssr: false })` before debugging hydration or `window is not defined` errors.
- If imports or entrypoints are unclear, inspect `node_modules/@excalidraw/excalidraw/package.json`. The installed package exports are the source of truth.
- Do not set `window.EXCALIDRAW_ASSET_PATH` unless you are intentionally self-hosting fonts/assets.
- When docs and generated code drift, copy the nearest working example from this repo, especially `examples/with-nextjs` or `examples/with-script-in-browser`.
## Migrating to `@excalidraw/excalidraw@0.18.x`
Version `0.18.x` removes the old `types/`-prefixed deep import paths. If you were importing types from `@excalidraw/excalidraw/types/...`, switch to the new type-only subpaths below.
| Old path | New path |
| --- | --- |
| `@excalidraw/excalidraw/types/data/transform.js` | `@excalidraw/excalidraw/element/transform` |
| `@excalidraw/excalidraw/types/data/types.js` | `@excalidraw/excalidraw/data/types` |
| `@excalidraw/excalidraw/types/element/types.js` | `@excalidraw/excalidraw/element/types` |
| `@excalidraw/excalidraw/types/utility-types.js` | `@excalidraw/excalidraw/common/utility-types` |
| `@excalidraw/excalidraw/types/types.js` | `@excalidraw/excalidraw/types` |
Drop the `.js` extension. The new package `exports` map resolves these paths without it.
These deep subpaths are for `import type` only. Runtime imports should come from the package root, plus `@excalidraw/excalidraw/index.css` for styles.
For example:
```ts
import { exportToSvg } from "@excalidraw/excalidraw";
```
## Self-hosting fonts
By default, Excalidraw downloads the fonts it needs from the [CDN](https://esm.run/@excalidraw/excalidraw/dist/prod).
For self-hosting, copy the contents of `node_modules/@excalidraw/excalidraw/dist/prod/fonts` into the path where your app serves static assets, for example `public/`. Then set `window.EXCALIDRAW_ASSET_PATH` to that same path:
```html
<script>
window.EXCALIDRAW_ASSET_PATH = "/";
</script>
```
## Demo ## Demo
Try the [CodeSandbox example](https://codesandbox.io/p/sandbox/github/excalidraw/excalidraw/tree/master/examples/with-script-in-browser). Go to [CodeSandbox](https://codesandbox.io/p/sandbox/github/excalidraw/excalidraw/tree/master/examples/with-script-in-browser) example.
## Integration ## Integration
Read the [integration docs](https://docs.excalidraw.com/docs/@excalidraw/excalidraw/integration). Head over to the [docs](https://docs.excalidraw.com/docs/@excalidraw/excalidraw/integration).
## API ## API
Read the [API docs](https://docs.excalidraw.com/docs/@excalidraw/excalidraw/api). Head over to the [docs](https://docs.excalidraw.com/docs/@excalidraw/excalidraw/api).
## Contributing ## Contributing
Read the [contributing docs](https://docs.excalidraw.com/docs/@excalidraw/excalidraw/contributing). Head over to the [docs](https://docs.excalidraw.com/docs/@excalidraw/excalidraw/contributing).
@@ -5,7 +5,6 @@ import {
VERTICAL_ALIGN, VERTICAL_ALIGN,
arrayToMap, arrayToMap,
getFontString, getFontString,
getStrokeWidthByKey,
} from "@excalidraw/common"; } from "@excalidraw/common";
import { import {
getOriginalContainerHeightFromCache, getOriginalContainerHeightFromCache,
@@ -250,10 +249,7 @@ export const actionWrapTextInContainer = register({
fillStyle: appState.currentItemFillStyle, fillStyle: appState.currentItemFillStyle,
strokeColor: appState.currentItemStrokeColor, strokeColor: appState.currentItemStrokeColor,
roughness: appState.currentItemRoughness, roughness: appState.currentItemRoughness,
strokeWidth: getStrokeWidthByKey( strokeWidth: appState.currentItemStrokeWidth,
"rectangle",
appState.currentItemStrokeWidthKey,
),
strokeStyle: appState.currentItemStrokeStyle, strokeStyle: appState.currentItemStrokeStyle,
roundness: roundness:
appState.currentItemRoundness === "round" appState.currentItemRoundness === "round"
+4 -15
View File
@@ -477,28 +477,17 @@ export const actionToggleTheme = register<AppState["theme"]>({
appState.theme === THEME.LIGHT ? MoonIcon : SunIcon, appState.theme === THEME.LIGHT ? MoonIcon : SunIcon,
viewMode: true, viewMode: true,
trackEvent: { category: "canvas" }, trackEvent: { category: "canvas" },
perform: (_, appState, value, app) => { perform: (_, appState, value) => {
const nextTheme =
value || (appState.theme === THEME.LIGHT ? THEME.DARK : THEME.LIGHT);
if (app.props.onThemeChange) {
app.props.onThemeChange(nextTheme);
return false;
}
return { return {
appState: { appState: {
...appState, ...appState,
theme: nextTheme, theme:
value || (appState.theme === THEME.LIGHT ? THEME.DARK : THEME.LIGHT),
}, },
captureUpdate: CaptureUpdateAction.EVENTUALLY, captureUpdate: CaptureUpdateAction.EVENTUALLY,
}; };
}, },
keyTest: (event) => keyTest: (event) => event.altKey && event.shiftKey && event.code === CODES.D,
!event[KEYS.CTRL_OR_CMD] &&
event.altKey &&
event.shiftKey &&
event.code === CODES.D,
predicate: (elements, appState, props, app) => { predicate: (elements, appState, props, app) => {
return !!app.props.UIOptions.canvasActions.toggleTheme; return !!app.props.UIOptions.canvasActions.toggleTheme;
}, },
@@ -30,7 +30,7 @@ import { getSelectedElements, isSomeElementSelected } from "../scene";
import { TrashIcon } from "../components/icons"; import { TrashIcon } from "../components/icons";
import { ToolButton } from "../components/ToolButton"; import { ToolButton } from "../components/ToolButton";
import { useStylesPanelMode } from "../components/App"; import { useStylesPanelMode } from "..";
import { register } from "./register"; import { register } from "./register";
@@ -1,147 +0,0 @@
import {
getElementsInGroup,
isSomeElementSelected,
makeNextSelectedElementIds,
selectGroupsForSelectedElements,
} from "@excalidraw/element";
import { CaptureUpdateAction } from "@excalidraw/element";
import { KEYS, isWritableElement, updateActiveTool } from "@excalidraw/common";
import type { GroupId } from "@excalidraw/element/types";
import { register } from "./register";
import type { AppClassProperties, AppState } from "../types";
const getNextActiveTool = (
appState: Readonly<AppState>,
app: AppClassProperties,
) => {
if (appState.activeTool.type === "eraser") {
return updateActiveTool(appState, {
...(appState.activeTool.lastActiveTool || {
type: app.state.preferredSelectionTool.type,
}),
lastActiveToolBeforeEraser: null,
});
}
return updateActiveTool(appState, {
type: app.state.preferredSelectionTool.type,
});
};
const getParentEditingGroupId = (
appState: Readonly<AppState>,
app: AppClassProperties,
selectedElementIds: AppState["selectedElementIds"],
): GroupId | null => {
if (!appState.editingGroupId) {
return null;
}
const nonDeletedElements = app.scene.getNonDeletedElements();
const selectedElements = app.scene.getSelectedElements({
selectedElementIds,
elements: nonDeletedElements,
});
const candidateElements = selectedElements.length
? selectedElements
: getElementsInGroup(nonDeletedElements, appState.editingGroupId);
for (const element of candidateElements) {
const editingGroupIndex = element.groupIds.indexOf(appState.editingGroupId);
if (editingGroupIndex !== -1 && element.groupIds[editingGroupIndex + 1]) {
return element.groupIds[editingGroupIndex + 1] as GroupId;
}
}
return null;
};
export const actionDeselect = register({
name: "deselect",
label: "",
trackEvent: false,
perform: (_elements, appState, _, app) => {
const activeTool = getNextActiveTool(appState, app);
if (appState.editingGroupId) {
const nonDeletedElements = app.scene.getNonDeletedElements();
const selectedElementIds =
Object.keys(appState.selectedElementIds).length > 0
? appState.selectedElementIds
: getElementsInGroup(
nonDeletedElements,
appState.editingGroupId,
).reduce((acc, element) => {
acc[element.id] = true;
return acc;
}, {} as Record<string, true>);
return {
appState: {
...appState,
...selectGroupsForSelectedElements(
{
editingGroupId: getParentEditingGroupId(
appState,
app,
selectedElementIds,
),
selectedElementIds,
},
nonDeletedElements,
appState,
app,
),
activeEmbeddable: null,
activeTool,
selectedLinearElement: null,
selectionElement: null,
showHyperlinkPopup: false,
suggestedBinding: null,
frameToHighlight: null,
},
captureUpdate: CaptureUpdateAction.IMMEDIATELY,
};
}
return {
appState: {
...appState,
activeEmbeddable: null,
activeTool,
editingGroupId: null,
selectedElementIds: makeNextSelectedElementIds({}, appState),
selectedGroupIds: {},
selectedLinearElement: null,
selectionElement: null,
showHyperlinkPopup: false,
suggestedBinding: null,
frameToHighlight: null,
},
captureUpdate: CaptureUpdateAction.IMMEDIATELY,
};
},
keyTest: (event, appState, _, app) => {
if (event.key !== KEYS.ESCAPE) {
return false;
}
if (isWritableElement(event.target)) {
return false;
}
return (
!appState.newElement &&
appState.multiElement === null &&
!appState.selectedLinearElement?.isEditing &&
(appState.activeEmbeddable !== null ||
appState.activeTool.type !== app.state.preferredSelectionTool.type ||
!!appState.editingGroupId ||
!!appState.selectedLinearElement ||
isSomeElementSelected(app.scene.getNonDeletedElements(), appState))
);
},
});
@@ -27,7 +27,7 @@ import { t } from "../i18n";
import { isSomeElementSelected } from "../scene"; import { isSomeElementSelected } from "../scene";
import { getShortcutKey } from "../shortcut"; import { getShortcutKey } from "../shortcut";
import { useStylesPanelMode } from "../components/App"; import { useStylesPanelMode } from "..";
import { register } from "./register"; import { register } from "./register";
+38 -220
View File
@@ -9,20 +9,18 @@ import { getNonDeletedElements } from "@excalidraw/element";
import { CaptureUpdateAction } from "@excalidraw/element"; import { CaptureUpdateAction } from "@excalidraw/element";
import type { ExcalidrawElement, Theme } from "@excalidraw/element/types"; import type { Theme } from "@excalidraw/element/types";
import { useEditorInterface } from "../components/App"; import { useEditorInterface } from "../components/App";
import { CheckboxItem } from "../components/CheckboxItem"; import { CheckboxItem } from "../components/CheckboxItem";
import { DarkModeToggle } from "../components/DarkModeToggle"; import { DarkModeToggle } from "../components/DarkModeToggle";
import { ProjectName } from "../components/ProjectName"; import { ProjectName } from "../components/ProjectName";
import { Toast } from "../components/Toast";
import { ToolButton } from "../components/ToolButton"; import { ToolButton } from "../components/ToolButton";
import { Tooltip } from "../components/Tooltip"; import { Tooltip } from "../components/Tooltip";
import { ExportIcon, questionCircle, saveAs } from "../components/icons"; import { ExportIcon, questionCircle, saveAs } from "../components/icons";
import { loadFromJSON, saveAsJSON } from "../data"; import { loadFromJSON, saveAsJSON } from "../data";
import { isImageFileHandle } from "../data/blob"; import { isImageFileHandle } from "../data/blob";
import { nativeFileSystemSupported } from "../data/filesystem"; import { nativeFileSystemSupported } from "../data/filesystem";
import { resaveAsImageWithScene } from "../data/resave"; import { resaveAsImageWithScene } from "../data/resave";
import { t } from "../i18n"; import { t } from "../i18n";
@@ -33,15 +31,7 @@ import "../components/ToolIcon.scss";
import { register } from "./register"; import { register } from "./register";
import type { JSONExportData } from "../data/json"; import type { AppState } from "../types";
import type {
AppClassProperties,
AppState,
BinaryFiles,
ExcalidrawProps,
OnExportProgress,
} from "../types";
export const actionChangeProjectName = register<AppState["name"]>({ export const actionChangeProjectName = register<AppState["name"]>({
name: "changeProjectName", name: "changeProjectName",
@@ -160,143 +150,6 @@ export const actionChangeExportEmbedScene = register<
), ),
}); });
// ---------------------------------------------------------------------------
// onExport interception helpers
// ---------------------------------------------------------------------------
let onExportInProgress = false;
const onProgressToast = (
app: AppClassProperties,
progress: {
message?: OnExportProgress["message"];
progress?: number | null;
},
) => {
const message = progress.message ?? t("progressDialog.defaultMessage");
app.setAppState({
toast: {
message:
progress.progress != null ? (
<>
{message}
<Toast.ProgressBar progress={progress.progress} />
</>
) : (
message
),
duration: Infinity,
},
});
};
/** awaits host app's onExport result, and renders progress to the UI */
async function handleOnExportResult(
onExportResult: ReturnType<NonNullable<ExcalidrawProps["onExport"]>>,
opts: {
signal: AbortSignal;
app: AppClassProperties;
},
): Promise<void> {
if (opts.app.state.isLoading) {
onProgressToast(opts.app, { progress: null });
await opts.app.onStateChange({ predicate: (state) => !state.isLoading });
}
if (
onExportResult != null &&
typeof onExportResult === "object" &&
Symbol.asyncIterator in onExportResult
) {
for await (const value of onExportResult) {
if (opts.signal.aborted) {
onExportResult.return();
return;
}
if (value.type === "progress") {
onProgressToast(opts.app, {
message: value.message,
progress: value.progress ?? null,
});
} else if (value.type === "done") {
return;
}
}
// Generator completed without explicit "done" message
return;
}
if (onExportResult instanceof Promise) {
onProgressToast(opts.app, { progress: null });
await onExportResult;
}
}
function prepareDataForJSONExport(
elements: readonly ExcalidrawElement[],
appState: AppState,
files: BinaryFiles,
app: AppClassProperties,
): { abortController: AbortController; data: Promise<JSONExportData> } {
const abortController = new AbortController();
const signal = abortController.signal;
const dataPromise = new Promise<JSONExportData>(async (resolve) => {
try {
if (app.props.onExport) {
await handleOnExportResult(
app.props.onExport(
"json",
{
elements,
appState,
files,
},
{
signal,
},
),
{
app,
signal,
},
);
}
} catch (error: any) {
if (error?.name === "AbortError") {
// if abort error, assume it's a reaction on the signal being aborted
console.warn(
`onExport() aborted by host app (signal aborted: ${signal.aborted})`,
);
} else {
// non-abort error
//
console.error("Error during props.onExport() handling", error);
}
// either way, we currently don't allow host apps to cancel save actions
// so we resolve to orig data
}
resolve({
elements,
appState,
// return latest files in case they finished loading during onExport
files: app.files,
});
});
return {
abortController,
data: dataPromise,
};
}
// ---------------------------------------------------------------------------
// Save actions
// ---------------------------------------------------------------------------
export const actionSaveToActiveFile = register({ export const actionSaveToActiveFile = register({
name: "saveToActiveFile", name: "saveToActiveFile",
label: "buttons.save", label: "buttons.save",
@@ -310,62 +163,42 @@ export const actionSaveToActiveFile = register({
); );
}, },
perform: async (elements, appState, value, app) => { perform: async (elements, appState, value, app) => {
if (onExportInProgress) { const fileHandleExists = !!appState.fileHandle;
return false;
}
onExportInProgress = true;
const previousFileHandle = appState.fileHandle;
const filename = app.getName();
const { abortController, data: exportedDataPromise } =
prepareDataForJSONExport(elements, appState, app.files, app);
try { try {
const { fileHandle } = isImageFileHandle(previousFileHandle) const { fileHandle } = isImageFileHandle(appState.fileHandle)
? await resaveAsImageWithScene( ? await resaveAsImageWithScene(
exportedDataPromise, elements,
previousFileHandle, appState,
filename, app.files,
app.getName(),
) )
: await saveAsJSON({ : await saveAsJSON(elements, appState, app.files, app.getName());
data: exportedDataPromise,
filename,
fileHandle: previousFileHandle,
});
return { return {
captureUpdate: CaptureUpdateAction.NEVER, captureUpdate: CaptureUpdateAction.EVENTUALLY,
appState: { appState: {
...appState,
fileHandle, fileHandle,
toast: { toast: fileHandleExists
message: ? {
previousFileHandle && fileHandle?.name message: fileHandle?.name
? t("toast.fileSavedToFilename").replace( ? t("toast.fileSavedToFilename").replace(
"{filename}", "{filename}",
`"${fileHandle.name}"`, `"${fileHandle.name}"`,
) )
: t("toast.fileSaved"), : t("toast.fileSaved"),
duration: 1500, }
}, : null,
}, },
}; };
} catch (error: any) { } catch (error: any) {
abortController.abort();
if (error?.name !== "AbortError") { if (error?.name !== "AbortError") {
console.error(error); console.error(error);
} else { } else {
console.warn(error); console.warn(error);
} }
return { return { captureUpdate: CaptureUpdateAction.EVENTUALLY };
captureUpdate: CaptureUpdateAction.NEVER,
appState: {
toast: null,
},
};
} finally {
onExportInProgress = false;
} }
}, },
keyTest: (event) => keyTest: (event) =>
@@ -379,50 +212,36 @@ export const actionSaveFileToDisk = register({
viewMode: true, viewMode: true,
trackEvent: { category: "export" }, trackEvent: { category: "export" },
perform: async (elements, appState, value, app) => { perform: async (elements, appState, value, app) => {
if (onExportInProgress) {
return false;
}
onExportInProgress = true;
const { abortController, data: exportedDataPromise } =
prepareDataForJSONExport(elements, appState, app.files, app);
try { try {
const { fileHandle: savedFileHandle } = await saveAsJSON({ const { fileHandle } = await saveAsJSON(
data: exportedDataPromise, elements,
filename: app.getName(), {
fileHandle: null, ...appState,
}); fileHandle: null,
},
app.files,
app.getName(),
);
return { return {
captureUpdate: CaptureUpdateAction.NEVER, captureUpdate: CaptureUpdateAction.EVENTUALLY,
appState: { appState: {
...appState,
openDialog: null, openDialog: null,
fileHandle: savedFileHandle, fileHandle,
toast: { message: t("toast.fileSaved"), duration: 3000 }, toast: { message: t("toast.fileSaved") },
}, },
}; };
} catch (error: any) { } catch (error: any) {
abortController.abort();
if (error?.name !== "AbortError") { if (error?.name !== "AbortError") {
console.error(error); console.error(error);
} else { } else {
console.warn(error); console.warn(error);
} }
return { return { captureUpdate: CaptureUpdateAction.EVENTUALLY };
captureUpdate: CaptureUpdateAction.NEVER,
appState: {
toast: null,
},
};
} finally {
onExportInProgress = false;
} }
}, },
keyTest: (event) => keyTest: (event) =>
event.key.toLowerCase() === KEYS.S && event.key === KEYS.S && event.shiftKey && event[KEYS.CTRL_OR_CMD],
event.shiftKey &&
event[KEYS.CTRL_OR_CMD],
PanelComponent: ({ updateData }) => ( PanelComponent: ({ updateData }) => (
<ToolButton <ToolButton
type="button" type="button"
@@ -481,8 +300,7 @@ export const actionExportWithDarkMode = register<
name: "exportWithDarkMode", name: "exportWithDarkMode",
label: "imageExportDialog.label.darkMode", label: "imageExportDialog.label.darkMode",
trackEvent: { category: "export", action: "toggleTheme" }, trackEvent: { category: "export", action: "toggleTheme" },
perform: (_elements, appState, value, app) => { perform: (_elements, appState, value) => {
app.sessionExportThemeOverride = value ? THEME.DARK : THEME.LIGHT;
return { return {
appState: { ...appState, exportWithDarkMode: value }, appState: { ...appState, exportWithDarkMode: value },
captureUpdate: CaptureUpdateAction.EVENTUALLY, captureUpdate: CaptureUpdateAction.EVENTUALLY,
+5 -41
View File
@@ -54,7 +54,6 @@ export const actionFinalize = register<FormData>({
label: "", label: "",
trackEvent: false, trackEvent: false,
perform: (elements, appState, data, app) => { perform: (elements, appState, data, app) => {
let shouldCommit = true;
let newElements = elements; let newElements = elements;
const { interactiveCanvas, focusContainer, scene } = app; const { interactiveCanvas, focusContainer, scene } = app;
const elementsMap = scene.getNonDeletedElementsMap(); const elementsMap = scene.getNonDeletedElementsMap();
@@ -223,44 +222,9 @@ export const actionFinalize = register<FormData>({
!lastCommittedPoint || !lastCommittedPoint ||
points[points.length - 1] !== lastCommittedPoint points[points.length - 1] !== lastCommittedPoint
) { ) {
shouldCommit = false;
scene.mutateElement(element, { scene.mutateElement(element, {
points: element.points.slice(0, -1), points: element.points.slice(0, -1),
}); });
if (
isBindingElement(element) &&
element.endBinding &&
// after slicing the trailing point a <2-point arrow may be left
element.points.length > 1
) {
const newArrow = !!appState.newElement;
const draggedPoints: PointsPositionUpdates = new Map([
[
element.points.length - 1,
{
point: element.points[element.points.length - 1],
isDragging: false,
},
],
]);
const globalPoint =
LinearElementEditor.getPointAtIndexGlobalCoordinates(
element,
-1,
elementsMap,
);
bindOrUnbindBindingElement(
element,
draggedPoints,
globalPoint[0],
globalPoint[1],
scene,
appState,
{
newArrow,
},
);
}
} }
} }
@@ -365,8 +329,8 @@ export const actionFinalize = register<FormData>({
selectionElement: null, selectionElement: null,
multiElement: null, multiElement: null,
editingTextElement: null, editingTextElement: null,
startBoundElement: null,
suggestedBinding: null, suggestedBinding: null,
frameToHighlight: null,
selectedElementIds: selectedElementIds:
element && element &&
!appState.activeTool.locked && !appState.activeTool.locked &&
@@ -380,13 +344,13 @@ export const actionFinalize = register<FormData>({
selectedLinearElement, selectedLinearElement,
}, },
// TODO: #7348 we should not capture everything, but if we don't, it leads to incosistencies -> revisit // TODO: #7348 we should not capture everything, but if we don't, it leads to incosistencies -> revisit
captureUpdate: shouldCommit captureUpdate: CaptureUpdateAction.IMMEDIATELY,
? CaptureUpdateAction.IMMEDIATELY
: CaptureUpdateAction.NEVER,
}; };
}, },
keyTest: (event, appState) => keyTest: (event, appState) =>
(event.key === KEYS.ESCAPE && appState.selectedLinearElement?.isEditing) || (event.key === KEYS.ESCAPE &&
(appState.selectedLinearElement?.isEditing ||
(!appState.newElement && 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, data }) => ( PanelComponent: ({ appState, updateData, data }) => (
@@ -205,6 +205,7 @@ export const actionWrapSelectionInFrame = register({
[...app.scene.getElementsIncludingDeleted(), frame], [...app.scene.getElementsIncludingDeleted(), frame],
selectedElements, selectedElements,
frame, frame,
appState,
); );
return { return {
@@ -277,6 +277,7 @@ export const actionUngroup = register({
elementsMap, elementsMap,
), ),
frame, frame,
app,
); );
} }
}); });
@@ -1,4 +1,5 @@
import { import {
isWindows,
KEYS, KEYS,
matchKey, matchKey,
arrayToMap, arrayToMap,
@@ -17,7 +18,7 @@ import { HistoryChangedEvent } from "../history";
import { useEmitter } from "../hooks/useEmitter"; import { useEmitter } from "../hooks/useEmitter";
import { t } from "../i18n"; import { t } from "../i18n";
import { useStylesPanelMode } from "../components/App"; import { useStylesPanelMode } from "..";
import type { History } from "../history"; import type { History } from "../history";
import type { AppClassProperties, AppState } from "../types"; import type { AppClassProperties, AppState } from "../types";
@@ -113,7 +114,7 @@ export const createRedoAction: ActionCreator = (history) => ({
), ),
keyTest: (event) => keyTest: (event) =>
(event[KEYS.CTRL_OR_CMD] && event.shiftKey && matchKey(event, KEYS.Z)) || (event[KEYS.CTRL_OR_CMD] && event.shiftKey && matchKey(event, KEYS.Z)) ||
(event[KEYS.CTRL_OR_CMD] && !event.shiftKey && matchKey(event, KEYS.Y)), (isWindows && event.ctrlKey && !event.shiftKey && matchKey(event, KEYS.Y)),
PanelComponent: ({ appState, updateData, data, app }) => { PanelComponent: ({ appState, updateData, data, app }) => {
const { isRedoStackEmpty } = useEmitter( const { isRedoStackEmpty } = useEmitter(
history.onHistoryChangedEmitter, history.onHistoryChangedEmitter,
@@ -1,9 +1,8 @@
import { fireEvent, queryByTestId } from "@testing-library/react"; import { queryByTestId } from "@testing-library/react";
import { import {
COLOR_PALETTE, COLOR_PALETTE,
DEFAULT_ELEMENT_BACKGROUND_PICKS, DEFAULT_ELEMENT_BACKGROUND_PICKS,
FREEDRAW_STROKE_WIDTH,
FONT_FAMILY, FONT_FAMILY,
STROKE_WIDTH, STROKE_WIDTH,
} from "@excalidraw/common"; } from "@excalidraw/common";
@@ -129,62 +128,6 @@ describe("element locking", () => {
expect(thinStrokeWidthButton).toBeChecked(); expect(thinStrokeWidthButton).toBeChecked();
}); });
it("should highlight common stroke width key across freedraw and non-freedraw elements", () => {
const rect = API.createElement({
type: "rectangle",
strokeWidth: STROKE_WIDTH.medium,
});
const freedraw = API.createElement({
type: "freedraw",
strokeWidth: FREEDRAW_STROKE_WIDTH.medium,
});
API.setElements([rect, freedraw]);
API.setSelectedElements([rect, freedraw]);
expect(queryByTestId(document.body, `strokeWidth-medium`)).toBeChecked();
});
it("should apply stroke width by element type", () => {
const rect = API.createElement({
type: "rectangle",
strokeWidth: STROKE_WIDTH.thin,
});
const freedraw = API.createElement({
type: "freedraw",
strokeWidth: FREEDRAW_STROKE_WIDTH.thin,
});
API.setElements([rect, freedraw]);
API.setSelectedElements([rect, freedraw]);
const boldStrokeWidthButton = queryByTestId(
document.body,
`strokeWidth-bold`,
);
expect(boldStrokeWidthButton).not.toBe(null);
fireEvent.click(boldStrokeWidthButton!);
const selectedElements = API.getSelectedElements();
const selectedRect = selectedElements.find(
(element) => element.type === "rectangle",
);
const selectedFreedraw = selectedElements.find(
(element) => element.type === "freedraw",
);
expect(selectedRect?.strokeWidth).toBe(STROKE_WIDTH.bold);
expect(selectedFreedraw?.strokeWidth).toBe(FREEDRAW_STROKE_WIDTH.bold);
});
it("should create new elements with stroke width by element type", () => {
API.setAppState({ currentItemStrokeWidthKey: "bold" });
const rect = API.createElement({ type: "rectangle" });
const freedraw = API.createElement({ type: "freedraw" });
expect(rect.strokeWidth).toBe(STROKE_WIDTH.bold);
expect(freedraw.strokeWidth).toBe(FREEDRAW_STROKE_WIDTH.bold);
});
it("should not highlight any stroke width button if no common style", () => { it("should not highlight any stroke width button if no common style", () => {
const rect1 = API.createElement({ const rect1 = API.createElement({
type: "rectangle", type: "rectangle",
@@ -192,7 +135,7 @@ describe("element locking", () => {
}); });
const rect2 = API.createElement({ const rect2 = API.createElement({
type: "rectangle", type: "rectangle",
strokeWidth: STROKE_WIDTH.medium, strokeWidth: STROKE_WIDTH.bold,
}); });
API.setElements([rect1, rect2]); API.setElements([rect1, rect2]);
API.setSelectedElements([rect1, rect2]); API.setSelectedElements([rect1, rect2]);
@@ -202,17 +145,17 @@ describe("element locking", () => {
queryByTestId(document.body, `strokeWidth-thin`), queryByTestId(document.body, `strokeWidth-thin`),
).not.toBeChecked(); ).not.toBeChecked();
expect( expect(
queryByTestId(document.body, `strokeWidth-medium`), queryByTestId(document.body, `strokeWidth-bold`),
).not.toBeChecked(); ).not.toBeChecked();
expect( expect(
queryByTestId(document.body, `strokeWidth-bold`), queryByTestId(document.body, `strokeWidth-extraBold`),
).not.toBeChecked(); ).not.toBeChecked();
}); });
it("should show properties of different element types when selected", () => { it("should show properties of different element types when selected", () => {
const rect = API.createElement({ const rect = API.createElement({
type: "rectangle", type: "rectangle",
strokeWidth: STROKE_WIDTH.medium, strokeWidth: STROKE_WIDTH.bold,
}); });
const text = API.createElement({ const text = API.createElement({
type: "text", type: "text",
@@ -221,7 +164,7 @@ describe("element locking", () => {
API.setElements([rect, text]); API.setElements([rect, text]);
API.setSelectedElements([rect, text]); API.setSelectedElements([rect, text]);
expect(queryByTestId(document.body, `strokeWidth-medium`)).toBeChecked(); expect(queryByTestId(document.body, `strokeWidth-bold`)).toBeChecked();
expect(queryByTestId(document.body, `font-family-code`)).toHaveClass( expect(queryByTestId(document.body, `font-family-code`)).toHaveClass(
"active", "active",
); );
+109 -285
View File
@@ -12,7 +12,7 @@ import {
DEFAULT_FONT_SIZE, DEFAULT_FONT_SIZE,
FONT_FAMILY, FONT_FAMILY,
ROUNDNESS, ROUNDNESS,
STROKE_WIDTH_KEYS, STROKE_WIDTH,
VERTICAL_ALIGN, VERTICAL_ALIGN,
KEYS, KEYS,
randomInteger, randomInteger,
@@ -20,11 +20,9 @@ import {
getFontFamilyString, getFontFamilyString,
getLineHeight, getLineHeight,
isTransparent, isTransparent,
getStrokeWidthByKey,
reduceToCommonValue, reduceToCommonValue,
invariant, invariant,
FONT_SIZES, FONT_SIZES,
type StrokeWidthKey,
} from "@excalidraw/common"; } from "@excalidraw/common";
import { canBecomePolygon, getNonDeletedElements } from "@excalidraw/element"; import { canBecomePolygon, getNonDeletedElements } from "@excalidraw/element";
@@ -38,7 +36,6 @@ import {
import { LinearElementEditor } from "@excalidraw/element"; import { LinearElementEditor } from "@excalidraw/element";
import { newElementWith } from "@excalidraw/element"; import { newElementWith } from "@excalidraw/element";
import { getArrowheadForPicker } from "@excalidraw/element";
import { import {
getBoundTextElement, getBoundTextElement,
@@ -72,11 +69,9 @@ import type {
ElementsMap, ElementsMap,
ExcalidrawBindableElement, ExcalidrawBindableElement,
ExcalidrawElement, ExcalidrawElement,
ExcalidrawFreeDrawElement,
ExcalidrawLinearElement, ExcalidrawLinearElement,
ExcalidrawTextElement, ExcalidrawTextElement,
FontFamilyValues, FontFamilyValues,
StrokeVariability,
TextAlign, TextAlign,
VerticalAlign, VerticalAlign,
} from "@excalidraw/element/types"; } from "@excalidraw/element/types";
@@ -87,7 +82,6 @@ import type { CaptureUpdateActionType } from "@excalidraw/element";
import { trackEvent } from "../analytics"; import { trackEvent } from "../analytics";
import { RadioSelection } from "../components/RadioSelection"; import { RadioSelection } from "../components/RadioSelection";
import { ToolButton } from "../components/ToolButton";
import { ColorPicker } from "../components/ColorPicker/ColorPicker"; import { ColorPicker } from "../components/ColorPicker/ColorPicker";
import { FontPicker } from "../components/FontPicker/FontPicker"; import { FontPicker } from "../components/FontPicker/FontPicker";
import { IconPicker } from "../components/IconPicker"; import { IconPicker } from "../components/IconPicker";
@@ -130,14 +124,9 @@ import {
sharpArrowIcon, sharpArrowIcon,
roundArrowIcon, roundArrowIcon,
elbowArrowIcon, elbowArrowIcon,
ArrowheadCardinalityExactlyOneIcon, ArrowheadCrowfootIcon,
ArrowheadCardinalityManyIcon, ArrowheadCrowfootOneIcon,
ArrowheadCardinalityOneIcon, ArrowheadCrowfootOneOrManyIcon,
ArrowheadCardinalityOneOrManyIcon,
ArrowheadCardinalityZeroOrManyIcon,
ArrowheadCardinalityZeroOrOneIcon,
strokeVariabilityConstantIcon,
strokeVariabilityVariableIcon,
} from "../components/icons"; } from "../components/icons";
import { Fonts } from "../fonts"; import { Fonts } from "../fonts";
@@ -197,12 +186,8 @@ export const changeProperty = (
export const getFormValue = function <T extends Primitive>( export const getFormValue = function <T extends Primitive>(
elements: readonly ExcalidrawElement[], elements: readonly ExcalidrawElement[],
app: AppClassProperties, app: AppClassProperties,
/** getAttribute: (element: ExcalidrawElement) => T,
* input value (usually the element attribute value, isRelevantElement: true | ((element: ExcalidrawElement) => boolean),
* but depends on what the action's PanelComponent input expects)
*/
getValue: (element: ExcalidrawElement) => T,
elementPredicate: true | ((element: ExcalidrawElement) => boolean),
defaultValue: T | ((isSomeElementSelected: boolean) => T), defaultValue: T | ((isSomeElementSelected: boolean) => T),
): T { ): T {
const editingTextElement = app.state.editingTextElement; const editingTextElement = app.state.editingTextElement;
@@ -211,7 +196,7 @@ export const getFormValue = function <T extends Primitive>(
let ret: T | null = null; let ret: T | null = null;
if (editingTextElement) { if (editingTextElement) {
ret = getValue(editingTextElement); ret = getAttribute(editingTextElement);
} }
if (!ret) { if (!ret) {
@@ -220,12 +205,12 @@ export const getFormValue = function <T extends Primitive>(
if (hasSelection) { if (hasSelection) {
const selectedElements = app.scene.getSelectedElements(app.state); const selectedElements = app.scene.getSelectedElements(app.state);
const targetElements = const targetElements =
elementPredicate === true isRelevantElement === true
? selectedElements ? selectedElements
: selectedElements.filter((el) => elementPredicate(el)); : selectedElements.filter((el) => isRelevantElement(el));
ret = ret =
reduceToCommonValue(targetElements, getValue) ?? reduceToCommonValue(targetElements, getAttribute) ??
(typeof defaultValue === "function" (typeof defaultValue === "function"
? defaultValue(true) ? defaultValue(true)
: defaultValue); : defaultValue);
@@ -555,37 +540,20 @@ export const actionChangeFillStyle = register<ExcalidrawElement["fillStyle"]>({
}, },
}); });
const getStrokeWidthKeyForElement = ( export const actionChangeStrokeWidth = register<
element: ExcalidrawElement, ExcalidrawElement["strokeWidth"]
): StrokeWidthKey | null => { >({
return (
STROKE_WIDTH_KEYS.find(
(key) => getStrokeWidthByKey(element.type, key) === element.strokeWidth,
) ?? null
);
};
const getStrokeWidthForElement = (
element: ExcalidrawElement,
strokeWidthKey: StrokeWidthKey,
): ExcalidrawElement["strokeWidth"] => {
return getStrokeWidthByKey(element.type, strokeWidthKey);
};
export const actionChangeStrokeWidth = register<StrokeWidthKey>({
name: "changeStrokeWidth", name: "changeStrokeWidth",
label: "labels.strokeWidth", label: "labels.strokeWidth",
trackEvent: false, trackEvent: false,
perform: (elements, appState, value) => { perform: (elements, appState, value) => {
invariant(value, "actionChangeStrokeWidth: value must be defined");
return { return {
elements: changeProperty(elements, appState, (el) => elements: changeProperty(elements, appState, (el) =>
newElementWith(el, { newElementWith(el, {
strokeWidth: getStrokeWidthForElement(el, value), strokeWidth: value,
}), }),
), ),
appState: { ...appState, currentItemStrokeWidthKey: value }, appState: { ...appState, currentItemStrokeWidth: value },
captureUpdate: CaptureUpdateAction.IMMEDIATELY, captureUpdate: CaptureUpdateAction.IMMEDIATELY,
}; };
}, },
@@ -593,35 +561,35 @@ export const actionChangeStrokeWidth = register<StrokeWidthKey>({
<fieldset> <fieldset>
<legend>{t("labels.strokeWidth")}</legend> <legend>{t("labels.strokeWidth")}</legend>
<div className="buttonList"> <div className="buttonList">
<RadioSelection<StrokeWidthKey> <RadioSelection
group="stroke-width" group="stroke-width"
options={[ options={[
{ {
value: "thin", value: STROKE_WIDTH.thin,
text: t("labels.thin"), text: t("labels.thin"),
icon: StrokeWidthBaseIcon, icon: StrokeWidthBaseIcon,
testId: "strokeWidth-thin", testId: "strokeWidth-thin",
}, },
{ {
value: "medium", value: STROKE_WIDTH.bold,
text: t("labels.medium"), text: t("labels.bold"),
icon: StrokeWidthBoldIcon, icon: StrokeWidthBoldIcon,
testId: "strokeWidth-medium", testId: "strokeWidth-bold",
}, },
{ {
value: "bold", value: STROKE_WIDTH.extraBold,
text: t("labels.bold"), text: t("labels.extraBold"),
icon: StrokeWidthExtraBoldIcon, icon: StrokeWidthExtraBoldIcon,
testId: "strokeWidth-bold", testId: "strokeWidth-extraBold",
}, },
]} ]}
value={getFormValue( value={getFormValue(
elements, elements,
app, app,
getStrokeWidthKeyForElement, (element) => element.strokeWidth,
(element) => element.hasOwnProperty("strokeWidth"), (element) => element.hasOwnProperty("strokeWidth"),
(hasSelection) => (hasSelection) =>
hasSelection ? null : appState.currentItemStrokeWidthKey, hasSelection ? null : appState.currentItemStrokeWidth,
)} )}
onChange={(value) => updateData(value)} onChange={(value) => updateData(value)}
/> />
@@ -684,87 +652,6 @@ export const actionChangeSloppiness = register<ExcalidrawElement["roughness"]>({
), ),
}); });
export const actionChangeFreedrawMode = register<StrokeVariability>({
name: "changeFreedrawMode",
label: "labels.pressure",
trackEvent: false,
perform: (elements, appState, value) => {
const variability = value || "constant";
return {
elements: changeProperty(elements, appState, (el) => {
if (el.type !== "freedraw") {
return el;
}
return newElementWith(el, {
strokeOptions: {
...el.strokeOptions,
variability,
},
}) as ExcalidrawElement;
}),
appState: { ...appState, currentItemStrokeVariability: variability },
captureUpdate: CaptureUpdateAction.IMMEDIATELY,
};
},
PanelComponent: ({ elements, appState, updateData, app, data }) => {
const strokeVariability =
getFormValue(
elements,
app,
(element) =>
(element as ExcalidrawFreeDrawElement).strokeOptions?.variability,
(element) => element.type === "freedraw",
(hasSelection) =>
hasSelection ? null : appState.currentItemStrokeVariability,
) ?? appState.currentItemStrokeVariability;
// in the compact UI the pressure setting is rendered as a single button
// that cycles between the two variability modes on click
if (data?.cycle) {
const isVariable = strokeVariability === "variable";
return (
<ToolButton
type="button"
icon={
isVariable
? strokeVariabilityVariableIcon
: strokeVariabilityConstantIcon
}
title={t("labels.pressure")}
aria-label={t("labels.pressure")}
onClick={() => updateData(isVariable ? "constant" : "variable")}
/>
);
}
return (
<fieldset>
<legend>{t("labels.pressure")}</legend>
<div className="buttonList">
<RadioSelection<StrokeVariability>
group="strokeOptions.variability"
options={[
{
value: "constant",
text: t("labels.pressure_constant"),
icon: strokeVariabilityConstantIcon,
},
{
value: "variable",
text: t("labels.pressure_variable"),
icon: strokeVariabilityVariableIcon,
},
]}
value={strokeVariability}
onChange={(value) => updateData(value)}
/>
</div>
</fieldset>
);
},
});
export const actionChangeStrokeStyle = register< export const actionChangeStrokeStyle = register<
ExcalidrawElement["strokeStyle"] ExcalidrawElement["strokeStyle"]
>({ >({
@@ -839,28 +726,9 @@ export const actionChangeOpacity = register<ExcalidrawElement["opacity"]>({
captureUpdate: CaptureUpdateAction.IMMEDIATELY, captureUpdate: CaptureUpdateAction.IMMEDIATELY,
}; };
}, },
PanelComponent: ({ elements, appState, app, updateData }) => { PanelComponent: ({ app, updateData }) => (
const opacity = getFormValue( <Range updateData={updateData} app={app} testId="opacity" />
elements, ),
app,
(element) => element.opacity,
true,
(hasSelection) => (hasSelection ? null : appState.currentItemOpacity),
);
return (
<Range
label={t("labels.opacity")}
value={opacity ?? appState.currentItemOpacity}
hasCommonValue={opacity !== null}
onChange={updateData}
min={0}
max={100}
step={10}
testId="opacity"
/>
);
},
}); });
export const actionChangeFontSize = register<ExcalidrawTextElement["fontSize"]>( export const actionChangeFontSize = register<ExcalidrawTextElement["fontSize"]>(
@@ -1682,117 +1550,80 @@ export const actionChangeRoundness = register<"sharp" | "round">({
}); });
const getArrowheadOptions = (flip: boolean) => { const getArrowheadOptions = (flip: boolean) => {
return { return [
visibleSections: [ {
{ value: null,
name: "default", text: t("labels.arrowhead_none"),
options: [ keyBinding: "q",
{ icon: <ArrowheadNoneIcon flip={flip} />,
value: null, },
text: t("labels.arrowhead_none"), {
keyBinding: "q", value: "arrow",
icon: <ArrowheadNoneIcon flip={flip} />, text: t("labels.arrowhead_arrow"),
}, keyBinding: "w",
{ icon: <ArrowheadArrowIcon flip={flip} />,
value: "arrow", },
text: t("labels.arrowhead_arrow"), {
keyBinding: "w", value: "triangle",
icon: <ArrowheadArrowIcon flip={flip} />, text: t("labels.arrowhead_triangle"),
}, icon: <ArrowheadTriangleIcon flip={flip} />,
{ keyBinding: "e",
value: "triangle", },
text: t("labels.arrowhead_triangle"), {
icon: <ArrowheadTriangleIcon flip={flip} />, value: "triangle_outline",
keyBinding: "e", text: t("labels.arrowhead_triangle_outline"),
}, icon: <ArrowheadTriangleOutlineIcon flip={flip} />,
{ keyBinding: "r",
value: "triangle_outline", },
text: t("labels.arrowhead_triangle_outline"), {
icon: <ArrowheadTriangleOutlineIcon flip={flip} />, value: "circle",
keyBinding: "r", text: t("labels.arrowhead_circle"),
}, keyBinding: "a",
], icon: <ArrowheadCircleIcon flip={flip} />,
}, },
], {
hiddenSections: [ value: "circle_outline",
{ text: t("labels.arrowhead_circle_outline"),
name: "default", keyBinding: "s",
options: [ icon: <ArrowheadCircleOutlineIcon flip={flip} />,
{ },
value: "circle", {
text: t("labels.arrowhead_circle"), value: "diamond",
keyBinding: "a", text: t("labels.arrowhead_diamond"),
icon: <ArrowheadCircleIcon flip={flip} />, icon: <ArrowheadDiamondIcon flip={flip} />,
}, keyBinding: "d",
{ },
value: "circle_outline", {
text: t("labels.arrowhead_circle_outline"), value: "diamond_outline",
keyBinding: "s", text: t("labels.arrowhead_diamond_outline"),
icon: <ArrowheadCircleOutlineIcon flip={flip} />, icon: <ArrowheadDiamondOutlineIcon flip={flip} />,
}, keyBinding: "f",
{ },
value: "diamond", {
text: t("labels.arrowhead_diamond"), value: "bar",
icon: <ArrowheadDiamondIcon flip={flip} />, text: t("labels.arrowhead_bar"),
keyBinding: "d", keyBinding: "z",
}, icon: <ArrowheadBarIcon flip={flip} />,
{ },
value: "diamond_outline", {
text: t("labels.arrowhead_diamond_outline"), value: "crowfoot_one",
icon: <ArrowheadDiamondOutlineIcon flip={flip} />, text: t("labels.arrowhead_crowfoot_one"),
keyBinding: "f", icon: <ArrowheadCrowfootOneIcon flip={flip} />,
}, keyBinding: "x",
{ },
value: "bar", {
text: t("labels.arrowhead_bar"), value: "crowfoot_many",
keyBinding: "z", text: t("labels.arrowhead_crowfoot_many"),
icon: <ArrowheadBarIcon flip={flip} />, icon: <ArrowheadCrowfootIcon flip={flip} />,
}, keyBinding: "c",
], },
}, {
{ value: "crowfoot_one_or_many",
name: t("labels.cardinality"), text: t("labels.arrowhead_crowfoot_one_or_many"),
options: [ icon: <ArrowheadCrowfootOneOrManyIcon flip={flip} />,
{ keyBinding: "v",
value: "cardinality_one", },
text: t("labels.arrowhead_cardinality_one"), ] as const;
icon: <ArrowheadCardinalityOneIcon flip={flip} />,
keyBinding: "x",
},
{
value: "cardinality_many",
text: t("labels.arrowhead_cardinality_many"),
icon: <ArrowheadCardinalityManyIcon flip={flip} />,
keyBinding: "c",
},
{
value: "cardinality_one_or_many",
text: t("labels.arrowhead_cardinality_one_or_many"),
icon: <ArrowheadCardinalityOneOrManyIcon flip={flip} />,
keyBinding: "v",
},
{
value: "cardinality_exactly_one",
text: t("labels.arrowhead_cardinality_exactly_one"),
icon: <ArrowheadCardinalityExactlyOneIcon flip={flip} />,
keyBinding: null,
},
{
value: "cardinality_zero_or_one",
text: t("labels.arrowhead_cardinality_zero_or_one"),
icon: <ArrowheadCardinalityZeroOrOneIcon flip={flip} />,
keyBinding: null,
},
{
value: "cardinality_zero_or_many",
text: t("labels.arrowhead_cardinality_zero_or_many"),
icon: <ArrowheadCardinalityZeroOrManyIcon flip={flip} />,
keyBinding: null,
},
],
},
],
} as const;
}; };
export const actionChangeArrowhead = register<{ export const actionChangeArrowhead = register<{
@@ -1836,52 +1667,45 @@ export const actionChangeArrowhead = register<{
}, },
PanelComponent: ({ elements, appState, updateData, app }) => { PanelComponent: ({ elements, appState, updateData, app }) => {
const isRTL = getLanguage().rtl; const isRTL = getLanguage().rtl;
const startArrowheadOptions = useMemo(
() => getArrowheadOptions(!isRTL),
[isRTL],
);
const endArrowheadOptions = useMemo(
() => getArrowheadOptions(!!isRTL),
[isRTL],
);
return ( return (
<fieldset> <fieldset>
<legend>{t("labels.arrowheads")}</legend> <legend>{t("labels.arrowheads")}</legend>
<div className="iconSelectList buttonList"> <div className="iconSelectList buttonList">
<IconPicker <IconPicker
visibleSections={startArrowheadOptions.visibleSections}
hiddenSections={startArrowheadOptions.hiddenSections}
label="arrowhead_start" label="arrowhead_start"
options={getArrowheadOptions(!isRTL)}
value={getFormValue<Arrowhead | null>( value={getFormValue<Arrowhead | null>(
elements, elements,
app, app,
(element) => (element) =>
isLinearElement(element) && canHaveArrowheads(element.type) isLinearElement(element) && canHaveArrowheads(element.type)
? getArrowheadForPicker(element.startArrowhead) ? element.startArrowhead
: appState.currentItemStartArrowhead, : appState.currentItemStartArrowhead,
true, true,
(hasSelection) => (hasSelection) =>
hasSelection ? null : appState.currentItemStartArrowhead, hasSelection ? null : appState.currentItemStartArrowhead,
)} )}
onChange={(value) => updateData({ position: "start", type: value })} onChange={(value) => updateData({ position: "start", type: value })}
numberOfOptionsToAlwaysShow={4}
/> />
<IconPicker <IconPicker
visibleSections={endArrowheadOptions.visibleSections}
hiddenSections={endArrowheadOptions.hiddenSections}
label="arrowhead_end" label="arrowhead_end"
group="arrowheads"
options={getArrowheadOptions(!!isRTL)}
value={getFormValue<Arrowhead | null>( value={getFormValue<Arrowhead | null>(
elements, elements,
app, app,
(element) => (element) =>
isLinearElement(element) && canHaveArrowheads(element.type) isLinearElement(element) && canHaveArrowheads(element.type)
? getArrowheadForPicker(element.endArrowhead) ? element.endArrowhead
: appState.currentItemEndArrowhead, : appState.currentItemEndArrowhead,
true, true,
(hasSelection) => (hasSelection) =>
hasSelection ? null : appState.currentItemEndArrowhead, hasSelection ? null : appState.currentItemEndArrowhead,
)} )}
onChange={(value) => updateData({ position: "end", type: value })} onChange={(value) => updateData({ position: "end", type: value })}
numberOfOptionsToAlwaysShow={4}
/> />
</div> </div>
</fieldset> </fieldset>
@@ -1,24 +1,24 @@
import { getFontString } from "@excalidraw/common"; import { getFontString } from "@excalidraw/common";
import { isExcalidrawElement, newElementWith } from "@excalidraw/element"; import { newElementWith } from "@excalidraw/element";
import { measureText } from "@excalidraw/element"; import { measureText } from "@excalidraw/element";
import { isTextElement } from "@excalidraw/element"; import { isTextElement } from "@excalidraw/element";
import { CaptureUpdateAction } from "@excalidraw/element"; import { CaptureUpdateAction } from "@excalidraw/element";
import type { ExcalidrawElement } from "@excalidraw/element/types";
import { getSelectedElements } from "../scene"; import { getSelectedElements } from "../scene";
import { register } from "./register"; import { register } from "./register";
import type { AppClassProperties } from "../types";
export const actionTextAutoResize = register({ export const actionTextAutoResize = register({
name: "autoResize", name: "autoResize",
label: "labels.autoResize", label: "labels.autoResize",
icon: null, icon: null,
trackEvent: { category: "element" }, trackEvent: { category: "element" },
predicate: (elements, appState, _: unknown) => { predicate: (elements, appState, _: unknown, app: AppClassProperties) => {
const selectedElements = getSelectedElements(elements, appState); const selectedElements = getSelectedElements(elements, appState);
return ( return (
selectedElements.length === 1 && selectedElements.length === 1 &&
@@ -26,18 +26,13 @@ export const actionTextAutoResize = register({
!selectedElements[0].autoResize !selectedElements[0].autoResize
); );
}, },
perform: (elements, appState, targetElement) => { perform: (elements, appState, _, app) => {
const selectedElements = getSelectedElements(elements, appState); const selectedElements = getSelectedElements(elements, appState);
const targetTextElement =
isExcalidrawElement(targetElement) && isTextElement(targetElement)
? targetElement
: (selectedElements[0] as ExcalidrawElement | undefined);
return { return {
appState, appState,
elements: elements.map((element) => { elements: elements.map((element) => {
if (element.id === targetTextElement?.id && isTextElement(element)) { if (element.id === selectedElements[0].id && isTextElement(element)) {
const metrics = measureText( const metrics = measureText(
element.originalText, element.originalText,
getFontString(element), getFontString(element),
-2
View File
@@ -13,7 +13,6 @@ export {
actionChangeStrokeWidth, actionChangeStrokeWidth,
actionChangeFillStyle, actionChangeFillStyle,
actionChangeSloppiness, actionChangeSloppiness,
actionChangeFreedrawMode,
actionChangeOpacity, actionChangeOpacity,
actionChangeFontSize, actionChangeFontSize,
actionChangeFontFamily, actionChangeFontFamily,
@@ -35,7 +34,6 @@ export {
export { actionSetEmbeddableAsActiveTool } from "./actionEmbeddable"; export { actionSetEmbeddableAsActiveTool } from "./actionEmbeddable";
export { actionFinalize } from "./actionFinalize"; export { actionFinalize } from "./actionFinalize";
export { actionDeselect } from "./actionDeselect";
export { export {
actionChangeProjectName, actionChangeProjectName,
-2
View File
@@ -68,7 +68,6 @@ export type ActionName =
| "changeStrokeWidth" | "changeStrokeWidth"
| "changeStrokeShape" | "changeStrokeShape"
| "changeSloppiness" | "changeSloppiness"
| "changeFreedrawMode"
| "changeStrokeStyle" | "changeStrokeStyle"
| "changeArrowhead" | "changeArrowhead"
| "changeArrowType" | "changeArrowType"
@@ -115,7 +114,6 @@ export type ActionName =
| "distributeVertically" | "distributeVertically"
| "flipHorizontal" | "flipHorizontal"
| "flipVertical" | "flipVertical"
| "deselect"
| "viewMode" | "viewMode"
| "exportWithDarkMode" | "exportWithDarkMode"
| "toggleTheme" | "toggleTheme"
@@ -1,4 +1,5 @@
import { LaserPointer } from "@excalidraw/laser-pointer"; import { LaserPointer } from "@excalidraw/laser-pointer";
import { import {
SVG_NS, SVG_NS,
getSvgPathFromStroke, getSvgPathFromStroke,
@@ -7,8 +8,7 @@ import {
import type { LaserPointerOptions } from "@excalidraw/laser-pointer"; import type { LaserPointerOptions } from "@excalidraw/laser-pointer";
import { AnimationController } from "./renderer/animation"; import type { AnimationFrameHandler } from "./animation-frame-handler";
import type App from "./components/App"; import type App from "./components/App";
import type { AppState } from "./types"; import type { AppState } from "./types";
@@ -34,16 +34,15 @@ export class AnimatedTrail implements Trail {
private container?: SVGSVGElement; private container?: SVGSVGElement;
private trailElement: SVGPathElement; private trailElement: SVGPathElement;
private trailAnimation?: SVGAnimateElement; private trailAnimation?: SVGAnimateElement;
private key: string;
private static counter = 0;
constructor( constructor(
private animationFrameHandler: AnimationFrameHandler,
protected app: App, protected app: App,
private options: Partial<LaserPointerOptions> & private options: Partial<LaserPointerOptions> &
Partial<AnimatedTrailOptions>, Partial<AnimatedTrailOptions>,
) { ) {
this.key = `animated-trail-${AnimatedTrail.counter++}`; this.animationFrameHandler.register(this, this.onFrame.bind(this));
this.trailElement = document.createElementNS(SVG_NS, "path"); this.trailElement = document.createElementNS(SVG_NS, "path");
if (this.options.animateTrail) { if (this.options.animateTrail) {
this.trailAnimation = document.createElementNS(SVG_NS, "animate"); this.trailAnimation = document.createElementNS(SVG_NS, "animate");
@@ -74,15 +73,6 @@ export class AnimatedTrail implements Trail {
return false; return false;
} }
private cleanup() {
this.pastTrails = [];
this.currentTrail = undefined;
if (this.trailElement.parentNode === this.container) {
this.container?.removeChild(this.trailElement);
}
}
start(container?: SVGSVGElement) { start(container?: SVGSVGElement) {
if (container) { if (container) {
this.container = container; this.container = container;
@@ -92,23 +82,15 @@ export class AnimatedTrail implements Trail {
this.container.appendChild(this.trailElement); this.container.appendChild(this.trailElement);
} }
if (!AnimationController.running(this.key)) { this.animationFrameHandler.start(this);
AnimationController.start(this.key, () => {
const needsNext = this.onFrame();
if (needsNext) {
return { keep: true };
}
this.cleanup();
return null;
});
}
} }
stop() { stop() {
AnimationController.cancel(this.key); this.animationFrameHandler.stop(this);
this.cleanup();
if (this.trailElement.parentNode === this.container) {
this.container?.removeChild(this.trailElement);
}
} }
startPath(x: number, y: number) { startPath(x: number, y: number) {
@@ -163,25 +145,21 @@ export class AnimatedTrail implements Trail {
if (this.currentTrail) { if (this.currentTrail) {
const currentPath = this.drawTrail(this.currentTrail, this.app.state); const currentPath = this.drawTrail(this.currentTrail, this.app.state);
paths.push(currentPath); paths.push(currentPath);
} }
this.pastTrails = this.pastTrails.filter( this.pastTrails = this.pastTrails.filter((trail) => {
(t) => return trail.getStrokeOutline().length !== 0;
t.getStrokeOutline(t.options.size / this.app.state.zoom.value) });
.length !== 0,
);
if (paths.length === 0) { if (paths.length === 0) {
// Clean up the SVG path if there are no trails to render this.stop();
this.trailElement.setAttribute("d", "");
return false;
} }
const svgPaths = paths.join(" ").trim(); const svgPaths = paths.join(" ").trim();
this.trailElement.setAttribute("d", svgPaths);
this.trailElement.setAttribute("d", svgPaths);
if (this.trailAnimation) { if (this.trailAnimation) {
this.trailElement.setAttribute( this.trailElement.setAttribute(
"fill", "fill",
@@ -197,8 +175,6 @@ export class AnimatedTrail implements Trail {
(this.options.fill ?? (() => "black"))(this), (this.options.fill ?? (() => "black"))(this),
); );
} }
return true;
} }
private drawTrail(trail: LaserPointer, state: AppState): string { private drawTrail(trail: LaserPointer, state: AppState): string {
@@ -0,0 +1,79 @@
export type AnimationCallback = (timestamp: number) => void | boolean;
export type AnimationTarget = {
callback: AnimationCallback;
stopped: boolean;
};
export class AnimationFrameHandler {
private targets = new WeakMap<object, AnimationTarget>();
private rafIds = new WeakMap<object, number>();
register(key: object, callback: AnimationCallback) {
this.targets.set(key, { callback, stopped: true });
}
start(key: object) {
const target = this.targets.get(key);
if (!target) {
return;
}
if (this.rafIds.has(key)) {
return;
}
this.targets.set(key, { ...target, stopped: false });
this.scheduleFrame(key);
}
stop(key: object) {
const target = this.targets.get(key);
if (target && !target.stopped) {
this.targets.set(key, { ...target, stopped: true });
}
this.cancelFrame(key);
}
private constructFrame(key: object): FrameRequestCallback {
return (timestamp: number) => {
const target = this.targets.get(key);
if (!target) {
return;
}
const shouldAbort = this.onFrame(target, timestamp);
if (!target.stopped && !shouldAbort) {
this.scheduleFrame(key);
} else {
this.cancelFrame(key);
}
};
}
private scheduleFrame(key: object) {
const rafId = requestAnimationFrame(this.constructFrame(key));
this.rafIds.set(key, rafId);
}
private cancelFrame(key: object) {
if (this.rafIds.has(key)) {
const rafId = this.rafIds.get(key)!;
cancelAnimationFrame(rafId);
}
this.rafIds.delete(key);
}
private onFrame(target: AnimationTarget, timestamp: number): boolean {
const shouldAbort = target.callback(timestamp);
return shouldAbort ?? false;
}
}
+4 -11
View File
@@ -4,7 +4,6 @@ import {
DEFAULT_ELEMENT_PROPS, DEFAULT_ELEMENT_PROPS,
DEFAULT_FONT_FAMILY, DEFAULT_FONT_FAMILY,
DEFAULT_FONT_SIZE, DEFAULT_FONT_SIZE,
DEFAULT_ELEMENT_STROKE_WIDTH_KEY,
DEFAULT_TEXT_ALIGN, DEFAULT_TEXT_ALIGN,
DEFAULT_GRID_SIZE, DEFAULT_GRID_SIZE,
EXPORT_SCALES, EXPORT_SCALES,
@@ -35,13 +34,12 @@ export const getDefaultAppState = (): Omit<
currentItemFontSize: DEFAULT_FONT_SIZE, currentItemFontSize: DEFAULT_FONT_SIZE,
currentItemOpacity: DEFAULT_ELEMENT_PROPS.opacity, currentItemOpacity: DEFAULT_ELEMENT_PROPS.opacity,
currentItemRoughness: DEFAULT_ELEMENT_PROPS.roughness, currentItemRoughness: DEFAULT_ELEMENT_PROPS.roughness,
currentItemStrokeVariability: "constant",
currentItemStartArrowhead: null, currentItemStartArrowhead: null,
currentItemStrokeColor: DEFAULT_ELEMENT_PROPS.strokeColor, currentItemStrokeColor: DEFAULT_ELEMENT_PROPS.strokeColor,
currentItemRoundness: isTestEnv() ? "sharp" : "round", currentItemRoundness: isTestEnv() ? "sharp" : "round",
currentItemArrowType: ARROW_TYPE.round, currentItemArrowType: ARROW_TYPE.round,
currentItemStrokeStyle: DEFAULT_ELEMENT_PROPS.strokeStyle, currentItemStrokeStyle: DEFAULT_ELEMENT_PROPS.strokeStyle,
currentItemStrokeWidthKey: DEFAULT_ELEMENT_STROKE_WIDTH_KEY, currentItemStrokeWidth: DEFAULT_ELEMENT_PROPS.strokeWidth,
currentItemTextAlign: DEFAULT_TEXT_ALIGN, currentItemTextAlign: DEFAULT_TEXT_ALIGN,
currentHoveredFontFamily: null, currentHoveredFontFamily: null,
cursorButton: "up", cursorButton: "up",
@@ -101,6 +99,7 @@ export const getDefaultAppState = (): Omit<
open: false, open: false,
panels: STATS_PANELS.generalStats | STATS_PANELS.elementProperties, panels: STATS_PANELS.generalStats | STATS_PANELS.elementProperties,
}, },
startBoundElement: null,
suggestedBinding: null, suggestedBinding: null,
frameRendering: { enabled: true, clip: true, name: true, outline: true }, frameRendering: { enabled: true, clip: true, name: true, outline: true },
frameToHighlight: null, frameToHighlight: null,
@@ -129,7 +128,6 @@ export const getDefaultAppState = (): Omit<
lockedMultiSelections: {}, lockedMultiSelections: {},
activeLockedId: null, activeLockedId: null,
bindMode: "orbit", bindMode: "orbit",
boxSelectionMode: "contain",
}; };
}; };
@@ -169,15 +167,10 @@ const APP_STATE_STORAGE_CONF = (<
}, },
currentItemOpacity: { browser: true, export: false, server: false }, currentItemOpacity: { browser: true, export: false, server: false },
currentItemRoughness: { browser: true, export: false, server: false }, currentItemRoughness: { browser: true, export: false, server: false },
currentItemStrokeVariability: {
browser: true,
export: false,
server: false,
},
currentItemStartArrowhead: { browser: true, export: false, server: false }, currentItemStartArrowhead: { browser: true, export: false, server: false },
currentItemStrokeColor: { browser: true, export: false, server: false }, currentItemStrokeColor: { browser: true, export: false, server: false },
currentItemStrokeStyle: { browser: true, export: false, server: false }, currentItemStrokeStyle: { browser: true, export: false, server: false },
currentItemStrokeWidthKey: { browser: true, export: false, server: false }, currentItemStrokeWidth: { browser: true, export: false, server: false },
currentItemTextAlign: { browser: true, export: false, server: false }, currentItemTextAlign: { browser: true, export: false, server: false },
currentHoveredFontFamily: { browser: false, export: false, server: false }, currentHoveredFontFamily: { browser: false, export: false, server: false },
cursorButton: { browser: true, export: false, server: false }, cursorButton: { browser: true, export: false, server: false },
@@ -200,7 +193,6 @@ const APP_STATE_STORAGE_CONF = (<
gridModeEnabled: { browser: true, export: true, server: true }, gridModeEnabled: { browser: true, export: true, server: true },
height: { browser: false, export: false, server: false }, height: { browser: false, export: false, server: false },
isBindingEnabled: { browser: true, export: false, server: false }, isBindingEnabled: { browser: true, export: false, server: false },
boxSelectionMode: { browser: true, export: false, server: false },
bindingPreference: { browser: true, export: false, server: false }, bindingPreference: { browser: true, export: false, server: false },
isMidpointSnappingEnabled: { browser: true, export: false, server: false }, isMidpointSnappingEnabled: { browser: true, export: false, server: false },
defaultSidebarDockedPreference: { defaultSidebarDockedPreference: {
@@ -237,6 +229,7 @@ const APP_STATE_STORAGE_CONF = (<
selectionElement: { browser: false, export: false, server: false }, selectionElement: { browser: false, export: false, server: false },
shouldCacheIgnoreZoom: { browser: true, export: false, server: false }, shouldCacheIgnoreZoom: { browser: true, export: false, server: false },
stats: { browser: true, export: false, server: false }, stats: { browser: true, export: false, server: false },
startBoundElement: { browser: false, export: false, server: false },
suggestedBinding: { browser: false, export: false, server: false }, suggestedBinding: { browser: false, export: false, server: false },
frameRendering: { browser: false, export: false, server: false }, frameRendering: { browser: false, export: false, server: false },
frameToHighlight: { browser: false, export: false, server: false }, frameToHighlight: { browser: false, export: false, server: false },
+4 -2
View File
@@ -33,6 +33,8 @@ import {
normalizeFile, normalizeFile,
} from "./data/blob"; } from "./data/blob";
import type { FileSystemHandle } from "./data/filesystem";
import type { BinaryFiles } from "./types"; import type { BinaryFiles } from "./types";
type ElementsClipboard = { type ElementsClipboard = {
@@ -367,7 +369,7 @@ type AllowedParsedDataTransferItem =
type: ValueOf<typeof IMAGE_MIME_TYPES>; type: ValueOf<typeof IMAGE_MIME_TYPES>;
kind: "file"; kind: "file";
file: File; file: File;
fileHandle: FileSystemFileHandle | null; fileHandle: FileSystemHandle | null;
} }
| { type: ValueOf<typeof STRING_MIME_TYPES>; kind: "string"; value: string }; | { type: ValueOf<typeof STRING_MIME_TYPES>; kind: "string"; value: string };
@@ -376,7 +378,7 @@ type ParsedDataTransferItem =
type: string; type: string;
kind: "file"; kind: "file";
file: File; file: File;
fileHandle: FileSystemFileHandle | null; fileHandle: FileSystemHandle | null;
} }
| { type: string; kind: "string"; value: string }; | { type: string; kind: "string"; value: string };
+11 -46
View File
@@ -41,7 +41,6 @@ import {
canHaveArrowheads, canHaveArrowheads,
getTargetElements, getTargetElements,
hasBackground, hasBackground,
hasFreedrawMode,
hasStrokeStyle, hasStrokeStyle,
hasStrokeWidth, hasStrokeWidth,
} from "../scene"; } from "../scene";
@@ -202,9 +201,9 @@ export const SelectedShapeActions = ({
targetElements.some((element) => hasStrokeWidth(element.type))) && targetElements.some((element) => hasStrokeWidth(element.type))) &&
renderAction("changeStrokeWidth")} renderAction("changeStrokeWidth")}
{(hasFreedrawMode(appState.activeTool.type) || {(appState.activeTool.type === "freedraw" ||
targetElements.some((element) => hasFreedrawMode(element.type))) && targetElements.some((element) => element.type === "freedraw")) &&
renderAction("changeFreedrawMode")} renderAction("changeStrokeShape")}
{(hasStrokeStyle(appState.activeTool.type) || {(hasStrokeStyle(appState.activeTool.type) ||
targetElements.some((element) => hasStrokeStyle(element.type))) && ( targetElements.some((element) => hasStrokeStyle(element.type))) && (
@@ -395,17 +394,6 @@ const CombinedShapeProperties = ({
hasStrokeWidth(element.type), hasStrokeWidth(element.type),
)) && )) &&
renderAction("changeStrokeWidth")} renderAction("changeStrokeWidth")}
{
/* in compact UI the freedraw pressure setting is rendered as a
standalone cycle button in the compact actions list; we render
it in the combined properties popup as well for clarity
*/
(hasFreedrawMode(appState.activeTool.type) ||
targetElements.some((element) =>
hasFreedrawMode(element.type),
)) &&
renderAction("changeFreedrawMode")
}
{(hasStrokeStyle(appState.activeTool.type) || {(hasStrokeStyle(appState.activeTool.type) ||
targetElements.some((element) => targetElements.some((element) =>
hasStrokeStyle(element.type), hasStrokeStyle(element.type),
@@ -838,14 +826,6 @@ export const CompactShapeActions = ({
</div> </div>
)} )}
{/* Freedraw pressure: standalone button cycling the variability mode */}
{(hasFreedrawMode(appState.activeTool.type) ||
targetElements.some((element) => hasFreedrawMode(element.type))) && (
<div className="compact-action-item">
{renderAction("changeFreedrawMode", { cycle: true })}
</div>
)}
<CombinedShapeProperties <CombinedShapeProperties
appState={appState} appState={appState}
renderAction={renderAction} renderAction={renderAction}
@@ -1074,11 +1054,6 @@ export const ShapesSwitcher = ({
const isFullStylesPanel = stylesPanelMode === "full"; const isFullStylesPanel = stylesPanelMode === "full";
const isCompactStylesPanel = stylesPanelMode === "compact"; const isCompactStylesPanel = stylesPanelMode === "compact";
// a pen detected on a tool button's pointer-down, to be applied (enabling
// pen mode) only after the tap's `change` has committed — see the tool
// button handlers below
const pendingPenDetectionRef = useRef(false);
const SELECTION_TOOLS = [ const SELECTION_TOOLS = [
{ {
type: "selection", type: "selection",
@@ -1177,13 +1152,8 @@ export const ShapesSwitcher = ({
aria-keyshortcuts={shortcut} aria-keyshortcuts={shortcut}
data-testid={`toolbar-${value}`} data-testid={`toolbar-${value}`}
onPointerDown={({ pointerType }) => { onPointerDown={({ pointerType }) => {
// Detect the pen here (pointerType is reliable on pointer-down)
// but DON'T enable pen mode yet: calling setState mid-gesture
// re-renders the controlled radio and, on iOS/iPadOS, aborts
// the ensuing click so the tool isn't selected on the first pen
// tap. Defer it until the tap's `change` has committed (below).
if (!app.state.penDetected && pointerType === "pen") { if (!app.state.penDetected && pointerType === "pen") {
pendingPenDetectionRef.current = true; app.togglePenMode(true);
} }
if (value === "selection") { if (value === "selection") {
@@ -1194,21 +1164,16 @@ export const ShapesSwitcher = ({
} }
} }
}} }}
onChange={() => { onChange={({ pointerType }) => {
if (app.state.activeTool.type !== value) { if (app.state.activeTool.type !== value) {
trackEvent("toolbar", value, "ui"); trackEvent("toolbar", value, "ui");
} }
app.setActiveTool({ type: value }); if (value === "image") {
app.setActiveTool({
// Apply the pen detection captured on pointer-down now that the type: value,
// tool is selected. rAF keeps the resulting re-render out of the });
// `change` event itself. We rely on the pointer-down detection } else {
// rather than this handler's pointerType because the latter is app.setActiveTool({ type: value });
// unreliable on iOS (its backing ref is cleared before the
// delayed click fires).
if (pendingPenDetectionRef.current) {
pendingPenDetectionRef.current = false;
requestAnimationFrame(() => app.togglePenMode(true));
} }
}} }}
/> />
File diff suppressed because it is too large Load Diff
@@ -1,208 +0,0 @@
import type { AppState, UnsubscribeCallback } from "../types";
type StateChangeSelector =
| keyof AppState
| (keyof AppState)[]
| ((appState: AppState) => unknown);
type StateChangePredicateOptions = {
predicate: (appState: AppState) => boolean;
callback?: (appState: AppState) => void;
once?: boolean;
};
type StateChangeArg = StateChangeSelector | StateChangePredicateOptions;
type StateChangeListener = {
predicate: (appState: AppState, prevState: AppState) => boolean;
getValue: (appState: AppState) => unknown;
callback: (value: any, appState: AppState) => void;
once: boolean;
};
type NormalizedStateChange = {
predicate: StateChangeListener["predicate"];
getValue: StateChangeListener["getValue"];
callback?: StateChangeListener["callback"];
once: boolean;
matchesImmediately: boolean;
};
export type OnStateChange = {
<K extends keyof AppState>(
prop: K,
callback: (value: AppState[K], appState: AppState) => void,
opts?: { once: boolean },
): UnsubscribeCallback;
<K extends keyof AppState>(prop: K): Promise<AppState[K]>;
(
prop: (keyof AppState)[],
callback: (appState: AppState, appState2: AppState) => void,
opts?: { once: boolean },
): UnsubscribeCallback;
(prop: (keyof AppState)[]): Promise<AppState>;
<T>(
prop: (appState: AppState) => T,
callback: (value: T, appState: AppState) => void,
opts?: { once: boolean },
): UnsubscribeCallback;
<T>(prop: (appState: AppState) => T): Promise<T>;
(opts: {
predicate: (appState: AppState) => boolean;
callback: (appState: AppState) => void;
once?: boolean;
}): UnsubscribeCallback;
(opts: { predicate: (appState: AppState) => boolean }): Promise<AppState>;
(
selector: StateChangeSelector,
callback: (value: any, appState: AppState) => void,
): any;
};
export class AppStateObserver {
private listeners: StateChangeListener[] = [];
constructor(private readonly getState: () => AppState) {}
private isStateChangePredicateOptions(
propOrOpts: StateChangeArg,
): propOrOpts is StateChangePredicateOptions {
return (
typeof propOrOpts === "object" &&
!Array.isArray(propOrOpts) &&
"predicate" in propOrOpts
);
}
private subscribe(listener: StateChangeListener): UnsubscribeCallback {
this.listeners.push(listener);
return () => {
this.listeners = this.listeners.filter(
(existingListener) => existingListener !== listener,
);
};
}
private normalize(
propOrOpts: StateChangeArg,
callback?: (value: any, appState: AppState) => void,
opts?: { once: boolean },
): NormalizedStateChange {
let predicate: StateChangeListener["predicate"];
let getValue: StateChangeListener["getValue"];
let normalizedCallback = callback;
let once = opts?.once ?? false;
let matchesImmediately = false;
if (this.isStateChangePredicateOptions(propOrOpts)) {
const {
predicate: predicateFn,
callback: callbackFromOpts,
once: onceFromOpts,
} = propOrOpts;
predicate = predicateFn;
getValue = (appState: AppState) => appState;
normalizedCallback = callbackFromOpts
? (_value: AppState, appState: AppState) => callbackFromOpts(appState)
: undefined;
once = onceFromOpts ?? false;
matchesImmediately = predicateFn(this.getState());
} else if (typeof propOrOpts === "function") {
const selector = propOrOpts;
predicate = (appState: AppState, prevState: AppState) =>
selector(appState) !== selector(prevState);
getValue = (appState: AppState) => selector(appState);
} else if (Array.isArray(propOrOpts)) {
const keys = propOrOpts;
predicate = (appState: AppState, prevState: AppState) =>
keys.some((key) => appState[key] !== prevState[key]);
getValue = (appState: AppState) => appState;
} else {
const key = propOrOpts;
predicate = (appState: AppState, prevState: AppState) =>
appState[key] !== prevState[key];
getValue = (appState: AppState) => appState[key];
}
return {
predicate,
getValue,
callback: normalizedCallback,
once,
matchesImmediately,
};
}
public onStateChange: OnStateChange = ((
propOrOpts: StateChangeArg,
callback?: any,
opts?: { once: boolean },
) => {
const {
predicate,
getValue,
callback: stateChangeCallback,
once,
matchesImmediately,
} = this.normalize(propOrOpts, callback, opts);
if (stateChangeCallback) {
if (matchesImmediately) {
queueMicrotask(() => {
const state = this.getState();
stateChangeCallback(getValue(state), state);
});
if (once) {
return () => {};
}
}
return this.subscribe({
predicate,
getValue,
callback: stateChangeCallback,
once,
});
}
if (matchesImmediately) {
return Promise.resolve(getValue(this.getState()));
}
return new Promise<any>((resolve) => {
this.subscribe({
predicate,
getValue,
callback: (value) => resolve(value),
once: true,
});
});
}) as OnStateChange;
public flush(prevState: AppState) {
if (!this.listeners.length) {
return;
}
const state = this.getState();
const listenersToKeep: StateChangeListener[] = [];
for (const listener of this.listeners) {
if (listener.predicate(state, prevState)) {
listener.callback(listener.getValue(state), state);
if (!listener.once) {
listenersToKeep.push(listener);
}
} else {
listenersToKeep.push(listener);
}
}
this.listeners = listenersToKeep;
}
public clear() {
this.listeners = [];
}
}

Some files were not shown because too many files have changed in this diff Show More