Compare commits

...
Author SHA1 Message Date
Excalidraw Bot b2597dcbc7 New translations en.json (Portuguese, Brazilian)
[ci skip]
2026-10-17 17:15:02 +02:00
Excalidraw Bot 0b06e1dbe4 New translations en.json (Romanian)
[ci skip]
2026-10-13 10:47:14 +02:00
Excalidraw Bot d5f386fbbb New translations en.json (Karakalpak)
[ci skip]
2026-10-12 14:44:13 +02:00
Excalidraw Bot 1d76a8ec51 New translations en.json (Kabyle)
[ci skip]
2026-10-12 14:44:11 +02:00
Excalidraw Bot 43d3fb5a01 New translations en.json (Bengali, India)
[ci skip]
2026-10-12 14:44:08 +02:00
Excalidraw Bot 9b24067fd9 New translations en.json (German, Switzerland)
[ci skip]
2026-10-12 14:44:06 +02:00
Excalidraw Bot a57546de58 New translations en.json (Occitan)
[ci skip]
2026-10-12 14:44:04 +02:00
Excalidraw Bot 9bfa1e6da0 New translations en.json (Norwegian Bokmal)
[ci skip]
2026-10-12 14:44:02 +02:00
Excalidraw Bot 2386f4b452 New translations en.json (Uzbek)
[ci skip]
2026-10-12 14:43:59 +02:00
Excalidraw Bot 35efc376e6 New translations en.json (Sinhala)
[ci skip]
2026-10-12 14:43:56 +02:00
Excalidraw Bot d188d807fc New translations en.json (Chinese Traditional, Hong Kong)
[ci skip]
2026-10-12 14:43:53 +02:00
Excalidraw Bot 3470471691 New translations en.json (Burmese)
[ci skip]
2026-10-12 14:43:51 +02:00
Excalidraw Bot c5bba25ca8 New translations en.json (Hindi)
[ci skip]
2026-10-12 14:43:49 +02:00
Excalidraw Bot 25b1388903 New translations en.json (Azerbaijani)
[ci skip]
2026-10-12 14:43:47 +02:00
Excalidraw Bot ce39f28e5e New translations en.json (Latvian)
[ci skip]
2026-10-12 14:43:45 +02:00
Excalidraw Bot dc934ca6ed New translations en.json (Kazakh)
[ci skip]
2026-10-12 14:43:43 +02:00
Excalidraw Bot 7a54251867 New translations en.json (Norwegian Nynorsk)
[ci skip]
2026-10-12 14:43:41 +02:00
Excalidraw Bot 134abc24cc New translations en.json (Marathi)
[ci skip]
2026-10-12 14:43:38 +02:00
Excalidraw Bot 80162b3684 New translations en.json (Bengali)
[ci skip]
2026-10-12 14:43:35 +02:00
Excalidraw Bot b5e409a525 New translations en.json (Tamil)
[ci skip]
2026-10-12 14:43:32 +02:00
Excalidraw Bot f4cce93cef New translations en.json (Khmer)
[ci skip]
2026-10-12 14:43:30 +02:00
Excalidraw Bot 1bebe0ac22 New translations en.json (Persian)
[ci skip]
2026-10-12 14:43:27 +02:00
Excalidraw Bot 9fd17047ac New translations en.json (Portuguese, Brazilian)
[ci skip]
2026-10-12 14:43:24 +02:00
Excalidraw Bot d0f7c2d4b5 New translations en.json (Galician)
[ci skip]
2026-10-12 14:43:21 +02:00
Excalidraw Bot 42ebb444a7 New translations en.json (Vietnamese)
[ci skip]
2026-10-12 14:43:19 +02:00
Excalidraw Bot 740dfdf39c New translations en.json (Chinese Traditional)
[ci skip]
2026-10-12 14:43:16 +02:00
Excalidraw Bot e2b840dadb New translations en.json (Chinese Simplified)
[ci skip]
2026-10-12 14:43:14 +02:00
Excalidraw Bot 72c0480a6f New translations en.json (Ukrainian)
[ci skip]
2026-10-12 14:43:11 +02:00
Excalidraw Bot e011a14982 New translations en.json (Turkish)
[ci skip]
2026-10-12 14:43:08 +02:00
Excalidraw Bot 86a09a38e8 New translations en.json (Swedish)
[ci skip]
2026-10-12 14:43:06 +02:00
Excalidraw Bot 045bd70996 New translations en.json (Slovenian)
[ci skip]
2026-10-12 14:43:04 +02:00
Excalidraw Bot 18ec4cb74f New translations en.json (Slovak)
[ci skip]
2026-10-12 14:43:02 +02:00
Excalidraw Bot cfe36b3e39 New translations en.json (Russian)
[ci skip]
2026-10-12 14:43:00 +02:00
Excalidraw Bot e6df597a2c New translations en.json (Portuguese)
[ci skip]
2026-10-12 14:42:57 +02:00
Excalidraw Bot 8129f2befa New translations en.json (Polish)
[ci skip]
2026-10-12 14:42:55 +02:00
Excalidraw Bot 07e34178ea New translations en.json (Punjabi)
[ci skip]
2026-10-12 14:42:53 +02:00
Excalidraw Bot 2c0f19cee6 New translations en.json (Dutch)
[ci skip]
2026-10-12 14:42:50 +02:00
Excalidraw Bot 9effdb22d6 New translations en.json (Lithuanian)
[ci skip]
2026-10-12 14:42:47 +02:00
Excalidraw Bot 1ff832777c New translations en.json (Thai)
[ci skip]
2026-10-12 14:42:45 +02:00
Excalidraw Bot dcc49bf556 New translations en.json (Indonesian)
[ci skip]
2026-10-12 14:42:42 +02:00
Excalidraw Bot ac1d63d460 New translations en.json (Kurdish)
[ci skip]
2026-10-12 14:42:40 +02:00
Excalidraw Bot 07b65dfb78 New translations en.json (Korean)
[ci skip]
2026-10-12 14:42:38 +02:00
Excalidraw Bot 14b4132d4d New translations en.json (Japanese)
[ci skip]
2026-10-12 14:42:35 +02:00
Excalidraw Bot 9b0c66e953 New translations en.json (Italian)
[ci skip]
2026-10-12 14:42:33 +02:00
Excalidraw Bot c4a2b437d5 New translations en.json (Hungarian)
[ci skip]
2026-10-12 14:42:30 +02:00
Excalidraw Bot 5cf76c3991 New translations en.json (Hebrew)
[ci skip]
2026-10-12 14:42:28 +02:00
Excalidraw Bot 2235eced5d New translations en.json (Finnish)
[ci skip]
2026-10-12 14:42:25 +02:00
Excalidraw Bot 14532ba7b3 New translations en.json (Basque)
[ci skip]
2026-10-12 14:42:23 +02:00
Excalidraw Bot 116aa0dbec New translations en.json (Greek)
[ci skip]
2026-10-12 14:42:20 +02:00
Excalidraw Bot cbb3947249 New translations en.json (German)
[ci skip]
2026-10-12 14:42:17 +02:00
Excalidraw Bot 8a211c500e New translations en.json (Danish)
[ci skip]
2026-10-12 14:42:15 +02:00
Excalidraw Bot 4f53ac341c New translations en.json (Czech)
[ci skip]
2026-10-12 14:42:13 +02:00
Excalidraw Bot 072bf48b28 New translations en.json (Catalan)
[ci skip]
2026-10-12 14:42:10 +02:00
Excalidraw Bot 37f98fe667 New translations en.json (Bulgarian)
[ci skip]
2026-10-12 14:42:08 +02:00
Excalidraw Bot 3ae29ca1e9 New translations en.json (Arabic)
[ci skip]
2026-10-12 14:42:06 +02:00
Excalidraw Bot 7df017443e New translations en.json (Spanish)
[ci skip]
2026-10-12 14:42:03 +02:00
Excalidraw Bot 359b18a621 New translations en.json (French)
[ci skip]
2026-10-12 14:42:01 +02:00
Excalidraw Bot 4ee18e065f New translations en.json (Romanian)
[ci skip]
2026-10-12 14:41:58 +02:00
Excalidraw Bot 180874a42e New translations en.json (Karakalpak)
[ci skip]
2026-10-12 12:09:39 +02:00
Excalidraw Bot a8a306e176 New translations en.json (Kabyle)
[ci skip]
2026-10-12 12:09:37 +02:00
Excalidraw Bot a57cf6f7a3 New translations en.json (Bengali, India)
[ci skip]
2026-10-12 12:09:35 +02:00
Excalidraw Bot a6d7c11db0 New translations en.json (German, Switzerland)
[ci skip]
2026-10-12 12:09:33 +02:00
Excalidraw Bot 4882ba96f6 New translations en.json (Occitan)
[ci skip]
2026-10-12 12:09:32 +02:00
Excalidraw Bot cd8ba40c57 New translations en.json (Norwegian Bokmal)
[ci skip]
2026-10-12 12:09:30 +02:00
Excalidraw Bot 9875eba5ea New translations en.json (Uzbek)
[ci skip]
2026-10-12 12:09:28 +02:00
Excalidraw Bot 1a8b5a6e9b New translations en.json (Sinhala)
[ci skip]
2026-10-12 12:09:26 +02:00
Excalidraw Bot 9e0488a136 New translations en.json (Chinese Traditional, Hong Kong)
[ci skip]
2026-10-12 12:09:24 +02:00
Excalidraw Bot 521bf389d3 New translations en.json (Burmese)
[ci skip]
2026-10-12 12:09:22 +02:00
Excalidraw Bot e98094377c New translations en.json (Hindi)
[ci skip]
2026-10-12 12:09:21 +02:00
Excalidraw Bot 3016eb7043 New translations en.json (Azerbaijani)
[ci skip]
2026-10-12 12:09:19 +02:00
Excalidraw Bot 0f1061281a New translations en.json (Latvian)
[ci skip]
2026-10-12 12:09:18 +02:00
Excalidraw Bot b2294291a1 New translations en.json (Kazakh)
[ci skip]
2026-10-12 12:09:16 +02:00
Excalidraw Bot f39c336d0e New translations en.json (Norwegian Nynorsk)
[ci skip]
2026-10-12 12:09:14 +02:00
Excalidraw Bot ff568612f1 New translations en.json (Marathi)
[ci skip]
2026-10-12 12:09:13 +02:00
Excalidraw Bot 544814b6e9 New translations en.json (Bengali)
[ci skip]
2026-10-12 12:09:11 +02:00
Excalidraw Bot 7d2243a9e7 New translations en.json (Tamil)
[ci skip]
2026-10-12 12:09:09 +02:00
Excalidraw Bot 93397e83c5 New translations en.json (Khmer)
[ci skip]
2026-10-12 12:09:07 +02:00
Excalidraw Bot e39b28d90d New translations en.json (Persian)
[ci skip]
2026-10-12 12:09:06 +02:00
Excalidraw Bot bdf03fb496 New translations en.json (Portuguese, Brazilian)
[ci skip]
2026-10-12 12:09:04 +02:00
Excalidraw Bot 7d2b373df1 New translations en.json (Galician)
[ci skip]
2026-10-12 12:09:02 +02:00
Excalidraw Bot 628696108a New translations en.json (Vietnamese)
[ci skip]
2026-10-12 12:09:00 +02:00
Excalidraw Bot d29e59283a New translations en.json (Chinese Traditional)
[ci skip]
2026-10-12 12:08:59 +02:00
Excalidraw Bot 357df658ea New translations en.json (Chinese Simplified)
[ci skip]
2026-10-12 12:08:57 +02:00
Excalidraw Bot c14ce48b47 New translations en.json (Ukrainian)
[ci skip]
2026-10-12 12:08:55 +02:00
Excalidraw Bot b6536a7131 New translations en.json (Turkish)
[ci skip]
2026-10-12 12:08:53 +02:00
Excalidraw Bot da55692998 New translations en.json (Swedish)
[ci skip]
2026-10-12 12:08:51 +02:00
Excalidraw Bot b733b73d54 New translations en.json (Slovenian)
[ci skip]
2026-10-12 12:08:49 +02:00
Excalidraw Bot 94e285b832 New translations en.json (Slovak)
[ci skip]
2026-10-12 12:08:47 +02:00
Excalidraw Bot d7e7872d9b New translations en.json (Russian)
[ci skip]
2026-10-12 12:08:45 +02:00
Excalidraw Bot 07ea2d197f New translations en.json (Portuguese)
[ci skip]
2026-10-12 12:08:44 +02:00
Excalidraw Bot 0f30b3eb76 New translations en.json (Polish)
[ci skip]
2026-10-12 12:08:42 +02:00
Excalidraw Bot d4bbff8853 New translations en.json (Punjabi)
[ci skip]
2026-10-12 12:08:40 +02:00
Excalidraw Bot f88e25ccac New translations en.json (Dutch)
[ci skip]
2026-10-12 12:08:39 +02:00
Excalidraw Bot c7c9941839 New translations en.json (Lithuanian)
[ci skip]
2026-10-12 12:08:37 +02:00
Excalidraw Bot cf2f996f5e New translations en.json (Kurdish)
[ci skip]
2026-10-12 12:08:35 +02:00
Excalidraw Bot bb24a60b44 New translations en.json (Korean)
[ci skip]
2026-10-12 12:08:33 +02:00
Excalidraw Bot 464e4797a1 New translations en.json (Japanese)
[ci skip]
2026-10-12 12:08:31 +02:00
Excalidraw Bot 13fa9c12ed New translations en.json (Italian)
[ci skip]
2026-10-12 12:08:29 +02:00
Excalidraw Bot c3f0835f0e New translations en.json (Hungarian)
[ci skip]
2026-10-12 12:08:28 +02:00
Excalidraw Bot 900d4550b9 New translations en.json (Hebrew)
[ci skip]
2026-10-12 12:08:26 +02:00
Excalidraw Bot 3313b6b940 New translations en.json (Finnish)
[ci skip]
2026-10-12 12:08:24 +02:00
Excalidraw Bot a4ff7fd072 New translations en.json (Basque)
[ci skip]
2026-10-12 12:08:22 +02:00
Excalidraw Bot ad31869462 New translations en.json (Greek)
[ci skip]
2026-10-12 12:08:21 +02:00
Excalidraw Bot b928a93ec5 New translations en.json (German)
[ci skip]
2026-10-12 12:08:19 +02:00
Excalidraw Bot e2de8c252e New translations en.json (Danish)
[ci skip]
2026-10-12 12:08:17 +02:00
Excalidraw Bot 2e018f332c New translations en.json (Czech)
[ci skip]
2026-10-12 12:08:15 +02:00
Excalidraw Bot 25ce0c85bd New translations en.json (Catalan)
[ci skip]
2026-10-12 12:08:13 +02:00
Excalidraw Bot 556711f5dc New translations en.json (Bulgarian)
[ci skip]
2026-10-12 12:08:12 +02:00
Excalidraw Bot c6b163cab5 New translations en.json (Arabic)
[ci skip]
2026-10-12 12:08:10 +02:00
Excalidraw Bot af94e04282 New translations en.json (Spanish)
[ci skip]
2026-10-12 12:08:08 +02:00
Excalidraw Bot 29982b9414 New translations en.json (French)
[ci skip]
2026-10-12 12:08:06 +02:00
Excalidraw Bot fef2fcff04 New translations en.json (Thai)
[ci skip]
2026-10-12 12:08:04 +02:00
Excalidraw Bot 79e5c421d0 New translations en.json (Indonesian)
[ci skip]
2026-10-12 12:08:02 +02:00
Excalidraw Bot ed59d2f066 New translations en.json (Romanian)
[ci skip]
2026-10-12 12:08:01 +02:00
Excalidraw Bot 03b8232900 New translations en.json (Romanian)
[ci skip]
2026-10-06 22:05:39 +02:00
Excalidraw Bot f7aca0118c New translations en.json (Karakalpak)
[ci skip]
2026-10-06 17:44:50 +02:00
Excalidraw Bot 1b3f16bf4a New translations en.json (Kabyle)
[ci skip]
2026-10-06 17:44:48 +02:00
Excalidraw Bot f17153ef60 New translations en.json (Bengali, India)
[ci skip]
2026-10-06 17:44:46 +02:00
Excalidraw Bot d18d378dee New translations en.json (German, Switzerland)
[ci skip]
2026-10-06 17:44:45 +02:00
Excalidraw Bot 8fedc9202d New translations en.json (Occitan)
[ci skip]
2026-10-06 17:44:43 +02:00
Excalidraw Bot 579bb1a192 New translations en.json (Norwegian Bokmal)
[ci skip]
2026-10-06 17:44:41 +02:00
Excalidraw Bot d415065cfa New translations en.json (Uzbek)
[ci skip]
2026-10-06 17:44:40 +02:00
Excalidraw Bot 14ba6825dd New translations en.json (Sinhala)
[ci skip]
2026-10-06 17:44:38 +02:00
Excalidraw Bot b705744053 New translations en.json (Chinese Traditional, Hong Kong)
[ci skip]
2026-10-06 17:44:36 +02:00
Excalidraw Bot c86b75f6c9 New translations en.json (Burmese)
[ci skip]
2026-10-06 17:44:34 +02:00
Excalidraw Bot 6fa35e8bfa New translations en.json (Hindi)
[ci skip]
2026-10-06 17:44:32 +02:00
Excalidraw Bot c80284d043 New translations en.json (Azerbaijani)
[ci skip]
2026-10-06 17:44:30 +02:00
Excalidraw Bot 5c5b9a8d44 New translations en.json (Latvian)
[ci skip]
2026-10-06 17:44:29 +02:00
Excalidraw Bot 04a8a6180e New translations en.json (Kazakh)
[ci skip]
2026-10-06 17:44:27 +02:00
Excalidraw Bot 09dfd40c94 New translations en.json (Norwegian Nynorsk)
[ci skip]
2026-10-06 17:44:26 +02:00
Excalidraw Bot db9268b834 New translations en.json (Marathi)
[ci skip]
2026-10-06 17:44:24 +02:00
Excalidraw Bot 106155fa65 New translations en.json (Bengali)
[ci skip]
2026-10-06 17:44:22 +02:00
Excalidraw Bot 90bd238af3 New translations en.json (Tamil)
[ci skip]
2026-10-06 17:44:20 +02:00
Excalidraw Bot 227f83cb82 New translations en.json (Khmer)
[ci skip]
2026-10-06 17:44:18 +02:00
Excalidraw Bot 2cf35fce03 New translations en.json (Persian)
[ci skip]
2026-10-06 17:44:16 +02:00
Excalidraw Bot 20817bb105 New translations en.json (Portuguese, Brazilian)
[ci skip]
2026-10-06 17:44:14 +02:00
Excalidraw Bot 2c8ad231f9 New translations en.json (Galician)
[ci skip]
2026-10-06 17:44:12 +02:00
Excalidraw Bot 860a174db7 New translations en.json (Vietnamese)
[ci skip]
2026-10-06 17:44:10 +02:00
Excalidraw Bot dd468e246b New translations en.json (Chinese Traditional)
[ci skip]
2026-10-06 17:44:08 +02:00
Excalidraw Bot b7a5bf8728 New translations en.json (Chinese Simplified)
[ci skip]
2026-10-06 17:44:07 +02:00
Excalidraw Bot f4d5744737 New translations en.json (Ukrainian)
[ci skip]
2026-10-06 17:44:05 +02:00
Excalidraw Bot f867113218 New translations en.json (Turkish)
[ci skip]
2026-10-06 17:44:03 +02:00
Excalidraw Bot 046c38c98f New translations en.json (Swedish)
[ci skip]
2026-10-06 17:44:01 +02:00
Excalidraw Bot 5fb9aea89a New translations en.json (Slovenian)
[ci skip]
2026-10-06 17:43:59 +02:00
Excalidraw Bot 16cd43b6dd New translations en.json (Slovak)
[ci skip]
2026-10-06 17:43:58 +02:00
Excalidraw Bot a3cff60adc New translations en.json (Russian)
[ci skip]
2026-10-06 17:43:56 +02:00
Excalidraw Bot 84d811a39b New translations en.json (Portuguese)
[ci skip]
2026-10-06 17:43:55 +02:00
Excalidraw Bot c0973ef9e3 New translations en.json (Polish)
[ci skip]
2026-10-06 17:43:53 +02:00
Excalidraw Bot 7421c54e15 New translations en.json (Punjabi)
[ci skip]
2026-10-06 17:43:52 +02:00
Excalidraw Bot fbab6a4941 New translations en.json (Dutch)
[ci skip]
2026-10-06 17:43:50 +02:00
Excalidraw Bot fd4c47e9ac New translations en.json (Lithuanian)
[ci skip]
2026-10-06 17:43:48 +02:00
Excalidraw Bot 7324d46286 New translations en.json (Kurdish)
[ci skip]
2026-10-06 17:43:46 +02:00
Excalidraw Bot 5055196be7 New translations en.json (Korean)
[ci skip]
2026-10-06 17:43:44 +02:00
Excalidraw Bot dae52245bd New translations en.json (Japanese)
[ci skip]
2026-10-06 17:43:43 +02:00
Excalidraw Bot 484596b45b New translations en.json (Italian)
[ci skip]
2026-10-06 17:43:41 +02:00
Excalidraw Bot 8b27b24466 New translations en.json (Hungarian)
[ci skip]
2026-10-06 17:43:39 +02:00
Excalidraw Bot 0bb5a3c805 New translations en.json (Hebrew)
[ci skip]
2026-10-06 17:43:37 +02:00
Excalidraw Bot dbea4f3b77 New translations en.json (Finnish)
[ci skip]
2026-10-06 17:43:35 +02:00
Excalidraw Bot 9a2693fdab New translations en.json (Basque)
[ci skip]
2026-10-06 17:43:34 +02:00
Excalidraw Bot ea01d7df42 New translations en.json (Greek)
[ci skip]
2026-10-06 17:43:32 +02:00
Excalidraw Bot 64767e5d45 New translations en.json (German)
[ci skip]
2026-10-06 17:43:30 +02:00
Excalidraw Bot 693a40c1b3 New translations en.json (Danish)
[ci skip]
2026-10-06 17:43:28 +02:00
Excalidraw Bot 05400653a8 New translations en.json (Czech)
[ci skip]
2026-10-06 17:43:27 +02:00
Excalidraw Bot 7742c450f3 New translations en.json (Catalan)
[ci skip]
2026-10-06 17:43:25 +02:00
Excalidraw Bot fea4499284 New translations en.json (Bulgarian)
[ci skip]
2026-10-06 17:43:23 +02:00
Excalidraw Bot 49080aeb75 New translations en.json (Arabic)
[ci skip]
2026-10-06 17:43:22 +02:00
Excalidraw Bot a6c9d4f5e4 New translations en.json (Spanish)
[ci skip]
2026-10-06 17:43:20 +02:00
Excalidraw Bot aef933a3cd New translations en.json (French)
[ci skip]
2026-10-06 17:43:18 +02:00
Excalidraw Bot 0e925b2285 New translations en.json (Romanian)
[ci skip]
2026-10-06 17:43:16 +02:00
Excalidraw Bot a47288fe7f New translations en.json (Thai)
[ci skip]
2026-10-06 17:43:15 +02:00
Excalidraw Bot a4c227c8e0 New translations en.json (Indonesian)
[ci skip]
2026-10-06 17:43:12 +02:00
Excalidraw Bot 5b60548a82 New translations en.json (Romanian)
[ci skip]
2026-09-30 14:45:23 +02:00
Excalidraw Bot 01f40d60d0 New translations en.json (Karakalpak)
[ci skip]
2026-09-29 18:17:47 +02:00
Excalidraw Bot 2dc799839f New translations en.json (Kabyle)
[ci skip]
2026-09-29 18:17:45 +02:00
Excalidraw Bot 79db0a1b0f New translations en.json (Bengali, India)
[ci skip]
2026-09-29 18:17:44 +02:00
Excalidraw Bot 83bec99750 New translations en.json (German, Switzerland)
[ci skip]
2026-09-29 18:17:42 +02:00
Excalidraw Bot ac4fc4f582 New translations en.json (Occitan)
[ci skip]
2026-09-29 18:17:40 +02:00
Excalidraw Bot 8db48fecd5 New translations en.json (Norwegian Bokmal)
[ci skip]
2026-09-29 18:17:39 +02:00
Excalidraw Bot 218a2a4030 New translations en.json (Uzbek)
[ci skip]
2026-09-29 18:17:37 +02:00
Excalidraw Bot 9a5409e901 New translations en.json (Sinhala)
[ci skip]
2026-09-29 18:17:35 +02:00
Excalidraw Bot 25581ee714 New translations en.json (Chinese Traditional, Hong Kong)
[ci skip]
2026-09-29 18:17:34 +02:00
Excalidraw Bot 346ebf84ee New translations en.json (Burmese)
[ci skip]
2026-09-29 18:17:32 +02:00
Excalidraw Bot f5501a7914 New translations en.json (Hindi)
[ci skip]
2026-09-29 18:17:30 +02:00
Excalidraw Bot e68be8a2cf New translations en.json (Azerbaijani)
[ci skip]
2026-09-29 18:17:28 +02:00
Excalidraw Bot c9e89aabd7 New translations en.json (Latvian)
[ci skip]
2026-09-29 18:17:26 +02:00
Excalidraw Bot 3b9711a965 New translations en.json (Kazakh)
[ci skip]
2026-09-29 18:17:25 +02:00
Excalidraw Bot 217d2df648 New translations en.json (Norwegian Nynorsk)
[ci skip]
2026-09-29 18:17:23 +02:00
Excalidraw Bot f55a6fba3c New translations en.json (Marathi)
[ci skip]
2026-09-29 18:17:21 +02:00
Excalidraw Bot c1ae23d146 New translations en.json (Bengali)
[ci skip]
2026-09-29 18:17:19 +02:00
Excalidraw Bot ea67d5541b New translations en.json (Tamil)
[ci skip]
2026-09-29 18:17:18 +02:00
Excalidraw Bot 5bcd2e817b New translations en.json (Khmer)
[ci skip]
2026-09-29 18:17:16 +02:00
Excalidraw Bot 5e23092297 New translations en.json (Persian)
[ci skip]
2026-09-29 18:17:14 +02:00
Excalidraw Bot 1529ecf806 New translations en.json (Portuguese, Brazilian)
[ci skip]
2026-09-29 18:17:12 +02:00
Excalidraw Bot bdf423e121 New translations en.json (Galician)
[ci skip]
2026-09-29 18:17:10 +02:00
Excalidraw Bot 59a13e8825 New translations en.json (Vietnamese)
[ci skip]
2026-09-29 18:17:08 +02:00
Excalidraw Bot 7fa74de332 New translations en.json (Chinese Traditional)
[ci skip]
2026-09-29 18:17:06 +02:00
Excalidraw Bot 43d9c7835b New translations en.json (Chinese Simplified)
[ci skip]
2026-09-29 18:17:04 +02:00
Excalidraw Bot aa23795c67 New translations en.json (Ukrainian)
[ci skip]
2026-09-29 18:17:02 +02:00
Excalidraw Bot 2de46833df New translations en.json (Turkish)
[ci skip]
2026-09-29 18:17:01 +02:00
Excalidraw Bot b80e0323db New translations en.json (Swedish)
[ci skip]
2026-09-29 18:16:59 +02:00
Excalidraw Bot 8cf887ff5f New translations en.json (Slovenian)
[ci skip]
2026-09-29 18:16:57 +02:00
Excalidraw Bot 33389171ee New translations en.json (Slovak)
[ci skip]
2026-09-29 18:16:55 +02:00
Excalidraw Bot 4ae6f4d28a New translations en.json (Russian)
[ci skip]
2026-09-29 18:16:53 +02:00
Excalidraw Bot deae8d9673 New translations en.json (Portuguese)
[ci skip]
2026-09-29 18:16:51 +02:00
Excalidraw Bot b45810499e New translations en.json (Polish)
[ci skip]
2026-09-29 18:16:50 +02:00
Excalidraw Bot cc40ea4e93 New translations en.json (Punjabi)
[ci skip]
2026-09-29 18:16:48 +02:00
Excalidraw Bot 6554060349 New translations en.json (Dutch)
[ci skip]
2026-09-29 18:16:46 +02:00
Excalidraw Bot 3a9c6ee887 New translations en.json (Lithuanian)
[ci skip]
2026-09-29 18:16:44 +02:00
Excalidraw Bot 1f21015f8c New translations en.json (Kurdish)
[ci skip]
2026-09-29 18:16:43 +02:00
Excalidraw Bot 18de20d0ba New translations en.json (Korean)
[ci skip]
2026-09-29 18:16:41 +02:00
Excalidraw Bot ab940b9a7a New translations en.json (Japanese)
[ci skip]
2026-09-29 18:16:39 +02:00
Excalidraw Bot cabfe20079 New translations en.json (Italian)
[ci skip]
2026-09-29 18:16:37 +02:00
Excalidraw Bot 11a1c1bd4a New translations en.json (Hungarian)
[ci skip]
2026-09-29 18:16:35 +02:00
Excalidraw Bot f2401a8be7 New translations en.json (Hebrew)
[ci skip]
2026-09-29 18:16:34 +02:00
Excalidraw Bot e45fc624fd New translations en.json (Finnish)
[ci skip]
2026-09-29 18:16:31 +02:00
Excalidraw Bot 4ee070910f New translations en.json (Basque)
[ci skip]
2026-09-29 18:16:30 +02:00
Excalidraw Bot 90367b19fb New translations en.json (Greek)
[ci skip]
2026-09-29 18:16:28 +02:00
Excalidraw Bot 8bacdc7b51 New translations en.json (German)
[ci skip]
2026-09-29 18:16:26 +02:00
Excalidraw Bot 8bcd6f7136 New translations en.json (Danish)
[ci skip]
2026-09-29 18:16:24 +02:00
Excalidraw Bot 404cd1d30d New translations en.json (Czech)
[ci skip]
2026-09-29 18:16:22 +02:00
Excalidraw Bot 14e20220db New translations en.json (Catalan)
[ci skip]
2026-09-29 18:16:20 +02:00
Excalidraw Bot 632a6772c4 New translations en.json (Bulgarian)
[ci skip]
2026-09-29 18:16:18 +02:00
Excalidraw Bot 53428a62e0 New translations en.json (Arabic)
[ci skip]
2026-09-29 18:16:16 +02:00
Excalidraw Bot 072ba31199 New translations en.json (Spanish)
[ci skip]
2026-09-29 18:16:14 +02:00
Excalidraw Bot 560ad218c8 New translations en.json (French)
[ci skip]
2026-09-29 18:16:12 +02:00
Excalidraw Bot 4aa1dbda08 New translations en.json (Romanian)
[ci skip]
2026-09-29 18:16:11 +02:00
Excalidraw Bot 3d50f5ab3d New translations en.json (Thai)
[ci skip]
2026-09-29 18:16:09 +02:00
Excalidraw Bot 52f2a1dd11 New translations en.json (Indonesian)
[ci skip]
2026-09-29 18:16:07 +02:00
Excalidraw Bot bf61e7285e New translations en.json (Romanian)
[ci skip]
2026-09-29 10:16:37 +02:00
Excalidraw Bot 5dcb08d36e New translations en.json (German, Switzerland)
[ci skip]
2026-09-29 00:25:33 +02:00
Excalidraw Bot cf087fd7a3 New translations en.json (Portuguese, Brazilian)
[ci skip]
2026-09-29 00:25:21 +02:00
Excalidraw Bot 8a4cf52629 New translations en.json (Chinese Traditional)
[ci skip]
2026-09-29 00:25:18 +02:00
Excalidraw Bot 9c50b6c222 New translations en.json (Chinese Simplified)
[ci skip]
2026-09-29 00:25:17 +02:00
Excalidraw Bot 9ec5eea042 New translations en.json (Ukrainian)
[ci skip]
2026-09-29 00:25:15 +02:00
Excalidraw Bot af2534d10d New translations en.json (Turkish)
[ci skip]
2026-09-29 00:25:13 +02:00
Excalidraw Bot 2d2162c89f New translations en.json (Swedish)
[ci skip]
2026-09-29 00:25:12 +02:00
Excalidraw Bot d82dd09ba6 New translations en.json (Slovenian)
[ci skip]
2026-09-29 00:25:10 +02:00
Excalidraw Bot f29c8b5b83 New translations en.json (Slovak)
[ci skip]
2026-09-29 00:25:09 +02:00
Excalidraw Bot 0c1f52f303 New translations en.json (Russian)
[ci skip]
2026-09-29 00:25:07 +02:00
Excalidraw Bot 95ecb38adf New translations en.json (Portuguese)
[ci skip]
2026-09-29 00:25:06 +02:00
Excalidraw Bot 65c1f1b2dd New translations en.json (Indonesian)
[ci skip]
2026-09-29 00:25:03 +02:00
Excalidraw Bot 9ec29dbf9d New translations en.json (Dutch)
[ci skip]
2026-09-29 00:25:01 +02:00
Excalidraw Bot 0f5ba33ca3 New translations en.json (Japanese)
[ci skip]
2026-09-29 00:24:57 +02:00
Excalidraw Bot d4af1b2790 New translations en.json (Italian)
[ci skip]
2026-09-29 00:24:55 +02:00
Excalidraw Bot 4ce00fe7b8 New translations en.json (Hebrew)
[ci skip]
2026-09-29 00:24:53 +02:00
Excalidraw Bot 09ee967c3e New translations en.json (Basque)
[ci skip]
2026-09-29 00:24:51 +02:00
Excalidraw Bot 05ba33fc0c New translations en.json (German)
[ci skip]
2026-09-29 00:24:49 +02:00
Excalidraw Bot 34ebc10d03 New translations en.json (Arabic)
[ci skip]
2026-09-29 00:24:44 +02:00
Excalidraw Bot 54aca9dbcc New translations en.json (Spanish)
[ci skip]
2026-09-29 00:24:43 +02:00
Excalidraw Bot 73911c0223 New translations en.json (French)
[ci skip]
2026-09-29 00:24:41 +02:00
Excalidraw Bot 6da09c4743 New translations en.json (Romanian)
[ci skip]
2026-09-29 00:24:40 +02:00
Excalidraw Bot c211b42b5b New translations en.json (Azerbaijani)
[ci skip]
2026-09-28 10:28:17 +02:00
Excalidraw Bot 347b1a5750 Auto commit: Calculate translation coverage 2026-09-27 07:47:01 +00:00
dwelle a4f9ec1a68 run ci 2026-09-27 09:46:37 +02:00
Excalidraw Bot 0b15e45669 New translations en.json (Romanian)
[ci skip]
2026-09-27 09:29:01 +02:00
Excalidraw Bot 97945cbaf0 New translations en.json (Karakalpak)
[ci skip]
2026-09-27 00:57:36 +02:00
Excalidraw Bot 7ed5dad4d8 New translations en.json (Kabyle)
[ci skip]
2026-09-27 00:57:35 +02:00
Excalidraw Bot 950116f7f2 New translations en.json (Bengali, India)
[ci skip]
2026-09-27 00:57:33 +02:00
Excalidraw Bot d60e9652c0 New translations en.json (German, Switzerland)
[ci skip]
2026-09-27 00:57:32 +02:00
Excalidraw Bot cb33770a80 New translations en.json (Occitan)
[ci skip]
2026-09-27 00:57:31 +02:00
Excalidraw Bot 04da2a11e1 New translations en.json (Norwegian Bokmal)
[ci skip]
2026-09-27 00:57:29 +02:00
Excalidraw Bot 7e2745c58d New translations en.json (Uzbek)
[ci skip]
2026-09-27 00:57:28 +02:00
Excalidraw Bot 84d4325b7c New translations en.json (Sinhala)
[ci skip]
2026-09-27 00:57:26 +02:00
Excalidraw Bot 251cdf2ed3 New translations en.json (Chinese Traditional, Hong Kong)
[ci skip]
2026-09-27 00:57:25 +02:00
Excalidraw Bot 18ef42bb8c New translations en.json (Burmese)
[ci skip]
2026-09-27 00:57:23 +02:00
Excalidraw Bot f02748cd21 New translations en.json (Hindi)
[ci skip]
2026-09-27 00:57:22 +02:00
Excalidraw Bot f6d65132e2 New translations en.json (Azerbaijani)
[ci skip]
2026-09-27 00:57:20 +02:00
Excalidraw Bot 29421e46bc New translations en.json (Latvian)
[ci skip]
2026-09-27 00:57:19 +02:00
Excalidraw Bot 819ce70240 New translations en.json (Kazakh)
[ci skip]
2026-09-27 00:57:18 +02:00
Excalidraw Bot 7f1d00bf3d New translations en.json (Norwegian Nynorsk)
[ci skip]
2026-09-27 00:57:16 +02:00
Excalidraw Bot 00f2b2484c New translations en.json (Marathi)
[ci skip]
2026-09-27 00:57:15 +02:00
Excalidraw Bot 0fb542d813 New translations en.json (Bengali)
[ci skip]
2026-09-27 00:57:14 +02:00
Excalidraw Bot f70d10e53d New translations en.json (Tamil)
[ci skip]
2026-09-27 00:57:12 +02:00
Excalidraw Bot 96ffa3a1e9 New translations en.json (Khmer)
[ci skip]
2026-09-27 00:57:11 +02:00
Excalidraw Bot c05092e75e New translations en.json (Persian)
[ci skip]
2026-09-27 00:57:09 +02:00
Excalidraw Bot 079001d158 New translations en.json (Portuguese, Brazilian)
[ci skip]
2026-09-27 00:57:08 +02:00
Excalidraw Bot 5c03363431 New translations en.json (Galician)
[ci skip]
2026-09-27 00:57:07 +02:00
Excalidraw Bot e36f3f568f New translations en.json (Vietnamese)
[ci skip]
2026-09-27 00:57:05 +02:00
Excalidraw Bot 375ee75c42 New translations en.json (Chinese Traditional)
[ci skip]
2026-09-27 00:57:04 +02:00
Excalidraw Bot 33de38a430 New translations en.json (Chinese Simplified)
[ci skip]
2026-09-27 00:57:03 +02:00
Excalidraw Bot ad4ad9a67a New translations en.json (Ukrainian)
[ci skip]
2026-09-27 00:57:01 +02:00
Excalidraw Bot 821aa16bdf New translations en.json (Turkish)
[ci skip]
2026-09-27 00:57:00 +02:00
Excalidraw Bot 7e7b952bfe New translations en.json (Swedish)
[ci skip]
2026-09-27 00:56:58 +02:00
Excalidraw Bot 695aeb2e37 New translations en.json (Slovenian)
[ci skip]
2026-09-27 00:56:57 +02:00
Excalidraw Bot 5373fde0af New translations en.json (Slovak)
[ci skip]
2026-09-27 00:56:55 +02:00
Excalidraw Bot 12d24433dc New translations en.json (Russian)
[ci skip]
2026-09-27 00:56:53 +02:00
Excalidraw Bot 2da1777a8b New translations en.json (Portuguese)
[ci skip]
2026-09-27 00:56:52 +02:00
Excalidraw Bot cbe5637ddb New translations en.json (Polish)
[ci skip]
2026-09-27 00:56:50 +02:00
Excalidraw Bot 24c4390273 New translations en.json (Punjabi)
[ci skip]
2026-09-27 00:56:48 +02:00
Excalidraw Bot 48f8a189cc New translations en.json (Dutch)
[ci skip]
2026-09-27 00:56:47 +02:00
Excalidraw Bot 781fabb221 New translations en.json (Lithuanian)
[ci skip]
2026-09-27 00:56:46 +02:00
Excalidraw Bot 7ebeddf9e3 New translations en.json (Kurdish)
[ci skip]
2026-09-27 00:56:44 +02:00
Excalidraw Bot 9ce39adce8 New translations en.json (Korean)
[ci skip]
2026-09-27 00:56:43 +02:00
Excalidraw Bot 97b623d83b New translations en.json (Japanese)
[ci skip]
2026-09-27 00:56:42 +02:00
Excalidraw Bot 3d4c42a9d4 New translations en.json (Italian)
[ci skip]
2026-09-27 00:56:40 +02:00
Excalidraw Bot 39601fa3fc New translations en.json (Hungarian)
[ci skip]
2026-09-27 00:56:39 +02:00
Excalidraw Bot 5b081dceac New translations en.json (Hebrew)
[ci skip]
2026-09-27 00:56:37 +02:00
Excalidraw Bot e6fc6cd448 New translations en.json (Finnish)
[ci skip]
2026-09-27 00:56:36 +02:00
Excalidraw Bot 24b177619e New translations en.json (Basque)
[ci skip]
2026-09-27 00:56:35 +02:00
Excalidraw Bot bb5a6bd831 New translations en.json (Greek)
[ci skip]
2026-09-27 00:56:33 +02:00
Excalidraw Bot f11198bc66 New translations en.json (German)
[ci skip]
2026-09-27 00:56:32 +02:00
Excalidraw Bot 2e4602c1af New translations en.json (Danish)
[ci skip]
2026-09-27 00:56:31 +02:00
Excalidraw Bot 6288555560 New translations en.json (Czech)
[ci skip]
2026-09-27 00:56:29 +02:00
Excalidraw Bot e9edf97017 New translations en.json (Catalan)
[ci skip]
2026-09-27 00:56:28 +02:00
Excalidraw Bot 01cda0223b New translations en.json (Bulgarian)
[ci skip]
2026-09-27 00:56:26 +02:00
Excalidraw Bot 8189b72363 New translations en.json (Arabic)
[ci skip]
2026-09-27 00:56:25 +02:00
Excalidraw Bot c6c18c043c New translations en.json (Spanish)
[ci skip]
2026-09-27 00:56:24 +02:00
Excalidraw Bot 7620bded7f New translations en.json (French)
[ci skip]
2026-09-27 00:56:22 +02:00
Excalidraw Bot 1e6a6ee071 New translations en.json (Romanian)
[ci skip]
2026-09-27 00:56:20 +02:00
Excalidraw Bot 8f6fd55581 New translations en.json (Thai)
[ci skip]
2026-09-27 00:56:19 +02:00
Excalidraw Bot 081165782e New translations en.json (Indonesian)
[ci skip]
2026-09-27 00:56:18 +02:00
Excalidraw Bot 37d32f1684 New translations en.json (Swedish)
[ci skip]
2026-09-26 08:22:39 +02:00
Excalidraw Bot aa856383aa New translations en.json (Romanian)
[ci skip]
2026-09-25 20:38:21 +02:00
Excalidraw Bot 232c099372 New translations en.json (Karakalpak)
[ci skip]
2026-09-25 00:51:43 +02:00
Excalidraw Bot 35b787d4f7 New translations en.json (Kabyle)
[ci skip]
2026-09-25 00:51:42 +02:00
Excalidraw Bot 5e80da5068 New translations en.json (Bengali, India)
[ci skip]
2026-09-25 00:51:40 +02:00
Excalidraw Bot 1a0293b231 New translations en.json (German, Switzerland)
[ci skip]
2026-09-25 00:51:38 +02:00
Excalidraw Bot 880f75361c New translations en.json (Occitan)
[ci skip]
2026-09-25 00:51:37 +02:00
Excalidraw Bot 23e9e049e2 New translations en.json (Norwegian Bokmal)
[ci skip]
2026-09-25 00:51:35 +02:00
Excalidraw Bot 4405fc366b New translations en.json (Uzbek)
[ci skip]
2026-09-25 00:51:34 +02:00
Excalidraw Bot 395c691185 New translations en.json (Sinhala)
[ci skip]
2026-09-25 00:51:32 +02:00
Excalidraw Bot e178c2b1af New translations en.json (Chinese Traditional, Hong Kong)
[ci skip]
2026-09-25 00:51:30 +02:00
Excalidraw Bot fdba6e2fce New translations en.json (Burmese)
[ci skip]
2026-09-25 00:51:29 +02:00
Excalidraw Bot 26e4fc8d17 New translations en.json (Hindi)
[ci skip]
2026-09-25 00:51:27 +02:00
Excalidraw Bot 1e89e837f9 New translations en.json (Azerbaijani)
[ci skip]
2026-09-25 00:51:25 +02:00
Excalidraw Bot 606bc12e09 New translations en.json (Latvian)
[ci skip]
2026-09-25 00:51:24 +02:00
Excalidraw Bot d1de5d4b60 New translations en.json (Kazakh)
[ci skip]
2026-09-25 00:51:22 +02:00
Excalidraw Bot bb6c4aefed New translations en.json (Norwegian Nynorsk)
[ci skip]
2026-09-25 00:51:20 +02:00
Excalidraw Bot 10e6368acd New translations en.json (Marathi)
[ci skip]
2026-09-25 00:51:19 +02:00
Excalidraw Bot b05205168e New translations en.json (Bengali)
[ci skip]
2026-09-25 00:51:17 +02:00
Excalidraw Bot 589ceb350e New translations en.json (Tamil)
[ci skip]
2026-09-25 00:51:15 +02:00
Excalidraw Bot 4fdb61e579 New translations en.json (Khmer)
[ci skip]
2026-09-25 00:51:13 +02:00
Excalidraw Bot 42cb49bc5f New translations en.json (Persian)
[ci skip]
2026-09-25 00:51:12 +02:00
Excalidraw Bot 7d7530bffe New translations en.json (Portuguese, Brazilian)
[ci skip]
2026-09-25 00:51:11 +02:00
Excalidraw Bot 0d55c4ed7d New translations en.json (Galician)
[ci skip]
2026-09-25 00:51:09 +02:00
Excalidraw Bot 71819c1e15 New translations en.json (Vietnamese)
[ci skip]
2026-09-25 00:51:07 +02:00
Excalidraw Bot 0e80b23849 New translations en.json (Chinese Traditional)
[ci skip]
2026-09-25 00:51:06 +02:00
Excalidraw Bot 6a283cc669 New translations en.json (Chinese Simplified)
[ci skip]
2026-09-25 00:51:04 +02:00
Excalidraw Bot 03a3cfbbab New translations en.json (Ukrainian)
[ci skip]
2026-09-25 00:51:03 +02:00
Excalidraw Bot de3e5d58c5 New translations en.json (Turkish)
[ci skip]
2026-09-25 00:51:01 +02:00
Excalidraw Bot d96a301824 New translations en.json (Swedish)
[ci skip]
2026-09-25 00:50:59 +02:00
Excalidraw Bot b6935c33a6 New translations en.json (Slovenian)
[ci skip]
2026-09-25 00:50:58 +02:00
Excalidraw Bot 20cdb3dc89 New translations en.json (Slovak)
[ci skip]
2026-09-25 00:50:56 +02:00
Excalidraw Bot b262d56744 New translations en.json (Russian)
[ci skip]
2026-09-25 00:50:55 +02:00
Excalidraw Bot b205b4d87b New translations en.json (Portuguese)
[ci skip]
2026-09-25 00:50:53 +02:00
Excalidraw Bot 3c5f794528 New translations en.json (Polish)
[ci skip]
2026-09-25 00:50:52 +02:00
Excalidraw Bot 83b1ea7671 New translations en.json (Punjabi)
[ci skip]
2026-09-25 00:50:50 +02:00
Excalidraw Bot 0ef16b72b6 New translations en.json (Dutch)
[ci skip]
2026-09-25 00:50:48 +02:00
Excalidraw Bot 92e08cf305 New translations en.json (Lithuanian)
[ci skip]
2026-09-25 00:50:47 +02:00
Excalidraw Bot f48ae714ba New translations en.json (Kurdish)
[ci skip]
2026-09-25 00:50:45 +02:00
Excalidraw Bot f515ff2fcf New translations en.json (Korean)
[ci skip]
2026-09-25 00:50:44 +02:00
Excalidraw Bot 4ddb33cd56 New translations en.json (Japanese)
[ci skip]
2026-09-25 00:50:42 +02:00
Excalidraw Bot 32b7aedf4c New translations en.json (Italian)
[ci skip]
2026-09-25 00:50:40 +02:00
Excalidraw Bot 563d6e305c New translations en.json (Hungarian)
[ci skip]
2026-09-25 00:50:38 +02:00
Excalidraw Bot 142ac4101c New translations en.json (Hebrew)
[ci skip]
2026-09-25 00:50:37 +02:00
Excalidraw Bot d378c13d3e New translations en.json (Finnish)
[ci skip]
2026-09-25 00:50:35 +02:00
Excalidraw Bot a3c64017c6 New translations en.json (Basque)
[ci skip]
2026-09-25 00:50:33 +02:00
Excalidraw Bot 598d9fe591 New translations en.json (Greek)
[ci skip]
2026-09-25 00:50:31 +02:00
Excalidraw Bot 0c636cb614 New translations en.json (German)
[ci skip]
2026-09-25 00:50:30 +02:00
Excalidraw Bot 3465e281ae New translations en.json (Danish)
[ci skip]
2026-09-25 00:50:28 +02:00
Excalidraw Bot d8f4837102 New translations en.json (Czech)
[ci skip]
2026-09-25 00:50:27 +02:00
Excalidraw Bot f475c8137a New translations en.json (Catalan)
[ci skip]
2026-09-25 00:50:25 +02:00
Excalidraw Bot 3faf88221d New translations en.json (Bulgarian)
[ci skip]
2026-09-25 00:50:24 +02:00
Excalidraw Bot 8b2bac702f New translations en.json (Arabic)
[ci skip]
2026-09-25 00:50:22 +02:00
Excalidraw Bot 07fba1ebd4 New translations en.json (Spanish)
[ci skip]
2026-09-25 00:50:20 +02:00
Excalidraw Bot 23700905c0 New translations en.json (French)
[ci skip]
2026-09-25 00:50:19 +02:00
Excalidraw Bot 0276c143c0 New translations en.json (Romanian)
[ci skip]
2026-09-25 00:50:17 +02:00
Excalidraw Bot 1711925658 New translations en.json (Thai)
[ci skip]
2026-09-25 00:50:16 +02:00
Excalidraw Bot c0a1cb6620 New translations en.json (Indonesian)
[ci skip]
2026-09-25 00:50:14 +02:00
Excalidraw Bot 77561586df New translations en.json (Romanian)
[ci skip]
2026-09-24 22:31:43 +02:00
Excalidraw Bot bdc9cfcd16 New translations en.json (Karakalpak)
[ci skip]
2026-09-24 15:49:54 +02:00
Excalidraw Bot 9a12d8dc05 New translations en.json (Kabyle)
[ci skip]
2026-09-24 15:49:53 +02:00
Excalidraw Bot c05f5724f8 New translations en.json (Bengali, India)
[ci skip]
2026-09-24 15:49:51 +02:00
Excalidraw Bot 68cc3a6cc7 New translations en.json (German, Switzerland)
[ci skip]
2026-09-24 15:49:49 +02:00
Excalidraw Bot cf8bd65105 New translations en.json (Occitan)
[ci skip]
2026-09-24 15:49:48 +02:00
Excalidraw Bot b3a389469f New translations en.json (Norwegian Bokmal)
[ci skip]
2026-09-24 15:49:46 +02:00
Excalidraw Bot 653859147a New translations en.json (Uzbek)
[ci skip]
2026-09-24 15:49:45 +02:00
Excalidraw Bot 11f95f6959 New translations en.json (Sinhala)
[ci skip]
2026-09-24 15:49:43 +02:00
Excalidraw Bot 8036063439 New translations en.json (Chinese Traditional, Hong Kong)
[ci skip]
2026-09-24 15:49:42 +02:00
Excalidraw Bot 98e1a68392 New translations en.json (Burmese)
[ci skip]
2026-09-24 15:49:41 +02:00
Excalidraw Bot 0bdcb6d79b New translations en.json (Hindi)
[ci skip]
2026-09-24 15:49:38 +02:00
Excalidraw Bot 82ade21159 New translations en.json (Azerbaijani)
[ci skip]
2026-09-24 15:49:36 +02:00
Excalidraw Bot 82182bd183 New translations en.json (Latvian)
[ci skip]
2026-09-24 15:49:34 +02:00
Excalidraw Bot 904110f538 New translations en.json (Kazakh)
[ci skip]
2026-09-24 15:49:33 +02:00
Excalidraw Bot 1a4c37961a New translations en.json (Norwegian Nynorsk)
[ci skip]
2026-09-24 15:49:31 +02:00
Excalidraw Bot cf83f17a12 New translations en.json (Marathi)
[ci skip]
2026-09-24 15:49:29 +02:00
Excalidraw Bot d025f44b4b New translations en.json (Bengali)
[ci skip]
2026-09-24 15:49:27 +02:00
Excalidraw Bot 47237d36b9 New translations en.json (Tamil)
[ci skip]
2026-09-24 15:49:25 +02:00
Excalidraw Bot b2088c28cc New translations en.json (Khmer)
[ci skip]
2026-09-24 15:49:24 +02:00
Excalidraw Bot df059f799b New translations en.json (Persian)
[ci skip]
2026-09-24 15:49:22 +02:00
Excalidraw Bot cb803005ac New translations en.json (Portuguese, Brazilian)
[ci skip]
2026-09-24 15:49:21 +02:00
Excalidraw Bot dae14b699f New translations en.json (Galician)
[ci skip]
2026-09-24 15:49:19 +02:00
Excalidraw Bot ae07aa3235 New translations en.json (Vietnamese)
[ci skip]
2026-09-24 15:49:18 +02:00
Excalidraw Bot c82839f3e5 New translations en.json (Chinese Traditional)
[ci skip]
2026-09-24 15:49:16 +02:00
Excalidraw Bot 7045f307c0 New translations en.json (Chinese Simplified)
[ci skip]
2026-09-24 15:49:14 +02:00
Excalidraw Bot 3ebbbfc8ec New translations en.json (Ukrainian)
[ci skip]
2026-09-24 15:49:13 +02:00
Excalidraw Bot cd4eb8c58e New translations en.json (Turkish)
[ci skip]
2026-09-24 15:49:11 +02:00
Excalidraw Bot c9441bcf10 New translations en.json (Swedish)
[ci skip]
2026-09-24 15:49:10 +02:00
Excalidraw Bot 8ef4177e32 New translations en.json (Slovenian)
[ci skip]
2026-09-24 15:49:08 +02:00
Excalidraw Bot 020a967ad2 New translations en.json (Slovak)
[ci skip]
2026-09-24 15:49:06 +02:00
Excalidraw Bot 337193f19d New translations en.json (Russian)
[ci skip]
2026-09-24 15:49:04 +02:00
Excalidraw Bot 6f216fddc5 New translations en.json (Portuguese)
[ci skip]
2026-09-24 15:49:02 +02:00
Excalidraw Bot 5f13d6f9fb New translations en.json (Polish)
[ci skip]
2026-09-24 15:49:01 +02:00
Excalidraw Bot 31f47dd094 New translations en.json (Punjabi)
[ci skip]
2026-09-24 15:48:59 +02:00
Excalidraw Bot 7cda89add4 New translations en.json (Dutch)
[ci skip]
2026-09-24 15:48:58 +02:00
Excalidraw Bot 03e31560f5 New translations en.json (Lithuanian)
[ci skip]
2026-09-24 15:48:56 +02:00
Excalidraw Bot ae8bb6b002 New translations en.json (Kurdish)
[ci skip]
2026-09-24 15:48:54 +02:00
Excalidraw Bot bac541ac8d New translations en.json (Korean)
[ci skip]
2026-09-24 15:48:51 +02:00
Excalidraw Bot bb5c34effa New translations en.json (Japanese)
[ci skip]
2026-09-24 15:48:50 +02:00
Excalidraw Bot 503becd97d New translations en.json (Italian)
[ci skip]
2026-09-24 15:48:48 +02:00
Excalidraw Bot 2d3e0323a2 New translations en.json (Hungarian)
[ci skip]
2026-09-24 15:48:47 +02:00
Excalidraw Bot beafb51ee1 New translations en.json (Hebrew)
[ci skip]
2026-09-24 15:48:45 +02:00
Excalidraw Bot aec2b208fa New translations en.json (Finnish)
[ci skip]
2026-09-24 15:48:44 +02:00
Excalidraw Bot 4806a6d48a New translations en.json (Basque)
[ci skip]
2026-09-24 15:48:42 +02:00
Excalidraw Bot 1f6e11e837 New translations en.json (Greek)
[ci skip]
2026-09-24 15:48:40 +02:00
Excalidraw Bot e130dbcbf7 New translations en.json (German)
[ci skip]
2026-09-24 15:48:39 +02:00
Excalidraw Bot 5f0f60fcbd New translations en.json (Danish)
[ci skip]
2026-09-24 15:48:37 +02:00
Excalidraw Bot 918aded6bc New translations en.json (Czech)
[ci skip]
2026-09-24 15:48:35 +02:00
Excalidraw Bot 81cdae9960 New translations en.json (Catalan)
[ci skip]
2026-09-24 15:48:34 +02:00
Excalidraw Bot ec751e0404 New translations en.json (Bulgarian)
[ci skip]
2026-09-24 15:48:32 +02:00
Excalidraw Bot 599102ca3f New translations en.json (Arabic)
[ci skip]
2026-09-24 15:48:30 +02:00
Excalidraw Bot 14005a1457 New translations en.json (Spanish)
[ci skip]
2026-09-24 15:48:28 +02:00
Excalidraw Bot 888724f104 New translations en.json (French)
[ci skip]
2026-09-24 15:48:27 +02:00
Excalidraw Bot 9f8460702d New translations en.json (Romanian)
[ci skip]
2026-09-24 15:48:25 +02:00
Excalidraw Bot 56ffe50098 New translations en.json (Thai)
[ci skip]
2026-09-24 15:48:23 +02:00
Excalidraw Bot 34e60fb12d New translations en.json (Indonesian)
[ci skip]
2026-09-24 15:48:22 +02:00
Excalidraw Bot 30efad4725 New translations en.json (Polish)
[ci skip]
2026-09-21 00:15:10 +02:00
Excalidraw Bot f80edbd154 New translations en.json (Polish)
[ci skip]
2026-09-20 22:57:49 +02:00
Excalidraw Bot 4051fc9fa7 New translations en.json (Italian)
[ci skip]
2026-09-14 19:29:34 +02:00
Excalidraw Bot 346c10c1fd New translations en.json (Romanian)
[ci skip]
2026-09-14 19:29:32 +02:00
Excalidraw Bot 5006347ecb New translations en.json (Karakalpak)
[ci skip]
2026-09-14 17:04:45 +02:00
Excalidraw Bot 87ca069b58 New translations en.json (Kabyle)
[ci skip]
2026-09-14 17:04:43 +02:00
Excalidraw Bot 4b67c87702 New translations en.json (Bengali, India)
[ci skip]
2026-09-14 17:04:41 +02:00
Excalidraw Bot a14e4c4be9 New translations en.json (German, Switzerland)
[ci skip]
2026-09-14 17:04:40 +02:00
Excalidraw Bot 1044b5db4f New translations en.json (Occitan)
[ci skip]
2026-09-14 17:04:38 +02:00
Excalidraw Bot 7f294a6be0 New translations en.json (Norwegian Bokmal)
[ci skip]
2026-09-14 17:04:36 +02:00
Excalidraw Bot 0f66905fc7 New translations en.json (Uzbek)
[ci skip]
2026-09-14 17:04:34 +02:00
Excalidraw Bot 10df2ae1ca New translations en.json (Sinhala)
[ci skip]
2026-09-14 17:04:32 +02:00
Excalidraw Bot bea86681c5 New translations en.json (Chinese Traditional, Hong Kong)
[ci skip]
2026-09-14 17:04:30 +02:00
Excalidraw Bot b5c9d3fb13 New translations en.json (Burmese)
[ci skip]
2026-09-14 17:04:28 +02:00
Excalidraw Bot dc5ace28f7 New translations en.json (Hindi)
[ci skip]
2026-09-14 17:04:26 +02:00
Excalidraw Bot c1b01b2d0b New translations en.json (Azerbaijani)
[ci skip]
2026-09-14 17:04:25 +02:00
Excalidraw Bot 66d591ba6c New translations en.json (Latvian)
[ci skip]
2026-09-14 17:04:23 +02:00
Excalidraw Bot 07376898e7 New translations en.json (Kazakh)
[ci skip]
2026-09-14 17:04:21 +02:00
Excalidraw Bot e8a7fe2ff8 New translations en.json (Norwegian Nynorsk)
[ci skip]
2026-09-14 17:04:19 +02:00
Excalidraw Bot a3cb798c29 New translations en.json (Marathi)
[ci skip]
2026-09-14 17:04:18 +02:00
Excalidraw Bot f5b31da983 New translations en.json (Bengali)
[ci skip]
2026-09-14 17:04:16 +02:00
Excalidraw Bot 3b23943d1a New translations en.json (Tamil)
[ci skip]
2026-09-14 17:04:14 +02:00
Excalidraw Bot 2d98f3a250 New translations en.json (Khmer)
[ci skip]
2026-09-14 17:04:12 +02:00
Excalidraw Bot bb6e50d1c1 New translations en.json (Persian)
[ci skip]
2026-09-14 17:04:10 +02:00
Excalidraw Bot b31bb58d59 New translations en.json (Portuguese, Brazilian)
[ci skip]
2026-09-14 17:04:08 +02:00
Excalidraw Bot 81652ff8ac New translations en.json (Galician)
[ci skip]
2026-09-14 17:04:07 +02:00
Excalidraw Bot 3a3009b83a New translations en.json (Vietnamese)
[ci skip]
2026-09-14 17:04:05 +02:00
Excalidraw Bot be79d4dc80 New translations en.json (Chinese Traditional)
[ci skip]
2026-09-14 17:04:03 +02:00
Excalidraw Bot 820247cb17 New translations en.json (Chinese Simplified)
[ci skip]
2026-09-14 17:04:01 +02:00
Excalidraw Bot f5c8115040 New translations en.json (Ukrainian)
[ci skip]
2026-09-14 17:03:59 +02:00
Excalidraw Bot 9c01a17708 New translations en.json (Turkish)
[ci skip]
2026-09-14 17:03:57 +02:00
Excalidraw Bot 3405aea566 New translations en.json (Swedish)
[ci skip]
2026-09-14 17:03:55 +02:00
Excalidraw Bot 45f6a80418 New translations en.json (Slovenian)
[ci skip]
2026-09-14 17:03:53 +02:00
Excalidraw Bot d29e1d0c3b New translations en.json (Slovak)
[ci skip]
2026-09-14 17:03:52 +02:00
Excalidraw Bot 00d8aa0347 New translations en.json (Russian)
[ci skip]
2026-09-14 17:03:50 +02:00
Excalidraw Bot aa0f162860 New translations en.json (Portuguese)
[ci skip]
2026-09-14 17:03:48 +02:00
Excalidraw Bot 86bcee4250 New translations en.json (Polish)
[ci skip]
2026-09-14 17:03:46 +02:00
Excalidraw Bot 2bdfb03711 New translations en.json (Punjabi)
[ci skip]
2026-09-14 17:03:45 +02:00
Excalidraw Bot 810a6da36d New translations en.json (Dutch)
[ci skip]
2026-09-14 17:03:43 +02:00
Excalidraw Bot cffbc64d96 New translations en.json (Lithuanian)
[ci skip]
2026-09-14 17:03:41 +02:00
Excalidraw Bot 2246581627 New translations en.json (Kurdish)
[ci skip]
2026-09-14 17:03:40 +02:00
Excalidraw Bot f4ea0fa6fc New translations en.json (Korean)
[ci skip]
2026-09-14 17:03:38 +02:00
Excalidraw Bot 6aaa281c9e New translations en.json (Japanese)
[ci skip]
2026-09-14 17:03:36 +02:00
Excalidraw Bot d7a1fbdb09 New translations en.json (Italian)
[ci skip]
2026-09-14 17:03:35 +02:00
Excalidraw Bot a0f4737c7b New translations en.json (Hungarian)
[ci skip]
2026-09-14 17:03:33 +02:00
Excalidraw Bot c975a9a030 New translations en.json (Hebrew)
[ci skip]
2026-09-14 17:03:31 +02:00
Excalidraw Bot 6dbf88ecaf New translations en.json (Finnish)
[ci skip]
2026-09-14 17:03:29 +02:00
Excalidraw Bot 2d9b856b8f New translations en.json (Basque)
[ci skip]
2026-09-14 17:03:26 +02:00
Excalidraw Bot 0596d895ad New translations en.json (Greek)
[ci skip]
2026-09-14 17:03:25 +02:00
Excalidraw Bot d6f01bac1b New translations en.json (German)
[ci skip]
2026-09-14 17:03:23 +02:00
Excalidraw Bot 7c1ef7dc6c New translations en.json (Danish)
[ci skip]
2026-09-14 17:03:21 +02:00
Excalidraw Bot 84aff75c63 New translations en.json (Czech)
[ci skip]
2026-09-14 17:03:19 +02:00
Excalidraw Bot 8a4b072d3f New translations en.json (Catalan)
[ci skip]
2026-09-14 17:03:17 +02:00
Excalidraw Bot 5d5176a993 New translations en.json (Bulgarian)
[ci skip]
2026-09-14 17:03:16 +02:00
Excalidraw Bot 668bcae82b New translations en.json (Arabic)
[ci skip]
2026-09-14 17:03:14 +02:00
Excalidraw Bot d1b3fc46dd New translations en.json (Spanish)
[ci skip]
2026-09-14 17:03:12 +02:00
Excalidraw Bot 16a14f32f9 New translations en.json (French)
[ci skip]
2026-09-14 17:03:09 +02:00
Excalidraw Bot 3f00c27f7b New translations en.json (Romanian)
[ci skip]
2026-09-14 17:03:07 +02:00
Excalidraw Bot e458da158f New translations en.json (Thai)
[ci skip]
2026-09-14 17:03:05 +02:00
Excalidraw Bot ea511c3b64 New translations en.json (Indonesian)
[ci skip]
2026-09-14 17:03:04 +02:00
Excalidraw Bot a932256b65 New translations en.json (Arabic)
[ci skip]
2026-09-14 16:05:16 +02:00
Excalidraw Bot c52fd8977e New translations en.json (Italian)
[ci skip]
2026-09-13 11:36:12 +02:00
Excalidraw Bot 2966c5f54f New translations en.json (Italian)
[ci skip]
2026-09-13 10:15:47 +02:00
Excalidraw Bot e5595f909e New translations en.json (Romanian)
[ci skip]
2026-09-10 21:18:03 +02:00
Excalidraw Bot ee1ddd6650 New translations en.json (Karakalpak)
[ci skip]
2026-09-10 17:27:52 +02:00
Excalidraw Bot 62e68adacc New translations en.json (Kabyle)
[ci skip]
2026-09-10 17:27:50 +02:00
Excalidraw Bot 9018742f54 New translations en.json (Bengali, India)
[ci skip]
2026-09-10 17:27:48 +02:00
Excalidraw Bot c17cf418da New translations en.json (German, Switzerland)
[ci skip]
2026-09-10 17:27:46 +02:00
Excalidraw Bot b6dc771b06 New translations en.json (Occitan)
[ci skip]
2026-09-10 17:27:45 +02:00
Excalidraw Bot 9ed61d4106 New translations en.json (Norwegian Bokmal)
[ci skip]
2026-09-10 17:27:43 +02:00
Excalidraw Bot 39bb13b023 New translations en.json (Uzbek)
[ci skip]
2026-09-10 17:27:41 +02:00
Excalidraw Bot 495c586484 New translations en.json (Sinhala)
[ci skip]
2026-09-10 17:27:40 +02:00
Excalidraw Bot c6f08c019f New translations en.json (Chinese Traditional, Hong Kong)
[ci skip]
2026-09-10 17:27:38 +02:00
Excalidraw Bot 6060a68346 New translations en.json (Burmese)
[ci skip]
2026-09-10 17:27:37 +02:00
Excalidraw Bot f0cf4501cf New translations en.json (Hindi)
[ci skip]
2026-09-10 17:27:35 +02:00
Excalidraw Bot 729ec4577c New translations en.json (Azerbaijani)
[ci skip]
2026-09-10 17:27:34 +02:00
Excalidraw Bot ac8f26d3b3 New translations en.json (Latvian)
[ci skip]
2026-09-10 17:27:32 +02:00
Excalidraw Bot 9f3e192a80 New translations en.json (Kazakh)
[ci skip]
2026-09-10 17:27:30 +02:00
Excalidraw Bot 1124c71074 New translations en.json (Norwegian Nynorsk)
[ci skip]
2026-09-10 17:27:29 +02:00
Excalidraw Bot 101bdc5da4 New translations en.json (Marathi)
[ci skip]
2026-09-10 17:27:27 +02:00
Excalidraw Bot a61613b2b4 New translations en.json (Bengali)
[ci skip]
2026-09-10 17:27:26 +02:00
Excalidraw Bot 1f8ac84927 New translations en.json (Tamil)
[ci skip]
2026-09-10 17:27:24 +02:00
Excalidraw Bot d2233e07f9 New translations en.json (Khmer)
[ci skip]
2026-09-10 17:27:22 +02:00
Excalidraw Bot 174631ef9a New translations en.json (Persian)
[ci skip]
2026-09-10 17:27:20 +02:00
Excalidraw Bot ca948152ef New translations en.json (Portuguese, Brazilian)
[ci skip]
2026-09-10 17:27:19 +02:00
Excalidraw Bot 2960dd89f4 New translations en.json (Galician)
[ci skip]
2026-09-10 17:27:17 +02:00
Excalidraw Bot b8ea93536f New translations en.json (Vietnamese)
[ci skip]
2026-09-10 17:27:15 +02:00
Excalidraw Bot 19454e449d New translations en.json (Chinese Traditional)
[ci skip]
2026-09-10 17:27:14 +02:00
Excalidraw Bot 6bcf01e41c New translations en.json (Chinese Simplified)
[ci skip]
2026-09-10 17:27:12 +02:00
Excalidraw Bot 902c234490 New translations en.json (Ukrainian)
[ci skip]
2026-09-10 17:27:09 +02:00
Excalidraw Bot a05e1451bf New translations en.json (Turkish)
[ci skip]
2026-09-10 17:27:07 +02:00
Excalidraw Bot ed0a28874d New translations en.json (Swedish)
[ci skip]
2026-09-10 17:27:05 +02:00
Excalidraw Bot 09e3d22b72 New translations en.json (Slovenian)
[ci skip]
2026-09-10 17:27:04 +02:00
Excalidraw Bot cf20edda93 New translations en.json (Slovak)
[ci skip]
2026-09-10 17:27:01 +02:00
Excalidraw Bot 4ef20d854f New translations en.json (Russian)
[ci skip]
2026-09-10 17:26:59 +02:00
Excalidraw Bot 9056010fec New translations en.json (Portuguese)
[ci skip]
2026-09-10 17:26:57 +02:00
Excalidraw Bot ae8c9a7c87 New translations en.json (Polish)
[ci skip]
2026-09-10 17:26:56 +02:00
Excalidraw Bot 75c6f1d96e New translations en.json (Punjabi)
[ci skip]
2026-09-10 17:26:54 +02:00
Excalidraw Bot 50336bdc46 New translations en.json (Dutch)
[ci skip]
2026-09-10 17:26:53 +02:00
Excalidraw Bot 31904e22bf New translations en.json (Lithuanian)
[ci skip]
2026-09-10 17:26:51 +02:00
Excalidraw Bot bdec0c36d9 New translations en.json (Kurdish)
[ci skip]
2026-09-10 17:26:49 +02:00
Excalidraw Bot 1a420e4786 New translations en.json (Korean)
[ci skip]
2026-09-10 17:26:47 +02:00
Excalidraw Bot 737254ce8c New translations en.json (Japanese)
[ci skip]
2026-09-10 17:26:46 +02:00
Excalidraw Bot 0bbda27b9d New translations en.json (Italian)
[ci skip]
2026-09-10 17:26:44 +02:00
Excalidraw Bot da97f0e212 New translations en.json (Hungarian)
[ci skip]
2026-09-10 17:26:42 +02:00
Excalidraw Bot 7357c0a463 New translations en.json (Hebrew)
[ci skip]
2026-09-10 17:26:40 +02:00
Excalidraw Bot d2ae2c2428 New translations en.json (Finnish)
[ci skip]
2026-09-10 17:26:39 +02:00
Excalidraw Bot 4108f9601c New translations en.json (Basque)
[ci skip]
2026-09-10 17:26:37 +02:00
Excalidraw Bot 29adf71122 New translations en.json (Greek)
[ci skip]
2026-09-10 17:26:35 +02:00
Excalidraw Bot aaa2c435c1 New translations en.json (German)
[ci skip]
2026-09-10 17:26:33 +02:00
Excalidraw Bot 2b9d6e0a8d New translations en.json (Danish)
[ci skip]
2026-09-10 17:26:32 +02:00
Excalidraw Bot 2fa61536ca New translations en.json (Czech)
[ci skip]
2026-09-10 17:26:30 +02:00
Excalidraw Bot 4d1751ad9e New translations en.json (Catalan)
[ci skip]
2026-09-10 17:26:27 +02:00
Excalidraw Bot 8c2fda1635 New translations en.json (Bulgarian)
[ci skip]
2026-09-10 17:26:26 +02:00
Excalidraw Bot f3113d908e New translations en.json (Arabic)
[ci skip]
2026-09-10 17:26:24 +02:00
Excalidraw Bot 5aa3f1f464 New translations en.json (Spanish)
[ci skip]
2026-09-10 17:26:22 +02:00
Excalidraw Bot 97c11b3910 New translations en.json (French)
[ci skip]
2026-09-10 17:26:21 +02:00
Excalidraw Bot accf5be283 New translations en.json (Romanian)
[ci skip]
2026-09-10 17:26:19 +02:00
Excalidraw Bot 5820601aa2 New translations en.json (Thai)
[ci skip]
2026-09-10 17:26:17 +02:00
Excalidraw Bot c9bf19c09e New translations en.json (Indonesian)
[ci skip]
2026-09-10 17:26:15 +02:00
David LuzarandClaude Fable 5.1 afa3a653fc feat(editor): sticky notes (#12064)
* feat(editor): sticky notes

* clamp sticky note font size

* [dragInput] reject non-finite values

* chore: prettier formatting after rebase

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* chore(editor): sticky notes cleanup (plan §7.1)

- hide `transparent` in the sticky stroke picker through master's
  `excludedColors` (#11872) instead of the branch's own
  `hiddenPaletteColorNames`; the swatch becomes a grid-preserving
  placeholder, its hotkey stays dead, and arrow-key navigation steps over
  it in the arrow's direction (unit + integration tests)
- move the text pixel-snap change out to `dwelle/text-pixel-snap`
  (705e497e2): it affects every text element at every zoom and is not
  zoom-aware, so it lands separately
- rename `STICKY_NOTE_DEFAULT_FONT_SIZE` → `STICKY_NOTE_FALLBACK_FONT_SIZE`:
  it is only the non-finite fallback; new sticky text seeds its ceiling from
  `currentItemFontSize` (decision D3)

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* feat(editor): one sticky note layout core + resize intents (plan §7.2, 1/2)

- `getStickyNoteLayout` is the single fit/grow/position calculation (pure;
  ceiling-anchored size grid, warm-started binary search instead of the
  linear descent); `updateStickyNoteLayout` applies it to a scene and runs
  the bound-arrow pass by default (callers forward `simultaneouslyUpdated`
  or opt out when they own a pass); `relayoutStickyNotes` applies it inside
  immutable element arrays, identity- and version-preserving
- resize intents are absolute values recomputed from the gesture-start
  snapshot on every pointer-move (`getStickyNoteResizeIntent`): width-only
  gestures keep the base height, height gestures set it, proportional
  gestures (Shift on any handle, aspect-locked multi-select, Stats group)
  also scale the font ceiling, flips preserve everything; the content
  correction anchors at the held edge (n* → bottom, Alt → center,
  Shift+E/W → center). The corner pre-fit clamp and the multi-resize
  sentinel/veto are gone: one fit per pointer-move
- arrows bound to a note follow text-driven growth, and multi-resize /
  Stats group update them after the content correction, not before
- `fontSizeMax` is `number | null`; every read/write goes through the
  container-aware `getUserFontSize` / `getUserFontSizeUpdate` (a detached
  label with a stale ceiling neither shows nor keeps writing it); unbind
  clears it and returns an empty note to its base height; restore seeds or
  clears it after binding repair and normalizes a sticky label's stroke
- paste styles lays out a restyled note and its label together in a
  post-pass (no phantom-container mismatch) and reads the source ceiling
  from the copied snapshot rather than the fitted size
- `redrawTextBoundingBox` / `handleBindTextResize` delegate sticky notes up
  front (no wasted generic wrap); the `refreshTextDimensions` sticky arm and
  the wysiwyg duplicate arm are gone; `clampStickyNoteProps` becomes the
  identity-preserving `normalizeStickyNote`

Tests: content-pinned corner drag keeps the requested base, Shift+E scales
note/base/ceiling, width-only drag preserves the base of a grown note,
Shift released mid-gesture restores gesture-start values, flipping a rotated
grown note keeps base/ceiling/label angle, multi-resize syncs an empty
note's base.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* feat(editor): sticky note invariants in changeProperty + regression tests (plan §7.2, 2/2)

- `changeProperty` normalizes sticky notes after any property write
  (identity-preserving `normalizeStickyNote`), replacing the three per-action
  wraps in the background, sloppiness and roundness actions
- tests (new `tests/stickyNotes.test.tsx` + restore): arrows bound to a
  note follow text-driven growth while typing; binding free text seeds the
  ceiling and unbinding clears it (`null`) and returns the note to its base
  height; pasting styles copies the user's ceiling and lays out the note
  together with its label; unselected notes keep object identity through
  property actions; a transparent background write is normalized; restore
  clears a stale ceiling on unbound text and seeds ceiling + visible stroke
  on a sticky label

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* feat(editor): sticky note creation feel + sticky color domain (plan §7.3)

Creation
- a note starts as a 0×0 draft inserted like every other tool and is
  previewed at its true dragged size (no jump to 40px on the first move);
  a gesture under the drag threshold is a click → the default 250×250
  square centered on the pointer; a longer drag snaps to the 40×40 minimum
  only on release, growing away from the origin corner. The preview
  suppression flag and the pointer-move sticky arm are gone
- `normalizeStickyNote` splits into style (constructor + every pass) and
  geometry (finalize, restore, action passes) so a draft is legal

Colors — sticky notes are their own color domain (decision D2)
- new `currentItemStickynoteBackgroundColor` default (yellow) next to the
  stroke one; both persisted/restored non-transparent; new notes and their
  labels seed from the sticky defaults (a label takes the note's stroke)
- own top-pick slots (`colorTopPicks.stickyNoteStroke/Background`) and
  classic note picks; transparent excluded like bucket fill
- one `resolveColorTarget` (regular / sticky / mixed, incl. the text being
  edited) resolved at execution time in the stroke and background actions'
  `perform`; the panel only sends `{ color }`, so the memoized picker's
  stale closure can no longer write the wrong tool's default (A1)
- one `getColorUpdate` policy used by the actions, both eyedroppers (which
  now include bound labels, so a stroke pick colors a note's visible text —
  1.12), paste styles and bind-existing-text (a transparent label bound to
  a note takes the note's text color — A3); the per-surface forks and
  `isStickyNoteStrokeColorElement` are gone

Tests: click under the threshold, 20px drag previews at 20 and snaps to 40
with the origin corner fixed (both directions); closed-popup top pick after
a tool switch writes the sticky default; mixed selection writes both
domains; notes are created from the sticky defaults; bind normalizes a
transparent label; the keyboard eyedropper colors a note's label. App-state
snapshots gain the three new keys.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* feat(editor): font-aware minimum sticky note size

The 40×40 floor let a freshly placed note grow on the very first keystroke:
one line at the default 20px ceiling plus the 16px padding needs 57px.

- `STICKY_NOTE_MIN_SIZE = 75` replaces the width/height pair as the data
  floor (normalization, layout, restore)
- `getStickyNoteMinSize({ fontSize, fontFamily })` =
  max(75, round(ceiling × lineHeight) + 2 × padding) is the UI floor: the
  creation snap on release uses the current font size/family, the single
  resize floor uses the label's ceiling (constant floor for an empty note).
  Excalifont: 75 up to the L ceiling, 77 at 36px, 92 at 48px

Tests: the 20px drag now snaps to 75; a 48px ceiling yields a 92px note
that keeps its height on the first character.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* test(editor): sticky note regression matrix (plan §7.4) + two Stats fixes

Tests (27 new): Alt/center resize keeps the center of a content-pinned
note; proportional multi-select scales the label's ceiling, a free
multi-select of an empty note uses the requested height; a proportional
gesture returning to scale 1 restores everything; rotated growth keeps the
top edge; the fit takes ≤ 2 measurements on a warm keystroke and ≤ 12 on a
cold search from a 512 ceiling; a lowered ceiling wins over a still-fitting
old size; odd/fractional ceilings stay reachable on the ceiling-anchored
grid; the font-aware minimum size. Bound arrows follow the note on unbind,
after a content-constrained resize and after a font-size action, and are
not moved twice when resized as part of the same selection. A top pick
while editing a note's label targets the label and the sticky default; the
preview eyedropper colors a selected note's label; paste styles resolves a
since-deleted source's ceiling from the copied snapshot; a detached label
with a stale ceiling neither shows nor writes it; duplication keeps the
ceiling and base height; an empty note escaped from editing stays and is
removed by undo; Stats W keeps the base height and a group W edit scales
the ceiling; restore normalizes both sticky defaults, keeps the new
top-pick slots and refits note + label under `refreshDimensions`; unit
tests for `resolveColorTarget` (regular / sticky / mixed / editing) and the
default normalization.

Source fixes the tests surfaced:
- Stats `DragInput`: the typed-value path handed callbacks the live
  elements map as "original", so gesture-start values (a note's width, its
  label's ceiling) read as the already-applied values; it now snapshots the
  scene like the pointer-drag path does
- Stats group resize (`MultiDimension`): a group unit lists the note's
  label too and scaled it as a standalone element after the note, wiping
  the fitted size; the note's layout owns its label

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* feat(editor): sticky note skeleton API, tool hint, help row, docs (plan §7.5)

- `convertToExcalidrawElements` accepts `{ type: "stickynote", x, y, label?,
  ... }` (new `ValidStickyNote` skeleton member): the note is constructed
  through `newStickyNoteElement` + `normalizeStickyNoteGeometry` (250×250
  default, `baseHeight` finalized, sticky color invariants), and a label is
  bound through the sticky fit so its `fontSize` becomes the ceiling and the
  note grows only if the text overflows at the minimum size (decision D7)
- tool hint for the sticky note tool ("Click to place a note, or drag to
  size it") and the `N` shortcut row in the help dialog
- dev-docs: "Sticky Notes" section in the element-skeleton page; CHANGELOG
  entry (PR number placeholder)

Tests: skeleton defaults, invariant enforcement on given props, label
binding with the ceiling and the note's text color, growth for an
overflowing label.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* refactor: move the seeded PRNG (mulberry32) to @excalidraw/common

Pure utility next to `randomInteger`/`reseed`; the sticky note corner
jitter imports it from there.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* refactor(editor): drop the dead roughjs Drawable for sticky notes

Nothing ever generated or drew it: both renderers paint a note directly from
`getStickyNoteRenderPoints`, and every `ShapeCache` consumer is type-gated
to the rectangle family or linear elements. It also disagreed with the
rendered outline (generic corner radius, no jitter). Sticky notes now sit
with text/image/frame in the `null`-shape group, which the cache treats
as a valid value.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* feat(editor): sticky note creation-date footer

Every note reserves a 32px band under its label body and paints the
note's `created` date there, on canvas and in SVG export: "7 Sep" while
the year is the current one, "7 Sep 2025" otherwise, nothing for an
unknown timestamp (the band stays reserved so geometry never depends on
data availability). The label form is chosen by width bucket, never
measured, so painting stays measurement-free — server-side SVG export has
no text measurer — and an absolute date means exports never go stale.

- `STICKY_NOTE_FOOTER` and `STICKY_NOTE_BODY_INSET_Y` in @excalidraw/common;
  `getStickyNoteDateLabel` / `getStickyNoteFooter` in stickyNote.ts
- the layout, `getBoundTextMaxHeight`, the text editor and both renderers
  share the one inset; a rotated label rotates about the note center, not
  the now-asymmetric body center
- `getStickyNoteMinSize` returns `{ width, height }`; creation and resize
  clamp each axis to its own floor (a Shift drag stays square)
- tests: date label, width buckets, body height, resize floor, SVG + canvas
  export, creation minimums

Plan: sticky_notes_review_final.md §9.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* feat(editor): image tool into "More tools", letter shortcut badges

With the sticky tool on N between text (8) and eraser (0), the numbered
badge row no longer reads as a sequence. On desktop and tablet, move the
image tool into the "More tools" menu (first entry, still `9`,
`UIOptions.tools.image` keeps hiding it) and badge the toolbar tools
with their letter shortcuts (V R D O A L P T N E) instead of numbers.
The number keys keep working; tooltips and the help dialog are unchanged.
The mobile toolbar keeps its image slot.

- Toolbar.tsx: image item in `ExtraToolsDropdown` (trigger highlight and
  icon while active), `UIOptions` passed through for the gate
- Tools.tsx: `keyBindingLabel` prefers the letter
- tests: `UI.clickExtraTool`, image test uses it, toolbar describe in
  tool.test.tsx (menu placement, UIOptions gate, letter badges)

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* fix(editor): a sticky note and its label share one ink

The creation-date footer paints from the note's `strokeColor`, the label
from its own, and three writes let them drift: coloring the label while
editing it (the wysiwyg clears the selection, so `changeProperty` targets
only the edited text), binding an already-colored text (only transparent
was rewritten), and paste styles from a source whose container and label
carry two colors. Red text under a black date.

The note's `strokeColor` is its ink — the seed for a new label and what
the footer paints with — and the label always mirrors it:

- `syncStickyNoteInk(elements, prevElementsMap)`: after a write, copies
  the side that changed onto the other; when both changed or the data
  drifted, the label wins (it is the text the user styled); a transparent
  label takes the note's color; identity-preserving when nothing changes
- entrances: the stroke action (after `changeProperty`, which covers edit
  mode), paste styles (before the relayout), bind text (the text's color
  wins, transparent falls back), the skeleton path (a label that sets its
  own color gives it to the note), restore (label wins over a drifted note)
- the stroke picker reads "Text color" for notes (`labels.textColor`)
- tests for the helper and for every entrance

Plan: sticky_notes_review_final.md §12.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* make footer opacity=1

* chore: fix sticky note test import order

* feat(editor): drag a tool out of the toolbar to drop a default-sized element

Press a tool button and drag onto the canvas: a default-sized element
follows the pointer as a translucent preview and is placed where the
pointer is released. Sticky notes opt in; the mechanism is generic.

`App.toolDrag.ts` owns the gesture. `DRAGGABLE_TOOLS` is the registry —
a tool provides `createElement(app, center)` (the element it drops,
centered on the pointer, from the same `currentItem*` defaults a click
uses) and an optional `onDrop` (a sticky note starts editing its label).
Adding a tool is one entry.

The preview exists only on the unit until the drop: not in the scene, the
store or the history, so collaborators never see it and Escape,
pointercancel, window blur or a release outside the canvas leave nothing
behind. It is painted by the existing `NewElementCanvas`, drawn exactly as
it will land, with the canvas itself made translucent (a CSS opacity on
the canvas — per-draw-call alpha would darken where a shadow and a fill
overlap).

The button arms on pointerdown (a new `onPointerDown` hook on the toggle
`IconButton`) and the drag starts once the pointer has moved
`DRAGGING_THRESHOLD`, so a plain click still selects the tool. Listeners
live on the owner window like the other pointer gestures. The drop is
`insertNewElement` + one store capture (one undo entry), selects the
element and switches to the preferred selection tool — whatever tool was
active (an arrow, say) must not stay armed under the dropped element or
its editor. The hit test prefers `elementFromPoint` (sees through a
touch's implicit capture on the button) and falls back to the event
target where it is missing (jsdom).

Made public on App for the unit: `store`, `startTextEditing`,
`getCurrentItemRoundness`, `getCurrentItemStrokeWidth`.

Tests: drag → preview with an untouched scene and history → drop centered
at the pointer with one history entry, the editor open and the selection
tool active, undo removes it; Escape mid-drag leaves nothing and a late
pointerup is inert; a release outside the canvas drops nothing; a plain
click still selects.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* fix(editor): default a text element's fontSizeMax to null

`newTextElement` left `fontSizeMax` undefined while restore normalizes it
to `null`. `Delta.distinctKeysIterator` compares with `!==`, so a
restored copy of a locally created text element reported a phantom
`fontSizeMax` change — today only in collab reconciliation deltas, which
are not undoable, but a latent source of spurious version bumps.

Normalize to `null` in the constructor, like `labelPosition`. Snapshots
change accordingly (`undefined` → `null`), including the embedded scene
payload in the SVG export snapshot.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* chore(editor): drop a duplicated scheduleCapture in the sticky note finalize

A paste error: the two calls in one tick set the same flag.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* fix(editor): decide history visibility from the applied result

Creating a sticky note and abandoning its editor by clicking out left two
history entries: the note's creation, and one that only deleted the empty
label and dropped the note's reference to it — nothing a user can see, so
the first undo appeared to do nothing. (Escape left the same deletion
pending instead, folded into the next entry.) The text tool's own
click-create-and-abandon had the same invisible entry.

History already skips entries with no visible change, but visibility was
decided per property patch, before binding repair and layout ran: an
`isDeleted` flip or a `boundElements` edit always counted, and anything
layout moved afterwards never did.

`ElementsDelta.applyTo` now decides it from the applied result. After the
deltas, reordering, binding repair and text/arrow layout, the "before"
and "after" of every affected element are compared:

- `idsToCheck` = the changed elements plus what layout can move for them:
  a text's container and everything in `boundElements` (a container's
  label and arrows); the set is walked as it grows, so an arrow brings in
  its own label.
- Unchanged elements are shared between the incoming map and the result
  and layout mutates them in place, so their "before" is a shallow copy;
  changed elements already have a fresh instance. The whole scene is
  never copied.
- An element counts as visible only if it is not deleted and, for text,
  not empty — except an empty label carrying arrow bindings or sitting on
  an arrow, where it cuts the gap. `version`, `versionNonce`, `updated`,
  `created` are metadata and `index` is covered by the z-order check;
  `boundElements` is compared as `type:id` strings with references to
  empty labels dropped, so binding repair that rebuilds equal objects
  reads as unchanged. Everything else counts.

A dev/test-only assertion records every element's version before layout
and throws if layout bumped one outside `idsToCheck`, so a new layout
dependency fails a test instead of letting undo silently skip a real
change. For that to work the `return` had to leave the `finally` block: a
return in `finally` overrides a thrown exception, so the existing
dev/test rethrow for failures in reordering and layout never actually
propagated. Production behavior is unchanged — the catch still returns
with the flag forced to visible.

Cost, production build, median per `applyTo`: sparse changes at parity
through 50k elements; 10k visible bulk updates +13%; 10k metadata-only
updates 44 → 95 ms, because proving "nothing visible" cannot stop at the
first element — the case the old code answered wrongly. `applyTo` runs
on undo/redo and the version slider, both user-paced.

Creation and typing still undo separately, and a drop from the toolbar
still records at the drop. Tests: delta-level (empty-label deletion and
restoration on a rectangle and a sticky note ignored; a non-empty label,
an arrow's label gap, a container resized by a restored label, arrow
bindings on empty text, a label and an arrow repositioned by a container
redraw all visible; excluded or already-applied properties and a
metadata-only update invisible; snapshot restoration visible) and the
sticky note flows (Escape, click-out, font change on an empty label,
typing).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* fix(editor): snap sticky note placement to the grid

* perf(editor): ignore arrow bindings in sticky layout input check

* feat(editor): center a sticky note's label in the whole note, tighten the footer

A middle-aligned label was centered in the body above the creation-date
footer, so it sat visibly high — half a footer above the note's center.
It is now centered in the whole padded note (`height − 2·padding`), the
footer ignored, and only pushed up against the body's bottom once
centering would run it into the footer. The fit and the growth rule are
unchanged: the note still grows at the same label height, no vertical
budget is lost, and top and bottom alignment are as before. Rotation
already turned the label about the note's center.

The footer takes less room: its band shrinks from 32px to 20px and the
date's baseline is defined from the note's bottom edge (14px) rather than
from the padding, so the glyphs sit ~11px from the edge (was 17) with
~13px to the label body above (was 19). The vertical inset follows
(64 → 52), and with it the font-aware minimum note sizes.

Tests: a middle-aligned label is centered on the note's center at three
angles; centering is clamped only once it would overlap the footer;
existing sizes derive from the inset constant.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* feat(editor): resize sticky notes proportionally by default

* fix(editor): preserve resize flips and text container anchors

Apply minimum sizes and proportional font scaling to dimension magnitudes, then restore their signs for shared flip geometry.

Share rotation-aware positioning for text-driven height changes and pass vertical flip state through single and multiple-element resizing.

Add regression coverage for mirrored resizing, proportional minimum sizes, and text growth after crossing the resize anchor.

* feat(editor): lift a seeded sticky note corner at high roughness

Curve one corner at roughness 2, chosen from the note seed so it stays consistent through redraws and resizes. Pull its shadow inward by half the bend while retaining the existing down-right offset.

Share the generated paper and shadow paths between Canvas and SVG rendering.

* fix(editor): share the default sticky note color with its quick pick

* refactor(editor): rename a label's `fontSizeMax` to `baseFontSize`

The field holds the font size the user picked, from which the sticky note
layout derives the fitted `fontSize`. "Max" described today's fitter (it
only shrinks); "base" names the intent and pairs with the note's
`baseHeight`, the other user-set value the layout derives from. Renamed
before the field ships, since it is persisted in scene files.

The type is now required (`number | null`): the constructor and restore
already always write it, and history deltas compare with strict
inequality, so an omitted key against `null` would record a spurious
change. Making it required lets tsc catch producers that omit it (one test
fixture did). Helpers follow: `getBaseFontSize`, `getBaseFontSizeUpdate`.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* fix(editor): slightly strengthen sticky note edges

* feat(editor): color a sticky note's background while editing its label

While editing a note's label the styles panel targets the label, which has
no fill, so the background picker was gone and the note's color could only
be changed after leaving the editor. The picker now stays available while
editing, shows the note's fill, and a pick colors the note — text color and
background in one editing pass.

One helper, `getColorTargetElement`, decides where a color pick on an
element lands (a background pick on a note's label goes to the note); the
target resolver, the panel predicate, the panel value and the action all
use it, so the picker also gets the sticky domain (sticky picks, no
transparent, the sticky default written).

Also fixes a latent bug in the background action: with nothing selected,
`selectedElements.every(isLine…)` was vacuously true, so the action took
the polygon branch and did nothing. Unreachable before, since no background
picker was available without a selection.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* fix changelog

* fix snaps

* more snap

---------

Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-10 16:45:37 +02:00
David LuzarandClaude Fable 5.1 6574de60b3 feat(editor): snap element bitmaps, scroll and grid to whole device pixels (#12063)
* feat(editor): blit cached element bitmaps on whole device pixels

Elements are painted from cached bitmaps, blitted 1:1 with smoothing
disabled for unrotated and right-angle elements. Nearest-neighbor at a
fractional offset is then a pixel-exact copy shifted to the nearest
pixel — except at an exact half pixel, where a GPU-accelerated canvas
decides the rounding per scanline by float precision: a few rows sample
the neighboring row and glyph strokes come out doubled or broken, varying
with scroll and position. A centered label lands on a half pixel
routinely (odd free space ÷ 2), which is how it showed up on sticky notes.

Measured in Chrome: a label blitted at y = 639.5 had 3 of 82 scanlines
taken from the adjacent row; rounded to 640 it was clean.

Round the blit position to whole device pixels for those elements. The
context is scaled at the blit (canvas scale × zoom ÷ devicePixelRatio),
so the rounding goes through `context.getTransform()`: rounding the
pre-zoom value only lands on device pixels at 100% zoom. Rotated
elements are resampled regardless and are left alone; export draws
directly and is unaffected.

Tests: the static canvas's `drawImage` events, with their CTM, land on
whole device pixels for text and a rectangle at zoom 1, 1.5, 0.73 and
2.2 under fractional scroll; a rotated element keeps its offset.

Extracted from dwelle/sticky-notes-2 (originally eddb67ca0).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* feat(editor): draw the canvases at a whole-device-pixel scroll

Panning writes a fractional scroll (pointer delta ÷ zoom). With every
bitmap landing on its nearest device pixel, a fractional scroll means
elements with different sub-pixel phases jump on different frames — a
one-pixel wobble between neighbors, a label shifting against its box —
and one scroll value in every pixel puts every integer-positioned
element on the half-pixel boundary at once.

Round the scroll to whole device pixels at render time, in all three
renderers with the same formula so overlays sit on the content — the way
a browser scrolls a page: each element keeps its own constant sub-pixel
phase and the scene moves rigidly. The real scroll in state is untouched,
so hit-testing and DOM overlays are unaffected; the difference stays
under half a device pixel. Export is excluded (it draws vectors).

Tests: the helper rounds to device pixels and is identity when already
whole; a rectangle at integer coordinates blits on whole device pixels at
zoom 1 and 1.5 under fractional scroll, and a scroll a tenth of a pixel
further lands on the same pixels.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* feat(editor): draw grid lines on whole device pixels at every zoom

The grid was nudged onto the pixel grid only at 100% zoom, and only when
the scroll happened to be whole; at any other zoom its one-pixel lines
landed on fractional device pixels and rendered as two lighter pixels.

Draw each line a whole number of device pixels wide, centered to cover
them exactly (on the half pixel when the count is odd), computed from the
zoom and the canvas scale — which also makes "one pixel" one device pixel
group at devicePixelRatio 2 instead of a fractional pair. Lines thinner
than a device pixel (zoomed far out) keep their sub-pixel width: their
lightness is the point. The zoom-1-only nudge is subsumed.

Test: with grid mode on, every grid path start on the static canvas has
its across-axis coordinate on a half device pixel at zoom 1, 1.5 and 2.2
under fractional scroll.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* fix(editor): snap right-angle blits via the transform origin; grid width in device pixels

Two review findings.

Elements rotated by 90° or 270° enter the snapping branch (smoothing is
off for right angles too), but their transform has near-zero `a` and `d`
with the scale in `b`/`c`. Dividing by the diagonal sent the bitmap far
offscreen — a rectangle at (110.37, 120.61) vanished at both angles.
The snap now moves the transform's origin onto the rounded device
position of the blit and draws at (0, 0): rotation, mirroring and scale
stay in `a`–`d`, no inverse is needed, and the origin is an exact integer
even in the canvas's float32 matrix (mapping a rounded position back
through user space left it ~1e-5 off).

The grid's "at least one device pixel wide" test compared
`(1 / zoom) × zoom × scale` against 1 exactly; at 190% that product is
0.9999999999999999, so those lines skipped snapping and stayed blurry
while neighboring zooms were crisp. The width is now computed straight
in device pixels, `min(scale, max × zoom × scale)`, so there is no round
trip to tolerate.

Tests: rectangles at π/2 and 3π/2 blit on whole device pixels and stay
next to the element; the grid test includes 190%. The blit helper in the
test maps positions through the full matrix. The CHANGELOG bullets link
the PR.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* refactor(editor): one devicePixelRatio read for the blit, no-op padding scaling dropped

`drawElementFromCanvas` read `window.devicePixelRatio` eleven times; it
reads it once. That read stays the global one on purpose: the cached
bitmap was generated with it (`generateElementCanvas`) and the blit's
size math needs the same value, so switching this path to the owner
window's ratio is a separate change that has to include bitmap
generation. `(padding × scale) / scale` is `padding`.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* fix(editor): snap blits only where smoothing is off, through one predicate

The snap ran during zoom gestures too, where the cached bitmaps stay at
the old scale and are resampled with smoothing on: rounding there could
only make elements twitch as the scale changes.

The smoothing switch and the snap now share `canSnapElement` — unrotated
or at a right angle, and not mid-gesture — so the two cannot drift
apart. It is eligibility, not a guarantee of a 1:1 copy: freedraw is
blitted with smoothing on and a size-capped cache is rescaled; snapping
is harmless for both.

Test: with `shouldCacheIgnoreZoom` set, the blit keeps its fractional
position.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* fix(editor): snap a label relative to its container, tie-safe

Each bitmap rounding its own origin lets a label shift by a pixel against
its box as a drag crosses half pixels (nearest-neighbor sampling did the
same implicitly before the explicit snap). A label under an unrotated
container now anchors to the container's rounded origin and rounds its
offset from it separately, so both land on whole pixels and their
distance never changes; every other element anchors to itself with a
zero offset, through the same code path.

The offset is formed from scene coordinates before the scroll is added,
and a tiny bias breaks an exact half-pixel tie the same way every frame.
Ties are common — any odd scene offset at 150% — and the float noise of
a fractional drag otherwise flips `Math.round` between the two neighbors:
a 200-wide box with a centered label went 92 → 91 → 92 device pixels
across drags of 0, 0.3 and 0.4.

Tests: a plain fractional offset and the half-pixel tie keep a constant
device offset across five fractional drags at 150%.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-09 14:39:59 +02:00
David LuzarandMárk Tolmács 854d00c31b feat(editor): track element creation timestamps (#12060)
feat(editor): element creation timestamp tracking

Co-authored-by: Márk Tolmács <mark@lazycat.hu>
2026-09-08 15:26:14 +02:00
Excalidraw Botanddwelle 0361bdd9cb chore(editor): Update translations from Crowdin (#11942)
* New translations en.json (Italian)

[ci skip]

* New translations en.json (Persian)

[ci skip]

* New translations en.json (Swedish)

[ci skip]

* New translations en.json (Slovenian)

[ci skip]

* New translations en.json (Slovenian)

[ci skip]

* New translations en.json (Indonesian)

[ci skip]

* New translations en.json (Indonesian)

[ci skip]

* New translations en.json (Thai)

[ci skip]

* Auto commit: Calculate translation coverage

---------

Co-authored-by: dwelle <5153846+dwelle@users.noreply.github.com>
2026-09-07 22:32:05 +02:00
David Luzar 2b1c2a9fbf fix(editor): dismiss dropdown menus on outside clicks (#12053)
fix: dismiss dropdown menus on outside clicks
2026-09-07 17:28:20 +02:00
David LuzarandClaude Fable 5.1 f1a79b73df perf(editor): stop inverting the interactive canvas via CSS filter in dark mode (#12050)
The interactive canvas (selection outlines, transform handles, binding
highlights, collaborator cursors, snap lines, scrollbars) was still
composited through `filter: invert(93%) hue-rotate(180deg)` in dark mode
(#10578 only de-filtered the static canvas). The canvas covers the whole
viewport and repaints on nearly every pointer move, so browsers that
composite in software (Firefox/Zen on software WebRender or with hardware
acceleration disabled) paid for a full-viewport color-matrix pass per frame
and dropped from 120fps to ~40fps during marquee/drag. Light mode was
unaffected.

Colors are now mapped in JS via `applyDarkModeFilter`, like the static
canvas. Hardcoded dark-mode values are the exact post-filter equivalents of
the previous ones, so rendering is unchanged, with two intentional
exceptions:

- collaborator cursors and remote selection outlines render in the
  collaborators' actual colors (matching their avatars) instead of inverted
  ones (per the existing TODO in clients.ts)
- the lasso trail now uses the theme's `--color-selection` like the marquee
  selection, instead of a hardcoded light-theme purple that never matched in
  dark mode

`--color-selection` for the dark theme now holds the actual rendered color
(#b4b0ff); hosts overriding it no longer need to pre-invert.

Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-07 11:52:22 +02:00
214cd6e6e8 feat(editor): allow for dragging bound text along an arrow's path (#10947)
Arrow labels can be dragged along their arrow. A label's place is stored on the text element as labelPosition, a normalized arc-length parameter (0–1) over the arrow's whole path, and the label's x/y is derived from it. Because the parameter covers the whole path rather than one segment, the label keeps its place when points are dragged, when midpoints are inserted or removed, and when the arrow is resized. (An earlier iteration anchored labels to a segment; that approach is gone.)

Closes #9338.
---------

Signed-off-by: Mark Tolmacs <mark@lazycat.hu>
Co-authored-by: Mark Tolmacs <mark@lazycat.hu>
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Co-authored-by: dwelle <5153846+dwelle@users.noreply.github.com>
2026-09-03 17:21:13 +02:00
zsvicziananddwelle e1bb9ff8f8 feat(packages/excalidraw): support rendering into another document (#11974)
* feat: support rendering into another document

* add missed places

* tweak jsdoc

---------

Co-authored-by: dwelle <5153846+dwelle@users.noreply.github.com>
2026-08-26 15:33:04 +02:00
David Luzar 4a6f4e7a32 feat(app): d2c streaming (#11960)
* support d2c streaming

* render status spinner while streaming

* enable analytics for ai
2026-08-25 16:08:37 +02:00
Excalidraw Botanddwelle e160ff7ba0 chore(editor): Update translations from Crowdin (#11815)
* New translations en.json (Romanian)

[ci skip]

* New translations en.json (Romanian)

[ci skip]

* New translations en.json (Romanian)

[ci skip]

* New translations en.json (French)

[ci skip]

* New translations en.json (Spanish)

[ci skip]

* New translations en.json (Arabic)

[ci skip]

* New translations en.json (Bulgarian)

[ci skip]

* New translations en.json (Catalan)

[ci skip]

* New translations en.json (Czech)

[ci skip]

* New translations en.json (Danish)

[ci skip]

* New translations en.json (German)

[ci skip]

* New translations en.json (Greek)

[ci skip]

* New translations en.json (Basque)

[ci skip]

* New translations en.json (Finnish)

[ci skip]

* New translations en.json (Hebrew)

[ci skip]

* New translations en.json (Hungarian)

[ci skip]

* New translations en.json (Italian)

[ci skip]

* New translations en.json (Japanese)

[ci skip]

* New translations en.json (Korean)

[ci skip]

* New translations en.json (Kurdish)

[ci skip]

* New translations en.json (Lithuanian)

[ci skip]

* New translations en.json (Dutch)

[ci skip]

* New translations en.json (Punjabi)

[ci skip]

* New translations en.json (Polish)

[ci skip]

* New translations en.json (Portuguese)

[ci skip]

* New translations en.json (Russian)

[ci skip]

* New translations en.json (Slovak)

[ci skip]

* New translations en.json (Slovenian)

[ci skip]

* New translations en.json (Swedish)

[ci skip]

* New translations en.json (Turkish)

[ci skip]

* New translations en.json (Ukrainian)

[ci skip]

* New translations en.json (Chinese Simplified)

[ci skip]

* New translations en.json (Chinese Traditional)

[ci skip]

* New translations en.json (Vietnamese)

[ci skip]

* New translations en.json (Galician)

[ci skip]

* New translations en.json (Portuguese, Brazilian)

[ci skip]

* New translations en.json (Indonesian)

[ci skip]

* New translations en.json (Persian)

[ci skip]

* New translations en.json (Khmer)

[ci skip]

* New translations en.json (Tamil)

[ci skip]

* New translations en.json (Bengali)

[ci skip]

* New translations en.json (Marathi)

[ci skip]

* New translations en.json (Thai)

[ci skip]

* New translations en.json (Norwegian Nynorsk)

[ci skip]

* New translations en.json (Kazakh)

[ci skip]

* New translations en.json (Latvian)

[ci skip]

* New translations en.json (Azerbaijani)

[ci skip]

* New translations en.json (Hindi)

[ci skip]

* New translations en.json (Burmese)

[ci skip]

* New translations en.json (Chinese Traditional, Hong Kong)

[ci skip]

* New translations en.json (Sinhala)

[ci skip]

* New translations en.json (Uzbek)

[ci skip]

* New translations en.json (Norwegian Bokmal)

[ci skip]

* New translations en.json (Occitan)

[ci skip]

* New translations en.json (German, Switzerland)

[ci skip]

* New translations en.json (Bengali, India)

[ci skip]

* New translations en.json (Kabyle)

[ci skip]

* New translations en.json (Karakalpak)

[ci skip]

* New translations en.json (Indonesian)

[ci skip]

* New translations en.json (Romanian)

[ci skip]

* New translations en.json (Romanian)

[ci skip]

* New translations en.json (French)

[ci skip]

* New translations en.json (Spanish)

[ci skip]

* New translations en.json (Arabic)

[ci skip]

* New translations en.json (Bulgarian)

[ci skip]

* New translations en.json (Catalan)

[ci skip]

* New translations en.json (Czech)

[ci skip]

* New translations en.json (Danish)

[ci skip]

* New translations en.json (German)

[ci skip]

* New translations en.json (Greek)

[ci skip]

* New translations en.json (Basque)

[ci skip]

* New translations en.json (Finnish)

[ci skip]

* New translations en.json (Hebrew)

[ci skip]

* New translations en.json (Hungarian)

[ci skip]

* New translations en.json (Italian)

[ci skip]

* New translations en.json (Japanese)

[ci skip]

* New translations en.json (Korean)

[ci skip]

* New translations en.json (Kurdish)

[ci skip]

* New translations en.json (Lithuanian)

[ci skip]

* New translations en.json (Dutch)

[ci skip]

* New translations en.json (Punjabi)

[ci skip]

* New translations en.json (Polish)

[ci skip]

* New translations en.json (Portuguese)

[ci skip]

* New translations en.json (Russian)

[ci skip]

* New translations en.json (Slovak)

[ci skip]

* New translations en.json (Slovenian)

[ci skip]

* New translations en.json (Swedish)

[ci skip]

* New translations en.json (Turkish)

[ci skip]

* New translations en.json (Ukrainian)

[ci skip]

* New translations en.json (Chinese Simplified)

[ci skip]

* New translations en.json (Chinese Traditional)

[ci skip]

* New translations en.json (Vietnamese)

[ci skip]

* New translations en.json (Galician)

[ci skip]

* New translations en.json (Portuguese, Brazilian)

[ci skip]

* New translations en.json (Indonesian)

[ci skip]

* New translations en.json (Persian)

[ci skip]

* New translations en.json (Khmer)

[ci skip]

* New translations en.json (Tamil)

[ci skip]

* New translations en.json (Bengali)

[ci skip]

* New translations en.json (Marathi)

[ci skip]

* New translations en.json (Thai)

[ci skip]

* New translations en.json (Norwegian Nynorsk)

[ci skip]

* New translations en.json (Kazakh)

[ci skip]

* New translations en.json (Latvian)

[ci skip]

* New translations en.json (Azerbaijani)

[ci skip]

* New translations en.json (Hindi)

[ci skip]

* New translations en.json (Burmese)

[ci skip]

* New translations en.json (Chinese Traditional, Hong Kong)

[ci skip]

* New translations en.json (Sinhala)

[ci skip]

* New translations en.json (Uzbek)

[ci skip]

* New translations en.json (Norwegian Bokmal)

[ci skip]

* New translations en.json (Occitan)

[ci skip]

* New translations en.json (German, Switzerland)

[ci skip]

* New translations en.json (Bengali, India)

[ci skip]

* New translations en.json (Kabyle)

[ci skip]

* New translations en.json (Karakalpak)

[ci skip]

* New translations en.json (Romanian)

[ci skip]

* New translations en.json (French)

[ci skip]

* New translations en.json (Indonesian)

[ci skip]

* New translations en.json (Hebrew)

[ci skip]

* New translations en.json (Hebrew)

[ci skip]

* New translations en.json (Romanian)

[ci skip]

* New translations en.json (French)

[ci skip]

* New translations en.json (Spanish)

[ci skip]

* New translations en.json (Arabic)

[ci skip]

* New translations en.json (Bulgarian)

[ci skip]

* New translations en.json (Catalan)

[ci skip]

* New translations en.json (Czech)

[ci skip]

* New translations en.json (Danish)

[ci skip]

* New translations en.json (German)

[ci skip]

* New translations en.json (Greek)

[ci skip]

* New translations en.json (Basque)

[ci skip]

* New translations en.json (Finnish)

[ci skip]

* New translations en.json (Hebrew)

[ci skip]

* New translations en.json (Hungarian)

[ci skip]

* New translations en.json (Italian)

[ci skip]

* New translations en.json (Japanese)

[ci skip]

* New translations en.json (Korean)

[ci skip]

* New translations en.json (Kurdish)

[ci skip]

* New translations en.json (Lithuanian)

[ci skip]

* New translations en.json (Dutch)

[ci skip]

* New translations en.json (Punjabi)

[ci skip]

* New translations en.json (Polish)

[ci skip]

* New translations en.json (Portuguese)

[ci skip]

* New translations en.json (Russian)

[ci skip]

* New translations en.json (Slovak)

[ci skip]

* New translations en.json (Slovenian)

[ci skip]

* New translations en.json (Swedish)

[ci skip]

* New translations en.json (Turkish)

[ci skip]

* New translations en.json (Ukrainian)

[ci skip]

* New translations en.json (Chinese Simplified)

[ci skip]

* New translations en.json (Chinese Traditional)

[ci skip]

* New translations en.json (Vietnamese)

[ci skip]

* New translations en.json (Galician)

[ci skip]

* New translations en.json (Portuguese, Brazilian)

[ci skip]

* New translations en.json (Indonesian)

[ci skip]

* New translations en.json (Persian)

[ci skip]

* New translations en.json (Khmer)

[ci skip]

* New translations en.json (Tamil)

[ci skip]

* New translations en.json (Bengali)

[ci skip]

* New translations en.json (Marathi)

[ci skip]

* New translations en.json (Thai)

[ci skip]

* New translations en.json (Norwegian Nynorsk)

[ci skip]

* New translations en.json (Kazakh)

[ci skip]

* New translations en.json (Latvian)

[ci skip]

* New translations en.json (Azerbaijani)

[ci skip]

* New translations en.json (Hindi)

[ci skip]

* New translations en.json (Burmese)

[ci skip]

* New translations en.json (Chinese Traditional, Hong Kong)

[ci skip]

* New translations en.json (Sinhala)

[ci skip]

* New translations en.json (Uzbek)

[ci skip]

* New translations en.json (Norwegian Bokmal)

[ci skip]

* New translations en.json (Occitan)

[ci skip]

* New translations en.json (German, Switzerland)

[ci skip]

* New translations en.json (Bengali, India)

[ci skip]

* New translations en.json (Kabyle)

[ci skip]

* New translations en.json (Karakalpak)

[ci skip]

* New translations en.json (Romanian)

[ci skip]

* New translations en.json (Italian)

[ci skip]

* New translations en.json (Chinese Traditional)

[ci skip]

* New translations en.json (Japanese)

[ci skip]

* New translations en.json (Japanese)

[ci skip]

* New translations en.json (Ukrainian)

[ci skip]

* New translations en.json (Ukrainian)

[ci skip]

* New translations en.json (Ukrainian)

[ci skip]

* New translations en.json (Ukrainian)

[ci skip]

* New translations en.json (Hebrew)

[ci skip]

* New translations en.json (Ukrainian)

[ci skip]

* New translations en.json (Ukrainian)

[ci skip]

* New translations en.json (Romanian)

[ci skip]

* New translations en.json (French)

[ci skip]

* New translations en.json (Spanish)

[ci skip]

* New translations en.json (Arabic)

[ci skip]

* New translations en.json (Bulgarian)

[ci skip]

* New translations en.json (Catalan)

[ci skip]

* New translations en.json (Czech)

[ci skip]

* New translations en.json (Danish)

[ci skip]

* New translations en.json (German)

[ci skip]

* New translations en.json (Greek)

[ci skip]

* New translations en.json (Basque)

[ci skip]

* New translations en.json (Finnish)

[ci skip]

* New translations en.json (Hebrew)

[ci skip]

* New translations en.json (Hungarian)

[ci skip]

* New translations en.json (Italian)

[ci skip]

* New translations en.json (Japanese)

[ci skip]

* New translations en.json (Korean)

[ci skip]

* New translations en.json (Kurdish)

[ci skip]

* New translations en.json (Lithuanian)

[ci skip]

* New translations en.json (Dutch)

[ci skip]

* New translations en.json (Punjabi)

[ci skip]

* New translations en.json (Polish)

[ci skip]

* New translations en.json (Portuguese)

[ci skip]

* New translations en.json (Russian)

[ci skip]

* New translations en.json (Slovak)

[ci skip]

* New translations en.json (Slovenian)

[ci skip]

* New translations en.json (Swedish)

[ci skip]

* New translations en.json (Turkish)

[ci skip]

* New translations en.json (Ukrainian)

[ci skip]

* New translations en.json (Chinese Simplified)

[ci skip]

* New translations en.json (Chinese Traditional)

[ci skip]

* New translations en.json (Vietnamese)

[ci skip]

* New translations en.json (Galician)

[ci skip]

* New translations en.json (Portuguese, Brazilian)

[ci skip]

* New translations en.json (Indonesian)

[ci skip]

* New translations en.json (Persian)

[ci skip]

* New translations en.json (Khmer)

[ci skip]

* New translations en.json (Tamil)

[ci skip]

* New translations en.json (Bengali)

[ci skip]

* New translations en.json (Marathi)

[ci skip]

* New translations en.json (Thai)

[ci skip]

* New translations en.json (Norwegian Nynorsk)

[ci skip]

* New translations en.json (Kazakh)

[ci skip]

* New translations en.json (Latvian)

[ci skip]

* New translations en.json (Azerbaijani)

[ci skip]

* New translations en.json (Hindi)

[ci skip]

* New translations en.json (Burmese)

[ci skip]

* New translations en.json (Chinese Traditional, Hong Kong)

[ci skip]

* New translations en.json (Sinhala)

[ci skip]

* New translations en.json (Uzbek)

[ci skip]

* New translations en.json (Norwegian Bokmal)

[ci skip]

* New translations en.json (Occitan)

[ci skip]

* New translations en.json (German, Switzerland)

[ci skip]

* New translations en.json (Bengali, India)

[ci skip]

* New translations en.json (Kabyle)

[ci skip]

* New translations en.json (Karakalpak)

[ci skip]

* New translations en.json (Romanian)

[ci skip]

* Auto commit: Calculate translation coverage

---------

Co-authored-by: dwelle <5153846+dwelle@users.noreply.github.com>
2026-08-16 17:30:01 +02:00
Vishnu RkandVishRk 884225b1c7 fix(app): localize hardcoded "Sign up"/"Sign in" menu labels (#11893)
fix(localization): update signup and signin labels for localization

Co-authored-by: VishRk <VishRk@users.noreply.github.com>
2026-08-16 13:20:15 +00:00
David Luzar abeeaeba21 feat(editor): customizing color top picks (#11872) 2026-08-11 15:59:35 +02:00
JayeshandMark Tolmacs c5a50d223f fix(editor): respect boxSelectionMode ('contain' vs 'overlap') in lasso tool (#11862)
---------

Signed-off-by: Mark Tolmacs <mark@lazycat.hu>
Co-authored-by: Mark Tolmacs <mark@lazycat.hu>
2026-08-10 13:09:43 +02:00
David Luzarandrafa cf212b2f33 feat(editor): eyedropper fixes and improvements (#11859)
Co-authored-by: rafa <rafaondjango@gmail.com>
2026-08-08 23:17:26 +02:00
David Luzar 4872083c04 feat(editor): bucketfill cursor + eyedropper support (#11849) 2026-08-07 14:07:53 +02:00
David Luzar ea74787447 fix(app): remove ViewportStatusFrame debug (#11848) 2026-08-07 12:53:21 +02:00
David Luzar 219571a718 fix(editor): viewportportStatusFrame UI fixes (#11841) 2026-08-06 22:01:48 +02:00
David Luzar e4ab626739 feat(packages/excalidraw): add props.className (#11839) 2026-08-05 20:10:18 +02:00
David Luzar 1e22618ab2 fix(packages/excalidraw): viewportStatusFrame label offset on mobile (#11840) 2026-08-05 20:00:18 +02:00
David Luzar 74789584be feat(packages/excalidraw): support ViewportStatusFrame label onClick (#11838) 2026-08-05 18:28:52 +02:00
David Luzar e33a1c0e85 feat(packages/excalidraw): do not dedupe collaborators (#11835) 2026-08-05 14:20:18 +02:00
David Luzar 5e375b4331 fix(editor): do not flicker bbox when drag-creating line midpoint (#11834) 2026-08-05 10:52:10 +02:00
David LuzarandTamas Lakatos 85270fcc8d feat(packages/excalidraw): ViewportStatusFrame & factor out user-follow state (#11819)
Co-authored-by: Tamas Lakatos <tamas@excalidraw.com>
2026-08-05 00:01:20 +02:00
yanrin 46c42a6cb0 fix(editor): initialize selectedLinearElement after pasting arrows (#11803) 2026-08-04 23:38:15 +02:00
David Luzar c9c96f619c fix(editor): normalize container + bound text order on restore (#11827) 2026-08-04 23:32:15 +02:00
David Luzar 1da120e914 fix(editor): include custom tools in pointer capture (#11826) 2026-08-04 19:39:21 +02:00
nihaarsirikonda ab0255f21e fix(editor): prevent crash when editing arrow with bound text (#11814) 2026-08-04 16:31:37 +02:00
792ea3a06c feat(editor): bucket fill (#11799)
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Co-authored-by: Mark Tolmacs <mark@lazycat.hu>
Signed-off-by: Mark Tolmacs <mark@lazycat.hu>
2026-08-04 16:25:16 +02:00
David Luzar f1aa8f86a9 feat(editor): disable arrowhead toggle via dblclick (#11822) 2026-08-04 16:25:00 +02:00
Excalidraw Botanddwelle 39103bd33a chore(editor): Update translations from Crowdin (#11813)
Co-authored-by: dwelle <5153846+dwelle@users.noreply.github.com>
2026-08-03 21:48:06 +02:00
cyforkkandMark Tolmacs 786ab266ff feat(editor): Error message for invalid hex color input (#11782)
---------
Signed-off-by: Mark Tolmacs <mark@lazycat.hu>
Co-authored-by: Mark Tolmacs <mark@lazycat.hu>
2026-07-30 13:30:20 +02:00
Adam 69d4c346cc fix(editor): prevent esc from exiting fullscreen on macos when modal open (#11789) 2026-07-30 07:47:42 +00:00
David Luzar 1acf66edab feat(editor): bind text to hovered arrow endpoint (#11777) 2026-07-28 18:12:33 +02:00
David Luzar 7c0b329576 fix(editor): increase autoresize handle threshold until it hides (#11773) 2026-07-27 23:04:03 +02:00
David Luzar b2e81e38a6 feat(editor): autoshape text + line improvements (#11752) 2026-07-24 16:10:00 +02:00
f179f7ffd2 feat(editor): draw to shape (auto-detection) (#9313)
* Initial commit

* Segments detection

* Remove aspect ratio

* Refactor

* Fix tests

* Toggle shapeSnap

* Fix linting

* debug: live preview

* Rebase fixes

Signed-off-by: Mark Tolmacs <mark@lazycat.hu>

* Bind to elements (no hover background)

* Shape closed

* Fix rad calc + rename segments

* Comments

* Fix binding highlighting + lints

* Rename to convert to shape

* fix: Binding

Signed-off-by: Mark Tolmacs <mark@lazycat.hu>

* chore: Refactor spline simplification

Signed-off-by: Mark Tolmacs <mark@lazycat.hu>

* chore: Remove unused doBoundsIntersect

Signed-off-by: Mark Tolmacs <mark@lazycat.hu>

* fix: Restore type signature

Signed-off-by: Mark Tolmacs <mark@lazycat.hu>

* feat: Propose new button shapes

Signed-off-by: Mark Tolmacs <mark@lazycat.hu>

* chore: Remove unused types

Signed-off-by: Mark Tolmacs <mark@lazycat.hu>

* chore: Icon shapes

Signed-off-by: Mark Tolmacs <mark@lazycat.hu>

* fix: Element cloning

Signed-off-by: Mark Tolmacs <mark@lazycat.hu>

* fix: Element cloning

Signed-off-by: Mark Tolmacs <mark@lazycat.hu>

* fix: Remove unused

Signed-off-by: Mark Tolmacs <mark@lazycat.hu>

* feat: Shape settings in recognition mode

Signed-off-by: Mark Tolmacs <mark@lazycat.hu>

* chore: Remove debug

Signed-off-by: Mark Tolmacs <mark@lazycat.hu>

* feat: Actions

Signed-off-by: Mark Tolmacs <mark@lazycat.hu>

* fix: Lint

Signed-off-by: Mark Tolmacs <mark@lazycat.hu>

* fix: Arrowheads for freedraw

Signed-off-by: Mark Tolmacs <mark@lazycat.hu>

* feat: Draw shape tool instead of freedraw

Signed-off-by: Mark Tolmacs <mark@lazycat.hu>

* fix: Test

Signed-off-by: Mark Tolmacs <mark@lazycat.hu>

* fix: Lint

Signed-off-by: Mark Tolmacs <mark@lazycat.hu>

* fix: Keyboard shortcut visibility

Signed-off-by: Mark Tolmacs <mark@lazycat.hu>

* feat: Unique cursor for draw shape

Signed-off-by: Mark Tolmacs <mark@lazycat.hu>

* fix: Remove selection

Signed-off-by: Mark Tolmacs <mark@lazycat.hu>

* fix: Shape selection

Signed-off-by: Mark Tolmacs <mark@lazycat.hu>

* feat: Improve shape recognition

Signed-off-by: Mark Tolmacs <mark@lazycat.hu>

* fix: Further refine shape recognition

Signed-off-by: Mark Tolmacs <mark@lazycat.hu>

* feat: Preview

Signed-off-by: Mark Tolmacs <mark@lazycat.hu>

* feat: Add hint

Signed-off-by: Mark Tolmacs <mark@lazycat.hu>

* fix: Refined shape recognition

Signed-off-by: Mark Tolmacs <mark@lazycat.hu>

* fix: Less ellipse more rectanguloid

Signed-off-by: Mark Tolmacs <mark@lazycat.hu>

* fix: Refined arrow recognition

Signed-off-by: Mark Tolmacs <mark@lazycat.hu>

* fix: Refinerecognition

Signed-off-by: Mark Tolmacs <mark@lazycat.hu>

* chore: Lower linear threshold

Signed-off-by: Mark Tolmacs <mark@lazycat.hu>

* feat: Rectangle head arrow

Signed-off-by: Mark Tolmacs <mark@lazycat.hu>

* feat: More arrowhead sizes

Signed-off-by: Mark Tolmacs <mark@lazycat.hu>

* fix: Arrow remains an arrow

Signed-off-by: Mark Tolmacs <mark@lazycat.hu>

* feat: Draw line between elements converts to arrow

Signed-off-by: Mark Tolmacs <mark@lazycat.hu>

* remove pendingDrawShapeElement

* fix: Arrow start point binding

Signed-off-by: Mark Tolmacs <mark@lazycat.hu>

* fix: Different arrow endpoint heuristic

Signed-off-by: Mark Tolmacs <mark@lazycat.hu>

* fix: Point type

Signed-off-by: Mark Tolmacs <mark@lazycat.hu>

* fix: Various small fixes

Signed-off-by: Mark Tolmacs <mark@lazycat.hu>

* fix: Avoid recompute

Signed-off-by: Mark Tolmacs <mark@lazycat.hu>

* fix: Type issues

Signed-off-by: Mark Tolmacs <mark@lazycat.hu>

* test(editor): cover the drawShape (snap-to-shape) tool

The drawShape tool had no dedicated tests, which let master's toolbar /
active-tool rewrite silently break it during the merge: actionFinalize
reverted to the selection tool after every sketch, because the drawShape
flow finalizes without a `newElement` and so failed master's new
`&& element` guard.

Covers the behavior the merge had to preserve:
- sketch recognition (rectangle, ellipse) and the too-small no-op
- the tool stays active after finalizing, so shapes can be sketched
  consecutively (regression guard for the above)
- activation via the S shortcut and the extra-tools dropdown
- the tool's own cursor

Points are fed into the trail directly rather than through pointermove,
which is rAF-throttled and would drop all but one point per frame — the
same approach lasso.test.tsx uses.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat: Add test

Signed-off-by: Mark Tolmacs <mark@lazycat.hu>

* feat: Breakout from App.tsx

Signed-off-by: Mark Tolmacs <mark@lazycat.hu>

* fix: Shortcut is now Shift+X

Signed-off-by: Mark Tolmacs <mark@lazycat.hu>

* fix: No selecting arrows for shape recog

Signed-off-by: Mark Tolmacs <mark@lazycat.hu>

* fix: Double history

Signed-off-by: Mark Tolmacs <mark@lazycat.hu>

* feat: PCA + feature extraction recognition

Signed-off-by: Mark Tolmacs <mark@lazycat.hu>

* reduce and normalize min-size gates

* never select newElement when shape recog tool active

* simplify lifecycle & remove double finalize

* make arrows bindings orbit

* funnel though actionFinalize & fix two unrelated cleanup cases

* use crosshair cursor

* show strokeWidth

* switch icons for drawShape & extraTools

* dedupe getBoundsFromPoints

* remove unused helpers

* jsdoc cleanup

* dynamic trail size

* improve rectangle recognition

* disallow linears to deviate too much from a straight line

* put under freedraw tool on tablet/mobile

* tweak label

* rename `drawShape` -> `autoshape`

---------

Signed-off-by: Mark Tolmacs <mark@lazycat.hu>
Co-authored-by: dwelle <5153846+dwelle@users.noreply.github.com>
Co-authored-by: Mark Tolmacs <mark@lazycat.hu>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-23 18:42:42 +02:00
Márk Tolmács e6ae6bf057 fix(editor): Diamond hit test shortcut (#11631)
---------

Signed-off-by: Mark Tolmacs <mark@lazycat.hu>
2026-07-22 22:39:16 +02:00
Márk Tolmács 91b78903ab fix(editor): Dangling deleted bound text (#11733)
---------

Signed-off-by: Mark Tolmacs <mark@lazycat.hu>
2026-07-22 22:37:35 +02:00
Márk Tolmács a3b90897b5 fix(editor): Lost focus point on transition to inside-inside (#10964)
---------

Signed-off-by: Mark Tolmacs <mark@lazycat.hu>
2026-07-22 13:03:45 +02:00
samirananddwelle 53732f08f4 fix(editor): prevent eyedropper preview from overflowing viewport (#11722)
* fix(editor): prevent eyedropper preview from overflowing viewport

* generalize

---------

Co-authored-by: dwelle <5153846+dwelle@users.noreply.github.com>
2026-07-21 22:07:15 +00:00
samiran 5cf547650b fix(editor): make Help button tooltip consistent with toolbar (#11681) 2026-07-18 00:17:39 +02:00
David Luzar a1d9b16b0d fix(editor): show scroll-back-to-content button on mobile (#11680) 2026-07-17 16:22:43 +02:00
David Luzar 65aa577e39 fix(editor): fix AnimationController scheduling race conds (#11678) 2026-07-16 20:52:06 +02:00
David Luzar 93dd51060a feat(packages/excalidraw): add props.ui.enabled.zoom/scrollBackToContent (#11677) 2026-07-16 19:50:19 +02:00
David Luzar 344878b0b5 ci(repo): skip docs & example deployments in CI (#11674) 2026-07-16 14:36:11 +00:00
David Luzar 374716304a fix(editor): improve UX around locked animations (#11671) 2026-07-16 13:30:13 +02:00
David Luzar 5ca083436d feat(packages/excalidraw): add props.activeTool and related (#11665) 2026-07-15 11:46:08 +02:00
David Luzar acb48c3f45 fix(editor): make embeddable ignore higher-z-index non-framelike elements (#11662) 2026-07-14 18:18:21 +02:00
David LuzarandClaude Fable 5 2423819ee0 feat(packages/excalidraw): interaction and UI control (#11605)
* feat(packages/excalidraw): add `props.interaction` and `props.ui`

* fix: prevent browser zoom when interaction disabled

* changelog

* clean up interactive state on disable and consolidate `viewMode` and interaction state updates

* render UI rendered to `children` even if editor-exported

* support `allowed.navigation` and `allowed.embeds/interactiveContent`

* change naming `allowed` -> `enabled`

* changelog: add PR links

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* clear flowchart sessions via consolidated `app.flowchart` after master merge

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* refactor getters -> methods

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-14 16:50:53 +02:00
Márk Tolmács 5776e1cc66 fix(editor): should not select deleted bound text (#11660)
fix: Incorrect selection of deleted bound text

Signed-off-by: Mark Tolmacs <mark@lazycat.hu>
2026-07-14 13:19:19 +02:00
Christopher TangonanandMark Tolmacs e9c856d262 chore(editor): streamline getSelectedElementsByGroup (#11636)
---------

Signed-off-by: Mark Tolmacs <mark@lazycat.hu>
Co-authored-by: Mark Tolmacs <mark@lazycat.hu>
2026-07-13 19:38:52 +02:00
Márk Tolmács 082b2eeb1e fix(editor): Hit cache soundness (#11630)
---------

Signed-off-by: Mark Tolmacs <mark@lazycat.hu>
2026-07-13 17:53:01 +02:00
minato32andMark Tolmacs d331bb49fc fix(editor): avoid overlap of flowchart children (#11532)
Closes #8518. Fixes the issue when adding a new child to a flowchart node that already has children (after the first creation session was committed), the new child could land on top of, or misaligned with, an existing sibling.
---------
Signed-off-by: Mark Tolmacs <mark@lazycat.hu>
Co-authored-by: Mark Tolmacs <mark@lazycat.hu>
2026-07-13 16:52:19 +02:00
David Luzar 339d3c3733 feat(editor): toolbar / active tool rewrites (#11649) 2026-07-13 00:30:09 +02:00
Márk Tolmácsanddwelle ed7c0c1d7d chore(editor): Refactor Actions for clarity (#11632)
Co-authored-by: dwelle <5153846+dwelle@users.noreply.github.com>
Signed-off-by: Mark Tolmacs <mark@lazycat.hu>
2026-07-11 17:22:35 +02:00
Márk Tolmácsanddwelle 3b9e1c07f5 fix(editor): Dragged arrow endpoint ignore grid and angle locks (#10972)
---------
Signed-off-by: Mark Tolmacs <mark@lazycat.hu>
Co-authored-by: dwelle <5153846+dwelle@users.noreply.github.com>
2026-07-10 11:19:44 +02:00
Christopher Tangonan adf9631997 fix(editor): keep multi-line bound text and container centered during alt resize (#11480) 2026-07-09 13:02:19 +02:00
David Luzar f790bf7abd feat(editor): tweak sidebar promo style (#11181) 2026-07-09 11:41:13 +02:00
David LuzarandDataVoyager-Aditya 063e0256f5 fix(editor): arrow label rendering fixes and perf improvements (#11637)
Co-authored-by: DataVoyager-Aditya <rajthakuraditya63@gmail.com>
2026-07-08 23:13:01 +02:00
Márk Tolmács fbff832424 fix(editor): Fix binding.test.ts type mismatch (#11633)
Signed-off-by: Mark Tolmacs <mark@lazycat.hu>
2026-07-08 20:20:01 +02:00
Márk Tolmács 8462bb81fb fix(editor): Very small bindables can blow up fixedPoint -> position ratio (#11607)
---
Signed-off-by: Mark Tolmacs <mark@lazycat.hu>
2026-07-08 12:20:30 +02:00
Excalidraw Botanddwelle ba0387d186 chore(editor): Update translations from Crowdin (#10731)
Co-authored-by: dwelle <5153846+dwelle@users.noreply.github.com>
2026-07-07 22:37:30 +02:00
Augusto XavierandMark Tolmacs dd8296af18 fix(editor): narrow NonDeleted type to isDeleted: false (#11470)
---------
Signed-off-by: Mark Tolmacs <mark@lazycat.hu>
Co-authored-by: Mark Tolmacs <mark@lazycat.hu>
2026-07-07 16:22:31 +02:00
David Luzar b2a729c400 feat(editor): dblclick to toggle arrowhead (#11615) 2026-07-07 14:22:21 +02:00
David Luzar aaa14e9df6 feat(editor): show cursor hint when switching arrow types (#11608) 2026-07-07 12:55:32 +02:00
David Luzar 9357f98a9b fix(editor): optimize UI rendering during animation (#11604) 2026-07-05 18:44:26 +02:00
David Luzar 5b0f5a4c2d fix(editor): revert viewport animation back to 500ms (#11600) 2026-07-05 09:45:59 +02:00
51ca8abde4 feat(packages/excalidraw): viewport locking (#11554)
Co-authored-by: barnabasmolnar <barnabas@excalidraw.com>
Co-authored-by: Arnost Pleskot <arnostpleskot@gmail.com>
Co-authored-by: dwelle <5153846+dwelle@users.noreply.github.com>
Signed-off-by: Mark Tolmacs <mark@lazycat.hu>
2026-07-03 22:57:47 +02:00
AnviandChristopher Tangonan cce5001aaa fix(editor): keep eraser shortcut consistent with tool switch rules (#11571)
---------
Co-authored-by: Christopher Tangonan <ctangonan123@gmail.com>
2026-06-29 10:04:08 +02:00
Dany Valverde Caldasanddwelle 4ce70b815e fix(editor): ensure canvas is cleared when background color is invalid to prevent ghosting (#11458)
Co-authored-by: dwelle <5153846+dwelle@users.noreply.github.com>
2026-06-26 22:59:17 +02:00
David Luzar c070c8ffa6 fix(editor): improve scroll animation interpolation (#11562) 2026-06-26 09:56:12 +02:00
Márk Tolmácsanddwelle e4c70cb6c6 feat(editor): AnimationController for scrollToContent (#11553)
---------

Signed-off-by: Mark Tolmacs <mark@lazycat.hu>
Co-authored-by: dwelle <5153846+dwelle@users.noreply.github.com>
2026-06-26 09:35:32 +02:00
minato32anddwelle 20f694d110 fix(editor): prevent freeze from extremely large line elements (#11556)
Co-authored-by: dwelle <5153846+dwelle@users.noreply.github.com>
2026-06-25 20:06:02 +02:00
David Luzar 2a82821ec5 fix(editor): tweak freedraw settings and tablet UI/UX (#11551)
* fix(editor): align constant freedraw stroke width more with generic shapes

* reduce streamline for non-mouse

* do not change `currentItemStrokeVariability` unintentionally

* render penMode button under compact styles panel on tablets

* show stroke variability as standalone button in compact actions menu

* improve toolbar clicking UX with pen

* change streamline defaults

* change to `variable` stroke if toggling penMode for the first time
2026-06-24 16:59:10 +02:00
Márk Tolmácsanddwelle 070df27e4d fix(editor): Modern TS require imports from rootDir (#11552)
---------

Signed-off-by: Mark Tolmacs <mark@lazycat.hu>
Co-authored-by: dwelle <5153846+dwelle@users.noreply.github.com>
2026-06-24 10:52:56 +00:00
Márk Tolmácsanddwelle cd514d72d6 feat(editor): LaserPointer based freedraw (#11507)
Introduces constant width freedraw mode, keeping the original variable mode as default.

---------

Signed-off-by: Mark Tolmacs <mark@lazycat.hu>
Co-authored-by: dwelle <5153846+dwelle@users.noreply.github.com>
2026-06-24 09:16:22 +02:00
zsviczian 0642e72cfa fix(editor): Arrows with text are rendered blurry in PNG export with larger scale (#11492) 2026-06-22 18:22:26 +02:00
David Luzar 28a9b1711d test(repo): less noisy test output (#11505) 2026-06-15 18:19:20 +02:00
Márk Tolmácsanddwelle 1cb9fff569 fix(editor): Double history (#11445)
---------

Signed-off-by: Mark Tolmacs <mark@lazycat.hu>
Co-authored-by: dwelle <5153846+dwelle@users.noreply.github.com>
2026-06-15 18:19:08 +02:00
David LuzarandDiego Mateos 069982606d fix(editor): update element.frameId on frame change (#11490)
Co-authored-by: Diego Mateos <dimateos@ucm.es>
2026-06-13 22:39:04 +02:00
David Luzar b324a85ab1 fix(editor): elements duplicated when moving frame children (#11485)
* fix(editor): elements duplicated when moving frame children

* fix(editor): accumulate reorders across frames in bring-to-front/back

* add invalid-order tests

* fix: make sure moved indices are within range

* add length/duplicate elements guards
2026-06-13 20:32:45 +02:00
a83ac48853 fix(editor): recalculate roundness type when switching shape types (#11473)
When converting between generic shapes, the roundness type was only
recalculated when the target type was a diamond. Converting a diamond
(which uses the proportional radius algorithm) back to a rectangle kept
the proportional radius, so rectangles ended up rendering with overly
round corners.

---------

Signed-off-by: Mark Tolmacs <mark@lazycat.hu>
Co-authored-by: Maruthan G <maruthang4@gmail.com>
Co-authored-by: Sivram <withsivram@gmail.com>
Co-authored-by: Jai Kumar Dewani <jai.dewani.99@gmail.com>
Co-authored-by: Mark Tolmacs <mark@lazycat.hu>
2026-06-11 19:17:15 +02:00
KrishhnaT 0cf56b19c7 test(editor): add unit tests for BinaryHeap (#11419) 2026-06-10 17:12:42 +02:00
KrishhnaT 61fe15a51d fix(editor): cardinal direction arrows with label are invisible in exported SVG (#11441)
fix: arrows with bound text labels missing from SVG export

Axis-aligned (horizontal/vertical) arrows with a bound label vanished from SVG exports. The label-gap mask defaulted to objectBoundingBox units, whose region collapses to zero area for a zero-size bounding box, masking out the whole line. Pin the mask to userSpaceOnUse with an explicit user-space region (the coords already used by the visible rect).

Fixes #11439
2026-06-07 19:19:44 +02:00
David Luzar 647a264a48 feat(packages/excalidraw): consolidate theme state handling (#11453) 2026-06-06 18:18:06 +02:00
Márk Tolmács b6d80e4256 fix(packages/excalidraw): consolidate bounds checks (#11275)
Signed-off-by: Mark Tolmacs <mark@lazycat.hu>
2026-06-04 19:27:25 +02:00
David Luzar 3372149277 feat(packages/excalidraw): export applyDarkModeFilter and simplify (#11429) 2026-06-01 15:43:45 +02:00
David Luzar c08be69618 ci(docker): fix docker dep bundling and pin remaining actions (#11398)
* docker: use slim alpine image to remove bundling deps in Docker image

* pin remaining yml actions

* use lockfile

* remove pulling
2026-05-25 14:39:21 +02:00
Márk Tolmácsanddwelle b42b1a193d fix(editor): excessive battery usage (#11377)
* fix: Excessive battery usage

* chore: Refactor Eraser, Lasso and Laser pointer to use AnimationController

* fix: Last laser trail element is not removed from SVG
---------

Signed-off-by: Mark Tolmacs <mark@lazycat.hu>
Co-authored-by: dwelle <5153846+dwelle@users.noreply.github.com>
2026-05-24 16:12:17 +02:00
David Luzar f6d85bc80f feat(packages/excalidraw): expose image size config and optimize resizing (#11332) 2026-05-14 22:45:03 +02:00
David Luzar 0457ac9063 fix(editor): handle invalid points on restore (#11321)
* fix: handle invalid points on restore

* move isValidPoint to @excalidraw/math
2026-05-12 18:44:49 +02:00
Praneeth Kodumagulla b2b2815954 fix(editor): prevent duplicate lasso toolbar item (#11286) 2026-05-06 23:10:50 +02:00
d992c10bc1 fix(app): resolve app-jotai import path in LocalData (#11290)
Co-authored-by: chulkin-mdb <oleksandr.chulkin@mongodb.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-06 23:03:10 +02:00
melvinandDavid Luzar 091b9053a3 fix(editor): enabled ctrl+y redo shortcut on linux and mac (#11179)
* enabled ctrl+y redo shortcut on linux and mac

* Apply suggestion from @dwelle

---------

Co-authored-by: David Luzar <5153846+dwelle@users.noreply.github.com>
2026-05-06 22:59:28 +02:00
David Luzar 97274a74b2 ci(repo): enforce scopes (#11292) 2026-05-06 19:54:30 +02:00
Márk Tolmácsanddwelle c59fb8dcbc fix: LocalStorage is empty object on node@25 which breaks tests (#11240)
Co-authored-by: dwelle <5153846+dwelle@users.noreply.github.com>
2026-05-06 17:42:36 +02:00
Márk Tolmácsanddwelle 7f56cc0cf3 fix: Speculative fixes for arrow invariant failures (#11241)
Co-authored-by: dwelle <5153846+dwelle@users.noreply.github.com>
2026-05-06 14:39:45 +00:00
974b338b7e fix: Group selection (#11234)
* fix: Group selection

Co-authored-by: Copilot <copilot@github.com>
Signed-off-by: Mark Tolmacs <mark@lazycat.hu>

* fix: Tests

Co-authored-by: Copilot <copilot@github.com>
Signed-off-by: Mark Tolmacs <mark@lazycat.hu>

* fix: Frames and overlap

Co-authored-by: Copilot <copilot@github.com>
Signed-off-by: Mark Tolmacs <mark@lazycat.hu>

* fix: Remove unnecessary crust

Signed-off-by: Mark Tolmacs <mark@lazycat.hu>

* revert unused Set signature

* skip ignored elements from group condition

when wrap-mode selecting grouped elements, we should not require to select those we ignore (bound elements or locked ones), else it's impossible to select grouped text containers

unclear whether locked elements should also be excluded - but it feels like a good heuristic on the whole

* apply exclusion

* simplify

* feat: return all elements in group for overlap selection

---------

Signed-off-by: Mark Tolmacs <mark@lazycat.hu>
Co-authored-by: Copilot <copilot@github.com>
Co-authored-by: dwelle <5153846+dwelle@users.noreply.github.com>
2026-05-06 15:27:34 +02:00
David Luzar d2557474e2 fix(editor): fix target element index when creating/adding elements to frames (#11257) 2026-05-05 21:35:32 +02:00
Márk Tolmácsanddwelle 3004c642da fix: Fractional index validation (#11258)
- Vendored fractional-indexing and converted to TypeScript
- Stricter index format validation in fractional-indexing
- Added format validation to fractional index validation

---
Signed-off-by: Mark Tolmacs <mark@lazycat.hu>
Co-authored-by: dwelle <5153846+dwelle@users.noreply.github.com>
2026-05-04 11:37:17 +02:00
Márk Tolmács 2dfcc6f0ce chore: Remove startBoundElement from state (#11264) 2026-05-02 16:36:42 +02:00
David Luzar 3f5fdec04e fix: group defragmenting (#11269) 2026-05-02 15:50:58 +02:00
David Luzar 278cd35772 feat(editor): scale video embeddables based on zoom (#11251) 2026-04-28 21:49:40 +02:00
David Luzar 43fa4b5602 fix: frame selection and membership (#11250)
fix: element fully overlapping frame
2026-04-28 18:23:10 +02:00
2e1a529c67 fix(editor): remove extremely large arrows on restore (#11235)
* fix: Temp fix for elbow arrow at restore

Co-authored-by: Copilot <copilot@github.com>
Signed-off-by: Mark Tolmacs <mark@lazycat.hu>

* fix: Speculative fixes to avoid Infinity

Co-authored-by: Copilot <copilot@github.com>
Signed-off-by: Mark Tolmacs <mark@lazycat.hu>

* validate/remove arrow size after point normalization & move binding repairs back

* validate even simple arrows

* remove x/y check

* remove duplicate constant

---------

Signed-off-by: Mark Tolmacs <mark@lazycat.hu>
Co-authored-by: Copilot <copilot@github.com>
Co-authored-by: dwelle <5153846+dwelle@users.noreply.github.com>
2026-04-25 12:03:50 +02:00
David Luzar b1c6bfcf40 chore(docker): bump node (#11208) 2026-04-20 22:07:00 +02:00
Tom LouveauandDavid Luzar 1caec99b29 docs: change twitter label by X (#11158)
Co-authored-by: David Luzar <5153846+dwelle@users.noreply.github.com>
2026-04-13 10:30:58 +02:00
Nand Gopal Sharma e18c1dd213 Fix typo in Discord badge URL parameter (#11096) 2026-04-02 10:37:02 +02:00
David LuzarandMark Tolmacs d9e8a33aa4 feat(editor): implement overlap box selection (#11053)
Co-authored-by: Mark Tolmacs <mark@lazycat.hu>
2026-04-01 18:41:11 +02:00
dagecko 4be4cc0ed0 fix: pin 9 actions to commit SHA (#11075) 2026-03-30 16:49:27 +02:00
David Luzar 4a5c9e990c fix(editor): ensure font picker font names are not quoted (#11036) 2026-03-25 17:56:48 +01:00
c09e170bdd feat(editor): deselect on esc (#11035)
Co-authored-by: Jawahar <jawahars_16@live.in>
Co-authored-by: Andrew Aquino <dawneraq@gmail.com>
2026-03-25 17:14:24 +01:00
David Luzar c1082923ee feat(editor): support mermaid staate diagrams (#11031) 2026-03-24 20:20:28 +01:00
KundanandMark Tolmacs 1c292e4936 fix(math): correctly validate second point in isLineSegment (#11007)
Co-authored-by: Mark Tolmacs <mark@lazycat.hu>
2026-03-24 19:01:08 +01:00
Márk Tolmács d6f0f34fe9 fix: Rotated rounded arrow center point (#10962) 2026-03-23 15:54:59 +01:00
Márk Tolmácsanddwelle 75789f620d fix: Other endpoint is not immediately updated on midpoint snap (#10933)
Co-authored-by: dwelle <5153846+dwelle@users.noreply.github.com>
2026-03-23 14:54:44 +00:00
David Luzar a9ca16eb42 chore(packages/excalidraw): export Fonts helper class (#11008) 2026-03-21 22:44:27 +01:00
Márk Tolmács 987173b52f fix: Arrow point index Out-of-Bounds (#10922)
* fix: Make OOB not fatal

Signed-off-by: Mark Tolmacs <mark@lazycat.hu>

* fix: More conservative temp arrow state update

Signed-off-by: Mark Tolmacs <mark@lazycat.hu>

* chore: Capture condition variables in binding restoration failure

Signed-off-by: Mark Tolmacs <mark@lazycat.hu>

---------

Signed-off-by: Mark Tolmacs <mark@lazycat.hu>
2026-03-21 19:26:47 +01:00
David Luzar 81ab857a6f feat(editor): various text related improvements (#10979) 2026-03-19 16:00:58 +01:00
David Luzar e8b4620a96 feat(editor): put caret at pointer coords when clicking on selected text element (#10970) 2026-03-18 19:14:44 +01:00
David Luzar 2b0e4c9623 fix(editor): remove leftover debug code path (#10954) 2026-03-14 13:12:48 +01:00
David Luzar c9ba7f839c chore(editor): bump @excalidraw/mermaid-to-excalidraw@2.1.1 (#10944) 2026-03-12 17:32:13 +01:00
David Luzar b4ce7c713b fix(editor): arrowhead picker overflowing viewport (#10943) 2026-03-12 16:08:46 +01:00
David Luzar 816c81c12e feat(editor): ERD arrowheads and diagrams (#10940) 2026-03-11 22:00:31 +01:00
David Luzar 92d25446d6 feat(packages/excalidraw): tweak and expose more API around state and lifecycle (#10939) 2026-03-11 16:51:03 +01:00
David Luzar e73a5b0116 docs(packages/excalidraw): improve readme (#10932) 2026-03-11 09:49:12 +01:00
David Luzar 21dd1cfacc feat(packages/excalidraw): state tracking, api hook, and others (#10870) 2026-03-08 23:15:18 +01:00
David Luzar fa1f7d9f22 feat(packages/excalidraw): export throttleRAF (#10912) 2026-03-07 12:05:33 +01:00
David Luzar 3d8c12fba4 fix(editor): do not conditionally disable midpoint snapping menu preference (#10906) 2026-03-06 20:44:57 +01:00
757dfeb6ad fix(editor): call throttleRAF with lastArgs and remove trailing (#10905)
Co-authored-by: Varun Chawla <varun_6april@hotmail.com>
Co-authored-by: aziamimoh <aziamimoh@users.noreply.github.com>
Co-authored-by: pgzcoa <pgzcoa@users.noreply.github.com>
Co-authored-by: TinaZhang24 <TinaZhang24@users.noreply.github.com>
2026-03-06 20:40:36 +01:00
David Luzar a0e93b6040 feat(editor): sync export theme with ui theme (#10903) 2026-03-06 18:37:28 +01:00
Hendrik Horstmann 499e9d64a5 fix: dropdownMenu item badge position (#10895) 2026-03-06 08:41:49 +00:00
David Luzar c1dbbdf678 feat(editor): mermaid code editor & improve parsing (#10897) 2026-03-05 18:52:41 +01:00
David Luzar 47c254216b fix(editor): disable snap-to-midpoint menu item when arrow-binding disabled (#10885) 2026-03-04 16:48:33 +01:00
Hendrik Horstmann d1cff91b75 fix: spacing in the left menu (#10880) 2026-03-03 22:11:30 +00:00
Márk Tolmácsanddwelle 437595fa65 feat: Arrow binding is a preference (#10839)
Co-authored-by: dwelle <5153846+dwelle@users.noreply.github.com>
2026-03-03 21:55:40 +00:00
David Luzar 60b275880d feat(editor): support radar chart and multiple series for other chart types (#10824) 2026-02-26 16:13:15 +01:00
zsviczian cae9d2bcbd fix: "hand" tool active after exiting view mode if laser point was used (#10841) 2026-02-26 12:55:13 +01:00
David Luzar 2874f9e48c fix(editor): simplify and fix midpoint highlighting (#10832) 2026-02-24 21:11:46 +01:00
Márk Tolmács 0b3a5e7cc4 fix: Multi-point arrow bound point update (#10831)
Signed-off-by: Mark Tolmacs <mark@lazycat.hu>
2026-02-24 13:32:44 +01:00
Márk Tolmácsanddwelle 7ea3229e17 fix(editor): Hardened fixed point and bound element parsing in restore (#10816)
* fix: Reinforce fixedPoint restore

Signed-off-by: Mark Tolmacs <mark@lazycat.hu>

* fix: Even more hardened boundElement in restore

Signed-off-by: Mark Tolmacs <mark@lazycat.hu>

* fix: Extract constant

Signed-off-by: Mark Tolmacs <mark@lazycat.hu>

* fix: Remove superfluous check from restore

Signed-off-by: Mark Tolmacs <mark@lazycat.hu>

* chore: Remove non-needed code path

Signed-off-by: Mark Tolmacs <mark@lazycat.hu>

* fix: More robust number test for fixedPoint parsing

Signed-off-by: Mark Tolmacs <mark@lazycat.hu>

* fix: Validate bindings for element being parsed

Signed-off-by: Mark Tolmacs <mark@lazycat.hu>

* unrelated type safety

---------

Signed-off-by: Mark Tolmacs <mark@lazycat.hu>
Co-authored-by: dwelle <5153846+dwelle@users.noreply.github.com>
2026-02-23 19:22:27 +00:00
David Luzar b0404b10b6 chore(debug): add debug.logChanged() and make easy to import (#10828) 2026-02-23 20:20:37 +01:00
David Luzar eb959128ac feat(editor): allow laser-pointing in view mode (#10802)
* feat(editor): allow laser pointing in view mode

* feat: allow switching between laser/hand in view mode

* fix lint

* factor out to utils

* fix: only handle primary clicks with the selection/laser tools
2026-02-20 22:49:46 +01:00
4c3d037f9c feat(editor): allow clicking on links and embeds with laser tool (#10797)
Co-authored-by: Anvi <anvikudaraya417@gmail.com>
Co-authored-by: Chris Tangonan <ctangonan123@gmail.com>
2026-02-19 11:45:01 +01:00
Márk Tolmácsanddwelle 5852d0d410 fix: Arrow overlap arrow behavior (#10732)
* fix(arrow): Overlap arrow behavior

Signed-off-by: Mark Tolmacs <mark@lazycat.hu>

* fix: Lint

Signed-off-by: Mark Tolmacs <mark@lazycat.hu>

* feat(editor): reduce binding gap (#10739)

* feat(editor): reduce binding gap to 7px

* feat(editor): reduce binding gap to 5px

* feat(editor): reduce binding gap to 3px

* go back to 5px

* update tests

* feat: Simplified update bind points

Signed-off-by: Mark Tolmacs <mark@lazycat.hu>

* fix: Remove non-needed export

Signed-off-by: Mark Tolmacs <mark@lazycat.hu>

* fix. Possessed arrows #1

Signed-off-by: Mark Tolmacs <mark@lazycat.hu>

* fix: Focus point projection stabilization

Signed-off-by: Mark Tolmacs <mark@lazycat.hu>

* fix: Remove arrow stability hack

Signed-off-by: Mark Tolmacs <mark@lazycat.hu>

* fix: Unbound other endpoint

Signed-off-by: Mark Tolmacs <mark@lazycat.hu>

* feat(editor): visualize binding midpoints + support for simple arrows (#10611)

* feat: Force exact center focus point

When the projected point is close to center snap it to the exact center.

Signed-off-by: Mark Tolmacs <mark@lazycat.hu>

* fix: Tests

Signed-off-by: Mark Tolmacs <mark@lazycat.hu>

* fix: Snap to center around side mid point.

Signed-off-by: Mark Tolmacs <mark@lazycat.hu>

* Trigger CI

* fix: Midpoint outline focus point

Signed-off-by: Mark Tolmacs <mark@lazycat.hu>

* fix: Tests

Signed-off-by: Mark Tolmacs <mark@lazycat.hu>

* fix: Dragging existing arrow reset focus point on outline

Signed-off-by: Mark Tolmacs <mark@lazycat.hu>

* fix: Tests

Signed-off-by: Mark Tolmacs <mark@lazycat.hu>

* feat: Midpoint indicator

Signed-off-by: Mark Tolmacs <mark@lazycat.hu>

* fix: Rotated mid points

Signed-off-by: Mark Tolmacs <mark@lazycat.hu>

* fix: No hole

Signed-off-by: Mark Tolmacs <mark@lazycat.hu>

* feat: Cache hits and scene lookups

Signed-off-by: Mark Tolmacs <mark@lazycat.hu>

* chore: Remove debug

Signed-off-by: Mark Tolmacs <mark@lazycat.hu>

* fix: Consider hit threshold and inside override too

Signed-off-by: Mark Tolmacs <mark@lazycat.hu>

* fix: Increase outline midpoint sticky distance

Signed-off-by: Mark Tolmacs <mark@lazycat.hu>

* fix: Don't show midpoint indicator when no snapping is possible

Signed-off-by: Mark Tolmacs <mark@lazycat.hu>

* feat: Indicate lock-in

Signed-off-by: Mark Tolmacs <mark@lazycat.hu>

* chore: Remove Map caching

Signed-off-by: Mark Tolmacs <mark@lazycat.hu>

* fix: incorrect threshold

Signed-off-by: Mark Tolmacs <mark@lazycat.hu>

* fix: threshold setting

Signed-off-by: Mark Tolmacs <mark@lazycat.hu>

* fix: Hit caching

Signed-off-by: Mark Tolmacs <mark@lazycat.hu>

* fix: Simple arrow mid point selection inconsistency

Signed-off-by: Mark Tolmacs <mark@lazycat.hu>

* fix: cache override

Signed-off-by: Mark Tolmacs <mark@lazycat.hu>

* fix: Precise know dragging with midpoint refactor

Signed-off-by: Mark Tolmacs <mark@lazycat.hu>

* fear: Frame support

Signed-off-by: Mark Tolmacs <mark@lazycat.hu>

* fix: Crossing arrow won't trigger mid point

Signed-off-by: Mark Tolmacs <mark@lazycat.hu>

* fix: Arrow creation point highlight

Signed-off-by: Mark Tolmacs <mark@lazycat.hu>

* fix: Restore types & tests

Signed-off-by: Mark Tolmacs <mark@lazycat.hu>

* chore: Restore restore.ts

Signed-off-by: Mark Tolmacs <mark@lazycat.hu>

* fix: restore.ts

Signed-off-by: Mark Tolmacs <mark@lazycat.hu>

* fix: Elbow arrows reliably highlight center point

Signed-off-by: Mark Tolmacs <mark@lazycat.hu>

* fix: Highlight point ordering

Signed-off-by: Mark Tolmacs <mark@lazycat.hu>

* feat: Bind with focus point across shape

Signed-off-by: Mark Tolmacs <mark@lazycat.hu>

* fix: Lint

* fix: Midpoint and binding alignment

Signed-off-by: Mark Tolmacs <mark@lazycat.hu>

* chore: Indicator color

Signed-off-by: Mark Tolmacs <mark@lazycat.hu>

* chore: More knob tuning

Signed-off-by: Mark Tolmacs <mark@lazycat.hu>

* fix: Radius

Signed-off-by: Mark Tolmacs <mark@lazycat.hu>

* fix: Tests

Signed-off-by: Mark Tolmacs <mark@lazycat.hu>

* simplify point indicators

---------

Signed-off-by: Mark Tolmacs <mark@lazycat.hu>
Co-authored-by: dwelle <5153846+dwelle@users.noreply.github.com>

* fix: Tests

Signed-off-by: Mark Tolmacs <mark@lazycat.hu>

* fix: Snapshots

Signed-off-by: Mark Tolmacs <mark@lazycat.hu>

* fix: Target point selection

Signed-off-by: Mark Tolmacs <mark@lazycat.hu>

* chore: Remove non-needed change

Signed-off-by: Mark Tolmacs <mark@lazycat.hu>

* chore: Try again removing non-needed modification

Signed-off-by: Mark Tolmacs <mark@lazycat.hu>

* fix: Inside-inside binding arrow endpoint drag trigger focus point editor

Signed-off-by: Mark Tolmacs <mark@lazycat.hu>

* fix: Area based edge case

Signed-off-by: Mark Tolmacs <mark@lazycat.hu>

* fix: Overlapping new arrow jump

Signed-off-by: Mark Tolmacs <mark@lazycat.hu>

---------

Signed-off-by: Mark Tolmacs <mark@lazycat.hu>
Co-authored-by: David Luzar <5153846+dwelle@users.noreply.github.com>
2026-02-17 14:10:05 +01:00
Christopher Tangonan c1e00c44f5 fix: convert ArrowheadNoneIcon to component matching arrowhead icon pattern (#10789) 2026-02-17 07:57:09 +01:00
Márk Tolmács ffcb67b21f fix: Inside-inside bound arrow endpoint drag trigger focus point editor (#10771)
fix: Inside-inside binding arrow endpoint drag trigger focus point editor

Signed-off-by: Mark Tolmacs <mark@lazycat.hu>
2026-02-16 22:25:28 +01:00
David Luzar 46ddd60948 feat(editor): support embedding google drive videos (#10788) 2026-02-16 22:19:11 +01:00
Christopher Tangonan 89a9badc27 fix: update arrowhead property defaultValue handling (#10778) 2026-02-13 22:33:52 +01:00
zsviczian 8b3e149db6 fix: hex8 regression #10578 (#10773) 2026-02-12 22:03:13 +01:00
Márk Tolmácsanddwelle a70417f23f feat(editor): visualize binding midpoints + support for simple arrows (#10611)
* feat: Force exact center focus point

When the projected point is close to center snap it to the exact center.

Signed-off-by: Mark Tolmacs <mark@lazycat.hu>

* fix: Tests

Signed-off-by: Mark Tolmacs <mark@lazycat.hu>

* fix: Snap to center around side mid point.

Signed-off-by: Mark Tolmacs <mark@lazycat.hu>

* Trigger CI

* fix: Midpoint outline focus point

Signed-off-by: Mark Tolmacs <mark@lazycat.hu>

* fix: Tests

Signed-off-by: Mark Tolmacs <mark@lazycat.hu>

* fix: Dragging existing arrow reset focus point on outline

Signed-off-by: Mark Tolmacs <mark@lazycat.hu>

* fix: Tests

Signed-off-by: Mark Tolmacs <mark@lazycat.hu>

* feat: Midpoint indicator

Signed-off-by: Mark Tolmacs <mark@lazycat.hu>

* fix: Rotated mid points

Signed-off-by: Mark Tolmacs <mark@lazycat.hu>

* fix: No hole

Signed-off-by: Mark Tolmacs <mark@lazycat.hu>

* feat: Cache hits and scene lookups

Signed-off-by: Mark Tolmacs <mark@lazycat.hu>

* chore: Remove debug

Signed-off-by: Mark Tolmacs <mark@lazycat.hu>

* fix: Consider hit threshold and inside override too

Signed-off-by: Mark Tolmacs <mark@lazycat.hu>

* fix: Increase outline midpoint sticky distance

Signed-off-by: Mark Tolmacs <mark@lazycat.hu>

* fix: Don't show midpoint indicator when no snapping is possible

Signed-off-by: Mark Tolmacs <mark@lazycat.hu>

* feat: Indicate lock-in

Signed-off-by: Mark Tolmacs <mark@lazycat.hu>

* chore: Remove Map caching

Signed-off-by: Mark Tolmacs <mark@lazycat.hu>

* fix: incorrect threshold

Signed-off-by: Mark Tolmacs <mark@lazycat.hu>

* fix: threshold setting

Signed-off-by: Mark Tolmacs <mark@lazycat.hu>

* fix: Hit caching

Signed-off-by: Mark Tolmacs <mark@lazycat.hu>

* fix: Simple arrow mid point selection inconsistency

Signed-off-by: Mark Tolmacs <mark@lazycat.hu>

* fix: cache override

Signed-off-by: Mark Tolmacs <mark@lazycat.hu>

* fix: Precise know dragging with midpoint refactor

Signed-off-by: Mark Tolmacs <mark@lazycat.hu>

* fear: Frame support

Signed-off-by: Mark Tolmacs <mark@lazycat.hu>

* fix: Crossing arrow won't trigger mid point

Signed-off-by: Mark Tolmacs <mark@lazycat.hu>

* fix: Arrow creation point highlight

Signed-off-by: Mark Tolmacs <mark@lazycat.hu>

* fix: Restore types & tests

Signed-off-by: Mark Tolmacs <mark@lazycat.hu>

* chore: Restore restore.ts

Signed-off-by: Mark Tolmacs <mark@lazycat.hu>

* fix: restore.ts

Signed-off-by: Mark Tolmacs <mark@lazycat.hu>

* fix: Elbow arrows reliably highlight center point

Signed-off-by: Mark Tolmacs <mark@lazycat.hu>

* fix: Highlight point ordering

Signed-off-by: Mark Tolmacs <mark@lazycat.hu>

* feat: Bind with focus point across shape

Signed-off-by: Mark Tolmacs <mark@lazycat.hu>

* fix: Lint

* fix: Midpoint and binding alignment

Signed-off-by: Mark Tolmacs <mark@lazycat.hu>

* chore: Indicator color

Signed-off-by: Mark Tolmacs <mark@lazycat.hu>

* chore: More knob tuning

Signed-off-by: Mark Tolmacs <mark@lazycat.hu>

* fix: Radius

Signed-off-by: Mark Tolmacs <mark@lazycat.hu>

* fix: Tests

Signed-off-by: Mark Tolmacs <mark@lazycat.hu>

* simplify point indicators

---------

Signed-off-by: Mark Tolmacs <mark@lazycat.hu>
Co-authored-by: dwelle <5153846+dwelle@users.noreply.github.com>
2026-02-11 11:06:27 +01:00
Márk TolmácsandDavid Luzar 1c8e8bb0f3 fix: Arrow update when cloned (#10747)
* fix: Arrow update when cloned

Signed-off-by: Mark Tolmacs <mark@lazycat.hu>

* feat(editor): reduce binding gap (#10739)

* feat(editor): reduce binding gap to 7px

* feat(editor): reduce binding gap to 5px

* feat(editor): reduce binding gap to 3px

* go back to 5px

* update tests

* chore: Refactor

Signed-off-by: Mark Tolmacs <mark@lazycat.hu>

* fix: Align focus points

Signed-off-by: Mark Tolmacs <mark@lazycat.hu>

---------

Signed-off-by: Mark Tolmacs <mark@lazycat.hu>
Co-authored-by: David Luzar <5153846+dwelle@users.noreply.github.com>
2026-02-09 13:03:05 +01:00
David Luzar 3a5ef4020d feat(app): add preferences to main menu (#10760)
feat(editor): add preferences to main menu
2026-02-08 23:30:45 +01:00
David LuzarandBarnabás Molnár 063533aede feat(packages/excalidraw): support nested dropdown menu (#10749)
Co-authored-by: Barnabás Molnár <38168628+barnabasmolnar@users.noreply.github.com>
2026-02-08 22:27:34 +01:00
Márk Tolmács b43260d97b fix: Other binding converted from fixed to orbit unconditionally (#10748)
* fix: Other binding converted from fixed to orbit unconditionally

Signed-off-by: Mark Tolmacs <mark@lazycat.hu>

* fix: New arrow creation

Signed-off-by: Mark Tolmacs <mark@lazycat.hu>

* fix: Alt point setting on inside binding

Signed-off-by: Mark Tolmacs <mark@lazycat.hu>

* fix: Initial arrow creation with Alt

Signed-off-by: Mark Tolmacs <mark@lazycat.hu>

---------

Signed-off-by: Mark Tolmacs <mark@lazycat.hu>
2026-02-06 15:01:07 +01:00
David Luzar 83d3943cd0 feat(editor): reduce binding gap (#10739)
* feat(editor): reduce binding gap to 7px

* feat(editor): reduce binding gap to 5px

* feat(editor): reduce binding gap to 3px

* go back to 5px

* update tests
2026-02-05 12:00:56 +01:00
Márk Tolmács f39ac4a653 fix(editor): On focus drag only update other binding if it's orbit (#10730)
fix(focus): Only update other binding if it's orbit

Signed-off-by: Mark Tolmacs <mark@lazycat.hu>
2026-02-02 20:12:56 +01:00
David Luzar 54a9826817 fix(editor): copying to clipboard with no ClipboardEvent (#10729)
* fix(editor): copying to clipboard with no ClipboardEvent

* fix(editor): use green for `success` state of `FilledButton`
2026-02-01 11:06:37 +01:00
David Luzar d29fd62e41 fix(editor): crop editor cursor drift (#10727)
* fix(editor): do not scale cropping editor pointer offsets

* fix lint

* fix more lint

* fix drift related to image canvas scale
2026-02-01 10:45:04 +01:00
David Luzar b57f3e0096 fix(editor): image positioning in crop editor (#10726) 2026-02-01 09:21:30 +01:00
Excalidraw Bot f12ae80ba1 chore: Update translations from Crowdin (#10598)
* New translations en.json (Russian)

* New translations en.json (Vietnamese)

* New translations en.json (Russian)

* New translations en.json (Romanian)

* New translations en.json (French)

* New translations en.json (Spanish)

* New translations en.json (Arabic)

* New translations en.json (Bulgarian)

* New translations en.json (Catalan)

* New translations en.json (Czech)

* New translations en.json (Danish)

* New translations en.json (German)

* New translations en.json (Greek)

* New translations en.json (Basque)

* New translations en.json (Finnish)

* New translations en.json (Hebrew)

* New translations en.json (Hungarian)

* New translations en.json (Italian)

* New translations en.json (Japanese)

* New translations en.json (Korean)

* New translations en.json (Kurdish)

* New translations en.json (Lithuanian)

* New translations en.json (Dutch)

* New translations en.json (Punjabi)

* New translations en.json (Polish)

* New translations en.json (Portuguese)

* New translations en.json (Slovak)

* New translations en.json (Slovenian)

* New translations en.json (Swedish)

* New translations en.json (Turkish)

* New translations en.json (Ukrainian)

* New translations en.json (Chinese Simplified)

* New translations en.json (Chinese Traditional)

* New translations en.json (Galician)

* New translations en.json (Portuguese, Brazilian)

* New translations en.json (Indonesian)

* New translations en.json (Persian)

* New translations en.json (Khmer)

* New translations en.json (Tamil)

* New translations en.json (Bengali)

* New translations en.json (Marathi)

* New translations en.json (Thai)

* New translations en.json (Norwegian Nynorsk)

* New translations en.json (Kazakh)

* New translations en.json (Latvian)

* New translations en.json (Azerbaijani)

* New translations en.json (Hindi)

* New translations en.json (Burmese)

* New translations en.json (Chinese Traditional, Hong Kong)

* New translations en.json (Sinhala)

* New translations en.json (Uzbek)

* New translations en.json (Norwegian Bokmal)

* New translations en.json (Occitan)

* New translations en.json (German, Switzerland)

* New translations en.json (Bengali, India)

* New translations en.json (Kabyle)

* New translations en.json (Karakalpak)

* Auto commit: Calculate translation coverage

* New translations en.json (Russian)

* Auto commit: Calculate translation coverage

* New translations en.json (Romanian)

* Auto commit: Calculate translation coverage

* New translations en.json (Italian)

* Auto commit: Calculate translation coverage

* New translations en.json (Italian)

* Auto commit: Calculate translation coverage

* New translations en.json (Hungarian)

* New translations en.json (Hungarian)

* New translations en.json (Hindi)

* New translations en.json (Dutch)

* New translations en.json (Dutch)

* New translations en.json (Vietnamese)

* New translations en.json (Russian)

* New translations en.json (Romanian)

* New translations en.json (French)

* New translations en.json (Spanish)

* New translations en.json (Arabic)

* New translations en.json (Bulgarian)

* New translations en.json (Catalan)

* New translations en.json (Czech)

* New translations en.json (Danish)

* New translations en.json (German)

* New translations en.json (Greek)

* New translations en.json (Basque)

* New translations en.json (Finnish)

* New translations en.json (Hebrew)

* New translations en.json (Hungarian)

* New translations en.json (Italian)

* New translations en.json (Japanese)

* New translations en.json (Korean)

* New translations en.json (Kurdish)

* New translations en.json (Lithuanian)

* New translations en.json (Dutch)

* New translations en.json (Punjabi)

* New translations en.json (Polish)

* New translations en.json (Portuguese)

* New translations en.json (Slovak)

* New translations en.json (Slovenian)

* New translations en.json (Swedish)

* New translations en.json (Turkish)

* New translations en.json (Ukrainian)

* New translations en.json (Chinese Simplified)

* New translations en.json (Chinese Traditional)

* New translations en.json (Galician)

* New translations en.json (Portuguese, Brazilian)

* New translations en.json (Indonesian)

* New translations en.json (Persian)

* New translations en.json (Khmer)

* New translations en.json (Tamil)

* New translations en.json (Bengali)

* New translations en.json (Marathi)

* New translations en.json (Thai)

* New translations en.json (Norwegian Nynorsk)

* New translations en.json (Kazakh)

* New translations en.json (Latvian)

* New translations en.json (Azerbaijani)

* New translations en.json (Hindi)

* New translations en.json (Burmese)

* New translations en.json (Chinese Traditional, Hong Kong)

* New translations en.json (Sinhala)

* New translations en.json (Uzbek)

* New translations en.json (Norwegian Bokmal)

* New translations en.json (Occitan)

* New translations en.json (German, Switzerland)

* New translations en.json (Bengali, India)

* New translations en.json (Kabyle)

* New translations en.json (Karakalpak)

* New translations en.json (Romanian)

* New translations en.json (Vietnamese)

* New translations en.json (Russian)

* New translations en.json (Romanian)

* New translations en.json (French)

* New translations en.json (Spanish)

* New translations en.json (Arabic)

* New translations en.json (Bulgarian)

* New translations en.json (Catalan)

* New translations en.json (Czech)

* New translations en.json (Danish)

* New translations en.json (German)

* New translations en.json (Greek)

* New translations en.json (Basque)

* New translations en.json (Finnish)

* New translations en.json (Hebrew)

* New translations en.json (Hungarian)

* New translations en.json (Italian)

* New translations en.json (Japanese)

* New translations en.json (Korean)

* New translations en.json (Kurdish)

* New translations en.json (Lithuanian)

* New translations en.json (Dutch)

* New translations en.json (Punjabi)

* New translations en.json (Polish)

* New translations en.json (Portuguese)

* New translations en.json (Slovak)

* New translations en.json (Slovenian)

* New translations en.json (Swedish)

* New translations en.json (Turkish)

* New translations en.json (Ukrainian)

* New translations en.json (Chinese Simplified)

* New translations en.json (Chinese Traditional)

* New translations en.json (Galician)

* New translations en.json (Portuguese, Brazilian)

* New translations en.json (Indonesian)

* New translations en.json (Persian)

* New translations en.json (Khmer)

* New translations en.json (Tamil)

* New translations en.json (Bengali)

* New translations en.json (Marathi)

* New translations en.json (Thai)

* New translations en.json (Norwegian Nynorsk)

* New translations en.json (Kazakh)

* New translations en.json (Latvian)

* New translations en.json (Azerbaijani)

* New translations en.json (Hindi)

* New translations en.json (Burmese)

* New translations en.json (Chinese Traditional, Hong Kong)

* New translations en.json (Sinhala)

* New translations en.json (Uzbek)

* New translations en.json (Norwegian Bokmal)

* New translations en.json (Occitan)

* New translations en.json (German, Switzerland)

* New translations en.json (Bengali, India)

* New translations en.json (Kabyle)

* New translations en.json (Karakalpak)

* New translations en.json (Romanian)

* New translations en.json (Italian)

* New translations en.json (Russian)

* New translations en.json (Romanian)

* New translations en.json (French)

* New translations en.json (Spanish)

* New translations en.json (Arabic)

* New translations en.json (Bulgarian)

* New translations en.json (Catalan)

* New translations en.json (Czech)

* New translations en.json (Danish)

* New translations en.json (German)

* New translations en.json (Greek)

* New translations en.json (Basque)

* New translations en.json (Finnish)

* New translations en.json (Hebrew)

* New translations en.json (Hungarian)

* New translations en.json (Italian)

* New translations en.json (Japanese)

* New translations en.json (Korean)

* New translations en.json (Kurdish)

* New translations en.json (Lithuanian)

* New translations en.json (Dutch)

* New translations en.json (Punjabi)

* New translations en.json (Polish)

* New translations en.json (Portuguese)

* New translations en.json (Slovak)

* New translations en.json (Slovenian)

* New translations en.json (Swedish)

* New translations en.json (Vietnamese)

* New translations en.json (Turkish)

* New translations en.json (Ukrainian)

* New translations en.json (Chinese Simplified)

* New translations en.json (Chinese Traditional)

* New translations en.json (Galician)

* New translations en.json (Portuguese, Brazilian)

* New translations en.json (Indonesian)

* New translations en.json (Persian)

* New translations en.json (Khmer)

* New translations en.json (Tamil)

* New translations en.json (Bengali)

* New translations en.json (Marathi)

* New translations en.json (Thai)

* New translations en.json (Norwegian Nynorsk)

* New translations en.json (Kazakh)

* New translations en.json (Latvian)

* New translations en.json (Azerbaijani)

* New translations en.json (Hindi)

* New translations en.json (Burmese)

* New translations en.json (Chinese Traditional, Hong Kong)

* New translations en.json (Sinhala)

* New translations en.json (Uzbek)

* New translations en.json (Norwegian Bokmal)

* New translations en.json (Occitan)

* New translations en.json (German, Switzerland)

* New translations en.json (Bengali, India)

* New translations en.json (Kabyle)

* New translations en.json (Karakalpak)

* New translations en.json (Romanian)

* Auto commit: Calculate translation coverage
2026-01-31 22:12:24 +01:00
David Luzar f7b537a8b1 feat(packages/excalidraw): export CommandPalette (#10724)
feat: export CommandPalette
2026-01-31 20:24:20 +01:00
Yashanddwelle 94364af68f fix: Clarify welcome screen message about browser storage limitations (#10721)
* fix: Clarify welcome screen message about browser storage limitations

* css tweaks

* update snaps

---------

Co-authored-by: dwelle <5153846+dwelle@users.noreply.github.com>
2026-01-31 17:15:14 +01:00
zsvicziananddwelle dfa1ce572b fix: SVG Inversion on Safari (#10712)
* invert image on safari

* lint

* Inversion to match theme filter

* cleanup

* Adjust canvas dimensions for device pixel ratio when inverting on Safari

* revert inversion algo & handle darkMode placeholder

---------

Co-authored-by: dwelle <5153846+dwelle@users.noreply.github.com>
2026-01-31 16:28:21 +01:00
Márk Tolmácsanddwelle b552c60714 feat: Focus indicator (#10613)
* feat: Focus indicator

Signed-off-by: Mark Tolmacs <mark@lazycat.hu>

* fix: Snapshot update

Signed-off-by: Mark Tolmacs <mark@lazycat.hu>

* feat: Move visualdebug to utils and introduce volume bindable volume visualization

Signed-off-by: Mark Tolmacs <mark@lazycat.hu>

* fix: Move visualdebug to elements

Due to dep circles

Signed-off-by: Mark Tolmacs <mark@lazycat.hu>

* fix: Possible test timeout

Signed-off-by: Mark Tolmacs <mark@lazycat.hu>

* fix: Incorrect hit test point input

Signed-off-by: Mark Tolmacs <mark@lazycat.hu>

* feat: Add fallback when dragged outside of allowed area

Signed-off-by: Mark Tolmacs <mark@lazycat.hu>

* fix: Elbow arrows don't need focus point mgmt

Signed-off-by: Mark Tolmacs <mark@lazycat.hu>

* fix: End bound indirect fix

Signed-off-by: Mark Tolmacs <mark@lazycat.hu>

* fix: Show indicator when arrow endpoint dragging

Signed-off-by: Mark Tolmacs <mark@lazycat.hu>

* fix: Update bound arrow endpoint at mid-point drag

Signed-off-by: Mark Tolmacs <mark@lazycat.hu>

* chore: Refactor

Signed-off-by: Mark Tolmacs <mark@lazycat.hu>

* fix: Curve endpoint intersection

Signed-off-by: Mark Tolmacs <mark@lazycat.hu>

* fix: Outline focus point is reset on existing arrow drag

Signed-off-by: Mark Tolmacs <mark@lazycat.hu>

* fix: Tests

Signed-off-by: Mark Tolmacs <mark@lazycat.hu>

* chore: Fix lint

Signed-off-by: Mark Tolmacs <mark@lazycat.hu>

* feat: Dragging focus point off

Signed-off-by: Mark Tolmacs <mark@lazycat.hu>

* fix: Don't show the focus indicator when arrow endpoint is dragged

Signed-off-by: Mark Tolmacs <mark@lazycat.hu>

* fix: Drag area for focus handles

Signed-off-by: Mark Tolmacs <mark@lazycat.hu>

* fix: Focus point size unified

Signed-off-by: Mark Tolmacs <mark@lazycat.hu>

* fix: Size bump for focus knob

Signed-off-by: Mark Tolmacs <mark@lazycat.hu>

* feat: Cache hits and scene lookups

Signed-off-by: Mark Tolmacs <mark@lazycat.hu>

* chore: Remove debug

Signed-off-by: Mark Tolmacs <mark@lazycat.hu>

* fix: Consider hit threshold and inside override too

Signed-off-by: Mark Tolmacs <mark@lazycat.hu>

* fix: Other shape switching

Signed-off-by: Mark Tolmacs <mark@lazycat.hu>

* perf: Update tolerance params

Signed-off-by: Mark Tolmacs <mark@lazycat.hu>

* fix: Focus know line width

Signed-off-by: Mark Tolmacs <mark@lazycat.hu>

* fix: knob offset

Signed-off-by: Mark Tolmacs <mark@lazycat.hu>

* fix: Full overlap

Signed-off-by: Mark Tolmacs <mark@lazycat.hu>

* chore: Remove Map caching

Signed-off-by: Mark Tolmacs <mark@lazycat.hu>

* fix: incorrect threshold

Signed-off-by: Mark Tolmacs <mark@lazycat.hu>

* fix: threshold setting

Signed-off-by: Mark Tolmacs <mark@lazycat.hu>

* fix: Hit caching

Signed-off-by: Mark Tolmacs <mark@lazycat.hu>

* fix: cache override

Signed-off-by: Mark Tolmacs <mark@lazycat.hu>

* fix: Snapshots

Signed-off-by: Mark Tolmacs <mark@lazycat.hu>

* feat: Redesigned focus point handling

Signed-off-by: Mark Tolmacs <mark@lazycat.hu>

* fix: Inside-inside mode

Signed-off-by: Mark Tolmacs <mark@lazycat.hu>

* chore: Remove comment

Signed-off-by: Mark Tolmacs <mark@lazycat.hu>

* feat: Allow focus knob outside the shape

Signed-off-by: Mark Tolmacs <mark@lazycat.hu>

* fix: Arrow endpoint offset

Signed-off-by: Mark Tolmacs <mark@lazycat.hu>

* fix: Focus knob element distance

Signed-off-by: Mark Tolmacs <mark@lazycat.hu>

* fix: Increase iteration on curve intersection calc

Signed-off-by: Mark Tolmacs <mark@lazycat.hu>

* fix: Handle disabled binding

Signed-off-by: Mark Tolmacs <mark@lazycat.hu>

* fix: Alt mode

Signed-off-by: Mark Tolmacs <mark@lazycat.hu>

* fix: Nested shape focus rewrite

Signed-off-by: Mark Tolmacs <mark@lazycat.hu>

* fix: Alt + Ctrl + arrow endpoitn

Signed-off-by: Mark Tolmacs <mark@lazycat.hu>

* fix: Hit ordering for focus points

Signed-off-by: Mark Tolmacs <mark@lazycat.hu>

* fix: Focus point visibility

Signed-off-by: Mark Tolmacs <mark@lazycat.hu>

* dry out renderFocusPointIndicator

* do not higlight point when dragging & make focus point smaller

* optimize retrieval of selectedLinearElement

* move focus highlighting into renderFocusPointIndicator to DRY out and colocate

* remove `disabled` state from focus highlight

* make focus point stroke color less prominent

* fix: No focus point for multi-point arrows

Signed-off-by: Mark Tolmacs <mark@lazycat.hu>

* fix: Arrow edit mode drag focus point release

Signed-off-by: Mark Tolmacs <mark@lazycat.hu>

* DRY out arrow point-like drag

* move `focus.ts` to `arrows/focus.ts`

---------

Signed-off-by: Mark Tolmacs <mark@lazycat.hu>
Co-authored-by: dwelle <5153846+dwelle@users.noreply.github.com>
2026-01-31 15:08:28 +01:00
Márk Tolmács 216afc3625 fix: Coherent stats binding (#10718)
Signed-off-by: Mark Tolmacs <mark@lazycat.hu>
2026-01-30 22:20:46 +01:00
David Luzar 802cde3501 feat: support customizing TTD welcome screen (#10719)
* feat: support customizing TTD welcome screen

* remove debug
2026-01-30 16:28:36 +01:00
David LuzarandAshutosh Kumar f5cf81ce42 feat: prevent pasting excalidraw into textarea & paste element text if avail (#10710)
* feat: prevent pasting excalidraw json into textarea & paste element text if avail

Co-authored-by: Ashutosh Kumar <130897584+codeaashu@users.noreply.github.com>

* fix FF

---------

Co-authored-by: Ashutosh Kumar <130897584+codeaashu@users.noreply.github.com>
2026-01-28 22:35:39 +01:00
Márk Tolmács 6a891365b9 fix: Arrow endpoint offset (#10706)
Signed-off-by: Mark Tolmacs <mark@lazycat.hu>
2026-01-26 20:55:08 +01:00
Márk Tolmács 54fa0c9089 fix: Increase iteration on curve intersection calc (#10707)
Signed-off-by: Mark Tolmacs <mark@lazycat.hu>
2026-01-26 20:52:13 +01:00
Márk Tolmács dfdd994dbb perf: Cache hits in collision detection (#10648)
* feat: Cache hits and scene lookups

Signed-off-by: Mark Tolmacs <mark@lazycat.hu>

* chore: Remove debug

Signed-off-by: Mark Tolmacs <mark@lazycat.hu>

* fix: Consider hit threshold and inside override too

Signed-off-by: Mark Tolmacs <mark@lazycat.hu>

* chore: Remove Map caching

Signed-off-by: Mark Tolmacs <mark@lazycat.hu>

* fix: incorrect threshold

Signed-off-by: Mark Tolmacs <mark@lazycat.hu>

* fix: threshold setting

Signed-off-by: Mark Tolmacs <mark@lazycat.hu>

* fix: Hit caching

Signed-off-by: Mark Tolmacs <mark@lazycat.hu>

* fix: cache override

Signed-off-by: Mark Tolmacs <mark@lazycat.hu>

---------

Signed-off-by: Mark Tolmacs <mark@lazycat.hu>
2026-01-26 20:50:44 +01:00
Christopher TangonanandAnvi Kudaraya 28691e14b1 fix: Bound arrow elements for distribute and wyswig updates (#10702)
* fix: update distributeElements to updateBoundElements

Co-authored-by: Anvi Kudaraya <anvikudaraya417@gmail.com>

* fix: apply updateBoundElements when bound text extends past container height

Co-authored-by: Anvi Kudaraya <anvikudaraya417@gmail.com>

---------

Co-authored-by: Anvi Kudaraya <anvikudaraya417@gmail.com>
2026-01-26 18:52:13 +01:00
zsviczian 60759d314d fix: Regression - invert SVGs in Dark Mode (#10695)
* initial implementation

* lint

* removed separate getThemeFilterValue function from renderElement

* removed BinaryFileData changes

* filter instead of css filter
2026-01-24 13:00:14 +01:00
zsviczian d5e37cda81 fix: set link icon opacity based on element opacity (#10693)
* Set global alpha for drawing elements

* Adjust opacity calculation for canvas rendering

* lint
2026-01-24 12:16:48 +01:00
David Luzar 6135548534 fix: rerender TTD preview on message remove (#10681) 2026-01-21 14:07:48 +01:00
David Luzar acf54c6f38 fix: dropdownMenu item badge position (#10682) 2026-01-21 14:07:36 +01:00
David Luzar 84a309d669 fix: ttd 429 error handling (#10680) 2026-01-21 14:07:23 +01:00
David Luzar 3c8e893cab fix: keep input focus during generation (#10679) 2026-01-20 20:14:28 +01:00
Márk Tolmács 9ba0f5dbc9 fix: Arrow drag start in bindable area jumps across bindable (#10676)
Signed-off-by: Mark Tolmacs <mark@lazycat.hu>
2026-01-20 12:38:40 +01:00
David Luzar 60ab14c2f6 fix: fail gracefully during restore (#10673)
* fix: fail gracefully during restore

* tests
2026-01-20 12:33:22 +01:00
Márk Tolmács 0988ecfef4 fix: Angle-locked line history (#10677)
* fix: Line history

Signed-off-by: Mark Tolmacs <mark@lazycat.hu>

* fix: More conservative condition

Signed-off-by: Mark Tolmacs <mark@lazycat.hu>

---------

Signed-off-by: Mark Tolmacs <mark@lazycat.hu>
2026-01-20 12:11:58 +01:00
David Luzar 1f47d61e8c chore: bump node in tests.yml (#10670) 2026-01-19 09:23:50 +01:00
David Luzar 9d760336d1 feat: reduce max tablet MQ size (#10669)
* feat: reduce max tablet MQ size

* replace UIOptions.formFactor with getFormFactor
2026-01-18 21:55:14 +01:00
David Luzar 0443511954 fix: tweak error display in ttd (#10668)
fix: better error handling for ttd
2026-01-17 18:17:04 +01:00
David Luzar 5a73b9a363 refactor: change TTD persistence to iDB (#10662)
refactor: change ttd persistence to iDB
2026-01-16 15:10:00 +01:00
David Luzar 24a6941861 fix: retries and related UX fixes (#10657)
* fix: retries and related UX fixes

* bump

* fix package version

* yarn.lock

* naming and clearer type

* ignore test flake
2026-01-16 09:52:18 +01:00
Tamas Landdwelle a0b98a944f feat: TextToDiagram v2 (#10530)
* feat: introducing TextToDiagram v2 feature

* fix: eslint issue

* debug mermaid bundle size

* tests: covering the utils

* fix: import mock chunks dynamically to shrink the bundle size

* fix: removing replay feature

* fix: removing unused prop

* fix: bumping workbox cache limit

* snapshots + yarn.lock

* bump mermaid-to-excalidraw@2 and split into its own chunk

* bump node@20

* css tweaks

* move files around & rewrite stream chunk schema

* random naming & file structure refactor + some tweaks

* fix preview theme

* support custom warning renderer

* label and css fix

* fix and rwrite 429 handling

* fix label

---------

Co-authored-by: dwelle <5153846+dwelle@users.noreply.github.com>
2026-01-15 19:15:41 +01:00
Márk Tolmács 6ebf52279d fix: Elbow end point disconnect (#10646)
Signed-off-by: Mark Tolmacs <mark@lazycat.hu>
2026-01-15 15:27:00 +01:00
Márk Tolmács 3b97f5a10c feat: move visualdebug to utils and introduce volume bindable visualization (#10617)
* feat: Move visualdebug to utils and introduce volume bindable volume visualization

Signed-off-by: Mark Tolmacs <mark@lazycat.hu>

* fix: Move visualdebug to elements

Due to dep circles

Signed-off-by: Mark Tolmacs <mark@lazycat.hu>

---------

Signed-off-by: Mark Tolmacs <mark@lazycat.hu>
2026-01-12 15:24:50 +01:00
Márk Tolmácsanddwelle da59205846 fix: Curve endpoint intersection (#10640)
* fix: Curve endpoint intersection

Signed-off-by: Mark Tolmacs <mark@lazycat.hu>

* fix debug

---------

Signed-off-by: Mark Tolmacs <mark@lazycat.hu>
Co-authored-by: dwelle <5153846+dwelle@users.noreply.github.com>
2026-01-12 15:02:56 +01:00
David Luzar b9a255407f refactor: update SCSS syntax & remove open-color dep (#10633) 2026-01-10 18:15:14 +01:00
David Luzar cc6c29c0b9 fix: update wysiwyg color on theme change (#10618) 2026-01-07 12:55:33 +01:00
David Luzar 87faa5d3da fix: sentry CI worfklow (#10610) 2026-01-06 11:39:45 +01:00
David Luzar c158187f20 fix: grid color in dark mode (#10600) 2026-01-04 17:51:53 +01:00
David Luzar 63e1148280 feat: stop using CSS filters for dark mode (static canvas) (#10578)
* feat: stop using CSS filters for dark mode (static canvas)

* fix comment

* remove conditional dark mode export

* make shape cache theme-aware

* refactor

* refactor

* fixes and notes
2026-01-04 15:16:35 +01:00
Excalidraw Bot b5fc873323 chore: Update translations from Crowdin (#10453)
* New translations en.json (German)

* Auto commit: Calculate translation coverage

* New translations en.json (French)

* New translations en.json (Spanish)

* New translations en.json (Dutch)

* Auto commit: Calculate translation coverage

* New translations en.json (Dutch)

* Auto commit: Calculate translation coverage

* New translations en.json (Dutch)

* New translations en.json (Dutch)

* New translations en.json (Spanish)

* New translations en.json (Italian)

* Auto commit: Calculate translation coverage

* New translations en.json (Slovak)

* Auto commit: Calculate translation coverage

* New translations en.json (Slovak)

* Auto commit: Calculate translation coverage

* New translations en.json (Vietnamese)

* New translations en.json (Vietnamese)
2026-01-04 15:15:46 +01:00
David Luzar 6c908553a9 fix: reconciliation of server updates & refactor restore (#10597) 2026-01-04 15:13:38 +01:00
kish dizonanddwelle 0586fc138c feat: add qr code to live session share dialog. (#10588)
* add qr code to live session share dialog

* use uqr

---------

Co-authored-by: dwelle <5153846+dwelle@users.noreply.github.com>
2026-01-02 11:08:27 +01:00
Ryan Dianddwelle e95222ed32 fix: add constants and side methods to packages (#10418)
* fix: add constants and side methods to packages

* add transform to the element package

* lint

* remove dead code

* put transform types back to transform.ts

* fix imports

* fix imports in test

---------

Co-authored-by: dwelle <5153846+dwelle@users.noreply.github.com>
2025-12-28 21:53:25 +01:00
Márk Tolmács d87620b239 fix: Circular reference (#10544)
* fix: Circular reference

Signed-off-by: Mark Tolmacs <mark@lazycat.hu>

* fix: Lint

Signed-off-by: Mark Tolmacs <mark@lazycat.hu>

* Trigger CI

---------

Signed-off-by: Mark Tolmacs <mark@lazycat.hu>
2025-12-21 22:14:21 +01:00
Viczián András 7cc31ac64a fix: Context menu paste adding image twice #10542 (#10543)
removed line that was adding image file twice to paste
2025-12-20 06:31:02 +01:00
zsviczian 071b17a217 fix: Embeddables lost stroke color option in element properties after #9996 (#10541)
Add 'embeddable' type to comparisons
2025-12-19 18:23:09 +01:00
Márk Tolmácsanddwelle 859207b8bc fix: Broken bindings during collab (#10537)
* fix: Broken bindings during collab

Signed-off-by: Mark Tolmacs <mark@lazycat.hu>

* move repair of non-legacy binding outside the migration branch

---------

Signed-off-by: Mark Tolmacs <mark@lazycat.hu>
Co-authored-by: dwelle <5153846+dwelle@users.noreply.github.com>
2025-12-17 16:22:24 +01:00
David Luzar becaabfa0f chore: bump node@20 in ci workflows (#10531) 2025-12-16 19:08:42 +01:00
Márk Tolmácsandzsviczian f06484c6ab fix: Angle snapping around bindable objects incorrectly resolves (#10501)
Signed-off-by: Mark Tolmacs <mark@lazycat.hu>
Co-authored-by: zsviczian <viczian.zsolt@gmail.com>
2025-12-15 09:49:46 +00:00
Márk Tolmács bf4c65f483 fix: Turn into inside bind when angle locked (#10479)
Signed-off-by: Mark Tolmacs <mark@lazycat.hu>
2025-12-09 19:36:28 +01:00
Márk Tolmács 8d18078f5c fix: Box selection of arrows (#10451) 2025-12-03 12:47:30 +01:00
David Luzar d080833f4d chore: bump typescript@5.9.3 (#10431) 2025-12-01 22:37:42 +01:00
Márk Tolmácsanddwelle 451bcac0b7 fix: Ctrl/Alt elbow arrow jumps (#10432)
* fix: Ctrl/Alt elbow arrow jumps

Signed-off-by: Mark Tolmacs <mark@lazycat.hu>

* chore: Trigger build

* style

---------

Signed-off-by: Mark Tolmacs <mark@lazycat.hu>
Co-authored-by: dwelle <5153846+dwelle@users.noreply.github.com>
2025-12-01 17:06:08 +00:00
Ethan Olesinski 06f01e11f8 style: remove blue lines (#10425)
remove blue lines
2025-12-01 11:15:29 +00:00
Excalidraw Bot 51ad8951d4 chore: Update translations from Crowdin (#10316)
* New translations en.json (Swedish)

* New translations en.json (Japanese)

* Auto commit: Calculate translation coverage

* New translations en.json (Occitan)

* Auto commit: Calculate translation coverage

* New translations en.json (Russian)

* Auto commit: Calculate translation coverage

* New translations en.json (Russian)

* Auto commit: Calculate translation coverage

* New translations en.json (Portuguese, Brazilian)

* New translations en.json (Occitan)

* New translations en.json (Italian)

* Auto commit: Calculate translation coverage

* New translations en.json (Italian)

* Auto commit: Calculate translation coverage

* New translations en.json (Italian)

* Auto commit: Calculate translation coverage

* New translations en.json (Italian)

* Auto commit: Calculate translation coverage

* New translations en.json (Swedish)

* New translations en.json (Japanese)

* New translations en.json (Occitan)

* New translations en.json (Russian)

* New translations en.json (Portuguese, Brazilian)

* New translations en.json (Italian)

* New translations en.json (Romanian)

* New translations en.json (French)

* New translations en.json (Spanish)

* New translations en.json (Arabic)

* New translations en.json (Bulgarian)

* New translations en.json (Catalan)

* New translations en.json (Czech)

* New translations en.json (Danish)

* New translations en.json (German)

* New translations en.json (Greek)

* New translations en.json (Basque)

* New translations en.json (Finnish)

* New translations en.json (Hebrew)

* New translations en.json (Hungarian)

* New translations en.json (Korean)

* New translations en.json (Kurdish)

* New translations en.json (Lithuanian)

* New translations en.json (Dutch)

* New translations en.json (Punjabi)

* New translations en.json (Polish)

* New translations en.json (Portuguese)

* New translations en.json (Slovak)

* New translations en.json (Slovenian)

* New translations en.json (Turkish)

* New translations en.json (Ukrainian)

* New translations en.json (Chinese Simplified)

* New translations en.json (Chinese Traditional)

* New translations en.json (Vietnamese)

* New translations en.json (Galician)

* New translations en.json (Indonesian)

* New translations en.json (Persian)

* New translations en.json (Khmer)

* New translations en.json (Tamil)

* New translations en.json (Bengali)

* New translations en.json (Marathi)

* New translations en.json (Thai)

* New translations en.json (Norwegian Nynorsk)

* New translations en.json (Kazakh)

* New translations en.json (Latvian)

* New translations en.json (Azerbaijani)

* New translations en.json (Hindi)

* New translations en.json (Burmese)

* New translations en.json (Chinese Traditional, Hong Kong)

* New translations en.json (Sinhala)

* New translations en.json (Uzbek)

* New translations en.json (Norwegian Bokmal)

* New translations en.json (German, Switzerland)

* New translations en.json (Bengali, India)

* New translations en.json (Kabyle)

* New translations en.json (Karakalpak)

* Auto commit: Calculate translation coverage

* New translations en.json (Romanian)

* Auto commit: Calculate translation coverage

* New translations en.json (Romanian)

* Auto commit: Calculate translation coverage

* New translations en.json (Romanian)

* Auto commit: Calculate translation coverage
2025-12-01 12:10:13 +01:00
zsviczian 7497a08270 fix: Frame and Frame Element binding highlight offset (#10423)
fix binding highlight offset
2025-12-01 12:08:55 +01:00
David Luzar 210dc85c8c fix: do not finalize multi-point lines if binding not enabled (#10410)
* fix: do not finalize multi-point lines if binding not enabled

* refactor
2025-11-26 21:02:36 +01:00
Márk Tolmácsanddwelle 019ce4c52c fix: Corner jumping & hints (#10403)
* fix: Corner jumping

Signed-off-by: Mark Tolmacs <mark@lazycat.hu>

* fix: Hints

Signed-off-by: Mark Tolmacs <mark@lazycat.hu>

* fix: No corner avoidance for simple arrows

Signed-off-by: Mark Tolmacs <mark@lazycat.hu>

* show alt/cmd hint when creating/moving arrow point any time

---------

Signed-off-by: Mark Tolmacs <mark@lazycat.hu>
Co-authored-by: dwelle <5153846+dwelle@users.noreply.github.com>
2025-11-26 11:00:33 +00:00
Márk Tolmácsanddwelle c141960ada feat: Non-elbow arrow snapping and behavior changes (#9670)
* Fixed point binding for simple arrows

Tests added

Fix binding

Remove unneeded params

Unfinished simple arrow avoidance

Fix newly created jumping arrow when gets outside

Do not apply the jumping logic to elbow arrows for new elements

Existing arrows now jump out

Type updates to support fixed binding for simple arrows

Fix crash for elbow arrws in mutateElement()

Refactored simple arrow creation

Updating tests

No confirm threshold when inside biding range

Fix multi-point arrow grid off

Make elbow arrows respect grids

Unbind arrow if bound and moved at shaft of arrow key

Fix binding test

Fix drag unbind when the bound element is in the selection

Do not move mid point for simple arrows bound on both ends

Add test for mobing mid points for simple arrows when bound on the same element on both ends

Fix linear editor bug when both midpoint and endpoint is moved

Fix all point multipoint arrow highlight and binding

Arrow dragging gets a little drag to avoid accidental unbinding

Fixed point binding for simple arrows when the arrow doesn't point to the element

Fix binding disabled use-case triggering arrow editor

Timed binding mode change for simple arrows

Apply fixes

Remove code to unbind on drag

Update simple arrow fixed point when arrow is dragged or moved by arrow keys

Binding highlight fixes

Change bind mode timeout logic

Fix tests

Add Alt bindMode switch

 No dragging of arrows when bound, similar to elbow

Fix timeout not taking effect immediately

Bumop z-index for arrows when dragged

Signed-off-by: Mark Tolmacs <mark@lazycat.hu>

Only transparent bindables allow binding fallthrough

Signed-off-by: Mark Tolmacs <mark@lazycat.hu>

Fix lint

Signed-off-by: Mark Tolmacs <mark@lazycat.hu>

Fix point click array creation interaction with fixed point binding

Signed-off-by: Mark Tolmacs <mark@lazycat.hu>

Restrict new behavior to arrows only

Signed-off-by: Mark Tolmacs <mark@lazycat.hu>

Allow binding inside images

Signed-off-by: Mark Tolmacs <mark@lazycat.hu>

Fix already existing fixed binding retention

Signed-off-by: Mark Tolmacs <mark@lazycat.hu>

Refactor and implement fixed point binding for unfilled elements

Restore drag

Removed point binding

Binding code refactor

Added centered focus point

Binding & focus point debug

Add invariants to check binding integrity in elements

Binding fixes

Small refactors

Completely rewritten binding

Include point updates after binding update

Fix point updates when endpoint dragged and opposite endpoint orbits

centered focus point only for new arrows

Make z-index arrow reorder on bind

Turn off inside binding mode after leaving a shape

Remove invariants from debug

feat: expose `applyTo` options, don't commit empty text element (#9744)

* Expose applyTo options, skip re-draw for empty text

* Don't commit empty text elements

test: added test file for distribute (#9754)

z-index update

Bind mode on precise binding

Fix binding to inside element

Fix initial arrow not following cursor (white dot)

Fix elbow arrow

Fix z-index so it works on hover

Fix fixed angle orbiting

Move point click arrow creation over to common strategy

Signed-off-by: Mark Tolmacs <mark@lazycat.hu>

Add binding strategy for drag arrow creation

Fix elbow arrow

Fix point handles

Snap to center

Fix transparent shape binding

Internal arrow creation fix

Fix point binding

Fix selection bug

Fix new arrow focus point

Images now always bind inside

Flashing arrow creation on binding band

Add watchState debug method to window.h

Fix debug canvas crash

Remove non-needed bind mode

Fix restore

No keyboard movement when bound

Add actionFinalize when arrow in edit mode

Add drag to the Stats panel when bound arrow is moved

Further simplify curve tracking

Add typing to action register()

Signed-off-by: Mark Tolmacs <mark@lazycat.hu>

Fix point at finalize

Signed-off-by: Mark Tolmacs <mark@lazycat.hu>

Fix type errors

Signed-off-by: Mark Tolmacs <mark@lazycat.hu>

New arrow binding rules

Signed-off-by: Mark Tolmacs <mark@lazycat.hu>

Fix cyclical dep

Signed-off-by: Mark Tolmacs <mark@lazycat.hu>

Fix jiggly arrows

Fix jiggly arrow x2

Long inside-other binding

Click-click binding

Fix arrows

Performance

[PERF] Replace in-place Jacobian derivation with analytical version

Different approach to inside binding

Signed-off-by: Mark Tolmacs <mark@lazycat.hu>

Fixes

Fix inconsistent arrow start jump out

Change how images are bound to on new arrow creation

Lower timeout

Small insurance fix

Fix curve test

Signed-off-by: Mark Tolmacs <mark@lazycat.hu>

No center focus point

90% inside center binding

Fixing tests

fix: Elbow arrow fixes

fix: More arrow fixes

Do not trigger arrow binding for linear elements

fix: Linear elements

fix: Refactor actionFinalize for linear

Binding tests updated

fix: Jump when cursor not moved

fix: history tests

Fix history snapshot

Fix undo issue

fix(eraser): Remove binding from the other element

fix(tests): Update tests

chore: Attempt filtering new set state

Fix excessive history recording

Signed-off-by: Mark Tolmacs <mark@lazycat.hu>

Fix all tests

Signed-off-by: Mark Tolmacs <mark@lazycat.hu>

fix(transform): Fix group resize and rotate

fix(binding): Harmonize binding param usage

fix: Center focus point

Signed-off-by: Mark Tolmacs <mark@lazycat.hu>

chore: Trigger build

Remove binding gap

Signed-off-by: Mark Tolmacs <mark@lazycat.hu>

Binding highlight refactor

fix: Refactored timeout bind mode handling

fix: Center when orbiting

feat: Color change on highlight

Fix orbit binding highlight

fix: hiding arrow

Fix arrow binding

Fix arrow drag selection logic

Binding highlight is now hot pink

Change inside binding logic for start point

Render focus point in debug mode

Fix snap to center

Fix actionFinalize for new arrow creation

fix: snapToCenter()

80% by length

fix: attempt at fixing the dancing arrows

feat: No center snap when start is not bound

Fix centering for existing arrows

tweak binding highlight color

change `appState.suggestedBindings` -> `suggestedBinding` & remove unused code

Refactor delayed bind mode change

Binding highlight rotation support + image support

fix(highlight): Overdraw fixes

feat: Do not allow drag bound arrow closer to the shape than dragging distance

feat: Stroke width adaptive fixed binding distance

chore: More point dragging centralization

New element behavior

Refactor dragging

Fix incorrect highlight sizing

Signed-off-by: Mark Tolmacs <mark@lazycat.hu>

Fix delayed bind mode for multiElement arrows

Signed-off-by: Mark Tolmacs <mark@lazycat.hu>

Fix multi-point

Signed-off-by: Mark Tolmacs <mark@lazycat.hu>

Fix elbow arrows

Simplify state

Small positional fixes

Fix jiggly arrows

Signed-off-by: Mark Tolmacs <mark@lazycat.hu>

Fixes for arrow dragging

Signed-off-by: Mark Tolmacs <mark@lazycat.hu>

Elbow arrow fixes

Highlight fixes

Fix elbow arrow binding

Frame highlight

Fix elbow mid-point binding

Fix binding suggestion for disabled binding state

Implement Alt

Remove debug

* CHange new arrow creation

* fix: allow inside binding via timeout if arrow has no startBinding

* fix: Delete invariant violation with arrows

* fix: Deleted arrow causes problems

* fix: Dragging issues

* fix: Dragging fix 2

* fix: Disable drag drag when arrow is bound

* fix: Multipoint arrow opposite point movement

* fix: Ctrl+Alt precedence

* feat: Alt inside start binding mode change

* Fix multipoint arrow orbit

Signed-off-by: Mark Tolmacs <mark@lazycat.hu>

* fix: Arrow start inside binding switch

Signed-off-by: Mark Tolmacs <mark@lazycat.hu>

* fix: New arrow never binds inside

Signed-off-by: Mark Tolmacs <mark@lazycat.hu>

* chore: Small refactor

Signed-off-by: Mark Tolmacs <mark@lazycat.hu>

* fix: Multi-point arrows and linears

Signed-off-by: Mark Tolmacs <mark@lazycat.hu>

* fix: Lint

Signed-off-by: Mark Tolmacs <mark@lazycat.hu>

* feat: Nested shapes handling

Signed-off-by: Mark Tolmacs <mark@lazycat.hu>

* fix: Overlap behavior

* Alt unbind fix

Signed-off-by: Mark Tolmacs <mark@lazycat.hu>

* fix: Existing arrow nested bindable

Signed-off-by: Mark Tolmacs <mark@lazycat.hu>

* fix: Binding suggestions

Signed-off-by: Mark Tolmacs <mark@lazycat.hu>

* fix: Circular dep

Signed-off-by: Mark Tolmacs <mark@lazycat.hu>

* fix: snapshots

Signed-off-by: Mark Tolmacs <mark@lazycat.hu>

* fix: Alt immediate update

Signed-off-by: Mark Tolmacs <mark@lazycat.hu>

* chore: Laxing on invariants

Signed-off-by: Mark Tolmacs <mark@lazycat.hu>

* fix: New highlight overdraws arrow

Signed-off-by: Mark Tolmacs <mark@lazycat.hu>

* fix: Image binding rule changed

* Trigger Rebuild

* fix:Highlight flicker

Signed-off-by: Mark Tolmacs <mark@lazycat.hu>

* fix: Fully nested shapes

* fix: Tune nested shape binding

* fix: Size-based orbit jump-in

* fix: Binding highlight stroke on sharp bindables

* fix: Nested shape binding

* fix: history

* fix:More precise element nesting check

* feat:Add tolerance to shape nesting detection

* fix: Reverse

* fix:Change center binding to circular

* ignore invisible elements when binding

* feat: Center point with more precise highlight outlines

* fix:Arrow tool hover stuck highlight

* fix:More stroke width for highlight

* POC: highlight center on hover

* tweak binding highlight width

* render highlight on the outside

* fix: Locked elbow arrow creation

* update hints

* reduce timeout

* handle overlap when both elements the same size

* tweak highlight stroke width some more

* fix:Add intersection padding

* fix: New arrow start orbit when nested binds on the end

* fix: Update history snapshot

* feat: Allow inside binding for new arrows in nested cases

* chore: Logic for measurement

* fix: Locked tool + arrow

* feat: Remove center binding

* fix: Jump arrow inside it gets visially too short

Signed-off-by: Mark Tolmacs <mark@lazycat.hu>

* chore:Basic interactive canvas animation re-render trigger for highlights

Signed-off-by: Mark Tolmacs <mark@lazycat.hu>

* feat:Highlight animations

Signed-off-by: Mark Tolmacs <mark@lazycat.hu>

* fix:Refactored and fixed highlight animation

* fix:Poisoned arrow

* fix Arrow edit mode selection

* fix:Tool lock binding behavior restored

* fix:Overlap inside binding

* fix:Animated binding highlight

* alt anims + increase timeout to 700

* tweak animation some more + remove countdown

* fix: False bind timeout indicator

Signed-off-by: Mark Tolmacs <mark@lazycat.hu>

* feat: better file normalization (#10024)

* feat: better file normalization

* fix lint

* fix png detection

* optimize

* fix type

* fix: increase rejection delay for opening files with legacy api (#8961)

* Increased input change interval to 1000 ms to fix IOS 18 file opening issue

* increase more

---------

Co-authored-by: dwelle <5153846+dwelle@users.noreply.github.com>

* feat: library search (#9903)

* feat(utils): add support for search input type in isWritableElement

* feat(i18n): add search text

* feat(cmdp+lib): add search functionality for command pallete and lib menu items

* chore: fix formats, and whitespaces

* fix: opt to optimal code changes

* chore: fix for linting

* focus input on mount

* tweak placeholder

* design and UX changes

* tweak item hover/active/seletected states

* unrelated: move publish button above delete/clear to keep it more stable

* esc to clear search input / close sidebar

* refactor command pallete library stuff

* make library commands bigger

---------

Co-authored-by: dwelle <5153846+dwelle@users.noreply.github.com>

* fix: Allow already inside bound arrows to continue inside binding

Signed-off-by: Mark Tolmacs <mark@lazycat.hu>

* feat: No angle lock over bindable elements

Signed-off-by: Mark Tolmacs <mark@lazycat.hu>

* feat: Center binding on SHIFT key

Signed-off-by: Mark Tolmacs <mark@lazycat.hu>

* Fix ghost start binding

Signed-off-by: Mark Tolmacs <mark@lazycat.hu>

* FEAT: No binding to frame cutout

* feat: Bind to frame when frame-bound object hidden part is approached

* fix: revert preferred selection to box once you switch to `full` UI (#10160)

* fix: mobile UI and other fixes (#10177)

* remove legacy openMenu=shape state and unused actions

* close menus/popups in applicable cases when opening a different one

* split ui z-indexes to account prefer different overlap

* make top canvas area clickable on mobile

* make mobile main menu closable by clicking outside and reduce width

* offset picker popups from viewport border on mobile

* reduce items gap in mobile main menu

* show top picks for canvas bg colors in all ui modes

* fix menu separator visibility on mobile

* fix command palette items not being filtered

* fix: Increase transform handle offset (#10180)

* fix: Increase transform handle offset

Signed-off-by: Mark Tolmacs <mark@lazycat.hu>

* fix: Temporarily disable transform handles for linear elements on mobile and tablets

Signed-off-by: Mark Tolmacs <mark@lazycat.hu>

* fix: Linear hidden resize

Signed-off-by: Mark Tolmacs <mark@lazycat.hu>

* disable mobielOrTablet linear element bbox completely

* fix: Test

Signed-off-by: Mark Tolmacs <mark@lazycat.hu>

* fix: Lint

Signed-off-by: Mark Tolmacs <mark@lazycat.hu>

---------

Signed-off-by: Mark Tolmacs <mark@lazycat.hu>
Co-authored-by: dwelle <5153846+dwelle@users.noreply.github.com>

* fix: context menu getting covered (#10199)

* do not show z-index actions on mobile or tablet

* fix: context menu getting covered

* fix lint

* fix style popup getting covered

* put contextmenu z-index above sidebar

---------

Co-authored-by: dwelle <5153846+dwelle@users.noreply.github.com>

* feat: More prominent keyboard shortcuts in hints (#10057)

* Initial

* Memoize

* Styling

* Use double angle brackets for keyboard shortcuts

* Use rem in gap

* Use an existing function for substituting tags in a string

* Revert styling

* Avoid unique key warnings

* Styling

* getTransChildren -> nodesFromTextWithTags

* Use height and padding instead of padding only

* Initial new idea

* WIP shortcut substitutions

* Use simple regex for parsing shortcuts

* Use single shortcut for combos

* Use kbd instead of span

* shortcutFromKeyString -> getTaggedShortcutKey

* Bug fix

* FlowChart -> Flowchart

* memo is useless here

* Trigger CI

* Translate in getShortcutKey

* More normalized shortcuts

* improve shortcut normalization and replacement & support multi-key tagged shortcuts

* fix regex

* tweak css

---------

Co-authored-by: dwelle <5153846+dwelle@users.noreply.github.com>

* fix: small tweaks to shortcut hints (#10214)

* fix: Test

Signed-off-by: Mark Tolmacs <mark@lazycat.hu>

* fix: Bind mode

* feat: Support special key shortcut highlight

* fix: Lint

* fix: Remove non-needed function

* fix: Skip frame cutout for hover, but keep shape for binding

* fix: Lint

* fix: Restore removal of deleted elements on restore

* fix: Inside-inside during drag

Signed-off-by: Mark Tolmacs <mark@lazycat.hu>

* fix: Arrow vanishes when orbit binding to the same element

* feat: Feature flag support

* Simplified binding

* fix: Diamond corner binding

* feat: Binding highlight band re-added

* feat: Settings menu

* fix: Same shape binding

* fix: set radix PropertiesPopover collision boundary (#10221)

* Set collision boundary

* Calculate collisionPadding dynamically based on container

* Add appState offsetTop and offsetLeft to padding calculation.

Refactor collisionPadding calculation to use app state offsets.

* Update PropertiesPopover.tsx

* popover positioning relative to container

* fix: prevent wrap text in a container to only text that are not bound to a container (#10250)

* fix: only enable wrap text in a container when at least one text element selected is unbound

* Trigger Rebuild

---------

Co-authored-by: Mark Tolmacs <mark@lazycat.hu>

* chore: Uncap the nodejs version requirement (#10238)

Co-authored-by: dwelle <5153846+dwelle@users.noreply.github.com>

* refactor: single source of truths with editor interface (#10178)

* refactor device to editor interface and derive styles panel

* allow host app to control form factor and ui mode

* add editor interface event listener

* put new props inside UIOptions

* refactor: move related apis into one file

* expose getFormFactor

* privatize the setting of desktop mode and fix snapshots

* refactor and fix test

* remove unimplemented code

* export getFormFactor()

* replace `getFormFactor` with `getEditorInterface`

* remove dead & useless

* comment

* fix ts

---------

Co-authored-by: dwelle <5153846+dwelle@users.noreply.github.com>

* chore: Update translations from Crowdin (#7429)

* New translations en.json (Punjabi)

* New translations en.json (Polish)

* New translations en.json (Russian)

* New translations en.json (Turkish)

* New translations en.json (Ukrainian)

* New translations en.json (Chinese Traditional)

* New translations en.json (Vietnamese)

* New translations en.json (Galician)

* New translations en.json (Portuguese, Brazilian)

* New translations en.json (Persian)

* New translations en.json (Khmer)

* New translations en.json (Tamil)

* New translations en.json (Bengali)

* New translations en.json (Marathi)

* New translations en.json (Thai)

* New translations en.json (Norwegian Nynorsk)

* New translations en.json (Kazakh)

* New translations en.json (Latvian)

* New translations en.json (Azerbaijani)

* New translations en.json (Hindi)

* New translations en.json (Burmese)

* New translations en.json (Chinese Traditional, Hong Kong)

* New translations en.json (Sinhala)

* New translations en.json (Uzbek)

* New translations en.json (Norwegian Bokmal)

* New translations en.json (Occitan)

* New translations en.json (German, Switzerland)

* New translations en.json (Bengali, India)

* New translations en.json (Kabyle)

* New translations en.json (Karakalpak)

* New translations en.json (Portuguese)

* New translations en.json (Russian)

* New translations en.json (Marathi)

* New translations en.json (Hindi)

* New translations en.json (German)

* New translations en.json (Chinese Simplified)

* New translations en.json (Polish)

* New translations en.json (Romanian)

* New translations en.json (Korean)

* New translations en.json (Chinese Traditional)

* New translations en.json (Hebrew)

* New translations en.json (Hebrew)

* New translations en.json (Slovak)

* New translations en.json (Slovak)

* New translations en.json (Hungarian)

* New translations en.json (Hungarian)

* New translations en.json (Slovak)

* New translations en.json (Hebrew)

* New translations en.json (Hungarian)

* New translations en.json (Korean)

* New translations en.json (Chinese Traditional)

* New translations en.json (Romanian)

* New translations en.json (French)

* New translations en.json (Spanish)

* New translations en.json (Arabic)

* New translations en.json (Bulgarian)

* New translations en.json (Catalan)

* New translations en.json (Czech)

* New translations en.json (Danish)

* New translations en.json (German)

* New translations en.json (Greek)

* New translations en.json (Basque)

* New translations en.json (Finnish)

* New translations en.json (Italian)

* New translations en.json (Japanese)

* New translations en.json (Kurdish)

* New translations en.json (Lithuanian)

* New translations en.json (Dutch)

* New translations en.json (Punjabi)

* New translations en.json (Polish)

* New translations en.json (Portuguese)

* New translations en.json (Russian)

* New translations en.json (Slovenian)

* New translations en.json (Swedish)

* New translations en.json (Turkish)

* New translations en.json (Ukrainian)

* New translations en.json (Chinese Simplified)

* New translations en.json (Vietnamese)

* New translations en.json (Galician)

* New translations en.json (Portuguese, Brazilian)

* New translations en.json (Indonesian)

* New translations en.json (Persian)

* New translations en.json (Khmer)

* New translations en.json (Tamil)

* New translations en.json (Bengali)

* New translations en.json (Marathi)

* New translations en.json (Thai)

* New translations en.json (Norwegian Nynorsk)

* New translations en.json (Kazakh)

* New translations en.json (Latvian)

* New translations en.json (Azerbaijani)

* New translations en.json (Hindi)

* New translations en.json (Burmese)

* New translations en.json (Chinese Traditional, Hong Kong)

* New translations en.json (Sinhala)

* New translations en.json (Uzbek)

* New translations en.json (Norwegian Bokmal)

* New translations en.json (Occitan)

* New translations en.json (German, Switzerland)

* New translations en.json (Bengali, India)

* New translations en.json (Kabyle)

* New translations en.json (Karakalpak)

* New translations en.json (Romanian)

* New translations en.json (German)

* New translations en.json (Slovenian)

* New translations en.json (Chinese Simplified)

* New translations en.json (Spanish)

* New translations en.json (Russian)

* New translations en.json (Chinese Traditional)

* New translations en.json (Turkish)

* New translations en.json (Slovak)

* New translations en.json (Slovak)

* New translations en.json (Hebrew)

* New translations en.json (Hungarian)

* New translations en.json (Korean)

* New translations en.json (Chinese Traditional)

* New translations en.json (Romanian)

* New translations en.json (French)

* New translations en.json (Spanish)

* New translations en.json (Arabic)

* New translations en.json (Bulgarian)

* New translations en.json (Catalan)

* New translations en.json (Czech)

* New translations en.json (Danish)

* New translations en.json (German)

* New translations en.json (Greek)

* New translations en.json (Basque)

* New translations en.json (Finnish)

* New translations en.json (Italian)

* New translations en.json (Japanese)

* New translations en.json (Kurdish)

* New translations en.json (Lithuanian)

* New translations en.json (Dutch)

* New translations en.json (Punjabi)

* New translations en.json (Polish)

* New translations en.json (Portuguese)

* New translations en.json (Russian)

* New translations en.json (Slovenian)

* New translations en.json (Swedish)

* New translations en.json (Turkish)

* New translations en.json (Ukrainian)

* New translations en.json (Chinese Simplified)

* New translations en.json (Vietnamese)

* New translations en.json (Galician)

* New translations en.json (Portuguese, Brazilian)

* New translations en.json (Indonesian)

* New translations en.json (Persian)

* New translations en.json (Khmer)

* New translations en.json (Tamil)

* New translations en.json (Bengali)

* New translations en.json (Marathi)

* New translations en.json (Thai)

* New translations en.json (Norwegian Nynorsk)

* New translations en.json (Kazakh)

* New translations en.json (Latvian)

* New translations en.json (Azerbaijani)

* New translations en.json (Hindi)

* New translations en.json (Burmese)

* New translations en.json (Chinese Traditional, Hong Kong)

* New translations en.json (Sinhala)

* New translations en.json (Uzbek)

* New translations en.json (Norwegian Bokmal)

* New translations en.json (Occitan)

* New translations en.json (German, Switzerland)

* New translations en.json (Bengali, India)

* New translations en.json (Kabyle)

* New translations en.json (Karakalpak)

* New translations en.json (German)

* New translations en.json (Russian)

* New translations en.json (Romanian)

* New translations en.json (Spanish)

* New translations en.json (Chinese Simplified)

* New translations en.json (Marathi)

* New translations en.json (Hindi)

* New translations en.json (Slovak)

* New translations en.json (German)

* New translations en.json (Portuguese)

* New translations en.json (Romanian)

* New translations en.json (French)

* New translations en.json (Spanish)

* New translations en.json (Arabic)

* New translations en.json (Bulgarian)

* New translations en.json (Catalan)

* New translations en.json (Czech)

* New translations en.json (Danish)

* New translations en.json (German)

* New translations en.json (Greek)

* New translations en.json (Basque)

* New translations en.json (Finnish)

* New translations en.json (Hebrew)

* New translations en.json (Hungarian)

* New translations en.json (Italian)

* New translations en.json (Japanese)

* New translations en.json (Korean)

* New translations en.json (Kurdish)

* New translations en.json (Lithuanian)

* New translations en.json (Dutch)

* New translations en.json (Punjabi)

* New translations en.json (Polish)

* New translations en.json (Portuguese)

* New translations en.json (Russian)

* New translations en.json (Slovak)

* New translations en.json (Slovenian)

* New translations en.json (Swedish)

* New translations en.json (Turkish)

* New translations en.json (Ukrainian)

* New translations en.json (Chinese Simplified)

* New translations en.json (Chinese Traditional)

* New translations en.json (Vietnamese)

* New translations en.json (Galician)

* New translations en.json (Portuguese, Brazilian)

* New translations en.json (Indonesian)

* New translations en.json (Persian)

* New translations en.json (Khmer)

* New translations en.json (Tamil)

* New translations en.json (Bengali)

* New translations en.json (Marathi)

* New translations en.json (Thai)

* New translations en.json (Norwegian Nynorsk)

* New translations en.json (Kazakh)

* New translations en.json (Latvian)

* New translations en.json (Azerbaijani)

* New translations en.json (Hindi)

* New translations en.json (Burmese)

* New translations en.json (Chinese Traditional, Hong Kong)

* New translations en.json (Sinhala)

* New translations en.json (Uzbek)

* New translations en.json (Norwegian Bokmal)

* New translations en.json (Occitan)

* New translations en.json (German, Switzerland)

* New translations en.json (Bengali, India)

* New translations en.json (Kabyle)

* New translations en.json (Karakalpak)

* Auto commit: Calculate translation coverage

* New translations en.json (Chinese Simplified)

* Auto commit: Calculate translation coverage

* New translations en.json (Romanian)

* Auto commit: Calculate translation coverage

* New translations en.json (Romanian)

* Auto commit: Calculate translation coverage

* New translations en.json (French)

* Auto commit: Calculate translation coverage

* New translations en.json (Polish)

* Auto commit: Calculate translation coverage

* New translations en.json (Polish)

* Auto commit: Calculate translation coverage

* New translations en.json (Turkish)

* Auto commit: Calculate translation coverage

* New translations en.json (Turkish)

---------

Co-authored-by: dwelle <5153846+dwelle@users.noreply.github.com>

* fix: mobile view ui issues (#10284)

* hide zen mode when formFactor = phone

* tool bar fixes: icon and width

* view mode

* fix lint

* add exit-view-mode button

---------

Co-authored-by: dwelle <5153846+dwelle@users.noreply.github.com>

* chore: Update snaps

Signed-off-by: Mark Tolmacs <mark@lazycat.hu>

* feat: Blue highlight

Signed-off-by: Mark Tolmacs <mark@lazycat.hu>

* feat: Diagonal binding point

Signed-off-by: Mark Tolmacs <mark@lazycat.hu>

* chore: Remove settings

Signed-off-by: Mark Tolmacs <mark@lazycat.hu>

* feat: Jump other binding

Signed-off-by: Mark Tolmacs <mark@lazycat.hu>

* fix: Hovered arrow mode highlight

Signed-off-by: Mark Tolmacs <mark@lazycat.hu>

* feat: Alt does not snap

Signed-off-by: Mark Tolmacs <mark@lazycat.hu>

* chore: Check debug

Signed-off-by: Mark Tolmacs <mark@lazycat.hu>

* fix: Alt precise positioning

* fix: Jump out to orbit for new arrows when dragged outside

* fix: New arrow preserved projection

* chore: Remove debug

* chore: Introduce different debug color for orbit and other binding modes

* fix: Restore arrow start point when self binding

* fix: Turn of start jump-out

* fix: Tests

* fix: Select the first possible altBindPoint

* fix: Random projection

* fix: Use last point for existing arrows

* fix: Preserve alternate orbit focus point during drag

* fix: Lint

* fix: Update tests

* fix: Elbow arrow direction at binding

* binding gap and distance and binding highlight tweaks

* chore: Naming refactors

Signed-off-by: Mark Tolmacs <mark@lazycat.hu>

* fix: Tests

Signed-off-by: Mark Tolmacs <mark@lazycat.hu>

* fix: Alt-duplication copied elements placement (#10152)

* feat: Animation support (#10042)

* fix: banner url (#10315)

* feat: Animation support (#10042)

* fix: Merge discrepancy

Signed-off-by: Mark Tolmacs <mark@lazycat.hu>

* chore: Remove non-needed code

Signed-off-by: Mark Tolmacs <mark@lazycat.hu>

* Trigger build

* chore: Remove hint for V1

Signed-off-by: Mark Tolmacs <mark@lazycat.hu>

* shorten focus point diagonal helpers to fix corner binding cases

* fix: Tests

* fix: Multi-point arrow closeness fallback

* fix: Finalize multipoint arrow on binding area click

* fix: Finalize arrow now truly finalzes

Signed-off-by: Mark Tolmacs <mark@lazycat.hu>

* fix: Point click arrow creation jumping to orbit

Signed-off-by: Mark Tolmacs <mark@lazycat.hu>

* fix: Alt+drag movement block

Signed-off-by: Mark Tolmacs <mark@lazycat.hu>

* fix: Tests

Signed-off-by: Mark Tolmacs <mark@lazycat.hu>

* Trigger build

* feat: hide point highlight when dragging

* feat: hide bbox when dragging points

* revert binding gap increase for elbow arrows

* reset selectionLinearElement on tool change

* chore: Remove debug

* feat: Better restore for bindings

* use elementsMap instead of array when passing to restoreElement

* fix: Arrow angle reset

* fix: Arrow angle

* Arrow angle support

* fix trashing cached canvases in `LinearElementEditor.getElementAbsoluteCoords`

---------

Signed-off-by: Mark Tolmacs <mark@lazycat.hu>
Co-authored-by: dwelle <5153846+dwelle@users.noreply.github.com>
2025-11-25 15:46:02 +01:00
zsviczian d7e63e66a7 fix: rounded left and top clipped image export to SVG (#10387)
Remove clipPathUnits attribute from clipPath

Removed setting of clipPathUnits attribute for clipPath element.
2025-11-24 23:34:42 +01:00
David Luzar b660478164 fix: prevent translation of excalidraw container (#10389) 2025-11-22 16:16:30 +01:00
gothamsiddanddwelle 37882c66cb fix: canvas panning stops when hovering over frame title (#10340) (#10351)
* fix: canvas panning stops when hovering over frame title (#10340)

* improve

---------

Co-authored-by: dwelle <5153846+dwelle@users.noreply.github.com>
2025-11-18 09:24:02 +00:00
David Luzar 7f66e1fe89 fix: banner url (#10315) 2025-11-11 11:29:44 +01:00
Márk Tolmács 2b4540225d feat: Animation support (#10042) 2025-11-10 22:31:23 +01:00
Márk Tolmács dc2f25c14a fix: Alt-duplication copied elements placement (#10152) 2025-11-10 22:31:08 +01:00
Márk Tolmács 8fb16669ab feat: Add binding visual debug (#10222) 2025-11-10 12:08:57 +01:00
David Luzar f2600fe3e8 fix: close floating sidebar on main menu open (#10295) 2025-11-06 22:39:39 +01:00
zsvicziananddwelle 95ddc66339 fix: add toggle pen-mode to MobileMenu (#10293)
* add toggle pen mode to MobileMenu

* swap buttons

---------

Co-authored-by: dwelle <5153846+dwelle@users.noreply.github.com>
2025-11-06 21:33:11 +00:00
David Luzar 5bcd8280c9 feat: add comments/presi eplus promos for discoveribility (#10294) 2025-11-06 21:35:14 +01:00
Ryan Dianddwelle c99e81678b fix: mobile view ui issues (#10284)
* hide zen mode when formFactor = phone

* tool bar fixes: icon and width

* view mode

* fix lint

* add exit-view-mode button

---------

Co-authored-by: dwelle <5153846+dwelle@users.noreply.github.com>
2025-11-04 11:20:55 +00:00
Excalidraw Botanddwelle d1f39823f1 chore: Update translations from Crowdin (#7429)
* New translations en.json (Punjabi)

* New translations en.json (Polish)

* New translations en.json (Russian)

* New translations en.json (Turkish)

* New translations en.json (Ukrainian)

* New translations en.json (Chinese Traditional)

* New translations en.json (Vietnamese)

* New translations en.json (Galician)

* New translations en.json (Portuguese, Brazilian)

* New translations en.json (Persian)

* New translations en.json (Khmer)

* New translations en.json (Tamil)

* New translations en.json (Bengali)

* New translations en.json (Marathi)

* New translations en.json (Thai)

* New translations en.json (Norwegian Nynorsk)

* New translations en.json (Kazakh)

* New translations en.json (Latvian)

* New translations en.json (Azerbaijani)

* New translations en.json (Hindi)

* New translations en.json (Burmese)

* New translations en.json (Chinese Traditional, Hong Kong)

* New translations en.json (Sinhala)

* New translations en.json (Uzbek)

* New translations en.json (Norwegian Bokmal)

* New translations en.json (Occitan)

* New translations en.json (German, Switzerland)

* New translations en.json (Bengali, India)

* New translations en.json (Kabyle)

* New translations en.json (Karakalpak)

* New translations en.json (Portuguese)

* New translations en.json (Russian)

* New translations en.json (Marathi)

* New translations en.json (Hindi)

* New translations en.json (German)

* New translations en.json (Chinese Simplified)

* New translations en.json (Polish)

* New translations en.json (Romanian)

* New translations en.json (Korean)

* New translations en.json (Chinese Traditional)

* New translations en.json (Hebrew)

* New translations en.json (Hebrew)

* New translations en.json (Slovak)

* New translations en.json (Slovak)

* New translations en.json (Hungarian)

* New translations en.json (Hungarian)

* New translations en.json (Slovak)

* New translations en.json (Hebrew)

* New translations en.json (Hungarian)

* New translations en.json (Korean)

* New translations en.json (Chinese Traditional)

* New translations en.json (Romanian)

* New translations en.json (French)

* New translations en.json (Spanish)

* New translations en.json (Arabic)

* New translations en.json (Bulgarian)

* New translations en.json (Catalan)

* New translations en.json (Czech)

* New translations en.json (Danish)

* New translations en.json (German)

* New translations en.json (Greek)

* New translations en.json (Basque)

* New translations en.json (Finnish)

* New translations en.json (Italian)

* New translations en.json (Japanese)

* New translations en.json (Kurdish)

* New translations en.json (Lithuanian)

* New translations en.json (Dutch)

* New translations en.json (Punjabi)

* New translations en.json (Polish)

* New translations en.json (Portuguese)

* New translations en.json (Russian)

* New translations en.json (Slovenian)

* New translations en.json (Swedish)

* New translations en.json (Turkish)

* New translations en.json (Ukrainian)

* New translations en.json (Chinese Simplified)

* New translations en.json (Vietnamese)

* New translations en.json (Galician)

* New translations en.json (Portuguese, Brazilian)

* New translations en.json (Indonesian)

* New translations en.json (Persian)

* New translations en.json (Khmer)

* New translations en.json (Tamil)

* New translations en.json (Bengali)

* New translations en.json (Marathi)

* New translations en.json (Thai)

* New translations en.json (Norwegian Nynorsk)

* New translations en.json (Kazakh)

* New translations en.json (Latvian)

* New translations en.json (Azerbaijani)

* New translations en.json (Hindi)

* New translations en.json (Burmese)

* New translations en.json (Chinese Traditional, Hong Kong)

* New translations en.json (Sinhala)

* New translations en.json (Uzbek)

* New translations en.json (Norwegian Bokmal)

* New translations en.json (Occitan)

* New translations en.json (German, Switzerland)

* New translations en.json (Bengali, India)

* New translations en.json (Kabyle)

* New translations en.json (Karakalpak)

* New translations en.json (Romanian)

* New translations en.json (German)

* New translations en.json (Slovenian)

* New translations en.json (Chinese Simplified)

* New translations en.json (Spanish)

* New translations en.json (Russian)

* New translations en.json (Chinese Traditional)

* New translations en.json (Turkish)

* New translations en.json (Slovak)

* New translations en.json (Slovak)

* New translations en.json (Hebrew)

* New translations en.json (Hungarian)

* New translations en.json (Korean)

* New translations en.json (Chinese Traditional)

* New translations en.json (Romanian)

* New translations en.json (French)

* New translations en.json (Spanish)

* New translations en.json (Arabic)

* New translations en.json (Bulgarian)

* New translations en.json (Catalan)

* New translations en.json (Czech)

* New translations en.json (Danish)

* New translations en.json (German)

* New translations en.json (Greek)

* New translations en.json (Basque)

* New translations en.json (Finnish)

* New translations en.json (Italian)

* New translations en.json (Japanese)

* New translations en.json (Kurdish)

* New translations en.json (Lithuanian)

* New translations en.json (Dutch)

* New translations en.json (Punjabi)

* New translations en.json (Polish)

* New translations en.json (Portuguese)

* New translations en.json (Russian)

* New translations en.json (Slovenian)

* New translations en.json (Swedish)

* New translations en.json (Turkish)

* New translations en.json (Ukrainian)

* New translations en.json (Chinese Simplified)

* New translations en.json (Vietnamese)

* New translations en.json (Galician)

* New translations en.json (Portuguese, Brazilian)

* New translations en.json (Indonesian)

* New translations en.json (Persian)

* New translations en.json (Khmer)

* New translations en.json (Tamil)

* New translations en.json (Bengali)

* New translations en.json (Marathi)

* New translations en.json (Thai)

* New translations en.json (Norwegian Nynorsk)

* New translations en.json (Kazakh)

* New translations en.json (Latvian)

* New translations en.json (Azerbaijani)

* New translations en.json (Hindi)

* New translations en.json (Burmese)

* New translations en.json (Chinese Traditional, Hong Kong)

* New translations en.json (Sinhala)

* New translations en.json (Uzbek)

* New translations en.json (Norwegian Bokmal)

* New translations en.json (Occitan)

* New translations en.json (German, Switzerland)

* New translations en.json (Bengali, India)

* New translations en.json (Kabyle)

* New translations en.json (Karakalpak)

* New translations en.json (German)

* New translations en.json (Russian)

* New translations en.json (Romanian)

* New translations en.json (Spanish)

* New translations en.json (Chinese Simplified)

* New translations en.json (Marathi)

* New translations en.json (Hindi)

* New translations en.json (Slovak)

* New translations en.json (German)

* New translations en.json (Portuguese)

* New translations en.json (Romanian)

* New translations en.json (French)

* New translations en.json (Spanish)

* New translations en.json (Arabic)

* New translations en.json (Bulgarian)

* New translations en.json (Catalan)

* New translations en.json (Czech)

* New translations en.json (Danish)

* New translations en.json (German)

* New translations en.json (Greek)

* New translations en.json (Basque)

* New translations en.json (Finnish)

* New translations en.json (Hebrew)

* New translations en.json (Hungarian)

* New translations en.json (Italian)

* New translations en.json (Japanese)

* New translations en.json (Korean)

* New translations en.json (Kurdish)

* New translations en.json (Lithuanian)

* New translations en.json (Dutch)

* New translations en.json (Punjabi)

* New translations en.json (Polish)

* New translations en.json (Portuguese)

* New translations en.json (Russian)

* New translations en.json (Slovak)

* New translations en.json (Slovenian)

* New translations en.json (Swedish)

* New translations en.json (Turkish)

* New translations en.json (Ukrainian)

* New translations en.json (Chinese Simplified)

* New translations en.json (Chinese Traditional)

* New translations en.json (Vietnamese)

* New translations en.json (Galician)

* New translations en.json (Portuguese, Brazilian)

* New translations en.json (Indonesian)

* New translations en.json (Persian)

* New translations en.json (Khmer)

* New translations en.json (Tamil)

* New translations en.json (Bengali)

* New translations en.json (Marathi)

* New translations en.json (Thai)

* New translations en.json (Norwegian Nynorsk)

* New translations en.json (Kazakh)

* New translations en.json (Latvian)

* New translations en.json (Azerbaijani)

* New translations en.json (Hindi)

* New translations en.json (Burmese)

* New translations en.json (Chinese Traditional, Hong Kong)

* New translations en.json (Sinhala)

* New translations en.json (Uzbek)

* New translations en.json (Norwegian Bokmal)

* New translations en.json (Occitan)

* New translations en.json (German, Switzerland)

* New translations en.json (Bengali, India)

* New translations en.json (Kabyle)

* New translations en.json (Karakalpak)

* Auto commit: Calculate translation coverage

* New translations en.json (Chinese Simplified)

* Auto commit: Calculate translation coverage

* New translations en.json (Romanian)

* Auto commit: Calculate translation coverage

* New translations en.json (Romanian)

* Auto commit: Calculate translation coverage

* New translations en.json (French)

* Auto commit: Calculate translation coverage

* New translations en.json (Polish)

* Auto commit: Calculate translation coverage

* New translations en.json (Polish)

* Auto commit: Calculate translation coverage

* New translations en.json (Turkish)

* Auto commit: Calculate translation coverage

* New translations en.json (Turkish)

---------

Co-authored-by: dwelle <5153846+dwelle@users.noreply.github.com>
2025-11-03 23:36:08 +01:00
Ryan Dianddwelle 47cbb5b6fb refactor: single source of truths with editor interface (#10178)
* refactor device to editor interface and derive styles panel

* allow host app to control form factor and ui mode

* add editor interface event listener

* put new props inside UIOptions

* refactor: move related apis into one file

* expose getFormFactor

* privatize the setting of desktop mode and fix snapshots

* refactor and fix test

* remove unimplemented code

* export getFormFactor()

* replace `getFormFactor` with `getEditorInterface`

* remove dead & useless

* comment

* fix ts

---------

Co-authored-by: dwelle <5153846+dwelle@users.noreply.github.com>
2025-11-03 23:34:17 +01:00
Márk Tolmácsanddwelle 8fd970320e chore: Uncap the nodejs version requirement (#10238)
Co-authored-by: dwelle <5153846+dwelle@users.noreply.github.com>
2025-11-03 17:30:35 +01:00
Christopher TangonanandMark Tolmacs 8d8f696628 fix: prevent wrap text in a container to only text that are not bound to a container (#10250)
* fix: only enable wrap text in a container when at least one text element selected is unbound

* Trigger Rebuild

---------

Co-authored-by: Mark Tolmacs <mark@lazycat.hu>
2025-10-26 23:00:17 +01:00
zsviczian 19b3dc658a fix: set radix PropertiesPopover collision boundary (#10221)
* Set collision boundary

* Calculate collisionPadding dynamically based on container

* Add appState offsetTop and offsetLeft to padding calculation.

Refactor collisionPadding calculation to use app state offsets.

* Update PropertiesPopover.tsx

* popover positioning relative to container
2025-10-22 23:29:39 +02:00
David Luzar 4e0441eeb4 fix: small tweaks to shortcut hints (#10214) 2025-10-20 16:57:40 +02:00
Omar Brikaaanddwelle 8013eb5e16 feat: More prominent keyboard shortcuts in hints (#10057)
* Initial

* Memoize

* Styling

* Use double angle brackets for keyboard shortcuts

* Use rem in gap

* Use an existing function for substituting tags in a string

* Revert styling

* Avoid unique key warnings

* Styling

* getTransChildren -> nodesFromTextWithTags

* Use height and padding instead of padding only

* Initial new idea

* WIP shortcut substitutions

* Use simple regex for parsing shortcuts

* Use single shortcut for combos

* Use kbd instead of span

* shortcutFromKeyString -> getTaggedShortcutKey

* Bug fix

* FlowChart -> Flowchart

* memo is useless here

* Trigger CI

* Translate in getShortcutKey

* More normalized shortcuts

* improve shortcut normalization and replacement & support multi-key tagged shortcuts

* fix regex

* tweak css

---------

Co-authored-by: dwelle <5153846+dwelle@users.noreply.github.com>
2025-10-20 16:09:20 +02:00
Ryan Dianddwelle 725412ebd3 fix: context menu getting covered (#10199)
* do not show z-index actions on mobile or tablet

* fix: context menu getting covered

* fix lint

* fix style popup getting covered

* put contextmenu z-index above sidebar

---------

Co-authored-by: dwelle <5153846+dwelle@users.noreply.github.com>
2025-10-20 11:56:55 +02:00
Márk Tolmácsanddwelle 7da176ff7d fix: Increase transform handle offset (#10180)
* fix: Increase transform handle offset

Signed-off-by: Mark Tolmacs <mark@lazycat.hu>

* fix: Temporarily disable transform handles for linear elements on mobile and tablets

Signed-off-by: Mark Tolmacs <mark@lazycat.hu>

* fix: Linear hidden resize

Signed-off-by: Mark Tolmacs <mark@lazycat.hu>

* disable mobielOrTablet linear element bbox completely

* fix: Test

Signed-off-by: Mark Tolmacs <mark@lazycat.hu>

* fix: Lint

Signed-off-by: Mark Tolmacs <mark@lazycat.hu>

---------

Signed-off-by: Mark Tolmacs <mark@lazycat.hu>
Co-authored-by: dwelle <5153846+dwelle@users.noreply.github.com>
2025-10-15 21:16:20 +02:00
David Luzar 5fffc4743f fix: mobile UI and other fixes (#10177)
* remove legacy openMenu=shape state and unused actions

* close menus/popups in applicable cases when opening a different one

* split ui z-indexes to account prefer different overlap

* make top canvas area clickable on mobile

* make mobile main menu closable by clicking outside and reduce width

* offset picker popups from viewport border on mobile

* reduce items gap in mobile main menu

* show top picks for canvas bg colors in all ui modes

* fix menu separator visibility on mobile

* fix command palette items not being filtered
2025-10-14 16:34:49 +02:00
David Luzar 8608d7b2e0 fix: revert preferred selection to box once you switch to full UI (#10160) 2025-10-12 23:33:02 +02:00
Omar Brikaa 19b03b4ca9 fix: remove redundant selectionStart/End resetting that causes scroll-reset bug on firefox (#8263)
Remove redundant selectionStart/End resetting that causes scroll-reset bug on firefox
2025-10-10 18:12:08 +02:00
416e8b3e42 feat: new mobile layout (#9996)
* compact bottom toolbar

* put menu trigger to top left

* add popup to switch between grouped tool types

* add a dedicated mobile toolbar

* update position for mobile

* fix active tool type

* add mobile mode as well

* mobile actions

* remove refactored popups

* excali logo mobile

* include mobile

* update mobile menu layout

* move selection and deletion back to right

* do not fill eraser

* fix styling

* fix active styling

* bigger buttons, smaller gaps

* fix other tools not opened

* fix: Style panel persistence and restore

Signed-off-by: Mark Tolmacs <mark@lazycat.hu>

* move hidden action btns to extra popover

* fix dropdown overlapping with welcome screen

* replace custom popup with popover

* improve button styles

* swapping redo and delete

* always show undo & redo and improve styling

* change background

* toolbar styles

* no any

* persist perferred selection tool and align tablet as well

* add a renderTopLeftUI to props

* tweak border and bg

* show combined properties only when using suitable tools

* fix preferred tool

* new stroke icon

* hide color picker hot keys

* init preferred tool based on device

* fix main menu sizing

* fix welcome screen offset

* put text before image

* disable call highlight on buttons

* fix renderTopLeftUI

---------

Signed-off-by: Mark Tolmacs <mark@lazycat.hu>
Co-authored-by: Mark Tolmacs <mark@lazycat.hu>
Co-authored-by: dwelle <5153846+dwelle@users.noreply.github.com>
2025-10-09 23:48:31 +02:00
David Espinoza 98e0cd9078 build: Docker compose version removed (#10074) 2025-10-05 14:48:54 +02:00
Akibur Rahman f3c16a600d fix: text to diagram translation update issue on language update (#10016) 2025-10-02 16:47:26 +02:00
Emilanddwelle 835eb8d2fd fix: display error message when local storage quota is exceeded (#9961)
* fix: display error message when local storage quota is exceeded

* add danger alert instead of toast

* tweak text

---------

Co-authored-by: dwelle <5153846+dwelle@users.noreply.github.com>
2025-09-30 23:54:43 +02:00
zsvicziananddwelle fde796a7a0 feat: Make naming of library items discoverable (#10041)
* updated library relevant strings

* fix: detect name changes

* clarify hashing function

---------

Co-authored-by: dwelle <5153846+dwelle@users.noreply.github.com>
2025-09-30 18:38:10 +00:00
Yago Dórea 7c41944856 fix: small improvement on binary heap implementation (#9992) 2025-09-30 17:09:20 +02:00
Omar Eltomyanddwelle f1b097ad06 fix: support bidirectional shift+click selection in library items (#10034)
* fix: support bidirectional shift+click selection in library items

- Enable bottom-up multi-selection (previously only top-down worked)
- Use Math.min/max to handle selection range in both directions
- Maintains existing behavior for preserving non-contiguous selections
- Fixes issue where shift+clicking items above last selected item failed

* improve deselection behavior

---------

Co-authored-by: dwelle <5153846+dwelle@users.noreply.github.com>
2025-09-29 11:46:42 +00:00
David Luzar 9fcbbe0d27 fix: library search UI fixes/tweaks (#10032)
* fix library icon height in command palette

* add clear button when no results
2025-09-29 12:06:17 +02:00
Archie Senguptaanddwelle ec070911b8 feat: library search (#9903)
* feat(utils): add support for search input type in isWritableElement

* feat(i18n): add search text

* feat(cmdp+lib): add search functionality for command pallete and lib menu items

* chore: fix formats, and whitespaces

* fix: opt to optimal code changes

* chore: fix for linting

* focus input on mount

* tweak placeholder

* design and UX changes

* tweak item hover/active/seletected states

* unrelated: move publish button above delete/clear to keep it more stable

* esc to clear search input / close sidebar

* refactor command pallete library stuff

* make library commands bigger

---------

Co-authored-by: dwelle <5153846+dwelle@users.noreply.github.com>
2025-09-28 22:16:28 +02:00
Davide Wietlisbachanddwelle dcdeb2be57 fix: increase rejection delay for opening files with legacy api (#8961)
* Increased input change interval to 1000 ms to fix IOS 18 file opening issue

* increase more

---------

Co-authored-by: dwelle <5153846+dwelle@users.noreply.github.com>
2025-09-26 16:30:23 +02:00
David Luzar a8acc8212d feat: better file normalization (#10024)
* feat: better file normalization

* fix lint

* fix png detection

* optimize

* fix type
2025-09-25 22:26:58 +02:00
Márk Tolmács a89a03c66c fix: Arrow eraser precision arrow selection (#10006) 2025-09-24 20:28:41 +02:00
Márk Tolmács e32836f799 fix: Use analytical Jacobian for curve intersection testing (#10007) 2025-09-24 19:33:20 +02:00
Márk Tolmács 06c40006db fix: Elbow arrow routing issue with diamonds and ellipses (#10021) 2025-09-24 19:22:32 +02:00
91c7748c3d fix: added normalization to images added with the image tool to prevent MIME-mismatches (#10018)
* fix: fixed a bug where a MIME-mismatch in an image would cause an error to update cache

* fix: fixed a bug where a MIME-mismatch in an image would cause an error to update cache

* normalize inside insertImages()

---------

Co-authored-by: Mårten Mossberg <marmo607@student.liu.se>
Co-authored-by: dwelle <5153846+dwelle@users.noreply.github.com>
2025-09-24 16:30:50 +00:00
David Luzar f738b74791 fix: reintroduce height-based mobile query detection (#10020) 2025-09-24 18:17:39 +02:00
Omar BrikaaandDavid Luzar 00ae455873 fix: Remove local elements when there is room data during startCollaboration (#9786)
* Remove local elements when there is room data

* Update excalidraw-app/collab/Collab.tsx

---------

Co-authored-by: David Luzar <5153846+dwelle@users.noreply.github.com>
2025-09-23 22:18:41 +00:00
ericvannunenanddwelle 06c5ea94d3 fix: Race conditions when adding many library items (#10013)
* Fix for race condition when adding many library items

* Remove unused import

* Replace any with LibraryItem type

* Fix comments on pr

* Fix build errors

* Fix hoisted variable

* new mime type

* duplicate before passing down to be sure

* lint

* fix tests

* Remove unused import

---------

Co-authored-by: dwelle <5153846+dwelle@users.noreply.github.com>
2025-09-23 23:47:03 +02:00
Márk Tolmács f55ecb96cc fix: Mobile arrow point drag broken (#9998)
* fix: Mobile bound arrow point drag broken

* fix:Check real point
2025-09-19 19:41:03 +02:00
David Luzar a6a32b9b29 fix: align MQ breakpoints and always use editor dimensions (#9991)
* fix: align MQ breakpoints and always use editor dimensions

* naming

* update snapshots
2025-09-17 07:57:10 +00:00
Márk Tolmács ac0d3059dc fix: Use the right polygon enclosure test (#9979) 2025-09-15 10:07:37 +02:00
Christopher TangonanandMárk Tolmács 1161f1b8ba fix: eraser can handle dots without regressing prior performance improvements (#9946)
Co-authored-by: Márk Tolmács <mark@lazycat.hu>
2025-09-14 11:33:43 +00:00
Ryan Dianddwelle 204e06b77b feat: compact layout for tablets (#9910)
* feat: allow the hiding of top picks

* feat: allow the hiding of default fonts

* refactor: rename to compactMode

* feat: introduce layout (incomplete)

* tweak icons

* do not show border

* lint

* add isTouchMobile to device

* add isTouchMobile to device

* refactor to use showCompactSidebar instead

* hide library label in compact

* fix icon color in dark theme

* fix library and share btns getting hidden in smaller tablet widths

* update tests

* use a smaller gap between shapes

* proper fix of range

* quicker switching between different popovers

* to not show properties panel at all when editing text

* fix switching between different popovers for texts

* fix popover not closable and font search auto focus

* change properties for a new or editing text

* change icon for more style settings

* use bolt icon for extra actions

* fix breakpoints

* use rem for icon sizes

* fix tests

* improve switching between triggers (incomplete)

* improve trigger switching (complete)

* clean up code

* put compact into app state

* fix button size

* remove redundant PanelComponentProps["compactMode"]

* move fontSize UI on top

* mobile detection (breakpoints incomplete)

* tweak compact mode detection

* rename appState prop & values

* update snapshots

---------

Co-authored-by: dwelle <5153846+dwelle@users.noreply.github.com>
2025-09-12 10:18:31 +10:00
David Luzar 414182f599 fix: normalize file on paste/drop (#9959) 2025-09-10 17:59:02 +02:00
David Luzar b9d27d308e fix: pasting not working in firefox (#9947) 2025-09-06 22:51:23 +02:00
Omar Brikaaanddwelle 3bdaafe4b5 feat: [cont.] support inserting multiple images (#9875)
* feat: support inserting multiple images

* Initial

* handleAppOnDrop, onImageToolbarButtonClick, pasteFromClipboard

* Initial get history working

* insertMultipleImages -> insertImages

* Bug fixes, improvements

* Remove redundant branch

* Refactor addElementsFromMixedContentPaste

* History, drag & drop bug fixes

* Update snapshots

* Remove redundant try-catch

* Refactor pasteFromClipboard

* Plain paste check in mermaid paste

* Move comment

* processClipboardData -> insertClipboardContent

* Redundant variable

* Redundant variable

* Refactor insertImages

* createImagePlaceholder -> newImagePlaceholder

* Get rid of unneeded NEVER schedule, filter out failed images

* Trigger CI

* Position placeholders before initializing

* Don't mutate scene with positionElementsOnGrid, captureUpdate: CaptureUpdateAction.IMMEDIATELY

* Comment

* Move positionOnGrid out of file

* Rename file

* Get rid of generic

* Initial tests

* More asserts, test paste

* Test image tool

* De-duplicate

* Stricter assert, move rest of logic outside of waitFor

* Modify history tests

* De-duplicate update snapshots

* Trigger CI

* Fix package build

* Make setupImageTest more explicit

* Re-introduce generic to use latest placeholder versions

* newElementWith instead of mutateElement to delete failed placeholder

* Insert failed images separately with CaptureUpdateAction.NEVER

* Refactor

* Don't re-order elements

* WIP

* Get rid of 'never' for failed

* refactor type check

* align max file size constant

* make grid padding scale to zoom

---------

Co-authored-by: dwelle <5153846+dwelle@users.noreply.github.com>
2025-09-01 17:31:24 +02:00
Christopher TangonanandA-Mundanilkunathil ae89608985 fix: bound text rotation across alignments (#9914)
Co-authored-by: A-Mundanilkunathil <aaronchackom2002@gmail.com>
2025-08-29 12:31:23 +02:00
Ryan Di 3085f4af81 fix: tighten distance for double tap text creation (#9889) 2025-08-22 18:12:51 +02:00
David LuzarandMark Tolmacs 531f3e5524 fix: restore from invalid fixedSegments & type-safer point updates (#9899)
* fix: restore from invalid fixedSegments & type-safer point updates

* fix: Type updates and throw for invalid point states

---------

Co-authored-by: Mark Tolmacs <mark@lazycat.hu>
2025-08-22 15:45:58 +00:00
David Luzar 90ec2739ae fix: calling toLowerCase on potentially undefined navigator.* values (#9901) 2025-08-22 17:37:16 +02:00
David Luzar f29e9df72d chore: bump mermaid-to-excalidraw to 1.1.3 (#9898) 2025-08-21 20:58:04 +02:00
Marcel Mraz b5ad7ae4e3 fix: even deltas with version & version nonce are valid (#9897) 2025-08-21 16:09:19 +02:00
David Luzar c78e4aab7f chore: tweak title & remove timeout (#9883) 2025-08-20 14:09:20 +02:00
Ryan Dianddwelle b4903a7eab feat: drag, resize, and rotate after selecting in lasso (#9732)
* feat: drag, resize, and rotate after selecting in lasso

* alternative ux: drag with lasso right away

* fix: lasso dragging should snap too

* fix: alt+cmd getting stuck

* test: snapshots

* alternatvie: keep lasso drag to only mobile

* alternative: drag after selection on PCs

* improve mobile dection

* add mobile lasso icon

* add default selection tool

* render according to default selection tool

* return to default selection tool after deletion

* reset to default tool after clearing out the canvas

* return to default tool after eraser toggle

* if default lasso, close lasso toggle

* finalize to default selection tool

* toggle between laser and default selection

* return to default selection tool after creation

* double click to add text when using default selection tool

* set to default selection tool after unlocking tool

* paste to center on touch screen

* switch to default selection tool after pasting

* lint

* fix tests

* show welcome screen when using default selection tool

* fix tests

* fix snapshots

* fix context menu not opening

* prevent potential displacement issue

* prevent element jumping during lasso selection

* fix dragging on mobile

* use same selection icon

* fix alt+cmd lasso getting cut off

* fix: shortcut handling

* lint

---------

Co-authored-by: dwelle <5153846+dwelle@users.noreply.github.com>
2025-08-20 00:03:02 +02:00
zsviczian c6f8ef9ad2 fix: Scene deleted after pica image resize failure (#9879)
Revert change in private updateImageCache
2025-08-18 11:45:05 +02:00
Marcel Mraz 2535d73054 feat: apply deltas API (#9869) 2025-08-15 15:25:56 +02:00
David Luzar dda3affcb0 fix: do not strip invisible elements from array (#9844) 2025-08-12 11:56:11 +02:00
Marcel Mraz 54c148f390 fix: text restore & deletion issues (#9853) 2025-08-12 09:27:04 +02:00
zsviczian cc8e490c75 fix: do not auto-add elements to locked frame (#9851)
* Do not return locked frames when filtering for top level frame

* lint

* lint

* lint
2025-08-11 11:52:44 +02:00
Marcel Mraz 9036812b6d fix: editing linear element (#9839) 2025-08-08 09:30:11 +02:00
Marcel Mraz df25de7e68 feat: fix delta apply to issues (#9830) 2025-08-07 15:38:58 +02:00
David Luzar a3763648fe chore: update title (#9814)
* chore: update title

* update meta tag

* lint
2025-08-01 17:17:42 +02:00
Ryan Dianddwelle 178eca5828 fix: add frame clipping to new element canvas (#9794)
* fix: add frame clipping to new element canvas

* cleanup save/restore

---------

Co-authored-by: dwelle <5153846+dwelle@users.noreply.github.com>
2025-07-31 12:10:59 +00:00
Ryan Di cb33de25f4 feat: allow a frame to snap to its children (#9795) 2025-07-31 13:58:29 +02:00
Omar Brikaa 37ad85cbaf fix: Fix the root cause of flushSync flickering (#9791)
* More reliable width and height change detection

* Put the dimensions useEffect before the scene render one, just in case
2025-07-27 23:52:07 +02:00
Márk Tolmácsanddwelle d6a934ed19 chore: Remove editingLinearElement (#9771)
Co-authored-by: dwelle <5153846+dwelle@users.noreply.github.com>
2025-07-24 17:02:21 +02:00
Omar BrikaaandMark Tolmacs 416da62138 fix: multiple line editor bugs (#9760)
Co-authored-by: Mark Tolmacs <mark@lazycat.hu>
2025-07-24 09:11:04 +02:00
Omar Brikaa f38f381989 fix: Remove flushSync from alt-lasso and elbow dragging (#9734)
* Remove lasso flushSync

* Remove selectedLinearElement flushSync

* Early return
2025-07-23 23:39:16 +02:00
Ryan Dianddwelle e5e07260c6 fix: improve line creation ux on touch screens (#9740)
* fix: awkward point adding and removing on touch device

* feat: move finalize to next to last point

* feat: on touch screen, click would create a default line/arrow

* fix: make default adaptive to zoom

* fix: increase padding to avoid cutoffs

* refactor: simplify

* fix: only use bigger padding when needed

* center arrow horizontally on pointer

* increase min drag distance before we start 2-point-arrow-drag-creating

* do not render 0-width arrow while creating

* dead code

* fix tests

* fix: remove redundant code

* do not enter line editor on creation

---------

Co-authored-by: dwelle <5153846+dwelle@users.noreply.github.com>
2025-07-23 18:49:56 +10:00
Christopher Tangonan 8492b144b0 test: added test file for distribute (#9754) 2025-07-17 19:52:16 +02:00
Marcel Mraz e46f038132 feat: expose applyTo options, don't commit empty text element (#9744)
* Expose applyTo options, skip re-draw for empty text

* Don't commit empty text elements
2025-07-17 15:22:32 +02:00
David Luzar 678dff25ed fix: ellipsify MainMenu and CommandPalette items (#9743)
* fix: ellipsify MainMenu and CommandPalette items

* fix lint
2025-07-15 12:59:55 +02:00
Christopher Tangonan 0cfa53b764 fix: aligning and distributing elements and nested groups while editing a group (#9721) 2025-07-15 12:43:42 +02:00
David Luzar cde46793f8 feat: support timestamps for youtube video emebds (#9737) 2025-07-13 19:19:10 +02:00
Aakansha Doshi 2d127f8c22 docs: fix broken update scene button example in docs (#9726)
fix: update scene example in docs
2025-07-08 19:29:44 +05:30
Soham KulkarniandRyan Di 4eadb891f8 fix(toast): prevent toast from re-rendering and resetting timeout Fixes #9714 (#9715)
* Update App.tsx

* fix: lint

---------

Co-authored-by: Ryan Di <ryan.weihao.di@gmail.com>
2025-07-03 17:07:26 +10:00
Marcel Mraz 258605d1d5 chore: release multiple packages (#9698) 2025-06-30 12:19:15 +02:00
Márk Tolmács c141500400 chore: Relocate visualdebug so ESLint doesn't complain (#9668) 2025-06-18 14:45:51 +02:00
Márk Tolmács 8e27de2cdc fix: Frame dimensions change by stats don't include new elements (#9568) 2025-06-16 14:07:03 +02:00
Márk Tolmács 0a19c93509 fix: Bindings at partially overlapping binding areas (#9536) 2025-06-16 12:30:59 +02:00
Márk Tolmács 958597dfaa chore: Refactor doBoundsIntersect (#9657) 2025-06-16 12:30:42 +02:00
Marcel Mrazanddwelle 058918f8e5 feat: capture images after they initialize (#9643)
Co-authored-by: dwelle <5153846+dwelle@users.noreply.github.com>
2025-06-15 23:43:14 +02:00
Spawn 3f194918e6 feat: add mulitplatform Docker image support (#9594) 2025-06-15 20:11:37 +02:00
Ryan Di 93c92d13e9 feat: wrap texts from stats panel (#9552) 2025-06-14 13:05:24 +02:00
zsvicziananddwelle 84e96e9393 fix: move doBoundsIntersect from element/src/bounds.ts to common/math/src/utils.ts (#9650)
move doBoundsIntersect to math/utils

Co-authored-by: dwelle <5153846+dwelle@users.noreply.github.com>
2025-06-14 11:01:30 +00:00
zsviczian 320af405e9 fix: move elementCenterPoint from common/src/utils.ts to element/src/bounds.ts (#9647)
move elementCenterPoint from utils to bounds.ts
2025-06-14 12:49:22 +02:00
Marcel Mraz 60512f13d5 Fix broken history when eleemnt in update scene are optional 2025-06-14 12:29:58 +02:00
Márk Tolmács f0458cc216 fix: Mid-point for rounded linears are not precisely centered (#9544) 2025-06-12 21:08:37 +02:00
Márk Tolmács 9f3fdf5505 fix: Test hook usage in production code (#9645) 2025-06-12 10:39:50 +02:00
Márk Tolmács f42e1ab64e perf: Improve elbow arrow indirect binding logic (#9624) 2025-06-11 19:15:48 +02:00
Ashwin TemkarandMark Tolmacs 18808481fd fix: set cursor to auto when not hovering a point on linear element (#9642)
* fix: set cursor to auto when not hovering a point on linear element #9628

* Simplify hover test for cursor

* Add back comment

* Fix test for hit testing

---------

Co-authored-by: Mark Tolmacs <mark@lazycat.hu>
2025-06-11 16:52:02 +02:00
Marcel Mrazanddwelle a7b64f02b3 fix: remove image preview on image insertion (#9626)
Co-authored-by: dwelle <5153846+dwelle@users.noreply.github.com>
2025-06-10 21:31:11 +02:00
Marcel Mraz 0d4abd1ddc fix: add history capture for paste and drop of images and embeds (#9605) 2025-06-10 14:28:16 +02:00
Sachintha Lakmin 9e77373c81 fix: add generic font family fallbacks before Segoe UI Emoji to fix glyph rendering on windows (#9425) 2025-06-10 13:43:39 +02:00
Marcel Mraz d108053351 feat: various delta improvements (#9571) 2025-06-09 09:55:35 +02:00
David Luzar d4e85a9480 feat: use enter to edit line points & update hints (#9630)
feat: use enter to edit line points & update hints
2025-06-07 18:05:20 +02:00
David Luzar 08cd4c4f9a test: improve getTextEditor test helper (#9629)
* test: improve getTextEditor test helper

* fix test
2025-06-07 17:45:37 +02:00
cheapsteranddwelle 469caadb87 fix: prevent double-click to edit/create text scenarios on line (#9597)
* fix : double click on line enables line editor

* fix : prevent double-click to edit/create text
when inside line editor

* refactor: use lineCheck instead of arrowCheck in
doubleClick handler to align with updated logic

* fix: replace negative arrowCheck with lineCheck in
dbl click handler and fix double-click bind text
test in linearElementEditor tests

* clean up test

* simplify check

* add tests

* prevent text editing on dblclick when inside arrow editor

---------

Co-authored-by: dwelle <5153846+dwelle@users.noreply.github.com>
2025-06-07 17:08:35 +02:00
Márk Tolmács ca1a4f25e7 feat: Precise hit testing (#9488) 2025-06-07 12:56:32 +02:00
Sujal Gupta 56c05b3099 fix: prevent search menu from opening when dialog is open (#9279) 2025-06-03 15:53:00 +02:00
Aarav Dayal 6c0ff7fc5c docs: added the correct CSS import for nextjs dynamic first import integration example (#9584)
Added the correct CSS import for nextjs dynamic first import integration example

This is with reference to [this](https://github.com/excalidraw/excalidraw/issues/9562)
2025-05-29 22:03:20 +02:00
722 changed files with 115131 additions and 30275 deletions
+1 -1
View File
@@ -1,4 +1,4 @@
FROM node:18-bullseye
FROM node:24-bullseye
# Vite wants to open the browser using `open`, so we
# need to install those utils.
+2 -2
View File
@@ -12,7 +12,7 @@ VITE_APP_WS_SERVER_URL=http://localhost:3002
VITE_APP_PLUS_LP=https://plus.excalidraw.com
VITE_APP_PLUS_APP=http://localhost:3000
VITE_APP_AI_BACKEND=http://localhost:3015
VITE_APP_AI_BACKEND=http://localhost:3016
VITE_APP_FIREBASE_CONFIG='{"apiKey":"AIzaSyCMkxA60XIW8KbqMYL7edC4qT5l4qHX2h8","authDomain":"excalidraw-oss-dev.firebaseapp.com","projectId":"excalidraw-oss-dev","storageBucket":"excalidraw-oss-dev.appspot.com","messagingSenderId":"664559512677","appId":"1:664559512677:web:a385181f2928d328a7aa8c"}'
@@ -27,7 +27,7 @@ VITE_APP_ENABLE_TRACKING=true
FAST_REFRESH=false
# The port the run the dev server
VITE_APP_PORT=3000
VITE_APP_PORT=3001
#Debug flags
+7
View File
@@ -0,0 +1,7 @@
# 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
+22 -1
View File
@@ -39,5 +39,26 @@
"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"]
}
]
}
}
]
}
+5 -5
View File
@@ -9,13 +9,13 @@ jobs:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v2
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
with:
fetch-depth: 2
- name: Setup Node.js 18.x
uses: actions/setup-node@v2
- name: Setup Node.js
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
with:
node-version: 18.x
node-version: 20.x
- name: Set up publish access
run: |
npm config set //registry.npmjs.org/:_authToken ${NPM_TOKEN}
@@ -24,4 +24,4 @@ jobs:
- name: Auto release
run: |
yarn add @actions/core -W
yarn autorelease
yarn release --tag=next --non-interactive
-55
View File
@@ -1,55 +0,0 @@
name: Auto release excalidraw preview
on:
issue_comment:
types: [created, edited]
jobs:
Auto-release-excalidraw-preview:
name: Auto release preview
if: github.event.comment.body == '@excalibot trigger release' && github.event.issue.pull_request
runs-on: ubuntu-latest
steps:
- name: React to release comment
uses: peter-evans/create-or-update-comment@v1
with:
token: ${{ secrets.PUSH_TRANSLATIONS_COVERAGE_PAT }}
comment-id: ${{ github.event.comment.id }}
reactions: "+1"
- name: Get PR SHA
id: sha
uses: actions/github-script@v4
with:
result-encoding: string
script: |
const { owner, repo, number } = context.issue;
const pr = await github.pulls.get({
owner,
repo,
pull_number: number,
});
return pr.data.head.sha
- uses: actions/checkout@v2
with:
ref: ${{ steps.sha.outputs.result }}
fetch-depth: 2
- name: Setup Node.js 18.x
uses: actions/setup-node@v2
with:
node-version: 18.x
- name: Set up publish access
run: |
npm config set //registry.npmjs.org/:_authToken ${NPM_TOKEN}
env:
NPM_TOKEN: ${{ secrets.NPM_TOKEN }}
- name: Auto release preview
id: "autorelease"
run: |
yarn add @actions/core -W
yarn autorelease preview ${{ github.event.issue.number }}
- name: Post comment post release
if: always()
uses: peter-evans/create-or-update-comment@v1
with:
token: ${{ secrets.PUSH_TRANSLATIONS_COVERAGE_PAT }}
issue-number: ${{ github.event.issue.number }}
body: "@${{ github.event.comment.user.login }} ${{ steps.autorelease.outputs.result }}"
+1 -1
View File
@@ -9,5 +9,5 @@ jobs:
build-docker:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v2
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
- run: docker build -t excalidraw .
+1 -1
View File
@@ -11,7 +11,7 @@ jobs:
runs-on: ubuntu-latest
timeout-minutes: 3
steps:
- uses: styfle/cancel-workflow-action@0.6.0
- uses: styfle/cancel-workflow-action@ce177499ccf9fd2aded3b0426c97e5434c2e8a73 # 0.6.0
with:
workflow_id: 400555, 400556, 905313, 1451724, 1710116, 3185001, 3438604
access_token: ${{ secrets.GITHUB_TOKEN }}
+4 -4
View File
@@ -7,12 +7,12 @@ jobs:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v2
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
- name: Setup Node.js 18.x
uses: actions/setup-node@v2
- name: Setup Node.js
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
with:
node-version: 18.x
node-version: 20.x
- name: Install and lint
run: |
+6 -6
View File
@@ -10,14 +10,14 @@ jobs:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
with:
token: ${{ secrets.PUSH_TRANSLATIONS_COVERAGE_PAT }}
- name: Setup Node.js 18.x
uses: actions/setup-node@v2
- name: Setup Node.js
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
with:
node-version: 18.x
node-version: 20.x
- name: Create report file
run: |
@@ -40,8 +40,8 @@ jobs:
echo ::set-output name=body::$body
- name: Update description with coverage
uses: kt3k/update-pr-description@v1.0.1
uses: kt3k/update-pr-description@1b35a6dcd84d81aa0bc1889610efdcde7f37b0c0 # v1.0.1
with:
pr_body: ${{ steps.getCommentBody.outputs.body }}
pr_title: "chore: Update translations from Crowdin"
pr_title: "chore(editor): Update translations from Crowdin"
github_token: ${{ secrets.PUSH_TRANSLATIONS_COVERAGE_PAT }}
+8 -3
View File
@@ -11,15 +11,20 @@ jobs:
steps:
- name: Checkout repository
uses: actions/checkout@v3
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
- name: Login to DockerHub
uses: docker/login-action@v2
uses: docker/login-action@465a07811f14bebb1938fbed4728c6a1ff8901fc # v2
with:
username: ${{ secrets.DOCKER_USERNAME }}
password: ${{ secrets.DOCKER_PASSWORD }}
- name: Set up QEMU
uses: docker/setup-qemu-action@c7c53464625b32c7a7e944ae62b3e17d2b600130 # v3
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3
- name: Build and push
uses: docker/build-push-action@v3
uses: docker/build-push-action@ca052bb54ab0790a636c9b5f226502c73d547a25 # v5
with:
context: .
push: true
tags: excalidraw/excalidraw:latest
platforms: linux/amd64, linux/arm64, linux/arm/v7
+99 -1
View File
@@ -6,11 +6,109 @@ on:
- opened
- edited
- synchronize
- labeled
- unlabeled
jobs:
semantic:
runs-on: ubuntu-latest
permissions:
pull-requests: read
steps:
- uses: amannn/action-semantic-pull-request@v5
- uses: amannn/action-semantic-pull-request@e32d7e603df1aa1ba07e981f2a23455dee596825 # v5
with:
types: |
feat
fix
docs
style
refactor
perf
test
build
ci
chore
revert
requireScope: true
scopes: |
app
editor
packages/excalidraw
packages/utils
docker
repo
ignoreLabels: |
skip-semantic-title
env:
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
+5 -5
View File
@@ -9,11 +9,11 @@ jobs:
sentry:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v2
- name: Setup Node.js 18.x
uses: actions/setup-node@v2
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
- name: Setup Node.js
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
with:
node-version: 18.x
node-version: 20.x
- name: Install and build
run: |
yarn --frozen-lockfile
@@ -28,7 +28,7 @@ jobs:
export SENTRY_RELEASE=$(sentry-cli releases propose-version)
sentry-cli releases new $SENTRY_RELEASE --project $SENTRY_PROJECT
sentry-cli releases set-commits --auto $SENTRY_RELEASE
sentry-cli releases files $SENTRY_RELEASE upload-sourcemaps --no-rewrite ./build/static/js/ --url-prefix "~/static/js"
sentry-cli sourcemaps upload --release $SENTRY_RELEASE --no-rewrite ./build/static/js/ --url-prefix "~/static/js"
sentry-cli releases finalize $SENTRY_RELEASE
sentry-cli releases deploys $SENTRY_RELEASE new -e production
env:
+5 -5
View File
@@ -10,17 +10,17 @@ jobs:
CI_JOB_NUMBER: 1
steps:
- name: Checkout repository
uses: actions/checkout@v3
- name: Setup Node.js 18.x
uses: actions/setup-node@v3
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
- name: Setup Node.js
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
with:
node-version: 18.x
node-version: 20.x
- name: Install in packages/excalidraw
run: yarn
working-directory: packages/excalidraw
env:
CI: true
- uses: andresz1/size-limit-action@v1
- uses: andresz1/size-limit-action@e7493a72a44b113341c0cf6186ab49c17c4b65c1 # v1
with:
github_token: ${{ secrets.GITHUB_TOKEN }}
build_script: build:esm
+4 -4
View File
@@ -10,17 +10,17 @@ jobs:
pull-requests: write
steps:
- uses: actions/checkout@v2
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
- name: "Install Node"
uses: actions/setup-node@v2
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
with:
node-version: "18.x"
node-version: "20.x"
- name: "Install Deps"
run: yarn install
- name: "Test Coverage"
run: yarn test:coverage
- name: "Report Coverage"
if: always() # Also generate the report if tests are failing
uses: davelosert/vitest-coverage-report-action@v2
uses: davelosert/vitest-coverage-report-action@2500dafcee7dd64f85ab689c0b83798a8359770e # v2
with:
github-token: ${{ secrets.GITHUB_TOKEN }}
+4 -4
View File
@@ -8,11 +8,11 @@ jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Setup Node.js 18.x
uses: actions/setup-node@v4
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
- name: Setup Node.js
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
with:
node-version: 18.x
node-version: 20.x
- name: Install and test
run: |
yarn install
-1
View File
@@ -1 +0,0 @@
18
+4
View File
@@ -0,0 +1,4 @@
# Guidelines
- For new DOM/browser API usage, use `app.ownerDocument` and `app.ownerWindow` instead of globals; without `app`, derive them from the mounted node's `ownerDocument` and its `defaultView`.
- When overriding properties of an existing type, prefer `Merge<Base, Overrides>` from `@excalidraw/common/utility-types` over `Omit<Base, keyof Overrides> & Overrides`.
+5 -4
View File
@@ -1,4 +1,4 @@
FROM node:18 AS build
FROM --platform=${BUILDPLATFORM} node:24@sha256:8530f76a96d88820d288761f022e318970dda93d01536919fbc16076b7983e63 AS build
WORKDIR /opt/node_app
@@ -6,13 +6,14 @@ COPY . .
# do not ignore optional dependencies:
# Error: Cannot find module @rollup/rollup-linux-x64-gnu
RUN yarn --network-timeout 600000
RUN --mount=type=cache,target=/root/.cache/yarn \
npm_config_target_arch=${TARGETARCH} yarn --frozen-lockfile --network-timeout 600000
ARG NODE_ENV=production
RUN yarn build:app:docker
RUN npm_config_target_arch=${TARGETARCH} yarn build:app:docker
FROM nginx:1.27-alpine
FROM nginx:stable-alpine-slim@sha256:2c605dbeab79a6b2a63340474fe58119d0ef95bdc4b1f41df0aa689659b3d13b
COPY --from=build /opt/node_app/excalidraw-app/build /usr/share/nginx/html
+6 -12
View File
@@ -23,23 +23,17 @@
<br />
<p align="center">
<a href="https://github.com/excalidraw/excalidraw/blob/master/LICENSE">
<img alt="Excalidraw is released under the MIT license." src="https://img.shields.io/badge/license-MIT-blue.svg" />
</a>
<img alt="Excalidraw is released under the MIT license." src="https://img.shields.io/badge/license-MIT-blue.svg" /></a>
<a href="https://www.npmjs.com/package/@excalidraw/excalidraw">
<img alt="npm downloads/month" src="https://img.shields.io/npm/dm/@excalidraw/excalidraw" />
</a>
<img alt="npm downloads/month" src="https://img.shields.io/npm/dm/@excalidraw/excalidraw" /></a>
<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">
<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>
<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>
<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">
<img alt="Follow Excalidraw on Twitter" src="https://img.shields.io/twitter/follow/excalidraw.svg?label=follow+@excalidraw&style=social&logo=twitter"/>
</a>
<img alt="Follow Excalidraw on Twitter" src="https://img.shields.io/twitter/follow/excalidraw.svg?label=follow+@excalidraw&style=social&logo=twitter"/></a>
</p>
<div align="center">
@@ -9,7 +9,7 @@ You will need to import the `Footer` component from the package and wrap your co
```jsx live
function App() {
return (
<div style={{ height: "500px"}}>
<div style={{ height: "500px" }}>
<Excalidraw>
<Footer>
<button
@@ -27,19 +27,19 @@ function App() {
This will only work for `Desktop` devices.
For `mobile` you will need to render it inside the [MainMenu](#mainmenu). You can use the [`useDevice`](#useDevice) hook to check the type of device, this will be available only inside the `children` of `Excalidraw` component.
For `mobile` you will need to render it inside the [MainMenu](#mainmenu). You can use the [`useEditorInterface`](#useEditorInterface) hook to check the type of device, this will be available only inside the `children` of `Excalidraw` component.
Open the `Menu` in the below playground and you will see the `custom footer` rendered.
```jsx live noInline
const MobileFooter = ({}) => {
const device = useDevice();
if (device.editor.isMobile) {
const editorInterface = useEditorInterface();
if (editorInterface.formFactor === "phone") {
return (
<Footer>
<button
className="custom-footer"
style= {{ marginLeft: '20px', height: '2rem'}}
style={{ marginLeft: "20px", height: "2rem" }}
onClick={() => alert("This is custom footer in mobile menu")}
>
custom footer
@@ -172,7 +172,7 @@ convertToExcalidrawElements([
type: "arrow",
x: 450,
y: 20,
startArrowhead: "dot",
startArrowhead: "circle",
endArrowhead: "triangle",
strokeColor: "#1971c2",
strokeWidth: 2,
@@ -274,6 +274,35 @@ convertToExcalidrawElements([
![image](https://github.com/excalidraw/excalidraw/assets/11256141/f8123cd1-c9aa-452d-b96b-05c846c5030d)
### Sticky Notes
A sticky note is an always-filled note whose label auto-fits: the font shrinks from the label's `fontSize` (the note's font ceiling) down to a minimum, and only then does the note grow in height. Only `type`, `x` and `y` are required; `width`/`height` default to a 250×250 square and `label` is optional. The note's `strokeColor` is its ink — its text and creation-date footer are always one color; a label that sets its own `strokeColor` gives it to the note. A footer band below the label shows the note's creation date (`created`); pass `created: null` for a note without one (the band stays reserved, but empty).
```js
convertToExcalidrawElements([
{
type: "stickynote",
x: 100,
y: 100,
label: {
text: "Remember to ship it",
fontSize: 28,
},
},
{
type: "stickynote",
x: 400,
y: 100,
width: 200,
height: 200,
backgroundColor: "#fcc2d7",
strokeColor: "#c2255c",
},
]);
```
Notes keep their invariants regardless of the properties you pass: the background is never transparent (it falls back to the default yellow), the fill is solid, and the size is at least 75×75. The height you pass is kept as the note's `baseHeight`; if a label needs more room at the minimum font size, the created note is taller.
### Labelled Arrows
Similar to Text Containers, you can create labelled arrows as well.
@@ -363,13 +363,7 @@ This API has the below signature. It sets the `tool` passed in param as the acti
```ts
(
tool: (
| (
| { type: Exclude<ToolType, "image"> }
| {
type: Extract<ToolType, "image">;
insertOnCanvasDirectly?: boolean;
}
)
| { type: ToolType }
| { type: "custom"; customType: string }
) & { locked?: boolean },
) => {};
@@ -377,7 +371,7 @@ This API has the below signature. It sets the `tool` passed in param as the acti
| Name | Type | Default | Description |
| --- | --- | --- | --- |
| `type` | [ToolType](https://github.com/excalidraw/excalidraw/blob/master/packages/excalidraw/types.ts#L91) | `selection` | The tool type which should be set as active tool. When setting `image` as active tool, the insertion onto canvas when using image tool is disabled by default, so you can enable it by setting `insertOnCanvasDirectly` to `true` |
| `type` | [ToolType](https://github.com/excalidraw/excalidraw/blob/master/packages/excalidraw/types.ts#L91) | `selection` | The tool type which should be set as active tool |
| `locked` | `boolean` | `false` | Indicates whether the the active tool should be locked. It behaves the same way when using the `lock` tool in the editor interface |
## setCursor
@@ -292,7 +292,7 @@ viewportCoordsToSceneCoords(&#123; clientX: number, clientY: number },<br/>&nbsp
appState: <a href="https://github.com/excalidraw/excalidraw/blob/master/packages/excalidraw/types.ts#L95">AppState</a><br/>): &#123;x: number, y: number}
</pre>
### useDevice
### useEditorInterface
This hook can be used to check the type of device which is being used. It can only be used inside the `children` of `Excalidraw` component.
@@ -300,8 +300,8 @@ Open the `main menu` in the below example to view the footer.
```jsx live noInline
const MobileFooter = ({}) => {
const device = useDevice();
if (device.editor.isMobile) {
const editorInterface = useEditorInterface();
if (editorInterface.formFactor === "phone") {
return (
<Footer>
<button
@@ -336,12 +336,20 @@ render(<App />);
The `device` has the following `attributes`, some grouped into `viewport` and `editor` objects, per context.
| Name | Type | Description |
| --- | --- | --- |
| `viewport.isMobile` | `boolean` | Set to `true` when viewport is in `mobile` breakpoint |
| `viewport.isLandscape` | `boolean` | Set to `true` when the viewport is in `landscape` mode |
| `editor.canFitSidebar` | `boolean` | Set to `true` if there's enough space to fit the `sidebar` |
| `editor.isMobile` | `boolean` | Set to `true` when editor container is in `mobile` breakpoint |
| `isTouchScreen` | `boolean` | Set to `true` for `touch` when touch event detected |
| ---- | ---- | ----------- |
The `EditorInterface` object has the following properties:
| Name | Type | Description |
| --- | --- | --- | --- | --- | --- |
| `formFactor` | `'phone' | 'tablet' | 'desktop'` | Indicates the device type based on screen size |
| `desktopUIMode` | `'compact' | 'full'` | UI mode for desktop form factor |
| `userAgent.raw` | `string` | Raw user agent string |
| `userAgent.isMobileDevice` | `boolean` | True if device is mobile |
| `userAgent.platform` | `'ios' | 'android' | 'other' | 'unknown'` | Device platform |
| `isTouchScreen` | `boolean` | True if touch events are detected |
| `canFitSidebar` | `boolean` | True if sidebar can fit in the viewport |
| `isLandscape` | `boolean` | True if viewport is in landscape mode |
### i18n
@@ -28,32 +28,12 @@ To start the example app using the `@excalidraw/excalidraw` package, follow the
## Releasing
### Create a test release
You can create a test release by posting the below comment in your pull request:
```bash
@excalibot trigger release
```
Once the version is released `@excalibot` will post a comment with the release version.
### Creating a production release
To release the next stable version follow the below steps:
```bash
yarn prerelease:excalidraw
yarn release --tag=latest --version=0.19.0
```
You need to pass the `version` for which you want to create the release. This will make the changes needed before making the release like updating `package.json`, `changelog` and more.
The next step is to run the `release` script:
```bash
yarn release:excalidraw
```
This will publish the package.
Right now there are two steps to create a production release but once this works fine these scripts will be combined and more automation will be done.
You will need to pass the `latest` tag with `version` for which you want to create the release. This will make the changes needed before publishing the packages into NPM, like updating dependencies of all `@excalidraw/*` packages, generating new entries in `CHANGELOG.md` and more.
@@ -38,6 +38,8 @@ If you want to only import `Excalidraw` component you can do :point_down:
```jsx showLineNumbers
import dynamic from "next/dynamic";
import "@excalidraw/excalidraw/index.css";
const Excalidraw = dynamic(
async () => (await import("@excalidraw/excalidraw")).Excalidraw,
{
+12
View File
@@ -62,6 +62,18 @@ When saving an Excalidraw scene locally to a file, the JSON file (`.excalidraw`)
}
```
### Element creation time
Every normalized element has a `created: number | null` field. A number is the client's wall-clock time in epoch milliseconds when this element instance was created. `null` means the creation time is unknown; restoring an older file does not substitute its load time or last-update time.
| Operation | `created` |
| --- | --- |
| Construct a new element | Current time, unless explicitly supplied (including `null`) |
| Duplicate, paste, or insert from a library | New time for the new instance |
| Convert element skeletons with ID regeneration | New time for each resulting instance |
| Restore/open/import an existing scene | Preserve the carried value; missing values become `null` |
| Edit, change type while retaining the ID, capture, undo, or redo | Preserve the value |
## Excalidraw clipboard format
When copying selected excalidraw elements to clipboard, the JSON schema is similar to `.excalidraw` format, except it differs in attributes.
+2 -2
View File
@@ -97,8 +97,8 @@ const config = {
href: "https://discord.gg/UexuTaE",
},
{
label: "Twitter",
href: "https://twitter.com/excalidraw",
label: "𝕏",
href: "https://x.com/excalidraw",
},
{
label: "Linkedin",
@@ -33,6 +33,7 @@ const ExcalidrawScope = {
initialData,
useI18n: ExcalidrawComp.useI18n,
convertToExcalidrawElements: ExcalidrawComp.convertToExcalidrawElements,
CaptureUpdateAction: ExcalidrawComp.CaptureUpdateAction,
};
export default ExcalidrawScope;
+6
View File
@@ -1,4 +1,10 @@
{
"git": {
"deploymentEnabled": {
"master": true,
"**": false
}
},
"outputDirectory": "build",
"installCommand": "yarn install"
}
-2
View File
@@ -1,5 +1,3 @@
version: "3.8"
services:
excalidraw:
build:
+2 -1
View File
@@ -3,7 +3,8 @@
"version": "0.1.0",
"private": true,
"scripts": {
"build:workspace": "yarn workspace @excalidraw/excalidraw run build:esm && yarn copy:assets",
"build:packages": "yarn --cwd ../../ build:packages",
"build:workspace": "yarn build:packages && yarn copy:assets",
"copy:assets": "cp -r ../../packages/excalidraw/dist/prod/fonts ./public",
"dev": "yarn build:workspace && next dev -p 3005",
"build": "yarn build:workspace && next build",
+6
View File
@@ -1,3 +1,9 @@
{
"git": {
"deploymentEnabled": {
"master": true,
"**": false
}
},
"outputDirectory": "build"
}
@@ -102,6 +102,12 @@ export default function ExampleApp({
} = excalidrawLib;
const appRef = useRef<any>(null);
const [viewModeEnabled, setViewModeEnabled] = useState(false);
const [interactive, setInteractive] = useState(true);
const [interactiveLinks, setInteractiveLinks] = useState(false);
const [interactiveEmbeds, setInteractiveEmbeds] = useState(false);
const [interactiveNavigation, setInteractiveNavigation] = useState(false);
const [interactiveBrowserZoom, setInteractiveBrowserZoom] = useState(false);
const [ui, setUi] = useState(true);
const [zenModeEnabled, setZenModeEnabled] = useState(false);
const [gridModeEnabled, setGridModeEnabled] = useState(false);
const [renderScrollbars, setRenderScrollbars] = useState(false);
@@ -192,6 +198,17 @@ export default function ExampleApp({
pointersMap: Gesture["pointers"];
}) => setPointerData(payload),
viewModeEnabled,
interaction: interactive
? true
: {
enabled: {
links: interactiveLinks,
embeds: interactiveEmbeds,
navigation: interactiveNavigation,
browserZoom: interactiveBrowserZoom,
},
},
ui,
zenModeEnabled,
renderScrollbars,
gridModeEnabled,
@@ -696,6 +713,52 @@ export default function ExampleApp({
/>
View mode
</label>
<label>
<input
type="checkbox"
checked={!interactive}
onChange={() => setInteractive(!interactive)}
/>
Non-interactive
</label>
<label>
<input
type="checkbox"
checked={interactiveLinks}
onChange={() => setInteractiveLinks(!interactiveLinks)}
/>
Links when non-interactive
</label>
<label>
<input
type="checkbox"
checked={interactiveEmbeds}
onChange={() => setInteractiveEmbeds(!interactiveEmbeds)}
/>
Embeds when non-interactive
</label>
<label>
<input
type="checkbox"
checked={interactiveNavigation}
onChange={() => setInteractiveNavigation(!interactiveNavigation)}
/>
Navigation when non-interactive
</label>
<label>
<input
type="checkbox"
checked={interactiveBrowserZoom}
onChange={() =>
setInteractiveBrowserZoom(!interactiveBrowserZoom)
}
/>
Browser zoom when non-interactive
</label>
<label>
<input type="checkbox" checked={!ui} onChange={() => setUi(!ui)} />
Hide UI
</label>
<label>
<input
type="checkbox"
@@ -917,8 +980,9 @@ export default function ExampleApp({
}
const elements = excalidrawAPI.getSceneElements();
excalidrawAPI.scrollToContent(elements[0], {
fitToViewport: true,
excalidrawAPI.setViewport({
target: elements[0],
fit: "contain",
});
}}
>
@@ -932,34 +996,14 @@ export default function ExampleApp({
}
const elements = excalidrawAPI.getSceneElements();
excalidrawAPI.scrollToContent(elements[0], {
fitToContent: true,
});
excalidrawAPI.scrollToContent(elements[0], {
fitToContent: true,
excalidrawAPI.setViewport({
target: elements[0],
fit: "scale-down",
});
}}
>
Fit to content, first element
</button>
<button
type="button"
onClick={() => {
if (!excalidrawAPI) {
return;
}
const elements = excalidrawAPI.getSceneElements();
excalidrawAPI.scrollToContent(elements[0], {
fitToContent: true,
});
excalidrawAPI.scrollToContent(elements[0]);
}}
>
Scroll to first element, no fitToContent, no fitToViewport
</button>
<div className="export export-canvas">
<img src={canvasUrl} alt="" />
</div>
@@ -12,10 +12,10 @@ const MobileFooter = ({
excalidrawAPI: ExcalidrawImperativeAPI;
excalidrawLib: typeof TExcalidraw;
}) => {
const { useDevice, Footer } = excalidrawLib;
const { useEditorInterface, Footer } = excalidrawLib;
const device = useDevice();
if (device.editor.isMobile) {
const editorInterface = useEditorInterface();
if (editorInterface.formFactor === "phone") {
return (
<Footer>
<CustomFooter
@@ -1,4 +1,4 @@
import type { ExcalidrawElementSkeleton } from "@excalidraw/excalidraw/data/transform";
import type { ExcalidrawElementSkeleton } from "@excalidraw/excalidraw/element/transform";
import type { FileId } from "@excalidraw/excalidraw/element/types";
const elements: ExcalidrawElementSkeleton[] = [
+6 -6
View File
@@ -3,20 +3,20 @@
"version": "1.0.0",
"private": true,
"dependencies": {
"react": "19.0.0",
"react-dom": "19.0.0",
"@excalidraw/excalidraw": "*",
"browser-fs-access": "0.29.1"
"browser-fs-access": "0.38.0",
"react": "19.0.0",
"react-dom": "19.0.0"
},
"devDependencies": {
"vite": "5.0.12",
"typescript": "^5"
"typescript": "^5",
"vite": "5.0.12"
},
"scripts": {
"start": "vite",
"build": "vite build",
"preview": "vite preview --port 5002",
"build:preview": "yarn build && yarn preview",
"build:package": "yarn workspace @excalidraw/excalidraw run build:esm"
"build:packages": "yarn --cwd ../../ build:packages"
}
}
-36
View File
@@ -4,8 +4,6 @@ import { unstable_batchedUpdates } from "react-dom";
type FILE_EXTENSION = Exclude<keyof typeof MIME_TYPES, "binary">;
const INPUT_CHANGE_INTERVAL_MS = 500;
export type ResolvablePromise<T> = Promise<T> & {
resolve: [T] extends [undefined] ? (value?: T) => void : (value: T) => void;
reject: (error: Error) => void;
@@ -54,40 +52,6 @@ export const fileOpen = <M extends boolean | undefined = false>(opts: {
extensions,
mimeTypes,
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>;
};
+7 -1
View File
@@ -1,5 +1,11 @@
{
"git": {
"deploymentEnabled": {
"master": true,
"**": false
}
},
"outputDirectory": "dist",
"installCommand": "yarn install",
"buildCommand": "yarn build:package && yarn build"
"buildCommand": "yarn build:packages && yarn build"
}
+205 -49
View File
@@ -4,6 +4,9 @@ import {
TTDDialogTrigger,
CaptureUpdateAction,
reconcileElements,
useEditorInterface,
ExcalidrawAPIProvider,
useExcalidrawAPI,
} from "@excalidraw/excalidraw";
import { trackEvent } from "@excalidraw/excalidraw/analytics";
import { getDefaultAppState } from "@excalidraw/excalidraw/appState";
@@ -19,8 +22,6 @@ import Trans from "@excalidraw/excalidraw/components/Trans";
import {
APP_NAME,
EVENT,
THEME,
TITLE_TIMEOUT,
VERSION_TIMEOUT,
debounce,
getVersion,
@@ -32,9 +33,8 @@ import {
isDevEnv,
} from "@excalidraw/common";
import polyfill from "@excalidraw/excalidraw/polyfill";
import { useCallback, useEffect, useRef, useState } from "react";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { loadFromBlob } from "@excalidraw/excalidraw/data/blob";
import { useCallbackRefState } from "@excalidraw/excalidraw/hooks/useCallbackRefState";
import { t } from "@excalidraw/excalidraw/i18n";
import {
@@ -48,7 +48,11 @@ import {
youtubeIcon,
} from "@excalidraw/excalidraw/components/icons";
import { isElementLink } from "@excalidraw/element";
import { restore, restoreAppState } from "@excalidraw/excalidraw/data/restore";
import {
bumpElementVersions,
restoreAppState,
restoreElements,
} from "@excalidraw/excalidraw/data/restore";
import { newElementWith } from "@excalidraw/element";
import { isInitializedImageElement } from "@excalidraw/element";
import clsx from "clsx";
@@ -70,6 +74,7 @@ import type {
BinaryFiles,
ExcalidrawInitialDataState,
UIAppState,
ExcalidrawProps,
} from "@excalidraw/excalidraw/types";
import type { ResolutionType } from "@excalidraw/common/utility-types";
import type { ResolvablePromise } from "@excalidraw/common/utils";
@@ -92,6 +97,7 @@ import Collab, {
collabAPIAtom,
isCollaboratingAtom,
isOfflineAtom,
userToFollowAtom,
} from "./collab/Collab";
import { AppFooter } from "./components/AppFooter";
import { AppMainMenu } from "./components/AppMainMenu";
@@ -105,11 +111,12 @@ import { TopErrorBoundary } from "./components/TopErrorBoundary";
import {
exportToBackend,
getCollaborationLinkData,
importFromBackend,
isCollaborationLink,
loadScene,
} from "./data";
import { updateStaleImageStatuses } from "./data/FileManager";
import { FileStatusStore } from "./data/fileStatusStore";
import {
importFromLocalStorage,
importUsernameFromLocalStorage,
@@ -120,6 +127,7 @@ import {
LibraryIndexedDBAdapter,
LibraryLocalStorageMigrationAdapter,
LocalData,
localStorageQuotaExceededAtom,
} from "./data/LocalData";
import { isBrowserStorageStateNewer } from "./data/tabSync";
import { ShareDialog, shareDialogStateAtom } from "./share/ShareDialog";
@@ -132,11 +140,15 @@ import DebugCanvas, {
isVisualDebuggerEnabled,
loadSavedDebugState,
} from "./components/DebugCanvas";
import { useSimulatedCollaborators } from "./debugCollaborators";
import { AIComponents } from "./components/AI";
import { ExcalidrawPlusIframeExport } from "./ExcalidrawPlusIframeExport";
import "./index.scss";
import { ExcalidrawPlusPromoBanner } from "./components/ExcalidrawPlusPromoBanner";
import { AppSidebar } from "./components/AppSidebar";
import type { CollabAPI } from "./collab/Collab";
polyfill();
@@ -220,9 +232,20 @@ const initializeScene = async (opts: {
const localDataState = importFromLocalStorage();
let scene: RestoredDataState & {
let scene: Omit<
RestoredDataState,
// we're not storing files in the scene database/localStorage, and instead
// fetch them async from a different store
"files"
> & {
scrollToContent?: boolean;
} = await loadScene(null, null, localDataState);
} = {
elements: restoreElements(localDataState?.elements, null, {
repairBindings: true,
deleteInvisibleElements: true,
}),
appState: restoreAppState(localDataState?.appState, null),
};
let roomLinkData = getCollaborationLinkData(window.location.href);
const isExternalScene = !!(id || jsonBackendMatch || roomLinkData);
@@ -236,11 +259,26 @@ const initializeScene = async (opts: {
(await openConfirmModal(shareableLinkConfirmDialog))
) {
if (jsonBackendMatch) {
scene = await loadScene(
const imported = await importFromBackend(
jsonBackendMatch[1],
jsonBackendMatch[2],
localDataState,
);
scene = {
elements: bumpElementVersions(
restoreElements(imported.elements, null, {
repairBindings: true,
deleteInvisibleElements: true,
}),
localDataState?.elements,
),
appState: restoreAppState(
imported.appState,
// local appState when importing from backend to ensure we restore
// localStorage user settings which we do not persist on server.
localDataState?.appState,
),
};
}
scene.scrollToContent = true;
if (!roomLinkData) {
@@ -335,6 +373,8 @@ const initializeScene = async (opts: {
};
const ExcalidrawWrapper = () => {
const excalidrawAPI = useExcalidrawAPI();
const [errorMessage, setErrorMessage] = useState("");
const isCollabDisabled = isRunningInIframe();
@@ -342,6 +382,8 @@ const ExcalidrawWrapper = () => {
const [langCode, setLangCode] = useAppLangCode();
const editorInterface = useEditorInterface();
// initial state
// ---------------------------------------------------------------------------
@@ -363,15 +405,43 @@ const ExcalidrawWrapper = () => {
}, VERSION_TIMEOUT);
}, []);
const [excalidrawAPI, excalidrawRefCallback] =
useCallbackRefState<ExcalidrawImperativeAPI>();
const [, setShareDialogState] = useAtom(shareDialogStateAtom);
const [collabAPI] = useAtom(collabAPIAtom);
const [isCollaborating] = useAtomWithInitialValue(isCollaboratingAtom, () => {
return isCollaborationLink(window.location.href);
});
const collabError = useAtomValue(collabErrorIndicatorAtom);
const userToFollow = useAtomValue(userToFollowAtom);
const viewportStatusFrame = useMemo(
() =>
userToFollow
? {
border: "var(--color-primary-hover)",
label: {
label: (
<>
Following{" "}
<span
style={{
display: "block",
whiteSpace: "nowrap",
overflow: "hidden",
textOverflow: "ellipsis",
maxWidth: 100,
}}
title={userToFollow.username}
>
{userToFollow.username}
</span>
</>
),
onClose: () => collabAPI?.setUserToFollow(null),
},
}
: null,
[userToFollow, collabAPI],
);
useHandleLibrary({
excalidrawAPI,
@@ -397,18 +467,20 @@ const ExcalidrawWrapper = () => {
}
}, [excalidrawAPI]);
useEffect(() => {
if (!excalidrawAPI || (!isCollabDisabled && !collabAPI)) {
return;
}
// ?collaborators=<N> — populate the canvas with N static fake
// collaborators for exercising avatar/UserList UI without a real
// collab room
useSimulatedCollaborators(excalidrawAPI);
const loadImages = (
data: ResolutionType<typeof initializeScene>,
isInitialLoad = false,
) => {
if (!data.scene) {
// ---------------------------------------------------------------------------
// Hoisted loadImages
// ---------------------------------------------------------------------------
const loadImages = useCallback(
(data: ResolutionType<typeof initializeScene>, isInitialLoad = false) => {
if (!data.scene || !excalidrawAPI) {
return;
}
if (collabAPI?.isCollaborating()) {
if (data.scene.elements) {
collabAPI
@@ -435,6 +507,12 @@ const ExcalidrawWrapper = () => {
}, [] as FileId[]) || [];
if (data.isExternalScene) {
if (fileIds.length) {
// Direct Firebase call (not through FileManager), so track manually
FileStatusStore.updateStatuses(
fileIds.map((id) => [id, "loading"]),
);
}
loadFilesFromFirebase(
`${FIREBASE_STORAGE_PREFIXES.shareLinkFiles}/${data.id}`,
data.key,
@@ -446,12 +524,18 @@ const ExcalidrawWrapper = () => {
erroredFiles,
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) {
if (fileIds.length) {
LocalData.fileStorage
.getFiles(fileIds)
.then(({ loadedFiles, erroredFiles }) => {
.then(async ({ loadedFiles, erroredFiles }) => {
if (loadedFiles.length) {
excalidrawAPI.addFiles(loadedFiles);
}
@@ -464,10 +548,19 @@ const ExcalidrawWrapper = () => {
}
// on fresh load, clear unused files from IDB (from previous
// session)
LocalData.fileStorage.clearObsoleteFiles({ currentFileIds: fileIds });
LocalData.fileStorage.clearObsoleteFiles({
currentFileIds: fileIds,
});
}
}
};
},
[collabAPI, excalidrawAPI],
);
useEffect(() => {
if (!excalidrawAPI || (!isCollabDisabled && !collabAPI)) {
return;
}
initializeScene({ collabAPI, excalidrawAPI }).then(async (data) => {
loadImages(data, /* isInitialLoad */ true);
@@ -490,8 +583,10 @@ const ExcalidrawWrapper = () => {
loadImages(data);
if (data.scene) {
excalidrawAPI.updateScene({
...data.scene,
...restore(data.scene, null, null, { repairBindings: true }),
elements: restoreElements(data.scene.elements, null, {
repairBindings: true,
}),
appState: restoreAppState(data.scene.appState, null),
captureUpdate: CaptureUpdateAction.IMMEDIATELY,
});
}
@@ -499,11 +594,6 @@ const ExcalidrawWrapper = () => {
}
};
const titleTimeout = setTimeout(
() => (document.title = APP_NAME),
TITLE_TIMEOUT,
);
const syncData = debounce(() => {
if (isTestEnv()) {
return;
@@ -594,9 +684,8 @@ const ExcalidrawWrapper = () => {
visibilityChange,
false,
);
clearTimeout(titleTimeout);
};
}, [isCollabDisabled, collabAPI, excalidrawAPI, setLangCode]);
}, [isCollabDisabled, collabAPI, excalidrawAPI, setLangCode, loadImages]);
useEffect(() => {
const unloadHandler = (event: BeforeUnloadEvent) => {
@@ -669,8 +758,8 @@ const ExcalidrawWrapper = () => {
debugRenderer(
debugCanvasRef.current,
appState,
elements,
window.devicePixelRatio,
() => forceRefresh((prev) => !prev),
);
}
};
@@ -734,11 +823,63 @@ const ExcalidrawWrapper = () => {
const isOffline = useAtomValue(isOfflineAtom);
const localStorageQuotaExceeded = useAtomValue(localStorageQuotaExceededAtom);
const onCollabDialogOpen = useCallback(
() => setShareDialogState({ isOpen: true, type: "collaborationOnly" }),
[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
// cases where it still happens, and while we disallow self-embedding
// by not whitelisting our own origin, this serves as an additional guard
@@ -805,8 +946,10 @@ const ExcalidrawWrapper = () => {
})}
>
<Excalidraw
excalidrawAPI={excalidrawRefCallback}
viewportStatusFrame={viewportStatusFrame}
userToFollow={userToFollow}
onChange={onChange}
onExport={onExport}
initialData={initialStatePromiseRef.current.promise}
isCollaborating={isCollaborating}
onPointerUpdate={collabAPI?.onPointerUpdate}
@@ -848,18 +991,27 @@ const ExcalidrawWrapper = () => {
handleKeyboardGlobally={true}
autoFocus={true}
theme={editorTheme}
onThemeChange={setAppTheme}
renderTopRightUI={(isMobile) => {
if (isMobile || !collabAPI || isCollabDisabled) {
return null;
}
return (
<div className="top-right-ui">
<div className="excalidraw-ui-top-right">
{excalidrawAPI?.getEditorInterface().formFactor === "desktop" && (
<ExcalidrawPlusPromoBanner
isSignedIn={isExcalidrawPlusSignedUser}
/>
)}
{collabError.message && <CollabError collabError={collabError} />}
<LiveCollaborationTrigger
isCollaborating={isCollaborating}
onSelect={() =>
setShareDialogState({ isOpen: true, type: "share" })
}
editorInterface={editorInterface}
/>
</div>
);
@@ -867,7 +1019,11 @@ const ExcalidrawWrapper = () => {
onLinkOpen={(element, event) => {
if (element.link && isElementLink(element.link)) {
event.preventDefault();
excalidrawAPI?.scrollToContent(element.link, { animate: true });
excalidrawAPI?.setViewport({
target: element.link,
fit: "scale-down",
animation: true,
});
}
}}
>
@@ -876,7 +1032,6 @@ const ExcalidrawWrapper = () => {
isCollaborating={isCollaborating}
isCollabEnabled={!isCollabDisabled}
theme={appTheme}
setTheme={(theme) => setAppTheme(theme)}
refresh={() => forceRefresh((prev) => !prev)}
/>
<AppWelcomeScreen
@@ -908,10 +1063,15 @@ const ExcalidrawWrapper = () => {
<TTDDialogTrigger />
{isCollaborating && isOffline && (
<div className="collab-offline-warning">
<div className="alertalert--warning">
{t("alerts.collabOfflineWarning")}
</div>
)}
{localStorageQuotaExceeded && (
<div className="alert alert--danger">
{t("alerts.localStorageQuotaExceeded")}
</div>
)}
{latestShareableLink && (
<ShareableLinkDialog
link={latestShareableLink}
@@ -940,6 +1100,8 @@ const ExcalidrawWrapper = () => {
}}
/>
<AppSidebar />
{errorMessage && (
<ErrorDialog onClose={() => setErrorMessage("")}>
{errorMessage}
@@ -1110,14 +1272,6 @@ const ExcalidrawWrapper = () => {
}
},
},
{
...CommandPalette.defaultItems.toggleTheme,
perform: () => {
setAppTheme(
editorTheme === THEME.DARK ? THEME.LIGHT : THEME.DARK,
);
},
},
{
label: t("labels.installPWA"),
category: DEFAULT_CATEGORIES.app,
@@ -1157,7 +1311,9 @@ const ExcalidrawApp = () => {
return (
<TopErrorBoundary>
<Provider store={appJotaiStore}>
<ExcalidrawWrapper />
<ExcalidrawAPIProvider>
<ExcalidrawWrapper />
</ExcalidrawAPIProvider>
</Provider>
</TopErrorBoundary>
);
+3 -1
View File
@@ -8,7 +8,8 @@ export const SYNC_BROWSER_TABS_TIMEOUT = 50;
export const CURSOR_SYNC_TIMEOUT = 33; // ~30fps
export const DELETED_ELEMENT_TIMEOUT = 24 * 60 * 60 * 1000; // 1 day
export const FILE_UPLOAD_MAX_BYTES = 3 * 1024 * 1024; // 3 MiB
// should be aligned with MAX_ALLOWED_FILE_BYTES
export const FILE_UPLOAD_MAX_BYTES = 4 * 1024 * 1024; // 4 MiB
// 1 year (https://stackoverflow.com/a/25201898/927631)
export const FILE_CACHE_MAX_AGE_SEC = 31536000;
@@ -45,6 +46,7 @@ export const STORAGE_KEYS = {
VERSION_FILES: "version-files",
IDB_LIBRARY: "excalidraw-library",
IDB_TTD_CHATS: "excalidraw-ttd-chats",
// do not use apart from migrations
__LEGACY_LOCAL_STORAGE_LIBRARY: "excalidraw-library",
+104 -35
View File
@@ -6,7 +6,7 @@ import {
reconcileElements,
} from "@excalidraw/excalidraw";
import { ErrorDialog } from "@excalidraw/excalidraw/components/ErrorDialog";
import { APP_NAME, EVENT } from "@excalidraw/common";
import { APP_NAME, cloneJSON, EVENT, toBrandedType } from "@excalidraw/common";
import {
IDLE_THRESHOLD,
ACTIVE_THRESHOLD,
@@ -29,6 +29,8 @@ import { withBatchedUpdates } from "@excalidraw/excalidraw/reactUtils";
import throttle from "lodash.throttle";
import { PureComponent } from "react";
import { bumpElementVersions } from "@excalidraw/excalidraw/data/restore";
import type {
ReconciledExcalidrawElement,
RemoteExcalidrawElement,
@@ -46,6 +48,7 @@ import type {
SocketId,
Collaborator,
Gesture,
UserToFollow,
} from "@excalidraw/excalidraw/types";
import type { Mutable, ValueOf } from "@excalidraw/common/utility-types";
@@ -70,6 +73,7 @@ import {
FileManager,
updateStaleImageStatuses,
} from "../data/FileManager";
import { FileStatusStore } from "../data/fileStatusStore";
import { LocalData } from "../data/LocalData";
import {
isSavedToFirebase,
@@ -105,6 +109,7 @@ interface CollabState {
}
export const activeRoomLinkAtom = atom<string | null>(null);
export const userToFollowAtom = atom<UserToFollow | null>(null);
type CollabInstance = InstanceType<typeof Collab>;
@@ -120,6 +125,7 @@ export interface CollabAPI {
getUsername: CollabInstance["getUsername"];
getActiveRoomLink: CollabInstance["getActiveRoomLink"];
setCollabError: CollabInstance["setErrorDialog"];
setUserToFollow: CollabInstance["setUserToFollow"];
}
interface CollabProps {
@@ -136,6 +142,8 @@ class Collab extends PureComponent<CollabProps, CollabState> {
private socketInitializationTimer?: number;
private lastBroadcastedOrReceivedSceneVersion: number = -1;
private collaborators = new Map<SocketId, Collaborator>();
/** the socket ids of the users following the current user */
private followedBy = new Set<SocketId>();
constructor(props: CollabProps) {
super(props);
@@ -147,6 +155,7 @@ class Collab extends PureComponent<CollabProps, CollabState> {
};
this.portal = new Portal(this);
this.fileManager = new FileManager({
onFileStatusChange: FileStatusStore.updateStatuses.bind(FileStatusStore),
getFiles: async (fileIds) => {
const { roomId, roomKey } = this.portal;
if (!roomId || !roomKey) {
@@ -208,7 +217,9 @@ class Collab extends PureComponent<CollabProps, CollabState> {
window.addEventListener(EVENT.UNLOAD, this.onUnload);
const unsubOnUserFollow = this.excalidrawAPI.onUserFollow((payload) => {
this.portal.socket && this.portal.broadcastUserFollowed(payload);
this.setUserToFollow(
payload.action === "FOLLOW" ? payload.userToFollow : null,
);
});
const throttledRelayUserViewportBounds = throttleRAF(
this.relayVisibleSceneBounds,
@@ -234,6 +245,7 @@ class Collab extends PureComponent<CollabProps, CollabState> {
getUsername: this.getUsername,
getActiveRoomLink: this.getActiveRoomLink,
setCollabError: this.setErrorDialog,
setUserToFollow: this.setUserToFollow,
};
appJotaiStore.set(collabAPIAtom, collabAPI);
@@ -311,6 +323,7 @@ class Collab extends PureComponent<CollabProps, CollabState> {
saveCollabRoomToFirebase = async (
syncableElements: readonly SyncableExcalidrawElement[],
) => {
syncableElements = cloneJSON(syncableElements);
try {
const storedElements = await saveToFirebase(
this.portal,
@@ -401,9 +414,11 @@ class Collab extends PureComponent<CollabProps, CollabState> {
this.lastBroadcastedOrReceivedSceneVersion = -1;
this.portal.close();
this.fileManager.reset();
this.followedBy = new Set();
if (!opts?.isUnload) {
this.setIsCollaborating(false);
this.setActiveRoomLink(null);
appJotaiStore.set(userToFollowAtom, null);
this.collaborators = new Map();
this.excalidrawAPI.updateScene({
collaborators: this.collaborators,
@@ -441,7 +456,7 @@ class Collab extends PureComponent<CollabProps, CollabState> {
};
private decryptPayload = async (
iv: Uint8Array,
iv: Uint8Array<ArrayBuffer>,
encryptedData: ArrayBuffer,
decryptionKey: string,
): Promise<ValueOf<SocketUpdateDataSource>> => {
@@ -530,7 +545,10 @@ class Collab extends PureComponent<CollabProps, CollabState> {
return null;
}
if (!existingRoomLinkData) {
if (existingRoomLinkData) {
// when joining existing room, don't merge it with current scene data
this.excalidrawAPI.resetScene();
} else {
const elements = this.excalidrawAPI.getSceneElements().map((element) => {
if (isImageElement(element) && element.status === "saved") {
return newElementWith(element, { status: "pending" });
@@ -559,7 +577,7 @@ class Collab extends PureComponent<CollabProps, CollabState> {
// All socket listeners are moving to Portal
this.portal.socket.on(
"client-broadcast",
async (encryptedData: ArrayBuffer, iv: Uint8Array) => {
async (encryptedData: ArrayBuffer, iv: Uint8Array<ArrayBuffer>) => {
if (!this.portal.roomKey) {
return;
}
@@ -576,7 +594,9 @@ class Collab extends PureComponent<CollabProps, CollabState> {
case WS_SUBTYPES.INIT: {
if (!this.portal.socketInitialized) {
this.initializeRoom({ fetchScene: false });
const remoteElements = decryptedData.payload.elements;
const remoteElements = toBrandedType<
readonly RemoteExcalidrawElement[]
>(decryptedData.payload.elements);
const reconciledElements =
this._reconcileElements(remoteElements);
this.handleRemoteSceneUpdate(reconciledElements);
@@ -590,7 +610,11 @@ class Collab extends PureComponent<CollabProps, CollabState> {
}
case WS_SUBTYPES.UPDATE:
this.handleRemoteSceneUpdate(
this._reconcileElements(decryptedData.payload.elements),
this._reconcileElements(
toBrandedType<readonly RemoteExcalidrawElement[]>(
decryptedData.payload.elements,
),
),
);
break;
case WS_SUBTYPES.MOUSE_LOCATION: {
@@ -615,11 +639,11 @@ class Collab extends PureComponent<CollabProps, CollabState> {
case WS_SUBTYPES.USER_VISIBLE_SCENE_BOUNDS: {
const { sceneBounds, socketId } = decryptedData.payload;
const appState = this.excalidrawAPI.getAppState();
const userToFollow = appJotaiStore.get(userToFollowAtom);
// we're not following the user
// (shouldn't happen, but could be late message or bug upstream)
if (appState.userToFollow?.socketId !== socketId) {
if (userToFollow?.socketId !== socketId) {
console.warn(
`receiving remote client's (from ${socketId}) viewport bounds even though we're not subscribed to it!`,
);
@@ -627,19 +651,17 @@ class Collab extends PureComponent<CollabProps, CollabState> {
}
// cross-follow case, ignore updates in this case
if (
appState.userToFollow &&
appState.followedBy.has(appState.userToFollow.socketId)
) {
if (this.followedBy.has(userToFollow.socketId)) {
return;
}
const appState = this.excalidrawAPI.getAppState();
this.excalidrawAPI.updateScene({
appState: zoomToFitBounds({
appState,
bounds: sceneBounds,
fitToViewport: true,
viewportZoomFactor: 1,
fit: "contain",
}).appState,
});
@@ -676,9 +698,7 @@ class Collab extends PureComponent<CollabProps, CollabState> {
this.portal.socket.on(
WS_EVENTS.USER_FOLLOW_ROOM_CHANGE,
(followedBy: SocketId[]) => {
this.excalidrawAPI.updateScene({
appState: { followedBy: new Set(followedBy) },
});
this.followedBy = new Set(followedBy);
this.relayVisibleSceneBounds({ force: true });
},
@@ -739,17 +759,28 @@ class Collab extends PureComponent<CollabProps, CollabState> {
};
private _reconcileElements = (
remoteElements: readonly ExcalidrawElement[],
remoteElements: readonly RemoteExcalidrawElement[],
): ReconciledExcalidrawElement[] => {
const localElements = this.getSceneElementsIncludingDeleted();
const appState = this.excalidrawAPI.getAppState();
const restoredRemoteElements = restoreElements(remoteElements, null);
const reconciledElements = reconcileElements(
localElements,
restoredRemoteElements as RemoteExcalidrawElement[],
const existingElements = this.getSceneElementsIncludingDeleted();
// NOTE ideally we restore _after_ reconciliation but we can't do that
// as we'd regenerate even elements such as appState.newElement which would
// break the state
remoteElements = restoreElements(remoteElements, existingElements);
let reconciledElements = reconcileElements(
existingElements,
remoteElements,
appState,
);
reconciledElements = bumpElementVersions(
reconciledElements,
existingElements,
);
// Avoid broadcasting to the rest of the collaborators the scene
// we just received!
// Note: this needs to be set before updating the scene as it
@@ -845,26 +876,38 @@ class Collab extends PureComponent<CollabProps, CollabState> {
const collaborators: InstanceType<typeof Collab>["collaborators"] =
new Map();
for (const socketId of sockets) {
const isCurrentUser = socketId === this.portal.socket?.id;
collaborators.set(
socketId,
Object.assign({}, this.collaborators.get(socketId), {
isCurrentUser: socketId === this.portal.socket?.id,
}),
Object.assign(
// we never receive our own broadcasts, so we need to seed
// our own collaborator entry with the local username
isCurrentUser ? { username: this.state.username } : {},
this.collaborators.get(socketId),
{ isCurrentUser },
),
);
}
this.collaborators = collaborators;
this.excalidrawAPI.updateScene({ collaborators });
// unfollow if the followed user left the room
const userToFollow = appJotaiStore.get(userToFollowAtom);
if (userToFollow && !collaborators.has(userToFollow.socketId)) {
this.setUserToFollow(null);
}
}
updateCollaborator = (socketId: SocketId, updates: Partial<Collaborator>) => {
const isCurrentUser = socketId === this.portal.socket?.id;
const collaborators = new Map(this.collaborators);
const user: Mutable<Collaborator> = Object.assign(
{},
// we never receive our own broadcasts, so we need to seed
// our own collaborator entry with the local username
isCurrentUser ? { username: this.state.username } : {},
collaborators.get(socketId),
updates,
{
isCurrentUser: socketId === this.portal.socket?.id,
},
{ isCurrentUser },
);
collaborators.set(socketId, user);
this.collaborators = collaborators;
@@ -900,12 +943,10 @@ class Collab extends PureComponent<CollabProps, CollabState> {
);
relayVisibleSceneBounds = (props?: { force: boolean }) => {
const appState = this.excalidrawAPI.getAppState();
if (this.portal.socket && (appState.followedBy.size > 0 || props?.force)) {
if (this.portal.socket && (this.followedBy.size > 0 || props?.force)) {
this.portal.broadcastVisibleSceneBounds(
{
sceneBounds: getVisibleSceneBounds(appState),
sceneBounds: getVisibleSceneBounds(this.excalidrawAPI.getAppState()),
},
`follow@${this.portal.socket.id}`,
);
@@ -960,9 +1001,37 @@ class Collab extends PureComponent<CollabProps, CollabState> {
{ leading: false },
);
setUserToFollow = (userToFollow: UserToFollow | null) => {
const prev = appJotaiStore.get(userToFollowAtom) ?? null;
if (prev?.socketId !== userToFollow?.socketId && this.portal.socket) {
// leave the previous user's follow room before joining the next one
if (prev) {
this.portal.broadcastUserFollowed({
userToFollow: prev,
action: "UNFOLLOW",
});
}
if (userToFollow) {
this.portal.broadcastUserFollowed({
userToFollow,
action: "FOLLOW",
});
}
}
appJotaiStore.set(userToFollowAtom, userToFollow);
};
setUsername = (username: string) => {
this.setState({ username });
saveUsernameToLocalStorage(username);
// keep our own collaborator entry in sync
const socketId = this.portal.socket?.id as SocketId | undefined;
if (socketId && this.collaborators.has(socketId)) {
this.updateCollaborator(socketId, { username });
}
};
getUsername = () => this.state.username;
+79 -65
View File
@@ -1,15 +1,21 @@
import {
DiagramToCodePlugin,
exportToBlob,
getNonDeletedElements,
getTextFromElements,
MIME_TYPES,
parseSSEStream,
TTDDialog,
TTDStreamFetch,
} from "@excalidraw/excalidraw";
import { getDataURL } from "@excalidraw/excalidraw/data/blob";
import { safelyParseJSON } from "@excalidraw/common";
import type { StreamChunk } from "@excalidraw/excalidraw";
import type { ExcalidrawImperativeAPI } from "@excalidraw/excalidraw/types";
import { TTDIndexedDBAdapter } from "../data/TTDStorage";
export const AIComponents = ({
excalidrawAPI,
}: {
@@ -18,11 +24,18 @@ export const AIComponents = ({
return (
<>
<DiagramToCodePlugin
generate={async ({ frame, children }) => {
generate={async ({ frame, children, onPartial }) => {
const appState = excalidrawAPI.getAppState();
// SAFETY: This should never happen, but log it just in case
if (children.some((el) => el.isDeleted)) {
console.error(
"[NONDELETED][INVARIANT] Generated children elements should not be `isDeleted: true`",
);
}
const blob = await exportToBlob({
elements: children,
elements: getNonDeletedElements(children),
appState: {
...appState,
exportBackground: true,
@@ -40,11 +53,11 @@ export const AIComponents = ({
const response = await fetch(
`${
import.meta.env.VITE_APP_AI_BACKEND
}/v1/ai/diagram-to-code/generate`,
}/v1/ai/diagram-to-code/generate-streaming`,
{
method: "POST",
headers: {
Accept: "application/json",
Accept: "text/event-stream",
"Content-Type": "application/json",
},
body: JSON.stringify({
@@ -83,77 +96,78 @@ export const AIComponents = ({
throw new Error(errorJSON.message || text);
}
try {
const { html } = await response.json();
const reader = response.body?.getReader();
if (!html) {
throw new Error("Generation failed (invalid response)");
}
return {
html,
};
} catch (error: any) {
if (!reader) {
throw new Error("Generation failed (invalid response)");
}
let html = "";
let streamError: Error | null = null;
for await (const data of parseSSEStream(reader)) {
if (data === "[DONE]") {
break;
}
const chunk = safelyParseJSON(data) as StreamChunk | null;
if (!chunk) {
continue;
}
switch (chunk.type) {
case "content": {
if (chunk.delta) {
html += chunk.delta;
onPartial?.(html);
}
break;
}
case "error": {
streamError = new Error(
chunk.error.message || "Generation failed",
);
break;
}
case "done": {
break;
}
}
}
if (streamError) {
throw streamError;
}
if (!html.trim()) {
throw new Error("Generation failed (invalid response)");
}
return {
html,
};
}}
/>
<TTDDialog
onTextSubmit={async (input) => {
try {
const response = await fetch(
`${
import.meta.env.VITE_APP_AI_BACKEND
}/v1/ai/text-to-diagram/generate`,
{
method: "POST",
headers: {
Accept: "application/json",
"Content-Type": "application/json",
},
body: JSON.stringify({ prompt: input }),
},
);
onTextSubmit={async (props) => {
const { onChunk, onStreamCreated, signal, messages } = props;
const rateLimit = response.headers.has("X-Ratelimit-Limit")
? parseInt(response.headers.get("X-Ratelimit-Limit") || "0", 10)
: undefined;
const result = await TTDStreamFetch({
url: `${
import.meta.env.VITE_APP_AI_BACKEND
}/v1/ai/text-to-diagram/chat-streaming`,
messages,
onChunk,
onStreamCreated,
extractRateLimits: true,
signal,
});
const rateLimitRemaining = response.headers.has(
"X-Ratelimit-Remaining",
)
? parseInt(
response.headers.get("X-Ratelimit-Remaining") || "0",
10,
)
: undefined;
const json = await response.json();
if (!response.ok) {
if (response.status === 429) {
return {
rateLimit,
rateLimitRemaining,
error: new Error(
"Too many requests today, please try again tomorrow!",
),
};
}
throw new Error(json.message || "Generation failed...");
}
const generatedResponse = json.generatedResponse;
if (!generatedResponse) {
throw new Error("Generation failed...");
}
return { generatedResponse, rateLimit, rateLimitRemaining };
} catch (err: any) {
throw new Error("Request failed");
}
return result;
}}
persistenceAdapter={TTDIndexedDBAdapter}
/>
</>
);
+1 -6
View File
@@ -5,7 +5,6 @@ import { isExcalidrawPlusSignedUser } from "../app_constants";
import { DebugFooter, isVisualDebuggerEnabled } from "./DebugCanvas";
import { EncryptedIcon } from "./EncryptedIcon";
import { ExcalidrawPlusAppLink } from "./ExcalidrawPlusAppLink";
export const AppFooter = React.memo(
({ onChange }: { onChange: () => void }) => {
@@ -19,11 +18,7 @@ export const AppFooter = React.memo(
}}
>
{isVisualDebuggerEnabled() && <DebugFooter onChange={onChange} />}
{isExcalidrawPlusSignedUser ? (
<ExcalidrawPlusAppLink />
) : (
<EncryptedIcon />
)}
{!isExcalidrawPlusSignedUser && <EncryptedIcon />}
</div>
</Footer>
);
+6 -8
View File
@@ -3,6 +3,7 @@ import {
ExcalLogo,
eyeIcon,
} from "@excalidraw/excalidraw/components/icons";
import { useI18n } from "@excalidraw/excalidraw/i18n";
import { MainMenu } from "@excalidraw/excalidraw/index";
import React from "react";
@@ -20,9 +21,9 @@ export const AppMainMenu: React.FC<{
isCollaborating: boolean;
isCollabEnabled: boolean;
theme: Theme | "system";
setTheme: (theme: Theme | "system") => void;
refresh: () => void;
}> = React.memo((props) => {
const { t } = useI18n();
return (
<MainMenu>
<MainMenu.DefaultItems.LoadScene />
@@ -57,12 +58,12 @@ export const AppMainMenu: React.FC<{
}?utm_source=signin&utm_medium=app&utm_content=hamburger`}
className="highlighted"
>
{isExcalidrawPlusSignedUser ? "Sign in" : "Sign up"}
{isExcalidrawPlusSignedUser ? t("labels.signIn") : t("labels.signUp")}
</MainMenu.ItemLink>
{isDevEnv() && (
<MainMenu.Item
icon={eyeIcon}
onClick={() => {
onSelect={() => {
if (window.visualDebug) {
delete window.visualDebug;
saveDebugState({ enabled: false });
@@ -77,11 +78,8 @@ export const AppMainMenu: React.FC<{
</MainMenu.Item>
)}
<MainMenu.Separator />
<MainMenu.DefaultItems.ToggleTheme
allowSystemTheme
theme={props.theme}
onSelect={props.setTheme}
/>
<MainMenu.DefaultItems.Preferences />
<MainMenu.DefaultItems.ToggleTheme allowSystemTheme theme={props.theme} />
<MainMenu.ItemCustom>
<LanguageList style={{ width: "100%" }} />
</MainMenu.ItemCustom>
+92
View File
@@ -0,0 +1,92 @@
.excalidraw {
.app-sidebar-promo-container {
padding: 0.75rem;
display: flex;
flex-direction: column;
text-align: center;
gap: 1.5rem;
flex: 1 1 auto;
.link-button {
margin: 0 auto !important;
width: 100%;
button {
width: 100%;
}
}
}
.app-sidebar-promo-image {
margin: 1rem 0;
height: 295px;
background-size: contain;
background-position: center;
background-repeat: no-repeat;
background-size: calc(
var(--right-sidebar-width, 100%) - var(--space-factor) * 2
);
background-image: radial-gradient(
circle,
transparent 0%,
var(--sidebar-bg-color) 100%
),
var(--image-source);
display: flex;
}
.app-sidebar-promo-text {
padding: 0 2rem;
}
.link-button {
margin: 0 auto;
}
.app-sidebar-promo-text {
padding: 0 0.5rem;
font-size: 0.875rem;
font-weight: 700;
line-height: 1.4;
}
.app-sidebar-promo-illustration {
position: relative;
min-height: 7rem;
display: flex;
align-items: flex-start;
justify-content: center;
padding-top: 0.125rem;
.app-sidebar-promo-heart {
width: 46px;
transform: translateX(-14px);
height: auto;
}
.app-sidebar-promo-trial-note {
position: absolute;
top: 2.75rem;
left: 50%;
transform: translateX(-56%) rotate(-7deg);
font-size: 1rem;
line-height: 1.15;
color: var(--color-primary);
white-space: nowrap;
}
.app-sidebar-promo-trial-arrow {
position: absolute;
top: 3.35rem;
left: 55%;
width: 4.5rem;
height: 3rem;
transform: translateX(1.75rem) rotate(10deg);
color: var(--color-primary);
overflow: visible;
}
}
}
+131
View File
@@ -0,0 +1,131 @@
import { DefaultSidebar, Sidebar, THEME } from "@excalidraw/excalidraw";
import {
messageCircleIcon,
presentationIcon,
} from "@excalidraw/excalidraw/components/icons";
import { LinkButton } from "@excalidraw/excalidraw/components/LinkButton";
import { useUIAppState } from "@excalidraw/excalidraw/context/ui-appState";
import "./AppSidebar.scss";
type SidebarPromoCopyProps = {
text: string;
};
const SidebarPromoCopy = (props: SidebarPromoCopyProps) => {
return (
<div className="app-sidebar-promo-copy">
<div className="app-sidebar-promo-illustration" aria-hidden="true">
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 300 250"
className="app-sidebar-promo-heart"
>
<path
d="M 145 75
C 110 35, 60 55, 65 120
C 70 180, 140 190, 215 200
C 225 180, 260 110, 235 55
C 210 -5, 140 20, 160 105"
fill="none"
stroke="#D06B64"
strokeWidth="16"
strokeLinecap="round"
/>
</svg>
<div className="app-sidebar-promo-trial-note excalifont">
14 days of
<br />
free trial
</div>
<svg
className="app-sidebar-promo-trial-arrow"
viewBox="0 0 72 48"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M5 6C23 1 50 8 48 32"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
/>
<path
d="M42 26L48 32L54 26"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
</div>
<div className="app-sidebar-promo-text">{props.text}</div>
</div>
);
};
export const AppSidebar = () => {
const { theme, openSidebar } = useUIAppState();
return (
<DefaultSidebar>
<DefaultSidebar.TabTriggers>
<Sidebar.TabTrigger
tab="comments"
style={{ opacity: openSidebar?.tab === "comments" ? 1 : 0.4 }}
>
{messageCircleIcon}
</Sidebar.TabTrigger>
<Sidebar.TabTrigger
tab="presentation"
style={{ opacity: openSidebar?.tab === "presentation" ? 1 : 0.4 }}
>
{presentationIcon}
</Sidebar.TabTrigger>
</DefaultSidebar.TabTriggers>
<Sidebar.Tab tab="comments">
<div className="app-sidebar-promo-container">
<div
className="app-sidebar-promo-image"
style={{
["--image-source" as any]: `url(/sidebar-comments-promo-${
theme === THEME.DARK ? "dark" : "light"
}.jpg)`,
opacity: 0.9,
}}
/>
<SidebarPromoCopy text="Make comments with Excalidraw+" />
<LinkButton
href={`${
import.meta.env.VITE_APP_PLUS_LP
}/plus?utm_source=excalidraw&utm_medium=app&utm_content=comments_promo#excalidraw-redirect`}
>
Sign up now
</LinkButton>
</div>
</Sidebar.Tab>
<Sidebar.Tab tab="presentation" className="px-3">
<div className="app-sidebar-promo-container">
<div
className="app-sidebar-promo-image"
style={{
["--image-source" as any]: `url(/sidebar-presentation-promo-${
theme === THEME.DARK ? "dark" : "light"
}.jpg)`,
opacity: 0.7,
}}
/>
<SidebarPromoCopy text="Create presentation with Excalidraw+" />
<LinkButton
href={`${
import.meta.env.VITE_APP_PLUS_LP
}/plus?utm_source=excalidraw&utm_medium=app&utm_content=presentations_promo#excalidraw-redirect`}
>
Sign up now
</LinkButton>
</div>
</Sidebar.Tab>
</DefaultSidebar>
);
};
+10 -2
View File
@@ -33,7 +33,15 @@ export const AppWelcomeScreen: React.FC<{
return bit;
});
} else {
headingContent = t("welcomeScreen.app.center_heading");
headingContent = (
<>
{t("welcomeScreen.app.center_heading")}
<br />
{t("welcomeScreen.app.center_heading_line2")}
<br />
{t("welcomeScreen.app.center_heading_line3")}
</>
);
}
return (
@@ -64,7 +72,7 @@ export const AppWelcomeScreen: React.FC<{
shortcut={null}
icon={loginIcon}
>
Sign up
{t("labels.signUp")}
</WelcomeScreen.Center.MenuItemLink>
)}
</WelcomeScreen.Center.Menu>
+256 -38
View File
@@ -8,8 +8,14 @@ import {
getNormalizedCanvasDimensions,
} from "@excalidraw/excalidraw/renderer/helpers";
import { type AppState } from "@excalidraw/excalidraw/types";
import { throttleRAF } from "@excalidraw/common";
import { useCallback, useImperativeHandle, useRef } from "react";
import { arrayToMap, throttleRAF } from "@excalidraw/common";
import { useCallback } from "react";
import {
getGlobalFixedPointForBindableElement,
isArrowElement,
isBindableElement,
} from "@excalidraw/element";
import {
isLineSegment,
@@ -18,9 +24,20 @@ import {
} from "@excalidraw/math";
import { isCurve } from "@excalidraw/math/curve";
import type { DebugElement } from "@excalidraw/excalidraw/visualdebug";
import React from "react";
import type { Curve } from "@excalidraw/math";
import type {
DebugElement,
DebugPolygon,
} from "@excalidraw/element/visualdebug";
import type {
ElementsMap,
ExcalidrawArrowElement,
ExcalidrawBindableElement,
FixedPointBinding,
OrderedExcalidrawElement,
} from "@excalidraw/element/types";
import { STORAGE_KEYS } from "../app_constants";
@@ -61,6 +78,44 @@ const renderCubicBezier = (
context.restore();
};
const renderPolygon = (
context: CanvasRenderingContext2D,
zoom: number,
polygon: DebugPolygon,
color: string,
) => {
const { points, fill, close } = polygon;
if (points.length < 2) {
return;
}
context.save();
context.beginPath();
context.moveTo(points[0][0] * zoom, points[0][1] * zoom);
for (let i = 1; i < points.length; i += 1) {
context.lineTo(points[i][0] * zoom, points[i][1] * zoom);
}
if (close !== false) {
context.closePath();
}
if (fill) {
context.save();
context.globalAlpha = 0.15;
context.fillStyle = color;
context.fill();
context.restore();
}
context.strokeStyle = color;
context.stroke();
context.restore();
};
const isDebugPolygon = (data: DebugElement["data"]): data is DebugPolygon =>
(data as DebugPolygon).type === "polygon";
const renderOrigin = (context: CanvasRenderingContext2D, zoom: number) => {
context.strokeStyle = "#888";
context.save();
@@ -73,6 +128,176 @@ const renderOrigin = (context: CanvasRenderingContext2D, zoom: number) => {
context.save();
};
const _renderBinding = (
context: CanvasRenderingContext2D,
binding: FixedPointBinding,
elementsMap: ElementsMap,
zoom: number,
width: number,
height: number,
color: string,
) => {
if (!binding.fixedPoint) {
console.warn("Binding must have a fixedPoint");
return;
}
const bindable = elementsMap.get(
binding.elementId,
) as ExcalidrawBindableElement;
const [x, y] = getGlobalFixedPointForBindableElement(
binding.fixedPoint,
bindable,
elementsMap,
);
context.save();
context.strokeStyle = color;
context.lineWidth = 1;
context.beginPath();
context.moveTo(x * zoom, y * zoom);
context.bezierCurveTo(
x * zoom - width,
y * zoom - height,
x * zoom - width,
y * zoom + height,
x * zoom,
y * zoom,
);
context.stroke();
context.restore();
};
const _renderBindableBinding = (
binding: FixedPointBinding,
context: CanvasRenderingContext2D,
elementsMap: ElementsMap,
zoom: number,
width: number,
height: number,
color: string,
) => {
const bindable = elementsMap.get(
binding.elementId,
) as ExcalidrawBindableElement;
if (!binding.fixedPoint) {
console.warn("Binding must have a fixedPoint");
return;
}
const [x, y] = getGlobalFixedPointForBindableElement(
binding.fixedPoint,
bindable,
elementsMap,
);
context.save();
context.strokeStyle = color;
context.lineWidth = 1;
context.beginPath();
context.moveTo(x * zoom, y * zoom);
context.bezierCurveTo(
x * zoom + width,
y * zoom + height,
x * zoom + width,
y * zoom - height,
x * zoom,
y * zoom,
);
context.stroke();
context.restore();
};
const renderBindings = (
context: CanvasRenderingContext2D,
elements: readonly OrderedExcalidrawElement[],
zoom: number,
) => {
const elementsMap = arrayToMap(elements);
const dim = 16;
elements.forEach((element) => {
if (element.isDeleted) {
return;
}
if (isArrowElement(element)) {
if (element.startBinding) {
if (
!elementsMap
.get(element.startBinding.elementId)
?.boundElements?.find((e) => e.id === element.id)
) {
return;
}
_renderBinding(
context,
element.startBinding,
elementsMap,
zoom,
dim,
dim,
element.startBinding?.mode === "orbit" ? "red" : "black",
);
}
if (element.endBinding) {
if (
!elementsMap
.get(element.endBinding.elementId)
?.boundElements?.find((e) => e.id === element.id)
) {
return;
}
_renderBinding(
context,
element.endBinding,
elementsMap,
zoom,
dim,
dim,
element.endBinding?.mode === "orbit" ? "red" : "black",
);
}
}
if (isBindableElement(element) && element.boundElements?.length) {
element.boundElements.forEach((boundElement) => {
if (boundElement.type !== "arrow") {
return;
}
const arrow = elementsMap.get(
boundElement.id,
) as ExcalidrawArrowElement;
if (arrow && arrow.startBinding?.elementId === element.id) {
_renderBindableBinding(
arrow.startBinding,
context,
elementsMap,
zoom,
dim,
dim,
"green",
);
}
if (arrow && arrow.endBinding?.elementId === element.id) {
_renderBindableBinding(
arrow.endBinding,
context,
elementsMap,
zoom,
dim,
dim,
"green",
);
}
});
}
});
};
const render = (
frame: DebugElement[],
context: CanvasRenderingContext2D,
@@ -96,6 +321,9 @@ const render = (
el.color,
);
break;
case isDebugPolygon(el.data):
renderPolygon(context, appState.zoom.value, el.data, el.color);
break;
default:
throw new Error(`Unknown element type ${JSON.stringify(el)}`);
}
@@ -105,18 +333,14 @@ const render = (
const _debugRenderer = (
canvas: HTMLCanvasElement,
appState: AppState,
elements: readonly OrderedExcalidrawElement[],
scale: number,
refresh: () => void,
) => {
const [normalizedWidth, normalizedHeight] = getNormalizedCanvasDimensions(
canvas,
scale,
);
if (appState.height !== canvas.height || appState.width !== canvas.width) {
refresh();
}
const context = bootstrapCanvas({
canvas,
scale,
@@ -133,6 +357,7 @@ const _debugRenderer = (
);
renderOrigin(context, appState.zoom.value);
renderBindings(context, elements, appState.zoom.value);
if (
window.visualDebug?.currentFrame &&
@@ -184,12 +409,11 @@ export const debugRenderer = throttleRAF(
(
canvas: HTMLCanvasElement,
appState: AppState,
elements: readonly OrderedExcalidrawElement[],
scale: number,
refresh: () => void,
) => {
_debugRenderer(canvas, appState, scale, refresh);
_debugRenderer(canvas, appState, elements, scale);
},
{ trailing: true },
);
export const loadSavedDebugState = () => {
@@ -314,35 +538,29 @@ export const DebugFooter = ({ onChange }: { onChange: () => void }) => {
interface DebugCanvasProps {
appState: AppState;
scale: number;
ref?: React.Ref<HTMLCanvasElement>;
}
const DebugCanvas = ({ appState, scale, ref }: DebugCanvasProps) => {
const { width, height } = appState;
const DebugCanvas = React.forwardRef<HTMLCanvasElement, DebugCanvasProps>(
({ appState, scale }, ref) => {
const { width, height } = appState;
const canvasRef = useRef<HTMLCanvasElement>(null);
useImperativeHandle<HTMLCanvasElement | null, HTMLCanvasElement | null>(
ref,
() => canvasRef.current,
[canvasRef],
);
return (
<canvas
style={{
width,
height,
position: "absolute",
zIndex: 2,
pointerEvents: "none",
}}
width={width * scale}
height={height * scale}
ref={canvasRef}
>
Debug Canvas
</canvas>
);
};
return (
<canvas
style={{
width,
height,
position: "absolute",
zIndex: 2,
pointerEvents: "none",
}}
width={width * scale}
height={height * scale}
ref={ref}
>
Debug Canvas
</canvas>
);
},
);
export default DebugCanvas;
@@ -1,19 +0,0 @@
import { isExcalidrawPlusSignedUser } from "../app_constants";
export const ExcalidrawPlusAppLink = () => {
if (!isExcalidrawPlusSignedUser) {
return null;
}
return (
<a
href={`${
import.meta.env.VITE_APP_PLUS_APP
}?utm_source=excalidraw&utm_medium=app&utm_content=signedInUserRedirectButton#excalidraw-redirect`}
target="_blank"
rel="noopener"
className="plus-button"
>
Go to Excalidraw+
</a>
);
};
@@ -0,0 +1,22 @@
export const ExcalidrawPlusPromoBanner = ({
isSignedIn,
}: {
isSignedIn: boolean;
}) => {
return (
<a
href={
isSignedIn
? import.meta.env.VITE_APP_PLUS_APP
: `${
import.meta.env.VITE_APP_PLUS_LP
}/plus?utm_source=excalidraw&utm_medium=app&utm_content=guestBanner#excalidraw-redirect`
}
target="_blank"
rel="noopener"
className="plus-banner"
>
Excalidraw+
</a>
);
};
@@ -5,7 +5,7 @@ import { nanoid } from "nanoid";
import { trackEvent } from "@excalidraw/excalidraw/analytics";
import { Card } from "@excalidraw/excalidraw/components/Card";
import { ExcalidrawLogo } from "@excalidraw/excalidraw/components/ExcalidrawLogo";
import { ToolButton } from "@excalidraw/excalidraw/components/ToolButton";
import { IconButton } from "@excalidraw/excalidraw/components/IconButton";
import { MIME_TYPES, getFrame } from "@excalidraw/common";
import {
encryptData,
@@ -111,7 +111,7 @@ export const ExportToExcalidrawPlus: React.FC<{
<div className="Card-details">
{t("exportDialog.excalidrawplus_description")}
</div>
<ToolButton
<IconButton
className="Card-button"
type="button"
title={t("exportDialog.excalidrawplus_button")}
@@ -1,46 +0,0 @@
import { THEME } from "@excalidraw/common";
import oc from "open-color";
import React from "react";
import type { Theme } from "@excalidraw/element/types";
// https://github.com/tholman/github-corners
export const GitHubCorner = React.memo(
({ theme, dir }: { theme: Theme; dir: string }) => (
<svg
xmlns="http://www.w3.org/2000/svg"
width="40"
height="40"
viewBox="0 0 250 250"
className="rtl-mirror"
style={{
marginTop: "calc(var(--space-factor) * -1)",
[dir === "rtl" ? "marginLeft" : "marginRight"]:
"calc(var(--space-factor) * -1)",
}}
>
<a
href="https://github.com/excalidraw/excalidraw"
target="_blank"
rel="noopener noreferrer"
aria-label="GitHub repository"
>
<path
d="M0 0l115 115h15l12 27 108 108V0z"
fill={theme === THEME.LIGHT ? oc.gray[6] : oc.gray[7]}
/>
<path
className="octo-arm"
d="M128 109c-15-9-9-19-9-19 3-7 2-11 2-11-1-7 3-2 3-2 4 5 2 11 2 11-3 10 5 15 9 16"
style={{ transformOrigin: "130px 106px" }}
fill={theme === THEME.LIGHT ? oc.white : "var(--default-bg-color)"}
/>
<path
className="octo-body"
d="M115 115s4 2 5 0l14-14c3-2 6-3 8-3-8-11-15-24 2-41 5-5 10-7 16-7 1-2 3-7 12-11 0 0 5 3 7 16 4 2 8 5 12 9s7 8 9 12c14 3 17 7 17 7-4 8-9 11-11 11 0 6-2 11-7 16-16 16-30 10-41 2 0 3-1 7-5 11l-12 11c-1 1 1 5 1 5z"
fill={theme === THEME.LIGHT ? oc.white : "var(--default-bg-color)"}
/>
</a>
</svg>
),
);
+22
View File
@@ -40,10 +40,12 @@ export class FileManager {
private _getFiles;
private _saveFiles;
private _onFileStatusChange;
constructor({
getFiles,
saveFiles,
onFileStatusChange,
}: {
getFiles: (fileIds: FileId[]) => Promise<{
loadedFiles: BinaryFileData[];
@@ -53,9 +55,13 @@ export class FileManager {
savedFiles: Map<FileId, BinaryFileData>;
erroredFiles: Map<FileId, BinaryFileData>;
}>;
onFileStatusChange?: (
updates: Array<[FileId, "loading" | "loaded" | "error"]>,
) => void;
}) {
this._getFiles = getFiles;
this._saveFiles = saveFiles;
this._onFileStatusChange = onFileStatusChange;
}
/**
@@ -146,6 +152,8 @@ export class FileManager {
this.fetchingFiles.set(id, true);
}
this._onFileStatusChange?.(ids.map((id) => [id, "loading"]));
try {
const { loadedFiles, erroredFiles } = await this._getFiles(ids);
@@ -156,6 +164,13 @@ export class FileManager {
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 };
} finally {
for (const id of ids) {
@@ -195,6 +210,13 @@ export class FileManager {
};
reset() {
if (this._onFileStatusChange && this.fetchingFiles.size) {
this._onFileStatusChange(
[...this.fetchingFiles.keys()].map(
(id) => [id, "error"] as [FileId, "error"],
),
);
}
this.fetchingFiles.clear();
this.savingFiles.clear();
this.savedFiles.clear();
+21 -2
View File
@@ -16,7 +16,6 @@ import {
DEFAULT_SIDEBAR,
debounce,
} from "@excalidraw/common";
import { clearElementsForLocalStorage } from "@excalidraw/element";
import {
createStore,
entries,
@@ -27,6 +26,8 @@ import {
get,
} from "idb-keyval";
import { getNonDeletedElements } from "@excalidraw/element";
import type { LibraryPersistedData } from "@excalidraw/excalidraw/data/library";
import type { ImportedDataState } from "@excalidraw/excalidraw/data/types";
import type { ExcalidrawElement, FileId } from "@excalidraw/element/types";
@@ -37,14 +38,18 @@ import type {
} from "@excalidraw/excalidraw/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 { FileManager } from "./FileManager";
import { FileStatusStore } from "./fileStatusStore";
import { Locker } from "./Locker";
import { updateBrowserStateVersion } from "./tabSync";
const filesStore = createStore("files-db", "files-store");
export const localStorageQuotaExceededAtom = atom(false);
class LocalFileManager extends FileManager {
clearObsoleteFiles = async (opts: { currentFileIds: FileId[] }) => {
await entries(filesStore).then((entries) => {
@@ -69,6 +74,9 @@ const saveDataStateToLocalStorage = (
elements: readonly ExcalidrawElement[],
appState: AppState,
) => {
const localStorageQuotaExceeded = appJotaiStore.get(
localStorageQuotaExceededAtom,
);
try {
const _appState = clearAppStateForLocalStorage(appState);
@@ -81,19 +89,29 @@ const saveDataStateToLocalStorage = (
localStorage.setItem(
STORAGE_KEYS.LOCAL_STORAGE_ELEMENTS,
JSON.stringify(clearElementsForLocalStorage(elements)),
JSON.stringify(getNonDeletedElements(elements)),
);
localStorage.setItem(
STORAGE_KEYS.LOCAL_STORAGE_APP_STATE,
JSON.stringify(_appState),
);
updateBrowserStateVersion(STORAGE_KEYS.VERSION_DATA_STATE);
if (localStorageQuotaExceeded) {
appJotaiStore.set(localStorageQuotaExceededAtom, false);
}
} catch (error: any) {
// Unable to access window.localStorage
console.error(error);
if (isQuotaExceededError(error) && !localStorageQuotaExceeded) {
appJotaiStore.set(localStorageQuotaExceededAtom, true);
}
}
};
const isQuotaExceededError = (error: any) => {
return error instanceof DOMException && error.name === "QuotaExceededError";
};
type SavingLockTypes = "collaboration";
export class LocalData {
@@ -149,6 +167,7 @@ export class LocalData {
// ---------------------------------------------------------------------------
static fileStorage = new LocalFileManager({
onFileStatusChange: FileStatusStore.updateStatuses.bind(FileStatusStore),
getFiles(ids) {
return getMany(ids, filesStore).then(
async (filesData: (BinaryFileData | undefined)[]) => {
+51
View File
@@ -0,0 +1,51 @@
import { createStore, get, set } from "idb-keyval";
import type { SavedChats } from "@excalidraw/excalidraw/components/TTDDialog/types";
import { STORAGE_KEYS } from "../app_constants";
/**
* IndexedDB adapter for TTD chat storage.
* Implements TTDPersistenceAdapter interface.
*/
export class TTDIndexedDBAdapter {
/** IndexedDB database name */
private static idb_name = STORAGE_KEYS.IDB_TTD_CHATS;
/** Store key for chat data */
private static key = "ttdChats";
private static store = createStore(
`${TTDIndexedDBAdapter.idb_name}-db`,
`${TTDIndexedDBAdapter.idb_name}-store`,
);
/**
* Load saved chats from IndexedDB.
* @returns Promise resolving to saved chats array (empty if none found)
*/
static async loadChats(): Promise<SavedChats> {
try {
const data = await get<SavedChats>(
TTDIndexedDBAdapter.key,
TTDIndexedDBAdapter.store,
);
return data || [];
} catch (error) {
console.warn("Failed to load TTD chats from IndexedDB:", error);
return [];
}
}
/**
* Save chats to IndexedDB.
* @param chats - The chats array to persist
*/
static async saveChats(chats: SavedChats): Promise<void> {
try {
await set(TTDIndexedDBAdapter.key, chats, TTDIndexedDBAdapter.store);
} catch (error) {
console.warn("Failed to save TTD chats to IndexedDB:", error);
throw error;
}
}
}
+48
View File
@@ -0,0 +1,48 @@
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 };
}
}
+7 -5
View File
@@ -1,5 +1,5 @@
import { reconcileElements } from "@excalidraw/excalidraw";
import { MIME_TYPES } from "@excalidraw/common";
import { MIME_TYPES, toBrandedType } from "@excalidraw/common";
import { decompressData } from "@excalidraw/excalidraw/data/encode";
import {
encryptData,
@@ -105,8 +105,8 @@ const decryptElements = async (
data: FirebaseStoredScene,
roomKey: string,
): Promise<readonly ExcalidrawElement[]> => {
const ciphertext = data.ciphertext.toUint8Array();
const iv = data.iv.toUint8Array();
const ciphertext = data.ciphertext.toUint8Array() as Uint8Array<ArrayBuffer>;
const iv = data.iv.toUint8Array() as Uint8Array<ArrayBuffer>;
const decrypted = await decryptData(iv, ciphertext, roomKey);
const decodedData = new TextDecoder("utf-8").decode(
@@ -243,7 +243,7 @@ export const saveToFirebase = async (
FirebaseSceneVersionCache.set(socket, storedElements);
return storedElements;
return toBrandedType<RemoteExcalidrawElement[]>(storedElements);
};
export const loadFromFirebase = async (
@@ -259,7 +259,9 @@ export const loadFromFirebase = async (
}
const storedScene = docSnap.data() as FirebaseStoredScene;
const elements = getSyncableElements(
restoreElements(await decryptElements(storedScene, roomKey), null),
restoreElements(await decryptElements(storedScene, roomKey), null, {
deleteInvisibleElements: true,
}),
);
if (socket) {
+3 -38
View File
@@ -8,7 +8,6 @@ import {
IV_LENGTH_BYTES,
} from "@excalidraw/excalidraw/data/encryption";
import { serializeAsJSON } from "@excalidraw/excalidraw/data/json";
import { restore } from "@excalidraw/excalidraw/data/restore";
import { isInvisiblySmallElement } from "@excalidraw/element";
import { isInitializedImageElement } from "@excalidraw/element";
import { t } from "@excalidraw/excalidraw/i18n";
@@ -84,13 +83,13 @@ export type SocketUpdateDataSource = {
SCENE_INIT: {
type: WS_SUBTYPES.INIT;
payload: {
elements: readonly ExcalidrawElement[];
elements: readonly OrderedExcalidrawElement[];
};
};
SCENE_UPDATE: {
type: WS_SUBTYPES.UPDATE;
payload: {
elements: readonly ExcalidrawElement[];
elements: readonly OrderedExcalidrawElement[];
};
};
MOUSE_LOCATION: {
@@ -200,7 +199,7 @@ const legacy_decodeFromBackend = async ({
};
};
const importFromBackend = async (
export const importFromBackend = async (
id: string,
decryptionKey: string,
): Promise<ImportedDataState> => {
@@ -242,40 +241,6 @@ const importFromBackend = async (
}
};
export const loadScene = async (
id: string | null,
privateKey: string | null,
// Supply local state even if importing from backend to ensure we restore
// localStorage user settings which we do not persist on server.
// Non-optional so we don't forget to pass it even if `undefined`.
localDataState: ImportedDataState | undefined | null,
) => {
let data;
if (id != null && privateKey != null) {
// the private key is used to decrypt the content from the server, take
// extra care not to leak it
data = restore(
await importFromBackend(id, privateKey),
localDataState?.appState,
localDataState?.elements,
{ repairBindings: true, refreshDimensions: false },
);
} else {
data = restore(localDataState || null, null, null, {
repairBindings: true,
});
}
return {
elements: data.elements,
appState: data.appState,
// note: this will always be empty because we're not storing files
// in the scene database/localStorage, and instead fetch them async
// from a different database
files: data.files,
};
};
type ExportToBackendResult =
| { url: null; errorMessage: string }
| { url: string; errorMessage: null };
+1 -2
View File
@@ -2,7 +2,6 @@ import {
clearAppStateForLocalStorage,
getDefaultAppState,
} from "@excalidraw/excalidraw/appState";
import { clearElementsForLocalStorage } from "@excalidraw/element";
import type { ExcalidrawElement } from "@excalidraw/element/types";
import type { AppState } from "@excalidraw/excalidraw/types";
@@ -50,7 +49,7 @@ export const importFromLocalStorage = () => {
let elements: ExcalidrawElement[] = [];
if (savedElements) {
try {
elements = clearElementsForLocalStorage(JSON.parse(savedElements));
elements = JSON.parse(savedElements);
} catch (error: any) {
console.error(error);
// Do nothing because elements array is already empty
+74
View File
@@ -0,0 +1,74 @@
import { useEffect } from "react";
import { isDevEnv, toBrandedType } from "@excalidraw/common";
import type {
Collaborator,
ExcalidrawImperativeAPI,
SocketId,
} from "@excalidraw/excalidraw/types";
/**
* Dev-only debug helper: `?collaborators=<N>` on excalidraw-app populates
* the canvas with N static fake collaborators (random usernames, one of
* them marked as the current user) so avatar/UserList UI can be exercised
* without a real collab room or a second browser tab.
*
* Not meant to coexist with a real collab session — joining an actual room
* will overwrite these with the real roster (Collab owns `collaborators`
* from that point on).
*/
const getSimulatedCollaboratorCountFromUrl = (): number | null => {
const count = Number(
new URLSearchParams(window.location.search).get("collaborators"),
);
return Number.isFinite(count) && count > 0 ? Math.floor(count) : null;
};
const generateSimulatedCollaborators = async (
count: number,
): Promise<Map<SocketId, Collaborator>> => {
const { getRandomUsername } = await import("@excalidraw/random-username");
const collaborators = new Map<SocketId, Collaborator>();
for (let i = 0; i < count; i++) {
const socketId = toBrandedType<SocketId>(`debug-collaborator-${i}`);
collaborators.set(socketId, {
id: socketId,
socketId,
username: getRandomUsername(),
isCurrentUser: i === 0,
});
}
return collaborators;
};
export const useSimulatedCollaborators = (
excalidrawAPI: ExcalidrawImperativeAPI | null,
) => {
useEffect(() => {
if (!isDevEnv() || !excalidrawAPI) {
return;
}
const count = getSimulatedCollaboratorCountFromUrl();
if (!count) {
return;
}
let cancelled = false;
generateSimulatedCollaborators(count).then((collaborators) => {
if (!cancelled) {
excalidrawAPI.updateScene({ collaborators });
}
});
return () => {
cancelled = true;
};
}, [excalidrawAPI]);
};
+2 -2
View File
@@ -2,7 +2,7 @@
<html lang="en">
<head>
<meta charset="utf-8" />
<title>Excalidraw | Hand-drawn look & feel • Collaborative • Secure</title>
<title>Excalidraw Whiteboard</title>
<meta
name="viewport"
content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no, viewport-fit=cover, shrink-to-fit=no"
@@ -14,7 +14,7 @@
<!-- Primary Meta Tags -->
<meta
name="title"
content="Excalidraw — Collaborative whiteboarding made easy"
content="Free, collaborative whiteboard • Hand-drawn look & feel | Excalidraw"
/>
<meta
name="description"
+27 -14
View File
@@ -1,3 +1,5 @@
@import "../packages/excalidraw/css/variables.module.scss";
.excalidraw {
--color-primary-contrast-offset: #625ee0; // to offset Chubb illusion
@@ -5,12 +7,6 @@
--color-primary-contrast-offset: #726dff; // to offset Chubb illusion
}
.top-right-ui {
display: flex;
justify-content: center;
align-items: flex-start;
}
.footer-center {
justify-content: flex-end;
margin-top: auto;
@@ -58,7 +54,7 @@
}
}
.collab-offline-warning {
.alert {
pointer-events: none;
position: absolute;
top: 6.5rem;
@@ -69,10 +65,18 @@
text-align: center;
line-height: 1.5;
border-radius: var(--border-radius-md);
background-color: var(--color-warning);
color: var(--color-text-warning);
z-index: 6;
white-space: pre;
&--warning {
background-color: var(--color-warning);
color: var(--color-text-warning);
}
&--danger {
background-color: var(--color-danger-dark);
color: var(--color-danger-text);
}
}
}
@@ -82,22 +86,31 @@
}
}
.plus-button {
.plus-banner {
display: flex;
justify-content: center;
cursor: pointer;
align-items: center;
border: 1px solid var(--color-primary);
padding: 0.5rem 0.75rem;
padding: 0.5rem 0.875rem;
border-radius: var(--border-radius-lg);
background-color: var(--island-bg-color);
color: var(--color-primary) !important;
text-decoration: none !important;
font-size: 0.75rem;
font-family: var(--ui-font);
font-size: 0.8333rem;
box-sizing: border-box;
height: var(--lg-button-size);
border: none;
box-shadow: 0 0 0 1px var(--color-surface-lowest);
background-color: var(--color-surface-low);
color: var(--button-color, var(--color-on-surface)) !important;
&:active {
box-shadow: 0 0 0 1px var(--color-brand-active);
}
&:hover {
background-color: var(--color-primary);
color: white !important;
@@ -109,7 +122,7 @@
}
.theme--dark {
.plus-button {
.plus-banner {
&:hover {
color: black !important;
}
+2 -1
View File
@@ -23,7 +23,7 @@
]
},
"engines": {
"node": "18.0.0 - 22.x.x"
"node": ">=18.0.0"
},
"dependencies": {
"@excalidraw/random-username": "1.0.0",
@@ -36,6 +36,7 @@
"react": "19.0.0",
"react-dom": "19.0.0",
"socket.io-client": "4.7.2",
"uqr": "0.1.2",
"vite-plugin-html": "3.2.2"
},
"prettier": "@excalidraw/prettier-config",
+13
View File
@@ -1,3 +1,4 @@
import { getFeatureFlag } from "@excalidraw/common";
import * as Sentry from "@sentry/browser";
import callsites from "callsites";
@@ -33,6 +34,7 @@ Sentry.init({
Sentry.captureConsoleIntegration({
levels: ["error"],
}),
Sentry.featureFlagsIntegration(),
],
beforeSend(event) {
if (event.request?.url) {
@@ -79,3 +81,14 @@ Sentry.init({
return event;
},
});
const flagsIntegration =
Sentry.getClient()?.getIntegrationByName<Sentry.FeatureFlagsIntegration>(
"FeatureFlags",
);
if (flagsIntegration) {
flagsIntegration.addFeatureFlag(
"COMPLEX_BINDINGS",
getFeatureFlag("COMPLEX_BINDINGS"),
);
}
+56
View File
@@ -0,0 +1,56 @@
import { useEffect, useState } from "react";
import Spinner from "@excalidraw/excalidraw/components/Spinner";
interface QRCodeProps {
value: string;
}
export const QRCode = ({ value }: QRCodeProps) => {
const [svgData, setSvgData] = useState<string | null>(null);
const [error, setError] = useState<boolean>(false);
useEffect(() => {
let mounted = true;
import("./qrcode.chunk")
.then(({ generateQRCodeSVG }) => {
if (mounted) {
try {
setSvgData(generateQRCodeSVG(value));
} catch {
setError(true);
}
}
})
.catch(() => {
if (mounted) {
setError(true);
}
});
return () => {
mounted = false;
};
}, [value]);
if (error) {
return null;
}
if (!svgData) {
return (
<div className="ShareDialog__active__qrcode ShareDialog__active__qrcode--loading">
<Spinner />
</div>
);
}
return (
<div
className="ShareDialog__active__qrcode"
role="img"
aria-label="QR code for collaboration link"
dangerouslySetInnerHTML={{ __html: svgData }}
/>
);
};
+25
View File
@@ -140,6 +140,31 @@
gap: 0.75rem;
}
&__qrcode {
display: flex;
justify-content: center;
align-items: center;
align-self: center;
padding: 1rem;
background: #fff;
border-radius: 0.5rem;
border: 1px solid #e0e0e0;
$size: 150px;
width: $size;
height: $size;
& svg {
width: $size;
height: $size;
}
&--loading {
background: var(--island-bg-color);
border: 1px solid var(--dialog-border-color);
}
}
&__description {
border-top: 1px solid var(--color-gray-20);
+2
View File
@@ -22,6 +22,7 @@ import { atom, useAtom, useAtomValue } from "../app-jotai";
import { activeRoomLinkAtom } from "../collab/Collab";
import "./ShareDialog.scss";
import { QRCode } from "./QRCode";
import type { CollabAPI } from "../collab/Collab";
@@ -142,6 +143,7 @@ const ActiveRoomDialog = ({
}}
/>
</div>
<QRCode value={activeRoomLink} />
<div className="ShareDialog__active__description">
<p>
<span
+5
View File
@@ -0,0 +1,5 @@
import { renderSVG } from "uqr";
export const generateQRCodeSVG = (text: string): string => {
return renderSVG(text);
};
+30 -5
View File
@@ -1,4 +1,5 @@
import { defaultLang } from "@excalidraw/excalidraw/i18n";
import { Excalidraw, MainMenu } from "@excalidraw/excalidraw";
import { defaultLang, languages } from "@excalidraw/excalidraw/i18n";
import { UI } from "@excalidraw/excalidraw/tests/helpers/ui";
import {
screen,
@@ -7,11 +8,35 @@ import {
render,
} from "@excalidraw/excalidraw/tests/test-utils";
import ExcalidrawApp from "../App";
import { LanguageList } from "../app-language/LanguageList";
import { useAppLangCode } from "../app-language/language-state";
import { Provider } from "../app-jotai";
const TEST_LANG_CODE = "fr-FR";
const TestApp = () => {
const [langCode] = useAppLangCode();
return (
<Excalidraw langCode={langCode}>
<MainMenu>
<MainMenu.ItemCustom>
<LanguageList />
</MainMenu.ItemCustom>
</MainMenu>
</Excalidraw>
);
};
describe("Test LanguageList", () => {
it("rerenders UI on language change", async () => {
await render(<ExcalidrawApp />);
expect(languages.some((lang) => lang.code === TEST_LANG_CODE)).toBe(true);
await render(
<Provider>
<TestApp />
</Provider>,
);
// select rectangle tool to show properties menu
UI.clickTool("rectangle");
@@ -20,9 +45,9 @@ describe("Test LanguageList", () => {
fireEvent.click(document.querySelector(".dropdown-menu-button")!);
fireEvent.change(document.querySelector(".dropdown-select__language")!, {
target: { value: "de-DE" },
target: { value: TEST_LANG_CODE },
});
// switching to german, `thin` label should no longer exist
// switching to French, `thin` label should no longer exist
await waitFor(() => expect(screen.queryByTitle(/thin/i)).toBeNull());
// reset language
fireEvent.change(document.querySelector(".dropdown-select__language")!, {
+9 -21
View File
@@ -1,3 +1,4 @@
import { Excalidraw, WelcomeScreen } from "@excalidraw/excalidraw";
import { UI } from "@excalidraw/excalidraw/tests/helpers/ui";
import {
mockBoundingClientRect,
@@ -5,8 +6,6 @@ import {
restoreOriginalGetBoundingClientRect,
} from "@excalidraw/excalidraw/tests/test-utils";
import ExcalidrawApp from "../App";
describe("Test MobileMenu", () => {
const { h } = window;
const dimensions = { height: 400, width: 800 };
@@ -16,31 +15,20 @@ describe("Test MobileMenu", () => {
});
beforeEach(async () => {
await render(<ExcalidrawApp />);
// @ts-ignore
h.app.refreshViewportBreakpoints();
// @ts-ignore
h.app.refreshEditorBreakpoints();
await render(
<Excalidraw>
<WelcomeScreen />
</Excalidraw>,
);
h.app.refreshEditorInterface();
});
afterAll(() => {
restoreOriginalGetBoundingClientRect();
});
it("should set device correctly", () => {
expect(h.app.device).toMatchInlineSnapshot(`
{
"editor": {
"canFitSidebar": false,
"isMobile": true,
},
"isTouchScreen": false,
"viewport": {
"isLandscape": false,
"isMobile": true,
},
}
`);
it("should set editor interface correctly", () => {
expect(h.app.editorInterface.formFactor).toBe("phone");
});
it("should initialize with welcome screen and hide once user interacts", async () => {
@@ -50,7 +50,7 @@ exports[`Test MobileMenu > should initialize with welcome screen and hide once u
<div
class="welcome-screen-center__heading welcome-screen-decor excalifont"
>
All your data is saved locally in your browser.
Diagrams. Made. Simple.
</div>
<div
class="welcome-screen-menu"
@@ -145,103 +145,6 @@ exports[`Test MobileMenu > should initialize with welcome screen and hide once u
?
</div>
</button>
<button
class="welcome-screen-menu-item "
type="button"
>
<div
class="welcome-screen-menu-item__icon"
>
<svg
aria-hidden="true"
class=""
fill="none"
focusable="false"
role="img"
stroke="currentColor"
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
viewBox="0 0 24 24"
>
<g
stroke-width="1.5"
>
<path
d="M0 0h24v24H0z"
fill="none"
stroke="none"
/>
<circle
cx="9"
cy="7"
r="4"
/>
<path
d="M3 21v-2a4 4 0 0 1 4 -4h4a4 4 0 0 1 4 4v2"
/>
<path
d="M16 3.13a4 4 0 0 1 0 7.75"
/>
<path
d="M21 21v-2a4 4 0 0 0 -3 -3.85"
/>
</g>
</svg>
</div>
<div
class="welcome-screen-menu-item__text"
>
Live collaboration...
</div>
</button>
<a
class="welcome-screen-menu-item "
href="undefined/plus?utm_source=excalidraw&utm_medium=app&utm_content=welcomeScreenGuest"
rel="noopener"
target="_blank"
>
<div
class="welcome-screen-menu-item__icon"
>
<svg
aria-hidden="true"
class=""
fill="none"
focusable="false"
role="img"
stroke="currentColor"
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
viewBox="0 0 24 24"
>
<g
stroke-width="1.5"
>
<path
d="M0 0h24v24H0z"
fill="none"
stroke="none"
/>
<path
d="M15 8v-2a2 2 0 0 0 -2 -2h-7a2 2 0 0 0 -2 2v12a2 2 0 0 0 2 2h7a2 2 0 0 0 2 -2v-2"
/>
<path
d="M21 12h-13l3 -3"
/>
<path
d="M11 15l-3 -3"
/>
</g>
</svg>
</div>
<div
class="welcome-screen-menu-item__text"
>
Sign up
</div>
</a>
</div>
</div>
`;
+1 -74
View File
@@ -205,6 +205,7 @@ describe("collaboration", () => {
// with explicit undo (as addition) we expect our item to be restored from the snapshot!
await waitFor(() => {
expect(API.getUndoStack().length).toBe(1);
expect(API.getRedoStack().length).toBe(1);
expect(API.getSnapshot()).toEqual([
expect.objectContaining(rect1Props),
expect.objectContaining({ ...rect2Props, isDeleted: false }),
@@ -247,79 +248,5 @@ describe("collaboration", () => {
expect.objectContaining({ ...rect2Props, isDeleted: true }),
]);
});
act(() => h.app.actionManager.executeAction(undoAction));
// simulate local update
API.updateScene({
elements: syncInvalidIndices([
h.elements[0],
newElementWith(h.elements[1], { x: 100 }),
]),
captureUpdate: CaptureUpdateAction.IMMEDIATELY,
});
await waitFor(() => {
expect(API.getUndoStack().length).toBe(2);
expect(API.getRedoStack().length).toBe(0);
expect(API.getSnapshot()).toEqual([
expect.objectContaining(rect1Props),
expect.objectContaining({ ...rect2Props, isDeleted: false, x: 100 }),
]);
expect(h.elements).toEqual([
expect.objectContaining(rect1Props),
expect.objectContaining({ ...rect2Props, isDeleted: false, x: 100 }),
]);
});
act(() => h.app.actionManager.executeAction(undoAction));
// we expect to iterate the stack to the first visible change
await waitFor(() => {
expect(API.getUndoStack().length).toBe(1);
expect(API.getRedoStack().length).toBe(1);
expect(API.getSnapshot()).toEqual([
expect.objectContaining(rect1Props),
expect.objectContaining({ ...rect2Props, isDeleted: false, x: 0 }),
]);
expect(h.elements).toEqual([
expect.objectContaining(rect1Props),
expect.objectContaining({ ...rect2Props, isDeleted: false, x: 0 }),
]);
});
// simulate force deleting the element remotely
API.updateScene({
elements: syncInvalidIndices([rect1]),
captureUpdate: CaptureUpdateAction.NEVER,
});
// snapshot was correctly updated and marked the element as deleted
await waitFor(() => {
expect(API.getUndoStack().length).toBe(1);
expect(API.getRedoStack().length).toBe(1);
expect(API.getSnapshot()).toEqual([
expect.objectContaining(rect1Props),
expect.objectContaining({ ...rect2Props, isDeleted: true, x: 0 }),
]);
expect(h.elements).toEqual([expect.objectContaining(rect1Props)]);
});
act(() => h.app.actionManager.executeAction(redoAction));
// with explicit redo (as update) we again restored the element from the snapshot!
await waitFor(() => {
expect(API.getUndoStack().length).toBe(2);
expect(API.getRedoStack().length).toBe(0);
expect(API.getSnapshot()).toEqual([
expect.objectContaining({ id: "A", isDeleted: false }),
expect.objectContaining({ id: "B", isDeleted: true, x: 100 }),
]);
expect(h.history.isRedoStackEmpty).toBeTruthy();
expect(h.elements).toEqual([
expect.objectContaining({ id: "A", isDeleted: false }),
expect.objectContaining({ id: "B", isDeleted: true, x: 100 }),
]);
});
});
});
+1 -20
View File
@@ -1,5 +1,4 @@
import { THEME } from "@excalidraw/excalidraw";
import { EVENT, CODES, KEYS } from "@excalidraw/common";
import { useEffect, useLayoutEffect, useState } from "react";
import type { Theme } from "@excalidraw/element/types";
@@ -31,28 +30,10 @@ export const useHandleAppTheme = () => {
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 () => {
mediaQuery?.removeEventListener("change", handleChange);
document.removeEventListener(EVENT.KEYDOWN, handleKeydown, {
capture: true,
});
};
}, [appTheme, editorTheme, setAppTheme]);
}, [appTheme]);
useLayoutEffect(() => {
localStorage.setItem(STORAGE_KEYS.LOCAL_STORAGE_THEME, appTheme);
+29 -1
View File
@@ -75,6 +75,20 @@ export default defineConfig(({ mode }) => {
find: /^@excalidraw\/utils\/(.*?)/,
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: {
@@ -102,6 +116,14 @@ export default defineConfig(({ mode }) => {
// Taking the substring after "locales/"
return `locales/${id.substring(index + 8)}`;
}
if (id.includes("@excalidraw/mermaid-to-excalidraw")) {
return "mermaid-to-excalidraw";
}
if (id.includes("@codemirror/") || id.includes("@lezer/")) {
return "codemirror.chunk";
}
},
},
},
@@ -146,6 +168,11 @@ export default defineConfig(({ mode }) => {
"**/locales/**",
"service-worker.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: [
{
@@ -185,7 +212,7 @@ export default defineConfig(({ mode }) => {
},
},
{
urlPattern: new RegExp(".chunk-.+.js"),
urlPattern: new RegExp("(.chunk-.+|CodeMirrorEditor-.+)\\.js"),
handler: "CacheFirst",
options: {
cacheName: "chunk",
@@ -196,6 +223,7 @@ export default defineConfig(({ mode }) => {
},
},
],
maximumFileSizeToCacheInBytes: 2.3 * 1024 ** 2, // 2.3MB
},
manifest: {
short_name: "Excalidraw",
+14 -7
View File
@@ -34,7 +34,7 @@
"prettier": "2.6.2",
"rewire": "6.0.0",
"rimraf": "^5.0.0",
"typescript": "4.9.4",
"typescript": "5.9.3",
"vite": "5.0.12",
"vite-plugin-checker": "0.7.2",
"vite-plugin-ejs": "1.7.0",
@@ -44,7 +44,7 @@
"vitest-canvas-mock": "0.3.3"
},
"engines": {
"node": "18.0.0 - 22.x.x"
"node": ">=18.0.0"
},
"homepage": ".",
"prettier": "@excalidraw/prettier-config",
@@ -52,13 +52,19 @@
"build-node": "node ./scripts/build-node.js",
"build:app:docker": "yarn --cwd ./excalidraw-app build:app:docker",
"build:app": "yarn --cwd ./excalidraw-app build:app",
"build:package": "yarn --cwd ./packages/excalidraw build:esm",
"build:common": "yarn --cwd ./packages/common build:esm",
"build:element": "yarn --cwd ./packages/element build:esm",
"build:excalidraw": "yarn --cwd ./packages/excalidraw build:esm",
"build:math": "yarn --cwd ./packages/math build:esm",
"build:fractional-indexing": "yarn --cwd ./packages/fractional-indexing build:esm",
"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": "yarn --cwd ./excalidraw-app build",
"build:preview": "yarn --cwd ./excalidraw-app build:preview",
"start": "yarn --cwd ./excalidraw-app start",
"start:production": "yarn --cwd ./excalidraw-app start:production",
"start:example": "yarn build:package && yarn --cwd ./examples/with-script-in-browser start",
"start:example": "yarn build:packages && yarn --cwd ./examples/with-script-in-browser start",
"test:all": "yarn test:typecheck && yarn test:code && yarn test:other && yarn test:app --watch=false",
"test:app": "vitest",
"test:code": "eslint --max-warnings=0 --ext .js,.ts,.tsx .",
@@ -76,9 +82,10 @@
"locales-coverage:description": "node scripts/locales-coverage-description.js",
"prepare": "husky install",
"prettier": "prettier \"**/*.{css,scss,json,md,html,yml}\" --ignore-path=.eslintignore",
"autorelease": "node scripts/autorelease.js",
"prerelease:excalidraw": "node scripts/prerelease.js",
"release:excalidraw": "node scripts/release.js",
"release": "node scripts/release.js",
"release:test": "node scripts/release.js --tag=test",
"release:next": "node scripts/release.js --tag=next",
"release:latest": "node scripts/release.js --tag=latest",
"rm:build": "rimraf --glob excalidraw-app/build excalidraw-app/dist excalidraw-app/dev-dist packages/*/dist packages/*/build examples/*/build examples/*/dist",
"rm:node_modules": "rimraf --glob node_modules excalidraw-app/node_modules packages/*/node_modules",
"clean-install": "yarn rm:node_modules && yarn install"
@@ -1,17 +1,9 @@
declare global {
interface Window {
debug: typeof Debug;
}
}
const lessPrecise = (num: number, precision = 5) =>
parseFloat(num.toPrecision(precision));
const getAvgFrameTime = (times: number[]) =>
lessPrecise(times.reduce((a, b) => a + b) / times.length);
const getFps = (frametime: number) => lessPrecise(1000 / frametime);
export class Debug {
public static DEBUG_LOG_TIMES = true;
@@ -24,34 +16,35 @@ export class Debug {
private static LAST_DEBUG_LOG_CALL = 0;
private static DEBUG_LOG_INTERVAL_ID: null | number = null;
private static LAST_FRAME_TIMESTAMP = 0;
private static FRAME_COUNT = 0;
private static ANIMATION_FRAME_ID: null | number = null;
private static scheduleAnimationFrame = () => {
if (Debug.DEBUG_LOG_INTERVAL_ID !== null) {
Debug.ANIMATION_FRAME_ID = requestAnimationFrame((timestamp) => {
if (Debug.LAST_FRAME_TIMESTAMP !== timestamp) {
Debug.LAST_FRAME_TIMESTAMP = timestamp;
Debug.FRAME_COUNT++;
}
if (Debug.DEBUG_LOG_INTERVAL_ID !== null) {
Debug.scheduleAnimationFrame();
}
});
}
};
private static setupInterval = () => {
if (Debug.DEBUG_LOG_INTERVAL_ID === null) {
console.info("%c(starting perf recording)", "color: lime");
Debug.DEBUG_LOG_INTERVAL_ID = window.setInterval(Debug.debugLogger, 1000);
Debug.scheduleAnimationFrame();
}
Debug.LAST_DEBUG_LOG_CALL = Date.now();
};
private static debugLogger = () => {
if (
Date.now() - Debug.LAST_DEBUG_LOG_CALL > 600 &&
Debug.DEBUG_LOG_INTERVAL_ID !== null
) {
window.clearInterval(Debug.DEBUG_LOG_INTERVAL_ID);
Debug.DEBUG_LOG_INTERVAL_ID = null;
for (const [name, { avg }] of Object.entries(Debug.TIMES_AVG)) {
if (avg != null) {
console.info(
`%c${name} run avg: ${avg}ms (${getFps(avg)} fps)`,
"color: blue",
);
}
}
console.info("%c(stopping perf recording)", "color: red");
Debug.TIMES_AGGR = {};
Debug.TIMES_AVG = {};
return;
}
if (Debug.DEBUG_LOG_TIMES) {
for (const [name, { t, times }] of Object.entries(Debug.TIMES_AGGR)) {
if (times.length) {
@@ -65,8 +58,18 @@ export class Debug {
}
for (const [name, { t, times, avg }] of Object.entries(Debug.TIMES_AVG)) {
if (times.length) {
const avgFrameTime = getAvgFrameTime(times);
console.info(name, `${avgFrameTime}ms (${getFps(avgFrameTime)} fps)`);
// const avgFrameTime = getAvgFrameTime(times);
const totalTime = times.reduce((a, b) => a + b);
const avgFrameTime = lessPrecise(totalTime / Debug.FRAME_COUNT);
console.info(
name,
`- ${times.length} calls - ${avgFrameTime}ms/frame across ${
Debug.FRAME_COUNT
} frames (${lessPrecise(
(avgFrameTime / 16.67) * 100,
1,
)}% of frame budget)`,
);
Debug.TIMES_AVG[name] = {
t,
times: [],
@@ -76,6 +79,24 @@ export class Debug {
}
}
}
Debug.FRAME_COUNT = 0;
// Check for stop condition after logging
if (
Date.now() - Debug.LAST_DEBUG_LOG_CALL > 600 &&
Debug.DEBUG_LOG_INTERVAL_ID !== null
) {
console.info("%c(stopping perf recording)", "color: red");
window.clearInterval(Debug.DEBUG_LOG_INTERVAL_ID);
window.cancelAnimationFrame(Debug.ANIMATION_FRAME_ID!);
Debug.ANIMATION_FRAME_ID = null;
Debug.FRAME_COUNT = 0;
Debug.LAST_FRAME_TIMESTAMP = 0;
Debug.DEBUG_LOG_INTERVAL_ID = null;
Debug.TIMES_AGGR = {};
Debug.TIMES_AVG = {};
}
};
public static logTime = (time?: number, name = "default") => {
@@ -109,7 +130,7 @@ export class Debug {
return (...args: T) => {
const t0 = performance.now();
const ret = fn(...args);
Debug.logTime(performance.now() - t0, name);
Debug[type](performance.now() - t0, name);
return ret;
};
};
@@ -130,6 +151,70 @@ export class Debug {
return ret;
};
};
private static CHANGED_CACHE: Record<string, Record<string, unknown>> = {};
public static logChanged(name: string, obj: Record<string, unknown>) {
const prev = Debug.CHANGED_CACHE[name];
Debug.CHANGED_CACHE[name] = obj;
if (!prev) {
return;
}
const allKeys = new Set([...Object.keys(prev), ...Object.keys(obj)]);
const changed: Record<string, { prev: unknown; next: unknown }> = {};
for (const key of allKeys) {
const prevVal = prev[key];
const nextVal = obj[key];
if (!deepEqual(prevVal, nextVal)) {
changed[key] = { prev: prevVal, next: nextVal };
}
}
if (Object.keys(changed).length > 0) {
console.info(`[${name}] changed:`, changed);
}
}
}
function deepEqual(a: unknown, b: unknown): boolean {
if (Object.is(a, b)) {
return true;
}
if (
a === null ||
b === null ||
typeof a !== "object" ||
typeof b !== "object"
) {
return false;
}
if (Array.isArray(a) !== Array.isArray(b)) {
return false;
}
const keysA = Object.keys(a as Record<string, unknown>);
const keysB = Object.keys(b as Record<string, unknown>);
if (keysA.length !== keysB.length) {
return false;
}
for (const key of keysA) {
if (
!deepEqual(
(a as Record<string, unknown>)[key],
(b as Record<string, unknown>)[key],
)
) {
return false;
}
}
return true;
}
//@ts-ignore
window.debug = Debug;
+11 -2
View File
@@ -1,6 +1,6 @@
{
"name": "@excalidraw/common",
"version": "0.1.0",
"version": "0.18.0",
"type": "module",
"types": "./dist/types/common/src/index.d.ts",
"main": "./dist/prod/index.js",
@@ -13,7 +13,10 @@
"default": "./dist/prod/index.js"
},
"./*": {
"types": "./dist/types/common/src/*.d.ts"
"types": "./dist/types/common/src/*.d.ts",
"development": "./dist/dev/index.js",
"production": "./dist/prod/index.js",
"default": "./dist/prod/index.js"
}
},
"files": [
@@ -52,5 +55,11 @@
"scripts": {
"gen:types": "rimraf types && tsc",
"build:esm": "rimraf dist && node ../../scripts/buildBase.js && yarn gen:types"
},
"dependencies": {
"tinycolor2": "1.6.0"
},
"devDependencies": {
"@types/tinycolor2": "1.4.6"
}
}
@@ -0,0 +1,185 @@
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
exports[`COLOR_PALETTE > color palette doesn't regress 1`] = `
{
"black": "#1e1e1e",
"blue": [
"#e7f5ff",
"#a5d8ff",
"#4dabf7",
"#228be6",
"#1971c2",
],
"bronze": [
"#f8f1ee",
"#eaddd7",
"#d2bab0",
"#a18072",
"#846358",
],
"cyan": [
"#e3fafc",
"#99e9f2",
"#3bc9db",
"#15aabf",
"#0c8599",
],
"grape": [
"#f8f0fc",
"#eebefa",
"#da77f2",
"#be4bdb",
"#9c36b5",
],
"gray": [
"#f8f9fa",
"#e9ecef",
"#ced4da",
"#868e96",
"#343a40",
],
"green": [
"#ebfbee",
"#b2f2bb",
"#69db7c",
"#40c057",
"#2f9e44",
],
"orange": [
"#fff4e6",
"#ffd8a8",
"#ffa94d",
"#fd7e14",
"#e8590c",
],
"pink": [
"#fff0f6",
"#fcc2d7",
"#f783ac",
"#e64980",
"#c2255c",
],
"red": [
"#fff5f5",
"#ffc9c9",
"#ff8787",
"#fa5252",
"#e03131",
],
"teal": [
"#e6fcf5",
"#96f2d7",
"#38d9a9",
"#12b886",
"#099268",
],
"transparent": "transparent",
"violet": [
"#f3f0ff",
"#d0bfff",
"#9775fa",
"#7950f2",
"#6741d9",
],
"white": "#ffffff",
"yellow": [
"#fff9db",
"#ffec99",
"#ffd43b",
"#fab005",
"#f08c00",
],
}
`;
exports[`applyDarkModeFilter > COLOR_PALETTE regression tests > matches snapshot for all palette colors 1`] = `
{
"black": "#d3d3d3",
"blue": [
"#121e26",
"#154162",
"#2273b4",
"#3791e0",
"#56a2e8",
],
"bronze": [
"#221c1a",
"#362b26",
"#5a463d",
"#917569",
"#a98d84",
],
"cyan": [
"#0a1e20",
"#004149",
"#007281",
"#0f8fa1",
"#3da5b6",
],
"grape": [
"#211a25",
"#5b3165",
"#a954be",
"#d471ed",
"#e28af8",
],
"gray": [
"#161718",
"#202325",
"#33383d",
"#6e757c",
"#b7bcc1",
],
"green": [
"#0f1d12",
"#043b0c",
"#056715",
"#16842a",
"#39994b",
],
"orange": [
"#22190d",
"#4c2b01",
"#924800",
"#cd6005",
"#f17634",
],
"pink": [
"#26191e",
"#602e40",
"#b04d70",
"#f56e9d",
"#ff8dbc",
],
"red": [
"#1f1717",
"#5a2c2c",
"#b44d4d",
"#fa6969",
"#ff8383",
],
"teal": [
"#0a1d17",
"#00422b",
"#00744b",
"#039267",
"#32a783",
],
"transparent": "#ededed00",
"violet": [
"#1f1c29",
"#4a3b72",
"#8a6cdf",
"#a885ff",
"#b595ff",
],
"white": "#121212",
"yellow": [
"#1e1900",
"#362600",
"#5f3a00",
"#905000",
"#b86200",
],
}
`;
+74
View File
@@ -0,0 +1,74 @@
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
@@ -0,0 +1,136 @@
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();
}
}
+24 -19
View File
@@ -5,17 +5,18 @@ export class BinaryHeap<T> {
sinkDown(idx: number) {
const node = this.content[idx];
const nodeScore = this.scoreFunction(node);
while (idx > 0) {
const parentN = ((idx + 1) >> 1) - 1;
const parent = this.content[parentN];
if (this.scoreFunction(node) < this.scoreFunction(parent)) {
this.content[parentN] = node;
if (nodeScore < this.scoreFunction(parent)) {
this.content[idx] = parent;
idx = parentN; // TODO: Optimize
} else {
break;
}
}
this.content[idx] = node;
}
bubbleUp(idx: number) {
@@ -24,35 +25,39 @@ export class BinaryHeap<T> {
const score = this.scoreFunction(node);
while (true) {
const child2N = (idx + 1) << 1;
const child1N = child2N - 1;
let swap = null;
let child1Score = 0;
const child1N = ((idx + 1) << 1) - 1;
const child2N = child1N + 1;
let smallestIdx = idx;
let smallestScore = score;
// Check left child
if (child1N < length) {
const child1 = this.content[child1N];
child1Score = this.scoreFunction(child1);
if (child1Score < score) {
swap = child1N;
const child1Score = this.scoreFunction(this.content[child1N]);
if (child1Score < smallestScore) {
smallestIdx = child1N;
smallestScore = child1Score;
}
}
// Check right child
if (child2N < length) {
const child2 = this.content[child2N];
const child2Score = this.scoreFunction(child2);
if (child2Score < (swap === null ? score : child1Score)) {
swap = child2N;
const child2Score = this.scoreFunction(this.content[child2N]);
if (child2Score < smallestScore) {
smallestIdx = child2N;
}
}
if (swap !== null) {
this.content[idx] = this.content[swap];
this.content[swap] = node;
idx = swap; // TODO: Optimize
} else {
if (smallestIdx === idx) {
break;
}
// Move the smaller child up, continue finding position for node
this.content[idx] = this.content[smallestIdx];
idx = smallestIdx;
}
// Place node in its final position
this.content[idx] = node;
}
push(node: T) {
+17
View File
@@ -0,0 +1,17 @@
/**
* x and y position of top left corner, x and y position of bottom right corner
*/
export type Bounds = readonly [
minX: number,
minY: number,
maxX: number,
maxY: number,
];
export const isBounds = (box: unknown): box is Bounds =>
Array.isArray(box) &&
box.length === 4 &&
typeof box[0] === "number" &&
typeof box[1] === "number" &&
typeof box[2] === "number" &&
typeof box[3] === "number";
+305
View File
@@ -0,0 +1,305 @@
import {
applyDarkModeFilter,
COLOR_PALETTE,
removeDarkModeFilter,
rgbToHex,
} from "@excalidraw/common";
describe("COLOR_PALETTE", () => {
it("color palette doesn't regress", () => {
expect(COLOR_PALETTE).toMatchSnapshot();
});
});
describe("applyDarkModeFilter", () => {
describe("basic transformations", () => {
it("transforms black to near-white", () => {
const result = applyDarkModeFilter("#000000");
// Black inverted 93% + hue rotate should be near white/light gray
expect(result).toBe("#ededed");
});
it("transforms white to near-black", () => {
const result = applyDarkModeFilter("#ffffff");
// White inverted 93% should be near black/dark gray
expect(result).toBe("#121212");
});
it("transforms pure red", () => {
const result = applyDarkModeFilter("#ff0000");
// Invert 93% + hue rotate 180deg produces a cyan-ish tint
expect(result).toBe("#ff9090");
});
it("transforms pure green", () => {
const result = applyDarkModeFilter("#00ff00");
// Invert 93% + hue rotate 180deg
expect(result).toBe("#008f00");
});
it("transforms pure blue", () => {
const result = applyDarkModeFilter("#0000ff");
// Invert 93% + hue rotate 180deg produces a light purple
expect(result).toBe("#cdcdff");
});
});
describe("color formats", () => {
it("handles hex with hash", () => {
const result = applyDarkModeFilter("#ff0000");
// Fully opaque colors return 6-char hex
expect(result).toMatch(/^#[0-9a-f]{6}$/);
});
it("handles named colors", () => {
const result = applyDarkModeFilter("red");
// "red" = #ff0000, fully opaque
expect(result).toBe("#ff9090");
});
it("handles rgb format", () => {
const result = applyDarkModeFilter("rgb(255, 0, 0)");
expect(result).toBe("#ff9090");
});
it("handles rgba format and preserves alpha", () => {
const result = applyDarkModeFilter("rgba(255, 0, 0, 0.5)");
expect(result).toMatch(/^#[0-9a-f]{8}$/);
// Alpha 0.5 = 128 in hex = 80
expect(result).toBe("#ff909080");
});
it("handles transparent", () => {
const result = applyDarkModeFilter("transparent");
// transparent = rgba(0,0,0,0), inverted should still have 0 alpha
expect(result).toBe("#ededed00");
});
it("handles shorthand hex", () => {
const result = applyDarkModeFilter("#f00");
expect(result).toBe("#ff9090");
});
});
describe("alpha preservation", () => {
it("omits alpha for full opacity", () => {
const result = applyDarkModeFilter("#ff0000ff");
// Full opacity returns 6-char hex (no alpha suffix)
expect(result).toBe("#ff9090");
});
it("preserves 50% opacity", () => {
const result = applyDarkModeFilter("#ff000080");
expect(result.slice(-2)).toBe("80");
});
it("preserves 0% opacity", () => {
const result = applyDarkModeFilter("#ff000000");
expect(result.slice(-2)).toBe("00");
});
});
describe("COLOR_PALETTE regression tests", () => {
it("transforms black from palette", () => {
// COLOR_PALETTE.black is #1e1e1e (not pure black)
const result = applyDarkModeFilter(COLOR_PALETTE.black);
expect(result).toBe("#d3d3d3");
});
it("transforms white from palette", () => {
const result = applyDarkModeFilter(COLOR_PALETTE.white);
expect(result).toBe("#121212");
});
it("transforms transparent from palette", () => {
const result = applyDarkModeFilter(COLOR_PALETTE.transparent);
expect(result).toBe("#ededed00");
});
// Test each color family from the palette (all opaque, so 6-char hex)
describe("red shades", () => {
const redShades = COLOR_PALETTE.red;
it.each(redShades.map((color, i) => [color, i]))(
"transforms red shade %s (index %d)",
(color) => {
const result = applyDarkModeFilter(color as string);
expect(result).toMatch(/^#[0-9a-f]{6}$/);
},
);
});
describe("blue shades", () => {
const blueShades = COLOR_PALETTE.blue;
it.each(blueShades.map((color, i) => [color, i]))(
"transforms blue shade %s (index %d)",
(color) => {
const result = applyDarkModeFilter(color as string);
expect(result).toMatch(/^#[0-9a-f]{6}$/);
},
);
});
describe("green shades", () => {
const greenShades = COLOR_PALETTE.green;
it.each(greenShades.map((color, i) => [color, i]))(
"transforms green shade %s (index %d)",
(color) => {
const result = applyDarkModeFilter(color as string);
expect(result).toMatch(/^#[0-9a-f]{6}$/);
},
);
});
describe("gray shades", () => {
const grayShades = COLOR_PALETTE.gray;
it.each(grayShades.map((color, i) => [color, i]))(
"transforms gray shade %s (index %d)",
(color) => {
const result = applyDarkModeFilter(color as string);
expect(result).toMatch(/^#[0-9a-f]{6}$/);
},
);
});
describe("bronze shades", () => {
const bronzeShades = COLOR_PALETTE.bronze;
it.each(bronzeShades.map((color, i) => [color, i]))(
"transforms bronze shade %s (index %d)",
(color) => {
const result = applyDarkModeFilter(color as string);
expect(result).toMatch(/^#[0-9a-f]{6}$/);
},
);
});
// Snapshot test for full palette to catch any regressions
it("matches snapshot for all palette colors", () => {
const transformedPalette: Record<string, string | string[]> = {};
transformedPalette.black = applyDarkModeFilter(COLOR_PALETTE.black);
transformedPalette.white = applyDarkModeFilter(COLOR_PALETTE.white);
transformedPalette.transparent = applyDarkModeFilter(
COLOR_PALETTE.transparent,
);
// Transform color arrays
for (const colorName of [
"gray",
"red",
"pink",
"grape",
"violet",
"blue",
"cyan",
"teal",
"green",
"yellow",
"orange",
"bronze",
] as const) {
const shades = COLOR_PALETTE[colorName];
transformedPalette[colorName] = shades.map((shade) =>
applyDarkModeFilter(shade),
);
}
expect(transformedPalette).toMatchSnapshot();
});
});
describe("caching", () => {
it("returns same result for same input (cached)", () => {
const result1 = applyDarkModeFilter("#ff0000");
const result2 = applyDarkModeFilter("#ff0000");
expect(result1).toBe(result2);
});
});
});
describe("removeDarkModeFilter", () => {
it("restores black and white", () => {
expect(removeDarkModeFilter("#ededed")).toBe("#000000");
expect(removeDarkModeFilter("#121212")).toBe("#ffffff");
});
it.each([
COLOR_PALETTE.black,
COLOR_PALETTE.white,
COLOR_PALETTE.red[4],
COLOR_PALETTE.green[4],
COLOR_PALETTE.blue[4],
])("preserves the rendered dark-mode color for %s", (color) => {
const filtered = applyDarkModeFilter(color);
expect(applyDarkModeFilter(removeDarkModeFilter(filtered))).toBe(filtered);
});
});
describe("rgbToHex", () => {
describe("basic RGB conversion", () => {
it("converts black (0,0,0)", () => {
expect(rgbToHex(0, 0, 0)).toBe("#000000");
});
it("converts white (255,255,255)", () => {
expect(rgbToHex(255, 255, 255)).toBe("#ffffff");
});
it("converts red (255,0,0)", () => {
expect(rgbToHex(255, 0, 0)).toBe("#ff0000");
});
it("converts green (0,255,0)", () => {
expect(rgbToHex(0, 255, 0)).toBe("#00ff00");
});
it("converts blue (0,0,255)", () => {
expect(rgbToHex(0, 0, 255)).toBe("#0000ff");
});
it("converts arbitrary color", () => {
expect(rgbToHex(30, 30, 30)).toBe("#1e1e1e");
});
});
describe("leading zeros preservation", () => {
it("preserves leading zeros for low values", () => {
expect(rgbToHex(0, 0, 1)).toBe("#000001");
expect(rgbToHex(0, 1, 0)).toBe("#000100");
expect(rgbToHex(1, 0, 0)).toBe("#010000");
});
it("preserves zeros for single-digit hex values", () => {
expect(rgbToHex(15, 15, 15)).toBe("#0f0f0f");
});
});
describe("alpha handling", () => {
it("omits alpha when undefined", () => {
expect(rgbToHex(255, 0, 0)).toBe("#ff0000");
expect(rgbToHex(255, 0, 0, undefined)).toBe("#ff0000");
});
it("omits alpha when fully opaque (1)", () => {
expect(rgbToHex(255, 0, 0, 1)).toBe("#ff0000");
});
it("includes alpha for semi-transparent (0.5)", () => {
// 0.5 * 255 = 127.5 -> rounds to 128 = 0x80
expect(rgbToHex(255, 0, 0, 0.5)).toBe("#ff000080");
});
it("includes alpha for fully transparent (0)", () => {
expect(rgbToHex(255, 0, 0, 0)).toBe("#ff000000");
});
it("includes alpha for near-opaque (0.99)", () => {
// 0.99 * 255 = 252.45 -> rounds to 252 = 0xfc
expect(rgbToHex(255, 0, 0, 0.99)).toBe("#ff0000fc");
});
it("pads alpha with leading zero when needed", () => {
// 0.05 * 255 = 12.75 -> rounds to 13 = 0x0d
expect(rgbToHex(255, 0, 0, 0.05)).toBe("#ff00000d");
});
});
});
+347 -59
View File
@@ -1,8 +1,167 @@
import oc from "open-color";
import tinycolor from "tinycolor2";
import type { Merge } from "./utility-types";
import { clamp } from "@excalidraw/math";
import { degreesToRadians } from "@excalidraw/math";
export const COLOR_OUTLINE_CONTRAST_THRESHOLD = 240;
import type { Degrees } from "@excalidraw/math";
// ---------------------------------------------------------------------------
// Dark mode color transformation
// ---------------------------------------------------------------------------
// Browser-only cache to avoid memory leaks on server
const DARK_MODE_COLORS_CACHE: Map<string, string> | null =
typeof window !== "undefined" ? new Map() : null;
const DARK_MODE_FILTER_INVERT_PERCENT = 93;
const DARK_MODE_FILTER_HUE_ROTATE_DEGREES = 180 as Degrees;
function cssHueRotate(
red: number,
green: number,
blue: number,
degrees: Degrees,
): { r: number; g: number; b: number } {
// normalize
const r = red / 255;
const g = green / 255;
const b = blue / 255;
// Convert degrees to radians
const a = degreesToRadians(degrees);
const c = Math.cos(a);
const s = Math.sin(a);
// rotation matrix
const matrix = [
0.213 + c * 0.787 - s * 0.213,
0.715 - c * 0.715 - s * 0.715,
0.072 - c * 0.072 + s * 0.928,
0.213 - c * 0.213 + s * 0.143,
0.715 + c * 0.285 + s * 0.14,
0.072 - c * 0.072 - s * 0.283,
0.213 - c * 0.213 - s * 0.787,
0.715 - c * 0.715 + s * 0.715,
0.072 + c * 0.928 + s * 0.072,
];
// transform
const newR = r * matrix[0] + g * matrix[1] + b * matrix[2];
const newG = r * matrix[3] + g * matrix[4] + b * matrix[5];
const newB = r * matrix[6] + g * matrix[7] + b * matrix[8];
// clamp the values to [0, 1] range and convert back to [0, 255]
return {
r: Math.round(Math.max(0, Math.min(1, newR)) * 255),
g: Math.round(Math.max(0, Math.min(1, newG)) * 255),
b: Math.round(Math.max(0, Math.min(1, newB)) * 255),
};
}
const cssInvert = (
r: number,
g: number,
b: number,
percent: number,
): { r: number; g: number; b: number } => {
const p = clamp(percent, 0, 100) / 100;
// Function to invert a single color component
const invertComponent = (color: number): number => {
// Apply the invert formula
const inverted = color * (1 - p) + (255 - color) * p;
// Round to the nearest integer and clamp to [0, 255]
return Math.round(clamp(inverted, 0, 255));
};
// Calculate the inverted RGB components
const invertedR = invertComponent(r);
const invertedG = invertComponent(g);
const invertedB = invertComponent(b);
return { r: invertedR, g: invertedG, b: invertedB };
};
export const applyDarkModeFilter = (color: string, enable = true): string => {
if (!enable) {
return color;
}
const cached = DARK_MODE_COLORS_CACHE?.get(color);
if (cached) {
return cached;
}
const tc = tinycolor(color);
const alpha = tc.getAlpha();
// order of operations matters
// (corresponds to "filter: invert(invertPercent) hue-rotate(hueDegrees)" in css)
const rgb = tc.toRgb();
const inverted = cssInvert(
rgb.r,
rgb.g,
rgb.b,
DARK_MODE_FILTER_INVERT_PERCENT,
);
const rotated = cssHueRotate(
inverted.r,
inverted.g,
inverted.b,
DARK_MODE_FILTER_HUE_ROTATE_DEGREES,
);
const result = rgbToHex(rotated.r, rotated.g, rotated.b, alpha);
if (DARK_MODE_COLORS_CACHE) {
DARK_MODE_COLORS_CACHE.set(color, result);
}
return result;
};
const _reverseDarkModeInvert = (
r: number,
g: number,
b: number,
): { r: number; g: number; b: number } => {
const p = DARK_MODE_FILTER_INVERT_PERCENT / 100;
const denominator = 1 - 2 * p;
const restore = (color: number) =>
Math.round(clamp((color - 255 * p) / denominator, 0, 255));
return {
r: restore(r),
g: restore(g),
b: restore(b),
};
};
export const removeDarkModeFilter = (color: string): string => {
const tc = tinycolor(color);
const alpha = tc.getAlpha();
const rgb = tc.toRgb();
// 180deg hue rotation is its own inverse, so undo it before the inversion.
const rotatedBack = cssHueRotate(
rgb.r,
rgb.g,
rgb.b,
DARK_MODE_FILTER_HUE_ROTATE_DEGREES,
);
const restored = _reverseDarkModeInvert(
rotatedBack.r,
rotatedBack.g,
rotatedBack.b,
);
return rgbToHex(restored.r, restored.g, restored.b, alpha);
};
// ---------------------------------------------------------------------------
// Color palette
// ---------------------------------------------------------------------------
// FIXME can't put to utils.ts rn because of circular dependency
const pick = <R extends Record<string, any>, K extends readonly (keyof R)[]>(
@@ -17,15 +176,7 @@ const pick = <R extends Record<string, any>, K extends readonly (keyof R)[]>(
}, {} as Pick<R, K[number]>) as Pick<R, K[number]>;
};
export type ColorPickerColor =
| Exclude<keyof oc, "indigo" | "lime">
| "transparent"
| "bronze";
export type ColorTuple = readonly [string, string, string, string, string];
export type ColorPalette = Merge<
Record<ColorPickerColor, ColorTuple>,
{ black: "#1e1e1e"; white: "#ffffff"; transparent: "transparent" }
>;
// used general type instead of specific type (ColorPalette) to support custom colors
export type ColorPaletteCustom = { [key: string]: ColorTuple | string };
@@ -38,38 +189,30 @@ export const DEFAULT_CHART_COLOR_INDEX = 4;
export const DEFAULT_ELEMENT_STROKE_COLOR_INDEX = 4;
export const DEFAULT_ELEMENT_BACKGROUND_COLOR_INDEX = 1;
export const ELEMENTS_PALETTE_SHADE_INDEXES = [0, 2, 4, 6, 8] as const;
export const CANVAS_PALETTE_SHADE_INDEXES = [0, 1, 2, 3, 4] as const;
export const getSpecificColorShades = (
color: Exclude<
ColorPickerColor,
"transparent" | "white" | "black" | "bronze"
>,
indexArr: Readonly<ColorShadesIndexes>,
) => {
return indexArr.map((index) => oc[color][index]) as any as ColorTuple;
};
export const COLOR_PALETTE = {
transparent: "transparent",
black: "#1e1e1e",
white: "#ffffff",
// open-colors
gray: getSpecificColorShades("gray", ELEMENTS_PALETTE_SHADE_INDEXES),
red: getSpecificColorShades("red", ELEMENTS_PALETTE_SHADE_INDEXES),
pink: getSpecificColorShades("pink", ELEMENTS_PALETTE_SHADE_INDEXES),
grape: getSpecificColorShades("grape", ELEMENTS_PALETTE_SHADE_INDEXES),
violet: getSpecificColorShades("violet", ELEMENTS_PALETTE_SHADE_INDEXES),
blue: getSpecificColorShades("blue", ELEMENTS_PALETTE_SHADE_INDEXES),
cyan: getSpecificColorShades("cyan", ELEMENTS_PALETTE_SHADE_INDEXES),
teal: getSpecificColorShades("teal", ELEMENTS_PALETTE_SHADE_INDEXES),
green: getSpecificColorShades("green", ELEMENTS_PALETTE_SHADE_INDEXES),
yellow: getSpecificColorShades("yellow", ELEMENTS_PALETTE_SHADE_INDEXES),
orange: getSpecificColorShades("orange", ELEMENTS_PALETTE_SHADE_INDEXES),
// radix bronze shades 3,5,7,9,11
// open-color from https://github.com/yeun/open-color/blob/master/open-color.js
// corresponds to indexes [0,2,4,6,8] (weights: 50, 200, 400, 600, 800)
gray: ["#f8f9fa", "#e9ecef", "#ced4da", "#868e96", "#343a40"],
red: ["#fff5f5", "#ffc9c9", "#ff8787", "#fa5252", "#e03131"],
pink: ["#fff0f6", "#fcc2d7", "#f783ac", "#e64980", "#c2255c"],
grape: ["#f8f0fc", "#eebefa", "#da77f2", "#be4bdb", "#9c36b5"],
violet: ["#f3f0ff", "#d0bfff", "#9775fa", "#7950f2", "#6741d9"],
blue: ["#e7f5ff", "#a5d8ff", "#4dabf7", "#228be6", "#1971c2"],
cyan: ["#e3fafc", "#99e9f2", "#3bc9db", "#15aabf", "#0c8599"],
teal: ["#e6fcf5", "#96f2d7", "#38d9a9", "#12b886", "#099268"],
green: ["#ebfbee", "#b2f2bb", "#69db7c", "#40c057", "#2f9e44"],
yellow: ["#fff9db", "#ffec99", "#ffd43b", "#fab005", "#f08c00"],
orange: ["#fff4e6", "#ffd8a8", "#ffa94d", "#fd7e14", "#e8590c"],
// radix bronze shades [3,5,7,9,11]
bronze: ["#f8f1ee", "#eaddd7", "#d2bab0", "#a18072", "#846358"],
} as ColorPalette;
} as const;
export type ColorPalette = typeof COLOR_PALETTE;
export type ColorPickerColor = keyof typeof COLOR_PALETTE;
const COMMON_ELEMENT_SHADES = pick(COLOR_PALETTE, [
"cyan",
@@ -84,10 +227,14 @@ const COMMON_ELEMENT_SHADES = pick(COLOR_PALETTE, [
"red",
]);
// -----------------------------------------------------------------------------
// quick picks defaults
// -----------------------------------------------------------------------------
/** number of slots in the color-picker top-picks strip — the strip layout is
* sized for exactly this many swatches, and pick customization (replace /
* reorder) preserves it */
export const COLOR_TOP_PICKS_SLOTS = 5;
// ORDER matters for positioning in quick picker
export const DEFAULT_ELEMENT_STROKE_PICKS = [
COLOR_PALETTE.black,
@@ -106,6 +253,33 @@ export const DEFAULT_ELEMENT_BACKGROUND_PICKS = [
COLOR_PALETTE.yellow[DEFAULT_ELEMENT_BACKGROUND_COLOR_INDEX],
] as ColorTuple;
// ORDER matters for positioning in quick picker.
// Same as element background picks, with `transparent` (a no-op fill for the
// bucket tool) swapped for white.
export const BUCKET_FILL_BACKGROUND_PICKS = [
COLOR_PALETTE.white,
COLOR_PALETTE.red[DEFAULT_ELEMENT_BACKGROUND_COLOR_INDEX],
COLOR_PALETTE.green[DEFAULT_ELEMENT_BACKGROUND_COLOR_INDEX],
COLOR_PALETTE.blue[DEFAULT_ELEMENT_BACKGROUND_COLOR_INDEX],
COLOR_PALETTE.yellow[DEFAULT_ELEMENT_BACKGROUND_COLOR_INDEX],
] as ColorTuple;
// sticky notes are their own color domain (own defaults, own picks)
export const DEFAULT_STICKY_NOTE_BG = "#ffdf6b";
export const STICKY_NOTE_STROKE_PICKS: ColorTuple =
DEFAULT_ELEMENT_STROKE_PICKS;
// ORDER matters for positioning in quick picker. Classic note colors; the
// first one is `DEFAULT_STICKY_NOTE_BG`. Never transparent.
export const STICKY_NOTE_BACKGROUND_PICKS = [
DEFAULT_STICKY_NOTE_BG,
COLOR_PALETTE.pink[1],
COLOR_PALETTE.green[1],
COLOR_PALETTE.blue[1],
COLOR_PALETTE.orange[1],
] as ColorTuple;
// ORDER matters for positioning in quick picker
export const DEFAULT_CANVAS_BACKGROUND_PICKS = [
COLOR_PALETTE.white,
@@ -119,7 +293,6 @@ export const DEFAULT_CANVAS_BACKGROUND_PICKS = [
"#fdf8f6",
] as ColorTuple;
// -----------------------------------------------------------------------------
// palette defaults
// -----------------------------------------------------------------------------
@@ -145,29 +318,144 @@ export const DEFAULT_ELEMENT_BACKGROUND_COLOR_PALETTE = {
...COMMON_ELEMENT_SHADES,
} as const;
// -----------------------------------------------------------------------------
// helpers
// color palette helpers
// -----------------------------------------------------------------------------
// !!!MUST BE WITHOUT GRAY, TRANSPARENT AND BLACK!!!
export const getAllColorsSpecificShade = (index: 0 | 1 | 2 | 3 | 4) =>
[
// 2nd row
COLOR_PALETTE.cyan[index],
COLOR_PALETTE.blue[index],
COLOR_PALETTE.violet[index],
COLOR_PALETTE.grape[index],
COLOR_PALETTE.pink[index],
export const getAllColorsSpecificShade = (index: 0 | 1 | 2 | 3 | 4) => [
// 2nd row
COLOR_PALETTE.cyan[index],
COLOR_PALETTE.blue[index],
COLOR_PALETTE.violet[index],
COLOR_PALETTE.grape[index],
COLOR_PALETTE.pink[index],
// 3rd row
COLOR_PALETTE.green[index],
COLOR_PALETTE.teal[index],
COLOR_PALETTE.yellow[index],
COLOR_PALETTE.orange[index],
COLOR_PALETTE.red[index],
] as const;
export const rgbToHex = (r: number, g: number, b: number) =>
`#${((1 << 24) + (r << 16) + (g << 8) + b).toString(16).slice(1)}`;
// 3rd row
COLOR_PALETTE.green[index],
COLOR_PALETTE.teal[index],
COLOR_PALETTE.yellow[index],
COLOR_PALETTE.orange[index],
COLOR_PALETTE.red[index],
];
// -----------------------------------------------------------------------------
// other helpers
// -----------------------------------------------------------------------------
export const rgbToHex = (r: number, g: number, b: number, a?: number) => {
// (1 << 24) adds 0x1000000 to ensure the hex string is always 7 chars,
// then slice(1) removes the leading "1" to get exactly 6 hex digits
// e.g. rgb(0,0,0) -> 0x1000000 -> "1000000" -> "000000"
const hex6 = `#${((1 << 24) + (r << 16) + (g << 8) + b)
.toString(16)
.slice(1)}`;
if (a !== undefined && a < 1) {
// convert alpha from 0-1 float to 0-255 int, then to 2-digit hex
// e.g. 0.5 -> 128 -> "80"
const alphaHex = Math.round(a * 255)
.toString(16)
.padStart(2, "0");
return `${hex6}${alphaHex}`;
}
return hex6;
};
/**
* @returns the color with its alpha replaced by `alpha` (as #RRGGBBAA), or the
* input unchanged if it's not a valid color
*/
export const setColorAlpha = (color: string, alpha: number): string => {
const tc = tinycolor(color);
if (!tc.isValid()) {
return color;
}
const { r, g, b } = tc.toRgb();
return rgbToHex(r, g, b, alpha);
};
/**
* @returns #RRGGBB or #RRGGBBAA based on color containing non-opaque alpha,
* null if not valid color
*/
export const colorToHex = (color: string): string | null => {
const tc = tinycolor(color);
if (!tc.isValid()) {
return null;
}
const { r, g, b, a } = tc.toRgb();
return rgbToHex(r, g, b, a);
};
export const isTransparent = (color: string) => {
return tinycolor(color).getAlpha() === 0;
};
/**
* Whether the color is fully opaque (alpha exactly 1).
*
* NOT the negation of `isTransparent`, which is true only at alpha 0 — a
* partial-alpha color (`#RRGGBBAA`, `rgba(…, 0.5)`) is neither transparent
* (it renders) nor opaque (what's beneath shows through it).
*/
export const isOpaqueColor = (color: string) => {
return tinycolor(color).getAlpha() === 1;
};
// -----------------------------------------------------------------------------
// color contract helpers
// -----------------------------------------------------------------------------
export const COLOR_OUTLINE_CONTRAST_THRESHOLD = 240;
const calculateContrast = (r: number, g: number, b: number): number => {
const yiq = (r * 299 + g * 587 + b * 114) / 1000;
return yiq;
};
// YIQ algo, inspiration from https://stackoverflow.com/a/11868398
export const isColorDark = (color: string, threshold = 160): boolean => {
// no color ("") -> assume it default to black
if (!color) {
return true;
}
if (isTransparent(color)) {
return false;
}
const tc = tinycolor(color);
if (!tc.isValid()) {
// invalid color -> assume it defaults to black
return true;
}
const { r, g, b } = tc.toRgb();
return calculateContrast(r, g, b) < threshold;
};
// -----------------------------------------------------------------------------
// normalization
// -----------------------------------------------------------------------------
/**
* tries to keep the input color as-is if it's valid, making minimal adjustments
* (trimming whitespace or adding `#` to hex colors)
*/
export const normalizeInputColor = (color: string): string | null => {
color = color.trim();
if (isTransparent(color)) {
return color;
}
const tc = tinycolor(color);
if (tc.isValid()) {
// testing for `#` first fixes a bug on Electron (more specfically, an
// Obsidian popout window), where a hex color without `#` is considered valid
if (["hex", "hex8"].includes(tc.getFormat()) && !color.startsWith("#")) {
return `#${color}`;
}
return color;
}
return null;
};
+161 -41
View File
@@ -6,24 +6,7 @@ import type { AppProps, AppState } from "@excalidraw/excalidraw/types";
import { COLOR_PALETTE } from "./colors";
export const isDarwin = /Mac|iPod|iPhone|iPad/.test(navigator.platform);
export const isWindows = /^Win/.test(navigator.platform);
export const isAndroid = /\b(android)\b/i.test(navigator.userAgent);
export const isFirefox =
typeof window !== "undefined" &&
"netscape" in window &&
navigator.userAgent.indexOf("rv:") > 1 &&
navigator.userAgent.indexOf("Gecko") > 1;
export const isChrome = navigator.userAgent.indexOf("Chrome") !== -1;
export const isSafari =
!isChrome && navigator.userAgent.indexOf("Safari") !== -1;
export const isIOS =
/iPad|iPhone/.test(navigator.platform) ||
// iPadOS 13+
(navigator.userAgent.includes("Mac") && "ontouchend" in document);
// keeping function so it can be mocked in test
export const isBrave = () =>
(navigator as any).brave?.isBrave?.name === "isBrave";
export { DEFAULT_STICKY_NOTE_BG } from "./colors";
export const supportsResizeObserver =
typeof window !== "undefined" && "ResizeObserver" in window;
@@ -36,6 +19,7 @@ export const APP_NAME = "Excalidraw";
// (happens a lot with fast clicks with the text tool)
export const TEXT_AUTOWRAP_THRESHOLD = 36; // px
export const DRAGGING_THRESHOLD = 10; // px
export const MINIMUM_ARROW_SIZE = 20; // px
export const LINE_CONFIRM_THRESHOLD = 8; // px
export const ELEMENT_SHIFT_TRANSLATE_AMOUNT = 5;
export const ELEMENT_TRANSLATE_AMOUNT = 1;
@@ -87,6 +71,7 @@ export enum EVENT {
POINTER_MOVE = "pointermove",
POINTER_DOWN = "pointerdown",
POINTER_UP = "pointerup",
POINTER_CANCEL = "pointercancel",
STATE_CHANGE = "statechange",
WHEEL = "wheel",
TOUCH_START = "touchstart",
@@ -117,12 +102,23 @@ export const ENV = {
};
export const CLASSES = {
SIDEBAR: "sidebar",
SHAPE_ACTIONS_MENU: "App-menu__left",
ZOOM_ACTIONS: "zoom-actions",
SEARCH_MENU_INPUT_WRAPPER: "layer-ui__search-inputWrapper",
CONVERT_ELEMENT_TYPE_POPUP: "ConvertElementTypePopup",
SHAPE_ACTIONS_THEME_SCOPE: "shape-actions-theme-scope",
FRAME_NAME: "frame-name",
DROPDOWN_MENU_EVENT_WRAPPER: "dropdown-menu-event-wrapper",
};
export const FONT_SIZES = {
sm: 16,
md: 20,
lg: 28,
xl: 36,
} as const;
export const CJK_HAND_DRAWN_FALLBACK_FONT = "Xiaolai";
export const WINDOWS_EMOJI_FALLBACK_FONT = "Segoe UI Emoji";
@@ -147,19 +143,49 @@ export const FONT_FAMILY = {
Assistant: 10,
};
// Segoe UI Emoji fails to properly fallback for some glyphs: ∞, ∫, ≠
// so we need to have generic font fallback before it
export const SANS_SERIF_GENERIC_FONT = "sans-serif";
export const MONOSPACE_GENERIC_FONT = "monospace";
export const FONT_FAMILY_GENERIC_FALLBACKS = {
[SANS_SERIF_GENERIC_FONT]: 998,
[MONOSPACE_GENERIC_FONT]: 999,
};
export const FONT_FAMILY_FALLBACKS = {
[CJK_HAND_DRAWN_FALLBACK_FONT]: 100,
...FONT_FAMILY_GENERIC_FALLBACKS,
[WINDOWS_EMOJI_FALLBACK_FONT]: 1000,
};
export function getGenericFontFamilyFallback(
fontFamily: number,
): keyof typeof FONT_FAMILY_GENERIC_FALLBACKS {
switch (fontFamily) {
case FONT_FAMILY.Cascadia:
case FONT_FAMILY["Comic Shanns"]:
return MONOSPACE_GENERIC_FONT;
default:
return SANS_SERIF_GENERIC_FONT;
}
}
export const getFontFamilyFallbacks = (
fontFamily: number,
): Array<keyof typeof FONT_FAMILY_FALLBACKS> => {
const genericFallbackFont = getGenericFontFamilyFallback(fontFamily);
switch (fontFamily) {
case FONT_FAMILY.Excalifont:
return [CJK_HAND_DRAWN_FALLBACK_FONT, WINDOWS_EMOJI_FALLBACK_FONT];
return [
CJK_HAND_DRAWN_FALLBACK_FONT,
genericFallbackFont,
WINDOWS_EMOJI_FALLBACK_FONT,
];
default:
return [WINDOWS_EMOJI_FALLBACK_FONT];
return [genericFallbackFont, WINDOWS_EMOJI_FALLBACK_FONT];
}
};
@@ -168,6 +194,8 @@ export const THEME = {
DARK: "dark",
} as const;
export const DARK_THEME_FILTER = "invert(93%) hue-rotate(180deg)";
export const FRAME_STYLE = {
strokeColor: "#bbb" as ExcalidrawElement["strokeColor"],
strokeWidth: 2 as ExcalidrawElement["strokeWidth"],
@@ -186,6 +214,50 @@ export const FRAME_STYLE = {
export const MIN_FONT_SIZE = 1;
export const DEFAULT_FONT_SIZE = 20;
export const STICKY_NOTE_MIN_FONT_SIZE = 16;
export const STICKY_NOTE_MAX_FONT_SIZE = 512;
export const STICKY_NOTE_FALLBACK_FONT_SIZE = 28;
export const STICKY_NOTE_FONT_STEP = 2;
export const STICKY_NOTE_PADDING = 16;
/**
* The creation-date footer: a 20px text row under the label body plus a 12px
* gap above it, inside the note's bottom padding. Reserved for every note —
* also when `created` is unknown — so geometry never depends on data
* availability. The label is chosen by width bucket, never measured, so
* painting stays measurement-free (the server has no text measurer):
* `minBodyWidthForYear` is the worst case ("30 May 2026") in the system sans
* stack at 12px, with margin for wider fallbacks such as DejaVu Sans.
*/
/**
* The creation-date footer of a sticky note. The label body ends `height`
* above the note's bottom padding, and the date's baseline sits
* `baselineFromBottom` above the note's bottom edge — so the 12px glyphs
* (~9px above the baseline, ~3px below) end up roughly 11px from the edge
* with a ~13px gap to the label body above them.
*/
export const STICKY_NOTE_FOOTER = {
height: 20,
fontSize: 12,
fontFamily: "Helvetica, Arial, sans-serif",
baselineFromBottom: 14,
opacity: 1,
minBodyWidthForYear: 80,
} as const;
/**
* outer height → label body height: top + bottom padding + footer. The body
* is what the label is fitted into; a middle-aligned label is still centered
* in the whole padded note when it fits (see `computeBoundTextPosition`).
*/
export const STICKY_NOTE_BODY_INSET_Y =
STICKY_NOTE_PADDING * 2 + STICKY_NOTE_FOOTER.height;
export const DEFAULT_STICKY_NOTE_SIZE = 250;
// floor for a finalized note's width and base height; the UI floor is font-aware
// on top of it (see `getStickyNoteMinSize`)
export const STICKY_NOTE_MIN_SIZE = 75;
export const STICKY_NOTE_SHADOW_OFFSET = 3;
export const STICKY_NOTE_SHADOW_OPACITY = 0.16;
export const STICKY_NOTE_EDGE_SHADOW_WIDTH = 0.5;
export const STICKY_NOTE_EDGE_SHADOW_OPACITY = 0.08;
export const DEFAULT_FONT_FAMILY: FontFamilyValues = FONT_FAMILY.Excalifont;
export const DEFAULT_TEXT_ALIGN = "left";
export const DEFAULT_VERTICAL_ALIGN = "top";
@@ -221,13 +293,21 @@ export const IMAGE_MIME_TYPES = {
jfif: "image/jfif",
} as const;
export const MIME_TYPES = {
export const STRING_MIME_TYPES = {
text: "text/plain",
html: "text/html",
json: "application/json",
// excalidraw data
excalidraw: "application/vnd.excalidraw+json",
excalidrawClipboard: "application/vnd.excalidraw.clipboard+json",
// LEGACY: fully-qualified library JSON data
excalidrawlib: "application/vnd.excalidrawlib+json",
// list of excalidraw library item ids
excalidrawlibIds: "application/vnd.excalidrawlib.ids+json",
} as const;
export const MIME_TYPES = {
...STRING_MIME_TYPES,
// image-encoded excalidraw data
"excalidraw.svg": "image/svg+xml",
"excalidraw.png": "image/png",
@@ -276,9 +356,6 @@ export const IDLE_THRESHOLD = 60_000;
// Report a user active each ACTIVE_THRESHOLD milliseconds
export const ACTIVE_THRESHOLD = 3_000;
// duplicates --theme-filter, should be removed soon
export const THEME_FILTER = "invert(93%) hue-rotate(180deg)";
export const URL_QUERY_KEYS = {
addLibrary: "addLibrary",
} as const;
@@ -302,24 +379,15 @@ export const DEFAULT_UI_OPTIONS: AppProps["UIOptions"] = {
},
};
// breakpoints
// -----------------------------------------------------------------------------
// md screen
export const MQ_MAX_WIDTH_PORTRAIT = 730;
export const MQ_MAX_WIDTH_LANDSCAPE = 1000;
export const MQ_MAX_HEIGHT_LANDSCAPE = 500;
// sidebar
export const MQ_RIGHT_SIDEBAR_MIN_WIDTH = 1229;
// -----------------------------------------------------------------------------
export const MAX_DECIMALS_FOR_SVG_EXPORT = 2;
export const EXPORT_SCALES = [1, 2, 3];
export const DEFAULT_EXPORT_PADDING = 10; // px
export const DEFAULT_MAX_IMAGE_WIDTH_OR_HEIGHT = 1440;
export const MAX_ALLOWED_FILE_BYTES = 4 * 1024 * 1024;
export const DEFAULT_IMAGE_OPTIONS: AppProps["imageOptions"] = {
maxWidthOrHeight: 1440,
maxFileSizeBytes: 4 * 1024 * 1024,
};
export const SVG_NS = "http://www.w3.org/2000/svg";
export const SVG_DOCUMENT_PREAMBLE = `<?xml version="1.0" standalone="no"?>
@@ -350,6 +418,7 @@ export const TEXT_ALIGN = {
};
export const ELEMENT_READY_TO_ERASE_OPACITY = 20;
export const ELEMENT_PENDING_DRAW_SHAPE_OPACITY = 70;
// Radius represented as 25% of element's largest side (width/height).
// Used for LEGACY and PROPORTIONAL_RADIUS algorithms, or when the element is
@@ -383,11 +452,47 @@ export const ROUGHNESS = {
cartoonist: 2,
} as const;
export const STROKE_WIDTH = {
export type StrokeWidthKey = "thin" | "medium" | "bold";
export const STROKE_WIDTH_KEYS: readonly StrokeWidthKey[] = [
"thin",
"medium",
"bold",
];
export const STROKE_WIDTH: Readonly<
Record<StrokeWidthKey | "extraBold", ExcalidrawElement["strokeWidth"]>
> = {
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,
extraBold: 4,
} as const;
extraBold: 4, // legacy (may be used again in the future)
};
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: {
strokeColor: ExcalidrawElement["strokeColor"];
@@ -402,7 +507,7 @@ export const DEFAULT_ELEMENT_PROPS: {
strokeColor: COLOR_PALETTE.black,
backgroundColor: COLOR_PALETTE.transparent,
fillStyle: "solid",
strokeWidth: 2,
strokeWidth: STROKE_WIDTH[DEFAULT_ELEMENT_STROKE_WIDTH_KEY],
strokeStyle: "solid",
roughness: ROUGHNESS.artist,
opacity: 100,
@@ -439,8 +544,11 @@ export const TOOL_TYPE = {
hand: "hand",
frame: "frame",
magicframe: "magicframe",
stickynote: "stickynote",
embeddable: "embeddable",
laser: "laser",
autoshape: "autoshape",
bucketfill: "bucketfill",
} as const;
export const EDITOR_LS_KEYS = {
@@ -484,3 +592,15 @@ export enum UserIdleState {
* the start and end points)
*/
export const LINE_POLYGON_POINT_MERGE_DISTANCE = 20;
export const DOUBLE_TAP_POSITION_THRESHOLD = 35;
export const BIND_MODE_TIMEOUT = 700; // ms
// glass background for mobile action buttons
export const MOBILE_ACTION_BUTTON_BG = {
background: "var(--mobile-action-button-bg)",
} as const;
export const DEFAULT_STROKE_STREAMLINE = 0.5;
export const DEFAULT_STROKE_STREAMLINE_PRECISE = 0.2;
+222
View File
@@ -0,0 +1,222 @@
export type StylesPanelMode = "compact" | "full" | "mobile";
export type EditorInterface = Readonly<{
formFactor: "phone" | "tablet" | "desktop";
desktopUIMode: "compact" | "full";
userAgent: Readonly<{
isMobileDevice: boolean;
platform: "ios" | "android" | "other" | "unknown";
}>;
isTouchScreen: boolean;
canFitSidebar: boolean;
isLandscape: boolean;
}>;
// storage key
const DESKTOP_UI_MODE_STORAGE_KEY = "excalidraw.desktopUIMode";
// breakpoints
export const MQ_MAX_MOBILE = 599;
export const MQ_MAX_WIDTH_LANDSCAPE = 1000;
export const MQ_MAX_HEIGHT_LANDSCAPE = 500;
// tablets
export const MQ_MIN_TABLET = MQ_MAX_MOBILE + 1; // lower bound (excludes phones)
export const MQ_MAX_TABLET = 1180; // ipad air
// desktop/laptop (NOTE: not used for form factor detection)
export const MQ_MIN_WIDTH_DESKTOP = 1440;
// sidebar
export const MQ_RIGHT_SIDEBAR_MIN_WIDTH = 1229;
// -----------------------------------------------------------------------------
// user agent detections
export const isDarwin = /Mac|iPod|iPhone|iPad/.test(navigator.platform);
export const isWindows = /^Win/.test(navigator.platform);
export const isAndroid = /\b(android)\b/i.test(navigator.userAgent);
export const isFirefox =
typeof window !== "undefined" &&
"netscape" in window &&
navigator.userAgent.indexOf("rv:") > 1 &&
navigator.userAgent.indexOf("Gecko") > 1;
export const isChrome = navigator.userAgent.indexOf("Chrome") !== -1;
export const isSafari =
!isChrome && navigator.userAgent.indexOf("Safari") !== -1;
export const isIOS =
/iPad|iPhone/i.test(navigator.platform) ||
// iPadOS 13+
(navigator.userAgent.includes("Mac") && "ontouchend" in document);
// keeping function so it can be mocked in test
export const isBrave = () =>
(navigator as any).brave?.isBrave?.name === "isBrave";
// export const isMobile =
// isIOS ||
// /android|webos|ipod|blackberry|iemobile|opera mini/i.test(
// navigator.userAgent,
// ) ||
// /android|ios|ipod|blackberry|windows phone/i.test(navigator.platform);
// utilities
export const isMobileBreakpoint = (width: number, height: number) => {
return (
width <= MQ_MAX_MOBILE ||
(height < MQ_MAX_HEIGHT_LANDSCAPE && width < MQ_MAX_WIDTH_LANDSCAPE)
);
};
export const isTabletBreakpoint = (
editorWidth: number,
editorHeight: number,
) => {
const minSide = Math.min(editorWidth, editorHeight);
const maxSide = Math.max(editorWidth, editorHeight);
return minSide >= MQ_MIN_TABLET && maxSide <= MQ_MAX_TABLET;
};
const isMobileOrTablet = (): boolean => {
const ua = navigator.userAgent || "";
const platform = navigator.platform || "";
const uaData = (navigator as any).userAgentData as
| { mobile?: boolean; platform?: string }
| undefined;
// --- 1) chromium: prefer ua client hints -------------------------------
if (uaData) {
const plat = (uaData.platform || "").toLowerCase();
const isDesktopOS =
plat === "windows" ||
plat === "macos" ||
plat === "linux" ||
plat === "chrome os";
if (uaData.mobile === true) {
return true;
}
if (uaData.mobile === false && plat === "android") {
const looksTouchTablet =
matchMedia?.("(hover: none)").matches &&
matchMedia?.("(pointer: coarse)").matches;
return looksTouchTablet;
}
if (isDesktopOS) {
return false;
}
}
// --- 2) ios (includes ipad) --------------------------------------------
if (isIOS) {
return true;
}
// --- 3) android legacy ua fallback -------------------------------------
if (isAndroid) {
const isAndroidPhone = /Mobile/i.test(ua);
const isAndroidTablet = !isAndroidPhone;
if (isAndroidPhone || isAndroidTablet) {
const looksTouchTablet =
matchMedia?.("(hover: none)").matches &&
matchMedia?.("(pointer: coarse)").matches;
return looksTouchTablet;
}
}
// --- 4) last resort desktop exclusion ----------------------------------
const looksDesktopPlatform =
/Win|Linux|CrOS|Mac/.test(platform) ||
/Windows NT|X11|CrOS|Macintosh/.test(ua);
if (looksDesktopPlatform) {
return false;
}
return false;
};
export const getFormFactor = (
editorWidth: number,
editorHeight: number,
): EditorInterface["formFactor"] => {
if (isMobileBreakpoint(editorWidth, editorHeight)) {
return "phone";
}
if (isTabletBreakpoint(editorWidth, editorHeight)) {
return "tablet";
}
return "desktop";
};
export const deriveStylesPanelMode = (
editorInterface: EditorInterface,
): StylesPanelMode => {
if (editorInterface.formFactor === "phone") {
return "mobile";
}
if (editorInterface.formFactor === "tablet") {
return "compact";
}
return editorInterface.desktopUIMode;
};
export const createUserAgentDescriptor = (
userAgentString: string,
): EditorInterface["userAgent"] => {
const normalizedUA = userAgentString ?? "";
let platform: EditorInterface["userAgent"]["platform"] = "unknown";
if (isIOS) {
platform = "ios";
} else if (isAndroid) {
platform = "android";
} else if (normalizedUA) {
platform = "other";
}
return {
isMobileDevice: isMobileOrTablet(),
platform,
} as const;
};
export const loadDesktopUIModePreference = () => {
if (typeof window === "undefined") {
return null;
}
try {
const stored = window.localStorage.getItem(DESKTOP_UI_MODE_STORAGE_KEY);
if (stored === "compact" || stored === "full") {
return stored as EditorInterface["desktopUIMode"];
}
} catch (error) {
// ignore storage access issues (e.g., Safari private mode)
}
return null;
};
const persistDesktopUIMode = (mode: EditorInterface["desktopUIMode"]) => {
if (typeof window === "undefined") {
return;
}
try {
window.localStorage.setItem(DESKTOP_UI_MODE_STORAGE_KEY, mode);
} catch (error) {
// ignore storage access issues (e.g., Safari private mode)
}
};
export const setDesktopUIMode = (mode: EditorInterface["desktopUIMode"]) => {
if (mode !== "compact" && mode !== "full") {
return;
}
persistDesktopUIMode(mode);
return mode;
};
+5
View File
@@ -1,4 +1,5 @@
export * from "./binary-heap";
export * from "./bounds";
export * from "./colors";
export * from "./constants";
export * from "./font-metadata";
@@ -10,3 +11,7 @@ export * from "./random";
export * from "./url";
export * from "./utils";
export * from "./emitter";
export * from "./appEventBus";
export * from "./editorInterface";
export * from "./versionedSnapshotStore";
export { Debug } from "../debug";
+3 -1
View File
@@ -1,4 +1,4 @@
import { isDarwin } from "./constants";
import { isDarwin } from "./editorInterface";
import type { ValueOf } from "./utility-types";
@@ -51,6 +51,7 @@ export const KEYS = {
SLASH: "/",
A: "a",
B: "b",
C: "c",
D: "d",
E: "e",
@@ -59,6 +60,7 @@ export const KEYS = {
H: "h",
I: "i",
L: "l",
N: "n",
O: "o",
P: "p",
Q: "q",
+5 -4
View File
@@ -1,4 +1,5 @@
import {
pointFrom,
pointFromPair,
type GlobalPoint,
type LocalPoint,
@@ -69,12 +70,12 @@ export const getGridPoint = (
x: number,
y: number,
gridSize: NullableGridSize,
): [number, number] => {
): GlobalPoint => {
if (gridSize) {
return [
return pointFrom<GlobalPoint>(
Math.round(x / gridSize) * gridSize,
Math.round(y / gridSize) * gridSize,
];
);
}
return [x, y];
return pointFrom<GlobalPoint>(x, y);
};
+17
View File
@@ -14,3 +14,20 @@ export const reseed = (seed: number) => {
};
export const randomId = () => (isTestEnv() ? `id${testIdBase++}` : nanoid());
/**
* Deterministic PRNG (mulberry32) for a given seed: same seed, same
* sequence — used where a render must be stable across frames and clients
* (e.g. the sticky note's corner jitter) and roughjs's `Random` is
* unsuitable (it degenerates for seed 0). Returns numbers in [0, 1).
*/
export const seededRandom = (seed: number) => {
let value = seed >>> 0;
return () => {
value += 0x6d2b79f5;
let next = value;
next = Math.imul(next ^ (next >>> 15), next | 1);
next ^= next + Math.imul(next ^ (next >>> 7), next | 61);
return ((next ^ (next >>> 14)) >>> 0) / 4294967296;
};
};
+122
View File
@@ -1,10 +1,49 @@
import {
getNearestScrollableContainer,
isInputLike,
isInteractive,
isToolIcon,
isTransparent,
isWritableElement,
mapFind,
reduceToCommonValue,
} 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("cross-document element guards", () => {
it("uses constructors from the target element's window", () => {
const iframe = document.createElement("iframe");
document.body.append(iframe);
const iframeDocument = iframe.contentDocument!;
const iframeWindow = iframe.contentWindow!;
const textarea = iframeDocument.createElement("textarea");
const button = iframeDocument.createElement("button");
const toolIcon = iframeDocument.createElement("div");
toolIcon.className = "ToolIcon__icon";
expect(textarea).not.toBeInstanceOf(HTMLTextAreaElement);
expect(textarea).toBeInstanceOf(
(iframeWindow as Window & typeof globalThis).HTMLTextAreaElement,
);
expect(isInputLike(textarea)).toBe(true);
expect(isWritableElement(textarea)).toBe(true);
expect(isInteractive(button)).toBe(true);
expect(isToolIcon(toolIcon)).toBe(true);
iframeDocument.body.append(textarea);
expect(getNearestScrollableContainer(textarea)).toBe(iframeDocument);
iframe.remove();
});
});
describe("isTransparent()", () => {
it("should return true when color is rgb transparent", () => {
expect(isTransparent("#ff00")).toEqual(true);
@@ -79,4 +118,87 @@ describe("@excalidraw/common/utils", () => {
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();
});
});
});
+182 -245
View File
@@ -1,11 +1,8 @@
import { average, pointFrom, type GlobalPoint } from "@excalidraw/math";
import { average } from "@excalidraw/math";
import type {
ExcalidrawBindableElement,
FontFamilyValues,
FontString,
ExcalidrawElement,
} from "@excalidraw/element/types";
import type { GlobalCoord } from "@excalidraw/math";
import type { FontFamilyValues, FontString } from "@excalidraw/element/types";
import type {
ActiveTool,
@@ -15,13 +12,11 @@ import type {
Zoom,
} from "@excalidraw/excalidraw/types";
import { COLOR_PALETTE } from "./colors";
import {
DEFAULT_VERSION,
ENV,
FONT_FAMILY,
getFontFamilyFallbacks,
isDarwin,
WINDOWS_EMOJI_FALLBACK_FONT,
} from "./constants";
@@ -53,10 +48,23 @@ export const getDateTime = () => {
export const capitalizeString = (str: string) =>
str.charAt(0).toUpperCase() + str.slice(1);
const getTargetWindow = (
target: Element | EventTarget | null,
): (Window & typeof globalThis) | null =>
(target as (EventTarget & { ownerDocument?: Document | null }) | null)
?.ownerDocument?.defaultView ??
(typeof window === "undefined" ? null : window);
export const isToolIcon = (
target: Element | EventTarget | null,
): target is HTMLElement =>
target instanceof HTMLElement && target.className.includes("ToolIcon");
): target is HTMLElement => {
const targetWindow = getTargetWindow(target);
return (
!!targetWindow &&
target instanceof targetWindow.HTMLElement &&
target.className.includes("ToolIcon")
);
};
export const isInputLike = (
target: Element | EventTarget | null,
@@ -65,17 +73,26 @@ export const isInputLike = (
| HTMLTextAreaElement
| HTMLSelectElement
| HTMLBRElement
| HTMLDivElement =>
(target instanceof HTMLElement && target.dataset.type === "wysiwyg") ||
target instanceof HTMLBRElement || // newline in wysiwyg
target instanceof HTMLInputElement ||
target instanceof HTMLTextAreaElement ||
target instanceof HTMLSelectElement;
| HTMLDivElement => {
const targetWindow = getTargetWindow(target);
return (
!!targetWindow &&
((target instanceof targetWindow.HTMLElement &&
target.dataset.type === "wysiwyg") ||
target instanceof targetWindow.HTMLBRElement || // newline in wysiwyg
target instanceof targetWindow.HTMLInputElement ||
target instanceof targetWindow.HTMLTextAreaElement ||
target instanceof targetWindow.HTMLSelectElement)
);
};
export const isInteractive = (target: Element | EventTarget | null) => {
const targetWindow = getTargetWindow(target);
return (
isInputLike(target) ||
(target instanceof Element && !!target.closest("label, button"))
(!!targetWindow &&
target instanceof targetWindow.Element &&
!!target.closest("label, button"))
);
};
@@ -85,14 +102,23 @@ export const isWritableElement = (
| HTMLInputElement
| HTMLTextAreaElement
| HTMLBRElement
| HTMLDivElement =>
(target instanceof HTMLElement && target.dataset.type === "wysiwyg") ||
target instanceof HTMLBRElement || // newline in wysiwyg
target instanceof HTMLTextAreaElement ||
(target instanceof HTMLInputElement &&
(target.type === "text" ||
target.type === "number" ||
target.type === "password"));
| HTMLDivElement => {
const targetWindow = getTargetWindow(target);
return (
!!targetWindow &&
((target instanceof targetWindow.HTMLElement &&
target.dataset.type === "wysiwyg") ||
target instanceof targetWindow.HTMLBRElement || // newline in wysiwyg
target instanceof targetWindow.HTMLTextAreaElement ||
(target instanceof targetWindow.HTMLInputElement &&
(target.type === "text" ||
target.type === "number" ||
target.type === "password" ||
target.type === "search")) ||
(target instanceof targetWindow.HTMLElement &&
target.closest(".cm-editor") !== null))
);
};
export const getFontFamilyString = ({
fontFamily,
@@ -101,7 +127,6 @@ export const getFontFamilyString = ({
}) => {
for (const [fontFamilyString, id] of Object.entries(FONT_FAMILY)) {
if (id === fontFamily) {
// TODO: we should fallback first to generic family names first
return `${fontFamilyString}${getFontFamilyFallbacks(id)
.map((x) => `, ${x}`)
.join("")}`;
@@ -121,6 +146,11 @@ export const getFontString = ({
return `${fontSize}px ${getFontFamilyString({ fontFamily })}` as FontString;
};
/** executes callback in the frame that's after the current one */
export const nextAnimationFrame = async (cb: () => any) => {
requestAnimationFrame(() => requestAnimationFrame(cb));
};
export const debounce = <T extends any[]>(
fn: (...args: T) => void,
timeout: number,
@@ -150,38 +180,27 @@ export const debounce = <T extends any[]>(
return ret;
};
// throttle callback to execute once per animation frame
export const throttleRAF = <T extends any[]>(
fn: (...args: T) => void,
opts?: { trailing?: boolean },
) => {
// throttle callback to execute once per animation frame using the latest args
export const throttleRAF = <T extends any[]>(fn: (...args: T) => void) => {
let timerId: number | null = null;
let lastArgs: T | null = null;
let lastArgsTrailing: T | null = null;
const scheduleFunc = (args: T) => {
const scheduleFunc = () => {
timerId = window.requestAnimationFrame(() => {
timerId = null;
fn(...args);
const args = lastArgs;
lastArgs = null;
if (lastArgsTrailing) {
lastArgs = lastArgsTrailing;
lastArgsTrailing = null;
scheduleFunc(lastArgs);
if (args) {
fn(...args);
}
});
};
const ret = (...args: T) => {
if (isTestEnv()) {
fn(...args);
return;
}
lastArgs = args;
if (timerId === null) {
scheduleFunc(lastArgs);
} else if (opts?.trailing) {
lastArgsTrailing = args;
scheduleFunc();
}
};
ret.flush = () => {
@@ -190,12 +209,12 @@ export const throttleRAF = <T extends any[]>(
timerId = null;
}
if (lastArgs) {
fn(...(lastArgsTrailing || lastArgs));
lastArgs = lastArgsTrailing = null;
fn(...lastArgs);
lastArgs = null;
}
};
ret.cancel = () => {
lastArgs = lastArgsTrailing = null;
lastArgs = null;
if (timerId !== null) {
cancelAnimationFrame(timerId);
timerId = null;
@@ -214,135 +233,6 @@ export const easeOut = (k: number) => {
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
export const chunk = <T extends any>(
array: readonly T[],
@@ -379,6 +269,10 @@ export const removeSelection = () => {
export const distance = (x: number, y: number) => Math.abs(x - y);
export const isSelectionLikeTool = (type: ToolType | "custom") => {
return type === "selection" || type === "lasso";
};
export const updateActiveTool = (
appState: Pick<AppState, "activeTool">,
data: ((
@@ -387,7 +281,7 @@ export const updateActiveTool = (
}
| { type: "custom"; customType: string }
) & { locked?: boolean; fromSelection?: boolean }) & {
lastActiveToolBeforeEraser?: ActiveTool | null;
lastActiveTool?: ActiveTool | null;
},
): AppState["activeTool"] => {
if (data.type === "custom") {
@@ -402,9 +296,9 @@ export const updateActiveTool = (
return {
...appState.activeTool,
lastActiveTool:
data.lastActiveToolBeforeEraser === undefined
data.lastActiveTool === undefined
? appState.activeTool.lastActiveTool
: data.lastActiveToolBeforeEraser,
: data.lastActiveTool,
type: data.type,
customType: null,
locked: data.locked ?? appState.activeTool.locked,
@@ -420,19 +314,6 @@ export const allowFullScreen = () =>
export const exitFullScreen = () => document.exitFullscreen();
export const getShortcutKey = (shortcut: string): string => {
shortcut = shortcut
.replace(/\bAlt\b/i, "Alt")
.replace(/\bShift\b/i, "Shift")
.replace(/\b(Enter|Return)\b/i, "Enter");
if (isDarwin) {
return shortcut
.replace(/\bCtrlOrCmd\b/gi, "Cmd")
.replace(/\bAlt\b/i, "Option");
}
return shortcut.replace(/\bCtrlOrCmd\b/gi, "Ctrl");
};
export const viewportCoordsToSceneCoords = (
{ clientX, clientY }: { clientX: number; clientY: number },
{
@@ -452,7 +333,7 @@ export const viewportCoordsToSceneCoords = (
const x = (clientX - offsetLeft) / zoom.value - scrollX;
const y = (clientY - offsetTop) / zoom.value - scrollY;
return { x, y };
return { x, y } as GlobalCoord;
};
export const sceneCoordsToViewportCoords = (
@@ -558,19 +439,6 @@ export const mapFind = <T, K>(
return undefined;
};
export const isTransparent = (color: string) => {
const isRGBTransparent = color.length === 5 && color.substr(4, 1) === "0";
const isRRGGBBTransparent = color.length === 9 && color.substr(7, 2) === "00";
return (
isRGBTransparent ||
isRRGGBBTransparent ||
color === COLOR_PALETTE.transparent
);
};
export const isBindingFallthroughEnabled = (el: ExcalidrawBindableElement) =>
el.fillStyle !== "solid" || isTransparent(el.backgroundColor);
export type ResolvablePromise<T> = Promise<T> & {
resolve: [T] extends [undefined]
? (value?: MaybePromise<Awaited<T>>) => void
@@ -636,12 +504,14 @@ export const supportsEmoji = () => {
export const getNearestScrollableContainer = (
element: HTMLElement,
): HTMLElement | Document => {
const ownerDocument = element.ownerDocument;
const ownerWindow = ownerDocument.defaultView ?? window;
let parent = element.parentElement;
while (parent) {
if (parent === document.body) {
return document;
if (parent === ownerDocument.body) {
return ownerDocument;
}
const { overflowY } = window.getComputedStyle(parent);
const { overflowY } = ownerWindow.getComputedStyle(parent);
const hasScrollableContent = parent.scrollHeight > parent.clientHeight;
if (
hasScrollableContent &&
@@ -653,7 +523,7 @@ export const getNearestScrollableContainer = (
}
parent = parent.parentElement;
}
return document;
return ownerDocument;
};
export const focusNearestParent = (element: HTMLInputElement) => {
@@ -712,8 +582,8 @@ export const arrayToObject = <T>(
array: readonly T[],
groupBy?: (value: T) => string | number,
) =>
array.reduce((acc, value) => {
acc[groupBy ? groupBy(value) : String(value)] = value;
array.reduce((acc, value, idx) => {
acc[groupBy ? groupBy(value) : idx] = value;
return acc;
}, {} as { [key: string]: T });
@@ -1170,39 +1040,69 @@ export const normalizeEOL = (str: string) => {
};
// -----------------------------------------------------------------------------
type HasBrand<T> = {
export type HasBrand<T> = {
// eslint-disable-next-line @typescript-eslint/no-unused-vars
[K in keyof T]: K extends `~brand${infer _}` ? true : never;
[K in keyof T]: K extends `~brand${infer _}` | "_brand" ? true : never;
}[keyof T];
type RemoveAllBrands<T> = HasBrand<T> extends true
? {
// eslint-disable-next-line @typescript-eslint/no-unused-vars
[K in keyof T as K extends `~brand~${infer _}` ? never : K]: T[K];
[K in keyof T as K extends `~brand~${infer _}` | "_brand"
? never
: K]: T[K];
}
: never;
: T;
// adapted from https://github.com/colinhacks/zod/discussions/1994#discussioncomment-6068940
// currently does not cover all types (e.g. tuples, promises...)
type Unbrand<T> = T extends Map<infer E, infer F>
? Map<E, F>
// For accepting values - uses loose matching for branded types
// Preserves readonly modifier: mutable array requires mutable input
type UnbrandForValue<T> = T extends Map<infer E, infer F>
? Map<UnbrandForValue<E>, UnbrandForValue<F>>
: T extends Set<infer E>
? Set<E>
: T extends Array<infer E>
? Array<E>
? Set<UnbrandForValue<E>>
: T extends readonly any[]
? T extends any[]
? unknown[] // mutable array - require mutable input
: readonly unknown[] // readonly array - accept readonly input
: RemoveAllBrands<T>;
// For return types - preserves array element unbranding
export type Unbrand<T> = T extends Map<infer E, infer F>
? Map<Unbrand<E>, Unbrand<F>>
: T extends Set<infer E>
? Set<Unbrand<E>>
: T extends readonly (infer E)[]
? Array<Unbrand<E>>
: RemoveAllBrands<T>;
export type CombineBrands<BrandedType, CurrentType> =
BrandedType extends readonly (infer BE)[]
? CurrentType extends readonly (infer CE)[]
? Array<CE & BE>
: CurrentType & BrandedType
: CurrentType & BrandedType;
export type CombineBrandsIfNeeded<T, Required> = [T] extends [Required]
? T[]
: HasBrand<T> extends true
? CombineBrands<T, Required>[]
: Required[];
/**
* Makes type into a branded type, ensuring that value is assignable to
* the base ubranded type. Optionally you can explicitly supply current value
* the base unbranded type. Optionally you can explicitly supply current value
* type to combine both (useful for composite branded types. Make sure you
* compose branded types which are not composite themselves.)
*/
export const toBrandedType = <BrandedType, CurrentType = BrandedType>(
value: Unbrand<BrandedType>,
) => {
return value as CurrentType & BrandedType;
};
export function toBrandedType<BrandedType>(
value: UnbrandForValue<BrandedType>,
): BrandedType;
export function toBrandedType<BrandedType, CurrentType>(
value: CurrentType,
): CombineBrands<BrandedType, CurrentType>;
export function toBrandedType(value: unknown) {
return value;
}
// -----------------------------------------------------------------------------
@@ -1238,20 +1138,6 @@ export const escapeDoubleQuotes = (str: string) => {
export const castArray = <T>(value: T | T[]): T[] =>
Array.isArray(value) ? value : [value];
export const elementCenterPoint = (
element: ExcalidrawElement,
xOffset: number = 0,
yOffset: number = 0,
) => {
const { x, y, width, height } = element;
const centerXPoint = x + width / 2 + xOffset;
const centerYPoint = y + height / 2 + yOffset;
return pointFrom<GlobalPoint>(centerXPoint, centerYPoint);
};
/** hack for Array.isArray type guard not working with readonly value[] */
export const isReadonlyArray = (value?: any): value is readonly any[] => {
return Array.isArray(value);
@@ -1294,3 +1180,54 @@ export const reduceToCommonValue = <T, R = T>(
return commonValue;
};
type FEATURE_FLAGS = {
COMPLEX_BINDINGS: boolean;
};
const FEATURE_FLAGS_STORAGE_KEY = "excalidraw-feature-flags";
const DEFAULT_FEATURE_FLAGS: FEATURE_FLAGS = {
COMPLEX_BINDINGS: false,
};
let featureFlags: FEATURE_FLAGS | null = null;
export const getFeatureFlag = <F extends keyof FEATURE_FLAGS>(
flag: F,
): FEATURE_FLAGS[F] => {
if (!featureFlags) {
try {
const serializedFlags = localStorage.getItem(FEATURE_FLAGS_STORAGE_KEY);
if (serializedFlags) {
const flags = JSON.parse(serializedFlags);
featureFlags = flags ?? DEFAULT_FEATURE_FLAGS;
}
} catch {}
}
return (featureFlags || DEFAULT_FEATURE_FLAGS)[flag];
};
export const setFeatureFlag = <F extends keyof FEATURE_FLAGS>(
flag: F,
value: FEATURE_FLAGS[F],
) => {
try {
featureFlags = {
...(featureFlags || DEFAULT_FEATURE_FLAGS),
[flag]: value,
};
localStorage.setItem(
FEATURE_FLAGS_STORAGE_KEY,
JSON.stringify(featureFlags),
);
} catch (e) {
console.error("unable to set feature flag", e);
}
};
export const oneOf = <N extends string | number | symbol | null, H extends N>(
needle: N,
haystack: readonly H[],
): needle is H => {
return haystack.includes(needle as any);
};
@@ -0,0 +1,70 @@
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
@@ -0,0 +1,109 @@
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);
});
});
});
+2 -1
View File
@@ -1,7 +1,8 @@
{
"extends": "../tsconfig.base.json",
"compilerOptions": {
"outDir": "./dist/types"
"outDir": "./dist/types",
"rootDir": "../"
},
"include": ["src/**/*", "global.d.ts"],
"exclude": ["**/*.test.*", "tests", "types", "examples", "dist"]
+16 -2
View File
@@ -1,6 +1,6 @@
{
"name": "@excalidraw/element",
"version": "0.1.0",
"version": "0.18.0",
"type": "module",
"types": "./dist/types/element/src/index.d.ts",
"main": "./dist/prod/index.js",
@@ -13,7 +13,16 @@
"default": "./dist/prod/index.js"
},
"./*": {
"types": "./dist/types/element/src/*.d.ts"
"types": "./dist/types/element/src/*.d.ts",
"development": "./dist/dev/index.js",
"production": "./dist/prod/index.js",
"default": "./dist/prod/index.js"
},
"./visualdebug": {
"types": "./dist/types/element/src/visualdebug.d.ts",
"development": "./dist/dev/visualdebug.js",
"production": "./dist/prod/visualdebug.js",
"default": "./dist/prod/visualdebug.js"
}
},
"files": [
@@ -52,5 +61,10 @@
"scripts": {
"gen:types": "rimraf types && tsc",
"build:esm": "rimraf dist && node ../../scripts/buildBase.js && yarn gen:types"
},
"dependencies": {
"@excalidraw/common": "0.18.0",
"@excalidraw/math": "0.18.0",
"@excalidraw/fractional-indexing": "3.3.0"
}
}
+32 -42
View File
@@ -51,7 +51,7 @@ const getNonDeletedElements = <T extends ExcalidrawElement>(
allElements: readonly T[],
) => {
const elementsMap = new Map() as NonDeletedSceneElementsMap;
const elements: T[] = [];
const elements: NonDeleted<T>[] = [];
for (const element of allElements) {
if (!element.isDeleted) {
elements.push(element as NonDeleted<T>);
@@ -164,9 +164,14 @@ export class Scene {
return this.frames;
}
constructor(elements: ElementsMapOrArray | null = null) {
constructor(
elements: ElementsMapOrArray | null = null,
options?: {
skipValidation?: true;
},
) {
if (elements) {
this.replaceAllElements(elements);
this.replaceAllElements(elements, options);
}
}
@@ -182,7 +187,7 @@ export class Scene {
// selection-related options
includeBoundTextElement?: boolean;
includeElementsInFrames?: boolean;
}): NonDeleted<ExcalidrawElement>[] {
}): NonDeletedExcalidrawElement[] {
const hash = hashSelectionOpts(opts);
const elements = opts?.elements || this.nonDeletedElements;
@@ -263,12 +268,19 @@ export class Scene {
return didChange;
}
replaceAllElements(nextElements: ElementsMapOrArray) {
replaceAllElements(
nextElements: ElementsMapOrArray,
options?: {
skipValidation?: true;
},
) {
// we do trust the insertion order on the map, though maybe we shouldn't and should prefer order defined by fractional indices
const _nextElements = toArray(nextElements);
const nextFrameLikes: ExcalidrawFrameLikeElement[] = [];
validateIndicesThrottled(_nextElements);
if (!options?.skipValidation) {
validateIndicesThrottled(_nextElements);
}
this.elements = syncInvalidIndices(_nextElements);
this.elementsMap.clear();
@@ -326,29 +338,20 @@ export class Scene {
this.callbacks.clear();
}
insertElementAtIndex(element: ExcalidrawElement, index: number) {
if (!Number.isFinite(index) || index < 0) {
throw new Error(
"insertElementAtIndex can only be called with index >= 0",
);
}
const nextElements = [
...this.elements.slice(0, index),
element,
...this.elements.slice(index),
];
syncMovedIndices(nextElements, arrayToMap([element]));
this.replaceAllElements(nextElements);
}
insertElementsAtIndex(elements: ExcalidrawElement[], index: number) {
/** low-level - generally use app.insertNewElements() */
insertElementsAtIndex(
elements: ExcalidrawElement[],
/** null indicates end of the array */
index: number | null,
) {
if (!elements.length) {
return;
}
if (index === null) {
index = this.elements.length;
}
if (!Number.isFinite(index) || index < 0) {
throw new Error(
"insertElementAtIndex can only be called with index >= 0",
@@ -366,24 +369,9 @@ export class Scene {
this.replaceAllElements(nextElements);
}
/** low-level - generally use app.insertNewElement() */
insertElement = (element: ExcalidrawElement) => {
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);
this.insertElementsAtIndex([element], null);
};
getElementIndex(elementId: string) {
@@ -426,6 +414,8 @@ export class Scene {
options: {
informMutation: boolean;
isDragging: boolean;
isBindingEnabled?: boolean;
isMidpointSnappingEnabled?: boolean;
} = {
informMutation: true,
isDragging: false,
-613
View File
@@ -1,613 +0,0 @@
import { simplify } from "points-on-curve";
import { pointFrom, pointDistance, type LocalPoint } from "@excalidraw/math";
import { ROUGHNESS, isTransparent, assertNever } from "@excalidraw/common";
import type { Mutable } from "@excalidraw/common/utility-types";
import type { EmbedsValidationStatus } from "@excalidraw/excalidraw/types";
import type { ElementShapes } from "@excalidraw/excalidraw/scene/types";
import {
isElbowArrow,
isEmbeddableElement,
isIframeElement,
isIframeLikeElement,
isLinearElement,
} from "./typeChecks";
import { getCornerRadius, isPathALoop } from "./shapes";
import { headingForPointIsHorizontal } from "./heading";
import { canChangeRoundness } from "./comparisons";
import { generateFreeDrawShape } from "./renderElement";
import { getArrowheadPoints, getDiamondPoints } from "./bounds";
import type {
ExcalidrawElement,
NonDeletedExcalidrawElement,
ExcalidrawSelectionElement,
ExcalidrawLinearElement,
Arrowhead,
} from "./types";
import type { Drawable, Options } from "roughjs/bin/core";
import type { RoughGenerator } from "roughjs/bin/generator";
import type { Point as RoughPoint } from "roughjs/bin/geometry";
const getDashArrayDashed = (strokeWidth: number) => [8, 8 + strokeWidth];
const getDashArrayDotted = (strokeWidth: number) => [1.5, 6 + strokeWidth];
function adjustRoughness(element: ExcalidrawElement): number {
const roughness = element.roughness;
const maxSize = Math.max(element.width, element.height);
const minSize = Math.min(element.width, element.height);
// don't reduce roughness if
if (
// both sides relatively big
(minSize >= 20 && maxSize >= 50) ||
// is round & both sides above 15px
(minSize >= 15 &&
!!element.roundness &&
canChangeRoundness(element.type)) ||
// relatively long linear element
(isLinearElement(element) && maxSize >= 50)
) {
return roughness;
}
return Math.min(roughness / (maxSize < 10 ? 3 : 2), 2.5);
}
export const generateRoughOptions = (
element: ExcalidrawElement,
continuousPath = false,
): Options => {
const options: Options = {
seed: element.seed,
strokeLineDash:
element.strokeStyle === "dashed"
? getDashArrayDashed(element.strokeWidth)
: element.strokeStyle === "dotted"
? getDashArrayDotted(element.strokeWidth)
: undefined,
// for non-solid strokes, disable multiStroke because it tends to make
// dashes/dots overlay each other
disableMultiStroke: element.strokeStyle !== "solid",
// for non-solid strokes, increase the width a bit to make it visually
// similar to solid strokes, because we're also disabling multiStroke
strokeWidth:
element.strokeStyle !== "solid"
? element.strokeWidth + 0.5
: element.strokeWidth,
// when increasing strokeWidth, we must explicitly set fillWeight and
// hachureGap because if not specified, roughjs uses strokeWidth to
// calculate them (and we don't want the fills to be modified)
fillWeight: element.strokeWidth / 2,
hachureGap: element.strokeWidth * 4,
roughness: adjustRoughness(element),
stroke: element.strokeColor,
preserveVertices:
continuousPath || element.roughness < ROUGHNESS.cartoonist,
};
switch (element.type) {
case "rectangle":
case "iframe":
case "embeddable":
case "diamond":
case "ellipse": {
options.fillStyle = element.fillStyle;
options.fill = isTransparent(element.backgroundColor)
? undefined
: element.backgroundColor;
if (element.type === "ellipse") {
options.curveFitting = 1;
}
return options;
}
case "line":
case "freedraw": {
if (isPathALoop(element.points)) {
options.fillStyle = element.fillStyle;
options.fill =
element.backgroundColor === "transparent"
? undefined
: element.backgroundColor;
}
return options;
}
case "arrow":
return options;
default: {
throw new Error(`Unimplemented type ${element.type}`);
}
}
};
const modifyIframeLikeForRoughOptions = (
element: NonDeletedExcalidrawElement,
isExporting: boolean,
embedsValidationStatus: EmbedsValidationStatus | null,
) => {
if (
isIframeLikeElement(element) &&
(isExporting ||
(isEmbeddableElement(element) &&
embedsValidationStatus?.get(element.id) !== true)) &&
isTransparent(element.backgroundColor) &&
isTransparent(element.strokeColor)
) {
return {
...element,
roughness: 0,
backgroundColor: "#d3d3d3",
fillStyle: "solid",
} as const;
} else if (isIframeElement(element)) {
return {
...element,
strokeColor: isTransparent(element.strokeColor)
? "#000000"
: element.strokeColor,
backgroundColor: isTransparent(element.backgroundColor)
? "#f4f4f6"
: element.backgroundColor,
};
}
return element;
};
const getArrowheadShapes = (
element: ExcalidrawLinearElement,
shape: Drawable[],
position: "start" | "end",
arrowhead: Arrowhead,
generator: RoughGenerator,
options: Options,
canvasBackgroundColor: string,
) => {
const arrowheadPoints = getArrowheadPoints(
element,
shape,
position,
arrowhead,
);
if (arrowheadPoints === null) {
return [];
}
const generateCrowfootOne = (
arrowheadPoints: number[] | null,
options: Options,
) => {
if (arrowheadPoints === null) {
return [];
}
const [, , x3, y3, x4, y4] = arrowheadPoints;
return [generator.line(x3, y3, x4, y4, options)];
};
switch (arrowhead) {
case "dot":
case "circle":
case "circle_outline": {
const [x, y, diameter] = arrowheadPoints;
// always use solid stroke for arrowhead
delete options.strokeLineDash;
return [
generator.circle(x, y, diameter, {
...options,
fill:
arrowhead === "circle_outline"
? canvasBackgroundColor
: element.strokeColor,
fillStyle: "solid",
stroke: element.strokeColor,
roughness: Math.min(0.5, options.roughness || 0),
}),
];
}
case "triangle":
case "triangle_outline": {
const [x, y, x2, y2, x3, y3] = arrowheadPoints;
// always use solid stroke for arrowhead
delete options.strokeLineDash;
return [
generator.polygon(
[
[x, y],
[x2, y2],
[x3, y3],
[x, y],
],
{
...options,
fill:
arrowhead === "triangle_outline"
? canvasBackgroundColor
: element.strokeColor,
fillStyle: "solid",
roughness: Math.min(1, options.roughness || 0),
},
),
];
}
case "diamond":
case "diamond_outline": {
const [x, y, x2, y2, x3, y3, x4, y4] = arrowheadPoints;
// always use solid stroke for arrowhead
delete options.strokeLineDash;
return [
generator.polygon(
[
[x, y],
[x2, y2],
[x3, y3],
[x4, y4],
[x, y],
],
{
...options,
fill:
arrowhead === "diamond_outline"
? canvasBackgroundColor
: element.strokeColor,
fillStyle: "solid",
roughness: Math.min(1, options.roughness || 0),
},
),
];
}
case "crowfoot_one":
return generateCrowfootOne(arrowheadPoints, options);
case "bar":
case "arrow":
case "crowfoot_many":
case "crowfoot_one_or_many":
default: {
const [x2, y2, x3, y3, x4, y4] = arrowheadPoints;
if (element.strokeStyle === "dotted") {
// 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,
)
: []),
];
}
}
};
/**
* Generates the roughjs shape for given element.
*
* Low-level. Use `ShapeCache.generateElementShape` instead.
*
* @private
*/
export const _generateElementShape = (
element: Exclude<NonDeletedExcalidrawElement, ExcalidrawSelectionElement>,
generator: RoughGenerator,
{
isExporting,
canvasBackgroundColor,
embedsValidationStatus,
}: {
isExporting: boolean;
canvasBackgroundColor: string;
embedsValidationStatus: EmbedsValidationStatus | null;
},
): Drawable | Drawable[] | null => {
switch (element.type) {
case "rectangle":
case "iframe":
case "embeddable": {
let shape: ElementShapes[typeof element.type];
// this is for rendering the stroke/bg of the embeddable, especially
// when the src url is not set
if (element.roundness) {
const w = element.width;
const h = element.height;
const r = getCornerRadius(Math.min(w, h), element);
shape = generator.path(
`M ${r} 0 L ${w - r} 0 Q ${w} 0, ${w} ${r} L ${w} ${
h - r
} Q ${w} ${h}, ${w - r} ${h} L ${r} ${h} Q 0 ${h}, 0 ${
h - r
} L 0 ${r} Q 0 0, ${r} 0`,
generateRoughOptions(
modifyIframeLikeForRoughOptions(
element,
isExporting,
embedsValidationStatus,
),
true,
),
);
} else {
shape = generator.rectangle(
0,
0,
element.width,
element.height,
generateRoughOptions(
modifyIframeLikeForRoughOptions(
element,
isExporting,
embedsValidationStatus,
),
false,
),
);
}
return shape;
}
case "diamond": {
let shape: ElementShapes[typeof element.type];
const [topX, topY, rightX, rightY, bottomX, bottomY, leftX, leftY] =
getDiamondPoints(element);
if (element.roundness) {
const verticalRadius = getCornerRadius(Math.abs(topX - leftX), element);
const horizontalRadius = getCornerRadius(
Math.abs(rightY - topY),
element,
);
shape = generator.path(
`M ${topX + verticalRadius} ${topY + horizontalRadius} L ${
rightX - verticalRadius
} ${rightY - horizontalRadius}
C ${rightX} ${rightY}, ${rightX} ${rightY}, ${
rightX - verticalRadius
} ${rightY + horizontalRadius}
L ${bottomX + verticalRadius} ${bottomY - horizontalRadius}
C ${bottomX} ${bottomY}, ${bottomX} ${bottomY}, ${
bottomX - verticalRadius
} ${bottomY - horizontalRadius}
L ${leftX + verticalRadius} ${leftY + horizontalRadius}
C ${leftX} ${leftY}, ${leftX} ${leftY}, ${leftX + verticalRadius} ${
leftY - horizontalRadius
}
L ${topX - verticalRadius} ${topY + horizontalRadius}
C ${topX} ${topY}, ${topX} ${topY}, ${topX + verticalRadius} ${
topY + horizontalRadius
}`,
generateRoughOptions(element, true),
);
} else {
shape = generator.polygon(
[
[topX, topY],
[rightX, rightY],
[bottomX, bottomY],
[leftX, leftY],
],
generateRoughOptions(element),
);
}
return shape;
}
case "ellipse": {
const shape: ElementShapes[typeof element.type] = generator.ellipse(
element.width / 2,
element.height / 2,
element.width,
element.height,
generateRoughOptions(element),
);
return shape;
}
case "line":
case "arrow": {
let shape: ElementShapes[typeof element.type];
const options = generateRoughOptions(element);
// points array can be empty in the beginning, so it is important to add
// initial position to it
const points = element.points.length
? element.points
: [pointFrom<LocalPoint>(0, 0)];
if (isElbowArrow(element)) {
// NOTE (mtolmacs): Temporary fix for extremely big arrow shapes
if (
!points.every(
(point) => Math.abs(point[0]) <= 1e6 && Math.abs(point[1]) <= 1e6,
)
) {
console.error(
`Elbow arrow with extreme point positions detected. Arrow not rendered.`,
element.id,
JSON.stringify(points),
);
shape = [];
} else {
shape = [
generator.path(
generateElbowArrowShape(points, 16),
generateRoughOptions(element, true),
),
];
}
} else if (!element.roundness) {
// curve is always the first element
// this simplifies finding the curve for an element
if (options.fill) {
shape = [
generator.polygon(points as unknown as RoughPoint[], options),
];
} else {
shape = [
generator.linearPath(points as unknown as RoughPoint[], options),
];
}
} else {
shape = [generator.curve(points as unknown as RoughPoint[], options)];
}
// add lines only in arrow
if (element.type === "arrow") {
const { startArrowhead = null, endArrowhead = "arrow" } = element;
if (startArrowhead !== null) {
const shapes = getArrowheadShapes(
element,
shape,
"start",
startArrowhead,
generator,
options,
canvasBackgroundColor,
);
shape.push(...shapes);
}
if (endArrowhead !== null) {
if (endArrowhead === undefined) {
// Hey, we have an old arrow here!
}
const shapes = getArrowheadShapes(
element,
shape,
"end",
endArrowhead,
generator,
options,
canvasBackgroundColor,
);
shape.push(...shapes);
}
}
return shape;
}
case "freedraw": {
let shape: ElementShapes[typeof element.type];
generateFreeDrawShape(element);
if (isPathALoop(element.points)) {
// generate rough polygon to fill freedraw shape
const simplifiedPoints = simplify(
element.points as Mutable<LocalPoint[]>,
0.75,
);
shape = generator.curve(simplifiedPoints as [number, number][], {
...generateRoughOptions(element),
stroke: "none",
});
} else {
shape = null;
}
return shape;
}
case "frame":
case "magicframe":
case "text":
case "image": {
const shape: ElementShapes[typeof element.type] = null;
// we return (and cache) `null` to make sure we don't regenerate
// `element.canvas` on rerenders
return shape;
}
default: {
assertNever(
element,
`generateElementShape(): Unimplemented type ${(element as any)?.type}`,
);
return null;
}
}
};
const generateElbowArrowShape = (
points: readonly LocalPoint[],
radius: number,
) => {
const subpoints = [] as [number, number][];
for (let i = 1; i < points.length - 1; i += 1) {
const prev = points[i - 1];
const next = points[i + 1];
const point = points[i];
const prevIsHorizontal = headingForPointIsHorizontal(point, prev);
const nextIsHorizontal = headingForPointIsHorizontal(next, point);
const corner = Math.min(
radius,
pointDistance(points[i], next) / 2,
pointDistance(points[i], prev) / 2,
);
if (prevIsHorizontal) {
if (prev[0] < point[0]) {
// LEFT
subpoints.push([points[i][0] - corner, points[i][1]]);
} else {
// RIGHT
subpoints.push([points[i][0] + corner, points[i][1]]);
}
} else if (prev[1] < point[1]) {
// UP
subpoints.push([points[i][0], points[i][1] - corner]);
} else {
subpoints.push([points[i][0], points[i][1] + corner]);
}
subpoints.push(points[i] as [number, number]);
if (nextIsHorizontal) {
if (next[0] < point[0]) {
// LEFT
subpoints.push([points[i][0] - corner, points[i][1]]);
} else {
// RIGHT
subpoints.push([points[i][0] + corner, points[i][1]]);
}
} else if (next[1] < point[1]) {
// UP
subpoints.push([points[i][0], points[i][1] - corner]);
} else {
// DOWN
subpoints.push([points[i][0], points[i][1] + corner]);
}
}
const d = [`M ${points[0][0]} ${points[0][1]}`];
for (let i = 0; i < subpoints.length; i += 3) {
d.push(`L ${subpoints[i][0]} ${subpoints[i][1]}`);
d.push(
`Q ${subpoints[i + 1][0]} ${subpoints[i + 1][1]}, ${
subpoints[i + 2][0]
} ${subpoints[i + 2][1]}`,
);
}
d.push(`L ${points[points.length - 1][0]} ${points[points.length - 1][1]}`);
return d.join(" ");
};
-95
View File
@@ -1,95 +0,0 @@
import { RoughGenerator } from "roughjs/bin/generator";
import { COLOR_PALETTE } from "@excalidraw/common";
import type {
AppState,
EmbedsValidationStatus,
} from "@excalidraw/excalidraw/types";
import type {
ElementShape,
ElementShapes,
} from "@excalidraw/excalidraw/scene/types";
import { _generateElementShape } from "./Shape";
import { elementWithCanvasCache } from "./renderElement";
import type { ExcalidrawElement, ExcalidrawSelectionElement } from "./types";
import type { Drawable } from "roughjs/bin/core";
export class ShapeCache {
private static rg = new RoughGenerator();
private static cache = new WeakMap<ExcalidrawElement, ElementShape>();
/**
* Retrieves shape from cache if available. Use this only if shape
* is optional and you have a fallback in case it's not cached.
*/
public static get = <T extends ExcalidrawElement>(element: T) => {
return ShapeCache.cache.get(
element,
) as T["type"] extends keyof ElementShapes
? ElementShapes[T["type"]] | undefined
: ElementShape | undefined;
};
public static set = <T extends ExcalidrawElement>(
element: T,
shape: T["type"] extends keyof ElementShapes
? ElementShapes[T["type"]]
: Drawable,
) => ShapeCache.cache.set(element, shape);
public static delete = (element: ExcalidrawElement) =>
ShapeCache.cache.delete(element);
public static destroy = () => {
ShapeCache.cache = new WeakMap();
};
/**
* Generates & caches shape for element if not already cached, otherwise
* returns cached shape.
*/
public static generateElementShape = <
T extends Exclude<ExcalidrawElement, ExcalidrawSelectionElement>,
>(
element: T,
renderConfig: {
isExporting: boolean;
canvasBackgroundColor: AppState["viewBackgroundColor"];
embedsValidationStatus: EmbedsValidationStatus;
} | null,
) => {
// when exporting, always regenerated to guarantee the latest shape
const cachedShape = renderConfig?.isExporting
? undefined
: ShapeCache.get(element);
// `null` indicates no rc shape applicable for this element type,
// but it's considered a valid cache value (= do not regenerate)
if (cachedShape !== undefined) {
return cachedShape;
}
elementWithCanvasCache.delete(element);
const shape = _generateElementShape(
element,
ShapeCache.rg,
renderConfig || {
isExporting: false,
canvasBackgroundColor: COLOR_PALETTE.white,
embedsValidationStatus: null,
},
) as T["type"] extends keyof ElementShapes
? ElementShapes[T["type"]]
: Drawable | null;
ShapeCache.cache.set(element, shape);
return shape;
};
}

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