Compare commits

...

58 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
185 changed files with 12102 additions and 1768 deletions
+6 -2
View File
@@ -266,7 +266,7 @@ const initializeScene = async (opts: {
repairBindings: true, repairBindings: true,
deleteInvisibleElements: true, deleteInvisibleElements: true,
}), }),
localDataState?.elements, scene.elements,
), ),
appState: restoreAppState( appState: restoreAppState(
imported.appState, imported.appState,
@@ -551,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) => {
+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 (
+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,
@@ -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);
}); });
+1 -1
View File
@@ -346,7 +346,7 @@ export const normalizeInputColor = (color: string): string | null => {
if (tc.isValid()) { if (tc.isValid()) {
// testing for `#` first fixes a bug on Electron (more specfically, an // testing for `#` first fixes a bug on Electron (more specfically, an
// Obsidian popout window), where a hex color without `#` is considered valid // Obsidian popout window), where a hex color without `#` is considered valid
if (tc.getFormat() === "hex" && !color.startsWith("#")) { if (["hex", "hex8"].includes(tc.getFormat()) && !color.startsWith("#")) {
return `#${color}`; return `#${color}`;
} }
return color; return color;
+2
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 = {
@@ -251,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
@@ -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;
};
+303 -186
View File
@@ -27,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,
@@ -113,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,
@@ -144,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;
}; };
@@ -258,7 +259,7 @@ const bindingStrategyForElbowArrowEndpointDragging = (
globalPoint, globalPoint,
elements, elements,
elementsMap, elementsMap,
(element) => maxBindingDistance_simple(zoom), maxBindingDistance_simple(zoom),
); );
const current = hit const current = hit
@@ -683,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 &&
@@ -710,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) {
@@ -790,6 +797,7 @@ const getBindingStrategyForDraggingBindingElementEndpoints_simple = (
hit, hit,
startDragged ? "start" : "end", startDragged ? "start" : "end",
elementsMap, elementsMap,
appState.zoom,
) || globalPoint, ) || globalPoint,
} }
: { mode: null }; : { mode: null };
@@ -799,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,
@@ -820,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,
@@ -1086,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;
} }
@@ -1158,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 = (
@@ -1171,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,
@@ -1292,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 ??
@@ -1307,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,
@@ -1322,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,
@@ -1476,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
@@ -1496,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 (
@@ -1505,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,
); );
@@ -1516,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 &&
@@ -1524,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,
); );
@@ -1535,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,
); );
@@ -1584,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>,
@@ -1598,7 +1735,7 @@ 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 ||
@@ -1613,152 +1750,136 @@ export const updateBoundPoint = (
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,
); );
}; };
@@ -1808,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,
@@ -1816,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(
+63 -5
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;
} }
@@ -290,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)
@@ -306,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);
@@ -323,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) {
@@ -348,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
* *
+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 -37
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,
}, },
@@ -2076,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;
@@ -2123,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,
@@ -2165,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;
@@ -2191,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;
@@ -2231,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 =
@@ -2279,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];
@@ -2302,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,
@@ -2310,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;
@@ -2332,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,
+115 -21
View File
@@ -22,7 +22,9 @@ import {
isRTL, isRTL,
getVerticalOffset, getVerticalOffset,
invariant, invariant,
isTransparent,
applyDarkModeFilter, applyDarkModeFilter,
isSafari,
} from "@excalidraw/common"; } from "@excalidraw/common";
import type { import type {
@@ -77,6 +79,7 @@ import type {
ExcalidrawFrameLikeElement, ExcalidrawFrameLikeElement,
NonDeletedSceneElementsMap, NonDeletedSceneElementsMap,
ElementsMap, ElementsMap,
ExcalidrawFrameElement,
} from "./types"; } from "./types";
import type { RoughCanvas } from "roughjs/bin/canvas"; import type { RoughCanvas } from "roughjs/bin/canvas";
@@ -360,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);
@@ -443,13 +447,6 @@ const drawElementOnCanvas = (
? cacheEntry?.image ? cacheEntry?.image
: undefined; : undefined;
const shouldInvertImage =
renderConfig.theme === THEME.DARK &&
cacheEntry?.mimeType === MIME_TYPES.svg;
if (shouldInvertImage) {
context.filter = DARK_THEME_FILTER;
}
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();
@@ -472,19 +469,78 @@ 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(); context.restore();
break; break;
@@ -723,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,
@@ -754,7 +849,6 @@ 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 = context.strokeStyle =
+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);
+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;
}; };
+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;
+47
View File
@@ -38,6 +38,53 @@ describe("check rotated elements can be hit:", () => {
}); });
}); });
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", () => { describe("hitElementItself cache", () => {
beforeEach(async () => { beforeEach(async () => {
// reset cache // reset cache
+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);
});
});
@@ -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,
+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,
@@ -108,7 +108,6 @@ export const actionFinalize = register<FormData>({
return map; return map;
}, new Map()) ?? new Map(); }, new Map()) ?? new Map();
bindOrUnbindBindingElement( bindOrUnbindBindingElement(
element, element,
draggedPoints, draggedPoints,
@@ -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.");
} }
}; };
+6 -2
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),
)
); );
}; };
+364 -128
View File
@@ -11,7 +11,6 @@ import {
pointDistance, pointDistance,
vector, vector,
pointRotateRads, pointRotateRads,
vectorScale,
vectorFromPoint, vectorFromPoint,
vectorSubtract, vectorSubtract,
vectorDot, vectorDot,
@@ -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";
@@ -347,6 +354,7 @@ import {
import { exportCanvas, loadFromBlob } from "../data"; import { exportCanvas, loadFromBlob } from "../data";
import Library, { distributeLibraryItemsOnSquareGrid } from "../data/library"; import Library, { distributeLibraryItemsOnSquareGrid } from "../data/library";
import { restoreAppState, 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(
@@ -2221,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,
@@ -2709,6 +2819,7 @@ class App extends React.Component<AppProps, AppState> {
const restoredElements = restoreElements(initialData?.elements, null, { const restoredElements = restoreElements(initialData?.elements, null, {
repairBindings: true, repairBindings: true,
deleteInvisibleElements: true, deleteInvisibleElements: true,
schemaMigrationRegistry: this.schemaMigrationRegistry,
}); });
let restoredAppState = restoreAppState(initialData?.appState, null); let restoredAppState = restoreAppState(initialData?.appState, null);
const activeTool = restoredAppState.activeTool; const activeTool = restoredAppState.activeTool;
@@ -3120,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();
@@ -3623,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);
@@ -4734,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)) {
@@ -4751,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)) {
@@ -4931,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" });
@@ -5024,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))
@@ -5034,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,
@@ -5055,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(
@@ -6375,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({
@@ -6410,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", {
@@ -6564,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")
@@ -6703,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)) &&
@@ -6735,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" ||
@@ -6760,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 {
@@ -6923,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);
} }
@@ -7383,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) {
@@ -7423,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,
@@ -7844,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(
@@ -8656,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,
@@ -8674,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,
}; };
@@ -8936,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
@@ -8991,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,
@@ -9229,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,
@@ -9279,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,
@@ -9778,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);
@@ -9859,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 {
@@ -9900,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
) { ) {
@@ -9910,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,
@@ -10684,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);
}
}); });
} }
@@ -11217,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,
@@ -11263,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) => ({
@@ -11333,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,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 { useRef, useEffect } from "react"; import { useRef, useEffect } from "react";
@@ -53,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;
@@ -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,45 +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));
}}
onMouseMove={() => {
if (hoveredFont?.value !== font.value) {
onHover(font.value);
}
}}
badge={
font.badge && (
<DropDownMenuItemBadge type={font.badge.type}>
{font.badge.placeholder}
</DropDownMenuItemBadge>
)
} }
> if (order === 0) {
{font.text} // scroll into the first item differently, so it's visible what is above (i.e. group title)
</DropdownMenuItem> ref.current?.scrollIntoView?.({ block: "end" });
); } else {
ref.current?.scrollIntoView?.({ block: "nearest" });
}
}, [isHovered, order]);
return (
<button
ref={ref}
type="button"
value={font.value}
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>,
); );
} }
@@ -315,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 * as Popover from "@radix-ui/react-popover"; import { Popover } from "radix-ui";
import clsx from "clsx"; import clsx from "clsx";
import React, { useEffect } from "react"; import React, { useEffect } from "react";
@@ -126,9 +126,10 @@
.dropdown-menu-container { .dropdown-menu-container {
width: 196px; width: 196px;
box-shadow: var(--library-dropdown-shadow);
border-radius: var(--border-radius-lg); border-radius: var(--border-radius-lg);
padding: 0.25rem 0.5rem; padding: 0.25rem 0.5rem;
--box-shadow: var(--library-dropdown-shadow);
} }
} }
@@ -375,7 +375,7 @@ export const MobileToolBar = ({
)} )}
{/* Other Shapes */} {/* Other Shapes */}
<DropdownMenu open={isOtherShapesMenuOpen} placement="top"> <DropdownMenu open={isOtherShapesMenuOpen}>
<DropdownMenu.Trigger <DropdownMenu.Trigger
className={clsx( className={clsx(
"App-toolbar__extra-tools-trigger App-toolbar__extra-tools-trigger--mobile", "App-toolbar__extra-tools-trigger App-toolbar__extra-tools-trigger--mobile",
@@ -403,6 +403,7 @@ export const MobileToolBar = ({
onClickOutside={() => setIsOtherShapesMenuOpen(false)} onClickOutside={() => setIsOtherShapesMenuOpen(false)}
onSelect={() => setIsOtherShapesMenuOpen(false)} onSelect={() => setIsOtherShapesMenuOpen(false)}
className="App-toolbar__extra-tools-dropdown" className="App-toolbar__extra-tools-dropdown"
align="start"
> >
{!showTextToolOutside && ( {!showTextToolOutside && (
<DropdownMenu.Item <DropdownMenu.Item
@@ -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, { type ReactNode } from "react"; import React, { type ReactNode } from "react";
@@ -1,4 +1,4 @@
import * as RadixTabs from "@radix-ui/react-tabs"; import { Tabs as RadixTabs } from "radix-ui";
import type { SidebarTabName } from "../../types"; import type { SidebarTabName } from "../../types";
@@ -1,4 +1,4 @@
import * as RadixTabs from "@radix-ui/react-tabs"; import { Tabs as RadixTabs } from "radix-ui";
import type { SidebarTabName } from "../../types"; import type { SidebarTabName } from "../../types";
@@ -1,4 +1,4 @@
import * as RadixTabs from "@radix-ui/react-tabs"; import { Tabs as RadixTabs } from "radix-ui";
export const SidebarTabTriggers = ({ export const SidebarTabTriggers = ({
children, children,
@@ -1,4 +1,4 @@
import * as RadixTabs from "@radix-ui/react-tabs"; import { Tabs as RadixTabs } from "radix-ui";
import { useUIAppState } from "../../context/ui-appState"; import { useUIAppState } from "../../context/ui-appState";
import { useExcalidrawSetAppState } from "../App"; import { useExcalidrawSetAppState } from "../App";
@@ -135,7 +135,7 @@ describe("binding with linear elements", () => {
) as HTMLInputElement; ) as HTMLInputElement;
expect(linear.startBinding).not.toBe(null); expect(linear.startBinding).not.toBe(null);
expect(inputX).not.toBeNull(); expect(inputX).not.toBeNull();
UI.updateInput(inputX, String("186")); UI.updateInput(inputX, String("184"));
expect(linear.startBinding).not.toBe(null); expect(linear.startBinding).not.toBe(null);
}); });
@@ -1,4 +1,4 @@
@import "../../../css/variables.module.scss"; @use "../../../css/variables.module.scss" as *;
$verticalBreakpoint: 861px; $verticalBreakpoint: 861px;
@@ -48,14 +48,14 @@ $verticalBreakpoint: 861px;
} }
} }
&__empty-state { &__welcome-screen {
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
height: 100%; height: 100%;
min-height: 200px; min-height: 200px;
&-content { &__welcome-message {
text-align: center; text-align: center;
h3 { h3 {
@@ -52,11 +52,7 @@ export const ChatHistoryMenu = ({
> >
{historyIcon} {historyIcon}
</DropdownMenu.Trigger> </DropdownMenu.Trigger>
<DropdownMenu.Content <DropdownMenu.Content onClickOutside={onClose} onSelect={onClose}>
onClickOutside={onClose}
onSelect={onClose}
placement="bottom"
>
<> <>
{savedChats.map((chat) => ( {savedChats.map((chat) => (
<DropdownMenu.ItemCustom <DropdownMenu.ItemCustom
@@ -6,6 +6,8 @@ import { InlineIcon } from "../../InlineIcon";
import { t } from "../../../i18n"; import { t } from "../../../i18n";
import { TTDWelcomeMessage } from "../TTDWelcomeMessage";
import { ChatMessage } from "./ChatMessage"; import { ChatMessage } from "./ChatMessage";
import type { TChat, TTTDDialog } from "../types"; import type { TChat, TTTDDialog } from "../types";
@@ -20,13 +22,13 @@ export const ChatInterface = ({
onGenerate, onGenerate,
isGenerating, isGenerating,
rateLimits, rateLimits,
placeholder,
onAbort, onAbort,
onMermaidTabClick, onMermaidTabClick,
onAiRepairClick, onAiRepairClick,
onDeleteMessage, onDeleteMessage,
onInsertMessage, onInsertMessage,
onRetry, onRetry,
renderWelcomeScreen,
renderWarning, renderWarning,
}: { }: {
chatId: string; chatId: string;
@@ -41,17 +43,13 @@ export const ChatInterface = ({
} | null; } | null;
onViewAsMermaid?: () => void; onViewAsMermaid?: () => void;
generatedResponse?: string | null; generatedResponse?: string | null;
placeholder: {
title: string;
description: string;
hint: string;
};
onAbort?: () => void; onAbort?: () => void;
onMermaidTabClick?: (message: TChat.ChatMessage) => void; onMermaidTabClick?: (message: TChat.ChatMessage) => void;
onAiRepairClick?: (message: TChat.ChatMessage) => void; onAiRepairClick?: (message: TChat.ChatMessage) => void;
onDeleteMessage?: (messageId: string) => void; onDeleteMessage?: (messageId: string) => void;
onInsertMessage?: (message: TChat.ChatMessage) => void; onInsertMessage?: (message: TChat.ChatMessage) => void;
onRetry?: (message: TChat.ChatMessage) => void; onRetry?: (message: TChat.ChatMessage) => void;
renderWelcomeScreen?: TTTDDialog.renderWelcomeScreen;
renderWarning?: TTTDDialog.renderWarning; renderWarning?: TTTDDialog.renderWarning;
}) => { }) => {
const messagesEndRef = useRef<HTMLDivElement>(null); const messagesEndRef = useRef<HTMLDivElement>(null);
@@ -113,12 +111,12 @@ export const ChatInterface = ({
<div className="chat-interface"> <div className="chat-interface">
<div className="chat-interface__messages"> <div className="chat-interface__messages">
{messages.length === 0 ? ( {messages.length === 0 ? (
<div className="chat-interface__empty-state"> <div className="chat-interface__welcome-screen">
<div className="chat-interface__empty-state-content"> {renderWelcomeScreen ? (
<h3>{placeholder.title}</h3> renderWelcomeScreen({ rateLimits: rateLimits ?? null })
<p>{placeholder.description}</p> ) : (
<p>{placeholder.hint}</p> <TTDWelcomeMessage />
</div> )}
</div> </div>
) : ( ) : (
messages.map((message, index) => ( messages.map((message, index) => (
@@ -40,6 +40,7 @@ export const TTDChatPanel = ({
onInsertMessage, onInsertMessage,
onRetry, onRetry,
onViewAsMermaid, onViewAsMermaid,
renderWelcomeScreen,
renderWarning, renderWarning,
}: { }: {
chatId: string; chatId: string;
@@ -68,6 +69,7 @@ export const TTDChatPanel = ({
onViewAsMermaid: () => void; onViewAsMermaid: () => void;
renderWelcomeScreen?: TTTDDialog.renderWelcomeScreen;
renderWarning?: TTTDDialog.renderWarning; renderWarning?: TTTDDialog.renderWarning;
}) => { }) => {
const [rateLimits] = useAtom(rateLimitsAtom); const [rateLimits] = useAtom(rateLimitsAtom);
@@ -151,11 +153,7 @@ export const TTDChatPanel = ({
onInsertMessage={onInsertMessage} onInsertMessage={onInsertMessage}
onRetry={onRetry} onRetry={onRetry}
rateLimits={rateLimits} rateLimits={rateLimits}
placeholder={{ renderWelcomeScreen={renderWelcomeScreen}
title: t("chat.placeholder.title"),
description: t("chat.placeholder.description"),
hint: t("chat.placeholder.hint"),
}}
renderWarning={renderWarning} renderWarning={renderWarning}
/> />
</TTDDialogPanel> </TTDDialogPanel>
@@ -15,6 +15,8 @@ import { TTDDialogTab } from "./TTDDialogTab";
import "./TTDDialog.scss"; import "./TTDDialog.scss";
import { TTDWelcomeMessage } from "./TTDWelcomeMessage";
import type { import type {
MermaidToExcalidrawLibProps, MermaidToExcalidrawLibProps,
TTDPersistenceAdapter, TTDPersistenceAdapter,
@@ -25,6 +27,7 @@ export const TTDDialog = (
props: props:
| { | {
onTextSubmit: TTTDDialog.onTextSubmit; onTextSubmit: TTTDDialog.onTextSubmit;
renderWelcomeScreen?: TTTDDialog.renderWelcomeScreen;
renderWarning?: TTTDDialog.renderWarning; renderWarning?: TTTDDialog.renderWarning;
persistenceAdapter: TTDPersistenceAdapter; persistenceAdapter: TTDPersistenceAdapter;
} }
@@ -39,6 +42,8 @@ export const TTDDialog = (
return <TTDDialogBase {...props} tab={appState.openDialog.tab} />; return <TTDDialogBase {...props} tab={appState.openDialog.tab} />;
}; };
TTDDialog.WelcomeMessage = TTDWelcomeMessage;
/** /**
* Text to diagram (TTD) dialog * Text to diagram (TTD) dialog
*/ */
@@ -54,6 +59,7 @@ const TTDDialogBase = withInternalFallback(
onTextSubmit( onTextSubmit(
props: TTTDDialog.OnTextSubmitProps, props: TTTDDialog.OnTextSubmitProps,
): Promise<TTTDDialog.OnTextSubmitRetValue>; ): Promise<TTTDDialog.OnTextSubmitRetValue>;
renderWelcomeScreen?: TTTDDialog.renderWelcomeScreen;
renderWarning?: TTTDDialog.renderWarning; renderWarning?: TTTDDialog.renderWarning;
persistenceAdapter: TTDPersistenceAdapter; persistenceAdapter: TTDPersistenceAdapter;
} }
@@ -110,6 +116,7 @@ const TTDDialogBase = withInternalFallback(
<TextToDiagram <TextToDiagram
mermaidToExcalidrawLib={mermaidToExcalidrawLib} mermaidToExcalidrawLib={mermaidToExcalidrawLib}
onTextSubmit={rest.onTextSubmit} onTextSubmit={rest.onTextSubmit}
renderWelcomeScreen={rest.renderWelcomeScreen}
renderWarning={rest.renderWarning} renderWarning={rest.renderWarning}
persistenceAdapter={rest.persistenceAdapter} persistenceAdapter={rest.persistenceAdapter}
/> />
@@ -1,4 +1,4 @@
import * as RadixTabs from "@radix-ui/react-tabs"; import { Tabs as RadixTabs } from "radix-ui";
export const TTDDialogTab = ({ export const TTDDialogTab = ({
tab, tab,
@@ -1,4 +1,4 @@
import * as RadixTabs from "@radix-ui/react-tabs"; import { Tabs as RadixTabs } from "radix-ui";
export const TTDDialogTabTrigger = ({ export const TTDDialogTabTrigger = ({
children, children,
@@ -1,4 +1,4 @@
import * as RadixTabs from "@radix-ui/react-tabs"; import { Tabs as RadixTabs } from "radix-ui";
export const TTDDialogTabTriggers = ({ export const TTDDialogTabTriggers = ({
children, children,
@@ -1,4 +1,4 @@
import * as RadixTabs from "@radix-ui/react-tabs"; import { Tabs as RadixTabs } from "radix-ui";
import { useRef } from "react"; import { useRef } from "react";
import { isMemberOf } from "@excalidraw/common"; import { isMemberOf } from "@excalidraw/common";
@@ -0,0 +1,11 @@
import { t } from "../../i18n";
export const TTDWelcomeMessage = () => {
return (
<div className="chat-interface__welcome-screen__welcome-message">
<h3>{t("chat.placeholder.title")}</h3>
<p>{t("chat.placeholder.description")}</p>
<p>{t("chat.placeholder.hint")}</p>
</div>
);
};
@@ -35,6 +35,7 @@ import type {
const TextToDiagramContent = ({ const TextToDiagramContent = ({
mermaidToExcalidrawLib, mermaidToExcalidrawLib,
onTextSubmit, onTextSubmit,
renderWelcomeScreen,
renderWarning, renderWarning,
persistenceAdapter, persistenceAdapter,
}: { }: {
@@ -42,6 +43,7 @@ const TextToDiagramContent = ({
onTextSubmit: ( onTextSubmit: (
props: TTTDDialog.OnTextSubmitProps, props: TTTDDialog.OnTextSubmitProps,
) => Promise<TTTDDialog.OnTextSubmitRetValue>; ) => Promise<TTTDDialog.OnTextSubmitRetValue>;
renderWelcomeScreen?: TTTDDialog.renderWelcomeScreen;
renderWarning?: TTTDDialog.renderWarning; renderWarning?: TTTDDialog.renderWarning;
persistenceAdapter: TTDPersistenceAdapter; persistenceAdapter: TTDPersistenceAdapter;
}) => { }) => {
@@ -220,6 +222,7 @@ const TextToDiagramContent = ({
onRetry={handleRetry} onRetry={handleRetry}
onViewAsMermaid={onViewAsMermaid} onViewAsMermaid={onViewAsMermaid}
renderWarning={renderWarning} renderWarning={renderWarning}
renderWelcomeScreen={renderWelcomeScreen}
/> />
{showPreview && ( {showPreview && (
<TTDPreviewPanel <TTDPreviewPanel
@@ -237,6 +240,7 @@ const TextToDiagramContent = ({
export const TextToDiagram = ({ export const TextToDiagram = ({
mermaidToExcalidrawLib, mermaidToExcalidrawLib,
onTextSubmit, onTextSubmit,
renderWelcomeScreen,
renderWarning, renderWarning,
persistenceAdapter, persistenceAdapter,
}: { }: {
@@ -244,6 +248,7 @@ export const TextToDiagram = ({
onTextSubmit( onTextSubmit(
props: TTTDDialog.OnTextSubmitProps, props: TTTDDialog.OnTextSubmitProps,
): Promise<TTTDDialog.OnTextSubmitRetValue>; ): Promise<TTTDDialog.OnTextSubmitRetValue>;
renderWelcomeScreen?: TTTDDialog.renderWelcomeScreen;
renderWarning?: TTTDDialog.renderWarning; renderWarning?: TTTDDialog.renderWarning;
persistenceAdapter: TTDPersistenceAdapter; persistenceAdapter: TTDPersistenceAdapter;
}) => { }) => {
@@ -251,6 +256,7 @@ export const TextToDiagram = ({
<TextToDiagramContent <TextToDiagramContent
mermaidToExcalidrawLib={mermaidToExcalidrawLib} mermaidToExcalidrawLib={mermaidToExcalidrawLib}
onTextSubmit={onTextSubmit} onTextSubmit={onTextSubmit}
renderWelcomeScreen={renderWelcomeScreen}
renderWarning={renderWarning} renderWarning={renderWarning}
persistenceAdapter={persistenceAdapter} persistenceAdapter={persistenceAdapter}
/> />
@@ -116,4 +116,9 @@ export namespace TTTDDialog {
export type renderWarning = ( export type renderWarning = (
chatMessage: TChat.ChatMessage, chatMessage: TChat.ChatMessage,
) => React.ReactNode | undefined; ) => React.ReactNode | undefined;
export type renderWelcomeScreen = (props: {
/** null if not rate limit data currently available */
rateLimits: RateLimits | null;
}) => React.ReactNode | undefined;
} }
@@ -3,7 +3,7 @@ import clsx from "clsx";
import { capitalizeString } from "@excalidraw/common"; import { capitalizeString } from "@excalidraw/common";
import * as Popover from "@radix-ui/react-popover"; import { Popover } from "radix-ui";
import { trackEvent } from "../analytics"; import { trackEvent } from "../analytics";
+1 -1
View File
@@ -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, { useLayoutEffect } from "react"; import React, { useLayoutEffect } from "react";
@@ -2,24 +2,35 @@
.excalidraw { .excalidraw {
.dropdown-menu { .dropdown-menu {
position: absolute;
top: 2.5rem;
margin-top: 0.5rem;
max-width: 16rem; max-width: 16rem;
z-index: 1;
&--placement-top { &--placement-top {
top: auto;
bottom: 100%;
margin-top: 0;
margin-bottom: 0.5rem; margin-bottom: 0.5rem;
} }
&__submenu-trigger {
&[aria-expanded="true"] {
.dropdown-menu-item {
background-color: var(--button-hover-bg);
}
}
}
&__submenu-trigger-icon {
margin-left: auto;
opacity: 0.5;
svg g {
stroke-width: 2;
}
}
&--mobile { &--mobile {
width: 100%; width: 100%;
row-gap: 0.75rem; row-gap: 0.75rem;
// When main menu is in the top toolbar, position relative to trigger // When main menu is in the top toolbar, position relative to trigger
&.main-menu-dropdown { &.main-menu {
min-width: 232px; min-width: 232px;
margin-top: 0; margin-top: 0;
margin-bottom: 0; margin-bottom: 0;
@@ -32,10 +43,6 @@
.dropdown-menu-container { .dropdown-menu-container {
padding: 8px 8px; padding: 8px 8px;
box-sizing: border-box; box-sizing: border-box;
max-height: calc(
100svh - var(--editor-container-padding) * 2 - 2.25rem
);
box-shadow: var(--shadow-island);
border-radius: var(--border-radius-lg); border-radius: var(--border-radius-lg);
position: relative; position: relative;
transition: box-shadow 0.5s ease-in-out; transition: box-shadow 0.5s ease-in-out;
@@ -51,14 +58,25 @@
.dropdown-menu-container { .dropdown-menu-container {
background-color: var(--island-bg-color); background-color: var(--island-bg-color);
overflow-y: auto; overflow-y: auto;
--gap: 2; display: flex;
flex-direction: column;
gap: 1px;
box-shadow: var(--box-shadow, var(--shadow-island));
max-height: calc(100svh - var(--editor-container-padding) * 2 - 2.25rem);
@at-root .excalidraw.theme--dark#{&} {
box-shadow: var(--box-shadow, var(--shadow-island)),
0 0 0 1px rgba(0, 0, 0, 0.15);
}
} }
.dropdown-menu-item-base { .dropdown-menu-item-base {
display: flex; display: flex;
column-gap: 0.625rem; column-gap: 0.625rem;
padding: 0 0.5rem;
font-size: 0.875rem; font-size: 0.875rem;
color: var(--color-on-surface); color: var(--color-on-surface);
width: 100%; width: 100%;
@@ -115,11 +133,9 @@
.dropdown-menu-item { .dropdown-menu-item {
height: 2rem; height: 2rem;
margin: 1px;
padding: 0 0.5rem; padding: 0 0.5rem;
width: calc(100% - 2px);
background-color: transparent; background-color: transparent;
border: 1px solid transparent; border: none;
align-items: center; align-items: center;
cursor: pointer; cursor: pointer;
border-radius: var(--border-radius-md); border-radius: var(--border-radius-md);
@@ -162,7 +178,7 @@
&:active { &:active {
background-color: var(--button-hover-bg); background-color: var(--button-hover-bg);
border-color: var(--color-brand-active); box-shadow: 0 0 0 1px var(--color-brand-active);
} }
svg { svg {
@@ -223,7 +239,7 @@
} }
&:active { &:active {
border-color: var(--color-primary); box-shadow: 0 0 0 1px var(--color-primary);
} }
&[disabled] { &[disabled] {
@@ -235,7 +251,7 @@
} }
&:active { &:active {
border-color: transparent; box-shadow: none;
} }
@at-root .excalidraw.theme--dark#{&} { @at-root .excalidraw.theme--dark#{&} {
@@ -1,12 +1,18 @@
import React from "react"; import React from "react";
import { DropdownMenu as DropdownMenuPrimitive } from "radix-ui";
import { CLASSES } from "@excalidraw/common";
import DropdownMenuContent from "./DropdownMenuContent"; import DropdownMenuContent from "./DropdownMenuContent";
import DropdownMenuGroup from "./DropdownMenuGroup"; import DropdownMenuGroup from "./DropdownMenuGroup";
import DropdownMenuItem from "./DropdownMenuItem"; import DropdownMenuItem from "./DropdownMenuItem";
import DropdownMenuItemCustom from "./DropdownMenuItemCustom"; import DropdownMenuItemCustom from "./DropdownMenuItemCustom";
import DropdownMenuItemLink from "./DropdownMenuItemLink"; import DropdownMenuItemLink from "./DropdownMenuItemLink";
import MenuSeparator from "./DropdownMenuSeparator"; import MenuSeparator from "./DropdownMenuSeparator";
import DropdownMenuSub from "./DropdownMenuSub";
import DropdownMenuTrigger from "./DropdownMenuTrigger"; import DropdownMenuTrigger from "./DropdownMenuTrigger";
import DropdownMenuItemCheckbox from "./DropdownMenuItemCheckbox";
import { import {
getMenuContentComponent, getMenuContentComponent,
getMenuTriggerComponent, getMenuTriggerComponent,
@@ -17,44 +23,47 @@ import "./DropdownMenu.scss";
const DropdownMenu = ({ const DropdownMenu = ({
children, children,
open, open,
placement,
}: { }: {
children?: React.ReactNode; children?: React.ReactNode;
open: boolean; open: boolean;
placement?: "top" | "bottom";
}) => { }) => {
const MenuTriggerComp = getMenuTriggerComponent(children); const MenuTriggerComp = getMenuTriggerComponent(children);
const MenuContentComp = getMenuContentComponent(children); const MenuContentComp = getMenuContentComponent(children);
const MenuContentWithState =
// clone the MenuContentComp to pass the placement prop
const MenuContentCompWithPlacement =
MenuContentComp && React.isValidElement(MenuContentComp) MenuContentComp && React.isValidElement(MenuContentComp)
? React.cloneElement(MenuContentComp as React.ReactElement<any>, { ? React.cloneElement(
placement, MenuContentComp as React.ReactElement<
}) React.ComponentProps<typeof DropdownMenuContent>
>,
{ open },
)
: MenuContentComp; : MenuContentComp;
return ( return (
<div <DropdownMenuPrimitive.Root open={open} modal={false}>
className="dropdown-menu-container" <div
style={{ className={CLASSES.DROPDOWN_MENU_EVENT_WRAPPER}
// remove this div from box layout style={{
display: "contents", // remove this div from box layout
}} display: "contents",
> }}
{MenuTriggerComp} >
{open && MenuContentCompWithPlacement} {MenuTriggerComp}
</div> {MenuContentWithState}
</div>
</DropdownMenuPrimitive.Root>
); );
}; };
DropdownMenu.Trigger = DropdownMenuTrigger; DropdownMenu.Trigger = DropdownMenuTrigger;
DropdownMenu.Content = DropdownMenuContent; DropdownMenu.Content = DropdownMenuContent;
DropdownMenu.Item = DropdownMenuItem; DropdownMenu.Item = DropdownMenuItem;
DropdownMenu.ItemCheckbox = DropdownMenuItemCheckbox;
DropdownMenu.ItemLink = DropdownMenuItemLink; DropdownMenu.ItemLink = DropdownMenuItemLink;
DropdownMenu.ItemCustom = DropdownMenuItemCustom; DropdownMenu.ItemCustom = DropdownMenuItemCustom;
DropdownMenu.Group = DropdownMenuGroup; DropdownMenu.Group = DropdownMenuGroup;
DropdownMenu.Separator = MenuSeparator; DropdownMenu.Separator = MenuSeparator;
DropdownMenu.Sub = DropdownMenuSub;
export default DropdownMenu; export default DropdownMenu;
@@ -1,7 +1,9 @@
import clsx from "clsx"; import clsx from "clsx";
import React, { useEffect, useRef } from "react"; import React, { useCallback, useEffect, useRef } from "react";
import { EVENT, KEYS } from "@excalidraw/common"; import { CLASSES, EVENT, KEYS } from "@excalidraw/common";
import { DropdownMenu as DropdownMenuPrimitive } from "radix-ui";
import { useOutsideClick } from "../../hooks/useOutsideClick"; import { useOutsideClick } from "../../hooks/useOutsideClick";
import { useStable } from "../../hooks/useStable"; import { useStable } from "../../hooks/useStable";
@@ -16,8 +18,9 @@ const MenuContent = ({
onClickOutside, onClickOutside,
className = "", className = "",
onSelect, onSelect,
open = true,
align = "end",
style, style,
placement = "bottom",
}: { }: {
children?: React.ReactNode; children?: React.ReactNode;
onClickOutside?: () => void; onClickOutside?: () => void;
@@ -26,26 +29,36 @@ const MenuContent = ({
* Called when any menu item is selected (clicked on). * Called when any menu item is selected (clicked on).
*/ */
onSelect?: (event: Event) => void; onSelect?: (event: Event) => void;
open?: boolean;
style?: React.CSSProperties; style?: React.CSSProperties;
placement?: "top" | "bottom"; align?: "start" | "center" | "end";
}) => { }) => {
const editorInterface = useEditorInterface(); const editorInterface = useEditorInterface();
const menuRef = useRef<HTMLDivElement>(null); const menuRef = useRef<HTMLDivElement>(null);
const callbacksRef = useStable({ onClickOutside }); const callbacksRef = useStable({ onClickOutside });
useOutsideClick(menuRef, (event) => { useOutsideClick(
// prevents closing if clicking on the trigger button menuRef,
if ( useCallback(
!menuRef.current (event) => {
?.closest(".dropdown-menu-container") // prevents closing if clicking on the trigger button
?.contains(event.target) if (
) { !menuRef.current
callbacksRef.onClickOutside?.(); ?.closest(`.${CLASSES.DROPDOWN_MENU_EVENT_WRAPPER}`)
} ?.contains(event.target)
}); ) {
callbacksRef.onClickOutside?.();
}
},
[callbacksRef],
),
);
useEffect(() => { useEffect(() => {
if (!open) {
return;
}
const onKeyDown = (event: KeyboardEvent) => { const onKeyDown = (event: KeyboardEvent) => {
if (event.key === KEYS.ESCAPE) { if (event.key === KEYS.ESCAPE) {
event.stopImmediatePropagation(); event.stopImmediatePropagation();
@@ -63,35 +76,33 @@ const MenuContent = ({
return () => { return () => {
document.removeEventListener(EVENT.KEYDOWN, onKeyDown, option); document.removeEventListener(EVENT.KEYDOWN, onKeyDown, option);
}; };
}, [callbacksRef]); }, [callbacksRef, open]);
const classNames = clsx(`dropdown-menu ${className}`, { const classNames = clsx(`dropdown-menu ${className}`, {
"dropdown-menu--mobile": editorInterface.formFactor === "phone", "dropdown-menu--mobile": editorInterface.formFactor === "phone",
"dropdown-menu--placement-top": placement === "top",
}).trim(); }).trim();
return ( return (
<DropdownMenuContentPropsContext.Provider value={{ onSelect }}> <DropdownMenuContentPropsContext.Provider value={{ onSelect }}>
<div <DropdownMenuPrimitive.Content
ref={menuRef} ref={menuRef}
className={classNames} className={classNames}
style={style} style={style}
data-testid="dropdown-menu" data-testid="dropdown-menu"
align={align}
sideOffset={8}
onCloseAutoFocus={(event: Event) => event.preventDefault()}
> >
{/* the zIndex ensures this menu has higher stacking order, {/* the zIndex ensures this menu has higher stacking order,
see https://github.com/excalidraw/excalidraw/pull/1445 */} see https://github.com/excalidraw/excalidraw/pull/1445 */}
{editorInterface.formFactor === "phone" ? ( {editorInterface.formFactor === "phone" ? (
<Stack.Col className="dropdown-menu-container">{children}</Stack.Col> <Stack.Col className="dropdown-menu-container">{children}</Stack.Col>
) : ( ) : (
<Island <Island className="dropdown-menu-container" padding={2}>
className="dropdown-menu-container"
padding={2}
style={{ zIndex: 2 }}
>
{children} {children}
</Island> </Island>
)} )}
</div> </DropdownMenuPrimitive.Content>
</DropdownMenuContentPropsContext.Provider> </DropdownMenuContentPropsContext.Provider>
); );
}; };
@@ -1,78 +1,62 @@
import React, { useEffect, useRef } from "react"; import React from "react";
import { THEME } from "@excalidraw/common"; import { THEME } from "@excalidraw/common";
import { DropdownMenu as DropdownMenuPrimitive } from "radix-ui";
import type { ValueOf } from "@excalidraw/common/utility-types"; import type { ValueOf } from "@excalidraw/common/utility-types";
import { useExcalidrawAppState } from "../App"; import { useExcalidrawAppState } from "../App";
import MenuItemContent from "./DropdownMenuItemContent";
import { import {
getDropdownMenuItemClassName, getDropdownMenuItemClassName,
useHandleDropdownMenuItemClick, useHandleDropdownMenuItemSelect,
} from "./common"; } from "./common";
import MenuItemContent from "./DropdownMenuItemContent";
import type { JSX } from "react"; import type { JSX } from "react";
const DropdownMenuItem = ({ export type DropdownMenuItemProps = {
icon,
value,
order,
children,
shortcut,
className,
hovered,
selected,
textStyle,
onSelect,
onClick,
badge,
...rest
}: {
icon?: JSX.Element; icon?: JSX.Element;
badge?: React.ReactNode;
value?: string | number | undefined; value?: string | number | undefined;
order?: number;
onSelect?: (event: Event) => void; onSelect?: (event: Event) => void;
children: React.ReactNode; children: React.ReactNode;
shortcut?: string; shortcut?: string;
hovered?: boolean;
selected?: boolean; selected?: boolean;
textStyle?: React.CSSProperties;
className?: string; className?: string;
badge?: React.ReactNode; } & Omit<React.ButtonHTMLAttributes<HTMLButtonElement>, "onSelect">;
} & Omit<React.ButtonHTMLAttributes<HTMLButtonElement>, "onSelect">) => {
const handleClick = useHandleDropdownMenuItemClick(onClick, onSelect);
const ref = useRef<HTMLButtonElement>(null);
useEffect(() => { const DropdownMenuItem = ({
if (hovered) { icon,
if (order === 0) { badge,
// scroll into the first item differently, so it's visible what is above (i.e. group title) value,
ref.current?.scrollIntoView({ block: "end" }); children,
} else { shortcut,
ref.current?.scrollIntoView({ block: "nearest" }); className,
} selected,
} onSelect,
}, [hovered, order]); ...rest
}: DropdownMenuItemProps) => {
const handleSelect = useHandleDropdownMenuItemSelect(onSelect);
return ( return (
<button <DropdownMenuPrimitive.Item
{...rest} className="radix-menu-item"
ref={ref} onSelect={handleSelect}
value={value} asChild
onClick={handleClick}
className={getDropdownMenuItemClassName(className, selected, hovered)}
title={rest.title ?? rest["aria-label"]}
> >
<MenuItemContent <button
textStyle={textStyle} {...rest}
icon={icon} value={value}
shortcut={shortcut} className={getDropdownMenuItemClassName(className, selected)}
badge={badge} title={rest.title ?? rest["aria-label"]}
> >
{children} <MenuItemContent icon={icon} shortcut={shortcut} badge={badge}>
</MenuItemContent> {children}
</button> </MenuItemContent>
</button>
</DropdownMenuPrimitive.Item>
); );
}; };
DropdownMenuItem.displayName = "DropdownMenuItem"; DropdownMenuItem.displayName = "DropdownMenuItem";
@@ -0,0 +1,15 @@
import { checkIcon, emptyIcon } from "../icons";
import DropdownMenuItem from "./DropdownMenuItem";
import type { DropdownMenuItemProps } from "./DropdownMenuItem";
const DropdownMenuItemCheckbox = (
props: Omit<DropdownMenuItemProps, "icon"> & { checked: boolean },
) => {
return (
<DropdownMenuItem {...props} icon={props.checked ? checkIcon : emptyIcon} />
);
};
export default DropdownMenuItemCheckbox;
@@ -27,9 +27,7 @@ const DropdownMenuItemContentRadio = <T,>({
return ( return (
<> <>
<div className="dropdown-menu-item-base dropdown-menu-item-bare"> <div className="dropdown-menu-item-base dropdown-menu-item-bare">
<label className="dropdown-menu-item__text" htmlFor={name}> <label className="dropdown-menu-item__text">{children}</label>
{children}
</label>
<RadioGroup <RadioGroup
name={name} name={name}
value={value} value={value}
@@ -1,9 +1,11 @@
import React from "react"; import React from "react";
import { DropdownMenu as DropdownMenuPrimitive } from "radix-ui";
import MenuItemContent from "./DropdownMenuItemContent"; import MenuItemContent from "./DropdownMenuItemContent";
import { import {
getDropdownMenuItemClassName, getDropdownMenuItemClassName,
useHandleDropdownMenuItemClick, useHandleDropdownMenuItemSelect,
} from "./common"; } from "./common";
import type { JSX } from "react"; import type { JSX } from "react";
@@ -28,23 +30,28 @@ const DropdownMenuItemLink = ({
onSelect?: (event: Event) => void; onSelect?: (event: Event) => void;
rel?: string; rel?: string;
} & React.AnchorHTMLAttributes<HTMLAnchorElement>) => { } & React.AnchorHTMLAttributes<HTMLAnchorElement>) => {
const handleClick = useHandleDropdownMenuItemClick(rest.onClick, onSelect); const handleSelect = useHandleDropdownMenuItemSelect(onSelect);
return ( return (
// eslint-disable-next-line react/jsx-no-target-blank // eslint-disable-next-line react/jsx-no-target-blank
<a <DropdownMenuPrimitive.Item
{...rest} className="radix-menu-item"
href={href} onSelect={handleSelect}
target="_blank" asChild
rel={rel || "noopener"}
className={getDropdownMenuItemClassName(className, selected)}
title={rest.title ?? rest["aria-label"]}
onClick={handleClick}
> >
<MenuItemContent icon={icon} shortcut={shortcut}> <a
{children} {...rest}
</MenuItemContent> href={href}
</a> target="_blank"
rel={`noopener ${rel}`}
className={getDropdownMenuItemClassName(className, selected)}
title={rest.title ?? rest["aria-label"]}
>
<MenuItemContent icon={icon} shortcut={shortcut}>
{children}
</MenuItemContent>
</a>
</DropdownMenuPrimitive.Item>
); );
}; };
@@ -5,7 +5,7 @@ const MenuSeparator = () => (
style={{ style={{
height: "1px", height: "1px",
backgroundColor: "var(--default-border-color)", backgroundColor: "var(--default-border-color)",
margin: ".5rem 0", margin: "6px 0",
flex: "0 0 auto", flex: "0 0 auto",
}} }}
/> />
@@ -0,0 +1,26 @@
import { DropdownMenu as DropdownMenuPrimitive } from "radix-ui";
import DropdownMenuSubContent from "./DropdownMenuSubContent";
import DropdownMenuSubTrigger from "./DropdownMenuSubTrigger";
import {
getSubMenuContentComponent,
getSubMenuTriggerComponent,
} from "./dropdownMenuUtils";
const DropdownMenuSub = ({ children }: { children?: React.ReactNode }) => {
const MenuTriggerComp = getSubMenuTriggerComponent(children);
const MenuContentComp = getSubMenuContentComponent(children);
return (
<DropdownMenuPrimitive.Sub>
{MenuTriggerComp}
{MenuContentComp}
</DropdownMenuPrimitive.Sub>
);
};
DropdownMenuSub.Trigger = DropdownMenuSubTrigger;
DropdownMenuSub.Content = DropdownMenuSubContent;
DropdownMenuSub.displayName = "DropdownMenuSub";
export default DropdownMenuSub;
@@ -0,0 +1,71 @@
import clsx from "clsx";
import { DropdownMenu as DropdownMenuPrimitive } from "radix-ui";
import { useCallback, useState } from "react";
import { useEditorInterface } from "../App";
import { Island } from "../Island";
import Stack from "../Stack";
const BASE_ALIGN_OFFSET = -4;
const BASE_SIDE_OFFSET = 4;
const DropdownMenuSubContent = ({
children,
className,
}: {
children?: React.ReactNode;
className?: string;
}) => {
const editorInterface = useEditorInterface();
const classNames = clsx(`dropdown-menu dropdown-submenu ${className}`, {
"dropdown-menu--mobile": editorInterface.formFactor === "phone",
}).trim();
const callbacksRef = useCallback((node: HTMLDivElement | null) => {
if (node) {
const parentContainer = node.closest(".dropdown-menu-container");
const parentRect = parentContainer?.getBoundingClientRect();
if (parentRect) {
const menuWidth = node.getBoundingClientRect().width;
const viewportWidth = window.innerWidth;
const spaceRemaining = viewportWidth - parentRect.right;
if (spaceRemaining < menuWidth + 20) {
setSideOffset(spaceRemaining - menuWidth + BASE_ALIGN_OFFSET);
setAlignOffset(BASE_ALIGN_OFFSET + 8);
}
}
}
}, []);
const [sideOffset, setSideOffset] = useState(BASE_SIDE_OFFSET);
const [alignOffset, setAlignOffset] = useState(BASE_ALIGN_OFFSET);
return (
<DropdownMenuPrimitive.SubContent
className={classNames}
sideOffset={sideOffset}
alignOffset={alignOffset}
collisionPadding={8}
ref={callbacksRef}
>
{editorInterface.formFactor === "phone" ? (
<Stack.Col className="dropdown-menu-container">{children}</Stack.Col>
) : (
<Island
className="dropdown-menu-container"
padding={2}
style={{ zIndex: 1 }}
>
{children}
</Island>
)}
</DropdownMenuPrimitive.SubContent>
);
};
export default DropdownMenuSubContent;
DropdownMenuSubContent.displayName = "DropdownMenuSubContent";
@@ -0,0 +1,38 @@
import React from "react";
import { DropdownMenu as DropdownMenuPrimitive } from "radix-ui";
import { chevronRight } from "../icons";
import { getDropdownMenuItemClassName } from "./common";
import MenuItemContent from "./DropdownMenuItemContent";
import type { JSX } from "react";
const DropdownMenuSubTrigger = ({
children,
icon,
shortcut,
className,
}: {
children: React.ReactNode;
icon?: JSX.Element;
shortcut?: string;
className?: string;
}) => {
return (
<DropdownMenuPrimitive.SubTrigger
className={`${getDropdownMenuItemClassName(
className,
)} dropdown-menu__submenu-trigger`}
>
<MenuItemContent icon={icon} shortcut={shortcut}>
{children}
</MenuItemContent>
<div className="dropdown-menu__submenu-trigger-icon">{chevronRight}</div>
</DropdownMenuPrimitive.SubTrigger>
);
};
export default DropdownMenuSubTrigger;
DropdownMenuSubTrigger.displayName = "DropdownMenuSubTrigger";
@@ -1,5 +1,7 @@
import clsx from "clsx"; import clsx from "clsx";
import { DropdownMenu as DropdownMenuPrimitive } from "radix-ui";
import { useEditorInterface } from "../App"; import { useEditorInterface } from "../App";
const MenuTrigger = ({ const MenuTrigger = ({
@@ -23,7 +25,7 @@ const MenuTrigger = ({
}, },
).trim(); ).trim();
return ( return (
<button <DropdownMenuPrimitive.Trigger
className={classNames} className={classNames}
onClick={onToggle} onClick={onToggle}
type="button" type="button"
@@ -32,7 +34,7 @@ const MenuTrigger = ({
{...rest} {...rest}
> >
{children} {children}
</button> </DropdownMenuPrimitive.Trigger>
); );
}; };
@@ -1,6 +1,6 @@
import React, { useContext } from "react"; import React, { useContext } from "react";
import { EVENT, composeEventHandlers } from "@excalidraw/common"; import { composeEventHandlers } from "@excalidraw/common";
export const DropdownMenuContentPropsContext = React.createContext<{ export const DropdownMenuContentPropsContext = React.createContext<{
onSelect?: (event: Event) => void; onSelect?: (event: Event) => void;
@@ -11,28 +11,17 @@ export const getDropdownMenuItemClassName = (
selected = false, selected = false,
hovered = false, hovered = false,
) => { ) => {
return `dropdown-menu-item dropdown-menu-item-base ${className} return `dropdown-menu-item dropdown-menu-item-base ${className} ${
${selected ? "dropdown-menu-item--selected" : ""} ${ selected ? "dropdown-menu-item--selected" : ""
hovered ? "dropdown-menu-item--hovered" : "" } ${hovered ? "dropdown-menu-item--hovered" : ""}`.trim();
}`.trim();
}; };
export const useHandleDropdownMenuItemClick = ( export const useHandleDropdownMenuItemSelect = (
origOnClick:
| React.MouseEventHandler<HTMLAnchorElement | HTMLButtonElement>
| undefined,
onSelect: ((event: Event) => void) | undefined, onSelect: ((event: Event) => void) | undefined,
) => { ) => {
const DropdownMenuContentProps = useContext(DropdownMenuContentPropsContext); const DropdownMenuContentProps = useContext(DropdownMenuContentPropsContext);
return composeEventHandlers(origOnClick, (event) => { return composeEventHandlers(onSelect, (event) => {
const itemSelectEvent = new CustomEvent(EVENT.MENU_ITEM_SELECT, { DropdownMenuContentProps.onSelect?.(event);
bubbles: true,
cancelable: true,
});
onSelect?.(itemSelectEvent);
if (!itemSelectEvent.defaultPrevented) {
DropdownMenuContentProps.onSelect?.(itemSelectEvent);
}
}); });
}; };
@@ -1,6 +1,6 @@
import React from "react"; import React from "react";
export const getMenuTriggerComponent = (children: React.ReactNode) => { const getMenuComponent = (component: string) => (children: React.ReactNode) => {
const comp = React.Children.toArray(children).find( const comp = React.Children.toArray(children).find(
(child) => (child) =>
React.isValidElement(child) && React.isValidElement(child) &&
@@ -8,7 +8,7 @@ export const getMenuTriggerComponent = (children: React.ReactNode) => {
//@ts-ignore //@ts-ignore
child?.type.displayName && child?.type.displayName &&
//@ts-ignore //@ts-ignore
child.type.displayName === "DropdownMenuTrigger", child.type.displayName === component,
); );
if (!comp) { if (!comp) {
return null; return null;
@@ -17,19 +17,11 @@ export const getMenuTriggerComponent = (children: React.ReactNode) => {
return comp; return comp;
}; };
export const getMenuContentComponent = (children: React.ReactNode) => { export const getMenuTriggerComponent = getMenuComponent("DropdownMenuTrigger");
const comp = React.Children.toArray(children).find( export const getMenuContentComponent = getMenuComponent("DropdownMenuContent");
(child) => export const getSubMenuTriggerComponent = getMenuComponent(
React.isValidElement(child) && "DropdownMenuSubTrigger",
typeof child.type !== "string" && );
//@ts-ignore export const getSubMenuContentComponent = getMenuComponent(
child?.type.displayName && "DropdownMenuSubContent",
//@ts-ignore );
child.type.displayName === "DropdownMenuContent",
);
if (!comp) {
return null;
}
//@ts-ignore
return comp;
};
+44 -7
View File
@@ -1287,13 +1287,21 @@ export const EdgeRoundIcon = createIcon(
tablerIconProps, tablerIconProps,
); );
export const ArrowheadNoneIcon = createIcon( export const ArrowheadNoneIcon = React.memo(
<g stroke="currentColor" opacity={0.3} strokeWidth={2}> ({ flip = false }: { flip?: boolean }) =>
<path d="M12 12l9 0" /> createIcon(
<path d="M3 9l6 6" /> <g
<path d="M3 15l6 -6" /> transform={flip ? "translate(24, 0) scale(-1, 1)" : ""}
</g>, stroke="currentColor"
tablerIconProps, opacity={0.3}
strokeWidth={2}
>
<path d="M12 12l-9 0" />
<path d="M21 9l-6 6" />
<path d="M21 15l-6 -6" />
</g>,
tablerIconProps,
),
); );
export const ArrowheadArrowIcon = React.memo( export const ArrowheadArrowIcon = React.memo(
@@ -2396,3 +2404,32 @@ export const presentationIcon = createIcon(
</g>, </g>,
tablerIconProps, tablerIconProps,
); );
// empty placeholder icon (used for alignment in menus)
export const emptyIcon = <div style={{ width: "1rem", height: "1rem" }} />;
//tabler-icons: chevron-right
export const chevronRight = createIcon(
<g strokeWidth="1.5">
<path stroke="none" d="M0 0h24v24H0z" fill="none" />
<polyline points="9 6 15 12 9 18" />
</g>,
tablerIconProps,
);
// tabler-icons: adjustments-horizontal
export const settingsIcon = createIcon(
<g strokeWidth={1.25}>
<path stroke="none" d="M0 0h24v24H0z" fill="none" />
<path d="M14 6m-2 0a2 2 0 1 0 4 0a2 2 0 1 0 -4 0" />
<path d="M4 6l8 0" />
<path d="M16 6l4 0" />
<path d="M8 12m-2 0a2 2 0 1 0 4 0a2 2 0 1 0 -4 0" />
<path d="M4 12l2 0" />
<path d="M10 12l10 0" />
<path d="M17 18m-2 0a2 2 0 1 0 4 0a2 2 0 1 0 -4 0" />
<path d="M4 18l11 0" />
<path d="M19 18l1 0" />
</g>,
tablerIconProps,
);
@@ -9,9 +9,14 @@ import {
actionLoadScene, actionLoadScene,
actionSaveToActiveFile, actionSaveToActiveFile,
actionShortcuts, actionShortcuts,
actionToggleGridMode,
actionToggleObjectsSnapMode,
actionToggleSearchMenu, actionToggleSearchMenu,
actionToggleStats,
actionToggleTheme, actionToggleTheme,
actionToggleZenMode,
} from "../../actions"; } from "../../actions";
import { actionToggleViewMode } from "../../actions/actionToggleViewMode";
import { getShortcutFromShortcutName } from "../../actions/shortcuts"; import { getShortcutFromShortcutName } from "../../actions/shortcuts";
import { trackEvent } from "../../analytics"; import { trackEvent } from "../../analytics";
import { useUIAppState } from "../../context/ui-appState"; import { useUIAppState } from "../../context/ui-appState";
@@ -23,13 +28,16 @@ import {
useExcalidrawActionManager, useExcalidrawActionManager,
useExcalidrawElements, useExcalidrawElements,
useAppProps, useAppProps,
useApp,
} from "../App"; } from "../App";
import { openConfirmModal } from "../OverwriteConfirm/OverwriteConfirmState"; import { openConfirmModal } from "../OverwriteConfirm/OverwriteConfirmState";
import Trans from "../Trans"; import Trans from "../Trans";
import DropdownMenuItem from "../dropdownMenu/DropdownMenuItem"; import DropdownMenuItem from "../dropdownMenu/DropdownMenuItem";
import DropdownMenuItemCheckbox from "../dropdownMenu/DropdownMenuItemCheckbox";
import DropdownMenuItemContentRadio from "../dropdownMenu/DropdownMenuItemContentRadio"; import DropdownMenuItemContentRadio from "../dropdownMenu/DropdownMenuItemContentRadio";
import DropdownMenuItemLink from "../dropdownMenu/DropdownMenuItemLink"; import DropdownMenuItemLink from "../dropdownMenu/DropdownMenuItemLink";
import { GithubIcon, DiscordIcon, XBrandIcon } from "../icons"; import DropdownMenuSub from "../dropdownMenu/DropdownMenuSub";
import { GithubIcon, DiscordIcon, XBrandIcon, settingsIcon } from "../icons";
import { import {
boltIcon, boltIcon,
DeviceDesktopIcon, DeviceDesktopIcon,
@@ -306,10 +314,14 @@ export const ChangeCanvasBackground = () => {
return null; return null;
} }
return ( return (
<div style={{ marginTop: "0.5rem" }}> <div style={{ marginTop: "0.75rem" }}>
<div <div
data-testid="canvas-background-label" data-testid="canvas-background-label"
style={{ fontSize: ".75rem", marginBottom: ".5rem" }} style={{
fontSize: "0.875rem",
marginBottom: "0.25rem",
marginLeft: "0.5rem",
}}
> >
{t("labels.canvasBackground")} {t("labels.canvasBackground")}
</div> </div>
@@ -393,3 +405,152 @@ export const LiveCollaborationTrigger = ({
}; };
LiveCollaborationTrigger.displayName = "LiveCollaborationTrigger"; LiveCollaborationTrigger.displayName = "LiveCollaborationTrigger";
const PreferencesToggleToolLockItem = () => {
const { t } = useI18n();
const app = useApp();
const appState = useUIAppState();
return (
<DropdownMenuItemCheckbox
checked={appState.activeTool.locked}
shortcut={getShortcutFromShortcutName("toolLock")}
onSelect={(event) => {
app.toggleLock();
event.preventDefault();
}}
>
{t("labels.preferences_toolLock")}
</DropdownMenuItemCheckbox>
);
};
const PreferencesToggleSnapModeItem = () => {
const { t } = useI18n();
const actionManager = useExcalidrawActionManager();
const appState = useUIAppState();
return (
<DropdownMenuItemCheckbox
checked={appState.objectsSnapModeEnabled}
shortcut={getShortcutFromShortcutName("objectsSnapMode")}
onSelect={(event) => {
actionManager.executeAction(actionToggleObjectsSnapMode);
event.preventDefault();
}}
>
{t("buttons.objectsSnapMode")}
</DropdownMenuItemCheckbox>
);
};
export const PreferencesToggleGridModeItem = () => {
const { t } = useI18n();
const actionManager = useExcalidrawActionManager();
const appState = useUIAppState();
return (
<DropdownMenuItemCheckbox
checked={appState.gridModeEnabled}
shortcut={getShortcutFromShortcutName("gridMode")}
onSelect={(event) => {
actionManager.executeAction(actionToggleGridMode);
event.preventDefault();
}}
>
{t("labels.toggleGrid")}
</DropdownMenuItemCheckbox>
);
};
export const PreferencesToggleZenModeItem = () => {
const { t } = useI18n();
const actionManager = useExcalidrawActionManager();
const appState = useUIAppState();
return (
<DropdownMenuItemCheckbox
checked={appState.zenModeEnabled}
shortcut={getShortcutFromShortcutName("zenMode")}
onSelect={(event) => {
actionManager.executeAction(actionToggleZenMode);
event.preventDefault();
}}
>
{t("buttons.zenMode")}
</DropdownMenuItemCheckbox>
);
};
const PreferencesToggleViewModeItem = () => {
const { t } = useI18n();
const actionManager = useExcalidrawActionManager();
const appState = useUIAppState();
return (
<DropdownMenuItemCheckbox
checked={appState.viewModeEnabled}
shortcut={getShortcutFromShortcutName("viewMode")}
onSelect={(event) => {
actionManager.executeAction(actionToggleViewMode);
event.preventDefault();
}}
>
{t("labels.viewMode")}
</DropdownMenuItemCheckbox>
);
};
const PreferencesToggleElementPropertiesItem = () => {
const { t } = useI18n();
const actionManager = useExcalidrawActionManager();
const appState = useUIAppState();
return (
<DropdownMenuItemCheckbox
checked={appState.stats.open}
shortcut={getShortcutFromShortcutName("stats")}
onSelect={(event) => {
actionManager.executeAction(actionToggleStats);
event.preventDefault();
}}
>
{t("stats.fullTitle")}
</DropdownMenuItemCheckbox>
);
};
export const Preferences = ({
children,
additionalItems,
}: {
children?: React.ReactNode;
additionalItems?: React.ReactNode;
}) => {
const { t } = useI18n();
return (
<DropdownMenuSub>
<DropdownMenuSub.Trigger icon={settingsIcon}>
{t("labels.preferences")}
</DropdownMenuSub.Trigger>
<DropdownMenuSub.Content className="excalidraw-main-menu-preferences-submenu">
{children || (
<>
<PreferencesToggleToolLockItem />
<PreferencesToggleSnapModeItem />
<PreferencesToggleGridModeItem />
<PreferencesToggleZenModeItem />
<PreferencesToggleViewModeItem />
<PreferencesToggleElementPropertiesItem />
</>
)}
{additionalItems}
</DropdownMenuSub.Content>
</DropdownMenuSub>
);
};
Preferences.ToggleToolLock = PreferencesToggleToolLockItem;
Preferences.ToggleSnapMode = PreferencesToggleSnapModeItem;
Preferences.ToggleGridMode = PreferencesToggleGridModeItem;
Preferences.ToggleZenMode = PreferencesToggleZenModeItem;
Preferences.ToggleViewMode = PreferencesToggleViewModeItem;
Preferences.ToggleElementProperties = PreferencesToggleElementPropertiesItem;
Preferences.displayName = "Preferences";
@@ -8,6 +8,7 @@ import { t } from "../../i18n";
import { useEditorInterface, useExcalidrawSetAppState } from "../App"; import { useEditorInterface, useExcalidrawSetAppState } from "../App";
import { UserList } from "../UserList"; import { UserList } from "../UserList";
import DropdownMenu from "../dropdownMenu/DropdownMenu"; import DropdownMenu from "../dropdownMenu/DropdownMenu";
import DropdownMenuSub from "../dropdownMenu/DropdownMenuSub";
import { withInternalFallback } from "../hoc/withInternalFallback"; import { withInternalFallback } from "../hoc/withInternalFallback";
import { HamburgerMenuIcon } from "../icons"; import { HamburgerMenuIcon } from "../icons";
@@ -52,12 +53,8 @@ const MainMenu = Object.assign(
onSelect={composeEventHandlers(onSelect, () => { onSelect={composeEventHandlers(onSelect, () => {
setAppState({ openMenu: null }); setAppState({ openMenu: null });
})} })}
placement="bottom" className="main-menu"
className={ align="start"
editorInterface.formFactor === "phone"
? "main-menu-dropdown"
: ""
}
> >
{children} {children}
{editorInterface.formFactor === "phone" && {editorInterface.formFactor === "phone" &&
@@ -84,6 +81,7 @@ const MainMenu = Object.assign(
ItemCustom: DropdownMenu.ItemCustom, ItemCustom: DropdownMenu.ItemCustom,
Group: DropdownMenu.Group, Group: DropdownMenu.Group,
Separator: DropdownMenu.Separator, Separator: DropdownMenu.Separator,
Sub: DropdownMenuSub,
DefaultItems, DefaultItems,
}, },
); );
@@ -29,7 +29,7 @@
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
.welcome-screen-decor-hint { .welcome-screen-decor-hint {
@media (max-height: 599px) { @media (max-height: 780px) {
display: none !important; display: none !important;
} }
@@ -148,6 +148,7 @@
.welcome-screen-center__heading { .welcome-screen-center__heading {
font-size: 1.125rem; font-size: 1.125rem;
text-align: center; text-align: center;
line-height: 1.35rem;
} }
.welcome-screen-menu { .welcome-screen-menu {
+16
View File
@@ -16,6 +16,7 @@
--zIndex-ui-context-menu: 90; --zIndex-ui-context-menu: 90;
--zIndex-ui-styles-popup: 100; --zIndex-ui-styles-popup: 100;
--zIndex-ui-top: 100; --zIndex-ui-top: 100;
--zIndex-ui-main-menu: 110;
--zIndex-ui-library: 120; --zIndex-ui-library: 120;
--zIndex-modal: 1000; --zIndex-modal: 1000;
@@ -223,6 +224,18 @@ body.excalidraw-cursor-resize * {
box-shadow: 0 0 0 1px var(--color-brand-hover); box-shadow: 0 0 0 1px var(--color-brand-hover);
} }
// radix doesn't allow differntiating between hover and keyboard active
// states (it's forcing :focus on both).
//
// proper handling would be to disable :focus-visible by default, and enable
// on keyboard arrows (it'd then have to be disabled again, e.g. on keydown
// or container focus)
//
// alas, that is left for another day
[data-radix-collection-item]:focus-visible {
box-shadow: none !important;
}
.buttonList { .buttonList {
.ToolIcon__icon { .ToolIcon__icon {
all: unset !important; all: unset !important;
@@ -670,6 +683,9 @@ body.excalidraw-cursor-resize * {
} }
} }
.main-menu {
z-index: var(--zIndex-ui-main-menu);
}
.main-menu-trigger { .main-menu-trigger {
@include filledButtonOnCanvas; @include filledButtonOnCanvas;
} }
+15 -2
View File
@@ -28,6 +28,7 @@ import {
import type { AppState, DataURL, LibraryItem } from "../types"; import type { AppState, DataURL, LibraryItem } from "../types";
import type { FileSystemHandle } from "browser-fs-access"; import type { FileSystemHandle } from "browser-fs-access";
import type { SchemaMigrationRegistry } from "./schema";
import type { ImportedLibraryData } from "./types"; import type { ImportedLibraryData } from "./types";
const parseFileContents = async (blob: Blob | File): Promise<string> => { const parseFileContents = async (blob: Blob | File): Promise<string> => {
@@ -141,6 +142,7 @@ export const loadSceneOrLibraryFromBlob = async (
localElements: readonly ExcalidrawElement[] | null, localElements: readonly ExcalidrawElement[] | null,
/** FileSystemHandle. Defaults to `blob.handle` if defined, otherwise null. */ /** FileSystemHandle. Defaults to `blob.handle` if defined, otherwise null. */
fileHandle?: FileSystemHandle | null, fileHandle?: FileSystemHandle | null,
schemaMigrationRegistry?: SchemaMigrationRegistry,
) => { ) => {
const contents = await parseFileContents(blob); const contents = await parseFileContents(blob);
let data; let data;
@@ -163,6 +165,7 @@ export const loadSceneOrLibraryFromBlob = async (
elements: restoreElements(data.elements, localElements, { elements: restoreElements(data.elements, localElements, {
repairBindings: true, repairBindings: true,
deleteInvisibleElements: true, deleteInvisibleElements: true,
schemaMigrationRegistry,
}), }),
appState: restoreAppState( appState: restoreAppState(
{ {
@@ -200,12 +203,14 @@ export const loadFromBlob = async (
localElements: readonly ExcalidrawElement[] | null, localElements: readonly ExcalidrawElement[] | null,
/** FileSystemHandle. Defaults to `blob.handle` if defined, otherwise null. */ /** FileSystemHandle. Defaults to `blob.handle` if defined, otherwise null. */
fileHandle?: FileSystemHandle | null, fileHandle?: FileSystemHandle | null,
schemaMigrationRegistry?: SchemaMigrationRegistry,
) => { ) => {
const ret = await loadSceneOrLibraryFromBlob( const ret = await loadSceneOrLibraryFromBlob(
blob, blob,
localAppState, localAppState,
localElements, localElements,
fileHandle, fileHandle,
schemaMigrationRegistry,
); );
if (ret.type !== MIME_TYPES.excalidraw) { if (ret.type !== MIME_TYPES.excalidraw) {
throw new Error("Error: invalid file"); throw new Error("Error: invalid file");
@@ -216,20 +221,28 @@ export const loadFromBlob = async (
export const parseLibraryJSON = ( export const parseLibraryJSON = (
json: string, json: string,
defaultStatus: LibraryItem["status"] = "unpublished", defaultStatus: LibraryItem["status"] = "unpublished",
schemaMigrationRegistry?: SchemaMigrationRegistry,
) => { ) => {
const data: ImportedLibraryData | undefined = JSON.parse(json); const data: ImportedLibraryData | undefined = JSON.parse(json);
if (!isValidLibrary(data)) { if (!isValidLibrary(data)) {
throw new Error("Invalid library"); throw new Error("Invalid library");
} }
const libraryItems = data.libraryItems || data.library; const libraryItems = data.libraryItems || data.library;
return restoreLibraryItems(libraryItems, defaultStatus); return restoreLibraryItems(libraryItems, defaultStatus, {
schemaMigrationRegistry,
});
}; };
export const loadLibraryFromBlob = async ( export const loadLibraryFromBlob = async (
blob: Blob, blob: Blob,
defaultStatus: LibraryItem["status"] = "unpublished", defaultStatus: LibraryItem["status"] = "unpublished",
schemaMigrationRegistry?: SchemaMigrationRegistry,
) => { ) => {
return parseLibraryJSON(await parseFileContents(blob), defaultStatus); return parseLibraryJSON(
await parseFileContents(blob),
defaultStatus,
schemaMigrationRegistry,
);
}; };
export const canvasToBlob = async ( export const canvasToBlob = async (
+9 -1
View File
@@ -14,6 +14,7 @@ import { isImageFileHandle, loadFromBlob } from "./blob";
import { fileOpen, fileSave } from "./filesystem"; import { fileOpen, fileSave } from "./filesystem";
import type { AppState, BinaryFiles, LibraryItems } from "../types"; import type { AppState, BinaryFiles, LibraryItems } from "../types";
import type { SchemaMigrationRegistry } from "./schema";
import type { import type {
ExportedDataState, ExportedDataState,
ImportedDataState, ImportedDataState,
@@ -93,6 +94,7 @@ export const saveAsJSON = async (
export const loadFromJSON = async ( export const loadFromJSON = async (
localAppState: AppState, localAppState: AppState,
localElements: readonly ExcalidrawElement[] | null, localElements: readonly ExcalidrawElement[] | null,
schemaMigrationRegistry?: SchemaMigrationRegistry,
) => { ) => {
const file = await fileOpen({ const file = await fileOpen({
description: "Excalidraw files", description: "Excalidraw files",
@@ -100,7 +102,13 @@ export const loadFromJSON = async (
// gets resolved. Else, iOS users cannot open `.excalidraw` files. // gets resolved. Else, iOS users cannot open `.excalidraw` files.
// extensions: ["json", "excalidraw", "png", "svg"], // extensions: ["json", "excalidraw", "png", "svg"],
}); });
return loadFromBlob(file, localAppState, localElements, file.handle); return loadFromBlob(
file,
localAppState,
localElements,
file.handle,
schemaMigrationRegistry,
);
}; };
export const isValidExcalidrawData = (data?: { export const isValidExcalidrawData = (data?: {
+120 -63
View File
@@ -35,6 +35,7 @@ import { loadLibraryFromBlob } from "./blob";
import { restoreLibraryItems } from "./restore"; import { restoreLibraryItems } from "./restore";
import type App from "../components/App"; import type App from "../components/App";
import type { SchemaMigrationRegistry } from "./schema";
import type { import type {
LibraryItems, LibraryItems,
@@ -65,9 +66,9 @@ type LibraryUpdate = {
updatedItems: Map<LibraryItem["id"], LibraryItem>; updatedItems: Map<LibraryItem["id"], LibraryItem>;
}; };
// an object so that we can later add more properties to it without breaking, export type LibraryPersistedData = {
// such as schema version libraryItems: LibraryItems;
export type LibraryPersistedData = { libraryItems: LibraryItems }; };
const onLibraryUpdateEmitter = new Emitter< const onLibraryUpdateEmitter = new Emitter<
[update: LibraryUpdate, libraryItems: LibraryItems] [update: LibraryUpdate, libraryItems: LibraryItems]
@@ -99,7 +100,9 @@ export interface LibraryMigrationAdapter {
* loads data from legacy data source. Returns `null` if no data is * loads data from legacy data source. Returns `null` if no data is
* to be migrated. * to be migrated.
*/ */
load(): MaybePromise<{ libraryItems: LibraryItems_anyVersion } | null>; load(): MaybePromise<{
libraryItems: LibraryItems_anyVersion;
} | null>;
/** clears entire storage afterwards */ /** clears entire storage afterwards */
clear(): MaybePromise<void>; clear(): MaybePromise<void>;
@@ -314,9 +317,15 @@ class Library {
let nextItems; let nextItems;
if (source instanceof Blob) { if (source instanceof Blob) {
nextItems = await loadLibraryFromBlob(source, defaultStatus); nextItems = await loadLibraryFromBlob(
source,
defaultStatus,
this.app.getSchemaMigrationRegistry(),
);
} else { } else {
nextItems = restoreLibraryItems(source, defaultStatus); nextItems = restoreLibraryItems(source, defaultStatus, {
schemaMigrationRegistry: this.app.getSchemaMigrationRegistry(),
});
} }
if ( if (
!prompt || !prompt ||
@@ -549,12 +558,17 @@ class AdapterTransaction {
adapter: LibraryPersistenceAdapter, adapter: LibraryPersistenceAdapter,
source: LibraryAdatapterSource, source: LibraryAdatapterSource,
_queue = true, _queue = true,
schemaMigrationRegistry?: SchemaMigrationRegistry,
): Promise<LibraryItems> { ): Promise<LibraryItems> {
const task = () => const task = () =>
new Promise<LibraryItems>(async (resolve, reject) => { new Promise<LibraryItems>(async (resolve, reject) => {
try { try {
const data = await adapter.load({ source }); const data = await adapter.load({ source });
resolve(restoreLibraryItems(data?.libraryItems || [], "published")); resolve(
restoreLibraryItems(data?.libraryItems || [], "published", {
schemaMigrationRegistry,
}),
);
} catch (error: any) { } catch (error: any) {
reject(error); reject(error);
} }
@@ -569,22 +583,36 @@ class AdapterTransaction {
static run = async <T>( static run = async <T>(
adapter: LibraryPersistenceAdapter, adapter: LibraryPersistenceAdapter,
schemaMigrationRegistry: SchemaMigrationRegistry | undefined,
fn: (transaction: AdapterTransaction) => Promise<T>, fn: (transaction: AdapterTransaction) => Promise<T>,
) => { ) => {
const transaction = new AdapterTransaction(adapter); const transaction = new AdapterTransaction(
adapter,
schemaMigrationRegistry,
);
return AdapterTransaction.queue.push(() => fn(transaction)); return AdapterTransaction.queue.push(() => fn(transaction));
}; };
// ------------------ // ------------------
private adapter: LibraryPersistenceAdapter; private adapter: LibraryPersistenceAdapter;
private schemaMigrationRegistry: SchemaMigrationRegistry | undefined;
constructor(adapter: LibraryPersistenceAdapter) { constructor(
adapter: LibraryPersistenceAdapter,
schemaMigrationRegistry: SchemaMigrationRegistry | undefined,
) {
this.adapter = adapter; this.adapter = adapter;
this.schemaMigrationRegistry = schemaMigrationRegistry;
} }
getLibraryItems(source: LibraryAdatapterSource) { getLibraryItems(source: LibraryAdatapterSource) {
return AdapterTransaction.getLibraryItems(this.adapter, source, false); return AdapterTransaction.getLibraryItems(
this.adapter,
source,
false,
this.schemaMigrationRegistry,
);
} }
} }
@@ -607,68 +635,73 @@ export const getLibraryItemsHash = (items: LibraryItems) => {
const persistLibraryUpdate = async ( const persistLibraryUpdate = async (
adapter: LibraryPersistenceAdapter, adapter: LibraryPersistenceAdapter,
update: LibraryUpdate, update: LibraryUpdate,
schemaMigrationRegistry: SchemaMigrationRegistry | undefined,
): Promise<LibraryItems> => { ): Promise<LibraryItems> => {
try { try {
librarySaveCounter++; librarySaveCounter++;
return await AdapterTransaction.run(adapter, async (transaction) => { return await AdapterTransaction.run(
const nextLibraryItemsMap = arrayToMap( adapter,
await transaction.getLibraryItems("save"), schemaMigrationRegistry,
); async (transaction) => {
const nextLibraryItemsMap = arrayToMap(
await transaction.getLibraryItems("save"),
);
for (const [id] of update.deletedItems) { for (const [id] of update.deletedItems) {
nextLibraryItemsMap.delete(id); nextLibraryItemsMap.delete(id);
}
const addedItems: LibraryItem[] = [];
// we want to merge current library items with the ones stored in the
// DB so that we don't lose any elements that for some reason aren't
// in the current editor library, which could happen when:
//
// 1. we haven't received an update deleting some elements
// (in which case it's still better to keep them in the DB lest
// it was due to a different reason)
// 2. we keep a single DB for all active editors, but the editors'
// libraries aren't synced or there's a race conditions during
// syncing
// 3. some other race condition, e.g. during init where emit updates
// for partial updates (e.g. you install a 3rd party library and
// init from DB only after — we emit events for both updates)
for (const [id, item] of update.addedItems) {
if (nextLibraryItemsMap.has(id)) {
// replace item with latest version
// TODO we could prefer the newer item instead
nextLibraryItemsMap.set(id, item);
} else {
// we want to prepend the new items with the ones that are already
// in DB to preserve the ordering we do in editor (newly added
// items are added to the beginning)
addedItems.push(item);
} }
}
// replace existing items with their updated versions const addedItems: LibraryItem[] = [];
if (update.updatedItems) {
for (const [id, item] of update.updatedItems) { // we want to merge current library items with the ones stored in the
nextLibraryItemsMap.set(id, item); // DB so that we don't lose any elements that for some reason aren't
// in the current editor library, which could happen when:
//
// 1. we haven't received an update deleting some elements
// (in which case it's still better to keep them in the DB lest
// it was due to a different reason)
// 2. we keep a single DB for all active editors, but the editors'
// libraries aren't synced or there's a race conditions during
// syncing
// 3. some other race condition, e.g. during init where emit updates
// for partial updates (e.g. you install a 3rd party library and
// init from DB only after — we emit events for both updates)
for (const [id, item] of update.addedItems) {
if (nextLibraryItemsMap.has(id)) {
// replace item with latest version
// TODO we could prefer the newer item instead
nextLibraryItemsMap.set(id, item);
} else {
// we want to prepend the new items with the ones that are already
// in DB to preserve the ordering we do in editor (newly added
// items are added to the beginning)
addedItems.push(item);
}
} }
}
const nextLibraryItems = addedItems.concat( // replace existing items with their updated versions
Array.from(nextLibraryItemsMap.values()), if (update.updatedItems) {
); for (const [id, item] of update.updatedItems) {
nextLibraryItemsMap.set(id, item);
}
}
const version = getLibraryItemsHash(nextLibraryItems); const nextLibraryItems = addedItems.concat(
Array.from(nextLibraryItemsMap.values()),
);
if (version !== lastSavedLibraryItemsHash) { const version = getLibraryItemsHash(nextLibraryItems);
await adapter.save({ libraryItems: nextLibraryItems });
}
lastSavedLibraryItemsHash = version; if (version !== lastSavedLibraryItemsHash) {
await adapter.save({ libraryItems: nextLibraryItems });
}
return nextLibraryItems; lastSavedLibraryItemsHash = version;
});
return nextLibraryItems;
},
);
} finally { } finally {
librarySaveCounter--; librarySaveCounter--;
} }
@@ -852,16 +885,24 @@ export const useHandleLibrary = (
.then(async (libraryData) => { .then(async (libraryData) => {
let restoredData: LibraryItems | null = null; let restoredData: LibraryItems | null = null;
try { try {
const schemaMigrationRegistry =
optsRef.current.excalidrawAPI?.getSchemaMigrationRegistry();
// if no library data to migrate, assume no migration needed // if no library data to migrate, assume no migration needed
// and skip persisting to new data store, as well as well // and skip persisting to new data store, as well as well
// clearing the old store via `migrationAdapter.clear()` // clearing the old store via `migrationAdapter.clear()`
if (!libraryData) { if (!libraryData) {
return AdapterTransaction.getLibraryItems(adapter, "load"); return AdapterTransaction.getLibraryItems(
adapter,
"load",
true,
schemaMigrationRegistry,
);
} }
restoredData = restoreLibraryItems( restoredData = restoreLibraryItems(
libraryData.libraryItems || [], libraryData.libraryItems || [],
"published", "published",
{ schemaMigrationRegistry },
); );
// we don't queue this operation because it's running inside // we don't queue this operation because it's running inside
@@ -869,6 +910,7 @@ export const useHandleLibrary = (
const nextItems = await persistLibraryUpdate( const nextItems = await persistLibraryUpdate(
adapter, adapter,
createLibraryUpdate([], restoredData), createLibraryUpdate([], restoredData),
schemaMigrationRegistry,
); );
try { try {
await migrationAdapter.clear(); await migrationAdapter.clear();
@@ -891,12 +933,23 @@ export const useHandleLibrary = (
.catch((error: any) => { .catch((error: any) => {
console.error(`error during library migration: ${error.message}`); console.error(`error during library migration: ${error.message}`);
// as a default, load latest library from current data source // as a default, load latest library from current data source
return AdapterTransaction.getLibraryItems(adapter, "load"); return AdapterTransaction.getLibraryItems(
adapter,
"load",
true,
optsRef.current.excalidrawAPI?.getSchemaMigrationRegistry(),
);
}), }),
); );
} else { } else {
initDataPromise.resolve( initDataPromise.resolve(
promiseTry(AdapterTransaction.getLibraryItems, adapter, "load"), promiseTry(
AdapterTransaction.getLibraryItems,
adapter,
"load",
true,
optsRef.current.excalidrawAPI?.getSchemaMigrationRegistry(),
),
); );
} }
@@ -958,7 +1011,11 @@ export const useHandleLibrary = (
lastSavedLibraryItemsHash !== lastSavedLibraryItemsHash !==
getLibraryItemsHash(nextLibraryItems) getLibraryItemsHash(nextLibraryItems)
) { ) {
await persistLibraryUpdate(adapter, update); await persistLibraryUpdate(
adapter,
update,
optsRef.current.excalidrawAPI?.getSchemaMigrationRegistry(),
);
} }
} }
} catch (error: any) { } catch (error: any) {
+53 -17
View File
@@ -82,7 +82,16 @@ import {
getNormalizedZoom, getNormalizedZoom,
} from "../scene"; } from "../scene";
import type { AppState, BinaryFiles, LibraryItem } from "../types"; import { migrateElements } from "./schema";
import type { SchemaMigrationRegistry } from "./schema";
import type {
AppState,
BinaryFiles,
LibraryItem,
NormalizedZoomValue,
} from "../types";
import type { ImportedDataState, LegacyAppState } from "./types"; import type { ImportedDataState, LegacyAppState } from "./types";
type RestoredAppState = Omit< type RestoredAppState = Omit<
@@ -212,6 +221,7 @@ const repairBinding = <T extends ExcalidrawArrowElement>(
boundElement, boundElement,
startOrEnd, startOrEnd,
elementsMap, elementsMap,
{ value: 1 as NormalizedZoomValue },
) || p; ) || p;
const { fixedPoint } = calculateFixedPointForNonElbowArrowBinding( const { fixedPoint } = calculateFixedPointForNonElbowArrowBinding(
element, element,
@@ -237,7 +247,7 @@ const repairBinding = <T extends ExcalidrawArrowElement>(
}; };
const restoreElementWithProperties = < const restoreElementWithProperties = <
T extends Required<Omit<ExcalidrawElement, "customData">> & { T extends Omit<ExcalidrawElement, "customData"> & {
customData?: ExcalidrawElement["customData"]; customData?: ExcalidrawElement["customData"];
/** @deprecated */ /** @deprecated */
boundElementIds?: readonly ExcalidrawElement["id"][]; boundElementIds?: readonly ExcalidrawElement["id"][];
@@ -279,6 +289,10 @@ const restoreElementWithProperties = <
width: element.width || 0, width: element.width || 0,
height: element.height || 0, height: element.height || 0,
seed: element.seed ?? 1, seed: element.seed ?? 1,
schemaState:
element.schemaState && typeof element.schemaState === "object"
? element.schemaState
: { tracks: {} },
groupIds: element.groupIds ?? [], groupIds: element.groupIds ?? [],
frameId: element.frameId ?? null, frameId: element.frameId ?? null,
roundness: element.roundness roundness: element.roundness
@@ -499,6 +513,9 @@ export const restoreElement = (
case "embeddable": case "embeddable":
return restoreElementWithProperties(element, {}); return restoreElementWithProperties(element, {});
case "magicframe": case "magicframe":
return restoreElementWithProperties(element, {
name: element.name ?? null,
});
case "frame": case "frame":
return restoreElementWithProperties(element, { return restoreElementWithProperties(element, {
name: element.name ?? null, name: element.name ?? null,
@@ -627,17 +644,25 @@ export const restoreElements = <T extends ExcalidrawElement>(
refreshDimensions?: boolean; refreshDimensions?: boolean;
repairBindings?: boolean; repairBindings?: boolean;
deleteInvisibleElements?: boolean; deleteInvisibleElements?: boolean;
schemaMigrationRegistry?: SchemaMigrationRegistry;
} }
| undefined, | undefined,
): CombineBrandsIfNeeded<T, OrderedExcalidrawElement> => { ): CombineBrandsIfNeeded<T, OrderedExcalidrawElement> => {
const migratedTargetElements = migrateElements(
targetElements as readonly ExcalidrawElement[] | undefined | null,
{
schemaMigrationRegistry: opts?.schemaMigrationRegistry,
},
) as readonly T[] | undefined | null;
// used to detect duplicate top-level element ids // used to detect duplicate top-level element ids
const existingIds = new Set<string>(); const existingIds = new Set<string>();
const targetElementsMap = arrayToMap(targetElements || []); const targetElementsMap = arrayToMap(migratedTargetElements || []);
const existingElementsMap = existingElements const existingElementsMap = existingElements
? arrayToMap(existingElements) ? arrayToMap(existingElements)
: null; : null;
const restoredElements = syncInvalidIndices( const restoredElements = syncInvalidIndices(
(targetElements || []).reduce((elements, element) => { (migratedTargetElements || []).reduce((elements, element) => {
// filtering out selection, which is legacy, no longer kept in elements, // filtering out selection, which is legacy, no longer kept in elements,
// and causing issues if retained // and causing issues if retained
if (element.type === "selection") { if (element.type === "selection") {
@@ -947,10 +972,14 @@ export const restoreAppState = (
}; };
}; };
const restoreLibraryItem = (libraryItem: LibraryItem) => { const restoreLibraryItem = (
libraryItem: LibraryItem,
opts?: { schemaMigrationRegistry?: SchemaMigrationRegistry },
) => {
const elements = restoreElements( const elements = restoreElements(
getNonDeletedElements(libraryItem.elements), getNonDeletedElements(libraryItem.elements),
null, null,
{ schemaMigrationRegistry: opts?.schemaMigrationRegistry },
); );
return elements.length ? { ...libraryItem, elements } : null; return elements.length ? { ...libraryItem, elements } : null;
}; };
@@ -958,17 +987,21 @@ const restoreLibraryItem = (libraryItem: LibraryItem) => {
export const restoreLibraryItems = ( export const restoreLibraryItems = (
libraryItems: ImportedDataState["libraryItems"] = [], libraryItems: ImportedDataState["libraryItems"] = [],
defaultStatus: LibraryItem["status"], defaultStatus: LibraryItem["status"],
opts?: { schemaMigrationRegistry?: SchemaMigrationRegistry },
) => { ) => {
const restoredItems: LibraryItem[] = []; const restoredItems: LibraryItem[] = [];
for (const item of libraryItems) { for (const item of libraryItems) {
// migrate older libraries // migrate older libraries
if (Array.isArray(item)) { if (Array.isArray(item)) {
const restoredItem = restoreLibraryItem({ const restoredItem = restoreLibraryItem(
status: defaultStatus, {
elements: item, status: defaultStatus,
id: randomId(), elements: item,
created: Date.now(), id: randomId(),
}); created: Date.now(),
},
opts,
);
if (restoredItem) { if (restoredItem) {
restoredItems.push(restoredItem); restoredItems.push(restoredItem);
} }
@@ -977,12 +1010,15 @@ export const restoreLibraryItems = (
LibraryItem, LibraryItem,
"id" | "status" | "created" "id" | "status" | "created"
>; >;
const restoredItem = restoreLibraryItem({ const restoredItem = restoreLibraryItem(
..._item, {
id: _item.id || randomId(), ..._item,
status: _item.status || defaultStatus, id: _item.id || randomId(),
created: _item.created || Date.now(), status: _item.status || defaultStatus,
}); created: _item.created || Date.now(),
},
opts,
);
if (restoredItem) { if (restoredItem) {
restoredItems.push(restoredItem); restoredItems.push(restoredItem);
} }
+408
View File
@@ -0,0 +1,408 @@
import { DEFAULT_ELEMENT_PROPS } from "@excalidraw/common";
import { API } from "../tests/helpers/api";
import {
CORE_FRAME_SCHEMA_TRACK,
createSchemaMigrationRegistry,
type SchemaPlugin,
type SchemaMigration,
CORE_SUPPORTED_TRACKS,
migrateElements,
resolveTrackVersion,
SCHEMA_INITIAL_TRACK_VERSION,
SCHEMA_MIGRATIONS,
validateSchemaMigrations,
validateSchemaPlugins,
} from "./schema";
describe("schema migration", () => {
it("should migrate legacy frame backgrounds to transparent", () => {
const frame = {
...API.createElement({
type: "frame",
backgroundColor: "#ffc9c9",
}),
schemaState: { tracks: {} },
};
const migrated = migrateElements([frame])!;
expect(migrated[0].backgroundColor).toBe(
DEFAULT_ELEMENT_PROPS.backgroundColor,
);
expect(migrated[0].schemaState.tracks[CORE_FRAME_SCHEMA_TRACK]).toBe(
CORE_SUPPORTED_TRACKS[CORE_FRAME_SCHEMA_TRACK],
);
});
it("should handle elements without schemaState", () => {
const frameWithoutSchemaState = {
...API.createElement({
type: "frame",
backgroundColor: "#ffc9c9",
}),
schemaState: undefined,
} as any;
const textWithoutSchemaState = {
...API.createElement({
type: "text",
text: "",
}),
schemaState: undefined,
} as any;
const migrated = migrateElements([
frameWithoutSchemaState,
textWithoutSchemaState,
])!;
expect(migrated[0].backgroundColor).toBe(
DEFAULT_ELEMENT_PROPS.backgroundColor,
);
expect(migrated[0].schemaState.tracks[CORE_FRAME_SCHEMA_TRACK]).toBe(
CORE_SUPPORTED_TRACKS[CORE_FRAME_SCHEMA_TRACK],
);
expect(migrated[1].schemaState).toEqual({ tracks: {} });
});
it("should keep latest-track frame backgrounds unchanged", () => {
const frame = {
...API.createElement({
type: "frame",
backgroundColor: "#ffc9c9",
}),
schemaState: {
tracks: {
[CORE_FRAME_SCHEMA_TRACK]:
CORE_SUPPORTED_TRACKS[CORE_FRAME_SCHEMA_TRACK],
},
},
};
const migrated = migrateElements([frame])!;
expect(migrated[0].backgroundColor).toBe("#ffc9c9");
expect(migrated[0].schemaState.tracks[CORE_FRAME_SCHEMA_TRACK]).toBe(
CORE_SUPPORTED_TRACKS[CORE_FRAME_SCHEMA_TRACK],
);
});
it("should normalize legacy frame backgrounds", () => {
const frame = {
...API.createElement({
type: "frame",
backgroundColor: "#a5d8ff",
}),
schemaState: {
tracks: {
[CORE_FRAME_SCHEMA_TRACK]: SCHEMA_INITIAL_TRACK_VERSION,
},
},
};
const migrated = migrateElements([frame])!;
expect(migrated[0].backgroundColor).toBe(
DEFAULT_ELEMENT_PROPS.backgroundColor,
);
});
it("should resolve invalid track versions to initial", () => {
expect(resolveTrackVersion(undefined)).toBe(SCHEMA_INITIAL_TRACK_VERSION);
expect(resolveTrackVersion(0)).toBe(SCHEMA_INITIAL_TRACK_VERSION);
expect(resolveTrackVersion(2)).toBe(2);
});
it("should have a valid migration registry configuration", () => {
expect(validateSchemaMigrations(SCHEMA_MIGRATIONS)).toEqual([]);
});
it("should reject invalid migration metadata", () => {
const invalidMigrations: SchemaMigration[] = [
{
id: "",
namespace: "core",
track: CORE_FRAME_SCHEMA_TRACK,
toVersion: 2.1,
title: "",
description: " ",
targetTypes: [],
apply: (element) => element,
},
{
id: "dup",
namespace: "core",
track: CORE_FRAME_SCHEMA_TRACK,
toVersion: 2.1,
title: "duplicate",
description: "duplicate version",
targetTypes: ["frame"],
apply: (element) => element,
},
{
id: "dup",
namespace: "core",
track: CORE_FRAME_SCHEMA_TRACK,
toVersion: 3,
title: "duplicate id",
description: "duplicate id",
targetTypes: ["frame"],
apply: (element) => element,
},
];
const errors = validateSchemaMigrations(invalidMigrations);
expect(errors.length).toBeGreaterThan(0);
expect(errors.join("\n")).toContain("integer version");
expect(errors.join("\n")).toContain("title must be non-empty");
expect(errors.join("\n")).toContain("non-empty description");
expect(errors.join("\n")).toContain("Duplicate schema migration id");
expect(errors.join("\n")).toContain("at least one target type");
});
it("should reject versions at or below initial", () => {
const errors = validateSchemaMigrations([
{
id: "invalid-start",
namespace: "core",
track: CORE_FRAME_SCHEMA_TRACK,
toVersion: SCHEMA_INITIAL_TRACK_VERSION,
title: "invalid start",
description: "bad version",
targetTypes: ["frame"],
apply: (element) => element,
},
]);
expect(errors.join("\n")).toContain("must be greater than 1");
});
it("should reject core track/version mismatch", () => {
const errors = validateSchemaMigrations([
{
id: "frame-v3",
namespace: "core",
track: CORE_FRAME_SCHEMA_TRACK,
toVersion: CORE_SUPPORTED_TRACKS[CORE_FRAME_SCHEMA_TRACK] + 1,
title: "future migration",
description: "future migration for test",
targetTypes: ["frame"],
apply: (element) => element,
},
]);
expect(errors.join("\n")).toContain(
`Core supported track "${CORE_FRAME_SCHEMA_TRACK}" (${CORE_SUPPORTED_TRACKS[CORE_FRAME_SCHEMA_TRACK]}) must match last migration version`,
);
});
it("should reject undeclared core tracks", () => {
const errors = validateSchemaMigrations([
{
id: "unknown-core-track",
namespace: "core",
track: "excalidraw.shape.unknown",
toVersion: 2,
title: "unknown core track",
description: "should require supported-track declaration",
targetTypes: ["rectangle"],
apply: (element) => element,
},
]);
expect(errors.join("\n")).toContain(
"must be declared in CORE_SUPPORTED_TRACKS",
);
});
it("should reject invalid plugin metadata", () => {
const errors = validateSchemaPlugins([
{
id: "",
migrations: [],
},
{
id: "dup",
migrations: [],
},
{
id: "dup",
migrations: [],
},
{
id: "core-overwrite",
migrations: [
{
id: "bad.core.migration",
namespace: "core",
track: CORE_FRAME_SCHEMA_TRACK,
toVersion: 2,
title: "bad",
description: "bad",
targetTypes: ["frame"],
apply: (element) => element,
},
],
},
]);
expect(errors.join("\n")).toContain("Schema plugin id must be non-empty");
expect(errors.join("\n")).toContain("Duplicate schema plugin id found");
expect(errors.join("\n")).toContain("cannot declare core migrations");
});
it("should not depend on temporary fields during migration", () => {
const frame = {
...API.createElement({
type: "frame",
backgroundColor: "#a5d8ff",
}),
schemaState: { tracks: {} },
};
const withTempField = {
...frame,
backgroundEnabled: false,
} as typeof frame & { backgroundEnabled: boolean };
const migratedBase = migrateElements([frame])!;
const migratedWithTempField = migrateElements([withTempField])!;
expect(migratedBase[0].backgroundColor).toBe(
DEFAULT_ELEMENT_PROPS.backgroundColor,
);
expect(migratedWithTempField[0].backgroundColor).toBe(
DEFAULT_ELEMENT_PROPS.backgroundColor,
);
});
it("should use per-element track hints", () => {
const frame = API.createElement({
type: "frame",
backgroundColor: "#ff0000",
});
const frameFromModernSource = {
...frame,
schemaState: {
tracks: {
[CORE_FRAME_SCHEMA_TRACK]:
CORE_SUPPORTED_TRACKS[CORE_FRAME_SCHEMA_TRACK],
},
},
};
const migrated = migrateElements([frameFromModernSource])!;
expect(migrated[0].backgroundColor).toBe("#ff0000");
});
it("should migrate mixed-hint elements individually", () => {
const legacyFrame = {
...API.createElement({
type: "frame",
backgroundColor: "#ff0000",
}),
schemaState: { tracks: {} },
};
const modernFrame = API.createElement({
type: "frame",
backgroundColor: "#00ff00",
});
const modernFrameWithTrack = {
...modernFrame,
schemaState: {
tracks: {
[CORE_FRAME_SCHEMA_TRACK]:
CORE_SUPPORTED_TRACKS[CORE_FRAME_SCHEMA_TRACK],
},
},
};
const migrated = migrateElements([legacyFrame, modernFrameWithTrack])!;
expect(migrated[0].backgroundColor).toBe(
DEFAULT_ELEMENT_PROPS.backgroundColor,
);
expect(migrated[1].backgroundColor).toBe("#00ff00");
});
it("should preserve higher-than-supported track versions", () => {
const frame = API.createElement({
type: "frame",
backgroundColor: "#ff0000",
});
const futureFrame = {
...frame,
schemaState: {
tracks: {
[CORE_FRAME_SCHEMA_TRACK]:
CORE_SUPPORTED_TRACKS[CORE_FRAME_SCHEMA_TRACK] + 1,
},
},
};
const migrated = migrateElements([futureFrame])!;
expect(migrated[0].schemaState.tracks[CORE_FRAME_SCHEMA_TRACK]).toBe(
CORE_SUPPORTED_TRACKS[CORE_FRAME_SCHEMA_TRACK] + 1,
);
expect(migrated[0].backgroundColor).toBe("#ff0000");
});
it("should normalize invalid schema state and preserve unknown tracks", () => {
const rect = {
...API.createElement({ type: "rectangle" }),
schemaState: {
tracks: {
"host.myapp.card": 4,
[CORE_FRAME_SCHEMA_TRACK]: 0,
},
},
};
const migrated = migrateElements([rect])!;
expect(migrated[0].schemaState.tracks[CORE_FRAME_SCHEMA_TRACK]).toBe(
SCHEMA_INITIAL_TRACK_VERSION,
);
expect(migrated[0].schemaState.tracks["host.myapp.card"]).toBe(4);
});
it("should not run plugin migrations unless plugins are provided", () => {
const rect = API.createElement({
type: "rectangle",
backgroundColor: "#ffd8a8",
});
const plugin: SchemaPlugin = {
id: "myapp",
migrations: [
{
id: "host.myapp.rect.normalize.v2",
namespace: "host.myapp",
track: "host.myapp.rectangle",
toVersion: 2,
title: "normalize rect background",
description: "plugin migration for testing",
targetTypes: ["rectangle"],
apply: (element) =>
element.type === "rectangle"
? { ...element, backgroundColor: "#12b886" }
: element,
},
],
};
const migratedWithoutPlugin = migrateElements([rect])!;
const migratedWithPlugin = migrateElements([rect], {
schemaMigrationRegistry: createSchemaMigrationRegistry([plugin]),
})!;
expect(migratedWithoutPlugin[0].backgroundColor).toBe("#ffd8a8");
expect(
migratedWithoutPlugin[0].schemaState.tracks["host.myapp.rectangle"],
).toBe(undefined);
expect(migratedWithPlugin[0].backgroundColor).toBe("#12b886");
expect(
migratedWithPlugin[0].schemaState.tracks["host.myapp.rectangle"],
).toBe(2);
});
});
+443
View File
@@ -0,0 +1,443 @@
import {
CORE_FRAME_SCHEMA_TRACK,
SCHEMA_CORE_NAMESPACE,
SCHEMA_INITIAL_TRACK_VERSION,
CORE_SUPPORTED_TRACKS,
} from "@excalidraw/element/schema";
import type { SchemaNamespace, SchemaTrack } from "@excalidraw/element/schema";
import type { ExcalidrawElement } from "@excalidraw/element/types";
export {
CORE_FRAME_SCHEMA_TRACK,
CORE_SUPPORTED_TRACKS,
SCHEMA_CORE_NAMESPACE,
SCHEMA_INITIAL_TRACK_VERSION,
};
export type { SchemaNamespace, SchemaTrack };
/**
* Schema migration flow:
* 0) Compile schema config from core migrations + optional host plugins.
* - validate plugin metadata
* - validate migration ordering/metadata
* - derive per-track supported versions for this registry
* 1) Normalize element.schemaState.tracks (invalid/missing -> initial track version).
* 2) Iterate compiled migrations in declaration order.
* 3) For matching element types, apply only forward migrations that are
* supported by the current registry config (never re-run, never downgrade).
* 4) Stamp migrated track versions back onto each element.
*/
/** One migration step for a single track version bump. */
export type SchemaMigration = {
/** Stable unique id for validation and debugging. */
id: string;
/** Owner of the migration: core or a host namespace. */
namespace: SchemaNamespace;
/** Version line this migration belongs to. */
track: SchemaTrack;
/** Target version reached after applying this migration. */
toVersion: number;
/** Human-readable metadata for maintainers/reviewers. */
title: string;
description: string;
/** Which element types this migration may transform ("*" = all). */
targetTypes: readonly ExcalidrawElement["type"][] | "*";
/** Pure transform for a single element. */
apply: (element: ExcalidrawElement) => ExcalidrawElement;
};
/**
* Optional host-provided migration bundle.
* Plugins are additive and may only declare host namespace migrations.
*/
export type SchemaPlugin = {
/** Stable plugin id for diagnostics. */
id: string;
/** Host migration steps merged with core migrations into one registry. */
migrations: readonly SchemaMigration[];
};
/** Default plugin registry (intentionally empty in core). */
export const SCHEMA_PLUGINS: readonly SchemaPlugin[] = [];
export type SchemaMigrationRegistry = Readonly<{
/** Fully validated core + host migrations used for this run. */
migrations: readonly SchemaMigration[];
/** Latest supported version for each known track in this run. */
supportedTrackVersions: Readonly<Record<string, number>>;
}>;
export const SCHEMA_MIGRATIONS: readonly SchemaMigration[] = [
{
id: "core.frame.background.normalize.v2",
namespace: SCHEMA_CORE_NAMESPACE,
track: CORE_FRAME_SCHEMA_TRACK,
toVersion: CORE_SUPPORTED_TRACKS[CORE_FRAME_SCHEMA_TRACK],
title: "Normalize legacy frame backgrounds",
description:
"Frames saved before frame track v2 must render without visible fill, so normalize backgroundColor to transparent on restore.",
targetTypes: ["frame"],
apply: (element) => {
if (element.type !== "frame") {
return element;
}
return {
...element,
backgroundColor: "transparent",
};
},
},
];
export const resolveTrackVersion = (trackVersion: unknown) => {
if (
Number.isInteger(trackVersion) &&
(trackVersion as number) >= SCHEMA_INITIAL_TRACK_VERSION
) {
return trackVersion as number;
}
return SCHEMA_INITIAL_TRACK_VERSION;
};
const normalizeSchemaTracks = (tracks: unknown) => {
if (!tracks || typeof tracks !== "object") {
return {} as Record<string, number>;
}
return Object.entries(tracks as Record<string, unknown>).reduce<
Record<string, number>
>((acc, [track, version]) => {
const normalizedVersion = resolveTrackVersion(version);
if (normalizedVersion >= SCHEMA_INITIAL_TRACK_VERSION) {
acc[track] = normalizedVersion;
}
return acc;
}, {});
};
const normalizeElementSchemaState = (
element: ExcalidrawElement,
): ExcalidrawElement["schemaState"] => {
const tracks = normalizeSchemaTracks(
(
element as ExcalidrawElement & {
schemaState?: ExcalidrawElement["schemaState"];
}
).schemaState?.tracks,
);
return {
tracks,
};
};
const ensureElementSchemaState = (element: ExcalidrawElement) => {
const normalizedSchemaState = normalizeElementSchemaState(element);
// Fast path: avoid reallocating when element already has normalized state.
if (element.schemaState === normalizedSchemaState) {
return element;
}
if (
element.schemaState &&
Object.keys(element.schemaState?.tracks || {}).length ===
Object.keys(normalizedSchemaState.tracks).length &&
Object.entries(normalizedSchemaState.tracks).every(
([track, version]) => element.schemaState?.tracks?.[track] === version,
)
) {
return element;
}
return {
...element,
schemaState: normalizedSchemaState,
};
};
const getTrackVersion = (element: ExcalidrawElement, track: SchemaTrack) => {
return resolveTrackVersion(element.schemaState.tracks[track]);
};
const withTrackVersion = (
element: ExcalidrawElement,
track: SchemaTrack,
version: number,
) => {
if (element.schemaState.tracks[track] === version) {
return element;
}
return {
...element,
schemaState: {
...element.schemaState,
tracks: {
...element.schemaState.tracks,
[track]: version,
},
},
};
};
const migrationMatchesElementType = (
migration: SchemaMigration,
element: ExcalidrawElement,
) => {
return (
migration.targetTypes === "*" ||
migration.targetTypes.includes(element.type)
);
};
export const validateSchemaMigrations = (
migrations: readonly SchemaMigration[],
) => {
const errors: string[] = [];
const seenIds = new Set<string>();
const previousVersionByTrack = new Map<string, number>();
for (const migration of migrations) {
if (!migration.id.trim()) {
errors.push("Migration id must be non-empty.");
}
if (seenIds.has(migration.id)) {
errors.push(`Duplicate schema migration id found: ${migration.id}.`);
}
seenIds.add(migration.id);
if (!migration.title.trim()) {
errors.push(`Migration "${migration.id}" title must be non-empty.`);
}
if (!migration.description.trim()) {
errors.push(
`Migration "${migration.id}" must include a non-empty description.`,
);
}
if (!Number.isInteger(migration.toVersion)) {
errors.push(`Migration "${migration.id}" must use an integer version.`);
}
if (migration.toVersion <= SCHEMA_INITIAL_TRACK_VERSION) {
errors.push(
`Migration "${migration.id}" version must be greater than ${SCHEMA_INITIAL_TRACK_VERSION}.`,
);
}
if (
migration.targetTypes !== "*" &&
(!migration.targetTypes.length ||
migration.targetTypes.some((type) => !type))
) {
errors.push(
`Migration "${migration.id}" must declare at least one target type.`,
);
}
const trackKey = `${migration.namespace}|${migration.track}`;
const previousVersion =
previousVersionByTrack.get(trackKey) ?? SCHEMA_INITIAL_TRACK_VERSION;
if (migration.toVersion <= previousVersion) {
errors.push(
`Migration "${migration.id}" must be ordered by increasing version within ${trackKey}.`,
);
}
previousVersionByTrack.set(trackKey, migration.toVersion);
if (
migration.namespace === SCHEMA_CORE_NAMESPACE &&
!migration.track.startsWith("excalidraw.")
) {
errors.push(
`Core migration "${migration.id}" must use an excalidraw.* track.`,
);
}
if (
migration.namespace === SCHEMA_CORE_NAMESPACE &&
!(migration.track in CORE_SUPPORTED_TRACKS)
) {
errors.push(
`Core migration "${migration.id}" track "${migration.track}" must be declared in CORE_SUPPORTED_TRACKS.`,
);
}
if (
migration.namespace !== SCHEMA_CORE_NAMESPACE &&
!migration.track.startsWith(`${migration.namespace}.`)
) {
errors.push(
`Host migration "${migration.id}" track must use namespace prefix ${migration.namespace}.`,
);
}
}
for (const [track, supportedVersion] of Object.entries(
CORE_SUPPORTED_TRACKS,
)) {
const migrationTrackKey = `${SCHEMA_CORE_NAMESPACE}|${track}`;
const lastDeclaredVersion =
previousVersionByTrack.get(migrationTrackKey) ??
SCHEMA_INITIAL_TRACK_VERSION;
if (lastDeclaredVersion !== supportedVersion) {
errors.push(
`Core supported track "${track}" (${supportedVersion}) must match last migration version (${lastDeclaredVersion}).`,
);
}
}
return errors;
};
export const validateSchemaPlugins = (plugins: readonly SchemaPlugin[]) => {
const errors: string[] = [];
const seenIds = new Set<string>();
for (const plugin of plugins) {
if (!plugin.id.trim()) {
errors.push("Schema plugin id must be non-empty.");
}
if (seenIds.has(plugin.id)) {
errors.push(`Duplicate schema plugin id found: ${plugin.id}.`);
}
seenIds.add(plugin.id);
for (const migration of plugin.migrations) {
if (migration.namespace === SCHEMA_CORE_NAMESPACE) {
errors.push(
`Schema plugin "${plugin.id}" cannot declare core migrations ("${migration.id}").`,
);
}
}
}
return errors;
};
const collectPluginMigrations = (plugins: readonly SchemaPlugin[]) =>
plugins.flatMap((plugin) => plugin.migrations);
/**
* Builds the registry "latest version" map:
* - core tracks come from CORE_SUPPORTED_TRACKS
* - host tracks are inferred from provided plugin migrations
*/
const getSupportedTrackVersions = (
migrations: readonly SchemaMigration[],
): Readonly<Record<string, number>> => {
const supportedTrackVersions: Record<string, number> = {
...CORE_SUPPORTED_TRACKS,
};
for (const migration of migrations) {
if (migration.namespace === SCHEMA_CORE_NAMESPACE) {
continue;
}
const currentSupportedVersion =
supportedTrackVersions[migration.track] ?? SCHEMA_INITIAL_TRACK_VERSION;
if (migration.toVersion > currentSupportedVersion) {
supportedTrackVersions[migration.track] = migration.toVersion;
}
}
return supportedTrackVersions;
};
export const createSchemaMigrationRegistry = (
plugins: readonly SchemaPlugin[] = SCHEMA_PLUGINS,
): SchemaMigrationRegistry => {
const pluginErrors = validateSchemaPlugins(plugins);
if (pluginErrors.length) {
throw new Error(
`Invalid schema plugin configuration:\n${pluginErrors.join("\n")}`,
);
}
const migrations = [
...SCHEMA_MIGRATIONS,
...collectPluginMigrations(plugins),
] as const;
const migrationErrors = validateSchemaMigrations(migrations);
if (migrationErrors.length) {
throw new Error(
`Invalid schema migration configuration:\n${migrationErrors.join("\n")}`,
);
}
return {
migrations,
supportedTrackVersions: getSupportedTrackVersions(migrations),
};
};
const CORE_SCHEMA_MIGRATION_REGISTRY = createSchemaMigrationRegistry();
/** Uses cached core config by default, recompiles when plugins are provided. */
const resolveSchemaMigrationRegistry = (
schemaMigrationRegistry: SchemaMigrationRegistry | undefined,
) => schemaMigrationRegistry || CORE_SCHEMA_MIGRATION_REGISTRY;
const migrateElement = (
element: ExcalidrawElement,
schemaMigrationRegistry: SchemaMigrationRegistry,
) => {
// Always migrate from a normalized per-element schema state.
let migratedElement = ensureElementSchemaState(element);
for (const migration of schemaMigrationRegistry.migrations) {
if (!migrationMatchesElementType(migration, migratedElement)) {
continue;
}
const currentTrackVersion = getTrackVersion(
migratedElement,
migration.track,
);
const supportedTrackVersion =
schemaMigrationRegistry.supportedTrackVersions[migration.track] ??
currentTrackVersion;
// Never re-run or downgrade.
if (currentTrackVersion >= migration.toVersion) {
continue;
}
// Preserve future data: ignore migrations newer than what this app supports.
if (migration.toVersion > supportedTrackVersion) {
continue;
}
// Apply transform, then stamp the element's track version.
migratedElement = withTrackVersion(
migration.apply(migratedElement),
migration.track,
migration.toVersion,
);
}
return migratedElement;
};
export const migrateElements = (
elements: readonly ExcalidrawElement[] | null | undefined,
opts?: {
schemaMigrationRegistry?: SchemaMigrationRegistry;
},
) => {
if (!elements) {
return elements;
}
const schemaMigrationRegistry = resolveSchemaMigrationRegistry(
opts?.schemaMigrationRegistry,
);
return elements.map((element) =>
migrateElement(element, schemaMigrationRegistry),
);
};
+1 -1
View File
@@ -30,7 +30,7 @@ export class HistoryDelta extends StoreDelta {
// as we always need to end up with a new version due to collaboration, // as we always need to end up with a new version due to collaboration,
// approaching each undo / redo as a new user action // approaching each undo / redo as a new user action
{ {
excludedProperties: new Set(["version", "versionNonce"]), excludedProperties: new Set(["version", "versionNonce", "schemaState"]),
}, },
); );
+4
View File
@@ -56,6 +56,7 @@ const ExcalidrawBase = (props: ExcalidrawProps) => {
aiEnabled, aiEnabled,
showDeprecatedFonts, showDeprecatedFonts,
renderScrollbars, renderScrollbars,
schemaPlugins,
} = props; } = props;
const canvasActions = props.UIOptions?.canvasActions; const canvasActions = props.UIOptions?.canvasActions;
@@ -149,6 +150,7 @@ const ExcalidrawBase = (props: ExcalidrawProps) => {
aiEnabled={aiEnabled !== false} aiEnabled={aiEnabled !== false}
showDeprecatedFonts={showDeprecatedFonts} showDeprecatedFonts={showDeprecatedFonts}
renderScrollbars={renderScrollbars} renderScrollbars={renderScrollbars}
schemaPlugins={schemaPlugins}
> >
{children} {children}
</App> </App>
@@ -317,3 +319,5 @@ export { getDataURL } from "./data/blob";
export { isElementLink } from "@excalidraw/element"; export { isElementLink } from "@excalidraw/element";
export { setCustomTextMetricsProvider } from "@excalidraw/element"; export { setCustomTextMetricsProvider } from "@excalidraw/element";
export { CommandPalette } from "./components/CommandPalette/CommandPalette";
+52 -2
View File
@@ -557,7 +557,9 @@
}, },
"welcomeScreen": { "welcomeScreen": {
"app": { "app": {
"center_heading": "جميع بياناتك محفوظة محليًا في المتصفح الخاص بك.", "center_heading": "",
"center_heading_line2": "",
"center_heading_line3": "",
"center_heading_plus": "هل تريد الانتقال إلى Excalidraw+ بدلاً من ذلك؟", "center_heading_plus": "هل تريد الانتقال إلى Excalidraw+ بدلاً من ذلك؟",
"menuHint": "التصدير، التفضيلات، اللغات..." "menuHint": "التصدير، التفضيلات، اللغات..."
}, },
@@ -612,7 +614,55 @@
"button": "إدراج", "button": "إدراج",
"description": "حاليًا، يتم دعم <flowchartLink>مخططات التدفق</flowchartLink>، <sequenceLink>التسلسلات</sequenceLink>، و<classLink>الفئات</classLink> فقط. سيتم عرض الأنواع الأخرى كصورة في Excalidraw.", "description": "حاليًا، يتم دعم <flowchartLink>مخططات التدفق</flowchartLink>، <sequenceLink>التسلسلات</sequenceLink>، و<classLink>الفئات</classLink> فقط. سيتم عرض الأنواع الأخرى كصورة في Excalidraw.",
"syntax": "صيغة Mermaid", "syntax": "صيغة Mermaid",
"preview": "معاينة" "preview": "معاينة",
"label": "",
"inputPlaceholder": ""
},
"ttd": {
"error": ""
},
"chat": {
"inputPlaceholder": "",
"inputPlaceholderWithMessages": "",
"generating": "",
"rateLimitRemaining": "",
"role": {
"user": "",
"assistant": "",
"system": ""
},
"aiBeta": "",
"label": "",
"menu": "",
"newChat": "",
"deleteChat": "",
"deleteMessage": "",
"viewAsMermaid": "",
"placeholder": {
"title": "",
"description": "",
"hint": ""
},
"preview": "",
"insert": "",
"retry": "",
"errors": {
"promptTooShort": "",
"promptTooLong": "",
"generationFailed": "",
"invalidDiagram": "",
"fixInMermaid": "",
"aiRepair": "",
"requestAborted": "",
"requestFailed": "",
"mermaidParseError": ""
},
"rateLimit": {
"messageLimit": "",
"generalRateLimit": "",
"messageLimitInputPlaceholder": ""
},
"upsellBtnLabel": ""
}, },
"quickSearch": { "quickSearch": {
"placeholder": "بحث سريع" "placeholder": "بحث سريع"
+51 -1
View File
@@ -558,6 +558,8 @@
"welcomeScreen": { "welcomeScreen": {
"app": { "app": {
"center_heading": "", "center_heading": "",
"center_heading_line2": "",
"center_heading_line3": "",
"center_heading_plus": "", "center_heading_plus": "",
"menuHint": "" "menuHint": ""
}, },
@@ -612,7 +614,55 @@
"button": "", "button": "",
"description": "", "description": "",
"syntax": "", "syntax": "",
"preview": "" "preview": "",
"label": "",
"inputPlaceholder": ""
},
"ttd": {
"error": ""
},
"chat": {
"inputPlaceholder": "",
"inputPlaceholderWithMessages": "",
"generating": "",
"rateLimitRemaining": "",
"role": {
"user": "",
"assistant": "",
"system": ""
},
"aiBeta": "",
"label": "",
"menu": "",
"newChat": "",
"deleteChat": "",
"deleteMessage": "",
"viewAsMermaid": "",
"placeholder": {
"title": "",
"description": "",
"hint": ""
},
"preview": "",
"insert": "",
"retry": "",
"errors": {
"promptTooShort": "",
"promptTooLong": "",
"generationFailed": "",
"invalidDiagram": "",
"fixInMermaid": "",
"aiRepair": "",
"requestAborted": "",
"requestFailed": "",
"mermaidParseError": ""
},
"rateLimit": {
"messageLimit": "",
"generalRateLimit": "",
"messageLimitInputPlaceholder": ""
},
"upsellBtnLabel": ""
}, },
"quickSearch": { "quickSearch": {
"placeholder": "" "placeholder": ""
+52 -2
View File
@@ -557,7 +557,9 @@
}, },
"welcomeScreen": { "welcomeScreen": {
"app": { "app": {
"center_heading": "Всичките Ви данни са запазени локално в браузъра Ви.", "center_heading": "",
"center_heading_line2": "",
"center_heading_line3": "",
"center_heading_plus": "", "center_heading_plus": "",
"menuHint": "Експорт, предпочитания, езици, ..." "menuHint": "Експорт, предпочитания, езици, ..."
}, },
@@ -612,7 +614,55 @@
"button": "Вмъкни", "button": "Вмъкни",
"description": "", "description": "",
"syntax": "Mermaid Синтаксис", "syntax": "Mermaid Синтаксис",
"preview": "Преглед" "preview": "Преглед",
"label": "",
"inputPlaceholder": ""
},
"ttd": {
"error": ""
},
"chat": {
"inputPlaceholder": "",
"inputPlaceholderWithMessages": "",
"generating": "",
"rateLimitRemaining": "",
"role": {
"user": "",
"assistant": "",
"system": ""
},
"aiBeta": "",
"label": "",
"menu": "",
"newChat": "",
"deleteChat": "",
"deleteMessage": "",
"viewAsMermaid": "",
"placeholder": {
"title": "",
"description": "",
"hint": ""
},
"preview": "",
"insert": "",
"retry": "",
"errors": {
"promptTooShort": "",
"promptTooLong": "",
"generationFailed": "",
"invalidDiagram": "",
"fixInMermaid": "",
"aiRepair": "",
"requestAborted": "",
"requestFailed": "",
"mermaidParseError": ""
},
"rateLimit": {
"messageLimit": "",
"generalRateLimit": "",
"messageLimitInputPlaceholder": ""
},
"upsellBtnLabel": ""
}, },
"quickSearch": { "quickSearch": {
"placeholder": "" "placeholder": ""
+51 -1
View File
@@ -558,6 +558,8 @@
"welcomeScreen": { "welcomeScreen": {
"app": { "app": {
"center_heading": "", "center_heading": "",
"center_heading_line2": "",
"center_heading_line3": "",
"center_heading_plus": "", "center_heading_plus": "",
"menuHint": "" "menuHint": ""
}, },
@@ -612,7 +614,55 @@
"button": "", "button": "",
"description": "", "description": "",
"syntax": "", "syntax": "",
"preview": "" "preview": "",
"label": "",
"inputPlaceholder": ""
},
"ttd": {
"error": ""
},
"chat": {
"inputPlaceholder": "",
"inputPlaceholderWithMessages": "",
"generating": "",
"rateLimitRemaining": "",
"role": {
"user": "",
"assistant": "",
"system": ""
},
"aiBeta": "",
"label": "",
"menu": "",
"newChat": "",
"deleteChat": "",
"deleteMessage": "",
"viewAsMermaid": "",
"placeholder": {
"title": "",
"description": "",
"hint": ""
},
"preview": "",
"insert": "",
"retry": "",
"errors": {
"promptTooShort": "",
"promptTooLong": "",
"generationFailed": "",
"invalidDiagram": "",
"fixInMermaid": "",
"aiRepair": "",
"requestAborted": "",
"requestFailed": "",
"mermaidParseError": ""
},
"rateLimit": {
"messageLimit": "",
"generalRateLimit": "",
"messageLimitInputPlaceholder": ""
},
"upsellBtnLabel": ""
}, },
"quickSearch": { "quickSearch": {
"placeholder": "" "placeholder": ""
+51 -1
View File
@@ -558,6 +558,8 @@
"welcomeScreen": { "welcomeScreen": {
"app": { "app": {
"center_heading": "", "center_heading": "",
"center_heading_line2": "",
"center_heading_line3": "",
"center_heading_plus": "", "center_heading_plus": "",
"menuHint": "" "menuHint": ""
}, },
@@ -612,7 +614,55 @@
"button": "", "button": "",
"description": "", "description": "",
"syntax": "", "syntax": "",
"preview": "" "preview": "",
"label": "",
"inputPlaceholder": ""
},
"ttd": {
"error": ""
},
"chat": {
"inputPlaceholder": "",
"inputPlaceholderWithMessages": "",
"generating": "",
"rateLimitRemaining": "",
"role": {
"user": "",
"assistant": "",
"system": ""
},
"aiBeta": "",
"label": "",
"menu": "",
"newChat": "",
"deleteChat": "",
"deleteMessage": "",
"viewAsMermaid": "",
"placeholder": {
"title": "",
"description": "",
"hint": ""
},
"preview": "",
"insert": "",
"retry": "",
"errors": {
"promptTooShort": "",
"promptTooLong": "",
"generationFailed": "",
"invalidDiagram": "",
"fixInMermaid": "",
"aiRepair": "",
"requestAborted": "",
"requestFailed": "",
"mermaidParseError": ""
},
"rateLimit": {
"messageLimit": "",
"generalRateLimit": "",
"messageLimitInputPlaceholder": ""
},
"upsellBtnLabel": ""
}, },
"quickSearch": { "quickSearch": {
"placeholder": "" "placeholder": ""
+52 -2
View File
@@ -557,7 +557,9 @@
}, },
"welcomeScreen": { "welcomeScreen": {
"app": { "app": {
"center_heading": "Totes les vostres dades es guarden localment al vostre navegador.", "center_heading": "",
"center_heading_line2": "",
"center_heading_line3": "",
"center_heading_plus": "Vols anar a Excalidraw+ en comptes?", "center_heading_plus": "Vols anar a Excalidraw+ en comptes?",
"menuHint": "Exportar, preferències, llenguatges..." "menuHint": "Exportar, preferències, llenguatges..."
}, },
@@ -612,7 +614,55 @@
"button": "Inseriu", "button": "Inseriu",
"description": "Actualment només s'admeten els diagrames <flowchartLink>Flowchart</flowchartLink>, <sequenceLink> Sequence, </sequenceLink> i <classLink> Class </classLink>. Els altres tipus es representaran com a imatge a Excalidraw.", "description": "Actualment només s'admeten els diagrames <flowchartLink>Flowchart</flowchartLink>, <sequenceLink> Sequence, </sequenceLink> i <classLink> Class </classLink>. Els altres tipus es representaran com a imatge a Excalidraw.",
"syntax": "Sintaxi de Mermaid", "syntax": "Sintaxi de Mermaid",
"preview": "Previsualització" "preview": "Previsualització",
"label": "",
"inputPlaceholder": ""
},
"ttd": {
"error": ""
},
"chat": {
"inputPlaceholder": "",
"inputPlaceholderWithMessages": "",
"generating": "",
"rateLimitRemaining": "",
"role": {
"user": "",
"assistant": "",
"system": ""
},
"aiBeta": "",
"label": "",
"menu": "",
"newChat": "",
"deleteChat": "",
"deleteMessage": "",
"viewAsMermaid": "",
"placeholder": {
"title": "",
"description": "",
"hint": ""
},
"preview": "",
"insert": "",
"retry": "",
"errors": {
"promptTooShort": "",
"promptTooLong": "",
"generationFailed": "",
"invalidDiagram": "",
"fixInMermaid": "",
"aiRepair": "",
"requestAborted": "",
"requestFailed": "",
"mermaidParseError": ""
},
"rateLimit": {
"messageLimit": "",
"generalRateLimit": "",
"messageLimitInputPlaceholder": ""
},
"upsellBtnLabel": ""
}, },
"quickSearch": { "quickSearch": {
"placeholder": "Cerca ràpida" "placeholder": "Cerca ràpida"
+52 -2
View File
@@ -557,7 +557,9 @@
}, },
"welcomeScreen": { "welcomeScreen": {
"app": { "app": {
"center_heading": "Všechna vaše data jsou uložena lokálně ve vašem prohlížeči.", "center_heading": "",
"center_heading_line2": "",
"center_heading_line3": "",
"center_heading_plus": "Chcete místo toho přejít na Excalidraw+?", "center_heading_plus": "Chcete místo toho přejít na Excalidraw+?",
"menuHint": "Export, nastavení, jazyky, ..." "menuHint": "Export, nastavení, jazyky, ..."
}, },
@@ -612,7 +614,55 @@
"button": "Vložit", "button": "Vložit",
"description": "", "description": "",
"syntax": "Mermaid syntaxe", "syntax": "Mermaid syntaxe",
"preview": "Náhled" "preview": "Náhled",
"label": "",
"inputPlaceholder": ""
},
"ttd": {
"error": ""
},
"chat": {
"inputPlaceholder": "",
"inputPlaceholderWithMessages": "",
"generating": "",
"rateLimitRemaining": "",
"role": {
"user": "",
"assistant": "",
"system": ""
},
"aiBeta": "",
"label": "",
"menu": "",
"newChat": "",
"deleteChat": "",
"deleteMessage": "",
"viewAsMermaid": "",
"placeholder": {
"title": "",
"description": "",
"hint": ""
},
"preview": "",
"insert": "",
"retry": "",
"errors": {
"promptTooShort": "",
"promptTooLong": "",
"generationFailed": "",
"invalidDiagram": "",
"fixInMermaid": "",
"aiRepair": "",
"requestAborted": "",
"requestFailed": "",
"mermaidParseError": ""
},
"rateLimit": {
"messageLimit": "",
"generalRateLimit": "",
"messageLimitInputPlaceholder": ""
},
"upsellBtnLabel": ""
}, },
"quickSearch": { "quickSearch": {
"placeholder": "Rychlé vyhledávání" "placeholder": "Rychlé vyhledávání"

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