Compare commits

..

67 Commits

Author SHA1 Message Date
dwelle 966cd35b08 fixes 2026-02-23 22:58:14 +01:00
dwelle 2e5bf3bb51 attempt to fix next-js example deploy issue 2026-02-23 22:25:52 +01:00
dwelle 0346233358 fix tsc 2026-02-23 21:34:38 +01:00
dwelle 9cc4f5b1d2 lint 2026-02-23 21:31:17 +01:00
dwelle 28292f4867 Merge branch 'master' into dwelle/oxc
# Conflicts:
#	packages/element/src/binding.ts
#	packages/element/src/elbowArrow.ts
#	packages/element/src/linearElementEditor.ts
#	packages/excalidraw/components/Actions.tsx
#	packages/excalidraw/components/App.tsx
#	packages/excalidraw/components/dropdownMenu/DropdownMenuItemLink.tsx
#	packages/excalidraw/components/dropdownMenu/common.ts
#	packages/excalidraw/tests/__snapshots__/excalidraw.test.tsx.snap
2026-02-23 21:28:39 +01:00
Márk Tolmács 7ea3229e17 fix(editor): Hardened fixed point and bound element parsing in restore (#10816)
* fix: Reinforce fixedPoint restore

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

* fix: Even more hardened boundElement in restore

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

* fix: Extract constant

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

* fix: Remove superfluous check from restore

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

* chore: Remove non-needed code path

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

* fix: More robust number test for fixedPoint parsing

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

* fix: Validate bindings for element being parsed

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

* unrelated type safety

---------

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

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

* fix lint

* factor out to utils

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

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

* fix: Lint

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

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

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

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

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

* go back to 5px

* update tests

* feat: Simplified update bind points

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

* fix: Remove non-needed export

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

* fix. Possessed arrows #1

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

* fix: Focus point projection stabilization

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

* fix: Remove arrow stability hack

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

* fix: Unbound other endpoint

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

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

* feat: Force exact center focus point

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

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

* fix: Tests

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

* fix: Snap to center around side mid point.

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

* Trigger CI

* fix: Midpoint outline focus point

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

* fix: Tests

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

* fix: Dragging existing arrow reset focus point on outline

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

* fix: Tests

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

* feat: Midpoint indicator

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

* fix: Rotated mid points

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

* fix: No hole

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

* feat: Cache hits and scene lookups

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

* chore: Remove debug

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

* fix: Consider hit threshold and inside override too

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

* fix: Increase outline midpoint sticky distance

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

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

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

* feat: Indicate lock-in

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

* chore: Remove Map caching

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

* fix: incorrect threshold

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

* fix: threshold setting

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

* fix: Hit caching

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

* fix: Simple arrow mid point selection inconsistency

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

* fix: cache override

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

* fix: Precise know dragging with midpoint refactor

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

* fear: Frame support

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

* fix: Crossing arrow won't trigger mid point

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

* fix: Arrow creation point highlight

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

* fix: Restore types & tests

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

* chore: Restore restore.ts

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

* fix: restore.ts

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

* fix: Elbow arrows reliably highlight center point

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

* fix: Highlight point ordering

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

* feat: Bind with focus point across shape

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

* fix: Lint

* fix: Midpoint and binding alignment

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

* chore: Indicator color

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

* chore: More knob tuning

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

* fix: Radius

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

* fix: Tests

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

* simplify point indicators

---------

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

* fix: Tests

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

* fix: Snapshots

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

* fix: Target point selection

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

* chore: Remove non-needed change

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

* chore: Try again removing non-needed modification

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

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

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

* fix: Area based edge case

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

* fix: Overlapping new arrow jump

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

---------

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

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

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

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

* fix: Tests

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

* fix: Snap to center around side mid point.

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

* Trigger CI

* fix: Midpoint outline focus point

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

* fix: Tests

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

* fix: Dragging existing arrow reset focus point on outline

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

* fix: Tests

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

* feat: Midpoint indicator

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

* fix: Rotated mid points

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

* fix: No hole

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

* feat: Cache hits and scene lookups

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

* chore: Remove debug

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

* fix: Consider hit threshold and inside override too

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

* fix: Increase outline midpoint sticky distance

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

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

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

* feat: Indicate lock-in

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

* chore: Remove Map caching

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

* fix: incorrect threshold

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

* fix: threshold setting

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

* fix: Hit caching

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

* fix: Simple arrow mid point selection inconsistency

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

* fix: cache override

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

* fix: Precise know dragging with midpoint refactor

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

* fear: Frame support

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

* fix: Crossing arrow won't trigger mid point

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

* fix: Arrow creation point highlight

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

* fix: Restore types & tests

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

* chore: Restore restore.ts

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

* fix: restore.ts

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

* fix: Elbow arrows reliably highlight center point

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

* fix: Highlight point ordering

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

* feat: Bind with focus point across shape

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

* fix: Lint

* fix: Midpoint and binding alignment

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

* chore: Indicator color

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

* chore: More knob tuning

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

* fix: Radius

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

* fix: Tests

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

* simplify point indicators

---------

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

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

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

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

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

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

* go back to 5px

* update tests

* chore: Refactor

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

* fix: Align focus points

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

---------

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

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

* fix: New arrow creation

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

* fix: Alt point setting on inside binding

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

* fix: Initial arrow creation with Alt

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

---------

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

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

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

* go back to 5px

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

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

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

* fix lint

* fix more lint

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

* New translations en.json (Vietnamese)

* New translations en.json (Russian)

* New translations en.json (Romanian)

* New translations en.json (French)

* New translations en.json (Spanish)

* New translations en.json (Arabic)

* New translations en.json (Bulgarian)

* New translations en.json (Catalan)

* New translations en.json (Czech)

* New translations en.json (Danish)

* New translations en.json (German)

* New translations en.json (Greek)

* New translations en.json (Basque)

* New translations en.json (Finnish)

* New translations en.json (Hebrew)

* New translations en.json (Hungarian)

* New translations en.json (Italian)

* New translations en.json (Japanese)

* New translations en.json (Korean)

* New translations en.json (Kurdish)

* New translations en.json (Lithuanian)

* New translations en.json (Dutch)

* New translations en.json (Punjabi)

* New translations en.json (Polish)

* New translations en.json (Portuguese)

* New translations en.json (Slovak)

* New translations en.json (Slovenian)

* New translations en.json (Swedish)

* New translations en.json (Turkish)

* New translations en.json (Ukrainian)

* New translations en.json (Chinese Simplified)

* New translations en.json (Chinese Traditional)

* New translations en.json (Galician)

* New translations en.json (Portuguese, Brazilian)

* New translations en.json (Indonesian)

* New translations en.json (Persian)

* New translations en.json (Khmer)

* New translations en.json (Tamil)

* New translations en.json (Bengali)

* New translations en.json (Marathi)

* New translations en.json (Thai)

* New translations en.json (Norwegian Nynorsk)

* New translations en.json (Kazakh)

* New translations en.json (Latvian)

* New translations en.json (Azerbaijani)

* New translations en.json (Hindi)

* New translations en.json (Burmese)

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

* New translations en.json (Sinhala)

* New translations en.json (Uzbek)

* New translations en.json (Norwegian Bokmal)

* New translations en.json (Occitan)

* New translations en.json (German, Switzerland)

* New translations en.json (Bengali, India)

* New translations en.json (Kabyle)

* New translations en.json (Karakalpak)

* Auto commit: Calculate translation coverage

* New translations en.json (Russian)

* Auto commit: Calculate translation coverage

* New translations en.json (Romanian)

* Auto commit: Calculate translation coverage

* New translations en.json (Italian)

* Auto commit: Calculate translation coverage

* New translations en.json (Italian)

* Auto commit: Calculate translation coverage

* New translations en.json (Hungarian)

* New translations en.json (Hungarian)

* New translations en.json (Hindi)

* New translations en.json (Dutch)

* New translations en.json (Dutch)

* New translations en.json (Vietnamese)

* New translations en.json (Russian)

* New translations en.json (Romanian)

* New translations en.json (French)

* New translations en.json (Spanish)

* New translations en.json (Arabic)

* New translations en.json (Bulgarian)

* New translations en.json (Catalan)

* New translations en.json (Czech)

* New translations en.json (Danish)

* New translations en.json (German)

* New translations en.json (Greek)

* New translations en.json (Basque)

* New translations en.json (Finnish)

* New translations en.json (Hebrew)

* New translations en.json (Hungarian)

* New translations en.json (Italian)

* New translations en.json (Japanese)

* New translations en.json (Korean)

* New translations en.json (Kurdish)

* New translations en.json (Lithuanian)

* New translations en.json (Dutch)

* New translations en.json (Punjabi)

* New translations en.json (Polish)

* New translations en.json (Portuguese)

* New translations en.json (Slovak)

* New translations en.json (Slovenian)

* New translations en.json (Swedish)

* New translations en.json (Turkish)

* New translations en.json (Ukrainian)

* New translations en.json (Chinese Simplified)

* New translations en.json (Chinese Traditional)

* New translations en.json (Galician)

* New translations en.json (Portuguese, Brazilian)

* New translations en.json (Indonesian)

* New translations en.json (Persian)

* New translations en.json (Khmer)

* New translations en.json (Tamil)

* New translations en.json (Bengali)

* New translations en.json (Marathi)

* New translations en.json (Thai)

* New translations en.json (Norwegian Nynorsk)

* New translations en.json (Kazakh)

* New translations en.json (Latvian)

* New translations en.json (Azerbaijani)

* New translations en.json (Hindi)

* New translations en.json (Burmese)

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

* New translations en.json (Sinhala)

* New translations en.json (Uzbek)

* New translations en.json (Norwegian Bokmal)

* New translations en.json (Occitan)

* New translations en.json (German, Switzerland)

* New translations en.json (Bengali, India)

* New translations en.json (Kabyle)

* New translations en.json (Karakalpak)

* New translations en.json (Romanian)

* New translations en.json (Vietnamese)

* New translations en.json (Russian)

* New translations en.json (Romanian)

* New translations en.json (French)

* New translations en.json (Spanish)

* New translations en.json (Arabic)

* New translations en.json (Bulgarian)

* New translations en.json (Catalan)

* New translations en.json (Czech)

* New translations en.json (Danish)

* New translations en.json (German)

* New translations en.json (Greek)

* New translations en.json (Basque)

* New translations en.json (Finnish)

* New translations en.json (Hebrew)

* New translations en.json (Hungarian)

* New translations en.json (Italian)

* New translations en.json (Japanese)

* New translations en.json (Korean)

* New translations en.json (Kurdish)

* New translations en.json (Lithuanian)

* New translations en.json (Dutch)

* New translations en.json (Punjabi)

* New translations en.json (Polish)

* New translations en.json (Portuguese)

* New translations en.json (Slovak)

* New translations en.json (Slovenian)

* New translations en.json (Swedish)

* New translations en.json (Turkish)

* New translations en.json (Ukrainian)

* New translations en.json (Chinese Simplified)

* New translations en.json (Chinese Traditional)

* New translations en.json (Galician)

* New translations en.json (Portuguese, Brazilian)

* New translations en.json (Indonesian)

* New translations en.json (Persian)

* New translations en.json (Khmer)

* New translations en.json (Tamil)

* New translations en.json (Bengali)

* New translations en.json (Marathi)

* New translations en.json (Thai)

* New translations en.json (Norwegian Nynorsk)

* New translations en.json (Kazakh)

* New translations en.json (Latvian)

* New translations en.json (Azerbaijani)

* New translations en.json (Hindi)

* New translations en.json (Burmese)

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

* New translations en.json (Sinhala)

* New translations en.json (Uzbek)

* New translations en.json (Norwegian Bokmal)

* New translations en.json (Occitan)

* New translations en.json (German, Switzerland)

* New translations en.json (Bengali, India)

* New translations en.json (Kabyle)

* New translations en.json (Karakalpak)

* New translations en.json (Romanian)

* New translations en.json (Italian)

* New translations en.json (Russian)

* New translations en.json (Romanian)

* New translations en.json (French)

* New translations en.json (Spanish)

* New translations en.json (Arabic)

* New translations en.json (Bulgarian)

* New translations en.json (Catalan)

* New translations en.json (Czech)

* New translations en.json (Danish)

* New translations en.json (German)

* New translations en.json (Greek)

* New translations en.json (Basque)

* New translations en.json (Finnish)

* New translations en.json (Hebrew)

* New translations en.json (Hungarian)

* New translations en.json (Italian)

* New translations en.json (Japanese)

* New translations en.json (Korean)

* New translations en.json (Kurdish)

* New translations en.json (Lithuanian)

* New translations en.json (Dutch)

* New translations en.json (Punjabi)

* New translations en.json (Polish)

* New translations en.json (Portuguese)

* New translations en.json (Slovak)

* New translations en.json (Slovenian)

* New translations en.json (Swedish)

* New translations en.json (Vietnamese)

* New translations en.json (Turkish)

* New translations en.json (Ukrainian)

* New translations en.json (Chinese Simplified)

* New translations en.json (Chinese Traditional)

* New translations en.json (Galician)

* New translations en.json (Portuguese, Brazilian)

* New translations en.json (Indonesian)

* New translations en.json (Persian)

* New translations en.json (Khmer)

* New translations en.json (Tamil)

* New translations en.json (Bengali)

* New translations en.json (Marathi)

* New translations en.json (Thai)

* New translations en.json (Norwegian Nynorsk)

* New translations en.json (Kazakh)

* New translations en.json (Latvian)

* New translations en.json (Azerbaijani)

* New translations en.json (Hindi)

* New translations en.json (Burmese)

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

* New translations en.json (Sinhala)

* New translations en.json (Uzbek)

* New translations en.json (Norwegian Bokmal)

* New translations en.json (Occitan)

* New translations en.json (German, Switzerland)

* New translations en.json (Bengali, India)

* New translations en.json (Kabyle)

* New translations en.json (Karakalpak)

* New translations en.json (Romanian)

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

* css tweaks

* update snaps

---------

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

* lint

* Inversion to match theme filter

* cleanup

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

* revert inversion algo & handle darkMode placeholder

---------

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

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

* fix: Snapshot update

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

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

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

* fix: Move visualdebug to elements

Due to dep circles

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

* fix: Possible test timeout

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

* fix: Incorrect hit test point input

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

* feat: Add fallback when dragged outside of allowed area

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

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

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

* fix: End bound indirect fix

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

* fix: Show indicator when arrow endpoint dragging

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

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

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

* chore: Refactor

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

* fix: Curve endpoint intersection

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

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

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

* fix: Tests

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

* chore: Fix lint

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

* feat: Dragging focus point off

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

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

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

* fix: Drag area for focus handles

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

* fix: Focus point size unified

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

* fix: Size bump for focus knob

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

* feat: Cache hits and scene lookups

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

* chore: Remove debug

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

* fix: Consider hit threshold and inside override too

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

* fix: Other shape switching

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

* perf: Update tolerance params

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

* fix: Focus know line width

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

* fix: knob offset

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

* fix: Full overlap

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

* chore: Remove Map caching

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

* fix: incorrect threshold

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

* fix: threshold setting

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

* fix: Hit caching

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

* fix: cache override

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

* fix: Snapshots

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

* feat: Redesigned focus point handling

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

* fix: Inside-inside mode

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

* chore: Remove comment

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

* feat: Allow focus knob outside the shape

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

* fix: Arrow endpoint offset

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

* fix: Focus knob element distance

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

* fix: Increase iteration on curve intersection calc

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

* fix: Handle disabled binding

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

* fix: Alt mode

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

* fix: Nested shape focus rewrite

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

* fix: Alt + Ctrl + arrow endpoitn

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

* fix: Hit ordering for focus points

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

* fix: Focus point visibility

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

* dry out renderFocusPointIndicator

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

* optimize retrieval of selectedLinearElement

* move focus highlighting into renderFocusPointIndicator to DRY out and colocate

* remove `disabled` state from focus highlight

* make focus point stroke color less prominent

* fix: No focus point for multi-point arrows

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

* fix: Arrow edit mode drag focus point release

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

* DRY out arrow point-like drag

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

---------

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

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

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

* fix FF

---------

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

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

* chore: Remove debug

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

* fix: Consider hit threshold and inside override too

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

* chore: Remove Map caching

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

* fix: incorrect threshold

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

* fix: threshold setting

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

* fix: Hit caching

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

* fix: cache override

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

---------

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

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

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

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

---------

Co-authored-by: Anvi Kudaraya <anvikudaraya417@gmail.com>
2026-01-26 18:52:13 +01:00
dwelle 63cd36e8ad remove husky 2026-01-25 18:33:49 +01:00
dwelle e1f6429e49 more react rules & support type-aware linting for later 2026-01-25 18:20:13 +01:00
dwelle 29ba7fe96d lint & format 2026-01-24 22:28:19 +01:00
dwelle be9981bda5 chore: replace eslint & prettier with oxc 2026-01-24 22:14:29 +01:00
zsviczian 60759d314d fix: Regression - invert SVGs in Dark Mode (#10695)
* initial implementation

* lint

* removed separate getThemeFilterValue function from renderElement

* removed BinaryFileData changes

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

* Adjust opacity calculation for canvas rendering

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

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

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

* fix: More conservative condition

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

---------

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

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

* bump

* fix package version

* yarn.lock

* naming and clearer type

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

* fix: eslint issue

* debug mermaid bundle size

* tests: covering the utils

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

* fix: removing replay feature

* fix: removing unused prop

* fix: bumping workbox cache limit

* snapshots + yarn.lock

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

* bump node@20

* css tweaks

* move files around & rewrite stream chunk schema

* random naming & file structure refactor + some tweaks

* fix preview theme

* support custom warning renderer

* label and css fix

* fix and rwrite 429 handling

* fix label

---------

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

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

* fix: Move visualdebug to elements

Due to dep circles

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

---------

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

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

* fix debug

---------

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

* fix comment

* remove conditional dark mode export

* make shape cache theme-aware

* refactor

* refactor

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

* Auto commit: Calculate translation coverage

* New translations en.json (French)

* New translations en.json (Spanish)

* New translations en.json (Dutch)

* Auto commit: Calculate translation coverage

* New translations en.json (Dutch)

* Auto commit: Calculate translation coverage

* New translations en.json (Dutch)

* New translations en.json (Dutch)

* New translations en.json (Spanish)

* New translations en.json (Italian)

* Auto commit: Calculate translation coverage

* New translations en.json (Slovak)

* Auto commit: Calculate translation coverage

* New translations en.json (Slovak)

* Auto commit: Calculate translation coverage

* New translations en.json (Vietnamese)

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

* use uqr

---------

Co-authored-by: dwelle <5153846+dwelle@users.noreply.github.com>
2026-01-02 11:08:27 +01:00
436 changed files with 20357 additions and 6656 deletions
-5
View File
@@ -19,11 +19,6 @@
"command": "yarn fix",
"runAtStart": false
},
"prettier": {
"name": "Prettify",
"command": "yarn prettier",
"runAtStart": false
},
"start": {
"name": "Start Excalidraw",
"command": "yarn start",
+2 -5
View File
@@ -12,7 +12,7 @@ VITE_APP_WS_SERVER_URL=http://localhost:3002
VITE_APP_PLUS_LP=https://plus.excalidraw.com
VITE_APP_PLUS_APP=http://localhost:3000
VITE_APP_AI_BACKEND=http://localhost:3015
VITE_APP_AI_BACKEND=http://localhost:3016
VITE_APP_FIREBASE_CONFIG='{"apiKey":"AIzaSyCMkxA60XIW8KbqMYL7edC4qT5l4qHX2h8","authDomain":"excalidraw-oss-dev.firebaseapp.com","projectId":"excalidraw-oss-dev","storageBucket":"excalidraw-oss-dev.appspot.com","messagingSenderId":"664559512677","appId":"1:664559512677:web:a385181f2928d328a7aa8c"}'
@@ -27,7 +27,7 @@ VITE_APP_ENABLE_TRACKING=true
FAST_REFRESH=false
# The port the run the dev server
VITE_APP_PORT=3000
VITE_APP_PORT=3001
#Debug flags
@@ -37,9 +37,6 @@ VITE_APP_DEBUG_ENABLE_TEXT_CONTAINER_BOUNDING_BOX=
# Set this flag to false if you want to open the overlay by default
VITE_APP_COLLAPSE_OVERLAY=true
# Set this flag to false to disable eslint
VITE_APP_ENABLE_ESLINT=true
# Enable PWA in dev server
VITE_APP_ENABLE_PWA=false
-3
View File
@@ -29,6 +29,3 @@ PQIDAQAB'
# Set the below flags explicitly to false in production mode since vite loads and merges .env.local vars when running the build command
VITE_APP_DEBUG_ENABLE_TEXT_CONTAINER_BOUNDING_BOX=false
VITE_APP_COLLAPSE_OVERLAY=false
# Enable eslint in dev server
VITE_APP_ENABLE_ESLINT=false
-11
View File
@@ -1,11 +0,0 @@
node_modules/
build/
package-lock.json
.vscode/
firebase/
dist/
public/workbox
packages/excalidraw/types
examples/**/public
dev-dist
coverage
-43
View File
@@ -1,43 +0,0 @@
{
"extends": ["@excalidraw/eslint-config", "react-app"],
"rules": {
"import/order": [
"warn",
{
"groups": ["builtin", "external", "internal", "parent", "sibling", "index", "object", "type"],
"pathGroups": [
{
"pattern": "@excalidraw/**",
"group": "external",
"position": "after"
}
],
"newlines-between": "always-and-inside-groups",
"warnOnUnassignedImports": true
}
],
"import/no-anonymous-default-export": "off",
"no-restricted-globals": "off",
"@typescript-eslint/consistent-type-imports": [
"error",
{
"prefer": "type-imports",
"disallowTypeAnnotations": false,
"fixStyle": "separate-type-imports"
}
],
"no-restricted-imports": [
"error",
{
"name": "jotai",
"message": "Do not import from \"jotai\" directly. Use our app-specific modules (\"editor-jotai\" or \"app-jotai\")."
}
],
"react/jsx-no-target-blank": [
"error",
{
"allowReferrer": true
}
]
}
}
+1 -1
View File
@@ -28,7 +28,7 @@ jobs:
export SENTRY_RELEASE=$(sentry-cli releases propose-version)
sentry-cli releases new $SENTRY_RELEASE --project $SENTRY_PROJECT
sentry-cli releases set-commits --auto $SENTRY_RELEASE
sentry-cli releases files $SENTRY_RELEASE upload-sourcemaps --no-rewrite ./build/static/js/ --url-prefix "~/static/js"
sentry-cli sourcemaps upload --release $SENTRY_RELEASE --no-rewrite ./build/static/js/ --url-prefix "~/static/js"
sentry-cli releases finalize $SENTRY_RELEASE
sentry-cli releases deploys $SENTRY_RELEASE new -e production
env:
+1 -1
View File
@@ -14,7 +14,7 @@ jobs:
- name: "Install Node"
uses: actions/setup-node@v2
with:
node-version: "18.x"
node-version: "20.x"
- name: "Install Deps"
run: yarn install
- name: "Test Coverage"
+2 -2
View File
@@ -9,10 +9,10 @@ jobs:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Setup Node.js 18.x
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: 18.x
node-version: 20.x
- name: Install and test
run: |
yarn install
+3 -1
View File
@@ -8,7 +8,9 @@
.history
.idea
.vercel
.vscode
.vscode/*
!.vscode/extensions.json
!.vscode/settings.recommended.json
.yarn
*.log
*.tgz
-14
View File
@@ -1,14 +0,0 @@
const { CLIEngine } = require("eslint");
// see https://github.com/okonet/lint-staged#how-can-i-ignore-files-from-eslintignore-
// for explanation
const cli = new CLIEngine({});
module.exports = {
"*.{js,ts,tsx}": files => {
return (
"eslint --max-warnings=0 --fix " + files.filter(file => !cli.isPathIgnored(file)).join(" ")
);
},
"*.{css,scss,json,md,html,yml}": ["prettier --write"],
};
+5
View File
@@ -0,0 +1,5 @@
{
"printWidth": 80,
"proseWrap": "never",
"trailingComma": "all"
}
+149
View File
@@ -0,0 +1,149 @@
{
"$schema": "https://raw.githubusercontent.com/oxc-project/oxc/main/npm/oxlint/configuration_schema.json",
"plugins": ["typescript", "react", "jsx-a11y", "import"],
"rules": {
"no-unused-vars": [
"warn",
{
"ignoreRestSiblings": true
}
],
"curly": "warn",
"no-console": [
"warn",
{
"allow": ["info", "warn", "error"]
}
],
"no-else-return": "warn",
"no-lonely-if": "warn",
"no-unneeded-ternary": "warn",
"no-unused-expressions": "warn",
"no-useless-return": "warn",
"no-var": "warn",
"one-var": "warn",
"prefer-arrow-callback": "warn",
"prefer-const": "warn",
"prefer-template": "warn",
"typescript/consistent-type-imports": [
"error",
{
"disallowTypeAnnotations": false
}
],
"typescript/no-restricted-imports": [
"error",
{
"patterns": [
{
"group": [
"../../excalidraw",
"../../../packages/excalidraw",
"@excalidraw/excalidraw"
],
"message": "Do not import from '@excalidraw/excalidraw' package anything but types, as this package must be independent.",
"allowTypeImports": true
}
]
}
],
"eslint/no-restricted-imports": [
"error",
{
"paths": [
{
"name": "jotai",
"message": "Do not import from \"jotai\" directly. Use our app-specific modules (\"editor-jotai\" or \"app-jotai\")."
}
]
}
],
"react/jsx-no-target-blank": [
"error",
{
"allowReferrer": true
}
],
"eslint/no-unreachable": "warn",
// react
"react/jsx-no-comment-textnodes": "error",
"react/iframe-missing-sandbox": "warn",
"react/rules-of-hooks": "error",
"react/no-unescaped-entities": "warn",
// for later
// ----------
// "react/no-array-index-key": "warn",
// "react/jsx-no-useless-fragment": "warn",
// will require major refactor
// ---------------------------
// "react/only-export-components": "warn",
// type-aware rules (requires --type-aware flag)
// -------------------------------------------------------------------------
"typescript/switch-exhaustiveness-check": "warn",
"typescript/unbound-method": [
"warn",
{
"ignoreStatic": true
}
],
// disabled rules
// -------------------------------------------------------------------------
// may be re-enabled later
"typescript/no-redundant-type-constituents": "off",
"typescript/no-unsafe-unary-minus": "off",
"typescript/no-floating-promises": "off",
// not planned
"eslint/no-async-promise-executor": "off",
"jsx-a11y/no-autofocus": "off",
"eslint-plugin-jsx-a11y/click-events-have-key-events": "off",
"eslint-plugin-jsx-a11y/label-has-associated-control": "off"
},
"ignorePatterns": [
"node_modules/",
"build/",
"dist/",
".vscode/",
"firebase/",
"public/workbox",
"packages/excalidraw/types",
"examples/**/public",
"dev-dist",
"coverage"
// "**/tests/**",
// "**/*.test*"
],
"overrides": [
{
"files": [
"packages/common/src/**/*.ts",
"packages/common/src/**/*.tsx",
"packages/element/src/**/*.ts",
"packages/element/src/**/*.tsx"
],
"rules": {
"typescript/no-restricted-imports": [
"error",
{
"patterns": [
{
"group": [
"../../excalidraw",
"../../../packages/excalidraw",
"@excalidraw/excalidraw"
],
"message": "Do not import from '@excalidraw/excalidraw' package anything but types, as this package must be independent.",
"allowTypeImports": true
}
]
}
]
}
}
]
}
View File
+3
View File
@@ -0,0 +1,3 @@
{
"recommendations": ["oxc.oxc-vscode"]
}
+22
View File
@@ -0,0 +1,22 @@
{
"oxc.enable": true,
"editor.formatOnSave": true,
"[javascript]": {
"editor.defaultFormatter": "oxc.oxc-vscode"
},
"[typescript]": {
"editor.defaultFormatter": "oxc.oxc-vscode"
},
"[javascriptreact]": {
"editor.defaultFormatter": "oxc.oxc-vscode"
},
"[typescriptreact]": {
"editor.defaultFormatter": "oxc.oxc-vscode"
},
"[json]": {
"editor.defaultFormatter": "oxc.oxc-vscode"
},
"[jsonc]": {
"editor.defaultFormatter": "oxc.oxc-vscode"
}
}
@@ -10,11 +10,11 @@ import { FONT_FAMILY } from "@excalidraw/excalidraw";
`FONT_FAMILY` contains all the font families used in `Excalidraw`. The default families are the following:
| Font Family | Description |
| ----------- | ---------------------- |
| `Excalifont` | The `Hand-drawn` font |
| `Nunito` | The `Normal` Font |
| `Comic Shanns` | The `Code` Font |
| Font Family | Description |
| -------------- | --------------------- |
| `Excalifont` | The `Hand-drawn` font |
| `Nunito` | The `Normal` Font |
| `Comic Shanns` | The `Code` Font |
Pre-selected family is `FONT_FAMILY.Excalifont`, unless it's overriden with `initialData.appState.currentItemFontFamily`.
@@ -13,7 +13,7 @@ Once the callback is triggered, you will need to store the api in state to acces
```jsx showLineNumbers
export default function App() {
const [excalidrawAPI, setExcalidrawAPI] = useState(null);
return <Excalidraw excalidrawAPI={(api)=> setExcalidrawAPI(api)} />;
return <Excalidraw excalidrawAPI={(api) => setExcalidrawAPI(api)} />;
}
```
@@ -362,10 +362,9 @@ This API has the below signature. It sets the `tool` passed in param as the acti
```ts
(
tool: (
| { type: ToolType }
| { type: "custom"; customType: string }
) & { locked?: boolean },
tool: ({ type: ToolType } | { type: "custom"; customType: string }) & {
locked?: boolean;
},
) => {};
```
@@ -1,7 +1,15 @@
# initialData
<pre>
&#123; elements?: <a href="https://github.com/excalidraw/excalidraw/blob/master/packages/excalidraw/element/types.ts#L114">ExcalidrawElement[]</a>, appState?: <a href="https://github.com/excalidraw/excalidraw/blob/master/packages/excalidraw/types.ts#L95">AppState</a> &#125;
&#123; elements?:{" "}
<a href="https://github.com/excalidraw/excalidraw/blob/master/packages/excalidraw/element/types.ts#L114">
ExcalidrawElement[]
</a>
, appState?:{" "}
<a href="https://github.com/excalidraw/excalidraw/blob/master/packages/excalidraw/types.ts#L95">
AppState
</a>{" "}
&#125;
</pre>
This helps to load Excalidraw with `initialData`. It must be an object or a [promise](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Promise/Promise) which resolves to an object containing the below optional fields.
@@ -46,7 +54,7 @@ function App() {
},
],
appState: { zenModeEnabled: true, viewBackgroundColor: "#a5d8ff" },
scrollToContent: true
scrollToContent: true,
}}
/>
</div>
@@ -3,7 +3,7 @@
All `props` are _optional_.
| Name | Type | Default | Description |
| --- | --- | --- | --- |
| --- | --- | --- | --- | --- |
| [`initialData`](/docs/@excalidraw/excalidraw/api/props/initialdata) | `object` &#124; `null` &#124; <code>Promise<object &#124; null></code> | `null` | The initial data with which app loads. |
| [`excalidrawAPI`](/docs/@excalidraw/excalidraw/api/props/excalidraw-api) | `function` | \_ | Callback triggered with the excalidraw api once rendered |
| [`isCollaborating`](#iscollaborating) | `boolean` | \_ | This indicates if the app is in `collaboration` mode |
@@ -31,7 +31,7 @@ All `props` are _optional_.
| [`generateIdForFile`](#generateidforfile) | `function` | \_ | Allows you to override `id` generation for files added on canvas |
| [`validateEmbeddable`](#validateembeddable) | `string[]` \| `boolean` \| `RegExp` \| `RegExp[]` \| <code>((link: string) => boolean &#124; undefined)</code> | \_ | use for custom src url validation |
| [`renderEmbeddable`](/docs/@excalidraw/excalidraw/api/props/render-props#renderEmbeddable) | `function` | \_ | Render function that can override the built-in `<iframe>` |
| [`renderScrollbars`] | `boolean`| | `false` | Indicates whether scrollbars will be shown
| [`renderScrollbars`] | `boolean` | | `false` | Indicates whether scrollbars will be shown |
### Storing custom data on Excalidraw elements
@@ -247,7 +247,7 @@ This prop indicates whether to `focus` the Excalidraw component on page load. De
Allows you to override `id` generation for files added on canvas (images). By default, an SHA-1 digest of the file is used.
```tsx
(file: File) => string | Promise<string>
(file: File) => string | Promise<string>;
```
### validateEmbeddable
@@ -65,7 +65,7 @@ If user choses to `dock` the sidebar, it will push the right part of the UI towa
function App() {
return (
<div style={{ height: "500px" }}>
<Excalidraw UIOptions={{dockedSidebarBreakpoint: 200}}/>
<Excalidraw UIOptions={{ dockedSidebarBreakpoint: 200 }} />
</div>
);
}
@@ -73,9 +73,8 @@ function App() {
## tools
This `prop` controls the visibility of the tools in the editor.
Currently you can control the visibility of `image` tool via this prop.
This `prop` controls the visibility of the tools in the editor. Currently you can control the visibility of `image` tool via this prop.
| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| image | boolean | true | Decides whether `image` tool should be visible.
| Prop | Type | Default | Description |
| ----- | ------- | ------- | ----------------------------------------------- |
| image | boolean | true | Decides whether `image` tool should be visible. |
@@ -14,35 +14,44 @@ We're working on much improved export utilities. Stay tuned!
**_Signature_**
<pre>
exportToCanvas(&#123;<br/>&nbsp;
elements,<br/>&nbsp;
appState<br/>&nbsp;
getDimensions,<br/>&nbsp;
files,<br/>&nbsp;
exportPadding?: number;<br/>
&#125;: <a href="https://github.com/excalidraw/excalidraw/blob/master/packages/utils/export.ts#L24">ExportOpts</a>
exportToCanvas(&#123;
<br />
&nbsp; elements,
<br />
&nbsp; appState
<br />
&nbsp; getDimensions,
<br />
&nbsp; files,
<br />
&nbsp; exportPadding?: number;
<br />
&#125;:{" "}
<a href="https://github.com/excalidraw/excalidraw/blob/master/packages/utils/export.ts#L24">
ExportOpts
</a>
</pre>
| Name | Type | Default | Description |
| --- | --- | --- | --- |
| `elements` | [Excalidraw Element []](https://github.com/excalidraw/excalidraw/blob/master/packages/excalidraw/element/types.ts#L114) | | The elements to be exported to canvas. |
| `appState` | [AppState](https://github.com/excalidraw/excalidraw/blob/master/packages/excalidraw/packages/utils.ts#L23) | [Default App State](https://github.com/excalidraw/excalidraw/blob/master/packages/excalidraw/appState.ts#L17) | The app state of the scene. |
| [`getDimensions`](#getdimensions) | `function` | _ | A function which returns the `width`, `height`, and optionally `scale` (defaults to `1`), with which canvas is to be exported. |
| `maxWidthOrHeight` | `number` | _ | The maximum `width` or `height` of the exported image. If provided, `getDimensions` is ignored. |
| `files` | [BinaryFiles](https://github.com/excalidraw/excalidraw/blob/master/packages/excalidraw/types.ts#L59) | _ | The files added to the scene. |
| [`getDimensions`](#getdimensions) | `function` | \_ | A function which returns the `width`, `height`, and optionally `scale` (defaults to `1`), with which canvas is to be exported. |
| `maxWidthOrHeight` | `number` | \_ | The maximum `width` or `height` of the exported image. If provided, `getDimensions` is ignored. |
| `files` | [BinaryFiles](https://github.com/excalidraw/excalidraw/blob/master/packages/excalidraw/types.ts#L59) | \_ | The files added to the scene. |
| `exportPadding` | `number` | `10` | The `padding` to be added on canvas. |
#### getDimensions
```tsx
(width: number, height: number) => {
(width: number, height: number) => {
width: number,
height: number,
scale?: number
height: number,
scale?: number
}
```
A function which returns the `width`, `height`, and optionally `scale` (defaults to `1`), with which canvas is to be exported.
A function which returns the `width`, `height`, and optionally `scale` (defaults to `1`), with which canvas is to be exported.
**How to use**
@@ -57,17 +66,17 @@ function App() {
const [canvasUrl, setCanvasUrl] = useState("");
const [excalidrawAPI, setExcalidrawAPI] = useState(null);
return (
return (
<>
<button
className="custom-button"
onClick={async () => {
if (!excalidrawAPI) {
return
return;
}
const elements = excalidrawAPI.getSceneElements();
if (!elements || !elements.length) {
return
return;
}
const canvas = await exportToCanvas({
elements,
@@ -76,7 +85,9 @@ function App() {
exportWithDarkMode: false,
},
files: excalidrawAPI.getFiles(),
getDimensions: () => { return {width: 350, height: 350}}
getDimensions: () => {
return { width: 350, height: 350 };
},
});
const ctx = canvas.getContext("2d");
ctx.font = "30px Virgil";
@@ -90,15 +101,13 @@ function App() {
<img src={canvasUrl} alt="" />
</div>
<div style={{ height: "400px" }}>
<Excalidraw excalidrawAPI={(api) => setExcalidrawAPI(api)}
/>
<Excalidraw excalidrawAPI={(api) => setExcalidrawAPI(api)} />
</div>
</>
)
);
}
```
### exportToBlob
**_Signature_**
@@ -114,7 +123,7 @@ exportToBlob(<br/>&nbsp;
| Name | Type | Default | Description |
| --- | --- | --- | --- |
| `opts` | `object` | _ | This param is passed to `exportToCanvas`. You can refer to [`exportToCanvas`](#exporttocanvas) |
| `opts` | `object` | \_ | This param is passed to `exportToCanvas`. You can refer to [`exportToCanvas`](#exporttocanvas) |
| `mimeType` | `string` | `image/png` | Indicates the image format. |
| `quality` | `number` | `0.92` | A value between `0` and `1` indicating the [image quality](https://developer.mozilla.org/en-US/docs/Web/API/HTMLCanvasElement/toBlob#parameters). Applies only to `image/jpeg`/`image/webp` MIME types. |
| `exportPadding` | `number` | `10` | The padding to be added on canvas. |
@@ -132,26 +141,34 @@ Returns a promise which resolves with a [blob](https://developer.mozilla.org/en-
**_Signature_**
<pre>
exportToSvg(&#123;<br/>&nbsp;
elements:&nbsp;
<a href="https://github.com/excalidraw/excalidraw/blob/master/packages/excalidraw/element/types.ts#L114">
ExcalidrawElement[]
</a>,<br/>&nbsp;
appState:
<a href="https://github.com/excalidraw/excalidraw/blob/master/packages/excalidraw/types.ts#L95"> AppState
</a>,<br/>&nbsp;
exportPadding: number,<br/>&nbsp;
metadata: string,<br/>&nbsp;
files:&nbsp;
exportToSvg(&#123;
<br />
&nbsp; elements:&nbsp;
<a href="https://github.com/excalidraw/excalidraw/blob/master/packages/excalidraw/element/types.ts#L114">
ExcalidrawElement[]
</a>
,<br />
&nbsp; appState:
<a href="https://github.com/excalidraw/excalidraw/blob/master/packages/excalidraw/types.ts#L95">
{" "}
AppState
</a>
,<br />
&nbsp; exportPadding: number,
<br />
&nbsp; metadata: string,
<br />
&nbsp; files:&nbsp;
<a href="https://github.com/excalidraw/excalidraw/blob/master/packages/excalidraw/types.ts#L59">
BinaryFiles
</a>,<br/>
&#125;);
BinaryFiles
</a>
,<br />
&#125;);
</pre>
| Name | Type | Default | Description |
| --- | --- | --- | --- |
| elements | [Excalidraw Element []](https://github.com/excalidraw/excalidraw/blob/master/packages/excalidraw/element/types.ts#L114) | | The elements to exported as `svg `|
| elements | [Excalidraw Element []](https://github.com/excalidraw/excalidraw/blob/master/packages/excalidraw/element/types.ts#L114) | | The elements to exported as `svg ` |
| appState | [AppState](https://github.com/excalidraw/excalidraw/blob/master/packages/excalidraw/types.ts#L95) | [defaultAppState](https://github.com/excalidraw/excalidraw/blob/master/packages/excalidraw/appState.ts#L11) | The `appState` of the scene |
| exportPadding | number | 10 | The `padding` to be added on canvas |
| files | [BinaryFiles](https://github.com/excalidraw/excalidraw/blob/master/packages/excalidraw/types.ts#L64) | undefined | The `files` added to the scene. |
@@ -176,7 +193,7 @@ exportToClipboard(<br/>&nbsp;
| `opts` | | | This param is same as the params passed to `exportToCanvas`. You can refer to [`exportToCanvas`](#exporttocanvas). |
| `mimeType` | `string` | `image/png` | Indicates the image format, this will be used when exporting as `png`. |
| `quality` | `number` | `0.92` | A value between `0` and `1` indicating the [image quality](https://developer.mozilla.org/en-US/docs/Web/API/HTMLCanvasElement/toBlob#parameters). Applies only to `image/jpeg` / `image/webp` MIME types. This will be used when exporting as `png`. |
| `type` | 'png' &#124; 'svg' &#124; 'json' | _ | This determines the format to which the scene data should be `exported`. |
| `type` | 'png' &#124; 'svg' &#124; 'json' | \_ | This determines the format to which the scene data should be `exported`. |
**How to use**
@@ -20,8 +20,7 @@ import { restoreAppState } from "@excalidraw/excalidraw";
This function will make sure all the `keys` have appropriate `values` in [appState](https://github.com/excalidraw/excalidraw/blob/master/packages/excalidraw/types.ts#L95) and if any key is missing, it will be set to its `default` value.
When `localAppState` is supplied, it's used in place of values that are missing (`undefined`) in `appState` instead of the defaults.
Use this as a way to not override user's defaults if you persist them.
You can pass `null` / `undefined` if not applicable.
Use this as a way to not override user's defaults if you persist them. You can pass `null` / `undefined` if not applicable.
### restoreElements
@@ -36,10 +35,10 @@ restoreElements(
</pre>
| Prop | Type | Description |
| ---- | ---- | ---- |
| --- | --- | --- |
| `elements` | <a href="https://github.com/excalidraw/excalidraw/blob/master/packages/excalidraw/element/types.ts#L114">ImportedDataState["elements"]</a> | The `elements` to be restored |
| [`localElements`](#localelements) | <a href="https://github.com/excalidraw/excalidraw/blob/master/packages/excalidraw/element/types.ts#L114">ExcalidrawElement[]</a> &#124; null &#124; undefined | When `localElements` are supplied, they are used to ensure that existing restored elements reuse `version` (and increment it), and regenerate `versionNonce`. |
| [`opts`](#opts) | `Object` | The extra optional parameter to configure restored elements
| [`localElements`](#localelements) | <a href="https://github.com/excalidraw/excalidraw/blob/master/packages/excalidraw/element/types.ts#L114">ExcalidrawElement[]</a> &#124; null &#124; undefined | When `localElements` are supplied, they are used to ensure that existing restored elements reuse `version` (and increment it), and regenerate `versionNonce`. |
| [`opts`](#opts) | `Object` | The extra optional parameter to configure restored elements |
#### localElements
@@ -47,13 +46,14 @@ When `localElements` are supplied, they are used to ensure that existing restore
Use this when you `import` elements which may already be present in the scene to ensure that you do not disregard the newly imported elements if you're using element version to detect the update
#### opts
The extra optional parameter to configure restored elements. It has the following attributes
| Prop | Type | Description|
| --- | --- | ------|
| Prop | Type | Description |
| --- | --- | --- |
| `refreshDimensions` | `boolean` | Indicates whether we should also _recalculate_ text element dimensions. Since this is a potentially costly operation, you may want to disable it if you restore elements in tight loops, such as during collaboration. |
| `repairBindings` |`boolean` | Indicates whether the _bindings_ for the elements should be repaired. This is to make sure there are no containers with non existent bound text element id and no bound text elements with non existent container id. |
| `normalizeIndices` |`boolean` | Indicates whether _fractional indices_ for the elements should be normalized. This is to prevent possible issues caused by using stale (too old, too long) indices. |
| `repairBindings` | `boolean` | Indicates whether the _bindings_ for the elements should be repaired. This is to make sure there are no containers with non existent bound text element id and no bound text elements with non existent container id. |
| `normalizeIndices` | `boolean` | Indicates whether _fractional indices_ for the elements should be normalized. This is to prevent possible issues caused by using stale (too old, too long) indices. |
**_How to use_**
@@ -94,8 +94,12 @@ This function makes sure elements and state is set to appropriate values and set
**_Signature_**
<pre>
restoreLibraryItems(libraryItems: <a href="https://github.com/excalidraw/excalidraw/blob/master/packages/excalidraw/data/types.ts#L34">ImportedDataState["libraryItems"]</a>,<br/>&nbsp;
defaultStatus: "published" | "unpublished")
restoreLibraryItems(libraryItems:{" "}
<a href="https://github.com/excalidraw/excalidraw/blob/master/packages/excalidraw/data/types.ts#L34">
ImportedDataState["libraryItems"]
</a>
,<br />
&nbsp; defaultStatus: "published" | "unpublished")
</pre>
**_How to use_**
@@ -38,6 +38,7 @@ For a complete list of variables, check [theme.scss](https://github.com/excalidr
--color-primary-light: #dcbec9;
}
```
```tsx live
function App() {
return (
@@ -23,7 +23,7 @@ To start the example app using the `@excalidraw/excalidraw` package, follow the
```
[http://localhost:3001](http://localhost:3001) will open in your default browser.
This is the same example as the [CodeSandbox](https://codesandbox.io/p/sandbox/github/excalidraw/excalidraw/tree/master/examples/with-script-in-browser) example.
## Releasing
+4 -9
View File
@@ -6,21 +6,17 @@ No, Excalidraw package doesn't come with collaboration built in, since the imple
### Turning off Aggressive Anti-Fingerprinting in Brave browser
When *Aggressive Anti-Fingerprinting* is turned on, the `measureText` API breaks which in turn breaks the Text Elements in your drawings. Here is more [info](https://github.com/excalidraw/excalidraw/pull/6336) on the same.
When _Aggressive Anti-Fingerprinting_ is turned on, the `measureText` API breaks which in turn breaks the Text Elements in your drawings. Here is more [info](https://github.com/excalidraw/excalidraw/pull/6336) on the same.
We strongly recommend turning it off. You can follow the steps below on how to do so.
1. Open [excalidraw.com](https://excalidraw.com) in Brave and click on the **Shield** button
![Shield button](../../assets/brave-shield.png)
1. Open [excalidraw.com](https://excalidraw.com) in Brave and click on the **Shield** button ![Shield button](../../assets/brave-shield.png)
<div style={{width:'30rem'}}>
2. Once opened, look for **Aggressively Block Fingerprinting**
![Aggressive block fingerprinting](../../assets/aggressive-block-fingerprint.png)
2. Once opened, look for **Aggressively Block Fingerprinting** ![Aggressive block fingerprinting](../../assets/aggressive-block-fingerprint.png)
3. Switch to **Block Fingerprinting**
![Block filtering](../../assets/block-fingerprint.png)
3. Switch to **Block Fingerprinting** ![Block filtering](../../assets/block-fingerprint.png)
4. Thats all. All text elements should be fixed now 🎉
@@ -28,7 +24,6 @@ We strongly recommend turning it off. You can follow the steps below on how to d
If disabling this setting doesn't fix the display of text elements, please consider opening an [issue](https://github.com/excalidraw/excalidraw/issues/new) on our GitHub, or message us on [Discord](https://discord.gg/UexuTaE).
### ReferenceError: process is not defined
When using `vite` or any build tools, you will have to make sure the `process` is accessible as we are accessing `process.env.IS_PREACT` to decide whether to use `preact` build.
@@ -31,9 +31,7 @@ or, if you serve your assets from the root of your CDN, you would do:
```js
// Vanilla
<head>
<script>
window.EXCALIDRAW_ASSET_PATH = "https://my.cdn.com/assets/";
</script>
<script>window.EXCALIDRAW_ASSET_PATH = "https://my.cdn.com/assets/";</script>
</head>
```
@@ -41,8 +39,8 @@ or, if you prefer the path to be dynamicly set based on the `location.origin`, y
```jsx
// Next.js
<Script id="load-env-variables" strategy="beforeInteractive" >
{ `window["EXCALIDRAW_ASSET_PATH"] = location.origin;` } // or use just "/"!
<Script id="load-env-variables" strategy="beforeInteractive">
{`window["EXCALIDRAW_ASSET_PATH"] = location.origin;`} // or use just "/"!
</Script>
```
@@ -12,8 +12,7 @@ import { Excalidraw } from "@excalidraw/excalidraw";
Throughout the documentation we use live, editable Excalidraw examples like the one shown below.
While we aim for the examples to closely reflect what you'd get if you rendered it yourself, we actually initialize it with some props behind the scenes.
For example, we're passing a `theme` prop to it based on the current color theme of the docs you're just reading.
While we aim for the examples to closely reflect what you'd get if you rendered it yourself, we actually initialize it with some props behind the scenes. For example, we're passing a `theme` prop to it based on the current color theme of the docs you're just reading.
:::
@@ -58,80 +57,76 @@ If you are using `pages router` then importing the wrapper dynamically would wor
<Tabs>
<TabItem value="Excalidraw Wrapper" label="Excalidraw Wrapper" >
```jsx showLineNumbers
"use client";
import { Excalidraw, convertToExcalidrawElements } from "@excalidraw/excalidraw";
```jsx showLineNumbers
"use client";
import { Excalidraw, convertToExcalidrawElements } from "@excalidraw/excalidraw";
import "@excalidraw/excalidraw/index.css";
import "@excalidraw/excalidraw/index.css";
const ExcalidrawWrapper: React.FC = () => {
console.info(convertToExcalidrawElements([{
type: "rectangle",
id: "rect-1",
width: 186.47265625,
height: 141.9765625,
},]));
return (
<div style={{height:"500px", width:"500px"}}>
<Excalidraw />
</div>
);
};
export default ExcalidrawWrapper;
```
const ExcalidrawWrapper: React.FC = () => {
console.info(convertToExcalidrawElements([{
type: "rectangle",
id: "rect-1",
width: 186.47265625,
height: 141.9765625,
},]));
return (
<div style={{height:"500px", width:"500px"}}>
<Excalidraw />
</div>
);
};
export default ExcalidrawWrapper;
```
</TabItem>
<TabItem value="pages" label="Pages router">
```jsx showLineNumbers
import dynamic from "next/dynamic";
```jsx showLineNumbers
import dynamic from "next/dynamic";
// Since client components get prerenderd on server as well hence importing
// the excalidraw stuff dynamically with ssr false
// Since client components get prerenderd on server as well hence importing
// the excalidraw stuff dynamically with ssr false
const ExcalidrawWrapper = dynamic(
async () => (await import("../excalidrawWrapper")).default,
{
ssr: false,
},
);
const ExcalidrawWrapper = dynamic(
async () => (await import("../excalidrawWrapper")).default,
{
ssr: false,
},
);
export default function Page() {
return <ExcalidrawWrapper />;
}
```
export default function Page() {
return (
<ExcalidrawWrapper />
);
}
```
</TabItem>
<TabItem value="app" label="App router">
```jsx showLineNumbers
import dynamic from "next/dynamic";
```jsx showLineNumbers
import dynamic from "next/dynamic";
// Since client components get prerenderd on server as well hence importing
// the excalidraw stuff dynamically with ssr false
// Since client components get prerenderd on server as well hence importing
// the excalidraw stuff dynamically with ssr false
const ExcalidrawWrapper = dynamic(
async () => (await import("../excalidrawWrapper")).default,
{
ssr: false,
},
);
const ExcalidrawWrapper = dynamic(
async () => (await import("../excalidrawWrapper")).default,
{
ssr: false,
},
);
export default function Page() {
return (
<ExcalidrawWrapper />
);
}
```
export default function Page() {
return <ExcalidrawWrapper />;
}
```
</TabItem>
</Tabs>
{/* Link should be updated to point to the latest! */}
Here is a [source code](https://github.com/excalidraw/excalidraw/tree/master/examples/with-nextjs) for the example with app and pages router. You you can try it out [here](https://excalidraw-package-example-with-nextjs.vercel.app/).
{/* Link should be updated to point to the latest! */} Here is a [source code](https://github.com/excalidraw/excalidraw/tree/master/examples/with-nextjs) for the example with app and pages router. You you can try it out [here](https://excalidraw-package-example-with-nextjs.vercel.app/).
The `types` are available at `@excalidraw/excalidraw/types`, check [CodeSandbox](https://codesandbox.io/p/sandbox/github/excalidraw/excalidraw/tree/master/examples/with-script-in-browser) example for details.
@@ -154,6 +149,7 @@ Since Vite removes env variables by default, you can update the vite config to e
"process.env.IS_PREACT": JSON.stringify("true"),
},
```
:::
## Browser
@@ -180,15 +176,16 @@ import TabItem from "@theme/TabItem";
/>
<link rel="stylesheet" href="./index.css" />
<script>
window.EXCALIDRAW_ASSET_PATH = "https://esm.sh/@excalidraw/excalidraw@0.18.0/dist/prod/";
</script>
window.EXCALIDRAW_ASSET_PATH =
"https://esm.sh/@excalidraw/excalidraw@0.18.0/dist/prod/";
</script>
<script type="importmap">
{
"imports": {
"react": "https://esm.sh/react@19.0.0",
"react/jsx-runtime": "https://esm.sh/react@19.0.0/jsx-runtime",
"react-dom": "https://esm.sh/react-dom@19.0.0"
}
}
}
</script>
</head>
@@ -208,9 +205,9 @@ import TabItem from "@theme/TabItem";
```js showLineNumbers
// See https://www.npmjs.com/package/@excalidraw/excalidraw documentation.
import * as ExcalidrawLib from 'https://esm.sh/@excalidraw/excalidraw@0.18.0/dist/dev/index.js?external=react,react-dom';
import * as ExcalidrawLib from "https://esm.sh/@excalidraw/excalidraw@0.18.0/dist/dev/index.js?external=react,react-dom";
import React from "https://esm.sh/react@19.0.0";
import ReactDOM from "https://esm.sh/react-dom@19.0.0"
import ReactDOM from "https://esm.sh/react-dom@19.0.0";
window.ExcalidrawLib = ExcalidrawLib;
console.log("Excalidraw library", ExcalidrawLib);
@@ -41,18 +41,24 @@ flowchart TD
C -->|One| D[Laptop]
C -->|Two| E[iPhone]
C -->|Three| F[Car]
```
<img src="https://github.com/excalidraw/excalidraw/assets/11256141/c8ea84fc-e9fb-4652-9a12-154136d1a798" width="250" height="200"/>
```
<img
src="https://github.com/excalidraw/excalidraw/assets/11256141/c8ea84fc-e9fb-4652-9a12-154136d1a798"
width="250"
height="200"
/>
```
flowchart LR
id1((Hello from Circle))
```
<img src="https://github.com/excalidraw/excalidraw/assets/11256141/6202a8b9-8aa7-451e-9478-4d8d75c0f2fa" width="250" height="200"/>
<img
src="https://github.com/excalidraw/excalidraw/assets/11256141/6202a8b9-8aa7-451e-9478-4d8d75c0f2fa"
width="250"
height="200"
/>
#### Subgraphs
@@ -72,7 +78,11 @@ flowchart TB
end
```
<img src="https://github.com/excalidraw/excalidraw/assets/11256141/098bce52-8f93-437c-9a06-c6972e27c70a" width="350" height="200"/>
<img
src="https://github.com/excalidraw/excalidraw/assets/11256141/098bce52-8f93-437c-9a06-c6972e27c70a"
width="350"
height="200"
/>
#### Unsupported shapes fallback to Rectangle
@@ -87,9 +97,14 @@ flowchart LR
id5[/Parallelogram fallback to Rectangle /]
id6[/Trapezoid fallback to Rectangle\]
```
The above shapes are not supported in Excalidraw hence they fallback to Rectangle
<img src="https://github.com/excalidraw/excalidraw/assets/11256141/cb269473-16c5-4c35-bd7a-d631d8cc5b47" width="350" height="200"/>
<img
src="https://github.com/excalidraw/excalidraw/assets/11256141/cb269473-16c5-4c35-bd7a-d631d8cc5b47"
width="350"
height="200"
/>
#### Markdown fallback to Regular text
@@ -99,7 +114,12 @@ Since we don't support wysiwyg text editor yet, hence [Markdown Strings](https:/
flowchart LR
A("`Hello **World**`") --> B("`Whats **up** ?`")
```
<img src="https://github.com/excalidraw/excalidraw/assets/11256141/107bd428-9ab9-42d4-ba12-b1e29c8db478" width="250" height="200"/>
<img
src="https://github.com/excalidraw/excalidraw/assets/11256141/107bd428-9ab9-42d4-ba12-b1e29c8db478"
width="250"
height="200"
/>
#### Basic FontAwesome fallback to text
@@ -112,8 +132,11 @@ flowchart TD
B-->E(A fa:fa-camera-retro perhaps?)
```
<img src="https://github.com/excalidraw/excalidraw/assets/11256141/7a693863-c3f9-42ff-b325-4b3f8303c7af" width="250" height="200"/>
<img
src="https://github.com/excalidraw/excalidraw/assets/11256141/7a693863-c3f9-42ff-b325-4b3f8303c7af"
width="250"
height="200"
/>
#### Cross Arrow head fallback to Bar Arrow head
@@ -121,8 +144,12 @@ flowchart TD
flowchart LR
Start x--x Stop
```
<img src="https://github.com/excalidraw/excalidraw/assets/11256141/217dd1ad-7f4e-4c80-8c1c-03647b42d821" width="250" height="200"/>
<img
src="https://github.com/excalidraw/excalidraw/assets/11256141/217dd1ad-7f4e-4c80-8c1c-03647b42d821"
width="250"
height="200"
/>
## Unsupported Diagram Types
@@ -135,7 +162,11 @@ erDiagram
CUSTOMER }|..|{ DELIVERY-ADDRESS : uses
```
<img src="https://github.com/excalidraw/excalidraw/assets/11256141/c1d3fdb3-32ef-4bf3-a38a-02ac3d7d2cb9" width="300" height="200"/>
<img
src="https://github.com/excalidraw/excalidraw/assets/11256141/c1d3fdb3-32ef-4bf3-a38a-02ac3d7d2cb9"
width="300"
height="200"
/>
```
gitGraph
@@ -152,4 +183,8 @@ gitGraph
```
<img src="https://github.com/excalidraw/excalidraw/assets/11256141/e5dcec0b-d570-4eb4-b981-412a996aa96c" width="400" height="300"/>
<img
src="https://github.com/excalidraw/excalidraw/assets/11256141/e5dcec0b-d570-4eb4-b981-412a996aa96c"
width="400"
height="300"
/>
@@ -2,6 +2,6 @@
The Codebase is divided into 2 Sections
* [How Parser Works under the hood](/docs/@excalidraw/mermaid-to-excalidraw/codebase/parser) - If you are interested in understanding and deep diving into inner workings of the Parser, then make sure to checkout this section.
- [How Parser Works under the hood](/docs/@excalidraw/mermaid-to-excalidraw/codebase/parser) - If you are interested in understanding and deep diving into inner workings of the Parser, then make sure to checkout this section.
* [Adding a new diagram type](/docs/@excalidraw/mermaid-to-excalidraw/codebase/new-diagram-type) - If you want to help us make the mermaid to Excalidraw Parser more powerful, you will find all information in this section to do so.
- [Adding a new diagram type](/docs/@excalidraw/mermaid-to-excalidraw/codebase/new-diagram-type) - If you want to help us make the mermaid to Excalidraw Parser more powerful, you will find all information in this section to do so.
@@ -10,7 +10,7 @@ lets run the playground server in local :point_down:
yarn start
```
This will start the playground server in port `1234` and open it in browser so you start playing with the playground.
This will start the playground server in port `1234` and open it in browser so you start playing with the playground.
## Update Supported Diagram Types
@@ -26,13 +26,13 @@ For this create a file named `{{diagramType}}.ts` in [`src/parser`](https://gith
The main aim of the parser is :point_down:
1. Determine how elements are connected in the diagram and thus finding arrow and text bindings.
1. Determine how elements are connected in the diagram and thus finding arrow and text bindings.
For this you might have to dig in to the parser `diagram.parser.yy` and which attributes to parse for the new diagram.
2. Determine the position and dimensions of each element, for this would be using the `svg`
Once the parser is ready, lets start using it.
Once the parser is ready, lets start using it.
Add the diagram type in switch case in [`parseMermaid`](https://github.com/excalidraw/mermaid-to-excalidraw/blob/master/src/parseMermaid.ts#L97) and call the parser for the same.
@@ -51,4 +51,3 @@ Thats it, you have added the new diagram type 🥳, now lets test it out!
2. Incase the new diagram type added is present in [`unsupported.ts`](https://github.com/excalidraw/mermaid-to-excalidraw/blob/master/playground/testcases/unsupported.ts) then remove it from there.
3. Verify if the test cases are running fine in playground.
@@ -8,12 +8,10 @@ In this section we will be diving into how the [flowchart parser](https://github
We use `diagram.parser.yy` attribute to parse the data. If you want to know more about how the `diagram.parse.yy` attribute looks like, you can check it [here](https://github.com/mermaid-js/mermaid/blob/00d06c7282a701849793680c1e97da1cfdfcce62/packages/mermaid/src/diagrams/flowchart/flowDb.js#L768), however for scope of flowchart we are using **3** APIs from this parser to compute `vertices`, `edges` and `clusters` as we need these data to transform to [ExcalidrawElementSkeleton](https://github.com/excalidraw/excalidraw/blob/master/packages/excalidraw/data/transform.ts#L133C13-L133C38).
For computing `vertices` and `edge`s lets consider the below svg generated by mermaid
![image](https://github.com/excalidraw/excalidraw/assets/11256141/d7013305-0b90-4fa0-a66e-b4f4604ad0b2)
## Computing the vertices
We use `getVertices` API from `diagram.parse.yy` to get the vertices for a given flowchart.
@@ -42,9 +40,10 @@ Considering the same example this is the response from the API
}
}
```
The dimensions and position is missing in this response and we need that to transform to [ExcalidrawElementSkeleton](https://github.com/excalidraw/excalidraw/blob/master/packages/excalidraw/data/transform.ts#L133C13-L133C38), for this we have our own parser [`parseVertex`](https://github.com/excalidraw/mermaid-to-excalidraw/blob/master/src/parseMermaid.ts#L178) which takes the above response and uses the `svg` together to compute position, dimensions and cleans up the response.
The final output from `parseVertex` looks like :point_down:
The final output from `parseVertex` looks like :point_down:
```js
{
@@ -73,57 +72,55 @@ The dimensions and position is missing in this response and we need that to tran
}
```
## Computing the edges
The lines and arrows are considered as `edges` in mermaid as shown in the above diagram.
We use `getEdges` API from `diagram.parse.yy` to get the edges for a given flowchart.
Considering the same example this is the response from the API
The lines and arrows are considered as `edges` in mermaid as shown in the above diagram. We use `getEdges` API from `diagram.parse.yy` to get the edges for a given flowchart. Considering the same example this is the response from the API
```js
[
{
"start": "start",
"end": "stop",
"type": "arrow_point",
"text": "",
"labelType": "text",
"stroke": "normal",
"length": 1
}
]
{
start: "start",
end: "stop",
type: "arrow_point",
text: "",
labelType: "text",
stroke: "normal",
length: 1,
},
];
```
Similarly here the dimensions and position is missing and we compute that from the svg. The [`parseEdge`](https://github.com/excalidraw/mermaid-to-excalidraw/blob/master/src/parseMermaid.ts#L245) takes the above response along with `svg` and computes the position, dimensions and cleans up the response.
The final output from `parseEdge` looks like :point_down:
The final output from `parseEdge` looks like :point_down:
```js
[
{
"start": "start",
"end": "stop",
"type": "arrow_point",
"text": "",
"labelType": "text",
"stroke": "normal",
"startX": 67.797,
"startY": 22,
"endX": 117.797,
"endY": 22,
"reflectionPoints": [
{
"x": 67.797,
"y": 22
},
{
"x": 117.797,
"y": 22
}
]
}
]
{
start: "start",
end: "stop",
type: "arrow_point",
text: "",
labelType: "text",
stroke: "normal",
startX: 67.797,
startY: 22,
endX: 117.797,
endY: 22,
reflectionPoints: [
{
x: 67.797,
y: 22,
},
{
x: 117.797,
y: 22,
},
],
},
];
```
## Computing the Subgraphs
`Subgraphs` is collection of elements grouped together. The Subgraphs map to `grouping` elements in Excalidraw.
@@ -132,46 +129,35 @@ Lets consider the below example :point_down:
![image](https://github.com/excalidraw/excalidraw/assets/11256141/5243ce4c-beaa-43d2-812a-0577b0a574d7)
We use `getSubgraphs` API to get the subgraph data for a given flowchart.
Considering the same example this is the response from the API
We use `getSubgraphs` API to get the subgraph data for a given flowchart. Considering the same example this is the response from the API
```js
[
{
"id": "one",
"nodes": [
"flowchart-a2-1399",
"flowchart-a1-1400"
],
"title": "one",
"classes": [],
"labelType": "text"
}
]
{
id: "one",
nodes: ["flowchart-a2-1399", "flowchart-a1-1400"],
title: "one",
classes: [],
labelType: "text",
},
];
```
For position and dimensions we use the svg to compute. The [`parseSubgraph`](https://github.com/excalidraw/mermaid-to-excalidraw/blob/master/src/parseMermaid.ts#L139) takes the above response along with `svg` and computes the position, dimensions and cleans up the response.
```js
[
{
"id": "one",
"nodes": [
"flowchart-a2-1399",
"flowchart-a1-1400"
],
"title": "one",
"labelType": "text",
"nodeIds": [
"a2",
"a1"
],
"x": 75.4921875,
"y": 0,
"width": 121.25,
"height": 188,
"text": "one"
}
]
```
{
id: "one",
nodes: ["flowchart-a2-1399", "flowchart-a1-1400"],
title: "one",
labelType: "text",
nodeIds: ["a2", "a1"],
x: 75.4921875,
y: 0,
width: 121.25,
height: 188,
text: "one",
},
];
```
@@ -2,7 +2,6 @@
[This](https://github.com/excalidraw/mermaid-to-excalidraw/blob/master/src/index.ts) is the entry point of the library.
`parseMermaidToExcalidraw` function is the only function exposed which receives mermaid syntax as the input, parses the mermaid syntax and resolves to Excalidraw Skeleton.
Lets look at the high level overview at how the parse works :point_down:
@@ -13,10 +12,10 @@ Lets dive deeper into individual section now to understand better.
## Parsing Mermaid diagram
One of the dependencies of the library is [`mermaid`](https://www.npmjs.com/package/mermaid) library.
We need the mermaid diagram in some extractable format so we can parse it to Excalidraw Elements.
One of the dependencies of the library is [`mermaid`](https://www.npmjs.com/package/mermaid) library. We need the mermaid diagram in some extractable format so we can parse it to Excalidraw Elements.
Parsing is broken into two steps
1. [`Rendering Mermaid to Svg`](/docs/@excalidraw/mermaid-to-excalidraw/codebase/parser#rendering-mermaid-to-svg) - This helps in determining the position and dimensions of each element in the diagram
2. [`Parsing the mermaid syntax`](/docs/@excalidraw/mermaid-to-excalidraw/codebase/parser#parsing-the-mermaid-syntax) - We also need to know how elements are connected which isn't possible with svg alone hence we also parse the mermaid syntax which helps in determining the connections and bindings between elements in the diagram.
@@ -27,10 +26,8 @@ Parsing is broken into two steps
The [`mermaid`](https://www.npmjs.com/package/mermaid) library provides the API `mermaid.render` API which gives the output of the diagram in `svg`.
If the diagram isn't supported, this svg is converted to `dataURL` and can be rendered as an image in Excalidraw.
### Parsing the mermaid syntax
For this we first need to process the options along with mermaid defination for diagram provided by the user.
@@ -57,9 +54,8 @@ If you want to understand how flowchart parser works, you can navigate to [Flowc
Now we have all the data, we just need to transform it to [ExcalidrawElementSkeleton](https://github.com/excalidraw/excalidraw/blob/master/packages/excalidraw/data/transform.ts#L133C13-L133C38) API so it can be rendered in Excalidraw.
For this we have `converters` which takes the parsed mermaid data and gives back the Excalidraw Skeleton.
For Unsupported types, we have already mentioned above that we convert it to `dataURL` and return the ExcalidrawImageSkeleton.
For this we have `converters` which takes the parsed mermaid data and gives back the Excalidraw Skeleton. For Unsupported types, we have already mentioned above that we convert it to `dataURL` and return the ExcalidrawImageSkeleton.
For supported types, currently only flowchart, we have [flowchartConverter](https://github.com/excalidraw/mermaid-to-excalidraw/blob/master/src/converter/types/flowchart.ts#L24) which parses the data and converts to [ExcalidrawElementSkeleton](https://github.com/excalidraw/excalidraw/blob/master/packages/excalidraw/data/transform.ts#L133C13-L133C38).
![image](https://github.com/excalidraw/excalidraw/assets/11256141/00226e9d-043d-4a08-989a-3ad9d2a574f1)
![image](https://github.com/excalidraw/excalidraw/assets/11256141/00226e9d-043d-4a08-989a-3ad9d2a574f1)
@@ -10,7 +10,6 @@ To set up the library in local, follow the below steps 👇🏼
Go to [@excalidraw/mermaid-to-excalidraw](https://github.com/excalidraw/mermaid-to-excalidraw) and clone the repository to your local.
```bash
git clone git@github.com:excalidraw/mermaid-to-excalidraw.git
```
@@ -20,7 +20,7 @@ Once the library is installed, its ready to use.
```js
import { parseMermaidToExcalidraw } from "@excalidraw/mermaid-to-excalidraw";
import { convertToExcalidrawElements} from "@excalidraw/excalidraw"
import { convertToExcalidrawElements } from "@excalidraw/excalidraw";
try {
const { elements, files } = await parseMermaid(diagramDefinition, {
@@ -38,5 +38,4 @@ try {
## Playground
Try it out [here](https://mermaid-to-excalidraw.vercel.app)
Try it out [here](https://mermaid-to-excalidraw.vercel.app)
+2 -6
View File
@@ -2,8 +2,7 @@
Pull requests are welcome. For major changes, please [open an issue](https://github.com/excalidraw/excalidraw/issues/new) first to discuss what you would like to change.
We have a [roadmap](https://github.com/orgs/excalidraw/projects/3) which we strongly recommend to go through and check if something interests you.
For new contributors we would recommend to start with *Easy* tasks.
We have a [roadmap](https://github.com/orgs/excalidraw/projects/3) which we strongly recommend to go through and check if something interests you. For new contributors we would recommend to start with _Easy_ tasks.
In case you want to pick up something from the roadmap, comment on that issue and one of the project maintainers will assign it to you, post which you can discuss in the issue and start working on it.
@@ -60,10 +59,7 @@ It's also a good idea to consider if your change should include additional tests
Finally - always manually test your changes using the convenient staging environment deployed for each pull request. As much as local development attempts to replicate production, there can still be subtle differences in behavior. For larger features consider testing your change in multiple browsers as well.
:::note
Some checks, such as the `lint` and `test`, require approval from the maintainers to run.
They will appear as `Expected — Waiting for status to be reported` in the PR checks when they are waiting for approval.
:::
:::note Some checks, such as the `lint` and `test`, require approval from the maintainers to run. They will appear as `Expected — Waiting for status to be reported` in the PR checks when they are waiting for approval. :::
## Translating
+1 -7
View File
@@ -54,7 +54,7 @@ yarn
yarn start
```
### Reformat all files with Prettier
### Lint & format
```bash
yarn fix
@@ -72,12 +72,6 @@ yarn test
yarn test:update
```
### Test for formatting with Prettier
```bash
yarn test:code
```
### Docker Compose
You can use docker-compose to work on Excalidraw locally if you don't want to setup a Node.js env.
+2 -2
View File
@@ -16,8 +16,8 @@ const FeatureList = [
Svg: require("@site/static/img/undraw_blank_canvas.svg").default,
description: (
<>
Want to build your own app powered by Excalidraw but don't know where to
start?
Want to build your own app powered by Excalidraw but don&apos;t know
where to start?
</>
),
},
+1 -3
View File
@@ -1,7 +1,5 @@
{
// This file is not used in compilation. It is here just for a nice editor experience.
"extends": "@tsconfig/docusaurus/tsconfig.json",
"compilerOptions": {
"baseUrl": "."
}
"compilerOptions": {}
}
@@ -11,7 +11,7 @@ const ExcalidrawWrapper: React.FC = () => {
<>
<App
appTitle={"Excalidraw with Nextjs Example"}
useCustom={(api: any, args?: any[]) => {}}
useCustom={() => {}}
excalidrawLib={excalidrawLib}
>
<Excalidraw />
+7 -1
View File
@@ -23,6 +23,12 @@
},
"forceConsistentCasingInFileNames": true
},
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts", "build/types/**/*.ts"],
"include": [
"next-env.d.ts",
"**/*.ts",
"**/*.tsx",
".next/types/**/*.ts",
"build/types/**/*.ts"
],
"exclude": ["node_modules"]
}
+2 -1
View File
@@ -1,3 +1,4 @@
{
"outputDirectory": "build"
"outputDirectory": "build",
"installCommand": "yarn install && yarn --cwd ../../ install"
}
@@ -238,7 +238,7 @@ export default function ExampleApp({
left: "50%",
transform: "translateX(-50%)",
bottom: "20px",
zIndex: 9999999999999999,
zIndex: 999999999999999,
}}
>
Toggle Custom Sidebar
@@ -250,7 +250,7 @@ export default function ExampleApp({
</TTDDialogTrigger>
)}
<TTDDialog
onTextSubmit={async (_) => {
onTextSubmit={async () => {
console.info("submit");
// sleep for 2s
await new Promise((resolve) => setTimeout(resolve, 2000));
@@ -555,7 +555,7 @@ export default function ExampleApp({
if (!comment) {
return null;
}
const appState = excalidrawAPI?.getAppState()!;
const appState = excalidrawAPI?.getAppState();
const { x, y } = sceneCoordsToViewportCoords(
{ sceneX: comment.x, sceneY: comment.y },
appState,
+1 -1
View File
@@ -1,4 +1,4 @@
<!DOCTYPE html>
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
+1 -1
View File
@@ -20,7 +20,7 @@ root.render(
<StrictMode>
<App
appTitle={"Excalidraw Example"}
useCustom={(api: any, args?: any[]) => {}}
useCustom={() => {}}
excalidrawLib={window.ExcalidrawLib}
>
<Excalidraw />
+10 -10
View File
@@ -2,21 +2,21 @@
"name": "with-script-in-browser",
"version": "1.0.0",
"private": true,
"dependencies": {
"react": "19.0.0",
"react-dom": "19.0.0",
"@excalidraw/excalidraw": "*",
"browser-fs-access": "0.29.1"
},
"devDependencies": {
"vite": "5.0.12",
"typescript": "^5"
},
"scripts": {
"start": "vite",
"build": "vite build",
"preview": "vite preview --port 5002",
"build:preview": "yarn build && yarn preview",
"build:packages": "yarn --cwd ../../ build:packages"
},
"dependencies": {
"@excalidraw/excalidraw": "0.18.0",
"browser-fs-access": "0.29.1",
"react": "19.0.0",
"react-dom": "19.0.0"
},
"devDependencies": {
"typescript": "^5",
"vite": "5.0.12"
}
}
+1 -1
View File
@@ -1,5 +1,5 @@
{
"outputDirectory": "dist",
"installCommand": "yarn install",
"installCommand": "yarn install && yarn --cwd ../../ install",
"buildCommand": "yarn build:packages && yarn build"
}
File diff suppressed because it is too large Load Diff
+43 -11
View File
@@ -48,7 +48,11 @@ import {
youtubeIcon,
} from "@excalidraw/excalidraw/components/icons";
import { isElementLink } from "@excalidraw/element";
import { restore, restoreAppState } from "@excalidraw/excalidraw/data/restore";
import {
bumpElementVersions,
restoreAppState,
restoreElements,
} from "@excalidraw/excalidraw/data/restore";
import { newElementWith } from "@excalidraw/element";
import { isInitializedImageElement } from "@excalidraw/element";
import clsx from "clsx";
@@ -105,8 +109,8 @@ import { TopErrorBoundary } from "./components/TopErrorBoundary";
import {
exportToBackend,
getCollaborationLinkData,
importFromBackend,
isCollaborationLink,
loadScene,
} from "./data";
import { updateStaleImageStatuses } from "./data/FileManager";
@@ -188,7 +192,7 @@ if (window.self !== window.top) {
if (parentUrl.origin === currentUrl.origin) {
isSelfEmbedding = true;
}
} catch (error) {
} catch {
// ignore
}
}
@@ -224,9 +228,20 @@ const initializeScene = async (opts: {
const localDataState = importFromLocalStorage();
let scene: RestoredDataState & {
let scene: Omit<
RestoredDataState,
// we're not storing files in the scene database/localStorage, and instead
// fetch them async from a different store
"files"
> & {
scrollToContent?: boolean;
} = await loadScene(null, null, localDataState);
} = {
elements: restoreElements(localDataState?.elements, null, {
repairBindings: true,
deleteInvisibleElements: true,
}),
appState: restoreAppState(localDataState?.appState, null),
};
let roomLinkData = getCollaborationLinkData(window.location.href);
const isExternalScene = !!(id || jsonBackendMatch || roomLinkData);
@@ -240,11 +255,26 @@ const initializeScene = async (opts: {
(await openConfirmModal(shareableLinkConfirmDialog))
) {
if (jsonBackendMatch) {
scene = await loadScene(
const imported = await importFromBackend(
jsonBackendMatch[1],
jsonBackendMatch[2],
localDataState,
);
scene = {
elements: bumpElementVersions(
restoreElements(imported.elements, null, {
repairBindings: true,
deleteInvisibleElements: true,
}),
localDataState?.elements,
),
appState: restoreAppState(
imported.appState,
// local appState when importing from backend to ensure we restore
// localStorage user settings which we do not persist on server.
localDataState?.appState,
),
};
}
scene.scrollToContent = true;
if (!roomLinkData) {
@@ -280,7 +310,7 @@ const initializeScene = async (opts: {
) {
return { scene: data, isExternalScene };
}
} catch (error: any) {
} catch {
return {
scene: {
appState: {
@@ -496,8 +526,10 @@ const ExcalidrawWrapper = () => {
loadImages(data);
if (data.scene) {
excalidrawAPI.updateScene({
...data.scene,
...restore(data.scene, null, null, { repairBindings: true }),
elements: restoreElements(data.scene.elements, null, {
repairBindings: true,
}),
appState: restoreAppState(data.scene.appState, null),
captureUpdate: CaptureUpdateAction.IMMEDIATELY,
});
}
@@ -755,7 +787,7 @@ const ExcalidrawWrapper = () => {
height: "100%",
}}
>
<h1>I'm not a pretzel!</h1>
<h1>I&apos;m not a pretzel!</h1>
</div>
);
}
+1
View File
@@ -46,6 +46,7 @@ export const STORAGE_KEYS = {
VERSION_FILES: "version-files",
IDB_LIBRARY: "excalidraw-library",
IDB_TTD_CHATS: "excalidraw-ttd-chats",
// do not use apart from migrations
__LEGACY_LOCAL_STORAGE_LIBRARY: "excalidraw-library",
+33 -14
View File
@@ -6,7 +6,7 @@ import {
reconcileElements,
} from "@excalidraw/excalidraw";
import { ErrorDialog } from "@excalidraw/excalidraw/components/ErrorDialog";
import { APP_NAME, EVENT } from "@excalidraw/common";
import { APP_NAME, cloneJSON, EVENT, toBrandedType } from "@excalidraw/common";
import {
IDLE_THRESHOLD,
ACTIVE_THRESHOLD,
@@ -29,6 +29,8 @@ import { withBatchedUpdates } from "@excalidraw/excalidraw/reactUtils";
import throttle from "lodash.throttle";
import { PureComponent } from "react";
import { bumpElementVersions } from "@excalidraw/excalidraw/data/restore";
import type {
ReconciledExcalidrawElement,
RemoteExcalidrawElement,
@@ -208,7 +210,9 @@ class Collab extends PureComponent<CollabProps, CollabState> {
window.addEventListener(EVENT.UNLOAD, this.onUnload);
const unsubOnUserFollow = this.excalidrawAPI.onUserFollow((payload) => {
this.portal.socket && this.portal.broadcastUserFollowed(payload);
if (this.portal.socket) {
this.portal.broadcastUserFollowed(payload);
}
});
const throttledRelayUserViewportBounds = throttleRAF(
this.relayVisibleSceneBounds,
@@ -311,6 +315,7 @@ class Collab extends PureComponent<CollabProps, CollabState> {
saveCollabRoomToFirebase = async (
syncableElements: readonly SyncableExcalidrawElement[],
) => {
syncableElements = cloneJSON(syncableElements);
try {
const storedElements = await saveToFirebase(
this.portal,
@@ -579,7 +584,9 @@ class Collab extends PureComponent<CollabProps, CollabState> {
case WS_SUBTYPES.INIT: {
if (!this.portal.socketInitialized) {
this.initializeRoom({ fetchScene: false });
const remoteElements = decryptedData.payload.elements;
const remoteElements = toBrandedType<
readonly RemoteExcalidrawElement[]
>(decryptedData.payload.elements);
const reconciledElements =
this._reconcileElements(remoteElements);
this.handleRemoteSceneUpdate(reconciledElements);
@@ -593,7 +600,11 @@ class Collab extends PureComponent<CollabProps, CollabState> {
}
case WS_SUBTYPES.UPDATE:
this.handleRemoteSceneUpdate(
this._reconcileElements(decryptedData.payload.elements),
this._reconcileElements(
toBrandedType<readonly RemoteExcalidrawElement[]>(
decryptedData.payload.elements,
),
),
);
break;
case WS_SUBTYPES.MOUSE_LOCATION: {
@@ -742,20 +753,28 @@ class Collab extends PureComponent<CollabProps, CollabState> {
};
private _reconcileElements = (
remoteElements: readonly ExcalidrawElement[],
remoteElements: readonly RemoteExcalidrawElement[],
): ReconciledExcalidrawElement[] => {
const localElements = this.getSceneElementsIncludingDeleted();
const appState = this.excalidrawAPI.getAppState();
const restoredRemoteElements = restoreElements(
const existingElements = this.getSceneElementsIncludingDeleted();
// NOTE ideally we restore _after_ reconciliation but we can't do that
// as we'd regenerate even elements such as appState.newElement which would
// break the state
remoteElements = restoreElements(remoteElements, existingElements);
let reconciledElements = reconcileElements(
existingElements,
remoteElements,
this.excalidrawAPI.getSceneElementsMapIncludingDeleted(),
);
const reconciledElements = reconcileElements(
localElements,
restoredRemoteElements as RemoteExcalidrawElement[],
appState,
);
reconciledElements = bumpElementVersions(
reconciledElements,
existingElements,
);
// Avoid broadcasting to the rest of the collaborators the scene
// we just received!
// Note: this needs to be set before updating the scene as it
@@ -898,9 +917,9 @@ class Collab extends PureComponent<CollabProps, CollabState> {
button: SocketUpdateDataSource["MOUSE_LOCATION"]["payload"]["button"];
pointersMap: Gesture["pointers"];
}) => {
payload.pointersMap.size < 2 &&
this.portal.socket &&
if (payload.pointersMap.size < 2 && this.portal.socket) {
this.portal.broadcastMouseLocation(payload);
}
},
CURSOR_SYNC_TIMEOUT,
);
+1 -1
View File
@@ -1,4 +1,4 @@
@import "../../packages/excalidraw/css/variables.module.scss";
@use "../../packages/excalidraw/css/variables.module.scss" as *;
.excalidraw {
.collab-errors-button {
+1 -1
View File
@@ -46,7 +46,7 @@ class Portal {
trackEvent("share", "room joined");
}
});
this.socket.on("new-user", async (_socketId: string) => {
this.socket.on("new-user", async () => {
this.broadcastScene(
WS_SUBTYPES.INIT,
this.collab.getSceneElementsIncludingDeleted(),
+18 -53
View File
@@ -4,12 +4,15 @@ import {
getTextFromElements,
MIME_TYPES,
TTDDialog,
TTDStreamFetch,
} from "@excalidraw/excalidraw";
import { getDataURL } from "@excalidraw/excalidraw/data/blob";
import { safelyParseJSON } from "@excalidraw/common";
import type { ExcalidrawImperativeAPI } from "@excalidraw/excalidraw/types";
import { TTDIndexedDBAdapter } from "../data/TTDStorage";
export const AIComponents = ({
excalidrawAPI,
}: {
@@ -92,68 +95,30 @@ export const AIComponents = ({
return {
html,
};
} catch (error: any) {
} catch {
throw new Error("Generation failed (invalid response)");
}
}}
/>
<TTDDialog
onTextSubmit={async (input) => {
try {
const response = await fetch(
`${
import.meta.env.VITE_APP_AI_BACKEND
}/v1/ai/text-to-diagram/generate`,
{
method: "POST",
headers: {
Accept: "application/json",
"Content-Type": "application/json",
},
body: JSON.stringify({ prompt: input }),
},
);
onTextSubmit={async (props) => {
const { onChunk, onStreamCreated, signal, messages } = props;
const rateLimit = response.headers.has("X-Ratelimit-Limit")
? parseInt(response.headers.get("X-Ratelimit-Limit") || "0", 10)
: undefined;
const result = await TTDStreamFetch({
url: `${
import.meta.env.VITE_APP_AI_BACKEND
}/v1/ai/text-to-diagram/chat-streaming`,
messages,
onChunk,
onStreamCreated,
extractRateLimits: true,
signal,
});
const rateLimitRemaining = response.headers.has(
"X-Ratelimit-Remaining",
)
? parseInt(
response.headers.get("X-Ratelimit-Remaining") || "0",
10,
)
: undefined;
const json = await response.json();
if (!response.ok) {
if (response.status === 429) {
return {
rateLimit,
rateLimitRemaining,
error: new Error(
"Too many requests today, please try again tomorrow!",
),
};
}
throw new Error(json.message || "Generation failed...");
}
const generatedResponse = json.generatedResponse;
if (!generatedResponse) {
throw new Error("Generation failed...");
}
return { generatedResponse, rateLimit, rateLimitRemaining };
} catch (err: any) {
throw new Error("Request failed");
}
return result;
}}
persistenceAdapter={TTDIndexedDBAdapter}
/>
</>
);
+2 -1
View File
@@ -62,7 +62,7 @@ export const AppMainMenu: React.FC<{
{isDevEnv() && (
<MainMenu.Item
icon={eyeIcon}
onClick={() => {
onSelect={() => {
if (window.visualDebug) {
delete window.visualDebug;
saveDebugState({ enabled: false });
@@ -77,6 +77,7 @@ export const AppMainMenu: React.FC<{
</MainMenu.Item>
)}
<MainMenu.Separator />
<MainMenu.DefaultItems.Preferences />
<MainMenu.DefaultItems.ToggleTheme
allowSystemTheme
theme={props.theme}
+2 -5
View File
@@ -16,11 +16,8 @@
background-position: center;
background-repeat: no-repeat;
background-image: radial-gradient(
circle,
transparent 60%,
var(--sidebar-bg-color) 100%
),
background-image:
radial-gradient(circle, transparent 60%, var(--sidebar-bg-color) 100%),
var(--image-source);
display: flex;
@@ -33,7 +33,15 @@ export const AppWelcomeScreen: React.FC<{
return bit;
});
} else {
headingContent = t("welcomeScreen.app.center_heading");
headingContent = (
<>
{t("welcomeScreen.app.center_heading")}
<br />
{t("welcomeScreen.app.center_heading_line2")}
<br />
{t("welcomeScreen.app.center_heading_line3")}
</>
);
}
return (
+45 -1
View File
@@ -27,7 +27,10 @@ import { isCurve } from "@excalidraw/math/curve";
import React from "react";
import type { Curve } from "@excalidraw/math";
import type { DebugElement } from "@excalidraw/common";
import type {
DebugElement,
DebugPolygon,
} from "@excalidraw/element/visualdebug";
import type {
ElementsMap,
ExcalidrawArrowElement,
@@ -75,6 +78,44 @@ const renderCubicBezier = (
context.restore();
};
const renderPolygon = (
context: CanvasRenderingContext2D,
zoom: number,
polygon: DebugPolygon,
color: string,
) => {
const { points, fill, close } = polygon;
if (points.length < 2) {
return;
}
context.save();
context.beginPath();
context.moveTo(points[0][0] * zoom, points[0][1] * zoom);
for (let i = 1; i < points.length; i += 1) {
context.lineTo(points[i][0] * zoom, points[i][1] * zoom);
}
if (close !== false) {
context.closePath();
}
if (fill) {
context.save();
context.globalAlpha = 0.15;
context.fillStyle = color;
context.fill();
context.restore();
}
context.strokeStyle = color;
context.stroke();
context.restore();
};
const isDebugPolygon = (data: DebugElement["data"]): data is DebugPolygon =>
(data as DebugPolygon).type === "polygon";
const renderOrigin = (context: CanvasRenderingContext2D, zoom: number) => {
context.strokeStyle = "#888";
context.save();
@@ -280,6 +321,9 @@ const render = (
el.color,
);
break;
case isDebugPolygon(el.data):
renderPolygon(context, appState.zoom.value, el.data, el.color);
break;
default:
throw new Error(`Unknown element type ${JSON.stringify(el)}`);
}
@@ -1,46 +0,0 @@
import { THEME } from "@excalidraw/common";
import oc from "open-color";
import React from "react";
import type { Theme } from "@excalidraw/element/types";
// https://github.com/tholman/github-corners
export const GitHubCorner = React.memo(
({ theme, dir }: { theme: Theme; dir: string }) => (
<svg
xmlns="http://www.w3.org/2000/svg"
width="40"
height="40"
viewBox="0 0 250 250"
className="rtl-mirror"
style={{
marginTop: "calc(var(--space-factor) * -1)",
[dir === "rtl" ? "marginLeft" : "marginRight"]:
"calc(var(--space-factor) * -1)",
}}
>
<a
href="https://github.com/excalidraw/excalidraw"
target="_blank"
rel="noopener noreferrer"
aria-label="GitHub repository"
>
<path
d="M0 0l115 115h15l12 27 108 108V0z"
fill={theme === THEME.LIGHT ? oc.gray[6] : oc.gray[7]}
/>
<path
className="octo-arm"
d="M128 109c-15-9-9-19-9-19 3-7 2-11 2-11-1-7 3-2 3-2 4 5 2 11 2 11-3 10 5 15 9 16"
style={{ transformOrigin: "130px 106px" }}
fill={theme === THEME.LIGHT ? oc.white : "var(--default-bg-color)"}
/>
<path
className="octo-body"
d="M115 115s4 2 5 0l14-14c3-2 6-3 8-3-8-11-15-24 2-41 5-5 10-7 16-7 1-2 3-7 12-11 0 0 5 3 7 16 4 2 8 5 12 9s7 8 9 12c14 3 17 7 17 7-4 8-9 11-11 11 0 6-2 11-7 16-16 16-30 10-41 2 0 3-1 7-5 11l-12 11c-1 1 1 5 1 5z"
fill={theme === THEME.LIGHT ? oc.white : "var(--default-bg-color)"}
/>
</a>
</svg>
),
);
@@ -28,7 +28,7 @@ export class TopErrorBoundary extends React.Component<
for (const [key, value] of Object.entries({ ...localStorage })) {
try {
_localStorage[key] = JSON.parse(value);
} catch (error: any) {
} catch {
_localStorage[key] = value;
}
}
@@ -37,7 +37,7 @@ export class TopErrorBoundary extends React.Component<
scope.setExtras(errorInfo);
const eventId = Sentry.captureException(error);
this.setState((state) => ({
this.setState(() => ({
hasError: true,
sentryEventId: eventId,
localStorage: JSON.stringify(_localStorage),
+51
View File
@@ -0,0 +1,51 @@
import { createStore, get, set } from "idb-keyval";
import type { SavedChats } from "@excalidraw/excalidraw/components/TTDDialog/types";
import { STORAGE_KEYS } from "../app_constants";
/**
* IndexedDB adapter for TTD chat storage.
* Implements TTDPersistenceAdapter interface.
*/
export class TTDIndexedDBAdapter {
/** IndexedDB database name */
private static idb_name = STORAGE_KEYS.IDB_TTD_CHATS;
/** Store key for chat data */
private static key = "ttdChats";
private static store = createStore(
`${TTDIndexedDBAdapter.idb_name}-db`,
`${TTDIndexedDBAdapter.idb_name}-store`,
);
/**
* Load saved chats from IndexedDB.
* @returns Promise resolving to saved chats array (empty if none found)
*/
static async loadChats(): Promise<SavedChats> {
try {
const data = await get<SavedChats>(
TTDIndexedDBAdapter.key,
TTDIndexedDBAdapter.store,
);
return data || [];
} catch (error) {
console.warn("Failed to load TTD chats from IndexedDB:", error);
return [];
}
}
/**
* Save chats to IndexedDB.
* @param chats - The chats array to persist
*/
static async saveChats(chats: SavedChats): Promise<void> {
try {
await set(TTDIndexedDBAdapter.key, chats, TTDIndexedDBAdapter.store);
} catch (error) {
console.warn("Failed to save TTD chats to IndexedDB:", error);
throw error;
}
}
}
+4 -4
View File
@@ -1,5 +1,5 @@
import { reconcileElements } from "@excalidraw/excalidraw";
import { MIME_TYPES } from "@excalidraw/common";
import { MIME_TYPES, toBrandedType } from "@excalidraw/common";
import { decompressData } from "@excalidraw/excalidraw/data/encode";
import {
encryptData,
@@ -44,7 +44,7 @@ import type { Socket } from "socket.io-client";
let FIREBASE_CONFIG: Record<string, any>;
try {
FIREBASE_CONFIG = JSON.parse(import.meta.env.VITE_APP_FIREBASE_CONFIG);
} catch (error: any) {
} catch {
console.warn(
`Error JSON parsing firebase config. Supplied value: ${
import.meta.env.VITE_APP_FIREBASE_CONFIG
@@ -162,7 +162,7 @@ export const saveFilesToFirebase = async ({
cacheControl: `public, max-age=${FILE_CACHE_MAX_AGE_SEC}`,
});
savedFiles.push(id);
} catch (error: any) {
} catch {
erroredFiles.push(id);
}
}),
@@ -243,7 +243,7 @@ export const saveToFirebase = async (
FirebaseSceneVersionCache.set(socket, storedElements);
return storedElements;
return toBrandedType<RemoteExcalidrawElement[]>(storedElements);
};
export const loadFromFirebase = async (
+4 -44
View File
@@ -8,7 +8,6 @@ import {
IV_LENGTH_BYTES,
} from "@excalidraw/excalidraw/data/encryption";
import { serializeAsJSON } from "@excalidraw/excalidraw/data/json";
import { restore } from "@excalidraw/excalidraw/data/restore";
import { isInvisiblySmallElement } from "@excalidraw/element";
import { isInitializedImageElement } from "@excalidraw/element";
import { t } from "@excalidraw/excalidraw/i18n";
@@ -84,13 +83,13 @@ export type SocketUpdateDataSource = {
SCENE_INIT: {
type: WS_SUBTYPES.INIT;
payload: {
elements: readonly ExcalidrawElement[];
elements: readonly OrderedExcalidrawElement[];
};
};
SCENE_UPDATE: {
type: WS_SUBTYPES.UPDATE;
payload: {
elements: readonly ExcalidrawElement[];
elements: readonly OrderedExcalidrawElement[];
};
};
MOUSE_LOCATION: {
@@ -182,7 +181,7 @@ const legacy_decodeFromBackend = async ({
const iv = buffer.slice(0, IV_LENGTH_BYTES);
const encrypted = buffer.slice(IV_LENGTH_BYTES, buffer.byteLength);
decrypted = await decryptData(new Uint8Array(iv), encrypted, decryptionKey);
} catch (error: any) {
} catch {
// Fixed IV (old format, backward compatibility)
const fixedIv = new Uint8Array(IV_LENGTH_BYTES);
decrypted = await decryptData(fixedIv, buffer, decryptionKey);
@@ -200,7 +199,7 @@ const legacy_decodeFromBackend = async ({
};
};
const importFromBackend = async (
export const importFromBackend = async (
id: string,
decryptionKey: string,
): Promise<ImportedDataState> => {
@@ -242,45 +241,6 @@ const importFromBackend = async (
}
};
export const loadScene = async (
id: string | null,
privateKey: string | null,
// Supply local state even if importing from backend to ensure we restore
// localStorage user settings which we do not persist on server.
// Non-optional so we don't forget to pass it even if `undefined`.
localDataState: ImportedDataState | undefined | null,
) => {
let data;
if (id != null && privateKey != null) {
// the private key is used to decrypt the content from the server, take
// extra care not to leak it
data = restore(
await importFromBackend(id, privateKey),
localDataState?.appState,
localDataState?.elements,
{
repairBindings: true,
refreshDimensions: false,
deleteInvisibleElements: true,
},
);
} else {
data = restore(localDataState || null, null, null, {
repairBindings: true,
deleteInvisibleElements: true,
});
}
return {
elements: data.elements,
appState: data.appState,
// note: this will always be empty because we're not storing files
// in the scene database/localStorage, and instead fetch them async
// from a different database
files: data.files,
};
};
type ExportToBackendResult =
| { url: null; errorMessage: string }
| { url: string; errorMessage: null };
+1 -1
View File
@@ -1,4 +1,4 @@
<!DOCTYPE html>
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
+1 -1
View File
@@ -1,4 +1,4 @@
@import "../packages/excalidraw/css/variables.module.scss";
@use "../packages/excalidraw/css/variables.module.scss" as *;
.excalidraw {
--color-primary-contrast-offset: #625ee0; // to offset Chubb illusion
+28 -28
View File
@@ -3,6 +3,34 @@
"version": "1.0.0",
"private": true,
"homepage": ".",
"scripts": {
"build-node": "node ./scripts/build-node.js",
"build:app:docker": "cross-env VITE_APP_DISABLE_SENTRY=true vite build",
"build:app": "cross-env VITE_APP_GIT_SHA=$VERCEL_GIT_COMMIT_SHA cross-env VITE_APP_ENABLE_TRACKING=true vite build",
"build:version": "node ../scripts/build-version.js",
"build": "yarn build:app && yarn build:version",
"start": "yarn && vite",
"start:production": "yarn build && yarn serve",
"serve": "npx http-server build -a localhost -p 5001 -o",
"build:preview": "yarn build && vite preview --port 5000"
},
"dependencies": {
"@excalidraw/random-username": "1.0.0",
"@sentry/browser": "9.0.1",
"callsites": "4.2.0",
"firebase": "11.3.1",
"i18next-browser-languagedetector": "6.1.4",
"idb-keyval": "6.0.3",
"jotai": "2.11.0",
"react": "19.0.0",
"react-dom": "19.0.0",
"socket.io-client": "4.7.2",
"uqr": "0.1.2",
"vite-plugin-html": "3.2.2"
},
"devDependencies": {
"vite-plugin-sitemap": "0.7.1"
},
"browserslist": {
"production": [
">0.2%",
@@ -24,33 +52,5 @@
},
"engines": {
"node": ">=18.0.0"
},
"dependencies": {
"@excalidraw/random-username": "1.0.0",
"@sentry/browser": "9.0.1",
"callsites": "4.2.0",
"firebase": "11.3.1",
"i18next-browser-languagedetector": "6.1.4",
"idb-keyval": "6.0.3",
"jotai": "2.11.0",
"react": "19.0.0",
"react-dom": "19.0.0",
"socket.io-client": "4.7.2",
"vite-plugin-html": "3.2.2"
},
"prettier": "@excalidraw/prettier-config",
"scripts": {
"build-node": "node ./scripts/build-node.js",
"build:app:docker": "cross-env VITE_APP_DISABLE_SENTRY=true vite build",
"build:app": "cross-env VITE_APP_GIT_SHA=$VERCEL_GIT_COMMIT_SHA cross-env VITE_APP_ENABLE_TRACKING=true vite build",
"build:version": "node ../scripts/build-version.js",
"build": "yarn build:app && yarn build:version",
"start": "yarn && vite",
"start:production": "yarn build && yarn serve",
"serve": "npx http-server build -a localhost -p 5001 -o",
"build:preview": "yarn build && vite preview --port 5000"
},
"devDependencies": {
"vite-plugin-sitemap": "0.7.1"
}
}
+56
View File
@@ -0,0 +1,56 @@
import { useEffect, useState } from "react";
import Spinner from "@excalidraw/excalidraw/components/Spinner";
interface QRCodeProps {
value: string;
}
export const QRCode = ({ value }: QRCodeProps) => {
const [svgData, setSvgData] = useState<string | null>(null);
const [error, setError] = useState<boolean>(false);
useEffect(() => {
let mounted = true;
import("./qrcode.chunk")
.then(({ generateQRCodeSVG }) => {
if (mounted) {
try {
setSvgData(generateQRCodeSVG(value));
} catch {
setError(true);
}
}
})
.catch(() => {
if (mounted) {
setError(true);
}
});
return () => {
mounted = false;
};
}, [value]);
if (error) {
return null;
}
if (!svgData) {
return (
<div className="ShareDialog__active__qrcode ShareDialog__active__qrcode--loading">
<Spinner />
</div>
);
}
return (
<div
className="ShareDialog__active__qrcode"
role="img"
aria-label="QR code for collaboration link"
dangerouslySetInnerHTML={{ __html: svgData }}
/>
);
};
+26 -1
View File
@@ -1,4 +1,4 @@
@import "../../packages/excalidraw/css/variables.module.scss";
@use "../../packages/excalidraw/css/variables.module.scss" as *;
.excalidraw {
.ShareDialog {
@@ -140,6 +140,31 @@
gap: 0.75rem;
}
&__qrcode {
display: flex;
justify-content: center;
align-items: center;
align-self: center;
padding: 1rem;
background: #fff;
border-radius: 0.5rem;
border: 1px solid #e0e0e0;
$size: 150px;
width: $size;
height: $size;
& svg {
width: $size;
height: $size;
}
&--loading {
background: var(--island-bg-color);
border: 1px solid var(--dialog-border-color);
}
}
&__description {
border-top: 1px solid var(--color-gray-20);
+4 -2
View File
@@ -22,6 +22,7 @@ import { atom, useAtom, useAtomValue } from "../app-jotai";
import { activeRoomLinkAtom } from "../collab/Collab";
import "./ShareDialog.scss";
import { QRCode } from "./QRCode";
import type { CollabAPI } from "../collab/Collab";
@@ -72,7 +73,7 @@ const ActiveRoomDialog = ({
const copyRoomLink = async () => {
try {
await copyTextToSystemClipboard(activeRoomLink);
} catch (e) {
} catch {
collabAPI.setCollabError(t("errors.copyToSystemClipboardFailed"));
}
@@ -96,7 +97,7 @@ const ActiveRoomDialog = ({
text: t("roomDialog.shareTitle"),
url: activeRoomLink,
});
} catch (error: any) {
} catch {
// Just ignore.
}
};
@@ -142,6 +143,7 @@ const ActiveRoomDialog = ({
}}
/>
</div>
<QRCode value={activeRoomLink} />
<div className="ShareDialog__active__description">
<p>
<span
+5
View File
@@ -0,0 +1,5 @@
import { renderSVG } from "uqr";
export const generateQRCodeSVG = (text: string): string => {
return renderSVG(text);
};
@@ -50,7 +50,11 @@ exports[`Test MobileMenu > should initialize with welcome screen and hide once u
<div
class="welcome-screen-center__heading welcome-screen-decor excalifont"
>
All your data is saved locally in your browser.
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
class="welcome-screen-menu"
-3
View File
@@ -26,9 +26,6 @@ interface ImportMetaEnv {
// Set this flag to false if you want to open the overlay by default
VITE_APP_COLLAPSE_OVERLAY: string;
// Enable eslint in dev server
VITE_APP_ENABLE_ESLINT: string;
// Enable PWA in dev server
VITE_APP_ENABLE_PWA: string;
+11 -4
View File
@@ -5,6 +5,7 @@ import svgrPlugin from "vite-plugin-svgr";
import { ViteEjsPlugin } from "vite-plugin-ejs";
import { VitePWA } from "vite-plugin-pwa";
import checker from "vite-plugin-checker";
import oxlint from "vite-plugin-oxlint";
import { createHtmlPlugin } from "vite-plugin-html";
import Sitemap from "vite-plugin-sitemap";
import { woff2BrowserPlugin } from "../scripts/woff2/woff2-vite-plugins";
@@ -102,6 +103,10 @@ export default defineConfig(({ mode }) => {
// Taking the substring after "locales/"
return `locales/${id.substring(index + 8)}`;
}
if (id.includes("@excalidraw/mermaid-to-excalidraw")) {
return "mermaid-to-excalidraw";
}
},
},
},
@@ -121,15 +126,16 @@ export default defineConfig(({ mode }) => {
react(),
checker({
typescript: true,
eslint:
envVars.VITE_APP_ENABLE_ESLINT === "false"
? undefined
: { lintCommand: 'eslint "./**/*.{js,ts,tsx}"' },
overlay: {
initialIsOpen: envVars.VITE_APP_COLLAPSE_OVERLAY === "false",
badgeStyle: "margin-bottom: 4rem; margin-left: 1rem",
},
}),
oxlint({
configFile: path.resolve(__dirname, "../.oxlintrc.json"),
path: path.resolve(__dirname, ".."),
oxlintPath: path.resolve(__dirname, "../node_modules/.bin/oxlint"),
}),
svgrPlugin(),
ViteEjsPlugin(),
VitePWA({
@@ -196,6 +202,7 @@ export default defineConfig(({ mode }) => {
},
},
],
maximumFileSizeToCacheInBytes: 2.3 * 1024 ** 2, // 2.3MB
},
manifest: {
short_name: "Excalidraw",
+47 -53
View File
@@ -1,53 +1,11 @@
{
"private": true,
"name": "excalidraw-monorepo",
"packageManager": "yarn@1.22.22",
"private": true,
"homepage": ".",
"workspaces": [
"excalidraw-app",
"packages/*",
"examples/*"
"packages/*"
],
"devDependencies": {
"@babel/preset-env": "7.26.9",
"@excalidraw/eslint-config": "1.0.3",
"@excalidraw/prettier-config": "1.0.2",
"@types/chai": "4.3.0",
"@types/jest": "27.4.0",
"@types/lodash.throttle": "4.1.7",
"@types/react": "19.0.10",
"@types/react-dom": "19.0.4",
"@types/socket.io-client": "3.0.0",
"@vitejs/plugin-react": "3.1.0",
"@vitest/coverage-v8": "3.0.7",
"@vitest/ui": "2.0.5",
"chai": "4.3.6",
"dotenv": "16.0.1",
"eslint-config-prettier": "8.5.0",
"eslint-config-react-app": "7.0.1",
"eslint-plugin-import": "2.31.0",
"eslint-plugin-prettier": "3.3.1",
"http-server": "14.1.1",
"husky": "7.0.4",
"jsdom": "22.1.0",
"lint-staged": "12.3.7",
"pepjs": "0.5.3",
"prettier": "2.6.2",
"rewire": "6.0.0",
"rimraf": "^5.0.0",
"typescript": "5.9.3",
"vite": "5.0.12",
"vite-plugin-checker": "0.7.2",
"vite-plugin-ejs": "1.7.0",
"vite-plugin-pwa": "0.21.1",
"vite-plugin-svgr": "4.2.0",
"vitest": "3.0.6",
"vitest-canvas-mock": "0.3.3"
},
"engines": {
"node": ">=18.0.0"
},
"homepage": ".",
"prettier": "@excalidraw/prettier-config",
"scripts": {
"build-node": "node ./scripts/build-node.js",
"build:app:docker": "yarn --cwd ./excalidraw-app build:app:docker",
@@ -65,21 +23,21 @@
"start:example": "yarn build:packages && yarn --cwd ./examples/with-script-in-browser start",
"test:all": "yarn test:typecheck && yarn test:code && yarn test:other && yarn test:app --watch=false",
"test:app": "vitest",
"test:code": "eslint --max-warnings=0 --ext .js,.ts,.tsx .",
"test:other": "yarn prettier --list-different",
"test:code": "oxlint .",
"test:other": "oxfmt --check .",
"test:typecheck": "tsc",
"test:update": "yarn test:app --update --watch=false",
"test": "yarn test:app",
"test:coverage": "vitest --coverage",
"test:coverage:watch": "vitest --coverage --watch",
"test:ui": "yarn test --ui --coverage.enabled=true",
"fix:code": "yarn test:code --fix",
"fix:other": "yarn prettier --write",
"fix": "yarn fix:other && yarn fix:code",
"lint": "oxlint",
"lint:type-aware": "oxlint --type-aware",
"lint:fix": "oxlint --fix",
"format:fix": "oxfmt",
"fix": "yarn lint:fix && yarn format:fix",
"locales-coverage": "node scripts/build-locales-coverage.js",
"locales-coverage:description": "node scripts/locales-coverage-description.js",
"prepare": "husky install",
"prettier": "prettier \"**/*.{css,scss,json,md,html,yml}\" --ignore-path=.eslintignore",
"release": "node scripts/release.js",
"release:test": "node scripts/release.js --tag=test",
"release:next": "node scripts/release.js --tag=next",
@@ -88,7 +46,43 @@
"rm:node_modules": "rimraf --glob node_modules excalidraw-app/node_modules packages/*/node_modules",
"clean-install": "yarn rm:node_modules && yarn install"
},
"devDependencies": {
"@babel/preset-env": "7.26.9",
"@types/chai": "4.3.0",
"@types/jest": "27.4.0",
"@types/lodash.throttle": "4.1.7",
"@types/react": "19.0.10",
"@types/react-dom": "19.0.4",
"@types/socket.io-client": "3.0.0",
"@vitejs/plugin-react": "3.1.0",
"@vitest/coverage-v8": "3.0.7",
"@vitest/ui": "2.0.5",
"chai": "4.3.6",
"dotenv": "16.0.1",
"http-server": "14.1.1",
"jsdom": "22.1.0",
"lint-staged": "12.3.7",
"oxfmt": "0.26.0",
"oxlint": "1.41.0",
"oxlint-tsgolint": "0.11.1",
"pepjs": "0.5.3",
"rewire": "6.0.0",
"rimraf": "^5.0.0",
"typescript": "5.9.3",
"vite": "5.0.12",
"vite-plugin-checker": "0.7.2",
"vite-plugin-ejs": "1.7.0",
"vite-plugin-oxlint": "github:dwelle/vite-plugin-oxlint",
"vite-plugin-pwa": "0.21.1",
"vite-plugin-svgr": "4.2.0",
"vitest": "3.0.6",
"vitest-canvas-mock": "0.3.3"
},
"resolutions": {
"strip-ansi": "6.0.1"
}
},
"engines": {
"node": ">=18.0.0"
},
"packageManager": "yarn@1.22.22"
}
-3
View File
@@ -1,3 +0,0 @@
{
"extends": ["../eslintrc.base.json"]
}
@@ -1,17 +1,9 @@
declare global {
interface Window {
debug: typeof Debug;
}
}
const lessPrecise = (num: number, precision = 5) =>
parseFloat(num.toPrecision(precision));
const getAvgFrameTime = (times: number[]) =>
lessPrecise(times.reduce((a, b) => a + b) / times.length);
const getFps = (frametime: number) => lessPrecise(1000 / frametime);
export class Debug {
public static DEBUG_LOG_TIMES = true;
@@ -24,34 +16,35 @@ export class Debug {
private static LAST_DEBUG_LOG_CALL = 0;
private static DEBUG_LOG_INTERVAL_ID: null | number = null;
private static LAST_FRAME_TIMESTAMP = 0;
private static FRAME_COUNT = 0;
private static ANIMATION_FRAME_ID: null | number = null;
private static scheduleAnimationFrame = () => {
if (Debug.DEBUG_LOG_INTERVAL_ID !== null) {
Debug.ANIMATION_FRAME_ID = requestAnimationFrame((timestamp) => {
if (Debug.LAST_FRAME_TIMESTAMP !== timestamp) {
Debug.LAST_FRAME_TIMESTAMP = timestamp;
Debug.FRAME_COUNT++;
}
if (Debug.DEBUG_LOG_INTERVAL_ID !== null) {
Debug.scheduleAnimationFrame();
}
});
}
};
private static setupInterval = () => {
if (Debug.DEBUG_LOG_INTERVAL_ID === null) {
console.info("%c(starting perf recording)", "color: lime");
Debug.DEBUG_LOG_INTERVAL_ID = window.setInterval(Debug.debugLogger, 1000);
Debug.scheduleAnimationFrame();
}
Debug.LAST_DEBUG_LOG_CALL = Date.now();
};
private static debugLogger = () => {
if (
Date.now() - Debug.LAST_DEBUG_LOG_CALL > 600 &&
Debug.DEBUG_LOG_INTERVAL_ID !== null
) {
window.clearInterval(Debug.DEBUG_LOG_INTERVAL_ID);
Debug.DEBUG_LOG_INTERVAL_ID = null;
for (const [name, { avg }] of Object.entries(Debug.TIMES_AVG)) {
if (avg != null) {
console.info(
`%c${name} run avg: ${avg}ms (${getFps(avg)} fps)`,
"color: blue",
);
}
}
console.info("%c(stopping perf recording)", "color: red");
Debug.TIMES_AGGR = {};
Debug.TIMES_AVG = {};
return;
}
if (Debug.DEBUG_LOG_TIMES) {
for (const [name, { t, times }] of Object.entries(Debug.TIMES_AGGR)) {
if (times.length) {
@@ -65,8 +58,18 @@ export class Debug {
}
for (const [name, { t, times, avg }] of Object.entries(Debug.TIMES_AVG)) {
if (times.length) {
const avgFrameTime = getAvgFrameTime(times);
console.info(name, `${avgFrameTime}ms (${getFps(avgFrameTime)} fps)`);
// const avgFrameTime = getAvgFrameTime(times);
const totalTime = times.reduce((a, b) => a + b);
const avgFrameTime = lessPrecise(totalTime / Debug.FRAME_COUNT);
console.info(
name,
`- ${times.length} calls - ${avgFrameTime}ms/frame across ${
Debug.FRAME_COUNT
} frames (${lessPrecise(
(avgFrameTime / 16.67) * 100,
1,
)}% of frame budget)`,
);
Debug.TIMES_AVG[name] = {
t,
times: [],
@@ -76,6 +79,24 @@ export class Debug {
}
}
}
Debug.FRAME_COUNT = 0;
// Check for stop condition after logging
if (
Date.now() - Debug.LAST_DEBUG_LOG_CALL > 600 &&
Debug.DEBUG_LOG_INTERVAL_ID !== null
) {
console.info("%c(stopping perf recording)", "color: red");
window.clearInterval(Debug.DEBUG_LOG_INTERVAL_ID);
window.cancelAnimationFrame(Debug.ANIMATION_FRAME_ID!);
Debug.ANIMATION_FRAME_ID = null;
Debug.FRAME_COUNT = 0;
Debug.LAST_FRAME_TIMESTAMP = 0;
Debug.DEBUG_LOG_INTERVAL_ID = null;
Debug.TIMES_AGGR = {};
Debug.TIMES_AVG = {};
}
};
public static logTime = (time?: number, name = "default") => {
@@ -109,7 +130,7 @@ export class Debug {
return (...args: T) => {
const t0 = performance.now();
const ret = fn(...args);
Debug.logTime(performance.now() - t0, name);
Debug[type](performance.now() - t0, name);
return ret;
};
};
@@ -130,6 +151,70 @@ export class Debug {
return ret;
};
};
private static CHANGED_CACHE: Record<string, Record<string, unknown>> = {};
public static logChanged(name: string, obj: Record<string, unknown>) {
const prev = Debug.CHANGED_CACHE[name];
Debug.CHANGED_CACHE[name] = obj;
if (!prev) {
return;
}
const allKeys = new Set([...Object.keys(prev), ...Object.keys(obj)]);
const changed: Record<string, { prev: unknown; next: unknown }> = {};
for (const key of allKeys) {
const prevVal = prev[key];
const nextVal = obj[key];
if (!deepEqual(prevVal, nextVal)) {
changed[key] = { prev: prevVal, next: nextVal };
}
}
if (Object.keys(changed).length > 0) {
console.info(`[${name}] changed:`, changed);
}
}
}
function deepEqual(a: unknown, b: unknown): boolean {
if (Object.is(a, b)) {
return true;
}
if (
a === null ||
b === null ||
typeof a !== "object" ||
typeof b !== "object"
) {
return false;
}
if (Array.isArray(a) !== Array.isArray(b)) {
return false;
}
const keysA = Object.keys(a as Record<string, unknown>);
const keysB = Object.keys(b as Record<string, unknown>);
if (keysA.length !== keysB.length) {
return false;
}
for (const key of keysA) {
if (
!deepEqual(
(a as Record<string, unknown>)[key],
(b as Record<string, unknown>)[key],
)
) {
return false;
}
}
return true;
}
//@ts-ignore
window.debug = Debug;
+22 -16
View File
@@ -1,10 +1,21 @@
{
"name": "@excalidraw/common",
"version": "0.18.0",
"description": "Excalidraw common functions, constants, etc.",
"keywords": [
"excalidraw",
"excalidraw-utils"
],
"bugs": "https://github.com/excalidraw/excalidraw/issues",
"license": "MIT",
"repository": "https://github.com/excalidraw/excalidraw",
"files": [
"dist/*"
],
"type": "module",
"types": "./dist/types/common/src/index.d.ts",
"main": "./dist/prod/index.js",
"module": "./dist/prod/index.js",
"types": "./dist/types/common/src/index.d.ts",
"exports": {
".": {
"types": "./dist/types/common/src/index.d.ts",
@@ -19,18 +30,19 @@
"default": "./dist/prod/index.js"
}
},
"files": [
"dist/*"
],
"description": "Excalidraw common functions, constants, etc.",
"publishConfig": {
"access": "public"
},
"license": "MIT",
"keywords": [
"excalidraw",
"excalidraw-utils"
],
"scripts": {
"gen:types": "rimraf types && tsc",
"build:esm": "rimraf dist && node ../../scripts/buildBase.js && yarn gen:types"
},
"dependencies": {
"tinycolor2": "1.6.0"
},
"devDependencies": {
"@types/tinycolor2": "1.4.6"
},
"browserslist": {
"production": [
">0.2%",
@@ -49,11 +61,5 @@
"last 1 firefox version",
"last 1 safari version"
]
},
"bugs": "https://github.com/excalidraw/excalidraw/issues",
"repository": "https://github.com/excalidraw/excalidraw",
"scripts": {
"gen:types": "rimraf types && tsc",
"build:esm": "rimraf dist && node ../../scripts/buildBase.js && yarn gen:types"
}
}
@@ -0,0 +1,185 @@
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
exports[`COLOR_PALETTE > color palette doesn't regress 1`] = `
{
"black": "#1e1e1e",
"blue": [
"#e7f5ff",
"#a5d8ff",
"#4dabf7",
"#228be6",
"#1971c2",
],
"bronze": [
"#f8f1ee",
"#eaddd7",
"#d2bab0",
"#a18072",
"#846358",
],
"cyan": [
"#e3fafc",
"#99e9f2",
"#3bc9db",
"#15aabf",
"#0c8599",
],
"grape": [
"#f8f0fc",
"#eebefa",
"#da77f2",
"#be4bdb",
"#9c36b5",
],
"gray": [
"#f8f9fa",
"#e9ecef",
"#ced4da",
"#868e96",
"#343a40",
],
"green": [
"#ebfbee",
"#b2f2bb",
"#69db7c",
"#40c057",
"#2f9e44",
],
"orange": [
"#fff4e6",
"#ffd8a8",
"#ffa94d",
"#fd7e14",
"#e8590c",
],
"pink": [
"#fff0f6",
"#fcc2d7",
"#f783ac",
"#e64980",
"#c2255c",
],
"red": [
"#fff5f5",
"#ffc9c9",
"#ff8787",
"#fa5252",
"#e03131",
],
"teal": [
"#e6fcf5",
"#96f2d7",
"#38d9a9",
"#12b886",
"#099268",
],
"transparent": "transparent",
"violet": [
"#f3f0ff",
"#d0bfff",
"#9775fa",
"#7950f2",
"#6741d9",
],
"white": "#ffffff",
"yellow": [
"#fff9db",
"#ffec99",
"#ffd43b",
"#fab005",
"#f08c00",
],
}
`;
exports[`applyDarkModeFilter > COLOR_PALETTE regression tests > matches snapshot for all palette colors 1`] = `
{
"black": "#d3d3d3",
"blue": [
"#121e26",
"#154162",
"#2273b4",
"#3791e0",
"#56a2e8",
],
"bronze": [
"#221c1a",
"#362b26",
"#5a463d",
"#917569",
"#a98d84",
],
"cyan": [
"#0a1e20",
"#004149",
"#007281",
"#0f8fa1",
"#3da5b6",
],
"grape": [
"#211a25",
"#5b3165",
"#a954be",
"#d471ed",
"#e28af8",
],
"gray": [
"#161718",
"#202325",
"#33383d",
"#6e757c",
"#b7bcc1",
],
"green": [
"#0f1d12",
"#043b0c",
"#056715",
"#16842a",
"#39994b",
],
"orange": [
"#22190d",
"#4c2b01",
"#924800",
"#cd6005",
"#f17634",
],
"pink": [
"#26191e",
"#602e40",
"#b04d70",
"#f56e9d",
"#ff8dbc",
],
"red": [
"#1f1717",
"#5a2c2c",
"#b44d4d",
"#fa6969",
"#ff8383",
],
"teal": [
"#0a1d17",
"#00422b",
"#00744b",
"#039267",
"#32a783",
],
"transparent": "#ededed00",
"violet": [
"#1f1c29",
"#4a3b72",
"#8a6cdf",
"#a885ff",
"#b595ff",
],
"white": "#121212",
"yellow": [
"#1e1900",
"#362600",
"#5f3a00",
"#905000",
"#b86200",
],
}
`;
+286
View File
@@ -0,0 +1,286 @@
import {
applyDarkModeFilter,
COLOR_PALETTE,
rgbToHex,
} from "@excalidraw/common";
describe("COLOR_PALETTE", () => {
it("color palette doesn't regress", () => {
expect(COLOR_PALETTE).toMatchSnapshot();
});
});
describe("applyDarkModeFilter", () => {
describe("basic transformations", () => {
it("transforms black to near-white", () => {
const result = applyDarkModeFilter("#000000");
// Black inverted 93% + hue rotate should be near white/light gray
expect(result).toBe("#ededed");
});
it("transforms white to near-black", () => {
const result = applyDarkModeFilter("#ffffff");
// White inverted 93% should be near black/dark gray
expect(result).toBe("#121212");
});
it("transforms pure red", () => {
const result = applyDarkModeFilter("#ff0000");
// Invert 93% + hue rotate 180deg produces a cyan-ish tint
expect(result).toBe("#ff9090");
});
it("transforms pure green", () => {
const result = applyDarkModeFilter("#00ff00");
// Invert 93% + hue rotate 180deg
expect(result).toBe("#008f00");
});
it("transforms pure blue", () => {
const result = applyDarkModeFilter("#0000ff");
// Invert 93% + hue rotate 180deg produces a light purple
expect(result).toBe("#cdcdff");
});
});
describe("color formats", () => {
it("handles hex with hash", () => {
const result = applyDarkModeFilter("#ff0000");
// Fully opaque colors return 6-char hex
expect(result).toMatch(/^#[0-9a-f]{6}$/);
});
it("handles named colors", () => {
const result = applyDarkModeFilter("red");
// "red" = #ff0000, fully opaque
expect(result).toBe("#ff9090");
});
it("handles rgb format", () => {
const result = applyDarkModeFilter("rgb(255, 0, 0)");
expect(result).toBe("#ff9090");
});
it("handles rgba format and preserves alpha", () => {
const result = applyDarkModeFilter("rgba(255, 0, 0, 0.5)");
expect(result).toMatch(/^#[0-9a-f]{8}$/);
// Alpha 0.5 = 128 in hex = 80
expect(result).toBe("#ff909080");
});
it("handles transparent", () => {
const result = applyDarkModeFilter("transparent");
// transparent = rgba(0,0,0,0), inverted should still have 0 alpha
expect(result).toBe("#ededed00");
});
it("handles shorthand hex", () => {
const result = applyDarkModeFilter("#f00");
expect(result).toBe("#ff9090");
});
});
describe("alpha preservation", () => {
it("omits alpha for full opacity", () => {
const result = applyDarkModeFilter("#ff0000ff");
// Full opacity returns 6-char hex (no alpha suffix)
expect(result).toBe("#ff9090");
});
it("preserves 50% opacity", () => {
const result = applyDarkModeFilter("#ff000080");
expect(result.slice(-2)).toBe("80");
});
it("preserves 0% opacity", () => {
const result = applyDarkModeFilter("#ff000000");
expect(result.slice(-2)).toBe("00");
});
});
describe("COLOR_PALETTE regression tests", () => {
it("transforms black from palette", () => {
// COLOR_PALETTE.black is #1e1e1e (not pure black)
const result = applyDarkModeFilter(COLOR_PALETTE.black);
expect(result).toBe("#d3d3d3");
});
it("transforms white from palette", () => {
const result = applyDarkModeFilter(COLOR_PALETTE.white);
expect(result).toBe("#121212");
});
it("transforms transparent from palette", () => {
const result = applyDarkModeFilter(COLOR_PALETTE.transparent);
expect(result).toBe("#ededed00");
});
// Test each color family from the palette (all opaque, so 6-char hex)
describe("red shades", () => {
const redShades = COLOR_PALETTE.red;
it.each(redShades.map((color, i) => [color, i]))(
"transforms red shade %s (index %d)",
(color) => {
const result = applyDarkModeFilter(color as string);
expect(result).toMatch(/^#[0-9a-f]{6}$/);
},
);
});
describe("blue shades", () => {
const blueShades = COLOR_PALETTE.blue;
it.each(blueShades.map((color, i) => [color, i]))(
"transforms blue shade %s (index %d)",
(color) => {
const result = applyDarkModeFilter(color as string);
expect(result).toMatch(/^#[0-9a-f]{6}$/);
},
);
});
describe("green shades", () => {
const greenShades = COLOR_PALETTE.green;
it.each(greenShades.map((color, i) => [color, i]))(
"transforms green shade %s (index %d)",
(color) => {
const result = applyDarkModeFilter(color as string);
expect(result).toMatch(/^#[0-9a-f]{6}$/);
},
);
});
describe("gray shades", () => {
const grayShades = COLOR_PALETTE.gray;
it.each(grayShades.map((color, i) => [color, i]))(
"transforms gray shade %s (index %d)",
(color) => {
const result = applyDarkModeFilter(color as string);
expect(result).toMatch(/^#[0-9a-f]{6}$/);
},
);
});
describe("bronze shades", () => {
const bronzeShades = COLOR_PALETTE.bronze;
it.each(bronzeShades.map((color, i) => [color, i]))(
"transforms bronze shade %s (index %d)",
(color) => {
const result = applyDarkModeFilter(color as string);
expect(result).toMatch(/^#[0-9a-f]{6}$/);
},
);
});
// Snapshot test for full palette to catch any regressions
it("matches snapshot for all palette colors", () => {
const transformedPalette: Record<string, string | string[]> = {};
transformedPalette.black = applyDarkModeFilter(COLOR_PALETTE.black);
transformedPalette.white = applyDarkModeFilter(COLOR_PALETTE.white);
transformedPalette.transparent = applyDarkModeFilter(
COLOR_PALETTE.transparent,
);
// Transform color arrays
for (const colorName of [
"gray",
"red",
"pink",
"grape",
"violet",
"blue",
"cyan",
"teal",
"green",
"yellow",
"orange",
"bronze",
] as const) {
const shades = COLOR_PALETTE[colorName];
transformedPalette[colorName] = shades.map((shade) =>
applyDarkModeFilter(shade),
);
}
expect(transformedPalette).toMatchSnapshot();
});
});
describe("caching", () => {
it("returns same result for same input (cached)", () => {
const result1 = applyDarkModeFilter("#ff0000");
const result2 = applyDarkModeFilter("#ff0000");
expect(result1).toBe(result2);
});
});
});
describe("rgbToHex", () => {
describe("basic RGB conversion", () => {
it("converts black (0,0,0)", () => {
expect(rgbToHex(0, 0, 0)).toBe("#000000");
});
it("converts white (255,255,255)", () => {
expect(rgbToHex(255, 255, 255)).toBe("#ffffff");
});
it("converts red (255,0,0)", () => {
expect(rgbToHex(255, 0, 0)).toBe("#ff0000");
});
it("converts green (0,255,0)", () => {
expect(rgbToHex(0, 255, 0)).toBe("#00ff00");
});
it("converts blue (0,0,255)", () => {
expect(rgbToHex(0, 0, 255)).toBe("#0000ff");
});
it("converts arbitrary color", () => {
expect(rgbToHex(30, 30, 30)).toBe("#1e1e1e");
});
});
describe("leading zeros preservation", () => {
it("preserves leading zeros for low values", () => {
expect(rgbToHex(0, 0, 1)).toBe("#000001");
expect(rgbToHex(0, 1, 0)).toBe("#000100");
expect(rgbToHex(1, 0, 0)).toBe("#010000");
});
it("preserves zeros for single-digit hex values", () => {
expect(rgbToHex(15, 15, 15)).toBe("#0f0f0f");
});
});
describe("alpha handling", () => {
it("omits alpha when undefined", () => {
expect(rgbToHex(255, 0, 0)).toBe("#ff0000");
expect(rgbToHex(255, 0, 0, undefined)).toBe("#ff0000");
});
it("omits alpha when fully opaque (1)", () => {
expect(rgbToHex(255, 0, 0, 1)).toBe("#ff0000");
});
it("includes alpha for semi-transparent (0.5)", () => {
// 0.5 * 255 = 127.5 -> rounds to 128 = 0x80
expect(rgbToHex(255, 0, 0, 0.5)).toBe("#ff000080");
});
it("includes alpha for fully transparent (0)", () => {
expect(rgbToHex(255, 0, 0, 0)).toBe("#ff000000");
});
it("includes alpha for near-opaque (0.99)", () => {
// 0.99 * 255 = 252.45 -> rounds to 252 = 0xfc
expect(rgbToHex(255, 0, 0, 0.99)).toBe("#ff0000fc");
});
it("pads alpha with leading zero when needed", () => {
// 0.05 * 255 = 12.75 -> rounds to 13 = 0x0d
expect(rgbToHex(255, 0, 0, 0.05)).toBe("#ff00000d");
});
});
});
+235 -49
View File
@@ -1,31 +1,135 @@
import oc from "open-color";
import tinycolor from "tinycolor2";
import type { Merge } from "./utility-types";
import { clamp } from "@excalidraw/math";
import { degreesToRadians } from "@excalidraw/math";
export const COLOR_OUTLINE_CONTRAST_THRESHOLD = 240;
import type { Degrees } from "@excalidraw/math";
// ---------------------------------------------------------------------------
// Dark mode color transformation
// ---------------------------------------------------------------------------
// Browser-only cache to avoid memory leaks on server
const DARK_MODE_COLORS_CACHE: Map<string, string> | null =
typeof window !== "undefined" ? new Map() : null;
function cssHueRotate(
red: number,
green: number,
blue: number,
degrees: Degrees,
): { r: number; g: number; b: number } {
// normalize
const r = red / 255;
const g = green / 255;
const b = blue / 255;
// Convert degrees to radians
const a = degreesToRadians(degrees);
const c = Math.cos(a);
const s = Math.sin(a);
// rotation matrix
const matrix = [
0.213 + c * 0.787 - s * 0.213,
0.715 - c * 0.715 - s * 0.715,
0.072 - c * 0.072 + s * 0.928,
0.213 - c * 0.213 + s * 0.143,
0.715 + c * 0.285 + s * 0.14,
0.072 - c * 0.072 - s * 0.283,
0.213 - c * 0.213 - s * 0.787,
0.715 - c * 0.715 + s * 0.715,
0.072 + c * 0.928 + s * 0.072,
];
// transform
const newR = r * matrix[0] + g * matrix[1] + b * matrix[2];
const newG = r * matrix[3] + g * matrix[4] + b * matrix[5];
const newB = r * matrix[6] + g * matrix[7] + b * matrix[8];
// clamp the values to [0, 1] range and convert back to [0, 255]
return {
r: Math.round(Math.max(0, Math.min(1, newR)) * 255),
g: Math.round(Math.max(0, Math.min(1, newG)) * 255),
b: Math.round(Math.max(0, Math.min(1, newB)) * 255),
};
}
const cssInvert = (
r: number,
g: number,
b: number,
percent: number,
): { r: number; g: number; b: number } => {
const p = clamp(percent, 0, 100) / 100;
// Function to invert a single color component
const invertComponent = (color: number): number => {
// Apply the invert formula
const inverted = color * (1 - p) + (255 - color) * p;
// Round to the nearest integer and clamp to [0, 255]
return Math.round(clamp(inverted, 0, 255));
};
// Calculate the inverted RGB components
const invertedR = invertComponent(r);
const invertedG = invertComponent(g);
const invertedB = invertComponent(b);
return { r: invertedR, g: invertedG, b: invertedB };
};
export const applyDarkModeFilter = (color: string): string => {
const cached = DARK_MODE_COLORS_CACHE?.get(color);
if (cached) {
return cached;
}
const tc = tinycolor(color);
const alpha = tc.getAlpha();
// order of operations matters
// (corresponds to "filter: invert(invertPercent) hue-rotate(hueDegrees)" in css)
const rgb = tc.toRgb();
const inverted = cssInvert(rgb.r, rgb.g, rgb.b, 93);
const rotated = cssHueRotate(
inverted.r,
inverted.g,
inverted.b,
180 as Degrees,
);
const result = rgbToHex(rotated.r, rotated.g, rotated.b, alpha);
if (DARK_MODE_COLORS_CACHE) {
DARK_MODE_COLORS_CACHE.set(color, result);
}
return result;
};
// ---------------------------------------------------------------------------
// Color palette
// ---------------------------------------------------------------------------
// FIXME can't put to utils.ts rn because of circular dependency
const pick = <R extends Record<string, any>, K extends readonly (keyof R)[]>(
source: R,
keys: K,
) => {
return keys.reduce((acc, key: K[number]) => {
if (key in source) {
acc[key] = source[key];
}
return acc;
}, {} as Pick<R, K[number]>) as Pick<R, K[number]>;
return keys.reduce(
(acc, key: K[number]) => {
if (key in source) {
acc[key] = source[key];
}
return acc;
},
{} as Pick<R, K[number]>,
) as Pick<R, K[number]>;
};
export type ColorPickerColor =
| Exclude<keyof oc, "indigo" | "lime">
| "transparent"
| "bronze";
export type ColorTuple = readonly [string, string, string, string, string];
export type ColorPalette = Merge<
Record<ColorPickerColor, ColorTuple>,
{ black: "#1e1e1e"; white: "#ffffff"; transparent: "transparent" }
>;
// used general type instead of specific type (ColorPalette) to support custom colors
export type ColorPaletteCustom = { [key: string]: ColorTuple | string };
@@ -38,38 +142,30 @@ export const DEFAULT_CHART_COLOR_INDEX = 4;
export const DEFAULT_ELEMENT_STROKE_COLOR_INDEX = 4;
export const DEFAULT_ELEMENT_BACKGROUND_COLOR_INDEX = 1;
export const ELEMENTS_PALETTE_SHADE_INDEXES = [0, 2, 4, 6, 8] as const;
export const CANVAS_PALETTE_SHADE_INDEXES = [0, 1, 2, 3, 4] as const;
export const getSpecificColorShades = (
color: Exclude<
ColorPickerColor,
"transparent" | "white" | "black" | "bronze"
>,
indexArr: Readonly<ColorShadesIndexes>,
) => {
return indexArr.map((index) => oc[color][index]) as any as ColorTuple;
};
export const COLOR_PALETTE = {
transparent: "transparent",
black: "#1e1e1e",
white: "#ffffff",
// open-colors
gray: getSpecificColorShades("gray", ELEMENTS_PALETTE_SHADE_INDEXES),
red: getSpecificColorShades("red", ELEMENTS_PALETTE_SHADE_INDEXES),
pink: getSpecificColorShades("pink", ELEMENTS_PALETTE_SHADE_INDEXES),
grape: getSpecificColorShades("grape", ELEMENTS_PALETTE_SHADE_INDEXES),
violet: getSpecificColorShades("violet", ELEMENTS_PALETTE_SHADE_INDEXES),
blue: getSpecificColorShades("blue", ELEMENTS_PALETTE_SHADE_INDEXES),
cyan: getSpecificColorShades("cyan", ELEMENTS_PALETTE_SHADE_INDEXES),
teal: getSpecificColorShades("teal", ELEMENTS_PALETTE_SHADE_INDEXES),
green: getSpecificColorShades("green", ELEMENTS_PALETTE_SHADE_INDEXES),
yellow: getSpecificColorShades("yellow", ELEMENTS_PALETTE_SHADE_INDEXES),
orange: getSpecificColorShades("orange", ELEMENTS_PALETTE_SHADE_INDEXES),
// radix bronze shades 3,5,7,9,11
// open-color from https://github.com/yeun/open-color/blob/master/open-color.js
// corresponds to indexes [0,2,4,6,8] (weights: 50, 200, 400, 600, 800)
gray: ["#f8f9fa", "#e9ecef", "#ced4da", "#868e96", "#343a40"],
red: ["#fff5f5", "#ffc9c9", "#ff8787", "#fa5252", "#e03131"],
pink: ["#fff0f6", "#fcc2d7", "#f783ac", "#e64980", "#c2255c"],
grape: ["#f8f0fc", "#eebefa", "#da77f2", "#be4bdb", "#9c36b5"],
violet: ["#f3f0ff", "#d0bfff", "#9775fa", "#7950f2", "#6741d9"],
blue: ["#e7f5ff", "#a5d8ff", "#4dabf7", "#228be6", "#1971c2"],
cyan: ["#e3fafc", "#99e9f2", "#3bc9db", "#15aabf", "#0c8599"],
teal: ["#e6fcf5", "#96f2d7", "#38d9a9", "#12b886", "#099268"],
green: ["#ebfbee", "#b2f2bb", "#69db7c", "#40c057", "#2f9e44"],
yellow: ["#fff9db", "#ffec99", "#ffd43b", "#fab005", "#f08c00"],
orange: ["#fff4e6", "#ffd8a8", "#ffa94d", "#fd7e14", "#e8590c"],
// radix bronze shades [3,5,7,9,11]
bronze: ["#f8f1ee", "#eaddd7", "#d2bab0", "#a18072", "#846358"],
} as ColorPalette;
} as const;
export type ColorPalette = typeof COLOR_PALETTE;
export type ColorPickerColor = keyof typeof COLOR_PALETTE;
const COMMON_ELEMENT_SHADES = pick(COLOR_PALETTE, [
"cyan",
@@ -84,7 +180,6 @@ const COMMON_ELEMENT_SHADES = pick(COLOR_PALETTE, [
"red",
]);
// -----------------------------------------------------------------------------
// quick picks defaults
// -----------------------------------------------------------------------------
@@ -119,7 +214,6 @@ export const DEFAULT_CANVAS_BACKGROUND_PICKS = [
"#fdf8f6",
] as ColorTuple;
// -----------------------------------------------------------------------------
// palette defaults
// -----------------------------------------------------------------------------
@@ -145,8 +239,7 @@ export const DEFAULT_ELEMENT_BACKGROUND_COLOR_PALETTE = {
...COMMON_ELEMENT_SHADES,
} as const;
// -----------------------------------------------------------------------------
// helpers
// color palette helpers
// -----------------------------------------------------------------------------
// !!!MUST BE WITHOUT GRAY, TRANSPARENT AND BLACK!!!
@@ -167,7 +260,100 @@ export const getAllColorsSpecificShade = (index: 0 | 1 | 2 | 3 | 4) =>
COLOR_PALETTE.red[index],
] as const;
export const rgbToHex = (r: number, g: number, b: number) =>
`#${((1 << 24) + (r << 16) + (g << 8) + b).toString(16).slice(1)}`;
// -----------------------------------------------------------------------------
// other helpers
// -----------------------------------------------------------------------------
export const rgbToHex = (r: number, g: number, b: number, a?: number) => {
// (1 << 24) adds 0x1000000 to ensure the hex string is always 7 chars,
// then slice(1) removes the leading "1" to get exactly 6 hex digits
// e.g. rgb(0,0,0) -> 0x1000000 -> "1000000" -> "000000"
const hex6 = `#${((1 << 24) + (r << 16) + (g << 8) + b)
.toString(16)
.slice(1)}`;
if (a !== undefined && a < 1) {
// convert alpha from 0-1 float to 0-255 int, then to 2-digit hex
// e.g. 0.5 -> 128 -> "80"
const alphaHex = Math.round(a * 255)
.toString(16)
.padStart(2, "0");
return `${hex6}${alphaHex}`;
}
return hex6;
};
/**
* @returns #RRGGBB or #RRGGBBAA based on color containing non-opaque alpha,
* null if not valid color
*/
export const colorToHex = (color: string): string | null => {
const tc = tinycolor(color);
if (!tc.isValid()) {
return null;
}
const { r, g, b, a } = tc.toRgb();
return rgbToHex(r, g, b, a);
};
export const isTransparent = (color: string) => {
return tinycolor(color).getAlpha() === 0;
};
// -----------------------------------------------------------------------------
// color contract helpers
// -----------------------------------------------------------------------------
export const COLOR_OUTLINE_CONTRAST_THRESHOLD = 240;
const calculateContrast = (r: number, g: number, b: number): number => {
const yiq = (r * 299 + g * 587 + b * 114) / 1000;
return yiq;
};
// YIQ algo, inspiration from https://stackoverflow.com/a/11868398
export const isColorDark = (color: string, threshold = 160): boolean => {
// no color ("") -> assume it default to black
if (!color) {
return true;
}
if (isTransparent(color)) {
return false;
}
const tc = tinycolor(color);
if (!tc.isValid()) {
// invalid color -> assume it defaults to black
return true;
}
const { r, g, b } = tc.toRgb();
return calculateContrast(r, g, b) < threshold;
};
// -----------------------------------------------------------------------------
// normalization
// -----------------------------------------------------------------------------
/**
* tries to keep the input color as-is if it's valid, making minimal adjustments
* (trimming whitespace or adding `#` to hex colors)
*/
export const normalizeInputColor = (color: string): string | null => {
color = color.trim();
if (isTransparent(color)) {
return color;
}
const tc = tinycolor(color);
if (tc.isValid()) {
// testing for `#` first fixes a bug on Electron (more specfically, an
// Obsidian popout window), where a hex color without `#` is considered valid
if (["hex", "hex8"].includes(tc.getFormat()) && !color.startsWith("#")) {
return `#${color}`;
}
return color;
}
return null;
};
+4 -3
View File
@@ -106,6 +106,7 @@ export const CLASSES = {
CONVERT_ELEMENT_TYPE_POPUP: "ConvertElementTypePopup",
SHAPE_ACTIONS_THEME_SCOPE: "shape-actions-theme-scope",
FRAME_NAME: "frame-name",
DROPDOWN_MENU_EVENT_WRAPPER: "dropdown-menu-event-wrapper",
};
export const FONT_SIZES = {
@@ -190,6 +191,8 @@ export const THEME = {
DARK: "dark",
} as const;
export const DARK_THEME_FILTER = "invert(93%) hue-rotate(180deg)";
export const FRAME_STYLE = {
strokeColor: "#bbb" as ExcalidrawElement["strokeColor"],
strokeWidth: 2 as ExcalidrawElement["strokeWidth"],
@@ -249,6 +252,7 @@ export const STRING_MIME_TYPES = {
json: "application/json",
// excalidraw data
excalidraw: "application/vnd.excalidraw+json",
excalidrawClipboard: "application/vnd.excalidraw.clipboard+json",
// LEGACY: fully-qualified library JSON data
excalidrawlib: "application/vnd.excalidrawlib+json",
// list of excalidraw library item ids
@@ -305,9 +309,6 @@ export const IDLE_THRESHOLD = 60_000;
// Report a user active each ACTIVE_THRESHOLD milliseconds
export const ACTIVE_THRESHOLD = 3_000;
// duplicates --theme-filter, should be removed soon
export const THEME_FILTER = "invert(93%) hue-rotate(180deg)";
export const URL_QUERY_KEYS = {
addLibrary: "addLibrary",
} as const;
+4 -5
View File
@@ -16,7 +16,6 @@ export type EditorInterface = Readonly<{
const DESKTOP_UI_MODE_STORAGE_KEY = "excalidraw.desktopUIMode";
// breakpoints
// mobile: up to 699px
export const MQ_MAX_MOBILE = 599;
export const MQ_MAX_WIDTH_LANDSCAPE = 1000;
@@ -24,9 +23,9 @@ export const MQ_MAX_HEIGHT_LANDSCAPE = 500;
// tablets
export const MQ_MIN_TABLET = MQ_MAX_MOBILE + 1; // lower bound (excludes phones)
export const MQ_MAX_TABLET = 1400; // upper bound (excludes laptops/desktops)
export const MQ_MAX_TABLET = 1180; // ipad air
// desktop/laptop
// desktop/laptop (NOTE: not used for form factor detection)
export const MQ_MIN_WIDTH_DESKTOP = 1440;
// sidebar
@@ -194,7 +193,7 @@ export const loadDesktopUIModePreference = () => {
if (stored === "compact" || stored === "full") {
return stored as EditorInterface["desktopUIMode"];
}
} catch (error) {
} catch {
// ignore storage access issues (e.g., Safari private mode)
}
@@ -207,7 +206,7 @@ const persistDesktopUIMode = (mode: EditorInterface["desktopUIMode"]) => {
}
try {
window.localStorage.setItem(DESKTOP_UI_MODE_STORAGE_KEY, mode);
} catch (error) {
} catch {
// ignore storage access issues (e.g., Safari private mode)
}
};
+1 -1
View File
@@ -11,5 +11,5 @@ export * from "./random";
export * from "./url";
export * from "./utils";
export * from "./emitter";
export * from "./visualdebug";
export * from "./editorInterface";
export { Debug } from "../debug";
+4 -6
View File
@@ -44,9 +44,8 @@ export type ForwardRef<T, P = any> = Parameters<
CallableType<React.ForwardRefRenderFunction<T, P>>
>[1];
export type ExtractSetType<T extends Set<any>> = T extends Set<infer U>
? U
: never;
export type ExtractSetType<T extends Set<any>> =
T extends Set<infer U> ? U : never;
export type SameType<T, U> = T extends U ? (U extends T ? true : false) : false;
export type Assert<T extends true> = T;
@@ -74,6 +73,5 @@ export type DTO<T> = {
[K in keyof T as T[K] extends Function ? never : K]: T[K];
};
export type MapEntry<M extends Map<any, any>> = M extends Map<infer K, infer V>
? [K, V]
: never;
export type MapEntry<M extends Map<any, any>> =
M extends Map<infer K, infer V> ? [K, V] : never;
+85 -48
View File
@@ -1,5 +1,7 @@
import { average } from "@excalidraw/math";
import type { GlobalCoord } from "@excalidraw/math";
import type { FontFamilyValues, FontString } from "@excalidraw/element/types";
import type {
@@ -10,7 +12,6 @@ import type {
Zoom,
} from "@excalidraw/excalidraw/types";
import { COLOR_PALETTE } from "./colors";
import {
DEFAULT_VERSION,
ENV,
@@ -442,7 +443,7 @@ export const viewportCoordsToSceneCoords = (
const x = (clientX - offsetLeft) / zoom.value - scrollX;
const y = (clientY - offsetTop) / zoom.value - scrollY;
return { x, y };
return { x, y } as GlobalCoord;
};
export const sceneCoordsToViewportCoords = (
@@ -548,16 +549,6 @@ export const mapFind = <T, K>(
return undefined;
};
export const isTransparent = (color: string) => {
const isRGBTransparent = color.length === 5 && color.substr(4, 1) === "0";
const isRRGGBBTransparent = color.length === 9 && color.substr(7, 2) === "00";
return (
isRGBTransparent ||
isRRGGBBTransparent ||
color === COLOR_PALETTE.transparent
);
};
export type ResolvablePromise<T> = Promise<T> & {
resolve: [T] extends [undefined]
? (value?: MaybePromise<Awaited<T>>) => void
@@ -678,10 +669,13 @@ export const arrayToMap = <T extends { id: string } | string>(
if (items instanceof Map) {
return items;
}
return items.reduce((acc: Map<string, T>, element) => {
acc.set(typeof element === "string" ? element : element.id, element);
return acc;
}, new Map() as Map<string, T>);
return items.reduce(
(acc: Map<string, T>, element) => {
acc.set(typeof element === "string" ? element : element.id, element);
return acc;
},
new Map() as Map<string, T>,
);
};
export const arrayToMapWithIndex = <T extends { id: string }>(
@@ -699,10 +693,13 @@ export const arrayToObject = <T>(
array: readonly T[],
groupBy?: (value: T) => string | number,
) =>
array.reduce((acc, value, idx) => {
acc[groupBy ? groupBy(value) : idx] = value;
return acc;
}, {} as { [key: string]: T });
array.reduce(
(acc, value, idx) => {
acc[groupBy ? groupBy(value) : idx] = value;
return acc;
},
{} as { [key: string]: T },
);
/** Doubly linked node */
export type Node<T> = T & {
@@ -810,7 +807,7 @@ export const isPrimitive = (val: any) => {
export const getFrame = () => {
try {
return window.self === window.top ? "top" : "iframe";
} catch (error) {
} catch {
return "iframe";
}
};
@@ -1030,8 +1027,8 @@ export const isMemberOf = <T extends string>(
return collection instanceof Set || collection instanceof Map
? collection.has(value as T)
: "includes" in collection
? collection.includes(value as T)
: collection.hasOwnProperty(value);
? collection.includes(value as T)
: collection.hasOwnProperty(value);
};
export const cloneJSON = <T>(obj: T): T => JSON.parse(JSON.stringify(obj));
@@ -1157,39 +1154,72 @@ export const normalizeEOL = (str: string) => {
};
// -----------------------------------------------------------------------------
type HasBrand<T> = {
export type HasBrand<T> = {
// eslint-disable-next-line @typescript-eslint/no-unused-vars
[K in keyof T]: K extends `~brand${infer _}` ? true : never;
[K in keyof T]: K extends `~brand${infer _}` | "_brand" ? true : never;
}[keyof T];
type RemoveAllBrands<T> = HasBrand<T> extends true
? {
// eslint-disable-next-line @typescript-eslint/no-unused-vars
[K in keyof T as K extends `~brand~${infer _}` ? never : K]: T[K];
}
: never;
type RemoveAllBrands<T> =
HasBrand<T> extends true
? {
// eslint-disable-next-line @typescript-eslint/no-unused-vars
[K in keyof T as K extends `~brand~${infer _}` | "_brand"
? never
: K]: T[K];
}
: T;
// adapted from https://github.com/colinhacks/zod/discussions/1994#discussioncomment-6068940
// currently does not cover all types (e.g. tuples, promises...)
type Unbrand<T> = T extends Map<infer E, infer F>
? Map<E, F>
: T extends Set<infer E>
? Set<E>
: T extends Array<infer E>
? Array<E>
: RemoveAllBrands<T>;
// For accepting values - uses loose matching for branded types
// Preserves readonly modifier: mutable array requires mutable input
type UnbrandForValue<T> =
T extends Map<infer E, infer F>
? Map<UnbrandForValue<E>, UnbrandForValue<F>>
: T extends Set<infer E>
? Set<UnbrandForValue<E>>
: T extends readonly any[]
? T extends any[]
? unknown[] // mutable array - require mutable input
: readonly unknown[] // readonly array - accept readonly input
: RemoveAllBrands<T>;
// For return types - preserves array element unbranding
export type Unbrand<T> =
T extends Map<infer E, infer F>
? Map<Unbrand<E>, Unbrand<F>>
: T extends Set<infer E>
? Set<Unbrand<E>>
: T extends readonly (infer E)[]
? Array<Unbrand<E>>
: RemoveAllBrands<T>;
export type CombineBrands<BrandedType, CurrentType> =
BrandedType extends readonly (infer BE)[]
? CurrentType extends readonly (infer CE)[]
? Array<CE & BE>
: CurrentType & BrandedType
: CurrentType & BrandedType;
export type CombineBrandsIfNeeded<T, Required> = [T] extends [Required]
? T[]
: HasBrand<T> extends true
? CombineBrands<T, Required>[]
: Required[];
/**
* Makes type into a branded type, ensuring that value is assignable to
* the base ubranded type. Optionally you can explicitly supply current value
* the base unbranded type. Optionally you can explicitly supply current value
* type to combine both (useful for composite branded types. Make sure you
* compose branded types which are not composite themselves.)
*/
export const toBrandedType = <BrandedType, CurrentType = BrandedType>(
value: Unbrand<BrandedType>,
) => {
return value as CurrentType & BrandedType;
};
export function toBrandedType<BrandedType>(
value: UnbrandForValue<BrandedType>,
): BrandedType;
export function toBrandedType<BrandedType, CurrentType>(
value: CurrentType,
): CombineBrands<BrandedType, CurrentType>;
export function toBrandedType(value: unknown) {
return value;
}
// -----------------------------------------------------------------------------
@@ -1240,8 +1270,8 @@ export const sizeOf = (
return isReadonlyArray(value)
? value.length
: value instanceof Map || value instanceof Set
? value.size
: Object.keys(value).length;
? value.size
: Object.keys(value).length;
};
export const reduceToCommonValue = <T, R = T>(
@@ -1311,3 +1341,10 @@ export const setFeatureFlag = <F extends keyof FEATURE_FLAGS>(
console.error("unable to set feature flag", e);
}
};
export const oneOf = <N extends string | number | symbol | null, H extends N>(
needle: N,
haystack: readonly H[],
): needle is H => {
return haystack.includes(needle as any);
};
-3
View File
@@ -1,3 +0,0 @@
{
"extends": ["../eslintrc.base.json"]
}
+26 -20
View File
@@ -1,10 +1,21 @@
{
"name": "@excalidraw/element",
"version": "0.18.0",
"description": "Excalidraw elements-related logic",
"keywords": [
"excalidraw",
"excalidraw-utils"
],
"bugs": "https://github.com/excalidraw/excalidraw/issues",
"license": "MIT",
"repository": "https://github.com/excalidraw/excalidraw",
"files": [
"dist/*"
],
"type": "module",
"types": "./dist/types/element/src/index.d.ts",
"main": "./dist/prod/index.js",
"module": "./dist/prod/index.js",
"types": "./dist/types/element/src/index.d.ts",
"exports": {
".": {
"types": "./dist/types/element/src/index.d.ts",
@@ -17,20 +28,25 @@
"development": "./dist/dev/index.js",
"production": "./dist/prod/index.js",
"default": "./dist/prod/index.js"
},
"./visualdebug": {
"types": "./dist/types/element/src/visualdebug.d.ts",
"development": "./dist/dev/visualdebug.js",
"production": "./dist/prod/visualdebug.js",
"default": "./dist/prod/visualdebug.js"
}
},
"files": [
"dist/*"
],
"description": "Excalidraw elements-related logic",
"publishConfig": {
"access": "public"
},
"license": "MIT",
"keywords": [
"excalidraw",
"excalidraw-utils"
],
"scripts": {
"gen:types": "rimraf types && tsc",
"build:esm": "rimraf dist && node ../../scripts/buildBase.js && yarn gen:types"
},
"dependencies": {
"@excalidraw/common": "0.18.0",
"@excalidraw/math": "0.18.0"
},
"browserslist": {
"production": [
">0.2%",
@@ -49,15 +65,5 @@
"last 1 firefox version",
"last 1 safari version"
]
},
"bugs": "https://github.com/excalidraw/excalidraw/issues",
"repository": "https://github.com/excalidraw/excalidraw",
"scripts": {
"gen:types": "rimraf types && tsc",
"build:esm": "rimraf dist && node ../../scripts/buildBase.js && yarn gen:types"
},
"dependencies": {
"@excalidraw/common": "0.18.0",
"@excalidraw/math": "0.18.0"
}
}
+1 -1
View File
@@ -90,7 +90,7 @@ const hashSelectionOpts = (
type _ = Assert<
SameType<
Required<HashableKeys>,
Pick<Required<HashableKeys>, typeof keys[number]>
Pick<Required<HashableKeys>, (typeof keys)[number]>
>
>;
+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;
};
+381 -248
View File
@@ -15,6 +15,7 @@ import {
pointFrom,
pointFromVector,
pointRotateRads,
pointsEqual,
vectorFromPoint,
vectorNormalize,
vectorScale,
@@ -26,14 +27,11 @@ import type { AppState } from "@excalidraw/excalidraw/types";
import type { MapEntry, Mutable } from "@excalidraw/common/utility-types";
import type { Bounds } from "@excalidraw/common";
import {
doBoundsIntersect,
getCenterForBounds,
getElementBounds,
} from "./bounds";
import { getCenterForBounds } from "./bounds";
import {
getAllHoveredElementAtPoint,
getHoveredElementForBinding,
hitElementItself,
intersectElementWithLineSegment,
isBindableElementInsideOtherBindable,
isPointInElement,
@@ -112,8 +110,10 @@ export type BindingStrategy =
*
* 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_ARROW_MIN_LENGTH = 10;
export const FOCUS_POINT_SIZE = 10 / 1.5;
export const getBindingGap = (
bindTarget: ExcalidrawBindableElement,
@@ -143,7 +143,9 @@ export const shouldEnableBindingForPointerEvent = (
return !event[KEYS.CTRL_OR_CMD];
};
export const isBindingEnabled = (appState: AppState): boolean => {
export const isBindingEnabled = (appState: {
isBindingEnabled: AppState["isBindingEnabled"];
}): boolean => {
return appState.isBindingEnabled;
};
@@ -257,7 +259,7 @@ const bindingStrategyForElbowArrowEndpointDragging = (
globalPoint,
elements,
elementsMap,
(element) => maxBindingDistance_simple(zoom),
maxBindingDistance_simple(zoom),
);
const current = hit
@@ -390,7 +392,7 @@ const bindingStrategyForNewSimpleArrowEndpointDragging = (
element: otherElement,
focusPoint: shiftKey
? elementCenterPoint(otherElement, elementsMap)
: origin ?? pointFrom<GlobalPoint>(arrow.x, arrow.y),
: (origin ?? pointFrom<GlobalPoint>(arrow.x, arrow.y)),
};
// We are hovering another element with the end point
@@ -521,41 +523,35 @@ const bindingStrategyForSimpleArrowEndpointDragging_complex = (
}
// The opposite binding is inside the same element
// eslint-disable-next-line no-else-return
else {
current = { element: hit, mode: "inside", focusPoint: point };
return { current, other: isMultiPoint ? { mode: undefined } : other };
}
}
// The opposite binding is on a different element (or nested)
// eslint-disable-next-line no-else-return
else {
// Handle the nested element case
if (isOverlapping && oppositeElement && !otherIsTransparent) {
current = {
element: oppositeElement,
mode: "inside",
focusPoint: point,
};
} else {
current = {
element: hit,
mode: "orbit",
focusPoint: isNested ? point : point,
};
}
current = { element: hit, mode: "inside", focusPoint: point };
return { current, other: isMultiPoint ? { mode: undefined } : other };
}
// The opposite binding is on a different element (or nested)
// eslint-disable-next-line no-else-return
// Handle the nested element case
if (isOverlapping && oppositeElement && !otherIsTransparent) {
current = {
element: oppositeElement,
mode: "inside",
focusPoint: point,
};
} else {
current = {
element: hit,
mode: "orbit",
focusPoint: isNested ? point : point,
};
}
return { current, other: isMultiPoint ? { mode: undefined } : other };
}
// The opposite binding is on a different element or no binding
else {
current = {
element: hit,
mode: "orbit",
focusPoint: point,
};
}
current = {
element: hit,
mode: "orbit",
focusPoint: point,
};
// Must return as only one endpoint is dragged, therefore
// the end binding strategy might accidentally gets overriden
@@ -682,7 +678,7 @@ const getBindingStrategyForDraggingBindingElementEndpoints_simple = (
globalPoint,
elements,
elementsMap,
(e) => maxBindingDistance_simple(appState.zoom),
maxBindingDistance_simple(appState.zoom),
);
const pointInElement =
hit &&
@@ -709,7 +705,13 @@ const getBindingStrategyForDraggingBindingElementEndpoints_simple = (
const otherFocusPointIsInElement =
otherBindableElement &&
otherFocusPoint &&
isPointInElement(otherFocusPoint, otherBindableElement, elementsMap);
hitElementItself({
point: otherFocusPoint,
element: otherBindableElement,
elementsMap,
threshold: 0,
overrideShouldTestInside: true,
});
// Handle outside-outside binding to the same element
if (otherBinding && otherBinding.elementId === hit?.id) {
@@ -725,13 +727,13 @@ const getBindingStrategyForDraggingBindingElementEndpoints_simple = (
focusPoint: startDragged
? globalPoint
: // NOTE: Can only affect the start point because new arrows always drag the end point
opts?.newArrow
? appState.selectedLinearElement!.initialState.origin!
: LinearElementEditor.getPointAtIndexGlobalCoordinates(
arrow,
0,
elementsMap,
), // startFixedPoint,
opts?.newArrow
? appState.selectedLinearElement!.initialState.origin!
: LinearElementEditor.getPointAtIndexGlobalCoordinates(
arrow,
0,
elementsMap,
), // startFixedPoint,
},
end: {
mode: "inside",
@@ -789,6 +791,7 @@ const getBindingStrategyForDraggingBindingElementEndpoints_simple = (
hit,
startDragged ? "start" : "end",
elementsMap,
appState.zoom,
) || globalPoint,
}
: { mode: null };
@@ -798,30 +801,45 @@ const getBindingStrategyForDraggingBindingElementEndpoints_simple = (
startDragged ? -1 : 0,
elementsMap,
);
const other: BindingStrategy =
const pointIsCloseToOtherElement =
otherFocusPoint &&
otherBindableElement &&
!otherFocusPointIsInElement &&
appState.selectedLinearElement?.initialState.altFocusPoint
hitElementItself({
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",
element: otherBindableElement,
focusPoint: appState.selectedLinearElement.initialState.altFocusPoint,
}
: opts?.angleLocked && otherBindableElement
? {
mode: "orbit",
element: otherBindableElement,
focusPoint:
projectFixedPointOntoDiagonal(
arrow,
otherEndpoint,
otherBindableElement,
startDragged ? "end" : "start",
elementsMap,
) || otherEndpoint,
}
: { mode: undefined };
? {
mode: "orbit",
element: otherBindableElement,
focusPoint:
projectFixedPointOntoDiagonal(
arrow,
otherEndpoint,
otherBindableElement,
startDragged ? "end" : "start",
elementsMap,
appState.zoom,
) || otherEndpoint,
}
: { mode: undefined }
: { mode: undefined };
return {
start: startDragged ? current : other,
@@ -1085,7 +1103,7 @@ export const updateBoundElements = (
});
}
boundElementsVisitor(elementsMap, changedElement, (element) => {
const visitor = (element: ExcalidrawElement | undefined) => {
if (!isArrowElement(element) || element.isDeleted) {
return;
}
@@ -1157,7 +1175,71 @@ export const updateBoundElements = (
if (boundText && !boundText.isDeleted) {
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 = (
@@ -1170,14 +1252,27 @@ export const updateBindings = (
},
) => {
if (isArrowElement(latestElement)) {
bindOrUnbindBindingElement(
latestElement,
new Map(),
Infinity,
Infinity,
scene,
appState,
);
const elementsMap = scene.getNonDeletedElementsMap();
if (latestElement.startBinding) {
updateArrowBindings(
latestElement,
"startBinding",
elementsMap,
scene,
appState,
);
}
if (latestElement.endBinding) {
updateArrowBindings(
latestElement,
"endBinding",
elementsMap,
scene,
appState,
);
}
} else {
updateBoundElements(latestElement, scene, {
...options,
@@ -1291,14 +1386,16 @@ export const bindPointToSnapToElementOutline = (
headingForPointFromElement(bindableElement, aabb, point),
);
const snapPoint = snapToMid(
arrowElement,
bindableElement,
elementsMap,
edgePoint,
0.05,
arrowElement,
);
const resolved = snapPoint || point;
const otherPoint = pointFrom<GlobalPoint>(
isHorizontal ? bindableCenter[0] : snapPoint[0],
!isHorizontal ? bindableCenter[1] : snapPoint[1],
isHorizontal ? bindableCenter[0] : resolved[0],
!isHorizontal ? bindableCenter[1] : resolved[1],
);
const intersector =
customIntersector ??
@@ -1306,7 +1403,7 @@ export const bindPointToSnapToElementOutline = (
otherPoint,
pointFromVector(
vectorScale(
vectorNormalize(vectorFromPoint(snapPoint, otherPoint)),
vectorNormalize(vectorFromPoint(resolved, otherPoint)),
Math.max(bindableElement.width, bindableElement.height) * 2,
),
otherPoint,
@@ -1321,14 +1418,14 @@ export const bindPointToSnapToElementOutline = (
if (!intersection) {
const anotherPoint = pointFrom<GlobalPoint>(
!isHorizontal ? bindableCenter[0] : snapPoint[0],
isHorizontal ? bindableCenter[1] : snapPoint[1],
!isHorizontal ? bindableCenter[0] : resolved[0],
isHorizontal ? bindableCenter[1] : resolved[1],
);
const anotherIntersector = lineSegment(
anotherPoint,
pointFromVector(
vectorScale(
vectorNormalize(vectorFromPoint(snapPoint, anotherPoint)),
vectorNormalize(vectorFromPoint(resolved, anotherPoint)),
Math.max(bindableElement.width, bindableElement.height) * 2,
),
anotherPoint,
@@ -1475,18 +1572,18 @@ export const avoidRectangularCorner = (
return p;
};
const snapToMid = (
arrowElement: ExcalidrawArrowElement,
export const snapToMid = (
bindTarget: ExcalidrawBindableElement,
elementsMap: ElementsMap,
p: GlobalPoint,
tolerance: number = 0.05,
): GlobalPoint => {
arrowElement?: ExcalidrawArrowElement,
): GlobalPoint | undefined => {
const { x, y, width, height, angle } = bindTarget;
const center = elementCenterPoint(bindTarget, elementsMap, -0.1, -0.1);
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
// above and below certain px distance
@@ -1495,7 +1592,7 @@ const snapToMid = (
// Too close to the center makes it hard to resolve direction precisely
if (pointDistance(center, nonRotated) < bindingGap) {
return p;
return undefined;
}
if (
@@ -1504,8 +1601,8 @@ const snapToMid = (
nonRotated[1] < center[1] + verticalThreshold
) {
// LEFT
return pointRotateRads<GlobalPoint>(
pointFrom(x - bindingGap, center[1]),
return pointRotateRads(
pointFrom<GlobalPoint>(x - bindingGap, center[1]),
center,
angle,
);
@@ -1515,7 +1612,11 @@ const snapToMid = (
nonRotated[0] < center[0] + horizontalThreshold
) {
// TOP
return pointRotateRads(pointFrom(center[0], y - bindingGap), center, angle);
return pointRotateRads(
pointFrom<GlobalPoint>(center[0], y - bindingGap),
center,
angle,
);
} else if (
nonRotated[0] >= x + width / 2 &&
nonRotated[1] > center[1] - verticalThreshold &&
@@ -1523,7 +1624,7 @@ const snapToMid = (
) {
// RIGHT
return pointRotateRads(
pointFrom(x + width + bindingGap, center[1]),
pointFrom<GlobalPoint>(x + width + bindingGap, center[1]),
center,
angle,
);
@@ -1534,7 +1635,7 @@ const snapToMid = (
) {
// DOWN
return pointRotateRads(
pointFrom(center[0], y + height + bindingGap),
pointFrom<GlobalPoint>(center[0], y + height + bindingGap),
center,
angle,
);
@@ -1583,13 +1684,44 @@ const snapToMid = (
}
}
return p;
return undefined;
};
const compareElementArea = (
a: ExcalidrawBindableElement,
b: ExcalidrawBindableElement,
) => b.width ** 2 + b.height ** 2 - (a.width ** 2 + a.height ** 2);
const extractBinding = (
arrow: ExcalidrawArrowElement,
startOrEnd: "startBinding" | "endBinding",
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 = (
arrow: NonDeleted<ExcalidrawArrowElement>,
@@ -1597,162 +1729,151 @@ export const updateBoundPoint = (
binding: FixedPointBinding | null | undefined,
bindableElement: ExcalidrawBindableElement,
elementsMap: ElementsMap,
customIntersector?: LineSegment<GlobalPoint>,
dragging?: boolean,
): LocalPoint | null => {
if (
binding == null ||
// We only need to update the other end if this is a 2 point line element
(binding.elementId !== bindableElement.id && arrow.points.length > 2)
(binding.elementId !== bindableElement.id && arrow.points.length > 2) ||
// Initial arrow created on pointer down needs to not update the points
pointsEqual(
arrow.points[arrow.points.length - 1],
pointFrom<LocalPoint>(0, 0),
)
) {
return null;
}
const global = getGlobalFixedPointForBindableElement(
const focusPoint = getGlobalFixedPointForBindableElement(
normalizeFixedPoint(binding.fixedPoint),
bindableElement,
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
// to the exact focus points on the elements.
// INTUITION: We're not interested in the exacts length of the arrow (which
// will change if we change where we route it), we want to know the length of
// 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(
// 0. Short-circuit for inside binding as it doesn't require any
// calculations and is not affected by other bindings
if (binding.mode === "inside") {
return LinearElementEditor.createPointAt(
arrow,
elementsMap,
);
const center = pointFrom<GlobalPoint>((x1 + x2) / 2, (y1 + y2) / 2);
const edgePoint = isRectanguloidElement(bindableElement)
? 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),
focusPoint[0],
focusPoint[1],
null,
);
}
const maybeOutlineGlobal =
binding.mode === "orbit" && bindableElement
? isNested
? global
: bindPointToSnapToElementOutline(
{
...arrow,
points: [
pointIndex === 0
? LinearElementEditor.createPointAt(
arrow,
elementsMap,
global[0],
global[1],
null,
)
: arrow.points[0],
...arrow.points.slice(1, -1),
pointIndex === arrow.points.length - 1
? LinearElementEditor.createPointAt(
arrow,
elementsMap,
global[0],
global[1],
null,
)
: arrow.points[arrow.points.length - 1],
],
},
bindableElement,
pointIndex === 0 ? "start" : "end",
elementsMap,
_customIntersector,
)
: global;
const { element: otherBindable, focusPoint: otherFocusPoint } =
extractBinding(
arrow,
startOrEnd === "startBinding" ? "endBinding" : "startBinding",
elementsMap,
);
const otherArrowPoint = LinearElementEditor.getPointAtIndexGlobalCoordinates(
arrow,
startOrEnd === "startBinding" ? -1 : 0,
elementsMap,
);
const otherFocusPointOrArrowPoint = otherFocusPoint || otherArrowPoint;
const intersector =
otherFocusPointOrArrowPoint &&
lineSegment(focusPoint, otherFocusPointOrArrowPoint);
const otherOutlinePoint =
otherBindable &&
intersector &&
intersectElementWithLineSegment(
otherBindable,
elementsMap,
intersector,
getBindingGap(otherBindable, arrow),
).sort(
(a, b) => pointDistanceSq(a, focusPoint) - pointDistanceSq(b, focusPoint),
)[0];
const outlinePoint =
intersector &&
intersectElementWithLineSegment(
bindableElement,
elementsMap,
intersector,
getBindingGap(bindableElement, arrow),
).sort(
(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(
arrow,
elementsMap,
maybeOutlineGlobal[0],
maybeOutlineGlobal[1],
outlinePoint?.[0] || focusPoint[0],
outlinePoint?.[1] || focusPoint[1],
null,
);
};
@@ -1802,7 +1923,7 @@ export const calculateFixedPointForNonElbowArrowBinding = (
elementsMap: ElementsMap,
focusPoint?: GlobalPoint,
): { fixedPoint: FixedPoint } => {
const edgePoint = focusPoint
const edgePoint: GlobalPoint = focusPoint
? focusPoint
: LinearElementEditor.getPointAtIndexGlobalCoordinates(
linearElement,
@@ -1810,11 +1931,7 @@ export const calculateFixedPointForNonElbowArrowBinding = (
elementsMap,
);
// Convert the global point to element-local coordinates
const elementCenter = pointFrom(
hoveredElement.x + hoveredElement.width / 2,
hoveredElement.y + hoveredElement.height / 2,
);
const elementCenter = elementCenterPoint(hoveredElement, elementsMap);
// Rotate the point to account for element rotation
const nonRotatedPoint = pointRotateRads(
@@ -1938,9 +2055,9 @@ const newBoundElements = (
nextBoundElements.push(
...elementsToAdd.map(
(x) =>
({ id: x.id, type: x.type } as
({ id: x.id, type: x.type }) as
| ExcalidrawArrowElement
| ExcalidrawTextElement),
| ExcalidrawTextElement,
),
);
@@ -2324,21 +2441,37 @@ export const getArrowLocalFixedPoints = (
];
};
export const normalizeFixedPoint = <T extends FixedPoint | null>(
export const isFixedPoint = (
fixedPoint: any,
): fixedPoint is FixedPointBinding["fixedPoint"] => {
return (
Array.isArray(fixedPoint) &&
fixedPoint.length === 2 &&
fixedPoint.every((coord) => Number.isFinite(coord))
);
};
export const normalizeFixedPoint = <T extends FixedPoint>(
fixedPoint: T,
): T extends null ? null : FixedPoint => {
): FixedPoint => {
if (!isFixedPoint(fixedPoint)) {
return [0.5001, 0.5001];
}
const EPSILON = 0.0001;
// Do not allow a precise 0.5 for fixed point ratio
// to avoid jumping arrow heading due to floating point imprecision
if (
fixedPoint &&
(Math.abs(fixedPoint[0] - 0.5) < 0.0001 ||
Math.abs(fixedPoint[1] - 0.5) < 0.0001)
Math.abs(fixedPoint[0] - 0.5) < EPSILON ||
Math.abs(fixedPoint[1] - 0.5) < EPSILON
) {
return fixedPoint.map((ratio) =>
Math.abs(ratio - 0.5) < 0.0001 ? 0.5001 : ratio,
) as T extends null ? null : FixedPoint;
Math.abs(ratio - 0.5) < EPSILON ? 0.5001 : ratio,
) as FixedPoint;
}
return fixedPoint as any as T extends null ? null : FixedPoint;
return fixedPoint;
};
type Side =
@@ -2408,7 +2541,7 @@ const SHAPE_CONFIGS: Record<ShapeType, SectorConfig[]> = {
const getSectorBoundaries = (
config: SectorConfig[],
): Array<{ start: number; end: number; side: Side }> => {
return config.map((sector, index) => {
return config.map((sector) => {
const halfWidth = sector.sectorWidth / 2;
let start = sector.centerAngle - halfWidth;
let end = sector.centerAngle + halfWidth;
+2 -1
View File
@@ -897,6 +897,7 @@ export const getArrowheadPoints = (
return [x2, y2, x3, y3, x4, y4];
};
// TODO reuse shape.ts
const generateLinearElementShape = (
element: ExcalidrawLinearElement,
): Drawable => {
@@ -954,7 +955,7 @@ const getLinearElementRotatedBounds = (
}
// first element is always the curve
const cachedShape = ShapeCache.get(element)?.[0];
const cachedShape = ShapeCache.get(element, null)?.[0];
const shape = cachedShape ?? generateLinearElementShape(element);
const ops = getCurvePathOps(shape);
const transformXY = ([x, y]: GlobalPoint) =>
+91 -5
View File
@@ -59,8 +59,11 @@ import { LinearElementEditor } from "./linearElementEditor";
import { distanceToElement } from "./distance";
import { getBindingGap } from "./binding";
import type {
ElementsMap,
ExcalidrawArrowElement,
ExcalidrawBindableElement,
ExcalidrawDiamondElement,
ExcalidrawElement,
@@ -105,6 +108,12 @@ export type HitTestArgs = {
overrideShouldTestInside?: boolean;
};
let cachedPoint: GlobalPoint | null = null;
let cachedElement: WeakRef<ExcalidrawElement> | null = null;
let cachedThreshold: number = Infinity;
let cachedHit: boolean = false;
let cachedOverrideShouldTestInside = false;
export const hitElementItself = ({
point,
element,
@@ -113,6 +122,24 @@ export const hitElementItself = ({
frameNameBound = null,
overrideShouldTestInside = false,
}: HitTestArgs) => {
// Return cached result if the same point and element version is tested again
if (
cachedPoint &&
pointsEqual(point, cachedPoint) &&
cachedThreshold <= threshold &&
overrideShouldTestInside === cachedOverrideShouldTestInside
) {
const derefElement = cachedElement?.deref();
if (
derefElement &&
derefElement.id === element.id &&
derefElement.version === element.version &&
derefElement.versionNonce === element.versionNonce
) {
return cachedHit;
}
}
// Hit test against a frame's name
const hitFrameName = frameNameBound
? isPointWithinBounds(
@@ -153,7 +180,16 @@ export const hitElementItself = ({
isPointOnElementOutline(point, element, elementsMap, threshold)
: isPointOnElementOutline(point, element, elementsMap, threshold);
return hitElement || hitFrameName;
const result = hitElement || hitFrameName;
// Cache end result
cachedPoint = point;
cachedElement = new WeakRef(element);
cachedThreshold = threshold;
cachedOverrideShouldTestInside = overrideShouldTestInside;
cachedHit = result;
return result;
};
export const hitElementBoundingBox = (
@@ -257,7 +293,7 @@ export const getAllHoveredElementAtPoint = (
point: Readonly<GlobalPoint>,
elements: readonly Ordered<NonDeletedExcalidrawElement>[],
elementsMap: NonDeletedSceneElementsMap,
toleranceFn?: (element: ExcalidrawBindableElement) => number,
tolerance?: number,
): NonDeleted<ExcalidrawBindableElement>[] => {
const candidateElements: NonDeleted<ExcalidrawBindableElement>[] = [];
// We need to to hit testing from front (end of the array) to back (beginning of the array)
@@ -273,7 +309,7 @@ export const getAllHoveredElementAtPoint = (
if (
isBindableElement(element, false) &&
bindingBorderTest(element, point, elementsMap, toleranceFn?.(element))
bindingBorderTest(element, point, elementsMap, tolerance)
) {
candidateElements.push(element);
@@ -290,13 +326,13 @@ export const getHoveredElementForBinding = (
point: Readonly<GlobalPoint>,
elements: readonly Ordered<NonDeletedExcalidrawElement>[],
elementsMap: NonDeletedSceneElementsMap,
toleranceFn?: (element: ExcalidrawBindableElement) => number,
tolerance?: number,
): NonDeleted<ExcalidrawBindableElement> | null => {
const candidateElements = getAllHoveredElementAtPoint(
point,
elements,
elementsMap,
toleranceFn,
tolerance,
);
if (!candidateElements || candidateElements.length === 0) {
@@ -315,6 +351,56 @@ export const getHoveredElementForBinding = (
.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
*
+10 -10
View File
@@ -467,6 +467,7 @@ export class Delta<T> {
} else {
assertNever(
join,
// oxlint-disable-next-line typescript/restrict-template-expressions
`Unknown distinctKeysIterator's join param "${join}"`,
true,
);
@@ -860,6 +861,7 @@ export class AppStateDelta implements DeltaContainer<AppState> {
default:
assertNever(
key,
// oxlint-disable-next-line typescript/restrict-template-expressions
`Unknown ObservedElementsAppState's key "${key}"`,
true,
);
@@ -974,7 +976,7 @@ export class AppStateDelta implements DeltaContainer<AppState> {
inserted,
"selectedElementIds",
// ts language server has a bit trouble resolving this, so we are giving it a little push
(_) => true as ValueOf<T["selectedElementIds"]>,
() => true as ValueOf<T["selectedElementIds"]>,
);
Delta.diffObjects(
deleted,
@@ -995,9 +997,8 @@ export class AppStateDelta implements DeltaContainer<AppState> {
if (isTestEnv() || isDevEnv()) {
throw e;
}
} finally {
return [deleted, inserted];
}
return [deleted, inserted];
}
private static orderAppStateKeys(partial: Partial<ObservedAppState>) {
@@ -1333,6 +1334,7 @@ export class ElementsDelta implements DeltaContainer<SceneElementsMap> {
for (const key of Object.keys(partial) as Array<keyof typeof partial>) {
// do not update following props:
// - `boundElements`, as it is a reference value which is postprocessed to contain only deleted/inserted keys
// oxlint-disable-next-line typescript/switch-exhaustiveness-check
switch (key) {
case "boundElements":
latestPartial[key] = partial[key];
@@ -1459,9 +1461,8 @@ export class ElementsDelta implements DeltaContainer<SceneElementsMap> {
if (isTestEnv() || isDevEnv()) {
throw e;
}
} finally {
return [nextElements, flags.containsVisibleDifference];
}
return [nextElements, flags.containsVisibleDifference];
}
public squash(delta: ElementsDelta): this {
@@ -1802,7 +1803,7 @@ export class ElementsDelta implements DeltaContainer<SceneElementsMap> {
// updated delta is affecting the binding only in case it contains changed binding or bindable property
for (const [id] of Array.from(Object.entries(this.updated)).filter(
([_, delta]) =>
([, delta]) =>
Object.keys({ ...delta.deleted, ...delta.inserted }).find((prop) =>
bindingProperties.has(prop as BindingProp | BindableProp),
),
@@ -1908,9 +1909,8 @@ export class ElementsDelta implements DeltaContainer<SceneElementsMap> {
if (isTestEnv() || isDevEnv()) {
throw e;
}
} finally {
return nextElements;
}
return nextElements;
}
private static redrawTextBoundingBoxes(
@@ -2051,9 +2051,9 @@ export class ElementsDelta implements DeltaContainer<SceneElementsMap> {
if (isTestEnv() || isDevEnv()) {
throw e;
}
} finally {
return [deleted, inserted];
}
return [deleted, inserted];
}
private static stripIrrelevantProps(
+20 -9
View File
@@ -1,10 +1,12 @@
import type { AppState } from "@excalidraw/excalidraw/types";
import { updateBoundElements } from "./binding";
import { getCommonBoundingBox } from "./bounds";
import { newElementWith } from "./mutateElement";
import { getSelectedElementsByGroup } from "./groups";
import type { Scene } from "./Scene";
import type { ElementsMap, ExcalidrawElement } from "./types";
export interface Distribution {
@@ -17,6 +19,7 @@ export const distributeElements = (
elementsMap: ElementsMap,
distribution: Distribution,
appState: Readonly<AppState>,
scene: Scene,
): ExcalidrawElement[] => {
const [start, mid, end, extent] =
distribution.axis === "x"
@@ -66,12 +69,16 @@ export const distributeElements = (
translation[distribution.axis] = pos - box[mid];
}
return group.map((element) =>
newElementWith(element, {
return group.map((element) => {
const updatedElement = scene.mutateElement(element, {
x: element.x + translation.x,
y: element.y + translation.y,
}),
);
});
updateBoundElements(element, scene, {
simultaneouslyUpdated: group,
});
return updatedElement;
});
});
}
@@ -90,11 +97,15 @@ export const distributeElements = (
pos += step;
pos += box[extent];
return group.map((element) =>
newElementWith(element, {
return group.map((element) => {
const updatedElement = scene.mutateElement(element, {
x: element.x + translation.x,
y: element.y + translation.y,
}),
);
});
updateBoundElements(element, scene, {
simultaneouslyUpdated: group,
});
return updatedElement;
});
});
};
+37 -32
View File
@@ -255,11 +255,12 @@ const handleSegmentRenormalization = (
);
}
isDevEnv() &&
if (isDevEnv()) {
invariant(
validateElbowPoints(nextPoints),
"Invalid elbow points with fixed segments",
);
}
return normalizeArrowElementUpdate(
nextPoints,
@@ -521,8 +522,8 @@ const handleSegmentMove = (
? segmentLength / 2
: BASE_PADDING
: segmentIsTooShort
? -segmentLength / 2
: -BASE_PADDING;
? -segmentLength / 2
: -BASE_PADDING;
fixedSegments[activelyModifiedSegmentIdx].start = pointFrom<LocalPoint>(
fixedSegments[activelyModifiedSegmentIdx].start[0] +
(startIsHorizontal ? padding : 0),
@@ -547,8 +548,8 @@ const handleSegmentMove = (
? segmentLength / 2
: BASE_PADDING
: segmentIsTooShort
? -segmentLength / 2
: -BASE_PADDING;
? -segmentLength / 2
: -BASE_PADDING;
fixedSegments[activelyModifiedSegmentIdx].end = pointFrom<LocalPoint>(
fixedSegments[activelyModifiedSegmentIdx].end[0] +
(endIsHorizontal ? padding : 0),
@@ -571,7 +572,7 @@ const handleSegmentMove = (
}));
// For start, clone old arrow points
const newPoints: GlobalPoint[] = arrow.points.map((p, i) =>
const newPoints: GlobalPoint[] = arrow.points.map((p) =>
pointFrom<GlobalPoint>(arrow.x + p[0], arrow.y + p[1]),
);
@@ -720,11 +721,11 @@ const handleEndpointDrag = (
i === 0
? pointFrom<GlobalPoint>(arrow.x + p[0], arrow.y + p[1])
: i === updatedPoints.length - 1
? pointFrom<GlobalPoint>(arrow.x + p[0], arrow.y + p[1])
: pointFrom<GlobalPoint>(
arrow.x + arrow.points[i][0],
arrow.y + arrow.points[i][1],
),
? pointFrom<GlobalPoint>(arrow.x + p[0], arrow.y + p[1])
: pointFrom<GlobalPoint>(
arrow.x + arrow.points[i][0],
arrow.y + arrow.points[i][1],
),
);
const nextFixedSegments = fixedSegments.map((segment) => ({
...segment,
@@ -982,8 +983,8 @@ export const updateElbowArrowPoints = (
idx === 0
? updates.points![0]
: idx === arrow.points.length - 1
? updates.points![1]
: p,
? updates.points![1]
: p,
)
: updates.points.slice()
: arrow.points.slice();
@@ -1489,8 +1490,12 @@ const routeElbowArrow = (
node.pos[0],
node.pos[1],
]) as GlobalPoint[];
startDongle && points.unshift(startGlobalPoint);
endDongle && points.push(endGlobalPoint);
if (startDongle) {
points.unshift(startGlobalPoint);
}
if (endDongle) {
points.push(endGlobalPoint);
}
return points;
}
@@ -1680,29 +1685,29 @@ const generateDynamicAABBs = (
? Math.min((startEl[0] + endEl[2]) / 2, a[0] - startLeft)
: (startEl[0] + endEl[2]) / 2
: a[0] > b[0]
? a[0] - startLeft
: common[0] - startLeft,
? a[0] - startLeft
: common[0] - startLeft,
a[1] > b[3]
? a[0] > b[2] || a[2] < b[0]
? Math.min((startEl[1] + endEl[3]) / 2, a[1] - startUp)
: (startEl[1] + endEl[3]) / 2
: a[1] > b[1]
? a[1] - startUp
: common[1] - startUp,
? a[1] - startUp
: common[1] - startUp,
a[2] < b[0]
? a[1] > b[3] || a[3] < b[1]
? Math.max((startEl[2] + endEl[0]) / 2, a[2] + startRight)
: (startEl[2] + endEl[0]) / 2
: a[2] < b[2]
? a[2] + startRight
: common[2] + startRight,
? a[2] + startRight
: common[2] + startRight,
a[3] < b[1]
? a[0] > b[2] || a[2] < b[0]
? Math.max((startEl[3] + endEl[1]) / 2, a[3] + startDown)
: (startEl[3] + endEl[1]) / 2
: a[3] < b[3]
? a[3] + startDown
: common[3] + startDown,
? a[3] + startDown
: common[3] + startDown,
] as Bounds;
const second = [
b[0] > a[2]
@@ -1710,29 +1715,29 @@ const generateDynamicAABBs = (
? Math.min((endEl[0] + startEl[2]) / 2, b[0] - endLeft)
: (endEl[0] + startEl[2]) / 2
: b[0] > a[0]
? b[0] - endLeft
: common[0] - endLeft,
? b[0] - endLeft
: common[0] - endLeft,
b[1] > a[3]
? b[0] > a[2] || b[2] < a[0]
? Math.min((endEl[1] + startEl[3]) / 2, b[1] - endUp)
: (endEl[1] + startEl[3]) / 2
: b[1] > a[1]
? b[1] - endUp
: common[1] - endUp,
? b[1] - endUp
: common[1] - endUp,
b[2] < a[0]
? b[1] > a[3] || b[3] < a[1]
? Math.max((endEl[2] + startEl[0]) / 2, b[2] + endRight)
: (endEl[2] + startEl[0]) / 2
: b[2] < a[2]
? b[2] + endRight
: common[2] + endRight,
? b[2] + endRight
: common[2] + endRight,
b[3] < a[1]
? b[0] > a[2] || b[2] < a[0]
? Math.max((endEl[3] + startEl[1]) / 2, b[3] + endDown)
: (endEl[3] + startEl[1]) / 2
: b[3] < a[3]
? b[3] + endDown
: common[3] + endDown,
? b[3] + endDown
: common[3] + endDown,
] as Bounds;
const c = commonAABB([first, second]);
@@ -2276,7 +2281,7 @@ const getHoveredElement = (
origPoint,
elements,
elementsMap,
(element) => maxBindingDistance_simple(zoom),
maxBindingDistance_simple(zoom),
);
};
+2 -2
View File
@@ -13,7 +13,7 @@ import type { ExcalidrawElement } from "./types";
export const defaultGetElementLinkFromSelection: Exclude<
AppProps["generateLinkForSelection"],
undefined
> = (id, type) => {
> = (id) => {
const url = window.location.href;
try {
@@ -86,7 +86,7 @@ export const isElementLink = (url: string) => {
_url.searchParams.has(ELEMENT_LINK_KEY) &&
_url.host === window.location.host
);
} catch (error) {
} catch {
return false;
}
};
+81 -4
View File
@@ -56,14 +56,14 @@ const RE_REDDIT =
const RE_REDDIT_EMBED =
/^<blockquote[\s\S]*?\shref=["'](https?:\/\/(?:www\.)?reddit\.com\/[^"']*)/i;
const parseYouTubeTimestamp = (url: string): number => {
const parseYouTubeLikeTimestamp = (url: string): number => {
let timeParam: string | null | undefined;
try {
const urlObj = new URL(url.startsWith("http") ? url : `https://${url}`);
timeParam =
urlObj.searchParams.get("t") || urlObj.searchParams.get("start");
} catch (error) {
} catch {
const timeMatch = url.match(/[?&#](?:t|start)=([^&#\s]+)/);
timeParam = timeMatch?.[1];
}
@@ -85,11 +85,57 @@ const parseYouTubeTimestamp = (url: string): number => {
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 {
return null;
}
};
const ALLOWED_DOMAINS = new Set([
"youtube.com",
"youtu.be",
"vimeo.com",
"player.vimeo.com",
"drive.google.com",
"figma.com",
"link.excalidraw.com",
"gist.github.com",
@@ -108,6 +154,7 @@ const ALLOW_SAME_ORIGIN = new Set([
"youtu.be",
"vimeo.com",
"player.vimeo.com",
"drive.google.com",
"figma.com",
"twitter.com",
"x.com",
@@ -142,7 +189,7 @@ export const getEmbedLink = (
let aspectRatio = { w: 560, h: 840 };
const ytLink = link.match(RE_YOUTUBE);
if (ytLink?.[2]) {
const startTime = parseYouTubeTimestamp(originalLink);
const startTime = parseYouTubeLikeTimestamp(originalLink);
const time = startTime > 0 ? `&start=${startTime}` : ``;
const isPortrait = link.includes("shorts");
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);
if (figmaLink) {
type = "generic";
@@ -388,7 +465,7 @@ const matchHostname = (
if (bareDomain === bareAllowedHostname) {
return bareAllowedHostname;
}
} catch (error) {
} catch {
// ignore
}
return null;
+4 -4
View File
@@ -198,8 +198,8 @@ const getOffsets = (
linkedNodes.length === 0
? 0
: (linkedNodes.length + 1) % 2 === 0
? ((linkedNodes.length + 1) / 2) * _HORIZONTAL_OFFSET
: (linkedNodes.length / 2) * _HORIZONTAL_OFFSET * -1;
? ((linkedNodes.length + 1) / 2) * _HORIZONTAL_OFFSET
: (linkedNodes.length / 2) * _HORIZONTAL_OFFSET * -1;
if (direction === "up") {
return {
@@ -222,8 +222,8 @@ const getOffsets = (
linkedNodes.length === 0
? 0
: (linkedNodes.length + 1) % 2 === 0
? ((linkedNodes.length + 1) / 2) * _VERTICAL_OFFSET
: (linkedNodes.length / 2) * _VERTICAL_OFFSET * -1;
? ((linkedNodes.length + 1) / 2) * _VERTICAL_OFFSET
: (linkedNodes.length / 2) * _VERTICAL_OFFSET * -1;
if (direction === "left") {
return {
+1 -1
View File
@@ -187,7 +187,7 @@ export const syncMovedIndices = (
for (const [element, { index }] of elementsUpdates) {
mutateElement(element, elementsMap, { index });
}
} catch (e) {
} catch {
// fallback to default sync
syncInvalidIndices(elements);
}
+1 -1
View File
@@ -797,7 +797,7 @@ export const isElementInFrame = (
for (const gid of _element.groupIds) {
if (opts?.checkedGroups?.has(gid)) {
return opts.checkedGroups.get(gid)!!;
return opts.checkedGroups.get(gid)!;
}
}

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