Compare commits

...

88 Commits

Author SHA1 Message Date
Ryan Di 692802f8a6 fix: handle missing schemaState in migration 2026-03-25 20:10:43 +11:00
Ryan Di 91981159d2 feat: wire schema migration plugins end-to-end 2026-03-25 19:19:13 +11:00
Ryan Di b871d4ceb3 test: update coverage and snapshots for per-track schemaState 2026-03-25 18:04:22 +11:00
Ryan Di 16cf593978 refactor: replace global schema version with per-track migration 2026-03-25 18:04:13 +11:00
Ryan Di 1d35cb406b refactor: switch elements to per-track schemaState 2026-03-25 18:04:04 +11:00
Ryan Di 205d90592a test: refresh snapshots for schema changes 2026-03-23 17:30:23 +11:00
Ryan Di 08af0964f2 test: cover element schema migration flow 2026-03-23 17:30:16 +11:00
Ryan Di 0e4ae079ac refactor: remove payload-level schema version 2026-03-23 17:30:11 +11:00
Ryan Di 11ba6784aa refactor: migrate elements by schema in restore 2026-03-23 17:30:05 +11:00
Ryan Di 16838ea792 test: cover collab field preservation 2026-03-20 19:55:40 +11:00
Ryan Di 8168d46f87 chore: harden local schema hints and tests 2026-03-20 19:33:01 +11:00
Ryan Di eda7c8d6e9 fix: keep schema hints across clipboard roundtrips 2026-03-20 19:00:54 +11:00
Ryan Di b818b3fe04 chore: keep schema migration rules in code 2026-03-20 18:17:00 +11:00
Ryan Di 67967c05fa refactor: add schema migration helper APIs 2026-03-20 18:15:03 +11:00
Ryan Di 6c9914049e docs: jot down schema migration rules 2026-03-20 18:03:43 +11:00
Ryan Di 1e113e4a3b fix: wire schema scopes at import boundaries 2026-03-20 18:03:36 +11:00
Ryan Di ec458d92e3 chore: set up scoped schema migrations 2026-03-20 18:03:22 +11:00
Ryan Di b3b9b26979 fix: smooth out schema-version edges 2026-03-20 14:03:22 +11:00
Ryan Di 21d26b1afe schema: add legacy frame edge test and refresh snapshots 2026-03-20 01:11:05 +11:00
Ryan Di 6d3eb16531 schema: remove frame bg guard flag from runtime 2026-03-20 01:06:00 +11:00
Ryan Di 5bb3046dea schema: run v1->v2 frame migration on load/paste 2026-03-20 00:57:50 +11:00
Ryan Di 7b51a6ac54 schema: add payload schemaVersion plumbing 2026-03-20 00:26:08 +11:00
Ryan Di d1cbab855d pass full bg payload in actionProperties 2026-03-18 20:08:56 +11:00
Ryan Di 40158fa0a0 update test snapshots 2026-03-18 20:02:58 +11:00
Ryan Di d1144b4779 frame bg: normalize false flag to transparent 2026-03-18 19:59:17 +11:00
Ryan Di c4925dc5b9 frame bg: treat non-boolean legacy flag as missing 2026-03-18 19:55:52 +11:00
Ryan Di 48bc930c09 frame bg: drop legacy color if flag missing 2026-03-18 19:53:24 +11:00
Ryan Di 858d1d4cce frame bg: make legacy scenes opt-in 2026-03-18 19:49:46 +11:00
Ryan Di 3f405ab833 fix: align svg frame bg with canvas 2026-03-18 19:41:14 +11:00
Ryan Di 8d003a1d21 perf: tidy svg frame bg render pass 2026-03-18 19:38:53 +11:00
Ryan Di 2b3871856e perf: tidy frame bg render pass 2026-03-18 19:36:41 +11:00
Ryan Di a7281de157 fix: skip unsupported style updates 2026-03-18 19:22:48 +11:00
dwelle f944f1f7aa feat(editor): support frame backgrounds 2026-02-22 23:08:46 +01:00
David Luzar 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ács 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ács 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ács 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 Luzar 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
Yash 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
zsviczian 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ács 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 Luzar 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 Tangonan 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 L 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ács 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 dizon 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
310 changed files with 20505 additions and 4103 deletions
+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_LP=https://plus.excalidraw.com
VITE_APP_PLUS_APP=http://localhost:3000 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"}' 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 FAST_REFRESH=false
# The port the run the dev server # The port the run the dev server
VITE_APP_PORT=3000 VITE_APP_PORT=3001
#Debug flags #Debug flags
+1 -1
View File
@@ -28,7 +28,7 @@ jobs:
export SENTRY_RELEASE=$(sentry-cli releases propose-version) export SENTRY_RELEASE=$(sentry-cli releases propose-version)
sentry-cli releases new $SENTRY_RELEASE --project $SENTRY_PROJECT sentry-cli releases new $SENTRY_RELEASE --project $SENTRY_PROJECT
sentry-cli releases set-commits --auto $SENTRY_RELEASE 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 finalize $SENTRY_RELEASE
sentry-cli releases deploys $SENTRY_RELEASE new -e production sentry-cli releases deploys $SENTRY_RELEASE new -e production
env: env:
+1 -1
View File
@@ -14,7 +14,7 @@ jobs:
- name: "Install Node" - name: "Install Node"
uses: actions/setup-node@v2 uses: actions/setup-node@v2
with: with:
node-version: "18.x" node-version: "20.x"
- name: "Install Deps" - name: "Install Deps"
run: yarn install run: yarn install
- name: "Test Coverage" - name: "Test Coverage"
+2 -2
View File
@@ -9,10 +9,10 @@ jobs:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
- name: Setup Node.js 18.x - name: Setup Node.js
uses: actions/setup-node@v4 uses: actions/setup-node@v4
with: with:
node-version: 18.x node-version: 20.x
- name: Install and test - name: Install and test
run: | run: |
yarn install yarn install
+45 -9
View File
@@ -48,7 +48,11 @@ import {
youtubeIcon, youtubeIcon,
} from "@excalidraw/excalidraw/components/icons"; } from "@excalidraw/excalidraw/components/icons";
import { isElementLink } from "@excalidraw/element"; 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 { newElementWith } from "@excalidraw/element";
import { isInitializedImageElement } from "@excalidraw/element"; import { isInitializedImageElement } from "@excalidraw/element";
import clsx from "clsx"; import clsx from "clsx";
@@ -105,8 +109,8 @@ import { TopErrorBoundary } from "./components/TopErrorBoundary";
import { import {
exportToBackend, exportToBackend,
getCollaborationLinkData, getCollaborationLinkData,
importFromBackend,
isCollaborationLink, isCollaborationLink,
loadScene,
} from "./data"; } from "./data";
import { updateStaleImageStatuses } from "./data/FileManager"; import { updateStaleImageStatuses } from "./data/FileManager";
@@ -224,9 +228,20 @@ const initializeScene = async (opts: {
const localDataState = importFromLocalStorage(); 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; 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); let roomLinkData = getCollaborationLinkData(window.location.href);
const isExternalScene = !!(id || jsonBackendMatch || roomLinkData); const isExternalScene = !!(id || jsonBackendMatch || roomLinkData);
@@ -240,11 +255,26 @@ const initializeScene = async (opts: {
(await openConfirmModal(shareableLinkConfirmDialog)) (await openConfirmModal(shareableLinkConfirmDialog))
) { ) {
if (jsonBackendMatch) { if (jsonBackendMatch) {
scene = await loadScene( const imported = await importFromBackend(
jsonBackendMatch[1], jsonBackendMatch[1],
jsonBackendMatch[2], jsonBackendMatch[2],
localDataState,
); );
scene = {
elements: bumpElementVersions(
restoreElements(imported.elements, null, {
repairBindings: true,
deleteInvisibleElements: true,
}),
scene.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; scene.scrollToContent = true;
if (!roomLinkData) { if (!roomLinkData) {
@@ -496,8 +526,10 @@ const ExcalidrawWrapper = () => {
loadImages(data); loadImages(data);
if (data.scene) { if (data.scene) {
excalidrawAPI.updateScene({ excalidrawAPI.updateScene({
...data.scene, elements: restoreElements(data.scene.elements, null, {
...restore(data.scene, null, null, { repairBindings: true }), repairBindings: true,
}),
appState: restoreAppState(data.scene.appState, null),
captureUpdate: CaptureUpdateAction.IMMEDIATELY, captureUpdate: CaptureUpdateAction.IMMEDIATELY,
}); });
} }
@@ -519,7 +551,11 @@ const ExcalidrawWrapper = () => {
const username = importUsernameFromLocalStorage(); const username = importUsernameFromLocalStorage();
setLangCode(getPreferredLanguage()); setLangCode(getPreferredLanguage());
excalidrawAPI.updateScene({ excalidrawAPI.updateScene({
...localDataState, elements: restoreElements(localDataState?.elements, null, {
repairBindings: true,
deleteInvisibleElements: true,
}),
appState: restoreAppState(localDataState?.appState, null),
captureUpdate: CaptureUpdateAction.NEVER, captureUpdate: CaptureUpdateAction.NEVER,
}); });
LibraryIndexedDBAdapter.load().then((data) => { LibraryIndexedDBAdapter.load().then((data) => {
+1
View File
@@ -46,6 +46,7 @@ export const STORAGE_KEYS = {
VERSION_FILES: "version-files", VERSION_FILES: "version-files",
IDB_LIBRARY: "excalidraw-library", IDB_LIBRARY: "excalidraw-library",
IDB_TTD_CHATS: "excalidraw-ttd-chats",
// do not use apart from migrations // do not use apart from migrations
__LEGACY_LOCAL_STORAGE_LIBRARY: "excalidraw-library", __LEGACY_LOCAL_STORAGE_LIBRARY: "excalidraw-library",
+28 -11
View File
@@ -6,7 +6,7 @@ import {
reconcileElements, reconcileElements,
} from "@excalidraw/excalidraw"; } from "@excalidraw/excalidraw";
import { ErrorDialog } from "@excalidraw/excalidraw/components/ErrorDialog"; import { ErrorDialog } from "@excalidraw/excalidraw/components/ErrorDialog";
import { APP_NAME, EVENT } from "@excalidraw/common"; import { APP_NAME, cloneJSON, EVENT, toBrandedType } from "@excalidraw/common";
import { import {
IDLE_THRESHOLD, IDLE_THRESHOLD,
ACTIVE_THRESHOLD, ACTIVE_THRESHOLD,
@@ -29,6 +29,8 @@ import { withBatchedUpdates } from "@excalidraw/excalidraw/reactUtils";
import throttle from "lodash.throttle"; import throttle from "lodash.throttle";
import { PureComponent } from "react"; import { PureComponent } from "react";
import { bumpElementVersions } from "@excalidraw/excalidraw/data/restore";
import type { import type {
ReconciledExcalidrawElement, ReconciledExcalidrawElement,
RemoteExcalidrawElement, RemoteExcalidrawElement,
@@ -311,6 +313,7 @@ class Collab extends PureComponent<CollabProps, CollabState> {
saveCollabRoomToFirebase = async ( saveCollabRoomToFirebase = async (
syncableElements: readonly SyncableExcalidrawElement[], syncableElements: readonly SyncableExcalidrawElement[],
) => { ) => {
syncableElements = cloneJSON(syncableElements);
try { try {
const storedElements = await saveToFirebase( const storedElements = await saveToFirebase(
this.portal, this.portal,
@@ -579,7 +582,9 @@ class Collab extends PureComponent<CollabProps, CollabState> {
case WS_SUBTYPES.INIT: { case WS_SUBTYPES.INIT: {
if (!this.portal.socketInitialized) { if (!this.portal.socketInitialized) {
this.initializeRoom({ fetchScene: false }); this.initializeRoom({ fetchScene: false });
const remoteElements = decryptedData.payload.elements; const remoteElements = toBrandedType<
readonly RemoteExcalidrawElement[]
>(decryptedData.payload.elements);
const reconciledElements = const reconciledElements =
this._reconcileElements(remoteElements); this._reconcileElements(remoteElements);
this.handleRemoteSceneUpdate(reconciledElements); this.handleRemoteSceneUpdate(reconciledElements);
@@ -593,7 +598,11 @@ class Collab extends PureComponent<CollabProps, CollabState> {
} }
case WS_SUBTYPES.UPDATE: case WS_SUBTYPES.UPDATE:
this.handleRemoteSceneUpdate( this.handleRemoteSceneUpdate(
this._reconcileElements(decryptedData.payload.elements), this._reconcileElements(
toBrandedType<readonly RemoteExcalidrawElement[]>(
decryptedData.payload.elements,
),
),
); );
break; break;
case WS_SUBTYPES.MOUSE_LOCATION: { case WS_SUBTYPES.MOUSE_LOCATION: {
@@ -742,20 +751,28 @@ class Collab extends PureComponent<CollabProps, CollabState> {
}; };
private _reconcileElements = ( private _reconcileElements = (
remoteElements: readonly ExcalidrawElement[], remoteElements: readonly RemoteExcalidrawElement[],
): ReconciledExcalidrawElement[] => { ): ReconciledExcalidrawElement[] => {
const localElements = this.getSceneElementsIncludingDeleted();
const appState = this.excalidrawAPI.getAppState(); const appState = this.excalidrawAPI.getAppState();
const restoredRemoteElements = restoreElements(
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, remoteElements,
this.excalidrawAPI.getSceneElementsMapIncludingDeleted(),
);
const reconciledElements = reconcileElements(
localElements,
restoredRemoteElements as RemoteExcalidrawElement[],
appState, appState,
); );
reconciledElements = bumpElementVersions(
reconciledElements,
existingElements,
);
// Avoid broadcasting to the rest of the collaborators the scene // Avoid broadcasting to the rest of the collaborators the scene
// we just received! // we just received!
// Note: this needs to be set before updating the scene as it // Note: this needs to be set before updating the scene as it
+17 -52
View File
@@ -4,12 +4,15 @@ import {
getTextFromElements, getTextFromElements,
MIME_TYPES, MIME_TYPES,
TTDDialog, TTDDialog,
TTDStreamFetch,
} from "@excalidraw/excalidraw"; } from "@excalidraw/excalidraw";
import { getDataURL } from "@excalidraw/excalidraw/data/blob"; import { getDataURL } from "@excalidraw/excalidraw/data/blob";
import { safelyParseJSON } from "@excalidraw/common"; import { safelyParseJSON } from "@excalidraw/common";
import type { ExcalidrawImperativeAPI } from "@excalidraw/excalidraw/types"; import type { ExcalidrawImperativeAPI } from "@excalidraw/excalidraw/types";
import { TTDIndexedDBAdapter } from "../data/TTDStorage";
export const AIComponents = ({ export const AIComponents = ({
excalidrawAPI, excalidrawAPI,
}: { }: {
@@ -99,61 +102,23 @@ export const AIComponents = ({
/> />
<TTDDialog <TTDDialog
onTextSubmit={async (input) => { onTextSubmit={async (props) => {
try { const { onChunk, onStreamCreated, signal, messages } = props;
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 }),
},
);
const rateLimit = response.headers.has("X-Ratelimit-Limit") const result = await TTDStreamFetch({
? parseInt(response.headers.get("X-Ratelimit-Limit") || "0", 10) url: `${
: undefined; 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( return result;
"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");
}
}} }}
persistenceAdapter={TTDIndexedDBAdapter}
/> />
</> </>
); );
+2 -1
View File
@@ -62,7 +62,7 @@ export const AppMainMenu: React.FC<{
{isDevEnv() && ( {isDevEnv() && (
<MainMenu.Item <MainMenu.Item
icon={eyeIcon} icon={eyeIcon}
onClick={() => { onSelect={() => {
if (window.visualDebug) { if (window.visualDebug) {
delete window.visualDebug; delete window.visualDebug;
saveDebugState({ enabled: false }); saveDebugState({ enabled: false });
@@ -77,6 +77,7 @@ export const AppMainMenu: React.FC<{
</MainMenu.Item> </MainMenu.Item>
)} )}
<MainMenu.Separator /> <MainMenu.Separator />
<MainMenu.DefaultItems.Preferences />
<MainMenu.DefaultItems.ToggleTheme <MainMenu.DefaultItems.ToggleTheme
allowSystemTheme allowSystemTheme
theme={props.theme} theme={props.theme}
@@ -33,7 +33,15 @@ export const AppWelcomeScreen: React.FC<{
return bit; return bit;
}); });
} else { } 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 ( return (
+45 -1
View File
@@ -27,7 +27,10 @@ import { isCurve } from "@excalidraw/math/curve";
import React from "react"; import React from "react";
import type { Curve } from "@excalidraw/math"; import type { Curve } from "@excalidraw/math";
import type { DebugElement } from "@excalidraw/common"; import type {
DebugElement,
DebugPolygon,
} from "@excalidraw/element/visualdebug";
import type { import type {
ElementsMap, ElementsMap,
ExcalidrawArrowElement, ExcalidrawArrowElement,
@@ -75,6 +78,44 @@ const renderCubicBezier = (
context.restore(); 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) => { const renderOrigin = (context: CanvasRenderingContext2D, zoom: number) => {
context.strokeStyle = "#888"; context.strokeStyle = "#888";
context.save(); context.save();
@@ -280,6 +321,9 @@ const render = (
el.color, el.color,
); );
break; break;
case isDebugPolygon(el.data):
renderPolygon(context, appState.zoom.value, el.data, el.color);
break;
default: default:
throw new Error(`Unknown element type ${JSON.stringify(el)}`); throw new Error(`Unknown element type ${JSON.stringify(el)}`);
} }
@@ -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>
),
);
+3 -1
View File
@@ -86,9 +86,11 @@ const saveDataStateToLocalStorage = (
_appState.openSidebar = null; _appState.openSidebar = null;
} }
const persistedElements = getNonDeletedElements(elements);
localStorage.setItem( localStorage.setItem(
STORAGE_KEYS.LOCAL_STORAGE_ELEMENTS, STORAGE_KEYS.LOCAL_STORAGE_ELEMENTS,
JSON.stringify(getNonDeletedElements(elements)), JSON.stringify(persistedElements),
); );
localStorage.setItem( localStorage.setItem(
STORAGE_KEYS.LOCAL_STORAGE_APP_STATE, STORAGE_KEYS.LOCAL_STORAGE_APP_STATE,
+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;
}
}
}
+2 -2
View File
@@ -1,5 +1,5 @@
import { reconcileElements } from "@excalidraw/excalidraw"; 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 { decompressData } from "@excalidraw/excalidraw/data/encode";
import { import {
encryptData, encryptData,
@@ -243,7 +243,7 @@ export const saveToFirebase = async (
FirebaseSceneVersionCache.set(socket, storedElements); FirebaseSceneVersionCache.set(socket, storedElements);
return storedElements; return toBrandedType<RemoteExcalidrawElement[]>(storedElements);
}; };
export const loadFromFirebase = async ( export const loadFromFirebase = async (
+3 -43
View File
@@ -8,7 +8,6 @@ import {
IV_LENGTH_BYTES, IV_LENGTH_BYTES,
} from "@excalidraw/excalidraw/data/encryption"; } from "@excalidraw/excalidraw/data/encryption";
import { serializeAsJSON } from "@excalidraw/excalidraw/data/json"; import { serializeAsJSON } from "@excalidraw/excalidraw/data/json";
import { restore } from "@excalidraw/excalidraw/data/restore";
import { isInvisiblySmallElement } from "@excalidraw/element"; import { isInvisiblySmallElement } from "@excalidraw/element";
import { isInitializedImageElement } from "@excalidraw/element"; import { isInitializedImageElement } from "@excalidraw/element";
import { t } from "@excalidraw/excalidraw/i18n"; import { t } from "@excalidraw/excalidraw/i18n";
@@ -84,13 +83,13 @@ export type SocketUpdateDataSource = {
SCENE_INIT: { SCENE_INIT: {
type: WS_SUBTYPES.INIT; type: WS_SUBTYPES.INIT;
payload: { payload: {
elements: readonly ExcalidrawElement[]; elements: readonly OrderedExcalidrawElement[];
}; };
}; };
SCENE_UPDATE: { SCENE_UPDATE: {
type: WS_SUBTYPES.UPDATE; type: WS_SUBTYPES.UPDATE;
payload: { payload: {
elements: readonly ExcalidrawElement[]; elements: readonly OrderedExcalidrawElement[];
}; };
}; };
MOUSE_LOCATION: { MOUSE_LOCATION: {
@@ -200,7 +199,7 @@ const legacy_decodeFromBackend = async ({
}; };
}; };
const importFromBackend = async ( export const importFromBackend = async (
id: string, id: string,
decryptionKey: string, decryptionKey: string,
): Promise<ImportedDataState> => { ): Promise<ImportedDataState> => {
@@ -242,45 +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,
deleteInvisibleElements: true,
},
);
} else {
data = restore(localDataState || null, null, null, {
repairBindings: true,
deleteInvisibleElements: 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 = type ExportToBackendResult =
| { url: null; errorMessage: string } | { url: null; errorMessage: string }
| { url: string; errorMessage: null }; | { url: string; errorMessage: null };
+51 -24
View File
@@ -10,8 +10,6 @@ const lessPrecise = (num: number, precision = 5) =>
const getAvgFrameTime = (times: number[]) => const getAvgFrameTime = (times: number[]) =>
lessPrecise(times.reduce((a, b) => a + b) / times.length); lessPrecise(times.reduce((a, b) => a + b) / times.length);
const getFps = (frametime: number) => lessPrecise(1000 / frametime);
export class Debug { export class Debug {
public static DEBUG_LOG_TIMES = true; public static DEBUG_LOG_TIMES = true;
@@ -24,34 +22,35 @@ export class Debug {
private static LAST_DEBUG_LOG_CALL = 0; private static LAST_DEBUG_LOG_CALL = 0;
private static DEBUG_LOG_INTERVAL_ID: null | number = null; 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 = () => { private static setupInterval = () => {
if (Debug.DEBUG_LOG_INTERVAL_ID === null) { if (Debug.DEBUG_LOG_INTERVAL_ID === null) {
console.info("%c(starting perf recording)", "color: lime"); console.info("%c(starting perf recording)", "color: lime");
Debug.DEBUG_LOG_INTERVAL_ID = window.setInterval(Debug.debugLogger, 1000); Debug.DEBUG_LOG_INTERVAL_ID = window.setInterval(Debug.debugLogger, 1000);
Debug.scheduleAnimationFrame();
} }
Debug.LAST_DEBUG_LOG_CALL = Date.now(); Debug.LAST_DEBUG_LOG_CALL = Date.now();
}; };
private static debugLogger = () => { 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) { if (Debug.DEBUG_LOG_TIMES) {
for (const [name, { t, times }] of Object.entries(Debug.TIMES_AGGR)) { for (const [name, { t, times }] of Object.entries(Debug.TIMES_AGGR)) {
if (times.length) { if (times.length) {
@@ -65,8 +64,18 @@ export class Debug {
} }
for (const [name, { t, times, avg }] of Object.entries(Debug.TIMES_AVG)) { for (const [name, { t, times, avg }] of Object.entries(Debug.TIMES_AVG)) {
if (times.length) { if (times.length) {
const avgFrameTime = getAvgFrameTime(times); // const avgFrameTime = getAvgFrameTime(times);
console.info(name, `${avgFrameTime}ms (${getFps(avgFrameTime)} fps)`); 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] = { Debug.TIMES_AVG[name] = {
t, t,
times: [], times: [],
@@ -76,6 +85,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") => { public static logTime = (time?: number, name = "default") => {
@@ -109,7 +136,7 @@ export class Debug {
return (...args: T) => { return (...args: T) => {
const t0 = performance.now(); const t0 = performance.now();
const ret = fn(...args); const ret = fn(...args);
Debug.logTime(performance.now() - t0, name); Debug[type](performance.now() - t0, name);
return ret; return ret;
}; };
}; };
+1
View File
@@ -36,6 +36,7 @@
"react": "19.0.0", "react": "19.0.0",
"react-dom": "19.0.0", "react-dom": "19.0.0",
"socket.io-client": "4.7.2", "socket.io-client": "4.7.2",
"uqr": "0.1.2",
"vite-plugin-html": "3.2.2" "vite-plugin-html": "3.2.2"
}, },
"prettier": "@excalidraw/prettier-config", "prettier": "@excalidraw/prettier-config",
+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; 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 { &__description {
border-top: 1px solid var(--color-gray-20); 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 { activeRoomLinkAtom } from "../collab/Collab";
import "./ShareDialog.scss"; import "./ShareDialog.scss";
import { QRCode } from "./QRCode";
import type { CollabAPI } from "../collab/Collab"; import type { CollabAPI } from "../collab/Collab";
@@ -142,6 +143,7 @@ const ActiveRoomDialog = ({
}} }}
/> />
</div> </div>
<QRCode value={activeRoomLink} />
<div className="ShareDialog__active__description"> <div className="ShareDialog__active__description">
<p> <p>
<span <span
+5
View File
@@ -0,0 +1,5 @@
import { renderSVG } from "uqr";
export const generateQRCodeSVG = (text: string): string => {
return renderSVG(text);
};
@@ -50,7 +50,11 @@ exports[`Test MobileMenu > should initialize with welcome screen and hide once u
<div <div
class="welcome-screen-center__heading welcome-screen-decor excalifont" class="welcome-screen-center__heading welcome-screen-decor excalifont"
> >
All your data is saved locally in your browser. Your drawings are saved in your browser's storage.
<br />
Browser storage can be cleared unexpectedly.
<br />
Save your work to a file regularly to avoid losing it.
</div> </div>
<div <div
class="welcome-screen-menu" class="welcome-screen-menu"
+125 -14
View File
@@ -69,6 +69,114 @@ vi.mock("socket.io-client", () => {
* i.e. multiplayer history tests could be a good first candidate, as we could test both history stacks simultaneously. * i.e. multiplayer history tests could be a good first candidate, as we could test both history stacks simultaneously.
*/ */
describe("collaboration", () => { describe("collaboration", () => {
it("should preserve future element fields across collab reconciliation", async () => {
await render(<ExcalidrawApp />);
const frame = API.createElement({
type: "frame",
id: "A",
width: 100,
height: 100,
x: 0,
y: 0,
backgroundColor: "#ff0000",
});
const frameWithFutureFields = {
...frame,
schemaState: {
tracks: {
...frame.schemaState.tracks,
"host.myapp.frame": 1,
},
},
futureField: "keep-me",
} as typeof frame & {
schemaState: { tracks: Record<string, number> };
futureField: string;
};
API.updateScene({
elements: [frameWithFutureFields],
captureUpdate: CaptureUpdateAction.IMMEDIATELY,
});
await waitFor(() => {
expect((h.elements[0] as any).futureField).toBe("keep-me");
expect((h.elements[0] as any).schemaState).toEqual(
frameWithFutureFields.schemaState,
);
expect(h.elements[0].backgroundColor).toBe("#ff0000");
});
const remoteMovedFrame = newElementWith(h.elements[0] as any, {
x: 120,
y: 80,
});
const reconciled = (window.collab as any)._reconcileElements([
remoteMovedFrame,
]);
expect(reconciled[0]).toEqual(
expect.objectContaining({
x: 120,
y: 80,
backgroundColor: "#ff0000",
}),
);
expect((reconciled[0] as any).futureField).toBe("keep-me");
expect((reconciled[0] as any).schemaState).toEqual(
frameWithFutureFields.schemaState,
);
});
it("should preserve future element fields on local edits before broadcast", async () => {
await render(<ExcalidrawApp />);
const rect = API.createElement({
type: "rectangle",
id: "A",
width: 100,
height: 100,
x: 0,
y: 0,
});
const rectWithFutureFields = {
...rect,
schemaState: {
tracks: {
...rect.schemaState.tracks,
"host.myapp.rect": 1,
},
},
futureField: { value: "keep-me" },
} as typeof rect & {
schemaState: { tracks: Record<string, number> };
futureField: { value: string };
};
API.updateScene({
elements: [rectWithFutureFields],
captureUpdate: CaptureUpdateAction.IMMEDIATELY,
});
const locallyEdited = newElementWith(h.elements[0] as any, { x: 200 });
API.updateScene({
elements: [locallyEdited],
captureUpdate: CaptureUpdateAction.NEVER,
});
await waitFor(() => {
expect((h.elements[0] as any).futureField).toEqual({ value: "keep-me" });
expect((h.elements[0] as any).schemaState).toEqual(
rectWithFutureFields.schemaState,
);
expect(h.elements[0]).toEqual(expect.objectContaining({ x: 200 }));
});
});
it("should emit two ephemeral increments even though updates get batched", async () => { it("should emit two ephemeral increments even though updates get batched", async () => {
const durableIncrements: DurableIncrement[] = []; const durableIncrements: DurableIncrement[] = [];
const ephemeralIncrements: EphemeralIncrement[] = []; const ephemeralIncrements: EphemeralIncrement[] = [];
@@ -83,14 +191,18 @@ describe("collaboration", () => {
} }
}); });
// eslint-disable-next-line dot-notation // Ensure this test starts from a deterministic scene regardless of previous
expect(h.store["scheduledMicroActions"].length).toBe(0); // test state restored from persistence.
expect(durableIncrements.length).toBe(0); API.updateScene({
expect(ephemeralIncrements.length).toBe(0); elements: [],
captureUpdate: CaptureUpdateAction.NEVER,
});
const durableBaseline = durableIncrements.length;
const ephemeralBaseline = ephemeralIncrements.length;
const rectProps = { const rectProps = {
type: "rectangle", type: "rectangle",
id: "A",
height: 200, height: 200,
width: 100, width: 100,
x: 0, x: 0,
@@ -105,8 +217,7 @@ describe("collaboration", () => {
}); });
await waitFor(() => { await waitFor(() => {
// expect(commitSpy).toHaveBeenCalledTimes(1); expect(durableIncrements.length).toBe(durableBaseline + 1);
expect(durableIncrements.length).toBe(1);
}); });
// simulate two batched remote updates // simulate two batched remote updates
@@ -130,13 +241,13 @@ describe("collaboration", () => {
// altough the updates get batched, // altough the updates get batched,
// we expect two ephemeral increments for each update, // we expect two ephemeral increments for each update,
// and each such update should have the expected change // and each such update should have the expected change
expect(ephemeralIncrements.length).toBe(2); expect(ephemeralIncrements.length).toBe(ephemeralBaseline + 2);
expect(ephemeralIncrements[0].change.elements.A).toEqual( expect(
expect.objectContaining({ x: 100 }), ephemeralIncrements[ephemeralBaseline].change.elements[rect.id],
); ).toEqual(expect.objectContaining({ x: 100 }));
expect(ephemeralIncrements[1].change.elements.A).toEqual( expect(
expect.objectContaining({ x: 200 }), ephemeralIncrements[ephemeralBaseline + 1].change.elements[rect.id],
); ).toEqual(expect.objectContaining({ x: 200 }));
// eslint-disable-next-line dot-notation // eslint-disable-next-line dot-notation
expect(h.store["scheduledMicroActions"].length).toBe(0); expect(h.store["scheduledMicroActions"].length).toBe(0);
}); });
+5
View File
@@ -102,6 +102,10 @@ export default defineConfig(({ mode }) => {
// Taking the substring after "locales/" // Taking the substring after "locales/"
return `locales/${id.substring(index + 8)}`; return `locales/${id.substring(index + 8)}`;
} }
if (id.includes("@excalidraw/mermaid-to-excalidraw")) {
return "mermaid-to-excalidraw";
}
}, },
}, },
}, },
@@ -196,6 +200,7 @@ export default defineConfig(({ mode }) => {
}, },
}, },
], ],
maximumFileSizeToCacheInBytes: 2.3 * 1024 ** 2, // 2.3MB
}, },
manifest: { manifest: {
short_name: "Excalidraw", short_name: "Excalidraw",
+6
View File
@@ -55,5 +55,11 @@
"scripts": { "scripts": {
"gen:types": "rimraf types && tsc", "gen:types": "rimraf types && tsc",
"build:esm": "rimraf dist && node ../../scripts/buildBase.js && yarn gen:types" "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",
],
}
`;
+286
View File
@@ -0,0 +1,286 @@
import {
applyDarkModeFilter,
COLOR_PALETTE,
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("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");
});
});
});
+226 -43
View File
@@ -1,8 +1,117 @@
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;
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): string => {
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, 93);
const rotated = cssHueRotate(
inverted.r,
inverted.g,
inverted.b,
180 as 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;
};
// ---------------------------------------------------------------------------
// Color palette
// ---------------------------------------------------------------------------
// FIXME can't put to utils.ts rn because of circular dependency // FIXME can't put to utils.ts rn because of circular dependency
const pick = <R extends Record<string, any>, K extends readonly (keyof R)[]>( const pick = <R extends Record<string, any>, K extends readonly (keyof R)[]>(
@@ -17,15 +126,7 @@ const pick = <R extends Record<string, any>, K extends readonly (keyof R)[]>(
}, {} as Pick<R, K[number]>) as Pick<R, K[number]>; }, {} 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 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 // used general type instead of specific type (ColorPalette) to support custom colors
export type ColorPaletteCustom = { [key: string]: ColorTuple | string }; export type ColorPaletteCustom = { [key: string]: ColorTuple | string };
@@ -38,38 +139,30 @@ export const DEFAULT_CHART_COLOR_INDEX = 4;
export const DEFAULT_ELEMENT_STROKE_COLOR_INDEX = 4; export const DEFAULT_ELEMENT_STROKE_COLOR_INDEX = 4;
export const DEFAULT_ELEMENT_BACKGROUND_COLOR_INDEX = 1; 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 = { export const COLOR_PALETTE = {
transparent: "transparent", transparent: "transparent",
black: "#1e1e1e", black: "#1e1e1e",
white: "#ffffff", white: "#ffffff",
// open-colors // open-color from https://github.com/yeun/open-color/blob/master/open-color.js
gray: getSpecificColorShades("gray", ELEMENTS_PALETTE_SHADE_INDEXES), // corresponds to indexes [0,2,4,6,8] (weights: 50, 200, 400, 600, 800)
red: getSpecificColorShades("red", ELEMENTS_PALETTE_SHADE_INDEXES), gray: ["#f8f9fa", "#e9ecef", "#ced4da", "#868e96", "#343a40"],
pink: getSpecificColorShades("pink", ELEMENTS_PALETTE_SHADE_INDEXES), red: ["#fff5f5", "#ffc9c9", "#ff8787", "#fa5252", "#e03131"],
grape: getSpecificColorShades("grape", ELEMENTS_PALETTE_SHADE_INDEXES), pink: ["#fff0f6", "#fcc2d7", "#f783ac", "#e64980", "#c2255c"],
violet: getSpecificColorShades("violet", ELEMENTS_PALETTE_SHADE_INDEXES), grape: ["#f8f0fc", "#eebefa", "#da77f2", "#be4bdb", "#9c36b5"],
blue: getSpecificColorShades("blue", ELEMENTS_PALETTE_SHADE_INDEXES), violet: ["#f3f0ff", "#d0bfff", "#9775fa", "#7950f2", "#6741d9"],
cyan: getSpecificColorShades("cyan", ELEMENTS_PALETTE_SHADE_INDEXES), blue: ["#e7f5ff", "#a5d8ff", "#4dabf7", "#228be6", "#1971c2"],
teal: getSpecificColorShades("teal", ELEMENTS_PALETTE_SHADE_INDEXES), cyan: ["#e3fafc", "#99e9f2", "#3bc9db", "#15aabf", "#0c8599"],
green: getSpecificColorShades("green", ELEMENTS_PALETTE_SHADE_INDEXES), teal: ["#e6fcf5", "#96f2d7", "#38d9a9", "#12b886", "#099268"],
yellow: getSpecificColorShades("yellow", ELEMENTS_PALETTE_SHADE_INDEXES), green: ["#ebfbee", "#b2f2bb", "#69db7c", "#40c057", "#2f9e44"],
orange: getSpecificColorShades("orange", ELEMENTS_PALETTE_SHADE_INDEXES), yellow: ["#fff9db", "#ffec99", "#ffd43b", "#fab005", "#f08c00"],
// radix bronze shades 3,5,7,9,11 orange: ["#fff4e6", "#ffd8a8", "#ffa94d", "#fd7e14", "#e8590c"],
// radix bronze shades [3,5,7,9,11]
bronze: ["#f8f1ee", "#eaddd7", "#d2bab0", "#a18072", "#846358"], 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, [ const COMMON_ELEMENT_SHADES = pick(COLOR_PALETTE, [
"cyan", "cyan",
@@ -84,7 +177,6 @@ const COMMON_ELEMENT_SHADES = pick(COLOR_PALETTE, [
"red", "red",
]); ]);
// -----------------------------------------------------------------------------
// quick picks defaults // quick picks defaults
// ----------------------------------------------------------------------------- // -----------------------------------------------------------------------------
@@ -119,7 +211,6 @@ export const DEFAULT_CANVAS_BACKGROUND_PICKS = [
"#fdf8f6", "#fdf8f6",
] as ColorTuple; ] as ColorTuple;
// -----------------------------------------------------------------------------
// palette defaults // palette defaults
// ----------------------------------------------------------------------------- // -----------------------------------------------------------------------------
@@ -145,8 +236,7 @@ export const DEFAULT_ELEMENT_BACKGROUND_COLOR_PALETTE = {
...COMMON_ELEMENT_SHADES, ...COMMON_ELEMENT_SHADES,
} as const; } as const;
// ----------------------------------------------------------------------------- // color palette helpers
// helpers
// ----------------------------------------------------------------------------- // -----------------------------------------------------------------------------
// !!!MUST BE WITHOUT GRAY, TRANSPARENT AND BLACK!!! // !!!MUST BE WITHOUT GRAY, TRANSPARENT AND BLACK!!!
@@ -167,7 +257,100 @@ export const getAllColorsSpecificShade = (index: 0 | 1 | 2 | 3 | 4) =>
COLOR_PALETTE.red[index], COLOR_PALETTE.red[index],
] as const; ] as const;
export const rgbToHex = (r: number, g: number, b: number) => // -----------------------------------------------------------------------------
`#${((1 << 24) + (r << 16) + (g << 8) + b).toString(16).slice(1)}`; // 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 #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;
};
// ----------------------------------------------------------------------------- // -----------------------------------------------------------------------------
// 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;
};
+4 -3
View File
@@ -106,6 +106,7 @@ export const CLASSES = {
CONVERT_ELEMENT_TYPE_POPUP: "ConvertElementTypePopup", CONVERT_ELEMENT_TYPE_POPUP: "ConvertElementTypePopup",
SHAPE_ACTIONS_THEME_SCOPE: "shape-actions-theme-scope", SHAPE_ACTIONS_THEME_SCOPE: "shape-actions-theme-scope",
FRAME_NAME: "frame-name", FRAME_NAME: "frame-name",
DROPDOWN_MENU_EVENT_WRAPPER: "dropdown-menu-event-wrapper",
}; };
export const FONT_SIZES = { export const FONT_SIZES = {
@@ -190,6 +191,8 @@ export const THEME = {
DARK: "dark", DARK: "dark",
} as const; } as const;
export const DARK_THEME_FILTER = "invert(93%) hue-rotate(180deg)";
export const FRAME_STYLE = { export const FRAME_STYLE = {
strokeColor: "#bbb" as ExcalidrawElement["strokeColor"], strokeColor: "#bbb" as ExcalidrawElement["strokeColor"],
strokeWidth: 2 as ExcalidrawElement["strokeWidth"], strokeWidth: 2 as ExcalidrawElement["strokeWidth"],
@@ -249,6 +252,7 @@ export const STRING_MIME_TYPES = {
json: "application/json", json: "application/json",
// excalidraw data // excalidraw data
excalidraw: "application/vnd.excalidraw+json", excalidraw: "application/vnd.excalidraw+json",
excalidrawClipboard: "application/vnd.excalidraw.clipboard+json",
// LEGACY: fully-qualified library JSON data // LEGACY: fully-qualified library JSON data
excalidrawlib: "application/vnd.excalidrawlib+json", excalidrawlib: "application/vnd.excalidrawlib+json",
// list of excalidraw library item ids // list of excalidraw library item ids
@@ -305,9 +309,6 @@ export const IDLE_THRESHOLD = 60_000;
// Report a user active each ACTIVE_THRESHOLD milliseconds // Report a user active each ACTIVE_THRESHOLD milliseconds
export const ACTIVE_THRESHOLD = 3_000; 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 = { export const URL_QUERY_KEYS = {
addLibrary: "addLibrary", addLibrary: "addLibrary",
} as const; } as const;
+2 -3
View File
@@ -16,7 +16,6 @@ export type EditorInterface = Readonly<{
const DESKTOP_UI_MODE_STORAGE_KEY = "excalidraw.desktopUIMode"; const DESKTOP_UI_MODE_STORAGE_KEY = "excalidraw.desktopUIMode";
// breakpoints // breakpoints
// mobile: up to 699px
export const MQ_MAX_MOBILE = 599; export const MQ_MAX_MOBILE = 599;
export const MQ_MAX_WIDTH_LANDSCAPE = 1000; export const MQ_MAX_WIDTH_LANDSCAPE = 1000;
@@ -24,9 +23,9 @@ export const MQ_MAX_HEIGHT_LANDSCAPE = 500;
// tablets // tablets
export const MQ_MIN_TABLET = MQ_MAX_MOBILE + 1; // lower bound (excludes phones) export const MQ_MIN_TABLET = MQ_MAX_MOBILE + 1; // lower bound (excludes phones)
export const MQ_MAX_TABLET = 1400; // upper bound (excludes laptops/desktops) export const MQ_MAX_TABLET = 1180; // ipad air
// desktop/laptop // desktop/laptop (NOTE: not used for form factor detection)
export const MQ_MIN_WIDTH_DESKTOP = 1440; export const MQ_MIN_WIDTH_DESKTOP = 1440;
// sidebar // sidebar
-1
View File
@@ -11,5 +11,4 @@ export * from "./random";
export * from "./url"; export * from "./url";
export * from "./utils"; export * from "./utils";
export * from "./emitter"; export * from "./emitter";
export * from "./visualdebug";
export * from "./editorInterface"; export * from "./editorInterface";
+47 -28
View File
@@ -10,7 +10,6 @@ import type {
Zoom, Zoom,
} from "@excalidraw/excalidraw/types"; } from "@excalidraw/excalidraw/types";
import { COLOR_PALETTE } from "./colors";
import { import {
DEFAULT_VERSION, DEFAULT_VERSION,
ENV, ENV,
@@ -548,16 +547,6 @@ export const mapFind = <T, K>(
return undefined; 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 type ResolvablePromise<T> = Promise<T> & { export type ResolvablePromise<T> = Promise<T> & {
resolve: [T] extends [undefined] resolve: [T] extends [undefined]
? (value?: MaybePromise<Awaited<T>>) => void ? (value?: MaybePromise<Awaited<T>>) => void
@@ -1157,39 +1146,69 @@ export const normalizeEOL = (str: string) => {
}; };
// ----------------------------------------------------------------------------- // -----------------------------------------------------------------------------
type HasBrand<T> = { export type HasBrand<T> = {
// eslint-disable-next-line @typescript-eslint/no-unused-vars // 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]; }[keyof T];
type RemoveAllBrands<T> = HasBrand<T> extends true type RemoveAllBrands<T> = HasBrand<T> extends true
? { ? {
// eslint-disable-next-line @typescript-eslint/no-unused-vars // 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 // For accepting values - uses loose matching for branded types
// currently does not cover all types (e.g. tuples, promises...) // Preserves readonly modifier: mutable array requires mutable input
type Unbrand<T> = T extends Map<infer E, infer F> type UnbrandForValue<T> = T extends Map<infer E, infer F>
? Map<E, F> ? Map<UnbrandForValue<E>, UnbrandForValue<F>>
: T extends Set<infer E> : T extends Set<infer E>
? Set<E> ? Set<UnbrandForValue<E>>
: T extends Array<infer E> : T extends readonly any[]
? Array<E> ? T extends any[]
? unknown[] // mutable array - require mutable input
: readonly unknown[] // readonly array - accept readonly input
: RemoveAllBrands<T>; : 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 * 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 * type to combine both (useful for composite branded types. Make sure you
* compose branded types which are not composite themselves.) * compose branded types which are not composite themselves.)
*/ */
export const toBrandedType = <BrandedType, CurrentType = BrandedType>( export function toBrandedType<BrandedType>(
value: Unbrand<BrandedType>, value: UnbrandForValue<BrandedType>,
) => { ): BrandedType;
return value as CurrentType & BrandedType; export function toBrandedType<BrandedType, CurrentType>(
}; value: CurrentType,
): CombineBrands<BrandedType, CurrentType>;
export function toBrandedType(value: unknown) {
return value;
}
// ----------------------------------------------------------------------------- // -----------------------------------------------------------------------------
+6
View File
@@ -17,6 +17,12 @@
"development": "./dist/dev/index.js", "development": "./dist/dev/index.js",
"production": "./dist/prod/index.js", "production": "./dist/prod/index.js",
"default": "./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": [ "files": [
@@ -27,6 +27,9 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to existing s
"opacity": 100, "opacity": 100,
"roughness": 1, "roughness": 1,
"roundness": null, "roundness": null,
"schemaState": {
"tracks": {},
},
"seed": Any<Number>, "seed": Any<Number>,
"strokeColor": "#66a80f", "strokeColor": "#66a80f",
"strokeStyle": "solid", "strokeStyle": "solid",
@@ -64,6 +67,9 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to existing s
"opacity": 100, "opacity": 100,
"roughness": 1, "roughness": 1,
"roundness": null, "roundness": null,
"schemaState": {
"tracks": {},
},
"seed": Any<Number>, "seed": Any<Number>,
"strokeColor": "#9c36b5", "strokeColor": "#9c36b5",
"strokeStyle": "solid", "strokeStyle": "solid",
@@ -116,6 +122,9 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to existing s
], ],
"roughness": 1, "roughness": 1,
"roundness": null, "roundness": null,
"schemaState": {
"tracks": {},
},
"seed": Any<Number>, "seed": Any<Number>,
"startArrowhead": null, "startArrowhead": null,
"startBinding": { "startBinding": {
@@ -177,6 +186,9 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to existing s
], ],
"roughness": 1, "roughness": 1,
"roundness": null, "roundness": null,
"schemaState": {
"tracks": {},
},
"seed": Any<Number>, "seed": Any<Number>,
"startArrowhead": null, "startArrowhead": null,
"startBinding": { "startBinding": {
@@ -223,6 +235,9 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to existing s
"opacity": 100, "opacity": 100,
"roughness": 1, "roughness": 1,
"roundness": null, "roundness": null,
"schemaState": {
"tracks": {},
},
"seed": Any<Number>, "seed": Any<Number>,
"strokeColor": "#1e1e1e", "strokeColor": "#1e1e1e",
"strokeStyle": "solid", "strokeStyle": "solid",
@@ -266,6 +281,9 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to existing t
"originalText": "HEYYYYY", "originalText": "HEYYYYY",
"roughness": 1, "roughness": 1,
"roundness": null, "roundness": null,
"schemaState": {
"tracks": {},
},
"seed": Any<Number>, "seed": Any<Number>,
"strokeColor": "#c2255c", "strokeColor": "#c2255c",
"strokeStyle": "solid", "strokeStyle": "solid",
@@ -312,6 +330,9 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to existing t
"originalText": "Whats up ?", "originalText": "Whats up ?",
"roughness": 1, "roughness": 1,
"roundness": null, "roundness": null,
"schemaState": {
"tracks": {},
},
"seed": Any<Number>, "seed": Any<Number>,
"strokeColor": "#1e1e1e", "strokeColor": "#1e1e1e",
"strokeStyle": "solid", "strokeStyle": "solid",
@@ -372,6 +393,9 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to existing t
], ],
"roughness": 1, "roughness": 1,
"roundness": null, "roundness": null,
"schemaState": {
"tracks": {},
},
"seed": Any<Number>, "seed": Any<Number>,
"startArrowhead": null, "startArrowhead": null,
"startBinding": { "startBinding": {
@@ -419,6 +443,9 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to existing t
"originalText": "HELLO WORLD!!", "originalText": "HELLO WORLD!!",
"roughness": 1, "roughness": 1,
"roundness": null, "roundness": null,
"schemaState": {
"tracks": {},
},
"seed": Any<Number>, "seed": Any<Number>,
"strokeColor": "#1e1e1e", "strokeColor": "#1e1e1e",
"strokeStyle": "solid", "strokeStyle": "solid",
@@ -479,6 +506,9 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to shapes whe
], ],
"roughness": 1, "roughness": 1,
"roundness": null, "roundness": null,
"schemaState": {
"tracks": {},
},
"seed": Any<Number>, "seed": Any<Number>,
"startArrowhead": null, "startArrowhead": null,
"startBinding": { "startBinding": {
@@ -526,6 +556,9 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to shapes whe
"originalText": "HELLO WORLD!!", "originalText": "HELLO WORLD!!",
"roughness": 1, "roughness": 1,
"roundness": null, "roundness": null,
"schemaState": {
"tracks": {},
},
"seed": Any<Number>, "seed": Any<Number>,
"strokeColor": "#1e1e1e", "strokeColor": "#1e1e1e",
"strokeStyle": "solid", "strokeStyle": "solid",
@@ -566,6 +599,9 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to shapes whe
"opacity": 100, "opacity": 100,
"roughness": 1, "roughness": 1,
"roundness": null, "roundness": null,
"schemaState": {
"tracks": {},
},
"seed": Any<Number>, "seed": Any<Number>,
"strokeColor": "#1e1e1e", "strokeColor": "#1e1e1e",
"strokeStyle": "solid", "strokeStyle": "solid",
@@ -603,6 +639,9 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to shapes whe
"opacity": 100, "opacity": 100,
"roughness": 1, "roughness": 1,
"roundness": null, "roundness": null,
"schemaState": {
"tracks": {},
},
"seed": Any<Number>, "seed": Any<Number>,
"strokeColor": "#1e1e1e", "strokeColor": "#1e1e1e",
"strokeStyle": "solid", "strokeStyle": "solid",
@@ -660,6 +699,9 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to text when
], ],
"roughness": 1, "roughness": 1,
"roundness": null, "roundness": null,
"schemaState": {
"tracks": {},
},
"seed": Any<Number>, "seed": Any<Number>,
"startArrowhead": null, "startArrowhead": null,
"startBinding": { "startBinding": {
@@ -707,6 +749,9 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to text when
"originalText": "HELLO WORLD!!", "originalText": "HELLO WORLD!!",
"roughness": 1, "roughness": 1,
"roundness": null, "roundness": null,
"schemaState": {
"tracks": {},
},
"seed": Any<Number>, "seed": Any<Number>,
"strokeColor": "#1e1e1e", "strokeColor": "#1e1e1e",
"strokeStyle": "solid", "strokeStyle": "solid",
@@ -753,6 +798,9 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to text when
"originalText": "HEYYYYY", "originalText": "HEYYYYY",
"roughness": 1, "roughness": 1,
"roundness": null, "roundness": null,
"schemaState": {
"tracks": {},
},
"seed": Any<Number>, "seed": Any<Number>,
"strokeColor": "#1e1e1e", "strokeColor": "#1e1e1e",
"strokeStyle": "solid", "strokeStyle": "solid",
@@ -799,6 +847,9 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to text when
"originalText": "WHATS UP ?", "originalText": "WHATS UP ?",
"roughness": 1, "roughness": 1,
"roundness": null, "roundness": null,
"schemaState": {
"tracks": {},
},
"seed": Any<Number>, "seed": Any<Number>,
"strokeColor": "#1e1e1e", "strokeColor": "#1e1e1e",
"strokeStyle": "solid", "strokeStyle": "solid",
@@ -834,6 +885,9 @@ exports[`Test Transform > should not allow duplicate ids 1`] = `
"opacity": 100, "opacity": 100,
"roughness": 1, "roughness": 1,
"roundness": null, "roundness": null,
"schemaState": {
"tracks": {},
},
"seed": Any<Number>, "seed": Any<Number>,
"strokeColor": "#1e1e1e", "strokeColor": "#1e1e1e",
"strokeStyle": "solid", "strokeStyle": "solid",
@@ -879,6 +933,9 @@ exports[`Test Transform > should transform linear elements 1`] = `
], ],
"roughness": 1, "roughness": 1,
"roundness": null, "roundness": null,
"schemaState": {
"tracks": {},
},
"seed": Any<Number>, "seed": Any<Number>,
"startArrowhead": null, "startArrowhead": null,
"startBinding": null, "startBinding": null,
@@ -926,6 +983,9 @@ exports[`Test Transform > should transform linear elements 2`] = `
], ],
"roughness": 1, "roughness": 1,
"roundness": null, "roundness": null,
"schemaState": {
"tracks": {},
},
"seed": Any<Number>, "seed": Any<Number>,
"startArrowhead": "dot", "startArrowhead": "dot",
"startBinding": null, "startBinding": null,
@@ -973,6 +1033,9 @@ exports[`Test Transform > should transform linear elements 3`] = `
"polygon": false, "polygon": false,
"roughness": 1, "roughness": 1,
"roundness": null, "roundness": null,
"schemaState": {
"tracks": {},
},
"seed": Any<Number>, "seed": Any<Number>,
"startArrowhead": null, "startArrowhead": null,
"startBinding": null, "startBinding": null,
@@ -1020,6 +1083,9 @@ exports[`Test Transform > should transform linear elements 4`] = `
"polygon": false, "polygon": false,
"roughness": 1, "roughness": 1,
"roundness": null, "roundness": null,
"schemaState": {
"tracks": {},
},
"seed": Any<Number>, "seed": Any<Number>,
"startArrowhead": null, "startArrowhead": null,
"startBinding": null, "startBinding": null,
@@ -1054,6 +1120,9 @@ exports[`Test Transform > should transform regular shapes 1`] = `
"opacity": 100, "opacity": 100,
"roughness": 1, "roughness": 1,
"roundness": null, "roundness": null,
"schemaState": {
"tracks": {},
},
"seed": Any<Number>, "seed": Any<Number>,
"strokeColor": "#1e1e1e", "strokeColor": "#1e1e1e",
"strokeStyle": "solid", "strokeStyle": "solid",
@@ -1086,6 +1155,9 @@ exports[`Test Transform > should transform regular shapes 2`] = `
"opacity": 100, "opacity": 100,
"roughness": 1, "roughness": 1,
"roundness": null, "roundness": null,
"schemaState": {
"tracks": {},
},
"seed": Any<Number>, "seed": Any<Number>,
"strokeColor": "#1e1e1e", "strokeColor": "#1e1e1e",
"strokeStyle": "solid", "strokeStyle": "solid",
@@ -1118,6 +1190,9 @@ exports[`Test Transform > should transform regular shapes 3`] = `
"opacity": 100, "opacity": 100,
"roughness": 1, "roughness": 1,
"roundness": null, "roundness": null,
"schemaState": {
"tracks": {},
},
"seed": Any<Number>, "seed": Any<Number>,
"strokeColor": "#1e1e1e", "strokeColor": "#1e1e1e",
"strokeStyle": "solid", "strokeStyle": "solid",
@@ -1150,6 +1225,9 @@ exports[`Test Transform > should transform regular shapes 4`] = `
"opacity": 100, "opacity": 100,
"roughness": 1, "roughness": 1,
"roundness": null, "roundness": null,
"schemaState": {
"tracks": {},
},
"seed": Any<Number>, "seed": Any<Number>,
"strokeColor": "#1e1e1e", "strokeColor": "#1e1e1e",
"strokeStyle": "solid", "strokeStyle": "solid",
@@ -1182,6 +1260,9 @@ exports[`Test Transform > should transform regular shapes 5`] = `
"opacity": 100, "opacity": 100,
"roughness": 1, "roughness": 1,
"roundness": null, "roundness": null,
"schemaState": {
"tracks": {},
},
"seed": Any<Number>, "seed": Any<Number>,
"strokeColor": "#1e1e1e", "strokeColor": "#1e1e1e",
"strokeStyle": "dotted", "strokeStyle": "dotted",
@@ -1214,6 +1295,9 @@ exports[`Test Transform > should transform regular shapes 6`] = `
"opacity": 100, "opacity": 100,
"roughness": 1, "roughness": 1,
"roundness": null, "roundness": null,
"schemaState": {
"tracks": {},
},
"seed": Any<Number>, "seed": Any<Number>,
"strokeColor": "#1971c2", "strokeColor": "#1971c2",
"strokeStyle": "dashed", "strokeStyle": "dashed",
@@ -1252,6 +1336,9 @@ exports[`Test Transform > should transform text element 1`] = `
"originalText": "HELLO WORLD!", "originalText": "HELLO WORLD!",
"roughness": 1, "roughness": 1,
"roundness": null, "roundness": null,
"schemaState": {
"tracks": {},
},
"seed": Any<Number>, "seed": Any<Number>,
"strokeColor": "#1e1e1e", "strokeColor": "#1e1e1e",
"strokeStyle": "solid", "strokeStyle": "solid",
@@ -1293,6 +1380,9 @@ exports[`Test Transform > should transform text element 2`] = `
"originalText": "STYLED HELLO WORLD!", "originalText": "STYLED HELLO WORLD!",
"roughness": 1, "roughness": 1,
"roundness": null, "roundness": null,
"schemaState": {
"tracks": {},
},
"seed": Any<Number>, "seed": Any<Number>,
"strokeColor": "#5f3dc4", "strokeColor": "#5f3dc4",
"strokeStyle": "solid", "strokeStyle": "solid",
@@ -1339,6 +1429,9 @@ exports[`Test Transform > should transform the elements correctly when linear el
"opacity": 100, "opacity": 100,
"roughness": 1, "roughness": 1,
"roundness": null, "roundness": null,
"schemaState": {
"tracks": {},
},
"seed": Any<Number>, "seed": Any<Number>,
"strokeColor": "#1e1e1e", "strokeColor": "#1e1e1e",
"strokeStyle": "solid", "strokeStyle": "solid",
@@ -1378,6 +1471,9 @@ exports[`Test Transform > should transform the elements correctly when linear el
"opacity": 100, "opacity": 100,
"roughness": 1, "roughness": 1,
"roundness": null, "roundness": null,
"schemaState": {
"tracks": {},
},
"seed": Any<Number>, "seed": Any<Number>,
"strokeColor": "#1e1e1e", "strokeColor": "#1e1e1e",
"strokeStyle": "solid", "strokeStyle": "solid",
@@ -1421,6 +1517,9 @@ exports[`Test Transform > should transform the elements correctly when linear el
"opacity": 100, "opacity": 100,
"roughness": 1, "roughness": 1,
"roundness": null, "roundness": null,
"schemaState": {
"tracks": {},
},
"seed": Any<Number>, "seed": Any<Number>,
"strokeColor": "#1e1e1e", "strokeColor": "#1e1e1e",
"strokeStyle": "solid", "strokeStyle": "solid",
@@ -1468,6 +1567,9 @@ exports[`Test Transform > should transform the elements correctly when linear el
"opacity": 100, "opacity": 100,
"roughness": 1, "roughness": 1,
"roundness": null, "roundness": null,
"schemaState": {
"tracks": {},
},
"seed": Any<Number>, "seed": Any<Number>,
"strokeColor": "#1e1e1e", "strokeColor": "#1e1e1e",
"strokeStyle": "solid", "strokeStyle": "solid",
@@ -1527,6 +1629,9 @@ exports[`Test Transform > should transform the elements correctly when linear el
"roundness": { "roundness": {
"type": 2, "type": 2,
}, },
"schemaState": {
"tracks": {},
},
"seed": Any<Number>, "seed": Any<Number>,
"startArrowhead": null, "startArrowhead": null,
"startBinding": { "startBinding": {
@@ -1591,6 +1696,9 @@ exports[`Test Transform > should transform the elements correctly when linear el
"roundness": { "roundness": {
"type": 2, "type": 2,
}, },
"schemaState": {
"tracks": {},
},
"seed": Any<Number>, "seed": Any<Number>,
"startArrowhead": null, "startArrowhead": null,
"startBinding": { "startBinding": {
@@ -1640,6 +1748,9 @@ exports[`Test Transform > should transform the elements correctly when linear el
"originalText": "B", "originalText": "B",
"roughness": 1, "roughness": 1,
"roundness": null, "roundness": null,
"schemaState": {
"tracks": {},
},
"seed": Any<Number>, "seed": Any<Number>,
"strokeColor": "#1e1e1e", "strokeColor": "#1e1e1e",
"strokeStyle": "solid", "strokeStyle": "solid",
@@ -1683,6 +1794,9 @@ exports[`Test Transform > should transform the elements correctly when linear el
"originalText": "A", "originalText": "A",
"roughness": 1, "roughness": 1,
"roundness": null, "roundness": null,
"schemaState": {
"tracks": {},
},
"seed": Any<Number>, "seed": Any<Number>,
"strokeColor": "#1e1e1e", "strokeColor": "#1e1e1e",
"strokeStyle": "solid", "strokeStyle": "solid",
@@ -1726,6 +1840,9 @@ exports[`Test Transform > should transform the elements correctly when linear el
"originalText": "Alice", "originalText": "Alice",
"roughness": 1, "roughness": 1,
"roundness": null, "roundness": null,
"schemaState": {
"tracks": {},
},
"seed": Any<Number>, "seed": Any<Number>,
"strokeColor": "#1e1e1e", "strokeColor": "#1e1e1e",
"strokeStyle": "solid", "strokeStyle": "solid",
@@ -1769,6 +1886,9 @@ exports[`Test Transform > should transform the elements correctly when linear el
"originalText": "Bob", "originalText": "Bob",
"roughness": 1, "roughness": 1,
"roundness": null, "roundness": null,
"schemaState": {
"tracks": {},
},
"seed": Any<Number>, "seed": Any<Number>,
"strokeColor": "#1e1e1e", "strokeColor": "#1e1e1e",
"strokeStyle": "solid", "strokeStyle": "solid",
@@ -1810,6 +1930,9 @@ exports[`Test Transform > should transform the elements correctly when linear el
"originalText": "How are you?", "originalText": "How are you?",
"roughness": 1, "roughness": 1,
"roundness": null, "roundness": null,
"schemaState": {
"tracks": {},
},
"seed": Any<Number>, "seed": Any<Number>,
"strokeColor": "#1e1e1e", "strokeColor": "#1e1e1e",
"strokeStyle": "solid", "strokeStyle": "solid",
@@ -1851,6 +1974,9 @@ exports[`Test Transform > should transform the elements correctly when linear el
"originalText": "Friendship", "originalText": "Friendship",
"roughness": 1, "roughness": 1,
"roundness": null, "roundness": null,
"schemaState": {
"tracks": {},
},
"seed": Any<Number>, "seed": Any<Number>,
"strokeColor": "#1e1e1e", "strokeColor": "#1e1e1e",
"strokeStyle": "solid", "strokeStyle": "solid",
@@ -1904,6 +2030,9 @@ exports[`Test Transform > should transform to labelled arrows when label provide
], ],
"roughness": 1, "roughness": 1,
"roundness": null, "roundness": null,
"schemaState": {
"tracks": {},
},
"seed": Any<Number>, "seed": Any<Number>,
"startArrowhead": null, "startArrowhead": null,
"startBinding": null, "startBinding": null,
@@ -1956,6 +2085,9 @@ exports[`Test Transform > should transform to labelled arrows when label provide
], ],
"roughness": 1, "roughness": 1,
"roundness": null, "roundness": null,
"schemaState": {
"tracks": {},
},
"seed": Any<Number>, "seed": Any<Number>,
"startArrowhead": null, "startArrowhead": null,
"startBinding": null, "startBinding": null,
@@ -2008,6 +2140,9 @@ exports[`Test Transform > should transform to labelled arrows when label provide
], ],
"roughness": 1, "roughness": 1,
"roundness": null, "roundness": null,
"schemaState": {
"tracks": {},
},
"seed": Any<Number>, "seed": Any<Number>,
"startArrowhead": null, "startArrowhead": null,
"startBinding": null, "startBinding": null,
@@ -2060,6 +2195,9 @@ exports[`Test Transform > should transform to labelled arrows when label provide
], ],
"roughness": 1, "roughness": 1,
"roundness": null, "roundness": null,
"schemaState": {
"tracks": {},
},
"seed": Any<Number>, "seed": Any<Number>,
"startArrowhead": null, "startArrowhead": null,
"startBinding": null, "startBinding": null,
@@ -2100,6 +2238,9 @@ exports[`Test Transform > should transform to labelled arrows when label provide
"originalText": "LABELED ARROW", "originalText": "LABELED ARROW",
"roughness": 1, "roughness": 1,
"roundness": null, "roundness": null,
"schemaState": {
"tracks": {},
},
"seed": Any<Number>, "seed": Any<Number>,
"strokeColor": "#1e1e1e", "strokeColor": "#1e1e1e",
"strokeStyle": "solid", "strokeStyle": "solid",
@@ -2141,6 +2282,9 @@ exports[`Test Transform > should transform to labelled arrows when label provide
"originalText": "STYLED LABELED ARROW", "originalText": "STYLED LABELED ARROW",
"roughness": 1, "roughness": 1,
"roundness": null, "roundness": null,
"schemaState": {
"tracks": {},
},
"seed": Any<Number>, "seed": Any<Number>,
"strokeColor": "#099268", "strokeColor": "#099268",
"strokeStyle": "solid", "strokeStyle": "solid",
@@ -2182,6 +2326,9 @@ exports[`Test Transform > should transform to labelled arrows when label provide
"originalText": "ANOTHER STYLED LABELLED ARROW", "originalText": "ANOTHER STYLED LABELLED ARROW",
"roughness": 1, "roughness": 1,
"roundness": null, "roundness": null,
"schemaState": {
"tracks": {},
},
"seed": Any<Number>, "seed": Any<Number>,
"strokeColor": "#1098ad", "strokeColor": "#1098ad",
"strokeStyle": "solid", "strokeStyle": "solid",
@@ -2224,6 +2371,9 @@ exports[`Test Transform > should transform to labelled arrows when label provide
"originalText": "ANOTHER STYLED LABELLED ARROW", "originalText": "ANOTHER STYLED LABELLED ARROW",
"roughness": 1, "roughness": 1,
"roundness": null, "roundness": null,
"schemaState": {
"tracks": {},
},
"seed": Any<Number>, "seed": Any<Number>,
"strokeColor": "#099268", "strokeColor": "#099268",
"strokeStyle": "solid", "strokeStyle": "solid",
@@ -2265,6 +2415,9 @@ exports[`Test Transform > should transform to text containers when label provide
"opacity": 100, "opacity": 100,
"roughness": 1, "roughness": 1,
"roundness": null, "roundness": null,
"schemaState": {
"tracks": {},
},
"seed": Any<Number>, "seed": Any<Number>,
"strokeColor": "#1e1e1e", "strokeColor": "#1e1e1e",
"strokeStyle": "solid", "strokeStyle": "solid",
@@ -2302,6 +2455,9 @@ exports[`Test Transform > should transform to text containers when label provide
"opacity": 100, "opacity": 100,
"roughness": 1, "roughness": 1,
"roundness": null, "roundness": null,
"schemaState": {
"tracks": {},
},
"seed": Any<Number>, "seed": Any<Number>,
"strokeColor": "#1e1e1e", "strokeColor": "#1e1e1e",
"strokeStyle": "solid", "strokeStyle": "solid",
@@ -2339,6 +2495,9 @@ exports[`Test Transform > should transform to text containers when label provide
"opacity": 100, "opacity": 100,
"roughness": 1, "roughness": 1,
"roundness": null, "roundness": null,
"schemaState": {
"tracks": {},
},
"seed": Any<Number>, "seed": Any<Number>,
"strokeColor": "#1e1e1e", "strokeColor": "#1e1e1e",
"strokeStyle": "solid", "strokeStyle": "solid",
@@ -2376,6 +2535,9 @@ exports[`Test Transform > should transform to text containers when label provide
"opacity": 100, "opacity": 100,
"roughness": 1, "roughness": 1,
"roundness": null, "roundness": null,
"schemaState": {
"tracks": {},
},
"seed": Any<Number>, "seed": Any<Number>,
"strokeColor": "#1e1e1e", "strokeColor": "#1e1e1e",
"strokeStyle": "solid", "strokeStyle": "solid",
@@ -2413,6 +2575,9 @@ exports[`Test Transform > should transform to text containers when label provide
"opacity": 100, "opacity": 100,
"roughness": 1, "roughness": 1,
"roundness": null, "roundness": null,
"schemaState": {
"tracks": {},
},
"seed": Any<Number>, "seed": Any<Number>,
"strokeColor": "#c2255c", "strokeColor": "#c2255c",
"strokeStyle": "solid", "strokeStyle": "solid",
@@ -2450,6 +2615,9 @@ exports[`Test Transform > should transform to text containers when label provide
"opacity": 100, "opacity": 100,
"roughness": 1, "roughness": 1,
"roundness": null, "roundness": null,
"schemaState": {
"tracks": {},
},
"seed": Any<Number>, "seed": Any<Number>,
"strokeColor": "#f08c00", "strokeColor": "#f08c00",
"strokeStyle": "solid", "strokeStyle": "solid",
@@ -2488,6 +2656,9 @@ exports[`Test Transform > should transform to text containers when label provide
"originalText": "RECTANGLE TEXT CONTAINER", "originalText": "RECTANGLE TEXT CONTAINER",
"roughness": 1, "roughness": 1,
"roundness": null, "roundness": null,
"schemaState": {
"tracks": {},
},
"seed": Any<Number>, "seed": Any<Number>,
"strokeColor": "#1e1e1e", "strokeColor": "#1e1e1e",
"strokeStyle": "solid", "strokeStyle": "solid",
@@ -2529,6 +2700,9 @@ exports[`Test Transform > should transform to text containers when label provide
"originalText": "ELLIPSE TEXT CONTAINER", "originalText": "ELLIPSE TEXT CONTAINER",
"roughness": 1, "roughness": 1,
"roundness": null, "roundness": null,
"schemaState": {
"tracks": {},
},
"seed": Any<Number>, "seed": Any<Number>,
"strokeColor": "#1e1e1e", "strokeColor": "#1e1e1e",
"strokeStyle": "solid", "strokeStyle": "solid",
@@ -2572,6 +2746,9 @@ exports[`Test Transform > should transform to text containers when label provide
TEXT CONTAINER", TEXT CONTAINER",
"roughness": 1, "roughness": 1,
"roundness": null, "roundness": null,
"schemaState": {
"tracks": {},
},
"seed": Any<Number>, "seed": Any<Number>,
"strokeColor": "#1e1e1e", "strokeColor": "#1e1e1e",
"strokeStyle": "solid", "strokeStyle": "solid",
@@ -2615,6 +2792,9 @@ exports[`Test Transform > should transform to text containers when label provide
"originalText": "STYLED DIAMOND TEXT CONTAINER", "originalText": "STYLED DIAMOND TEXT CONTAINER",
"roughness": 1, "roughness": 1,
"roundness": null, "roundness": null,
"schemaState": {
"tracks": {},
},
"seed": Any<Number>, "seed": Any<Number>,
"strokeColor": "#099268", "strokeColor": "#099268",
"strokeStyle": "solid", "strokeStyle": "solid",
@@ -2657,6 +2837,9 @@ exports[`Test Transform > should transform to text containers when label provide
"originalText": "TOP LEFT ALIGNED RECTANGLE TEXT CONTAINER", "originalText": "TOP LEFT ALIGNED RECTANGLE TEXT CONTAINER",
"roughness": 1, "roughness": 1,
"roundness": null, "roundness": null,
"schemaState": {
"tracks": {},
},
"seed": Any<Number>, "seed": Any<Number>,
"strokeColor": "#c2255c", "strokeColor": "#c2255c",
"strokeStyle": "solid", "strokeStyle": "solid",
@@ -2700,6 +2883,9 @@ exports[`Test Transform > should transform to text containers when label provide
"originalText": "STYLED ELLIPSE TEXT CONTAINER", "originalText": "STYLED ELLIPSE TEXT CONTAINER",
"roughness": 1, "roughness": 1,
"roundness": null, "roundness": null,
"schemaState": {
"tracks": {},
},
"seed": Any<Number>, "seed": Any<Number>,
"strokeColor": "#c2255c", "strokeColor": "#c2255c",
"strokeStyle": "solid", "strokeStyle": "solid",
+558
View File
@@ -0,0 +1,558 @@
import { pointDistance, pointFrom, type GlobalPoint } from "@excalidraw/math";
import { invariant } from "@excalidraw/common";
import type { AppState, NullableGridSize } from "@excalidraw/excalidraw/types";
import {
bindBindingElement,
calculateFixedPointForNonElbowArrowBinding,
FOCUS_POINT_SIZE,
getBindingGap,
getGlobalFixedPointForBindableElement,
isBindingEnabled,
maxBindingDistance_simple,
unbindBindingElement,
updateBoundPoint,
} from "../binding";
import {
isBindableElement,
isBindingElement,
isElbowArrow,
} from "../typeChecks";
import { LinearElementEditor } from "../linearElementEditor";
import { getHoveredElementForFocusPoint, hitElementItself } from "../collision";
import { moveArrowAboveBindable } from "../zindex";
import type {
ElementsMap,
ExcalidrawArrowElement,
ExcalidrawBindableElement,
NonDeletedSceneElementsMap,
PointsPositionUpdates,
} from "../types";
import type { Scene } from "../Scene";
export const isFocusPointVisible = (
focusPoint: GlobalPoint,
arrow: ExcalidrawArrowElement,
bindableElement: ExcalidrawBindableElement,
elementsMap: ElementsMap,
appState: {
isBindingEnabled: AppState["isBindingEnabled"];
zoom: AppState["zoom"];
},
startOrEnd: "start" | "end",
ignoreOverlap = false,
): boolean => {
// No focus point management for elbow arrows, because elbow arrows
// always have their focus point at the arrow point itself
if (
isElbowArrow(arrow) ||
!isBindingEnabled(appState) ||
arrow.points.length !== 2
) {
return false;
}
// Avoid showing the focus point indicator if the focus point is essentially
// on top of the arrow point it belongs to itself, if not ignoring specifically
if (!ignoreOverlap) {
const associatedPointIdx =
arrow.startBinding?.elementId === bindableElement.id
? 0
: arrow.points.length - 1;
const associatedArrowPoint =
LinearElementEditor.getPointAtIndexGlobalCoordinates(
arrow,
associatedPointIdx,
elementsMap,
);
if (
pointDistance(focusPoint, associatedArrowPoint) <
(FOCUS_POINT_SIZE * 1.5) / appState.zoom.value
) {
return false;
}
}
const arrowPoint = LinearElementEditor.getPointAtIndexGlobalCoordinates(
arrow,
startOrEnd === "end" ? arrow.points.length - 1 : 0,
elementsMap,
);
// Check if the focus point is within the element's shape bounds
// Endpoint dragging takes precedence
return (
pointDistance(focusPoint, arrowPoint) >=
(FOCUS_POINT_SIZE * 1.5) / appState.zoom.value &&
hitElementItself({
element: bindableElement,
elementsMap,
point: focusPoint,
threshold: getBindingGap(bindableElement, arrow),
overrideShouldTestInside: true,
})
);
};
// Updates the arrow endpoints in "orbit" configuration
const focusPointUpdate = (
arrow: ExcalidrawArrowElement,
bindableElement: ExcalidrawBindableElement | null,
isStartBinding: boolean,
elementsMap: NonDeletedSceneElementsMap,
scene: Scene,
appState: AppState,
switchToInsideBinding: boolean,
) => {
const pointUpdates = new Map();
const bindingField = isStartBinding ? "startBinding" : "endBinding";
const adjacentBindingField = isStartBinding ? "endBinding" : "startBinding";
let currentBinding = arrow[bindingField];
let adjacentBinding = arrow[adjacentBindingField];
// Update the dragged focus point related end
if (currentBinding && bindableElement) {
// Update the targeted bindings
const boundToSameElement =
bindableElement &&
adjacentBinding &&
currentBinding.elementId === adjacentBinding.elementId;
if (switchToInsideBinding || boundToSameElement) {
currentBinding = {
...currentBinding,
mode: "inside",
};
} else {
currentBinding = {
...currentBinding,
mode: "orbit",
};
}
const pointIndex = isStartBinding ? 0 : arrow.points.length - 1;
const newPoint = updateBoundPoint(
arrow,
bindingField as "startBinding" | "endBinding",
currentBinding,
bindableElement,
elementsMap,
true,
);
if (newPoint) {
pointUpdates.set(pointIndex, { point: newPoint });
}
}
// Also update the adjacent end if it has a binding
if (adjacentBinding && adjacentBinding.mode === "orbit") {
const adjacentBindableElement = elementsMap.get(
adjacentBinding.elementId,
) as ExcalidrawBindableElement;
if (
adjacentBindableElement &&
isBindableElement(adjacentBindableElement) &&
isBindingEnabled(appState)
) {
// Same shape bound on both ends
const boundToSameElementAfterUpdate =
bindableElement && adjacentBinding.elementId === bindableElement.id;
if (switchToInsideBinding || boundToSameElementAfterUpdate) {
adjacentBinding = {
...adjacentBinding,
mode: "inside",
};
} else {
adjacentBinding = {
...adjacentBinding,
mode: "orbit",
};
}
const adjacentPointIndex = isStartBinding ? arrow.points.length - 1 : 0;
const adjacentNewPoint = updateBoundPoint(
arrow,
adjacentBindingField,
adjacentBinding,
adjacentBindableElement,
elementsMap,
);
if (adjacentNewPoint) {
pointUpdates.set(adjacentPointIndex, {
point: adjacentNewPoint,
});
}
}
}
if (pointUpdates.size > 0) {
LinearElementEditor.movePoints(arrow, scene, pointUpdates, {
[bindingField]: currentBinding,
[adjacentBindingField]: adjacentBinding,
});
}
};
export const handleFocusPointDrag = (
linearElementEditor: LinearElementEditor,
elementsMap: NonDeletedSceneElementsMap,
pointerCoords: { x: number; y: number },
scene: Scene,
appState: AppState,
gridSize: NullableGridSize,
switchToInsideBinding: boolean,
) => {
const arrow = LinearElementEditor.getElement(
linearElementEditor.elementId,
elementsMap,
) as any;
// Sanity checks
if (
!arrow ||
!isBindingElement(arrow) ||
isElbowArrow(arrow) ||
!linearElementEditor.hoveredFocusPointBinding ||
!linearElementEditor.draggedFocusPointBinding
) {
return;
}
const isStartBinding =
linearElementEditor.draggedFocusPointBinding === "start";
const binding = isStartBinding ? arrow.startBinding : arrow.endBinding;
const { x: offsetX, y: offsetY } = linearElementEditor.pointerOffset;
const point = pointFrom<GlobalPoint>(
pointerCoords.x - offsetX,
pointerCoords.y - offsetY,
);
const bindingField = isStartBinding ? "startBinding" : "endBinding";
const hit = getHoveredElementForFocusPoint(
point,
arrow,
scene.getNonDeletedElements(),
elementsMap,
maxBindingDistance_simple(appState.zoom),
);
// Hovering a bindable element
if (hit && isBindingEnabled(appState)) {
// Break existing binding if bound to another shape or if binding is disabled
if (arrow[bindingField] && hit.id !== binding?.elementId) {
unbindBindingElement(
arrow,
linearElementEditor.draggedFocusPointBinding,
scene,
);
}
// Handle binding mode switch
const newMode =
switchToInsideBinding && arrow[bindingField]?.mode === "orbit"
? "inside"
: !switchToInsideBinding && arrow[bindingField]?.mode === "inside"
? "orbit"
: null;
// If no existing binding, create it
if (!arrow[bindingField] || newMode) {
// Create a new binding if none exists
bindBindingElement(
arrow,
hit,
newMode || "orbit",
linearElementEditor.draggedFocusPointBinding,
scene,
point,
);
}
// Update the binding's fixed point
scene.mutateElement(arrow, {
[bindingField]: {
...arrow[bindingField],
elementId: hit.id,
mode: newMode || arrow[bindingField]?.mode || "orbit",
...calculateFixedPointForNonElbowArrowBinding(
arrow,
hit,
linearElementEditor.draggedFocusPointBinding,
elementsMap,
point,
),
},
});
} else {
// Not hovering any bindable element, move the arrow endpoint
const pointUpdates: PointsPositionUpdates = new Map();
const pointIndex = isStartBinding ? 0 : arrow.points.length - 1;
pointUpdates.set(pointIndex, {
point: LinearElementEditor.createPointAt(
arrow,
elementsMap,
point[0],
point[1],
gridSize,
),
});
LinearElementEditor.movePoints(arrow, scene, pointUpdates);
if (arrow[bindingField]) {
unbindBindingElement(arrow, isStartBinding ? "start" : "end", scene);
}
}
// Update the arrow endpoints
focusPointUpdate(
arrow,
hit,
isStartBinding,
elementsMap,
scene,
appState,
switchToInsideBinding,
);
if (hit && isBindingEnabled(appState)) {
moveArrowAboveBindable(
point,
arrow,
scene.getElementsIncludingDeleted(),
elementsMap,
scene,
hit,
);
}
};
export const handleFocusPointPointerDown = (
arrow: ExcalidrawArrowElement,
pointerDownState: { origin: { x: number; y: number } },
elementsMap: NonDeletedSceneElementsMap,
appState: AppState,
): {
hitFocusPoint: "start" | "end" | null;
pointerOffset: { x: number; y: number };
} => {
const pointerPos = pointFrom(
pointerDownState.origin.x,
pointerDownState.origin.y,
);
const hitThreshold = (FOCUS_POINT_SIZE * 1.5) / appState.zoom.value;
// Check start binding focus point
if (arrow.startBinding?.elementId) {
const bindableElement = elementsMap.get(arrow.startBinding.elementId);
if (
bindableElement &&
isBindableElement(bindableElement) &&
!bindableElement.isDeleted
) {
const focusPoint = getGlobalFixedPointForBindableElement(
arrow.startBinding.fixedPoint,
bindableElement,
elementsMap,
);
if (
isFocusPointVisible(
focusPoint,
arrow,
bindableElement,
elementsMap,
appState,
"start",
) &&
pointDistance(pointerPos, focusPoint) <= hitThreshold
) {
return {
hitFocusPoint: "start",
pointerOffset: {
x: pointerPos[0] - focusPoint[0],
y: pointerPos[1] - focusPoint[1],
},
};
}
}
}
// Check end binding focus point (only if start not already hit)
if (arrow.endBinding?.elementId) {
const bindableElement = elementsMap.get(arrow.endBinding.elementId);
if (
bindableElement &&
isBindableElement(bindableElement) &&
!bindableElement.isDeleted
) {
const focusPoint = getGlobalFixedPointForBindableElement(
arrow.endBinding.fixedPoint,
bindableElement,
elementsMap,
);
if (
isFocusPointVisible(
focusPoint,
arrow,
bindableElement,
elementsMap,
appState,
"end",
) &&
pointDistance(pointerPos, focusPoint) <= hitThreshold
) {
return {
hitFocusPoint: "end",
pointerOffset: {
x: pointerPos[0] - focusPoint[0],
y: pointerPos[1] - focusPoint[1],
},
};
}
}
}
return {
hitFocusPoint: null,
pointerOffset: { x: 0, y: 0 },
};
};
export const handleFocusPointPointerUp = (
linearElementEditor: LinearElementEditor,
scene: Scene,
) => {
invariant(
linearElementEditor.draggedFocusPointBinding,
"Must have a dragged focus point at pointer release",
);
const arrow = LinearElementEditor.getElement<ExcalidrawArrowElement>(
linearElementEditor.elementId,
scene.getNonDeletedElementsMap(),
);
invariant(arrow, "Arrow must be in the scene");
// Clean up
const bindingKey =
linearElementEditor.draggedFocusPointBinding === "start"
? "startBinding"
: "endBinding";
const otherBindingKey =
linearElementEditor.draggedFocusPointBinding === "start"
? "endBinding"
: "startBinding";
const boundElementId = arrow[bindingKey]?.elementId;
const otherBoundElementId = arrow[otherBindingKey]?.elementId;
const oldBoundElement =
boundElementId &&
scene
.getNonDeletedElements()
.find(
(element) =>
element.id !== boundElementId &&
element.id !== otherBoundElementId &&
isBindableElement(element) &&
element.boundElements?.find(({ id }) => id === arrow.id),
);
if (oldBoundElement) {
scene.mutateElement(oldBoundElement, {
boundElements: oldBoundElement.boundElements?.filter(
({ id }) => id !== arrow.id,
),
});
}
// Record the new bound element
const boundElement =
boundElementId && scene.getNonDeletedElementsMap().get(boundElementId);
if (boundElement) {
scene.mutateElement(boundElement, {
boundElements: [
...(boundElement.boundElements || [])?.filter(
({ id }) => id !== arrow.id,
),
{
id: arrow.id,
type: "arrow",
},
],
});
}
};
export const handleFocusPointHover = (
arrow: ExcalidrawArrowElement,
scenePointerX: number,
scenePointerY: number,
scene: Scene,
appState: AppState,
): "start" | "end" | null => {
const elementsMap = scene.getNonDeletedElementsMap();
const pointerPos = pointFrom(scenePointerX, scenePointerY);
const hitThreshold = (FOCUS_POINT_SIZE * 1.5) / appState.zoom.value;
// Check start binding focus point
if (arrow.startBinding?.elementId) {
const bindableElement = elementsMap.get(arrow.startBinding.elementId);
if (
bindableElement &&
isBindableElement(bindableElement) &&
!bindableElement.isDeleted
) {
const focusPoint = getGlobalFixedPointForBindableElement(
arrow.startBinding.fixedPoint,
bindableElement,
elementsMap,
);
if (
isFocusPointVisible(
focusPoint,
arrow,
bindableElement,
elementsMap,
appState,
"start",
) &&
pointDistance(pointerPos, focusPoint) <= hitThreshold
) {
return "start";
}
}
}
// Check end binding focus point (only if start not already hovered)
if (arrow.endBinding?.elementId) {
const bindableElement = elementsMap.get(arrow.endBinding.elementId);
if (
bindableElement &&
isBindableElement(bindableElement) &&
!bindableElement.isDeleted
) {
const focusPoint = getGlobalFixedPointForBindableElement(
arrow.endBinding.fixedPoint,
bindableElement,
elementsMap,
);
if (
isFocusPointVisible(
focusPoint,
arrow,
bindableElement,
elementsMap,
appState,
"end",
) &&
pointDistance(pointerPos, focusPoint) <= hitThreshold
) {
return "end";
}
}
}
return null;
};
+45
View File
@@ -0,0 +1,45 @@
import type { App } from "@excalidraw/excalidraw/types";
import { LinearElementEditor } from "../linearElementEditor";
import { handleFocusPointDrag } from "./focus";
export const maybeHandleArrowPointlikeDrag = ({
app,
event,
}: {
app: App;
event: KeyboardEvent | React.KeyboardEvent<Element> | PointerEvent;
}): boolean => {
const appState = app.state;
if (appState.selectedLinearElement && app.lastPointerMoveCoords) {
// Update focus point status if the binding mode is changing
if (appState.selectedLinearElement.draggedFocusPointBinding) {
handleFocusPointDrag(
appState.selectedLinearElement,
app.scene.getNonDeletedElementsMap(),
app.lastPointerMoveCoords,
app.scene,
appState,
app.getEffectiveGridSize(),
event.altKey,
);
return true;
} else if (
appState.selectedLinearElement.hoverPointIndex !== null &&
app.lastPointerMoveEvent &&
appState.selectedLinearElement.initialState.lastClickedPoint >= 0 &&
appState.selectedLinearElement.isDragging
) {
LinearElementEditor.handlePointDragging(
app.lastPointerMoveEvent,
app,
app.lastPointerMoveCoords.x,
app.lastPointerMoveCoords.y,
appState.selectedLinearElement,
);
return true;
}
}
return false;
};
+310 -187
View File
@@ -15,6 +15,7 @@ import {
pointFrom, pointFrom,
pointFromVector, pointFromVector,
pointRotateRads, pointRotateRads,
pointsEqual,
vectorFromPoint, vectorFromPoint,
vectorNormalize, vectorNormalize,
vectorScale, vectorScale,
@@ -26,14 +27,11 @@ import type { AppState } from "@excalidraw/excalidraw/types";
import type { MapEntry, Mutable } from "@excalidraw/common/utility-types"; import type { MapEntry, Mutable } from "@excalidraw/common/utility-types";
import type { Bounds } from "@excalidraw/common"; import type { Bounds } from "@excalidraw/common";
import { import { getCenterForBounds } from "./bounds";
doBoundsIntersect,
getCenterForBounds,
getElementBounds,
} from "./bounds";
import { import {
getAllHoveredElementAtPoint, getAllHoveredElementAtPoint,
getHoveredElementForBinding, getHoveredElementForBinding,
hitElementItself,
intersectElementWithLineSegment, intersectElementWithLineSegment,
isBindableElementInsideOtherBindable, isBindableElementInsideOtherBindable,
isPointInElement, isPointInElement,
@@ -112,8 +110,10 @@ export type BindingStrategy =
* *
* IMPORTANT: currently must be > 0 (this also applies to the computed gap) * IMPORTANT: currently must be > 0 (this also applies to the computed gap)
*/ */
export const BASE_BINDING_GAP = 10; export const BASE_BINDING_GAP = 5;
export const BASE_BINDING_GAP_ELBOW = 5; export const BASE_BINDING_GAP_ELBOW = 5;
export const BASE_ARROW_MIN_LENGTH = 10;
export const FOCUS_POINT_SIZE = 10 / 1.5;
export const getBindingGap = ( export const getBindingGap = (
bindTarget: ExcalidrawBindableElement, bindTarget: ExcalidrawBindableElement,
@@ -143,7 +143,9 @@ export const shouldEnableBindingForPointerEvent = (
return !event[KEYS.CTRL_OR_CMD]; return !event[KEYS.CTRL_OR_CMD];
}; };
export const isBindingEnabled = (appState: AppState): boolean => { export const isBindingEnabled = (appState: {
isBindingEnabled: AppState["isBindingEnabled"];
}): boolean => {
return appState.isBindingEnabled; return appState.isBindingEnabled;
}; };
@@ -257,7 +259,7 @@ const bindingStrategyForElbowArrowEndpointDragging = (
globalPoint, globalPoint,
elements, elements,
elementsMap, elementsMap,
(element) => maxBindingDistance_simple(zoom), maxBindingDistance_simple(zoom),
); );
const current = hit const current = hit
@@ -682,7 +684,7 @@ const getBindingStrategyForDraggingBindingElementEndpoints_simple = (
globalPoint, globalPoint,
elements, elements,
elementsMap, elementsMap,
(e) => maxBindingDistance_simple(appState.zoom), maxBindingDistance_simple(appState.zoom),
); );
const pointInElement = const pointInElement =
hit && hit &&
@@ -709,7 +711,13 @@ const getBindingStrategyForDraggingBindingElementEndpoints_simple = (
const otherFocusPointIsInElement = const otherFocusPointIsInElement =
otherBindableElement && otherBindableElement &&
otherFocusPoint && otherFocusPoint &&
isPointInElement(otherFocusPoint, otherBindableElement, elementsMap); hitElementItself({
point: otherFocusPoint,
element: otherBindableElement,
elementsMap,
threshold: 0,
overrideShouldTestInside: true,
});
// Handle outside-outside binding to the same element // Handle outside-outside binding to the same element
if (otherBinding && otherBinding.elementId === hit?.id) { if (otherBinding && otherBinding.elementId === hit?.id) {
@@ -789,6 +797,7 @@ const getBindingStrategyForDraggingBindingElementEndpoints_simple = (
hit, hit,
startDragged ? "start" : "end", startDragged ? "start" : "end",
elementsMap, elementsMap,
appState.zoom,
) || globalPoint, ) || globalPoint,
} }
: { mode: null }; : { mode: null };
@@ -798,11 +807,24 @@ const getBindingStrategyForDraggingBindingElementEndpoints_simple = (
startDragged ? -1 : 0, startDragged ? -1 : 0,
elementsMap, elementsMap,
); );
const pointIsCloseToOtherElement =
const other: BindingStrategy = otherFocusPoint &&
otherBindableElement && otherBindableElement &&
!otherFocusPointIsInElement && hitElementItself({
appState.selectedLinearElement?.initialState.altFocusPoint point: globalPoint,
element: otherBindableElement,
elementsMap,
threshold: maxBindingDistance_simple(appState.zoom),
overrideShouldTestInside: true,
});
const otherNeverOverride = opts?.newArrow
? appState.selectedLinearElement?.initialState.arrowStartIsInside
: otherBinding?.mode === "inside";
const other: BindingStrategy = !otherNeverOverride
? otherBindableElement &&
!otherFocusPointIsInElement &&
!pointIsCloseToOtherElement &&
appState.selectedLinearElement?.initialState.altFocusPoint
? { ? {
mode: "orbit", mode: "orbit",
element: otherBindableElement, element: otherBindableElement,
@@ -819,9 +841,11 @@ const getBindingStrategyForDraggingBindingElementEndpoints_simple = (
otherBindableElement, otherBindableElement,
startDragged ? "end" : "start", startDragged ? "end" : "start",
elementsMap, elementsMap,
appState.zoom,
) || otherEndpoint, ) || otherEndpoint,
} }
: { mode: undefined }; : { mode: undefined }
: { mode: undefined };
return { return {
start: startDragged ? current : other, start: startDragged ? current : other,
@@ -1085,7 +1109,7 @@ export const updateBoundElements = (
}); });
} }
boundElementsVisitor(elementsMap, changedElement, (element) => { const visitor = (element: ExcalidrawElement | undefined) => {
if (!isArrowElement(element) || element.isDeleted) { if (!isArrowElement(element) || element.isDeleted) {
return; return;
} }
@@ -1157,7 +1181,71 @@ export const updateBoundElements = (
if (boundText && !boundText.isDeleted) { if (boundText && !boundText.isDeleted) {
handleBindTextResize(element, scene, false); handleBindTextResize(element, scene, false);
} }
}); };
boundElementsVisitor(elementsMap, changedElement, visitor);
};
const updateArrowBindings = (
latestElement: ExcalidrawArrowElement,
startOrEnd: "startBinding" | "endBinding",
elementsMap: NonDeletedSceneElementsMap,
scene: Scene,
appState: AppState,
) => {
invariant(
!isElbowArrow(latestElement),
"Elbow arrows not supported for indirect updates",
);
const binding = latestElement[startOrEnd];
const bindableElement =
binding &&
(elementsMap.get(binding.elementId) as ExcalidrawBindableElement);
const point = LinearElementEditor.getPointAtIndexGlobalCoordinates(
latestElement,
startOrEnd === "startBinding" ? 0 : -1,
elementsMap,
);
const hit =
bindableElement &&
hitElementItself({
element: bindableElement,
point,
elementsMap,
threshold: maxBindingDistance_simple(appState.zoom),
});
const strategyName = startOrEnd === "startBinding" ? "start" : "end";
unbindBindingElement(latestElement, strategyName, scene);
if (hit) {
const pointIdx =
startOrEnd === "startBinding" ? 0 : latestElement.points.length - 1;
const localPoint = latestElement.points[pointIdx];
const strategy =
getBindingStrategyForDraggingBindingElementEndpoints_simple(
latestElement,
new Map([[pointIdx, { point: localPoint }]]),
point[0],
point[1],
elementsMap,
scene.getNonDeletedElements(),
appState,
);
if (
strategy[strategyName] &&
strategy[strategyName].element?.id === bindableElement.id &&
strategy[strategyName].mode
) {
bindBindingElement(
latestElement,
bindableElement,
strategy[strategyName].mode,
strategyName,
scene,
strategy[strategyName].focusPoint,
);
}
}
}; };
export const updateBindings = ( export const updateBindings = (
@@ -1170,14 +1258,27 @@ export const updateBindings = (
}, },
) => { ) => {
if (isArrowElement(latestElement)) { if (isArrowElement(latestElement)) {
bindOrUnbindBindingElement( const elementsMap = scene.getNonDeletedElementsMap();
latestElement,
new Map(), if (latestElement.startBinding) {
Infinity, updateArrowBindings(
Infinity, latestElement,
scene, "startBinding",
appState, elementsMap,
); scene,
appState,
);
}
if (latestElement.endBinding) {
updateArrowBindings(
latestElement,
"endBinding",
elementsMap,
scene,
appState,
);
}
} else { } else {
updateBoundElements(latestElement, scene, { updateBoundElements(latestElement, scene, {
...options, ...options,
@@ -1291,14 +1392,16 @@ export const bindPointToSnapToElementOutline = (
headingForPointFromElement(bindableElement, aabb, point), headingForPointFromElement(bindableElement, aabb, point),
); );
const snapPoint = snapToMid( const snapPoint = snapToMid(
arrowElement,
bindableElement, bindableElement,
elementsMap, elementsMap,
edgePoint, edgePoint,
0.05,
arrowElement,
); );
const resolved = snapPoint || point;
const otherPoint = pointFrom<GlobalPoint>( const otherPoint = pointFrom<GlobalPoint>(
isHorizontal ? bindableCenter[0] : snapPoint[0], isHorizontal ? bindableCenter[0] : resolved[0],
!isHorizontal ? bindableCenter[1] : snapPoint[1], !isHorizontal ? bindableCenter[1] : resolved[1],
); );
const intersector = const intersector =
customIntersector ?? customIntersector ??
@@ -1306,7 +1409,7 @@ export const bindPointToSnapToElementOutline = (
otherPoint, otherPoint,
pointFromVector( pointFromVector(
vectorScale( vectorScale(
vectorNormalize(vectorFromPoint(snapPoint, otherPoint)), vectorNormalize(vectorFromPoint(resolved, otherPoint)),
Math.max(bindableElement.width, bindableElement.height) * 2, Math.max(bindableElement.width, bindableElement.height) * 2,
), ),
otherPoint, otherPoint,
@@ -1321,14 +1424,14 @@ export const bindPointToSnapToElementOutline = (
if (!intersection) { if (!intersection) {
const anotherPoint = pointFrom<GlobalPoint>( const anotherPoint = pointFrom<GlobalPoint>(
!isHorizontal ? bindableCenter[0] : snapPoint[0], !isHorizontal ? bindableCenter[0] : resolved[0],
isHorizontal ? bindableCenter[1] : snapPoint[1], isHorizontal ? bindableCenter[1] : resolved[1],
); );
const anotherIntersector = lineSegment( const anotherIntersector = lineSegment(
anotherPoint, anotherPoint,
pointFromVector( pointFromVector(
vectorScale( vectorScale(
vectorNormalize(vectorFromPoint(snapPoint, anotherPoint)), vectorNormalize(vectorFromPoint(resolved, anotherPoint)),
Math.max(bindableElement.width, bindableElement.height) * 2, Math.max(bindableElement.width, bindableElement.height) * 2,
), ),
anotherPoint, anotherPoint,
@@ -1475,18 +1578,18 @@ export const avoidRectangularCorner = (
return p; return p;
}; };
const snapToMid = ( export const snapToMid = (
arrowElement: ExcalidrawArrowElement,
bindTarget: ExcalidrawBindableElement, bindTarget: ExcalidrawBindableElement,
elementsMap: ElementsMap, elementsMap: ElementsMap,
p: GlobalPoint, p: GlobalPoint,
tolerance: number = 0.05, tolerance: number = 0.05,
): GlobalPoint => { arrowElement?: ExcalidrawArrowElement,
): GlobalPoint | undefined => {
const { x, y, width, height, angle } = bindTarget; const { x, y, width, height, angle } = bindTarget;
const center = elementCenterPoint(bindTarget, elementsMap, -0.1, -0.1); const center = elementCenterPoint(bindTarget, elementsMap, -0.1, -0.1);
const nonRotated = pointRotateRads(p, center, -angle as Radians); const nonRotated = pointRotateRads(p, center, -angle as Radians);
const bindingGap = getBindingGap(bindTarget, arrowElement); const bindingGap = arrowElement ? getBindingGap(bindTarget, arrowElement) : 0;
// snap-to-center point is adaptive to element size, but we don't want to go // snap-to-center point is adaptive to element size, but we don't want to go
// above and below certain px distance // above and below certain px distance
@@ -1495,7 +1598,7 @@ const snapToMid = (
// Too close to the center makes it hard to resolve direction precisely // Too close to the center makes it hard to resolve direction precisely
if (pointDistance(center, nonRotated) < bindingGap) { if (pointDistance(center, nonRotated) < bindingGap) {
return p; return undefined;
} }
if ( if (
@@ -1504,8 +1607,8 @@ const snapToMid = (
nonRotated[1] < center[1] + verticalThreshold nonRotated[1] < center[1] + verticalThreshold
) { ) {
// LEFT // LEFT
return pointRotateRads<GlobalPoint>( return pointRotateRads(
pointFrom(x - bindingGap, center[1]), pointFrom<GlobalPoint>(x - bindingGap, center[1]),
center, center,
angle, angle,
); );
@@ -1515,7 +1618,11 @@ const snapToMid = (
nonRotated[0] < center[0] + horizontalThreshold nonRotated[0] < center[0] + horizontalThreshold
) { ) {
// TOP // TOP
return pointRotateRads(pointFrom(center[0], y - bindingGap), center, angle); return pointRotateRads(
pointFrom<GlobalPoint>(center[0], y - bindingGap),
center,
angle,
);
} else if ( } else if (
nonRotated[0] >= x + width / 2 && nonRotated[0] >= x + width / 2 &&
nonRotated[1] > center[1] - verticalThreshold && nonRotated[1] > center[1] - verticalThreshold &&
@@ -1523,7 +1630,7 @@ const snapToMid = (
) { ) {
// RIGHT // RIGHT
return pointRotateRads( return pointRotateRads(
pointFrom(x + width + bindingGap, center[1]), pointFrom<GlobalPoint>(x + width + bindingGap, center[1]),
center, center,
angle, angle,
); );
@@ -1534,7 +1641,7 @@ const snapToMid = (
) { ) {
// DOWN // DOWN
return pointRotateRads( return pointRotateRads(
pointFrom(center[0], y + height + bindingGap), pointFrom<GlobalPoint>(center[0], y + height + bindingGap),
center, center,
angle, angle,
); );
@@ -1583,13 +1690,44 @@ const snapToMid = (
} }
} }
return p; return undefined;
}; };
const compareElementArea = ( const extractBinding = (
a: ExcalidrawBindableElement, arrow: ExcalidrawArrowElement,
b: ExcalidrawBindableElement, startOrEnd: "startBinding" | "endBinding",
) => b.width ** 2 + b.height ** 2 - (a.width ** 2 + a.height ** 2); elementsMap: ElementsMap,
) => {
const binding = arrow[startOrEnd];
if (!binding) {
return {
element: null,
fixedPoint: null,
focusPoint: null,
binding,
mode: null,
};
}
const element = elementsMap.get(
binding.elementId,
) as ExcalidrawBindableElement;
return {
element,
fixedPoint: binding.fixedPoint,
focusPoint: getGlobalFixedPointForBindableElement(
normalizeFixedPoint(binding.fixedPoint),
element,
elementsMap,
),
binding,
mode: binding.mode,
};
};
const elementArea = (element: ExcalidrawBindableElement) =>
element.width * element.height;
export const updateBoundPoint = ( export const updateBoundPoint = (
arrow: NonDeleted<ExcalidrawArrowElement>, arrow: NonDeleted<ExcalidrawArrowElement>,
@@ -1597,162 +1735,151 @@ export const updateBoundPoint = (
binding: FixedPointBinding | null | undefined, binding: FixedPointBinding | null | undefined,
bindableElement: ExcalidrawBindableElement, bindableElement: ExcalidrawBindableElement,
elementsMap: ElementsMap, elementsMap: ElementsMap,
customIntersector?: LineSegment<GlobalPoint>, dragging?: boolean,
): LocalPoint | null => { ): LocalPoint | null => {
if ( if (
binding == null || binding == null ||
// We only need to update the other end if this is a 2 point line element // We only need to update the other end if this is a 2 point line element
(binding.elementId !== bindableElement.id && arrow.points.length > 2) (binding.elementId !== bindableElement.id && arrow.points.length > 2) ||
// Initial arrow created on pointer down needs to not update the points
pointsEqual(
arrow.points[arrow.points.length - 1],
pointFrom<LocalPoint>(0, 0),
)
) { ) {
return null; return null;
} }
const global = getGlobalFixedPointForBindableElement( const focusPoint = getGlobalFixedPointForBindableElement(
normalizeFixedPoint(binding.fixedPoint), normalizeFixedPoint(binding.fixedPoint),
bindableElement, bindableElement,
elementsMap, elementsMap,
); );
const pointIndex =
startOrEnd === "startBinding" ? 0 : arrow.points.length - 1;
const elbowed = isElbowArrow(arrow);
const otherBinding =
startOrEnd === "startBinding" ? arrow.endBinding : arrow.startBinding;
const otherBindableElement =
otherBinding &&
(elementsMap.get(otherBinding.elementId)! as ExcalidrawBindableElement);
const bounds = getElementBounds(bindableElement, elementsMap);
const otherBounds =
otherBindableElement && getElementBounds(otherBindableElement, elementsMap);
const isLargerThanOther =
otherBindableElement &&
compareElementArea(bindableElement, otherBindableElement) <
// if both shapes the same size, pretend the other is larger
(startOrEnd === "endBinding" ? 1 : 0);
const isOverlapping = otherBounds && doBoundsIntersect(bounds, otherBounds);
// GOAL: If the arrow becomes too short, we want to jump the arrow endpoints // 0. Short-circuit for inside binding as it doesn't require any
// to the exact focus points on the elements. // calculations and is not affected by other bindings
// INTUITION: We're not interested in the exacts length of the arrow (which if (binding.mode === "inside") {
// will change if we change where we route it), we want to know the length of return LinearElementEditor.createPointAt(
// the part which lies outside of both shapes and consider that as a trigger
// to change where we point the arrow. Avoids jumping the arrow in and out
// at every frame.
let arrowTooShort = false;
if (
!isOverlapping &&
!elbowed &&
arrow.startBinding &&
arrow.endBinding &&
otherBindableElement &&
arrow.points.length === 2
) {
const startFocusPoint = getGlobalFixedPointForBindableElement(
arrow.startBinding.fixedPoint,
startOrEnd === "startBinding" ? bindableElement : otherBindableElement,
elementsMap,
);
const endFocusPoint = getGlobalFixedPointForBindableElement(
arrow.endBinding.fixedPoint,
startOrEnd === "endBinding" ? bindableElement : otherBindableElement,
elementsMap,
);
const segment = lineSegment(startFocusPoint, endFocusPoint);
const startIntersection = intersectElementWithLineSegment(
startOrEnd === "endBinding" ? bindableElement : otherBindableElement,
elementsMap,
segment,
0,
true,
);
const endIntersection = intersectElementWithLineSegment(
startOrEnd === "startBinding" ? bindableElement : otherBindableElement,
elementsMap,
segment,
0,
true,
);
if (startIntersection.length > 0 && endIntersection.length > 0) {
const len = pointDistance(startIntersection[0], endIntersection[0]);
arrowTooShort = len < 40;
}
}
const isNested = (arrowTooShort || isOverlapping) && isLargerThanOther;
let _customIntersector = customIntersector;
if (!elbowed && !_customIntersector) {
const [x1, y1, x2, y2] = LinearElementEditor.getElementAbsoluteCoords(
arrow, arrow,
elementsMap, elementsMap,
); focusPoint[0],
const center = pointFrom<GlobalPoint>((x1 + x2) / 2, (y1 + y2) / 2); focusPoint[1],
const edgePoint = isRectanguloidElement(bindableElement) null,
? avoidRectangularCorner(arrow, bindableElement, elementsMap, global)
: global;
const adjacentPoint = pointRotateRads(
pointFrom<GlobalPoint>(
arrow.x +
arrow.points[pointIndex === 0 ? 1 : arrow.points.length - 2][0],
arrow.y +
arrow.points[pointIndex === 0 ? 1 : arrow.points.length - 2][1],
),
center,
arrow.angle as Radians,
);
const bindingGap = getBindingGap(bindableElement, arrow);
const halfVector = vectorScale(
vectorNormalize(vectorFromPoint(edgePoint, adjacentPoint)),
pointDistance(edgePoint, adjacentPoint) +
Math.max(bindableElement.width, bindableElement.height) +
bindingGap * 2,
);
_customIntersector = lineSegment(
pointFromVector(halfVector, adjacentPoint),
pointFromVector(vectorScale(halfVector, -1), adjacentPoint),
); );
} }
const maybeOutlineGlobal = const { element: otherBindable, focusPoint: otherFocusPoint } =
binding.mode === "orbit" && bindableElement extractBinding(
? isNested arrow,
? global startOrEnd === "startBinding" ? "endBinding" : "startBinding",
: bindPointToSnapToElementOutline( elementsMap,
{ );
...arrow, const otherArrowPoint = LinearElementEditor.getPointAtIndexGlobalCoordinates(
points: [ arrow,
pointIndex === 0 startOrEnd === "startBinding" ? -1 : 0,
? LinearElementEditor.createPointAt( elementsMap,
arrow, );
elementsMap, const otherFocusPointOrArrowPoint = otherFocusPoint || otherArrowPoint;
global[0], const intersector =
global[1], otherFocusPointOrArrowPoint &&
null, lineSegment(focusPoint, otherFocusPointOrArrowPoint);
) const otherOutlinePoint =
: arrow.points[0], otherBindable &&
...arrow.points.slice(1, -1), intersector &&
pointIndex === arrow.points.length - 1 intersectElementWithLineSegment(
? LinearElementEditor.createPointAt( otherBindable,
arrow, elementsMap,
elementsMap, intersector,
global[0], getBindingGap(otherBindable, arrow),
global[1], ).sort(
null, (a, b) => pointDistanceSq(a, focusPoint) - pointDistanceSq(b, focusPoint),
) )[0];
: arrow.points[arrow.points.length - 1], const outlinePoint =
], intersector &&
}, intersectElementWithLineSegment(
bindableElement, bindableElement,
pointIndex === 0 ? "start" : "end", elementsMap,
elementsMap, intersector,
_customIntersector, getBindingGap(bindableElement, arrow),
) ).sort(
: global; (a, b) =>
pointDistanceSq(a, otherFocusPointOrArrowPoint) -
pointDistanceSq(b, otherFocusPointOrArrowPoint),
)[0];
const startHasArrowhead = arrow.startArrowhead !== null;
const endHasArrowhead = arrow.endArrowhead !== null;
const resolvedTarget =
(!startHasArrowhead && !endHasArrowhead) ||
(startOrEnd === "startBinding" && startHasArrowhead) ||
(startOrEnd === "endBinding" && endHasArrowhead)
? focusPoint
: outlinePoint || focusPoint;
// 1. Handle case when the outline point (or focus point) is inside
// the other shape by short-circuiting to the focus point, otherwise
// the arrow would invert
if (
otherBindable &&
outlinePoint &&
!dragging &&
// Arbitrary threshold to handle wireframing use cases
elementArea(otherBindable) < elementArea(bindableElement) * 2 &&
hitElementItself({
element: otherBindable,
point: outlinePoint,
elementsMap,
threshold: getBindingGap(otherBindable, arrow),
overrideShouldTestInside: true,
})
) {
return LinearElementEditor.createPointAt(
arrow,
elementsMap,
resolvedTarget[0],
resolvedTarget[1],
null,
);
}
const otherTargetPoint = otherBindable
? otherOutlinePoint || otherFocusPoint || otherArrowPoint
: otherArrowPoint;
const arrowTooShort =
pointDistance(otherTargetPoint, outlinePoint || focusPoint) <=
BASE_ARROW_MIN_LENGTH;
// 2. If the arrow is unconnected at the other end, just check arrow size
// and short-circuit to the focus point if the arrow is too short to
// avoid inversion
if (!otherBindable) {
return LinearElementEditor.createPointAt(
arrow,
elementsMap,
arrowTooShort ? focusPoint[0] : outlinePoint?.[0] ?? focusPoint[0],
arrowTooShort ? focusPoint[1] : outlinePoint?.[1] ?? focusPoint[1],
null,
);
}
// 3. If the arrow is too short while connected on both ends and
// the other arrow endpoint will not be inside the bindable, just
// check the arrow size and make a decision based on that
if (arrowTooShort) {
return LinearElementEditor.createPointAt(
arrow,
elementsMap,
resolvedTarget?.[0] || focusPoint[0],
resolvedTarget?.[1] || focusPoint[1],
null,
);
}
// 4. In the general case, snap to the outline if possible
return LinearElementEditor.createPointAt( return LinearElementEditor.createPointAt(
arrow, arrow,
elementsMap, elementsMap,
maybeOutlineGlobal[0], outlinePoint?.[0] || focusPoint[0],
maybeOutlineGlobal[1], outlinePoint?.[1] || focusPoint[1],
null, null,
); );
}; };
@@ -1802,7 +1929,7 @@ export const calculateFixedPointForNonElbowArrowBinding = (
elementsMap: ElementsMap, elementsMap: ElementsMap,
focusPoint?: GlobalPoint, focusPoint?: GlobalPoint,
): { fixedPoint: FixedPoint } => { ): { fixedPoint: FixedPoint } => {
const edgePoint = focusPoint const edgePoint: GlobalPoint = focusPoint
? focusPoint ? focusPoint
: LinearElementEditor.getPointAtIndexGlobalCoordinates( : LinearElementEditor.getPointAtIndexGlobalCoordinates(
linearElement, linearElement,
@@ -1810,11 +1937,7 @@ export const calculateFixedPointForNonElbowArrowBinding = (
elementsMap, elementsMap,
); );
// Convert the global point to element-local coordinates const elementCenter = elementCenterPoint(hoveredElement, elementsMap);
const elementCenter = pointFrom(
hoveredElement.x + hoveredElement.width / 2,
hoveredElement.y + hoveredElement.height / 2,
);
// Rotate the point to account for element rotation // Rotate the point to account for element rotation
const nonRotatedPoint = pointRotateRads( const nonRotatedPoint = pointRotateRads(
+2 -1
View File
@@ -897,6 +897,7 @@ export const getArrowheadPoints = (
return [x2, y2, x3, y3, x4, y4]; return [x2, y2, x3, y3, x4, y4];
}; };
// TODO reuse shape.ts
const generateLinearElementShape = ( const generateLinearElementShape = (
element: ExcalidrawLinearElement, element: ExcalidrawLinearElement,
): Drawable => { ): Drawable => {
@@ -954,7 +955,7 @@ const getLinearElementRotatedBounds = (
} }
// first element is always the curve // first element is always the curve
const cachedShape = ShapeCache.get(element)?.[0]; const cachedShape = ShapeCache.get(element, null)?.[0];
const shape = cachedShape ?? generateLinearElementShape(element); const shape = cachedShape ?? generateLinearElementShape(element);
const ops = getCurvePathOps(shape); const ops = getCurvePathOps(shape);
const transformXY = ([x, y]: GlobalPoint) => const transformXY = ([x, y]: GlobalPoint) =>
+97 -6
View File
@@ -59,8 +59,11 @@ import { LinearElementEditor } from "./linearElementEditor";
import { distanceToElement } from "./distance"; import { distanceToElement } from "./distance";
import { getBindingGap } from "./binding";
import type { import type {
ElementsMap, ElementsMap,
ExcalidrawArrowElement,
ExcalidrawBindableElement, ExcalidrawBindableElement,
ExcalidrawDiamondElement, ExcalidrawDiamondElement,
ExcalidrawElement, ExcalidrawElement,
@@ -75,7 +78,12 @@ import type {
} from "./types"; } from "./types";
export const shouldTestInside = (element: ExcalidrawElement) => { export const shouldTestInside = (element: ExcalidrawElement) => {
if (element.type === "arrow") { if (
element.type === "arrow" ||
// frame elements should ignore inside hit test even if background is not
// transparent, so we can select children easily
isFrameLikeElement(element)
) {
return false; return false;
} }
@@ -105,6 +113,12 @@ export type HitTestArgs = {
overrideShouldTestInside?: boolean; overrideShouldTestInside?: boolean;
}; };
let cachedPoint: GlobalPoint | null = null;
let cachedElement: WeakRef<ExcalidrawElement> | null = null;
let cachedThreshold: number = Infinity;
let cachedHit: boolean = false;
let cachedOverrideShouldTestInside = false;
export const hitElementItself = ({ export const hitElementItself = ({
point, point,
element, element,
@@ -113,6 +127,24 @@ export const hitElementItself = ({
frameNameBound = null, frameNameBound = null,
overrideShouldTestInside = false, overrideShouldTestInside = false,
}: HitTestArgs) => { }: HitTestArgs) => {
// Return cached result if the same point and element version is tested again
if (
cachedPoint &&
pointsEqual(point, cachedPoint) &&
cachedThreshold <= threshold &&
overrideShouldTestInside === cachedOverrideShouldTestInside
) {
const derefElement = cachedElement?.deref();
if (
derefElement &&
derefElement.id === element.id &&
derefElement.version === element.version &&
derefElement.versionNonce === element.versionNonce
) {
return cachedHit;
}
}
// Hit test against a frame's name // Hit test against a frame's name
const hitFrameName = frameNameBound const hitFrameName = frameNameBound
? isPointWithinBounds( ? isPointWithinBounds(
@@ -153,7 +185,16 @@ export const hitElementItself = ({
isPointOnElementOutline(point, element, elementsMap, threshold) isPointOnElementOutline(point, element, elementsMap, threshold)
: isPointOnElementOutline(point, element, elementsMap, threshold); : isPointOnElementOutline(point, element, elementsMap, threshold);
return hitElement || hitFrameName; const result = hitElement || hitFrameName;
// Cache end result
cachedPoint = point;
cachedElement = new WeakRef(element);
cachedThreshold = threshold;
cachedOverrideShouldTestInside = overrideShouldTestInside;
cachedHit = result;
return result;
}; };
export const hitElementBoundingBox = ( export const hitElementBoundingBox = (
@@ -257,7 +298,7 @@ export const getAllHoveredElementAtPoint = (
point: Readonly<GlobalPoint>, point: Readonly<GlobalPoint>,
elements: readonly Ordered<NonDeletedExcalidrawElement>[], elements: readonly Ordered<NonDeletedExcalidrawElement>[],
elementsMap: NonDeletedSceneElementsMap, elementsMap: NonDeletedSceneElementsMap,
toleranceFn?: (element: ExcalidrawBindableElement) => number, tolerance?: number,
): NonDeleted<ExcalidrawBindableElement>[] => { ): NonDeleted<ExcalidrawBindableElement>[] => {
const candidateElements: NonDeleted<ExcalidrawBindableElement>[] = []; const candidateElements: NonDeleted<ExcalidrawBindableElement>[] = [];
// We need to to hit testing from front (end of the array) to back (beginning of the array) // We need to to hit testing from front (end of the array) to back (beginning of the array)
@@ -273,7 +314,7 @@ export const getAllHoveredElementAtPoint = (
if ( if (
isBindableElement(element, false) && isBindableElement(element, false) &&
bindingBorderTest(element, point, elementsMap, toleranceFn?.(element)) bindingBorderTest(element, point, elementsMap, tolerance)
) { ) {
candidateElements.push(element); candidateElements.push(element);
@@ -290,13 +331,13 @@ export const getHoveredElementForBinding = (
point: Readonly<GlobalPoint>, point: Readonly<GlobalPoint>,
elements: readonly Ordered<NonDeletedExcalidrawElement>[], elements: readonly Ordered<NonDeletedExcalidrawElement>[],
elementsMap: NonDeletedSceneElementsMap, elementsMap: NonDeletedSceneElementsMap,
toleranceFn?: (element: ExcalidrawBindableElement) => number, tolerance?: number,
): NonDeleted<ExcalidrawBindableElement> | null => { ): NonDeleted<ExcalidrawBindableElement> | null => {
const candidateElements = getAllHoveredElementAtPoint( const candidateElements = getAllHoveredElementAtPoint(
point, point,
elements, elements,
elementsMap, elementsMap,
toleranceFn, tolerance,
); );
if (!candidateElements || candidateElements.length === 0) { if (!candidateElements || candidateElements.length === 0) {
@@ -315,6 +356,56 @@ export const getHoveredElementForBinding = (
.pop() as NonDeleted<ExcalidrawBindableElement>; .pop() as NonDeleted<ExcalidrawBindableElement>;
}; };
export const getHoveredElementForFocusPoint = (
point: GlobalPoint,
arrow: ExcalidrawArrowElement,
elements: readonly Ordered<NonDeletedExcalidrawElement>[],
elementsMap: NonDeletedSceneElementsMap,
tolerance?: number,
): ExcalidrawBindableElement | null => {
const candidateElements: NonDeleted<ExcalidrawBindableElement>[] = [];
// We need to to hit testing from front (end of the array) to back (beginning of the array)
// because array is ordered from lower z-index to highest and we want element z-index
// with higher z-index
for (let index = elements.length - 1; index >= 0; --index) {
const element = elements[index];
invariant(
!element.isDeleted,
"Elements in the function parameter for getAllElementsAtPositionForBinding() should not contain deleted elements",
);
if (
isBindableElement(element, false) &&
bindingBorderTest(element, point, elementsMap, tolerance)
) {
candidateElements.push(element);
}
}
if (!candidateElements || candidateElements.length === 0) {
return null;
}
if (candidateElements.length === 1) {
return candidateElements[0];
}
const distanceFilteredCandidateElements = candidateElements
// Resolve by distance
.filter(
(el) =>
distanceToElement(el, elementsMap, point) <= getBindingGap(el, arrow) ||
isPointInElement(point, el, elementsMap),
);
if (distanceFilteredCandidateElements.length === 0) {
return null;
}
return distanceFilteredCandidateElements[0] as NonDeleted<ExcalidrawBindableElement>;
};
/** /**
* Intersect a line with an element for binding test * Intersect a line with an element for binding test
* *
+20 -9
View File
@@ -1,10 +1,12 @@
import type { AppState } from "@excalidraw/excalidraw/types"; import type { AppState } from "@excalidraw/excalidraw/types";
import { updateBoundElements } from "./binding";
import { getCommonBoundingBox } from "./bounds"; import { getCommonBoundingBox } from "./bounds";
import { newElementWith } from "./mutateElement";
import { getSelectedElementsByGroup } from "./groups"; import { getSelectedElementsByGroup } from "./groups";
import type { Scene } from "./Scene";
import type { ElementsMap, ExcalidrawElement } from "./types"; import type { ElementsMap, ExcalidrawElement } from "./types";
export interface Distribution { export interface Distribution {
@@ -17,6 +19,7 @@ export const distributeElements = (
elementsMap: ElementsMap, elementsMap: ElementsMap,
distribution: Distribution, distribution: Distribution,
appState: Readonly<AppState>, appState: Readonly<AppState>,
scene: Scene,
): ExcalidrawElement[] => { ): ExcalidrawElement[] => {
const [start, mid, end, extent] = const [start, mid, end, extent] =
distribution.axis === "x" distribution.axis === "x"
@@ -66,12 +69,16 @@ export const distributeElements = (
translation[distribution.axis] = pos - box[mid]; translation[distribution.axis] = pos - box[mid];
} }
return group.map((element) => return group.map((element) => {
newElementWith(element, { const updatedElement = scene.mutateElement(element, {
x: element.x + translation.x, x: element.x + translation.x,
y: element.y + translation.y, y: element.y + translation.y,
}), });
); updateBoundElements(element, scene, {
simultaneouslyUpdated: group,
});
return updatedElement;
});
}); });
} }
@@ -90,11 +97,15 @@ export const distributeElements = (
pos += step; pos += step;
pos += box[extent]; pos += box[extent];
return group.map((element) => return group.map((element) => {
newElementWith(element, { const updatedElement = scene.mutateElement(element, {
x: element.x + translation.x, x: element.x + translation.x,
y: element.y + translation.y, y: element.y + translation.y,
}), });
); updateBoundElements(element, scene, {
simultaneouslyUpdated: group,
});
return updatedElement;
});
}); });
}; };
+1 -1
View File
@@ -2276,7 +2276,7 @@ const getHoveredElement = (
origPoint, origPoint,
elements, elements,
elementsMap, elementsMap,
(element) => maxBindingDistance_simple(zoom), maxBindingDistance_simple(zoom),
); );
}; };
+79 -2
View File
@@ -56,7 +56,7 @@ const RE_REDDIT =
const RE_REDDIT_EMBED = const RE_REDDIT_EMBED =
/^<blockquote[\s\S]*?\shref=["'](https?:\/\/(?:www\.)?reddit\.com\/[^"']*)/i; /^<blockquote[\s\S]*?\shref=["'](https?:\/\/(?:www\.)?reddit\.com\/[^"']*)/i;
const parseYouTubeTimestamp = (url: string): number => { const parseYouTubeLikeTimestamp = (url: string): number => {
let timeParam: string | null | undefined; let timeParam: string | null | undefined;
try { try {
@@ -85,11 +85,57 @@ const parseYouTubeTimestamp = (url: string): number => {
return parseInt(hours) * 3600 + parseInt(minutes) * 60 + parseInt(seconds); return parseInt(hours) * 3600 + parseInt(minutes) * 60 + parseInt(seconds);
}; };
const parseGoogleDriveVideoLink = (
url: string,
): { fileId: string; resourceKey?: string; timestamp?: number } | null => {
try {
const urlObj = new URL(url.startsWith("http") ? url : `https://${url}`);
const hostname = urlObj.hostname.replace(/^www\./, "");
if (hostname !== "drive.google.com") {
return null;
}
let fileId: string | null = null;
const pathMatch = urlObj.pathname.match(/^\/file\/d\/([^/]+)(?:\/|$)/);
if (pathMatch?.[1]) {
fileId = pathMatch[1];
} else if (urlObj.pathname === "/open" || urlObj.pathname === "/uc") {
// Shared Drive links can be emitted as:
// - /open?id=<fileId> (common "open in Drive" format)
// - /uc?...&id=<fileId> (download/export endpoint often seen in copied links)
fileId = urlObj.searchParams.get("id");
}
if (!fileId || !/^[a-zA-Z0-9_-]+$/.test(fileId)) {
return null;
}
// Some Drive share links include `resourcekey` for access to link-shared
// files; preserve it in the preview URL so embeds keep working.
const resourceKey = urlObj.searchParams.get("resourcekey");
const timestamp = parseYouTubeLikeTimestamp(urlObj.toString());
return {
fileId,
resourceKey:
resourceKey && /^[a-zA-Z0-9_-]+$/.test(resourceKey)
? resourceKey
: undefined,
// Drive accepts YouTube-like `t` formats (e.g. `t=90`, `t=1m30s`);
// normalize to seconds for a stable preview URL.
timestamp: timestamp > 0 ? timestamp : undefined,
};
} catch (error) {
return null;
}
};
const ALLOWED_DOMAINS = new Set([ const ALLOWED_DOMAINS = new Set([
"youtube.com", "youtube.com",
"youtu.be", "youtu.be",
"vimeo.com", "vimeo.com",
"player.vimeo.com", "player.vimeo.com",
"drive.google.com",
"figma.com", "figma.com",
"link.excalidraw.com", "link.excalidraw.com",
"gist.github.com", "gist.github.com",
@@ -108,6 +154,7 @@ const ALLOW_SAME_ORIGIN = new Set([
"youtu.be", "youtu.be",
"vimeo.com", "vimeo.com",
"player.vimeo.com", "player.vimeo.com",
"drive.google.com",
"figma.com", "figma.com",
"twitter.com", "twitter.com",
"x.com", "x.com",
@@ -142,7 +189,7 @@ export const getEmbedLink = (
let aspectRatio = { w: 560, h: 840 }; let aspectRatio = { w: 560, h: 840 };
const ytLink = link.match(RE_YOUTUBE); const ytLink = link.match(RE_YOUTUBE);
if (ytLink?.[2]) { if (ytLink?.[2]) {
const startTime = parseYouTubeTimestamp(originalLink); const startTime = parseYouTubeLikeTimestamp(originalLink);
const time = startTime > 0 ? `&start=${startTime}` : ``; const time = startTime > 0 ? `&start=${startTime}` : ``;
const isPortrait = link.includes("shorts"); const isPortrait = link.includes("shorts");
type = "video"; type = "video";
@@ -201,6 +248,36 @@ export const getEmbedLink = (
}; };
} }
const googleDriveVideo = parseGoogleDriveVideoLink(link);
if (googleDriveVideo) {
type = "video";
const searchParams = new URLSearchParams();
if (googleDriveVideo.resourceKey) {
searchParams.set("resourcekey", googleDriveVideo.resourceKey);
}
if (googleDriveVideo.timestamp) {
searchParams.set("t", `${googleDriveVideo.timestamp}`);
}
const search = searchParams.toString();
link = `https://drive.google.com/file/d/${googleDriveVideo.fileId}/preview${
search ? `?${search}` : ""
}`;
aspectRatio = { w: 560, h: 315 };
embeddedLinkCache.set(originalLink, {
link,
intrinsicSize: aspectRatio,
type,
sandbox: { allowSameOrigin },
});
return {
link,
intrinsicSize: aspectRatio,
type,
sandbox: { allowSameOrigin },
};
}
const figmaLink = link.match(RE_FIGMA); const figmaLink = link.match(RE_FIGMA);
if (figmaLink) { if (figmaLink) {
type = "generic"; type = "generic";
+3
View File
@@ -70,6 +70,7 @@ export * from "./elbowArrow";
export * from "./elementLink"; export * from "./elementLink";
export * from "./embeddable"; export * from "./embeddable";
export * from "./flowchart"; export * from "./flowchart";
export * from "./arrows/focus";
export * from "./fractionalIndex"; export * from "./fractionalIndex";
export * from "./frame"; export * from "./frame";
export * from "./groups"; export * from "./groups";
@@ -82,6 +83,7 @@ export * from "./positionElementsOnGrid";
export * from "./renderElement"; export * from "./renderElement";
export * from "./resizeElements"; export * from "./resizeElements";
export * from "./resizeTest"; export * from "./resizeTest";
export * from "./schema";
export * from "./Scene"; export * from "./Scene";
export * from "./selection"; export * from "./selection";
export * from "./shape"; export * from "./shape";
@@ -97,3 +99,4 @@ export * from "./transformHandles";
export * from "./typeChecks"; export * from "./typeChecks";
export * from "./utils"; export * from "./utils";
export * from "./zindex"; export * from "./zindex";
export * from "./arrows/helpers";
+128 -38
View File
@@ -9,7 +9,6 @@ import {
vectorFromPoint, vectorFromPoint,
curveLength, curveLength,
curvePointAtLength, curvePointAtLength,
lineSegment,
} from "@excalidraw/math"; } from "@excalidraw/math";
import { getCurvePathOps } from "@excalidraw/utils/shape"; import { getCurvePathOps } from "@excalidraw/utils/shape";
@@ -26,6 +25,7 @@ import {
import { import {
deconstructLinearOrFreeDrawElement, deconstructLinearOrFreeDrawElement,
getSnapOutlineMidPoint,
isPathALoop, isPathALoop,
moveArrowAboveBindable, moveArrowAboveBindable,
projectFixedPointOntoDiagonal, projectFixedPointOntoDiagonal,
@@ -48,6 +48,7 @@ import {
calculateFixedPointForNonElbowArrowBinding, calculateFixedPointForNonElbowArrowBinding,
getBindingStrategyForDraggingBindingElementEndpoints, getBindingStrategyForDraggingBindingElementEndpoints,
isBindingEnabled, isBindingEnabled,
snapToMid,
updateBoundPoint, updateBoundPoint,
} from "./binding"; } from "./binding";
import { import {
@@ -149,6 +150,8 @@ export class LinearElementEditor {
public readonly pointerOffset: Readonly<{ x: number; y: number }>; public readonly pointerOffset: Readonly<{ x: number; y: number }>;
public readonly hoverPointIndex: number; public readonly hoverPointIndex: number;
public readonly segmentMidPointHoveredCoords: GlobalPoint | null; public readonly segmentMidPointHoveredCoords: GlobalPoint | null;
public readonly hoveredFocusPointBinding: "start" | "end" | null;
public readonly draggedFocusPointBinding: "start" | "end" | null;
public readonly elbowed: boolean; public readonly elbowed: boolean;
public readonly customLineAngle: number | null; public readonly customLineAngle: number | null;
public readonly isEditing: boolean; public readonly isEditing: boolean;
@@ -194,6 +197,8 @@ export class LinearElementEditor {
}; };
this.hoverPointIndex = -1; this.hoverPointIndex = -1;
this.segmentMidPointHoveredCoords = null; this.segmentMidPointHoveredCoords = null;
this.hoveredFocusPointBinding = null;
this.draggedFocusPointBinding = null;
this.elbowed = isElbowArrow(element) && element.elbowed; this.elbowed = isElbowArrow(element) && element.elbowed;
this.customLineAngle = null; this.customLineAngle = null;
this.isEditing = isEditing; this.isEditing = isEditing;
@@ -351,6 +356,7 @@ export class LinearElementEditor {
app, app,
shouldRotateWithDiscreteAngle(event), shouldRotateWithDiscreteAngle(event),
event.altKey, event.altKey,
linearElementEditor,
); );
LinearElementEditor.movePoints(element, app.scene, positions, { LinearElementEditor.movePoints(element, app.scene, positions, {
@@ -404,13 +410,14 @@ export class LinearElementEditor {
altFocusPoint: altFocusPoint:
!linearElementEditor.initialState.altFocusPoint && !linearElementEditor.initialState.altFocusPoint &&
startBindingElement && startBindingElement &&
updates?.suggestedBinding?.id !== startBindingElement.id updates?.suggestedBinding?.element.id !== startBindingElement.id
? projectFixedPointOntoDiagonal( ? projectFixedPointOntoDiagonal(
element, element,
pointFrom<GlobalPoint>(element.x, element.y), pointFrom<GlobalPoint>(element.x, element.y),
startBindingElement, startBindingElement,
"start", "start",
elementsMap, elementsMap,
app.state.zoom,
) )
: linearElementEditor.initialState.altFocusPoint, : linearElementEditor.initialState.altFocusPoint,
}, },
@@ -528,6 +535,7 @@ export class LinearElementEditor {
app, app,
shouldRotateWithDiscreteAngle(event) && singlePointDragged, shouldRotateWithDiscreteAngle(event) && singlePointDragged,
event.altKey, event.altKey,
linearElementEditor,
); );
LinearElementEditor.movePoints(element, app.scene, positions, { LinearElementEditor.movePoints(element, app.scene, positions, {
@@ -603,11 +611,11 @@ export class LinearElementEditor {
const altFocusPointBindableElement = const altFocusPointBindableElement =
endIsSelected && // The "other" end (i.e. "end") is dragged endIsSelected && // The "other" end (i.e. "end") is dragged
startBindingElement && startBindingElement &&
updates?.suggestedBinding?.id !== startBindingElement.id // The end point is not hovering the start bindable + it's binding gap updates?.suggestedBinding?.element.id !== startBindingElement.id // The end point is not hovering the start bindable + it's binding gap
? startBindingElement ? startBindingElement
: startIsSelected && // The "other" end (i.e. "start") is dragged : startIsSelected && // The "other" end (i.e. "start") is dragged
endBindingElement && endBindingElement &&
updates?.suggestedBinding?.id !== endBindingElement.id // The start point is not hovering the end bindable + it's binding gap updates?.suggestedBinding?.element.id !== endBindingElement.id // The start point is not hovering the end bindable + it's binding gap
? endBindingElement ? endBindingElement
: null; : null;
@@ -627,6 +635,7 @@ export class LinearElementEditor {
altFocusPointBindableElement, altFocusPointBindableElement,
"start", "start",
elementsMap, elementsMap,
app.state.zoom,
) )
: linearElementEditor.initialState.altFocusPoint, : linearElementEditor.initialState.altFocusPoint,
}, },
@@ -724,7 +733,6 @@ export class LinearElementEditor {
? [pointerDownState.lastClickedPoint] ? [pointerDownState.lastClickedPoint]
: selectedPointsIndices, : selectedPointsIndices,
isDragging: false, isDragging: false,
pointerOffset: { x: 0, y: 0 },
customLineAngle: null, customLineAngle: null,
initialState: { initialState: {
...editingLinearElement.initialState, ...editingLinearElement.initialState,
@@ -2077,6 +2085,7 @@ const pointDraggingUpdates = (
app: AppClassProperties, app: AppClassProperties,
angleLocked: boolean, angleLocked: boolean,
altKey: boolean, altKey: boolean,
linearElementEditor: LinearElementEditor,
): { ): {
positions: PointsPositionUpdates; positions: PointsPositionUpdates;
updates?: PointMoveOtherUpdates; updates?: PointMoveOtherUpdates;
@@ -2124,18 +2133,89 @@ const pointDraggingUpdates = (
); );
if (isElbowArrow(element)) { if (isElbowArrow(element)) {
const suggestedBindingElement = startIsDragged
? start.element
: endIsDragged
? end.element
: null;
return { return {
positions: naiveDraggingPoints, positions: naiveDraggingPoints,
updates: { updates: {
suggestedBinding: startIsDragged suggestedBinding: suggestedBindingElement
? start.element ? {
: endIsDragged element: suggestedBindingElement,
? end.element midPoint: snapToMid(
suggestedBindingElement,
elementsMap,
pointFrom<GlobalPoint>(
scenePointerX - linearElementEditor.pointerOffset.x,
scenePointerY - linearElementEditor.pointerOffset.y,
),
),
}
: null, : null,
}, },
}; };
} }
// Handle the case where neither endpoint is being dragged
// but we need to update bound endpoints
if (!startIsDragged && !endIsDragged) {
const nextArrow = {
...element,
points: element.points.map((p, idx) => {
return naiveDraggingPoints.get(idx)?.point ?? p;
}),
};
const positions = new Map(naiveDraggingPoints);
if (element.startBinding) {
const startBindable = elementsMap.get(element.startBinding.elementId) as
| ExcalidrawBindableElement
| undefined;
if (startBindable) {
const startPoint =
updateBoundPoint(
nextArrow,
"startBinding",
element.startBinding,
startBindable,
elementsMap,
) ?? null;
if (startPoint) {
positions.set(0, { point: startPoint, isDragging: true });
}
}
}
if (element.endBinding) {
const endBindable = elementsMap.get(element.endBinding.elementId) as
| ExcalidrawBindableElement
| undefined;
if (endBindable) {
const endPoint =
updateBoundPoint(
nextArrow,
"endBinding",
element.endBinding,
endBindable,
elementsMap,
) ?? null;
if (endPoint) {
positions.set(element.points.length - 1, {
point: endPoint,
isDragging: true,
});
}
}
}
return {
positions,
};
}
if (startIsDragged === endIsDragged) { if (startIsDragged === endIsDragged) {
return { return {
positions: naiveDraggingPoints, positions: naiveDraggingPoints,
@@ -2166,7 +2246,20 @@ const pointDraggingUpdates = (
(updates.startBinding.mode === "orbit" || (updates.startBinding.mode === "orbit" ||
!getFeatureFlag("COMPLEX_BINDINGS")) !getFeatureFlag("COMPLEX_BINDINGS"))
) { ) {
updates.suggestedBinding = start.element; updates.suggestedBinding = start.element
? {
element: start.element,
midPoint: getSnapOutlineMidPoint(
pointFrom<GlobalPoint>(
scenePointerX - linearElementEditor.pointerOffset.x,
scenePointerY - linearElementEditor.pointerOffset.y,
),
start.element,
elementsMap,
app.state.zoom,
),
}
: null;
} }
} else if (startIsDragged) { } else if (startIsDragged) {
updates.suggestedBinding = app.state.suggestedBinding; updates.suggestedBinding = app.state.suggestedBinding;
@@ -2192,7 +2285,20 @@ const pointDraggingUpdates = (
(updates.endBinding.mode === "orbit" || (updates.endBinding.mode === "orbit" ||
!getFeatureFlag("COMPLEX_BINDINGS")) !getFeatureFlag("COMPLEX_BINDINGS"))
) { ) {
updates.suggestedBinding = end.element; updates.suggestedBinding = end.element
? {
element: end.element,
midPoint: getSnapOutlineMidPoint(
pointFrom<GlobalPoint>(
scenePointerX - linearElementEditor.pointerOffset.x,
scenePointerY - linearElementEditor.pointerOffset.y,
),
end.element,
elementsMap,
app.state.zoom,
),
}
: null;
} }
} else if (endIsDragged) { } else if (endIsDragged) {
updates.suggestedBinding = app.state.suggestedBinding; updates.suggestedBinding = app.state.suggestedBinding;
@@ -2232,19 +2338,6 @@ const pointDraggingUpdates = (
: updates.endBinding, : updates.endBinding,
}; };
// We need to use a custom intersector to ensure that if there is a big "jump"
// in the arrow's position, we can position it with outline avoidance
// pixel-perfectly and avoid "dancing" arrows.
// NOTE: Direction matters here, so we create two intersectors
const startCustomIntersector =
start.focusPoint && end.focusPoint
? lineSegment(start.focusPoint, end.focusPoint)
: undefined;
const endCustomIntersector =
start.focusPoint && end.focusPoint
? lineSegment(end.focusPoint, start.focusPoint)
: undefined;
// Needed to handle a special case where an existing arrow is dragged over // Needed to handle a special case where an existing arrow is dragged over
// the same element it is bound to on the other side // the same element it is bound to on the other side
const startIsDraggingOverEndElement = const startIsDraggingOverEndElement =
@@ -2280,7 +2373,7 @@ const pointDraggingUpdates = (
nextArrow.endBinding, nextArrow.endBinding,
endBindable, endBindable,
elementsMap, elementsMap,
endCustomIntersector, endIsDragged,
) || nextArrow.points[nextArrow.points.length - 1] ) || nextArrow.points[nextArrow.points.length - 1]
: nextArrow.points[nextArrow.points.length - 1]; : nextArrow.points[nextArrow.points.length - 1];
@@ -2303,7 +2396,7 @@ const pointDraggingUpdates = (
: startIsDraggingOverEndElement && : startIsDraggingOverEndElement &&
app.state.bindMode !== "inside" && app.state.bindMode !== "inside" &&
getFeatureFlag("COMPLEX_BINDINGS") getFeatureFlag("COMPLEX_BINDINGS")
? nextArrow.points[nextArrow.points.length - 1] ? endLocalPoint
: startBindable : startBindable
? updateBoundPoint( ? updateBoundPoint(
element, element,
@@ -2311,15 +2404,18 @@ const pointDraggingUpdates = (
nextArrow.startBinding, nextArrow.startBinding,
startBindable, startBindable,
elementsMap, elementsMap,
startCustomIntersector, startIsDragged,
) || nextArrow.points[0] ) || nextArrow.points[0]
: nextArrow.points[0]; : nextArrow.points[0];
const endChanged = const endChanged =
pointDistance( !startIsDraggingOverEndElement &&
endLocalPoint, !(
nextArrow.points[nextArrow.points.length - 1], endIsDraggingOverStartElement &&
) !== 0; app.state.bindMode !== "inside" &&
getFeatureFlag("COMPLEX_BINDINGS")
) &&
!!endBindable;
const startChanged = const startChanged =
pointDistance(startLocalPoint, nextArrow.points[0]) !== 0; pointDistance(startLocalPoint, nextArrow.points[0]) !== 0;
@@ -2333,13 +2429,7 @@ const pointDraggingUpdates = (
const indices = Array.from(indicesSet); const indices = Array.from(indicesSet);
return { return {
updates: updates,
updates.startBinding || updates.suggestedBinding
? {
startBinding: updates.startBinding,
suggestedBinding: updates.suggestedBinding,
}
: undefined,
positions: new Map( positions: new Map(
indices.map((idx) => { indices.map((idx) => {
return [ return [
+14 -1
View File
@@ -11,6 +11,7 @@ import type { Mutable } from "@excalidraw/common/utility-types";
import { ShapeCache } from "./shape"; import { ShapeCache } from "./shape";
import { updateElbowArrowPoints } from "./elbowArrow"; import { updateElbowArrowPoints } from "./elbowArrow";
import { ensureSchemaStateForElementType } from "./schema";
import { isElbowArrow } from "./typeChecks"; import { isElbowArrow } from "./typeChecks";
@@ -137,6 +138,10 @@ export const mutateElement = <TElement extends Mutable<ExcalidrawElement>>(
element.version = updates.version ?? element.version + 1; element.version = updates.version ?? element.version + 1;
element.versionNonce = updates.versionNonce ?? randomInteger(); element.versionNonce = updates.versionNonce ?? randomInteger();
element.updated = getUpdatedTimestamp(); element.updated = getUpdatedTimestamp();
element.schemaState = ensureSchemaStateForElementType(
element.schemaState,
element.type,
) as TElement["schemaState"];
return element; return element;
}; };
@@ -166,13 +171,21 @@ export const newElementWith = <TElement extends ExcalidrawElement>(
return element; return element;
} }
return { const updatedElement = {
...element, ...element,
...updates, ...updates,
version: updates.version ?? element.version + 1, version: updates.version ?? element.version + 1,
versionNonce: updates.versionNonce ?? randomInteger(), versionNonce: updates.versionNonce ?? randomInteger(),
updated: getUpdatedTimestamp(), updated: getUpdatedTimestamp(),
}; };
return {
...updatedElement,
schemaState: ensureSchemaStateForElementType(
updatedElement.schemaState,
updatedElement.type,
),
};
}; };
/** /**
+3
View File
@@ -20,6 +20,7 @@ import {
getElementAbsoluteCoords, getElementAbsoluteCoords,
getResizedElementAbsoluteCoords, getResizedElementAbsoluteCoords,
} from "./bounds"; } from "./bounds";
import { ensureSchemaStateForElementType } from "./schema";
import { newElementWith } from "./mutateElement"; import { newElementWith } from "./mutateElement";
import { getBoundTextMaxWidth } from "./textElement"; import { getBoundTextMaxWidth } from "./textElement";
import { normalizeText, measureText } from "./textMeasurements"; import { normalizeText, measureText } from "./textMeasurements";
@@ -70,6 +71,7 @@ export type ElementConstructorOpts = MarkOptional<
| "roughness" | "roughness"
| "strokeWidth" | "strokeWidth"
| "roundness" | "roundness"
| "schemaState"
| "locked" | "locked"
| "opacity" | "opacity"
| "customData" | "customData"
@@ -144,6 +146,7 @@ const _newElementBase = <T extends ExcalidrawElement>(
roundness, roundness,
seed: rest.seed ?? randomInteger(), seed: rest.seed ?? randomInteger(),
version: rest.version || 1, version: rest.version || 1,
schemaState: ensureSchemaStateForElementType(rest.schemaState, type),
versionNonce: rest.versionNonce ?? 0, versionNonce: rest.versionNonce ?? 0,
isDeleted: false as false, isDeleted: false as false,
boundElements, boundElements,
+154 -140
View File
@@ -1,5 +1,4 @@
import rough from "roughjs/bin/rough"; import rough from "roughjs/bin/rough";
import { getStroke } from "perfect-freehand";
import { import {
type GlobalPoint, type GlobalPoint,
@@ -15,6 +14,7 @@ import {
DEFAULT_REDUCED_GLOBAL_ALPHA, DEFAULT_REDUCED_GLOBAL_ALPHA,
ELEMENT_READY_TO_ERASE_OPACITY, ELEMENT_READY_TO_ERASE_OPACITY,
FRAME_STYLE, FRAME_STYLE,
DARK_THEME_FILTER,
MIME_TYPES, MIME_TYPES,
THEME, THEME,
distance, distance,
@@ -22,6 +22,9 @@ import {
isRTL, isRTL,
getVerticalOffset, getVerticalOffset,
invariant, invariant,
isTransparent,
applyDarkModeFilter,
isSafari,
} from "@excalidraw/common"; } from "@excalidraw/common";
import type { import type {
@@ -76,18 +79,11 @@ import type {
ExcalidrawFrameLikeElement, ExcalidrawFrameLikeElement,
NonDeletedSceneElementsMap, NonDeletedSceneElementsMap,
ElementsMap, ElementsMap,
ExcalidrawFrameElement,
} from "./types"; } from "./types";
import type { StrokeOptions } from "perfect-freehand";
import type { RoughCanvas } from "roughjs/bin/canvas"; import type { RoughCanvas } from "roughjs/bin/canvas";
// using a stronger invert (100% vs our regular 93%) and saturate
// as a temp hack to make images in dark theme look closer to original
// color scheme (it's still not quite there and the colors look slightly
// desatured, alas...)
export const IMAGE_INVERT_FILTER =
"invert(100%) hue-rotate(180deg) saturate(1.25)";
const isPendingImageElement = ( const isPendingImageElement = (
element: ExcalidrawElement, element: ExcalidrawElement,
renderConfig: StaticCanvasRenderConfig, renderConfig: StaticCanvasRenderConfig,
@@ -95,19 +91,6 @@ const isPendingImageElement = (
isInitializedImageElement(element) && isInitializedImageElement(element) &&
!renderConfig.imageCache.has(element.fileId); !renderConfig.imageCache.has(element.fileId);
const shouldResetImageFilter = (
element: ExcalidrawElement,
renderConfig: StaticCanvasRenderConfig,
appState: StaticCanvasAppState | InteractiveCanvasAppState,
) => {
return (
appState.theme === THEME.DARK &&
isInitializedImageElement(element) &&
!isPendingImageElement(element, renderConfig) &&
renderConfig.imageCache.get(element.fileId)?.mimeType !== MIME_TYPES.svg
);
};
const getCanvasPadding = (element: ExcalidrawElement) => { const getCanvasPadding = (element: ExcalidrawElement) => {
switch (element.type) { switch (element.type) {
case "freedraw": case "freedraw":
@@ -272,11 +255,6 @@ const generateElementCanvas = (
const rc = rough.canvas(canvas); const rc = rough.canvas(canvas);
// in dark theme, revert the image color filter
if (shouldResetImageFilter(element, renderConfig, appState)) {
context.filter = IMAGE_INVERT_FILTER;
}
drawElementOnCanvas(element, rc, context, renderConfig); drawElementOnCanvas(element, rc, context, renderConfig);
context.restore(); context.restore();
@@ -385,8 +363,9 @@ IMAGE_ERROR_PLACEHOLDER_IMG.src = `data:${MIME_TYPES.svg},${encodeURIComponent(
const drawImagePlaceholder = ( const drawImagePlaceholder = (
element: ExcalidrawImageElement, element: ExcalidrawImageElement,
context: CanvasRenderingContext2D, context: CanvasRenderingContext2D,
theme: StaticCanvasRenderConfig["theme"],
) => { ) => {
context.fillStyle = "#E7E7E7"; context.fillStyle = theme === THEME.DARK ? "#2E2E2E" : "#E7E7E7";
context.fillRect(0, 0, element.width, element.height); context.fillRect(0, 0, element.width, element.height);
const imageMinWidthOrHeight = Math.min(element.width, element.height); const imageMinWidthOrHeight = Math.min(element.width, element.height);
@@ -421,7 +400,8 @@ const drawElementOnCanvas = (
case "ellipse": { case "ellipse": {
context.lineJoin = "round"; context.lineJoin = "round";
context.lineCap = "round"; context.lineCap = "round";
rc.draw(ShapeCache.get(element)!);
rc.draw(ShapeCache.generateElementShape(element, renderConfig));
break; break;
} }
case "arrow": case "arrow":
@@ -429,33 +409,44 @@ const drawElementOnCanvas = (
context.lineJoin = "round"; context.lineJoin = "round";
context.lineCap = "round"; context.lineCap = "round";
ShapeCache.get(element)!.forEach((shape) => { ShapeCache.generateElementShape(element, renderConfig).forEach(
rc.draw(shape); (shape) => {
}); rc.draw(shape);
},
);
break; break;
} }
case "freedraw": { case "freedraw": {
// Draw directly to canvas // Draw directly to canvas
context.save(); context.save();
context.fillStyle = element.strokeColor;
const path = getFreeDrawPath2D(element) as Path2D; const shapes = ShapeCache.generateElementShape(element, renderConfig);
const fillShape = ShapeCache.get(element);
if (fillShape) { for (const shape of shapes) {
rc.draw(fillShape); if (typeof shape === "string") {
context.fillStyle =
renderConfig.theme === THEME.DARK
? applyDarkModeFilter(element.strokeColor)
: element.strokeColor;
context.fill(new Path2D(shape));
} else {
rc.draw(shape);
}
} }
context.fillStyle = element.strokeColor;
context.fill(path);
context.restore(); context.restore();
break; break;
} }
case "image": { case "image": {
context.save();
const cacheEntry =
element.fileId !== null
? renderConfig.imageCache.get(element.fileId)
: null;
const img = isInitializedImageElement(element) const img = isInitializedImageElement(element)
? renderConfig.imageCache.get(element.fileId)?.image ? cacheEntry?.image
: undefined; : undefined;
if (img != null && !(img instanceof Promise)) { if (img != null && !(img instanceof Promise)) {
if (element.roundness && context.roundRect) { if (element.roundness && context.roundRect) {
context.beginPath(); context.beginPath();
@@ -478,20 +469,80 @@ const drawElementOnCanvas = (
height: img.naturalHeight, height: img.naturalHeight,
}; };
context.drawImage( const shouldInvertImage =
img, renderConfig.theme === THEME.DARK &&
x, cacheEntry?.mimeType === MIME_TYPES.svg;
y,
width, if (shouldInvertImage && isSafari) {
height, const devicePixelRatio = window.devicePixelRatio || 1;
0 /* hardcoded for the selection box*/, const tempCanvas = document.createElement("canvas");
0, tempCanvas.width = element.width * devicePixelRatio;
element.width, tempCanvas.height = element.height * devicePixelRatio;
element.height, const tempContext = tempCanvas.getContext("2d");
);
if (tempContext) {
tempContext.scale(devicePixelRatio, devicePixelRatio);
tempContext.drawImage(
img,
x,
y,
width,
height,
0,
0,
element.width,
element.height,
);
const imageData = tempContext.getImageData(
0,
0,
tempCanvas.width,
tempCanvas.height,
);
const data = imageData.data;
for (let i = 0; i < data.length; i += 4) {
data[i] = 255 - data[i];
data[i + 1] = 255 - data[i + 1];
data[i + 2] = 255 - data[i + 2];
}
tempContext.putImageData(imageData, 0, 0);
context.drawImage(
tempCanvas,
0,
0,
tempCanvas.width,
tempCanvas.height,
0,
0,
element.width,
element.height,
);
}
} else {
if (shouldInvertImage) {
context.filter = DARK_THEME_FILTER;
}
context.drawImage(
img,
x,
y,
width,
height,
0 /* hardcoded for the selection box*/,
0,
element.width,
element.height,
);
}
} else { } else {
drawImagePlaceholder(element, context); drawImagePlaceholder(element, context, renderConfig.theme);
} }
context.restore();
break; break;
} }
default: { default: {
@@ -506,7 +557,10 @@ const drawElementOnCanvas = (
context.canvas.setAttribute("dir", rtl ? "rtl" : "ltr"); context.canvas.setAttribute("dir", rtl ? "rtl" : "ltr");
context.save(); context.save();
context.font = getFontString(element); context.font = getFontString(element);
context.fillStyle = element.strokeColor; context.fillStyle =
renderConfig.theme === THEME.DARK
? applyDarkModeFilter(element.strokeColor)
: element.strokeColor;
context.textAlign = element.textAlign as CanvasTextAlign; context.textAlign = element.textAlign as CanvasTextAlign;
// Canvas does not support multiline text by default // Canvas does not support multiline text by default
@@ -725,6 +779,45 @@ export const renderSelectionElement = (
context.restore(); context.restore();
}; };
export const renderFrameBackground = (
frame: ExcalidrawFrameElement,
context: CanvasRenderingContext2D,
appState: StaticCanvasAppState | InteractiveCanvasAppState,
opts?: {
roundCorners?: boolean;
},
) => {
if (isTransparent(frame.backgroundColor)) {
return;
}
context.save();
context.translate(frame.x + appState.scrollX, frame.y + appState.scrollY);
context.fillStyle =
appState.theme === THEME.DARK
? applyDarkModeFilter(frame.backgroundColor)
: frame.backgroundColor;
const shouldRoundCorners = opts?.roundCorners ?? true;
if (shouldRoundCorners && FRAME_STYLE.radius && context.roundRect) {
context.beginPath();
context.roundRect(
0,
0,
frame.width,
frame.height,
FRAME_STYLE.radius / appState.zoom.value,
);
context.fill();
context.closePath();
} else {
context.fillRect(0, 0, frame.width, frame.height);
}
context.restore();
};
export const renderElement = ( export const renderElement = (
element: NonDeletedExcalidrawElement, element: NonDeletedExcalidrawElement,
elementsMap: RenderableElementsMap, elementsMap: RenderableElementsMap,
@@ -756,15 +849,19 @@ export const renderElement = (
element.x + appState.scrollX, element.x + appState.scrollX,
element.y + appState.scrollY, element.y + appState.scrollY,
); );
context.fillStyle = "rgba(0, 0, 200, 0.04)";
context.lineWidth = FRAME_STYLE.strokeWidth / appState.zoom.value; context.lineWidth = FRAME_STYLE.strokeWidth / appState.zoom.value;
context.strokeStyle = FRAME_STYLE.strokeColor; context.strokeStyle =
appState.theme === THEME.DARK
? applyDarkModeFilter(FRAME_STYLE.strokeColor)
: FRAME_STYLE.strokeColor;
// TODO change later to only affect AI frames // TODO change later to only affect AI frames
if (isMagicFrameElement(element)) { if (isMagicFrameElement(element)) {
context.strokeStyle = context.strokeStyle =
appState.theme === THEME.LIGHT ? "#7affd7" : "#1d8264"; appState.theme === THEME.LIGHT
? "#7affd7"
: applyDarkModeFilter("#1d8264");
} }
if (FRAME_STYLE.radius && context.roundRect) { if (FRAME_STYLE.radius && context.roundRect) {
@@ -787,11 +884,6 @@ export const renderElement = (
break; break;
} }
case "freedraw": { case "freedraw": {
// TODO investigate if we can do this in situ. Right now we need to call
// beforehand because math helpers (such as getElementAbsoluteCoords)
// rely on existing shapes
ShapeCache.generateElementShape(element, null);
if (renderConfig.isExporting) { if (renderConfig.isExporting) {
const [x1, y1, x2, y2] = getElementAbsoluteCoords(element, elementsMap); const [x1, y1, x2, y2] = getElementAbsoluteCoords(element, elementsMap);
const cx = (x1 + x2) / 2 + appState.scrollX; const cx = (x1 + x2) / 2 + appState.scrollX;
@@ -835,10 +927,6 @@ export const renderElement = (
case "text": case "text":
case "iframe": case "iframe":
case "embeddable": { case "embeddable": {
// TODO investigate if we can do this in situ. Right now we need to call
// beforehand because math helpers (such as getElementAbsoluteCoords)
// rely on existing shapes
ShapeCache.generateElementShape(element, renderConfig);
if (renderConfig.isExporting) { if (renderConfig.isExporting) {
const [x1, y1, x2, y2] = getElementAbsoluteCoords(element, elementsMap); const [x1, y1, x2, y2] = getElementAbsoluteCoords(element, elementsMap);
const cx = (x1 + x2) / 2 + appState.scrollX; const cx = (x1 + x2) / 2 + appState.scrollX;
@@ -861,9 +949,6 @@ export const renderElement = (
context.save(); context.save();
context.translate(cx, cy); context.translate(cx, cy);
if (shouldResetImageFilter(element, renderConfig, appState)) {
context.filter = "none";
}
const boundTextElement = getBoundTextElement(element, elementsMap); const boundTextElement = getBoundTextElement(element, elementsMap);
if (isArrowElement(element) && boundTextElement) { if (isArrowElement(element) && boundTextElement) {
@@ -1026,23 +1111,6 @@ export const renderElement = (
context.globalAlpha = 1; context.globalAlpha = 1;
}; };
export const pathsCache = new WeakMap<ExcalidrawFreeDrawElement, Path2D>([]);
export function generateFreeDrawShape(element: ExcalidrawFreeDrawElement) {
const svgPathData = getFreeDrawSvgPath(element);
const path = new Path2D(svgPathData);
pathsCache.set(element, path);
return path;
}
export function getFreeDrawPath2D(element: ExcalidrawFreeDrawElement) {
return pathsCache.get(element);
}
export function getFreeDrawSvgPath(element: ExcalidrawFreeDrawElement) {
return getSvgPathFromStroke(getFreedrawOutlinePoints(element));
}
export function getFreedrawOutlineAsSegments( export function getFreedrawOutlineAsSegments(
element: ExcalidrawFreeDrawElement, element: ExcalidrawFreeDrawElement,
points: [number, number][], points: [number, number][],
@@ -1098,57 +1166,3 @@ export function getFreedrawOutlineAsSegments(
], ],
); );
} }
export function getFreedrawOutlinePoints(element: ExcalidrawFreeDrawElement) {
// If input points are empty (should they ever be?) return a dot
const inputPoints = element.simulatePressure
? element.points
: element.points.length
? element.points.map(([x, y], i) => [x, y, element.pressures[i]])
: [[0, 0, 0.5]];
// Consider changing the options for simulated pressure vs real pressure
const options: StrokeOptions = {
simulatePressure: element.simulatePressure,
size: element.strokeWidth * 4.25,
thinning: 0.6,
smoothing: 0.5,
streamline: 0.5,
easing: (t) => Math.sin((t * Math.PI) / 2), // https://easings.net/#easeOutSine
last: true,
};
return getStroke(inputPoints as number[][], options) as [number, number][];
}
function med(A: number[], B: number[]) {
return [(A[0] + B[0]) / 2, (A[1] + B[1]) / 2];
}
// Trim SVG path data so number are each two decimal points. This
// improves SVG exports, and prevents rendering errors on points
// with long decimals.
const TO_FIXED_PRECISION = /(\s?[A-Z]?,?-?[0-9]*\.[0-9]{0,2})(([0-9]|e|-)*)/g;
function getSvgPathFromStroke(points: number[][]): string {
if (!points.length) {
return "";
}
const max = points.length - 1;
return points
.reduce(
(acc, point, i, arr) => {
if (i === max) {
acc.push(point, med(point, arr[0]), "L", arr[0], "Z");
} else {
acc.push(point, med(point, arr[i + 1]));
}
return acc;
},
["M", points[0], "Q"],
)
.join(" ")
.replace(TO_FIXED_PRECISION, "$1");
}
+91
View File
@@ -0,0 +1,91 @@
/**
* Shared schema primitives used by element types and higher-level migrations.
*/
export const SCHEMA_INITIAL_TRACK_VERSION = 1 as const;
/** Core namespace reserved for built-in Excalidraw migrations. */
export const SCHEMA_CORE_NAMESPACE = "core" as const;
export type SchemaNamespace = typeof SCHEMA_CORE_NAMESPACE | `host.${string}`;
/**
* A schema track is an independent version line:
* - core tracks: "excalidraw.*"
* - host tracks: "host.<appId>.<track>"
*/
export type SchemaTrack = `excalidraw.${string}` | `host.${string}.${string}`;
export type ElementSchemaState = Readonly<{
tracks: Readonly<Record<string, number>>;
}>;
/** Core frame track id used by the frame background migration. */
export const CORE_FRAME_SCHEMA_TRACK = "excalidraw.shape.frame" as const;
/** Latest core track versions supported by this build. */
export const CORE_SUPPORTED_TRACKS = {
[CORE_FRAME_SCHEMA_TRACK]: 2,
} as const;
const getRequiredCoreTracksForElementType = (type: string) => {
if (type === "frame") {
return {
[CORE_FRAME_SCHEMA_TRACK]: CORE_SUPPORTED_TRACKS[CORE_FRAME_SCHEMA_TRACK],
} as const;
}
return {} as const;
};
const isValidTrackVersion = (version: unknown): version is number =>
typeof version === "number" &&
Number.isInteger(version) &&
version >= SCHEMA_INITIAL_TRACK_VERSION;
/**
* Ensures an element schema state is normalized and satisfies type defaults.
* Required core tracks are only ever bumped forward (never downgraded).
*/
export const ensureSchemaStateForElementType = (
schemaState: ElementSchemaState | undefined,
type: string,
): ElementSchemaState => {
const requiredTracks = getRequiredCoreTracksForElementType(type);
const currentTracks = schemaState?.tracks || {};
const nextTracks: Record<string, number> = {};
let didChange = !schemaState;
for (const [track, version] of Object.entries(
currentTracks as Record<string, unknown>,
)) {
if (isValidTrackVersion(version)) {
nextTracks[track] = version;
continue;
}
nextTracks[track] = SCHEMA_INITIAL_TRACK_VERSION;
didChange = true;
}
for (const [track, requiredVersion] of Object.entries(requiredTracks)) {
const currentVersion = nextTracks[track];
if (
!isValidTrackVersion(currentVersion) ||
currentVersion < requiredVersion
) {
nextTracks[track] = requiredVersion;
didChange = true;
}
}
if (!didChange) {
return schemaState!;
}
return { tracks: nextTracks };
};
/**
* Default schema state for newly created elements.
* New frames are created at the latest supported frame track version.
*/
export const getDefaultSchemaStateForElementType = (
type: string,
): ElementSchemaState => ensureSchemaStateForElementType(undefined, type);
+144 -43
View File
@@ -1,4 +1,5 @@
import { simplify } from "points-on-curve"; import { simplify } from "points-on-curve";
import { getStroke } from "perfect-freehand";
import { import {
type GeometricShape, type GeometricShape,
@@ -17,10 +18,12 @@ import {
} from "@excalidraw/math"; } from "@excalidraw/math";
import { import {
ROUGHNESS, ROUGHNESS,
THEME,
isTransparent, isTransparent,
assertNever, assertNever,
COLOR_PALETTE, COLOR_PALETTE,
LINE_POLYGON_POINT_MERGE_DISTANCE, LINE_POLYGON_POINT_MERGE_DISTANCE,
applyDarkModeFilter,
} from "@excalidraw/common"; } from "@excalidraw/common";
import { RoughGenerator } from "roughjs/bin/generator"; import { RoughGenerator } from "roughjs/bin/generator";
@@ -36,6 +39,7 @@ import type {
import type { import type {
ElementShape, ElementShape,
ElementShapes, ElementShapes,
SVGPathString,
} from "@excalidraw/excalidraw/scene/types"; } from "@excalidraw/excalidraw/scene/types";
import { elementWithCanvasCache } from "./renderElement"; import { elementWithCanvasCache } from "./renderElement";
@@ -52,7 +56,6 @@ import { getCornerRadius, isPathALoop } from "./utils";
import { headingForPointIsHorizontal } from "./heading"; import { headingForPointIsHorizontal } from "./heading";
import { canChangeRoundness } from "./comparisons"; import { canChangeRoundness } from "./comparisons";
import { generateFreeDrawShape } from "./renderElement";
import { import {
getArrowheadPoints, getArrowheadPoints,
getCenterForBounds, getCenterForBounds,
@@ -77,29 +80,32 @@ import type { Point as RoughPoint } from "roughjs/bin/geometry";
export class ShapeCache { export class ShapeCache {
private static rg = new RoughGenerator(); private static rg = new RoughGenerator();
private static cache = new WeakMap<ExcalidrawElement, ElementShape>(); private static cache = new WeakMap<
ExcalidrawElement,
{ shape: ElementShape; theme: AppState["theme"] }
>();
/** /**
* Retrieves shape from cache if available. Use this only if shape * Retrieves shape from cache if available. Use this only if shape
* is optional and you have a fallback in case it's not cached. * is optional and you have a fallback in case it's not cached.
*/ */
public static get = <T extends ExcalidrawElement>(element: T) => { public static get = <T extends ExcalidrawElement>(
return ShapeCache.cache.get( element: T,
element, theme: AppState["theme"] | null,
) as T["type"] extends keyof ElementShapes ) => {
? ElementShapes[T["type"]] | undefined const cached = ShapeCache.cache.get(element);
: ElementShape | undefined; if (cached && (theme === null || cached.theme === theme)) {
return cached.shape as T["type"] extends keyof ElementShapes
? ElementShapes[T["type"]] | undefined
: ElementShape | undefined;
}
return undefined;
}; };
public static set = <T extends ExcalidrawElement>( public static delete = (element: 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); ShapeCache.cache.delete(element);
elementWithCanvasCache.delete(element);
};
public static destroy = () => { public static destroy = () => {
ShapeCache.cache = new WeakMap(); ShapeCache.cache = new WeakMap();
@@ -117,12 +123,13 @@ export class ShapeCache {
isExporting: boolean; isExporting: boolean;
canvasBackgroundColor: AppState["viewBackgroundColor"]; canvasBackgroundColor: AppState["viewBackgroundColor"];
embedsValidationStatus: EmbedsValidationStatus; embedsValidationStatus: EmbedsValidationStatus;
theme: AppState["theme"];
} | null, } | null,
) => { ) => {
// when exporting, always regenerated to guarantee the latest shape // when exporting, always regenerated to guarantee the latest shape
const cachedShape = renderConfig?.isExporting const cachedShape = renderConfig?.isExporting
? undefined ? undefined
: ShapeCache.get(element); : ShapeCache.get(element, renderConfig ? renderConfig.theme : null);
// `null` indicates no rc shape applicable for this element type, // `null` indicates no rc shape applicable for this element type,
// but it's considered a valid cache value (= do not regenerate) // but it's considered a valid cache value (= do not regenerate)
@@ -132,19 +139,25 @@ export class ShapeCache {
elementWithCanvasCache.delete(element); elementWithCanvasCache.delete(element);
const shape = generateElementShape( const shape = _generateElementShape(
element, element,
ShapeCache.rg, ShapeCache.rg,
renderConfig || { renderConfig || {
isExporting: false, isExporting: false,
canvasBackgroundColor: COLOR_PALETTE.white, canvasBackgroundColor: COLOR_PALETTE.white,
embedsValidationStatus: null, embedsValidationStatus: null,
theme: THEME.LIGHT,
}, },
) as T["type"] extends keyof ElementShapes ) as T["type"] extends keyof ElementShapes
? ElementShapes[T["type"]] ? ElementShapes[T["type"]]
: Drawable | null; : Drawable | null;
ShapeCache.cache.set(element, shape); if (!renderConfig?.isExporting) {
ShapeCache.cache.set(element, {
shape,
theme: renderConfig?.theme || THEME.LIGHT,
});
}
return shape; return shape;
}; };
@@ -180,6 +193,7 @@ function adjustRoughness(element: ExcalidrawElement): number {
export const generateRoughOptions = ( export const generateRoughOptions = (
element: ExcalidrawElement, element: ExcalidrawElement,
continuousPath = false, continuousPath = false,
isDarkMode: boolean = false,
): Options => { ): Options => {
const options: Options = { const options: Options = {
seed: element.seed, seed: element.seed,
@@ -204,7 +218,9 @@ export const generateRoughOptions = (
fillWeight: element.strokeWidth / 2, fillWeight: element.strokeWidth / 2,
hachureGap: element.strokeWidth * 4, hachureGap: element.strokeWidth * 4,
roughness: adjustRoughness(element), roughness: adjustRoughness(element),
stroke: element.strokeColor, stroke: isDarkMode
? applyDarkModeFilter(element.strokeColor)
: element.strokeColor,
preserveVertices: preserveVertices:
continuousPath || element.roughness < ROUGHNESS.cartoonist, continuousPath || element.roughness < ROUGHNESS.cartoonist,
}; };
@@ -218,6 +234,8 @@ export const generateRoughOptions = (
options.fillStyle = element.fillStyle; options.fillStyle = element.fillStyle;
options.fill = isTransparent(element.backgroundColor) options.fill = isTransparent(element.backgroundColor)
? undefined ? undefined
: isDarkMode
? applyDarkModeFilter(element.backgroundColor)
: element.backgroundColor; : element.backgroundColor;
if (element.type === "ellipse") { if (element.type === "ellipse") {
options.curveFitting = 1; options.curveFitting = 1;
@@ -231,6 +249,8 @@ export const generateRoughOptions = (
options.fill = options.fill =
element.backgroundColor === "transparent" element.backgroundColor === "transparent"
? undefined ? undefined
: isDarkMode
? applyDarkModeFilter(element.backgroundColor)
: element.backgroundColor; : element.backgroundColor;
} }
return options; return options;
@@ -284,6 +304,7 @@ const getArrowheadShapes = (
generator: RoughGenerator, generator: RoughGenerator,
options: Options, options: Options,
canvasBackgroundColor: string, canvasBackgroundColor: string,
isDarkMode: boolean,
) => { ) => {
const arrowheadPoints = getArrowheadPoints( const arrowheadPoints = getArrowheadPoints(
element, element,
@@ -309,6 +330,10 @@ const getArrowheadShapes = (
return [generator.line(x3, y3, x4, y4, options)]; return [generator.line(x3, y3, x4, y4, options)];
}; };
const strokeColor = isDarkMode
? applyDarkModeFilter(element.strokeColor)
: element.strokeColor;
switch (arrowhead) { switch (arrowhead) {
case "dot": case "dot":
case "circle": case "circle":
@@ -324,10 +349,10 @@ const getArrowheadShapes = (
fill: fill:
arrowhead === "circle_outline" arrowhead === "circle_outline"
? canvasBackgroundColor ? canvasBackgroundColor
: element.strokeColor, : strokeColor,
fillStyle: "solid", fillStyle: "solid",
stroke: element.strokeColor, stroke: strokeColor,
roughness: Math.min(0.5, options.roughness || 0), roughness: Math.min(0.5, options.roughness || 0),
}), }),
]; ];
@@ -352,7 +377,7 @@ const getArrowheadShapes = (
fill: fill:
arrowhead === "triangle_outline" arrowhead === "triangle_outline"
? canvasBackgroundColor ? canvasBackgroundColor
: element.strokeColor, : strokeColor,
fillStyle: "solid", fillStyle: "solid",
roughness: Math.min(1, options.roughness || 0), roughness: Math.min(1, options.roughness || 0),
}, },
@@ -380,7 +405,7 @@ const getArrowheadShapes = (
fill: fill:
arrowhead === "diamond_outline" arrowhead === "diamond_outline"
? canvasBackgroundColor ? canvasBackgroundColor
: element.strokeColor, : strokeColor,
fillStyle: "solid", fillStyle: "solid",
roughness: Math.min(1, options.roughness || 0), roughness: Math.min(1, options.roughness || 0),
}, },
@@ -602,19 +627,22 @@ export const generateLinearCollisionShape = (
* *
* @private * @private
*/ */
const generateElementShape = ( const _generateElementShape = (
element: Exclude<NonDeletedExcalidrawElement, ExcalidrawSelectionElement>, element: Exclude<NonDeletedExcalidrawElement, ExcalidrawSelectionElement>,
generator: RoughGenerator, generator: RoughGenerator,
{ {
isExporting, isExporting,
canvasBackgroundColor, canvasBackgroundColor,
embedsValidationStatus, embedsValidationStatus,
theme,
}: { }: {
isExporting: boolean; isExporting: boolean;
canvasBackgroundColor: string; canvasBackgroundColor: string;
embedsValidationStatus: EmbedsValidationStatus | null; embedsValidationStatus: EmbedsValidationStatus | null;
theme?: AppState["theme"];
}, },
): Drawable | Drawable[] | null => { ): ElementShape => {
const isDarkMode = theme === THEME.DARK;
switch (element.type) { switch (element.type) {
case "rectangle": case "rectangle":
case "iframe": case "iframe":
@@ -640,6 +668,7 @@ const generateElementShape = (
embedsValidationStatus, embedsValidationStatus,
), ),
true, true,
isDarkMode,
), ),
); );
} else { } else {
@@ -655,6 +684,7 @@ const generateElementShape = (
embedsValidationStatus, embedsValidationStatus,
), ),
false, false,
isDarkMode,
), ),
); );
} }
@@ -692,7 +722,7 @@ const generateElementShape = (
C ${topX} ${topY}, ${topX} ${topY}, ${topX + verticalRadius} ${ C ${topX} ${topY}, ${topX} ${topY}, ${topX + verticalRadius} ${
topY + horizontalRadius topY + horizontalRadius
}`, }`,
generateRoughOptions(element, true), generateRoughOptions(element, true, isDarkMode),
); );
} else { } else {
shape = generator.polygon( shape = generator.polygon(
@@ -702,7 +732,7 @@ const generateElementShape = (
[bottomX, bottomY], [bottomX, bottomY],
[leftX, leftY], [leftX, leftY],
], ],
generateRoughOptions(element), generateRoughOptions(element, false, isDarkMode),
); );
} }
return shape; return shape;
@@ -713,14 +743,14 @@ const generateElementShape = (
element.height / 2, element.height / 2,
element.width, element.width,
element.height, element.height,
generateRoughOptions(element), generateRoughOptions(element, false, isDarkMode),
); );
return shape; return shape;
} }
case "line": case "line":
case "arrow": { case "arrow": {
let shape: ElementShapes[typeof element.type]; let shape: ElementShapes[typeof element.type];
const options = generateRoughOptions(element); const options = generateRoughOptions(element, false, isDarkMode);
// points array can be empty in the beginning, so it is important to add // points array can be empty in the beginning, so it is important to add
// initial position to it // initial position to it
@@ -745,7 +775,7 @@ const generateElementShape = (
shape = [ shape = [
generator.path( generator.path(
generateElbowArrowShape(points, 16), generateElbowArrowShape(points, 16),
generateRoughOptions(element, true), generateRoughOptions(element, true, isDarkMode),
), ),
]; ];
} }
@@ -778,6 +808,7 @@ const generateElementShape = (
generator, generator,
options, options,
canvasBackgroundColor, canvasBackgroundColor,
isDarkMode,
); );
shape.push(...shapes); shape.push(...shapes);
} }
@@ -795,6 +826,7 @@ const generateElementShape = (
generator, generator,
options, options,
canvasBackgroundColor, canvasBackgroundColor,
isDarkMode,
); );
shape.push(...shapes); shape.push(...shapes);
} }
@@ -802,23 +834,28 @@ const generateElementShape = (
return shape; return shape;
} }
case "freedraw": { case "freedraw": {
let shape: ElementShapes[typeof element.type]; // oredered in terms of z-index [background, stroke]
generateFreeDrawShape(element); const shapes: ElementShapes[typeof element.type] = [];
// (1) background fill (rc shape), optional
if (isPathALoop(element.points)) { if (isPathALoop(element.points)) {
// generate rough polygon to fill freedraw shape // generate rough polygon to fill freedraw shape
const simplifiedPoints = simplify( const simplifiedPoints = simplify(
element.points as Mutable<LocalPoint[]>, element.points as Mutable<LocalPoint[]>,
0.75, 0.75,
); );
shape = generator.curve(simplifiedPoints as [number, number][], { shapes.push(
...generateRoughOptions(element), generator.curve(simplifiedPoints as [number, number][], {
stroke: "none", ...generateRoughOptions(element, false, isDarkMode),
}); stroke: "none",
} else { }),
shape = null; );
} }
return shape;
// (2) stroke
shapes.push(getFreeDrawSvgPath(element));
return shapes;
} }
case "frame": case "frame":
case "magicframe": case "magicframe":
@@ -925,9 +962,7 @@ export const getElementShape = <Point extends GlobalPoint | LocalPoint>(
return getPolygonShape(element); return getPolygonShape(element);
case "arrow": case "arrow":
case "line": { case "line": {
const roughShape = const roughShape = ShapeCache.generateElementShape(element, null)[0];
ShapeCache.get(element)?.[0] ??
ShapeCache.generateElementShape(element, null)[0];
const [, , , , cx, cy] = getElementAbsoluteCoords(element, elementsMap); const [, , , , cx, cy] = getElementAbsoluteCoords(element, elementsMap);
return shouldTestInside(element) return shouldTestInside(element)
@@ -1003,3 +1038,69 @@ export const toggleLinePolygonState = (
return ret; return ret;
}; };
// -----------------------------------------------------------------------------
// freedraw shape helper
// -----------------------------------------------------------------------------
// NOTE not cached (-> for SVG export)
const getFreeDrawSvgPath = (element: ExcalidrawFreeDrawElement) => {
return getSvgPathFromStroke(
getFreedrawOutlinePoints(element),
) as SVGPathString;
};
export const getFreedrawOutlinePoints = (
element: ExcalidrawFreeDrawElement,
) => {
// If input points are empty (should they ever be?) return a dot
const inputPoints = element.simulatePressure
? element.points
: element.points.length
? element.points.map(([x, y], i) => [x, y, element.pressures[i]])
: [[0, 0, 0.5]];
return getStroke(inputPoints as number[][], {
simulatePressure: element.simulatePressure,
size: element.strokeWidth * 4.25,
thinning: 0.6,
smoothing: 0.5,
streamline: 0.5,
easing: (t) => Math.sin((t * Math.PI) / 2), // https://easings.net/#easeOutSine
last: true,
}) as [number, number][];
};
const med = (A: number[], B: number[]) => {
return [(A[0] + B[0]) / 2, (A[1] + B[1]) / 2];
};
// Trim SVG path data so number are each two decimal points. This
// improves SVG exports, and prevents rendering errors on points
// with long decimals.
const TO_FIXED_PRECISION = /(\s?[A-Z]?,?-?[0-9]*\.[0-9]{0,2})(([0-9]|e|-)*)/g;
const getSvgPathFromStroke = (points: number[][]): string => {
if (!points.length) {
return "";
}
const max = points.length - 1;
return points
.reduce(
(acc, point, i, arr) => {
if (i === max) {
acc.push(point, med(point, arr[0]), "L", arr[0], "Z");
} else {
acc.push(point, med(point, arr[i + 1]));
}
return acc;
},
["M", points[0], "Q"],
)
.join(" ")
.replace(TO_FIXED_PRECISION, "$1");
};
// -----------------------------------------------------------------------------
+4
View File
@@ -15,6 +15,8 @@ import type {
ValueOf, ValueOf,
} from "@excalidraw/common/utility-types"; } from "@excalidraw/common/utility-types";
import type { ElementSchemaState } from "./schema";
export type ChartType = "bar" | "line"; export type ChartType = "bar" | "line";
export type FillStyle = "hachure" | "cross-hatch" | "solid" | "zigzag"; export type FillStyle = "hachure" | "cross-hatch" | "solid" | "zigzag";
export type FontFamilyKeys = keyof typeof FONT_FAMILY; export type FontFamilyKeys = keyof typeof FONT_FAMILY;
@@ -58,6 +60,8 @@ type _ExcalidrawElementBase = Readonly<{
/** Integer that is sequentially incremented on each change. Used to reconcile /** Integer that is sequentially incremented on each change. Used to reconcile
elements during collaboration or when saving to server. */ elements during collaboration or when saving to server. */
version: number; version: number;
/** Per-track schema state used by migrations during restore. */
schemaState: ElementSchemaState;
/** Random integer that is regenerated on each change. /** Random integer that is regenerated on each change.
Used for deterministic reconciliation of updates during collaboration, Used for deterministic reconciliation of updates during collaboration,
in case the versions (see above) are identical. */ in case the versions (see above) are identical. */
+149 -26
View File
@@ -7,6 +7,7 @@ import {
} from "@excalidraw/common"; } from "@excalidraw/common";
import { import {
bezierEquation,
curve, curve,
curveCatmullRomCubicApproxPoints, curveCatmullRomCubicApproxPoints,
curveOffsetPoints, curveOffsetPoints,
@@ -27,19 +28,30 @@ import {
import type { Curve, LineSegment, LocalPoint } from "@excalidraw/math"; import type { Curve, LineSegment, LocalPoint } from "@excalidraw/math";
import type { NormalizedZoomValue, Zoom } from "@excalidraw/excalidraw/types"; import type {
AppState,
NormalizedZoomValue,
Zoom,
} from "@excalidraw/excalidraw/types";
import { elementCenterPoint, getDiamondPoints } from "./bounds"; import { elementCenterPoint, getDiamondPoints } from "./bounds";
import { generateLinearCollisionShape } from "./shape"; import { generateLinearCollisionShape } from "./shape";
import { isPointInElement } from "./collision"; import { hitElementItself, isPointInElement } from "./collision";
import { LinearElementEditor } from "./linearElementEditor"; import { LinearElementEditor } from "./linearElementEditor";
import { isRectangularElement } from "./typeChecks"; import { isRectangularElement } from "./typeChecks";
import { maxBindingDistance_simple } from "./binding";
import {
getGlobalFixedPointForBindableElement,
normalizeFixedPoint,
} from "./binding";
import type { import type {
ElementsMap, ElementsMap,
ExcalidrawArrowElement, ExcalidrawArrowElement,
ExcalidrawBindableElement,
ExcalidrawDiamondElement, ExcalidrawDiamondElement,
ExcalidrawElement, ExcalidrawElement,
ExcalidrawFreeDrawElement, ExcalidrawFreeDrawElement,
@@ -329,24 +341,10 @@ export function deconstructRectanguloidElement(
return shape; return shape;
} }
/** export function getDiamondBaseCorners(
* Get the **unrotated** building components of a diamond element
* in the form of line segments and curves as a tuple, in this order.
*
* @param element The element to deconstruct
* @param offset An optional offset
* @returns Tuple of line **unrotated** segments (0) and curves (1)
*/
export function deconstructDiamondElement(
element: ExcalidrawDiamondElement, element: ExcalidrawDiamondElement,
offset: number = 0, offset: number = 0,
): [LineSegment<GlobalPoint>[], Curve<GlobalPoint>[]] { ): Curve<GlobalPoint>[] {
const cachedShape = getElementShapesCacheEntry(element, offset);
if (cachedShape) {
return cachedShape;
}
const [topX, topY, rightX, rightY, bottomX, bottomY, leftX, leftY] = const [topX, topY, rightX, rightY, bottomX, bottomY, leftX, leftY] =
getDiamondPoints(element); getDiamondPoints(element);
const verticalRadius = element.roundness const verticalRadius = element.roundness
@@ -363,7 +361,7 @@ export function deconstructDiamondElement(
pointFrom(element.x + leftX, element.y + leftY), pointFrom(element.x + leftX, element.y + leftY),
]; ];
const baseCorners = [ return [
curve( curve(
pointFrom<GlobalPoint>( pointFrom<GlobalPoint>(
right[0] - verticalRadius, right[0] - verticalRadius,
@@ -413,6 +411,27 @@ export function deconstructDiamondElement(
), ),
), // TOP ), // TOP
]; ];
}
/**
* Get the **unrotated** building components of a diamond element
* in the form of line segments and curves as a tuple, in this order.
*
* @param element The element to deconstruct
* @param offset An optional offset
* @returns Tuple of line **unrotated** segments (0) and curves (1)
*/
export function deconstructDiamondElement(
element: ExcalidrawDiamondElement,
offset: number = 0,
): [LineSegment<GlobalPoint>[], Curve<GlobalPoint>[]] {
const cachedShape = getElementShapesCacheEntry(element, offset);
if (cachedShape) {
return cachedShape;
}
const baseCorners = getDiamondBaseCorners(element, offset);
const corners = baseCorners.map( const corners = baseCorners.map(
(corner) => (corner) =>
@@ -570,28 +589,128 @@ const getDiagonalsForBindableElement = (
return [diagonalOne, diagonalTwo]; return [diagonalOne, diagonalTwo];
}; };
export const getSnapOutlineMidPoint = (
point: GlobalPoint,
element: ExcalidrawBindableElement,
elementsMap: ElementsMap,
zoom: AppState["zoom"],
) => {
const center = elementCenterPoint(element, elementsMap);
const sideMidpoints =
element.type === "diamond"
? getDiamondBaseCorners(element).map((curve) => {
const point = bezierEquation(curve, 0.5);
const rotatedPoint = pointRotateRads(point, center, element.angle);
return pointFrom<GlobalPoint>(rotatedPoint[0], rotatedPoint[1]);
})
: [
// RIGHT midpoint
pointRotateRads(
pointFrom<GlobalPoint>(
element.x + element.width,
element.y + element.height / 2,
),
center,
element.angle,
),
// BOTTOM midpoint
pointRotateRads(
pointFrom<GlobalPoint>(
element.x + element.width / 2,
element.y + element.height,
),
center,
element.angle,
),
// LEFT midpoint
pointRotateRads(
pointFrom<GlobalPoint>(element.x, element.y + element.height / 2),
center,
element.angle,
),
// TOP midpoint
pointRotateRads(
pointFrom<GlobalPoint>(element.x + element.width / 2, element.y),
center,
element.angle,
),
];
const candidate = sideMidpoints.find(
(midpoint) =>
pointDistance(point, midpoint) <=
maxBindingDistance_simple(zoom) + element.strokeWidth / 2 &&
!hitElementItself({
point,
element,
threshold: 0,
elementsMap,
overrideShouldTestInside: true,
}),
);
return candidate;
};
export const projectFixedPointOntoDiagonal = ( export const projectFixedPointOntoDiagonal = (
arrow: ExcalidrawArrowElement, arrow: ExcalidrawArrowElement,
point: GlobalPoint, point: GlobalPoint,
element: ExcalidrawElement, element: ExcalidrawBindableElement,
startOrEnd: "start" | "end", startOrEnd: "start" | "end",
elementsMap: ElementsMap, elementsMap: ElementsMap,
zoom: AppState["zoom"],
): GlobalPoint | null => { ): GlobalPoint | null => {
invariant(arrow.points.length >= 2, "Arrow must have at least two points"); invariant(arrow.points.length >= 2, "Arrow must have at least two points");
if (arrow.width < 3 && arrow.height < 3) { if (arrow.width < 3 && arrow.height < 3) {
return null; return null;
} }
const sideMidPoint = getSnapOutlineMidPoint(
point,
element,
elementsMap,
zoom,
);
if (sideMidPoint) {
return sideMidPoint;
}
// Do the projection onto the diagonals (or center lines
// for non-rectangular shapes)
const [diagonalOne, diagonalTwo] = getDiagonalsForBindableElement( const [diagonalOne, diagonalTwo] = getDiagonalsForBindableElement(
element, element,
elementsMap, elementsMap,
); );
const a = LinearElementEditor.getPointAtIndexGlobalCoordinates( // To avoid working with stale arrow state, we use the opposite focus point
// of the current endpoint, which will always be unchanged during moving of
// the endpoint. This is only needed when the arrow has only two points.
let a = LinearElementEditor.getPointAtIndexGlobalCoordinates(
arrow, arrow,
startOrEnd === "start" ? 1 : arrow.points.length - 2, startOrEnd === "start" ? 1 : arrow.points.length - 2,
elementsMap, elementsMap,
); );
if (arrow.points.length === 2) {
const otherBinding =
startOrEnd === "start" ? arrow.endBinding : arrow.startBinding;
const otherBindable =
otherBinding &&
(elementsMap.get(otherBinding.elementId) as
| ExcalidrawBindableElement
| undefined);
const otherFocusPoint =
otherBinding &&
otherBindable &&
getGlobalFixedPointForBindableElement(
normalizeFixedPoint(otherBinding.fixedPoint),
otherBindable,
elementsMap,
);
if (otherFocusPoint) {
a = otherFocusPoint;
}
}
const b = pointFromVector<GlobalPoint>( const b = pointFromVector<GlobalPoint>(
vectorScale( vectorScale(
vectorFromPoint(point, a), vectorFromPoint(point, a),
@@ -603,18 +722,22 @@ export const projectFixedPointOntoDiagonal = (
), ),
a, a,
); );
const intersector = lineSegment<GlobalPoint>(point, b); const intersector = lineSegment<GlobalPoint>(b, a);
const p1 = lineSegmentIntersectionPoints(diagonalOne, intersector); const p1 = lineSegmentIntersectionPoints(diagonalOne, intersector);
const p2 = lineSegmentIntersectionPoints(diagonalTwo, intersector); const p2 = lineSegmentIntersectionPoints(diagonalTwo, intersector);
const d1 = p1 && pointDistance(a, p1); const d1 = p1 && pointDistance(a, p1);
const d2 = p2 && pointDistance(a, p2); const d2 = p2 && pointDistance(a, p2);
let p = null; let projection = null;
if (d1 != null && d2 != null) { if (d1 != null && d2 != null) {
p = d1 < d2 ? p1 : p2; projection = d1 < d2 ? p1 : p2;
} else { } else {
p = p1 || p2 || null; projection = p1 || p2 || null;
} }
return p && isPointInElement(p, element, elementsMap) ? p : null; if (projection && isPointInElement(projection, element, elementsMap)) {
return projection;
}
return null;
}; };
@@ -1,16 +1,24 @@
import { import {
isLineSegment, isLineSegment,
lineSegment, lineSegment,
pointDistanceSq,
pointFrom, pointFrom,
type GlobalPoint, type GlobalPoint,
type LocalPoint, type LocalPoint,
} from "@excalidraw/math"; } from "@excalidraw/math";
import { type Bounds, isBounds } from "@excalidraw/common";
import {
getElementBounds,
intersectElementWithLineSegment,
isFreeDrawElement,
isLinearElement,
isPathALoop,
} from "@excalidraw/element";
import type { ElementsMap, ExcalidrawElement } from "@excalidraw/element/types";
import type { Curve } from "@excalidraw/math"; import type { Curve } from "@excalidraw/math";
import type { LineSegment } from "@excalidraw/utils"; import type { LineSegment } from "@excalidraw/utils";
import { type Bounds, isBounds } from "./bounds";
// The global data holder to collect the debug operations // The global data holder to collect the debug operations
declare global { declare global {
interface Window { interface Window {
@@ -23,10 +31,69 @@ declare global {
export type DebugElement = { export type DebugElement = {
color: string; color: string;
data: LineSegment<GlobalPoint> | Curve<GlobalPoint>; data: LineSegment<GlobalPoint> | Curve<GlobalPoint> | DebugPolygon;
permanent: boolean; permanent: boolean;
}; };
export type DebugPolygon = {
type: "polygon";
points: GlobalPoint[];
fill?: boolean;
close?: boolean;
};
export const debugDrawHitVolume = (
element: ExcalidrawElement,
elementsMap: ElementsMap,
options?: {
rays?: number;
color?: string;
fill?: boolean;
},
) => {
if (
(isLinearElement(element) || isFreeDrawElement(element)) &&
!isPathALoop(element.points)
) {
return;
}
const [x1, y1, x2, y2] = getElementBounds(element, elementsMap);
const center = pointFrom<GlobalPoint>((x1 + x2) / 2, (y1 + y2) / 2);
const rays = options?.rays ?? 100;
const radius = Math.max(x2 - x1, y2 - y1) * 2;
const points: GlobalPoint[] = [];
for (let i = 0; i < rays; i += 1) {
const angle = (i / rays) * Math.PI * 2;
const end = pointFrom<GlobalPoint>(
center[0] + Math.cos(angle) * radius,
center[1] + Math.sin(angle) * radius,
);
const hits = intersectElementWithLineSegment(
element,
elementsMap,
lineSegment(center, end),
);
if (hits.length === 0) {
continue;
}
hits.sort(pointDistanceSq);
points.push(hits[0]);
}
if (points.length >= 3) {
debugDrawPolygon(points, {
color: options?.color ?? "orange",
fill: options?.fill ?? true,
});
} else {
console.warn(
`debugDrawHitVolume: could not compute hit volume for element ${element.id}`,
);
}
};
export const debugDrawCubicBezier = ( export const debugDrawCubicBezier = (
c: Curve<GlobalPoint>, c: Curve<GlobalPoint>,
opts?: { opts?: {
@@ -61,6 +128,31 @@ export const debugDrawLine = (
); );
}; };
export const debugDrawPolygon = (
points: GlobalPoint[],
opts?: {
color?: string;
permanent?: boolean;
fill?: boolean;
close?: boolean;
},
) => {
if (points.length < 2) {
return;
}
addToCurrentFrame({
color: opts?.color ?? "orange",
permanent: !!opts?.permanent,
data: {
type: "polygon",
points,
fill: opts?.fill,
close: opts?.close,
},
});
};
export const debugDrawPoint = ( export const debugDrawPoint = (
p: GlobalPoint, p: GlobalPoint,
opts?: { opts?: {
@@ -101,7 +193,7 @@ export const debugDrawBounds = (
permanent?: boolean; permanent?: boolean;
}, },
) => { ) => {
(isBounds(box) ? [box] : box).forEach((bbox) => (isBounds(box) ? [box] : box).forEach((bbox: Bounds) =>
debugDrawLine( debugDrawLine(
[ [
lineSegment( lineSegment(
+4 -5
View File
@@ -156,12 +156,11 @@ export const moveArrowAboveBindable = (
elements: readonly Ordered<NonDeletedExcalidrawElement>[], elements: readonly Ordered<NonDeletedExcalidrawElement>[],
elementsMap: NonDeletedSceneElementsMap, elementsMap: NonDeletedSceneElementsMap,
scene: Scene, scene: Scene,
hit?: NonDeletedExcalidrawElement,
): readonly OrderedExcalidrawElement[] => { ): readonly OrderedExcalidrawElement[] => {
const hoveredElement = getHoveredElementForBinding( const hoveredElement = hit
point, ? hit
elements, : getHoveredElementForBinding(point, elements, elementsMap);
elementsMap,
);
if (!hoveredElement) { if (!hoveredElement) {
return elements; return elements;
@@ -17,7 +17,7 @@ exports[`Test Linear Elements > Test bound text element > should match styles fo
class="excalidraw-wysiwyg" class="excalidraw-wysiwyg"
data-type="wysiwyg" data-type="wysiwyg"
dir="auto" dir="auto"
style="position: absolute; display: inline-block; min-height: 1em; backface-visibility: hidden; margin: 0px; padding: 0px; border: 0px; outline: 0; resize: none; background: transparent; overflow: hidden; z-index: var(--zIndex-wysiwyg); word-break: break-word; white-space: pre-wrap; overflow-wrap: break-word; box-sizing: content-box; width: 10.5px; height: 26.25px; left: 35px; top: 7.5px; transform: translate(0px, 0px) scale(1) rotate(0deg); text-align: center; vertical-align: middle; color: rgb(30, 30, 30); opacity: 1; filter: var(--theme-filter); max-height: 992.5px; font: Emoji 20px 20px; line-height: 1.25; font-family: Excalifont, Xiaolai, sans-serif, Segoe UI Emoji;" style="position: absolute; display: inline-block; min-height: 1em; backface-visibility: hidden; margin: 0px; padding: 0px; border: 0px; outline: 0; resize: none; background: transparent; overflow: hidden; z-index: var(--zIndex-wysiwyg); word-break: break-word; white-space: pre-wrap; overflow-wrap: break-word; box-sizing: content-box; width: 10.5px; height: 26.25px; left: 35px; top: 7.5px; transform: translate(0px, 0px) scale(1) rotate(0deg); text-align: center; vertical-align: middle; color: rgb(30, 30, 30); opacity: 1; max-height: 992.5px; font: Emoji 20px 20px; line-height: 1.25; font-family: Excalifont, Xiaolai, sans-serif, Segoe UI Emoji;"
tabindex="0" tabindex="0"
wrap="off" wrap="off"
/> />
+229 -2
View File
@@ -1,9 +1,12 @@
import { arrayToMap } from "@excalidraw/common";
import { type GlobalPoint, type LocalPoint, pointFrom } from "@excalidraw/math"; import { type GlobalPoint, type LocalPoint, pointFrom } from "@excalidraw/math";
import { Excalidraw } from "@excalidraw/excalidraw"; import { Excalidraw } from "@excalidraw/excalidraw";
import { API } from "@excalidraw/excalidraw/tests/helpers/api";
import { UI } from "@excalidraw/excalidraw/tests/helpers/ui"; import { UI } from "@excalidraw/excalidraw/tests/helpers/ui";
import "@excalidraw/utils/test-utils"; import "@excalidraw/utils/test-utils";
import { render } from "@excalidraw/excalidraw/tests/test-utils"; import { render } from "@excalidraw/excalidraw/tests/test-utils";
import * as distance from "../src/distance";
import { hitElementItself } from "../src/collision"; import { hitElementItself } from "../src/collision";
describe("check rotated elements can be hit:", () => { describe("check rotated elements can be hit:", () => {
@@ -25,8 +28,6 @@ describe("check rotated elements can be hit:", () => {
[-4, -302], [-4, -302],
] as LocalPoint[], ] as LocalPoint[],
}); });
//const p = [120, -211];
//const p = [0, 13];
const hit = hitElementItself({ const hit = hitElementItself({
point: pointFrom<GlobalPoint>(88, -68), point: pointFrom<GlobalPoint>(88, -68),
element: window.h.elements[0], element: window.h.elements[0],
@@ -36,3 +37,229 @@ describe("check rotated elements can be hit:", () => {
expect(hit).toBe(true); expect(hit).toBe(true);
}); });
}); });
describe("frame hit testing", () => {
it.each(["transparent", "#ffffff"])(
"does not hit frame inside regardless of background color (%s)",
(backgroundColor) => {
const element = API.createElement({
type: "frame",
x: 0,
y: 0,
width: 100,
height: 100,
backgroundColor,
});
const elementsMap = arrayToMap([element]);
expect(
hitElementItself({
point: pointFrom<GlobalPoint>(50, 50),
element,
threshold: 10,
elementsMap,
}),
).toBe(false);
},
);
it("hits frame outline", () => {
const element = API.createElement({
type: "frame",
x: 0,
y: 0,
width: 100,
height: 100,
backgroundColor: "#ffffff",
});
const elementsMap = arrayToMap([element]);
expect(
hitElementItself({
point: pointFrom<GlobalPoint>(0, 50),
element,
threshold: 1,
elementsMap,
}),
).toBe(true);
});
});
describe("hitElementItself cache", () => {
beforeEach(async () => {
// reset cache
hitElementItself({
point: pointFrom<GlobalPoint>(50, 50),
element: API.createElement({
type: "rectangle",
x: 0,
y: 0,
width: 100,
height: 100,
backgroundColor: "#ffffff",
}),
threshold: Infinity,
elementsMap: new Map([]),
});
localStorage.clear();
await render(<Excalidraw handleKeyboardGlobally={true} />);
});
it("reuses cached result when threshold increases", () => {
const element = API.createElement({
type: "rectangle",
x: 0,
y: 0,
width: 100,
height: 100,
backgroundColor: "#ffffff",
});
const elementsMap = arrayToMap([element]);
const point = pointFrom<GlobalPoint>(100.5, 50);
const distanceSpy = jest.spyOn(distance, "distanceToElement");
expect(
hitElementItself({
point,
element,
threshold: 1,
elementsMap,
}),
).toBe(true);
expect(distanceSpy).toHaveBeenCalledTimes(1);
expect(
hitElementItself({
point,
element,
threshold: 10,
elementsMap,
}),
).toBe(true);
expect(distanceSpy).toHaveBeenCalledTimes(1);
distanceSpy.mockRestore();
});
it("does not reuse cache when threshold decreases", () => {
const element = API.createElement({
type: "rectangle",
x: 0,
y: 0,
width: 100,
height: 100,
backgroundColor: "transparent",
});
const elementsMap = arrayToMap([element]);
const point = pointFrom<GlobalPoint>(105, 50);
const distanceSpy = jest.spyOn(distance, "distanceToElement");
expect(
hitElementItself({
point,
element,
threshold: 10,
elementsMap,
}),
).toBe(true);
expect(distanceSpy).toHaveBeenCalledTimes(1);
expect(
hitElementItself({
point,
element,
threshold: 6,
elementsMap,
}),
).toBe(true);
expect(distanceSpy).toHaveBeenCalledTimes(2);
distanceSpy.mockRestore();
});
it("invalidates cache when element version changes", () => {
const element = API.createElement({
type: "rectangle",
x: 0,
y: 0,
width: 100,
height: 100,
backgroundColor: "#ffffff",
});
const elementsMap = arrayToMap([element]);
const point = pointFrom<GlobalPoint>(100.5, 50);
const distanceSpy = jest.spyOn(distance, "distanceToElement");
expect(
hitElementItself({
point,
element,
threshold: 1,
elementsMap,
}),
).toBe(true);
expect(distanceSpy).toHaveBeenCalledTimes(1);
const movedElement = {
...element,
version: element.version + 1,
versionNonce: element.versionNonce + 1,
};
expect(
hitElementItself({
point,
element: movedElement,
threshold: 1,
elementsMap,
}),
).toBe(true);
expect(distanceSpy).toHaveBeenCalledTimes(2);
distanceSpy.mockRestore();
});
it("override does not affect caching", () => {
const element = API.createElement({
type: "rectangle",
x: 0,
y: 0,
width: 100,
height: 100,
backgroundColor: "transparent",
});
const elementsMap = arrayToMap([element]);
const point = pointFrom<GlobalPoint>(50, 50);
const distanceSpy = jest.spyOn(distance, "distanceToElement");
expect(
hitElementItself({
point,
element,
threshold: 10,
elementsMap,
}),
).toBe(false);
expect(distanceSpy).toHaveBeenCalledTimes(1);
expect(
hitElementItself({
point,
element,
threshold: 10,
elementsMap,
overrideShouldTestInside: true,
}),
).toBe(true);
});
});
+81 -1
View File
@@ -1,4 +1,4 @@
import { getEmbedLink } from "../src/embeddable"; import { embeddableURLValidator, getEmbedLink } from "../src/embeddable";
describe("YouTube timestamp parsing", () => { describe("YouTube timestamp parsing", () => {
it("should parse YouTube URLs with timestamp in seconds", () => { it("should parse YouTube URLs with timestamp in seconds", () => {
@@ -151,3 +151,83 @@ describe("YouTube timestamp parsing", () => {
} }
}); });
}); });
describe("Google Drive video embedding", () => {
it.each([
{
url: "https://drive.google.com/file/d/1AbCdEfGhIjKlMnOpQrStUvWxYz123456/view?usp=sharing",
expectedLink:
"https://drive.google.com/file/d/1AbCdEfGhIjKlMnOpQrStUvWxYz123456/preview",
},
{
url: "https://drive.google.com/open?id=1AbCdEfGhIjKlMnOpQrStUvWxYz123456",
expectedLink:
"https://drive.google.com/file/d/1AbCdEfGhIjKlMnOpQrStUvWxYz123456/preview",
},
{
url: "https://drive.google.com/uc?export=download&id=1AbCdEfGhIjKlMnOpQrStUvWxYz123456",
expectedLink:
"https://drive.google.com/file/d/1AbCdEfGhIjKlMnOpQrStUvWxYz123456/preview",
},
])("should normalize Google Drive link: $url", ({ url, expectedLink }) => {
const result = getEmbedLink(url);
expect(result).toBeTruthy();
expect(result?.type).toBe("video");
if (result?.type === "video" || result?.type === "generic") {
expect(result.link).toBe(expectedLink);
}
expect(result?.intrinsicSize).toEqual({ w: 560, h: 315 });
});
it("should preserve resourcekey when available", () => {
const url =
"https://drive.google.com/file/d/1AbCdEfGhIjKlMnOpQrStUvWxYz123456/view?resourcekey=0-abcdef123456";
const result = getEmbedLink(url);
expect(result).toBeTruthy();
expect(result?.type).toBe("video");
if (result?.type === "video" || result?.type === "generic") {
expect(result.link).toBe(
"https://drive.google.com/file/d/1AbCdEfGhIjKlMnOpQrStUvWxYz123456/preview?resourcekey=0-abcdef123456",
);
}
});
it("should preserve timestamp when available", () => {
const url =
"https://drive.google.com/file/d/1AbCdEfGhIjKlMnOpQrStUvWxYz123456/view?t=9";
const result = getEmbedLink(url);
expect(result).toBeTruthy();
expect(result?.type).toBe("video");
if (result?.type === "video" || result?.type === "generic") {
expect(result.link).toBe(
"https://drive.google.com/file/d/1AbCdEfGhIjKlMnOpQrStUvWxYz123456/preview?t=9",
);
}
});
it("should preserve resourcekey and timestamp together", () => {
const url =
"https://drive.google.com/file/d/1AbCdEfGhIjKlMnOpQrStUvWxYz123456/view?resourcekey=0-abcdef123456&t=9";
const result = getEmbedLink(url);
expect(result).toBeTruthy();
expect(result?.type).toBe("video");
if (result?.type === "video" || result?.type === "generic") {
expect(result.link).toBe(
"https://drive.google.com/file/d/1AbCdEfGhIjKlMnOpQrStUvWxYz123456/preview?resourcekey=0-abcdef123456&t=9",
);
}
});
it("should validate Google Drive domain by default", () => {
expect(
embeddableURLValidator(
"https://drive.google.com/file/d/1AbCdEfGhIjKlMnOpQrStUvWxYz123456/view",
undefined,
),
).toBe(true);
});
});
@@ -218,7 +218,7 @@ describe("Test Linear Elements", () => {
// drag line from midpoint // drag line from midpoint
drag(midpoint, pointFrom(midpoint[0] + delta, midpoint[1] + delta)); drag(midpoint, pointFrom(midpoint[0] + delta, midpoint[1] + delta));
expect(renderInteractiveScene.mock.calls.length).toMatchInlineSnapshot(`8`); expect(renderInteractiveScene.mock.calls.length).toMatchInlineSnapshot(`8`);
expect(renderStaticScene.mock.calls.length).toMatchInlineSnapshot(`7`); expect(renderStaticScene.mock.calls.length).toMatchInlineSnapshot(`6`);
expect(line.points.length).toEqual(3); expect(line.points.length).toEqual(3);
expect(line.points).toMatchInlineSnapshot(` expect(line.points).toMatchInlineSnapshot(`
[ [
@@ -378,7 +378,7 @@ describe("Test Linear Elements", () => {
// drag line from midpoint // drag line from midpoint
drag(midpoint, pointFrom(midpoint[0] + delta, midpoint[1] + delta)); drag(midpoint, pointFrom(midpoint[0] + delta, midpoint[1] + delta));
expect(renderInteractiveScene.mock.calls.length).toMatchInlineSnapshot( expect(renderInteractiveScene.mock.calls.length).toMatchInlineSnapshot(
`11`, `12`,
); );
expect(renderStaticScene.mock.calls.length).toMatchInlineSnapshot(`7`); expect(renderStaticScene.mock.calls.length).toMatchInlineSnapshot(`7`);
@@ -419,7 +419,7 @@ describe("Test Linear Elements", () => {
fireEvent.click(screen.getByTitle("Round")); fireEvent.click(screen.getByTitle("Round"));
expect(renderInteractiveScene.mock.calls.length).toMatchInlineSnapshot( expect(renderInteractiveScene.mock.calls.length).toMatchInlineSnapshot(
`9`, `10`,
); );
expect(renderStaticScene.mock.calls.length).toMatchInlineSnapshot(`6`); expect(renderStaticScene.mock.calls.length).toMatchInlineSnapshot(`6`);
@@ -480,7 +480,7 @@ describe("Test Linear Elements", () => {
drag(startPoint, endPoint); drag(startPoint, endPoint);
expect(renderInteractiveScene.mock.calls.length).toMatchInlineSnapshot( expect(renderInteractiveScene.mock.calls.length).toMatchInlineSnapshot(
`11`, `12`,
); );
expect(renderStaticScene.mock.calls.length).toMatchInlineSnapshot(`7`); expect(renderStaticScene.mock.calls.length).toMatchInlineSnapshot(`7`);
@@ -548,7 +548,7 @@ describe("Test Linear Elements", () => {
); );
expect(renderInteractiveScene.mock.calls.length).toMatchInlineSnapshot( expect(renderInteractiveScene.mock.calls.length).toMatchInlineSnapshot(
`14`, `15`,
); );
expect(renderStaticScene.mock.calls.length).toMatchInlineSnapshot(`9`); expect(renderStaticScene.mock.calls.length).toMatchInlineSnapshot(`9`);
@@ -599,7 +599,7 @@ describe("Test Linear Elements", () => {
drag(hitCoords, pointFrom(hitCoords[0] - delta, hitCoords[1] - delta)); drag(hitCoords, pointFrom(hitCoords[0] - delta, hitCoords[1] - delta));
expect(renderInteractiveScene.mock.calls.length).toMatchInlineSnapshot( expect(renderInteractiveScene.mock.calls.length).toMatchInlineSnapshot(
`11`, `12`,
); );
expect(renderStaticScene.mock.calls.length).toMatchInlineSnapshot(`7`); expect(renderStaticScene.mock.calls.length).toMatchInlineSnapshot(`7`);
@@ -640,7 +640,7 @@ describe("Test Linear Elements", () => {
drag(hitCoords, pointFrom(hitCoords[0] + delta, hitCoords[1] + delta)); drag(hitCoords, pointFrom(hitCoords[0] + delta, hitCoords[1] + delta));
expect(renderInteractiveScene.mock.calls.length).toMatchInlineSnapshot( expect(renderInteractiveScene.mock.calls.length).toMatchInlineSnapshot(
`11`, `12`,
); );
expect(renderStaticScene.mock.calls.length).toMatchInlineSnapshot(`7`); expect(renderStaticScene.mock.calls.length).toMatchInlineSnapshot(`7`);
@@ -688,7 +688,7 @@ describe("Test Linear Elements", () => {
deletePoint(points[2]); deletePoint(points[2]);
expect(line.points.length).toEqual(3); expect(line.points.length).toEqual(3);
expect(renderInteractiveScene.mock.calls.length).toMatchInlineSnapshot( expect(renderInteractiveScene.mock.calls.length).toMatchInlineSnapshot(
`17`, `18`,
); );
expect(renderStaticScene.mock.calls.length).toMatchInlineSnapshot(`10`); expect(renderStaticScene.mock.calls.length).toMatchInlineSnapshot(`10`);
@@ -746,7 +746,7 @@ describe("Test Linear Elements", () => {
), ),
); );
expect(renderInteractiveScene.mock.calls.length).toMatchInlineSnapshot( expect(renderInteractiveScene.mock.calls.length).toMatchInlineSnapshot(
`14`, `15`,
); );
expect(renderStaticScene.mock.calls.length).toMatchInlineSnapshot(`9`); expect(renderStaticScene.mock.calls.length).toMatchInlineSnapshot(`9`);
expect(line.points.length).toEqual(5); expect(line.points.length).toEqual(5);
@@ -844,7 +844,7 @@ describe("Test Linear Elements", () => {
drag(hitCoords, pointFrom(hitCoords[0] + delta, hitCoords[1] + delta)); drag(hitCoords, pointFrom(hitCoords[0] + delta, hitCoords[1] + delta));
expect(renderInteractiveScene.mock.calls.length).toMatchInlineSnapshot( expect(renderInteractiveScene.mock.calls.length).toMatchInlineSnapshot(
`11`, `12`,
); );
expect(renderStaticScene.mock.calls.length).toMatchInlineSnapshot(`7`); expect(renderStaticScene.mock.calls.length).toMatchInlineSnapshot(`7`);
@@ -1317,7 +1317,7 @@ describe("Test Linear Elements", () => {
const textElement = h.elements[2] as ExcalidrawTextElementWithContainer; const textElement = h.elements[2] as ExcalidrawTextElementWithContainer;
expect(arrow.endBinding?.elementId).toBe(rect.id); expect(arrow.endBinding?.elementId).toBe(rect.id);
expect(arrow.width).toBeCloseTo(399); expect(arrow.width).toBeCloseTo(404);
expect(rect.x).toBe(400); expect(rect.x).toBe(400);
expect(rect.y).toBe(0); expect(rect.y).toBe(0);
expect( expect(
@@ -1336,7 +1336,7 @@ describe("Test Linear Elements", () => {
mouse.downAt(rect.x, rect.y); mouse.downAt(rect.x, rect.y);
mouse.moveTo(200, 0); mouse.moveTo(200, 0);
mouse.upAt(200, 0); mouse.upAt(200, 0);
expect(arrow.width).toBeCloseTo(199); expect(arrow.width).toBeCloseTo(204);
expect(rect.x).toBe(200); expect(rect.x).toBe(200);
expect(rect.y).toBe(0); expect(rect.y).toBe(0);
expect(handleBindTextResizeSpy).toHaveBeenCalledWith( expect(handleBindTextResizeSpy).toHaveBeenCalledWith(
+2 -2
View File
@@ -1350,8 +1350,8 @@ describe("multiple selection", () => {
expect(boundArrow.x).toBeCloseTo(380 * scaleX); expect(boundArrow.x).toBeCloseTo(380 * scaleX);
expect(boundArrow.y).toBeCloseTo(240 * scaleY); expect(boundArrow.y).toBeCloseTo(240 * scaleY);
expect(boundArrow.points[1][0]).toBeCloseTo(59.7979); expect(boundArrow.points[1][0]).toBeCloseTo(63.40354208105561);
expect(boundArrow.points[1][1]).toBeCloseTo(-79.7305); expect(boundArrow.points[1][1]).toBeCloseTo(-84.53805610807356);
expect(arrowLabelPos.x + arrowLabel.width / 2).toBeCloseTo( expect(arrowLabelPos.x + arrowLabel.width / 2).toBeCloseTo(
boundArrow.x + boundArrow.points[1][0] / 2, boundArrow.x + boundArrow.points[1][0] / 2,
@@ -58,6 +58,7 @@ const distributeSelectedElements = (
app.scene.getNonDeletedElementsMap(), app.scene.getNonDeletedElementsMap(),
distribution, distribution,
appState, appState,
app.scene,
); );
const updatedElementsMap = arrayToMap(updatedElements); const updatedElementsMap = arrayToMap(updatedElements);
+5 -1
View File
@@ -271,7 +271,11 @@ export const actionLoadScene = register({
elements: loadedElements, elements: loadedElements,
appState: loadedAppState, appState: loadedAppState,
files, files,
} = await loadFromJSON(appState, elements); } = await loadFromJSON(
appState,
elements,
app.getSchemaMigrationRegistry(),
);
return { return {
elements: loadedElements, elements: loadedElements,
appState: loadedAppState, appState: loadedAppState,
+11 -5
View File
@@ -82,7 +82,10 @@ export const actionFinalize = register<FormData>({
app.scene, app.scene,
); );
if (isBindingElement(element)) { if (
isBindingElement(element) &&
!appState.selectedLinearElement.segmentMidPointHoveredCoords
) {
const newArrow = !!appState.newElement; const newArrow = !!appState.newElement;
const selectedPointsIndices = const selectedPointsIndices =
@@ -95,19 +98,21 @@ export const actionFinalize = register<FormData>({
map.set(index, { map.set(index, {
point: LinearElementEditor.pointFromAbsoluteCoords( point: LinearElementEditor.pointFromAbsoluteCoords(
element, element,
pointFrom<GlobalPoint>(sceneCoords.x, sceneCoords.y), pointFrom<GlobalPoint>(
sceneCoords.x - linearElementEditor.pointerOffset.x,
sceneCoords.y - linearElementEditor.pointerOffset.y,
),
elementsMap, elementsMap,
), ),
}); });
return map; return map;
}, new Map()) ?? new Map(); }, new Map()) ?? new Map();
bindOrUnbindBindingElement( bindOrUnbindBindingElement(
element, element,
draggedPoints, draggedPoints,
sceneCoords.x, sceneCoords.x - linearElementEditor.pointerOffset.x,
sceneCoords.y, sceneCoords.y - linearElementEditor.pointerOffset.y,
scene, scene,
appState, appState,
{ {
@@ -170,6 +175,7 @@ export const actionFinalize = register<FormData>({
...linearElementEditor.initialState, ...linearElementEditor.initialState,
lastClickedPoint: -1, lastClickedPoint: -1,
}, },
pointerOffset: { x: 0, y: 0 },
}, },
selectionElement: null, selectionElement: null,
suggestedBinding: null, suggestedBinding: null,
@@ -6,11 +6,23 @@ import {
FONT_FAMILY, FONT_FAMILY,
STROKE_WIDTH, STROKE_WIDTH,
} from "@excalidraw/common"; } from "@excalidraw/common";
import {
CORE_FRAME_SCHEMA_TRACK,
CORE_SUPPORTED_TRACKS,
} from "@excalidraw/element";
import { Excalidraw } from "../index"; import { Excalidraw } from "../index";
import { API } from "../tests/helpers/api"; import { API } from "../tests/helpers/api";
import { UI } from "../tests/helpers/ui"; import { UI } from "../tests/helpers/ui";
import { render } from "../tests/test-utils"; import { act, render } from "../tests/test-utils";
import {
actionChangeBackgroundColor,
actionChangeRoundness,
actionChangeStrokeWidth,
} from "./actionProperties";
const { h } = window;
describe("element locking", () => { describe("element locking", () => {
beforeEach(async () => { beforeEach(async () => {
@@ -109,6 +121,21 @@ describe("element locking", () => {
expect(crossHatchButton).toBe(null); expect(crossHatchButton).toBe(null);
}); });
it("should show background color picker for selected frame", () => {
const frame = API.createElement({
type: "frame",
});
API.setElements([frame]);
API.setSelectedElements([frame]);
expect(
queryByTestId(
document.body,
`color-top-pick-${DEFAULT_ELEMENT_BACKGROUND_PICKS[0]}`,
),
).not.toBe(null);
});
it("should highlight common stroke width of selected elements", () => { it("should highlight common stroke width of selected elements", () => {
const rect1 = API.createElement({ const rect1 = API.createElement({
type: "rectangle", type: "rectangle",
@@ -169,5 +196,77 @@ describe("element locking", () => {
"active", "active",
); );
}); });
it("should not update text background when changing background in mixed frame selection", () => {
const frame = API.createElement({
type: "frame",
schemaState: { tracks: {} },
});
const text = API.createElement({
type: "text",
backgroundColor: COLOR_PALETTE.transparent,
});
API.setElements([text, frame]);
API.setSelectedElements([text, frame]);
act(() => {
h.app.actionManager.executeAction(actionChangeBackgroundColor, "ui", {
viewBackgroundColor: h.state.viewBackgroundColor,
currentItemBackgroundColor: "#ffc9c9",
});
});
expect(API.getElement(frame).backgroundColor).toBe("#ffc9c9");
expect(API.getElement(text).backgroundColor).toBe(
COLOR_PALETTE.transparent,
);
expect(
API.getElement(frame).schemaState.tracks[CORE_FRAME_SCHEMA_TRACK],
).toBe(CORE_SUPPORTED_TRACKS[CORE_FRAME_SCHEMA_TRACK]);
});
it("should not update frame stroke width when changing stroke width in mixed selection", () => {
const frame = API.createElement({
type: "frame",
});
const rect = API.createElement({
type: "rectangle",
strokeWidth: STROKE_WIDTH.thin,
});
API.setElements([rect, frame]);
API.setSelectedElements([rect, frame]);
const originalFrameStrokeWidth = API.getElement(frame).strokeWidth;
act(() => {
h.app.actionManager.executeAction(
actionChangeStrokeWidth,
"ui",
STROKE_WIDTH.extraBold,
);
});
expect(API.getElement(rect).strokeWidth).toBe(STROKE_WIDTH.extraBold);
expect(API.getElement(frame).strokeWidth).toBe(originalFrameStrokeWidth);
});
it("should not update frame roundness when changing roundness in mixed selection", () => {
const frame = API.createElement({
type: "frame",
});
const rect = API.createElement({
type: "rectangle",
roundness: null,
});
API.setElements([rect, frame]);
API.setSelectedElements([rect, frame]);
act(() => {
h.app.actionManager.executeAction(actionChangeRoundness, "ui", "round");
});
expect(API.getElement(rect).roundness).not.toBe(null);
expect(API.getElement(frame).roundness).toBe(null);
});
}); });
}); });
@@ -45,6 +45,7 @@ import {
import { import {
isArrowElement, isArrowElement,
isBoundToContainer, isBoundToContainer,
isFrameElement,
isElbowArrow, isElbowArrow,
isLinearElement, isLinearElement,
isLineElement, isLineElement,
@@ -52,7 +53,13 @@ import {
isUsingAdaptiveRadius, isUsingAdaptiveRadius,
} from "@excalidraw/element"; } from "@excalidraw/element";
import { hasStrokeColor } from "@excalidraw/element"; import {
canChangeRoundness,
hasBackground,
hasStrokeColor,
hasStrokeStyle,
hasStrokeWidth,
} from "@excalidraw/element";
import { import {
updateElbowArrowPoints, updateElbowArrowPoints,
@@ -409,11 +416,18 @@ export const actionChangeBackgroundColor = register<
return el; return el;
}); });
} else { } else {
nextElements = changeProperty(elements, appState, (el) => nextElements = changeProperty(elements, appState, (el) => {
newElementWith(el, { if (isFrameElement(el)) {
backgroundColor: value.currentItemBackgroundColor, return newElementWith(el, {
}), backgroundColor: value.currentItemBackgroundColor,
); });
}
return hasBackground(el.type)
? newElementWith(el, {
backgroundColor: value.currentItemBackgroundColor,
})
: el;
});
} }
return { return {
@@ -444,7 +458,12 @@ export const actionChangeBackgroundColor = register<
(element) => element.backgroundColor, (element) => element.backgroundColor,
true, true,
(hasSelection) => (hasSelection) =>
!hasSelection ? appState.currentItemBackgroundColor : null, !hasSelection
? appState.activeTool.type === "frame"
? // background default shouldn't apply to new frames
"transparent"
: appState.currentItemBackgroundColor
: null,
)} )}
onChange={(color) => onChange={(color) =>
updateData({ currentItemBackgroundColor: color }) updateData({ currentItemBackgroundColor: color })
@@ -471,11 +490,13 @@ export const actionChangeFillStyle = register<ExcalidrawElement["fillStyle"]>({
})`, })`,
); );
return { return {
elements: changeProperty(elements, appState, (el) => elements: changeProperty(elements, appState, (el) => {
newElementWith(el, { return hasBackground(el.type)
fillStyle: value, ? newElementWith(el, {
}), fillStyle: value,
), })
: el;
}),
appState: { ...appState, currentItemFillStyle: value }, appState: { ...appState, currentItemFillStyle: value },
captureUpdate: CaptureUpdateAction.IMMEDIATELY, captureUpdate: CaptureUpdateAction.IMMEDIATELY,
}; };
@@ -548,11 +569,13 @@ export const actionChangeStrokeWidth = register<
trackEvent: false, trackEvent: false,
perform: (elements, appState, value) => { perform: (elements, appState, value) => {
return { return {
elements: changeProperty(elements, appState, (el) => elements: changeProperty(elements, appState, (el) => {
newElementWith(el, { return hasStrokeWidth(el.type)
strokeWidth: value, ? newElementWith(el, {
}), strokeWidth: value,
), })
: el;
}),
appState: { ...appState, currentItemStrokeWidth: value }, appState: { ...appState, currentItemStrokeWidth: value },
captureUpdate: CaptureUpdateAction.IMMEDIATELY, captureUpdate: CaptureUpdateAction.IMMEDIATELY,
}; };
@@ -604,12 +627,14 @@ export const actionChangeSloppiness = register<ExcalidrawElement["roughness"]>({
trackEvent: false, trackEvent: false,
perform: (elements, appState, value) => { perform: (elements, appState, value) => {
return { return {
elements: changeProperty(elements, appState, (el) => elements: changeProperty(elements, appState, (el) => {
newElementWith(el, { return hasStrokeStyle(el.type)
seed: randomInteger(), ? newElementWith(el, {
roughness: value, seed: randomInteger(),
}), roughness: value,
), })
: el;
}),
appState: { ...appState, currentItemRoughness: value }, appState: { ...appState, currentItemRoughness: value },
captureUpdate: CaptureUpdateAction.IMMEDIATELY, captureUpdate: CaptureUpdateAction.IMMEDIATELY,
}; };
@@ -660,11 +685,13 @@ export const actionChangeStrokeStyle = register<
trackEvent: false, trackEvent: false,
perform: (elements, appState, value) => { perform: (elements, appState, value) => {
return { return {
elements: changeProperty(elements, appState, (el) => elements: changeProperty(elements, appState, (el) => {
newElementWith(el, { return hasStrokeStyle(el.type)
strokeStyle: value, ? newElementWith(el, {
}), strokeStyle: value,
), })
: el;
}),
appState: { ...appState, currentItemStrokeStyle: value }, appState: { ...appState, currentItemStrokeStyle: value },
captureUpdate: CaptureUpdateAction.IMMEDIATELY, captureUpdate: CaptureUpdateAction.IMMEDIATELY,
}; };
@@ -1476,7 +1503,7 @@ export const actionChangeRoundness = register<"sharp" | "round">({
perform: (elements, appState, value) => { perform: (elements, appState, value) => {
return { return {
elements: changeProperty(elements, appState, (el) => { elements: changeProperty(elements, appState, (el) => {
if (isElbowArrow(el)) { if (isElbowArrow(el) || !canChangeRoundness(el.type)) {
return el; return el;
} }
@@ -1555,7 +1582,7 @@ const getArrowheadOptions = (flip: boolean) => {
value: null, value: null,
text: t("labels.arrowhead_none"), text: t("labels.arrowhead_none"),
keyBinding: "q", keyBinding: "q",
icon: ArrowheadNoneIcon, icon: <ArrowheadNoneIcon flip={flip} />,
}, },
{ {
value: "arrow", value: "arrow",
@@ -1683,7 +1710,8 @@ export const actionChangeArrowhead = register<{
? element.startArrowhead ? element.startArrowhead
: appState.currentItemStartArrowhead, : appState.currentItemStartArrowhead,
true, true,
appState.currentItemStartArrowhead, (hasSelection) =>
hasSelection ? null : appState.currentItemStartArrowhead,
)} )}
onChange={(value) => updateData({ position: "start", type: value })} onChange={(value) => updateData({ position: "start", type: value })}
numberOfOptionsToAlwaysShow={4} numberOfOptionsToAlwaysShow={4}
@@ -1700,7 +1728,8 @@ export const actionChangeArrowhead = register<{
? element.endArrowhead ? element.endArrowhead
: appState.currentItemEndArrowhead, : appState.currentItemEndArrowhead,
true, true,
appState.currentItemEndArrowhead, (hasSelection) =>
hasSelection ? null : appState.currentItemEndArrowhead,
)} )}
onChange={(value) => updateData({ position: "end", type: value })} onChange={(value) => updateData({ position: "end", type: value })}
numberOfOptionsToAlwaysShow={4} numberOfOptionsToAlwaysShow={4}
+3 -1
View File
@@ -55,7 +55,8 @@ export type ShortcutName =
| "saveScene" | "saveScene"
| "imageExport" | "imageExport"
| "commandPalette" | "commandPalette"
| "searchMenu"; | "searchMenu"
| "toolLock";
const shortcutMap: Record<ShortcutName, string[]> = { const shortcutMap: Record<ShortcutName, string[]> = {
toggleTheme: [getShortcutKey("Shift+Alt+D")], toggleTheme: [getShortcutKey("Shift+Alt+D")],
@@ -117,6 +118,7 @@ const shortcutMap: Record<ShortcutName, string[]> = {
toggleShortcuts: [getShortcutKey("?")], toggleShortcuts: [getShortcutKey("?")],
searchMenu: [getShortcutKey("CtrlOrCmd+F")], searchMenu: [getShortcutKey("CtrlOrCmd+F")],
wrapSelectionInFrame: [], wrapSelectionInFrame: [],
toolLock: [getShortcutKey("Q")],
}; };
export const getShortcutFromShortcutName = (name: ShortcutName, idx = 0) => { export const getShortcutFromShortcutName = (name: ShortcutName, idx = 0) => {
+71 -3
View File
@@ -54,7 +54,13 @@ describe("parseClipboard()", () => {
}), }),
), ),
); );
expect(clipboardData.elements).toEqual([rect]); expect(clipboardData.elements).toEqual([
expect.objectContaining({
id: rect.id,
type: rect.type,
schemaState: rect.schemaState,
}),
]);
}); });
it("should parse valid excalidraw JSON if inside text/html", async () => { it("should parse valid excalidraw JSON if inside text/html", async () => {
@@ -73,7 +79,13 @@ describe("parseClipboard()", () => {
}), }),
), ),
); );
expect(clipboardData.elements).toEqual([rect]); expect(clipboardData.elements).toEqual([
expect.objectContaining({
id: rect.id,
type: rect.type,
schemaState: rect.schemaState,
}),
]);
// ------------------------------------------------------------------------- // -------------------------------------------------------------------------
json = serializeAsClipboardJSON({ elements: [rect], files: null }); json = serializeAsClipboardJSON({ elements: [rect], files: null });
clipboardData = await parseClipboard( clipboardData = await parseClipboard(
@@ -85,10 +97,66 @@ describe("parseClipboard()", () => {
}), }),
), ),
); );
expect(clipboardData.elements).toEqual([rect]); expect(clipboardData.elements).toEqual([
expect.objectContaining({
id: rect.id,
type: rect.type,
schemaState: rect.schemaState,
}),
]);
// ------------------------------------------------------------------------- // -------------------------------------------------------------------------
}); });
it("should preserve per-element schema on clipboard payload", async () => {
const rect = API.createElement({ type: "rectangle" });
const clipboardPayload = JSON.parse(
serializeAsClipboardJSON({ elements: [rect], files: null }),
);
const clipboardData = await parseClipboard(
await parseDataTransferEvent(
createPasteEvent({
types: {
"text/plain": JSON.stringify(clipboardPayload),
},
}),
),
);
expect(clipboardData.elements?.[0]).toEqual(
expect.objectContaining({
id: rect.id,
schemaState: rect.schemaState,
}),
);
});
it("should not upcast legacy elements to latest schema on clipboard serialize", async () => {
const rect = API.createElement({ type: "rectangle" });
const legacyRect = { ...(rect as any) };
delete legacyRect.schemaState;
const clipboardPayload = JSON.parse(
serializeAsClipboardJSON({
elements: [legacyRect as typeof rect],
files: null,
}),
);
expect(clipboardPayload.elements[0]).not.toHaveProperty("schemaState");
const clipboardData = await parseClipboard(
await parseDataTransferEvent(
createPasteEvent({
types: {
"text/plain": JSON.stringify(clipboardPayload),
},
}),
),
);
expect(clipboardData.elements?.[0]).not.toHaveProperty("schemaState");
});
it("should parse <image> `src` urls out of text/html", async () => { it("should parse <image> `src` urls out of text/html", async () => {
let clipboardData; let clipboardData;
// ------------------------------------------------------------------------- // -------------------------------------------------------------------------
+73 -26
View File
@@ -79,7 +79,10 @@ export const probablySupportsClipboardBlob =
const clipboardContainsElements = ( const clipboardContainsElements = (
contents: any, contents: any,
): contents is { elements: ExcalidrawElement[]; files?: BinaryFiles } => { ): contents is {
elements: ExcalidrawElement[];
files?: BinaryFiles;
} => {
if ( if (
[ [
EXPORT_DATA_TYPES.excalidraw, EXPORT_DATA_TYPES.excalidraw,
@@ -204,8 +207,13 @@ export const copyToClipboard = async (
/** supply if available to make the operation more certain to succeed */ /** supply if available to make the operation more certain to succeed */
clipboardEvent?: ClipboardEvent | null, clipboardEvent?: ClipboardEvent | null,
) => { ) => {
const json = serializeAsClipboardJSON({ elements, files });
await copyTextToSystemClipboard( await copyTextToSystemClipboard(
serializeAsClipboardJSON({ elements, files }), {
[MIME_TYPES.excalidrawClipboard]: json,
[MIME_TYPES.text]: json,
},
clipboardEvent, clipboardEvent,
); );
}; };
@@ -401,7 +409,7 @@ export type ParsedDataTransferFile = Extract<
{ kind: "file" } { kind: "file" }
>; >;
type ParsedDataTranferList = ParsedDataTransferItem[] & { export type ParsedDataTranferList = ParsedDataTransferItem[] & {
/** /**
* Only allows filtering by known `string` data types, since `file` * Only allows filtering by known `string` data types, since `file`
* types can have multiple items of the same type (e.g. multiple image files) * types can have multiple items of the same type (e.g. multiple image files)
@@ -452,6 +460,29 @@ const getDataTransferFiles = function (
); );
}; };
/** @returns list of MIME types, synchronously */
export const parseDataTransferEventMimeTypes = (
event: ClipboardEvent | DragEvent | React.DragEvent<HTMLDivElement>,
): Set<string> => {
let items: DataTransferItemList | undefined = undefined;
if (isClipboardEvent(event)) {
items = event.clipboardData?.items;
} else {
items = event.dataTransfer?.items;
}
const types: Set<string> = new Set();
for (const item of Array.from(items || [])) {
if (!types.has(item.type)) {
types.add(item.type);
}
}
return types;
};
export const parseDataTransferEvent = async ( export const parseDataTransferEvent = async (
event: ClipboardEvent | DragEvent | React.DragEvent<HTMLDivElement>, event: ClipboardEvent | DragEvent | React.DragEvent<HTMLDivElement>,
): Promise<ParsedDataTranferList> => { ): Promise<ParsedDataTranferList> => {
@@ -460,8 +491,7 @@ export const parseDataTransferEvent = async (
if (isClipboardEvent(event)) { if (isClipboardEvent(event)) {
items = event.clipboardData?.items; items = event.clipboardData?.items;
} else { } else {
const dragEvent = event; items = event.dataTransfer?.items;
items = dragEvent.dataTransfer?.items;
} }
const dataItems = ( const dataItems = (
@@ -567,7 +597,7 @@ export const copyBlobToClipboardAsPng = async (blob: Blob | Promise<Blob>) => {
// ClipboardItem constructor, but throws on an unrelated MIME type error. // ClipboardItem constructor, but throws on an unrelated MIME type error.
// So we need to await this and fallback to awaiting the blob if applicable. // So we need to await this and fallback to awaiting the blob if applicable.
await navigator.clipboard.write([ await navigator.clipboard.write([
new window.ClipboardItem({ new ClipboardItem({
[MIME_TYPES.png]: blob, [MIME_TYPES.png]: blob,
}), }),
]); ]);
@@ -576,7 +606,7 @@ export const copyBlobToClipboardAsPng = async (blob: Blob | Promise<Blob>) => {
// with resolution value instead // with resolution value instead
if (isPromiseLike(blob)) { if (isPromiseLike(blob)) {
await navigator.clipboard.write([ await navigator.clipboard.write([
new window.ClipboardItem({ new ClipboardItem({
[MIME_TYPES.png]: await blob, [MIME_TYPES.png]: await blob,
}), }),
]); ]);
@@ -586,28 +616,27 @@ export const copyBlobToClipboardAsPng = async (blob: Blob | Promise<Blob>) => {
} }
}; };
export const copyTextToSystemClipboard = async ( export const copyTextToSystemClipboard = async <
text: string | null, MimeType extends ValueOf<typeof STRING_MIME_TYPES>,
>(
text: string | { [K in MimeType]: string } | null,
clipboardEvent?: ClipboardEvent | null, clipboardEvent?: ClipboardEvent | null,
) => { ) => {
// (1) first try using Async Clipboard API text = text || "";
if (probablySupportsClipboardWriteText) {
try {
// NOTE: doesn't work on FF on non-HTTPS domains, or when document
// not focused
await navigator.clipboard.writeText(text || "");
return;
} catch (error: any) {
console.error(error);
}
}
// (2) if fails and we have access to ClipboardEvent, use plain old setData() const entries = Object.entries(
typeof text === "string" ? { [MIME_TYPES.text]: text } : text,
);
// (1) if we have clipboardEvent, try using it first as it's the most
// versatile
try { try {
if (clipboardEvent) { if (clipboardEvent) {
clipboardEvent.clipboardData?.setData(MIME_TYPES.text, text || ""); for (const [mimeType, value] of entries) {
if (clipboardEvent.clipboardData?.getData(MIME_TYPES.text) !== text) { clipboardEvent.clipboardData?.setData(mimeType, value);
throw new Error("Failed to setData on clipboardEvent"); if (clipboardEvent.clipboardData?.getData(mimeType) !== value) {
throw new Error("Failed to setData on clipboardEvent");
}
} }
return; return;
} }
@@ -615,8 +644,26 @@ export const copyTextToSystemClipboard = async (
console.error(error); console.error(error);
} }
// (3) if that fails, use document.execCommand const plainTextEntry = entries.find(
if (!copyTextViaExecCommand(text)) { ([mimeType]) => mimeType === MIME_TYPES.text,
);
// (2) if we don't have access to clipboardEvent, or that fails,
// at least try setting text/plain via navigator.clipboard.writeText
// (navigator.clipboard.write doesn't work with non-standard mime types)
if (probablySupportsClipboardWriteText && plainTextEntry) {
try {
// NOTE: doesn't work on FF on non-HTTPS domains, or when document
// not focused
await navigator.clipboard.writeText(plainTextEntry[1]);
return;
} catch (error: any) {
console.error(error);
}
}
// (3) if previous fails, use document.execCommand
if (plainTextEntry && !copyTextViaExecCommand(plainTextEntry[1])) {
throw new Error("Error copying to clipboard."); throw new Error("Error copying to clipboard.");
} }
}; };
+7 -3
View File
@@ -1,6 +1,6 @@
import clsx from "clsx"; import clsx from "clsx";
import { useRef, useState } from "react"; import { useRef, useState } from "react";
import * as Popover from "@radix-ui/react-popover"; import { Popover } from "radix-ui";
import { import {
CLASSES, CLASSES,
@@ -20,6 +20,7 @@ import {
isArrowElement, isArrowElement,
hasStrokeColor, hasStrokeColor,
toolIsArrow, toolIsArrow,
isFrameElement,
} from "@excalidraw/element"; } from "@excalidraw/element";
import type { import type {
@@ -129,8 +130,11 @@ export const canChangeBackgroundColor = (
targetElements: ExcalidrawElement[], targetElements: ExcalidrawElement[],
) => { ) => {
return ( return (
// frame tool shouldn't allow to set background until frame is created
hasBackground(appState.activeTool.type) || hasBackground(appState.activeTool.type) ||
targetElements.some((element) => hasBackground(element.type)) targetElements.some(
(element) => hasBackground(element.type) || isFrameElement(element),
)
); );
}; };
@@ -1263,9 +1267,9 @@ export const ShapesSwitcher = ({
onSelect={() => app.onMagicframeToolSelect()} onSelect={() => app.onMagicframeToolSelect()}
icon={MagicIcon} icon={MagicIcon}
data-testid="toolbar-magicframe" data-testid="toolbar-magicframe"
badge={<DropdownMenu.Item.Badge>AI</DropdownMenu.Item.Badge>}
> >
{t("toolBar.magicframe")} {t("toolBar.magicframe")}
<DropdownMenu.Item.Badge>AI</DropdownMenu.Item.Badge>
</DropdownMenu.Item> </DropdownMenu.Item>
)} )}
</DropdownMenu.Content> </DropdownMenu.Content>
+396 -154
View File
@@ -11,7 +11,6 @@ import {
pointDistance, pointDistance,
vector, vector,
pointRotateRads, pointRotateRads,
vectorScale,
vectorFromPoint, vectorFromPoint,
vectorSubtract, vectorSubtract,
vectorDot, vectorDot,
@@ -47,7 +46,6 @@ import {
TAP_TWICE_TIMEOUT, TAP_TWICE_TIMEOUT,
TEXT_TO_CENTER_SNAP_THRESHOLD, TEXT_TO_CENTER_SNAP_THRESHOLD,
THEME, THEME,
THEME_FILTER,
TOUCH_CTX_MENU_TIMEOUT, TOUCH_CTX_MENU_TIMEOUT,
VERTICAL_ALIGN, VERTICAL_ALIGN,
YOUTUBE_STATES, YOUTUBE_STATES,
@@ -89,6 +87,7 @@ import {
getDateTime, getDateTime,
isShallowEqual, isShallowEqual,
arrayToMap, arrayToMap,
applyDarkModeFilter,
type EXPORT_IMAGE_TYPES, type EXPORT_IMAGE_TYPES,
randomInteger, randomInteger,
CLASSES, CLASSES,
@@ -250,6 +249,14 @@ import {
maxBindingDistance_simple, maxBindingDistance_simple,
convertToExcalidrawElements, convertToExcalidrawElements,
type ExcalidrawElementSkeleton, type ExcalidrawElementSkeleton,
getSnapOutlineMidPoint,
handleFocusPointDrag,
handleFocusPointHover,
handleFocusPointPointerDown,
handleFocusPointPointerUp,
maybeHandleArrowPointlikeDrag,
getUncroppedWidthAndHeight,
isFrameElement,
} from "@excalidraw/element"; } from "@excalidraw/element";
import type { GlobalPoint, LocalPoint, Radians } from "@excalidraw/math"; import type { GlobalPoint, LocalPoint, Radians } from "@excalidraw/math";
@@ -346,7 +353,8 @@ import {
import { exportCanvas, loadFromBlob } from "../data"; import { exportCanvas, loadFromBlob } from "../data";
import Library, { distributeLibraryItemsOnSquareGrid } from "../data/library"; import Library, { distributeLibraryItemsOnSquareGrid } from "../data/library";
import { restore, restoreElements } from "../data/restore"; import { restoreAppState, restoreElements } from "../data/restore";
import { createSchemaMigrationRegistry } from "../data/schema";
import { getCenter, getDistance } from "../gesture"; import { getCenter, getDistance } from "../gesture";
import { History } from "../history"; import { History } from "../history";
import { defaultLang, getLanguage, languages, setLanguage, t } from "../i18n"; import { defaultLang, getLanguage, languages, setLanguage, t } from "../i18n";
@@ -436,10 +444,7 @@ import { searchItemInFocusAtom } from "./SearchMenu";
import { isSidebarDockedAtom } from "./Sidebar/Sidebar"; import { isSidebarDockedAtom } from "./Sidebar/Sidebar";
import { StaticCanvas, InteractiveCanvas } from "./canvases"; import { StaticCanvas, InteractiveCanvas } from "./canvases";
import NewElementCanvas from "./canvases/NewElementCanvas"; import NewElementCanvas from "./canvases/NewElementCanvas";
import { import { isPointHittingLink } from "./hyperlink/helpers";
isPointHittingLink,
isPointHittingLinkIcon,
} from "./hyperlink/helpers";
import { MagicIcon, copyIcon, fullscreenIcon } from "./icons"; import { MagicIcon, copyIcon, fullscreenIcon } from "./icons";
import { Toast } from "./Toast"; import { Toast } from "./Toast";
@@ -456,6 +461,7 @@ import type {
import type { ClipboardData, PastedMixedContent } from "../clipboard"; import type { ClipboardData, PastedMixedContent } from "../clipboard";
import type { ExportedElements } from "../data"; import type { ExportedElements } from "../data";
import type { SchemaMigrationRegistry } from "../data/schema";
import type { ContextMenuItems } from "./ContextMenu"; import type { ContextMenuItems } from "./ContextMenu";
import type { FileSystemHandle } from "../data/filesystem"; import type { FileSystemHandle } from "../data/filesystem";
@@ -602,6 +608,7 @@ class App extends React.Component<AppProps, AppState> {
public fonts: Fonts; public fonts: Fonts;
public renderer: Renderer; public renderer: Renderer;
public visibleElements: readonly NonDeletedExcalidrawElement[]; public visibleElements: readonly NonDeletedExcalidrawElement[];
private schemaMigrationRegistry: SchemaMigrationRegistry;
private resizeObserver: ResizeObserver | undefined; private resizeObserver: ResizeObserver | undefined;
public library: AppClassProperties["library"]; public library: AppClassProperties["library"];
public libraryItemsFromStorage: LibraryItems | undefined; public libraryItemsFromStorage: LibraryItems | undefined;
@@ -643,7 +650,10 @@ class App extends React.Component<AppProps, AppState> {
lastPointerUpEvent: React.PointerEvent<HTMLElement> | PointerEvent | null = lastPointerUpEvent: React.PointerEvent<HTMLElement> | PointerEvent | null =
null; null;
lastPointerMoveEvent: PointerEvent | null = null; lastPointerMoveEvent: PointerEvent | null = null;
/** current frame pointer cords */
lastPointerMoveCoords: { x: number; y: number } | null = null; lastPointerMoveCoords: { x: number; y: number } | null = null;
/** previous frame pointer coords */
previousPointerMoveCoords: { x: number; y: number } | null = null;
lastViewportPosition = { x: 0, y: 0 }; lastViewportPosition = { x: 0, y: 0 };
animationFrameHandler = new AnimationFrameHandler(); animationFrameHandler = new AnimationFrameHandler();
@@ -715,6 +725,9 @@ class App extends React.Component<AppProps, AppState> {
this.stylesPanelMode = deriveStylesPanelMode(this.editorInterface); this.stylesPanelMode = deriveStylesPanelMode(this.editorInterface);
this.id = nanoid(); this.id = nanoid();
this.schemaMigrationRegistry = createSchemaMigrationRegistry(
props.schemaPlugins,
);
this.library = new Library(this); this.library = new Library(this);
this.actionManager = new ActionManager( this.actionManager = new ActionManager(
this.syncActionResult, this.syncActionResult,
@@ -761,6 +774,7 @@ class App extends React.Component<AppProps, AppState> {
setCursor: this.setCursor, setCursor: this.setCursor,
resetCursor: this.resetCursor, resetCursor: this.resetCursor,
getEditorInterface: () => this.editorInterface, getEditorInterface: () => this.editorInterface,
getSchemaMigrationRegistry: this.getSchemaMigrationRegistry,
updateFrameRendering: this.updateFrameRendering, updateFrameRendering: this.updateFrameRendering,
toggleSidebar: this.toggleSidebar, toggleSidebar: this.toggleSidebar,
onChange: (cb) => this.onChangeEmitter.on(cb), onChange: (cb) => this.onChangeEmitter.on(cb),
@@ -1201,12 +1215,99 @@ class App extends React.Component<AppProps, AppState> {
return this.iFrameRefs.get(element.id); return this.iFrameRefs.get(element.id);
} }
private handleEmbeddableCenterClick(element: ExcalidrawIframeLikeElement) { private handleIframeLikeElementHover = ({
hitElement,
scenePointer,
moveEvent,
}: {
hitElement: NonDeleted<ExcalidrawElement> | null;
scenePointer: { x: number; y: number };
moveEvent: React.PointerEvent<HTMLCanvasElement>;
}): boolean => {
if ( if (
this.state.activeEmbeddable?.element === element && hitElement &&
isIframeLikeElement(hitElement) &&
this.isIframeLikeElementCenter(
hitElement,
moveEvent,
scenePointer.x,
scenePointer.y,
)
) {
setCursor(this.interactiveCanvas, CURSOR_TYPE.POINTER);
this.setState({
activeEmbeddable: { element: hitElement, state: "hover" },
});
return true;
} else if (this.state.activeEmbeddable?.state === "hover") {
this.setState({ activeEmbeddable: null });
}
return false;
};
/** @returns true if iframe-like element click handled */
private handleIframeLikeCenterClick(): boolean {
if (!this.lastPointerDownEvent || !this.lastPointerUpEvent) {
return false;
}
const scenePointerStart = viewportCoordsToSceneCoords(
{
clientX: this.lastPointerDownEvent.clientX,
clientY: this.lastPointerDownEvent.clientY,
},
this.state,
);
const scenePointerEnd = viewportCoordsToSceneCoords(
{
clientX: this.lastPointerUpEvent.clientX,
clientY: this.lastPointerUpEvent.clientY,
},
this.state,
);
const hitElementStart = this.getElementAtPosition(
scenePointerStart.x,
scenePointerStart.y,
);
const hitElementEnd = this.getElementAtPosition(
scenePointerEnd.x,
scenePointerEnd.y,
);
if (
!hitElementStart ||
!hitElementEnd ||
hitElementStart !== hitElementEnd ||
this.lastPointerUpEvent.timeStamp - this.lastPointerDownEvent.timeStamp >
300 ||
gesture.pointers.size > 1 ||
!isIframeLikeElement(hitElementStart) ||
!isIframeLikeElement(hitElementEnd) ||
!this.isIframeLikeElementCenter(
hitElementStart,
this.lastPointerUpEvent,
scenePointerStart.x,
scenePointerStart.y,
) ||
!this.isIframeLikeElementCenter(
hitElementEnd,
this.lastPointerUpEvent,
scenePointerEnd.x,
scenePointerEnd.y,
)
) {
return false;
}
const iframeLikeElement = hitElementEnd;
if (
this.state.activeEmbeddable?.element === iframeLikeElement &&
this.state.activeEmbeddable?.state === "active" this.state.activeEmbeddable?.state === "active"
) { ) {
return; return true;
} }
// The delay serves two purposes // The delay serves two purposes
@@ -1217,31 +1318,34 @@ class App extends React.Component<AppProps, AppState> {
// in fullscreen mode // in fullscreen mode
setTimeout(() => { setTimeout(() => {
this.setState({ this.setState({
activeEmbeddable: { element, state: "active" }, activeEmbeddable: { element: iframeLikeElement, state: "active" },
selectedElementIds: { [element.id]: true }, selectedElementIds: { [iframeLikeElement.id]: true },
newElement: null, newElement: null,
selectionElement: null, selectionElement: null,
}); });
}, 100); }, 100);
if (isIframeElement(element)) { if (isIframeElement(iframeLikeElement)) {
return; return true;
} }
const iframe = this.getHTMLIFrameElement(element); const iframe = this.getHTMLIFrameElement(iframeLikeElement);
if (!iframe?.contentWindow) { if (!iframe?.contentWindow) {
return; return true;
} }
if (iframe.src.includes("youtube")) { if (iframe.src.includes("youtube")) {
const state = YOUTUBE_VIDEO_STATES.get(element.id); const state = YOUTUBE_VIDEO_STATES.get(iframeLikeElement.id);
if (!state) { if (!state) {
YOUTUBE_VIDEO_STATES.set(element.id, YOUTUBE_STATES.UNSTARTED); YOUTUBE_VIDEO_STATES.set(
iframeLikeElement.id,
YOUTUBE_STATES.UNSTARTED,
);
iframe.contentWindow.postMessage( iframe.contentWindow.postMessage(
JSON.stringify({ JSON.stringify({
event: "listening", event: "listening",
id: element.id, id: iframeLikeElement.id,
}), }),
"*", "*",
); );
@@ -1278,6 +1382,8 @@ class App extends React.Component<AppProps, AppState> {
"*", "*",
); );
} }
return true;
} }
private isIframeLikeElementCenter( private isIframeLikeElementCenter(
@@ -1770,8 +1876,9 @@ class App extends React.Component<AppProps, AppState> {
} }
}} }}
style={{ style={{
background: this.state.viewBackgroundColor, background: isDarkTheme
filter: isDarkTheme ? THEME_FILTER : "none", ? applyDarkModeFilter(this.state.viewBackgroundColor)
: this.state.viewBackgroundColor,
zIndex: 2, zIndex: 2,
border: "none", border: "none",
display: "block", display: "block",
@@ -1781,7 +1888,9 @@ class App extends React.Component<AppProps, AppState> {
fontFamily: "Assistant", fontFamily: "Assistant",
fontSize: `${FRAME_STYLE.nameFontSize}px`, fontSize: `${FRAME_STYLE.nameFontSize}px`,
transform: `translate(-${FRAME_NAME_EDIT_PADDING}px, ${FRAME_NAME_EDIT_PADDING}px)`, transform: `translate(-${FRAME_NAME_EDIT_PADDING}px, ${FRAME_NAME_EDIT_PADDING}px)`,
color: "var(--color-gray-80)", color: isDarkTheme
? FRAME_STYLE.nameColorDarkTheme
: FRAME_STYLE.nameColorLightTheme,
overflow: "hidden", overflow: "hidden",
maxWidth: `${ maxWidth: `${
document.body.clientWidth - x1 - FRAME_NAME_EDIT_PADDING document.body.clientWidth - x1 - FRAME_NAME_EDIT_PADDING
@@ -2116,6 +2225,7 @@ class App extends React.Component<AppProps, AppState> {
elementsPendingErasure: this.elementsPendingErasure, elementsPendingErasure: this.elementsPendingErasure,
pendingFlowchartNodes: pendingFlowchartNodes:
this.flowChartCreator.pendingNodes, this.flowChartCreator.pendingNodes,
theme: this.state.theme,
}} }}
/> />
{this.state.newElement && ( {this.state.newElement && (
@@ -2136,6 +2246,7 @@ class App extends React.Component<AppProps, AppState> {
elementsPendingErasure: elementsPendingErasure:
this.elementsPendingErasure, this.elementsPendingErasure,
pendingFlowchartNodes: null, pendingFlowchartNodes: null,
theme: this.state.theme,
}} }}
/> />
)} )}
@@ -2216,6 +2327,10 @@ class App extends React.Component<AppProps, AppState> {
return this.scene.getNonDeletedElements(); return this.scene.getNonDeletedElements();
}; };
public getSchemaMigrationRegistry = () => {
return this.schemaMigrationRegistry;
};
public onInsertElements = (elements: readonly ExcalidrawElement[]) => { public onInsertElements = (elements: readonly ExcalidrawElement[]) => {
this.addElementsFromPasteOrLibrary({ this.addElementsFromPasteOrLibrary({
elements, elements,
@@ -2701,46 +2816,48 @@ class App extends React.Component<AppProps, AppState> {
}, },
}; };
} }
const scene = restore(initialData, null, null, { const restoredElements = restoreElements(initialData?.elements, null, {
repairBindings: true, repairBindings: true,
deleteInvisibleElements: true, deleteInvisibleElements: true,
schemaMigrationRegistry: this.schemaMigrationRegistry,
}); });
const activeTool = scene.appState.activeTool; let restoredAppState = restoreAppState(initialData?.appState, null);
const activeTool = restoredAppState.activeTool;
if (!scene.appState.preferredSelectionTool.initialized) { if (!restoredAppState.preferredSelectionTool.initialized) {
scene.appState.preferredSelectionTool = { restoredAppState.preferredSelectionTool = {
type: type:
this.editorInterface.formFactor === "phone" ? "lasso" : "selection", this.editorInterface.formFactor === "phone" ? "lasso" : "selection",
initialized: true, initialized: true,
}; };
} }
scene.appState = { restoredAppState = {
...scene.appState, ...restoredAppState,
theme: this.props.theme || scene.appState.theme, theme: this.props.theme || restoredAppState.theme,
// we're falling back to current (pre-init) state when deciding // we're falling back to current (pre-init) state when deciding
// whether to open the library, to handle a case where we // whether to open the library, to handle a case where we
// update the state outside of initialData (e.g. when loading the app // update the state outside of initialData (e.g. when loading the app
// with a library install link, which should auto-open the library) // with a library install link, which should auto-open the library)
openSidebar: scene.appState?.openSidebar || this.state.openSidebar, openSidebar: restoredAppState?.openSidebar || this.state.openSidebar,
activeTool: activeTool:
activeTool.type === "image" || activeTool.type === "image" ||
activeTool.type === "lasso" || activeTool.type === "lasso" ||
activeTool.type === "selection" activeTool.type === "selection"
? { ? {
...activeTool, ...activeTool,
type: scene.appState.preferredSelectionTool.type, type: restoredAppState.preferredSelectionTool.type,
} }
: scene.appState.activeTool, : restoredAppState.activeTool,
isLoading: false, isLoading: false,
toast: this.state.toast, toast: this.state.toast,
}; };
if (initialData?.scrollToContent) { if (initialData?.scrollToContent) {
scene.appState = { restoredAppState = {
...scene.appState, ...restoredAppState,
...calculateScrollCenter(scene.elements, { ...calculateScrollCenter(restoredElements, {
...scene.appState, ...restoredAppState,
width: this.state.width, width: this.state.width,
height: this.state.height, height: this.state.height,
offsetTop: this.state.offsetTop, offsetTop: this.state.offsetTop,
@@ -2752,7 +2869,9 @@ class App extends React.Component<AppProps, AppState> {
this.resetStore(); this.resetStore();
this.resetHistory(); this.resetHistory();
this.syncActionResult({ this.syncActionResult({
...scene, elements: restoredElements,
appState: restoredAppState,
files: initialData?.files,
captureUpdate: CaptureUpdateAction.NEVER, captureUpdate: CaptureUpdateAction.NEVER,
}); });
@@ -2772,7 +2891,7 @@ class App extends React.Component<AppProps, AppState> {
private getFormFactor = (editorWidth: number, editorHeight: number) => { private getFormFactor = (editorWidth: number, editorHeight: number) => {
return ( return (
this.props.UIOptions.formFactor ?? this.props.UIOptions.getFormFactor?.(editorWidth, editorHeight) ??
getFormFactor(editorWidth, editorHeight) getFormFactor(editorWidth, editorHeight)
); );
}; };
@@ -2796,10 +2915,7 @@ class App extends React.Component<AppProps, AppState> {
? this.props.UIOptions.dockedSidebarBreakpoint ? this.props.UIOptions.dockedSidebarBreakpoint
: MQ_RIGHT_SIDEBAR_MIN_WIDTH; : MQ_RIGHT_SIDEBAR_MIN_WIDTH;
const nextEditorInterface = updateObject(this.editorInterface, { const nextEditorInterface = updateObject(this.editorInterface, {
desktopUIMode: desktopUIMode: storedDesktopUIMode ?? this.editorInterface.desktopUIMode,
this.props.UIOptions.desktopUIMode ??
storedDesktopUIMode ??
this.editorInterface.desktopUIMode,
formFactor: this.getFormFactor(editorWidth, editorHeight), formFactor: this.getFormFactor(editorWidth, editorHeight),
userAgent: userAgentDescriptor, userAgent: userAgentDescriptor,
canFitSidebar: editorWidth > sidebarBreakpoint, canFitSidebar: editorWidth > sidebarBreakpoint,
@@ -3115,6 +3231,12 @@ class App extends React.Component<AppProps, AppState> {
} }
componentDidUpdate(prevProps: AppProps, prevState: AppState) { componentDidUpdate(prevProps: AppProps, prevState: AppState) {
if (prevProps.schemaPlugins !== this.props.schemaPlugins) {
this.schemaMigrationRegistry = createSchemaMigrationRegistry(
this.props.schemaPlugins,
);
}
this.updateEmbeddables(); this.updateEmbeddables();
const elements = this.scene.getElementsIncludingDeleted(); const elements = this.scene.getElementsIncludingDeleted();
const elementsMap = this.scene.getElementsMapIncludingDeleted(); const elementsMap = this.scene.getElementsMapIncludingDeleted();
@@ -3178,6 +3300,7 @@ class App extends React.Component<AppProps, AppState> {
) { ) {
setEraserCursor(this.interactiveCanvas, this.state.theme); setEraserCursor(this.interactiveCanvas, this.state.theme);
} }
// Hide hyperlink popup if shown when element type is not selection // Hide hyperlink popup if shown when element type is not selection
if ( if (
prevState.activeTool.type === "selection" && prevState.activeTool.type === "selection" &&
@@ -3617,6 +3740,7 @@ class App extends React.Component<AppProps, AppState> {
}) => { }) => {
const elements = restoreElements(opts.elements, null, { const elements = restoreElements(opts.elements, null, {
deleteInvisibleElements: true, deleteInvisibleElements: true,
schemaMigrationRegistry: this.schemaMigrationRegistry,
}); });
const [minX, minY, maxX, maxY] = getCommonBounds(elements); const [minX, minY, maxX, maxY] = getCommonBounds(elements);
@@ -4728,8 +4852,12 @@ class App extends React.Component<AppProps, AppState> {
} }
// Handle Alt key for bind mode // Handle Alt key for bind mode
if (event.key === KEYS.ALT && getFeatureFlag("COMPLEX_BINDINGS")) { if (event.key === KEYS.ALT) {
this.handleSkipBindMode(); if (getFeatureFlag("COMPLEX_BINDINGS")) {
this.handleSkipBindMode();
} else {
maybeHandleArrowPointlikeDrag({ app: this, event });
}
} }
if (this.actionManager.handleKeyDown(event)) { if (this.actionManager.handleKeyDown(event)) {
@@ -4745,7 +4873,11 @@ class App extends React.Component<AppProps, AppState> {
this.resetDelayedBindMode(); this.resetDelayedBindMode();
} }
this.setState({ isBindingEnabled: false }); flushSync(() => {
this.setState({ isBindingEnabled: false });
});
maybeHandleArrowPointlikeDrag({ app: this, event });
} }
if (isArrowKey(event.key)) { if (isArrowKey(event.key)) {
@@ -4925,6 +5057,8 @@ class App extends React.Component<AppProps, AppState> {
if ( if (
event.key === KEYS.G && event.key === KEYS.G &&
(hasBackground(this.state.activeTool.type) || (hasBackground(this.state.activeTool.type) ||
this.state.activeTool.type === "frame" ||
selectedElements.some((element) => isFrameElement(element)) ||
selectedElements.some((element) => hasBackground(element.type))) selectedElements.some((element) => hasBackground(element.type)))
) { ) {
this.setState({ openPopup: "elementBackground" }); this.setState({ openPopup: "elementBackground" });
@@ -5018,6 +5152,11 @@ class App extends React.Component<AppProps, AppState> {
} }
isHoldingSpace = false; isHoldingSpace = false;
} }
if (event.key === KEYS.ALT) {
maybeHandleArrowPointlikeDrag({ app: this, event });
}
if ( if (
(event.key === KEYS.ALT && this.state.bindMode === "skip") || (event.key === KEYS.ALT && this.state.bindMode === "skip") ||
(!event[KEYS.CTRL_OR_CMD] && !isBindingEnabled(this.state)) (!event[KEYS.CTRL_OR_CMD] && !isBindingEnabled(this.state))
@@ -5028,7 +5167,7 @@ class App extends React.Component<AppProps, AppState> {
}); });
// Restart the timer if we're creating/editing a linear element and hovering over an element // Restart the timer if we're creating/editing a linear element and hovering over an element
if (this.lastPointerMoveEvent) { if (this.lastPointerMoveEvent && getFeatureFlag("COMPLEX_BINDINGS")) {
const scenePointer = viewportCoordsToSceneCoords( const scenePointer = viewportCoordsToSceneCoords(
{ {
clientX: this.lastPointerMoveEvent.clientX, clientX: this.lastPointerMoveEvent.clientX,
@@ -5049,14 +5188,18 @@ class App extends React.Component<AppProps, AppState> {
this.scene.getNonDeletedElementsMap(), this.scene.getNonDeletedElementsMap(),
); );
if (isBindingElement(element) && getFeatureFlag("COMPLEX_BINDINGS")) { if (isBindingElement(element)) {
this.handleDelayedBindModeChange(element, hoveredElement); this.handleDelayedBindModeChange(element, hoveredElement);
} }
} }
} }
} }
if (!event[KEYS.CTRL_OR_CMD] && !this.state.isBindingEnabled) { if (!event[KEYS.CTRL_OR_CMD] && !this.state.isBindingEnabled) {
this.setState({ isBindingEnabled: true }); flushSync(() => {
this.setState({ isBindingEnabled: true });
});
maybeHandleArrowPointlikeDrag({ app: this, event });
} }
if (isArrowKey(event.key)) { if (isArrowKey(event.key)) {
bindOrUnbindBindingElements( bindOrUnbindBindingElements(
@@ -6369,15 +6512,28 @@ class App extends React.Component<AppProps, AppState> {
// and point // and point
const { newElement } = this.state; const { newElement } = this.state;
if (!newElement && isBindingEnabled(this.state)) { if (!newElement && isBindingEnabled(this.state)) {
const globalPoint = pointFrom<GlobalPoint>(
scenePointerX,
scenePointerY,
);
const elementsMap = this.scene.getNonDeletedElementsMap();
const hoveredElement = getHoveredElementForBinding( const hoveredElement = getHoveredElementForBinding(
pointFrom<GlobalPoint>(scenePointerX, scenePointerY), globalPoint,
this.scene.getNonDeletedElements(), this.scene.getNonDeletedElements(),
this.scene.getNonDeletedElementsMap(), elementsMap,
(el) => maxBindingDistance_simple(this.state.zoom), maxBindingDistance_simple(this.state.zoom),
); );
if (hoveredElement) { if (hoveredElement) {
this.setState({ this.setState({
suggestedBinding: hoveredElement, suggestedBinding: {
element: hoveredElement,
midPoint: getSnapOutlineMidPoint(
globalPoint,
hoveredElement,
elementsMap,
this.state.zoom,
),
},
}); });
} else if (this.state.suggestedBinding) { } else if (this.state.suggestedBinding) {
this.setState({ this.setState({
@@ -6404,7 +6560,7 @@ class App extends React.Component<AppProps, AppState> {
pointFrom<GlobalPoint>(scenePointerX, scenePointerY), pointFrom<GlobalPoint>(scenePointerX, scenePointerY),
this.scene.getNonDeletedElements(), this.scene.getNonDeletedElements(),
this.scene.getNonDeletedElementsMap(), this.scene.getNonDeletedElementsMap(),
(el) => maxBindingDistance_simple(this.state.zoom), maxBindingDistance_simple(this.state.zoom),
); );
if (hoveredElement) { if (hoveredElement) {
this.actionManager.executeAction(actionFinalize, "ui", { this.actionManager.executeAction(actionFinalize, "ui", {
@@ -6558,26 +6714,33 @@ class App extends React.Component<AppProps, AppState> {
pointFrom<GlobalPoint>(scenePointerX, scenePointerY), pointFrom<GlobalPoint>(scenePointerX, scenePointerY),
this.scene.getNonDeletedElements(), this.scene.getNonDeletedElements(),
this.scene.getNonDeletedElementsMap(), this.scene.getNonDeletedElementsMap(),
(el) => maxBindingDistance_simple(this.state.zoom), maxBindingDistance_simple(this.state.zoom),
); );
if ( const scenePointer = pointFrom<GlobalPoint>(scenePointerX, scenePointerY);
hit && const elementsMap = this.scene.getNonDeletedElementsMap();
!isPointInElement( if (hit && !isPointInElement(scenePointer, hit, elementsMap)) {
pointFrom<GlobalPoint>(scenePointerX, scenePointerY),
hit,
this.scene.getNonDeletedElementsMap(),
)
) {
this.setState({ this.setState({
suggestedBinding: hit, suggestedBinding: {
element: hit,
midPoint: getSnapOutlineMidPoint(
scenePointer,
hit,
elementsMap,
this.state.zoom,
),
},
}); });
} }
} }
const hasDeselectedButton = Boolean(event.buttons); const isPressingAnyButton = Boolean(event.buttons);
const isLaserTool = this.state.activeTool.type === "laser";
if ( if (
hasDeselectedButton || isPressingAnyButton ||
(this.state.activeTool.type !== "selection" && // checking against laser so that if you mouseover with a laser tool
// over a link/embeddable, we change the cursor
(!isLaserTool &&
this.state.activeTool.type !== "selection" &&
this.state.activeTool.type !== "lasso" && this.state.activeTool.type !== "lasso" &&
this.state.activeTool.type !== "text" && this.state.activeTool.type !== "text" &&
this.state.activeTool.type !== "eraser") this.state.activeTool.type !== "eraser")
@@ -6697,6 +6860,14 @@ class App extends React.Component<AppProps, AppState> {
); );
} else { } else {
hideHyperlinkToolip(); hideHyperlinkToolip();
if (isLaserTool) {
this.handleIframeLikeElementHover({
hitElement,
scenePointer,
moveEvent: event,
});
return;
}
if ( if (
hitElement && hitElement &&
(hitElement.link || isEmbeddableElement(hitElement)) && (hitElement.link || isEmbeddableElement(hitElement)) &&
@@ -6729,24 +6900,15 @@ class App extends React.Component<AppProps, AppState> {
!hitElement?.locked !hitElement?.locked
) { ) {
if ( if (
hitElement && !this.handleIframeLikeElementHover({
isIframeLikeElement(hitElement) &&
this.isIframeLikeElementCenter(
hitElement, hitElement,
event, scenePointer,
scenePointerX, moveEvent: event,
scenePointerY, }) &&
) (!hitElement ||
) { // Elbow arrows can only be moved when unconnected
setCursor(this.interactiveCanvas, CURSOR_TYPE.POINTER); !isElbowArrow(hitElement) ||
this.setState({ !(hitElement.startBinding || hitElement.endBinding))
activeEmbeddable: { element: hitElement, state: "hover" },
});
} else if (
!hitElement ||
// Elbow arrows can only be moved when unconnected
!isElbowArrow(hitElement) ||
!(hitElement.startBinding || hitElement.endBinding)
) { ) {
if ( if (
this.state.activeTool.type !== "lasso" || this.state.activeTool.type !== "lasso" ||
@@ -6754,9 +6916,6 @@ class App extends React.Component<AppProps, AppState> {
) { ) {
setCursor(this.interactiveCanvas, CURSOR_TYPE.MOVE); setCursor(this.interactiveCanvas, CURSOR_TYPE.MOVE);
} }
if (this.state.activeEmbeddable?.state === "hover") {
this.setState({ activeEmbeddable: null });
}
} }
} }
} else { } else {
@@ -6917,6 +7076,37 @@ class App extends React.Component<AppProps, AppState> {
}, },
}); });
} }
// Check for focus point hover
let hoveredFocusPointBinding: "start" | "end" | null = null;
const arrow = element as any;
if (arrow.startBinding || arrow.endBinding) {
hoveredFocusPointBinding = handleFocusPointHover(
element as ExcalidrawArrowElement,
scenePointerX,
scenePointerY,
this.scene,
this.state,
);
}
if (
this.state.selectedLinearElement.hoveredFocusPointBinding !==
hoveredFocusPointBinding
) {
this.setState({
selectedLinearElement: {
...this.state.selectedLinearElement,
isDragging: false,
hoveredFocusPointBinding,
},
});
}
// Set cursor to pointer when hovering over a focus point
if (hoveredFocusPointBinding) {
setCursor(this.interactiveCanvas, CURSOR_TYPE.POINTER);
}
} else { } else {
setCursor(this.interactiveCanvas, CURSOR_TYPE.AUTO); setCursor(this.interactiveCanvas, CURSOR_TYPE.AUTO);
} }
@@ -7377,26 +7567,9 @@ class App extends React.Component<AppProps, AppState> {
x: scenePointerX, x: scenePointerX,
y: scenePointerY, y: scenePointerY,
}; };
const clicklength =
event.timeStamp - (this.lastPointerDownEvent?.timeStamp ?? 0);
if (this.editorInterface.formFactor === "phone" && clicklength < 300) { if (this.handleIframeLikeCenterClick()) {
const hitElement = this.getElementAtPosition( return;
scenePointer.x,
scenePointer.y,
);
if (
isIframeLikeElement(hitElement) &&
this.isIframeLikeElementCenter(
hitElement,
event,
scenePointer.x,
scenePointer.y,
)
) {
this.handleEmbeddableCenterClick(hitElement);
return;
}
} }
if (this.editorInterface.isTouchScreen) { if (this.editorInterface.isTouchScreen) {
@@ -7417,20 +7590,7 @@ class App extends React.Component<AppProps, AppState> {
this.hitLinkElement && this.hitLinkElement &&
!this.state.selectedElementIds[this.hitLinkElement.id] !this.state.selectedElementIds[this.hitLinkElement.id]
) { ) {
if ( this.redirectToLink(event, this.editorInterface.isTouchScreen);
clicklength < 300 &&
isIframeLikeElement(this.hitLinkElement) &&
!isPointHittingLinkIcon(
this.hitLinkElement,
this.scene.getNonDeletedElementsMap(),
this.state,
pointFrom(scenePointer.x, scenePointer.y),
)
) {
this.handleEmbeddableCenterClick(this.hitLinkElement);
} else {
this.redirectToLink(event, this.editorInterface.isTouchScreen);
}
} else if (this.state.viewModeEnabled) { } else if (this.state.viewModeEnabled) {
this.setState({ this.setState({
activeEmbeddable: null, activeEmbeddable: null,
@@ -7838,6 +7998,37 @@ class App extends React.Component<AppProps, AppState> {
if (ret.didAddPoint) { if (ret.didAddPoint) {
return true; return true;
} }
// Also check at current pointer position if focus point is being hovered
// (in case we're clicking directly without a prior move event)
const elementsMap = this.scene.getNonDeletedElementsMap();
const arrow = LinearElementEditor.getElement(
linearElementEditor.elementId,
elementsMap,
) as any;
if (arrow && isBindingElement(arrow)) {
const { hitFocusPoint, pointerOffset } =
handleFocusPointPointerDown(
arrow,
pointerDownState,
elementsMap,
this.state,
);
// If focus point is hit, update state and prevent element selection
if (hitFocusPoint) {
this.setState({
selectedLinearElement: {
...linearElementEditor,
hoveredFocusPointBinding: hitFocusPoint,
draggedFocusPointBinding: hitFocusPoint,
pointerOffset,
},
});
return false;
}
}
} }
const allHitElements = this.getElementsAtPosition( const allHitElements = this.getElementsAtPosition(
@@ -8650,6 +8841,7 @@ class App extends React.Component<AppProps, AppState> {
selectedPointsIndices: [endIdx], selectedPointsIndices: [endIdx],
initialState: { initialState: {
...linearElementEditor.initialState, ...linearElementEditor.initialState,
arrowStartIsInside: event.altKey,
lastClickedPoint: endIdx, lastClickedPoint: endIdx,
origin: pointFrom<GlobalPoint>( origin: pointFrom<GlobalPoint>(
pointerDownState.origin.x, pointerDownState.origin.x,
@@ -8668,7 +8860,18 @@ class App extends React.Component<AppProps, AppState> {
bindMode: "orbit", bindMode: "orbit",
newElement: element, newElement: element,
startBoundElement: boundElement, startBoundElement: boundElement,
suggestedBinding: boundElement || null, suggestedBinding:
boundElement && isBindingElement(element)
? {
element: boundElement,
midPoint: getSnapOutlineMidPoint(
point,
boundElement,
elementsMap,
this.state.zoom,
),
}
: null,
selectedElementIds: nextSelectedElementIds, selectedElementIds: nextSelectedElementIds,
selectedLinearElement: linearElementEditor, selectedLinearElement: linearElementEditor,
}; };
@@ -8930,8 +9133,8 @@ class App extends React.Component<AppProps, AppState> {
} }
const lastPointerCoords = const lastPointerCoords =
this.lastPointerMoveCoords ?? pointerDownState.origin; this.previousPointerMoveCoords ?? pointerDownState.origin;
this.lastPointerMoveCoords = pointerCoords; this.previousPointerMoveCoords = pointerCoords;
// We need to initialize dragOffsetXY only after we've updated // We need to initialize dragOffsetXY only after we've updated
// `state.selectedElementIds` on pointerDown. Doing it here in pointerMove // `state.selectedElementIds` on pointerDown. Doing it here in pointerMove
@@ -8985,6 +9188,31 @@ class App extends React.Component<AppProps, AppState> {
if (this.state.selectedLinearElement) { if (this.state.selectedLinearElement) {
const linearElementEditor = this.state.selectedLinearElement; const linearElementEditor = this.state.selectedLinearElement;
// Handle focus point dragging if needed
if (linearElementEditor.draggedFocusPointBinding) {
handleFocusPointDrag(
linearElementEditor,
elementsMap,
pointerCoords,
this.scene,
this.state,
this.getEffectiveGridSize(),
event.altKey,
);
this.setState({
selectedLinearElement: {
...linearElementEditor,
isDragging: false,
selectedPointsIndices: [],
initialState: {
...linearElementEditor.initialState,
lastClickedPoint: -1,
},
},
});
return;
}
if ( if (
LinearElementEditor.shouldAddMidpoint( LinearElementEditor.shouldAddMidpoint(
this.state.selectedLinearElement, this.state.selectedLinearElement,
@@ -9223,14 +9451,21 @@ class App extends React.Component<AppProps, AppState> {
this.imageCache.get(croppingElement.fileId)?.image; this.imageCache.get(croppingElement.fileId)?.image;
if (image && !(image instanceof Promise)) { if (image && !(image instanceof Promise)) {
const instantDragOffset = vectorScale( const uncroppedSize =
vector( getUncroppedWidthAndHeight(croppingElement);
pointerCoords.x - lastPointerCoords.x, const instantDragOffset = vector(
pointerCoords.y - lastPointerCoords.y, pointerCoords.x - lastPointerCoords.x,
), pointerCoords.y - lastPointerCoords.y,
Math.max(this.state.zoom.value, 2),
); );
// to reduce cursor:image drift, we need to take into account
// the canvas image element scaling so we can accurately
// track the pixels on movement
instantDragOffset[0] *=
image.naturalWidth / uncroppedSize.width;
instantDragOffset[1] *=
image.naturalHeight / uncroppedSize.height;
const [x1, y1, x2, y2, cx, cy] = getElementAbsoluteCoords( const [x1, y1, x2, y2, cx, cy] = getElementAbsoluteCoords(
croppingElement, croppingElement,
elementsMap, elementsMap,
@@ -9273,13 +9508,13 @@ class App extends React.Component<AppProps, AppState> {
const nextCrop = { const nextCrop = {
...crop, ...crop,
x: clamp( x: clamp(
crop.x + crop.x -
offsetVector[0] * Math.sign(croppingElement.scale[0]), offsetVector[0] * Math.sign(croppingElement.scale[0]),
0, 0,
image.naturalWidth - crop.width, image.naturalWidth - crop.width,
), ),
y: clamp( y: clamp(
crop.y + crop.y -
offsetVector[1] * Math.sign(croppingElement.scale[1]), offsetVector[1] * Math.sign(croppingElement.scale[1]),
0, 0,
image.naturalHeight - crop.height, image.naturalHeight - crop.height,
@@ -9772,6 +10007,7 @@ class App extends React.Component<AppProps, AppState> {
// just in case, tool changes mid drag, always clean up // just in case, tool changes mid drag, always clean up
this.lassoTrail.endPath(); this.lassoTrail.endPath();
this.previousPointerMoveCoords = null;
SnapCache.setReferenceSnapPoints(null); SnapCache.setReferenceSnapPoints(null);
SnapCache.setVisibleGaps(null); SnapCache.setVisibleGaps(null);
@@ -9853,12 +10089,14 @@ class App extends React.Component<AppProps, AppState> {
// and sets binding element // and sets binding element
if ( if (
this.state.selectedLinearElement?.isEditing && this.state.selectedLinearElement?.isEditing &&
!this.state.newElement !this.state.newElement &&
this.state.selectedLinearElement.draggedFocusPointBinding === null
) { ) {
if ( if (
!pointerDownState.boxSelection.hasOccurred && !pointerDownState.boxSelection.hasOccurred &&
pointerDownState.hit?.element?.id !== pointerDownState.hit?.element?.id !==
this.state.selectedLinearElement.elementId this.state.selectedLinearElement.elementId &&
this.state.selectedLinearElement.draggedFocusPointBinding === null
) { ) {
this.actionManager.executeAction(actionFinalize); this.actionManager.executeAction(actionFinalize);
} else { } else {
@@ -9894,7 +10132,18 @@ class App extends React.Component<AppProps, AppState> {
} }
} }
if ( if (this.state.selectedLinearElement.draggedFocusPointBinding) {
handleFocusPointPointerUp(
this.state.selectedLinearElement,
this.scene,
);
this.setState({
selectedLinearElement: {
...this.state.selectedLinearElement,
draggedFocusPointBinding: null,
},
});
} else if (
pointerDownState.hit?.element?.id !== pointerDownState.hit?.element?.id !==
this.state.selectedLinearElement.elementId this.state.selectedLinearElement.elementId
) { ) {
@@ -9904,6 +10153,12 @@ class App extends React.Component<AppProps, AppState> {
this.setState({ selectedLinearElement: null }); this.setState({ selectedLinearElement: null });
} }
} else if (this.state.selectedLinearElement.isDragging) { } else if (this.state.selectedLinearElement.isDragging) {
this.setState({
selectedLinearElement: {
...this.state.selectedLinearElement,
isDragging: false,
},
});
this.actionManager.executeAction(actionFinalize, "ui", { this.actionManager.executeAction(actionFinalize, "ui", {
event: childEvent, event: childEvent,
sceneCoords, sceneCoords,
@@ -10046,7 +10301,7 @@ class App extends React.Component<AppProps, AppState> {
}); });
} }
} else if (pointerDownState.drag.hasOccurred && !multiElement) { } else if (pointerDownState.drag.hasOccurred && !multiElement) {
if (isBindingElement(newElement, false)) { if (isLinearElement(newElement)) {
this.actionManager.executeAction(actionFinalize, "ui", { this.actionManager.executeAction(actionFinalize, "ui", {
event: childEvent, event: childEvent,
sceneCoords, sceneCoords,
@@ -10678,25 +10933,6 @@ class App extends React.Component<AppProps, AppState> {
suggestedBinding: null, suggestedBinding: null,
}); });
} }
if (
hitElement &&
this.lastPointerUpEvent &&
this.lastPointerDownEvent &&
this.lastPointerUpEvent.timeStamp -
this.lastPointerDownEvent.timeStamp <
300 &&
gesture.pointers.size <= 1 &&
isIframeLikeElement(hitElement) &&
this.isIframeLikeElementCenter(
hitElement,
this.lastPointerUpEvent,
pointerDownState.origin.x,
pointerDownState.origin.y,
)
) {
this.handleEmbeddableCenterClick(hitElement);
}
}); });
} }
@@ -11211,6 +11447,7 @@ class App extends React.Component<AppProps, AppState> {
this.state, this.state,
this.scene.getElementsIncludingDeleted(), this.scene.getElementsIncludingDeleted(),
fileHandle, fileHandle,
this.schemaMigrationRegistry,
); );
this.syncActionResult({ this.syncActionResult({
...scene, ...scene,
@@ -11257,7 +11494,11 @@ class App extends React.Component<AppProps, AppState> {
); );
// legacy library dataTransfer format // legacy library dataTransfer format
} else if (excalidrawLibrary_data) { } else if (excalidrawLibrary_data) {
libraryItems = parseLibraryJSON(excalidrawLibrary_data); libraryItems = parseLibraryJSON(
excalidrawLibrary_data,
"unpublished",
this.schemaMigrationRegistry,
);
} }
if (libraryItems?.length) { if (libraryItems?.length) {
libraryItems = libraryItems.map((item) => ({ libraryItems = libraryItems.map((item) => ({
@@ -11327,6 +11568,7 @@ class App extends React.Component<AppProps, AppState> {
this.state, this.state,
elements, elements,
fileHandle, fileHandle,
this.schemaMigrationRegistry,
); );
} catch (error: any) { } catch (error: any) {
const imageSceneDataError = error instanceof ImageSceneDataError; const imageSceneDataError = error instanceof ImageSceneDataError;
+1 -1
View File
@@ -1,4 +1,4 @@
@import "../css/variables.module.scss"; @use "../css/variables.module" as *;
.excalidraw { .excalidraw {
.Avatar { .Avatar {
+1 -1
View File
@@ -1,4 +1,4 @@
@import "../css/theme"; @use "../css/theme" as *;
.excalidraw { .excalidraw {
.excalidraw-button { .excalidraw-button {
@@ -1,4 +1,4 @@
@import "../css/theme"; @use "../css/theme" as *;
.excalidraw { .excalidraw {
button.standalone { button.standalone {
+3 -3
View File
@@ -1,4 +1,4 @@
@import "../css/variables.module.scss"; @use "../css/variables.module.scss" as *;
.excalidraw { .excalidraw {
.Card { .Card {
@@ -19,7 +19,7 @@
padding: 1.4rem; padding: 1.4rem;
border-radius: 50%; border-radius: 50%;
background: var(--card-color); background: var(--card-color);
color: $oc-white; color: #fff;
svg { svg {
width: 2.8rem; width: 2.8rem;
@@ -46,7 +46,7 @@
background-color: var(--card-color-darkest); background-color: var(--card-color-darkest);
} }
.ToolIcon__label { .ToolIcon__label {
color: $oc-white; color: #fff;
} }
.Spinner { .Spinner {
+23 -13
View File
@@ -1,25 +1,35 @@
import OpenColor from "open-color";
import "./Card.scss"; import "./Card.scss";
// for open-color see https://github.com/yeun/open-color/blob/master/open-color.scss
const COLOR_MAP = {
primary: {
base: "var(--color-primary)",
darker: "var(--color-primary-darker)",
darkest: "var(--color-primary-darkest)",
},
lime: {
base: "#74b816", // open-color lime[7]
darker: "#66a80f", // open-color lime[8]
darkest: "#5c940d", // open-color lime[9]
},
pink: {
base: "#d6336c", // open-color pink[7]
darker: "#c2255c", // open-color pink[8]
darkest: "#a61e4d", // open-color pink[9]
},
};
export const Card: React.FC<{ export const Card: React.FC<{
color: keyof OpenColor | "primary"; color: "primary" | "lime" | "pink";
children?: React.ReactNode; children?: React.ReactNode;
}> = ({ children, color }) => { }> = ({ children, color }) => {
return ( return (
<div <div
className="Card" className="Card"
style={{ style={{
["--card-color" as any]: ["--card-color" as any]: COLOR_MAP[color].base,
color === "primary" ? "var(--color-primary)" : OpenColor[color][7], ["--card-color-darker" as any]: COLOR_MAP[color].darker,
["--card-color-darker" as any]: ["--card-color-darkest" as any]: COLOR_MAP[color].darkest,
color === "primary"
? "var(--color-primary-darker)"
: OpenColor[color][8],
["--card-color-darkest" as any]:
color === "primary"
? "var(--color-primary-darkest)"
: OpenColor[color][9],
}} }}
> >
{children} {children}
@@ -1,4 +1,5 @@
@import "../css/variables.module.scss"; @use "sass:color";
@use "../css/variables.module" as *;
.excalidraw { .excalidraw {
.Checkbox { .Checkbox {
@@ -12,7 +13,7 @@
-webkit-tap-highlight-color: transparent; -webkit-tap-highlight-color: transparent;
&:hover:not(.is-checked) .Checkbox-box:not(:focus) { &:hover:not(.is-checked) .Checkbox-box:not(:focus) {
box-shadow: 0 0 0 2px #{$oc-blue-4}; box-shadow: 0 0 0 2px #{$color-blue-4};
} }
&:hover:not(.is-checked) .Checkbox-box:not(:focus) { &:hover:not(.is-checked) .Checkbox-box:not(:focus) {
@@ -24,25 +25,25 @@
&:active { &:active {
.Checkbox-box { .Checkbox-box {
box-shadow: 0 0 2px 1px inset #{$oc-blue-7} !important; box-shadow: 0 0 2px 1px inset #{$color-blue-7} !important;
} }
} }
&:hover { &:hover {
.Checkbox-box { .Checkbox-box {
background-color: fade-out($oc-blue-1, 0.8); background-color: color.adjust($color-blue-1, $alpha: -0.8);
} }
} }
&.is-checked { &.is-checked {
.Checkbox-box { .Checkbox-box {
background-color: #{$oc-blue-1}; background-color: #{$color-blue-1};
svg { svg {
display: block; display: block;
} }
} }
&:hover .Checkbox-box { &:hover .Checkbox-box {
background-color: #{$oc-blue-2}; background-color: #{$color-blue-2};
} }
} }
@@ -58,16 +59,16 @@
align-items: center; align-items: center;
justify-content: center; justify-content: center;
box-shadow: 0 0 0 2px #{$oc-blue-7}; box-shadow: 0 0 0 2px #{$color-blue-7};
background-color: transparent; background-color: transparent;
border-radius: 4px; border-radius: 4px;
color: #{$oc-blue-7}; color: #{$color-blue-7};
border: 0; border: 0;
&:focus { &:focus {
box-shadow: 0 0 0 3px #{$oc-blue-7}; box-shadow: 0 0 0 3px #{$color-blue-7};
} }
svg { svg {
@@ -1,7 +1,7 @@
import clsx from "clsx"; import clsx from "clsx";
import { useCallback, useEffect, useRef, useState } from "react"; import { useCallback, useEffect, useRef, useState } from "react";
import { KEYS } from "@excalidraw/common"; import { KEYS, normalizeInputColor } from "@excalidraw/common";
import { getShortcutKey } from "../..//shortcut"; import { getShortcutKey } from "../..//shortcut";
import { useAtom } from "../../editor-jotai"; import { useAtom } from "../../editor-jotai";
@@ -10,26 +10,23 @@ import { useEditorInterface } from "../App";
import { activeEyeDropperAtom } from "../EyeDropper"; import { activeEyeDropperAtom } from "../EyeDropper";
import { eyeDropperIcon } from "../icons"; import { eyeDropperIcon } from "../icons";
import { getColor } from "./ColorPicker";
import { activeColorPickerSectionAtom } from "./colorPickerUtils"; import { activeColorPickerSectionAtom } from "./colorPickerUtils";
import type { ColorPickerType } from "./colorPickerUtils"; import type { ColorPickerType } from "./colorPickerUtils";
interface ColorInputProps {
color: string;
onChange: (color: string) => void;
label: string;
colorPickerType: ColorPickerType;
placeholder?: string;
}
export const ColorInput = ({ export const ColorInput = ({
color, color,
onChange, onChange,
label, label,
colorPickerType, colorPickerType,
placeholder, placeholder,
}: ColorInputProps) => { }: {
color: string;
onChange: (color: string) => void;
label: string;
colorPickerType: ColorPickerType;
placeholder?: string;
}) => {
const editorInterface = useEditorInterface(); const editorInterface = useEditorInterface();
const [innerValue, setInnerValue] = useState(color); const [innerValue, setInnerValue] = useState(color);
const [activeSection, setActiveColorPickerSection] = useAtom( const [activeSection, setActiveColorPickerSection] = useAtom(
@@ -43,7 +40,7 @@ export const ColorInput = ({
const changeColor = useCallback( const changeColor = useCallback(
(inputValue: string) => { (inputValue: string) => {
const value = inputValue.toLowerCase(); const value = inputValue.toLowerCase();
const color = getColor(value); const color = normalizeInputColor(value);
if (color) { if (color) {
onChange(color); onChange(color);
@@ -1,4 +1,5 @@
@import "../../css/variables.module.scss"; @use "sass:color";
@use "../../css/variables.module" as *;
.excalidraw { .excalidraw {
.focus-visible-none { .focus-visible-none {
@@ -185,8 +186,8 @@
.color-picker { .color-picker {
background: var(--popup-bg-color); background: var(--popup-bg-color);
border: 0 solid transparentize($oc-white, 0.75); border: 0 solid color.adjust(#fff, $alpha: -0.75);
box-shadow: transparentize($oc-black, 0.75) 0 1px 4px; box-shadow: color.adjust(#000, $alpha: -0.75) 0 1px 4px;
border-radius: 4px; border-radius: 4px;
position: absolute; position: absolute;
@@ -243,7 +244,7 @@
} }
.color-picker-triangle-shadow { .color-picker-triangle-shadow {
border-color: transparent transparent transparentize($oc-black, 0.9); border-color: transparent transparent color.adjust(#000, $alpha: -0.9);
:root[dir="ltr"] & { :root[dir="ltr"] & {
left: -14px; left: -14px;
@@ -280,7 +281,7 @@
padding: 0.25rem; padding: 0.25rem;
&-title { &-title {
color: $oc-gray-6; color: $color-gray-6;
font-size: 12px; font-size: 12px;
padding: 0 0.25rem; padding: 0 0.25rem;
} }
@@ -319,7 +320,7 @@
.color-picker-transparent { .color-picker-transparent {
border-radius: 4px; border-radius: 4px;
box-shadow: transparentize($oc-black, 0.9) 0 0 0 1px inset; box-shadow: color.adjust(#000, $alpha: -0.9) 0 0 0 1px inset;
position: absolute; position: absolute;
top: 0; top: 0;
right: 0; right: 0;
@@ -473,7 +474,7 @@
} }
.color-picker-type-elementBackground .color-picker-keybinding { .color-picker-type-elementBackground .color-picker-keybinding {
color: $oc-white; color: #fff;
} }
.color-picker-swatch[aria-label="transparent"] .color-picker-keybinding { .color-picker-swatch[aria-label="transparent"] .color-picker-keybinding {
@@ -486,10 +487,10 @@
&.theme--dark { &.theme--dark {
.color-picker-type-elementBackground .color-picker-keybinding { .color-picker-type-elementBackground .color-picker-keybinding {
color: $oc-black; color: #000;
} }
.color-picker-swatch[aria-label="transparent"] .color-picker-keybinding { .color-picker-swatch[aria-label="transparent"] .color-picker-keybinding {
color: $oc-black; color: #000;
} }
} }
} }
@@ -1,11 +1,11 @@
import * as Popover from "@radix-ui/react-popover"; import { Popover } from "radix-ui";
import clsx from "clsx"; import clsx from "clsx";
import { useRef, useEffect } from "react"; import { useRef, useEffect } from "react";
import { import {
COLOR_OUTLINE_CONTRAST_THRESHOLD, COLOR_OUTLINE_CONTRAST_THRESHOLD,
COLOR_PALETTE, COLOR_PALETTE,
isTransparent, isColorDark,
isWritableElement, isWritableElement,
} from "@excalidraw/common"; } from "@excalidraw/common";
@@ -30,7 +30,7 @@ import { ColorInput } from "./ColorInput";
import { Picker } from "./Picker"; import { Picker } from "./Picker";
import PickerHeading from "./PickerHeading"; import PickerHeading from "./PickerHeading";
import { TopPicks } from "./TopPicks"; import { TopPicks } from "./TopPicks";
import { activeColorPickerSectionAtom, isColorDark } from "./colorPickerUtils"; import { activeColorPickerSectionAtom } from "./colorPickerUtils";
import "./ColorPicker.scss"; import "./ColorPicker.scss";
@@ -38,27 +38,6 @@ import type { ColorPickerType } from "./colorPickerUtils";
import type { AppState } from "../../types"; import type { AppState } from "../../types";
const isValidColor = (color: string) => {
const style = new Option().style;
style.color = color;
return !!style.color;
};
export const getColor = (color: string): string | null => {
if (isTransparent(color)) {
return color;
}
// testing for `#` first fixes a bug on Electron (more specfically, an
// Obsidian popout window), where a hex color without `#` is (incorrectly)
// considered valid
return isValidColor(`#${color}`)
? `#${color}`
: isValidColor(color)
? color
: null;
};
interface ColorPickerProps { interface ColorPickerProps {
type: ColorPickerType; type: ColorPickerType;
/** /**
@@ -1,6 +1,5 @@
import React from "react"; import React from "react";
import { isColorDark } from "@excalidraw/common";
import { isColorDark } from "./colorPickerUtils";
interface HotkeyLabelProps { interface HotkeyLabelProps {
color: string; color: string;
@@ -5,10 +5,9 @@ import {
DEFAULT_CANVAS_BACKGROUND_PICKS, DEFAULT_CANVAS_BACKGROUND_PICKS,
DEFAULT_ELEMENT_BACKGROUND_PICKS, DEFAULT_ELEMENT_BACKGROUND_PICKS,
DEFAULT_ELEMENT_STROKE_PICKS, DEFAULT_ELEMENT_STROKE_PICKS,
isColorDark,
} from "@excalidraw/common"; } from "@excalidraw/common";
import { isColorDark } from "./colorPickerUtils";
import type { ColorPickerType } from "./colorPickerUtils"; import type { ColorPickerType } from "./colorPickerUtils";
interface TopPicksProps { interface TopPicksProps {
@@ -96,63 +96,6 @@ export type ActiveColorPickerSectionAtomType =
export const activeColorPickerSectionAtom = export const activeColorPickerSectionAtom =
atom<ActiveColorPickerSectionAtomType>(null); atom<ActiveColorPickerSectionAtomType>(null);
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 (color === "transparent") {
return false;
}
// a string color (white etc) or any other format -> convert to rgb by way
// of creating a DOM node and retrieving the computeStyle
if (!color.startsWith("#")) {
const node = document.createElement("div");
node.style.color = color;
if (node.style.color) {
// making invisible so document doesn't reflow (hopefully).
// display=none works too, but supposedly not in all browsers
node.style.position = "absolute";
node.style.visibility = "hidden";
node.style.width = "0";
node.style.height = "0";
// needs to be in DOM else browser won't compute the style
document.body.appendChild(node);
const computedColor = getComputedStyle(node).color;
document.body.removeChild(node);
// computed style is in rgb() format
const rgb = computedColor
.replace(/^(rgb|rgba)\(/, "")
.replace(/\)$/, "")
.replace(/\s/g, "")
.split(",");
const r = parseInt(rgb[0]);
const g = parseInt(rgb[1]);
const b = parseInt(rgb[2]);
return calculateContrast(r, g, b) < threshold;
}
// invalid color -> assume it default to black
return true;
}
const r = parseInt(color.slice(1, 3), 16);
const g = parseInt(color.slice(3, 5), 16);
const b = parseInt(color.slice(5, 7), 16);
return calculateContrast(r, g, b) < threshold;
};
export type ColorPickerType = export type ColorPickerType =
| "canvasBackground" | "canvasBackground"
| "elementBackground" | "elementBackground"
@@ -1,4 +1,4 @@
@import "../../css/variables.module.scss"; @use "../../css/variables.module" as *;
$verticalBreakpoint: 861px; $verticalBreakpoint: 861px;
@@ -44,7 +44,6 @@ import { getSelectedElements } from "../../scene";
import { import {
LockedIcon, LockedIcon,
UnlockedIcon, UnlockedIcon,
clockIcon,
searchIcon, searchIcon,
boltIcon, boltIcon,
bucketFillIcon, bucketFillIcon,
@@ -52,6 +51,7 @@ import {
mermaidLogoIcon, mermaidLogoIcon,
brainIconThin, brainIconThin,
LibraryIcon, LibraryIcon,
historyCommandIcon,
} from "../icons"; } from "../icons";
import { SHAPES } from "../shapes"; import { SHAPES } from "../shapes";
@@ -928,7 +928,7 @@ function CommandPaletteInner({
marginLeft: "6px", marginLeft: "6px",
}} }}
> >
{clockIcon} {historyCommandIcon}
</div> </div>
</div> </div>
<CommandItem <CommandItem
@@ -1,4 +1,4 @@
@import "../css/variables.module.scss"; @use "../css/variables.module" as *;
.excalidraw { .excalidraw {
.confirm-dialog { .confirm-dialog {
@@ -1,4 +1,5 @@
@import "../css/variables.module.scss"; @use "sass:color";
@use "../css/variables.module" as *;
.excalidraw { .excalidraw {
.context-menu-popover { .context-menu-popover {
@@ -8,7 +9,7 @@
.context-menu { .context-menu {
position: relative; position: relative;
border-radius: 4px; border-radius: 4px;
box-shadow: 0 3px 10px transparentize($oc-black, 0.8); box-shadow: 0 3px 10px color.adjust(#000, $alpha: -0.8);
padding: 0; padding: 0;
list-style: none; list-style: none;
user-select: none; user-select: none;
@@ -49,7 +50,7 @@
&.dangerous { &.dangerous {
.context-menu-item__label { .context-menu-item__label {
color: $oc-red-7; color: $color-red-7;
} }
} }
@@ -73,7 +74,7 @@
.context-menu-item__label { .context-menu-item__label {
color: var(--popup-bg-color); color: var(--popup-bg-color);
} }
background-color: $oc-red-6; background-color: $color-red-6;
} }
} }
@@ -97,6 +98,6 @@
.context-menu-item-separator { .context-menu-item-separator {
border: none; border: none;
border-top: 1px solid $oc-gray-5; border-top: 1px solid $color-gray-5;
} }
} }
@@ -1,4 +1,4 @@
@import "../css//variables.module.scss"; @use "../css/variables.module" as *;
.excalidraw { .excalidraw {
.ConvertElementTypePopup { .ConvertElementTypePopup {
+1 -1
View File
@@ -1,4 +1,4 @@
@import "../css/variables.module.scss"; @use "../css/variables.module" as *;
.excalidraw { .excalidraw {
.Dialog { .Dialog {
@@ -1,4 +1,4 @@
@import "../css/variables.module.scss"; @use "../css/variables.module" as *;
.excalidraw { .excalidraw {
.ElementLinkDialog { .ElementLinkDialog {
@@ -59,7 +59,7 @@
} }
.ElementLinkDialog__remove { .ElementLinkDialog__remove {
color: $oc-red-9; color: $color-red-9;
margin-left: 1rem; margin-left: 1rem;
.ToolIcon__icon { .ToolIcon__icon {
@@ -68,7 +68,7 @@
} }
.ToolIcon__icon svg { .ToolIcon__icon svg {
color: $oc-red-6; color: $color-red-6;
} }
} }
} }
@@ -1,4 +1,4 @@
@import "../css/variables.module.scss"; @use "../css/variables.module" as *;
.excalidraw { .excalidraw {
.ExportDialog__preview { .ExportDialog__preview {
@@ -112,7 +112,7 @@
font-family: Cascadia; font-family: Cascadia;
font-size: 1.8em; font-size: 1.8em;
color: $oc-white; color: #fff;
&:hover { &:hover {
background-color: var(--button-color-darker); background-color: var(--button-color-darker);
@@ -1,4 +1,4 @@
@import "../css/variables.module.scss"; @use "../css/variables.module" as *;
@keyframes successStatusAnimation { @keyframes successStatusAnimation {
0% { 0% {
@@ -24,6 +24,14 @@
background-color: var(--back-color); background-color: var(--back-color);
border-color: var(--border-color); border-color: var(--border-color);
border-radius: 0.5rem;
border-width: 1px;
border-style: solid;
font-family: var(--font-family);
user-select: none;
&:hover { &:hover {
transition: all 150ms ease-out; transition: all 150ms ease-out;
} }
@@ -45,6 +53,7 @@
&.ExcButton--status-loading, &.ExcButton--status-loading,
&.ExcButton--status-success { &.ExcButton--status-success {
pointer-events: none; pointer-events: none;
background-color: var(--color-success);
.ExcButton__contents { .ExcButton__contents {
visibility: hidden; visibility: hidden;
@@ -52,7 +61,19 @@
} }
&[disabled] { &[disabled] {
pointer-events: none; cursor: not-allowed;
&.ExcButton--variant-filled,
&:hover {
--back-color: var(--color-surface-low) !important;
--text-color: var(--color-on-surface-variant) !important;
}
&.ExcButton--variant-outlined,
&.ExcButton--variant-icon {
--text-color: var(--color-on-surface-variant);
--border-color: var(--color-surface-high);
}
} }
&, &,
@@ -266,14 +287,6 @@
} }
} }
border-radius: 0.5rem;
border-width: 1px;
border-style: solid;
font-family: var(--font-family);
user-select: none;
&--size-large { &--size-large {
font-weight: 600; font-weight: 600;
font-size: 0.875rem; font-size: 0.875rem;
@@ -33,6 +33,7 @@ export type FilledButtonProps = {
fullWidth?: boolean; fullWidth?: boolean;
icon?: React.ReactNode; icon?: React.ReactNode;
disabled?: boolean;
}; };
export const FilledButton = forwardRef<HTMLButtonElement, FilledButtonProps>( export const FilledButton = forwardRef<HTMLButtonElement, FilledButtonProps>(
@@ -48,6 +49,7 @@ export const FilledButton = forwardRef<HTMLButtonElement, FilledButtonProps>(
fullWidth, fullWidth,
className, className,
status, status,
disabled,
}, },
ref, ref,
) => { ) => {
@@ -94,7 +96,7 @@ export const FilledButton = forwardRef<HTMLButtonElement, FilledButtonProps>(
type="button" type="button"
aria-label={label} aria-label={label}
ref={ref} ref={ref}
disabled={_status === "loading" || _status === "success"} disabled={disabled || _status === "loading" || _status === "success"}
> >
<div className="ExcButton__contents"> <div className="ExcButton__contents">
{_status === "loading" ? ( {_status === "loading" ? (
@@ -1,4 +1,4 @@
@import "../css/variables.module.scss"; @use "../css/variables.module" as *;
.excalidraw { .excalidraw {
.FixedSideContainer { .FixedSideContainer {
@@ -1,4 +1,4 @@
@import "../../css/variables.module.scss"; @use "../../css/variables.module" as *;
.excalidraw { .excalidraw {
.FontPicker__container { .FontPicker__container {
@@ -0,0 +1,31 @@
import { KEYS } from "@excalidraw/common";
import { Excalidraw } from "../..";
import { Keyboard } from "../../tests/helpers/ui";
import { act, render } from "../../tests/test-utils";
describe("FontPicker", () => {
it("should be able to open font picker", async () => {
(global as any).ResizeObserver =
(global as any).ResizeObserver ||
class ResizeObserver {
observe() {}
unobserve() {}
disconnect() {}
};
const { queryByTestId } = await render(
<Excalidraw handleKeyboardGlobally={true} />,
);
Keyboard.keyPress(KEYS.T);
const fontPickerTrigger = queryByTestId("font-family-show-fonts");
expect(fontPickerTrigger).not.toBeNull();
act(() => {
fontPickerTrigger!.click();
});
});
});
@@ -1,4 +1,4 @@
import * as Popover from "@radix-ui/react-popover"; import { Popover } from "radix-ui";
import clsx from "clsx"; import clsx from "clsx";
import React, { useCallback, useMemo } from "react"; import React, { useCallback, useMemo } from "react";
@@ -30,10 +30,12 @@ import { PropertiesPopover } from "../PropertiesPopover";
import { QuickSearch } from "../QuickSearch"; import { QuickSearch } from "../QuickSearch";
import { ScrollableList } from "../ScrollableList"; import { ScrollableList } from "../ScrollableList";
import DropdownMenuGroup from "../dropdownMenu/DropdownMenuGroup"; import DropdownMenuGroup from "../dropdownMenu/DropdownMenuGroup";
import DropdownMenuItem, { import {
DropDownMenuItemBadgeType, DropDownMenuItemBadgeType,
DropDownMenuItemBadge, DropDownMenuItemBadge,
} from "../dropdownMenu/DropdownMenuItem"; } from "../dropdownMenu/DropdownMenuItem";
import MenuItemContent from "../dropdownMenu/DropdownMenuItemContent";
import { getDropdownMenuItemClassName } from "../dropdownMenu/common";
import { import {
FontFamilyCodeIcon, FontFamilyCodeIcon,
FontFamilyHeadingIcon, FontFamilyHeadingIcon,
@@ -269,43 +271,74 @@ export const FontPickerList = React.memo(
[filteredFonts, sceneFamilies], [filteredFonts, sceneFamilies],
); );
const renderFont = (font: FontDescriptor, index: number) => ( const FontPickerListItem = ({
<DropdownMenuItem font,
key={font.value} order,
icon={font.icon} }: {
value={font.value} font: FontDescriptor;
order={index} order: number;
textStyle={{ }) => {
fontFamily: getFontFamilyString({ fontFamily: font.value }), const ref = useRef<HTMLButtonElement>(null);
}} const isHovered = font.value === hoveredFont?.value;
hovered={font.value === hoveredFont?.value} const isSelected = font.value === selectedFontFamily;
selected={font.value === selectedFontFamily}
// allow to tab between search and selected font useEffect(() => {
tabIndex={font.value === selectedFontFamily ? 0 : -1} if (!isHovered) {
onClick={(e) => { return;
wrappedOnSelect(Number(e.currentTarget.value)); }
}} if (order === 0) {
onMouseMove={() => { // scroll into the first item differently, so it's visible what is above (i.e. group title)
if (hoveredFont?.value !== font.value) { ref.current?.scrollIntoView?.({ block: "end" });
onHover(font.value); } else {
} ref.current?.scrollIntoView?.({ block: "nearest" });
}} }
> }, [isHovered, order]);
{font.text}
{font.badge && ( return (
<DropDownMenuItemBadge type={font.badge.type}> <button
{font.badge.placeholder} ref={ref}
</DropDownMenuItemBadge> type="button"
)} value={font.value}
</DropdownMenuItem> className={getDropdownMenuItemClassName("", isSelected, isHovered)}
); title={font.text}
// allow to tab between search and selected font
tabIndex={isSelected ? 0 : -1}
onClick={(e) => {
wrappedOnSelect(Number(e.currentTarget.value));
}}
onMouseMove={() => {
if (hoveredFont?.value !== font.value) {
onHover(font.value);
}
}}
>
<MenuItemContent
icon={font.icon}
badge={
font.badge && (
<DropDownMenuItemBadge type={font.badge.type}>
{font.badge.placeholder}
</DropDownMenuItemBadge>
)
}
textStyle={{
fontFamily: getFontFamilyString({ fontFamily: font.value }),
}}
>
{font.text}
</MenuItemContent>
</button>
);
};
const groups = []; const groups = [];
if (sceneFilteredFonts.length) { if (sceneFilteredFonts.length) {
groups.push( groups.push(
<DropdownMenuGroup title={t("fontList.sceneFonts")} key="group_1"> <DropdownMenuGroup title={t("fontList.sceneFonts")} key="group_1">
{sceneFilteredFonts.map(renderFont)} {sceneFilteredFonts.map((font, index) => (
<FontPickerListItem key={font.value} font={font} order={index} />
))}
</DropdownMenuGroup>, </DropdownMenuGroup>,
); );
} }
@@ -313,9 +346,13 @@ export const FontPickerList = React.memo(
if (availableFilteredFonts.length) { if (availableFilteredFonts.length) {
groups.push( groups.push(
<DropdownMenuGroup title={t("fontList.availableFonts")} key="group_2"> <DropdownMenuGroup title={t("fontList.availableFonts")} key="group_2">
{availableFilteredFonts.map((font, index) => {availableFilteredFonts.map((font, index) => (
renderFont(font, index + sceneFilteredFonts.length), <FontPickerListItem
)} key={font.value}
font={font}
order={index + sceneFilteredFonts.length}
/>
))}
</DropdownMenuGroup>, </DropdownMenuGroup>,
); );
} }
@@ -1,4 +1,4 @@
import * as Popover from "@radix-ui/react-popover"; import { Popover } from "radix-ui";
import { MOBILE_ACTION_BUTTON_BG } from "@excalidraw/common"; import { MOBILE_ACTION_BUTTON_BG } from "@excalidraw/common";
@@ -1,4 +1,4 @@
@import "../css/variables.module.scss"; @use "../css/variables.module" as *;
.excalidraw { .excalidraw {
.HelpDialog { .HelpDialog {
@@ -60,11 +60,12 @@
&__islands-container { &__islands-container {
display: grid; display: grid;
grid-column-gap: 1.5rem;
grid-row-gap: 2rem;
@media screen and (min-width: 1024px) { @media screen and (min-width: 1024px) {
grid-template-columns: 1fr 1fr; grid-template-columns: 1fr 1fr;
} }
grid-column-gap: 1.5rem;
grid-row-gap: 2rem;
} }
@media screen and (min-width: 1024px) { @media screen and (min-width: 1024px) {
@@ -1,4 +1,4 @@
@import "../css/variables.module.scss"; @use "../css/variables.module" as *;
// this is loosely based on the longest hint text // this is loosely based on the longest hint text
$wide-viewport-width: 1000px; $wide-viewport-width: 1000px;
@@ -1,10 +1,11 @@
@import "../css/variables.module.scss"; @use "sass:color";
@use "../css/variables.module" as *;
.excalidraw { .excalidraw {
.picker { .picker {
padding: 0.5rem; padding: 0.5rem;
background: var(--popup-bg-color); background: var(--popup-bg-color);
border: 0 solid transparentize($oc-white, 0.75); border: 0 solid color.adjust(#fff, $alpha: -0.75);
box-shadow: var(--shadow-island); box-shadow: var(--shadow-island);
border-radius: 4px; border-radius: 4px;
position: absolute; position: absolute;
@@ -87,7 +88,7 @@
} }
.picker-type-elementBackground .picker-keybinding { .picker-type-elementBackground .picker-keybinding {
color: $oc-white; color: #fff;
} }
.picker-swatch[aria-label="transparent"] .picker-keybinding { .picker-swatch[aria-label="transparent"] .picker-keybinding {
@@ -100,10 +101,10 @@
&.theme--dark { &.theme--dark {
.picker-type-elementBackground .picker-keybinding { .picker-type-elementBackground .picker-keybinding {
color: $oc-black; color: #000;
} }
.picker-swatch[aria-label="transparent"] .picker-keybinding { .picker-swatch[aria-label="transparent"] .picker-keybinding {
color: $oc-black; color: #000;
} }
} }
} }

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