Compare commits

...

55 Commits

Author SHA1 Message Date
dwelle 4060682c57 dedupe & handle embeddables 2026-02-18 22:57:55 +01:00
Márk Tolmács 5852d0d410 fix: Arrow overlap arrow behavior (#10732)
* fix(arrow): Overlap arrow behavior

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

* fix: Lint

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

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

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

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

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

* go back to 5px

* update tests

* feat: Simplified update bind points

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

* fix: Remove non-needed export

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

* fix. Possessed arrows #1

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

* fix: Focus point projection stabilization

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

* fix: Remove arrow stability hack

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

* fix: Unbound other endpoint

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

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

* feat: Force exact center focus point

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

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

* fix: Tests

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

* fix: Snap to center around side mid point.

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

* Trigger CI

* fix: Midpoint outline focus point

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

* fix: Tests

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

* fix: Dragging existing arrow reset focus point on outline

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

* fix: Tests

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

* feat: Midpoint indicator

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

* fix: Rotated mid points

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

* fix: No hole

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

* feat: Cache hits and scene lookups

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

* chore: Remove debug

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

* fix: Consider hit threshold and inside override too

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

* fix: Increase outline midpoint sticky distance

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

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

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

* feat: Indicate lock-in

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

* chore: Remove Map caching

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

* fix: incorrect threshold

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

* fix: threshold setting

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

* fix: Hit caching

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

* fix: Simple arrow mid point selection inconsistency

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

* fix: cache override

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

* fix: Precise know dragging with midpoint refactor

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

* fear: Frame support

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

* fix: Crossing arrow won't trigger mid point

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

* fix: Arrow creation point highlight

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

* fix: Restore types & tests

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

* chore: Restore restore.ts

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

* fix: restore.ts

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

* fix: Elbow arrows reliably highlight center point

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

* fix: Highlight point ordering

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

* feat: Bind with focus point across shape

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

* fix: Lint

* fix: Midpoint and binding alignment

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

* chore: Indicator color

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

* chore: More knob tuning

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

* fix: Radius

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

* fix: Tests

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

* simplify point indicators

---------

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

* fix: Tests

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

* fix: Snapshots

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

* fix: Target point selection

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

* chore: Remove non-needed change

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

* chore: Try again removing non-needed modification

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

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

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

* fix: Area based edge case

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

* fix: Overlapping new arrow jump

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

---------

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

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

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

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

* fix: Tests

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

* fix: Snap to center around side mid point.

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

* Trigger CI

* fix: Midpoint outline focus point

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

* fix: Tests

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

* fix: Dragging existing arrow reset focus point on outline

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

* fix: Tests

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

* feat: Midpoint indicator

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

* fix: Rotated mid points

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

* fix: No hole

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

* feat: Cache hits and scene lookups

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

* chore: Remove debug

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

* fix: Consider hit threshold and inside override too

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

* fix: Increase outline midpoint sticky distance

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

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

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

* feat: Indicate lock-in

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

* chore: Remove Map caching

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

* fix: incorrect threshold

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

* fix: threshold setting

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

* fix: Hit caching

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

* fix: Simple arrow mid point selection inconsistency

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

* fix: cache override

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

* fix: Precise know dragging with midpoint refactor

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

* fear: Frame support

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

* fix: Crossing arrow won't trigger mid point

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

* fix: Arrow creation point highlight

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

* fix: Restore types & tests

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

* chore: Restore restore.ts

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

* fix: restore.ts

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

* fix: Elbow arrows reliably highlight center point

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

* fix: Highlight point ordering

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

* feat: Bind with focus point across shape

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

* fix: Lint

* fix: Midpoint and binding alignment

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

* chore: Indicator color

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

* chore: More knob tuning

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

* fix: Radius

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

* fix: Tests

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

* simplify point indicators

---------

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

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

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

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

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

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

* go back to 5px

* update tests

* chore: Refactor

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

* fix: Align focus points

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

---------

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

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

* fix: New arrow creation

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

* fix: Alt point setting on inside binding

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

* fix: Initial arrow creation with Alt

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

---------

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

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

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

* go back to 5px

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

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

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

* fix lint

* fix more lint

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

* New translations en.json (Vietnamese)

* New translations en.json (Russian)

* New translations en.json (Romanian)

* New translations en.json (French)

* New translations en.json (Spanish)

* New translations en.json (Arabic)

* New translations en.json (Bulgarian)

* New translations en.json (Catalan)

* New translations en.json (Czech)

* New translations en.json (Danish)

* New translations en.json (German)

* New translations en.json (Greek)

* New translations en.json (Basque)

* New translations en.json (Finnish)

* New translations en.json (Hebrew)

* New translations en.json (Hungarian)

* New translations en.json (Italian)

* New translations en.json (Japanese)

* New translations en.json (Korean)

* New translations en.json (Kurdish)

* New translations en.json (Lithuanian)

* New translations en.json (Dutch)

* New translations en.json (Punjabi)

* New translations en.json (Polish)

* New translations en.json (Portuguese)

* New translations en.json (Slovak)

* New translations en.json (Slovenian)

* New translations en.json (Swedish)

* New translations en.json (Turkish)

* New translations en.json (Ukrainian)

* New translations en.json (Chinese Simplified)

* New translations en.json (Chinese Traditional)

* New translations en.json (Galician)

* New translations en.json (Portuguese, Brazilian)

* New translations en.json (Indonesian)

* New translations en.json (Persian)

* New translations en.json (Khmer)

* New translations en.json (Tamil)

* New translations en.json (Bengali)

* New translations en.json (Marathi)

* New translations en.json (Thai)

* New translations en.json (Norwegian Nynorsk)

* New translations en.json (Kazakh)

* New translations en.json (Latvian)

* New translations en.json (Azerbaijani)

* New translations en.json (Hindi)

* New translations en.json (Burmese)

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

* New translations en.json (Sinhala)

* New translations en.json (Uzbek)

* New translations en.json (Norwegian Bokmal)

* New translations en.json (Occitan)

* New translations en.json (German, Switzerland)

* New translations en.json (Bengali, India)

* New translations en.json (Kabyle)

* New translations en.json (Karakalpak)

* Auto commit: Calculate translation coverage

* New translations en.json (Russian)

* Auto commit: Calculate translation coverage

* New translations en.json (Romanian)

* Auto commit: Calculate translation coverage

* New translations en.json (Italian)

* Auto commit: Calculate translation coverage

* New translations en.json (Italian)

* Auto commit: Calculate translation coverage

* New translations en.json (Hungarian)

* New translations en.json (Hungarian)

* New translations en.json (Hindi)

* New translations en.json (Dutch)

* New translations en.json (Dutch)

* New translations en.json (Vietnamese)

* New translations en.json (Russian)

* New translations en.json (Romanian)

* New translations en.json (French)

* New translations en.json (Spanish)

* New translations en.json (Arabic)

* New translations en.json (Bulgarian)

* New translations en.json (Catalan)

* New translations en.json (Czech)

* New translations en.json (Danish)

* New translations en.json (German)

* New translations en.json (Greek)

* New translations en.json (Basque)

* New translations en.json (Finnish)

* New translations en.json (Hebrew)

* New translations en.json (Hungarian)

* New translations en.json (Italian)

* New translations en.json (Japanese)

* New translations en.json (Korean)

* New translations en.json (Kurdish)

* New translations en.json (Lithuanian)

* New translations en.json (Dutch)

* New translations en.json (Punjabi)

* New translations en.json (Polish)

* New translations en.json (Portuguese)

* New translations en.json (Slovak)

* New translations en.json (Slovenian)

* New translations en.json (Swedish)

* New translations en.json (Turkish)

* New translations en.json (Ukrainian)

* New translations en.json (Chinese Simplified)

* New translations en.json (Chinese Traditional)

* New translations en.json (Galician)

* New translations en.json (Portuguese, Brazilian)

* New translations en.json (Indonesian)

* New translations en.json (Persian)

* New translations en.json (Khmer)

* New translations en.json (Tamil)

* New translations en.json (Bengali)

* New translations en.json (Marathi)

* New translations en.json (Thai)

* New translations en.json (Norwegian Nynorsk)

* New translations en.json (Kazakh)

* New translations en.json (Latvian)

* New translations en.json (Azerbaijani)

* New translations en.json (Hindi)

* New translations en.json (Burmese)

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

* New translations en.json (Sinhala)

* New translations en.json (Uzbek)

* New translations en.json (Norwegian Bokmal)

* New translations en.json (Occitan)

* New translations en.json (German, Switzerland)

* New translations en.json (Bengali, India)

* New translations en.json (Kabyle)

* New translations en.json (Karakalpak)

* New translations en.json (Romanian)

* New translations en.json (Vietnamese)

* New translations en.json (Russian)

* New translations en.json (Romanian)

* New translations en.json (French)

* New translations en.json (Spanish)

* New translations en.json (Arabic)

* New translations en.json (Bulgarian)

* New translations en.json (Catalan)

* New translations en.json (Czech)

* New translations en.json (Danish)

* New translations en.json (German)

* New translations en.json (Greek)

* New translations en.json (Basque)

* New translations en.json (Finnish)

* New translations en.json (Hebrew)

* New translations en.json (Hungarian)

* New translations en.json (Italian)

* New translations en.json (Japanese)

* New translations en.json (Korean)

* New translations en.json (Kurdish)

* New translations en.json (Lithuanian)

* New translations en.json (Dutch)

* New translations en.json (Punjabi)

* New translations en.json (Polish)

* New translations en.json (Portuguese)

* New translations en.json (Slovak)

* New translations en.json (Slovenian)

* New translations en.json (Swedish)

* New translations en.json (Turkish)

* New translations en.json (Ukrainian)

* New translations en.json (Chinese Simplified)

* New translations en.json (Chinese Traditional)

* New translations en.json (Galician)

* New translations en.json (Portuguese, Brazilian)

* New translations en.json (Indonesian)

* New translations en.json (Persian)

* New translations en.json (Khmer)

* New translations en.json (Tamil)

* New translations en.json (Bengali)

* New translations en.json (Marathi)

* New translations en.json (Thai)

* New translations en.json (Norwegian Nynorsk)

* New translations en.json (Kazakh)

* New translations en.json (Latvian)

* New translations en.json (Azerbaijani)

* New translations en.json (Hindi)

* New translations en.json (Burmese)

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

* New translations en.json (Sinhala)

* New translations en.json (Uzbek)

* New translations en.json (Norwegian Bokmal)

* New translations en.json (Occitan)

* New translations en.json (German, Switzerland)

* New translations en.json (Bengali, India)

* New translations en.json (Kabyle)

* New translations en.json (Karakalpak)

* New translations en.json (Romanian)

* New translations en.json (Italian)

* New translations en.json (Russian)

* New translations en.json (Romanian)

* New translations en.json (French)

* New translations en.json (Spanish)

* New translations en.json (Arabic)

* New translations en.json (Bulgarian)

* New translations en.json (Catalan)

* New translations en.json (Czech)

* New translations en.json (Danish)

* New translations en.json (German)

* New translations en.json (Greek)

* New translations en.json (Basque)

* New translations en.json (Finnish)

* New translations en.json (Hebrew)

* New translations en.json (Hungarian)

* New translations en.json (Italian)

* New translations en.json (Japanese)

* New translations en.json (Korean)

* New translations en.json (Kurdish)

* New translations en.json (Lithuanian)

* New translations en.json (Dutch)

* New translations en.json (Punjabi)

* New translations en.json (Polish)

* New translations en.json (Portuguese)

* New translations en.json (Slovak)

* New translations en.json (Slovenian)

* New translations en.json (Swedish)

* New translations en.json (Vietnamese)

* New translations en.json (Turkish)

* New translations en.json (Ukrainian)

* New translations en.json (Chinese Simplified)

* New translations en.json (Chinese Traditional)

* New translations en.json (Galician)

* New translations en.json (Portuguese, Brazilian)

* New translations en.json (Indonesian)

* New translations en.json (Persian)

* New translations en.json (Khmer)

* New translations en.json (Tamil)

* New translations en.json (Bengali)

* New translations en.json (Marathi)

* New translations en.json (Thai)

* New translations en.json (Norwegian Nynorsk)

* New translations en.json (Kazakh)

* New translations en.json (Latvian)

* New translations en.json (Azerbaijani)

* New translations en.json (Hindi)

* New translations en.json (Burmese)

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

* New translations en.json (Sinhala)

* New translations en.json (Uzbek)

* New translations en.json (Norwegian Bokmal)

* New translations en.json (Occitan)

* New translations en.json (German, Switzerland)

* New translations en.json (Bengali, India)

* New translations en.json (Kabyle)

* New translations en.json (Karakalpak)

* New translations en.json (Romanian)

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

* css tweaks

* update snaps

---------

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

* lint

* Inversion to match theme filter

* cleanup

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

* revert inversion algo & handle darkMode placeholder

---------

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

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

* fix: Snapshot update

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

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

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

* fix: Move visualdebug to elements

Due to dep circles

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

* fix: Possible test timeout

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

* fix: Incorrect hit test point input

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

* feat: Add fallback when dragged outside of allowed area

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

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

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

* fix: End bound indirect fix

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

* fix: Show indicator when arrow endpoint dragging

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

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

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

* chore: Refactor

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

* fix: Curve endpoint intersection

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

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

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

* fix: Tests

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

* chore: Fix lint

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

* feat: Dragging focus point off

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

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

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

* fix: Drag area for focus handles

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

* fix: Focus point size unified

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

* fix: Size bump for focus knob

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

* feat: Cache hits and scene lookups

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

* chore: Remove debug

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

* fix: Consider hit threshold and inside override too

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

* fix: Other shape switching

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

* perf: Update tolerance params

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

* fix: Focus know line width

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

* fix: knob offset

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

* fix: Full overlap

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

* chore: Remove Map caching

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

* fix: incorrect threshold

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

* fix: threshold setting

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

* fix: Hit caching

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

* fix: cache override

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

* fix: Snapshots

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

* feat: Redesigned focus point handling

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

* fix: Inside-inside mode

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

* chore: Remove comment

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

* feat: Allow focus knob outside the shape

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

* fix: Arrow endpoint offset

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

* fix: Focus knob element distance

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

* fix: Increase iteration on curve intersection calc

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

* fix: Handle disabled binding

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

* fix: Alt mode

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

* fix: Nested shape focus rewrite

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

* fix: Alt + Ctrl + arrow endpoitn

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

* fix: Hit ordering for focus points

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

* fix: Focus point visibility

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

* dry out renderFocusPointIndicator

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

* optimize retrieval of selectedLinearElement

* move focus highlighting into renderFocusPointIndicator to DRY out and colocate

* remove `disabled` state from focus highlight

* make focus point stroke color less prominent

* fix: No focus point for multi-point arrows

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

* fix: Arrow edit mode drag focus point release

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

* DRY out arrow point-like drag

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

---------

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

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

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

* fix FF

---------

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

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

* chore: Remove debug

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

* fix: Consider hit threshold and inside override too

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

* chore: Remove Map caching

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

* fix: incorrect threshold

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

* fix: threshold setting

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

* fix: Hit caching

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

* fix: cache override

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

---------

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

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

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

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

---------

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

* lint

* removed separate getThemeFilterValue function from renderElement

* removed BinaryFileData changes

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

* Adjust opacity calculation for canvas rendering

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

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

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

* fix: More conservative condition

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

---------

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

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

* bump

* fix package version

* yarn.lock

* naming and clearer type

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

* fix: eslint issue

* debug mermaid bundle size

* tests: covering the utils

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

* fix: removing replay feature

* fix: removing unused prop

* fix: bumping workbox cache limit

* snapshots + yarn.lock

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

* bump node@20

* css tweaks

* move files around & rewrite stream chunk schema

* random naming & file structure refactor + some tweaks

* fix preview theme

* support custom warning renderer

* label and css fix

* fix and rwrite 429 handling

* fix label

---------

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

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

* fix: Move visualdebug to elements

Due to dep circles

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

---------

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

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

* fix debug

---------

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

* fix comment

* remove conditional dark mode export

* make shape cache theme-aware

* refactor

* refactor

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

* Auto commit: Calculate translation coverage

* New translations en.json (French)

* New translations en.json (Spanish)

* New translations en.json (Dutch)

* Auto commit: Calculate translation coverage

* New translations en.json (Dutch)

* Auto commit: Calculate translation coverage

* New translations en.json (Dutch)

* New translations en.json (Dutch)

* New translations en.json (Spanish)

* New translations en.json (Italian)

* Auto commit: Calculate translation coverage

* New translations en.json (Slovak)

* Auto commit: Calculate translation coverage

* New translations en.json (Slovak)

* Auto commit: Calculate translation coverage

* New translations en.json (Vietnamese)

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

* use uqr

---------

Co-authored-by: dwelle <5153846+dwelle@users.noreply.github.com>
2026-01-02 11:08:27 +01:00
288 changed files with 15854 additions and 3844 deletions
+2 -2
View File
@@ -12,7 +12,7 @@ VITE_APP_WS_SERVER_URL=http://localhost:3002
VITE_APP_PLUS_LP=https://plus.excalidraw.com
VITE_APP_PLUS_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
+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
+40 -8
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";
@@ -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) {
@@ -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,
});
}
+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",
+28 -11
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,
@@ -311,6 +313,7 @@ class Collab extends PureComponent<CollabProps, CollabState> {
saveCollabRoomToFirebase = async (
syncableElements: readonly SyncableExcalidrawElement[],
) => {
syncableElements = cloneJSON(syncableElements);
try {
const storedElements = await saveToFirebase(
this.portal,
@@ -579,7 +582,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 +598,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 +751,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
+17 -52
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,
}: {
@@ -99,61 +102,23 @@ export const AIComponents = ({
/>
<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}
@@ -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>
),
);
+51
View File
@@ -0,0 +1,51 @@
import { createStore, get, set } from "idb-keyval";
import type { SavedChats } from "@excalidraw/excalidraw/components/TTDDialog/types";
import { STORAGE_KEYS } from "../app_constants";
/**
* IndexedDB adapter for TTD chat storage.
* Implements TTDPersistenceAdapter interface.
*/
export class TTDIndexedDBAdapter {
/** IndexedDB database name */
private static idb_name = STORAGE_KEYS.IDB_TTD_CHATS;
/** Store key for chat data */
private static key = "ttdChats";
private static store = createStore(
`${TTDIndexedDBAdapter.idb_name}-db`,
`${TTDIndexedDBAdapter.idb_name}-store`,
);
/**
* Load saved chats from IndexedDB.
* @returns Promise resolving to saved chats array (empty if none found)
*/
static async loadChats(): Promise<SavedChats> {
try {
const data = await get<SavedChats>(
TTDIndexedDBAdapter.key,
TTDIndexedDBAdapter.store,
);
return data || [];
} catch (error) {
console.warn("Failed to load TTD chats from IndexedDB:", error);
return [];
}
}
/**
* Save chats to IndexedDB.
* @param chats - The chats array to persist
*/
static async saveChats(chats: SavedChats): Promise<void> {
try {
await set(TTDIndexedDBAdapter.key, chats, TTDIndexedDBAdapter.store);
} catch (error) {
console.warn("Failed to save TTD chats to IndexedDB:", error);
throw error;
}
}
}
+2 -2
View File
@@ -1,5 +1,5 @@
import { reconcileElements } from "@excalidraw/excalidraw";
import { MIME_TYPES } from "@excalidraw/common";
import { MIME_TYPES, toBrandedType } from "@excalidraw/common";
import { decompressData } from "@excalidraw/excalidraw/data/encode";
import {
encryptData,
@@ -243,7 +243,7 @@ export const saveToFirebase = async (
FirebaseSceneVersionCache.set(socket, storedElements);
return storedElements;
return toBrandedType<RemoteExcalidrawElement[]>(storedElements);
};
export const loadFromFirebase = async (
+3 -43
View File
@@ -8,7 +8,6 @@ import {
IV_LENGTH_BYTES,
} from "@excalidraw/excalidraw/data/encryption";
import { serializeAsJSON } from "@excalidraw/excalidraw/data/json";
import { restore } from "@excalidraw/excalidraw/data/restore";
import { isInvisiblySmallElement } from "@excalidraw/element";
import { isInitializedImageElement } from "@excalidraw/element";
import { t } from "@excalidraw/excalidraw/i18n";
@@ -84,13 +83,13 @@ export type SocketUpdateDataSource = {
SCENE_INIT: {
type: WS_SUBTYPES.INIT;
payload: {
elements: readonly ExcalidrawElement[];
elements: readonly OrderedExcalidrawElement[];
};
};
SCENE_UPDATE: {
type: WS_SUBTYPES.UPDATE;
payload: {
elements: readonly ExcalidrawElement[];
elements: readonly OrderedExcalidrawElement[];
};
};
MOUSE_LOCATION: {
@@ -200,7 +199,7 @@ const legacy_decodeFromBackend = async ({
};
};
const importFromBackend = async (
export const importFromBackend = async (
id: string,
decryptionKey: string,
): Promise<ImportedDataState> => {
@@ -242,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 };
+51 -24
View File
@@ -10,8 +10,6 @@ const lessPrecise = (num: number, precision = 5) =>
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 +22,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 +64,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 +85,24 @@ export class Debug {
}
}
}
Debug.FRAME_COUNT = 0;
// Check for stop condition after logging
if (
Date.now() - Debug.LAST_DEBUG_LOG_CALL > 600 &&
Debug.DEBUG_LOG_INTERVAL_ID !== null
) {
console.info("%c(stopping perf recording)", "color: red");
window.clearInterval(Debug.DEBUG_LOG_INTERVAL_ID);
window.cancelAnimationFrame(Debug.ANIMATION_FRAME_ID!);
Debug.ANIMATION_FRAME_ID = null;
Debug.FRAME_COUNT = 0;
Debug.LAST_FRAME_TIMESTAMP = 0;
Debug.DEBUG_LOG_INTERVAL_ID = null;
Debug.TIMES_AGGR = {};
Debug.TIMES_AVG = {};
}
};
public static logTime = (time?: number, name = "default") => {
@@ -109,7 +136,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;
};
};
+1
View File
@@ -36,6 +36,7 @@
"react": "19.0.0",
"react-dom": "19.0.0",
"socket.io-client": "4.7.2",
"uqr": "0.1.2",
"vite-plugin-html": "3.2.2"
},
"prettier": "@excalidraw/prettier-config",
+56
View File
@@ -0,0 +1,56 @@
import { useEffect, useState } from "react";
import Spinner from "@excalidraw/excalidraw/components/Spinner";
interface QRCodeProps {
value: string;
}
export const QRCode = ({ value }: QRCodeProps) => {
const [svgData, setSvgData] = useState<string | null>(null);
const [error, setError] = useState<boolean>(false);
useEffect(() => {
let mounted = true;
import("./qrcode.chunk")
.then(({ generateQRCodeSVG }) => {
if (mounted) {
try {
setSvgData(generateQRCodeSVG(value));
} catch {
setError(true);
}
}
})
.catch(() => {
if (mounted) {
setError(true);
}
});
return () => {
mounted = false;
};
}, [value]);
if (error) {
return null;
}
if (!svgData) {
return (
<div className="ShareDialog__active__qrcode ShareDialog__active__qrcode--loading">
<Spinner />
</div>
);
}
return (
<div
className="ShareDialog__active__qrcode"
role="img"
aria-label="QR code for collaboration link"
dangerouslySetInnerHTML={{ __html: svgData }}
/>
);
};
+25
View File
@@ -140,6 +140,31 @@
gap: 0.75rem;
}
&__qrcode {
display: flex;
justify-content: center;
align-items: center;
align-self: center;
padding: 1rem;
background: #fff;
border-radius: 0.5rem;
border: 1px solid #e0e0e0;
$size: 150px;
width: $size;
height: $size;
& svg {
width: $size;
height: $size;
}
&--loading {
background: var(--island-bg-color);
border: 1px solid var(--dialog-border-color);
}
}
&__description {
border-top: 1px solid var(--color-gray-20);
+2
View File
@@ -22,6 +22,7 @@ import { atom, useAtom, useAtomValue } from "../app-jotai";
import { activeRoomLinkAtom } from "../collab/Collab";
import "./ShareDialog.scss";
import { QRCode } from "./QRCode";
import type { CollabAPI } from "../collab/Collab";
@@ -142,6 +143,7 @@ const ActiveRoomDialog = ({
}}
/>
</div>
<QRCode value={activeRoomLink} />
<div className="ShareDialog__active__description">
<p>
<span
+5
View File
@@ -0,0 +1,5 @@
import { renderSVG } from "uqr";
export const generateQRCodeSVG = (text: string): string => {
return renderSVG(text);
};
@@ -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"
+5
View File
@@ -102,6 +102,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";
}
},
},
},
@@ -196,6 +200,7 @@ export default defineConfig(({ mode }) => {
},
},
],
maximumFileSizeToCacheInBytes: 2.3 * 1024 ** 2, // 2.3MB
},
manifest: {
short_name: "Excalidraw",
+6
View File
@@ -55,5 +55,11 @@
"scripts": {
"gen:types": "rimraf types && tsc",
"build:esm": "rimraf dist && node ../../scripts/buildBase.js && yarn gen:types"
},
"dependencies": {
"tinycolor2": "1.6.0"
},
"devDependencies": {
"@types/tinycolor2": "1.4.6"
}
}
@@ -0,0 +1,185 @@
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
exports[`COLOR_PALETTE > color palette doesn't regress 1`] = `
{
"black": "#1e1e1e",
"blue": [
"#e7f5ff",
"#a5d8ff",
"#4dabf7",
"#228be6",
"#1971c2",
],
"bronze": [
"#f8f1ee",
"#eaddd7",
"#d2bab0",
"#a18072",
"#846358",
],
"cyan": [
"#e3fafc",
"#99e9f2",
"#3bc9db",
"#15aabf",
"#0c8599",
],
"grape": [
"#f8f0fc",
"#eebefa",
"#da77f2",
"#be4bdb",
"#9c36b5",
],
"gray": [
"#f8f9fa",
"#e9ecef",
"#ced4da",
"#868e96",
"#343a40",
],
"green": [
"#ebfbee",
"#b2f2bb",
"#69db7c",
"#40c057",
"#2f9e44",
],
"orange": [
"#fff4e6",
"#ffd8a8",
"#ffa94d",
"#fd7e14",
"#e8590c",
],
"pink": [
"#fff0f6",
"#fcc2d7",
"#f783ac",
"#e64980",
"#c2255c",
],
"red": [
"#fff5f5",
"#ffc9c9",
"#ff8787",
"#fa5252",
"#e03131",
],
"teal": [
"#e6fcf5",
"#96f2d7",
"#38d9a9",
"#12b886",
"#099268",
],
"transparent": "transparent",
"violet": [
"#f3f0ff",
"#d0bfff",
"#9775fa",
"#7950f2",
"#6741d9",
],
"white": "#ffffff",
"yellow": [
"#fff9db",
"#ffec99",
"#ffd43b",
"#fab005",
"#f08c00",
],
}
`;
exports[`applyDarkModeFilter > COLOR_PALETTE regression tests > matches snapshot for all palette colors 1`] = `
{
"black": "#d3d3d3",
"blue": [
"#121e26",
"#154162",
"#2273b4",
"#3791e0",
"#56a2e8",
],
"bronze": [
"#221c1a",
"#362b26",
"#5a463d",
"#917569",
"#a98d84",
],
"cyan": [
"#0a1e20",
"#004149",
"#007281",
"#0f8fa1",
"#3da5b6",
],
"grape": [
"#211a25",
"#5b3165",
"#a954be",
"#d471ed",
"#e28af8",
],
"gray": [
"#161718",
"#202325",
"#33383d",
"#6e757c",
"#b7bcc1",
],
"green": [
"#0f1d12",
"#043b0c",
"#056715",
"#16842a",
"#39994b",
],
"orange": [
"#22190d",
"#4c2b01",
"#924800",
"#cd6005",
"#f17634",
],
"pink": [
"#26191e",
"#602e40",
"#b04d70",
"#f56e9d",
"#ff8dbc",
],
"red": [
"#1f1717",
"#5a2c2c",
"#b44d4d",
"#fa6969",
"#ff8383",
],
"teal": [
"#0a1d17",
"#00422b",
"#00744b",
"#039267",
"#32a783",
],
"transparent": "#ededed00",
"violet": [
"#1f1c29",
"#4a3b72",
"#8a6cdf",
"#a885ff",
"#b595ff",
],
"white": "#121212",
"yellow": [
"#1e1900",
"#362600",
"#5f3a00",
"#905000",
"#b86200",
],
}
`;
+286
View File
@@ -0,0 +1,286 @@
import {
applyDarkModeFilter,
COLOR_PALETTE,
rgbToHex,
} from "@excalidraw/common";
describe("COLOR_PALETTE", () => {
it("color palette doesn't regress", () => {
expect(COLOR_PALETTE).toMatchSnapshot();
});
});
describe("applyDarkModeFilter", () => {
describe("basic transformations", () => {
it("transforms black to near-white", () => {
const result = applyDarkModeFilter("#000000");
// Black inverted 93% + hue rotate should be near white/light gray
expect(result).toBe("#ededed");
});
it("transforms white to near-black", () => {
const result = applyDarkModeFilter("#ffffff");
// White inverted 93% should be near black/dark gray
expect(result).toBe("#121212");
});
it("transforms pure red", () => {
const result = applyDarkModeFilter("#ff0000");
// Invert 93% + hue rotate 180deg produces a cyan-ish tint
expect(result).toBe("#ff9090");
});
it("transforms pure green", () => {
const result = applyDarkModeFilter("#00ff00");
// Invert 93% + hue rotate 180deg
expect(result).toBe("#008f00");
});
it("transforms pure blue", () => {
const result = applyDarkModeFilter("#0000ff");
// Invert 93% + hue rotate 180deg produces a light purple
expect(result).toBe("#cdcdff");
});
});
describe("color formats", () => {
it("handles hex with hash", () => {
const result = applyDarkModeFilter("#ff0000");
// Fully opaque colors return 6-char hex
expect(result).toMatch(/^#[0-9a-f]{6}$/);
});
it("handles named colors", () => {
const result = applyDarkModeFilter("red");
// "red" = #ff0000, fully opaque
expect(result).toBe("#ff9090");
});
it("handles rgb format", () => {
const result = applyDarkModeFilter("rgb(255, 0, 0)");
expect(result).toBe("#ff9090");
});
it("handles rgba format and preserves alpha", () => {
const result = applyDarkModeFilter("rgba(255, 0, 0, 0.5)");
expect(result).toMatch(/^#[0-9a-f]{8}$/);
// Alpha 0.5 = 128 in hex = 80
expect(result).toBe("#ff909080");
});
it("handles transparent", () => {
const result = applyDarkModeFilter("transparent");
// transparent = rgba(0,0,0,0), inverted should still have 0 alpha
expect(result).toBe("#ededed00");
});
it("handles shorthand hex", () => {
const result = applyDarkModeFilter("#f00");
expect(result).toBe("#ff9090");
});
});
describe("alpha preservation", () => {
it("omits alpha for full opacity", () => {
const result = applyDarkModeFilter("#ff0000ff");
// Full opacity returns 6-char hex (no alpha suffix)
expect(result).toBe("#ff9090");
});
it("preserves 50% opacity", () => {
const result = applyDarkModeFilter("#ff000080");
expect(result.slice(-2)).toBe("80");
});
it("preserves 0% opacity", () => {
const result = applyDarkModeFilter("#ff000000");
expect(result.slice(-2)).toBe("00");
});
});
describe("COLOR_PALETTE regression tests", () => {
it("transforms black from palette", () => {
// COLOR_PALETTE.black is #1e1e1e (not pure black)
const result = applyDarkModeFilter(COLOR_PALETTE.black);
expect(result).toBe("#d3d3d3");
});
it("transforms white from palette", () => {
const result = applyDarkModeFilter(COLOR_PALETTE.white);
expect(result).toBe("#121212");
});
it("transforms transparent from palette", () => {
const result = applyDarkModeFilter(COLOR_PALETTE.transparent);
expect(result).toBe("#ededed00");
});
// Test each color family from the palette (all opaque, so 6-char hex)
describe("red shades", () => {
const redShades = COLOR_PALETTE.red;
it.each(redShades.map((color, i) => [color, i]))(
"transforms red shade %s (index %d)",
(color) => {
const result = applyDarkModeFilter(color as string);
expect(result).toMatch(/^#[0-9a-f]{6}$/);
},
);
});
describe("blue shades", () => {
const blueShades = COLOR_PALETTE.blue;
it.each(blueShades.map((color, i) => [color, i]))(
"transforms blue shade %s (index %d)",
(color) => {
const result = applyDarkModeFilter(color as string);
expect(result).toMatch(/^#[0-9a-f]{6}$/);
},
);
});
describe("green shades", () => {
const greenShades = COLOR_PALETTE.green;
it.each(greenShades.map((color, i) => [color, i]))(
"transforms green shade %s (index %d)",
(color) => {
const result = applyDarkModeFilter(color as string);
expect(result).toMatch(/^#[0-9a-f]{6}$/);
},
);
});
describe("gray shades", () => {
const grayShades = COLOR_PALETTE.gray;
it.each(grayShades.map((color, i) => [color, i]))(
"transforms gray shade %s (index %d)",
(color) => {
const result = applyDarkModeFilter(color as string);
expect(result).toMatch(/^#[0-9a-f]{6}$/);
},
);
});
describe("bronze shades", () => {
const bronzeShades = COLOR_PALETTE.bronze;
it.each(bronzeShades.map((color, i) => [color, i]))(
"transforms bronze shade %s (index %d)",
(color) => {
const result = applyDarkModeFilter(color as string);
expect(result).toMatch(/^#[0-9a-f]{6}$/);
},
);
});
// Snapshot test for full palette to catch any regressions
it("matches snapshot for all palette colors", () => {
const transformedPalette: Record<string, string | string[]> = {};
transformedPalette.black = applyDarkModeFilter(COLOR_PALETTE.black);
transformedPalette.white = applyDarkModeFilter(COLOR_PALETTE.white);
transformedPalette.transparent = applyDarkModeFilter(
COLOR_PALETTE.transparent,
);
// Transform color arrays
for (const colorName of [
"gray",
"red",
"pink",
"grape",
"violet",
"blue",
"cyan",
"teal",
"green",
"yellow",
"orange",
"bronze",
] as const) {
const shades = COLOR_PALETTE[colorName];
transformedPalette[colorName] = shades.map((shade) =>
applyDarkModeFilter(shade),
);
}
expect(transformedPalette).toMatchSnapshot();
});
});
describe("caching", () => {
it("returns same result for same input (cached)", () => {
const result1 = applyDarkModeFilter("#ff0000");
const result2 = applyDarkModeFilter("#ff0000");
expect(result1).toBe(result2);
});
});
});
describe("rgbToHex", () => {
describe("basic RGB conversion", () => {
it("converts black (0,0,0)", () => {
expect(rgbToHex(0, 0, 0)).toBe("#000000");
});
it("converts white (255,255,255)", () => {
expect(rgbToHex(255, 255, 255)).toBe("#ffffff");
});
it("converts red (255,0,0)", () => {
expect(rgbToHex(255, 0, 0)).toBe("#ff0000");
});
it("converts green (0,255,0)", () => {
expect(rgbToHex(0, 255, 0)).toBe("#00ff00");
});
it("converts blue (0,0,255)", () => {
expect(rgbToHex(0, 0, 255)).toBe("#0000ff");
});
it("converts arbitrary color", () => {
expect(rgbToHex(30, 30, 30)).toBe("#1e1e1e");
});
});
describe("leading zeros preservation", () => {
it("preserves leading zeros for low values", () => {
expect(rgbToHex(0, 0, 1)).toBe("#000001");
expect(rgbToHex(0, 1, 0)).toBe("#000100");
expect(rgbToHex(1, 0, 0)).toBe("#010000");
});
it("preserves zeros for single-digit hex values", () => {
expect(rgbToHex(15, 15, 15)).toBe("#0f0f0f");
});
});
describe("alpha handling", () => {
it("omits alpha when undefined", () => {
expect(rgbToHex(255, 0, 0)).toBe("#ff0000");
expect(rgbToHex(255, 0, 0, undefined)).toBe("#ff0000");
});
it("omits alpha when fully opaque (1)", () => {
expect(rgbToHex(255, 0, 0, 1)).toBe("#ff0000");
});
it("includes alpha for semi-transparent (0.5)", () => {
// 0.5 * 255 = 127.5 -> rounds to 128 = 0x80
expect(rgbToHex(255, 0, 0, 0.5)).toBe("#ff000080");
});
it("includes alpha for fully transparent (0)", () => {
expect(rgbToHex(255, 0, 0, 0)).toBe("#ff000000");
});
it("includes alpha for near-opaque (0.99)", () => {
// 0.99 * 255 = 252.45 -> rounds to 252 = 0xfc
expect(rgbToHex(255, 0, 0, 0.99)).toBe("#ff0000fc");
});
it("pads alpha with leading zero when needed", () => {
// 0.05 * 255 = 12.75 -> rounds to 13 = 0x0d
expect(rgbToHex(255, 0, 0, 0.05)).toBe("#ff00000d");
});
});
});
+226 -43
View File
@@ -1,8 +1,117 @@
import oc from "open-color";
import tinycolor from "tinycolor2";
import type { Merge } from "./utility-types";
import { clamp } from "@excalidraw/math";
import { degreesToRadians } from "@excalidraw/math";
export const COLOR_OUTLINE_CONTRAST_THRESHOLD = 240;
import type { Degrees } from "@excalidraw/math";
// ---------------------------------------------------------------------------
// Dark mode color transformation
// ---------------------------------------------------------------------------
// Browser-only cache to avoid memory leaks on server
const DARK_MODE_COLORS_CACHE: Map<string, string> | null =
typeof window !== "undefined" ? new Map() : null;
function cssHueRotate(
red: number,
green: number,
blue: number,
degrees: Degrees,
): { r: number; g: number; b: number } {
// normalize
const r = red / 255;
const g = green / 255;
const b = blue / 255;
// Convert degrees to radians
const a = degreesToRadians(degrees);
const c = Math.cos(a);
const s = Math.sin(a);
// rotation matrix
const matrix = [
0.213 + c * 0.787 - s * 0.213,
0.715 - c * 0.715 - s * 0.715,
0.072 - c * 0.072 + s * 0.928,
0.213 - c * 0.213 + s * 0.143,
0.715 + c * 0.285 + s * 0.14,
0.072 - c * 0.072 - s * 0.283,
0.213 - c * 0.213 - s * 0.787,
0.715 - c * 0.715 + s * 0.715,
0.072 + c * 0.928 + s * 0.072,
];
// transform
const newR = r * matrix[0] + g * matrix[1] + b * matrix[2];
const newG = r * matrix[3] + g * matrix[4] + b * matrix[5];
const newB = r * matrix[6] + g * matrix[7] + b * matrix[8];
// clamp the values to [0, 1] range and convert back to [0, 255]
return {
r: Math.round(Math.max(0, Math.min(1, newR)) * 255),
g: Math.round(Math.max(0, Math.min(1, newG)) * 255),
b: Math.round(Math.max(0, Math.min(1, newB)) * 255),
};
}
const cssInvert = (
r: number,
g: number,
b: number,
percent: number,
): { r: number; g: number; b: number } => {
const p = clamp(percent, 0, 100) / 100;
// Function to invert a single color component
const invertComponent = (color: number): number => {
// Apply the invert formula
const inverted = color * (1 - p) + (255 - color) * p;
// Round to the nearest integer and clamp to [0, 255]
return Math.round(clamp(inverted, 0, 255));
};
// Calculate the inverted RGB components
const invertedR = invertComponent(r);
const invertedG = invertComponent(g);
const invertedB = invertComponent(b);
return { r: invertedR, g: invertedG, b: invertedB };
};
export const applyDarkModeFilter = (color: string): string => {
const cached = DARK_MODE_COLORS_CACHE?.get(color);
if (cached) {
return cached;
}
const tc = tinycolor(color);
const alpha = tc.getAlpha();
// order of operations matters
// (corresponds to "filter: invert(invertPercent) hue-rotate(hueDegrees)" in css)
const rgb = tc.toRgb();
const inverted = cssInvert(rgb.r, rgb.g, rgb.b, 93);
const rotated = cssHueRotate(
inverted.r,
inverted.g,
inverted.b,
180 as Degrees,
);
const result = rgbToHex(rotated.r, rotated.g, rotated.b, alpha);
if (DARK_MODE_COLORS_CACHE) {
DARK_MODE_COLORS_CACHE.set(color, result);
}
return result;
};
// ---------------------------------------------------------------------------
// Color palette
// ---------------------------------------------------------------------------
// FIXME can't put to utils.ts rn because of circular dependency
const pick = <R extends Record<string, any>, K extends readonly (keyof R)[]>(
@@ -17,15 +126,7 @@ const pick = <R extends Record<string, any>, K extends readonly (keyof R)[]>(
}, {} as Pick<R, K[number]>) as Pick<R, K[number]>;
};
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 +139,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 +177,6 @@ const COMMON_ELEMENT_SHADES = pick(COLOR_PALETTE, [
"red",
]);
// -----------------------------------------------------------------------------
// quick picks defaults
// -----------------------------------------------------------------------------
@@ -119,7 +211,6 @@ export const DEFAULT_CANVAS_BACKGROUND_PICKS = [
"#fdf8f6",
] as ColorTuple;
// -----------------------------------------------------------------------------
// palette defaults
// -----------------------------------------------------------------------------
@@ -145,8 +236,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 +257,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;
+2 -3
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
-1
View File
@@ -11,5 +11,4 @@ export * from "./random";
export * from "./url";
export * from "./utils";
export * from "./emitter";
export * from "./visualdebug";
export * from "./editorInterface";
+47 -28
View File
@@ -10,7 +10,6 @@ import type {
Zoom,
} from "@excalidraw/excalidraw/types";
import { COLOR_PALETTE } from "./colors";
import {
DEFAULT_VERSION,
ENV,
@@ -548,16 +547,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
@@ -1157,39 +1146,69 @@ export const normalizeEOL = (str: string) => {
};
// -----------------------------------------------------------------------------
type HasBrand<T> = {
export type HasBrand<T> = {
// eslint-disable-next-line @typescript-eslint/no-unused-vars
[K in keyof T]: K extends `~brand${infer _}` ? true : never;
[K in keyof T]: K extends `~brand${infer _}` | "_brand" ? true : never;
}[keyof T];
type RemoveAllBrands<T> = HasBrand<T> extends true
? {
// eslint-disable-next-line @typescript-eslint/no-unused-vars
[K in keyof T as K extends `~brand~${infer _}` ? never : K]: T[K];
[K in keyof T as K extends `~brand~${infer _}` | "_brand"
? never
: K]: T[K];
}
: never;
: T;
// adapted from https://github.com/colinhacks/zod/discussions/1994#discussioncomment-6068940
// currently does not cover all types (e.g. tuples, promises...)
type Unbrand<T> = T extends Map<infer E, infer F>
? Map<E, F>
// For accepting values - uses loose matching for branded types
// Preserves readonly modifier: mutable array requires mutable input
type UnbrandForValue<T> = T extends Map<infer E, infer F>
? Map<UnbrandForValue<E>, UnbrandForValue<F>>
: T extends Set<infer E>
? Set<E>
: T extends Array<infer E>
? Array<E>
? Set<UnbrandForValue<E>>
: T extends readonly any[]
? T extends any[]
? unknown[] // mutable array - require mutable input
: readonly unknown[] // readonly array - accept readonly input
: RemoveAllBrands<T>;
// For return types - preserves array element unbranding
export type Unbrand<T> = T extends Map<infer E, infer F>
? Map<Unbrand<E>, Unbrand<F>>
: T extends Set<infer E>
? Set<Unbrand<E>>
: T extends readonly (infer E)[]
? Array<Unbrand<E>>
: RemoveAllBrands<T>;
export type CombineBrands<BrandedType, CurrentType> =
BrandedType extends readonly (infer BE)[]
? CurrentType extends readonly (infer CE)[]
? Array<CE & BE>
: CurrentType & BrandedType
: CurrentType & BrandedType;
export type CombineBrandsIfNeeded<T, Required> = [T] extends [Required]
? T[]
: HasBrand<T> extends true
? CombineBrands<T, Required>[]
: Required[];
/**
* Makes type into a branded type, ensuring that value is assignable to
* the base ubranded type. Optionally you can explicitly supply current value
* the base unbranded type. Optionally you can explicitly supply current value
* type to combine both (useful for composite branded types. Make sure you
* compose branded types which are not composite themselves.)
*/
export const toBrandedType = <BrandedType, CurrentType = BrandedType>(
value: Unbrand<BrandedType>,
) => {
return value as CurrentType & BrandedType;
};
export function toBrandedType<BrandedType>(
value: UnbrandForValue<BrandedType>,
): BrandedType;
export function toBrandedType<BrandedType, CurrentType>(
value: CurrentType,
): CombineBrands<BrandedType, CurrentType>;
export function toBrandedType(value: unknown) {
return value;
}
// -----------------------------------------------------------------------------
+6
View File
@@ -17,6 +17,12 @@
"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": [
+558
View File
@@ -0,0 +1,558 @@
import { pointDistance, pointFrom, type GlobalPoint } from "@excalidraw/math";
import { invariant } from "@excalidraw/common";
import type { AppState, NullableGridSize } from "@excalidraw/excalidraw/types";
import {
bindBindingElement,
calculateFixedPointForNonElbowArrowBinding,
FOCUS_POINT_SIZE,
getBindingGap,
getGlobalFixedPointForBindableElement,
isBindingEnabled,
maxBindingDistance_simple,
unbindBindingElement,
updateBoundPoint,
} from "../binding";
import {
isBindableElement,
isBindingElement,
isElbowArrow,
} from "../typeChecks";
import { LinearElementEditor } from "../linearElementEditor";
import { getHoveredElementForFocusPoint, hitElementItself } from "../collision";
import { moveArrowAboveBindable } from "../zindex";
import type {
ElementsMap,
ExcalidrawArrowElement,
ExcalidrawBindableElement,
NonDeletedSceneElementsMap,
PointsPositionUpdates,
} from "../types";
import type { Scene } from "../Scene";
export const isFocusPointVisible = (
focusPoint: GlobalPoint,
arrow: ExcalidrawArrowElement,
bindableElement: ExcalidrawBindableElement,
elementsMap: ElementsMap,
appState: {
isBindingEnabled: AppState["isBindingEnabled"];
zoom: AppState["zoom"];
},
startOrEnd: "start" | "end",
ignoreOverlap = false,
): boolean => {
// No focus point management for elbow arrows, because elbow arrows
// always have their focus point at the arrow point itself
if (
isElbowArrow(arrow) ||
!isBindingEnabled(appState) ||
arrow.points.length !== 2
) {
return false;
}
// Avoid showing the focus point indicator if the focus point is essentially
// on top of the arrow point it belongs to itself, if not ignoring specifically
if (!ignoreOverlap) {
const associatedPointIdx =
arrow.startBinding?.elementId === bindableElement.id
? 0
: arrow.points.length - 1;
const associatedArrowPoint =
LinearElementEditor.getPointAtIndexGlobalCoordinates(
arrow,
associatedPointIdx,
elementsMap,
);
if (
pointDistance(focusPoint, associatedArrowPoint) <
(FOCUS_POINT_SIZE * 1.5) / appState.zoom.value
) {
return false;
}
}
const arrowPoint = LinearElementEditor.getPointAtIndexGlobalCoordinates(
arrow,
startOrEnd === "end" ? arrow.points.length - 1 : 0,
elementsMap,
);
// Check if the focus point is within the element's shape bounds
// Endpoint dragging takes precedence
return (
pointDistance(focusPoint, arrowPoint) >=
(FOCUS_POINT_SIZE * 1.5) / appState.zoom.value &&
hitElementItself({
element: bindableElement,
elementsMap,
point: focusPoint,
threshold: getBindingGap(bindableElement, arrow),
overrideShouldTestInside: true,
})
);
};
// Updates the arrow endpoints in "orbit" configuration
const focusPointUpdate = (
arrow: ExcalidrawArrowElement,
bindableElement: ExcalidrawBindableElement | null,
isStartBinding: boolean,
elementsMap: NonDeletedSceneElementsMap,
scene: Scene,
appState: AppState,
switchToInsideBinding: boolean,
) => {
const pointUpdates = new Map();
const bindingField = isStartBinding ? "startBinding" : "endBinding";
const adjacentBindingField = isStartBinding ? "endBinding" : "startBinding";
let currentBinding = arrow[bindingField];
let adjacentBinding = arrow[adjacentBindingField];
// Update the dragged focus point related end
if (currentBinding && bindableElement) {
// Update the targeted bindings
const boundToSameElement =
bindableElement &&
adjacentBinding &&
currentBinding.elementId === adjacentBinding.elementId;
if (switchToInsideBinding || boundToSameElement) {
currentBinding = {
...currentBinding,
mode: "inside",
};
} else {
currentBinding = {
...currentBinding,
mode: "orbit",
};
}
const pointIndex = isStartBinding ? 0 : arrow.points.length - 1;
const newPoint = updateBoundPoint(
arrow,
bindingField as "startBinding" | "endBinding",
currentBinding,
bindableElement,
elementsMap,
true,
);
if (newPoint) {
pointUpdates.set(pointIndex, { point: newPoint });
}
}
// Also update the adjacent end if it has a binding
if (adjacentBinding && adjacentBinding.mode === "orbit") {
const adjacentBindableElement = elementsMap.get(
adjacentBinding.elementId,
) as ExcalidrawBindableElement;
if (
adjacentBindableElement &&
isBindableElement(adjacentBindableElement) &&
isBindingEnabled(appState)
) {
// Same shape bound on both ends
const boundToSameElementAfterUpdate =
bindableElement && adjacentBinding.elementId === bindableElement.id;
if (switchToInsideBinding || boundToSameElementAfterUpdate) {
adjacentBinding = {
...adjacentBinding,
mode: "inside",
};
} else {
adjacentBinding = {
...adjacentBinding,
mode: "orbit",
};
}
const adjacentPointIndex = isStartBinding ? arrow.points.length - 1 : 0;
const adjacentNewPoint = updateBoundPoint(
arrow,
adjacentBindingField,
adjacentBinding,
adjacentBindableElement,
elementsMap,
);
if (adjacentNewPoint) {
pointUpdates.set(adjacentPointIndex, {
point: adjacentNewPoint,
});
}
}
}
if (pointUpdates.size > 0) {
LinearElementEditor.movePoints(arrow, scene, pointUpdates, {
[bindingField]: currentBinding,
[adjacentBindingField]: adjacentBinding,
});
}
};
export const handleFocusPointDrag = (
linearElementEditor: LinearElementEditor,
elementsMap: NonDeletedSceneElementsMap,
pointerCoords: { x: number; y: number },
scene: Scene,
appState: AppState,
gridSize: NullableGridSize,
switchToInsideBinding: boolean,
) => {
const arrow = LinearElementEditor.getElement(
linearElementEditor.elementId,
elementsMap,
) as any;
// Sanity checks
if (
!arrow ||
!isBindingElement(arrow) ||
isElbowArrow(arrow) ||
!linearElementEditor.hoveredFocusPointBinding ||
!linearElementEditor.draggedFocusPointBinding
) {
return;
}
const isStartBinding =
linearElementEditor.draggedFocusPointBinding === "start";
const binding = isStartBinding ? arrow.startBinding : arrow.endBinding;
const { x: offsetX, y: offsetY } = linearElementEditor.pointerOffset;
const point = pointFrom<GlobalPoint>(
pointerCoords.x - offsetX,
pointerCoords.y - offsetY,
);
const bindingField = isStartBinding ? "startBinding" : "endBinding";
const hit = getHoveredElementForFocusPoint(
point,
arrow,
scene.getNonDeletedElements(),
elementsMap,
maxBindingDistance_simple(appState.zoom),
);
// Hovering a bindable element
if (hit && isBindingEnabled(appState)) {
// Break existing binding if bound to another shape or if binding is disabled
if (arrow[bindingField] && hit.id !== binding?.elementId) {
unbindBindingElement(
arrow,
linearElementEditor.draggedFocusPointBinding,
scene,
);
}
// Handle binding mode switch
const newMode =
switchToInsideBinding && arrow[bindingField]?.mode === "orbit"
? "inside"
: !switchToInsideBinding && arrow[bindingField]?.mode === "inside"
? "orbit"
: null;
// If no existing binding, create it
if (!arrow[bindingField] || newMode) {
// Create a new binding if none exists
bindBindingElement(
arrow,
hit,
newMode || "orbit",
linearElementEditor.draggedFocusPointBinding,
scene,
point,
);
}
// Update the binding's fixed point
scene.mutateElement(arrow, {
[bindingField]: {
...arrow[bindingField],
elementId: hit.id,
mode: newMode || arrow[bindingField]?.mode || "orbit",
...calculateFixedPointForNonElbowArrowBinding(
arrow,
hit,
linearElementEditor.draggedFocusPointBinding,
elementsMap,
point,
),
},
});
} else {
// Not hovering any bindable element, move the arrow endpoint
const pointUpdates: PointsPositionUpdates = new Map();
const pointIndex = isStartBinding ? 0 : arrow.points.length - 1;
pointUpdates.set(pointIndex, {
point: LinearElementEditor.createPointAt(
arrow,
elementsMap,
point[0],
point[1],
gridSize,
),
});
LinearElementEditor.movePoints(arrow, scene, pointUpdates);
if (arrow[bindingField]) {
unbindBindingElement(arrow, isStartBinding ? "start" : "end", scene);
}
}
// Update the arrow endpoints
focusPointUpdate(
arrow,
hit,
isStartBinding,
elementsMap,
scene,
appState,
switchToInsideBinding,
);
if (hit && isBindingEnabled(appState)) {
moveArrowAboveBindable(
point,
arrow,
scene.getElementsIncludingDeleted(),
elementsMap,
scene,
hit,
);
}
};
export const handleFocusPointPointerDown = (
arrow: ExcalidrawArrowElement,
pointerDownState: { origin: { x: number; y: number } },
elementsMap: NonDeletedSceneElementsMap,
appState: AppState,
): {
hitFocusPoint: "start" | "end" | null;
pointerOffset: { x: number; y: number };
} => {
const pointerPos = pointFrom(
pointerDownState.origin.x,
pointerDownState.origin.y,
);
const hitThreshold = (FOCUS_POINT_SIZE * 1.5) / appState.zoom.value;
// Check start binding focus point
if (arrow.startBinding?.elementId) {
const bindableElement = elementsMap.get(arrow.startBinding.elementId);
if (
bindableElement &&
isBindableElement(bindableElement) &&
!bindableElement.isDeleted
) {
const focusPoint = getGlobalFixedPointForBindableElement(
arrow.startBinding.fixedPoint,
bindableElement,
elementsMap,
);
if (
isFocusPointVisible(
focusPoint,
arrow,
bindableElement,
elementsMap,
appState,
"start",
) &&
pointDistance(pointerPos, focusPoint) <= hitThreshold
) {
return {
hitFocusPoint: "start",
pointerOffset: {
x: pointerPos[0] - focusPoint[0],
y: pointerPos[1] - focusPoint[1],
},
};
}
}
}
// Check end binding focus point (only if start not already hit)
if (arrow.endBinding?.elementId) {
const bindableElement = elementsMap.get(arrow.endBinding.elementId);
if (
bindableElement &&
isBindableElement(bindableElement) &&
!bindableElement.isDeleted
) {
const focusPoint = getGlobalFixedPointForBindableElement(
arrow.endBinding.fixedPoint,
bindableElement,
elementsMap,
);
if (
isFocusPointVisible(
focusPoint,
arrow,
bindableElement,
elementsMap,
appState,
"end",
) &&
pointDistance(pointerPos, focusPoint) <= hitThreshold
) {
return {
hitFocusPoint: "end",
pointerOffset: {
x: pointerPos[0] - focusPoint[0],
y: pointerPos[1] - focusPoint[1],
},
};
}
}
}
return {
hitFocusPoint: null,
pointerOffset: { x: 0, y: 0 },
};
};
export const handleFocusPointPointerUp = (
linearElementEditor: LinearElementEditor,
scene: Scene,
) => {
invariant(
linearElementEditor.draggedFocusPointBinding,
"Must have a dragged focus point at pointer release",
);
const arrow = LinearElementEditor.getElement<ExcalidrawArrowElement>(
linearElementEditor.elementId,
scene.getNonDeletedElementsMap(),
);
invariant(arrow, "Arrow must be in the scene");
// Clean up
const bindingKey =
linearElementEditor.draggedFocusPointBinding === "start"
? "startBinding"
: "endBinding";
const otherBindingKey =
linearElementEditor.draggedFocusPointBinding === "start"
? "endBinding"
: "startBinding";
const boundElementId = arrow[bindingKey]?.elementId;
const otherBoundElementId = arrow[otherBindingKey]?.elementId;
const oldBoundElement =
boundElementId &&
scene
.getNonDeletedElements()
.find(
(element) =>
element.id !== boundElementId &&
element.id !== otherBoundElementId &&
isBindableElement(element) &&
element.boundElements?.find(({ id }) => id === arrow.id),
);
if (oldBoundElement) {
scene.mutateElement(oldBoundElement, {
boundElements: oldBoundElement.boundElements?.filter(
({ id }) => id !== arrow.id,
),
});
}
// Record the new bound element
const boundElement =
boundElementId && scene.getNonDeletedElementsMap().get(boundElementId);
if (boundElement) {
scene.mutateElement(boundElement, {
boundElements: [
...(boundElement.boundElements || [])?.filter(
({ id }) => id !== arrow.id,
),
{
id: arrow.id,
type: "arrow",
},
],
});
}
};
export const handleFocusPointHover = (
arrow: ExcalidrawArrowElement,
scenePointerX: number,
scenePointerY: number,
scene: Scene,
appState: AppState,
): "start" | "end" | null => {
const elementsMap = scene.getNonDeletedElementsMap();
const pointerPos = pointFrom(scenePointerX, scenePointerY);
const hitThreshold = (FOCUS_POINT_SIZE * 1.5) / appState.zoom.value;
// Check start binding focus point
if (arrow.startBinding?.elementId) {
const bindableElement = elementsMap.get(arrow.startBinding.elementId);
if (
bindableElement &&
isBindableElement(bindableElement) &&
!bindableElement.isDeleted
) {
const focusPoint = getGlobalFixedPointForBindableElement(
arrow.startBinding.fixedPoint,
bindableElement,
elementsMap,
);
if (
isFocusPointVisible(
focusPoint,
arrow,
bindableElement,
elementsMap,
appState,
"start",
) &&
pointDistance(pointerPos, focusPoint) <= hitThreshold
) {
return "start";
}
}
}
// Check end binding focus point (only if start not already hovered)
if (arrow.endBinding?.elementId) {
const bindableElement = elementsMap.get(arrow.endBinding.elementId);
if (
bindableElement &&
isBindableElement(bindableElement) &&
!bindableElement.isDeleted
) {
const focusPoint = getGlobalFixedPointForBindableElement(
arrow.endBinding.fixedPoint,
bindableElement,
elementsMap,
);
if (
isFocusPointVisible(
focusPoint,
arrow,
bindableElement,
elementsMap,
appState,
"end",
) &&
pointDistance(pointerPos, focusPoint) <= hitThreshold
) {
return "end";
}
}
}
return null;
};
+45
View File
@@ -0,0 +1,45 @@
import type { App } from "@excalidraw/excalidraw/types";
import { LinearElementEditor } from "../linearElementEditor";
import { handleFocusPointDrag } from "./focus";
export const maybeHandleArrowPointlikeDrag = ({
app,
event,
}: {
app: App;
event: KeyboardEvent | React.KeyboardEvent<Element> | PointerEvent;
}): boolean => {
const appState = app.state;
if (appState.selectedLinearElement && app.lastPointerMoveCoords) {
// Update focus point status if the binding mode is changing
if (appState.selectedLinearElement.draggedFocusPointBinding) {
handleFocusPointDrag(
appState.selectedLinearElement,
app.scene.getNonDeletedElementsMap(),
app.lastPointerMoveCoords,
app.scene,
appState,
app.getEffectiveGridSize(),
event.altKey,
);
return true;
} else if (
appState.selectedLinearElement.hoverPointIndex !== null &&
app.lastPointerMoveEvent &&
appState.selectedLinearElement.initialState.lastClickedPoint >= 0 &&
appState.selectedLinearElement.isDragging
) {
LinearElementEditor.handlePointDragging(
app.lastPointerMoveEvent,
app,
app.lastPointerMoveCoords.x,
app.lastPointerMoveCoords.y,
appState.selectedLinearElement,
);
return true;
}
}
return false;
};
+310 -187
View File
@@ -15,6 +15,7 @@ import {
pointFrom,
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
@@ -682,7 +684,7 @@ const getBindingStrategyForDraggingBindingElementEndpoints_simple = (
globalPoint,
elements,
elementsMap,
(e) => maxBindingDistance_simple(appState.zoom),
maxBindingDistance_simple(appState.zoom),
);
const pointInElement =
hit &&
@@ -709,7 +711,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) {
@@ -789,6 +797,7 @@ const getBindingStrategyForDraggingBindingElementEndpoints_simple = (
hit,
startDragged ? "start" : "end",
elementsMap,
appState.zoom,
) || globalPoint,
}
: { mode: null };
@@ -798,11 +807,24 @@ 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,
@@ -819,9 +841,11 @@ const getBindingStrategyForDraggingBindingElementEndpoints_simple = (
otherBindableElement,
startDragged ? "end" : "start",
elementsMap,
appState.zoom,
) || otherEndpoint,
}
: { mode: undefined };
: { mode: undefined }
: { mode: undefined };
return {
start: startDragged ? current : other,
@@ -1085,7 +1109,7 @@ export const updateBoundElements = (
});
}
boundElementsVisitor(elementsMap, changedElement, (element) => {
const visitor = (element: ExcalidrawElement | undefined) => {
if (!isArrowElement(element) || element.isDeleted) {
return;
}
@@ -1157,7 +1181,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 +1258,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 +1392,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 +1409,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 +1424,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 +1578,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 +1598,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 +1607,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 +1618,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 +1630,7 @@ const snapToMid = (
) {
// RIGHT
return pointRotateRads(
pointFrom(x + width + bindingGap, center[1]),
pointFrom<GlobalPoint>(x + width + bindingGap, center[1]),
center,
angle,
);
@@ -1534,7 +1641,7 @@ const snapToMid = (
) {
// DOWN
return pointRotateRads(
pointFrom(center[0], y + height + bindingGap),
pointFrom<GlobalPoint>(center[0], y + height + bindingGap),
center,
angle,
);
@@ -1583,13 +1690,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 +1735,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 +1929,7 @@ export const calculateFixedPointForNonElbowArrowBinding = (
elementsMap: ElementsMap,
focusPoint?: GlobalPoint,
): { fixedPoint: FixedPoint } => {
const edgePoint = focusPoint
const edgePoint: GlobalPoint = focusPoint
? focusPoint
: LinearElementEditor.getPointAtIndexGlobalCoordinates(
linearElement,
@@ -1810,11 +1937,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(
+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
*
+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;
});
});
};
+1 -1
View File
@@ -2276,7 +2276,7 @@ const getHoveredElement = (
origPoint,
elements,
elementsMap,
(element) => maxBindingDistance_simple(zoom),
maxBindingDistance_simple(zoom),
);
};
+79 -2
View File
@@ -56,7 +56,7 @@ 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 {
@@ -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 (error) {
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";
+2
View File
@@ -70,6 +70,7 @@ export * from "./elbowArrow";
export * from "./elementLink";
export * from "./embeddable";
export * from "./flowchart";
export * from "./arrows/focus";
export * from "./fractionalIndex";
export * from "./frame";
export * from "./groups";
@@ -97,3 +98,4 @@ export * from "./transformHandles";
export * from "./typeChecks";
export * from "./utils";
export * from "./zindex";
export * from "./arrows/helpers";
+128 -38
View File
@@ -9,7 +9,6 @@ import {
vectorFromPoint,
curveLength,
curvePointAtLength,
lineSegment,
} from "@excalidraw/math";
import { getCurvePathOps } from "@excalidraw/utils/shape";
@@ -26,6 +25,7 @@ import {
import {
deconstructLinearOrFreeDrawElement,
getSnapOutlineMidPoint,
isPathALoop,
moveArrowAboveBindable,
projectFixedPointOntoDiagonal,
@@ -48,6 +48,7 @@ import {
calculateFixedPointForNonElbowArrowBinding,
getBindingStrategyForDraggingBindingElementEndpoints,
isBindingEnabled,
snapToMid,
updateBoundPoint,
} from "./binding";
import {
@@ -149,6 +150,8 @@ export class LinearElementEditor {
public readonly pointerOffset: Readonly<{ x: number; y: number }>;
public readonly hoverPointIndex: number;
public readonly segmentMidPointHoveredCoords: GlobalPoint | null;
public readonly hoveredFocusPointBinding: "start" | "end" | null;
public readonly draggedFocusPointBinding: "start" | "end" | null;
public readonly elbowed: boolean;
public readonly customLineAngle: number | null;
public readonly isEditing: boolean;
@@ -194,6 +197,8 @@ export class LinearElementEditor {
};
this.hoverPointIndex = -1;
this.segmentMidPointHoveredCoords = null;
this.hoveredFocusPointBinding = null;
this.draggedFocusPointBinding = null;
this.elbowed = isElbowArrow(element) && element.elbowed;
this.customLineAngle = null;
this.isEditing = isEditing;
@@ -351,6 +356,7 @@ export class LinearElementEditor {
app,
shouldRotateWithDiscreteAngle(event),
event.altKey,
linearElementEditor,
);
LinearElementEditor.movePoints(element, app.scene, positions, {
@@ -404,13 +410,14 @@ export class LinearElementEditor {
altFocusPoint:
!linearElementEditor.initialState.altFocusPoint &&
startBindingElement &&
updates?.suggestedBinding?.id !== startBindingElement.id
updates?.suggestedBinding?.element.id !== startBindingElement.id
? projectFixedPointOntoDiagonal(
element,
pointFrom<GlobalPoint>(element.x, element.y),
startBindingElement,
"start",
elementsMap,
app.state.zoom,
)
: linearElementEditor.initialState.altFocusPoint,
},
@@ -528,6 +535,7 @@ export class LinearElementEditor {
app,
shouldRotateWithDiscreteAngle(event) && singlePointDragged,
event.altKey,
linearElementEditor,
);
LinearElementEditor.movePoints(element, app.scene, positions, {
@@ -603,11 +611,11 @@ export class LinearElementEditor {
const altFocusPointBindableElement =
endIsSelected && // The "other" end (i.e. "end") is dragged
startBindingElement &&
updates?.suggestedBinding?.id !== startBindingElement.id // The end point is not hovering the start bindable + it's binding gap
updates?.suggestedBinding?.element.id !== startBindingElement.id // The end point is not hovering the start bindable + it's binding gap
? startBindingElement
: startIsSelected && // The "other" end (i.e. "start") is dragged
endBindingElement &&
updates?.suggestedBinding?.id !== endBindingElement.id // The start point is not hovering the end bindable + it's binding gap
updates?.suggestedBinding?.element.id !== endBindingElement.id // The start point is not hovering the end bindable + it's binding gap
? endBindingElement
: null;
@@ -627,6 +635,7 @@ export class LinearElementEditor {
altFocusPointBindableElement,
"start",
elementsMap,
app.state.zoom,
)
: linearElementEditor.initialState.altFocusPoint,
},
@@ -724,7 +733,6 @@ export class LinearElementEditor {
? [pointerDownState.lastClickedPoint]
: selectedPointsIndices,
isDragging: false,
pointerOffset: { x: 0, y: 0 },
customLineAngle: null,
initialState: {
...editingLinearElement.initialState,
@@ -2077,6 +2085,7 @@ const pointDraggingUpdates = (
app: AppClassProperties,
angleLocked: boolean,
altKey: boolean,
linearElementEditor: LinearElementEditor,
): {
positions: PointsPositionUpdates;
updates?: PointMoveOtherUpdates;
@@ -2124,18 +2133,89 @@ const pointDraggingUpdates = (
);
if (isElbowArrow(element)) {
const suggestedBindingElement = startIsDragged
? start.element
: endIsDragged
? end.element
: null;
return {
positions: naiveDraggingPoints,
updates: {
suggestedBinding: startIsDragged
? start.element
: endIsDragged
? end.element
suggestedBinding: suggestedBindingElement
? {
element: suggestedBindingElement,
midPoint: snapToMid(
suggestedBindingElement,
elementsMap,
pointFrom<GlobalPoint>(
scenePointerX - linearElementEditor.pointerOffset.x,
scenePointerY - linearElementEditor.pointerOffset.y,
),
),
}
: null,
},
};
}
// Handle the case where neither endpoint is being dragged
// but we need to update bound endpoints
if (!startIsDragged && !endIsDragged) {
const nextArrow = {
...element,
points: element.points.map((p, idx) => {
return naiveDraggingPoints.get(idx)?.point ?? p;
}),
};
const positions = new Map(naiveDraggingPoints);
if (element.startBinding) {
const startBindable = elementsMap.get(element.startBinding.elementId) as
| ExcalidrawBindableElement
| undefined;
if (startBindable) {
const startPoint =
updateBoundPoint(
nextArrow,
"startBinding",
element.startBinding,
startBindable,
elementsMap,
) ?? null;
if (startPoint) {
positions.set(0, { point: startPoint, isDragging: true });
}
}
}
if (element.endBinding) {
const endBindable = elementsMap.get(element.endBinding.elementId) as
| ExcalidrawBindableElement
| undefined;
if (endBindable) {
const endPoint =
updateBoundPoint(
nextArrow,
"endBinding",
element.endBinding,
endBindable,
elementsMap,
) ?? null;
if (endPoint) {
positions.set(element.points.length - 1, {
point: endPoint,
isDragging: true,
});
}
}
}
return {
positions,
};
}
if (startIsDragged === endIsDragged) {
return {
positions: naiveDraggingPoints,
@@ -2166,7 +2246,20 @@ const pointDraggingUpdates = (
(updates.startBinding.mode === "orbit" ||
!getFeatureFlag("COMPLEX_BINDINGS"))
) {
updates.suggestedBinding = start.element;
updates.suggestedBinding = start.element
? {
element: start.element,
midPoint: getSnapOutlineMidPoint(
pointFrom<GlobalPoint>(
scenePointerX - linearElementEditor.pointerOffset.x,
scenePointerY - linearElementEditor.pointerOffset.y,
),
start.element,
elementsMap,
app.state.zoom,
),
}
: null;
}
} else if (startIsDragged) {
updates.suggestedBinding = app.state.suggestedBinding;
@@ -2192,7 +2285,20 @@ const pointDraggingUpdates = (
(updates.endBinding.mode === "orbit" ||
!getFeatureFlag("COMPLEX_BINDINGS"))
) {
updates.suggestedBinding = end.element;
updates.suggestedBinding = end.element
? {
element: end.element,
midPoint: getSnapOutlineMidPoint(
pointFrom<GlobalPoint>(
scenePointerX - linearElementEditor.pointerOffset.x,
scenePointerY - linearElementEditor.pointerOffset.y,
),
end.element,
elementsMap,
app.state.zoom,
),
}
: null;
}
} else if (endIsDragged) {
updates.suggestedBinding = app.state.suggestedBinding;
@@ -2232,19 +2338,6 @@ const pointDraggingUpdates = (
: updates.endBinding,
};
// We need to use a custom intersector to ensure that if there is a big "jump"
// in the arrow's position, we can position it with outline avoidance
// pixel-perfectly and avoid "dancing" arrows.
// NOTE: Direction matters here, so we create two intersectors
const startCustomIntersector =
start.focusPoint && end.focusPoint
? lineSegment(start.focusPoint, end.focusPoint)
: undefined;
const endCustomIntersector =
start.focusPoint && end.focusPoint
? lineSegment(end.focusPoint, start.focusPoint)
: undefined;
// Needed to handle a special case where an existing arrow is dragged over
// the same element it is bound to on the other side
const startIsDraggingOverEndElement =
@@ -2280,7 +2373,7 @@ const pointDraggingUpdates = (
nextArrow.endBinding,
endBindable,
elementsMap,
endCustomIntersector,
endIsDragged,
) || nextArrow.points[nextArrow.points.length - 1]
: nextArrow.points[nextArrow.points.length - 1];
@@ -2303,7 +2396,7 @@ const pointDraggingUpdates = (
: startIsDraggingOverEndElement &&
app.state.bindMode !== "inside" &&
getFeatureFlag("COMPLEX_BINDINGS")
? nextArrow.points[nextArrow.points.length - 1]
? endLocalPoint
: startBindable
? updateBoundPoint(
element,
@@ -2311,15 +2404,18 @@ const pointDraggingUpdates = (
nextArrow.startBinding,
startBindable,
elementsMap,
startCustomIntersector,
startIsDragged,
) || nextArrow.points[0]
: nextArrow.points[0];
const endChanged =
pointDistance(
endLocalPoint,
nextArrow.points[nextArrow.points.length - 1],
) !== 0;
!startIsDraggingOverEndElement &&
!(
endIsDraggingOverStartElement &&
app.state.bindMode !== "inside" &&
getFeatureFlag("COMPLEX_BINDINGS")
) &&
!!endBindable;
const startChanged =
pointDistance(startLocalPoint, nextArrow.points[0]) !== 0;
@@ -2333,13 +2429,7 @@ const pointDraggingUpdates = (
const indices = Array.from(indicesSet);
return {
updates:
updates.startBinding || updates.suggestedBinding
? {
startBinding: updates.startBinding,
suggestedBinding: updates.suggestedBinding,
}
: undefined,
updates,
positions: new Map(
indices.map((idx) => {
return [
+113 -139
View File
@@ -1,5 +1,4 @@
import rough from "roughjs/bin/rough";
import { getStroke } from "perfect-freehand";
import {
type GlobalPoint,
@@ -15,6 +14,7 @@ import {
DEFAULT_REDUCED_GLOBAL_ALPHA,
ELEMENT_READY_TO_ERASE_OPACITY,
FRAME_STYLE,
DARK_THEME_FILTER,
MIME_TYPES,
THEME,
distance,
@@ -22,6 +22,8 @@ import {
isRTL,
getVerticalOffset,
invariant,
applyDarkModeFilter,
isSafari,
} from "@excalidraw/common";
import type {
@@ -78,16 +80,8 @@ import type {
ElementsMap,
} from "./types";
import type { StrokeOptions } from "perfect-freehand";
import type { RoughCanvas } from "roughjs/bin/canvas";
// using a stronger invert (100% vs our regular 93%) and saturate
// as a temp hack to make images in dark theme look closer to original
// color scheme (it's still not quite there and the colors look slightly
// desatured, alas...)
export const IMAGE_INVERT_FILTER =
"invert(100%) hue-rotate(180deg) saturate(1.25)";
const isPendingImageElement = (
element: ExcalidrawElement,
renderConfig: StaticCanvasRenderConfig,
@@ -95,19 +89,6 @@ const isPendingImageElement = (
isInitializedImageElement(element) &&
!renderConfig.imageCache.has(element.fileId);
const shouldResetImageFilter = (
element: ExcalidrawElement,
renderConfig: StaticCanvasRenderConfig,
appState: StaticCanvasAppState | InteractiveCanvasAppState,
) => {
return (
appState.theme === THEME.DARK &&
isInitializedImageElement(element) &&
!isPendingImageElement(element, renderConfig) &&
renderConfig.imageCache.get(element.fileId)?.mimeType !== MIME_TYPES.svg
);
};
const getCanvasPadding = (element: ExcalidrawElement) => {
switch (element.type) {
case "freedraw":
@@ -272,11 +253,6 @@ const generateElementCanvas = (
const rc = rough.canvas(canvas);
// in dark theme, revert the image color filter
if (shouldResetImageFilter(element, renderConfig, appState)) {
context.filter = IMAGE_INVERT_FILTER;
}
drawElementOnCanvas(element, rc, context, renderConfig);
context.restore();
@@ -385,8 +361,9 @@ IMAGE_ERROR_PLACEHOLDER_IMG.src = `data:${MIME_TYPES.svg},${encodeURIComponent(
const drawImagePlaceholder = (
element: ExcalidrawImageElement,
context: CanvasRenderingContext2D,
theme: StaticCanvasRenderConfig["theme"],
) => {
context.fillStyle = "#E7E7E7";
context.fillStyle = theme === THEME.DARK ? "#2E2E2E" : "#E7E7E7";
context.fillRect(0, 0, element.width, element.height);
const imageMinWidthOrHeight = Math.min(element.width, element.height);
@@ -421,7 +398,8 @@ const drawElementOnCanvas = (
case "ellipse": {
context.lineJoin = "round";
context.lineCap = "round";
rc.draw(ShapeCache.get(element)!);
rc.draw(ShapeCache.generateElementShape(element, renderConfig));
break;
}
case "arrow":
@@ -429,33 +407,44 @@ const drawElementOnCanvas = (
context.lineJoin = "round";
context.lineCap = "round";
ShapeCache.get(element)!.forEach((shape) => {
rc.draw(shape);
});
ShapeCache.generateElementShape(element, renderConfig).forEach(
(shape) => {
rc.draw(shape);
},
);
break;
}
case "freedraw": {
// Draw directly to canvas
context.save();
context.fillStyle = element.strokeColor;
const path = getFreeDrawPath2D(element) as Path2D;
const fillShape = ShapeCache.get(element);
const shapes = ShapeCache.generateElementShape(element, renderConfig);
if (fillShape) {
rc.draw(fillShape);
for (const shape of shapes) {
if (typeof shape === "string") {
context.fillStyle =
renderConfig.theme === THEME.DARK
? applyDarkModeFilter(element.strokeColor)
: element.strokeColor;
context.fill(new Path2D(shape));
} else {
rc.draw(shape);
}
}
context.fillStyle = element.strokeColor;
context.fill(path);
context.restore();
break;
}
case "image": {
context.save();
const cacheEntry =
element.fileId !== null
? renderConfig.imageCache.get(element.fileId)
: null;
const img = isInitializedImageElement(element)
? renderConfig.imageCache.get(element.fileId)?.image
? cacheEntry?.image
: undefined;
if (img != null && !(img instanceof Promise)) {
if (element.roundness && context.roundRect) {
context.beginPath();
@@ -478,20 +467,80 @@ const drawElementOnCanvas = (
height: img.naturalHeight,
};
context.drawImage(
img,
x,
y,
width,
height,
0 /* hardcoded for the selection box*/,
0,
element.width,
element.height,
);
const shouldInvertImage =
renderConfig.theme === THEME.DARK &&
cacheEntry?.mimeType === MIME_TYPES.svg;
if (shouldInvertImage && isSafari) {
const devicePixelRatio = window.devicePixelRatio || 1;
const tempCanvas = document.createElement("canvas");
tempCanvas.width = element.width * devicePixelRatio;
tempCanvas.height = element.height * devicePixelRatio;
const tempContext = tempCanvas.getContext("2d");
if (tempContext) {
tempContext.scale(devicePixelRatio, devicePixelRatio);
tempContext.drawImage(
img,
x,
y,
width,
height,
0,
0,
element.width,
element.height,
);
const imageData = tempContext.getImageData(
0,
0,
tempCanvas.width,
tempCanvas.height,
);
const data = imageData.data;
for (let i = 0; i < data.length; i += 4) {
data[i] = 255 - data[i];
data[i + 1] = 255 - data[i + 1];
data[i + 2] = 255 - data[i + 2];
}
tempContext.putImageData(imageData, 0, 0);
context.drawImage(
tempCanvas,
0,
0,
tempCanvas.width,
tempCanvas.height,
0,
0,
element.width,
element.height,
);
}
} else {
if (shouldInvertImage) {
context.filter = DARK_THEME_FILTER;
}
context.drawImage(
img,
x,
y,
width,
height,
0 /* hardcoded for the selection box*/,
0,
element.width,
element.height,
);
}
} else {
drawImagePlaceholder(element, context);
drawImagePlaceholder(element, context, renderConfig.theme);
}
context.restore();
break;
}
default: {
@@ -506,7 +555,10 @@ const drawElementOnCanvas = (
context.canvas.setAttribute("dir", rtl ? "rtl" : "ltr");
context.save();
context.font = getFontString(element);
context.fillStyle = element.strokeColor;
context.fillStyle =
renderConfig.theme === THEME.DARK
? applyDarkModeFilter(element.strokeColor)
: element.strokeColor;
context.textAlign = element.textAlign as CanvasTextAlign;
// Canvas does not support multiline text by default
@@ -759,12 +811,17 @@ export const renderElement = (
context.fillStyle = "rgba(0, 0, 200, 0.04)";
context.lineWidth = FRAME_STYLE.strokeWidth / appState.zoom.value;
context.strokeStyle = FRAME_STYLE.strokeColor;
context.strokeStyle =
appState.theme === THEME.DARK
? applyDarkModeFilter(FRAME_STYLE.strokeColor)
: FRAME_STYLE.strokeColor;
// TODO change later to only affect AI frames
if (isMagicFrameElement(element)) {
context.strokeStyle =
appState.theme === THEME.LIGHT ? "#7affd7" : "#1d8264";
appState.theme === THEME.LIGHT
? "#7affd7"
: applyDarkModeFilter("#1d8264");
}
if (FRAME_STYLE.radius && context.roundRect) {
@@ -787,11 +844,6 @@ export const renderElement = (
break;
}
case "freedraw": {
// TODO investigate if we can do this in situ. Right now we need to call
// beforehand because math helpers (such as getElementAbsoluteCoords)
// rely on existing shapes
ShapeCache.generateElementShape(element, null);
if (renderConfig.isExporting) {
const [x1, y1, x2, y2] = getElementAbsoluteCoords(element, elementsMap);
const cx = (x1 + x2) / 2 + appState.scrollX;
@@ -835,10 +887,6 @@ export const renderElement = (
case "text":
case "iframe":
case "embeddable": {
// TODO investigate if we can do this in situ. Right now we need to call
// beforehand because math helpers (such as getElementAbsoluteCoords)
// rely on existing shapes
ShapeCache.generateElementShape(element, renderConfig);
if (renderConfig.isExporting) {
const [x1, y1, x2, y2] = getElementAbsoluteCoords(element, elementsMap);
const cx = (x1 + x2) / 2 + appState.scrollX;
@@ -861,9 +909,6 @@ export const renderElement = (
context.save();
context.translate(cx, cy);
if (shouldResetImageFilter(element, renderConfig, appState)) {
context.filter = "none";
}
const boundTextElement = getBoundTextElement(element, elementsMap);
if (isArrowElement(element) && boundTextElement) {
@@ -1026,23 +1071,6 @@ export const renderElement = (
context.globalAlpha = 1;
};
export const pathsCache = new WeakMap<ExcalidrawFreeDrawElement, Path2D>([]);
export function generateFreeDrawShape(element: ExcalidrawFreeDrawElement) {
const svgPathData = getFreeDrawSvgPath(element);
const path = new Path2D(svgPathData);
pathsCache.set(element, path);
return path;
}
export function getFreeDrawPath2D(element: ExcalidrawFreeDrawElement) {
return pathsCache.get(element);
}
export function getFreeDrawSvgPath(element: ExcalidrawFreeDrawElement) {
return getSvgPathFromStroke(getFreedrawOutlinePoints(element));
}
export function getFreedrawOutlineAsSegments(
element: ExcalidrawFreeDrawElement,
points: [number, number][],
@@ -1098,57 +1126,3 @@ export function getFreedrawOutlineAsSegments(
],
);
}
export function getFreedrawOutlinePoints(element: ExcalidrawFreeDrawElement) {
// If input points are empty (should they ever be?) return a dot
const inputPoints = element.simulatePressure
? element.points
: element.points.length
? element.points.map(([x, y], i) => [x, y, element.pressures[i]])
: [[0, 0, 0.5]];
// Consider changing the options for simulated pressure vs real pressure
const options: StrokeOptions = {
simulatePressure: element.simulatePressure,
size: element.strokeWidth * 4.25,
thinning: 0.6,
smoothing: 0.5,
streamline: 0.5,
easing: (t) => Math.sin((t * Math.PI) / 2), // https://easings.net/#easeOutSine
last: true,
};
return getStroke(inputPoints as number[][], options) as [number, number][];
}
function med(A: number[], B: number[]) {
return [(A[0] + B[0]) / 2, (A[1] + B[1]) / 2];
}
// Trim SVG path data so number are each two decimal points. This
// improves SVG exports, and prevents rendering errors on points
// with long decimals.
const TO_FIXED_PRECISION = /(\s?[A-Z]?,?-?[0-9]*\.[0-9]{0,2})(([0-9]|e|-)*)/g;
function getSvgPathFromStroke(points: number[][]): string {
if (!points.length) {
return "";
}
const max = points.length - 1;
return points
.reduce(
(acc, point, i, arr) => {
if (i === max) {
acc.push(point, med(point, arr[0]), "L", arr[0], "Z");
} else {
acc.push(point, med(point, arr[i + 1]));
}
return acc;
},
["M", points[0], "Q"],
)
.join(" ")
.replace(TO_FIXED_PRECISION, "$1");
}
+144 -43
View File
@@ -1,4 +1,5 @@
import { simplify } from "points-on-curve";
import { getStroke } from "perfect-freehand";
import {
type GeometricShape,
@@ -17,10 +18,12 @@ import {
} from "@excalidraw/math";
import {
ROUGHNESS,
THEME,
isTransparent,
assertNever,
COLOR_PALETTE,
LINE_POLYGON_POINT_MERGE_DISTANCE,
applyDarkModeFilter,
} from "@excalidraw/common";
import { RoughGenerator } from "roughjs/bin/generator";
@@ -36,6 +39,7 @@ import type {
import type {
ElementShape,
ElementShapes,
SVGPathString,
} from "@excalidraw/excalidraw/scene/types";
import { elementWithCanvasCache } from "./renderElement";
@@ -52,7 +56,6 @@ import { getCornerRadius, isPathALoop } from "./utils";
import { headingForPointIsHorizontal } from "./heading";
import { canChangeRoundness } from "./comparisons";
import { generateFreeDrawShape } from "./renderElement";
import {
getArrowheadPoints,
getCenterForBounds,
@@ -77,29 +80,32 @@ import type { Point as RoughPoint } from "roughjs/bin/geometry";
export class ShapeCache {
private static rg = new RoughGenerator();
private static cache = new WeakMap<ExcalidrawElement, ElementShape>();
private static cache = new WeakMap<
ExcalidrawElement,
{ shape: ElementShape; theme: AppState["theme"] }
>();
/**
* Retrieves shape from cache if available. Use this only if shape
* is optional and you have a fallback in case it's not cached.
*/
public static get = <T extends ExcalidrawElement>(element: T) => {
return ShapeCache.cache.get(
element,
) as T["type"] extends keyof ElementShapes
? ElementShapes[T["type"]] | undefined
: ElementShape | undefined;
public static get = <T extends ExcalidrawElement>(
element: T,
theme: AppState["theme"] | null,
) => {
const cached = ShapeCache.cache.get(element);
if (cached && (theme === null || cached.theme === theme)) {
return cached.shape as T["type"] extends keyof ElementShapes
? ElementShapes[T["type"]] | undefined
: ElementShape | undefined;
}
return undefined;
};
public static set = <T extends ExcalidrawElement>(
element: T,
shape: T["type"] extends keyof ElementShapes
? ElementShapes[T["type"]]
: Drawable,
) => ShapeCache.cache.set(element, shape);
public static delete = (element: ExcalidrawElement) =>
public static delete = (element: ExcalidrawElement) => {
ShapeCache.cache.delete(element);
elementWithCanvasCache.delete(element);
};
public static destroy = () => {
ShapeCache.cache = new WeakMap();
@@ -117,12 +123,13 @@ export class ShapeCache {
isExporting: boolean;
canvasBackgroundColor: AppState["viewBackgroundColor"];
embedsValidationStatus: EmbedsValidationStatus;
theme: AppState["theme"];
} | null,
) => {
// when exporting, always regenerated to guarantee the latest shape
const cachedShape = renderConfig?.isExporting
? undefined
: ShapeCache.get(element);
: ShapeCache.get(element, renderConfig ? renderConfig.theme : null);
// `null` indicates no rc shape applicable for this element type,
// but it's considered a valid cache value (= do not regenerate)
@@ -132,19 +139,25 @@ export class ShapeCache {
elementWithCanvasCache.delete(element);
const shape = generateElementShape(
const shape = _generateElementShape(
element,
ShapeCache.rg,
renderConfig || {
isExporting: false,
canvasBackgroundColor: COLOR_PALETTE.white,
embedsValidationStatus: null,
theme: THEME.LIGHT,
},
) as T["type"] extends keyof ElementShapes
? ElementShapes[T["type"]]
: Drawable | null;
ShapeCache.cache.set(element, shape);
if (!renderConfig?.isExporting) {
ShapeCache.cache.set(element, {
shape,
theme: renderConfig?.theme || THEME.LIGHT,
});
}
return shape;
};
@@ -180,6 +193,7 @@ function adjustRoughness(element: ExcalidrawElement): number {
export const generateRoughOptions = (
element: ExcalidrawElement,
continuousPath = false,
isDarkMode: boolean = false,
): Options => {
const options: Options = {
seed: element.seed,
@@ -204,7 +218,9 @@ export const generateRoughOptions = (
fillWeight: element.strokeWidth / 2,
hachureGap: element.strokeWidth * 4,
roughness: adjustRoughness(element),
stroke: element.strokeColor,
stroke: isDarkMode
? applyDarkModeFilter(element.strokeColor)
: element.strokeColor,
preserveVertices:
continuousPath || element.roughness < ROUGHNESS.cartoonist,
};
@@ -218,6 +234,8 @@ export const generateRoughOptions = (
options.fillStyle = element.fillStyle;
options.fill = isTransparent(element.backgroundColor)
? undefined
: isDarkMode
? applyDarkModeFilter(element.backgroundColor)
: element.backgroundColor;
if (element.type === "ellipse") {
options.curveFitting = 1;
@@ -231,6 +249,8 @@ export const generateRoughOptions = (
options.fill =
element.backgroundColor === "transparent"
? undefined
: isDarkMode
? applyDarkModeFilter(element.backgroundColor)
: element.backgroundColor;
}
return options;
@@ -284,6 +304,7 @@ const getArrowheadShapes = (
generator: RoughGenerator,
options: Options,
canvasBackgroundColor: string,
isDarkMode: boolean,
) => {
const arrowheadPoints = getArrowheadPoints(
element,
@@ -309,6 +330,10 @@ const getArrowheadShapes = (
return [generator.line(x3, y3, x4, y4, options)];
};
const strokeColor = isDarkMode
? applyDarkModeFilter(element.strokeColor)
: element.strokeColor;
switch (arrowhead) {
case "dot":
case "circle":
@@ -324,10 +349,10 @@ const getArrowheadShapes = (
fill:
arrowhead === "circle_outline"
? canvasBackgroundColor
: element.strokeColor,
: strokeColor,
fillStyle: "solid",
stroke: element.strokeColor,
stroke: strokeColor,
roughness: Math.min(0.5, options.roughness || 0),
}),
];
@@ -352,7 +377,7 @@ const getArrowheadShapes = (
fill:
arrowhead === "triangle_outline"
? canvasBackgroundColor
: element.strokeColor,
: strokeColor,
fillStyle: "solid",
roughness: Math.min(1, options.roughness || 0),
},
@@ -380,7 +405,7 @@ const getArrowheadShapes = (
fill:
arrowhead === "diamond_outline"
? canvasBackgroundColor
: element.strokeColor,
: strokeColor,
fillStyle: "solid",
roughness: Math.min(1, options.roughness || 0),
},
@@ -602,19 +627,22 @@ export const generateLinearCollisionShape = (
*
* @private
*/
const generateElementShape = (
const _generateElementShape = (
element: Exclude<NonDeletedExcalidrawElement, ExcalidrawSelectionElement>,
generator: RoughGenerator,
{
isExporting,
canvasBackgroundColor,
embedsValidationStatus,
theme,
}: {
isExporting: boolean;
canvasBackgroundColor: string;
embedsValidationStatus: EmbedsValidationStatus | null;
theme?: AppState["theme"];
},
): Drawable | Drawable[] | null => {
): ElementShape => {
const isDarkMode = theme === THEME.DARK;
switch (element.type) {
case "rectangle":
case "iframe":
@@ -640,6 +668,7 @@ const generateElementShape = (
embedsValidationStatus,
),
true,
isDarkMode,
),
);
} else {
@@ -655,6 +684,7 @@ const generateElementShape = (
embedsValidationStatus,
),
false,
isDarkMode,
),
);
}
@@ -692,7 +722,7 @@ const generateElementShape = (
C ${topX} ${topY}, ${topX} ${topY}, ${topX + verticalRadius} ${
topY + horizontalRadius
}`,
generateRoughOptions(element, true),
generateRoughOptions(element, true, isDarkMode),
);
} else {
shape = generator.polygon(
@@ -702,7 +732,7 @@ const generateElementShape = (
[bottomX, bottomY],
[leftX, leftY],
],
generateRoughOptions(element),
generateRoughOptions(element, false, isDarkMode),
);
}
return shape;
@@ -713,14 +743,14 @@ const generateElementShape = (
element.height / 2,
element.width,
element.height,
generateRoughOptions(element),
generateRoughOptions(element, false, isDarkMode),
);
return shape;
}
case "line":
case "arrow": {
let shape: ElementShapes[typeof element.type];
const options = generateRoughOptions(element);
const options = generateRoughOptions(element, false, isDarkMode);
// points array can be empty in the beginning, so it is important to add
// initial position to it
@@ -745,7 +775,7 @@ const generateElementShape = (
shape = [
generator.path(
generateElbowArrowShape(points, 16),
generateRoughOptions(element, true),
generateRoughOptions(element, true, isDarkMode),
),
];
}
@@ -778,6 +808,7 @@ const generateElementShape = (
generator,
options,
canvasBackgroundColor,
isDarkMode,
);
shape.push(...shapes);
}
@@ -795,6 +826,7 @@ const generateElementShape = (
generator,
options,
canvasBackgroundColor,
isDarkMode,
);
shape.push(...shapes);
}
@@ -802,23 +834,28 @@ const generateElementShape = (
return shape;
}
case "freedraw": {
let shape: ElementShapes[typeof element.type];
generateFreeDrawShape(element);
// oredered in terms of z-index [background, stroke]
const shapes: ElementShapes[typeof element.type] = [];
// (1) background fill (rc shape), optional
if (isPathALoop(element.points)) {
// generate rough polygon to fill freedraw shape
const simplifiedPoints = simplify(
element.points as Mutable<LocalPoint[]>,
0.75,
);
shape = generator.curve(simplifiedPoints as [number, number][], {
...generateRoughOptions(element),
stroke: "none",
});
} else {
shape = null;
shapes.push(
generator.curve(simplifiedPoints as [number, number][], {
...generateRoughOptions(element, false, isDarkMode),
stroke: "none",
}),
);
}
return shape;
// (2) stroke
shapes.push(getFreeDrawSvgPath(element));
return shapes;
}
case "frame":
case "magicframe":
@@ -925,9 +962,7 @@ export const getElementShape = <Point extends GlobalPoint | LocalPoint>(
return getPolygonShape(element);
case "arrow":
case "line": {
const roughShape =
ShapeCache.get(element)?.[0] ??
ShapeCache.generateElementShape(element, null)[0];
const roughShape = ShapeCache.generateElementShape(element, null)[0];
const [, , , , cx, cy] = getElementAbsoluteCoords(element, elementsMap);
return shouldTestInside(element)
@@ -1003,3 +1038,69 @@ export const toggleLinePolygonState = (
return ret;
};
// -----------------------------------------------------------------------------
// freedraw shape helper
// -----------------------------------------------------------------------------
// NOTE not cached (-> for SVG export)
const getFreeDrawSvgPath = (element: ExcalidrawFreeDrawElement) => {
return getSvgPathFromStroke(
getFreedrawOutlinePoints(element),
) as SVGPathString;
};
export const getFreedrawOutlinePoints = (
element: ExcalidrawFreeDrawElement,
) => {
// If input points are empty (should they ever be?) return a dot
const inputPoints = element.simulatePressure
? element.points
: element.points.length
? element.points.map(([x, y], i) => [x, y, element.pressures[i]])
: [[0, 0, 0.5]];
return getStroke(inputPoints as number[][], {
simulatePressure: element.simulatePressure,
size: element.strokeWidth * 4.25,
thinning: 0.6,
smoothing: 0.5,
streamline: 0.5,
easing: (t) => Math.sin((t * Math.PI) / 2), // https://easings.net/#easeOutSine
last: true,
}) as [number, number][];
};
const med = (A: number[], B: number[]) => {
return [(A[0] + B[0]) / 2, (A[1] + B[1]) / 2];
};
// Trim SVG path data so number are each two decimal points. This
// improves SVG exports, and prevents rendering errors on points
// with long decimals.
const TO_FIXED_PRECISION = /(\s?[A-Z]?,?-?[0-9]*\.[0-9]{0,2})(([0-9]|e|-)*)/g;
const getSvgPathFromStroke = (points: number[][]): string => {
if (!points.length) {
return "";
}
const max = points.length - 1;
return points
.reduce(
(acc, point, i, arr) => {
if (i === max) {
acc.push(point, med(point, arr[0]), "L", arr[0], "Z");
} else {
acc.push(point, med(point, arr[i + 1]));
}
return acc;
},
["M", points[0], "Q"],
)
.join(" ")
.replace(TO_FIXED_PRECISION, "$1");
};
// -----------------------------------------------------------------------------
+149 -26
View File
@@ -7,6 +7,7 @@ import {
} from "@excalidraw/common";
import {
bezierEquation,
curve,
curveCatmullRomCubicApproxPoints,
curveOffsetPoints,
@@ -27,19 +28,30 @@ import {
import type { Curve, LineSegment, LocalPoint } from "@excalidraw/math";
import type { NormalizedZoomValue, Zoom } from "@excalidraw/excalidraw/types";
import type {
AppState,
NormalizedZoomValue,
Zoom,
} from "@excalidraw/excalidraw/types";
import { elementCenterPoint, getDiamondPoints } from "./bounds";
import { generateLinearCollisionShape } from "./shape";
import { isPointInElement } from "./collision";
import { hitElementItself, isPointInElement } from "./collision";
import { LinearElementEditor } from "./linearElementEditor";
import { isRectangularElement } from "./typeChecks";
import { maxBindingDistance_simple } from "./binding";
import {
getGlobalFixedPointForBindableElement,
normalizeFixedPoint,
} from "./binding";
import type {
ElementsMap,
ExcalidrawArrowElement,
ExcalidrawBindableElement,
ExcalidrawDiamondElement,
ExcalidrawElement,
ExcalidrawFreeDrawElement,
@@ -329,24 +341,10 @@ export function deconstructRectanguloidElement(
return shape;
}
/**
* Get the **unrotated** building components of a diamond element
* in the form of line segments and curves as a tuple, in this order.
*
* @param element The element to deconstruct
* @param offset An optional offset
* @returns Tuple of line **unrotated** segments (0) and curves (1)
*/
export function deconstructDiamondElement(
export function getDiamondBaseCorners(
element: ExcalidrawDiamondElement,
offset: number = 0,
): [LineSegment<GlobalPoint>[], Curve<GlobalPoint>[]] {
const cachedShape = getElementShapesCacheEntry(element, offset);
if (cachedShape) {
return cachedShape;
}
): Curve<GlobalPoint>[] {
const [topX, topY, rightX, rightY, bottomX, bottomY, leftX, leftY] =
getDiamondPoints(element);
const verticalRadius = element.roundness
@@ -363,7 +361,7 @@ export function deconstructDiamondElement(
pointFrom(element.x + leftX, element.y + leftY),
];
const baseCorners = [
return [
curve(
pointFrom<GlobalPoint>(
right[0] - verticalRadius,
@@ -413,6 +411,27 @@ export function deconstructDiamondElement(
),
), // TOP
];
}
/**
* Get the **unrotated** building components of a diamond element
* in the form of line segments and curves as a tuple, in this order.
*
* @param element The element to deconstruct
* @param offset An optional offset
* @returns Tuple of line **unrotated** segments (0) and curves (1)
*/
export function deconstructDiamondElement(
element: ExcalidrawDiamondElement,
offset: number = 0,
): [LineSegment<GlobalPoint>[], Curve<GlobalPoint>[]] {
const cachedShape = getElementShapesCacheEntry(element, offset);
if (cachedShape) {
return cachedShape;
}
const baseCorners = getDiamondBaseCorners(element, offset);
const corners = baseCorners.map(
(corner) =>
@@ -570,28 +589,128 @@ const getDiagonalsForBindableElement = (
return [diagonalOne, diagonalTwo];
};
export const getSnapOutlineMidPoint = (
point: GlobalPoint,
element: ExcalidrawBindableElement,
elementsMap: ElementsMap,
zoom: AppState["zoom"],
) => {
const center = elementCenterPoint(element, elementsMap);
const sideMidpoints =
element.type === "diamond"
? getDiamondBaseCorners(element).map((curve) => {
const point = bezierEquation(curve, 0.5);
const rotatedPoint = pointRotateRads(point, center, element.angle);
return pointFrom<GlobalPoint>(rotatedPoint[0], rotatedPoint[1]);
})
: [
// RIGHT midpoint
pointRotateRads(
pointFrom<GlobalPoint>(
element.x + element.width,
element.y + element.height / 2,
),
center,
element.angle,
),
// BOTTOM midpoint
pointRotateRads(
pointFrom<GlobalPoint>(
element.x + element.width / 2,
element.y + element.height,
),
center,
element.angle,
),
// LEFT midpoint
pointRotateRads(
pointFrom<GlobalPoint>(element.x, element.y + element.height / 2),
center,
element.angle,
),
// TOP midpoint
pointRotateRads(
pointFrom<GlobalPoint>(element.x + element.width / 2, element.y),
center,
element.angle,
),
];
const candidate = sideMidpoints.find(
(midpoint) =>
pointDistance(point, midpoint) <=
maxBindingDistance_simple(zoom) + element.strokeWidth / 2 &&
!hitElementItself({
point,
element,
threshold: 0,
elementsMap,
overrideShouldTestInside: true,
}),
);
return candidate;
};
export const projectFixedPointOntoDiagonal = (
arrow: ExcalidrawArrowElement,
point: GlobalPoint,
element: ExcalidrawElement,
element: ExcalidrawBindableElement,
startOrEnd: "start" | "end",
elementsMap: ElementsMap,
zoom: AppState["zoom"],
): GlobalPoint | null => {
invariant(arrow.points.length >= 2, "Arrow must have at least two points");
if (arrow.width < 3 && arrow.height < 3) {
return null;
}
const sideMidPoint = getSnapOutlineMidPoint(
point,
element,
elementsMap,
zoom,
);
if (sideMidPoint) {
return sideMidPoint;
}
// Do the projection onto the diagonals (or center lines
// for non-rectangular shapes)
const [diagonalOne, diagonalTwo] = getDiagonalsForBindableElement(
element,
elementsMap,
);
const a = LinearElementEditor.getPointAtIndexGlobalCoordinates(
// To avoid working with stale arrow state, we use the opposite focus point
// of the current endpoint, which will always be unchanged during moving of
// the endpoint. This is only needed when the arrow has only two points.
let a = LinearElementEditor.getPointAtIndexGlobalCoordinates(
arrow,
startOrEnd === "start" ? 1 : arrow.points.length - 2,
elementsMap,
);
if (arrow.points.length === 2) {
const otherBinding =
startOrEnd === "start" ? arrow.endBinding : arrow.startBinding;
const otherBindable =
otherBinding &&
(elementsMap.get(otherBinding.elementId) as
| ExcalidrawBindableElement
| undefined);
const otherFocusPoint =
otherBinding &&
otherBindable &&
getGlobalFixedPointForBindableElement(
normalizeFixedPoint(otherBinding.fixedPoint),
otherBindable,
elementsMap,
);
if (otherFocusPoint) {
a = otherFocusPoint;
}
}
const b = pointFromVector<GlobalPoint>(
vectorScale(
vectorFromPoint(point, a),
@@ -603,18 +722,22 @@ export const projectFixedPointOntoDiagonal = (
),
a,
);
const intersector = lineSegment<GlobalPoint>(point, b);
const intersector = lineSegment<GlobalPoint>(b, a);
const p1 = lineSegmentIntersectionPoints(diagonalOne, intersector);
const p2 = lineSegmentIntersectionPoints(diagonalTwo, intersector);
const d1 = p1 && pointDistance(a, p1);
const d2 = p2 && pointDistance(a, p2);
let p = null;
let projection = null;
if (d1 != null && d2 != null) {
p = d1 < d2 ? p1 : p2;
projection = d1 < d2 ? p1 : p2;
} else {
p = p1 || p2 || null;
projection = p1 || p2 || null;
}
return p && isPointInElement(p, element, elementsMap) ? p : null;
if (projection && isPointInElement(projection, element, elementsMap)) {
return projection;
}
return null;
};
@@ -1,16 +1,24 @@
import {
isLineSegment,
lineSegment,
pointDistanceSq,
pointFrom,
type GlobalPoint,
type LocalPoint,
} from "@excalidraw/math";
import { type Bounds, isBounds } from "@excalidraw/common";
import {
getElementBounds,
intersectElementWithLineSegment,
isFreeDrawElement,
isLinearElement,
isPathALoop,
} from "@excalidraw/element";
import type { ElementsMap, ExcalidrawElement } from "@excalidraw/element/types";
import type { Curve } from "@excalidraw/math";
import type { LineSegment } from "@excalidraw/utils";
import { type Bounds, isBounds } from "./bounds";
// The global data holder to collect the debug operations
declare global {
interface Window {
@@ -23,10 +31,69 @@ declare global {
export type DebugElement = {
color: string;
data: LineSegment<GlobalPoint> | Curve<GlobalPoint>;
data: LineSegment<GlobalPoint> | Curve<GlobalPoint> | DebugPolygon;
permanent: boolean;
};
export type DebugPolygon = {
type: "polygon";
points: GlobalPoint[];
fill?: boolean;
close?: boolean;
};
export const debugDrawHitVolume = (
element: ExcalidrawElement,
elementsMap: ElementsMap,
options?: {
rays?: number;
color?: string;
fill?: boolean;
},
) => {
if (
(isLinearElement(element) || isFreeDrawElement(element)) &&
!isPathALoop(element.points)
) {
return;
}
const [x1, y1, x2, y2] = getElementBounds(element, elementsMap);
const center = pointFrom<GlobalPoint>((x1 + x2) / 2, (y1 + y2) / 2);
const rays = options?.rays ?? 100;
const radius = Math.max(x2 - x1, y2 - y1) * 2;
const points: GlobalPoint[] = [];
for (let i = 0; i < rays; i += 1) {
const angle = (i / rays) * Math.PI * 2;
const end = pointFrom<GlobalPoint>(
center[0] + Math.cos(angle) * radius,
center[1] + Math.sin(angle) * radius,
);
const hits = intersectElementWithLineSegment(
element,
elementsMap,
lineSegment(center, end),
);
if (hits.length === 0) {
continue;
}
hits.sort(pointDistanceSq);
points.push(hits[0]);
}
if (points.length >= 3) {
debugDrawPolygon(points, {
color: options?.color ?? "orange",
fill: options?.fill ?? true,
});
} else {
console.warn(
`debugDrawHitVolume: could not compute hit volume for element ${element.id}`,
);
}
};
export const debugDrawCubicBezier = (
c: Curve<GlobalPoint>,
opts?: {
@@ -61,6 +128,31 @@ export const debugDrawLine = (
);
};
export const debugDrawPolygon = (
points: GlobalPoint[],
opts?: {
color?: string;
permanent?: boolean;
fill?: boolean;
close?: boolean;
},
) => {
if (points.length < 2) {
return;
}
addToCurrentFrame({
color: opts?.color ?? "orange",
permanent: !!opts?.permanent,
data: {
type: "polygon",
points,
fill: opts?.fill,
close: opts?.close,
},
});
};
export const debugDrawPoint = (
p: GlobalPoint,
opts?: {
@@ -101,7 +193,7 @@ export const debugDrawBounds = (
permanent?: boolean;
},
) => {
(isBounds(box) ? [box] : box).forEach((bbox) =>
(isBounds(box) ? [box] : box).forEach((bbox: Bounds) =>
debugDrawLine(
[
lineSegment(
+4 -5
View File
@@ -156,12 +156,11 @@ export const moveArrowAboveBindable = (
elements: readonly Ordered<NonDeletedExcalidrawElement>[],
elementsMap: NonDeletedSceneElementsMap,
scene: Scene,
hit?: NonDeletedExcalidrawElement,
): readonly OrderedExcalidrawElement[] => {
const hoveredElement = getHoveredElementForBinding(
point,
elements,
elementsMap,
);
const hoveredElement = hit
? hit
: getHoveredElementForBinding(point, elements, elementsMap);
if (!hoveredElement) {
return elements;
@@ -17,7 +17,7 @@ exports[`Test Linear Elements > Test bound text element > should match styles fo
class="excalidraw-wysiwyg"
data-type="wysiwyg"
dir="auto"
style="position: absolute; display: inline-block; min-height: 1em; backface-visibility: hidden; margin: 0px; padding: 0px; border: 0px; outline: 0; resize: none; background: transparent; overflow: hidden; z-index: var(--zIndex-wysiwyg); word-break: break-word; white-space: pre-wrap; overflow-wrap: break-word; box-sizing: content-box; width: 10.5px; height: 26.25px; left: 35px; top: 7.5px; transform: translate(0px, 0px) scale(1) rotate(0deg); text-align: center; vertical-align: middle; color: rgb(30, 30, 30); opacity: 1; filter: var(--theme-filter); max-height: 992.5px; font: Emoji 20px 20px; line-height: 1.25; font-family: Excalifont, Xiaolai, sans-serif, Segoe UI Emoji;"
style="position: absolute; display: inline-block; min-height: 1em; backface-visibility: hidden; margin: 0px; padding: 0px; border: 0px; outline: 0; resize: none; background: transparent; overflow: hidden; z-index: var(--zIndex-wysiwyg); word-break: break-word; white-space: pre-wrap; overflow-wrap: break-word; box-sizing: content-box; width: 10.5px; height: 26.25px; left: 35px; top: 7.5px; transform: translate(0px, 0px) scale(1) rotate(0deg); text-align: center; vertical-align: middle; color: rgb(30, 30, 30); opacity: 1; max-height: 992.5px; font: Emoji 20px 20px; line-height: 1.25; font-family: Excalifont, Xiaolai, sans-serif, Segoe UI Emoji;"
tabindex="0"
wrap="off"
/>
+182 -2
View File
@@ -1,9 +1,12 @@
import { arrayToMap } from "@excalidraw/common";
import { type GlobalPoint, type LocalPoint, pointFrom } from "@excalidraw/math";
import { Excalidraw } from "@excalidraw/excalidraw";
import { API } from "@excalidraw/excalidraw/tests/helpers/api";
import { UI } from "@excalidraw/excalidraw/tests/helpers/ui";
import "@excalidraw/utils/test-utils";
import { render } from "@excalidraw/excalidraw/tests/test-utils";
import * as distance from "../src/distance";
import { hitElementItself } from "../src/collision";
describe("check rotated elements can be hit:", () => {
@@ -25,8 +28,6 @@ describe("check rotated elements can be hit:", () => {
[-4, -302],
] as LocalPoint[],
});
//const p = [120, -211];
//const p = [0, 13];
const hit = hitElementItself({
point: pointFrom<GlobalPoint>(88, -68),
element: window.h.elements[0],
@@ -36,3 +37,182 @@ describe("check rotated elements can be hit:", () => {
expect(hit).toBe(true);
});
});
describe("hitElementItself cache", () => {
beforeEach(async () => {
// reset cache
hitElementItself({
point: pointFrom<GlobalPoint>(50, 50),
element: API.createElement({
type: "rectangle",
x: 0,
y: 0,
width: 100,
height: 100,
backgroundColor: "#ffffff",
}),
threshold: Infinity,
elementsMap: new Map([]),
});
localStorage.clear();
await render(<Excalidraw handleKeyboardGlobally={true} />);
});
it("reuses cached result when threshold increases", () => {
const element = API.createElement({
type: "rectangle",
x: 0,
y: 0,
width: 100,
height: 100,
backgroundColor: "#ffffff",
});
const elementsMap = arrayToMap([element]);
const point = pointFrom<GlobalPoint>(100.5, 50);
const distanceSpy = jest.spyOn(distance, "distanceToElement");
expect(
hitElementItself({
point,
element,
threshold: 1,
elementsMap,
}),
).toBe(true);
expect(distanceSpy).toHaveBeenCalledTimes(1);
expect(
hitElementItself({
point,
element,
threshold: 10,
elementsMap,
}),
).toBe(true);
expect(distanceSpy).toHaveBeenCalledTimes(1);
distanceSpy.mockRestore();
});
it("does not reuse cache when threshold decreases", () => {
const element = API.createElement({
type: "rectangle",
x: 0,
y: 0,
width: 100,
height: 100,
backgroundColor: "transparent",
});
const elementsMap = arrayToMap([element]);
const point = pointFrom<GlobalPoint>(105, 50);
const distanceSpy = jest.spyOn(distance, "distanceToElement");
expect(
hitElementItself({
point,
element,
threshold: 10,
elementsMap,
}),
).toBe(true);
expect(distanceSpy).toHaveBeenCalledTimes(1);
expect(
hitElementItself({
point,
element,
threshold: 6,
elementsMap,
}),
).toBe(true);
expect(distanceSpy).toHaveBeenCalledTimes(2);
distanceSpy.mockRestore();
});
it("invalidates cache when element version changes", () => {
const element = API.createElement({
type: "rectangle",
x: 0,
y: 0,
width: 100,
height: 100,
backgroundColor: "#ffffff",
});
const elementsMap = arrayToMap([element]);
const point = pointFrom<GlobalPoint>(100.5, 50);
const distanceSpy = jest.spyOn(distance, "distanceToElement");
expect(
hitElementItself({
point,
element,
threshold: 1,
elementsMap,
}),
).toBe(true);
expect(distanceSpy).toHaveBeenCalledTimes(1);
const movedElement = {
...element,
version: element.version + 1,
versionNonce: element.versionNonce + 1,
};
expect(
hitElementItself({
point,
element: movedElement,
threshold: 1,
elementsMap,
}),
).toBe(true);
expect(distanceSpy).toHaveBeenCalledTimes(2);
distanceSpy.mockRestore();
});
it("override does not affect caching", () => {
const element = API.createElement({
type: "rectangle",
x: 0,
y: 0,
width: 100,
height: 100,
backgroundColor: "transparent",
});
const elementsMap = arrayToMap([element]);
const point = pointFrom<GlobalPoint>(50, 50);
const distanceSpy = jest.spyOn(distance, "distanceToElement");
expect(
hitElementItself({
point,
element,
threshold: 10,
elementsMap,
}),
).toBe(false);
expect(distanceSpy).toHaveBeenCalledTimes(1);
expect(
hitElementItself({
point,
element,
threshold: 10,
elementsMap,
overrideShouldTestInside: true,
}),
).toBe(true);
});
});
+81 -1
View File
@@ -1,4 +1,4 @@
import { getEmbedLink } from "../src/embeddable";
import { embeddableURLValidator, getEmbedLink } from "../src/embeddable";
describe("YouTube timestamp parsing", () => {
it("should parse YouTube URLs with timestamp in seconds", () => {
@@ -151,3 +151,83 @@ describe("YouTube timestamp parsing", () => {
}
});
});
describe("Google Drive video embedding", () => {
it.each([
{
url: "https://drive.google.com/file/d/1AbCdEfGhIjKlMnOpQrStUvWxYz123456/view?usp=sharing",
expectedLink:
"https://drive.google.com/file/d/1AbCdEfGhIjKlMnOpQrStUvWxYz123456/preview",
},
{
url: "https://drive.google.com/open?id=1AbCdEfGhIjKlMnOpQrStUvWxYz123456",
expectedLink:
"https://drive.google.com/file/d/1AbCdEfGhIjKlMnOpQrStUvWxYz123456/preview",
},
{
url: "https://drive.google.com/uc?export=download&id=1AbCdEfGhIjKlMnOpQrStUvWxYz123456",
expectedLink:
"https://drive.google.com/file/d/1AbCdEfGhIjKlMnOpQrStUvWxYz123456/preview",
},
])("should normalize Google Drive link: $url", ({ url, expectedLink }) => {
const result = getEmbedLink(url);
expect(result).toBeTruthy();
expect(result?.type).toBe("video");
if (result?.type === "video" || result?.type === "generic") {
expect(result.link).toBe(expectedLink);
}
expect(result?.intrinsicSize).toEqual({ w: 560, h: 315 });
});
it("should preserve resourcekey when available", () => {
const url =
"https://drive.google.com/file/d/1AbCdEfGhIjKlMnOpQrStUvWxYz123456/view?resourcekey=0-abcdef123456";
const result = getEmbedLink(url);
expect(result).toBeTruthy();
expect(result?.type).toBe("video");
if (result?.type === "video" || result?.type === "generic") {
expect(result.link).toBe(
"https://drive.google.com/file/d/1AbCdEfGhIjKlMnOpQrStUvWxYz123456/preview?resourcekey=0-abcdef123456",
);
}
});
it("should preserve timestamp when available", () => {
const url =
"https://drive.google.com/file/d/1AbCdEfGhIjKlMnOpQrStUvWxYz123456/view?t=9";
const result = getEmbedLink(url);
expect(result).toBeTruthy();
expect(result?.type).toBe("video");
if (result?.type === "video" || result?.type === "generic") {
expect(result.link).toBe(
"https://drive.google.com/file/d/1AbCdEfGhIjKlMnOpQrStUvWxYz123456/preview?t=9",
);
}
});
it("should preserve resourcekey and timestamp together", () => {
const url =
"https://drive.google.com/file/d/1AbCdEfGhIjKlMnOpQrStUvWxYz123456/view?resourcekey=0-abcdef123456&t=9";
const result = getEmbedLink(url);
expect(result).toBeTruthy();
expect(result?.type).toBe("video");
if (result?.type === "video" || result?.type === "generic") {
expect(result.link).toBe(
"https://drive.google.com/file/d/1AbCdEfGhIjKlMnOpQrStUvWxYz123456/preview?resourcekey=0-abcdef123456&t=9",
);
}
});
it("should validate Google Drive domain by default", () => {
expect(
embeddableURLValidator(
"https://drive.google.com/file/d/1AbCdEfGhIjKlMnOpQrStUvWxYz123456/view",
undefined,
),
).toBe(true);
});
});
@@ -218,7 +218,7 @@ describe("Test Linear Elements", () => {
// drag line from midpoint
drag(midpoint, pointFrom(midpoint[0] + delta, midpoint[1] + delta));
expect(renderInteractiveScene.mock.calls.length).toMatchInlineSnapshot(`8`);
expect(renderStaticScene.mock.calls.length).toMatchInlineSnapshot(`7`);
expect(renderStaticScene.mock.calls.length).toMatchInlineSnapshot(`6`);
expect(line.points.length).toEqual(3);
expect(line.points).toMatchInlineSnapshot(`
[
@@ -378,7 +378,7 @@ describe("Test Linear Elements", () => {
// drag line from midpoint
drag(midpoint, pointFrom(midpoint[0] + delta, midpoint[1] + delta));
expect(renderInteractiveScene.mock.calls.length).toMatchInlineSnapshot(
`11`,
`12`,
);
expect(renderStaticScene.mock.calls.length).toMatchInlineSnapshot(`7`);
@@ -419,7 +419,7 @@ describe("Test Linear Elements", () => {
fireEvent.click(screen.getByTitle("Round"));
expect(renderInteractiveScene.mock.calls.length).toMatchInlineSnapshot(
`9`,
`10`,
);
expect(renderStaticScene.mock.calls.length).toMatchInlineSnapshot(`6`);
@@ -480,7 +480,7 @@ describe("Test Linear Elements", () => {
drag(startPoint, endPoint);
expect(renderInteractiveScene.mock.calls.length).toMatchInlineSnapshot(
`11`,
`12`,
);
expect(renderStaticScene.mock.calls.length).toMatchInlineSnapshot(`7`);
@@ -548,7 +548,7 @@ describe("Test Linear Elements", () => {
);
expect(renderInteractiveScene.mock.calls.length).toMatchInlineSnapshot(
`14`,
`15`,
);
expect(renderStaticScene.mock.calls.length).toMatchInlineSnapshot(`9`);
@@ -599,7 +599,7 @@ describe("Test Linear Elements", () => {
drag(hitCoords, pointFrom(hitCoords[0] - delta, hitCoords[1] - delta));
expect(renderInteractiveScene.mock.calls.length).toMatchInlineSnapshot(
`11`,
`12`,
);
expect(renderStaticScene.mock.calls.length).toMatchInlineSnapshot(`7`);
@@ -640,7 +640,7 @@ describe("Test Linear Elements", () => {
drag(hitCoords, pointFrom(hitCoords[0] + delta, hitCoords[1] + delta));
expect(renderInteractiveScene.mock.calls.length).toMatchInlineSnapshot(
`11`,
`12`,
);
expect(renderStaticScene.mock.calls.length).toMatchInlineSnapshot(`7`);
@@ -688,7 +688,7 @@ describe("Test Linear Elements", () => {
deletePoint(points[2]);
expect(line.points.length).toEqual(3);
expect(renderInteractiveScene.mock.calls.length).toMatchInlineSnapshot(
`17`,
`18`,
);
expect(renderStaticScene.mock.calls.length).toMatchInlineSnapshot(`10`);
@@ -746,7 +746,7 @@ describe("Test Linear Elements", () => {
),
);
expect(renderInteractiveScene.mock.calls.length).toMatchInlineSnapshot(
`14`,
`15`,
);
expect(renderStaticScene.mock.calls.length).toMatchInlineSnapshot(`9`);
expect(line.points.length).toEqual(5);
@@ -844,7 +844,7 @@ describe("Test Linear Elements", () => {
drag(hitCoords, pointFrom(hitCoords[0] + delta, hitCoords[1] + delta));
expect(renderInteractiveScene.mock.calls.length).toMatchInlineSnapshot(
`11`,
`12`,
);
expect(renderStaticScene.mock.calls.length).toMatchInlineSnapshot(`7`);
@@ -1317,7 +1317,7 @@ describe("Test Linear Elements", () => {
const textElement = h.elements[2] as ExcalidrawTextElementWithContainer;
expect(arrow.endBinding?.elementId).toBe(rect.id);
expect(arrow.width).toBeCloseTo(399);
expect(arrow.width).toBeCloseTo(404);
expect(rect.x).toBe(400);
expect(rect.y).toBe(0);
expect(
@@ -1336,7 +1336,7 @@ describe("Test Linear Elements", () => {
mouse.downAt(rect.x, rect.y);
mouse.moveTo(200, 0);
mouse.upAt(200, 0);
expect(arrow.width).toBeCloseTo(199);
expect(arrow.width).toBeCloseTo(204);
expect(rect.x).toBe(200);
expect(rect.y).toBe(0);
expect(handleBindTextResizeSpy).toHaveBeenCalledWith(
+2 -2
View File
@@ -1350,8 +1350,8 @@ describe("multiple selection", () => {
expect(boundArrow.x).toBeCloseTo(380 * scaleX);
expect(boundArrow.y).toBeCloseTo(240 * scaleY);
expect(boundArrow.points[1][0]).toBeCloseTo(59.7979);
expect(boundArrow.points[1][1]).toBeCloseTo(-79.7305);
expect(boundArrow.points[1][0]).toBeCloseTo(63.40354208105561);
expect(boundArrow.points[1][1]).toBeCloseTo(-84.53805610807356);
expect(arrowLabelPos.x + arrowLabel.width / 2).toBeCloseTo(
boundArrow.x + boundArrow.points[1][0] / 2,
@@ -58,6 +58,7 @@ const distributeSelectedElements = (
app.scene.getNonDeletedElementsMap(),
distribution,
appState,
app.scene,
);
const updatedElementsMap = arrayToMap(updatedElements);
+11 -5
View File
@@ -82,7 +82,10 @@ export const actionFinalize = register<FormData>({
app.scene,
);
if (isBindingElement(element)) {
if (
isBindingElement(element) &&
!appState.selectedLinearElement.segmentMidPointHoveredCoords
) {
const newArrow = !!appState.newElement;
const selectedPointsIndices =
@@ -95,19 +98,21 @@ export const actionFinalize = register<FormData>({
map.set(index, {
point: LinearElementEditor.pointFromAbsoluteCoords(
element,
pointFrom<GlobalPoint>(sceneCoords.x, sceneCoords.y),
pointFrom<GlobalPoint>(
sceneCoords.x - linearElementEditor.pointerOffset.x,
sceneCoords.y - linearElementEditor.pointerOffset.y,
),
elementsMap,
),
});
return map;
}, new Map()) ?? new Map();
bindOrUnbindBindingElement(
element,
draggedPoints,
sceneCoords.x,
sceneCoords.y,
sceneCoords.x - linearElementEditor.pointerOffset.x,
sceneCoords.y - linearElementEditor.pointerOffset.y,
scene,
appState,
{
@@ -170,6 +175,7 @@ export const actionFinalize = register<FormData>({
...linearElementEditor.initialState,
lastClickedPoint: -1,
},
pointerOffset: { x: 0, y: 0 },
},
selectionElement: null,
suggestedBinding: null,
@@ -1555,7 +1555,7 @@ const getArrowheadOptions = (flip: boolean) => {
value: null,
text: t("labels.arrowhead_none"),
keyBinding: "q",
icon: ArrowheadNoneIcon,
icon: <ArrowheadNoneIcon flip={flip} />,
},
{
value: "arrow",
@@ -1683,7 +1683,8 @@ export const actionChangeArrowhead = register<{
? element.startArrowhead
: appState.currentItemStartArrowhead,
true,
appState.currentItemStartArrowhead,
(hasSelection) =>
hasSelection ? null : appState.currentItemStartArrowhead,
)}
onChange={(value) => updateData({ position: "start", type: value })}
numberOfOptionsToAlwaysShow={4}
@@ -1700,7 +1701,8 @@ export const actionChangeArrowhead = register<{
? element.endArrowhead
: appState.currentItemEndArrowhead,
true,
appState.currentItemEndArrowhead,
(hasSelection) =>
hasSelection ? null : appState.currentItemEndArrowhead,
)}
onChange={(value) => updateData({ position: "end", type: value })}
numberOfOptionsToAlwaysShow={4}
+3 -1
View File
@@ -55,7 +55,8 @@ export type ShortcutName =
| "saveScene"
| "imageExport"
| "commandPalette"
| "searchMenu";
| "searchMenu"
| "toolLock";
const shortcutMap: Record<ShortcutName, string[]> = {
toggleTheme: [getShortcutKey("Shift+Alt+D")],
@@ -117,6 +118,7 @@ const shortcutMap: Record<ShortcutName, string[]> = {
toggleShortcuts: [getShortcutKey("?")],
searchMenu: [getShortcutKey("CtrlOrCmd+F")],
wrapSelectionInFrame: [],
toolLock: [getShortcutKey("Q")],
};
export const getShortcutFromShortcutName = (name: ShortcutName, idx = 0) => {
+69 -25
View File
@@ -204,8 +204,13 @@ export const copyToClipboard = async (
/** supply if available to make the operation more certain to succeed */
clipboardEvent?: ClipboardEvent | null,
) => {
const json = serializeAsClipboardJSON({ elements, files });
await copyTextToSystemClipboard(
serializeAsClipboardJSON({ elements, files }),
{
[MIME_TYPES.excalidrawClipboard]: json,
[MIME_TYPES.text]: json,
},
clipboardEvent,
);
};
@@ -401,7 +406,7 @@ export type ParsedDataTransferFile = Extract<
{ kind: "file" }
>;
type ParsedDataTranferList = ParsedDataTransferItem[] & {
export type ParsedDataTranferList = ParsedDataTransferItem[] & {
/**
* Only allows filtering by known `string` data types, since `file`
* types can have multiple items of the same type (e.g. multiple image files)
@@ -452,6 +457,29 @@ const getDataTransferFiles = function (
);
};
/** @returns list of MIME types, synchronously */
export const parseDataTransferEventMimeTypes = (
event: ClipboardEvent | DragEvent | React.DragEvent<HTMLDivElement>,
): Set<string> => {
let items: DataTransferItemList | undefined = undefined;
if (isClipboardEvent(event)) {
items = event.clipboardData?.items;
} else {
items = event.dataTransfer?.items;
}
const types: Set<string> = new Set();
for (const item of Array.from(items || [])) {
if (!types.has(item.type)) {
types.add(item.type);
}
}
return types;
};
export const parseDataTransferEvent = async (
event: ClipboardEvent | DragEvent | React.DragEvent<HTMLDivElement>,
): Promise<ParsedDataTranferList> => {
@@ -460,8 +488,7 @@ export const parseDataTransferEvent = async (
if (isClipboardEvent(event)) {
items = event.clipboardData?.items;
} else {
const dragEvent = event;
items = dragEvent.dataTransfer?.items;
items = event.dataTransfer?.items;
}
const dataItems = (
@@ -567,7 +594,7 @@ export const copyBlobToClipboardAsPng = async (blob: Blob | Promise<Blob>) => {
// ClipboardItem constructor, but throws on an unrelated MIME type error.
// So we need to await this and fallback to awaiting the blob if applicable.
await navigator.clipboard.write([
new window.ClipboardItem({
new ClipboardItem({
[MIME_TYPES.png]: blob,
}),
]);
@@ -576,7 +603,7 @@ export const copyBlobToClipboardAsPng = async (blob: Blob | Promise<Blob>) => {
// with resolution value instead
if (isPromiseLike(blob)) {
await navigator.clipboard.write([
new window.ClipboardItem({
new ClipboardItem({
[MIME_TYPES.png]: await blob,
}),
]);
@@ -586,28 +613,27 @@ export const copyBlobToClipboardAsPng = async (blob: Blob | Promise<Blob>) => {
}
};
export const copyTextToSystemClipboard = async (
text: string | null,
export const copyTextToSystemClipboard = async <
MimeType extends ValueOf<typeof STRING_MIME_TYPES>,
>(
text: string | { [K in MimeType]: string } | null,
clipboardEvent?: ClipboardEvent | null,
) => {
// (1) first try using Async Clipboard API
if (probablySupportsClipboardWriteText) {
try {
// NOTE: doesn't work on FF on non-HTTPS domains, or when document
// not focused
await navigator.clipboard.writeText(text || "");
return;
} catch (error: any) {
console.error(error);
}
}
text = text || "";
// (2) if fails and we have access to ClipboardEvent, use plain old setData()
const entries = Object.entries(
typeof text === "string" ? { [MIME_TYPES.text]: text } : text,
);
// (1) if we have clipboardEvent, try using it first as it's the most
// versatile
try {
if (clipboardEvent) {
clipboardEvent.clipboardData?.setData(MIME_TYPES.text, text || "");
if (clipboardEvent.clipboardData?.getData(MIME_TYPES.text) !== text) {
throw new Error("Failed to setData on clipboardEvent");
for (const [mimeType, value] of entries) {
clipboardEvent.clipboardData?.setData(mimeType, value);
if (clipboardEvent.clipboardData?.getData(mimeType) !== value) {
throw new Error("Failed to setData on clipboardEvent");
}
}
return;
}
@@ -615,8 +641,26 @@ export const copyTextToSystemClipboard = async (
console.error(error);
}
// (3) if that fails, use document.execCommand
if (!copyTextViaExecCommand(text)) {
const plainTextEntry = entries.find(
([mimeType]) => mimeType === MIME_TYPES.text,
);
// (2) if we don't have access to clipboardEvent, or that fails,
// at least try setting text/plain via navigator.clipboard.writeText
// (navigator.clipboard.write doesn't work with non-standard mime types)
if (probablySupportsClipboardWriteText && plainTextEntry) {
try {
// NOTE: doesn't work on FF on non-HTTPS domains, or when document
// not focused
await navigator.clipboard.writeText(plainTextEntry[1]);
return;
} catch (error: any) {
console.error(error);
}
}
// (3) if previous fails, use document.execCommand
if (plainTextEntry && !copyTextViaExecCommand(plainTextEntry[1])) {
throw new Error("Error copying to clipboard.");
}
};
+2 -2
View File
@@ -1,6 +1,6 @@
import clsx from "clsx";
import { useRef, useState } from "react";
import * as Popover from "@radix-ui/react-popover";
import { Popover } from "radix-ui";
import {
CLASSES,
@@ -1263,9 +1263,9 @@ export const ShapesSwitcher = ({
onSelect={() => app.onMagicframeToolSelect()}
icon={MagicIcon}
data-testid="toolbar-magicframe"
badge={<DropdownMenu.Item.Badge>AI</DropdownMenu.Item.Badge>}
>
{t("toolBar.magicframe")}
<DropdownMenu.Item.Badge>AI</DropdownMenu.Item.Badge>
</DropdownMenu.Item>
)}
</DropdownMenu.Content>
+367 -153
View File
@@ -11,7 +11,6 @@ import {
pointDistance,
vector,
pointRotateRads,
vectorScale,
vectorFromPoint,
vectorSubtract,
vectorDot,
@@ -47,7 +46,6 @@ import {
TAP_TWICE_TIMEOUT,
TEXT_TO_CENTER_SNAP_THRESHOLD,
THEME,
THEME_FILTER,
TOUCH_CTX_MENU_TIMEOUT,
VERTICAL_ALIGN,
YOUTUBE_STATES,
@@ -89,6 +87,7 @@ import {
getDateTime,
isShallowEqual,
arrayToMap,
applyDarkModeFilter,
type EXPORT_IMAGE_TYPES,
randomInteger,
CLASSES,
@@ -250,6 +249,13 @@ import {
maxBindingDistance_simple,
convertToExcalidrawElements,
type ExcalidrawElementSkeleton,
getSnapOutlineMidPoint,
handleFocusPointDrag,
handleFocusPointHover,
handleFocusPointPointerDown,
handleFocusPointPointerUp,
maybeHandleArrowPointlikeDrag,
getUncroppedWidthAndHeight,
} from "@excalidraw/element";
import type { GlobalPoint, LocalPoint, Radians } from "@excalidraw/math";
@@ -346,7 +352,7 @@ import {
import { exportCanvas, loadFromBlob } from "../data";
import Library, { distributeLibraryItemsOnSquareGrid } from "../data/library";
import { restore, restoreElements } from "../data/restore";
import { restoreAppState, restoreElements } from "../data/restore";
import { getCenter, getDistance } from "../gesture";
import { History } from "../history";
import { defaultLang, getLanguage, languages, setLanguage, t } from "../i18n";
@@ -436,10 +442,7 @@ import { searchItemInFocusAtom } from "./SearchMenu";
import { isSidebarDockedAtom } from "./Sidebar/Sidebar";
import { StaticCanvas, InteractiveCanvas } from "./canvases";
import NewElementCanvas from "./canvases/NewElementCanvas";
import {
isPointHittingLink,
isPointHittingLinkIcon,
} from "./hyperlink/helpers";
import { isPointHittingLink } from "./hyperlink/helpers";
import { MagicIcon, copyIcon, fullscreenIcon } from "./icons";
import { Toast } from "./Toast";
@@ -643,7 +646,10 @@ class App extends React.Component<AppProps, AppState> {
lastPointerUpEvent: React.PointerEvent<HTMLElement> | PointerEvent | null =
null;
lastPointerMoveEvent: PointerEvent | null = null;
/** current frame pointer cords */
lastPointerMoveCoords: { x: number; y: number } | null = null;
/** previous frame pointer coords */
previousPointerMoveCoords: { x: number; y: number } | null = null;
lastViewportPosition = { x: 0, y: 0 };
animationFrameHandler = new AnimationFrameHandler();
@@ -1201,12 +1207,99 @@ class App extends React.Component<AppProps, AppState> {
return this.iFrameRefs.get(element.id);
}
private handleEmbeddableCenterClick(element: ExcalidrawIframeLikeElement) {
private handleIframeLikeElementHover = ({
hitElement,
scenePointer,
moveEvent,
}: {
hitElement: NonDeleted<ExcalidrawElement> | null;
scenePointer: { x: number; y: number };
moveEvent: React.PointerEvent<HTMLCanvasElement>;
}): boolean => {
if (
this.state.activeEmbeddable?.element === element &&
hitElement &&
isIframeLikeElement(hitElement) &&
this.isIframeLikeElementCenter(
hitElement,
moveEvent,
scenePointer.x,
scenePointer.y,
)
) {
setCursor(this.interactiveCanvas, CURSOR_TYPE.POINTER);
this.setState({
activeEmbeddable: { element: hitElement, state: "hover" },
});
return true;
} else if (this.state.activeEmbeddable?.state === "hover") {
this.setState({ activeEmbeddable: null });
}
return false;
};
/** @returns true if iframe-like element click handled */
private handleIframeLikeCenterClick(): boolean {
if (!this.lastPointerDownEvent || !this.lastPointerUpEvent) {
return false;
}
const scenePointerStart = viewportCoordsToSceneCoords(
{
clientX: this.lastPointerDownEvent.clientX,
clientY: this.lastPointerDownEvent.clientY,
},
this.state,
);
const scenePointerEnd = viewportCoordsToSceneCoords(
{
clientX: this.lastPointerUpEvent.clientX,
clientY: this.lastPointerUpEvent.clientY,
},
this.state,
);
const hitElementStart = this.getElementAtPosition(
scenePointerStart.x,
scenePointerStart.y,
);
const hitElementEnd = this.getElementAtPosition(
scenePointerEnd.x,
scenePointerEnd.y,
);
if (
!hitElementStart ||
!hitElementEnd ||
hitElementStart !== hitElementEnd ||
this.lastPointerUpEvent.timeStamp - this.lastPointerDownEvent.timeStamp >
300 ||
gesture.pointers.size > 1 ||
!isIframeLikeElement(hitElementStart) ||
!isIframeLikeElement(hitElementEnd) ||
!this.isIframeLikeElementCenter(
hitElementStart,
this.lastPointerUpEvent,
scenePointerStart.x,
scenePointerStart.y,
) ||
!this.isIframeLikeElementCenter(
hitElementEnd,
this.lastPointerUpEvent,
scenePointerEnd.x,
scenePointerEnd.y,
)
) {
return false;
}
const iframeLikeElement = hitElementEnd;
if (
this.state.activeEmbeddable?.element === iframeLikeElement &&
this.state.activeEmbeddable?.state === "active"
) {
return;
return true;
}
// The delay serves two purposes
@@ -1217,31 +1310,34 @@ class App extends React.Component<AppProps, AppState> {
// in fullscreen mode
setTimeout(() => {
this.setState({
activeEmbeddable: { element, state: "active" },
selectedElementIds: { [element.id]: true },
activeEmbeddable: { element: iframeLikeElement, state: "active" },
selectedElementIds: { [iframeLikeElement.id]: true },
newElement: null,
selectionElement: null,
});
}, 100);
if (isIframeElement(element)) {
return;
if (isIframeElement(iframeLikeElement)) {
return true;
}
const iframe = this.getHTMLIFrameElement(element);
const iframe = this.getHTMLIFrameElement(iframeLikeElement);
if (!iframe?.contentWindow) {
return;
return true;
}
if (iframe.src.includes("youtube")) {
const state = YOUTUBE_VIDEO_STATES.get(element.id);
const state = YOUTUBE_VIDEO_STATES.get(iframeLikeElement.id);
if (!state) {
YOUTUBE_VIDEO_STATES.set(element.id, YOUTUBE_STATES.UNSTARTED);
YOUTUBE_VIDEO_STATES.set(
iframeLikeElement.id,
YOUTUBE_STATES.UNSTARTED,
);
iframe.contentWindow.postMessage(
JSON.stringify({
event: "listening",
id: element.id,
id: iframeLikeElement.id,
}),
"*",
);
@@ -1278,6 +1374,8 @@ class App extends React.Component<AppProps, AppState> {
"*",
);
}
return true;
}
private isIframeLikeElementCenter(
@@ -1770,8 +1868,9 @@ class App extends React.Component<AppProps, AppState> {
}
}}
style={{
background: this.state.viewBackgroundColor,
filter: isDarkTheme ? THEME_FILTER : "none",
background: isDarkTheme
? applyDarkModeFilter(this.state.viewBackgroundColor)
: this.state.viewBackgroundColor,
zIndex: 2,
border: "none",
display: "block",
@@ -1781,7 +1880,9 @@ class App extends React.Component<AppProps, AppState> {
fontFamily: "Assistant",
fontSize: `${FRAME_STYLE.nameFontSize}px`,
transform: `translate(-${FRAME_NAME_EDIT_PADDING}px, ${FRAME_NAME_EDIT_PADDING}px)`,
color: "var(--color-gray-80)",
color: isDarkTheme
? FRAME_STYLE.nameColorDarkTheme
: FRAME_STYLE.nameColorLightTheme,
overflow: "hidden",
maxWidth: `${
document.body.clientWidth - x1 - FRAME_NAME_EDIT_PADDING
@@ -2116,6 +2217,7 @@ class App extends React.Component<AppProps, AppState> {
elementsPendingErasure: this.elementsPendingErasure,
pendingFlowchartNodes:
this.flowChartCreator.pendingNodes,
theme: this.state.theme,
}}
/>
{this.state.newElement && (
@@ -2136,6 +2238,7 @@ class App extends React.Component<AppProps, AppState> {
elementsPendingErasure:
this.elementsPendingErasure,
pendingFlowchartNodes: null,
theme: this.state.theme,
}}
/>
)}
@@ -2701,46 +2804,47 @@ class App extends React.Component<AppProps, AppState> {
},
};
}
const scene = restore(initialData, null, null, {
const restoredElements = restoreElements(initialData?.elements, null, {
repairBindings: true,
deleteInvisibleElements: true,
});
const activeTool = scene.appState.activeTool;
let restoredAppState = restoreAppState(initialData?.appState, null);
const activeTool = restoredAppState.activeTool;
if (!scene.appState.preferredSelectionTool.initialized) {
scene.appState.preferredSelectionTool = {
if (!restoredAppState.preferredSelectionTool.initialized) {
restoredAppState.preferredSelectionTool = {
type:
this.editorInterface.formFactor === "phone" ? "lasso" : "selection",
initialized: true,
};
}
scene.appState = {
...scene.appState,
theme: this.props.theme || scene.appState.theme,
restoredAppState = {
...restoredAppState,
theme: this.props.theme || restoredAppState.theme,
// we're falling back to current (pre-init) state when deciding
// whether to open the library, to handle a case where we
// update the state outside of initialData (e.g. when loading the app
// with a library install link, which should auto-open the library)
openSidebar: scene.appState?.openSidebar || this.state.openSidebar,
openSidebar: restoredAppState?.openSidebar || this.state.openSidebar,
activeTool:
activeTool.type === "image" ||
activeTool.type === "lasso" ||
activeTool.type === "selection"
? {
...activeTool,
type: scene.appState.preferredSelectionTool.type,
type: restoredAppState.preferredSelectionTool.type,
}
: scene.appState.activeTool,
: restoredAppState.activeTool,
isLoading: false,
toast: this.state.toast,
};
if (initialData?.scrollToContent) {
scene.appState = {
...scene.appState,
...calculateScrollCenter(scene.elements, {
...scene.appState,
restoredAppState = {
...restoredAppState,
...calculateScrollCenter(restoredElements, {
...restoredAppState,
width: this.state.width,
height: this.state.height,
offsetTop: this.state.offsetTop,
@@ -2752,7 +2856,9 @@ class App extends React.Component<AppProps, AppState> {
this.resetStore();
this.resetHistory();
this.syncActionResult({
...scene,
elements: restoredElements,
appState: restoredAppState,
files: initialData?.files,
captureUpdate: CaptureUpdateAction.NEVER,
});
@@ -2772,7 +2878,7 @@ class App extends React.Component<AppProps, AppState> {
private getFormFactor = (editorWidth: number, editorHeight: number) => {
return (
this.props.UIOptions.formFactor ??
this.props.UIOptions.getFormFactor?.(editorWidth, editorHeight) ??
getFormFactor(editorWidth, editorHeight)
);
};
@@ -2796,10 +2902,7 @@ class App extends React.Component<AppProps, AppState> {
? this.props.UIOptions.dockedSidebarBreakpoint
: MQ_RIGHT_SIDEBAR_MIN_WIDTH;
const nextEditorInterface = updateObject(this.editorInterface, {
desktopUIMode:
this.props.UIOptions.desktopUIMode ??
storedDesktopUIMode ??
this.editorInterface.desktopUIMode,
desktopUIMode: storedDesktopUIMode ?? this.editorInterface.desktopUIMode,
formFactor: this.getFormFactor(editorWidth, editorHeight),
userAgent: userAgentDescriptor,
canFitSidebar: editorWidth > sidebarBreakpoint,
@@ -3178,6 +3281,7 @@ class App extends React.Component<AppProps, AppState> {
) {
setEraserCursor(this.interactiveCanvas, this.state.theme);
}
// Hide hyperlink popup if shown when element type is not selection
if (
prevState.activeTool.type === "selection" &&
@@ -4728,8 +4832,12 @@ class App extends React.Component<AppProps, AppState> {
}
// Handle Alt key for bind mode
if (event.key === KEYS.ALT && getFeatureFlag("COMPLEX_BINDINGS")) {
this.handleSkipBindMode();
if (event.key === KEYS.ALT) {
if (getFeatureFlag("COMPLEX_BINDINGS")) {
this.handleSkipBindMode();
} else {
maybeHandleArrowPointlikeDrag({ app: this, event });
}
}
if (this.actionManager.handleKeyDown(event)) {
@@ -4745,7 +4853,11 @@ class App extends React.Component<AppProps, AppState> {
this.resetDelayedBindMode();
}
this.setState({ isBindingEnabled: false });
flushSync(() => {
this.setState({ isBindingEnabled: false });
});
maybeHandleArrowPointlikeDrag({ app: this, event });
}
if (isArrowKey(event.key)) {
@@ -5018,6 +5130,11 @@ class App extends React.Component<AppProps, AppState> {
}
isHoldingSpace = false;
}
if (event.key === KEYS.ALT) {
maybeHandleArrowPointlikeDrag({ app: this, event });
}
if (
(event.key === KEYS.ALT && this.state.bindMode === "skip") ||
(!event[KEYS.CTRL_OR_CMD] && !isBindingEnabled(this.state))
@@ -5028,7 +5145,7 @@ class App extends React.Component<AppProps, AppState> {
});
// Restart the timer if we're creating/editing a linear element and hovering over an element
if (this.lastPointerMoveEvent) {
if (this.lastPointerMoveEvent && getFeatureFlag("COMPLEX_BINDINGS")) {
const scenePointer = viewportCoordsToSceneCoords(
{
clientX: this.lastPointerMoveEvent.clientX,
@@ -5049,14 +5166,18 @@ class App extends React.Component<AppProps, AppState> {
this.scene.getNonDeletedElementsMap(),
);
if (isBindingElement(element) && getFeatureFlag("COMPLEX_BINDINGS")) {
if (isBindingElement(element)) {
this.handleDelayedBindModeChange(element, hoveredElement);
}
}
}
}
if (!event[KEYS.CTRL_OR_CMD] && !this.state.isBindingEnabled) {
this.setState({ isBindingEnabled: true });
flushSync(() => {
this.setState({ isBindingEnabled: true });
});
maybeHandleArrowPointlikeDrag({ app: this, event });
}
if (isArrowKey(event.key)) {
bindOrUnbindBindingElements(
@@ -6369,15 +6490,28 @@ class App extends React.Component<AppProps, AppState> {
// and point
const { newElement } = this.state;
if (!newElement && isBindingEnabled(this.state)) {
const globalPoint = pointFrom<GlobalPoint>(
scenePointerX,
scenePointerY,
);
const elementsMap = this.scene.getNonDeletedElementsMap();
const hoveredElement = getHoveredElementForBinding(
pointFrom<GlobalPoint>(scenePointerX, scenePointerY),
globalPoint,
this.scene.getNonDeletedElements(),
this.scene.getNonDeletedElementsMap(),
(el) => maxBindingDistance_simple(this.state.zoom),
elementsMap,
maxBindingDistance_simple(this.state.zoom),
);
if (hoveredElement) {
this.setState({
suggestedBinding: hoveredElement,
suggestedBinding: {
element: hoveredElement,
midPoint: getSnapOutlineMidPoint(
globalPoint,
hoveredElement,
elementsMap,
this.state.zoom,
),
},
});
} else if (this.state.suggestedBinding) {
this.setState({
@@ -6404,7 +6538,7 @@ class App extends React.Component<AppProps, AppState> {
pointFrom<GlobalPoint>(scenePointerX, scenePointerY),
this.scene.getNonDeletedElements(),
this.scene.getNonDeletedElementsMap(),
(el) => maxBindingDistance_simple(this.state.zoom),
maxBindingDistance_simple(this.state.zoom),
);
if (hoveredElement) {
this.actionManager.executeAction(actionFinalize, "ui", {
@@ -6558,26 +6692,33 @@ class App extends React.Component<AppProps, AppState> {
pointFrom<GlobalPoint>(scenePointerX, scenePointerY),
this.scene.getNonDeletedElements(),
this.scene.getNonDeletedElementsMap(),
(el) => maxBindingDistance_simple(this.state.zoom),
maxBindingDistance_simple(this.state.zoom),
);
if (
hit &&
!isPointInElement(
pointFrom<GlobalPoint>(scenePointerX, scenePointerY),
hit,
this.scene.getNonDeletedElementsMap(),
)
) {
const scenePointer = pointFrom<GlobalPoint>(scenePointerX, scenePointerY);
const elementsMap = this.scene.getNonDeletedElementsMap();
if (hit && !isPointInElement(scenePointer, hit, elementsMap)) {
this.setState({
suggestedBinding: hit,
suggestedBinding: {
element: hit,
midPoint: getSnapOutlineMidPoint(
scenePointer,
hit,
elementsMap,
this.state.zoom,
),
},
});
}
}
const hasDeselectedButton = Boolean(event.buttons);
const isPressingAnyButton = Boolean(event.buttons);
const isLaserTool = this.state.activeTool.type === "laser";
if (
hasDeselectedButton ||
(this.state.activeTool.type !== "selection" &&
isPressingAnyButton ||
// checking against laser so that if you mouseover with a laser tool
// over a link/embeddable, we change the cursor
(!isLaserTool &&
this.state.activeTool.type !== "selection" &&
this.state.activeTool.type !== "lasso" &&
this.state.activeTool.type !== "text" &&
this.state.activeTool.type !== "eraser")
@@ -6697,6 +6838,14 @@ class App extends React.Component<AppProps, AppState> {
);
} else {
hideHyperlinkToolip();
if (isLaserTool) {
this.handleIframeLikeElementHover({
hitElement,
scenePointer,
moveEvent: event,
});
return;
}
if (
hitElement &&
(hitElement.link || isEmbeddableElement(hitElement)) &&
@@ -6729,24 +6878,15 @@ class App extends React.Component<AppProps, AppState> {
!hitElement?.locked
) {
if (
hitElement &&
isIframeLikeElement(hitElement) &&
this.isIframeLikeElementCenter(
!this.handleIframeLikeElementHover({
hitElement,
event,
scenePointerX,
scenePointerY,
)
) {
setCursor(this.interactiveCanvas, CURSOR_TYPE.POINTER);
this.setState({
activeEmbeddable: { element: hitElement, state: "hover" },
});
} else if (
!hitElement ||
// Elbow arrows can only be moved when unconnected
!isElbowArrow(hitElement) ||
!(hitElement.startBinding || hitElement.endBinding)
scenePointer,
moveEvent: event,
}) &&
(!hitElement ||
// Elbow arrows can only be moved when unconnected
!isElbowArrow(hitElement) ||
!(hitElement.startBinding || hitElement.endBinding))
) {
if (
this.state.activeTool.type !== "lasso" ||
@@ -6754,9 +6894,6 @@ class App extends React.Component<AppProps, AppState> {
) {
setCursor(this.interactiveCanvas, CURSOR_TYPE.MOVE);
}
if (this.state.activeEmbeddable?.state === "hover") {
this.setState({ activeEmbeddable: null });
}
}
}
} else {
@@ -6917,6 +7054,37 @@ class App extends React.Component<AppProps, AppState> {
},
});
}
// Check for focus point hover
let hoveredFocusPointBinding: "start" | "end" | null = null;
const arrow = element as any;
if (arrow.startBinding || arrow.endBinding) {
hoveredFocusPointBinding = handleFocusPointHover(
element as ExcalidrawArrowElement,
scenePointerX,
scenePointerY,
this.scene,
this.state,
);
}
if (
this.state.selectedLinearElement.hoveredFocusPointBinding !==
hoveredFocusPointBinding
) {
this.setState({
selectedLinearElement: {
...this.state.selectedLinearElement,
isDragging: false,
hoveredFocusPointBinding,
},
});
}
// Set cursor to pointer when hovering over a focus point
if (hoveredFocusPointBinding) {
setCursor(this.interactiveCanvas, CURSOR_TYPE.POINTER);
}
} else {
setCursor(this.interactiveCanvas, CURSOR_TYPE.AUTO);
}
@@ -7377,26 +7545,9 @@ class App extends React.Component<AppProps, AppState> {
x: scenePointerX,
y: scenePointerY,
};
const clicklength =
event.timeStamp - (this.lastPointerDownEvent?.timeStamp ?? 0);
if (this.editorInterface.formFactor === "phone" && clicklength < 300) {
const hitElement = this.getElementAtPosition(
scenePointer.x,
scenePointer.y,
);
if (
isIframeLikeElement(hitElement) &&
this.isIframeLikeElementCenter(
hitElement,
event,
scenePointer.x,
scenePointer.y,
)
) {
this.handleEmbeddableCenterClick(hitElement);
return;
}
if (this.handleIframeLikeCenterClick()) {
return;
}
if (this.editorInterface.isTouchScreen) {
@@ -7417,20 +7568,7 @@ class App extends React.Component<AppProps, AppState> {
this.hitLinkElement &&
!this.state.selectedElementIds[this.hitLinkElement.id]
) {
if (
clicklength < 300 &&
isIframeLikeElement(this.hitLinkElement) &&
!isPointHittingLinkIcon(
this.hitLinkElement,
this.scene.getNonDeletedElementsMap(),
this.state,
pointFrom(scenePointer.x, scenePointer.y),
)
) {
this.handleEmbeddableCenterClick(this.hitLinkElement);
} else {
this.redirectToLink(event, this.editorInterface.isTouchScreen);
}
this.redirectToLink(event, this.editorInterface.isTouchScreen);
} else if (this.state.viewModeEnabled) {
this.setState({
activeEmbeddable: null,
@@ -7838,6 +7976,37 @@ class App extends React.Component<AppProps, AppState> {
if (ret.didAddPoint) {
return true;
}
// Also check at current pointer position if focus point is being hovered
// (in case we're clicking directly without a prior move event)
const elementsMap = this.scene.getNonDeletedElementsMap();
const arrow = LinearElementEditor.getElement(
linearElementEditor.elementId,
elementsMap,
) as any;
if (arrow && isBindingElement(arrow)) {
const { hitFocusPoint, pointerOffset } =
handleFocusPointPointerDown(
arrow,
pointerDownState,
elementsMap,
this.state,
);
// If focus point is hit, update state and prevent element selection
if (hitFocusPoint) {
this.setState({
selectedLinearElement: {
...linearElementEditor,
hoveredFocusPointBinding: hitFocusPoint,
draggedFocusPointBinding: hitFocusPoint,
pointerOffset,
},
});
return false;
}
}
}
const allHitElements = this.getElementsAtPosition(
@@ -8650,6 +8819,7 @@ class App extends React.Component<AppProps, AppState> {
selectedPointsIndices: [endIdx],
initialState: {
...linearElementEditor.initialState,
arrowStartIsInside: event.altKey,
lastClickedPoint: endIdx,
origin: pointFrom<GlobalPoint>(
pointerDownState.origin.x,
@@ -8668,7 +8838,18 @@ class App extends React.Component<AppProps, AppState> {
bindMode: "orbit",
newElement: element,
startBoundElement: boundElement,
suggestedBinding: boundElement || null,
suggestedBinding:
boundElement && isBindingElement(element)
? {
element: boundElement,
midPoint: getSnapOutlineMidPoint(
point,
boundElement,
elementsMap,
this.state.zoom,
),
}
: null,
selectedElementIds: nextSelectedElementIds,
selectedLinearElement: linearElementEditor,
};
@@ -8930,8 +9111,8 @@ class App extends React.Component<AppProps, AppState> {
}
const lastPointerCoords =
this.lastPointerMoveCoords ?? pointerDownState.origin;
this.lastPointerMoveCoords = pointerCoords;
this.previousPointerMoveCoords ?? pointerDownState.origin;
this.previousPointerMoveCoords = pointerCoords;
// We need to initialize dragOffsetXY only after we've updated
// `state.selectedElementIds` on pointerDown. Doing it here in pointerMove
@@ -8985,6 +9166,31 @@ class App extends React.Component<AppProps, AppState> {
if (this.state.selectedLinearElement) {
const linearElementEditor = this.state.selectedLinearElement;
// Handle focus point dragging if needed
if (linearElementEditor.draggedFocusPointBinding) {
handleFocusPointDrag(
linearElementEditor,
elementsMap,
pointerCoords,
this.scene,
this.state,
this.getEffectiveGridSize(),
event.altKey,
);
this.setState({
selectedLinearElement: {
...linearElementEditor,
isDragging: false,
selectedPointsIndices: [],
initialState: {
...linearElementEditor.initialState,
lastClickedPoint: -1,
},
},
});
return;
}
if (
LinearElementEditor.shouldAddMidpoint(
this.state.selectedLinearElement,
@@ -9223,14 +9429,21 @@ class App extends React.Component<AppProps, AppState> {
this.imageCache.get(croppingElement.fileId)?.image;
if (image && !(image instanceof Promise)) {
const instantDragOffset = vectorScale(
vector(
pointerCoords.x - lastPointerCoords.x,
pointerCoords.y - lastPointerCoords.y,
),
Math.max(this.state.zoom.value, 2),
const uncroppedSize =
getUncroppedWidthAndHeight(croppingElement);
const instantDragOffset = vector(
pointerCoords.x - lastPointerCoords.x,
pointerCoords.y - lastPointerCoords.y,
);
// to reduce cursor:image drift, we need to take into account
// the canvas image element scaling so we can accurately
// track the pixels on movement
instantDragOffset[0] *=
image.naturalWidth / uncroppedSize.width;
instantDragOffset[1] *=
image.naturalHeight / uncroppedSize.height;
const [x1, y1, x2, y2, cx, cy] = getElementAbsoluteCoords(
croppingElement,
elementsMap,
@@ -9273,13 +9486,13 @@ class App extends React.Component<AppProps, AppState> {
const nextCrop = {
...crop,
x: clamp(
crop.x +
crop.x -
offsetVector[0] * Math.sign(croppingElement.scale[0]),
0,
image.naturalWidth - crop.width,
),
y: clamp(
crop.y +
crop.y -
offsetVector[1] * Math.sign(croppingElement.scale[1]),
0,
image.naturalHeight - crop.height,
@@ -9772,6 +9985,7 @@ class App extends React.Component<AppProps, AppState> {
// just in case, tool changes mid drag, always clean up
this.lassoTrail.endPath();
this.previousPointerMoveCoords = null;
SnapCache.setReferenceSnapPoints(null);
SnapCache.setVisibleGaps(null);
@@ -9853,12 +10067,14 @@ class App extends React.Component<AppProps, AppState> {
// and sets binding element
if (
this.state.selectedLinearElement?.isEditing &&
!this.state.newElement
!this.state.newElement &&
this.state.selectedLinearElement.draggedFocusPointBinding === null
) {
if (
!pointerDownState.boxSelection.hasOccurred &&
pointerDownState.hit?.element?.id !==
this.state.selectedLinearElement.elementId
this.state.selectedLinearElement.elementId &&
this.state.selectedLinearElement.draggedFocusPointBinding === null
) {
this.actionManager.executeAction(actionFinalize);
} else {
@@ -9894,7 +10110,18 @@ class App extends React.Component<AppProps, AppState> {
}
}
if (
if (this.state.selectedLinearElement.draggedFocusPointBinding) {
handleFocusPointPointerUp(
this.state.selectedLinearElement,
this.scene,
);
this.setState({
selectedLinearElement: {
...this.state.selectedLinearElement,
draggedFocusPointBinding: null,
},
});
} else if (
pointerDownState.hit?.element?.id !==
this.state.selectedLinearElement.elementId
) {
@@ -9904,6 +10131,12 @@ class App extends React.Component<AppProps, AppState> {
this.setState({ selectedLinearElement: null });
}
} else if (this.state.selectedLinearElement.isDragging) {
this.setState({
selectedLinearElement: {
...this.state.selectedLinearElement,
isDragging: false,
},
});
this.actionManager.executeAction(actionFinalize, "ui", {
event: childEvent,
sceneCoords,
@@ -10046,7 +10279,7 @@ class App extends React.Component<AppProps, AppState> {
});
}
} else if (pointerDownState.drag.hasOccurred && !multiElement) {
if (isBindingElement(newElement, false)) {
if (isLinearElement(newElement)) {
this.actionManager.executeAction(actionFinalize, "ui", {
event: childEvent,
sceneCoords,
@@ -10678,25 +10911,6 @@ class App extends React.Component<AppProps, AppState> {
suggestedBinding: null,
});
}
if (
hitElement &&
this.lastPointerUpEvent &&
this.lastPointerDownEvent &&
this.lastPointerUpEvent.timeStamp -
this.lastPointerDownEvent.timeStamp <
300 &&
gesture.pointers.size <= 1 &&
isIframeLikeElement(hitElement) &&
this.isIframeLikeElementCenter(
hitElement,
this.lastPointerUpEvent,
pointerDownState.origin.x,
pointerDownState.origin.y,
)
) {
this.handleEmbeddableCenterClick(hitElement);
}
});
}
+1 -1
View File
@@ -1,4 +1,4 @@
@import "../css/variables.module.scss";
@use "../css/variables.module" as *;
.excalidraw {
.Avatar {
+1 -1
View File
@@ -1,4 +1,4 @@
@import "../css/theme";
@use "../css/theme" as *;
.excalidraw {
.excalidraw-button {
@@ -1,4 +1,4 @@
@import "../css/theme";
@use "../css/theme" as *;
.excalidraw {
button.standalone {
+3 -3
View File
@@ -1,4 +1,4 @@
@import "../css/variables.module.scss";
@use "../css/variables.module.scss" as *;
.excalidraw {
.Card {
@@ -19,7 +19,7 @@
padding: 1.4rem;
border-radius: 50%;
background: var(--card-color);
color: $oc-white;
color: #fff;
svg {
width: 2.8rem;
@@ -46,7 +46,7 @@
background-color: var(--card-color-darkest);
}
.ToolIcon__label {
color: $oc-white;
color: #fff;
}
.Spinner {
+23 -13
View File
@@ -1,25 +1,35 @@
import OpenColor from "open-color";
import "./Card.scss";
// for open-color see https://github.com/yeun/open-color/blob/master/open-color.scss
const COLOR_MAP = {
primary: {
base: "var(--color-primary)",
darker: "var(--color-primary-darker)",
darkest: "var(--color-primary-darkest)",
},
lime: {
base: "#74b816", // open-color lime[7]
darker: "#66a80f", // open-color lime[8]
darkest: "#5c940d", // open-color lime[9]
},
pink: {
base: "#d6336c", // open-color pink[7]
darker: "#c2255c", // open-color pink[8]
darkest: "#a61e4d", // open-color pink[9]
},
};
export const Card: React.FC<{
color: keyof OpenColor | "primary";
color: "primary" | "lime" | "pink";
children?: React.ReactNode;
}> = ({ children, color }) => {
return (
<div
className="Card"
style={{
["--card-color" as any]:
color === "primary" ? "var(--color-primary)" : OpenColor[color][7],
["--card-color-darker" as any]:
color === "primary"
? "var(--color-primary-darker)"
: OpenColor[color][8],
["--card-color-darkest" as any]:
color === "primary"
? "var(--color-primary-darkest)"
: OpenColor[color][9],
["--card-color" as any]: COLOR_MAP[color].base,
["--card-color-darker" as any]: COLOR_MAP[color].darker,
["--card-color-darkest" as any]: COLOR_MAP[color].darkest,
}}
>
{children}
@@ -1,4 +1,5 @@
@import "../css/variables.module.scss";
@use "sass:color";
@use "../css/variables.module" as *;
.excalidraw {
.Checkbox {
@@ -12,7 +13,7 @@
-webkit-tap-highlight-color: transparent;
&:hover:not(.is-checked) .Checkbox-box:not(:focus) {
box-shadow: 0 0 0 2px #{$oc-blue-4};
box-shadow: 0 0 0 2px #{$color-blue-4};
}
&:hover:not(.is-checked) .Checkbox-box:not(:focus) {
@@ -24,25 +25,25 @@
&:active {
.Checkbox-box {
box-shadow: 0 0 2px 1px inset #{$oc-blue-7} !important;
box-shadow: 0 0 2px 1px inset #{$color-blue-7} !important;
}
}
&:hover {
.Checkbox-box {
background-color: fade-out($oc-blue-1, 0.8);
background-color: color.adjust($color-blue-1, $alpha: -0.8);
}
}
&.is-checked {
.Checkbox-box {
background-color: #{$oc-blue-1};
background-color: #{$color-blue-1};
svg {
display: block;
}
}
&:hover .Checkbox-box {
background-color: #{$oc-blue-2};
background-color: #{$color-blue-2};
}
}
@@ -58,16 +59,16 @@
align-items: center;
justify-content: center;
box-shadow: 0 0 0 2px #{$oc-blue-7};
box-shadow: 0 0 0 2px #{$color-blue-7};
background-color: transparent;
border-radius: 4px;
color: #{$oc-blue-7};
color: #{$color-blue-7};
border: 0;
&:focus {
box-shadow: 0 0 0 3px #{$oc-blue-7};
box-shadow: 0 0 0 3px #{$color-blue-7};
}
svg {
@@ -1,7 +1,7 @@
import clsx from "clsx";
import { useCallback, useEffect, useRef, useState } from "react";
import { KEYS } from "@excalidraw/common";
import { KEYS, normalizeInputColor } from "@excalidraw/common";
import { getShortcutKey } from "../..//shortcut";
import { useAtom } from "../../editor-jotai";
@@ -10,26 +10,23 @@ import { useEditorInterface } from "../App";
import { activeEyeDropperAtom } from "../EyeDropper";
import { eyeDropperIcon } from "../icons";
import { getColor } from "./ColorPicker";
import { activeColorPickerSectionAtom } from "./colorPickerUtils";
import type { ColorPickerType } from "./colorPickerUtils";
interface ColorInputProps {
color: string;
onChange: (color: string) => void;
label: string;
colorPickerType: ColorPickerType;
placeholder?: string;
}
export const ColorInput = ({
color,
onChange,
label,
colorPickerType,
placeholder,
}: ColorInputProps) => {
}: {
color: string;
onChange: (color: string) => void;
label: string;
colorPickerType: ColorPickerType;
placeholder?: string;
}) => {
const editorInterface = useEditorInterface();
const [innerValue, setInnerValue] = useState(color);
const [activeSection, setActiveColorPickerSection] = useAtom(
@@ -43,7 +40,7 @@ export const ColorInput = ({
const changeColor = useCallback(
(inputValue: string) => {
const value = inputValue.toLowerCase();
const color = getColor(value);
const color = normalizeInputColor(value);
if (color) {
onChange(color);
@@ -1,4 +1,5 @@
@import "../../css/variables.module.scss";
@use "sass:color";
@use "../../css/variables.module" as *;
.excalidraw {
.focus-visible-none {
@@ -185,8 +186,8 @@
.color-picker {
background: var(--popup-bg-color);
border: 0 solid transparentize($oc-white, 0.75);
box-shadow: transparentize($oc-black, 0.75) 0 1px 4px;
border: 0 solid color.adjust(#fff, $alpha: -0.75);
box-shadow: color.adjust(#000, $alpha: -0.75) 0 1px 4px;
border-radius: 4px;
position: absolute;
@@ -243,7 +244,7 @@
}
.color-picker-triangle-shadow {
border-color: transparent transparent transparentize($oc-black, 0.9);
border-color: transparent transparent color.adjust(#000, $alpha: -0.9);
:root[dir="ltr"] & {
left: -14px;
@@ -280,7 +281,7 @@
padding: 0.25rem;
&-title {
color: $oc-gray-6;
color: $color-gray-6;
font-size: 12px;
padding: 0 0.25rem;
}
@@ -319,7 +320,7 @@
.color-picker-transparent {
border-radius: 4px;
box-shadow: transparentize($oc-black, 0.9) 0 0 0 1px inset;
box-shadow: color.adjust(#000, $alpha: -0.9) 0 0 0 1px inset;
position: absolute;
top: 0;
right: 0;
@@ -473,7 +474,7 @@
}
.color-picker-type-elementBackground .color-picker-keybinding {
color: $oc-white;
color: #fff;
}
.color-picker-swatch[aria-label="transparent"] .color-picker-keybinding {
@@ -486,10 +487,10 @@
&.theme--dark {
.color-picker-type-elementBackground .color-picker-keybinding {
color: $oc-black;
color: #000;
}
.color-picker-swatch[aria-label="transparent"] .color-picker-keybinding {
color: $oc-black;
color: #000;
}
}
}
@@ -1,11 +1,11 @@
import * as Popover from "@radix-ui/react-popover";
import { Popover } from "radix-ui";
import clsx from "clsx";
import { useRef, useEffect } from "react";
import {
COLOR_OUTLINE_CONTRAST_THRESHOLD,
COLOR_PALETTE,
isTransparent,
isColorDark,
isWritableElement,
} from "@excalidraw/common";
@@ -30,7 +30,7 @@ import { ColorInput } from "./ColorInput";
import { Picker } from "./Picker";
import PickerHeading from "./PickerHeading";
import { TopPicks } from "./TopPicks";
import { activeColorPickerSectionAtom, isColorDark } from "./colorPickerUtils";
import { activeColorPickerSectionAtom } from "./colorPickerUtils";
import "./ColorPicker.scss";
@@ -38,27 +38,6 @@ import type { ColorPickerType } from "./colorPickerUtils";
import type { AppState } from "../../types";
const isValidColor = (color: string) => {
const style = new Option().style;
style.color = color;
return !!style.color;
};
export const getColor = (color: string): string | null => {
if (isTransparent(color)) {
return color;
}
// testing for `#` first fixes a bug on Electron (more specfically, an
// Obsidian popout window), where a hex color without `#` is (incorrectly)
// considered valid
return isValidColor(`#${color}`)
? `#${color}`
: isValidColor(color)
? color
: null;
};
interface ColorPickerProps {
type: ColorPickerType;
/**
@@ -1,6 +1,5 @@
import React from "react";
import { isColorDark } from "./colorPickerUtils";
import { isColorDark } from "@excalidraw/common";
interface HotkeyLabelProps {
color: string;
@@ -5,10 +5,9 @@ import {
DEFAULT_CANVAS_BACKGROUND_PICKS,
DEFAULT_ELEMENT_BACKGROUND_PICKS,
DEFAULT_ELEMENT_STROKE_PICKS,
isColorDark,
} from "@excalidraw/common";
import { isColorDark } from "./colorPickerUtils";
import type { ColorPickerType } from "./colorPickerUtils";
interface TopPicksProps {
@@ -96,63 +96,6 @@ export type ActiveColorPickerSectionAtomType =
export const activeColorPickerSectionAtom =
atom<ActiveColorPickerSectionAtomType>(null);
const calculateContrast = (r: number, g: number, b: number): number => {
const yiq = (r * 299 + g * 587 + b * 114) / 1000;
return yiq;
};
// YIQ algo, inspiration from https://stackoverflow.com/a/11868398
export const isColorDark = (color: string, threshold = 160): boolean => {
// no color ("") -> assume it default to black
if (!color) {
return true;
}
if (color === "transparent") {
return false;
}
// a string color (white etc) or any other format -> convert to rgb by way
// of creating a DOM node and retrieving the computeStyle
if (!color.startsWith("#")) {
const node = document.createElement("div");
node.style.color = color;
if (node.style.color) {
// making invisible so document doesn't reflow (hopefully).
// display=none works too, but supposedly not in all browsers
node.style.position = "absolute";
node.style.visibility = "hidden";
node.style.width = "0";
node.style.height = "0";
// needs to be in DOM else browser won't compute the style
document.body.appendChild(node);
const computedColor = getComputedStyle(node).color;
document.body.removeChild(node);
// computed style is in rgb() format
const rgb = computedColor
.replace(/^(rgb|rgba)\(/, "")
.replace(/\)$/, "")
.replace(/\s/g, "")
.split(",");
const r = parseInt(rgb[0]);
const g = parseInt(rgb[1]);
const b = parseInt(rgb[2]);
return calculateContrast(r, g, b) < threshold;
}
// invalid color -> assume it default to black
return true;
}
const r = parseInt(color.slice(1, 3), 16);
const g = parseInt(color.slice(3, 5), 16);
const b = parseInt(color.slice(5, 7), 16);
return calculateContrast(r, g, b) < threshold;
};
export type ColorPickerType =
| "canvasBackground"
| "elementBackground"
@@ -1,4 +1,4 @@
@import "../../css/variables.module.scss";
@use "../../css/variables.module" as *;
$verticalBreakpoint: 861px;
@@ -44,7 +44,6 @@ import { getSelectedElements } from "../../scene";
import {
LockedIcon,
UnlockedIcon,
clockIcon,
searchIcon,
boltIcon,
bucketFillIcon,
@@ -52,6 +51,7 @@ import {
mermaidLogoIcon,
brainIconThin,
LibraryIcon,
historyCommandIcon,
} from "../icons";
import { SHAPES } from "../shapes";
@@ -928,7 +928,7 @@ function CommandPaletteInner({
marginLeft: "6px",
}}
>
{clockIcon}
{historyCommandIcon}
</div>
</div>
<CommandItem
@@ -1,4 +1,4 @@
@import "../css/variables.module.scss";
@use "../css/variables.module" as *;
.excalidraw {
.confirm-dialog {
@@ -1,4 +1,5 @@
@import "../css/variables.module.scss";
@use "sass:color";
@use "../css/variables.module" as *;
.excalidraw {
.context-menu-popover {
@@ -8,7 +9,7 @@
.context-menu {
position: relative;
border-radius: 4px;
box-shadow: 0 3px 10px transparentize($oc-black, 0.8);
box-shadow: 0 3px 10px color.adjust(#000, $alpha: -0.8);
padding: 0;
list-style: none;
user-select: none;
@@ -49,7 +50,7 @@
&.dangerous {
.context-menu-item__label {
color: $oc-red-7;
color: $color-red-7;
}
}
@@ -73,7 +74,7 @@
.context-menu-item__label {
color: var(--popup-bg-color);
}
background-color: $oc-red-6;
background-color: $color-red-6;
}
}
@@ -97,6 +98,6 @@
.context-menu-item-separator {
border: none;
border-top: 1px solid $oc-gray-5;
border-top: 1px solid $color-gray-5;
}
}
@@ -1,4 +1,4 @@
@import "../css//variables.module.scss";
@use "../css/variables.module" as *;
.excalidraw {
.ConvertElementTypePopup {
+1 -1
View File
@@ -1,4 +1,4 @@
@import "../css/variables.module.scss";
@use "../css/variables.module" as *;
.excalidraw {
.Dialog {
@@ -1,4 +1,4 @@
@import "../css/variables.module.scss";
@use "../css/variables.module" as *;
.excalidraw {
.ElementLinkDialog {
@@ -59,7 +59,7 @@
}
.ElementLinkDialog__remove {
color: $oc-red-9;
color: $color-red-9;
margin-left: 1rem;
.ToolIcon__icon {
@@ -68,7 +68,7 @@
}
.ToolIcon__icon svg {
color: $oc-red-6;
color: $color-red-6;
}
}
}
@@ -1,4 +1,4 @@
@import "../css/variables.module.scss";
@use "../css/variables.module" as *;
.excalidraw {
.ExportDialog__preview {
@@ -112,7 +112,7 @@
font-family: Cascadia;
font-size: 1.8em;
color: $oc-white;
color: #fff;
&:hover {
background-color: var(--button-color-darker);
@@ -1,4 +1,4 @@
@import "../css/variables.module.scss";
@use "../css/variables.module" as *;
@keyframes successStatusAnimation {
0% {
@@ -24,6 +24,14 @@
background-color: var(--back-color);
border-color: var(--border-color);
border-radius: 0.5rem;
border-width: 1px;
border-style: solid;
font-family: var(--font-family);
user-select: none;
&:hover {
transition: all 150ms ease-out;
}
@@ -45,6 +53,7 @@
&.ExcButton--status-loading,
&.ExcButton--status-success {
pointer-events: none;
background-color: var(--color-success);
.ExcButton__contents {
visibility: hidden;
@@ -52,7 +61,19 @@
}
&[disabled] {
pointer-events: none;
cursor: not-allowed;
&.ExcButton--variant-filled,
&:hover {
--back-color: var(--color-surface-low) !important;
--text-color: var(--color-on-surface-variant) !important;
}
&.ExcButton--variant-outlined,
&.ExcButton--variant-icon {
--text-color: var(--color-on-surface-variant);
--border-color: var(--color-surface-high);
}
}
&,
@@ -266,14 +287,6 @@
}
}
border-radius: 0.5rem;
border-width: 1px;
border-style: solid;
font-family: var(--font-family);
user-select: none;
&--size-large {
font-weight: 600;
font-size: 0.875rem;
@@ -33,6 +33,7 @@ export type FilledButtonProps = {
fullWidth?: boolean;
icon?: React.ReactNode;
disabled?: boolean;
};
export const FilledButton = forwardRef<HTMLButtonElement, FilledButtonProps>(
@@ -48,6 +49,7 @@ export const FilledButton = forwardRef<HTMLButtonElement, FilledButtonProps>(
fullWidth,
className,
status,
disabled,
},
ref,
) => {
@@ -94,7 +96,7 @@ export const FilledButton = forwardRef<HTMLButtonElement, FilledButtonProps>(
type="button"
aria-label={label}
ref={ref}
disabled={_status === "loading" || _status === "success"}
disabled={disabled || _status === "loading" || _status === "success"}
>
<div className="ExcButton__contents">
{_status === "loading" ? (
@@ -1,4 +1,4 @@
@import "../css/variables.module.scss";
@use "../css/variables.module" as *;
.excalidraw {
.FixedSideContainer {
@@ -1,4 +1,4 @@
@import "../../css/variables.module.scss";
@use "../../css/variables.module" as *;
.excalidraw {
.FontPicker__container {
@@ -0,0 +1,31 @@
import { KEYS } from "@excalidraw/common";
import { Excalidraw } from "../..";
import { Keyboard } from "../../tests/helpers/ui";
import { act, render } from "../../tests/test-utils";
describe("FontPicker", () => {
it("should be able to open font picker", async () => {
(global as any).ResizeObserver =
(global as any).ResizeObserver ||
class ResizeObserver {
observe() {}
unobserve() {}
disconnect() {}
};
const { queryByTestId } = await render(
<Excalidraw handleKeyboardGlobally={true} />,
);
Keyboard.keyPress(KEYS.T);
const fontPickerTrigger = queryByTestId("font-family-show-fonts");
expect(fontPickerTrigger).not.toBeNull();
act(() => {
fontPickerTrigger!.click();
});
});
});
@@ -1,4 +1,4 @@
import * as Popover from "@radix-ui/react-popover";
import { Popover } from "radix-ui";
import clsx from "clsx";
import React, { useCallback, useMemo } from "react";
@@ -30,10 +30,12 @@ import { PropertiesPopover } from "../PropertiesPopover";
import { QuickSearch } from "../QuickSearch";
import { ScrollableList } from "../ScrollableList";
import DropdownMenuGroup from "../dropdownMenu/DropdownMenuGroup";
import DropdownMenuItem, {
import {
DropDownMenuItemBadgeType,
DropDownMenuItemBadge,
} from "../dropdownMenu/DropdownMenuItem";
import MenuItemContent from "../dropdownMenu/DropdownMenuItemContent";
import { getDropdownMenuItemClassName } from "../dropdownMenu/common";
import {
FontFamilyCodeIcon,
FontFamilyHeadingIcon,
@@ -269,43 +271,74 @@ export const FontPickerList = React.memo(
[filteredFonts, sceneFamilies],
);
const renderFont = (font: FontDescriptor, index: number) => (
<DropdownMenuItem
key={font.value}
icon={font.icon}
value={font.value}
order={index}
textStyle={{
fontFamily: getFontFamilyString({ fontFamily: font.value }),
}}
hovered={font.value === hoveredFont?.value}
selected={font.value === selectedFontFamily}
// allow to tab between search and selected font
tabIndex={font.value === selectedFontFamily ? 0 : -1}
onClick={(e) => {
wrappedOnSelect(Number(e.currentTarget.value));
}}
onMouseMove={() => {
if (hoveredFont?.value !== font.value) {
onHover(font.value);
}
}}
>
{font.text}
{font.badge && (
<DropDownMenuItemBadge type={font.badge.type}>
{font.badge.placeholder}
</DropDownMenuItemBadge>
)}
</DropdownMenuItem>
);
const FontPickerListItem = ({
font,
order,
}: {
font: FontDescriptor;
order: number;
}) => {
const ref = useRef<HTMLButtonElement>(null);
const isHovered = font.value === hoveredFont?.value;
const isSelected = font.value === selectedFontFamily;
useEffect(() => {
if (!isHovered) {
return;
}
if (order === 0) {
// scroll into the first item differently, so it's visible what is above (i.e. group title)
ref.current?.scrollIntoView?.({ block: "end" });
} else {
ref.current?.scrollIntoView?.({ block: "nearest" });
}
}, [isHovered, order]);
return (
<button
ref={ref}
type="button"
value={font.value}
className={getDropdownMenuItemClassName("", isSelected, isHovered)}
title={font.text}
// allow to tab between search and selected font
tabIndex={isSelected ? 0 : -1}
onClick={(e) => {
wrappedOnSelect(Number(e.currentTarget.value));
}}
onMouseMove={() => {
if (hoveredFont?.value !== font.value) {
onHover(font.value);
}
}}
>
<MenuItemContent
icon={font.icon}
badge={
font.badge && (
<DropDownMenuItemBadge type={font.badge.type}>
{font.badge.placeholder}
</DropDownMenuItemBadge>
)
}
textStyle={{
fontFamily: getFontFamilyString({ fontFamily: font.value }),
}}
>
{font.text}
</MenuItemContent>
</button>
);
};
const groups = [];
if (sceneFilteredFonts.length) {
groups.push(
<DropdownMenuGroup title={t("fontList.sceneFonts")} key="group_1">
{sceneFilteredFonts.map(renderFont)}
{sceneFilteredFonts.map((font, index) => (
<FontPickerListItem key={font.value} font={font} order={index} />
))}
</DropdownMenuGroup>,
);
}
@@ -313,9 +346,13 @@ export const FontPickerList = React.memo(
if (availableFilteredFonts.length) {
groups.push(
<DropdownMenuGroup title={t("fontList.availableFonts")} key="group_2">
{availableFilteredFonts.map((font, index) =>
renderFont(font, index + sceneFilteredFonts.length),
)}
{availableFilteredFonts.map((font, index) => (
<FontPickerListItem
key={font.value}
font={font}
order={index + sceneFilteredFonts.length}
/>
))}
</DropdownMenuGroup>,
);
}
@@ -1,4 +1,4 @@
import * as Popover from "@radix-ui/react-popover";
import { Popover } from "radix-ui";
import { MOBILE_ACTION_BUTTON_BG } from "@excalidraw/common";
@@ -1,4 +1,4 @@
@import "../css/variables.module.scss";
@use "../css/variables.module" as *;
.excalidraw {
.HelpDialog {
@@ -60,11 +60,12 @@
&__islands-container {
display: grid;
grid-column-gap: 1.5rem;
grid-row-gap: 2rem;
@media screen and (min-width: 1024px) {
grid-template-columns: 1fr 1fr;
}
grid-column-gap: 1.5rem;
grid-row-gap: 2rem;
}
@media screen and (min-width: 1024px) {
@@ -1,4 +1,4 @@
@import "../css/variables.module.scss";
@use "../css/variables.module" as *;
// this is loosely based on the longest hint text
$wide-viewport-width: 1000px;
@@ -1,10 +1,11 @@
@import "../css/variables.module.scss";
@use "sass:color";
@use "../css/variables.module" as *;
.excalidraw {
.picker {
padding: 0.5rem;
background: var(--popup-bg-color);
border: 0 solid transparentize($oc-white, 0.75);
border: 0 solid color.adjust(#fff, $alpha: -0.75);
box-shadow: var(--shadow-island);
border-radius: 4px;
position: absolute;
@@ -87,7 +88,7 @@
}
.picker-type-elementBackground .picker-keybinding {
color: $oc-white;
color: #fff;
}
.picker-swatch[aria-label="transparent"] .picker-keybinding {
@@ -100,10 +101,10 @@
&.theme--dark {
.picker-type-elementBackground .picker-keybinding {
color: $oc-black;
color: #000;
}
.picker-swatch[aria-label="transparent"] .picker-keybinding {
color: $oc-black;
color: #000;
}
}
}
@@ -1,4 +1,4 @@
import * as Popover from "@radix-ui/react-popover";
import { Popover } from "radix-ui";
import clsx from "clsx";
import React, { useEffect } from "react";
@@ -1,4 +1,4 @@
@import "../css/variables.module.scss";
@use "../css/variables.module" as *;
.excalidraw {
--ImageExportModal-preview-border: #d6d6d6;
@@ -40,9 +40,6 @@ import type { ActionManager } from "../actions/manager";
import type { AppClassProperties, BinaryFiles, UIAppState } from "../types";
const supportsContextFilters =
"filter" in document.createElement("canvas").getContext("2d")!;
export const ErrorCanvasPreview = () => {
return (
<div>
@@ -230,25 +227,23 @@ const ImageExportModal = ({
}}
/>
</ExportSetting>
{supportsContextFilters && (
<ExportSetting
label={t("imageExportDialog.label.darkMode")}
<ExportSetting
label={t("imageExportDialog.label.darkMode")}
name="exportDarkModeSwitch"
>
<Switch
name="exportDarkModeSwitch"
>
<Switch
name="exportDarkModeSwitch"
checked={exportDarkMode}
onChange={(checked) => {
setExportDarkMode(checked);
actionManager.executeAction(
actionExportWithDarkMode,
"ui",
checked,
);
}}
/>
</ExportSetting>
)}
checked={exportDarkMode}
onChange={(checked) => {
setExportDarkMode(checked);
actionManager.executeAction(
actionExportWithDarkMode,
"ui",
checked,
);
}}
/>
</ExportSetting>
<ExportSetting
label={t("imageExportDialog.label.embedScene")}
tooltip={t("imageExportDialog.tooltip.embedScene")}
+1 -2
View File
@@ -1,5 +1,4 @@
@import "open-color/open-color";
@import "../css/variables.module.scss";
@use "../css/variables.module" as *;
.excalidraw {
.layer-ui__wrapper.animate {
@@ -1,4 +1,4 @@
@import "open-color/open-color";
@use "../css/variables.module" as *;
.excalidraw {
.layer-ui__library {
@@ -46,15 +46,15 @@
}
&-close.ToolIcon_type_button {
background-color: $oc-blue-6;
background-color: $color-blue-6;
align-self: flex-end;
&:hover {
background-color: $oc-blue-8;
background-color: $color-blue-8;
}
.ToolIcon__icon {
width: auto;
font-size: 1rem;
color: $oc-white;
color: #fff;
padding: 0 0.5rem;
}
}
@@ -90,7 +90,7 @@
border-radius: var(--border-radius-lg);
background-color: var(--color-primary);
color: $oc-white;
color: #fff;
text-align: center;
white-space: nowrap;
text-decoration: none !important;
@@ -126,9 +126,10 @@
.dropdown-menu-container {
width: 196px;
box-shadow: var(--library-dropdown-shadow);
border-radius: var(--border-radius-lg);
padding: 0.25rem 0.5rem;
--box-shadow: var(--library-dropdown-shadow);
}
}
@@ -1,4 +1,4 @@
@import "open-color/open-color";
@use "../css/variables.module" as *;
.excalidraw {
--container-padding-y: 1rem;
@@ -1,4 +1,4 @@
@import "../css/variables.module.scss";
@use "../css/variables.module" as *;
.excalidraw {
.library-unit {
@@ -1,5 +1,4 @@
@import "open-color/open-color.scss";
@import "../css/variables.module.scss";
@use "../css/variables.module" as *;
.excalidraw {
.mobile-toolbar {
@@ -375,7 +375,7 @@ export const MobileToolBar = ({
)}
{/* Other Shapes */}
<DropdownMenu open={isOtherShapesMenuOpen} placement="top">
<DropdownMenu open={isOtherShapesMenuOpen}>
<DropdownMenu.Trigger
className={clsx(
"App-toolbar__extra-tools-trigger App-toolbar__extra-tools-trigger--mobile",
@@ -403,6 +403,7 @@ export const MobileToolBar = ({
onClickOutside={() => setIsOtherShapesMenuOpen(false)}
onSelect={() => setIsOtherShapesMenuOpen(false)}
className="App-toolbar__extra-tools-dropdown"
align="start"
>
{!showTextToolOutside && (
<DropdownMenu.Item
+1 -1
View File
@@ -1,4 +1,4 @@
@import "../css/variables.module.scss";
@use "../css/variables.module" as *;
.excalidraw {
&.excalidraw-modal-container {

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